opencode-miniterm 1.0.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/.prettierrc +16 -0
- package/AGENTS.md +243 -0
- package/README.md +5 -0
- package/bun.lock +108 -0
- package/package.json +34 -0
- package/src/ansi.ts +22 -0
- package/src/commands/agents.ts +178 -0
- package/src/commands/debug.ts +74 -0
- package/src/commands/details.ts +17 -0
- package/src/commands/diff.ts +155 -0
- package/src/commands/exit.ts +17 -0
- package/src/commands/init.ts +35 -0
- package/src/commands/kill.ts +33 -0
- package/src/commands/log.ts +24 -0
- package/src/commands/models.ts +218 -0
- package/src/commands/new.ts +42 -0
- package/src/commands/page.ts +78 -0
- package/src/commands/quit.ts +17 -0
- package/src/commands/run.ts +34 -0
- package/src/commands/sessions.ts +257 -0
- package/src/commands/undo.ts +65 -0
- package/src/config.ts +56 -0
- package/src/index.ts +990 -0
- package/src/render.ts +320 -0
- package/src/types.ts +11 -0
- package/test/render.test.ts +390 -0
- package/test/test.ts +115 -0
- package/tsconfig.json +29 -0
package/src/render.ts
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import type { OpencodeClient } from "@opencode-ai/sdk";
|
|
2
|
+
import { gfm, parse, renderToConsole } from "allmark";
|
|
3
|
+
import * as ansi from "./ansi";
|
|
4
|
+
import { config } from "./config";
|
|
5
|
+
import type { State } from "./index";
|
|
6
|
+
|
|
7
|
+
export function render(state: State, details = false): void {
|
|
8
|
+
let output = "";
|
|
9
|
+
|
|
10
|
+
if (details) {
|
|
11
|
+
output += "📋 Detailed output from the last run:\n\n";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Only show the last (i.e. active) thinking part
|
|
15
|
+
// Only show the last (i.e. active) tool use
|
|
16
|
+
// Only show the last files part between parts
|
|
17
|
+
let foundPart = false;
|
|
18
|
+
let foundFiles = false;
|
|
19
|
+
let foundTodo = false;
|
|
20
|
+
for (let i = state.accumulatedResponse.length - 1; i >= 0; i--) {
|
|
21
|
+
const part = state.accumulatedResponse[i];
|
|
22
|
+
if (!part) continue;
|
|
23
|
+
if (details) {
|
|
24
|
+
part.active = true;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (part.title === "thinking") {
|
|
29
|
+
part.active = !foundPart;
|
|
30
|
+
foundPart = true;
|
|
31
|
+
} else if (part.title === "tool") {
|
|
32
|
+
part.active = !foundPart;
|
|
33
|
+
} else if (part.title === "files") {
|
|
34
|
+
part.active = !foundFiles;
|
|
35
|
+
foundFiles = true;
|
|
36
|
+
} else if (part.title === "todo") {
|
|
37
|
+
part.active = !foundTodo;
|
|
38
|
+
foundTodo = true;
|
|
39
|
+
} else {
|
|
40
|
+
foundPart = true;
|
|
41
|
+
part.active = true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (let i = 0; i < state.accumulatedResponse.length; i++) {
|
|
46
|
+
const part = state.accumulatedResponse[i];
|
|
47
|
+
if (!part || !part.active) continue;
|
|
48
|
+
if (!part.text.trim()) continue;
|
|
49
|
+
|
|
50
|
+
if (part.title === "thinking") {
|
|
51
|
+
const partText = details ? part.text.trimStart() : lastThinkingLines(part.text.trimStart());
|
|
52
|
+
output += `💭 ${ansi.BRIGHT_BLACK}${partText}${ansi.RESET}\n\n`;
|
|
53
|
+
} else if (part.title === "response") {
|
|
54
|
+
const doc = parse(part.text.trimStart(), gfm);
|
|
55
|
+
const partText = renderToConsole(doc);
|
|
56
|
+
output += `💬 ${partText}\n\n`;
|
|
57
|
+
} else if (part.title === "tool") {
|
|
58
|
+
output += part.text + "\n\n";
|
|
59
|
+
} else if (part.title === "files") {
|
|
60
|
+
output += part.text + "\n\n";
|
|
61
|
+
} else if (part.title === "todo") {
|
|
62
|
+
output += part.text + "\n\n";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (output) {
|
|
67
|
+
const lines = wrapText(output, process.stdout.columns || 80);
|
|
68
|
+
|
|
69
|
+
// Clear lines that have changed
|
|
70
|
+
let firstDiff = state.renderedLines.length;
|
|
71
|
+
for (let i = 0; i < Math.max(state.renderedLines.length, lines.length); i++) {
|
|
72
|
+
if (state.renderedLines[i] !== lines[i]) {
|
|
73
|
+
firstDiff = i;
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
let linesToClear = state.renderedLines.length - firstDiff;
|
|
78
|
+
clearRenderedLines(state, linesToClear);
|
|
79
|
+
|
|
80
|
+
// Write new lines
|
|
81
|
+
for (let i = firstDiff; i < lines.length; i++) {
|
|
82
|
+
state.write(lines[i]!);
|
|
83
|
+
state.write("\n");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
state.renderedLines = lines;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function lastThinkingLines(text: string): string {
|
|
91
|
+
const consoleWidth = process.stdout.columns || 80;
|
|
92
|
+
const strippedText = ansi.stripAnsiCodes(text);
|
|
93
|
+
|
|
94
|
+
let lineCount = 0;
|
|
95
|
+
let col = 0;
|
|
96
|
+
const lineBreaks: number[] = [0];
|
|
97
|
+
|
|
98
|
+
for (let i = 0; i < strippedText.length; i++) {
|
|
99
|
+
const char = strippedText[i];
|
|
100
|
+
|
|
101
|
+
if (char === "\n") {
|
|
102
|
+
lineCount++;
|
|
103
|
+
col = 0;
|
|
104
|
+
lineBreaks.push(i + 1);
|
|
105
|
+
} else if (char === "\r") {
|
|
106
|
+
continue;
|
|
107
|
+
} else {
|
|
108
|
+
col++;
|
|
109
|
+
if (col >= consoleWidth) {
|
|
110
|
+
lineCount++;
|
|
111
|
+
col = 0;
|
|
112
|
+
lineBreaks.push(i);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (col > 0) {
|
|
118
|
+
lineCount++;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const startIndex = lineBreaks[Math.max(0, lineBreaks.length - 10)] || 0;
|
|
122
|
+
return text.slice(startIndex);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function clearRenderedLines(state: State, linesToClear: number): void {
|
|
126
|
+
if (linesToClear > 0) {
|
|
127
|
+
state.write(`${ansi.CURSOR_UP(linesToClear)}\x1b[J`);
|
|
128
|
+
}
|
|
129
|
+
state.write(ansi.CURSOR_HOME);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function wrapText(text: string, width: number): string[] {
|
|
133
|
+
const lines: string[] = [];
|
|
134
|
+
let currentLine = "";
|
|
135
|
+
let visibleLength = 0;
|
|
136
|
+
let i = 0;
|
|
137
|
+
|
|
138
|
+
const pushLine = () => {
|
|
139
|
+
lines.push(currentLine);
|
|
140
|
+
currentLine = "";
|
|
141
|
+
visibleLength = 0;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const addWord = (word: string, wordVisibleLength: number) => {
|
|
145
|
+
if (!word || wordVisibleLength === 0) return;
|
|
146
|
+
|
|
147
|
+
const wouldFit =
|
|
148
|
+
visibleLength === 0
|
|
149
|
+
? wordVisibleLength <= width
|
|
150
|
+
: visibleLength + 1 + wordVisibleLength <= width;
|
|
151
|
+
|
|
152
|
+
if (wouldFit) {
|
|
153
|
+
if (visibleLength > 0) {
|
|
154
|
+
currentLine += " ";
|
|
155
|
+
visibleLength++;
|
|
156
|
+
}
|
|
157
|
+
currentLine += word;
|
|
158
|
+
visibleLength += wordVisibleLength;
|
|
159
|
+
} else if (visibleLength > 0) {
|
|
160
|
+
pushLine();
|
|
161
|
+
currentLine = word;
|
|
162
|
+
visibleLength = wordVisibleLength;
|
|
163
|
+
} else if (wordVisibleLength <= width) {
|
|
164
|
+
currentLine = word;
|
|
165
|
+
visibleLength = wordVisibleLength;
|
|
166
|
+
} else {
|
|
167
|
+
const wordWidth = width;
|
|
168
|
+
for (let w = 0; w < word.length; ) {
|
|
169
|
+
let segment = "";
|
|
170
|
+
let segmentVisible = 0;
|
|
171
|
+
let segmentStart = w;
|
|
172
|
+
|
|
173
|
+
while (w < word.length && segmentVisible < wordWidth) {
|
|
174
|
+
const char = word[w];
|
|
175
|
+
if (char === "\x1b" && word[w + 1] === "[") {
|
|
176
|
+
const ansiMatch = word.slice(w).match(ansi.ANSI_CODE_PATTERN);
|
|
177
|
+
if (ansiMatch) {
|
|
178
|
+
segment += ansiMatch[0];
|
|
179
|
+
w += ansiMatch[0].length;
|
|
180
|
+
} else {
|
|
181
|
+
segment += char;
|
|
182
|
+
w++;
|
|
183
|
+
}
|
|
184
|
+
} else {
|
|
185
|
+
segment += char;
|
|
186
|
+
segmentVisible++;
|
|
187
|
+
w++;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (segment) {
|
|
192
|
+
if (currentLine) {
|
|
193
|
+
pushLine();
|
|
194
|
+
}
|
|
195
|
+
currentLine = segment;
|
|
196
|
+
visibleLength = segmentVisible;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
while (i < text.length) {
|
|
203
|
+
const char = text[i];
|
|
204
|
+
|
|
205
|
+
if (char === "\n") {
|
|
206
|
+
pushLine();
|
|
207
|
+
i++;
|
|
208
|
+
} else if (char === "\r") {
|
|
209
|
+
i++;
|
|
210
|
+
} else if (char === " " || char === "\t") {
|
|
211
|
+
i++;
|
|
212
|
+
} else {
|
|
213
|
+
let word = "";
|
|
214
|
+
let wordVisibleLength = 0;
|
|
215
|
+
|
|
216
|
+
while (i < text.length) {
|
|
217
|
+
const char = text[i];
|
|
218
|
+
if (char === "\n" || char === "\r" || char === " " || char === "\t") {
|
|
219
|
+
break;
|
|
220
|
+
} else if (char === "\x1b" && text[i + 1] === "[") {
|
|
221
|
+
const ansiMatch = text.slice(i).match(ansi.ANSI_CODE_PATTERN);
|
|
222
|
+
if (ansiMatch) {
|
|
223
|
+
word += ansiMatch[0];
|
|
224
|
+
i += ansiMatch[0].length;
|
|
225
|
+
} else {
|
|
226
|
+
word += char;
|
|
227
|
+
i++;
|
|
228
|
+
}
|
|
229
|
+
} else {
|
|
230
|
+
word += char;
|
|
231
|
+
wordVisibleLength++;
|
|
232
|
+
i++;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
addWord(word, wordVisibleLength);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (currentLine || lines.length === 0) {
|
|
241
|
+
pushLine();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return lines;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function writePrompt(): void {
|
|
248
|
+
stopAnimation();
|
|
249
|
+
process.stdout.write(ansi.CURSOR_SHOW);
|
|
250
|
+
process.stdout.write(`${ansi.BOLD_MAGENTA}# ${ansi.RESET}`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const ANIMATION_CHARS = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇"];
|
|
254
|
+
let animationInterval: ReturnType<typeof setInterval> | null = null;
|
|
255
|
+
|
|
256
|
+
export function startAnimation(): void {
|
|
257
|
+
if (animationInterval) return;
|
|
258
|
+
|
|
259
|
+
let index = 0;
|
|
260
|
+
animationInterval = setInterval(() => {
|
|
261
|
+
process.stdout.write(`\r${ansi.BOLD_MAGENTA}`);
|
|
262
|
+
process.stdout.write(`${ANIMATION_CHARS[index]}${ansi.RESET}`);
|
|
263
|
+
index = (index + 1) % ANIMATION_CHARS.length;
|
|
264
|
+
}, 100);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function stopAnimation(): void {
|
|
268
|
+
if (animationInterval) {
|
|
269
|
+
clearInterval(animationInterval);
|
|
270
|
+
animationInterval = null;
|
|
271
|
+
}
|
|
272
|
+
process.stdout.write(`\r${ansi.CLEAR_LINE}`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export async function getActiveDisplay(client: OpencodeClient): Promise<string> {
|
|
276
|
+
let agentName = "";
|
|
277
|
+
let providerName = "";
|
|
278
|
+
let modelName = "";
|
|
279
|
+
try {
|
|
280
|
+
const [agentsResult, providersResult] = await Promise.all([
|
|
281
|
+
client.app.agents(),
|
|
282
|
+
client.config.providers(),
|
|
283
|
+
]);
|
|
284
|
+
if (!agentsResult.error) {
|
|
285
|
+
const agents = agentsResult.data || [];
|
|
286
|
+
const agent = agents.find((a) => a.name === config.agentID);
|
|
287
|
+
if (agent) {
|
|
288
|
+
agentName = agent.name.substring(0, 1).toUpperCase() + agent.name.substring(1);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (!providersResult.error) {
|
|
292
|
+
const providers = providersResult.data?.providers || [];
|
|
293
|
+
for (const provider of providers) {
|
|
294
|
+
const models = Object.values(provider.models || {});
|
|
295
|
+
for (const model of models) {
|
|
296
|
+
if (provider.id === config.providerID && model.id === config.modelID) {
|
|
297
|
+
providerName = provider.name;
|
|
298
|
+
modelName = model.name || model.id;
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (providerName) break;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
} catch (error) {}
|
|
306
|
+
|
|
307
|
+
const parts: string[] = [];
|
|
308
|
+
if (agentName) {
|
|
309
|
+
parts.push(`${ansi.CYAN}${agentName}${ansi.RESET}`);
|
|
310
|
+
}
|
|
311
|
+
if (modelName) {
|
|
312
|
+
let modelPart = `${ansi.BRIGHT_WHITE}${modelName}${ansi.RESET}`;
|
|
313
|
+
if (providerName) {
|
|
314
|
+
modelPart += ` ${ansi.BRIGHT_BLACK}(${providerName})${ansi.RESET}`;
|
|
315
|
+
}
|
|
316
|
+
parts.push(modelPart);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return parts.join(" ");
|
|
320
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { OpencodeClient } from "@opencode-ai/sdk";
|
|
2
|
+
import type { Key } from "node:readline";
|
|
3
|
+
import type { State } from "./index";
|
|
4
|
+
|
|
5
|
+
export interface Command {
|
|
6
|
+
name: string;
|
|
7
|
+
description: string;
|
|
8
|
+
run: (client: OpencodeClient, state: State, input?: string) => Promise<void> | void;
|
|
9
|
+
handleKey?: (client: OpencodeClient, key: Key, input?: string) => Promise<void> | void;
|
|
10
|
+
running: boolean;
|
|
11
|
+
}
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "bun:test";
|
|
2
|
+
import { type State } from "../src";
|
|
3
|
+
import { render, wrapText } from "../src/render";
|
|
4
|
+
|
|
5
|
+
describe("render", () => {
|
|
6
|
+
const createMockState = (overrides?: Partial<State>): State => ({
|
|
7
|
+
sessionID: "",
|
|
8
|
+
renderedLines: [],
|
|
9
|
+
accumulatedResponse: [],
|
|
10
|
+
allEvents: [],
|
|
11
|
+
write: vi.fn(),
|
|
12
|
+
lastFileAfter: new Map(),
|
|
13
|
+
...overrides,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("clearRenderedLines", () => {
|
|
17
|
+
it("should not write escape sequence when renderedLines is empty", () => {
|
|
18
|
+
const write = vi.fn();
|
|
19
|
+
const state = createMockState({ renderedLines: [], write });
|
|
20
|
+
|
|
21
|
+
render(state);
|
|
22
|
+
|
|
23
|
+
expect(write).not.toHaveBeenCalled();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("should write escape sequence to clear lines when renderedLines has content", () => {
|
|
27
|
+
const write = vi.fn();
|
|
28
|
+
const state = createMockState({
|
|
29
|
+
renderedLines: ["line1", "line2", "line3", "line4", "line5"],
|
|
30
|
+
accumulatedResponse: [{ key: "xxx", title: "response", text: "new content" }],
|
|
31
|
+
write,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
render(state);
|
|
35
|
+
|
|
36
|
+
expect(write).toHaveBeenCalledWith("\x1b[5A\x1b[J");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("should clear previous accumulated parts", () => {
|
|
40
|
+
const write = vi.fn();
|
|
41
|
+
let state = createMockState({ renderedLines: [], accumulatedResponse: [], write });
|
|
42
|
+
|
|
43
|
+
state.accumulatedResponse.push({ key: "xxx", title: "thinking", text: "gotta do the thing" });
|
|
44
|
+
state.accumulatedResponse.push({
|
|
45
|
+
key: "xxx",
|
|
46
|
+
title: "thinking",
|
|
47
|
+
text: "now i know how to do it",
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
render(state);
|
|
51
|
+
|
|
52
|
+
const firstOutput = write.mock.calls.map((c) => c[0]).join("");
|
|
53
|
+
expect(firstOutput).toContain("now i know how to do it");
|
|
54
|
+
|
|
55
|
+
write.mockClear();
|
|
56
|
+
|
|
57
|
+
state.accumulatedResponse.push({ key: "xxx", title: "response", text: "i've done it" });
|
|
58
|
+
|
|
59
|
+
render(state);
|
|
60
|
+
|
|
61
|
+
const calls = write.mock.calls.map((c) => c[0]);
|
|
62
|
+
expect(calls.some((c) => c.includes("\u001B[2A"))).toBe(true);
|
|
63
|
+
const outputCall = calls.find((c) => c.includes("i've done it"));
|
|
64
|
+
expect(outputCall).toContain("💬");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("thinking parts", () => {
|
|
69
|
+
it("should render thinking part with thinking indicator and gray text", () => {
|
|
70
|
+
const write = vi.fn();
|
|
71
|
+
const state = createMockState({
|
|
72
|
+
accumulatedResponse: [{ key: "xxx", title: "thinking", text: "分析问题" }],
|
|
73
|
+
write,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
render(state);
|
|
77
|
+
|
|
78
|
+
const output = write.mock.calls.map((c) => c[0]).join("");
|
|
79
|
+
expect(output).toContain("💭");
|
|
80
|
+
expect(output).toContain("分析问题");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("should only show thinking indicator for last thinking part", () => {
|
|
84
|
+
const write = vi.fn();
|
|
85
|
+
const state = createMockState({
|
|
86
|
+
accumulatedResponse: [
|
|
87
|
+
{ key: "xxx", title: "thinking", text: "first" },
|
|
88
|
+
{ key: "xxx", title: "thinking", text: "second" },
|
|
89
|
+
],
|
|
90
|
+
write,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
render(state);
|
|
94
|
+
|
|
95
|
+
const output = write.mock.calls.map((c) => c[0]).join("");
|
|
96
|
+
expect(output).toContain("💭");
|
|
97
|
+
expect(output).toContain("second");
|
|
98
|
+
expect(output).not.toMatch(/first.*Thinking/);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("should skip parts with empty text", () => {
|
|
102
|
+
const write = vi.fn();
|
|
103
|
+
const state = createMockState({
|
|
104
|
+
accumulatedResponse: [{ key: "xxx", title: "thinking", text: "" }],
|
|
105
|
+
write,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
render(state);
|
|
109
|
+
|
|
110
|
+
expect(write).not.toHaveBeenCalled();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("should skip null parts", () => {
|
|
114
|
+
const write = vi.fn();
|
|
115
|
+
const state = createMockState({
|
|
116
|
+
accumulatedResponse: [null as any, { key: "xxx", title: "response", text: "test" }],
|
|
117
|
+
write,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
render(state);
|
|
121
|
+
|
|
122
|
+
expect(write).toHaveBeenCalled();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("response parts", () => {
|
|
127
|
+
it("should render response part with response indicator", () => {
|
|
128
|
+
const write = vi.fn();
|
|
129
|
+
const state = createMockState({
|
|
130
|
+
accumulatedResponse: [{ key: "xxx", title: "response", text: "Hello world" }],
|
|
131
|
+
write,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
render(state);
|
|
135
|
+
|
|
136
|
+
const output = write.mock.calls.map((c) => c[0]).join("");
|
|
137
|
+
expect(output).toContain("💬");
|
|
138
|
+
expect(output).toContain("Hello world");
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("tool parts", () => {
|
|
143
|
+
it("should render tool part without indicator", () => {
|
|
144
|
+
const write = vi.fn();
|
|
145
|
+
const state = createMockState({
|
|
146
|
+
accumulatedResponse: [{ key: "xxx", title: "tool", text: "🔧 bash: ls -la" }],
|
|
147
|
+
write,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
render(state);
|
|
151
|
+
|
|
152
|
+
const output = write.mock.calls.map((c) => c[0]).join("");
|
|
153
|
+
expect(output).toContain("🔧 bash: ls -la");
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("files parts", () => {
|
|
158
|
+
it("should render files part without indicator", () => {
|
|
159
|
+
const write = vi.fn();
|
|
160
|
+
const state = createMockState({
|
|
161
|
+
accumulatedResponse: [{ key: "xxx", title: "files", text: "src/index.ts" }],
|
|
162
|
+
write,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
render(state);
|
|
166
|
+
|
|
167
|
+
const output = write.mock.calls.map((c) => c[0]).join("");
|
|
168
|
+
expect(output).toContain("src/index.ts");
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("line counting", () => {
|
|
173
|
+
it("should count lines correctly for multiline output", () => {
|
|
174
|
+
const write = vi.fn();
|
|
175
|
+
const state = createMockState({
|
|
176
|
+
accumulatedResponse: [{ key: "xxx", title: "response", text: "line1\nline2\nline3" }],
|
|
177
|
+
write,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
render(state);
|
|
181
|
+
|
|
182
|
+
expect(state.renderedLines.length).toBe(4);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("should set renderedLines to empty when output is empty", () => {
|
|
186
|
+
const state = createMockState({
|
|
187
|
+
accumulatedResponse: [],
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
render(state);
|
|
191
|
+
|
|
192
|
+
expect(state.renderedLines.length).toBe(0);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("should count lines including newlines from part formatting", () => {
|
|
196
|
+
const write = vi.fn();
|
|
197
|
+
const state = createMockState({
|
|
198
|
+
accumulatedResponse: [{ key: "xxx", title: "response", text: "A" }],
|
|
199
|
+
write,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
render(state);
|
|
203
|
+
|
|
204
|
+
expect(state.renderedLines.length).toBe(2);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe("empty state", () => {
|
|
209
|
+
it("should not write anything when accumulatedResponse is empty", () => {
|
|
210
|
+
const write = vi.fn();
|
|
211
|
+
const state = createMockState({ write });
|
|
212
|
+
|
|
213
|
+
render(state);
|
|
214
|
+
|
|
215
|
+
expect(write).not.toHaveBeenCalled();
|
|
216
|
+
expect(state.renderedLines.length).toBe(0);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("should not write anything when all parts have empty text", () => {
|
|
220
|
+
const write = vi.fn();
|
|
221
|
+
const state = createMockState({
|
|
222
|
+
accumulatedResponse: [
|
|
223
|
+
{ key: "xxx", title: "thinking", text: "" },
|
|
224
|
+
{ key: "xxx", title: "response", text: "" },
|
|
225
|
+
],
|
|
226
|
+
write,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
render(state);
|
|
230
|
+
|
|
231
|
+
expect(write).not.toHaveBeenCalled();
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe("multiple parts", () => {
|
|
236
|
+
it("should render multiple parts in order", () => {
|
|
237
|
+
const write = vi.fn();
|
|
238
|
+
const state = createMockState({
|
|
239
|
+
accumulatedResponse: [
|
|
240
|
+
{ key: "xxx", title: "thinking", text: "分析中" },
|
|
241
|
+
{ key: "xxx", title: "tool", text: "🔧 bash: npm test" },
|
|
242
|
+
{ key: "xxx", title: "response", text: "Test results: 5 passed" },
|
|
243
|
+
],
|
|
244
|
+
write,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
render(state);
|
|
248
|
+
|
|
249
|
+
const output = write.mock.calls.map((c) => c[0]).join("");
|
|
250
|
+
expect(output).not.toContain("💭");
|
|
251
|
+
expect(output).not.toContain("🔧 bash: npm test");
|
|
252
|
+
expect(output).toContain("💬");
|
|
253
|
+
expect(output).toContain("Test results: 5 passed");
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
describe("wrapText", () => {
|
|
259
|
+
describe("basic wrapping", () => {
|
|
260
|
+
it("should return single line for text shorter than width", () => {
|
|
261
|
+
const result = wrapText("hello", 20);
|
|
262
|
+
expect(result).toEqual(["hello"]);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("should wrap text longer than width", () => {
|
|
266
|
+
const result = wrapText("hello world this is a long text", 10);
|
|
267
|
+
expect(result).toEqual(["hello", "world this", "is a long", "text"]);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("should handle text exactly at width", () => {
|
|
271
|
+
const result = wrapText("1234567890", 10);
|
|
272
|
+
expect(result).toEqual(["1234567890"]);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("should break long word that exceeds width", () => {
|
|
276
|
+
const result = wrapText("12345678901", 10);
|
|
277
|
+
expect(result).toEqual(["1234567890", "1"]);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe("multiple lines", () => {
|
|
282
|
+
it("should preserve existing newlines", () => {
|
|
283
|
+
const result = wrapText("line1\nline2\nline3", 20);
|
|
284
|
+
expect(result).toEqual(["line1", "line2", "line3"]);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("should wrap lines that are too long", () => {
|
|
288
|
+
const result = wrapText("very long line1\nshort\nvery long line2", 10);
|
|
289
|
+
expect(result).toEqual(["very long", "line1", "short", "very long", "line2"]);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("should handle empty lines", () => {
|
|
293
|
+
const result = wrapText("line1\n\nline3", 20);
|
|
294
|
+
expect(result).toEqual(["line1", "", "line3"]);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
describe("ANSI codes", () => {
|
|
299
|
+
it("should preserve ANSI codes in output", () => {
|
|
300
|
+
const result = wrapText("\x1b[31mred\x1b[0m text", 20);
|
|
301
|
+
expect(result).toEqual(["\x1b[31mred\x1b[0m text"]);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("should not count ANSI codes toward visible width", () => {
|
|
305
|
+
const result = wrapText("\x1b[31mred\x1b[0m text", 8);
|
|
306
|
+
expect(result).toEqual(["\x1b[31mred\x1b[0m text"]);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("should handle multiple ANSI codes", () => {
|
|
310
|
+
const result = wrapText("\x1b[31m\x1b[1mbold red\x1b[0m\x1b[32m green\x1b[0m", 10);
|
|
311
|
+
expect(result).toEqual(["\x1b[31m\x1b[1mbold red\x1b[0m\x1b[32m", "green\x1b[0m"]);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("should handle ANSI codes at wrap boundary", () => {
|
|
315
|
+
const result = wrapText("12345\x1b[31m67890\x1b[0m", 10);
|
|
316
|
+
expect(result).toEqual(["12345\x1b[31m67890\x1b[0m"]);
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
describe("edge cases", () => {
|
|
321
|
+
it("should handle empty string", () => {
|
|
322
|
+
const result = wrapText("", 20);
|
|
323
|
+
expect(result).toEqual([""]);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("should handle single character", () => {
|
|
327
|
+
const result = wrapText("a", 20);
|
|
328
|
+
expect(result).toEqual(["a"]);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("should handle width of 1", () => {
|
|
332
|
+
const result = wrapText("a b c", 1);
|
|
333
|
+
expect(result).toEqual(["a", "b", "c"]);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("should handle carriage return characters", () => {
|
|
337
|
+
const result = wrapText("hello\r\nworld", 20);
|
|
338
|
+
expect(result).toEqual(["hello", "world"]);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("should handle trailing newline", () => {
|
|
342
|
+
const result = wrapText("hello\n", 20);
|
|
343
|
+
expect(result).toEqual(["hello"]);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("should handle multiple trailing newlines", () => {
|
|
347
|
+
const result = wrapText("hello\n\n", 20);
|
|
348
|
+
expect(result).toEqual(["hello", ""]);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("should handle leading newline", () => {
|
|
352
|
+
const result = wrapText("\nhello", 20);
|
|
353
|
+
expect(result).toEqual(["", "hello"]);
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
describe("real-world scenarios", () => {
|
|
358
|
+
it("should wrap thinking output with emoji", () => {
|
|
359
|
+
const result = wrapText(
|
|
360
|
+
"💭 Let me analyze this problem step by step to find the best solution",
|
|
361
|
+
40,
|
|
362
|
+
);
|
|
363
|
+
expect(result.length).toBeGreaterThan(1);
|
|
364
|
+
expect(result[0]).toBe("💭 Let me analyze this problem step by");
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("should wrap response output with emoji", () => {
|
|
368
|
+
const result = wrapText(
|
|
369
|
+
"💬 Here is the solution:\nWe need to implement the fix by updating the wrapText function",
|
|
370
|
+
30,
|
|
371
|
+
);
|
|
372
|
+
expect(result.length).toBeGreaterThan(1);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("should wrap output with ANSI colors", () => {
|
|
376
|
+
const result = wrapText(
|
|
377
|
+
"\x1b[90mThis is gray text\x1b[0m and this is \x1b[31mred\x1b[0m",
|
|
378
|
+
25,
|
|
379
|
+
);
|
|
380
|
+
expect(result[0]).toContain("\x1b[90m");
|
|
381
|
+
expect(result[0]).toContain("\x1b[0m");
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("should handle tool output", () => {
|
|
385
|
+
const result = wrapText("🔧 Using `bash`\nRunning command to install dependencies", 35);
|
|
386
|
+
expect(result[0]).toContain("🔧 Using");
|
|
387
|
+
expect(result[1]).toContain("Running command");
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
});
|