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

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