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
18 changes: 17 additions & 1 deletion docs/tui-capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,23 @@ observed generation window, not server hardware throughput.

## Terminal titles and notifications

The interactive TUI also reports [OSC 7501 program status](https://www.superlogical.com/rex/docs/build/program-status)
with `app=mcode`: idle, working, done, error, or blocked on permission, a question,
or login. It reuses the current session's runtime status, including queue handoffs,
compaction and retries, and does not depend on status line items. Cancellation and
suspension report idle; resume reapplies the current status. Completed and failed
results remain reported when the TUI exits. Reports contain only `state`, `kind` and
`app`, and are sent on changes, without a heartbeat or capability query, as
permitted by protocol revision 0.4. Supporting terminals decide how to display
them; other terminals should ignore the sequence. The protocol treats each
multiplexer pane as its own terminal, so MCode does not wrap reports for tmux
passthrough; the multiplexer must implement OSC 7501 for its panes. Redirected
output, headless exec and ACP do not emit these reports. Set
`tui.programStatus: false` to disable them. Existing title and notification
settings remain independent of program status.

Terminal titles show the current state, session name and MCode, for example
`Needs approval | Fix login | MCode`. Renaming or switching a session updates the
`Needs approval | Fix login | MCode`, or `Needs login` while sign-in is required. 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.

Expand All @@ -28,6 +43,7 @@ Configure these presentation settings in the MCode data directory's `config.yaml
```yaml
tui:
terminalTitle: [status, session-name, app-name]
programStatus: true
notifications:
when: unfocused
method: auto
Expand Down
3 changes: 3 additions & 0 deletions packages/config/src/tui-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export interface TuiConfig {
};
/** Show contextual Tips in the idle composer header. Defaults to true. */
showTips?: boolean;
/** Report OSC 7501 program status to the terminal. Defaults to true. */
programStatus?: boolean;
/**
* Status line items, in display order.
*
Expand Down Expand Up @@ -112,6 +114,7 @@ export function parseTuiConfig(raw: Record<string, unknown>): TuiConfig {
}
: {}),
...(typeof tui.showTips === 'boolean' ? { showTips: tui.showTips } : {}),
...(typeof tui.programStatus === 'boolean' ? { programStatus: tui.programStatus } : {}),
...(notifications ? { notifications } : {}),
...(statusLine ? { statusLine } : {}),
...(customStatusLine ? { customStatusLine } : {}),
Expand Down
2 changes: 1 addition & 1 deletion packages/tui/docs/status-line-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ The `build-mode` line is a stable automation protocol, for example for systems t
- **`state`**: one of `ready`, `run`, `perm`, `ask`, `plan`, `done`, `fail`, `cancel`, `error`. Publish `ready` only when SSE is live and a structured snapshot confirms no active / retiring / queued turn or pending interaction. Startup, loading, reconnecting, retrying, compaction, stopping, and interaction submission project to `run`.
- **Waiting for input**: `perm`, `ask`, and `plan` are stable actionable states and require both `turn` and `request`. Submission immediately returns to `run` and clears `request`; no transient `_submit` state is published.
- **Terminal states**: `done`, `fail`, and `cancel` are canonical outcomes of a specific turn and require a non-`none` `turn`. Runtime `blocked` maps to `fail`; `ExecResultV1.status` retains the exact `blocked` result.
- **Fatal errors**: `error` always uses `turn=none request=none` for errors not attributable to the current turn, such as login blocks or invalid interactions. Consumers should fail immediately. It persists until session reset or process recovery. Recoverable reconnect / history-refresh failures and turn errors without canonical settlement remain `run`.
- **Fatal errors**: `error` always uses `turn=none request=none` for errors not attributable to the current turn, such as login blocks or invalid interactions. Consumers should fail immediately. It persists until session reset or process recovery, except that a login block clears once the account is signed in within the same session. Recoverable reconnect / history-refresh failures and turn errors without canonical settlement remain `run`.
- **Single source of truth**: Automation Status Store uses only structured runtime / controller facts. It does not read human-facing activity phases, assistant text, or transcripts, or infer completion from spinners / idle labels.
- **Read-only status line**: consumers must not infer interaction states from pane content, popup copy, or transcripts.

Expand Down
6 changes: 6 additions & 0 deletions packages/tui/src/tui/app-composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import { detectProcessTerminalCapabilities } from './platform/terminal-capabilit
import { createTuiTextClipboardWriter } from './platform/terminal-clipboard.js';
import { TuiTerminalNotifications } from './platform/terminal-notifications.js';
import { TuiTerminalTitle } from './platform/terminal-title.js';
import { TuiTerminalProgramStatus } from './platform/terminal-program-status.js';
import {
ProcessTerminal,
type Component,
Expand Down Expand Up @@ -174,6 +175,10 @@ export function createTuiApplicationRenderer(options: CreateTuiAppOptions) {
{ settings: options.notifications, capabilities },
);
const terminalTitle = new TuiTerminalTitle(terminal, capabilities.isTTY);
const terminalProgramStatus = new TuiTerminalProgramStatus(
terminal,
capabilities.isTTY && options.programStatus !== false,
);
themeController = new TuiThemeController({
ui: tui,
colorLevel: capabilities.colorLevel,
Expand All @@ -188,6 +193,7 @@ export function createTuiApplicationRenderer(options: CreateTuiAppOptions) {
tui,
terminalNotifications,
terminalTitle,
terminalProgramStatus,
themeController,
openExternalTarget,
writeClipboardText,
Expand Down
7 changes: 6 additions & 1 deletion packages/tui/src/tui/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp {
tui,
terminalNotifications,
terminalTitle,
terminalProgramStatus,
themeController,
openExternalTarget,
writeClipboardText,
Expand Down Expand Up @@ -274,7 +275,6 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp {
);
},
...delegationFlow.permissionResolvers(liveRunId),
agentStatusLineItems: options.statusLineItems,
stopTurn: () => {
void abortLiveTurn();
},
Expand Down Expand Up @@ -816,6 +816,7 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp {
isStarted: () => started,
isStopped: () => stopped,
setTerminalTitle: (title) => terminalTitle.update(title),
setProgramStatus: (programStatus) => terminalProgramStatus.update(programStatus),
terminalTitle: options.terminalTitle,
connection: () => stateStore.snapshot().connection,
liveRunId,
Expand Down Expand Up @@ -873,6 +874,7 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp {
stopped = true;
terminalNotifications.dispose();
terminalTitle.dispose();
terminalProgramStatus.dispose();
const bashStopped = bashFlow.stop();
detachInputFlow();
composerDraft.abortClipboardRead();
Expand Down Expand Up @@ -923,6 +925,7 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp {
suspended = true;
terminalNotifications.setActive(false);
terminalTitle.setActive(false);
terminalProgramStatus.setActive(false);
renderer.stop();
await terminal.drainOutput?.();
await draftLifecycle?.suspend();
Expand All @@ -933,6 +936,7 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp {
renderer.start();
terminalNotifications.setActive(true);
terminalTitle.setActive(true);
terminalProgramStatus.setActive(true);
updateChrome(controller.snapshot());
runtimeEventFlow.restart();
tui.requestRender(true);
Expand All @@ -955,6 +959,7 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp {
started = true;
terminalNotifications.setActive(true);
terminalTitle.setActive(true);
terminalProgramStatus.setActive(true);
updateChrome(controller.snapshot());
surfaceHost.setChatFocus(editor);
runtimeEventFlow.start();
Expand Down
13 changes: 12 additions & 1 deletion packages/tui/src/tui/automation/status-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ export interface TuiAutomationStatusFacts {
export interface TuiAutomationStatusSnapshot {
readonly seq: string;
readonly status: TuiAgentStatus;
/**
* `error` caused by a missing login rather than a fatal failure. The `[V]`
* line still reports `error`; human-facing outputs can say that login is
* needed. Derived from current account facts, so it clears after login.
*/
readonly loginRequired?: true;
readonly sessionId?: string;
readonly turnId?: string;
readonly requestId?: string;
Expand Down Expand Up @@ -82,8 +88,8 @@ export class TuiAutomationStatusStore {
acceptedSettledTurn?.status === 'failed' || acceptedSettledTurn?.status === 'blocked';
if (
facts.interaction?.kind === 'invalid' ||
(facts.snapshot.account && tuiAccountNeedsLoginPrompt(facts.snapshot.account)) ||
(facts.snapshot.status === 'error' &&
!(facts.snapshot.account && tuiAccountNeedsLoginPrompt(facts.snapshot.account)) &&
facts.snapshot.errorRetryable === false &&
!activeTurnId &&
!this.pendingTurnId &&
Expand Down Expand Up @@ -139,6 +145,11 @@ export class TuiAutomationStatusStore {
return { status: 'error', ...session, ...counts };
}

// Login is recoverable within the same Session; derive it from current account facts.
if (facts.snapshot.account && tuiAccountNeedsLoginPrompt(facts.snapshot.account)) {
return { status: 'error', loginRequired: true, ...session, ...counts };
}

if (facts.snapshot.status === 'error') {
const failedTurnId =
facts.settledTurn?.status === 'failed' || facts.settledTurn?.status === 'blocked'
Expand Down
14 changes: 4 additions & 10 deletions packages/tui/src/tui/controller/interaction/interaction-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ import {
TuiPlanEntryPanel,
type TuiPlanEntryAction,
} from '../../features/interaction/plan-entry-panel.js';
import { parseTuiStatusLineItems } from '../../shell/status-line-items.js';

type AppendLocalCell = (content: string, kind?: 'final-summary' | 'warning' | 'error') => void;

Expand Down Expand Up @@ -81,14 +80,12 @@ export interface TuiInteractionFlowOptions {
readonly notify?: (kind: TuiTerminalNotificationKind, key: string) => void;
readonly permissionSessionScope?: (activeSessionId: string) => ReadonlySet<string>;
readonly permissionOwnerTurnId?: (ownerSessionId: string) => string | undefined;
readonly agentStatusLineItems?: readonly string[];
readonly stopTurn: () => void;
}

export class TuiInteractionFlow {
private readonly defaultAgentName: string;
private readonly continuation: QuestionnaireContinuation;
private readonly agentStatusEnabled: boolean;
private readonly questionnaireDismissals = new Set<string>();
private panel: Component | undefined;
private planViewRevision = 0;
Expand All @@ -97,10 +94,6 @@ export class TuiInteractionFlow {

constructor(private readonly options: TuiInteractionFlowOptions) {
this.defaultAgentName = options.defaultAgentName ?? MINIMAX_CODE_DEFAULT_AGENT_NAME;
this.agentStatusEnabled = Boolean(
options.agentStatusLineItems &&
parseTuiStatusLineItems(options.agentStatusLineItems).includes('build-mode'),
);
this.continuation = new QuestionnaireContinuation({
runtime: options.runtime,
controller: options.controller,
Expand Down Expand Up @@ -156,9 +149,11 @@ export class TuiInteractionFlow {
return false;
}

/** Structured interaction state for the machine status line. */
/**
* Structured interaction state shared by the machine status line, terminal
* title and program status. Display settings must not change these facts.
*/
agentReadback(): TuiAgentInteractionReadback | undefined {
if (!this.agentStatusEnabled) return undefined;
const permission = this.permission();
if (permission) {
if (!hasPermissionRequestId(permission)) return { kind: 'invalid' };
Expand Down Expand Up @@ -1301,7 +1296,6 @@ export class TuiInteractionFlow {
}

private withAgentSubmission<T>(requestId: string, action: () => Promise<T>): Promise<T> {
if (!this.agentStatusEnabled) return action();
const sessionId = this.options.stateStore.snapshot().activeSessionId;
this.submittingRequestId = requestId;
this.submittingSessionId = sessionId;
Expand Down
10 changes: 9 additions & 1 deletion packages/tui/src/tui/controller/product/chrome-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
} from '../../commands/catalog.js';
import { resolveTuiComposerInputIntent } from '../../commands/input-intent.js';
import { formatTuiTerminalTitle } from '../../platform/terminal-title.js';
import type { TuiProgramStatus } from '../../platform/terminal-program-status.js';

type PresentationSink<K extends keyof TuiVisiblePresentation> = {
setState(state: TuiVisiblePresentation[K]): void;
Expand All @@ -52,6 +53,7 @@ export class TuiChromeFlow {
readonly isStarted: () => boolean;
readonly isStopped: () => boolean;
readonly setTerminalTitle: (title: string | undefined) => void;
readonly setProgramStatus: (status: TuiProgramStatus) => void;
readonly terminalTitle?: readonly string[] | null;
readonly connection: () => Pick<TuiState['connection'], 'phase' | 'generation' | 'lastError'>;
/** Multi-Session state kernel, including the background parent Turn. */
Expand Down Expand Up @@ -219,13 +221,19 @@ export class TuiChromeFlow {
...(backgroundTasks !== undefined ? { backgroundTasks } : {}),
});
if (this.options.isStarted()) {
// Title and program status present the same projection; only login gets
// a more specific human-facing state than the machine `[V]` error.
const programStatus: TuiProgramStatus = automationStatus.loginRequired
? 'auth'
: automationStatus.status;
this.options.setProgramStatus(programStatus);
this.options.setTerminalTitle(
formatTuiTerminalTitle(
{
title: snapshot.session?.title,
sessionId: snapshot.session?.sessionId,
workspace: this.options.workspace,
status: automationStatus.status,
status: programStatus,
},
this.options.terminalTitle,
),
Expand Down
3 changes: 3 additions & 0 deletions packages/tui/src/tui/launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,9 @@ export async function launchTui(
...(presentationConfig.notifications
? { notifications: presentationConfig.notifications }
: {}),
...(presentationConfig.programStatus === undefined
? {}
: { programStatus: presentationConfig.programStatus }),
...(process.env[MCODE_TUI_RESULT_PATH_ENV]
? { automationResultPath: process.env[MCODE_TUI_RESULT_PATH_ENV] }
: {}),
Expand Down
65 changes: 65 additions & 0 deletions packages/tui/src/tui/platform/terminal-program-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { Terminal } from '../engine/public.js';
import type { TuiAgentStatus } from '../shell/status-protocol.js';

export type TuiProgramStatus = TuiAgentStatus | 'auth';

const REPORTS: Record<TuiProgramStatus, string> = {
ready: 'state=idle',
run: 'state=working',
perm: 'state=blocked:kind=permission',
plan: 'state=blocked:kind=permission',
ask: 'state=blocked:kind=question',
auth: 'state=blocked:kind=auth',
done: 'state=done',
fail: 'state=error',
error: 'state=error',
cancel: 'state=idle',
};

/** Optional OSC 7501 output for the interactive TUI's root record. */
export class TuiTerminalProgramStatus {
private active = false;
private disposed = false;
private lastReport?: string;

constructor(
private readonly terminal: Pick<Terminal, 'write'>,
private readonly isTTY: boolean,
) {}

setActive(active: boolean): void {
if (this.disposed || this.active === active) return;
if (!active) {
if (this.lastReport !== undefined) this.update('ready');
this.lastReport = undefined;
}
this.active = active;
}

update(status: TuiProgramStatus): void {
if (!this.active || this.disposed || !this.isTTY) return;
const report = REPORTS[status];
if (report === undefined || report === this.lastReport) return;
try {
// Detection is optional in OSC 7501 revision 0.4. Unknown OSCs are ignored.
// Only fixed metadata is sent; prompts, output and credentials stay in the TUI.
this.terminal.write(`\u001b]7501;${report}:app=mcode\u001b\\`);
this.lastReport = report;
} catch {
// Optional status output must not interrupt a Turn or interaction.
}
}

dispose(): void {
// Completed results survive process exit. An interrupted live TUI is idle.
if (
this.lastReport !== undefined &&
this.lastReport !== REPORTS.done &&
this.lastReport !== REPORTS.error
) {
this.update('ready');
}
this.active = false;
this.disposed = true;
}
}
7 changes: 4 additions & 3 deletions packages/tui/src/tui/platform/terminal-title.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import path from 'node:path';
import type { Terminal } from '../engine/public.js';
import type { TuiAgentStatus } from '../shell/status-protocol.js';
import type { TuiProgramStatus } from './terminal-program-status.js';
import { sanitizeTerminalLabel } from '../rendering/terminal-text.js';

const DEFAULT_ITEMS = ['status', 'session-name', 'app-name'] as const;
const STATUS_LABELS: Record<TuiAgentStatus, string> = {
const STATUS_LABELS: Record<TuiProgramStatus, string> = {
ready: 'Ready',
run: 'Working',
perm: 'Needs approval',
plan: 'Needs approval',
ask: 'Needs input',
auth: 'Needs login',
done: 'Done',
fail: 'Failed',
cancel: 'Stopped',
Expand All @@ -32,7 +33,7 @@ export function formatTuiTerminalTitle(
readonly title?: string;
readonly sessionId?: string;
readonly workspace: string;
readonly status: TuiAgentStatus;
readonly status: TuiProgramStatus;
},
items: readonly string[] | null = DEFAULT_ITEMS,
): string | undefined {
Expand Down
2 changes: 2 additions & 0 deletions packages/tui/src/tui/shell/status-line-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface TuiPresentationConfig {
readonly showTips?: boolean;
readonly statusLineItems?: readonly string[];
readonly notifications?: TuiNotificationSettings;
readonly programStatus?: boolean;
readonly customStatusLine?: TuiCustomStatusLineConfig;
}

Expand All @@ -32,6 +33,7 @@ export async function readTuiPresentationConfig(dataDir: string): Promise<TuiPre
...(typeof tui?.showTips === 'boolean' ? { showTips: tui.showTips } : {}),
...(Array.isArray(tui?.statusLine) ? { statusLineItems: tui.statusLine } : {}),
...(tui?.notifications ? { notifications: tui.notifications } : {}),
...(typeof tui?.programStatus === 'boolean' ? { programStatus: tui.programStatus } : {}),
// Config already validated the shape; a block without a command was dropped there.
...(tui?.customStatusLine?.command ? { customStatusLine: tui.customStatusLine } : {}),
};
Expand Down
2 changes: 2 additions & 0 deletions packages/tui/src/types/tui-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ export interface CreateTuiAppOptions extends TuiUpdateOptions {
/** Ordered terminal title items from tui.terminalTitle; null or [] disables updates. */
terminalTitle?: readonly string[] | null;
notifications?: TuiNotificationSettings;
/** OSC 7501 program status from tui.programStatus; false disables reports. Defaults to true. */
programStatus?: boolean;
/** Internal result-channel path; ignored unless statusLineItems enables build-mode. */
automationResultPath?: string;
productFeatures?: Partial<TuiProductFeatures>;
Expand Down
Loading
Loading