diff --git a/apps/electron/src/main/lib/adapters/pi-agent-adapter.ts b/apps/electron/src/main/lib/adapters/pi-agent-adapter.ts index 5d6b3f286..d4c97665c 100644 --- a/apps/electron/src/main/lib/adapters/pi-agent-adapter.ts +++ b/apps/electron/src/main/lib/adapters/pi-agent-adapter.ts @@ -93,6 +93,40 @@ const PI_NATIVE_MAX_TOTAL_DELAY_MS = 5 * 60_000 const PI_NATIVE_RETRY_JITTER_RATIO = 0.2 const MAX_AUTOMATIC_COMPACTION_CONTINUATIONS = 20 +/** + * Bash 流式输出缓冲:toolCallId → 已透传的累计文本。 + * 用于计算增量(只推送新增部分),并在 tool_execution_end 时清理。 + * + * 生命周期:Pi SDK 契约保证 tool_execution_end 在正常/错误/abort/超时/截断 + * 所有路径都发射(bash 工具 throw → error result → emitToolExecutionEnd), + * 因此清理可靠。仅进程被杀等极端场景会残留少量条目(key 全局唯一、不重用, + * 单条几 KB,可接受)。 + */ +const bashOutputBuffer = new Map() + +/** + * 从 Pi 的 partialResult(AgentToolUpdateCallback 参数)中提取文本内容。 + * + * partialResult 形如 { content: [{ type: 'text', text: '...' }], details }, + * 与工具最终 result 的 content 结构一致。只提取 text 块并拼接。 + * 导出供单元测试使用。 + */ +export function extractPartialResultText( + partialResult: unknown, +): string | undefined { + if (!partialResult || typeof partialResult !== 'object') return undefined + const content = (partialResult as { content?: unknown }).content + if (!Array.isArray(content)) return undefined + const parts: string[] = [] + for (const block of content) { + if (!block || typeof block !== 'object') continue + const b = block as { type?: unknown; text?: unknown } + if (b.type === 'text' && typeof b.text === 'string') parts.push(b.text) + } + const text = parts.join('') + return text.length > 0 ? text : undefined +} + /** Pi SDK 查询选项(扩展通用 AgentQueryInput) */ export interface PiAgentQueryOptions extends AgentQueryInput { apiKey: string @@ -1728,6 +1762,46 @@ export class PiAgentAdapter implements AgentProviderAdapter { tool_name: displayToolName(event.toolName, event.args as Record | undefined), parent_tool_use_id: null, } as unknown as SDKMessage) + // Bash 工具的流式输出:Pi 的 onUpdate 推送累计输出快照, + // 提取文本内容并计算增量后透传给渲染进程,驱动实时终端化渲染。 + // 仅处理 Bash:当前只有 Bash 工具调用 onUpdate,白名单避免未来 + // 其他工具意外接入时产生无谓的 buffer + IPC 开销。 + if (event.toolName === 'Bash' || event.toolName === 'bash') { + const partialOutput = extractPartialResultText(event.partialResult) + if (partialOutput) { + const prev = bashOutputBuffer.get(event.toolCallId) + if (prev === undefined || !partialOutput.startsWith(prev)) { + // 首帧或快照被截断重置(SDK 只保留 tail):整帧替换,避免渲染层拼出脏内容 + queue.push({ + type: 'tool_output', + session_id: session.sessionId, + tool_use_id: event.toolCallId, + tool_name: displayToolName(event.toolName, event.args as Record | undefined), + parent_tool_use_id: null, + output: partialOutput, + replace: true, + } as unknown as SDKMessage) + bashOutputBuffer.set(event.toolCallId, partialOutput) + } else if (partialOutput.length > prev.length) { + // 常规增量:仅推送新增文本,减少 IPC 与渲染压力 + queue.push({ + type: 'tool_output', + session_id: session.sessionId, + tool_use_id: event.toolCallId, + tool_name: displayToolName(event.toolName, event.args as Record | undefined), + parent_tool_use_id: null, + output: partialOutput.slice(prev.length), + } as unknown as SDKMessage) + bashOutputBuffer.set(event.toolCallId, partialOutput) + } + } + } + break + case 'tool_execution_end': + // 工具执行结束,清理流式输出缓冲,避免长会话内存泄漏 + if (bashOutputBuffer.has(event.toolCallId)) { + bashOutputBuffer.delete(event.toolCallId) + } break case 'compaction_start': // 压缩开始(手动 /compact 或自动阈值/溢出触发):发前端已识别的 compacting system 消息, diff --git a/apps/electron/src/main/lib/adapters/pi-agent-bash.test.ts b/apps/electron/src/main/lib/adapters/pi-agent-bash.test.ts index af70c6834..b8b86c47c 100644 --- a/apps/electron/src/main/lib/adapters/pi-agent-bash.test.ts +++ b/apps/electron/src/main/lib/adapters/pi-agent-bash.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { buildWslBashArgs, windowsPathToWslPath } from './pi-agent-adapter' +import { buildWslBashArgs, windowsPathToWslPath, extractPartialResultText } from './pi-agent-adapter' describe('Pi WSL Bash', () => { test('Given a Windows workspace path When building WSL Bash arguments Then uses its mounted Linux path', () => { @@ -24,3 +24,37 @@ describe('Pi WSL Bash', () => { expect(windowsPathToWslPath('/home/alice/project')).toBe('/home/alice/project') }) }) + +describe('extractPartialResultText', () => { + test('Given Pi SDK partialResult with text content When extracting Then returns the text', () => { + expect(extractPartialResultText({ + content: [{ type: 'text', text: 'Compiling...\n' }], + })).toBe('Compiling...\n') + }) + + test('Given multiple text blocks When extracting Then joins them', () => { + expect(extractPartialResultText({ + content: [{ type: 'text', text: 'a' }, { type: 'text', text: 'b\n' }], + })).toBe('ab\n') + }) + + test('Given empty content When extracting Then returns undefined', () => { + expect(extractPartialResultText({ content: [] })).toBeUndefined() + }) + + test('Given no content field When extracting Then returns undefined', () => { + expect(extractPartialResultText({ details: {} })).toBeUndefined() + }) + + test('Given non-text blocks only When extracting Then returns undefined', () => { + expect(extractPartialResultText({ content: [{ type: 'image', source: 'x' }] })).toBeUndefined() + }) + + test('Given null When extracting Then returns undefined', () => { + expect(extractPartialResultText(null)).toBeUndefined() + }) + + test('Given empty string text When extracting Then returns undefined', () => { + expect(extractPartialResultText({ content: [{ type: 'text', text: '' }] })).toBeUndefined() + }) +}) diff --git a/apps/electron/src/renderer/atoms/agent-atoms.test.ts b/apps/electron/src/renderer/atoms/agent-atoms.test.ts index 466465324..289e0c892 100644 --- a/apps/electron/src/renderer/atoms/agent-atoms.test.ts +++ b/apps/electron/src/renderer/atoms/agent-atoms.test.ts @@ -287,3 +287,71 @@ describe('Agent 流式错误状态', () => { expect(clearAgentStreamError(errors, 'retried-session')).toBe(errors) }) }) + +describe('Agent Bash 流式输出状态', () => { + function stateWithBashActivity(streamingOutput?: string): AgentStreamState { + return createStreamState({ + toolActivities: [{ + toolUseId: 'tool-bash-1', + toolName: 'Bash', + input: { command: 'npm run build' }, + done: false, + streamingOutput, + }], + }) + } + + test('given Bash 增量 chunk when 收到 tool_output then 追加到 streamingOutput', () => { + const result = applyAgentEvent(stateWithBashActivity('line1\n'), { + type: 'tool_output', + toolUseId: 'tool-bash-1', + output: 'line2\n', + }) + + expect(result.toolActivities[0]?.streamingOutput).toBe('line1\nline2\n') + expect(result.toolActivities[0]?.done).toBe(false) + }) + + test('given 无已有输出 when 收到首个 tool_output then 初始化为 chunk 内容', () => { + const result = applyAgentEvent(stateWithBashActivity(), { + type: 'tool_output', + toolUseId: 'tool-bash-1', + output: 'build start\n', + }) + + expect(result.toolActivities[0]?.streamingOutput).toBe('build start\n') + }) + + test('given 快照被截断重置 when 收到 replace=true then 整体替换而非追加', () => { + const result = applyAgentEvent(stateWithBashActivity('old-tail-content'), { + type: 'tool_output', + toolUseId: 'tool-bash-1', + output: 'new-tail-content', + replace: true, + }) + + expect(result.toolActivities[0]?.streamingOutput).toBe('new-tail-content') + }) + + test('given 空增量 when 收到 tool_output then 保持原引用避免重渲染', () => { + const state = stateWithBashActivity('same') + const result = applyAgentEvent(state, { + type: 'tool_output', + toolUseId: 'tool-bash-1', + output: '', + }) + + expect(result).toBe(state) + }) + + test('given 不匹配的 toolUseId when 收到 tool_output then 不修改任何 activity', () => { + const state = stateWithBashActivity('keep') + const result = applyAgentEvent(state, { + type: 'tool_output', + toolUseId: 'tool-other', + output: 'ignored', + }) + + expect(result).toBe(state) + }) +}) diff --git a/apps/electron/src/renderer/atoms/agent-atoms.ts b/apps/electron/src/renderer/atoms/agent-atoms.ts index 37f956fcf..b59feff7e 100644 --- a/apps/electron/src/renderer/atoms/agent-atoms.ts +++ b/apps/electron/src/renderer/atoms/agent-atoms.ts @@ -33,6 +33,8 @@ export interface ToolActivity { isBackground?: boolean /** MCP 工具返回的图片附件 */ imageAttachments?: Array<{ localPath: string; filename: string; mediaType: string }> + /** Bash 等工具执行期间的流式输出(实时终端化渲染用) */ + streamingOutput?: string } /** 活动分组(Task 子代理) */ @@ -305,6 +307,31 @@ export const agentSessionStreamingStateAtomFamily = atomFamily((sessionId: strin atom((get) => get(agentStreamingStatesAtom).get(sessionId)), ) +/** + * 单个 toolUseId 的流式输出派生 atom — 按 toolUseId 切片订阅。 + * + * ContentBlock 遍历所有 session 的 toolActivities 查找 streamingOutput, + * 若直接订阅 agentStreamingStatesAtom,任意 session 的 Bash 流式 tick(10Hz) + * 都会让消息树所有 ContentBlock 重渲染。本 family 让订阅者只在目标 toolUseId + * 的 streamingOutput 引用变化时重渲染——其他工具/会话的更新虽然让 base atom + * 变化,但派生 atom 输出引用未变,jotai 自动跳过通知。 + * + * toolUseId 由 SDK 生成全局唯一,跨 session 遍历定位是安全的。 + */ +export const agentToolStreamingOutputAtomFamily = atomFamily((toolUseId: string) => + atom((get) => { + const states = get(agentStreamingStatesAtom) + for (const state of states.values()) { + for (const activity of state.toolActivities) { + if (activity.toolUseId === toolUseId && activity.streamingOutput) { + return activity.streamingOutput + } + } + } + return undefined + }), +) + /** * 实时 SDKMessage 累积 Map — Phase 2 新增 * @@ -760,6 +787,26 @@ export function applyAgentEvent( } } + case 'tool_output': { + // Bash 等工具的实时输出 chunk: + // - replace=true:快照被截断重置,直接整体替换缓冲 + // - 否则:增量追加到 streamingOutput + // 无匹配 activity 或输出无变化时返回原引用,避免高频事件导致整个状态树重渲染。 + const resumed = clearFinishedCompactionForResumedWork(prev) + let changed = false + const toolActivities = resumed.toolActivities.map((t) => { + if (t.toolUseId !== event.toolUseId) return t + const nextOutput = event.replace + ? event.output + : `${t.streamingOutput ?? ''}${event.output}` + if (nextOutput === t.streamingOutput) return t + changed = true + return { ...t, streamingOutput: nextOutput } + }) + if (!changed) return prev + return { ...resumed, toolActivities } + } + case 'task_backgrounded': { const resumed = clearFinishedCompactionForResumedWork(prev) return { diff --git a/apps/electron/src/renderer/components/agent/ContentBlock.tsx b/apps/electron/src/renderer/components/agent/ContentBlock.tsx index 7925aab16..f87b23e60 100644 --- a/apps/electron/src/renderer/components/agent/ContentBlock.tsx +++ b/apps/electron/src/renderer/components/agent/ContentBlock.tsx @@ -8,6 +8,7 @@ */ import * as React from 'react' +import { useAtomValue } from 'jotai' import { ChevronRight, ChevronDown, @@ -26,6 +27,7 @@ import { PreviewOpenButton } from './tool-result-renderers/preview-open-button' import { getTaskGetStatusLabel, parseTaskGetResult, type ParsedTaskGetResult } from './tool-result-renderers/task-get-result' import { parseTaskListResult, type ParsedTaskListItem } from './tool-result-renderers/task-list-result' import { formatDuration } from './AgentMessages' +import { agentToolStreamingOutputAtomFamily } from '@/atoms/agent-atoms' import type { SDKContentBlock, SDKMessage, @@ -75,6 +77,18 @@ function useToolResult(toolUseId: string, allMessages: SDKMessage[]): ToolResult }, [toolUseId, allMessages]) } +// ===== useToolStreamingOutput Hook ===== + +/** + * 按全局唯一 toolUseId 查找工具执行期间的流式输出。 + * + * 通过 atomFamily 按 toolUseId 切片订阅:只有目标工具的输出变化时 + * 本组件才重渲染,避免任意 session 的 Bash tick(10Hz)污染消息树。 + */ +function useToolStreamingOutput(toolUseId: string): string | undefined { + return useAtomValue(agentToolStreamingOutputAtomFamily(toolUseId)) +} + // ===== useSubAgentMeta Hook ===== interface SubAgentMeta { @@ -338,6 +352,11 @@ function ToolUseBlock({ block, allMessages, animate = false, index = 0, dimmed = const resultText = toolResult?.result const isError = toolResult?.isError === true const shouldShowResult = !!resultText + // Bash 等工具的实时流式输出:从流式状态中按全局唯一 toolUseId 查找 activity + const streamingOutput = useToolStreamingOutput(block.id) + // 渲染条件派生布尔值(避免 JSX 中复杂组合表达式) + const hasResult = shouldShowResult && !!resultText + const hasStreamFallback = !!streamingOutput const taskGetSummary = React.useMemo(() => { if (block.name !== 'TaskGet' || !resultText || isError) return null return parseTaskGetResult(resultText) @@ -358,6 +377,25 @@ function ToolUseBlock({ block, allMessages, animate = false, index = 0, dimmed = const isCompleted = toolResult !== null + // Bash 工具在流式期间命令启动即显示终端(即使当前尚无输出); + // tool_result 一到(isCompleted)立即切结束模式,避免显示假的“执行中”脉冲。 + const isStreamingOutput = isStreaming && block.name === 'Bash' && !isCompleted + const showStreaming = isStreamingOutput + + // 记录是否经历过流式输出:结束后结果保持展开,避免展开/收起跳动。 + // 用户手动收起时清除标记,恢复常规折叠行为。 + const [keepResultExpanded, setKeepResultExpanded] = React.useState(false) + React.useEffect(() => { + if (isStreamingOutput) setKeepResultExpanded(true) + }, [isStreamingOutput]) + const toggleResult = React.useCallback(() => { + setExpanded((prev) => { + const next = !prev + if (!next) setKeepResultExpanded(false) + return next + }) + }, []) + // 运行中显示进行时短语,完成或非流式(已终止)显示完成态短语 const displayLabel = (isCompleted || !isStreaming) ? phrase.label : phrase.loadingLabel const filePath = extractFilePath(block.input) @@ -481,7 +519,7 @@ function ToolUseBlock({ block, allMessages, animate = false, index = 0, dimmed = 'inline-flex max-w-full items-center gap-2 py-0.5 text-left transition-opacity group', 'hover:opacity-70', )} - onClick={() => setExpanded(!expanded)} + onClick={toggleResult} > {!isCompleted && isStreaming ? ( @@ -533,7 +571,12 @@ function ToolUseBlock({ block, allMessages, animate = false, index = 0, dimmed = )} - {shouldShowResult && resultText && expanded && ( + {/* 渲染条件: + * - hasResult:正常结束态有完整结果 + * - showStreaming:Bash 流式执行中(命令启动即显示) + * - hasStreamFallback:流式输出缓冲仍存在(如结束但 result 为空兜底) + * 展开条件:用户展开 / 流式中强制显示 / 曾流式且仍有内容(避免结束后内容消失) */} + {(hasResult || showStreaming || hasStreamFallback) && (expanded || showStreaming || (keepResultExpanded && (hasResult || hasStreamFallback))) && (
)} diff --git a/apps/electron/src/renderer/components/agent/tool-result-renderers/bash-result.test.ts b/apps/electron/src/renderer/components/agent/tool-result-renderers/bash-result.test.ts new file mode 100644 index 000000000..4a9a6bbc4 --- /dev/null +++ b/apps/electron/src/renderer/components/agent/tool-result-renderers/bash-result.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from 'bun:test' +import { parseAnsiSgr } from './bash-result' + +describe('parseAnsiSgr', () => { + test('Given plain text without ANSI When parsing Then returns single plain segment', () => { + expect(parseAnsiSgr('hello world')).toEqual([ + { text: 'hello world', style: {} }, + ]) + }) + + test('Given empty string When parsing Then returns empty array', () => { + expect(parseAnsiSgr('')).toEqual([]) + }) + + test('Given red foreground escape When parsing Then applies fg color', () => { + expect(parseAnsiSgr('\x1b[31mred\x1b[0m')).toEqual([ + { text: 'red', style: { fg: '#c91b00' } }, + ]) + }) + + test('Given bold + color combined codes When parsing Then applies both styles', () => { + expect(parseAnsiSgr('\x1b[1;32mbold green\x1b[0m')).toEqual([ + { text: 'bold green', style: { bold: true, fg: '#00c200' } }, + ]) + }) + + test('Given style change mid-text When parsing Then splits into styled segments', () => { + expect(parseAnsiSgr('a\x1b[31mb\x1b[0mc')).toEqual([ + { text: 'a', style: {} }, + { text: 'b', style: { fg: '#c91b00' } }, + { text: 'c', style: {} }, + ]) + }) + + test('Given 256-color escape (38;5;n) When parsing Then resolves palette color', () => { + expect(parseAnsiSgr('\x1b[38;5;196mhi\x1b[0m')).toEqual([ + { text: 'hi', style: { fg: 'rgb(255,0,0)' } }, + ]) + }) + + test('Given truecolor escape (38;2;r;g;b) When parsing Then resolves rgb color', () => { + expect(parseAnsiSgr('\x1b[38;2;255;128;0morange\x1b[0m')).toEqual([ + { text: 'orange', style: { fg: 'rgb(255,128,0)' } }, + ]) + }) + + test('Given background color escape When parsing Then applies bg color', () => { + expect(parseAnsiSgr('\x1b[44mblue bg\x1b[0m')).toEqual([ + { text: 'blue bg', style: { bg: '#0225c7' } }, + ]) + }) + + test('Given bare ESC m (code 0) When parsing Then resets style', () => { + expect(parseAnsiSgr('\x1b[31mred\x1b[mnormal')).toEqual([ + { text: 'red', style: { fg: '#c91b00' } }, + { text: 'normal', style: {} }, + ]) + }) + + test('Given non-color SGR like underline When parsing Then applies text decoration', () => { + expect(parseAnsiSgr('\x1b[4munderlined\x1b[0m')).toEqual([ + { text: 'underlined', style: { underline: true } }, + ]) + }) +}) diff --git a/apps/electron/src/renderer/components/agent/tool-result-renderers/bash-result.tsx b/apps/electron/src/renderer/components/agent/tool-result-renderers/bash-result.tsx index 9d249e833..dff8ef645 100644 --- a/apps/electron/src/renderer/components/agent/tool-result-renderers/bash-result.tsx +++ b/apps/electron/src/renderer/components/agent/tool-result-renderers/bash-result.tsx @@ -1,17 +1,31 @@ /** * Bash 工具结果渲染器 — 终端风格 * - * 深色背景、等宽字体、stderr 红色高亮 + * 固定高度终端块(max-h-[320px] + 内部滚动),运行中与结束时高度一致: + * - 运行中:实时显示 stdout/stderr chunk,自动滚动跟随、暂停/恢复、ANSI 颜色、stderr 高亮 + * - 结束:同一固定高度块展示完整输出(可内部滚动 + 复制),仅去掉“执行中”脉冲 + * 展开/折叠由外层 ContentBlock 控制:展开 = 完整输出块,折叠 = 无输出块 + * + * ANSI SGR 解析:轻量实现,支持 16/256/truecolor 前景/背景、粗体/斜体/下划线等样式。 + * 注:Pi SDK 的 bash-executor(远程/SSH 路径)会剥离 ANSI;但 Proma 实际使用的 + * 本地 Bash 工具(createLocalBashOperations / WSL operations)不剥离 ANSI, + * 因此 npm run build 等命令的彩色输出会被保留并在此解析。此解析器对未来 + * SDK 行为变化也保持防御性(无 dangerouslySetInnerHTML,无注入风险)。 */ import * as React from 'react' +import { Check, Copy, Pause, Play, ListEnd } from 'lucide-react' import { cn } from '@/lib/utils' -import { CollapsibleResult } from './collapsible-result' +import { copyTextToClipboard } from '@/lib/clipboard' interface BashResultRendererProps { result: string isError: boolean input: Record + /** 执行期间的实时流式输出 */ + streamingOutput?: string + /** 是否处于流式输出中 */ + isStreamingOutput?: boolean } /** 简单检测 stderr 行(常见模式) */ @@ -31,46 +45,338 @@ function classifyLine(line: string): 'stderr' | 'normal' { return 'normal' } -export function BashResultRenderer({ result, isError, input }: BashResultRendererProps): React.ReactElement { - const command = typeof input.command === 'string' ? input.command : undefined +// ============================================================================ +// ANSI SGR 解析 — 将转义序列转换为 React span 样式 +// ============================================================================ + +interface AnsiStyle { + fg?: string + bg?: string + bold?: boolean + italic?: boolean + underline?: boolean + dim?: boolean + strikethrough?: boolean +} + +const ANSI_SGR_RE = /\x1b\[([0-9;]*)m/g + +/** 16 色基础色板(标准色 + 亮色) */ +const ANSI_16_COLORS: Record = { + 30: '#333333', 31: '#c91b00', 32: '#00c200', 33: '#c7c400', + 34: '#0225c7', 35: '#c930c7', 36: '#00c5c7', 37: '#c7c7c7', + 90: '#676767', 91: '#ff6d67', 92: '#5ff967', 93: '#fefb67', + 94: '#6871ff', 95: '#ff76ff', 96: '#5ffdff', 97: '#ffffff', +} + +/** 256 色(xterm):16 + 216 立方体 + 24 灰阶 */ +function ansi256Color(code: number): string { + if (code < 16) return ANSI_16_COLORS[code] ?? '#ffffff' + if (code < 232) { + // 6×6×6 立方体 + const idx = code - 16 + const r = Math.floor(idx / 36) + const g = Math.floor((idx % 36) / 6) + const b = idx % 6 + const scale = (v: number): number => (v === 0 ? 0 : 55 + v * 40) + return `rgb(${scale(r)},${scale(g)},${scale(b)})` + } + // 24 阶灰度 + const gray = 8 + (code - 232) * 10 + return `rgb(${gray},${gray},${gray})` +} + +/** 解析一段 SGR 转义文本,返回带样式的分段数组(导出供单元测试) */ +export function parseAnsiSgr(text: string): Array<{ text: string; style: AnsiStyle }> { + const segments: Array<{ text: string; style: AnsiStyle }> = [] + let lastIndex = 0 + let style: AnsiStyle = {} + + const pushPlain = (end: number): void => { + if (end > lastIndex) { + const plain = text.slice(lastIndex, end) + segments.push({ text: plain, style: { ...style } }) + } + } - const renderTerminal = React.useCallback((text: string): React.ReactNode => { - const lines = text.split('\n') + ANSI_SGR_RE.lastIndex = 0 + let match: RegExpExecArray | null + while ((match = ANSI_SGR_RE.exec(text)) !== null) { + pushPlain(match.index) + lastIndex = ANSI_SGR_RE.lastIndex + + const codes = match[1] === '' ? [0] : match[1]!.split(';').map((n) => Number.parseInt(n, 10)) + // 解析参数序列(支持 38;5;n / 38;2;r;g;b 等复合码) + let i = 0 + while (i < codes.length) { + const code = codes[i]! + if (code === 0) { + style = {} + } else if (code === 1) { + style.bold = true + } else if (code === 2) { + style.dim = true + } else if (code === 3) { + style.italic = true + } else if (code === 4) { + style.underline = true + } else if (code === 9) { + style.strikethrough = true + } else if (code === 22) { + style.bold = false + style.dim = false + } else if (code === 23) { + style.italic = false + } else if (code === 24) { + style.underline = false + } else if (code === 29) { + style.strikethrough = false + } else if (code >= 30 && code <= 37) { + style.fg = ANSI_16_COLORS[code]! + } else if (code >= 40 && code <= 47) { + style.bg = ANSI_16_COLORS[code - 10]! + } else if (code >= 90 && code <= 97) { + style.fg = ANSI_16_COLORS[code]! + } else if (code >= 100 && code <= 107) { + style.bg = ANSI_16_COLORS[code - 10]! + } else if (code === 38 || code === 48) { + // 扩展前景/背景色:38;5;n | 38;2;r;g;b + const target = code === 38 ? 'fg' : 'bg' + const mode = codes[i + 1] + if (mode === 5 && typeof codes[i + 2] === 'number') { + const color = ansi256Color(codes[i + 2]!) + if (target === 'fg') style.fg = color + else style.bg = color + i += 2 + } else if (mode === 2 && typeof codes[i + 2] === 'number' && typeof codes[i + 3] === 'number' && typeof codes[i + 4] === 'number') { + const color = `rgb(${codes[i + 2]!},${codes[i + 3]!},${codes[i + 4]!})` + if (target === 'fg') style.fg = color + else style.bg = color + i += 4 + } + } else if (code === 39) { + style.fg = undefined + } else if (code === 49) { + style.bg = undefined + } + i++ + } + } + pushPlain(text.length) + return segments +} + +/** 将带样式分段渲染为 React 节点 */ +function renderAnsiSegments(segments: Array<{ text: string; style: AnsiStyle }>): React.ReactNode[] { + return segments.map((segment, idx) => { + const { text: segText, style } = segment + const styleObj: React.CSSProperties = {} + if (style.fg) styleObj.color = style.fg + if (style.bg) styleObj.backgroundColor = style.bg + if (style.bold) styleObj.fontWeight = 600 + if (style.dim) styleObj.opacity = 0.6 + if (style.italic) styleObj.fontStyle = 'italic' + if (style.underline) styleObj.textDecoration = 'underline' + if (style.strikethrough) styleObj.textDecoration = 'line-through' return ( -
+ + {segText} + + ) + }) +} + +// ============================================================================ +// 流式终端组件 +// ============================================================================ + +interface StreamingTerminalProps { + output: string + command?: string + isError?: boolean + isFinished: boolean +} + +/** 流式终端:深色背景 + 自动滚动 + 暂停/恢复 + 实时行渲染 */ +function StreamingTerminal({ output, command, isError, isFinished }: StreamingTerminalProps): React.ReactElement { + const scrollRef = React.useRef(null) + /** 是否跟随底部滚动(用户上滚查看历史时暂停跟随) */ + const [followBottom, setFollowBottom] = React.useState(true) + /** 用户手动暂停滚动 */ + const [paused, setPaused] = React.useState(false) + const [copied, setCopied] = React.useState(false) + + // 新输出到达且未暂停跟随 → 滚动到底部 + React.useEffect(() => { + const el = scrollRef.current + if (el && followBottom && !paused) { + el.scrollTop = el.scrollHeight + } + }, [output, followBottom, paused]) + + const handleScroll = React.useCallback(() => { + const el = scrollRef.current + if (!el) return + // 距离底部 < 24px 视为跟随;否则暂停跟随(供用户回看) + const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 24 + setFollowBottom(nearBottom) + }, []) + + const handleCopy = React.useCallback(async () => { + try { + await copyTextToClipboard(output) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch (error) { + console.error('复制失败:', error) + } + }, [output]) + + const togglePause = React.useCallback(() => { + setPaused((prev) => { + const next = !prev + if (!next) setFollowBottom(true) + return next + }) + }, []) + + const scrollToBottom = React.useCallback(() => { + setFollowBottom(true) + setPaused(false) + const el = scrollRef.current + if (el) el.scrollTop = el.scrollHeight + }, []) + + // 渲染输出行(ANSI 解析 + stderr 启发式高亮) + // 仅渲染最近 MAX_RENDER_LINES 行,避免长输出(如 build 日志)DOM 爆炸; + // 完整输出仍可通过复制按钮获取。 + const MAX_RENDER_LINES = 500 + const renderLines = React.useMemo(() => { + const lines = output.split('\n') + const tail = lines.length > MAX_RENDER_LINES ? lines.slice(-MAX_RENDER_LINES) : lines + return tail.map((line, i) => { + const segments = parseAnsiSgr(line) + const hasAnsi = segments.some((s) => s.style.fg || s.style.bg || s.style.bold || s.style.italic || s.style.underline || s.style.dim || s.style.strikethrough) + const isStderr = isError || (!hasAnsi && classifyLine(line) === 'stderr') + return ( +
+ {hasAnsi ? renderAnsiSegments(segments) : (line || '\u200B')} +
+ ) + }) + }, [output, isError]) + + const lineCount = React.useMemo(() => output.split('\n').length, [output]) + const showPauseControl = !isFinished && lineCount > 3 + const truncatedLines = lineCount - Math.min(lineCount, MAX_RENDER_LINES) + + return ( +
+ {/* 终端容器 */} +
{/* 命令回显 */} {command && ( -
+
$ {command}
)} - {/* 输出行 */} - {lines.map((line, i) => { - const type = isError ? 'stderr' : classifyLine(line) - return ( -
- {line || '\u200B'} -
- ) - })} + {truncatedLines > 0 && ( +
… 省略前 {truncatedLines} 行(复制可获取完整输出)
+ )} + {isFinished && output.trim().length === 0 ? ( +
(no output)
+ ) : ( + renderLines + )} + {isStreamingTailIndicator()} +
+ + {/* 工具条:暂停/恢复 + 回到底部 + 复制 */} +
+ {showPauseControl && ( + + )} + {!followBottom && !paused && ( + + )} + +
+
+ ) + + /** 流式中的光标提示 */ + function isStreamingTailIndicator(): React.ReactNode { + if (isFinished) return null + return ( +
+ + 执行中…
) - }, [command, isError]) + } +} + +// ============================================================================ +// 主渲染器 +// ============================================================================ + +export function BashResultRenderer({ result, isError, input, streamingOutput, isStreamingOutput }: BashResultRendererProps): React.ReactElement { + const command = typeof input.command === 'string' ? input.command : undefined + + // 固定高度终端块:无论运行中还是结束,块高度一致(max-h-[320px] + 内部滚动)。 + // 展开 = 完整输出块(由外层 ContentBlock 控制),折叠 = 无输出块。 + const finalText = result || streamingOutput || '' + + // 空输出兜底:同样固定高度,避免无输出时块消失造成跳动 + if (isStreamingOutput && !finalText) { + return + } return ( - ) } diff --git a/apps/electron/src/renderer/components/agent/tool-result-renderers/index.tsx b/apps/electron/src/renderer/components/agent/tool-result-renderers/index.tsx index d96eb88ad..71e92cf0a 100644 --- a/apps/electron/src/renderer/components/agent/tool-result-renderers/index.tsx +++ b/apps/electron/src/renderer/components/agent/tool-result-renderers/index.tsx @@ -24,12 +24,16 @@ export interface ToolResultRendererProps { result: string isError: boolean basePath?: string + /** Bash 等工具的实时流式输出(执行中) */ + streamingOutput?: string + /** 是否处于流式输出中 */ + isStreamingOutput?: boolean } -export function ToolResultRenderer({ toolName, input, result, isError, basePath }: ToolResultRendererProps): React.ReactElement { +export function ToolResultRenderer({ toolName, input, result, isError, basePath, streamingOutput, isStreamingOutput }: ToolResultRendererProps): React.ReactElement { switch (toolName) { case 'Bash': - return + return case 'Read': return case 'Edit': diff --git a/apps/electron/src/renderer/hooks/useGlobalAgentListeners.ts b/apps/electron/src/renderer/hooks/useGlobalAgentListeners.ts index e482fb2d4..cb7252c75 100644 --- a/apps/electron/src/renderer/hooks/useGlobalAgentListeners.ts +++ b/apps/electron/src/renderer/hooks/useGlobalAgentListeners.ts @@ -437,6 +437,18 @@ function payloadToLegacyEvents(payload: AgentStreamPayload): AgentEvent[] { }] } + case 'tool_output': { + const toMsg = msg as { tool_use_id: string; tool_name?: string; output: string; replace?: boolean } + return [{ + type: 'tool_output', + toolUseId: toMsg.tool_use_id, + toolName: toMsg.tool_name, + // 运行时防御:异常发送方缺 output 时避免把 undefined 拼进缓冲 + output: typeof toMsg.output === 'string' ? toMsg.output : '', + replace: toMsg.replace === true, + }] + } + case 'prompt_suggestion': { const psMsg = msg as { suggestion?: string } if (psMsg.suggestion) return [{ type: 'prompt_suggestion', suggestion: psMsg.suggestion }] @@ -741,6 +753,10 @@ export function useGlobalAgentListeners(): void { // 跳过写入 liveMessages } else if (msgRecord.type === 'system' && msgRecord.subtype === 'thinking_tokens') { // thinking_tokens 是高频进度估算,只更新流式状态,不进入消息转录。 + } else if (msgRecord.type === 'tool_output') { + // tool_output 是工具执行期间的流式输出 chunk, + // 高频且量大,只通过 legacyEvents 更新 ToolActivity.streamingOutput, + // 不进入 liveMessages 转录,避免污染消息列表。 } else if (!msgRecord.isReplay) { // 为实时消息补充 _createdAt 时间戳(与持久化时的逻辑一致), // 避免 AssistantTurnRenderer 因缺少时间戳导致 header 时间消失 @@ -842,6 +858,9 @@ export function useGlobalAgentListeners(): void { startedAt: undefined, } const next = applyAgentEvent(current, event) + // 引用守卫:applyAgentEvent 对无实际变化的事件(如不匹配 toolUseId 的 + // tool_output)返回原引用,此时不新建 Map,避免 jotai 通知整个订阅树。 + if (next === current) return prev const map = new Map(prev) map.set(sessionId, next) return map diff --git a/packages/shared/src/types/agent.ts b/packages/shared/src/types/agent.ts index c6fa288ef..5d5cbf33e 100644 --- a/packages/shared/src/types/agent.ts +++ b/packages/shared/src/types/agent.ts @@ -346,6 +346,25 @@ export interface SDKToolProgressMessage { session_id?: string } +/** + * SDK 工具执行期间的部分输出消息(Bash 流式 stdout/stderr chunk) + * + * Pi SDK 在 Bash 工具执行期间通过 onUpdate 每 100ms 推送累计输出快照, + * 主进程将其转换为 tool_output 消息透传给渲染进程,驱动前端实时终端化渲染。 + */ +export interface SDKToolOutputMessage { + type: 'tool_output' + /** 匹配 tool_use 块的 id */ + tool_use_id: string + tool_name: string + parent_tool_use_id: string | null + /** 输出增量文本;当 replace=true 时为完整快照,前端应重置缓冲 */ + output: string + /** 完整快照标记(输出被截断/重置场景),默认 false 表示增量追加 */ + replace?: boolean + session_id?: string +} + /** SDK prompt_suggestion 消息 */ export interface SDKPromptSuggestionMessage { type: 'prompt_suggestion' @@ -369,6 +388,7 @@ export type SDKMessage = | SDKThinkingTokensMessage | SDKSystemMessage | SDKToolProgressMessage + | SDKToolOutputMessage | SDKPromptSuggestionMessage | SDKToolUseSummaryMessage | { type: string; session_id?: string; parent_tool_use_id?: string | null; [key: string]: unknown } @@ -536,6 +556,7 @@ export type AgentEvent = // 工具执行 | { type: 'tool_start'; toolName: string; toolUseId: string; input: Record; intent?: string; displayName?: string; turnId?: string; parentToolUseId?: string } | { type: 'tool_result'; toolUseId: string; toolName?: string; result: string; isError: boolean; input?: Record; turnId?: string; parentToolUseId?: string; imageAttachments?: AgentToolResultImage[] } + | { type: 'tool_output'; toolUseId: string; toolName?: string; output: string; replace?: boolean; turnId?: string } // 后台任务 | { type: 'task_backgrounded'; toolUseId: string; taskId: string; intent?: string; turnId?: string } | { type: 'task_started'; taskId: string; toolUseId?: string; description: string; taskType?: string; turnId?: string }