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.
- package/package.json +2 -2
- package/src/chat/agent.ts +62 -16
- package/src/chat/session.ts +19 -6
- package/src/cli.ts +2 -0
- package/src/commands/thread.ts +180 -0
- package/src/config/schemas.ts +3 -1
- package/src/daemon/large-results.ts +15 -3
- package/src/daemon/llm.ts +22 -7
- package/src/daemon/prompt.ts +1 -9
- package/src/daemon/tick.ts +9 -0
- package/src/db/threads.ts +17 -0
- package/src/init/templates.ts +1 -0
- package/src/tools/context/read-large-result.ts +2 -1
- package/src/tools/context/search.ts +2 -0
- package/src/tools/context/update-beliefs.ts +2 -0
- package/src/tools/context/update-goals.ts +2 -0
- package/src/tools/dir/create.ts +3 -2
- package/src/tools/dir/list.ts +2 -1
- package/src/tools/dir/size.ts +2 -1
- package/src/tools/dir/tree.ts +3 -2
- package/src/tools/file/copy.ts +2 -1
- package/src/tools/file/count-lines.ts +2 -1
- package/src/tools/file/delete.ts +3 -2
- package/src/tools/file/edit.ts +2 -1
- package/src/tools/file/exists.ts +2 -1
- package/src/tools/file/info.ts +2 -0
- package/src/tools/file/move.ts +2 -1
- package/src/tools/file/read.ts +2 -1
- package/src/tools/file/write.ts +3 -2
- package/src/tools/mcp/exec.ts +70 -3
- package/src/tools/mcp/info.ts +8 -0
- package/src/tools/mcp/list-tools.ts +18 -6
- package/src/tools/mcp/search.ts +38 -10
- package/src/tools/registry.ts +2 -0
- package/src/tools/schedule/create.ts +2 -0
- package/src/tools/schedule/list.ts +2 -0
- package/src/tools/search/grep.ts +3 -2
- package/src/tools/search/semantic.ts +2 -0
- package/src/tools/task/complete.ts +2 -0
- package/src/tools/task/create.ts +17 -4
- package/src/tools/task/fail.ts +2 -0
- package/src/tools/task/list.ts +2 -0
- package/src/tools/task/update.ts +87 -0
- package/src/tools/task/view.ts +3 -1
- package/src/tools/task/wait.ts +2 -0
- package/src/tools/thread/list.ts +2 -0
- package/src/tools/thread/view.ts +3 -1
- package/src/tools/tool.ts +5 -3
- package/src/tui/App.tsx +209 -82
- package/src/tui/components/ContextPanel.tsx +6 -3
- package/src/tui/components/HelpPanel.tsx +52 -3
- package/src/tui/components/InputBar.tsx +125 -59
- package/src/tui/components/MessageList.tsx +40 -75
- package/src/tui/components/StatusBar.tsx +9 -8
- package/src/tui/components/TabBar.tsx +4 -2
- package/src/tui/components/TaskPanel.tsx +409 -0
- package/src/tui/components/ThreadPanel.tsx +541 -0
- package/src/tui/components/ToolCall.tsx +36 -3
- package/src/tui/components/ToolPanel.tsx +40 -31
- package/src/tui/theme.ts +20 -3
- 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
|
|
52
|
-
{
|
|
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
|
|
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
|
}
|