From b859791c181f33e2757a899940c51938872fe78a Mon Sep 17 00:00:00 2001 From: zhangpenghui <1360315221@qq.com> Date: Sun, 16 Aug 2026 15:58:28 +0800 Subject: [PATCH] feat(tui): show session title in terminal tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror opencode's live terminal title ("OC | ") so hosts like Orca show a content-derived tab label instead of the process name. - Emit OSC 0 "ZC | <first-message title>" after the first user message - Show "ZC | ⠋ <activity>" while a turn is running - Clear the title on /new - Title: whitespace-collapsed first message, 50 code points max --- packages/zcode-tui/src/index.ts | 32 +++++++++ packages/zcode-tui/src/session-title.ts | 28 ++++++++ test/session-title.test.ts | 86 +++++++++++++++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 packages/zcode-tui/src/session-title.ts create mode 100644 test/session-title.test.ts diff --git a/packages/zcode-tui/src/index.ts b/packages/zcode-tui/src/index.ts index 0840ba1..b3a6669 100644 --- a/packages/zcode-tui/src/index.ts +++ b/packages/zcode-tui/src/index.ts @@ -147,6 +147,10 @@ import { type ProtectedSubmission, type SelectionCommand } from "./selection-command.ts"; +import { + emitSessionTerminalTitle, + sessionTitleFromFirstMessage +} from "./session-title.ts"; import { SkillCatalog } from "./skills.ts"; import { isActiveBackgroundJob, @@ -483,6 +487,8 @@ class ZCodeTui { private goalRefreshInFlight = false; private goalRefreshPending = false; private sessionId?: string; + private sessionTitleEmitted = false; + private sessionTerminalTitle?: string; private sessionMetrics: SessionMetrics = {}; private usageRefreshInFlight = false; private usageRefreshPending = false; @@ -1084,6 +1090,14 @@ class ZCodeTui { this.interruptBackgroundHandoffForInput(); return; } + if (!steering && !input.startsWith("/") && !this.sessionTitleEmitted) { + const sessionTitle = sessionTitleFromFirstMessage(submission.displayInput); + if (sessionTitle !== null) { + this.sessionTerminalTitle = sessionTitle; + this.sessionTitleEmitted = true; + this.refreshSessionTerminalTitle(); + } + } const turnEpoch = steering ? this.activeTurnEpoch : ++this.turnEpoch; if (turnEpoch === undefined) { this.inputQueue.queueFollowUp({ ...submission, recordHistory: false }); @@ -1254,6 +1268,7 @@ class ZCodeTui { ): QueuedSubmission | undefined { this.activeSubmissions = Math.max(0, this.activeSubmissions - 1); if (this.activeTurnEpoch !== turnEpoch) return undefined; + this.refreshSessionTerminalTitle(); const turnFinishState = abortController.signal.aborted ? "cancelled" : unfinishedToolState; const recoveryReason = turnFinishState === "cancelled" @@ -1417,6 +1432,9 @@ class ZCodeTui { this.clearTranscriptProjection(); this.workflowView = undefined; this.sessionId = undefined; + this.sessionTitleEmitted = false; + this.sessionTerminalTitle = undefined; + emitSessionTerminalTitle(this.options.stdout ?? process.stdout, ""); this.sessionMetrics = {}; this.restoreTranscript(restoredMessages(result.restoredMessages)); } @@ -3991,9 +4009,23 @@ class ZCodeTui { private updateActivity(activity: string | undefined, requestRender = true): void { this.activity = activity ? sanitizeTerminalText(activity, { preserveSgr: false }) : undefined; + this.refreshSessionTerminalTitle(); this.updateTurnStatus(requestRender); } + // Keeps the terminal title in sync: "ZC | ⠋ <activity>" while a turn runs, + // "ZC | <first-message title>" when idle (mirrors opencode's live title). + private refreshSessionTerminalTitle(): void { + if (!this.sessionTerminalTitle) return; + const working = this.activeSubmissions > 0 && this.activity + ? `⠋ ${this.activity}` + : undefined; + emitSessionTerminalTitle( + this.options.stdout ?? process.stdout, + working ?? this.sessionTerminalTitle + ); + } + private updateTurnStatus(requestRender = true): void { if (this.turnStartedAt !== undefined) { this.turnElapsedMilliseconds = Math.max(0, performance.now() - this.turnStartedAt); diff --git a/packages/zcode-tui/src/session-title.ts b/packages/zcode-tui/src/session-title.ts new file mode 100644 index 0000000..1548a98 --- /dev/null +++ b/packages/zcode-tui/src/session-title.ts @@ -0,0 +1,28 @@ +import { sanitizeTerminalText } from "./terminal-text.ts"; + +// Mirrors opencode's "OC | <first message>" terminal title so hosts (Orca, +// terminal emulators) show a content-derived label instead of the process name. +export const SESSION_TITLE_PREFIX = "ZC | "; + +export const MAX_SESSION_TITLE_CHARS = 50; + +export function sessionTitleFromFirstMessage(message: string): string | null { + const normalized = sanitizeTerminalText(message).replace(/\s+/gu, " ").trim(); + if (!normalized) { + return null; + } + const characters = Array.from(normalized); + if (characters.length <= MAX_SESSION_TITLE_CHARS) { + return normalized; + } + return `${characters.slice(0, MAX_SESSION_TITLE_CHARS).join("")}…`; +} + +export function emitSessionTerminalTitle( + stream: { isTTY?: boolean; write: (chunk: string) => void } | undefined, + title: string +): void { + if (!stream?.isTTY) return; + // Empty title clears the terminal title, restoring the host's default label. + stream.write(title ? `\x1b]0;${SESSION_TITLE_PREFIX}${title}\x07` : "\x1b]0;\x07"); +} diff --git a/test/session-title.test.ts b/test/session-title.test.ts new file mode 100644 index 0000000..c55ef9b --- /dev/null +++ b/test/session-title.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test"; + +import { + MAX_SESSION_TITLE_CHARS, + SESSION_TITLE_PREFIX, + emitSessionTerminalTitle, + sessionTitleFromFirstMessage +} from "../packages/zcode-tui/src/session-title.ts"; + +describe("session title from first message", () => { + test("trims and collapses repeated whitespace", () => { + expect(sessionTitleFromFirstMessage(" fix the login bug ")).toBe("fix the login bug"); + }); + + test("collapses newlines into a single space", () => { + expect(sessionTitleFromFirstMessage("line one\nline two\n\nline three")).toBe( + "line one line two line three" + ); + }); + + test("strips terminal control sequences before titling", () => { + expect(sessionTitleFromFirstMessage("fix \x1b]0;owned\x07 the bug")).toBe("fix the bug"); + }); + + test("truncates long messages with an ellipsis", () => { + const message = "x".repeat(MAX_SESSION_TITLE_CHARS + 20); + const title = sessionTitleFromFirstMessage(message); + expect(title).toBe(`${"x".repeat(MAX_SESSION_TITLE_CHARS)}…`); + expect(Array.from(title!)).toHaveLength(MAX_SESSION_TITLE_CHARS + 1); + }); + + test("truncates by code point without splitting surrogate pairs", () => { + const emoji = "😀".repeat(MAX_SESSION_TITLE_CHARS + 5); + expect(sessionTitleFromFirstMessage(emoji)).toBe(`${"😀".repeat(MAX_SESSION_TITLE_CHARS)}…`); + }); + + test("keeps CJK text intact when it fits", () => { + expect(sessionTitleFromFirstMessage("修复登录页面的空指针")).toBe("修复登录页面的空指针"); + }); + + test("returns null for empty or whitespace-only messages", () => { + expect(sessionTitleFromFirstMessage("")).toBeNull(); + expect(sessionTitleFromFirstMessage(" \n\t ")).toBeNull(); + }); +}); + +describe("terminal title emission", () => { + interface FakeStream { + isTTY: boolean; + output: string; + write: (chunk: string) => void; + } + + function fakeStream(isTTY: boolean): FakeStream { + const stream: FakeStream = { + isTTY, + output: "", + write(chunk: string) { + stream.output += chunk; + } + }; + return stream; + } + + test("writes the prefixed OSC 0 sequence to a TTY stream", () => { + const stream = fakeStream(true); + emitSessionTerminalTitle(stream, "fix the login bug"); + expect(stream.output).toBe(`\x1b]0;${SESSION_TITLE_PREFIX}fix the login bug\x07`); + }); + + test("clears the terminal title when given an empty title", () => { + const stream = fakeStream(true); + emitSessionTerminalTitle(stream, ""); + expect(stream.output).toBe("\x1b]0;\x07"); + }); + + test("does not write to a non-TTY stream", () => { + const stream = fakeStream(false); + emitSessionTerminalTitle(stream, "fix the login bug"); + expect(stream.output).toBe(""); + }); + + test("does nothing when no stream is available", () => { + expect(() => emitSessionTerminalTitle(undefined, "fix the login bug")).not.toThrow(); + }); +});