@oh-my-pi/pi-coding-agent 14.6.5 → 14.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/examples/hooks/handoff.ts +1 -1
  3. package/examples/hooks/qna.ts +1 -1
  4. package/examples/sdk/03-custom-prompt.ts +7 -4
  5. package/examples/sdk/README.md +1 -1
  6. package/package.json +7 -7
  7. package/src/autoresearch/index.ts +48 -44
  8. package/src/cli/read-cli.ts +58 -0
  9. package/src/cli.ts +1 -0
  10. package/src/commands/read.ts +40 -0
  11. package/src/commit/agentic/agent.ts +1 -1
  12. package/src/commit/analysis/conventional.ts +1 -1
  13. package/src/commit/analysis/summary.ts +1 -1
  14. package/src/commit/changelog/generate.ts +1 -1
  15. package/src/commit/map-reduce/map-phase.ts +1 -1
  16. package/src/commit/map-reduce/reduce-phase.ts +1 -1
  17. package/src/config/settings-schema.ts +39 -0
  18. package/src/edit/line-hash.ts +34 -4
  19. package/src/edit/modes/hashline.ts +221 -7
  20. package/src/edit/streaming.ts +4 -1
  21. package/src/export/html/index.ts +1 -1
  22. package/src/extensibility/extensions/runner.ts +3 -3
  23. package/src/extensibility/extensions/types.ts +4 -4
  24. package/src/main.ts +3 -3
  25. package/src/memories/index.ts +1 -1
  26. package/src/modes/components/agent-dashboard.ts +1 -1
  27. package/src/modes/components/custom-editor.ts +4 -5
  28. package/src/modes/components/read-tool-group.ts +4 -9
  29. package/src/modes/components/tool-execution.ts +4 -0
  30. package/src/modes/controllers/event-controller.ts +2 -0
  31. package/src/modes/controllers/input-controller.ts +3 -1
  32. package/src/modes/interactive-mode.ts +24 -0
  33. package/src/modes/rpc/rpc-types.ts +1 -1
  34. package/src/modes/utils/context-usage.ts +12 -5
  35. package/src/modes/utils/ui-helpers.ts +1 -0
  36. package/src/prompts/system/project-prompt.md +36 -0
  37. package/src/prompts/system/system-prompt.md +0 -29
  38. package/src/prompts/tools/github.md +1 -0
  39. package/src/prompts/tools/hashline.md +24 -6
  40. package/src/prompts/tools/read.md +15 -14
  41. package/src/sdk.ts +29 -28
  42. package/src/session/agent-session.ts +20 -12
  43. package/src/session/compaction/branch-summarization.ts +1 -1
  44. package/src/session/compaction/compaction.ts +3 -3
  45. package/src/session/session-dump-format.ts +10 -5
  46. package/src/session/session-manager.ts +57 -0
  47. package/src/session/streaming-output.ts +1 -1
  48. package/src/system-prompt.ts +35 -3
  49. package/src/task/executor.ts +4 -3
  50. package/src/tools/fetch.ts +4 -4
  51. package/src/tools/gh.ts +187 -0
  52. package/src/tools/image-gen.ts +3 -1
  53. package/src/tools/inspect-image.ts +1 -1
  54. package/src/tools/output-meta.ts +1 -1
  55. package/src/tools/path-utils.ts +11 -0
  56. package/src/tools/read.ts +388 -204
  57. package/src/tools/search.ts +1 -1
  58. package/src/tools/sqlite-reader.ts +1 -1
  59. package/src/utils/commit-message-generator.ts +1 -1
  60. package/src/utils/title-generator.ts +1 -1
  61. package/src/web/search/providers/anthropic.ts +1 -1
  62. package/src/workspace-tree.ts +396 -0
@@ -103,6 +103,9 @@ export interface CompactHashlineDiffOptions {
103
103
  /** Maximum entries kept on each side of an unchanged-context truncation (default: 2). */
104
104
  maxUnchangedRun?: number;
105
105
  }
106
+ export interface HashlineApplyOptions {
107
+ autoDropPureInsertDuplicates?: boolean;
108
+ }
106
109
 
107
110
  export interface SplitHashlineOptions {
108
111
  cwd?: string;
@@ -140,7 +143,7 @@ const HL_OUTPUT_PREFIX_SEPARATOR_RE = `[:${HL_BODY_SEP_RE_RAW}]`;
140
143
  const HL_PREFIX_RE = new RegExp(`^\\s*(?:>>>|>>)?\\s*(?:[+*]\\s*)?\\d+[a-z]{2}${HL_OUTPUT_PREFIX_SEPARATOR_RE}`);
141
144
  const HL_PREFIX_PLUS_RE = new RegExp(`^\\s*(?:>>>|>>)?\\s*\\+\\s*\\d+[a-z]{2}${HL_OUTPUT_PREFIX_SEPARATOR_RE}`);
142
145
  const DIFF_PLUS_RE = /^[+](?![+])/;
143
- const READ_TRUNCATION_NOTICE_RE = /^\[(?:Showing lines \d+-\d+ of \d+|\d+ more lines? in (?:file|\S+))\b.*\bsel=L?\d+/;
146
+ const READ_TRUNCATION_NOTICE_RE = /^\[(?:Showing lines \d+-\d+ of \d+|\d+ more lines? in (?:file|\S+))\b.*\bUse :L?\d+/;
144
147
 
145
148
  const HL_HASH_HINT_RE = /^[a-z]{2}$/i;
146
149
  const HL_ANCHOR_EXAMPLES = describeAnchorExamples("160");
@@ -1003,10 +1006,150 @@ function deleteEditForAutoAbsorbedLine(
1003
1006
  };
1004
1007
  }
1005
1008
 
1009
+ interface HashlinePureInsertGroup {
1010
+ startIndex: number;
1011
+ endIndex: number;
1012
+ sourceLineNum: number;
1013
+ cursor: HashlineCursor;
1014
+ payload: string[];
1015
+ }
1016
+
1017
+ function cursorMatches(a: HashlineCursor, b: HashlineCursor): boolean {
1018
+ if (a.kind !== b.kind) return false;
1019
+ if (a.kind === "bof" || a.kind === "eof") return true;
1020
+ const aAnchor = (a as { anchor: Anchor }).anchor;
1021
+ const bAnchor = (b as { anchor: Anchor }).anchor;
1022
+ return aAnchor.line === bAnchor.line && aAnchor.hash === bAnchor.hash;
1023
+ }
1024
+
1025
+ /**
1026
+ * Collects a run of consecutive `insert` edits that all share the same
1027
+ * `lineNum` and `cursor`, IFF that run is not immediately followed by a
1028
+ * `delete` at the same `lineNum` (which would make it a replacement group
1029
+ * instead). Returns the contiguous payload so we can check it for boundary
1030
+ * duplicates against the file.
1031
+ */
1032
+ function findPureInsertGroup(edits: HashlineEdit[], startIndex: number): HashlinePureInsertGroup | undefined {
1033
+ const first = edits[startIndex];
1034
+ if (first?.kind !== "insert") return undefined;
1035
+
1036
+ const sourceLineNum = first.lineNum;
1037
+ const cursor = first.cursor;
1038
+ const payload: string[] = [];
1039
+ let index = startIndex;
1040
+ while (index < edits.length) {
1041
+ const edit = edits[index];
1042
+ if (edit.kind !== "insert" || edit.lineNum !== sourceLineNum) break;
1043
+ if (!cursorMatches(edit.cursor, cursor)) break;
1044
+ payload.push(edit.text);
1045
+ index++;
1046
+ }
1047
+
1048
+ // If the run is followed by a delete at the same source lineNum, this is a
1049
+ // replacement group (handled by absorbReplacement…). Decline.
1050
+ if (index < edits.length && edits[index].kind === "delete" && edits[index].lineNum === sourceLineNum) {
1051
+ return undefined;
1052
+ }
1053
+
1054
+ return { startIndex, endIndex: index - 1, sourceLineNum, cursor, payload };
1055
+ }
1056
+
1057
+ /**
1058
+ * For a pure-insert group, locate the file region adjacent to the insertion
1059
+ * point. Returns 0-indexed bounds:
1060
+ * - `aboveEndIdx`: index of the last file line strictly above the insertion
1061
+ * point (-1 if none).
1062
+ * - `belowStartIdx`: index of the first file line strictly below the
1063
+ * insertion point (`fileLines.length` if none).
1064
+ */
1065
+ function pureInsertNeighborhood(
1066
+ cursor: HashlineCursor,
1067
+ fileLines: string[],
1068
+ ): { aboveEndIdx: number; belowStartIdx: number } {
1069
+ if (cursor.kind === "bof") return { aboveEndIdx: -1, belowStartIdx: 0 };
1070
+ if (cursor.kind === "eof") return { aboveEndIdx: fileLines.length - 1, belowStartIdx: fileLines.length };
1071
+ if (cursor.kind === "before_anchor") {
1072
+ return { aboveEndIdx: cursor.anchor.line - 2, belowStartIdx: cursor.anchor.line - 1 };
1073
+ }
1074
+ // after_anchor
1075
+ return { aboveEndIdx: cursor.anchor.line - 1, belowStartIdx: cursor.anchor.line };
1076
+ }
1077
+
1078
+ interface PureInsertAbsorbResult {
1079
+ keptPayload: string[];
1080
+ absorbedLeading: number;
1081
+ absorbedTrailing: number;
1082
+ leadingFileRange?: { start: number; end: number }; // 1-indexed inclusive
1083
+ trailingFileRange?: { start: number; end: number }; // 1-indexed inclusive
1084
+ }
1085
+
1086
+ /**
1087
+ * Mirror of replacement-absorb's prefix/suffix block check, but for pure
1088
+ * inserts: drop payload lines that exactly duplicate the file lines
1089
+ * immediately above (leading) or immediately below (trailing) the insertion
1090
+ * point. Requires a minimum run of 2 to avoid spurious single-line matches,
1091
+ * matching the existing replacement-absorb threshold.
1092
+ */
1093
+ function tryAbsorbPureInsertGroup(group: HashlinePureInsertGroup, fileLines: string[]): PureInsertAbsorbResult {
1094
+ const empty: PureInsertAbsorbResult = { keptPayload: group.payload, absorbedLeading: 0, absorbedTrailing: 0 };
1095
+ if (group.payload.length < 2) return empty;
1096
+
1097
+ const { aboveEndIdx, belowStartIdx } = pureInsertNeighborhood(group.cursor, fileLines);
1098
+
1099
+ // Leading: payload[0..k-1] vs fileLines[aboveEndIdx-k+1 .. aboveEndIdx].
1100
+ let absorbedLeading = 0;
1101
+ const maxLead = Math.min(group.payload.length, aboveEndIdx + 1);
1102
+ for (let count = maxLead; count >= 2; count--) {
1103
+ let ok = true;
1104
+ for (let offset = 0; offset < count; offset++) {
1105
+ if (group.payload[offset] !== fileLines[aboveEndIdx - count + 1 + offset]) {
1106
+ ok = false;
1107
+ break;
1108
+ }
1109
+ }
1110
+ if (ok) {
1111
+ absorbedLeading = count;
1112
+ break;
1113
+ }
1114
+ }
1115
+
1116
+ // Trailing: payload[len-k..len-1] vs fileLines[belowStartIdx..belowStartIdx+k-1].
1117
+ // Don't double-count payload lines already absorbed as leading.
1118
+ let absorbedTrailing = 0;
1119
+ const remaining = group.payload.length - absorbedLeading;
1120
+ const maxTrail = Math.min(remaining, fileLines.length - belowStartIdx);
1121
+ for (let count = maxTrail; count >= 2; count--) {
1122
+ let ok = true;
1123
+ for (let offset = 0; offset < count; offset++) {
1124
+ if (group.payload[group.payload.length - count + offset] !== fileLines[belowStartIdx + offset]) {
1125
+ ok = false;
1126
+ break;
1127
+ }
1128
+ }
1129
+ if (ok) {
1130
+ absorbedTrailing = count;
1131
+ break;
1132
+ }
1133
+ }
1134
+
1135
+ if (absorbedLeading === 0 && absorbedTrailing === 0) return empty;
1136
+
1137
+ return {
1138
+ keptPayload: group.payload.slice(absorbedLeading, group.payload.length - absorbedTrailing),
1139
+ absorbedLeading,
1140
+ absorbedTrailing,
1141
+ leadingFileRange:
1142
+ absorbedLeading > 0 ? { start: aboveEndIdx - absorbedLeading + 2, end: aboveEndIdx + 1 } : undefined,
1143
+ trailingFileRange:
1144
+ absorbedTrailing > 0 ? { start: belowStartIdx + 1, end: belowStartIdx + absorbedTrailing } : undefined,
1145
+ };
1146
+ }
1147
+
1006
1148
  function absorbReplacementBoundaryDuplicates(
1007
1149
  edits: HashlineEdit[],
1008
1150
  fileLines: string[],
1009
1151
  warnings: string[],
1152
+ options: HashlineApplyOptions,
1010
1153
  ): HashlineEdit[] {
1011
1154
  let nextSyntheticIndex = edits.length;
1012
1155
  const absorbed: HashlineEdit[] = [];
@@ -1021,6 +1164,47 @@ function absorbReplacementBoundaryDuplicates(
1021
1164
  for (let index = 0; index < edits.length; index++) {
1022
1165
  const group = findReplacementGroup(edits, index);
1023
1166
  if (!group) {
1167
+ if (options.autoDropPureInsertDuplicates) {
1168
+ const pureInsert = findPureInsertGroup(edits, index);
1169
+ if (pureInsert) {
1170
+ const result = tryAbsorbPureInsertGroup(pureInsert, fileLines);
1171
+ if (result.absorbedLeading > 0 || result.absorbedTrailing > 0) {
1172
+ if (result.leadingFileRange) {
1173
+ const { start, end } = result.leadingFileRange;
1174
+ const key = `pure-insert-leading:${start}..${end}`;
1175
+ if (!emittedAbsorbKeys.has(key)) {
1176
+ emittedAbsorbKeys.add(key);
1177
+ warnings.push(
1178
+ `Auto-dropped ${result.absorbedLeading} duplicate line(s) at the start of insert at line ${pureInsert.sourceLineNum} ` +
1179
+ `(file lines ${start}..${end} already match the payload's leading lines).`,
1180
+ );
1181
+ }
1182
+ }
1183
+ if (result.trailingFileRange) {
1184
+ const { start, end } = result.trailingFileRange;
1185
+ const key = `pure-insert-trailing:${start}..${end}`;
1186
+ if (!emittedAbsorbKeys.has(key)) {
1187
+ emittedAbsorbKeys.add(key);
1188
+ warnings.push(
1189
+ `Auto-dropped ${result.absorbedTrailing} duplicate line(s) at the end of insert at line ${pureInsert.sourceLineNum} ` +
1190
+ `(file lines ${start}..${end} already match the payload's trailing lines).`,
1191
+ );
1192
+ }
1193
+ }
1194
+ for (const text of result.keptPayload) {
1195
+ absorbed.push({
1196
+ kind: "insert",
1197
+ cursor: cloneCursor(pureInsert.cursor),
1198
+ text,
1199
+ lineNum: pureInsert.sourceLineNum,
1200
+ index: nextSyntheticIndex++,
1201
+ });
1202
+ }
1203
+ index = pureInsert.endIndex;
1204
+ continue;
1205
+ }
1206
+ }
1207
+ }
1024
1208
  absorbed.push(edits[index]);
1025
1209
  continue;
1026
1210
  }
@@ -1094,7 +1278,11 @@ function bucketAnchorEditsByLine(edits: IndexedEdit[]): Map<number, IndexedEdit[
1094
1278
  return byLine;
1095
1279
  }
1096
1280
 
1097
- export function applyHashlineEdits(text: string, edits: HashlineEdit[]): HashlineApplyResult {
1281
+ export function applyHashlineEdits(
1282
+ text: string,
1283
+ edits: HashlineEdit[],
1284
+ options: HashlineApplyOptions = {},
1285
+ ): HashlineApplyResult {
1098
1286
  if (edits.length === 0) return { lines: text, firstChangedLine: undefined };
1099
1287
 
1100
1288
  const fileLines = text.split("\n");
@@ -1109,7 +1297,7 @@ export function applyHashlineEdits(text: string, edits: HashlineEdit[]): Hashlin
1109
1297
  const mismatches = validateHashlineAnchors(edits, fileLines, warnings);
1110
1298
  if (mismatches.length > 0) throw new HashlineMismatchError(mismatches, fileLines);
1111
1299
 
1112
- const normalizedEdits = absorbReplacementBoundaryDuplicates(edits, fileLines, warnings);
1300
+ const normalizedEdits = absorbReplacementBoundaryDuplicates(edits, fileLines, warnings, options);
1113
1301
 
1114
1302
  // Normalize after_anchor inserts to before_anchor of the next line, or EOF
1115
1303
  // when the anchor is the final line. This keeps the bucketing logic below
@@ -1332,6 +1520,7 @@ async function readHashlineFileText(file: { text(): Promise<string> }, pathText:
1332
1520
  export async function computeHashlineDiff(
1333
1521
  input: { input: string; path?: string },
1334
1522
  cwd: string,
1523
+ options: HashlineApplyOptions = {},
1335
1524
  ): Promise<{ diff: string; firstChangedLine: number | undefined } | { error: string }> {
1336
1525
  try {
1337
1526
  const sections = splitHashlineInputs(input.input, { cwd, path: input.path });
@@ -1344,7 +1533,7 @@ export async function computeHashlineDiff(
1344
1533
  const rawContent = await readHashlineFileText(Bun.file(absolutePath), section.path);
1345
1534
  const { text: content } = stripBom(rawContent);
1346
1535
  const normalized = normalizeToLF(content);
1347
- const result = applyHashlineEdits(normalized, parseHashline(section.diff));
1536
+ const result = applyHashlineEdits(normalized, parseHashline(section.diff), options);
1348
1537
  if (normalized === result.lines) return { error: `No changes would be made to ${section.path}.` };
1349
1538
  return generateDiffString(normalized, result.lines);
1350
1539
  } catch (err) {
@@ -1382,6 +1571,12 @@ function formatNoChangeDiagnostic(pathText: string): string {
1382
1571
  return `Edits to ${pathText} resulted in no changes being made.`;
1383
1572
  }
1384
1573
 
1574
+ function getHashlineApplyOptions(session: ToolSession): HashlineApplyOptions {
1575
+ return {
1576
+ autoDropPureInsertDuplicates: session.settings.get("edit.hashlineAutoDropPureInsertDuplicates"),
1577
+ };
1578
+ }
1579
+
1385
1580
  function getTextContent(result: AgentToolResult<EditToolDetails>): string {
1386
1581
  return result.content.map(part => (part.type === "text" ? part.text : "")).join("\n");
1387
1582
  }
@@ -1408,7 +1603,7 @@ async function preflightHashlineSection(options: ExecuteHashlineSingleOptions &
1408
1603
 
1409
1604
  const { text } = stripBom(source.rawContent);
1410
1605
  const normalized = normalizeToLF(text);
1411
- const result = applyHashlineEdits(normalized, edits);
1606
+ const result = applyHashlineEdits(normalized, edits, getHashlineApplyOptions(session));
1412
1607
  if (normalized === result.lines) throw new Error(formatNoChangeDiagnostic(sectionPath));
1413
1608
  }
1414
1609
 
@@ -1436,7 +1631,7 @@ async function executeHashlineSection(
1436
1631
  const { bom, text } = stripBom(source.rawContent);
1437
1632
  const originalEnding = detectLineEnding(text);
1438
1633
  const originalNormalized = normalizeToLF(text);
1439
- const result = applyHashlineEdits(originalNormalized, edits);
1634
+ const result = applyHashlineEdits(originalNormalized, edits, getHashlineApplyOptions(session));
1440
1635
 
1441
1636
  if (originalNormalized === result.lines) {
1442
1637
  return {
@@ -1486,7 +1681,9 @@ async function executeHashlineSection(
1486
1681
  export async function executeHashlineSingle(
1487
1682
  options: ExecuteHashlineSingleOptions,
1488
1683
  ): Promise<AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema>> {
1489
- const sections = splitHashlineInputs(options.input, { cwd: options.session.cwd, path: options.path });
1684
+ const sections = mergeSamePathSections(
1685
+ splitHashlineInputs(options.input, { cwd: options.session.cwd, path: options.path }),
1686
+ );
1490
1687
 
1491
1688
  // Fast path: a single section needs no preflight pass.
1492
1689
  if (sections.length === 1) return executeHashlineSection({ ...options, ...sections[0] });
@@ -1518,3 +1715,20 @@ export async function executeHashlineSingle(
1518
1715
  },
1519
1716
  };
1520
1717
  }
1718
+
1719
+ /**
1720
+ * Collapse consecutive or interleaved sections targeting the same path into a
1721
+ * single section with concatenated diffs. Anchors authored against the same
1722
+ * file snapshot must be applied as one batch; otherwise the first sub-edit
1723
+ * shifts line numbers out from under the second's anchors and rebase fails.
1724
+ * Path order is preserved by first occurrence.
1725
+ */
1726
+ function mergeSamePathSections(sections: HashlineInputSection[]): HashlineInputSection[] {
1727
+ const byPath = new Map<string, string[]>();
1728
+ for (const section of sections) {
1729
+ const existing = byPath.get(section.path);
1730
+ if (existing) existing.push(section.diff);
1731
+ else byPath.set(section.path, [section.diff]);
1732
+ }
1733
+ return Array.from(byPath, ([path, diffs]) => ({ path, diff: diffs.join("\n") }));
1734
+ }
@@ -32,6 +32,7 @@ export interface StreamingDiffContext {
32
32
  signal: AbortSignal;
33
33
  fuzzyThreshold?: number;
34
34
  allowFuzzy?: boolean;
35
+ hashlineAutoDropPureInsertDuplicates?: boolean;
35
36
  }
36
37
 
37
38
  export interface EditStreamingStrategy<Args = unknown> {
@@ -222,7 +223,9 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
222
223
  async computeDiffPreview(args, ctx) {
223
224
  if (typeof args.input !== "string" || args.input.length === 0) return null;
224
225
  ctx.signal.throwIfAborted();
225
- const result = await computeHashlineDiff({ input: args.input, path: args.path }, ctx.cwd);
226
+ const result = await computeHashlineDiff({ input: args.input, path: args.path }, ctx.cwd, {
227
+ autoDropPureInsertDuplicates: ctx.hashlineAutoDropPureInsertDuplicates,
228
+ });
226
229
  ctx.signal.throwIfAborted();
227
230
  if ("error" in result && !args.path) return [{ path: "", error: result.error }];
228
231
  return [toPerFilePreview(args.path ?? "", result)];
@@ -124,7 +124,7 @@ export async function exportSessionToHtml(
124
124
  header: sm.getHeader(),
125
125
  entries: sm.getEntries(),
126
126
  leafId: sm.getLeafId(),
127
- systemPrompt: state?.systemPrompt,
127
+ systemPrompt: state?.systemPrompt.join("\n\n"),
128
128
  tools: state?.tools?.map(t => ({ name: t.name, description: t.description })),
129
129
  };
130
130
 
@@ -55,7 +55,7 @@ import type {
55
55
  /** Combined result from all before_agent_start handlers */
56
56
  interface BeforeAgentStartCombinedResult {
57
57
  messages?: NonNullable<BeforeAgentStartEventResult["message"]>[];
58
- systemPrompt?: string;
58
+ systemPrompt?: string[];
59
59
  }
60
60
 
61
61
  export type ExtensionErrorListener = (error: ExtensionError) => void;
@@ -168,7 +168,7 @@ export class ExtensionRunner {
168
168
  #hasPendingMessagesFn: () => boolean = () => false;
169
169
  #getContextUsageFn: () => ContextUsage | undefined = () => undefined;
170
170
  #compactFn: (instructionsOrOptions?: string | CompactOptions) => Promise<void> = async () => {};
171
- #getSystemPromptFn: () => string = () => "";
171
+ #getSystemPromptFn: () => string[] = () => [];
172
172
  #newSessionHandler: NewSessionHandler = async () => ({ cancelled: false });
173
173
  #branchHandler: BranchHandler = async () => ({ cancelled: false });
174
174
  #navigateTreeHandler: NavigateTreeHandler = async () => ({ cancelled: false });
@@ -795,7 +795,7 @@ export class ExtensionRunner {
795
795
  async emitBeforeAgentStart(
796
796
  prompt: string,
797
797
  images: ImageContent[] | undefined,
798
- systemPrompt: string,
798
+ systemPrompt: string[],
799
799
  ): Promise<BeforeAgentStartCombinedResult | undefined> {
800
800
  const ctx = this.createContext();
801
801
  const messages: NonNullable<BeforeAgentStartEventResult["message"]>[] = [];
@@ -240,7 +240,7 @@ export interface ExtensionContext {
240
240
  /** Gracefully shutdown and exit. */
241
241
  shutdown(): void;
242
242
  /** Get the current effective system prompt. */
243
- getSystemPrompt(): string;
243
+ getSystemPrompt(): string[];
244
244
  /** @deprecated Use hasPendingMessages() instead */
245
245
  hasQueuedMessages(): boolean;
246
246
  }
@@ -492,7 +492,7 @@ export interface BeforeAgentStartEvent {
492
492
  type: "before_agent_start";
493
493
  prompt: string;
494
494
  images?: ImageContent[];
495
- systemPrompt: string;
495
+ systemPrompt: string[];
496
496
  }
497
497
 
498
498
  /** Fired when an agent loop starts */
@@ -876,7 +876,7 @@ export interface ToolResultEventResult {
876
876
  export interface BeforeAgentStartEventResult {
877
877
  message?: Pick<CustomMessage, "customType" | "content" | "display" | "details" | "attribution">;
878
878
  /** Replace the system prompt for this turn. If multiple extensions return this, they are chained. */
879
- systemPrompt?: string;
879
+ systemPrompt?: string[];
880
880
  }
881
881
 
882
882
  export interface SessionBeforeSwitchResult {
@@ -1318,7 +1318,7 @@ export interface ExtensionContextActions {
1318
1318
  shutdown: () => void;
1319
1319
  getContextUsage: () => ContextUsage | undefined;
1320
1320
  compact: (instructionsOrOptions?: string | CompactOptions) => Promise<void>;
1321
- getSystemPrompt: () => string;
1321
+ getSystemPrompt: () => string[];
1322
1322
  }
1323
1323
 
1324
1324
  /** Actions for ExtensionCommandContext (ctx.* in command handlers). */
package/src/main.ts CHANGED
@@ -540,11 +540,11 @@ async function buildSessionOptions(
540
540
 
541
541
  // System prompt
542
542
  if (resolvedSystemPrompt && resolvedAppendPrompt) {
543
- options.systemPrompt = `${resolvedSystemPrompt}\n\n${resolvedAppendPrompt}`;
543
+ options.systemPrompt = defaultPrompt => [resolvedSystemPrompt, resolvedAppendPrompt, ...defaultPrompt.slice(1)];
544
544
  } else if (resolvedSystemPrompt) {
545
- options.systemPrompt = resolvedSystemPrompt;
545
+ options.systemPrompt = defaultPrompt => [resolvedSystemPrompt, ...defaultPrompt.slice(1)];
546
546
  } else if (resolvedAppendPrompt) {
547
- options.systemPrompt = defaultPrompt => `${defaultPrompt}\n\n${resolvedAppendPrompt}`;
547
+ options.systemPrompt = defaultPrompt => [...defaultPrompt, resolvedAppendPrompt];
548
548
  }
549
549
 
550
550
  // Tools
@@ -602,7 +602,7 @@ async function runStage1Job(options: {
602
602
  const response = await completeSimple(
603
603
  model,
604
604
  {
605
- systemPrompt: stageOneSystemTemplate,
605
+ systemPrompt: [stageOneSystemTemplate],
606
606
  messages: [{ role: "user", content: [{ type: "text", text: inputPrompt }], timestamp: Date.now() }],
607
607
  },
608
608
  {
@@ -635,7 +635,7 @@ export class AgentDashboard extends Container {
635
635
  modelRegistry,
636
636
  settings,
637
637
  model: selectedModel,
638
- systemPrompt,
638
+ systemPrompt: [systemPrompt],
639
639
  hasUI: false,
640
640
  enableLsp: false,
641
641
  enableMCP: false,
@@ -197,12 +197,11 @@ export class CustomEditor extends Editor {
197
197
  return;
198
198
  }
199
199
 
200
- // Intercept configured exit shortcut (only when editor is empty)
200
+ // Intercept configured exit shortcut. Always consume the shortcut so it
201
+ // never reaches the parent handler; firing onExit is the controller's
202
+ // chance to snapshot the current text as a draft before shutting down.
201
203
  if (this.#matchesAction(data, "app.exit")) {
202
- if (this.getText().length === 0 && this.onExit) {
203
- this.onExit();
204
- }
205
- // Always consume exit shortcut (don't pass to parent)
204
+ this.onExit?.();
206
205
  return;
207
206
  }
208
207
 
@@ -8,6 +8,7 @@ import type { ToolExecutionHandle } from "./tool-execution";
8
8
  type ReadRenderArgs = {
9
9
  path?: string;
10
10
  file_path?: string;
11
+ // Legacy field from the old schema; tolerated for rebuilt transcripts.
11
12
  sel?: string;
12
13
  };
13
14
 
@@ -37,7 +38,6 @@ function getSuffixResolution(details: ReadToolResultDetails | undefined): ReadTo
37
38
  type ReadEntry = {
38
39
  toolCallId: string;
39
40
  path: string;
40
- sel?: string;
41
41
  status: "pending" | "success" | "warning" | "error";
42
42
  correctedFrom?: string;
43
43
  contentText?: string;
@@ -62,15 +62,14 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
62
62
 
63
63
  updateArgs(args: ReadRenderArgs, toolCallId?: string): void {
64
64
  if (!toolCallId) return;
65
- const rawPath = args.file_path || args.path || "";
65
+ const basePath = args.file_path || args.path || "";
66
+ const rawPath = args.sel ? `${basePath}:${args.sel}` : basePath;
66
67
  const entry: ReadEntry = this.#entries.get(toolCallId) ?? {
67
68
  toolCallId,
68
69
  path: rawPath,
69
- sel: args.sel,
70
70
  status: "pending",
71
71
  };
72
72
  entry.path = rawPath;
73
- entry.sel = args.sel;
74
73
  this.#entries.set(toolCallId, entry);
75
74
  this.#updateDisplay();
76
75
  }
@@ -172,9 +171,8 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
172
171
  #addContentPreview(entry: ReadEntry): void {
173
172
  const lang = getLanguageFromPath(entry.path);
174
173
  const filePath = shortenPath(entry.path);
175
- const selectionSuffix = entry.sel ? `:${entry.sel}` : "";
176
174
  const correctionSuffix = entry.correctedFrom ? ` (corrected from ${shortenPath(entry.correctedFrom)})` : "";
177
- const title = filePath ? `Read ${filePath}${selectionSuffix}${correctionSuffix}` : `Read${selectionSuffix}`;
175
+ const title = filePath ? `Read ${filePath}${correctionSuffix}` : "Read";
178
176
  let cachedWidth: number | undefined;
179
177
  let cachedLines: string[] | undefined;
180
178
  const expanded = this.#expanded;
@@ -211,9 +209,6 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
211
209
  #formatPath(entry: ReadEntry): string {
212
210
  const filePath = shortenPath(entry.path);
213
211
  let pathDisplay = filePath ? theme.fg("accent", filePath) : theme.fg("toolOutput", "…");
214
- if (entry.sel) {
215
- pathDisplay += theme.fg("warning", `:${entry.sel}`);
216
- }
217
212
  if (entry.correctedFrom) {
218
213
  pathDisplay += theme.fg("dim", ` (corrected from ${shortenPath(entry.correctedFrom)})`);
219
214
  }
@@ -67,6 +67,7 @@ export interface ToolExecutionOptions {
67
67
  showImages?: boolean; // default: true (only used if terminal supports images)
68
68
  editFuzzyThreshold?: number;
69
69
  editAllowFuzzy?: boolean;
70
+ hashlineAutoDropPureInsertDuplicates?: boolean;
70
71
  }
71
72
 
72
73
  export interface ToolExecutionHandle {
@@ -100,6 +101,7 @@ export class ToolExecutionComponent extends Container {
100
101
  #showImages: boolean;
101
102
  #editFuzzyThreshold: number | undefined;
102
103
  #editAllowFuzzy: boolean | undefined;
104
+ #hashlineAutoDropPureInsertDuplicates: boolean | undefined;
103
105
  #isPartial = true;
104
106
  #tool?: AgentTool;
105
107
  #ui: TUI;
@@ -147,6 +149,7 @@ export class ToolExecutionComponent extends Container {
147
149
  this.#showImages = options.showImages ?? true;
148
150
  this.#editFuzzyThreshold = options.editFuzzyThreshold;
149
151
  this.#editAllowFuzzy = options.editAllowFuzzy;
152
+ this.#hashlineAutoDropPureInsertDuplicates = options.hashlineAutoDropPureInsertDuplicates;
150
153
  this.#tool = tool;
151
154
  this.#ui = ui;
152
155
  this.#cwd = cwd;
@@ -248,6 +251,7 @@ export class ToolExecutionComponent extends Container {
248
251
  signal: controller.signal,
249
252
  fuzzyThreshold: this.#editFuzzyThreshold,
250
253
  allowFuzzy: this.#editAllowFuzzy,
254
+ hashlineAutoDropPureInsertDuplicates: this.#hashlineAutoDropPureInsertDuplicates,
251
255
  });
252
256
  if (controller.signal.aborted) return;
253
257
  if (previews) {
@@ -280,6 +280,7 @@ export class EventController {
280
280
  showImages: settings.get("terminal.showImages"),
281
281
  editFuzzyThreshold: settings.get("edit.fuzzyThreshold"),
282
282
  editAllowFuzzy: settings.get("edit.fuzzyMatch"),
283
+ hashlineAutoDropPureInsertDuplicates: settings.get("edit.hashlineAutoDropPureInsertDuplicates"),
283
284
  },
284
285
  tool,
285
286
  this.ctx.ui,
@@ -383,6 +384,7 @@ export class EventController {
383
384
  showImages: settings.get("terminal.showImages"),
384
385
  editFuzzyThreshold: settings.get("edit.fuzzyThreshold"),
385
386
  editAllowFuzzy: settings.get("edit.fuzzyMatch"),
387
+ hashlineAutoDropPureInsertDuplicates: settings.get("edit.hashlineAutoDropPureInsertDuplicates"),
386
388
  },
387
389
  tool,
388
390
  this.ctx.ui,
@@ -403,7 +403,9 @@ export class InputController {
403
403
  }
404
404
 
405
405
  handleCtrlD(): void {
406
- // Only called when editor is empty (enforced by CustomEditor)
406
+ // Editor text (if any) is snapshotted at the start of shutdown() and
407
+ // persisted as a draft for the next resume. Empty text is also fine —
408
+ // shutdown clears any stale sidecar in that case.
407
409
  void this.ctx.shutdown();
408
410
  }
409
411
 
@@ -445,6 +445,20 @@ export class InteractiveMode implements InteractiveModeContext {
445
445
  // Restore mode from session (e.g. plan mode on resume)
446
446
  await this.#restoreModeFromSession();
447
447
 
448
+ // Restore unsent editor draft from previous session shutdown (Ctrl+D).
449
+ // One-shot: consumeDraft removes the sidecar after read so the next
450
+ // resume does not re-restore the same text.
451
+ try {
452
+ const draft = await this.sessionManager.consumeDraft();
453
+ if (draft && !this.editor.getText()) {
454
+ this.editor.setText(draft);
455
+ this.updateEditorBorderColor();
456
+ this.ui.requestRender();
457
+ }
458
+ } catch (err) {
459
+ logger.warn("Failed to restore session draft", { error: String(err) });
460
+ }
461
+
448
462
  // Subscribe to agent events
449
463
  this.#subscribeToAgent();
450
464
 
@@ -1189,8 +1203,18 @@ export class InteractiveMode implements InteractiveModeContext {
1189
1203
  if (this.#isShuttingDown) return;
1190
1204
  this.#isShuttingDown = true;
1191
1205
 
1206
+ // Snapshot the editor before any teardown empties it. Persisting the draft
1207
+ // here covers Ctrl+D shutdown with non-empty text; for /exit the editor is
1208
+ // already cleared so saveDraft("") just removes any stale sidecar.
1209
+ const draftText = this.editor.getText();
1210
+
1192
1211
  // Flush pending session writes before shutdown
1193
1212
  await this.sessionManager.flush();
1213
+ try {
1214
+ await this.sessionManager.saveDraft(draftText);
1215
+ } catch (err) {
1216
+ logger.warn("Failed to save session draft", { error: String(err) });
1217
+ }
1194
1218
  this.#btwController.dispose();
1195
1219
 
1196
1220
  // Emit shutdown event to hooks
@@ -89,7 +89,7 @@ export interface RpcSessionState {
89
89
  queuedMessageCount: number;
90
90
  todoPhases: TodoPhase[];
91
91
  /** For session dump / export (plain-text parity with /dump). */
92
- systemPrompt?: string;
92
+ systemPrompt?: string[];
93
93
  dumpTools?: Array<{ name: string; description: string; parameters: unknown }>;
94
94
  /** Current context window usage. Null tokens/percent when unknown (e.g. right after compaction). */
95
95
  contextUsage?: ContextUsage;