diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 497cd7cb..39ffb23f 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -241,6 +241,8 @@ Further detail, closing: ⚠️ **Closing has the mirror-image race and one owne **Codeman creates every pane with `remain-on-exit on`, so a session whose agent exited still looks alive.** `/exit` ends the CLI, tmux keeps the pane and the tmux session, and the `tmux attach-session` process Codeman records as `Session.pid` runs on, so no PTY exit handler fires and the record keeps its pid and `status: 'idle'` (Ark0N/Codeman#446). `SessionState.paneExit` (`{status?, signal?, at}`) is the fact tmux already knows, published through `toState()` so it rides `session:updated` and lands in `state.json` on the same persist — there is no SSE event for it. One batched `tmux list-panes -a` per tick fills it, from `TmuxManager.startPaneExitWatcher()`, which has its OWN always-on interval: the stats collector cannot carry it, because the browser arms and disarms that one with the Monitor panel (`panels-ui.js`) and boot skips it entirely when no session was recovered. ⚠️ **The field is TRI-STATE and its third state is absence**, meaning UNKNOWN, which renders as nothing and must NEVER read as alive; it covers a running pane, a session the read did not list, a failed probe, and every session shape a dead local pane does not describe. `Session.paneExitApplies` is the single place that scoping lives, and it fails closed for four shapes: a direct-PTY session (no pane), a remote SSH session (the local pane is the ssh client, whose death is a transport drop OR an exit — the whole of #355), a docker case (the local pane is a `docker exec` into the container's own tmux), and a session rebuilt from the socket (`MuxSession.discovered`: its synthetic `restored-` id matches no `state.json` entry, so a remote session rediscovered after `mux-sessions.json` was lost would arrive looking local). ⚠️ **Never set `status: 'error'`** for an exited pane — that value is the PTY-exit breaker's and the browser answers it with a "restart it?" confirm — and **never null the `pid`**, which is what makes `selectSession()` re-attach and launch a fresh CLI. Local panes keep `remain-on-exit on`; flipping them to `failed` ends the tmux session, nulls the pid and reintroduces the auto-revive #355 removed. ⚠️ **An absent `#{pane_dead_status}` is not 0**: measured on tmux 3.2a a SIGKILLed pane reports neither a status nor a signal (`#{pane_dead_signal}` did not exist before tmux 3.4), so folding it into 0 would turn an unexplained death into a clean exit. A session answers only when the read listed EXACTLY ONE pane for it, since Codeman never splits a pane and a session the user split by hand has none that speaks for the agent. The three synchronous `isPaneDead()` callers (the `/wait` route, the TUI, the attach path) keep their own probes — this watcher is never fresh enough for them. ⚠️ **A path that starts a command in a pane must clear the record AND persist**, since the watcher's next tick sees the field already cleared and writes nothing. ⚠️ **The always-on timer gates the READ, never the tick.** `hasObservablePaneSession()` (`tmux-manager.ts`) skips the tmux exec while every session on the manager is one of the shapes `paneExitApplies` forces to UNKNOWN, so an instance running only remote or Docker work keeps ticking and costs nothing; the two predicates are two copies of one rule, and `test/session-pane-exit.test.ts` pins them against each other for the four session shapes that exist today — a FIFTH condition added to one and not the other still fails nothing, so change them together. Skipping retracts nothing, for the same reason a failed read does not. ⚠️ **The muted status dot is a specificity fight, and it is fought on three surfaces.** The tab renders `status` as before, and `tab-agent-exited` only quiets the dot, so the rule excludes three states BY HAND: `.tab-alert-action` and `.tab-alert-idle` on the tab, and `.tab-status.error` on the dot itself. Each of those colours means "this needs you" — the two alerts because a human is blocked, `error` because the browser answers it with a "restart it?" confirm — and each must survive the exit. The rich tab rail needs a SECOND copy of the rule, because its own `tab-state-*` dot rules are (0,9,1) against the strip's (0,5,0) — measured, an exited session on a detailed rail kept a full green dot and the working halo beside a badge reading "exited". Its twin matches that specificity exactly and therefore must stay BELOW those rules in source order. mobile.css needs a THIRD copy, with `!important`, because the phone block enlarges a `busy` dot and gives it a green glow that way, and `status` stays `busy` for a pane whose agent died mid-turn — without it a phone renders a grey dot still wearing the green halo. `test/session-pane-exit-ui.test.ts` resolves the real stylesheets in jsdom rather than matching selector text — styles.css for the desktop cases and both files for the phone ones — so the ordering, the hand-written exclusions and a missing phone rule all fail there. Tests: `test/session-pane-exit.test.ts`, `test/tmux-manager.test.ts`, `test/session-pane-exit-ui.test.ts`. +⚠️ **The tab's badge is translated (zh-CN), so its updates compare with English, never the DOM.** `applyPaneExitBadge()` remembers the last English badge text (`data-label`) and the tab's accessible name (`data-aria-source`), both also seeded by the full render, and writes only when those change: the translator rewrites both in zh-CN, and a DOM compare (or an unconditional set) would write the English back on every incremental pass for it to translate again. i18n.js has `exited` and patterns for `exited (N)`, `exited (signal N)` and ` session, agent exited ...` (the name passed through); `test/i18n-exit-run-help.test.ts` covers every `paneExitLabel()` form. + **A session whose agent exited cleanly is closed, and a crashed one is kept** (Ark0N/Codeman#446, part 2). After every pane read, `closeCleanlyExitedSessions()` (`server.ts`) closes each session that `shouldCloseCleanlyExitedSession()` (`pane-exit-sweep.ts`, pure) accepts, through `cleanupSession(id, true, CLEAN_EXIT_CLOSE_REASON)`. That is the X button's path, so an unpinned session is removed, a pinned one is demoted to `status: 'stopped'`, the lifecycle log records why, and the conversation stays resumable from the Resume list, which reads the lifecycle log and the transcripts rather than the pane. The rule has four parts, and each guards against a wrong close: - ⚠️ **The status must be an explicit numeric 0 with no signal** (`isCleanPaneExit()`). An absent status is how a SIGKILL presents on tmux 3.2a, so `status ?? 0` would close an agent the OOM killer took. A non-zero status or any signal keeps the row, marked with the exit, as the crash evidence #210 was filed to keep. diff --git a/src/web/public/app.js b/src/web/public/app.js index 0381fa77..7f496ca0 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -9331,14 +9331,16 @@ class CodemanApp { groups[g].push(shortcut); } const fmtBindings = (s) => { - if (s.displayBindings) return s.displayBindings.map((b) => `${escapeHtml(b)}`).join(' / '); + // Key names, never translated: "Home" is also a dictionary word (the Home + // button), so without the skip zh-CN showed the key as 主页. + if (s.displayBindings) return s.displayBindings.map((b) => `${escapeHtml(b)}`).join(' / '); if (!s.bindings) return ''; // An action with no key (Close Session by default) is still listed, so the // overlay says so instead of showing an empty key column. if (s.bindings.length === 0) return 'not bound'; return s.bindings.map((b) => { const parts = [...(b.modifiers || []).map((m) => m.charAt(0).toUpperCase() + m.slice(1)), b.key || b.code || '']; - return `${escapeHtml(parts.join('+'))}`; + return `${escapeHtml(parts.join('+'))}`; }).join(' / '); }; list.innerHTML = Object.entries(groups).map(([group, items]) => diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 1ac614e7..d8d9f535 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -149,6 +149,18 @@ // The tab's exited-agent badge (app.js applyPaneExitBadge, Ark0N/Codeman#446); // its exit-code forms and the tab's accessible name are patterns. exited: '已退出', + // The Run button family (session-ui.js _applyRunMode; "Run CC", "Run SH" ... + // are a pattern; mode codes and product names stay), and the toolbar beside it. + 'Terminal / Shell': '终端 / Shell', + 'Send Enter': '发送回车', + // The Help modal and the shortcut overlay. Key names stay; Wheel is a mouse + // input like Click (单击). + Tabs: '标签页', + 'Toggle Session Sidebar': '切换会话侧边栏', + 'Copy Selection': '复制选中内容', + 'Copy Selection (interrupts when nothing is selected)': '复制选中内容(无选中内容时中断)', + 'Focus Tabs': '聚焦标签页', + Wheel: '滚轮', 'Ultracode / Workflow agents': 'Ultracode / Workflow 智能体', 'Open ultracode workflow agents': '打开 Ultracode 工作流智能体', Notifications: '通知', @@ -1050,6 +1062,9 @@ ], [/^The agent exited \((-?\d+)\)$/, (_m, code) => `智能体已退出(${code})`], [/^The agent exited \(signal (\d+)\)$/, (_m, signal) => `智能体已退出(信号 ${signal})`], + // The Run button's mode codes ("Run CC", "Run SH", "Run OC" ...; a registry + // CLI's shortBadge too). Exact entries win first ("Run Shell", "Run OMP"). + [/^Run ([A-Z][A-Z0-9]{1,5})$/, (_m, code) => `运行 ${code}`], // The tab's exited-agent badge, and the tab's accessible name carrying it. // The session name is user text: it passes through untranslated. [/^exited \((-?\d+)\)$/, (_m, code) => `已退出(${code})`], diff --git a/src/web/public/index.html b/src/web/public/index.html index 115af7e8..cd4320f9 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -828,7 +828,7 @@
Ctrl+}
Move Active Tab Right
ArrowLeft / ArrowUp
Focus Previous Tab
ArrowRight / ArrowDown
Focus Next Tab
-
Home
Focus First Tab
+
Home
Focus First Tab
End
Focus Last Tab
Enter / Space
Activate Focused Tab
diff --git a/test/i18n-exit-run-help.test.ts b/test/i18n-exit-run-help.test.ts index afe67a39..d96b7836 100644 --- a/test/i18n-exit-run-help.test.ts +++ b/test/i18n-exit-run-help.test.ts @@ -38,9 +38,12 @@ afterAll(() => { en.dom.window.close(); }); -/** Latin words left after removing what may stay (key names). */ +/** Latin words left after removing what may stay: key names, the AI acronym, the N placeholder. */ const leftover = (text: string) => - text.replace(/\b(Ctrl|Cmd|Shift|Alt|Option|Enter|Tab|Space|End|Home|Escape|G)\b/g, '').match(/[A-Za-z]+/g) ?? []; + text + .replace(/\b(Ctrl|Cmd|Shift|Alt|Option)\+\w+/g, '') + .replace(/\b(Ctrl|Cmd|Shift|Alt|Option|Enter|Tab|Space|End|Home|Escape|G|AI|N)\b/g, '') + .match(/[A-Za-z]+/g) ?? []; const helpers = new Function( `${APP.match(/function paneExitLabel\([\s\S]*?\n\}/)![0]}\n${APP.match(/function paneExitAriaLabel\([\s\S]*?\n\}/)![0]}\n` + @@ -78,3 +81,105 @@ describe('the exited-agent badge in zh-CN', () => { } }); }); + +describe('the Run button family in zh-CN', () => { + const SESSION_UI = read('session-ui.js'); + const STOCK = JSON.parse(readFileSync(resolve(import.meta.dirname, '../config/clis.stock.json'), 'utf8')) as Array<{ + shortBadge: string; + }>; + // What _applyRunMode can show: its hard-coded labels, and `Run ` for any registry CLI. + const applyRunMode = SESSION_UI.slice( + SESSION_UI.indexOf(' _applyRunMode() {'), + SESSION_UI.indexOf(' sendEnterKey() {') + ); + const hardCoded = [...applyRunMode.matchAll(/'(Run(?: [A-Z]+)?)'/g)].map((m) => m[1]); + const fromRegistry = STOCK.map((e) => `Run ${e.shortBadge}`); + + it('covers the hard-coded labels and every stock mode code', () => { + expect(hardCoded).toEqual(expect.arrayContaining(['Run SH', 'Run OC', 'Run CX', 'Run OMP', 'Run'])); + expect(fromRegistry).toEqual(expect.arrayContaining(['Run CC', 'Run SH', 'Run OM'])); + }); + + it('"Run" becomes 运行, the mode code and product names stay, English unchanged', () => { + for (const label of new Set([...hardCoded, ...fromRegistry])) { + const text = zh.api.t(label); + const code = label.slice(4); + // A code that is also a product name has its own entry, matched without + // case ("Run PI" -> the "Run Pi" entry, 运行 Pi): the code survives either way. + expect(text.startsWith('运行'), label).toBe(true); + expect(text.slice(2).trim().toLowerCase(), label).toBe(code.toLowerCase()); + expect(en.api.t(label)).toBe(label); + } + }); + + it('the toolbar around it: titles and the Shell button', () => { + for (const s of [ + 'Run Shell', + 'Select AI backend', + 'Terminal / Shell', + 'Send Enter', + 'Instance count', + 'Stop (Ctrl+C)', + ]) { + const text = zh.api.t(s); + expect(text, s).not.toBe(s); + // "Shell" stays, as the table already had it (运行 Shell). + expect(leftover(text.replace(/Shell/g, '')), s).toEqual([]); + expect(en.api.t(s)).toBe(s); + } + }); +}); + +describe('the Help modal and the shortcut overlay in zh-CN', () => { + const dom = new JSDOM(read('index.html'), { runScripts: 'outside-only', url: 'http://localhost/' }); + vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' }); + const doc = dom.window.document; + const control = doc.createElement('button'); + control.textContent = 'Home'; + doc.body.appendChild(control); + const api = (dom.window as unknown as { CodemanI18n: Api & { start(): void } }).CodemanI18n; + api.start(); + api.configure({ language: 'zh-CN' }); + + it('no English left in the Help modal outside the key names', () => { + const left: string[] = []; + const walk = (el: Element) => { + for (const node of el.childNodes) { + if (node.nodeType === 3) { + if (node.parentElement?.tagName !== 'KBD' && leftover(node.nodeValue ?? '').length) + left.push(node.nodeValue!.trim()); + } else if (node.nodeType === 1) walk(node as Element); + } + }; + walk(doc.getElementById('helpModal')!); + expect(left).toEqual([]); + }); + + it('the Home KEY stays Home, while the word Home elsewhere still translates', () => { + const homeKey = [...doc.querySelectorAll('#helpModal kbd')].find((k) => k.closest('div')?.nextElementSibling); + const keys = [...doc.querySelectorAll('#helpModal kbd')].map((k) => k.textContent); + expect(keys).toContain('Home'); + expect(keys).not.toContain('主页'); + // Mouse inputs in the key column do translate (as Click / Right-click do). + expect(keys).toContain('滚轮'); + expect(homeKey).toBeTruthy(); + expect(control.textContent).toBe('主页'); + }); + + it('every shortcut registry group and label translates (the overlay and the App Settings list)', () => { + const start = APP.indexOf('const DEFAULT_SHORTCUTS = ['); + const registry = APP.slice(start, APP.indexOf('\n];', start)); + const pairs = [...registry.matchAll(/group: '([^']+)',\s*label: '([^']+)'/g)]; + expect(pairs.length).toBeGreaterThan(20); + const bad = pairs.flatMap(([, group, label]) => + [group, label].filter((s) => zh.api.t(s) === s || leftover(zh.api.t(s)).length > 0) + ); + expect([...new Set(bad)]).toEqual([]); + }); + + it("the overlay's key column is never translated", () => { + const overlay = APP.slice(APP.indexOf(' renderShortcutOverlay() {'), APP.indexOf(' closeShortcutOverlay() {')); + expect(overlay.match(//g)).toHaveLength(2); + expect(overlay).not.toMatch(//); + }); +});