From 03f44d4bd6a6598d8cb9cce105b489dfa8f45b5c Mon Sep 17 00:00:00 2001 From: ytahdn Date: Thu, 4 Jun 2026 14:19:53 +0800 Subject: [PATCH] feat(web-shell): polish embedded terminal interactions --- packages/web-shell/client/App.tsx | 252 +++++++++++------- .../dialogs/DeleteSessionDialog.tsx | 11 +- .../dialogs/DialogPrimitives.module.css | 26 +- .../client/components/dialogs/HelpDialog.tsx | 1 - .../dialogs/ReleaseSessionDialog.tsx | 6 +- .../components/dialogs/ResumeDialog.tsx | 3 + .../messages/ApprovalModeMessage.module.css | 70 +++++ .../messages/ApprovalModeMessage.tsx | 145 ++++++++++ .../components/messages/AssistantMessage.tsx | 2 +- .../client/components/messages/Markdown.tsx | 37 ++- .../client/components/messages/ToolGroup.tsx | 99 +++++-- .../client/constants/localCommands.ts | 1 - packages/web-shell/client/customization.tsx | 56 ++++ packages/web-shell/client/i18n.tsx | 26 +- packages/web-shell/client/index.ts | 8 + packages/web-shell/client/index.tsx | 8 + 16 files changed, 620 insertions(+), 131 deletions(-) create mode 100644 packages/web-shell/client/components/messages/ApprovalModeMessage.module.css create mode 100644 packages/web-shell/client/components/messages/ApprovalModeMessage.tsx create mode 100644 packages/web-shell/client/customization.tsx diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 69403f85a3..f7810a42e6 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -27,7 +27,10 @@ import { StreamingStatus } from './components/StreamingStatus'; import { TodoPanel } from './components/panels/TodoPanel'; import { ActiveAgentsPanel } from './components/panels/ActiveAgentsPanel'; import { WelcomeHeader } from './components/WelcomeHeader'; -import { ApprovalModeDialog } from './components/dialogs/ApprovalModeDialog'; +import { + APPROVAL_MODE_ACTIVE_EVENT, + ApprovalModeMessage, +} from './components/messages/ApprovalModeMessage'; import { ResumeDialog } from './components/dialogs/ResumeDialog'; import { AGENTS_ACTIVE_EVENT, @@ -97,6 +100,11 @@ import type { } from './adapters/types'; import { extractTodosFromToolCall, hasActiveTodos } from './utils/todos'; import { ThemeProvider } from './themeContext'; +import { + WebShellCustomizationProvider, + type WebShellMarkdownCustomization, + type ToolHeaderExtraRenderer, +} from './customization'; import styles from './App.module.css'; export const CompactModeContext = createContext(false); @@ -104,6 +112,30 @@ export const CompactModeContext = createContext(false); const MODES_CYCLE = DAEMON_APPROVAL_MODES; const MAX_DISPLAYED_QUEUED_PROMPTS = 3; const MAX_QUEUED_PROMPT_PREVIEW_CHARS = 240; +const COMPACT_MODE_STORAGE_KEY = 'web-shell:compact-mode'; + +function loadCompactMode(): boolean { + try { + return window.localStorage.getItem(COMPACT_MODE_STORAGE_KEY) === 'true'; + } catch { + return false; + } +} + +function saveCompactMode(enabled: boolean) { + try { + window.localStorage.setItem( + COMPACT_MODE_STORAGE_KEY, + enabled ? 'true' : 'false', + ); + } catch { + // Ignore storage failures in private browsing or restricted contexts. + } +} + +function normalizeHiddenCommand(command: string): string { + return command.trim().replace(/^\/+/, '').toLowerCase(); +} interface QueuedPrompt { id: number; @@ -153,6 +185,12 @@ export interface WebShellProps { 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; + /** Slash command names to hide from completion/help, for example `['approval-mode']`. */ + hiddenSlashCommands?: string[]; + /** Custom renderer for the tool-card header content after the status icon and tool name. */ + renderToolHeaderExtra?: ToolHeaderExtraRenderer; + /** Custom Markdown behavior for assistant content only. */ + markdown?: WebShellMarkdownCustomization; } function replaceSessionUrl(sessionId: string): void { @@ -438,6 +476,9 @@ export function App({ onStreamingStateChange, onError, onBugReport, + hiddenSlashCommands, + renderToolHeaderExtra, + markdown, }: WebShellProps = {}) { const [selectedLanguage, setSelectedLanguage] = useState( () => @@ -446,6 +487,10 @@ export function App({ : normalizeLanguage(providedLanguage), ); const t = useMemo(() => getTranslator(selectedLanguage), [selectedLanguage]); + const customization = useMemo( + () => ({ renderToolHeaderExtra, markdown }), + [renderToolHeaderExtra, markdown], + ); const store = useTranscriptStore(); const blocks = useTranscriptBlocks(); const connection = useConnection(); @@ -568,7 +613,7 @@ export function App({ const [modelInlineMode, setModelInlineMode] = useState(null); - const [showModeDialog, setShowModeDialog] = useState(false); + const [approvalModeInlineOpen, setApprovalModeInlineOpen] = useState(false); const [showResumeDialog, setShowResumeDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showReleaseDialog, setShowReleaseDialog] = useState(false); @@ -586,6 +631,7 @@ export function App({ const [memoryPortalHost, setMemoryPortalHost] = useState(null); const [showShortcuts, setShowShortcuts] = useState(false); + const approvalModePanelActive = usePanelActive(APPROVAL_MODE_ACTIVE_EVENT); const mcpPanelActive = usePanelActive(MCP_STATUS_ACTIVE_EVENT); const agentsPanelActive = usePanelActive(AGENTS_ACTIVE_EVENT); const memoryPanelActive = usePanelActive(MEMORY_ACTIVE_EVENT); @@ -603,7 +649,6 @@ export function App({ const nextQueuedPromptIdRef = useRef(1); const drainingQueueRef = useRef(false); const dialogOpen = - showModeDialog || showResumeDialog || showDeleteDialog || showReleaseDialog || @@ -612,6 +657,7 @@ export function App({ showToolsDialog; const bottomHidden = dialogOpen || + approvalModePanelActive || mcpPanelActive || agentsPanelActive || memoryPanelActive || @@ -854,7 +900,7 @@ export function App({ setShowShortcuts((prev) => !prev); }, []); - const [compactMode, setCompactMode] = useState(false); + const [compactMode, setCompactMode] = useState(loadCompactMode); const compactModeRef = useRef(compactMode); compactModeRef.current = compactMode; @@ -869,13 +915,8 @@ export function App({ const handleToggleCompact = useCallback(() => { const next = !compactModeRef.current; setCompactMode(next); - store.dispatch([ - { - type: 'status', - text: next ? t('compact.enabled') : t('compact.disabled'), - }, - ]); - }, [store, t]); + saveCompactMode(next); + }, []); const handleSetMode = useCallback( (modeId: string) => { @@ -1192,12 +1233,13 @@ export function App({ }); return true; } - if (cmd === 'approval-mode' || cmd === 'mode') { + if (cmd === 'approval-mode') { const modeArg = text.slice(match[0].length).trim(); if (modeArg) { handleSetMode(modeArg); } else { - setShowModeDialog(true); + store.appendLocalUserMessage(text); + setApprovalModeInlineOpen(true); } return true; } @@ -1374,7 +1416,9 @@ export function App({ return true; } if (cmd === 'clear') { - store.reset(); + sessionActions.newSession().catch((error: unknown) => { + reportError(error, 'Failed to create a new session'); + }); return true; } if (cmd === 'new' || cmd === 'reset') { @@ -1791,13 +1835,17 @@ export function App({ const commands = useMemo(() => { const skillNames = new Set(connection.skills ?? []); - return mergeCommands(connection.commands ?? [], getLocalCommands(t)).map( - (command) => + const hidden = new Set( + (hiddenSlashCommands ?? []).map(normalizeHiddenCommand).filter(Boolean), + ); + return mergeCommands(connection.commands ?? [], getLocalCommands(t)) + .filter((command) => !hidden.has(normalizeHiddenCommand(command.name))) + .map((command) => skillNames.has(command.name) ? { ...command, description: t('skills.run') } : command, - ); - }, [connection.commands, connection.skills, t]); + ); + }, [connection.commands, connection.skills, hiddenSlashCommands, t]); const welcomeHeader = useMemo( () => ( @@ -1893,13 +1941,6 @@ export function App({ onClose={() => setShowReleaseDialog(false)} /> )} - {showModeDialog && ( - setShowModeDialog(false)} - /> - )} {showHelpDialog && ( )} - -
0 || floatingAgents.length > 0 - ? `${styles.content} ${styles.contentHasMessages}` - : styles.content - } - style={dialogOpen ? { visibility: 'hidden' } : undefined} - > - - {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 + + +
0 || floatingAgents.length > 0 + ? `${styles.content} ${styles.contentHasMessages}` + : styles.content } - /> - - {btwMessage?.role === 'btw' && ( -
- -
- )} + style={dialogOpen ? { visibility: 'hidden' } : undefined} + > + + {approvalModeInlineOpen && ( + setApprovalModeInlineOpen(false)} + /> + )} + {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 || + approvalModeInlineOpen + ? `inline-${modelInlineMode ?? 'none'}-${agentsInlineMode ?? 'none'}-${memoryInlineOpen ? 'memory' : 'none'}-${approvalModeInlineOpen ? 'approval' : 'none'}` + : undefined + } + /> - -
-
- + {btwMessage?.role === 'btw' && ( +
+ +
+ )} + + +
+
+ +
0; return ( -
+ // Hover selection is intentionally disabled here: otherwise a stationary + // mouse can override the row selected by keyboard ↑↓ navigation. +
{t('delete.title')} {hasSelection && ( @@ -352,12 +354,13 @@ export function DeleteSessionDialog({ filtered.map((s, i) => { const isCurrent = s.sessionId === currentSessionId; const isChecked = selectedIds.has(s.sessionId); - const checkbox = isCurrent ? '[-]' : isChecked ? '[✓]' : '[ ]'; + const checkbox = isChecked ? '[x] ' : '[ ] '; return (
setSelectedIdx(i)} >
+ {i === selectedIdx && !searchMode ? '›' : ' '} + + {checkbox} diff --git a/packages/web-shell/client/components/dialogs/DialogPrimitives.module.css b/packages/web-shell/client/components/dialogs/DialogPrimitives.module.css index c2751044c2..acebb9e9af 100644 --- a/packages/web-shell/client/components/dialogs/DialogPrimitives.module.css +++ b/packages/web-shell/client/components/dialogs/DialogPrimitives.module.css @@ -104,6 +104,13 @@ cursor: pointer; } +.resume-picker-session-item { + align-items: stretch; + flex-direction: column; + padding-top: 2px; + padding-bottom: 6px; +} + .resume-picker-item.selected { background: var(--bg-tertiary); } @@ -129,6 +136,8 @@ display: flex; align-items: baseline; gap: 4px; + min-width: 0; + width: 100%; } .resume-picker-item-prefix { @@ -143,13 +152,27 @@ .resume-picker-item-title { color: var(--text-primary); + flex: 1 1 auto; font-size: 13px; font-weight: 500; + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.resume-picker-item-checkbox { + color: var(--text-secondary); + flex-shrink: 0; + font-size: 13px; + white-space: pre; +} + +.resume-picker-item.selected .resume-picker-item-checkbox { + color: var(--accent-color); + font-weight: 700; +} + .resume-picker-item-number { min-width: 28px; color: var(--text-secondary); @@ -173,7 +196,7 @@ display: flex; align-items: center; gap: 8px; - padding-left: 16px; + padding-left: 26px; font-size: 12px; color: var(--text-secondary); } @@ -195,6 +218,7 @@ } .resume-picker-item-badge { + flex-shrink: 0; margin-left: 8px; font-size: 11px; color: var(--accent-color, #4a9eff); diff --git a/packages/web-shell/client/components/dialogs/HelpDialog.tsx b/packages/web-shell/client/components/dialogs/HelpDialog.tsx index 464d05e097..84cda3b255 100644 --- a/packages/web-shell/client/components/dialogs/HelpDialog.tsx +++ b/packages/web-shell/client/components/dialogs/HelpDialog.tsx @@ -58,7 +58,6 @@ const BUILT_IN_COMMANDS = new Set([ 'lsp', 'mcp', 'memory', - 'mode', 'model', 'new', 'permissions', diff --git a/packages/web-shell/client/components/dialogs/ReleaseSessionDialog.tsx b/packages/web-shell/client/components/dialogs/ReleaseSessionDialog.tsx index 88ef9a0127..f98c5820e1 100644 --- a/packages/web-shell/client/components/dialogs/ReleaseSessionDialog.tsx +++ b/packages/web-shell/client/components/dialogs/ReleaseSessionDialog.tsx @@ -178,7 +178,9 @@ export function ReleaseSessionDialog({ ); return ( -
+ // Hover selection is intentionally disabled here: otherwise a stationary + // mouse can override the row selected by keyboard ↑↓ navigation. +
{t('release.title')} {searchQuery && ( @@ -257,6 +259,7 @@ export function ReleaseSessionDialog({ key={s.sessionId} className={dp( 'resume-picker-item', + 'resume-picker-session-item', i === selectedIdx && !searchMode ? 'selected' : undefined, isDisabled ? 'resume-picker-item-current' : undefined, isDisabled ? 'disabled' : undefined, @@ -265,7 +268,6 @@ export function ReleaseSessionDialog({ setSelectedIdx(i); if (!isDisabled) handleRelease(s); }} - onMouseEnter={() => setSelectedIdx(i)} >
diff --git a/packages/web-shell/client/components/dialogs/ResumeDialog.tsx b/packages/web-shell/client/components/dialogs/ResumeDialog.tsx index c58d06c141..622d124b59 100644 --- a/packages/web-shell/client/components/dialogs/ResumeDialog.tsx +++ b/packages/web-shell/client/components/dialogs/ResumeDialog.tsx @@ -124,6 +124,8 @@ export function ResumeDialog({ onSelect, onClose }: ResumeDialogProps) { ); return ( + // Hover selection is intentionally disabled here: otherwise a stationary + // mouse can override the row selected by keyboard ↑↓ navigation.
{/* Header */}
@@ -204,6 +206,7 @@ export function ResumeDialog({ onSelect, onClose }: ResumeDialogProps) { key={s.sessionId} className={dp( 'resume-picker-item', + 'resume-picker-session-item', i === selectedIdx && !searchMode ? 'selected' : undefined, isCurrent ? 'resume-picker-item-current' : undefined, )} diff --git a/packages/web-shell/client/components/messages/ApprovalModeMessage.module.css b/packages/web-shell/client/components/messages/ApprovalModeMessage.module.css new file mode 100644 index 0000000000..0f3ae5143c --- /dev/null +++ b/packages/web-shell/client/components/messages/ApprovalModeMessage.module.css @@ -0,0 +1,70 @@ +.panel { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0px 0 12px 14px; + padding: 10px 14px; + border: 1px solid var(--border-color); + border-radius: 4px; + font-family: var(--font-mono); + font-size: 13px; + color: var(--text-primary); + background: var(--bg-primary); + max-width: min(920px, calc(100vw - 64px)); + margin-left: 14px; +} + +.header { + display: flex; + align-items: baseline; + gap: 8px; +} + +.title { + font-weight: 700; +} + +.list { + display: flex; + flex-direction: column; + gap: 2px; + max-height: min(360px, calc(100vh - 260px)); + overflow-y: auto; +} + +.row { + display: flex; + gap: 6px; + align-items: baseline; + min-height: 18px; + cursor: pointer; +} + +.selected .label { + color: var(--accent-color); +} + +.pointer { + display: inline-block; + width: 1ch; + flex-shrink: 0; + color: var(--accent-color); + font-weight: 700; +} + +.number { + min-width: 2ch; + color: var(--text-secondary); + text-align: right; +} + +.label { + color: var(--text-primary); + overflow-wrap: anywhere; +} + +.footer { + color: var(--text-secondary); + border-top: 1px solid var(--border-color); + padding-top: 8px; +} diff --git a/packages/web-shell/client/components/messages/ApprovalModeMessage.tsx b/packages/web-shell/client/components/messages/ApprovalModeMessage.tsx new file mode 100644 index 0000000000..97f8639e6e --- /dev/null +++ b/packages/web-shell/client/components/messages/ApprovalModeMessage.tsx @@ -0,0 +1,145 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { DAEMON_APPROVAL_MODES } from '@qwen-code/webui/daemon-react-sdk'; +import { useDelayedGlobalKeyDown } from '../../hooks/useDelayedGlobalKeyDown'; +import { useI18n } from '../../i18n'; +import styles from './ApprovalModeMessage.module.css'; + +export const APPROVAL_MODE_ACTIVE_EVENT = + 'web-shell:approval-mode-panel-active'; + +interface ApprovalModeMessageProps { + currentMode: string; + onSelect: (modeId: string) => void; + onClose: () => void; +} + +interface ModeItem { + id: string; + name: string; + description: string; +} + +export function ApprovalModeMessage({ + currentMode, + onSelect, + onClose, +}: ApprovalModeMessageProps) { + const { t } = useI18n(); + const panelIdRef = useRef( + `approval-mode-${Math.random().toString(36).slice(2)}`, + ); + const listRef = useRef(null); + + const approvalModes: ModeItem[] = DAEMON_APPROVAL_MODES.map((id) => ({ + id, + name: t(`mode.name.${id}`), + description: t(`mode.desc.${id}`), + })); + + const [selectedIdx, setSelectedIdx] = useState(() => { + const idx = approvalModes.findIndex((m) => m.id === currentMode); + return idx >= 0 ? idx : 0; + }); + + const emitActive = useCallback((active: boolean) => { + window.dispatchEvent( + new CustomEvent(APPROVAL_MODE_ACTIVE_EVENT, { + detail: { id: panelIdRef.current, active }, + }), + ); + }, []); + + useEffect(() => { + emitActive(true); + return () => emitActive(false); + }, [emitActive]); + + useEffect(() => { + if (selectedIdx >= approvalModes.length && approvalModes.length > 0) { + setSelectedIdx(approvalModes.length - 1); + } + }, [approvalModes.length, selectedIdx]); + + useEffect(() => { + const el = listRef.current?.children[selectedIdx] as + | HTMLElement + | undefined; + el?.scrollIntoView({ block: 'nearest' }); + }, [selectedIdx]); + + const handleSelect = useCallback(() => { + const mode = approvalModes[selectedIdx]; + if (!mode) return; + onSelect(mode.id); + onClose(); + }, [approvalModes, onClose, onSelect, selectedIdx]); + + useDelayedGlobalKeyDown( + (e: KeyboardEvent) => { + if (e.defaultPrevented) return; + const claim = () => { + e.preventDefault(); + e.stopPropagation(); + }; + + if (e.key === 'Escape') { + claim(); + onClose(); + return; + } + if (e.key === 'ArrowDown' || e.key === 'j') { + claim(); + setSelectedIdx((idx) => + approvalModes.length > 0 + ? Math.min(idx + 1, approvalModes.length - 1) + : 0, + ); + return; + } + if (e.key === 'ArrowUp' || e.key === 'k') { + claim(); + setSelectedIdx((idx) => Math.max(idx - 1, 0)); + return; + } + if (e.key === 'Enter') { + claim(); + handleSelect(); + return; + } + }, + [approvalModes.length, handleSelect, onClose], + ); + + return ( +
+
+ {t('mode.select')} +
+ +
+ {approvalModes.map((m, index) => { + const selected = index === selectedIdx; + return ( +
{ + onSelect(m.id); + onClose(); + }} + onMouseEnter={() => setSelectedIdx(index)} + > + {selected ? '›' : ' '} + {index + 1}. + + {m.name} - {m.description} + +
+ ); + })} +
+ +
{t('mode.footer')}
+
+ ); +} diff --git a/packages/web-shell/client/components/messages/AssistantMessage.tsx b/packages/web-shell/client/components/messages/AssistantMessage.tsx index 071391e61b..e4793badda 100644 --- a/packages/web-shell/client/components/messages/AssistantMessage.tsx +++ b/packages/web-shell/client/components/messages/AssistantMessage.tsx @@ -26,7 +26,7 @@ export const AssistantMessage = memo(function AssistantMessage({
- +
)} diff --git a/packages/web-shell/client/components/messages/Markdown.tsx b/packages/web-shell/client/components/messages/Markdown.tsx index 5828f50ef1..d23a640aea 100644 --- a/packages/web-shell/client/components/messages/Markdown.tsx +++ b/packages/web-shell/client/components/messages/Markdown.tsx @@ -7,10 +7,15 @@ import remarkMath from 'remark-math'; import rehypeKatex from 'rehype-katex'; import { codeToHtml, type BundledLanguage } from 'shiki'; import { useI18n } from '../../i18n'; +import { + useWebShellCustomization, + type MarkdownContentSource, +} from '../../customization'; import styles from './Markdown.module.css'; interface MarkdownProps { content: string; + source?: MarkdownContentSource; } const SUPPORTED_LANGUAGES = new Set([ @@ -434,17 +439,37 @@ const components: Components = { }, }; -export const Markdown = memo(function Markdown({ content }: MarkdownProps) { +export const Markdown = memo(function Markdown({ + content, + source, +}: MarkdownProps) { + const { markdown } = useWebShellCustomization(); + if (!content) return null; + const sourceMarkdown = source ? markdown : undefined; + const renderedContent = + source && sourceMarkdown?.transformMarkdown + ? sourceMarkdown.transformMarkdown(content, { source }) + : content; + const renderedComponents = sourceMarkdown?.components + ? { ...components, ...sourceMarkdown.components } + : components; + const remarkPlugins = sourceMarkdown?.remarkPlugins + ? [remarkGfm, remarkMath, ...sourceMarkdown.remarkPlugins] + : [remarkGfm, remarkMath]; + const rehypePlugins = sourceMarkdown?.rehypePlugins + ? [rehypeKatex, ...sourceMarkdown.rehypePlugins] + : [rehypeKatex]; + return ( -
+
- {content} + {renderedContent}
); diff --git a/packages/web-shell/client/components/messages/ToolGroup.tsx b/packages/web-shell/client/components/messages/ToolGroup.tsx index 030707226c..afe123994f 100644 --- a/packages/web-shell/client/components/messages/ToolGroup.tsx +++ b/packages/web-shell/client/components/messages/ToolGroup.tsx @@ -33,6 +33,11 @@ import { } from './toolFormatting'; import { useI18n } from '../../i18n'; import { CompactModeContext } from '../../App'; +import { + type ToolHeaderExtraRenderInfo, + type ToolHeaderKind, + useWebShellCustomization, +} from '../../customization'; import styles from './tools/ToolChrome.module.css'; interface ToolGroupProps { @@ -383,6 +388,46 @@ function shouldAutoExpand(tool: ACPToolCall): boolean { return false; } +function getToolHeaderKind(tool: ACPToolCall): ToolHeaderKind { + const name = tool.toolName.toLowerCase(); + if (isSubAgentToolCall(tool)) return 'agent'; + if (isShellToolName(name)) return 'shell'; + if (isWebFetchToolName(name)) return 'fetch'; + if (name === 'todowrite') return 'todo'; + if (name === 'read' || name === 'read_file' || name === 'readfile') + return 'read'; + if (name === 'edit' || name === 'editfile') return 'edit'; + if (name === 'write' || name === 'write_file' || name === 'writefile') + return 'write'; + return 'other'; +} + +function DefaultToolHeaderExtra({ + description, + elapsed, +}: { + description: string; + elapsed: string; +}) { + return ( + <> + {description && {description}} + {elapsed && {elapsed}} + + ); +} + +function ToolHeaderExtra({ info }: { info: ToolHeaderExtraRenderInfo }) { + const { renderToolHeaderExtra } = useWebShellCustomization(); + if (renderToolHeaderExtra) return <>{renderToolHeaderExtra(info)}; + return ( + + ); +} + function getActiveTool(tools: ACPToolCall[]): ACPToolCall { return ( tools.find((t) => t.status === 'in_progress') ?? tools[tools.length - 1] @@ -405,6 +450,7 @@ function CompactToolGroup({ }) { const { t } = useI18n(); const activeTool = getActiveTool(tools); + const displayName = formatToolDisplayName(activeTool.toolName); const overallStatus = getCompactDisplayStatus(activeTool); const description = getToolDescription(activeTool, workspaceCwd); const elapsed = @@ -417,17 +463,23 @@ function CompactToolGroup({
- - {formatToolDisplayName(activeTool.toolName)} - + {displayName} {tools.length > 1 && ( {'× '} {tools.length} )} - {description && {description}} - {elapsed && {elapsed}} +
{t('compact.hint')}
@@ -530,6 +582,7 @@ const ToolLine = memo(function ToolLine({ } const info = getAgentDisplayInfo(tool, now); + const displayName = t('agent.label'); const isComplete = tool.status === 'completed' || tool.status === 'failed'; const toolHint = getAgentCurrentToolHint(tool); const progressLabel = tool.status === 'pending' ? 'pending' : 'running'; @@ -541,12 +594,19 @@ const ToolLine = memo(function ToolLine({
- {t('agent.label')} - {info.description && ( - - {truncateText(info.description, 60)} - - )} + {displayName} +
{!isComplete && (
setExpanded(!expanded) : undefined} > - - {formatToolDisplayName(tool.toolName)} - - {description && {description}} - {elapsed && {elapsed}} + {displayName} +
{isTodo && } {!isTodo && !expanded && result && ( diff --git a/packages/web-shell/client/constants/localCommands.ts b/packages/web-shell/client/constants/localCommands.ts index f6288d44e1..9152a1be81 100644 --- a/packages/web-shell/client/constants/localCommands.ts +++ b/packages/web-shell/client/constants/localCommands.ts @@ -37,7 +37,6 @@ export function getLocalCommands(t: Translate): CommandInfo[] { }, { name: 'delete', description: t('local.delete') }, { name: 'release', description: t('local.release') }, - { name: 'mode', description: t('local.mode'), argumentHint: '' }, { name: 'approval-mode', description: t('local.approvalMode'), diff --git a/packages/web-shell/client/customization.tsx b/packages/web-shell/client/customization.tsx new file mode 100644 index 0000000000..bddb433a78 --- /dev/null +++ b/packages/web-shell/client/customization.tsx @@ -0,0 +1,56 @@ +import { createContext, useContext, type ReactNode } from 'react'; +import type { Components, Options } from 'react-markdown'; +import type { ACPToolCall } from './adapters/types'; + +export type MarkdownContentSource = 'assistant'; + +export interface MarkdownRenderContext { + source: MarkdownContentSource; +} + +export interface WebShellMarkdownCustomization { + transformMarkdown?: ( + markdown: string, + context: MarkdownRenderContext, + ) => string; + components?: Components; + remarkPlugins?: Options['remarkPlugins']; + rehypePlugins?: Options['rehypePlugins']; +} + +export type ToolHeaderKind = + | 'agent' + | 'edit' + | 'fetch' + | 'read' + | 'shell' + | 'todo' + | 'write' + | 'other'; + +export interface ToolHeaderExtraRenderInfo { + kind: ToolHeaderKind; + tool: ACPToolCall; + displayName: string; + description: string; + elapsed: string; + workspaceCwd?: string; +} + +export type ToolHeaderExtraRenderer = ( + info: ToolHeaderExtraRenderInfo, +) => ReactNode; + +export interface WebShellCustomization { + renderToolHeaderExtra?: ToolHeaderExtraRenderer; + markdown?: WebShellMarkdownCustomization; +} + +const WebShellCustomizationContext = createContext({}); + +export const WebShellCustomizationProvider = + WebShellCustomizationContext.Provider; + +export function useWebShellCustomization(): WebShellCustomization { + return useContext(WebShellCustomizationContext); +} diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 466be39b1c..10270d05aa 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -380,7 +380,6 @@ const EN: Messages = { 'local.language': 'Change UI language', 'local.mcp': 'Manage MCP servers', 'local.memory': 'Manage memory', - 'local.mode': 'Change approval mode', 'local.model': 'Switch model or set fast model', 'local.new': 'Start a new conversation', 'local.plan': 'Enter Plan mode', @@ -553,6 +552,18 @@ const EN: Messages = { 'mode.auto.desc': 'Automatically decide when approval is needed', 'mode.auto.notice': 'Auto mode enabled. An LLM classifier evaluates each tool call and auto-approves safe actions, blocks risky ones. To exit: Shift+Tab or /approval-mode default.', + 'mode.footer': '(Use Enter to select, Esc to cancel)', + 'mode.name.plan': 'plan', + 'mode.name.default': 'default', + 'mode.name.auto-edit': 'auto-edit', + 'mode.name.auto': 'auto', + 'mode.name.yolo': 'yolo', + 'mode.desc.plan': 'Analyze only, do not modify files or execute commands', + 'mode.desc.default': 'Require approval for file edits or shell commands', + 'mode.desc.auto-edit': 'Automatically approve file edits', + 'mode.desc.auto': 'auto mode', + 'mode.desc.yolo': 'Automatically approve all tools', + 'mode.select': 'Approval Mode', 'mode.autoApproved': ((v) => v?.tool ? `Auto-approved: ${v.tool}` @@ -1040,7 +1051,6 @@ const ZH: Messages = { 'local.language': '切换 UI 语言', 'local.mcp': '管理 MCP servers', 'local.memory': '管理 memory', - 'local.mode': '切换审批模式', 'local.model': '切换模型或设置 fast model', 'local.new': '开始新对话', 'local.plan': '进入 Plan 模式', @@ -1203,6 +1213,18 @@ const ZH: Messages = { 'mode.auto.desc': '自动判断是否需要请求批准', 'mode.auto.notice': 'Auto 模式已启用。LLM 分类器会评估每个工具调用,自动批准安全操作,拦截危险操作。退出方式:Shift+Tab 或 /approval-mode default。', + 'mode.footer': '(Enter 选择,Esc 取消)', + 'mode.name.plan': 'plan', + 'mode.name.default': 'default', + 'mode.name.auto-edit': 'auto-edit', + 'mode.name.auto': 'auto', + 'mode.name.yolo': 'yolo', + 'mode.desc.plan': '仅分析,不修改文件或执行命令', + 'mode.desc.default': '编辑文件或执行命令前需要批准', + 'mode.desc.auto-edit': '自动批准文件编辑', + 'mode.desc.auto': 'auto 模式', + 'mode.desc.yolo': '自动批准所有工具', + 'mode.select': '审批模式', 'mode.autoApproved': ((v) => v?.tool ? `已自动批准:${v.tool}` diff --git a/packages/web-shell/client/index.ts b/packages/web-shell/client/index.ts index c7f33df733..c198f8d98d 100644 --- a/packages/web-shell/client/index.ts +++ b/packages/web-shell/client/index.ts @@ -1,3 +1,11 @@ export { App as WebShell } from './App'; export type { WebShellProps, BugReportInfo } from './App'; export type { WebShellLanguage } from './i18n'; +export type { + MarkdownContentSource, + MarkdownRenderContext, + ToolHeaderExtraRenderer, + ToolHeaderExtraRenderInfo, + ToolHeaderKind, + WebShellMarkdownCustomization, +} from './customization'; diff --git a/packages/web-shell/client/index.tsx b/packages/web-shell/client/index.tsx index e2ce60815c..34709a6c3f 100644 --- a/packages/web-shell/client/index.tsx +++ b/packages/web-shell/client/index.tsx @@ -55,3 +55,11 @@ export const StandaloneWebShell = WebShellWithProviders; export type { WebShellProps } from './App'; export type { WebShellLanguage } from './i18n'; +export type { + MarkdownContentSource, + MarkdownRenderContext, + ToolHeaderExtraRenderer, + ToolHeaderExtraRenderInfo, + ToolHeaderKind, + WebShellMarkdownCustomization, +} from './customization';