botholomew 0.3.0 → 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 (70) hide show
  1. package/README.md +9 -0
  2. package/package.json +3 -1
  3. package/src/chat/agent.ts +87 -23
  4. package/src/chat/session.ts +19 -6
  5. package/src/cli.ts +2 -0
  6. package/src/commands/chat.ts +5 -2
  7. package/src/commands/context.ts +91 -35
  8. package/src/commands/thread.ts +180 -0
  9. package/src/config/schemas.ts +3 -1
  10. package/src/context/embedder.ts +0 -3
  11. package/src/daemon/context.ts +146 -0
  12. package/src/daemon/large-results.ts +100 -0
  13. package/src/daemon/llm.ts +45 -19
  14. package/src/daemon/prompt.ts +1 -6
  15. package/src/daemon/tick.ts +9 -0
  16. package/src/db/sql/4-unique_context_path.sql +1 -0
  17. package/src/db/threads.ts +17 -0
  18. package/src/init/templates.ts +2 -1
  19. package/src/tools/context/read-large-result.ts +33 -0
  20. package/src/tools/context/search.ts +2 -0
  21. package/src/tools/context/update-beliefs.ts +2 -0
  22. package/src/tools/context/update-goals.ts +2 -0
  23. package/src/tools/dir/create.ts +3 -2
  24. package/src/tools/dir/list.ts +2 -1
  25. package/src/tools/dir/size.ts +2 -1
  26. package/src/tools/dir/tree.ts +3 -2
  27. package/src/tools/file/copy.ts +12 -3
  28. package/src/tools/file/count-lines.ts +2 -1
  29. package/src/tools/file/delete.ts +3 -2
  30. package/src/tools/file/edit.ts +3 -2
  31. package/src/tools/file/exists.ts +2 -1
  32. package/src/tools/file/info.ts +2 -0
  33. package/src/tools/file/move.ts +12 -3
  34. package/src/tools/file/read.ts +2 -1
  35. package/src/tools/file/write.ts +5 -4
  36. package/src/tools/mcp/exec.ts +70 -3
  37. package/src/tools/mcp/info.ts +8 -0
  38. package/src/tools/mcp/list-tools.ts +18 -6
  39. package/src/tools/mcp/search.ts +38 -10
  40. package/src/tools/registry.ts +4 -0
  41. package/src/tools/schedule/create.ts +2 -0
  42. package/src/tools/schedule/list.ts +2 -0
  43. package/src/tools/search/grep.ts +3 -2
  44. package/src/tools/search/semantic.ts +2 -0
  45. package/src/tools/task/complete.ts +2 -0
  46. package/src/tools/task/create.ts +17 -4
  47. package/src/tools/task/fail.ts +2 -0
  48. package/src/tools/task/list.ts +2 -0
  49. package/src/tools/task/update.ts +87 -0
  50. package/src/tools/task/view.ts +3 -1
  51. package/src/tools/task/wait.ts +2 -0
  52. package/src/tools/thread/list.ts +2 -0
  53. package/src/tools/thread/view.ts +3 -1
  54. package/src/tools/tool.ts +7 -3
  55. package/src/tui/App.tsx +323 -78
  56. package/src/tui/components/ContextPanel.tsx +415 -0
  57. package/src/tui/components/Divider.tsx +14 -0
  58. package/src/tui/components/HelpPanel.tsx +166 -0
  59. package/src/tui/components/InputBar.tsx +157 -47
  60. package/src/tui/components/Logo.tsx +79 -0
  61. package/src/tui/components/MessageList.tsx +50 -23
  62. package/src/tui/components/QueuePanel.tsx +57 -0
  63. package/src/tui/components/StatusBar.tsx +21 -9
  64. package/src/tui/components/TabBar.tsx +40 -0
  65. package/src/tui/components/TaskPanel.tsx +409 -0
  66. package/src/tui/components/ThreadPanel.tsx +541 -0
  67. package/src/tui/components/ToolCall.tsx +68 -5
  68. package/src/tui/components/ToolPanel.tsx +295 -281
  69. package/src/tui/theme.ts +75 -0
  70. package/src/utils/title.ts +47 -0
@@ -1,328 +1,342 @@
1
- import { Box, Text, useInput } from "ink";
2
- import { useMemo, useState } from "react";
3
- import type { ToolCallData } from "./ToolCall.tsx";
1
+ import { Box, Text, useInput, useStdout } from "ink";
2
+ import { memo, useEffect, useMemo, useState } from "react";
3
+ import { ansi, theme } from "../theme.ts";
4
+ import { resolveToolDisplay, type ToolCallData } from "./ToolCall.tsx";
4
5
 
5
6
  interface ToolPanelProps {
6
7
  toolCalls: ToolCallData[];
7
- onClose: () => void;
8
+ isActive: boolean;
8
9
  }
9
10
 
10
- /** A flattened row in the JSON tree */
11
- interface TreeRow {
12
- depth: number;
13
- key: string;
14
- value: string | null; // null = expandable parent
15
- path: string;
16
- hasChildren: boolean;
17
- }
18
-
19
- function flattenJson(
20
- obj: unknown,
21
- parentPath: string,
22
- depth: number,
23
- expanded: Set<string>,
24
- ): TreeRow[] {
25
- const rows: TreeRow[] = [];
11
+ const SIDEBAR_WIDTH = 42;
26
12
 
27
- if (obj === null || obj === undefined) {
28
- return rows;
13
+ /** Try to parse a string as JSON; returns the parsed value or undefined on failure */
14
+ function tryParseJson(str: string): unknown | undefined {
15
+ try {
16
+ return JSON.parse(str);
17
+ } catch {
18
+ return undefined;
29
19
  }
20
+ }
30
21
 
31
- if (Array.isArray(obj)) {
32
- for (let i = 0; i < obj.length; i++) {
33
- const path = `${parentPath}[${i}]`;
34
- const child = obj[i];
35
- if (typeof child === "object" && child !== null) {
36
- rows.push({
37
- depth,
38
- key: `[${i}]`,
39
- value: expanded.has(path) ? null : `[…]`,
40
- path,
41
- hasChildren: true,
42
- });
43
- if (expanded.has(path)) {
44
- rows.push(...flattenJson(child, path, depth + 1, expanded));
45
- }
46
- } else {
47
- rows.push({
48
- depth,
49
- key: `[${i}]`,
50
- value: formatValue(child),
51
- path,
52
- hasChildren: false,
53
- });
54
- }
55
- }
56
- } else if (typeof obj === "object") {
57
- for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
58
- const path = parentPath ? `${parentPath}.${k}` : k;
59
- if (typeof v === "object" && v !== null) {
60
- const childCount = Array.isArray(v) ? v.length : Object.keys(v).length;
61
- const preview = Array.isArray(v)
62
- ? `[${childCount} items]`
63
- : `{${childCount} keys}`;
64
- rows.push({
65
- depth,
66
- key: k,
67
- value: expanded.has(path) ? null : preview,
68
- path,
69
- hasChildren: true,
70
- });
71
- if (expanded.has(path)) {
72
- rows.push(...flattenJson(v, path, depth + 1, expanded));
73
- }
74
- } else {
75
- rows.push({
76
- depth,
77
- key: k,
78
- value: formatValue(v),
79
- path,
80
- hasChildren: false,
81
- });
82
- }
22
+ /** Colorize a JSON string with ANSI codes */
23
+ function colorizeJson(str: string): string {
24
+ const parsed = tryParseJson(str);
25
+ if (parsed === undefined) return str;
26
+ return colorizeValue(parsed, 0);
27
+ }
28
+
29
+ function colorizeValue(value: unknown, indent: number): string {
30
+ if (value === null) return `${ansi.toolName}null${ansi.reset}`;
31
+ if (typeof value === "boolean")
32
+ return `${ansi.toolName}${value ? "true" : "false"}${ansi.reset}`;
33
+ if (typeof value === "number") return `${ansi.accent}${value}${ansi.reset}`;
34
+ if (typeof value === "string") {
35
+ // Try to unwrap stringified JSON (common in tool results)
36
+ const inner = tryParseJson(value);
37
+ if (inner !== undefined && typeof inner === "object" && inner !== null) {
38
+ return colorizeValue(inner, indent);
83
39
  }
40
+ const escaped = JSON.stringify(value);
41
+ return `${ansi.success}${escaped}${ansi.reset}`;
84
42
  }
85
43
 
86
- return rows;
87
- }
44
+ const pad = " ".repeat(indent);
45
+ const innerPad = " ".repeat(indent + 1);
88
46
 
89
- function formatValue(v: unknown): string {
90
- if (v === null) return "null";
91
- if (v === undefined) return "undefined";
92
- if (typeof v === "string") {
93
- if (v.length > 80) return `"${v.slice(0, 77)}…"`;
94
- return `"${v}"`;
47
+ if (Array.isArray(value)) {
48
+ if (value.length === 0) return "[]";
49
+ const items = value.map(
50
+ (v) => `${innerPad}${colorizeValue(v, indent + 1)}`,
51
+ );
52
+ return `[\n${items.join(",\n")}\n${pad}]`;
95
53
  }
96
- return String(v);
97
- }
98
54
 
99
- function safeParseJson(str: string): unknown {
100
- try {
101
- return JSON.parse(str);
102
- } catch {
103
- return str;
55
+ if (typeof value === "object") {
56
+ const entries = Object.entries(value as Record<string, unknown>);
57
+ if (entries.length === 0) return "{}";
58
+ const lines = entries.map(
59
+ ([k, v]) =>
60
+ `${innerPad}${ansi.info}${JSON.stringify(k)}${ansi.reset}: ${colorizeValue(v, indent + 1)}`,
61
+ );
62
+ return `{\n${lines.join(",\n")}\n${pad}}`;
104
63
  }
64
+
65
+ return String(value);
105
66
  }
106
67
 
107
- type PanelTab = "input" | "output";
108
-
109
- export function ToolPanel({ toolCalls, onClose }: ToolPanelProps) {
110
- const [selectedTool, setSelectedTool] = useState(0);
111
- const [tab, setTab] = useState<PanelTab>("input");
112
- const [cursor, setCursor] = useState(0);
113
- const [expanded, setExpanded] = useState<Set<string>>(new Set());
114
-
115
- const tool = toolCalls[selectedTool];
116
-
117
- const data = useMemo(() => {
118
- if (!tool) return null;
119
- return tab === "input"
120
- ? safeParseJson(tool.input)
121
- : safeParseJson(tool.output ?? "");
122
- }, [tool, tab]);
123
-
124
- const rows = useMemo(() => {
125
- if (data === null || data === undefined) return [];
126
- if (typeof data === "string") {
127
- return data
128
- .split("\n")
129
- .filter((l) => l.trim())
130
- .map(
131
- (line, i): TreeRow => ({
132
- depth: 0,
133
- key: "",
134
- value: line,
135
- path: `line-${i}`,
136
- hasChildren: false,
137
- }),
68
+ function buildDetailAnsi(tool: ToolCallData): string {
69
+ const lines: string[] = [];
70
+
71
+ const time = tool.timestamp.toLocaleTimeString([], {
72
+ hour: "2-digit",
73
+ minute: "2-digit",
74
+ second: "2-digit",
75
+ });
76
+
77
+ const { displayName, displayInput } = resolveToolDisplay(
78
+ tool.name,
79
+ tool.input,
80
+ );
81
+ lines.push(`${ansi.bold}${ansi.info}${displayName}${ansi.reset}`);
82
+ if (tool.name === "mcp_exec") {
83
+ lines.push(`${ansi.dim}via mcp_exec${ansi.reset}`);
84
+ }
85
+ lines.push(`${ansi.dim}Time: ${time}${ansi.reset}`);
86
+ if (tool.running) {
87
+ lines.push(`${ansi.accent}⟳ running${ansi.reset}`);
88
+ }
89
+ lines.push("");
90
+
91
+ lines.push(`${ansi.bold}${ansi.primary}Input${ansi.reset}`);
92
+ lines.push(colorizeJson(displayInput));
93
+ lines.push("");
94
+
95
+ if (tool.output) {
96
+ if (tool.isError) {
97
+ lines.push(`${ansi.bold}${ansi.error}Error${ansi.reset}`);
98
+ lines.push(`${ansi.error}${colorizeJson(tool.output)}${ansi.reset}`);
99
+ } else {
100
+ lines.push(`${ansi.bold}${ansi.primary}Output${ansi.reset}`);
101
+ if (tool.largeResult) {
102
+ lines.push(
103
+ `${ansi.accent}Paginated for LLM: ${tool.largeResult.chars.toLocaleString()} chars, ${tool.largeResult.pages} page(s) — stored as ${tool.largeResult.id}${ansi.reset}`,
138
104
  );
105
+ }
106
+ lines.push(colorizeJson(tool.output));
139
107
  }
140
- return flattenJson(data, "", 0, expanded);
141
- }, [data, expanded]);
108
+ } else if (!tool.running) {
109
+ lines.push(`${ansi.bold}${ansi.primary}Output${ansi.reset}`);
110
+ lines.push(`${ansi.dim}(no output)${ansi.reset}`);
111
+ }
142
112
 
143
- useInput((input, key) => {
144
- if (key.escape) {
145
- onClose();
146
- return;
147
- }
113
+ return lines.join("\n");
114
+ }
148
115
 
149
- if (key.tab) {
150
- setTab((t) => (t === "input" ? "output" : "input"));
151
- setCursor(0);
152
- setExpanded(new Set());
153
- return;
154
- }
116
+ const PAGE_SCROLL_LINES = 10;
155
117
 
156
- if (key.leftArrow) {
157
- setSelectedTool((i) => Math.max(0, i - 1));
158
- setCursor(0);
159
- setExpanded(new Set());
160
- return;
161
- }
162
- if (key.rightArrow) {
163
- setSelectedTool((i) => Math.min(toolCalls.length - 1, i + 1));
164
- setCursor(0);
165
- setExpanded(new Set());
166
- return;
167
- }
118
+ export const ToolPanel = memo(function ToolPanel({
119
+ toolCalls,
120
+ isActive,
121
+ }: ToolPanelProps) {
122
+ const { stdout } = useStdout();
123
+ const termRows = stdout?.rows ?? 24;
124
+ const [selectedIndex, setSelectedIndex] = useState(0);
125
+ const [detailScroll, setDetailScroll] = useState(0);
168
126
 
169
- if (key.upArrow) {
170
- setCursor((c) => Math.max(0, c - 1));
171
- return;
172
- }
173
- if (key.downArrow) {
174
- setCursor((c) => Math.min(rows.length - 1, c + 1));
175
- return;
176
- }
127
+ // Reverse-chronological order (most recent first)
128
+ const reversedCalls = useMemo(() => [...toolCalls].reverse(), [toolCalls]);
177
129
 
178
- if (key.return) {
179
- const row = rows[cursor];
180
- if (row?.hasChildren) {
181
- setExpanded((prev) => {
182
- const next = new Set(prev);
183
- if (next.has(row.path)) {
184
- for (const p of next) {
185
- if (
186
- p === row.path ||
187
- p.startsWith(`${row.path}.`) ||
188
- p.startsWith(`${row.path}[`)
189
- ) {
190
- next.delete(p);
191
- }
192
- }
193
- } else {
194
- next.add(row.path);
195
- }
196
- return next;
197
- });
198
- }
199
- return;
130
+ // Keep selection in bounds when new calls arrive
131
+ useEffect(() => {
132
+ if (selectedIndex >= reversedCalls.length && reversedCalls.length > 0) {
133
+ setSelectedIndex(reversedCalls.length - 1);
200
134
  }
135
+ }, [reversedCalls.length, selectedIndex]);
136
+
137
+ const selectedTool = reversedCalls[selectedIndex];
138
+
139
+ const renderedDetail = useMemo(() => {
140
+ if (!selectedTool) return "";
141
+ return buildDetailAnsi(selectedTool);
142
+ }, [selectedTool]);
201
143
 
202
- if (input === "e") {
203
- const allPaths = new Set<string>();
204
- const expandAll = (obj: unknown, parentPath: string) => {
205
- if (typeof obj === "object" && obj !== null) {
206
- if (Array.isArray(obj)) {
207
- for (let i = 0; i < obj.length; i++) {
208
- const p = `${parentPath}[${i}]`;
209
- if (typeof obj[i] === "object" && obj[i] !== null) {
210
- allPaths.add(p);
211
- expandAll(obj[i], p);
212
- }
213
- }
214
- } else {
215
- for (const [k, v] of Object.entries(obj)) {
216
- const p = parentPath ? `${parentPath}.${k}` : k;
217
- if (typeof v === "object" && v !== null) {
218
- allPaths.add(p);
219
- expandAll(v, p);
220
- }
221
- }
222
- }
144
+ const detailLines = useMemo(
145
+ () => renderedDetail.split("\n"),
146
+ [renderedDetail],
147
+ );
148
+
149
+ // Visible area for sidebar and detail
150
+ const visibleRows = Math.max(1, termRows - 6); // chrome: tab bar, divider, status, input, borders
151
+ const maxDetailScroll = Math.max(0, detailLines.length - visibleRows);
152
+ const sidebarScrollOffset = Math.max(
153
+ 0,
154
+ Math.min(
155
+ selectedIndex - Math.floor(visibleRows / 2),
156
+ reversedCalls.length - visibleRows,
157
+ ),
158
+ );
159
+
160
+ // Reset detail scroll when selection changes
161
+ // biome-ignore lint/correctness/useExhaustiveDependencies: selectedIndex is the intentional trigger
162
+ useEffect(() => {
163
+ setDetailScroll(0);
164
+ }, [selectedIndex]);
165
+
166
+ useInput(
167
+ (input, key) => {
168
+ if (key.upArrow) {
169
+ if (key.shift) {
170
+ // Shift+up scrolls detail
171
+ setDetailScroll((s) => Math.max(0, s - 1));
172
+ } else {
173
+ setSelectedIndex((i) => Math.max(0, i - 1));
223
174
  }
224
- };
225
- if (data && typeof data === "object") expandAll(data, "");
226
- setExpanded(allPaths);
227
- return;
228
- }
175
+ return;
176
+ }
177
+ if (key.downArrow) {
178
+ if (key.shift) {
179
+ setDetailScroll((s) => Math.min(maxDetailScroll, s + 1));
180
+ } else {
181
+ setSelectedIndex((i) => Math.min(reversedCalls.length - 1, i + 1));
182
+ }
183
+ return;
184
+ }
229
185
 
230
- if (input === "c") {
231
- setExpanded(new Set());
232
- setCursor(0);
233
- }
234
- });
186
+ // j/k vim-style for detail scrolling (single line)
187
+ if (input === "j") {
188
+ setDetailScroll((s) => Math.min(maxDetailScroll, s + 1));
189
+ return;
190
+ }
191
+ if (input === "k") {
192
+ setDetailScroll((s) => Math.max(0, s - 1));
193
+ return;
194
+ }
235
195
 
236
- if (!tool) return null;
196
+ // J/K for page scrolling (hold shift or caps)
197
+ if (input === "J") {
198
+ setDetailScroll((s) =>
199
+ Math.min(maxDetailScroll, s + PAGE_SCROLL_LINES),
200
+ );
201
+ return;
202
+ }
203
+ if (input === "K") {
204
+ setDetailScroll((s) => Math.max(0, s - PAGE_SCROLL_LINES));
205
+ return;
206
+ }
237
207
 
238
- const hasOutput = Boolean(tool.output);
208
+ // g/G for top/bottom
209
+ if (input === "g") {
210
+ setDetailScroll(0);
211
+ return;
212
+ }
213
+ if (input === "G") {
214
+ setDetailScroll(maxDetailScroll);
215
+ return;
216
+ }
217
+ },
218
+ { isActive },
219
+ );
239
220
 
240
- return (
241
- <Box
242
- flexDirection="column"
243
- borderStyle="round"
244
- borderColor="cyan"
245
- paddingX={1}
246
- height={16}
247
- >
248
- {/* Header */}
249
- <Box justifyContent="space-between">
250
- <Box>
251
- <Text bold color="cyan">
252
- 🔍 Tool Inspector
253
- </Text>
254
- <Text dimColor>
255
- {" "}
256
- ({selectedTool + 1}/{toolCalls.length})
257
- </Text>
258
- </Box>
221
+ if (reversedCalls.length === 0) {
222
+ return (
223
+ <Box flexDirection="column" flexGrow={1} paddingX={1}>
259
224
  <Text dimColor>
260
- esc close · ←→ tools · tab switch · ↑↓ navigate · enter expand · e/c
261
- all
262
- </Text>
263
- </Box>
264
-
265
- {/* Tool name */}
266
- <Box>
267
- <Text bold color="magenta">
268
- {tool.name}
225
+ No tool calls to inspect yet. Tool calls will appear here as the agent
226
+ uses them.
269
227
  </Text>
270
- {tool.running && <Text color="yellow"> ⟳ running</Text>}
271
228
  </Box>
229
+ );
230
+ }
272
231
 
273
- {/* Tabs */}
274
- <Box gap={2}>
275
- <Text
276
- bold={tab === "input"}
277
- color={tab === "input" ? "green" : undefined}
278
- dimColor={tab !== "input"}
279
- >
280
- {tab === "input" ? "▸ " : " "}Input
281
- </Text>
282
- <Text
283
- bold={tab === "output"}
284
- color={tab === "output" ? "green" : undefined}
285
- dimColor={tab !== "output" && !hasOutput}
286
- >
287
- {tab === "output" ? "▸ " : " "}Output{!hasOutput ? " (none)" : ""}
288
- </Text>
289
- </Box>
232
+ // Sidebar visible window
233
+ const sidebarVisible = reversedCalls.slice(
234
+ sidebarScrollOffset,
235
+ sidebarScrollOffset + visibleRows,
236
+ );
290
237
 
291
- {/* Tree content */}
292
- <Box flexDirection="column" flexGrow={1} overflow="hidden">
293
- {rows.length === 0 && <Text dimColor> (empty)</Text>}
294
- {rows.map((row, i) => {
295
- const isSelected = i === cursor;
296
- const indent = " ".repeat(row.depth);
297
- const arrow = row.hasChildren
298
- ? expanded.has(row.path)
299
- ? "▾ "
300
- : "▸ "
301
- : " ";
238
+ // Detail visible window
239
+ const detailVisible = detailLines.slice(
240
+ detailScroll,
241
+ detailScroll + visibleRows,
242
+ );
302
243
 
244
+ return (
245
+ <Box flexGrow={1} height={visibleRows + 1} overflow="hidden">
246
+ {/* Left sidebar: tool call list */}
247
+ <Box
248
+ flexDirection="column"
249
+ width={SIDEBAR_WIDTH}
250
+ height={visibleRows + 1}
251
+ borderStyle="single"
252
+ borderColor={theme.muted}
253
+ borderRight
254
+ borderTop={false}
255
+ borderBottom={false}
256
+ borderLeft={false}
257
+ overflow="hidden"
258
+ >
259
+ <Box paddingX={1}>
260
+ <Text bold dimColor>
261
+ Tool Calls ({reversedCalls.length})
262
+ </Text>
263
+ </Box>
264
+ {sidebarVisible.map((tc, vi) => {
265
+ const i = vi + sidebarScrollOffset;
266
+ const isSelected = i === selectedIndex;
267
+ const icon = tc.running ? "⟳" : tc.isError ? "✘" : "✔";
268
+ const time = tc.timestamp.toLocaleTimeString([], {
269
+ hour: "2-digit",
270
+ minute: "2-digit",
271
+ });
272
+ const { displayName } = resolveToolDisplay(tc.name, tc.input);
273
+ const maxName = SIDEBAR_WIDTH - 12; // icon + time + padding
274
+ const nameDisplay =
275
+ displayName.length > maxName
276
+ ? `${displayName.slice(0, maxName - 1)}…`
277
+ : displayName;
303
278
  return (
304
- <Box key={row.path}>
279
+ <Box key={tc.id} paddingX={1}>
305
280
  <Text
306
- backgroundColor={isSelected ? "#333" : undefined}
307
- color={isSelected ? "cyan" : undefined}
281
+ backgroundColor={isSelected ? theme.selectionBg : undefined}
282
+ bold={isSelected}
283
+ color={
284
+ isSelected
285
+ ? theme.info
286
+ : tc.running
287
+ ? theme.accent
288
+ : undefined
289
+ }
290
+ wrap="truncate-end"
308
291
  >
309
- {indent}
310
- {arrow}
311
- {row.key ? (
312
- <>
313
- <Text color="blue" bold={isSelected}>
314
- {row.key}
315
- </Text>
316
- {row.value !== null ? `: ${row.value}` : ""}
317
- </>
318
- ) : (
319
- (row.value ?? "")
320
- )}
292
+ {isSelected ? "▸" : " "}{" "}
293
+ <Text
294
+ color={
295
+ tc.running
296
+ ? theme.accent
297
+ : tc.isError
298
+ ? theme.error
299
+ : theme.muted
300
+ }
301
+ bold={false}
302
+ >
303
+ {icon}
304
+ </Text>{" "}
305
+ {nameDisplay}
306
+ <Text dimColor> {time}</Text>
321
307
  </Text>
322
308
  </Box>
323
309
  );
324
310
  })}
325
311
  </Box>
312
+
313
+ {/* Right detail pane */}
314
+ <Box
315
+ flexDirection="column"
316
+ flexGrow={1}
317
+ height={visibleRows + 1}
318
+ paddingX={1}
319
+ overflow="hidden"
320
+ >
321
+ {detailVisible.map((line, i) => {
322
+ const lineNum = detailScroll + i;
323
+ return <Text key={lineNum}>{line || " "}</Text>;
324
+ })}
325
+ {detailLines.length > visibleRows && (
326
+ <Box>
327
+ <Text dimColor>
328
+ ↑↓ select · j/k scroll · J/K page · g/G top/bottom · [
329
+ {detailScroll + 1}–
330
+ {Math.min(detailScroll + visibleRows, detailLines.length)} of{" "}
331
+ {detailLines.length}]
332
+ </Text>
333
+ </Box>
334
+ )}
335
+ {detailLines.length <= visibleRows && <Box flexGrow={1} />}
336
+ {detailLines.length <= visibleRows && (
337
+ <Text dimColor>↑↓ select tool calls</Text>
338
+ )}
339
+ </Box>
326
340
  </Box>
327
341
  );
328
- }
342
+ });