From c11c3d4ebea0203ead807a7eaec800d35ed2145f Mon Sep 17 00:00:00 2001 From: jiachen Date: Sat, 10 Oct 2026 00:36:34 +0800 Subject: [PATCH 1/2] feat(tui): report program status with OSC 7501 --- docs/tui-capabilities.md | 12 +++ packages/tui/src/tui/app-composition.ts | 3 + packages/tui/src/tui/app.ts | 6 ++ .../tui/src/tui/automation/status-store.ts | 8 +- .../src/tui/controller/product/chrome-flow.ts | 8 ++ .../tui/platform/terminal-program-status.ts | 65 ++++++++++++++ packages/tui/test/unit/tui-app.test.ts | 11 +++ .../unit/tui-terminal-presentation.test.ts | 87 +++++++++++++++++++ .../controller/product/chrome-flow.test.ts | 38 ++++++++ release/public-source.json | 1 + 10 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 packages/tui/src/tui/platform/terminal-program-status.ts diff --git a/docs/tui-capabilities.md b/docs/tui-capabilities.md index d165050fe..93f9da75e 100644 --- a/docs/tui-capabilities.md +++ b/docs/tui-capabilities.md @@ -18,6 +18,18 @@ 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. Cancellation and suspension report idle; resume reapplies +the current status. Completed and failed results remain reported when the TUI exits. +Reports contain only fixed status metadata and are sent on changes, without a +heartbeat or capability query, as permitted by protocol revision 0.3. Supporting +terminals decide how to display them; other terminals should ignore the sequence. +Multiplexers must support or pass through OSC 7501 for the outer terminal to see it. +Redirected output, headless exec and ACP do not emit these reports. 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 title. Unnamed sessions use the project name and a short session ID. Titles are diff --git a/packages/tui/src/tui/app-composition.ts b/packages/tui/src/tui/app-composition.ts index 73760ada9..3683a2870 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,7 @@ export function createTuiApplicationRenderer(options: CreateTuiAppOptions) { { settings: options.notifications, capabilities }, ); const terminalTitle = new TuiTerminalTitle(terminal, capabilities.isTTY); + const terminalProgramStatus = new TuiTerminalProgramStatus(terminal, capabilities.isTTY); themeController = new TuiThemeController({ ui: tui, colorLevel: capabilities.colorLevel, @@ -188,6 +190,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..8e12c34a1 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, @@ -816,6 +817,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 +875,7 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { stopped = true; terminalNotifications.dispose(); terminalTitle.dispose(); + terminalProgramStatus.dispose(); const bashStopped = bashFlow.stop(); detachInputFlow(); composerDraft.abortClipboardRead(); @@ -923,6 +926,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 +937,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 +960,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..395437e37 100644 --- a/packages/tui/src/tui/automation/status-store.ts +++ b/packages/tui/src/tui/automation/status-store.ts @@ -82,8 +82,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 && @@ -135,7 +135,11 @@ export class TuiAutomationStatusStore { return { status: 'run', ...session, ...counts }; } - if (this.stickyFatalError) { + // Login is recoverable within the same Session; derive it from current account facts. + if ( + this.stickyFatalError || + (facts.snapshot.account && tuiAccountNeedsLoginPrompt(facts.snapshot.account)) + ) { return { status: 'error', ...session, ...counts }; } diff --git a/packages/tui/src/tui/controller/product/chrome-flow.ts b/packages/tui/src/tui/controller/product/chrome-flow.ts index 90a089f43..61e557ce2 100644 --- a/packages/tui/src/tui/controller/product/chrome-flow.ts +++ b/packages/tui/src/tui/controller/product/chrome-flow.ts @@ -28,6 +28,8 @@ 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'; +import { tuiAccountNeedsLoginPrompt } from '../../../application/login-gate.js'; type PresentationSink = { setState(state: TuiVisiblePresentation[K]): void; @@ -52,6 +54,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,6 +222,11 @@ export class TuiChromeFlow { ...(backgroundTasks !== undefined ? { backgroundTasks } : {}), }); if (this.options.isStarted()) { + this.options.setProgramStatus( + snapshot.account && tuiAccountNeedsLoginPrompt(snapshot.account) + ? 'auth' + : automationStatus.status, + ); this.options.setTerminalTitle( formatTuiTerminalTitle( { 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..f2f7b98ab --- /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.3. 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/test/unit/tui-app.test.ts b/packages/tui/test/unit/tui-app.test.ts index 76cb635dd..80086d466 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(""); diff --git a/packages/tui/test/unit/tui-terminal-presentation.test.ts b/packages/tui/test/unit/tui-terminal-presentation.test.ts index 1d04d2fbb..903ab5595 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 = {}, @@ -89,6 +93,89 @@ 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\\`]]); + }); + + 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..3cabc6f85 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,36 @@ 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"); + const authenticated = { + ...snapshot("session-3"), + account: { + status: "ready", + authMode: "managed-login", + managedTokenPresent: true, + } as TuiChatSnapshot["account"], + }; + flow.update(authenticated); + expect(setProgramStatus).toHaveBeenLastCalledWith("ready"); + 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"); }); }); 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", From 23f3a8e4613ce5d5fbdfb0144f047b94adf01fab Mon Sep 17 00:00:00 2001 From: jiachen Date: Sun, 11 Oct 2026 16:46:38 +0800 Subject: [PATCH 2/2] fix(tui): report approval and question waits without build-mode Interaction readback was gated on the build-mode status line item, so with the default configuration a pending permission or question was reported as working in both the terminal title and OSC 7501. The readback now always feeds the status projection; build-mode only controls rendering the [V] line. Missing login is derived once in the status projection as an error with loginRequired, so the [V] line keeps state=error while the title shows "Needs login" and OSC 7501 reports blocked:kind=auth from the same fact. Add tui.programStatus (default true) to disable OSC 7501 reports, document why reports are not wrapped for tmux passthrough, and reference protocol revision 0.4. --- docs/tui-capabilities.md | 22 +++-- packages/config/src/tui-config.ts | 3 + packages/tui/docs/status-line-config.md | 2 +- packages/tui/src/tui/app-composition.ts | 5 +- packages/tui/src/tui/app.ts | 1 - .../tui/src/tui/automation/status-store.ts | 17 +++- .../interaction/interaction-flow.ts | 14 +-- .../src/tui/controller/product/chrome-flow.ts | 14 +-- packages/tui/src/tui/launcher.ts | 3 + .../tui/platform/terminal-program-status.ts | 2 +- .../tui/src/tui/platform/terminal-title.ts | 7 +- .../tui/src/tui/shell/status-line-config.ts | 2 + packages/tui/src/types/tui-app.ts | 2 + .../tui/test/unit/host-tui-settings.test.ts | 11 +++ packages/tui/test/unit/tui-app.test.ts | 94 +++++++++++++++++++ .../unit/tui-terminal-presentation.test.ts | 10 ++ .../controller/product/chrome-flow.test.ts | 13 +++ 17 files changed, 184 insertions(+), 38 deletions(-) diff --git a/docs/tui-capabilities.md b/docs/tui-capabilities.md index 93f9da75e..e29b665e8 100644 --- a/docs/tui-capabilities.md +++ b/docs/tui-capabilities.md @@ -21,17 +21,20 @@ observed generation window, not server hardware throughput. 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. Cancellation and suspension report idle; resume reapplies -the current status. Completed and failed results remain reported when the TUI exits. -Reports contain only fixed status metadata and are sent on changes, without a -heartbeat or capability query, as permitted by protocol revision 0.3. Supporting -terminals decide how to display them; other terminals should ignore the sequence. -Multiplexers must support or pass through OSC 7501 for the outer terminal to see it. -Redirected output, headless exec and ACP do not emit these reports. Existing title -and notification settings remain independent of program status. +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. @@ -40,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 3683a2870..2892aa6d4 100644 --- a/packages/tui/src/tui/app-composition.ts +++ b/packages/tui/src/tui/app-composition.ts @@ -175,7 +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); + const terminalProgramStatus = new TuiTerminalProgramStatus( + terminal, + capabilities.isTTY && options.programStatus !== false, + ); themeController = new TuiThemeController({ ui: tui, colorLevel: capabilities.colorLevel, diff --git a/packages/tui/src/tui/app.ts b/packages/tui/src/tui/app.ts index 8e12c34a1..9b8964307 100644 --- a/packages/tui/src/tui/app.ts +++ b/packages/tui/src/tui/app.ts @@ -275,7 +275,6 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { ); }, ...delegationFlow.permissionResolvers(liveRunId), - agentStatusLineItems: options.statusLineItems, stopTurn: () => { void abortLiveTurn(); }, diff --git a/packages/tui/src/tui/automation/status-store.ts b/packages/tui/src/tui/automation/status-store.ts index 395437e37..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; @@ -135,14 +141,15 @@ export class TuiAutomationStatusStore { return { status: 'run', ...session, ...counts }; } - // Login is recoverable within the same Session; derive it from current account facts. - if ( - this.stickyFatalError || - (facts.snapshot.account && tuiAccountNeedsLoginPrompt(facts.snapshot.account)) - ) { + if (this.stickyFatalError) { 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 61e557ce2..7855c7803 100644 --- a/packages/tui/src/tui/controller/product/chrome-flow.ts +++ b/packages/tui/src/tui/controller/product/chrome-flow.ts @@ -29,7 +29,6 @@ import { import { resolveTuiComposerInputIntent } from '../../commands/input-intent.js'; import { formatTuiTerminalTitle } from '../../platform/terminal-title.js'; import type { TuiProgramStatus } from '../../platform/terminal-program-status.js'; -import { tuiAccountNeedsLoginPrompt } from '../../../application/login-gate.js'; type PresentationSink = { setState(state: TuiVisiblePresentation[K]): void; @@ -222,18 +221,19 @@ export class TuiChromeFlow { ...(backgroundTasks !== undefined ? { backgroundTasks } : {}), }); if (this.options.isStarted()) { - this.options.setProgramStatus( - snapshot.account && tuiAccountNeedsLoginPrompt(snapshot.account) - ? 'auth' - : automationStatus.status, - ); + // 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 index f2f7b98ab..0c03949b5 100644 --- a/packages/tui/src/tui/platform/terminal-program-status.ts +++ b/packages/tui/src/tui/platform/terminal-program-status.ts @@ -41,7 +41,7 @@ export class TuiTerminalProgramStatus { const report = REPORTS[status]; if (report === undefined || report === this.lastReport) return; try { - // Detection is optional in OSC 7501 revision 0.3. Unknown OSCs are ignored. + // 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; 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 80086d466..ba8cceb9e 100644 --- a/packages/tui/test/unit/tui-app.test.ts +++ b/packages/tui/test/unit/tui-app.test.ts @@ -9849,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 903ab5595..d8f3c9ada 100644 --- a/packages/tui/test/unit/tui-terminal-presentation.test.ts +++ b/packages/tui/test/unit/tui-terminal-presentation.test.ts @@ -35,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'); @@ -112,6 +115,13 @@ describe('OSC 7501 program status', () => { 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', () => { 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 3cabc6f85..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 @@ -386,6 +386,10 @@ describe("TuiChromeFlow agent status sequence", () => { 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: { @@ -396,6 +400,8 @@ describe("TuiChromeFlow agent status sequence", () => { }; 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({ @@ -410,5 +416,12 @@ describe("TuiChromeFlow agent status sequence", () => { 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"); }); });