diff --git a/docs/specs/alert.md b/docs/specs/alert.md index 1929aeef9..8573f2ea7 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -66,13 +66,13 @@ A Session is engaged while some present viewer focuses it. | Gesture | Acknowledges | |---|---| -| typing into the pane (CSI/SS3 key encodings included), a paste, a file drop, the mobile input bar or a gesture key, a remote Client's write | with input | +| typing into the pane (CSI/SS3 key encodings included), a reported wheel tick, a paste, a file drop, the mobile input bar or a gesture key, a remote Client's write | with input | | a Pane body or header click, zoom or unzoom, the dev-server chip, a Workspace tab's TODO pill on the member it enters, entering passthrough by keyboard, a Door click or `Enter`, a mobile tap | without input | -| a terminal reply, a mouse-only report chunk, `d` reattach, a spawn, split, or promotion, a `dor` reveal, an embed focusing itself, DOM focus alone | nothing | +| a terminal reply, a hover or button report, `d` reattach, a spawn, split, or promotion, a `dor` reveal, an embed focusing itself, DOM focus alone | nothing | -- **Never treat terminal replies or mouse-only report chunks as keyboard input** (rationale). +- **Never treat terminal replies or hover or button reports as input** (rationale). - **Must write all human-originated PTY input through `writeUserInput`**, the mobile input bar's included: it carries `userInput`, and the host acknowledges it with input before the bytes reach the PTY, in the one message (rationale). -- **A remote Client's write must acknowledge the same way unless it holds only mouse reports**, the Burrow having dropped a mirror's terminal replies (rationale). +- **A remote Client's write must acknowledge the same way unless it holds only hover or button reports**, the Burrow having dropped a mirror's terminal replies (rationale). - **Never create an Activity entry to acknowledge**, the host's one check, so a browser Surface keeps its own TODO. - **User input must open the echo window** (`cfg.alert.echoWindow`) even when acknowledging clears nothing, a helper's included. Output inside it is ignored entirely — detector, `outputSince`, the acknowledged state, awaits — and a completion inside it neither rings nor is held (rationale). @@ -244,7 +244,7 @@ The output/silence detector is always on, one per Session for its whole lifetime **Must use the detector as the single output-activity signal for status, inferred completion, and alert deferral**; its states are the Output detector region in Completion events. A single accepted output chunk leaves a quiet detector quiet, and only confirmed work can settle. -**A retired id must stay retired.** A host removes the alert entry in the same turn as its kill, ahead of any output still in flight. **Never let raw output or resizes revive a retired id**; a semantic or protocol event may (rationale). +**A retired id must stay retired.** A host removes the alert entry in the same turn as its kill, ahead of any output still in flight. **Never let raw output or input revive a retired id**; a semantic or protocol event may (rationale). Rules: @@ -258,14 +258,14 @@ Rules: Limitation: WATCHING needs the shell to report its command line — `OSC 633 ; E`, or `cmdline_url` / `cmdline` on `OSC 133 ; C` (fish ≥ 4) — beside the boundaries. A shell reporting bare `OSC 133` boundaries, or none (`docs/specs/terminal-state.md` → Shell-integration injection), never names a command, so WATCHING never engages and the terminal context reports `no command`. Terminal reports still work; command-exit alerting also requires semantic command boundaries. **Never route the keystroke fallback in `docs/specs/terminal-state.md` into the `AlertManager`** (rationale). -**Must count only output that changes the screen**: a chunk of only queries, mode and color changes, or cursor motion is not output (rationale), nor are theme changes, remounts, DOM reparenting, selection, focus changes, or resize redraw noise during `T_RESIZE_DEBOUNCE`, a grace the host opens before it resizes the PTY, a Client's resize included. Invariants: +**Must count only output that changes the screen**: a chunk of only queries, mode and color changes, or cursor motion is not output (rationale), nor are theme changes, remounts, DOM reparenting, or selection. **A busy run must begin within `cfg.alert.workStartWindow` of input or an active `OSC 9;4` report**; input is any PTY write but a terminal reply or mouse report, a wheel tick included. Later output, such as a focus or resize repaint, is ignored entirely (rationale). Invariants: - **Must preserve an owed `watching` source when output resumes**; Completion events owns deferral (rationale). - **Never reset the detector for engagement alone**; settles must reach awaits. - **Must reset to `NOTHING_TO_SHOW` when a user verb or an await clears a ring with a `watching` source**, preventing its output tail from settling again; resumed work and rule removal leave it running. - **New WATCHING summons must be caused by a fresh settle**, never rerender, theme change, remount, minimize, or reattach; Completion events owns showing a deferred summons. -Source of truth: `commandWatchKey` / `watchRuleFor` in `lib/src/lib/terminal-state.ts`; `QuiesceDetector` in `lib/src/lib/quiesce-detector.ts`; `outputPaints` in `lib/src/lib/terminal-controls.ts`; `onSettled` in `lib/src/lib/alert-manager.ts`; multi-renderer coordinator `lib/src/lib/watched-command-host.ts`. +Source of truth: `commandWatchKey` / `watchRuleFor` in `lib/src/lib/terminal-state.ts`; `QuiesceDetector` in `lib/src/lib/quiesce-detector.ts`; `outputPaints` in `lib/src/lib/terminal-controls.ts`; `inputCanStartWork` in `lib/src/lib/terminal-report-filter.ts`; `onSettled` in `lib/src/lib/alert-manager.ts`; multi-renderer coordinator `lib/src/lib/watched-command-host.ts`. ## Terminal reports @@ -316,7 +316,7 @@ Detail follows one richness order: a text report (`OSC 9`, `OSC 99`, `OSC 777`) | Verb | Effect | |---|---| | acknowledge without input (Engagement), or dismiss (`a`, Pane Header) | clears the ring, leaving `todo` on with its detail; with nothing ringing or held, no change | -| acknowledge with input — any keystroke, paste, or drop — or clear TODO (pill click) | clears the ring; `todo` off, notification dropped, even if already off (rationale) | +| acknowledge with input — any keystroke, wheel tick, paste, or drop — or clear TODO (pill click) | clears the ring; `todo` off, notification dropped, even if already off (rationale) | | toggle TODO (`t`) | clears the ring; `todo` flips, on keeping the ring's detail, off dropping the notification | | an await consumes a source (Await) | withdraws that source | | a rule stops covering the `watching` key | withdraws `watching` | diff --git a/docs/specs/alert.rationale.md b/docs/specs/alert.rationale.md index 33a514bdb..be5529c5b 100644 --- a/docs/specs/alert.rationale.md +++ b/docs/specs/alert.rationale.md @@ -24,7 +24,9 @@ **Why each VS Code webview is its own viewer.** The webviews share one manager. With one attention slot, a webview's window blur sent an id-less clear that wiped the attention another webview had just set, arming its running build (audit, 2026-09-23). -**Why the reply classifier is the narrow one.** The prompt recorder filters every CSI/SS3 sequence because it cannot interpret them as command text. Reusing that filter here ignored real encoded keys, while its omission of DCS let device-query replies acknowledge background panes; regression tests reproduced both failures in September 2026. The narrower reply classifier already used for replay and untouched-session tracking separates keys from replies without changing live PTY forwarding. Mouse-only chunks need a separate guard: the narrower classifier does not include mouse encodings, and inside programs can request hover and wheel reports without a click. Those reports otherwise dismiss a ring and cancel its pending alarms. Actual clicks already acknowledge through the Pane's DOM handler. A Client's write carries no mark — protocol-v1's `terminal.write` is bytes alone — so a Pocket scroll in tmux or `vim` cleared the ring and TODO until the host classified the bytes itself (review, 2026-09-24). +**Why the reply classifier is the narrow one.** The prompt recorder filters every CSI/SS3 sequence because it cannot interpret them as command text. Reusing that filter here ignored real encoded keys, while its omission of DCS let device-query replies acknowledge background panes; regression tests reproduced both failures in September 2026. The narrower reply classifier already used for replay and untouched-session tracking separates keys from replies without changing live PTY forwarding. Mouse reports need a separate guard: the narrower classifier does not include mouse encodings, and inside programs can request hover reports without a click. Those reports otherwise dismiss a ring and cancel its pending alarms. Actual clicks already acknowledge through the Pane's DOM handler. A Client's write carries no mark — protocol-v1's `terminal.write` is bytes alone — so a Pocket tap in tmux or `vim` cleared the ring and TODO until the host classified the bytes itself (review, 2026-09-24). + +**Why a wheel tick is input.** Scrolling a program that asked for wheel reports, such as Claude Code's transcript, repaints it for as long as the scroll lasts; counted as nothing, a scroll through a finished turn never answered its ring (report, 2026-10-21). As input it acknowledges as typing does, clearing the TODO, as it already did on an alternate screen without mouse reporting, where xterm sends the wheel as arrow keys. It never opens the work-start window: scrolling asks for no work, and a repaint that trails the scroll or a status-line tick after it would otherwise start a run (review, 2026-10-21). Nor does it end a selection, which a scroll moved past before. A hover or button report stays nothing, a click acknowledging through the Pane instead. **Why human input rides its write.** The echo of a keystroke comes back from the PTY within milliseconds, so the acknowledgement that opens the echo window has to be in effect before the bytes are written. As a separate command it would race the write across the host boundary, and an echo that won would count as the program working. @@ -98,6 +100,8 @@ **Why only output that paints counts.** Two diagnostic reports (2026-10-18) showed a finished Claude Code holding its ring deferred for over ten minutes: idle, it sends `CSI ? 6 n` every 200 ms for as long as the terminal answers (Claude Code 2.1.303; unanswered, it asks once), so the detector never saw 5 s of silence. A third showed Codex answering a click with frames that only reset colors and move the cursor, which looked like work and rang `codex went quiet` once the user left. Discounting only known-inert sequences keeps every misread on the side of the old count. +**Why a busy run must follow input.** Two reports (2026-10-19) showed a click ringing `claude went quiet`: the focus report made an idle Claude Code repaint text, once 1.4 s later and for 1.8 s, and the settle rang after the user left. In both reports every real turn either followed a keystroke by about a second or opened with Claude's own `OSC 9;4` cycle; neither a click nor its repaint does either. The gate replaced a 500 ms grace after each resize, which covered one cause of the same repaint. Any write but a reply or a mouse report opens it, so `dor send` and launches still start work, and a progress report opens it so a turn Claude starts unasked, such as a background task waking it, still defers an owed ring. A command whose first output comes more than the window after Enter never reads as busy, and output resuming after a mid-run settle with no input or report inside the window is ignored like any other: an owed ring stays up through it, and a later await resolves on that ring. Letting an owed ring reopen the gate would bring back a resize or status-line repaint deferring a ring that is showing; agents' spinners never leave a turn silent long enough to settle. + **Why resumed work defers an owed ring.** The marked `ttr.pgstencil-adopt` speech (2026-09-09 18:17:03) followed a WATCHING settle and resumed output. Withdrawing only confirmed WATCHING left program-sent reports ringing through animation. Deferral preserves the debt; the same episode and original deadlines prevent alarm replay and avoid adding another full delay after quiet (product decision, 2026-10-01). ## Terminal reports diff --git a/lib/src/cfg.ts b/lib/src/cfg.ts index c7a635f37..dbb9fc5a9 100644 --- a/lib/src/cfg.ts +++ b/lib/src/cfg.ts @@ -22,8 +22,10 @@ export const cfg = { mightNeedAttention: 2_000, /** ms — additional silence before confirming NEEDS_ATTENTION. */ needsAttentionConfirm: 3_000, - /** ms — ignore resize redraw noise. */ - resizeDebounce: 500, + /** ms — after input reaches the program, how long its output may still + * begin a busy run. Output that begins later — a repaint on focus or + * resize, a status-line tick — is not work. */ + workStartWindow: 5_000, /** ms — default inactivity timeout: presence lapses this long after the last typing, mouse, or scroll input. */ inactivityTimeout: 15_000, /** ms — shared grace period for keystroke echo AND fast command completions. diff --git a/lib/src/host/owner-pty.ts b/lib/src/host/owner-pty.ts index 18a399bc5..005d6d9bd 100644 --- a/lib/src/host/owner-pty.ts +++ b/lib/src/host/owner-pty.ts @@ -15,7 +15,7 @@ import { import type { TerminalSemanticEvent } from '../lib/terminal-state'; import { ToolLaunchLatch } from '../lib/tool-launch-latch'; import { outputPaints } from '../lib/terminal-controls'; -import { stripMouseReportsFromInput } from '../lib/terminal-report-filter'; +import { stripPassiveMouseReportsFromInput } from '../lib/terminal-report-filter'; /** * What the process that owns a PTY does with it on behalf of its alerts, the @@ -88,36 +88,31 @@ export function createOwnerPtyStream(id: string, options: OwnerPtyStreamOptions) }); } -/** The slice of a host's PTY manager that input and size changes reach. */ +/** The slice of a host's PTY manager that input reaches. */ export interface PtyWriteTarget { write(id: string, data: string, options?: { paced?: boolean }): void; - resize(id: string, cols: number, rows: number, repaint?: boolean): void; } /** - * A host's PTY writes and resizes as its alerts must see them, whoever asked — - * a local renderer or a remote Client: **human input is acknowledged, echo - * window opened, before its bytes reach the PTY**, and a resize opens its grace - * window before the program repaints (`docs/specs/alert.md` → Engagement). + * A host's PTY writes as its alerts must see them, whoever asked — a local + * renderer, a remote Client, or a `dor` verb: **alerts hear each write + * (`AlertManager.onWrite`) before its bytes reach the PTY** + * (`docs/specs/alert.md` → Engagement). */ export function alertedPty(alerts: AlertManager, pty: PtyWriteTarget) { const write = (id: string, data: string, options: { paced?: boolean; userInput?: boolean } = {}): void => { - if (options.userInput) alerts.acknowledge(id, { input: true }); + alerts.onWrite(id, data, options); pty.write(id, data, options.paced ? { paced: true } : undefined); }; return { write, /** * A remote Client's write, the Burrow having dropped a mirror's terminal - * replies: human input unless it holds only mouse reports, which a Client - * sends unmarked and the desktop never acknowledges. + * replies: human input unless it holds only passive mouse reports, which a + * Client sends unmarked and the desktop never acknowledges. */ writeClientInput(id: string, data: string): void { - write(id, data, { userInput: stripMouseReportsFromInput(data).length > 0 }); - }, - resize(id: string, cols: number, rows: number, repaint?: boolean): void { - alerts.onResize(id); - pty.resize(id, cols, rows, repaint); + write(id, data, { userInput: stripPassiveMouseReportsFromInput(data).length > 0 }); }, }; } diff --git a/lib/src/host/remote/sidecar-entry.test.ts b/lib/src/host/remote/sidecar-entry.test.ts index b02173d21..f839c1b36 100644 --- a/lib/src/host/remote/sidecar-entry.test.ts +++ b/lib/src/host/remote/sidecar-entry.test.ts @@ -391,14 +391,22 @@ describe('PTYs', () => { expect(atWrite).toEqual([{ status: 'WATCHING_DISABLED', todo: false }]); }); - // A phone's tap or scroll in tmux or vim reaches the PTY, as the desktop's does. - it.each(['\x1b[<64;10;5M', '\x1b[M`!!', '\x1b[64;10;5M'])('acknowledges nothing for a Client\'s mouse report %j', (report) => { + // A phone's tap in tmux or vim reaches the PTY, as the desktop's does. + it.each(['\x1b[<0;10;5M', '\x1b[<35;10;5M', '\x1b[64;10;5M'])('acknowledges nothing for a Client\'s tap or hover report %j', (report) => { alerts.notifyFromProtocol('pty-1', REPORT); bridge.provider.writePty('pty-1', report); expect(written).toEqual([{ id: 'pty-1', data: report }]); expect(alerts.getState('pty-1')).toMatchObject({ status: 'ALERT_RINGING', todo: false }); }); + // A scroll is the user's input there too, as on the desktop. + it.each(['\x1b[<64;10;5M', '\x1b[M`!!'])('acknowledges a Client\'s wheel report %j as input', (report) => { + alerts.notifyFromProtocol('pty-1', REPORT); + bridge.provider.writePty('pty-1', report); + expect(written).toEqual([{ id: 'pty-1', data: report }]); + expect(alerts.getState('pty-1').status).not.toBe('ALERT_RINGING'); + }); + // The webview holds `untouched`, which decides whether closing asks // (docs/specs/layout.md → "Kill confirmation"); mouse reports count, as local ones do. it.each(['ls\r', '\x1b[<0;10;5M'])('tells the webview a Client wrote %j', (data) => { @@ -406,13 +414,6 @@ describe('PTYs', () => { expect(emitted('terminal:clientInput')).toEqual([{ id: 'pty-1' }]); }); - it('gives a Client\'s repaint bounce the resize grace', () => { - const onResize = vi.spyOn(alerts, 'onResize'); - bridge.provider.resizePty('pty-1', 80, 24, true); - expect(onResize).toHaveBeenCalledWith('pty-1'); - expect(resized).toEqual([{ id: 'pty-1', cols: 80, rows: 24, repaint: true }]); - }); - it('writes and resizes straight through to the manager', () => { bridge.provider.writePty('pty-1', 'ls\r'); bridge.provider.resizePty('pty-1', 80, 24); @@ -809,12 +810,19 @@ describe('the sidecar host', () => { expect(opens()).toEqual(['/launched']); }); - it('opens the resize grace before the PTY resizes', () => { - const graced: string[] = []; - vi.spyOn(host.alerts, 'onResize').mockImplementation((id) => void graced.push(`grace ${id} after ${calls.length} calls`)); - host.handleCommand('pty:resize', { id: 'pty-1', cols: 100, rows: 30 }); - expect(graced).toEqual(['grace pty-1 after 0 calls']); - expect(calls.map((call) => [call.op, call.args])).toEqual([['resize', ['pty-1', 100, 30, undefined]]]); + it('lets a write start work: output after it defers a report', () => { + vi.useFakeTimers(); + try { + host.handleCommand('pty:input', { id: 'pty-1', data: 'hi\r', paced: true }); + for (let t = 0; t < 2_000; t += 100) { + vi.advanceTimersByTime(100); + host.onPtyEvent('data', { id: 'pty-1', data: 'frame' }); + } + host.alerts.notifyFromProtocol('pty-1', REPORT); + expect(stateOf('pty-1').status).not.toBe('ALERT_RINGING'); + } finally { + vi.useRealTimers(); + } }); it('removes a killed Session\'s alert state', () => { diff --git a/lib/src/host/remote/sidecar-entry.ts b/lib/src/host/remote/sidecar-entry.ts index 5ba084236..fe6474038 100644 --- a/lib/src/host/remote/sidecar-entry.ts +++ b/lib/src/host/remote/sidecar-entry.ts @@ -191,7 +191,7 @@ export function createSidecarSurfaceBridge( return stream; } - // A Client's keystrokes and resizes reach the PTY as a local renderer's do. + // A Client's keystrokes reach the PTY as a local renderer's do. const pty = alertedPty(options.alerts, options.mgr); const { provider, notifyDirectoryChanged } = createAskSurfaceProvider(ask, { @@ -201,7 +201,7 @@ export function createSidecarSurfaceBridge( pty.writeClientInput(id, data); options.send('terminal:clientInput', { id }); }, - resizePty: pty.resize, + resizePty: (id, cols, rows, repaint) => options.mgr.resize(id, cols, rows, repaint), streamPty(ptyId, sink) { const subscribed = ownerStream(ptyId); @@ -555,7 +555,7 @@ export function createSidecarHost(options: SidecarHostOptions): SidecarHost { }); return true; case 'pty:resize': - pty.resize(id as string, detail.cols as number, detail.rows as number); + mgr.resize(id as string, detail.cols as number, detail.rows as number); return true; // Reached only after Rust dropped the id's owner, so the removal's // state reaches no window. diff --git a/lib/src/lib/alert-delivery-scheduler.test.ts b/lib/src/lib/alert-delivery-scheduler.test.ts index 442f918c3..6ae7b89eb 100644 --- a/lib/src/lib/alert-delivery-scheduler.test.ts +++ b/lib/src/lib/alert-delivery-scheduler.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { AlertManager } from './alert-manager'; import { createAlertDeliveryScheduler, type AlertDeliveryScheduler } from './alert-delivery-scheduler'; import { DEFAULT_ALERT_SETTINGS, type AlertSettings } from './alert-settings-model'; -import { engage, leave, REPORT } from './alert-manager-test-utils'; +import { engage, leave, REPORT, work } from './alert-manager-test-utils'; /** * When a ring is spoken or pushed (`docs/specs/alert.md` -> Alarm settings): @@ -26,10 +26,10 @@ let delivered: Delivered[]; const sinks = () => delivered.map((delivery) => delivery.sink); const episodeId = (id = PANE) => manager.getState(id).episode!.id; const ring = (id = PANE) => manager.notifyFromProtocol(id, REPORT); -/** Clear the ring with a click, then allow a fresh report after output. */ +/** Clear the ring with a click, then allow a fresh report after work. */ const ringAgain = () => { manager.acknowledge(PANE, { input: false }); - manager.onData(PANE); + work(manager, PANE); ring(); vi.advanceTimersByTime(5_000); }; diff --git a/lib/src/lib/alert-engagement.test.ts b/lib/src/lib/alert-engagement.test.ts index 7a7d6b8a2..b43316af9 100644 --- a/lib/src/lib/alert-engagement.test.ts +++ b/lib/src/lib/alert-engagement.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { AlertManager, type ActivityNotification, type AwaitOutcome } from './alert-manager'; import { applyTerminalEvents, TerminalProtocolParser } from './terminal-protocol'; import { cfg } from '../cfg'; -import { collectEpisodes, driveToBusy, engage, finishCommand, goIdle, leave, runCommand, VIEWER } from './alert-manager-test-utils'; +import { collectEpisodes, driveToBusy, engage, finishCommand, goIdle, leave, press, runCommand, VIEWER, work } from './alert-manager-test-utils'; import { alertedPty, createOwnerPtyStream } from '../host/owner-pty'; /** @@ -37,9 +37,15 @@ function output(id: string, ms: number, everyMs = 100): void { } } +/** Work, then `ms` more of its output. */ +function workFor(id: string, ms: number): void { + work(manager, id); + output(id, ms); +} + /** One keystroke into the pane, echoed a few ms later. */ function keystroke(id: string): void { - manager.acknowledge(id, { input: true }); + press(manager, id); vi.advanceTimersByTime(8); manager.onData(id); } @@ -147,7 +153,7 @@ describe('held completions', () => { }); it('sends an escalated report through animation deferral', () => { - output(PANE, 3_000); + workFor(PANE, 3_000); engage(manager, PANE); manager.notifyFromProtocol(PANE, PERMISSION); output(PANE, 1_000); @@ -160,7 +166,7 @@ describe('held completions', () => { }); it('holds a ring that comes due while engaged without ever having shown', () => { - output(PANE, 3_000); + workFor(PANE, 3_000); manager.notifyFromProtocol(PANE, PERMISSION); engage(manager, PANE); vi.advanceTimersByTime(5_000); @@ -171,7 +177,7 @@ describe('held completions', () => { }); it('rings a never-shown deferred ring once quiet if the viewer left before it came due', () => { - output(PANE, 3_000); + workFor(PANE, 3_000); manager.notifyFromProtocol(PANE, PERMISSION); engage(manager, PANE); leave(manager); @@ -205,7 +211,8 @@ describe('held completions', () => { it('defers a held settle once watched work resumes, without losing it', () => { engage(manager, PANE); watchedTurn(PANE); - output(PANE, 3_000); + // Another turn arrives, sent by a peer rather than typed. + workFor(PANE, 3_000); goIdle(manager, PANE); expect(ringing(PANE)).toBe(false); vi.advanceTimersByTime(5_000); @@ -261,7 +268,7 @@ describe('acknowledge', () => { /** A report behind confirmed work: a ring that has not shown. */ function deferredReport(): void { - output(PANE, 3_000); + workFor(PANE, 3_000); manager.notifyFromProtocol(PANE, PERMISSION); } @@ -344,7 +351,7 @@ describe('echo window', () => { it('counts output once the window has passed', () => { manager.acknowledge(PANE, { input: false }); manager.onData(PANE); - manager.acknowledge(PANE, { input: true }); + press(manager, PANE); vi.advanceTimersByTime(cfg.alert.echoWindow); output(PANE, 2_000); // Confirmed busy, so a report now waits for quiet. @@ -367,6 +374,72 @@ describe('echo window', () => { }); }); +/** + * A busy run begins only within `cfg.alert.workStartWindow` of input reaching + * the program, through the host's input path; a terminal reply is not input. + */ +describe('the work-start gate', () => { + const pty = () => alertedPty(manager, { write() {} }); + + /** Claude launched, painted, and left idle long past its last input. */ + function idleClaude(): void { + manager.setWatchedCommands(['claude']); + runCommand(manager, PANE, 'claude'); + output(PANE, 800); + vi.advanceTimersByTime(60_000); + } + + it('never rings the repaint a click into an idle program causes', () => { + idleClaude(); + const episodes = collectEpisodes(manager, PANE); + + // The click engages the pane and xterm reports the focus; Claude repaints. + engage(manager, PANE); + manager.acknowledge(PANE, { input: false }); + pty().write(PANE, '\x1b[I'); + output(PANE, 2_000); + leave(manager); + vi.advanceTimersByTime(60_000); + + expect(ringing(PANE)).toBe(false); + expect(episodes.size).toBe(0); + expect(manager.getState(PANE)).toMatchObject({ todo: false, notification: null }); + }); + + it('hears each write before it reaches the PTY', () => { + const order: string[] = []; + vi.spyOn(manager, 'onWrite').mockImplementation((_id, data) => void order.push(`alerts ${data}`)); + alertedPty(manager, { write: (_id, data) => void order.push(`pty ${data}`) }).write(PANE, 'hi\r', { paced: true }); + expect(order).toEqual(['alerts hi\r', 'pty hi\r']); + }); + + it('lets a key start work even when it encodes like a terminal reply', () => { + idleClaude(); + // Shift+F3 encodes as a cursor position report does. + pty().write(PANE, '\x1b[1;2R', { userInput: true }); + vi.advanceTimersByTime(cfg.alert.echoWindow); + output(PANE, 3_000); + vi.advanceTimersByTime(5_000); + expect(manager.getState(PANE)).toMatchObject({ status: 'ALERT_RINGING', notification: { title: 'claude went quiet' } }); + }); + + it('rings the turn a `dor send` starts, but not the repaint before it', () => { + idleClaude(); + // A status-line tick nobody asked for. + output(PANE, 2_000); + vi.advanceTimersByTime(10_000); + expect(ringing(PANE)).toBe(false); + + pty().write(PANE, 'hi\r', { paced: true }); + output(PANE, 3_000); + vi.advanceTimersByTime(5_000); + expect(manager.getState(PANE)).toMatchObject({ + status: 'ALERT_RINGING', + notification: { source: 'WATCHING', title: 'claude went quiet' }, + }); + }); +}); + /** * A synthetic timeline with the timings of a recorded Claude Code 2.1 session * (2026-09-23, under `TERM_PROGRAM=iTerm.app`): the user types a prompt, @@ -384,9 +457,9 @@ describe('walking away from a permission prompt', () => { for (let t = 230; t < 830; t += 100) steps.push(at(t, () => manager.onData(PANE))); // The prompt, typed at 40ms a key, each key echoed. for (let t = 6_000; t <= 9_000; t += 40) { - steps.push(at(t, () => manager.acknowledge(PANE, { input: true })), at(t + 7, () => manager.onData(PANE))); + steps.push(at(t, () => press(manager, PANE)), at(t + 7, () => manager.onData(PANE))); } - steps.push(at(ENTER_AT, () => manager.acknowledge(PANE, { input: true }))); + steps.push(at(ENTER_AT, () => press(manager, PANE))); // Working, then the permission prompt drawn. for (let t = 9_390; t < 11_960; t += 110) steps.push(at(t, () => manager.onData(PANE))); steps.push(at(17_960, () => applyTerminalEvents(manager, PANE, parser.process(OSC99).events))); @@ -448,7 +521,7 @@ describe('a Claude Code turn', () => { onClipboardOffer() {}, onChunk() {}, }); - const pty = alertedPty(manager, { write() {}, resize() {} }); + const pty = alertedPty(manager, { write() {} }); emit = (ms, data) => at(ms, () => stream.write(data)); key = (ms, data) => at(ms, () => pty.write(PANE, data, { userInput: true })); episodes = collectEpisodes(manager, PANE); diff --git a/lib/src/lib/alert-manager-test-utils.ts b/lib/src/lib/alert-manager-test-utils.ts index 6508ed1d7..2091cba95 100644 --- a/lib/src/lib/alert-manager-test-utils.ts +++ b/lib/src/lib/alert-manager-test-utils.ts @@ -5,7 +5,7 @@ * manager it drives and runs under fake timers. * * Timing from cfg.alert: busyCandidateGap=1500, busyConfirmGap=500, - * mightNeedAttention=2000, needsAttentionConfirm=3000. + * mightNeedAttention=2000, needsAttentionConfirm=3000, workStartWindow=5000. */ import { vi } from 'vitest'; import type { ActivityNotification, AlertManager } from './alert-manager'; @@ -31,8 +31,21 @@ export function goIdle(manager: AlertManager, id: string, viewer = VIEWER): void manager.setViewer(viewer, { present: false, focusId: id }, 'idle'); } -/** Start `commandLine` the way shell integration reports it: the line, then its start. */ +/** A key the user presses, through the human-input path: acknowledged, and output may start work. */ +export function press(manager: AlertManager, id: string): void { + manager.onWrite(id, 'x', { userInput: true }); +} + +/** A write that sets the program working — Enter, a `dor send`, a launch — and its first output. */ +export function work(manager: AlertManager, id: string): void { + manager.onWrite(id, '\r'); + manager.onData(id); +} + +/** Start `commandLine` the way a typed command starts: the Enter that runs it, + * then shell integration's line and start. */ export function runCommand(manager: AlertManager, id: string, commandLine = 'pnpm build'): void { + manager.onWrite(id, '\r'); manager.applyTerminalSemanticEvents(id, [ { type: 'commandLine', commandLine }, { type: 'commandStart', source: 'osc633_E', startedAt: Date.now() }, @@ -68,9 +81,9 @@ export function armCommandExit(manager: AlertManager, id: string, commandLine = leave(manager); } -/** Two output bursts across the busy-candidate gap: NOTHING_TO_SHOW -> MIGHT_BE_BUSY. */ +/** Work, then a second output burst across the busy-candidate gap: NOTHING_TO_SHOW -> MIGHT_BE_BUSY. */ export function driveToCandidate(manager: AlertManager, id: string): void { - manager.onData(id); + work(manager, id); vi.advanceTimersByTime(1_600); manager.onData(id); } diff --git a/lib/src/lib/alert-manager.test.ts b/lib/src/lib/alert-manager.test.ts index 28dd7dc2a..99336a24c 100644 --- a/lib/src/lib/alert-manager.test.ts +++ b/lib/src/lib/alert-manager.test.ts @@ -8,7 +8,7 @@ import { } from './terminal-protocol'; import { cfg } from '../cfg'; import { toPersistedAlertState } from './session-types'; -import { armCommandExit, collectEpisodes, driveToBusy, driveToCandidate, engage, finishCommand, goIdle, heartbeat, leave, REPORT, runCommand, settle } from './alert-manager-test-utils'; +import { armCommandExit, collectEpisodes, driveToBusy, driveToCandidate, engage, finishCommand, goIdle, heartbeat, leave, press, REPORT, runCommand, settle, work } from './alert-manager-test-utils'; describe('AlertManager in isolation', () => { let manager: AlertManager; @@ -51,7 +51,7 @@ describe('AlertManager in isolation', () => { manager.toggleTodo(HELPER); manager.clearTodo(HELPER); manager.seed(HELPER, { todo: true }); - manager.onResize(HELPER); + press(manager, HELPER); finishCommand(manager, HELPER, 1); manager.onExit(HELPER, 0); vi.advanceTimersByTime(10_000); @@ -200,7 +200,8 @@ describe('AlertManager in isolation', () => { settle(); expect(manager.getState(id).status).toBe('ALERT_RINGING'); - manager.onData(id); + // The next turn starts. + work(manager, id); expect(manager.getState(id).status).toBe('ALERT_RINGING'); for (let i = 0; i < 10; i++) { @@ -410,7 +411,7 @@ describe('AlertManager in isolation', () => { const id = 'acknowledged-then-output'; manager.notifyFromProtocol(id, { source: 'OSC 9', title: null, body: 'first' }); manager.dismissAlert(id); - manager.onData(id); + work(manager, id); manager.notifyFromProtocol(id, { source: 'OSC 9', title: null, body: 'second' }); vi.advanceTimersByTime(5_000); expect(manager.getState(id).status).toBe('ALERT_RINGING'); @@ -535,7 +536,7 @@ describe('AlertManager in isolation', () => { expect(manager.getState(id).status).toBe('OSC_NOTIF_BUSY'); // The run goes on after the answer, then its cycle ends. - manager.onData(id); + work(manager, id); manager.updateProtocolProgress(id, { state: 'clear', percent: null }); vi.advanceTimersByTime(5_000); @@ -715,7 +716,7 @@ describe('AlertManager in isolation', () => { expect(manager.getState(id).episode).toBeNull(); // Output since the clear: the next bell is news, not the acknowledged state. - manager.onData(id); + work(manager, id); applyTerminalEvents(manager, id, [{ kind: 'notification', notification: bell }]); vi.advanceTimersByTime(5_000); const second = manager.getState(id).episode; @@ -1134,7 +1135,7 @@ describe('AlertManager in isolation', () => { it('rings immediately in an idle pane and after a single output chunk', () => { manager.notifyFromProtocol('idle', REPORT); expect(manager.getState('idle').status).toBe('ALERT_RINGING'); - manager.onData('one-chunk'); + work(manager, 'one-chunk'); manager.notifyFromProtocol('one-chunk', REPORT); expect(manager.getState('one-chunk')).toMatchObject({ status: 'ALERT_RINGING', notification: REPORT }); }); @@ -1365,6 +1366,7 @@ describe('AlertManager in isolation', () => { applyTerminalEvents(manager, id, parsed.events); if (parsed.visibleData.length > 0) manager.onData(id); }; + manager.onWrite(id, '\r'); feed('\x1b]633;E;./build.sh\x07\x1b]633;C\x07'); for (let t = 0; t < 3_000; t += 200) { feed('compiling...\r\n'); @@ -1609,7 +1611,7 @@ describe('AlertManager in isolation', () => { const id = 'await-keeps-earlier-todo'; manager.notifyFromProtocol(id, { source: 'OSC 777', title: 'earlier', body: null }); manager.dismissAlert(id); - manager.onData(id); + work(manager, id); manager.notifyFromProtocol(id, { source: 'OSC 9', title: null, body: 'later' }); vi.advanceTimersByTime(5_000); expect(manager.getState(id).notification?.body).toBe('later'); @@ -1711,7 +1713,7 @@ describe('AlertManager in isolation', () => { // The peer was sent another turn and is talking again. Nothing clears the // latched ring — it is still the human's — but the quiet it describes is // over, so it cannot answer "output stopped" about the turn in flight. - manager.onData(id); + work(manager, id); vi.advanceTimersByTime(gapMs); manager.onData(id); vi.advanceTimersByTime(gapMs); @@ -1736,7 +1738,7 @@ describe('AlertManager in isolation', () => { // Output that reaches MIGHT_BE_BUSY and then stops: the confirm timer // returns the detector to NOTHING_TO_SHOW without ever settling, so the // latch is untouched and the detector's status is back where it started. - manager.onData(id); + work(manager, id); vi.advanceTimersByTime(1_600); manager.onData(id); vi.advanceTimersByTime(10_000); @@ -1900,8 +1902,8 @@ describe('AlertManager in isolation', () => { const quietOutcome = watch(quiet); const strictOutcome = watch(strict); - manager.onData('await-grace-quiet'); - manager.onData('await-grace-exit'); + work(manager, 'await-grace-quiet'); + work(manager, 'await-grace-exit'); vi.advanceTimersByTime(AWAIT_GRACE_MS); // Output is evidence of work for `quiet`; for `exit` only a command start is. @@ -2022,7 +2024,7 @@ describe('AlertManager in isolation', () => { // that were already on their way out. Rebuilding here would strand an // entry and a detector nothing ever disposes. manager.onData(id); - manager.onResize(id); + press(manager, id); expect(manager.getState(id)).toEqual(DEFAULT_ALERT_STATE); // A replacement pane may reuse the id; its first reported command is the diff --git a/lib/src/lib/alert-manager.ts b/lib/src/lib/alert-manager.ts index f1cd0d2c0..406ff1d2d 100644 --- a/lib/src/lib/alert-manager.ts +++ b/lib/src/lib/alert-manager.ts @@ -2,6 +2,7 @@ import { createAlertEpisode, type AlertEpisode } from './alert-episode'; import { QuiesceDetector, type QuiesceStatus } from './quiesce-detector'; import { DEFAULT_ALERT_SETTINGS, type AlertSettings } from './alert-settings-model'; import { cfg } from '../cfg'; +import { inputCanStartWork } from './terminal-report-filter'; import { commandWatchKey, resolveCommandStart, @@ -312,7 +313,7 @@ interface AlertEntry { * observe→claim→ring seam, so await can claim completions before suppression. */ export class AlertManager { private entries = new Map(); - /** Blocks late output/resize from recreating a removed entry. Only a semantic + /** Blocks late output or input from recreating a removed entry. Only a semantic * or protocol event proves a reused id belongs to a live replacement. */ private removed = new Set(); private claimants = new Map>(); @@ -404,10 +405,15 @@ export class AlertManager { this.settleWaiters(id, 'died'); } - onResize(id: string): void { - // Same reasoning as `onData`: the resize grace window is part of the - // always-on detector, and a Pane's first fit usually beats any PTY event. - this.streamEntry(id)?.detector.onResize(); + /** + * A write is about to reach the PTY. Human input acknowledges, and any input + * but terminal replies and mouse reports lets output start work — a `dor + * send` or a launch as much as a keystroke (`QuiesceDetector.expectWork`). + */ + onWrite(id: string, data: string, { userInput = false }: { userInput?: boolean } = {}): void { + if (userInput) this.acknowledge(id, { input: true }); + // Same reasoning as `onData`: the first keystroke can beat any output. + if (inputCanStartWork(data, { userInput })) this.streamEntry(id)?.detector.expectWork(); } // --- WATCHING rule set --- @@ -796,6 +802,9 @@ export class AlertManager { return; } + // A program announcing work may start a run as input does, so a turn it + // began unasked still reads as busy. + entry.detector.expectWork(); if (entry.progress?.state === progress.state && entry.progress.percent === progress.percent) return; entry.progress = { state: progress.state, percent: progress.percent }; this.notify(id); diff --git a/lib/src/lib/alert-resumed-output.test.ts b/lib/src/lib/alert-resumed-output.test.ts index 44a5b2f96..6e72d3767 100644 --- a/lib/src/lib/alert-resumed-output.test.ts +++ b/lib/src/lib/alert-resumed-output.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { AlertManager } from './alert-manager'; import { createAlertDeliveryScheduler, type AlertDeliveryScheduler } from './alert-delivery-scheduler'; import { DEFAULT_ALERT_SETTINGS } from './alert-settings-model'; -import { armCommandExit, driveToBusy, driveToCandidate, engage, finishCommand, goIdle, heartbeat, REPORT, runCommand, settle } from './alert-manager-test-utils'; +import { armCommandExit, driveToBusy, driveToCandidate, engage, finishCommand, goIdle, heartbeat, press, REPORT, runCommand, settle, work } from './alert-manager-test-utils'; import { toPersistedAlertState } from './session-types'; import { createOwnerPtyStream } from '../host/owner-pty'; @@ -29,6 +29,13 @@ afterEach(() => { manager.dispose(); vi.useRealTimers(); }); +/** Output every 100ms for `ms`: dense enough to confirm work. */ +function burst(ms: number): void { + for (let elapsed = 0; elapsed < ms; elapsed += 100) { + vi.advanceTimersByTime(100); + manager.onData(ID); + } +} function ring(source: 'watching' | 'report'): void { if (source === 'report') manager.notifyFromProtocol(ID, REPORT); else { driveToBusy(manager, ID); settle(); } @@ -42,7 +49,7 @@ describe('owed alerts during resumed output', () => { manager.onData(ID); manager.registerCompletionClaimant(ID, (event) => { if (event.kind !== 'settled') return false; - manager.onData(ID); + work(manager, ID); return true; }); settle(); @@ -69,6 +76,8 @@ describe('owed alerts during resumed output', () => { it.each(['watching', 'report'] as const)('keeps a %s past its deadlines during output, then delivers once on quiet', (source) => { ring(source); const episode = manager.getState(ID).episode; + // Another turn starts, and runs for two minutes. + work(manager, ID); heartbeat(manager, ID, 120_000); expect(manager.getState(ID).status).not.toBe('ALERT_RINGING'); expect(manager.getState(ID).episode).toEqual(episode); @@ -157,27 +166,29 @@ describe('owed alerts during resumed output', () => { expect(episode).not.toBeNull(); expect(manager.getState(ID)).toMatchObject({ status: 'ALERT_RINGING', episode }); }); - it('ignores resize output when deciding whether to defer', () => { + it('ignores a repaint long after the last input when deciding whether to defer', () => { + vi.advanceTimersByTime(60_000); ring('report'); - manager.onResize(ID); - manager.onData(ID); + // A resize or focus repaint, with nothing typed since the command started. + burst(3_000); expect(manager.getState(ID).status).toBe('ALERT_RINGING'); - vi.advanceTimersByTime(DELAY); - expect(spoken).toHaveBeenCalledOnce(); }); - it('ignores late isolated resize output', () => { - manager.onResize(ID); - manager.onData(ID); - vi.advanceTimersByTime(600); - manager.onData(ID); // A late redraw outside the resize grace is still only one chunk. - manager.notifyFromProtocol(ID, REPORT); - expect(manager.getState(ID).status).toBe('ALERT_RINGING'); + it('lets a progress report start work with no input, deferring an owed ring while the turn runs', () => { + vi.advanceTimersByTime(60_000); + ring('report'); + const episode = manager.getState(ID).episode; + // A turn the program began unasked, announced as `OSC 9;4;3`. + manager.updateProtocolProgress(ID, { state: 'indeterminate', percent: null }); + burst(3_000); + expect(manager.getState(ID)).toMatchObject({ status: 'OSC_NOTIF_BUSY', episode }); + vi.advanceTimersByTime(5_000); + expect(manager.getState(ID)).toMatchObject({ status: 'ALERT_RINGING', episode }); }); it('ignores repeated typing echoes', () => { - manager.acknowledge(ID, { input: true }); + press(manager, ID); for (let i = 0; i < 20; i++) { vi.advanceTimersByTime(400); - manager.acknowledge(ID, { input: true }); + press(manager, ID); manager.onData(ID); } vi.advanceTimersByTime(751); diff --git a/lib/src/lib/engagement.test.ts b/lib/src/lib/engagement.test.ts index e5f35a2c6..ce3887e23 100644 --- a/lib/src/lib/engagement.test.ts +++ b/lib/src/lib/engagement.test.ts @@ -35,6 +35,7 @@ describe('engagement reporter', () => { platform.spawnPty('pane-a'); publishEngagementFocus(wall, 'pane-a'); window.dispatchEvent(new Event('keydown')); + platform.writePty('pane-a', '\r', { userInput: true }); platform.sendOutput('pane-a', '\x1b]633;E;claude\x07\x1b]633;C\x07'); // Claude works for three seconds and goes quiet; the user reads along, // moving the mouse now and then but never typing. diff --git a/lib/src/lib/platform/fake-adapter.test.ts b/lib/src/lib/platform/fake-adapter.test.ts index c52b87dc2..670d190d8 100644 --- a/lib/src/lib/platform/fake-adapter.test.ts +++ b/lib/src/lib/platform/fake-adapter.test.ts @@ -86,6 +86,7 @@ describe('FakePtyAdapter', () => { const alertEvents: { status: string }[] = []; adapter.onAlertState((detail) => alertEvents.push(detail)); adapter.spawnPty('t1'); + adapter.writePty('t1', 'make\r'); adapter.sendOutput('t1', 'working'); vi.advanceTimersByTime(1_600); adapter.sendOutput('t1', 'still working'); @@ -104,6 +105,7 @@ describe('FakePtyAdapter', () => { const alertEvents: { status: string }[] = []; adapter.onAlertState((detail) => alertEvents.push(detail)); adapter.spawnPty('t1'); + adapter.writePty('t1', 'make\r'); adapter.sendOutput('t1', 'working'); vi.advanceTimersByTime(1_600); adapter.sendOutput('t1', 'still working'); diff --git a/lib/src/lib/platform/fake-adapter.ts b/lib/src/lib/platform/fake-adapter.ts index e61e0aae3..efcb111a8 100644 --- a/lib/src/lib/platform/fake-adapter.ts +++ b/lib/src/lib/platform/fake-adapter.ts @@ -275,7 +275,7 @@ export class FakePtyAdapter implements PlatformAdapter { } writePty(id: string, data: string, options?: WritePtyOptions): void { - if (options?.userInput) this.alertManager.acknowledge(id, { input: true }); + this.alertManager.onWrite(id, data, options); if (!this.terminals.has(id)) return; if (options?.launch) this.ownerStream(id).armLaunch(data); // Only echo if no scenario is actively playing @@ -290,7 +290,6 @@ export class FakePtyAdapter implements PlatformAdapter { } resizePty(id: string, cols: number, rows: number): void { - this.alertManager.onResize(id); if (!this.terminals.has(id)) return; const next = { cols, rows }; const prev = this.terminalSizes.get(id); diff --git a/lib/src/lib/platform/types.ts b/lib/src/lib/platform/types.ts index ef84a3866..2da788be5 100644 --- a/lib/src/lib/platform/types.ts +++ b/lib/src/lib/platform/types.ts @@ -424,7 +424,8 @@ export interface PlatformAdapter { notifySessionFlushComplete(requestId: string): void; // Alert management: a Session's alert state follows its PTY — `writePty`'s - // `userInput` acknowledges, a resize opens its grace window, a kill removes it. + // `userInput` acknowledges, any write but a terminal reply lets output start + // work, a kill removes it. /** Offer persisted WATCHING rules as the host's startup seed. */ alertSetWatchedCommands(names: string[]): void; /** Mutate one bare-command WATCHING rule without replacing unrelated rules. */ diff --git a/lib/src/lib/quiesce-detector.test.ts b/lib/src/lib/quiesce-detector.test.ts index 270b65ad1..1faeb1f37 100644 --- a/lib/src/lib/quiesce-detector.test.ts +++ b/lib/src/lib/quiesce-detector.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { QuiesceDetector, type QuiesceStatus } from './quiesce-detector'; // Timing constants from cfg.alert: -// busyCandidateGap=1500, busyConfirmGap=500, mightNeedAttention=2000, needsAttentionConfirm=3000, resizeDebounce=500 +// busyCandidateGap=1500, busyConfirmGap=500, mightNeedAttention=2000, needsAttentionConfirm=3000, workStartWindow=5000 describe('QuiesceDetector', () => { beforeEach(() => { @@ -13,17 +13,20 @@ describe('QuiesceDetector', () => { vi.useRealTimers(); }); - function createMonitor() { + /** A monitor whose program was just given input, unless `expectWork` is false. */ + function createMonitor({ expectWork = true }: { expectWork?: boolean } = {}) { const changes: QuiesceStatus[] = []; const settled = vi.fn(); const monitor = new QuiesceDetector({ onChange: (status) => changes.push(status), onSettled: settled, }); + if (expectWork) monitor.expectWork(); return { monitor, changes, settled }; } function driveMonitorToBusy(monitor: QuiesceDetector) { + monitor.expectWork(); monitor.onData(); vi.advanceTimersByTime(1_500); monitor.onData(); @@ -64,11 +67,10 @@ describe('QuiesceDetector', () => { expect(settled).toHaveBeenCalledOnce(); }); - it('rejects resize redraws and disposed output', () => { - const { monitor } = createMonitor(); - monitor.onResize(); + it('rejects output nobody asked for and disposed output', () => { + const { monitor } = createMonitor({ expectWork: false }); expect(monitor.onData()).toBe(false); - vi.advanceTimersByTime(500); + monitor.expectWork(); expect(monitor.onData()).toBe(true); expect(monitor.isConfirmedBusy()).toBe(false); monitor.dispose(); @@ -134,6 +136,8 @@ describe('QuiesceDetector', () => { if (failedConfirmation) monitor.onData(); vi.advanceTimersByTime(218_342); + // Input inside the window, so only the forgotten history keeps it idle. + monitor.expectWork(); monitor.onData(); vi.advanceTimersByTime(62); monitor.onData(); @@ -153,6 +157,7 @@ describe('QuiesceDetector', () => { monitor.onData(); expect(monitor.getStatus()).toBe('MIGHT_BE_BUSY'); vi.setSystemTime(Date.now() + 218_342); + monitor.expectWork(); monitor.onData(); expect(monitor.getStatus()).toBe('NOTHING_TO_SHOW'); vi.advanceTimersByTime(62); @@ -322,23 +327,73 @@ describe('QuiesceDetector', () => { expect(changes).toEqual([]); }); - it('onResize suppresses output detection for 500ms', () => { - const { monitor, changes } = createMonitor(); - monitor.onResize(); - monitor.onData(); - expect(monitor.getStatus()).toBe('NOTHING_TO_SHOW'); - expect(changes).toEqual([]); - }); + describe('work-start gate', () => { + it('ignores sustained output with no input', () => { + const { monitor, changes, settled } = createMonitor({ expectWork: false }); + for (let i = 0; i < 20; i++) { + expect(monitor.onData()).toBe(false); + vi.advanceTimersByTime(500); + } + vi.advanceTimersByTime(10_000); + expect(monitor.getStatus()).toBe('NOTHING_TO_SHOW'); + expect(changes).toEqual([]); + expect(settled).not.toHaveBeenCalled(); + }); - it('output after the resize debounce participates in busy detection normally', () => { - const { monitor, changes } = createMonitor(); - monitor.onResize(); - vi.advanceTimersByTime(500); - monitor.onData(); - vi.advanceTimersByTime(1_500); - monitor.onData(); - expect(monitor.getStatus()).toBe('MIGHT_BE_BUSY'); - expect(changes).toEqual(['MIGHT_BE_BUSY']); + it('lets output that begins within the window start a run', () => { + const { monitor, changes } = createMonitor({ expectWork: false }); + // An idle program's repaint, then the keystroke that sets it working. + expect(monitor.onData()).toBe(false); + monitor.expectWork(); + vi.advanceTimersByTime(4_900); + expect(monitor.onData()).toBe(true); + vi.advanceTimersByTime(1_500); + monitor.onData(); + monitor.onData(); + expect(monitor.getStatus()).toBe('BUSY'); + expect(changes).toEqual(['MIGHT_BE_BUSY', 'BUSY']); + }); + + it('ignores output that begins after the window', () => { + const { monitor, changes } = createMonitor(); + vi.advanceTimersByTime(5_001); + expect(monitor.onData()).toBe(false); + vi.advanceTimersByTime(1_500); + expect(monitor.onData()).toBe(false); + expect(monitor.onData()).toBe(false); + expect(monitor.getStatus()).toBe('NOTHING_TO_SHOW'); + expect(changes).toEqual([]); + }); + + it('keeps counting a run begun inside the window through its settle, and gates the next', () => { + const { monitor, changes, settled } = createMonitor(); + vi.advanceTimersByTime(4_000); + monitor.onData(); + vi.advanceTimersByTime(1_500); + monitor.onData(); + monitor.onData(); + expect(monitor.getStatus()).toBe('BUSY'); + vi.advanceTimersByTime(1_500); + expect(monitor.onData()).toBe(true); + vi.advanceTimersByTime(5_000); + expect(settled).toHaveBeenCalledOnce(); + expect(changes).toEqual(['MIGHT_BE_BUSY', 'BUSY', 'MIGHT_NEED_ATTENTION', 'NOTHING_TO_SHOW']); + // The next run needs input of its own. + expect(monitor.onData()).toBe(false); + }); + + it('keeps input across reset(), which opens no window of its own', () => { + const { monitor } = createMonitor(); + monitor.reset(); + monitor.onData(); + vi.advanceTimersByTime(1_500); + monitor.onData(); + monitor.onData(); + expect(monitor.getStatus()).toBe('BUSY'); + vi.advanceTimersByTime(4_000); + monitor.reset(); + expect(monitor.onData()).toBe(false); + }); }); it('resets the BUSY silence timer when more output arrives', () => { @@ -366,6 +421,7 @@ describe('QuiesceDetector', () => { it('does not emit changes after dispose', () => { const onChange = vi.fn(); const monitor = new QuiesceDetector({ onChange }); + monitor.expectWork(); monitor.onData(); vi.advanceTimersByTime(1_500); monitor.onData(); diff --git a/lib/src/lib/quiesce-detector.ts b/lib/src/lib/quiesce-detector.ts index 9f7cb0689..b12dfe7d4 100644 --- a/lib/src/lib/quiesce-detector.ts +++ b/lib/src/lib/quiesce-detector.ts @@ -25,12 +25,11 @@ const T_BUSY_CANDIDATE_GAP = cfg.alert.busyCandidateGap; const T_BUSY_CONFIRM_GAP = cfg.alert.busyConfirmGap; const T_MIGHT_NEED_ATTENTION = cfg.alert.mightNeedAttention; const T_SETTLED_CONFIRM = cfg.alert.needsAttentionConfirm; -const T_RESIZE_DEBOUNCE = cfg.alert.resizeDebounce; /** Longest silence unconfirmed candidate history can span and still count. */ const CANDIDATE_HISTORY_TTL_MS = T_BUSY_CANDIDATE_GAP + T_BUSY_CONFIRM_GAP; -type DetectorTimer = 'busyCandidate' | 'busyConfirm' | 'mightNeedAttention' | 'settledConfirm' | 'resize'; +type DetectorTimer = 'busyCandidate' | 'busyConfirm' | 'mightNeedAttention' | 'settledConfirm'; /** * Watches one Session's PTY output and reports busy/quiet transitions. @@ -38,11 +37,15 @@ type DetectorTimer = 'busyCandidate' | 'busyConfirm' | 'mightNeedAttention' | 's * One of these runs for every Session for its whole lifetime — it is a plain * observer, not an alarm. It never latches: a settle is announced through * `onSettled` and the detector immediately starts over. + * + * A busy run must begin within `cfg.alert.workStartWindow` of `expectWork`: a + * program repaints on focus, on resize, and on its own timers without working, + * and output nothing asked for is ignored until it is already busy. */ export class QuiesceDetector { private status: QuiesceStatus = 'NOTHING_TO_SHOW'; - private resizeGrace = false; private disposed = false; + private lastWorkExpectedAt = -Infinity; private timers = new Map>(); private firstOutputAt: number | null = null; private lastOutputAt: number | null = null; @@ -67,13 +70,13 @@ export class QuiesceDetector { /** Start over from quiet, forgetting the previous command's output history. */ reset(): void { if (this.disposed) return; - this.clearActivityTimers(); + this.clearTimers(); this.resetOutputTracking(); this.setStatus('NOTHING_TO_SHOW'); } onData(): boolean { - if (this.disposed || this.resizeGrace) return false; + if (this.disposed) return false; const now = Date.now(); // Candidate history only describes one run of output. A timer callback can @@ -85,6 +88,11 @@ export class QuiesceDetector { ) { this.reset(); } + // Only a run's first chunk is gated; once it began, its output counts. + if ( + this.status === 'NOTHING_TO_SHOW' && this.firstOutputAt === null + && now - this.lastWorkExpectedAt > cfg.alert.workStartWindow + ) return false; this.lastOutputAt = now; @@ -105,16 +113,16 @@ export class QuiesceDetector { return true; } - onResize(): void { - if (this.disposed) return; - this.resizeGrace = true; - this.schedule('resize', T_RESIZE_DEBOUNCE); + /** Input reached the program, or it announced work: output may begin a busy + * run. Kept across `reset`: the keystroke that starts a command precedes + * its boundary. */ + expectWork(): void { + this.lastWorkExpectedAt = Date.now(); } dispose(): void { this.disposed = true; - for (const timer of this.timers.values()) clearTimeout(timer); - this.timers.clear(); + this.clearTimers(); } private handleNothingToShowOutput(now: number): void { @@ -133,13 +141,13 @@ export class QuiesceDetector { } private enterMightBeBusy(): void { - this.clearActivityTimers(); + this.clearTimers(); this.setStatus('MIGHT_BE_BUSY'); this.schedule('busyConfirm', T_BUSY_CONFIRM_GAP); } private enterBusy(): void { - this.clearActivityTimers(); + this.clearTimers(); this.resetOutputTracking(); this.setStatus('BUSY'); this.startMightNeedAttentionTimer(); @@ -160,7 +168,6 @@ export class QuiesceDetector { this.timers.delete(kind); if (this.disposed) return; switch (kind) { - case 'resize': this.resizeGrace = false; break; case 'busyCandidate': if (this.status === 'NOTHING_TO_SHOW' && this.outputCountSinceReset >= 2) this.enterMightBeBusy(); break; @@ -185,12 +192,9 @@ export class QuiesceDetector { this.timers.set(kind, timer); } - private clearActivityTimers(): void { - for (const [kind, timer] of this.timers) { - if (kind === 'resize') continue; - clearTimeout(timer); - this.timers.delete(kind); - } + private clearTimers(): void { + for (const timer of this.timers.values()) clearTimeout(timer); + this.timers.clear(); } private seedFromLatestOutput(): void { diff --git a/lib/src/lib/terminal-lifecycle.selection.test.ts b/lib/src/lib/terminal-lifecycle.selection.test.ts index ef36e7c1a..6db290def 100644 --- a/lib/src/lib/terminal-lifecycle.selection.test.ts +++ b/lib/src/lib/terminal-lifecycle.selection.test.ts @@ -47,6 +47,13 @@ describe('writeUserInput', () => { } }); + it('leaves a selection be for a wheel tick, which scrolls the program', () => { + getOrCreateTerminal('pane-3'); + setSelection('pane-3', finalizedSelection({ endCol: 4 })); + writeUserInput('pane-3', '\x1b[<65;1;1M'); + expect(getMouseSelectionState('pane-3').selection).not.toBeNull(); + }); + it('leaves a drag in progress alone', () => { getOrCreateTerminal('pane-2'); beginDrag('pane-2', { row: 0, col: 0, altKey: false, startedInScrollback: false }); diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index 0fc29a102..f5bc811aa 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -45,6 +45,7 @@ import { inputIsSyntheticTerminalReport, REPLAY_MODE_RESET, stripMouseReportsFromInput, + stripPassiveMouseReportsFromInput, writeReplay, } from './terminal-report-filter'; import { getTerminalTheme, paintTerminalHost, startThemeObserver } from './terminal-theme'; @@ -228,14 +229,12 @@ function wireXtermHandlers( selection: SelectionWatch, ): () => void { const inputDisposable = terminal.onData((data) => { - // One strip, two readers. While an override is active the reports must not - // reach the PTY at all (belt and braces behind DOM interception), so the - // stripped chunk *is* the input; with no override the inside program that - // asked for them still gets them, but they are never keystrokes either way. - const withoutMouseReports = stripMouseReportsFromInput(data); + // While an override is active the reports must not reach the PTY at all + // (belt and braces behind DOM interception), so the stripped chunk *is* the + // input; with no override the inside program that asked for them gets them. let input = data; if (getMouseSelectionState(id).override !== 'off') { - input = withoutMouseReports; + input = stripMouseReportsFromInput(data); if (input.length === 0) return; } @@ -243,10 +242,10 @@ function wireXtermHandlers( if (isReplayTerminalReport && registry.get(id)?.isReplaying) return; - // Inside programs can request hover and wheel reports. Mouse-only chunks - // are not keystrokes; actual clicks acknowledge through the Pane's DOM - // handler. Terminal replies are never keystrokes either. - if (isReplayTerminalReport || withoutMouseReports.length === 0) { + // Inside programs can request hover and wheel reports. A wheel tick is the + // user's input; hover and button reports are not — actual clicks acknowledge + // through the Pane's DOM handler. Terminal replies are never input either. + if (isReplayTerminalReport || stripPassiveMouseReportsFromInput(input).length === 0) { // Forwarded mouse interaction still counts as touched for kill confirmation. if (!isReplayTerminalReport) markSessionTouched(id); getPlatform().writePty(id, input); @@ -789,8 +788,11 @@ export function getSessionInputVersion(id: string): number { export function writeUserInput(id: string, data: string): void { // Typing, a paste, or Pocket's input bar ends a finalized selection — the // copy editor over it, or a shadowed drag — on every path alike - // (docs/specs/mouse-and-clipboard.md §4.3, §3.8). - if (getMouseSelectionState(id).selection?.dragging === false) setMouseSelection(id, null); + // (docs/specs/mouse-and-clipboard.md §4.3, §3.8). A wheel tick scrolls the + // program and leaves it be. + if (getMouseSelectionState(id).selection?.dragging === false && stripMouseReportsFromInput(data).length > 0) { + setMouseSelection(id, null); + } markSessionTouched(id); getPlatform().writePty(id, data, { userInput: true }); } diff --git a/lib/src/lib/terminal-registry.alert.test.ts b/lib/src/lib/terminal-registry.alert.test.ts index d09466f68..2b8baedf5 100644 --- a/lib/src/lib/terminal-registry.alert.test.ts +++ b/lib/src/lib/terminal-registry.alert.test.ts @@ -65,7 +65,6 @@ interface MockTerminalInstance { writes: string[]; addons: Array<{ constructor: { name: string }; options?: Record }>; emitInput(data: string): void; - emitResize(cols: number, rows: number): void; } class MockElement { @@ -190,7 +189,8 @@ function clearWatchedCommands(): void { } // Timing helpers based on cfg.alert values: -// busyCandidateGap=1500, busyConfirmGap=500, mightNeedAttention=2000, needsAttentionConfirm=3000 +// busyCandidateGap=1500, busyConfirmGap=500, mightNeedAttention=2000, needsAttentionConfirm=3000, +// workStartWindow=5000. `emitOutput` writes through the PTY's echo, so it is input as well. function driveToBusy(id: string): void { emitOutput(id, 'prompt> '); @@ -592,6 +592,8 @@ describe('terminal-registry alert behavior', () => { ); enableAlert(id); engageSession(id); + // The keystroke that started it; the scenario already owns the output. + fakePlatform.writePty(id, '\r'); advance(1_800); expect(getActivity(id)).toMatchObject({ status: 'BUSY' }); @@ -823,13 +825,17 @@ describe('terminal-registry alert behavior', () => { }); }); - it('Story 12: resize noise never creates a false alert', () => { + it('Story 12: a repaint long after the last input never creates a false alert', () => { const id = 'story-12'; - const entry = createSession(id); + createSession(id); enableAlert(id); + advance(cfg.alert.workStartWindow + 1); - entry.terminal.emitResize(120, 40); - emitOutput(id, 'redraw noise'); + // sendOutput, not emitOutput: a repaint on resize or focus is output nobody typed. + fakePlatform.sendOutput(id, 'redraw'); + advance(1_600); + fakePlatform.sendOutput(id, 'redraw'); + fakePlatform.sendOutput(id, 'redraw'); advance(12_000); expect(getActivity(id)).toMatchObject({ @@ -838,6 +844,40 @@ describe('terminal-registry alert behavior', () => { }); }); + it('Story 12b: scrolling a program never makes its repaint a busy run', () => { + const id = 'story-12b'; + const entry = createSession(id); + enableAlert(id); + advance(cfg.alert.workStartWindow + 1); + + // Each wheel tick is input, and the program repaints the scrolled view. + for (let tick = 0; tick < 30; tick++) { + entry.terminal.emitInput('\x1b[<65;10;20M'); + fakePlatform.sendOutput(id, 'scrolled view'); + advance(100); + } + advance(12_000); + + expect(getActivity(id)).toMatchObject({ status: 'NOTHING_TO_SHOW', todo: false }); + }); + + it('Story 12c: a repaint trailing a scroll never creates a false alert', () => { + const id = 'story-12c'; + const entry = createSession(id); + enableAlert(id); + advance(cfg.alert.workStartWindow + 1); + + entry.terminal.emitInput('\x1b[<65;10;20M'); + advance(1_500); + for (let paint = 0; paint < 20; paint++) { + fakePlatform.sendOutput(id, 'scrolled view'); + advance(100); + } + advance(12_000); + + expect(getActivity(id)).toMatchObject({ status: 'NOTHING_TO_SHOW', todo: false }); + }); + it('Story 13: multiple sessions ring independently', () => { const alpha = 'story-13-a'; const beta = 'story-13-b'; @@ -908,6 +948,10 @@ describe('terminal-registry alert behavior', () => { ['arrow key', '\x1b[A'], ['SS3 key', '\x1bOA'], ['mouse report with a real key', '\x1b[<35;10;20Mx'], + ['SGR wheel report', '\x1b[<64;10;20M'], + ['SGR Ctrl+wheel report', '\x1b[<81;10;20M'], + ['urxvt wheel report', '\x1b[96;10;20M'], + ['X10 wheel report', '\x1b[M`!!'], ])('counts a %s as a keystroke and forwards it unchanged', (_name, input) => { const id = 'encoded-input-acknowledges'; const entry = createSession(id); @@ -933,7 +977,6 @@ describe('terminal-registry alert behavior', () => { ['focus report', '\x1b[I'], ['combined replies', '\x1bP1$r0m\x1b\\\x1b[?1;2c'], ['SGR hover report', '\x1b[<35;10;20M'], - ['SGR wheel report', '\x1b[<64;10;20M'], ['SGR release report', '\x1b[<0;10;20m'], ['urxvt mouse report', '\x1b[64;10;20M'], ['X10 mouse report', '\x1b[M@!!'], @@ -1251,23 +1294,6 @@ describe('terminal-registry alert behavior', () => { }); }); - it('ignores prompt redraw output immediately after a resize', () => { - const id = 'resize-debounce'; - const session = createSession(id); - enableAlert(id); - engageSession(id); - - session.terminal.emitResize(120, 30); - emitOutput(id, 'prompt redraw'); - advance(1_600); - emitOutput(id, 'working...'); - - expect(getActivity(id)).toMatchObject({ - status: 'NOTHING_TO_SHOW', - todo: false, - }); - }); - }); describe('pending shell opts → spawnPty', () => { diff --git a/lib/src/lib/terminal-report-filter.test.ts b/lib/src/lib/terminal-report-filter.test.ts index 2dd27faea..cd895fe50 100644 --- a/lib/src/lib/terminal-report-filter.test.ts +++ b/lib/src/lib/terminal-report-filter.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from 'vitest'; import { + inputCanStartWork, inputIsReplayTerminalReport, inputIsSyntheticTerminalReport, stripMouseReportsFromInput, + stripPassiveMouseReportsFromInput, } from './terminal-report-filter'; describe('terminal-report-filter: mouse reports', () => { @@ -25,6 +27,19 @@ describe('terminal-report-filter: mouse reports', () => { }); }); +describe('terminal-report-filter: passive mouse reports', () => { + it('keeps wheel ticks in every encoding, modifiers included', () => { + for (const wheel of ['\x1b[<64;12;4M', '\x1b[<67;12;4M', '\x1b[<84;12;4M', '\x1b[96;12;4M', '\x1b[M`!!', '\x1b[Ma!!']) { + expect(stripPassiveMouseReportsFromInput(wheel)).toBe(wheel); + } + }); + + it('strips hover, drag motion, buttons, and releases', () => { + const input = '\x1b[<35;12;4M\x1b[<32;12;4M\x1b[<0;12;4M\x1b[<0;12;4m\x1b[<128;12;4M\x1b[64;12;4M\x1b[M@!!\x1b[M !!'; + expect(stripPassiveMouseReportsFromInput(input)).toBe(''); + }); +}); + describe('terminal-report-filter: inputIsSyntheticTerminalReport', () => { it('accepts a string made entirely of terminal report escapes', () => { expect(inputIsSyntheticTerminalReport('\x1b[0n')).toBe(true); // DSR OK @@ -79,3 +94,36 @@ describe('terminal-report-filter: inputIsReplayTerminalReport', () => { expect(inputIsReplayTerminalReport('\x1b[3;4Rx')).toBe(false); // report + stray char }); }); + +describe('terminal-report-filter: inputCanStartWork', () => { + it('rejects terminal replies and mouse reports', () => { + expect(inputCanStartWork('\x1b[I')).toBe(false); // focus in + expect(inputCanStartWork('\x1b[O')).toBe(false); // focus out + expect(inputCanStartWork('\x1b[12;3R')).toBe(false); // cursor position report + expect(inputCanStartWork('\x1b[<0;12;4M\x1b[<0;12;4m')).toBe(false); // SGR click + expect(inputCanStartWork('\x1b[<35;12;4M')).toBe(false); // SGR hover + }); + + it('rejects a wheel tick, input that asks for no work', () => { + expect(inputCanStartWork('\x1b[<65;12;4M', { userInput: true })).toBe(false); + }); + + it('accepts a reply-shaped key a human typed', () => { + expect(inputCanStartWork('\x1b[1;2R')).toBe(false); + expect(inputCanStartWork('\x1b[1;2R', { userInput: true })).toBe(true); // Shift+F3 + }); + + it('accepts keys, Ctrl-C, and a key typed alongside a mouse report', () => { + expect(inputCanStartWork('a')).toBe(true); + expect(inputCanStartWork('\r')).toBe(true); + expect(inputCanStartWork('\x1b[A')).toBe(true); // cursor up + expect(inputCanStartWork('\x03')).toBe(true); + expect(inputCanStartWork('\x1b[<0;12;4Ma')).toBe(true); + expect(inputCanStartWork('x'.repeat(1_000_000))).toBe(true); // a large paste + }); + + it('reads a mouse report beside a focus report as the renderer does: a keystroke', () => { + // Not a reply as a whole and not mouse-only, so `terminal-lifecycle` writes it as user input. + expect(inputCanStartWork('\x1b[<0;12;4M\x1b[I')).toBe(true); + }); +}); diff --git a/lib/src/lib/terminal-report-filter.ts b/lib/src/lib/terminal-report-filter.ts index 1fe1ed930..221f25c52 100644 --- a/lib/src/lib/terminal-report-filter.ts +++ b/lib/src/lib/terminal-report-filter.ts @@ -41,6 +41,36 @@ export function stripMouseReportsFromInput(data: string): string { return data.replace(MOUSE_REPORT_TOKENS, ''); } +/** A wheel tick: button 64–67 with any modifiers, never motion. X10 and URXVT + * carry the button offset by 32; SGR carries it bare. */ +function isWheelReport(report: string): boolean { + const button = report[2] === 'M' ? report.charCodeAt(3) - 32 + : report[2] === '<' ? parseInt(report.slice(3), 10) + : parseInt(report.slice(2), 10) - 32; + return (button & 0b1110_0000) === 64; +} + +/** + * Strips the mouse reports no deliberate gesture made: hover, drag motion, and + * buttons, a click acknowledging through the Pane's DOM handler instead. A + * wheel tick stays — scrolling a program is input to it, as the arrow keys + * xterm sends for the wheel on an alternate screen without mouse reporting are. + */ +export function stripPassiveMouseReportsFromInput(data: string): string { + return data.replace(MOUSE_REPORT_TOKENS, (report) => (isWheelReport(report) ? report : '')); +} + +/** + * Input a program may start work on: anything but mouse reports and, unless a + * human typed it, terminal replies — a focus report among them — read as the + * renderer reads them. A wheel tick is the user's input but asks for no work. + */ +export function inputCanStartWork(data: string, { userInput = false }: { userInput?: boolean } = {}): boolean { + // Both start with ESC; checking first keeps a large paste off the regexes. + if (data.length > 0 && data.charCodeAt(0) !== 0x1b) return true; + return (userInput || !inputIsReplayTerminalReport(data)) && stripMouseReportsFromInput(data).length > 0; +} + // Baseline for a dead Session's terminal only; a live process still owns its modes. The // mouse-encoding resets are parser-only (`terminal.modes` does not expose them). // See docs/specs/transport.md §Replay-time mode-reset tail (Dormouse-emitted). diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index f5e2578a8..9febe4ef7 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -3,7 +3,7 @@ "SECURITY.md": 200, "SELF_HOST.md": 4600, "docs/compatible-agents.md": 1800, - "docs/specs/alert.md": 7100, + "docs/specs/alert.md": 7150, "docs/specs/auto-update.md": 1000, "docs/specs/deploy.md": 2200, "docs/specs/dor-browser.md": 5800, diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index a747dedee..57748c7f6 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -83,7 +83,7 @@ configurePeerLink({ invalidateDirectory: notifyDirectoryChanged, streamPty: processedPtyStreams.streamPty, writePty: writeClientInput, - resizePty: resizeForClient, + resizePty: ptyManager.resize, // Peer PTYs use generated provider-local route handles. Keep those handles // outside this window's real PTY namespace so local ids always fall through // to the manager that owns them. @@ -108,7 +108,7 @@ configureBurrow({ broadcastToWebviews, streamPty: processedPtyStreams.streamPty, writePty: writeClientInput, - resizePty: resizeForClient, + resizePty: ptyManager.resize, }); /** @@ -120,10 +120,6 @@ function writeClientInput(ptyId: string, data: string): void { for (const listener of clientInputListeners) listener(ptyId); } -function resizeForClient(ptyId: string, cols: number, rows: number, repaint?: boolean): void { - alertedPtys.resize(ptyId, cols, rows, repaint); -} - /** * Put one question to every webview in this window and settle with everything * they answered. @@ -206,7 +202,7 @@ export function reportWindowPresence(state: vscode.WindowState): void { const active = (state as { active?: unknown }).active === true; alertManager.setViewer(WINDOW_VIEWER, { present: state.focused && active, focusId: null }); } -/** Every PTY write and resize, as the alerts must see it. */ +/** Every PTY write, as the alerts must see it. */ const alertedPtys = alertedPty(alertManager, ptyManager); /** * This window's parse sites: one per PTY generation, created at spawn and fed @@ -604,7 +600,7 @@ export function attachRouter( alertedPtys.write(msg.id, msg.data, { paced: msg.paced, userInput: msg.userInput === true }); break; case 'pty:resize': - alertedPtys.resize(msg.id, msg.cols, msg.rows); + ptyManager.resize(msg.id, msg.cols, msg.rows); break; case 'pty:kill': release(msg.id); diff --git a/vscode-ext/test/message-router.test.ts b/vscode-ext/test/message-router.test.ts index 749bbe3c8..5eb16405e 100644 --- a/vscode-ext/test/message-router.test.ts +++ b/vscode-ext/test/message-router.test.ts @@ -576,8 +576,8 @@ describe('engagement viewers', () => { ptys.callbacks!.onData('pty-remote', REPORT); const atWrite: Array = []; ptys.onWrite = (id) => void atWrite.push(status(id)); - // A phone's scroll in tmux reaches the PTY, acknowledging nothing, as the desktop's does. - deps.writePty('pty-remote', '\x1b[<64;10;5M'); + // A phone's tap in tmux reaches the PTY, acknowledging nothing, as the desktop's does. + deps.writePty('pty-remote', '\x1b[<0;10;5M'); deps.writePty('pty-remote', 'y'); expect(atWrite).toEqual(['ALERT_RINGING', 'WATCHING_DISABLED']); expect(router.getAlertStates().get('pty-remote')?.todo).toBe(false); @@ -708,6 +708,7 @@ describe('alarm delivery', () => { // Focus alone is not presence: an older VS Code reports no `active`. alert(webview, { op: 'dismiss', id: 'pty-1' }); + webview.send({ type: 'pty:input', id: 'pty-1', data: 'y\r' }); ptys.callbacks!.onData('pty-1', 'output'); moveWindow({ focused: true }); ptys.callbacks!.onData('pty-1', REPORT); @@ -774,7 +775,7 @@ it('replays every listed PTY at a reconnect, an empty buffer included', () => { /** * A Session's alert state follows its PTY here, whoever asked * (docs/specs/alert.md): started over, and seeded, at the spawn; given the - * resize grace at the resize; removed at the kill. + * work-start window at each input; removed at the kill. */ describe('alert state follows the PTY', () => { it('spawns a cold-restored pane with its persisted TODO, and tells its webview', () => { @@ -796,17 +797,22 @@ describe('alert state follows the PTY', () => { } }); - it('opens the resize grace before the PTY resizes', async () => { - // The router's own instance: `resetModules` gave this test one registry. - const { AlertManager } = await import('../../lib/src/lib/alert-manager'); - vi.spyOn(AlertManager.prototype, 'onResize').mockImplementation((id) => void ptys.order.push(`grace ${id}`)); + it('lets a write start work: output after it defers a report', () => { + vi.useFakeTimers(); const webview = fakeWebview(); const disposable = router.attachRouter(webview.channel); try { - webview.send({ type: 'pty:resize', id: 'pty-sized', cols: 100, rows: 30 }); - expect(ptys.order).toEqual(['grace pty-sized', 'resize pty-sized']); + webview.send({ type: 'pty:spawn', id: 'pty-sent', options: { cwd: '/repo' } }); + webview.send({ type: 'pty:input', id: 'pty-sent', data: 'hi\r', paced: true }); + for (let t = 0; t < 2_000; t += 100) { + vi.advanceTimersByTime(100); + ptys.callbacks!.onData('pty-sent', 'frame'); + } + ptys.callbacks!.onData('pty-sent', REPORT); + expect(router.getAlertStates().get('pty-sent')?.status).not.toBe('ALERT_RINGING'); } finally { disposable.dispose(); + vi.useRealTimers(); } }); diff --git a/website/src/lib/alert-programs.test.ts b/website/src/lib/alert-programs.test.ts index 14b27f517..e38778ece 100644 --- a/website/src/lib/alert-programs.test.ts +++ b/website/src/lib/alert-programs.test.ts @@ -44,13 +44,19 @@ afterEach(() => { vi.useRealTimers(); }); +/** Typing into the pane: the adapter hears it as the playground's input path + * does, and with no xterm mounted here the shell takes the bytes directly. */ +const type = (data: string) => { + adapter.writePty(PANE, data, { userInput: true }); + shell.handleInput(data); +}; const lookAt = (id: string) => adapter.alertEngagement({ present: true, focusId: id }); const screen = () => output.join(""); describe("alert programs", () => { it("agent rings with its question once its screen settles, while the user is elsewhere", () => { lookAt(OTHER); - shell.handleInput("agent\r"); + type("agent\r"); vi.advanceTimersByTime(TURN_MS); expect(screen()).toContain("\x1b]9;agent: Allow edit to README.md?\x07"); expect(latest?.status).not.toBe("ALERT_RINGING"); @@ -61,7 +67,7 @@ describe("alert programs", () => { it("agent leaves a TODO, not a ring, when the user is watching its pane", () => { lookAt(PANE); - shell.handleInput("agent\r"); + type("agent\r"); vi.advanceTimersByTime(TURN_MS + QUIET_MS); expect(latest).toMatchObject({ status: "WATCHING_DISABLED", todo: true, notification: { source: "OSC 9" } }); }); @@ -69,7 +75,7 @@ describe("alert programs", () => { it("agent --quiet sends nothing and rings through the default agent rule", () => { adapter.alertSetWatchedCommands([...DEFAULT_WATCHED_COMMANDS]); lookAt(OTHER); - shell.handleInput("agent --quiet\r"); + type("agent --quiet\r"); vi.advanceTimersByTime(TURN_MS + QUIET_MS); expect(screen()).not.toContain("\x1b]9;"); expect(latest).toMatchObject({ status: "ALERT_RINGING", notification: { source: "WATCHING" } }); @@ -77,7 +83,7 @@ describe("alert programs", () => { it("build rings on exit when the user started it and then looked away", () => { lookAt(PANE); - shell.handleInput("build\r"); + type("build\r"); lookAt(OTHER); vi.advanceTimersByTime(BUILD_MS - 1); expect(latest?.status).not.toBe("ALERT_RINGING"); @@ -86,19 +92,19 @@ describe("alert programs", () => { }); it.each(["agent", "build"])("%s quits on Ctrl-C", (name) => { - shell.handleInput(`${name}\r`); - shell.handleInput("\x03"); + type(`${name}\r`); + type("\x03"); expect(screen()).toContain("\x1b]633;D;130\x07"); }); it("agent takes another request after an answer", () => { lookAt(OTHER); - shell.handleInput("agent\r"); + type("agent\r"); vi.advanceTimersByTime(TURN_MS); output.length = 0; - shell.handleInput("y"); + type("y"); expect(screen()).toContain("Edited README.md"); - shell.handleInput("fix the tests\r"); + type("fix the tests\r"); vi.advanceTimersByTime(200); expect(screen()).toContain("Working on fix the tests"); });