diff --git a/src/dashboard/app.tsx b/src/dashboard/app.tsx index 9de1a926..49aaf4d3 100644 --- a/src/dashboard/app.tsx +++ b/src/dashboard/app.tsx @@ -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'; @@ -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. @@ -128,6 +134,7 @@ function LiveView({ mode, pan, focused, + maximized = false, }: { frame: LiveViewFrame | null; error: string | null; @@ -135,6 +142,9 @@ function LiveView({ 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'; @@ -168,10 +178,10 @@ function LiveView({ return ( {header.length > 0 ? header : 'live view'} @@ -467,6 +477,12 @@ function App({ options }: { options: DashboardAppOptions }): React.ReactNode { const [focus, setFocus] = useState('list'); const [mode, setMode] = useState('one-to-one'); const [pan, setPan] = useState({ 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(null); // The Home the dashboard currently observes. Initialized to the launched/ @@ -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). @@ -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') { @@ -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; } @@ -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 ( @@ -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. + ) : ( <> - {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}` : ''} diff --git a/src/dashboard/sessionListLayout.ts b/src/dashboard/sessionListLayout.ts index 380f4cb8..b087a1c2 100644 --- a/src/dashboard/sessionListLayout.ts +++ b/src/dashboard/sessionListLayout.ts @@ -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 @@ -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; diff --git a/test/unit/dashboard/sessionListLayout.test.ts b/test/unit/dashboard/sessionListLayout.test.ts index 6324b453..a8ae968d 100644 --- a/test/unit/dashboard/sessionListLayout.test.ts +++ b/test/unit/dashboard/sessionListLayout.test.ts @@ -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'; @@ -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); + }); +});