From 9f44c7e99c7aca7fce6688c537a7ae009f977121 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Mon, 19 Oct 2026 22:31:04 -0700 Subject: [PATCH 1/8] Start a busy run only from input, so a repaint on focus or resize is never work A click into an idle Claude Code pane sends a focus report; Claude repaints text, the detector called it work, and leaving the pane rang a false "claude went quiet". A busy run now has to begin within cfg.alert.workStartWindow of a write that is not a terminal reply or mouse report, which also replaces the resize grace window. Co-Authored-By: Claude Opus 5.5 --- docs/specs/alert.md | 6 +- docs/specs/alert.rationale.md | 2 + lib/src/cfg.ts | 6 +- lib/src/host/owner-pty.ts | 19 ++-- lib/src/host/remote/sidecar-entry.test.ts | 20 ++-- lib/src/host/remote/sidecar-entry.ts | 6 +- lib/src/lib/alert-delivery-scheduler.test.ts | 3 +- lib/src/lib/alert-engagement.test.ts | 82 ++++++++++++-- lib/src/lib/alert-manager-test-utils.ts | 9 +- lib/src/lib/alert-manager.test.ts | 20 +++- lib/src/lib/alert-manager.ts | 8 +- lib/src/lib/alert-resumed-output.test.ts | 28 ++--- lib/src/lib/engagement.test.ts | 1 + lib/src/lib/platform/fake-adapter.test.ts | 2 + lib/src/lib/platform/fake-adapter.ts | 3 +- lib/src/lib/platform/types.ts | 3 +- lib/src/lib/quiesce-detector.test.ts | 107 +++++++++++++++---- lib/src/lib/quiesce-detector.ts | 35 +++--- lib/src/lib/terminal-registry.alert.test.ts | 16 ++- lib/src/lib/terminal-report-filter.test.ts | 18 ++++ lib/src/lib/terminal-report-filter.ts | 7 ++ vscode-ext/src/message-router.ts | 4 +- vscode-ext/test/message-router.test.ts | 11 +- 23 files changed, 300 insertions(+), 116 deletions(-) diff --git a/docs/specs/alert.md b/docs/specs/alert.md index 1929aeef9..5c834c143 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -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, selection, or focus changes. **A busy run must begin within `cfg.alert.workStartWindow` of input reaching the program**: any write but terminal replies and mouse reports, `dor send` and launches included. Later output, such as a repaint on focus or resize, 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 diff --git a/docs/specs/alert.rationale.md b/docs/specs/alert.rationale.md index 33a514bdb..73131986a 100644 --- a/docs/specs/alert.rationale.md +++ b/docs/specs/alert.rationale.md @@ -98,6 +98,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 ran inside Claude's own `OSC 9;4` cycle, which shows and rings without the detector. The gate replaced a 500 ms grace after each resize, which covered one cause of the same repaint. Any write but a reply opens it, so `dor send` and launches still start work; work a program starts unasked, such as a background task waking Claude, no longer reads as busy. + **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..c3451c8fc 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 { inputCanStartWork, stripMouseReportsFromInput } from '../lib/terminal-report-filter'; /** * What the process that owns a PTY does with it on behalf of its alerts, the @@ -88,21 +88,22 @@ 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: **human input is acknowledged, + * echo window opened, and any input but terminal replies opens the work-start + * window, all 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 }); + if (inputCanStartWork(data)) alerts.onInput(id); pty.write(id, data, options.paced ? { paced: true } : undefined); }; return { @@ -115,9 +116,5 @@ export function alertedPty(alerts: AlertManager, pty: PtyWriteTarget) { 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); - }, }; } diff --git a/lib/src/host/remote/sidecar-entry.test.ts b/lib/src/host/remote/sidecar-entry.test.ts index b02173d21..b2e9363c0 100644 --- a/lib/src/host/remote/sidecar-entry.test.ts +++ b/lib/src/host/remote/sidecar-entry.test.ts @@ -406,13 +406,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 +802,13 @@ 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('opens the work-start window before an input\'s write, and never for a terminal reply', () => { + const opened: string[] = []; + vi.spyOn(host.alerts, 'onInput').mockImplementation((id) => void opened.push(`input ${id} after ${calls.length} calls`)); + host.handleCommand('pty:input', { id: 'pty-1', data: '\x1b[I' }); + host.handleCommand('pty:input', { id: 'pty-1', data: 'hi\r', paced: true }); + expect(opened).toEqual(['input pty-1 after 1 calls']); + expect(calls.map((call) => call.op)).toEqual(['write', 'write']); }); 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..663e44312 100644 --- a/lib/src/lib/alert-delivery-scheduler.test.ts +++ b/lib/src/lib/alert-delivery-scheduler.test.ts @@ -26,9 +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 input and its output. */ const ringAgain = () => { manager.acknowledge(PANE, { input: false }); + manager.onInput(PANE); manager.onData(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..d1b0d499b 100644 --- a/lib/src/lib/alert-engagement.test.ts +++ b/lib/src/lib/alert-engagement.test.ts @@ -37,9 +37,21 @@ function output(id: string, ms: number, everyMs = 100): void { } } +/** Input that sets the program working, then `ms` of its output. */ +function work(id: string, ms: number): void { + manager.onInput(id); + output(id, ms); +} + +/** A key the user presses, as the host's input path delivers it. */ +function press(id: string): void { + manager.acknowledge(id, { input: true }); + manager.onInput(id); +} + /** One keystroke into the pane, echoed a few ms later. */ function keystroke(id: string): void { - manager.acknowledge(id, { input: true }); + press(id); vi.advanceTimersByTime(8); manager.onData(id); } @@ -147,7 +159,7 @@ describe('held completions', () => { }); it('sends an escalated report through animation deferral', () => { - output(PANE, 3_000); + work(PANE, 3_000); engage(manager, PANE); manager.notifyFromProtocol(PANE, PERMISSION); output(PANE, 1_000); @@ -160,7 +172,7 @@ describe('held completions', () => { }); it('holds a ring that comes due while engaged without ever having shown', () => { - output(PANE, 3_000); + work(PANE, 3_000); manager.notifyFromProtocol(PANE, PERMISSION); engage(manager, PANE); vi.advanceTimersByTime(5_000); @@ -171,7 +183,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); + work(PANE, 3_000); manager.notifyFromProtocol(PANE, PERMISSION); engage(manager, PANE); leave(manager); @@ -205,7 +217,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. + work(PANE, 3_000); goIdle(manager, PANE); expect(ringing(PANE)).toBe(false); vi.advanceTimersByTime(5_000); @@ -261,7 +274,7 @@ describe('acknowledge', () => { /** A report behind confirmed work: a ring that has not shown. */ function deferredReport(): void { - output(PANE, 3_000); + work(PANE, 3_000); manager.notifyFromProtocol(PANE, PERMISSION); } @@ -344,7 +357,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(PANE); vi.advanceTimersByTime(cfg.alert.echoWindow); output(PANE, 2_000); // Confirmed busy, so a report now waits for quiet. @@ -367,6 +380,55 @@ 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('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 +446,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(PANE)), at(t + 7, () => manager.onData(PANE))); } - steps.push(at(ENTER_AT, () => manager.acknowledge(PANE, { input: true }))); + steps.push(at(ENTER_AT, () => press(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 +510,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..e750a7406 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,10 @@ 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. */ +/** 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.onInput(id); manager.applyTerminalSemanticEvents(id, [ { type: 'commandLine', commandLine }, { type: 'commandStart', source: 'osc633_E', startedAt: Date.now() }, @@ -68,8 +70,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. */ +/** Input, then two output bursts across the busy-candidate gap: NOTHING_TO_SHOW -> MIGHT_BE_BUSY. */ export function driveToCandidate(manager: AlertManager, id: string): void { + manager.onInput(id); manager.onData(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..0d25b77b2 100644 --- a/lib/src/lib/alert-manager.test.ts +++ b/lib/src/lib/alert-manager.test.ts @@ -51,7 +51,7 @@ describe('AlertManager in isolation', () => { manager.toggleTodo(HELPER); manager.clearTodo(HELPER); manager.seed(HELPER, { todo: true }); - manager.onResize(HELPER); + manager.onInput(HELPER); finishCommand(manager, HELPER, 1); manager.onExit(HELPER, 0); vi.advanceTimersByTime(10_000); @@ -200,6 +200,8 @@ describe('AlertManager in isolation', () => { settle(); expect(manager.getState(id).status).toBe('ALERT_RINGING'); + // The next turn starts. + manager.onInput(id); manager.onData(id); expect(manager.getState(id).status).toBe('ALERT_RINGING'); @@ -410,6 +412,7 @@ describe('AlertManager in isolation', () => { const id = 'acknowledged-then-output'; manager.notifyFromProtocol(id, { source: 'OSC 9', title: null, body: 'first' }); manager.dismissAlert(id); + manager.onInput(id); manager.onData(id); manager.notifyFromProtocol(id, { source: 'OSC 9', title: null, body: 'second' }); vi.advanceTimersByTime(5_000); @@ -535,6 +538,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.onInput(id); manager.onData(id); manager.updateProtocolProgress(id, { state: 'clear', percent: null }); vi.advanceTimersByTime(5_000); @@ -715,6 +719,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.onInput(id); manager.onData(id); applyTerminalEvents(manager, id, [{ kind: 'notification', notification: bell }]); vi.advanceTimersByTime(5_000); @@ -1134,6 +1139,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.onInput('one-chunk'); manager.onData('one-chunk'); manager.notifyFromProtocol('one-chunk', REPORT); expect(manager.getState('one-chunk')).toMatchObject({ status: 'ALERT_RINGING', notification: REPORT }); @@ -1365,6 +1371,7 @@ describe('AlertManager in isolation', () => { applyTerminalEvents(manager, id, parsed.events); if (parsed.visibleData.length > 0) manager.onData(id); }; + manager.onInput(id); 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,6 +1616,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.onInput(id); manager.onData(id); manager.notifyFromProtocol(id, { source: 'OSC 9', title: null, body: 'later' }); vi.advanceTimersByTime(5_000); @@ -1711,6 +1719,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.onInput(id); manager.onData(id); vi.advanceTimersByTime(gapMs); manager.onData(id); @@ -1736,6 +1745,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.onInput(id); manager.onData(id); vi.advanceTimersByTime(1_600); manager.onData(id); @@ -1900,8 +1910,10 @@ describe('AlertManager in isolation', () => { const quietOutcome = watch(quiet); const strictOutcome = watch(strict); - manager.onData('await-grace-quiet'); - manager.onData('await-grace-exit'); + for (const id of ['await-grace-quiet', 'await-grace-exit']) { + manager.onInput(id); + manager.onData(id); + } vi.advanceTimersByTime(AWAIT_GRACE_MS); // Output is evidence of work for `quiet`; for `exit` only a command start is. @@ -2022,7 +2034,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); + manager.onInput(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..b0a050e47 100644 --- a/lib/src/lib/alert-manager.ts +++ b/lib/src/lib/alert-manager.ts @@ -404,10 +404,10 @@ 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(); + /** Input other than terminal replies reached the PTY: its output may start work. */ + onInput(id: string): void { + // Same reasoning as `onData`: the first keystroke can beat any output. + this.streamEntry(id)?.detector.onInput(); } // --- WATCHING rule set --- diff --git a/lib/src/lib/alert-resumed-output.test.ts b/lib/src/lib/alert-resumed-output.test.ts index 44a5b2f96..26f06b649 100644 --- a/lib/src/lib/alert-resumed-output.test.ts +++ b/lib/src/lib/alert-resumed-output.test.ts @@ -5,6 +5,7 @@ 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 { toPersistedAlertState } from './session-types'; import { createOwnerPtyStream } from '../host/owner-pty'; +import { cfg } from '../cfg'; const ID = 'resumed-work'; const WATCHED = 'longtask'; @@ -42,6 +43,7 @@ describe('owed alerts during resumed output', () => { manager.onData(ID); manager.registerCompletionClaimant(ID, (event) => { if (event.kind !== 'settled') return false; + manager.onInput(ID); manager.onData(ID); return true; }); @@ -69,6 +71,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. + manager.onInput(ID); heartbeat(manager, ID, 120_000); expect(manager.getState(ID).status).not.toBe('ALERT_RINGING'); expect(manager.getState(ID).episode).toEqual(episode); @@ -157,27 +161,25 @@ 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(cfg.alert.workStartWindow + 1); ring('report'); - manager.onResize(ID); - manager.onData(ID); - expect(manager.getState(ID).status).toBe('ALERT_RINGING'); - vi.advanceTimersByTime(DELAY); + // A resize or focus repaint, with nothing typed since the command started. + for (let elapsed = 0; elapsed < 3_000; elapsed += 100) { + vi.advanceTimersByTime(100); + manager.onData(ID); + expect(manager.getState(ID).status).toBe('ALERT_RINGING'); + } + vi.advanceTimersByTime(DELAY - 3_000); 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('ignores repeated typing echoes', () => { manager.acknowledge(ID, { input: true }); for (let i = 0; i < 20; i++) { vi.advanceTimersByTime(400); + // A keystroke as the host's input path delivers it, then its echo. manager.acknowledge(ID, { input: true }); + manager.onInput(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..e6624b043 100644 --- a/lib/src/lib/platform/fake-adapter.ts +++ b/lib/src/lib/platform/fake-adapter.ts @@ -12,6 +12,7 @@ import { applyTerminalSemanticEvents } from '../terminal-state-store'; import { themeColorProvider } from '../terminal-theme'; import { applyLiveToolEvents } from '../tool-events'; import { offerProgramCopy } from '../mouse-selection'; +import { inputCanStartWork } from '../terminal-report-filter'; /** This renderer is its host's one realm. */ const LOCAL_VIEWER = 'local'; @@ -276,6 +277,7 @@ export class FakePtyAdapter implements PlatformAdapter { writePty(id: string, data: string, options?: WritePtyOptions): void { if (options?.userInput) this.alertManager.acknowledge(id, { input: true }); + if (inputCanStartWork(data)) this.alertManager.onInput(id); if (!this.terminals.has(id)) return; if (options?.launch) this.ownerStream(id).armLaunch(data); // Only echo if no scenario is actively playing @@ -290,7 +292,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..00e2effda 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(() => { @@ -24,6 +24,7 @@ describe('QuiesceDetector', () => { } function driveMonitorToBusy(monitor: QuiesceDetector) { + monitor.onInput(); monitor.onData(); vi.advanceTimersByTime(1_500); monitor.onData(); @@ -45,6 +46,7 @@ describe('QuiesceDetector', () => { it('only treats confirmed work and its finishing window as busy', () => { const { monitor, settled } = createMonitor(); expect(monitor.isConfirmedBusy()).toBe(false); + monitor.onInput(); monitor.onData(); expect(monitor.isConfirmedBusy()).toBe(false); vi.advanceTimersByTime(1_500); @@ -64,11 +66,10 @@ describe('QuiesceDetector', () => { expect(settled).toHaveBeenCalledOnce(); }); - it('rejects resize redraws and disposed output', () => { + it('rejects output nobody asked for and disposed output', () => { const { monitor } = createMonitor(); - monitor.onResize(); expect(monitor.onData()).toBe(false); - vi.advanceTimersByTime(500); + monitor.onInput(); expect(monitor.onData()).toBe(true); expect(monitor.isConfirmedBusy()).toBe(false); monitor.dispose(); @@ -77,6 +78,7 @@ describe('QuiesceDetector', () => { it('keeps the first meaningful output after a reset in NOTHING_TO_SHOW', () => { const { monitor, changes } = createMonitor(); + monitor.onInput(); monitor.reset(); monitor.onData(); expect(monitor.getStatus()).toBe('NOTHING_TO_SHOW'); @@ -85,6 +87,7 @@ describe('QuiesceDetector', () => { it('enters MIGHT_BE_BUSY when a later output arrives after the candidate gap', () => { const { monitor, changes } = createMonitor(); + monitor.onInput(); monitor.onData(); vi.advanceTimersByTime(1_500); monitor.onData(); @@ -94,6 +97,7 @@ describe('QuiesceDetector', () => { it('can also enter MIGHT_BE_BUSY from dense output once the candidate timer matures', () => { const { monitor, changes } = createMonitor(); + monitor.onInput(); monitor.onData(); vi.advanceTimersByTime(750); monitor.onData(); @@ -105,6 +109,7 @@ describe('QuiesceDetector', () => { it('confirms MIGHT_BE_BUSY into BUSY on further output', () => { const { monitor, changes } = createMonitor(); + monitor.onInput(); monitor.onData(); vi.advanceTimersByTime(1_500); monitor.onData(); @@ -116,6 +121,7 @@ describe('QuiesceDetector', () => { it('falls back from MIGHT_BE_BUSY to NOTHING_TO_SHOW if work is not confirmed', () => { const { monitor, changes, settled } = createMonitor(); + monitor.onInput(); monitor.onData(); vi.advanceTimersByTime(1_500); monitor.onData(); @@ -129,11 +135,14 @@ describe('QuiesceDetector', () => { it.each([false, true])('forgets stale candidate history before a short redraw (failed confirmation: %s)', (failedConfirmation) => { const { monitor, settled } = createMonitor(); + monitor.onInput(); monitor.onData(); vi.advanceTimersByTime(1_500); if (failedConfirmation) monitor.onData(); vi.advanceTimersByTime(218_342); + // Input inside the window, so only the forgotten history keeps it idle. + monitor.onInput(); monitor.onData(); vi.advanceTimersByTime(62); monitor.onData(); @@ -148,11 +157,13 @@ describe('QuiesceDetector', () => { it('expires candidate history even when the confirmation timer has not run', () => { const { monitor, settled } = createMonitor(); + monitor.onInput(); monitor.onData(); vi.advanceTimersByTime(1_500); monitor.onData(); expect(monitor.getStatus()).toBe('MIGHT_BE_BUSY'); vi.setSystemTime(Date.now() + 218_342); + monitor.onInput(); monitor.onData(); expect(monitor.getStatus()).toBe('NOTHING_TO_SHOW'); vi.advanceTimersByTime(62); @@ -288,6 +299,7 @@ describe('QuiesceDetector', () => { it('reset() returns MIGHT_BE_BUSY to NOTHING_TO_SHOW', () => { const { monitor, changes } = createMonitor(); + monitor.onInput(); monitor.onData(); vi.advanceTimersByTime(1_500); monitor.onData(); @@ -312,6 +324,7 @@ describe('QuiesceDetector', () => { it('reset() forgets output history, so the next output starts candidate tracking over', () => { const { monitor, changes } = createMonitor(); + monitor.onInput(); monitor.onData(); vi.advanceTimersByTime(1_400); monitor.reset(); @@ -322,23 +335,76 @@ 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(); + 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(); + // An idle program's repaint, then the keystroke that sets it working. + expect(monitor.onData()).toBe(false); + monitor.onInput(); + 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(); + monitor.onInput(); + 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(); + monitor.onInput(); + 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.onInput(); + 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 +432,7 @@ describe('QuiesceDetector', () => { it('does not emit changes after dispose', () => { const onChange = vi.fn(); const monitor = new QuiesceDetector({ onChange }); + monitor.onInput(); 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..898794b3b 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 input: a program + * repaints on focus, on resize, and on its own timers without working, and + * output it was not 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 lastInputAt = -Infinity; private timers = new Map>(); private firstOutputAt: number | null = null; private lastOutputAt: number | null = null; @@ -73,7 +76,7 @@ export class QuiesceDetector { } 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.lastInputAt > cfg.alert.workStartWindow + ) return false; this.lastOutputAt = now; @@ -105,16 +113,15 @@ export class QuiesceDetector { return true; } - onResize(): void { - if (this.disposed) return; - this.resizeGrace = true; - this.schedule('resize', T_RESIZE_DEBOUNCE); + /** Input reached the program, so output may begin a busy run. Kept across + * `reset`: the keystroke that starts a command precedes its boundary. */ + onInput(): void { + this.lastInputAt = Date.now(); } dispose(): void { this.disposed = true; - for (const timer of this.timers.values()) clearTimeout(timer); - this.timers.clear(); + this.clearActivityTimers(); } private handleNothingToShowOutput(now: number): void { @@ -160,7 +167,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; @@ -186,11 +192,8 @@ export class QuiesceDetector { } private clearActivityTimers(): void { - for (const [kind, timer] of this.timers) { - if (kind === 'resize') continue; - clearTimeout(timer); - this.timers.delete(kind); - } + for (const timer of this.timers.values()) clearTimeout(timer); + this.timers.clear(); } private seedFromLatestOutput(): void { diff --git a/lib/src/lib/terminal-registry.alert.test.ts b/lib/src/lib/terminal-registry.alert.test.ts index d09466f68..10b3c2382 100644 --- a/lib/src/lib/terminal-registry.alert.test.ts +++ b/lib/src/lib/terminal-registry.alert.test.ts @@ -190,7 +190,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 +593,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' }); @@ -1251,16 +1254,19 @@ describe('terminal-registry alert behavior', () => { }); }); - it('ignores prompt redraw output immediately after a resize', () => { - const id = 'resize-debounce'; + it('ignores a resize redraw long after the last input', () => { + const id = 'resize-redraw'; const session = createSession(id); enableAlert(id); engageSession(id); + advance(cfg.alert.workStartWindow + 1); + // sendOutput, not emitOutput: a redraw is output nobody typed. session.terminal.emitResize(120, 30); - emitOutput(id, 'prompt redraw'); + fakePlatform.sendOutput(id, 'prompt redraw'); advance(1_600); - emitOutput(id, 'working...'); + fakePlatform.sendOutput(id, 'prompt redraw'); + fakePlatform.sendOutput(id, 'prompt redraw'); expect(getActivity(id)).toMatchObject({ status: 'NOTHING_TO_SHOW', diff --git a/lib/src/lib/terminal-report-filter.test.ts b/lib/src/lib/terminal-report-filter.test.ts index 2dd27faea..ec44c936d 100644 --- a/lib/src/lib/terminal-report-filter.test.ts +++ b/lib/src/lib/terminal-report-filter.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + inputCanStartWork, inputIsReplayTerminalReport, inputIsSyntheticTerminalReport, stripMouseReportsFromInput, @@ -79,3 +80,20 @@ 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 + }); + + 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); + }); +}); diff --git a/lib/src/lib/terminal-report-filter.ts b/lib/src/lib/terminal-report-filter.ts index 1fe1ed930..9365d9583 100644 --- a/lib/src/lib/terminal-report-filter.ts +++ b/lib/src/lib/terminal-report-filter.ts @@ -41,6 +41,13 @@ export function stripMouseReportsFromInput(data: string): string { return data.replace(MOUSE_REPORT_TOKENS, ''); } +/** Input a program may start work on: anything but terminal replies — a focus + * report among them — and mouse reports. */ +export function inputCanStartWork(data: string): boolean { + const withoutMouseReports = stripMouseReportsFromInput(data); + return withoutMouseReports.length > 0 && !inputIsReplayTerminalReport(withoutMouseReports); +} + // 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/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index a747dedee..540868f86 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -121,7 +121,7 @@ function writeClientInput(ptyId: string, data: string): void { } function resizeForClient(ptyId: string, cols: number, rows: number, repaint?: boolean): void { - alertedPtys.resize(ptyId, cols, rows, repaint); + ptyManager.resize(ptyId, cols, rows, repaint); } /** @@ -604,7 +604,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..0fae9bce6 100644 --- a/vscode-ext/test/message-router.test.ts +++ b/vscode-ext/test/message-router.test.ts @@ -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); @@ -796,15 +797,17 @@ describe('alert state follows the PTY', () => { } }); - it('opens the resize grace before the PTY resizes', async () => { + it('opens the work-start window before an input\'s write, and never for a terminal reply', 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}`)); + vi.spyOn(AlertManager.prototype, 'onInput').mockImplementation((id) => void ptys.order.push(`input ${id}`)); + ptys.onWrite = (id) => void ptys.order.push(`write ${id}`); 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:input', id: 'pty-sent', data: '\x1b[I' }); + webview.send({ type: 'pty:input', id: 'pty-sent', data: 'hi\r', paced: true }); + expect(ptys.order).toEqual(['write pty-sent', 'input pty-sent', 'write pty-sent']); } finally { disposable.dispose(); } From 17eac1e84d64a0add22ea2f2359b07c4f79a81e6 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Mon, 19 Oct 2026 22:46:19 -0700 Subject: [PATCH 2/8] Hear each PTY write in one AlertManager.onWrite, and let OSC 9;4 progress start work /simplify pass: the host and the fake adapter shared a two-step input prologue; it is now one call. An active OSC 9;4 report opens the work-start window like input, so a turn Claude starts unasked still reads as busy and still defers an owed ring. Classification skips writes already marked as human input and returns early when a write doesn't start with ESC. Tests use shared press/work helpers, and the dead resize setup is gone. Co-Authored-By: Claude Opus 5.5 --- docs/specs/alert.md | 2 +- docs/specs/alert.rationale.md | 2 +- lib/src/host/owner-pty.ts | 12 ++--- lib/src/host/remote/sidecar-entry.test.ts | 20 +++++--- lib/src/lib/alert-delivery-scheduler.test.ts | 7 ++- lib/src/lib/alert-engagement.test.ts | 49 ++++++++++++-------- lib/src/lib/alert-manager-test-utils.ts | 18 +++++-- lib/src/lib/alert-manager.test.ts | 38 ++++++--------- lib/src/lib/alert-manager.ts | 17 +++++-- lib/src/lib/alert-resumed-output.test.ts | 43 ++++++++++------- lib/src/lib/platform/fake-adapter.ts | 4 +- lib/src/lib/quiesce-detector.test.ts | 35 +++++--------- lib/src/lib/quiesce-detector.ts | 29 ++++++------ lib/src/lib/terminal-registry.alert.test.ts | 33 ++++--------- lib/src/lib/terminal-report-filter.test.ts | 6 +++ lib/src/lib/terminal-report-filter.ts | 7 +-- vscode-ext/src/message-router.ts | 8 +--- vscode-ext/test/message-router.test.ts | 19 ++++---- 18 files changed, 179 insertions(+), 170 deletions(-) diff --git a/docs/specs/alert.md b/docs/specs/alert.md index 5c834c143..78e82ea18 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -258,7 +258,7 @@ 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, or focus changes. **A busy run must begin within `cfg.alert.workStartWindow` of input reaching the program**: any write but terminal replies and mouse reports, `dor send` and launches included. Later output, such as a repaint on focus or resize, is ignored entirely (rationale). 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. 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. diff --git a/docs/specs/alert.rationale.md b/docs/specs/alert.rationale.md index 73131986a..6efba4601 100644 --- a/docs/specs/alert.rationale.md +++ b/docs/specs/alert.rationale.md @@ -98,7 +98,7 @@ **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 ran inside Claude's own `OSC 9;4` cycle, which shows and rings without the detector. The gate replaced a 500 ms grace after each resize, which covered one cause of the same repaint. Any write but a reply opens it, so `dor send` and launches still start work; work a program starts unasked, such as a background task waking Claude, no longer reads as busy. +**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 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. **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). diff --git a/lib/src/host/owner-pty.ts b/lib/src/host/owner-pty.ts index c3451c8fc..7d269a912 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 { inputCanStartWork, stripMouseReportsFromInput } from '../lib/terminal-report-filter'; +import { stripMouseReportsFromInput } from '../lib/terminal-report-filter'; /** * What the process that owns a PTY does with it on behalf of its alerts, the @@ -95,15 +95,13 @@ export interface PtyWriteTarget { /** * A host's PTY writes as its alerts must see them, whoever asked — a local - * renderer, a remote Client, or a `dor` verb: **human input is acknowledged, - * echo window opened, and any input but terminal replies opens the work-start - * window, all before its bytes reach the PTY** (`docs/specs/alert.md` → - * Engagement). + * 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 }); - if (inputCanStartWork(data)) alerts.onInput(id); + alerts.onWrite(id, data, options); pty.write(id, data, options.paced ? { paced: true } : undefined); }; return { diff --git a/lib/src/host/remote/sidecar-entry.test.ts b/lib/src/host/remote/sidecar-entry.test.ts index b2e9363c0..1458db0b6 100644 --- a/lib/src/host/remote/sidecar-entry.test.ts +++ b/lib/src/host/remote/sidecar-entry.test.ts @@ -802,13 +802,19 @@ describe('the sidecar host', () => { expect(opens()).toEqual(['/launched']); }); - it('opens the work-start window before an input\'s write, and never for a terminal reply', () => { - const opened: string[] = []; - vi.spyOn(host.alerts, 'onInput').mockImplementation((id) => void opened.push(`input ${id} after ${calls.length} calls`)); - host.handleCommand('pty:input', { id: 'pty-1', data: '\x1b[I' }); - host.handleCommand('pty:input', { id: 'pty-1', data: 'hi\r', paced: true }); - expect(opened).toEqual(['input pty-1 after 1 calls']); - expect(calls.map((call) => call.op)).toEqual(['write', 'write']); + 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/lib/alert-delivery-scheduler.test.ts b/lib/src/lib/alert-delivery-scheduler.test.ts index 663e44312..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,11 +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 input and its output. */ +/** Clear the ring with a click, then allow a fresh report after work. */ const ringAgain = () => { manager.acknowledge(PANE, { input: false }); - manager.onInput(PANE); - 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 d1b0d499b..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,21 +37,15 @@ function output(id: string, ms: number, everyMs = 100): void { } } -/** Input that sets the program working, then `ms` of its output. */ -function work(id: string, ms: number): void { - manager.onInput(id); +/** Work, then `ms` more of its output. */ +function workFor(id: string, ms: number): void { + work(manager, id); output(id, ms); } -/** A key the user presses, as the host's input path delivers it. */ -function press(id: string): void { - manager.acknowledge(id, { input: true }); - manager.onInput(id); -} - /** One keystroke into the pane, echoed a few ms later. */ function keystroke(id: string): void { - press(id); + press(manager, id); vi.advanceTimersByTime(8); manager.onData(id); } @@ -159,7 +153,7 @@ describe('held completions', () => { }); it('sends an escalated report through animation deferral', () => { - work(PANE, 3_000); + workFor(PANE, 3_000); engage(manager, PANE); manager.notifyFromProtocol(PANE, PERMISSION); output(PANE, 1_000); @@ -172,7 +166,7 @@ describe('held completions', () => { }); it('holds a ring that comes due while engaged without ever having shown', () => { - work(PANE, 3_000); + workFor(PANE, 3_000); manager.notifyFromProtocol(PANE, PERMISSION); engage(manager, PANE); vi.advanceTimersByTime(5_000); @@ -183,7 +177,7 @@ describe('held completions', () => { }); it('rings a never-shown deferred ring once quiet if the viewer left before it came due', () => { - work(PANE, 3_000); + workFor(PANE, 3_000); manager.notifyFromProtocol(PANE, PERMISSION); engage(manager, PANE); leave(manager); @@ -218,7 +212,7 @@ describe('held completions', () => { engage(manager, PANE); watchedTurn(PANE); // Another turn arrives, sent by a peer rather than typed. - work(PANE, 3_000); + workFor(PANE, 3_000); goIdle(manager, PANE); expect(ringing(PANE)).toBe(false); vi.advanceTimersByTime(5_000); @@ -274,7 +268,7 @@ describe('acknowledge', () => { /** A report behind confirmed work: a ring that has not shown. */ function deferredReport(): void { - work(PANE, 3_000); + workFor(PANE, 3_000); manager.notifyFromProtocol(PANE, PERMISSION); } @@ -357,7 +351,7 @@ describe('echo window', () => { it('counts output once the window has passed', () => { manager.acknowledge(PANE, { input: false }); manager.onData(PANE); - press(PANE); + press(manager, PANE); vi.advanceTimersByTime(cfg.alert.echoWindow); output(PANE, 2_000); // Confirmed busy, so a report now waits for quiet. @@ -412,6 +406,23 @@ describe('the work-start gate', () => { 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. @@ -446,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, () => press(PANE)), at(t + 7, () => manager.onData(PANE))); + steps.push(at(t, () => press(manager, PANE)), at(t + 7, () => manager.onData(PANE))); } - steps.push(at(ENTER_AT, () => press(PANE))); + 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))); diff --git a/lib/src/lib/alert-manager-test-utils.ts b/lib/src/lib/alert-manager-test-utils.ts index e750a7406..2091cba95 100644 --- a/lib/src/lib/alert-manager-test-utils.ts +++ b/lib/src/lib/alert-manager-test-utils.ts @@ -31,10 +31,21 @@ export function goIdle(manager: AlertManager, id: string, viewer = VIEWER): void manager.setViewer(viewer, { present: false, focusId: id }, 'idle'); } +/** 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.onInput(id); + manager.onWrite(id, '\r'); manager.applyTerminalSemanticEvents(id, [ { type: 'commandLine', commandLine }, { type: 'commandStart', source: 'osc633_E', startedAt: Date.now() }, @@ -70,10 +81,9 @@ export function armCommandExit(manager: AlertManager, id: string, commandLine = leave(manager); } -/** Input, then 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.onInput(id); - 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 0d25b77b2..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.onInput(HELPER); + press(manager, HELPER); finishCommand(manager, HELPER, 1); manager.onExit(HELPER, 0); vi.advanceTimersByTime(10_000); @@ -201,8 +201,7 @@ describe('AlertManager in isolation', () => { expect(manager.getState(id).status).toBe('ALERT_RINGING'); // The next turn starts. - manager.onInput(id); - manager.onData(id); + work(manager, id); expect(manager.getState(id).status).toBe('ALERT_RINGING'); for (let i = 0; i < 10; i++) { @@ -412,8 +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.onInput(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'); @@ -538,8 +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.onInput(id); - manager.onData(id); + work(manager, id); manager.updateProtocolProgress(id, { state: 'clear', percent: null }); vi.advanceTimersByTime(5_000); @@ -719,8 +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.onInput(id); - manager.onData(id); + work(manager, id); applyTerminalEvents(manager, id, [{ kind: 'notification', notification: bell }]); vi.advanceTimersByTime(5_000); const second = manager.getState(id).episode; @@ -1139,8 +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.onInput('one-chunk'); - manager.onData('one-chunk'); + work(manager, 'one-chunk'); manager.notifyFromProtocol('one-chunk', REPORT); expect(manager.getState('one-chunk')).toMatchObject({ status: 'ALERT_RINGING', notification: REPORT }); }); @@ -1371,7 +1366,7 @@ describe('AlertManager in isolation', () => { applyTerminalEvents(manager, id, parsed.events); if (parsed.visibleData.length > 0) manager.onData(id); }; - manager.onInput(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'); @@ -1616,8 +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.onInput(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'); @@ -1719,8 +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.onInput(id); - manager.onData(id); + work(manager, id); vi.advanceTimersByTime(gapMs); manager.onData(id); vi.advanceTimersByTime(gapMs); @@ -1745,8 +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.onInput(id); - manager.onData(id); + work(manager, id); vi.advanceTimersByTime(1_600); manager.onData(id); vi.advanceTimersByTime(10_000); @@ -1910,10 +1902,8 @@ describe('AlertManager in isolation', () => { const quietOutcome = watch(quiet); const strictOutcome = watch(strict); - for (const id of ['await-grace-quiet', 'await-grace-exit']) { - manager.onInput(id); - manager.onData(id); - } + 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. @@ -2034,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.onInput(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 b0a050e47..97693aeca 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'); } - /** Input other than terminal replies reached the PTY: its output may start work. */ - onInput(id: string): void { + /** + * 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. - this.streamEntry(id)?.detector.onInput(); + if (userInput || inputCanStartWork(data)) 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 26f06b649..6e72d3767 100644 --- a/lib/src/lib/alert-resumed-output.test.ts +++ b/lib/src/lib/alert-resumed-output.test.ts @@ -2,10 +2,9 @@ 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'; -import { cfg } from '../cfg'; const ID = 'resumed-work'; const WATCHED = 'longtask'; @@ -30,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(); } @@ -43,8 +49,7 @@ describe('owed alerts during resumed output', () => { manager.onData(ID); manager.registerCompletionClaimant(ID, (event) => { if (event.kind !== 'settled') return false; - manager.onInput(ID); - manager.onData(ID); + work(manager, ID); return true; }); settle(); @@ -72,7 +77,7 @@ describe('owed alerts during resumed output', () => { ring(source); const episode = manager.getState(ID).episode; // Another turn starts, and runs for two minutes. - manager.onInput(ID); + work(manager, ID); heartbeat(manager, ID, 120_000); expect(manager.getState(ID).status).not.toBe('ALERT_RINGING'); expect(manager.getState(ID).episode).toEqual(episode); @@ -162,24 +167,28 @@ describe('owed alerts during resumed output', () => { expect(manager.getState(ID)).toMatchObject({ status: 'ALERT_RINGING', episode }); }); it('ignores a repaint long after the last input when deciding whether to defer', () => { - vi.advanceTimersByTime(cfg.alert.workStartWindow + 1); + vi.advanceTimersByTime(60_000); ring('report'); // A resize or focus repaint, with nothing typed since the command started. - for (let elapsed = 0; elapsed < 3_000; elapsed += 100) { - vi.advanceTimersByTime(100); - manager.onData(ID); - expect(manager.getState(ID).status).toBe('ALERT_RINGING'); - } - vi.advanceTimersByTime(DELAY - 3_000); - expect(spoken).toHaveBeenCalledOnce(); + burst(3_000); + 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); - // A keystroke as the host's input path delivers it, then its echo. - manager.acknowledge(ID, { input: true }); - manager.onInput(ID); + press(manager, ID); manager.onData(ID); } vi.advanceTimersByTime(751); diff --git a/lib/src/lib/platform/fake-adapter.ts b/lib/src/lib/platform/fake-adapter.ts index e6624b043..efcb111a8 100644 --- a/lib/src/lib/platform/fake-adapter.ts +++ b/lib/src/lib/platform/fake-adapter.ts @@ -12,7 +12,6 @@ import { applyTerminalSemanticEvents } from '../terminal-state-store'; import { themeColorProvider } from '../terminal-theme'; import { applyLiveToolEvents } from '../tool-events'; import { offerProgramCopy } from '../mouse-selection'; -import { inputCanStartWork } from '../terminal-report-filter'; /** This renderer is its host's one realm. */ const LOCAL_VIEWER = 'local'; @@ -276,8 +275,7 @@ export class FakePtyAdapter implements PlatformAdapter { } writePty(id: string, data: string, options?: WritePtyOptions): void { - if (options?.userInput) this.alertManager.acknowledge(id, { input: true }); - if (inputCanStartWork(data)) this.alertManager.onInput(id); + 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 diff --git a/lib/src/lib/quiesce-detector.test.ts b/lib/src/lib/quiesce-detector.test.ts index 00e2effda..1faeb1f37 100644 --- a/lib/src/lib/quiesce-detector.test.ts +++ b/lib/src/lib/quiesce-detector.test.ts @@ -13,18 +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.onInput(); + monitor.expectWork(); monitor.onData(); vi.advanceTimersByTime(1_500); monitor.onData(); @@ -46,7 +48,6 @@ describe('QuiesceDetector', () => { it('only treats confirmed work and its finishing window as busy', () => { const { monitor, settled } = createMonitor(); expect(monitor.isConfirmedBusy()).toBe(false); - monitor.onInput(); monitor.onData(); expect(monitor.isConfirmedBusy()).toBe(false); vi.advanceTimersByTime(1_500); @@ -67,9 +68,9 @@ describe('QuiesceDetector', () => { }); it('rejects output nobody asked for and disposed output', () => { - const { monitor } = createMonitor(); + const { monitor } = createMonitor({ expectWork: false }); expect(monitor.onData()).toBe(false); - monitor.onInput(); + monitor.expectWork(); expect(monitor.onData()).toBe(true); expect(monitor.isConfirmedBusy()).toBe(false); monitor.dispose(); @@ -78,7 +79,6 @@ describe('QuiesceDetector', () => { it('keeps the first meaningful output after a reset in NOTHING_TO_SHOW', () => { const { monitor, changes } = createMonitor(); - monitor.onInput(); monitor.reset(); monitor.onData(); expect(monitor.getStatus()).toBe('NOTHING_TO_SHOW'); @@ -87,7 +87,6 @@ describe('QuiesceDetector', () => { it('enters MIGHT_BE_BUSY when a later output arrives after the candidate gap', () => { const { monitor, changes } = createMonitor(); - monitor.onInput(); monitor.onData(); vi.advanceTimersByTime(1_500); monitor.onData(); @@ -97,7 +96,6 @@ describe('QuiesceDetector', () => { it('can also enter MIGHT_BE_BUSY from dense output once the candidate timer matures', () => { const { monitor, changes } = createMonitor(); - monitor.onInput(); monitor.onData(); vi.advanceTimersByTime(750); monitor.onData(); @@ -109,7 +107,6 @@ describe('QuiesceDetector', () => { it('confirms MIGHT_BE_BUSY into BUSY on further output', () => { const { monitor, changes } = createMonitor(); - monitor.onInput(); monitor.onData(); vi.advanceTimersByTime(1_500); monitor.onData(); @@ -121,7 +118,6 @@ describe('QuiesceDetector', () => { it('falls back from MIGHT_BE_BUSY to NOTHING_TO_SHOW if work is not confirmed', () => { const { monitor, changes, settled } = createMonitor(); - monitor.onInput(); monitor.onData(); vi.advanceTimersByTime(1_500); monitor.onData(); @@ -135,14 +131,13 @@ describe('QuiesceDetector', () => { it.each([false, true])('forgets stale candidate history before a short redraw (failed confirmation: %s)', (failedConfirmation) => { const { monitor, settled } = createMonitor(); - monitor.onInput(); monitor.onData(); vi.advanceTimersByTime(1_500); if (failedConfirmation) monitor.onData(); vi.advanceTimersByTime(218_342); // Input inside the window, so only the forgotten history keeps it idle. - monitor.onInput(); + monitor.expectWork(); monitor.onData(); vi.advanceTimersByTime(62); monitor.onData(); @@ -157,13 +152,12 @@ describe('QuiesceDetector', () => { it('expires candidate history even when the confirmation timer has not run', () => { const { monitor, settled } = createMonitor(); - monitor.onInput(); monitor.onData(); vi.advanceTimersByTime(1_500); monitor.onData(); expect(monitor.getStatus()).toBe('MIGHT_BE_BUSY'); vi.setSystemTime(Date.now() + 218_342); - monitor.onInput(); + monitor.expectWork(); monitor.onData(); expect(monitor.getStatus()).toBe('NOTHING_TO_SHOW'); vi.advanceTimersByTime(62); @@ -299,7 +293,6 @@ describe('QuiesceDetector', () => { it('reset() returns MIGHT_BE_BUSY to NOTHING_TO_SHOW', () => { const { monitor, changes } = createMonitor(); - monitor.onInput(); monitor.onData(); vi.advanceTimersByTime(1_500); monitor.onData(); @@ -324,7 +317,6 @@ describe('QuiesceDetector', () => { it('reset() forgets output history, so the next output starts candidate tracking over', () => { const { monitor, changes } = createMonitor(); - monitor.onInput(); monitor.onData(); vi.advanceTimersByTime(1_400); monitor.reset(); @@ -337,7 +329,7 @@ describe('QuiesceDetector', () => { describe('work-start gate', () => { it('ignores sustained output with no input', () => { - const { monitor, changes, settled } = createMonitor(); + const { monitor, changes, settled } = createMonitor({ expectWork: false }); for (let i = 0; i < 20; i++) { expect(monitor.onData()).toBe(false); vi.advanceTimersByTime(500); @@ -349,10 +341,10 @@ describe('QuiesceDetector', () => { }); it('lets output that begins within the window start a run', () => { - const { monitor, changes } = createMonitor(); + const { monitor, changes } = createMonitor({ expectWork: false }); // An idle program's repaint, then the keystroke that sets it working. expect(monitor.onData()).toBe(false); - monitor.onInput(); + monitor.expectWork(); vi.advanceTimersByTime(4_900); expect(monitor.onData()).toBe(true); vi.advanceTimersByTime(1_500); @@ -364,7 +356,6 @@ describe('QuiesceDetector', () => { it('ignores output that begins after the window', () => { const { monitor, changes } = createMonitor(); - monitor.onInput(); vi.advanceTimersByTime(5_001); expect(monitor.onData()).toBe(false); vi.advanceTimersByTime(1_500); @@ -376,7 +367,6 @@ describe('QuiesceDetector', () => { it('keeps counting a run begun inside the window through its settle, and gates the next', () => { const { monitor, changes, settled } = createMonitor(); - monitor.onInput(); vi.advanceTimersByTime(4_000); monitor.onData(); vi.advanceTimersByTime(1_500); @@ -394,7 +384,6 @@ describe('QuiesceDetector', () => { it('keeps input across reset(), which opens no window of its own', () => { const { monitor } = createMonitor(); - monitor.onInput(); monitor.reset(); monitor.onData(); vi.advanceTimersByTime(1_500); @@ -432,7 +421,7 @@ describe('QuiesceDetector', () => { it('does not emit changes after dispose', () => { const onChange = vi.fn(); const monitor = new QuiesceDetector({ onChange }); - monitor.onInput(); + 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 898794b3b..b12dfe7d4 100644 --- a/lib/src/lib/quiesce-detector.ts +++ b/lib/src/lib/quiesce-detector.ts @@ -38,14 +38,14 @@ type DetectorTimer = 'busyCandidate' | 'busyConfirm' | 'mightNeedAttention' | 's * 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 input: a program - * repaints on focus, on resize, and on its own timers without working, and - * output it was not asked for is ignored until it is already busy. + * 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 disposed = false; - private lastInputAt = -Infinity; + private lastWorkExpectedAt = -Infinity; private timers = new Map>(); private firstOutputAt: number | null = null; private lastOutputAt: number | null = null; @@ -70,7 +70,7 @@ 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'); } @@ -91,7 +91,7 @@ export class QuiesceDetector { // 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.lastInputAt > cfg.alert.workStartWindow + && now - this.lastWorkExpectedAt > cfg.alert.workStartWindow ) return false; this.lastOutputAt = now; @@ -113,15 +113,16 @@ export class QuiesceDetector { return true; } - /** Input reached the program, so output may begin a busy run. Kept across - * `reset`: the keystroke that starts a command precedes its boundary. */ - onInput(): void { - this.lastInputAt = Date.now(); + /** 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; - this.clearActivityTimers(); + this.clearTimers(); } private handleNothingToShowOutput(now: number): void { @@ -140,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(); @@ -191,7 +192,7 @@ export class QuiesceDetector { this.timers.set(kind, timer); } - private clearActivityTimers(): void { + private clearTimers(): void { for (const timer of this.timers.values()) clearTimeout(timer); this.timers.clear(); } diff --git a/lib/src/lib/terminal-registry.alert.test.ts b/lib/src/lib/terminal-registry.alert.test.ts index 10b3c2382..1e99c38e5 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 { @@ -826,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({ @@ -1254,26 +1257,6 @@ describe('terminal-registry alert behavior', () => { }); }); - it('ignores a resize redraw long after the last input', () => { - const id = 'resize-redraw'; - const session = createSession(id); - enableAlert(id); - engageSession(id); - advance(cfg.alert.workStartWindow + 1); - - // sendOutput, not emitOutput: a redraw is output nobody typed. - session.terminal.emitResize(120, 30); - fakePlatform.sendOutput(id, 'prompt redraw'); - advance(1_600); - fakePlatform.sendOutput(id, 'prompt redraw'); - fakePlatform.sendOutput(id, 'prompt redraw'); - - 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 ec44c936d..dcf2d5765 100644 --- a/lib/src/lib/terminal-report-filter.test.ts +++ b/lib/src/lib/terminal-report-filter.test.ts @@ -95,5 +95,11 @@ describe('terminal-report-filter: inputCanStartWork', () => { 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 9365d9583..5654a14dc 100644 --- a/lib/src/lib/terminal-report-filter.ts +++ b/lib/src/lib/terminal-report-filter.ts @@ -42,10 +42,11 @@ export function stripMouseReportsFromInput(data: string): string { } /** Input a program may start work on: anything but terminal replies — a focus - * report among them — and mouse reports. */ + * report among them — and mouse reports, read as the renderer reads them. */ export function inputCanStartWork(data: string): boolean { - const withoutMouseReports = stripMouseReportsFromInput(data); - return withoutMouseReports.length > 0 && !inputIsReplayTerminalReport(withoutMouseReports); + // Both start with ESC; checking first keeps a large paste off the regexes. + if (data.length > 0 && data.charCodeAt(0) !== 0x1b) return true; + return !inputIsReplayTerminalReport(data) && stripMouseReportsFromInput(data).length > 0; } // Baseline for a dead Session's terminal only; a live process still owns its modes. The diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index 540868f86..888bb732d 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 { - ptyManager.resize(ptyId, cols, rows, repaint); -} - /** * Put one question to every webview in this window and settle with everything * they answered. diff --git a/vscode-ext/test/message-router.test.ts b/vscode-ext/test/message-router.test.ts index 0fae9bce6..c8225a378 100644 --- a/vscode-ext/test/message-router.test.ts +++ b/vscode-ext/test/message-router.test.ts @@ -775,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', () => { @@ -797,19 +797,22 @@ describe('alert state follows the PTY', () => { } }); - it('opens the work-start window before an input\'s write, and never for a terminal reply', 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, 'onInput').mockImplementation((id) => void ptys.order.push(`input ${id}`)); - ptys.onWrite = (id) => void ptys.order.push(`write ${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:input', id: 'pty-sent', data: '\x1b[I' }); + webview.send({ type: 'pty:spawn', id: 'pty-sent', options: { cwd: '/repo' } }); webview.send({ type: 'pty:input', id: 'pty-sent', data: 'hi\r', paced: true }); - expect(ptys.order).toEqual(['write pty-sent', 'input pty-sent', 'write pty-sent']); + 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(); } }); From f84145326f335b8f7f62824e8387f49eeab8438e Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Mon, 19 Oct 2026 22:54:31 -0700 Subject: [PATCH 3/8] Type the website alert-program tests through the adapter, and record the mid-run settle trade-off The alert-program tests fed the tutorial shell directly, so no input reached the alert host and the agent's turn never read as busy; they now write through FakePtyAdapter.writePty as the playground does. Output resuming after a mid-run settle with no input or progress report inside the window is ignored like any other; the rationale says so and why an owed ring does not reopen the gate. The vscode alertedPtys comment no longer mentions resizes. Co-Authored-By: Claude Opus 5.5 --- docs/specs/alert.rationale.md | 2 +- vscode-ext/src/message-router.ts | 2 +- website/src/lib/alert-programs.test.ts | 24 +++++++++++++++--------- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/docs/specs/alert.rationale.md b/docs/specs/alert.rationale.md index 6efba4601..e3727ae9d 100644 --- a/docs/specs/alert.rationale.md +++ b/docs/specs/alert.rationale.md @@ -98,7 +98,7 @@ **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 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. +**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 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). diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index 888bb732d..57748c7f6 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -202,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 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"); }); From 7c32ada885ffdbaad6435e27f2c04bde6099e712 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 20 Oct 2026 17:49:10 -0700 Subject: [PATCH 4/8] Count a wheel tick reported to the program as input Scrolling Claude Code's transcript sends wheel reports, and the program repaints for as long as the scroll lasts. As a mouse-only chunk the scroll acknowledged nothing, so reading a finished turn never answered its ring. A wheel tick is now user input like a keystroke: it acknowledges, opens the echo window so the repaint is ignored while the scroll lasts, and lets work start. Hover, drag-motion, and button reports stay passive, a click acknowledging through the Pane's DOM handler. Co-Authored-By: Claude Opus 5.5 --- docs/specs/alert.md | 12 +++++------ docs/specs/alert.rationale.md | 6 ++++-- lib/src/host/owner-pty.ts | 8 +++---- lib/src/host/remote/sidecar-entry.test.ts | 12 +++++++++-- lib/src/lib/terminal-lifecycle.ts | 23 ++++++++++----------- lib/src/lib/terminal-registry.alert.test.ts | 22 +++++++++++++++++++- lib/src/lib/terminal-report-filter.test.ts | 19 +++++++++++++++++ lib/src/lib/terminal-report-filter.ts | 23 +++++++++++++++++++-- scripts/spec-word-budgets.json | 2 +- 9 files changed, 97 insertions(+), 30 deletions(-) diff --git a/docs/specs/alert.md b/docs/specs/alert.md index 78e82ea18..bf1b4dd07 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). @@ -258,7 +258,7 @@ 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, 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. Later output, such as a focus or resize repaint, is ignored entirely (rationale). 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 a hover or button report. 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. @@ -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 e3727ae9d..44789dd0e 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 neither answered its ring nor kept the repaint from reading as work (report, 2026-10-21). Engagement alone would not do: a tick that let output start work without opening the echo window would make the repaint the work. As input, each tick reopens the echo window, so the repaint is ignored while the scroll lasts, as it already was on an alternate screen without mouse reporting, where xterm sends the wheel as arrow keys. The cost: a scroll clears the TODO as typing does. 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,7 +100,7 @@ **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 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 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 hover or button 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). diff --git a/lib/src/host/owner-pty.ts b/lib/src/host/owner-pty.ts index 7d269a912..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 @@ -108,11 +108,11 @@ export function alertedPty(alerts: AlertManager, pty: PtyWriteTarget) { 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 }); + 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 1458db0b6..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) => { diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index 0fc29a102..0aa43ff56 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); @@ -254,8 +253,8 @@ function wireXtermHandlers( } // CSI/SS3 can encode real keys. The broader filter protects the prompt - // recorder only. - if (!inputIsSyntheticTerminalReport(input)) { + // recorder only; a wheel-only chunk types no text. + if (!inputIsSyntheticTerminalReport(input) && stripMouseReportsFromInput(input).length > 0) { recordTerminalUserInput(id, input, makePromptLineReader(terminal)); } writeUserInput(id, input); diff --git a/lib/src/lib/terminal-registry.alert.test.ts b/lib/src/lib/terminal-registry.alert.test.ts index 1e99c38e5..ddb6e5023 100644 --- a/lib/src/lib/terminal-registry.alert.test.ts +++ b/lib/src/lib/terminal-registry.alert.test.ts @@ -844,6 +844,23 @@ 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 13: multiple sessions ring independently', () => { const alpha = 'story-13-a'; const beta = 'story-13-b'; @@ -914,6 +931,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); @@ -939,7 +960,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@!!'], diff --git a/lib/src/lib/terminal-report-filter.test.ts b/lib/src/lib/terminal-report-filter.test.ts index dcf2d5765..1fafe1ffc 100644 --- a/lib/src/lib/terminal-report-filter.test.ts +++ b/lib/src/lib/terminal-report-filter.test.ts @@ -4,6 +4,7 @@ import { inputIsReplayTerminalReport, inputIsSyntheticTerminalReport, stripMouseReportsFromInput, + stripPassiveMouseReportsFromInput, } from './terminal-report-filter'; describe('terminal-report-filter: mouse reports', () => { @@ -26,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 @@ -87,6 +101,11 @@ describe('terminal-report-filter: inputCanStartWork', () => { 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('accepts a wheel tick, which scrolls the program', () => { + expect(inputCanStartWork('\x1b[<65;12;4M')).toBe(true); }); it('accepts keys, Ctrl-C, and a key typed alongside a mouse report', () => { diff --git a/lib/src/lib/terminal-report-filter.ts b/lib/src/lib/terminal-report-filter.ts index 5654a14dc..5bf833578 100644 --- a/lib/src/lib/terminal-report-filter.ts +++ b/lib/src/lib/terminal-report-filter.ts @@ -41,12 +41,31 @@ 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 terminal replies — a focus - * report among them — and mouse reports, read as the renderer reads them. */ + * report among them — and passive mouse reports, read as the renderer reads them. */ export function inputCanStartWork(data: string): 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 !inputIsReplayTerminalReport(data) && stripMouseReportsFromInput(data).length > 0; + return !inputIsReplayTerminalReport(data) && stripPassiveMouseReportsFromInput(data).length > 0; } // Baseline for a dead Session's terminal only; a live process still owns its modes. The 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, From 2e20547b5bf0ca437f49868bcc91e626519fc70c Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 20 Oct 2026 17:49:35 -0700 Subject: [PATCH 5/8] Leave the prompt recorder's filter as it was Co-Authored-By: Claude Opus 5.5 --- lib/src/lib/terminal-lifecycle.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index 0aa43ff56..94263d576 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -253,8 +253,8 @@ function wireXtermHandlers( } // CSI/SS3 can encode real keys. The broader filter protects the prompt - // recorder only; a wheel-only chunk types no text. - if (!inputIsSyntheticTerminalReport(input) && stripMouseReportsFromInput(input).length > 0) { + // recorder only. + if (!inputIsSyntheticTerminalReport(input)) { recordTerminalUserInput(id, input, makePromptLineReader(terminal)); } writeUserInput(id, input); From ab1ddb8453aca3d6cfa0a7dff51f725ac0cd8e53 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 20 Oct 2026 17:54:20 -0700 Subject: [PATCH 6/8] Use a tap, not a scroll, as the Client mouse report that acknowledges nothing Co-Authored-By: Claude Opus 5.5 --- vscode-ext/test/message-router.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vscode-ext/test/message-router.test.ts b/vscode-ext/test/message-router.test.ts index c8225a378..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); From 6acff863939733fcc23df593c239866cde2a9099 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 20 Oct 2026 18:04:23 -0700 Subject: [PATCH 7/8] Let a wheel tick acknowledge without opening the work-start window or ending a selection Co-Authored-By: Claude Opus 5.5 --- docs/specs/alert.md | 2 +- docs/specs/alert.rationale.md | 4 ++-- lib/src/lib/alert-manager.ts | 2 +- .../lib/terminal-lifecycle.selection.test.ts | 7 +++++++ lib/src/lib/terminal-lifecycle.ts | 7 +++++-- lib/src/lib/terminal-registry.alert.test.ts | 17 +++++++++++++++++ lib/src/lib/terminal-report-filter.test.ts | 9 +++++++-- lib/src/lib/terminal-report-filter.ts | 11 +++++++---- 8 files changed, 47 insertions(+), 12 deletions(-) diff --git a/docs/specs/alert.md b/docs/specs/alert.md index bf1b4dd07..8573f2ea7 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -258,7 +258,7 @@ 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, 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 a hover or button report. Later output, such as a focus or resize repaint, is ignored entirely (rationale). 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. diff --git a/docs/specs/alert.rationale.md b/docs/specs/alert.rationale.md index 44789dd0e..be5529c5b 100644 --- a/docs/specs/alert.rationale.md +++ b/docs/specs/alert.rationale.md @@ -26,7 +26,7 @@ **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 neither answered its ring nor kept the repaint from reading as work (report, 2026-10-21). Engagement alone would not do: a tick that let output start work without opening the echo window would make the repaint the work. As input, each tick reopens the echo window, so the repaint is ignored while the scroll lasts, as it already was on an alternate screen without mouse reporting, where xterm sends the wheel as arrow keys. The cost: a scroll clears the TODO as typing does. A hover or button report stays nothing, a click acknowledging through the Pane instead. +**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. @@ -100,7 +100,7 @@ **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 hover or button 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 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). diff --git a/lib/src/lib/alert-manager.ts b/lib/src/lib/alert-manager.ts index 97693aeca..406ff1d2d 100644 --- a/lib/src/lib/alert-manager.ts +++ b/lib/src/lib/alert-manager.ts @@ -413,7 +413,7 @@ export class AlertManager { 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 (userInput || inputCanStartWork(data)) this.streamEntry(id)?.detector.expectWork(); + if (inputCanStartWork(data, { userInput })) this.streamEntry(id)?.detector.expectWork(); } // --- WATCHING rule set --- 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 94263d576..f5bc811aa 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -788,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 ddb6e5023..2b8baedf5 100644 --- a/lib/src/lib/terminal-registry.alert.test.ts +++ b/lib/src/lib/terminal-registry.alert.test.ts @@ -861,6 +861,23 @@ describe('terminal-registry alert behavior', () => { 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'; diff --git a/lib/src/lib/terminal-report-filter.test.ts b/lib/src/lib/terminal-report-filter.test.ts index 1fafe1ffc..fe9f12744 100644 --- a/lib/src/lib/terminal-report-filter.test.ts +++ b/lib/src/lib/terminal-report-filter.test.ts @@ -104,8 +104,13 @@ describe('terminal-report-filter: inputCanStartWork', () => { expect(inputCanStartWork('\x1b[<35;12;4M')).toBe(false); // SGR hover }); - it('accepts a wheel tick, which scrolls the program', () => { - expect(inputCanStartWork('\x1b[<65;12;4M')).toBe(true); + it('rejects a wheel tick, the user's 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', () => { diff --git a/lib/src/lib/terminal-report-filter.ts b/lib/src/lib/terminal-report-filter.ts index 5bf833578..221f25c52 100644 --- a/lib/src/lib/terminal-report-filter.ts +++ b/lib/src/lib/terminal-report-filter.ts @@ -60,12 +60,15 @@ 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 terminal replies — a focus - * report among them — and passive mouse reports, read as the renderer reads them. */ -export function inputCanStartWork(data: string): boolean { +/** + * 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 !inputIsReplayTerminalReport(data) && stripPassiveMouseReportsFromInput(data).length > 0; + return (userInput || !inputIsReplayTerminalReport(data)) && stripMouseReportsFromInput(data).length > 0; } // Baseline for a dead Session's terminal only; a live process still owns its modes. The From 51c38e3c5aca501a50462fd3881506982cba88ef Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 20 Oct 2026 18:05:57 -0700 Subject: [PATCH 8/8] Fix a test title that broke the filter test file's syntax Co-Authored-By: Claude Opus 5.5 --- lib/src/lib/terminal-report-filter.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/lib/terminal-report-filter.test.ts b/lib/src/lib/terminal-report-filter.test.ts index fe9f12744..cd895fe50 100644 --- a/lib/src/lib/terminal-report-filter.test.ts +++ b/lib/src/lib/terminal-report-filter.test.ts @@ -104,7 +104,7 @@ describe('terminal-report-filter: inputCanStartWork', () => { expect(inputCanStartWork('\x1b[<35;12;4M')).toBe(false); // SGR hover }); - it('rejects a wheel tick, the user's input that asks for no work', () => { + it('rejects a wheel tick, input that asks for no work', () => { expect(inputCanStartWork('\x1b[<65;12;4M', { userInput: true })).toBe(false); });