feat(tui,coding-agent): report program status via OSC 7501

Report idle, working, blocked, done, and error to terminals that support
the Program Status Protocol. ProcessTerminal sends the support query with
the keyboard query and reports only after the terminal answers before DA1;
PI_PROGRAM_STATUS=1|0 overrides detection. Interactive mode reports runs,
compaction, extension dialogs, and logins. agent_settled now carries
aborted so cancelled runs report idle.

closes #10607
This commit is contained in:
Mario Zechner 2026-10-07 19:04:53 +00:00
parent f10993bc7f
commit 503c605528
27 changed files with 643 additions and 23 deletions

View file

@ -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

View file

@ -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 |

View file

@ -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. |

View file

@ -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.

View file

@ -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;
}

View file

@ -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";

View file

@ -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<string | undefined> {
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<boolean> {
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<void> {
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<void> {
@ -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);

View file

@ -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<ProgramStatus["kind"]>; 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<string, BlockedStatus>();
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;
}
}

View file

@ -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;

View file

@ -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(),

View file

@ -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" });
});
});

View file

@ -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 }]);
});
});

View file

@ -37,6 +37,7 @@ type RenderSessionContextThis = {
pendingTools: Map<string, ToolExecutionComponent>;
chatContainer: Container;
footer: { invalidate(): void };
programStatus: { handleEvent(): void };
ui: TUI;
settingsManager: {
getShowImages(): boolean;
@ -68,6 +69,7 @@ function createFakeInteractiveModeThis(): RenderSessionContextThis {
pendingTools: new Map<string, ToolExecutionComponent>(),
chatContainer,
footer: { invalidate: vi.fn() },
programStatus: { handleEvent: vi.fn() },
ui: { requestRender: vi.fn() } as unknown as TUI,
settingsManager: {
getShowImages: () => false,

View file

@ -41,6 +41,7 @@ type LoadedResourcesContext = {
type RebindContext = {
unsubscribe?: () => void;
applyRuntimeSettings: () => void;
programStatus: { reset(): void };
renderCurrentSessionState: () => void;
bindCurrentSessionExtensions: () => Promise<void>;
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");

View file

@ -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"]);
});

View file

@ -5,6 +5,7 @@ type RebindContext = {
session: object;
unsubscribe?: () => void;
applyRuntimeSettings: () => void;
programStatus: { reset(): void };
renderCurrentSessionState: () => void;
bindCurrentSessionExtensions: () => Promise<void>;
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;

View file

@ -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

View file

@ -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

View file

@ -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\\`;
}

View file

@ -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<typeof setInterval>;
/** 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

View file

@ -67,6 +67,7 @@ class NullTerminal implements Terminal {
clearScreen(): void {}
setTitle(_title: string): void {}
setProgress(_active: boolean): void {}
setProgramStatus(): void {}
}
const userMarkdown = [

View file

@ -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);
});
});

View file

@ -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. */

View file

@ -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);

View file

@ -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();

View file

@ -60,6 +60,7 @@ class BoundedWriteTerminal implements Terminal {
clearScreen(): void {}
setTitle(_title: string): void {}
setProgress(_active: boolean): void {}
setProgramStatus(): void {}
}
class LoggingVirtualTerminal extends VirtualTerminal {

View file

@ -101,6 +101,7 @@ export class VirtualTerminal implements Terminal {
}
setProgress(_active: boolean): void {}
setProgramStatus(): void {}
// Test-specific methods not in Terminal interface