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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Box, Text, useInput
|
|
1
|
+
import { Box, Text, useInput } from "ink";
|
|
2
2
|
import { memo, useCallback, useEffect, useMemo, useState } from "react";
|
|
3
3
|
import { getDbPath } from "../../constants.ts";
|
|
4
4
|
import {
|
|
@@ -24,6 +24,8 @@ import { isMarkdownPath, renderMarkdown } from "../markdown.ts";
|
|
|
24
24
|
import { theme } from "../theme.ts";
|
|
25
25
|
import { useDeleteConfirm } from "../useDeleteConfirm.ts";
|
|
26
26
|
import { useLatestRef } from "../useLatestRef.ts";
|
|
27
|
+
import { useTerminalSize } from "../useTerminalSize.ts";
|
|
28
|
+
import { wrapDetailLines } from "../wrapDetail.ts";
|
|
27
29
|
import { DeleteArmedBanner } from "./DeleteArmedBanner.tsx";
|
|
28
30
|
import { Scrollbar } from "./Scrollbar.tsx";
|
|
29
31
|
|
|
@@ -39,8 +41,8 @@ export const ContextPanel = memo(function ContextPanel({
|
|
|
39
41
|
projectDir,
|
|
40
42
|
isActive,
|
|
41
43
|
}: ContextPanelProps) {
|
|
42
|
-
const {
|
|
43
|
-
const
|
|
44
|
+
const { rows: termRows, cols: termCols } = useTerminalSize();
|
|
45
|
+
const detailWidth = Math.max(1, termCols - SIDEBAR_WIDTH - 5);
|
|
44
46
|
|
|
45
47
|
const [currentPath, setCurrentPath] = useState("");
|
|
46
48
|
const [entries, setEntries] = useState<ContextEntry[]>([]);
|
|
@@ -150,8 +152,8 @@ export const ContextPanel = memo(function ContextPanel({
|
|
|
150
152
|
const body = isMarkdownPath(fileContent.path)
|
|
151
153
|
? renderMarkdown(fileContent.content)
|
|
152
154
|
: fileContent.content;
|
|
153
|
-
return body
|
|
154
|
-
}, [fileContent, selectedEntry]);
|
|
155
|
+
return wrapDetailLines(body, detailWidth);
|
|
156
|
+
}, [fileContent, selectedEntry, detailWidth]);
|
|
155
157
|
|
|
156
158
|
const visibleDetailRows = Math.max(1, visibleRows - 2);
|
|
157
159
|
const maxDetailScroll = Math.max(0, detailLines.length - visibleDetailRows);
|
|
@@ -18,6 +18,9 @@ interface MessageListProps {
|
|
|
18
18
|
isLoading: boolean;
|
|
19
19
|
activeToolCalls: ToolCallData[];
|
|
20
20
|
preparingTool: { id: string; name: string } | null;
|
|
21
|
+
/** Timestamp the current streaming bubble started. Stable across token flushes
|
|
22
|
+
* so the displayed time doesn't flicker on every re-render. */
|
|
23
|
+
streamStartedAt: Date | null;
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
function formatTime(date: Date): string {
|
|
@@ -124,11 +127,46 @@ export const MessageBubble = memo(function MessageBubble({
|
|
|
124
127
|
);
|
|
125
128
|
});
|
|
126
129
|
|
|
130
|
+
const ActiveToolsBox = memo(function ActiveToolsBox({
|
|
131
|
+
toolCalls,
|
|
132
|
+
}: {
|
|
133
|
+
toolCalls: ToolCallData[];
|
|
134
|
+
}) {
|
|
135
|
+
if (toolCalls.length === 0) return null;
|
|
136
|
+
return (
|
|
137
|
+
<Box
|
|
138
|
+
flexDirection="column"
|
|
139
|
+
marginLeft={1}
|
|
140
|
+
borderStyle="round"
|
|
141
|
+
borderColor={theme.accentBorder}
|
|
142
|
+
paddingX={1}
|
|
143
|
+
>
|
|
144
|
+
{toolCalls.map((tc) => (
|
|
145
|
+
<ToolCall key={tc.id} tool={tc} />
|
|
146
|
+
))}
|
|
147
|
+
</Box>
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const StreamingMarkdown = memo(function StreamingMarkdown({
|
|
152
|
+
text,
|
|
153
|
+
}: {
|
|
154
|
+
text: string;
|
|
155
|
+
}) {
|
|
156
|
+
const rendered = useMemo(() => renderMarkdown(text), [text]);
|
|
157
|
+
return (
|
|
158
|
+
<Box marginLeft={1}>
|
|
159
|
+
<Text>{rendered}</Text>
|
|
160
|
+
</Box>
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
127
164
|
export function MessageList({
|
|
128
165
|
streamingText,
|
|
129
166
|
isLoading,
|
|
130
167
|
activeToolCalls,
|
|
131
168
|
preparingTool,
|
|
169
|
+
streamStartedAt,
|
|
132
170
|
}: MessageListProps) {
|
|
133
171
|
return (
|
|
134
172
|
<>
|
|
@@ -139,26 +177,10 @@ export function MessageList({
|
|
|
139
177
|
<Text bold color="green">
|
|
140
178
|
Botholomew
|
|
141
179
|
</Text>
|
|
142
|
-
<Text dimColor> {formatTime(new Date())}</Text>
|
|
180
|
+
<Text dimColor> {formatTime(streamStartedAt ?? new Date())}</Text>
|
|
143
181
|
</Box>
|
|
144
|
-
{activeToolCalls
|
|
145
|
-
|
|
146
|
-
flexDirection="column"
|
|
147
|
-
marginLeft={1}
|
|
148
|
-
borderStyle="round"
|
|
149
|
-
borderColor={theme.accentBorder}
|
|
150
|
-
paddingX={1}
|
|
151
|
-
>
|
|
152
|
-
{activeToolCalls.map((tc) => (
|
|
153
|
-
<ToolCall key={tc.id} tool={tc} />
|
|
154
|
-
))}
|
|
155
|
-
</Box>
|
|
156
|
-
)}
|
|
157
|
-
{streamingText && (
|
|
158
|
-
<Box marginLeft={1}>
|
|
159
|
-
<Text>{renderMarkdown(streamingText)}</Text>
|
|
160
|
-
</Box>
|
|
161
|
-
)}
|
|
182
|
+
<ActiveToolsBox toolCalls={activeToolCalls} />
|
|
183
|
+
{streamingText && <StreamingMarkdown text={streamingText} />}
|
|
162
184
|
</Box>
|
|
163
185
|
)}
|
|
164
186
|
|
|
@@ -173,14 +195,15 @@ export function MessageList({
|
|
|
173
195
|
|
|
174
196
|
{isLoading &&
|
|
175
197
|
!preparingTool &&
|
|
176
|
-
!streamingText &&
|
|
177
198
|
(activeToolCalls.length === 0 ||
|
|
178
199
|
activeToolCalls.every((tc) => !tc.running)) && (
|
|
179
200
|
<Box marginTop={1}>
|
|
180
201
|
<Text color={theme.accent}>
|
|
181
202
|
<Spinner type="dots" />
|
|
182
203
|
</Text>
|
|
183
|
-
<Text dimColor>
|
|
204
|
+
<Text dimColor>
|
|
205
|
+
{streamingText ? " Streaming..." : " Thinking..."}
|
|
206
|
+
</Text>
|
|
184
207
|
</Box>
|
|
185
208
|
)}
|
|
186
209
|
</>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Box, Text, useInput
|
|
1
|
+
import { Box, Text, useInput } from "ink";
|
|
2
2
|
import { memo, useCallback, useEffect, useMemo, useState } from "react";
|
|
3
3
|
import type { Schedule } from "../../schedules/schema.ts";
|
|
4
4
|
import {
|
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
import { ansi, theme } from "../theme.ts";
|
|
15
15
|
import { useDeleteConfirm } from "../useDeleteConfirm.ts";
|
|
16
16
|
import { useLatestRef } from "../useLatestRef.ts";
|
|
17
|
+
import { useTerminalSize } from "../useTerminalSize.ts";
|
|
18
|
+
import { wrapDetailLines } from "../wrapDetail.ts";
|
|
17
19
|
import { DeleteArmedBanner } from "./DeleteArmedBanner.tsx";
|
|
18
20
|
import { Scrollbar } from "./Scrollbar.tsx";
|
|
19
21
|
|
|
@@ -88,8 +90,8 @@ export const SchedulePanel = memo(function SchedulePanel({
|
|
|
88
90
|
projectDir,
|
|
89
91
|
isActive,
|
|
90
92
|
}: SchedulePanelProps) {
|
|
91
|
-
const {
|
|
92
|
-
const
|
|
93
|
+
const { rows: termRows, cols: termCols } = useTerminalSize();
|
|
94
|
+
const detailWidth = Math.max(1, termCols - SIDEBAR_WIDTH - 5);
|
|
93
95
|
const [schedules, setSchedules] = useState<Schedule[]>([]);
|
|
94
96
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
95
97
|
const [detailScroll, setDetailScroll] = useState(0);
|
|
@@ -133,8 +135,8 @@ export const SchedulePanel = memo(function SchedulePanel({
|
|
|
133
135
|
}, [selectedSchedule]);
|
|
134
136
|
|
|
135
137
|
const detailLines = useMemo(
|
|
136
|
-
() => renderedDetail
|
|
137
|
-
[renderedDetail],
|
|
138
|
+
() => wrapDetailLines(renderedDetail, detailWidth),
|
|
139
|
+
[renderedDetail, detailWidth],
|
|
138
140
|
);
|
|
139
141
|
|
|
140
142
|
const visibleRows = Math.max(1, termRows - 6);
|
|
@@ -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
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Box, Text, useInput
|
|
1
|
+
import { Box, Text, useInput } from "ink";
|
|
2
2
|
import { memo, useCallback, useEffect, useMemo, useState } from "react";
|
|
3
3
|
import {
|
|
4
4
|
TASK_PRIORITIES,
|
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
import { ansi, theme } from "../theme.ts";
|
|
15
15
|
import { useDeleteConfirm } from "../useDeleteConfirm.ts";
|
|
16
16
|
import { useLatestRef } from "../useLatestRef.ts";
|
|
17
|
+
import { useTerminalSize } from "../useTerminalSize.ts";
|
|
18
|
+
import { wrapDetailLines } from "../wrapDetail.ts";
|
|
17
19
|
import { DeleteArmedBanner } from "./DeleteArmedBanner.tsx";
|
|
18
20
|
import { Scrollbar } from "./Scrollbar.tsx";
|
|
19
21
|
|
|
@@ -121,8 +123,8 @@ export const TaskPanel = memo(function TaskPanel({
|
|
|
121
123
|
projectDir,
|
|
122
124
|
isActive,
|
|
123
125
|
}: TaskPanelProps) {
|
|
124
|
-
const {
|
|
125
|
-
const
|
|
126
|
+
const { rows: termRows, cols: termCols } = useTerminalSize();
|
|
127
|
+
const detailWidth = Math.max(1, termCols - SIDEBAR_WIDTH - 5);
|
|
126
128
|
const [tasks, setTasks] = useState<Task[]>([]);
|
|
127
129
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
128
130
|
const [detailScroll, setDetailScroll] = useState(0);
|
|
@@ -173,8 +175,8 @@ export const TaskPanel = memo(function TaskPanel({
|
|
|
173
175
|
}, [selectedTask]);
|
|
174
176
|
|
|
175
177
|
const detailLines = useMemo(
|
|
176
|
-
() => renderedDetail
|
|
177
|
-
[renderedDetail],
|
|
178
|
+
() => wrapDetailLines(renderedDetail, detailWidth),
|
|
179
|
+
[renderedDetail, detailWidth],
|
|
178
180
|
);
|
|
179
181
|
|
|
180
182
|
const visibleRows = Math.max(1, termRows - 6);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Box, Text, useInput
|
|
1
|
+
import { Box, Text, useInput } from "ink";
|
|
2
2
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
3
|
import {
|
|
4
4
|
deleteThread,
|
|
@@ -17,6 +17,8 @@ import {
|
|
|
17
17
|
import { ansi, theme } from "../theme.ts";
|
|
18
18
|
import { useDeleteConfirm } from "../useDeleteConfirm.ts";
|
|
19
19
|
import { useLatestRef } from "../useLatestRef.ts";
|
|
20
|
+
import { useTerminalSize } from "../useTerminalSize.ts";
|
|
21
|
+
import { wrapDetailLines } from "../wrapDetail.ts";
|
|
20
22
|
import { DeleteArmedBanner } from "./DeleteArmedBanner.tsx";
|
|
21
23
|
import { Scrollbar } from "./Scrollbar.tsx";
|
|
22
24
|
|
|
@@ -162,8 +164,8 @@ export const ThreadPanel = memo(function ThreadPanel({
|
|
|
162
164
|
activeThreadId,
|
|
163
165
|
isActive,
|
|
164
166
|
}: ThreadPanelProps) {
|
|
165
|
-
const {
|
|
166
|
-
const
|
|
167
|
+
const { rows: termRows, cols: termCols } = useTerminalSize();
|
|
168
|
+
const detailWidth = Math.max(1, termCols - SIDEBAR_WIDTH - 5);
|
|
167
169
|
const [threads, setThreads] = useState<Thread[]>([]);
|
|
168
170
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
169
171
|
const [detailScroll, setDetailScroll] = useState(0);
|
|
@@ -299,8 +301,8 @@ export const ThreadPanel = memo(function ThreadPanel({
|
|
|
299
301
|
}, [selectedDetail, activeThreadId]);
|
|
300
302
|
|
|
301
303
|
const detailLines = useMemo(
|
|
302
|
-
() => renderedDetail
|
|
303
|
-
[renderedDetail],
|
|
304
|
+
() => wrapDetailLines(renderedDetail, detailWidth),
|
|
305
|
+
[renderedDetail, detailWidth],
|
|
304
306
|
);
|
|
305
307
|
|
|
306
308
|
const visibleRows = Math.max(1, termRows - 6);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Box, Text } from "ink";
|
|
2
|
+
import { memo } from "react";
|
|
2
3
|
import { theme } from "../theme.ts";
|
|
3
4
|
import { parseSleepInput, SleepProgress } from "./SleepProgress.tsx";
|
|
4
5
|
|
|
@@ -48,7 +49,7 @@ interface ToolCallProps {
|
|
|
48
49
|
tool: ToolCallData;
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
export function ToolCall({ tool }: ToolCallProps) {
|
|
52
|
+
export const ToolCall = memo(function ToolCall({ tool }: ToolCallProps) {
|
|
52
53
|
const { displayName, displayInput } = resolveToolDisplay(
|
|
53
54
|
tool.name,
|
|
54
55
|
tool.input,
|
|
@@ -122,4 +123,4 @@ export function ToolCall({ tool }: ToolCallProps) {
|
|
|
122
123
|
))}
|
|
123
124
|
</Box>
|
|
124
125
|
);
|
|
125
|
-
}
|
|
126
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Box, Text, useInput
|
|
1
|
+
import { Box, Text, useInput } from "ink";
|
|
2
2
|
import { memo, useEffect, useMemo, useState } from "react";
|
|
3
3
|
import {
|
|
4
4
|
detailPaneBorderProps,
|
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
} from "../listDetailKeys.ts";
|
|
8
8
|
import { ansi, theme } from "../theme.ts";
|
|
9
9
|
import { useLatestRef } from "../useLatestRef.ts";
|
|
10
|
+
import { useTerminalSize } from "../useTerminalSize.ts";
|
|
11
|
+
import { wrapDetailLines } from "../wrapDetail.ts";
|
|
10
12
|
import { Scrollbar } from "./Scrollbar.tsx";
|
|
11
13
|
import { resolveToolDisplay, type ToolCallData } from "./ToolCall.tsx";
|
|
12
14
|
|
|
@@ -109,8 +111,10 @@ export const ToolPanel = memo(function ToolPanel({
|
|
|
109
111
|
toolCalls,
|
|
110
112
|
isActive,
|
|
111
113
|
}: ToolPanelProps) {
|
|
112
|
-
const {
|
|
113
|
-
|
|
114
|
+
const { rows: termRows, cols: termCols } = useTerminalSize();
|
|
115
|
+
// Detail-pane content width: total cols minus sidebar, minus 4 chars of
|
|
116
|
+
// border+padding on the right pane, minus 1 col for the scrollbar.
|
|
117
|
+
const detailWidth = Math.max(1, termCols - SIDEBAR_WIDTH - 5);
|
|
114
118
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
115
119
|
const [detailScroll, setDetailScroll] = useState(0);
|
|
116
120
|
const [focus, setFocus] = useState<FocusState>("list");
|
|
@@ -133,8 +137,8 @@ export const ToolPanel = memo(function ToolPanel({
|
|
|
133
137
|
}, [selectedTool]);
|
|
134
138
|
|
|
135
139
|
const detailLines = useMemo(
|
|
136
|
-
() => renderedDetail
|
|
137
|
-
[renderedDetail],
|
|
140
|
+
() => wrapDetailLines(renderedDetail, detailWidth),
|
|
141
|
+
[renderedDetail, detailWidth],
|
|
138
142
|
);
|
|
139
143
|
|
|
140
144
|
// Visible area for sidebar and detail
|
|
@@ -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
|
+
}
|