@pi-unipi/compactor 2.6.1 → 2.6.2

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 (44) hide show
  1. package/README.md +5 -4
  2. package/package.json +1 -1
  3. package/skills/compactor/SKILL.md +1 -1
  4. package/skills/compactor-detail/SKILL.md +3 -5
  5. package/skills/compactor-doctor/SKILL.md +1 -1
  6. package/skills/compactor-stats/SKILL.md +1 -1
  7. package/src/commands/index.ts +47 -78
  8. package/src/compaction/brief.ts +161 -90
  9. package/src/compaction/build-sections.ts +3 -4
  10. package/src/compaction/compact-args.ts +86 -0
  11. package/src/compaction/cut.ts +270 -28
  12. package/src/compaction/drill-down.ts +261 -0
  13. package/src/compaction/format-recall.ts +96 -0
  14. package/src/compaction/format.ts +8 -3
  15. package/src/compaction/hooks.ts +248 -72
  16. package/src/compaction/merge.ts +34 -4
  17. package/src/compaction/rank.ts +270 -0
  18. package/src/compaction/recall-scope.ts +28 -0
  19. package/src/compaction/search-entries.ts +333 -96
  20. package/src/compaction/skill-collapse.ts +35 -0
  21. package/src/compaction/summarize.ts +37 -6
  22. package/src/compaction/token-estimate.ts +104 -0
  23. package/src/compaction/touched-files.ts +35 -0
  24. package/src/config/manager.ts +2 -27
  25. package/src/config/presets.ts +0 -2
  26. package/src/config/schema.ts +2 -16
  27. package/src/executor/executor.ts +6 -15
  28. package/src/executor/runtime.ts +2 -12
  29. package/src/index.ts +12 -122
  30. package/src/info-screen.ts +3 -10
  31. package/src/security/evaluator.ts +0 -53
  32. package/src/security/policy.ts +7 -8
  33. package/src/session/db.ts +0 -6
  34. package/src/tools/ctx-execute-file.ts +0 -5
  35. package/src/tools/register.ts +27 -50
  36. package/src/tools/vcc-recall.ts +86 -48
  37. package/src/tui/settings-overlay.ts +20 -40
  38. package/src/types.ts +43 -100
  39. package/src/display/diff-renderer.ts +0 -281
  40. package/src/display/line-width-safety.ts +0 -28
  41. package/src/display/render-utils.ts +0 -52
  42. package/src/display/thinking-label.ts +0 -18
  43. package/src/display/tool-overrides.ts +0 -136
  44. package/src/tools/compact.ts +0 -20
@@ -15,8 +15,7 @@
15
15
 
16
16
  import { Type } from "typebox";
17
17
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
18
- import { compactTool } from "./compact.js";
19
- import { MAX_RECALL_RESULTS, vccRecall, type RecallInput } from "./vcc-recall.js";
18
+ import { vccRecall } from "./vcc-recall.js";
20
19
  import { ctxExecute, type CtxExecuteInput } from "./ctx-execute.js";
21
20
  import { ctxExecuteFile, type CtxExecuteFileInput } from "./ctx-execute-file.js";
22
21
  import { ctxBatchExecute, type BatchItem } from "./ctx-batch-execute.js";
@@ -51,17 +50,15 @@ const CompactParams = Type.Object({
51
50
  });
52
51
 
53
52
  const RecallParams = Type.Object({
54
- query: Type.String({ description: "Search query for session history" }),
55
- mode: Type.Optional(Type.Union([Type.Literal("bm25"), Type.Literal("regex")], {
56
- description: "Search mode: bm25 (default) or regex fallback",
53
+ query: Type.Optional(Type.String({ description: "What to recall, in plain keywords (e.g. 'redis cache decision'). Multi-word queries are ranked by relevance. A regex pattern also works. #N:path drills into a file's content from an entry." })),
54
+ expand: Type.Optional(Type.Array(Type.Number(), { description: "Entry indices to return full untruncated content for" })),
55
+ page: Type.Optional(Type.Number({ description: "Page number (1-based) for paginated search results. Default: 1.", minimum: 1 })),
56
+ scope: Type.Optional(Type.Union([Type.Literal("lineage"), Type.Literal("all")], {
57
+ description: "Default 'lineage' covers the active conversation path. Use 'all' to also reach messages from other branches, such as turns that were edited or retried.",
57
58
  })),
58
- limit: Type.Optional(Type.Number({
59
- description: `Max results to return (default 10, hard cap ${MAX_RECALL_RESULTS})`,
60
- minimum: 1,
61
- maximum: MAX_RECALL_RESULTS,
59
+ mode: Type.Optional(Type.Union([Type.Literal("hybrid"), Type.Literal("touched")], {
60
+ description: "What to show. hybrid (default) = normal search; touched = aggregated files-by-path with entry indices.",
62
61
  })),
63
- offset: Type.Optional(Type.Number({ description: "Pagination offset", minimum: 0 })),
64
- expand: Type.Optional(Type.Boolean({ description: "Return full message content for hits" })),
65
62
  });
66
63
 
67
64
  const SandboxParams = Type.Object({
@@ -111,20 +108,6 @@ function jsonResult(data: unknown, label?: string): any {
111
108
  };
112
109
  }
113
110
 
114
- /** Log a deprecation warning when old tool names are used. */
115
- function deprecationLog(_oldName: string, _newName: string): void {
116
- // Deprecation logging disabled — was writing to stdout causing TUI rendering issues.
117
- }
118
-
119
- // --- Old schema names for backward compat aliases ---
120
-
121
- const VccRecallParams = RecallParams;
122
- const CtxExecuteParams = SandboxParams;
123
- const CtxExecuteFileParams = SandboxFileParams;
124
- const CtxBatchExecuteParams = SandboxBatchParams;
125
- const CtxStatsParams = StatsParams;
126
- const CtxDoctorParams = DoctorParams;
127
-
128
111
  // --- Registration ---
129
112
 
130
113
  export interface CompactorToolDeps {
@@ -163,8 +146,7 @@ export function registerCompactorTools(pi: ExtensionAPI, deps: CompactorToolDeps
163
146
  }
164
147
  const c = deps.getCounters?.();
165
148
  if (c) { c.compactions++; }
166
- const result = compactTool();
167
- return jsonResult(result, "Compaction triggered");
149
+ return jsonResult({ success: true, message: "Compaction triggered. Stats will be available after next compact event." }, "Compaction triggered");
168
150
  },
169
151
  } as any));
170
152
 
@@ -175,28 +157,28 @@ export function registerCompactorTools(pi: ExtensionAPI, deps: CompactorToolDeps
175
157
  const config = loadConfig(ctx?.cwd ?? process.cwd());
176
158
  const liveBlocks = ctx ? filterNoise(recallBlocksFromContext(ctx), config.pipeline?.customNoisePatterns) : [];
177
159
  const blocks = liveBlocks.length > 0 ? liveBlocks : deps.getBlocks();
178
- const input: RecallInput = {
160
+ const result = vccRecall(blocks, {
179
161
  query: params.query,
162
+ scope: params.scope,
180
163
  mode: params.mode,
181
- limit: params.limit,
182
- offset: params.offset,
164
+ page: params.page,
183
165
  expand: params.expand,
184
- };
185
- const result = vccRecall(blocks, input);
186
- if (result.hits.length === 0) {
187
- return textResult(`No results found for "${result.query}".`);
188
- }
189
- const lines = result.hits.map(
190
- (h, i) =>
191
- `[${i + 1}/${result.total}] score=${h.score.toFixed(2)} kind=${h.kind}\n${h.text}`,
192
- );
193
- return textResult(
194
- `Found ${result.total} results for "${result.query}":\n\n${lines.join("\n\n")}`,
195
- result as unknown as Record<string, unknown>,
196
- );
166
+ });
167
+ return textResult(result.text, { query: params.query ?? null });
197
168
  };
198
- pi.registerTool({ name: "session_recall", label: "Session Recall", description: "Search session history using BM25 or regex. Find previous goals, files, commits, and context.", parameters: RecallParams, execute: recallExec } as any);
199
- pi.registerTool({ name: "vcc_recall", label: "Session Recall", description: "Search session history using BM25 or regex. (DEPRECATED: use session_recall instead)", parameters: VccRecallParams, async execute(tcId: string, p: any) { deprecationLog("vcc_recall", "session_recall"); return recallExec(tcId, p); } } as any);
169
+ pi.registerTool({
170
+ name: "session_recall",
171
+ label: "Session Recall",
172
+ description:
173
+ "Search session history using keyword or regex search. Find previous goals, files, commits, decisions, and context — " +
174
+ "including anything dropped by compaction. Reach for this before telling the user you no longer have the context. " +
175
+ "Plain keywords work best; a regex pattern is also accepted. Results are paged (page); pass expand with entry indices " +
176
+ "to read full untruncated content. Use mode:'touched' to list files worked on in this session with their entry indices, " +
177
+ "and #N:path to drill into a file's content from an entry (#N:path:full for all lines). Only the current session is " +
178
+ "searchable — earlier sessions are not.",
179
+ parameters: RecallParams,
180
+ execute: recallExec,
181
+ } as any);
200
182
 
201
183
  // Sandbox tools are session-scoped and only registered when enabled.
202
184
  if (deps.sandbox) {
@@ -226,7 +208,6 @@ export function registerCompactorTools(pi: ExtensionAPI, deps: CompactorToolDeps
226
208
  }
227
209
  };
228
210
  pi.registerTool({ name: "sandbox", label: "Sandbox", description: "Run code in a sandboxed environment. Supports 11 languages. Only stdout enters context.", parameters: SandboxParams, execute: sandboxExec } as any);
229
- pi.registerTool({ name: "ctx_execute", label: "Sandbox", description: "Run code in sandbox. (DEPRECATED: use sandbox instead)", parameters: CtxExecuteParams, async execute(tcId: string, p: any) { deprecationLog("ctx_execute", "sandbox"); return sandboxExec(tcId, p); } } as any);
230
211
 
231
212
  // 4. sandbox_file (new) / ctx_execute_file (deprecated) — execute file
232
213
  const sandboxFileExec = async (_toolCallId: string, params: any): Promise<import("@earendil-works/pi-coding-agent").AgentToolResult<unknown>> => {
@@ -246,7 +227,6 @@ export function registerCompactorTools(pi: ExtensionAPI, deps: CompactorToolDeps
246
227
  }
247
228
  };
248
229
  pi.registerTool({ name: "sandbox_file", label: "Sandbox File", description: "Execute a file in the sandbox. File content is injected as FILE_CONTENT variable.", parameters: SandboxFileParams, execute: sandboxFileExec } as any);
249
- pi.registerTool({ name: "ctx_execute_file", label: "Sandbox File", description: "Execute file in sandbox. (DEPRECATED: use sandbox_file instead)", parameters: CtxExecuteFileParams, async execute(tcId: string, p: any) { deprecationLog("ctx_execute_file", "sandbox_file"); return sandboxFileExec(tcId, p); } } as any);
250
230
 
251
231
  // 5. sandbox_batch (new) / ctx_batch_execute (deprecated) — atomic batch (execute only)
252
232
  const sandboxBatchExec = async (_toolCallId: string, params: any): Promise<import("@earendil-works/pi-coding-agent").AgentToolResult<unknown>> => {
@@ -266,7 +246,6 @@ export function registerCompactorTools(pi: ExtensionAPI, deps: CompactorToolDeps
266
246
  }
267
247
  };
268
248
  pi.registerTool({ name: "sandbox_batch", label: "Sandbox Batch", description: "Run multiple code executions atomically as a batch.", parameters: SandboxBatchParams, execute: sandboxBatchExec } as any);
269
- pi.registerTool({ name: "ctx_batch_execute", label: "Sandbox Batch", description: "Run batch operations. (DEPRECATED: use sandbox_batch instead)", parameters: CtxBatchExecuteParams, async execute(tcId: string, p: any) { deprecationLog("ctx_batch_execute", "sandbox_batch"); return sandboxBatchExec(tcId, p); } } as any);
270
249
  }
271
250
 
272
251
  // 6. compactor_stats (new) / ctx_stats (deprecated) — context savings dashboard
@@ -287,7 +266,6 @@ export function registerCompactorTools(pi: ExtensionAPI, deps: CompactorToolDeps
287
266
  }
288
267
  };
289
268
  pi.registerTool({ name: "compactor_stats", label: "Compactor Stats", description: "Show context savings dashboard — session events, compactions, tool usage.", parameters: StatsParams, execute: statsExec } as any);
290
- pi.registerTool({ name: "ctx_stats", label: "Compactor Stats", description: "Show stats dashboard. (DEPRECATED: use compactor_stats instead)", parameters: CtxStatsParams, async execute() { deprecationLog("ctx_stats", "compactor_stats"); return statsExec(); } } as any);
291
269
 
292
270
  // 7. compactor_doctor (new) / ctx_doctor (deprecated) — diagnostics checklist
293
271
  const doctorExec = async (): Promise<import("@earendil-works/pi-coding-agent").AgentToolResult<unknown>> => {
@@ -305,7 +283,6 @@ export function registerCompactorTools(pi: ExtensionAPI, deps: CompactorToolDeps
305
283
  }
306
284
  };
307
285
  pi.registerTool({ name: "compactor_doctor", label: "Compactor Doctor", description: "Run diagnostics checklist — validate config, DB, runtimes.", parameters: DoctorParams, execute: doctorExec } as any);
308
- pi.registerTool({ name: "ctx_doctor", label: "Compactor Doctor", description: "Run diagnostics. (DEPRECATED: use compactor_doctor instead)", parameters: CtxDoctorParams, async execute() { deprecationLog("ctx_doctor", "compactor_doctor"); return doctorExec(); } } as any);
309
286
 
310
287
  // 8. context_budget — estimate remaining context window
311
288
  pi.registerTool(({
@@ -1,19 +1,36 @@
1
1
  /**
2
- * vcc_recall tool BM25-lite session history search
2
+ * session_recall / vcc_recall — session history search (pi-vcc parity).
3
+ *
4
+ * Hybrid keyword/regex search over the session branch, with:
5
+ * - scope: lineage (active branch, default) | all
6
+ * - pagination (page, 5 results/page)
7
+ * - mode:"touched" — files worked on + entry indices
8
+ * - #N:path drill-down into file content from an entry
9
+ * - expand: entry indices returned as full untruncated content
3
10
  */
4
11
 
5
12
  import type { NormalizedBlock } from "../types.js";
6
- import { searchEntries } from "../compaction/search-entries.js";
13
+ import { searchEntries, type RecallHit } from "../compaction/search-entries.js";
14
+ import { getTouchedFiles } from "../compaction/touched-files.js";
15
+ import { formatRecallOutput, formatTouchedOutput } from "../compaction/format-recall.js";
16
+ import { normalizeRecallScope, normalizeRecallMode, type RecallScope } from "../compaction/recall-scope.js";
17
+ import { parseDrillDown, expandEntryFile } from "../compaction/drill-down.js";
7
18
 
8
19
  export const MAX_RECALL_RESULTS = 50;
9
20
  export const MAX_EXPANDED_HIT_BYTES = 16 * 1024;
21
+ export const DEFAULT_RECENT = 25;
22
+ export const PAGE_SIZE = 5;
10
23
 
11
24
  export interface RecallInput {
12
- query: string;
13
- mode?: "bm25" | "regex";
14
- limit?: number;
15
- offset?: number;
16
- expand?: boolean;
25
+ query?: string;
26
+ scope?: string;
27
+ mode?: string;
28
+ page?: number;
29
+ expand?: number[];
30
+ }
31
+
32
+ export interface RecallResult {
33
+ text: string;
17
34
  }
18
35
 
19
36
  function truncateExpandedHit(text: string): string {
@@ -24,56 +41,77 @@ function truncateExpandedHit(text: string): string {
24
41
  return `${visible}\n… ${omitted} bytes omitted from this hit; narrow the query to inspect more specific context …`;
25
42
  }
26
43
 
27
- export interface RecallResult {
28
- hits: Array<{
29
- index: number;
30
- score: number;
31
- text: string;
32
- kind: string;
33
- }>;
34
- total: number;
35
- query: string;
36
- }
44
+ export const invalidExpandIndices = (requested: number[], available: Set<number>): number[] =>
45
+ requested.filter((i) => !Number.isInteger(i) || !available.has(i));
46
+
47
+ const scopeSuffix = (scope: RecallScope): string => (scope === "all" ? " (scope: all)" : "");
37
48
 
38
49
  export function vccRecall(
39
50
  blocks: NormalizedBlock[],
40
51
  input: RecallInput,
41
52
  ): RecallResult {
42
- const { query, mode = "bm25", expand = false } = input;
43
- const requestedLimit = Number.isFinite(input.limit) ? Math.floor(input.limit!) : 10;
44
- const requestedOffset = Number.isFinite(input.offset) ? Math.floor(input.offset!) : 0;
45
- const limit = Math.min(MAX_RECALL_RESULTS, Math.max(1, requestedLimit));
46
- const offset = Math.max(0, requestedOffset);
53
+ const scope = normalizeRecallScope(input.scope);
47
54
 
48
- let hits: Array<{ index: number; score: number; text: string; kind: string }> = [];
55
+ // Drill-down: #N:path resolves to file-scoped tool content. Anchored so
56
+ // inline mentions like "see #42:auth.ts" are never treated as drill-down.
57
+ const q = input.query?.trim();
58
+ if (q && parseDrillDown(q)) {
59
+ const parsed = parseDrillDown(q)!;
60
+ const text = expandEntryFile(blocks, parsed.index, parsed.pathPattern, parsed.full, parsed.offset, parsed.limit);
61
+ return { text };
62
+ }
49
63
 
50
- if (mode === "bm25") {
51
- const results = searchEntries(blocks, query, { limit: limit + offset, offset: 0 });
52
- hits = results.map((r, i) => ({
53
- index: r.docId,
54
- score: r.score,
55
- text: expand ? truncateExpandedHit(r.text) : r.text.slice(0, 200),
56
- kind: r.kind,
57
- }));
58
- } else {
59
- // Regex fallback
60
- const re = new RegExp(query, "i");
61
- for (let i = 0; i < blocks.length; i++) {
62
- const b = blocks[i];
63
- const text = b.kind === "tool_call" ? `${b.name} ${JSON.stringify(b.args)}` : b.kind === "tool_result" ? `${b.name} ${b.text}` : b.text;
64
- if (re.test(text)) {
65
- hits.push({
66
- index: i,
67
- score: 1,
68
- text: expand ? truncateExpandedHit(text) : text.slice(0, 200),
69
- kind: b.kind,
70
- });
71
- }
64
+ // touched mode: aggregate file operations across the searched window.
65
+ if (normalizeRecallMode(input.mode) === "touched") {
66
+ const touched = getTouchedFiles(blocks);
67
+ return { text: formatTouchedOutput(touched, input.page) };
68
+ }
69
+
70
+ const expandSet = new Set(input.expand ?? []);
71
+ const hasExpand = expandSet.size > 0;
72
+
73
+ // expand without query: return full untruncated content for the indices
74
+ if (hasExpand && !q) {
75
+ const byIndex = new Map(blocks.map((b, i) => [b.sourceIndex ?? i, b]));
76
+ const requested = [...expandSet];
77
+ const invalid = invalidExpandIndices(requested, new Set(byIndex.keys()));
78
+ if (invalid.length > 0) {
79
+ return {
80
+ text: `Cannot expand indices outside ${scope === "all" ? "session history" : "active lineage"}: ${invalid.join(", ")}`,
81
+ };
72
82
  }
83
+ const expanded = requested
84
+ .sort((a, b) => a - b)
85
+ .map((i) => byIndex.get(i))
86
+ .filter((b): b is NormalizedBlock => Boolean(b));
87
+ const lines = expanded.map((b) => {
88
+ const idx = b.sourceIndex ?? 0;
89
+ const text = truncateExpandedHit(b.kind === "tool_call" ? `${b.name} ${JSON.stringify(b.args)}` : b.text);
90
+ return `#${idx} [${b.kind}] ${text}`;
91
+ });
92
+ return { text: (scope === "all" ? "Scope: all\n\n" : "") + lines.join("\n\n") };
73
93
  }
74
94
 
75
- const total = hits.length;
76
- const paginated = hits.slice(offset, offset + limit);
95
+ const allResults = q ? searchEntries(blocks, q) : searchEntries(blocks).slice(-DEFAULT_RECENT);
96
+
97
+ if (q) {
98
+ const page = Math.max(1, input.page ?? 1);
99
+ const start = (page - 1) * PAGE_SIZE;
100
+ const pageResults: RecallHit[] = allResults.slice(start, start + PAGE_SIZE);
101
+ const totalPages = Math.ceil(allResults.length / PAGE_SIZE);
102
+ if (pageResults.length === 0) {
103
+ return { text: `No matches for "${q}"${scopeSuffix(scope)}${page > 1 ? ` on page ${page}` : ""}.` };
104
+ }
105
+ const header = totalPages > 1
106
+ ? `Page ${page}/${totalPages} (${allResults.length} total matches${scopeSuffix(scope)})`
107
+ : `${allResults.length} matches${scopeSuffix(scope)}`;
108
+ const footer = page < totalPages
109
+ ? `\n--- Use page:${page + 1}${scope === "all" ? " with scope:'all'" : ""} for more results ---`
110
+ : "";
111
+ return { text: formatRecallOutput(pageResults, q, header) + footer };
112
+ }
77
113
 
78
- return { hits: paginated, total, query };
114
+ // No query: recent entries
115
+ const output = (scope === "all" ? "Scope: all\n\n" : "") + formatRecallOutput(allResults, q);
116
+ return { text: output };
79
117
  }
@@ -8,14 +8,14 @@
8
8
  */
9
9
 
10
10
  import type { Component, TUI } from "@earendil-works/pi-tui";
11
- import { matchesKey, truncateToWidth, visibleWidth, SettingsList, type SettingItem, type SettingsListTheme } from "@earendil-works/pi-tui";
11
+ import { Key, matchesKey, SettingsList, type SettingItem, type SettingsListTheme } from "@earendil-works/pi-tui";
12
12
  import type { Theme, KeybindingsManager } from "@earendil-works/pi-coding-agent";
13
13
  import { loadConfig, saveConfig, projectConfigPath } from "../config/manager.js";
14
14
  import { applyPreset, detectPreset } from "../config/presets.js";
15
15
  import type { CompactorPreset } from "../types.js";
16
16
  import type { CompactorConfig } from "../types.js";
17
17
  import { existsSync, unlinkSync } from "node:fs";
18
- import { boxInnerWidth } from "@pi-unipi/core";
18
+ import { boxInnerWidth, OverlayTheme } from "@pi-unipi/core";
19
19
 
20
20
  // ─── Section types ─────────────────────────────────────────────────────
21
21
 
@@ -48,16 +48,6 @@ interface PipelineDef {
48
48
  // ─── Static definitions ────────────────────────────────────────────────
49
49
 
50
50
  const STRATEGIES: StrategyDef[] = [
51
- {
52
- key: "debug",
53
- label: "Verbose Debug",
54
- description: "Log ALL compaction events to console",
55
- modes: ["on", "off"],
56
- getEnabled: (c) => c.debug,
57
- setEnabled: (c, v) => (c.debug = v),
58
- getMode: (c) => (c.debug ? "on" : "off"),
59
- setMode: (c, v) => (c.debug = v === "on"),
60
- },
61
51
  {
62
52
  key: "sessionGoals",
63
53
  label: "Session Goals",
@@ -144,6 +134,9 @@ const PIPELINE_ITEMS: PipelineDef[] = [
144
134
  // Only expose implemented behavior. Reserved compatibility fields remain in
145
135
  // the persisted schema so existing config files continue to load.
146
136
  { key: "autoInjection", label: "Auto Injection", description: "Inject behavioral state after compaction", group: "On Compaction", getValue: (c) => c.pipeline.autoInjection, setValue: (c, v) => (c.pipeline.autoInjection = v) },
137
+ { key: "smartKeepTail", label: "Smart Keep Tail", description: "Grow keep:N tail to ≥5k tokens when it would be tiny", group: "Cut Behavior", getValue: (c) => c.smartKeepTail, setValue: (c, v) => (c.smartKeepTail = v) },
138
+ { key: "continueAfterThresholdCompact", label: "Auto-continue", description: "Resume agent after threshold/overflow compaction", group: "On Compaction", getValue: (c) => c.continueAfterThresholdCompact, setValue: (c, v) => (c.continueAfterThresholdCompact = v) },
139
+ { key: "debug", label: "Debug Output", description: "Write compaction diagnostics to /tmp/compactor-debug.json", group: "Diagnostics", getValue: (c) => c.debug, setValue: (c, v) => (c.debug = v) },
147
140
  ];
148
141
 
149
142
  const PRESETS: CompactorPreset[] = ["precise", "balanced", "thorough", "lean"];
@@ -181,23 +174,10 @@ const THEME: SettingsListTheme = {
181
174
  hint: (text) => `\x1b[2m${text}\x1b[0m`,
182
175
  };
183
176
 
184
- // ─── Helper: frame a line inside box drawing ───────────────────────────
185
-
186
- function frameLine(content: string, innerWidth: number): string {
187
- const truncated = truncateToWidth(content, innerWidth, "");
188
- const padding = Math.max(0, innerWidth - visibleWidth(truncated));
189
- return `\x1b[90m│\x1b[0m${truncated}${" ".repeat(padding)}\x1b[90m│\x1b[0m`;
190
- }
177
+ // ─── Shared box-drawing helper ────────────────────────────────────────
191
178
 
192
- function ruleLine(innerWidth: number): string {
193
- return `\x1b[90m├${"─".repeat(innerWidth)}┤\x1b[0m`;
194
- }
179
+ const overlay = new OverlayTheme();
195
180
 
196
- function borderLine(innerWidth: number, edge: "top" | "bottom"): string {
197
- const left = edge === "top" ? "┌" : "└";
198
- const right = edge === "top" ? "┐" : "┘";
199
- return `\x1b[90m${left}${"─".repeat(innerWidth)}${right}\x1b[0m`;
200
- }
201
181
 
202
182
  function uniqueValues(values: string[]): string[] {
203
183
  return [...new Set(values)];
@@ -538,7 +518,7 @@ export class CompactorSettingsOverlay implements Component {
538
518
 
539
519
  handleInput(data: string): void {
540
520
  // Tab switches section
541
- if (data === "\t" || data === "\x1b[Z") {
521
+ if (data === "\t" || matchesKey(data, Key.shift("tab"))) {
542
522
  const idx = SECTIONS.indexOf(this.section);
543
523
  this.section = SECTIONS[(idx + 1) % SECTIONS.length];
544
524
  return;
@@ -569,8 +549,8 @@ export class CompactorSettingsOverlay implements Component {
569
549
  const lines: string[] = [];
570
550
 
571
551
  // Header
572
- lines.push(borderLine(innerWidth, "top"));
573
- lines.push(frameLine(`\x1b[1m\x1b[36m🗜️ Compactor Settings\x1b[0m`, innerWidth));
552
+ lines.push(overlay.borderLine(innerWidth, "top"));
553
+ lines.push(overlay.frameLine(`\x1b[1m\x1b[36m🗜️ Compactor Settings\x1b[0m`, innerWidth));
574
554
 
575
555
  // Current preset indicator
576
556
  const presetName = detectPreset(this.config);
@@ -578,8 +558,8 @@ export class CompactorSettingsOverlay implements Component {
578
558
  const overrideLabel = this.perProjectOverride
579
559
  ? `\x1b[33mProject override\x1b[0m`
580
560
  : `\x1b[2mGlobal config\x1b[0m`;
581
- lines.push(frameLine(`\x1b[2mPreset: ${presetLabel} · ${overrideLabel}\x1b[0m`, innerWidth));
582
- lines.push(ruleLine(innerWidth));
561
+ lines.push(overlay.frameLine(`\x1b[2mPreset: ${presetLabel} · ${overrideLabel}\x1b[0m`, innerWidth));
562
+ lines.push(overlay.ruleLine(innerWidth));
583
563
 
584
564
  // Section tabs
585
565
  const tabParts = SECTIONS.map((s) => {
@@ -589,28 +569,28 @@ export class CompactorSettingsOverlay implements Component {
589
569
  }
590
570
  return `\x1b[2m${label}\x1b[0m`;
591
571
  });
592
- lines.push(frameLine(` ${tabParts.join(" ")}`, innerWidth));
593
- lines.push(ruleLine(innerWidth));
572
+ lines.push(overlay.frameLine(` ${tabParts.join(" ")}`, innerWidth));
573
+ lines.push(overlay.ruleLine(innerWidth));
594
574
 
595
575
  // Section content (rendered by SettingsList)
596
576
  const contentLines = this.currentList.render(innerWidth - 2);
597
577
  for (const line of contentLines) {
598
- lines.push(frameLine(` ${line}`, innerWidth));
578
+ lines.push(overlay.frameLine(` ${line}`, innerWidth));
599
579
  }
600
580
 
601
581
  // Saved indicator
602
582
  if (this.saved) {
603
- lines.push(ruleLine(innerWidth));
604
- lines.push(frameLine(` \x1b[32m✓ Settings saved\x1b[0m`, innerWidth));
583
+ lines.push(overlay.ruleLine(innerWidth));
584
+ lines.push(overlay.frameLine(` \x1b[32m✓ Settings saved\x1b[0m`, innerWidth));
605
585
  }
606
586
 
607
587
  // Footer hints
608
- lines.push(ruleLine(innerWidth));
588
+ lines.push(overlay.ruleLine(innerWidth));
609
589
  const hints = this.section === "strategies"
610
590
  ? "↑↓ navigate · Space change · Tab switch · / search · Enter save · Esc cancel"
611
591
  : "↑↓ navigate · Space change · Tab switch · Enter save · Esc cancel";
612
- lines.push(frameLine(`\x1b[2m${hints}\x1b[0m`, innerWidth));
613
- lines.push(borderLine(innerWidth, "bottom"));
592
+ lines.push(overlay.frameLine(`\x1b[2m${hints}\x1b[0m`, innerWidth));
593
+ lines.push(overlay.borderLine(innerWidth, "bottom"));
614
594
 
615
595
  return lines;
616
596
  }