diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index 04c16752870..887c0ca2e14 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -341,6 +341,10 @@ function applyDaemonTranscriptEvent( break; case 'status': case 'debug': + appendStatusBlock(next, event.type, event.text, event, { + clearActiveText: event.clearActiveText, + }); + break; case 'error': appendStatusBlock(next, event.type, event.text, event); break; @@ -1256,6 +1260,10 @@ function appendStatusBlock( }; appendBlock(state, block); if (opts.clearActiveText !== false) clearActiveText(state); + // Opt-out only protects the streaming assistant/thought block; the user + // pointer must still reset, otherwise a later mergeable user.text.delta + // (e.g. a peer client's prompt echo) appends onto the command echo block. + else state.activeUserBlockId = undefined; } function appendPromptCancelledBlock( diff --git a/packages/sdk-typescript/src/daemon/ui/types.ts b/packages/sdk-typescript/src/daemon/ui/types.ts index 2e6c43e4cea..44cf7dd23ee 100644 --- a/packages/sdk-typescript/src/daemon/ui/types.ts +++ b/packages/sdk-typescript/src/daemon/ui/types.ts @@ -278,6 +278,13 @@ export interface DaemonUiStatusEvent extends DaemonUiEventBase { text: string; source?: string; data?: unknown; + /** + * Client-dispatch opt-out: `false` inserts the status block without + * finalizing the active assistant/thought block, so read-only command + * output dispatched mid-turn does not split a streaming answer or orphan + * its usage frames. Daemon-emitted events leave this unset. + */ + clearActiveText?: boolean; } export interface DaemonUiErrorEvent extends DaemonUiEventBase { diff --git a/packages/sdk-typescript/test/daemon-ui-transcript.test.ts b/packages/sdk-typescript/test/daemon-ui-transcript.test.ts index 744856ef6e3..839cf5057e6 100644 --- a/packages/sdk-typescript/test/daemon-ui-transcript.test.ts +++ b/packages/sdk-typescript/test/daemon-ui-transcript.test.ts @@ -38,3 +38,115 @@ describe('daemon transcript rewind', () => { expect(state.activeAssistantBlockId).toBeUndefined(); }); }); + +describe('status event while an assistant block is streaming', () => { + it('finalizes the active assistant block by default', () => { + const state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 1 }), + [ + { type: 'user.text.delta', text: 'question' }, + { type: 'assistant.text.delta', text: 'answering' }, + { type: 'status', text: 'mid-stream status' }, + { type: 'assistant.text.delta', text: ' more' }, + { type: 'assistant.done' }, + ], + { now: 1 }, + ); + + expect(state.blocks.map((block) => block.kind)).toEqual([ + 'user', + 'assistant', + 'status', + 'assistant', + ]); + }); + + it('keeps the assistant block active when clearActiveText is false', () => { + const state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 1 }), + [ + { type: 'user.text.delta', text: 'question' }, + { type: 'assistant.text.delta', text: 'answering' }, + { type: 'status', text: 'mid-stream status', clearActiveText: false }, + { type: 'assistant.text.delta', text: ' more' }, + { + type: 'assistant.usage', + usage: { inputTokens: 3, outputTokens: 5 }, + }, + { type: 'assistant.done' }, + ], + { now: 1 }, + ); + + expect(state.blocks.map((block) => block.kind)).toEqual([ + 'user', + 'assistant', + 'status', + ]); + const assistant = state.blocks[1]; + if (assistant.kind !== 'assistant') throw new Error('expected assistant'); + expect(assistant.text).toBe('answering more'); + expect(assistant.usage).toEqual({ + inputTokens: 3, + outputTokens: 5, + cachedTokens: 0, + }); + }); + + it('resets the active user block even when clearActiveText is false', () => { + let state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 1 }), + [{ type: 'user.text.delta', text: '/stats' }], + { now: 1 }, + ); + state = reduceDaemonTranscriptEvents( + state, + [{ type: 'status', text: 'stats output', clearActiveText: false }], + { now: 1 }, + ); + + expect(state.activeUserBlockId).toBeUndefined(); + + // A peer client's prompt echo must open its own user block instead of + // merging into the local command echo. + state = reduceDaemonTranscriptEvents( + state, + [{ type: 'user.text.delta', text: 'fix the bug' }], + { now: 1 }, + ); + + expect(state.blocks.map((block) => block.kind)).toEqual([ + 'user', + 'status', + 'user', + ]); + expect( + state.blocks.map((block) => ('text' in block ? block.text : '')), + ).toEqual(['/stats', 'stats output', 'fix the bug']); + }); +}); + +describe('status event while a thought block is streaming', () => { + it('keeps the thought block active when clearActiveText is false', () => { + const state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 1 }), + [ + { type: 'user.text.delta', text: 'question' }, + { type: 'thought.text.delta', text: 'thinking' }, + { type: 'status', text: 'mid-stream status', clearActiveText: false }, + { type: 'thought.text.delta', text: ' more' }, + { type: 'assistant.done' }, + ], + { now: 1 }, + ); + + expect(state.blocks.map((block) => block.kind)).toEqual([ + 'user', + 'thought', + 'status', + ]); + const thought = state.blocks[1]; + if (thought.kind !== 'thought') throw new Error('expected thought'); + expect(thought.text).toBe('thinking more'); + }); +}); diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index b539f88c45e..6ed085f1ec9 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -4,8 +4,10 @@ import { act, createRef, type CSSProperties, type ReactNode } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import type { DaemonInputAnnotation, + DaemonSessionContextUsageStatus, DaemonSessionMonitorTaskStatus, DaemonSessionShellTaskStatus, + DaemonSessionStatsStatus, DaemonSettingDescriptor, DaemonWorkspaceGitStatus, } from '@qwen-code/sdk/daemon'; @@ -16,6 +18,9 @@ import type { VoiceWorkspaceTarget, } from './voice/voice-workspace-target'; import type { WebShellComposerToolbarRenderInfo } from './customization'; +import { serializeContextUsageMessage } from './components/messages/ContextUsageMessage'; +import { serializeStatsMessage } from './components/messages/StatsMessage'; +import { serializeStatusMessage } from './components/messages/StatusMessage'; import { loadSplitSessions, saveSplitSessions } from './utils/splitUrl'; type StreamingState = 'idle' | 'responding'; @@ -124,6 +129,7 @@ function sessionWorkflowSetting(): DaemonSettingDescriptor { } const { + mockCollectSystemInfo, mockConnection, mockSessionActions, mockWorkspace, @@ -191,7 +197,9 @@ const { listWorkspaceSessions: vi.fn(() => Promise.resolve([])), }; const settingsSetValue = vi.fn().mockResolvedValue(undefined); + const mockCollectSystemInfo = vi.fn(); return { + mockCollectSystemInfo, mockConnection: connection, mockSessionActions: { sendPrompt: vi.fn().mockResolvedValue(undefined), @@ -216,6 +224,7 @@ const { sendShellCommand: vi.fn().mockResolvedValue(undefined), cancel: vi.fn().mockResolvedValue(undefined), getStats: vi.fn().mockResolvedValue({}), + getContextUsage: vi.fn().mockResolvedValue({}), getTasks: vi.fn().mockResolvedValue({ v: 1, sessionId: 'session-1', @@ -448,6 +457,10 @@ vi.mock('./hooks/useQueuedPrompts', () => ({ }), })); +vi.mock('./utils/systemInfo', () => ({ + collectSystemInfo: mockCollectSystemInfo, +})); + vi.mock('./components/ChatEditor', async () => { const React = await import('react'); return { @@ -2353,6 +2366,7 @@ beforeEach(() => { mockSessionActions.sendShellCommand.mockResolvedValue(undefined); mockSessionActions.cancel.mockResolvedValue(undefined); mockSessionActions.getStats.mockResolvedValue({}); + mockSessionActions.getContextUsage.mockResolvedValue({}); mockSessionActions.getTasks.mockResolvedValue({ v: 1, sessionId: 'session-1', @@ -2367,6 +2381,16 @@ beforeEach(() => { mockWorkspaceActions.loadProviders.mockResolvedValue({ current: null }); mockWorkspaceActions.loadPreflight.mockResolvedValue(null); mockWorkspaceActions.loadEnv.mockResolvedValue(null); + mockCollectSystemInfo.mockImplementation(() => ({ + nodeVersion: '', + npmVersion: '', + authSource: '', + platform: '', + arch: '', + sandbox: '', + proxy: '', + memoryUsage: '', + })); mockWorkspaceActions.loadMcpStatus.mockResolvedValue({ servers: [] }); mockWorkspaceActions.loadMcpTools.mockResolvedValue([]); mockWorkspaceActions.loadMcpResources.mockResolvedValue([]); @@ -3920,6 +3944,296 @@ describe('App shell command queueing', () => { }); }); +describe('App read-only local commands mid-turn', () => { + it('runs /stats immediately while streaming and skips the echo', async () => { + const statsFixture: DaemonSessionStatsStatus = { + v: 1, + sessionId: 'session-1', + workspaceCwd: '/tmp/project', + sessionStartTimeMs: 1000, + durationMs: 42000, + promptCount: 2, + models: {}, + tools: { + totalCalls: 1, + totalSuccess: 1, + totalFail: 0, + totalDurationMs: 120, + byName: {}, + }, + files: { totalLinesAdded: 3, totalLinesRemoved: 1 }, + }; + mockSessionActions.getStats.mockResolvedValue(statsFixture); + const { rerender } = renderApp({}); + await flush(); + + act(() => { + testState.streamingState = 'responding'; + rerender({}); + }); + + let accepted: boolean | void; + await act(async () => { + accepted = testState.latestChatEditorProps?.onSubmit('/stats'); + await vi.waitFor(() => { + expect(mockSessionActions.getStats).toHaveBeenCalled(); + }); + }); + + expect(accepted).toBe(true); + expect(mockStore.appendLocalUserMessage).not.toHaveBeenCalled(); + expect(mockStore.dispatch).toHaveBeenCalledWith([ + expect.objectContaining({ + type: 'status', + clearActiveText: false, + text: serializeStatsMessage(statsFixture, 'overview'), + }), + ]); + }); + + it('echoes /stats when idle', async () => { + renderApp({}); + await flush(); + + let accepted: boolean | void; + await act(async () => { + accepted = testState.latestChatEditorProps?.onSubmit('/stats'); + await vi.waitFor(() => { + expect(mockSessionActions.getStats).toHaveBeenCalled(); + }); + }); + + expect(accepted).toBe(true); + expect(mockStore.appendLocalUserMessage).toHaveBeenCalledWith('/stats'); + }); + + it('runs /about immediately while streaming and skips the echo', async () => { + const { rerender } = renderApp({}); + await flush(); + + act(() => { + testState.streamingState = 'responding'; + rerender({}); + }); + + let accepted: boolean | void; + await act(async () => { + accepted = testState.latestChatEditorProps?.onSubmit('/about'); + await vi.waitFor(() => { + expect(mockWorkspaceActions.loadPreflight).toHaveBeenCalled(); + }); + }); + + expect(accepted).toBe(true); + expect(mockStore.appendLocalUserMessage).not.toHaveBeenCalled(); + expect(mockStore.dispatch).toHaveBeenCalledWith([ + expect.objectContaining({ + type: 'status', + clearActiveText: false, + text: serializeStatusMessage({ + cliVersion: '1.2.3', + runtime: '', + platform: '', + auth: '', + baseUrl: '', + model: 'qwen', + fastModel: 'qwen', + sessionId: 'session-1', + sandbox: '', + proxy: '', + memoryUsage: '', + }), + }), + ]); + }); + + it('echoes /about when idle', async () => { + renderApp({}); + await flush(); + + await act(async () => { + testState.latestChatEditorProps?.onSubmit('/about'); + await vi.waitFor(() => { + expect(mockWorkspaceActions.loadPreflight).toHaveBeenCalled(); + }); + }); + + expect(mockStore.appendLocalUserMessage).toHaveBeenCalledWith('/about'); + }); + + it('runs /status immediately while streaming and skips the echo', async () => { + const { rerender } = renderApp({}); + await flush(); + + act(() => { + testState.streamingState = 'responding'; + rerender({}); + }); + + let accepted: boolean | void; + await act(async () => { + accepted = testState.latestChatEditorProps?.onSubmit('/status'); + await vi.waitFor(() => { + expect(mockWorkspaceActions.loadPreflight).toHaveBeenCalled(); + }); + }); + + expect(accepted).toBe(true); + expect(mockStore.appendLocalUserMessage).not.toHaveBeenCalled(); + expect(mockStore.dispatch).toHaveBeenCalledWith([ + expect.objectContaining({ + type: 'status', + clearActiveText: false, + text: serializeStatusMessage({ + cliVersion: '1.2.3', + runtime: '', + platform: '', + auth: '', + baseUrl: '', + model: 'qwen', + fastModel: 'qwen', + sessionId: 'session-1', + sandbox: '', + proxy: '', + memoryUsage: '', + }), + }), + ]); + }); + + it('echoes /status when idle', async () => { + renderApp({}); + await flush(); + + await act(async () => { + testState.latestChatEditorProps?.onSubmit('/status'); + await vi.waitFor(() => { + expect(mockWorkspaceActions.loadPreflight).toHaveBeenCalled(); + }); + }); + + expect(mockStore.appendLocalUserMessage).toHaveBeenCalledWith('/status'); + }); + + it('runs /context immediately while streaming and skips the echo', async () => { + const contextFixture: DaemonSessionContextUsageStatus = { + v: 1, + sessionId: 'session-1', + workspaceCwd: '/tmp/project', + usage: { + modelName: 'qwen', + totalTokens: 1234, + contextWindowSize: 131072, + breakdown: { + systemPrompt: 500, + builtinTools: 200, + mcpTools: 0, + memoryFiles: 50, + skills: 0, + messages: 584, + freeSpace: 129738, + autocompactBuffer: 0, + }, + builtinTools: [{ name: 'read_file', tokens: 120 }], + mcpTools: [], + memoryFiles: [{ path: 'QWEN.md', tokens: 50 }], + skills: [], + }, + formattedText: 'Context usage: 1.2k / 131k tokens', + }; + mockSessionActions.getContextUsage.mockResolvedValue(contextFixture); + const { rerender } = renderApp({}); + await flush(); + + act(() => { + testState.streamingState = 'responding'; + rerender({}); + }); + + let accepted: boolean | void; + await act(async () => { + accepted = testState.latestChatEditorProps?.onSubmit('/context'); + await vi.waitFor(() => { + expect(mockSessionActions.getContextUsage).toHaveBeenCalled(); + }); + }); + + expect(accepted).toBe(true); + expect(mockStore.appendLocalUserMessage).not.toHaveBeenCalled(); + expect(mockStore.dispatch).toHaveBeenCalledWith([ + expect.objectContaining({ + type: 'status', + clearActiveText: false, + text: serializeContextUsageMessage(contextFixture), + }), + ]); + }); + + it('echoes /context when idle', async () => { + renderApp({}); + await flush(); + + await act(async () => { + testState.latestChatEditorProps?.onSubmit('/context'); + await vi.waitFor(() => { + expect(mockSessionActions.getContextUsage).toHaveBeenCalled(); + }); + }); + + expect(mockStore.appendLocalUserMessage).toHaveBeenCalledWith('/context'); + }); + + it('reports /stats load failures instead of swallowing them', async () => { + renderApp({}); + await flush(); + + mockSessionActions.getStats.mockRejectedValueOnce( + new Error('stats unavailable'), + ); + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + await act(async () => { + testState.latestChatEditorProps?.onSubmit('/stats'); + await vi.waitFor(() => { + expect(consoleError).toHaveBeenCalledWith( + '[web-shell]', + expect.stringContaining('stats unavailable'), + expect.anything(), + ); + }); + }); + + consoleError.mockRestore(); + }); + + it('reports /about load failures instead of swallowing them', async () => { + renderApp({}); + await flush(); + + mockCollectSystemInfo.mockImplementationOnce(() => { + throw new Error('status unavailable'); + }); + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + await act(async () => { + testState.latestChatEditorProps?.onSubmit('/about'); + await vi.waitFor(() => { + expect(consoleError).toHaveBeenCalledWith( + '[web-shell]', + expect.stringContaining('status unavailable'), + expect.anything(), + ); + }); + }); + + consoleError.mockRestore(); + }); +}); + describe('App session callbacks', () => { it('binds the main composer Voice target to its active secondary session', async () => { mockConnection.workspaceCwd = '/work/secondary'; diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index de51cff9aed..9691a82bccc 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -4995,7 +4995,10 @@ export function App({ // Echo a local command into the transcript, or suppress it while a turn is // streaming so the injected user row can't split the active turn (see // appendOrDeferLocalUserMessage). Returns true when suppressed — callers must - // then stop and not run the command's inline side effects. + // then stop and not run the command's inline side effects. Exception: + // read-only display commands wrap this in echoLocalCommandIfIdle and + // intentionally ignore the suppression signal — their status-block output + // does not split the active turn. const echoOrDeferLocalCommand = useCallback( (text: string, images?: PromptImage[]): boolean => appendOrDeferLocalUserMessage( @@ -5009,6 +5012,29 @@ export function App({ [store], ); + // Echo a local command when idle, but never block it. Read-only display + // commands (/stats, /about, /context) render their result as a status + // block, which does not split the active turn, so they run immediately + // mid-turn with only the echo skipped. + const echoLocalCommandIfIdle = useCallback( + (text: string): void => { + void echoOrDeferLocalCommand(text); + }, + [echoOrDeferLocalCommand], + ); + + // Shared result dispatch for those read-only commands: `clearActiveText: + // false` keeps the status block from finalizing the in-flight assistant + // block mid-stream, which would split the streaming answer and orphan its + // usage frames. + const dispatchReadOnlyStatus = useCallback( + (text: string) => { + store.dispatch([{ type: 'status', text, clearActiveText: false }]); + resumeChatBottomFollow('smooth'); + }, + [store, resumeChatBottomFollow], + ); + const blockLocalCommandDuringTurn = useCallback((): false => { pushToast('error', t('queue.commandBlocked')); return false; @@ -5872,37 +5898,32 @@ export function App({ }, [currentMode, handleSetMode]); // Shared by the /context slash command and the status-bar context - // indicator. Echoes the command as a local user message first — that also - // makes the transcript follow the tail (MessageList Rule 4), so the panel - // is revealed even when the click comes while scrolled up. + // indicator. Echoes the command when idle — that also makes the transcript + // follow the tail (MessageList Rule 4). Mid-turn the echo is skipped and + // dispatchReadOnlyStatus's resumeChatBottomFollow resumes bottom-follow, + // so the panel is revealed even when the click comes while scrolled up. const showContextUsage = useCallback( (commandText: string, detail: boolean) => { - // Self-guard so every entry point (keyboard, status-bar button, in-chat - // "context detail" click) defers mid-turn instead of splitting the turn. + // Read-only: every entry point (keyboard, status-bar button, in-chat + // "context detail" click) runs immediately, even mid-turn — only the + // echo is skipped while streaming so the active turn is not split. if (!requireActiveSessionForLocalCommand()) return; - if (echoOrDeferLocalCommand(commandText)) return; + echoLocalCommandIfIdle(commandText); sessionActions .getContextUsage({ detail }) .then((result) => { - store.dispatch([ - { - type: 'status', - text: serializeContextUsageMessage(result), - }, - ]); - resumeChatBottomFollow('smooth'); + dispatchReadOnlyStatus(serializeContextUsageMessage(result)); }) .catch((error: unknown) => { reportError(error, 'Failed to load context usage'); }); }, [ - echoOrDeferLocalCommand, - store, + echoLocalCommandIfIdle, + dispatchReadOnlyStatus, requireActiveSessionForLocalCommand, sessionActions, reportError, - resumeChatBottomFollow, ], ); @@ -7586,84 +7607,84 @@ export function App({ if (statsArg === 'model') statsView = 'model'; else if (statsArg === 'tools') statsView = 'tools'; if (!requireActiveSessionForLocalCommand()) return false; - if (echoOrDeferLocalCommand(text, images)) return true; + echoLocalCommandIfIdle(text); sessionActions .getStats() .then((result) => { - store.dispatch([ - { - type: 'status', - text: serializeStatsMessage(result, statsView), - }, - ]); - resumeChatBottomFollow('smooth'); + dispatchReadOnlyStatus( + serializeStatsMessage(result, statsView), + ); }) - .catch(() => {}); + .catch((error: unknown) => { + reportError(error, 'Failed to load stats'); + }); return true; } if (cmd === 'status' || cmd === 'about') { - if (echoOrDeferLocalCommand(text, images)) return true; + echoLocalCommandIfIdle(text); Promise.all([ workspaceActions.loadPreflight().catch(() => null), workspaceActions.loadProviders().catch(() => null), workspaceActions.loadEnv().catch(() => null), - ]).then(([preflight, providers, env]) => { - const sys = collectSystemInfo(preflight, env); - - let authSource = sys.authSource; - if (!authSource && providers?.current?.authType) { - authSource = providers.current.authType; - } - - const runtimeParts: string[] = []; - if (sys.nodeVersion) - runtimeParts.push(`Node.js v${sys.nodeVersion}`); - if (sys.npmVersion) runtimeParts.push(`npm ${sys.npmVersion}`); + ]) + .then(([preflight, providers, env]) => { + const sys = collectSystemInfo(preflight, env); - let formattedAuth = ''; - if (authSource) { - if ( - authSource.startsWith('oauth') || - authSource === 'qwen-oauth' - ) { - formattedAuth = 'Qwen OAuth'; - } else { - formattedAuth = `API Key - ${authSource}`; + let authSource = sys.authSource; + if (!authSource && providers?.current?.authType) { + authSource = providers.current.authType; } - } - const platformStr = `${sys.platform} ${sys.arch}`.trim(); - const curModel = currentModelRef.current; - const conn = connectionRef.current; - const qwenCodeVersion = conn.capabilities?.qwenCodeVersion || ''; - const info: StatusInfo = { - cliVersion: qwenCodeVersion, - runtime: runtimeParts.join(' / '), - platform: platformStr, - auth: formattedAuth, - baseUrl: providers?.current?.baseUrl || '', - model: - curModel || - conn.currentModel || - providers?.current?.modelId || - '', - fastModel: - providers?.current?.fastModelId || - curModel || - conn.currentModel || - providers?.current?.modelId || - '', - sessionId: conn.sessionId || '', - sandbox: sys.sandbox, - proxy: sys.proxy, - memoryUsage: sys.memoryUsage, - }; + const runtimeParts: string[] = []; + if (sys.nodeVersion) + runtimeParts.push(`Node.js v${sys.nodeVersion}`); + if (sys.npmVersion) runtimeParts.push(`npm ${sys.npmVersion}`); + + let formattedAuth = ''; + if (authSource) { + if ( + authSource.startsWith('oauth') || + authSource === 'qwen-oauth' + ) { + formattedAuth = 'Qwen OAuth'; + } else { + formattedAuth = `API Key - ${authSource}`; + } + } - store.dispatch([ - { type: 'status', text: serializeStatusMessage(info) }, - ]); - resumeChatBottomFollow('smooth'); - }); + const platformStr = `${sys.platform} ${sys.arch}`.trim(); + const curModel = currentModelRef.current; + const conn = connectionRef.current; + const qwenCodeVersion = + conn.capabilities?.qwenCodeVersion || ''; + const info: StatusInfo = { + cliVersion: qwenCodeVersion, + runtime: runtimeParts.join(' / '), + platform: platformStr, + auth: formattedAuth, + baseUrl: providers?.current?.baseUrl || '', + model: + curModel || + conn.currentModel || + providers?.current?.modelId || + '', + fastModel: + providers?.current?.fastModelId || + curModel || + conn.currentModel || + providers?.current?.modelId || + '', + sessionId: conn.sessionId || '', + sandbox: sys.sandbox, + proxy: sys.proxy, + memoryUsage: sys.memoryUsage, + }; + + dispatchReadOnlyStatus(serializeStatusMessage(info)); + }) + .catch((error: unknown) => { + reportError(error, 'Failed to load status info'); + }); return true; } if (cmd === 'bug') { @@ -7805,6 +7826,8 @@ export function App({ store, enqueuePrompt, echoOrDeferLocalCommand, + echoLocalCommandIfIdle, + dispatchReadOnlyStatus, branchCurrentSession, closeMobileDrawer, closePanel, diff --git a/packages/web-shell/client/utils/localCommandQueue.ts b/packages/web-shell/client/utils/localCommandQueue.ts index 377f4ec8b64..62fe0368030 100644 --- a/packages/web-shell/client/utils/localCommandQueue.ts +++ b/packages/web-shell/client/utils/localCommandQueue.ts @@ -17,7 +17,10 @@ import type { PromptImage } from '../adapters/promptTypes'; * * The only call sites that should bypass this and append mid-stream are the * deliberate "busy acknowledgement" paths (e.g. clearing a goal while a turn - * runs), which opt in by calling `append` directly. + * runs), which opt in by calling `append` directly. Read-only display + * commands (/stats, /about, /context) go through the same helper but ignore + * its suppression signal: they skip the echo mid-turn and still run + * immediately. */ export interface LocalEchoSink { /** Append the command as a local user message (renders inline immediately). */ @@ -29,7 +32,8 @@ export interface LocalEchoSink { * * @returns `true` if the command was suppressed — the caller must stop and not * run its inline side effects. `false` if it was appended and the caller - * should proceed. + * should proceed. Read-only display commands are the deliberate exception: + * they ignore the signal and run mid-turn anyway (see the module docstring). */ export function appendOrDeferLocalUserMessage( isStreaming: boolean,