@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.
@@ -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 theme.fg("success", `${prefix}${checkbox.checked} ${chalk.strikethrough(todo.content)}`) + marker;
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}${checkbox.unchecked} ${todo.content}`) + marker;
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) return;
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
- const visibleTasks = activePhase.tasks.slice(0, 5);
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 (visibleTasks.length < activePhase.tasks.length) {
999
- const remaining = activePhase.tasks.length - visibleTasks.length;
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();
@@ -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.recordContiguous(absolutePath, 1, fullText.split("\n"), { fullText });
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.
@@ -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 { getFileSnapshotStore } from "../edit/file-snapshot-store";
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, { absolutePath: string; tag?: 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
- try {
230
- await access(absolutePath, constants.R_OK);
231
- hashContexts.set(relativePath, { absolutePath });
232
- } catch {
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).recordContiguous(absolutePath, 1, normalized.split("\n"), {
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. The snapshot (sparseSnapshotEntries)
1067
- // MUST hold on-disk content so later edits can verify line content against
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 && sparseSnapshotEntries.length > 0 && outputText) {
1094
- const tag = getFileSnapshotStore(this.session).recordSparse(absolutePath, sparseSnapshotEntries);
1095
- outputText = `${formatHashlineHeader(formatPathRelativeToCwd(absolutePath, this.session.cwd), tag)}\n${outputText}`;
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
- const store = getFileSnapshotStore(this.session);
1909
- const tag =
1910
- offset === undefined && limit === undefined && !wasTruncated
1911
- ? (() => {
1912
- const normalized = normalizeToLF(collectedLines.join("\n"));
1913
- return store.recordContiguous(absolutePath, 1, normalized.split("\n"), {
1914
- fullText: normalized,
1915
- });
1916
- })()
1917
- : store.recordContiguous(absolutePath, startLineDisplay, collectedLines);
1918
- hashContext = hashlineHeaderContext(formatPathRelativeToCwd(absolutePath, this.session.cwd), tag);
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 hashContext = shouldAddHashLines
2064
- ? hashlineHeaderContext(
2065
- formatPathRelativeToCwd(entry.absolutePath, this.session.cwd),
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);
@@ -1,5 +1,4 @@
1
- import { constants } from "node:fs";
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 { getFileSnapshotStore } from "../edit/file-snapshot-store";
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, { absolutePath: string; tag?: 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
- try {
621
- await access(absoluteFilePath, constants.R_OK);
622
- hashContexts.set(relativePath, { absolutePath: absoluteFilePath });
623
- } catch {
624
- // Best-effort: if the file disappeared between grep and render, fall back to plain line output.
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, recordable: 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, true);
655
+ pushLine(ctx.lineNumber, ctx.line, false);
661
656
  }
662
657
  }
663
- pushLine(match.lineNumber, match.line, true, !match.truncated);
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, true);
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) {