From 0e08b7330f4ec82b80c00eacda3fd08478eab04b Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Wed, 22 Jul 2026 10:35:07 +0200 Subject: [PATCH] fix(tui): set mini terminal title on Linux (#38261) --- packages/tui/src/mini/runtime.lifecycle.ts | 9 +++++++ packages/tui/src/mini/runtime.ts | 6 +++++ packages/tui/src/mini/stream-v2.transport.ts | 5 ++++ packages/tui/test/mini/runtime.test.ts | 7 ++++++ .../tui/test/mini/stream-v2.transport.test.ts | 25 +++++++++++++++++++ 5 files changed, 52 insertions(+) diff --git a/packages/tui/src/mini/runtime.lifecycle.ts b/packages/tui/src/mini/runtime.lifecycle.ts index 2baacfe46f..5354946d91 100644 --- a/packages/tui/src/mini/runtime.lifecycle.ts +++ b/packages/tui/src/mini/runtime.lifecycle.ts @@ -82,6 +82,7 @@ export type Lifecycle = { footer: FooterApi onResize(fn: () => void): () => void refreshTheme(): void + setTitle(title?: string): void resetForReplay(input: { sessionTitle?: string; sessionID?: string; history: RunPrompt[] }): Promise close(input: { showExit: boolean; sessionTitle?: string; sessionID?: string; history?: RunPrompt[] }): Promise } @@ -184,6 +185,12 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise { + if (input.host.platform !== "linux") return + if (!title || isDefaultTitle(title)) return renderer.setTerminalTitle("OpenCode") + renderer.setTerminalTitle(`OC | ${title.length > 40 ? title.slice(0, 37) + "..." : title}`) + } + setTitle(input.sessionTitle) const theme = await resolveRunTheme(renderer, tuiConfig.theme, mono) renderer.setBackgroundColor(theme.background) const state: SplashState = { @@ -338,6 +345,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise {}) footer.destroy() + if (input.host.platform === "linux") renderer.setTerminalTitle("") shutdown(renderer) if (!wroteExit) { input.host.stdout.write("\n") @@ -350,6 +358,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise { + state.sessionTitle = title + shell.setTitle(title) + }, trace: log, onCatalogRefresh: requestCatalogRefresh, contextLimit: (model) => @@ -887,6 +892,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep state.shown = false state.sessionID = created.sessionID state.sessionTitle = created.sessionTitle + shell.setTitle(state.sessionTitle) state.agent = created.agent ?? state.agent state.location = created.location state.model = created.model diff --git a/packages/tui/src/mini/stream-v2.transport.ts b/packages/tui/src/mini/stream-v2.transport.ts index b90f5e682d..b8aab5ee24 100644 --- a/packages/tui/src/mini/stream-v2.transport.ts +++ b/packages/tui/src/mini/stream-v2.transport.ts @@ -46,6 +46,7 @@ type StreamInput = { replayLimit?: number footer: FooterApi onCommit?: (commit: StreamCommit) => void + onSessionTitle?: (title: string) => void trace?: Trace signal?: AbortSignal onCatalogRefresh?: (signal?: AbortSignal) => unknown | Promise @@ -828,6 +829,10 @@ export async function createSessionTransport(input: StreamInput): Promise { footer: api, onResize: () => () => {}, refreshTheme: () => {}, + setTitle: () => {}, resetForReplay: () => Promise.resolve(), close: () => Promise.resolve(), } @@ -180,6 +181,7 @@ describe("run interactive runtime", () => { footer: api, onResize: () => () => {}, refreshTheme: () => {}, + setTitle: () => {}, resetForReplay: () => Promise.resolve(), close: () => Promise.resolve(), } @@ -199,6 +201,7 @@ describe("run interactive runtime", () => { const lifecycleStarted = defer() const painted = defer() const events: FooterEvent[] = [] + const titles: Array = [] const api = footer(events) api.idle = () => painted.promise const event = api.event @@ -265,6 +268,7 @@ describe("run interactive runtime", () => { footer: api, onResize: () => () => {}, refreshTheme: () => {}, + setTitle: (title) => titles.push(title), resetForReplay: () => Promise.resolve(), close: () => Promise.resolve(), } @@ -282,6 +286,7 @@ describe("run interactive runtime", () => { history: [{ text: "previous prompt", parts: [] }], }) expect(events).toContainEqual({ type: "agent", agent: "review" }) + expect(titles).toEqual(["Resume"]) expect(events).toContainEqual({ type: "model", model: "Little Frank · OpenAI · high", @@ -344,6 +349,7 @@ describe("run interactive runtime", () => { footer: api, onResize: () => () => {}, refreshTheme: () => {}, + setTitle: () => {}, resetForReplay: () => Promise.resolve(), close: async (input) => { closedTitle = input.sessionTitle @@ -423,6 +429,7 @@ describe("run interactive runtime", () => { footer: api, onResize: () => () => {}, refreshTheme: () => {}, + setTitle: () => {}, resetForReplay: () => Promise.resolve(), close: () => Promise.resolve(), } diff --git a/packages/tui/test/mini/stream-v2.transport.test.ts b/packages/tui/test/mini/stream-v2.transport.test.ts index 371611972e..18bbc0b08c 100644 --- a/packages/tui/test/mini/stream-v2.transport.test.ts +++ b/packages/tui/test/mini/stream-v2.transport.test.ts @@ -200,6 +200,31 @@ afterEach(() => { }) describe("V2 mini transport", () => { + test("reports session title changes", async () => { + const events = feed() + events.push(connected()) + const titles: string[] = [] + const transport = await createSessionTransport({ + sdk: sdk({ streams: [events] }), + sessionID: "ses_1", + thinking: false, + footer: footer().api, + onSessionTitle: (title) => titles.push(title), + }) + + events.push({ + id: "evt_renamed", + created: 1, + type: "session.renamed", + durable: durable("ses_1", 1), + data: { sessionID: "ses_1", title: "Greeting" }, + }) + + while (titles.length === 0) await Bun.sleep(0) + expect(titles).toEqual(["Greeting"]) + await transport.close() + }) + test("formats footer usage with compact tokens and context percentage", async () => { const events = feed() events.push(connected())