@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
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: ["
|
|
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+)(
|
|
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
|
-
|
|
400
|
-
|
|
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("
|
|
386
|
+
throw new ToolError("sel=0 is invalid; lines are 1-indexed. Use sel=1.");
|
|
406
387
|
}
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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=
|
|
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
|
|
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
|
|
968
|
-
const
|
|
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
|
|
1034
|
-
const
|
|
1035
|
-
const
|
|
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=
|
|
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
|
|
1203
|
+
outputText += `\n\n[${remaining} more lines in file. Use sel=${nextOffset} to continue]`;
|
|
1294
1204
|
details = {};
|
|
1295
1205
|
sourcePath = absolutePath;
|
|
1296
1206
|
} else {
|
package/src/tools/renderers.ts
CHANGED
|
@@ -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,
|
package/src/tools/todo-write.ts
CHANGED
|
@@ -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: ["
|
|
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(
|
|
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
|
-
|
|
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 (
|
|
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", ` ${
|
|
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
|
-
|
|
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,
|
package/src/utils/edit-mode.ts
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { $env, $flag } from "@oh-my-pi/pi-utils";
|
|
2
2
|
|
|
3
|
-
export type EditMode = "replace" | "patch" | "hashline" | "
|
|
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
|
}
|