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/README.md +5 -0
- package/package.json +1 -1
- package/src/context/fetcher.ts +2 -2
- package/src/context/markdown-converter.ts +2 -2
- package/src/tui/App.tsx +111 -748
- package/src/tui/components/TabBar.tsx +1 -1
- package/src/tui/components/TabPanels.tsx +108 -0
- package/src/tui/handleSubmit.ts +206 -0
- package/src/tui/hooks/useAppKeybindings.ts +166 -0
- package/src/tui/hooks/useCaptureTabCycle.ts +28 -0
- package/src/tui/hooks/useChatSession.ts +151 -0
- package/src/tui/hooks/useChatTitlePolling.ts +36 -0
- package/src/tui/hooks/useMessageQueue.ts +254 -0
- package/src/tui/hooks/useTerminalRows.ts +20 -0
- package/src/tui/keys.ts +24 -0
- package/src/tui/messages.ts +11 -0
- package/src/worker/fake-llm.ts +60 -0
- package/src/worker/fake-mcp.ts +134 -0
|
@@ -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
|
+
}
|
package/src/tui/keys.ts
ADDED
|
@@ -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
|
+
}
|
package/src/worker/fake-llm.ts
CHANGED
|
@@ -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,
|
package/src/worker/fake-mcp.ts
CHANGED
|
@@ -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
|
}
|