mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
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:
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.
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 }],
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user