Skip to content

Commit 8451d59

Browse files
committed
feat: add Ctrl+O to view live process stdout in fullscreen overlay
- Add onProcessStdout callback chain through executor → bash-handler → session → App - Stream real-time stdout/stderr from bash commands to UI ref (capped at 1MB) - Add ProcessStdoutView fullscreen overlay with scroll support - Bind Ctrl+O in PromptInput to toggle the stdout view - Footer hint shows 'ctrl+o view output' when a process is running
1 parent 42ee6fe commit 8451d59

7 files changed

Lines changed: 321 additions & 658 deletions

File tree

package-lock.json

Lines changed: 146 additions & 654 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/session.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ type SessionManagerOptions = {
197197
onSessionEntryUpdated?: (entry: SessionEntry) => void;
198198
onLlmStreamProgress?: (progress: LlmStreamProgress) => void;
199199
onMcpStatusChanged?: () => void;
200+
onProcessStdout?: (pid: number, chunk: string) => void;
200201
};
201202

202203
export type LlmStreamProgress = {
@@ -220,6 +221,7 @@ export class SessionManager {
220221
private readonly onSessionEntryUpdated?: (entry: SessionEntry) => void;
221222
private readonly onLlmStreamProgress?: (progress: LlmStreamProgress) => void;
222223
private readonly onMcpStatusChanged?: () => void;
224+
private readonly onProcessStdout?: (pid: number, chunk: string) => void;
223225
private activeSessionId: string | null = null;
224226
private activePromptController: AbortController | null = null;
225227
private readonly sessionControllers = new Map<string, AbortController>();
@@ -235,6 +237,7 @@ export class SessionManager {
235237
this.onSessionEntryUpdated = options.onSessionEntryUpdated;
236238
this.onLlmStreamProgress = options.onLlmStreamProgress;
237239
this.onMcpStatusChanged = options.onMcpStatusChanged;
240+
this.onProcessStdout = options.onProcessStdout;
238241
this.toolExecutor = new ToolExecutor(this.projectRoot, this.createOpenAIClient, this.mcpManager);
239242
this.mcpManager.prepare(this.getResolvedSettings().mcpServers);
240243
}
@@ -1699,6 +1702,7 @@ ${skillMd}
16991702
const toolExecutions = await this.toolExecutor.executeToolCalls(sessionId, toolCalls, {
17001703
onProcessStart: (pid, command) => this.addSessionProcess(sessionId, pid, command),
17011704
onProcessExit: (pid) => this.removeSessionProcess(sessionId, pid),
1705+
onProcessStdout: (pid, chunk) => this.onProcessStdout?.(Number(pid), chunk),
17021706
shouldStop: () => this.isInterrupted(sessionId),
17031707
});
17041708
if (this.isInterrupted(sessionId)) {

src/tools/bash-handler.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,9 +124,13 @@ async function executeShellCommand(
124124

125125
child.stdout?.on("data", (chunk: string | Buffer) => {
126126
stdout = appendChunk(stdout, chunk);
127+
const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
128+
context.onProcessStdout?.(pid as number, text);
127129
});
128130
child.stderr?.on("data", (chunk: string | Buffer) => {
129131
stderr = appendChunk(stderr, chunk);
132+
const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
133+
context.onProcessStdout?.(pid as number, text);
130134
});
131135

132136
child.on("error", (spawnError) => {

src/tools/executor.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,13 @@ export type ToolExecutionContext = {
3737
createOpenAIClient?: CreateOpenAIClient;
3838
onProcessStart?: (processId: string | number, command: string) => void;
3939
onProcessExit?: (processId: string | number) => void;
40+
onProcessStdout?: (processId: string | number, chunk: string) => void;
4041
};
4142

4243
export type ToolExecutionHooks = {
4344
onProcessStart?: (processId: string | number, command: string) => void;
4445
onProcessExit?: (processId: string | number) => void;
46+
onProcessStdout?: (processId: string | number, chunk: string) => void;
4547
shouldStop?: () => boolean;
4648
};
4749

@@ -195,6 +197,7 @@ export class ToolExecutor {
195197
createOpenAIClient: this.createOpenAIClient,
196198
onProcessStart: hooks?.onProcessStart,
197199
onProcessExit: hooks?.onProcessExit,
200+
onProcessStdout: hooks?.onProcessStdout,
198201
});
199202
} catch (error) {
200203
const message = error instanceof Error ? error.message : String(error);

src/ui/App.tsx

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { findExpandedThinkingId } from "./thinkingState";
3030
import { WelcomeScreen } from "./WelcomeScreen";
3131
import { AskUserQuestionPrompt } from "./AskUserQuestionPrompt";
3232
import { McpStatusList } from "./McpStatusList";
33+
import { ProcessStdoutView } from "./ProcessStdoutView";
3334
import {
3435
findPendingAskUserQuestion,
3536
formatAskUserQuestionAnswers,
@@ -69,6 +70,8 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R
6970
const [resolvedSettings, setResolvedSettings] = useState(() => resolveCurrentSettings(projectRoot));
7071
const [nowTick, setNowTick] = useState(0);
7172
const [mcpStatuses, setMcpStatuses] = useState<ReturnType<typeof sessionManager.getMcpStatus>>([]);
73+
const [showProcessStdout, setShowProcessStdout] = useState(false);
74+
const processStdoutRef = useRef<Map<number, string>>(new Map());
7275

7376
const messagesRef = useRef<SessionMessage[]>([]);
7477
messagesRef.current = messages;
@@ -98,6 +101,20 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R
98101
// 当 MCP 状态变更时,如果当前正在查看 MCP 状态页面,则更新显示
99102
setMcpStatuses(sessionManager.getMcpStatus());
100103
},
104+
onProcessStdout: (pid, chunk) => {
105+
const buf = processStdoutRef.current;
106+
const current = buf.get(pid) ?? "";
107+
// Cap at 1 MB per process to avoid unbounded memory growth
108+
// on noisy or long-running commands like `yes` or verbose builds.
109+
const MAX_STDOUT_BUFFER = 1_000_000;
110+
if (current.length >= MAX_STDOUT_BUFFER) {
111+
return;
112+
}
113+
const text = typeof chunk === "string" ? chunk : String(chunk);
114+
const available = MAX_STDOUT_BUFFER - current.length;
115+
buf.set(pid, current + text.slice(0, available));
116+
},
117+
},
101118
});
102119
}, [projectRoot]);
103120

@@ -218,6 +235,8 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R
218235
setBusy(true);
219236
setErrorLine(null);
220237
setRunningProcesses(null);
238+
setShowProcessStdout(false);
239+
processStdoutRef.current.clear();
221240
try {
222241
await sessionManager.handleUserPrompt(prompt);
223242
await refreshSkills();
@@ -238,6 +257,14 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R
238257
sessionManager.interruptActiveSession();
239258
}, [sessionManager]);
240259

260+
const handleToggleProcessStdout = useCallback(() => {
261+
setShowProcessStdout(true);
262+
}, []);
263+
264+
const handleDismissProcessStdout = useCallback(() => {
265+
setShowProcessStdout(false);
266+
}, []);
267+
241268
const handleModelConfigChange = useCallback(
242269
(selection: ModelConfigSelection): string => {
243270
const current = resolveCurrentSettings(projectRoot);
@@ -442,7 +469,14 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R
442469
<Text color="red">Error: {errorLine}</Text>
443470
</Box>
444471
) : null}
445-
{view === "session-list" ? (
472+
{showProcessStdout ? (
473+
<ProcessStdoutView
474+
processStdoutRef={processStdoutRef}
475+
runningProcesses={runningProcesses}
476+
onDismiss={handleDismissProcessStdout}
477+
screenWidth={screenWidth}
478+
/>
479+
) : view === "session-list" ? (
446480
<SessionList
447481
sessions={sessions}
448482
onSelect={(id) => void handleSelectSession(id)}
@@ -464,9 +498,11 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R
464498
promptHistory={promptHistory}
465499
busy={busy}
466500
loadingText={loadingText}
501+
runningProcesses={runningProcesses}
467502
onSubmit={handleSubmit}
468503
onModelConfigChange={handleModelConfigChange}
469504
onInterrupt={handleInterrupt}
505+
onToggleProcessStdout={handleToggleProcessStdout}
470506
placeholder="Type your message..."
471507
/>
472508
)}

src/ui/ProcessStdoutView.tsx

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import React, { useEffect, useMemo, useRef, useState } from "react";
2+
import { Box, Text } from "ink";
3+
import type { SessionEntry } from "../session";
4+
import { useTerminalInput } from "./prompt";
5+
6+
type RunningProcesses = SessionEntry["processes"];
7+
8+
type ProcessStdoutViewProps = {
9+
processStdoutRef: React.MutableRefObject<Map<number, string>>;
10+
runningProcesses: RunningProcesses;
11+
onDismiss: () => void;
12+
screenWidth: number;
13+
};
14+
15+
const REFRESH_INTERVAL_MS = 150;
16+
const MAX_VISIBLE_LINES = 100;
17+
18+
export const ProcessStdoutView = React.memo(function ProcessStdoutView({
19+
processStdoutRef,
20+
runningProcesses,
21+
onDismiss,
22+
screenWidth,
23+
}: ProcessStdoutViewProps): React.ReactElement {
24+
const [stdoutText, setStdoutText] = useState("");
25+
const [scrollOffset, setScrollOffset] = useState(0);
26+
const containerRef = useRef<{ lineCount: number }>({ lineCount: 0 });
27+
28+
useEffect(() => {
29+
const updateStdout = () => {
30+
let text = "";
31+
if (runningProcesses && runningProcesses.size > 0) {
32+
for (const [pid, proc] of runningProcesses.entries()) {
33+
const pidNum = Number(pid);
34+
const stdout = processStdoutRef.current.get(pidNum) ?? "";
35+
if (text) {
36+
text += "\n";
37+
}
38+
if (runningProcesses.size > 1) {
39+
text += `── Process ${pid} [${proc.command}] ──\n`;
40+
}
41+
text += stdout || "(no output yet)";
42+
}
43+
} else {
44+
text = "(no running processes)";
45+
}
46+
setStdoutText(text);
47+
};
48+
49+
updateStdout();
50+
const interval = setInterval(updateStdout, REFRESH_INTERVAL_MS);
51+
return () => clearInterval(interval);
52+
}, [processStdoutRef, runningProcesses]);
53+
54+
// Update container line count for scroll awareness
55+
const lines = useMemo(() => stdoutText.split("\n"), [stdoutText]);
56+
containerRef.current.lineCount = lines.length;
57+
58+
const visibleLines = useMemo(() => {
59+
if (lines.length <= MAX_VISIBLE_LINES) {
60+
return lines;
61+
}
62+
const start = Math.max(0, lines.length - MAX_VISIBLE_LINES - scrollOffset);
63+
const slice = lines.slice(start, start + MAX_VISIBLE_LINES);
64+
if (lines.length > MAX_VISIBLE_LINES) {
65+
slice.unshift(`... (${start} lines above · ↑/↓ to scroll · ${lines.length} total lines) ...`);
66+
}
67+
return slice;
68+
}, [lines, scrollOffset]);
69+
70+
useTerminalInput(
71+
(input, key) => {
72+
if ((key.ctrl && (input === "o" || input === "O")) || key.escape) {
73+
onDismiss();
74+
return;
75+
}
76+
if (key.upArrow) {
77+
setScrollOffset((s) => Math.min(s + 10, Math.max(0, lines.length - MAX_VISIBLE_LINES)));
78+
return;
79+
}
80+
if (key.downArrow) {
81+
setScrollOffset((s) => Math.max(s - 10, 0));
82+
return;
83+
}
84+
if (key.pageUp) {
85+
setScrollOffset((s) => Math.min(s + MAX_VISIBLE_LINES, Math.max(0, lines.length - MAX_VISIBLE_LINES)));
86+
return;
87+
}
88+
if (key.pageDown) {
89+
setScrollOffset((s) => Math.max(s - MAX_VISIBLE_LINES, 0));
90+
return;
91+
}
92+
},
93+
{ isActive: true }
94+
);
95+
96+
return (
97+
<Box flexDirection="column" width={screenWidth} minWidth={80}>
98+
<Box borderStyle="single" borderBottom={true} borderLeft={false} borderRight={false} borderTop={false}>
99+
<Text bold>📟 Process Output</Text>
100+
<Text dimColor> (Ctrl+O or Esc to close · ↑↓ PageUp/PageDown to scroll)</Text>
101+
</Box>
102+
<Box flexDirection="column" paddingX={1}>
103+
{visibleLines.map((line, index) => (
104+
<Text key={`${index}`}>{line}</Text>
105+
))}
106+
</Box>
107+
</Box>
108+
);
109+
});

src/ui/PromptInput.tsx

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,11 @@ type Props = {
6060
loadingText?: string | null;
6161
disabled?: boolean;
6262
placeholder?: string;
63+
runningProcesses?: Map<string, { startTime: string; command: string }> | null;
6364
onSubmit: (submission: PromptSubmission) => void;
6465
onModelConfigChange: (selection: ModelConfigSelection) => string | Promise<string>;
6566
onInterrupt: () => void;
67+
onToggleProcessStdout?: () => void;
6668
};
6769

6870
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
@@ -109,9 +111,11 @@ export const PromptInput = React.memo(function PromptInput({
109111
loadingText,
110112
disabled,
111113
placeholder,
114+
runningProcesses,
112115
onSubmit,
113116
onModelConfigChange,
114117
onInterrupt,
118+
onToggleProcessStdout,
115119
}: Props): React.ReactElement {
116120
const { exit } = useApp();
117121
const { stdout } = useStdout();
@@ -141,13 +145,15 @@ export const PromptInput = React.memo(function PromptInput({
141145
);
142146
const showMenu = slashMenu.length > 0;
143147
const promptHistoryKey = React.useMemo(() => promptHistory.join("\0"), [promptHistory]);
148+
const hasRunningProcess = runningProcesses && runningProcesses.size > 0;
149+
const processHint = hasRunningProcess ? " · ctrl+o view output" : "";
144150
const footerText = statusMessage
145151
? statusMessage
146152
: busy
147153
? loadingText && loadingText.trim()
148-
? loadingText
149-
: "esc to interrupt · ctrl+c to cancel input"
150-
: "enter send · shift+enter newline · ctrl+v image · / commands · ctrl+d exit";
154+
? `${loadingText}${processHint}`
155+
: `esc to interrupt · ctrl+c to cancel input${processHint}`
156+
: `enter send · shift+enter newline · ctrl+v image · / commands · ctrl+d exit${processHint}`;
151157
useTerminalFocusReporting(stdout, !disabled);
152158
useTerminalExtendedKeys(stdout, !disabled);
153159
useHiddenTerminalCursor(stdout, !disabled);
@@ -223,6 +229,15 @@ export const PromptInput = React.memo(function PromptInput({
223229
return;
224230
}
225231

232+
if (key.ctrl && (input === "o" || input === "O")) {
233+
if (runningProcesses && runningProcesses.size > 0 && onToggleProcessStdout) {
234+
onToggleProcessStdout();
235+
} else {
236+
setStatusMessage("No running process to inspect");
237+
}
238+
return;
239+
}
240+
226241
if (key.ctrl && (input === "d" || input === "D")) {
227242
if (!isEmpty(buffer)) {
228243
updateBuffer((s) => deleteForward(s));

0 commit comments

Comments
 (0)