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,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
|
+
}
|
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
|
+
}
|