botholomew 0.16.2 → 0.16.4

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.
@@ -0,0 +1,36 @@
1
+ import { type MutableRefObject, useEffect, useState } from "react";
2
+ import type { ChatSession } from "../../chat/session.ts";
3
+ import { getThread } from "../../threads/store.ts";
4
+
5
+ export function useChatTitlePolling(
6
+ ready: boolean,
7
+ sessionRef: MutableRefObject<ChatSession | null>,
8
+ ): {
9
+ chatTitle: string | undefined;
10
+ setChatTitle: (t: string | undefined) => void;
11
+ } {
12
+ const [chatTitle, setChatTitle] = useState<string | undefined>(undefined);
13
+
14
+ useEffect(() => {
15
+ if (!ready || !sessionRef.current) return;
16
+ let mounted = true;
17
+
18
+ const refreshTitle = async () => {
19
+ const session = sessionRef.current;
20
+ if (!session) return;
21
+ const result = await getThread(session.projectDir, session.threadId);
22
+ if (mounted && result?.thread.title) {
23
+ setChatTitle(result.thread.title);
24
+ }
25
+ };
26
+
27
+ refreshTitle();
28
+ const interval = setInterval(refreshTitle, 5000);
29
+ return () => {
30
+ mounted = false;
31
+ clearInterval(interval);
32
+ };
33
+ }, [ready, sessionRef]);
34
+
35
+ return { chatTitle, setChatTitle };
36
+ }
@@ -0,0 +1,254 @@
1
+ import {
2
+ type Dispatch,
3
+ type MutableRefObject,
4
+ type SetStateAction,
5
+ useCallback,
6
+ useRef,
7
+ useState,
8
+ } from "react";
9
+ import { type ChatSession, sendMessage } from "../../chat/session.ts";
10
+ import type { ContextUsage } from "../../chat/usage.ts";
11
+ import type { ChatMessage } from "../components/MessageList.tsx";
12
+ import type { ToolCallData } from "../components/ToolCall.tsx";
13
+ import { msgId } from "../messages.ts";
14
+
15
+ export interface QueueEntry {
16
+ display: string;
17
+ content: string;
18
+ }
19
+
20
+ interface UseMessageQueueParams {
21
+ sessionRef: MutableRefObject<ChatSession | null>;
22
+ setMessages: Dispatch<SetStateAction<ChatMessage[]>>;
23
+ markActivityRef: MutableRefObject<() => void>;
24
+ }
25
+
26
+ export interface UseMessageQueueResult {
27
+ queueRef: MutableRefObject<QueueEntry[]>;
28
+ processingRef: MutableRefObject<boolean>;
29
+ queuedMessages: string[];
30
+ selectedQueueIndex: number;
31
+ setSelectedQueueIndex: Dispatch<SetStateAction<number>>;
32
+ syncQueue: () => void;
33
+ processQueue: () => Promise<void>;
34
+ isLoading: boolean;
35
+ streamingText: string;
36
+ activeToolCalls: ToolCallData[];
37
+ preparingTool: { id: string; name: string } | null;
38
+ streamStartedAt: Date | null;
39
+ usage: ContextUsage | null;
40
+ setUsage: Dispatch<SetStateAction<ContextUsage | null>>;
41
+ clearStreamingState: () => void;
42
+ }
43
+
44
+ export function useMessageQueue({
45
+ sessionRef,
46
+ setMessages,
47
+ markActivityRef,
48
+ }: UseMessageQueueParams): UseMessageQueueResult {
49
+ const queueRef = useRef<QueueEntry[]>([]);
50
+ const processingRef = useRef(false);
51
+ const [queuedMessages, setQueuedMessages] = useState<string[]>([]);
52
+ const [selectedQueueIndex, setSelectedQueueIndex] = useState(0);
53
+ const [isLoading, setIsLoading] = useState(false);
54
+ const [streamingText, setStreamingText] = useState("");
55
+ const [activeToolCalls, setActiveToolCalls] = useState<ToolCallData[]>([]);
56
+ const [streamStartedAt, setStreamStartedAt] = useState<Date | null>(null);
57
+ const [preparingTool, setPreparingTool] = useState<{
58
+ id: string;
59
+ name: string;
60
+ } | null>(null);
61
+ const [usage, setUsage] = useState<ContextUsage | null>(null);
62
+
63
+ const syncQueue = useCallback(() => {
64
+ const snapshot = queueRef.current.map((e) => e.display);
65
+ setQueuedMessages(snapshot);
66
+ setSelectedQueueIndex((prev) =>
67
+ snapshot.length === 0 ? 0 : Math.min(prev, snapshot.length - 1),
68
+ );
69
+ }, []);
70
+
71
+ const clearStreamingState = useCallback(() => {
72
+ setStreamingText("");
73
+ setActiveToolCalls([]);
74
+ setPreparingTool(null);
75
+ setStreamStartedAt(null);
76
+ }, []);
77
+
78
+ const processQueue = useCallback(async () => {
79
+ if (processingRef.current || !sessionRef.current) return;
80
+ processingRef.current = true;
81
+
82
+ while (queueRef.current.length > 0) {
83
+ const entry = queueRef.current.shift();
84
+ syncQueue();
85
+ if (!entry) break;
86
+ setIsLoading(true);
87
+ setStreamingText("");
88
+ setActiveToolCalls([]);
89
+ setPreparingTool(null);
90
+ setStreamStartedAt(new Date());
91
+
92
+ const userMsg: ChatMessage = {
93
+ id: msgId(),
94
+ role: "user",
95
+ content: entry.display,
96
+ timestamp: new Date(),
97
+ };
98
+ setMessages((prev) => [...prev, userMsg]);
99
+
100
+ let pendingToolCalls: ToolCallData[] = [];
101
+ let currentText = "";
102
+
103
+ const finalizeSegment = () => {
104
+ if (currentText || pendingToolCalls.length > 0) {
105
+ const assistantMsg: ChatMessage = {
106
+ id: msgId(),
107
+ role: "assistant",
108
+ content: currentText,
109
+ timestamp: new Date(),
110
+ toolCalls:
111
+ pendingToolCalls.length > 0 ? [...pendingToolCalls] : undefined,
112
+ };
113
+ setMessages((prev) => [...prev, assistantMsg]);
114
+ currentText = "";
115
+ pendingToolCalls = [];
116
+ setStreamingText("");
117
+ setActiveToolCalls([]);
118
+ setStreamStartedAt(new Date());
119
+ }
120
+ };
121
+
122
+ let lastStreamFlush = 0;
123
+ try {
124
+ await sendMessage(sessionRef.current, entry.content, {
125
+ onToken: (token) => {
126
+ currentText += token;
127
+ const now = Date.now();
128
+ if (now - lastStreamFlush >= 50) {
129
+ setStreamingText(currentText);
130
+ lastStreamFlush = now;
131
+ markActivityRef.current();
132
+ }
133
+ },
134
+ onToolPreparing: (id, name) => {
135
+ markActivityRef.current();
136
+ setPreparingTool({ id, name });
137
+ },
138
+ onToolStart: (id, name, input) => {
139
+ markActivityRef.current();
140
+ if (currentText) {
141
+ finalizeSegment();
142
+ }
143
+ const tc: ToolCallData = {
144
+ id,
145
+ name,
146
+ input,
147
+ running: true,
148
+ timestamp: new Date(),
149
+ };
150
+ pendingToolCalls = [...pendingToolCalls, tc];
151
+ setActiveToolCalls(pendingToolCalls);
152
+ setPreparingTool(null);
153
+ },
154
+ onToolEnd: (id, _name, output, isError, meta) => {
155
+ markActivityRef.current();
156
+ // Replace the matched entry with a new object so its identity
157
+ // changes (memoized ToolCall children rely on this); other entries
158
+ // keep their reference and skip re-render.
159
+ pendingToolCalls = pendingToolCalls.map((t) =>
160
+ t.id === id
161
+ ? {
162
+ ...t,
163
+ running: false,
164
+ output,
165
+ isError,
166
+ ...(meta?.largeResult
167
+ ? { largeResult: meta.largeResult }
168
+ : {}),
169
+ }
170
+ : t,
171
+ );
172
+ setActiveToolCalls(pendingToolCalls);
173
+ },
174
+ onToolNotify: (id, message) => {
175
+ markActivityRef.current();
176
+ let touched = false;
177
+ pendingToolCalls = pendingToolCalls.map((t) => {
178
+ if (t.id !== id) return t;
179
+ touched = true;
180
+ return { ...t, notes: [...(t.notes ?? []), message] };
181
+ });
182
+ if (touched) setActiveToolCalls(pendingToolCalls);
183
+ },
184
+ onUsage: (info) => {
185
+ setUsage(info);
186
+ },
187
+ takeInjections: () => {
188
+ // Drain queued messages into the running turn so the agent sees
189
+ // them on the next LLM call instead of after the whole tool loop.
190
+ // Finalize the in-flight assistant segment first so the new user
191
+ // bubbles render in the right order in the chat view.
192
+ if (queueRef.current.length === 0) return [];
193
+ if (currentText || pendingToolCalls.length > 0) {
194
+ finalizeSegment();
195
+ }
196
+ const drained = queueRef.current.splice(0);
197
+ syncQueue();
198
+ for (const e of drained) {
199
+ const userMsg: ChatMessage = {
200
+ id: msgId(),
201
+ role: "user",
202
+ content: e.display,
203
+ timestamp: new Date(),
204
+ };
205
+ setMessages((prev) => [...prev, userMsg]);
206
+ }
207
+ return drained.map((e) => e.content);
208
+ },
209
+ });
210
+
211
+ if (sessionRef.current?.aborted) {
212
+ currentText += currentText
213
+ ? "\n\n_(steered — response interrupted)_"
214
+ : "_(steered — no response)_";
215
+ }
216
+ finalizeSegment();
217
+ } catch (err) {
218
+ const errorMsg: ChatMessage = {
219
+ id: msgId(),
220
+ role: "system",
221
+ content: `Error: ${err}`,
222
+ timestamp: new Date(),
223
+ };
224
+ setMessages((prev) => [...prev, errorMsg]);
225
+ } finally {
226
+ setStreamingText("");
227
+ setActiveToolCalls([]);
228
+ setPreparingTool(null);
229
+ setStreamStartedAt(null);
230
+ }
231
+ }
232
+
233
+ setIsLoading(false);
234
+ processingRef.current = false;
235
+ }, [sessionRef, setMessages, markActivityRef, syncQueue]);
236
+
237
+ return {
238
+ queueRef,
239
+ processingRef,
240
+ queuedMessages,
241
+ selectedQueueIndex,
242
+ setSelectedQueueIndex,
243
+ syncQueue,
244
+ processQueue,
245
+ isLoading,
246
+ streamingText,
247
+ activeToolCalls,
248
+ preparingTool,
249
+ streamStartedAt,
250
+ usage,
251
+ setUsage,
252
+ clearStreamingState,
253
+ };
254
+ }
@@ -0,0 +1,20 @@
1
+ import { useStdout } from "ink";
2
+ import { useEffect, useState } from "react";
3
+
4
+ // Track the terminal's row count so the dynamic frame stays strictly below
5
+ // fullscreen. Ink 7 wipes scrollback whenever the dynamic frame is overflowing
6
+ // or transitions out of fullscreen — so as long as the rendered output height
7
+ // stays < `rows` on every render, scrollback is preserved.
8
+ export function useTerminalRows(): number {
9
+ const { stdout } = useStdout();
10
+ const [rows, setRows] = useState(stdout?.rows ?? 24);
11
+ useEffect(() => {
12
+ if (!stdout) return;
13
+ const onResize = () => setRows(stdout.rows ?? 24);
14
+ stdout.on("resize", onResize);
15
+ return () => {
16
+ stdout.off("resize", onResize);
17
+ };
18
+ }, [stdout]);
19
+ return rows;
20
+ }
@@ -0,0 +1,24 @@
1
+ import type { TabId } from "./components/TabBar.tsx";
2
+
3
+ // Tab routing: Ctrl+<letter> jumps to a tab. Chosen for memorability — first
4
+ // available letter that doesn't collide with other Ctrl bindings (Ctrl+C exit,
5
+ // Ctrl+J/K/X/E queue ops on Chat).
6
+ //
7
+ // Help is bound to Ctrl+G rather than Ctrl+H because most terminals deliver
8
+ // Ctrl+H as ASCII 0x08 (backspace). Bonus: macOS Terminal.app and several
9
+ // other terminals map Ctrl+/ to BEL (0x07), the same byte as Ctrl+G — so this
10
+ // binding also catches the Ctrl+/ keystroke on those terminals "for free".
11
+ // We also accept "/" and "_" as fallbacks for terminals that deliver Ctrl+/
12
+ // as 0x1F or as the literal "/" with ctrl=true (Kitty keyboard protocol).
13
+ export const TAB_BY_CTRL_KEY: Record<string, TabId> = {
14
+ a: 1, // ch[a]t
15
+ o: 2, // t[o]ols
16
+ n: 3, // co[n]text
17
+ t: 4, // [t]asks
18
+ e: 5, // thr[e]ads
19
+ s: 6, // [s]chedules
20
+ w: 7, // [w]orkers
21
+ g: 8, // help (also catches Ctrl+/ on terminals that map it to BEL)
22
+ "/": 8, // help (Kitty keyboard protocol)
23
+ _: 8, // help (terminals that send Ctrl+/ as 0x1F)
24
+ };
@@ -0,0 +1,11 @@
1
+ // Conservative line reservation for the bottom chrome — StatusBar (1) +
2
+ // bordered InputBar (3) + multiline hint (1) + TabBar (1) + slack for the
3
+ // SlashCommandPopup or QueuePanel (~4). The chat-tab body's `maxHeight` and
4
+ // the panel boxes' `height` both subtract this from `rows` so the dynamic
5
+ // frame's total output stays strictly below the viewport.
6
+ export const FOOTER_RESERVE = 10;
7
+
8
+ let nextMsgId = 0;
9
+ export function msgId(): string {
10
+ return `msg-${++nextMsgId}`;
11
+ }
@@ -17,6 +17,13 @@ export interface FakeTurn {
17
17
  chunkSize?: number;
18
18
  /** Delay between chunks in milliseconds. */
19
19
  delayMs?: number;
20
+ /**
21
+ * Initial wait before the first chunk emits, in milliseconds. Mirrors a
22
+ * real model's first-token latency — useful for capture fixtures where
23
+ * back-to-back turns would otherwise complete instantly and look
24
+ * unrealistic.
25
+ */
26
+ preDelayMs?: number;
20
27
  /** Optional tool calls to emit after text. */
21
28
  toolCalls?: Array<{
22
29
  id?: string;
@@ -125,6 +132,12 @@ function buildFinalMessage(
125
132
  class FakeMessageStream extends EventEmitter {
126
133
  private resolveFinal: (m: Message) => void = () => {};
127
134
  private readonly finalPromise: Promise<Message>;
135
+ // Buffered events for `for await` consumers; populated by `run()` so the
136
+ // EventEmitter and async-iterator interfaces stay in sync without
137
+ // double-driving the turn.
138
+ private readonly events: Array<Record<string, unknown>> = [];
139
+ private eventsDone = false;
140
+ private notifyEvent: (() => void) | null = null;
128
141
 
129
142
  constructor(private readonly turn: FakeTurn) {
130
143
  super();
@@ -134,13 +147,27 @@ class FakeMessageStream extends EventEmitter {
134
147
  queueMicrotask(() => this.run());
135
148
  }
136
149
 
150
+ private pushEvent(ev: Record<string, unknown>): void {
151
+ this.events.push(ev);
152
+ this.notifyEvent?.();
153
+ }
154
+
137
155
  private async run(): Promise<void> {
138
156
  const text = this.turn.text ?? this.turn.chunks?.join("") ?? "";
139
157
  const chunks =
140
158
  this.turn.chunks ?? chunkText(text, this.turn.chunkSize ?? 6);
141
159
  const delay = this.turn.delayMs ?? 40;
160
+ const preDelay = this.turn.preDelayMs ?? 0;
161
+ if (preDelay > 0) await new Promise((r) => setTimeout(r, preDelay));
142
162
  for (const chunk of chunks) {
143
163
  this.emit("text", chunk);
164
+ const ev = {
165
+ type: "content_block_delta",
166
+ index: 0,
167
+ delta: { type: "text_delta", text: chunk },
168
+ };
169
+ this.emit("streamEvent", ev);
170
+ this.pushEvent(ev);
144
171
  if (delay > 0) await new Promise((r) => setTimeout(r, delay));
145
172
  }
146
173
  const final = buildFinalMessage(text, this.turn.toolCalls);
@@ -162,9 +189,37 @@ class FakeMessageStream extends EventEmitter {
162
189
  blockIndex++;
163
190
  }
164
191
  }
192
+ const stop = { type: "message_stop" };
193
+ this.emit("streamEvent", stop);
194
+ this.pushEvent(stop);
195
+ this.eventsDone = true;
196
+ this.notifyEvent?.();
165
197
  this.resolveFinal(final);
166
198
  }
167
199
 
200
+ /**
201
+ * Async iterator support so `for await (const event of stream)` callers
202
+ * (e.g. `src/context/markdown-converter.ts`) get the same shape as the
203
+ * real SDK. Events are sourced from the buffer populated by `run()`, so
204
+ * the iterator and the EventEmitter interface observe a single timeline.
205
+ */
206
+ async *[Symbol.asyncIterator](): AsyncIterableIterator<
207
+ Record<string, unknown>
208
+ > {
209
+ let cursor = 0;
210
+ while (true) {
211
+ while (cursor < this.events.length) {
212
+ const ev = this.events[cursor++];
213
+ if (ev) yield ev;
214
+ }
215
+ if (this.eventsDone) return;
216
+ await new Promise<void>((resolve) => {
217
+ this.notifyEvent = resolve;
218
+ });
219
+ this.notifyEvent = null;
220
+ }
221
+ }
222
+
168
223
  finalMessage(): Promise<Message> {
169
224
  return this.finalPromise;
170
225
  }
@@ -207,6 +262,11 @@ export function createFakeAnthropicClient(): Anthropic {
207
262
  return buildFinalMessage("Chat session");
208
263
  }
209
264
  const turn = selectTurn(extractLastUserText(params.messages));
265
+ // Honour preDelayMs so non-streaming callers (e.g. the fetcher
266
+ // loop in src/context/fetcher.ts) get the same "thinking" pause
267
+ // a streaming caller would.
268
+ const preDelay = turn.preDelayMs ?? 0;
269
+ if (preDelay > 0) await new Promise((r) => setTimeout(r, preDelay));
210
270
  return buildFinalMessage(
211
271
  turn.text ?? turn.chunks?.join("") ?? "",
212
272
  turn.toolCalls,
@@ -48,6 +48,56 @@ export function fakeMcpSearch(query: string): FakeMcpSearchResult[] | null {
48
48
  },
49
49
  ];
50
50
  }
51
+ if (/google.*doc|docs?\.google|gdoc|document/.test(q)) {
52
+ return [
53
+ {
54
+ server: "google-docs",
55
+ tool: "GetDocumentAsMarkdown",
56
+ description:
57
+ "Fetch the contents of a Google Doc by URL or ID and return it as Markdown.",
58
+ score: 0.96,
59
+ match_type: "semantic",
60
+ },
61
+ {
62
+ server: "google-docs",
63
+ tool: "GetDocumentAsHtml",
64
+ description: "Fetch a Google Doc as raw HTML.",
65
+ score: 0.71,
66
+ match_type: "semantic",
67
+ },
68
+ ];
69
+ }
70
+ if (/github|pull request|\bpr\b|repo|commit/.test(q)) {
71
+ return [
72
+ {
73
+ server: "github",
74
+ tool: "ListMyPullRequests",
75
+ description:
76
+ "List the user's recent pull requests across all repos, with status and last activity.",
77
+ score: 0.93,
78
+ match_type: "semantic",
79
+ },
80
+ {
81
+ server: "github",
82
+ tool: "ListAssignedIssues",
83
+ description: "List GitHub issues assigned to the user.",
84
+ score: 0.79,
85
+ match_type: "semantic",
86
+ },
87
+ ];
88
+ }
89
+ if (/linear|ticket|issue.*track/.test(q)) {
90
+ return [
91
+ {
92
+ server: "linear",
93
+ tool: "ListMyIssues",
94
+ description:
95
+ "List Linear issues assigned to the user, including status and any blocking notes.",
96
+ score: 0.95,
97
+ match_type: "semantic",
98
+ },
99
+ ];
100
+ }
51
101
  return null;
52
102
  }
53
103
 
@@ -70,5 +120,89 @@ export function fakeMcpExec(
70
120
  2,
71
121
  );
72
122
  }
123
+ if (server === "github" && tool === "ListMyPullRequests") {
124
+ return JSON.stringify(
125
+ {
126
+ pull_requests: [
127
+ {
128
+ number: 213,
129
+ repo: "evantahler/botholomew",
130
+ title:
131
+ "TUI: close chat gap, hide chat scrollback on other tabs, wrap detail panes",
132
+ status: "merged",
133
+ merged_at: "2026-05-05T18:42:00Z",
134
+ },
135
+ {
136
+ number: 214,
137
+ repo: "evantahler/botholomew",
138
+ title: "Make /standup demo call GitHub + Linear before synthesis",
139
+ status: "open",
140
+ updated_at: "2026-05-06T09:01:00Z",
141
+ review_state: "requested_changes",
142
+ },
143
+ ],
144
+ },
145
+ null,
146
+ 2,
147
+ );
148
+ }
149
+ if (server === "linear" && tool === "ListMyIssues") {
150
+ return JSON.stringify(
151
+ {
152
+ issues: [
153
+ {
154
+ id: "ENG-487",
155
+ title: "Worker reaper sometimes drops heartbeats under load",
156
+ status: "in_progress",
157
+ priority: "medium",
158
+ },
159
+ {
160
+ id: "ENG-491",
161
+ title: "Flaky context-reindex test under Bun 1.4",
162
+ status: "blocked",
163
+ priority: "high",
164
+ note: "Blocked on upstream Bun fix oven-sh/bun#26081",
165
+ },
166
+ {
167
+ id: "ENG-503",
168
+ title: "Add structured event log for capabilities refresh",
169
+ status: "todo",
170
+ priority: "low",
171
+ },
172
+ ],
173
+ },
174
+ null,
175
+ 2,
176
+ );
177
+ }
178
+ if (server === "google-docs" && tool === "GetDocumentAsMarkdown") {
179
+ return [
180
+ "# Botholomew v0.8 launch plan",
181
+ "",
182
+ "## Themes",
183
+ "",
184
+ "1. Better MCPX ergonomics — fewer steps to wire up a new server.",
185
+ "2. Chat TUI polish — context indicator, idle-pause, slash menu.",
186
+ "3. Doc captures — every GIF in the docs is hermetic + regenerable.",
187
+ "",
188
+ "## Milestones",
189
+ "",
190
+ "- [x] Move tasks/schedules/context onto disk",
191
+ "- [x] Replace OpenAI embeddings with @huggingface/transformers",
192
+ "- [ ] Ship `context import` for Google Docs end-to-end",
193
+ "- [ ] Cut v0.8 release notes",
194
+ "",
195
+ "## Open questions",
196
+ "",
197
+ "- How do we version skill templates across releases?",
198
+ "- Should `context refresh` rate-limit per source-domain?",
199
+ "",
200
+ "## Stakeholders",
201
+ "",
202
+ "- Pascal (design review)",
203
+ "- Sterling (reliability + oncall sign-off)",
204
+ "",
205
+ ].join("\n");
206
+ }
73
207
  return null;
74
208
  }