@meowlynxsea/koi 0.1.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/LICENSE +34 -0
- package/NOTICE +35 -0
- package/README.md +15 -0
- package/bin/koi +12 -0
- package/dist/highlights-eq9cgrbb.scm +604 -0
- package/dist/highlights-ghv9g403.scm +205 -0
- package/dist/highlights-hk7bwhj4.scm +284 -0
- package/dist/highlights-r812a2qc.scm +150 -0
- package/dist/highlights-x6tmsnaa.scm +115 -0
- package/dist/injections-73j83es3.scm +27 -0
- package/dist/main.js +489918 -0
- package/dist/tree-sitter-javascript-nd0q4pe9.wasm +0 -0
- package/dist/tree-sitter-markdown-411r6y9b.wasm +0 -0
- package/dist/tree-sitter-markdown_inline-j5349f42.wasm +0 -0
- package/dist/tree-sitter-typescript-zxjzwt75.wasm +0 -0
- package/dist/tree-sitter-zig-e78zbjpm.wasm +0 -0
- package/package.json +51 -0
- package/src/agent/check-permissions.ts +239 -0
- package/src/agent/hooks/message-utils.ts +305 -0
- package/src/agent/hooks/types.ts +32 -0
- package/src/agent/hooks.ts +1560 -0
- package/src/agent/mode.ts +163 -0
- package/src/agent/monitor-registry.ts +308 -0
- package/src/agent/permission-ui.ts +71 -0
- package/src/agent/plan-ui.ts +74 -0
- package/src/agent/question-ui.ts +58 -0
- package/src/agent/session-fork.ts +299 -0
- package/src/agent/session-snapshots.ts +216 -0
- package/src/agent/session-store.ts +649 -0
- package/src/agent/session-tasks.ts +305 -0
- package/src/agent/session.ts +27 -0
- package/src/agent/subagent-registry.ts +176 -0
- package/src/agent/subagent.ts +194 -0
- package/src/agent/tool-orchestration.ts +55 -0
- package/src/agent/tools.ts +8 -0
- package/src/cli/args.ts +6 -0
- package/src/cli/commands.ts +5 -0
- package/src/commands/skills/index.ts +23 -0
- package/src/config/models.ts +6 -0
- package/src/config/settings.ts +392 -0
- package/src/main.tsx +64 -0
- package/src/services/mcp/client.ts +194 -0
- package/src/services/mcp/config.ts +232 -0
- package/src/services/mcp/connection-manager.ts +258 -0
- package/src/services/mcp/index.ts +80 -0
- package/src/services/mcp/mcp-commands.ts +114 -0
- package/src/services/mcp/stdio-transport.ts +246 -0
- package/src/services/mcp/types.ts +155 -0
- package/src/skills/SkillsMenu.tsx +370 -0
- package/src/skills/bundled/batch.ts +106 -0
- package/src/skills/bundled/debug.ts +86 -0
- package/src/skills/bundled/loremIpsum.ts +101 -0
- package/src/skills/bundled/remember.ts +97 -0
- package/src/skills/bundled/simplify.ts +100 -0
- package/src/skills/bundled/skillify.ts +123 -0
- package/src/skills/bundled/stuck.ts +101 -0
- package/src/skills/bundled/updateConfig.ts +228 -0
- package/src/skills/bundled.ts +46 -0
- package/src/skills/frontmatter.ts +179 -0
- package/src/skills/index.ts +87 -0
- package/src/skills/invoke.ts +231 -0
- package/src/skills/loader.ts +710 -0
- package/src/skills/substitution.ts +169 -0
- package/src/skills/types.ts +201 -0
- package/src/tools/agent.ts +143 -0
- package/src/tools/ask-user-question.ts +46 -0
- package/src/tools/bash.ts +148 -0
- package/src/tools/edit.ts +164 -0
- package/src/tools/glob.ts +102 -0
- package/src/tools/grep.ts +248 -0
- package/src/tools/index.ts +73 -0
- package/src/tools/list-mcp-resources.ts +74 -0
- package/src/tools/ls.ts +85 -0
- package/src/tools/mcp.ts +76 -0
- package/src/tools/monitor.ts +159 -0
- package/src/tools/plan-mode.ts +134 -0
- package/src/tools/read-mcp-resource.ts +79 -0
- package/src/tools/read.ts +137 -0
- package/src/tools/skill.ts +176 -0
- package/src/tools/task.ts +349 -0
- package/src/tools/types.ts +52 -0
- package/src/tools/webfetch-domains.ts +239 -0
- package/src/tools/webfetch.ts +533 -0
- package/src/tools/write.ts +101 -0
- package/src/tui/app.tsx +1178 -0
- package/src/tui/components/chat-panel.tsx +1071 -0
- package/src/tui/components/command-panel.tsx +261 -0
- package/src/tui/components/confirm-modal.tsx +135 -0
- package/src/tui/components/connect-modal.tsx +435 -0
- package/src/tui/components/connecting-modal.tsx +167 -0
- package/src/tui/components/edit-pending-modal.tsx +103 -0
- package/src/tui/components/exit-modal.tsx +131 -0
- package/src/tui/components/fork-modal.tsx +377 -0
- package/src/tui/components/image-preview-modal.tsx +141 -0
- package/src/tui/components/image-utils.ts +128 -0
- package/src/tui/components/info-bar.tsx +103 -0
- package/src/tui/components/input-box.tsx +352 -0
- package/src/tui/components/mcp/MCPSettings.tsx +386 -0
- package/src/tui/components/mcp/index.ts +7 -0
- package/src/tui/components/model-modal.tsx +310 -0
- package/src/tui/components/pending-area.tsx +88 -0
- package/src/tui/components/rename-modal.tsx +119 -0
- package/src/tui/components/session-modal.tsx +233 -0
- package/src/tui/components/side-bar.tsx +349 -0
- package/src/tui/components/tool-output.ts +6 -0
- package/src/tui/hooks/user-prompt-history.ts +114 -0
- package/src/tui/theme.ts +63 -0
- package/src/types/commands.ts +80 -0
- package/src/types/cross-spawn.d.ts +24 -0
|
@@ -0,0 +1,1071 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat Panel Component
|
|
3
|
+
*
|
|
4
|
+
* Renders the scrollable message history using OpenTUI native components.
|
|
5
|
+
* Supports per-tool-type rendering, diff views, segmented markdown with
|
|
6
|
+
* custom code blocks, separators, and image links.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useMemo, useImperativeHandle, forwardRef, useRef, useState, useEffect } from "react";
|
|
10
|
+
import stringWidth from "string-width";
|
|
11
|
+
import { SyntaxStyle, createTextAttributes, type ScrollBoxRenderable, type MouseEvent, RGBA } from "@opentui/core";
|
|
12
|
+
import { imageToHalfBlocks, type ImageRow } from "./image-utils.js";
|
|
13
|
+
|
|
14
|
+
export type UIMessage =
|
|
15
|
+
| { id: string; type: "user"; content: string }
|
|
16
|
+
| {
|
|
17
|
+
id: string;
|
|
18
|
+
type: "agent";
|
|
19
|
+
content: string;
|
|
20
|
+
thinking?: string;
|
|
21
|
+
thinkingCollapsed?: boolean;
|
|
22
|
+
thinkingStartTime?: number;
|
|
23
|
+
thinkingEndTime?: number;
|
|
24
|
+
thinkingTokens?: number;
|
|
25
|
+
}
|
|
26
|
+
| { id: string; type: "status"; content: string }
|
|
27
|
+
| {
|
|
28
|
+
id: string;
|
|
29
|
+
type: "tool_call";
|
|
30
|
+
toolCallId: string;
|
|
31
|
+
toolName: string;
|
|
32
|
+
args: Record<string, unknown>;
|
|
33
|
+
result?: unknown;
|
|
34
|
+
isError?: boolean;
|
|
35
|
+
collapsed: boolean;
|
|
36
|
+
}
|
|
37
|
+
| { id: string; type: "system"; content: string }
|
|
38
|
+
| { id: string; type: "compaction"; content: string }
|
|
39
|
+
| {
|
|
40
|
+
id: string;
|
|
41
|
+
type: "retry";
|
|
42
|
+
attempt: number;
|
|
43
|
+
maxAttempts: number;
|
|
44
|
+
content: string;
|
|
45
|
+
}
|
|
46
|
+
| { id: string; type: "plan"; content: string };
|
|
47
|
+
|
|
48
|
+
interface ChatPanelProps {
|
|
49
|
+
messages: UIMessage[];
|
|
50
|
+
width?: number;
|
|
51
|
+
height?: number;
|
|
52
|
+
onToggleCollapse?: (id: string) => void;
|
|
53
|
+
onImageClick?: (url: string) => void;
|
|
54
|
+
isStreaming?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ChatPanelHandle {
|
|
58
|
+
scrollToBottom: () => void;
|
|
59
|
+
scrollUp: () => void;
|
|
60
|
+
scrollDown: () => void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Text Utilities
|
|
65
|
+
*
|
|
66
|
+
* wrapText uses Intl.Segmenter for grapheme-accurate wrapping (handles emoji/CJK correctly).
|
|
67
|
+
* padToWidth pads with spaces so background colors fill the full line width in the TUI.
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
export function wrapText(text: string, width: number, indent: number): string[] {
|
|
71
|
+
const available = Math.max(1, width - indent);
|
|
72
|
+
const lines: string[] = [];
|
|
73
|
+
let current = "";
|
|
74
|
+
let currentWidth = 0;
|
|
75
|
+
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
|
|
76
|
+
|
|
77
|
+
for (const seg of segmenter.segment(text)) {
|
|
78
|
+
const g = seg.segment;
|
|
79
|
+
const w = stringWidth(g);
|
|
80
|
+
if (g === "\n") {
|
|
81
|
+
lines.push(current);
|
|
82
|
+
current = "";
|
|
83
|
+
currentWidth = 0;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (currentWidth + w > available && currentWidth > 0) {
|
|
87
|
+
lines.push(current);
|
|
88
|
+
current = g;
|
|
89
|
+
currentWidth = w;
|
|
90
|
+
} else {
|
|
91
|
+
current += g;
|
|
92
|
+
currentWidth += w;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (current.length > 0 || lines.length === 0) {
|
|
96
|
+
lines.push(current);
|
|
97
|
+
}
|
|
98
|
+
return lines;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function padToWidth(text: string, width: number): string {
|
|
102
|
+
const w = stringWidth(text);
|
|
103
|
+
if (w >= width) return text;
|
|
104
|
+
return text + " ".repeat(Math.max(0, width - w));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Middle Truncation
|
|
109
|
+
*
|
|
110
|
+
* Truncates text to fit within maxWidth, preserving the beginning and end,
|
|
111
|
+
* with an ellipsis in the middle. Correctly handles CJK characters and emoji
|
|
112
|
+
* by using string-width for accurate visual width calculation.
|
|
113
|
+
*/
|
|
114
|
+
function truncateMiddle(text: string, maxWidth: number): string {
|
|
115
|
+
const w = stringWidth(text);
|
|
116
|
+
if (w <= maxWidth) return text;
|
|
117
|
+
|
|
118
|
+
// Reserve space for ellipsis
|
|
119
|
+
const ellipsis = "...";
|
|
120
|
+
const ellipsisWidth = stringWidth(ellipsis);
|
|
121
|
+
const availableWidth = maxWidth - ellipsisWidth;
|
|
122
|
+
|
|
123
|
+
if (availableWidth <= 0) {
|
|
124
|
+
// Max width is too small even for ellipsis, just return partial ellipsis
|
|
125
|
+
const partial = stringWidth(ellipsis) > maxWidth ? ".." : ".";
|
|
126
|
+
return partial.slice(0, Math.min(partial.length, Math.floor(maxWidth / stringWidth("."))));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Split available width between head and tail (roughly equal)
|
|
130
|
+
const headMaxWidth = Math.ceil(availableWidth / 2);
|
|
131
|
+
const tailMaxWidth = Math.floor(availableWidth / 2);
|
|
132
|
+
|
|
133
|
+
// Find head portion that fits
|
|
134
|
+
let head = "";
|
|
135
|
+
let headWidth = 0;
|
|
136
|
+
for (const seg of new Intl.Segmenter("en", { granularity: "grapheme" }).segment(text)) {
|
|
137
|
+
const segWidth = stringWidth(seg.segment);
|
|
138
|
+
if (headWidth + segWidth > headMaxWidth) break;
|
|
139
|
+
head += seg.segment;
|
|
140
|
+
headWidth += segWidth;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Find tail portion that fits (from the end)
|
|
144
|
+
let tail = "";
|
|
145
|
+
let tailWidth = 0;
|
|
146
|
+
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
|
|
147
|
+
const segments = [...segmenter.segment(text)];
|
|
148
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
149
|
+
const seg = segments[i];
|
|
150
|
+
if (!seg) break;
|
|
151
|
+
const segWidth = stringWidth(seg.segment);
|
|
152
|
+
if (tailWidth + segWidth > tailMaxWidth) break;
|
|
153
|
+
tail = seg.segment + tail;
|
|
154
|
+
tailWidth += segWidth;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return head + ellipsis + tail;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Tool Summary
|
|
162
|
+
*
|
|
163
|
+
* Maps tool names to short one-line descriptions for the collapsed tool_call view.
|
|
164
|
+
*/
|
|
165
|
+
|
|
166
|
+
const TOOL_SUMMARY_MAP: Record<string, (args: Record<string, unknown>) => string> = {
|
|
167
|
+
read: (a) => `read: ${String(a["path"] ?? a["file"] ?? "?")}`,
|
|
168
|
+
bash: (a) => {
|
|
169
|
+
const cmd = String(a["command"] ?? "");
|
|
170
|
+
// Reserve 10 chars for "bash: " prefix
|
|
171
|
+
const prefix = "bash: ";
|
|
172
|
+
const maxCmdWidth = 60 - stringWidth(prefix);
|
|
173
|
+
const truncatedCmd = truncateMiddle(cmd, maxCmdWidth);
|
|
174
|
+
return `${prefix}${truncatedCmd}`;
|
|
175
|
+
},
|
|
176
|
+
edit: (a) => `edit: ${String(a["path"] ?? a["file"] ?? "?")}`,
|
|
177
|
+
write: (a) => `write: ${String(a["path"] ?? a["file"] ?? "?")}`,
|
|
178
|
+
grep: (a) => `grep: ${String(a["pattern"] ?? "?")}`,
|
|
179
|
+
find: (a) => `find: ${String(a["path"] ?? ".")}`,
|
|
180
|
+
ls: (a) => `ls: ${String(a["path"] ?? ".")}`,
|
|
181
|
+
webfetch: (a) => {
|
|
182
|
+
const url = String(a["url"] ?? "?");
|
|
183
|
+
// Reserve 12 chars for "webfetch: " prefix
|
|
184
|
+
const prefix = "webfetch: ";
|
|
185
|
+
const maxUrlWidth = 70 - stringWidth(prefix);
|
|
186
|
+
return `${prefix}${truncateMiddle(url, maxUrlWidth)}`;
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Tool Classification
|
|
192
|
+
*
|
|
193
|
+
* Determines expand/collapse behavior per tool type.
|
|
194
|
+
*/
|
|
195
|
+
|
|
196
|
+
const NON_EXPANDABLE_TOOLS = new Set(["read", "glob", "grep", "ls", "taskCreate", "taskGet", "taskList", "taskUpdate"]);
|
|
197
|
+
const FORCE_EXPANDED_TOOLS = new Set(["write", "edit"]);
|
|
198
|
+
|
|
199
|
+
export function isToolExpandable(toolName: string): boolean {
|
|
200
|
+
return !NON_EXPANDABLE_TOOLS.has(toolName) && !FORCE_EXPANDED_TOOLS.has(toolName);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function isToolForceExpanded(toolName: string): boolean {
|
|
204
|
+
return FORCE_EXPANDED_TOOLS.has(toolName);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function getToolDefaultCollapsed(toolName: string, allExpanded: boolean): boolean {
|
|
208
|
+
if (FORCE_EXPANDED_TOOLS.has(toolName)) return false;
|
|
209
|
+
if (NON_EXPANDABLE_TOOLS.has(toolName)) return true;
|
|
210
|
+
return !allExpanded;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function summarizeToolCall(toolName: string, args: Record<string, unknown>): string {
|
|
214
|
+
try {
|
|
215
|
+
const formatter = TOOL_SUMMARY_MAP[toolName];
|
|
216
|
+
if (formatter) return formatter(args);
|
|
217
|
+
// Fallback for unknown tools: truncate JSON args with middle ellipsis
|
|
218
|
+
const jsonArgs = JSON.stringify(args);
|
|
219
|
+
const prefix = `${toolName}: `;
|
|
220
|
+
const maxArgsWidth = 60 - stringWidth(prefix);
|
|
221
|
+
return `${prefix}${truncateMiddle(jsonArgs, maxArgsWidth)}`;
|
|
222
|
+
} catch {
|
|
223
|
+
return `${toolName}: ...`;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function formatResult(result: unknown): string {
|
|
228
|
+
if (result === undefined || result === null) return "";
|
|
229
|
+
if (typeof result === "string") return result;
|
|
230
|
+
try {
|
|
231
|
+
return JSON.stringify(result, null, 2);
|
|
232
|
+
} catch {
|
|
233
|
+
return String(result);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function extractToolResultText(result: unknown): string {
|
|
238
|
+
if (result === undefined || result === null) return "";
|
|
239
|
+
if (typeof result === "string") return result;
|
|
240
|
+
if (typeof result === "object" && result !== null) {
|
|
241
|
+
const r = result as Record<string, unknown>;
|
|
242
|
+
if (Array.isArray(r["content"])) {
|
|
243
|
+
const texts = r["content"]
|
|
244
|
+
.filter(
|
|
245
|
+
(c): c is { type: string; text: string } =>
|
|
246
|
+
typeof c === "object" && c !== null && (c as Record<string, unknown>)["type"] === "text"
|
|
247
|
+
)
|
|
248
|
+
.map((c) => c["text"]);
|
|
249
|
+
return texts.join("\n");
|
|
250
|
+
}
|
|
251
|
+
if (typeof r["text"] === "string") return r["text"];
|
|
252
|
+
}
|
|
253
|
+
return formatResult(result);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function extractDiffFromResult(result: unknown): string | null {
|
|
257
|
+
if (result === undefined || result === null) return null;
|
|
258
|
+
if (typeof result === "object" && result !== null) {
|
|
259
|
+
const r = result as Record<string, unknown>;
|
|
260
|
+
if (typeof r["details"] === "object" && r["details"] !== null) {
|
|
261
|
+
const diff = (r["details"] as Record<string, unknown>)["diff"];
|
|
262
|
+
if (typeof diff === "string" && diff.length > 0) return diff;
|
|
263
|
+
}
|
|
264
|
+
const text = extractToolResultText(result);
|
|
265
|
+
const diffIndex = text.indexOf("Diff:\n");
|
|
266
|
+
if (diffIndex >= 0) return text.slice(diffIndex + 6);
|
|
267
|
+
}
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function tailLines(text: string, count: number): { lines: string[]; total: number } {
|
|
272
|
+
const all = text.split("\n");
|
|
273
|
+
return {
|
|
274
|
+
lines: all.length > count ? all.slice(-count) : all,
|
|
275
|
+
total: all.length,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function middleEllipsisLines(text: string, maxLines: number, headCount: number, tailCount: number): string[] {
|
|
280
|
+
const all = text.split("\n");
|
|
281
|
+
if (all.length <= maxLines) return all;
|
|
282
|
+
const head = all.slice(0, headCount);
|
|
283
|
+
const tail = all.slice(-tailCount);
|
|
284
|
+
const omitted = all.length - headCount - tailCount;
|
|
285
|
+
return [...head, `... (${omitted} lines omitted) ...`, ...tail];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
289
|
+
|
|
290
|
+
function formatDuration(ms: number): string {
|
|
291
|
+
return `${Math.max(0, Math.floor(ms / 1000))}s`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Markdown Segment Parser
|
|
296
|
+
*
|
|
297
|
+
* Splits finalized markdown content into segments so that code blocks,
|
|
298
|
+
* horizontal rules, and images can be rendered with custom components.
|
|
299
|
+
*/
|
|
300
|
+
|
|
301
|
+
type MarkdownSegment =
|
|
302
|
+
| { type: "text"; content: string }
|
|
303
|
+
| { type: "code"; language: string; content: string }
|
|
304
|
+
| { type: "hr" }
|
|
305
|
+
| { type: "image"; alt: string; url: string };
|
|
306
|
+
|
|
307
|
+
const LANG_MAP: Record<string, string> = {
|
|
308
|
+
ts: "typescript",
|
|
309
|
+
js: "javascript",
|
|
310
|
+
py: "python",
|
|
311
|
+
sh: "bash",
|
|
312
|
+
shell: "bash",
|
|
313
|
+
yml: "yaml",
|
|
314
|
+
jsonc: "json",
|
|
315
|
+
md: "markdown",
|
|
316
|
+
tf: "hcl",
|
|
317
|
+
hcl: "hcl",
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
function normalizeLang(lang: string): string {
|
|
321
|
+
return LANG_MAP[lang] ?? lang;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function ImageThumbnail({
|
|
325
|
+
url,
|
|
326
|
+
alt,
|
|
327
|
+
onClick,
|
|
328
|
+
}: {
|
|
329
|
+
url: string;
|
|
330
|
+
alt: string;
|
|
331
|
+
onClick: () => void;
|
|
332
|
+
}) {
|
|
333
|
+
const [rows, setRows] = useState<ImageRow[] | null>(null);
|
|
334
|
+
const [loading, setLoading] = useState(true);
|
|
335
|
+
|
|
336
|
+
useEffect(() => {
|
|
337
|
+
let cancelled = false;
|
|
338
|
+
async function load() {
|
|
339
|
+
try {
|
|
340
|
+
const data = await imageToHalfBlocks(url, 30, 12);
|
|
341
|
+
if (!cancelled) setRows(data);
|
|
342
|
+
} catch {
|
|
343
|
+
if (!cancelled) setRows(null);
|
|
344
|
+
} finally {
|
|
345
|
+
if (!cancelled) setLoading(false);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
void load();
|
|
350
|
+
return () => {
|
|
351
|
+
cancelled = true;
|
|
352
|
+
};
|
|
353
|
+
}, [url]);
|
|
354
|
+
|
|
355
|
+
if (rows) {
|
|
356
|
+
return (
|
|
357
|
+
<box
|
|
358
|
+
flexDirection="column"
|
|
359
|
+
marginTop={1}
|
|
360
|
+
marginBottom={1}
|
|
361
|
+
onMouseUp={onClick}
|
|
362
|
+
>
|
|
363
|
+
{rows.map((row, y) => (
|
|
364
|
+
<text key={y}>
|
|
365
|
+
{row.map((cell, x) => (
|
|
366
|
+
<span key={x} fg={cell.fg} bg={cell.bg}>
|
|
367
|
+
{"▄"}
|
|
368
|
+
</span>
|
|
369
|
+
))}
|
|
370
|
+
</text>
|
|
371
|
+
))}
|
|
372
|
+
<text fg="#6c6c7c" marginTop={1} attributes={createTextAttributes({ dim: true })}>
|
|
373
|
+
{`[Click to enlarge] ${alt || url}`}
|
|
374
|
+
</text>
|
|
375
|
+
</box>
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return (
|
|
380
|
+
<box flexDirection="row" marginTop={1} marginBottom={1}>
|
|
381
|
+
<text fg="#8be9fd" onMouseUp={onClick}>
|
|
382
|
+
{loading ? `[Loading image: ${alt || url}]` : `[Image: ${alt || url}]`}
|
|
383
|
+
</text>
|
|
384
|
+
</box>
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function parseTextSegment(text: string): MarkdownSegment[] {
|
|
389
|
+
const segments: MarkdownSegment[] = [];
|
|
390
|
+
// Match horizontal rules or inline images
|
|
391
|
+
const regex = /(^[ \t]*---[ \t]*$|!\[([^\]]*)\]\(([^)]+)\))/gm;
|
|
392
|
+
let lastIndex = 0;
|
|
393
|
+
let match: RegExpExecArray | null;
|
|
394
|
+
|
|
395
|
+
while ((match = regex.exec(text)) !== null) {
|
|
396
|
+
if (match.index > lastIndex) {
|
|
397
|
+
segments.push({ type: "text", content: text.slice(lastIndex, match.index) });
|
|
398
|
+
}
|
|
399
|
+
if (match[0].startsWith("!")) {
|
|
400
|
+
segments.push({ type: "image", alt: match[2]!, url: match[3]! });
|
|
401
|
+
} else {
|
|
402
|
+
segments.push({ type: "hr" });
|
|
403
|
+
}
|
|
404
|
+
lastIndex = regex.lastIndex;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (lastIndex < text.length) {
|
|
408
|
+
segments.push({ type: "text", content: text.slice(lastIndex) });
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return segments;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function parseMarkdownSegments(content: string): MarkdownSegment[] {
|
|
415
|
+
const segments: MarkdownSegment[] = [];
|
|
416
|
+
const codeBlockRegex = /```([^\n]*)\n([\s\S]*?)```/g;
|
|
417
|
+
let lastIndex = 0;
|
|
418
|
+
let match: RegExpExecArray | null;
|
|
419
|
+
|
|
420
|
+
while ((match = codeBlockRegex.exec(content)) !== null) {
|
|
421
|
+
if (match.index > lastIndex) {
|
|
422
|
+
segments.push(...parseTextSegment(content.slice(lastIndex, match.index)));
|
|
423
|
+
}
|
|
424
|
+
segments.push({ type: "code", language: match[1]!.trim(), content: match[2]! });
|
|
425
|
+
lastIndex = codeBlockRegex.lastIndex;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (lastIndex < content.length) {
|
|
429
|
+
segments.push(...parseTextSegment(content.slice(lastIndex)));
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return segments;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Message Renderers
|
|
437
|
+
*/
|
|
438
|
+
|
|
439
|
+
function UserMessage({
|
|
440
|
+
msg,
|
|
441
|
+
contentWidth,
|
|
442
|
+
marginTop,
|
|
443
|
+
}: {
|
|
444
|
+
msg: UIMessage & { type: "user" };
|
|
445
|
+
contentWidth: number;
|
|
446
|
+
marginTop: number;
|
|
447
|
+
}) {
|
|
448
|
+
const margin = " ";
|
|
449
|
+
const prefix = "> ";
|
|
450
|
+
const prefixWidth = stringWidth(prefix);
|
|
451
|
+
const available = Math.max(1, contentWidth - 2 - prefixWidth);
|
|
452
|
+
const wrapped = wrapText(msg.content, available, prefixWidth);
|
|
453
|
+
|
|
454
|
+
return (
|
|
455
|
+
<box flexDirection="column" width={contentWidth} marginTop={marginTop}>
|
|
456
|
+
{wrapped.map((line, j) => {
|
|
457
|
+
const raw = j === 0 ? margin + prefix + line : margin + " ".repeat(prefixWidth) + line;
|
|
458
|
+
return (
|
|
459
|
+
<text key={j} bg="#333333">
|
|
460
|
+
{padToWidth(raw, contentWidth)}
|
|
461
|
+
</text>
|
|
462
|
+
);
|
|
463
|
+
})}
|
|
464
|
+
</box>
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function SegmentedMarkdownContent({
|
|
469
|
+
content,
|
|
470
|
+
width,
|
|
471
|
+
syntaxStyle,
|
|
472
|
+
onImageClick,
|
|
473
|
+
}: {
|
|
474
|
+
content: string;
|
|
475
|
+
width: number;
|
|
476
|
+
syntaxStyle: SyntaxStyle;
|
|
477
|
+
onImageClick: (url: string) => void;
|
|
478
|
+
}) {
|
|
479
|
+
const segments = useMemo(() => parseMarkdownSegments(content), [content]);
|
|
480
|
+
|
|
481
|
+
return (
|
|
482
|
+
<box flexDirection="column" width={width}>
|
|
483
|
+
{segments.map((seg, i) => {
|
|
484
|
+
switch (seg.type) {
|
|
485
|
+
case "text":
|
|
486
|
+
if (!seg.content.trim()) return <text key={i} />;
|
|
487
|
+
return (
|
|
488
|
+
<MarkdownContent
|
|
489
|
+
key={i}
|
|
490
|
+
content={seg.content}
|
|
491
|
+
width={width}
|
|
492
|
+
streaming={false}
|
|
493
|
+
syntaxStyle={syntaxStyle}
|
|
494
|
+
/>
|
|
495
|
+
);
|
|
496
|
+
case "code": {
|
|
497
|
+
const lang = normalizeLang(seg.language);
|
|
498
|
+
return (
|
|
499
|
+
<box
|
|
500
|
+
key={i}
|
|
501
|
+
flexDirection="column"
|
|
502
|
+
width={width}
|
|
503
|
+
border={["left"]}
|
|
504
|
+
borderColor="#6272a4"
|
|
505
|
+
paddingLeft={1}
|
|
506
|
+
marginTop={1}
|
|
507
|
+
marginBottom={1}
|
|
508
|
+
>
|
|
509
|
+
<code
|
|
510
|
+
content={seg.content}
|
|
511
|
+
filetype={lang || undefined}
|
|
512
|
+
syntaxStyle={syntaxStyle}
|
|
513
|
+
conceal={true}
|
|
514
|
+
width={width - 2}
|
|
515
|
+
/>
|
|
516
|
+
</box>
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
case "hr":
|
|
520
|
+
return (
|
|
521
|
+
<text key={i} fg="#6c6c7c" marginLeft={2} marginRight={2} marginTop={1} marginBottom={1}>
|
|
522
|
+
{"─".repeat(Math.max(1, width - 4))}
|
|
523
|
+
</text>
|
|
524
|
+
);
|
|
525
|
+
case "image":
|
|
526
|
+
return (
|
|
527
|
+
<ImageThumbnail
|
|
528
|
+
key={i}
|
|
529
|
+
url={seg.url}
|
|
530
|
+
alt={seg.alt}
|
|
531
|
+
onClick={() => onImageClick(seg.url)}
|
|
532
|
+
/>
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
})}
|
|
536
|
+
</box>
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function AgentMessage({
|
|
541
|
+
msg,
|
|
542
|
+
contentWidth,
|
|
543
|
+
marginTop,
|
|
544
|
+
isStreaming,
|
|
545
|
+
spinnerFrame,
|
|
546
|
+
onImageClick,
|
|
547
|
+
}: {
|
|
548
|
+
msg: UIMessage & { type: "agent" };
|
|
549
|
+
contentWidth: number;
|
|
550
|
+
marginTop: number;
|
|
551
|
+
isStreaming: boolean;
|
|
552
|
+
spinnerFrame: number;
|
|
553
|
+
onImageClick: (url: string) => void;
|
|
554
|
+
}) {
|
|
555
|
+
const margin = " ";
|
|
556
|
+
const prefix = "⏺ ";
|
|
557
|
+
const prefixWidth = stringWidth(prefix);
|
|
558
|
+
const thinkingInProgress = msg.thinking && msg.thinkingStartTime && !msg.thinkingEndTime;
|
|
559
|
+
const thinkingElapsed = thinkingInProgress
|
|
560
|
+
? Date.now() - (msg.thinkingStartTime ?? 0)
|
|
561
|
+
: (msg.thinkingEndTime ?? 0) - (msg.thinkingStartTime ?? 0);
|
|
562
|
+
const thinkingDuration = formatDuration(thinkingElapsed);
|
|
563
|
+
const syntaxStyle = useMemo(() => buildSyntaxStyle(), []);
|
|
564
|
+
|
|
565
|
+
return (
|
|
566
|
+
<box flexDirection="column" width={contentWidth} marginTop={marginTop}>
|
|
567
|
+
{msg.thinking && thinkingInProgress && (
|
|
568
|
+
<>
|
|
569
|
+
<box flexDirection="row">
|
|
570
|
+
<text fg="#00f5ff">{margin}{SPINNER[spinnerFrame]}</text>
|
|
571
|
+
<text fg="#6c6c7c" marginLeft={1}>Thinking... {thinkingDuration}</text>
|
|
572
|
+
</box>
|
|
573
|
+
{wrapText(msg.thinking, contentWidth - 2, 2).map((line, j) => (
|
|
574
|
+
<text key={`think-${j}`} fg="#6c6c7c">{margin} {line}</text>
|
|
575
|
+
))}
|
|
576
|
+
{msg.content.trimEnd().length > 0 && <text />}
|
|
577
|
+
</>
|
|
578
|
+
)}
|
|
579
|
+
{msg.thinking && !thinkingInProgress && (
|
|
580
|
+
<>
|
|
581
|
+
{(msg.thinkingCollapsed ?? true) ? (
|
|
582
|
+
<text fg="#6c6c7c">{margin}▶ Thought for {thinkingDuration}</text>
|
|
583
|
+
) : (
|
|
584
|
+
<>
|
|
585
|
+
<text fg="#6c6c7c">{margin}▼ Thought for {thinkingDuration}</text>
|
|
586
|
+
{wrapText(msg.thinking, contentWidth - 2, 2).map((line, j) => (
|
|
587
|
+
<text key={`think-${j}`} fg="#6c6c7c">{margin} {line}</text>
|
|
588
|
+
))}
|
|
589
|
+
</>
|
|
590
|
+
)}
|
|
591
|
+
{msg.content.trimEnd().length > 0 && <text />}
|
|
592
|
+
</>
|
|
593
|
+
)}
|
|
594
|
+
{msg.content.trimEnd().length > 0 && (
|
|
595
|
+
<box flexDirection="row" width={contentWidth}>
|
|
596
|
+
<text width={prefixWidth}>{prefix}</text>
|
|
597
|
+
{isStreaming ? (
|
|
598
|
+
<MarkdownContent
|
|
599
|
+
content={msg.content.trimEnd()}
|
|
600
|
+
width={contentWidth - prefixWidth}
|
|
601
|
+
streaming={isStreaming}
|
|
602
|
+
syntaxStyle={syntaxStyle}
|
|
603
|
+
/>
|
|
604
|
+
) : (
|
|
605
|
+
<SegmentedMarkdownContent
|
|
606
|
+
content={msg.content.trimEnd()}
|
|
607
|
+
width={contentWidth - prefixWidth}
|
|
608
|
+
syntaxStyle={syntaxStyle}
|
|
609
|
+
onImageClick={onImageClick}
|
|
610
|
+
/>
|
|
611
|
+
)}
|
|
612
|
+
</box>
|
|
613
|
+
)}
|
|
614
|
+
</box>
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function StatusMessage({ msg, marginTop }: { msg: UIMessage & { type: "status" }; marginTop: number }) {
|
|
619
|
+
return (
|
|
620
|
+
<text fg="#6c6c7c" marginTop={marginTop}>* {msg.content}</text>
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Terminal default background color - uses INTENT_DEFAULT to tell the terminal
|
|
625
|
+
// to use its actual default background instead of a hardcoded color
|
|
626
|
+
const DEFAULT_BG = RGBA.defaultBackground();
|
|
627
|
+
|
|
628
|
+
function DiffToolContent({
|
|
629
|
+
diff,
|
|
630
|
+
contentWidth,
|
|
631
|
+
filePath,
|
|
632
|
+
}: {
|
|
633
|
+
diff: string;
|
|
634
|
+
contentWidth: number;
|
|
635
|
+
filePath?: string;
|
|
636
|
+
}) {
|
|
637
|
+
const syntaxStyle = useMemo(() => buildSyntaxStyle(), []);
|
|
638
|
+
return (
|
|
639
|
+
<box marginTop={1} flexDirection="column" width={contentWidth - 4}>
|
|
640
|
+
{filePath && (
|
|
641
|
+
<text fg="#8be9fd" marginBottom={1}>{filePath}</text>
|
|
642
|
+
)}
|
|
643
|
+
<diff
|
|
644
|
+
diff={diff}
|
|
645
|
+
view="unified"
|
|
646
|
+
showLineNumbers={true}
|
|
647
|
+
width={contentWidth - 4}
|
|
648
|
+
syntaxStyle={syntaxStyle}
|
|
649
|
+
addedSignColor="#50fa7b"
|
|
650
|
+
removedSignColor="#ff5555"
|
|
651
|
+
addedBg="#1d3b2a"
|
|
652
|
+
removedBg="#3b1d1d"
|
|
653
|
+
contextBg={DEFAULT_BG}
|
|
654
|
+
addedContentBg="#1d3b2a"
|
|
655
|
+
removedContentBg="#3b1d1d"
|
|
656
|
+
contextContentBg={DEFAULT_BG}
|
|
657
|
+
lineNumberFg="#6c6c7c"
|
|
658
|
+
lineNumberBg={DEFAULT_BG}
|
|
659
|
+
fg="#f8f8f2"
|
|
660
|
+
/>
|
|
661
|
+
</box>
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function BashToolContent({
|
|
666
|
+
result,
|
|
667
|
+
contentWidth,
|
|
668
|
+
}: {
|
|
669
|
+
result: unknown;
|
|
670
|
+
contentWidth: number;
|
|
671
|
+
}) {
|
|
672
|
+
const margin = " ";
|
|
673
|
+
const text = extractToolResultText(result);
|
|
674
|
+
const { lines, total } = tailLines(text, 15);
|
|
675
|
+
|
|
676
|
+
return (
|
|
677
|
+
<box flexDirection="column" width={contentWidth}>
|
|
678
|
+
{total > 15 && (
|
|
679
|
+
<text fg="#6c6c7c">{margin} ... (showing last 15 of {total} lines)</text>
|
|
680
|
+
)}
|
|
681
|
+
{lines.map((line, j) => (
|
|
682
|
+
<text key={`bash-${j}`} fg="#6c6c7c">{margin} {line}</text>
|
|
683
|
+
))}
|
|
684
|
+
</box>
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function WebfetchToolContent({
|
|
689
|
+
result,
|
|
690
|
+
contentWidth,
|
|
691
|
+
}: {
|
|
692
|
+
result: unknown;
|
|
693
|
+
contentWidth: number;
|
|
694
|
+
}) {
|
|
695
|
+
const margin = " ";
|
|
696
|
+
const text = extractToolResultText(result);
|
|
697
|
+
const lines = middleEllipsisLines(text, 30, 10, 10);
|
|
698
|
+
|
|
699
|
+
return (
|
|
700
|
+
<box flexDirection="column" width={contentWidth}>
|
|
701
|
+
{lines.map((line, j) => (
|
|
702
|
+
<text key={`wf-${j}`} fg="#6c6c7c">{margin} {line}</text>
|
|
703
|
+
))}
|
|
704
|
+
</box>
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function ToolCallMessage({
|
|
709
|
+
msg,
|
|
710
|
+
contentWidth,
|
|
711
|
+
marginTop,
|
|
712
|
+
spinnerFrame,
|
|
713
|
+
onToggleCollapse,
|
|
714
|
+
}: {
|
|
715
|
+
msg: UIMessage & { type: "tool_call" };
|
|
716
|
+
contentWidth: number;
|
|
717
|
+
marginTop: number;
|
|
718
|
+
spinnerFrame?: number;
|
|
719
|
+
onToggleCollapse?: (id: string) => void;
|
|
720
|
+
}) {
|
|
721
|
+
const margin = " ";
|
|
722
|
+
const summary = summarizeToolCall(msg.toolName, msg.args);
|
|
723
|
+
const isExecuting = msg.result === undefined && !msg.isError;
|
|
724
|
+
const statusIcon = msg.isError
|
|
725
|
+
? "·"
|
|
726
|
+
: isExecuting
|
|
727
|
+
? SPINNER[spinnerFrame ?? 0]
|
|
728
|
+
: "·";
|
|
729
|
+
const statusColor = msg.isError
|
|
730
|
+
? "#ff5555"
|
|
731
|
+
: isExecuting
|
|
732
|
+
? "#00f5ff"
|
|
733
|
+
: "#50fa7b";
|
|
734
|
+
|
|
735
|
+
const expandable = isToolExpandable(msg.toolName);
|
|
736
|
+
const forceExpanded = isToolForceExpanded(msg.toolName);
|
|
737
|
+
|
|
738
|
+
const handleToggle = (e: MouseEvent) => {
|
|
739
|
+
if (expandable && onToggleCollapse) {
|
|
740
|
+
e.stopPropagation();
|
|
741
|
+
onToggleCollapse(msg.id);
|
|
742
|
+
}
|
|
743
|
+
};
|
|
744
|
+
|
|
745
|
+
// Non-expandable tools always render collapsed summary
|
|
746
|
+
if (!expandable && !forceExpanded) {
|
|
747
|
+
return (
|
|
748
|
+
<box flexDirection="column" width={contentWidth} marginTop={marginTop}>
|
|
749
|
+
<box flexDirection="row">
|
|
750
|
+
<text fg={statusColor}>{margin}{statusIcon} </text>
|
|
751
|
+
<text fg={msg.isError ? "#ff5555" : "#6c6c7c"}>
|
|
752
|
+
{summary}{msg.isError ? " [error]" : ""}
|
|
753
|
+
</text>
|
|
754
|
+
</box>
|
|
755
|
+
</box>
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Force-expanded tools (write/edit) always render expanded with diff
|
|
760
|
+
if (forceExpanded) {
|
|
761
|
+
const diff = extractDiffFromResult(msg.result);
|
|
762
|
+
const filePath = String(msg.args["path"] ?? msg.args["file"] ?? "");
|
|
763
|
+
return (
|
|
764
|
+
<box flexDirection="column" width={contentWidth} marginTop={marginTop}>
|
|
765
|
+
<box flexDirection="row">
|
|
766
|
+
<text fg={statusColor}>{margin}{statusIcon} </text>
|
|
767
|
+
<text fg={msg.isError ? "#ff5555" : "#6c6c7c"}>{msg.toolName} {filePath}</text>
|
|
768
|
+
</box>
|
|
769
|
+
{msg.result !== undefined && diff ? (
|
|
770
|
+
<DiffToolContent diff={diff} contentWidth={contentWidth} />
|
|
771
|
+
) : msg.result !== undefined ? (
|
|
772
|
+
wrapText(
|
|
773
|
+
msg.isError ? `Error: ${extractToolResultText(msg.result)}` : extractToolResultText(msg.result),
|
|
774
|
+
contentWidth,
|
|
775
|
+
2
|
|
776
|
+
).map((line, j) => (
|
|
777
|
+
<text key={`res-${j}`} fg={msg.isError ? "#ff5555" : "#6c6c7c"}>
|
|
778
|
+
{margin} {line}
|
|
779
|
+
</text>
|
|
780
|
+
))
|
|
781
|
+
) : (
|
|
782
|
+
<text fg="#00f5ff">{margin} Executing...</text>
|
|
783
|
+
)}
|
|
784
|
+
</box>
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Expandable tools (bash, webfetch, and others)
|
|
789
|
+
const isCollapsed = msg.collapsed;
|
|
790
|
+
|
|
791
|
+
return (
|
|
792
|
+
<box flexDirection="column" width={contentWidth} marginTop={marginTop}>
|
|
793
|
+
{isCollapsed ? (
|
|
794
|
+
<box flexDirection="row" onMouseUp={handleToggle}>
|
|
795
|
+
<text fg={statusColor}>{margin}{statusIcon} </text>
|
|
796
|
+
<text fg={msg.isError ? "#ff5555" : "#6c6c7c"}>
|
|
797
|
+
{summary}{msg.isError ? " [error]" : ""} (ctrl+o to expand)
|
|
798
|
+
</text>
|
|
799
|
+
</box>
|
|
800
|
+
) : (
|
|
801
|
+
<>
|
|
802
|
+
<box flexDirection="row" onMouseUp={handleToggle}>
|
|
803
|
+
<text fg={statusColor}>{margin}{statusIcon} </text>
|
|
804
|
+
<text fg={msg.isError ? "#ff5555" : "#6c6c7c"}>{msg.toolName}</text>
|
|
805
|
+
</box>
|
|
806
|
+
{wrapText(JSON.stringify(msg.args, null, 2), contentWidth, 2).map((line, j) => (
|
|
807
|
+
<text key={`args-${j}`} fg="#6c6c7c">{margin} {line}</text>
|
|
808
|
+
))}
|
|
809
|
+
{msg.result !== undefined ? (
|
|
810
|
+
<>
|
|
811
|
+
<text fg="#6c6c7c">{margin} ──</text>
|
|
812
|
+
{msg.toolName === "bash" ? (
|
|
813
|
+
<BashToolContent result={msg.result} contentWidth={contentWidth} />
|
|
814
|
+
) : msg.toolName === "webfetch" ? (
|
|
815
|
+
<WebfetchToolContent result={msg.result} contentWidth={contentWidth} />
|
|
816
|
+
) : (
|
|
817
|
+
wrapText(
|
|
818
|
+
msg.isError ? `Error: ${extractToolResultText(msg.result)}` : extractToolResultText(msg.result),
|
|
819
|
+
contentWidth,
|
|
820
|
+
2
|
|
821
|
+
).map((line, j) => (
|
|
822
|
+
<text key={`res-${j}`} fg={msg.isError ? "#ff5555" : "#6c6c7c"}>
|
|
823
|
+
{margin} {line}
|
|
824
|
+
</text>
|
|
825
|
+
))
|
|
826
|
+
)}
|
|
827
|
+
</>
|
|
828
|
+
) : (
|
|
829
|
+
<text fg="#00f5ff">{margin} Executing...</text>
|
|
830
|
+
)}
|
|
831
|
+
</>
|
|
832
|
+
)}
|
|
833
|
+
</box>
|
|
834
|
+
);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function SystemMessage({
|
|
838
|
+
msg,
|
|
839
|
+
contentWidth,
|
|
840
|
+
marginTop,
|
|
841
|
+
}: {
|
|
842
|
+
msg: UIMessage & { type: "system" };
|
|
843
|
+
contentWidth: number;
|
|
844
|
+
marginTop: number;
|
|
845
|
+
}) {
|
|
846
|
+
const wrapped = wrapText(msg.content, contentWidth, 0);
|
|
847
|
+
return (
|
|
848
|
+
<box flexDirection="column" width={contentWidth} marginTop={marginTop}>
|
|
849
|
+
{wrapped.map((line, j) => (
|
|
850
|
+
<text key={j} fg="#6c6c7c">{line}</text>
|
|
851
|
+
))}
|
|
852
|
+
</box>
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function SimpleMessage({ msg, marginTop, spinnerFrame }: { msg: UIMessage & { type: "compaction" | "retry" }; marginTop: number; spinnerFrame?: number }) {
|
|
857
|
+
const margin = " ";
|
|
858
|
+
// For compaction messages, show spinner during compaction, dot on success
|
|
859
|
+
if (msg.type === "compaction") {
|
|
860
|
+
const isCompacting = msg.content.includes("Compacting");
|
|
861
|
+
const isSuccess = msg.content === "Session compacted." || msg.content === "Compaction aborted.";
|
|
862
|
+
if (isCompacting) {
|
|
863
|
+
return <text fg="#00f5ff" marginTop={marginTop}>{margin}{SPINNER[spinnerFrame ?? 0]} {msg.content}</text>;
|
|
864
|
+
}
|
|
865
|
+
if (isSuccess) {
|
|
866
|
+
return <text fg="#6c6c7c" marginTop={marginTop}>{margin}· {msg.content}</text>;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
return <text fg="#6c6c7c" marginTop={marginTop}>{msg.content}</text>;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function PlanMessage({
|
|
873
|
+
msg,
|
|
874
|
+
contentWidth,
|
|
875
|
+
marginTop,
|
|
876
|
+
}: {
|
|
877
|
+
msg: UIMessage & { type: "plan" };
|
|
878
|
+
contentWidth: number;
|
|
879
|
+
marginTop: number;
|
|
880
|
+
}) {
|
|
881
|
+
const syntaxStyle = useMemo(() => buildSyntaxStyle(), []);
|
|
882
|
+
return (
|
|
883
|
+
<box
|
|
884
|
+
flexDirection="column"
|
|
885
|
+
width={contentWidth}
|
|
886
|
+
marginTop={marginTop}
|
|
887
|
+
borderStyle="rounded"
|
|
888
|
+
borderColor="#60a5fa"
|
|
889
|
+
backgroundColor="#1e3a5f"
|
|
890
|
+
paddingX={1}
|
|
891
|
+
paddingY={1}
|
|
892
|
+
>
|
|
893
|
+
<text fg="#60a5fa" attributes={createTextAttributes({ bold: true })}>
|
|
894
|
+
{"📋 Plan"}
|
|
895
|
+
</text>
|
|
896
|
+
<box marginTop={1}>
|
|
897
|
+
<MarkdownContent
|
|
898
|
+
content={msg.content}
|
|
899
|
+
width={contentWidth - 2}
|
|
900
|
+
streaming={false}
|
|
901
|
+
syntaxStyle={syntaxStyle}
|
|
902
|
+
/>
|
|
903
|
+
</box>
|
|
904
|
+
</box>
|
|
905
|
+
);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* Syntax Style
|
|
910
|
+
*
|
|
911
|
+
* Registers Dracula-like colors for markdown elements rendered by OpenTUI's native markdown component.
|
|
912
|
+
*/
|
|
913
|
+
|
|
914
|
+
function buildSyntaxStyle() {
|
|
915
|
+
const style = SyntaxStyle.create();
|
|
916
|
+
for (let i = 1; i <= 6; i++) {
|
|
917
|
+
style.registerStyle(`markup.heading.${i}`, { fg: "#ff79c6", bold: true });
|
|
918
|
+
}
|
|
919
|
+
style.registerStyle("markup.heading", { fg: "#ff79c6", bold: true });
|
|
920
|
+
style.registerStyle("markup.strong", { bold: true });
|
|
921
|
+
style.registerStyle("markup.italic", { fg: "#bd93f9", italic: true });
|
|
922
|
+
style.registerStyle("markup.strikethrough", {});
|
|
923
|
+
style.registerStyle("markup.link", { fg: "#8be9fd", underline: true });
|
|
924
|
+
style.registerStyle("markup.link.label", { fg: "#8be9fd", underline: true });
|
|
925
|
+
style.registerStyle("markup.link.url", { fg: "#8be9fd" });
|
|
926
|
+
style.registerStyle("markup.raw", { fg: "#a5b4fc" });
|
|
927
|
+
style.registerStyle("markup.raw.block", { fg: "#f8f8f2", bg: "#44475a" });
|
|
928
|
+
style.registerStyle("markup.list", { fg: "#ff79c6" });
|
|
929
|
+
style.registerStyle("markup.list.unchecked", { fg: "#ff79c6" });
|
|
930
|
+
style.registerStyle("markup.list.checked", { fg: "#ff79c6" });
|
|
931
|
+
style.registerStyle("markup.quote", { fg: "#6272a4" });
|
|
932
|
+
style.registerStyle("punctuation.special", { fg: "#6272a4" });
|
|
933
|
+
return style;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function MarkdownContent({
|
|
937
|
+
content,
|
|
938
|
+
width,
|
|
939
|
+
streaming,
|
|
940
|
+
syntaxStyle,
|
|
941
|
+
}: {
|
|
942
|
+
content: string;
|
|
943
|
+
width: number;
|
|
944
|
+
streaming: boolean;
|
|
945
|
+
syntaxStyle: SyntaxStyle;
|
|
946
|
+
}) {
|
|
947
|
+
return (
|
|
948
|
+
<markdown
|
|
949
|
+
content={content}
|
|
950
|
+
syntaxStyle={syntaxStyle}
|
|
951
|
+
width={width}
|
|
952
|
+
streaming={streaming}
|
|
953
|
+
conceal={true}
|
|
954
|
+
tableOptions={{ borderColor: "#6272a4", style: "columns" }}
|
|
955
|
+
/>
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Margin Calculator
|
|
961
|
+
*
|
|
962
|
+
* Consecutive tool_call messages are rendered with 0 margin so they visually group
|
|
963
|
+
* into a single "tool batch". All other adjacent pairs get 1 line of separation.
|
|
964
|
+
*/
|
|
965
|
+
|
|
966
|
+
function getMarginTop(messages: UIMessage[], msgIdx: number): number {
|
|
967
|
+
if (msgIdx === 0) return 0;
|
|
968
|
+
const prevMsg = messages[msgIdx - 1];
|
|
969
|
+
const msg = messages[msgIdx];
|
|
970
|
+
if (msg?.type === "tool_call" && prevMsg?.type === "tool_call") return 0;
|
|
971
|
+
return 1;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* ChatPanel
|
|
976
|
+
*
|
|
977
|
+
* Scrollable message history with sticky bottom scroll.
|
|
978
|
+
* Exposes imperative scroll handles (scrollUp/scrollDown) for the global keyboard shortcuts.
|
|
979
|
+
*/
|
|
980
|
+
|
|
981
|
+
export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
|
982
|
+
function ChatPanel({ messages, width = 80, height, isStreaming, onToggleCollapse, onImageClick }, ref) {
|
|
983
|
+
const scrollboxRef = useRef<ScrollBoxRenderable>(null);
|
|
984
|
+
const panelHeight = Math.max(1, height ?? 10);
|
|
985
|
+
const contentWidth = Math.max(1, (width ?? 80) - 2);
|
|
986
|
+
const [spinnerFrame, setSpinnerFrame] = useState(0);
|
|
987
|
+
useEffect(() => {
|
|
988
|
+
const hasThinkingInProgress = messages.some(
|
|
989
|
+
(m) => m.type === "agent" && m.thinking && m.thinkingStartTime && !m.thinkingEndTime
|
|
990
|
+
);
|
|
991
|
+
const hasToolExecuting = messages.some(
|
|
992
|
+
(m) => m.type === "tool_call" && m.result === undefined && !m.isError
|
|
993
|
+
);
|
|
994
|
+
const hasCompacting = messages.some(
|
|
995
|
+
(m) => m.type === "compaction" && m.content.includes("Compacting")
|
|
996
|
+
);
|
|
997
|
+
if (!hasThinkingInProgress && !hasToolExecuting && !hasCompacting) return;
|
|
998
|
+
const interval = setInterval(() => setSpinnerFrame((f) => (f + 1) % SPINNER.length), 80);
|
|
999
|
+
return () => clearInterval(interval);
|
|
1000
|
+
}, [messages]);
|
|
1001
|
+
|
|
1002
|
+
useImperativeHandle(ref, () => ({
|
|
1003
|
+
scrollToBottom: () => {
|
|
1004
|
+
const sb = scrollboxRef.current;
|
|
1005
|
+
if (sb) sb.scrollTo({ x: 0, y: sb.scrollHeight });
|
|
1006
|
+
},
|
|
1007
|
+
scrollUp: () => scrollboxRef.current?.scrollBy(-3, "step"),
|
|
1008
|
+
scrollDown: () => scrollboxRef.current?.scrollBy(3, "step"),
|
|
1009
|
+
}));
|
|
1010
|
+
|
|
1011
|
+
return (
|
|
1012
|
+
<scrollbox
|
|
1013
|
+
ref={scrollboxRef}
|
|
1014
|
+
width={width}
|
|
1015
|
+
height={panelHeight}
|
|
1016
|
+
flexGrow={1}
|
|
1017
|
+
stickyScroll={true}
|
|
1018
|
+
stickyStart="bottom"
|
|
1019
|
+
scrollY={true}
|
|
1020
|
+
scrollX={false}
|
|
1021
|
+
>
|
|
1022
|
+
<box flexDirection="column" width={contentWidth}>
|
|
1023
|
+
<text />
|
|
1024
|
+
{messages.map((msg, msgIdx) => {
|
|
1025
|
+
const isLast = msgIdx === messages.length - 1;
|
|
1026
|
+
const msgStreaming = Boolean(isStreaming && isLast);
|
|
1027
|
+
const marginTop = getMarginTop(messages, msgIdx);
|
|
1028
|
+
|
|
1029
|
+
switch (msg.type) {
|
|
1030
|
+
case "user":
|
|
1031
|
+
return <UserMessage key={msg.id} msg={msg} contentWidth={contentWidth} marginTop={marginTop} />;
|
|
1032
|
+
case "agent":
|
|
1033
|
+
return (
|
|
1034
|
+
<AgentMessage
|
|
1035
|
+
key={msg.id}
|
|
1036
|
+
msg={msg}
|
|
1037
|
+
contentWidth={contentWidth}
|
|
1038
|
+
marginTop={marginTop}
|
|
1039
|
+
isStreaming={msgStreaming}
|
|
1040
|
+
spinnerFrame={spinnerFrame}
|
|
1041
|
+
onImageClick={onImageClick ?? (() => {})}
|
|
1042
|
+
/>
|
|
1043
|
+
);
|
|
1044
|
+
case "status":
|
|
1045
|
+
return <StatusMessage key={msg.id} msg={msg} marginTop={marginTop} />;
|
|
1046
|
+
case "tool_call":
|
|
1047
|
+
return (
|
|
1048
|
+
<ToolCallMessage
|
|
1049
|
+
key={msg.id}
|
|
1050
|
+
msg={msg}
|
|
1051
|
+
contentWidth={contentWidth}
|
|
1052
|
+
marginTop={marginTop}
|
|
1053
|
+
spinnerFrame={spinnerFrame}
|
|
1054
|
+
onToggleCollapse={onToggleCollapse}
|
|
1055
|
+
/>
|
|
1056
|
+
);
|
|
1057
|
+
case "system":
|
|
1058
|
+
return <SystemMessage key={msg.id} msg={msg} contentWidth={contentWidth} marginTop={marginTop} />;
|
|
1059
|
+
case "compaction":
|
|
1060
|
+
case "retry":
|
|
1061
|
+
return <SimpleMessage key={msg.id} msg={msg} marginTop={marginTop} spinnerFrame={spinnerFrame} />;
|
|
1062
|
+
case "plan":
|
|
1063
|
+
return <PlanMessage key={msg.id} msg={msg} contentWidth={contentWidth} marginTop={marginTop} />;
|
|
1064
|
+
}
|
|
1065
|
+
})}
|
|
1066
|
+
<text />
|
|
1067
|
+
</box>
|
|
1068
|
+
</scrollbox>
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
);
|