Skip to content

Commit 9e2e08f

Browse files
committed
Merge branch 'improve-process-output'
2 parents 733e38b + e6a492c commit 9e2e08f

7 files changed

Lines changed: 421 additions & 116 deletions

File tree

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import assert from "node:assert/strict";
2+
import { PassThrough, Writable } from "node:stream";
3+
import { test, type TestContext } from "node:test";
4+
import { setTimeout } from "node:timers/promises";
5+
import { stripVTControlCharacters } from "node:util";
6+
import React from "react";
7+
import { render } from "ink";
8+
import type { SessionMessage } from "@vegamo/deepcode-core";
9+
import { buildProcessOutputItems, type ProcessOutputItem } from "../ui/core/process-output";
10+
import { ProcessStdoutView } from "../ui/views/ProcessStdoutView";
11+
import { PromptInput } from "../ui/views/PromptInput";
12+
13+
function toolMessage(id: string, command: string, output: string, exitCode: number): SessionMessage {
14+
return {
15+
id,
16+
sessionId: "session",
17+
role: "tool",
18+
visible: exitCode === 0,
19+
compacted: false,
20+
createTime: "2026-01-01T00:00:00Z",
21+
updateTime: "2026-01-01T00:00:00Z",
22+
contentParams: null,
23+
messageParams: { tool_call_id: id },
24+
meta: { function: { name: "bash", arguments: JSON.stringify({ command }) } },
25+
content: JSON.stringify({ output, metadata: { exitCode } }),
26+
};
27+
}
28+
29+
test("history retains repeated commands and failed outputs, and merges a running background call", () => {
30+
const first = toolMessage("first", "npm test", "passed", 0);
31+
const failed = toolMessage("second", "npm test", "failed", 1);
32+
const background = toolMessage("background", "npm start", "started", 0);
33+
const items = buildProcessOutputItems(
34+
[first, failed, background],
35+
new Map([
36+
[
37+
"123",
38+
{
39+
command: "npm start",
40+
startTime: first.createTime,
41+
toolCallId: "background",
42+
runInBackground: true,
43+
},
44+
],
45+
])
46+
);
47+
assert.deepEqual(
48+
items.map((item) => [item.id, item.output, item.exitCode, item.running]),
49+
[
50+
["first", "passed", 0, false],
51+
["second", "failed", 1, false],
52+
["background", "", null, true],
53+
]
54+
);
55+
assert.equal(items[2]?.background, true);
56+
assert.deepEqual(buildProcessOutputItems([{ ...first, content: "invalid json" }], null), []);
57+
});
58+
59+
async function terminal(t: TestContext, element: React.ReactElement) {
60+
let frame = "";
61+
const stdin = Object.assign(new PassThrough(), { isTTY: true, setRawMode() {}, ref() {}, unref() {} });
62+
const stdout = Object.assign(
63+
new Writable({
64+
write(chunk, _encoding, done) {
65+
const text = stripVTControlCharacters(chunk.toString());
66+
if (text.trim()) frame = text;
67+
done();
68+
},
69+
}),
70+
{ isTTY: true, columns: 120, rows: 24 }
71+
);
72+
const app = render(element, {
73+
stdin: stdin as unknown as NodeJS.ReadStream,
74+
stdout: stdout as unknown as NodeJS.WriteStream,
75+
stderr: stdout as unknown as NodeJS.WriteStream,
76+
debug: true,
77+
patchConsole: false,
78+
exitOnCtrlC: false,
79+
});
80+
t.after(() => {
81+
app.unmount();
82+
app.cleanup();
83+
});
84+
async function flush() {
85+
await setTimeout(30);
86+
await app.waitUntilRenderFlush();
87+
}
88+
await flush();
89+
return {
90+
frame: () => frame,
91+
async send(input: string) {
92+
stdin.emit("data", input);
93+
await flush();
94+
},
95+
async rerender(next: React.ReactElement) {
96+
app.rerender(next);
97+
await flush();
98+
},
99+
};
100+
}
101+
102+
test("panel retains the selected result, navigates history and targets timeout adjustments", async (t) => {
103+
const old = buildProcessOutputItems([toolMessage("old", "old command", "old output", 2)], null)[0]!;
104+
const running: ProcessOutputItem = {
105+
id: "live",
106+
command: "npm test",
107+
pid: "123",
108+
background: false,
109+
running: true,
110+
output: "",
111+
exitCode: null,
112+
timeoutMs: 60000,
113+
};
114+
const adjustments: string[] = [];
115+
let dismissed = 0;
116+
const props = {
117+
processStdoutRef: { current: new Map([[123, Array.from({ length: 40 }, (_, i) => `line ${i}`).join("\n")]]) },
118+
onDismiss: () => {
119+
dismissed++;
120+
},
121+
onAdjustTimeout: (_delta: number, pid: string) => {
122+
adjustments.push(pid);
123+
return null;
124+
},
125+
screenWidth: 120,
126+
screenHeight: 15,
127+
};
128+
const view = (items: ProcessOutputItem[]) => React.createElement(ProcessStdoutView, { ...props, items });
129+
const h = await terminal(t, view([old, running]));
130+
assert.match(h.frame(), /── Process 123 \[npm test\] ──/);
131+
assert.match(h.frame(), /line 39/);
132+
await h.send("+");
133+
assert.deepEqual(adjustments, ["123"]);
134+
await h.send("\u001b[A");
135+
assert.doesNotMatch(h.frame(), /line 39/);
136+
await h.send("\u001b[D");
137+
assert.match(h.frame(), /── \(return 2\) \[old command\] ──/);
138+
await h.send("+");
139+
assert.deepEqual(adjustments, ["123"]);
140+
await h.send("\u001b[D");
141+
assert.match(h.frame(), /old output/);
142+
await h.send("\u001b[C");
143+
assert.match(h.frame(), /line 39/);
144+
145+
const completed = { ...running, running: false, output: "finished", exitCode: 0 };
146+
const next = { ...running, id: "next", pid: "456", command: "next command", background: true };
147+
await h.rerender(view([old, completed, next]));
148+
assert.match(h.frame(), /── \(return 0\) \[npm test\] ──/);
149+
assert.match(h.frame(), /finished/);
150+
await h.send("\u001b[C");
151+
assert.match(h.frame(), /── Process 456 \(background\) \[next command\] ──/);
152+
await h.send("+");
153+
assert.deepEqual(adjustments, ["123"]);
154+
await h.send("\u000f");
155+
assert.equal(dismissed, 1);
156+
});
157+
158+
test("Ctrl+O opens output for an empty prompt and expands pasted text in a nonempty prompt", async (t) => {
159+
let opened = 0;
160+
const h = await terminal(
161+
t,
162+
React.createElement(PromptInput, {
163+
projectRoot: process.cwd(),
164+
skills: [],
165+
modelConfig: { model: "test", thinkingEnabled: false, reasoningEffort: "high" },
166+
screenWidth: 120,
167+
promptHistory: [],
168+
busy: false,
169+
planMode: false,
170+
onSubmit() {},
171+
onPromptDraftApplied() {},
172+
onModelConfigChange: () => "",
173+
onPlanModeChange() {},
174+
onInterrupt() {},
175+
onToggleProcessStdout: () => {
176+
opened++;
177+
},
178+
})
179+
);
180+
await h.send("\u000f");
181+
assert.equal(opened, 1);
182+
await h.send(" ");
183+
await h.send("\u000f");
184+
assert.equal(opened, 1);
185+
await h.send("\u007f");
186+
await h.send("\u001b[200~" + Array.from({ length: 20 }, (_, i) => `pasted line ${i}`).join("\n") + "\u001b[201~");
187+
assert.match(h.frame(), /\[paste #1 \+20 lines\]/);
188+
await h.send("\u000f");
189+
assert.equal(opened, 1);
190+
assert.match(h.frame(), /pasted line 0/);
191+
assert.doesNotMatch(h.frame(), /\[paste #1/);
192+
await h.send("\u000f");
193+
assert.match(h.frame(), /\[paste #1 \+20 lines\]/);
194+
});
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import type { SessionEntry, SessionMessage } from "@vegamo/deepcode-core";
2+
3+
export type ProcessOutputItem = {
4+
id: string;
5+
command: string;
6+
pid?: string;
7+
background: boolean;
8+
running: boolean;
9+
output: string;
10+
exitCode: number | null;
11+
timeoutMs?: number;
12+
};
13+
14+
export function buildProcessOutputItems(
15+
messages: SessionMessage[],
16+
runningProcesses: SessionEntry["processes"]
17+
): ProcessOutputItem[] {
18+
const items = new Map<string, ProcessOutputItem>();
19+
for (const message of messages) {
20+
if (message.role !== "tool") continue;
21+
const fn = message.meta?.function as { name?: string; arguments?: string } | undefined;
22+
if (fn?.name !== "bash" && fn?.name !== "Bash") continue;
23+
try {
24+
const args = JSON.parse(fn.arguments ?? "{}");
25+
const result = JSON.parse(message.content ?? "{}");
26+
if (typeof args.command !== "string") continue;
27+
const id = (message.messageParams as { tool_call_id?: string })?.tool_call_id ?? message.id;
28+
items.set(id, {
29+
id,
30+
command: args.command,
31+
pid: result.metadata?.processId == null ? undefined : String(result.metadata.processId),
32+
background: args.run_in_background === true || args.run_in_background === "true",
33+
running: false,
34+
output: result.output || result.error || "",
35+
exitCode: result.metadata?.exitCode ?? null,
36+
});
37+
} catch {
38+
// Older or incomplete tool messages may not have a readable result.
39+
}
40+
}
41+
for (const [pid, process] of runningProcesses ?? []) {
42+
const id = process.toolCallId ?? [...items.values()].find((item) => item.pid === pid)?.id ?? `process:${pid}`;
43+
items.set(id, {
44+
id,
45+
command: process.command,
46+
pid,
47+
background: process.runInBackground ?? items.get(id)?.background ?? false,
48+
running: true,
49+
output: "",
50+
exitCode: null,
51+
timeoutMs: process.timeoutMs,
52+
});
53+
}
54+
return [...items.values()];
55+
}
56+
57+
export function formatProcessOutputTitle(item: ProcessOutputItem): string {
58+
const command = item.command.replace(/[\r\n]+/g, " ");
59+
return item.running
60+
? `── Process ${item.pid} ${item.background ? "(background) " : ""}[${command}] ──`
61+
: `── (return ${item.exitCode ?? "unknown"}) [${command}] ──`;
62+
}
63+
64+
export function moveProcessOutputSelection(
65+
items: ProcessOutputItem[],
66+
id: string | null,
67+
direction: -1 | 1
68+
): string | null {
69+
const index = items.findIndex((item) => item.id === id);
70+
return items[Math.max(0, Math.min(items.length - 1, (index < 0 ? items.length - 1 : index) + direction))]?.id ?? null;
71+
}

‎packages/cli/src/ui/views/App.tsx‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { findExpandedThinkingId } from "../core/thinking-state";
1414
import { WelcomeScreen } from "./WelcomeScreen";
1515
import { AskUserQuestionPrompt } from "./AskUserQuestionPrompt";
1616
import { McpStatusList } from "./McpStatusList";
17+
import { buildProcessOutputItems } from "../core/process-output";
1718
import { ProcessStdoutView } from "./ProcessStdoutView";
1819
import {
1920
type AskUserQuestionAnswers,
@@ -179,6 +180,17 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes
179180
}, [projectRoot, clientFactory]);
180181

181182
const activeSessionId = sessionManager.getActiveSessionId();
183+
useEffect(() => {
184+
processStdoutRef.current.clear();
185+
}, [activeSessionId]);
186+
const processOutputItems = useMemo(
187+
() =>
188+
buildProcessOutputItems(
189+
showProcessStdout && activeSessionId ? sessionManager.listSessionMessages(activeSessionId) : messages,
190+
runningProcesses
191+
),
192+
[showProcessStdout, activeSessionId, sessionManager, messages, runningProcesses]
193+
);
182194
useEffect(() => {
183195
const entry = activeSessionId ? sessionManager.getSession(activeSessionId) : null;
184196
terminalTitle.set(entry?.summary || "Deep Code", terminalTitleState(entry?.status));
@@ -551,7 +563,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes
551563
}, []);
552564

553565
const handleAdjustBashTimeout = useCallback(
554-
(deltaMs: number) => sessionManager.adjustActiveBashTimeout(deltaMs),
566+
(deltaMs: number, processId: string) => sessionManager.adjustActiveBashTimeout(deltaMs, processId),
555567
[sessionManager]
556568
);
557569

@@ -1086,8 +1098,9 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes
10861098
) : null}
10871099
{showProcessStdout ? (
10881100
<ProcessStdoutView
1101+
key={activeSessionId}
1102+
items={processOutputItems}
10891103
processStdoutRef={processStdoutRef}
1090-
runningProcesses={runningProcesses}
10911104
onDismiss={handleDismissProcessStdout}
10921105
onAdjustTimeout={handleAdjustBashTimeout}
10931106
screenWidth={screenWidth}
@@ -1156,7 +1169,6 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes
11561169
busy={busy}
11571170
cursorLayoutKey={promptCursorLayoutKey}
11581171
loadingText={loadingText}
1159-
runningProcesses={runningProcesses}
11601172
promptDraft={promptDraft}
11611173
onPromptDraftApplied={handlePromptDraftApplied}
11621174
onSubmit={handleSubmit}

0 commit comments

Comments
 (0)