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
|
@@ -10,7 +10,7 @@ const TABS: { id: TabId; label: string; key: string }[] = [
|
|
|
10
10
|
{ id: 2, label: "Tools", key: "^o" },
|
|
11
11
|
{ id: 3, label: "Context", key: "^n" },
|
|
12
12
|
{ id: 4, label: "Tasks", key: "^t" },
|
|
13
|
-
{ id: 5, label: "Threads", key: "^
|
|
13
|
+
{ id: 5, label: "Threads", key: "^e" },
|
|
14
14
|
{ id: 6, label: "Schedules", key: "^s" },
|
|
15
15
|
{ id: 7, label: "Workers", key: "^w" },
|
|
16
16
|
{ id: 8, label: "Help", key: "^g" },
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Box } from "ink";
|
|
2
|
+
import type { ContextUsage } from "../../chat/usage.ts";
|
|
3
|
+
import { ContextPanel } from "./ContextPanel.tsx";
|
|
4
|
+
import { HelpPanel } from "./HelpPanel.tsx";
|
|
5
|
+
import { SchedulePanel } from "./SchedulePanel.tsx";
|
|
6
|
+
import type { TabId } from "./TabBar.tsx";
|
|
7
|
+
import { TaskPanel } from "./TaskPanel.tsx";
|
|
8
|
+
import { ThreadPanel } from "./ThreadPanel.tsx";
|
|
9
|
+
import type { ToolCallData } from "./ToolCall.tsx";
|
|
10
|
+
import { ToolPanel } from "./ToolPanel.tsx";
|
|
11
|
+
import { WorkerPanel } from "./WorkerPanel.tsx";
|
|
12
|
+
|
|
13
|
+
interface TabPanelsProps {
|
|
14
|
+
activeTab: TabId;
|
|
15
|
+
projectDir: string;
|
|
16
|
+
threadId: string;
|
|
17
|
+
allToolCalls: ToolCallData[];
|
|
18
|
+
workerRunning: boolean;
|
|
19
|
+
usage: ContextUsage | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Tabs 2–8. The chat tab (1) is structurally different (`maxHeight` clipping,
|
|
23
|
+
// streaming props) and stays inline in App.tsx. All panels stay mounted to
|
|
24
|
+
// avoid expensive remount cycles — `display="none"` hides inactive panels
|
|
25
|
+
// from layout without destroying them.
|
|
26
|
+
//
|
|
27
|
+
// `flexGrow={1}` fills the root (which is pinned to `rows` on these tabs)
|
|
28
|
+
// minus the footer's actual height, so the panel always reaches the top of
|
|
29
|
+
// the viewport — no scrollback leak above the panel regardless of footer
|
|
30
|
+
// height.
|
|
31
|
+
export function TabPanels({
|
|
32
|
+
activeTab,
|
|
33
|
+
projectDir,
|
|
34
|
+
threadId,
|
|
35
|
+
allToolCalls,
|
|
36
|
+
workerRunning,
|
|
37
|
+
usage,
|
|
38
|
+
}: TabPanelsProps) {
|
|
39
|
+
return (
|
|
40
|
+
<>
|
|
41
|
+
<Box
|
|
42
|
+
display={activeTab === 2 ? "flex" : "none"}
|
|
43
|
+
flexDirection="column"
|
|
44
|
+
flexGrow={1}
|
|
45
|
+
overflow="hidden"
|
|
46
|
+
>
|
|
47
|
+
<ToolPanel toolCalls={allToolCalls} isActive={activeTab === 2} />
|
|
48
|
+
</Box>
|
|
49
|
+
<Box
|
|
50
|
+
display={activeTab === 3 ? "flex" : "none"}
|
|
51
|
+
flexDirection="column"
|
|
52
|
+
flexGrow={1}
|
|
53
|
+
overflow="hidden"
|
|
54
|
+
>
|
|
55
|
+
<ContextPanel projectDir={projectDir} isActive={activeTab === 3} />
|
|
56
|
+
</Box>
|
|
57
|
+
<Box
|
|
58
|
+
display={activeTab === 4 ? "flex" : "none"}
|
|
59
|
+
flexDirection="column"
|
|
60
|
+
flexGrow={1}
|
|
61
|
+
overflow="hidden"
|
|
62
|
+
>
|
|
63
|
+
<TaskPanel projectDir={projectDir} isActive={activeTab === 4} />
|
|
64
|
+
</Box>
|
|
65
|
+
<Box
|
|
66
|
+
display={activeTab === 5 ? "flex" : "none"}
|
|
67
|
+
flexDirection="column"
|
|
68
|
+
flexGrow={1}
|
|
69
|
+
overflow="hidden"
|
|
70
|
+
>
|
|
71
|
+
<ThreadPanel
|
|
72
|
+
projectDir={projectDir}
|
|
73
|
+
activeThreadId={threadId}
|
|
74
|
+
isActive={activeTab === 5}
|
|
75
|
+
/>
|
|
76
|
+
</Box>
|
|
77
|
+
<Box
|
|
78
|
+
display={activeTab === 6 ? "flex" : "none"}
|
|
79
|
+
flexDirection="column"
|
|
80
|
+
flexGrow={1}
|
|
81
|
+
overflow="hidden"
|
|
82
|
+
>
|
|
83
|
+
<SchedulePanel projectDir={projectDir} isActive={activeTab === 6} />
|
|
84
|
+
</Box>
|
|
85
|
+
<Box
|
|
86
|
+
display={activeTab === 7 ? "flex" : "none"}
|
|
87
|
+
flexDirection="column"
|
|
88
|
+
flexGrow={1}
|
|
89
|
+
overflow="hidden"
|
|
90
|
+
>
|
|
91
|
+
<WorkerPanel projectDir={projectDir} isActive={activeTab === 7} />
|
|
92
|
+
</Box>
|
|
93
|
+
<Box
|
|
94
|
+
display={activeTab === 8 ? "flex" : "none"}
|
|
95
|
+
flexDirection="column"
|
|
96
|
+
flexGrow={1}
|
|
97
|
+
overflow="hidden"
|
|
98
|
+
>
|
|
99
|
+
<HelpPanel
|
|
100
|
+
projectDir={projectDir}
|
|
101
|
+
threadId={threadId}
|
|
102
|
+
workerRunning={workerRunning}
|
|
103
|
+
usage={usage}
|
|
104
|
+
/>
|
|
105
|
+
</Box>
|
|
106
|
+
</>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Dispatch,
|
|
3
|
+
type MutableRefObject,
|
|
4
|
+
type SetStateAction,
|
|
5
|
+
useCallback,
|
|
6
|
+
} from "react";
|
|
7
|
+
import {
|
|
8
|
+
abortActiveStream,
|
|
9
|
+
type ChatSession,
|
|
10
|
+
clearChatSession,
|
|
11
|
+
} from "../chat/session.ts";
|
|
12
|
+
import type { ContextUsage } from "../chat/usage.ts";
|
|
13
|
+
import { handleSlashCommand } from "../skills/commands.ts";
|
|
14
|
+
import type { ChatMessage } from "./components/MessageList.tsx";
|
|
15
|
+
import type { QueueEntry } from "./hooks/useMessageQueue.ts";
|
|
16
|
+
import { msgId } from "./messages.ts";
|
|
17
|
+
|
|
18
|
+
interface UseChatSubmitParams {
|
|
19
|
+
sessionRef: MutableRefObject<ChatSession | null>;
|
|
20
|
+
queueRef: MutableRefObject<QueueEntry[]>;
|
|
21
|
+
processingRef: MutableRefObject<boolean>;
|
|
22
|
+
clearingRef: MutableRefObject<boolean>;
|
|
23
|
+
syncQueue: () => void;
|
|
24
|
+
processQueue: () => Promise<void>;
|
|
25
|
+
performShutdown: () => Promise<void>;
|
|
26
|
+
clearStreamingState: () => void;
|
|
27
|
+
setMessages: Dispatch<SetStateAction<ChatMessage[]>>;
|
|
28
|
+
setInputValue: Dispatch<SetStateAction<string>>;
|
|
29
|
+
setInputHistory: Dispatch<SetStateAction<string[]>>;
|
|
30
|
+
setMessagesEpoch: Dispatch<SetStateAction<number>>;
|
|
31
|
+
setChatTitle: (t: string | undefined) => void;
|
|
32
|
+
setClearing: Dispatch<SetStateAction<boolean>>;
|
|
33
|
+
setUsage: Dispatch<SetStateAction<ContextUsage | null>>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function useChatSubmit({
|
|
37
|
+
sessionRef,
|
|
38
|
+
queueRef,
|
|
39
|
+
processingRef,
|
|
40
|
+
clearingRef,
|
|
41
|
+
syncQueue,
|
|
42
|
+
processQueue,
|
|
43
|
+
performShutdown,
|
|
44
|
+
clearStreamingState,
|
|
45
|
+
setMessages,
|
|
46
|
+
setInputValue,
|
|
47
|
+
setInputHistory,
|
|
48
|
+
setMessagesEpoch,
|
|
49
|
+
setChatTitle,
|
|
50
|
+
setClearing,
|
|
51
|
+
setUsage,
|
|
52
|
+
}: UseChatSubmitParams): (text: string) => Promise<void> {
|
|
53
|
+
return useCallback(
|
|
54
|
+
async (text: string) => {
|
|
55
|
+
const trimmed = text.trim();
|
|
56
|
+
if (!trimmed || !sessionRef.current) return;
|
|
57
|
+
// /clear is mid-flight: don't queue against the old thread id.
|
|
58
|
+
if (clearingRef.current) return;
|
|
59
|
+
|
|
60
|
+
setInputValue("");
|
|
61
|
+
|
|
62
|
+
if (trimmed === "/help") {
|
|
63
|
+
const skills = sessionRef.current.skills;
|
|
64
|
+
const lines: string[] = [
|
|
65
|
+
"For the full keyboard reference, switch to the Help tab (`Ctrl+g`) — this message lists chat commands only.",
|
|
66
|
+
"",
|
|
67
|
+
"Slash commands:",
|
|
68
|
+
" /help Show this message",
|
|
69
|
+
" /skills List available skills",
|
|
70
|
+
" /clear End current thread and start a new one",
|
|
71
|
+
" /exit End the chat session",
|
|
72
|
+
];
|
|
73
|
+
if (skills.size > 0) {
|
|
74
|
+
lines.push("", "Skills:");
|
|
75
|
+
for (const [skillName, skill] of skills) {
|
|
76
|
+
lines.push(
|
|
77
|
+
` /${skillName.padEnd(14)} ${skill.description || "(no description)"}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
} else {
|
|
81
|
+
lines.push("", "Skills:", " (none — add .md files to skills/)");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const helpMsg: ChatMessage = {
|
|
85
|
+
id: msgId(),
|
|
86
|
+
role: "system",
|
|
87
|
+
content: lines.join("\n"),
|
|
88
|
+
timestamp: new Date(),
|
|
89
|
+
};
|
|
90
|
+
setMessages((prev) => [...prev, helpMsg]);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (trimmed.startsWith("/")) {
|
|
95
|
+
const skills = sessionRef.current.skills;
|
|
96
|
+
const handled = handleSlashCommand(trimmed, {
|
|
97
|
+
skills,
|
|
98
|
+
addSystemMessage: (content) => {
|
|
99
|
+
const msg: ChatMessage = {
|
|
100
|
+
id: msgId(),
|
|
101
|
+
role: "system",
|
|
102
|
+
content,
|
|
103
|
+
timestamp: new Date(),
|
|
104
|
+
};
|
|
105
|
+
setMessages((prev) => [...prev, msg]);
|
|
106
|
+
},
|
|
107
|
+
queueUserMessage: (content, opts) => {
|
|
108
|
+
setInputHistory((prev) => [...prev, trimmed]);
|
|
109
|
+
queueRef.current.push({
|
|
110
|
+
display: opts?.display ?? content,
|
|
111
|
+
content,
|
|
112
|
+
});
|
|
113
|
+
syncQueue();
|
|
114
|
+
processQueue();
|
|
115
|
+
},
|
|
116
|
+
exit: () => void performShutdown(),
|
|
117
|
+
clearChat: () => {
|
|
118
|
+
const session = sessionRef.current;
|
|
119
|
+
if (!session) return;
|
|
120
|
+
// Drain any queued messages so they don't leak into the new thread.
|
|
121
|
+
queueRef.current.length = 0;
|
|
122
|
+
syncQueue();
|
|
123
|
+
// Abort any in-flight stream synchronously so its callbacks stop
|
|
124
|
+
// firing before we reset UI state. clearChatSession also calls
|
|
125
|
+
// this, but doing it here lets us start the wait-for-quiesce
|
|
126
|
+
// poll below immediately rather than waiting on the
|
|
127
|
+
// createThread/endThread round trip first.
|
|
128
|
+
abortActiveStream(session);
|
|
129
|
+
// Block new submissions until the new thread id is in place —
|
|
130
|
+
// otherwise the user's first post-/clear message races the
|
|
131
|
+
// async createThread, runs against the old thread id, and is
|
|
132
|
+
// then wiped by setMessages([sys]) below.
|
|
133
|
+
clearingRef.current = true;
|
|
134
|
+
setClearing(true);
|
|
135
|
+
void (async () => {
|
|
136
|
+
// Wait for any in-flight processQueue iteration to finish so
|
|
137
|
+
// its trailing `finalizeSegment` can't race our state reset
|
|
138
|
+
// and re-add the previous thread's assistant message after
|
|
139
|
+
// the UI has been cleared. (Issue #190.)
|
|
140
|
+
while (processingRef.current) {
|
|
141
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
const { previousThreadId, newThreadId } =
|
|
145
|
+
await clearChatSession(session);
|
|
146
|
+
// Ink's <Static> writes messages to terminal scrollback and
|
|
147
|
+
// can't un-write them, so setMessages alone leaves the old
|
|
148
|
+
// lines visible. Clear the terminal (including scrollback)
|
|
149
|
+
// and bump the epoch key on <Static> to force a fresh mount.
|
|
150
|
+
process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
|
|
151
|
+
setMessages([
|
|
152
|
+
{
|
|
153
|
+
id: msgId(),
|
|
154
|
+
role: "system",
|
|
155
|
+
content: `Started a new chat thread (${newThreadId}). Previous thread saved — resume with: botholomew chat --thread-id ${previousThreadId}`,
|
|
156
|
+
timestamp: new Date(),
|
|
157
|
+
},
|
|
158
|
+
]);
|
|
159
|
+
setMessagesEpoch((n) => n + 1);
|
|
160
|
+
setChatTitle(undefined);
|
|
161
|
+
clearStreamingState();
|
|
162
|
+
setUsage(null);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
setMessages((prev) => [
|
|
165
|
+
...prev,
|
|
166
|
+
{
|
|
167
|
+
id: msgId(),
|
|
168
|
+
role: "system",
|
|
169
|
+
content: `Failed to clear chat: ${err}`,
|
|
170
|
+
timestamp: new Date(),
|
|
171
|
+
},
|
|
172
|
+
]);
|
|
173
|
+
} finally {
|
|
174
|
+
clearingRef.current = false;
|
|
175
|
+
setClearing(false);
|
|
176
|
+
}
|
|
177
|
+
})();
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
if (handled) return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
setInputHistory((prev) => [...prev, trimmed]);
|
|
184
|
+
queueRef.current.push({ display: trimmed, content: trimmed });
|
|
185
|
+
syncQueue();
|
|
186
|
+
processQueue();
|
|
187
|
+
},
|
|
188
|
+
[
|
|
189
|
+
sessionRef,
|
|
190
|
+
queueRef,
|
|
191
|
+
processingRef,
|
|
192
|
+
clearingRef,
|
|
193
|
+
syncQueue,
|
|
194
|
+
processQueue,
|
|
195
|
+
performShutdown,
|
|
196
|
+
clearStreamingState,
|
|
197
|
+
setMessages,
|
|
198
|
+
setInputValue,
|
|
199
|
+
setInputHistory,
|
|
200
|
+
setMessagesEpoch,
|
|
201
|
+
setChatTitle,
|
|
202
|
+
setClearing,
|
|
203
|
+
setUsage,
|
|
204
|
+
],
|
|
205
|
+
);
|
|
206
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { useInput } from "ink";
|
|
2
|
+
import {
|
|
3
|
+
type Dispatch,
|
|
4
|
+
type MutableRefObject,
|
|
5
|
+
type SetStateAction,
|
|
6
|
+
useCallback,
|
|
7
|
+
useRef,
|
|
8
|
+
} from "react";
|
|
9
|
+
import { abortActiveStream, type ChatSession } from "../../chat/session.ts";
|
|
10
|
+
import type { SlashCommand } from "../../skills/commands.ts";
|
|
11
|
+
import type { TabId } from "../components/TabBar.tsx";
|
|
12
|
+
import { TAB_BY_CTRL_KEY } from "../keys.ts";
|
|
13
|
+
import { getSlashMatches } from "../slashCompletion.ts";
|
|
14
|
+
import type { QueueEntry } from "./useMessageQueue.ts";
|
|
15
|
+
|
|
16
|
+
interface UseAppKeybindingsParams {
|
|
17
|
+
activeTab: TabId;
|
|
18
|
+
setActiveTab: Dispatch<SetStateAction<TabId>>;
|
|
19
|
+
performShutdown: () => Promise<void>;
|
|
20
|
+
sessionRef: MutableRefObject<ChatSession | null>;
|
|
21
|
+
processingRef: MutableRefObject<boolean>;
|
|
22
|
+
queueRef: MutableRefObject<QueueEntry[]>;
|
|
23
|
+
queuedMessages: string[];
|
|
24
|
+
selectedQueueIndex: number;
|
|
25
|
+
setSelectedQueueIndex: Dispatch<SetStateAction<number>>;
|
|
26
|
+
setInputValue: Dispatch<SetStateAction<string>>;
|
|
27
|
+
syncQueue: () => void;
|
|
28
|
+
slashCommandsRef: MutableRefObject<SlashCommand[]>;
|
|
29
|
+
inputValueRef: MutableRefObject<string>;
|
|
30
|
+
markActivityRef: MutableRefObject<() => void>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function useAppKeybindings({
|
|
34
|
+
activeTab,
|
|
35
|
+
setActiveTab,
|
|
36
|
+
performShutdown,
|
|
37
|
+
sessionRef,
|
|
38
|
+
processingRef,
|
|
39
|
+
queueRef,
|
|
40
|
+
queuedMessages,
|
|
41
|
+
selectedQueueIndex,
|
|
42
|
+
setSelectedQueueIndex,
|
|
43
|
+
setInputValue,
|
|
44
|
+
syncQueue,
|
|
45
|
+
slashCommandsRef,
|
|
46
|
+
inputValueRef,
|
|
47
|
+
markActivityRef,
|
|
48
|
+
}: UseAppKeybindingsParams): void {
|
|
49
|
+
// Stable refs for the input handler — same pattern as InputBar to prevent
|
|
50
|
+
// Ink's useInput from re-registering stdin listeners on every render.
|
|
51
|
+
const activeTabRef = useRef(activeTab);
|
|
52
|
+
const queuedMessagesRef = useRef(queuedMessages);
|
|
53
|
+
const selectedQueueIndexRef = useRef(selectedQueueIndex);
|
|
54
|
+
activeTabRef.current = activeTab;
|
|
55
|
+
queuedMessagesRef.current = queuedMessages;
|
|
56
|
+
selectedQueueIndexRef.current = selectedQueueIndex;
|
|
57
|
+
|
|
58
|
+
const handler = useCallback(
|
|
59
|
+
// biome-ignore lint/suspicious/noExplicitAny: Ink's Key type is not exported
|
|
60
|
+
(input: string, key: any) => {
|
|
61
|
+
markActivityRef.current();
|
|
62
|
+
|
|
63
|
+
// Ctrl+C exits. Routed through performShutdown so the in-flight LLM
|
|
64
|
+
// stream is aborted and mcpx is closed before we unmount Ink — without
|
|
65
|
+
// that, one Ctrl-C prints the goodbye but the process stays pinned by
|
|
66
|
+
// the open HTTPS socket and a second Ctrl-C is needed.
|
|
67
|
+
if (input === "c" && key.ctrl) {
|
|
68
|
+
void performShutdown();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Ctrl+<letter> jumps directly to a tab from any tab. On Chat, only
|
|
73
|
+
// suppress these if the slash-autocomplete popup needs the keystroke
|
|
74
|
+
// (Ctrl combos don't drive the popup, but keep the guard symmetric
|
|
75
|
+
// with the previous Tab-cycle behavior).
|
|
76
|
+
if (key.ctrl) {
|
|
77
|
+
const tabForKey = TAB_BY_CTRL_KEY[input];
|
|
78
|
+
if (tabForKey !== undefined) {
|
|
79
|
+
if (activeTabRef.current === 1) {
|
|
80
|
+
const popupOpen = getSlashMatches(
|
|
81
|
+
inputValueRef.current,
|
|
82
|
+
slashCommandsRef.current,
|
|
83
|
+
);
|
|
84
|
+
if (popupOpen) return;
|
|
85
|
+
// Ctrl+E edits a queued message when one is selected; only
|
|
86
|
+
// fall through to the Threads tab-jump when the queue is empty.
|
|
87
|
+
if (input === "e" && queuedMessagesRef.current.length > 0) {
|
|
88
|
+
// handled by the queue keybindings block below
|
|
89
|
+
} else {
|
|
90
|
+
setActiveTab(tabForKey);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
setActiveTab(tabForKey);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const tab = activeTabRef.current;
|
|
101
|
+
|
|
102
|
+
// Esc on Chat tab while a turn is in flight: steer / interrupt.
|
|
103
|
+
// Calls MessageStream.abort() at the SDK layer; tools already running
|
|
104
|
+
// finish normally, but no further LLM turn is started.
|
|
105
|
+
if (key.escape && tab === 1 && processingRef.current) {
|
|
106
|
+
const session = sessionRef.current;
|
|
107
|
+
if (session) {
|
|
108
|
+
abortActiveStream(session);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Queue manipulation keybindings (only when queue has items on Chat tab)
|
|
114
|
+
const queue = queuedMessagesRef.current;
|
|
115
|
+
if (tab === 1 && queue.length > 0 && key.ctrl) {
|
|
116
|
+
if (input === "j") {
|
|
117
|
+
setSelectedQueueIndex((i) => Math.min(i + 1, queue.length - 1));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (input === "k") {
|
|
121
|
+
setSelectedQueueIndex((i) => Math.max(i - 1, 0));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (input === "x") {
|
|
125
|
+
queueRef.current.splice(selectedQueueIndexRef.current, 1);
|
|
126
|
+
syncQueue();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (input === "e") {
|
|
130
|
+
const [msg] = queueRef.current.splice(
|
|
131
|
+
selectedQueueIndexRef.current,
|
|
132
|
+
1,
|
|
133
|
+
);
|
|
134
|
+
syncQueue();
|
|
135
|
+
if (msg) {
|
|
136
|
+
setInputValue(msg.display);
|
|
137
|
+
}
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (tab !== 1) {
|
|
143
|
+
// Escape returns to chat
|
|
144
|
+
if (key.escape) {
|
|
145
|
+
setActiveTab(1);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
[
|
|
151
|
+
performShutdown,
|
|
152
|
+
sessionRef,
|
|
153
|
+
processingRef,
|
|
154
|
+
queueRef,
|
|
155
|
+
setActiveTab,
|
|
156
|
+
setSelectedQueueIndex,
|
|
157
|
+
setInputValue,
|
|
158
|
+
syncQueue,
|
|
159
|
+
slashCommandsRef,
|
|
160
|
+
inputValueRef,
|
|
161
|
+
markActivityRef,
|
|
162
|
+
],
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
useInput(handler);
|
|
166
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type Dispatch, type SetStateAction, useEffect } from "react";
|
|
2
|
+
import type { TabId } from "../components/TabBar.tsx";
|
|
3
|
+
|
|
4
|
+
// Capture-mode tab auto-cycle. Under VHS/ttyd the Tab key doesn't reliably
|
|
5
|
+
// reach Ink, so a docs tape can't drive the tab tour by keystroke. When
|
|
6
|
+
// BOTHOLOMEW_CAPTURE_TAB_CYCLE is set, schedule timers that walk through
|
|
7
|
+
// every tab so a single recording can show all panels.
|
|
8
|
+
//
|
|
9
|
+
// Format: "dwellMs" or "dwellMs:startDelayMs". The optional start delay
|
|
10
|
+
// lets a tape finish a streamed chat reply before the cycle kicks in.
|
|
11
|
+
export function useCaptureTabCycle(
|
|
12
|
+
setActiveTab: Dispatch<SetStateAction<TabId>>,
|
|
13
|
+
): void {
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
const spec = process.env.BOTHOLOMEW_CAPTURE_TAB_CYCLE;
|
|
16
|
+
if (!spec) return;
|
|
17
|
+
const [dwellRaw, delayRaw] = spec.split(":");
|
|
18
|
+
const dwellMs = Number.parseInt(dwellRaw ?? "", 10) || 2500;
|
|
19
|
+
const startDelayMs = Number.parseInt(delayRaw ?? "", 10) || 0;
|
|
20
|
+
const sequence: TabId[] = [2, 3, 4, 5, 6, 7, 8, 1];
|
|
21
|
+
const timers = sequence.map((tab, i) =>
|
|
22
|
+
setTimeout(() => setActiveTab(tab), startDelayMs + dwellMs * (i + 1)),
|
|
23
|
+
);
|
|
24
|
+
return () => {
|
|
25
|
+
for (const t of timers) clearTimeout(t);
|
|
26
|
+
};
|
|
27
|
+
}, [setActiveTab]);
|
|
28
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { useApp } from "ink";
|
|
2
|
+
import {
|
|
3
|
+
type Dispatch,
|
|
4
|
+
type MutableRefObject,
|
|
5
|
+
type SetStateAction,
|
|
6
|
+
useCallback,
|
|
7
|
+
useEffect,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
} from "react";
|
|
11
|
+
import {
|
|
12
|
+
abortActiveStream,
|
|
13
|
+
type ChatSession,
|
|
14
|
+
endChatSession,
|
|
15
|
+
startChatSession,
|
|
16
|
+
} from "../../chat/session.ts";
|
|
17
|
+
import { getThread } from "../../threads/store.ts";
|
|
18
|
+
import type { ChatMessage } from "../components/MessageList.tsx";
|
|
19
|
+
import { msgId } from "../messages.ts";
|
|
20
|
+
import { restoreMessagesFromInteractions } from "../restoreMessages.ts";
|
|
21
|
+
import { ansi } from "../theme.ts";
|
|
22
|
+
|
|
23
|
+
interface UseChatSessionParams {
|
|
24
|
+
projectDir: string;
|
|
25
|
+
resumeThreadId: string | undefined;
|
|
26
|
+
initialPrompt: string | undefined;
|
|
27
|
+
setMessages: Dispatch<SetStateAction<ChatMessage[]>>;
|
|
28
|
+
setError: Dispatch<SetStateAction<string | null>>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface UseChatSessionResult {
|
|
32
|
+
sessionRef: MutableRefObject<ChatSession | null>;
|
|
33
|
+
ready: boolean;
|
|
34
|
+
splashDone: boolean;
|
|
35
|
+
performShutdown: () => Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function useChatSession({
|
|
39
|
+
projectDir,
|
|
40
|
+
resumeThreadId,
|
|
41
|
+
initialPrompt,
|
|
42
|
+
setMessages,
|
|
43
|
+
setError,
|
|
44
|
+
}: UseChatSessionParams): UseChatSessionResult {
|
|
45
|
+
const { exit } = useApp();
|
|
46
|
+
const [ready, setReady] = useState(false);
|
|
47
|
+
const skipSplash = !!(resumeThreadId || initialPrompt);
|
|
48
|
+
const [splashDone, setSplashDone] = useState(skipSplash);
|
|
49
|
+
const sessionRef = useRef<ChatSession | null>(null);
|
|
50
|
+
const shuttingDownRef = useRef(false);
|
|
51
|
+
|
|
52
|
+
// Initialize session
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
let cancelled = false;
|
|
55
|
+
|
|
56
|
+
startChatSession(projectDir, resumeThreadId)
|
|
57
|
+
.then(async (session) => {
|
|
58
|
+
if (cancelled) {
|
|
59
|
+
endChatSession(session);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
sessionRef.current = session;
|
|
63
|
+
|
|
64
|
+
if (resumeThreadId) {
|
|
65
|
+
// Always hydrate on resume so the Tools tab and chat history
|
|
66
|
+
// pick up prior tool_use/tool_result rows from the CSV — even if
|
|
67
|
+
// the thread has no plain message-kind interactions yet.
|
|
68
|
+
const threadData = await getThread(
|
|
69
|
+
session.projectDir,
|
|
70
|
+
session.threadId,
|
|
71
|
+
);
|
|
72
|
+
if (threadData) {
|
|
73
|
+
setMessages(
|
|
74
|
+
restoreMessagesFromInteractions(threadData.interactions),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
setMessages((prev) => [
|
|
80
|
+
...prev,
|
|
81
|
+
{
|
|
82
|
+
id: msgId(),
|
|
83
|
+
role: "system" as const,
|
|
84
|
+
content:
|
|
85
|
+
"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.",
|
|
86
|
+
timestamp: new Date(),
|
|
87
|
+
},
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
setReady(true);
|
|
91
|
+
})
|
|
92
|
+
.catch((err) => {
|
|
93
|
+
setError(`Failed to start session: ${err}`);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return () => {
|
|
97
|
+
cancelled = true;
|
|
98
|
+
// Fire-and-forget safety net: only triggers when unmount happens via a
|
|
99
|
+
// path that didn't go through performShutdown (which nulls sessionRef
|
|
100
|
+
// first). React doesn't await unmount cleanups, so the goodbye lands
|
|
101
|
+
// before mcpx finishes closing — that's fine for non-Ctrl-C paths.
|
|
102
|
+
if (sessionRef.current) {
|
|
103
|
+
const session = sessionRef.current;
|
|
104
|
+
const threadId = session.threadId;
|
|
105
|
+
abortActiveStream(session);
|
|
106
|
+
void endChatSession(session);
|
|
107
|
+
process.stderr.write(
|
|
108
|
+
`\nThread: ${threadId}\nResume with: ${ansi.success}botholomew chat --thread-id ${threadId}${ansi.reset}\nBye!\n`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
}, [projectDir, resumeThreadId, setMessages, setError]);
|
|
113
|
+
|
|
114
|
+
const performShutdown = useCallback(async () => {
|
|
115
|
+
if (shuttingDownRef.current) {
|
|
116
|
+
// Second Ctrl-C while cleanup is in flight — give the user an escape
|
|
117
|
+
// hatch. 130 = standard SIGINT exit code.
|
|
118
|
+
process.exit(130);
|
|
119
|
+
}
|
|
120
|
+
shuttingDownRef.current = true;
|
|
121
|
+
|
|
122
|
+
const session = sessionRef.current;
|
|
123
|
+
// Null the ref so the useEffect cleanup that runs on Ink unmount becomes
|
|
124
|
+
// a no-op — otherwise it would double-print the goodbye and double-close
|
|
125
|
+
// the mcpx client.
|
|
126
|
+
sessionRef.current = null;
|
|
127
|
+
|
|
128
|
+
if (session) {
|
|
129
|
+
const threadId = session.threadId;
|
|
130
|
+
abortActiveStream(session);
|
|
131
|
+
try {
|
|
132
|
+
await endChatSession(session);
|
|
133
|
+
} catch {
|
|
134
|
+
// Best-effort: the user pressed Ctrl-C, surfacing a stack trace here
|
|
135
|
+
// would just hide the goodbye line.
|
|
136
|
+
}
|
|
137
|
+
process.stderr.write(
|
|
138
|
+
`\nThread: ${threadId}\nResume with: ${ansi.success}botholomew chat --thread-id ${threadId}${ansi.reset}\nBye!\n`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
exit();
|
|
142
|
+
}, [exit]);
|
|
143
|
+
|
|
144
|
+
// Minimum splash screen duration
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
const timer = setTimeout(() => setSplashDone(true), 2000);
|
|
147
|
+
return () => clearTimeout(timer);
|
|
148
|
+
}, []);
|
|
149
|
+
|
|
150
|
+
return { sessionRef, ready, splashDone, performShutdown };
|
|
151
|
+
}
|