From 7d8c188f83adbff6eca71ee60444209ae6ec01e4 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 9 Oct 2026 03:06:06 +0200 Subject: [PATCH] fix(gemini): read Gemini CLI's composer bar and spinner line so a turn can end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A gemini session stayed "working" for good after its first turn. Its braille spinner trips the generic SPINNER_PATTERN and marks the pane working, but only a composer glyph arms the idle confirmation and gemini declared none, so it fell back to Claude's `❯`, which gemini never draws. Measured on live Gemini CLI 0.63.0 panes (capture-pane every 300 ms through real turns with a shell call at 40, 120 and 200 columns, YOLO and default approval mode, plus the raw PTY stream). The turns ran against a local stand-in for the Gemini API (GOOGLE_GEMINI_BASE_URL, which Codeman's custom endpoint support already sets), since the CLI's TUI does not depend on the backend and no account is needed for it: - The TUI repaints its whole bottom region every frame, composer included, and the composer sits between a `▄` bar and a `▀` bar; the submitted prompt is echoed between the same bars. The `▀` bar arms the idle check: every repaint carries it, tmux's reattach repaint too. The composer's prompt character is no good: it follows the approval mode (`*` in YOLO), and its `>` also starts the echoed prompt, which would make the submit verifier read a submitted prompt as stranded and press Enter again. - While a turn runs a line `⠦ Thinking... (esc to cancel, 6s)` animates about every 80 ms (largest gap mid-turn: 214 ms). The label can be any loading phrase, so the working line is the `(esc to cancel, ` suffix, or a spinner frame opening a line for when a long phrase wraps that suffix. Nothing at rest matches either. - A tool confirmation (default mode) replaces the composer, stops the spinner and the pane goes silent (3.9 s gap), so it reads as idle. Verified on an isolated instance from this branch: a YOLO turn emitted one session:working (+170 ms) and one session:idle (2.5 s after the last output); a default-mode turn went working -> idle while the confirmation waited -> working once allowed -> idle at the end; after a server restart four restored gemini panes went busy -> idle in about 4 s; a fresh launch settled in 3 s. The launch-settle and uncharacterised-CLI tests that used gemini as their example of a CLI without work detection now use grok and deepseek. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/architecture-invariants.md | 2 +- docs/wiki/Agent-CLIs.md | 7 +- src/config/cli-registry/stock.ts | 22 ++++ src/session.ts | 2 +- test/session-activity.test.ts | 173 ++++++++++++++++++++++++++++--- test/session-watching.test.ts | 4 +- 6 files changed, 192 insertions(+), 18 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index b445f159..0232bb32 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -266,7 +266,7 @@ Scoping needs no check of its own here: `setPaneExit()` already forces `paneExit So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks the SCREEN via `capturePaneText()` + `CLAUDE_WORKING_LINE_PATTERN` before believing it; a sustained run of repaints (`session-activity.ts`, pure + unit tested) is what marks a turn as started, with the same screen probe vetoing keystroke echo. Idle now lands ~3-5s after a turn ends instead of 2s into one. -⚠️ **The composer glyph and the working line are per-CLI registry DATA** (`capabilities.workDetect`, #385), not Claude constants: claude declares `❯` plus the pattern above, codex declares `›` plus `[Ee]sc to interrupt`, pi declares its composer rule `─` plus the spinner it embeds there, opencode declares its composer bar `┃` plus its 8-cell footer spinner `[⬝■]{8}`, omp declares its input row `╰─` plus the spinner and elapsed time that replace the `π` in its status bar, and a CLI that declares neither falls back to Claude's pair, which is what every session used before the registry carried one. Before that, this whole mechanism was gated Claude-mode-only on the reasoning that an external CLI has no `❯`, which was true and still left every Codex session reporting `idle` for its entire life. +⚠️ **The composer glyph and the working line are per-CLI registry DATA** (`capabilities.workDetect`, #385), not Claude constants: claude declares `❯` plus the pattern above, codex declares `›` plus `[Ee]sc to interrupt`, pi declares its composer rule `─` plus the spinner it embeds there, opencode declares its composer bar `┃` plus its 8-cell footer spinner `[⬝■]{8}`, omp declares its input row `╰─` plus the spinner and elapsed time that replace the `π` in its status bar, gemini declares its composer's bottom bar `▀` plus its `(esc to cancel, s)` spinner line, and a CLI that declares neither falls back to Claude's pair, which is what every session used before the registry carried one. Before that, this whole mechanism was gated Claude-mode-only on the reasoning that an external CLI has no `❯`, which was true and still left every Codex session reporting `idle` for its entire life. ⚠️ `workingLine` is config-supplied (a user `clis.json` can set it) and the compiled pattern runs on the PTY hot path, so it goes through `compileVersionRegex()` in BOTH the schema refine and `_workingLinePattern()`: a nested quantifier there is a ReDoS against the event loop, and the helper returns null rather than throwing so the fallback is structural. diff --git a/docs/wiki/Agent-CLIs.md b/docs/wiki/Agent-CLIs.md index 6fbe5e96..0b077926 100644 --- a/docs/wiki/Agent-CLIs.md +++ b/docs/wiki/Agent-CLIs.md @@ -69,7 +69,7 @@ output. The other CLIs expose no equivalent. | Respawn cycling and unattended runs | Yes | Yes | | Cron jobs | Yes | Yes | | Docker cases, remote SSH cases | Yes | Yes | -| Precise idle detection | Yes | Codex, Pi, OpenCode and OMP: same screen check, via their own prompt and working line. DeepSeek: reports its state itself. Others: output stabilization, coarser | +| Precise idle detection | Yes | Codex, Pi, OpenCode, OMP and Gemini: same screen check, via their own prompt and working line. DeepSeek: reports its state itself. Others: output stabilization, coarser | | Auto-resume when a usage limit resets | Yes | No | | Plan usage chip | Yes | No | | Approvals Inbox | Yes | DeepSeek yes; others no | @@ -153,6 +153,11 @@ needs `GOOGLE_CLOUD_PROJECT`, `GOOGLE_APPLICATION_CREDENTIALS`, and `GOOGLE_GENAI_USE_VERTEXAI`. That is the loosest allowlist entry in Codeman and it affects only the CLI you spawned yourself. +Working and idle come from the screen: while a turn runs, Gemini CLI draws a spinner line +(`⠦ Thinking... (esc to cancel, 6s)`) above its composer, and Codeman reads that. A tool +confirmation that waits for you shows as idle. Before 1.36.0 a Gemini session showed as +working for good after its first turn. + ### Antigravity Google's successor to the consumer Gemini CLI, invoked as `agy`. It keeps all of its state diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index e5f5fb96..249e0df8 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -790,6 +790,28 @@ const GEMINI: CliEntry = { ...agentDefaults(), altScreen: 'strip-full', echo: { policy: 'buffer', anchor: { kind: 'cursor' } }, + // Measured on a live Gemini CLI 0.63.0 pane (capture-pane every 300 ms through real + // turns with a shell call at 40, 120 and 200 columns, YOLO and default approval mode, + // plus the raw PTY stream, 2026-10-09). The TUI repaints its whole bottom region on + // every frame, composer included, and the composer sits between a `▄` bar and a `▀` + // bar; the submitted prompt is echoed between the same bars. So the `▀` bar arms the + // idle confirmation (every repaint and tmux's reattach repaint carry it), and it is the + // glyph rather than the composer's prompt character, which follows the approval mode + // (`*` in YOLO) and whose `>` also starts the echoed prompt. While a turn runs a line + // `⠦ Thinking... (esc to cancel, 6s)` animates about every 80 ms (largest gap mid-turn: + // 214 ms); the label can be any loading phrase, so the working line is the + // `(esc to cancel, ` suffix, or a spinner frame opening a line where a long phrase + // pushed that suffix onto the next one. At rest nothing on screen matches either. A tool + // confirmation (default mode) replaces the composer and stops the spinner, and the pane + // goes silent, so it reads as idle (waiting on the user). + // ⚠️ Without this entry a gemini session latched `busy` after its first turn: the braille + // spinner trips SPINNER_PATTERN, and gemini never draws Claude's `❯`, the fallback that + // would have armed the idle check. A line starting with `▀` is a bar, never prompt text, + // so the submit verifier stands down. + workDetect: { + promptGlyph: '▀', + workingLine: String.raw`\(esc to cancel, \d|(?:^|\n) ?[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] `, + }, // gemini's builder defaults an ABSENT approvalMode to 'yolo', so the clamp must // MATERIALIZE a config (not just touch an already-sent one) or a non-granted owner who // sends no geminiConfig at all would still get yolo for free. diff --git a/src/session.ts b/src/session.ts index 366c494b..d2b5ec0c 100644 --- a/src/session.ts +++ b/src/session.ts @@ -4485,7 +4485,7 @@ export class Session extends EventEmitter { : this._mux.capturePaneText?.(this._muxSession.muxName), sendEnter: () => this._mux?.sendInput(this.id, '\r'), // ⚠ NO fallback glyph here, unlike the screen-reading probe elsewhere in this file. - // Only claude, codex, pi, opencode and omp declare a promptGlyph; the other modes would fall back + // Only claude, codex, pi, opencode, omp and gemini declare a promptGlyph; the other modes would fall back // to claude's `❯`, which is ALSO starship's default shell prompt (and pure's, and // spaceship's, and p10k lean's). On a shell session the line `❯ npm run build` sits // on screen for as long as the command runs, promptStillInComposer() reads that as diff --git a/test/session-activity.test.ts b/test/session-activity.test.ts index 1dd274f4..4849f389 100644 --- a/test/session-activity.test.ts +++ b/test/session-activity.test.ts @@ -1,5 +1,6 @@ /** - * Working/idle detection for an interactive agent pane, Claude's, Codex's, pi's, opencode's and omp's. + * Working/idle detection for an interactive agent pane, Claude's, Codex's, pi's, opencode's, omp's + * and Gemini CLI's. * * The bug this pins: Claude redraws the composer (`❯`) about once a second all * the way through a turn, so the old "saw a ❯, wait 2s, call it idle" rule @@ -14,7 +15,8 @@ * * The status-line fixtures below are verbatim captures from live panes * (`tmux -L codeman capture-pane -p`) on Claude Code 2.1.220, Codex CLI 0.152.1, pi 1.1.0, - * opencode 1.3.0 and omp 18.8.6 / 18.0.11. + * opencode 1.3.0, omp 18.8.6 / 18.0.11 and Gemini CLI 0.63.0 (its turns driven by a local + * stand-in for the Gemini API, since the CLI's look does not depend on the backend). */ import { describe, expect, it, vi, afterEach } from 'vitest'; import { Session } from '../src/session.js'; @@ -49,7 +51,7 @@ function feed(session: Session, data: string): void { */ function withFakePane( screen: string | (() => string), - mode: 'claude' | 'codex' | 'pi' | 'opencode' | 'omp' = 'claude' + mode: 'claude' | 'codex' | 'pi' | 'opencode' | 'omp' | 'gemini' = 'claude' ): Session { const read = typeof screen === 'function' ? screen : () => screen; const mux = { @@ -157,6 +159,39 @@ const OMP_SPINNER_FRAME = /** The input row omp redraws when a turn is submitted and when it ends. */ const OMP_INPUT_REPAINT = '\x1b[20;1H\x1b[0m\x1b[K\x1b[38;5;239m╰─ \x1b[39;38;5;254;39m \x1b[0m'; +/** + * Gemini CLI's pane, verbatim from live 0.63.0 captures (bars shortened). The composer + * sits between a `▄` bar and a `▀` bar, and a running turn draws a spinner line above it. + */ +const GEM_COMPOSER = + `${'─'.repeat(40)}\n YOLO Ctrl+Y\n${'▄'.repeat(40)}\n * Type your message or @path/to/file\n` + + `${'▀'.repeat(40)}\n workspace sandbox\n /tmp/.../gem-turn no sandbox …\n`; +const GEM_WORKING = + '╭──────────────────────────────────╮\n│ ⊶ Shell sleep 8; echo done │\n' + + `╰──────────────────────────────────╯\n ⠦ Thinking... (esc to cancel, 6s)\n${GEM_COMPOSER}`; +const GEM_AT_REST = + '✦ The sleep command pauses the shell for\n the number of seconds it is given,\n' + + ` before printing done.\n${GEM_COMPOSER}`; +/** + * Default approval mode: the confirmation replaces the composer and the spinner stops. + * The submitted prompt above it is echoed between the composer's own bars. + */ +const GEM_CONFIRM = + `${'▄'.repeat(40)}\n > Run sleep 8 in the shell, then explain what sleep does.\n${'▀'.repeat(40)}\n` + + '╭──────────────────────────────────╮\n│ ? Shell sleep 8; echo done │\n' + + '│ Allow execution of [Shell]? │\n│ │\n' + + '│ ● 1. Allow once │\n│ 2. Allow for this session │\n' + + '│ 3. No, suggest changes (esc) │\n╰──────────────────────────────────╯\n'; +/** One spinner frame on the wire (every ~80 ms), verbatim: the line starts with a cursor move. */ +const GEM_SPINNER_FRAME = + '\x1b[9;1H\x1b(B\x1b[m \x1b[38;5;111m⠧\x1b[39m \x1b[38;5;231m\x1b[3mThinking...\x1b(B\x1b[m ' + + `\x1b[38;5;145m(esc to cancel, 8s)\r\n\x1b[15;1H\x1b[49m\x1b[38;5;59m${'▀'.repeat(40)}`; +/** The turn's last repaint, verbatim (shortened): no spinner line, the composer bars redrawn. */ +const GEM_TURN_END = + `\x1b[?2026h\x1b[11;1H\x1b(B\x1b[m \x1b[38;5;211mYOLO\x1b[38;5;145m Ctrl+Y\x1b[13;1H\x1b[38;5;59m${'▄'.repeat(40)}` + + '\x1b[14;1H\x1b[39m\x1b[48;5;59m \x1b[38;5;211m* \x1b[39m\x1b[7m \x1b(B\x1b[m\x1b[38;5;145m\x1b[48;5;59m Type your message' + + ` or @path/to/file\x1b[39m \x1b[15;1H\x1b[49m\x1b[38;5;59m${'▀'.repeat(40)}\x1b[?2026l`; + /** A composer repaint: the frame Claude ships roughly once a second while working. */ const COMPOSER_REPAINT = '\x1b[31;1H\x1b[38;5;246m❯\xa0\x1b[39m\x1b[0m\x1b[33;1H \x1b[38;5;246mOpus 5 in:143,699 out:669 ctx:14%\x1b[39m'; @@ -337,11 +372,11 @@ describe('Session interactive idle detection', () => { it('does not mark an uncharacterised CLI working off raw activity', () => { vi.useFakeTimers(); - // Gemini and OpenCode render their own TUIs, and Codeman knows neither one's glyph, - // so nothing would arm the idle confirmation and a session marked working here would - // never recover. A CLI that names no glyph therefore reports no work at all. - expect(getCli('gemini')?.capabilities.workDetect).toBeUndefined(); - const session = new Session({ workingDir: '/tmp', mode: 'gemini' }); + // Grok renders its own TUI, and Codeman knows no glyph for it, so nothing would arm + // the idle confirmation and a session marked working here would never recover. A CLI + // that names no glyph therefore reports no work at all. + expect(getCli('grok')?.capabilities.workDetect).toBeUndefined(); + const session = new Session({ workingDir: '/tmp', mode: 'grok' }); const events: string[] = []; session.on('working', () => events.push('working')); @@ -486,8 +521,8 @@ describe('external CLI launch settle', () => { vi.useFakeTimers(); // Nothing arms an idle confirmation for a CLI that names no composer glyph, so // the launch timer is the only thing that can ever settle this pane. - expect(getCli('gemini')?.capabilities.workDetect).toBeUndefined(); - const session = new Session({ workingDir: '/tmp', mode: 'gemini' }); + expect(getCli('grok')?.capabilities.workDetect).toBeUndefined(); + const session = new Session({ workingDir: '/tmp', mode: 'grok' }); const events = launch(session); feed(session, '\x1b[5;3H⠋ Loading'); @@ -529,7 +564,7 @@ describe('external CLI launch settle', () => { it('still settles a prompted CLI without work detection, which has nothing else to settle it', () => { vi.useFakeTimers(); - const session = new Session({ workingDir: '/tmp', mode: 'gemini' }); + const session = new Session({ workingDir: '/tmp', mode: 'grok' }); const events = launch(session); vi.advanceTimersByTime(2000); @@ -542,8 +577,8 @@ describe('external CLI launch settle', () => { it('settles a RESTORED pane of a CLI without work detection, without a refetch', () => { vi.useFakeTimers(); // A Codeman restart re-attaches every surviving pane through startInteractive(), - // which leaves it busy; gemini and antigravity have no glyph that would ever clear that. - for (const mode of ['gemini', 'antigravity', 'shell'] as const) { + // which leaves it busy; grok and deepseek have no glyph that would ever clear that. + for (const mode of ['grok', 'deepseek', 'shell'] as const) { expect(getCli(mode)?.capabilities.workDetect).toBeUndefined(); const session = new Session({ workingDir: '/tmp', mode }); const events = launch(session, true); @@ -831,6 +866,118 @@ describe('opencode interactive idle detection', () => { }); }); +describe("gemini's work-detection descriptor", () => { + const gem = getCli('gemini')?.capabilities.workDetect; + const working = () => new RegExp(gem!.workingLine); + + it('matches the spinner line while a turn runs, on screen and on the wire', () => { + expect(working().test(GEM_WORKING)).toBe(true); + // The stream detector reads the ANSI-stripped chunk, where a cursor move, not a + // newline, opens the spinner line: the `(esc to cancel` half is what matches there. + expect(working().test(stripAnsi(GEM_SPINNER_FRAME))).toBe(true); + // A long loading phrase can push the suffix onto the next line on a narrow pane + // (constructed, not captured): the spinner frame opening its line still matches. + expect(working().test(' ⠦ Reticulating the splines for your\n request... (esc to cancel,\n')).toBe(true); + }); + + it('does not match a pane at rest, nor a pending tool confirmation', () => { + expect(working().test(GEM_AT_REST)).toBe(false); + expect(working().test(GEM_CONFIRM)).toBe(false); + }); + + it('names the composer bar every repaint carries, so the idle check can arm', () => { + expect(GEM_SPINNER_FRAME).toContain(gem!.promptGlyph); + expect(GEM_TURN_END).toContain(gem!.promptGlyph); + expect(GEM_AT_REST).toContain(gem!.promptGlyph); + }); + + it('leaves the submit verifier unable to press Enter on a gemini pane', () => { + // The last row starting with `▀` is a bar, never prompt text, even when the echoed + // prompt sits just above it while a confirmation waits. + const typed = GEM_COMPOSER.replace('Type your message or @path/to/file', 'say ok'); + expect(promptStillInComposer(typed, 'say ok', gem!.promptGlyph)).toBe(false); + expect(promptStillInComposer(GEM_AT_REST, 'say ok', gem!.promptGlyph)).toBe(false); + expect(promptStillInComposer(GEM_CONFIRM, 'Run sleep 8 in the shell', gem!.promptGlyph)).toBe(false); + }); +}); + +describe('gemini interactive idle detection', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + function spin(session: Session, ms: number): void { + for (let t = 0; t < ms; t += 80) { + feed(session, GEM_SPINNER_FRAME); + vi.advanceTimersByTime(80); + } + } + + it('lets a turn end, instead of latching busy', () => { + vi.useFakeTimers(); + let screen = GEM_WORKING; + const session = withFakePane(() => screen, 'gemini'); + const events: string[] = []; + session.on('working', () => events.push('working')); + session.on('idle', () => events.push('idle')); + + spin(session, 8000); + expect(events).toEqual(['working']); + expect(session.status).toBe('busy'); + + // Before gemini declared its bar and spinner line, nothing ever armed the idle check + // (gemini never draws `❯`), so this session stayed busy for good. + screen = GEM_AT_REST; + feed(session, GEM_TURN_END); + vi.advanceTimersByTime(20_000); + + expect(events).toEqual(['working', 'idle']); + expect(session.status).toBe('idle'); + }); + + it('reads a pending tool confirmation as idle, and ends the resumed turn too', () => { + vi.useFakeTimers(); + let screen = GEM_WORKING; + const session = withFakePane(() => screen, 'gemini'); + const events: string[] = []; + session.on('working', () => events.push('working')); + session.on('idle', () => events.push('idle')); + + spin(session, 3000); + + // The confirmation replaces the composer and the pane goes silent: waiting on the user. + screen = GEM_CONFIRM; + feed(session, `\x1b[5;1H${'▀'.repeat(40)}\x1b[7;1H│ Allow execution of [Shell]?`); + vi.advanceTimersByTime(10_000); + expect(events).toEqual(['working', 'idle']); + + // Allowed: the spinner line returns with every repaint carrying the bar. + screen = GEM_WORKING; + spin(session, 4000); + expect(events).toEqual(['working', 'idle', 'working']); + + screen = GEM_AT_REST; + feed(session, GEM_TURN_END); + vi.advanceTimersByTime(20_000); + expect(events).toEqual(['working', 'idle', 'working', 'idle']); + expect(session.status).toBe('idle'); + }); + + it('settles a reattached gemini pane that is at rest', () => { + vi.useFakeTimers(); + const session = withFakePane(GEM_AT_REST, 'gemini'); + (session as unknown as { _status: string })._status = 'busy'; + const events: string[] = []; + session.on('idle', () => events.push('idle')); + + feed(session, GEM_TURN_END); + vi.advanceTimersByTime(20_000); + + expect(events).toEqual(['idle']); + expect(session.status).toBe('idle'); + }); +}); + describe('wire activity stamp across recovery', () => { // The stamp both home screens sort the quiet group on. Recovery restores the // previous run's value, and the settle window keeps the boot attach repaint diff --git a/test/session-watching.test.ts b/test/session-watching.test.ts index 2f935ba0..c85d1661 100644 --- a/test/session-watching.test.ts +++ b/test/session-watching.test.ts @@ -373,8 +373,8 @@ describe('Session.watching', () => { it('reports nothing for a CLI whose screen nobody has characterised', () => { vi.useFakeTimers(); - expect(getCli('gemini')?.capabilities.workDetect).toBeUndefined(); - const session = withFakePane(WITH_MONITOR, 'gemini'); + expect(getCli('grok')?.capabilities.workDetect).toBeUndefined(); + const session = withFakePane(WITH_MONITOR, 'grok'); runAndSettle(session); expect(session.watching).toBeNull(); });