Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 102 additions & 26 deletions src/dashboard/app.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { Box, Text, render, useApp, useInput, useStdout } from 'ink';
import { Box, Text, render, useApp, useInput, useStdout, type Key } from 'ink';

import type { DashboardAppOptions } from '../cli/commands/dashboard.js';
import { createRendererBackend } from '../renderer/registry.js';
Expand All @@ -22,7 +22,13 @@ import {
type DashboardScope,
type DashboardSession,
} from './sessionScope.js';
import { formatSessionId, listWidthFor, shortId } from './sessionListLayout.js';
import {
PANE_BORDER,
PANE_LIST_GAP,
formatSessionId,
paneLayout,
shortId,
} from './sessionListLayout.js';

// reference-dark profile defaults; cells matching these are left unstyled so the
// terminal's own theme shows through instead of repainting every cell.
Expand Down Expand Up @@ -128,13 +134,17 @@ function LiveView({
mode,
pan,
focused,
maximized = false,
}: {
frame: LiveViewFrame | null;
error: string | null;
pane: { cols: number; rows: number };
mode: LiveViewMode;
pan: PanOffset;
focused: boolean;
// When maximized the bordered pane sits flush to the left edge and spans the
// full width (the list is gone); otherwise it has a 1-col gap beside the list.
maximized?: boolean;
}): React.ReactNode {
const snapshot = frame?.snapshot ?? null;
const status = frame?.status ?? 'pending';
Expand Down Expand Up @@ -168,10 +178,10 @@ function LiveView({
return (
<Box
flexDirection="column"
marginLeft={1}
marginLeft={maximized ? 0 : PANE_LIST_GAP}
borderStyle="round"
borderColor={focused ? 'cyan' : 'gray'}
width={pane.cols + 2}
width={pane.cols + PANE_BORDER}
>
<Text dimColor>
{header.length > 0 ? header : 'live view'}
Expand Down Expand Up @@ -467,6 +477,12 @@ function App({ options }: { options: DashboardAppOptions }): React.ReactNode {
const [focus, setFocus] = useState<Focus>('list');
const [mode, setMode] = useState<LiveViewMode>('one-to-one');
const [pan, setPan] = useState<PanOffset>({ row: 0, col: 0 });
// When true the Live View is "maximized": it takes over the whole dashboard
// body (the Session list is dropped) and spans the full terminal width while
// staying framed by its border, so the viewport is large enough that panning
// is rarely needed. It is a modal layer orthogonal to `focus` — it never
// mutates `focus`, so Esc restores whatever was focused underneath.
const [maximized, setMaximized] = useState(false);
const [error, setError] = useState<string | null>(null);

// The Home the dashboard currently observes. Initialized to the launched/
Expand Down Expand Up @@ -584,11 +600,15 @@ function App({ options }: { options: DashboardAppOptions }): React.ReactNode {

const termCols = stdout.columns;
const termRows = stdout.rows;
// The list scales with the terminal (wide screens show full session ids); the
// Live View takes the rest, less a small gap for the divider.
const listWidth = listWidthFor(termCols);
const paneCols = Math.max(10, termCols - listWidth - 5);
const paneRows = Math.max(4, termRows - 5);
// Pane geometry (see `paneLayout`): split shares the width with the Session
// list; maximized drops the list to span the full width while keeping the same
// right edge. `paneCols` is the *effective* content width for the current
// mode, so clampPan, the projection, and the rendered pane all agree.
const { listWidth, paneCols, paneRows } = paneLayout(
termCols,
termRows,
maximized,
);

// Clamp a candidate pan to the current screen so stored pan never drifts past
// the edges (which would otherwise make pan keys feel dead after overshooting).
Expand Down Expand Up @@ -624,11 +644,50 @@ function App({ options }: { options: DashboardAppOptions }): React.ReactNode {
}
};

// Toggle the lossy Overview projection. Pan is meaningless in Overview, so
// reset it (matching every other view transition).
const toggleOverview = (): void => {
setMode((current) =>
current === 'one-to-one' ? 'overview' : 'one-to-one',
);
setPan({ row: 0, col: 0 });
};

// Arrow / hjkl panning of the clipped one-to-one screen, clamped to the
// current pane. Shared by the focused split pane and the maximized layer.
const handlePanKeys = (input: string, key: Key): void => {
if (key.upArrow || input === 'k')
setPan((p) => clampPan({ row: p.row - PAN_STEP, col: p.col }));
if (key.downArrow || input === 'j')
setPan((p) => clampPan({ row: p.row + PAN_STEP, col: p.col }));
if (key.leftArrow || input === 'h')
setPan((p) => clampPan({ row: p.row, col: p.col - PAN_STEP }));
if (key.rightArrow || input === 'l')
setPan((p) => clampPan({ row: p.row, col: p.col + PAN_STEP }));
};

useInput((input, key) => {
if (input === 'q' || (key.ctrl && input === 'c')) {
exit();
return;
}
// While maximized this layer owns input: only pan / overview / restore
// respond. Tab, H, 'a' and Enter are swallowed so it reads as a distinct
// mode. `focus` is left untouched, so Esc (restore) returns to whatever was
// focused underneath.
if (maximized) {
if (key.escape) {
setMaximized(false);
setPan({ row: 0, col: 0 });
return;
}
if (input === 'z') {
toggleOverview();
return;
}
handlePanKeys(input, key);
return;
}
// 'H' toggles the Home picker from anywhere; it is additive — closing it
// leaves the current Home and Session selection untouched.
if (input === 'H') {
Expand Down Expand Up @@ -678,10 +737,19 @@ function App({ options }: { options: DashboardAppOptions }): React.ReactNode {
return;
}
if (input === 'z') {
setMode((current) =>
current === 'one-to-one' ? 'overview' : 'one-to-one',
);
setPan({ row: 0, col: 0 });
toggleOverview();
return;
}

// Enter maximizes the Live View — from the list (jump straight from
// browsing to a full-bleed view of the selected session) or from the pane.
// It is a modal layer: `focus` is left as-is, so Esc restores it. Reset pan
// like every other geometry change, and no-op when nothing is selected.
if (key.return) {
if (selectedSession !== undefined) {
setMaximized(true);
setPan({ row: 0, col: 0 });
}
return;
}

Expand All @@ -692,14 +760,7 @@ function App({ options }: { options: DashboardAppOptions }): React.ReactNode {
}

// focus === 'live': pan the clipped screen (no-op in overview).
if (key.upArrow || input === 'k')
setPan((p) => clampPan({ row: p.row - PAN_STEP, col: p.col }));
if (key.downArrow || input === 'j')
setPan((p) => clampPan({ row: p.row + PAN_STEP, col: p.col }));
if (key.leftArrow || input === 'h')
setPan((p) => clampPan({ row: p.row, col: p.col - PAN_STEP }));
if (key.rightArrow || input === 'l')
setPan((p) => clampPan({ row: p.row, col: p.col + PAN_STEP }));
handlePanKeys(input, key);
});

return (
Expand Down Expand Up @@ -735,6 +796,19 @@ function App({ options }: { options: DashboardAppOptions }): React.ReactNode {
height={paneRows}
width={termCols}
/>
) : maximized ? (
// Maximized: the Live View takes the whole body (no list), full width
// but still bordered. The header and footer bars stay. It is the sole
// active view, so it always renders focused.
<LiveView
frame={frame}
error={liveError}
pane={{ cols: paneCols, rows: paneRows }}
mode={mode}
pan={pan}
focused
maximized
/>
) : (
<>
<SessionList
Expand All @@ -759,11 +833,13 @@ function App({ options }: { options: DashboardAppOptions }): React.ReactNode {

<Box>
<Text dimColor>
{focus === 'home'
? '↑/↓ j/k select Home · ⏎ open · a scope · esc cancel · q quit'
: focus === 'list'
? 'focus:list · Tab switch · ↑/↓ j/k select · a scope · H homes · z overview · q quit'
: 'focus:live · Tab switch · ↑/↓ h/j/k/l pan · a scope · H homes · z overview · q quit'}
{maximized
? 'maximized · ↑/↓ h/j/k/l pan · z overview · esc restore · q quit'
: focus === 'home'
? '↑/↓ j/k select Home · ⏎ open · a scope · esc cancel · q quit'
: focus === 'list'
? 'focus:list · Tab switch · ↑/↓ j/k select · ⏎ maximize · a scope · H homes · z overview · q quit'
: 'focus:live · Tab switch · ↑/↓ h/j/k/l pan · ⏎ maximize · a scope · H homes · z overview · q quit'}
{error !== null ? ` · ERR: ${error}` : ''}
</Text>
</Box>
Expand Down
51 changes: 50 additions & 1 deletion src/dashboard/sessionListLayout.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/**
* Layout math for the Session Dashboard's session-list pane.
* Layout math for the Session Dashboard's panes — the session-list pane and the
* Live View pane beside (or, when maximized, instead of) it.
*
* The list scales with the terminal so that wide screens can show the full
* 26-char session id (a ULID), while narrow screens fall back to a compact
Expand All @@ -17,6 +18,54 @@ export function listWidthFor(termCols: number): number {
return Math.max(MIN_LIST_WIDTH, Math.min(MAX_LIST_WIDTH, proportional));
}

// Columns the Live View pane reserves around its own content. Both layouts keep
// the pane's 2-col border (PANE_BORDER) and 2 cols of right-edge slack
// (PANE_RIGHT_SLACK); the split layout additionally spends the list width plus a
// 1-col gap (PANE_LIST_GAP) before the pane. Keeping the border and slack equal
// in both layouts makes the pane's right edge land on the same column whether or
// not it is maximized, so it never jumps when toggling.
export const PANE_BORDER = 2;
export const PANE_RIGHT_SLACK = 2;
export const PANE_LIST_GAP = 1;
const MIN_PANE_COLS = 10;
const MIN_PANE_ROWS = 4;
// Rows the dashboard chrome takes from the pane's content height: the header (1)
// and footer (1) bars, plus the pane box's top/bottom border (2) and its
// one-line status header (1).
const PANE_VERTICAL_CHROME = 5;

/** Content width + height of the Live View pane, with the list width beside it. */
export interface PaneLayout {
/** Width of the session-list pane (rendered only in the split layout). */
listWidth: number;
/** Content width (columns) of the Live View pane. */
paneCols: number;
/** Content height (rows) of the Live View pane. */
paneRows: number;
}

/**
* Live View pane geometry. The split layout shares the terminal width with the
* session list; the maximized layout drops the list (and its gap) to span the
* full width, keeping the same border and right slack so the pane's right edge
* stays put across the toggle. Height is identical in both layouts.
*/
export function paneLayout(
termCols: number,
termRows: number,
maximized: boolean,
): PaneLayout {
const listWidth = listWidthFor(termCols);
const reservedCols = maximized
? PANE_BORDER + PANE_RIGHT_SLACK
: listWidth + PANE_LIST_GAP + PANE_BORDER + PANE_RIGHT_SLACK;
return {
listWidth,
paneCols: Math.max(MIN_PANE_COLS, termCols - reservedCols),
paneRows: Math.max(MIN_PANE_ROWS, termRows - PANE_VERTICAL_CHROME),
};
}

/** Compact `…last9` id used when the full id will not fit the list. */
export function shortId(sessionId: string): string {
return sessionId.length > 10 ? `…${sessionId.slice(-9)}` : sessionId;
Expand Down
42 changes: 42 additions & 0 deletions test/unit/dashboard/sessionListLayout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@ import { describe, expect, it } from 'vitest';
import {
MAX_LIST_WIDTH,
MIN_LIST_WIDTH,
PANE_BORDER,
PANE_LIST_GAP,
PANE_RIGHT_SLACK,
formatSessionId,
listWidthFor,
paneLayout,
shortId,
} from '../../../src/dashboard/sessionListLayout.js';

Expand Down Expand Up @@ -41,3 +45,41 @@ describe('session list layout', () => {
expect(shortId('bash')).toBe('bash');
});
});

describe('live view pane layout', () => {
it('maximized spans the full width; split shares it with the list + gap', () => {
const split = paneLayout(120, 40, false);
const max = paneLayout(120, 40, true);
expect(split.listWidth).toBe(listWidthFor(120));
expect(split.paneCols).toBe(
120 - split.listWidth - PANE_LIST_GAP - PANE_BORDER - PANE_RIGHT_SLACK,
);
expect(max.paneCols).toBe(120 - PANE_BORDER - PANE_RIGHT_SLACK);
expect(max.paneCols).toBeGreaterThan(split.paneCols);
});

it("keeps the pane's right edge on the same column across the maximize toggle", () => {
// Right edge = left offset + border-left + content. Split sits after the
// list and the gap; maximized sits flush at column 0. The shared border and
// right slack must make both land on the same column (termCols − slack).
for (const cols of [80, 120, 200]) {
const split = paneLayout(cols, 40, false);
const max = paneLayout(cols, 40, true);
const splitRight =
split.listWidth + PANE_LIST_GAP + PANE_BORDER + split.paneCols;
const maxRight = PANE_BORDER + max.paneCols;
expect(maxRight).toBe(splitRight);
expect(maxRight).toBe(cols - PANE_RIGHT_SLACK);
}
});

it('height is identical in both layouts and floored on tiny terminals', () => {
expect(paneLayout(120, 40, false).paneRows).toBe(
paneLayout(120, 40, true).paneRows,
);
expect(paneLayout(120, 40, false).paneRows).toBe(35); // 40 − 5 chrome rows
// Floors keep the pane usable even when the terminal is smaller than chrome.
expect(paneLayout(20, 6, false).paneRows).toBeGreaterThanOrEqual(4);
expect(paneLayout(20, 6, true).paneCols).toBeGreaterThanOrEqual(10);
});
});
Loading