diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a2d1c4143..bd0508990 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,6 +9,8 @@ - Added `outputPad` to the tool render context ([#10557](https://github.com/earendil-works/pi/pull/10557) by [@rwachtler](https://github.com/rwachtler)) - Added OpenAI's GPT-6 Luna as a classifier model through the Decisions API, available with `OPENAI_API_KEY` (see [Use classifier models](docs/models.md#use-classifier-models)) - Added `images` to codemode's `models.classify()` context, so classifiers that accept images, such as GPT-6 Luna, can judge them +- Added program status reporting with OSC 7501: terminals and agent dashboards that support it see whether Pi is working, blocked on a dialog or login, done, or failed. `PI_PROGRAM_STATUS=1|0` overrides detection (see [Terminal setup](docs/terminal-setup.md#program-status)) ([#10607](https://github.com/earendil-works/pi/issues/10607)) +- Added `aborted` to `agent_settled` session, extension, and JSON events, so integrations can tell a cancelled run from a finished one ([#10607](https://github.com/earendil-works/pi/issues/10607)) ### Changed diff --git a/packages/coding-agent/docs/environment-variables.md b/packages/coding-agent/docs/environment-variables.md index 50d50481f..f4e484fc8 100644 --- a/packages/coding-agent/docs/environment-variables.md +++ b/packages/coding-agent/docs/environment-variables.md @@ -89,6 +89,7 @@ These variables are read by Pi itself: | `PI_RADIUS_GATEWAY` | Override the Radius gateway origin used by `/bug` uploads and Radius relay connections | | `PI_HARDWARE_CURSOR` | Set to `1` to show the hardware cursor; see [Terminal setup](terminal-setup.md) | | `PI_HYPERLINKS` | Override OSC 8 hyperlink detection with `1`, `0`, or `auto` | +| `PI_PROGRAM_STATUS` | Override OSC 7501 program status detection: `1` always reports, `0` never reports; otherwise Pi reports only after the terminal confirms support. See [Terminal setup](terminal-setup.md#program-status) | | `PI_IMAGE_PROTOCOL` | Override inline image detection with `kitty`, `iterm2`, `none`, or `auto` | | `PI_TRUE_COLOR` | Override truecolor detection with `1`, `0`, or `auto` | | `PI_TUI_ESC_TIMEOUT` | How long to wait after a lone ESC before treating it as Escape, in milliseconds; defaults to `100` over SSH and `10` otherwise. Increase if Alt-key input is misread as Escape | diff --git a/packages/coding-agent/docs/json.md b/packages/coding-agent/docs/json.md index d2094a434..ca76d6326 100644 --- a/packages/coding-agent/docs/json.md +++ b/packages/coding-agent/docs/json.md @@ -42,7 +42,7 @@ A basic run produces records like these: {"type":"message_end","message":{"role":"assistant","...":"..."}} {"type":"turn_end","message":{"role":"assistant","...":"..."},"toolResults":[]} {"type":"agent_end","messages":[{"...":"..."}],"willRetry":false} -{"type":"agent_settled"} +{"type":"agent_settled","aborted":false} ``` `agent_end` closes one low-level agent run. Automatic retry, overflow recovery, compaction retry, steering, or follow-up work can still continue. `agent_settled` means Pi has no remaining automatic work for that session-level run. @@ -53,7 +53,7 @@ A basic run produces records like these: |---|---|---| | `agent_start` | None | A low-level agent run started. | | `agent_end` | `messages`, `willRetry` | That low-level run ended. `messages` contains messages generated by the run. | -| `agent_settled` | None | Pi will not continue automatically through retries, compaction recovery, or queued messages. | +| `agent_settled` | `aborted` | Pi will not continue automatically through retries, compaction recovery, or queued messages. `aborted` is `true` when the run ended because it was aborted. | | `turn_start` | None | One assistant turn started. | | `turn_end` | `message`, `toolResults` | One assistant response and its resulting tool calls finished. | diff --git a/packages/coding-agent/docs/terminal-setup.md b/packages/coding-agent/docs/terminal-setup.md index 20efd590f..a3c67af07 100644 --- a/packages/coding-agent/docs/terminal-setup.md +++ b/packages/coding-agent/docs/terminal-setup.md @@ -217,3 +217,17 @@ Pi automatically detects OSC 8 hyperlinks, inline image protocols, and truecolor Settings take precedence over environment variables. An unset value or `auto` preserves automatic detection. Only force a capability supported by the complete terminal path. Unsupported escape sequences can corrupt rendering. See [Environment Variables](environment-variables.md#pi-process-configuration) and [Settings](settings.md) for the canonical value definitions. + +## Program status + +Pi reports its state with the [Program Status Protocol (OSC 7501)](https://www.superlogical.com/rex/docs/build/program-status), so terminals and agent dashboards can show whether it is working, waiting for you, done, or failed: + +| State | When | +|---|---| +| `working` | An agent run or compaction is in progress. The message is the session name. | +| `blocked` | An extension dialog or login waits for you. The message is the dialog title. | +| `done` | A run finished. The message is the session name. | +| `error` | A run ended with an error that is not retried. The message is the first line of the error. | +| `idle` | Pi started, or you cancelled the run. | + +Reports never contain prompts or model output. Pi sends them only after the terminal answers the protocol's support query; tmux and screen do not forward them. Set `PI_PROGRAM_STATUS=1` to send reports without asking, or `PI_PROGRAM_STATUS=0` to turn them off. diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index ebb775b8a..5decccd80 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -200,7 +200,7 @@ export type AgentSessionEvent = messages: AgentMessage[]; willRetry: boolean; } - | { type: "agent_settled" } + | { type: "agent_settled"; aborted: boolean } | { type: "queue_update"; steering: readonly string[]; @@ -1080,8 +1080,9 @@ export class AgentSession { this._isAgentRunActive = false; this._isEmittingAgentSettled = true; try { - await this._extensionRunner.emit({ type: "agent_settled" }); - this._emit({ type: "agent_settled" }); + const aborted = this._agentRunAbortRequested; + await this._extensionRunner.emit({ type: "agent_settled", aborted }); + this._emit({ type: "agent_settled", aborted }); } finally { this._isEmittingAgentSettled = false; } diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 278cc3e14..5d65cce98 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -1004,6 +1004,8 @@ export interface AgentBeforeSettleEvent extends BoundaryState { /** Fired after an agent run has fully settled and no automatic retry, compaction, or queued continuation will run. */ export interface AgentSettledEvent { type: "agent_settled"; + /** Whether the run ended because it was aborted, for example with Escape. */ + aborted: boolean; } export type UIPromptKind = "select" | "confirm" | "input" | "editor" | "custom"; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 750af1075..55ea40409 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -184,6 +184,7 @@ import { UserMessageSelectorComponent } from "./components/user-message-selector import { editInExternalEditor } from "./external-editor.ts"; import { refreshModelCatalogs } from "./model-catalog-refresh.ts"; import { getModelSearchText } from "./model-search.ts"; +import { type BlockedStatus, ProgramStatusReporter } from "./program-status-reporter.ts"; import { shareSession } from "./session-share.ts"; import { getAvailableThemes, @@ -546,6 +547,12 @@ export class InteractiveMode { // Shutdown state private shutdownRequested = false; + /** Reports working, blocked, done, and error states to terminals that support OSC 7501. */ + private readonly programStatus = new ProgramStatusReporter( + () => this.ui.terminal, + () => this.sessionManager.getSessionName(), + ); + /** The `/bug` hint is shown at most once per session so error output stays readable. */ private bugReportHintShown = false; private installChangeWarningShown = false; @@ -990,6 +997,7 @@ export class InteractiveMode { // Start the UI before initializing extensions so session_start handlers can use interactive dialogs this.ui.start(); this.isInitialized = true; + this.programStatus.report(); this.ensurePngTranscoder(); this.themeController.applyFromSettings(); @@ -2084,6 +2092,7 @@ export class InteractiveMode { this.unsubscribe?.(); this.unsubscribe = undefined; + this.programStatus.reset(); this.applyRuntimeSettings(); if (options.renderBeforeBind) { @@ -2682,6 +2691,7 @@ export class InteractiveMode { title: string, options: string[], opts?: ExtensionUIDialogOptions, + blocked: BlockedStatus = { kind: "question", message: title }, ): Promise { return new Promise((resolve) => { if (opts?.signal?.aborted) { @@ -2715,6 +2725,8 @@ export class InteractiveMode { this.editorContainer.clear(); this.editorContainer.addChild(this.extensionSelector); this.ui.setFocus(this.extensionSelector); + // Extension dialogs share the editor slot: opening one replaces the status of a displaced one. + this.programStatus.setBlocked("extension-dialog", blocked); this.ui.requestRender(); }); } @@ -2727,6 +2739,7 @@ export class InteractiveMode { this.editorContainer.clear(); this.editorContainer.addChild(this.editor); this.extensionSelector = undefined; + this.programStatus.setBlocked("extension-dialog", undefined); this.ui.setFocus(this.editor); this.ui.requestRender(); } @@ -2739,7 +2752,10 @@ export class InteractiveMode { message: string, opts?: ExtensionUIDialogOptions, ): Promise { - const result = await this.showExtensionSelector(`${title}\n${message}`, ["Yes", "No"], opts); + const result = await this.showExtensionSelector(`${title}\n${message}`, ["Yes", "No"], opts, { + kind: "permission", + message: title, + }); return result === "Yes"; } @@ -2791,6 +2807,7 @@ export class InteractiveMode { this.editorContainer.clear(); this.editorContainer.addChild(this.extensionInput); this.ui.setFocus(this.extensionInput); + this.programStatus.setBlocked("extension-dialog", { kind: "question", message: title }); this.ui.requestRender(); }); } @@ -2803,6 +2820,7 @@ export class InteractiveMode { this.editorContainer.clear(); this.editorContainer.addChild(this.editor); this.extensionInput = undefined; + this.programStatus.setBlocked("extension-dialog", undefined); this.ui.setFocus(this.editor); this.ui.requestRender(); } @@ -2833,6 +2851,7 @@ export class InteractiveMode { this.editorContainer.clear(); this.editorContainer.addChild(this.extensionEditor); this.ui.setFocus(this.extensionEditor); + this.programStatus.setBlocked("extension-dialog", { kind: "question", message: title }); this.ui.requestRender(); }); } @@ -2844,6 +2863,7 @@ export class InteractiveMode { this.editorContainer.clear(); this.editorContainer.addChild(this.editor); this.extensionEditor = undefined; + this.programStatus.setBlocked("extension-dialog", undefined); this.ui.setFocus(this.editor); this.ui.requestRender(); } @@ -3389,6 +3409,7 @@ export class InteractiveMode { } this.footer.invalidate(); + this.programStatus.handleEvent(event); switch (event.type) { case "agent_start": @@ -6201,7 +6222,7 @@ export class InteractiveMode { }; try { - await this.loginProvider(dialog, providerId, "api_key"); + await this.loginProvider(dialog, providerId, providerName, "api_key"); restoreEditor(); await this.completeProviderAuthentication(providerId, providerName, "api_key", previousModel); } catch (error: unknown) { @@ -6294,18 +6315,24 @@ export class InteractiveMode { private async loginProvider( dialog: LoginDialogComponent, providerId: string, + providerName: string, method: "api_key" | "oauth", ): Promise { - await this.session.modelRuntime.login( - providerId, - method, - { - signal: dialog.signal, - prompt: (prompt) => this.showAuthPrompt(dialog, prompt, providerId), - notify: (event) => this.notifyAuthDialog(dialog, event), - }, - { getDeviceId: () => this.settingsManager.getOrCreateDeviceId() }, - ); + this.programStatus.setBlocked("login", { kind: "auth", message: `Log in to ${providerName}` }); + try { + await this.session.modelRuntime.login( + providerId, + method, + { + signal: dialog.signal, + prompt: (prompt) => this.showAuthPrompt(dialog, prompt, providerId), + notify: (event) => this.notifyAuthDialog(dialog, event), + }, + { getDeviceId: () => this.settingsManager.getOrCreateDeviceId() }, + ); + } finally { + this.programStatus.setBlocked("login", undefined); + } } private async showLoginDialog(providerId: string, providerName: string, onBack?: () => void): Promise { @@ -6324,7 +6351,7 @@ export class InteractiveMode { }; try { - await this.loginProvider(dialog, providerId, "oauth"); + await this.loginProvider(dialog, providerId, providerName, "oauth"); restoreEditor(); await this.completeProviderAuthentication(providerId, providerName, "oauth", previousModel); if (providerId === RADIUS_PROVIDER_ID) this.offerRadiusMcpServer(providerId, providerName); diff --git a/packages/coding-agent/src/modes/interactive/program-status-reporter.ts b/packages/coding-agent/src/modes/interactive/program-status-reporter.ts new file mode 100644 index 000000000..4dac611f9 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/program-status-reporter.ts @@ -0,0 +1,111 @@ +import type { ProgramStatus, Terminal } from "@earendil-works/pi-tui"; +import { APP_NAME } from "../../config.ts"; +import type { AgentSessionEvent } from "../../core/agent-session.ts"; + +export type BlockedStatus = { kind: NonNullable; message: string }; + +function firstLine(text: string | undefined): string { + return text?.split(/\r?\n/, 1)[0]?.trim() || "Error"; +} + +/** + * Reports interactive-mode state to the terminal (OSC 7501): `working` during agent runs and + * compaction, `blocked` while a dialog waits for the user, then `done`, `error`, or `idle` once the run + * settles. Messages are limited to the session name, dialog titles, and the first line of errors; prompts + * and assistant output are never reported. + */ +export class ProgramStatusReporter { + private readonly getTerminal: () => Terminal; + private readonly getSessionName: () => string | undefined; + private runActive = false; + private compacting = false; + /** Outcome of the current run, reported once it settles. */ + private runResult: ProgramStatus = { state: "done" }; + /** Status while no run is active. */ + private restingStatus: ProgramStatus = { state: "idle" }; + /** Open dialogs by source, in the order they opened. The most recent one is reported. */ + private readonly blocked = new Map(); + private lastReport: string | undefined; + + constructor(getTerminal: () => Terminal, getSessionName: () => string | undefined) { + this.getTerminal = getTerminal; + this.getSessionName = getSessionName; + } + + handleEvent(event: AgentSessionEvent): void { + switch (event.type) { + case "agent_start": + this.runActive = true; + this.runResult = { state: "done" }; + break; + case "message_end": + // The latest response decides the outcome, so a retried error is replaced by its successful retry. + if (event.message.role !== "assistant") return; + this.runResult = + event.message.stopReason === "error" + ? { state: "error", message: firstLine(event.message.errorMessage) } + : { state: "done" }; + break; + case "compaction_start": + this.compacting = true; + break; + case "compaction_end": + this.compacting = false; + if (this.runActive) { + // A failed recovery compaction ends the run unless a later response succeeds. + if (event.aborted) this.runResult = { state: "idle" }; + else if (event.errorMessage) this.runResult = { state: "error", message: firstLine(event.errorMessage) }; + } else if (event.aborted) { + this.restingStatus = { state: "idle" }; + } else if (event.reason === "manual") { + this.restingStatus = event.errorMessage + ? { state: "error", message: firstLine(event.errorMessage) } + : { state: "done" }; + } + break; + case "agent_settled": + this.runActive = false; + this.restingStatus = event.aborted ? { state: "idle" } : this.runResult; + break; + case "session_info_changed": + // The session name is part of working and done reports. + break; + default: + return; + } + this.report(); + } + + /** Report `blocked` for a dialog until it is cleared with `undefined`. Reopening a source replaces it. */ + setBlocked(source: string, status: BlockedStatus | undefined): void { + this.blocked.delete(source); + if (status) this.blocked.set(source, status); + this.report(); + } + + /** Forget the previous session's run, for example after switching sessions. */ + reset(): void { + this.runActive = false; + this.compacting = false; + this.runResult = { state: "done" }; + this.restingStatus = { state: "idle" }; + this.report(); + } + + report(): void { + const status: ProgramStatus = { ...this.currentStatus(), app: APP_NAME }; + const key = JSON.stringify(status); + if (key === this.lastReport) return; + this.lastReport = key; + this.getTerminal().setProgramStatus(status); + } + + private currentStatus(): ProgramStatus { + const blocked = [...this.blocked.values()].at(-1); + if (blocked) return { state: "blocked", ...blocked }; + if (this.compacting) return { state: "working", message: "Compacting context" }; + const status = this.runActive ? { state: "working" as const } : this.restingStatus; + if (status.state === "working" || status.state === "done") return { ...status, message: this.getSessionName() }; + return status; + } +} diff --git a/packages/coding-agent/test/edit-tool-no-full-redraw.test.ts b/packages/coding-agent/test/edit-tool-no-full-redraw.test.ts index 8e6d66f56..72a5034f6 100644 --- a/packages/coding-agent/test/edit-tool-no-full-redraw.test.ts +++ b/packages/coding-agent/test/edit-tool-no-full-redraw.test.ts @@ -28,6 +28,7 @@ class FakeTerminal implements Terminal { clearScreen(): void {} setTitle(_title: string): void {} setProgress(_active: boolean): void {} + setProgramStatus(): void {} get fullClearCount(): number { return this.writes.filter((write) => write.includes("\x1b[2J\x1b[H\x1b[3J")).length; diff --git a/packages/coding-agent/test/interactive-mode-compaction.test.ts b/packages/coding-agent/test/interactive-mode-compaction.test.ts index 5b0980ba7..0f35b02b4 100644 --- a/packages/coding-agent/test/interactive-mode-compaction.test.ts +++ b/packages/coding-agent/test/interactive-mode-compaction.test.ts @@ -138,6 +138,7 @@ describe("InteractiveMode compaction events", () => { const fakeThis = { isInitialized: true, footer: { invalidate: vi.fn() }, + programStatus: { handleEvent: vi.fn() }, autoCompactionEscapeHandler: undefined as (() => void) | undefined, autoCompactionLoader: undefined, defaultEditor: {}, @@ -201,6 +202,7 @@ describe("InteractiveMode compaction events", () => { const fakeThis = { isInitialized: true, footer: { invalidate: vi.fn() }, + programStatus: { handleEvent: vi.fn() }, activeStatusIndicator: undefined, workingVisible: true, showWorkingStatusIndicator: vi.fn(), diff --git a/packages/coding-agent/test/program-status-reporter.test.ts b/packages/coding-agent/test/program-status-reporter.test.ts new file mode 100644 index 000000000..aa09333ac --- /dev/null +++ b/packages/coding-agent/test/program-status-reporter.test.ts @@ -0,0 +1,156 @@ +import type { AssistantMessage } from "@earendil-works/pi-ai"; +import type { ProgramStatus, Terminal } from "@earendil-works/pi-tui"; +import { describe, expect, it } from "vitest"; +import type { AgentSessionEvent } from "../src/core/agent-session.ts"; +import { ProgramStatusReporter } from "../src/modes/interactive/program-status-reporter.ts"; + +function setup(sessionName?: string) { + const reports: ProgramStatus[] = []; + const terminal = { setProgramStatus: (status: ProgramStatus) => reports.push(status) } as unknown as Terminal; + const session = { name: sessionName }; + const reporter = new ProgramStatusReporter( + () => terminal, + () => session.name, + ); + const send = (...events: AgentSessionEvent[]) => { + for (const event of events) reporter.handleEvent(event); + }; + const last = () => { + const { app: _app, ...status } = reports.at(-1)!; + return Object.fromEntries(Object.entries(status).filter(([, value]) => value !== undefined)); + }; + return { reporter, reports, session, send, last }; +} + +function assistantEnd(stopReason: AssistantMessage["stopReason"], errorMessage?: string): AgentSessionEvent { + const message = { + role: "assistant", + content: [{ type: "text", text: "secret assistant output" }], + stopReason, + errorMessage, + } as AssistantMessage; + return { type: "message_end", message }; +} + +const settled: AgentSessionEvent = { type: "agent_settled", aborted: false }; + +const compactionEnd = ( + reason: "manual" | "threshold" | "overflow", + options: { aborted?: boolean; errorMessage?: string } = {}, +): AgentSessionEvent => ({ + type: "compaction_end", + reason, + result: undefined, + aborted: options.aborted ?? false, + willRetry: false, + errorMessage: options.errorMessage, +}); + +// #10607 +describe("ProgramStatusReporter", () => { + it("reports idle, working during a run, and done once it settles", () => { + const { reporter, reports, send, last } = setup("Fix login"); + reporter.report(); + expect(reports.at(-1)).toEqual({ state: "idle", app: "pi" }); + + send({ type: "agent_start" }); + expect(last()).toEqual({ state: "working", message: "Fix login" }); + + send(assistantEnd("toolUse"), assistantEnd("stop")); + expect(last()).toEqual({ state: "working", message: "Fix login" }); + + send(settled); + expect(last()).toEqual({ state: "done", message: "Fix login" }); + expect(JSON.stringify(reports)).not.toContain("secret assistant output"); + }); + + it("reports only the outcome of the run: retried errors, final errors, and aborts", () => { + const { send, last } = setup(); + send({ type: "agent_start" }, assistantEnd("error", "overloaded"), assistantEnd("stop"), settled); + expect(last()).toEqual({ state: "done" }); + + send({ type: "agent_start" }, assistantEnd("error", "Invalid API key\n{details}"), settled); + expect(last()).toEqual({ state: "error", message: "Invalid API key" }); + + send({ type: "agent_start" }, assistantEnd("aborted"), { type: "agent_settled", aborted: true }); + expect(last()).toEqual({ state: "idle" }); + + // Aborted after a successful response, for example in an agent_before_settle hook or a retry delay. + send({ type: "agent_start" }, assistantEnd("stop"), { type: "agent_settled", aborted: true }); + expect(last()).toEqual({ state: "idle" }); + }); + + it("reports a failed recovery compaction as the run's error unless a later response succeeds", () => { + const { send, last } = setup(); + send( + { type: "agent_start" }, + assistantEnd("length"), + { type: "compaction_start", reason: "overflow" }, + compactionEnd("overflow", { errorMessage: "Compaction failed\nstack" }), + settled, + ); + expect(last()).toEqual({ state: "error", message: "Compaction failed" }); + + send( + { type: "agent_start" }, + { type: "compaction_start", reason: "threshold" }, + compactionEnd("threshold", { errorMessage: "Compaction failed" }), + assistantEnd("stop"), + settled, + ); + expect(last()).toEqual({ state: "done" }); + }); + + it("reports compaction inside a run and the result of a manual compaction", () => { + const { send, last } = setup("Session"); + send({ type: "agent_start" }, { type: "compaction_start", reason: "threshold" }); + expect(last()).toEqual({ state: "working", message: "Compacting context" }); + send(compactionEnd("threshold")); + expect(last()).toEqual({ state: "working", message: "Session" }); + send(assistantEnd("stop"), settled); + + send({ type: "compaction_start", reason: "manual" }, compactionEnd("manual")); + expect(last()).toEqual({ state: "done", message: "Session" }); + send({ type: "compaction_start", reason: "manual" }, compactionEnd("manual", { errorMessage: "No model" })); + expect(last()).toEqual({ state: "error", message: "No model" }); + send({ type: "compaction_start", reason: "manual" }, compactionEnd("manual", { aborted: true })); + expect(last()).toEqual({ state: "idle" }); + }); + + it("reports the most recent open dialog and the underlying state once all close", () => { + const { reporter, send, last } = setup(); + send({ type: "agent_start" }); + reporter.setBlocked("extension-selector", { kind: "permission", message: "Allow bash?" }); + reporter.setBlocked("login", { kind: "auth", message: "Log in to Anthropic" }); + expect(last()).toEqual({ state: "blocked", kind: "auth", message: "Log in to Anthropic" }); + + // The run settles while the selector is still open. + reporter.setBlocked("login", undefined); + send(assistantEnd("stop"), settled); + expect(last()).toEqual({ state: "blocked", kind: "permission", message: "Allow bash?" }); + + // Reopening a source replaces its dialog instead of stacking a second one. + reporter.setBlocked("extension-selector", { kind: "question", message: "Pick one" }); + expect(last()).toEqual({ state: "blocked", kind: "question", message: "Pick one" }); + reporter.setBlocked("extension-selector", undefined); + expect(last()).toEqual({ state: "done" }); + }); + + it("sends each status once and follows session name changes", () => { + const { reporter, reports, session, send, last } = setup("Old"); + send({ type: "agent_start" }, { type: "turn_start" } as AgentSessionEvent, assistantEnd("toolUse")); + reporter.report(); + expect(reports).toHaveLength(1); + + session.name = "New"; + send({ type: "session_info_changed", name: "New" }); + expect(last()).toEqual({ state: "working", message: "New" }); + }); + + it("returns to idle when the session is replaced", () => { + const { reporter, send, last } = setup(); + send({ type: "agent_start" }, assistantEnd("stop"), settled); + reporter.reset(); + expect(last()).toEqual({ state: "idle" }); + }); +}); diff --git a/packages/coding-agent/test/suite/agent-session-boundaries.test.ts b/packages/coding-agent/test/suite/agent-session-boundaries.test.ts index 5115efb74..a4d30c870 100644 --- a/packages/coding-agent/test/suite/agent-session-boundaries.test.ts +++ b/packages/coding-agent/test/suite/agent-session-boundaries.test.ts @@ -732,7 +732,8 @@ describe("AgentSession actionable boundaries", () => { expect(harness.sessionManager.getEntries()).toContainEqual( expect.objectContaining({ type: "custom", customType: "committed-after-abort", data: true }), ); - expect(harness.eventsOfType("agent_settled")).toHaveLength(1); + // #10607: settlement reports the abort although the last response succeeded. + expect(harness.eventsOfType("agent_settled")).toEqual([{ type: "agent_settled", aborted: true }]); }); }); diff --git a/packages/coding-agent/test/suite/regressions/4167-thinking-toggle-pending-tool-render.test.ts b/packages/coding-agent/test/suite/regressions/4167-thinking-toggle-pending-tool-render.test.ts index 8d1c2dab0..14a73c8d9 100644 --- a/packages/coding-agent/test/suite/regressions/4167-thinking-toggle-pending-tool-render.test.ts +++ b/packages/coding-agent/test/suite/regressions/4167-thinking-toggle-pending-tool-render.test.ts @@ -37,6 +37,7 @@ type RenderSessionContextThis = { pendingTools: Map; chatContainer: Container; footer: { invalidate(): void }; + programStatus: { handleEvent(): void }; ui: TUI; settingsManager: { getShowImages(): boolean; @@ -68,6 +69,7 @@ function createFakeInteractiveModeThis(): RenderSessionContextThis { pendingTools: new Map(), chatContainer, footer: { invalidate: vi.fn() }, + programStatus: { handleEvent: vi.fn() }, ui: { requestRender: vi.fn() } as unknown as TUI, settingsManager: { getShowImages: () => false, diff --git a/packages/coding-agent/test/suite/regressions/5943-session-start-notify.test.ts b/packages/coding-agent/test/suite/regressions/5943-session-start-notify.test.ts index c73e90216..dd56c1991 100644 --- a/packages/coding-agent/test/suite/regressions/5943-session-start-notify.test.ts +++ b/packages/coding-agent/test/suite/regressions/5943-session-start-notify.test.ts @@ -41,6 +41,7 @@ type LoadedResourcesContext = { type RebindContext = { unsubscribe?: () => void; applyRuntimeSettings: () => void; + programStatus: { reset(): void }; renderCurrentSessionState: () => void; bindCurrentSessionExtensions: () => Promise; subscribeToAgent: () => void; @@ -252,6 +253,7 @@ describe("regression #5943: session_start transient UI", () => { try { const context: RebindContext = { applyRuntimeSettings: () => events.push("apply"), + programStatus: { reset: () => {} }, renderCurrentSessionState: () => events.push("render"), bindCurrentSessionExtensions: async () => { events.push("bind"); @@ -293,6 +295,7 @@ describe("regression #5943: session_start transient UI", () => { try { const context: RebindContext = { applyRuntimeSettings: () => {}, + programStatus: { reset: () => {} }, renderCurrentSessionState: () => events.push("render"), bindCurrentSessionExtensions: async () => { events.push("bind"); @@ -345,6 +348,7 @@ describe("regression #5943: session_start transient UI", () => { try { const context: RebindContext = { applyRuntimeSettings: () => {}, + programStatus: { reset: () => {} }, renderCurrentSessionState: () => events.push("render"), bindCurrentSessionExtensions: async () => { events.push("bind"); diff --git a/packages/coding-agent/test/suite/regressions/6363-agent-settled-event.test.ts b/packages/coding-agent/test/suite/regressions/6363-agent-settled-event.test.ts index d31010478..d4edec68a 100644 --- a/packages/coding-agent/test/suite/regressions/6363-agent-settled-event.test.ts +++ b/packages/coding-agent/test/suite/regressions/6363-agent-settled-event.test.ts @@ -56,7 +56,7 @@ describe("regression #6363: agent settled event and idle waiting", () => { await harness.session.prompt("test"); expect(harness.eventsOfType("agent_end").map((event) => event.willRetry)).toEqual([true, false]); - expect(harness.eventsOfType("agent_settled")).toHaveLength(1); + expect(harness.eventsOfType("agent_settled")).toEqual([{ type: "agent_settled", aborted: false }]); expect(extensionEvents).toEqual(["agent_end", "agent_end", "agent_settled:true"]); expect(publicEvents).toEqual(["agent_settled"]); }); diff --git a/packages/coding-agent/test/suite/regressions/startup-session-rebind-duplicate-subscription.test.ts b/packages/coding-agent/test/suite/regressions/startup-session-rebind-duplicate-subscription.test.ts index 7b641d7f8..76efdcf5e 100644 --- a/packages/coding-agent/test/suite/regressions/startup-session-rebind-duplicate-subscription.test.ts +++ b/packages/coding-agent/test/suite/regressions/startup-session-rebind-duplicate-subscription.test.ts @@ -5,6 +5,7 @@ type RebindContext = { session: object; unsubscribe?: () => void; applyRuntimeSettings: () => void; + programStatus: { reset(): void }; renderCurrentSessionState: () => void; bindCurrentSessionExtensions: () => Promise; subscribeToAgent: () => void; @@ -40,6 +41,7 @@ describe("overlapping startup and replacement session rebinds", () => { const context: RebindContext = { session: startupSession, applyRuntimeSettings: () => {}, + programStatus: { reset: () => {} }, renderCurrentSessionState: () => {}, bindCurrentSessionExtensions: () => { bindCount += 1; diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index d10b963d7..76e80e3ee 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,9 +2,14 @@ ## [Unreleased] +### Breaking Changes + +- `Terminal` implementations must provide `setProgramStatus(status)`; a terminal without OSC 7501 support can implement it as a no-op ([#10607](https://github.com/earendil-works/pi/issues/10607)) + ### Added - Added `Box.setPaddingX(...)` and `Text.setPaddingX(...)` ([#10557](https://github.com/earendil-works/pi/pull/10557) by [@rwachtler](https://github.com/rwachtler)) +- Added `Terminal.setProgramStatus()` and `formatProgramStatus()` for the Program Status Protocol (OSC 7501). `ProcessTerminal` asks the terminal for support at startup and sends reports only if it answers; `PI_PROGRAM_STATUS=1|0` overrides detection ([#10607](https://github.com/earendil-works/pi/issues/10607)) ### Fixed diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 957221509..a7aa6215c 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -111,6 +111,8 @@ export { type RenderLatexOptions, renderLatex } from "./latex.ts"; // Native platform integration export { getNativeClipboard, type NativeClipboard } from "./native-platform.ts"; export { oklabToOkhslLightness } from "./oklab.ts"; +// Program status reporting (OSC 7501) +export { formatProgramStatus, type ProgramStatus } from "./program-status.ts"; // Input buffering for batch splitting export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from "./stdin-buffer.ts"; // Terminal interface and implementations diff --git a/packages/tui/src/program-status.ts b/packages/tui/src/program-status.ts new file mode 100644 index 000000000..3b71e268d --- /dev/null +++ b/packages/tui/src/program-status.ts @@ -0,0 +1,53 @@ +/** + * Program Status Protocol (OSC 7501): a program tells the terminal whether it is idle, working, + * blocked on the user, done, or failed. Only the root record is supported. + * + * Spec: https://www.superlogical.com/rex/docs/build/program-status + */ + +export interface ProgramStatus { + /** `clear` removes the status instead of reporting one. */ + state: "idle" | "working" | "blocked" | "done" | "error" | "clear"; + /** Stable program name, `[A-Za-z0-9_.+-]{1,32}`. Other values are omitted. */ + app?: string; + /** What a blocked program waits for. Omitted for other states. */ + kind?: "permission" | "question" | "auth"; + /** One human-readable line. Control characters become spaces; longer text is cut to the spec limit. */ + message?: string; +} + +/** Feature detection query. A supporting terminal replies with the same body. */ +export const PROGRAM_STATUS_QUERY = "\x1b]7501;?\x1b\\"; + +/** The reply to {@link PROGRAM_STATUS_QUERY}. Later spec revisions may add pairs after the `?`. */ +export function isProgramStatusReply(sequence: string): boolean { + return /^\x1b\]7501;\?[^\x07\x1b]*(?:\x07|\x1b\\)$/.test(sequence); +} + +const APP_PATTERN = /^[A-Za-z0-9_.+-]{1,32}$/; +const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]+/g; +/** Decoded `msg` limit. Its base64 encoding stays under the 2732-byte encoded limit. */ +const MAX_MESSAGE_BYTES = 2048; + +function truncateUtf8(text: string, maxBytes: number): string { + if (Buffer.byteLength(text, "utf8") <= maxBytes) return text; + let bytes = 0; + let end = 0; + for (const char of text) { + const size = Buffer.byteLength(char, "utf8"); + if (bytes + size > maxBytes) break; + bytes += size; + end += char.length; + } + return text.slice(0, end); +} + +/** Encode a status report. Terminals discard reports whose text contains control characters, so they are replaced. */ +export function formatProgramStatus(status: ProgramStatus): string { + const pairs = [`state=${status.state}`]; + if (status.app !== undefined && APP_PATTERN.test(status.app)) pairs.push(`app=${status.app}`); + if (status.state === "blocked" && status.kind) pairs.push(`kind=${status.kind}`); + const message = truncateUtf8((status.message ?? "").replace(CONTROL_CHARACTERS, " ").trim(), MAX_MESSAGE_BYTES); + if (message) pairs.push(`msg=${Buffer.from(message, "utf8").toString("base64")}`); + return `\x1b]7501;${pairs.join(":")}\x1b\\`; +} diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index cce2dd116..b846869b5 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -3,6 +3,12 @@ import * as path from "node:path"; import { setKittyProtocolActive } from "./keys.ts"; import { isNativeModifierPressed } from "./native-modifiers.ts"; import { getNativePlatformHelper } from "./native-platform.ts"; +import { + formatProgramStatus, + isProgramStatusReply, + PROGRAM_STATUS_QUERY, + type ProgramStatus, +} from "./program-status.ts"; import { StdinBuffer } from "./stdin-buffer.ts"; const TERMINAL_PROGRESS_KEEPALIVE_MS = 1000; @@ -11,7 +17,8 @@ const TERMINAL_PROGRESS_CLEAR_SEQUENCE = "\x1b]9;4;0\x07"; const NATIVE_SHIFT_ENTER_SEQUENCE = "\x1b[13;2u"; const DESIRED_KITTY_KEYBOARD_PROTOCOL_FLAGS = 7; const KEYBOARD_PROTOCOL_RESPONSE_FRAGMENT_TIMEOUT_MS = 150; -const KITTY_KEYBOARD_PROTOCOL_QUERY = `\x1b[>${DESIRED_KITTY_KEYBOARD_PROTOCOL_FLAGS}u\x1b[?u\x1b[c`; +const KITTY_KEYBOARD_PROTOCOL_QUERY = `\x1b[>${DESIRED_KITTY_KEYBOARD_PROTOCOL_FLAGS}u\x1b[?u`; +const DEVICE_ATTRIBUTES_QUERY = "\x1b[c"; export type KeyboardProtocolNegotiationSequence = | { type: "kitty-flags"; flags: number } @@ -110,6 +117,12 @@ export interface Terminal { // Progress indicator (OSC 9;4) setProgress(active: boolean): void; + + /** + * Report what the program is doing (OSC 7501). Sent only to terminals that support it; the latest + * status is re-sent when support is confirmed or the terminal restarts. + */ + setProgramStatus(status: ProgramStatus): void; } const DEFAULT_ESCAPE_TIMEOUT_MS = 10; @@ -148,6 +161,12 @@ export class ProcessTerminal implements Terminal { private stdinBuffer?: StdinBuffer; private stdinDataHandler?: (data: string) => void; private progressInterval?: ReturnType; + /** Latest program status, kept across stop() so start() can report it again. */ + private programStatus?: ProgramStatus; + /** Whether the terminal confirmed OSC 7501 support since the last start(), or `PI_PROGRAM_STATUS=1`. */ + private programStatusSupported = false; + /** The support query was sent and its DA1 sentinel has not arrived yet. */ + private programStatusQueryPending = false; private writeLogPath = (() => { const env = process.env.PI_TUI_WRITE_LOG || ""; if (!env) return ""; @@ -217,6 +236,14 @@ export class ProcessTerminal implements Terminal { // Forward individual sequences to the input handler this.stdinBuffer.on("data", (sequence) => { + if (isProgramStatusReply(sequence)) { + if (this.programStatusQueryPending) { + this.programStatusQueryPending = false; + this.programStatusSupported = true; + this.writeProgramStatus(); + } + return; + } const negotiation = this.readKeyboardProtocolNegotiationSequence(sequence); if (negotiation === "pending") { this.scheduleKeyboardProtocolNegotiationBufferFlush(); @@ -254,6 +281,9 @@ export class ProcessTerminal implements Terminal { * - 1 = disambiguate escape codes * - 2 = report event types (press/repeat/release) * - 4 = report alternate keys (shifted key, base layout key) + * + * The OSC 7501 program status query shares the DA sentinel: a terminal that supports it replies + * before DA. `PI_PROGRAM_STATUS=1` or `0` skips the query. */ private queryAndEnableKittyProtocol(): void { this.setupStdinBuffer(); @@ -261,7 +291,12 @@ export class ProcessTerminal implements Terminal { this.keyboardProtocolPushed = true; this.pendingKeyboardProtocolDeviceAttributes += 1; this.clearKeyboardProtocolNegotiationBuffer(); - process.stdout.write(KITTY_KEYBOARD_PROTOCOL_QUERY); + const programStatusOverride = process.env.PI_PROGRAM_STATUS; + this.programStatusSupported = programStatusOverride === "1"; + this.programStatusQueryPending = programStatusOverride !== "1" && programStatusOverride !== "0"; + const programStatusQuery = this.programStatusQueryPending ? PROGRAM_STATUS_QUERY : ""; + process.stdout.write(`${KITTY_KEYBOARD_PROTOCOL_QUERY}${programStatusQuery}${DEVICE_ATTRIBUTES_QUERY}`); + this.writeProgramStatus(); } private handleKeyboardProtocolNegotiationSequence( @@ -271,6 +306,9 @@ export class ProcessTerminal implements Terminal { if (negotiationSequence.type === "device-attributes") { if (this.pendingKeyboardProtocolDeviceAttributes === 0) return false; this.pendingKeyboardProtocolDeviceAttributes -= 1; + // The last owed DA answers the latest query, which got no program status reply first. Earlier DA + // replies belong to queries from before a restart. + if (this.pendingKeyboardProtocolDeviceAttributes === 0) this.programStatusQueryPending = false; } if (negotiationSequence.type === "kitty-flags") { if (negotiationSequence.flags !== 0) { @@ -430,6 +468,12 @@ export class ProcessTerminal implements Terminal { if (this.clearProgressInterval()) { process.stdout.write(TERMINAL_PROGRESS_CLEAR_SEQUENCE); } + // Remove the status while stopped, for example after exit or while suspended. start() reports it again. + if (this.programStatusSupported && this.programStatus) { + process.stdout.write(formatProgramStatus({ state: "clear" })); + } + this.programStatusSupported = false; + this.programStatusQueryPending = false; // Disable bracketed paste mode process.stdout.write("\x1b[?2004l"); @@ -529,6 +573,17 @@ export class ProcessTerminal implements Terminal { process.stdout.write(`\x1b]0;${title}\x07`); } + setProgramStatus(status: ProgramStatus): void { + this.programStatus = status.state === "clear" ? undefined : status; + if (this.programStatusSupported) process.stdout.write(formatProgramStatus(status)); + } + + private writeProgramStatus(): void { + if (this.programStatusSupported && this.programStatus) { + process.stdout.write(formatProgramStatus(this.programStatus)); + } + } + setProgress(active: boolean): void { if (active) { // OSC 9;4;3 - indeterminate progress diff --git a/packages/tui/test/alt-screen-large-transcript-bench.ts b/packages/tui/test/alt-screen-large-transcript-bench.ts index 03df87543..de6a7958c 100644 --- a/packages/tui/test/alt-screen-large-transcript-bench.ts +++ b/packages/tui/test/alt-screen-large-transcript-bench.ts @@ -67,6 +67,7 @@ class NullTerminal implements Terminal { clearScreen(): void {} setTitle(_title: string): void {} setProgress(_active: boolean): void {} + setProgramStatus(): void {} } const userMarkdown = [ diff --git a/packages/tui/test/program-status.test.ts b/packages/tui/test/program-status.test.ts new file mode 100644 index 000000000..a2a164c00 --- /dev/null +++ b/packages/tui/test/program-status.test.ts @@ -0,0 +1,51 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { formatProgramStatus, isProgramStatusReply } from "../src/program-status.ts"; + +function decodeMessage(sequence: string): string | undefined { + const message = /:msg=([A-Za-z0-9+/=]*)/.exec(sequence)?.[1]; + return message === undefined ? undefined : Buffer.from(message, "base64").toString("utf8"); +} + +// #10607 +describe("formatProgramStatus", () => { + it("encodes state, app, kind, and a base64 message", () => { + assert.equal( + formatProgramStatus({ state: "blocked", app: "pi", kind: "permission", message: "Allow bash?" }), + `\x1b]7501;state=blocked:app=pi:kind=permission:msg=${Buffer.from("Allow bash?").toString("base64")}\x1b\\`, + ); + assert.equal(formatProgramStatus({ state: "clear" }), "\x1b]7501;state=clear\x1b\\"); + }); + + it("omits kind outside blocked, invalid app names, and empty messages", () => { + assert.equal( + formatProgramStatus({ state: "working", app: "my app", kind: "auth", message: " \n " }), + "\x1b]7501;state=working\x1b\\", + ); + assert.match(formatProgramStatus({ state: "idle", app: "a".repeat(32) }), /:app=a{32}\x1b/); + assert.doesNotMatch(formatProgramStatus({ state: "idle", app: "a".repeat(33) }), /app=/); + }); + + it("replaces control characters, which make terminals discard the report", () => { + const sequence = formatProgramStatus({ state: "error", message: "first\nsecond\x1b[31m\u009bthird\t" }); + assert.equal(decodeMessage(sequence), "first second [31m third"); + }); + + it("cuts long messages at a UTF-8 boundary within the spec limits", () => { + const sequence = formatProgramStatus({ state: "working", app: "pi", message: "é".repeat(2000) }); + const message = decodeMessage(sequence)!; + assert.equal(message, "é".repeat(1024)); + assert.ok(Buffer.byteLength(message) <= 2048); + assert.ok(sequence.length <= 4096); + }); +}); + +describe("isProgramStatusReply", () => { + it("accepts the query echo with either terminator and future pairs", () => { + assert.equal(isProgramStatusReply("\x1b]7501;?\x1b\\"), true); + assert.equal(isProgramStatusReply("\x1b]7501;?\x07"), true); + assert.equal(isProgramStatusReply("\x1b]7501;?version=2\x1b\\"), true); + assert.equal(isProgramStatusReply("\x1b]7501;state=idle\x1b\\"), false); + assert.equal(isProgramStatusReply("\x1b]11;rgb:0000/0000/0000\x07"), false); + }); +}); diff --git a/packages/tui/test/render-churn-bench.ts b/packages/tui/test/render-churn-bench.ts index 103c9561b..6bf712560 100644 --- a/packages/tui/test/render-churn-bench.ts +++ b/packages/tui/test/render-churn-bench.ts @@ -57,6 +57,7 @@ class NullTerminal implements Terminal { clearScreen(): void {} setTitle(_title: string): void {} setProgress(_active: boolean): void {} + setProgramStatus(): void {} } /** Editor stand-in: caches lines per (text, width), re-renders when text changes. */ diff --git a/packages/tui/test/terminal-colors.test.ts b/packages/tui/test/terminal-colors.test.ts index 1f2b7013f..5977ac881 100644 --- a/packages/tui/test/terminal-colors.test.ts +++ b/packages/tui/test/terminal-colors.test.ts @@ -65,6 +65,7 @@ class TestTerminal implements Terminal { setTitle(_title: string): void {} setProgress(_active: boolean): void {} + setProgramStatus(): void {} sendInput(data: string): void { this.inputHandler?.(data); diff --git a/packages/tui/test/terminal.test.ts b/packages/tui/test/terminal.test.ts index 79f4410f3..437994a38 100644 --- a/packages/tui/test/terminal.test.ts +++ b/packages/tui/test/terminal.test.ts @@ -132,7 +132,7 @@ describe("ProcessTerminal Kitty keyboard protocol negotiation", () => { it("queries Kitty mode before enabling modifyOtherKeys fallback", () => { const harness = setupNegotiation(); try { - assert.equal(harness.writes[0], "\x1b[>7u\x1b[?u\x1b[c"); + assert.equal(harness.writes[0], "\x1b[>7u\x1b[?u\x1b]7501;?\x1b\\\x1b[c"); assert.equal(harness.writes.includes("\x1b[>4;2m"), false); assert.equal(harness.terminal.kittyProtocolActive, false); } finally { @@ -233,6 +233,129 @@ describe("ProcessTerminal Kitty keyboard protocol negotiation", () => { } }); + // #10607 + describe("program status (OSC 7501)", () => { + const working = "\x1b]7501;state=working:app=pi\x1b\\"; + const clear = "\x1b]7501;state=clear\x1b\\"; + + function withOverride(value: string | undefined, fn: () => void): void { + const previous = process.env.PI_PROGRAM_STATUS; + if (value === undefined) delete process.env.PI_PROGRAM_STATUS; + else process.env.PI_PROGRAM_STATUS = value; + try { + fn(); + } finally { + if (previous === undefined) delete process.env.PI_PROGRAM_STATUS; + else process.env.PI_PROGRAM_STATUS = previous; + } + } + + it("reports the latest status once the terminal answers the query before DA", () => { + withOverride(undefined, () => { + const harness = setupNegotiation(); + try { + harness.terminal.setProgramStatus({ state: "working", app: "pi" }); + assert.equal(harness.writes.includes(working), false); + + harness.send("\x1b]7501;?\x1b\\"); + assert.equal(harness.getInput(), undefined); + assert.equal(harness.writes.filter((write) => write === working).length, 1); + harness.send("\x1b[?62;4;52c"); + + harness.terminal.setProgramStatus({ state: "done" }); + assert.equal(harness.writes.at(-1), "\x1b]7501;state=done\x1b\\"); + } finally { + harness.cleanup(); + } + }); + }); + + it("reports nothing when DA arrives first and swallows late replies", () => { + withOverride(undefined, () => { + const harness = setupNegotiation(); + try { + harness.send("\x1b[?62;4;52c"); + harness.send("\x1b]7501;?\x07"); + harness.terminal.setProgramStatus({ state: "working", app: "pi" }); + + assert.equal(harness.getInput(), undefined); + assert.equal( + harness.writes.some((write) => write.startsWith("\x1b]7501;state=")), + false, + ); + } finally { + harness.cleanup(); + } + }); + }); + + it("skips the query when PI_PROGRAM_STATUS is set", () => { + withOverride("1", () => { + const harness = setupNegotiation(); + try { + assert.equal(harness.writes[0], "\x1b[>7u\x1b[?u\x1b[c"); + harness.terminal.setProgramStatus({ state: "working", app: "pi" }); + assert.equal(harness.writes.at(-1), working); + } finally { + harness.cleanup(); + } + }); + withOverride("0", () => { + const harness = setupNegotiation(); + try { + assert.equal(harness.writes[0], "\x1b[>7u\x1b[?u\x1b[c"); + harness.terminal.setProgramStatus({ state: "working", app: "pi" }); + assert.equal(harness.writes.includes(working), false); + } finally { + harness.cleanup(); + } + }); + }); + + it("does not let a DA reply from before a restart end the new query", () => { + withOverride(undefined, () => { + const harness = setupNegotiation(); + try { + harness.terminal.setProgramStatus({ state: "working", app: "pi" }); + harness.terminal.stop(); + (harness.terminal as unknown as { queryAndEnableKittyProtocol(): void }).queryAndEnableKittyProtocol(); + + // The first start's replies arrive late: its DA, then the second start's reply and DA. + harness.send("\x1b[?62;4;52c"); + harness.send("\x1b]7501;?\x1b\\"); + harness.send("\x1b[?62;4;52c"); + assert.equal(harness.writes.at(-1), working); + assert.equal(harness.getInput(), undefined); + } finally { + harness.cleanup(); + } + }); + }); + + it("clears the status on stop and reports it again after restart", () => { + withOverride(undefined, () => { + const harness = setupNegotiation(); + try { + harness.terminal.setProgramStatus({ state: "working", app: "pi" }); + harness.send("\x1b]7501;?\x1b\\"); + harness.terminal.stop(); + assert.ok(harness.writes.includes(clear)); + + // Stopped: nothing is written until the restarted terminal confirms support again. + const writesBeforeRestart = harness.writes.length; + harness.terminal.setProgramStatus({ state: "working", app: "pi" }); + assert.equal(harness.writes.length, writesBeforeRestart); + + (harness.terminal as unknown as { queryAndEnableKittyProtocol(): void }).queryAndEnableKittyProtocol(); + harness.send("\x1b]7501;?\x1b\\"); + assert.equal(harness.writes.at(-1), working); + } finally { + harness.cleanup(); + } + }); + }); + }); + it("replays buffered CSI-prefix input when it is not a Kitty response", () => { mock.timers.enable({ apis: ["setTimeout"] }); const harness = setupNegotiation(); diff --git a/packages/tui/test/tui-render.test.ts b/packages/tui/test/tui-render.test.ts index 67e0f3e47..ae6e7bb68 100644 --- a/packages/tui/test/tui-render.test.ts +++ b/packages/tui/test/tui-render.test.ts @@ -60,6 +60,7 @@ class BoundedWriteTerminal implements Terminal { clearScreen(): void {} setTitle(_title: string): void {} setProgress(_active: boolean): void {} + setProgramStatus(): void {} } class LoggingVirtualTerminal extends VirtualTerminal { diff --git a/packages/tui/test/virtual-terminal.ts b/packages/tui/test/virtual-terminal.ts index 4e067f4e5..28865d18e 100644 --- a/packages/tui/test/virtual-terminal.ts +++ b/packages/tui/test/virtual-terminal.ts @@ -101,6 +101,7 @@ export class VirtualTerminal implements Terminal { } setProgress(_active: boolean): void {} + setProgramStatus(): void {} // Test-specific methods not in Terminal interface