Skip to content
Merged
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
45 changes: 45 additions & 0 deletions docs/tui-capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
21 changes: 20 additions & 1 deletion packages/config/src/tui-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -75,12 +79,17 @@ export function parseTuiConfig(raw: Record<string, unknown>): TuiConfig {
typeof rawNotifications === 'object' &&
!Array.isArray(rawNotifications)
) {
const { when, method } = rawNotifications as Record<string, unknown>;
const { when, method, events } = rawNotifications as Record<string, unknown>;
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;
Expand All @@ -92,6 +101,16 @@ export function parseTuiConfig(raw: Record<string, unknown>): 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 } : {}),
Expand Down
29 changes: 5 additions & 24 deletions packages/tui/src/tui/app-composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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,
Expand All @@ -184,6 +186,7 @@ export function createTuiApplicationRenderer(options: CreateTuiAppOptions) {
renderer,
tui,
terminalNotifications,
terminalTitle,
themeController,
openExternalTarget,
writeClipboardText,
Expand Down Expand Up @@ -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
Expand Down
32 changes: 29 additions & 3 deletions packages/tui/src/tui/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -64,6 +65,7 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp {
renderer,
tui,
terminalNotifications,
terminalTitle,
themeController,
openExternalTarget,
writeClipboardText,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -890,13 +909,18 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp {
async function suspend(): Promise<void> {
if (!started || stopped || suspended) return;
suspended = true;
terminalNotifications.setActive(false);
terminalTitle.setActive(false);
renderer.stop();
await draftLifecycle?.suspend();
}
async function resume(): Promise<void> {
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();
Expand All @@ -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();
Expand Down
31 changes: 16 additions & 15 deletions packages/tui/src/tui/controller/product/chrome-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<K extends keyof TuiVisiblePresentation> = {
setState(state: TuiVisiblePresentation[K]): void;
Expand All @@ -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(
Expand All @@ -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<TuiState['connection'], 'phase' | 'generation' | 'lastError'>;
/** Multi-Session state kernel, including the background parent Turn. */
readonly liveRunId: (snapshot: TuiChatSnapshot) => string | undefined;
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
}
18 changes: 15 additions & 3 deletions packages/tui/src/tui/controller/runtime/runtime-event-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -906,7 +906,19 @@ export class TuiRuntimeEventFlow {
event: TuiSessionLifecycleEvent,
sessionId: string,
): Promise<void> {
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
Expand All @@ -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;
Expand All @@ -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<boolean> {
Expand Down
4 changes: 4 additions & 0 deletions packages/tui/src/tui/launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
: {}),
Expand Down
3 changes: 3 additions & 0 deletions packages/tui/src/tui/platform/observed-terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down
Loading
Loading