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.
Files changed (61) hide show
  1. package/package.json +2 -2
  2. package/src/chat/agent.ts +62 -16
  3. package/src/chat/session.ts +19 -6
  4. package/src/cli.ts +2 -0
  5. package/src/commands/thread.ts +180 -0
  6. package/src/config/schemas.ts +3 -1
  7. package/src/daemon/large-results.ts +15 -3
  8. package/src/daemon/llm.ts +22 -7
  9. package/src/daemon/prompt.ts +1 -9
  10. package/src/daemon/tick.ts +9 -0
  11. package/src/db/threads.ts +17 -0
  12. package/src/init/templates.ts +1 -0
  13. package/src/tools/context/read-large-result.ts +2 -1
  14. package/src/tools/context/search.ts +2 -0
  15. package/src/tools/context/update-beliefs.ts +2 -0
  16. package/src/tools/context/update-goals.ts +2 -0
  17. package/src/tools/dir/create.ts +3 -2
  18. package/src/tools/dir/list.ts +2 -1
  19. package/src/tools/dir/size.ts +2 -1
  20. package/src/tools/dir/tree.ts +3 -2
  21. package/src/tools/file/copy.ts +2 -1
  22. package/src/tools/file/count-lines.ts +2 -1
  23. package/src/tools/file/delete.ts +3 -2
  24. package/src/tools/file/edit.ts +2 -1
  25. package/src/tools/file/exists.ts +2 -1
  26. package/src/tools/file/info.ts +2 -0
  27. package/src/tools/file/move.ts +2 -1
  28. package/src/tools/file/read.ts +2 -1
  29. package/src/tools/file/write.ts +3 -2
  30. package/src/tools/mcp/exec.ts +70 -3
  31. package/src/tools/mcp/info.ts +8 -0
  32. package/src/tools/mcp/list-tools.ts +18 -6
  33. package/src/tools/mcp/search.ts +38 -10
  34. package/src/tools/registry.ts +2 -0
  35. package/src/tools/schedule/create.ts +2 -0
  36. package/src/tools/schedule/list.ts +2 -0
  37. package/src/tools/search/grep.ts +3 -2
  38. package/src/tools/search/semantic.ts +2 -0
  39. package/src/tools/task/complete.ts +2 -0
  40. package/src/tools/task/create.ts +17 -4
  41. package/src/tools/task/fail.ts +2 -0
  42. package/src/tools/task/list.ts +2 -0
  43. package/src/tools/task/update.ts +87 -0
  44. package/src/tools/task/view.ts +3 -1
  45. package/src/tools/task/wait.ts +2 -0
  46. package/src/tools/thread/list.ts +2 -0
  47. package/src/tools/thread/view.ts +3 -1
  48. package/src/tools/tool.ts +5 -3
  49. package/src/tui/App.tsx +209 -82
  50. package/src/tui/components/ContextPanel.tsx +6 -3
  51. package/src/tui/components/HelpPanel.tsx +52 -3
  52. package/src/tui/components/InputBar.tsx +125 -59
  53. package/src/tui/components/MessageList.tsx +40 -75
  54. package/src/tui/components/StatusBar.tsx +9 -8
  55. package/src/tui/components/TabBar.tsx +4 -2
  56. package/src/tui/components/TaskPanel.tsx +409 -0
  57. package/src/tui/components/ThreadPanel.tsx +541 -0
  58. package/src/tui/components/ToolCall.tsx +36 -3
  59. package/src/tui/components/ToolPanel.tsx +40 -31
  60. package/src/tui/theme.ts +20 -3
  61. 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 function ContextPanel({ conn, isActive }: ContextPanelProps) {
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-4{" "}Jump to tab (non-chat tabs)
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 type { ReactNode } from "react";
3
- import { useEffect, useRef, useState } from "react";
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
- // Blink cursor when input is active
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
- useInput(
43
- (input, key) => {
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
- setCursorVisible(true);
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 = value.slice(0, cursorPos);
52
- const after = value.slice(cursorPos);
53
- onChange(`${before}\n${after}`);
54
- setCursorPos(cursorPos + 1);
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
- onSubmit(value);
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 (cursorPos > 0) {
67
- const before = value.slice(0, cursorPos - 1);
68
- const after = value.slice(cursorPos);
69
- onChange(before + after);
70
- setCursorPos(cursorPos - 1);
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
- setCursorPos((c) => Math.max(0, c - 1));
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
- setCursorPos((c) => Math.min(value.length, c + 1));
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 && history.length > 0) {
87
- const nextIndex = historyIndex + 1;
88
- if (nextIndex < history.length) {
89
- if (historyIndex === -1) {
90
- savedInput.current = value;
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 = history[history.length - 1 - nextIndex];
141
+ const entry = hist[hist.length - 1 - nextIndex];
94
142
  if (entry !== undefined) {
95
- onChange(entry);
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 && history.length > 0) {
103
- if (historyIndex > 0) {
104
- const nextIndex = historyIndex - 1;
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 = history[history.length - 1 - nextIndex];
157
+ const entry = hist[hist.length - 1 - nextIndex];
107
158
  if (entry !== undefined) {
108
- onChange(entry);
159
+ valueRef.current = entry;
160
+ cursorPosRef.current = entry.length;
161
+ onChangeRef.current(entry);
109
162
  setCursorPos(entry.length);
110
163
  }
111
- } else if (historyIndex === 0) {
164
+ } else if (hIdx === 0) {
165
+ historyIndexRef.current = -1;
112
166
  setHistoryIndex(-1);
113
- onChange(savedInput.current);
114
- setCursorPos(savedInput.current.length);
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 (historyIndex !== -1) {
183
+ if (hIdx !== -1) {
184
+ historyIndexRef.current = -1;
127
185
  setHistoryIndex(-1);
128
186
  }
129
- const before = value.slice(0, cursorPos);
130
- const after = value.slice(cursorPos);
131
- onChange(before + input + after);
132
- setCursorPos(cursorPos + input.length);
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
- { isActive: !disabled },
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
- <Box flexDirection="column">
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 dimColor> alt+return for newline, return to send</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
- </Box>
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, Text, useInput, useStdout } from "ink";
1
+ import { Box, Static, Text, useStdout } from "ink";
2
2
  import Spinner from "ink-spinner";
3
- import { memo, useEffect, useMemo, useRef, useState } from "react";
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) => padLine(` ${line}`, cols))
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={`${tc.name}-${tc.input.slice(0, 20)}`} tool={tc} />
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
- <Box
163
- flexDirection="column"
164
- flexGrow={1}
165
- overflow="hidden"
166
- justifyContent="flex-end"
167
- >
168
- {visibleMessages.map((msg) => (
169
- <MessageBubble key={msg.id} message={msg} />
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={`active-${tc.name}`} tool={tc} />
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
- {isAtBottom &&
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
- isLoading: boolean;
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
- isLoading,
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
- <Text dimColor> | </Text>
67
- {isLoading ? (
68
- <Text color={theme.accent}>Working...</Text>
69
- ) : (
70
- <Text color="green">Ready</Text>
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: "Help" },
9
+ { id: 4, label: "Tasks" },
10
+ { id: 5, label: "Threads" },
11
+ { id: 6, label: "Help" },
10
12
  ];
11
13
 
12
14
  interface TabBarProps {