fix(tui): set mini terminal title on Linux (#38261)

This commit is contained in:
Simon Klee 2026-07-22 10:35:07 +02:00 committed by GitHub
commit 0e08b7330f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 52 additions and 0 deletions

View file

@ -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<void>
close(input: { showExit: boolean; sessionTitle?: string; sessionID?: string; history?: RunPrompt[] }): Promise<void>
}
@ -184,6 +185,12 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
consoleMode: "disabled",
clearOnShutdown: false,
})
const setTitle = (title?: string) => {
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<Lif
footer.close()
await footer.idle().catch(() => {})
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<Lif
refreshTheme() {
footer.refreshTheme()
},
setTitle,
onResize(fn) {
let width = renderer.terminalWidth
let height = renderer.terminalHeight

View file

@ -443,6 +443,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
if (footer.isClosed || runtimeController.signal.aborted) return
state.sessionID = next.sessionID
state.sessionTitle = next.sessionTitle ?? state.sessionTitle
shell.setTitle(state.sessionTitle)
state.agent = next.agent
state.location = next.location
state.model = next.model
@ -741,6 +742,10 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
replayLimit: input.replayLimit,
footer,
onCommit: rememberLocal,
onSessionTitle: (title) => {
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

View file

@ -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<unknown>
@ -828,6 +829,10 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
}
input.trace?.write("recv.event", event)
subagents.main(client, event, attempt.signal)
if (event.type === "session.renamed") {
input.onSessionTitle?.(event.data.title)
return
}
if (event.type === "session.input.admitted") {
if (event.data.input.type !== "user") return
mergePending({

View file

@ -84,6 +84,7 @@ describe("run interactive runtime", () => {
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<void>()
const painted = defer<void>()
const events: FooterEvent[] = []
const titles: Array<string | undefined> = []
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(),
}

View file

@ -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())