@oh-my-pi/pi-coding-agent 15.5.11 → 15.5.13
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 +28 -0
- package/dist/types/cli-commands.d.ts +19 -0
- package/dist/types/commands/install.d.ts +51 -0
- package/dist/types/discovery/index.d.ts +1 -0
- package/dist/types/discovery/omp-extension-roots.d.ts +43 -0
- package/dist/types/discovery/omp-plugins.d.ts +1 -0
- package/dist/types/edit/file-snapshot-store.d.ts +19 -0
- package/dist/types/extensibility/plugins/loader.d.ts +12 -2
- package/dist/types/tools/todo-write.d.ts +30 -0
- package/package.json +8 -8
- package/src/cli-commands.ts +44 -0
- package/src/cli.ts +2 -32
- package/src/commands/install.ts +107 -0
- package/src/discovery/index.ts +1 -0
- package/src/discovery/omp-extension-roots.ts +190 -0
- package/src/discovery/omp-plugins.ts +383 -0
- package/src/edit/file-snapshot-store.ts +34 -0
- package/src/edit/hashline/diff.ts +3 -8
- package/src/edit/renderer.ts +1 -1
- package/src/extensibility/plugins/loader.ts +43 -18
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/main.ts +12 -0
- package/src/modes/interactive-mode.ts +243 -12
- package/src/tools/ast-edit.ts +1 -1
- package/src/tools/ast-grep.ts +6 -17
- package/src/tools/read.ts +23 -33
- package/src/tools/search.ts +12 -21
- package/src/tools/todo-write.ts +64 -0
- package/src/tools/write.ts +1 -3
- package/src/utils/file-mentions.ts +1 -3
|
@@ -67,7 +67,7 @@ import type { LspStartupServerInfo } from "../tools";
|
|
|
67
67
|
import { normalizeLocalScheme } from "../tools/path-utils";
|
|
68
68
|
import { setAutoQaConsentHandler } from "../tools/report-tool-issue";
|
|
69
69
|
import { type ResolveToolDetails, runResolveInvocation } from "../tools/resolve";
|
|
70
|
-
import { formatPhaseDisplayName } from "../tools/todo-write";
|
|
70
|
+
import { formatPhaseDisplayName, selectStickyTodoWindow, todoMatchesAnyDescription } from "../tools/todo-write";
|
|
71
71
|
import { ToolError } from "../tools/tool-errors";
|
|
72
72
|
import type { EventBus } from "../utils/event-bus";
|
|
73
73
|
import { getEditorCommand, openInEditor } from "../utils/external-editor";
|
|
@@ -324,6 +324,10 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
324
324
|
#eventBus?: EventBus;
|
|
325
325
|
#eventBusUnsubscribers: Array<() => void> = [];
|
|
326
326
|
#welcomeComponent?: WelcomeComponent;
|
|
327
|
+
#todoSpinnerInterval?: NodeJS.Timeout;
|
|
328
|
+
#todoSpinnerFrame = 0;
|
|
329
|
+
#todoClosingTimeout?: NodeJS.Timeout;
|
|
330
|
+
#todoClosingState: "idle" | "playing" | "done" = "idle";
|
|
327
331
|
|
|
328
332
|
constructor(
|
|
329
333
|
session: AgentSession,
|
|
@@ -529,6 +533,12 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
529
533
|
this.#observerRegistry.setMainSession(this.sessionManager.getSessionFile() ?? undefined);
|
|
530
534
|
this.#observerRegistry.onChange(() => {
|
|
531
535
|
this.statusLine.setSubagentCount(this.#observerRegistry.getActiveSubagentCount());
|
|
536
|
+
// Auto-checkmark todos whose matching subagent just succeeded, then
|
|
537
|
+
// re-render so the running override (animated row when a subagent
|
|
538
|
+
// is doing the work for a still-pending todo) updates as subagents
|
|
539
|
+
// start, finish, or fail. Also handles spinner start/stop.
|
|
540
|
+
this.#reconcileTodosWithSubagents();
|
|
541
|
+
this.#renderTodoList();
|
|
532
542
|
this.ui.requestRender();
|
|
533
543
|
});
|
|
534
544
|
|
|
@@ -839,6 +849,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
839
849
|
this.#pendingSubmissionDispose = undefined;
|
|
840
850
|
}
|
|
841
851
|
this.editor.setText("");
|
|
852
|
+
this.ui.refreshNativeScrollbackIfDirty();
|
|
842
853
|
this.ensureLoadingAnimation();
|
|
843
854
|
this.ui.requestRender();
|
|
844
855
|
return submission;
|
|
@@ -949,21 +960,101 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
949
960
|
this.renderSessionContext(context);
|
|
950
961
|
}
|
|
951
962
|
|
|
952
|
-
#formatTodoLine(todo: TodoItem, prefix: string): string {
|
|
963
|
+
#formatTodoLine(todo: TodoItem, prefix: string, matched: boolean, spinnerOn: boolean): string {
|
|
953
964
|
const checkbox = theme.checkbox;
|
|
954
965
|
const marker = formatHudNoteMarker(todo.notes?.length ?? 0);
|
|
966
|
+
const frames = theme.spinnerFrames;
|
|
967
|
+
// When the spinner is ticking, use the current animated frame; otherwise
|
|
968
|
+
// fall back to the static "running" glyph so in_progress rows still look
|
|
969
|
+
// distinct from pending rows.
|
|
970
|
+
const runningGlyph =
|
|
971
|
+
spinnerOn && frames.length > 0
|
|
972
|
+
? (frames[this.#todoSpinnerFrame % frames.length] ?? theme.status.running)
|
|
973
|
+
: theme.status.running;
|
|
955
974
|
switch (todo.status) {
|
|
956
975
|
case "completed":
|
|
957
|
-
return
|
|
976
|
+
return (
|
|
977
|
+
theme.fg("success", `${prefix}${theme.status.success} ${chalk.strikethrough(todo.content)}`) + marker
|
|
978
|
+
);
|
|
958
979
|
case "in_progress":
|
|
959
|
-
return theme.fg("accent", `${prefix}${
|
|
980
|
+
return theme.fg("accent", `${prefix}${runningGlyph} ${todo.content}`) + marker;
|
|
960
981
|
case "abandoned":
|
|
961
982
|
return theme.fg("error", `${prefix}${checkbox.unchecked} ${chalk.strikethrough(todo.content)}`) + marker;
|
|
962
983
|
default:
|
|
984
|
+
if (matched) {
|
|
985
|
+
return theme.fg("accent", `${prefix}${runningGlyph} ${todo.content}`) + marker;
|
|
986
|
+
}
|
|
963
987
|
return theme.fg("dim", `${prefix}${checkbox.unchecked} ${todo.content}`) + marker;
|
|
964
988
|
}
|
|
965
989
|
}
|
|
966
990
|
|
|
991
|
+
#getActiveSubagentDescriptions(): string[] {
|
|
992
|
+
const out: string[] = [];
|
|
993
|
+
for (const session of this.#observerRegistry.getSessions()) {
|
|
994
|
+
if (session.kind !== "subagent") continue;
|
|
995
|
+
if (session.status !== "active") continue;
|
|
996
|
+
const candidate =
|
|
997
|
+
session.description?.trim() || session.progress?.description?.trim() || session.label?.trim();
|
|
998
|
+
if (candidate) out.push(candidate);
|
|
999
|
+
}
|
|
1000
|
+
return out;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* Auto-complete any pending/in_progress todo whose content matches a
|
|
1005
|
+
* subagent that has finished successfully. Fires on every observer
|
|
1006
|
+
* `onChange` so the visual state stays in sync with subagent lifecycle
|
|
1007
|
+
* without requiring the agent to issue a follow-up `todo_write`. Failed
|
|
1008
|
+
* and aborted subagents are intentionally NOT auto-completed — those
|
|
1009
|
+
* stay open so the user (or the next agent turn) can decide what to do.
|
|
1010
|
+
*
|
|
1011
|
+
* Idempotent: only flips open tasks, never re-touches completed ones.
|
|
1012
|
+
*/
|
|
1013
|
+
#reconcileTodosWithSubagents(): void {
|
|
1014
|
+
const completedDescs: string[] = [];
|
|
1015
|
+
for (const session of this.#observerRegistry.getSessions()) {
|
|
1016
|
+
if (session.kind !== "subagent") continue;
|
|
1017
|
+
if (session.status !== "completed") continue;
|
|
1018
|
+
const candidate =
|
|
1019
|
+
session.description?.trim() || session.progress?.description?.trim() || session.label?.trim();
|
|
1020
|
+
if (candidate) completedDescs.push(candidate);
|
|
1021
|
+
}
|
|
1022
|
+
if (completedDescs.length === 0) return;
|
|
1023
|
+
|
|
1024
|
+
let mutated = false;
|
|
1025
|
+
const next: TodoPhase[] = this.todoPhases.map(phase => ({
|
|
1026
|
+
name: phase.name,
|
|
1027
|
+
tasks: phase.tasks.map(task => {
|
|
1028
|
+
if (task.status !== "pending" && task.status !== "in_progress") return task;
|
|
1029
|
+
if (!todoMatchesAnyDescription(task.content, completedDescs)) return task;
|
|
1030
|
+
mutated = true;
|
|
1031
|
+
return { ...task, status: "completed" as const };
|
|
1032
|
+
}),
|
|
1033
|
+
}));
|
|
1034
|
+
if (!mutated) return;
|
|
1035
|
+
this.todoPhases = next;
|
|
1036
|
+
this.session.setTodoPhases(next);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
#updateTodoSpinnerAnimation(needSpinner: boolean): void {
|
|
1040
|
+
if (needSpinner) {
|
|
1041
|
+
if (this.#todoSpinnerInterval) return;
|
|
1042
|
+
this.#todoSpinnerInterval = setInterval(() => {
|
|
1043
|
+
const frames = theme.spinnerFrames;
|
|
1044
|
+
if (frames.length === 0) return;
|
|
1045
|
+
this.#todoSpinnerFrame = (this.#todoSpinnerFrame + 1) % frames.length;
|
|
1046
|
+
// Rebuild the todo container so the new frame appears, then schedule
|
|
1047
|
+
// a paint. The renderer self-stops the interval once no row needs it.
|
|
1048
|
+
this.#renderTodoList();
|
|
1049
|
+
this.ui.requestRender();
|
|
1050
|
+
}, 80);
|
|
1051
|
+
} else if (this.#todoSpinnerInterval) {
|
|
1052
|
+
clearInterval(this.#todoSpinnerInterval);
|
|
1053
|
+
this.#todoSpinnerInterval = undefined;
|
|
1054
|
+
this.#todoSpinnerFrame = 0;
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
967
1058
|
#getActivePhase(phases: TodoPhase[]): TodoPhase | undefined {
|
|
968
1059
|
const nonEmpty = phases.filter(phase => phase.tasks.length > 0);
|
|
969
1060
|
const active = nonEmpty.find(phase =>
|
|
@@ -976,44 +1067,183 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
976
1067
|
this.todoContainer.clear();
|
|
977
1068
|
const phases = this.todoPhases.filter(phase => phase.tasks.length > 0);
|
|
978
1069
|
if (phases.length === 0) {
|
|
1070
|
+
this.#updateTodoSpinnerAnimation(false);
|
|
1071
|
+
this.#stopTodoClosingAnimation();
|
|
1072
|
+
this.#todoClosingState = "idle";
|
|
979
1073
|
return;
|
|
980
1074
|
}
|
|
981
1075
|
|
|
1076
|
+
// When every visible task is completed or abandoned, fold the panel
|
|
1077
|
+
// away with a brief celebratory animation (see
|
|
1078
|
+
// #startTodoClosingAnimation). State machine guards against replaying
|
|
1079
|
+
// on every re-render once the animation has finished.
|
|
1080
|
+
const allClosed = phases.every(phase =>
|
|
1081
|
+
phase.tasks.every(t => t.status === "completed" || t.status === "abandoned"),
|
|
1082
|
+
);
|
|
1083
|
+
if (allClosed) {
|
|
1084
|
+
this.#updateTodoSpinnerAnimation(false);
|
|
1085
|
+
if (this.#todoClosingState === "done") return;
|
|
1086
|
+
if (this.#todoClosingState === "idle") this.#startTodoClosingAnimation(phases);
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
// Any open task here means the close animation is no longer applicable.
|
|
1090
|
+
this.#stopTodoClosingAnimation();
|
|
1091
|
+
this.#todoClosingState = "idle";
|
|
1092
|
+
|
|
982
1093
|
const indent = " ";
|
|
983
1094
|
const hook = theme.tree.hook;
|
|
984
1095
|
const lines = ["", indent + theme.bold(theme.fg("accent", "Todos"))];
|
|
985
1096
|
|
|
1097
|
+
const activeDescs = this.#getActiveSubagentDescriptions();
|
|
1098
|
+
// Cache matcher results so we don't re-scan the description list per row
|
|
1099
|
+
// twice (once for the spinner decision, once for the render).
|
|
1100
|
+
const matchedSet = new Set<TodoItem>();
|
|
1101
|
+
const isMatched = (todo: TodoItem): boolean => {
|
|
1102
|
+
if (activeDescs.length === 0) return false;
|
|
1103
|
+
if (matchedSet.has(todo)) return true;
|
|
1104
|
+
if (todoMatchesAnyDescription(todo.content, activeDescs)) {
|
|
1105
|
+
matchedSet.add(todo);
|
|
1106
|
+
return true;
|
|
1107
|
+
}
|
|
1108
|
+
return false;
|
|
1109
|
+
};
|
|
1110
|
+
|
|
1111
|
+
// The cube animates whenever any visible open todo is "live":
|
|
1112
|
+
// (a) status is in_progress (the agent itself is working it), or
|
|
1113
|
+
// (b) a still-pending todo has a matching in-flight subagent doing
|
|
1114
|
+
// the work for it. The renderer self-stops the interval once no row
|
|
1115
|
+
// qualifies, so an orphan in_progress row at end-of-session keeps
|
|
1116
|
+
// ticking — that's the intentional "this todo is still open" signal.
|
|
1117
|
+
let needsSpinner = false;
|
|
1118
|
+
const considerForSpinner = (todo: TodoItem): void => {
|
|
1119
|
+
if (todo.status === "in_progress") {
|
|
1120
|
+
needsSpinner = true;
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
if (todo.status !== "pending") return;
|
|
1124
|
+
if (isMatched(todo)) needsSpinner = true;
|
|
1125
|
+
};
|
|
1126
|
+
|
|
986
1127
|
if (!this.todoExpanded) {
|
|
987
1128
|
const activeIdx = phases.indexOf(this.#getActivePhase(phases) ?? phases[0]);
|
|
988
1129
|
const activePhase = phases[activeIdx];
|
|
989
|
-
if (!activePhase)
|
|
1130
|
+
if (!activePhase) {
|
|
1131
|
+
this.#updateTodoSpinnerAnimation(false);
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
const { visible, hiddenOpenCount } = selectStickyTodoWindow(activePhase.tasks, 5);
|
|
1135
|
+
for (const todo of visible) considerForSpinner(todo);
|
|
1136
|
+
this.#updateTodoSpinnerAnimation(needsSpinner);
|
|
1137
|
+
|
|
990
1138
|
lines.push(
|
|
991
1139
|
`${indent}${theme.fg("accent", `${hook} ${formatPhaseDisplayName(activePhase.name, activeIdx + 1)}`)}`,
|
|
992
1140
|
);
|
|
993
|
-
|
|
994
|
-
visibleTasks.forEach((todo, index) => {
|
|
1141
|
+
visible.forEach((todo, index) => {
|
|
995
1142
|
const prefix = `${indent}${index === 0 ? hook : " "} `;
|
|
996
|
-
lines.push(this.#formatTodoLine(todo, prefix));
|
|
1143
|
+
lines.push(this.#formatTodoLine(todo, prefix, matchedSet.has(todo), needsSpinner));
|
|
997
1144
|
});
|
|
998
|
-
if (
|
|
999
|
-
|
|
1000
|
-
lines.push(theme.fg("muted", `${indent} ${hook} +${remaining} more`));
|
|
1145
|
+
if (hiddenOpenCount > 0) {
|
|
1146
|
+
lines.push(theme.fg("muted", `${indent} ${hook} +${hiddenOpenCount} more`));
|
|
1001
1147
|
}
|
|
1002
1148
|
this.todoContainer.addChild(new Text(lines.join("\n"), 1, 0));
|
|
1003
1149
|
return;
|
|
1004
1150
|
}
|
|
1005
1151
|
|
|
1152
|
+
for (const phase of phases) for (const todo of phase.tasks) considerForSpinner(todo);
|
|
1153
|
+
this.#updateTodoSpinnerAnimation(needsSpinner);
|
|
1154
|
+
|
|
1006
1155
|
phases.forEach((phase, phaseIndex) => {
|
|
1007
1156
|
lines.push(`${indent}${theme.fg("accent", `${hook} ${formatPhaseDisplayName(phase.name, phaseIndex + 1)}`)}`);
|
|
1008
1157
|
phase.tasks.forEach((todo, index) => {
|
|
1009
1158
|
const prefix = `${indent}${index === 0 ? hook : " "} `;
|
|
1010
|
-
lines.push(this.#formatTodoLine(todo, prefix));
|
|
1159
|
+
lines.push(this.#formatTodoLine(todo, prefix, matchedSet.has(todo), needsSpinner));
|
|
1011
1160
|
});
|
|
1012
1161
|
});
|
|
1013
1162
|
|
|
1014
1163
|
this.todoContainer.addChild(new Text(lines.join("\n"), 1, 0));
|
|
1015
1164
|
}
|
|
1016
1165
|
|
|
1166
|
+
/**
|
|
1167
|
+
* Play a short "all done" close animation: a celebratory bright frame,
|
|
1168
|
+
* a brief dim transition, then a row-by-row vertical collapse until the
|
|
1169
|
+
* panel is empty. Triggered from #renderTodoList exactly once per
|
|
1170
|
+
* open-to-all-closed transition; #todoClosingState gates re-entry.
|
|
1171
|
+
*
|
|
1172
|
+
* While playing, the animator owns the panel container; #renderTodoList
|
|
1173
|
+
* returns early. Subsequent renders with state === "done" keep the
|
|
1174
|
+
* panel hidden until a fresh open task flips state back to "idle".
|
|
1175
|
+
*/
|
|
1176
|
+
#startTodoClosingAnimation(phases: TodoPhase[]): void {
|
|
1177
|
+
this.#stopTodoClosingAnimation();
|
|
1178
|
+
this.#todoClosingState = "playing";
|
|
1179
|
+
|
|
1180
|
+
const indent = " ";
|
|
1181
|
+
const hook = theme.tree.hook;
|
|
1182
|
+
const snapshot: string[] = ["", `${indent}Todos ${theme.status.success}`];
|
|
1183
|
+
for (let i = 0; i < phases.length; i++) {
|
|
1184
|
+
const phase = phases[i];
|
|
1185
|
+
snapshot.push(`${indent}${hook} ${formatPhaseDisplayName(phase.name, i + 1)}`);
|
|
1186
|
+
for (let j = 0; j < phase.tasks.length; j++) {
|
|
1187
|
+
const task = phase.tasks[j];
|
|
1188
|
+
const mark = task.status === "abandoned" ? theme.status.aborted : theme.status.success;
|
|
1189
|
+
const prefix = `${indent}${j === 0 ? hook : " "} `;
|
|
1190
|
+
snapshot.push(`${prefix}${mark} ${task.content}`);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// Frame schedule (tint, drop-from-bottom, hold-ms). Frame 0 holds long
|
|
1195
|
+
// enough for the user to actually read the final checkmarks before the
|
|
1196
|
+
// fade starts; later frames fade and progressively drop rows from the
|
|
1197
|
+
// bottom for the collapse effect. Total runtime ≈ 1.4s.
|
|
1198
|
+
const frames = [
|
|
1199
|
+
{ tint: "success" as const, drop: 0, holdMs: 900 },
|
|
1200
|
+
{ tint: "success" as const, drop: 0, holdMs: 150 },
|
|
1201
|
+
{ tint: "muted" as const, drop: 1, holdMs: 90 },
|
|
1202
|
+
{ tint: "muted" as const, drop: 2, holdMs: 90 },
|
|
1203
|
+
{ tint: "dim" as const, drop: 3, holdMs: 80 },
|
|
1204
|
+
{ tint: "dim" as const, drop: 4, holdMs: 80 },
|
|
1205
|
+
];
|
|
1206
|
+
|
|
1207
|
+
let frameIdx = 0;
|
|
1208
|
+
const tick = (): void => {
|
|
1209
|
+
if (this.#todoClosingState !== "playing") return;
|
|
1210
|
+
if (frameIdx >= frames.length) {
|
|
1211
|
+
this.todoContainer.clear();
|
|
1212
|
+
this.#stopTodoClosingAnimation();
|
|
1213
|
+
this.#todoClosingState = "done";
|
|
1214
|
+
this.ui.requestRender();
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
const { tint, drop, holdMs } = frames[frameIdx];
|
|
1218
|
+
const visibleCount = Math.max(0, snapshot.length - drop);
|
|
1219
|
+
this.todoContainer.clear();
|
|
1220
|
+
if (visibleCount > 0) {
|
|
1221
|
+
const visible = snapshot.slice(0, visibleCount);
|
|
1222
|
+
const painted = visible.map((line, idx) => {
|
|
1223
|
+
if (idx === 1) {
|
|
1224
|
+
// Header row gets a bold flourish on the opening tick.
|
|
1225
|
+
const colored = theme.fg(tint, line);
|
|
1226
|
+
return frameIdx === 0 ? theme.bold(colored) : colored;
|
|
1227
|
+
}
|
|
1228
|
+
return theme.fg(tint, line);
|
|
1229
|
+
});
|
|
1230
|
+
this.todoContainer.addChild(new Text(painted.join("\n"), 1, 0));
|
|
1231
|
+
}
|
|
1232
|
+
this.ui.requestRender();
|
|
1233
|
+
frameIdx++;
|
|
1234
|
+
this.#todoClosingTimeout = setTimeout(tick, holdMs);
|
|
1235
|
+
};
|
|
1236
|
+
|
|
1237
|
+
tick();
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
#stopTodoClosingAnimation(): void {
|
|
1241
|
+
if (this.#todoClosingTimeout) {
|
|
1242
|
+
clearTimeout(this.#todoClosingTimeout);
|
|
1243
|
+
this.#todoClosingTimeout = undefined;
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1017
1247
|
async #loadTodoList(): Promise<void> {
|
|
1018
1248
|
this.todoPhases = this.session.getTodoPhases();
|
|
1019
1249
|
this.#renderTodoList();
|
|
@@ -2035,6 +2265,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
2035
2265
|
this.loadingAnimation = undefined;
|
|
2036
2266
|
}
|
|
2037
2267
|
this.#cleanupMicAnimation();
|
|
2268
|
+
this.#updateTodoSpinnerAnimation(false);
|
|
2038
2269
|
this.#cancelGoalContinuation();
|
|
2039
2270
|
if (this.#sttController) {
|
|
2040
2271
|
this.#sttController.dispose();
|
package/src/tools/ast-edit.ts
CHANGED
|
@@ -290,7 +290,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
|
|
|
290
290
|
const absolutePath = path.resolve(this.session.cwd, relativePath);
|
|
291
291
|
try {
|
|
292
292
|
const fullText = normalizeToLF(await Bun.file(absolutePath).text());
|
|
293
|
-
const tag = snapshotStore.
|
|
293
|
+
const tag = snapshotStore.record(absolutePath, fullText);
|
|
294
294
|
hashContexts.set(relativePath, { tag });
|
|
295
295
|
} catch {
|
|
296
296
|
// Best-effort: if a file disappears between ast-edit and rendering, emit plain line output.
|
package/src/tools/ast-grep.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { constants } from "node:fs";
|
|
2
|
-
import { access } from "node:fs/promises";
|
|
3
1
|
import * as path from "node:path";
|
|
4
2
|
import { formatHashlineHeader } from "@oh-my-pi/hashline";
|
|
5
3
|
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
|
|
@@ -8,7 +6,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
|
|
|
8
6
|
import { Text } from "@oh-my-pi/pi-tui";
|
|
9
7
|
import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
|
|
10
8
|
import * as z from "zod/v4";
|
|
11
|
-
import {
|
|
9
|
+
import { recordFileSnapshot } from "../edit/file-snapshot-store";
|
|
12
10
|
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
13
11
|
import type { Theme } from "../modes/theme/theme";
|
|
14
12
|
import astGrepDescription from "../prompts/tools/ast-grep.md" with { type: "text" };
|
|
@@ -221,17 +219,14 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
|
|
|
221
219
|
}
|
|
222
220
|
|
|
223
221
|
const useHashLines = resolveFileDisplayMode(this.session).hashLines;
|
|
224
|
-
const hashContexts = new Map<string, {
|
|
225
|
-
const snapshotStore = useHashLines ? getFileSnapshotStore(this.session) : undefined;
|
|
222
|
+
const hashContexts = new Map<string, { tag: string }>();
|
|
226
223
|
if (useHashLines) {
|
|
227
224
|
for (const relativePath of fileList) {
|
|
228
225
|
const absolutePath = path.resolve(this.session.cwd, relativePath);
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
// Best-effort: if a file disappears between ast-grep and rendering, emit plain line output.
|
|
234
|
-
}
|
|
226
|
+
// Whole-file content tag: any anchor validates while the file is
|
|
227
|
+
// unchanged; over-cap / unreadable files get no tag (plain output).
|
|
228
|
+
const tag = await recordFileSnapshot(this.session, absolutePath);
|
|
229
|
+
if (tag) hashContexts.set(relativePath, { tag });
|
|
235
230
|
}
|
|
236
231
|
}
|
|
237
232
|
const outputLines: string[] = [];
|
|
@@ -246,7 +241,6 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
|
|
|
246
241
|
const endLine = match.startLine + lineCount - 1;
|
|
247
242
|
return Math.max(width, String(match.startLine).length, String(endLine).length);
|
|
248
243
|
}, 0);
|
|
249
|
-
const cacheEntries: Array<readonly [number, string]> = [];
|
|
250
244
|
for (const match of fileMatches) {
|
|
251
245
|
const matchLines = match.text.split("\n");
|
|
252
246
|
for (let index = 0; index < matchLines.length; index++) {
|
|
@@ -257,7 +251,6 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
|
|
|
257
251
|
formatMatchLine(lineNumber, line, isMatch, { useHashLines: hashContext !== undefined }),
|
|
258
252
|
);
|
|
259
253
|
displayOut.push(formatCodeFrameLine(isMatch ? "*" : " ", lineNumber, line, lineNumberWidth));
|
|
260
|
-
cacheEntries.push([lineNumber, line] as const);
|
|
261
254
|
}
|
|
262
255
|
if (match.metaVariables && Object.keys(match.metaVariables).length > 0) {
|
|
263
256
|
const serializedMeta = Object.entries(match.metaVariables)
|
|
@@ -269,10 +262,6 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
|
|
|
269
262
|
}
|
|
270
263
|
fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
|
|
271
264
|
}
|
|
272
|
-
if (hashContext && cacheEntries.length > 0) {
|
|
273
|
-
const tag = snapshotStore?.recordSparse(hashContext.absolutePath, cacheEntries);
|
|
274
|
-
if (tag) hashContext.tag = tag;
|
|
275
|
-
}
|
|
276
265
|
return { model: modelOut, display: displayOut };
|
|
277
266
|
};
|
|
278
267
|
|
package/src/tools/read.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
|
|
|
9
9
|
import { Text } from "@oh-my-pi/pi-tui";
|
|
10
10
|
import { getRemoteDir, logger, prompt, readImageMetadata, untilAborted } from "@oh-my-pi/pi-utils";
|
|
11
11
|
import * as z from "zod/v4";
|
|
12
|
-
import { getFileSnapshotStore } from "../edit/file-snapshot-store";
|
|
12
|
+
import { getFileSnapshotStore, recordFileSnapshot } from "../edit/file-snapshot-store";
|
|
13
13
|
import { normalizeToLF } from "../edit/normalize";
|
|
14
14
|
import { isNotebookPath, readEditableNotebookText } from "../edit/notebook";
|
|
15
15
|
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
@@ -130,9 +130,7 @@ function recordFullHashlineContext(
|
|
|
130
130
|
): HashlineHeaderContext | undefined {
|
|
131
131
|
if (!absolutePath || !path.isAbsolute(absolutePath)) return undefined;
|
|
132
132
|
const normalized = normalizeToLF(fullText);
|
|
133
|
-
const tag = getFileSnapshotStore(session).
|
|
134
|
-
fullText: normalized,
|
|
135
|
-
});
|
|
133
|
+
const tag = getFileSnapshotStore(session).record(absolutePath, normalized);
|
|
136
134
|
return {
|
|
137
135
|
header: formatHashlineHeader(displayPath, tag),
|
|
138
136
|
tag,
|
|
@@ -1033,7 +1031,6 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1033
1031
|
|
|
1034
1032
|
const shouldAddHashLines = !rawSelector && displayMode.hashLines;
|
|
1035
1033
|
const shouldAddLineNumbers = rawSelector ? false : shouldAddHashLines ? false : displayMode.lineNumbers;
|
|
1036
|
-
const sparseSnapshotEntries: Array<readonly [number, string]> = [];
|
|
1037
1034
|
const maxColumns = resolveOutputMaxColumns(this.session.settings);
|
|
1038
1035
|
|
|
1039
1036
|
const blocks: string[] = [];
|
|
@@ -1063,10 +1060,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1063
1060
|
}
|
|
1064
1061
|
|
|
1065
1062
|
const collectedLines = streamResult.lines;
|
|
1066
|
-
// Column truncation is display-only
|
|
1067
|
-
//
|
|
1068
|
-
// the live file. Stamping ellipsis-truncated lines into the snapshot makes
|
|
1069
|
-
// every long-line file uneditable on the next edit attempt.
|
|
1063
|
+
// Column truncation is display-only; clone before stamping ellipsis so
|
|
1064
|
+
// the original on-disk lines stay intact for display reconstruction.
|
|
1070
1065
|
let displayLines: string[] = collectedLines;
|
|
1071
1066
|
if (!rawSelector && maxColumns > 0) {
|
|
1072
1067
|
let cloned: string[] | undefined;
|
|
@@ -1080,19 +1075,16 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1080
1075
|
}
|
|
1081
1076
|
if (cloned) displayLines = cloned;
|
|
1082
1077
|
}
|
|
1083
|
-
|
|
1084
|
-
for (let index = 0; index < collectedLines.length; index++) {
|
|
1085
|
-
sparseSnapshotEntries.push([range.startLine + index, collectedLines[index]]);
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
1078
|
const blockText = displayLines.join("\n");
|
|
1089
1079
|
blocks.push(formatTextWithMode(blockText, range.startLine, shouldAddHashLines, shouldAddLineNumbers));
|
|
1090
1080
|
}
|
|
1091
1081
|
|
|
1092
1082
|
let outputText = blocks.join("\n\n…\n\n");
|
|
1093
|
-
if (shouldAddHashLines &&
|
|
1094
|
-
const tag =
|
|
1095
|
-
|
|
1083
|
+
if (shouldAddHashLines && outputText) {
|
|
1084
|
+
const tag = await recordFileSnapshot(this.session, absolutePath);
|
|
1085
|
+
if (tag) {
|
|
1086
|
+
outputText = `${formatHashlineHeader(formatPathRelativeToCwd(absolutePath, this.session.cwd), tag)}\n${outputText}`;
|
|
1087
|
+
}
|
|
1096
1088
|
}
|
|
1097
1089
|
if (notices.length > 0) {
|
|
1098
1090
|
outputText = outputText ? `${outputText}\n${notices.join("\n")}` : notices.join("\n");
|
|
@@ -1905,17 +1897,17 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1905
1897
|
const shouldAddLineNumbers = rawSelector ? false : shouldAddHashLines ? false : displayMode.lineNumbers;
|
|
1906
1898
|
let hashContext: HashlineHeaderContext | undefined;
|
|
1907
1899
|
if (shouldAddHashLines && collectedLines.length > 0 && !firstLineExceedsLimit) {
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1900
|
+
// The tag is a content hash of the WHOLE file. A whole-file read
|
|
1901
|
+
// already holds every line in memory; a range read re-reads the
|
|
1902
|
+
// file (bounded by SNAPSHOT_MAX_BYTES) so the tag fingerprints the
|
|
1903
|
+
// full file and any anchor validates while the file is unchanged.
|
|
1904
|
+
const isWholeFile = offset === undefined && limit === undefined && !wasTruncated;
|
|
1905
|
+
const tag = isWholeFile
|
|
1906
|
+
? getFileSnapshotStore(this.session).record(absolutePath, normalizeToLF(collectedLines.join("\n")))
|
|
1907
|
+
: await recordFileSnapshot(this.session, absolutePath);
|
|
1908
|
+
if (tag) {
|
|
1909
|
+
hashContext = hashlineHeaderContext(formatPathRelativeToCwd(absolutePath, this.session.cwd), tag);
|
|
1910
|
+
}
|
|
1919
1911
|
}
|
|
1920
1912
|
|
|
1921
1913
|
let capturedDisplayContent: { text: string; startLine: number } | undefined;
|
|
@@ -2060,11 +2052,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
2060
2052
|
const shouldAddLineNumbers = shouldAddHashLines ? false : displayMode.lineNumbers;
|
|
2061
2053
|
|
|
2062
2054
|
const rawText = region.lines.join("\n");
|
|
2063
|
-
const
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
getFileSnapshotStore(this.session).recordContiguous(entry.absolutePath, region.startLine, region.lines),
|
|
2067
|
-
)
|
|
2055
|
+
const tag = shouldAddHashLines ? await recordFileSnapshot(this.session, entry.absolutePath) : undefined;
|
|
2056
|
+
const hashContext = tag
|
|
2057
|
+
? hashlineHeaderContext(formatPathRelativeToCwd(entry.absolutePath, this.session.cwd), tag)
|
|
2068
2058
|
: undefined;
|
|
2069
2059
|
const formattedBody = formatTextWithMode(rawText, region.startLine, shouldAddHashLines, shouldAddLineNumbers);
|
|
2070
2060
|
const formattedText = prependHashlineHeader(formattedBody, hashContext);
|
package/src/tools/search.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { access, mkdtemp, rm, stat, writeFile } from "node:fs/promises";
|
|
1
|
+
import { mkdtemp, rm, stat, writeFile } from "node:fs/promises";
|
|
3
2
|
import { tmpdir } from "node:os";
|
|
4
3
|
import * as path from "node:path";
|
|
5
4
|
import { formatHashlineHeader } from "@oh-my-pi/hashline";
|
|
@@ -9,7 +8,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
|
|
|
9
8
|
import { Text } from "@oh-my-pi/pi-tui";
|
|
10
9
|
import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
|
|
11
10
|
import * as z from "zod/v4";
|
|
12
|
-
import {
|
|
11
|
+
import { recordFileSnapshot } from "../edit/file-snapshot-store";
|
|
13
12
|
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
14
13
|
import type { Theme } from "../modes/theme/theme";
|
|
15
14
|
import searchDescription from "../prompts/tools/search.md" with { type: "text" };
|
|
@@ -610,19 +609,17 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
|
|
|
610
609
|
matchesByFile.get(relativePath)!.push(match);
|
|
611
610
|
}
|
|
612
611
|
const displayLines: string[] = [];
|
|
613
|
-
const hashContexts = new Map<string, {
|
|
614
|
-
const snapshotStore = baseDisplayMode.hashLines ? getFileSnapshotStore(this.session) : undefined;
|
|
612
|
+
const hashContexts = new Map<string, { tag: string }>();
|
|
615
613
|
if (baseDisplayMode.hashLines) {
|
|
616
614
|
for (const relativePath of fileList) {
|
|
617
615
|
if (archiveDisplaySet.has(relativePath)) continue;
|
|
618
616
|
const absoluteFilePath = path.resolve(this.session.cwd, relativePath);
|
|
619
617
|
if (immutableSourcePaths.has(absoluteFilePath)) continue;
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
}
|
|
618
|
+
// Mint a whole-file content tag so any anchor validates while the
|
|
619
|
+
// file is unchanged; over-cap / unreadable files get no tag (and
|
|
620
|
+
// therefore plain, non-editable line output).
|
|
621
|
+
const tag = await recordFileSnapshot(this.session, absoluteFilePath);
|
|
622
|
+
if (tag) hashContexts.set(relativePath, { tag });
|
|
626
623
|
}
|
|
627
624
|
}
|
|
628
625
|
const renderMatchesForFile = (relativePath: string): { model: string[]; display: string[] } => {
|
|
@@ -641,40 +638,34 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
|
|
|
641
638
|
}
|
|
642
639
|
return nextWidth;
|
|
643
640
|
}, 0);
|
|
644
|
-
const cacheEntries: Array<readonly [number, string]> = [];
|
|
645
641
|
let lastEmittedLine: number | undefined;
|
|
646
642
|
const gutterPad = " ".repeat(lineNumberWidth + 1);
|
|
647
643
|
for (const match of fileMatches) {
|
|
648
|
-
const pushLine = (lineNumber: number, line: string, isMatch: boolean
|
|
644
|
+
const pushLine = (lineNumber: number, line: string, isMatch: boolean) => {
|
|
649
645
|
if (lastEmittedLine !== undefined && lineNumber > lastEmittedLine + 1) {
|
|
650
646
|
modelOut.push("...");
|
|
651
647
|
displayOut.push(`${gutterPad}│...`);
|
|
652
648
|
}
|
|
653
649
|
modelOut.push(formatMatchLine(lineNumber, line, isMatch, { useHashLines }));
|
|
654
650
|
displayOut.push(formatCodeFrameLine(isMatch ? "*" : " ", lineNumber, line, lineNumberWidth));
|
|
655
|
-
if (recordable) cacheEntries.push([lineNumber, line] as const);
|
|
656
651
|
lastEmittedLine = lineNumber;
|
|
657
652
|
};
|
|
658
653
|
if (match.contextBefore) {
|
|
659
654
|
for (const ctx of match.contextBefore) {
|
|
660
|
-
pushLine(ctx.lineNumber, ctx.line, false
|
|
655
|
+
pushLine(ctx.lineNumber, ctx.line, false);
|
|
661
656
|
}
|
|
662
657
|
}
|
|
663
|
-
pushLine(match.lineNumber, match.line, true
|
|
658
|
+
pushLine(match.lineNumber, match.line, true);
|
|
664
659
|
if (match.truncated) {
|
|
665
660
|
linesTruncated = true;
|
|
666
661
|
}
|
|
667
662
|
if (match.contextAfter) {
|
|
668
663
|
for (const ctx of match.contextAfter) {
|
|
669
|
-
pushLine(ctx.lineNumber, ctx.line, false
|
|
664
|
+
pushLine(ctx.lineNumber, ctx.line, false);
|
|
670
665
|
}
|
|
671
666
|
}
|
|
672
667
|
fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
|
|
673
668
|
}
|
|
674
|
-
if (cacheEntries.length > 0 && hashContext) {
|
|
675
|
-
const tag = snapshotStore?.recordSparse(hashContext.absolutePath, cacheEntries);
|
|
676
|
-
if (tag) hashContext.tag = tag;
|
|
677
|
-
}
|
|
678
669
|
return { model: modelOut, display: displayOut };
|
|
679
670
|
};
|
|
680
671
|
if (isDirectory) {
|