botholomew 0.3.1 → 0.3.2
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/package.json +2 -2
- package/src/chat/agent.ts +62 -16
- package/src/chat/session.ts +19 -6
- package/src/cli.ts +2 -0
- package/src/commands/thread.ts +180 -0
- package/src/config/schemas.ts +3 -1
- package/src/daemon/large-results.ts +15 -3
- package/src/daemon/llm.ts +22 -7
- package/src/daemon/prompt.ts +1 -9
- package/src/daemon/tick.ts +9 -0
- package/src/db/threads.ts +17 -0
- package/src/init/templates.ts +1 -0
- package/src/tools/context/read-large-result.ts +2 -1
- package/src/tools/context/search.ts +2 -0
- package/src/tools/context/update-beliefs.ts +2 -0
- package/src/tools/context/update-goals.ts +2 -0
- package/src/tools/dir/create.ts +3 -2
- package/src/tools/dir/list.ts +2 -1
- package/src/tools/dir/size.ts +2 -1
- package/src/tools/dir/tree.ts +3 -2
- package/src/tools/file/copy.ts +2 -1
- package/src/tools/file/count-lines.ts +2 -1
- package/src/tools/file/delete.ts +3 -2
- package/src/tools/file/edit.ts +2 -1
- package/src/tools/file/exists.ts +2 -1
- package/src/tools/file/info.ts +2 -0
- package/src/tools/file/move.ts +2 -1
- package/src/tools/file/read.ts +2 -1
- package/src/tools/file/write.ts +3 -2
- package/src/tools/mcp/exec.ts +70 -3
- package/src/tools/mcp/info.ts +8 -0
- package/src/tools/mcp/list-tools.ts +18 -6
- package/src/tools/mcp/search.ts +38 -10
- package/src/tools/registry.ts +2 -0
- package/src/tools/schedule/create.ts +2 -0
- package/src/tools/schedule/list.ts +2 -0
- package/src/tools/search/grep.ts +3 -2
- package/src/tools/search/semantic.ts +2 -0
- package/src/tools/task/complete.ts +2 -0
- package/src/tools/task/create.ts +17 -4
- package/src/tools/task/fail.ts +2 -0
- package/src/tools/task/list.ts +2 -0
- package/src/tools/task/update.ts +87 -0
- package/src/tools/task/view.ts +3 -1
- package/src/tools/task/wait.ts +2 -0
- package/src/tools/thread/list.ts +2 -0
- package/src/tools/thread/view.ts +3 -1
- package/src/tools/tool.ts +5 -3
- package/src/tui/App.tsx +209 -82
- package/src/tui/components/ContextPanel.tsx +6 -3
- package/src/tui/components/HelpPanel.tsx +52 -3
- package/src/tui/components/InputBar.tsx +125 -59
- package/src/tui/components/MessageList.tsx +40 -75
- package/src/tui/components/StatusBar.tsx +9 -8
- package/src/tui/components/TabBar.tsx +4 -2
- package/src/tui/components/TaskPanel.tsx +409 -0
- package/src/tui/components/ThreadPanel.tsx +541 -0
- package/src/tui/components/ToolCall.tsx +36 -3
- package/src/tui/components/ToolPanel.tsx +40 -31
- package/src/tui/theme.ts +20 -3
- package/src/utils/title.ts +47 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Box, Text, useInput, useStdout } from "ink";
|
|
2
|
-
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { memo, useCallback, useEffect, useMemo, useState } from "react";
|
|
3
3
|
import type { DbConnection } from "../../db/connection.ts";
|
|
4
4
|
import {
|
|
5
5
|
type ContextItem,
|
|
@@ -31,7 +31,10 @@ type Entry = DirEntry | FileEntry;
|
|
|
31
31
|
// Reserve lines for header, search bar, padding, tab bar, status/input bar
|
|
32
32
|
const CHROME_LINES = 8;
|
|
33
33
|
|
|
34
|
-
export
|
|
34
|
+
export const ContextPanel = memo(function ContextPanel({
|
|
35
|
+
conn,
|
|
36
|
+
isActive,
|
|
37
|
+
}: ContextPanelProps) {
|
|
35
38
|
const { stdout } = useStdout();
|
|
36
39
|
const termRows = stdout?.rows ?? 24;
|
|
37
40
|
|
|
@@ -409,4 +412,4 @@ export function ContextPanel({ conn, isActive }: ContextPanelProps) {
|
|
|
409
412
|
)}
|
|
410
413
|
</Box>
|
|
411
414
|
);
|
|
412
|
-
}
|
|
415
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Box, Text } from "ink";
|
|
2
|
+
import { memo } from "react";
|
|
2
3
|
|
|
3
4
|
interface HelpPanelProps {
|
|
4
5
|
projectDir: string;
|
|
@@ -6,7 +7,7 @@ interface HelpPanelProps {
|
|
|
6
7
|
daemonRunning: boolean;
|
|
7
8
|
}
|
|
8
9
|
|
|
9
|
-
export function HelpPanel({
|
|
10
|
+
export const HelpPanel = memo(function HelpPanel({
|
|
10
11
|
projectDir,
|
|
11
12
|
threadId,
|
|
12
13
|
daemonRunning,
|
|
@@ -21,7 +22,7 @@ export function HelpPanel({
|
|
|
21
22
|
{" "}Tab{" "}Cycle between tabs
|
|
22
23
|
</Text>
|
|
23
24
|
<Text>
|
|
24
|
-
{" "}1-
|
|
25
|
+
{" "}1-6{" "}Jump to tab (non-chat tabs)
|
|
25
26
|
</Text>
|
|
26
27
|
<Text>
|
|
27
28
|
{" "}Escape{" "}Return to Chat tab
|
|
@@ -79,6 +80,54 @@ export function HelpPanel({
|
|
|
79
80
|
</Text>
|
|
80
81
|
</Box>
|
|
81
82
|
|
|
83
|
+
<Box marginTop={1} flexDirection="column">
|
|
84
|
+
<Text bold color="cyan">
|
|
85
|
+
Tasks (Tab 4)
|
|
86
|
+
</Text>
|
|
87
|
+
<Text>
|
|
88
|
+
{" "}↑/↓{" "}Navigate task list
|
|
89
|
+
</Text>
|
|
90
|
+
<Text>
|
|
91
|
+
{" "}Shift+↑/↓{" "}Scroll detail pane
|
|
92
|
+
</Text>
|
|
93
|
+
<Text>
|
|
94
|
+
{" "}j / k{" "}Scroll detail pane
|
|
95
|
+
</Text>
|
|
96
|
+
<Text>
|
|
97
|
+
{" "}f{" "}Cycle status filter
|
|
98
|
+
</Text>
|
|
99
|
+
<Text>
|
|
100
|
+
{" "}p{" "}Cycle priority filter
|
|
101
|
+
</Text>
|
|
102
|
+
<Text>
|
|
103
|
+
{" "}r{" "}Refresh tasks
|
|
104
|
+
</Text>
|
|
105
|
+
</Box>
|
|
106
|
+
|
|
107
|
+
<Box marginTop={1} flexDirection="column">
|
|
108
|
+
<Text bold color="cyan">
|
|
109
|
+
Threads (Tab 5)
|
|
110
|
+
</Text>
|
|
111
|
+
<Text>
|
|
112
|
+
{" "}↑/↓{" "}Navigate thread list
|
|
113
|
+
</Text>
|
|
114
|
+
<Text>
|
|
115
|
+
{" "}Shift+↑/↓{" "}Scroll detail pane
|
|
116
|
+
</Text>
|
|
117
|
+
<Text>
|
|
118
|
+
{" "}j / k{" "}Scroll detail pane
|
|
119
|
+
</Text>
|
|
120
|
+
<Text>
|
|
121
|
+
{" "}f{" "}Cycle type filter
|
|
122
|
+
</Text>
|
|
123
|
+
<Text>
|
|
124
|
+
{" "}d{" "}Delete thread (with confirmation)
|
|
125
|
+
</Text>
|
|
126
|
+
<Text>
|
|
127
|
+
{" "}r{" "}Refresh threads
|
|
128
|
+
</Text>
|
|
129
|
+
</Box>
|
|
130
|
+
|
|
82
131
|
<Box marginTop={1} flexDirection="column">
|
|
83
132
|
<Text bold color="cyan">
|
|
84
133
|
Commands
|
|
@@ -114,4 +163,4 @@ export function HelpPanel({
|
|
|
114
163
|
</Box>
|
|
115
164
|
</Box>
|
|
116
165
|
);
|
|
117
|
-
}
|
|
166
|
+
});
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { Box, Text, useInput } from "ink";
|
|
2
|
-
import
|
|
3
|
-
|
|
2
|
+
import {
|
|
3
|
+
memo,
|
|
4
|
+
type ReactNode,
|
|
5
|
+
useCallback,
|
|
6
|
+
useEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
} from "react";
|
|
4
10
|
|
|
5
11
|
interface InputBarProps {
|
|
6
12
|
value: string;
|
|
@@ -11,7 +17,7 @@ interface InputBarProps {
|
|
|
11
17
|
header?: ReactNode;
|
|
12
18
|
}
|
|
13
19
|
|
|
14
|
-
export function InputBar({
|
|
20
|
+
export const InputBar = memo(function InputBar({
|
|
15
21
|
value,
|
|
16
22
|
onChange,
|
|
17
23
|
onSubmit,
|
|
@@ -25,7 +31,24 @@ export function InputBar({
|
|
|
25
31
|
const savedInput = useRef("");
|
|
26
32
|
const lastActivity = useRef(Date.now());
|
|
27
33
|
|
|
28
|
-
//
|
|
34
|
+
// Refs for values read inside the input handler — eagerly updated so rapid
|
|
35
|
+
// keystrokes that arrive before React re-renders always see fresh state.
|
|
36
|
+
const valueRef = useRef(value);
|
|
37
|
+
const cursorPosRef = useRef(cursorPos);
|
|
38
|
+
const historyIndexRef = useRef(historyIndex);
|
|
39
|
+
const onChangeRef = useRef(onChange);
|
|
40
|
+
const onSubmitRef = useRef(onSubmit);
|
|
41
|
+
const historyRef = useRef(history);
|
|
42
|
+
|
|
43
|
+
valueRef.current = value;
|
|
44
|
+
cursorPosRef.current = cursorPos;
|
|
45
|
+
historyIndexRef.current = historyIndex;
|
|
46
|
+
onChangeRef.current = onChange;
|
|
47
|
+
onSubmitRef.current = onSubmit;
|
|
48
|
+
historyRef.current = history;
|
|
49
|
+
|
|
50
|
+
// Blink cursor when input is active — skip ticks while typing so the
|
|
51
|
+
// cursor stays solid and we avoid unnecessary renders during rapid input.
|
|
29
52
|
useEffect(() => {
|
|
30
53
|
if (disabled) {
|
|
31
54
|
setCursorVisible(true);
|
|
@@ -33,85 +56,119 @@ export function InputBar({
|
|
|
33
56
|
}
|
|
34
57
|
const id = setInterval(() => {
|
|
35
58
|
const elapsed = Date.now() - lastActivity.current;
|
|
59
|
+
if (elapsed < 530) return; // still typing — keep cursor solid
|
|
36
60
|
const phase = Math.floor(elapsed / 530) % 2 === 0;
|
|
37
|
-
setCursorVisible(phase);
|
|
61
|
+
setCursorVisible((prev) => (prev === phase ? prev : phase));
|
|
38
62
|
}, 530);
|
|
39
63
|
return () => clearInterval(id);
|
|
40
64
|
}, [disabled]);
|
|
41
65
|
|
|
42
|
-
|
|
43
|
-
|
|
66
|
+
// Stable input handler — the callback reference never changes, which
|
|
67
|
+
// prevents Ink's useInput from removing/re-adding the stdin listener on
|
|
68
|
+
// every render. Without this, rapid typing causes listener churn that
|
|
69
|
+
// overwhelms the event loop and pegs the CPU at 100%.
|
|
70
|
+
const stableHandler = useCallback(
|
|
71
|
+
// biome-ignore lint/suspicious/noExplicitAny: Ink's Key type is not exported
|
|
72
|
+
(input: string, key: any) => {
|
|
44
73
|
if (disabled) return;
|
|
45
74
|
lastActivity.current = Date.now();
|
|
46
|
-
|
|
75
|
+
|
|
76
|
+
const val = valueRef.current;
|
|
77
|
+
const pos = cursorPosRef.current;
|
|
78
|
+
const hIdx = historyIndexRef.current;
|
|
79
|
+
const hist = historyRef.current;
|
|
47
80
|
|
|
48
81
|
// Enter: submit (shift+enter or opt+enter inserts newline)
|
|
49
82
|
if (key.return) {
|
|
50
83
|
if (key.shift || key.meta) {
|
|
51
|
-
const before =
|
|
52
|
-
const after =
|
|
53
|
-
|
|
54
|
-
|
|
84
|
+
const before = val.slice(0, pos);
|
|
85
|
+
const after = val.slice(pos);
|
|
86
|
+
const newVal = `${before}\n${after}`;
|
|
87
|
+
const newPos = pos + 1;
|
|
88
|
+
valueRef.current = newVal;
|
|
89
|
+
cursorPosRef.current = newPos;
|
|
90
|
+
onChangeRef.current(newVal);
|
|
91
|
+
setCursorPos(newPos);
|
|
55
92
|
} else {
|
|
93
|
+
historyIndexRef.current = -1;
|
|
56
94
|
setHistoryIndex(-1);
|
|
57
95
|
savedInput.current = "";
|
|
96
|
+
cursorPosRef.current = 0;
|
|
58
97
|
setCursorPos(0);
|
|
59
|
-
|
|
98
|
+
onSubmitRef.current(val);
|
|
60
99
|
}
|
|
61
100
|
return;
|
|
62
101
|
}
|
|
63
102
|
|
|
64
103
|
// Backspace
|
|
65
104
|
if (key.backspace || key.delete) {
|
|
66
|
-
if (
|
|
67
|
-
const before =
|
|
68
|
-
const after =
|
|
69
|
-
|
|
70
|
-
|
|
105
|
+
if (pos > 0) {
|
|
106
|
+
const before = val.slice(0, pos - 1);
|
|
107
|
+
const after = val.slice(pos);
|
|
108
|
+
const newVal = before + after;
|
|
109
|
+
const newPos = pos - 1;
|
|
110
|
+
valueRef.current = newVal;
|
|
111
|
+
cursorPosRef.current = newPos;
|
|
112
|
+
onChangeRef.current(newVal);
|
|
113
|
+
setCursorPos(newPos);
|
|
71
114
|
}
|
|
72
115
|
return;
|
|
73
116
|
}
|
|
74
117
|
|
|
75
118
|
// Left/right arrow for cursor movement
|
|
76
119
|
if (key.leftArrow) {
|
|
77
|
-
|
|
120
|
+
const newPos = Math.max(0, pos - 1);
|
|
121
|
+
cursorPosRef.current = newPos;
|
|
122
|
+
setCursorPos(newPos);
|
|
78
123
|
return;
|
|
79
124
|
}
|
|
80
125
|
if (key.rightArrow) {
|
|
81
|
-
|
|
126
|
+
const newPos = Math.min(val.length, pos + 1);
|
|
127
|
+
cursorPosRef.current = newPos;
|
|
128
|
+
setCursorPos(newPos);
|
|
82
129
|
return;
|
|
83
130
|
}
|
|
84
131
|
|
|
85
132
|
// History navigation
|
|
86
|
-
if (key.upArrow &&
|
|
87
|
-
const nextIndex =
|
|
88
|
-
if (nextIndex <
|
|
89
|
-
if (
|
|
90
|
-
savedInput.current =
|
|
133
|
+
if (key.upArrow && hist.length > 0) {
|
|
134
|
+
const nextIndex = hIdx + 1;
|
|
135
|
+
if (nextIndex < hist.length) {
|
|
136
|
+
if (hIdx === -1) {
|
|
137
|
+
savedInput.current = val;
|
|
91
138
|
}
|
|
139
|
+
historyIndexRef.current = nextIndex;
|
|
92
140
|
setHistoryIndex(nextIndex);
|
|
93
|
-
const entry =
|
|
141
|
+
const entry = hist[hist.length - 1 - nextIndex];
|
|
94
142
|
if (entry !== undefined) {
|
|
95
|
-
|
|
143
|
+
valueRef.current = entry;
|
|
144
|
+
cursorPosRef.current = entry.length;
|
|
145
|
+
onChangeRef.current(entry);
|
|
96
146
|
setCursorPos(entry.length);
|
|
97
147
|
}
|
|
98
148
|
}
|
|
99
149
|
return;
|
|
100
150
|
}
|
|
101
151
|
|
|
102
|
-
if (key.downArrow &&
|
|
103
|
-
if (
|
|
104
|
-
const nextIndex =
|
|
152
|
+
if (key.downArrow && hist.length > 0) {
|
|
153
|
+
if (hIdx > 0) {
|
|
154
|
+
const nextIndex = hIdx - 1;
|
|
155
|
+
historyIndexRef.current = nextIndex;
|
|
105
156
|
setHistoryIndex(nextIndex);
|
|
106
|
-
const entry =
|
|
157
|
+
const entry = hist[hist.length - 1 - nextIndex];
|
|
107
158
|
if (entry !== undefined) {
|
|
108
|
-
|
|
159
|
+
valueRef.current = entry;
|
|
160
|
+
cursorPosRef.current = entry.length;
|
|
161
|
+
onChangeRef.current(entry);
|
|
109
162
|
setCursorPos(entry.length);
|
|
110
163
|
}
|
|
111
|
-
} else if (
|
|
164
|
+
} else if (hIdx === 0) {
|
|
165
|
+
historyIndexRef.current = -1;
|
|
112
166
|
setHistoryIndex(-1);
|
|
113
|
-
|
|
114
|
-
|
|
167
|
+
const saved = savedInput.current;
|
|
168
|
+
valueRef.current = saved;
|
|
169
|
+
cursorPosRef.current = saved.length;
|
|
170
|
+
onChangeRef.current(saved);
|
|
171
|
+
setCursorPos(saved.length);
|
|
115
172
|
}
|
|
116
173
|
return;
|
|
117
174
|
}
|
|
@@ -123,18 +180,25 @@ export function InputBar({
|
|
|
123
180
|
|
|
124
181
|
// Regular character input
|
|
125
182
|
if (input) {
|
|
126
|
-
if (
|
|
183
|
+
if (hIdx !== -1) {
|
|
184
|
+
historyIndexRef.current = -1;
|
|
127
185
|
setHistoryIndex(-1);
|
|
128
186
|
}
|
|
129
|
-
const before =
|
|
130
|
-
const after =
|
|
131
|
-
|
|
132
|
-
|
|
187
|
+
const before = val.slice(0, pos);
|
|
188
|
+
const after = val.slice(pos);
|
|
189
|
+
const newVal = before + input + after;
|
|
190
|
+
const newPos = pos + input.length;
|
|
191
|
+
valueRef.current = newVal;
|
|
192
|
+
cursorPosRef.current = newPos;
|
|
193
|
+
onChangeRef.current(newVal);
|
|
194
|
+
setCursorPos(newPos);
|
|
133
195
|
}
|
|
134
196
|
},
|
|
135
|
-
|
|
197
|
+
[disabled],
|
|
136
198
|
);
|
|
137
199
|
|
|
200
|
+
useInput(stableHandler, { isActive: !disabled });
|
|
201
|
+
|
|
138
202
|
const isMultiline = value.includes("\n");
|
|
139
203
|
const placeholder = !value && !disabled;
|
|
140
204
|
|
|
@@ -146,25 +210,27 @@ export function InputBar({
|
|
|
146
210
|
paddingX={1}
|
|
147
211
|
>
|
|
148
212
|
{header}
|
|
149
|
-
|
|
150
|
-
<Box>
|
|
151
|
-
<Text color={disabled ? "gray" : "green"}>{"› "}</Text>
|
|
152
|
-
{placeholder ? (
|
|
153
|
-
<Text dimColor>Type a message...</Text>
|
|
154
|
-
) : (
|
|
155
|
-
<Text>
|
|
156
|
-
{value.slice(0, cursorPos)}
|
|
157
|
-
<Text inverse={cursorVisible}>{value[cursorPos] ?? " "}</Text>
|
|
158
|
-
{value.slice(cursorPos + 1)}
|
|
159
|
-
</Text>
|
|
160
|
-
)}
|
|
161
|
-
</Box>
|
|
162
|
-
{isMultiline && (
|
|
213
|
+
{!disabled && (
|
|
214
|
+
<Box flexDirection="column">
|
|
163
215
|
<Box>
|
|
164
|
-
<Text
|
|
216
|
+
<Text color="green">{"› "}</Text>
|
|
217
|
+
{placeholder ? (
|
|
218
|
+
<Text dimColor>Type a message...</Text>
|
|
219
|
+
) : (
|
|
220
|
+
<Text>
|
|
221
|
+
{value.slice(0, cursorPos)}
|
|
222
|
+
<Text inverse={cursorVisible}>{value[cursorPos] ?? " "}</Text>
|
|
223
|
+
{value.slice(cursorPos + 1)}
|
|
224
|
+
</Text>
|
|
225
|
+
)}
|
|
165
226
|
</Box>
|
|
166
|
-
|
|
167
|
-
|
|
227
|
+
{isMultiline && (
|
|
228
|
+
<Box>
|
|
229
|
+
<Text dimColor> alt+return for newline, return to send</Text>
|
|
230
|
+
</Box>
|
|
231
|
+
)}
|
|
232
|
+
</Box>
|
|
233
|
+
)}
|
|
168
234
|
</Box>
|
|
169
235
|
);
|
|
170
|
-
}
|
|
236
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { Box,
|
|
1
|
+
import { Box, Static, Text, useStdout } from "ink";
|
|
2
2
|
import Spinner from "ink-spinner";
|
|
3
|
-
import { memo,
|
|
3
|
+
import { memo, useMemo } from "react";
|
|
4
4
|
import { theme } from "../theme.ts";
|
|
5
5
|
import { ToolCall, type ToolCallData } from "./ToolCall.tsx";
|
|
6
6
|
|
|
@@ -17,7 +17,6 @@ interface MessageListProps {
|
|
|
17
17
|
streamingText: string;
|
|
18
18
|
isLoading: boolean;
|
|
19
19
|
activeToolCalls: ToolCallData[];
|
|
20
|
-
isActive: boolean;
|
|
21
20
|
}
|
|
22
21
|
|
|
23
22
|
function formatTime(date: Date): string {
|
|
@@ -29,6 +28,27 @@ function padLine(text: string, width: number): string {
|
|
|
29
28
|
return text + " ".repeat(pad);
|
|
30
29
|
}
|
|
31
30
|
|
|
31
|
+
function wrapAndPad(text: string, width: number): string {
|
|
32
|
+
const lines: string[] = [];
|
|
33
|
+
for (const line of text.split("\n")) {
|
|
34
|
+
if (line.length <= width) {
|
|
35
|
+
lines.push(padLine(line, width));
|
|
36
|
+
} else {
|
|
37
|
+
let remaining = line;
|
|
38
|
+
while (remaining.length > width) {
|
|
39
|
+
let breakAt = remaining.lastIndexOf(" ", width);
|
|
40
|
+
if (breakAt <= 0) breakAt = width;
|
|
41
|
+
lines.push(padLine(remaining.slice(0, breakAt), width));
|
|
42
|
+
remaining = remaining.slice(breakAt).trimStart();
|
|
43
|
+
}
|
|
44
|
+
if (remaining.length > 0) {
|
|
45
|
+
lines.push(padLine(remaining, width));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return lines.join("\n");
|
|
50
|
+
}
|
|
51
|
+
|
|
32
52
|
function renderMarkdown(text: string): string {
|
|
33
53
|
if (!text) return "";
|
|
34
54
|
return Bun.markdown.ansi(text).trimEnd();
|
|
@@ -52,7 +72,7 @@ const MessageBubble = memo(function MessageBubble({
|
|
|
52
72
|
if (message.role === "user") {
|
|
53
73
|
const paddedContent = message.content
|
|
54
74
|
.split("\n")
|
|
55
|
-
.map((line) =>
|
|
75
|
+
.map((line) => wrapAndPad(` ${line}`, cols))
|
|
56
76
|
.join("\n");
|
|
57
77
|
return (
|
|
58
78
|
<Box flexDirection="column" marginTop={1}>
|
|
@@ -87,7 +107,7 @@ const MessageBubble = memo(function MessageBubble({
|
|
|
87
107
|
</Text>
|
|
88
108
|
<Text dimColor> {time}</Text>
|
|
89
109
|
</Box>
|
|
90
|
-
<Box marginLeft={1} flexDirection="column">
|
|
110
|
+
<Box marginLeft={1} flexDirection="column" width={cols - 1}>
|
|
91
111
|
{message.toolCalls && message.toolCalls.length > 0 && (
|
|
92
112
|
<Box
|
|
93
113
|
flexDirection="column"
|
|
@@ -95,9 +115,10 @@ const MessageBubble = memo(function MessageBubble({
|
|
|
95
115
|
borderColor="gray"
|
|
96
116
|
paddingX={1}
|
|
97
117
|
marginBottom={0}
|
|
118
|
+
width="100%"
|
|
98
119
|
>
|
|
99
120
|
{message.toolCalls.map((tc) => (
|
|
100
|
-
<ToolCall key={
|
|
121
|
+
<ToolCall key={tc.id} tool={tc} />
|
|
101
122
|
))}
|
|
102
123
|
</Box>
|
|
103
124
|
)}
|
|
@@ -107,70 +128,21 @@ const MessageBubble = memo(function MessageBubble({
|
|
|
107
128
|
);
|
|
108
129
|
});
|
|
109
130
|
|
|
110
|
-
/** Maximum messages to render at once (performance guard) */
|
|
111
|
-
const MAX_RENDER = 200;
|
|
112
|
-
|
|
113
131
|
export function MessageList({
|
|
114
132
|
messages,
|
|
115
133
|
streamingText,
|
|
116
134
|
isLoading,
|
|
117
135
|
activeToolCalls,
|
|
118
|
-
isActive,
|
|
119
136
|
}: MessageListProps) {
|
|
120
|
-
// scrollBack: number of messages hidden below the viewport.
|
|
121
|
-
// 0 means "pinned to bottom" (newest messages visible).
|
|
122
|
-
const [scrollBack, setScrollBack] = useState(0);
|
|
123
|
-
const prevLen = useRef(messages.length);
|
|
124
|
-
|
|
125
|
-
// When new messages arrive and we're pinned to bottom, stay there.
|
|
126
|
-
// When new messages arrive and we're scrolled up, hold position by
|
|
127
|
-
// increasing scrollBack so the same messages stay in view.
|
|
128
|
-
useEffect(() => {
|
|
129
|
-
const added = messages.length - prevLen.current;
|
|
130
|
-
if (added > 0 && scrollBack > 0) {
|
|
131
|
-
setScrollBack((sb) => sb + added);
|
|
132
|
-
}
|
|
133
|
-
prevLen.current = messages.length;
|
|
134
|
-
}, [messages.length, scrollBack]);
|
|
135
|
-
|
|
136
|
-
// Scroll input — Shift+↑/↓
|
|
137
|
-
useInput((_input, key) => {
|
|
138
|
-
if (!isActive) return;
|
|
139
|
-
|
|
140
|
-
if (key.shift && key.upArrow) {
|
|
141
|
-
setScrollBack((sb) => Math.min(sb + 3, Math.max(0, messages.length - 1)));
|
|
142
|
-
}
|
|
143
|
-
if (key.shift && key.downArrow) {
|
|
144
|
-
setScrollBack((sb) => Math.max(sb - 3, 0));
|
|
145
|
-
}
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
// Compute the slice of messages to render
|
|
149
|
-
const visibleMessages = useMemo(() => {
|
|
150
|
-
if (scrollBack === 0) {
|
|
151
|
-
// Pinned to bottom — show last MAX_RENDER messages
|
|
152
|
-
return messages.slice(-MAX_RENDER);
|
|
153
|
-
}
|
|
154
|
-
const end = messages.length - scrollBack;
|
|
155
|
-
const start = Math.max(0, end - MAX_RENDER);
|
|
156
|
-
return messages.slice(start, Math.max(0, end));
|
|
157
|
-
}, [messages, scrollBack]);
|
|
158
|
-
|
|
159
|
-
const isAtBottom = scrollBack === 0;
|
|
160
|
-
|
|
161
137
|
return (
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
{
|
|
169
|
-
|
|
170
|
-
))}
|
|
171
|
-
|
|
172
|
-
{/* Active streaming / tool calls — only shown when pinned to bottom */}
|
|
173
|
-
{isAtBottom && (streamingText || activeToolCalls.length > 0) && (
|
|
138
|
+
<>
|
|
139
|
+
{/* Completed messages — rendered once to terminal scrollback */}
|
|
140
|
+
<Static items={messages}>
|
|
141
|
+
{(msg) => <MessageBubble key={msg.id} message={msg} />}
|
|
142
|
+
</Static>
|
|
143
|
+
|
|
144
|
+
{/* Dynamic area — streaming content, managed by Ink */}
|
|
145
|
+
{(streamingText || activeToolCalls.length > 0) && (
|
|
174
146
|
<Box flexDirection="column" marginTop={1}>
|
|
175
147
|
<Box>
|
|
176
148
|
<Text bold color="green">
|
|
@@ -187,7 +159,7 @@ export function MessageList({
|
|
|
187
159
|
paddingX={1}
|
|
188
160
|
>
|
|
189
161
|
{activeToolCalls.map((tc) => (
|
|
190
|
-
<ToolCall key={
|
|
162
|
+
<ToolCall key={tc.id} tool={tc} />
|
|
191
163
|
))}
|
|
192
164
|
</Box>
|
|
193
165
|
)}
|
|
@@ -199,10 +171,10 @@ export function MessageList({
|
|
|
199
171
|
</Box>
|
|
200
172
|
)}
|
|
201
173
|
|
|
202
|
-
{
|
|
203
|
-
isLoading &&
|
|
174
|
+
{isLoading &&
|
|
204
175
|
!streamingText &&
|
|
205
|
-
activeToolCalls.length === 0
|
|
176
|
+
(activeToolCalls.length === 0 ||
|
|
177
|
+
activeToolCalls.every((tc) => !tc.running)) && (
|
|
206
178
|
<Box marginTop={1}>
|
|
207
179
|
<Text color={theme.accent}>
|
|
208
180
|
<Spinner type="dots" />
|
|
@@ -210,13 +182,6 @@ export function MessageList({
|
|
|
210
182
|
<Text dimColor> Thinking...</Text>
|
|
211
183
|
</Box>
|
|
212
184
|
)}
|
|
213
|
-
|
|
214
|
-
{/* Scroll indicator */}
|
|
215
|
-
{!isAtBottom && (
|
|
216
|
-
<Box justifyContent="center">
|
|
217
|
-
<Text dimColor>↓ {scrollBack} more — Shift+↓ to scroll down</Text>
|
|
218
|
-
</Box>
|
|
219
|
-
)}
|
|
220
|
-
</Box>
|
|
185
|
+
</>
|
|
221
186
|
);
|
|
222
187
|
}
|
|
@@ -3,13 +3,12 @@ import { useEffect, useState } from "react";
|
|
|
3
3
|
import type { DbConnection } from "../../db/connection.ts";
|
|
4
4
|
import { listTasks } from "../../db/tasks.ts";
|
|
5
5
|
import { getDaemonStatus } from "../../utils/pid.ts";
|
|
6
|
-
import { theme } from "../theme.ts";
|
|
7
6
|
import { LogoChar } from "./Logo.tsx";
|
|
8
7
|
|
|
9
8
|
interface StatusBarProps {
|
|
10
9
|
projectDir: string;
|
|
11
10
|
conn: DbConnection;
|
|
12
|
-
|
|
11
|
+
chatTitle?: string;
|
|
13
12
|
onDaemonStatusChange?: (running: boolean) => void;
|
|
14
13
|
}
|
|
15
14
|
|
|
@@ -22,7 +21,7 @@ interface Status {
|
|
|
22
21
|
export function StatusBar({
|
|
23
22
|
projectDir,
|
|
24
23
|
conn,
|
|
25
|
-
|
|
24
|
+
chatTitle,
|
|
26
25
|
onDaemonStatusChange,
|
|
27
26
|
}: StatusBarProps) {
|
|
28
27
|
const [status, setStatus] = useState<Status>({
|
|
@@ -63,11 +62,13 @@ export function StatusBar({
|
|
|
63
62
|
<Text bold color="blue">
|
|
64
63
|
Botholomew
|
|
65
64
|
</Text>
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
65
|
+
{chatTitle && (
|
|
66
|
+
<>
|
|
67
|
+
<Text dimColor> | </Text>
|
|
68
|
+
<Text color="cyan" bold>
|
|
69
|
+
{chatTitle.length > 30 ? `${chatTitle.slice(0, 29)}…` : chatTitle}
|
|
70
|
+
</Text>
|
|
71
|
+
</>
|
|
71
72
|
)}
|
|
72
73
|
<Text dimColor> | </Text>
|
|
73
74
|
{status.daemonRunning ? (
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { Box, Text } from "ink";
|
|
2
2
|
|
|
3
|
-
export type TabId = 1 | 2 | 3 | 4;
|
|
3
|
+
export type TabId = 1 | 2 | 3 | 4 | 5 | 6;
|
|
4
4
|
|
|
5
5
|
const TABS: { id: TabId; label: string }[] = [
|
|
6
6
|
{ id: 1, label: "Chat" },
|
|
7
7
|
{ id: 2, label: "Tools" },
|
|
8
8
|
{ id: 3, label: "Context" },
|
|
9
|
-
{ id: 4, label: "
|
|
9
|
+
{ id: 4, label: "Tasks" },
|
|
10
|
+
{ id: 5, label: "Threads" },
|
|
11
|
+
{ id: 6, label: "Help" },
|
|
10
12
|
];
|
|
11
13
|
|
|
12
14
|
interface TabBarProps {
|