@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.
- package/CHANGELOG.md +47 -0
- package/examples/hooks/handoff.ts +1 -1
- package/examples/hooks/qna.ts +1 -1
- package/examples/sdk/03-custom-prompt.ts +7 -4
- package/examples/sdk/README.md +1 -1
- package/package.json +7 -7
- package/src/autoresearch/index.ts +48 -44
- package/src/cli/read-cli.ts +58 -0
- package/src/cli.ts +1 -0
- package/src/commands/read.ts +40 -0
- package/src/commit/agentic/agent.ts +1 -1
- package/src/commit/analysis/conventional.ts +1 -1
- package/src/commit/analysis/summary.ts +1 -1
- package/src/commit/changelog/generate.ts +1 -1
- package/src/commit/map-reduce/map-phase.ts +1 -1
- package/src/commit/map-reduce/reduce-phase.ts +1 -1
- package/src/config/settings-schema.ts +39 -0
- package/src/edit/line-hash.ts +34 -4
- package/src/edit/modes/hashline.ts +221 -7
- package/src/edit/streaming.ts +4 -1
- package/src/export/html/index.ts +1 -1
- package/src/extensibility/extensions/runner.ts +3 -3
- package/src/extensibility/extensions/types.ts +4 -4
- package/src/main.ts +3 -3
- package/src/memories/index.ts +1 -1
- package/src/modes/components/agent-dashboard.ts +1 -1
- package/src/modes/components/custom-editor.ts +4 -5
- package/src/modes/components/read-tool-group.ts +4 -9
- package/src/modes/components/tool-execution.ts +4 -0
- package/src/modes/controllers/event-controller.ts +2 -0
- package/src/modes/controllers/input-controller.ts +3 -1
- package/src/modes/interactive-mode.ts +24 -0
- package/src/modes/rpc/rpc-types.ts +1 -1
- package/src/modes/utils/context-usage.ts +12 -5
- package/src/modes/utils/ui-helpers.ts +1 -0
- package/src/prompts/system/project-prompt.md +36 -0
- package/src/prompts/system/system-prompt.md +0 -29
- package/src/prompts/tools/github.md +1 -0
- package/src/prompts/tools/hashline.md +24 -6
- package/src/prompts/tools/read.md +15 -14
- package/src/sdk.ts +29 -28
- package/src/session/agent-session.ts +20 -12
- package/src/session/compaction/branch-summarization.ts +1 -1
- package/src/session/compaction/compaction.ts +3 -3
- package/src/session/session-dump-format.ts +10 -5
- package/src/session/session-manager.ts +57 -0
- package/src/session/streaming-output.ts +1 -1
- package/src/system-prompt.ts +35 -3
- package/src/task/executor.ts +4 -3
- package/src/tools/fetch.ts +4 -4
- package/src/tools/gh.ts +187 -0
- package/src/tools/image-gen.ts +3 -1
- package/src/tools/inspect-image.ts +1 -1
- package/src/tools/output-meta.ts +1 -1
- package/src/tools/path-utils.ts +11 -0
- package/src/tools/read.ts +388 -204
- package/src/tools/search.ts +1 -1
- package/src/tools/sqlite-reader.ts +1 -1
- package/src/utils/commit-message-generator.ts +1 -1
- package/src/utils/title-generator.ts +1 -1
- package/src/web/search/providers/anthropic.ts +1 -1
- 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.*\
|
|
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(
|
|
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 =
|
|
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
|
+
}
|
package/src/edit/streaming.ts
CHANGED
|
@@ -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)];
|
package/src/export/html/index.ts
CHANGED
|
@@ -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 =
|
|
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 =>
|
|
547
|
+
options.systemPrompt = defaultPrompt => [...defaultPrompt, resolvedAppendPrompt];
|
|
548
548
|
}
|
|
549
549
|
|
|
550
550
|
// Tools
|
package/src/memories/index.ts
CHANGED
|
@@ -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
|
{
|
|
@@ -197,12 +197,11 @@ export class CustomEditor extends Editor {
|
|
|
197
197
|
return;
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
-
// Intercept configured exit shortcut
|
|
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
|
-
|
|
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
|
|
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}${
|
|
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
|
-
//
|
|
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;
|