botholomew 0.3.1 → 0.3.2

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.
Files changed (61) hide show
  1. package/package.json +2 -2
  2. package/src/chat/agent.ts +62 -16
  3. package/src/chat/session.ts +19 -6
  4. package/src/cli.ts +2 -0
  5. package/src/commands/thread.ts +180 -0
  6. package/src/config/schemas.ts +3 -1
  7. package/src/daemon/large-results.ts +15 -3
  8. package/src/daemon/llm.ts +22 -7
  9. package/src/daemon/prompt.ts +1 -9
  10. package/src/daemon/tick.ts +9 -0
  11. package/src/db/threads.ts +17 -0
  12. package/src/init/templates.ts +1 -0
  13. package/src/tools/context/read-large-result.ts +2 -1
  14. package/src/tools/context/search.ts +2 -0
  15. package/src/tools/context/update-beliefs.ts +2 -0
  16. package/src/tools/context/update-goals.ts +2 -0
  17. package/src/tools/dir/create.ts +3 -2
  18. package/src/tools/dir/list.ts +2 -1
  19. package/src/tools/dir/size.ts +2 -1
  20. package/src/tools/dir/tree.ts +3 -2
  21. package/src/tools/file/copy.ts +2 -1
  22. package/src/tools/file/count-lines.ts +2 -1
  23. package/src/tools/file/delete.ts +3 -2
  24. package/src/tools/file/edit.ts +2 -1
  25. package/src/tools/file/exists.ts +2 -1
  26. package/src/tools/file/info.ts +2 -0
  27. package/src/tools/file/move.ts +2 -1
  28. package/src/tools/file/read.ts +2 -1
  29. package/src/tools/file/write.ts +3 -2
  30. package/src/tools/mcp/exec.ts +70 -3
  31. package/src/tools/mcp/info.ts +8 -0
  32. package/src/tools/mcp/list-tools.ts +18 -6
  33. package/src/tools/mcp/search.ts +38 -10
  34. package/src/tools/registry.ts +2 -0
  35. package/src/tools/schedule/create.ts +2 -0
  36. package/src/tools/schedule/list.ts +2 -0
  37. package/src/tools/search/grep.ts +3 -2
  38. package/src/tools/search/semantic.ts +2 -0
  39. package/src/tools/task/complete.ts +2 -0
  40. package/src/tools/task/create.ts +17 -4
  41. package/src/tools/task/fail.ts +2 -0
  42. package/src/tools/task/list.ts +2 -0
  43. package/src/tools/task/update.ts +87 -0
  44. package/src/tools/task/view.ts +3 -1
  45. package/src/tools/task/wait.ts +2 -0
  46. package/src/tools/thread/list.ts +2 -0
  47. package/src/tools/thread/view.ts +3 -1
  48. package/src/tools/tool.ts +5 -3
  49. package/src/tui/App.tsx +209 -82
  50. package/src/tui/components/ContextPanel.tsx +6 -3
  51. package/src/tui/components/HelpPanel.tsx +52 -3
  52. package/src/tui/components/InputBar.tsx +125 -59
  53. package/src/tui/components/MessageList.tsx +40 -75
  54. package/src/tui/components/StatusBar.tsx +9 -8
  55. package/src/tui/components/TabBar.tsx +4 -2
  56. package/src/tui/components/TaskPanel.tsx +409 -0
  57. package/src/tui/components/ThreadPanel.tsx +541 -0
  58. package/src/tui/components/ToolCall.tsx +36 -3
  59. package/src/tui/components/ToolPanel.tsx +40 -31
  60. package/src/tui/theme.ts +20 -3
  61. package/src/utils/title.ts +47 -0
@@ -0,0 +1,541 @@
1
+ import { Box, Text, useInput, useStdout } from "ink";
2
+ import { memo, useCallback, useEffect, useMemo, useState } from "react";
3
+ import type { DbConnection } from "../../db/connection.ts";
4
+ import {
5
+ deleteThread,
6
+ getThread,
7
+ type Interaction,
8
+ listThreads,
9
+ type Thread,
10
+ } from "../../db/threads.ts";
11
+ import { ansi, theme } from "../theme.ts";
12
+
13
+ interface ThreadPanelProps {
14
+ conn: DbConnection;
15
+ activeThreadId: string;
16
+ isActive: boolean;
17
+ }
18
+
19
+ const SIDEBAR_WIDTH = 42;
20
+ const PAGE_SCROLL_LINES = 10;
21
+
22
+ const THREAD_TYPES: readonly Thread["type"][] = [
23
+ "daemon_tick",
24
+ "chat_session",
25
+ ] as const;
26
+
27
+ const TYPE_LABELS: Record<Thread["type"], string> = {
28
+ daemon_tick: "daemon",
29
+ chat_session: "agent",
30
+ };
31
+
32
+ const TYPE_ICONS: Record<Thread["type"], string> = {
33
+ daemon_tick: "⚙",
34
+ chat_session: "💬",
35
+ };
36
+
37
+ const TYPE_COLORS: Record<Thread["type"], string> = {
38
+ daemon_tick: theme.accent,
39
+ chat_session: theme.info,
40
+ };
41
+
42
+ const TYPE_ANSI: Record<Thread["type"], string> = {
43
+ daemon_tick: ansi.accent,
44
+ chat_session: ansi.info,
45
+ };
46
+
47
+ const ROLE_ANSI: Record<string, string> = {
48
+ user: ansi.success,
49
+ assistant: ansi.info,
50
+ system: ansi.toolName,
51
+ tool: ansi.accent,
52
+ };
53
+
54
+ function formatDuration(start: Date, end: Date | null): string {
55
+ const endTime = end ?? new Date();
56
+ const ms = endTime.getTime() - start.getTime();
57
+ if (ms < 1000) return `${ms}ms`;
58
+ const secs = Math.floor(ms / 1000);
59
+ if (secs < 60) return `${secs}s`;
60
+ const mins = Math.floor(secs / 60);
61
+ if (mins < 60) return `${mins}m ${secs % 60}s`;
62
+ const hrs = Math.floor(mins / 60);
63
+ return `${hrs}h ${mins % 60}m`;
64
+ }
65
+
66
+ function formatDate(d: Date): string {
67
+ return d.toLocaleString([], {
68
+ month: "short",
69
+ day: "numeric",
70
+ hour: "2-digit",
71
+ minute: "2-digit",
72
+ });
73
+ }
74
+
75
+ function buildThreadDetailAnsi(
76
+ thread: Thread,
77
+ interactions: Interaction[],
78
+ isActiveThread: boolean,
79
+ ): string {
80
+ const lines: string[] = [];
81
+
82
+ lines.push(
83
+ `${ansi.bold}${ansi.info}${thread.title || "(untitled)"}${ansi.reset}`,
84
+ );
85
+ lines.push("");
86
+
87
+ const typeAnsi = TYPE_ANSI[thread.type];
88
+ lines.push(
89
+ `${ansi.bold}${ansi.primary}Type${ansi.reset} ${typeAnsi}${TYPE_ICONS[thread.type]} ${TYPE_LABELS[thread.type]}${ansi.reset}`,
90
+ );
91
+
92
+ if (thread.task_id) {
93
+ lines.push(
94
+ `${ansi.bold}${ansi.primary}Task${ansi.reset} ${ansi.dim}${thread.task_id}${ansi.reset}`,
95
+ );
96
+ }
97
+
98
+ lines.push(
99
+ `${ansi.bold}${ansi.primary}Started${ansi.reset} ${ansi.dim}${formatDate(thread.started_at)}${ansi.reset}`,
100
+ );
101
+ lines.push(
102
+ `${ansi.bold}${ansi.primary}Ended${ansi.reset} ${thread.ended_at ? `${ansi.dim}${formatDate(thread.ended_at)}${ansi.reset}` : `${ansi.success}ongoing${ansi.reset}`}`,
103
+ );
104
+ lines.push(
105
+ `${ansi.bold}${ansi.primary}Duration${ansi.reset} ${ansi.dim}${formatDuration(thread.started_at, thread.ended_at)}${ansi.reset}`,
106
+ );
107
+
108
+ if (isActiveThread) {
109
+ lines.push("");
110
+ lines.push(
111
+ `${ansi.bold}${ansi.success}★ Current session thread${ansi.reset}`,
112
+ );
113
+ }
114
+
115
+ lines.push("");
116
+ lines.push(
117
+ `${ansi.bold}${ansi.primary}Interactions${ansi.reset} ${interactions.length} total`,
118
+ );
119
+
120
+ // Breakdown by role
121
+ const byRole: Record<string, number> = {};
122
+ const byKind: Record<string, number> = {};
123
+ for (const ix of interactions) {
124
+ byRole[ix.role] = (byRole[ix.role] ?? 0) + 1;
125
+ byKind[ix.kind] = (byKind[ix.kind] ?? 0) + 1;
126
+ }
127
+
128
+ const roleSummary = Object.entries(byRole)
129
+ .map(
130
+ ([role, count]) =>
131
+ `${ROLE_ANSI[role] ?? ansi.dim}${role}${ansi.reset}: ${count}`,
132
+ )
133
+ .join(" ");
134
+ if (roleSummary) {
135
+ lines.push(` ${roleSummary}`);
136
+ }
137
+
138
+ const kindSummary = Object.entries(byKind)
139
+ .map(([kind, count]) => `${ansi.dim}${kind}${ansi.reset}: ${count}`)
140
+ .join(" ");
141
+ if (kindSummary) {
142
+ lines.push(` ${kindSummary}`);
143
+ }
144
+
145
+ // Condensed interaction timeline
146
+ if (interactions.length > 0) {
147
+ lines.push("");
148
+ lines.push(`${ansi.bold}${ansi.primary}Timeline${ansi.reset}`);
149
+ for (const ix of interactions) {
150
+ const roleColor = ROLE_ANSI[ix.role] ?? ansi.dim;
151
+ const time = ix.created_at.toLocaleTimeString([], {
152
+ hour: "2-digit",
153
+ minute: "2-digit",
154
+ second: "2-digit",
155
+ });
156
+ const preview =
157
+ ix.content.length > 60 ? `${ix.content.slice(0, 57)}...` : ix.content;
158
+ const firstLine = preview.split("\n")[0] ?? "";
159
+ const toolTag = ix.tool_name
160
+ ? ` ${ansi.toolName}[${ix.tool_name}]${ansi.reset}`
161
+ : "";
162
+ lines.push(
163
+ ` ${ansi.dim}${String(ix.sequence).padStart(3)}${ansi.reset} ${roleColor}${ix.role}${ansi.reset} ${ansi.dim}${ix.kind}${ansi.reset}${toolTag} ${ansi.dim}${time}${ansi.reset}`,
164
+ );
165
+ if (firstLine) {
166
+ lines.push(` ${ansi.dim}${firstLine}${ansi.reset}`);
167
+ }
168
+ }
169
+ }
170
+
171
+ return lines.join("\n");
172
+ }
173
+
174
+ function cycleFilter<T>(current: T | null, values: readonly T[]): T | null {
175
+ if (current === null) return values[0] ?? null;
176
+ const idx = values.indexOf(current);
177
+ if (idx === -1 || idx === values.length - 1) return null;
178
+ return values[idx + 1] ?? null;
179
+ }
180
+
181
+ export const ThreadPanel = memo(function ThreadPanel({
182
+ conn,
183
+ activeThreadId,
184
+ isActive,
185
+ }: ThreadPanelProps) {
186
+ const { stdout } = useStdout();
187
+ const termRows = stdout?.rows ?? 24;
188
+ const [threads, setThreads] = useState<Thread[]>([]);
189
+ const [selectedIndex, setSelectedIndex] = useState(0);
190
+ const [detailScroll, setDetailScroll] = useState(0);
191
+ const [typeFilter, setTypeFilter] = useState<Thread["type"] | null>(null);
192
+ const [refreshTick, setRefreshTick] = useState(0);
193
+ const [confirmDelete, setConfirmDelete] = useState(false);
194
+ const [searching, setSearching] = useState(false);
195
+ const [searchQuery, setSearchQuery] = useState("");
196
+ const [selectedDetail, setSelectedDetail] = useState<{
197
+ thread: Thread;
198
+ interactions: Interaction[];
199
+ } | null>(null);
200
+
201
+ // Fetch thread list
202
+ // biome-ignore lint/correctness/useExhaustiveDependencies: refreshTick triggers manual refresh
203
+ useEffect(() => {
204
+ let mounted = true;
205
+
206
+ const refresh = async () => {
207
+ const filters: { type?: Thread["type"] } = {};
208
+ if (typeFilter) filters.type = typeFilter;
209
+ const result = await listThreads(conn, filters);
210
+ if (mounted) {
211
+ setThreads(result);
212
+ setSelectedIndex((prev) =>
213
+ Math.min(prev, Math.max(0, result.length - 1)),
214
+ );
215
+ }
216
+ };
217
+
218
+ refresh();
219
+ const interval = setInterval(refresh, 5000);
220
+ return () => {
221
+ mounted = false;
222
+ clearInterval(interval);
223
+ };
224
+ }, [conn, typeFilter, refreshTick]);
225
+
226
+ // Filter threads by search query
227
+ const filteredThreads = useMemo(() => {
228
+ if (!searchQuery) return threads;
229
+ const q = searchQuery.toLowerCase();
230
+ return threads.filter((t) => t.title.toLowerCase().includes(q));
231
+ }, [threads, searchQuery]);
232
+
233
+ // Fetch detail for selected thread
234
+ const selectedThread = filteredThreads[selectedIndex];
235
+ // biome-ignore lint/correctness/useExhaustiveDependencies: selectedThread?.id is the intentional trigger
236
+ useEffect(() => {
237
+ let mounted = true;
238
+ if (!selectedThread) {
239
+ setSelectedDetail(null);
240
+ return;
241
+ }
242
+
243
+ getThread(conn, selectedThread.id).then((result) => {
244
+ if (mounted && result) {
245
+ setSelectedDetail(result);
246
+ }
247
+ });
248
+
249
+ return () => {
250
+ mounted = false;
251
+ };
252
+ }, [conn, selectedThread?.id]);
253
+
254
+ const isActiveSelected = selectedThread?.id === activeThreadId;
255
+
256
+ const renderedDetail = useMemo(() => {
257
+ if (!selectedDetail) return "";
258
+ return buildThreadDetailAnsi(
259
+ selectedDetail.thread,
260
+ selectedDetail.interactions,
261
+ selectedDetail.thread.id === activeThreadId,
262
+ );
263
+ }, [selectedDetail, activeThreadId]);
264
+
265
+ const detailLines = useMemo(
266
+ () => renderedDetail.split("\n"),
267
+ [renderedDetail],
268
+ );
269
+
270
+ const visibleRows = Math.max(1, termRows - 6);
271
+ const maxDetailScroll = Math.max(0, detailLines.length - visibleRows);
272
+ const sidebarScrollOffset = Math.max(
273
+ 0,
274
+ Math.min(
275
+ selectedIndex - Math.floor(visibleRows / 2),
276
+ filteredThreads.length - visibleRows,
277
+ ),
278
+ );
279
+
280
+ // Reset detail scroll when selection changes
281
+ // biome-ignore lint/correctness/useExhaustiveDependencies: selectedIndex is the intentional trigger
282
+ useEffect(() => {
283
+ setDetailScroll(0);
284
+ }, [selectedIndex]);
285
+
286
+ const forceRefresh = useCallback(() => {
287
+ setRefreshTick((t) => t + 1);
288
+ }, []);
289
+
290
+ useInput(
291
+ (input, key) => {
292
+ // Search mode: capture typed characters
293
+ if (searching) {
294
+ if (key.escape) {
295
+ setSearching(false);
296
+ setSearchQuery("");
297
+ return;
298
+ }
299
+ if (key.return) {
300
+ setSearching(false);
301
+ return;
302
+ }
303
+ if (key.backspace || key.delete) {
304
+ setSearchQuery((q) => q.slice(0, -1));
305
+ setSelectedIndex(0);
306
+ return;
307
+ }
308
+ if (input && !key.ctrl && !key.meta) {
309
+ setSearchQuery((q) => q + input);
310
+ setSelectedIndex(0);
311
+ return;
312
+ }
313
+ return;
314
+ }
315
+
316
+ // Delete confirmation mode
317
+ if (confirmDelete) {
318
+ if (input === "y" || input === "d") {
319
+ if (selectedThread && !isActiveSelected) {
320
+ deleteThread(conn, selectedThread.id).then(() => {
321
+ forceRefresh();
322
+ });
323
+ }
324
+ setConfirmDelete(false);
325
+ } else {
326
+ setConfirmDelete(false);
327
+ }
328
+ return;
329
+ }
330
+
331
+ if (key.upArrow) {
332
+ if (key.shift) {
333
+ setDetailScroll((s) => Math.max(0, s - 1));
334
+ } else {
335
+ setSelectedIndex((i) => Math.max(0, i - 1));
336
+ }
337
+ return;
338
+ }
339
+ if (key.downArrow) {
340
+ if (key.shift) {
341
+ setDetailScroll((s) => Math.min(maxDetailScroll, s + 1));
342
+ } else {
343
+ setSelectedIndex((i) => Math.min(filteredThreads.length - 1, i + 1));
344
+ }
345
+ return;
346
+ }
347
+
348
+ if (input === "j") {
349
+ setDetailScroll((s) => Math.min(maxDetailScroll, s + 1));
350
+ return;
351
+ }
352
+ if (input === "k") {
353
+ setDetailScroll((s) => Math.max(0, s - 1));
354
+ return;
355
+ }
356
+ if (input === "J") {
357
+ setDetailScroll((s) =>
358
+ Math.min(maxDetailScroll, s + PAGE_SCROLL_LINES),
359
+ );
360
+ return;
361
+ }
362
+ if (input === "K") {
363
+ setDetailScroll((s) => Math.max(0, s - PAGE_SCROLL_LINES));
364
+ return;
365
+ }
366
+ if (input === "g") {
367
+ setDetailScroll(0);
368
+ return;
369
+ }
370
+ if (input === "G") {
371
+ setDetailScroll(maxDetailScroll);
372
+ return;
373
+ }
374
+
375
+ if (input === "f") {
376
+ setTypeFilter((f) => cycleFilter(f, THREAD_TYPES));
377
+ return;
378
+ }
379
+ if (input === "d" && selectedThread) {
380
+ if (isActiveSelected) return; // Can't delete active thread
381
+ setConfirmDelete(true);
382
+ return;
383
+ }
384
+ if (input === "r") {
385
+ forceRefresh();
386
+ return;
387
+ }
388
+ if (input === "s" || input === "/") {
389
+ setSearching(true);
390
+ setSearchQuery("");
391
+ return;
392
+ }
393
+ },
394
+ { isActive },
395
+ );
396
+
397
+ if (filteredThreads.length === 0) {
398
+ return (
399
+ <Box flexDirection="column" flexGrow={1} paddingX={1}>
400
+ <Text dimColor>
401
+ {searchQuery
402
+ ? `No threads match "${searchQuery}". Press Escape to clear search.`
403
+ : typeFilter
404
+ ? "No threads match the current filter. Press f to change filter."
405
+ : "No threads found. Threads will appear as chat sessions and daemon ticks occur."}
406
+ </Text>
407
+ {typeFilter && (
408
+ <Box marginTop={1}>
409
+ <Text color={TYPE_COLORS[typeFilter]}>
410
+ {TYPE_ICONS[typeFilter]} {TYPE_LABELS[typeFilter]}
411
+ </Text>
412
+ </Box>
413
+ )}
414
+ </Box>
415
+ );
416
+ }
417
+
418
+ const sidebarVisible = filteredThreads.slice(
419
+ sidebarScrollOffset,
420
+ sidebarScrollOffset + visibleRows,
421
+ );
422
+
423
+ const detailVisible = detailLines.slice(
424
+ detailScroll,
425
+ detailScroll + visibleRows,
426
+ );
427
+
428
+ return (
429
+ <Box flexGrow={1} height={visibleRows + 1} overflow="hidden">
430
+ {/* Left sidebar: thread list */}
431
+ <Box
432
+ flexDirection="column"
433
+ width={SIDEBAR_WIDTH}
434
+ height={visibleRows + 1}
435
+ borderStyle="single"
436
+ borderColor={theme.muted}
437
+ borderRight
438
+ borderTop={false}
439
+ borderBottom={false}
440
+ borderLeft={false}
441
+ overflow="hidden"
442
+ >
443
+ <Box paddingX={1} gap={1}>
444
+ <Text bold dimColor>
445
+ Threads ({filteredThreads.length})
446
+ </Text>
447
+ {typeFilter && (
448
+ <Text color={TYPE_COLORS[typeFilter]}>
449
+ [{TYPE_ICONS[typeFilter]} {TYPE_LABELS[typeFilter]}]
450
+ </Text>
451
+ )}
452
+ {!searching && searchQuery && <Text dimColor>🔍 {searchQuery}</Text>}
453
+ </Box>
454
+ {searching && (
455
+ <Box paddingX={1}>
456
+ <Text color={theme.info}>🔍 </Text>
457
+ <Text color={theme.info}>{searchQuery}</Text>
458
+ <Text color={theme.info}>▌</Text>
459
+ </Box>
460
+ )}
461
+ {confirmDelete && selectedThread && (
462
+ <Box paddingX={1}>
463
+ <Text color="red" bold>
464
+ Delete thread? (y/n)
465
+ </Text>
466
+ </Box>
467
+ )}
468
+ {sidebarVisible.map((thread, vi) => {
469
+ const i = vi + sidebarScrollOffset;
470
+ const isSelected = i === selectedIndex;
471
+ const icon = TYPE_ICONS[thread.type];
472
+ const isActive = thread.id === activeThreadId;
473
+ const dateStr = thread.started_at.toLocaleDateString([], {
474
+ month: "short",
475
+ day: "numeric",
476
+ });
477
+ const maxName = SIDEBAR_WIDTH - 15; // icon + date + padding
478
+ const title = thread.title || "(untitled)";
479
+ const nameDisplay =
480
+ title.length > maxName ? `${title.slice(0, maxName - 1)}…` : title;
481
+ return (
482
+ <Box key={thread.id} paddingX={1}>
483
+ <Text
484
+ backgroundColor={isSelected ? theme.selectionBg : undefined}
485
+ bold={isSelected}
486
+ color={isSelected ? theme.info : undefined}
487
+ wrap="truncate-end"
488
+ >
489
+ {isSelected ? "▸" : " "}{" "}
490
+ <Text color={TYPE_COLORS[thread.type]} bold={false}>
491
+ {icon}
492
+ </Text>{" "}
493
+ {nameDisplay}
494
+ {isActive && (
495
+ <Text color={theme.success} bold={false}>
496
+ {" "}
497
+
498
+ </Text>
499
+ )}
500
+ <Text dimColor bold={false}>
501
+ {" "}
502
+ {dateStr}
503
+ </Text>
504
+ </Text>
505
+ </Box>
506
+ );
507
+ })}
508
+ </Box>
509
+
510
+ {/* Right detail pane */}
511
+ <Box
512
+ flexDirection="column"
513
+ flexGrow={1}
514
+ height={visibleRows + 1}
515
+ paddingX={1}
516
+ overflow="hidden"
517
+ >
518
+ {detailVisible.map((line, i) => {
519
+ const lineNum = detailScroll + i;
520
+ return <Text key={lineNum}>{line || " "}</Text>;
521
+ })}
522
+ {detailLines.length > visibleRows && (
523
+ <Box>
524
+ <Text dimColor>
525
+ s search · f filter · ↑↓ select · j/k scroll · d delete · r
526
+ refresh · [{detailScroll + 1}–
527
+ {Math.min(detailScroll + visibleRows, detailLines.length)} of{" "}
528
+ {detailLines.length}]
529
+ </Text>
530
+ </Box>
531
+ )}
532
+ {detailLines.length <= visibleRows && <Box flexGrow={1} />}
533
+ {detailLines.length <= visibleRows && (
534
+ <Text dimColor>
535
+ s search · f filter · ↑↓ select · d delete · r refresh
536
+ </Text>
537
+ )}
538
+ </Box>
539
+ </Box>
540
+ );
541
+ });
@@ -24,12 +24,21 @@ export function resolveToolDisplay(
24
24
  }
25
25
  }
26
26
 
27
+ export interface LargeResultMeta {
28
+ id: string;
29
+ chars: number;
30
+ pages: number;
31
+ }
32
+
27
33
  export interface ToolCallData {
34
+ id: string;
28
35
  name: string;
29
36
  input: string;
30
37
  output?: string;
31
38
  running: boolean;
32
39
  timestamp: Date;
40
+ largeResult?: LargeResultMeta;
41
+ isError?: boolean;
33
42
  }
34
43
 
35
44
  interface ToolCallProps {
@@ -48,10 +57,27 @@ export function ToolCall({ tool }: ToolCallProps) {
48
57
  return (
49
58
  <Box flexDirection="column">
50
59
  <Box>
51
- <Text color={tool.running ? theme.accent : theme.muted}>
52
- {tool.running ? " ⟳ " : " ✔ "}
60
+ <Text
61
+ color={
62
+ tool.running
63
+ ? theme.accent
64
+ : tool.isError
65
+ ? theme.error
66
+ : theme.muted
67
+ }
68
+ >
69
+ {tool.running ? " ⟳ " : tool.isError ? " ✘ " : " ✔ "}
53
70
  </Text>
54
- <Text color={tool.running ? theme.accent : theme.toolName} bold>
71
+ <Text
72
+ color={
73
+ tool.running
74
+ ? theme.accent
75
+ : tool.isError
76
+ ? theme.error
77
+ : theme.toolName
78
+ }
79
+ bold
80
+ >
55
81
  {displayName}
56
82
  </Text>
57
83
  {tool.name === "mcp_exec" && <Text dimColor> (exec)</Text>}
@@ -63,6 +89,13 @@ export function ToolCall({ tool }: ToolCallProps) {
63
89
  {truncatedOutput}
64
90
  </Text>
65
91
  )}
92
+ {tool.largeResult && !tool.running && (
93
+ <Text color="yellow" wrap="truncate-end">
94
+ {" ⚡ "}
95
+ Paginated for LLM [{Math.round(tool.largeResult.chars / 1000)}K,{" "}
96
+ {tool.largeResult.pages}pg]
97
+ </Text>
98
+ )}
66
99
  </Box>
67
100
  );
68
101
  }