mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(tui,coding-agent): send the session name as the OSC 7501 title
This commit is contained in:
parent
6e491cfa95
commit
397184e924
5 changed files with 64 additions and 9 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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\\`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue