From 4a47ead824d7c190f7c7ca3dd5c4b3e6ab3b271d Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:55:42 +0800 Subject: [PATCH] fix(antigravity): show the working indicator while an agy turn runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agy animates an 8-dot braille spinner (⣯ ⡿ ⣻ ⣾ ⣷ ⣟ ⢿ ⣽) with a label one row above the composer, none of which is in SPINNER_PATTERN, and the entry declared no workDetect, so no frame ever marked a tab working. Declare the working line from the live pane and the footer hint `? for shortcuts` as the idle-arming mark. The composer's `>` cannot be the glyph: the echoed prompt starts with it too. Tests use verbatim captures from agy 1.3.3. --- .changeset/antigravity-working-indicator.md | 5 + docs/architecture-invariants.md | 2 +- docs/wiki/Agent-CLIs.md | 12 +- src/config/cli-registry/stock.ts | 18 +++ test/session-activity.test.ts | 123 +++++++++++++++++++- 5 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 .changeset/antigravity-working-indicator.md diff --git a/.changeset/antigravity-working-indicator.md b/.changeset/antigravity-working-indicator.md new file mode 100644 index 00000000..fe69e3d7 --- /dev/null +++ b/.changeset/antigravity-working-indicator.md @@ -0,0 +1,5 @@ +--- +'aicodeman': patch +--- + +An Antigravity (`agy`) tab now shows that it is working. Its spinner animates a braille set Codeman did not recognise and the entry declared no work detection, so a running turn never marked the tab working and the activity indicator stayed off. The entry now declares what a turn draws (`⣯ Generating...`, `Working...`, `Loading...`) and its footer hint (`? for shortcuts`) as the idle mark, so a finished turn, or a shell call waiting for approval, reads as idle again. diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 896e8d73..24209a65 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -272,7 +272,7 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t ⚠️ **The workspace-trust dialog is not a composer.** Its `❯` sits on `No, exit`, so it arms the confirmation, and the dialog is quiet with no working line, so a fresh case used to be concluded idle about 4 s after launch while the dialog was still up (measured on Claude Code 2.1.294: `GET .../wait?until=idle` answered `idle` at 3.8 s in 6 of 12 fresh-dir launches). An agent that waited for idle and then prompted typed into the dialog, and the prompt's Enter confirmed `No, exit`: the pane died and the wait reported `exit`. `_probePaneWorking()` therefore also records whether its capture shows `isTrustDialogScreen()` (a failed capture keeps the last answer, since it proves nothing), and `_confirmIdle()` re-checks on `PANE_PROBE_RECHECK_MS` instead of concluding while it does, but only inside the auto-accept's own `TRUST_DIALOG_WINDOW_MS`: a later screen that merely quotes the dialog must not hold a session busy. The status is left alone rather than marked working, since nothing is running. Tests: `test/session-trust-dialog.test.ts`. -⚠️ **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. +⚠️ **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, antigravity declares the head of its footer hint `? for shortcuts` (`? for sh`, drawn only at rest, so nothing arms the idle check during a turn and a pending shell approval reads as working until it is answered; the composer's `>` is also the echoed prompt's) plus its 8-dot braille spinner row `⣯ Generating...`, 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 ea3da118..d1d1f581 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, OMP and Gemini: same screen check, via their own prompt and working line. DeepSeek: reports its end of turn itself. Antigravity and Grok: generic cues only, unreliable | +| Precise idle detection | Yes | Codex, Pi, OpenCode, OMP, Gemini and Antigravity: same screen check, via their own prompt and working line. DeepSeek: reports its end of turn itself. Grok: generic cues only, unreliable | | Auto-resume when a usage limit resets | Yes | No | | Plan usage chip | Yes | No | | Approvals Inbox | Yes | DeepSeek and Codex as alerts (answer in the terminal); others no | @@ -184,6 +184,16 @@ Google's successor to the consumer Gemini CLI, invoked as `agy`. It keeps all of in `~/.gemini/antigravity-cli/`, so the credential handling that applies to Gemini applies to it as well. +- **Work detection reads its spinner row.** While a turn runs, `agy` animates an 8-dot braille + frame and a label one row above the composer (`⣯ Generating...`; `Working...` and + `Loading...` too), and its footer reads `esc to cancel`. Codeman matches that row, which is + what puts the activity indicator on the tab. The frames are not the braille set Claude and + Gemini use, which is why the tab used to look idle through a whole turn. +- **At rest the footer hint is the idle mark.** The composer's `>` is no good for that, since + the submitted prompt is echoed as `> ` as well. `? for shortcuts` is drawn only at + rest, and the turn's last repaint carries it. A shell call that waits for approval replaces + the composer, so the tab reads as idle (waiting on you) until you answer. + ### Pi Pi needs the opposite instincts from every other CLI here. diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index fc874532..00c602ac 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -996,6 +996,24 @@ const ANTIGRAVITY: CliEntry = { ...AGENTS_DIR_SKILL, altScreen: 'strip-mux-only', echo: { policy: 'buffer', anchor: { kind: 'cursor' } }, + // Measured on a live `agy` 1.3.3 pane (capture-pane every second through a prose turn + // and a shell-call turn, plus the raw PTY stream, 2026-10-11). A running turn animates + // `⣯ Generating...` (the label also reads `Working...` and `Loading...`) one row above + // the composer, and the footer's left end reads `esc to cancel`; at rest the footer + // reads `? for shortcuts` and nothing else on screen matches. A shell call replaces the + // composer with a `Run this command?` list, the spinner stops, and the pane goes + // silent, so it reads as idle (waiting on the user), like gemini. + // ⚠️ The frames are the 8-dot braille set (`⣯ ⡿ ⣻ ⣾ ⣷ ⣟ ⢿ ⣽`), none of which is in + // SPINNER_PATTERN's Claude set, and this entry used to declare nothing, so no frame + // ever marked an antigravity tab working and it never showed the activity spinner. + // The composer's `>` is no arming mark: the submitted prompt is echoed as `> ` + // too. The footer hint is drawn only at rest, and the turn's last repaint carries it. + // The schema caps a glyph at 8 characters, so this is the head of `? for shortcuts`. It also + // keeps the submit verifier inert (no row starts with it and holds prompt text). + workDetect: { + promptGlyph: '? for sh', + workingLine: String.raw`[⠀-⣿] [A-Z][A-Za-z ]{2,30}(?:\.\.\.|…)`, + }, // Like codex: an ABSENT config already defaults safe (no bypass flag), so only a // SENT config needs the flag forced off — nothing is materialized. privilegedParams: [{ param: 'dangerouslySkipPermissions', clampTo: false }], diff --git a/test/session-activity.test.ts b/test/session-activity.test.ts index 4849f389..3635860a 100644 --- a/test/session-activity.test.ts +++ b/test/session-activity.test.ts @@ -51,7 +51,7 @@ function feed(session: Session, data: string): void { */ function withFakePane( screen: string | (() => string), - mode: 'claude' | 'codex' | 'pi' | 'opencode' | 'omp' | 'gemini' = 'claude' + mode: 'claude' | 'codex' | 'pi' | 'opencode' | 'omp' | 'gemini' | 'antigravity' = 'claude' ): Session { const read = typeof screen === 'function' ? screen : () => screen; const mux = { @@ -978,6 +978,127 @@ describe('gemini interactive idle detection', () => { }); }); +/** + * Antigravity's (`agy` 1.3.3) pane, verbatim from live captures (rules shortened). A running + * turn animates an 8-dot braille frame and a label one row above the composer, and the + * footer reads `esc to cancel`; at rest the footer reads `? for shortcuts`. The composer + * row is `>`, which the echoed prompt starts with as well. + */ +const AGY_RULE = '─'.repeat(48); +const AGY_FOOTER_REST = '? for shortcuts Gemini 3.8 Flash · medium\n'; +const AGY_FOOTER_WORKING = 'esc to cancel Gemini 3.8 Flash · medium\n'; +const AGY_WORKING = `> say ok\n⣷ Generating...\n${AGY_RULE}\n>\n${AGY_RULE}\n${AGY_FOOTER_WORKING}`; +const AGY_AT_REST = ` ok\n${AGY_RULE}\n>\n${AGY_RULE}\n${AGY_FOOTER_REST}`; +/** A shell call replaces the composer with a confirmation list and the spinner stops. */ +const AGY_CONFIRM = + '> Run the shell command: sleep 6 && echo done\n sleep 6 && echo done\nRun this command?\n' + + "> 1. Yes, run command\n 2. Yes, and always allow in this conversation for commands that start with 'sleep'\n" + + ' 4. No, cancel\n ↑/↓ Navigate · tab Amend · ctrl+g edit/expand command\n'; +/** One spinner frame on the wire, verbatim: the glyph is not one of SPINNER_PATTERN's. */ +const AGY_SPINNER_FRAME = '\r\n\x1b[34;1m> say ok\x1b[m\x1b[K\r\n⣷ \x1b[90mGenerating...\x1b[m\x1b[K\r\n'; +/** The turn's last repaint, verbatim (shortened): the footer hint comes back. */ +const AGY_TURN_END = `${AGY_RULE}\x1b[m\r\n\x1b[90m? for shortcuts\x1b[m\x1b[80X\x1b[80C\x1b[2mGemini 3.8 Flash · medium\r`; + +describe("antigravity's work-detection descriptor", () => { + const agy = getCli('antigravity')?.capabilities.workDetect; + const working = () => new RegExp(agy!.workingLine); + + it('matches every frame of the spinner and every label, on screen and on the wire', () => { + expect(working().test(AGY_WORKING)).toBe(true); + expect(working().test(stripAnsi(AGY_SPINNER_FRAME))).toBe(true); + for (const frame of ['⣯', '⡿', '⣻', '⣾', '⣷', '⣟', '⢿', '⣽']) { + for (const label of ['Generating...', 'Working...', 'Loading...']) { + expect(working().test(`${frame} ${label}`)).toBe(true); + } + } + }); + + it('does not match a pane at rest, a pending confirmation, or prose', () => { + expect(working().test(AGY_AT_REST)).toBe(false); + expect(working().test(AGY_CONFIRM)).toBe(false); + expect(working().test('Loading... is what the spinner label reads\n')).toBe(false); + }); + + it('names the footer hint the turn end carries, and no working repaint does', () => { + expect(AGY_TURN_END).toContain(agy!.promptGlyph); + expect(AGY_AT_REST).toContain(agy!.promptGlyph); + expect(AGY_WORKING).not.toContain(agy!.promptGlyph); + expect(AGY_SPINNER_FRAME).not.toContain(agy!.promptGlyph); + }); + + it('leaves the submit verifier unable to press Enter on an antigravity pane', () => { + // The composer row starts with `>`, but the glyph is the footer hint, so a prompt + // typed into the composer (or echoed above it) is never read as "still unsubmitted". + const typed = AGY_AT_REST.replace('>\n', '> say ok\n'); + expect(promptStillInComposer(typed, 'say ok', agy!.promptGlyph)).not.toBe(true); + expect(promptStillInComposer(AGY_WORKING, 'say ok', agy!.promptGlyph)).not.toBe(true); + expect(promptStillInComposer(AGY_CONFIRM, 'Run the shell command', agy!.promptGlyph)).not.toBe(true); + }); +}); + +describe('antigravity interactive activity', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + function spin(session: Session, ms: number): void { + for (let t = 0; t < ms; t += 100) { + feed(session, AGY_SPINNER_FRAME); + vi.advanceTimersByTime(100); + } + } + + it('shows a turn as working, and lets it end instead of latching busy', () => { + vi.useFakeTimers(); + let screen = AGY_WORKING; + const session = withFakePane(() => screen, 'antigravity'); + (session as unknown as { _status: string })._status = 'idle'; + const events: string[] = []; + session.on('working', () => events.push('working')); + session.on('idle', () => events.push('idle')); + + // Before antigravity declared its spinner line no frame ever marked the tab working. + spin(session, 4000); + expect(events).toEqual(['working']); + expect(session.status).toBe('busy'); + + screen = AGY_AT_REST; + feed(session, AGY_TURN_END); + vi.advanceTimersByTime(20_000); + + expect(events).toEqual(['working', 'idle']); + expect(session.status).toBe('idle'); + }); + + it('reads a pending shell confirmation as idle, and ends the resumed turn too', () => { + vi.useFakeTimers(); + let screen = AGY_WORKING; + const session = withFakePane(() => screen, 'antigravity'); + (session as unknown as { _status: string })._status = 'idle'; + const events: string[] = []; + session.on('working', () => events.push('working')); + session.on('idle', () => events.push('idle')); + + spin(session, 3000); + expect(events).toEqual(['working']); + + screen = AGY_CONFIRM; + feed(session, 'Run this command?\r\n'); + vi.advanceTimersByTime(10_000); + // The footer hint is not drawn under the confirmation, so nothing armed the check: + // the pane stays working until the user answers and the turn ends below. + screen = AGY_WORKING; + spin(session, 3000); + screen = AGY_AT_REST; + feed(session, AGY_TURN_END); + vi.advanceTimersByTime(20_000); + + expect(events[0]).toBe('working'); + expect(events[events.length - 1]).toBe('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