mini-coder 0.5.2 → 0.5.4
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 +31 -27
- package/bun.lock +7 -7
- package/package.json +4 -4
- package/src/agent.ts +38 -15
- package/src/git.ts +57 -7
- package/src/prompt.ts +4 -1
- package/src/session.ts +47 -17
- package/src/tools.ts +668 -4
- package/src/ui/conversation.test.ts +230 -29
- package/src/ui/conversation.ts +110 -32
- package/src/ui/help.test.ts +9 -4
- package/src/ui/help.ts +5 -3
- package/src/ui.ts +7 -3
|
@@ -64,7 +64,15 @@ async function renderBufferRows(
|
|
|
64
64
|
node: Node | null,
|
|
65
65
|
cols = PREVIEW_WIDTH,
|
|
66
66
|
rows = 24,
|
|
67
|
-
): Promise<
|
|
67
|
+
): Promise<
|
|
68
|
+
Array<{
|
|
69
|
+
text: string;
|
|
70
|
+
fgColors: Array<string | null>;
|
|
71
|
+
bold: boolean[];
|
|
72
|
+
italic: boolean[];
|
|
73
|
+
underline: boolean[];
|
|
74
|
+
}>
|
|
75
|
+
> {
|
|
68
76
|
if (!node) {
|
|
69
77
|
return [];
|
|
70
78
|
}
|
|
@@ -79,16 +87,28 @@ async function renderBufferRows(
|
|
|
79
87
|
throw new Error("Expected cel-tui to produce a render buffer");
|
|
80
88
|
}
|
|
81
89
|
|
|
82
|
-
const snapshot: Array<{
|
|
90
|
+
const snapshot: Array<{
|
|
91
|
+
text: string;
|
|
92
|
+
fgColors: Array<string | null>;
|
|
93
|
+
bold: boolean[];
|
|
94
|
+
italic: boolean[];
|
|
95
|
+
underline: boolean[];
|
|
96
|
+
}> = [];
|
|
83
97
|
for (let y = 0; y < rows; y++) {
|
|
84
98
|
let text = "";
|
|
85
99
|
const fgColors: Array<string | null> = [];
|
|
100
|
+
const bold: boolean[] = [];
|
|
101
|
+
const italic: boolean[] = [];
|
|
102
|
+
const underline: boolean[] = [];
|
|
86
103
|
for (let x = 0; x < cols; x++) {
|
|
87
104
|
const cell = buffer.get(x, y);
|
|
88
105
|
text += cell.char;
|
|
89
106
|
fgColors.push(cell.fgColor);
|
|
107
|
+
bold.push(cell.bold);
|
|
108
|
+
italic.push(cell.italic);
|
|
109
|
+
underline.push(cell.underline);
|
|
90
110
|
}
|
|
91
|
-
snapshot.push({ text, fgColors });
|
|
111
|
+
snapshot.push({ text, fgColors, bold, italic, underline });
|
|
92
112
|
}
|
|
93
113
|
|
|
94
114
|
cel.stop();
|
|
@@ -119,18 +139,20 @@ afterEach(() => {
|
|
|
119
139
|
});
|
|
120
140
|
|
|
121
141
|
describe("ui/conversation", () => {
|
|
122
|
-
test("renderAssistantMessage
|
|
142
|
+
test("renderAssistantMessage keeps raw markdown markers visible in assistant text", () => {
|
|
123
143
|
// Arrange
|
|
124
|
-
const message = fauxAssistantMessage(
|
|
144
|
+
const message = fauxAssistantMessage(
|
|
145
|
+
"# Heading\n\nUse **bold** and `code`.\n- item",
|
|
146
|
+
);
|
|
125
147
|
|
|
126
148
|
// Act
|
|
127
149
|
const text = collectText(renderAssistantMessage(message, RENDER_OPTS));
|
|
128
|
-
const firstParagraphIndex = text.indexOf("First paragraph");
|
|
129
|
-
const secondParagraphIndex = text.indexOf("Second paragraph");
|
|
130
150
|
|
|
131
151
|
// Assert
|
|
132
|
-
expect(
|
|
133
|
-
expect(
|
|
152
|
+
expect(text).toContain("# Heading");
|
|
153
|
+
expect(text).toContain("Use **bold** and `code`.");
|
|
154
|
+
expect(text).toContain("- item");
|
|
155
|
+
expect(text).not.toContain("Use bold and code.");
|
|
134
156
|
});
|
|
135
157
|
|
|
136
158
|
test("renderAssistantMessage with reasoning enabled shows thinking blocks", () => {
|
|
@@ -196,18 +218,131 @@ describe("ui/conversation", () => {
|
|
|
196
218
|
expect(height).toBe(6);
|
|
197
219
|
});
|
|
198
220
|
|
|
199
|
-
test("renderAssistantMessage
|
|
221
|
+
test("renderAssistantMessage syntax-highlights markdown tokens with theme-derived colors", async () => {
|
|
200
222
|
// Arrange
|
|
201
|
-
const
|
|
223
|
+
const theme = {
|
|
224
|
+
...DEFAULT_THEME,
|
|
225
|
+
accentText: "color14",
|
|
226
|
+
secondaryAccentText: "color09",
|
|
227
|
+
diffAdded: "color10",
|
|
228
|
+
mutedText: "color13",
|
|
229
|
+
} satisfies typeof DEFAULT_THEME;
|
|
230
|
+
const message = fauxAssistantMessage("# Heading\n- item\n> quote\n`code`");
|
|
202
231
|
|
|
203
232
|
// Act
|
|
204
|
-
const
|
|
205
|
-
renderAssistantMessage(message,
|
|
206
|
-
|
|
233
|
+
const rows = await renderBufferRows(
|
|
234
|
+
renderAssistantMessage(message, {
|
|
235
|
+
...RENDER_OPTS,
|
|
236
|
+
theme,
|
|
237
|
+
}),
|
|
238
|
+
32,
|
|
239
|
+
12,
|
|
240
|
+
);
|
|
241
|
+
const headingRow = rows.find((row) => row.text.includes("# Heading"));
|
|
242
|
+
const bulletRow = rows.find((row) => row.text.includes("- item"));
|
|
243
|
+
const quoteRow = rows.find((row) => row.text.includes("> quote"));
|
|
244
|
+
const codeRow = rows.find((row) => row.text.includes("`code`"));
|
|
245
|
+
|
|
246
|
+
// Assert
|
|
247
|
+
expect(headingRow).toBeDefined();
|
|
248
|
+
expect(bulletRow).toBeDefined();
|
|
249
|
+
expect(quoteRow).toBeDefined();
|
|
250
|
+
expect(codeRow).toBeDefined();
|
|
251
|
+
expect(headingRow?.fgColors[headingRow.text.indexOf("#")]).toBe(
|
|
252
|
+
theme.accentText ?? null,
|
|
253
|
+
);
|
|
254
|
+
expect(bulletRow?.fgColors[bulletRow.text.indexOf("-")]).toBe(
|
|
255
|
+
theme.secondaryAccentText ?? null,
|
|
256
|
+
);
|
|
257
|
+
expect(quoteRow?.fgColors[quoteRow.text.indexOf(">")]).toBe(
|
|
258
|
+
theme.mutedText ?? null,
|
|
259
|
+
);
|
|
260
|
+
expect(quoteRow?.italic[quoteRow.text.indexOf(">")]).toBe(true);
|
|
261
|
+
expect(codeRow?.fgColors[codeRow.text.indexOf("`")]).toBe(
|
|
262
|
+
theme.diffAdded ?? null,
|
|
263
|
+
);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("renderAssistantMessage syntax-highlights markdown emphasis and links with style cues", async () => {
|
|
267
|
+
// Arrange
|
|
268
|
+
const theme = {
|
|
269
|
+
...DEFAULT_THEME,
|
|
270
|
+
accentText: "color14",
|
|
271
|
+
diffAdded: "color10",
|
|
272
|
+
} satisfies typeof DEFAULT_THEME;
|
|
273
|
+
const message = fauxAssistantMessage(
|
|
274
|
+
"*italic* **bold** [label](https://example.com)",
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
// Act
|
|
278
|
+
const rows = await renderBufferRows(
|
|
279
|
+
renderAssistantMessage(message, {
|
|
280
|
+
...RENDER_OPTS,
|
|
281
|
+
theme,
|
|
282
|
+
}),
|
|
283
|
+
64,
|
|
284
|
+
12,
|
|
285
|
+
);
|
|
286
|
+
const contentRow = rows.find((row) =>
|
|
287
|
+
row.text.includes("*italic* **bold** [label](https://example.com)"),
|
|
207
288
|
);
|
|
208
289
|
|
|
209
290
|
// Assert
|
|
210
|
-
expect(
|
|
291
|
+
expect(contentRow).toBeDefined();
|
|
292
|
+
expect(contentRow?.italic[contentRow.text.indexOf("*italic*")]).toBe(true);
|
|
293
|
+
expect(contentRow?.bold[contentRow.text.indexOf("**bold**")]).toBe(true);
|
|
294
|
+
expect(contentRow?.fgColors[contentRow.text.indexOf("label")]).toBe(
|
|
295
|
+
theme.diffAdded ?? null,
|
|
296
|
+
);
|
|
297
|
+
expect(
|
|
298
|
+
contentRow?.fgColors[contentRow.text.indexOf("https://example.com")],
|
|
299
|
+
).toBe(theme.accentText ?? null);
|
|
300
|
+
expect(
|
|
301
|
+
contentRow?.underline[contentRow.text.indexOf("https://example.com")],
|
|
302
|
+
).toBe(true);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("renderAssistantMessage with adjacent text blocks preserves markdown structure across the block boundary", async () => {
|
|
306
|
+
// Arrange
|
|
307
|
+
const singleBlock = {
|
|
308
|
+
content: [fauxText("```js\nconst x = 1;\n```")],
|
|
309
|
+
};
|
|
310
|
+
const splitBlocks = {
|
|
311
|
+
content: [fauxText("```js\n"), fauxText("const x = 1;\n```")],
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// Act
|
|
315
|
+
const singleRows = await renderBufferRows(
|
|
316
|
+
renderAssistantMessage(singleBlock, RENDER_OPTS),
|
|
317
|
+
40,
|
|
318
|
+
12,
|
|
319
|
+
);
|
|
320
|
+
const splitRows = await renderBufferRows(
|
|
321
|
+
renderAssistantMessage(splitBlocks, RENDER_OPTS),
|
|
322
|
+
40,
|
|
323
|
+
12,
|
|
324
|
+
);
|
|
325
|
+
const singleHeight = measureRenderedHeight(
|
|
326
|
+
renderAssistantMessage(singleBlock, RENDER_OPTS),
|
|
327
|
+
40,
|
|
328
|
+
);
|
|
329
|
+
const splitHeight = measureRenderedHeight(
|
|
330
|
+
renderAssistantMessage(splitBlocks, RENDER_OPTS),
|
|
331
|
+
40,
|
|
332
|
+
);
|
|
333
|
+
const singleRow = singleRows.find((row) =>
|
|
334
|
+
row.text.includes("const x = 1;"),
|
|
335
|
+
);
|
|
336
|
+
const splitRow = splitRows.find((row) => row.text.includes("const x = 1;"));
|
|
337
|
+
|
|
338
|
+
// Assert
|
|
339
|
+
expect(singleRow).toBeDefined();
|
|
340
|
+
expect(splitRow).toBeDefined();
|
|
341
|
+
expect(singleHeight).toBe(splitHeight);
|
|
342
|
+
expect(singleRow?.fgColors[singleRow.text.indexOf("const")]).not.toBe(null);
|
|
343
|
+
expect(splitRow?.fgColors[splitRow.text.indexOf("const")]).toBe(
|
|
344
|
+
singleRow?.fgColors[singleRow.text.indexOf("const")],
|
|
345
|
+
);
|
|
211
346
|
});
|
|
212
347
|
|
|
213
348
|
test("buildConversationLogNodes with a pending shell result keeps the streamed call and result append-only", () => {
|
|
@@ -250,9 +385,9 @@ describe("ui/conversation", () => {
|
|
|
250
385
|
|
|
251
386
|
// Assert
|
|
252
387
|
expect(text).toContain("Working...");
|
|
253
|
-
expect(text.filter((line) => line === "
|
|
388
|
+
expect(text.filter((line) => line === "shell ->")).toHaveLength(1);
|
|
254
389
|
expect(text).toContain("echo hi");
|
|
255
|
-
expect(text).toContain("
|
|
390
|
+
expect(text).toContain("shell <-");
|
|
256
391
|
expect(text).toContain("partial output");
|
|
257
392
|
expect(text).not.toContain("Exit code: 0");
|
|
258
393
|
});
|
|
@@ -301,7 +436,7 @@ describe("ui/conversation", () => {
|
|
|
301
436
|
});
|
|
302
437
|
|
|
303
438
|
// Assert
|
|
304
|
-
expect(text).toContain("
|
|
439
|
+
expect(text).toContain("edit <-");
|
|
305
440
|
expect(text).toContain("~ src/app.ts");
|
|
306
441
|
expect(text).not.toContain("before");
|
|
307
442
|
expect(text).not.toContain("after");
|
|
@@ -513,7 +648,7 @@ describe("ui/conversation", () => {
|
|
|
513
648
|
expect(text).toContain("Thinking... 1 line.");
|
|
514
649
|
});
|
|
515
650
|
|
|
516
|
-
test("renderAssistantMessage for a shell tool call
|
|
651
|
+
test("renderAssistantMessage for a shell tool call renders an unbracketed header inside the pill", () => {
|
|
517
652
|
// Arrange
|
|
518
653
|
const assistant = {
|
|
519
654
|
content: [
|
|
@@ -522,12 +657,47 @@ describe("ui/conversation", () => {
|
|
|
522
657
|
};
|
|
523
658
|
|
|
524
659
|
// Act
|
|
525
|
-
const
|
|
660
|
+
const node = renderAssistantMessage(assistant, RENDER_OPTS);
|
|
661
|
+
const text = collectText(node);
|
|
526
662
|
|
|
527
663
|
// Assert
|
|
528
|
-
expect(text).toContain("
|
|
664
|
+
expect(text).toContain("shell ->");
|
|
665
|
+
expect(text).not.toContain("[shell ->]");
|
|
529
666
|
expect(text).toContain("echo hi");
|
|
530
667
|
expect(text).not.toContain('"command": "echo hi"');
|
|
668
|
+
|
|
669
|
+
expect(node?.type).toBe("vstack");
|
|
670
|
+
if (!node || node.type !== "vstack") {
|
|
671
|
+
throw new Error("Expected the assistant node to be a vstack");
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const toolBlock = node.children[0];
|
|
675
|
+
expect(toolBlock?.type).toBe("hstack");
|
|
676
|
+
if (!toolBlock || toolBlock.type !== "hstack") {
|
|
677
|
+
throw new Error("Expected the tool block to be an hstack");
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const contentColumn = toolBlock.children[1];
|
|
681
|
+
expect(contentColumn?.type).toBe("vstack");
|
|
682
|
+
if (!contentColumn || contentColumn.type !== "vstack") {
|
|
683
|
+
throw new Error("Expected the tool content column to be a vstack");
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const headerRow = contentColumn.children[0];
|
|
687
|
+
expect(headerRow?.type).toBe("hstack");
|
|
688
|
+
if (!headerRow || headerRow.type !== "hstack") {
|
|
689
|
+
throw new Error("Expected the tool header row to be an hstack");
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const headerPill = headerRow.children[0];
|
|
693
|
+
expect(headerPill?.type).toBe("hstack");
|
|
694
|
+
if (!headerPill || headerPill.type !== "hstack") {
|
|
695
|
+
throw new Error("Expected the tool header pill to be an hstack");
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
expect(headerPill.props.bgColor).toBe(DEFAULT_THEME.toolBorder);
|
|
699
|
+
expect(headerPill.props.padding).toEqual({ x: 1 });
|
|
700
|
+
expect(collectText(headerPill)).toEqual(["shell ->"]);
|
|
531
701
|
});
|
|
532
702
|
|
|
533
703
|
test("renderAssistantMessage for a shell tool call syntax-highlights bash tokens", async () => {
|
|
@@ -795,7 +965,7 @@ describe("ui/conversation", () => {
|
|
|
795
965
|
const text = collectText(renderAssistantMessage(assistant, RENDER_OPTS));
|
|
796
966
|
|
|
797
967
|
// Assert
|
|
798
|
-
expect(text).toContain("
|
|
968
|
+
expect(text).toContain("read image ->");
|
|
799
969
|
expect(text).toContain("assets/preview.png");
|
|
800
970
|
expect(text).not.toContain("{");
|
|
801
971
|
});
|
|
@@ -820,7 +990,7 @@ describe("ui/conversation", () => {
|
|
|
820
990
|
const text = collectText(renderAssistantMessage(assistant, RENDER_OPTS));
|
|
821
991
|
|
|
822
992
|
// Assert
|
|
823
|
-
expect(text).toContain("
|
|
993
|
+
expect(text).toContain("edit ->");
|
|
824
994
|
expect(text).toContain("src/file.ts");
|
|
825
995
|
expect(text).toContain("old line");
|
|
826
996
|
expect(text).toContain("new line");
|
|
@@ -883,7 +1053,7 @@ describe("ui/conversation", () => {
|
|
|
883
1053
|
);
|
|
884
1054
|
|
|
885
1055
|
// Assert
|
|
886
|
-
expect(text).toContain("
|
|
1056
|
+
expect(text).toContain("shell <-");
|
|
887
1057
|
expect(text).toContain("line 18");
|
|
888
1058
|
expect(text).toContain("line 25");
|
|
889
1059
|
expect(text).toContain("And 17 lines more");
|
|
@@ -891,6 +1061,37 @@ describe("ui/conversation", () => {
|
|
|
891
1061
|
expect(text).not.toContain("seq 1 25");
|
|
892
1062
|
});
|
|
893
1063
|
|
|
1064
|
+
test("renderToolResult for a wrapped shell preview keeps the summary directly below the visible tail", async () => {
|
|
1065
|
+
// Arrange
|
|
1066
|
+
const output = [
|
|
1067
|
+
"line 1",
|
|
1068
|
+
"line 2",
|
|
1069
|
+
"line 3",
|
|
1070
|
+
"line 4",
|
|
1071
|
+
"this is a very long wrapped line that will take more than six rendered rows in the preview width so it gets dropped entirely",
|
|
1072
|
+
"tail A",
|
|
1073
|
+
"tail B",
|
|
1074
|
+
].join("\n");
|
|
1075
|
+
|
|
1076
|
+
// Act
|
|
1077
|
+
const rows = await renderBufferRows(
|
|
1078
|
+
renderToolResult("shell", { command: "demo" }, output, false, {
|
|
1079
|
+
...RENDER_OPTS,
|
|
1080
|
+
previewWidth: 24,
|
|
1081
|
+
}),
|
|
1082
|
+
24,
|
|
1083
|
+
20,
|
|
1084
|
+
);
|
|
1085
|
+
const tailRowIndex = rows.findIndex((row) => row.text.includes("tail B"));
|
|
1086
|
+
const summaryRowIndex = rows.findIndex(
|
|
1087
|
+
(row) => row.text.includes("And ") && row.text.includes(" lines more"),
|
|
1088
|
+
);
|
|
1089
|
+
|
|
1090
|
+
// Assert
|
|
1091
|
+
expect(tailRowIndex).toBeGreaterThan(-1);
|
|
1092
|
+
expect(summaryRowIndex).toBe(tailRowIndex + 1);
|
|
1093
|
+
});
|
|
1094
|
+
|
|
894
1095
|
test("renderToolResult for shell output in verbose mode shows the full stored output", () => {
|
|
895
1096
|
// Arrange
|
|
896
1097
|
const output = Array.from({ length: 25 }, (_, i) => `line ${i + 1}`).join(
|
|
@@ -927,7 +1128,7 @@ describe("ui/conversation", () => {
|
|
|
927
1128
|
);
|
|
928
1129
|
|
|
929
1130
|
// Assert
|
|
930
|
-
expect(text).toContain("
|
|
1131
|
+
expect(text).toContain("shell <-");
|
|
931
1132
|
expect(text).toContain("exit 42");
|
|
932
1133
|
expect(text).toContain("boom");
|
|
933
1134
|
expect(text).not.toContain("Exit code: 42");
|
|
@@ -944,7 +1145,7 @@ describe("ui/conversation", () => {
|
|
|
944
1145
|
);
|
|
945
1146
|
|
|
946
1147
|
// Assert
|
|
947
|
-
expect(text).toContain("
|
|
1148
|
+
expect(text).toContain("read image <-");
|
|
948
1149
|
expect(text).toContain("diagram.png");
|
|
949
1150
|
expect(text).not.toContain("Read image.");
|
|
950
1151
|
});
|
|
@@ -993,7 +1194,7 @@ describe("ui/conversation", () => {
|
|
|
993
1194
|
);
|
|
994
1195
|
|
|
995
1196
|
// Assert
|
|
996
|
-
expect(previewText).toContain("
|
|
1197
|
+
expect(previewText).toContain("edit <-");
|
|
997
1198
|
expect(previewText).toContain("~ src/file.ts");
|
|
998
1199
|
expect(previewText).not.toContain("before");
|
|
999
1200
|
expect(previewText).not.toContain("after");
|
|
@@ -1026,7 +1227,7 @@ describe("ui/conversation", () => {
|
|
|
1026
1227
|
);
|
|
1027
1228
|
|
|
1028
1229
|
// Assert
|
|
1029
|
-
expect(text).toContain("
|
|
1230
|
+
expect(text).toContain("edit <-");
|
|
1030
1231
|
expect(text).toContain("error 18");
|
|
1031
1232
|
expect(text).toContain("error 25");
|
|
1032
1233
|
expect(text).toContain("And 17 lines more");
|
|
@@ -1049,7 +1250,7 @@ describe("ui/conversation", () => {
|
|
|
1049
1250
|
);
|
|
1050
1251
|
|
|
1051
1252
|
// Assert
|
|
1052
|
-
expect(text).toContain("
|
|
1253
|
+
expect(text).toContain("mcp/search <-");
|
|
1053
1254
|
expect(text).toContain("session persistence sqlite turn numbering");
|
|
1054
1255
|
expect(text).not.toContain('"query"');
|
|
1055
1256
|
});
|
package/src/ui/conversation.ts
CHANGED
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import {
|
|
12
|
-
Markdown,
|
|
13
12
|
SyntaxHighlight,
|
|
14
13
|
type SyntaxHighlightTheme,
|
|
15
14
|
} from "@cel-tui/components";
|
|
@@ -142,11 +141,15 @@ type ToolRenderLineKind =
|
|
|
142
141
|
| "summary"
|
|
143
142
|
| "error";
|
|
144
143
|
|
|
145
|
-
|
|
144
|
+
type SyntaxThemeVariant = "markdown" | "shell";
|
|
145
|
+
|
|
146
|
+
interface HighlightedBodySpec {
|
|
146
147
|
/** Full raw source content to syntax-highlight. */
|
|
147
148
|
text: string;
|
|
148
149
|
/** Registered lextide language id. */
|
|
149
150
|
language: string;
|
|
151
|
+
/** Semantic theme variant for this highlighted content. */
|
|
152
|
+
themeVariant: SyntaxThemeVariant;
|
|
150
153
|
}
|
|
151
154
|
|
|
152
155
|
interface ToolBlockSpec {
|
|
@@ -157,7 +160,7 @@ interface ToolBlockSpec {
|
|
|
157
160
|
/** Logical body lines for the tool block. */
|
|
158
161
|
bodyLines: readonly ToolRenderLine[];
|
|
159
162
|
/** Optional syntax-highlighted body rendered from the full unsplit source. */
|
|
160
|
-
highlightedBody?:
|
|
163
|
+
highlightedBody?: HighlightedBodySpec;
|
|
161
164
|
/** Whether `/verbose` preview rules apply to the body. */
|
|
162
165
|
previewBody: boolean;
|
|
163
166
|
}
|
|
@@ -222,16 +225,25 @@ function renderUserMessage(msg: UserMessage, theme: Theme): Node {
|
|
|
222
225
|
]);
|
|
223
226
|
}
|
|
224
227
|
|
|
225
|
-
/** Render a markdown block
|
|
226
|
-
function
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
}
|
|
231
|
-
return HStack({ padding: { x: 1 } }, [VStack({ flex: 1 }, [node])]);
|
|
232
|
-
});
|
|
228
|
+
/** Render a syntax-highlighted raw markdown block. */
|
|
229
|
+
function renderMarkdownTextBlock(content: string, theme: Theme): Node | null {
|
|
230
|
+
if (content === "") {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
233
|
|
|
234
|
-
|
|
234
|
+
const children = getHighlightedBodyLines(
|
|
235
|
+
{
|
|
236
|
+
text: content,
|
|
237
|
+
language: "markdown",
|
|
238
|
+
themeVariant: "markdown",
|
|
239
|
+
},
|
|
240
|
+
theme,
|
|
241
|
+
);
|
|
242
|
+
if (children.length === 0) {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return HStack({ padding: { x: 1 } }, [VStack({ flex: 1 }, children)]);
|
|
235
247
|
}
|
|
236
248
|
|
|
237
249
|
function getAssistantErrorMessage(
|
|
@@ -270,12 +282,38 @@ function renderThinkingBlock(
|
|
|
270
282
|
]);
|
|
271
283
|
}
|
|
272
284
|
|
|
285
|
+
function coalesceAdjacentTextBlocks(
|
|
286
|
+
content: AssistantMessage["content"],
|
|
287
|
+
): AssistantMessage["content"] {
|
|
288
|
+
const coalesced: AssistantMessage["content"] = [];
|
|
289
|
+
|
|
290
|
+
for (const block of content) {
|
|
291
|
+
if (block.type !== "text") {
|
|
292
|
+
coalesced.push(block);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const previous = coalesced.at(-1);
|
|
297
|
+
if (previous?.type === "text") {
|
|
298
|
+
coalesced[coalesced.length - 1] = {
|
|
299
|
+
...previous,
|
|
300
|
+
text: previous.text + block.text,
|
|
301
|
+
};
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
coalesced.push({ ...block });
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return coalesced;
|
|
309
|
+
}
|
|
310
|
+
|
|
273
311
|
function renderAssistantContentBlock(
|
|
274
312
|
block: AssistantMessage["content"][number],
|
|
275
313
|
opts: ConversationRenderOpts,
|
|
276
314
|
): Node | null {
|
|
277
315
|
if (block.type === "text" && block.text) {
|
|
278
|
-
return
|
|
316
|
+
return renderMarkdownTextBlock(block.text, opts.theme);
|
|
279
317
|
}
|
|
280
318
|
if (block.type === "thinking" && block.thinking) {
|
|
281
319
|
return renderThinkingBlock(block.thinking, opts);
|
|
@@ -297,7 +335,7 @@ export function renderAssistantMessage(
|
|
|
297
335
|
assistant: AssistantMessage | AssistantRenderState,
|
|
298
336
|
opts: ConversationRenderOpts,
|
|
299
337
|
): Node | null {
|
|
300
|
-
const children = assistant.content
|
|
338
|
+
const children = coalesceAdjacentTextBlocks(assistant.content)
|
|
301
339
|
.map((block) => {
|
|
302
340
|
return renderAssistantContentBlock(block, opts);
|
|
303
341
|
})
|
|
@@ -388,7 +426,13 @@ type SyntaxThemeTokenColor = NonNullable<
|
|
|
388
426
|
const graphemeSegmenter = new Intl.Segmenter(undefined, {
|
|
389
427
|
granularity: "grapheme",
|
|
390
428
|
});
|
|
391
|
-
const
|
|
429
|
+
const syntaxThemeCache: Record<
|
|
430
|
+
SyntaxThemeVariant,
|
|
431
|
+
WeakMap<Theme, SyntaxThemeRegistration>
|
|
432
|
+
> = {
|
|
433
|
+
markdown: new WeakMap(),
|
|
434
|
+
shell: new WeakMap(),
|
|
435
|
+
};
|
|
392
436
|
|
|
393
437
|
function colorToHex(color: Color | undefined): string | undefined {
|
|
394
438
|
return color ? ANSI_COLOR_HEX[color] : undefined;
|
|
@@ -414,14 +458,11 @@ function pushSyntaxTokenColor(
|
|
|
414
458
|
});
|
|
415
459
|
}
|
|
416
460
|
|
|
417
|
-
function
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
const tokenColors: SyntaxThemeTokenColor[] = [];
|
|
424
|
-
pushSyntaxTokenColor(tokenColors, ["comment", "quote"], theme.mutedText);
|
|
461
|
+
function pushShellSyntaxTokenColors(
|
|
462
|
+
tokenColors: SyntaxThemeTokenColor[],
|
|
463
|
+
theme: Theme,
|
|
464
|
+
): void {
|
|
465
|
+
pushSyntaxTokenColor(tokenColors, "comment", theme.mutedText);
|
|
425
466
|
pushSyntaxTokenColor(
|
|
426
467
|
tokenColors,
|
|
427
468
|
["keyword", "storage"],
|
|
@@ -472,12 +513,47 @@ function getShellSyntaxTheme(theme: Theme): SyntaxThemeRegistration {
|
|
|
472
513
|
["entity.name.tag", "name", "tag"],
|
|
473
514
|
theme.accentText,
|
|
474
515
|
);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function pushMarkdownSyntaxTokenColors(
|
|
519
|
+
tokenColors: SyntaxThemeTokenColor[],
|
|
520
|
+
theme: Theme,
|
|
521
|
+
): void {
|
|
522
|
+
pushSyntaxTokenColor(tokenColors, "quote", theme.mutedText, "italic");
|
|
523
|
+
pushSyntaxTokenColor(tokenColors, "section", theme.accentText, "bold");
|
|
524
|
+
pushSyntaxTokenColor(
|
|
525
|
+
tokenColors,
|
|
526
|
+
"bullet",
|
|
527
|
+
theme.secondaryAccentText,
|
|
528
|
+
"bold",
|
|
529
|
+
);
|
|
530
|
+
pushSyntaxTokenColor(tokenColors, ["code", "string"], theme.diffAdded);
|
|
531
|
+
pushSyntaxTokenColor(tokenColors, "link", theme.accentText, "underline");
|
|
532
|
+
pushSyntaxTokenColor(tokenColors, "strong", undefined, "bold");
|
|
533
|
+
pushSyntaxTokenColor(tokenColors, "emphasis", undefined, "italic");
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function getSyntaxTheme(
|
|
537
|
+
theme: Theme,
|
|
538
|
+
variant: SyntaxThemeVariant,
|
|
539
|
+
): SyntaxThemeRegistration {
|
|
540
|
+
const cache = syntaxThemeCache[variant];
|
|
541
|
+
const cached = cache.get(theme);
|
|
542
|
+
if (cached) {
|
|
543
|
+
return cached;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const tokenColors: SyntaxThemeTokenColor[] = [];
|
|
547
|
+
if (variant === "shell") {
|
|
548
|
+
pushShellSyntaxTokenColors(tokenColors, theme);
|
|
549
|
+
} else {
|
|
550
|
+
pushMarkdownSyntaxTokenColors(tokenColors, theme);
|
|
551
|
+
}
|
|
475
552
|
|
|
476
553
|
const syntaxTheme: SyntaxThemeRegistration = {
|
|
477
|
-
...(colorToHex(theme.toolText) ? { fg: colorToHex(theme.toolText) } : {}),
|
|
478
554
|
tokenColors,
|
|
479
555
|
};
|
|
480
|
-
|
|
556
|
+
cache.set(theme, syntaxTheme);
|
|
481
557
|
return syntaxTheme;
|
|
482
558
|
}
|
|
483
559
|
|
|
@@ -508,7 +584,7 @@ function splitHighlightedTextNode(
|
|
|
508
584
|
return children;
|
|
509
585
|
}
|
|
510
586
|
|
|
511
|
-
function
|
|
587
|
+
function normalizeHighlightedLine(line: Node): Node {
|
|
512
588
|
if (line.type !== "hstack") {
|
|
513
589
|
return line;
|
|
514
590
|
}
|
|
@@ -519,8 +595,8 @@ function normalizeHighlightedToolLine(line: Node): Node {
|
|
|
519
595
|
return HStack(line.props, children);
|
|
520
596
|
}
|
|
521
597
|
|
|
522
|
-
function
|
|
523
|
-
spec:
|
|
598
|
+
function getHighlightedBodyLines(
|
|
599
|
+
spec: HighlightedBodySpec,
|
|
524
600
|
theme: Theme,
|
|
525
601
|
): Node[] {
|
|
526
602
|
if (spec.text === "") {
|
|
@@ -528,9 +604,9 @@ function getHighlightedToolBodyLines(
|
|
|
528
604
|
}
|
|
529
605
|
|
|
530
606
|
const highlighted = SyntaxHighlight(spec.text, spec.language, {
|
|
531
|
-
theme:
|
|
607
|
+
theme: getSyntaxTheme(theme, spec.themeVariant),
|
|
532
608
|
});
|
|
533
|
-
return highlighted.children.map((line) =>
|
|
609
|
+
return highlighted.children.map((line) => normalizeHighlightedLine(line));
|
|
534
610
|
}
|
|
535
611
|
|
|
536
612
|
/** Render a single styled text node for a tool line. */
|
|
@@ -606,7 +682,7 @@ function renderToolHeaderPill(
|
|
|
606
682
|
theme: Theme,
|
|
607
683
|
): Node {
|
|
608
684
|
return HStack({ bgColor: theme.toolBorder, padding: { x: 1 } }, [
|
|
609
|
-
Text(
|
|
685
|
+
Text(`${getToolHeaderName(toolName)} ${direction}`, {
|
|
610
686
|
fgColor: getToolHeaderColor(toolName, theme),
|
|
611
687
|
bold: true,
|
|
612
688
|
}),
|
|
@@ -644,6 +720,7 @@ function renderToolBodyFromNodes(
|
|
|
644
720
|
body: VStack(
|
|
645
721
|
{
|
|
646
722
|
height: UI_TOOL_PREVIEW_ROWS,
|
|
723
|
+
justifyContent: "end",
|
|
647
724
|
},
|
|
648
725
|
[...previewLines],
|
|
649
726
|
),
|
|
@@ -663,7 +740,7 @@ function renderToolBody(
|
|
|
663
740
|
): { body: Node | null; summary?: ToolRenderLine } {
|
|
664
741
|
if (spec.highlightedBody) {
|
|
665
742
|
return renderToolBodyFromNodes(
|
|
666
|
-
|
|
743
|
+
getHighlightedBodyLines(spec.highlightedBody, opts.theme),
|
|
667
744
|
spec.previewBody,
|
|
668
745
|
opts,
|
|
669
746
|
);
|
|
@@ -720,6 +797,7 @@ function buildShellToolCallSpec(args: Record<string, unknown>): ToolBlockSpec {
|
|
|
720
797
|
: {
|
|
721
798
|
text: command,
|
|
722
799
|
language: "bash",
|
|
800
|
+
themeVariant: "shell",
|
|
723
801
|
},
|
|
724
802
|
previewBody: true,
|
|
725
803
|
};
|
package/src/ui/help.test.ts
CHANGED
|
@@ -24,7 +24,7 @@ describe("ui/help", () => {
|
|
|
24
24
|
);
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
-
test("buildHelpText
|
|
27
|
+
test("buildHelpText describes the current Escape behavior", () => {
|
|
28
28
|
const helpState: HelpRenderState = {
|
|
29
29
|
providers: new Map(),
|
|
30
30
|
model: null,
|
|
@@ -37,8 +37,13 @@ describe("ui/help", () => {
|
|
|
37
37
|
|
|
38
38
|
const text = buildHelpText(helpState);
|
|
39
39
|
|
|
40
|
-
expect(text).toContain(
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
expect(text).toContain(
|
|
41
|
+
"Escape closes the current overlay and returns focus to the input",
|
|
42
|
+
);
|
|
43
|
+
expect(text).toContain(
|
|
44
|
+
"With no overlay open, Escape interrupts the current turn",
|
|
45
|
+
);
|
|
46
|
+
expect(text).toContain("Otherwise Escape does nothing");
|
|
47
|
+
expect(text).not.toContain("Escape blurs the input first");
|
|
43
48
|
});
|
|
44
49
|
});
|