From 6a29f3df2d04d7db26eb1e162281f3a10eef5a22 Mon Sep 17 00:00:00 2001 From: ytahdn Date: Mon, 1 Jun 2026 19:10:23 +0800 Subject: [PATCH 1/8] fix(web-shell): refine input and tool display --- packages/web-shell/client/App.module.css | 2 + packages/web-shell/client/App.tsx | 13 +- .../client/components/Editor.test.ts | 73 +++++++++++ .../web-shell/client/components/Editor.tsx | 113 +++++++++++++++++- .../client/components/messages/ToolGroup.tsx | 26 +--- .../components/messages/toolFormatting.ts | 2 +- packages/web-shell/client/config/daemon.ts | 1 + 7 files changed, 199 insertions(+), 31 deletions(-) create mode 100644 packages/web-shell/client/components/Editor.test.ts diff --git a/packages/web-shell/client/App.module.css b/packages/web-shell/client/App.module.css index e437213f0cc..86b18f2c05b 100644 --- a/packages/web-shell/client/App.module.css +++ b/packages/web-shell/client/App.module.css @@ -145,6 +145,8 @@ } .queuedPrompt { + min-width: 0; + max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 4514a5ebdbe..faef8f0cf31 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -80,6 +80,7 @@ export const CompactModeContext = createContext(false); const WEB_SHELL_VERSION = __WEB_SHELL_VERSION__; const MODES_CYCLE = DAEMON_APPROVAL_MODES; const MAX_DISPLAYED_QUEUED_PROMPTS = 3; +const MAX_QUEUED_PROMPT_PREVIEW_CHARS = 240; interface QueuedPrompt { id: number; @@ -120,8 +121,10 @@ function replaceSessionUrl(sessionId: string): void { if (typeof window === 'undefined') return; const url = new URL(window.location.href); url.pathname = `/session/${encodeURIComponent(sessionId)}`; - url.searchParams.delete('token'); - url.searchParams.delete('daemon'); + if (!import.meta.env.DEV) { + url.searchParams.delete('token'); + url.searchParams.delete('daemon'); + } window.history.replaceState(null, '', url); } @@ -269,7 +272,11 @@ function QueuedPromptDisplay({ return (
{prompts.slice(0, MAX_DISPLAYED_QUEUED_PROMPTS).map((prompt) => { - const preview = prompt.text.replace(/\s+/g, ' '); + const normalizedPreview = prompt.text.replace(/\s+/g, ' ').trim(); + const preview = + normalizedPreview.length > MAX_QUEUED_PROMPT_PREVIEW_CHARS + ? `${normalizedPreview.slice(0, MAX_QUEUED_PROMPT_PREVIEW_CHARS)}...` + : normalizedPreview; const imageCount = prompt.images?.length ?? 0; return (
diff --git a/packages/web-shell/client/components/Editor.test.ts b/packages/web-shell/client/components/Editor.test.ts new file mode 100644 index 00000000000..6aa4f818457 --- /dev/null +++ b/packages/web-shell/client/components/Editor.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { + createLargePastePlaceholder, + expandLargePastePlaceholders, + isLargePaste, + normalizePastedText, + prunePendingPastes, +} from './Editor'; + +describe('Editor large paste helpers', () => { + it('normalizes pasted newlines before threshold checks', () => { + const pasted = 'a\r\nb\rc'; + + expect(normalizePastedText(pasted)).toBe('a\nb\nc'); + }); + + it('treats long or multi-line pasted text as a large paste', () => { + expect(isLargePaste('x'.repeat(1001))).toBe(true); + expect(isLargePaste(Array.from({ length: 11 }, () => 'x').join('\n'))).toBe( + true, + ); + expect(isLargePaste('short\ntext')).toBe(false); + }); + + it('creates stable placeholders and expands them on submit', () => { + const pendingPastes = new Map(); + const firstPaste = 'first pasted block'; + const secondPaste = 'second pasted block'; + + const first = createLargePastePlaceholder(pendingPastes, 1, firstPaste); + const second = createLargePastePlaceholder( + pendingPastes, + first.nextPasteId, + secondPaste, + ); + + expect(first.placeholderText).toBe('[Pasted Content 18 chars]'); + expect(second.placeholderText).toBe('[Pasted Content 19 chars] #2'); + expect(second.nextPasteId).toBe(3); + expect( + expandLargePastePlaceholders( + pendingPastes, + `before ${first.placeholderText} middle ${second.placeholderText} after`, + ), + ).toBe(`before ${firstPaste} middle ${secondPaste} after`); + }); + + it('removes deleted placeholders and resets the counter once none remain', () => { + const pendingPastes = new Map(); + const first = createLargePastePlaceholder(pendingPastes, 1, 'first'); + const second = createLargePastePlaceholder( + pendingPastes, + first.nextPasteId, + 'second', + ); + + expect( + prunePendingPastes(pendingPastes, second.placeholderText), + ).toBeNull(); + expect([...pendingPastes.keys()]).toEqual([second.placeholderText]); + expect(prunePendingPastes(pendingPastes, '')).toBe(1); + expect(pendingPastes.size).toBe(0); + }); + + it('leaves unknown placeholder-shaped text unchanged', () => { + expect( + expandLargePastePlaceholders( + new Map(), + 'keep [Pasted Content 10 chars] as text', + ), + ).toBe('keep [Pasted Content 10 chars] as text'); + }); +}); diff --git a/packages/web-shell/client/components/Editor.tsx b/packages/web-shell/client/components/Editor.tsx index e341e3dddb5..b5c07104c65 100644 --- a/packages/web-shell/client/components/Editor.tsx +++ b/packages/web-shell/client/components/Editor.tsx @@ -69,6 +69,67 @@ export interface EditorHandle { const editableCompartment = new Compartment(); const placeholderCompartment = new Compartment(); +const LARGE_PASTE_CHAR_THRESHOLD = 1000; +const LARGE_PASTE_LINE_THRESHOLD = 10; + +export function normalizePastedText(text: string): string { + return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); +} + +export function isLargePaste(text: string): boolean { + return ( + [...text].length > LARGE_PASTE_CHAR_THRESHOLD || + text.split('\n').length > LARGE_PASTE_LINE_THRESHOLD + ); +} + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export interface LargePastePlaceholderResult { + placeholderText: string; + nextPasteId: number; +} + +export function createLargePastePlaceholder( + pendingPastes: Map, + nextPasteId: number, + pasted: string, +): LargePastePlaceholderResult { + const charCount = [...pasted].length; + const base = `[Pasted Content ${charCount} chars]`; + const placeholderText = nextPasteId === 1 ? base : `${base} #${nextPasteId}`; + pendingPastes.set(placeholderText, pasted); + return { placeholderText, nextPasteId: nextPasteId + 1 }; +} + +export function prunePendingPastes( + pendingPastes: Map, + docText: string, +): number | null { + for (const placeholderText of pendingPastes.keys()) { + if (!docText.includes(placeholderText)) { + pendingPastes.delete(placeholderText); + } + } + return pendingPastes.size === 0 ? 1 : null; +} + +export function expandLargePastePlaceholders( + pendingPastes: Map, + text: string, +): string { + if (pendingPastes.size === 0) return text; + const placeholders = [...pendingPastes.keys()].sort( + (a, b) => b.length - a.length, + ); + const pattern = new RegExp(placeholders.map(escapeRegExp).join('|'), 'g'); + return text.replace( + pattern, + (placeholderText) => pendingPastes.get(placeholderText) ?? placeholderText, + ); +} function getModeClass(mode: string, shellMode: boolean): string { if (shellMode) return ''; @@ -152,6 +213,8 @@ export const Editor = forwardRef(function Editor( const searchDraftRef = useRef(''); const [pastedImages, setPastedImages] = useState([]); const pastedImagesRef = useRef([]); + const pendingPastesRef = useRef>(new Map()); + const nextPasteIdRef = useRef(1); const promptHistory = useInputHistory(); const shellHistory = useInputHistory('qwen-web-shell-command-history'); @@ -194,8 +257,12 @@ export const Editor = forwardRef(function Editor( if (!containerRef.current) return; const submitText = (view: EditorView, textOverride?: string) => { - const text = (textOverride ?? view.state.doc.toString()).trim(); - if (!text) return true; + const rawText = (textOverride ?? view.state.doc.toString()).trim(); + if (!rawText) return true; + const text = expandLargePastePlaceholders( + pendingPastesRef.current, + rawText, + ); const images = pastedImagesRef.current; const isShellMode = shellModeRef.current; const accepted = onSubmitRef.current( @@ -204,6 +271,8 @@ export const Editor = forwardRef(function Editor( ); if (accepted === false) return true; onDismissFollowupRef.current?.(); + pendingPastesRef.current.clear(); + nextPasteIdRef.current = 1; if (isShellMode) { shellHistoryActionsRef.current.push(text); shellHistoryActionsRef.current.reset(); @@ -429,6 +498,15 @@ export const Editor = forwardRef(function Editor( if (!update.docChanged) { return; } + if (pendingPastesRef.current.size > 0) { + const nextPasteId = prunePendingPastes( + pendingPastesRef.current, + update.state.doc.toString(), + ); + if (nextPasteId !== null) { + nextPasteIdRef.current = nextPasteId; + } + } const selection = update.state.selection.main; if (!selection.empty) return; const line = update.state.doc.lineAt(selection.head); @@ -530,7 +608,36 @@ export const Editor = forwardRef(function Editor( event.preventDefault(); return true; } - return false; + const pasted = normalizePastedText( + event.clipboardData?.getData('text/plain') ?? '', + ); + if (!pasted || !isLargePaste(pasted)) return false; + + event.preventDefault(); + if ( + view.state.doc.toString() === '' && + followupStateRef.current?.isVisible + ) { + onDismissFollowupRef.current?.(); + } + const { placeholderText, nextPasteId } = + createLargePastePlaceholder( + pendingPastesRef.current, + nextPasteIdRef.current, + pasted, + ); + nextPasteIdRef.current = nextPasteId; + const selection = view.state.selection.main; + view.dispatch({ + changes: { + from: selection.from, + to: selection.to, + insert: placeholderText, + }, + selection: { anchor: selection.from + placeholderText.length }, + scrollIntoView: true, + }); + return true; }, }), EditorView.theme({ diff --git a/packages/web-shell/client/components/messages/ToolGroup.tsx b/packages/web-shell/client/components/messages/ToolGroup.tsx index 1bf65744a8c..fd48fc3fc78 100644 --- a/packages/web-shell/client/components/messages/ToolGroup.tsx +++ b/packages/web-shell/client/components/messages/ToolGroup.tsx @@ -145,7 +145,7 @@ function ExpandedBashOutput({ tool }: { tool: ACPToolCall }) { const [showAll, setShowAll] = useState(false); const output = useMemo(() => extractText(tool) || '', [tool]); const lines = useMemo(() => output.split('\n'), [output]); - const isUserShell = isShellToolName(tool.toolName); + const isUserShell = tool.toolName === 'shell'; const isLong = lines.length > MAX_BASH_LINES; const hiddenLinesCount = Math.max(0, lines.length - MAX_BASH_LINES); const displayText = useMemo( @@ -247,28 +247,6 @@ function getWriteContent(tool: ACPToolCall): string { return ''; } -function ExpandedWriteContent({ tool }: { tool: ACPToolCall }) { - const content = useMemo(() => getWriteContent(tool), [tool]); - const lines = useMemo(() => { - const nextLines = content.split('\n'); - if (nextLines.length > 0 && nextLines[nextLines.length - 1] === '') { - nextLines.pop(); - } - return nextLines; - }, [content]); - if (!content) return null; - - return ( -
-
-        {lines.map((line, i) => (
-          {`+ ${line}\n`}
-        ))}
-      
-
- ); -} - function TodoWriteContent({ tool }: { tool: ACPToolCall }) { const todos = extractTodosFromToolCall(tool); if (todos) { @@ -653,7 +631,7 @@ const ToolLine = memo(function ToolLine({
{isShellToolName(name) && } {(name === 'write_file' || name === 'writefile') && ( - + )} {(name === 'edit' || name === 'write' || name === 'editfile') && ( diff --git a/packages/web-shell/client/components/messages/toolFormatting.ts b/packages/web-shell/client/components/messages/toolFormatting.ts index 2d1bd6b87b2..8690925f62e 100644 --- a/packages/web-shell/client/components/messages/toolFormatting.ts +++ b/packages/web-shell/client/components/messages/toolFormatting.ts @@ -121,7 +121,7 @@ export function getToolResultSummary(tool: ACPToolCall): string { } if (name === 'ask_user_question') { - return text; + return ''; } const firstLine = lines[0] || ''; diff --git a/packages/web-shell/client/config/daemon.ts b/packages/web-shell/client/config/daemon.ts index a5f1aa6ee31..83f705f5bc2 100644 --- a/packages/web-shell/client/config/daemon.ts +++ b/packages/web-shell/client/config/daemon.ts @@ -21,6 +21,7 @@ export function getDaemonToken(): string | undefined { export function removeDaemonTokenFromUrl(): void { if (typeof window === 'undefined') return; + if (import.meta.env.DEV) return; const url = new URL(window.location.href); if (!url.searchParams.has('token')) return; url.searchParams.delete('token'); From 4d086f483a6d7e15ef23b5745ea12df0dae5ac41 Mon Sep 17 00:00:00 2001 From: ytahdn Date: Mon, 1 Jun 2026 20:28:28 +0800 Subject: [PATCH 2/8] fix(web-shell): align permission approval display --- .../components/dialogs/HelpDialog.module.css | 7 ++- .../client/components/dialogs/HelpDialog.tsx | 2 +- .../messages/ToolApproval.module.css | 18 ++++++- .../components/messages/ToolApproval.tsx | 53 ++++++++++++++++--- .../client/components/messages/ToolGroup.tsx | 30 +++++++++-- .../messages/tools/ToolChrome.module.css | 1 + packages/web-shell/client/i18n.tsx | 18 +++---- 7 files changed, 102 insertions(+), 27 deletions(-) diff --git a/packages/web-shell/client/components/dialogs/HelpDialog.module.css b/packages/web-shell/client/components/dialogs/HelpDialog.module.css index 495d023c410..bf995dbdf1c 100644 --- a/packages/web-shell/client/components/dialogs/HelpDialog.module.css +++ b/packages/web-shell/client/components/dialogs/HelpDialog.module.css @@ -62,13 +62,16 @@ display: grid; grid-template-columns: minmax(92px, 140px) minmax(0, 1fr); gap: 8px; - align-items: baseline; + align-items: start; min-width: 0; + line-height: 1.35; } .shortcutKey { color: var(--accent-color); - white-space: nowrap; + min-width: 0; + overflow-wrap: anywhere; + white-space: normal; } .shortcutDesc { diff --git a/packages/web-shell/client/components/dialogs/HelpDialog.tsx b/packages/web-shell/client/components/dialogs/HelpDialog.tsx index 6ace5f49d31..464d05e097a 100644 --- a/packages/web-shell/client/components/dialogs/HelpDialog.tsx +++ b/packages/web-shell/client/components/dialogs/HelpDialog.tsx @@ -92,7 +92,7 @@ const GENERAL_SHORTCUTS: Array<[string, string]> = [ ['/', 'help.shortcut.commandMenu'], ['Tab', 'help.shortcut.completion'], ['Esc', 'help.shortcut.cancel'], - ['Shift+Enter / Ctrl+J', 'help.shortcut.newline'], + ['Ctrl+J', 'help.shortcut.newline'], ['Ctrl+L', 'help.shortcut.clear'], ['Ctrl+Y', 'help.shortcut.retry'], ['Ctrl+O', 'help.shortcut.compact'], diff --git a/packages/web-shell/client/components/messages/ToolApproval.module.css b/packages/web-shell/client/components/messages/ToolApproval.module.css index cb3b9dc8258..71afddb08f5 100644 --- a/packages/web-shell/client/components/messages/ToolApproval.module.css +++ b/packages/web-shell/client/components/messages/ToolApproval.module.css @@ -1,9 +1,10 @@ .approval { - margin: 4px 14px; - padding: 8px 0 8px 12px; + margin: 4px 0px 4px 14px; + padding: 8px 8px 8px 12px; border: 1px solid var(--border-color); font-family: var(--font-mono, monospace); font-size: 13px; + border-radius: var(--radius); } .header { @@ -11,20 +12,33 @@ align-items: baseline; gap: 8px; margin-bottom: 6px; + min-width: 0; + overflow: hidden; + white-space: nowrap; } .icon { font-weight: 700; color: var(--warning-color); + flex-shrink: 0; } .name { font-weight: 700; color: var(--text-primary); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .desc { color: var(--text-secondary); + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .content { diff --git a/packages/web-shell/client/components/messages/ToolApproval.tsx b/packages/web-shell/client/components/messages/ToolApproval.tsx index 579fbd1facb..cfd22315839 100644 --- a/packages/web-shell/client/components/messages/ToolApproval.tsx +++ b/packages/web-shell/client/components/messages/ToolApproval.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import type { PermissionRequest } from '../../adapters/types'; import { useI18n } from '../../i18n'; import { isEditableTarget } from '../../utils/dom'; @@ -70,6 +70,36 @@ function getSafeDefaultIndex(options: PermissionRequest['options']): number { return 0; } +function getOptionRank(option: PermissionRequest['options'][number]): number { + if (option.kind === 'allow_once') return 0; + if ( + option.kind === 'allow_always' && + option.id === 'proceed_always_project' + ) { + return 1; + } + if (option.kind === 'allow_always' && option.id === 'proceed_always_user') { + return 2; + } + if (option.kind === 'allow_always') return 3; + if (option.kind === 'reject_once' || option.kind === 'reject_always') { + return 4; + } + return 5; +} + +function orderPermissionOptions( + options: PermissionRequest['options'], +): PermissionRequest['options'] { + return options + .map((option, index) => ({ option, index })) + .sort((a, b) => { + const rankDelta = getOptionRank(a.option) - getOptionRank(b.option); + return rankDelta === 0 ? a.index - b.index : rankDelta; + }) + .map(({ option }) => option); +} + function getOptionI18nKey( option: PermissionRequest['options'][number], ): string | undefined { @@ -87,8 +117,12 @@ function getOptionI18nKey( export function ToolApproval({ request, onConfirm }: ToolApprovalProps) { const { t } = useI18n(); + const displayOptions = useMemo( + () => orderPermissionOptions(request.options), + [request.options], + ); const [selected, setSelected] = useState(() => - getSafeDefaultIndex(request.options), + getSafeDefaultIndex(orderPermissionOptions(request.options)), ); const requestRef = useRef(request); requestRef.current = request; @@ -97,7 +131,9 @@ export function ToolApproval({ request, onConfirm }: ToolApprovalProps) { const interactedRef = useRef(false); useEffect(() => { - const safeDefaultIndex = getSafeDefaultIndex(requestRef.current.options); + const safeDefaultIndex = getSafeDefaultIndex( + orderPermissionOptions(requestRef.current.options), + ); submittedRef.current = false; interactedRef.current = false; selectedRef.current = safeDefaultIndex; @@ -120,7 +156,8 @@ export function ToolApproval({ request, onConfirm }: ToolApprovalProps) { (e: KeyboardEvent) => { if (e.defaultPrevented || isEditableTarget(e.target)) return; const currentRequest = requestRef.current; - const optCount = currentRequest.options.length; + const currentOptions = orderPermissionOptions(currentRequest.options); + const optCount = currentOptions.length; if (e.key === 'ArrowUp' || e.key === 'k') { e.preventDefault(); interactedRef.current = true; @@ -143,7 +180,7 @@ export function ToolApproval({ request, onConfirm }: ToolApprovalProps) { interactedRef.current = true; return; } - const option = currentRequest.options[selectedRef.current]; + const option = currentOptions[selectedRef.current]; if (option) confirm(option.id); } else if (e.key === 'Escape') { e.preventDefault(); @@ -156,7 +193,7 @@ export function ToolApproval({ request, onConfirm }: ToolApprovalProps) { if (idx < optCount) { e.preventDefault(); interactedRef.current = true; - confirm(currentRequest.options[idx].id); + confirm(currentOptions[idx].id); } } }, @@ -188,7 +225,7 @@ export function ToolApproval({ request, onConfirm }: ToolApprovalProps) {
{command}
- ) : contentText ? ( + ) : contentText && contentText !== request.title ? (
{contentText}
) : null} @@ -199,7 +236,7 @@ export function ToolApproval({ request, onConfirm }: ToolApprovalProps) {
- {request.options.map((option, i) => { + {displayOptions.map((option, i) => { const isSelected = i === selected; const i18nKey = getOptionI18nKey(option); const label = i18nKey ? t(i18nKey) : option.label; diff --git a/packages/web-shell/client/components/messages/ToolGroup.tsx b/packages/web-shell/client/components/messages/ToolGroup.tsx index fd48fc3fc78..18dfb3f0e17 100644 --- a/packages/web-shell/client/components/messages/ToolGroup.tsx +++ b/packages/web-shell/client/components/messages/ToolGroup.tsx @@ -502,13 +502,13 @@ const ToolLine = memo(function ToolLine({ // eslint-disable-next-line react-hooks/exhaustive-deps [compactMode, tool.callId, tool.toolName], ); + const isAgent = isSubAgentToolCall(tool); const hasApproval = approval && approval.toolCallId === tool.callId; const hasSubToolApproval = !hasApproval && approval?.toolCallId && - isSubAgentToolCall(tool) && + isAgent && toolContainsCallId(tool, approval.toolCallId); - const isAgent = isSubAgentToolCall(tool); const isRunningAgent = isAgent && tool.status === 'in_progress'; useEffect(() => { @@ -519,6 +519,14 @@ const ToolLine = memo(function ToolLine({ }, [isRunningAgent]); if (isAgent) { + if (hasApproval && onConfirm) { + return ( +
+ +
+ ); + } + const info = getAgentDisplayInfo(tool, now); const isComplete = tool.status === 'completed' || tool.status === 'failed'; const toolHint = getAgentCurrentToolHint(tool); @@ -607,6 +615,14 @@ const ToolLine = memo(function ToolLine({ const name = tool.toolName.toLowerCase(); const isTodo = name === 'todowrite'; + if (hasApproval && onConfirm) { + return ( +
+ +
+ ); + } + return (
{description}} {elapsed && {elapsed}}
- {hasApproval && onConfirm && ( - - )} {isTodo && } {!isTodo && !expanded && result && (
{result}
@@ -652,11 +665,18 @@ export const ToolGroup = memo(function ToolGroup({ workspaceCwd, }: ToolGroupProps) { const compactMode = useContext(CompactModeContext); + const directApprovalTool = + pendingApproval?.toolCallId && + tools.find((t) => t.callId === pendingApproval.toolCallId); const hasApprovalTool = pendingApproval?.toolCallId && tools.some((t) => toolContainsCallId(t, pendingApproval.toolCallId!)); const showCompact = compactMode && !hasApprovalTool; + if (directApprovalTool && tools.length === 1 && onConfirm) { + return ; + } + if (showCompact) { return ; } diff --git a/packages/web-shell/client/components/messages/tools/ToolChrome.module.css b/packages/web-shell/client/components/messages/tools/ToolChrome.module.css index 77e11146606..ac4dd481953 100644 --- a/packages/web-shell/client/components/messages/tools/ToolChrome.module.css +++ b/packages/web-shell/client/components/messages/tools/ToolChrome.module.css @@ -149,6 +149,7 @@ display: block; width: 100%; padding: 3px 10px; + margin-top: 10px; font-size: 11px; font-family: var(--font-mono); background: var(--subtle-bg); diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 88cbefefdf5..fde6030c4c1 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -49,11 +49,11 @@ const EN: Messages = { 'request.cancelled': 'Request cancelled.', 'approval.execQuestion': (v) => `Allow execution of: '${v?.tool ?? ''}'?`, 'approval.changeQuestion': 'Apply this change?', - 'approval.option.allowOnce': 'Allow', - 'approval.option.rejectOnce': 'Reject', + 'approval.option.allowOnce': 'Yes, allow once', + 'approval.option.rejectOnce': 'No, suggest changes (esc)', 'approval.option.allowAllEdits': 'Allow All Edits', - 'approval.option.allowAlwaysProject': 'Always Allow in project', - 'approval.option.allowAlwaysUser': 'Always Allow for user', + 'approval.option.allowAlwaysProject': 'Always allow in this project', + 'approval.option.allowAlwaysUser': 'Always allow for this user', 'common.back': 'back', 'common.cancel': 'cancel', 'common.close': 'close', @@ -459,12 +459,12 @@ const ZH: Messages = { 'activeAgents.tools': (v) => `${v?.count ?? 0} 个工具`, 'request.cancelled': '请求已取消。', 'approval.execQuestion': (v) => `允许执行:'${v?.tool ?? ''}'?`, - 'approval.changeQuestion': '应用此更改?', - 'approval.option.allowOnce': '允许', - 'approval.option.rejectOnce': '拒绝', + 'approval.changeQuestion': '是否继续?', + 'approval.option.allowOnce': '是,允许一次', + 'approval.option.rejectOnce': '否,建议更改 (esc)', 'approval.option.allowAllEdits': '允许所有编辑', - 'approval.option.allowAlwaysProject': '项目级始终允许', - 'approval.option.allowAlwaysUser': '用户级始终允许', + 'approval.option.allowAlwaysProject': '在本项目中总是允许', + 'approval.option.allowAlwaysUser': '对该用户总是允许', 'common.back': '返回', 'common.cancel': '取消', 'common.close': '关闭', From 7fa605e5e2c3b9f65c186709c66c9a7b704b0c0c Mon Sep 17 00:00:00 2001 From: ytahdn Date: Tue, 2 Jun 2026 20:28:32 +0800 Subject: [PATCH 3/8] feat(web-shell): add inline insight progress, slash command UI, and auto-scroll fix - Parse insight protocol JSON from ACP session into typed messages (insight_progress / insight_ready) and render inline progress bar with spinner matching CLI display - Consolidate multiple progress updates to show only the latest; hide progress bar once the report is ready - Add slash command message rendering: /stats, /model, /memory, /mcp, /agents, /btw, /status, /user-shell with dedicated cards - Fix auto-scroll breaking when tool cards, SubAgent panels, or TodoList cards appear by adding scroll cooldown mechanism - Extend daemon SDK with agent management and MCP workspace APIs - Add slash command completions with inline descriptions --- packages/acp-bridge/src/bridge.ts | 65 + packages/acp-bridge/src/bridgeTypes.ts | 26 + packages/acp-bridge/src/status.ts | 74 +- packages/cli/src/acp-integration/acpAgent.ts | 482 ++++- .../src/acp-integration/session/Session.ts | 34 +- packages/cli/src/serve/capabilities.ts | 3 + packages/cli/src/serve/envSnapshot.ts | 11 +- packages/cli/src/serve/server.ts | 61 + packages/cli/src/serve/workspaceAgents.ts | 43 + packages/core/src/models/modelsConfig.ts | 3 + .../sdk-typescript/src/daemon/DaemonClient.ts | 91 + .../src/daemon/DaemonSessionClient.ts | 16 + packages/sdk-typescript/src/daemon/index.ts | 8 + packages/sdk-typescript/src/daemon/types.ts | 98 +- .../sdk-typescript/src/daemon/ui/index.ts | 1 + .../src/daemon/ui/normalizer.ts | 27 +- .../sdk-typescript/src/daemon/ui/terminal.ts | 10 + .../src/daemon/ui/transcript.ts | 54 + .../sdk-typescript/src/daemon/ui/types.ts | 31 + .../sdk-typescript/test/unit/daemonUi.test.ts | 55 + packages/web-shell/client/App.module.css | 5 + packages/web-shell/client/App.tsx | 789 ++++++- packages/web-shell/client/adapters/types.ts | 2 + .../completions/slashCompletion.test.ts | 107 + .../client/completions/slashCompletion.ts | 196 +- .../web-shell/client/components/Editor.tsx | 71 +- .../components/InsightProgress.module.css | 44 +- .../client/components/InsightProgress.tsx | 14 +- .../client/components/InsightReady.tsx | 17 + .../client/components/MessageItem.tsx | 51 + .../client/components/MessageList.tsx | 39 +- .../components/dialogs/AgentsDialog.tsx | 557 ----- .../dialogs/DialogPrimitives.module.css | 28 +- .../client/components/dialogs/McpDialog.tsx | 227 +- .../messages/AgentsMessage.module.css | 353 ++++ .../components/messages/AgentsMessage.tsx | 1822 +++++++++++++++++ .../components/messages/BtwMessage.module.css | 32 + .../client/components/messages/BtwMessage.tsx | 37 + .../messages/ContextUsageMessage.module.css | 3 +- .../messages/McpStatusMessage.module.css | 191 ++ .../components/messages/McpStatusMessage.tsx | 835 ++++++++ .../messages/MemoryMessage.module.css | 193 ++ .../components/messages/MemoryMessage.tsx | 612 ++++++ .../messages/ModelMessage.module.css | 115 ++ .../components/messages/ModelMessage.tsx | 243 +++ .../messages/StatsMessage.module.css | 170 ++ .../components/messages/StatsMessage.tsx | 536 +++++ .../messages/StatusMessage.module.css | 54 + .../components/messages/StatusMessage.tsx | 85 + .../messages/SystemMessage.module.css | 7 +- .../components/messages/SystemMessage.tsx | 43 +- .../client/components/messages/ToolGroup.tsx | 4 +- .../messages/UserShellMessage.module.css | 67 + .../components/messages/UserShellMessage.tsx | 36 + .../messages/toolFormatting.test.ts | 23 +- .../components/messages/toolFormatting.ts | 4 +- .../client/constants/localCommands.ts | 21 +- .../client/extensions/inputHighlight.ts | 91 +- packages/web-shell/client/i18n.tsx | 573 +++++- packages/web-shell/client/index.ts | 2 +- packages/webui/src/daemon-react-sdk.ts | 12 + packages/webui/src/daemon/index.ts | 6 + packages/webui/src/daemon/session/actions.ts | 32 + packages/webui/src/daemon/session/index.ts | 3 + packages/webui/src/daemon/session/mappers.ts | 15 +- .../webui/src/daemon/session/messageTypes.ts | 36 +- .../session/transcriptToMessages.test.ts | 46 +- .../daemon/session/transcriptToMessages.ts | 129 ++ packages/webui/src/daemon/session/types.ts | 9 + packages/webui/src/daemon/timing.ts | 5 +- .../webui/src/daemon/workspace/actions.ts | 19 + .../daemon/workspace/hooks/useDaemonAgents.ts | 2 + .../daemon/workspace/hooks/useDaemonMcp.ts | 1 + packages/webui/src/daemon/workspace/types.ts | 8 + 74 files changed, 8828 insertions(+), 987 deletions(-) create mode 100644 packages/web-shell/client/completions/slashCompletion.test.ts create mode 100644 packages/web-shell/client/components/InsightReady.tsx delete mode 100644 packages/web-shell/client/components/dialogs/AgentsDialog.tsx create mode 100644 packages/web-shell/client/components/messages/AgentsMessage.module.css create mode 100644 packages/web-shell/client/components/messages/AgentsMessage.tsx create mode 100644 packages/web-shell/client/components/messages/BtwMessage.module.css create mode 100644 packages/web-shell/client/components/messages/BtwMessage.tsx create mode 100644 packages/web-shell/client/components/messages/McpStatusMessage.module.css create mode 100644 packages/web-shell/client/components/messages/McpStatusMessage.tsx create mode 100644 packages/web-shell/client/components/messages/MemoryMessage.module.css create mode 100644 packages/web-shell/client/components/messages/MemoryMessage.tsx create mode 100644 packages/web-shell/client/components/messages/ModelMessage.module.css create mode 100644 packages/web-shell/client/components/messages/ModelMessage.tsx create mode 100644 packages/web-shell/client/components/messages/StatsMessage.module.css create mode 100644 packages/web-shell/client/components/messages/StatsMessage.tsx create mode 100644 packages/web-shell/client/components/messages/StatusMessage.module.css create mode 100644 packages/web-shell/client/components/messages/StatusMessage.tsx create mode 100644 packages/web-shell/client/components/messages/UserShellMessage.module.css create mode 100644 packages/web-shell/client/components/messages/UserShellMessage.tsx diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 60ce3a97408..ccc6080975b 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -42,6 +42,7 @@ import { createIdleWorkspaceSkillsStatus, mapDomainErrorToErrorKind, type ServePreflightCell, + type ServeSessionStatsStatus, type ServeSessionTasksStatus, type ServeStatusCell, } from './status.js'; @@ -3174,6 +3175,13 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { ); }, + async getSessionStatsStatus(sessionId) { + return requestSessionStatus( + sessionId, + SERVE_STATUS_EXT_METHODS.sessionStats, + ); + }, + async setSessionModel(sessionId, req, context) { const entry = byId.get(sessionId); if (!entry) throw new SessionNotFoundError(sessionId); @@ -3823,6 +3831,63 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { return response; }, + async manageMcpServer(serverName, action, originatorClientId) { + const info = liveChannelInfo(); + if (!info) { + throw new SessionNotFoundError(`mcp:${serverName}`); + } + const response = (await Promise.race([ + withTimeout( + info.connection.extMethod( + SERVE_CONTROL_EXT_METHODS.workspaceMcpManage, + { serverName, action, originatorClientId }, + ), + MCP_RESTART_TIMEOUT_MS, + SERVE_CONTROL_EXT_METHODS.workspaceMcpManage, + ), + getChannelClosedReject(info), + ])) as { + serverName: string; + action: 'enable' | 'disable' | 'authenticate' | 'clear-auth'; + ok: true; + changed?: boolean; + messages?: string[]; + authUrl?: string; + }; + broadcastWorkspaceEvent({ + type: 'mcp_server_changed', + data: { + serverName: response.serverName, + action: response.action, + originatorClientId, + }, + ...(originatorClientId ? { originatorClientId } : {}), + }); + return response; + }, + + async generateWorkspaceAgent(description, _originatorClientId) { + const info = liveChannelInfo(); + if (!info) { + throw new SessionNotFoundError('agents:generate'); + } + return (await Promise.race([ + withTimeout( + info.connection.extMethod( + SERVE_CONTROL_EXT_METHODS.workspaceAgentGenerate, + { description }, + ), + MCP_RESTART_TIMEOUT_MS, + SERVE_CONTROL_EXT_METHODS.workspaceAgentGenerate, + ), + getChannelClosedReject(info), + ])) as { + name: string; + description: string; + systemPrompt: string; + }; + }, + async addRuntimeMcpServer(name, config, originatorClientId) { // T2.8 (#4514). Round-trip the runtime-add ext-method through the // live ACP child and broadcast an `mcp_server_added` event on diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 0db96d6dc02..0d5c66e734f 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -29,6 +29,7 @@ import type { ServeWorkspaceSkillsStatus, ServeWorkspaceToolsStatus, ServeSessionContextUsageStatus, + ServeSessionStatsStatus, } from './status.js'; export interface BridgeSpawnRequest { @@ -340,6 +341,9 @@ export interface HttpAcpBridge { /** Read the live background task snapshot for a live session. */ getSessionTasksStatus(sessionId: string): Promise; + /** Read structured session usage stats (tokens, tools, files). */ + getSessionStatsStatus(sessionId: string): Promise; + /** * Switch the active model service for a session. Throws * `SessionNotFoundError` for unknown ids. @@ -523,6 +527,28 @@ export interface HttpAcpBridge { } >; + manageMcpServer( + serverName: string, + action: 'enable' | 'disable' | 'authenticate' | 'clear-auth', + originatorClientId: string | undefined, + ): Promise<{ + serverName: string; + action: 'enable' | 'disable' | 'authenticate' | 'clear-auth'; + ok: true; + changed?: boolean; + messages?: string[]; + authUrl?: string; + }>; + + generateWorkspaceAgent( + description: string, + originatorClientId: string | undefined, + ): Promise<{ + name: string; + description: string; + systemPrompt: string; + }>; + /** * Tear down a session — kill the child, drop from maps, publish * `session_died`. Idempotent on already-dead sessions. diff --git a/packages/acp-bridge/src/status.ts b/packages/acp-bridge/src/status.ts index 2d0875cce0e..c8017d21532 100644 --- a/packages/acp-bridge/src/status.ts +++ b/packages/acp-bridge/src/status.ts @@ -106,6 +106,7 @@ export const SERVE_STATUS_EXT_METHODS = { sessionContextUsage: 'qwen/status/session/context_usage', sessionSupportedCommands: 'qwen/status/session/supported_commands', sessionTasks: 'qwen/status/session/tasks', + sessionStats: 'qwen/status/session/stats', } as const; /** @@ -122,6 +123,8 @@ export const SERVE_CONTROL_EXT_METHODS = { sessionBtw: 'qwen/control/session/btw', sessionShellHistory: 'qwen/control/session/shell_history', workspaceMcpRestart: 'qwen/control/workspace/mcp/restart', + workspaceMcpManage: 'qwen/control/workspace/mcp/manage', + workspaceAgentGenerate: 'qwen/control/workspace/agents/generate', // T2.8 (#4514): runtime MCP server mutation ext-methods workspaceMcpRuntimeAdd: 'qwen/control/workspace/mcp/runtime-add', workspaceMcpRuntimeRemove: 'qwen/control/workspace/mcp/runtime-remove', @@ -167,6 +170,15 @@ export interface ServeWorkspaceMcpServerStatus extends ServeStatusCell { mcpStatus?: ServeMcpServerRuntimeStatus; transport: ServeMcpTransport; disabled: boolean; + hasOAuthTokens?: boolean; + source?: 'user' | 'project' | 'extension'; + config?: { + command?: string; + args?: string[]; + httpUrl?: string; + url?: string; + cwd?: string; + }; description?: string; extensionName?: string; /** @@ -321,6 +333,8 @@ export interface ServeWorkspaceSkillsStatus { export interface ServeWorkspaceProviderCurrent { authType?: string; modelId?: string; + baseUrl?: string; + fastModelId?: string; } export interface ServeWorkspaceProviderModel { @@ -329,6 +343,14 @@ export interface ServeWorkspaceProviderModel { name: string; description?: string | null; contextLimit?: number; + modalities?: { + image?: boolean; + pdf?: boolean; + audio?: boolean; + video?: boolean; + }; + baseUrl?: string; + envKey?: string; isCurrent: boolean; isRuntime: boolean; } @@ -494,6 +516,55 @@ export interface ServeSessionTasksStatus { tasks: ServeSessionTaskStatus[]; } +export interface ServeSessionStatsModelMetrics { + api: { + totalRequests: number; + totalErrors: number; + totalLatencyMs: number; + }; + tokens: { + prompt: number; + candidates: number; + total: number; + cached: number; + thoughts: number; + }; +} + +export interface ServeSessionStatsToolByName { + count: number; + success: number; + fail: number; + durationMs: number; + decisions: { + accept: number; + reject: number; + modify: number; + auto_accept: number; + }; +} + +export interface ServeSessionStatsStatus { + v: typeof STATUS_SCHEMA_VERSION; + sessionId: string; + workspaceCwd: string; + sessionStartTimeMs: number; + durationMs: number; + promptCount: number; + models: Record; + tools: { + totalCalls: number; + totalSuccess: number; + totalFail: number; + totalDurationMs: number; + byName: Record; + }; + files: { + totalLinesAdded: number; + totalLinesRemoved: number; + }; +} + /** * Issue #4175 PR 16: workspace memory + agents read surfaces. * @@ -690,7 +761,8 @@ export type ServeEnvKind = | 'platform' | 'sandbox' | 'proxy' - | 'env_var'; + | 'env_var' + | 'memory'; export interface ServeEnvCell extends ServeStatusCell { kind: ServeEnvKind; diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index da6449f0c20..c4f957cca4d 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -32,9 +32,13 @@ import { WorkspaceMcpBudget, DiscoveredMCPTool, restoreWorktreeContext, + uiTelemetryService, McpBudgetWouldExceedError, McpServerSpawnFailedError, InvalidMcpConfigError, + MCPOAuthProvider, + MCPOAuthTokenStorage, + subagentGenerator, } from '@qwen-code/qwen-code-core'; import type { ApprovalMode, @@ -102,6 +106,7 @@ import { } from '../utils/acpModelUtils.js'; import { runWithAcpRuntimeOutputDir } from './runtimeOutputDirContext.js'; import { runExitCleanup } from '../utils/cleanup.js'; +import { appEvents, AppEvent } from '../utils/events.js'; import { ACP_PREFLIGHT_KINDS, STATUS_SCHEMA_VERSION, @@ -134,6 +139,7 @@ import { type ServeWorkspaceToolStatus, type ServeWorkspaceToolsStatus, type ServeSessionContextUsageStatus, + type ServeSessionStatsStatus, } from '../serve/status.js'; import { collectContextData, @@ -1110,9 +1116,16 @@ class QwenAgent implements Agent { } } - private buildWorkspaceMcpStatus(config: Config): ServeWorkspaceMcpStatus { + private async buildWorkspaceMcpStatus( + config: Config, + ): Promise { try { const workspaceCwd = this.workspaceCwd(config); + const settings = loadSettings(config.getTargetDir()); + const userSettings = settings.forScope(SettingScope.User).settings; + const workspaceSettings = settings.forScope( + SettingScope.Workspace, + ).settings; const servers = config.getMcpServers() ?? {}; // F2 (#4175 commit 5): pool snapshot powers `entryCount` + @@ -1205,74 +1218,127 @@ class QwenAgent implements Agent { workspaceCwd, initialized: true, discoveryState: this.discoveryState(), - servers: Object.entries(servers).map(([name, server]) => { - const disabled = config.isMcpServerDisabled(name); - const rawStatus = getMCPServerStatus(name); - const refusedByBudget = refusedSet.has(name); - // PR 14 fix (review #4247): config-disable takes precedence - // over budget-refusal. `lastRefusedServerNames` is a - // per-discovery-pass snapshot; if an operator runs - // `/mcp disable ` against a server that was refused - // last pass, the entry stays in the refused list until the - // next discovery pass clears it (`McpClientManager.removeServer` - // now drops the entry too — see sibling fix). Either way, - // a `disabled` cell should NEVER show `budget_exhausted` — - // the operator's deliberate disable wins. - const effectivelyRefused = refusedByBudget && !disabled; - const out: ServeWorkspaceMcpServerStatus = { - kind: 'mcp_server', - // Refused-by-budget shadows the raw status: the rawStatus - // is `DISCONNECTED` (we never tried to connect), but the - // operator-facing severity is `error` with an explanatory - // errorKind rather than the generic disconnected `error`. - status: effectivelyRefused - ? 'error' - : this.mcpCellStatus(rawStatus, disabled), - name, - mcpStatus: this.mcpStatus(rawStatus), - transport: this.mcpTransport(server), - disabled, - }; - if (effectivelyRefused) { - out.errorKind = 'budget_exhausted'; - out.disabledReason = 'budget'; - out.hint = - 'Raise --mcp-client-budget or remove servers from mcpServers config.'; - } else if (disabled) { - out.disabledReason = 'config'; - } - const description = - server && typeof server === 'object' - ? (server as { description?: unknown }).description - : undefined; - const extensionName = - server && typeof server === 'object' - ? (server as { extensionName?: unknown }).extensionName - : undefined; - if (typeof description === 'string') { - out.description = description; - } - if (typeof extensionName === 'string') { - out.extensionName = extensionName; - } - // F2 (#4175 commit 5): pool entries enrichment. `entryCount` - // and `entrySummary` are present together (or both absent) - // — guards on `mcp_workspace_pool` capability advertise the - // pair atomically. The runtime status is mapped through the - // existing `this.mcpStatus(...)` so downstream string-typed - // consumers see the same `'connected' | 'connecting' | - // 'disconnected'` enum the top-level `mcpStatus` field uses. - const poolRow = poolByName[name]; - if (poolRow) { - out.entryCount = poolRow.entryCount; - out.entrySummary = poolRow.entrySummary.map((e) => ({ - entryIndex: e.entryIndex, - refs: e.refs, - status: this.mcpStatus(e.status), - })); - } - return out; - }), + servers: await Promise.all( + Object.entries(servers).map(async ([name, server]) => { + const disabled = config.isMcpServerDisabled(name); + let hasOAuthTokens = false; + try { + const tokenStorage = new MCPOAuthTokenStorage(); + const credentials = await tokenStorage.getCredentials(name); + hasOAuthTokens = credentials !== null; + } catch { + // Match CLI: token lookup errors should not break /mcp status. + } + const rawStatus = getMCPServerStatus(name); + const refusedByBudget = refusedSet.has(name); + // PR 14 fix (review #4247): config-disable takes precedence + // over budget-refusal. `lastRefusedServerNames` is a + // per-discovery-pass snapshot; if an operator runs + // `/mcp disable ` against a server that was refused + // last pass, the entry stays in the refused list until the + // next discovery pass clears it (`McpClientManager.removeServer` + // now drops the entry too — see sibling fix). Either way, + // a `disabled` cell should NEVER show `budget_exhausted` — + // the operator's deliberate disable wins. + const effectivelyRefused = refusedByBudget && !disabled; + const out: ServeWorkspaceMcpServerStatus = { + kind: 'mcp_server', + // Refused-by-budget shadows the raw status: the rawStatus + // is `DISCONNECTED` (we never tried to connect), but the + // operator-facing severity is `error` with an explanatory + // errorKind rather than the generic disconnected `error`. + status: effectivelyRefused + ? 'error' + : this.mcpCellStatus(rawStatus, disabled), + name, + mcpStatus: this.mcpStatus(rawStatus), + transport: this.mcpTransport(server), + disabled, + hasOAuthTokens, + }; + if (effectivelyRefused) { + out.errorKind = 'budget_exhausted'; + out.disabledReason = 'budget'; + out.hint = + 'Raise --mcp-client-budget or remove servers from mcpServers config.'; + } else if (disabled) { + out.disabledReason = 'config'; + } + const description = + server && typeof server === 'object' + ? (server as { description?: unknown }).description + : undefined; + const extensionName = + server && typeof server === 'object' + ? (server as { extensionName?: unknown }).extensionName + : undefined; + if (typeof description === 'string') { + out.description = description; + } + if (typeof extensionName === 'string') { + out.extensionName = extensionName; + } + out.source = out.extensionName + ? 'extension' + : workspaceSettings.mcpServers?.[name] + ? 'project' + : userSettings.mcpServers?.[name] + ? 'user' + : 'user'; + if (server && typeof server === 'object') { + const candidate = server as { + command?: unknown; + args?: unknown; + httpUrl?: unknown; + url?: unknown; + cwd?: unknown; + }; + const serverConfig: NonNullable< + ServeWorkspaceMcpServerStatus['config'] + > = {}; + if (typeof candidate.command === 'string') { + serverConfig.command = candidate.command; + } + if (Array.isArray(candidate.args)) { + const args = candidate.args.filter( + (arg): arg is string => typeof arg === 'string', + ); + if (args.length > 0) { + serverConfig.args = args; + } + } + if (typeof candidate.httpUrl === 'string') { + serverConfig.httpUrl = candidate.httpUrl; + } + if (typeof candidate.url === 'string') { + serverConfig.url = candidate.url; + } + if (typeof candidate.cwd === 'string') { + serverConfig.cwd = candidate.cwd; + } + if (Object.keys(serverConfig).length > 0) { + out.config = serverConfig; + } + } + // F2 (#4175 commit 5): pool entries enrichment. `entryCount` + // and `entrySummary` are present together (or both absent) + // — guards on `mcp_workspace_pool` capability advertise the + // pair atomically. The runtime status is mapped through the + // existing `this.mcpStatus(...)` so downstream string-typed + // consumers see the same `'connected' | 'connecting' | + // 'disconnected'` enum the top-level `mcpStatus` field uses. + const poolRow = poolByName[name]; + if (poolRow) { + out.entryCount = poolRow.entryCount; + out.entrySummary = poolRow.entrySummary.map((e) => ({ + entryIndex: e.entryIndex, + refs: e.refs, + status: this.mcpStatus(e.status), + })); + } + return out; + }), + ), ...(clientCount !== undefined ? { clientCount } : {}), ...(clientBudget !== undefined ? { clientBudget } : {}), ...(budgetMode !== undefined ? { budgetMode } : {}), @@ -1336,8 +1402,28 @@ class QwenAgent implements Agent { }; } - const registry = config.getToolRegistry(); - const allTools = registry?.getAllTools() ?? []; + let registry = config.getToolRegistry(); + let allTools = registry?.getAllTools() ?? []; + if ( + allTools.filter( + (t) => t instanceof DiscoveredMCPTool && t.serverName === serverName, + ).length === 0 + ) { + for (const session of this.getActiveSessions()) { + const sessionRegistry = session.getConfig().getToolRegistry(); + const sessionTools = sessionRegistry?.getAllTools() ?? []; + if ( + sessionTools.some( + (t) => + t instanceof DiscoveredMCPTool && t.serverName === serverName, + ) + ) { + registry = sessionRegistry; + allTools = sessionTools; + break; + } + } + } const tools: ServeWorkspaceMcpToolStatus[] = allTools .filter( (tool): tool is DiscoveredMCPTool => @@ -1602,6 +1688,11 @@ class QwenAgent implements Agent { ? { description: model.description } : {}), contextLimit: model.contextWindowSize ?? tokenLimit(effectiveModelId), + ...(model.modalities !== undefined + ? { modalities: model.modalities } + : {}), + ...(model.baseUrl !== undefined ? { baseUrl: model.baseUrl } : {}), + ...(model.envKey !== undefined ? { envKey: model.envKey } : {}), isCurrent, isRuntime: model.isRuntimeModel === true, }; @@ -1609,6 +1700,10 @@ class QwenAgent implements Agent { if (isCurrent) provider.current = true; } + const cgConfig = config.getContentGeneratorConfig?.(); + const baseUrl = cgConfig?.baseUrl || undefined; + const fastModelId = this.settings.merged?.fastModel || undefined; + return { v: STATUS_SCHEMA_VERSION, workspaceCwd, @@ -1618,6 +1713,8 @@ class QwenAgent implements Agent { current: { ...(currentAuth ? { authType: String(currentAuth) } : {}), ...(currentAcpModelId ? { modelId: currentAcpModelId } : {}), + ...(baseUrl ? { baseUrl } : {}), + ...(fastModelId ? { fastModelId } : {}), }, } : {}), @@ -2092,6 +2189,59 @@ class QwenAgent implements Agent { return buildSessionTasksStatus(sessionId, session.getConfig()); } + private buildSessionStatsStatus(sessionId: string): ServeSessionStatsStatus { + const session = this.sessionOrThrow(sessionId); + const config = session.getConfig(); + const metrics = uiTelemetryService.getMetrics(); + const now = Date.now(); + const createdAt = session.getCreatedAt(); + + const models: ServeSessionStatsStatus['models'] = {}; + for (const [name, m] of Object.entries(metrics.models)) { + models[name] = { + api: { ...m.api }, + tokens: { ...m.tokens }, + }; + } + + const byName: ServeSessionStatsStatus['tools']['byName'] = {}; + for (const [name, t] of Object.entries(metrics.tools.byName)) { + byName[name] = { + count: t.count, + success: t.success, + fail: t.fail, + durationMs: t.durationMs, + decisions: { + accept: t.decisions.accept, + reject: t.decisions.reject, + modify: t.decisions.modify, + auto_accept: t.decisions.auto_accept, + }, + }; + } + + return { + v: STATUS_SCHEMA_VERSION, + sessionId, + workspaceCwd: this.workspaceCwd(config), + sessionStartTimeMs: createdAt, + durationMs: now - createdAt, + promptCount: session.getTurnCount(), + models, + tools: { + totalCalls: metrics.tools.totalCalls, + totalSuccess: metrics.tools.totalSuccess, + totalFail: metrics.tools.totalFail, + totalDurationMs: metrics.tools.totalDurationMs, + byName, + }, + files: { + totalLinesAdded: metrics.files.totalLinesAdded, + totalLinesRemoved: metrics.files.totalLinesRemoved, + }, + }; + } + async extMethod( method: string, params: Record, @@ -2101,10 +2251,9 @@ class QwenAgent implements Agent { switch (method) { case SERVE_STATUS_EXT_METHODS.workspaceMcp: - return this.buildWorkspaceMcpStatus(this.config) as unknown as Record< - string, - unknown - >; + return (await this.buildWorkspaceMcpStatus( + this.config, + )) as unknown as Record; case SERVE_STATUS_EXT_METHODS.workspaceMcpTools: { const serverName = params['serverName']; if (typeof serverName !== 'string' || serverName.length === 0) { @@ -2186,6 +2335,19 @@ class QwenAgent implements Agent { unknown >; } + case SERVE_STATUS_EXT_METHODS.sessionStats: { + const sessionId = params['sessionId']; + if (typeof sessionId !== 'string' || sessionId.length === 0) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing sessionId', + ); + } + return this.buildSessionStatsStatus(sessionId) as unknown as Record< + string, + unknown + >; + } case SERVE_CONTROL_EXT_METHODS.workspaceMcpRestart: { // #4175 Wave 4 PR 17. Single-server MCP restart with budget // pre-check from PR 14 v1's accounting snapshot. Soft skips @@ -2459,6 +2621,176 @@ class QwenAgent implements Agent { durationMs: Date.now() - start, }; } + case SERVE_CONTROL_EXT_METHODS.workspaceMcpManage: { + const serverName = params['serverName']; + const action = params['action']; + if (typeof serverName !== 'string' || serverName.length === 0) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing serverName', + ); + } + if ( + action !== 'enable' && + action !== 'disable' && + action !== 'authenticate' && + action !== 'clear-auth' + ) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing MCP manage action', + ); + } + const servers = this.config.getMcpServers() ?? {}; + const server = servers[serverName]; + if (!server) { + throw new RequestError( + -32004, + `MCP server not configured: ${JSON.stringify(serverName)}`, + { errorKind: 'mcp_server_not_found', serverName }, + ); + } + const toolRegistry = this.config.getToolRegistry(); + if (!toolRegistry) { + throw RequestError.internalError( + undefined, + 'ToolRegistry unavailable on this Config', + ); + } + + if (action === 'enable') { + const settings = loadSettings(this.config.getTargetDir()); + for (const scope of [SettingScope.User, SettingScope.Workspace]) { + const scopeSettings = settings.forScope(scope).settings; + const currentExcluded = scopeSettings.mcp?.excluded || []; + if (currentExcluded.includes(serverName)) { + settings.setValue( + scope, + 'mcp.excluded', + currentExcluded.filter((name: string) => name !== serverName), + ); + } + } + const currentExcluded = this.config.getExcludedMcpServers() || []; + this.config.setExcludedMcpServers( + currentExcluded.filter((name: string) => name !== serverName), + ); + await toolRegistry.discoverToolsForServer(serverName); + return { serverName, action, ok: true, changed: true }; + } + + if (action === 'disable') { + const settings = loadSettings(this.config.getTargetDir()); + const userSettings = settings.forScope(SettingScope.User).settings; + const workspaceSettings = settings.forScope( + SettingScope.Workspace, + ).settings; + let targetScope = SettingScope.User; + if (server.extensionName) { + throw RequestError.invalidParams( + undefined, + `Cannot disable extension MCP server: ${serverName}`, + ); + } + if (workspaceSettings.mcpServers?.[serverName]) { + targetScope = SettingScope.Workspace; + } else if (userSettings.mcpServers?.[serverName]) { + targetScope = SettingScope.User; + } + const scopeSettings = settings.forScope(targetScope).settings; + const currentExcluded = scopeSettings.mcp?.excluded || []; + if (!currentExcluded.includes(serverName)) { + settings.setValue(targetScope, 'mcp.excluded', [ + ...currentExcluded, + serverName, + ]); + } + const runtimeExcluded = this.config.getExcludedMcpServers() || []; + if (!runtimeExcluded.includes(serverName)) { + this.config.setExcludedMcpServers([...runtimeExcluded, serverName]); + } + await toolRegistry.disableMcpServer(serverName); + return { serverName, action, ok: true, changed: true }; + } + + if (action === 'clear-auth') { + const tokenStorage = new MCPOAuthTokenStorage(); + await tokenStorage.deleteCredentials(serverName); + await toolRegistry.disconnectServer(serverName); + return { serverName, action, ok: true, changed: true }; + } + + const messages: string[] = []; + let authUrl: string | undefined; + const displayListener = (message: unknown) => { + if (typeof message === 'string') { + messages.push(message); + } else if (message && typeof message === 'object') { + const key = (message as { key?: unknown }).key; + if (typeof key === 'string') { + messages.push(key); + } + } + }; + const authUrlListener = (url: unknown) => { + if (typeof url === 'string') { + authUrl = url; + } + }; + appEvents.on(AppEvent.OauthDisplayMessage, displayListener); + appEvents.on(AppEvent.OauthAuthUrl, authUrlListener); + try { + const oauthConfig = server.oauth ?? { enabled: false }; + const mcpServerUrl = server.httpUrl || server.url; + const authProvider = new MCPOAuthProvider(new MCPOAuthTokenStorage()); + await authProvider.authenticate( + serverName, + oauthConfig, + mcpServerUrl, + appEvents, + ); + messages.push( + `Successfully authenticated and refreshed tools for '${serverName}'.`, + ); + await toolRegistry.discoverToolsForServer(serverName); + const geminiClient = this.config.getGeminiClient(); + if (geminiClient) { + await geminiClient.setTools(); + } + return { + serverName, + action, + ok: true, + changed: true, + messages, + ...(authUrl ? { authUrl } : {}), + }; + } finally { + appEvents.removeListener( + AppEvent.OauthDisplayMessage, + displayListener, + ); + appEvents.removeListener(AppEvent.OauthAuthUrl, authUrlListener); + } + } + case SERVE_CONTROL_EXT_METHODS.workspaceAgentGenerate: { + const description = params['description']; + if ( + typeof description !== 'string' || + !description.trim() || + description.length > 4096 + ) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing description (max 4096 chars)', + ); + } + return (await subagentGenerator( + description.trim(), + this.config, + new AbortController().signal, + )) as unknown as Record; + } case SERVE_CONTROL_EXT_METHODS.sessionClose: { const sessionId = params['sessionId']; if (typeof sessionId !== 'string' || sessionId.length === 0) { diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index f6e8d2ca0d6..0bd05f6546d 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -139,6 +139,13 @@ import { const debugLogger = createDebugLogger('SESSION'); +function maskApiKeyForDisplay(apiKey: string | undefined): string { + const trimmed = apiKey?.trim() ?? ''; + if (trimmed.length === 0) return '(not set)'; + if (trimmed.length <= 6) return '***'; + return `${trimmed.slice(0, 3)}...${trimmed.slice(-4)}`; +} + type AutoCompressionSendResult = | { responseStream: AsyncGenerator; stopReason?: never } | { responseStream: null; stopReason: PromptResponse['stopReason'] }; @@ -285,6 +292,7 @@ export class Session implements SessionContext { */ private followupAbort: AbortController | null = null; private turn: number = 0; + private readonly createdAt: number = Date.now(); private readonly runtimeBaseDir: string; // Cron scheduling state @@ -347,6 +355,14 @@ export class Session implements SessionContext { return this.config; } + getTurnCount(): number { + return this.turn; + } + + getCreatedAt(): number { + return this.createdAt; + } + /** * Install the message rewrite middleware if configured. * Must be called AFTER history replay to avoid rewriting historical messages. @@ -1807,6 +1823,10 @@ export class Session implements SessionContext { : undefined, ); + const after = this.config.getContentGeneratorConfig?.(); + const effectiveAuthType = after?.authType ?? selectedAuthType; + const effectiveModelId = after?.model ?? parsed.modelId; + // A1 (#4511): notify attached clients of an in-session model switch so a // `/model` slash command or plan-mode change reaches the bus (today only // the HTTP `POST /session/:id/model` path publishes `model_switched`). @@ -1821,7 +1841,7 @@ export class Session implements SessionContext { .extNotification('qwen/notify/session/model-update', { v: 1, sessionId: this.sessionId, - currentModelId: parsed.modelId, + currentModelId: effectiveModelId, }) .catch(() => { // Advisory only; a failed notification must not fail the model switch. @@ -1836,6 +1856,18 @@ export class Session implements SessionContext { selectedAuthType, ); } + + return { + _meta: { + qwenModelSwitch: { + authType: effectiveAuthType, + modelId: effectiveModelId, + baseUrl: after?.baseUrl ?? '(default)', + apiKey: maskApiKeyForDisplay(after?.apiKey), + isRuntime: rawModelId.startsWith('$runtime|'), + }, + }, + }; } /** diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index b71e108d61d..6748d86b141 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -77,12 +77,14 @@ export const SERVE_CAPABILITY_REGISTRY = { // create / update / delete project- and user-level subagent // definitions. Built-in / extension agents stay read-only. workspace_agents: { since: 'v1' }, + workspace_agent_generate: { since: 'v1' }, workspace_env: { since: 'v1' }, workspace_preflight: { since: 'v1' }, session_context: { since: 'v1' }, session_context_usage: { since: 'v1' }, session_supported_commands: { since: 'v1' }, session_tasks: { since: 'v1' }, + session_stats: { since: 'v1' }, session_close: { since: 'v1' }, session_metadata: { since: 'v1' }, // Issue #4175 PR 14. Daemon supports the MCP client guardrail @@ -98,6 +100,7 @@ export const SERVE_CAPABILITY_REGISTRY = { // `require_auth` is the only conditional tag, kept last for // visibility in `Object.keys(SERVE_CAPABILITY_REGISTRY)`. mcp_guardrails: { since: 'v1', modes: ['warn', 'enforce'] }, + workspace_mcp_manage: { since: 'v1' }, // Issue #4175 PR 14b. Daemon emits typed push events for MCP budget // state crossings: `mcp_budget_warning` (synthetic, fires once per // upward 75% crossing with hysteresis re-arm at 37.5%) and diff --git a/packages/cli/src/serve/envSnapshot.ts b/packages/cli/src/serve/envSnapshot.ts index d8043f365de..842bc061a7b 100644 --- a/packages/cli/src/serve/envSnapshot.ts +++ b/packages/cli/src/serve/envSnapshot.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import os from 'node:os'; import { detectRuntime, redactProxyCredentials, @@ -13,6 +14,7 @@ import { type ServeEnvCell, type ServeWorkspaceEnvStatus, } from './status.js'; +import { formatMemoryUsage } from '../ui/utils/formatters.js'; /** * Whitelisted environment variables whose **presence** the daemon will @@ -153,7 +155,14 @@ export function buildEnvStatusFromProcess( kind: 'platform', name: process.platform, status: 'ok', - value: process.arch, + value: `${process.arch} (${os.release()})`, + }); + + cells.push({ + kind: 'memory', + name: 'rss', + status: 'ok', + value: formatMemoryUsage(process.memoryUsage().rss), }); const sandboxName = env['SANDBOX']; diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 04bbbc95cf8..009edeacd0a 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -1634,6 +1634,24 @@ export function createServeApp( } }); + app.get('/session/:id/stats', async (req, res) => { + const sessionId = req.params['id']; + if (!sessionId) { + res + .status(400) + .json({ error: '`sessionId` route parameter is required' }); + return; + } + try { + res.status(200).json(await bridge.getSessionStatsStatus(sessionId)); + } catch (err) { + sendBridgeError(res, err, { + route: 'GET /session/:id/stats', + sessionId, + }); + } + }); + app.get('/session/:id/supported-commands', async (req, res) => { const sessionId = req.params['id']; if (!sessionId) { @@ -2371,6 +2389,49 @@ export function createServeApp( }, ); + for (const [routeAction, bridgeAction] of [ + ['enable', 'enable'], + ['disable', 'disable'], + ['authenticate', 'authenticate'], + ['clear-auth', 'clear-auth'], + ] as const) { + app.post( + `/workspace/mcp/:server/${routeAction}`, + mutate({ strict: true }), + async (req, res) => { + const serverName = req.params['server']; + if (!serverName || typeof serverName !== 'string') { + res.status(400).json({ + error: 'Server name path parameter is required', + code: 'invalid_server_name', + }); + return; + } + if (serverName.length > MAX_SERVER_NAME_LENGTH) { + res.status(400).json({ + error: `Server name exceeds ${MAX_SERVER_NAME_LENGTH}-character limit`, + code: 'invalid_server_name', + }); + return; + } + const clientId = parseAndValidateWorkspaceClientId(req, res, bridge); + if (clientId === null) return; + try { + const result = await bridge.manageMcpServer( + serverName, + bridgeAction, + clientId, + ); + res.status(200).json(result); + } catch (err) { + sendBridgeError(res, err, { + route: `POST /workspace/mcp/:server/${routeAction}`, + }); + } + }, + ); + } + // T2.8 (#4514): Add a runtime MCP server. Validates body.name + // body.config shape, forwards to HttpAcpBridge.addRuntimeMcpServer. // Typed ACP errors (budget-exceeded, spawn-failed, invalid-config) are diff --git a/packages/cli/src/serve/workspaceAgents.ts b/packages/cli/src/serve/workspaceAgents.ts index ec0b7c950de..d6cbb6b0b26 100644 --- a/packages/cli/src/serve/workspaceAgents.ts +++ b/packages/cli/src/serve/workspaceAgents.ts @@ -285,6 +285,49 @@ export function mountWorkspaceAgentsRoutes( }, ); + app.post( + '/workspace/agents/generate', + deps.mutate({ strict: true }), + async (req, res) => { + const body = deps.safeBody(req); + const clientIdResult = resolveOriginatorClientId(deps, req, res); + if (clientIdResult === null) return; + const originatorClientId = clientIdResult; + const description = body['description']; + if ( + typeof description !== 'string' || + description.trim().length === 0 || + Buffer.byteLength(description, 'utf8') > MAX_DESCRIPTION_BYTES + ) { + res.status(400).json({ + error: '`description` must be a non-empty string', + code: 'invalid_description', + }); + return; + } + try { + const generated = await deps.bridge.generateWorkspaceAgent( + description.trim(), + originatorClientId, + ); + res.status(200).json(generated); + } catch (err) { + writeStderrLine( + `qwen serve: POST /workspace/agents/generate failed: ${ + err instanceof Error ? (err.stack ?? err.message) : String(err) + }`, + ); + res.status(500).json({ + error: + err instanceof Error + ? err.message + : 'Failed to generate workspace agent', + code: 'agent_generate_failed', + }); + } + }, + ); + app.get('/workspace/agents/:agentType', async (req, res) => { const agentType = validateAgentType(req, res); if (agentType === null) return; diff --git a/packages/core/src/models/modelsConfig.ts b/packages/core/src/models/modelsConfig.ts index 0c90931905b..a458374d032 100644 --- a/packages/core/src/models/modelsConfig.ts +++ b/packages/core/src/models/modelsConfig.ts @@ -1199,6 +1199,9 @@ export class ModelsConfig { */ isVision: false, contextWindowSize: snapshot.generationConfig?.contextWindowSize, + modalities: snapshot.generationConfig?.modalities, + baseUrl: snapshot.baseUrl, + envKey: snapshot.apiKeyEnvKey, isRuntimeModel: true, runtimeSnapshotId: snapshot.id, }; diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index f2c30369aca..f8480324a88 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -16,6 +16,7 @@ import type { DaemonAuthStatusSnapshot, DaemonCapabilities, DaemonCreateAgentRequest, + DaemonGeneratedAgentContent, DaemonDeviceFlowStartResult, DaemonDeviceFlowState, DaemonEvent, @@ -25,6 +26,7 @@ import type { DaemonSession, DaemonSessionSummary, DaemonSessionSupportedCommandsStatus, + DaemonSessionStatsStatus, DaemonSessionTasksStatus, DaemonUpdateAgentRequest, DaemonWorkspaceFile, @@ -55,6 +57,9 @@ import type { DaemonApprovalModeResult, DaemonInitWorkspaceResult, DaemonMcpRestartResult, + DaemonMcpManageAction, + DaemonMcpManageResult, + DaemonSessionBtwResult, DaemonSessionRecapResult, DaemonShellCommandResult, DaemonRuntimeMcpAddRequest, @@ -728,6 +733,27 @@ export class DaemonClient { ); } + async generateWorkspaceAgent( + description: string, + clientId?: string, + ): Promise { + return await this.fetchWithTimeout( + `${this.baseUrl}/workspace/agents/generate`, + { + method: 'POST', + headers: this.headers({ 'Content-Type': 'application/json' }, clientId), + body: JSON.stringify({ description }), + }, + async (res) => { + if (!res.ok) { + throw await this.failOnError(res, 'POST /workspace/agents/generate'); + } + return (await res.json()) as DaemonGeneratedAgentContent; + }, + MCP_RESTART_DEFAULT_TIMEOUT_MS, + ); + } + async getWorkspaceAgent( agentType: string, ): Promise { @@ -1028,6 +1054,22 @@ export class DaemonClient { ); } + async sessionStats( + sessionId: string, + clientId?: string, + ): Promise { + return await this.fetchWithTimeout( + `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/stats`, + { headers: this.headers({}, clientId) }, + async (res) => { + if (!res.ok) { + throw await this.failOnError(res, 'GET /session/:id/stats'); + } + return (await res.json()) as DaemonSessionStatsStatus; + }, + ); + } + /** * Shared transport for `loadSession` / `resumeSession`. Both routes * share an identical wire shape (POST /session/:id/{load|resume} @@ -1145,6 +1187,27 @@ export class DaemonClient { return (await res.json()) as DaemonSessionRecapResult; } + async btwSession( + sessionId: string, + question: string, + opts?: { signal?: AbortSignal; clientId?: string }, + ): Promise { + const res = await this._fetch( + `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/btw`, + { + method: 'POST', + headers: this.headers( + { 'Content-Type': 'application/json' }, + opts?.clientId, + ), + body: JSON.stringify({ question }), + signal: opts?.signal, + }, + ); + if (!res.ok) throw await this.failOnError(res, 'POST /session/:id/btw'); + return (await res.json()) as DaemonSessionBtwResult; + } + async shellCommand( sessionId: string, command: string, @@ -1253,6 +1316,34 @@ export class DaemonClient { ); } + async manageMcpServer( + serverName: string, + action: DaemonMcpManageAction, + opts?: { clientId?: string; timeoutMs?: number }, + ): Promise { + return await this.fetchWithTimeout( + `${this.baseUrl}/workspace/mcp/${encodeURIComponent(serverName)}/${encodeURIComponent(action)}`, + { + method: 'POST', + headers: this.headers( + { 'Content-Type': 'application/json' }, + opts?.clientId, + ), + body: '{}', + }, + async (res) => { + if (!res.ok) { + throw await this.failOnError( + res, + 'POST /workspace/mcp/:server/:action', + ); + } + return (await res.json()) as DaemonMcpManageResult; + }, + opts?.timeoutMs ?? MCP_RESTART_DEFAULT_TIMEOUT_MS, + ); + } + /** * T2.8 (#4514). Add (or replace) a runtime MCP server. The daemon * validates the config, starts the server, and emits an diff --git a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts index 2ff7eb40d32..432ada0a4f7 100644 --- a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts @@ -15,12 +15,14 @@ import { } from './DaemonClient.js'; import type { DaemonEvent, + DaemonSessionBtwResult, DaemonSessionContextStatus, DaemonSessionContextUsageStatus, DaemonSessionRecapResult, DaemonShellCommandResult, DaemonSessionState, DaemonSession, + DaemonSessionStatsStatus, DaemonSessionSupportedCommandsStatus, DaemonSessionTasksStatus, HeartbeatResult, @@ -286,6 +288,16 @@ export class DaemonSessionClient { }); } + async btw( + question: string, + opts?: { signal?: AbortSignal }, + ): Promise { + return await this.client.btwSession(this.sessionId, question, { + ...(opts?.signal ? { signal: opts.signal } : {}), + ...(this.clientId ? { clientId: this.clientId } : {}), + }); + } + async shellCommand( command: string, signal?: AbortSignal, @@ -321,6 +333,10 @@ export class DaemonSessionClient { return await this.client.sessionTasks(this.sessionId, this.clientId); } + async stats(): Promise { + return await this.client.sessionStats(this.sessionId, this.clientId); + } + async respondToPermission( requestId: string, response: PermissionResponse, diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index bef503db4c2..5983cab2fdc 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -96,6 +96,7 @@ export type { } from './ui/index.js'; export type { DaemonShellTranscriptBlock, + DaemonUserShellTranscriptBlock, DaemonPermissionTranscriptBlock, DaemonStatusTranscriptBlock, DaemonTextTranscriptBlock, @@ -254,10 +255,14 @@ export type { export type { DaemonAgentLevel, DaemonAgentMutationResult, + DaemonGeneratedAgentContent, DaemonApprovalMode, DaemonApprovalModeResult, DaemonInitWorkspaceResult, + DaemonMcpManageAction, + DaemonMcpManageResult, DaemonMcpRestartResult, + DaemonSessionBtwResult, DaemonSessionRecapResult, DaemonShellCommandResult, DaemonRuntimeMcpAddRequest, @@ -303,6 +308,9 @@ export type { DaemonSessionTaskLifecycleStatus, DaemonSessionTaskStatus, DaemonSessionTasksStatus, + DaemonSessionStatsStatus, + DaemonSessionStatsModelMetrics, + DaemonSessionStatsToolByName, DaemonSkillLevel, DaemonPreflightCell, DaemonPreflightKind, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 1f8cbfec30b..353a8fe65bb 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -242,6 +242,15 @@ export interface DaemonWorkspaceMcpServerStatus extends DaemonStatusCell { mcpStatus?: DaemonMcpServerRuntimeStatus; transport: DaemonMcpTransport; disabled: boolean; + hasOAuthTokens?: boolean; + source?: 'user' | 'project' | 'extension'; + config?: { + command?: string; + args?: string[]; + httpUrl?: string; + url?: string; + cwd?: string; + }; description?: string; extensionName?: string; /** @@ -352,6 +361,8 @@ export interface DaemonWorkspaceSkillsStatus { export interface DaemonWorkspaceProviderCurrent { authType?: string; modelId?: string; + baseUrl?: string; + fastModelId?: string; } export interface DaemonWorkspaceProviderModel { @@ -360,6 +371,14 @@ export interface DaemonWorkspaceProviderModel { name: string; description?: string | null; contextLimit?: number; + modalities?: { + image?: boolean; + pdf?: boolean; + audio?: boolean; + video?: boolean; + }; + baseUrl?: string; + envKey?: string; isCurrent: boolean; isRuntime: boolean; } @@ -612,6 +631,12 @@ export interface DaemonCreateAgentRequest { background?: boolean; } +export interface DaemonGeneratedAgentContent { + name: string; + description: string; + systemPrompt: string; +} + /** * Body of `POST /workspace/agents/:agentType`. `name` / `level` / * `filePath` / `isBuiltin` are intentionally omitted — agent type @@ -652,7 +677,8 @@ export type DaemonEnvKind = | 'platform' | 'sandbox' | 'proxy' - | 'env_var'; + | 'env_var' + | 'memory'; export interface DaemonEnvCell extends DaemonStatusCell { kind: DaemonEnvKind; @@ -863,6 +889,56 @@ export interface DaemonSessionTasksStatus { tasks: DaemonSessionTaskStatus[]; } +export interface DaemonSessionStatsModelMetrics { + api: { + totalRequests: number; + totalErrors: number; + totalLatencyMs: number; + }; + tokens: { + prompt: number; + candidates: number; + total: number; + cached: number; + thoughts: number; + }; +} + +export interface DaemonSessionStatsToolByName { + count: number; + success: number; + fail: number; + durationMs: number; + decisions: { + accept: number; + reject: number; + modify: number; + auto_accept: number; + }; +} + +/** Returned from `GET /session/:id/stats`. */ +export interface DaemonSessionStatsStatus { + v: 1; + sessionId: string; + workspaceCwd: string; + sessionStartTimeMs: number; + durationMs: number; + promptCount: number; + models: Record; + tools: { + totalCalls: number; + totalSuccess: number; + totalFail: number; + totalDurationMs: number; + byName: Record; + }; + files: { + totalLinesAdded: number; + totalLinesRemoved: number; + }; +} + /** Returned from `POST /session/:id/model`. ACP currently allows an opaque body. */ export interface SetModelResult { [key: string]: unknown; @@ -956,6 +1032,11 @@ export interface DaemonSessionRecapResult { recap: string | null; } +export interface DaemonSessionBtwResult { + sessionId: string; + answer: string | null; +} + export interface DaemonShellCommandResult { exitCode: number | null; output: string; @@ -993,6 +1074,21 @@ export type DaemonMcpRestartResult = reason: 'in_flight' | 'disabled' | 'budget_would_exceed'; }; +export type DaemonMcpManageAction = + | 'enable' + | 'disable' + | 'authenticate' + | 'clear-auth'; + +export interface DaemonMcpManageResult { + serverName: string; + action: DaemonMcpManageAction; + ok: true; + changed?: boolean; + messages?: string[]; + authUrl?: string; +} + /** * T2.8 (#4514). Structural subset of core's `MCPServerConfig` exposed * on the `POST /workspace/mcp/servers` route body. Covers all wire- diff --git a/packages/sdk-typescript/src/daemon/ui/index.ts b/packages/sdk-typescript/src/daemon/ui/index.ts index fd2f3c2f5ac..82c11ff27b0 100644 --- a/packages/sdk-typescript/src/daemon/ui/index.ts +++ b/packages/sdk-typescript/src/daemon/ui/index.ts @@ -58,6 +58,7 @@ export { DAEMON_PLAN_TOOL_CALL_ID } from './types.js'; export type { DaemonUiContentPart } from './utils.js'; export type { DaemonShellTranscriptBlock, + DaemonUserShellTranscriptBlock, DaemonPermissionTranscriptBlock, DaemonStatusTranscriptBlock, DaemonTextTranscriptBlock, diff --git a/packages/sdk-typescript/src/daemon/ui/normalizer.ts b/packages/sdk-typescript/src/daemon/ui/normalizer.ts index 0b36c221185..98c1b116a21 100644 --- a/packages/sdk-typescript/src/daemon/ui/normalizer.ts +++ b/packages/sdk-typescript/src/daemon/ui/normalizer.ts @@ -60,11 +60,13 @@ export function normalizeDaemonEvent( case 'shell_output': { const text = getOutputText(event.data); const stream = getShellStream(event.data); + const source = getSource(event.data); return text ? [ { ...base, - type: 'shell.output', + type: + source === 'user-shell' ? 'user.shell.output' : 'shell.output', text, ...(stream ? { stream } : {}), }, @@ -170,8 +172,17 @@ export function normalizeDaemonEvent( case 'user_shell_command': { const command = getString(event.data, 'command'); + const cwd = getString(event.data, 'cwd'); return command - ? [{ ...base, type: 'user.text.delta', text: `! ${command}` }] + ? [ + { + ...base, + type: 'user.shell.command', + command, + ...(cwd ? { cwd } : {}), + }, + { ...base, type: 'user.text.delta', text: `$ ${command}` }, + ] : []; } case 'user_shell_result': { @@ -410,11 +421,13 @@ function normalizeSessionUpdate( case 'tool_output': { const text = getOutputText(update); const stream = getShellStream(update) ?? getShellStream(event.data); + const source = getSource(update) ?? getSource(event.data); return text ? [ { ...base, - type: 'shell.output', + type: + source === 'user-shell' ? 'user.shell.output' : 'shell.output', text, ...(stream ? { stream } : {}), }, @@ -776,6 +789,14 @@ function getShellStream(value: unknown): 'stdout' | 'stderr' | undefined { return stream === 'stdout' || stream === 'stderr' ? stream : undefined; } +function getSource(value: unknown): string | undefined { + if (!isRecord(value)) return undefined; + const direct = getString(value, 'source'); + if (direct) return direct; + const meta = value['_meta']; + return isRecord(meta) ? getString(meta, 'source') : undefined; +} + /* ────────────────────────────────────────────────────────────────────────── * Session-meta + workspace + auth normalizers (Wave 3-4 coverage) * diff --git a/packages/sdk-typescript/src/daemon/ui/terminal.ts b/packages/sdk-typescript/src/daemon/ui/terminal.ts index c77ded267a3..e80578e515a 100644 --- a/packages/sdk-typescript/src/daemon/ui/terminal.ts +++ b/packages/sdk-typescript/src/daemon/ui/terminal.ts @@ -26,6 +26,8 @@ export function daemonUiEventToTerminalText(event: DaemonUiEvent): string { ); case 'shell.output': return terminalBlock('shell', event.text, '38;5;244'); + case 'user.shell.output': + return terminalBlock('user-shell', event.text, '38;5;244'); case 'permission.request': { const options = event.options.map((option) => option.label).join(' / '); return terminalLine( @@ -163,6 +165,8 @@ export function daemonUiEventToTerminalText(event: DaemonUiEvent): string { `device-flow cancelled (${event.deviceFlowId})`, '2', ); + case 'user.shell.command': + return ''; default: return assertNever(event); } @@ -186,6 +190,12 @@ export function transcriptBlockToTerminalText( ); case 'shell': return terminalBlock('shell', block.text, '38;5;244'); + case 'user_shell': + return terminalBlock( + `shell command ${block.command}`.trim(), + block.text, + '38;5;244', + ); case 'permission': { const options = block.options.map((option) => option.label).join(' / '); const suffix = block.resolved ? ` resolved=${block.resolved}` : ''; diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index 363e96e0192..9151f7f8e28 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -13,6 +13,7 @@ import type { DaemonTranscriptReducerOptions, DaemonTranscriptState, DaemonUiEvent, + DaemonUserShellTranscriptBlock, } from './types.js'; import { DAEMON_PLAN_TOOL_CALL_ID } from './types.js'; import { createDaemonToolPreview } from './toolPreview.js'; @@ -165,6 +166,12 @@ function applyDaemonTranscriptEvent( } switch (event.type) { + case 'user.shell.command': + next.pendingUserShellCommand = { + command: event.command, + ...(event.cwd ? { cwd: event.cwd } : {}), + }; + break; case 'user.text.delta': if (!next.activeUserBlockId) { next.lastFollowupSuggestion = undefined; @@ -214,6 +221,9 @@ function applyDaemonTranscriptEvent( case 'shell.output': appendShellBlock(next, event); break; + case 'user.shell.output': + appendUserShellBlock(next, event); + break; case 'permission.request': upsertPermissionBlock(next, event); break; @@ -630,6 +640,50 @@ function appendShellBlock( clearActiveText(state); } +function appendUserShellBlock( + state: DaemonTranscriptState, + event: Extract, +): void { + if (!event.text) return; + const last = state.blocks[state.blocks.length - 1]; + if ( + last?.kind === 'user_shell' && + last.stream === event.stream && + !state.pendingUserShellCommand + ) { + const writable = getWritableBlockById(state, last.id); + if (writable?.kind === 'user_shell') { + writable.text = appendBoundedText(writable.text, event.text); + writable.updatedAt = state.now; + if (event.eventId !== undefined) writable.eventId = event.eventId; + } + return; + } + + const pending = state.pendingUserShellCommand; + const previous = last?.kind === 'user_shell' ? last : undefined; + const block: DaemonUserShellTranscriptBlock = { + id: allocateBlockId(state, 'user-shell'), + kind: 'user_shell', + text: truncateText(event.text), + command: pending?.command ?? previous?.command ?? '', + ...(pending?.cwd || previous?.cwd + ? { cwd: pending?.cwd ?? previous?.cwd } + : {}), + clientReceivedAt: state.now, + createdAt: state.now, + updatedAt: state.now, + ...(event.eventId !== undefined ? { eventId: event.eventId } : {}), + ...(event.serverTimestamp !== undefined + ? { serverTimestamp: event.serverTimestamp } + : {}), + ...(event.stream ? { stream: event.stream } : {}), + }; + state.pendingUserShellCommand = undefined; + appendBlock(state, block); + clearActiveText(state); +} + function upsertPermissionBlock( state: DaemonTranscriptState, event: Extract, diff --git a/packages/sdk-typescript/src/daemon/ui/types.ts b/packages/sdk-typescript/src/daemon/ui/types.ts index 9f9f60c9353..2ded6e83059 100644 --- a/packages/sdk-typescript/src/daemon/ui/types.ts +++ b/packages/sdk-typescript/src/daemon/ui/types.ts @@ -17,11 +17,13 @@ export const DAEMON_PLAN_TOOL_CALL_ID = 'daemon-plan'; export type DaemonUiEventType = // Chat-stream events (Stage 1) | 'user.text.delta' + | 'user.shell.command' | 'assistant.text.delta' | 'assistant.done' | 'thought.text.delta' | 'tool.update' | 'shell.output' + | 'user.shell.output' | 'permission.request' | 'permission.resolved' | 'model.changed' @@ -82,6 +84,12 @@ export interface DaemonUiTextEvent extends DaemonUiEventBase { text: string; } +export interface DaemonUiUserShellCommandEvent extends DaemonUiEventBase { + type: 'user.shell.command'; + command: string; + cwd?: string; +} + export interface DaemonUiAssistantDoneEvent extends DaemonUiEventBase { type: 'assistant.done'; reason?: string; @@ -148,6 +156,12 @@ export interface DaemonUiShellOutputEvent extends DaemonUiEventBase { stream?: 'stdout' | 'stderr'; } +export interface DaemonUiUserShellOutputEvent extends DaemonUiEventBase { + type: 'user.shell.output'; + text: string; + stream?: 'stdout' | 'stderr'; +} + export interface DaemonUiPermissionOption { optionId: string; label: string; @@ -415,9 +429,11 @@ export type DaemonUiAuthDeviceFlowEvent = export type DaemonUiEvent = // Chat-stream events | DaemonUiTextEvent + | DaemonUiUserShellCommandEvent | DaemonUiAssistantDoneEvent | DaemonUiToolUpdateEvent | DaemonUiShellOutputEvent + | DaemonUiUserShellOutputEvent | DaemonUiPermissionRequestEvent | DaemonUiPermissionResolvedEvent | DaemonUiModelChangedEvent @@ -579,6 +595,7 @@ export type DaemonTranscriptBlockKind = | 'thought' | 'tool' | 'shell' + | 'user_shell' | 'permission' | 'status' | 'error' @@ -674,6 +691,15 @@ export interface DaemonShellTranscriptBlock extends DaemonTranscriptBlockBase { stream?: 'stdout' | 'stderr'; } +export interface DaemonUserShellTranscriptBlock + extends DaemonTranscriptBlockBase { + kind: 'user_shell'; + text: string; + command: string; + cwd?: string; + stream?: 'stdout' | 'stderr'; +} + export interface DaemonPermissionTranscriptBlock extends DaemonTranscriptBlockBase { kind: 'permission'; @@ -695,6 +721,7 @@ export type DaemonTranscriptBlock = | DaemonTextTranscriptBlock | DaemonToolTranscriptBlock | DaemonShellTranscriptBlock + | DaemonUserShellTranscriptBlock | DaemonPermissionTranscriptBlock | DaemonStatusTranscriptBlock; @@ -745,6 +772,10 @@ export interface DaemonTranscriptSidechannelState { suggestion: string; promptId: string; }; + pendingUserShellCommand?: { + command: string; + cwd?: string; + }; } export interface DaemonTranscriptState diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index 29400dd53fe..ccc4fb597b5 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -199,6 +199,61 @@ describe('daemon UI normalizer and transcript reducer', () => { ).toMatchObject([{ type: 'user.text.delta', text: 'hello' }]); }); + it('carries user shell command metadata into user shell transcript blocks', () => { + let state = createDaemonTranscriptState({ now: 1 }); + const commandEvents = normalizeDaemonEvent({ + id: 25, + v: 1, + type: 'user_shell_command', + data: { + sessionId: 'session-1', + command: 'ls', + cwd: '/workspace/project', + }, + }); + const outputEvents = normalizeDaemonEvent({ + id: 26, + v: 1, + type: 'session_update', + data: { + sessionId: 'session-1', + update: { + sessionUpdate: 'shell_output', + output: 'README.md\n', + _meta: { source: 'user-shell' }, + }, + }, + }); + + expect(commandEvents).toMatchObject([ + { + type: 'user.shell.command', + command: 'ls', + cwd: '/workspace/project', + }, + { type: 'user.text.delta', text: '$ ls' }, + ]); + expect(outputEvents).toMatchObject([ + { type: 'user.shell.output', text: 'README.md\n' }, + ]); + + state = reduceDaemonTranscriptEvents( + state, + [...commandEvents, ...outputEvents], + { now: 2 }, + ); + + expect(state.blocks).toMatchObject([ + { kind: 'user', text: '$ ls' }, + { + kind: 'user_shell', + text: 'README.md\n', + command: 'ls', + cwd: '/workspace/project', + }, + ]); + }); + it('optionally carries raw daemon events for diagnostics', () => { const event = { id: 24, diff --git a/packages/web-shell/client/App.module.css b/packages/web-shell/client/App.module.css index 86b18f2c05b..6caa590b93b 100644 --- a/packages/web-shell/client/App.module.css +++ b/packages/web-shell/client/App.module.css @@ -129,6 +129,11 @@ padding-bottom: 4px; } +.btwPanel { + flex-shrink: 0; + padding: 4px 0; +} + .composer { flex-shrink: 0; padding: 0; diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index faef8f0cf31..553094e8672 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -27,14 +27,22 @@ import { StreamingStatus } from './components/StreamingStatus'; import { TodoPanel } from './components/panels/TodoPanel'; import { ActiveAgentsPanel } from './components/panels/ActiveAgentsPanel'; import { WelcomeHeader } from './components/WelcomeHeader'; -import { ModelDialog } from './components/dialogs/ModelDialog'; import { ApprovalModeDialog } from './components/dialogs/ApprovalModeDialog'; import { ResumeDialog } from './components/dialogs/ResumeDialog'; -import { McpDialog } from './components/dialogs/McpDialog'; -import { MemoryDialog } from './components/dialogs/MemoryDialog'; -import type { MemoryDialogInitialMode } from './components/dialogs/MemoryDialog'; -import { AgentsDialog } from './components/dialogs/AgentsDialog'; -import type { AgentsDialogInitialMode } from './components/dialogs/AgentsDialog'; +import { + AGENTS_ACTIVE_EVENT, + AgentsMessage, + type AgentsInitialMode, +} from './components/messages/AgentsMessage'; +import { + MEMORY_ACTIVE_EVENT, + MemoryMessage, +} from './components/messages/MemoryMessage'; +import { + MODEL_ACTIVE_EVENT, + ModelMessage, + type ModelInlineMode, +} from './components/messages/ModelMessage'; import { ToolsDialog } from './components/dialogs/ToolsDialog'; import { HelpDialog } from './components/dialogs/HelpDialog'; import { @@ -65,6 +73,19 @@ import { type DaemonApprovalMode, } from '@qwen-code/webui/daemon-react-sdk'; import { serializeContextUsageMessage } from './components/messages/ContextUsageMessage'; +import { + serializeStatsMessage, + type StatsView, +} from './components/messages/StatsMessage'; +import { + serializeStatusMessage, + type StatusInfo, +} from './components/messages/StatusMessage'; +import { + MCP_STATUS_ACTIVE_EVENT, + serializeMcpStatusMessage, +} from './components/messages/McpStatusMessage'; +import { BtwMessage } from './components/messages/BtwMessage'; import type { ACPToolCall, Message, @@ -88,12 +109,25 @@ interface QueuedPrompt { images?: PromptImage[]; } -interface LocalRecapMessage { +interface LocalAnchoredMessage { anchorAfterId?: string; anchorIndex: number; message: Message; } +interface ModelSwitchSummary { + authType: string; + modelId: string; + baseUrl: string; + apiKey: string; + isRuntime?: boolean; +} + +export interface BugReportInfo { + title: string; + systemInfo: Record; +} + export interface WebShellProps { /** Called whenever the attached daemon session id changes. */ onSessionIdChange?: (sessionId: string) => void; @@ -115,6 +149,8 @@ export interface WebShellProps { onStreamingStateChange?: (state: DaemonStreamingState) => void; /** Called when a critical error occurs (auth failure, session gone, etc). */ onError?: (error: Error) => void; + /** Called when `/bug` is invoked. Receives system info. If omitted, web-shell opens the report URL itself. */ + onBugReport?: (info: BugReportInfo) => void; } function replaceSessionUrl(sessionId: string): void { @@ -140,6 +176,86 @@ function formatError(error: unknown, fallback: string): string { return error instanceof Error ? error.message : fallback; } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function formatModelAuthType(authType: string): string { + const normalized = authType.trim(); + if (normalized.startsWith('USE_')) { + return normalized.slice(4).toLowerCase().replace(/_/g, '-'); + } + return normalized.toLowerCase(); +} + +function getModelSwitchSummary(result: unknown): ModelSwitchSummary | null { + if (!isRecord(result)) return null; + const meta = result._meta; + if (!isRecord(meta)) return null; + const summary = meta.qwenModelSwitch; + if (!isRecord(summary)) return null; + const authType = summary.authType; + const modelId = summary.modelId; + const baseUrl = summary.baseUrl; + const apiKey = summary.apiKey; + if ( + typeof authType !== 'string' || + typeof modelId !== 'string' || + typeof baseUrl !== 'string' || + typeof apiKey !== 'string' + ) { + return null; + } + return { + authType, + modelId, + baseUrl, + apiKey, + ...(typeof summary.isRuntime === 'boolean' + ? { isRuntime: summary.isRuntime } + : {}), + }; +} + +function serializeModelSwitchSummary(summary: ModelSwitchSummary): string { + return ( + `● authType: ${formatModelAuthType(summary.authType)}` + + `\n Using ${summary.isRuntime ? 'runtime ' : ''}model: ${summary.modelId}` + + `\n Base URL: ${summary.baseUrl}` + + `\n API key: ${summary.apiKey}` + ); +} + +function parseModelSwitchStatusModel(content: string): string | null { + const prefix = 'Model switched: '; + if (!content.startsWith(prefix)) return null; + const rawModel = content.slice(prefix.length).trim(); + return rawModel.replace(/\([^()]+\)$/, ''); +} + +function parseModelSwitchSummaryModel(content: string): string | null { + if (!content.startsWith('● authType:')) return null; + const match = content.match(/\n {2}Using (?:runtime )?model: ([^\n]+)/); + return match?.[1]?.trim() || null; +} + +function filterDuplicateModelSwitchMessages( + messages: readonly Message[], +): Message[] { + const summarizedModels = new Set(); + for (const message of messages) { + if (message.role !== 'system' || message.variant !== 'info') continue; + const model = parseModelSwitchSummaryModel(message.content); + if (model) summarizedModels.add(model); + } + if (summarizedModels.size === 0) return [...messages]; + return messages.filter((message) => { + if (message.role !== 'system' || message.variant !== 'info') return true; + const statusModel = parseModelSwitchStatusModel(message.content); + return !statusModel || !summarizedModels.has(statusModel); + }); +} + function isDaemonApprovalMode(mode: string): mode is DaemonApprovalMode { return DAEMON_APPROVAL_MODES.includes(mode as DaemonApprovalMode); } @@ -310,6 +426,7 @@ export function App({ onConnectionChange, onStreamingStateChange, onError, + onBugReport, }: WebShellProps = {}) { const [selectedLanguage, setSelectedLanguage] = useState( () => @@ -327,27 +444,38 @@ export function App({ const messages = useMessages(); const messagesRef = useRef(messages); messagesRef.current = messages; - const [recapMessage, setRecapMessage] = useState( + const [recapMessage, setRecapMessage] = useState( null, ); + const [btwMessage, setBtwMessage] = useState(null); const nextRecapMessageIdRef = useRef(1); + const nextBtwMessageIdRef = useRef(1); + const btwAbortControllerRef = useRef(null); const activeSessionIdRef = useRef(connection.sessionId); const displayMessages = useMemo(() => { - if (!recapMessage) return messages; - const anchorIndex = recapMessage.anchorAfterId - ? messages.findIndex( - (message) => message.id === recapMessage.anchorAfterId, - ) - : -1; - const index = - anchorIndex >= 0 - ? anchorIndex + 1 - : Math.min(recapMessage.anchorIndex, messages.length); - return [ - ...messages.slice(0, index), - recapMessage.message, - ...messages.slice(index), - ]; + const localMessages = [recapMessage].filter( + (message): message is LocalAnchoredMessage => message !== null, + ); + if (localMessages.length === 0) { + return filterDuplicateModelSwitchMessages(messages); + } + + const result = [...messages]; + for (const localMessage of localMessages.sort( + (a, b) => a.anchorIndex - b.anchorIndex, + )) { + const anchorIndex = localMessage.anchorAfterId + ? result.findIndex( + (message) => message.id === localMessage.anchorAfterId, + ) + : -1; + const index = + anchorIndex >= 0 + ? anchorIndex + 1 + : Math.min(localMessage.anchorIndex, result.length); + result.splice(index, 0, localMessage.message); + } + return filterDuplicateModelSwitchMessages(result); }, [messages, recapMessage]); const messageBlocks = useAnimationFrameValue(blocks); const rawPendingApproval = useMemo( @@ -415,22 +543,30 @@ export function App({ .catch(() => {}); }, [connected, workspaceActions]); - const [modelDialogMode, setModelDialogMode] = useState< - 'main' | 'fast' | null - >(null); + const [modelInlineMode, setModelInlineMode] = + useState(null); const [showModeDialog, setShowModeDialog] = useState(false); const [showResumeDialog, setShowResumeDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showReleaseDialog, setShowReleaseDialog] = useState(false); - const [showMcpDialog, setShowMcpDialog] = useState(false); const [showHelpDialog, setShowHelpDialog] = useState(false); const [showThemeDialog, setShowThemeDialog] = useState(false); const [showToolsDialog, setShowToolsDialog] = useState(false); - const [memoryDialogMode, setMemoryDialogMode] = - useState(null); - const [agentsDialogMode, setAgentsDialogMode] = - useState(null); + const [memoryInlineOpen, setMemoryInlineOpen] = useState(false); + const [memoryRefreshSignal, setMemoryRefreshSignal] = useState(0); + const [memoryAddSignal, setMemoryAddSignal] = useState(0); + const [memoryAddScope, setMemoryAddScope] = useState<'workspace' | 'global'>( + 'workspace', + ); + const [agentsInlineMode, setAgentsInlineMode] = + useState(null); + const [memoryPortalHost, setMemoryPortalHost] = + useState(null); const [showShortcuts, setShowShortcuts] = useState(false); + const [mcpPanelActive, setMcpPanelActive] = useState(false); + const [agentsPanelActive, setAgentsPanelActive] = useState(false); + const [memoryPanelActive, setMemoryPanelActive] = useState(false); + const [modelPanelActive, setModelPanelActive] = useState(false); const [selectedTheme, setSelectedTheme] = useState(providedTheme); const [currentModel, setCurrentModel] = useState(''); @@ -440,17 +576,95 @@ export function App({ const nextQueuedPromptIdRef = useRef(1); const drainingQueueRef = useRef(false); const dialogOpen = - !!modelDialogMode || showModeDialog || showResumeDialog || showDeleteDialog || showReleaseDialog || - showMcpDialog || showHelpDialog || showThemeDialog || - showToolsDialog || - !!memoryDialogMode || - !!agentsDialogMode; + showToolsDialog; + const bottomHidden = + dialogOpen || + mcpPanelActive || + agentsPanelActive || + memoryPanelActive || + modelPanelActive; + + useEffect(() => { + const activePanels = new Set(); + const onMcpPanelActive = (event: Event) => { + const detail = (event as CustomEvent<{ id?: string; active?: boolean }>) + .detail; + if (!detail?.id) return; + if (detail.active) { + activePanels.add(detail.id); + } else { + activePanels.delete(detail.id); + } + setMcpPanelActive(activePanels.size > 0); + }; + window.addEventListener(MCP_STATUS_ACTIVE_EVENT, onMcpPanelActive); + return () => { + window.removeEventListener(MCP_STATUS_ACTIVE_EVENT, onMcpPanelActive); + }; + }, []); + + useEffect(() => { + const activePanels = new Set(); + const onAgentsPanelActive = (event: Event) => { + const detail = (event as CustomEvent<{ id?: string; active?: boolean }>) + .detail; + if (!detail?.id) return; + if (detail.active) { + activePanels.add(detail.id); + } else { + activePanels.delete(detail.id); + } + setAgentsPanelActive(activePanels.size > 0); + }; + window.addEventListener(AGENTS_ACTIVE_EVENT, onAgentsPanelActive); + return () => { + window.removeEventListener(AGENTS_ACTIVE_EVENT, onAgentsPanelActive); + }; + }, []); + + useEffect(() => { + const activePanels = new Set(); + const onMemoryPanelActive = (event: Event) => { + const detail = (event as CustomEvent<{ id?: string; active?: boolean }>) + .detail; + if (!detail?.id) return; + if (detail.active) { + activePanels.add(detail.id); + } else { + activePanels.delete(detail.id); + } + setMemoryPanelActive(activePanels.size > 0); + }; + window.addEventListener(MEMORY_ACTIVE_EVENT, onMemoryPanelActive); + return () => { + window.removeEventListener(MEMORY_ACTIVE_EVENT, onMemoryPanelActive); + }; + }, []); + + useEffect(() => { + const activePanels = new Set(); + const onModelPanelActive = (event: Event) => { + const detail = (event as CustomEvent<{ id?: string; active?: boolean }>) + .detail; + if (!detail?.id) return; + if (detail.active) { + activePanels.add(detail.id); + } else { + activePanels.delete(detail.id); + } + setModelPanelActive(activePanels.size > 0); + }; + window.addEventListener(MODEL_ACTIVE_EVENT, onModelPanelActive); + return () => { + window.removeEventListener(MODEL_ACTIVE_EVENT, onModelPanelActive); + }; + }, []); const reportError = useCallback( (error: unknown, fallback: string) => { @@ -459,9 +673,15 @@ export function App({ [store], ); + const onBugReportRef = useRef(onBugReport); + onBugReportRef.current = onBugReport; + useEffect(() => { activeSessionIdRef.current = connection.sessionId; + btwAbortControllerRef.current?.abort(); + btwAbortControllerRef.current = null; setRecapMessage(null); + setBtwMessage(null); lastRecapBlockCountRef.current = 0; }, [connection.sessionId]); @@ -512,6 +732,114 @@ export function App({ ); }, [connection.sessionId, messages, sessionActions, t]); + const runVisibleBtw = useCallback( + (rawQuestion: string) => { + const question = rawQuestion.trim(); + if (!question) { + store.dispatch([ + { + type: 'error', + text: t('btw.empty'), + }, + ]); + return; + } + + const messageId = `local-btw-${nextBtwMessageIdRef.current++}`; + const sessionId = connection.sessionId; + btwAbortControllerRef.current?.abort(); + const abortController = new AbortController(); + btwAbortControllerRef.current = abortController; + setBtwMessage({ + id: messageId, + role: 'btw', + question, + answer: '', + isPending: true, + }); + + sessionActions + .btwSession(question, { signal: abortController.signal }) + .then( + (result) => { + if (activeSessionIdRef.current !== sessionId) return; + if (btwAbortControllerRef.current !== abortController) return; + btwAbortControllerRef.current = null; + setBtwMessage({ + id: messageId, + role: 'btw', + question, + answer: result.answer || t('btw.emptyAnswer'), + isPending: false, + }); + }, + (error: unknown) => { + if (activeSessionIdRef.current !== sessionId) return; + if (btwAbortControllerRef.current !== abortController) return; + btwAbortControllerRef.current = null; + setBtwMessage({ + id: messageId, + role: 'btw', + question, + answer: formatError(error, t('btw.failed')), + isPending: false, + }); + }, + ); + }, + [connection.sessionId, sessionActions, store, t], + ); + + const dismissBtwMessage = useCallback(() => { + btwAbortControllerRef.current?.abort(); + btwAbortControllerRef.current = null; + setBtwMessage(null); + }, []); + + useEffect(() => { + const onBtwShortcut = (e: KeyboardEvent) => { + if (bottomHidden || pendingApproval) return; + const message = btwMessage; + if (!message || message.role !== 'btw') return; + + const key = e.key.toLowerCase(); + const isPlainEscape = + e.key === 'Escape' && + !e.ctrlKey && + !e.metaKey && + !e.altKey && + !e.shiftKey; + const isCtrlCancel = + e.ctrlKey && + !e.metaKey && + !e.altKey && + !e.shiftKey && + (key === 'c' || key === 'd'); + + if (message.isPending) { + if (!isPlainEscape && !isCtrlCancel) return; + } else { + const editorHasText = + (editorRef.current?.getText().trim().length ?? 0) > 0; + const isPlainDismiss = + !e.ctrlKey && + !e.metaKey && + !e.altKey && + !e.shiftKey && + (e.key === 'Escape' || + (!editorHasText && (e.key === 'Enter' || e.key === ' '))); + if (!isPlainDismiss) return; + } + + e.preventDefault(); + e.stopPropagation(); + dismissBtwMessage(); + }; + + window.addEventListener('keydown', onBtwShortcut, true); + return () => window.removeEventListener('keydown', onBtwShortcut, true); + }, [bottomHidden, btwMessage, dismissBtwMessage, pendingApproval]); + useEffect(() => { queuedPromptsRef.current = queuedPrompts; }, [queuedPrompts]); @@ -869,8 +1197,8 @@ export function App({ if (cmd === 'model') { const modelArg = text.slice(match[0].length).trim(); if (modelArg === '--fast') { - if (promptBlocked) return false; - setModelDialogMode('fast'); + store.appendLocalUserMessage(text); + setModelInlineMode('fast'); return true; } if (modelArg.startsWith('--fast ')) { @@ -890,7 +1218,8 @@ export function App({ reportError(error, t('model.switch')); }); } else { - setModelDialogMode('main'); + store.appendLocalUserMessage(text); + setModelInlineMode('main'); } return true; } @@ -922,7 +1251,37 @@ export function App({ return true; } if (cmd === 'mcp') { - setShowMcpDialog(true); + const mcpArg = text.slice(match[0].length).trim().toLowerCase(); + store.appendLocalUserMessage(text); + workspaceActions + .loadMcpStatus() + .then(async (status) => { + const toolsByServer: Record< + string, + Awaited> + > = {}; + await Promise.all( + (status?.servers ?? []).map(async (server) => { + toolsByServer[server.name] = + await workspaceActions.loadMcpTools(server.name); + }), + ); + store.dispatch([ + { + type: 'status', + text: serializeMcpStatusMessage({ + status, + toolsByServer, + showDescriptions: mcpArg === 'desc', + showSchema: mcpArg === 'schema', + showTips: !mcpArg, + }), + }, + ]); + }) + .catch((error: unknown) => { + reportError(error, 'Failed to load MCP status'); + }); return true; } if (cmd === 'skills') { @@ -1022,43 +1381,41 @@ export function App({ } if (cmd === 'memory') { const memoryArg = text.slice(match[0].length).trim().toLowerCase(); - if (memoryArg === 'show') { - setMemoryDialogMode('show'); - } else if (memoryArg === 'refresh') { - setMemoryDialogMode('refresh'); - } else if (memoryArg === 'add user' || memoryArg === 'add global') { - setMemoryDialogMode('add-user'); - } else if ( - memoryArg === 'add project' || - memoryArg === 'add workspace' - ) { - setMemoryDialogMode('add-project'); - } else if (memoryArg.startsWith('add')) { - setMemoryDialogMode('add'); - } else { - setMemoryDialogMode('menu'); + store.appendLocalUserMessage(text); + if (memoryArg === 'refresh') { + setMemoryRefreshSignal((signal) => signal + 1); + } else if (memoryArg === 'add' || memoryArg.startsWith('add ')) { + const addTarget = memoryArg.slice('add'.length).trim(); + setMemoryAddScope( + addTarget === 'user' || addTarget === 'global' + ? 'global' + : 'workspace', + ); + setMemoryAddSignal((signal) => signal + 1); } + setMemoryInlineOpen(true); return true; } if (cmd === 'agents') { const subCommand = text.slice(match[0].length).trim().toLowerCase(); + store.appendLocalUserMessage(text); + let agentsMode: AgentsInitialMode = 'menu'; if (subCommand === 'create') { - setAgentsDialogMode('create'); + agentsMode = 'create'; } else if ( subCommand === 'create user' || subCommand === 'create global' ) { - setAgentsDialogMode('create-user'); + agentsMode = 'create-user'; } else if ( subCommand === 'create project' || subCommand === 'create workspace' ) { - setAgentsDialogMode('create-project'); + agentsMode = 'create-project'; } else if (subCommand === 'manage') { - setAgentsDialogMode('manage'); - } else { - setAgentsDialogMode('menu'); + agentsMode = 'manage'; } + setAgentsInlineMode(agentsMode); return true; } if (cmd === 'clear') { @@ -1128,6 +1485,191 @@ export function App({ runVisibleRecap(); return true; } + if (cmd === 'btw') { + runVisibleBtw(text.slice(match[0].length)); + return true; + } + if (cmd === 'stats') { + const statsArg = text.slice(match[0].length).trim().toLowerCase(); + let statsView: StatsView = 'overview'; + if (statsArg === 'model') statsView = 'model'; + else if (statsArg === 'tools') statsView = 'tools'; + store.appendLocalUserMessage(text); + sessionActions + .getStats() + .then((result) => { + store.dispatch([ + { + type: 'status', + text: serializeStatsMessage(result, statsView), + }, + ]); + }) + .catch((error: unknown) => { + reportError(error, 'Failed to load stats'); + }); + return true; + } + if (cmd === 'status' || cmd === 'about') { + store.appendLocalUserMessage(text); + Promise.all([ + workspaceActions.loadPreflight().catch(() => null), + workspaceActions.loadProviders().catch(() => null), + workspaceActions.loadEnv().catch(() => null), + ]).then(([preflight, providers, env]) => { + let nodeVersion = ''; + let npmVersion = ''; + let authSource = ''; + + if (preflight) { + for (const cell of preflight.cells) { + const d = cell.detail as Record | undefined; + if (cell.kind === 'node_version' && d?.version) { + nodeVersion = d.version; + } else if (cell.kind === 'npm' && d?.version) { + npmVersion = String(d.version).replace(/^npm\s*/i, ''); + } else if (cell.kind === 'auth' && d?.source) { + authSource = d.source; + } + } + } + + if (!authSource && providers?.current?.authType) { + authSource = providers.current.authType; + } + + let platformStr = ''; + let sandboxStr = 'no sandbox'; + let proxyStr = 'no proxy'; + let memoryUsage = ''; + + if (env) { + for (const cell of env.cells) { + if (cell.kind === 'platform') { + platformStr = `${cell.name} ${cell.value ?? ''}`.trim(); + } else if ( + cell.kind === 'sandbox' && + cell.name === 'SANDBOX' + ) { + sandboxStr = cell.value || 'no sandbox'; + } else if ( + cell.kind === 'proxy' && + cell.present && + cell.value + ) { + proxyStr = `${cell.name}: ${cell.value}`; + } else if (cell.kind === 'memory' && cell.value) { + memoryUsage = cell.value; + } + } + } + + const runtimeParts: string[] = []; + if (nodeVersion) runtimeParts.push(`Node.js v${nodeVersion}`); + if (npmVersion) runtimeParts.push(`npm ${npmVersion}`); + + let formattedAuth = ''; + if (authSource) { + if ( + authSource.startsWith('oauth') || + authSource === 'qwen-oauth' + ) { + formattedAuth = 'Qwen OAuth'; + } else { + formattedAuth = `API Key - ${authSource}`; + } + } + + const info: StatusInfo = { + cliVersion: WEB_SHELL_VERSION, + runtime: runtimeParts.join(' / '), + platform: platformStr, + auth: formattedAuth, + baseUrl: providers?.current?.baseUrl || '', + model: + currentModel || + connection.currentModel || + providers?.current?.modelId || + '', + fastModel: + providers?.current?.fastModelId || + currentModel || + connection.currentModel || + providers?.current?.modelId || + '', + sessionId: connection.sessionId || '', + sandbox: sandboxStr, + proxy: proxyStr, + memoryUsage, + }; + + store.dispatch([ + { type: 'status', text: serializeStatusMessage(info) }, + ]); + }); + return true; + } + if (cmd === 'bug') { + const bugTitle = text.slice(match[0].length).trim(); + Promise.all([ + workspaceActions.loadPreflight().catch(() => null), + workspaceActions.loadEnv().catch(() => null), + ]) + .then(([preflight, env]) => { + const sysInfo: Record = { + cliVersion: WEB_SHELL_VERSION, + }; + if (preflight) { + for (const cell of preflight.cells) { + const d = cell.detail as Record | undefined; + if (cell.kind === 'node_version' && d?.version) { + sysInfo.nodeVersion = d.version; + } else if (cell.kind === 'npm' && d?.version) { + sysInfo.npmVersion = String(d.version).replace( + /^npm\s*/i, + '', + ); + } + } + } + if (env) { + for (const cell of env.cells) { + if (cell.kind === 'platform') { + sysInfo.platform = cell.name; + if (cell.value) sysInfo.arch = cell.value; + } else if ( + cell.kind === 'sandbox' && + cell.name === 'SANDBOX' + ) { + sysInfo.sandbox = cell.value || 'none'; + } else if (cell.kind === 'memory' && cell.value) { + sysInfo.memoryUsage = cell.value; + } + } + } + if (onBugReportRef.current) { + onBugReportRef.current({ + title: bugTitle, + systemInfo: sysInfo, + }); + } else { + const fields = Object.entries(sysInfo) + .filter(([, v]) => v) + .map(([k, v]) => `${k}: ${v}`) + .join('\n'); + const url = + `https://github.com/QwenLM/qwen-code/issues/new?template=bug_report.yml` + + `&title=${encodeURIComponent(bugTitle)}` + + `&info=${encodeURIComponent('\n' + fields + '\n')}`; + window.open(url, '_blank'); + } + store.dispatch([{ type: 'status', text: t('bug.submitted') }]); + }) + .catch((error: unknown) => { + reportError(error, t('bug.failed')); + }); + return true; + } } // Forward slash commands as prompts if (promptBlocked) return enqueuePrompt(text, images); @@ -1161,9 +1703,13 @@ export function App({ onLanguageChange, reportError, runVisibleRecap, + runVisibleBtw, selectedLanguage, t, workspaceActions, + connection.currentModel, + connection.sessionId, + currentModel, ], ); @@ -1171,7 +1717,7 @@ export function App({ if (drainingQueueRef.current) return; if (!connected) return; if (streamingState !== 'idle') return; - if (dialogOpen) return; + if (bottomHidden) return; if (pendingApproval) return; if (queuedPrompts.length === 0) return; @@ -1192,7 +1738,7 @@ export function App({ }; }, [ connected, - dialogOpen, + bottomHidden, handleSubmit, pendingApproval, popNextQueuedPrompt, @@ -1241,7 +1787,7 @@ export function App({ useEffect(() => { const onGlobalShortcut = (e: KeyboardEvent) => { - if (dialogOpen) return; + if (bottomHidden) return; if (e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) { if (e.key === 'l') { e.preventDefault(); @@ -1262,12 +1808,12 @@ export function App({ }; window.addEventListener('keydown', onGlobalShortcut, true); return () => window.removeEventListener('keydown', onGlobalShortcut, true); - }, [dialogOpen, handleClearScreen, handleToggleCompact]); + }, [bottomHidden, handleClearScreen, handleToggleCompact]); useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { if (e.defaultPrevented) return; - if (e.key === 'Tab' && e.shiftKey && !dialogOpen) { + if (e.key === 'Tab' && e.shiftKey && !bottomHidden) { e.preventDefault(); handleCycleMode(); return; @@ -1275,7 +1821,7 @@ export function App({ if ( e.key === 'Escape' && !pendingApproval && - !dialogOpen && + !bottomHidden && clearQueuedPrompts() ) { e.preventDefault(); @@ -1285,7 +1831,7 @@ export function App({ e.key === 'Escape' && streamingState !== 'idle' && !pendingApproval && - !dialogOpen + !bottomHidden ) { handleCancel(); return; @@ -1298,7 +1844,7 @@ export function App({ handleCancel, handleCycleMode, pendingApproval, - dialogOpen, + bottomHidden, clearQueuedPrompts, ]); @@ -1308,14 +1854,21 @@ export function App({ (modelId: string) => { sessionActions .setModel(modelId) - .then(() => { - setCurrentModel(modelId); + .then((result) => { + const summary = getModelSwitchSummary(result); + setCurrentModel(summary?.modelId ?? modelId); + if (summary) { + store.dispatch({ + type: 'debug', + text: serializeModelSwitchSummary(summary), + }); + } }) .catch((error: unknown) => { reportError(error, t('model.switch')); }); }, - [sessionActions, reportError, t], + [sessionActions, store, reportError, t], ); const handleFastModelSelect = useCallback( @@ -1361,20 +1914,9 @@ export function App({ return ( -
+
{dialogOpen && (
- {modelDialogMode && ( - setModelDialogMode(null)} - /> - )} {showResumeDialog && ( { @@ -1445,9 +1987,6 @@ export function App({ onClose={() => setShowModeDialog(false)} /> )} - {showMcpDialog && ( - setShowMcpDialog(false)} /> - )} {showHelpDialog && ( setShowToolsDialog(false)} /> )} - {memoryDialogMode && ( - { - store.dispatch([{ type, text }]); - }} - onClose={() => setMemoryDialogMode(null)} - /> - )} - {agentsDialogMode && ( - { - store.dispatch([{ type, text }]); - }} - onClose={() => setAgentsDialogMode(null)} - /> - )}
)} @@ -1501,15 +2022,69 @@ export function App({ catchingUp={connection.catchingUp} workspaceCwd={connection.workspaceCwd || ''} welcomeHeader={welcomeHeader} + tailContent={ + agentsInlineMode || memoryInlineOpen || modelInlineMode ? ( + <> + {modelInlineMode && ( + setModelInlineMode(null)} + /> + )} + {agentsInlineMode && ( + + store.dispatch([{ type: 'status', text }]) + } + onClose={() => setAgentsInlineMode(null)} + /> + )} + {memoryInlineOpen && ( + { + store.dispatch([{ type, text }]); + }} + onClose={() => setMemoryInlineOpen(false)} + /> + )} + + ) : undefined + } + tailKey={ + agentsInlineMode || memoryInlineOpen || modelInlineMode + ? `inline-${modelInlineMode ?? 'none'}-${agentsInlineMode ?? 'none'}-${memoryInlineOpen ? 'memory' : 'none'}` + : undefined + } /> + {btwMessage?.role === 'btw' && ( +
+ +
+ )} +
+
{floatingTodos.length > 0 && (
@@ -1532,7 +2107,7 @@ export function App({ onPopQueuedMessages={popQueuedPromptsForEdit} onClearQueuedMessages={clearQueuedPrompts} currentMode={currentMode} - dialogOpen={dialogOpen} + dialogOpen={bottomHidden} followupState={followupState} onAcceptFollowup={onAcceptFollowup} onDismissFollowup={onDismissFollowup} diff --git a/packages/web-shell/client/adapters/types.ts b/packages/web-shell/client/adapters/types.ts index 4b38cba37c1..2be0ae4f5a7 100644 --- a/packages/web-shell/client/adapters/types.ts +++ b/packages/web-shell/client/adapters/types.ts @@ -21,6 +21,8 @@ export type StreamingState = DaemonStreamingState; export type { DaemonUserMessage as UserMessage, DaemonAssistantMessage as AssistantMessage, + DaemonInsightProgressMessage as InsightProgressMessage, + DaemonInsightReadyMessage as InsightReadyMessage, DaemonToolGroupMessage as ToolGroupMessage, DaemonPlanMessage as PlanMessage, DaemonSystemMessage as SystemMessage, diff --git a/packages/web-shell/client/completions/slashCompletion.test.ts b/packages/web-shell/client/completions/slashCompletion.test.ts new file mode 100644 index 00000000000..35d2c4c40c4 --- /dev/null +++ b/packages/web-shell/client/completions/slashCompletion.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; +import { CompletionContext } from '@codemirror/autocomplete'; +import { EditorState } from '@codemirror/state'; +import type { CommandInfo } from '../adapters/types'; +import { + getSlashCommandArgumentHint, + slashCompletionSource, +} from './slashCompletion'; + +describe('getSlashCommandArgumentHint', () => { + it('returns a command argument hint for a bare slash command', () => { + const commands: CommandInfo[] = [ + { + name: 'stats', + description: 'Show usage stats', + argumentHint: '[model|tools]', + }, + ]; + + expect(getSlashCommandArgumentHint('/stats', commands, 'en')).toBe( + '[model|tools]', + ); + expect(getSlashCommandArgumentHint('/stats ', commands, 'en')).toBe( + '[model|tools]', + ); + }); + + it('falls back to implicit subcommands when no argument hint is provided', () => { + const commands: CommandInfo[] = [ + { + name: 'context', + description: 'Show context usage', + }, + ]; + + expect(getSlashCommandArgumentHint('/context', commands, 'en')).toBe( + '[detail]', + ); + }); + + it('does not return a hint once arguments are being typed', () => { + const commands: CommandInfo[] = [ + { + name: 'stats', + description: 'Show usage stats', + argumentHint: '[model|tools]', + }, + ]; + + expect(getSlashCommandArgumentHint('/stats m', commands, 'en')).toBeNull(); + }); +}); + +describe('slashCompletionSource', () => { + it('completes a top-level slash command from any cursor position in the command', () => { + const commands: CommandInfo[] = [ + { name: 'context', description: 'Show context usage' }, + { name: 'clear', description: 'Clear the screen' }, + ]; + const source = slashCompletionSource(() => commands); + + for (const pos of [0, 2, 4]) { + const state = EditorState.create({ doc: '/con' }); + const result = source(new CompletionContext(state, pos, true)); + + expect(result?.from).toBe(0); + expect(result?.to).toBe(4); + expect(result?.options.map((option) => option.label)).toEqual([ + '/context', + ]); + } + }); + + it('completes implicit /mcp subcommands', () => { + const commands: CommandInfo[] = [ + { + name: 'mcp', + description: 'Manage MCP servers', + argumentHint: 'desc|nodesc|schema|auth|noauth', + }, + ]; + const source = slashCompletionSource(() => commands); + const state = EditorState.create({ doc: '/mcp d' }); + const result = source(new CompletionContext(state, 6, true)); + + expect(result?.options.map((option) => option.label)).toEqual([ + 'desc', + 'nodesc', + ]); + expect(result?.options[0]?.apply).toBe('/mcp desc '); + }); + + it('does not expose third-level /agents create completions', () => { + const commands: CommandInfo[] = [ + { + name: 'agents', + description: 'Manage subagents', + argumentHint: 'manage|create', + }, + ]; + const source = slashCompletionSource(() => commands); + const state = EditorState.create({ doc: '/agents create ' }); + const result = source(new CompletionContext(state, 15, true)); + + expect(result).toBeNull(); + }); +}); diff --git a/packages/web-shell/client/completions/slashCompletion.ts b/packages/web-shell/client/completions/slashCompletion.ts index 07b11f14f3f..726c650b2fd 100644 --- a/packages/web-shell/client/completions/slashCompletion.ts +++ b/packages/web-shell/client/completions/slashCompletion.ts @@ -20,31 +20,12 @@ interface SubcommandNode { const SUBCOMMAND_TREE_ZH: Record = { agents: [ { name: 'manage', description: '管理现有 subagents' }, - { - name: 'create', - description: '创建新的 subagent', - children: [ - { name: 'user', description: '创建 User subagent' }, - { name: 'project', description: '创建 Project subagent' }, - ], - }, + { name: 'create', description: '创建新的 subagent' }, ], theme: [ { name: 'light', description: '切换到浅色主题' }, { name: 'dark', description: '切换到深色主题' }, ], - memory: [ - { - name: 'add', - description: '新增 memory', - children: [ - { name: 'user', description: '写入 User memory' }, - { name: 'project', description: '写入 Project memory' }, - ], - }, - { name: 'show', description: '查看 memory 文件' }, - { name: 'refresh', description: '刷新 memory 文件列表' }, - ], export: [ { name: 'md', description: '将会话导出为 Markdown 文件' }, { name: 'html', description: '将会话导出为 HTML 文件' }, @@ -67,31 +48,12 @@ const SUBCOMMAND_TREE_ZH: Record = { const SUBCOMMAND_TREE_EN: Record = { agents: [ { name: 'manage', description: 'Manage existing subagents' }, - { - name: 'create', - description: 'Create a new subagent', - children: [ - { name: 'user', description: 'Create a user subagent' }, - { name: 'project', description: 'Create a project subagent' }, - ], - }, + { name: 'create', description: 'Create a new subagent' }, ], theme: [ { name: 'light', description: 'Switch to light theme' }, { name: 'dark', description: 'Switch to dark theme' }, ], - memory: [ - { - name: 'add', - description: 'Add memory', - children: [ - { name: 'user', description: 'Write user memory' }, - { name: 'project', description: 'Write project memory' }, - ], - }, - { name: 'show', description: 'Show memory files' }, - { name: 'refresh', description: 'Refresh memory files' }, - ], export: [ { name: 'md', description: 'Export as Markdown' }, { name: 'html', description: 'Export as HTML' }, @@ -111,6 +73,58 @@ const SUBCOMMAND_TREE_EN: Record = { ], }; +const IMPLICIT_SUBCOMMAND_TREE_ZH: Record = { + context: [{ name: 'detail', description: '显示详细上下文信息' }], + copy: [ + { name: 'code', description: '复制代码块' }, + { name: 'latex', description: '复制 LaTeX 公式' }, + { name: 'inline-latex', description: '复制行内 LaTeX 公式' }, + ], + tools: [{ name: 'desc', description: '显示工具详细描述' }], + stats: [ + { name: 'model', description: '显示各模型使用统计' }, + { name: 'tools', description: '显示工具使用统计' }, + ], + mcp: [ + { name: 'desc', description: '显示 MCP server 和工具描述' }, + { name: 'nodesc', description: '隐藏 MCP 描述' }, + { name: 'schema', description: '显示工具参数 schema' }, + { name: 'auth', description: '认证 OAuth MCP server' }, + { name: 'noauth', description: '隐藏 OAuth 认证状态' }, + ], + memory: [ + { name: 'show', description: '查看 memory 文件' }, + { name: 'add', description: '新增 memory' }, + { name: 'refresh', description: '刷新 memory 文件列表' }, + ], +}; + +const IMPLICIT_SUBCOMMAND_TREE_EN: Record = { + context: [{ name: 'detail', description: 'Show detailed context info' }], + copy: [ + { name: 'code', description: 'Copy code blocks' }, + { name: 'latex', description: 'Copy LaTeX formula' }, + { name: 'inline-latex', description: 'Copy inline LaTeX formula' }, + ], + tools: [{ name: 'desc', description: 'Show tool descriptions' }], + stats: [ + { name: 'model', description: 'Show per-model usage statistics' }, + { name: 'tools', description: 'Show tool usage statistics' }, + ], + mcp: [ + { name: 'desc', description: 'Show MCP server and tool descriptions' }, + { name: 'nodesc', description: 'Hide MCP descriptions' }, + { name: 'schema', description: 'Show tool parameter schemas' }, + { name: 'auth', description: 'Authenticate OAuth MCP servers' }, + { name: 'noauth', description: 'Hide OAuth authentication status' }, + ], + memory: [ + { name: 'show', description: 'Show memory files' }, + { name: 'add', description: 'Add memory' }, + { name: 'refresh', description: 'Refresh memory files' }, + ], +}; + function resolveSubcommands( cmdName: string, parts: string[], @@ -127,6 +141,15 @@ function resolveSubcommands( const tree = language === 'zh-CN' ? SUBCOMMAND_TREE_ZH : SUBCOMMAND_TREE_EN; let nodes = tree[cmdName]; + + if (!nodes) { + const implicitTree = + language === 'zh-CN' + ? IMPLICIT_SUBCOMMAND_TREE_ZH + : IMPLICIT_SUBCOMMAND_TREE_EN; + nodes = implicitTree[cmdName]; + } + if (!nodes) return null; for (const part of parts) { @@ -146,6 +169,74 @@ function comparePrefixFirst(a: string, b: string, query: string): number { return a.localeCompare(b); } +export function getForgottenSlashCompletion( + text: string, + commands: CommandInfo[], +): string | null { + if (!text || text.includes(' ') || /^[/@!?]/.test(text)) return null; + + const lp = text.toLowerCase(); + const match = commands.find((c) => c.name.toLowerCase().startsWith(lp)); + if (!match) return null; + + return `/${match.name} `; +} + +export function getImplicitTabCompletion( + text: string, + commands: CommandInfo[], + language: WebShellLanguage, +): string | null { + const match = text.match(/^\/(\w[\w-]*)\s+$/); + if (!match) return null; + + const cmdName = match[1]; + const cmd = commands.find((c) => c.name === cmdName); + const tree = language === 'zh-CN' ? SUBCOMMAND_TREE_ZH : SUBCOMMAND_TREE_EN; + if (cmd?.subcommands?.length || tree[cmdName] || cmdName === 'skills') { + return null; + } + + const implicitTree = + language === 'zh-CN' + ? IMPLICIT_SUBCOMMAND_TREE_ZH + : IMPLICIT_SUBCOMMAND_TREE_EN; + const nodes = implicitTree[cmdName]; + if (!nodes || nodes.length === 0) return null; + + return `/${cmdName} ${nodes[0].name} `; +} + +export function getSlashCommandArgumentHint( + text: string, + commands: CommandInfo[], + language: WebShellLanguage, +): string | null { + const match = text.match(/^\/(\w[\w-]*)(\s*)$/); + if (!match) return null; + + const cmdName = match[1]; + const cmd = commands.find((c) => c.name === cmdName); + if (!cmd) return null; + + const argumentHint = cmd.argumentHint?.trim(); + if (argumentHint) return argumentHint; + + const tree = language === 'zh-CN' ? SUBCOMMAND_TREE_ZH : SUBCOMMAND_TREE_EN; + if (cmd.subcommands?.length || tree[cmdName] || cmdName === 'skills') { + return null; + } + + const implicitTree = + language === 'zh-CN' + ? IMPLICIT_SUBCOMMAND_TREE_ZH + : IMPLICIT_SUBCOMMAND_TREE_EN; + const nodes = implicitTree[cmdName]; + if (!nodes || nodes.length === 0) return null; + + return `[${nodes.map((node) => node.name).join('|')}]`; +} + export function slashCompletionSource( getCommands: () => CommandInfo[], getSkills: () => SkillInfo[] = () => [], @@ -164,14 +255,30 @@ export function slashCompletionSource( const language = getLanguage(); const tree = language === 'zh-CN' ? SUBCOMMAND_TREE_ZH : SUBCOMMAND_TREE_EN; + const implicitTree = + language === 'zh-CN' + ? IMPLICIT_SUBCOMMAND_TREE_ZH + : IMPLICIT_SUBCOMMAND_TREE_EN; const hasTree = !!tree[cmdName] || cmdName === 'skills'; - if (!cmd?.subcommands?.length && !hasTree) return null; + const hasImplicitTree = !!implicitTree[cmdName]; + if (!cmd?.subcommands?.length && !hasTree && !hasImplicitTree) + return null; // Split rest into completed parts and current typing const tokens = rest.split(/\s+/); const currentTyping = tokens.pop() || ''; const completedParts = tokens; + // Implicit sub-commands: only show when user starts typing (not on space alone) + if ( + !cmd?.subcommands?.length && + !hasTree && + hasImplicitTree && + !currentTyping + ) { + return null; + } + const nodes = resolveSubcommands( cmdName, completedParts, @@ -216,8 +323,10 @@ export function slashCompletionSource( }; } - // Top-level command completion: "/" or "/ex" - const match = textBefore.match(/^\/(\w*)$/); + // Top-level command completion: "/" or "/ex". + // Use the whole line so moving the cursor before or inside the command + // still offers the same completions and replaces the full command token. + const match = line.text.match(/^\/(\w*)$/); if (!match) return null; const prefix = match[1]; @@ -243,6 +352,7 @@ export function slashCompletionSource( return { from: line.from, + to: line.to, options, filter: false, }; diff --git a/packages/web-shell/client/components/Editor.tsx b/packages/web-shell/client/components/Editor.tsx index b5c07104c65..7bdd44a0151 100644 --- a/packages/web-shell/client/components/Editor.tsx +++ b/packages/web-shell/client/components/Editor.tsx @@ -26,6 +26,8 @@ import { } from '@qwen-code/webui/daemon-react-sdk'; import { slashCompletionSource, + getImplicitTabCompletion, + getForgottenSlashCompletion, type SkillInfo, } from '../completions/slashCompletion'; import { createAtCompletionSource } from '../completions/atCompletion'; @@ -63,6 +65,7 @@ interface EditorProps { export interface EditorHandle { blur(): void; focus(): void; + getText(): string; insertText(text: string): void; retryLast(): void; } @@ -457,16 +460,44 @@ export const Editor = forwardRef(function Editor( if (completionStatus(view.state) === 'active') { return acceptCompletion(view); } + const text = view.state.doc.toString(); + const implicitResult = getImplicitTabCompletion( + text, + commandsRef.current, + languageRef.current, + ); + if (implicitResult) { + view.dispatch({ + changes: { + from: 0, + to: view.state.doc.length, + insert: implicitResult, + }, + selection: { anchor: implicitResult.length }, + }); + return true; + } + const forgottenSlash = getForgottenSlashCompletion( + text, + commandsRef.current, + ); + if (forgottenSlash) { + view.dispatch({ + changes: { + from: 0, + to: view.state.doc.length, + insert: forgottenSlash, + }, + selection: { anchor: forgottenSlash.length }, + }); + return true; + } const followup = followupStateRef.current; - if ( - view.state.doc.toString().length === 0 && - followup?.isVisible && - followup.suggestion - ) { + if (text.length === 0 && followup?.isVisible && followup.suggestion) { onAcceptFollowupRef.current?.('tab'); return true; } - return acceptCompletion(view); + return true; }, }, { @@ -495,10 +526,10 @@ export const Editor = forwardRef(function Editor( ]); const slashCompletionRestarter = EditorView.updateListener.of((update) => { - if (!update.docChanged) { + if (!update.docChanged && !update.selectionSet) { return; } - if (pendingPastesRef.current.size > 0) { + if (update.docChanged && pendingPastesRef.current.size > 0) { const nextPasteId = prunePendingPastes( pendingPastesRef.current, update.state.doc.toString(), @@ -510,11 +541,7 @@ export const Editor = forwardRef(function Editor( const selection = update.state.selection.main; if (!selection.empty) return; const line = update.state.doc.lineAt(selection.head); - const textBefore = line.text.slice(0, selection.head - line.from); - const shouldCompleteSlash = - line.from === 0 && - textBefore.startsWith('/') && - !textBefore.includes('\n'); + const shouldCompleteSlash = line.from === 0 && line.text.startsWith('/'); if (!shouldCompleteSlash) return; window.setTimeout(() => { const view = viewRef.current; @@ -522,11 +549,7 @@ export const Editor = forwardRef(function Editor( const nextSelection = view.state.selection.main; if (!nextSelection.empty) return; const nextLine = view.state.doc.lineAt(nextSelection.head); - const nextTextBefore = nextLine.text.slice( - 0, - nextSelection.head - nextLine.from, - ); - if (nextLine.from === 0 && nextTextBefore.startsWith('/')) { + if (nextLine.from === 0 && nextLine.text.startsWith('/')) { startCompletion(view); } }, 0); @@ -553,7 +576,10 @@ export const Editor = forwardRef(function Editor( placeholderCompartment.of(placeholder('')), EditorView.lineWrapping, editableCompartment.of(EditorView.editable.of(true)), - inputHighlight, + inputHighlight( + () => commandsRef.current, + () => languageRef.current, + ), inputHighlightTheme, slashCompletionRestarter, EditorView.inputHandler.of((view, from, to, insert) => { @@ -895,6 +921,10 @@ export const Editor = forwardRef(function Editor( } }, []); + const getText = useCallback(() => { + return viewRef.current?.state.doc.toString() ?? ''; + }, []); + const retryLast = useCallback(() => { const last = historyActionsRef.current.getLastEntry( (e) => !e.startsWith('/') && !e.startsWith('!'), @@ -910,10 +940,11 @@ export const Editor = forwardRef(function Editor( () => ({ blur, focus, + getText, insertText, retryLast, }), - [blur, focus, insertText, retryLast], + [blur, focus, getText, insertText, retryLast], ); const replaceEditorText = useCallback((text: string) => { diff --git a/packages/web-shell/client/components/InsightProgress.module.css b/packages/web-shell/client/components/InsightProgress.module.css index 37c0ec0c69d..8b6ca803422 100644 --- a/packages/web-shell/client/components/InsightProgress.module.css +++ b/packages/web-shell/client/components/InsightProgress.module.css @@ -2,47 +2,28 @@ flex-shrink: 0; display: flex; align-items: center; - gap: 8px; - padding: 4px 0; - padding-left: 2ch; + gap: 6px; + padding: 10px 0px; font-size: 13px; color: var(--text-secondary); + margin-top: -20px; } .spinner { color: var(--accent-color); - animation: spinFrames 0.8s steps(6) infinite; -} - -@keyframes spinFrames { - 0% { - content: '⠋'; - } - 16% { - content: '⠙'; - } - 33% { - content: '⠹'; - } - 50% { - content: '⠸'; - } - 66% { - content: '⠼'; - } - 83% { - content: '⠴'; - } + display: inline-block; + width: 1ch; + text-align: center; } .bar { - color: var(--accent-color); + color: var(--text-secondary); letter-spacing: 0; font-size: 11px; } .stage { - color: var(--text-secondary); + color: var(--accent-color); } .icon { @@ -55,6 +36,10 @@ color: var(--success-color); } +.done .stage { + color: var(--success-color); +} + .error .icon { color: var(--error-color); } @@ -67,3 +52,8 @@ color: var(--text-dimmed); font-size: 12px; } + +.path { + color: var(--text-secondary); + font-size: 12px; +} diff --git a/packages/web-shell/client/components/InsightProgress.tsx b/packages/web-shell/client/components/InsightProgress.tsx index e5525798c25..31b51f11904 100644 --- a/packages/web-shell/client/components/InsightProgress.tsx +++ b/packages/web-shell/client/components/InsightProgress.tsx @@ -1,3 +1,4 @@ +import { useState, useEffect } from 'react'; import styles from './InsightProgress.module.css'; export interface InsightProgressData { @@ -12,8 +13,11 @@ interface InsightProgressProps { progress: InsightProgressData; } +const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧']; + export function InsightProgress({ progress }: InsightProgressProps) { const { stage, progress: percent, detail, isComplete, error } = progress; + const [frame, setFrame] = useState(0); const width = 30; const completedWidth = Math.round((percent / 100) * width); const remainingWidth = width - completedWidth; @@ -21,6 +25,14 @@ export function InsightProgress({ progress }: InsightProgressProps) { '█'.repeat(Math.max(0, completedWidth)) + '░'.repeat(Math.max(0, remainingWidth)); + useEffect(() => { + if (isComplete || error) return; + const id = setInterval(() => { + setFrame((f) => (f + 1) % SPINNER_FRAMES.length); + }, 120); + return () => clearInterval(id); + }, [isComplete, error]); + if (error) { return (
@@ -42,7 +54,7 @@ export function InsightProgress({ progress }: InsightProgressProps) { return (
- + {SPINNER_FRAMES[frame]} {bar} {stage} diff --git a/packages/web-shell/client/components/InsightReady.tsx b/packages/web-shell/client/components/InsightReady.tsx new file mode 100644 index 00000000000..45b0bd869a1 --- /dev/null +++ b/packages/web-shell/client/components/InsightReady.tsx @@ -0,0 +1,17 @@ +import styles from './InsightProgress.module.css'; +import { useI18n } from '../i18n'; + +interface InsightReadyProps { + path: string; +} + +export function InsightReady({ path }: InsightReadyProps) { + const { t } = useI18n(); + return ( +
+ + {t('insight.ready')} + {path} +
+ ); +} diff --git a/packages/web-shell/client/components/MessageItem.tsx b/packages/web-shell/client/components/MessageItem.tsx index 615e3b725fc..d7caec0e0b9 100644 --- a/packages/web-shell/client/components/MessageItem.tsx +++ b/packages/web-shell/client/components/MessageItem.tsx @@ -10,6 +10,10 @@ import { AssistantMessage } from './messages/AssistantMessage'; import { SystemMessage } from './messages/SystemMessage'; import { ToolGroup } from './messages/ToolGroup'; import { PlanMessage } from './messages/PlanMessage'; +import { BtwMessage } from './messages/BtwMessage'; +import { UserShellMessage } from './messages/UserShellMessage'; +import { InsightProgress } from './InsightProgress'; +import { InsightReady } from './InsightReady'; interface MessageItemProps { message: Message; @@ -53,6 +57,30 @@ export const MessageItem = memo(function MessageItem({ return ( ); + case 'user_shell': + return ( + + ); + case 'btw': + return ( + + ); + case 'insight_progress': + return ( + + ); + case 'insight_ready': + return ; default: return null; } @@ -87,6 +115,29 @@ function areMessagesEqual(prev: Message, next: Message): boolean { prev.content === next.content && prev.variant === next.variant ); + case 'user_shell': + return ( + next.role === 'user_shell' && + prev.command === next.command && + prev.output === next.output && + prev.cwd === next.cwd + ); + case 'btw': + return ( + next.role === 'btw' && + prev.question === next.question && + prev.answer === next.answer && + prev.isPending === next.isPending + ); + case 'insight_progress': + return ( + next.role === 'insight_progress' && + prev.stage === next.stage && + prev.progress === next.progress && + prev.detail === next.detail + ); + case 'insight_ready': + return next.role === 'insight_ready' && prev.path === next.path; case 'plan': return next.role === 'plan' && areTodosEqual(prev.todos, next.todos); case 'tool_group': diff --git a/packages/web-shell/client/components/MessageList.tsx b/packages/web-shell/client/components/MessageList.tsx index 44f25e11b6d..5e4090e6ebe 100644 --- a/packages/web-shell/client/components/MessageList.tsx +++ b/packages/web-shell/client/components/MessageList.tsx @@ -31,6 +31,8 @@ interface MessageListProps { catchingUp?: boolean; welcomeHeader?: ReactNode; workspaceCwd?: string; + tailContent?: ReactNode; + tailKey?: string; } function isAskUserQuestion(request: PermissionRequest): boolean { @@ -191,6 +193,7 @@ const HEADER_INDEX = 0; const ESTIMATE_HEADER = 120; const ESTIMATE_MESSAGE = 80; const ESTIMATE_APPROVAL = 200; +const ESTIMATE_TAIL = 240; export function MessageList({ messages, @@ -199,6 +202,8 @@ export function MessageList({ catchingUp, welcomeHeader, workspaceCwd, + tailContent, + tailKey = 'tail', }: MessageListProps) { const compactMode = useContext(CompactModeContext); const mergedMessages = useMemo( @@ -261,6 +266,7 @@ export function MessageList({ const shouldFollow = useRef(true); const lastScrollTop = useRef(0); + const scrollCooldown = useRef(false); const prevLastUserMsgId = useRef(null); const prevCatchingUp: MutableRefObject = useRef(catchingUp); @@ -271,18 +277,24 @@ export function MessageList({ return !approvalMatchesToolGroup(messages, pendingApproval); }, [pendingApproval, messages]); + const hasTailContent = tailContent !== undefined && tailContent !== null; const hasHeader = !!welcomeHeader; const headerOffset = hasHeader ? 1 : 0; - const totalCount = - headerOffset + displayItems.length + (hasTailApproval ? 1 : 0); + const tailApprovalIndex = headerOffset + displayItems.length; + const tailContentIndex = tailApprovalIndex + (hasTailApproval ? 1 : 0); + const totalCount = tailContentIndex + (hasTailContent ? 1 : 0); // Rule 6: skip if content doesn't overflow (no scrollbar). const scrollToBottom = useCallback(() => { const el = containerRef.current; if (!el) return; if (el.scrollHeight <= el.clientHeight) return; + scrollCooldown.current = true; el.scrollTop = el.scrollHeight; lastScrollTop.current = el.scrollTop; + requestAnimationFrame(() => { + scrollCooldown.current = false; + }); }, []); const virtualizer = useVirtualizer({ @@ -290,15 +302,19 @@ export function MessageList({ getScrollElement: () => containerRef.current, getItemKey: (index) => { if (hasHeader && index === HEADER_INDEX) return 'header'; - if (hasTailApproval && index === totalCount - 1) { + if (hasTailApproval && index === tailApprovalIndex) { return pendingApproval ? `approval-${pendingApproval.id}` : 'approval'; } + if (hasTailContent && index === tailContentIndex) return tailKey; const item = displayItems[index - headerOffset]; return item?.key ?? `row-${index}`; }, estimateSize: (index) => { if (hasHeader && index === HEADER_INDEX) return ESTIMATE_HEADER; - if (hasTailApproval && index === totalCount - 1) return ESTIMATE_APPROVAL; + if (hasTailApproval && index === tailApprovalIndex) { + return ESTIMATE_APPROVAL; + } + if (hasTailContent && index === tailContentIndex) return ESTIMATE_TAIL; return ESTIMATE_MESSAGE; }, overscan: 5, @@ -311,6 +327,10 @@ export function MessageList({ const handleScroll = useCallback(() => { const el = containerRef.current; if (!el) return; + if (scrollCooldown.current) { + lastScrollTop.current = el.scrollTop; + return; + } const prev = lastScrollTop.current; const curr = el.scrollTop; lastScrollTop.current = curr; @@ -366,7 +386,7 @@ export function MessageList({ return welcomeHeader; } - if (hasTailApproval && index === totalCount - 1) { + if (hasTailApproval && index === tailApprovalIndex) { if (pendingApproval && isAskUserQuestion(pendingApproval)) { return ( @@ -380,6 +400,10 @@ export function MessageList({ return null; } + if (hasTailContent && index === tailContentIndex) { + return tailContent; + } + const itemIndex = index - headerOffset; const item = displayItems[itemIndex]; if (!item) return null; @@ -406,8 +430,11 @@ export function MessageList({ [ hasHeader, welcomeHeader, + hasTailContent, + tailContent, + tailContentIndex, hasTailApproval, - totalCount, + tailApprovalIndex, pendingApproval, onConfirm, headerOffset, diff --git a/packages/web-shell/client/components/dialogs/AgentsDialog.tsx b/packages/web-shell/client/components/dialogs/AgentsDialog.tsx deleted file mode 100644 index 5d1398acfc2..00000000000 --- a/packages/web-shell/client/components/dialogs/AgentsDialog.tsx +++ /dev/null @@ -1,557 +0,0 @@ -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - type KeyboardEvent as ReactKeyboardEvent, -} from 'react'; -import { dp } from './dialogStyles'; -import { - useAgents, - type DaemonWorkspaceAgentDetail, - type DaemonWorkspaceAgentSummary, -} from '@qwen-code/webui/daemon-react-sdk'; -import { useDelayedGlobalKeyDown } from '../../hooks/useDelayedGlobalKeyDown'; -import { useI18n } from '../../i18n'; - -export type AgentsDialogInitialMode = - | 'menu' - | 'create' - | 'create-user' - | 'create-project' - | 'manage'; - -interface AgentsDialogProps { - initialMode?: AgentsDialogInitialMode; - onMessage?: (text: string, type?: 'status' | 'error') => void; - onClose: () => void; -} - -function scopeForLevel(level: string): 'workspace' | 'global' | undefined { - if (level === 'project') return 'workspace'; - if (level === 'user') return 'global'; - return undefined; -} - -function canDeleteAgent(agent: DaemonWorkspaceAgentSummary): boolean { - return ( - scopeForLevel(agent.level) !== undefined && - !agent.isBuiltin && - agent.level !== 'extension' - ); -} - -function initialDialogMode( - mode: AgentsDialogInitialMode, -): 'menu' | 'create-scope' | 'create' | 'manage' { - if (mode === 'create') return 'create-scope'; - if (mode === 'create-user' || mode === 'create-project') return 'create'; - return mode; -} - -function initialScope(mode: AgentsDialogInitialMode): 'workspace' | 'global' { - return mode === 'create-user' ? 'global' : 'workspace'; -} - -export function AgentsDialog({ - initialMode = 'menu', - onMessage, - onClose, -}: AgentsDialogProps) { - const { t } = useI18n(); - const { - agents, - loading, - error: agentsError, - reload, - getAgent, - createAgent, - deleteAgent, - } = useAgents({ autoLoad: true }); - const [mode, setMode] = useState< - 'menu' | 'create-scope' | 'create' | 'manage' - >(() => initialDialogMode(initialMode)); - const [selectedIdx, setSelectedIdx] = useState(0); - const [detail, setDetail] = useState(null); - const [busy, setBusy] = useState(false); - const [message, setMessage] = useState(null); - const [name, setName] = useState(''); - const [description, setDescription] = useState(''); - const [systemPrompt, setSystemPrompt] = useState(''); - const [scope, setScope] = useState<'workspace' | 'global'>(() => - initialScope(initialMode), - ); - const listRef = useRef(null); - const nameInputRef = useRef(null); - const descriptionInputRef = useRef(null); - const systemPromptRef = useRef(null); - const directCreateMode = - initialMode === 'create' || - initialMode === 'create-user' || - initialMode === 'create-project'; - - const selected = agents[selectedIdx]; - const scopeItems = useMemo( - () => [ - { - label: t('agent.create.user'), - description: t('agent.create.user.desc'), - scope: 'global' as const, - }, - { - label: t('agent.create.project'), - description: t('agent.create.project.desc'), - scope: 'workspace' as const, - }, - ], - [t], - ); - const menuItems = useMemo( - () => [ - { - label: t('agent.manage'), - description: t('agent.manage.desc'), - onSelect: () => { - setSelectedIdx(0); - setMode('manage' as const); - }, - }, - { - label: t('agent.create'), - description: t('agent.create'), - onSelect: () => { - setSelectedIdx(scope === 'global' ? 0 : 1); - setMode('create-scope' as const); - }, - }, - ], - [scope, t], - ); - - useEffect(() => { - if (agentsError) setMessage(agentsError.message); - }, [agentsError]); - - useEffect(() => { - if (mode !== 'manage') return; - if (selectedIdx >= agents.length && agents.length > 0) { - setSelectedIdx(agents.length - 1); - } - }, [agents.length, mode, selectedIdx]); - - useEffect(() => { - const el = listRef.current?.children[selectedIdx] as - | HTMLElement - | undefined; - el?.scrollIntoView({ block: 'nearest' }); - }, [selectedIdx]); - - useEffect(() => { - if (mode !== 'create') return; - window.setTimeout(() => nameInputRef.current?.focus(), 0); - }, [mode]); - - const loadDetail = useCallback( - (agent: DaemonWorkspaceAgentSummary) => { - setDetail(null); - getAgent(agent.name) - .then(setDetail) - .catch((error: unknown) => { - setMessage(error instanceof Error ? error.message : String(error)); - }); - }, - [getAgent], - ); - - useEffect(() => { - if (selected && mode === 'manage') { - loadDetail(selected); - } - }, [loadDetail, mode, selected]); - - const handleCreate = useCallback(() => { - const trimmedName = name.trim(); - const trimmedDescription = description.trim(); - const trimmedPrompt = systemPrompt.trim(); - if (!trimmedName || !trimmedDescription || !trimmedPrompt) { - setMessage(t('agent.create.required')); - return; - } - setBusy(true); - createAgent({ - name: trimmedName, - description: trimmedDescription, - systemPrompt: trimmedPrompt, - scope, - }) - .then((result) => { - const msg = t('agent.created', { name: result.agent.name }); - onMessage?.(msg); - if (directCreateMode) { - onClose(); - return; - } - setMessage(msg); - setName(''); - setDescription(''); - setSystemPrompt(''); - setMode('manage'); - reload(); - }) - .catch((error: unknown) => { - setMessage(error instanceof Error ? error.message : String(error)); - }) - .finally(() => setBusy(false)); - }, [ - createAgent, - description, - directCreateMode, - name, - onClose, - onMessage, - reload, - scope, - systemPrompt, - t, - ]); - - const handleDelete = useCallback( - (agent: DaemonWorkspaceAgentSummary) => { - const deleteScope = scopeForLevel(agent.level); - if (!deleteScope || agent.isBuiltin || agent.level === 'extension') { - setMessage(t('agent.readonly')); - return; - } - setBusy(true); - deleteAgent(agent.name, deleteScope) - .then(() => { - setMessage(t('agent.deleted', { name: agent.name })); - setDetail(null); - reload(); - }) - .catch((error: unknown) => { - setMessage(error instanceof Error ? error.message : String(error)); - }) - .finally(() => setBusy(false)); - }, - [deleteAgent, reload, t], - ); - - const handleCreateKeyDown = useCallback( - (e: ReactKeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { - e.preventDefault(); - if (!busy) handleCreate(); - return; - } - - if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') { - return; - } - - const target = e.target; - if ( - !(target instanceof HTMLInputElement) && - !(target instanceof HTMLTextAreaElement) - ) { - return; - } - - const fields = [ - nameInputRef.current, - descriptionInputRef.current, - systemPromptRef.current, - ].filter((field): field is HTMLInputElement | HTMLTextAreaElement => - Boolean(field), - ); - const index = fields.indexOf(target); - if (index < 0) return; - - e.preventDefault(); - const nextIndex = - e.key === 'ArrowDown' - ? Math.min(index + 1, fields.length - 1) - : Math.max(index - 1, 0); - fields[nextIndex]?.focus(); - }, - [busy, handleCreate], - ); - - useDelayedGlobalKeyDown( - (e: KeyboardEvent) => { - if (e.key === 'Escape') { - e.preventDefault(); - if (mode === 'menu' || initialMode !== 'menu') { - onClose(); - } else { - setMode('menu'); - setSelectedIdx(0); - setMessage(null); - } - return; - } - if (mode === 'create') return; - if (e.key === 'ArrowDown' || e.key === 'j') { - e.preventDefault(); - if (mode === 'menu') { - setSelectedIdx((i) => - Math.min(i + 1, Math.max(menuItems.length - 1, 0)), - ); - } else if (mode === 'create-scope') { - setSelectedIdx((i) => - Math.min(i + 1, Math.max(scopeItems.length - 1, 0)), - ); - } else { - setSelectedIdx((i) => - Math.min(i + 1, Math.max(agents.length - 1, 0)), - ); - } - return; - } - if (e.key === 'ArrowUp' || e.key === 'k') { - e.preventDefault(); - setSelectedIdx((i) => Math.max(i - 1, 0)); - return; - } - if (e.key === 'Enter' && mode === 'menu') { - e.preventDefault(); - menuItems[selectedIdx]?.onSelect(); - } else if (e.key === 'Enter' && mode === 'create-scope') { - e.preventDefault(); - const nextScope = scopeItems[selectedIdx]?.scope ?? 'workspace'; - setScope(nextScope); - setMode('create'); - } - if (e.key === 'd' && e.ctrlKey && mode === 'manage' && selected) { - e.preventDefault(); - if (!busy && canDeleteAgent(selected)) { - handleDelete(selected); - } - } - }, - [ - agents.length, - busy, - handleDelete, - initialMode, - menuItems, - mode, - selected, - onClose, - scopeItems, - selectedIdx, - ], - ); - - return ( -
-
- {t('agents.title')} - - {t('agent.count', { count: agents.length })} - - -
- -
- - {message || - (loading - ? t('common.loading') - : mode === 'menu' - ? t('agent.selectAction') - : mode === 'create-scope' - ? t('agent.create.scope') - : '')} - -
- -
- - {mode === 'menu' ? ( -
- {menuItems.map((item, index) => ( -
item.onSelect()} - onMouseEnter={() => setSelectedIdx(index)} - > -
- - {index === selectedIdx ? '›' : ' '} - - - {item.label} - -
-
- {item.description} -
-
- ))} -
- ) : mode === 'create-scope' ? ( -
- {scopeItems.map((item, index) => ( -
{ - setScope(item.scope); - setMode('create'); - }} - onMouseEnter={() => setSelectedIdx(index)} - > -
- - {index === selectedIdx ? '›' : ' '} - - - {item.label} - -
-
- {item.description} -
-
- ))} -
- ) : mode === 'create' ? ( -
- - -