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/README.md +5 -0
- package/package.json +2 -1
- package/src/context/fetcher.ts +2 -2
- package/src/context/markdown-converter.ts +2 -2
- package/src/tui/App.tsx +134 -789
- package/src/tui/components/ContextPanel.tsx +7 -5
- package/src/tui/components/MessageList.tsx +44 -21
- package/src/tui/components/SchedulePanel.tsx +7 -5
- package/src/tui/components/TabBar.tsx +1 -1
- package/src/tui/components/TabPanels.tsx +108 -0
- package/src/tui/components/TaskPanel.tsx +7 -5
- package/src/tui/components/ThreadPanel.tsx +7 -5
- package/src/tui/components/ToolCall.tsx +3 -2
- package/src/tui/components/ToolPanel.tsx +9 -5
- 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/tui/restoreMessages.ts +106 -0
- package/src/tui/useTerminalSize.ts +26 -0
- package/src/tui/wrapDetail.ts +15 -0
- package/src/worker/fake-llm.ts +60 -0
- package/src/worker/fake-mcp.ts +134 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { Interaction } from "../threads/store.ts";
|
|
2
|
+
import { MAX_INLINE_CHARS, PAGE_SIZE_CHARS } from "../worker/large-results.ts";
|
|
3
|
+
import type { ChatMessage } from "./components/MessageList.tsx";
|
|
4
|
+
import type { ToolCallData } from "./components/ToolCall.tsx";
|
|
5
|
+
|
|
6
|
+
let nextRestoreId = 0;
|
|
7
|
+
function restoreMsgId(): string {
|
|
8
|
+
return `restore-msg-${++nextRestoreId}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function detectToolError(output: string | undefined): boolean {
|
|
12
|
+
if (!output) return false;
|
|
13
|
+
try {
|
|
14
|
+
const parsed = JSON.parse(output);
|
|
15
|
+
if (typeof parsed === "object" && parsed?.is_error === true) return true;
|
|
16
|
+
} catch {
|
|
17
|
+
/* not JSON */
|
|
18
|
+
}
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Reconstruct `ChatMessage[]` from a thread's interaction log so the TUI can
|
|
24
|
+
* hydrate chat history (plus the Tools tab) when resuming a session.
|
|
25
|
+
*
|
|
26
|
+
* Tools attach to the assistant message that *issued* them, not the next one.
|
|
27
|
+
* `runChatTurn` logs in the order: assistant text → tool_use(s) → tool_result(s)
|
|
28
|
+
* → next assistant text, so we track the most recent assistant message and
|
|
29
|
+
* append tool calls there until a user message resets the cursor.
|
|
30
|
+
*/
|
|
31
|
+
export function restoreMessagesFromInteractions(
|
|
32
|
+
interactions: Interaction[],
|
|
33
|
+
): ChatMessage[] {
|
|
34
|
+
const result: ChatMessage[] = [];
|
|
35
|
+
let currentAssistant: ChatMessage | null = null;
|
|
36
|
+
let orphanTools: ToolCallData[] = [];
|
|
37
|
+
let restoredIdx = 0;
|
|
38
|
+
|
|
39
|
+
const makeToolCall = (ix: Interaction): ToolCallData => ({
|
|
40
|
+
id: `restored-${restoredIdx++}`,
|
|
41
|
+
name: ix.tool_name ?? "unknown",
|
|
42
|
+
input: ix.tool_input ?? "{}",
|
|
43
|
+
running: false,
|
|
44
|
+
timestamp: ix.created_at,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
for (const ix of interactions) {
|
|
48
|
+
if (ix.kind === "tool_use") {
|
|
49
|
+
const tc = makeToolCall(ix);
|
|
50
|
+
if (currentAssistant) {
|
|
51
|
+
const list = currentAssistant.toolCalls ?? [];
|
|
52
|
+
list.push(tc);
|
|
53
|
+
currentAssistant.toolCalls = list;
|
|
54
|
+
} else {
|
|
55
|
+
orphanTools.push(tc);
|
|
56
|
+
}
|
|
57
|
+
} else if (ix.kind === "tool_result") {
|
|
58
|
+
const pool = currentAssistant?.toolCalls ?? orphanTools;
|
|
59
|
+
const tc = pool.find((t) => t.name === ix.tool_name && !t.output);
|
|
60
|
+
if (tc) {
|
|
61
|
+
tc.output = ix.content;
|
|
62
|
+
tc.isError = detectToolError(ix.content);
|
|
63
|
+
if (ix.content.length > MAX_INLINE_CHARS) {
|
|
64
|
+
tc.largeResult = {
|
|
65
|
+
id: "(restored)",
|
|
66
|
+
chars: ix.content.length,
|
|
67
|
+
pages: Math.ceil(ix.content.length / PAGE_SIZE_CHARS),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} else if (ix.kind === "message" && ix.role === "user") {
|
|
72
|
+
result.push({
|
|
73
|
+
id: restoreMsgId(),
|
|
74
|
+
role: "user",
|
|
75
|
+
content: ix.content,
|
|
76
|
+
timestamp: ix.created_at,
|
|
77
|
+
});
|
|
78
|
+
currentAssistant = null;
|
|
79
|
+
} else if (ix.kind === "message" && ix.role === "assistant") {
|
|
80
|
+
const msg: ChatMessage = {
|
|
81
|
+
id: restoreMsgId(),
|
|
82
|
+
role: "assistant",
|
|
83
|
+
content: ix.content,
|
|
84
|
+
timestamp: ix.created_at,
|
|
85
|
+
};
|
|
86
|
+
if (orphanTools.length > 0) {
|
|
87
|
+
msg.toolCalls = [...orphanTools];
|
|
88
|
+
orphanTools = [];
|
|
89
|
+
}
|
|
90
|
+
result.push(msg);
|
|
91
|
+
currentAssistant = msg;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (orphanTools.length > 0) {
|
|
96
|
+
result.push({
|
|
97
|
+
id: restoreMsgId(),
|
|
98
|
+
role: "assistant",
|
|
99
|
+
content: "",
|
|
100
|
+
timestamp: orphanTools[0]?.timestamp ?? new Date(),
|
|
101
|
+
toolCalls: [...orphanTools],
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useStdout } from "ink";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Track terminal columns + rows. Ink's `useStdout` doesn't re-render on
|
|
6
|
+
* resize, so panels that compute layout from terminal width (e.g. detail
|
|
7
|
+
* panes that wrap long lines) need this hook to stay accurate.
|
|
8
|
+
*/
|
|
9
|
+
export function useTerminalSize(): { cols: number; rows: number } {
|
|
10
|
+
const { stdout } = useStdout();
|
|
11
|
+
const [size, setSize] = useState(() => ({
|
|
12
|
+
cols: stdout?.columns ?? 80,
|
|
13
|
+
rows: stdout?.rows ?? 24,
|
|
14
|
+
}));
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (!stdout) return;
|
|
17
|
+
const onResize = () => {
|
|
18
|
+
setSize({ cols: stdout.columns ?? 80, rows: stdout.rows ?? 24 });
|
|
19
|
+
};
|
|
20
|
+
stdout.on("resize", onResize);
|
|
21
|
+
return () => {
|
|
22
|
+
stdout.off("resize", onResize);
|
|
23
|
+
};
|
|
24
|
+
}, [stdout]);
|
|
25
|
+
return size;
|
|
26
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import wrapAnsi from "wrap-ansi";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wrap an ANSI-colored body string to a column width and return one entry
|
|
5
|
+
* per visual line. Used by the right-pane detail views in every list/detail
|
|
6
|
+
* panel (Tools, Tasks, Threads, Schedules, Context) so long lines wrap
|
|
7
|
+
* instead of getting truncated by `<Text wrap="truncate-end">`.
|
|
8
|
+
*
|
|
9
|
+
* `wrap-ansi` preserves SGR state across wrap boundaries, so colorized
|
|
10
|
+
* JSON / markdown stays intact.
|
|
11
|
+
*/
|
|
12
|
+
export function wrapDetailLines(text: string, width: number): string[] {
|
|
13
|
+
if (width <= 0) return text.split("\n");
|
|
14
|
+
return wrapAnsi(text, width, { hard: true, trim: false }).split("\n");
|
|
15
|
+
}
|
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
|
}
|