-
Notifications
You must be signed in to change notification settings - Fork 3
feat(tui): show session title in terminal tab #81
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] Initialize the title from restored session messages
Consequently:
The startup Please introduce a shared helper that derives the title from the first user message in a normalized restored transcript, and use it for both startup restoration and Please add regression coverage for both restored startup sessions and in-app |
||
| 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); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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("")}…`; | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [non-blocking] Keep the final title within the declared 50-code-point limit When truncation occurs, this keeps 50 code points and then appends an ellipsis, producing a final title of 51 code points. The test currently codifies that behavior, but it differs from the PR description of being capped at 50 code points. Please either keep the ellipsis within the 50-code-point limit, or clarify that |
||
| } | ||
|
|
||
| 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"); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P2] Refresh the terminal title on every turn-finalization path
This refresh only covers
finishPrimaryTurnSubmission(). The suspended login path decrementsactiveSubmissionsand then callsfinishTurn(), butfinishTurn()clearsactivitydirectly without refreshing the terminal title.As a result, running
/loginin an already titled session leaves the terminal title stuck atZC | ⠋ signing in….Please move the refresh into the common
finishTurn()path after clearingactivity, or explicitly refresh it fromrunSuspendedLogin(). Centralizing this infinishTurn()would avoid future lifecycle paths missing the same cleanup.Please also add a regression test covering: session title →
/loginworking title → restored idle session title.