diff --git a/docs/tui-capabilities.md b/docs/tui-capabilities.md index 85e1b3aa..fc625115 100644 --- a/docs/tui-capabilities.md +++ b/docs/tui-capabilities.md @@ -2,6 +2,51 @@ The current capability target is **TUI 0.4.12**; see [version and evidence baseline](open-source-status.md#version-and-evidence-baseline) for the separate workspace and embedded-tool versions. “Restored” below describes implementation and assembly, not acceptance of every account or online service. +## Terminal titles and notifications + +Terminal titles show the current state, session name and MCode, for example +`Needs approval | Fix login | MCode`. Renaming or switching a session updates the +title. Unnamed sessions use the project name and a short session ID. Titles are +cleared when MCode exits or suspends and reapplied when it resumes. + +Configure these presentation settings in the MCode data directory's `config.yaml`: + +```yaml +tui: + terminalTitle: [status, session-name, app-name] + notifications: + when: unfocused + method: auto + events: [turn-complete, turn-failed, permission-required, question-required] +``` + +Title items can be ordered or omitted; `project-name` is also available. Set +`terminalTitle` to `null` or `[]` to disable title updates. Unknown items are ignored. +Notification `when` accepts `unfocused`, `always` or `never`; `method` accepts +`auto`, `osc9`, `osc777` or `bel`. Omitting `events` enables all four events; `[]` +disables them. Apply configuration changes by restarting MCode. + +Notifications identify the session and suppress duplicates. Completion waits for +the session's queue to finish; failed turns and requests for input can notify +independently. Known foreground focus suppresses notifications by default. When +focus is unknown, delivery is best-effort; cmux manages its own surface focus. +Automatic delivery uses the detected terminal's notification protocol or falls +back to a bell. The existing Windows toast bridge is restricted to local Windows +or WSL interop. Terminal settings and OS notification permissions still apply. + +VS Code normally displays a process name in its terminal tabs. To display MCode's +session titles, use this VS Code setting: + +```json +"terminal.integrated.tabs.title": "${sequence}" +``` + +A manually assigned tab title overrides automatic titles. VS Code's bell is a +terminal-tab indicator, not a guarantee of a desktop notification. See the +[VS Code terminal appearance documentation](https://code.visualstudio.com/docs/terminal/appearance#_tab-text). +Inside tmux, OSC notifications require passthrough and support from the outer +terminal; use `method: bel` for a bell fallback. + The evidence column summarizes the historical TUI 0.3.11 restoration record from 2026-09-11. It does not claim fresh TUI 0.4.12 live-service acceptance. Use [current verification status](verification.md#current-source-verification-status) for checks run against the updated source and explicit NOT RUN boundaries. | Capability | Implementation | Evidence | diff --git a/packages/config/src/tui-config.ts b/packages/config/src/tui-config.ts index 13838c51..b04ecd98 100644 --- a/packages/config/src/tui-config.ts +++ b/packages/config/src/tui-config.ts @@ -32,10 +32,14 @@ export interface TuiCustomStatusLineConfig { } export interface TuiConfig { + /** Ordered terminal title items. Null or an empty list disables title updates. */ + terminalTitle?: readonly string[] | null; /** Terminal notification policy. Unknown focus falls back to notifying. */ notifications?: { when?: 'unfocused' | 'always' | 'never'; method?: 'auto' | 'osc9' | 'osc777' | 'bel'; + /** Omit to enable all supported notification events; an empty list disables them. */ + events?: readonly string[]; }; /** Show contextual Tips in the idle composer header. Defaults to true. */ showTips?: boolean; @@ -75,12 +79,17 @@ export function parseTuiConfig(raw: Record): TuiConfig { typeof rawNotifications === 'object' && !Array.isArray(rawNotifications) ) { - const { when, method } = rawNotifications as Record; + const { when, method, events } = rawNotifications as Record; notifications = { ...(when === 'unfocused' || when === 'always' || when === 'never' ? { when } : {}), ...(method === 'auto' || method === 'osc9' || method === 'osc777' || method === 'bel' ? { method } : {}), + ...(Array.isArray(events) + ? { + events: events.filter((event): event is string => typeof event === 'string'), + } + : {}), }; } const rawStatusLine = tui.statusLine; @@ -92,6 +101,16 @@ export function parseTuiConfig(raw: Record): TuiConfig { : undefined; const customStatusLine = parseTuiCustomStatusLineConfig(tui.customStatusLine); return { + ...(tui.terminalTitle === null + ? { terminalTitle: null } + : Array.isArray(tui.terminalTitle) + ? { + terminalTitle: tui.terminalTitle + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter(Boolean), + } + : {}), ...(typeof tui.showTips === 'boolean' ? { showTips: tui.showTips } : {}), ...(notifications ? { notifications } : {}), ...(statusLine ? { statusLine } : {}), diff --git a/packages/tui/src/tui/app-composition.ts b/packages/tui/src/tui/app-composition.ts index 6273fc7d..1303a731 100644 --- a/packages/tui/src/tui/app-composition.ts +++ b/packages/tui/src/tui/app-composition.ts @@ -55,6 +55,7 @@ import { import { detectProcessTerminalCapabilities } from './platform/terminal-capabilities.js'; import { createTuiTextClipboardWriter } from './platform/terminal-clipboard.js'; import { TuiTerminalNotifications } from './platform/terminal-notifications.js'; +import { TuiTerminalTitle } from './platform/terminal-title.js'; import { ProcessTerminal, type Component, @@ -119,7 +120,7 @@ export function createTuiChatControllerComposition(options: CreateTuiAppOptions) */ export function createTuiApplicationRenderer(options: CreateTuiAppOptions) { const terminal = options.terminal ?? new ProcessTerminal(); - const capabilities = detectProcessTerminalCapabilities(); + const capabilities = options.terminalCapabilities ?? detectProcessTerminalCapabilities(); const openExternalTarget = options.openExternalTarget ?? createTuiExternalTargetOpener(options.workspaceDir); const writeClipboardText = @@ -169,8 +170,9 @@ export function createTuiApplicationRenderer(options: CreateTuiAppOptions) { return terminal.focused; }, }, - { settings: options.notifications }, + { settings: options.notifications, capabilities }, ); + const terminalTitle = new TuiTerminalTitle(terminal, capabilities.isTTY); themeController = new TuiThemeController({ ui: tui, colorLevel: capabilities.colorLevel, @@ -184,6 +186,7 @@ export function createTuiApplicationRenderer(options: CreateTuiAppOptions) { renderer, tui, terminalNotifications, + terminalTitle, themeController, openExternalTarget, writeClipboardText, @@ -524,28 +527,6 @@ export function createTuiBusinessEventTracker(options: { }); } -/** - * Sync the OS terminal title with the active session title. Skips writes - * when the title did not change and when the TUI is suspended, to avoid - * flicker and to keep the title stable across process suspension. - */ -export function createTuiTerminalTitleSync(options: { - readonly terminal: Terminal; - readonly isActive: () => boolean; -}) { - let lastTitle: string | undefined; - return (sessionTitle: string | undefined): void => { - if (!options.isActive()) return; - const title = - sessionTitle?.trim() && sessionTitle.toLocaleLowerCase() !== 'new session' - ? sessionTitle.trim() - : 'Minimax Code'; - if (title === lastTitle) return; - options.terminal.setTitle(title); - lastTitle = title; - }; -} - /** * Resolve the live "active turn id" from either the chat controller snapshot * or the runtime projection. Both must agree, but the projection is the diff --git a/packages/tui/src/tui/app.ts b/packages/tui/src/tui/app.ts index 6d556331..9f51e7e5 100644 --- a/packages/tui/src/tui/app.ts +++ b/packages/tui/src/tui/app.ts @@ -54,6 +54,7 @@ import { parseTuiStatusLineItems as parseStatusItems } from './shell/status-line import { showTuiStatusLineSetup } from './controller/product/status-line-setup.js'; import { showTuiThemeSetup } from './controller/product/theme-setup.js'; import { TuiCodexHandoffFlow } from './controller/product/codex-handoff-flow.js'; +import { tuiTerminalSessionLabel } from './platform/terminal-title.js'; export type { CreateTuiAppOptions, TuiApp, TuiStopOptions }; export function createTuiApp(options: CreateTuiAppOptions): TuiApp { @@ -64,6 +65,7 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { renderer, tui, terminalNotifications, + terminalTitle, themeController, openExternalTarget, writeClipboardText, @@ -260,7 +262,14 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { }, isStopped: () => stopped, notify: (kind, key) => { - terminalNotifications.notifyOnce(kind, key); + terminalNotifications.notifyOnce( + kind, + key, + tuiTerminalSessionLabel({ + ...controller.snapshot().session, + workspace: options.workspaceDir, + }), + ); }, ...delegationFlow.permissionResolvers(liveRunId), agentStatusLineItems: options.statusLineItems, @@ -709,7 +718,14 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { observability: options.observability, incidentReporter: options.incidentReporter, notify: (kind, key) => { - terminalNotifications.notifyOnce(kind, key); + terminalNotifications.notifyOnce( + kind, + key, + tuiTerminalSessionLabel({ + ...controller.snapshot().session, + workspace: options.workspaceDir, + }), + ); }, }); codexHandoffFlow = new TuiCodexHandoffFlow({ @@ -794,7 +810,8 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { queueEnabled: productFeatures.queue, isStarted: () => started, isStopped: () => stopped, - setTerminalTitle: (title) => terminal.setTitle(title), + setTerminalTitle: (title) => terminalTitle.update(title), + terminalTitle: options.terminalTitle, connection: () => stateStore.snapshot().connection, liveRunId, runProjection: () => runProjection.snapshot(), @@ -843,6 +860,8 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { if (stopped) return stoppedPromise; stateStore.dispatch({ type: 'lifecycle/leaveUi' }); stopped = true; + terminalNotifications.dispose(); + terminalTitle.dispose(); const bashStopped = bashFlow.stop(); detachInputFlow(); composerDraft.abortClipboardRead(); @@ -890,6 +909,8 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { async function suspend(): Promise { if (!started || stopped || suspended) return; suspended = true; + terminalNotifications.setActive(false); + terminalTitle.setActive(false); renderer.stop(); await draftLifecycle?.suspend(); } @@ -897,6 +918,9 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { if (!started || stopped || !suspended) return; suspended = false; renderer.start(); + terminalNotifications.setActive(true); + terminalTitle.setActive(true); + updateChrome(controller.snapshot()); runtimeEventFlow.restart(); tui.requestRender(true); draftLifecycle?.resume(); @@ -916,6 +940,8 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { start() { if (started || stopped) return; started = true; + terminalNotifications.setActive(true); + terminalTitle.setActive(true); updateChrome(controller.snapshot()); surfaceHost.setChatFocus(editor); runtimeEventFlow.start(); diff --git a/packages/tui/src/tui/controller/product/chrome-flow.ts b/packages/tui/src/tui/controller/product/chrome-flow.ts index d3efc4b0..6bd44222 100644 --- a/packages/tui/src/tui/controller/product/chrome-flow.ts +++ b/packages/tui/src/tui/controller/product/chrome-flow.ts @@ -27,6 +27,7 @@ import { type TuiCommand, } from '../../commands/catalog.js'; import { resolveTuiComposerInputIntent } from '../../commands/input-intent.js'; +import { formatTuiTerminalTitle } from '../../platform/terminal-title.js'; type PresentationSink = { setState(state: TuiVisiblePresentation[K]): void; @@ -39,7 +40,6 @@ export class TuiChromeFlow { private startupHint?: string; private compacting = false; private llmRetry?: TuiLlmRetryEvent; - private lastTerminalTitle?: string; private readonly automationStatus = new TuiAutomationStatusStore(); constructor( @@ -51,7 +51,8 @@ export class TuiChromeFlow { readonly keybindings?: TuiKeybindingRegistry; readonly isStarted: () => boolean; readonly isStopped: () => boolean; - readonly setTerminalTitle: (title: string) => void; + readonly setTerminalTitle: (title: string | undefined) => void; + readonly terminalTitle?: readonly string[] | null; readonly connection: () => Pick; /** Multi-Session state kernel, including the background parent Turn. */ readonly liveRunId: (snapshot: TuiChatSnapshot) => string | undefined; @@ -140,7 +141,6 @@ export class TuiChromeFlow { update(snapshot: TuiChatSnapshot): void { if (this.options.isStopped()) return; - this.syncTerminalTitle(snapshot); const sessionId = snapshot.session?.sessionId; if (this.llmRetry && this.llmRetry.sessionId !== sessionId) this.llmRetry = undefined; if ( @@ -212,6 +212,19 @@ export class TuiChromeFlow { retrying: this.isLlmRetrying(), ...(agentCounts ? { agentCounts } : {}), }); + if (this.options.isStarted()) { + this.options.setTerminalTitle( + formatTuiTerminalTitle( + { + title: snapshot.session?.title, + sessionId: snapshot.session?.sessionId, + workspace: this.options.workspace, + status: automationStatus.status, + }, + this.options.terminalTitle, + ), + ); + } const shell = { ...presentation.shell, agentSeq: automationStatus.seq, @@ -241,16 +254,4 @@ export class TuiChromeFlow { this.startupHint ? { ...composer, hint: this.startupHint, headerHidden: false } : composer, ); } - - private syncTerminalTitle(snapshot: TuiChatSnapshot): void { - if (!this.options.isStarted() || this.options.isStopped()) return; - const sessionTitle = snapshot.session?.title?.trim(); - const nextTitle = - sessionTitle && sessionTitle.toLocaleLowerCase() !== 'new session' - ? sessionTitle - : 'Minimax Code'; - if (nextTitle === this.lastTerminalTitle) return; - this.options.setTerminalTitle(nextTitle); - this.lastTerminalTitle = nextTitle; - } } diff --git a/packages/tui/src/tui/controller/runtime/runtime-event-flow.ts b/packages/tui/src/tui/controller/runtime/runtime-event-flow.ts index 5ce68b04..72baa15e 100644 --- a/packages/tui/src/tui/controller/runtime/runtime-event-flow.ts +++ b/packages/tui/src/tui/controller/runtime/runtime-event-flow.ts @@ -906,7 +906,19 @@ export class TuiRuntimeEventFlow { event: TuiSessionLifecycleEvent, sessionId: string, ): Promise { - if (!this.options.notify || !event.turnId || event.type === 'session.abort') return; + if ( + !this.options.notify || + !event.turnId || + this.options.isStopped() || + this.options.controller.snapshot().session?.sessionId !== sessionId || + (event.type !== 'session.finish' && event.type !== 'session.error') + ) + return; + // A failed Turn still needs attention when another message remains queued. + if (event.type === 'session.error') { + this.options.notify('turn-failed', `turn-failed:${sessionId}:${event.turnId}`); + return; + } const [activeRun, queue] = await Promise.allSettled([ this.options.runtime.getActiveRun(sessionId), this.options.queueEnabled @@ -916,6 +928,7 @@ export class TuiRuntimeEventFlow { if ( activeRun.status === 'rejected' || queue.status === 'rejected' || + this.options.isStopped() || this.options.controller.snapshot().session?.sessionId !== sessionId ) { return; @@ -929,8 +942,7 @@ export class TuiRuntimeEventFlow { (item) => item.status === 'queued' || item.status === 'running', ).length; if (!shouldNotifyMcodeTurnComplete({ queuedCount, hasActiveRun })) return; - const kind = event.type === 'session.finish' ? 'turn-complete' : 'turn-failed'; - this.options.notify(kind, `${kind}:${sessionId}:${event.turnId}`); + this.options.notify('turn-complete', `turn-complete:${sessionId}:${event.turnId}`); } private async reconcileCurrentSessionFromRuntime(): Promise { diff --git a/packages/tui/src/tui/launcher.ts b/packages/tui/src/tui/launcher.ts index 19dd6e0b..4d6bb905 100644 --- a/packages/tui/src/tui/launcher.ts +++ b/packages/tui/src/tui/launcher.ts @@ -387,6 +387,10 @@ export async function launchTui( dataDir, version: options.version, workspaceDir, + terminalCapabilities, + ...(presentationConfig.terminalTitle !== undefined + ? { terminalTitle: presentationConfig.terminalTitle } + : {}), ...(presentationConfig.statusLineItems ? { statusLineItems: presentationConfig.statusLineItems } : {}), diff --git a/packages/tui/src/tui/platform/observed-terminal.ts b/packages/tui/src/tui/platform/observed-terminal.ts index 63b51e88..d5ecf0fa 100644 --- a/packages/tui/src/tui/platform/observed-terminal.ts +++ b/packages/tui/src/tui/platform/observed-terminal.ts @@ -33,6 +33,9 @@ export function createObservedTerminal( get kittyProtocolActive() { return terminal.kittyProtocolActive; }, + get focused() { + return terminal.focused; + }, start: (onInput, onResize) => observeSync('terminal.start.sync', () => terminal.start(onInput, onResize)), stop: () => observeSync('terminal.stop.sync', () => terminal.stop()), diff --git a/packages/tui/src/tui/platform/terminal-capabilities.ts b/packages/tui/src/tui/platform/terminal-capabilities.ts index f08cfe10..15f5977a 100644 --- a/packages/tui/src/tui/platform/terminal-capabilities.ts +++ b/packages/tui/src/tui/platform/terminal-capabilities.ts @@ -1,9 +1,11 @@ export type TerminalId = | 'apple-terminal' + | 'cmux' | 'ghostty' | 'iterm2' | 'kitty' | 'vscode' + | 'warp' | 'wezterm' | 'windows-terminal' | 'unknown'; @@ -83,6 +85,7 @@ function detectColorLevel( } function detectTerminalId(env: Readonly>): TerminalId { + if (env.CMUX_SOCKET_PATH || env.TERM_PROGRAM?.toLowerCase() === 'cmux') return 'cmux'; if (env.WT_SESSION && !isSshSession(env)) return 'windows-terminal'; const program = env.TERM_PROGRAM?.toLocaleLowerCase(); if (program === 'apple_terminal') return 'apple-terminal'; @@ -90,6 +93,7 @@ function detectTerminalId(env: Readonly>): Te if (program === 'iterm.app') return 'iterm2'; if (program === 'vscode') return 'vscode'; if (program === 'wezterm') return 'wezterm'; + if (program === 'warpterminal') return 'warp'; const term = env.TERM?.toLocaleLowerCase() ?? ''; if (term.includes('ghostty')) return 'ghostty'; diff --git a/packages/tui/src/tui/platform/terminal-notifications.ts b/packages/tui/src/tui/platform/terminal-notifications.ts index 77fb240d..02df5a92 100644 --- a/packages/tui/src/tui/platform/terminal-notifications.ts +++ b/packages/tui/src/tui/platform/terminal-notifications.ts @@ -1,6 +1,8 @@ import { execFile } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; import type { Terminal } from '../engine/public.js'; -import { detectTerminalMultiplexer } from './terminal-capabilities.js'; +import { detectTerminalCapabilities, type TerminalCapabilities } from './terminal-capabilities.js'; +import { sanitizeTerminalLabel } from '../rendering/terminal-text.js'; const ESC = '\u001B'; const BEL = '\u0007'; @@ -10,6 +12,7 @@ const MAX_DEDUPE_KEYS = 256; export interface TuiNotificationSettings { readonly when?: 'unfocused' | 'always' | 'never'; readonly method?: 'auto' | 'osc9' | 'osc777' | 'bel'; + readonly events?: readonly string[]; } export type TuiTerminalNotificationKind = @@ -38,6 +41,10 @@ export class TuiTerminalNotifications { private readonly dedupeOrder: string[] = []; private readonly environment: Readonly>; private readonly executeFile: ExecuteNotificationFile; + private readonly capabilities: TerminalCapabilities; + private active = false; + private disposed = false; + private generation = 0; constructor( private readonly terminal: Pick, @@ -45,45 +52,102 @@ export class TuiTerminalNotifications { readonly environment?: Readonly>; readonly executeFile?: ExecuteNotificationFile; readonly settings?: TuiNotificationSettings; + readonly capabilities?: TerminalCapabilities; } = {}, ) { this.environment = options.environment ?? process.env; this.settings = options.settings ?? {}; + this.capabilities = + options.capabilities ?? + detectTerminalCapabilities({ + platform: process.platform, + isTTY: Boolean(process.stdout.isTTY), + env: this.environment, + }); this.executeFile = - options.executeFile ?? ((file, args, callback) => execFile(file, [...args], callback)); + options.executeFile ?? + ((file, args, callback) => + execFile(file, [...args], { timeout: 3000, windowsHide: true, maxBuffer: 4096 }, callback)); } private readonly settings: TuiNotificationSettings; - notifyOnce(kind: TuiTerminalNotificationKind, key: string): boolean { + private get method(): TuiNotificationBackend { + return resolveNotificationBackend( + this.settings.method ?? 'auto', + this.capabilities, + this.environment, + ); + } + + setActive(active: boolean): void { + if (this.disposed || this.active === active) return; + this.active = active; + this.generation += 1; + } + + dispose(): void { + this.setActive(false); + this.disposed = true; + } + + /** True means output was attempted, not that the OS displayed a notification. */ + notifyOnce(kind: TuiTerminalNotificationKind, key: string, sessionLabel?: string): boolean { if (this.dedupe.has(key)) return false; this.remember(key); + if (!this.active || this.disposed || !this.capabilities.isTTY) return false; if (this.settings.when === 'never') return false; + if (this.settings.events && !this.settings.events.includes(kind)) return false; // cmux owns notification suppression; its CSI focus is not a reliable surface-focus signal. - const hostOwnsFocus = - this.environment.TERM_PROGRAM === 'cmux' || Boolean(this.environment.CMUX_SOCKET_PATH); + const hostOwnsFocus = this.capabilities.terminalId === 'cmux'; if (this.settings.when !== 'always' && !hostOwnsFocus && this.terminal.focused === true) return false; - const notification = NOTIFICATIONS[kind]; + const label = sanitizeTerminalLabel(sessionLabel ?? '', 120); + const notification = { + ...NOTIFICATIONS[kind], + body: label ? `${label}: ${NOTIFICATIONS[kind].body}` : NOTIFICATIONS[kind].body, + }; const method = this.settings.method ?? 'auto'; - if (method === 'auto' && this.environment.WT_SESSION) { - this.executeFile( - 'powershell.exe', - ['-NoProfile', '-Command', windowsToastScript(notification.title, notification.body)], - (error) => { - if (error) this.terminal.write(BEL); - }, - ); + try { + if (this.method === 'windows-toast') { + const generation = this.generation; + this.executeFile( + 'powershell.exe', + ['-NoProfile', '-Command', windowsToastScript(notification.title, notification.body)], + (error) => { + if ( + error && + this.active && + !this.disposed && + generation === this.generation && + (this.settings.when === 'always' || this.terminal.focused !== true) + ) + this.writeBell(); + }, + ); + return true; + } + for (const sequence of buildTuiTerminalNotificationSequences( + notification, + this.environment, + method, + this.capabilities, + )) { + this.terminal.write(sequence); + } return true; + } catch { + // Notification failures must not interrupt a Turn or permission request. + return false; } - for (const sequence of buildTuiTerminalNotificationSequences( - notification, - this.environment, - method, - )) { - this.terminal.write(sequence); + } + + private writeBell(): void { + try { + this.terminal.write(BEL); + } catch { + // The terminal may have closed while the native notification was pending. } - return true; } private remember(key: string): void { @@ -106,33 +170,60 @@ export function buildTuiTerminalNotificationSequences( notification: { readonly title: string; readonly body: string }, environment: Readonly> = process.env, method: NonNullable = 'auto', + capabilities = detectTerminalCapabilities({ + platform: process.platform, + isTTY: true, + env: environment, + }), ): readonly string[] { - if (method === 'auto' && environment.WT_SESSION) return []; - const cmux = environment.TERM_PROGRAM === 'cmux' || Boolean(environment.CMUX_SOCKET_PATH); - const useOsc9 = - method === 'osc9' || - (method === 'auto' && - !cmux && - ['ghostty', 'iTerm.app', 'WezTerm', 'WarpTerminal'].includes(environment.TERM_PROGRAM ?? '')); - const useOsc777 = method === 'osc777' || (method === 'auto' && cmux); - if ( - method === 'bel' || - (method === 'auto' && !useOsc9 && !useOsc777 && !environment.KITTY_WINDOW_ID) - ) - return [BEL]; + if (!capabilities.isTTY) return []; + const backend = resolveNotificationBackend(method, capabilities, environment); + if (backend === 'windows-toast') return []; + if (backend === 'bel') return [BEL]; + const title = sanitizeTerminalLabel(notification.title, 120); + const body = sanitizeTerminalLabel(notification.body, 400); + const id = randomUUID(); const sequences = - method === 'auto' && !cmux && environment.KITTY_WINDOW_ID - ? [ - `${ESC}]99;i=1:d=0;${notification.title}${ST}`, - `${ESC}]99;i=1:p=body;${notification.body}${ST}`, - ] - : useOsc9 - ? [`${ESC}]9;${notification.title}: ${notification.body}${BEL}`] - : [`${ESC}]777;notify;${notification.title};${notification.body}${BEL}`]; - if (detectTerminalMultiplexer(environment) !== 'tmux') return sequences; + backend === 'osc99' + ? [`${ESC}]99;i=${id}:d=0;${title}${ST}`, `${ESC}]99;i=${id}:p=body:d=1;${body}${ST}`] + : backend === 'osc9' + ? [`${ESC}]9;${title}: ${body}${BEL}`] + : [`${ESC}]777;notify;${title.replaceAll(';', ',')};${body.replaceAll(';', ',')}${BEL}`]; + if (capabilities.multiplexer !== 'tmux') return sequences; return sequences.map(wrapForTmuxPassthrough); } +type TuiNotificationBackend = 'osc9' | 'osc777' | 'osc99' | 'bel' | 'windows-toast'; + +function resolveNotificationBackend( + method: NonNullable, + capabilities: TerminalCapabilities, + environment: Readonly>, +): TuiNotificationBackend { + if (method !== 'auto') return method; + switch (capabilities.terminalId) { + case 'cmux': + return 'osc777'; + case 'kitty': + return 'osc99'; + case 'ghostty': + case 'iterm2': + case 'wezterm': + case 'warp': + return 'osc9'; + case 'windows-terminal': + return capabilities.transport === 'local' && + (capabilities.platform === 'win32' || + (capabilities.platform === 'linux' && + environment.WSL_DISTRO_NAME && + environment.WSL_INTEROP)) + ? 'windows-toast' + : 'bel'; + default: + return 'bel'; + } +} + function wrapForTmuxPassthrough(sequence: string): string { return `${ESC}Ptmux;${sequence.replaceAll(ESC, `${ESC}${ESC}`)}${ST}`; } @@ -145,7 +236,7 @@ function windowsToastScript(title: string, body: string): string { return [ `${manager} > $null`, `$xml = [${type}.ToastNotificationManager]::GetTemplateContent(${template})`, - `$xml.GetElementsByTagName('text')[0].AppendChild($xml.CreateTextNode('${body}')) > $null`, - `[${type}.ToastNotificationManager]::CreateToastNotifier('${title}').Show(${toast})`, + `$xml.GetElementsByTagName('text')[0].AppendChild($xml.CreateTextNode('${body.replaceAll("'", "''")}')) > $null`, + `[${type}.ToastNotificationManager]::CreateToastNotifier('${title.replaceAll("'", "''")}').Show(${toast})`, ].join('; '); } diff --git a/packages/tui/src/tui/platform/terminal-title.ts b/packages/tui/src/tui/platform/terminal-title.ts new file mode 100644 index 00000000..874c2c78 --- /dev/null +++ b/packages/tui/src/tui/platform/terminal-title.ts @@ -0,0 +1,99 @@ +import path from 'node:path'; +import type { Terminal } from '../engine/public.js'; +import type { TuiAgentStatus } from '../shell/status-protocol.js'; +import { sanitizeTerminalLabel } from '../rendering/terminal-text.js'; + +const DEFAULT_ITEMS = ['status', 'session-name', 'app-name'] as const; +const STATUS_LABELS: Record = { + ready: 'Ready', + run: 'Working', + perm: 'Needs approval', + plan: 'Needs approval', + ask: 'Needs input', + done: 'Done', + fail: 'Failed', + cancel: 'Stopped', + error: 'Error', +}; + +export function tuiTerminalSessionLabel(input: { + readonly title?: string; + readonly sessionId?: string; + readonly workspace: string; +}): string { + const title = sanitizeTerminalLabel(input.title ?? ''); + if (title && title.toLowerCase() !== 'new session') return title; + const project = sanitizeTerminalLabel(path.basename(input.workspace)) || 'Session'; + return input.sessionId ? `${project} (${sanitizeTerminalLabel(input.sessionId, 8)})` : project; +} + +export function formatTuiTerminalTitle( + input: { + readonly title?: string; + readonly sessionId?: string; + readonly workspace: string; + readonly status: TuiAgentStatus; + }, + items: readonly string[] | null = DEFAULT_ITEMS, +): string | undefined { + const values: Readonly> = { + 'app-name': 'MCode', + 'session-name': tuiTerminalSessionLabel(input), + 'project-name': sanitizeTerminalLabel(path.basename(input.workspace)), + status: STATUS_LABELS[input.status], + }; + const title = [...new Set(items ?? [])] + .map((item) => (Object.hasOwn(values, item) ? values[item] : undefined)) + .filter(Boolean) + .join(' | '); + return sanitizeTerminalLabel(title) || undefined; +} + +/** Owns only the title written by this TUI; the previous shell title is unknown. */ +export class TuiTerminalTitle { + private active = false; + private disposed = false; + private lastTitle?: string; + + constructor( + private readonly terminal: Pick, + private readonly isTTY: boolean, + ) {} + + setActive(active: boolean): void { + if (this.disposed || this.active === active) return; + if (!active) this.clear(); + this.active = active; + } + + update(title: string | undefined): void { + if (!this.active || this.disposed || !this.isTTY) return; + const nextTitle = title === undefined ? undefined : sanitizeTerminalLabel(title) || undefined; + if (nextTitle === undefined) { + this.clear(); + return; + } + if (nextTitle === this.lastTitle) return; + try { + this.terminal.setTitle(nextTitle); + this.lastTitle = nextTitle; + } catch { + // Optional title output must not interrupt the conversation. + } + } + + dispose(): void { + this.setActive(false); + this.disposed = true; + } + + private clear(): void { + if (this.lastTitle === undefined) return; + try { + this.terminal.setTitle(''); + } catch { + // Release ownership even if the terminal has already closed. + } + this.lastTitle = undefined; + } +} diff --git a/packages/tui/src/tui/rendering/terminal-text.ts b/packages/tui/src/tui/rendering/terminal-text.ts index a76fe286..93539743 100644 --- a/packages/tui/src/tui/rendering/terminal-text.ts +++ b/packages/tui/src/tui/rendering/terminal-text.ts @@ -1,5 +1,17 @@ import { stripVTControlCharacters } from 'node:util'; +/** A bounded, single-line label suitable for an OSC payload. */ +export function sanitizeTerminalLabel(value: string, maxLength = 240): string { + return Array.from( + sanitizeTerminalText(value) + .replace(/[\u00ad\u061c\u200b\u200e\u200f\u202a-\u202e\u2066-\u2069\ufeff]/gu, '') + .replace(/\s+/gu, ' ') + .trim(), + ) + .slice(0, maxLength) + .join(''); +} + export function sanitizeTerminalText(value: string): string { // Consume terminal strings, including incomplete streaming prefixes and C1 // forms, before stripping ordinary ANSI styles. Their payload is not prose. diff --git a/packages/tui/src/tui/shell/status-line-config.ts b/packages/tui/src/tui/shell/status-line-config.ts index 2307ac76..6103333f 100644 --- a/packages/tui/src/tui/shell/status-line-config.ts +++ b/packages/tui/src/tui/shell/status-line-config.ts @@ -4,6 +4,7 @@ import type { TuiNotificationSettings } from '../platform/terminal-notifications import type { TuiCustomStatusLineConfig } from '../../host/custom-status-command.js'; export interface TuiPresentationConfig { + readonly terminalTitle?: readonly string[] | null; readonly showTips?: boolean; readonly statusLineItems?: readonly string[]; readonly notifications?: TuiNotificationSettings; @@ -27,6 +28,7 @@ export async function readTuiPresentationConfig(dataDir: string): Promise void; /** Saved theme selection, e.g. `aurora` or `aurora/dark`. */ @@ -60,6 +62,8 @@ export interface CreateTuiAppOptions extends TuiUpdateOptions { customStatusLine?: TuiCustomStatusLineConfig; /** Whether the idle conversation composer may show contextual Tips. Defaults to true. */ showTips?: boolean; + /** Ordered terminal title items from tui.terminalTitle; null or [] disables updates. */ + terminalTitle?: readonly string[] | null; notifications?: TuiNotificationSettings; /** Internal result-channel path; ignored unless statusLineItems enables build-mode. */ automationResultPath?: string; diff --git a/packages/tui/test/unit/host-tui-settings.test.ts b/packages/tui/test/unit/host-tui-settings.test.ts index 3e995aee..9c80f66e 100644 --- a/packages/tui/test/unit/host-tui-settings.test.ts +++ b/packages/tui/test/unit/host-tui-settings.test.ts @@ -8,6 +8,7 @@ import { writeTuiModeSetting, writeTuiThemeSetting, } from '../../src/host/tui-settings.js'; +import { readTuiPresentationConfig } from '../../src/tui/shell/status-line-config.js'; const directories: string[] = []; @@ -21,6 +22,29 @@ async function temporaryDataDir(): Promise { return directory; } +describe('terminal presentation configuration', () => { + it.each(['null', '[]', '[session-name, status]'])( + 'preserves terminalTitle %s through the config loader', + async (title) => { + const dataDir = await temporaryDataDir(); + await writeFile( + join(dataDir, 'config.yaml'), + `tui:\n terminalTitle: ${title}\n notifications:\n when: unfocused\n method: bel\n events: [permission-required, turn-failed]\n`, + 'utf8', + ); + const settings = await readTuiPresentationConfig(dataDir); + expect(settings.terminalTitle).toEqual( + title === 'null' ? null : title === '[]' ? [] : ['session-name', 'status'], + ); + expect(settings.notifications).toEqual({ + when: 'unfocused', + method: 'bel', + events: ['permission-required', 'turn-failed'], + }); + }, + ); +}); + describe('TUI mode settings', () => { it('uses regular mode when no valid explicit setting exists', async () => { const dataDir = await temporaryDataDir(); diff --git a/packages/tui/test/unit/tui-app.test.ts b/packages/tui/test/unit/tui-app.test.ts index 30d84699..04710bcc 100644 --- a/packages/tui/test/unit/tui-app.test.ts +++ b/packages/tui/test/unit/tui-app.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it, vi } from "vitest"; import { mkdtemp, readFile, rm, writeFile, mkdir } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import type { TerminalCapabilities } from "../../src/tui/platform/terminal-capabilities.js"; +import { + detectTerminalCapabilities, + type TerminalCapabilities, +} from "../../src/tui/platform/terminal-capabilities.js"; import { TuiAltScreen, VStack, @@ -49,6 +52,12 @@ import { TuiFailure } from "../../src/failure.js"; const runtimeEvent = (event: RawTuiRuntimeEvent): TuiRuntimeEvent => normalizeTuiRuntimeEvent(event); +const TERMINAL_CAPABILITIES = detectTerminalCapabilities({ + platform: "linux", + isTTY: true, + env: { TERM_PROGRAM: "vscode" }, +}); + function planReviewEventRequest(id: string) { return { schemaVersion: 2, @@ -2734,6 +2743,7 @@ describe("createTuiApp", () => { const app = createTuiApp({ runtime, terminal, + terminalCapabilities: TERMINAL_CAPABILITIES, version: "0.1.0", workspaceDir: "/workspace", homeDir: "/home/dev", @@ -2750,7 +2760,7 @@ describe("createTuiApp", () => { expect(conversation).toContain("Say hello"); expect(conversation).toContain("Hello from the Agent"); expect(terminal.started).toBe(true); - expect(terminal.title).toBe("Minimax Code"); + expect(terminal.title).toBe("Done | workspace (session-) | MCode"); expect(runtime.createSession).toHaveBeenCalledWith({ workspaceDir: "/workspace", }); @@ -2799,6 +2809,8 @@ describe("createTuiApp", () => { const app = createTuiApp({ runtime, terminal, + terminalCapabilities: TERMINAL_CAPABILITIES, + terminalTitle: ["session-name"], version: "0.1.0", workspaceDir: "/workspace", }); @@ -2822,10 +2834,79 @@ describe("createTuiApp", () => { ); await vi.waitFor(() => expect(terminal.title).toBe(sessionTitle)); + const titleWrites = terminal.titleUpdates.length; await app.submit("/status"); - expect(terminal.titleUpdates).toEqual(["Minimax Code", sessionTitle]); + expect(terminal.titleUpdates).toHaveLength(titleWrites); + await app.submit("/rename Renamed session"); + expect(terminal.title).toBe("Renamed session"); + + await app.suspend(); + expect(terminal.title).toBe(""); + await app.controller.renameCurrentSession("Renamed while suspended"); + expect(terminal.title).toBe(""); + await app.resume(); + expect(terminal.title).toBe("Renamed while suspended"); await app.stop(); + expect(terminal.title).toBe(""); + }); + + it.each([ + ["session.finish", false, 1], + ["session.finish", true, 0], + ["session.error", true, 1], + ["session.abort", false, 0], + ] as const)("notifies once for %s with queued=%s", async (type, queued, count) => { + const terminal = new FakeTerminal(); + const runtime = createRuntime(); + let handled = false; + vi.mocked(runtime.listQueuedMessages).mockResolvedValue( + queued + ? [{ + itemId: "queued-1", + sessionId: "session-1", + status: "queued", + content: "Next message", + }] + : [], + ); + vi.mocked(runtime.watchEvents).mockImplementation(async function* (signal) { + const event = runtimeEvent({ + type, + timestamp: Date.now(), + source: "runtime", + payload: { + sessionId: "session-1", + turnId: "turn-1", + error: "Synthetic failure", + }, + }); + yield event; + yield event; + handled = true; + await new Promise((resolve) => + signal.addEventListener("abort", () => resolve(), { once: true }), + ); + }); + const app = createTuiApp({ + runtime, + terminal, + version: "0.1.0", + workspaceDir: "/workspace", + terminalCapabilities: TERMINAL_CAPABILITIES, + notifications: { when: "always", method: "osc9" }, + }); + try { + await app.ready; + await app.openSession("session-1"); + app.start(); + await vi.waitFor(() => expect(handled).toBe(true)); + const notifications = terminal.writes.filter((value) => value.startsWith("\u001b]9;")); + expect(notifications).toHaveLength(count); + if (count) expect(notifications[0]).toContain("Existing session: Response"); + } finally { + await app.stop(); + } }); it("keeps chat state alive while a regular feature overlay owns the viewport", async () => { diff --git a/packages/tui/test/unit/tui-terminal-presentation.test.ts b/packages/tui/test/unit/tui-terminal-presentation.test.ts new file mode 100644 index 00000000..1d04d2fb --- /dev/null +++ b/packages/tui/test/unit/tui-terminal-presentation.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Terminal } from '../../src/tui/engine/public.js'; +import { createObservedTerminal } from '../../src/tui/platform/observed-terminal.js'; +import { detectTerminalCapabilities } from '../../src/tui/platform/terminal-capabilities.js'; +import { + buildTuiTerminalNotificationSequences, + TuiTerminalNotifications, +} from '../../src/tui/platform/terminal-notifications.js'; +import { formatTuiTerminalTitle, TuiTerminalTitle } from '../../src/tui/platform/terminal-title.js'; + +const capabilities = ( + env: NodeJS.ProcessEnv = {}, + platform: NodeJS.Platform = 'linux', + isTTY = true, +) => detectTerminalCapabilities({ platform, isTTY, env }); + +describe('terminal title ownership', () => { + it('sanitizes session names and preserves status and configured order', () => { + const input = { + title: '\u001b[31m修复\u001b[0m\n登录\u0007\u202e', + sessionId: 'session-1', + workspace: '/workspace', + status: 'perm' as const, + }; + expect(formatTuiTerminalTitle(input)).toBe('Needs approval | 修复 登录 | MCode'); + expect( + formatTuiTerminalTitle(input, ['session-name', 'status', 'status', 'unknown', 'toString']), + ).toBe('修复 登录 | Needs approval'); + expect(formatTuiTerminalTitle(input, null)).toBeUndefined(); + expect(formatTuiTerminalTitle(input, [])).toBeUndefined(); + expect(formatTuiTerminalTitle({ ...input, title: 'New session' })).toBe( + 'Needs approval | workspace (session-) | MCode', + ); + const long = formatTuiTerminalTitle({ ...input, title: '😀'.repeat(300) })!; + expect(Array.from(long)).toHaveLength(240); + expect(long).not.toContain('\ufffd'); + }); + + it('deduplicates writes, releases its title on suspend and reapplies it on resume', () => { + const terminal = { setTitle: vi.fn() }; + const title = new TuiTerminalTitle(terminal, true); + title.update('Before start'); + expect(terminal.setTitle).not.toHaveBeenCalled(); + title.setActive(true); + title.update('Working | session'); + title.update('Working | session'); + expect(terminal.setTitle).toHaveBeenCalledTimes(1); + title.setActive(false); + title.update('Late update'); + expect(terminal.setTitle).toHaveBeenLastCalledWith(''); + title.setActive(true); + title.update('Working | session'); + title.dispose(); + title.setActive(true); + title.update('After dispose'); + expect(terminal.setTitle.mock.calls.flat()).toEqual([ + 'Working | session', + '', + 'Working | session', + '', + ]); + }); + + it('does not clear an unmanaged title or write to a non-TTY', () => { + const terminal = { setTitle: vi.fn() }; + const disabled = new TuiTerminalTitle(terminal, true); + disabled.setActive(true); + disabled.update(undefined); + disabled.dispose(); + const redirected = new TuiTerminalTitle(terminal, false); + redirected.setActive(true); + redirected.update('Redirected'); + redirected.dispose(); + expect(terminal.setTitle).not.toHaveBeenCalled(); + }); + + it('does not cache a failed title write or interrupt the caller', () => { + const terminal = { + setTitle: vi.fn().mockImplementationOnce(() => { + throw new Error('closed'); + }), + }; + const title = new TuiTerminalTitle(terminal, true); + title.setActive(true); + expect(() => title.update('Ready')).not.toThrow(); + title.update('Ready'); + title.update('Ready'); + expect(terminal.setTitle).toHaveBeenCalledTimes(2); + }); +}); + +describe('terminal notification policy and transport', () => { + it('preserves live focus through the production wrapper and suppresses each foreground event once', () => { + const base = { focused: true, write: vi.fn() }; + const terminal = createObservedTerminal(base as unknown as Terminal, vi.fn()); + const notifications = new TuiTerminalNotifications(terminal, { + capabilities: capabilities(), + }); + notifications.setActive(true); + expect(notifications.notifyOnce('turn-complete', 'turn-1')).toBe(false); + base.focused = false; + expect(terminal.focused).toBe(false); + expect(notifications.notifyOnce('turn-complete', 'turn-1')).toBe(false); + expect(notifications.notifyOnce('permission-required', 'permission-1')).toBe(true); + expect(base.write.mock.calls).toEqual([['\u0007']]); + }); + + it('honors event filters, always/never, unknown focus and non-TTY output', () => { + const terminal = { focused: true, write: vi.fn() }; + const filtered = new TuiTerminalNotifications(terminal, { + capabilities: capabilities(), + settings: { when: 'always', events: ['turn-failed'] }, + }); + filtered.setActive(true); + expect(filtered.notifyOnce('turn-complete', 'done')).toBe(false); + expect(filtered.notifyOnce('turn-failed', 'failed')).toBe(true); + for (const options of [ + { capabilities: capabilities(), settings: { when: 'never' as const } }, + { capabilities: capabilities({}, 'linux', false) }, + { capabilities: capabilities(), settings: { events: [] } }, + ]) { + const notifications = new TuiTerminalNotifications({ write: terminal.write }, options); + notifications.setActive(true); + expect(notifications.notifyOnce('turn-complete', 'done')).toBe(false); + } + const unknown = new TuiTerminalNotifications( + { write: terminal.write }, + { capabilities: capabilities() }, + ); + unknown.setActive(true); + expect(unknown.notifyOnce('turn-complete', 'done')).toBe(true); + expect(terminal.write).toHaveBeenCalledTimes(2); + }); + + it.each([ + [{ TERM_PROGRAM: 'vscode' }, '\u0007'], + [{ TERM_PROGRAM: 'ITERM.APP' }, '\u001b]9;'], + [{ TERM: 'xterm-ghostty' }, '\u001b]9;'], + [{ TERM: 'xterm-kitty' }, '\u001b]99;'], + [{ TERM_PROGRAM: 'cmux' }, '\u001b]777;'], + [{ TERM_PROGRAM: 'WarpTerminal' }, '\u001b]9;'], + [{ TMUX: 'synthetic', TERM_PROGRAM: 'iTerm.app' }, '\u001bPtmux;'], + ])('uses the shared terminal detection for %j', (env, prefix) => { + const sequences = buildTuiTerminalNotificationSequences( + { title: 'MCode', body: 'Complete' }, + env, + ); + expect(sequences[0]?.startsWith(prefix)).toBe(true); + }); + + it('assigns distinct kitty IDs and sanitizes title/body before framing', () => { + const env = { TERM: 'xterm-kitty' }; + const message = { + title: 'MCode\u001b]0;injected\u0007', + body: '修复\n登录\u0007', + }; + const first = buildTuiTerminalNotificationSequences(message, env); + const second = buildTuiTerminalNotificationSequences(message, env); + const id = first[0]!.match(/i=([^:;]+)/u)![1]; + expect(first[1]).toContain(`i=${id}:p=body:d=1;修复 登录`); + expect(first.join('')).not.toContain('injected'); + expect(first[0]).not.toBe(second[0]); + const cmux = buildTuiTerminalNotificationSequences( + { title: 'a;b', body: 'c;d' }, + { TERM_PROGRAM: 'cmux' }, + ); + expect(cmux).toEqual(['\u001b]777;notify;a,b;c,d\u0007']); + }); + + it('does not execute a Windows bridge on SSH or ordinary Linux', () => { + for (const env of [ + { WT_SESSION: 'test', SSH_CONNECTION: 'synthetic' }, + { WT_SESSION: 'test' }, + ]) { + const executeFile = vi.fn(); + const terminal = { write: vi.fn() }; + const notifications = new TuiTerminalNotifications(terminal, { + environment: env, + capabilities: capabilities(env), + executeFile, + }); + notifications.setActive(true); + expect(notifications.notifyOnce('turn-complete', 'done')).toBe(true); + expect(executeFile).not.toHaveBeenCalled(); + expect(terminal.write).toHaveBeenCalledWith('\u0007'); + } + }); + + it('quotes native notification text and ignores native failures from a suspended generation', () => { + const env = { WT_SESSION: 'test' }; + const terminal = { write: vi.fn() }; + let callback: ((error: Error | null) => void) | undefined; + const executeFile = vi.fn( + (_file: string, _args: readonly string[], done: (error: Error | null) => void) => { + callback = done; + }, + ); + const notifications = new TuiTerminalNotifications(terminal, { + environment: env, + capabilities: capabilities(env, 'win32'), + executeFile, + }); + notifications.setActive(true); + notifications.notifyOnce('turn-complete', 'done', "User's session"); + expect(executeFile.mock.calls[0]?.[1][2]).toContain("User''s session: Response complete"); + notifications.setActive(false); + notifications.setActive(true); + callback?.(new Error('timeout')); + expect(terminal.write).not.toHaveBeenCalled(); + notifications.dispose(); + expect(notifications.notifyOnce('turn-failed', 'late')).toBe(false); + expect(executeFile).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/tui/test/unit/tui/controller/product/chrome-flow.test.ts b/packages/tui/test/unit/tui/controller/product/chrome-flow.test.ts index 74f93e01..900007c1 100644 --- a/packages/tui/test/unit/tui/controller/product/chrome-flow.test.ts +++ b/packages/tui/test/unit/tui/controller/product/chrome-flow.test.ts @@ -213,6 +213,7 @@ describe("TuiChromeFlow agent status sequence", () => { let interaction: TuiAgentInteractionReadback | undefined; let agentCounts = { active: 0, total: 0 }; const shells: TuiShellState[] = []; + const setTerminalTitle = vi.fn(); const shellSink = { setState: (state: TuiShellState) => shells.push(state), }; @@ -222,7 +223,7 @@ describe("TuiChromeFlow agent status sequence", () => { queueEnabled: true, isStarted: () => true, isStopped: () => false, - setTerminalTitle: vi.fn(), + setTerminalTitle, connection: () => ({ phase: "live", generation: 1 }), liveRunId: (snapshot) => snapshot.activeTurnId, runProjection: () => ({ @@ -255,6 +256,7 @@ describe("TuiChromeFlow agent status sequence", () => { flow.update(snapshot("session-1")); flow.update(snapshot("session-1")); + expect(setTerminalTitle).toHaveBeenLastCalledWith("Ready | workspace (session-) | MCode"); expect(shells.at(-1)).toMatchObject({ agentSeq: "0", agentStatus: "ready", @@ -281,6 +283,7 @@ describe("TuiChromeFlow agent status sequence", () => { activeTurnId: "turn-1", }; flow.update(interactingSnapshot); + expect(setTerminalTitle).toHaveBeenLastCalledWith("Needs approval | workspace (session-) | MCode"); expect(shells.at(-1)).toMatchObject({ agentSeq: "2", agentStatus: "perm", @@ -290,6 +293,7 @@ describe("TuiChromeFlow agent status sequence", () => { interaction = { ...interaction, submitting: true }; flow.update(interactingSnapshot); flow.update(interactingSnapshot); + expect(setTerminalTitle).toHaveBeenLastCalledWith("Working | workspace (session-) | MCode"); expect(shells.at(-1)).toMatchObject({ agentSeq: "3", agentStatus: "run", @@ -332,6 +336,7 @@ describe("TuiChromeFlow agent status sequence", () => { agentStatus: "done", agentRunId: "turn-queued", }); + expect(setTerminalTitle).toHaveBeenLastCalledWith("Done | workspace (session-) | MCode"); flow.update({ ...snapshot("session-2"), diff --git a/release/public-source.json b/release/public-source.json index 025677c6..39240780 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -3135,6 +3135,7 @@ "packages/tui/src/tui/platform/terminal-capabilities.ts", "packages/tui/src/tui/platform/terminal-clipboard.ts", "packages/tui/src/tui/platform/terminal-notifications.ts", + "packages/tui/src/tui/platform/terminal-title.ts", "packages/tui/src/tui/product-features.ts", "packages/tui/src/tui/renderer/index.ts", "packages/tui/src/tui/renderer/interactive-renderer.ts", @@ -3298,6 +3299,7 @@ "packages/tui/test/unit/tui-status-line-items.test.ts", "packages/tui/test/unit/tui-surface-host.test.ts", "packages/tui/test/unit/tui-terminal-image-paste.test.ts", + "packages/tui/test/unit/tui-terminal-presentation.test.ts", "packages/tui/test/unit/tui-terminal-text-paste.test.ts", "packages/tui/test/unit/tui-thinking-preview.test.ts", "packages/tui/test/unit/tui-tool-preview-diff-omitted.test.ts", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index 30722e2a..db55c40e 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -94,6 +94,7 @@ "packages/tui/test/unit/tui-status-line-items.test.ts", "packages/tui/test/unit/tui-terminal-image-paste.test.ts", "packages/tui/test/unit/tui-terminal-text-paste.test.ts", + "packages/tui/test/unit/tui-terminal-presentation.test.ts", "packages/tui/test/unit/tui-thinking-preview.test.ts", "packages/tui/test/unit/tui-transcript-presentation.test.ts", "packages/tui/test/unit/tui-transcript-view.test.ts",