botholomew 0.15.0 → 0.15.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/package.json +3 -2
- package/src/chat/agent.ts +40 -0
- package/src/chat/usage.ts +69 -0
- package/src/commands/chat.ts +5 -1
- package/src/config/schemas.ts +2 -0
- package/src/context/fetcher.ts +1 -1
- package/src/context/store.ts +11 -5
- package/src/db/embeddings.ts +17 -0
- package/src/fs/sandbox.ts +31 -6
- package/src/tui/App.tsx +55 -13
- package/src/tui/components/ContextPanel.tsx +42 -1
- package/src/tui/components/DeleteArmedBanner.tsx +18 -0
- package/src/tui/components/HelpPanel.tsx +73 -6
- package/src/tui/components/InputBar.tsx +6 -4
- package/src/tui/components/Logo.tsx +15 -5
- package/src/tui/components/SchedulePanel.tsx +18 -25
- package/src/tui/components/SleepProgress.tsx +5 -2
- package/src/tui/components/StatusBar.tsx +9 -6
- package/src/tui/components/TabBar.tsx +29 -4
- package/src/tui/components/TaskPanel.tsx +18 -4
- package/src/tui/components/ThreadPanel.tsx +18 -26
- package/src/tui/components/WorkerPanel.tsx +38 -2
- package/src/tui/idle.tsx +68 -0
- package/src/tui/useDeleteConfirm.ts +115 -0
- package/src/utils/frontmatter.ts +1 -1
- package/src/workers/store.ts +24 -2
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "botholomew",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.2",
|
|
4
4
|
"description": "An autonomous AI agent for knowledge work — works your task queue while you sleep.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"botholomew": "./src/cli.ts"
|
|
7
|
+
"botholomew": "./src/cli.ts",
|
|
8
|
+
"bothy": "./src/cli.ts"
|
|
8
9
|
},
|
|
9
10
|
"files": [
|
|
10
11
|
"src",
|
package/src/chat/agent.ts
CHANGED
|
@@ -27,6 +27,11 @@ import {
|
|
|
27
27
|
STYLE_RULES,
|
|
28
28
|
} from "../worker/prompt.ts";
|
|
29
29
|
import type { ChatSession } from "./session.ts";
|
|
30
|
+
import {
|
|
31
|
+
type ContextUsage,
|
|
32
|
+
estimateTokens,
|
|
33
|
+
partitionMessages,
|
|
34
|
+
} from "./usage.ts";
|
|
30
35
|
|
|
31
36
|
registerAllTools();
|
|
32
37
|
|
|
@@ -156,6 +161,12 @@ export interface ChatTurnCallbacks {
|
|
|
156
161
|
* the entire tool loop to finish. Each returned message is logged + pushed
|
|
157
162
|
* to `messages` before the next `messages.stream(...)` call. */
|
|
158
163
|
takeInjections?: () => string[];
|
|
164
|
+
/** Fired after each finalized assistant turn with the prompt size the
|
|
165
|
+
* server billed for (sum of fresh, cache-read, and cache-creation input
|
|
166
|
+
* tokens), the model's max input tokens, and a local estimate of where
|
|
167
|
+
* the bytes went. The TUI uses this to render the tab-bar indicator and
|
|
168
|
+
* the breakdown shown on the Help tab. */
|
|
169
|
+
onUsage?: (info: ContextUsage) => void;
|
|
159
170
|
}
|
|
160
171
|
|
|
161
172
|
/**
|
|
@@ -243,6 +254,14 @@ export async function runChatTurn(input: {
|
|
|
243
254
|
config,
|
|
244
255
|
hasMcpTools: mcpxClient != null,
|
|
245
256
|
});
|
|
257
|
+
// Re-derive the persistent-context portion (prompts files) so the Help
|
|
258
|
+
// tab can show how much of the system prompt is user-authored prompts vs
|
|
259
|
+
// built-in instructions. Cheap — same FS read just hit by
|
|
260
|
+
// buildChatSystemPrompt is still hot.
|
|
261
|
+
const persistentContext = await loadPersistentContext(
|
|
262
|
+
projectDir,
|
|
263
|
+
keywordSource ? extractKeywords(keywordSource) : null,
|
|
264
|
+
);
|
|
246
265
|
|
|
247
266
|
fitToContextWindow(messages, systemPrompt, maxInputTokens);
|
|
248
267
|
const stream = client.messages.stream({
|
|
@@ -312,6 +331,27 @@ export async function runChatTurn(input: {
|
|
|
312
331
|
const durationMs = Date.now() - startTime;
|
|
313
332
|
const tokenCount =
|
|
314
333
|
response.usage.input_tokens + response.usage.output_tokens;
|
|
334
|
+
const promptTokens =
|
|
335
|
+
response.usage.input_tokens +
|
|
336
|
+
(response.usage.cache_read_input_tokens ?? 0) +
|
|
337
|
+
(response.usage.cache_creation_input_tokens ?? 0);
|
|
338
|
+
if (callbacks.onUsage) {
|
|
339
|
+
const { textChars, toolIoChars } = partitionMessages(messages);
|
|
340
|
+
const promptsChars = persistentContext.length;
|
|
341
|
+
const instructionsChars = Math.max(0, systemPrompt.length - promptsChars);
|
|
342
|
+
const toolsChars = JSON.stringify(chatTools).length;
|
|
343
|
+
callbacks.onUsage({
|
|
344
|
+
used: promptTokens,
|
|
345
|
+
max: maxInputTokens,
|
|
346
|
+
breakdown: {
|
|
347
|
+
prompts: estimateTokens(promptsChars),
|
|
348
|
+
instructions: estimateTokens(instructionsChars),
|
|
349
|
+
tools: estimateTokens(toolsChars),
|
|
350
|
+
messages: estimateTokens(textChars),
|
|
351
|
+
toolIo: estimateTokens(toolIoChars),
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
}
|
|
315
355
|
|
|
316
356
|
// Log assistant text
|
|
317
357
|
if (assistantText) {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { MessageParam } from "@anthropic-ai/sdk/resources/messages";
|
|
2
|
+
|
|
3
|
+
/** Rough Anthropic-style estimate: ~4 characters per token. */
|
|
4
|
+
const CHARS_PER_TOKEN = 4;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Estimate of where the prompt's bytes went on the most recent assistant
|
|
8
|
+
* turn. The five categories sum to roughly the server-billed input-tokens
|
|
9
|
+
* total — they're estimates derived from string length / 4, so they don't
|
|
10
|
+
* line up exactly with the API's count.
|
|
11
|
+
*/
|
|
12
|
+
export interface ContextBreakdown {
|
|
13
|
+
/** Persistent context files from `prompts/` (soul, beliefs, goals, capabilities, contextual). */
|
|
14
|
+
prompts: number;
|
|
15
|
+
/** Chat instructions block + MCP guidance + style rules + meta header. */
|
|
16
|
+
instructions: number;
|
|
17
|
+
/** Anthropic tool schemas (chat-allowed tools + MCP meta-tools). */
|
|
18
|
+
tools: number;
|
|
19
|
+
/** User and assistant text in the conversation history. */
|
|
20
|
+
messages: number;
|
|
21
|
+
/** `tool_use` and `tool_result` blocks accumulated during the conversation. */
|
|
22
|
+
toolIo: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ContextUsage {
|
|
26
|
+
/** Prompt tokens billed by the server (input + cache_read + cache_creation). */
|
|
27
|
+
used: number;
|
|
28
|
+
/** Model's max input tokens (from the Anthropic Models API). */
|
|
29
|
+
max: number;
|
|
30
|
+
/** Local estimates per section. */
|
|
31
|
+
breakdown: ContextBreakdown;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function estimateTokens(chars: number): number {
|
|
35
|
+
return Math.ceil(chars / CHARS_PER_TOKEN);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Walk a `messages` array and split chars into plain text vs. tool I/O. */
|
|
39
|
+
export function partitionMessages(messages: MessageParam[]): {
|
|
40
|
+
textChars: number;
|
|
41
|
+
toolIoChars: number;
|
|
42
|
+
} {
|
|
43
|
+
let textChars = 0;
|
|
44
|
+
let toolIoChars = 0;
|
|
45
|
+
for (const msg of messages) {
|
|
46
|
+
if (typeof msg.content === "string") {
|
|
47
|
+
textChars += msg.content.length;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (!Array.isArray(msg.content)) continue;
|
|
51
|
+
for (const block of msg.content) {
|
|
52
|
+
if (!("type" in block)) continue;
|
|
53
|
+
if (block.type === "text") {
|
|
54
|
+
textChars += block.text.length;
|
|
55
|
+
} else if (block.type === "tool_use") {
|
|
56
|
+
toolIoChars += JSON.stringify(block).length;
|
|
57
|
+
} else if (block.type === "tool_result") {
|
|
58
|
+
toolIoChars +=
|
|
59
|
+
typeof block.content === "string"
|
|
60
|
+
? block.content.length
|
|
61
|
+
: JSON.stringify(block.content).length;
|
|
62
|
+
} else {
|
|
63
|
+
// image, document, etc. — count under text for now.
|
|
64
|
+
textChars += JSON.stringify(block).length;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { textChars, toolIoChars };
|
|
69
|
+
}
|
package/src/commands/chat.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Command } from "commander";
|
|
2
|
+
import { loadConfig } from "../config/loader.ts";
|
|
2
3
|
|
|
3
4
|
export function registerChatCommand(program: Command) {
|
|
4
5
|
program
|
|
@@ -7,7 +8,7 @@ export function registerChatCommand(program: Command) {
|
|
|
7
8
|
"Open the interactive chat TUI\n\n" +
|
|
8
9
|
" Tab navigation (Ctrl+<letter> from any tab):\n" +
|
|
9
10
|
" Ctrl+a Chat Ctrl+t Tasks Ctrl+w Workers\n" +
|
|
10
|
-
" Ctrl+o Tools Ctrl+r Threads
|
|
11
|
+
" Ctrl+o Tools Ctrl+r Threads Ctrl+g Help\n" +
|
|
11
12
|
" Ctrl+n Context Ctrl+s Schedules Esc Return to Chat\n\n" +
|
|
12
13
|
" Chat input:\n" +
|
|
13
14
|
" Enter Send message\n" +
|
|
@@ -29,6 +30,8 @@ export function registerChatCommand(program: Command) {
|
|
|
29
30
|
const React = await import("react");
|
|
30
31
|
const { App } = await import("../tui/App.tsx");
|
|
31
32
|
const dir = program.opts().dir;
|
|
33
|
+
const config = await loadConfig(dir);
|
|
34
|
+
const idleTimeoutMs = config.tui_idle_timeout_seconds * 1000;
|
|
32
35
|
|
|
33
36
|
// VHS/ttyd doesn't fully negotiate the Kitty Keyboard protocol, so
|
|
34
37
|
// Ink's "enabled" mode drops non-text keystrokes (Tab, Escape) under
|
|
@@ -41,6 +44,7 @@ export function registerChatCommand(program: Command) {
|
|
|
41
44
|
projectDir: dir,
|
|
42
45
|
threadId: opts.threadId,
|
|
43
46
|
initialPrompt: opts.prompt,
|
|
47
|
+
idleTimeoutMs,
|
|
44
48
|
}),
|
|
45
49
|
{
|
|
46
50
|
exitOnCtrlC: false,
|
package/src/config/schemas.ts
CHANGED
|
@@ -14,6 +14,7 @@ export interface BotholomewConfig {
|
|
|
14
14
|
worker_stopped_retention_seconds?: number;
|
|
15
15
|
schedule_min_interval_seconds?: number;
|
|
16
16
|
schedule_claim_stale_seconds?: number;
|
|
17
|
+
tui_idle_timeout_seconds?: number;
|
|
17
18
|
log_level?: string;
|
|
18
19
|
}
|
|
19
20
|
|
|
@@ -33,5 +34,6 @@ export const DEFAULT_CONFIG: Required<BotholomewConfig> = {
|
|
|
33
34
|
worker_stopped_retention_seconds: 3600,
|
|
34
35
|
schedule_min_interval_seconds: 60,
|
|
35
36
|
schedule_claim_stale_seconds: 300,
|
|
37
|
+
tui_idle_timeout_seconds: 180,
|
|
36
38
|
log_level: "",
|
|
37
39
|
};
|
package/src/context/fetcher.ts
CHANGED
|
@@ -39,7 +39,7 @@ export interface FetchedContent {
|
|
|
39
39
|
/**
|
|
40
40
|
* MCP server that produced the content (e.g. "google-docs", "github",
|
|
41
41
|
* "firecrawl"), or null when we fell back to a plain HTTP fetch. Useful
|
|
42
|
-
* for `
|
|
42
|
+
* for `botholomew context import` to pick a default destination subdirectory.
|
|
43
43
|
*/
|
|
44
44
|
source: string | null;
|
|
45
45
|
}
|
package/src/context/store.ts
CHANGED
|
@@ -171,14 +171,17 @@ export function normalizeContextPath(path: string): string {
|
|
|
171
171
|
* or attempts to resolve into a protected area.
|
|
172
172
|
*
|
|
173
173
|
* `allowSymlinks` is the opt-in for read-side callers (read, list, tree,
|
|
174
|
-
* info, reindex
|
|
175
|
-
*
|
|
176
|
-
*
|
|
174
|
+
* info, reindex). Mutating callers (write, edit, mv, cp, mkdir) leave it
|
|
175
|
+
* `false` so user-placed symlinks under `context/` cannot be traversed to
|
|
176
|
+
* modify external content. `allowSymlinkLeaf` is the narrower opt-in for
|
|
177
|
+
* `delete`: the leaf may be a symlink (so the agent can unlink it) but
|
|
178
|
+
* parent components may not, so a delete cannot reach external content
|
|
179
|
+
* through a symlinked parent directory.
|
|
177
180
|
*/
|
|
178
181
|
async function resolveContext(
|
|
179
182
|
projectDir: string,
|
|
180
183
|
path: string,
|
|
181
|
-
opts: { allowSymlinks?: boolean } = {},
|
|
184
|
+
opts: { allowSymlinks?: boolean; allowSymlinkLeaf?: boolean } = {},
|
|
182
185
|
): Promise<string> {
|
|
183
186
|
const normalized = normalizeContextPath(path);
|
|
184
187
|
if (PROTECTED_AREAS.has(normalized)) {
|
|
@@ -190,6 +193,7 @@ async function resolveContext(
|
|
|
190
193
|
return resolveInRoot(projectDir, fromPosix(normalized), {
|
|
191
194
|
area: CONTEXT_DIR,
|
|
192
195
|
allowSymlinks: opts.allowSymlinks,
|
|
196
|
+
allowSymlinkLeaf: opts.allowSymlinkLeaf,
|
|
193
197
|
});
|
|
194
198
|
}
|
|
195
199
|
|
|
@@ -343,7 +347,9 @@ export async function deleteContextPath(
|
|
|
343
347
|
path: string,
|
|
344
348
|
opts: { recursive?: boolean } = {},
|
|
345
349
|
): Promise<{ removed: number; was_directory: boolean; was_symlink: boolean }> {
|
|
346
|
-
const abs = await resolveContext(projectDir, path, {
|
|
350
|
+
const abs = await resolveContext(projectDir, path, {
|
|
351
|
+
allowSymlinkLeaf: true,
|
|
352
|
+
});
|
|
347
353
|
const normalized = normalizeContextPath(path);
|
|
348
354
|
if (normalized === "") {
|
|
349
355
|
throw new PathEscapeError("refusing to delete the context root", path);
|
package/src/db/embeddings.ts
CHANGED
|
@@ -94,6 +94,23 @@ export async function deleteIndexedPath(
|
|
|
94
94
|
return result.changes;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Remove every indexed entry whose path equals `prefix` or lives beneath
|
|
99
|
+
* `prefix/`. Used when a folder is deleted from `context/` and we need to
|
|
100
|
+
* drop all child entries in one shot.
|
|
101
|
+
*/
|
|
102
|
+
export async function deleteIndexedPathsUnder(
|
|
103
|
+
conn: DbConnection,
|
|
104
|
+
prefix: string,
|
|
105
|
+
): Promise<number> {
|
|
106
|
+
const result = await conn.queryRun(
|
|
107
|
+
"DELETE FROM context_index WHERE path = ?1 OR path LIKE ?2",
|
|
108
|
+
prefix,
|
|
109
|
+
`${prefix}/%`,
|
|
110
|
+
);
|
|
111
|
+
return result.changes;
|
|
112
|
+
}
|
|
113
|
+
|
|
97
114
|
export interface IndexedPathSummary {
|
|
98
115
|
path: string;
|
|
99
116
|
content_hash: string;
|
package/src/fs/sandbox.ts
CHANGED
|
@@ -36,6 +36,15 @@ export interface SandboxOptions {
|
|
|
36
36
|
* content.
|
|
37
37
|
*/
|
|
38
38
|
allowSymlinks?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Permit a symlink only as the final path component. Parent components
|
|
41
|
+
* are still lstat-walked and rejected if any is a symlink. This is the
|
|
42
|
+
* mode for mutating callers that intentionally operate on a symlink
|
|
43
|
+
* leaf (e.g., `deleteContextPath` unlinking a user-placed symlink) but
|
|
44
|
+
* must not be coaxed into reaching outside content via a symlinked
|
|
45
|
+
* parent directory.
|
|
46
|
+
*/
|
|
47
|
+
allowSymlinkLeaf?: boolean;
|
|
39
48
|
}
|
|
40
49
|
|
|
41
50
|
let cachedCanonicalRoot: string | null = null;
|
|
@@ -97,7 +106,11 @@ export async function resolveInRoot(
|
|
|
97
106
|
ensureContainment(resolved, boundary, opts.allowRoot ?? true, userPath);
|
|
98
107
|
|
|
99
108
|
if (!opts.allowSymlinks) {
|
|
100
|
-
await assertNoSymlinkComponents(
|
|
109
|
+
await assertNoSymlinkComponents(
|
|
110
|
+
resolved,
|
|
111
|
+
canonicalRoot,
|
|
112
|
+
opts.allowSymlinkLeaf ?? false,
|
|
113
|
+
);
|
|
101
114
|
}
|
|
102
115
|
return resolved;
|
|
103
116
|
}
|
|
@@ -122,7 +135,11 @@ export function resolveInRootSync(
|
|
|
122
135
|
ensureContainment(resolved, boundary, opts.allowRoot ?? true, userPath);
|
|
123
136
|
|
|
124
137
|
if (!opts.allowSymlinks) {
|
|
125
|
-
assertNoSymlinkComponentsSync(
|
|
138
|
+
assertNoSymlinkComponentsSync(
|
|
139
|
+
resolved,
|
|
140
|
+
canonicalRoot,
|
|
141
|
+
opts.allowSymlinkLeaf ?? false,
|
|
142
|
+
);
|
|
126
143
|
}
|
|
127
144
|
return resolved;
|
|
128
145
|
}
|
|
@@ -181,19 +198,24 @@ function ensureContainment(
|
|
|
181
198
|
async function assertNoSymlinkComponents(
|
|
182
199
|
resolved: string,
|
|
183
200
|
canonicalRoot: string,
|
|
201
|
+
allowLeaf: boolean,
|
|
184
202
|
): Promise<void> {
|
|
185
203
|
// Walk from canonical root toward the leaf, lstat'ing each existing
|
|
186
204
|
// component. The root itself is canonical (already realpath'd) so we skip
|
|
187
205
|
// it; we only care that nothing the agent writes goes through a symlink.
|
|
206
|
+
// When `allowLeaf` is true, the final component may itself be a symlink
|
|
207
|
+
// (e.g., delete unlinking a user-placed symlink) — only parents are checked.
|
|
188
208
|
const rel = resolved.slice(canonicalRoot.length);
|
|
189
209
|
if (!rel || rel === sep) return;
|
|
190
210
|
const parts = rel.split(sep).filter((p) => p.length > 0);
|
|
191
211
|
let current = canonicalRoot;
|
|
192
|
-
for (
|
|
193
|
-
current = current + sep +
|
|
212
|
+
for (let i = 0; i < parts.length; i++) {
|
|
213
|
+
current = current + sep + parts[i];
|
|
214
|
+
const isLeaf = i === parts.length - 1;
|
|
194
215
|
try {
|
|
195
216
|
const st = await lstat(current);
|
|
196
217
|
if (st.isSymbolicLink()) {
|
|
218
|
+
if (isLeaf && allowLeaf) continue;
|
|
197
219
|
throw new PathEscapeError(
|
|
198
220
|
`path traverses a symlink: ${current}`,
|
|
199
221
|
resolved,
|
|
@@ -214,16 +236,19 @@ async function assertNoSymlinkComponents(
|
|
|
214
236
|
function assertNoSymlinkComponentsSync(
|
|
215
237
|
resolved: string,
|
|
216
238
|
canonicalRoot: string,
|
|
239
|
+
allowLeaf: boolean,
|
|
217
240
|
): void {
|
|
218
241
|
const rel = resolved.slice(canonicalRoot.length);
|
|
219
242
|
if (!rel || rel === sep) return;
|
|
220
243
|
const parts = rel.split(sep).filter((p) => p.length > 0);
|
|
221
244
|
let current = canonicalRoot;
|
|
222
|
-
for (
|
|
223
|
-
current = current + sep +
|
|
245
|
+
for (let i = 0; i < parts.length; i++) {
|
|
246
|
+
current = current + sep + parts[i];
|
|
247
|
+
const isLeaf = i === parts.length - 1;
|
|
224
248
|
try {
|
|
225
249
|
const st = lstatSync(current);
|
|
226
250
|
if (st.isSymbolicLink()) {
|
|
251
|
+
if (isLeaf && allowLeaf) continue;
|
|
227
252
|
throw new PathEscapeError(
|
|
228
253
|
`path traverses a symlink: ${current}`,
|
|
229
254
|
resolved,
|
package/src/tui/App.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
sendMessage,
|
|
9
9
|
startChatSession,
|
|
10
10
|
} from "../chat/session.ts";
|
|
11
|
+
import type { ContextUsage } from "../chat/usage.ts";
|
|
11
12
|
import {
|
|
12
13
|
BUILTIN_SLASH_COMMANDS,
|
|
13
14
|
handleSlashCommand,
|
|
@@ -33,6 +34,7 @@ import { ThreadPanel } from "./components/ThreadPanel.tsx";
|
|
|
33
34
|
import type { ToolCallData } from "./components/ToolCall.tsx";
|
|
34
35
|
import { ToolPanel } from "./components/ToolPanel.tsx";
|
|
35
36
|
import { WorkerPanel } from "./components/WorkerPanel.tsx";
|
|
37
|
+
import { IdleProvider, useIdle } from "./idle.tsx";
|
|
36
38
|
import { buildSlashCommands, getSlashMatches } from "./slashCompletion.ts";
|
|
37
39
|
import { ansi } from "./theme.ts";
|
|
38
40
|
|
|
@@ -40,6 +42,7 @@ interface AppProps {
|
|
|
40
42
|
projectDir: string;
|
|
41
43
|
threadId?: string;
|
|
42
44
|
initialPrompt?: string;
|
|
45
|
+
idleTimeoutMs: number;
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
let nextMsgId = 0;
|
|
@@ -49,9 +52,14 @@ function msgId(): string {
|
|
|
49
52
|
|
|
50
53
|
// Tab routing: Ctrl+<letter> jumps to a tab. Chosen for memorability — first
|
|
51
54
|
// available letter that doesn't collide with other Ctrl bindings (Ctrl+C exit,
|
|
52
|
-
// Ctrl+J/K/X/E queue ops on Chat).
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
+
// Ctrl+J/K/X/E queue ops on Chat).
|
|
56
|
+
//
|
|
57
|
+
// Help is bound to Ctrl+G rather than Ctrl+H because most terminals deliver
|
|
58
|
+
// Ctrl+H as ASCII 0x08 (backspace). Bonus: macOS Terminal.app and several
|
|
59
|
+
// other terminals map Ctrl+/ to BEL (0x07), the same byte as Ctrl+G — so this
|
|
60
|
+
// binding also catches the Ctrl+/ keystroke on those terminals "for free".
|
|
61
|
+
// We also accept "/" and "_" as fallbacks for terminals that deliver Ctrl+/
|
|
62
|
+
// as 0x1F or as the literal "/" with ctrl=true (Kitty keyboard protocol).
|
|
55
63
|
const TAB_BY_CTRL_KEY: Record<string, TabId> = {
|
|
56
64
|
a: 1, // ch[a]t
|
|
57
65
|
o: 2, // t[o]ols
|
|
@@ -60,6 +68,9 @@ const TAB_BY_CTRL_KEY: Record<string, TabId> = {
|
|
|
60
68
|
r: 5, // th[r]eads
|
|
61
69
|
s: 6, // [s]chedules
|
|
62
70
|
w: 7, // [w]orkers
|
|
71
|
+
g: 8, // help (also catches Ctrl+/ on terminals that map it to BEL)
|
|
72
|
+
"/": 8, // help (Kitty keyboard protocol)
|
|
73
|
+
_: 8, // help (terminals that send Ctrl+/ as 0x1F)
|
|
63
74
|
};
|
|
64
75
|
|
|
65
76
|
function detectToolError(output: string | undefined): boolean {
|
|
@@ -138,10 +149,35 @@ export function App({
|
|
|
138
149
|
projectDir,
|
|
139
150
|
threadId: resumeThreadId,
|
|
140
151
|
initialPrompt,
|
|
152
|
+
idleTimeoutMs,
|
|
141
153
|
}: AppProps) {
|
|
154
|
+
return (
|
|
155
|
+
<IdleProvider timeoutMs={idleTimeoutMs}>
|
|
156
|
+
<AppInner
|
|
157
|
+
projectDir={projectDir}
|
|
158
|
+
threadId={resumeThreadId}
|
|
159
|
+
initialPrompt={initialPrompt}
|
|
160
|
+
/>
|
|
161
|
+
</IdleProvider>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
interface AppInnerProps {
|
|
166
|
+
projectDir: string;
|
|
167
|
+
threadId?: string;
|
|
168
|
+
initialPrompt?: string;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function AppInner({
|
|
172
|
+
projectDir,
|
|
173
|
+
threadId: resumeThreadId,
|
|
174
|
+
initialPrompt,
|
|
175
|
+
}: AppInnerProps) {
|
|
142
176
|
const { exit } = useApp();
|
|
177
|
+
const { markActivity } = useIdle();
|
|
143
178
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
144
179
|
const [messagesEpoch, setMessagesEpoch] = useState(0);
|
|
180
|
+
const [usage, setUsage] = useState<ContextUsage | null>(null);
|
|
145
181
|
const [inputValue, setInputValue] = useState("");
|
|
146
182
|
const [inputHistory, setInputHistory] = useState<string[]>([]);
|
|
147
183
|
const [isLoading, setIsLoading] = useState(false);
|
|
@@ -265,9 +301,14 @@ export function App({
|
|
|
265
301
|
const slashCommandsRef = useRef<SlashCommand[]>([]);
|
|
266
302
|
const inputValueRef = useRef("");
|
|
267
303
|
|
|
304
|
+
const markActivityRef = useRef(markActivity);
|
|
305
|
+
markActivityRef.current = markActivity;
|
|
306
|
+
|
|
268
307
|
const stableAppHandler = useCallback(
|
|
269
308
|
// biome-ignore lint/suspicious/noExplicitAny: Ink's Key type is not exported
|
|
270
309
|
(input: string, key: any) => {
|
|
310
|
+
markActivityRef.current();
|
|
311
|
+
|
|
271
312
|
// Ctrl+C exits
|
|
272
313
|
if (input === "c" && key.ctrl) {
|
|
273
314
|
exit();
|
|
@@ -293,14 +334,6 @@ export function App({
|
|
|
293
334
|
}
|
|
294
335
|
}
|
|
295
336
|
|
|
296
|
-
// `?` jumps to the Help tab from any non-chat tab (on Chat, `?` is
|
|
297
|
-
// a regular character). Acts as the Help shortcut since Ctrl+H is
|
|
298
|
-
// unusable — most terminals send it as backspace.
|
|
299
|
-
if (input === "?" && activeTabRef.current !== 1) {
|
|
300
|
-
setActiveTab(8);
|
|
301
|
-
return;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
337
|
const tab = activeTabRef.current;
|
|
305
338
|
|
|
306
339
|
// Esc on Chat tab while a turn is in flight: steer / interrupt.
|
|
@@ -407,12 +440,15 @@ export function App({
|
|
|
407
440
|
if (now - lastStreamFlush >= 50) {
|
|
408
441
|
setStreamingText(currentText);
|
|
409
442
|
lastStreamFlush = now;
|
|
443
|
+
markActivityRef.current();
|
|
410
444
|
}
|
|
411
445
|
},
|
|
412
446
|
onToolPreparing: (id, name) => {
|
|
447
|
+
markActivityRef.current();
|
|
413
448
|
setPreparingTool({ id, name });
|
|
414
449
|
},
|
|
415
450
|
onToolStart: (id, name, input) => {
|
|
451
|
+
markActivityRef.current();
|
|
416
452
|
if (currentText) {
|
|
417
453
|
finalizeSegment();
|
|
418
454
|
}
|
|
@@ -428,6 +464,7 @@ export function App({
|
|
|
428
464
|
setPreparingTool(null);
|
|
429
465
|
},
|
|
430
466
|
onToolEnd: (id, _name, output, isError, meta) => {
|
|
467
|
+
markActivityRef.current();
|
|
431
468
|
const tc = pendingToolCalls.find((t) => t.id === id);
|
|
432
469
|
if (tc) {
|
|
433
470
|
tc.running = false;
|
|
@@ -439,6 +476,9 @@ export function App({
|
|
|
439
476
|
}
|
|
440
477
|
setActiveToolCalls([...pendingToolCalls]);
|
|
441
478
|
},
|
|
479
|
+
onUsage: (info) => {
|
|
480
|
+
setUsage(info);
|
|
481
|
+
},
|
|
442
482
|
takeInjections: () => {
|
|
443
483
|
// Drain queued messages into the running turn so the agent sees
|
|
444
484
|
// them on the next LLM call instead of after the whole tool loop.
|
|
@@ -535,7 +575,7 @@ export function App({
|
|
|
535
575
|
if (trimmed === "/help") {
|
|
536
576
|
const skills = sessionRef.current.skills;
|
|
537
577
|
const lines: string[] = [
|
|
538
|
-
"For the full keyboard reference, switch to the Help tab (
|
|
578
|
+
"For the full keyboard reference, switch to the Help tab (`Ctrl+g`) — this message lists chat commands only.",
|
|
539
579
|
"",
|
|
540
580
|
"Slash commands:",
|
|
541
581
|
" /help Show this message",
|
|
@@ -628,6 +668,7 @@ export function App({
|
|
|
628
668
|
setStreamingText("");
|
|
629
669
|
setActiveToolCalls([]);
|
|
630
670
|
setPreparingTool(null);
|
|
671
|
+
setUsage(null);
|
|
631
672
|
} catch (err) {
|
|
632
673
|
setMessages((prev) => [
|
|
633
674
|
...prev,
|
|
@@ -796,6 +837,7 @@ export function App({
|
|
|
796
837
|
projectDir={projectDir}
|
|
797
838
|
threadId={threadId}
|
|
798
839
|
workerRunning={workerRunning}
|
|
840
|
+
usage={usage}
|
|
799
841
|
/>
|
|
800
842
|
</Box>
|
|
801
843
|
|
|
@@ -817,7 +859,7 @@ export function App({
|
|
|
817
859
|
header={inputBarHeader}
|
|
818
860
|
slashCommands={slashCommands}
|
|
819
861
|
/>
|
|
820
|
-
<TabBar activeTab={activeTab} />
|
|
862
|
+
<TabBar activeTab={activeTab} usage={usage} />
|
|
821
863
|
</Box>
|
|
822
864
|
);
|
|
823
865
|
}
|
|
@@ -3,13 +3,17 @@ import { memo, useCallback, useEffect, useMemo, useState } from "react";
|
|
|
3
3
|
import { getDbPath } from "../../constants.ts";
|
|
4
4
|
import {
|
|
5
5
|
type ContextEntry,
|
|
6
|
+
deleteContextPath,
|
|
6
7
|
listContextDir,
|
|
7
8
|
readContextFile,
|
|
8
9
|
} from "../../context/store.ts";
|
|
9
10
|
import { withDb } from "../../db/connection.ts";
|
|
10
11
|
import {
|
|
12
|
+
deleteIndexedPath,
|
|
13
|
+
deleteIndexedPathsUnder,
|
|
11
14
|
getIndexedPath,
|
|
12
15
|
type IndexedPathSummary,
|
|
16
|
+
rebuildSearchIndex,
|
|
13
17
|
} from "../../db/embeddings.ts";
|
|
14
18
|
import {
|
|
15
19
|
detailPaneBorderProps,
|
|
@@ -18,7 +22,9 @@ import {
|
|
|
18
22
|
} from "../listDetailKeys.ts";
|
|
19
23
|
import { isMarkdownPath, renderMarkdown } from "../markdown.ts";
|
|
20
24
|
import { theme } from "../theme.ts";
|
|
25
|
+
import { useDeleteConfirm } from "../useDeleteConfirm.ts";
|
|
21
26
|
import { useLatestRef } from "../useLatestRef.ts";
|
|
27
|
+
import { DeleteArmedBanner } from "./DeleteArmedBanner.tsx";
|
|
22
28
|
import { Scrollbar } from "./Scrollbar.tsx";
|
|
23
29
|
|
|
24
30
|
interface ContextPanelProps {
|
|
@@ -163,8 +169,33 @@ export const ContextPanel = memo(function ContextPanel({
|
|
|
163
169
|
const currentPathRef = useLatestRef(currentPath);
|
|
164
170
|
const focusRef = useLatestRef(focus);
|
|
165
171
|
|
|
172
|
+
const deleteConfirm = useDeleteConfirm(() => {
|
|
173
|
+
const entry = selectedEntryRef.current;
|
|
174
|
+
if (!entry) return;
|
|
175
|
+
const path = entry.path;
|
|
176
|
+
const isDirectory = entry.is_directory;
|
|
177
|
+
(async () => {
|
|
178
|
+
try {
|
|
179
|
+
await deleteContextPath(projectDir, path, { recursive: isDirectory });
|
|
180
|
+
await withDb(getDbPath(projectDir), async (conn) => {
|
|
181
|
+
if (isDirectory) {
|
|
182
|
+
await deleteIndexedPathsUnder(conn, path);
|
|
183
|
+
} else {
|
|
184
|
+
await deleteIndexedPath(conn, path);
|
|
185
|
+
}
|
|
186
|
+
await rebuildSearchIndex(conn);
|
|
187
|
+
});
|
|
188
|
+
} catch {
|
|
189
|
+
// ignore — refresh will reflect any partial state
|
|
190
|
+
}
|
|
191
|
+
refresh(currentPathRef.current);
|
|
192
|
+
})();
|
|
193
|
+
});
|
|
194
|
+
|
|
166
195
|
useInput(
|
|
167
196
|
(input, key) => {
|
|
197
|
+
if (input !== "d") deleteConfirm.cancel();
|
|
198
|
+
|
|
168
199
|
if (
|
|
169
200
|
handleListDetailKey(input, key, {
|
|
170
201
|
focusRef,
|
|
@@ -199,6 +230,12 @@ export const ContextPanel = memo(function ContextPanel({
|
|
|
199
230
|
return;
|
|
200
231
|
}
|
|
201
232
|
|
|
233
|
+
if (input === "d") {
|
|
234
|
+
const entry = selectedEntryRef.current;
|
|
235
|
+
if (!entry) return;
|
|
236
|
+
deleteConfirm.pressDelete(entry.path);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
202
239
|
if (input === "r") {
|
|
203
240
|
refresh(currentPathRef.current);
|
|
204
241
|
}
|
|
@@ -322,11 +359,15 @@ export const ContextPanel = memo(function ContextPanel({
|
|
|
322
359
|
) : (
|
|
323
360
|
<Text dimColor>(no item selected)</Text>
|
|
324
361
|
)}
|
|
362
|
+
<DeleteArmedBanner
|
|
363
|
+
armed={deleteConfirm.armed}
|
|
364
|
+
label={deleteConfirm.armedLabel}
|
|
365
|
+
/>
|
|
325
366
|
<Box>
|
|
326
367
|
<Text dimColor>
|
|
327
368
|
{focus === "detail"
|
|
328
369
|
? "↑↓ scroll · ⇧↑↓ page · g/G top/bot · ← back to list"
|
|
329
|
-
: "↑↓ select · → drill in/enter detail · ← up · r refresh"}
|
|
370
|
+
: "↑↓ select · → drill in/enter detail · ← up · d delete (×2) · r refresh"}
|
|
330
371
|
</Text>
|
|
331
372
|
</Box>
|
|
332
373
|
</Box>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Box, Text } from "ink";
|
|
2
|
+
import { theme } from "../theme.ts";
|
|
3
|
+
|
|
4
|
+
interface DeleteArmedBannerProps {
|
|
5
|
+
armed: boolean;
|
|
6
|
+
label: string | null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function DeleteArmedBanner({ armed, label }: DeleteArmedBannerProps) {
|
|
10
|
+
if (!armed) return null;
|
|
11
|
+
return (
|
|
12
|
+
<Box>
|
|
13
|
+
<Text color={theme.error} bold>
|
|
14
|
+
⚠ Press d again to delete {label ?? ""} (any other key cancels)
|
|
15
|
+
</Text>
|
|
16
|
+
</Box>
|
|
17
|
+
);
|
|
18
|
+
}
|