@oh-my-pi/pi-coding-agent 14.4.1 → 14.4.4
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/CHANGELOG.md +56 -0
- package/package.json +7 -7
- package/src/cli.ts +0 -1
- package/src/config/prompt-templates.ts +0 -30
- package/src/config/settings-schema.ts +68 -36
- package/src/config/settings.ts +1 -1
- package/src/edit/index.ts +1 -53
- package/src/edit/line-hash.ts +0 -53
- package/src/edit/modes/atom.ts +82 -47
- package/src/edit/modes/hashline.ts +6 -8
- package/src/edit/renderer.ts +6 -8
- package/src/edit/streaming.ts +90 -114
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +10 -15
- package/src/internal-urls/docs-index.generated.ts +1 -2
- package/src/modes/components/session-observer-overlay.ts +635 -295
- package/src/modes/components/settings-defs.ts +1 -5
- package/src/modes/components/tool-execution.ts +2 -5
- package/src/modes/controllers/btw-controller.ts +17 -105
- package/src/modes/controllers/command-controller.ts +16 -5
- package/src/modes/controllers/selector-controller.ts +32 -19
- package/src/modes/controllers/todo-command-controller.ts +537 -0
- package/src/modes/interactive-mode.ts +45 -10
- package/src/modes/types.ts +3 -0
- package/src/modes/utils/ui-helpers.ts +17 -0
- package/src/prompts/system/irc-incoming.md +8 -0
- package/src/prompts/system/subagent-system-prompt.md +8 -0
- package/src/prompts/tools/ast-grep.md +1 -1
- package/src/prompts/tools/atom.md +37 -26
- package/src/prompts/tools/bash.md +2 -2
- package/src/prompts/tools/grep.md +2 -5
- package/src/prompts/tools/irc.md +49 -0
- package/src/prompts/tools/job.md +11 -0
- package/src/prompts/tools/read.md +12 -13
- package/src/prompts/tools/task.md +1 -1
- package/src/prompts/tools/todo-write.md +14 -5
- package/src/registry/agent-registry.ts +139 -0
- package/src/sdk.ts +35 -0
- package/src/session/agent-session.ts +226 -6
- package/src/session/session-manager.ts +13 -0
- package/src/session/session-storage.ts +4 -0
- package/src/session/streaming-output.ts +1 -1
- package/src/slash-commands/builtin-registry.ts +32 -0
- package/src/task/executor.ts +14 -0
- package/src/tools/bash.ts +1 -1
- package/src/tools/fetch.ts +18 -6
- package/src/tools/fs-cache-invalidation.ts +0 -5
- package/src/tools/grep.ts +4 -124
- package/src/tools/index.ts +12 -6
- package/src/tools/irc.ts +258 -0
- package/src/tools/job.ts +489 -0
- package/src/tools/match-line-format.ts +7 -6
- package/src/tools/output-meta.ts +1 -1
- package/src/tools/read.ts +36 -126
- package/src/tools/renderers.ts +2 -0
- package/src/tools/todo-write.ts +243 -12
- package/src/utils/edit-mode.ts +1 -2
- package/src/utils/file-display-mode.ts +0 -3
- package/src/web/search/index.ts +2 -2
- package/src/web/search/provider.ts +3 -0
- package/src/web/search/providers/searxng.ts +238 -0
- package/src/web/search/types.ts +3 -1
- package/src/cli/read-cli.ts +0 -67
- package/src/commands/read.ts +0 -33
- package/src/edit/modes/chunk.ts +0 -832
- package/src/prompts/tools/cancel-job.md +0 -5
- package/src/prompts/tools/chunk-edit.md +0 -158
- package/src/prompts/tools/poll.md +0 -5
- package/src/prompts/tools/read-chunk.md +0 -73
- package/src/tools/cancel-job.ts +0 -95
- package/src/tools/poll-tool.ts +0 -173
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import { resolveToCwd } from "../../tools/path-utils";
|
|
3
|
+
import {
|
|
4
|
+
applyOpsToPhases,
|
|
5
|
+
getLatestTodoPhasesFromEntries,
|
|
6
|
+
markdownToPhases,
|
|
7
|
+
phasesToMarkdown,
|
|
8
|
+
type TodoItem,
|
|
9
|
+
type TodoPhase,
|
|
10
|
+
USER_TODO_EDIT_CUSTOM_TYPE,
|
|
11
|
+
} from "../../tools/todo-write";
|
|
12
|
+
import { copyToClipboard } from "../../utils/clipboard";
|
|
13
|
+
import { getEditorCommand, openInEditor } from "../../utils/external-editor";
|
|
14
|
+
import type { InteractiveModeContext } from "../types";
|
|
15
|
+
|
|
16
|
+
const USAGE = [
|
|
17
|
+
"Usage: /todo <verb> [args]",
|
|
18
|
+
" /todo Show current todos",
|
|
19
|
+
" /todo edit Open todos in $EDITOR",
|
|
20
|
+
" /todo copy Copy todos as Markdown to clipboard",
|
|
21
|
+
" /todo export <path> Write todos as Markdown to <path>",
|
|
22
|
+
" /todo import <path> Replace todos from Markdown at <path>",
|
|
23
|
+
" /todo append [<phase>] <task...> Append a task; phase fuzzy-matched or auto-created",
|
|
24
|
+
" /todo start <task> Mark task in_progress (id or fuzzy content)",
|
|
25
|
+
" /todo done [<task|phase>] Mark task/phase/all completed",
|
|
26
|
+
" /todo drop [<task|phase>] Mark task/phase/all abandoned",
|
|
27
|
+
" /todo rm [<task|phase>] Remove task/phase/all",
|
|
28
|
+
].join("\n");
|
|
29
|
+
|
|
30
|
+
// =============================================================================
|
|
31
|
+
// Argument tokenizer (respects double-quoted strings)
|
|
32
|
+
// =============================================================================
|
|
33
|
+
|
|
34
|
+
function tokenize(input: string): string[] {
|
|
35
|
+
const tokens: string[] = [];
|
|
36
|
+
let cur = "";
|
|
37
|
+
let inQuote = false;
|
|
38
|
+
for (let i = 0; i < input.length; i++) {
|
|
39
|
+
const ch = input[i];
|
|
40
|
+
if (ch === "\\" && i + 1 < input.length) {
|
|
41
|
+
cur += input[++i];
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (ch === '"') {
|
|
45
|
+
inQuote = !inQuote;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (!inQuote && /\s/.test(ch)) {
|
|
49
|
+
if (cur) {
|
|
50
|
+
tokens.push(cur);
|
|
51
|
+
cur = "";
|
|
52
|
+
}
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
cur += ch;
|
|
56
|
+
}
|
|
57
|
+
if (cur) tokens.push(cur);
|
|
58
|
+
return tokens;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// =============================================================================
|
|
62
|
+
// Roman numerals + name normalization
|
|
63
|
+
// =============================================================================
|
|
64
|
+
|
|
65
|
+
const ROMAN_PAIRS: Array<[number, string]> = [
|
|
66
|
+
[1000, "M"],
|
|
67
|
+
[900, "CM"],
|
|
68
|
+
[500, "D"],
|
|
69
|
+
[400, "CD"],
|
|
70
|
+
[100, "C"],
|
|
71
|
+
[90, "XC"],
|
|
72
|
+
[50, "L"],
|
|
73
|
+
[40, "XL"],
|
|
74
|
+
[10, "X"],
|
|
75
|
+
[9, "IX"],
|
|
76
|
+
[5, "V"],
|
|
77
|
+
[4, "IV"],
|
|
78
|
+
[1, "I"],
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
function toRoman(n: number): string {
|
|
82
|
+
if (n <= 0) return "I";
|
|
83
|
+
let out = "";
|
|
84
|
+
let rem = n;
|
|
85
|
+
for (const [value, sym] of ROMAN_PAIRS) {
|
|
86
|
+
while (rem >= value) {
|
|
87
|
+
out += sym;
|
|
88
|
+
rem -= value;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const PHASE_PREFIX_RE = /^([IVXLCDM]+|[A-Z]|\d+)\.\s*/i;
|
|
95
|
+
|
|
96
|
+
function stripPrefix(name: string): string {
|
|
97
|
+
return name.replace(PHASE_PREFIX_RE, "").trim();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function titleCase(s: string): string {
|
|
101
|
+
return s
|
|
102
|
+
.split(/\s+/)
|
|
103
|
+
.filter(Boolean)
|
|
104
|
+
.map(word => word[0].toUpperCase() + word.slice(1))
|
|
105
|
+
.join(" ");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function buildPhaseName(rawName: string, existingPhases: TodoPhase[]): string {
|
|
109
|
+
const stripped = stripPrefix(rawName.trim());
|
|
110
|
+
if (!stripped) return `${toRoman(existingPhases.length + 1)}. Todos`;
|
|
111
|
+
const titled = titleCase(stripped);
|
|
112
|
+
return `${toRoman(existingPhases.length + 1)}. ${titled}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// =============================================================================
|
|
116
|
+
// Fuzzy matching
|
|
117
|
+
// =============================================================================
|
|
118
|
+
|
|
119
|
+
function findPhaseFuzzy(phases: TodoPhase[], query: string): TodoPhase | undefined {
|
|
120
|
+
const q = query.trim().toLowerCase();
|
|
121
|
+
if (!q) return undefined;
|
|
122
|
+
// Exact id
|
|
123
|
+
const byId = phases.find(p => p.id.toLowerCase() === q);
|
|
124
|
+
if (byId) return byId;
|
|
125
|
+
// Exact name (case-insensitive)
|
|
126
|
+
const byName = phases.find(p => p.name.toLowerCase() === q);
|
|
127
|
+
if (byName) return byName;
|
|
128
|
+
// Stripped name match
|
|
129
|
+
const strippedQ = stripPrefix(q);
|
|
130
|
+
const byStripped = phases.find(p => stripPrefix(p.name).toLowerCase() === strippedQ);
|
|
131
|
+
if (byStripped) return byStripped;
|
|
132
|
+
// Substring (prefer prefix match on stripped name)
|
|
133
|
+
const prefixMatches = phases.filter(p => stripPrefix(p.name).toLowerCase().startsWith(strippedQ));
|
|
134
|
+
if (prefixMatches.length === 1) return prefixMatches[0];
|
|
135
|
+
const subMatches = phases.filter(p => stripPrefix(p.name).toLowerCase().includes(strippedQ));
|
|
136
|
+
if (subMatches.length === 1) return subMatches[0];
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function findTaskFuzzy(phases: TodoPhase[], query: string): { task: TodoItem; phase: TodoPhase } | undefined {
|
|
141
|
+
const q = query.trim().toLowerCase();
|
|
142
|
+
if (!q) return undefined;
|
|
143
|
+
for (const phase of phases) {
|
|
144
|
+
for (const task of phase.tasks) {
|
|
145
|
+
if (task.id.toLowerCase() === q) return { task, phase };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const matches: Array<{ task: TodoItem; phase: TodoPhase }> = [];
|
|
149
|
+
for (const phase of phases) {
|
|
150
|
+
for (const task of phase.tasks) {
|
|
151
|
+
if (task.content.toLowerCase().includes(q)) {
|
|
152
|
+
matches.push({ task, phase });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (matches.length === 1) return matches[0];
|
|
157
|
+
// Prefer single in_progress/pending hit when ambiguous
|
|
158
|
+
const active = matches.filter(m => m.task.status === "in_progress" || m.task.status === "pending");
|
|
159
|
+
if (active.length === 1) return active[0];
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// =============================================================================
|
|
164
|
+
// Build system reminder
|
|
165
|
+
// =============================================================================
|
|
166
|
+
|
|
167
|
+
function buildSystemReminder(action: string, phases: TodoPhase[]): string {
|
|
168
|
+
const md = phases.length === 0 ? "(empty)" : phasesToMarkdown(phases).trimEnd();
|
|
169
|
+
return [
|
|
170
|
+
"<system-reminder>",
|
|
171
|
+
`The user manually modified the todo list (${action}).`,
|
|
172
|
+
"Current todo list (note task ids may have been reassigned by /todo edit):",
|
|
173
|
+
"",
|
|
174
|
+
md,
|
|
175
|
+
"</system-reminder>",
|
|
176
|
+
].join("\n");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export class TodoCommandController {
|
|
180
|
+
constructor(private readonly ctx: InteractiveModeContext) {}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* True latest todo state for the user-facing /todo verbs. Reads from session
|
|
184
|
+
* entries so that completed/abandoned tasks remain visible after resume
|
|
185
|
+
* (where `session.getTodoPhases()` would have stripped them).
|
|
186
|
+
*/
|
|
187
|
+
#currentPhases(): TodoPhase[] {
|
|
188
|
+
const fromEntries = getLatestTodoPhasesFromEntries(this.ctx.sessionManager.getBranch());
|
|
189
|
+
if (fromEntries.length > 0) return fromEntries;
|
|
190
|
+
return this.ctx.session.getTodoPhases();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async handleTodoCommand(args: string): Promise<void> {
|
|
194
|
+
const trimmed = args.trim();
|
|
195
|
+
if (!trimmed) {
|
|
196
|
+
this.#showCurrent();
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const spaceIdx = trimmed.search(/\s/);
|
|
201
|
+
const verb = (spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx)).toLowerCase();
|
|
202
|
+
const rest = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1).trim();
|
|
203
|
+
|
|
204
|
+
switch (verb) {
|
|
205
|
+
case "edit":
|
|
206
|
+
await this.#editInExternalEditor();
|
|
207
|
+
return;
|
|
208
|
+
case "copy":
|
|
209
|
+
this.#copyMarkdown();
|
|
210
|
+
return;
|
|
211
|
+
case "export":
|
|
212
|
+
await this.#exportToFile(rest);
|
|
213
|
+
return;
|
|
214
|
+
case "import":
|
|
215
|
+
await this.#importFromFile(rest);
|
|
216
|
+
return;
|
|
217
|
+
case "help":
|
|
218
|
+
case "?":
|
|
219
|
+
this.ctx.showStatus(USAGE);
|
|
220
|
+
return;
|
|
221
|
+
case "append":
|
|
222
|
+
this.#append(rest);
|
|
223
|
+
return;
|
|
224
|
+
case "start":
|
|
225
|
+
this.#start(rest);
|
|
226
|
+
return;
|
|
227
|
+
case "done":
|
|
228
|
+
this.#mutateStatus(rest, "completed");
|
|
229
|
+
return;
|
|
230
|
+
case "drop":
|
|
231
|
+
this.#mutateStatus(rest, "abandoned");
|
|
232
|
+
return;
|
|
233
|
+
case "rm":
|
|
234
|
+
this.#remove(rest);
|
|
235
|
+
return;
|
|
236
|
+
default:
|
|
237
|
+
this.ctx.showError(`Unknown /todo verb "${verb}".\n${USAGE}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
#showCurrent(): void {
|
|
242
|
+
const phases = this.#currentPhases();
|
|
243
|
+
if (phases.length === 0) {
|
|
244
|
+
this.ctx.showStatus("No todos. Use /todo append <task> to start one.");
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
this.ctx.showStatus(phasesToMarkdown(phases).trimEnd());
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
#copyMarkdown(): void {
|
|
251
|
+
const phases = this.#currentPhases();
|
|
252
|
+
if (phases.length === 0) {
|
|
253
|
+
this.ctx.showWarning("No todos to copy.");
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
try {
|
|
257
|
+
copyToClipboard(phasesToMarkdown(phases));
|
|
258
|
+
this.ctx.showStatus("Copied todos as Markdown to clipboard.");
|
|
259
|
+
} catch (error) {
|
|
260
|
+
this.ctx.showError(error instanceof Error ? error.message : String(error));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
#resolveTodoPath(rest: string): string {
|
|
265
|
+
const trimmed = rest.trim();
|
|
266
|
+
const raw = trimmed || "TODO.md";
|
|
267
|
+
return resolveToCwd(raw, this.ctx.sessionManager.getCwd());
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async #exportToFile(rest: string): Promise<void> {
|
|
271
|
+
const phases = this.#currentPhases();
|
|
272
|
+
if (phases.length === 0) {
|
|
273
|
+
this.ctx.showWarning("No todos to export.");
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const target = this.#resolveTodoPath(rest);
|
|
277
|
+
try {
|
|
278
|
+
await fs.writeFile(target, phasesToMarkdown(phases), "utf8");
|
|
279
|
+
this.ctx.showStatus(`Wrote todos to ${target}`);
|
|
280
|
+
} catch (error) {
|
|
281
|
+
this.ctx.showError(`Failed to write ${target}: ${error instanceof Error ? error.message : String(error)}`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async #importFromFile(rest: string): Promise<void> {
|
|
286
|
+
const source = this.#resolveTodoPath(rest);
|
|
287
|
+
let content: string;
|
|
288
|
+
try {
|
|
289
|
+
content = await fs.readFile(source, "utf8");
|
|
290
|
+
} catch (error) {
|
|
291
|
+
this.ctx.showError(`Failed to read ${source}: ${error instanceof Error ? error.message : String(error)}`);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const { phases, errors } = markdownToPhases(content);
|
|
295
|
+
if (errors.length > 0) {
|
|
296
|
+
this.ctx.showError(`Could not parse ${source}:\n ${errors.join("\n ")}`);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
this.#commit(phases, `/todo import ${source}`);
|
|
300
|
+
const taskCount = phases.reduce((sum, p) => sum + p.tasks.length, 0);
|
|
301
|
+
this.ctx.showStatus(`Imported ${phases.length} phase(s), ${taskCount} task(s) from ${source}.`);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ------------------------------------------------------------- append
|
|
305
|
+
|
|
306
|
+
#append(rest: string): void {
|
|
307
|
+
const tokens = tokenize(rest);
|
|
308
|
+
if (tokens.length === 0) {
|
|
309
|
+
this.ctx.showError("Usage: /todo append [<phase>] <task...>");
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const current = this.#currentPhases();
|
|
314
|
+
let phaseName: string | undefined;
|
|
315
|
+
let content: string;
|
|
316
|
+
|
|
317
|
+
if (tokens.length === 1) {
|
|
318
|
+
content = tokens[0];
|
|
319
|
+
} else {
|
|
320
|
+
phaseName = tokens[0];
|
|
321
|
+
content = tokens.slice(1).join(" ");
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const next = current.map(phase => ({ ...phase, tasks: phase.tasks.slice() }));
|
|
325
|
+
let targetPhase: TodoPhase | undefined;
|
|
326
|
+
|
|
327
|
+
if (phaseName) {
|
|
328
|
+
targetPhase = findPhaseFuzzy(next, phaseName);
|
|
329
|
+
if (!targetPhase) {
|
|
330
|
+
const newName = buildPhaseName(phaseName, next);
|
|
331
|
+
targetPhase = { id: `phase-${next.length + 1}`, name: newName, tasks: [] };
|
|
332
|
+
next.push(targetPhase);
|
|
333
|
+
}
|
|
334
|
+
} else if (next.length > 0) {
|
|
335
|
+
targetPhase = next[next.length - 1];
|
|
336
|
+
} else {
|
|
337
|
+
targetPhase = { id: "phase-1", name: `${toRoman(1)}. Todos`, tasks: [] };
|
|
338
|
+
next.push(targetPhase);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const usedTaskIds = new Set(next.flatMap(p => p.tasks.map(t => t.id)));
|
|
342
|
+
let n = 1;
|
|
343
|
+
while (usedTaskIds.has(`task-${n}`)) n++;
|
|
344
|
+
targetPhase.tasks.push({
|
|
345
|
+
id: `task-${n}`,
|
|
346
|
+
content: titleCaseSentence(content),
|
|
347
|
+
status: "pending",
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
this.#commit(next, `/todo append → ${targetPhase.name}`);
|
|
351
|
+
this.ctx.showStatus(`Appended to ${targetPhase.name}: ${content}`);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ------------------------------------------------------------- start / done / drop / rm
|
|
355
|
+
|
|
356
|
+
#start(rest: string): void {
|
|
357
|
+
if (!rest) {
|
|
358
|
+
this.ctx.showError("Usage: /todo start <task>");
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const current = this.#currentPhases();
|
|
362
|
+
const hit = findTaskFuzzy(current, rest);
|
|
363
|
+
if (!hit) {
|
|
364
|
+
this.ctx.showError(`No task matched "${rest}". Use /todo to list current tasks.`);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const { phases, errors } = applyOpsToPhases(current, [{ op: "start", task: hit.task.id }]);
|
|
368
|
+
if (errors.length > 0) {
|
|
369
|
+
this.ctx.showError(errors.join("; "));
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
this.#commit(phases, `/todo start ${hit.task.id}`);
|
|
373
|
+
this.ctx.showStatus(`Started: ${hit.task.content}`);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
#mutateStatus(rest: string, target: "completed" | "abandoned"): void {
|
|
377
|
+
const op = target === "completed" ? "done" : "drop";
|
|
378
|
+
const current = this.#currentPhases();
|
|
379
|
+
const trimmed = rest.trim();
|
|
380
|
+
if (!trimmed) {
|
|
381
|
+
// no-arg: apply to all
|
|
382
|
+
const { phases, errors } = applyOpsToPhases(current, [{ op }]);
|
|
383
|
+
if (errors.length > 0) {
|
|
384
|
+
this.ctx.showError(errors.join("; "));
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
this.#commit(phases, `/todo ${op} (all)`);
|
|
388
|
+
this.ctx.showStatus(`Marked all tasks ${target}.`);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const taskHit = findTaskFuzzy(current, trimmed);
|
|
393
|
+
if (taskHit) {
|
|
394
|
+
const { phases, errors } = applyOpsToPhases(current, [{ op, task: taskHit.task.id }]);
|
|
395
|
+
if (errors.length > 0) {
|
|
396
|
+
this.ctx.showError(errors.join("; "));
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
this.#commit(phases, `/todo ${op} ${taskHit.task.id}`);
|
|
400
|
+
this.ctx.showStatus(`Marked ${target}: ${taskHit.task.content}`);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const phaseHit = findPhaseFuzzy(current, trimmed);
|
|
405
|
+
if (phaseHit) {
|
|
406
|
+
const { phases, errors } = applyOpsToPhases(current, [{ op, phase: phaseHit.id }]);
|
|
407
|
+
if (errors.length > 0) {
|
|
408
|
+
this.ctx.showError(errors.join("; "));
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
this.#commit(phases, `/todo ${op} ${phaseHit.name}`);
|
|
412
|
+
this.ctx.showStatus(`Marked phase ${phaseHit.name} ${target}.`);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
this.ctx.showError(`No task or phase matched "${trimmed}".`);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
#remove(rest: string): void {
|
|
420
|
+
const current = this.#currentPhases();
|
|
421
|
+
const trimmed = rest.trim();
|
|
422
|
+
if (!trimmed) {
|
|
423
|
+
this.#commit([], "/todo rm (all)");
|
|
424
|
+
this.ctx.showStatus("Cleared all todos.");
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
const taskHit = findTaskFuzzy(current, trimmed);
|
|
428
|
+
if (taskHit) {
|
|
429
|
+
const { phases, errors } = applyOpsToPhases(current, [{ op: "rm", task: taskHit.task.id }]);
|
|
430
|
+
if (errors.length > 0) {
|
|
431
|
+
this.ctx.showError(errors.join("; "));
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
this.#commit(phases, `/todo rm ${taskHit.task.id}`);
|
|
435
|
+
this.ctx.showStatus(`Removed: ${taskHit.task.content}`);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
const phaseHit = findPhaseFuzzy(current, trimmed);
|
|
439
|
+
if (phaseHit) {
|
|
440
|
+
const { phases, errors } = applyOpsToPhases(current, [{ op: "rm", phase: phaseHit.id }]);
|
|
441
|
+
if (errors.length > 0) {
|
|
442
|
+
this.ctx.showError(errors.join("; "));
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
this.#commit(phases, `/todo rm ${phaseHit.name}`);
|
|
446
|
+
this.ctx.showStatus(`Removed phase: ${phaseHit.name}`);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
this.ctx.showError(`No task or phase matched "${trimmed}".`);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ------------------------------------------------------------- editor
|
|
453
|
+
|
|
454
|
+
async #editInExternalEditor(): Promise<void> {
|
|
455
|
+
const editorCmd = getEditorCommand();
|
|
456
|
+
if (!editorCmd) {
|
|
457
|
+
this.ctx.showWarning("No editor configured. Set $VISUAL or $EDITOR environment variable.");
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const current = this.#currentPhases();
|
|
462
|
+
const initialMarkdown =
|
|
463
|
+
current.length > 0 ? phasesToMarkdown(current) : "# I. Todos\n- [ ] (replace this with your tasks)\n";
|
|
464
|
+
|
|
465
|
+
const fileHandle = await this.#openTtyHandle();
|
|
466
|
+
this.ctx.ui.stop();
|
|
467
|
+
try {
|
|
468
|
+
const stdio: [number | "inherit", number | "inherit", number | "inherit"] = fileHandle
|
|
469
|
+
? [fileHandle.fd, fileHandle.fd, fileHandle.fd]
|
|
470
|
+
: ["inherit", "inherit", "inherit"];
|
|
471
|
+
const result = await openInEditor(editorCmd, initialMarkdown, {
|
|
472
|
+
extension: ".todo.md",
|
|
473
|
+
stdio,
|
|
474
|
+
});
|
|
475
|
+
if (result === null) {
|
|
476
|
+
this.ctx.showWarning("Editor exited without saving; todos unchanged.");
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
const { phases: parsed, errors } = markdownToPhases(result);
|
|
480
|
+
if (errors.length > 0) {
|
|
481
|
+
this.ctx.showError(`Could not parse Markdown:\n ${errors.join("\n ")}`);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
this.#commit(parsed, "/todo edit");
|
|
485
|
+
const taskCount = parsed.reduce((sum, p) => sum + p.tasks.length, 0);
|
|
486
|
+
this.ctx.showStatus(`Todos updated from editor: ${parsed.length} phase(s), ${taskCount} task(s).`);
|
|
487
|
+
} catch (error) {
|
|
488
|
+
this.ctx.showWarning(
|
|
489
|
+
`Failed to open external editor: ${error instanceof Error ? error.message : String(error)}`,
|
|
490
|
+
);
|
|
491
|
+
} finally {
|
|
492
|
+
if (fileHandle) {
|
|
493
|
+
await fileHandle.close().catch(() => {});
|
|
494
|
+
}
|
|
495
|
+
this.ctx.ui.start();
|
|
496
|
+
this.ctx.ui.requestRender();
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async #openTtyHandle(): Promise<fs.FileHandle | null> {
|
|
501
|
+
const stdinPath = (process.stdin as unknown as { path?: string }).path;
|
|
502
|
+
const candidate = typeof stdinPath === "string" ? stdinPath : undefined;
|
|
503
|
+
if (!candidate) return null;
|
|
504
|
+
try {
|
|
505
|
+
return await fs.open(candidate, "r+");
|
|
506
|
+
} catch {
|
|
507
|
+
return null;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
#commit(nextPhases: TodoPhase[], action: string): void {
|
|
512
|
+
// 1. In-memory + UI state
|
|
513
|
+
this.ctx.session.setTodoPhases(nextPhases);
|
|
514
|
+
this.ctx.setTodos(nextPhases);
|
|
515
|
+
|
|
516
|
+
// 2. Persist for reload survival via custom session entry.
|
|
517
|
+
this.ctx.sessionManager.appendCustomEntry(USER_TODO_EDIT_CUSTOM_TYPE, { phases: nextPhases });
|
|
518
|
+
|
|
519
|
+
// 3. Inject system reminder so the agent learns about the change next turn.
|
|
520
|
+
const reminderText = buildSystemReminder(action, nextPhases);
|
|
521
|
+
const message = {
|
|
522
|
+
role: "developer" as const,
|
|
523
|
+
content: [{ type: "text" as const, text: reminderText }],
|
|
524
|
+
attribution: "user" as const,
|
|
525
|
+
timestamp: Date.now(),
|
|
526
|
+
};
|
|
527
|
+
this.ctx.agent.appendMessage(message);
|
|
528
|
+
this.ctx.sessionManager.appendMessage(message);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** Capitalize first letter only — keeps acronyms / casing in the rest of the sentence intact. */
|
|
533
|
+
function titleCaseSentence(s: string): string {
|
|
534
|
+
const trimmed = s.trim();
|
|
535
|
+
if (!trimmed) return trimmed;
|
|
536
|
+
return trimmed[0].toUpperCase() + trimmed.slice(1);
|
|
537
|
+
}
|
|
@@ -61,6 +61,7 @@ import { InputController } from "./controllers/input-controller";
|
|
|
61
61
|
import { MCPCommandController } from "./controllers/mcp-command-controller";
|
|
62
62
|
import { SelectorController } from "./controllers/selector-controller";
|
|
63
63
|
import { SSHCommandController } from "./controllers/ssh-command-controller";
|
|
64
|
+
import { TodoCommandController } from "./controllers/todo-command-controller";
|
|
64
65
|
import { OAuthManualInputManager } from "./oauth-manual-input";
|
|
65
66
|
import { SessionObserverRegistry } from "./session-observer-registry";
|
|
66
67
|
import type { Theme } from "./theme/theme";
|
|
@@ -80,6 +81,28 @@ const EDITOR_MAX_HEIGHT_MAX = 18;
|
|
|
80
81
|
const EDITOR_RESERVED_ROWS = 12;
|
|
81
82
|
const EDITOR_FALLBACK_ROWS = 24;
|
|
82
83
|
|
|
84
|
+
const HUD_NOTE_SUP_DIGITS: Record<string, string> = {
|
|
85
|
+
"0": "\u2070",
|
|
86
|
+
"1": "\u00b9",
|
|
87
|
+
"2": "\u00b2",
|
|
88
|
+
"3": "\u00b3",
|
|
89
|
+
"4": "\u2074",
|
|
90
|
+
"5": "\u2075",
|
|
91
|
+
"6": "\u2076",
|
|
92
|
+
"7": "\u2077",
|
|
93
|
+
"8": "\u2078",
|
|
94
|
+
"9": "\u2079",
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
function formatHudNoteMarker(count: number): string {
|
|
98
|
+
if (count <= 0) return "";
|
|
99
|
+
const sub = String(count)
|
|
100
|
+
.split("")
|
|
101
|
+
.map(d => HUD_NOTE_SUP_DIGITS[d] ?? d)
|
|
102
|
+
.join("");
|
|
103
|
+
return theme.fg("dim", chalk.italic(` \u207a${sub}`));
|
|
104
|
+
}
|
|
105
|
+
|
|
83
106
|
/** Options for creating an InteractiveMode instance (for future API use) */
|
|
84
107
|
export interface InteractiveModeOptions {
|
|
85
108
|
/** Providers that were migrated during startup */
|
|
@@ -173,6 +196,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
173
196
|
|
|
174
197
|
readonly #btwController: BtwController;
|
|
175
198
|
readonly #commandController: CommandController;
|
|
199
|
+
readonly #todoCommandController: TodoCommandController;
|
|
176
200
|
readonly #eventController: EventController;
|
|
177
201
|
readonly #extensionUiController: ExtensionUiController;
|
|
178
202
|
readonly #inputController: InputController;
|
|
@@ -287,6 +311,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
287
311
|
this.#extensionUiController = new ExtensionUiController(this);
|
|
288
312
|
this.#eventController = new EventController(this);
|
|
289
313
|
this.#commandController = new CommandController(this);
|
|
314
|
+
this.#todoCommandController = new TodoCommandController(this);
|
|
290
315
|
this.#selectorController = new SelectorController(this);
|
|
291
316
|
this.#inputController = new InputController(this);
|
|
292
317
|
this.#observerRegistry = new SessionObserverRegistry();
|
|
@@ -563,19 +588,16 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
563
588
|
|
|
564
589
|
#formatTodoLine(todo: TodoItem, prefix: string): string {
|
|
565
590
|
const checkbox = theme.checkbox;
|
|
591
|
+
const marker = formatHudNoteMarker(todo.notes?.length ?? 0);
|
|
566
592
|
switch (todo.status) {
|
|
567
593
|
case "completed":
|
|
568
|
-
return theme.fg("success", `${prefix}${checkbox.checked} ${chalk.strikethrough(todo.content)}`);
|
|
569
|
-
case "in_progress":
|
|
570
|
-
|
|
571
|
-
if (!todo.details) return main;
|
|
572
|
-
const detailLines = todo.details.split("\n").map(line => theme.fg("dim", `${prefix} ${line}`));
|
|
573
|
-
return [main, ...detailLines].join("\n");
|
|
574
|
-
}
|
|
594
|
+
return theme.fg("success", `${prefix}${checkbox.checked} ${chalk.strikethrough(todo.content)}`) + marker;
|
|
595
|
+
case "in_progress":
|
|
596
|
+
return theme.fg("accent", `${prefix}${checkbox.unchecked} ${todo.content}`) + marker;
|
|
575
597
|
case "abandoned":
|
|
576
|
-
return theme.fg("error", `${prefix}${checkbox.unchecked} ${chalk.strikethrough(todo.content)}`);
|
|
598
|
+
return theme.fg("error", `${prefix}${checkbox.unchecked} ${chalk.strikethrough(todo.content)}`) + marker;
|
|
577
599
|
default:
|
|
578
|
-
return theme.fg("dim", `${prefix}${checkbox.unchecked} ${todo.content}`);
|
|
600
|
+
return theme.fg("dim", `${prefix}${checkbox.unchecked} ${todo.content}`) + marker;
|
|
579
601
|
}
|
|
580
602
|
}
|
|
581
603
|
|
|
@@ -1274,6 +1296,10 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
1274
1296
|
return this.#commandController.handleCopyCommand(sub);
|
|
1275
1297
|
}
|
|
1276
1298
|
|
|
1299
|
+
handleTodoCommand(args: string): Promise<void> {
|
|
1300
|
+
return this.#todoCommandController.handleTodoCommand(args);
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1277
1303
|
handleSessionCommand(): Promise<void> {
|
|
1278
1304
|
return this.#commandController.handleSessionCommand();
|
|
1279
1305
|
}
|
|
@@ -1298,13 +1324,22 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
1298
1324
|
this.#commandController.handleToolsCommand();
|
|
1299
1325
|
}
|
|
1300
1326
|
|
|
1301
|
-
|
|
1327
|
+
#prepareSessionSwitch(): void {
|
|
1302
1328
|
this.#btwController.dispose();
|
|
1303
1329
|
this.#extensionUiController.clearExtensionTerminalInputListeners();
|
|
1304
1330
|
this.#planReviewContainer = undefined;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
handleClearCommand(): Promise<void> {
|
|
1334
|
+
this.#prepareSessionSwitch();
|
|
1305
1335
|
return this.#commandController.handleClearCommand();
|
|
1306
1336
|
}
|
|
1307
1337
|
|
|
1338
|
+
handleDropCommand(): Promise<void> {
|
|
1339
|
+
this.#prepareSessionSwitch();
|
|
1340
|
+
return this.#commandController.handleDropCommand();
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1308
1343
|
handleForkCommand(): Promise<void> {
|
|
1309
1344
|
this.#btwController.dispose();
|
|
1310
1345
|
return this.#commandController.handleForkCommand();
|
package/src/modes/types.ts
CHANGED
|
@@ -46,6 +46,7 @@ export type TodoItem = {
|
|
|
46
46
|
content: string;
|
|
47
47
|
status: TodoStatus;
|
|
48
48
|
details?: string;
|
|
49
|
+
notes?: string[];
|
|
49
50
|
};
|
|
50
51
|
|
|
51
52
|
export type TodoPhase = {
|
|
@@ -170,6 +171,7 @@ export interface InteractiveModeContext {
|
|
|
170
171
|
handleExportCommand(text: string): Promise<void>;
|
|
171
172
|
handleShareCommand(): Promise<void>;
|
|
172
173
|
handleCopyCommand(sub?: string): void;
|
|
174
|
+
handleTodoCommand(args: string): Promise<void>;
|
|
173
175
|
handleSessionCommand(): Promise<void>;
|
|
174
176
|
handleJobsCommand(): Promise<void>;
|
|
175
177
|
handleUsageCommand(reports?: UsageReport[] | null): Promise<void>;
|
|
@@ -179,6 +181,7 @@ export interface InteractiveModeContext {
|
|
|
179
181
|
handleDumpCommand(): void;
|
|
180
182
|
handleDebugTranscriptCommand(): Promise<void>;
|
|
181
183
|
handleClearCommand(): Promise<void>;
|
|
184
|
+
handleDropCommand(): Promise<void>;
|
|
182
185
|
handleForkCommand(): Promise<void>;
|
|
183
186
|
handleBashCommand(command: string, excludeFromContext?: boolean): Promise<void>;
|
|
184
187
|
handlePythonCommand(code: string, excludeFromContext?: boolean): Promise<void>;
|
|
@@ -127,6 +127,23 @@ export class UiHelpers {
|
|
|
127
127
|
this.ctx.chatContainer.addChild(component);
|
|
128
128
|
break;
|
|
129
129
|
}
|
|
130
|
+
if (message.customType === "irc:incoming" || message.customType === "irc:autoreply") {
|
|
131
|
+
const details = (
|
|
132
|
+
message as CustomMessage<{ from?: string; to?: string; message?: string; reply?: string }>
|
|
133
|
+
).details;
|
|
134
|
+
const isIncoming = message.customType === "irc:incoming";
|
|
135
|
+
const peer = isIncoming ? (details?.from ?? "?") : (details?.to ?? "?");
|
|
136
|
+
const body = isIncoming ? (details?.message ?? "") : (details?.reply ?? "");
|
|
137
|
+
const arrow = isIncoming ? `\u21e6 ${peer}` : `\u21e8 ${peer} (auto)`;
|
|
138
|
+
const header = `${theme.fg("accent", `[IRC] ${arrow}`)}`;
|
|
139
|
+
this.ctx.chatContainer.addChild(new Text(header, 1, 0));
|
|
140
|
+
if (body) {
|
|
141
|
+
for (const line of body.split("\n")) {
|
|
142
|
+
this.ctx.chatContainer.addChild(new Text(theme.fg("muted", ` ${line}`), 0, 0));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
130
147
|
const renderer = this.ctx.session.extensionRunner?.getMessageRenderer(message.customType);
|
|
131
148
|
// Both HookMessage and CustomMessage have the same structure, cast for compatibility
|
|
132
149
|
const component = new CustomMessageComponent(message as CustomMessage<unknown>, renderer);
|