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.
Files changed (73) hide show
  1. package/README.md +41 -23
  2. package/index.ts +20 -12
  3. package/package.json +3 -2
  4. package/src/anchor-registry.ts +22 -5
  5. package/src/auto-read-all.ts +39 -21
  6. package/src/batch.ts +127 -41
  7. package/src/commit.ts +1 -1
  8. package/src/config-ui.ts +135 -55
  9. package/src/config.ts +27 -9
  10. package/src/copy-move.ts +61 -72
  11. package/src/edit-common.ts +6 -4
  12. package/src/grep.ts +53 -121
  13. package/src/hash-store.ts +13 -12
  14. package/src/hashline/resolve.ts +13 -2
  15. package/src/insert.ts +40 -34
  16. package/src/line-endings.ts +10 -1
  17. package/src/outline/index.ts +46 -0
  18. package/src/outline/parsers.ts +42 -0
  19. package/src/outline/render.ts +170 -0
  20. package/src/outline/vendor/LICENSE +201 -0
  21. package/src/outline/vendor/README.md +17 -0
  22. package/src/outline/vendor/parsers/blade.ts +138 -0
  23. package/src/outline/vendor/parsers/index.ts +75 -0
  24. package/src/outline/vendor/parsers/java.ts +501 -0
  25. package/src/outline/vendor/parsers/manager.ts +1721 -0
  26. package/src/outline/vendor/parsers/php.ts +332 -0
  27. package/src/outline/vendor/parsers/sass.ts +416 -0
  28. package/src/outline/vendor/parsers/scss.ts +566 -0
  29. package/src/outline/vendor/types.ts +82 -0
  30. package/src/outline/vendor/wasm/LICENSES.md +37 -0
  31. package/src/outline/vendor/wasm/tree-sitter-blade.wasm +0 -0
  32. package/src/outline/vendor/wasm/tree-sitter-go.wasm +0 -0
  33. package/src/outline/vendor/wasm/tree-sitter-java.wasm +0 -0
  34. package/src/outline/vendor/wasm/tree-sitter-javascript.wasm +0 -0
  35. package/src/outline/vendor/wasm/tree-sitter-php.wasm +0 -0
  36. package/src/outline/vendor/wasm/tree-sitter-python.wasm +0 -0
  37. package/src/outline/vendor/wasm/tree-sitter-rust.wasm +0 -0
  38. package/src/outline/vendor/wasm/tree-sitter-sass.wasm +0 -0
  39. package/src/outline/vendor/wasm/tree-sitter-scss.wasm +0 -0
  40. package/src/outline/vendor/wasm/tree-sitter-solidity.wasm +0 -0
  41. package/src/outline/vendor/wasm/tree-sitter-tsx.wasm +0 -0
  42. package/src/outline/vendor/wasm/tree-sitter-typescript.wasm +0 -0
  43. package/src/read.ts +118 -41
  44. package/src/replace-match.ts +42 -41
  45. package/src/replace-undo.ts +4 -4
  46. package/src/replace.ts +30 -19
  47. package/tool-prompts/read-guidelines.md +3 -0
  48. package/{prompts → tool-prompts}/read.md +2 -0
  49. package/prompts/read-guidelines.md +0 -1
  50. package/src/auto-read-all-state.ts +0 -27
  51. /package/{prompts → tool-prompts}/copy-guidelines.md +0 -0
  52. /package/{prompts → tool-prompts}/copy-snippet.md +0 -0
  53. /package/{prompts → tool-prompts}/copy.md +0 -0
  54. /package/{prompts → tool-prompts}/grep-guidelines.md +0 -0
  55. /package/{prompts → tool-prompts}/grep-snippet.md +0 -0
  56. /package/{prompts → tool-prompts}/grep.md +0 -0
  57. /package/{prompts → tool-prompts}/insert-guidelines.md +0 -0
  58. /package/{prompts → tool-prompts}/insert-snippet.md +0 -0
  59. /package/{prompts → tool-prompts}/insert.md +0 -0
  60. /package/{prompts → tool-prompts}/move-guidelines.md +0 -0
  61. /package/{prompts → tool-prompts}/move-snippet.md +0 -0
  62. /package/{prompts → tool-prompts}/move.md +0 -0
  63. /package/{prompts → tool-prompts}/read-snippet.md +0 -0
  64. /package/{prompts → tool-prompts}/replace-guidelines.md +0 -0
  65. /package/{prompts → tool-prompts}/replace-match-guidelines.md +0 -0
  66. /package/{prompts → tool-prompts}/replace-match-snippet.md +0 -0
  67. /package/{prompts → tool-prompts}/replace-match.md +0 -0
  68. /package/{prompts → tool-prompts}/replace-snippet.md +0 -0
  69. /package/{prompts → tool-prompts}/replace.md +0 -0
  70. /package/{prompts → tool-prompts}/transfer-guidelines.md +0 -0
  71. /package/{prompts → tool-prompts}/undo-last-change-guidelines.md +0 -0
  72. /package/{prompts → tool-prompts}/undo-last-change-snippet.md +0 -0
  73. /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
- hedit: HEdit;
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.memberIds) {
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) return 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] "${input.hedit.hash_bounds[0].hash}" is no longer owned by ${input.member.target}. Call read for fresh anchors.`);
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(input.hedit, base.baseLines, base.hashes);
719
+ const effectiveHedit = preserveDeletionSeparators(memberPlan.hedit, base.baseLines, base.hashes);
638
720
  let planned: PlannedEdit;
639
- try {
640
- planned = planEdit(base.content, effectiveHedit, base.hashes, {
641
- filePath: displayPath,
642
- servedHashes: input.servedOverride ?? runtime.served,
643
- signal: input.signal,
644
- baseFileLines: base.baseLines,
645
- stripWarning: input.stripWarning,
646
- });
647
- } catch (error) {
648
- if (error instanceof RangeStaleError) adoptAnchors(base.absolutePath, error.rangeServedMap);
649
- else if (error instanceof AnchorMismatchError) adoptAnchors(base.absolutePath, error.feedbackMap);
650
- noteBatchFailure(input.member, error);
651
- discardBatchState(runtime);
652
- throw error;
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 = input.foldedLines ?? 0;
661
- const separators = input.contentSeparators ?? input.hedit.content_separators;
746
+ const foldedLines = memberPlan.foldedLines;
747
+ const separators = memberPlan.contentSeparators ?? memberPlan.hedit.content_separators;
662
748
  const carryIndex =
663
- input.carryIndex !== undefined
664
- ? input.carryIndex
665
- : input.kind === "insert" && foldedLines > 0
666
- ? input.direction === "after"
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: input.kind,
673
- ...(input.direction !== undefined ? { direction: input.direction } : {}),
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: [...input.extraWarnings, ...planned.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 (input.sourceMove !== undefined) runtime.sources.push(input.sourceMove);
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.memberIds) {
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
- return [
21
- { key: "autoRead", label: "Auto-read", hint: "Anchors after write + post-edit diffs", enabled: config.autoRead !== false },
22
- { key: "autoReadAll", label: "Auto-read all", hint: "Attach files on the first turn: off, on, git (git repos only)", enabled: (config.autoReadAll ?? "off") !== "off", mode: config.autoReadAll ?? "off", cycle: ["off", "on", "git"] },
23
- { key: "autoReadAllIgnore", label: "Ignore folders/files", hint: "Extra folders, files, or globs skipped by auto-read all (comma-separated)", enabled: (config.autoReadAllIgnore ?? []).length > 0, entries: config.autoReadAllIgnore ?? [] },
24
- { key: "diffContextLines", label: "Diff context", hint: "Surrounding lines in post-edit diffs (needs Auto-read)", enabled: config.autoRead !== false, value: config.diffContextLines ?? 1, disabled: config.autoRead === false },
25
- { key: "anchorGrepEnabled", label: "Anchor grep", hint: "anchor_grep tool (builtin grep off while on)", enabled: config.anchorGrepEnabled === true },
26
- { key: "copyMoveEnabled", label: "Copy/move", hint: "copy and move tools (both off while disabled)", enabled: config.copyMoveEnabled !== false },
27
- { key: "requirePath", label: "Require path", hint: "replace, insert, copy, move need path (RPC visibility)", enabled: config.requirePath === true },
28
- { key: "strictInput", label: "Strict input", hint: "Reject auto-fixable slips instead of warnings", enabled: config.strictInput === true },
29
- { key: "replaceMatchEnabled", label: "Replace match", hint: "replace_match tool (off while disabled)", enabled: config.replaceMatchEnabled !== false },
30
- { key: "disableOnModels", label: "Disable on models", hint: "Model globs (provider/id, model id, or api) that turn off the anchored edit tools (comma-separated)", enabled: (config.disableOnModels ?? []).length > 0, entries: config.disableOnModels ?? [] },
31
- { key: "readOnDisabledModels", label: "Read on disabled models", hint: "Read for models matched by Disable on models: vanilla (plain pi read), or remove it", enabled: (config.readOnDisabledModels ?? "vanilla") !== "remove", mode: config.readOnDisabledModels ?? "vanilla", cycle: ["remove", "vanilla"] },
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 padded = content + " ".repeat(Math.max(0, innerWidth - visibleWidth(content)));
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
- function listBox(theme: Theme, entries: string[]): string {
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
- function formatList(entries: string[]): string {
54
- if (entries.length === 0) return "(empty)";
55
- const joined = entries.join(", ");
56
- return joined.length > 40 ? `${joined.slice(0, 37)}...` : joined;
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
- const next = row.cycle[(row.cycle.indexOf(row.mode) + 1) % row.cycle.length] ?? row.cycle[0];
117
- if (next === undefined) return;
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 || row.value === undefined) return;
129
- row.value += delta;
130
- this.runToggle(row, delta);
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 lines: string[] = [];
197
- lines.push(theme.fg("border", `╭${"─".repeat(innerWidth)}╮`));
198
- lines.push(padRow(theme, innerWidth, ` ${theme.fg("accent", theme.bold("Hashline Config"))}`));
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
- const cursor = index === this.selected ? theme.fg("accent", "> ") : " ";
202
- const box = row.entries !== undefined ? listBox(theme, row.entries) : row.value !== undefined ? numberBox(theme, row.value, row.disabled) : row.mode !== undefined ? modeBox(theme, row.mode) : row.enabled ? theme.fg("success", "[x]") : theme.fg("dim", "[ ]");
203
- const label = row.disabled ? theme.fg("dim", row.label) : index === this.selected ? theme.fg("accent", theme.bold(row.label)) : row.label;
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
- lines.push(theme.fg("border", `├${"─".repeat(innerWidth)}┤`));
211
- const footer = this.editingList ? " type to edit · Enter save · Esc cancel" : " ↑↓ navigate · space toggle · ←/→ or -/+ adjust · e edit list · q close";
212
- lines.push(padRow(theme, innerWidth, theme.fg("dim", footer)));
213
- lines.push(theme.fg("border", `╰${"─".repeat(innerWidth)}╯`));
214
- return lines;
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
  }