mini-coder 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -48
- package/assets/icon-1-minimal.svg +31 -0
- package/assets/icon-2-dark-terminal.svg +48 -0
- package/assets/icon-3-gradient-modern.svg +45 -0
- package/assets/icon-4-filled-bold.svg +54 -0
- package/assets/icon-5-community-badge.svg +63 -0
- package/assets/preview-0-5-0.png +0 -0
- package/assets/preview.gif +0 -0
- package/bin/mc.ts +14 -0
- package/bun.lock +438 -0
- package/package.json +12 -29
- package/src/agent.ts +592 -0
- package/src/cli.ts +124 -0
- package/src/git.ts +164 -0
- package/src/headless.ts +140 -0
- package/src/index.ts +645 -0
- package/src/input.ts +155 -0
- package/src/paths.ts +37 -0
- package/src/plugins.ts +183 -0
- package/src/prompt.ts +294 -0
- package/src/session.ts +838 -0
- package/src/settings.ts +184 -0
- package/src/skills.ts +258 -0
- package/src/submit.ts +323 -0
- package/src/theme.ts +147 -0
- package/src/tools.ts +636 -0
- package/src/ui/agent.test.ts +49 -0
- package/src/ui/agent.ts +210 -0
- package/src/ui/commands.test.ts +610 -0
- package/src/ui/commands.ts +638 -0
- package/src/ui/conversation.test.ts +892 -0
- package/src/ui/conversation.ts +926 -0
- package/src/ui/help.test.ts +26 -0
- package/src/ui/help.ts +119 -0
- package/src/ui/input.test.ts +74 -0
- package/src/ui/input.ts +138 -0
- package/src/ui/overlay.test.ts +42 -0
- package/src/ui/overlay.ts +59 -0
- package/src/ui/status.test.ts +450 -0
- package/src/ui/status.ts +357 -0
- package/src/ui.ts +615 -0
- package/.claude/settings.local.json +0 -54
- package/.prettierignore +0 -7
- package/dist/mc-edit.js +0 -275
- package/dist/mc.js +0 -7355
- package/docs/KNOWN_ISSUES.md +0 -13
- package/docs/design-decisions.md +0 -31
- package/docs/mini-coder.1.md +0 -227
- package/docs/superpowers/plans/2026-03-30-anthropic-oauth-removal.md +0 -61
- package/docs/superpowers/specs/2026-03-30-anthropic-oauth-removal-design.md +0 -47
- package/lefthook.yml +0 -4
|
@@ -0,0 +1,892 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
cel,
|
|
4
|
+
MockTerminal,
|
|
5
|
+
measureContentHeight,
|
|
6
|
+
Text,
|
|
7
|
+
VStack,
|
|
8
|
+
} from "@cel-tui/core";
|
|
9
|
+
import type { Node } from "@cel-tui/types";
|
|
10
|
+
import {
|
|
11
|
+
fauxAssistantMessage,
|
|
12
|
+
fauxText,
|
|
13
|
+
fauxThinking,
|
|
14
|
+
fauxToolCall,
|
|
15
|
+
} from "@mariozechner/pi-ai";
|
|
16
|
+
import { DEFAULT_THEME } from "../theme.ts";
|
|
17
|
+
import {
|
|
18
|
+
buildConversationLogNodes,
|
|
19
|
+
type PendingToolResult,
|
|
20
|
+
renderAssistantMessage,
|
|
21
|
+
renderToolResult,
|
|
22
|
+
resetConversationRenderCache,
|
|
23
|
+
} from "./conversation.ts";
|
|
24
|
+
|
|
25
|
+
const PREVIEW_WIDTH = 32;
|
|
26
|
+
const RENDER_OPTS = {
|
|
27
|
+
showReasoning: false,
|
|
28
|
+
verbose: false,
|
|
29
|
+
theme: DEFAULT_THEME,
|
|
30
|
+
previewWidth: PREVIEW_WIDTH,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function collectText(node: Node | null): string[] {
|
|
34
|
+
if (!node) {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
if (node.type === "text") {
|
|
38
|
+
return [node.content];
|
|
39
|
+
}
|
|
40
|
+
if (node.type === "textinput") {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
return node.children.flatMap((child) => collectText(child));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function measureRenderedHeight(node: Node | null, width: number): number {
|
|
47
|
+
if (!node) {
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
return measureContentHeight(VStack({}, [node]), { width });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function waitForCelRender(): Promise<void> {
|
|
54
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function renderBufferRows(
|
|
58
|
+
node: Node | null,
|
|
59
|
+
cols = PREVIEW_WIDTH,
|
|
60
|
+
rows = 24,
|
|
61
|
+
): Promise<Array<{ text: string; fgColors: Array<string | null> }>> {
|
|
62
|
+
if (!node) {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const terminal = new MockTerminal(cols, rows);
|
|
67
|
+
cel.init(terminal);
|
|
68
|
+
cel.viewport(() => VStack({ width: cols, height: rows }, [node]));
|
|
69
|
+
await waitForCelRender();
|
|
70
|
+
|
|
71
|
+
const buffer = cel._getBuffer();
|
|
72
|
+
if (!buffer) {
|
|
73
|
+
throw new Error("Expected cel-tui to produce a render buffer");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const snapshot: Array<{ text: string; fgColors: Array<string | null> }> = [];
|
|
77
|
+
for (let y = 0; y < rows; y++) {
|
|
78
|
+
let text = "";
|
|
79
|
+
const fgColors: Array<string | null> = [];
|
|
80
|
+
for (let x = 0; x < cols; x++) {
|
|
81
|
+
const cell = buffer.get(x, y);
|
|
82
|
+
text += cell.char;
|
|
83
|
+
fgColors.push(cell.fgColor);
|
|
84
|
+
}
|
|
85
|
+
snapshot.push({ text, fgColors });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
cel.stop();
|
|
89
|
+
return snapshot;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function renderVisibleText(
|
|
93
|
+
node: Node | null,
|
|
94
|
+
cols = PREVIEW_WIDTH,
|
|
95
|
+
rows = 24,
|
|
96
|
+
): Promise<string[]> {
|
|
97
|
+
const snapshot = await renderBufferRows(node, cols, rows);
|
|
98
|
+
const lines: string[] = [];
|
|
99
|
+
|
|
100
|
+
for (const row of snapshot) {
|
|
101
|
+
const normalized = row.text.trim().replace(/^│\s*/, "");
|
|
102
|
+
if (normalized !== "") {
|
|
103
|
+
lines.push(normalized);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return lines;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
afterEach(() => {
|
|
111
|
+
resetConversationRenderCache();
|
|
112
|
+
cel.stop();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("ui/conversation", () => {
|
|
116
|
+
test("renderAssistantMessage preserves visible paragraph order in streamed markdown", () => {
|
|
117
|
+
// Arrange
|
|
118
|
+
const message = fauxAssistantMessage("First paragraph\n\nSecond paragraph");
|
|
119
|
+
|
|
120
|
+
// Act
|
|
121
|
+
const text = collectText(renderAssistantMessage(message, RENDER_OPTS));
|
|
122
|
+
const firstParagraphIndex = text.indexOf("First paragraph");
|
|
123
|
+
const secondParagraphIndex = text.indexOf("Second paragraph");
|
|
124
|
+
|
|
125
|
+
// Assert
|
|
126
|
+
expect(firstParagraphIndex).toBeGreaterThanOrEqual(0);
|
|
127
|
+
expect(secondParagraphIndex).toBeGreaterThan(firstParagraphIndex);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("renderAssistantMessage with reasoning enabled shows thinking blocks", () => {
|
|
131
|
+
// Arrange
|
|
132
|
+
const message = fauxAssistantMessage([
|
|
133
|
+
fauxThinking("I should inspect the tests first."),
|
|
134
|
+
fauxText("Done."),
|
|
135
|
+
]);
|
|
136
|
+
|
|
137
|
+
// Act
|
|
138
|
+
const text = collectText(
|
|
139
|
+
renderAssistantMessage(message, {
|
|
140
|
+
...RENDER_OPTS,
|
|
141
|
+
showReasoning: true,
|
|
142
|
+
}),
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
// Assert
|
|
146
|
+
expect(text).toContain("I should inspect the tests first.");
|
|
147
|
+
expect(text).toContain("Done.");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("renderAssistantMessage with reasoning hidden shows a thinking line-count placeholder", () => {
|
|
151
|
+
// Arrange
|
|
152
|
+
const message = fauxAssistantMessage([
|
|
153
|
+
fauxThinking("line one\nline two\nline three"),
|
|
154
|
+
fauxText("Done."),
|
|
155
|
+
]);
|
|
156
|
+
|
|
157
|
+
// Act
|
|
158
|
+
const text = collectText(
|
|
159
|
+
renderAssistantMessage(message, {
|
|
160
|
+
...RENDER_OPTS,
|
|
161
|
+
showReasoning: false,
|
|
162
|
+
}),
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
// Assert
|
|
166
|
+
expect(text).toContain("Thinking... 3 lines.");
|
|
167
|
+
expect(text).toContain("Done.");
|
|
168
|
+
expect(text).not.toContain("line one");
|
|
169
|
+
expect(text).not.toContain("line two");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("renderAssistantMessage with mixed top-level blocks keeps a single blank line between sections", () => {
|
|
173
|
+
// Arrange
|
|
174
|
+
const message = fauxAssistantMessage([
|
|
175
|
+
fauxThinking("Plan first."),
|
|
176
|
+
fauxText("Done."),
|
|
177
|
+
fauxToolCall("shell", { command: "echo hi" }, { id: "tool-1" }),
|
|
178
|
+
]);
|
|
179
|
+
|
|
180
|
+
// Act
|
|
181
|
+
const height = measureRenderedHeight(
|
|
182
|
+
renderAssistantMessage(message, {
|
|
183
|
+
...RENDER_OPTS,
|
|
184
|
+
showReasoning: true,
|
|
185
|
+
}),
|
|
186
|
+
PREVIEW_WIDTH,
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
// Assert
|
|
190
|
+
expect(height).toBe(6);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("renderAssistantMessage keeps markdown paragraph spacing inside a single text block", () => {
|
|
194
|
+
// Arrange
|
|
195
|
+
const message = fauxAssistantMessage("First paragraph\n\nSecond paragraph");
|
|
196
|
+
|
|
197
|
+
// Act
|
|
198
|
+
const height = measureRenderedHeight(
|
|
199
|
+
renderAssistantMessage(message, RENDER_OPTS),
|
|
200
|
+
PREVIEW_WIDTH,
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
// Assert
|
|
204
|
+
expect(height).toBe(3);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("buildConversationLogNodes with a pending shell result keeps the streamed call and result append-only", () => {
|
|
208
|
+
// Arrange
|
|
209
|
+
const pendingToolResults: PendingToolResult[] = [
|
|
210
|
+
{
|
|
211
|
+
toolCallId: "tool-1",
|
|
212
|
+
toolName: "shell",
|
|
213
|
+
content: [{ type: "text", text: "Exit code: 0\npartial output" }],
|
|
214
|
+
isError: false,
|
|
215
|
+
},
|
|
216
|
+
];
|
|
217
|
+
|
|
218
|
+
// Act
|
|
219
|
+
const nodes = buildConversationLogNodes(
|
|
220
|
+
{
|
|
221
|
+
messages: [
|
|
222
|
+
fauxAssistantMessage([
|
|
223
|
+
fauxText("Working..."),
|
|
224
|
+
fauxToolCall("shell", { command: "echo hi" }, { id: "tool-1" }),
|
|
225
|
+
]),
|
|
226
|
+
],
|
|
227
|
+
showReasoning: false,
|
|
228
|
+
verbose: false,
|
|
229
|
+
theme: DEFAULT_THEME,
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
isStreaming: true,
|
|
233
|
+
content: [],
|
|
234
|
+
pendingToolResults,
|
|
235
|
+
},
|
|
236
|
+
0,
|
|
237
|
+
PREVIEW_WIDTH,
|
|
238
|
+
);
|
|
239
|
+
const text = collectText({
|
|
240
|
+
type: "vstack",
|
|
241
|
+
props: {},
|
|
242
|
+
children: nodes,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Assert
|
|
246
|
+
expect(text).toContain("Working...");
|
|
247
|
+
expect(text.filter((line) => line === "[shell ->]")).toHaveLength(1);
|
|
248
|
+
expect(text).toContain("echo hi");
|
|
249
|
+
expect(text).toContain("[shell <-]");
|
|
250
|
+
expect(text).toContain("partial output");
|
|
251
|
+
expect(text).not.toContain("Exit code: 0");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("buildConversationLogNodes with a sliced window keeps hidden tool-call args available for compact edit results", () => {
|
|
255
|
+
// Arrange
|
|
256
|
+
const nodes = buildConversationLogNodes(
|
|
257
|
+
{
|
|
258
|
+
messages: [
|
|
259
|
+
fauxAssistantMessage([
|
|
260
|
+
fauxToolCall(
|
|
261
|
+
"edit",
|
|
262
|
+
{
|
|
263
|
+
path: "src/app.ts",
|
|
264
|
+
oldText: "before",
|
|
265
|
+
newText: "after",
|
|
266
|
+
},
|
|
267
|
+
{ id: "tool-1" },
|
|
268
|
+
),
|
|
269
|
+
]),
|
|
270
|
+
{
|
|
271
|
+
role: "toolResult" as const,
|
|
272
|
+
toolCallId: "tool-1",
|
|
273
|
+
toolName: "edit",
|
|
274
|
+
content: [{ type: "text" as const, text: "Edited src/app.ts" }],
|
|
275
|
+
isError: false,
|
|
276
|
+
timestamp: Date.now(),
|
|
277
|
+
},
|
|
278
|
+
],
|
|
279
|
+
showReasoning: false,
|
|
280
|
+
verbose: false,
|
|
281
|
+
theme: DEFAULT_THEME,
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
isStreaming: false,
|
|
285
|
+
content: [],
|
|
286
|
+
pendingToolResults: [],
|
|
287
|
+
},
|
|
288
|
+
1,
|
|
289
|
+
PREVIEW_WIDTH,
|
|
290
|
+
);
|
|
291
|
+
const text = collectText({
|
|
292
|
+
type: "vstack",
|
|
293
|
+
props: {},
|
|
294
|
+
children: nodes,
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// Assert
|
|
298
|
+
expect(text).toContain("[edit <-]");
|
|
299
|
+
expect(text).toContain("~ src/app.ts");
|
|
300
|
+
expect(text).not.toContain("before");
|
|
301
|
+
expect(text).not.toContain("after");
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("buildConversationLogNodes with unchanged state reuses cached committed nodes", () => {
|
|
305
|
+
// Arrange
|
|
306
|
+
const state = {
|
|
307
|
+
messages: [fauxAssistantMessage("Committed response")],
|
|
308
|
+
showReasoning: false,
|
|
309
|
+
verbose: false,
|
|
310
|
+
theme: DEFAULT_THEME,
|
|
311
|
+
};
|
|
312
|
+
const streaming = {
|
|
313
|
+
isStreaming: false,
|
|
314
|
+
content: [],
|
|
315
|
+
pendingToolResults: [],
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
// Act
|
|
319
|
+
const first = buildConversationLogNodes(state, streaming, 0, PREVIEW_WIDTH);
|
|
320
|
+
const second = buildConversationLogNodes(
|
|
321
|
+
state,
|
|
322
|
+
streaming,
|
|
323
|
+
0,
|
|
324
|
+
PREVIEW_WIDTH,
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
// Assert
|
|
328
|
+
expect(second).toBe(first);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("buildConversationLogNodes with only a new streaming tail reuses the committed prefix", () => {
|
|
332
|
+
// Arrange
|
|
333
|
+
const state = {
|
|
334
|
+
messages: [fauxAssistantMessage("Committed response")],
|
|
335
|
+
showReasoning: false,
|
|
336
|
+
verbose: false,
|
|
337
|
+
theme: DEFAULT_THEME,
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
// Act
|
|
341
|
+
const committed = buildConversationLogNodes(
|
|
342
|
+
state,
|
|
343
|
+
{
|
|
344
|
+
isStreaming: false,
|
|
345
|
+
content: [],
|
|
346
|
+
pendingToolResults: [],
|
|
347
|
+
},
|
|
348
|
+
0,
|
|
349
|
+
PREVIEW_WIDTH,
|
|
350
|
+
);
|
|
351
|
+
const withStreamingTail = buildConversationLogNodes(
|
|
352
|
+
state,
|
|
353
|
+
{
|
|
354
|
+
isStreaming: true,
|
|
355
|
+
content: [fauxText("Streaming tail")],
|
|
356
|
+
pendingToolResults: [],
|
|
357
|
+
},
|
|
358
|
+
0,
|
|
359
|
+
PREVIEW_WIDTH,
|
|
360
|
+
);
|
|
361
|
+
const text = collectText({
|
|
362
|
+
type: "vstack",
|
|
363
|
+
props: {},
|
|
364
|
+
children: withStreamingTail,
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// Assert
|
|
368
|
+
expect(withStreamingTail[0]).toBe(committed[0]);
|
|
369
|
+
expect(text).toContain("Committed response");
|
|
370
|
+
expect(text).toContain("Streaming tail");
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("buildConversationLogNodes when verbose mode changes rebuilds cached tool nodes", async () => {
|
|
374
|
+
// Arrange
|
|
375
|
+
const output = Array.from({ length: 25 }, (_, i) => `line ${i + 1}`).join(
|
|
376
|
+
"\n",
|
|
377
|
+
);
|
|
378
|
+
const state = {
|
|
379
|
+
messages: [
|
|
380
|
+
fauxAssistantMessage([
|
|
381
|
+
fauxToolCall("shell", { command: "seq 1 25" }, { id: "tool-1" }),
|
|
382
|
+
]),
|
|
383
|
+
{
|
|
384
|
+
role: "toolResult" as const,
|
|
385
|
+
toolCallId: "tool-1",
|
|
386
|
+
toolName: "shell",
|
|
387
|
+
content: [{ type: "text" as const, text: output }],
|
|
388
|
+
isError: false,
|
|
389
|
+
timestamp: Date.now(),
|
|
390
|
+
},
|
|
391
|
+
],
|
|
392
|
+
showReasoning: false,
|
|
393
|
+
verbose: false,
|
|
394
|
+
theme: DEFAULT_THEME,
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
// Act
|
|
398
|
+
const previewNodes = buildConversationLogNodes(
|
|
399
|
+
state,
|
|
400
|
+
{
|
|
401
|
+
isStreaming: false,
|
|
402
|
+
content: [],
|
|
403
|
+
pendingToolResults: [],
|
|
404
|
+
},
|
|
405
|
+
0,
|
|
406
|
+
PREVIEW_WIDTH,
|
|
407
|
+
);
|
|
408
|
+
const previewText = await renderVisibleText(
|
|
409
|
+
VStack({}, previewNodes),
|
|
410
|
+
PREVIEW_WIDTH,
|
|
411
|
+
24,
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
const verboseNodes = buildConversationLogNodes(
|
|
415
|
+
{ ...state, verbose: true },
|
|
416
|
+
{
|
|
417
|
+
isStreaming: false,
|
|
418
|
+
content: [],
|
|
419
|
+
pendingToolResults: [],
|
|
420
|
+
},
|
|
421
|
+
0,
|
|
422
|
+
PREVIEW_WIDTH,
|
|
423
|
+
);
|
|
424
|
+
const verboseText = await renderVisibleText(
|
|
425
|
+
VStack({}, verboseNodes),
|
|
426
|
+
PREVIEW_WIDTH,
|
|
427
|
+
40,
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
// Assert
|
|
431
|
+
expect(previewNodes).not.toBe(verboseNodes);
|
|
432
|
+
expect(previewText).toContain("line 18");
|
|
433
|
+
expect(previewText).toContain("line 25");
|
|
434
|
+
expect(previewText).toContain("And 17 lines more");
|
|
435
|
+
expect(previewText).not.toContain("line 17");
|
|
436
|
+
expect(verboseText).toContain("line 17");
|
|
437
|
+
expect(verboseText).toContain("line 25");
|
|
438
|
+
expect(verboseText).not.toContain("And 17 lines more");
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test("buildConversationLogNodes when preview width changes rebuilds cached tool nodes", () => {
|
|
442
|
+
// Arrange
|
|
443
|
+
const state = {
|
|
444
|
+
messages: [
|
|
445
|
+
fauxAssistantMessage([
|
|
446
|
+
fauxToolCall(
|
|
447
|
+
"shell",
|
|
448
|
+
{
|
|
449
|
+
command:
|
|
450
|
+
"printf 'this is a very long wrapped line that depends on width'",
|
|
451
|
+
},
|
|
452
|
+
{ id: "tool-1" },
|
|
453
|
+
),
|
|
454
|
+
]),
|
|
455
|
+
],
|
|
456
|
+
showReasoning: false,
|
|
457
|
+
verbose: false,
|
|
458
|
+
theme: DEFAULT_THEME,
|
|
459
|
+
};
|
|
460
|
+
const streaming = {
|
|
461
|
+
isStreaming: false,
|
|
462
|
+
content: [],
|
|
463
|
+
pendingToolResults: [],
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
// Act
|
|
467
|
+
const wide = buildConversationLogNodes(state, streaming, 0, 40);
|
|
468
|
+
const narrow = buildConversationLogNodes(state, streaming, 0, 20);
|
|
469
|
+
|
|
470
|
+
// Assert
|
|
471
|
+
expect(narrow).not.toBe(wide);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test("renderAssistantMessage with in-progress reasoning visible shows thinking text", () => {
|
|
475
|
+
// Arrange
|
|
476
|
+
const assistant = {
|
|
477
|
+
content: [fauxThinking("Reasoning in progress")],
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
// Act
|
|
481
|
+
const text = collectText(
|
|
482
|
+
renderAssistantMessage(assistant, {
|
|
483
|
+
...RENDER_OPTS,
|
|
484
|
+
showReasoning: true,
|
|
485
|
+
}),
|
|
486
|
+
);
|
|
487
|
+
|
|
488
|
+
// Assert
|
|
489
|
+
expect(text).toContain("Reasoning in progress");
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test("renderAssistantMessage with in-progress reasoning hidden shows a one-line placeholder", () => {
|
|
493
|
+
// Arrange
|
|
494
|
+
const assistant = {
|
|
495
|
+
content: [fauxThinking("some thinking")],
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
// Act
|
|
499
|
+
const text = collectText(
|
|
500
|
+
renderAssistantMessage(assistant, {
|
|
501
|
+
...RENDER_OPTS,
|
|
502
|
+
showReasoning: false,
|
|
503
|
+
}),
|
|
504
|
+
);
|
|
505
|
+
|
|
506
|
+
// Assert
|
|
507
|
+
expect(text).toContain("Thinking... 1 line.");
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test("renderAssistantMessage for a shell tool call shows the command under the new header pill", () => {
|
|
511
|
+
// Arrange
|
|
512
|
+
const assistant = {
|
|
513
|
+
content: [
|
|
514
|
+
fauxToolCall("shell", { command: "echo hi" }, { id: "tool-1" }),
|
|
515
|
+
],
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
// Act
|
|
519
|
+
const text = collectText(renderAssistantMessage(assistant, RENDER_OPTS));
|
|
520
|
+
|
|
521
|
+
// Assert
|
|
522
|
+
expect(text).toContain("[shell ->]");
|
|
523
|
+
expect(text).toContain("echo hi");
|
|
524
|
+
expect(text).not.toContain('"command": "echo hi"');
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
test("renderAssistantMessage for a wrapped shell command keeps a fixed preview height when verbose is off", () => {
|
|
528
|
+
// Arrange
|
|
529
|
+
const command = Array.from(
|
|
530
|
+
{ length: 4 },
|
|
531
|
+
() =>
|
|
532
|
+
"printf 'this wrapped command line is intentionally long for the preview'",
|
|
533
|
+
).join("\n");
|
|
534
|
+
const assistant = {
|
|
535
|
+
content: [fauxToolCall("shell", { command }, { id: "tool-1" })],
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
// Act
|
|
539
|
+
const node = renderAssistantMessage(assistant, {
|
|
540
|
+
...RENDER_OPTS,
|
|
541
|
+
previewWidth: 24,
|
|
542
|
+
});
|
|
543
|
+
const height = measureRenderedHeight(node, 24);
|
|
544
|
+
|
|
545
|
+
// Assert
|
|
546
|
+
expect(height).toBe(10);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
test("renderAssistantMessage for a long single-line shell command keeps the command start visible in non-verbose mode", async () => {
|
|
550
|
+
// Arrange
|
|
551
|
+
const command = `IMPORTANT_PREFIX ${"x".repeat(400)}`;
|
|
552
|
+
const assistant = {
|
|
553
|
+
content: [fauxToolCall("shell", { command }, { id: "tool-1" })],
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
// Act
|
|
557
|
+
const text = await renderVisibleText(
|
|
558
|
+
renderAssistantMessage(assistant, {
|
|
559
|
+
...RENDER_OPTS,
|
|
560
|
+
previewWidth: 24,
|
|
561
|
+
}),
|
|
562
|
+
24,
|
|
563
|
+
20,
|
|
564
|
+
);
|
|
565
|
+
|
|
566
|
+
// Assert
|
|
567
|
+
expect(text).toContain("IMPORTANT_PREFIX");
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
test("renderToolResult for a shell preview allows the outer conversation scroll to handle mouse wheel events", async () => {
|
|
571
|
+
// Arrange
|
|
572
|
+
const terminal = new MockTerminal(24, 10);
|
|
573
|
+
let outerScrollOffset = 0;
|
|
574
|
+
const toolNode = renderToolResult(
|
|
575
|
+
"shell",
|
|
576
|
+
{ command: "seq 1 25" },
|
|
577
|
+
Array.from({ length: 25 }, (_, i) => `line ${i + 1}`).join("\n"),
|
|
578
|
+
false,
|
|
579
|
+
{
|
|
580
|
+
...RENDER_OPTS,
|
|
581
|
+
previewWidth: 24,
|
|
582
|
+
},
|
|
583
|
+
);
|
|
584
|
+
|
|
585
|
+
cel.init(terminal);
|
|
586
|
+
cel.viewport(() =>
|
|
587
|
+
VStack(
|
|
588
|
+
{
|
|
589
|
+
width: 24,
|
|
590
|
+
height: 10,
|
|
591
|
+
overflow: "scroll",
|
|
592
|
+
scrollOffset: outerScrollOffset,
|
|
593
|
+
onScroll: (offset) => {
|
|
594
|
+
outerScrollOffset = offset;
|
|
595
|
+
},
|
|
596
|
+
},
|
|
597
|
+
[
|
|
598
|
+
Text("before 1"),
|
|
599
|
+
toolNode,
|
|
600
|
+
Text("after 1"),
|
|
601
|
+
Text("after 2"),
|
|
602
|
+
Text("after 3"),
|
|
603
|
+
Text("after 4"),
|
|
604
|
+
Text("after 5"),
|
|
605
|
+
],
|
|
606
|
+
),
|
|
607
|
+
);
|
|
608
|
+
await waitForCelRender();
|
|
609
|
+
|
|
610
|
+
// Act
|
|
611
|
+
terminal.sendInput("\x1b[<65;4;3M");
|
|
612
|
+
await waitForCelRender();
|
|
613
|
+
|
|
614
|
+
// Assert
|
|
615
|
+
expect(outerScrollOffset).toBeGreaterThan(0);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
test("renderAssistantMessage for a readImage tool call shows the path rather than JSON", () => {
|
|
619
|
+
// Arrange
|
|
620
|
+
const assistant = {
|
|
621
|
+
content: [
|
|
622
|
+
fauxToolCall(
|
|
623
|
+
"readImage",
|
|
624
|
+
{ path: "assets/preview.png" },
|
|
625
|
+
{ id: "tool-1" },
|
|
626
|
+
),
|
|
627
|
+
],
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
// Act
|
|
631
|
+
const text = collectText(renderAssistantMessage(assistant, RENDER_OPTS));
|
|
632
|
+
|
|
633
|
+
// Assert
|
|
634
|
+
expect(text).toContain("[read image ->]");
|
|
635
|
+
expect(text).toContain("assets/preview.png");
|
|
636
|
+
expect(text).not.toContain("{");
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
test("renderAssistantMessage for an edit tool call shows both old and new content without diff prefixes", () => {
|
|
640
|
+
// Arrange
|
|
641
|
+
const assistant = {
|
|
642
|
+
content: [
|
|
643
|
+
fauxToolCall(
|
|
644
|
+
"edit",
|
|
645
|
+
{
|
|
646
|
+
path: "src/file.ts",
|
|
647
|
+
oldText: "old line",
|
|
648
|
+
newText: "new line",
|
|
649
|
+
},
|
|
650
|
+
{ id: "tool-1" },
|
|
651
|
+
),
|
|
652
|
+
],
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
// Act
|
|
656
|
+
const text = collectText(renderAssistantMessage(assistant, RENDER_OPTS));
|
|
657
|
+
|
|
658
|
+
// Assert
|
|
659
|
+
expect(text).toContain("[edit ->]");
|
|
660
|
+
expect(text).toContain("src/file.ts");
|
|
661
|
+
expect(text).toContain("old line");
|
|
662
|
+
expect(text).toContain("new line");
|
|
663
|
+
expect(text).not.toContain("+new line");
|
|
664
|
+
expect(text).not.toContain("-old line");
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
test("renderAssistantMessage for an edit tool call colors old text red and new text green", async () => {
|
|
668
|
+
// Arrange
|
|
669
|
+
const assistant = {
|
|
670
|
+
content: [
|
|
671
|
+
fauxToolCall(
|
|
672
|
+
"edit",
|
|
673
|
+
{
|
|
674
|
+
path: "src/file.ts",
|
|
675
|
+
oldText: "old line",
|
|
676
|
+
newText: "new line",
|
|
677
|
+
},
|
|
678
|
+
{ id: "tool-1" },
|
|
679
|
+
),
|
|
680
|
+
],
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
// Act
|
|
684
|
+
const rows = await renderBufferRows(
|
|
685
|
+
renderAssistantMessage(assistant, RENDER_OPTS),
|
|
686
|
+
PREVIEW_WIDTH,
|
|
687
|
+
12,
|
|
688
|
+
);
|
|
689
|
+
const oldRow = rows.find((row) => row.text.includes("old line"));
|
|
690
|
+
const newRow = rows.find((row) => row.text.includes("new line"));
|
|
691
|
+
|
|
692
|
+
// Assert
|
|
693
|
+
expect(oldRow).toBeDefined();
|
|
694
|
+
expect(newRow).toBeDefined();
|
|
695
|
+
|
|
696
|
+
const oldColor = oldRow?.fgColors[oldRow.text.indexOf("o")];
|
|
697
|
+
const newColor = newRow?.fgColors[newRow.text.indexOf("n")];
|
|
698
|
+
expect(oldColor).toBe(DEFAULT_THEME.diffRemoved ?? null);
|
|
699
|
+
expect(newColor).toBe(DEFAULT_THEME.diffAdded ?? null);
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
test("renderToolResult for shell output in non-verbose mode shows the visible tail under a result header", async () => {
|
|
703
|
+
// Arrange
|
|
704
|
+
const output = Array.from({ length: 25 }, (_, i) => `line ${i + 1}`).join(
|
|
705
|
+
"\n",
|
|
706
|
+
);
|
|
707
|
+
|
|
708
|
+
// Act
|
|
709
|
+
const text = await renderVisibleText(
|
|
710
|
+
renderToolResult(
|
|
711
|
+
"shell",
|
|
712
|
+
{ command: "seq 1 25" },
|
|
713
|
+
output,
|
|
714
|
+
false,
|
|
715
|
+
RENDER_OPTS,
|
|
716
|
+
),
|
|
717
|
+
PREVIEW_WIDTH,
|
|
718
|
+
24,
|
|
719
|
+
);
|
|
720
|
+
|
|
721
|
+
// Assert
|
|
722
|
+
expect(text).toContain("[shell <-]");
|
|
723
|
+
expect(text).toContain("line 18");
|
|
724
|
+
expect(text).toContain("line 25");
|
|
725
|
+
expect(text).toContain("And 17 lines more");
|
|
726
|
+
expect(text).not.toContain("line 17");
|
|
727
|
+
expect(text).not.toContain("seq 1 25");
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
test("renderToolResult for shell output in verbose mode shows the full stored output", () => {
|
|
731
|
+
// Arrange
|
|
732
|
+
const output = Array.from({ length: 25 }, (_, i) => `line ${i + 1}`).join(
|
|
733
|
+
"\n",
|
|
734
|
+
);
|
|
735
|
+
|
|
736
|
+
// Act
|
|
737
|
+
const text = collectText(
|
|
738
|
+
renderToolResult("shell", { command: "seq 1 25" }, output, false, {
|
|
739
|
+
...RENDER_OPTS,
|
|
740
|
+
verbose: true,
|
|
741
|
+
}),
|
|
742
|
+
);
|
|
743
|
+
|
|
744
|
+
// Assert
|
|
745
|
+
expect(text).toContain("line 17");
|
|
746
|
+
expect(text).toContain("line 25");
|
|
747
|
+
expect(text).not.toContain("And 17 lines more");
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
test("renderToolResult for shell errors normalizes exit-code and stderr labels", () => {
|
|
751
|
+
// Arrange
|
|
752
|
+
const resultText = "Exit code: 42\n[stderr]\nboom";
|
|
753
|
+
|
|
754
|
+
// Act
|
|
755
|
+
const text = collectText(
|
|
756
|
+
renderToolResult(
|
|
757
|
+
"shell",
|
|
758
|
+
{ command: "exit 42" },
|
|
759
|
+
resultText,
|
|
760
|
+
true,
|
|
761
|
+
RENDER_OPTS,
|
|
762
|
+
),
|
|
763
|
+
);
|
|
764
|
+
|
|
765
|
+
// Assert
|
|
766
|
+
expect(text).toContain("[shell <-]");
|
|
767
|
+
expect(text).toContain("exit 42");
|
|
768
|
+
expect(text).toContain("boom");
|
|
769
|
+
expect(text).not.toContain("Exit code: 42");
|
|
770
|
+
expect(text).not.toContain("[stderr]");
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
test("renderToolResult for a readImage success shows a compact path result", () => {
|
|
774
|
+
// Arrange
|
|
775
|
+
const args = { path: "diagram.png" };
|
|
776
|
+
|
|
777
|
+
// Act
|
|
778
|
+
const text = collectText(
|
|
779
|
+
renderToolResult("readImage", args, "", false, RENDER_OPTS),
|
|
780
|
+
);
|
|
781
|
+
|
|
782
|
+
// Assert
|
|
783
|
+
expect(text).toContain("[read image <-]");
|
|
784
|
+
expect(text).toContain("diagram.png");
|
|
785
|
+
expect(text).not.toContain("Read image.");
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
test("renderToolResult for a readImage error shows the full error even when verbose is off", () => {
|
|
789
|
+
// Arrange
|
|
790
|
+
const errorText = Array.from(
|
|
791
|
+
{ length: 25 },
|
|
792
|
+
(_, i) => `error ${i + 1}`,
|
|
793
|
+
).join("\n");
|
|
794
|
+
|
|
795
|
+
// Act
|
|
796
|
+
const text = collectText(
|
|
797
|
+
renderToolResult(
|
|
798
|
+
"readImage",
|
|
799
|
+
{ path: "diagram.png" },
|
|
800
|
+
errorText,
|
|
801
|
+
true,
|
|
802
|
+
RENDER_OPTS,
|
|
803
|
+
),
|
|
804
|
+
);
|
|
805
|
+
|
|
806
|
+
// Assert
|
|
807
|
+
expect(text).toContain("error 1");
|
|
808
|
+
expect(text).toContain("error 25");
|
|
809
|
+
expect(text.some((line) => /^And \d+ lines more$/.test(line))).toBe(false);
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
test("renderToolResult for a successful edit stays compact regardless of verbose mode", () => {
|
|
813
|
+
// Arrange
|
|
814
|
+
const args = {
|
|
815
|
+
path: "src/file.ts",
|
|
816
|
+
oldText: "before",
|
|
817
|
+
newText: "after",
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
// Act
|
|
821
|
+
const previewText = collectText(
|
|
822
|
+
renderToolResult("edit", args, "Edited src/file.ts", false, RENDER_OPTS),
|
|
823
|
+
);
|
|
824
|
+
const verboseText = collectText(
|
|
825
|
+
renderToolResult("edit", args, "Edited src/file.ts", false, {
|
|
826
|
+
...RENDER_OPTS,
|
|
827
|
+
verbose: true,
|
|
828
|
+
}),
|
|
829
|
+
);
|
|
830
|
+
|
|
831
|
+
// Assert
|
|
832
|
+
expect(previewText).toContain("[edit <-]");
|
|
833
|
+
expect(previewText).toContain("~ src/file.ts");
|
|
834
|
+
expect(previewText).not.toContain("before");
|
|
835
|
+
expect(previewText).not.toContain("after");
|
|
836
|
+
expect(previewText).not.toContain("And 1 lines more");
|
|
837
|
+
expect(verboseText).toEqual(previewText);
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
test("renderToolResult for an edit error uses the preview policy in non-verbose mode", async () => {
|
|
841
|
+
// Arrange
|
|
842
|
+
const errorText = Array.from(
|
|
843
|
+
{ length: 25 },
|
|
844
|
+
(_, i) => `error ${i + 1}`,
|
|
845
|
+
).join("\n");
|
|
846
|
+
|
|
847
|
+
// Act
|
|
848
|
+
const text = await renderVisibleText(
|
|
849
|
+
renderToolResult(
|
|
850
|
+
"edit",
|
|
851
|
+
{
|
|
852
|
+
path: "src/file.ts",
|
|
853
|
+
oldText: "before",
|
|
854
|
+
newText: "after",
|
|
855
|
+
},
|
|
856
|
+
errorText,
|
|
857
|
+
true,
|
|
858
|
+
RENDER_OPTS,
|
|
859
|
+
),
|
|
860
|
+
PREVIEW_WIDTH,
|
|
861
|
+
24,
|
|
862
|
+
);
|
|
863
|
+
|
|
864
|
+
// Assert
|
|
865
|
+
expect(text).toContain("[edit <-]");
|
|
866
|
+
expect(text).toContain("error 18");
|
|
867
|
+
expect(text).toContain("error 25");
|
|
868
|
+
expect(text).toContain("And 17 lines more");
|
|
869
|
+
expect(text).not.toContain("error 17");
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
test("renderToolResult for a generic plugin tool uses the shared result header", () => {
|
|
873
|
+
// Arrange
|
|
874
|
+
const args = { query: "session persistence sqlite turn numbering" };
|
|
875
|
+
|
|
876
|
+
// Act
|
|
877
|
+
const text = collectText(
|
|
878
|
+
renderToolResult(
|
|
879
|
+
"mcp/search",
|
|
880
|
+
args,
|
|
881
|
+
"session persistence sqlite turn numbering",
|
|
882
|
+
false,
|
|
883
|
+
RENDER_OPTS,
|
|
884
|
+
),
|
|
885
|
+
);
|
|
886
|
+
|
|
887
|
+
// Assert
|
|
888
|
+
expect(text).toContain("[mcp/search <-]");
|
|
889
|
+
expect(text).toContain("session persistence sqlite turn numbering");
|
|
890
|
+
expect(text).not.toContain('"query"');
|
|
891
|
+
});
|
|
892
|
+
});
|