mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat(tui): keep the session tabs visible inside a pane, and move the way out to F1
Attaching made every other session disappear: the dashboard is gone, tmux owns
the terminal, and there is nothing left saying what else is running. The attach
bar now carries the session strip, numbered exactly as the dashboard numbers
them, with the session you are in inverted, and it sits at the TOP of the pane
where the web UI keeps its tabs.
The strip is a WINDOW around the active tab, not the whole list, with ellipses
marking each end that is actually cut. The bar is one line shared with the way
out, and that hint is the only instruction a user gets while tmux has the
terminal, so it must never be crowded off; a test drives 20 long-named sessions
through the bar and asserts it survives.
⚠️ The strip is a snapshot taken at attach time and never refreshed. The TUI is
blocked in `spawnSync` for the whole attach so there is no loop to update from,
and tmux's own format language cannot map a `codeman-<hex>` session name back
to a label a human recognises. Slightly stale beats absent.
The way out moves from F12 to F1, which sits beside Esc where a hand backing
out already goes. Verified against BOTH encodings a terminal sends for it:
xterm's SS3 (ESC O P) and PuTTY's default (ESC [ 1 1 ~).
`status-position` joins the snapshot, so a session that had its bar at the
bottom gets it back there on detach along with everything else.
This commit is contained in:
+94
-10
@@ -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<string, string> {
|
||||
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 <session>` 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<n>-<case>`, 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<TuiAttachHandoff> {
|
||||
export async function beginAttachHandoff(
|
||||
client: TuiClient,
|
||||
muxName: string,
|
||||
label: string,
|
||||
tabs: readonly TuiAttachTab[] = []
|
||||
): Promise<TuiAttachHandoff> {
|
||||
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-<hex>` 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`);
|
||||
|
||||
+79
-12
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user