botholomew 0.14.2 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "botholomew",
3
- "version": "0.14.2",
3
+ "version": "0.15.0",
4
4
  "description": "An autonomous AI agent for knowledge work — works your task queue while you sleep.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -180,6 +180,10 @@ export async function endChatSession(session: ChatSession): Promise<void> {
180
180
  export async function clearChatSession(
181
181
  session: ChatSession,
182
182
  ): Promise<{ previousThreadId: string; newThreadId: string }> {
183
+ // Abort any in-flight stream up front so its callbacks don't continue to
184
+ // fire into the new thread (caused #190 — old messages reappearing on the
185
+ // next user submission).
186
+ abortActiveStream(session);
183
187
  const previousThreadId = session.threadId;
184
188
  await endThread(session.projectDir, previousThreadId);
185
189
  const newThreadId = await createThread(
@@ -5,13 +5,21 @@ export function registerChatCommand(program: Command) {
5
5
  .command("chat")
6
6
  .description(
7
7
  "Open the interactive chat TUI\n\n" +
8
- " Keyboard shortcuts:\n" +
8
+ " Tab navigation (Ctrl+<letter> from any tab):\n" +
9
+ " Ctrl+a Chat Ctrl+t Tasks Ctrl+w Workers\n" +
10
+ " Ctrl+o Tools Ctrl+r Threads ? Help (non-chat)\n" +
11
+ " Ctrl+n Context Ctrl+s Schedules Esc Return to Chat\n\n" +
12
+ " Chat input:\n" +
9
13
  " Enter Send message\n" +
10
- " ⌥+Enter Insert newline (multiline input)\n" +
11
- " ↑/↓ Browse input history\n\n" +
12
- " Commands:\n" +
13
- " /help Show keyboard shortcuts\n" +
14
- " /tools Open tool call inspector\n" +
14
+ " ⌥+Enter Insert newline\n" +
15
+ " ↑/↓ Browse input history\n" +
16
+ " Esc Steer / abort an in-flight turn\n" +
17
+ " Ctrl+J/K Navigate queued messages\n" +
18
+ " Ctrl+E/X Edit / remove the selected queued message\n\n" +
19
+ " Slash commands:\n" +
20
+ " /help Show chat-command reference (Help tab has the full keymap)\n" +
21
+ " /skills List available skills\n" +
22
+ " /clear End current thread and start a new one\n" +
15
23
  " /exit End the chat session",
16
24
  )
17
25
  .option("--thread-id <id>", "Resume an existing chat thread")
package/src/tui/App.tsx CHANGED
@@ -47,6 +47,21 @@ function msgId(): string {
47
47
  return `msg-${++nextMsgId}`;
48
48
  }
49
49
 
50
+ // Tab routing: Ctrl+<letter> jumps to a tab. Chosen for memorability — first
51
+ // available letter that doesn't collide with other Ctrl bindings (Ctrl+C exit,
52
+ // Ctrl+J/K/X/E queue ops on Chat). Help is bound to `?` instead of Ctrl+H
53
+ // because most terminals send Ctrl+H as ASCII 0x08 (backspace), which Ink
54
+ // reports as `key.backspace=true`, not `input='h'`.
55
+ const TAB_BY_CTRL_KEY: Record<string, TabId> = {
56
+ a: 1, // ch[a]t
57
+ o: 2, // t[o]ols
58
+ n: 3, // co[n]text
59
+ t: 4, // [t]asks
60
+ r: 5, // th[r]eads
61
+ s: 6, // [s]chedules
62
+ w: 7, // [w]orkers
63
+ };
64
+
50
65
  function detectToolError(output: string | undefined): boolean {
51
66
  if (!output) return false;
52
67
  try {
@@ -187,7 +202,7 @@ export function App({
187
202
  id: msgId(),
188
203
  role: "system" as const,
189
204
  content:
190
- "Press Tab to switch between panels. Type /help for commands.",
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.",
191
206
  timestamp: new Date(),
192
207
  },
193
208
  ]);
@@ -259,17 +274,30 @@ export function App({
259
274
  return;
260
275
  }
261
276
 
262
- // Tab key cycles tabs but on the Chat tab, let InputBar consume it
263
- // whenever the slash autocomplete popup would be open.
264
- if (key.tab && !key.shift) {
265
- if (activeTabRef.current === 1) {
266
- const popupOpen = getSlashMatches(
267
- inputValueRef.current,
268
- slashCommandsRef.current,
269
- );
270
- if (popupOpen) return;
277
+ // Ctrl+<letter> jumps directly to a tab from any tab. On Chat, only
278
+ // suppress these if the slash-autocomplete popup needs the keystroke
279
+ // (Ctrl combos don't drive the popup, but keep the guard symmetric
280
+ // with the previous Tab-cycle behavior).
281
+ if (key.ctrl) {
282
+ const tabForKey = TAB_BY_CTRL_KEY[input];
283
+ if (tabForKey !== undefined) {
284
+ if (activeTabRef.current === 1) {
285
+ const popupOpen = getSlashMatches(
286
+ inputValueRef.current,
287
+ slashCommandsRef.current,
288
+ );
289
+ if (popupOpen) return;
290
+ }
291
+ setActiveTab(tabForKey);
292
+ return;
271
293
  }
272
- setActiveTab((t) => ((t % 8) + 1) as TabId);
294
+ }
295
+
296
+ // `?` jumps to the Help tab from any non-chat tab (on Chat, `?` is
297
+ // a regular character). Acts as the Help shortcut since Ctrl+H is
298
+ // unusable — most terminals send it as backspace.
299
+ if (input === "?" && activeTabRef.current !== 1) {
300
+ setActiveTab(8);
273
301
  return;
274
302
  }
275
303
 
@@ -316,12 +344,6 @@ export function App({
316
344
  }
317
345
 
318
346
  if (tab !== 1) {
319
- // Number keys jump to tab on non-chat tabs
320
- const num = Number.parseInt(input, 10);
321
- if (num >= 1 && num <= 8) {
322
- setActiveTab(num as TabId);
323
- return;
324
- }
325
347
  // Escape returns to chat
326
348
  if (key.escape) {
327
349
  setActiveTab(1);
@@ -512,81 +534,30 @@ export function App({
512
534
 
513
535
  if (trimmed === "/help") {
514
536
  const skills = sessionRef.current.skills;
515
- const skillLines: string[] = [];
537
+ const lines: string[] = [
538
+ "For the full keyboard reference, switch to the Help tab (`?` from any non-chat tab) — this message lists chat commands only.",
539
+ "",
540
+ "Slash commands:",
541
+ " /help Show this message",
542
+ " /skills List available skills",
543
+ " /clear End current thread and start a new one",
544
+ " /exit End the chat session",
545
+ ];
516
546
  if (skills.size > 0) {
517
- skillLines.push("", "Skills:");
547
+ lines.push("", "Skills:");
518
548
  for (const [skillName, skill] of skills) {
519
- skillLines.push(
549
+ lines.push(
520
550
  ` /${skillName.padEnd(14)} ${skill.description || "(no description)"}`,
521
551
  );
522
552
  }
523
553
  } else {
524
- skillLines.push("", "Skills:", " (none — add .md files to skills/)");
554
+ lines.push("", "Skills:", " (none — add .md files to skills/)");
525
555
  }
526
556
 
527
557
  const helpMsg: ChatMessage = {
528
558
  id: msgId(),
529
559
  role: "system",
530
- content: [
531
- "Navigation:",
532
- " Tab Cycle between panels",
533
- " 1-7 Jump to panel (when not in Chat)",
534
- " Escape Return to Chat",
535
- "",
536
- "Chat (Tab 1):",
537
- " Enter Send message",
538
- " ⌥+Enter Insert newline",
539
- " ↑/↓ Browse input history",
540
- " / Open slash-command autocomplete",
541
- " Enter Run highlighted command / insert if it takes args (popup open)",
542
- " Tab Insert highlighted command without submitting (popup open)",
543
- " ↑/↓ Move highlight (popup open)",
544
- " Esc Close popup",
545
- "",
546
- "Tools (Tab 2):",
547
- " ↑/↓ Select tool call",
548
- " Shift+↑/↓ Scroll detail pane",
549
- " j/k Scroll detail pane",
550
- "",
551
- "Context (Tab 3):",
552
- " ↑/↓ Navigate items",
553
- " Enter Expand directory / preview file",
554
- " Backspace Go up one directory",
555
- " / Search context",
556
- " d Delete selected item",
557
- "",
558
- "Tasks (Tab 4):",
559
- " ↑/↓ Navigate task list",
560
- " Shift+↑/↓ Scroll detail pane",
561
- " j/k Scroll detail pane",
562
- " f Cycle status filter",
563
- " p Cycle priority filter",
564
- " r Refresh tasks",
565
- "",
566
- "Threads (Tab 5):",
567
- " ↑/↓ Navigate thread list",
568
- " Shift+↑/↓ Scroll detail pane",
569
- " j/k Scroll detail pane",
570
- " f Cycle type filter",
571
- " d Delete thread (with confirmation)",
572
- " r Refresh threads",
573
- "",
574
- "Schedules (Tab 6):",
575
- " ↑/↓ Navigate schedule list",
576
- " Shift+↑/↓ Scroll detail pane",
577
- " j/k Scroll detail pane",
578
- " f Cycle enabled/disabled filter",
579
- " e Toggle enable/disable",
580
- " d Delete schedule (with confirmation)",
581
- " r Refresh schedules",
582
- "",
583
- "Commands:",
584
- " /help Show this help",
585
- " /skills List available skills",
586
- " /clear End current thread and start a new one",
587
- " /exit End the chat session",
588
- ...skillLines,
589
- ].join("\n"),
560
+ content: lines.join("\n"),
590
561
  timestamp: new Date(),
591
562
  };
592
563
  setMessages((prev) => [...prev, helpMsg]);
@@ -622,8 +593,23 @@ export function App({
622
593
  // Drain any queued messages so they don't leak into the new thread.
623
594
  queueRef.current.length = 0;
624
595
  syncQueue();
625
- clearChatSession(session)
626
- .then(({ previousThreadId, newThreadId }) => {
596
+ // Abort any in-flight stream synchronously so its callbacks stop
597
+ // firing before we reset UI state. clearChatSession also calls
598
+ // this, but doing it here lets us start the wait-for-quiesce
599
+ // poll below immediately rather than waiting on the
600
+ // createThread/endThread round trip first.
601
+ abortActiveStream(session);
602
+ void (async () => {
603
+ // Wait for any in-flight processQueue iteration to finish so
604
+ // its trailing `finalizeSegment` can't race our state reset
605
+ // and re-add the previous thread's assistant message after
606
+ // the UI has been cleared. (Issue #190.)
607
+ while (processingRef.current) {
608
+ await new Promise((r) => setTimeout(r, 10));
609
+ }
610
+ try {
611
+ const { previousThreadId, newThreadId } =
612
+ await clearChatSession(session);
627
613
  // Ink's <Static> writes messages to terminal scrollback and
628
614
  // can't un-write them, so setMessages alone leaves the old
629
615
  // lines visible. Clear the terminal (including scrollback)
@@ -639,8 +625,10 @@ export function App({
639
625
  ]);
640
626
  setMessagesEpoch((n) => n + 1);
641
627
  setChatTitle(undefined);
642
- })
643
- .catch((err) => {
628
+ setStreamingText("");
629
+ setActiveToolCalls([]);
630
+ setPreparingTool(null);
631
+ } catch (err) {
644
632
  setMessages((prev) => [
645
633
  ...prev,
646
634
  {
@@ -650,7 +638,8 @@ export function App({
650
638
  timestamp: new Date(),
651
639
  },
652
640
  ]);
653
- });
641
+ }
642
+ })();
654
643
  },
655
644
  });
656
645
  if (handled) return;