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

+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