pi-hashline-edit-pro 6.1.2 → 6.3.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/README.md +41 -23
- package/index.ts +20 -12
- package/package.json +3 -2
- package/src/anchor-registry.ts +22 -5
- package/src/auto-read-all.ts +39 -21
- package/src/batch.ts +127 -41
- package/src/commit.ts +1 -1
- package/src/config-ui.ts +135 -55
- package/src/config.ts +27 -9
- package/src/copy-move.ts +61 -72
- package/src/edit-common.ts +6 -4
- package/src/grep.ts +53 -121
- package/src/hash-store.ts +13 -12
- package/src/hashline/resolve.ts +13 -2
- package/src/insert.ts +40 -34
- package/src/line-endings.ts +10 -1
- package/src/outline/index.ts +46 -0
- package/src/outline/parsers.ts +42 -0
- package/src/outline/render.ts +170 -0
- package/src/outline/vendor/LICENSE +201 -0
- package/src/outline/vendor/README.md +17 -0
- package/src/outline/vendor/parsers/blade.ts +138 -0
- package/src/outline/vendor/parsers/index.ts +75 -0
- package/src/outline/vendor/parsers/java.ts +501 -0
- package/src/outline/vendor/parsers/manager.ts +1721 -0
- package/src/outline/vendor/parsers/php.ts +332 -0
- package/src/outline/vendor/parsers/sass.ts +416 -0
- package/src/outline/vendor/parsers/scss.ts +566 -0
- package/src/outline/vendor/types.ts +82 -0
- package/src/outline/vendor/wasm/LICENSES.md +37 -0
- package/src/outline/vendor/wasm/tree-sitter-blade.wasm +0 -0
- package/src/outline/vendor/wasm/tree-sitter-go.wasm +0 -0
- package/src/outline/vendor/wasm/tree-sitter-java.wasm +0 -0
- package/src/outline/vendor/wasm/tree-sitter-javascript.wasm +0 -0
- package/src/outline/vendor/wasm/tree-sitter-php.wasm +0 -0
- package/src/outline/vendor/wasm/tree-sitter-python.wasm +0 -0
- package/src/outline/vendor/wasm/tree-sitter-rust.wasm +0 -0
- package/src/outline/vendor/wasm/tree-sitter-sass.wasm +0 -0
- package/src/outline/vendor/wasm/tree-sitter-scss.wasm +0 -0
- package/src/outline/vendor/wasm/tree-sitter-solidity.wasm +0 -0
- package/src/outline/vendor/wasm/tree-sitter-tsx.wasm +0 -0
- package/src/outline/vendor/wasm/tree-sitter-typescript.wasm +0 -0
- package/src/read.ts +118 -41
- package/src/replace-match.ts +42 -41
- package/src/replace-undo.ts +4 -4
- package/src/replace.ts +30 -19
- package/tool-prompts/read-guidelines.md +3 -0
- package/{prompts → tool-prompts}/read.md +2 -0
- package/prompts/read-guidelines.md +0 -1
- package/src/auto-read-all-state.ts +0 -27
- /package/{prompts → tool-prompts}/copy-guidelines.md +0 -0
- /package/{prompts → tool-prompts}/copy-snippet.md +0 -0
- /package/{prompts → tool-prompts}/copy.md +0 -0
- /package/{prompts → tool-prompts}/grep-guidelines.md +0 -0
- /package/{prompts → tool-prompts}/grep-snippet.md +0 -0
- /package/{prompts → tool-prompts}/grep.md +0 -0
- /package/{prompts → tool-prompts}/insert-guidelines.md +0 -0
- /package/{prompts → tool-prompts}/insert-snippet.md +0 -0
- /package/{prompts → tool-prompts}/insert.md +0 -0
- /package/{prompts → tool-prompts}/move-guidelines.md +0 -0
- /package/{prompts → tool-prompts}/move-snippet.md +0 -0
- /package/{prompts → tool-prompts}/move.md +0 -0
- /package/{prompts → tool-prompts}/read-snippet.md +0 -0
- /package/{prompts → tool-prompts}/replace-guidelines.md +0 -0
- /package/{prompts → tool-prompts}/replace-match-guidelines.md +0 -0
- /package/{prompts → tool-prompts}/replace-match-snippet.md +0 -0
- /package/{prompts → tool-prompts}/replace-match.md +0 -0
- /package/{prompts → tool-prompts}/replace-snippet.md +0 -0
- /package/{prompts → tool-prompts}/replace.md +0 -0
- /package/{prompts → tool-prompts}/transfer-guidelines.md +0 -0
- /package/{prompts → tool-prompts}/undo-last-change-guidelines.md +0 -0
- /package/{prompts → tool-prompts}/undo-last-change-snippet.md +0 -0
- /package/{prompts → tool-prompts}/undo-last-change.md +0 -0
package/src/batch.ts
CHANGED
|
@@ -41,6 +41,8 @@ export interface PlannedMember {
|
|
|
41
41
|
order: number;
|
|
42
42
|
size: number;
|
|
43
43
|
last: boolean;
|
|
44
|
+
plan?: BatchMemberPlan;
|
|
45
|
+
demoted?: Error;
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
export type BatchKind = "replace" | "insert" | "replace_match" | "copy" | "move";
|
|
@@ -64,6 +66,36 @@ export interface BatchSourceMove {
|
|
|
64
66
|
pipe: PipelineResult;
|
|
65
67
|
}
|
|
66
68
|
|
|
69
|
+
export interface BatchMemberPlan {
|
|
70
|
+
hedit: HEdit;
|
|
71
|
+
warnings: string[];
|
|
72
|
+
planned?: PlannedEdit;
|
|
73
|
+
foldedLines: number;
|
|
74
|
+
direction?: "before" | "after";
|
|
75
|
+
stripWarning?: StripWarningLocation;
|
|
76
|
+
contentSeparators?: (LineEnding | undefined)[];
|
|
77
|
+
carryIndex?: number;
|
|
78
|
+
servedOverride?: ReadonlyMap<string, string>;
|
|
79
|
+
sourceMove?: BatchSourceMove;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface BatchPlannerInput {
|
|
83
|
+
member: PlannedMember;
|
|
84
|
+
base: BatchBase;
|
|
85
|
+
displayPath: string;
|
|
86
|
+
served?: ReadonlyMap<string, string>;
|
|
87
|
+
cwd: string;
|
|
88
|
+
signal?: AbortSignal;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type BatchMemberPlanner = (input: BatchPlannerInput) => BatchMemberPlan | undefined | Promise<BatchMemberPlan | undefined>;
|
|
92
|
+
|
|
93
|
+
const batchPlanners = new Map<BatchKind, BatchMemberPlanner>();
|
|
94
|
+
|
|
95
|
+
export function registerBatchPlanner(kind: BatchKind, planner: BatchMemberPlanner): void {
|
|
96
|
+
batchPlanners.set(kind, planner);
|
|
97
|
+
}
|
|
98
|
+
|
|
67
99
|
interface SourcePreparation {
|
|
68
100
|
move: BatchSourceMove;
|
|
69
101
|
separators?: LineEnding[];
|
|
@@ -88,21 +120,12 @@ export interface BatchPiece {
|
|
|
88
120
|
}
|
|
89
121
|
|
|
90
122
|
export interface BatchMemberInput {
|
|
91
|
-
kind: BatchKind;
|
|
92
|
-
direction?: "before" | "after";
|
|
93
123
|
member: PlannedMember;
|
|
94
124
|
targetPath: string;
|
|
95
125
|
mutationTargetPath: string;
|
|
96
126
|
cwd: string;
|
|
97
127
|
signal?: AbortSignal;
|
|
98
|
-
|
|
99
|
-
extraWarnings: string[];
|
|
100
|
-
foldedLines?: number;
|
|
101
|
-
stripWarning?: StripWarningLocation;
|
|
102
|
-
contentSeparators?: (LineEnding | undefined)[];
|
|
103
|
-
carryIndex?: number;
|
|
104
|
-
servedOverride?: ReadonlyMap<string, string>;
|
|
105
|
-
sourceMove?: BatchSourceMove;
|
|
128
|
+
plan?: BatchMemberPlan;
|
|
106
129
|
}
|
|
107
130
|
|
|
108
131
|
interface BatchFailure {
|
|
@@ -115,7 +138,9 @@ interface BatchState {
|
|
|
115
138
|
display: number;
|
|
116
139
|
target: string;
|
|
117
140
|
memberIds: string[];
|
|
141
|
+
allMemberIds: string[];
|
|
118
142
|
stale: boolean;
|
|
143
|
+
preflighted?: boolean;
|
|
119
144
|
base?: BatchBase;
|
|
120
145
|
paths?: { absolutePath: string; mutationTargetPath: string; displayPath: string };
|
|
121
146
|
served?: ReadonlyMap<string, string>;
|
|
@@ -335,7 +360,7 @@ function enforceCap(): void {
|
|
|
335
360
|
const state = batches.get(oldest);
|
|
336
361
|
batches.delete(oldest);
|
|
337
362
|
if (state) {
|
|
338
|
-
for (const id of state.
|
|
363
|
+
for (const id of state.allMemberIds) {
|
|
339
364
|
plan.delete(id);
|
|
340
365
|
placeholderResults.delete(id);
|
|
341
366
|
}
|
|
@@ -397,6 +422,7 @@ export async function planAssistantMessage(message: unknown, cwd: string): Promi
|
|
|
397
422
|
display,
|
|
398
423
|
target: group[0]!.target,
|
|
399
424
|
memberIds: group.map((item) => item.id),
|
|
425
|
+
allMemberIds: group.map((item) => item.id),
|
|
400
426
|
stale: false,
|
|
401
427
|
pieces: [],
|
|
402
428
|
sources: [],
|
|
@@ -565,6 +591,49 @@ function batchAbortedError(runtime: BatchState): Error {
|
|
|
565
591
|
discardBatchState(runtime);
|
|
566
592
|
return new Error(abortedBatchMessage(runtime));
|
|
567
593
|
}
|
|
594
|
+
|
|
595
|
+
const ATTRIBUTABLE_BATCH_CODES = new Set(["E_BAD_SHAPE", "E_BAD_REF", "E_STALE_ANCHOR", "E_RANGE_STALE", "E_SUBSTRING_NOT_FOUND"]);
|
|
596
|
+
|
|
597
|
+
function isAttributableBatchError(error: unknown): boolean {
|
|
598
|
+
const code = errorCodeOf(error);
|
|
599
|
+
return code !== undefined && ATTRIBUTABLE_BATCH_CODES.has(code);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
async function preflightBatch(runtime: BatchState, base: BatchBase, cwd: string, signal?: AbortSignal): Promise<void> {
|
|
603
|
+
if (runtime.preflighted) return;
|
|
604
|
+
runtime.preflighted = true;
|
|
605
|
+
if (runtime.failed) return;
|
|
606
|
+
const displayPath = runtime.paths?.displayPath ?? runtime.target;
|
|
607
|
+
for (const id of [...runtime.memberIds]) {
|
|
608
|
+
const member = plan.get(id);
|
|
609
|
+
if (!member || member.demoted) continue;
|
|
610
|
+
const planner = batchPlanners.get(member.kind);
|
|
611
|
+
if (!planner) continue;
|
|
612
|
+
try {
|
|
613
|
+
const memberPlan = await planner({ member, base, displayPath, served: runtime.served, cwd, signal });
|
|
614
|
+
if (memberPlan === undefined) continue;
|
|
615
|
+
await throwIfStrictInput([...memberPlan.warnings, ...(memberPlan.planned?.warnings ?? [])]);
|
|
616
|
+
member.plan = memberPlan;
|
|
617
|
+
} catch (error) {
|
|
618
|
+
if (!isAttributableBatchError(error)) {
|
|
619
|
+
noteBatchFailure(member, error);
|
|
620
|
+
throw error;
|
|
621
|
+
}
|
|
622
|
+
if (error instanceof RangeStaleError) adoptAnchors(base.absolutePath, error.rangeServedMap);
|
|
623
|
+
else if (error instanceof AnchorMismatchError) adoptAnchors(base.absolutePath, error.feedbackMap);
|
|
624
|
+
member.demoted = error instanceof Error ? error : new Error(String(error));
|
|
625
|
+
runtime.memberIds = runtime.memberIds.filter((candidate) => candidate !== id);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
const survivors = runtime.memberIds
|
|
629
|
+
.map((id) => plan.get(id))
|
|
630
|
+
.filter((member): member is PlannedMember => member !== undefined);
|
|
631
|
+
survivors.forEach((member, index) => {
|
|
632
|
+
member.size = survivors.length;
|
|
633
|
+
member.last = index === survivors.length - 1;
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
|
|
568
637
|
function discardBatchState(runtime: BatchState): void {
|
|
569
638
|
markBatchMembersAborted(runtime);
|
|
570
639
|
}
|
|
@@ -578,7 +647,10 @@ export async function ensureBatchBase(input: {
|
|
|
578
647
|
}): Promise<BatchBase> {
|
|
579
648
|
const runtime = batches.get(input.member.batchKey);
|
|
580
649
|
if (!runtime) throw new Error(`[E_STALE_ANCHOR] Batch ${input.member.display} is no longer tracked. Call read for fresh anchors.`);
|
|
581
|
-
if (runtime.base)
|
|
650
|
+
if (runtime.base) {
|
|
651
|
+
await preflightBatch(runtime, runtime.base, input.cwd, input.signal);
|
|
652
|
+
return runtime.base;
|
|
653
|
+
}
|
|
582
654
|
abortIf(input.signal);
|
|
583
655
|
const file = await readNormFile(input.targetPath, input.cwd, {
|
|
584
656
|
signal: input.signal,
|
|
@@ -606,6 +678,7 @@ export async function ensureBatchBase(input: {
|
|
|
606
678
|
mutationTargetPath: input.mutationTargetPath,
|
|
607
679
|
displayPath: toDisplayPath(input.cwd, file.absolutePath, input.targetPath),
|
|
608
680
|
};
|
|
681
|
+
await preflightBatch(runtime, base, input.cwd, input.signal);
|
|
609
682
|
return base;
|
|
610
683
|
}
|
|
611
684
|
|
|
@@ -627,29 +700,42 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
|
|
|
627
700
|
discardBatchState(runtime);
|
|
628
701
|
throw error;
|
|
629
702
|
}
|
|
703
|
+
if (runtime.failed) throw batchAbortedError(runtime);
|
|
704
|
+
if (input.member.demoted) throw input.member.demoted;
|
|
705
|
+
const memberPlan = input.plan ?? input.member.plan;
|
|
706
|
+
if (memberPlan === undefined) {
|
|
707
|
+
const error = new Error(`[E_OP_ABORTED] Batch ${runtime.display} aborted: no plan for [${input.member.kind}] Call Nr ${input.member.order}. ${BATCH_DISCARDED_NOTE}`);
|
|
708
|
+
noteBatchFailure(input.member, error);
|
|
709
|
+
throw batchAbortedError(runtime);
|
|
710
|
+
}
|
|
630
711
|
if (input.mutationTargetPath !== input.member.target) {
|
|
631
|
-
const error = new Error(`[E_STALE_ANCHOR] "${
|
|
712
|
+
const error = new Error(`[E_STALE_ANCHOR] "${memberPlan.hedit.hash_bounds[0].hash}" is no longer owned by ${input.member.target}. Call read for fresh anchors.`);
|
|
632
713
|
noteBatchFailure(input.member, error);
|
|
633
714
|
discardBatchState(runtime);
|
|
634
715
|
throw error;
|
|
635
716
|
}
|
|
717
|
+
const kind = input.member.kind;
|
|
636
718
|
const displayPath = runtime.paths?.displayPath ?? input.targetPath;
|
|
637
|
-
const effectiveHedit = preserveDeletionSeparators(
|
|
719
|
+
const effectiveHedit = preserveDeletionSeparators(memberPlan.hedit, base.baseLines, base.hashes);
|
|
638
720
|
let planned: PlannedEdit;
|
|
639
|
-
|
|
640
|
-
planned =
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
721
|
+
if (memberPlan.planned !== undefined) {
|
|
722
|
+
planned = memberPlan.planned;
|
|
723
|
+
} else {
|
|
724
|
+
try {
|
|
725
|
+
planned = planEdit(base.content, effectiveHedit, base.hashes, {
|
|
726
|
+
filePath: displayPath,
|
|
727
|
+
servedHashes: memberPlan.servedOverride ?? runtime.served,
|
|
728
|
+
signal: input.signal,
|
|
729
|
+
baseFileLines: base.baseLines,
|
|
730
|
+
stripWarning: memberPlan.stripWarning,
|
|
731
|
+
});
|
|
732
|
+
} catch (error) {
|
|
733
|
+
if (error instanceof RangeStaleError) adoptAnchors(base.absolutePath, error.rangeServedMap);
|
|
734
|
+
else if (error instanceof AnchorMismatchError) adoptAnchors(base.absolutePath, error.feedbackMap);
|
|
735
|
+
noteBatchFailure(input.member, error);
|
|
736
|
+
discardBatchState(runtime);
|
|
737
|
+
throw error;
|
|
738
|
+
}
|
|
653
739
|
}
|
|
654
740
|
const start = planned.resolved.hash_bounds[0].line;
|
|
655
741
|
const end = planned.resolved.hash_bounds[1].line;
|
|
@@ -657,20 +743,20 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
|
|
|
657
743
|
const baseLines = base.baseLines;
|
|
658
744
|
const originalSlice = baseLines.slice(start - 1, end);
|
|
659
745
|
const noop = originalSlice.length === newLines.length && originalSlice.every((line, index) => line === newLines[index]);
|
|
660
|
-
const foldedLines =
|
|
661
|
-
const separators =
|
|
746
|
+
const foldedLines = memberPlan.foldedLines;
|
|
747
|
+
const separators = memberPlan.contentSeparators ?? memberPlan.hedit.content_separators;
|
|
662
748
|
const carryIndex =
|
|
663
|
-
|
|
664
|
-
?
|
|
665
|
-
:
|
|
666
|
-
?
|
|
749
|
+
memberPlan.carryIndex !== undefined
|
|
750
|
+
? memberPlan.carryIndex
|
|
751
|
+
: kind === "insert" && foldedLines > 0
|
|
752
|
+
? memberPlan.direction === "after"
|
|
667
753
|
? 0
|
|
668
754
|
: newLines.length - 1
|
|
669
755
|
: undefined;
|
|
670
756
|
const piece: BatchPiece = {
|
|
671
757
|
order: input.member.order,
|
|
672
|
-
kind
|
|
673
|
-
...(
|
|
758
|
+
kind,
|
|
759
|
+
...(memberPlan.direction !== undefined ? { direction: memberPlan.direction } : {}),
|
|
674
760
|
...(carryIndex !== undefined ? { carryIndex } : {}),
|
|
675
761
|
start,
|
|
676
762
|
end,
|
|
@@ -678,7 +764,7 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
|
|
|
678
764
|
toHash: planned.resolved.hash_bounds[1].hash,
|
|
679
765
|
newLines: [...newLines],
|
|
680
766
|
...(separators !== undefined ? { separators } : {}),
|
|
681
|
-
warnings: [...
|
|
767
|
+
warnings: [...memberPlan.warnings, ...planned.warnings],
|
|
682
768
|
noop,
|
|
683
769
|
foldedLines,
|
|
684
770
|
};
|
|
@@ -686,7 +772,7 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
|
|
|
686
772
|
if (noop) runtime.noops += 1;
|
|
687
773
|
else runtime.applied += 1;
|
|
688
774
|
runtime.warnings.push(...piece.warnings);
|
|
689
|
-
if (
|
|
775
|
+
if (memberPlan.sourceMove !== undefined) runtime.sources.push(memberPlan.sourceMove);
|
|
690
776
|
if (!input.member.last) {
|
|
691
777
|
const placeholder = batchPlaceholder(input.member, piece, base.snapshotId);
|
|
692
778
|
placeholderResults.set(input.member.id, placeholder);
|
|
@@ -817,7 +903,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
817
903
|
const spans = composed.length === 0
|
|
818
904
|
? [{ start: 0, end: base.hashes.length - 1, replacementCount: 1 }]
|
|
819
905
|
: pieceMappingSpans(effectivePieces);
|
|
820
|
-
const resultSeparators = separatorsForSpans(base.separators, base.hashes.length, spans, composed, base.ending);
|
|
906
|
+
const resultSeparators = separatorsForSpans(base.separators, base.hashes.length, spans, composed, base.ending, base.baseLines);
|
|
821
907
|
applySpanEndings(resultSeparators, effectivePieces.map((piece) => ({
|
|
822
908
|
start: piece.start - 1,
|
|
823
909
|
end: piece.end - 1,
|
|
@@ -883,7 +969,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
883
969
|
}
|
|
884
970
|
const sourceSpan = source.pipe.spans?.[0];
|
|
885
971
|
const sourceSeparators = sourceSpan
|
|
886
|
-
? separatorsForSpans(source.pipe.originalSeparators, source.pipe.originalHashes.length, [sourceSpan], source.pipe.result, source.pipe.originalEnding)
|
|
972
|
+
? separatorsForSpans(source.pipe.originalSeparators, source.pipe.originalHashes.length, [sourceSpan], source.pipe.result, source.pipe.originalEnding, splitLines(source.pipe.originalNormalized))
|
|
887
973
|
: undefined;
|
|
888
974
|
const sourceBytes = source.pipe.bom + (sourceSeparators !== undefined
|
|
889
975
|
? joinSeparators(source.pipe.result, sourceSeparators)
|
|
@@ -1102,7 +1188,7 @@ export async function finalizeTurn(toolCallIds: string[]): Promise<void> {
|
|
|
1102
1188
|
for (const key of keys) {
|
|
1103
1189
|
const runtime = batches.get(key);
|
|
1104
1190
|
if (!runtime) continue;
|
|
1105
|
-
for (const id of runtime.
|
|
1191
|
+
for (const id of runtime.allMemberIds) {
|
|
1106
1192
|
plan.delete(id);
|
|
1107
1193
|
placeholderResults.delete(id);
|
|
1108
1194
|
}
|
package/src/commit.ts
CHANGED
|
@@ -37,7 +37,7 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
37
37
|
const span = pipe.spans?.[0] ?? (meta.editAnchors ? spanForEdit(pipe.originalHashes, meta.editAnchors[0], meta.editAnchors[1], pipe.result) : undefined);
|
|
38
38
|
if (span && meta.anchorCarry !== undefined) span.carry = meta.anchorCarry;
|
|
39
39
|
const resultSeparators = span
|
|
40
|
-
? separatorsForSpans(pipe.originalSeparators, pipe.originalHashes.length, [span], pipe.result, pipe.originalEnding)
|
|
40
|
+
? separatorsForSpans(pipe.originalSeparators, pipe.originalHashes.length, [span], pipe.result, pipe.originalEnding, splitLines(pipe.originalNormalized))
|
|
41
41
|
: undefined;
|
|
42
42
|
if (resultSeparators !== undefined && span !== undefined) {
|
|
43
43
|
applyEndingOverrides(resultSeparators, span.start, pipe.contentSeparators);
|
package/src/config-ui.ts
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
|
-
import { Key, matchesKey, visibleWidth } from "@earendil-works/pi-tui";
|
|
1
|
+
import { Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
2
2
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { readConfig, type Config } from "./config";
|
|
4
4
|
|
|
5
|
-
export type ConfigToggleKey = "autoRead" | "autoReadAll" | "autoReadAllIgnore" | "anchorGrepEnabled" | "copyMoveEnabled" | "replaceMatchEnabled" | "requirePath" | "strictInput" | "diffContextLines" | "disableOnModels" | "readOnDisabledModels";
|
|
5
|
+
export type ConfigToggleKey = "autoRead" | "autoReadAll" | "autoReadAllRequireGit" | "autoReadAllIgnore" | "anchorGrepEnabled" | "copyMoveEnabled" | "replaceMatchEnabled" | "requirePath" | "strictInput" | "diffContextLines" | "disableOnModels" | "readOnDisabledModels";
|
|
6
6
|
|
|
7
7
|
export interface ConfigRow {
|
|
8
8
|
key: ConfigToggleKey;
|
|
9
9
|
label: string;
|
|
10
10
|
hint: string;
|
|
11
|
+
group: string;
|
|
12
|
+
depth: number;
|
|
11
13
|
enabled: boolean;
|
|
14
|
+
dependsOn?: ConfigToggleKey;
|
|
15
|
+
gated?: boolean;
|
|
12
16
|
mode?: string;
|
|
13
17
|
cycle?: string[];
|
|
14
18
|
value?: number;
|
|
@@ -16,53 +20,79 @@ export interface ConfigRow {
|
|
|
16
20
|
disabled?: boolean;
|
|
17
21
|
}
|
|
18
22
|
|
|
23
|
+
function rowActive(row: ConfigRow): boolean {
|
|
24
|
+
if (row.entries !== undefined) return row.entries.length > 0;
|
|
25
|
+
if (row.mode !== undefined) return row.mode !== "off";
|
|
26
|
+
return row.enabled;
|
|
27
|
+
}
|
|
28
|
+
|
|
19
29
|
export function configRows(config: Config): ConfigRow[] {
|
|
20
|
-
|
|
21
|
-
{ key: "autoRead", label: "Auto-read", hint: "
|
|
22
|
-
{ key: "
|
|
23
|
-
{ key: "
|
|
24
|
-
{ key: "
|
|
25
|
-
{ key: "
|
|
26
|
-
{ key: "
|
|
27
|
-
{ key: "
|
|
28
|
-
{ key: "
|
|
29
|
-
{ key: "
|
|
30
|
-
{ key: "
|
|
31
|
-
{ key: "
|
|
30
|
+
const rows: ConfigRow[] = [
|
|
31
|
+
{ key: "autoRead", group: "Auto-read", depth: 0, label: "Auto-read", hint: "Show fresh anchors after write and post-edit diffs", enabled: config.autoRead !== false },
|
|
32
|
+
{ key: "diffContextLines", group: "Auto-read", depth: 1, dependsOn: "autoRead", gated: true, label: "Diff context", hint: "Context lines around each change in post-edit diffs", enabled: config.autoRead !== false, value: config.diffContextLines ?? 1 },
|
|
33
|
+
{ key: "autoReadAll", group: "Auto-read all", depth: 0, label: "Auto-read all", hint: "Attach project files when a session starts", enabled: (config.autoReadAll ?? "off") !== "off", mode: config.autoReadAll ?? "off", cycle: ["off", "outline", "full"] },
|
|
34
|
+
{ key: "autoReadAllRequireGit", group: "Auto-read all", depth: 1, dependsOn: "autoReadAll", gated: true, label: "Git repos only", hint: "Attach only inside a git repository", enabled: config.autoReadAllRequireGit !== false },
|
|
35
|
+
{ key: "autoReadAllIgnore", group: "Auto-read all", depth: 1, dependsOn: "autoReadAll", label: "Ignore folders/files", hint: "Folders, files, or globs that auto-read all skips (comma-separated)", enabled: (config.autoReadAllIgnore ?? []).length > 0, entries: config.autoReadAllIgnore ?? [] },
|
|
36
|
+
{ key: "anchorGrepEnabled", group: "Tools", depth: 0, label: "Anchor grep", hint: "Use anchor_grep instead of the built-in grep", enabled: config.anchorGrepEnabled === true },
|
|
37
|
+
{ key: "copyMoveEnabled", group: "Tools", depth: 0, label: "Copy/move", hint: "Enable the copy and move tools", enabled: config.copyMoveEnabled !== false },
|
|
38
|
+
{ key: "replaceMatchEnabled", group: "Tools", depth: 0, label: "Replace match", hint: "Enable the replace_match tool", enabled: config.replaceMatchEnabled !== false },
|
|
39
|
+
{ key: "requirePath", group: "Edit behavior", depth: 0, label: "Require path", hint: "Edit tools must also send a matching path", enabled: config.requirePath === true },
|
|
40
|
+
{ key: "strictInput", group: "Edit behavior", depth: 0, label: "Strict input", hint: "Reject auto-fixable slips instead of warnings", enabled: config.strictInput === true },
|
|
41
|
+
{ key: "disableOnModels", group: "Model gating", depth: 0, label: "Disable on models", hint: "Model globs that turn off anchored tools (comma-separated)", enabled: (config.disableOnModels ?? []).length > 0, entries: config.disableOnModels ?? [] },
|
|
42
|
+
{ key: "readOnDisabledModels", group: "Model gating", depth: 1, dependsOn: "disableOnModels", gated: true, label: "Read on disabled models", hint: "Keep read available for models disabled above", enabled: (config.readOnDisabledModels ?? "vanilla") !== "remove", mode: config.readOnDisabledModels ?? "vanilla", cycle: ["remove", "vanilla"] },
|
|
32
43
|
];
|
|
44
|
+
for (const row of rows) {
|
|
45
|
+
if (row.gated !== true || row.dependsOn === undefined) continue;
|
|
46
|
+
const parent = rows.find((candidate) => candidate.key === row.dependsOn);
|
|
47
|
+
row.disabled = parent !== undefined && !rowActive(parent);
|
|
48
|
+
}
|
|
49
|
+
return rows;
|
|
33
50
|
}
|
|
34
51
|
|
|
35
52
|
function padRow(theme: Theme, innerWidth: number, content: string): string {
|
|
36
|
-
const
|
|
53
|
+
const clipped = visibleWidth(content) > innerWidth ? truncateToWidth(content, innerWidth) : content;
|
|
54
|
+
const padded = clipped + " ".repeat(Math.max(0, innerWidth - visibleWidth(clipped)));
|
|
37
55
|
return theme.fg("border", "│") + padded + theme.fg("border", "│");
|
|
38
56
|
}
|
|
39
57
|
|
|
40
|
-
function modeBox(theme: Theme, mode: string): string {
|
|
41
|
-
if (mode === "off") return theme.fg("dim", `[${mode}]`);
|
|
58
|
+
function modeBox(theme: Theme, mode: string, disabled?: boolean): string {
|
|
59
|
+
if (disabled === true || mode === "off") return theme.fg("dim", `[${mode}]`);
|
|
42
60
|
if (mode === "strict") return theme.fg("accent", `[${mode}]`);
|
|
43
61
|
return theme.fg("success", `[${mode}]`);
|
|
44
62
|
}
|
|
45
63
|
|
|
46
64
|
function numberBox(theme: Theme, value: number, disabled?: boolean): string {
|
|
47
|
-
if (disabled) return theme.fg("dim", `[${value}]`);
|
|
65
|
+
if (disabled === true) return theme.fg("dim", `[${value}]`);
|
|
48
66
|
return theme.fg("accent", `[${value}]`);
|
|
49
67
|
}
|
|
50
|
-
|
|
68
|
+
|
|
69
|
+
function listBox(theme: Theme, entries: string[], disabled?: boolean): string {
|
|
70
|
+
if (disabled === true) return theme.fg("dim", `[${entries.length}]`);
|
|
51
71
|
return theme.fg("accent", `[${entries.length}]`);
|
|
52
72
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
73
|
+
|
|
74
|
+
function rowBox(theme: Theme, row: ConfigRow): string {
|
|
75
|
+
if (row.entries !== undefined) return listBox(theme, row.entries, row.disabled);
|
|
76
|
+
if (row.value !== undefined) return numberBox(theme, row.value, row.disabled);
|
|
77
|
+
if (row.mode !== undefined) return modeBox(theme, row.mode, row.disabled);
|
|
78
|
+
if (row.disabled === true) return theme.fg("dim", row.enabled ? "[x]" : "[ ]");
|
|
79
|
+
return row.enabled ? theme.fg("success", "[x]") : theme.fg("dim", "[ ]");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function rowDetail(row: ConfigRow): string {
|
|
83
|
+
if (row.entries === undefined) return row.hint;
|
|
84
|
+
const joined = row.entries.length > 0 ? row.entries.join(", ") : "(empty)";
|
|
85
|
+
return `${row.hint}: ${joined}`;
|
|
57
86
|
}
|
|
58
87
|
|
|
59
88
|
export class HashlineConfigOverlay {
|
|
60
89
|
private rows: ConfigRow[];
|
|
61
90
|
private selected = 0;
|
|
91
|
+
private scrollTop = 0;
|
|
62
92
|
private editingList = false;
|
|
63
93
|
private editBuffer = "";
|
|
64
94
|
|
|
65
|
-
constructor(private readonly opts: { tui: { requestRender(force?: boolean): void }; theme: Theme; done: () => void; onToggle: (key: ConfigToggleKey, delta?: number, value?: string) => Promise<void> }) {
|
|
95
|
+
constructor(private readonly opts: { tui: { requestRender(force?: boolean): void }; theme: Theme; done: () => void; maxHeight?: () => number | undefined; onToggle: (key: ConfigToggleKey, delta?: number, value?: string) => Promise<void> }) {
|
|
66
96
|
this.rows = [];
|
|
67
97
|
}
|
|
68
98
|
|
|
@@ -100,6 +130,16 @@ export class HashlineConfigOverlay {
|
|
|
100
130
|
this.opts.tui.requestRender(true);
|
|
101
131
|
}
|
|
102
132
|
|
|
133
|
+
private applyCycle(row: ConfigRow, step: number): void {
|
|
134
|
+
if (row.cycle === undefined || row.mode === undefined) return;
|
|
135
|
+
const index = row.cycle.indexOf(row.mode);
|
|
136
|
+
const next = row.cycle[(index + step + row.cycle.length) % row.cycle.length];
|
|
137
|
+
if (next === undefined) return;
|
|
138
|
+
row.mode = next;
|
|
139
|
+
row.enabled = next !== "off";
|
|
140
|
+
this.runToggle(row, step);
|
|
141
|
+
}
|
|
142
|
+
|
|
103
143
|
private toggleSelected(): void {
|
|
104
144
|
const row = this.rows[this.selected];
|
|
105
145
|
if (!row || row.disabled) return;
|
|
@@ -112,22 +152,31 @@ export class HashlineConfigOverlay {
|
|
|
112
152
|
this.runToggle(row, 1);
|
|
113
153
|
return;
|
|
114
154
|
}
|
|
115
|
-
if (row.cycle && row.mode !== undefined) {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
row.mode = next;
|
|
119
|
-
row.enabled = next !== "off";
|
|
120
|
-
} else {
|
|
121
|
-
row.enabled = !row.enabled;
|
|
155
|
+
if (row.cycle !== undefined && row.mode !== undefined) {
|
|
156
|
+
this.applyCycle(row, 1);
|
|
157
|
+
return;
|
|
122
158
|
}
|
|
159
|
+
row.enabled = !row.enabled;
|
|
123
160
|
this.runToggle(row);
|
|
124
161
|
}
|
|
125
162
|
|
|
126
163
|
private adjustSelected(delta: number): void {
|
|
127
164
|
const row = this.rows[this.selected];
|
|
128
|
-
if (!row || row.disabled
|
|
129
|
-
row.value
|
|
130
|
-
|
|
165
|
+
if (!row || row.disabled) return;
|
|
166
|
+
if (row.value !== undefined) {
|
|
167
|
+
row.value += delta;
|
|
168
|
+
this.runToggle(row, delta);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (row.cycle !== undefined && row.mode !== undefined) {
|
|
172
|
+
this.applyCycle(row, delta > 0 ? 1 : -1);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (row.entries !== undefined) return;
|
|
176
|
+
const wanted = delta > 0;
|
|
177
|
+
if (row.enabled === wanted) return;
|
|
178
|
+
row.enabled = wanted;
|
|
179
|
+
this.runToggle(row);
|
|
131
180
|
}
|
|
132
181
|
|
|
133
182
|
handleInput(data: string): void {
|
|
@@ -177,11 +226,6 @@ export class HashlineConfigOverlay {
|
|
|
177
226
|
this.toggleSelected();
|
|
178
227
|
return;
|
|
179
228
|
}
|
|
180
|
-
const row = this.rows[this.selected];
|
|
181
|
-
if ((data === "e" || data === "E") && row && row.entries !== undefined && !row.disabled) {
|
|
182
|
-
this.startListEdit(row);
|
|
183
|
-
return;
|
|
184
|
-
}
|
|
185
229
|
if (matchesKey(data, Key.escape) || data === "q") {
|
|
186
230
|
this.opts.done();
|
|
187
231
|
}
|
|
@@ -193,24 +237,60 @@ export class HashlineConfigOverlay {
|
|
|
193
237
|
render(width: number): string[] {
|
|
194
238
|
const theme = this.opts.theme;
|
|
195
239
|
const innerWidth = width - 2;
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
lines.push(theme.fg("border", `├${"─".repeat(innerWidth)}┤`));
|
|
240
|
+
const body: string[] = [];
|
|
241
|
+
const spans: Array<{ start: number; end: number }> = [];
|
|
242
|
+
let group = "";
|
|
200
243
|
this.rows.forEach((row, index) => {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const suffix = row.entries !== undefined ? ` — ${row.hint}: ${formatList(row.entries)}` : ` — ${row.hint}`;
|
|
205
|
-
lines.push(padRow(theme, innerWidth, `${cursor}${box} ${label} ${theme.fg("dim", suffix)}`));
|
|
206
|
-
if (row.entries !== undefined && index === this.selected && this.editingList) {
|
|
207
|
-
lines.push(padRow(theme, innerWidth, ` ${theme.fg("accent", "edit:")} ${this.editBuffer}█ ${theme.fg("dim", "(Enter save · Esc cancel · Ctrl-U clear)")}`));
|
|
244
|
+
if (row.group !== group) {
|
|
245
|
+
group = row.group;
|
|
246
|
+
body.push(padRow(theme, innerWidth, theme.fg("dim", theme.bold(` ${group}`))));
|
|
208
247
|
}
|
|
248
|
+
const start = body.length;
|
|
249
|
+
const selected = index === this.selected;
|
|
250
|
+
const indent = " ".repeat(row.depth);
|
|
251
|
+
const cursor = selected ? theme.fg("accent", "> ") : " ";
|
|
252
|
+
const box = rowBox(theme, row);
|
|
253
|
+
const label = row.disabled === true ? theme.fg("dim", row.label) : selected ? theme.fg("accent", theme.bold(row.label)) : row.label;
|
|
254
|
+
const headWidth = 2 + indent.length + visibleWidth(box) + 1;
|
|
255
|
+
const contentWidth = Math.max(1, innerWidth - headWidth);
|
|
256
|
+
wrapTextWithAnsi(label, contentWidth).forEach((segment, part) => {
|
|
257
|
+
body.push(padRow(theme, innerWidth, (part === 0 ? `${cursor}${indent}${box} ` : " ".repeat(headWidth)) + segment));
|
|
258
|
+
});
|
|
259
|
+
for (const segment of wrapTextWithAnsi(theme.fg("dim", rowDetail(row)), contentWidth)) {
|
|
260
|
+
body.push(padRow(theme, innerWidth, " ".repeat(headWidth) + segment));
|
|
261
|
+
}
|
|
262
|
+
if (row.entries !== undefined && selected && this.editingList) {
|
|
263
|
+
const edit = `${theme.fg("accent", "edit:")} ${this.editBuffer}█ ${theme.fg("dim", "(Enter save · Esc cancel · Ctrl-U clear)")}`;
|
|
264
|
+
for (const segment of wrapTextWithAnsi(edit, Math.max(1, innerWidth - 2))) {
|
|
265
|
+
body.push(padRow(theme, innerWidth, ` ${segment}`));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
spans.push({ start, end: body.length - 1 });
|
|
209
269
|
});
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
270
|
+
const head = [
|
|
271
|
+
theme.fg("border", `╭${"─".repeat(innerWidth)}╮`),
|
|
272
|
+
padRow(theme, innerWidth, ` ${theme.fg("accent", theme.bold("Hashline Config"))}`),
|
|
273
|
+
theme.fg("border", `├${"─".repeat(innerWidth)}┤`),
|
|
274
|
+
];
|
|
275
|
+
const footer = this.editingList ? " type to edit · Enter save · Esc cancel" : " ↑↓ navigate · space toggle/edit · ←/→ or -/+ adjust · q close";
|
|
276
|
+
const tail = [
|
|
277
|
+
theme.fg("border", `├${"─".repeat(innerWidth)}┤`),
|
|
278
|
+
padRow(theme, innerWidth, theme.fg("dim", footer)),
|
|
279
|
+
theme.fg("border", `╰${"─".repeat(innerWidth)}╯`),
|
|
280
|
+
];
|
|
281
|
+
const limit = this.opts.maxHeight?.();
|
|
282
|
+
if (limit === undefined || body.length + head.length + tail.length <= limit) {
|
|
283
|
+
this.scrollTop = 0;
|
|
284
|
+
return [...head, ...body, ...tail];
|
|
285
|
+
}
|
|
286
|
+
const viewport = Math.max(1, limit - head.length - tail.length);
|
|
287
|
+
const span = spans[this.selected] ?? { start: 0, end: 0 };
|
|
288
|
+
let top = this.scrollTop;
|
|
289
|
+
if (span.end - span.start + 1 > viewport) top = span.start;
|
|
290
|
+
else if (span.start < top) top = span.start;
|
|
291
|
+
else if (span.end >= top + viewport) top = span.end - viewport + 1;
|
|
292
|
+
top = Math.max(0, Math.min(top, Math.max(0, body.length - viewport)));
|
|
293
|
+
this.scrollTop = top;
|
|
294
|
+
return [...head, ...body.slice(top, top + viewport), ...tail];
|
|
215
295
|
}
|
|
216
296
|
}
|