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,6 +1,6 @@
1
1
  import { Box, Text, useInput, useStdout } from "ink";
2
- import { useEffect, useMemo, useState } from "react";
3
- import { theme } from "../theme.ts";
2
+ import { memo, useEffect, useMemo, useState } from "react";
3
+ import { ansi, theme } from "../theme.ts";
4
4
  import { resolveToolDisplay, type ToolCallData } from "./ToolCall.tsx";
5
5
 
6
6
  interface ToolPanelProps {
@@ -10,16 +10,6 @@ interface ToolPanelProps {
10
10
 
11
11
  const SIDEBAR_WIDTH = 42;
12
12
 
13
- // ANSI escape helpers
14
- const RESET = "\x1b[0m";
15
- const BOLD = "\x1b[1m";
16
- const DIM = "\x1b[2m";
17
- const CYAN = "\x1b[36m";
18
- const GREEN = "\x1b[32m";
19
- const YELLOW = "\x1b[33m";
20
- const MAGENTA = "\x1b[35m";
21
- const BLUE = "\x1b[34m";
22
-
23
13
  /** Try to parse a string as JSON; returns the parsed value or undefined on failure */
24
14
  function tryParseJson(str: string): unknown | undefined {
25
15
  try {
@@ -37,10 +27,10 @@ function colorizeJson(str: string): string {
37
27
  }
38
28
 
39
29
  function colorizeValue(value: unknown, indent: number): string {
40
- if (value === null) return `${MAGENTA}null${RESET}`;
30
+ if (value === null) return `${ansi.toolName}null${ansi.reset}`;
41
31
  if (typeof value === "boolean")
42
- return `${MAGENTA}${value ? "true" : "false"}${RESET}`;
43
- if (typeof value === "number") return `${YELLOW}${value}${RESET}`;
32
+ return `${ansi.toolName}${value ? "true" : "false"}${ansi.reset}`;
33
+ if (typeof value === "number") return `${ansi.accent}${value}${ansi.reset}`;
44
34
  if (typeof value === "string") {
45
35
  // Try to unwrap stringified JSON (common in tool results)
46
36
  const inner = tryParseJson(value);
@@ -48,7 +38,7 @@ function colorizeValue(value: unknown, indent: number): string {
48
38
  return colorizeValue(inner, indent);
49
39
  }
50
40
  const escaped = JSON.stringify(value);
51
- return `${GREEN}${escaped}${RESET}`;
41
+ return `${ansi.success}${escaped}${ansi.reset}`;
52
42
  }
53
43
 
54
44
  const pad = " ".repeat(indent);
@@ -67,7 +57,7 @@ function colorizeValue(value: unknown, indent: number): string {
67
57
  if (entries.length === 0) return "{}";
68
58
  const lines = entries.map(
69
59
  ([k, v]) =>
70
- `${innerPad}${CYAN}${JSON.stringify(k)}${RESET}: ${colorizeValue(v, indent + 1)}`,
60
+ `${innerPad}${ansi.info}${JSON.stringify(k)}${ansi.reset}: ${colorizeValue(v, indent + 1)}`,
71
61
  );
72
62
  return `{\n${lines.join(",\n")}\n${pad}}`;
73
63
  }
@@ -88,26 +78,36 @@ function buildDetailAnsi(tool: ToolCallData): string {
88
78
  tool.name,
89
79
  tool.input,
90
80
  );
91
- lines.push(`${BOLD}${CYAN}${displayName}${RESET}`);
81
+ lines.push(`${ansi.bold}${ansi.info}${displayName}${ansi.reset}`);
92
82
  if (tool.name === "mcp_exec") {
93
- lines.push(`${DIM}via mcp_exec${RESET}`);
83
+ lines.push(`${ansi.dim}via mcp_exec${ansi.reset}`);
94
84
  }
95
- lines.push(`${DIM}Time: ${time}${RESET}`);
85
+ lines.push(`${ansi.dim}Time: ${time}${ansi.reset}`);
96
86
  if (tool.running) {
97
- lines.push(`${YELLOW}⟳ running${RESET}`);
87
+ lines.push(`${ansi.accent}⟳ running${ansi.reset}`);
98
88
  }
99
89
  lines.push("");
100
90
 
101
- lines.push(`${BOLD}${BLUE}Input${RESET}`);
91
+ lines.push(`${ansi.bold}${ansi.primary}Input${ansi.reset}`);
102
92
  lines.push(colorizeJson(displayInput));
103
93
  lines.push("");
104
94
 
105
95
  if (tool.output) {
106
- lines.push(`${BOLD}${BLUE}Output${RESET}`);
107
- lines.push(colorizeJson(tool.output));
96
+ if (tool.isError) {
97
+ lines.push(`${ansi.bold}${ansi.error}Error${ansi.reset}`);
98
+ lines.push(`${ansi.error}${colorizeJson(tool.output)}${ansi.reset}`);
99
+ } else {
100
+ lines.push(`${ansi.bold}${ansi.primary}Output${ansi.reset}`);
101
+ if (tool.largeResult) {
102
+ lines.push(
103
+ `${ansi.accent}Paginated for LLM: ${tool.largeResult.chars.toLocaleString()} chars, ${tool.largeResult.pages} page(s) — stored as ${tool.largeResult.id}${ansi.reset}`,
104
+ );
105
+ }
106
+ lines.push(colorizeJson(tool.output));
107
+ }
108
108
  } else if (!tool.running) {
109
- lines.push(`${BOLD}${BLUE}Output${RESET}`);
110
- lines.push(`${DIM}(no output)${RESET}`);
109
+ lines.push(`${ansi.bold}${ansi.primary}Output${ansi.reset}`);
110
+ lines.push(`${ansi.dim}(no output)${ansi.reset}`);
111
111
  }
112
112
 
113
113
  return lines.join("\n");
@@ -115,7 +115,10 @@ function buildDetailAnsi(tool: ToolCallData): string {
115
115
 
116
116
  const PAGE_SCROLL_LINES = 10;
117
117
 
118
- export function ToolPanel({ toolCalls, isActive }: ToolPanelProps) {
118
+ export const ToolPanel = memo(function ToolPanel({
119
+ toolCalls,
120
+ isActive,
121
+ }: ToolPanelProps) {
119
122
  const { stdout } = useStdout();
120
123
  const termRows = stdout?.rows ?? 24;
121
124
  const [selectedIndex, setSelectedIndex] = useState(0);
@@ -261,7 +264,7 @@ export function ToolPanel({ toolCalls, isActive }: ToolPanelProps) {
261
264
  {sidebarVisible.map((tc, vi) => {
262
265
  const i = vi + sidebarScrollOffset;
263
266
  const isSelected = i === selectedIndex;
264
- const icon = tc.running ? "⟳" : "✔";
267
+ const icon = tc.running ? "⟳" : tc.isError ? "✘" : "✔";
265
268
  const time = tc.timestamp.toLocaleTimeString([], {
266
269
  hour: "2-digit",
267
270
  minute: "2-digit",
@@ -273,7 +276,7 @@ export function ToolPanel({ toolCalls, isActive }: ToolPanelProps) {
273
276
  ? `${displayName.slice(0, maxName - 1)}…`
274
277
  : displayName;
275
278
  return (
276
- <Box key={`${i}-${tc.name}`} paddingX={1}>
279
+ <Box key={tc.id} paddingX={1}>
277
280
  <Text
278
281
  backgroundColor={isSelected ? theme.selectionBg : undefined}
279
282
  bold={isSelected}
@@ -288,7 +291,13 @@ export function ToolPanel({ toolCalls, isActive }: ToolPanelProps) {
288
291
  >
289
292
  {isSelected ? "▸" : " "}{" "}
290
293
  <Text
291
- color={tc.running ? theme.accent : theme.muted}
294
+ color={
295
+ tc.running
296
+ ? theme.accent
297
+ : tc.isError
298
+ ? theme.error
299
+ : theme.muted
300
+ }
292
301
  bold={false}
293
302
  >
294
303
  {icon}
@@ -330,4 +339,4 @@ export function ToolPanel({ toolCalls, isActive }: ToolPanelProps) {
330
339
  </Box>
331
340
  </Box>
332
341
  );
333
- }
342
+ });
package/src/tui/theme.ts CHANGED
@@ -29,8 +29,11 @@ function detectDarkBackground(): boolean {
29
29
  ["read", "-g", "AppleInterfaceStyle"],
30
30
  { encoding: "utf-8", timeout: 500 },
31
31
  );
32
- // Returns "Dark" in dark mode; exits non-zero in light mode
33
- return result.stdout?.trim() === "Dark";
32
+ // Returns "Dark" in dark mode, "Light" or exit 1 in light mode
33
+ // Only trust the result if the command succeeded (status 0)
34
+ if (result.status === 0) {
35
+ return result.stdout?.trim() === "Dark";
36
+ }
34
37
  } catch {
35
38
  // fall through to default
36
39
  }
@@ -44,7 +47,7 @@ const isDark = detectDarkBackground();
44
47
  export const theme = {
45
48
  accent: isDark ? "yellow" : "#B8860B",
46
49
  accentBorder: isDark ? "yellow" : "#B8860B",
47
- userBg: isDark ? "#2a4a6c" : "#d0e0f0",
50
+ userBg: isDark ? "#2a5a8c" : "#d0e0f0",
48
51
  selectionBg: isDark ? "#333" : "#ddd",
49
52
  success: "green",
50
53
  error: "red",
@@ -54,5 +57,19 @@ export const theme = {
54
57
  muted: "gray",
55
58
  } as const;
56
59
 
60
+ /** ANSI escape codes for raw string building (detail panes, etc.) */
61
+ export const ansi = {
62
+ reset: "\x1b[0m",
63
+ bold: "\x1b[1m",
64
+ dim: "\x1b[2m",
65
+ success: "\x1b[32m",
66
+ error: "\x1b[31m",
67
+ info: "\x1b[36m",
68
+ primary: "\x1b[34m",
69
+ accent: "\x1b[33m",
70
+ toolName: "\x1b[35m",
71
+ muted: "\x1b[2m",
72
+ } as const;
73
+
57
74
  // Exported for testing
58
75
  export { detectDarkBackground };
@@ -0,0 +1,47 @@
1
+ import Anthropic from "@anthropic-ai/sdk";
2
+ import type { BotholomewConfig } from "../config/schemas.ts";
3
+ import type { DbConnection } from "../db/connection.ts";
4
+ import { updateThreadTitle } from "../db/threads.ts";
5
+ import { logger } from "./logger.ts";
6
+
7
+ /**
8
+ * Generate a short title for a thread using the chunker model (Haiku).
9
+ * Fire-and-forget — errors are logged at debug level and never propagated.
10
+ */
11
+ export async function generateThreadTitle(
12
+ config: Required<BotholomewConfig>,
13
+ conn: DbConnection,
14
+ threadId: string,
15
+ context: string,
16
+ ): Promise<void> {
17
+ try {
18
+ const client = new Anthropic({
19
+ apiKey: config.anthropic_api_key || undefined,
20
+ });
21
+
22
+ const response = await client.messages.create({
23
+ model: config.chunker_model,
24
+ max_tokens: 50,
25
+ system:
26
+ "You are a title generator. The user will provide the first message from a conversation. Output a short descriptive title (5-8 words). Output ONLY the title, nothing else.",
27
+ messages: [
28
+ {
29
+ role: "user",
30
+ content: `Generate a title for this message:\n\n"${context}"`,
31
+ },
32
+ ],
33
+ });
34
+
35
+ const title = response.content
36
+ .filter((b) => b.type === "text")
37
+ .map((b) => b.text)
38
+ .join("")
39
+ .trim();
40
+
41
+ if (title) {
42
+ await updateThreadTitle(conn, threadId, title);
43
+ }
44
+ } catch (err) {
45
+ logger.warn(`Failed to generate thread title: ${err}`);
46
+ }
47
+ }