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