Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions packages/zcode-tui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -1254,6 +1268,7 @@ class ZCodeTui {
): QueuedSubmission | undefined {
this.activeSubmissions = Math.max(0, this.activeSubmissions - 1);
if (this.activeTurnEpoch !== turnEpoch) return undefined;
this.refreshSessionTerminalTitle();

Copy link
Copy Markdown
Owner

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 decrements activeSubmissions and then calls finishTurn(), but finishTurn() clears activity directly without refreshing the terminal title.

As a result, running /login in an already titled session leaves the terminal title stuck at ZC | ⠋ signing in….

Please move the refresh into the common finishTurn() path after clearing activity, or explicitly refresh it from runSuspendedLogin(). Centralizing this in finishTurn() would avoid future lifecycle paths missing the same cleanup.

Please also add a regression test covering: session title → /login working title → restored idle session title.


const turnFinishState = abortController.signal.aborted ? "cancelled" : unfinishedToolState;
const recoveryReason = turnFinishState === "cancelled"
Expand Down Expand Up @@ -1417,6 +1432,9 @@ class ZCodeTui {
this.clearTranscriptProjection();
this.workflowView = undefined;
this.sessionId = undefined;
this.sessionTitleEmitted = false;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Initialize the title from restored session messages

resetSessionProjection is also returned by /resume together with restoredMessages. This code clears the terminal-title state and restores the transcript, but it never derives the title from the first restored user message.

Consequently:

  1. A resumed session initially has no content-derived terminal title.
  2. The next prompt is incorrectly treated as the first message of the session and becomes the title.

The startup restoreInitialTranscript() path has the same issue.

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 /resume restoration. For /new, where the restored transcript is empty, the title should remain cleared.

Please add regression coverage for both restored startup sessions and in-app /resume.

this.sessionTerminalTitle = undefined;
emitSessionTerminalTitle(this.options.stdout ?? process.stdout, "");
this.sessionMetrics = {};
this.restoreTranscript(restoredMessages(result.restoredMessages));
}
Expand Down Expand Up @@ -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);
Expand Down
28 changes: 28 additions & 0 deletions packages/zcode-tui/src/session-title.ts
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("")}…`;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The 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 MAX_SESSION_TITLE_CHARS excludes the ellipsis and update the PR description accordingly.

}

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");
}
86 changes: 86 additions & 0 deletions test/session-title.test.ts
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();
});
});