mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 01:09:43 +02:00
fix(tui): make an attach fit the terminal, show the way out, and resume history
Three things the first beta test surfaced. 1. Attaching from a terminal of a different shape showed the pane clipped to the browser's size, with tmux's dot padding filling the rest. Codeman pins every window it owns to `window-size manual` at whatever the web client reports (tmux-manager.ts), so no attaching client can resize it. The handoff now brackets the attach with `window-size latest` and restores the snapshot on detach. `latest`, rather than a one-off resize to our own size, is also what lets a terminal resized MID-attach follow along: tmux recomputes on every SIGWINCH while the TUI is blocked in spawnSync and cannot. 2. Nothing on screen said how to get back out, because Codeman keeps the status bar off on its panes (the web UI carries that information around the terminal instead). The tester exited the agent looking for the exit, leaving a dead pane. An attach now wears a `status-format[0]` bar reading "<prefix> D detach, back to the codeman dashboard", with the prefix READ from tmux rather than assumed, and the session's options are put back exactly as they were on detach. One option, not status-left/status-right, so tmux draws no window list beside it; `reverse` so it inherits the terminal's own theme. Restoring an array option unsets the BASE name, since dropping the `[0]` index leaves an empty array, which renders as a blank bar on a session that had one. The help overlay names the chord, and the dashboard confirms the detach. 3. Enter on a RECENT row said resuming was not wired up. It now creates a session carrying that conversation (`resumeSessionId` plus `/interactive`, the path the web UI's Resume Conversation list already uses), in the directory it ran in and under its old name, then attaches to it. The attach mechanics deliberately sit in a method the group dispatch cannot reach, plus a re-entrancy flag: routing resume back through the Enter handler re-dispatched on "this row is RECENT" and spawned one session per pass, 35 in about 40 seconds on the beta before it was killed. test/tui/tui-e2e.test.ts pins one press to one session with a pane that never appears, which is exactly the case that looped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+110
-2
@@ -13,13 +13,17 @@ import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
applyLiveMetrics,
|
||||
applyMuxNames,
|
||||
buildAttachBanner,
|
||||
buildListLines,
|
||||
confirmAccepts,
|
||||
confirmKillStep,
|
||||
detachChord,
|
||||
footerKeysFor,
|
||||
formatPrefixKey,
|
||||
helpKeysFor,
|
||||
isSelfSession,
|
||||
planAttach,
|
||||
planResume,
|
||||
previewIntervalMs,
|
||||
previewNoteFor,
|
||||
resyncDelayMs,
|
||||
@@ -72,7 +76,6 @@ describe('planAttach', () => {
|
||||
kind: 'attach',
|
||||
file: 'tmux',
|
||||
args: ['-L', 'codeman', 'attach-session', '-t', 'codeman-abcdef01'],
|
||||
hint: expect.stringContaining('Ctrl+B D'),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -206,7 +209,12 @@ describe('footerKeysFor', () => {
|
||||
it('keeps the help overlay to the same inventory', () => {
|
||||
const help = helpKeysFor(GLYPHS, { server: true });
|
||||
expect(help.map(([, description]) => description)).toEqual(
|
||||
expect.arrayContaining(['attach', 'new session', 'kill (typed confirmation)', 'quit'])
|
||||
expect.arrayContaining([
|
||||
'attach — on a RECENT row, resume that conversation',
|
||||
'new session',
|
||||
'kill (typed confirmation)',
|
||||
'quit',
|
||||
])
|
||||
);
|
||||
expect(help.flat().join(' ')).toContain('search');
|
||||
const degraded = helpKeysFor(GLYPHS, { server: false }).flat().join(' ');
|
||||
@@ -448,3 +456,103 @@ describe('buildListLines', () => {
|
||||
expect(line.label.endsWith('…')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the way out of an attach', () => {
|
||||
it('spells the prefix the way a human reads it, and never assumes C-b', () => {
|
||||
expect(formatPrefixKey('C-b')).toBe('Ctrl+B');
|
||||
// A user who remapped the prefix must not be told to press Ctrl+B.
|
||||
expect(formatPrefixKey('C-a')).toBe('Ctrl+A');
|
||||
expect(formatPrefixKey('M-x')).toBe('Alt+X');
|
||||
// Nothing to go on: the tmux default is the honest guess.
|
||||
expect(formatPrefixKey(undefined)).toBe('Ctrl+B');
|
||||
expect(formatPrefixKey(' ')).toBe('Ctrl+B');
|
||||
// A shape we do not recognise passes through rather than being mangled.
|
||||
expect(formatPrefixKey('F1')).toBe('F1');
|
||||
});
|
||||
|
||||
it('names the chord, not just the prefix', () => {
|
||||
expect(detachChord('C-a')).toBe('Ctrl+A D');
|
||||
expect(detachChord()).toBe('Ctrl+B D');
|
||||
});
|
||||
|
||||
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]']);
|
||||
expect(banner.status).toBe('on');
|
||||
expect(banner['status-format[0]']).toContain('#[bold]Ctrl+B D#[nobold]');
|
||||
expect(banner['status-format[0]']).toContain('#[align=right] w3-codeman ');
|
||||
});
|
||||
|
||||
it('carries the remapped prefix into the bar', () => {
|
||||
expect(buildAttachBanner({ prefix: 'C-a' })['status-format[0]']).toContain('Ctrl+A D');
|
||||
});
|
||||
|
||||
it('escapes a label that would otherwise open a tmux format', () => {
|
||||
const banner = buildAttachBanner({ label: 'fix #42 #[bold]' });
|
||||
expect(banner['status-format[0]']).toContain('fix ##42 ##[bold]');
|
||||
});
|
||||
|
||||
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]', '');
|
||||
// 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');
|
||||
});
|
||||
|
||||
it('leaves the right side out entirely when there is no label', () => {
|
||||
expect(buildAttachBanner({})['status-format[0]']).not.toContain('#[align=right]');
|
||||
});
|
||||
|
||||
it("tells the help overlay how to get back, in the socket's own prefix", () => {
|
||||
const keys = helpKeysFor(GLYPHS, { server: true, detach: 'Ctrl+A D' });
|
||||
const detach = keys.find(([key]) => key === 'Ctrl+A D');
|
||||
expect(detach?.[1]).toContain('detach');
|
||||
// Degraded mode still attaches, so it still needs the way out.
|
||||
expect(helpKeysFor(GLYPHS, { server: false }).map(([key]) => key)).toContain('Ctrl+B D');
|
||||
});
|
||||
});
|
||||
|
||||
describe('planResume', () => {
|
||||
const base = { sessionId: 'aaaaaaaa-1111-2222-3333-444444444444', sources: ['transcript'] } as const;
|
||||
|
||||
it('resumes the CONVERSATION id, not the row id', () => {
|
||||
const plan = planResume({
|
||||
...base,
|
||||
claudeSessionId: 'bbbbbbbb-5555-6666-7777-888888888888',
|
||||
workingDir: '/home/dev/codeman',
|
||||
name: 'w7-codeman',
|
||||
});
|
||||
expect(plan).toEqual({
|
||||
kind: 'resume',
|
||||
workingDir: '/home/dev/codeman',
|
||||
resumeSessionId: 'bbbbbbbb-5555-6666-7777-888888888888',
|
||||
sessionName: 'w7-codeman',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the row id when the row IS the transcript', () => {
|
||||
const plan = planResume({ ...base, workingDir: '/home/dev/codeman' });
|
||||
expect(plan).toMatchObject({ kind: 'resume', resumeSessionId: base.sessionId });
|
||||
// No name to keep: the server names it rather than the TUI inventing one.
|
||||
expect(plan).not.toHaveProperty('sessionName');
|
||||
});
|
||||
|
||||
it('refuses a row with nowhere to run', () => {
|
||||
expect(planResume({ ...base })).toMatchObject({ kind: 'refuse' });
|
||||
});
|
||||
|
||||
it('refuses a non-claude row, since resume is a Claude Code feature', () => {
|
||||
const plan = planResume({ ...base, workingDir: '/home/dev/codeman', mode: 'codex' });
|
||||
expect(plan.kind).toBe('refuse');
|
||||
if (plan.kind === 'refuse') expect(plan.message).toContain('codex');
|
||||
});
|
||||
|
||||
it('refuses an id the server would reject anyway', () => {
|
||||
// The route validates `/^[a-f0-9-]+$/`; a mux-derived row id is not that.
|
||||
expect(planResume({ ...base, sessionId: 'codeman-w1', workingDir: '/home/dev' })).toMatchObject({
|
||||
kind: 'refuse',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user