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
@@ -7,6 +7,7 @@ const inputSchema = z.object({
7
7
 
8
8
  const outputSchema = z.object({
9
9
  message: z.string(),
10
+ is_error: z.boolean(),
10
11
  });
11
12
 
12
13
  export const failTaskTool = {
@@ -18,5 +19,6 @@ export const failTaskTool = {
18
19
  outputSchema,
19
20
  execute: async (input) => ({
20
21
  message: `Task failed: ${input.reason}`,
22
+ is_error: false,
21
23
  }),
22
24
  } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -20,6 +20,7 @@ const outputSchema = z.object({
20
20
  }),
21
21
  ),
22
22
  count: z.number(),
23
+ is_error: z.boolean(),
23
24
  });
24
25
 
25
26
  export const listTasksTool = {
@@ -44,6 +45,7 @@ export const listTasksTool = {
44
45
  created_at: t.created_at.toISOString(),
45
46
  })),
46
47
  count: tasks.length,
48
+ is_error: false,
47
49
  };
48
50
  },
49
51
  } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -0,0 +1,87 @@
1
+ import { z } from "zod";
2
+ import { getTask, TASK_PRIORITIES, updateTask } from "../../db/tasks.ts";
3
+ import { logger } from "../../utils/logger.ts";
4
+ import type { ToolDefinition } from "../tool.ts";
5
+
6
+ const inputSchema = z.object({
7
+ id: z.string().describe("ID of the task to update"),
8
+ name: z.string().optional().describe("Updated task name"),
9
+ description: z.string().optional().describe("Updated task description"),
10
+ priority: z.enum(TASK_PRIORITIES).optional().describe("Updated priority"),
11
+ blocked_by: z
12
+ .array(z.string())
13
+ .optional()
14
+ .describe("Replacement list of task IDs that must complete first"),
15
+ });
16
+
17
+ const outputSchema = z.object({
18
+ task: z
19
+ .object({
20
+ id: z.string(),
21
+ name: z.string(),
22
+ description: z.string(),
23
+ status: z.string(),
24
+ priority: z.string(),
25
+ blocked_by: z.array(z.string()),
26
+ updated_at: z.string(),
27
+ })
28
+ .nullable(),
29
+ message: z.string(),
30
+ is_error: z.boolean(),
31
+ });
32
+
33
+ export const updateTaskTool = {
34
+ name: "update_task",
35
+ description:
36
+ "Update a pending task's name, description, priority, or dependencies. Only pending tasks can be updated.",
37
+ group: "task",
38
+ inputSchema,
39
+ outputSchema,
40
+ execute: async (input, ctx) => {
41
+ const existing = await getTask(ctx.conn, input.id);
42
+ if (!existing) {
43
+ return {
44
+ task: null,
45
+ message: `Task ${input.id} not found`,
46
+ is_error: true,
47
+ };
48
+ }
49
+ if (existing.status !== "pending") {
50
+ return {
51
+ task: null,
52
+ message: `Cannot update task ${input.id}: only pending tasks can be updated (current status: ${existing.status})`,
53
+ is_error: true,
54
+ };
55
+ }
56
+
57
+ const updated = await updateTask(ctx.conn, input.id, {
58
+ name: input.name,
59
+ description: input.description,
60
+ priority: input.priority,
61
+ blocked_by: input.blocked_by,
62
+ });
63
+
64
+ if (!updated) {
65
+ return {
66
+ task: null,
67
+ message: `Failed to update task ${input.id}`,
68
+ is_error: true,
69
+ };
70
+ }
71
+
72
+ logger.info(`Updated task: ${updated.name} (${updated.id})`);
73
+ return {
74
+ task: {
75
+ id: updated.id,
76
+ name: updated.name,
77
+ description: updated.description,
78
+ status: updated.status,
79
+ priority: updated.priority,
80
+ blocked_by: updated.blocked_by,
81
+ updated_at: updated.updated_at.toISOString(),
82
+ },
83
+ message: `Updated task "${updated.name}"`,
84
+ is_error: false,
85
+ };
86
+ },
87
+ } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -22,6 +22,7 @@ const outputSchema = z.object({
22
22
  updated_at: z.string(),
23
23
  })
24
24
  .nullable(),
25
+ is_error: z.boolean(),
25
26
  });
26
27
 
27
28
  export const viewTaskTool = {
@@ -32,7 +33,7 @@ export const viewTaskTool = {
32
33
  outputSchema,
33
34
  execute: async (input, ctx) => {
34
35
  const task = await getTask(ctx.conn, input.id);
35
- if (!task) return { task: null };
36
+ if (!task) return { task: null, is_error: true };
36
37
  return {
37
38
  task: {
38
39
  id: task.id,
@@ -47,6 +48,7 @@ export const viewTaskTool = {
47
48
  created_at: task.created_at.toISOString(),
48
49
  updated_at: task.updated_at.toISOString(),
49
50
  },
51
+ is_error: false,
50
52
  };
51
53
  },
52
54
  } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -7,6 +7,7 @@ const inputSchema = z.object({
7
7
 
8
8
  const outputSchema = z.object({
9
9
  message: z.string(),
10
+ is_error: z.boolean(),
10
11
  });
11
12
 
12
13
  export const waitTaskTool = {
@@ -19,5 +20,6 @@ export const waitTaskTool = {
19
20
  outputSchema,
20
21
  execute: async (input) => ({
21
22
  message: `Task waiting: ${input.reason}`,
23
+ is_error: false,
22
24
  }),
23
25
  } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -22,6 +22,7 @@ const outputSchema = z.object({
22
22
  }),
23
23
  ),
24
24
  count: z.number(),
25
+ is_error: z.boolean(),
25
26
  });
26
27
 
27
28
  export const listThreadsTool = {
@@ -45,6 +46,7 @@ export const listThreadsTool = {
45
46
  ended_at: t.ended_at?.toISOString() ?? null,
46
47
  })),
47
48
  count: threads.length,
49
+ is_error: false,
48
50
  };
49
51
  },
50
52
  } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -28,6 +28,7 @@ const outputSchema = z.object({
28
28
  created_at: z.string(),
29
29
  }),
30
30
  ),
31
+ is_error: z.boolean(),
31
32
  });
32
33
 
33
34
  export const viewThreadTool = {
@@ -39,7 +40,7 @@ export const viewThreadTool = {
39
40
  outputSchema,
40
41
  execute: async (input, ctx) => {
41
42
  const result = await getThread(ctx.conn, input.id);
42
- if (!result) return { thread: null, interactions: [] };
43
+ if (!result) return { thread: null, interactions: [], is_error: false };
43
44
  return {
44
45
  thread: {
45
46
  id: result.thread.id,
@@ -58,6 +59,7 @@ export const viewThreadTool = {
58
59
  tool_name: i.tool_name,
59
60
  created_at: i.created_at.toISOString(),
60
61
  })),
62
+ is_error: false,
61
63
  };
62
64
  },
63
65
  } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
package/src/tools/tool.ts CHANGED
@@ -13,9 +13,11 @@ export interface ToolContext {
13
13
  embedFn?: EmbedFn;
14
14
  }
15
15
 
16
+ type ToolOutputBase = { is_error: z.ZodBoolean };
17
+
16
18
  export interface ToolDefinition<
17
19
  TInput extends z.ZodObject<z.ZodRawShape>,
18
- TOutput extends z.ZodType,
20
+ TOutput extends z.ZodObject<z.ZodRawShape & ToolOutputBase>,
19
21
  > {
20
22
  name: string;
21
23
  description: string;
@@ -33,14 +35,14 @@ export interface ToolDefinition<
33
35
 
34
36
  export type AnyToolDefinition = ToolDefinition<
35
37
  z.ZodObject<z.ZodRawShape>,
36
- z.ZodType
38
+ z.ZodObject<z.ZodRawShape & ToolOutputBase>
37
39
  >;
38
40
 
39
41
  const tools = new Map<string, AnyToolDefinition>();
40
42
 
41
43
  export function registerTool<
42
44
  TInput extends z.ZodObject<z.ZodRawShape>,
43
- TOutput extends z.ZodType,
45
+ TOutput extends z.ZodObject<z.ZodRawShape & ToolOutputBase>,
44
46
  >(tool: ToolDefinition<TInput, TOutput>): void {
45
47
  tools.set(tool.name, tool as unknown as AnyToolDefinition);
46
48
  }
package/src/tui/App.tsx CHANGED
@@ -6,10 +6,10 @@ import {
6
6
  sendMessage,
7
7
  startChatSession,
8
8
  } from "../chat/session.ts";
9
+ import { MAX_INLINE_CHARS, PAGE_SIZE_CHARS } from "../daemon/large-results.ts";
9
10
  import type { Interaction } from "../db/threads.ts";
10
11
  import { getThread } from "../db/threads.ts";
11
12
  import { ContextPanel } from "./components/ContextPanel.tsx";
12
- import { Divider } from "./components/Divider.tsx";
13
13
  import { HelpPanel } from "./components/HelpPanel.tsx";
14
14
  import { InputBar } from "./components/InputBar.tsx";
15
15
  import { AnimatedLogo } from "./components/Logo.tsx";
@@ -17,8 +17,11 @@ import { type ChatMessage, MessageList } from "./components/MessageList.tsx";
17
17
  import { QueuePanel } from "./components/QueuePanel.tsx";
18
18
  import { StatusBar } from "./components/StatusBar.tsx";
19
19
  import { TabBar, type TabId } from "./components/TabBar.tsx";
20
+ import { TaskPanel } from "./components/TaskPanel.tsx";
21
+ import { ThreadPanel } from "./components/ThreadPanel.tsx";
20
22
  import type { ToolCallData } from "./components/ToolCall.tsx";
21
23
  import { ToolPanel } from "./components/ToolPanel.tsx";
24
+ import { ansi } from "./theme.ts";
22
25
 
23
26
  interface AppProps {
24
27
  projectDir: string;
@@ -31,15 +34,28 @@ function msgId(): string {
31
34
  return `msg-${++nextMsgId}`;
32
35
  }
33
36
 
37
+ function detectToolError(output: string | undefined): boolean {
38
+ if (!output) return false;
39
+ try {
40
+ const parsed = JSON.parse(output);
41
+ if (typeof parsed === "object" && parsed?.is_error === true) return true;
42
+ } catch {
43
+ /* not JSON */
44
+ }
45
+ return false;
46
+ }
47
+
34
48
  function restoreMessagesFromInteractions(
35
49
  interactions: Interaction[],
36
50
  ): ChatMessage[] {
37
51
  const result: ChatMessage[] = [];
38
52
  let pendingTools: ToolCallData[] = [];
39
53
 
54
+ let restoredIdx = 0;
40
55
  for (const ix of interactions) {
41
56
  if (ix.kind === "tool_use") {
42
57
  pendingTools.push({
58
+ id: `restored-${restoredIdx++}`,
43
59
  name: ix.tool_name ?? "unknown",
44
60
  input: ix.tool_input ?? "{}",
45
61
  running: false,
@@ -49,6 +65,14 @@ function restoreMessagesFromInteractions(
49
65
  const tc = pendingTools.find((t) => t.name === ix.tool_name && !t.output);
50
66
  if (tc) {
51
67
  tc.output = ix.content;
68
+ tc.isError = detectToolError(ix.content);
69
+ if (ix.content.length > MAX_INLINE_CHARS) {
70
+ tc.largeResult = {
71
+ id: "(restored)",
72
+ chars: ix.content.length,
73
+ pages: Math.ceil(ix.content.length / PAGE_SIZE_CHARS),
74
+ };
75
+ }
52
76
  }
53
77
  } else if (ix.kind === "message" && ix.role === "user") {
54
78
  result.push({
@@ -95,11 +119,13 @@ export function App({
95
119
  const [streamingText, setStreamingText] = useState("");
96
120
  const [activeToolCalls, setActiveToolCalls] = useState<ToolCallData[]>([]);
97
121
  const [ready, setReady] = useState(false);
98
- const [splashDone, setSplashDone] = useState(false);
122
+ const skipSplash = !!(resumeThreadId || initialPrompt);
123
+ const [splashDone, setSplashDone] = useState(skipSplash);
99
124
  const [error, setError] = useState<string | null>(null);
100
125
  const sessionRef = useRef<ChatSession | null>(null);
101
126
  const [activeTab, setActiveTab] = useState<TabId>(1);
102
127
  const [daemonRunning, setDaemonRunning] = useState(false);
128
+ const [chatTitle, setChatTitle] = useState<string | undefined>(undefined);
103
129
  const queueRef = useRef<string[]>([]);
104
130
  const processingRef = useRef(false);
105
131
  const [queuedMessages, setQueuedMessages] = useState<string[]>([]);
@@ -157,7 +183,7 @@ export function App({
157
183
  const threadId = sessionRef.current.threadId;
158
184
  endChatSession(sessionRef.current);
159
185
  process.stderr.write(
160
- `\nThread: ${threadId}\nResume with: \x1b[32mbotholomew chat --thread-id ${threadId}\x1b[0m\n`,
186
+ `\nThread: ${threadId}\nResume with: ${ansi.success}botholomew chat --thread-id ${threadId}${ansi.reset}\n`,
161
187
  );
162
188
  }
163
189
  };
@@ -169,63 +195,78 @@ export function App({
169
195
  return () => clearTimeout(timer);
170
196
  }, []);
171
197
 
172
- // Tab switching via useInput at the App level
173
- // On the Chat tab (1), only Tab key switches — number keys go to InputBar.
174
- // On other tabs, both Tab and number keys switch tabs, Escape returns to Chat.
175
- useInput((input, key) => {
176
- // Ctrl+C exits
177
- if (input === "c" && key.ctrl) {
178
- exit();
179
- return;
180
- }
181
-
182
- // Tab key cycles tabs always active (InputBar ignores tab)
183
- if (key.tab && !key.shift) {
184
- setActiveTab((t) => ((t % 4) + 1) as TabId);
185
- return;
186
- }
187
-
188
- // Queue manipulation keybindings (only when queue has items on Chat tab)
189
- if (activeTab === 1 && queuedMessages.length > 0 && key.ctrl) {
190
- if (input === "j") {
191
- setSelectedQueueIndex((i) =>
192
- Math.min(i + 1, queuedMessages.length - 1),
193
- );
194
- return;
195
- }
196
- if (input === "k") {
197
- setSelectedQueueIndex((i) => Math.max(i - 1, 0));
198
+ // Stable ref for App-level input handler same pattern as InputBar to
199
+ // prevent Ink's useInput from re-registering stdin listeners on every render.
200
+ const activeTabRef = useRef(activeTab);
201
+ const queuedMessagesRef = useRef(queuedMessages);
202
+ const selectedQueueIndexRef = useRef(selectedQueueIndex);
203
+ activeTabRef.current = activeTab;
204
+ queuedMessagesRef.current = queuedMessages;
205
+ selectedQueueIndexRef.current = selectedQueueIndex;
206
+
207
+ const stableAppHandler = useCallback(
208
+ // biome-ignore lint/suspicious/noExplicitAny: Ink's Key type is not exported
209
+ (input: string, key: any) => {
210
+ // Ctrl+C exits
211
+ if (input === "c" && key.ctrl) {
212
+ exit();
198
213
  return;
199
214
  }
200
- if (input === "x") {
201
- queueRef.current.splice(selectedQueueIndex, 1);
202
- syncQueue();
215
+
216
+ // Tab key cycles tabs — always active (InputBar ignores tab)
217
+ if (key.tab && !key.shift) {
218
+ setActiveTab((t) => ((t % 6) + 1) as TabId);
203
219
  return;
204
220
  }
205
- if (input === "e") {
206
- const [msg] = queueRef.current.splice(selectedQueueIndex, 1);
207
- syncQueue();
208
- if (msg) {
209
- setInputValue(msg);
221
+
222
+ // Queue manipulation keybindings (only when queue has items on Chat tab)
223
+ const tab = activeTabRef.current;
224
+ const queue = queuedMessagesRef.current;
225
+ if (tab === 1 && queue.length > 0 && key.ctrl) {
226
+ if (input === "j") {
227
+ setSelectedQueueIndex((i) => Math.min(i + 1, queue.length - 1));
228
+ return;
229
+ }
230
+ if (input === "k") {
231
+ setSelectedQueueIndex((i) => Math.max(i - 1, 0));
232
+ return;
233
+ }
234
+ if (input === "x") {
235
+ queueRef.current.splice(selectedQueueIndexRef.current, 1);
236
+ syncQueue();
237
+ return;
238
+ }
239
+ if (input === "e") {
240
+ const [msg] = queueRef.current.splice(
241
+ selectedQueueIndexRef.current,
242
+ 1,
243
+ );
244
+ syncQueue();
245
+ if (msg) {
246
+ setInputValue(msg);
247
+ }
248
+ return;
210
249
  }
211
- return;
212
250
  }
213
- }
214
251
 
215
- if (activeTab !== 1) {
216
- // Number keys jump to tab on non-chat tabs
217
- const num = Number.parseInt(input, 10);
218
- if (num >= 1 && num <= 4) {
219
- setActiveTab(num as TabId);
220
- return;
221
- }
222
- // Escape returns to chat
223
- if (key.escape) {
224
- setActiveTab(1);
225
- return;
252
+ if (tab !== 1) {
253
+ // Number keys jump to tab on non-chat tabs
254
+ const num = Number.parseInt(input, 10);
255
+ if (num >= 1 && num <= 6) {
256
+ setActiveTab(num as TabId);
257
+ return;
258
+ }
259
+ // Escape returns to chat
260
+ if (key.escape) {
261
+ setActiveTab(1);
262
+ return;
263
+ }
226
264
  }
227
- }
228
- });
265
+ },
266
+ [exit, syncQueue],
267
+ );
268
+
269
+ useInput(stableAppHandler);
229
270
 
230
271
  const processQueue = useCallback(async () => {
231
272
  if (processingRef.current || !sessionRef.current) return;
@@ -268,17 +309,23 @@ export function App({
268
309
  }
269
310
  };
270
311
 
312
+ let lastStreamFlush = 0;
271
313
  try {
272
314
  await sendMessage(sessionRef.current, trimmed, {
273
315
  onToken: (token) => {
274
316
  currentText += token;
275
- setStreamingText(currentText);
317
+ const now = Date.now();
318
+ if (now - lastStreamFlush >= 50) {
319
+ setStreamingText(currentText);
320
+ lastStreamFlush = now;
321
+ }
276
322
  },
277
- onToolStart: (name, input) => {
323
+ onToolStart: (id, name, input) => {
278
324
  if (currentText) {
279
325
  finalizeSegment();
280
326
  }
281
327
  const tc: ToolCallData = {
328
+ id,
282
329
  name,
283
330
  input,
284
331
  running: true,
@@ -287,13 +334,15 @@ export function App({
287
334
  pendingToolCalls.push(tc);
288
335
  setActiveToolCalls([...pendingToolCalls]);
289
336
  },
290
- onToolEnd: (name, output) => {
291
- const tc = pendingToolCalls.find(
292
- (t) => t.name === name && t.running,
293
- );
337
+ onToolEnd: (id, _name, output, isError, meta) => {
338
+ const tc = pendingToolCalls.find((t) => t.id === id);
294
339
  if (tc) {
295
340
  tc.running = false;
296
341
  tc.output = output;
342
+ tc.isError = isError;
343
+ if (meta?.largeResult) {
344
+ tc.largeResult = meta.largeResult;
345
+ }
297
346
  }
298
347
  setActiveToolCalls([...pendingToolCalls]);
299
348
  },
@@ -330,6 +379,28 @@ export function App({
330
379
  }
331
380
  }, [ready, initialPrompt, processQueue, syncQueue]);
332
381
 
382
+ // Poll for chat thread title updates
383
+ useEffect(() => {
384
+ if (!ready || !sessionRef.current) return;
385
+ let mounted = true;
386
+
387
+ const refreshTitle = async () => {
388
+ const session = sessionRef.current;
389
+ if (!session) return;
390
+ const result = await getThread(session.conn, session.threadId);
391
+ if (mounted && result?.thread.title) {
392
+ setChatTitle(result.thread.title);
393
+ }
394
+ };
395
+
396
+ refreshTitle();
397
+ const interval = setInterval(refreshTitle, 5000);
398
+ return () => {
399
+ mounted = false;
400
+ clearInterval(interval);
401
+ };
402
+ }, [ready]);
403
+
333
404
  const handleSubmit = useCallback(
334
405
  async (text: string) => {
335
406
  const trimmed = text.trim();
@@ -344,14 +415,13 @@ export function App({
344
415
  content: [
345
416
  "Navigation:",
346
417
  " Tab Cycle between panels",
347
- " 1-4 Jump to panel (when not in Chat)",
418
+ " 1-6 Jump to panel (when not in Chat)",
348
419
  " Escape Return to Chat",
349
420
  "",
350
421
  "Chat (Tab 1):",
351
422
  " Enter Send message",
352
423
  " ⌥+Enter Insert newline",
353
424
  " ↑/↓ Browse input history",
354
- " Shift+↑/↓ Scroll chat history",
355
425
  "",
356
426
  "Tools (Tab 2):",
357
427
  " ↑/↓ Select tool call",
@@ -365,6 +435,22 @@ export function App({
365
435
  " / Search context",
366
436
  " d Delete selected item",
367
437
  "",
438
+ "Tasks (Tab 4):",
439
+ " ↑/↓ Navigate task list",
440
+ " Shift+↑/↓ Scroll detail pane",
441
+ " j/k Scroll detail pane",
442
+ " f Cycle status filter",
443
+ " p Cycle priority filter",
444
+ " r Refresh tasks",
445
+ "",
446
+ "Threads (Tab 5):",
447
+ " ↑/↓ Navigate thread list",
448
+ " Shift+↑/↓ Scroll detail pane",
449
+ " j/k Scroll detail pane",
450
+ " f Cycle type filter",
451
+ " d Delete thread (with confirmation)",
452
+ " r Refresh threads",
453
+ "",
368
454
  "Commands:",
369
455
  " /help Show this help",
370
456
  " /quit, /exit End the chat session",
@@ -388,6 +474,20 @@ export function App({
388
474
  [exit, processQueue, syncQueue],
389
475
  );
390
476
 
477
+ const sessionConn = sessionRef.current?.conn;
478
+ const inputBarHeader = useMemo(
479
+ () =>
480
+ sessionConn ? (
481
+ <StatusBar
482
+ projectDir={projectDir}
483
+ conn={sessionConn}
484
+ chatTitle={chatTitle}
485
+ onDaemonStatusChange={setDaemonRunning}
486
+ />
487
+ ) : null,
488
+ [projectDir, sessionConn, chatTitle],
489
+ );
490
+
391
491
  const allToolCalls = useMemo(
392
492
  () => messages.flatMap((m) => m.toolCalls ?? []),
393
493
  [messages],
@@ -420,32 +520,65 @@ export function App({
420
520
 
421
521
  return (
422
522
  <Box flexDirection="column" height="100%">
423
- <TabBar activeTab={activeTab} />
424
- <Divider isLoading={isLoading} />
425
-
426
- {/* Tab content area */}
427
- {activeTab === 1 && (
523
+ {/* Tab content area — all panels stay mounted to avoid expensive
524
+ remount cycles (especially <Static> in MessageList re-rendering
525
+ the entire history). display="none" hides inactive panels from
526
+ layout without destroying them. */}
527
+ <Box
528
+ display={activeTab === 1 ? "flex" : "none"}
529
+ flexDirection="column"
530
+ flexGrow={1}
531
+ >
428
532
  <MessageList
429
533
  messages={messages}
430
534
  streamingText={streamingText}
431
535
  isLoading={isLoading}
432
536
  activeToolCalls={activeToolCalls}
433
- isActive={activeTab === 1}
434
537
  />
435
- )}
436
- {activeTab === 2 && (
538
+ </Box>
539
+ <Box
540
+ display={activeTab === 2 ? "flex" : "none"}
541
+ flexDirection="column"
542
+ flexGrow={1}
543
+ >
437
544
  <ToolPanel toolCalls={allToolCalls} isActive={activeTab === 2} />
438
- )}
439
- {activeTab === 3 && (
545
+ </Box>
546
+ <Box
547
+ display={activeTab === 3 ? "flex" : "none"}
548
+ flexDirection="column"
549
+ flexGrow={1}
550
+ >
440
551
  <ContextPanel conn={conn} isActive={activeTab === 3} />
441
- )}
442
- {activeTab === 4 && (
552
+ </Box>
553
+ <Box
554
+ display={activeTab === 4 ? "flex" : "none"}
555
+ flexDirection="column"
556
+ flexGrow={1}
557
+ >
558
+ <TaskPanel conn={conn} isActive={activeTab === 4} />
559
+ </Box>
560
+ <Box
561
+ display={activeTab === 5 ? "flex" : "none"}
562
+ flexDirection="column"
563
+ flexGrow={1}
564
+ >
565
+ <ThreadPanel
566
+ conn={conn}
567
+ activeThreadId={threadId}
568
+ isActive={activeTab === 5}
569
+ />
570
+ </Box>
571
+ <Box
572
+ display={activeTab === 6 ? "flex" : "none"}
573
+ flexDirection="column"
574
+ flexGrow={1}
575
+ >
443
576
  <HelpPanel
444
577
  projectDir={projectDir}
445
578
  threadId={threadId}
446
579
  daemonRunning={daemonRunning}
447
580
  />
448
- )}
581
+ </Box>
449
582
 
450
583
  {/* Queued messages (only on Chat tab) */}
451
584
  {activeTab === 1 && queuedMessages.length > 0 && (
@@ -455,22 +588,16 @@ export function App({
455
588
  />
456
589
  )}
457
590
 
458
- {/* Bottom bar: StatusBar + InputBar (input only on Chat tab) */}
591
+ {/* Bottom bar: StatusBar + InputBar (input only on Chat tab) + TabBar */}
459
592
  <InputBar
460
593
  value={inputValue}
461
594
  onChange={setInputValue}
462
595
  onSubmit={handleSubmit}
463
596
  disabled={activeTab !== 1}
464
597
  history={inputHistory}
465
- header={
466
- <StatusBar
467
- projectDir={projectDir}
468
- conn={conn}
469
- isLoading={isLoading}
470
- onDaemonStatusChange={setDaemonRunning}
471
- />
472
- }
598
+ header={inputBarHeader}
473
599
  />
600
+ <TabBar activeTab={activeTab} />
474
601
  </Box>
475
602
  );
476
603
  }