Skip to content

Commit 289f999

Browse files
committed
Merge branch 'streamed-output-chain-of-thought'
2 parents 008a15d + 83cd79c commit 289f999

15 files changed

Lines changed: 377 additions & 47 deletions

File tree

package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"react": "^19.2.5",
4343
"read-package-up": "^12.0.0",
4444
"sharp": "^0.35.3",
45+
"string-width": "^8.2.1",
4546
"yargs": "^18.0.0"
4647
},
4748
"devDependencies": {

packages/cli/src/tests/loading-text.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { test } from "node:test";
22
import assert from "node:assert/strict";
3+
import stringWidth from "string-width";
34
import { buildLoadingText } from "../ui";
45

56
test("buildLoadingText returns plain Thinking... when no progress", () => {
@@ -109,3 +110,60 @@ test("buildLoadingText falls back to Thinking... when timestamp is unparseable",
109110
});
110111
assert.equal(text, "Thinking...");
111112
});
113+
114+
const previewProgress = {
115+
requestId: "preview",
116+
startedAt: "2026-04-28T00:00:00.000Z",
117+
estimatedTokens: 1501,
118+
formattedTokens: "1.5k",
119+
phase: "update" as const,
120+
previewText: "latest text",
121+
};
122+
const previewNow = Date.parse(previewProgress.startedAt) + 5000;
123+
const previewStatus = "Thinking... (5s) · ↓ 1.5k tokens";
124+
125+
test("loading preview requires more than 1500 tokens and preserves status priority", () => {
126+
const input = { progress: previewProgress, now: previewNow, screenWidth: 100 };
127+
assert.equal(buildLoadingText(input), `${previewStatus} [latest text]`);
128+
assert.equal(buildLoadingText({ ...input, progress: { ...previewProgress, estimatedTokens: 1500 } }), previewStatus);
129+
assert.equal(buildLoadingText({ ...input, progress: { ...previewProgress, previewText: "" } }), previewStatus);
130+
assert.equal(buildLoadingText({ ...input, now: previewNow - 4000 }), "Thinking...");
131+
assert.equal(
132+
buildLoadingText({
133+
...input,
134+
processes: new Map([["p", { startTime: previewProgress.startedAt, command: "cmd" }]]),
135+
}),
136+
"(5s) cmd"
137+
);
138+
assert.equal(
139+
buildLoadingText({ ...input, retry: { requestId: "r", error: "err", attempt: 1, maxRetries: 5, delayMs: 800 } }),
140+
"Reconnecting... 1/5 (esc to interrupt)"
141+
);
142+
});
143+
144+
test("loading preview keeps the newest complete graphemes within the reserved boundary", () => {
145+
const previewText = "old ".repeat(100) + "中文👨‍👩‍👧‍👦é";
146+
for (const screenWidth of [35, 60, 70, 80, 100, 200]) {
147+
const text = buildLoadingText({ progress: { ...previewProgress, previewText }, now: previewNow, screenWidth });
148+
if (text !== previewStatus) {
149+
assert.ok(stringWidth(text) <= screenWidth - 28);
150+
assert.ok(text.startsWith(`${previewStatus} [...`));
151+
assert.ok(text.endsWith("é]"));
152+
const tail = text.slice(previewStatus.length + 5, -1);
153+
assert.ok(previewText.endsWith(tail));
154+
assert.ok(!tail.startsWith("\u200d"));
155+
}
156+
}
157+
assert.equal(buildLoadingText({ progress: previewProgress, now: previewNow, screenWidth: 40 }), previewStatus);
158+
});
159+
160+
test("loading preview hides below 80 columns and returns when the terminal grows", () => {
161+
const input = { progress: previewProgress, now: previewNow };
162+
for (const screenWidth of [40, 60, 70, 79, 0]) {
163+
assert.equal(buildLoadingText({ ...input, screenWidth }), previewStatus);
164+
}
165+
assert.equal(buildLoadingText(input), previewStatus);
166+
for (const screenWidth of [80, 100, 160]) {
167+
assert.equal(buildLoadingText({ ...input, screenWidth }), `${previewStatus} [latest text]`);
168+
}
169+
});
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import assert from "node:assert/strict";
2+
import { Writable } from "node:stream";
3+
import { test } 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 stringWidth from "string-width";
9+
import { StatusLine } from "../ui/components/status-line";
10+
11+
test("model status stays on one physical line while the spinner updates and the terminal shrinks", async () => {
12+
const frames: string[] = [];
13+
const output = Object.assign(
14+
new Writable({
15+
write(chunk, _encoding, callback) {
16+
const frame = stripVTControlCharacters(chunk.toString()).trimEnd();
17+
if (frame) frames.push(frame);
18+
callback();
19+
},
20+
}),
21+
{ columns: 120, rows: 24, isTTY: true }
22+
);
23+
const text = "status: processing · 81.1K/1M [▓░░░░░░░░░░░░░░░] 8% · deepseek-v4-flash max 中文👋";
24+
const app = render(React.createElement(StatusLine, { busy: true, text, width: output.columns }), {
25+
stdout: output as unknown as NodeJS.WriteStream,
26+
debug: true,
27+
patchConsole: false,
28+
exitOnCtrlC: false,
29+
});
30+
try {
31+
for (const width of [120, 79, 65, 40, 120]) {
32+
frames.length = 0;
33+
output.columns = width;
34+
output.emit("resize");
35+
app.rerender(React.createElement(StatusLine, { busy: true, text, width }));
36+
await app.waitUntilRenderFlush();
37+
// A resize can flush the previous props before React commits the new width.
38+
const resizedFrame = frames.at(-1);
39+
frames.length = 0;
40+
if (resizedFrame) frames.push(resizedFrame);
41+
await setTimeout(100);
42+
await app.waitUntilRenderFlush();
43+
assert.ok(frames.length > 0);
44+
for (const frame of frames) {
45+
assert.equal(frame.split("\n").length, 1, frame);
46+
assert.ok(stringWidth(frame) <= width, frame);
47+
}
48+
}
49+
} finally {
50+
app.unmount();
51+
app.cleanup();
52+
}
53+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import React, { useEffect, useState } from "react";
2+
import { Box, Text } from "ink";
3+
4+
const STATUS_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
5+
6+
export const StatusLine = React.memo(function StatusLine({
7+
busy,
8+
text,
9+
width,
10+
}: {
11+
busy: boolean;
12+
text?: string;
13+
width: number;
14+
}): React.ReactElement {
15+
const [spinnerIndex, setSpinnerIndex] = useState(0);
16+
17+
useEffect(() => {
18+
if (!busy) {
19+
setSpinnerIndex(0);
20+
return;
21+
}
22+
23+
const timer = setInterval(() => {
24+
setSpinnerIndex((index) => (index + 1) % STATUS_SPINNER_FRAMES.length);
25+
}, 80);
26+
return () => clearInterval(timer);
27+
}, [busy]);
28+
29+
return (
30+
<Box width={width} height={1} overflow="hidden">
31+
{busy ? (
32+
<Box marginRight={1} flexShrink={0}>
33+
<Text color="yellow">{STATUS_SPINNER_FRAMES[spinnerIndex]}</Text>
34+
</Box>
35+
) : null}
36+
{text ? (
37+
<Box flexGrow={1} flexShrink={1} minWidth={0}>
38+
<Text dimColor wrap="truncate-end">
39+
{text}
40+
</Text>
41+
</Box>
42+
) : null}
43+
</Box>
44+
);
45+
});

packages/cli/src/ui/core/loading-text.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { LlmRetryEvent, LlmStreamProgress, SessionEntry } from "@vegamo/deepcode-core";
2+
import stringWidth from "string-width";
23

34
type RunningProcesses = SessionEntry["processes"];
45

@@ -7,9 +8,12 @@ export type LoadingTextInput = {
78
retry?: LlmRetryEvent | null;
89
processes?: RunningProcesses;
910
now: number;
11+
screenWidth?: number;
1012
};
1113

1214
const STALL_THRESHOLD_MS = 3000;
15+
const MIN_PREVIEW_TERMINAL_WIDTH = 80;
16+
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
1317

1418
export function buildLoadingText(input: LoadingTextInput): string {
1519
const { progress, retry, processes, now } = input;
@@ -38,7 +42,27 @@ export function buildLoadingText(input: LoadingTextInput): string {
3842

3943
const elapsedSeconds = Math.floor(elapsedMs / 1000);
4044
const tokens = progress.formattedTokens || "0";
41-
return `Thinking... (${elapsedSeconds}s) · ↓ ${tokens} tokens`;
45+
const status = `Thinking... (${elapsedSeconds}s) · ↓ ${tokens} tokens`;
46+
const preview = progress.previewText;
47+
if (progress.estimatedTokens <= 1500 || !preview || (input.screenWidth ?? 0) < MIN_PREVIEW_TERMINAL_WIDTH) {
48+
return status;
49+
}
50+
const available = (input.screenWidth ?? 0) - 28 - stringWidth(status) - 3; // Space and brackets.
51+
if (available <= 0) {
52+
return status;
53+
}
54+
if (stringWidth(preview) <= available) {
55+
return `${status} [${preview}]`;
56+
}
57+
let tail = "";
58+
let width = 3; // Leading ellipsis.
59+
const graphemes = Array.from(segmenter.segment(preview), (part) => part.segment);
60+
for (let i = graphemes.length - 1; i >= 0; i--) {
61+
width += stringWidth(graphemes[i]!);
62+
if (width > available) break;
63+
tail = graphemes[i] + tail;
64+
}
65+
return tail ? `${status} [...${tail}]` : status;
4266
}
4367

4468
function buildProcessLoadingText(processes: RunningProcesses | undefined, now: number): string | null {

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

Lines changed: 5 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { type PromptDraft, PromptInput, type PromptSubmission } from "./PromptIn
88
import { MessageView, RawModeExitPrompt } from "../components";
99
import { SessionList } from "./SessionList";
1010
import { type UndoRestoreMode, UndoSelector } from "./UndoSelector";
11+
import { StatusLine } from "../components/status-line";
1112
import { buildLoadingText } from "../core/loading-text";
1213
import { findExpandedThinkingId } from "../core/thinking-state";
1314
import { WelcomeScreen } from "./WelcomeScreen";
@@ -60,8 +61,6 @@ import { writeStdout, writeStdoutLine } from "../../utils/stdio-helpers";
6061

6162
type View = "chat" | "session-list" | "undo" | "mcp-status";
6263

63-
const STATUS_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
64-
6564
type AppProps = {
6665
projectRoot: string;
6766
initialPrompt?: string;
@@ -70,39 +69,6 @@ type AppProps = {
7069
onRestart?: () => void;
7170
};
7271

73-
const StatusLine = React.memo(function StatusLine({
74-
busy,
75-
text,
76-
}: {
77-
busy: boolean;
78-
text?: string;
79-
}): React.ReactElement {
80-
const [spinnerIndex, setSpinnerIndex] = useState(0);
81-
82-
useEffect(() => {
83-
if (!busy) {
84-
setSpinnerIndex(0);
85-
return;
86-
}
87-
88-
const timer = setInterval(() => {
89-
setSpinnerIndex((index) => (index + 1) % STATUS_SPINNER_FRAMES.length);
90-
}, 80);
91-
return () => clearInterval(timer);
92-
}, [busy]);
93-
94-
return (
95-
<Box>
96-
{busy ? (
97-
<Box marginRight={1}>
98-
<Text color="yellow">{STATUS_SPINNER_FRAMES[spinnerIndex]}</Text>
99-
</Box>
100-
) : null}
101-
{text ? <Text dimColor>{text}</Text> : null}
102-
</Box>
103-
);
104-
});
105-
10672
function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRestart }: AppProps): React.ReactElement {
10773
const { exit } = useApp();
10874
const { stdout, write } = useStdout();
@@ -876,11 +842,12 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes
876842
progress: streamProgress,
877843
retry: retryEvent,
878844
processes: runningProcesses,
845+
screenWidth,
879846
now: Date.now(),
880847
})
881848
: null,
882849
// eslint-disable-next-line react-hooks/exhaustive-deps -- nowTick forces periodic recalculation for spinner animation
883-
[busy, streamProgress, retryEvent, runningProcesses, nowTick]
850+
[busy, streamProgress, retryEvent, runningProcesses, nowTick, screenWidth]
884851
);
885852

886853
const welcomeItem: SessionMessage = useMemo(
@@ -996,7 +963,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes
996963
}
997964

998965
return (
999-
<Box flexDirection="column" width={screenWidth} minWidth={80} overflowX={"visible"}>
966+
<Box flexDirection="column" width={screenWidth}>
1000967
<Static items={staticItems}>
1001968
{(item) => {
1002969
if (item.id.startsWith("__welcome__")) {
@@ -1020,7 +987,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes
1020987
);
1021988
}}
1022989
</Static>
1023-
{(busy || statusLine) && !isExiting ? <StatusLine busy={busy} text={statusLine} /> : null}
990+
{(busy || statusLine) && !isExiting ? <StatusLine busy={busy} text={statusLine} width={screenWidth} /> : null}
1024991
{errorLine ? (
1025992
<Box>
1026993
<Text color="red">Error: {errorLine}</Text>

packages/cli/src/ui/views/PromptInput.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -871,7 +871,9 @@ export const PromptInput = React.memo(function PromptInput({
871871
<SlashCommandMenu width={screenWidth} items={slashMenu} activeIndex={menuIndex} />
872872
{!showFooterText && (
873873
<Box>
874-
<Text dimColor>{footerText}</Text>
874+
<Text dimColor wrap="truncate-end">
875+
{footerText}
876+
</Text>
875877
</Box>
876878
)}
877879
{statusLineSegments && statusLineSegments.length > 0 && (

0 commit comments

Comments
 (0)