diff --git a/docs/tui-capabilities.md b/docs/tui-capabilities.md index d165050fe..e29b665e8 100644 --- a/docs/tui-capabilities.md +++ b/docs/tui-capabilities.md @@ -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. @@ -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 diff --git a/packages/config/src/tui-config.ts b/packages/config/src/tui-config.ts index b04ecd987..6c09a9a89 100644 --- a/packages/config/src/tui-config.ts +++ b/packages/config/src/tui-config.ts @@ -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. * @@ -112,6 +114,7 @@ export function parseTuiConfig(raw: Record): TuiConfig { } : {}), ...(typeof tui.showTips === 'boolean' ? { showTips: tui.showTips } : {}), + ...(typeof tui.programStatus === 'boolean' ? { programStatus: tui.programStatus } : {}), ...(notifications ? { notifications } : {}), ...(statusLine ? { statusLine } : {}), ...(customStatusLine ? { customStatusLine } : {}), diff --git a/packages/tui/docs/status-line-config.md b/packages/tui/docs/status-line-config.md index 6df7f5535..49bb66029 100644 --- a/packages/tui/docs/status-line-config.md +++ b/packages/tui/docs/status-line-config.md @@ -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. diff --git a/packages/tui/src/tui/app-composition.ts b/packages/tui/src/tui/app-composition.ts index 73760ada9..2892aa6d4 100644 --- a/packages/tui/src/tui/app-composition.ts +++ b/packages/tui/src/tui/app-composition.ts @@ -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, @@ -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, @@ -188,6 +193,7 @@ export function createTuiApplicationRenderer(options: CreateTuiAppOptions) { tui, terminalNotifications, terminalTitle, + terminalProgramStatus, themeController, openExternalTarget, writeClipboardText, diff --git a/packages/tui/src/tui/app.ts b/packages/tui/src/tui/app.ts index f0b23495b..9b8964307 100644 --- a/packages/tui/src/tui/app.ts +++ b/packages/tui/src/tui/app.ts @@ -66,6 +66,7 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { tui, terminalNotifications, terminalTitle, + terminalProgramStatus, themeController, openExternalTarget, writeClipboardText, @@ -274,7 +275,6 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { ); }, ...delegationFlow.permissionResolvers(liveRunId), - agentStatusLineItems: options.statusLineItems, stopTurn: () => { void abortLiveTurn(); }, @@ -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, @@ -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(); @@ -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(); @@ -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); @@ -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(); diff --git a/packages/tui/src/tui/automation/status-store.ts b/packages/tui/src/tui/automation/status-store.ts index 777a73d15..9a93d1861 100644 --- a/packages/tui/src/tui/automation/status-store.ts +++ b/packages/tui/src/tui/automation/status-store.ts @@ -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; @@ -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 && @@ -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' diff --git a/packages/tui/src/tui/controller/interaction/interaction-flow.ts b/packages/tui/src/tui/controller/interaction/interaction-flow.ts index c43380e49..dba00df55 100644 --- a/packages/tui/src/tui/controller/interaction/interaction-flow.ts +++ b/packages/tui/src/tui/controller/interaction/interaction-flow.ts @@ -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; @@ -81,14 +80,12 @@ export interface TuiInteractionFlowOptions { readonly notify?: (kind: TuiTerminalNotificationKind, key: string) => void; readonly permissionSessionScope?: (activeSessionId: string) => ReadonlySet; 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(); private panel: Component | undefined; private planViewRevision = 0; @@ -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, @@ -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' }; @@ -1301,7 +1296,6 @@ export class TuiInteractionFlow { } private withAgentSubmission(requestId: string, action: () => Promise): Promise { - if (!this.agentStatusEnabled) return action(); const sessionId = this.options.stateStore.snapshot().activeSessionId; this.submittingRequestId = requestId; this.submittingSessionId = sessionId; diff --git a/packages/tui/src/tui/controller/product/chrome-flow.ts b/packages/tui/src/tui/controller/product/chrome-flow.ts index 90a089f43..7855c7803 100644 --- a/packages/tui/src/tui/controller/product/chrome-flow.ts +++ b/packages/tui/src/tui/controller/product/chrome-flow.ts @@ -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 = { setState(state: TuiVisiblePresentation[K]): void; @@ -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; /** Multi-Session state kernel, including the background parent Turn. */ @@ -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, ), diff --git a/packages/tui/src/tui/launcher.ts b/packages/tui/src/tui/launcher.ts index 01f817e7e..c31af0736 100644 --- a/packages/tui/src/tui/launcher.ts +++ b/packages/tui/src/tui/launcher.ts @@ -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] } : {}), diff --git a/packages/tui/src/tui/platform/terminal-program-status.ts b/packages/tui/src/tui/platform/terminal-program-status.ts new file mode 100644 index 000000000..0c03949b5 --- /dev/null +++ b/packages/tui/src/tui/platform/terminal-program-status.ts @@ -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 = { + 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, + 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; + } +} diff --git a/packages/tui/src/tui/platform/terminal-title.ts b/packages/tui/src/tui/platform/terminal-title.ts index 874c2c785..12a0f446d 100644 --- a/packages/tui/src/tui/platform/terminal-title.ts +++ b/packages/tui/src/tui/platform/terminal-title.ts @@ -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 = { +const STATUS_LABELS: Record = { ready: 'Ready', run: 'Working', perm: 'Needs approval', plan: 'Needs approval', ask: 'Needs input', + auth: 'Needs login', done: 'Done', fail: 'Failed', cancel: 'Stopped', @@ -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 { diff --git a/packages/tui/src/tui/shell/status-line-config.ts b/packages/tui/src/tui/shell/status-line-config.ts index 6103333fe..b5e8a4a1d 100644 --- a/packages/tui/src/tui/shell/status-line-config.ts +++ b/packages/tui/src/tui/shell/status-line-config.ts @@ -8,6 +8,7 @@ export interface TuiPresentationConfig { readonly showTips?: boolean; readonly statusLineItems?: readonly string[]; readonly notifications?: TuiNotificationSettings; + readonly programStatus?: boolean; readonly customStatusLine?: TuiCustomStatusLineConfig; } @@ -32,6 +33,7 @@ export async function readTuiPresentationConfig(dataDir: string): Promise; diff --git a/packages/tui/test/unit/host-tui-settings.test.ts b/packages/tui/test/unit/host-tui-settings.test.ts index 9c80f66e1..da144d5b6 100644 --- a/packages/tui/test/unit/host-tui-settings.test.ts +++ b/packages/tui/test/unit/host-tui-settings.test.ts @@ -43,6 +43,17 @@ describe('terminal presentation configuration', () => { }); }, ); + + it.each([ + ['false', false], + ['true', true], + ['off', undefined], + ] as const)('reads programStatus %s through the config loader', async (raw, expected) => { + const dataDir = await temporaryDataDir(); + await writeFile(join(dataDir, 'config.yaml'), `tui:\n programStatus: ${raw}\n`, 'utf8'); + const settings = await readTuiPresentationConfig(dataDir); + expect(settings.programStatus).toBe(expected); + }); }); describe('TUI mode settings', () => { diff --git a/packages/tui/test/unit/tui-app.test.ts b/packages/tui/test/unit/tui-app.test.ts index 76cb635dd..ba8cceb9e 100644 --- a/packages/tui/test/unit/tui-app.test.ts +++ b/packages/tui/test/unit/tui-app.test.ts @@ -2786,6 +2786,10 @@ describe("createTuiApp", () => { expect(conversation).toContain("Hello from the Agent"); expect(terminal.started).toBe(true); expect(terminal.title).toBe("Done | workspace (session-) | MCode"); + const programReports = () => + terminal.writes.filter((value) => value.startsWith("\u001b]7501;")); + expect(programReports()).toContain("\u001b]7501;state=working:app=mcode\u001b\\"); + expect(programReports().at(-1)).toBe("\u001b]7501;state=done:app=mcode\u001b\\"); expect(runtime.createSession).toHaveBeenCalledWith({ workspaceDir: "/workspace", }); @@ -2801,6 +2805,7 @@ describe("createTuiApp", () => { await app.stop(); expect(terminal.stopped).toBe(true); + expect(programReports().at(-1)).toBe("\u001b]7501;state=done:app=mcode\u001b\\"); }); it("syncs the generated Session title to the terminal without duplicate writes", async () => { @@ -2865,12 +2870,18 @@ describe("createTuiApp", () => { await app.submit("/rename Renamed session"); expect(terminal.title).toBe("Renamed session"); + const programReports = () => + terminal.writes.filter((value) => value.startsWith("\u001b]7501;")); await app.suspend(); expect(terminal.title).toBe(""); + expect(programReports().at(-1)).toBe("\u001b]7501;state=idle:app=mcode\u001b\\"); + const reportCount = programReports().length; await app.controller.renameCurrentSession("Renamed while suspended"); + expect(programReports()).toHaveLength(reportCount); expect(terminal.title).toBe(""); await app.resume(); expect(terminal.title).toBe("Renamed while suspended"); + expect(programReports().at(-1)).toBe("\u001b]7501;state=done:app=mcode\u001b\\"); await app.stop(); expect(terminal.title).toBe(""); @@ -9838,6 +9849,100 @@ describe("createTuiApp", () => { await app.stop(); }); + it("reports permission and question waits to the title and OSC 7501 without build-mode", async () => { + const terminal = new FakeTerminal(); + const runtime = createRuntime(); + vi.mocked(runtime.listSessions).mockResolvedValue([ + { + sessionId: "session-existing", + title: "Existing session", + workspaceDir: "/workspace", + }, + ]); + vi.mocked(runtime.listPendingPermissions).mockResolvedValue([ + { + requestId: "permission-1", + sessionId: "session-existing", + agentName: "mavis", + toolName: "bash", + reason: "Run tests", + }, + ]); + // The permission arrives while its Turn is active, as in a real approval prompt. + vi.mocked(runtime.getActiveRun).mockImplementation(async (sessionId: string) => ({ + schemaVersion: 1, + sessionId, + state: "decision-blocked", + turnId: "turn-permission", + actions: { steer: false }, + })); + vi.mocked(runtime.getPendingQuestionnaire).mockResolvedValue({ + id: "question-1", + title: "Choose", + requester: { sessionId: "session-existing", runId: "turn-question" }, + steps: [ + { + id: "step-1", + question: "Proceed?", + selectionMode: "single", + options: [ + { id: "yes", label: "Yes" }, + { id: "no", label: "No" }, + ], + }, + ], + }); + // Default configuration: no status line items, so no build-mode line. + const app = createTuiApp({ + runtime, + terminal, + terminalCapabilities: TERMINAL_CAPABILITIES, + version: "0.1.0", + workspaceDir: "/workspace", + }); + const programReports = () => + terminal.writes.filter((value) => value.startsWith("\u001b]7501;")); + + app.start(); + await app.ready; + await app.submit("/resume session-existing"); + await vi.waitFor(() => + expect(terminal.title).toBe("Needs approval | Existing session | MCode"), + ); + expect(programReports().at(-1)).toBe( + "\u001b]7501;state=blocked:kind=permission:app=mcode\u001b\\", + ); + + await app.submit("/allow"); + await vi.waitFor(() => + expect(terminal.title).toBe("Needs input | Existing session | MCode"), + ); + expect(programReports().at(-1)).toBe( + "\u001b]7501;state=blocked:kind=question:app=mcode\u001b\\", + ); + expect(app.tui.render(120).join("\n")).not.toContain("[V]"); + await app.stop(); + }); + + it("does not report OSC 7501 when tui.programStatus is false", async () => { + const terminal = new FakeTerminal(); + const app = createTuiApp({ + runtime: createRuntime(), + terminal, + terminalCapabilities: TERMINAL_CAPABILITIES, + version: "0.1.0", + workspaceDir: "/workspace", + programStatus: false, + }); + + app.start(); + await app.ready; + await app.submit("Say hello"); + expect(terminal.title).toBe("Done | workspace (session-) | MCode"); + await app.stop(); + expect(terminal.writes.some((value) => value.includes("\u001b]7501;"))).toBe(false); + }); + it("shows account status and resolves pending permission and questionnaire decisions", async () => { const terminal = new FakeTerminal(); const runtime = createRuntime(); diff --git a/packages/tui/test/unit/tui-terminal-presentation.test.ts b/packages/tui/test/unit/tui-terminal-presentation.test.ts index 1d04d2fbb..d8f3c9ada 100644 --- a/packages/tui/test/unit/tui-terminal-presentation.test.ts +++ b/packages/tui/test/unit/tui-terminal-presentation.test.ts @@ -7,6 +7,10 @@ import { TuiTerminalNotifications, } from '../../src/tui/platform/terminal-notifications.js'; import { formatTuiTerminalTitle, TuiTerminalTitle } from '../../src/tui/platform/terminal-title.js'; +import { + TuiTerminalProgramStatus, + type TuiProgramStatus, +} from '../../src/tui/platform/terminal-program-status.js'; const capabilities = ( env: NodeJS.ProcessEnv = {}, @@ -31,6 +35,9 @@ describe('terminal title ownership', () => { expect(formatTuiTerminalTitle({ ...input, title: 'New session' })).toBe( 'Needs approval | workspace (session-) | MCode', ); + expect(formatTuiTerminalTitle({ ...input, status: 'auth' })).toBe( + 'Needs login | 修复 登录 | MCode', + ); const long = formatTuiTerminalTitle({ ...input, title: '😀'.repeat(300) })!; expect(Array.from(long)).toHaveLength(240); expect(long).not.toContain('\ufffd'); @@ -89,6 +96,96 @@ describe('terminal title ownership', () => { }); }); +describe('OSC 7501 program status', () => { + it.each([ + ['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'], + ] as const)('reports %s using fixed protocol metadata', (state, body) => { + const terminal = { write: vi.fn() }; + const status = new TuiTerminalProgramStatus(terminal, true); + status.setActive(true); + status.update(state); + status.update(state); + expect(terminal.write.mock.calls).toEqual([[`\u001b]7501;${body}:app=mcode\u001b\\`]]); + // Only fixed metadata leaves the TUI: no id, msg, title or progress pairs. + const sequence = terminal.write.mock.calls[0]![0] as string; + const keys = sequence + .slice('\u001b]7501;'.length, -'\u001b\\'.length) + .split(':') + .map((pair) => pair.split('=')[0]); + expect(keys.every((key) => ['state', 'kind', 'app'].includes(key!))).toBe(true); + }); + + it('releases a suspended TUI, ignores late updates and reapplies status on resume', () => { + const terminal = { write: vi.fn() }; + const status = new TuiTerminalProgramStatus(terminal, true); + status.update('run'); + expect(terminal.write).not.toHaveBeenCalled(); + status.setActive(true); + status.update('perm'); + status.update('plan'); + status.setActive(false); + status.update('ask'); + status.setActive(true); + status.update('ask'); + status.dispose(); + status.setActive(true); + status.update('run'); + expect(terminal.write.mock.calls.flat()).toEqual([ + '\u001b]7501;state=blocked:kind=permission:app=mcode\u001b\\', + '\u001b]7501;state=idle:app=mcode\u001b\\', + '\u001b]7501;state=blocked:kind=question:app=mcode\u001b\\', + '\u001b]7501;state=idle:app=mcode\u001b\\', + ]); + }); + + it.each(['done', 'fail', 'error'] as const)('preserves %s on exit', (state) => { + const terminal = { write: vi.fn() }; + const status = new TuiTerminalProgramStatus(terminal, true); + status.setActive(true); + status.update(state); + status.dispose(); + status.dispose(); + status.update('ready'); + expect(terminal.write).toHaveBeenCalledTimes(1); + }); + + it('never writes to a non-TTY or clears a record it did not write', () => { + const terminal = { write: vi.fn() }; + for (const isTTY of [false, true]) { + const status = new TuiTerminalProgramStatus(terminal, isTTY); + status.setActive(true); + if (!isTTY) status.update('run'); + status.setActive(false); + status.dispose(); + } + expect(terminal.write).not.toHaveBeenCalled(); + }); + + it('ignores unknown states and retries failed writes without interrupting the caller', () => { + const terminal = { + write: vi.fn().mockImplementationOnce(() => { + throw new Error('closed'); + }), + }; + const status = new TuiTerminalProgramStatus(terminal, true); + status.setActive(true); + status.update('unknown' as TuiProgramStatus); + expect(() => status.update('run')).not.toThrow(); + status.update('run'); + status.update('run'); + expect(terminal.write).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() }; 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 9686f31e0..e1cd7ab59 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 @@ -38,6 +38,7 @@ describe("TuiChromeFlow agent status sequence", () => { isStarted: () => true, isStopped: () => false, setTerminalTitle: vi.fn(), + setProgramStatus: vi.fn(), connection: () => ({ phase: "live", generation: 1 }), liveRunId: () => undefined, runProjection: () => ({ @@ -101,6 +102,7 @@ describe("TuiChromeFlow agent status sequence", () => { isStarted: () => true, isStopped: () => false, setTerminalTitle: vi.fn(), + setProgramStatus: vi.fn(), connection: () => ({ phase: "live", generation: 1 }), liveRunId: () => undefined, runProjection: () => ({ @@ -158,6 +160,7 @@ describe("TuiChromeFlow agent status sequence", () => { isStarted: () => true, isStopped: () => false, setTerminalTitle: vi.fn(), + setProgramStatus: vi.fn(), connection: () => ({ phase: "live", generation: 1 }), liveRunId: () => undefined, runProjection: () => ({ @@ -215,6 +218,7 @@ describe("TuiChromeFlow agent status sequence", () => { let backgroundTasks = 0; const shells: TuiShellState[] = []; const setTerminalTitle = vi.fn(); + const setProgramStatus = vi.fn(); const shellSink = { setState: (state: TuiShellState) => shells.push(state), }; @@ -225,6 +229,7 @@ describe("TuiChromeFlow agent status sequence", () => { isStarted: () => true, isStopped: () => false, setTerminalTitle, + setProgramStatus, connection: () => ({ phase: "live", generation: 1 }), liveRunId: (snapshot) => snapshot.activeTurnId, runProjection: () => ({ @@ -294,6 +299,7 @@ describe("TuiChromeFlow agent status sequence", () => { }; flow.update(interactingSnapshot); expect(setTerminalTitle).toHaveBeenLastCalledWith("Needs approval | workspace (session-) | MCode"); + expect(setProgramStatus).toHaveBeenLastCalledWith("perm"); expect(shells.at(-1)).toMatchObject({ agentSeq: "4", agentStatus: "perm", @@ -304,6 +310,7 @@ describe("TuiChromeFlow agent status sequence", () => { flow.update(interactingSnapshot); flow.update(interactingSnapshot); expect(setTerminalTitle).toHaveBeenLastCalledWith("Working | workspace (session-) | MCode"); + expect(setProgramStatus).toHaveBeenLastCalledWith("run"); expect(shells.at(-1)).toMatchObject({ agentSeq: "5", agentStatus: "run", @@ -372,5 +379,49 @@ describe("TuiChromeFlow agent status sequence", () => { }); expect(shells.at(-1)).toMatchObject({ agentStatus: "ready" }); expect(shells.at(-1)?.agentRunId).toBeUndefined(); + expect(setProgramStatus).toHaveBeenLastCalledWith("ready"); + + flow.update({ + ...snapshot("session-3"), + account: { status: "needs-login", authMode: "managed-login" } as TuiChatSnapshot["account"], + }); + expect(setProgramStatus).toHaveBeenLastCalledWith("auth"); + // The machine line keeps its fatal-error contract; human-facing outputs say why. + expect(shells.at(-1)).toMatchObject({ agentStatus: "error" }); + expect(shells.at(-1)?.agentRunId).toBeUndefined(); + expect(setTerminalTitle).toHaveBeenLastCalledWith("Needs login | workspace (session-) | MCode"); + const authenticated = { + ...snapshot("session-3"), + account: { + status: "ready", + authMode: "managed-login", + managedTokenPresent: true, + } as TuiChatSnapshot["account"], + }; + flow.update(authenticated); + expect(setProgramStatus).toHaveBeenLastCalledWith("ready"); + expect(shells.at(-1)).toMatchObject({ agentStatus: "ready" }); + expect(setTerminalTitle).toHaveBeenLastCalledWith("Ready | workspace (session-) | MCode"); + flow.update({ ...authenticated, status: "running", activeTurnId: "after-login" }); + expect(setProgramStatus).toHaveBeenLastCalledWith("run"); + flow.update({ + ...authenticated, + lastSettledTurn: { sessionId: "session-3", turnId: "after-login", status: "succeeded" }, + }); + expect(setProgramStatus).toHaveBeenLastCalledWith("done"); + flow.update(authenticated); + expect(setProgramStatus).toHaveBeenLastCalledWith("done"); + + flow.update({ ...snapshot("fatal-session"), status: "error", errorRetryable: false }); + expect(setProgramStatus).toHaveBeenLastCalledWith("error"); + flow.update(snapshot("fatal-session")); + expect(setProgramStatus).toHaveBeenLastCalledWith("error"); + // A fatal error is not downgraded to a recoverable login wait. + flow.update({ + ...snapshot("fatal-session"), + account: { status: "needs-login", authMode: "managed-login" } as TuiChatSnapshot["account"], + }); + expect(setProgramStatus).toHaveBeenLastCalledWith("error"); + expect(setTerminalTitle).toHaveBeenLastCalledWith("Error | workspace (fatal-se) | MCode"); }); }); diff --git a/release/public-source.json b/release/public-source.json index 7edf6fa39..253d195e6 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -3201,6 +3201,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-program-status.ts", "packages/tui/src/tui/platform/terminal-title.ts", "packages/tui/src/tui/product-features.ts", "packages/tui/src/tui/renderer/index.ts",