diff --git a/packages/coding-agent/docs/terminal-setup.md b/packages/coding-agent/docs/terminal-setup.md index a3c67af07..416b72735 100644 --- a/packages/coding-agent/docs/terminal-setup.md +++ b/packages/coding-agent/docs/terminal-setup.md @@ -230,4 +230,6 @@ Pi reports its state with the [Program Status Protocol (OSC 7501)](https://www.s | `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. | +When the session has a name, every report also carries it as the `title`, so dashboards get a stable label whatever the state. + 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/modes/interactive/program-status-reporter.ts b/packages/coding-agent/src/modes/interactive/program-status-reporter.ts index 4dac611f9..7922fdae5 100644 --- a/packages/coding-agent/src/modes/interactive/program-status-reporter.ts +++ b/packages/coding-agent/src/modes/interactive/program-status-reporter.ts @@ -11,7 +11,7 @@ function firstLine(text: string | undefined): string { /** * 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 + * settles. The title is the session name. 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 { @@ -68,7 +68,7 @@ export class ProgramStatusReporter { this.restingStatus = event.aborted ? { state: "idle" } : this.runResult; break; case "session_info_changed": - // The session name is part of working and done reports. + // The session name is the title of every report and the message of working and done reports. break; default: return; @@ -93,7 +93,7 @@ export class ProgramStatusReporter { } report(): void { - const status: ProgramStatus = { ...this.currentStatus(), app: APP_NAME }; + const status: ProgramStatus = { ...this.currentStatus(), app: APP_NAME, title: this.getSessionName() }; const key = JSON.stringify(status); if (key === this.lastReport) return; this.lastReport = key; diff --git a/packages/coding-agent/test/program-status-reporter.test.ts b/packages/coding-agent/test/program-status-reporter.test.ts index aa09333ac..6d78b9fe4 100644 --- a/packages/coding-agent/test/program-status-reporter.test.ts +++ b/packages/coding-agent/test/program-status-reporter.test.ts @@ -16,7 +16,7 @@ function setup(sessionName?: string) { for (const event of events) reporter.handleEvent(event); }; const last = () => { - const { app: _app, ...status } = reports.at(-1)!; + const { app: _app, title: _title, ...status } = reports.at(-1)!; return Object.fromEntries(Object.entries(status).filter(([, value]) => value !== undefined)); }; return { reporter, reports, session, send, last }; @@ -51,7 +51,7 @@ 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" }); + expect(reports.at(-1)).toEqual({ state: "idle", app: "pi", title: "Fix login" }); send({ type: "agent_start" }); expect(last()).toEqual({ state: "working", message: "Fix login" }); @@ -147,6 +147,27 @@ describe("ProgramStatusReporter", () => { expect(last()).toEqual({ state: "working", message: "New" }); }); + // #11670 + it("sends the session name as the title in every state", () => { + const { reporter, reports, session, send } = setup(); + reporter.report(); + expect(reports.at(-1)!.title).toBeUndefined(); + + session.name = "Fix login"; + send({ type: "session_info_changed", name: "Fix login" }); + expect(reports.at(-1)).toMatchObject({ state: "idle", title: "Fix login" }); + + send({ type: "agent_start" }, { type: "compaction_start", reason: "threshold" } as AgentSessionEvent); + expect(reports.at(-1)).toMatchObject({ state: "working", title: "Fix login", message: "Compacting context" }); + + reporter.setBlocked("extension-selector", { kind: "question", message: "Pick one" }); + expect(reports.at(-1)).toMatchObject({ state: "blocked", title: "Fix login", message: "Pick one" }); + reporter.setBlocked("extension-selector", undefined); + + send(compactionEnd("threshold"), assistantEnd("error", "Invalid API key"), settled); + expect(reports.at(-1)).toMatchObject({ state: "error", title: "Fix login", message: "Invalid API key" }); + }); + it("returns to idle when the session is replaced", () => { const { reporter, send, last } = setup(); send({ type: "agent_start" }, assistantEnd("stop"), settled); diff --git a/packages/tui/src/program-status.ts b/packages/tui/src/program-status.ts index 3b71e268d..04ce04454 100644 --- a/packages/tui/src/program-status.ts +++ b/packages/tui/src/program-status.ts @@ -12,6 +12,8 @@ export interface ProgramStatus { app?: string; /** What a blocked program waits for. Omitted for other states. */ kind?: "permission" | "question" | "auth"; + /** Short human-readable label for the program. Control characters become spaces; longer text is cut to the spec limit. */ + title?: string; /** One human-readable line. Control characters become spaces; longer text is cut to the spec limit. */ message?: string; } @@ -28,6 +30,8 @@ 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; +/** Decoded `title` limit. Its base64 encoding stays within the 256-byte encoded limit. */ +const MAX_TITLE_BYTES = 192; function truncateUtf8(text: string, maxBytes: number): string { if (Buffer.byteLength(text, "utf8") <= maxBytes) return text; @@ -42,12 +46,20 @@ function truncateUtf8(text: string, maxBytes: number): string { return text.slice(0, end); } +/** Replace control characters, cut to `maxBytes` decoded bytes, and base64-encode. Empty text yields "". */ +function encodeText(text: string | undefined, maxBytes: number): string { + const clean = truncateUtf8((text ?? "").replace(CONTROL_CHARACTERS, " ").trim(), maxBytes); + return clean ? Buffer.from(clean, "utf8").toString("base64") : ""; +} + /** 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")}`); + const title = encodeText(status.title, MAX_TITLE_BYTES); + if (title) pairs.push(`title=${title}`); + const message = encodeText(status.message, MAX_MESSAGE_BYTES); + if (message) pairs.push(`msg=${message}`); return `\x1b]7501;${pairs.join(":")}\x1b\\`; } diff --git a/packages/tui/test/program-status.test.ts b/packages/tui/test/program-status.test.ts index a2a164c00..c7cb39da7 100644 --- a/packages/tui/test/program-status.test.ts +++ b/packages/tui/test/program-status.test.ts @@ -2,8 +2,8 @@ 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]; +function decodeMessage(sequence: string, key = "msg"): string | undefined { + const message = new RegExp(`:${key}=([A-Za-z0-9+/=]*)`).exec(sequence)?.[1]; return message === undefined ? undefined : Buffer.from(message, "base64").toString("utf8"); } @@ -40,6 +40,26 @@ describe("formatProgramStatus", () => { }); }); +// #11670 +describe("formatProgramStatus title", () => { + it("encodes the title as base64 before the message", () => { + const b64 = (text: string) => Buffer.from(text).toString("base64"); + assert.equal( + formatProgramStatus({ state: "done", app: "pi", title: "Fix login", message: "Fix login" }), + `\x1b]7501;state=done:app=pi:title=${b64("Fix login")}:msg=${b64("Fix login")}\x1b\\`, + ); + assert.equal(formatProgramStatus({ state: "idle", title: " \t " }), "\x1b]7501;state=idle\x1b\\"); + }); + + it("replaces control characters and cuts long titles at a UTF-8 boundary", () => { + assert.equal(decodeMessage(formatProgramStatus({ state: "idle", title: "a\nb\x1b" }), "title"), "a b"); + const sequence = formatProgramStatus({ state: "idle", title: "é".repeat(200) }); + const encoded = /:title=([A-Za-z0-9+/=]*)/.exec(sequence)![1]; + assert.equal(decodeMessage(sequence, "title"), "é".repeat(96)); + assert.ok(encoded.length <= 256); + }); +}); + describe("isProgramStatusReply", () => { it("accepts the query echo with either terminator and future pairs", () => { assert.equal(isProgramStatusReply("\x1b]7501;?\x1b\\"), true);