diff --git a/src/tui/tui-app.ts b/src/tui/tui-app.ts index 299852ec..77ade6da 100644 --- a/src/tui/tui-app.ts +++ b/src/tui/tui-app.ts @@ -274,12 +274,13 @@ export const DEFAULT_DETACH_KEY = 'd'; * The instruction itself was the problem ("release Ctrl and THEN d" is, in the * tester's words, very unclear), so the way out stopped being a chord. * - * F12 because it is a single keystroke with no modifier to hold or release, and - * because no CLI that runs in these panes wants it: claude, codex, a shell and - * vim all leave it alone, and tmux ships an EMPTY root table apart from mouse - * bindings, so claiming it shadows nothing. + * F1 because it is a single keystroke with no modifier to hold or release, it + * sits next to Esc where a hand reaching to back out already goes, and no CLI + * that runs in these panes wants it: claude, codex, a shell and vim all leave + * it alone, and tmux ships an EMPTY root table apart from mouse bindings, so + * claiming it shadows nothing. */ -export const ONE_KEY_DETACH = 'F12'; +export const ONE_KEY_DETACH = 'F1'; /** * The key a user produces when they DON'T let go of Ctrl: `d` becomes `C-d`. @@ -345,6 +346,8 @@ export function buildAttachBanner(options: { heldAlias?: string; /** The prefix-less key, when the attach managed to claim it. Preferred over every chord. */ oneKey?: string; + /** The other sessions, drawn as a strip so they stay visible from inside a pane. */ + tabs?: readonly TuiAttachTab[]; }): Record { const chord = escapeTmuxFormat(detachChord(options.prefix, options.detachKey)); // Named on the bar because it is what people actually type: keeping Ctrl held @@ -356,22 +359,71 @@ export function buildAttachBanner(options: { // owns the whole line, which is what removes tmux's window list (`0:bash*`) // from the middle of it. The window-status options that would otherwise hide // it are WINDOW options, so `set-option -t ` cannot even reach them. - const right = label ? `#[align=right] ${label} ` : ''; + // The strip names the session it highlights, so the standalone label is only + // a fallback for when there is no strip to draw (degraded mode has no list). + const strip = buildAttachTabs(options.tabs ?? []); + const left = strip || (label ? ` ${label} ` : ''); return { status: 'on', 'status-style': 'bg=default,fg=default', + // At the TOP, where the web UI keeps its tabs and where a strip of sessions + // is read as a strip of sessions rather than as a footer. + 'status-position': 'top', // One key when we have one, the chord only as a fallback. The bar is the // ONLY instruction a user gets during an attach, so it names the simplest // thing that is known to work, never a menu of ways. 'status-format[0]': options.oneKey - ? `#[align=left] press #[bold]${escapeTmuxFormat(options.oneKey)}#[nobold] to get ${ATTACH_BANNER_MARKER}${right}#[default]` - : `#[align=left] press #[bold]${chord}#[nobold]${alias} to detach, ${ATTACH_BANNER_MARKER}${right}#[default]`, + ? `#[align=left]${left}#[align=right] #[bold]${escapeTmuxFormat(options.oneKey)}#[nobold] ${ATTACH_BANNER_MARKER} #[default]` + : `#[align=left]${left}#[align=right] #[bold]${chord}#[nobold]${alias} ${ATTACH_BANNER_MARKER} #[default]`, }; } /** Long enough for a session name, short enough to survive a narrow terminal. */ const ATTACH_BANNER_LABEL_MAX = 28; +/** One session as the attach bar draws it. */ +export interface TuiAttachTab { + /** The number that selects it on the dashboard, so the bar and the list agree. */ + index: number; + label: string; + active: boolean; +} + +/** Per-tab label cap. Eight of these plus separators still fit an 80-column terminal. */ +const ATTACH_TAB_LABEL_MAX = 12; + +/** + * The session strip the attach bar carries, so the other sessions stay visible + * from inside a pane instead of the dashboard vanishing the moment you enter + * one. + * + * A WINDOW around the active tab rather than the whole list: the bar is one + * line shared with the way-out hint, and a strip that overflowed would push + * that hint off the end, which is the one thing on the bar that must never be + * lost. Ellipses mark what is not shown, so a truncated strip reads as + * truncated rather than as the whole list. + */ +export function buildAttachTabs(tabs: readonly TuiAttachTab[], maxTabs = 6): string { + if (tabs.length === 0) return ''; + const active = Math.max( + 0, + tabs.findIndex((tab) => tab.active) + ); + let start = Math.max(0, Math.min(active - Math.floor(maxTabs / 2), tabs.length - maxTabs)); + if (start < 0) start = 0; + const shown = tabs.slice(start, start + maxTabs); + const parts = shown.map((tab) => { + const label = truncateLabel(tab.label, ATTACH_TAB_LABEL_MAX); + const text = escapeTmuxFormat(`${tab.index} ${label}`); + // The active tab is inverted rather than bracketed: brackets cost two + // columns per tab and read as punctuation next to the session names. + return tab.active ? `#[reverse] ${text} #[noreverse]` : ` ${text} `; + }); + const head = start > 0 ? '…' : ''; + const tail = start + maxTabs < tabs.length ? '…' : ''; + return `${head}${parts.join('')}${tail}`; +} + /** * The name a newly started session gets: `w-`, the same convention the * web UI uses, with `n` one past the highest already in use. @@ -448,7 +500,12 @@ export interface TuiAttachHandoff { * `window-size manual` with no status bar (what Codeman creates) is exactly * that again after the detach. */ -export async function beginAttachHandoff(client: TuiClient, muxName: string, label: string): Promise { +export async function beginAttachHandoff( + client: TuiClient, + muxName: string, + label: string, + tabs: readonly TuiAttachTab[] = [] +): Promise { const prefix = (await client.readPrefixKey(muxName)) ?? undefined; // Read, not assumed: see detachChord() for the `d` vs `D` mix-up this closes. const detachKey = (await client.readDetachKey()) ?? undefined; @@ -475,6 +532,7 @@ export async function beginAttachHandoff(client: TuiClient, muxName: string, lab ...(claimed && alias ? { heldAlias: alias } : {}), ...(oneKey ? { oneKey: ONE_KEY_DETACH } : {}), label, + tabs, }); const options = await client.readSessionOptions(muxName, Object.keys(banner)); await client.applySessionOptions(muxName, banner); @@ -1459,6 +1517,27 @@ class TuiApp { this.handle({ type: 'char', value }); } + /** + * The live sessions, numbered the way the dashboard numbers them, for the + * strip the attach bar draws. + * + * A SNAPSHOT taken at attach time and not refreshed: the TUI is blocked in + * `spawnSync` for the whole attach, so there is no loop to update it from, + * and tmux's own format language cannot map a `codeman-` session name + * back to the label a human recognises. A strip that is a few minutes stale + * about a session created elsewhere is worth far more than no strip. + */ + private attachTabs(activeId: string): TuiAttachTab[] { + const tabs: TuiAttachTab[] = []; + let index = 0; + for (const row of this.model.rows()) { + if (row.group === 'recent') continue; + index += 1; + tabs.push({ index, label: rowLabel(row.session), active: row.session.sessionId === activeId }); + } + return tabs; + } + /** Every key can change the selection or the mode, and both steer the preview. */ private afterInput(): void { if (this.exiting) return; @@ -2089,7 +2168,12 @@ class TuiApp { // The way OUT, set up before tmux takes the terminal: a status bar that // stays for the whole attach. The line written below is on a screen tmux // repaints a moment later, so it is not what the user reads. - const handoff = await beginAttachHandoff(this.client, muxName, rowLabel(row.session)); + const handoff = await beginAttachHandoff( + this.client, + muxName, + rowLabel(row.session), + this.attachTabs(row.session.sessionId) + ); this.detachChordLabel = handoff.chord; this.screen.leave(); this.stdout.write(`${handoff.chord} detaches and brings you back here.\n`); diff --git a/test/tui/tui-app.test.ts b/test/tui/tui-app.test.ts index b1a35a05..9c1e794c 100644 --- a/test/tui/tui-app.test.ts +++ b/test/tui/tui-app.test.ts @@ -19,6 +19,7 @@ import { detachChord, heldCtrlAlias, ONE_KEY_DETACH, + buildAttachTabs, nextSessionName, footerKeysFor, formatPrefixKey, @@ -460,16 +461,17 @@ describe('the dead-row resume offer', () => { describe('the one-key way out', () => { it('names a single key with no modifier at all', () => { // The whole point: three beta rounds died on a chord that had to be typed - // in the right order with the modifier released at the right moment. - expect(ONE_KEY_DETACH).toBe('F12'); + // in the right order with the modifier released at the right moment. F1 + // rather than F12 so it sits beside Esc, where a hand backing out goes. + expect(ONE_KEY_DETACH).toBe('F1'); expect(ONE_KEY_DETACH).not.toContain('C-'); expect(ONE_KEY_DETACH).not.toContain('+'); }); it('puts ONE instruction on the bar, not a menu of ways out', () => { - const banner = buildAttachBanner({ prefix: 'C-b', detachKey: 'd', heldAlias: 'C-d', oneKey: 'F12' }); + const banner = buildAttachBanner({ prefix: 'C-b', detachKey: 'd', heldAlias: 'C-d', oneKey: 'F1' }); const bar = banner['status-format[0]']; - expect(bar).toContain('press #[bold]F12#[nobold] to get back to the codeman dashboard'); + expect(bar).toContain('#[bold]F1#[nobold] back to the codeman dashboard'); // Even though both fallbacks still work, the bar must not offer them: a bar // listing three ways to leave is what the tester called way too complicated. expect(bar).not.toContain('Ctrl+B'); @@ -486,6 +488,58 @@ describe('the one-key way out', () => { }); }); +describe('the attach tab strip', () => { + const tabs = (count: number, activeIndex: number) => + Array.from({ length: count }, (_, i) => ({ index: i + 1, label: `w${i + 1}-case`, active: i === activeIndex })); + + it('draws every session when they all fit, numbered as the dashboard numbers them', () => { + const strip = buildAttachTabs(tabs(3, 1)); + expect(strip).toContain('1 w1-case'); + expect(strip).toContain('2 w2-case'); + expect(strip).toContain('3 w3-case'); + expect(strip).not.toContain('…'); + }); + + it('inverts the session you are actually in', () => { + const strip = buildAttachTabs(tabs(3, 1)); + expect(strip).toContain('#[reverse] 2 w2-case #[noreverse]'); + expect(strip).not.toContain('#[reverse] 1 w1-case'); + }); + + it('windows around the active tab rather than overflowing the bar', () => { + // Overflow would push the way-out hint off the end, which is the one thing + // on the bar that must survive. + const strip = buildAttachTabs(tabs(20, 9), 6); + expect(strip).toContain('10 w10-case'); + expect(strip.startsWith('…')).toBe(true); + expect(strip.endsWith('…')).toBe(true); + expect(strip).not.toContain('1 w1-case '); + }); + + it('marks only the end that is actually cut', () => { + const first = buildAttachTabs(tabs(20, 0), 6); + expect(first.startsWith('…')).toBe(false); + expect(first.endsWith('…')).toBe(true); + const last = buildAttachTabs(tabs(20, 19), 6); + expect(last.startsWith('…')).toBe(true); + expect(last.endsWith('…')).toBe(false); + }); + + it('truncates a long session name instead of eating the whole strip', () => { + const strip = buildAttachTabs([{ index: 1, label: 'w1-an-extremely-long-session-name', active: true }]); + expect(strip).toContain('…'); + expect(strip.length).toBeLessThan(60); + }); + + it('is empty with no sessions, so the bar falls back to the plain label', () => { + expect(buildAttachTabs([])).toBe(''); + }); + + it('escapes a name that would otherwise open a tmux format', () => { + expect(buildAttachTabs([{ index: 1, label: 'fix #42', active: false }])).toContain('fix ##42'); + }); +}); + describe('the held-Ctrl detach alias', () => { it('names the key a user produces when they never let go of Ctrl', () => { // The failure this exists for: "Ctrl+B then d" typed as one held chord @@ -569,10 +623,13 @@ describe('the way out of an attach', () => { it('builds ONE status-format option, so tmux draws no window list beside it', () => { const banner = buildAttachBanner({ prefix: 'C-b', label: 'w3-codeman' }); - expect(Object.keys(banner).sort()).toEqual(['status', 'status-format[0]', 'status-style']); + expect(Object.keys(banner).sort()).toEqual(['status', 'status-format[0]', 'status-position', 'status-style']); expect(banner.status).toBe('on'); + // Top, where the web UI keeps its tabs. + expect(banner['status-position']).toBe('top'); expect(banner['status-format[0]']).toContain('#[bold]Ctrl+B then d#[nobold]'); - expect(banner['status-format[0]']).toContain('#[align=right] w3-codeman '); + // With no strip to draw, the session's own name is the fallback. + expect(banner['status-format[0]']).toContain('w3-codeman'); }); it('sets status-style, or tmux paints its stock green bar under the bar', () => { @@ -595,15 +652,25 @@ describe('the way out of an attach', () => { it('truncates a long label instead of pushing the instruction off the bar', () => { const banner = buildAttachBanner({ label: 'w12-codeman: a very long session label indeed' }); - const right = (banner['status-format[0]'].split('#[align=right]')[1] ?? '').replace('#[default]', ''); + const left = (banner['status-format[0]'].split('#[align=right]')[0] ?? '').replace('#[align=left]', ''); // 28 characters of label plus the space either side. - expect(right.length).toBeLessThanOrEqual(30); - expect(right).toContain('…'); - expect(banner['status-format[0]']).toContain('detach, back to the codeman dashboard'); + expect(left.length).toBeLessThanOrEqual(30); + expect(left).toContain('…'); + expect(banner['status-format[0]']).toContain('back to the codeman dashboard'); }); - it('leaves the right side out entirely when there is no label', () => { - expect(buildAttachBanner({})['status-format[0]']).not.toContain('#[align=right]'); + it('always keeps the way out on the bar, whatever else is on it', () => { + // The hint is the one thing that must never be crowded off: it is the only + // instruction a user gets while tmux owns the terminal. + const crowded = buildAttachBanner({ + oneKey: 'F1', + tabs: Array.from({ length: 20 }, (_, i) => ({ + index: i + 1, + label: `w${i + 1}-a-long-session-name`, + active: i === 9, + })), + }); + expect(crowded['status-format[0]']).toContain('#[bold]F1#[nobold] back to the codeman dashboard'); }); it("tells the help overlay how to get back, in the socket's own prefix", () => {