diff --git a/.changeset/shell-streaming-output.md b/.changeset/shell-streaming-output.md new file mode 100644 index 000000000..5d4fb7ada --- /dev/null +++ b/.changeset/shell-streaming-output.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +Stream foreground Bash stdout and stderr while commands are still running. diff --git a/apps/kimi-code/src/tui/components/messages/shell-execution.ts b/apps/kimi-code/src/tui/components/messages/shell-execution.ts index 06023d55d..1a28c7c64 100644 --- a/apps/kimi-code/src/tui/components/messages/shell-execution.ts +++ b/apps/kimi-code/src/tui/components/messages/shell-execution.ts @@ -20,6 +20,8 @@ export interface ShellExecutionOptions { */ readonly commandPreviewLines?: number; readonly resultPreviewLines?: number; + readonly tailOutput?: boolean; + readonly expandHint?: boolean; } export class ShellExecutionComponent extends Container { @@ -35,6 +37,8 @@ export class ShellExecutionComponent extends Container { options.result, options.expanded ?? false, options.resultPreviewLines ?? PREVIEW_LINES, + options.tailOutput ?? false, + options.expandHint ?? true, ); } } @@ -53,6 +57,8 @@ export class ShellExecutionComponent extends Container { result: ToolResultBlockData, expanded: boolean, previewLines: number, + tailOutput: boolean, + expandHint: boolean, ): void { if (!result.output) return; this.addChild( @@ -60,6 +66,8 @@ export class ShellExecutionComponent extends Container { expanded, isError: result.is_error ?? false, maxLines: previewLines, + tail: tailOutput, + expandHint, }), ); } diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index f9f5db738..983d1bf8c 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -44,6 +44,7 @@ const STREAMING_PROGRESS_INTERVAL_MS = 1000; const SUBAGENT_ELAPSED_INTERVAL_MS = 1000; const PROGRESS_URL_RE = /https?:\/\/\S+/g; const ABORTED_MARK = '⊘'; +const MAX_LIVE_OUTPUT_CHARS = 50_000; type SubagentTextKind = 'thinking' | 'text'; type SubagentPhase = 'queued' | 'spawning' | 'running' | 'done' | 'failed' | 'backgrounded'; @@ -557,6 +558,7 @@ export class ToolCallComponent extends Container { // authoritative final state. private progressLines: string[] = []; private static readonly MAX_PROGRESS_LINES = 24; + private liveOutput = ''; /** * Registered by a group container (`AgentGroupComponent` or @@ -586,6 +588,7 @@ export class ToolCallComponent extends Container { this.buildCallPreview(); this.callPreviewEndIndex = this.children.length; this.buildProgressBlock(); + this.buildLiveOutputBlock(); this.buildContent(); this.buildSubagentBlock(); this.syncStreamingProgressTimer(); @@ -615,6 +618,7 @@ export class ToolCallComponent extends Container { // authoritative final state. Without this clear, a finished tool would // show both the streamed status lines and the final output stacked. this.progressLines = []; + this.liveOutput = ''; this.finalizeSubagentElapsedIfNeeded(); this.syncStreamingProgressTimer(); this.syncSubagentElapsedTimer(); @@ -657,6 +661,19 @@ export class ToolCallComponent extends Container { this.ui?.requestRender(); } + appendLiveOutput(text: string): void { + if (this.result !== undefined || text.length === 0) return; + this.liveOutput += text; + if (this.liveOutput.length > MAX_LIVE_OUTPUT_CHARS) { + this.liveOutput = `[...truncated]\n${this.liveOutput.slice( + this.liveOutput.length - MAX_LIVE_OUTPUT_CHARS, + )}`; + } + this.rebuildContent(); + this.notifySnapshotChange(); + this.ui?.requestRender(); + } + dispose(): void { this.stopStreamingProgressTimer(); this.stopSubagentElapsedTimer(); @@ -1179,6 +1196,24 @@ export class ToolCallComponent extends Container { this.ui?.requestRender(); } + appendSubToolLiveOutput(id: string, text: string): void { + if (text.length === 0) return; + const activity = this.subToolActivities.get(id); + const ongoing = this.ongoingSubCalls.get(id); + if (activity === undefined && ongoing === undefined) return; + const name = activity?.name ?? ongoing?.name ?? 'Tool'; + const args = activity?.args ?? ongoing?.args ?? {}; + const existingOutput = activity?.output ?? ''; + let output = existingOutput + text; + if (output.length > MAX_LIVE_OUTPUT_CHARS) { + output = `[...truncated]\n${output.slice(output.length - MAX_LIVE_OUTPUT_CHARS)}`; + } + this.upsertSubToolActivity(id, name, args, activity?.phase ?? 'ongoing', output); + this.rebuildContent(); + this.notifySnapshotChange(); + this.ui?.requestRender(); + } + finishSubToolCall(result: { tool_call_id: string; output: string; @@ -1300,6 +1335,7 @@ export class ToolCallComponent extends Container { this.children.pop(); } this.buildProgressBlock(); + this.buildLiveOutputBlock(); this.buildContent(); this.buildSubagentBlock(); } @@ -1311,6 +1347,7 @@ export class ToolCallComponent extends Container { this.buildCallPreview(); this.callPreviewEndIndex = this.children.length; this.buildProgressBlock(); + this.buildLiveOutputBlock(); this.buildContent(); this.buildSubagentBlock(); } @@ -1345,6 +1382,24 @@ export class ToolCallComponent extends Container { } } + private buildLiveOutputBlock(): void { + if (this.result !== undefined) return; + if (this.liveOutput.length === 0) return; + this.addChild( + new ShellExecutionComponent({ + result: { + tool_call_id: this.toolCall.id, + output: this.liveOutput, + is_error: false, + }, + expanded: this.expanded, + resultPreviewLines: RESULT_PREVIEW_LINES, + tailOutput: true, + expandHint: false, + }), + ); + } + private buildSubagentBlock(): void { if ( this.subagentAgentId === undefined && @@ -1612,7 +1667,6 @@ export class ToolCallComponent extends Container { } private addSubToolOutputPreview(activity: SubToolActivity): void { - if (activity.phase === 'ongoing') return; const output = activity.output; if (output === undefined || output.trim().length === 0) return; // Mirror the main agent: Bash and any tool without a dedicated renderer @@ -1628,6 +1682,7 @@ export class ToolCallComponent extends Container { isError: activity.phase === 'failed', maxLines: RESULT_PREVIEW_LINES, indent: SUBAGENT_SUBTOOL_OUTPUT_INDENT, + tail: activity.phase === 'ongoing', }), ); } diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts index 1933bfd50..0c1019068 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts @@ -30,6 +30,7 @@ export class TruncatedOutputComponent implements Component { private readonly maxLines: number; private readonly indent: number; private readonly expandHint: boolean; + private readonly tail: boolean; constructor( output: string, @@ -41,12 +42,16 @@ export class TruncatedOutputComponent implements Component { // When false, the truncation footer omits the "ctrl+o to expand" promise // (for contexts whose output is fixed-truncated and never expands). expandHint?: boolean; + // When true, collapsed rendering keeps the latest visual rows instead of + // the first rows. This is useful for live output from a running command. + tail?: boolean; }, ) { this.expanded = options.expanded; this.maxLines = options.maxLines ?? PREVIEW_LINES; this.indent = options.indent ?? DEFAULT_INDENT; this.expandHint = options.expandHint ?? true; + this.tail = options.tail ?? false; const cleaned = trimTrailingEmptyLines(output.split('\n')).join('\n'); this.textComponent = new Text( options.isError ? currentTheme.fg('error', cleaned) : currentTheme.dim(cleaned), @@ -67,8 +72,16 @@ export class TruncatedOutputComponent implements Component { return contentLines; } - const shown = contentLines.slice(0, this.maxLines); const remaining = contentLines.length - this.maxLines; + if (this.tail) { + const shown = contentLines.slice(contentLines.length - this.maxLines); + return [ + ' '.repeat(this.indent) + currentTheme.dim(`... (${String(remaining)} earlier lines)`), + ...shown, + ]; + } + + const shown = contentLines.slice(0, this.maxLines); const hint = this.expandHint ? `... (${String(remaining)} more lines, ctrl+o to expand)` : `... (${String(remaining)} more lines)`; diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 09593c927..919568191 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -515,12 +515,17 @@ export class SessionEventHandler { } private handleToolProgress(event: ToolProgressEvent): void { - if (event.update.kind !== 'status') return; const text = event.update.text; if (text === undefined || text.length === 0) return; const tc = this.host.streamingUI.getToolComponent(event.toolCallId); if (tc === undefined) return; - tc.appendProgress(text); + if (event.update.kind === 'status') { + tc.appendProgress(text); + return; + } + if (event.update.kind === 'stdout' || event.update.kind === 'stderr') { + tc.appendLiveOutput(text); + } } private handleToolResult(event: ToolResultEvent): void { diff --git a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts index 1fb2d71c5..6d08330c5 100644 --- a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts @@ -107,6 +107,12 @@ export class SubAgentEventHandler { name: event.name, argumentsPart: event.argumentsPart ?? null, }); + } else if ( + event.type === 'tool.progress' && + (event.update.kind === 'stdout' || event.update.kind === 'stderr') && + event.update.text !== undefined + ) { + toolCall.appendSubToolLiveOutput(`${childAgentId}:${event.toolCallId}`, event.update.text); } else if (event.type === 'tool.result') { toolCall.finishSubToolCall({ tool_call_id: `${childAgentId}:${event.toolCallId}`, diff --git a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts index 576947f3a..6d9147030 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts @@ -78,6 +78,48 @@ describe('ToolCallComponent', () => { expect(expanded).not.toContain('ctrl+o to expand'); }); + it('renders live Bash output while the command is running', () => { + const component = new ToolCallComponent( + { + id: 'call_shell_live', + name: 'Bash', + args: { command: 'printf output' }, + }, + undefined, + ); + + component.appendLiveOutput('line1\n'); + component.appendLiveOutput('line2\n'); + + const out = strip(component.render(100).join('\n')); + expect(out).toContain('Using Bash'); + expect(out).toContain('line1'); + expect(out).toContain('line2'); + }); + + it('clears live Bash output when the final result arrives', () => { + const component = new ToolCallComponent( + { + id: 'call_shell_live_done', + name: 'Bash', + args: { command: 'printf output' }, + }, + undefined, + ); + + component.appendLiveOutput('streamed-only\n'); + component.setResult({ + tool_call_id: 'call_shell_live_done', + output: 'final-only\n', + is_error: false, + }); + + const out = strip(component.render(100).join('\n')); + expect(out).toContain('Used Bash'); + expect(out).toContain('final-only'); + expect(out).not.toContain('streamed-only'); + }); + it('hides tool output bodies that start with a { const reminderOutput = '\nThe task tools have not been used recently.\n'; diff --git a/docs/en/reference/tools.md b/docs/en/reference/tools.md index d2935ab03..497b4ea76 100644 --- a/docs/en/reference/tools.md +++ b/docs/en/reference/tools.md @@ -44,7 +44,7 @@ File tools handle reading, writing, and searching the local filesystem — the f - `description`: background task description; required when `run_in_background=true` - `disable_timeout`: whether to remove the timeout limit for background tasks -Foreground mode blocks the current turn until the command completes or times out; background mode returns a task ID immediately and automatically notifies the Agent when the task finishes. stdin is always closed — interactive commands receive EOF immediately. A two-phase termination strategy (SIGTERM → 5-second grace period → SIGKILL) ensures reliable process cleanup after a timeout. On Windows, Git Bash is used by default. +Foreground mode blocks the current turn until the command completes or times out, and the TUI streams stdout and stderr into the running `Bash` tool card while the command is still active. Background mode returns a task ID immediately and automatically notifies the Agent when the task finishes. stdin is always closed — interactive commands receive EOF immediately. A two-phase termination strategy (SIGTERM → 5-second grace period → SIGKILL) ensures reliable process cleanup after a timeout. On Windows, Git Bash is used by default. ## Web Tools diff --git a/docs/zh/reference/tools.md b/docs/zh/reference/tools.md index 74fd9dbb5..eef9fc7fe 100644 --- a/docs/zh/reference/tools.md +++ b/docs/zh/reference/tools.md @@ -44,7 +44,7 @@ - `description`:后台任务描述,`run_in_background=true` 时必填 - `disable_timeout`:后台任务是否取消超时限制 -前台模式会阻塞当前轮次,直到命令结束或超时;后台模式立即返回任务 ID,任务结束时自动通知 Agent。stdin 始终被关闭,交互式命令会立即收到 EOF。两阶段终止策略(SIGTERM → 5 秒宽限期 → SIGKILL)确保超时后进程可靠结束。Windows 平台默认使用 Git Bash。 +前台模式会阻塞当前轮次,直到命令结束或超时;命令运行期间,TUI 会把 stdout 和 stderr 流式显示在正在运行的 `Bash` 工具卡片中。后台模式立即返回任务 ID,任务结束时自动通知 Agent。stdin 始终被关闭,交互式命令会立即收到 EOF。两阶段终止策略(SIGTERM → 5 秒宽限期 → SIGKILL)确保超时后进程可靠结束。Windows 平台默认使用 Git Bash。 ## 网络类 diff --git a/packages/agent-core/src/tools/builtin/shell/bash.ts b/packages/agent-core/src/tools/builtin/shell/bash.ts index a176678e8..4b131446a 100644 --- a/packages/agent-core/src/tools/builtin/shell/bash.ts +++ b/packages/agent-core/src/tools/builtin/shell/bash.ts @@ -31,7 +31,7 @@ import { z } from 'zod'; import { ProcessBackgroundTask, type BackgroundManager } from '../../../agent/background'; import type { BuiltinTool } from '../../../agent/tool'; -import type { ExecutableToolResult, ToolExecution } from '../../../loop/types'; +import type { ExecutableToolResult, ToolExecution, ToolUpdate } from '../../../loop/types'; import { renderPrompt } from '../../../utils/render-prompt'; import { toInputJsonSchema } from '../../support/input-schema'; import { literalRulePattern, matchesGlobRuleSubject } from '../../support/rule-match'; @@ -181,7 +181,7 @@ export class BashTool implements BuiltinTool { }, approvalRule: literalRulePattern(this.name, args.command), matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.command), - execute: ({ signal }) => this.execution(args, signal), + execute: ({ signal, onUpdate }) => this.execution(args, signal, onUpdate), }; } @@ -212,7 +212,11 @@ export class BashTool implements BuiltinTool { return this.kaos.execWithEnv(shellArgs, mergedEnv); } - private async execution(args: BashInput, signal: AbortSignal): Promise { + private async execution( + args: BashInput, + signal: AbortSignal, + onUpdate?: ((update: ToolUpdate) => void) | undefined, + ): Promise { if (signal.aborted) { return { isError: true, output: 'Aborted before command started' }; } @@ -312,8 +316,8 @@ export class BashTool implements BuiltinTool { const isTerminating = (): boolean => timedOut || aborted || killed; const [, exitCode] = await Promise.all([ Promise.all([ - readStreamIntoBuilder(proc.stdout, builder, isTerminating), - readStreamIntoBuilder(proc.stderr, builder, isTerminating), + readStreamIntoBuilder(proc.stdout, builder, 'stdout', onUpdate, isTerminating), + readStreamIntoBuilder(proc.stderr, builder, 'stderr', onUpdate, isTerminating), ]), proc.wait(), ]); @@ -439,6 +443,8 @@ export class BashTool implements BuiltinTool { async function readStreamIntoBuilder( stream: Readable, builder: ToolResultBuilder, + kind: 'stdout' | 'stderr', + onUpdate?: ((update: ToolUpdate) => void) | undefined, suppressPrematureClose?: () => boolean, ): Promise { const decoder = new StringDecoder('utf8'); @@ -446,14 +452,18 @@ async function readStreamIntoBuilder( for await (const chunk of stream) { const buf: Buffer = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : (chunk as Buffer); - builder.write(decoder.write(buf)); + const text = decoder.write(buf); + if (text.length > 0) onUpdate?.({ kind, text }); + builder.write(text); } } catch (error) { if (!isPrematureCloseError(error) || suppressPrematureClose?.() !== true) { throw error; } } - builder.write(decoder.end()); + const trailing = decoder.end(); + if (trailing.length > 0) onUpdate?.({ kind, text: trailing }); + builder.write(trailing); } function isPrematureCloseError(error: unknown): boolean { diff --git a/packages/agent-core/test/agent/basic.test.ts b/packages/agent-core/test/agent/basic.test.ts index 6e947631e..1c9bfec61 100644 --- a/packages/agent-core/test/agent/basic.test.ts +++ b/packages/agent-core/test/agent/basic.test.ts @@ -119,6 +119,7 @@ it('runs an agent turn through builtin tool approval and execution', async () => [wire] permission.record_approval_result { "turnId": 0, "toolCallId": "call_bash", "toolName": "Bash", "action": "Running: printf lookup-result", "result": { "decision": "approved", "selectedLabel": "approve" }, "time": "