botholomew 0.15.1 → 0.15.3
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/package.json +1 -1
- package/src/chat/agent.ts +40 -0
- package/src/chat/usage.ts +69 -0
- package/src/commands/chat.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 +20 -13
- package/src/tui/components/ContextPanel.tsx +43 -5
- package/src/tui/components/DeleteArmedBanner.tsx +18 -0
- package/src/tui/components/HelpPanel.tsx +73 -6
- package/src/tui/components/SchedulePanel.tsx +19 -29
- package/src/tui/components/TabBar.tsx +29 -4
- package/src/tui/components/TaskPanel.tsx +19 -8
- package/src/tui/components/ThreadPanel.tsx +19 -30
- package/src/tui/components/WorkerPanel.tsx +38 -2
- package/src/tui/theme.ts +1 -0
- package/src/tui/useDeleteConfirm.ts +115 -0
- package/src/workers/store.ts +24 -2
package/package.json
CHANGED
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
|
@@ -8,7 +8,7 @@ export function registerChatCommand(program: Command) {
|
|
|
8
8
|
"Open the interactive chat TUI\n\n" +
|
|
9
9
|
" Tab navigation (Ctrl+<letter> from any tab):\n" +
|
|
10
10
|
" Ctrl+a Chat Ctrl+t Tasks Ctrl+w Workers\n" +
|
|
11
|
-
" Ctrl+o Tools Ctrl+r Threads
|
|
11
|
+
" Ctrl+o Tools Ctrl+r Threads Ctrl+g Help\n" +
|
|
12
12
|
" Ctrl+n Context Ctrl+s Schedules Esc Return to Chat\n\n" +
|
|
13
13
|
" Chat input:\n" +
|
|
14
14
|
" Enter Send message\n" +
|
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,
|
|
@@ -51,9 +52,14 @@ function msgId(): string {
|
|
|
51
52
|
|
|
52
53
|
// Tab routing: Ctrl+<letter> jumps to a tab. Chosen for memorability — first
|
|
53
54
|
// available letter that doesn't collide with other Ctrl bindings (Ctrl+C exit,
|
|
54
|
-
// Ctrl+J/K/X/E queue ops on Chat).
|
|
55
|
-
//
|
|
56
|
-
//
|
|
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).
|
|
57
63
|
const TAB_BY_CTRL_KEY: Record<string, TabId> = {
|
|
58
64
|
a: 1, // ch[a]t
|
|
59
65
|
o: 2, // t[o]ols
|
|
@@ -62,6 +68,9 @@ const TAB_BY_CTRL_KEY: Record<string, TabId> = {
|
|
|
62
68
|
r: 5, // th[r]eads
|
|
63
69
|
s: 6, // [s]chedules
|
|
64
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)
|
|
65
74
|
};
|
|
66
75
|
|
|
67
76
|
function detectToolError(output: string | undefined): boolean {
|
|
@@ -168,6 +177,7 @@ function AppInner({
|
|
|
168
177
|
const { markActivity } = useIdle();
|
|
169
178
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
170
179
|
const [messagesEpoch, setMessagesEpoch] = useState(0);
|
|
180
|
+
const [usage, setUsage] = useState<ContextUsage | null>(null);
|
|
171
181
|
const [inputValue, setInputValue] = useState("");
|
|
172
182
|
const [inputHistory, setInputHistory] = useState<string[]>([]);
|
|
173
183
|
const [isLoading, setIsLoading] = useState(false);
|
|
@@ -324,14 +334,6 @@ function AppInner({
|
|
|
324
334
|
}
|
|
325
335
|
}
|
|
326
336
|
|
|
327
|
-
// `?` jumps to the Help tab from any non-chat tab (on Chat, `?` is
|
|
328
|
-
// a regular character). Acts as the Help shortcut since Ctrl+H is
|
|
329
|
-
// unusable — most terminals send it as backspace.
|
|
330
|
-
if (input === "?" && activeTabRef.current !== 1) {
|
|
331
|
-
setActiveTab(8);
|
|
332
|
-
return;
|
|
333
|
-
}
|
|
334
|
-
|
|
335
337
|
const tab = activeTabRef.current;
|
|
336
338
|
|
|
337
339
|
// Esc on Chat tab while a turn is in flight: steer / interrupt.
|
|
@@ -474,6 +476,9 @@ function AppInner({
|
|
|
474
476
|
}
|
|
475
477
|
setActiveToolCalls([...pendingToolCalls]);
|
|
476
478
|
},
|
|
479
|
+
onUsage: (info) => {
|
|
480
|
+
setUsage(info);
|
|
481
|
+
},
|
|
477
482
|
takeInjections: () => {
|
|
478
483
|
// Drain queued messages into the running turn so the agent sees
|
|
479
484
|
// them on the next LLM call instead of after the whole tool loop.
|
|
@@ -570,7 +575,7 @@ function AppInner({
|
|
|
570
575
|
if (trimmed === "/help") {
|
|
571
576
|
const skills = sessionRef.current.skills;
|
|
572
577
|
const lines: string[] = [
|
|
573
|
-
"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.",
|
|
574
579
|
"",
|
|
575
580
|
"Slash commands:",
|
|
576
581
|
" /help Show this message",
|
|
@@ -663,6 +668,7 @@ function AppInner({
|
|
|
663
668
|
setStreamingText("");
|
|
664
669
|
setActiveToolCalls([]);
|
|
665
670
|
setPreparingTool(null);
|
|
671
|
+
setUsage(null);
|
|
666
672
|
} catch (err) {
|
|
667
673
|
setMessages((prev) => [
|
|
668
674
|
...prev,
|
|
@@ -831,6 +837,7 @@ function AppInner({
|
|
|
831
837
|
projectDir={projectDir}
|
|
832
838
|
threadId={threadId}
|
|
833
839
|
workerRunning={workerRunning}
|
|
840
|
+
usage={usage}
|
|
834
841
|
/>
|
|
835
842
|
</Box>
|
|
836
843
|
|
|
@@ -852,7 +859,7 @@ function AppInner({
|
|
|
852
859
|
header={inputBarHeader}
|
|
853
860
|
slashCommands={slashCommands}
|
|
854
861
|
/>
|
|
855
|
-
<TabBar activeTab={activeTab} />
|
|
862
|
+
<TabBar activeTab={activeTab} usage={usage} />
|
|
856
863
|
</Box>
|
|
857
864
|
);
|
|
858
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>
|
|
@@ -359,7 +400,7 @@ function ContextDetailHeader({
|
|
|
359
400
|
indexLoaded: boolean;
|
|
360
401
|
}) {
|
|
361
402
|
return (
|
|
362
|
-
<Box flexDirection="column">
|
|
403
|
+
<Box flexDirection="column" width="100%" backgroundColor={theme.headerBg}>
|
|
363
404
|
<Box>
|
|
364
405
|
<Text bold color="cyan" wrap="truncate-end">
|
|
365
406
|
{entry.is_directory ? "📁" : "📄"} context/{entry.path}
|
|
@@ -400,9 +441,6 @@ function ContextDetailHeader({
|
|
|
400
441
|
</Box>
|
|
401
442
|
</>
|
|
402
443
|
)}
|
|
403
|
-
<Box>
|
|
404
|
-
<Text dimColor>{"─".repeat(2)}</Text>
|
|
405
|
-
</Box>
|
|
406
444
|
</Box>
|
|
407
445
|
);
|
|
408
446
|
}
|
|
@@ -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
|
+
}
|
|
@@ -1,17 +1,42 @@
|
|
|
1
1
|
import { Box, Text } from "ink";
|
|
2
2
|
import { memo } from "react";
|
|
3
|
+
import type { ContextUsage } from "../../chat/usage.ts";
|
|
3
4
|
|
|
4
5
|
interface HelpPanelProps {
|
|
5
6
|
projectDir: string;
|
|
6
7
|
threadId: string;
|
|
7
8
|
workerRunning: boolean;
|
|
9
|
+
usage?: ContextUsage | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function formatK(n: number): string {
|
|
13
|
+
return n >= 1000 ? `${Math.round(n / 1000)}k` : String(n);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function usageColorFor(pct: number): "red" | "yellow" | "green" {
|
|
17
|
+
if (pct >= 90) return "red";
|
|
18
|
+
if (pct >= 70) return "yellow";
|
|
19
|
+
return "green";
|
|
8
20
|
}
|
|
9
21
|
|
|
10
22
|
export const HelpPanel = memo(function HelpPanel({
|
|
11
23
|
projectDir,
|
|
12
24
|
threadId,
|
|
13
25
|
workerRunning,
|
|
26
|
+
usage,
|
|
14
27
|
}: HelpPanelProps) {
|
|
28
|
+
const pct =
|
|
29
|
+
usage && usage.max > 0 ? Math.round((usage.used / usage.max) * 100) : null;
|
|
30
|
+
const breakdownRows: { label: string; tokens: number }[] = usage
|
|
31
|
+
? [
|
|
32
|
+
{ label: "Prompts (files)", tokens: usage.breakdown.prompts },
|
|
33
|
+
{ label: "Instructions ", tokens: usage.breakdown.instructions },
|
|
34
|
+
{ label: "Tools ", tokens: usage.breakdown.tools },
|
|
35
|
+
{ label: "Messages ", tokens: usage.breakdown.messages },
|
|
36
|
+
{ label: "Tool I/O ", tokens: usage.breakdown.toolIo },
|
|
37
|
+
]
|
|
38
|
+
: [];
|
|
39
|
+
const breakdownTotal = breakdownRows.reduce((s, r) => s + r.tokens, 0);
|
|
15
40
|
return (
|
|
16
41
|
<Box flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
|
|
17
42
|
<Box marginTop={1} flexDirection="column">
|
|
@@ -40,7 +65,7 @@ export const HelpPanel = memo(function HelpPanel({
|
|
|
40
65
|
{" "}Ctrl+w{" "}Workers
|
|
41
66
|
</Text>
|
|
42
67
|
<Text>
|
|
43
|
-
{" "}
|
|
68
|
+
{" "}Ctrl+g{" "}Help (Ctrl+/ also works in most terminals)
|
|
44
69
|
</Text>
|
|
45
70
|
<Text>
|
|
46
71
|
{" "}Escape{" "}Return to Chat
|
|
@@ -112,18 +137,28 @@ export const HelpPanel = memo(function HelpPanel({
|
|
|
112
137
|
<Text bold color="cyan">
|
|
113
138
|
Per-panel actions
|
|
114
139
|
</Text>
|
|
140
|
+
<Text dimColor>
|
|
141
|
+
{" "}d delete needs two presses — arms first, confirms second
|
|
142
|
+
(cancels on any other key or after 3s)
|
|
143
|
+
</Text>
|
|
115
144
|
<Text>
|
|
116
|
-
{" "}Tasks{" "}f filter · p priority · d delete · r
|
|
145
|
+
{" "}Tasks{" "}f filter · p priority · d delete (×2) · r
|
|
146
|
+
refresh
|
|
117
147
|
</Text>
|
|
118
148
|
<Text>
|
|
119
|
-
{" "}Threads{" "}f filter · s/ search · w follow · d delete
|
|
120
|
-
r refresh
|
|
149
|
+
{" "}Threads{" "}f filter · s/ search · w follow · d delete
|
|
150
|
+
(×2) · r refresh
|
|
121
151
|
</Text>
|
|
122
152
|
<Text>
|
|
123
|
-
{" "}Schedules{" "}f filter · e toggle · d delete · r
|
|
153
|
+
{" "}Schedules{" "}f filter · e toggle · d delete (×2) · r
|
|
154
|
+
refresh
|
|
124
155
|
</Text>
|
|
125
156
|
<Text>
|
|
126
|
-
{" "}
|
|
157
|
+
{" "}Context{" "}d delete (×2) · r refresh
|
|
158
|
+
</Text>
|
|
159
|
+
<Text>
|
|
160
|
+
{" "}Workers{" "}f filter · l toggle log/detail · d delete log
|
|
161
|
+
(×2, log view)
|
|
127
162
|
</Text>
|
|
128
163
|
</Box>
|
|
129
164
|
|
|
@@ -139,6 +174,38 @@ export const HelpPanel = memo(function HelpPanel({
|
|
|
139
174
|
</Text>
|
|
140
175
|
</Box>
|
|
141
176
|
|
|
177
|
+
<Box marginTop={1} flexDirection="column">
|
|
178
|
+
<Text bold color="cyan">
|
|
179
|
+
Context usage
|
|
180
|
+
</Text>
|
|
181
|
+
{usage && pct !== null ? (
|
|
182
|
+
<>
|
|
183
|
+
<Text>
|
|
184
|
+
{" "}Total{" "}
|
|
185
|
+
<Text color={usageColorFor(pct)}>
|
|
186
|
+
{formatK(usage.used)}/{formatK(usage.max)} ({pct}%)
|
|
187
|
+
</Text>
|
|
188
|
+
</Text>
|
|
189
|
+
<Text dimColor>
|
|
190
|
+
{" "}Estimate (~4 chars/token, sums to ~{formatK(breakdownTotal)}
|
|
191
|
+
):
|
|
192
|
+
</Text>
|
|
193
|
+
{breakdownRows.map((row) => (
|
|
194
|
+
<Text key={row.label}>
|
|
195
|
+
{" "}
|
|
196
|
+
{row.label}
|
|
197
|
+
{" "}
|
|
198
|
+
{formatK(row.tokens)}
|
|
199
|
+
</Text>
|
|
200
|
+
))}
|
|
201
|
+
</>
|
|
202
|
+
) : (
|
|
203
|
+
<Text dimColor>
|
|
204
|
+
{" "}Send a message to see token usage for the next turn.
|
|
205
|
+
</Text>
|
|
206
|
+
)}
|
|
207
|
+
</Box>
|
|
208
|
+
|
|
142
209
|
<Box marginTop={1} flexDirection="column">
|
|
143
210
|
<Text bold color="cyan">
|
|
144
211
|
System Info
|
|
@@ -12,7 +12,9 @@ import {
|
|
|
12
12
|
handleListDetailKey,
|
|
13
13
|
} from "../listDetailKeys.ts";
|
|
14
14
|
import { ansi, theme } from "../theme.ts";
|
|
15
|
+
import { useDeleteConfirm } from "../useDeleteConfirm.ts";
|
|
15
16
|
import { useLatestRef } from "../useLatestRef.ts";
|
|
17
|
+
import { DeleteArmedBanner } from "./DeleteArmedBanner.tsx";
|
|
16
18
|
import { Scrollbar } from "./Scrollbar.tsx";
|
|
17
19
|
|
|
18
20
|
interface SchedulePanelProps {
|
|
@@ -94,7 +96,6 @@ export const SchedulePanel = memo(function SchedulePanel({
|
|
|
94
96
|
const [focus, setFocus] = useState<FocusState>("list");
|
|
95
97
|
const [enabledFilter, setEnabledFilter] = useState<boolean | null>(null);
|
|
96
98
|
const [refreshTick, setRefreshTick] = useState(0);
|
|
97
|
-
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
98
99
|
|
|
99
100
|
// biome-ignore lint/correctness/useExhaustiveDependencies: refreshTick triggers manual refresh
|
|
100
101
|
useEffect(() => {
|
|
@@ -159,25 +160,19 @@ export const SchedulePanel = memo(function SchedulePanel({
|
|
|
159
160
|
const itemCountRef = useLatestRef(schedules.length);
|
|
160
161
|
const maxDetailScrollRef = useLatestRef(maxDetailScroll);
|
|
161
162
|
const selectedScheduleRef = useLatestRef(selectedSchedule);
|
|
162
|
-
const confirmDeleteRef = useLatestRef(confirmDelete);
|
|
163
163
|
const focusRef = useLatestRef(focus);
|
|
164
164
|
|
|
165
|
+
const deleteConfirm = useDeleteConfirm(() => {
|
|
166
|
+
const s = selectedScheduleRef.current;
|
|
167
|
+
if (!s) return;
|
|
168
|
+
deleteSchedule(projectDir, s.id).then(() => {
|
|
169
|
+
forceRefresh();
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
165
173
|
useInput(
|
|
166
174
|
(input, key) => {
|
|
167
|
-
if (
|
|
168
|
-
if (input === "y" || input === "d") {
|
|
169
|
-
const s = selectedScheduleRef.current;
|
|
170
|
-
if (s) {
|
|
171
|
-
deleteSchedule(projectDir, s.id).then(() => {
|
|
172
|
-
forceRefresh();
|
|
173
|
-
});
|
|
174
|
-
}
|
|
175
|
-
setConfirmDelete(false);
|
|
176
|
-
} else {
|
|
177
|
-
setConfirmDelete(false);
|
|
178
|
-
}
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
175
|
+
if (input !== "d") deleteConfirm.cancel();
|
|
181
176
|
|
|
182
177
|
if (
|
|
183
178
|
handleListDetailKey(input, key, {
|
|
@@ -208,7 +203,8 @@ export const SchedulePanel = memo(function SchedulePanel({
|
|
|
208
203
|
return;
|
|
209
204
|
}
|
|
210
205
|
if (input === "d" && selectedScheduleRef.current) {
|
|
211
|
-
|
|
206
|
+
const s = selectedScheduleRef.current;
|
|
207
|
+
deleteConfirm.pressDelete(s.name);
|
|
212
208
|
return;
|
|
213
209
|
}
|
|
214
210
|
if (input === "r") {
|
|
@@ -273,13 +269,6 @@ export const SchedulePanel = memo(function SchedulePanel({
|
|
|
273
269
|
</Text>
|
|
274
270
|
)}
|
|
275
271
|
</Box>
|
|
276
|
-
{confirmDelete && selectedSchedule && (
|
|
277
|
-
<Box paddingX={1}>
|
|
278
|
-
<Text color="red" bold>
|
|
279
|
-
Delete schedule? (y/n)
|
|
280
|
-
</Text>
|
|
281
|
-
</Box>
|
|
282
|
-
)}
|
|
283
272
|
{sidebarVisible.map((schedule, vi) => {
|
|
284
273
|
const i = vi + sidebarScrollOffset;
|
|
285
274
|
const isSelected = i === selectedIndex;
|
|
@@ -342,10 +331,14 @@ export const SchedulePanel = memo(function SchedulePanel({
|
|
|
342
331
|
focused={focus === "detail"}
|
|
343
332
|
/>
|
|
344
333
|
</Box>
|
|
334
|
+
<DeleteArmedBanner
|
|
335
|
+
armed={deleteConfirm.armed}
|
|
336
|
+
label={deleteConfirm.armedLabel}
|
|
337
|
+
/>
|
|
345
338
|
<Text dimColor>
|
|
346
339
|
{focus === "detail"
|
|
347
340
|
? "↑↓ scroll · ⇧↑↓ page · g/G top/bot · ← back to list"
|
|
348
|
-
: "↑↓ select · → enter detail · f filter · e toggle · d delete · r refresh"}
|
|
341
|
+
: "↑↓ select · → enter detail · f filter · e toggle · d delete (×2) · r refresh"}
|
|
349
342
|
</Text>
|
|
350
343
|
</Box>
|
|
351
344
|
</Box>
|
|
@@ -355,7 +348,7 @@ export const SchedulePanel = memo(function SchedulePanel({
|
|
|
355
348
|
function ScheduleDetailHeader({ schedule }: { schedule: Schedule }) {
|
|
356
349
|
const enabledKey = String(schedule.enabled);
|
|
357
350
|
return (
|
|
358
|
-
<Box flexDirection="column">
|
|
351
|
+
<Box flexDirection="column" width="100%" backgroundColor={theme.headerBg}>
|
|
359
352
|
<Box>
|
|
360
353
|
<Text bold color={theme.info} wrap="truncate-end">
|
|
361
354
|
{schedule.name}
|
|
@@ -374,9 +367,6 @@ function ScheduleDetailHeader({ schedule }: { schedule: Schedule }) {
|
|
|
374
367
|
</Text>
|
|
375
368
|
</Text>
|
|
376
369
|
</Box>
|
|
377
|
-
<Box>
|
|
378
|
-
<Text dimColor>{"─".repeat(2)}</Text>
|
|
379
|
-
</Box>
|
|
380
370
|
</Box>
|
|
381
371
|
);
|
|
382
372
|
}
|
|
@@ -2,8 +2,9 @@ import { Box, Text } from "ink";
|
|
|
2
2
|
|
|
3
3
|
export type TabId = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
|
|
4
4
|
|
|
5
|
-
// Help uses
|
|
6
|
-
//
|
|
5
|
+
// Help uses Ctrl+G rather than Ctrl+H because most terminals deliver Ctrl+H
|
|
6
|
+
// as backspace. Ctrl+G also catches the Ctrl+/ keystroke on terminals that
|
|
7
|
+
// map it to BEL (0x07) — most macOS terminals do.
|
|
7
8
|
const TABS: { id: TabId; label: string; key: string }[] = [
|
|
8
9
|
{ id: 1, label: "Chat", key: "^a" },
|
|
9
10
|
{ id: 2, label: "Tools", key: "^o" },
|
|
@@ -12,14 +13,30 @@ const TABS: { id: TabId; label: string; key: string }[] = [
|
|
|
12
13
|
{ id: 5, label: "Threads", key: "^r" },
|
|
13
14
|
{ id: 6, label: "Schedules", key: "^s" },
|
|
14
15
|
{ id: 7, label: "Workers", key: "^w" },
|
|
15
|
-
{ id: 8, label: "Help", key: "
|
|
16
|
+
{ id: 8, label: "Help", key: "^g" },
|
|
16
17
|
];
|
|
17
18
|
|
|
18
19
|
interface TabBarProps {
|
|
19
20
|
activeTab: TabId;
|
|
21
|
+
usage?: { used: number; max: number } | null;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
|
-
|
|
24
|
+
function formatK(n: number): string {
|
|
25
|
+
return n >= 1000 ? `${Math.round(n / 1000)}k` : String(n);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function TabBar({ activeTab, usage }: TabBarProps) {
|
|
29
|
+
const pct =
|
|
30
|
+
usage && usage.max > 0 ? Math.round((usage.used / usage.max) * 100) : null;
|
|
31
|
+
const usageColor =
|
|
32
|
+
pct === null
|
|
33
|
+
? undefined
|
|
34
|
+
: pct >= 90
|
|
35
|
+
? "red"
|
|
36
|
+
: pct >= 70
|
|
37
|
+
? "yellow"
|
|
38
|
+
: "green";
|
|
39
|
+
|
|
23
40
|
return (
|
|
24
41
|
<Box paddingX={1} gap={1}>
|
|
25
42
|
{TABS.map(({ id, label, key: shortcut }) => {
|
|
@@ -37,6 +54,14 @@ export function TabBar({ activeTab }: TabBarProps) {
|
|
|
37
54
|
</Box>
|
|
38
55
|
);
|
|
39
56
|
})}
|
|
57
|
+
<Box flexGrow={1} />
|
|
58
|
+
{usage && (
|
|
59
|
+
<Box>
|
|
60
|
+
<Text color={usageColor}>
|
|
61
|
+
{formatK(usage.used)}/{formatK(usage.max)}
|
|
62
|
+
</Text>
|
|
63
|
+
</Box>
|
|
64
|
+
)}
|
|
40
65
|
</Box>
|
|
41
66
|
);
|
|
42
67
|
}
|
|
@@ -12,7 +12,9 @@ import {
|
|
|
12
12
|
handleListDetailKey,
|
|
13
13
|
} from "../listDetailKeys.ts";
|
|
14
14
|
import { ansi, theme } from "../theme.ts";
|
|
15
|
+
import { useDeleteConfirm } from "../useDeleteConfirm.ts";
|
|
15
16
|
import { useLatestRef } from "../useLatestRef.ts";
|
|
17
|
+
import { DeleteArmedBanner } from "./DeleteArmedBanner.tsx";
|
|
16
18
|
import { Scrollbar } from "./Scrollbar.tsx";
|
|
17
19
|
|
|
18
20
|
interface TaskPanelProps {
|
|
@@ -199,8 +201,18 @@ export const TaskPanel = memo(function TaskPanel({
|
|
|
199
201
|
const selectedTaskRef = useLatestRef(selectedTask);
|
|
200
202
|
const focusRef = useLatestRef(focus);
|
|
201
203
|
|
|
204
|
+
const deleteConfirm = useDeleteConfirm(() => {
|
|
205
|
+
const t = selectedTaskRef.current;
|
|
206
|
+
if (!t) return;
|
|
207
|
+
deleteTask(projectDir, t.id).then(() => {
|
|
208
|
+
forceRefresh();
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
202
212
|
useInput(
|
|
203
213
|
(input, key) => {
|
|
214
|
+
if (input !== "d") deleteConfirm.cancel();
|
|
215
|
+
|
|
204
216
|
if (
|
|
205
217
|
handleListDetailKey(input, key, {
|
|
206
218
|
focusRef,
|
|
@@ -226,9 +238,7 @@ export const TaskPanel = memo(function TaskPanel({
|
|
|
226
238
|
if (input === "d") {
|
|
227
239
|
const t = selectedTaskRef.current;
|
|
228
240
|
if (!t) return;
|
|
229
|
-
|
|
230
|
-
forceRefresh();
|
|
231
|
-
});
|
|
241
|
+
deleteConfirm.pressDelete(t.name || t.id);
|
|
232
242
|
return;
|
|
233
243
|
}
|
|
234
244
|
if (input === "r") {
|
|
@@ -359,10 +369,14 @@ export const TaskPanel = memo(function TaskPanel({
|
|
|
359
369
|
focused={focus === "detail"}
|
|
360
370
|
/>
|
|
361
371
|
</Box>
|
|
372
|
+
<DeleteArmedBanner
|
|
373
|
+
armed={deleteConfirm.armed}
|
|
374
|
+
label={deleteConfirm.armedLabel}
|
|
375
|
+
/>
|
|
362
376
|
<Text dimColor>
|
|
363
377
|
{focus === "detail"
|
|
364
378
|
? "↑↓ scroll · ⇧↑↓ page · g/G top/bot · ← back to list"
|
|
365
|
-
: "↑↓ select · → enter detail · f filter · p priority · d delete · r refresh"}
|
|
379
|
+
: "↑↓ select · → enter detail · f filter · p priority · d delete (×2) · r refresh"}
|
|
366
380
|
</Text>
|
|
367
381
|
</Box>
|
|
368
382
|
</Box>
|
|
@@ -371,7 +385,7 @@ export const TaskPanel = memo(function TaskPanel({
|
|
|
371
385
|
|
|
372
386
|
function TaskDetailHeader({ task }: { task: Task }) {
|
|
373
387
|
return (
|
|
374
|
-
<Box flexDirection="column">
|
|
388
|
+
<Box flexDirection="column" width="100%" backgroundColor={theme.headerBg}>
|
|
375
389
|
<Box>
|
|
376
390
|
<Text bold color={theme.info} wrap="truncate-end">
|
|
377
391
|
{task.name}
|
|
@@ -394,9 +408,6 @@ function TaskDetailHeader({ task }: { task: Task }) {
|
|
|
394
408
|
</Text>
|
|
395
409
|
</Text>
|
|
396
410
|
</Box>
|
|
397
|
-
<Box>
|
|
398
|
-
<Text dimColor>{"─".repeat(2)}</Text>
|
|
399
|
-
</Box>
|
|
400
411
|
</Box>
|
|
401
412
|
);
|
|
402
413
|
}
|
|
@@ -15,7 +15,9 @@ import {
|
|
|
15
15
|
handleListDetailKey,
|
|
16
16
|
} from "../listDetailKeys.ts";
|
|
17
17
|
import { ansi, theme } from "../theme.ts";
|
|
18
|
+
import { useDeleteConfirm } from "../useDeleteConfirm.ts";
|
|
18
19
|
import { useLatestRef } from "../useLatestRef.ts";
|
|
20
|
+
import { DeleteArmedBanner } from "./DeleteArmedBanner.tsx";
|
|
19
21
|
import { Scrollbar } from "./Scrollbar.tsx";
|
|
20
22
|
|
|
21
23
|
interface ThreadPanelProps {
|
|
@@ -168,7 +170,6 @@ export const ThreadPanel = memo(function ThreadPanel({
|
|
|
168
170
|
const [focus, setFocus] = useState<FocusState>("list");
|
|
169
171
|
const [typeFilter, setTypeFilter] = useState<Thread["type"] | null>(null);
|
|
170
172
|
const [refreshTick, setRefreshTick] = useState(0);
|
|
171
|
-
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
172
173
|
const [searching, setSearching] = useState(false);
|
|
173
174
|
const [searchQuery, setSearchQuery] = useState("");
|
|
174
175
|
const [selectedDetail, setSelectedDetail] = useState<{
|
|
@@ -329,11 +330,18 @@ export const ThreadPanel = memo(function ThreadPanel({
|
|
|
329
330
|
const selectedThreadRef = useLatestRef(selectedThread);
|
|
330
331
|
const selectedDetailRef = useLatestRef(selectedDetail);
|
|
331
332
|
const searchingRef = useLatestRef(searching);
|
|
332
|
-
const confirmDeleteRef = useLatestRef(confirmDelete);
|
|
333
333
|
const isActiveSelectedRef = useLatestRef(isActiveSelected);
|
|
334
334
|
const followingRef = useLatestRef(following);
|
|
335
335
|
const focusRef = useLatestRef(focus);
|
|
336
336
|
|
|
337
|
+
const deleteConfirm = useDeleteConfirm(() => {
|
|
338
|
+
const t = selectedThreadRef.current;
|
|
339
|
+
if (!t || isActiveSelectedRef.current) return;
|
|
340
|
+
deleteThread(projectDir, t.id).then(() => {
|
|
341
|
+
forceRefresh();
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
337
345
|
useInput(
|
|
338
346
|
(input, key) => {
|
|
339
347
|
// Search mode: capture typed characters
|
|
@@ -360,21 +368,7 @@ export const ThreadPanel = memo(function ThreadPanel({
|
|
|
360
368
|
return;
|
|
361
369
|
}
|
|
362
370
|
|
|
363
|
-
|
|
364
|
-
if (confirmDeleteRef.current) {
|
|
365
|
-
if (input === "y" || input === "d") {
|
|
366
|
-
const t = selectedThreadRef.current;
|
|
367
|
-
if (t && !isActiveSelectedRef.current) {
|
|
368
|
-
deleteThread(projectDir, t.id).then(() => {
|
|
369
|
-
forceRefresh();
|
|
370
|
-
});
|
|
371
|
-
}
|
|
372
|
-
setConfirmDelete(false);
|
|
373
|
-
} else {
|
|
374
|
-
setConfirmDelete(false);
|
|
375
|
-
}
|
|
376
|
-
return;
|
|
377
|
-
}
|
|
371
|
+
if (input !== "d") deleteConfirm.cancel();
|
|
378
372
|
|
|
379
373
|
if (
|
|
380
374
|
handleListDetailKey(input, key, {
|
|
@@ -396,7 +390,8 @@ export const ThreadPanel = memo(function ThreadPanel({
|
|
|
396
390
|
}
|
|
397
391
|
if (input === "d" && selectedThreadRef.current) {
|
|
398
392
|
if (isActiveSelectedRef.current) return; // Can't delete active thread
|
|
399
|
-
|
|
393
|
+
const t = selectedThreadRef.current;
|
|
394
|
+
deleteConfirm.pressDelete(t.title || "(untitled)");
|
|
400
395
|
return;
|
|
401
396
|
}
|
|
402
397
|
if (input === "r") {
|
|
@@ -490,13 +485,6 @@ export const ThreadPanel = memo(function ThreadPanel({
|
|
|
490
485
|
<Text color={theme.info}>▌</Text>
|
|
491
486
|
</Box>
|
|
492
487
|
)}
|
|
493
|
-
{confirmDelete && selectedThread && (
|
|
494
|
-
<Box paddingX={1}>
|
|
495
|
-
<Text color="red" bold>
|
|
496
|
-
Delete thread? (y/n)
|
|
497
|
-
</Text>
|
|
498
|
-
</Box>
|
|
499
|
-
)}
|
|
500
488
|
{sidebarVisible.map((thread, vi) => {
|
|
501
489
|
const i = vi + sidebarScrollOffset;
|
|
502
490
|
const isSelected = i === selectedIndex;
|
|
@@ -573,6 +561,10 @@ export const ThreadPanel = memo(function ThreadPanel({
|
|
|
573
561
|
focused={focus === "detail"}
|
|
574
562
|
/>
|
|
575
563
|
</Box>
|
|
564
|
+
<DeleteArmedBanner
|
|
565
|
+
armed={deleteConfirm.armed}
|
|
566
|
+
label={deleteConfirm.armedLabel}
|
|
567
|
+
/>
|
|
576
568
|
<Box>
|
|
577
569
|
{following && (
|
|
578
570
|
<Text color={theme.success} bold>
|
|
@@ -583,7 +575,7 @@ export const ThreadPanel = memo(function ThreadPanel({
|
|
|
583
575
|
<Text dimColor>
|
|
584
576
|
{focus === "detail"
|
|
585
577
|
? "↑↓ scroll · ⇧↑↓ page · g/G top/bot · ← back to list"
|
|
586
|
-
: `↑↓ select · → enter detail · s search · f filter · d delete${selectedThread && !selectedThread.ended_at ? " · w follow" : ""} · r refresh`}
|
|
578
|
+
: `↑↓ select · → enter detail · s search · f filter · d delete (×2)${selectedThread && !selectedThread.ended_at ? " · w follow" : ""} · r refresh`}
|
|
587
579
|
</Text>
|
|
588
580
|
</Box>
|
|
589
581
|
</Box>
|
|
@@ -599,7 +591,7 @@ function ThreadDetailHeader({
|
|
|
599
591
|
isActiveThread: boolean;
|
|
600
592
|
}) {
|
|
601
593
|
return (
|
|
602
|
-
<Box flexDirection="column">
|
|
594
|
+
<Box flexDirection="column" width="100%" backgroundColor={theme.headerBg}>
|
|
603
595
|
<Box>
|
|
604
596
|
<Text wrap="truncate-end">
|
|
605
597
|
<Text bold italic color={theme.info}>
|
|
@@ -633,9 +625,6 @@ function ThreadDetailHeader({
|
|
|
633
625
|
</Text>
|
|
634
626
|
</Text>
|
|
635
627
|
</Box>
|
|
636
|
-
<Box>
|
|
637
|
-
<Text dimColor>{"─".repeat(2)}</Text>
|
|
638
|
-
</Box>
|
|
639
628
|
</Box>
|
|
640
629
|
);
|
|
641
630
|
}
|
|
@@ -1,13 +1,20 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
1
2
|
import { Box, Text, useInput, useStdout } from "ink";
|
|
2
3
|
import { memo, useEffect, useMemo, useState } from "react";
|
|
3
4
|
import { readLogTail } from "../../worker/log-reader.ts";
|
|
4
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
deleteWorkerLog,
|
|
7
|
+
listWorkers,
|
|
8
|
+
type Worker,
|
|
9
|
+
} from "../../workers/store.ts";
|
|
5
10
|
import {
|
|
6
11
|
detailPaneBorderProps,
|
|
7
12
|
type FocusState,
|
|
8
13
|
handleListDetailKey,
|
|
9
14
|
} from "../listDetailKeys.ts";
|
|
15
|
+
import { useDeleteConfirm } from "../useDeleteConfirm.ts";
|
|
10
16
|
import { useLatestRef } from "../useLatestRef.ts";
|
|
17
|
+
import { DeleteArmedBanner } from "./DeleteArmedBanner.tsx";
|
|
11
18
|
import { Scrollbar } from "./Scrollbar.tsx";
|
|
12
19
|
|
|
13
20
|
interface WorkerPanelProps {
|
|
@@ -161,6 +168,21 @@ export const WorkerPanel = memo(function WorkerPanel({
|
|
|
161
168
|
const itemCountRef = useLatestRef(workers.length);
|
|
162
169
|
const maxLogScrollRef = useLatestRef(maxLogScroll);
|
|
163
170
|
const focusRef = useLatestRef(focus);
|
|
171
|
+
const viewModeRef = useLatestRef(viewMode);
|
|
172
|
+
const selectedLogPathRef = useLatestRef(selectedLogPath);
|
|
173
|
+
|
|
174
|
+
const deleteConfirm = useDeleteConfirm(() => {
|
|
175
|
+
const path = selectedLogPathRef.current;
|
|
176
|
+
if (!path) return;
|
|
177
|
+
deleteWorkerLog(projectDir, path)
|
|
178
|
+
.catch(() => {})
|
|
179
|
+
.finally(() => {
|
|
180
|
+
setLogContent("");
|
|
181
|
+
setLogSize(0);
|
|
182
|
+
setLogTruncated(false);
|
|
183
|
+
setLogScroll(0);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
164
186
|
|
|
165
187
|
// The right pane scrolls with arrows when focused. Tee the log scroll into
|
|
166
188
|
// the follow-state so reaching the bottom resumes follow mode (and any
|
|
@@ -181,6 +203,8 @@ export const WorkerPanel = memo(function WorkerPanel({
|
|
|
181
203
|
(input, key) => {
|
|
182
204
|
if (!isActive) return;
|
|
183
205
|
|
|
206
|
+
if (input !== "d") deleteConfirm.cancel();
|
|
207
|
+
|
|
184
208
|
// `l` toggles between detail (worker info) and log (tail) view in the
|
|
185
209
|
// right pane.
|
|
186
210
|
if (input === "l") {
|
|
@@ -206,6 +230,14 @@ export const WorkerPanel = memo(function WorkerPanel({
|
|
|
206
230
|
setFilterIdx((i) => (i + 1) % STATUS_FILTERS.length);
|
|
207
231
|
return;
|
|
208
232
|
}
|
|
233
|
+
|
|
234
|
+
if (input === "d") {
|
|
235
|
+
if (viewModeRef.current !== "log") return;
|
|
236
|
+
const path = selectedLogPathRef.current;
|
|
237
|
+
if (!path) return;
|
|
238
|
+
deleteConfirm.pressDelete(`worker log: ${basename(path)}`);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
209
241
|
},
|
|
210
242
|
{ isActive },
|
|
211
243
|
);
|
|
@@ -225,10 +257,14 @@ export const WorkerPanel = memo(function WorkerPanel({
|
|
|
225
257
|
{focus === "detail"
|
|
226
258
|
? " · ↑↓ scroll ⇧↑↓ page g/G top/bot ← back to list l toggle"
|
|
227
259
|
: viewMode === "log"
|
|
228
|
-
? " · ↑↓ select → enter log l detail f filter"
|
|
260
|
+
? " · ↑↓ select → enter log l detail f filter d delete log (×2)"
|
|
229
261
|
: " · ↑↓ select → enter detail l view log f filter"}
|
|
230
262
|
</Text>
|
|
231
263
|
</Box>
|
|
264
|
+
<DeleteArmedBanner
|
|
265
|
+
armed={deleteConfirm.armed}
|
|
266
|
+
label={deleteConfirm.armedLabel}
|
|
267
|
+
/>
|
|
232
268
|
|
|
233
269
|
{workers.length === 0 ? (
|
|
234
270
|
<Text dimColor>
|
package/src/tui/theme.ts
CHANGED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Two-press delete confirmation. First press arms; second press within
|
|
5
|
+
* `ttlMs` confirms. Any non-`d` keystroke should call `cancel()`. The TTL
|
|
6
|
+
* is a safety net for idle/escape.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface DeleteConfirmController {
|
|
10
|
+
isArmed(): boolean;
|
|
11
|
+
armedLabel(): string | null;
|
|
12
|
+
pressDelete: (label: string) => void;
|
|
13
|
+
cancel: () => void;
|
|
14
|
+
dispose: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createDeleteConfirmController(
|
|
18
|
+
onConfirm: () => void,
|
|
19
|
+
opts: { ttlMs?: number; onChange?: () => void } = {},
|
|
20
|
+
): DeleteConfirmController {
|
|
21
|
+
const ttlMs = opts.ttlMs ?? 3000;
|
|
22
|
+
let armed = false;
|
|
23
|
+
let label: string | null = null;
|
|
24
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
25
|
+
|
|
26
|
+
const clearTimer = () => {
|
|
27
|
+
if (timer) {
|
|
28
|
+
clearTimeout(timer);
|
|
29
|
+
timer = null;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const notify = () => {
|
|
34
|
+
opts.onChange?.();
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const pressDelete = (next: string) => {
|
|
38
|
+
if (armed) {
|
|
39
|
+
clearTimer();
|
|
40
|
+
armed = false;
|
|
41
|
+
label = null;
|
|
42
|
+
notify();
|
|
43
|
+
onConfirm();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
armed = true;
|
|
47
|
+
label = next;
|
|
48
|
+
timer = setTimeout(() => {
|
|
49
|
+
armed = false;
|
|
50
|
+
label = null;
|
|
51
|
+
timer = null;
|
|
52
|
+
notify();
|
|
53
|
+
}, ttlMs);
|
|
54
|
+
notify();
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const cancel = () => {
|
|
58
|
+
if (!armed && !timer) return;
|
|
59
|
+
clearTimer();
|
|
60
|
+
armed = false;
|
|
61
|
+
label = null;
|
|
62
|
+
notify();
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const dispose = () => {
|
|
66
|
+
clearTimer();
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
isArmed: () => armed,
|
|
71
|
+
armedLabel: () => label,
|
|
72
|
+
pressDelete,
|
|
73
|
+
cancel,
|
|
74
|
+
dispose,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface UseDeleteConfirmResult {
|
|
79
|
+
armed: boolean;
|
|
80
|
+
armedLabel: string | null;
|
|
81
|
+
pressDelete: (label: string) => void;
|
|
82
|
+
cancel: () => void;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function useDeleteConfirm(
|
|
86
|
+
onConfirm: () => void,
|
|
87
|
+
opts: { ttlMs?: number } = {},
|
|
88
|
+
): UseDeleteConfirmResult {
|
|
89
|
+
const [, setTick] = useState(0);
|
|
90
|
+
|
|
91
|
+
const onConfirmRef = useRef(onConfirm);
|
|
92
|
+
onConfirmRef.current = onConfirm;
|
|
93
|
+
|
|
94
|
+
const controllerRef = useRef<DeleteConfirmController | null>(null);
|
|
95
|
+
if (!controllerRef.current) {
|
|
96
|
+
controllerRef.current = createDeleteConfirmController(
|
|
97
|
+
() => onConfirmRef.current(),
|
|
98
|
+
{ ttlMs: opts.ttlMs, onChange: () => setTick((t) => t + 1) },
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
return () => {
|
|
104
|
+
controllerRef.current?.dispose();
|
|
105
|
+
};
|
|
106
|
+
}, []);
|
|
107
|
+
|
|
108
|
+
const c = controllerRef.current;
|
|
109
|
+
return {
|
|
110
|
+
armed: c.isArmed(),
|
|
111
|
+
armedLabel: c.armedLabel(),
|
|
112
|
+
pressDelete: c.pressDelete,
|
|
113
|
+
cancel: c.cancel,
|
|
114
|
+
};
|
|
115
|
+
}
|
package/src/workers/store.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readdir, stat, unlink } from "node:fs/promises";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { getWorkersDir } from "../constants.ts";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { getWorkerLogsDir, getWorkersDir } from "../constants.ts";
|
|
4
4
|
import { atomicWrite, readWithMtime } from "../fs/atomic.ts";
|
|
5
5
|
|
|
6
6
|
export const WORKER_MODES = ["persist", "once"] as const;
|
|
@@ -217,6 +217,28 @@ export async function deleteWorker(
|
|
|
217
217
|
}
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
+
/**
|
|
221
|
+
* Delete a worker's on-disk log file. Refuses to touch anything outside
|
|
222
|
+
* `<projectDir>/logs/`. ENOENT is treated as success (idempotent).
|
|
223
|
+
*/
|
|
224
|
+
export async function deleteWorkerLog(
|
|
225
|
+
projectDir: string,
|
|
226
|
+
logPath: string,
|
|
227
|
+
): Promise<boolean> {
|
|
228
|
+
const logsDir = resolve(getWorkerLogsDir(projectDir));
|
|
229
|
+
const target = resolve(logPath);
|
|
230
|
+
if (target !== logsDir && !target.startsWith(`${logsDir}/`)) {
|
|
231
|
+
throw new Error(`refusing to delete log outside ${logsDir}: ${logPath}`);
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
await unlink(target);
|
|
235
|
+
return true;
|
|
236
|
+
} catch (err) {
|
|
237
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
238
|
+
throw err;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
220
242
|
async function listWorkerIds(projectDir: string): Promise<string[]> {
|
|
221
243
|
const dir = getWorkersDir(projectDir);
|
|
222
244
|
try {
|