diff --git a/src/session.ts b/src/session.ts index 8c078f3..6da8835 100644 --- a/src/session.ts +++ b/src/session.ts @@ -197,6 +197,7 @@ type SessionManagerOptions = { onSessionEntryUpdated?: (entry: SessionEntry) => void; onLlmStreamProgress?: (progress: LlmStreamProgress) => void; onMcpStatusChanged?: () => void; + onProcessStdout?: (pid: number, chunk: string) => void; }; export type LlmStreamProgress = { @@ -220,6 +221,7 @@ export class SessionManager { private readonly onSessionEntryUpdated?: (entry: SessionEntry) => void; private readonly onLlmStreamProgress?: (progress: LlmStreamProgress) => void; private readonly onMcpStatusChanged?: () => void; + private readonly onProcessStdout?: (pid: number, chunk: string) => void; private activeSessionId: string | null = null; private activePromptController: AbortController | null = null; private readonly sessionControllers = new Map(); @@ -235,6 +237,7 @@ export class SessionManager { this.onSessionEntryUpdated = options.onSessionEntryUpdated; this.onLlmStreamProgress = options.onLlmStreamProgress; this.onMcpStatusChanged = options.onMcpStatusChanged; + this.onProcessStdout = options.onProcessStdout; this.toolExecutor = new ToolExecutor(this.projectRoot, this.createOpenAIClient, this.mcpManager); this.mcpManager.prepare(this.getResolvedSettings().mcpServers); } @@ -1699,6 +1702,7 @@ ${skillMd} const toolExecutions = await this.toolExecutor.executeToolCalls(sessionId, toolCalls, { onProcessStart: (pid, command) => this.addSessionProcess(sessionId, pid, command), onProcessExit: (pid) => this.removeSessionProcess(sessionId, pid), + onProcessStdout: (pid, chunk) => this.onProcessStdout?.(Number(pid), chunk), shouldStop: () => this.isInterrupted(sessionId), }); if (this.isInterrupted(sessionId)) { diff --git a/src/tests/tool-handlers.test.ts b/src/tests/tool-handlers.test.ts index 43af7ca..611d012 100644 --- a/src/tests/tool-handlers.test.ts +++ b/src/tests/tool-handlers.test.ts @@ -3,7 +3,9 @@ import assert from "node:assert/strict"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; +import { setTimeout as delay } from "node:timers/promises"; import type { ToolExecutionContext } from "../tools/executor"; +import { handleBashTool } from "../tools/bash-handler"; import { handleEditTool } from "../tools/edit-handler"; import { handleReadTool } from "../tools/read-handler"; import { handleWriteTool } from "../tools/write-handler"; @@ -19,6 +21,36 @@ afterEach(() => { } }); +test("Bash streams stdout and stderr before command completion", async () => { + const workspace = createTempWorkspace(); + const chunks: string[] = []; + let completed = false; + + const resultPromise = handleBashTool( + { + command: "printf 'first\\n'; sleep 1; printf 'second\\n'; printf 'err\\n' >&2", + }, + createContext("bash-live-output", workspace, { + onProcessStdout: (_pid, chunk) => { + chunks.push(chunk); + }, + }) + ).finally(() => { + completed = true; + }); + + await waitFor(() => chunks.join("").includes("first"), 1500); + + assert.equal(completed, false); + + const result = await resultPromise; + const streamedOutput = chunks.join(""); + assert.equal(result.ok, true); + assert.match(streamedOutput, /first/); + assert.match(streamedOutput, /second/); + assert.match(streamedOutput, /err/); +}); + test("Read returns snippet metadata and Edit can scope replacements by snippet_id", async () => { const workspace = createTempWorkspace(); const filePath = path.join(workspace, "sample.txt"); @@ -573,3 +605,14 @@ function createTempWorkspace(): string { tempDirs.push(dir); return dir; } + +async function waitFor(predicate: () => boolean, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) { + return; + } + await delay(25); + } + assert.equal(predicate(), true); +} diff --git a/src/tools/bash-handler.ts b/src/tools/bash-handler.ts index 95e7e76..071da53 100644 --- a/src/tools/bash-handler.ts +++ b/src/tools/bash-handler.ts @@ -124,9 +124,13 @@ async function executeShellCommand( child.stdout?.on("data", (chunk: string | Buffer) => { stdout = appendChunk(stdout, chunk); + const text = typeof chunk === "string" ? chunk : chunk.toString("utf8"); + context.onProcessStdout?.(pid as number, text); }); child.stderr?.on("data", (chunk: string | Buffer) => { stderr = appendChunk(stderr, chunk); + const text = typeof chunk === "string" ? chunk : chunk.toString("utf8"); + context.onProcessStdout?.(pid as number, text); }); child.on("error", (spawnError) => { diff --git a/src/tools/executor.ts b/src/tools/executor.ts index bc2d7d8..e6018d9 100644 --- a/src/tools/executor.ts +++ b/src/tools/executor.ts @@ -37,11 +37,13 @@ export type ToolExecutionContext = { createOpenAIClient?: CreateOpenAIClient; onProcessStart?: (processId: string | number, command: string) => void; onProcessExit?: (processId: string | number) => void; + onProcessStdout?: (processId: string | number, chunk: string) => void; }; export type ToolExecutionHooks = { onProcessStart?: (processId: string | number, command: string) => void; onProcessExit?: (processId: string | number) => void; + onProcessStdout?: (processId: string | number, chunk: string) => void; shouldStop?: () => boolean; }; @@ -195,6 +197,7 @@ export class ToolExecutor { createOpenAIClient: this.createOpenAIClient, onProcessStart: hooks?.onProcessStart, onProcessExit: hooks?.onProcessExit, + onProcessStdout: hooks?.onProcessStdout, }); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 8c5c375..c864187 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -30,6 +30,7 @@ import { findExpandedThinkingId } from "./thinkingState"; import { WelcomeScreen } from "./WelcomeScreen"; import { AskUserQuestionPrompt } from "./AskUserQuestionPrompt"; import { McpStatusList } from "./McpStatusList"; +import { ProcessStdoutView } from "./ProcessStdoutView"; import { findPendingAskUserQuestion, formatAskUserQuestionAnswers, @@ -69,6 +70,8 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R const [resolvedSettings, setResolvedSettings] = useState(() => resolveCurrentSettings(projectRoot)); const [nowTick, setNowTick] = useState(0); const [mcpStatuses, setMcpStatuses] = useState>([]); + const [showProcessStdout, setShowProcessStdout] = useState(false); + const processStdoutRef = useRef>(new Map()); const messagesRef = useRef([]); messagesRef.current = messages; @@ -98,6 +101,19 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R // 当 MCP 状态变更时,如果当前正在查看 MCP 状态页面,则更新显示 setMcpStatuses(sessionManager.getMcpStatus()); }, + onProcessStdout: (pid, chunk) => { + const buf = processStdoutRef.current; + const current = buf.get(pid) ?? ""; + // Cap at 1 MB per process to avoid unbounded memory growth + // on noisy or long-running commands like `yes` or verbose builds. + const MAX_STDOUT_BUFFER = 1_000_000; + if (current.length >= MAX_STDOUT_BUFFER) { + return; + } + const text = typeof chunk === "string" ? chunk : String(chunk); + const available = MAX_STDOUT_BUFFER - current.length; + buf.set(pid, current + text.slice(0, available)); + }, }); }, [projectRoot]); @@ -218,6 +234,8 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R setBusy(true); setErrorLine(null); setRunningProcesses(null); + setShowProcessStdout(false); + processStdoutRef.current.clear(); try { await sessionManager.handleUserPrompt(prompt); await refreshSkills(); @@ -238,6 +256,14 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R sessionManager.interruptActiveSession(); }, [sessionManager]); + const handleToggleProcessStdout = useCallback(() => { + setShowProcessStdout(true); + }, []); + + const handleDismissProcessStdout = useCallback(() => { + setShowProcessStdout(false); + }, []); + const handleModelConfigChange = useCallback( (selection: ModelConfigSelection): string => { const current = resolveCurrentSettings(projectRoot); @@ -442,7 +468,14 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R Error: {errorLine} ) : null} - {view === "session-list" ? ( + {showProcessStdout ? ( + + ) : view === "session-list" ? ( void handleSelectSession(id)} @@ -464,9 +497,11 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R promptHistory={promptHistory} busy={busy} loadingText={loadingText} + runningProcesses={runningProcesses} onSubmit={handleSubmit} onModelConfigChange={handleModelConfigChange} onInterrupt={handleInterrupt} + onToggleProcessStdout={handleToggleProcessStdout} placeholder="Type your message..." /> )} diff --git a/src/ui/ProcessStdoutView.tsx b/src/ui/ProcessStdoutView.tsx new file mode 100644 index 0000000..a0676c6 --- /dev/null +++ b/src/ui/ProcessStdoutView.tsx @@ -0,0 +1,109 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { Box, Text } from "ink"; +import type { SessionEntry } from "../session"; +import { useTerminalInput } from "./prompt"; + +type RunningProcesses = SessionEntry["processes"]; + +type ProcessStdoutViewProps = { + processStdoutRef: React.MutableRefObject>; + runningProcesses: RunningProcesses; + onDismiss: () => void; + screenWidth: number; +}; + +const REFRESH_INTERVAL_MS = 150; +const MAX_VISIBLE_LINES = 100; + +export const ProcessStdoutView = React.memo(function ProcessStdoutView({ + processStdoutRef, + runningProcesses, + onDismiss, + screenWidth, +}: ProcessStdoutViewProps): React.ReactElement { + const [stdoutText, setStdoutText] = useState(""); + const [scrollOffset, setScrollOffset] = useState(0); + const containerRef = useRef<{ lineCount: number }>({ lineCount: 0 }); + + useEffect(() => { + const updateStdout = () => { + let text = ""; + if (runningProcesses && runningProcesses.size > 0) { + for (const [pid, proc] of runningProcesses.entries()) { + const pidNum = Number(pid); + const stdout = processStdoutRef.current.get(pidNum) ?? ""; + if (text) { + text += "\n"; + } + if (runningProcesses.size > 1) { + text += `── Process ${pid} [${proc.command}] ──\n`; + } + text += stdout || "(no output yet)"; + } + } else { + text = "(no running processes)"; + } + setStdoutText(text); + }; + + updateStdout(); + const interval = setInterval(updateStdout, REFRESH_INTERVAL_MS); + return () => clearInterval(interval); + }, [processStdoutRef, runningProcesses]); + + // Update container line count for scroll awareness + const lines = useMemo(() => stdoutText.split("\n"), [stdoutText]); + containerRef.current.lineCount = lines.length; + + const visibleLines = useMemo(() => { + if (lines.length <= MAX_VISIBLE_LINES) { + return lines; + } + const start = Math.max(0, lines.length - MAX_VISIBLE_LINES - scrollOffset); + const slice = lines.slice(start, start + MAX_VISIBLE_LINES); + if (lines.length > MAX_VISIBLE_LINES) { + slice.unshift(`... (${start} lines above · ↑/↓ to scroll · ${lines.length} total lines) ...`); + } + return slice; + }, [lines, scrollOffset]); + + useTerminalInput( + (input, key) => { + if ((key.ctrl && (input === "o" || input === "O")) || key.escape) { + onDismiss(); + return; + } + if (key.upArrow) { + setScrollOffset((s) => Math.min(s + 10, Math.max(0, lines.length - MAX_VISIBLE_LINES))); + return; + } + if (key.downArrow) { + setScrollOffset((s) => Math.max(s - 10, 0)); + return; + } + if (key.pageUp) { + setScrollOffset((s) => Math.min(s + MAX_VISIBLE_LINES, Math.max(0, lines.length - MAX_VISIBLE_LINES))); + return; + } + if (key.pageDown) { + setScrollOffset((s) => Math.max(s - MAX_VISIBLE_LINES, 0)); + return; + } + }, + { isActive: true } + ); + + return ( + + + 📟 Process Output + (Ctrl+O or Esc to close · ↑↓ PageUp/PageDown to scroll) + + + {visibleLines.map((line, index) => ( + {line} + ))} + + + ); +}); diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index 3965144..db3a956 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -60,9 +60,11 @@ type Props = { loadingText?: string | null; disabled?: boolean; placeholder?: string; + runningProcesses?: Map | null; onSubmit: (submission: PromptSubmission) => void; onModelConfigChange: (selection: ModelConfigSelection) => string | Promise; onInterrupt: () => void; + onToggleProcessStdout?: () => void; }; const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; @@ -109,9 +111,11 @@ export const PromptInput = React.memo(function PromptInput({ loadingText, disabled, placeholder, + runningProcesses, onSubmit, onModelConfigChange, onInterrupt, + onToggleProcessStdout, }: Props): React.ReactElement { const { exit } = useApp(); const { stdout } = useStdout(); @@ -141,13 +145,15 @@ export const PromptInput = React.memo(function PromptInput({ ); const showMenu = slashMenu.length > 0; const promptHistoryKey = React.useMemo(() => promptHistory.join("\0"), [promptHistory]); + const hasRunningProcess = runningProcesses && runningProcesses.size > 0; + const processHint = hasRunningProcess ? " · ctrl+o view output" : ""; const footerText = statusMessage ? statusMessage : busy ? loadingText && loadingText.trim() - ? loadingText - : "esc to interrupt · ctrl+c to cancel input" - : "enter send · shift+enter newline · ctrl+v image · / commands · ctrl+d exit"; + ? `${loadingText}${processHint}` + : `esc to interrupt · ctrl+c to cancel input${processHint}` + : `enter send · shift+enter newline · ctrl+v image · / commands · ctrl+d exit${processHint}`; useTerminalFocusReporting(stdout, !disabled); useTerminalExtendedKeys(stdout, !disabled); useHiddenTerminalCursor(stdout, !disabled); @@ -223,6 +229,15 @@ export const PromptInput = React.memo(function PromptInput({ return; } + if (key.ctrl && (input === "o" || input === "O")) { + if (runningProcesses && runningProcesses.size > 0 && onToggleProcessStdout) { + onToggleProcessStdout(); + } else { + setStatusMessage("No running process to inspect"); + } + return; + } + if (key.ctrl && (input === "d" || input === "D")) { if (!isEmpty(buffer)) { updateBuffer((s) => deleteForward(s));