feat(tui,coding-agent): send the session name as the OSC 7501 title

This commit is contained in:
triagent 2026-11-04 08:52:20 +00:00
parent 6e491cfa95
commit 397184e924
5 changed files with 64 additions and 9 deletions

View file

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

View file

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

View file

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

View file

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

View file

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