@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
package/src/tools/read.ts CHANGED
@@ -9,20 +9,11 @@ import { Text } from "@oh-my-pi/pi-tui";
9
9
  import { getRemoteDir, prompt, readImageMetadata, untilAborted } from "@oh-my-pi/pi-utils";
10
10
  import { type Static, Type } from "@sinclair/typebox";
11
11
  import { formatHashLines } from "../edit/line-hash";
12
- import {
13
- type ChunkReadTarget,
14
- formatChunkedRead,
15
- parseChunkReadPath,
16
- parseChunkSelector,
17
- resolveAnchorStyle,
18
- resolveChunkAutoIndent,
19
- } from "../edit/modes/chunk";
20
12
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
21
13
  import { parseInternalUrl } from "../internal-urls/parse";
22
14
  import type { InternalUrl } from "../internal-urls/types";
23
15
  import { getLanguageFromPath, type Theme } from "../modes/theme/theme";
24
16
  import readDescription from "../prompts/tools/read.md" with { type: "text" };
25
- import readChunkDescription from "../prompts/tools/read-chunk.md" with { type: "text" };
26
17
  import type { ToolSession } from "../sdk";
27
18
  import {
28
19
  DEFAULT_MAX_BYTES,
@@ -34,7 +25,6 @@ import {
34
25
  } from "../session/streaming-output";
35
26
  import { renderCodeCell, renderStatusLine } from "../tui";
36
27
  import { CachedOutputBlock } from "../tui/output-block";
37
- import { resolveEditMode } from "../utils/edit-mode";
38
28
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
39
29
  import { ImageInputTooLargeError, loadImageInput, MAX_IMAGE_INPUT_BYTES } from "../utils/image-loading";
40
30
  import { convertFileWithMarkit } from "../utils/markit";
@@ -71,12 +61,6 @@ import {
71
61
  import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
72
62
  import { toolResult } from "./tool-result";
73
63
 
74
- const PROSE_LANGUAGES = new Set(["markdown", "text", "log", "asciidoc", "restructuredtext"]);
75
-
76
- function isProseLanguage(language: string | undefined): boolean {
77
- return language !== undefined && PROSE_LANGUAGES.has(language);
78
- }
79
-
80
64
  // Document types converted to markdown via markit.
81
65
  const CONVERTIBLE_EXTENSIONS = new Set([".pdf", ".doc", ".docx", ".ppt", ".pptx", ".xls", ".xlsx", ".rtf", ".epub"]);
82
66
 
@@ -358,7 +342,7 @@ function prependSuffixResolutionNotice(text: string, suffixResolution?: { from:
358
342
 
359
343
  const readSchema = Type.Object({
360
344
  path: Type.String({ description: "path or url", examples: ["src/foo.ts", "https://example.com"] }),
361
- sel: Type.Optional(Type.String({ description: "line range or mode", examples: ["L50", "L50-L120", "raw"] })),
345
+ sel: Type.Optional(Type.String({ description: "line range or mode", examples: ["50", "50-200", "50+150", "raw"] })),
362
346
  timeout: Type.Optional(Type.Number({ description: "timeout in seconds", default: 20 })),
363
347
  });
364
348
 
@@ -370,7 +354,6 @@ export interface ReadToolDetails {
370
354
  isDirectory?: boolean;
371
355
  resolvedPath?: string;
372
356
  suffixResolution?: { from: string; to: string };
373
- chunk?: ChunkReadTarget;
374
357
  url?: string;
375
358
  finalUrl?: string;
376
359
  contentType?: string;
@@ -389,28 +372,37 @@ type ReadParams = ReadToolInput;
389
372
  type ParsedSelector =
390
373
  | { kind: "none" }
391
374
  | { kind: "raw" }
392
- | { kind: "lines"; startLine: number; endLine: number | undefined }
393
- | { kind: "chunk"; selector: string };
375
+ | { kind: "lines"; startLine: number; endLine: number | undefined };
394
376
 
395
- const LINE_RANGE_RE = /^L(\d+)(?:-L?(\d+))?$/i;
377
+ const LINE_RANGE_RE = /^L?(\d+)(?:([-+])L?(\d+))?$/i;
396
378
 
397
379
  function parseSel(sel: string | undefined): ParsedSelector {
398
380
  if (!sel || sel.length === 0) return { kind: "none" };
399
- const normalizedSelector = parseChunkSelector(sel).selector ?? sel;
400
- if (normalizedSelector === "raw") return { kind: "raw" };
401
- const lineMatch = LINE_RANGE_RE.exec(normalizedSelector);
381
+ if (sel === "raw") return { kind: "raw" };
382
+ const lineMatch = LINE_RANGE_RE.exec(sel);
402
383
  if (lineMatch) {
403
384
  const rawStart = Number.parseInt(lineMatch[1]!, 10);
404
385
  if (rawStart < 1) {
405
- throw new ToolError("L0 is invalid; lines are 1-indexed. Use sel=L1.");
386
+ throw new ToolError("sel=0 is invalid; lines are 1-indexed. Use sel=1.");
406
387
  }
407
- const rawEnd = lineMatch[2] ? Number.parseInt(lineMatch[2], 10) : undefined;
408
- if (rawEnd !== undefined && rawEnd < rawStart) {
409
- throw new ToolError(`Invalid range L${rawStart}-L${rawEnd}: end must be >= start.`);
388
+ const sep = lineMatch[2];
389
+ const rhs = lineMatch[3] ? Number.parseInt(lineMatch[3], 10) : undefined;
390
+ let rawEnd: number | undefined;
391
+ if (sep === "+") {
392
+ if (rhs === undefined || rhs < 1) {
393
+ throw new ToolError(`Invalid range ${rawStart}+${rhs ?? 0}: count must be >= 1.`);
394
+ }
395
+ rawEnd = rawStart + rhs - 1;
396
+ } else if (sep === "-") {
397
+ if (rhs === undefined || rhs < rawStart) {
398
+ throw new ToolError(`Invalid range ${rawStart}-${rhs ?? 0}: end must be >= start.`);
399
+ }
400
+ rawEnd = rhs;
410
401
  }
411
402
  return { kind: "lines", startLine: rawStart, endLine: rawEnd };
412
403
  }
413
- return { kind: "chunk", selector: normalizedSelector };
404
+ // Unrecognized selectors fall through; sqlite/archive/url readers consume `sel` themselves.
405
+ return { kind: "none" };
414
406
  }
415
407
 
416
408
  /** Convert a line-range selector to the offset/limit pair used by internal pagination. */
@@ -477,18 +469,12 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
477
469
  Math.min(session.settings.get("read.defaultLimit") ?? DEFAULT_MAX_LINES, DEFAULT_MAX_LINES),
478
470
  );
479
471
  this.#inspectImageEnabled = session.settings.get("inspect_image.enabled");
480
- this.description =
481
- resolveEditMode(session) === "chunk"
482
- ? prompt.render(readChunkDescription, {
483
- anchorStyle: resolveAnchorStyle(session.settings),
484
- chunkAutoIndent: resolveChunkAutoIndent(),
485
- })
486
- : prompt.render(readDescription, {
487
- DEFAULT_LIMIT: String(this.#defaultLimit),
488
- DEFAULT_MAX_LINES: String(DEFAULT_MAX_LINES),
489
- IS_HASHLINE_MODE: displayMode.hashLines,
490
- IS_LINE_NUMBER_MODE: !displayMode.hashLines && displayMode.lineNumbers,
491
- });
472
+ this.description = prompt.render(readDescription, {
473
+ DEFAULT_LIMIT: String(this.#defaultLimit),
474
+ DEFAULT_MAX_LINES: String(DEFAULT_MAX_LINES),
475
+ IS_HASHLINE_MODE: displayMode.hashLines,
476
+ IS_LINE_NUMBER_MODE: !displayMode.hashLines && displayMode.lineNumbers,
477
+ });
492
478
  }
493
479
 
494
480
  async #resolveArchiveReadPath(readPath: string, signal?: AbortSignal): Promise<ResolvedArchiveReadPath | null> {
@@ -617,7 +603,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
617
603
  const suggestion =
618
604
  allLines.length === 0
619
605
  ? `The ${options.entityLabel} is empty.`
620
- : `Use sel=L1 to read from the start, or sel=L${allLines.length} to read the last line.`;
606
+ : `Use sel=1 to read from the start, or sel=${allLines.length} to read the last line.`;
621
607
  return resultBuilder
622
608
  .text(
623
609
  `Line ${startLineDisplay} is beyond end of ${options.entityLabel} (${allLines.length} lines total). ${suggestion}`,
@@ -679,7 +665,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
679
665
  const nextOffset = startLine + userLimitedLines + 1;
680
666
 
681
667
  outputText = formatText(selectedContent, startLineDisplay);
682
- outputText += `\n\n[${remaining} more lines in ${options.entityLabel}. Use sel=L${nextOffset} to continue]`;
668
+ outputText += `\n\n[${remaining} more lines in ${options.entityLabel}. Use sel=${nextOffset} to continue]`;
683
669
  } else {
684
670
  outputText = formatText(truncation.content, startLineDisplay);
685
671
  }
@@ -930,7 +916,6 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
930
916
  readPath = expandPath(readPath);
931
917
  }
932
918
  const displayMode = resolveFileDisplayMode(this.session);
933
- const chunkMode = resolveEditMode(this.session) === "chunk";
934
919
 
935
920
  // Handle internal URLs (agent://, artifact://, memory://, skill://, rule://, local://, mcp://)
936
921
  const internalRouter = this.session.internalRouter;
@@ -964,13 +949,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
964
949
  return executeReadUrl(this.session, { path: parsedUrlTarget.path, timeout, raw: parsedUrlTarget.raw }, signal);
965
950
  }
966
951
 
967
- const parsedReadPath = chunkMode ? parseChunkReadPath(readPath) : { filePath: readPath };
968
- const localReadPath = parsedReadPath.filePath;
969
- const pathSelectorParsed = chunkMode ? parseSel(parsedReadPath.selector) : { kind: "none" as const };
970
- const pathChunkSelector = pathSelectorParsed.kind === "chunk" ? pathSelectorParsed.selector : undefined;
971
- const selectorInput = sel ?? parsedReadPath.selector;
972
- const rawSelectorInput = sel ?? parsedReadPath.selector;
973
- const parsed = parseSel(selectorInput);
952
+ const localReadPath = readPath;
953
+ const parsed = parseSel(sel);
974
954
 
975
955
  const archivePath = await this.#resolveArchiveReadPath(localReadPath, signal);
976
956
  if (archivePath) {
@@ -1030,54 +1010,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1030
1010
  const imageMetadata = await readImageMetadata(absolutePath);
1031
1011
  const mimeType = imageMetadata?.mimeType;
1032
1012
  const ext = path.extname(absolutePath).toLowerCase();
1033
- const hasEditTool = this.session.hasEditTool ?? true;
1034
- const language = getLanguageFromPath(absolutePath);
1035
- const skipChunksForExplore = !hasEditTool && !this.session.settings.get("read.explorechunks");
1036
- const skipChunksForProse = isProseLanguage(language) && !this.session.settings.get("read.prosechunks");
1037
- const shouldConvertWithMarkit =
1038
- CONVERTIBLE_EXTENSIONS.has(ext) || (ext === ".ipynb" && (parsed.kind === "raw" || !chunkMode));
1039
-
1040
- if (chunkMode && parsed.kind !== "raw" && !skipChunksForExplore && !skipChunksForProse) {
1041
- const absoluteLineRange =
1042
- pathChunkSelector && parsed.kind === "lines"
1043
- ? { startLine: parsed.startLine, endLine: parsed.endLine }
1044
- : undefined;
1045
- // sel= wins over path:chunk when both are provided (explicit param > embedded path).
1046
- const effectiveSelector = sel ? selectorInput : (pathChunkSelector ?? selectorInput);
1047
- const rawEffectiveSelector = sel ? selectorInput : (rawSelectorInput ?? effectiveSelector);
1048
- const chunkReadPath =
1049
- parsed.kind === "chunk" || (pathChunkSelector && !sel)
1050
- ? rawEffectiveSelector
1051
- ? `${localReadPath}:${rawEffectiveSelector}`
1052
- : localReadPath
1053
- : parsed.kind === "lines"
1054
- ? parsed.endLine !== undefined
1055
- ? `${localReadPath}:L${parsed.startLine}-L${parsed.endLine}`
1056
- : `${localReadPath}:L${parsed.startLine}`
1057
- : localReadPath;
1058
- const chunkResult = await formatChunkedRead({
1059
- filePath: absolutePath,
1060
- readPath: chunkReadPath,
1061
- cwd: this.session.cwd,
1062
- language,
1063
- omitChecksum: !hasEditTool,
1064
- anchorStyle: resolveAnchorStyle(this.session.settings),
1065
- absoluteLineRange,
1066
- });
1067
- let text = chunkResult.text;
1068
- if (suffixResolution) {
1069
- text = prependSuffixResolutionNotice(text, suffixResolution);
1070
- }
1071
- return toolResult<ReadToolDetails>({
1072
- resolvedPath: absolutePath,
1073
- suffixResolution,
1074
- chunk: chunkResult.chunk,
1075
- })
1076
- .text(text)
1077
- .sourcePath(absolutePath)
1078
- .done();
1079
- }
1080
-
1013
+ const _hasEditTool = this.session.hasEditTool ?? true;
1014
+ const _language = getLanguageFromPath(absolutePath);
1015
+ const shouldConvertWithMarkit = CONVERTIBLE_EXTENSIONS.has(ext) || (ext === ".ipynb" && parsed.kind === "raw");
1081
1016
  // Read the file based on type
1082
1017
  let content: Array<TextContent | ImageContent>;
1083
1018
  let details: ReadToolDetails = {};
@@ -1160,31 +1095,6 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1160
1095
  content = [{ type: "text", text: `[Cannot read ${ext} file: conversion failed]` }];
1161
1096
  }
1162
1097
  } else {
1163
- // Chunk mode: dispatch to chunk tree unless raw or line range requested
1164
- if (chunkMode && parsed.kind !== "raw" && parsed.kind !== "lines") {
1165
- const chunkSel = parsed.kind === "chunk" ? parsed.selector : undefined;
1166
- const chunkResult = await formatChunkedRead({
1167
- filePath: absolutePath,
1168
- readPath: chunkSel ? `${localReadPath}:${chunkSel}` : localReadPath,
1169
- cwd: this.session.cwd,
1170
- language: getLanguageFromPath(absolutePath),
1171
- omitChecksum: !(this.session.hasEditTool ?? true),
1172
- anchorStyle: resolveAnchorStyle(this.session.settings),
1173
- });
1174
- let text = chunkResult.text;
1175
- if (suffixResolution) {
1176
- text = prependSuffixResolutionNotice(text, suffixResolution);
1177
- }
1178
- return toolResult<ReadToolDetails>({
1179
- resolvedPath: absolutePath,
1180
- suffixResolution,
1181
- chunk: chunkResult.chunk,
1182
- })
1183
- .text(text)
1184
- .sourcePath(absolutePath)
1185
- .done();
1186
- }
1187
-
1188
1098
  // Raw text or line-range mode
1189
1099
  const { offset, limit } = selToOffsetLimit(parsed);
1190
1100
  const startLine = offset ? Math.max(0, offset - 1) : 0;
@@ -1218,7 +1128,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1218
1128
  const suggestion =
1219
1129
  totalFileLines === 0
1220
1130
  ? "The file is empty."
1221
- : `Use sel=L1 to read from the start, or sel=L${totalFileLines} to read the last line.`;
1131
+ : `Use sel=1 to read from the start, or sel=${totalFileLines} to read the last line.`;
1222
1132
  return toolResult<ReadToolDetails>({ resolvedPath: absolutePath, suffixResolution })
1223
1133
  .text(`Line ${startLineDisplay} is beyond end of file (${totalFileLines} lines total). ${suggestion}`)
1224
1134
  .done();
@@ -1290,7 +1200,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1290
1200
  const nextOffset = startLine + userLimitedLines + 1;
1291
1201
 
1292
1202
  outputText = formatText(truncation.content, startLineDisplay);
1293
- outputText += `\n\n[${remaining} more lines in file. Use sel=L${nextOffset} to continue]`;
1203
+ outputText += `\n\n[${remaining} more lines in file. Use sel=${nextOffset} to continue]`;
1294
1204
  details = {};
1295
1205
  sourcePath = absolutePath;
1296
1206
  } else {
@@ -20,6 +20,7 @@ import { findToolRenderer } from "./find";
20
20
  import { githubToolRenderer } from "./gh-renderer";
21
21
  import { grepToolRenderer } from "./grep";
22
22
  import { inspectImageToolRenderer } from "./inspect-image-renderer";
23
+ import { jobToolRenderer } from "./job";
23
24
  import { notebookToolRenderer } from "./notebook";
24
25
  import { pythonToolRenderer } from "./python";
25
26
  import { readToolRenderer } from "./read";
@@ -58,6 +59,7 @@ export const toolRenderers: Record<string, ToolRenderer> = {
58
59
  notebook: notebookToolRenderer as ToolRenderer,
59
60
  inspect_image: inspectImageToolRenderer as ToolRenderer,
60
61
  read: readToolRenderer as ToolRenderer,
62
+ job: jobToolRenderer as ToolRenderer,
61
63
  resolve: resolveToolRenderer as ToolRenderer,
62
64
  search_tool_bm25: searchToolBm25Renderer as ToolRenderer,
63
65
  ssh: sshToolRenderer as ToolRenderer,
@@ -23,6 +23,13 @@ export interface TodoItem {
23
23
  id: string;
24
24
  content: string;
25
25
  status: TodoStatus;
26
+ /**
27
+ * Append-only list of freeform notes attached by `op: "note"`.
28
+ * Each element is one note and may itself be multi-line.
29
+ * Rendered as text only when the task is in_progress; otherwise shown as a
30
+ * dim marker indicating the task has notes.
31
+ */
32
+ notes?: string[];
26
33
  }
27
34
 
28
35
  export interface TodoPhase {
@@ -40,7 +47,7 @@ export interface TodoWriteToolDetails {
40
47
  // Schema
41
48
  // =============================================================================
42
49
 
43
- const TodoOp = StringEnum(["replace", "start", "done", "rm", "drop", "append"] as const, {
50
+ const TodoOp = StringEnum(["replace", "start", "done", "rm", "drop", "append", "note"] as const, {
44
51
  description: "operation to apply",
45
52
  });
46
53
 
@@ -54,7 +61,7 @@ const InputTask = Type.Object({
54
61
  });
55
62
 
56
63
  const InputPhase = Type.Object({
57
- name: Type.String({ description: "phase name", examples: ["Investigation", "Implementation"] }),
64
+ name: Type.String({ description: "phase name", examples: ["I. Foundation", "II. Auth", "III. Verification"] }),
58
65
  tasks: Type.Optional(Type.Array(InputTask)),
59
66
  });
60
67
 
@@ -71,6 +78,7 @@ const TodoOpEntry = Type.Object({
71
78
  Type.String({ description: "phase id for done/rm/drop/append", examples: ["Implementation", "phase-1"] }),
72
79
  ),
73
80
  items: Type.Optional(Type.Array(AppendItem, { minItems: 1, description: "items to append for op=append" })),
81
+ text: Type.Optional(Type.String({ description: "note text for op=note (appended with newline)" })),
74
82
  });
75
83
 
76
84
  const todoWriteSchema = Type.Object(
@@ -160,8 +168,14 @@ function fileFromPhases(phases: TodoPhase[]): TodoFile {
160
168
  return { phases, nextTaskId, nextPhaseId };
161
169
  }
162
170
 
171
+ function cloneTask(task: TodoItem): TodoItem {
172
+ const out: TodoItem = { id: task.id, content: task.content, status: task.status };
173
+ if (task.notes && task.notes.length > 0) out.notes = [...task.notes];
174
+ return out;
175
+ }
176
+
163
177
  function clonePhases(phases: TodoPhase[]): TodoPhase[] {
164
- return phases.map(phase => ({ ...phase, tasks: phase.tasks.map(task => ({ ...task })) }));
178
+ return phases.map(phase => ({ ...phase, tasks: phase.tasks.map(cloneTask) }));
165
179
  }
166
180
 
167
181
  function normalizeInProgressTask(phases: TodoPhase[]): void {
@@ -181,11 +195,19 @@ function normalizeInProgressTask(phases: TodoPhase[]): void {
181
195
  if (firstPendingTask) firstPendingTask.status = "in_progress";
182
196
  }
183
197
 
198
+ export const USER_TODO_EDIT_CUSTOM_TYPE = "user_todo_edit";
199
+
184
200
  export function getLatestTodoPhasesFromEntries(entries: SessionEntry[]): TodoPhase[] {
185
201
  for (let i = entries.length - 1; i >= 0; i--) {
186
202
  const entry = entries[i];
203
+ if (entry.type === "custom" && entry.customType === USER_TODO_EDIT_CUSTOM_TYPE) {
204
+ const data = entry.data as { phases?: unknown } | undefined;
205
+ if (data && Array.isArray(data.phases)) {
206
+ return clonePhases(data.phases as TodoPhase[]);
207
+ }
208
+ continue;
209
+ }
187
210
  if (entry.type !== "message") continue;
188
-
189
211
  const message = entry.message as { role?: string; toolName?: string; details?: unknown; isError?: boolean };
190
212
  if (message.role !== "toolResult" || message.toolName !== "todo_write" || message.isError) continue;
191
213
 
@@ -328,6 +350,17 @@ function applyEntry(file: TodoFile, entry: TodoOpEntryValue, errors: string[]):
328
350
  removeTasks(file, entry, errors);
329
351
  return file;
330
352
  }
353
+ case "note": {
354
+ const task = resolveTaskOrError(file.phases, entry.task, errors);
355
+ if (!task) return file;
356
+ const text = (entry.text ?? "").replace(/\s+$/u, "");
357
+ if (!text) {
358
+ errors.push("Missing text for note operation");
359
+ return file;
360
+ }
361
+ task.notes = task.notes ? [...task.notes, text] : [text];
362
+ return file;
363
+ }
331
364
  case "append": {
332
365
  appendItems(file, entry, errors);
333
366
  return file;
@@ -343,6 +376,142 @@ function applyParams(file: TodoFile, params: TodoWriteParams): { file: TodoFile;
343
376
  normalizeInProgressTask(file.phases);
344
377
  return { file, errors };
345
378
  }
379
+ /** Apply an array of `todo_write`-style ops to existing phases. Used by /todo slash command. */
380
+ export function applyOpsToPhases(
381
+ currentPhases: TodoPhase[],
382
+ ops: TodoWriteParams["ops"],
383
+ ): { phases: TodoPhase[]; errors: string[] } {
384
+ const startFile = fileFromPhases(currentPhases);
385
+ const { file, errors } = applyParams(startFile, { ops });
386
+ return { phases: file.phases, errors };
387
+ }
388
+
389
+ // =============================================================================
390
+ // Markdown round-trip
391
+ // =============================================================================
392
+
393
+ const STATUS_TO_MARKER: Record<TodoStatus, string> = {
394
+ pending: " ",
395
+ in_progress: "/",
396
+ completed: "x",
397
+ abandoned: "-",
398
+ };
399
+
400
+ /** Render todo phases as a Markdown checklist suitable for editing/copying. */
401
+ export function phasesToMarkdown(phases: TodoPhase[]): string {
402
+ if (phases.length === 0) return "# I. Todos\n";
403
+ const out: string[] = [];
404
+ for (let i = 0; i < phases.length; i++) {
405
+ if (i > 0) out.push("");
406
+ out.push(`# ${phases[i].name}`);
407
+ for (const task of phases[i].tasks) {
408
+ out.push(`- [${STATUS_TO_MARKER[task.status]}] ${task.content}`);
409
+ if (task.notes && task.notes.length > 0) {
410
+ for (let j = 0; j < task.notes.length; j++) {
411
+ if (j > 0) out.push(" >");
412
+ for (const noteLine of task.notes[j].split("\n")) {
413
+ out.push(noteLine === "" ? " >" : ` > ${noteLine}`);
414
+ }
415
+ }
416
+ }
417
+ }
418
+ }
419
+ return `${out.join("\n")}\n`;
420
+ }
421
+
422
+ const MARKER_TO_STATUS: Record<string, TodoStatus> = {
423
+ " ": "pending",
424
+ "": "pending",
425
+ x: "completed",
426
+ X: "completed",
427
+ "/": "in_progress",
428
+ ">": "in_progress",
429
+ "-": "abandoned",
430
+ "~": "abandoned",
431
+ };
432
+
433
+ /**
434
+ * Parse a Markdown checklist back into todo phases. Task and phase ids are
435
+ * regenerated; the agent observes the new ids in the system reminder.
436
+ */
437
+ export function markdownToPhases(md: string): { phases: TodoPhase[]; errors: string[] } {
438
+ const errors: string[] = [];
439
+ const phases: TodoPhase[] = [];
440
+ let currentPhase: TodoPhase | undefined;
441
+ let currentTask: TodoItem | undefined;
442
+ let noteBuf: string[] = [];
443
+ let nextPhaseId = 1;
444
+ let nextTaskId = 1;
445
+
446
+ const flushNote = () => {
447
+ if (!currentTask || noteBuf.length === 0) {
448
+ noteBuf = [];
449
+ return;
450
+ }
451
+ while (noteBuf.length > 0 && noteBuf[noteBuf.length - 1] === "") noteBuf.pop();
452
+ if (noteBuf.length === 0) return;
453
+ const joined = noteBuf.join("\n");
454
+ currentTask.notes = currentTask.notes ? [...currentTask.notes, joined] : [joined];
455
+ noteBuf = [];
456
+ };
457
+
458
+ const lines = md.split(/\r?\n/);
459
+ for (let lineNum = 0; lineNum < lines.length; lineNum++) {
460
+ const raw = lines[lineNum];
461
+
462
+ // Blockquote line attached to the current task: ` > text` or ` >`
463
+ const noteMatch = /^\s*>\s?(.*)$/.exec(raw);
464
+ if (noteMatch && currentTask) {
465
+ const noteLine = noteMatch[1];
466
+ if (noteLine === "") {
467
+ // Blank `>` separates two distinct notes
468
+ flushNote();
469
+ } else {
470
+ noteBuf.push(noteLine);
471
+ }
472
+ continue;
473
+ }
474
+
475
+ const trimmed = raw.trim();
476
+ if (!trimmed) continue;
477
+
478
+ const headingMatch = /^#{1,6}\s+(.+?)\s*$/.exec(trimmed);
479
+ if (headingMatch) {
480
+ flushNote();
481
+ currentTask = undefined;
482
+ currentPhase = { id: `phase-${nextPhaseId++}`, name: headingMatch[1].trim(), tasks: [] };
483
+ phases.push(currentPhase);
484
+ continue;
485
+ }
486
+
487
+ const taskMatch = /^[-*+]\s*\[(.?)\]\s+(.+?)\s*$/.exec(trimmed);
488
+ if (taskMatch) {
489
+ flushNote();
490
+ if (!currentPhase) {
491
+ currentPhase = { id: `phase-${nextPhaseId++}`, name: "I. Todos", tasks: [] };
492
+ phases.push(currentPhase);
493
+ }
494
+ const marker = taskMatch[1];
495
+ const status = MARKER_TO_STATUS[marker];
496
+ if (!status) {
497
+ errors.push(`Line ${lineNum + 1}: unknown status marker "[${marker}]" (use [ ], [x], [/], [-])`);
498
+ currentTask = undefined;
499
+ continue;
500
+ }
501
+ currentTask = { id: `task-${nextTaskId++}`, content: taskMatch[2].trim(), status };
502
+ currentPhase.tasks.push(currentTask);
503
+ continue;
504
+ }
505
+
506
+ flushNote();
507
+ currentTask = undefined;
508
+ errors.push(`Line ${lineNum + 1}: unrecognized syntax "${trimmed}"`);
509
+ }
510
+ flushNote();
511
+
512
+ normalizeInProgressTask(phases);
513
+ return { phases, errors };
514
+ }
346
515
 
347
516
  function formatSummary(phases: TodoPhase[], errors: string[]): string {
348
517
  const tasks = phases.flatMap(phase => phase.tasks);
@@ -387,7 +556,17 @@ function formatSummary(phases: TodoPhase[], errors: string[]): string {
387
556
  : task.status === "abandoned"
388
557
  ? "✗"
389
558
  : "○";
390
- lines.push(` ${sym} ${task.id} ${task.content}`);
559
+ const noteCount = task.notes?.length ?? 0;
560
+ const noteMarker = noteCount > 0 ? ` (+${noteCount} note${noteCount === 1 ? "" : "s"})` : "";
561
+ lines.push(` ${sym} ${task.id} ${task.content}${noteMarker}`);
562
+ if (task.status === "in_progress" && task.notes && task.notes.length > 0) {
563
+ for (let j = 0; j < task.notes.length; j++) {
564
+ if (j > 0) lines.push(" ---");
565
+ for (const noteLine of task.notes[j].split("\n")) {
566
+ lines.push(` ${noteLine}`);
567
+ }
568
+ }
569
+ }
391
570
  }
392
571
  }
393
572
  return lines.join("\n");
@@ -442,18 +621,65 @@ type TodoWriteRenderArgs = {
442
621
  }>;
443
622
  };
444
623
 
624
+ const SUP_DIGITS: Record<string, string> = {
625
+ "0": "\u2070",
626
+ "1": "\u00b9",
627
+ "2": "\u00b2",
628
+ "3": "\u00b3",
629
+ "4": "\u2074",
630
+ "5": "\u2075",
631
+ "6": "\u2076",
632
+ "7": "\u2077",
633
+ "8": "\u2078",
634
+ "9": "\u2079",
635
+ };
636
+
637
+ function toSuperscript(n: number): string {
638
+ return n
639
+ .toString()
640
+ .split("")
641
+ .map(d => SUP_DIGITS[d] ?? d)
642
+ .join("");
643
+ }
644
+
645
+ function noteMarker(count: number, uiTheme: Theme): string {
646
+ if (count <= 0) return "";
647
+ return uiTheme.fg("dim", chalk.italic(` \u207a${toSuperscript(count)}`));
648
+ }
649
+
445
650
  function formatTodoLine(item: TodoItem, uiTheme: Theme, prefix: string): string {
446
651
  const checkbox = uiTheme.checkbox;
652
+ const marker = noteMarker(item.notes?.length ?? 0, uiTheme);
447
653
  switch (item.status) {
448
654
  case "completed":
449
- return uiTheme.fg("success", `${prefix}${checkbox.checked} ${chalk.strikethrough(item.content)}`);
655
+ return uiTheme.fg("success", `${prefix}${checkbox.checked} ${chalk.strikethrough(item.content)}`) + marker;
450
656
  case "in_progress":
451
- return uiTheme.fg("accent", `${prefix}${checkbox.unchecked} ${item.content}`);
657
+ return uiTheme.fg("accent", `${prefix}${checkbox.unchecked} ${item.content}`) + marker;
452
658
  case "abandoned":
453
- return uiTheme.fg("error", `${prefix}${checkbox.unchecked} ${chalk.strikethrough(item.content)}`);
659
+ return uiTheme.fg("error", `${prefix}${checkbox.unchecked} ${chalk.strikethrough(item.content)}`) + marker;
454
660
  default:
455
- return uiTheme.fg("dim", `${prefix}${checkbox.unchecked} ${item.content}`);
661
+ return uiTheme.fg("dim", `${prefix}${checkbox.unchecked} ${item.content}`) + marker;
662
+ }
663
+ }
664
+
665
+ function renderNoteAttachments(phases: TodoPhase[], uiTheme: Theme): string[] {
666
+ const lines: string[] = [];
667
+ for (const phase of phases) {
668
+ for (const task of phase.tasks) {
669
+ if (task.status !== "in_progress" || !task.notes || task.notes.length === 0) continue;
670
+ const bar = uiTheme.fg("dim", uiTheme.tree.vertical);
671
+ const title = uiTheme.fg("dim", chalk.italic(`\u00a7 notes \u2014 ${task.content}`));
672
+ lines.push("");
673
+ lines.push(` ${title}`);
674
+ for (let j = 0; j < task.notes.length; j++) {
675
+ if (j > 0) lines.push(` ${bar}`);
676
+ for (const noteLine of task.notes[j].split("\n")) {
677
+ lines.push(` ${bar} ${uiTheme.fg("dim", noteLine)}`);
678
+ }
679
+ }
680
+ }
456
681
  }
682
+ return lines;
457
683
  }
458
684
 
459
685
  export const todoWriteToolRenderer = {
@@ -488,9 +714,11 @@ export const todoWriteToolRenderer = {
488
714
 
489
715
  const { expanded } = options;
490
716
  const lines: string[] = [header];
491
- for (const phase of phases) {
717
+ for (let p = 0; p < phases.length; p++) {
718
+ const phase = phases[p];
719
+ if (p > 0) lines.push("");
492
720
  if (phases.length > 1) {
493
- lines.push(uiTheme.fg("accent", ` ${uiTheme.tree.hook} ${phase.name}`));
721
+ lines.push(uiTheme.fg("accent", chalk.bold(` ${phase.name}`)));
494
722
  }
495
723
  const treeLines = renderTreeList(
496
724
  {
@@ -502,8 +730,11 @@ export const todoWriteToolRenderer = {
502
730
  },
503
731
  uiTheme,
504
732
  );
505
- lines.push(...treeLines);
733
+ for (const line of treeLines) {
734
+ lines.push(` ${line}`);
735
+ }
506
736
  }
737
+ lines.push(...renderNoteAttachments(phases, uiTheme));
507
738
  return new Text(lines.join("\n"), 0, 0);
508
739
  },
509
740
  mergeCallAndResult: true,
@@ -1,13 +1,12 @@
1
1
  import { $env, $flag } from "@oh-my-pi/pi-utils";
2
2
 
3
- export type EditMode = "replace" | "patch" | "hashline" | "chunk" | "vim" | "apply_patch" | "atom";
3
+ export type EditMode = "replace" | "patch" | "hashline" | "vim" | "apply_patch" | "atom";
4
4
 
5
5
  export const DEFAULT_EDIT_MODE: EditMode = "hashline";
6
6
 
7
7
  const EDIT_MODE_IDS = {
8
8
  apply_patch: "apply_patch",
9
9
  atom: "atom",
10
- chunk: "chunk",
11
10
  hashline: "hashline",
12
11
  patch: "patch",
13
12
  replace: "replace",
@@ -7,7 +7,6 @@ import { resolveEditMode } from "./edit-mode";
7
7
  export interface FileDisplayMode {
8
8
  lineNumbers: boolean;
9
9
  hashLines: boolean;
10
- chunked: boolean;
11
10
  }
12
11
 
13
12
  /** Session-like object providing settings and tool availability for display mode resolution. */
@@ -33,10 +32,8 @@ export function resolveFileDisplayMode(session: FileDisplayModeSession, options?
33
32
  const usesHashLineAnchors = editMode === "hashline" || editMode === "atom";
34
33
  const raw = options?.raw === true;
35
34
  const hashLines = !raw && hasEditTool && usesHashLineAnchors && settings.get("readHashLines") !== false;
36
- const chunked = !raw && hasEditTool && editMode === "chunk";
37
35
  return {
38
36
  hashLines,
39
37
  lineNumbers: !raw && (hashLines || settings.get("readLineNumbers") === true),
40
- chunked,
41
38
  };
42
39
  }