mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
fix(tmux): keep browser keys from switching a tab onto another session (#669)
Squash of PR #669. Browser terminals could be moved onto another session by tmux's prefix keys: Ctrl+B then ), (, s or w switched the tab's tmux client to a different session while the tab kept the original's name. Codeman's tmux socket now keeps only Ctrl+B Ctrl+B (send-prefix) in its prefix table, and codeman tui and scripts/tmux-manager.sh get F1 as their way out instead of Ctrl+B d. Merge-time fixes by the maintainer: on an empty prefix table the unbind-key that fails no longer skips binding send-prefix; a key tmux lists escaped (prefix C-\ shows as C-\\) compares equal, so the reset does not rerun on every create; and reconcileSessions() reduces the table at boot when the socket has live sessions, so sessions that survive an upgrade are covered too. Co-authored-by: christianhaberl <116439789+christianhaberl@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
620e7728ec
commit
6eaaa960ff
14 files changed
+775
-28
No files matched your search
@@ -125,6 +125,7 @@ import {
|
||||
|
||||
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
||||
import { ensureDeepSeekStatusShim } from './deepseek-status-shim.js';
|
||||
import { restrictTmuxPrefixTable } from './tmux-prefix-table.js';
|
||||
|
||||
/** How long a cached process snapshot stays usable. */
|
||||
const PROC_SNAPSHOT_TTL_MS = 2000;
|
||||
@@ -2491,6 +2492,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
/* Non-critical */
|
||||
}
|
||||
|
||||
// Browser keystrokes reach the attach client, so tmux's stock prefix table would
|
||||
// let Ctrl+B ( or s move a tab onto another session. Before the agent starts.
|
||||
restrictTmuxPrefixTable(this.tmux());
|
||||
|
||||
// Per-CLI env: API keys, config blobs, config-sourced vars, status bridges. All of
|
||||
// it is registry data, so this is one unconditional call rather than a per-mode ladder.
|
||||
this._configureCliEnv(
|
||||
@@ -3176,6 +3181,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
active = new Map();
|
||||
}
|
||||
|
||||
// Sessions that survived an upgrade may sit on a server whose prefix table was never
|
||||
// reduced (createSession does it for new ones), so cover them at boot too. Only with
|
||||
// panes listed: on an empty socket `list-keys` spins up a throwaway server instead.
|
||||
if (active.size > 0) restrictTmuxPrefixTable(this.tmux());
|
||||
|
||||
// Check tracked sessions against the live pane list.
|
||||
for (const [sessionId, session] of this.sessions) {
|
||||
const pid = active.get(session.muxName);
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* @fileoverview Keep tmux's prefix table on Codeman's socket down to `send-prefix`.
|
||||
*
|
||||
* Codeman drives tmux through commands, never through key bindings, but every
|
||||
* keystroke typed into a browser terminal reaches the `tmux attach-session`
|
||||
* client behind it. With tmux's stock prefix table that client obeys the whole
|
||||
* menu: Ctrl+B then `(`, `)`, `L`, `s` or `w` runs `switch-client` /
|
||||
* `choose-tree` and re-attaches the tab's client to a DIFFERENT session, so the
|
||||
* tab shows, and types into, another session's agent while Codeman still labels
|
||||
* it as the original. Ctrl+B `d` detaches the client, `$` renames the session
|
||||
* under Codeman's feet, `:` opens tmux's command prompt. Ctrl+B is not a rare
|
||||
* key in a terminal (readline backward-char, Claude Code's background chord),
|
||||
* and whatever is typed next lands in that menu.
|
||||
*
|
||||
* The prefix itself stays: Claude Code running inside tmux asks for
|
||||
* "ctrl+b ctrl+b" to background a command, which only works through
|
||||
* `send-prefix`. So the table keeps exactly that one binding, on whatever key
|
||||
* the socket's prefix is (the socket reads the user's `~/.tmux.conf`).
|
||||
*
|
||||
* `codeman tui` shares the socket (src/tui/tui-app.ts, beginAttachHandoff). For
|
||||
* the length of an attach it claims a held-Ctrl detach alias (`C-d`) in the
|
||||
* prefix table, and its one-key way out (F1) and Alt+1..9 in the `root` table.
|
||||
* `root` is never touched here, and a `C-<letter> detach-client` binding is left
|
||||
* alone (and put back when a reset clears the rest of the table around it), so
|
||||
* creating a session while a TUI attach is open does not take the attach's way
|
||||
* out away. The reset only runs while the table holds anything else, i.e. once
|
||||
* per tmux server in practice.
|
||||
*
|
||||
* That tolerance is server-wide like every key table: while such an alias is
|
||||
* bound, a browser tab's client can detach itself with it too. The same goes
|
||||
* for the `root` keys an attach claims, including Alt+1..9, which run
|
||||
* `switch-client`: a tab that receives Esc then a digit within tmux's
|
||||
* `escape-time` is moved onto another session for as long as those bindings
|
||||
* exist. Leaving `root` alone keeps the attach working; it does not close that.
|
||||
*
|
||||
* @module tmux-prefix-table
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
||||
import { shellescape } from './remote-hosts.js';
|
||||
|
||||
/** tmux's stock prefix, used when `show-options` cannot be read. */
|
||||
const DEFAULT_PREFIX = 'C-b';
|
||||
|
||||
/** One `list-keys -T prefix` line: key and command. */
|
||||
const PREFIX_LINE = /^bind-key\s+(?:-\S+\s+)*?-T\s+prefix\s+(\S+)\s+(.+)$/;
|
||||
|
||||
/** The held-Ctrl detach alias `codeman tui` claims during an attach (heldCtrlAlias). */
|
||||
const TUI_DETACH_ALIAS = /^C-[a-z]$/;
|
||||
|
||||
/**
|
||||
* A key as `list-keys` prints it, without tmux's backslash escaping: `show-options`
|
||||
* prints the prefix `C-\` as is, while the listing shows `C-\\` (and `\#`, `\$`, `\"`).
|
||||
*/
|
||||
function unescapeListedKey(key: string): string {
|
||||
return key.replace(/\\(.)/g, '$1');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the prefix table (as printed by `tmux list-keys -T prefix`) still holds
|
||||
* a binding other than `<prefix> send-prefix` or a TUI detach alias, or lacks
|
||||
* `<prefix> send-prefix`. Unparseable output counts as "needs a reset":
|
||||
* resetting is idempotent, skipping one leaves `switch-client` reachable from
|
||||
* the browser. A prefix of `None` expects no `send-prefix` binding at all.
|
||||
*/
|
||||
export function prefixTableNeedsReset(listKeysOutput: string, prefix: string = DEFAULT_PREFIX): boolean {
|
||||
let hasSendPrefix = false;
|
||||
for (const raw of listKeysOutput.split('\n')) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
const match = PREFIX_LINE.exec(line);
|
||||
if (!match) return true;
|
||||
const key = unescapeListedKey(match[1]);
|
||||
const command = match[2].trim();
|
||||
if (command === 'send-prefix' && key === prefix) {
|
||||
hasSendPrefix = true;
|
||||
continue;
|
||||
}
|
||||
if (command === 'detach-client' && TUI_DETACH_ALIAS.test(key) && key !== prefix) continue;
|
||||
return true;
|
||||
}
|
||||
return prefix === 'None' ? false : !hasSendPrefix;
|
||||
}
|
||||
|
||||
/** The `C-<letter> detach-client` bindings in a `list-keys -T prefix` listing (see prefixTableNeedsReset). */
|
||||
function tuiDetachAliases(listKeysOutput: string, prefix: string): string[] {
|
||||
const keys: string[] = [];
|
||||
for (const raw of listKeysOutput.split('\n')) {
|
||||
const match = PREFIX_LINE.exec(raw.trim());
|
||||
if (!match) continue;
|
||||
const key = unescapeListedKey(match[1]);
|
||||
if (match[2].trim() === 'detach-client' && TUI_DETACH_ALIAS.test(key) && key !== prefix) keys.push(key);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/** Runs one tmux command line and returns its stdout. */
|
||||
export type TmuxRunner = (command: string) => string;
|
||||
|
||||
const defaultRunner: TmuxRunner = (command) =>
|
||||
execSync(command, { encoding: 'utf8', timeout: EXEC_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'ignore'] });
|
||||
|
||||
/**
|
||||
* Reduce the prefix table of the tmux server behind `tmux` (e.g. `tmux -L 'codeman'`)
|
||||
* to `<prefix> send-prefix`, when it holds anything more. Never throws: a failure
|
||||
* only leaves tmux's stock bindings in place. Returns whether a reset ran.
|
||||
*/
|
||||
export function restrictTmuxPrefixTable(tmux: string, run: TmuxRunner = defaultRunner): boolean {
|
||||
let prefix = DEFAULT_PREFIX;
|
||||
try {
|
||||
prefix = run(`${tmux} show-options -gv prefix`).trim() || DEFAULT_PREFIX;
|
||||
} catch {
|
||||
/* keep tmux's stock prefix */
|
||||
}
|
||||
let listing: string | null;
|
||||
try {
|
||||
listing = run(`${tmux} list-keys -T prefix`);
|
||||
} catch {
|
||||
listing = null; // an empty table makes list-keys fail on some tmux builds
|
||||
}
|
||||
if (listing !== null && !prefixTableNeedsReset(listing, prefix)) return false;
|
||||
try {
|
||||
run(`${tmux} unbind-key -a -T prefix`);
|
||||
} catch {
|
||||
/* an empty table makes unbind-key -a fail too ("table prefix doesn't exist"); nothing to clear */
|
||||
}
|
||||
try {
|
||||
if (prefix !== 'None') run(`${tmux} bind-key -T prefix ${shellescape(prefix)} send-prefix`);
|
||||
// `unbind-key -a` also took a running TUI attach's held-Ctrl way out; give it back.
|
||||
for (const key of tuiDetachAliases(listing ?? '', prefix)) {
|
||||
run(`${tmux} bind-key -T prefix ${shellescape(key)} detach-client`);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+22
-9
@@ -317,6 +317,18 @@ export function detachChord(prefix?: string, key?: string): string {
|
||||
return `${formatPrefixKey(prefix)} then ${key || DEFAULT_DETACH_KEY}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The chord an attach advertises when it could not claim the one-key way out.
|
||||
*
|
||||
* With no key bound to `detach-client` in the prefix table there is no stock
|
||||
* `d` to name: Codeman keeps only `send-prefix` there (src/tmux-prefix-table.ts),
|
||||
* so the held-Ctrl alias the attach claimed is the chord that works.
|
||||
*/
|
||||
export function fallbackDetachChord(prefix?: string, detachKey?: string, heldAlias?: string): string {
|
||||
if (!detachKey && heldAlias) return detachChord(prefix, formatPrefixKey(heldAlias));
|
||||
return detachChord(prefix, detachKey);
|
||||
}
|
||||
|
||||
/** `#` opens `#[…]`/`#{…}` in a tmux format, so a name carrying one must double it. */
|
||||
function escapeTmuxFormat(value: string): string {
|
||||
return value.replace(/#/g, '##');
|
||||
@@ -355,10 +367,10 @@ export function buildAttachBanner(options: {
|
||||
/** Alt+1..9 really switch sessions, so the bar may say so. */
|
||||
switchKeys?: boolean;
|
||||
}): Record<string, string> {
|
||||
const chord = escapeTmuxFormat(detachChord(options.prefix, options.detachKey));
|
||||
const chord = escapeTmuxFormat(fallbackDetachChord(options.prefix, options.detachKey, options.heldAlias));
|
||||
// Named on the bar because it is what people actually type: keeping Ctrl held
|
||||
// is the common way to press this, and the bar has to say that it works.
|
||||
const held = options.heldAlias ? escapeTmuxFormat(formatPrefixKey(options.heldAlias)) : '';
|
||||
const held = options.heldAlias && options.detachKey ? escapeTmuxFormat(formatPrefixKey(options.heldAlias)) : '';
|
||||
const alias = held ? ` (or ${held})` : '';
|
||||
const label = escapeTmuxFormat(truncateLabel((options.label ?? '').trim(), ATTACH_BANNER_LABEL_MAX));
|
||||
// ONE option, not `status-left`/`status-right`/`status-style`: `status-format[0]`
|
||||
@@ -624,7 +636,7 @@ export async function beginAttachHandoff(
|
||||
if (target === muxName) await client.followAttachingClient(target);
|
||||
}
|
||||
return {
|
||||
chord: oneKey ? ONE_KEY_DETACH : detachChord(prefix, detachKey),
|
||||
chord: oneKey ? ONE_KEY_DETACH : fallbackDetachChord(prefix, detachKey, claimed && alias ? alias : undefined),
|
||||
async restore(): Promise<void> {
|
||||
for (const key of switchKeys) await client.unbindSwitchKey(key);
|
||||
if (oneKey) await client.unbindDetachKey(ONE_KEY_DETACH, 'root');
|
||||
@@ -702,7 +714,7 @@ export interface TuiKeymapContext {
|
||||
/** False in degraded mode, where the only verb that works is attach. */
|
||||
server: boolean;
|
||||
approval?: TuiApprovalKeys;
|
||||
/** tmux's detach chord as this socket reports it. Defaults to the stock `Ctrl+B D`. */
|
||||
/** How the last attach said to leave it. Defaults to F1, the key an attach claims. */
|
||||
detach?: string;
|
||||
/** A dead-row card is offering `r` to resume, so the footer has to say so. */
|
||||
resumeOffer?: boolean;
|
||||
@@ -761,7 +773,7 @@ export function helpKeysFor(glyphs: TuiGlyphSet, context: TuiKeymapContext): Arr
|
||||
['tab', 'next session'],
|
||||
// The one key that is not the TUI's: an attach hands the terminal to tmux,
|
||||
// and leaving it is the question every first attach asks.
|
||||
[context.detach ?? detachChord(), 'detach from an attached session, back to here'],
|
||||
[context.detach ?? ONE_KEY_DETACH, 'detach from an attached session, back to here'],
|
||||
];
|
||||
if (context.server) {
|
||||
keys.push(
|
||||
@@ -1109,11 +1121,12 @@ class TuiApp {
|
||||
private readonly glyphs: TuiGlyphSet;
|
||||
private readonly socket = resolveTmuxSocketName();
|
||||
/**
|
||||
* How to leave an attach, in the local tmux's own prefix. Read per attach
|
||||
* (a session can override the prefix) and remembered so the help overlay
|
||||
* names the real chord even before the first attach.
|
||||
* How to leave an attach. Read per attach (F1 may be the user's, a session
|
||||
* can override the prefix) and remembered for the help overlay; until the
|
||||
* first attach it names F1, what an attach claims unless the user's config
|
||||
* already holds it.
|
||||
*/
|
||||
private detachChordLabel = detachChord();
|
||||
private detachChordLabel: string = ONE_KEY_DETACH;
|
||||
/** True for the length of one resume. The only thing standing between a resume and a loop. */
|
||||
private resuming = false;
|
||||
|
||||
|
||||
Reference in new issue
Block a user