mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat(tui): leave an attach with ONE key, F12, and no modifier
Three beta rounds died on tmux's native way out, and the last one died on the
instruction rather than the mechanism: "press Ctrl+B, release Ctrl, then d" is,
in the tester's words, very unclear, and holding the modifier through both keys
silently does nothing.
So the way out stops being a chord. The attach claims F12 in tmux's prefix-less
`root` table for its own duration, and the bar reads "press F12 to get back to
the codeman dashboard" — one keystroke, nothing to hold, nothing to release,
no order to get right. F12 because stock tmux ships an empty root table apart
from mouse bindings, and none of the CLIs that run in these panes want the key.
⚠️ The bar names the one key ONLY when the claim succeeded, and falls back to
the chord wording otherwise. A bar advertising a key that does nothing is the
bug this whole series started with, and it must not come back in a new costume.
Same claim rules as the prefix alias: taken only when tmux reports the key
unbound, given back only while it still means `detach-client`.
The chord and the held-Ctrl alias both keep working; they are simply no longer
what the user is told to press.
This commit is contained in:
+33
-2
@@ -264,6 +264,23 @@ export function formatPrefixKey(prefix: string | undefined): string {
|
||||
/** tmux's stock `detach-client` binding, used when `list-keys` cannot be read. */
|
||||
export const DEFAULT_DETACH_KEY = 'd';
|
||||
|
||||
/**
|
||||
* The ONE key that leaves an attach, bound in tmux's prefix-less `root` table.
|
||||
*
|
||||
* ⚠️ Everything else here is a fallback. tmux's native way out is a chord typed
|
||||
* in a particular order — press the prefix, LET GO of the modifier, then a
|
||||
* letter — and three rounds of beta testing died on it: first the bar named the
|
||||
* wrong letter, then the right letter failed because the modifier was held.
|
||||
* 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.
|
||||
*/
|
||||
export const ONE_KEY_DETACH = 'F12';
|
||||
|
||||
/**
|
||||
* The key a user produces when they DON'T let go of Ctrl: `d` becomes `C-d`.
|
||||
*
|
||||
@@ -326,6 +343,8 @@ export function buildAttachBanner(options: {
|
||||
detachKey?: string;
|
||||
/** The held-Ctrl form, when the attach managed to claim it. */
|
||||
heldAlias?: string;
|
||||
/** The prefix-less key, when the attach managed to claim it. Preferred over every chord. */
|
||||
oneKey?: string;
|
||||
}): 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
|
||||
@@ -341,7 +360,12 @@ export function buildAttachBanner(options: {
|
||||
return {
|
||||
status: 'on',
|
||||
'status-style': 'bg=default,fg=default',
|
||||
'status-format[0]': `#[align=left] press #[bold]${chord}#[nobold]${alias} to detach, ${ATTACH_BANNER_MARKER}${right}#[default]`,
|
||||
// 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]`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -440,17 +464,24 @@ export async function beginAttachHandoff(client: TuiClient, muxName: string, lab
|
||||
// binding the user put in their own config.
|
||||
const alias = heldCtrlAlias(detachKey ?? DEFAULT_DETACH_KEY);
|
||||
const claimed = alias && (await client.readPrefixBinding(alias)) === null ? await client.bindDetachKey(alias) : false;
|
||||
// The one-key way out, in the prefix-less table. Same rule: only if free.
|
||||
const oneKey =
|
||||
(await client.readPrefixBinding(ONE_KEY_DETACH, 'root')) === null
|
||||
? await client.bindDetachKey(ONE_KEY_DETACH, 'root')
|
||||
: false;
|
||||
const banner = buildAttachBanner({
|
||||
...(prefix ? { prefix } : {}),
|
||||
...(detachKey ? { detachKey } : {}),
|
||||
...(claimed && alias ? { heldAlias: alias } : {}),
|
||||
...(oneKey ? { oneKey: ONE_KEY_DETACH } : {}),
|
||||
label,
|
||||
});
|
||||
const options = await client.readSessionOptions(muxName, Object.keys(banner));
|
||||
await client.applySessionOptions(muxName, banner);
|
||||
return {
|
||||
chord: detachChord(prefix, detachKey),
|
||||
chord: oneKey ? ONE_KEY_DETACH : detachChord(prefix, detachKey),
|
||||
async restore(): Promise<void> {
|
||||
if (oneKey) await client.unbindDetachKey(ONE_KEY_DETACH, 'root');
|
||||
if (claimed && alias) await client.unbindDetachKey(alias);
|
||||
// Options first, then the size: dropping the status bar gives its row
|
||||
// back to the pane, and the resize is what re-pins the browser's
|
||||
|
||||
+29
-14
@@ -472,9 +472,10 @@ export function parseSessionOptions(stdout: string, keys: readonly string[]): Tu
|
||||
* key appears nowhere in the table. The command is returned verbatim, so a
|
||||
* caller can insist on an exact match rather than a prefix of one.
|
||||
*/
|
||||
export function parsePrefixBinding(stdout: string, key: string): string | null {
|
||||
export function parsePrefixBinding(stdout: string, key: string, table: TuiKeyTable = 'prefix'): string | null {
|
||||
const pattern = new RegExp(`^bind-key\\s+(?:-\\S+\\s+)*?-T\\s+${table}\\s+(\\S+)\\s+(.+)$`);
|
||||
for (const line of stdout.split('\n')) {
|
||||
const match = /^bind-key\s+(?:-\S+\s+)*?-T\s+prefix\s+(\S+)\s+(.+)$/.exec(line.trim());
|
||||
const match = pattern.exec(line.trim());
|
||||
if (!match) continue;
|
||||
if (unquoteTmuxValue(match[1] ?? '') !== key) continue;
|
||||
return (match[2] ?? '').trim();
|
||||
@@ -490,6 +491,13 @@ export function parsePrefixBinding(stdout: string, key: string): string | null {
|
||||
*/
|
||||
export const ATTACH_BANNER_MARKER = 'back to the codeman dashboard';
|
||||
|
||||
/**
|
||||
* The tmux key tables an attach touches. `root` is the one without a prefix:
|
||||
* a key bound there is delivered on its own, which is what makes a one-key way
|
||||
* out possible at all.
|
||||
*/
|
||||
export type TuiKeyTable = 'prefix' | 'root';
|
||||
|
||||
/**
|
||||
* The key bound to a bare `detach-client` in `list-keys -T prefix` output, or
|
||||
* null when nothing there detaches.
|
||||
@@ -917,19 +925,26 @@ export class TuiClient {
|
||||
* free. Used to check a key is unbound BEFORE claiming it, so the attach can
|
||||
* never shadow a binding the user relies on.
|
||||
*/
|
||||
async readPrefixBinding(key: string): Promise<string | null> {
|
||||
async readPrefixBinding(key: string, table: TuiKeyTable = 'prefix'): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await this.exec('tmux', ['-L', this.socket, 'list-keys', '-T', 'prefix']);
|
||||
return parsePrefixBinding(stdout, key);
|
||||
const { stdout } = await this.exec('tmux', ['-L', this.socket, 'list-keys', '-T', table]);
|
||||
return parsePrefixBinding(stdout, key, table);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Claim a prefix key for `detach-client`. Best effort; a failure is not fatal to an attach. */
|
||||
async bindDetachKey(key: string): Promise<boolean> {
|
||||
/**
|
||||
* Claim a key for `detach-client`. Best effort; a failure is not fatal to an
|
||||
* attach, and the caller advertises the key only if this returned true.
|
||||
*
|
||||
* The `root` table is the one that matters for a way OUT: a key bound there
|
||||
* needs no prefix at all, so leaving a session is one keypress rather than a
|
||||
* chord typed in the right order.
|
||||
*/
|
||||
async bindDetachKey(key: string, table: TuiKeyTable = 'prefix'): Promise<boolean> {
|
||||
try {
|
||||
await this.exec('tmux', ['-L', this.socket, 'bind-key', '-T', 'prefix', key, 'detach-client']);
|
||||
await this.exec('tmux', ['-L', this.socket, 'bind-key', '-T', table, key, 'detach-client']);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -937,14 +952,14 @@ export class TuiClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Give a prefix key back, but ONLY while it still means `detach-client`.
|
||||
* Anything else there is the user's, arrived after we bound ours, and must
|
||||
* not be removed.
|
||||
* Give a key back, but ONLY while it still means `detach-client`. Anything
|
||||
* else there is the user's, arrived after we bound ours, and must not be
|
||||
* removed.
|
||||
*/
|
||||
async unbindDetachKey(key: string): Promise<void> {
|
||||
if ((await this.readPrefixBinding(key)) !== 'detach-client') return;
|
||||
async unbindDetachKey(key: string, table: TuiKeyTable = 'prefix'): Promise<void> {
|
||||
if ((await this.readPrefixBinding(key, table)) !== 'detach-client') return;
|
||||
try {
|
||||
await this.exec('tmux', ['-L', this.socket, 'unbind-key', '-T', 'prefix', key]);
|
||||
await this.exec('tmux', ['-L', this.socket, 'unbind-key', '-T', table, key]);
|
||||
} catch {
|
||||
/* a key we cannot give back is a stray convenience binding, not a failure */
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
confirmKillStep,
|
||||
detachChord,
|
||||
heldCtrlAlias,
|
||||
ONE_KEY_DETACH,
|
||||
nextSessionName,
|
||||
footerKeysFor,
|
||||
formatPrefixKey,
|
||||
@@ -459,6 +460,35 @@ describe('buildListLines', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
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 bar = banner['status-format[0]'];
|
||||
expect(bar).toContain('press #[bold]F12#[nobold] to get 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');
|
||||
expect(bar).not.toContain('or Ctrl+D');
|
||||
});
|
||||
|
||||
it('falls back to the chord when the key could not be claimed', () => {
|
||||
// Never advertise a key we did not get: a bar naming an inert key is the
|
||||
// original bug, in a new costume.
|
||||
const bar = buildAttachBanner({ prefix: 'C-b', detachKey: 'd', heldAlias: 'C-d' })['status-format[0]'];
|
||||
expect(bar).toContain('Ctrl+B then d');
|
||||
expect(bar).toContain('(or Ctrl+D)');
|
||||
expect(bar).not.toContain('F12');
|
||||
});
|
||||
});
|
||||
|
||||
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
|
||||
|
||||
@@ -621,6 +621,15 @@ describe('parsePrefixBinding', () => {
|
||||
it('is case-sensitive, like tmux itself', () => {
|
||||
expect(parsePrefixBinding('bind-key -T prefix D choose-client', 'd')).toBeNull();
|
||||
});
|
||||
|
||||
it('reads the prefix-less root table too, where the one-key exit lives', () => {
|
||||
const root = ['bind-key -T root MouseDown1Pane select-pane -t =', 'bind-key -T root F12 detach-client'].join('\n');
|
||||
expect(parsePrefixBinding(root, 'F12', 'root')).toBe('detach-client');
|
||||
// Stock tmux has no F12 there, which is what makes it safe to claim.
|
||||
expect(parsePrefixBinding('bind-key -T root MouseDown1Pane select-pane -t =', 'F12', 'root')).toBeNull();
|
||||
// A prefix binding must not be mistaken for a root one.
|
||||
expect(parsePrefixBinding('bind-key -T prefix F12 detach-client', 'F12', 'root')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TuiClient.clearLeakedAttachBanners', () => {
|
||||
|
||||
Reference in New Issue
Block a user