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 | " after the first user message
- Show "ZC | ⠋ " 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 | ⠋ " while a turn runs,
+ // "ZC | " 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 | " 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();
+ });
+});