Skip to content

Commit 57dceec

Browse files
committed
refactor: remove printMode in favor of Ink-only streaming
Print Mode (direct stdout.write) conflicts with Ink's terminal control and provides no real benefit over Ink Mode's real-time Markdown rendering. A proper non-interactive mode (like Claude Code's -p flag) would bypass Ink entirely rather than mixing stdout.write within the React tree. Removed: printMode prop, stdout.write branches, printMode state.
1 parent 21b634a commit 57dceec

4 files changed

Lines changed: 36 additions & 53 deletions

File tree

src/agent/__tests__/agent-streaming-progress.test.ts

Lines changed: 30 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import { describe, expect, test } from "bun:test";
2+
import { z } from "zod";
3+
4+
import type { AssistantMessage } from "@/foundation";
5+
import { Model } from "@/foundation/models/model";
6+
import type { ModelProvider, ModelProviderInvokeParams } from "@/foundation/models/model-provider";
7+
28
import { Agent } from "../agent";
39
import type { AgentProgressThinkingEvent } from "../agent-event";
4-
import { Model } from "@/foundation/models/model";
5-
import type {
6-
ModelProvider,
7-
ModelProviderInvokeParams,
8-
} from "@/foundation/models/model-provider";
9-
import type { AssistantMessage } from "@/foundation";
10-
import { z } from "zod";
1110

1211
function createTextStreamingProvider(): ModelProvider {
1312
const finalMessage: AssistantMessage = {
@@ -16,7 +15,9 @@ function createTextStreamingProvider(): ModelProvider {
1615
};
1716

1817
return {
18+
// eslint-disable-next-line no-unused-vars
1919
invoke: async (_params: ModelProviderInvokeParams) => finalMessage,
20+
// eslint-disable-next-line no-unused-vars
2021
async *stream(_params: ModelProviderInvokeParams) {
2122
const snapshots: AssistantMessage[] = [
2223
{
@@ -63,7 +64,9 @@ function createToolStreamingProvider(): ModelProvider {
6364
};
6465

6566
return {
67+
// eslint-disable-next-line no-unused-vars
6668
invoke: async (_params: ModelProviderInvokeParams) => toolMessage,
69+
// eslint-disable-next-line no-unused-vars
6770
async *stream(_params: ModelProviderInvokeParams) {
6871
callCount++;
6972
if (callCount === 1) {
@@ -82,7 +85,6 @@ function createToolStreamingProvider(): ModelProvider {
8285
};
8386
yield toolMessage;
8487
} else {
85-
// Second call: return a text-only message to end the loop
8688
yield doneMessage;
8789
}
8890
},
@@ -95,28 +97,26 @@ describe("Agent streaming progress events", () => {
9597
const model = new Model("test-model", provider);
9698
const agent = new Agent({ model, prompt: "You are a test assistant.", tools: [] });
9799

98-
const events: any[] = [];
100+
const events: AgentProgressThinkingEvent[] = [];
99101
for await (const event of agent.stream({
100102
role: "user",
101103
content: [{ type: "text", text: "Hi" }],
102104
})) {
103-
events.push(event);
105+
if (event.type === "progress" && event.subtype === "thinking") {
106+
events.push(event);
107+
}
104108
}
105109

106-
const thinkingEvents = events.filter(
107-
(e) => e.type === "progress" && e.subtype === "thinking",
108-
) as AgentProgressThinkingEvent[];
110+
expect(events.length).toBe(2);
109111

110-
expect(thinkingEvents.length).toBe(2);
111-
112-
expect(thinkingEvents[0]).toMatchObject({
112+
expect(events[0]).toMatchObject({
113113
type: "progress",
114114
subtype: "thinking",
115115
text: "Hello",
116116
delta: "Hello",
117117
});
118118

119-
expect(thinkingEvents[1]).toMatchObject({
119+
expect(events[1]).toMatchObject({
120120
type: "progress",
121121
subtype: "thinking",
122122
text: "Hello, world",
@@ -129,23 +129,24 @@ describe("Agent streaming progress events", () => {
129129
const model = new Model("test-model", provider);
130130
const agent = new Agent({ model, prompt: "You are a test assistant.", tools: [] });
131131

132-
const events: any[] = [];
132+
let finalMessage: AssistantMessage | null = null;
133133
for await (const event of agent.stream({
134134
role: "user",
135135
content: [{ type: "text", text: "Hi" }],
136136
})) {
137-
events.push(event);
137+
if (event.type === "message" && event.message.role === "assistant") {
138+
finalMessage = event.message as AssistantMessage;
139+
}
138140
}
139141

140-
const messageEvent = events.find((e) => e.type === "message");
141-
expect(messageEvent).toBeDefined();
142-
expect(messageEvent.message.role).toBe("assistant");
142+
expect(finalMessage).toBeDefined();
143+
expect(finalMessage!.role).toBe("assistant");
143144

144-
const textBlock = messageEvent.message.content.find(
145-
(block: any) => block.type === "text",
145+
const textBlock = finalMessage!.content.find(
146+
(block) => block.type === "text",
146147
);
147148
expect(textBlock).toBeDefined();
148-
expect(textBlock.text).toBe("Hello, world!");
149+
expect((textBlock as { text: string }).text).toBe("Hello, world!");
149150
});
150151

151152
test("yields tool progress events without text fields", async () => {
@@ -161,18 +162,16 @@ describe("Agent streaming progress events", () => {
161162

162163
const agent = new Agent({ model, prompt: "You are a test assistant.", tools: [bashTool] });
163164

164-
const events: any[] = [];
165+
const toolProgressEvents: { name?: string; text?: string; delta?: string }[] = [];
165166
for await (const event of agent.stream({
166167
role: "user",
167168
content: [{ type: "text", text: "Hi" }],
168169
})) {
169-
events.push(event);
170+
if (event.type === "progress" && event.subtype === "tool") {
171+
toolProgressEvents.push(event as unknown as { name?: string; text?: string; delta?: string });
172+
}
170173
}
171174

172-
const toolProgressEvents = events.filter(
173-
(e) => e.type === "progress" && e.subtype === "tool",
174-
);
175-
176175
expect(toolProgressEvents.length).toBeGreaterThanOrEqual(1);
177176

178177
for (const toolEvent of toolProgressEvents) {

src/cli/tui/app.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export function App({
3030
commands: SlashCommand[];
3131
supportProjectWideAllow?: boolean;
3232
}) {
33-
const { streaming, messages, streamingText, printMode, onSubmit, abort } = useAgentLoop();
33+
const { streaming, messages, streamingText, onSubmit, abort } = useAgentLoop();
3434
const { approvalRequest, respondToApproval } = useApprovalManager();
3535
const { askUserQuestionRequest, respondWithAnswers } = useAskUserQuestionManager();
3636
const { latestTodos, todoSnapshots } = useMemo(() => buildTodoViewState(messages), [messages]);
@@ -47,7 +47,7 @@ export function App({
4747
useFlushToScrollback(messages, flushedRef, write);
4848

4949
// Show streaming text in Ink mode (not print mode)
50-
const showStreamingText = streaming && streamingText && !printMode;
50+
const showStreamingText = streaming && !!streamingText;
5151
// Only show the shimmer indicator when there is no streaming text to display
5252
const showShimmer = streaming && !streamingText && !approvalRequest && !askUserQuestionRequest;
5353

src/cli/tui/components/streaming-message.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Box, Text } from "ink";
2-
import { memo, useMemo } from "react";
32
import { marked } from "marked";
43
import TerminalRenderer from "marked-terminal";
4+
import { memo, useMemo } from "react";
55

66
import { currentTheme } from "../themes";
77

src/cli/tui/hooks/use-agent-loop.ts

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ type AgentLoopState = {
1212
streaming: boolean;
1313
messages: NonSystemMessage[];
1414
streamingText: string;
15-
printMode: boolean;
1615
// eslint-disable-next-line no-unused-vars
1716
onSubmit: (submission: PromptSubmission) => Promise<void>;
1817
abort: () => void;
@@ -24,12 +23,10 @@ const AgentLoopContext = createContext<AgentLoopState | null>(null);
2423
export function AgentLoopProvider({
2524
agent,
2625
commands = [],
27-
printMode = false,
2826
children,
2927
}: {
3028
agent: Agent;
3129
commands?: SlashCommand[];
32-
printMode?: boolean;
3330
children: ReactNode;
3431
}) {
3532
const [streaming, setStreaming] = useState(false);
@@ -135,23 +132,11 @@ export function AgentLoopProvider({
135132
setStreamingText("");
136133
enqueueMessage(event.message);
137134
} else if (event.type === "progress" && event.subtype === "thinking") {
138-
if (printMode) {
139-
// Print Mode: write delta directly to stdout for instant output
140-
if (event.delta) {
141-
process.stdout.write(event.delta);
142-
}
143-
} else {
144-
// Ink Mode: update React state for re-rendering
145-
setStreamingText(event.text);
146-
}
135+
setStreamingText(event.text);
147136
}
148137
// tool progress events are handled by StreamingIndicator
149138
}
150139

151-
if (printMode) {
152-
// Ensure a newline after print-mode streaming completes
153-
process.stdout.write("\n");
154-
}
155140
} catch (error) {
156141
if (isAbortError(error)) return;
157142
throw error;
@@ -162,7 +147,7 @@ export function AgentLoopProvider({
162147
setStreaming(false);
163148
}
164149
},
165-
[agent, commands, enqueueMessage, flushPendingMessages, printMode],
150+
[agent, commands, enqueueMessage, flushPendingMessages],
166151
);
167152

168153
const value = useMemo(
@@ -171,12 +156,11 @@ export function AgentLoopProvider({
171156
streaming,
172157
messages,
173158
streamingText,
174-
printMode,
175159
onSubmit,
176160
abort,
177161
tokenCount,
178162
}),
179-
[abort, agent, messages, onSubmit, streaming, streamingText, printMode, tokenCount],
163+
[abort, agent, messages, onSubmit, streaming, streamingText, tokenCount],
180164
);
181165

182166
return createElement(AgentLoopContext.Provider, { value }, children);

0 commit comments

Comments
 (0)