botholomew 0.16.0 → 0.16.3
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/README.md +5 -0
- package/package.json +2 -1
- package/src/context/fetcher.ts +2 -2
- package/src/context/markdown-converter.ts +2 -2
- package/src/tui/App.tsx +134 -789
- package/src/tui/components/ContextPanel.tsx +7 -5
- package/src/tui/components/MessageList.tsx +44 -21
- package/src/tui/components/SchedulePanel.tsx +7 -5
- package/src/tui/components/TabBar.tsx +1 -1
- package/src/tui/components/TabPanels.tsx +108 -0
- package/src/tui/components/TaskPanel.tsx +7 -5
- package/src/tui/components/ThreadPanel.tsx +7 -5
- package/src/tui/components/ToolCall.tsx +3 -2
- package/src/tui/components/ToolPanel.tsx +9 -5
- package/src/tui/handleSubmit.ts +206 -0
- package/src/tui/hooks/useAppKeybindings.ts +166 -0
- package/src/tui/hooks/useCaptureTabCycle.ts +28 -0
- package/src/tui/hooks/useChatSession.ts +151 -0
- package/src/tui/hooks/useChatTitlePolling.ts +36 -0
- package/src/tui/hooks/useMessageQueue.ts +254 -0
- package/src/tui/hooks/useTerminalRows.ts +20 -0
- package/src/tui/keys.ts +24 -0
- package/src/tui/messages.ts +11 -0
- package/src/tui/restoreMessages.ts +106 -0
- package/src/tui/useTerminalSize.ts +26 -0
- package/src/tui/wrapDetail.ts +15 -0
- package/src/worker/fake-llm.ts +60 -0
- package/src/worker/fake-mcp.ts +134 -0
package/src/tui/App.tsx
CHANGED
|
@@ -1,23 +1,9 @@
|
|
|
1
|
-
import { Box, Static, Text
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
abortActiveStream,
|
|
5
|
-
type ChatSession,
|
|
6
|
-
clearChatSession,
|
|
7
|
-
endChatSession,
|
|
8
|
-
sendMessage,
|
|
9
|
-
startChatSession,
|
|
10
|
-
} from "../chat/session.ts";
|
|
11
|
-
import type { ContextUsage } from "../chat/usage.ts";
|
|
1
|
+
import { Box, Static, Text } from "ink";
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
12
3
|
import {
|
|
13
4
|
BUILTIN_SLASH_COMMANDS,
|
|
14
|
-
handleSlashCommand,
|
|
15
5
|
type SlashCommand,
|
|
16
6
|
} from "../skills/commands.ts";
|
|
17
|
-
import { getThread, type Interaction } from "../threads/store.ts";
|
|
18
|
-
import { MAX_INLINE_CHARS, PAGE_SIZE_CHARS } from "../worker/large-results.ts";
|
|
19
|
-
import { ContextPanel } from "./components/ContextPanel.tsx";
|
|
20
|
-
import { HelpPanel } from "./components/HelpPanel.tsx";
|
|
21
7
|
import { InputBar } from "./components/InputBar.tsx";
|
|
22
8
|
import { AnimatedLogo } from "./components/Logo.tsx";
|
|
23
9
|
import {
|
|
@@ -26,17 +12,19 @@ import {
|
|
|
26
12
|
MessageList,
|
|
27
13
|
} from "./components/MessageList.tsx";
|
|
28
14
|
import { QueuePanel } from "./components/QueuePanel.tsx";
|
|
29
|
-
import { SchedulePanel } from "./components/SchedulePanel.tsx";
|
|
30
15
|
import { StatusBar } from "./components/StatusBar.tsx";
|
|
31
16
|
import { TabBar, type TabId } from "./components/TabBar.tsx";
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
34
|
-
import
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
17
|
+
import { TabPanels } from "./components/TabPanels.tsx";
|
|
18
|
+
import { useChatSubmit } from "./handleSubmit.ts";
|
|
19
|
+
import { useAppKeybindings } from "./hooks/useAppKeybindings.ts";
|
|
20
|
+
import { useCaptureTabCycle } from "./hooks/useCaptureTabCycle.ts";
|
|
21
|
+
import { useChatSession } from "./hooks/useChatSession.ts";
|
|
22
|
+
import { useChatTitlePolling } from "./hooks/useChatTitlePolling.ts";
|
|
23
|
+
import { useMessageQueue } from "./hooks/useMessageQueue.ts";
|
|
24
|
+
import { useTerminalRows } from "./hooks/useTerminalRows.ts";
|
|
37
25
|
import { IdleProvider, useIdle } from "./idle.tsx";
|
|
38
|
-
import {
|
|
39
|
-
import {
|
|
26
|
+
import { FOOTER_RESERVE } from "./messages.ts";
|
|
27
|
+
import { buildSlashCommands } from "./slashCompletion.ts";
|
|
40
28
|
|
|
41
29
|
interface AppProps {
|
|
42
30
|
projectDir: string;
|
|
@@ -45,106 +33,6 @@ interface AppProps {
|
|
|
45
33
|
idleTimeoutMs: number;
|
|
46
34
|
}
|
|
47
35
|
|
|
48
|
-
let nextMsgId = 0;
|
|
49
|
-
function msgId(): string {
|
|
50
|
-
return `msg-${++nextMsgId}`;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// Tab routing: Ctrl+<letter> jumps to a tab. Chosen for memorability — first
|
|
54
|
-
// available letter that doesn't collide with other Ctrl bindings (Ctrl+C exit,
|
|
55
|
-
// Ctrl+J/K/X/E queue ops on Chat).
|
|
56
|
-
//
|
|
57
|
-
// Help is bound to Ctrl+G rather than Ctrl+H because most terminals deliver
|
|
58
|
-
// Ctrl+H as ASCII 0x08 (backspace). Bonus: macOS Terminal.app and several
|
|
59
|
-
// other terminals map Ctrl+/ to BEL (0x07), the same byte as Ctrl+G — so this
|
|
60
|
-
// binding also catches the Ctrl+/ keystroke on those terminals "for free".
|
|
61
|
-
// We also accept "/" and "_" as fallbacks for terminals that deliver Ctrl+/
|
|
62
|
-
// as 0x1F or as the literal "/" with ctrl=true (Kitty keyboard protocol).
|
|
63
|
-
const TAB_BY_CTRL_KEY: Record<string, TabId> = {
|
|
64
|
-
a: 1, // ch[a]t
|
|
65
|
-
o: 2, // t[o]ols
|
|
66
|
-
n: 3, // co[n]text
|
|
67
|
-
t: 4, // [t]asks
|
|
68
|
-
e: 5, // thr[e]ads
|
|
69
|
-
s: 6, // [s]chedules
|
|
70
|
-
w: 7, // [w]orkers
|
|
71
|
-
g: 8, // help (also catches Ctrl+/ on terminals that map it to BEL)
|
|
72
|
-
"/": 8, // help (Kitty keyboard protocol)
|
|
73
|
-
_: 8, // help (terminals that send Ctrl+/ as 0x1F)
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
function detectToolError(output: string | undefined): boolean {
|
|
77
|
-
if (!output) return false;
|
|
78
|
-
try {
|
|
79
|
-
const parsed = JSON.parse(output);
|
|
80
|
-
if (typeof parsed === "object" && parsed?.is_error === true) return true;
|
|
81
|
-
} catch {
|
|
82
|
-
/* not JSON */
|
|
83
|
-
}
|
|
84
|
-
return false;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function restoreMessagesFromInteractions(
|
|
88
|
-
interactions: Interaction[],
|
|
89
|
-
): ChatMessage[] {
|
|
90
|
-
const result: ChatMessage[] = [];
|
|
91
|
-
let pendingTools: ToolCallData[] = [];
|
|
92
|
-
|
|
93
|
-
let restoredIdx = 0;
|
|
94
|
-
for (const ix of interactions) {
|
|
95
|
-
if (ix.kind === "tool_use") {
|
|
96
|
-
pendingTools.push({
|
|
97
|
-
id: `restored-${restoredIdx++}`,
|
|
98
|
-
name: ix.tool_name ?? "unknown",
|
|
99
|
-
input: ix.tool_input ?? "{}",
|
|
100
|
-
running: false,
|
|
101
|
-
timestamp: ix.created_at,
|
|
102
|
-
});
|
|
103
|
-
} else if (ix.kind === "tool_result") {
|
|
104
|
-
const tc = pendingTools.find((t) => t.name === ix.tool_name && !t.output);
|
|
105
|
-
if (tc) {
|
|
106
|
-
tc.output = ix.content;
|
|
107
|
-
tc.isError = detectToolError(ix.content);
|
|
108
|
-
if (ix.content.length > MAX_INLINE_CHARS) {
|
|
109
|
-
tc.largeResult = {
|
|
110
|
-
id: "(restored)",
|
|
111
|
-
chars: ix.content.length,
|
|
112
|
-
pages: Math.ceil(ix.content.length / PAGE_SIZE_CHARS),
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
} else if (ix.kind === "message" && ix.role === "user") {
|
|
117
|
-
result.push({
|
|
118
|
-
id: msgId(),
|
|
119
|
-
role: "user",
|
|
120
|
-
content: ix.content,
|
|
121
|
-
timestamp: ix.created_at,
|
|
122
|
-
});
|
|
123
|
-
} else if (ix.kind === "message" && ix.role === "assistant") {
|
|
124
|
-
result.push({
|
|
125
|
-
id: msgId(),
|
|
126
|
-
role: "assistant",
|
|
127
|
-
content: ix.content,
|
|
128
|
-
timestamp: ix.created_at,
|
|
129
|
-
toolCalls: pendingTools.length > 0 ? [...pendingTools] : undefined,
|
|
130
|
-
});
|
|
131
|
-
pendingTools = [];
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
if (pendingTools.length > 0) {
|
|
136
|
-
result.push({
|
|
137
|
-
id: msgId(),
|
|
138
|
-
role: "assistant",
|
|
139
|
-
content: "",
|
|
140
|
-
timestamp: new Date(),
|
|
141
|
-
toolCalls: [...pendingTools],
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
return result;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
36
|
export function App({
|
|
149
37
|
projectDir,
|
|
150
38
|
threadId: resumeThreadId,
|
|
@@ -173,26 +61,9 @@ function AppInner({
|
|
|
173
61
|
threadId: resumeThreadId,
|
|
174
62
|
initialPrompt,
|
|
175
63
|
}: AppInnerProps) {
|
|
176
|
-
const { exit } = useApp();
|
|
177
|
-
const { stdout } = useStdout();
|
|
178
64
|
const { markActivity } = useIdle();
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
// (`shouldClearTerminalForFrame` → `ansiEscapes.clearTerminal`) whenever
|
|
182
|
-
// the dynamic frame transitions in/out of fullscreen, so a fluctuating
|
|
183
|
-
// `outputHeight` (streaming text + tool boxes appearing/disappearing) used
|
|
184
|
-
// to delete the chat history on every turn. Ink doesn't pin a height on
|
|
185
|
-
// its internal root, so a `height="100%"` on our root collapses to `auto`
|
|
186
|
-
// — we have to pass the explicit row count and re-read it on resize.
|
|
187
|
-
const [rows, setRows] = useState(stdout?.rows ?? 24);
|
|
188
|
-
useEffect(() => {
|
|
189
|
-
if (!stdout) return;
|
|
190
|
-
const onResize = () => setRows(stdout.rows ?? 24);
|
|
191
|
-
stdout.on("resize", onResize);
|
|
192
|
-
return () => {
|
|
193
|
-
stdout.off("resize", onResize);
|
|
194
|
-
};
|
|
195
|
-
}, [stdout]);
|
|
65
|
+
const rows = useTerminalRows();
|
|
66
|
+
|
|
196
67
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
197
68
|
const [messagesEpoch, setMessagesEpoch] = useState(0);
|
|
198
69
|
// `clearing` gates new submissions while /clear's async work is in flight.
|
|
@@ -201,413 +72,49 @@ function AppInner({
|
|
|
201
72
|
// overwrites the user bubble it added — the message disappears.
|
|
202
73
|
const [clearing, setClearing] = useState(false);
|
|
203
74
|
const clearingRef = useRef(false);
|
|
204
|
-
const [usage, setUsage] = useState<ContextUsage | null>(null);
|
|
205
75
|
const [inputValue, setInputValue] = useState("");
|
|
206
76
|
const [inputHistory, setInputHistory] = useState<string[]>([]);
|
|
207
|
-
const [isLoading, setIsLoading] = useState(false);
|
|
208
|
-
const [streamingText, setStreamingText] = useState("");
|
|
209
|
-
const [activeToolCalls, setActiveToolCalls] = useState<ToolCallData[]>([]);
|
|
210
|
-
const [preparingTool, setPreparingTool] = useState<{
|
|
211
|
-
id: string;
|
|
212
|
-
name: string;
|
|
213
|
-
} | null>(null);
|
|
214
|
-
const [ready, setReady] = useState(false);
|
|
215
|
-
const skipSplash = !!(resumeThreadId || initialPrompt);
|
|
216
|
-
const [splashDone, setSplashDone] = useState(skipSplash);
|
|
217
77
|
const [error, setError] = useState<string | null>(null);
|
|
218
|
-
const sessionRef = useRef<ChatSession | null>(null);
|
|
219
|
-
const shuttingDownRef = useRef(false);
|
|
220
78
|
const [activeTab, setActiveTab] = useState<TabId>(1);
|
|
221
79
|
const [workerRunning, setWorkerRunning] = useState(false);
|
|
222
|
-
const [chatTitle, setChatTitle] = useState<string | undefined>(undefined);
|
|
223
|
-
const queueRef = useRef<Array<{ display: string; content: string }>>([]);
|
|
224
|
-
const processingRef = useRef(false);
|
|
225
|
-
const [queuedMessages, setQueuedMessages] = useState<string[]>([]);
|
|
226
|
-
const [selectedQueueIndex, setSelectedQueueIndex] = useState(0);
|
|
227
|
-
|
|
228
|
-
const syncQueue = useCallback(() => {
|
|
229
|
-
const snapshot = queueRef.current.map((e) => e.display);
|
|
230
|
-
setQueuedMessages(snapshot);
|
|
231
|
-
setSelectedQueueIndex((prev) =>
|
|
232
|
-
snapshot.length === 0 ? 0 : Math.min(prev, snapshot.length - 1),
|
|
233
|
-
);
|
|
234
|
-
}, []);
|
|
235
|
-
|
|
236
|
-
// Initialize session
|
|
237
|
-
useEffect(() => {
|
|
238
|
-
let cancelled = false;
|
|
239
|
-
|
|
240
|
-
startChatSession(projectDir, resumeThreadId)
|
|
241
|
-
.then(async (session) => {
|
|
242
|
-
if (cancelled) {
|
|
243
|
-
endChatSession(session);
|
|
244
|
-
return;
|
|
245
|
-
}
|
|
246
|
-
sessionRef.current = session;
|
|
247
|
-
|
|
248
|
-
if (session.messages.length > 0) {
|
|
249
|
-
const threadData = await getThread(
|
|
250
|
-
session.projectDir,
|
|
251
|
-
session.threadId,
|
|
252
|
-
);
|
|
253
|
-
if (threadData) {
|
|
254
|
-
setMessages(
|
|
255
|
-
restoreMessagesFromInteractions(threadData.interactions),
|
|
256
|
-
);
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
setMessages((prev) => [
|
|
261
|
-
...prev,
|
|
262
|
-
{
|
|
263
|
-
id: msgId(),
|
|
264
|
-
role: "system" as const,
|
|
265
|
-
content:
|
|
266
|
-
"Switch panels with Ctrl+<letter> (^a chat · ^o tools · ^n context · ^t tasks · ^r threads · ^s schedules · ^w workers) — `?` for help. Type /help for commands.",
|
|
267
|
-
timestamp: new Date(),
|
|
268
|
-
},
|
|
269
|
-
]);
|
|
270
|
-
|
|
271
|
-
setReady(true);
|
|
272
|
-
})
|
|
273
|
-
.catch((err) => {
|
|
274
|
-
setError(`Failed to start session: ${err}`);
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
return () => {
|
|
278
|
-
cancelled = true;
|
|
279
|
-
// Fire-and-forget safety net: only triggers when unmount happens via a
|
|
280
|
-
// path that didn't go through performShutdown (which nulls sessionRef
|
|
281
|
-
// first). React doesn't await unmount cleanups, so the goodbye lands
|
|
282
|
-
// before mcpx finishes closing — that's fine for non-Ctrl-C paths.
|
|
283
|
-
if (sessionRef.current) {
|
|
284
|
-
const session = sessionRef.current;
|
|
285
|
-
const threadId = session.threadId;
|
|
286
|
-
abortActiveStream(session);
|
|
287
|
-
void endChatSession(session);
|
|
288
|
-
process.stderr.write(
|
|
289
|
-
`\nThread: ${threadId}\nResume with: ${ansi.success}botholomew chat --thread-id ${threadId}${ansi.reset}\nBye!\n`,
|
|
290
|
-
);
|
|
291
|
-
}
|
|
292
|
-
};
|
|
293
|
-
}, [projectDir, resumeThreadId]);
|
|
294
|
-
|
|
295
|
-
const performShutdown = useCallback(async () => {
|
|
296
|
-
if (shuttingDownRef.current) {
|
|
297
|
-
// Second Ctrl-C while cleanup is in flight — give the user an escape
|
|
298
|
-
// hatch. 130 = standard SIGINT exit code.
|
|
299
|
-
process.exit(130);
|
|
300
|
-
}
|
|
301
|
-
shuttingDownRef.current = true;
|
|
302
|
-
|
|
303
|
-
const session = sessionRef.current;
|
|
304
|
-
// Null the ref so the useEffect cleanup that runs on Ink unmount becomes
|
|
305
|
-
// a no-op — otherwise it would double-print the goodbye and double-close
|
|
306
|
-
// the mcpx client.
|
|
307
|
-
sessionRef.current = null;
|
|
308
|
-
|
|
309
|
-
if (session) {
|
|
310
|
-
const threadId = session.threadId;
|
|
311
|
-
abortActiveStream(session);
|
|
312
|
-
try {
|
|
313
|
-
await endChatSession(session);
|
|
314
|
-
} catch {
|
|
315
|
-
// Best-effort: the user pressed Ctrl-C, surfacing a stack trace here
|
|
316
|
-
// would just hide the goodbye line.
|
|
317
|
-
}
|
|
318
|
-
process.stderr.write(
|
|
319
|
-
`\nThread: ${threadId}\nResume with: ${ansi.success}botholomew chat --thread-id ${threadId}${ansi.reset}\nBye!\n`,
|
|
320
|
-
);
|
|
321
|
-
}
|
|
322
|
-
exit();
|
|
323
|
-
}, [exit]);
|
|
324
|
-
|
|
325
|
-
// Minimum splash screen duration
|
|
326
|
-
useEffect(() => {
|
|
327
|
-
const timer = setTimeout(() => setSplashDone(true), 2000);
|
|
328
|
-
return () => clearTimeout(timer);
|
|
329
|
-
}, []);
|
|
330
|
-
|
|
331
|
-
// Capture-mode tab auto-cycle. Under VHS/ttyd the Tab key doesn't reliably
|
|
332
|
-
// reach Ink, so a docs tape can't drive the tab tour by keystroke. When
|
|
333
|
-
// BOTHOLOMEW_CAPTURE_TAB_CYCLE is set, schedule timers that walk through
|
|
334
|
-
// every tab so a single recording can show all panels.
|
|
335
|
-
//
|
|
336
|
-
// Format: "dwellMs" or "dwellMs:startDelayMs". The optional start delay
|
|
337
|
-
// lets a tape finish a streamed chat reply before the cycle kicks in.
|
|
338
|
-
useEffect(() => {
|
|
339
|
-
const spec = process.env.BOTHOLOMEW_CAPTURE_TAB_CYCLE;
|
|
340
|
-
if (!spec) return;
|
|
341
|
-
const [dwellRaw, delayRaw] = spec.split(":");
|
|
342
|
-
const dwellMs = Number.parseInt(dwellRaw ?? "", 10) || 2500;
|
|
343
|
-
const startDelayMs = Number.parseInt(delayRaw ?? "", 10) || 0;
|
|
344
|
-
const sequence: TabId[] = [2, 3, 4, 5, 6, 7, 8, 1];
|
|
345
|
-
const timers = sequence.map((tab, i) =>
|
|
346
|
-
setTimeout(() => setActiveTab(tab), startDelayMs + dwellMs * (i + 1)),
|
|
347
|
-
);
|
|
348
|
-
return () => {
|
|
349
|
-
for (const t of timers) clearTimeout(t);
|
|
350
|
-
};
|
|
351
|
-
}, []);
|
|
352
|
-
|
|
353
|
-
// Stable ref for App-level input handler — same pattern as InputBar to
|
|
354
|
-
// prevent Ink's useInput from re-registering stdin listeners on every render.
|
|
355
|
-
const activeTabRef = useRef(activeTab);
|
|
356
|
-
const queuedMessagesRef = useRef(queuedMessages);
|
|
357
|
-
const selectedQueueIndexRef = useRef(selectedQueueIndex);
|
|
358
|
-
activeTabRef.current = activeTab;
|
|
359
|
-
queuedMessagesRef.current = queuedMessages;
|
|
360
|
-
selectedQueueIndexRef.current = selectedQueueIndex;
|
|
361
|
-
|
|
362
|
-
const slashCommandsRef = useRef<SlashCommand[]>([]);
|
|
363
|
-
const inputValueRef = useRef("");
|
|
364
80
|
|
|
365
81
|
const markActivityRef = useRef(markActivity);
|
|
366
82
|
markActivityRef.current = markActivity;
|
|
367
83
|
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
}
|
|
403
|
-
} else {
|
|
404
|
-
setActiveTab(tabForKey);
|
|
405
|
-
return;
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
const tab = activeTabRef.current;
|
|
411
|
-
|
|
412
|
-
// Esc on Chat tab while a turn is in flight: steer / interrupt.
|
|
413
|
-
// Calls MessageStream.abort() at the SDK layer; tools already running
|
|
414
|
-
// finish normally, but no further LLM turn is started.
|
|
415
|
-
if (key.escape && tab === 1 && processingRef.current) {
|
|
416
|
-
const session = sessionRef.current;
|
|
417
|
-
if (session) {
|
|
418
|
-
abortActiveStream(session);
|
|
419
|
-
return;
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
// Queue manipulation keybindings (only when queue has items on Chat tab)
|
|
424
|
-
const queue = queuedMessagesRef.current;
|
|
425
|
-
if (tab === 1 && queue.length > 0 && key.ctrl) {
|
|
426
|
-
if (input === "j") {
|
|
427
|
-
setSelectedQueueIndex((i) => Math.min(i + 1, queue.length - 1));
|
|
428
|
-
return;
|
|
429
|
-
}
|
|
430
|
-
if (input === "k") {
|
|
431
|
-
setSelectedQueueIndex((i) => Math.max(i - 1, 0));
|
|
432
|
-
return;
|
|
433
|
-
}
|
|
434
|
-
if (input === "x") {
|
|
435
|
-
queueRef.current.splice(selectedQueueIndexRef.current, 1);
|
|
436
|
-
syncQueue();
|
|
437
|
-
return;
|
|
438
|
-
}
|
|
439
|
-
if (input === "e") {
|
|
440
|
-
const [msg] = queueRef.current.splice(
|
|
441
|
-
selectedQueueIndexRef.current,
|
|
442
|
-
1,
|
|
443
|
-
);
|
|
444
|
-
syncQueue();
|
|
445
|
-
if (msg) {
|
|
446
|
-
setInputValue(msg.display);
|
|
447
|
-
}
|
|
448
|
-
return;
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
if (tab !== 1) {
|
|
453
|
-
// Escape returns to chat
|
|
454
|
-
if (key.escape) {
|
|
455
|
-
setActiveTab(1);
|
|
456
|
-
return;
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
},
|
|
460
|
-
[performShutdown, syncQueue],
|
|
461
|
-
);
|
|
462
|
-
|
|
463
|
-
useInput(stableAppHandler);
|
|
464
|
-
|
|
465
|
-
const processQueue = useCallback(async () => {
|
|
466
|
-
if (processingRef.current || !sessionRef.current) return;
|
|
467
|
-
processingRef.current = true;
|
|
468
|
-
|
|
469
|
-
while (queueRef.current.length > 0) {
|
|
470
|
-
const entry = queueRef.current.shift();
|
|
471
|
-
syncQueue();
|
|
472
|
-
if (!entry) break;
|
|
473
|
-
setIsLoading(true);
|
|
474
|
-
setStreamingText("");
|
|
475
|
-
setActiveToolCalls([]);
|
|
476
|
-
setPreparingTool(null);
|
|
477
|
-
|
|
478
|
-
const userMsg: ChatMessage = {
|
|
479
|
-
id: msgId(),
|
|
480
|
-
role: "user",
|
|
481
|
-
content: entry.display,
|
|
482
|
-
timestamp: new Date(),
|
|
483
|
-
};
|
|
484
|
-
setMessages((prev) => [...prev, userMsg]);
|
|
485
|
-
|
|
486
|
-
let pendingToolCalls: ToolCallData[] = [];
|
|
487
|
-
let currentText = "";
|
|
488
|
-
|
|
489
|
-
const finalizeSegment = () => {
|
|
490
|
-
if (currentText || pendingToolCalls.length > 0) {
|
|
491
|
-
const assistantMsg: ChatMessage = {
|
|
492
|
-
id: msgId(),
|
|
493
|
-
role: "assistant",
|
|
494
|
-
content: currentText,
|
|
495
|
-
timestamp: new Date(),
|
|
496
|
-
toolCalls:
|
|
497
|
-
pendingToolCalls.length > 0 ? [...pendingToolCalls] : undefined,
|
|
498
|
-
};
|
|
499
|
-
setMessages((prev) => [...prev, assistantMsg]);
|
|
500
|
-
currentText = "";
|
|
501
|
-
pendingToolCalls = [];
|
|
502
|
-
setStreamingText("");
|
|
503
|
-
setActiveToolCalls([]);
|
|
504
|
-
}
|
|
505
|
-
};
|
|
506
|
-
|
|
507
|
-
let lastStreamFlush = 0;
|
|
508
|
-
try {
|
|
509
|
-
await sendMessage(sessionRef.current, entry.content, {
|
|
510
|
-
onToken: (token) => {
|
|
511
|
-
currentText += token;
|
|
512
|
-
const now = Date.now();
|
|
513
|
-
if (now - lastStreamFlush >= 50) {
|
|
514
|
-
setStreamingText(currentText);
|
|
515
|
-
lastStreamFlush = now;
|
|
516
|
-
markActivityRef.current();
|
|
517
|
-
}
|
|
518
|
-
},
|
|
519
|
-
onToolPreparing: (id, name) => {
|
|
520
|
-
markActivityRef.current();
|
|
521
|
-
setPreparingTool({ id, name });
|
|
522
|
-
},
|
|
523
|
-
onToolStart: (id, name, input) => {
|
|
524
|
-
markActivityRef.current();
|
|
525
|
-
if (currentText) {
|
|
526
|
-
finalizeSegment();
|
|
527
|
-
}
|
|
528
|
-
const tc: ToolCallData = {
|
|
529
|
-
id,
|
|
530
|
-
name,
|
|
531
|
-
input,
|
|
532
|
-
running: true,
|
|
533
|
-
timestamp: new Date(),
|
|
534
|
-
};
|
|
535
|
-
pendingToolCalls.push(tc);
|
|
536
|
-
setActiveToolCalls([...pendingToolCalls]);
|
|
537
|
-
setPreparingTool(null);
|
|
538
|
-
},
|
|
539
|
-
onToolEnd: (id, _name, output, isError, meta) => {
|
|
540
|
-
markActivityRef.current();
|
|
541
|
-
const tc = pendingToolCalls.find((t) => t.id === id);
|
|
542
|
-
if (tc) {
|
|
543
|
-
tc.running = false;
|
|
544
|
-
tc.output = output;
|
|
545
|
-
tc.isError = isError;
|
|
546
|
-
if (meta?.largeResult) {
|
|
547
|
-
tc.largeResult = meta.largeResult;
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
|
-
setActiveToolCalls([...pendingToolCalls]);
|
|
551
|
-
},
|
|
552
|
-
onToolNotify: (id, message) => {
|
|
553
|
-
markActivityRef.current();
|
|
554
|
-
const tc = pendingToolCalls.find((t) => t.id === id);
|
|
555
|
-
if (tc) {
|
|
556
|
-
tc.notes = [...(tc.notes ?? []), message];
|
|
557
|
-
setActiveToolCalls([...pendingToolCalls]);
|
|
558
|
-
}
|
|
559
|
-
},
|
|
560
|
-
onUsage: (info) => {
|
|
561
|
-
setUsage(info);
|
|
562
|
-
},
|
|
563
|
-
takeInjections: () => {
|
|
564
|
-
// Drain queued messages into the running turn so the agent sees
|
|
565
|
-
// them on the next LLM call instead of after the whole tool loop.
|
|
566
|
-
// Finalize the in-flight assistant segment first so the new user
|
|
567
|
-
// bubbles render in the right order in the chat view.
|
|
568
|
-
if (queueRef.current.length === 0) return [];
|
|
569
|
-
if (currentText || pendingToolCalls.length > 0) {
|
|
570
|
-
finalizeSegment();
|
|
571
|
-
}
|
|
572
|
-
const drained = queueRef.current.splice(0);
|
|
573
|
-
syncQueue();
|
|
574
|
-
for (const e of drained) {
|
|
575
|
-
const userMsg: ChatMessage = {
|
|
576
|
-
id: msgId(),
|
|
577
|
-
role: "user",
|
|
578
|
-
content: e.display,
|
|
579
|
-
timestamp: new Date(),
|
|
580
|
-
};
|
|
581
|
-
setMessages((prev) => [...prev, userMsg]);
|
|
582
|
-
}
|
|
583
|
-
return drained.map((e) => e.content);
|
|
584
|
-
},
|
|
585
|
-
});
|
|
586
|
-
|
|
587
|
-
if (sessionRef.current?.aborted) {
|
|
588
|
-
currentText += currentText
|
|
589
|
-
? "\n\n_(steered — response interrupted)_"
|
|
590
|
-
: "_(steered — no response)_";
|
|
591
|
-
}
|
|
592
|
-
finalizeSegment();
|
|
593
|
-
} catch (err) {
|
|
594
|
-
const errorMsg: ChatMessage = {
|
|
595
|
-
id: msgId(),
|
|
596
|
-
role: "system",
|
|
597
|
-
content: `Error: ${err}`,
|
|
598
|
-
timestamp: new Date(),
|
|
599
|
-
};
|
|
600
|
-
setMessages((prev) => [...prev, errorMsg]);
|
|
601
|
-
} finally {
|
|
602
|
-
setStreamingText("");
|
|
603
|
-
setActiveToolCalls([]);
|
|
604
|
-
setPreparingTool(null);
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
setIsLoading(false);
|
|
609
|
-
processingRef.current = false;
|
|
610
|
-
}, [syncQueue]);
|
|
84
|
+
const { sessionRef, ready, splashDone, performShutdown } = useChatSession({
|
|
85
|
+
projectDir,
|
|
86
|
+
resumeThreadId,
|
|
87
|
+
initialPrompt,
|
|
88
|
+
setMessages,
|
|
89
|
+
setError,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const queue = useMessageQueue({
|
|
93
|
+
sessionRef,
|
|
94
|
+
setMessages,
|
|
95
|
+
markActivityRef,
|
|
96
|
+
});
|
|
97
|
+
const {
|
|
98
|
+
queueRef,
|
|
99
|
+
processingRef,
|
|
100
|
+
queuedMessages,
|
|
101
|
+
selectedQueueIndex,
|
|
102
|
+
setSelectedQueueIndex,
|
|
103
|
+
syncQueue,
|
|
104
|
+
processQueue,
|
|
105
|
+
isLoading,
|
|
106
|
+
streamingText,
|
|
107
|
+
activeToolCalls,
|
|
108
|
+
preparingTool,
|
|
109
|
+
streamStartedAt,
|
|
110
|
+
usage,
|
|
111
|
+
setUsage,
|
|
112
|
+
clearStreamingState,
|
|
113
|
+
} = queue;
|
|
114
|
+
|
|
115
|
+
const { chatTitle, setChatTitle } = useChatTitlePolling(ready, sessionRef);
|
|
116
|
+
|
|
117
|
+
useCaptureTabCycle(setActiveTab);
|
|
611
118
|
|
|
612
119
|
// Auto-submit initial prompt once session is ready
|
|
613
120
|
const initialPromptSent = useRef(false);
|
|
@@ -622,183 +129,7 @@ function AppInner({
|
|
|
622
129
|
setInputHistory((prev) => [...prev, initialPrompt]);
|
|
623
130
|
processQueue();
|
|
624
131
|
}
|
|
625
|
-
}, [ready, initialPrompt, processQueue, syncQueue]);
|
|
626
|
-
|
|
627
|
-
// Poll for chat thread title updates
|
|
628
|
-
useEffect(() => {
|
|
629
|
-
if (!ready || !sessionRef.current) return;
|
|
630
|
-
let mounted = true;
|
|
631
|
-
|
|
632
|
-
const refreshTitle = async () => {
|
|
633
|
-
const session = sessionRef.current;
|
|
634
|
-
if (!session) return;
|
|
635
|
-
const result = await getThread(session.projectDir, session.threadId);
|
|
636
|
-
if (mounted && result?.thread.title) {
|
|
637
|
-
setChatTitle(result.thread.title);
|
|
638
|
-
}
|
|
639
|
-
};
|
|
640
|
-
|
|
641
|
-
refreshTitle();
|
|
642
|
-
const interval = setInterval(refreshTitle, 5000);
|
|
643
|
-
return () => {
|
|
644
|
-
mounted = false;
|
|
645
|
-
clearInterval(interval);
|
|
646
|
-
};
|
|
647
|
-
}, [ready]);
|
|
648
|
-
|
|
649
|
-
const handleSubmit = useCallback(
|
|
650
|
-
async (text: string) => {
|
|
651
|
-
const trimmed = text.trim();
|
|
652
|
-
if (!trimmed || !sessionRef.current) return;
|
|
653
|
-
// /clear is mid-flight: don't queue against the old thread id.
|
|
654
|
-
if (clearingRef.current) return;
|
|
655
|
-
|
|
656
|
-
setInputValue("");
|
|
657
|
-
|
|
658
|
-
if (trimmed === "/help") {
|
|
659
|
-
const skills = sessionRef.current.skills;
|
|
660
|
-
const lines: string[] = [
|
|
661
|
-
"For the full keyboard reference, switch to the Help tab (`Ctrl+g`) — this message lists chat commands only.",
|
|
662
|
-
"",
|
|
663
|
-
"Slash commands:",
|
|
664
|
-
" /help Show this message",
|
|
665
|
-
" /skills List available skills",
|
|
666
|
-
" /clear End current thread and start a new one",
|
|
667
|
-
" /exit End the chat session",
|
|
668
|
-
];
|
|
669
|
-
if (skills.size > 0) {
|
|
670
|
-
lines.push("", "Skills:");
|
|
671
|
-
for (const [skillName, skill] of skills) {
|
|
672
|
-
lines.push(
|
|
673
|
-
` /${skillName.padEnd(14)} ${skill.description || "(no description)"}`,
|
|
674
|
-
);
|
|
675
|
-
}
|
|
676
|
-
} else {
|
|
677
|
-
lines.push("", "Skills:", " (none — add .md files to skills/)");
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
const helpMsg: ChatMessage = {
|
|
681
|
-
id: msgId(),
|
|
682
|
-
role: "system",
|
|
683
|
-
content: lines.join("\n"),
|
|
684
|
-
timestamp: new Date(),
|
|
685
|
-
};
|
|
686
|
-
setMessages((prev) => [...prev, helpMsg]);
|
|
687
|
-
return;
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
if (trimmed.startsWith("/")) {
|
|
691
|
-
const skills = sessionRef.current.skills;
|
|
692
|
-
const handled = handleSlashCommand(trimmed, {
|
|
693
|
-
skills,
|
|
694
|
-
addSystemMessage: (content) => {
|
|
695
|
-
const msg: ChatMessage = {
|
|
696
|
-
id: msgId(),
|
|
697
|
-
role: "system",
|
|
698
|
-
content,
|
|
699
|
-
timestamp: new Date(),
|
|
700
|
-
};
|
|
701
|
-
setMessages((prev) => [...prev, msg]);
|
|
702
|
-
},
|
|
703
|
-
queueUserMessage: (content, opts) => {
|
|
704
|
-
setInputHistory((prev) => [...prev, trimmed]);
|
|
705
|
-
queueRef.current.push({
|
|
706
|
-
display: opts?.display ?? content,
|
|
707
|
-
content,
|
|
708
|
-
});
|
|
709
|
-
syncQueue();
|
|
710
|
-
processQueue();
|
|
711
|
-
},
|
|
712
|
-
exit: () => void performShutdown(),
|
|
713
|
-
clearChat: () => {
|
|
714
|
-
const session = sessionRef.current;
|
|
715
|
-
if (!session) return;
|
|
716
|
-
// Drain any queued messages so they don't leak into the new thread.
|
|
717
|
-
queueRef.current.length = 0;
|
|
718
|
-
syncQueue();
|
|
719
|
-
// Abort any in-flight stream synchronously so its callbacks stop
|
|
720
|
-
// firing before we reset UI state. clearChatSession also calls
|
|
721
|
-
// this, but doing it here lets us start the wait-for-quiesce
|
|
722
|
-
// poll below immediately rather than waiting on the
|
|
723
|
-
// createThread/endThread round trip first.
|
|
724
|
-
abortActiveStream(session);
|
|
725
|
-
// Block new submissions until the new thread id is in place —
|
|
726
|
-
// otherwise the user's first post-/clear message races the
|
|
727
|
-
// async createThread, runs against the old thread id, and is
|
|
728
|
-
// then wiped by setMessages([sys]) below.
|
|
729
|
-
clearingRef.current = true;
|
|
730
|
-
setClearing(true);
|
|
731
|
-
void (async () => {
|
|
732
|
-
// Wait for any in-flight processQueue iteration to finish so
|
|
733
|
-
// its trailing `finalizeSegment` can't race our state reset
|
|
734
|
-
// and re-add the previous thread's assistant message after
|
|
735
|
-
// the UI has been cleared. (Issue #190.)
|
|
736
|
-
while (processingRef.current) {
|
|
737
|
-
await new Promise((r) => setTimeout(r, 10));
|
|
738
|
-
}
|
|
739
|
-
try {
|
|
740
|
-
const { previousThreadId, newThreadId } =
|
|
741
|
-
await clearChatSession(session);
|
|
742
|
-
// Ink's <Static> writes messages to terminal scrollback and
|
|
743
|
-
// can't un-write them, so setMessages alone leaves the old
|
|
744
|
-
// lines visible. Clear the terminal (including scrollback)
|
|
745
|
-
// and bump the epoch key on <Static> to force a fresh mount.
|
|
746
|
-
process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
|
|
747
|
-
setMessages([
|
|
748
|
-
{
|
|
749
|
-
id: msgId(),
|
|
750
|
-
role: "system",
|
|
751
|
-
content: `Started a new chat thread (${newThreadId}). Previous thread saved — resume with: botholomew chat --thread-id ${previousThreadId}`,
|
|
752
|
-
timestamp: new Date(),
|
|
753
|
-
},
|
|
754
|
-
]);
|
|
755
|
-
setMessagesEpoch((n) => n + 1);
|
|
756
|
-
setChatTitle(undefined);
|
|
757
|
-
setStreamingText("");
|
|
758
|
-
setActiveToolCalls([]);
|
|
759
|
-
setPreparingTool(null);
|
|
760
|
-
setUsage(null);
|
|
761
|
-
} catch (err) {
|
|
762
|
-
setMessages((prev) => [
|
|
763
|
-
...prev,
|
|
764
|
-
{
|
|
765
|
-
id: msgId(),
|
|
766
|
-
role: "system",
|
|
767
|
-
content: `Failed to clear chat: ${err}`,
|
|
768
|
-
timestamp: new Date(),
|
|
769
|
-
},
|
|
770
|
-
]);
|
|
771
|
-
} finally {
|
|
772
|
-
clearingRef.current = false;
|
|
773
|
-
setClearing(false);
|
|
774
|
-
}
|
|
775
|
-
})();
|
|
776
|
-
},
|
|
777
|
-
});
|
|
778
|
-
if (handled) return;
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
setInputHistory((prev) => [...prev, trimmed]);
|
|
782
|
-
queueRef.current.push({ display: trimmed, content: trimmed });
|
|
783
|
-
syncQueue();
|
|
784
|
-
processQueue();
|
|
785
|
-
},
|
|
786
|
-
[performShutdown, processQueue, syncQueue],
|
|
787
|
-
);
|
|
788
|
-
|
|
789
|
-
const sessionDbPath = sessionRef.current?.dbPath;
|
|
790
|
-
const inputBarHeader = useMemo(
|
|
791
|
-
() =>
|
|
792
|
-
sessionDbPath ? (
|
|
793
|
-
<StatusBar
|
|
794
|
-
projectDir={projectDir}
|
|
795
|
-
dbPath={sessionDbPath}
|
|
796
|
-
chatTitle={chatTitle}
|
|
797
|
-
onWorkerStatusChange={setWorkerRunning}
|
|
798
|
-
/>
|
|
799
|
-
) : null,
|
|
800
|
-
[projectDir, sessionDbPath, chatTitle],
|
|
801
|
-
);
|
|
132
|
+
}, [ready, initialPrompt, processQueue, syncQueue, queueRef]);
|
|
802
133
|
|
|
803
134
|
const sessionSkills = ready ? sessionRef.current?.skills : undefined;
|
|
804
135
|
const slashCommands = useMemo<SlashCommand[]>(() => {
|
|
@@ -815,9 +146,60 @@ function AppInner({
|
|
|
815
146
|
return buildSlashCommands(BUILTIN_SLASH_COMMANDS, skillList);
|
|
816
147
|
}, [sessionSkills]);
|
|
817
148
|
|
|
149
|
+
const slashCommandsRef = useRef<SlashCommand[]>([]);
|
|
150
|
+
const inputValueRef = useRef("");
|
|
818
151
|
slashCommandsRef.current = slashCommands;
|
|
819
152
|
inputValueRef.current = inputValue;
|
|
820
153
|
|
|
154
|
+
useAppKeybindings({
|
|
155
|
+
activeTab,
|
|
156
|
+
setActiveTab,
|
|
157
|
+
performShutdown,
|
|
158
|
+
sessionRef,
|
|
159
|
+
processingRef,
|
|
160
|
+
queueRef,
|
|
161
|
+
queuedMessages,
|
|
162
|
+
selectedQueueIndex,
|
|
163
|
+
setSelectedQueueIndex,
|
|
164
|
+
setInputValue,
|
|
165
|
+
syncQueue,
|
|
166
|
+
slashCommandsRef,
|
|
167
|
+
inputValueRef,
|
|
168
|
+
markActivityRef,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const handleSubmit = useChatSubmit({
|
|
172
|
+
sessionRef,
|
|
173
|
+
queueRef,
|
|
174
|
+
processingRef,
|
|
175
|
+
clearingRef,
|
|
176
|
+
syncQueue,
|
|
177
|
+
processQueue,
|
|
178
|
+
performShutdown,
|
|
179
|
+
clearStreamingState,
|
|
180
|
+
setMessages,
|
|
181
|
+
setInputValue,
|
|
182
|
+
setInputHistory,
|
|
183
|
+
setMessagesEpoch,
|
|
184
|
+
setChatTitle,
|
|
185
|
+
setClearing,
|
|
186
|
+
setUsage,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const sessionDbPath = sessionRef.current?.dbPath;
|
|
190
|
+
const inputBarHeader = useMemo(
|
|
191
|
+
() =>
|
|
192
|
+
sessionDbPath ? (
|
|
193
|
+
<StatusBar
|
|
194
|
+
projectDir={projectDir}
|
|
195
|
+
dbPath={sessionDbPath}
|
|
196
|
+
chatTitle={chatTitle}
|
|
197
|
+
onWorkerStatusChange={setWorkerRunning}
|
|
198
|
+
/>
|
|
199
|
+
) : null,
|
|
200
|
+
[projectDir, sessionDbPath, chatTitle],
|
|
201
|
+
);
|
|
202
|
+
|
|
821
203
|
const allToolCalls = useMemo(
|
|
822
204
|
() => messages.flatMap((m) => m.toolCalls ?? []),
|
|
823
205
|
[messages],
|
|
@@ -845,11 +227,24 @@ function AppInner({
|
|
|
845
227
|
);
|
|
846
228
|
}
|
|
847
229
|
|
|
848
|
-
const _dbPath = sessionRef.current.dbPath;
|
|
849
230
|
const threadId = sessionRef.current.threadId;
|
|
231
|
+
const panelHeight = Math.max(1, rows - FOOTER_RESERVE);
|
|
232
|
+
const onChatTab = activeTab === 1;
|
|
850
233
|
|
|
851
234
|
return (
|
|
852
|
-
|
|
235
|
+
// The root box is auto-sized on the chat tab so the dynamic frame stays
|
|
236
|
+
// small and the static-rendered chat history (in scrollback above the
|
|
237
|
+
// frame) flows directly into the streaming reply with no blank pad.
|
|
238
|
+
//
|
|
239
|
+
// On every other tab we pin the root to `height={rows}` so the dynamic
|
|
240
|
+
// frame fills the entire viewport — without that, the panel + footer
|
|
241
|
+
// are shorter than the terminal and the bottom of the chat scrollback
|
|
242
|
+
// bleeds through above the active panel. Switching chat→panel goes
|
|
243
|
+
// small→rows (no wipe, since `nextOutputHeight === viewportRows` is
|
|
244
|
+
// not "overflowing"). Switching panel→chat goes rows→small, which
|
|
245
|
+
// does trip Ink's `isLeavingFullscreen` clear, but Ink immediately
|
|
246
|
+
// re-emits `fullStaticOutput` so chat history is preserved.
|
|
247
|
+
<Box flexDirection="column" {...(onChatTab ? {} : { height: rows })}>
|
|
853
248
|
{/* Completed messages — rendered once to terminal scrollback.
|
|
854
249
|
Must live outside the display="none" tab wrappers so the <Static>
|
|
855
250
|
node always has proper terminal width in its Yoga layout.
|
|
@@ -859,19 +254,17 @@ function AppInner({
|
|
|
859
254
|
{(msg) => <MessageBubble key={msg.id} message={msg} />}
|
|
860
255
|
</Static>
|
|
861
256
|
|
|
862
|
-
{/*
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
tool-call card pinned to the bottom of the chat area (just
|
|
870
|
-
above the input bar) instead of leaving a tall gap below them. */}
|
|
257
|
+
{/* Chat tab body: `maxHeight={panelHeight}` (not `height`) so the box
|
|
258
|
+
shrinks to its content when streaming is short or absent. When
|
|
259
|
+
streaming overflows, the box stops at `panelHeight`;
|
|
260
|
+
`justifyContent="flex-end"` + `overflow="hidden"` clip the *top*
|
|
261
|
+
so the most-recent tokens stay visible above the input bar.
|
|
262
|
+
The frame stays strictly below `rows`, so Ink never wipes
|
|
263
|
+
scrollback during a turn. */}
|
|
871
264
|
<Box
|
|
872
|
-
display={
|
|
265
|
+
display={onChatTab ? "flex" : "none"}
|
|
873
266
|
flexDirection="column"
|
|
874
|
-
|
|
267
|
+
maxHeight={panelHeight}
|
|
875
268
|
overflow="hidden"
|
|
876
269
|
justifyContent="flex-end"
|
|
877
270
|
>
|
|
@@ -880,66 +273,18 @@ function AppInner({
|
|
|
880
273
|
isLoading={isLoading}
|
|
881
274
|
activeToolCalls={activeToolCalls}
|
|
882
275
|
preparingTool={preparingTool}
|
|
276
|
+
streamStartedAt={streamStartedAt}
|
|
883
277
|
/>
|
|
884
278
|
</Box>
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
flexDirection="column"
|
|
895
|
-
flexGrow={1}
|
|
896
|
-
>
|
|
897
|
-
<ContextPanel projectDir={projectDir} isActive={activeTab === 3} />
|
|
898
|
-
</Box>
|
|
899
|
-
<Box
|
|
900
|
-
display={activeTab === 4 ? "flex" : "none"}
|
|
901
|
-
flexDirection="column"
|
|
902
|
-
flexGrow={1}
|
|
903
|
-
>
|
|
904
|
-
<TaskPanel projectDir={projectDir} isActive={activeTab === 4} />
|
|
905
|
-
</Box>
|
|
906
|
-
<Box
|
|
907
|
-
display={activeTab === 5 ? "flex" : "none"}
|
|
908
|
-
flexDirection="column"
|
|
909
|
-
flexGrow={1}
|
|
910
|
-
>
|
|
911
|
-
<ThreadPanel
|
|
912
|
-
projectDir={projectDir}
|
|
913
|
-
activeThreadId={threadId}
|
|
914
|
-
isActive={activeTab === 5}
|
|
915
|
-
/>
|
|
916
|
-
</Box>
|
|
917
|
-
<Box
|
|
918
|
-
display={activeTab === 6 ? "flex" : "none"}
|
|
919
|
-
flexDirection="column"
|
|
920
|
-
flexGrow={1}
|
|
921
|
-
>
|
|
922
|
-
<SchedulePanel projectDir={projectDir} isActive={activeTab === 6} />
|
|
923
|
-
</Box>
|
|
924
|
-
<Box
|
|
925
|
-
display={activeTab === 7 ? "flex" : "none"}
|
|
926
|
-
flexDirection="column"
|
|
927
|
-
flexGrow={1}
|
|
928
|
-
>
|
|
929
|
-
<WorkerPanel projectDir={projectDir} isActive={activeTab === 7} />
|
|
930
|
-
</Box>
|
|
931
|
-
<Box
|
|
932
|
-
display={activeTab === 8 ? "flex" : "none"}
|
|
933
|
-
flexDirection="column"
|
|
934
|
-
flexGrow={1}
|
|
935
|
-
>
|
|
936
|
-
<HelpPanel
|
|
937
|
-
projectDir={projectDir}
|
|
938
|
-
threadId={threadId}
|
|
939
|
-
workerRunning={workerRunning}
|
|
940
|
-
usage={usage}
|
|
941
|
-
/>
|
|
942
|
-
</Box>
|
|
279
|
+
|
|
280
|
+
<TabPanels
|
|
281
|
+
activeTab={activeTab}
|
|
282
|
+
projectDir={projectDir}
|
|
283
|
+
threadId={threadId}
|
|
284
|
+
allToolCalls={allToolCalls}
|
|
285
|
+
workerRunning={workerRunning}
|
|
286
|
+
usage={usage}
|
|
287
|
+
/>
|
|
943
288
|
|
|
944
289
|
{/* Queued messages (only on Chat tab) */}
|
|
945
290
|
{activeTab === 1 && queuedMessages.length > 0 && (
|