fix(antigravity): show the working indicator while an agy turn runs

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.
This commit is contained in:
Devvyn committed 2026-10-18 00:45:52 +08:00
1 parent de99a6d57a
commit 4a47ead824
5 files changed
+157 -3

No files matched your search

@@ -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.
+1 -1
View File
@@ -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, <n>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, <n>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.
+11 -1
View File
@@ -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 `> <text>` 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.
+18
View File
@@ -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 `> <text>`
// 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 }],
+122 -1
View File
@@ -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