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.
@@ -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
+ }
@@ -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
+ }