botholomew 0.14.2 → 0.15.1

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 CHANGED
@@ -76,6 +76,8 @@ Requires [Bun](https://bun.sh) 1.1+.
76
76
  bun install -g botholomew
77
77
  ```
78
78
 
79
+ The CLI installs as both `botholomew` and `bothy` — the same binary, two names.
80
+
79
81
  Or run the dev build from a checkout:
80
82
 
81
83
  ```bash
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "botholomew",
3
- "version": "0.14.2",
3
+ "version": "0.15.1",
4
4
  "description": "An autonomous AI agent for knowledge work — works your task queue while you sleep.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "botholomew": "./src/cli.ts"
7
+ "botholomew": "./src/cli.ts",
8
+ "bothy": "./src/cli.ts"
8
9
  },
9
10
  "files": [
10
11
  "src",
@@ -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(
@@ -1,17 +1,26 @@
1
1
  import type { Command } from "commander";
2
+ import { loadConfig } from "../config/loader.ts";
2
3
 
3
4
  export function registerChatCommand(program: Command) {
4
5
  program
5
6
  .command("chat")
6
7
  .description(
7
8
  "Open the interactive chat TUI\n\n" +
8
- " Keyboard shortcuts:\n" +
9
+ " Tab navigation (Ctrl+<letter> from any tab):\n" +
10
+ " Ctrl+a Chat Ctrl+t Tasks Ctrl+w Workers\n" +
11
+ " Ctrl+o Tools Ctrl+r Threads ? Help (non-chat)\n" +
12
+ " Ctrl+n Context Ctrl+s Schedules Esc Return to Chat\n\n" +
13
+ " Chat input:\n" +
9
14
  " 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" +
15
+ " ⌥+Enter Insert newline\n" +
16
+ " ↑/↓ Browse input history\n" +
17
+ " Esc Steer / abort an in-flight turn\n" +
18
+ " Ctrl+J/K Navigate queued messages\n" +
19
+ " Ctrl+E/X Edit / remove the selected queued message\n\n" +
20
+ " Slash commands:\n" +
21
+ " /help Show chat-command reference (Help tab has the full keymap)\n" +
22
+ " /skills List available skills\n" +
23
+ " /clear End current thread and start a new one\n" +
15
24
  " /exit End the chat session",
16
25
  )
17
26
  .option("--thread-id <id>", "Resume an existing chat thread")
@@ -21,6 +30,8 @@ export function registerChatCommand(program: Command) {
21
30
  const React = await import("react");
22
31
  const { App } = await import("../tui/App.tsx");
23
32
  const dir = program.opts().dir;
33
+ const config = await loadConfig(dir);
34
+ const idleTimeoutMs = config.tui_idle_timeout_seconds * 1000;
24
35
 
25
36
  // VHS/ttyd doesn't fully negotiate the Kitty Keyboard protocol, so
26
37
  // Ink's "enabled" mode drops non-text keystrokes (Tab, Escape) under
@@ -33,6 +44,7 @@ export function registerChatCommand(program: Command) {
33
44
  projectDir: dir,
34
45
  threadId: opts.threadId,
35
46
  initialPrompt: opts.prompt,
47
+ idleTimeoutMs,
36
48
  }),
37
49
  {
38
50
  exitOnCtrlC: false,
@@ -14,6 +14,7 @@ export interface BotholomewConfig {
14
14
  worker_stopped_retention_seconds?: number;
15
15
  schedule_min_interval_seconds?: number;
16
16
  schedule_claim_stale_seconds?: number;
17
+ tui_idle_timeout_seconds?: number;
17
18
  log_level?: string;
18
19
  }
19
20
 
@@ -33,5 +34,6 @@ export const DEFAULT_CONFIG: Required<BotholomewConfig> = {
33
34
  worker_stopped_retention_seconds: 3600,
34
35
  schedule_min_interval_seconds: 60,
35
36
  schedule_claim_stale_seconds: 300,
37
+ tui_idle_timeout_seconds: 180,
36
38
  log_level: "",
37
39
  };
@@ -39,7 +39,7 @@ export interface FetchedContent {
39
39
  /**
40
40
  * MCP server that produced the content (e.g. "google-docs", "github",
41
41
  * "firecrawl"), or null when we fell back to a plain HTTP fetch. Useful
42
- * for `bothy context import` to pick a default destination subdirectory.
42
+ * for `botholomew context import` to pick a default destination subdirectory.
43
43
  */
44
44
  source: string | null;
45
45
  }
package/src/tui/App.tsx CHANGED
@@ -33,6 +33,7 @@ import { ThreadPanel } from "./components/ThreadPanel.tsx";
33
33
  import type { ToolCallData } from "./components/ToolCall.tsx";
34
34
  import { ToolPanel } from "./components/ToolPanel.tsx";
35
35
  import { WorkerPanel } from "./components/WorkerPanel.tsx";
36
+ import { IdleProvider, useIdle } from "./idle.tsx";
36
37
  import { buildSlashCommands, getSlashMatches } from "./slashCompletion.ts";
37
38
  import { ansi } from "./theme.ts";
38
39
 
@@ -40,6 +41,7 @@ interface AppProps {
40
41
  projectDir: string;
41
42
  threadId?: string;
42
43
  initialPrompt?: string;
44
+ idleTimeoutMs: number;
43
45
  }
44
46
 
45
47
  let nextMsgId = 0;
@@ -47,6 +49,21 @@ function msgId(): string {
47
49
  return `msg-${++nextMsgId}`;
48
50
  }
49
51
 
52
+ // Tab routing: Ctrl+<letter> jumps to a tab. Chosen for memorability — first
53
+ // available letter that doesn't collide with other Ctrl bindings (Ctrl+C exit,
54
+ // Ctrl+J/K/X/E queue ops on Chat). Help is bound to `?` instead of Ctrl+H
55
+ // because most terminals send Ctrl+H as ASCII 0x08 (backspace), which Ink
56
+ // reports as `key.backspace=true`, not `input='h'`.
57
+ const TAB_BY_CTRL_KEY: Record<string, TabId> = {
58
+ a: 1, // ch[a]t
59
+ o: 2, // t[o]ols
60
+ n: 3, // co[n]text
61
+ t: 4, // [t]asks
62
+ r: 5, // th[r]eads
63
+ s: 6, // [s]chedules
64
+ w: 7, // [w]orkers
65
+ };
66
+
50
67
  function detectToolError(output: string | undefined): boolean {
51
68
  if (!output) return false;
52
69
  try {
@@ -123,8 +140,32 @@ export function App({
123
140
  projectDir,
124
141
  threadId: resumeThreadId,
125
142
  initialPrompt,
143
+ idleTimeoutMs,
126
144
  }: AppProps) {
145
+ return (
146
+ <IdleProvider timeoutMs={idleTimeoutMs}>
147
+ <AppInner
148
+ projectDir={projectDir}
149
+ threadId={resumeThreadId}
150
+ initialPrompt={initialPrompt}
151
+ />
152
+ </IdleProvider>
153
+ );
154
+ }
155
+
156
+ interface AppInnerProps {
157
+ projectDir: string;
158
+ threadId?: string;
159
+ initialPrompt?: string;
160
+ }
161
+
162
+ function AppInner({
163
+ projectDir,
164
+ threadId: resumeThreadId,
165
+ initialPrompt,
166
+ }: AppInnerProps) {
127
167
  const { exit } = useApp();
168
+ const { markActivity } = useIdle();
128
169
  const [messages, setMessages] = useState<ChatMessage[]>([]);
129
170
  const [messagesEpoch, setMessagesEpoch] = useState(0);
130
171
  const [inputValue, setInputValue] = useState("");
@@ -187,7 +228,7 @@ export function App({
187
228
  id: msgId(),
188
229
  role: "system" as const,
189
230
  content:
190
- "Press Tab to switch between panels. Type /help for commands.",
231
+ "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
232
  timestamp: new Date(),
192
233
  },
193
234
  ]);
@@ -250,26 +291,44 @@ export function App({
250
291
  const slashCommandsRef = useRef<SlashCommand[]>([]);
251
292
  const inputValueRef = useRef("");
252
293
 
294
+ const markActivityRef = useRef(markActivity);
295
+ markActivityRef.current = markActivity;
296
+
253
297
  const stableAppHandler = useCallback(
254
298
  // biome-ignore lint/suspicious/noExplicitAny: Ink's Key type is not exported
255
299
  (input: string, key: any) => {
300
+ markActivityRef.current();
301
+
256
302
  // Ctrl+C exits
257
303
  if (input === "c" && key.ctrl) {
258
304
  exit();
259
305
  return;
260
306
  }
261
307
 
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;
308
+ // Ctrl+<letter> jumps directly to a tab from any tab. On Chat, only
309
+ // suppress these if the slash-autocomplete popup needs the keystroke
310
+ // (Ctrl combos don't drive the popup, but keep the guard symmetric
311
+ // with the previous Tab-cycle behavior).
312
+ if (key.ctrl) {
313
+ const tabForKey = TAB_BY_CTRL_KEY[input];
314
+ if (tabForKey !== undefined) {
315
+ if (activeTabRef.current === 1) {
316
+ const popupOpen = getSlashMatches(
317
+ inputValueRef.current,
318
+ slashCommandsRef.current,
319
+ );
320
+ if (popupOpen) return;
321
+ }
322
+ setActiveTab(tabForKey);
323
+ return;
271
324
  }
272
- setActiveTab((t) => ((t % 8) + 1) as TabId);
325
+ }
326
+
327
+ // `?` jumps to the Help tab from any non-chat tab (on Chat, `?` is
328
+ // a regular character). Acts as the Help shortcut since Ctrl+H is
329
+ // unusable — most terminals send it as backspace.
330
+ if (input === "?" && activeTabRef.current !== 1) {
331
+ setActiveTab(8);
273
332
  return;
274
333
  }
275
334
 
@@ -316,12 +375,6 @@ export function App({
316
375
  }
317
376
 
318
377
  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
378
  // Escape returns to chat
326
379
  if (key.escape) {
327
380
  setActiveTab(1);
@@ -385,12 +438,15 @@ export function App({
385
438
  if (now - lastStreamFlush >= 50) {
386
439
  setStreamingText(currentText);
387
440
  lastStreamFlush = now;
441
+ markActivityRef.current();
388
442
  }
389
443
  },
390
444
  onToolPreparing: (id, name) => {
445
+ markActivityRef.current();
391
446
  setPreparingTool({ id, name });
392
447
  },
393
448
  onToolStart: (id, name, input) => {
449
+ markActivityRef.current();
394
450
  if (currentText) {
395
451
  finalizeSegment();
396
452
  }
@@ -406,6 +462,7 @@ export function App({
406
462
  setPreparingTool(null);
407
463
  },
408
464
  onToolEnd: (id, _name, output, isError, meta) => {
465
+ markActivityRef.current();
409
466
  const tc = pendingToolCalls.find((t) => t.id === id);
410
467
  if (tc) {
411
468
  tc.running = false;
@@ -512,81 +569,30 @@ export function App({
512
569
 
513
570
  if (trimmed === "/help") {
514
571
  const skills = sessionRef.current.skills;
515
- const skillLines: string[] = [];
572
+ const lines: string[] = [
573
+ "For the full keyboard reference, switch to the Help tab (`?` from any non-chat tab) — this message lists chat commands only.",
574
+ "",
575
+ "Slash commands:",
576
+ " /help Show this message",
577
+ " /skills List available skills",
578
+ " /clear End current thread and start a new one",
579
+ " /exit End the chat session",
580
+ ];
516
581
  if (skills.size > 0) {
517
- skillLines.push("", "Skills:");
582
+ lines.push("", "Skills:");
518
583
  for (const [skillName, skill] of skills) {
519
- skillLines.push(
584
+ lines.push(
520
585
  ` /${skillName.padEnd(14)} ${skill.description || "(no description)"}`,
521
586
  );
522
587
  }
523
588
  } else {
524
- skillLines.push("", "Skills:", " (none — add .md files to skills/)");
589
+ lines.push("", "Skills:", " (none — add .md files to skills/)");
525
590
  }
526
591
 
527
592
  const helpMsg: ChatMessage = {
528
593
  id: msgId(),
529
594
  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"),
595
+ content: lines.join("\n"),
590
596
  timestamp: new Date(),
591
597
  };
592
598
  setMessages((prev) => [...prev, helpMsg]);
@@ -622,8 +628,23 @@ export function App({
622
628
  // Drain any queued messages so they don't leak into the new thread.
623
629
  queueRef.current.length = 0;
624
630
  syncQueue();
625
- clearChatSession(session)
626
- .then(({ previousThreadId, newThreadId }) => {
631
+ // Abort any in-flight stream synchronously so its callbacks stop
632
+ // firing before we reset UI state. clearChatSession also calls
633
+ // this, but doing it here lets us start the wait-for-quiesce
634
+ // poll below immediately rather than waiting on the
635
+ // createThread/endThread round trip first.
636
+ abortActiveStream(session);
637
+ void (async () => {
638
+ // Wait for any in-flight processQueue iteration to finish so
639
+ // its trailing `finalizeSegment` can't race our state reset
640
+ // and re-add the previous thread's assistant message after
641
+ // the UI has been cleared. (Issue #190.)
642
+ while (processingRef.current) {
643
+ await new Promise((r) => setTimeout(r, 10));
644
+ }
645
+ try {
646
+ const { previousThreadId, newThreadId } =
647
+ await clearChatSession(session);
627
648
  // Ink's <Static> writes messages to terminal scrollback and
628
649
  // can't un-write them, so setMessages alone leaves the old
629
650
  // lines visible. Clear the terminal (including scrollback)
@@ -639,8 +660,10 @@ export function App({
639
660
  ]);
640
661
  setMessagesEpoch((n) => n + 1);
641
662
  setChatTitle(undefined);
642
- })
643
- .catch((err) => {
663
+ setStreamingText("");
664
+ setActiveToolCalls([]);
665
+ setPreparingTool(null);
666
+ } catch (err) {
644
667
  setMessages((prev) => [
645
668
  ...prev,
646
669
  {
@@ -650,7 +673,8 @@ export function App({
650
673
  timestamp: new Date(),
651
674
  },
652
675
  ]);
653
- });
676
+ }
677
+ })();
654
678
  },
655
679
  });
656
680
  if (handled) return;