Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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<string, AbortController>();
Expand All @@ -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);
}
Expand Down Expand Up @@ -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)) {
Expand Down
43 changes: 43 additions & 0 deletions src/tests/tool-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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");
Expand Down Expand Up @@ -573,3 +605,14 @@ function createTempWorkspace(): string {
tempDirs.push(dir);
return dir;
}

async function waitFor(predicate: () => boolean, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (predicate()) {
return;
}
await delay(25);
}
assert.equal(predicate(), true);
}
4 changes: 4 additions & 0 deletions src/tools/bash-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
3 changes: 3 additions & 0 deletions src/tools/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down Expand Up @@ -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);
Expand Down
37 changes: 36 additions & 1 deletion src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<ReturnType<typeof sessionManager.getMcpStatus>>([]);
const [showProcessStdout, setShowProcessStdout] = useState(false);
const processStdoutRef = useRef<Map<number, string>>(new Map());

const messagesRef = useRef<SessionMessage[]>([]);
messagesRef.current = messages;
Expand Down Expand Up @@ -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]);

Expand Down Expand Up @@ -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();
Expand All @@ -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);
Expand Down Expand Up @@ -442,7 +468,14 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R
<Text color="red">Error: {errorLine}</Text>
</Box>
) : null}
{view === "session-list" ? (
{showProcessStdout ? (
<ProcessStdoutView
processStdoutRef={processStdoutRef}
runningProcesses={runningProcesses}
onDismiss={handleDismissProcessStdout}
screenWidth={screenWidth}
/>
) : view === "session-list" ? (
<SessionList
sessions={sessions}
onSelect={(id) => void handleSelectSession(id)}
Expand All @@ -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..."
/>
)}
Expand Down
109 changes: 109 additions & 0 deletions src/ui/ProcessStdoutView.tsx
Original file line number Diff line number Diff line change
@@ -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<Map<number, string>>;
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 (
<Box flexDirection="column" width={screenWidth} minWidth={80}>
<Box borderStyle="single" borderBottom={true} borderLeft={false} borderRight={false} borderTop={false}>
<Text bold>📟 Process Output</Text>
<Text dimColor> (Ctrl+O or Esc to close · ↑↓ PageUp/PageDown to scroll)</Text>
</Box>
<Box flexDirection="column" paddingX={1}>
{visibleLines.map((line, index) => (
<Text key={`${index}`}>{line}</Text>
))}
</Box>
</Box>
);
});
21 changes: 18 additions & 3 deletions src/ui/PromptInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,11 @@ type Props = {
loadingText?: string | null;
disabled?: boolean;
placeholder?: string;
runningProcesses?: Map<string, { startTime: string; command: string }> | null;
onSubmit: (submission: PromptSubmission) => void;
onModelConfigChange: (selection: ModelConfigSelection) => string | Promise<string>;
onInterrupt: () => void;
onToggleProcessStdout?: () => void;
};

const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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));
Expand Down
Loading