botholomew 0.7.0 → 0.7.1
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 +218 -1
- package/package.json +1 -1
- package/src/commands/chat.ts +1 -1
- package/src/skills/commands.ts +12 -1
- package/src/tui/App.tsx +33 -18
- package/src/tui/components/HelpPanel.tsx +1 -1
- package/src/tui/components/InputBar.tsx +176 -99
- package/src/tui/components/SlashCommandPopup.tsx +50 -0
- package/src/tui/slashCompletion.ts +38 -0
package/README.md
CHANGED
|
@@ -6,4 +6,221 @@
|
|
|
6
6
|
" "
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
**A local-first AI agent for knowledge work.** Botholomew is a long-running
|
|
10
|
+
autonomous agent that works its way through a task queue — reading email,
|
|
11
|
+
summarizing documents, researching topics, organizing notes, and maintaining
|
|
12
|
+
context over time — while you sleep, work, or chat with it.
|
|
13
|
+
|
|
14
|
+
Unlike coding agents, Botholomew has **no shell, no filesystem, and no network
|
|
15
|
+
tools** by default. Everything it touches lives inside a single DuckDB database
|
|
16
|
+
at `.botholomew/data.duckdb` and a handful of markdown files. External access
|
|
17
|
+
is granted deliberately, per project, through MCP servers.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Why Botholomew?
|
|
22
|
+
|
|
23
|
+
- **Autonomous.** A background daemon ticks on a schedule, claims tasks,
|
|
24
|
+
works them with Claude, and logs every interaction. You can close the
|
|
25
|
+
terminal and come back later.
|
|
26
|
+
- **Portable.** Each project is a `.botholomew/` directory — markdown +
|
|
27
|
+
DuckDB. Copy it, share it, check it in (or `.gitignore` it).
|
|
28
|
+
- **Local-first.** All data stays on your machine. Embeddings are indexed in
|
|
29
|
+
DuckDB's native vector store with HNSW. Model calls go direct to Anthropic
|
|
30
|
+
and OpenAI.
|
|
31
|
+
- **Extensible.** External tools come from MCP servers via
|
|
32
|
+
[MCPX](https://github.com/evantahler/mcpx) — run them locally (Gmail,
|
|
33
|
+
Slack, GitHub) or connect through an MCP gateway like
|
|
34
|
+
[Arcade.dev](https://www.arcade.dev/) to reach hundreds of
|
|
35
|
+
authenticated services without managing each server yourself.
|
|
36
|
+
Reusable workflows are defined as markdown "skills" (slash commands).
|
|
37
|
+
- **Safe by default.** The agent has no shell, no network, and no
|
|
38
|
+
filesystem access of its own. Everything it can touch lives in
|
|
39
|
+
`.botholomew/` — and every external capability is something you
|
|
40
|
+
explicitly add.
|
|
41
|
+
- **Self-healing.** An OS-level watchdog (launchd on macOS, systemd on Linux)
|
|
42
|
+
restarts the daemon if it dies, rotates logs, and runs on boot.
|
|
43
|
+
- **Self-modifying.** The agent maintains its own `beliefs.md` and
|
|
44
|
+
`goals.md` — it learns, updates its priors, and revises its goals as it
|
|
45
|
+
works.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
Requires [Bun](https://bun.sh) 1.1+.
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
bun install -g botholomew
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Or run the dev build from a checkout:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
git clone https://github.com/evantahler/botholomew
|
|
61
|
+
cd botholomew
|
|
62
|
+
bun install
|
|
63
|
+
bun run dev -- --help
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Quickstart
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
# 1. Initialize a project in the current directory
|
|
72
|
+
botholomew init
|
|
73
|
+
|
|
74
|
+
# 2. Add your API keys to .botholomew/config.json, or export env vars
|
|
75
|
+
export ANTHROPIC_API_KEY=sk-ant-...
|
|
76
|
+
export OPENAI_API_KEY=sk-... # used for embeddings
|
|
77
|
+
|
|
78
|
+
# 3. Queue some work
|
|
79
|
+
botholomew task add "Summarize every markdown file in ~/notes"
|
|
80
|
+
|
|
81
|
+
# 4. Start the daemon (foreground — watch it work)
|
|
82
|
+
botholomew daemon start --foreground
|
|
83
|
+
|
|
84
|
+
# 5. Or chat with the agent interactively
|
|
85
|
+
botholomew chat
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## What a project looks like
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
my-project/
|
|
94
|
+
.botholomew/
|
|
95
|
+
soul.md # always-loaded identity (not agent-editable)
|
|
96
|
+
beliefs.md # always-loaded, agent-editable priors
|
|
97
|
+
goals.md # always-loaded, agent-editable goals
|
|
98
|
+
config.json # models, tick interval, API keys
|
|
99
|
+
data.duckdb # tasks, schedules, context, embeddings, logs
|
|
100
|
+
mcpx/servers.json # external MCP servers (Gmail, Slack, …)
|
|
101
|
+
skills/ # user-defined slash commands
|
|
102
|
+
summarize.md
|
|
103
|
+
standup.md
|
|
104
|
+
daemon.pid # PID file for the running daemon
|
|
105
|
+
daemon.log # rotating daemon logs
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Everything the agent can touch is here. No surprises.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## The CLI
|
|
113
|
+
|
|
114
|
+
| Command | Purpose |
|
|
115
|
+
|---|---|
|
|
116
|
+
| `botholomew init` | Create `.botholomew/` with templates and a fresh database |
|
|
117
|
+
| `botholomew daemon start\|stop\|status` | Run, stop, or inspect the daemon |
|
|
118
|
+
| `botholomew daemon install\|uninstall` | Register/remove the OS watchdog |
|
|
119
|
+
| `botholomew daemon list` | List all Botholomew projects on this machine |
|
|
120
|
+
| `botholomew chat` | Interactive Ink/React TUI |
|
|
121
|
+
| `botholomew task list\|add\|view\|update\|reset\|delete` | Manage the task queue |
|
|
122
|
+
| `botholomew schedule list\|add\|enable\|trigger\|delete` | Recurring work |
|
|
123
|
+
| `botholomew context add\|list\|view\|search\|refresh\|remove` | Ingest & browse knowledge (files, folders, URLs) |
|
|
124
|
+
| `botholomew mcpx add\|list\|tools\|test` | Configure external MCP servers |
|
|
125
|
+
| `botholomew skill list\|show\|create` | Manage slash-command skills |
|
|
126
|
+
| `botholomew file\|dir\|search ...` | Direct access to the agent's virtual filesystem |
|
|
127
|
+
| `botholomew thread list\|view` | Browse the agent's interaction history |
|
|
128
|
+
| `botholomew upgrade` | Self-update |
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## How it works
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
|
136
|
+
│ Chat │ │ Daemon │ │ Watchdog │
|
|
137
|
+
│ (Ink TUI) │ │ (tick loop) │ │ launchd/ │
|
|
138
|
+
│ │ │ │ │ systemd │
|
|
139
|
+
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
|
|
140
|
+
│ │ │
|
|
141
|
+
│ enqueue tasks │ claims tasks │ every 60s:
|
|
142
|
+
│ browse history │ runs LLM tool loops │ check PID
|
|
143
|
+
│ invoke skills │ updates status │ restart if
|
|
144
|
+
│ │ logs to threads │ dead
|
|
145
|
+
│ │ │
|
|
146
|
+
└────────────┬───────────┴────────────┬───────────┘
|
|
147
|
+
│ │
|
|
148
|
+
┌─────▼────────────────────────▼─────┐
|
|
149
|
+
│ DuckDB │
|
|
150
|
+
│ ┌───────────┐ ┌──────────────┐ │
|
|
151
|
+
│ │ tasks │ │ context_items│ │
|
|
152
|
+
│ │ schedules │ │ embeddings │ │
|
|
153
|
+
│ │ threads │ │ (HNSW) │ │
|
|
154
|
+
│ └───────────┘ └──────────────┘ │
|
|
155
|
+
└─────┬───────────────────────────────┘
|
|
156
|
+
│
|
|
157
|
+
▼
|
|
158
|
+
MCPX ─► Gmail, Slack, GitHub, Firecrawl, …
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
See [docs/architecture.md](docs/architecture.md) for a deeper tour.
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## Deep dives
|
|
166
|
+
|
|
167
|
+
Topics worth understanding in detail:
|
|
168
|
+
|
|
169
|
+
- **[Architecture](docs/architecture.md)** — daemon, chat, watchdog, and how
|
|
170
|
+
they share a database.
|
|
171
|
+
- **[The virtual filesystem](docs/virtual-filesystem.md)** — why the agent's
|
|
172
|
+
"files" are actually DuckDB rows, and how `file_read`/`file_write` work.
|
|
173
|
+
- **[Context & hybrid search](docs/context-and-search.md)** — LLM-driven
|
|
174
|
+
chunking, OpenAI embeddings, and DuckDB's HNSW-accelerated keyword +
|
|
175
|
+
vector search.
|
|
176
|
+
- **[Tasks & schedules](docs/tasks-and-schedules.md)** — the claim loop, DAG
|
|
177
|
+
validation, stale-task recovery, and natural-language recurring schedules.
|
|
178
|
+
- **[The Tool class](docs/tools.md)** — one Zod definition, three consumers
|
|
179
|
+
(Anthropic tool-use, Commander CLI, tests).
|
|
180
|
+
- **[Persistent context](docs/persistent-context.md)** — `soul.md`,
|
|
181
|
+
`beliefs.md`, `goals.md`, frontmatter flags, and agent self-modification.
|
|
182
|
+
- **[Skills (slash commands)](docs/skills.md)** — reusable prompt templates
|
|
183
|
+
with positional arguments and tab completion.
|
|
184
|
+
- **[MCPX integration](docs/mcpx.md)** — configuring external servers and
|
|
185
|
+
how MCP tools are merged into the agent's toolset.
|
|
186
|
+
- **[The watchdog](docs/watchdog.md)** — launchd plists, systemd units, and
|
|
187
|
+
multi-project service naming.
|
|
188
|
+
- **[Configuration](docs/configuration.md)** — every key in `config.json`
|
|
189
|
+
and its default.
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## Tech stack
|
|
194
|
+
|
|
195
|
+
- **[Bun](https://bun.sh)** + TypeScript
|
|
196
|
+
- **[DuckDB](https://duckdb.org)** via `@duckdb/node-api`, with the
|
|
197
|
+
**[VSS extension](https://duckdb.org/docs/stable/extensions/vss)** for
|
|
198
|
+
native vector search
|
|
199
|
+
- **[Anthropic SDK](https://docs.anthropic.com/en/api/client-sdks)** for
|
|
200
|
+
Claude — the reasoning model
|
|
201
|
+
- **OpenAI embeddings API** (`text-embedding-3-small`, 1536-dim) for
|
|
202
|
+
semantic search
|
|
203
|
+
- **[MCPX](https://github.com/evantahler/mcpx)** for external tools
|
|
204
|
+
- **[Ink 6](https://github.com/vadimdemedes/ink)** + **React 19** for the
|
|
205
|
+
terminal UI
|
|
206
|
+
- **[Commander.js](https://github.com/tj/commander.js)** for the CLI
|
|
207
|
+
- **[Zod](https://zod.dev)** for tool input/output schemas
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
## Contributing
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
bun install
|
|
215
|
+
bun test
|
|
216
|
+
bun run lint # tsc --noEmit + biome check
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
See [CLAUDE.md](CLAUDE.md) for conventions (always use `bun`, bump the
|
|
220
|
+
version in `package.json` on every merge to `main`, etc.).
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## License
|
|
225
|
+
|
|
226
|
+
MIT.
|
package/package.json
CHANGED
package/src/commands/chat.ts
CHANGED
|
@@ -12,7 +12,7 @@ export function registerChatCommand(program: Command) {
|
|
|
12
12
|
" Commands:\n" +
|
|
13
13
|
" /help Show keyboard shortcuts\n" +
|
|
14
14
|
" /tools Open tool call inspector\n" +
|
|
15
|
-
" /
|
|
15
|
+
" /exit End the chat session",
|
|
16
16
|
)
|
|
17
17
|
.option("--thread-id <id>", "Resume an existing chat thread")
|
|
18
18
|
.option("-p, --prompt <text>", "Start chat with an initial prompt")
|
package/src/skills/commands.ts
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import type { SkillDefinition } from "./parser.ts";
|
|
2
2
|
import { renderSkill } from "./parser.ts";
|
|
3
3
|
|
|
4
|
+
export interface SlashCommand {
|
|
5
|
+
name: string;
|
|
6
|
+
description: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
|
|
10
|
+
{ name: "help", description: "Show command reference and shortcuts" },
|
|
11
|
+
{ name: "skills", description: "List available skills" },
|
|
12
|
+
{ name: "exit", description: "End the chat session" },
|
|
13
|
+
];
|
|
14
|
+
|
|
4
15
|
export interface SlashCommandContext {
|
|
5
16
|
skills: Map<string, SkillDefinition>;
|
|
6
17
|
addSystemMessage: (content: string) => void;
|
|
@@ -22,7 +33,7 @@ export function handleSlashCommand(
|
|
|
22
33
|
const name = commandPart.slice(1).toLowerCase(); // remove leading /
|
|
23
34
|
|
|
24
35
|
// Built-in commands
|
|
25
|
-
if (name === "
|
|
36
|
+
if (name === "exit") {
|
|
26
37
|
ctx.exit();
|
|
27
38
|
return true;
|
|
28
39
|
}
|
package/src/tui/App.tsx
CHANGED
|
@@ -9,7 +9,11 @@ import {
|
|
|
9
9
|
import { MAX_INLINE_CHARS, PAGE_SIZE_CHARS } from "../daemon/large-results.ts";
|
|
10
10
|
import type { Interaction } from "../db/threads.ts";
|
|
11
11
|
import { getThread } from "../db/threads.ts";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
BUILTIN_SLASH_COMMANDS,
|
|
14
|
+
handleSlashCommand,
|
|
15
|
+
type SlashCommand,
|
|
16
|
+
} from "../skills/commands.ts";
|
|
13
17
|
import { ContextPanel } from "./components/ContextPanel.tsx";
|
|
14
18
|
import { HelpPanel } from "./components/HelpPanel.tsx";
|
|
15
19
|
import { InputBar } from "./components/InputBar.tsx";
|
|
@@ -27,6 +31,7 @@ import { TaskPanel } from "./components/TaskPanel.tsx";
|
|
|
27
31
|
import { ThreadPanel } from "./components/ThreadPanel.tsx";
|
|
28
32
|
import type { ToolCallData } from "./components/ToolCall.tsx";
|
|
29
33
|
import { ToolPanel } from "./components/ToolPanel.tsx";
|
|
34
|
+
import { buildSlashCommands, getSlashMatches } from "./slashCompletion.ts";
|
|
30
35
|
import { ansi } from "./theme.ts";
|
|
31
36
|
|
|
32
37
|
interface AppProps {
|
|
@@ -210,10 +215,8 @@ export function App({
|
|
|
210
215
|
queuedMessagesRef.current = queuedMessages;
|
|
211
216
|
selectedQueueIndexRef.current = selectedQueueIndex;
|
|
212
217
|
|
|
213
|
-
const
|
|
214
|
-
const
|
|
215
|
-
tabConsumedRef.current = true;
|
|
216
|
-
}, []);
|
|
218
|
+
const slashCommandsRef = useRef<SlashCommand[]>([]);
|
|
219
|
+
const inputValueRef = useRef("");
|
|
217
220
|
|
|
218
221
|
const stableAppHandler = useCallback(
|
|
219
222
|
// biome-ignore lint/suspicious/noExplicitAny: Ink's Key type is not exported
|
|
@@ -224,11 +227,15 @@ export function App({
|
|
|
224
227
|
return;
|
|
225
228
|
}
|
|
226
229
|
|
|
227
|
-
// Tab key cycles tabs —
|
|
230
|
+
// Tab key cycles tabs — but on the Chat tab, let InputBar consume it
|
|
231
|
+
// whenever the slash autocomplete popup would be open.
|
|
228
232
|
if (key.tab && !key.shift) {
|
|
229
|
-
if (
|
|
230
|
-
|
|
231
|
-
|
|
233
|
+
if (activeTabRef.current === 1) {
|
|
234
|
+
const popupOpen = getSlashMatches(
|
|
235
|
+
inputValueRef.current,
|
|
236
|
+
slashCommandsRef.current,
|
|
237
|
+
);
|
|
238
|
+
if (popupOpen) return;
|
|
232
239
|
}
|
|
233
240
|
setActiveTab((t) => ((t % 7) + 1) as TabId);
|
|
234
241
|
return;
|
|
@@ -454,6 +461,10 @@ export function App({
|
|
|
454
461
|
" Enter Send message",
|
|
455
462
|
" ⌥+Enter Insert newline",
|
|
456
463
|
" ↑/↓ Browse input history",
|
|
464
|
+
" / Open slash-command autocomplete",
|
|
465
|
+
" Tab/Enter Accept highlighted command (popup open)",
|
|
466
|
+
" ↑/↓ Move highlight (popup open)",
|
|
467
|
+
" Esc Close popup",
|
|
457
468
|
"",
|
|
458
469
|
"Tools (Tab 2):",
|
|
459
470
|
" ↑/↓ Select tool call",
|
|
@@ -495,7 +506,7 @@ export function App({
|
|
|
495
506
|
"Commands:",
|
|
496
507
|
" /help Show this help",
|
|
497
508
|
" /skills List available skills",
|
|
498
|
-
" /
|
|
509
|
+
" /exit End the chat session",
|
|
499
510
|
...skillLines,
|
|
500
511
|
].join("\n"),
|
|
501
512
|
timestamp: new Date(),
|
|
@@ -551,14 +562,19 @@ export function App({
|
|
|
551
562
|
);
|
|
552
563
|
|
|
553
564
|
const sessionSkills = ready ? sessionRef.current?.skills : undefined;
|
|
554
|
-
const
|
|
555
|
-
const
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
565
|
+
const slashCommands = useMemo<SlashCommand[]>(() => {
|
|
566
|
+
const skillList = sessionSkills
|
|
567
|
+
? Array.from(sessionSkills.values()).map((s) => ({
|
|
568
|
+
name: s.name,
|
|
569
|
+
description: s.description,
|
|
570
|
+
}))
|
|
571
|
+
: [];
|
|
572
|
+
return buildSlashCommands(BUILTIN_SLASH_COMMANDS, skillList);
|
|
560
573
|
}, [sessionSkills]);
|
|
561
574
|
|
|
575
|
+
slashCommandsRef.current = slashCommands;
|
|
576
|
+
inputValueRef.current = inputValue;
|
|
577
|
+
|
|
562
578
|
const allToolCalls = useMemo(
|
|
563
579
|
() => messages.flatMap((m) => m.toolCalls ?? []),
|
|
564
580
|
[messages],
|
|
@@ -681,8 +697,7 @@ export function App({
|
|
|
681
697
|
disabled={activeTab !== 1}
|
|
682
698
|
history={inputHistory}
|
|
683
699
|
header={inputBarHeader}
|
|
684
|
-
|
|
685
|
-
onTabConsumed={handleTabConsumed}
|
|
700
|
+
slashCommands={slashCommands}
|
|
686
701
|
/>
|
|
687
702
|
<TabBar activeTab={activeTab} />
|
|
688
703
|
</Box>
|
|
@@ -4,9 +4,13 @@ import {
|
|
|
4
4
|
type ReactNode,
|
|
5
5
|
useCallback,
|
|
6
6
|
useEffect,
|
|
7
|
+
useMemo,
|
|
7
8
|
useRef,
|
|
8
9
|
useState,
|
|
9
10
|
} from "react";
|
|
11
|
+
import type { SlashCommand } from "../../skills/commands.ts";
|
|
12
|
+
import { getSlashMatches } from "../slashCompletion.ts";
|
|
13
|
+
import { SlashCommandPopup } from "./SlashCommandPopup.tsx";
|
|
10
14
|
|
|
11
15
|
interface InputBarProps {
|
|
12
16
|
value: string;
|
|
@@ -15,8 +19,7 @@ interface InputBarProps {
|
|
|
15
19
|
disabled: boolean;
|
|
16
20
|
history: string[];
|
|
17
21
|
header?: ReactNode;
|
|
18
|
-
|
|
19
|
-
onTabConsumed?: () => void;
|
|
22
|
+
slashCommands?: SlashCommand[];
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
export const InputBar = memo(function InputBar({
|
|
@@ -26,12 +29,13 @@ export const InputBar = memo(function InputBar({
|
|
|
26
29
|
disabled,
|
|
27
30
|
history,
|
|
28
31
|
header,
|
|
29
|
-
|
|
30
|
-
onTabConsumed,
|
|
32
|
+
slashCommands,
|
|
31
33
|
}: InputBarProps) {
|
|
32
34
|
const [historyIndex, setHistoryIndex] = useState(-1);
|
|
33
35
|
const [cursorPos, setCursorPos] = useState(0);
|
|
34
36
|
const [cursorVisible, setCursorVisible] = useState(true);
|
|
37
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
38
|
+
const [popupDismissed, setPopupDismissed] = useState(false);
|
|
35
39
|
const savedInput = useRef("");
|
|
36
40
|
const lastActivity = useRef(Date.now());
|
|
37
41
|
|
|
@@ -43,9 +47,9 @@ export const InputBar = memo(function InputBar({
|
|
|
43
47
|
const onChangeRef = useRef(onChange);
|
|
44
48
|
const onSubmitRef = useRef(onSubmit);
|
|
45
49
|
const historyRef = useRef(history);
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
const
|
|
50
|
+
const slashCommandsRef = useRef(slashCommands);
|
|
51
|
+
const selectedIndexRef = useRef(selectedIndex);
|
|
52
|
+
const popupDismissedRef = useRef(popupDismissed);
|
|
49
53
|
|
|
50
54
|
valueRef.current = value;
|
|
51
55
|
cursorPosRef.current = cursorPos;
|
|
@@ -53,8 +57,39 @@ export const InputBar = memo(function InputBar({
|
|
|
53
57
|
onChangeRef.current = onChange;
|
|
54
58
|
onSubmitRef.current = onSubmit;
|
|
55
59
|
historyRef.current = history;
|
|
56
|
-
|
|
57
|
-
|
|
60
|
+
slashCommandsRef.current = slashCommands;
|
|
61
|
+
selectedIndexRef.current = selectedIndex;
|
|
62
|
+
popupDismissedRef.current = popupDismissed;
|
|
63
|
+
|
|
64
|
+
// Matches visible in the autocomplete popup, or null when it should be
|
|
65
|
+
// hidden (non-slash input, space typed, no matches, or user escaped).
|
|
66
|
+
const popupMatches = useMemo(() => {
|
|
67
|
+
if (popupDismissed) return null;
|
|
68
|
+
return getSlashMatches(value, slashCommands ?? []);
|
|
69
|
+
}, [value, slashCommands, popupDismissed]);
|
|
70
|
+
|
|
71
|
+
// Reset highlight to top whenever the match list changes.
|
|
72
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally reset on match-list change, not value change
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
setSelectedIndex(0);
|
|
75
|
+
}, [popupMatches?.length]);
|
|
76
|
+
|
|
77
|
+
// Clamp highlight if the list shrank (defensive — the effect above usually handles it).
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (popupMatches && selectedIndex >= popupMatches.length) {
|
|
80
|
+
setSelectedIndex(Math.max(0, popupMatches.length - 1));
|
|
81
|
+
}
|
|
82
|
+
}, [popupMatches, selectedIndex]);
|
|
83
|
+
|
|
84
|
+
// Clear the dismissed flag as soon as the user edits the value,
|
|
85
|
+
// so a fresh "/" reopens the popup.
|
|
86
|
+
const prevValueRef = useRef(value);
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (prevValueRef.current !== value && popupDismissed) {
|
|
89
|
+
setPopupDismissed(false);
|
|
90
|
+
}
|
|
91
|
+
prevValueRef.current = value;
|
|
92
|
+
}, [value, popupDismissed]);
|
|
58
93
|
|
|
59
94
|
// Blink cursor when input is active — skip ticks while typing so the
|
|
60
95
|
// cursor stays solid and we avoid unnecessary renders during rapid input.
|
|
@@ -87,8 +122,43 @@ export const InputBar = memo(function InputBar({
|
|
|
87
122
|
const hIdx = historyIndexRef.current;
|
|
88
123
|
const hist = historyRef.current;
|
|
89
124
|
|
|
90
|
-
//
|
|
125
|
+
// Is the slash popup visible right now? Recompute from the authoritative
|
|
126
|
+
// ref-state so we don't depend on stale closure values.
|
|
127
|
+
const popupOpen = !popupDismissedRef.current
|
|
128
|
+
? getSlashMatches(val, slashCommandsRef.current ?? [])
|
|
129
|
+
: null;
|
|
130
|
+
|
|
131
|
+
const acceptSelection = () => {
|
|
132
|
+
if (!popupOpen) return false;
|
|
133
|
+
const chosen =
|
|
134
|
+
popupOpen[Math.min(selectedIndexRef.current, popupOpen.length - 1)];
|
|
135
|
+
if (!chosen) return false;
|
|
136
|
+
const completed = `/${chosen.name} `;
|
|
137
|
+
valueRef.current = completed;
|
|
138
|
+
cursorPosRef.current = completed.length;
|
|
139
|
+
onChangeRef.current(completed);
|
|
140
|
+
setCursorPos(completed.length);
|
|
141
|
+
// A trailing space makes the popup disappear naturally via regex,
|
|
142
|
+
// but set dismissed too so stray state can't re-open it.
|
|
143
|
+
setPopupDismissed(true);
|
|
144
|
+
return true;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// Escape: close popup if open, keep value untouched
|
|
148
|
+
if (key.escape) {
|
|
149
|
+
if (popupOpen) {
|
|
150
|
+
setPopupDismissed(true);
|
|
151
|
+
}
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Enter: if popup is open, accept selection (do not submit).
|
|
156
|
+
// Otherwise submit as before.
|
|
91
157
|
if (key.return) {
|
|
158
|
+
if (popupOpen && !key.shift && !key.meta) {
|
|
159
|
+
acceptSelection();
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
92
162
|
if (key.shift || key.meta) {
|
|
93
163
|
const before = val.slice(0, pos);
|
|
94
164
|
const after = val.slice(pos);
|
|
@@ -109,6 +179,14 @@ export const InputBar = memo(function InputBar({
|
|
|
109
179
|
return;
|
|
110
180
|
}
|
|
111
181
|
|
|
182
|
+
// Tab: accept popup selection if open. No-op otherwise.
|
|
183
|
+
if (key.tab) {
|
|
184
|
+
if (popupOpen) {
|
|
185
|
+
acceptSelection();
|
|
186
|
+
}
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
112
190
|
// Backspace
|
|
113
191
|
if (key.backspace || key.delete) {
|
|
114
192
|
if (pos > 0) {
|
|
@@ -138,81 +216,71 @@ export const InputBar = memo(function InputBar({
|
|
|
138
216
|
return;
|
|
139
217
|
}
|
|
140
218
|
|
|
141
|
-
//
|
|
142
|
-
if (key.upArrow
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
historyIndexRef.current = nextIndex;
|
|
149
|
-
setHistoryIndex(nextIndex);
|
|
150
|
-
const entry = hist[hist.length - 1 - nextIndex];
|
|
151
|
-
if (entry !== undefined) {
|
|
152
|
-
valueRef.current = entry;
|
|
153
|
-
cursorPosRef.current = entry.length;
|
|
154
|
-
onChangeRef.current(entry);
|
|
155
|
-
setCursorPos(entry.length);
|
|
156
|
-
}
|
|
219
|
+
// Up/Down: popup navigation when open, history otherwise
|
|
220
|
+
if (key.upArrow) {
|
|
221
|
+
if (popupOpen) {
|
|
222
|
+
const next = Math.max(0, selectedIndexRef.current - 1);
|
|
223
|
+
selectedIndexRef.current = next;
|
|
224
|
+
setSelectedIndex(next);
|
|
225
|
+
return;
|
|
157
226
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
227
|
+
if (hist.length > 0) {
|
|
228
|
+
const nextIndex = hIdx + 1;
|
|
229
|
+
if (nextIndex < hist.length) {
|
|
230
|
+
if (hIdx === -1) {
|
|
231
|
+
savedInput.current = val;
|
|
232
|
+
}
|
|
233
|
+
historyIndexRef.current = nextIndex;
|
|
234
|
+
setHistoryIndex(nextIndex);
|
|
235
|
+
const entry = hist[hist.length - 1 - nextIndex];
|
|
236
|
+
if (entry !== undefined) {
|
|
237
|
+
valueRef.current = entry;
|
|
238
|
+
cursorPosRef.current = entry.length;
|
|
239
|
+
onChangeRef.current(entry);
|
|
240
|
+
setCursorPos(entry.length);
|
|
241
|
+
}
|
|
172
242
|
}
|
|
173
|
-
} else if (hIdx === 0) {
|
|
174
|
-
historyIndexRef.current = -1;
|
|
175
|
-
setHistoryIndex(-1);
|
|
176
|
-
const saved = savedInput.current;
|
|
177
|
-
valueRef.current = saved;
|
|
178
|
-
cursorPosRef.current = saved.length;
|
|
179
|
-
onChangeRef.current(saved);
|
|
180
|
-
setCursorPos(saved.length);
|
|
181
243
|
}
|
|
182
244
|
return;
|
|
183
245
|
}
|
|
184
246
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
247
|
+
if (key.downArrow) {
|
|
248
|
+
if (popupOpen) {
|
|
249
|
+
const next = Math.min(
|
|
250
|
+
popupOpen.length - 1,
|
|
251
|
+
selectedIndexRef.current + 1,
|
|
252
|
+
);
|
|
253
|
+
selectedIndexRef.current = next;
|
|
254
|
+
setSelectedIndex(next);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (hist.length > 0) {
|
|
258
|
+
if (hIdx > 0) {
|
|
259
|
+
const nextIndex = hIdx - 1;
|
|
260
|
+
historyIndexRef.current = nextIndex;
|
|
261
|
+
setHistoryIndex(nextIndex);
|
|
262
|
+
const entry = hist[hist.length - 1 - nextIndex];
|
|
263
|
+
if (entry !== undefined) {
|
|
264
|
+
valueRef.current = entry;
|
|
265
|
+
cursorPosRef.current = entry.length;
|
|
266
|
+
onChangeRef.current(entry);
|
|
267
|
+
setCursorPos(entry.length);
|
|
268
|
+
}
|
|
269
|
+
} else if (hIdx === 0) {
|
|
270
|
+
historyIndexRef.current = -1;
|
|
271
|
+
setHistoryIndex(-1);
|
|
272
|
+
const saved = savedInput.current;
|
|
273
|
+
valueRef.current = saved;
|
|
274
|
+
cursorPosRef.current = saved.length;
|
|
275
|
+
onChangeRef.current(saved);
|
|
276
|
+
setCursorPos(saved.length);
|
|
205
277
|
}
|
|
206
|
-
onTabConsumedRef.current?.();
|
|
207
278
|
}
|
|
208
279
|
return;
|
|
209
280
|
}
|
|
210
281
|
|
|
211
|
-
// Reset tab cycle on any non-tab key
|
|
212
|
-
tabCycleRef.current = -1;
|
|
213
|
-
|
|
214
282
|
// Ignore other control keys
|
|
215
|
-
if (key.ctrl
|
|
283
|
+
if (key.ctrl) {
|
|
216
284
|
return;
|
|
217
285
|
}
|
|
218
286
|
|
|
@@ -239,36 +307,45 @@ export const InputBar = memo(function InputBar({
|
|
|
239
307
|
|
|
240
308
|
const isMultiline = value.includes("\n");
|
|
241
309
|
const placeholder = !value && !disabled;
|
|
310
|
+
const showPopup = !disabled && popupMatches !== null;
|
|
242
311
|
|
|
243
312
|
return (
|
|
244
|
-
<Box
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
<Text inverse={cursorVisible}>{value[cursorPos] ?? " "}</Text>
|
|
261
|
-
{value.slice(cursorPos + 1)}
|
|
262
|
-
</Text>
|
|
263
|
-
)}
|
|
264
|
-
</Box>
|
|
265
|
-
{isMultiline && (
|
|
313
|
+
<Box flexDirection="column">
|
|
314
|
+
{showPopup && popupMatches && (
|
|
315
|
+
<SlashCommandPopup
|
|
316
|
+
matches={popupMatches}
|
|
317
|
+
selectedIndex={selectedIndex}
|
|
318
|
+
/>
|
|
319
|
+
)}
|
|
320
|
+
<Box
|
|
321
|
+
flexDirection="column"
|
|
322
|
+
borderStyle="single"
|
|
323
|
+
borderColor={disabled ? "gray" : "green"}
|
|
324
|
+
paddingX={1}
|
|
325
|
+
>
|
|
326
|
+
{header}
|
|
327
|
+
{!disabled && (
|
|
328
|
+
<Box flexDirection="column">
|
|
266
329
|
<Box>
|
|
267
|
-
<Text
|
|
330
|
+
<Text color="green">{"› "}</Text>
|
|
331
|
+
{placeholder ? (
|
|
332
|
+
<Text dimColor>Type a message...</Text>
|
|
333
|
+
) : (
|
|
334
|
+
<Text>
|
|
335
|
+
{value.slice(0, cursorPos)}
|
|
336
|
+
<Text inverse={cursorVisible}>{value[cursorPos] ?? " "}</Text>
|
|
337
|
+
{value.slice(cursorPos + 1)}
|
|
338
|
+
</Text>
|
|
339
|
+
)}
|
|
268
340
|
</Box>
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
341
|
+
{isMultiline && (
|
|
342
|
+
<Box>
|
|
343
|
+
<Text dimColor> alt+return for newline, return to send</Text>
|
|
344
|
+
</Box>
|
|
345
|
+
)}
|
|
346
|
+
</Box>
|
|
347
|
+
)}
|
|
348
|
+
</Box>
|
|
272
349
|
</Box>
|
|
273
350
|
);
|
|
274
351
|
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Box, Text } from "ink";
|
|
2
|
+
import { memo } from "react";
|
|
3
|
+
import type { SlashCommand } from "../../skills/commands.ts";
|
|
4
|
+
|
|
5
|
+
interface SlashCommandPopupProps {
|
|
6
|
+
matches: SlashCommand[];
|
|
7
|
+
selectedIndex: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const SlashCommandPopup = memo(function SlashCommandPopup({
|
|
11
|
+
matches,
|
|
12
|
+
selectedIndex,
|
|
13
|
+
}: SlashCommandPopupProps) {
|
|
14
|
+
if (matches.length === 0) return null;
|
|
15
|
+
|
|
16
|
+
const nameWidth = matches.reduce(
|
|
17
|
+
(max, c) => Math.max(max, c.name.length + 1),
|
|
18
|
+
0,
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<Box
|
|
23
|
+
flexDirection="column"
|
|
24
|
+
borderStyle="round"
|
|
25
|
+
borderColor="gray"
|
|
26
|
+
paddingX={1}
|
|
27
|
+
>
|
|
28
|
+
{matches.map((cmd, i) => {
|
|
29
|
+
const active = i === selectedIndex;
|
|
30
|
+
const marker = active ? "›" : " ";
|
|
31
|
+
const padded = `/${cmd.name}`.padEnd(nameWidth + 1);
|
|
32
|
+
return (
|
|
33
|
+
<Box key={cmd.name}>
|
|
34
|
+
<Text color={active ? "green" : undefined} bold={active}>
|
|
35
|
+
{marker} {padded}
|
|
36
|
+
</Text>
|
|
37
|
+
<Text dimColor={!active}>
|
|
38
|
+
{cmd.description || "(no description)"}
|
|
39
|
+
</Text>
|
|
40
|
+
</Box>
|
|
41
|
+
);
|
|
42
|
+
})}
|
|
43
|
+
<Box marginTop={0}>
|
|
44
|
+
<Text dimColor>
|
|
45
|
+
↑↓ to navigate · tab/return to accept · esc to close
|
|
46
|
+
</Text>
|
|
47
|
+
</Box>
|
|
48
|
+
</Box>
|
|
49
|
+
);
|
|
50
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { SlashCommand } from "../skills/commands.ts";
|
|
2
|
+
|
|
3
|
+
export const MAX_VISIBLE_COMPLETIONS = 8;
|
|
4
|
+
|
|
5
|
+
const SLASH_QUERY = /^\/([\w-]*)$/;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Given the current input value, return the list of slash commands that
|
|
9
|
+
* should appear in the autocomplete popup. Returns `null` when the popup
|
|
10
|
+
* should not be shown at all (e.g. the input isn't a slash query, or the
|
|
11
|
+
* user has already typed a space to start writing arguments).
|
|
12
|
+
*/
|
|
13
|
+
export function getSlashMatches(
|
|
14
|
+
value: string,
|
|
15
|
+
commands: SlashCommand[],
|
|
16
|
+
): SlashCommand[] | null {
|
|
17
|
+
const match = SLASH_QUERY.exec(value);
|
|
18
|
+
if (!match) return null;
|
|
19
|
+
|
|
20
|
+
const query = (match[1] ?? "").toLowerCase();
|
|
21
|
+
const filtered = commands.filter((c) =>
|
|
22
|
+
c.name.toLowerCase().startsWith(query),
|
|
23
|
+
);
|
|
24
|
+
if (filtered.length === 0) return null;
|
|
25
|
+
|
|
26
|
+
return filtered.slice(0, MAX_VISIBLE_COMPLETIONS);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function buildSlashCommands(
|
|
30
|
+
builtins: SlashCommand[],
|
|
31
|
+
skills: Iterable<{ name: string; description: string }>,
|
|
32
|
+
): SlashCommand[] {
|
|
33
|
+
const out: SlashCommand[] = [...builtins];
|
|
34
|
+
for (const s of skills) {
|
|
35
|
+
out.push({ name: s.name, description: s.description });
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|