@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.
Files changed (71) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/package.json +7 -7
  3. package/src/cli.ts +0 -1
  4. package/src/config/prompt-templates.ts +0 -30
  5. package/src/config/settings-schema.ts +68 -36
  6. package/src/config/settings.ts +1 -1
  7. package/src/edit/index.ts +1 -53
  8. package/src/edit/line-hash.ts +0 -53
  9. package/src/edit/modes/atom.ts +82 -47
  10. package/src/edit/modes/hashline.ts +6 -8
  11. package/src/edit/renderer.ts +6 -8
  12. package/src/edit/streaming.ts +90 -114
  13. package/src/export/html/template.generated.ts +1 -1
  14. package/src/export/html/template.js +10 -15
  15. package/src/internal-urls/docs-index.generated.ts +1 -2
  16. package/src/modes/components/session-observer-overlay.ts +635 -295
  17. package/src/modes/components/settings-defs.ts +1 -5
  18. package/src/modes/components/tool-execution.ts +2 -5
  19. package/src/modes/controllers/btw-controller.ts +17 -105
  20. package/src/modes/controllers/command-controller.ts +16 -5
  21. package/src/modes/controllers/selector-controller.ts +32 -19
  22. package/src/modes/controllers/todo-command-controller.ts +537 -0
  23. package/src/modes/interactive-mode.ts +45 -10
  24. package/src/modes/types.ts +3 -0
  25. package/src/modes/utils/ui-helpers.ts +17 -0
  26. package/src/prompts/system/irc-incoming.md +8 -0
  27. package/src/prompts/system/subagent-system-prompt.md +8 -0
  28. package/src/prompts/tools/ast-grep.md +1 -1
  29. package/src/prompts/tools/atom.md +37 -26
  30. package/src/prompts/tools/bash.md +2 -2
  31. package/src/prompts/tools/grep.md +2 -5
  32. package/src/prompts/tools/irc.md +49 -0
  33. package/src/prompts/tools/job.md +11 -0
  34. package/src/prompts/tools/read.md +12 -13
  35. package/src/prompts/tools/task.md +1 -1
  36. package/src/prompts/tools/todo-write.md +14 -5
  37. package/src/registry/agent-registry.ts +139 -0
  38. package/src/sdk.ts +35 -0
  39. package/src/session/agent-session.ts +226 -6
  40. package/src/session/session-manager.ts +13 -0
  41. package/src/session/session-storage.ts +4 -0
  42. package/src/session/streaming-output.ts +1 -1
  43. package/src/slash-commands/builtin-registry.ts +32 -0
  44. package/src/task/executor.ts +14 -0
  45. package/src/tools/bash.ts +1 -1
  46. package/src/tools/fetch.ts +18 -6
  47. package/src/tools/fs-cache-invalidation.ts +0 -5
  48. package/src/tools/grep.ts +4 -124
  49. package/src/tools/index.ts +12 -6
  50. package/src/tools/irc.ts +258 -0
  51. package/src/tools/job.ts +489 -0
  52. package/src/tools/match-line-format.ts +7 -6
  53. package/src/tools/output-meta.ts +1 -1
  54. package/src/tools/read.ts +36 -126
  55. package/src/tools/renderers.ts +2 -0
  56. package/src/tools/todo-write.ts +243 -12
  57. package/src/utils/edit-mode.ts +1 -2
  58. package/src/utils/file-display-mode.ts +0 -3
  59. package/src/web/search/index.ts +2 -2
  60. package/src/web/search/provider.ts +3 -0
  61. package/src/web/search/providers/searxng.ts +238 -0
  62. package/src/web/search/types.ts +3 -1
  63. package/src/cli/read-cli.ts +0 -67
  64. package/src/commands/read.ts +0 -33
  65. package/src/edit/modes/chunk.ts +0 -832
  66. package/src/prompts/tools/cancel-job.md +0 -5
  67. package/src/prompts/tools/chunk-edit.md +0 -158
  68. package/src/prompts/tools/poll.md +0 -5
  69. package/src/prompts/tools/read-chunk.md +0 -73
  70. package/src/tools/cancel-job.ts +0 -95
  71. 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
- const main = theme.fg("accent", `${prefix}${checkbox.unchecked} ${todo.content}`);
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
- handleClearCommand(): Promise<void> {
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();
@@ -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);