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/src/tui/App.tsx CHANGED
@@ -1,23 +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, 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 { TaskPanel } from "./components/TaskPanel.tsx";
33
- import { ThreadPanel } from "./components/ThreadPanel.tsx";
34
- import type { ToolCallData } from "./components/ToolCall.tsx";
35
- import { ToolPanel } from "./components/ToolPanel.tsx";
36
- 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";
37
25
  import { IdleProvider, useIdle } from "./idle.tsx";
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,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
- // Pin the root box to a known viewport height so the rendered frame size
180
- // never crosses the viewport boundary. Ink 7's renderer wipes scrollback
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 stableAppHandler = useCallback(
369
- // biome-ignore lint/suspicious/noExplicitAny: Ink's Key type is not exported
370
- (input: string, key: any) => {
371
- markActivityRef.current();
372
-
373
- // Ctrl+C exits. Routed through performShutdown so the in-flight LLM
374
- // stream is aborted and mcpx is closed before we unmount Ink — without
375
- // that, one Ctrl-C prints the goodbye but the process stays pinned by
376
- // the open HTTPS socket and a second Ctrl-C is needed.
377
- if (input === "c" && key.ctrl) {
378
- void performShutdown();
379
- return;
380
- }
381
-
382
- // Ctrl+<letter> jumps directly to a tab from any tab. On Chat, only
383
- // suppress these if the slash-autocomplete popup needs the keystroke
384
- // (Ctrl combos don't drive the popup, but keep the guard symmetric
385
- // with the previous Tab-cycle behavior).
386
- if (key.ctrl) {
387
- const tabForKey = TAB_BY_CTRL_KEY[input];
388
- if (tabForKey !== undefined) {
389
- if (activeTabRef.current === 1) {
390
- const popupOpen = getSlashMatches(
391
- inputValueRef.current,
392
- slashCommandsRef.current,
393
- );
394
- if (popupOpen) return;
395
- // Ctrl+E edits a queued message when one is selected; only
396
- // fall through to the Threads tab-jump when the queue is empty.
397
- if (input === "e" && queuedMessagesRef.current.length > 0) {
398
- // handled by the queue keybindings block below
399
- } else {
400
- setActiveTab(tabForKey);
401
- return;
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
- <Box flexDirection="column" height={rows} overflow="hidden">
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
- {/* Tab content area all panels stay mounted to avoid expensive
863
- remount cycles. display="none" hides inactive panels from
864
- layout without destroying them.
865
- The chat tab's flexGrow box is overflow-clipped so streaming
866
- content can't push the rendered frame past the viewport — see
867
- the comment on the `rows` state for why that matters.
868
- `justifyContent="flex-end"` keeps active streaming content + the
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={activeTab === 1 ? "flex" : "none"}
265
+ display={onChatTab ? "flex" : "none"}
873
266
  flexDirection="column"
874
- flexGrow={1}
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
- <Box
886
- display={activeTab === 2 ? "flex" : "none"}
887
- flexDirection="column"
888
- flexGrow={1}
889
- >
890
- <ToolPanel toolCalls={allToolCalls} isActive={activeTab === 2} />
891
- </Box>
892
- <Box
893
- display={activeTab === 3 ? "flex" : "none"}
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 && (