@pi-unipi/compactor 0.1.7 → 0.2.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.
@@ -2,34 +2,27 @@
2
2
  * @pi-unipi/compactor — TUI Settings Overlay
3
3
  *
4
4
  * Interactive settings editor for compactor configuration.
5
- * Navigate strategies, toggle on/off, cycle modes, apply presets.
5
+ * Uses pi-tui SettingsList for proper keybinding support.
6
+ * Tabbed sections (Presets / Strategies / Pipeline), search,
7
+ * preset preview, per-project override.
6
8
  */
7
9
 
8
10
  import type { Component } from "@mariozechner/pi-tui";
9
- import { truncateToWidth } from "@mariozechner/pi-tui";
10
- import { loadConfig, saveConfig } from "../config/manager.js";
11
+ import { truncateToWidth, visibleWidth, SettingsList, type SettingItem, type SettingsListTheme } from "@mariozechner/pi-tui";
12
+ import { loadConfig, saveConfig, projectConfigPath } from "../config/manager.js";
11
13
  import { applyPreset, detectPreset } from "../config/presets.js";
12
14
  import type { CompactorPreset } from "../types.js";
13
15
  import type { CompactorConfig } from "../types.js";
16
+ import { existsSync, unlinkSync } from "node:fs";
14
17
 
15
- /** ANSI escape codes */
16
- const ansi = {
17
- reset: "\x1b[0m",
18
- bold: "\x1b[1m",
19
- dim: "\x1b[2m",
20
- cyan: "\x1b[36m",
21
- green: "\x1b[32m",
22
- yellow: "\x1b[33m",
23
- red: "\x1b[31m",
24
- gray: "\x1b[90m",
25
- magenta: "\x1b[35m",
26
- };
18
+ // ─── Section types ─────────────────────────────────────────────────────
19
+
20
+ type Section = "presets" | "strategies" | "pipeline";
21
+ const SECTIONS: Section[] = ["presets", "strategies", "pipeline"];
27
22
 
28
- const TOGGLE_ON = `${ansi.green}●${ansi.reset}`;
29
- const TOGGLE_OFF = `${ansi.dim}○${ansi.reset}`;
23
+ // ─── Strategy item definition ──────────────────────────────────────────
30
24
 
31
- /** Strategy item definition */
32
- interface StrategyItem {
25
+ interface StrategyDef {
33
26
  key: string;
34
27
  label: string;
35
28
  description: string;
@@ -40,25 +33,34 @@ interface StrategyItem {
40
33
  setMode: (c: CompactorConfig, v: string) => void;
41
34
  }
42
35
 
43
- /** Top-level debug toggle that mirrors config.debug */
44
- const GLOBAL_DEBUG: StrategyItem = {
45
- key: "debug",
46
- label: "Verbose Debug",
47
- description: "Log ALL compaction events to console",
48
- modes: ["on", "off"],
49
- getEnabled: (c) => c.debug,
50
- setEnabled: (c, v) => (c.debug = v),
51
- getMode: (c) => (c.debug ? "on" : "off"),
52
- setMode: (c, v) => (c.debug = v === "on"),
53
- };
36
+ /** Pipeline feature item */
37
+ interface PipelineDef {
38
+ key: string;
39
+ label: string;
40
+ description: string;
41
+ group: string;
42
+ getValue: (c: CompactorConfig) => boolean;
43
+ setValue: (c: CompactorConfig, v: boolean) => void;
44
+ }
45
+
46
+ // ─── Static definitions ────────────────────────────────────────────────
54
47
 
55
- /** All configurable strategies */
56
- const STRATEGIES: StrategyItem[] = [
48
+ const STRATEGIES: StrategyDef[] = [
49
+ {
50
+ key: "debug",
51
+ label: "Verbose Debug",
52
+ description: "Log ALL compaction events to console",
53
+ modes: ["on", "off"],
54
+ getEnabled: (c) => c.debug,
55
+ setEnabled: (c, v) => (c.debug = v),
56
+ getMode: (c) => (c.debug ? "on" : "off"),
57
+ setMode: (c, v) => (c.debug = v === "on"),
58
+ },
57
59
  {
58
60
  key: "sessionGoals",
59
61
  label: "Session Goals",
60
62
  description: "Extract goals from conversation",
61
- modes: ["full", "minimal"],
63
+ modes: ["full", "brief", "off"],
62
64
  getEnabled: (c) => c.sessionGoals.enabled,
63
65
  setEnabled: (c, v) => (c.sessionGoals.enabled = v),
64
66
  getMode: (c) => c.sessionGoals.mode,
@@ -68,7 +70,7 @@ const STRATEGIES: StrategyItem[] = [
68
70
  key: "filesAndChanges",
69
71
  label: "Files & Changes",
70
72
  description: "Track file activity",
71
- modes: ["all", "modified", "none"],
73
+ modes: ["all", "modified-only", "off"],
72
74
  getEnabled: (c) => c.filesAndChanges.enabled,
73
75
  setEnabled: (c, v) => (c.filesAndChanges.enabled = v),
74
76
  getMode: (c) => c.filesAndChanges.mode,
@@ -78,7 +80,7 @@ const STRATEGIES: StrategyItem[] = [
78
80
  key: "commits",
79
81
  label: "Commits",
80
82
  description: "Extract git commits",
81
- modes: ["full", "minimal", "none"],
83
+ modes: ["full", "brief", "off"],
82
84
  getEnabled: (c) => c.commits.enabled,
83
85
  setEnabled: (c, v) => (c.commits.enabled = v),
84
86
  getMode: (c) => c.commits.mode,
@@ -88,7 +90,7 @@ const STRATEGIES: StrategyItem[] = [
88
90
  key: "outstandingContext",
89
91
  label: "Outstanding Context",
90
92
  description: "Track blockers and pending items",
91
- modes: ["full", "minimal"],
93
+ modes: ["full", "critical-only", "off"],
92
94
  getEnabled: (c) => c.outstandingContext.enabled,
93
95
  setEnabled: (c, v) => (c.outstandingContext.enabled = v),
94
96
  getMode: (c) => c.outstandingContext.mode,
@@ -98,7 +100,7 @@ const STRATEGIES: StrategyItem[] = [
98
100
  key: "userPreferences",
99
101
  label: "User Preferences",
100
102
  description: "Track learned preferences",
101
- modes: ["all", "minimal"],
103
+ modes: ["all", "recent-only", "off"],
102
104
  getEnabled: (c) => c.userPreferences.enabled,
103
105
  setEnabled: (c, v) => (c.userPreferences.enabled = v),
104
106
  getMode: (c) => c.userPreferences.mode,
@@ -108,7 +110,7 @@ const STRATEGIES: StrategyItem[] = [
108
110
  key: "briefTranscript",
109
111
  label: "Brief Transcript",
110
112
  description: "Rolling window of recent messages",
111
- modes: ["full", "compact", "minimal"],
113
+ modes: ["full", "compact", "minimal", "off"],
112
114
  getEnabled: (c) => c.briefTranscript.enabled,
113
115
  setEnabled: (c, v) => (c.briefTranscript.enabled = v),
114
116
  getMode: (c) => c.briefTranscript.mode,
@@ -118,7 +120,7 @@ const STRATEGIES: StrategyItem[] = [
118
120
  key: "sessionContinuity",
119
121
  label: "Session Continuity",
120
122
  description: "XML resume snapshot for compaction survival",
121
- modes: ["full", "minimal"],
123
+ modes: ["full", "essential-only", "off"],
122
124
  getEnabled: (c) => c.sessionContinuity.enabled,
123
125
  setEnabled: (c, v) => (c.sessionContinuity.enabled = v),
124
126
  getMode: (c) => c.sessionContinuity.mode,
@@ -128,7 +130,7 @@ const STRATEGIES: StrategyItem[] = [
128
130
  key: "fts5Index",
129
131
  label: "FTS5 Index",
130
132
  description: "Full-text search index",
131
- modes: ["auto", "manual", "disabled"],
133
+ modes: ["auto", "manual", "off"],
132
134
  getEnabled: (c) => c.fts5Index.enabled,
133
135
  setEnabled: (c, v) => (c.fts5Index.enabled = v),
134
136
  getMode: (c) => c.fts5Index.mode,
@@ -138,7 +140,7 @@ const STRATEGIES: StrategyItem[] = [
138
140
  key: "sandboxExecution",
139
141
  label: "Sandbox Execution",
140
142
  description: "Polyglot code execution",
141
- modes: ["all", "safe", "none"],
143
+ modes: ["all", "safe-only", "off"],
142
144
  getEnabled: (c) => c.sandboxExecution.enabled,
143
145
  setEnabled: (c, v) => (c.sandboxExecution.enabled = v),
144
146
  getMode: (c) => c.sandboxExecution.mode,
@@ -148,7 +150,7 @@ const STRATEGIES: StrategyItem[] = [
148
150
  key: "toolDisplay",
149
151
  label: "Tool Display",
150
152
  description: "Override tool output rendering",
151
- modes: ["opencode", "summary", "verbose", "minimal"],
153
+ modes: ["opencode", "balanced", "verbose", "custom"],
152
154
  getEnabled: (c) => c.toolDisplay.enabled,
153
155
  setEnabled: (c, v) => (c.toolDisplay.enabled = v),
154
156
  getMode: (c) => c.toolDisplay.mode,
@@ -156,149 +158,357 @@ const STRATEGIES: StrategyItem[] = [
156
158
  },
157
159
  ];
158
160
 
159
- /** All navigable items: debug toggle first, then strategies */
160
- const ALL_ITEMS: StrategyItem[] = [GLOBAL_DEBUG, ...STRATEGIES];
161
+ const PIPELINE_ITEMS: PipelineDef[] = [
162
+ { key: "ttlCache", label: "TTL Cache", description: "Cache with time-based expiry", group: "On Compaction", getValue: (c) => c.pipeline.ttlCache, setValue: (c, v) => (c.pipeline.ttlCache = v) },
163
+ { 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) },
164
+ { key: "mmapPragma", label: "MMap Pragma", description: "Use mmap for SQLite I/O", group: "On Compaction", getValue: (c) => c.pipeline.mmapPragma, setValue: (c, v) => (c.pipeline.mmapPragma = v) },
165
+ { key: "proximityReranking", label: "Proximity Reranking", description: "Rerank search results by proximity", group: "On Search", getValue: (c) => c.pipeline.proximityReranking, setValue: (c, v) => (c.pipeline.proximityReranking = v) },
166
+ { key: "timelineSort", label: "Timeline Sort", description: "Sort session events chronologically", group: "On Search", getValue: (c) => c.pipeline.timelineSort, setValue: (c, v) => (c.pipeline.timelineSort = v) },
167
+ { key: "progressiveThrottling", label: "Progressive Throttling", description: "Slow down indexing for large projects", group: "On Index", getValue: (c) => c.pipeline.progressiveThrottling, setValue: (c, v) => (c.pipeline.progressiveThrottling = v) },
168
+ ];
169
+
170
+ const PRESETS: CompactorPreset[] = ["precise", "balanced", "thorough", "lean"];
171
+
172
+ const PRESET_DESCRIPTIONS: Record<string, { summary: string; detail: string }> = {
173
+ precise: {
174
+ summary: "Code-heavy, minimal waste — compaction: full, FTS5: manual, pipeline: 2/6 on",
175
+ detail: "Max token savings. Compaction: full. Display: minimal.\nFTS5: manual. Sandbox: safe-only. Pipeline: ttlCache+mmap on.",
176
+ },
177
+ balanced: {
178
+ summary: "Daily use (default) — all strategies moderate, pipeline: all on",
179
+ detail: "Moderate all strategies. Display: balanced.\nFTS5: auto. Sandbox: all. Pipeline: all 6 on.",
180
+ },
181
+ thorough: {
182
+ summary: "Debug/audit — everything on, full transcript, pipeline: all on",
183
+ detail: "Everything enabled. Display: verbose.\nFTS5: auto. Sandbox: all. Pipeline: all 6 on.",
184
+ },
185
+ lean: {
186
+ summary: "Quick fixes, short sessions — compaction only, pipeline: all off",
187
+ detail: "Compaction only. Display: opencode.\nFTS5: off. Sandbox: off. Pipeline: all 6 off.",
188
+ },
189
+ };
190
+
191
+ // ─── Theme for SettingsList ────────────────────────────────────────────
192
+
193
+ const THEME: SettingsListTheme = {
194
+ label: (text, selected) => selected ? `\x1b[1m${text}\x1b[0m` : `\x1b[2m${text}\x1b[0m`,
195
+ value: (text, selected) => selected ? `\x1b[35m${text}\x1b[0m` : `\x1b[35m${text}\x1b[0m`,
196
+ description: (text) => `\x1b[90m${text}\x1b[0m`,
197
+ cursor: `\x1b[36m▸\x1b[0m`,
198
+ hint: (text) => `\x1b[2m${text}\x1b[0m`,
199
+ };
200
+
201
+ // ─── Helper: frame a line inside box drawing ───────────────────────────
202
+
203
+ function frameLine(content: string, innerWidth: number): string {
204
+ const truncated = truncateToWidth(content, innerWidth, "");
205
+ const padding = Math.max(0, innerWidth - visibleWidth(truncated));
206
+ return `\x1b[90m│\x1b[0m${truncated}${" ".repeat(padding)}\x1b[90m│\x1b[0m`;
207
+ }
208
+
209
+ function ruleLine(innerWidth: number): string {
210
+ return `\x1b[90m├${"─".repeat(innerWidth)}┤\x1b[0m`;
211
+ }
161
212
 
162
- const PRESETS: CompactorPreset[] = ["opencode", "balanced", "verbose", "minimal"];
213
+ function borderLine(innerWidth: number, edge: "top" | "bottom"): string {
214
+ const left = edge === "top" ? "┌" : "└";
215
+ const right = edge === "top" ? "┐" : "┘";
216
+ return `\x1b[90m${left}${"─".repeat(innerWidth)}${right}\x1b[0m`;
217
+ }
218
+
219
+ // ─── Main component ────────────────────────────────────────────────────
163
220
 
164
221
  /**
165
222
  * Settings overlay component for compactor configuration.
223
+ * Uses SettingsList from pi-tui for proper vim/arrow keybinding support.
166
224
  */
167
225
  export class CompactorSettingsOverlay implements Component {
168
226
  private config: CompactorConfig;
169
- private selectedIndex = 0;
170
- private mode = "strategy" as "strategy" | "preset";
171
- private presetIndex = 0;
227
+ private section: Section = "presets";
228
+ private perProjectOverride = false;
229
+ private projectDir: string;
230
+ private saved = false;
172
231
  onClose?: () => void;
173
232
 
174
- constructor() {
175
- this.config = loadConfig();
176
- const currentPreset = detectPreset(this.config);
177
- this.presetIndex = PRESETS.indexOf(currentPreset as CompactorPreset);
178
- if (this.presetIndex < 0) this.presetIndex = 0;
233
+ // Per-section SettingsList instances
234
+ private presetList!: SettingsList;
235
+ private strategyList!: SettingsList;
236
+ private pipelineList!: SettingsList;
237
+
238
+ constructor(opts?: { cwd?: string }) {
239
+ this.projectDir = opts?.cwd ?? process.cwd();
240
+ this.config = loadConfig(this.projectDir);
241
+
242
+ // Detect per-project override
243
+ const projPath = projectConfigPath(this.projectDir);
244
+ this.perProjectOverride = existsSync(projPath);
245
+
246
+ this.buildLists();
247
+ }
248
+
249
+ invalidate(): void {
250
+ this.currentList?.invalidate();
251
+ }
252
+
253
+ // ─── Build SettingsList instances ──────────────────────────────────
254
+
255
+ private buildLists(): void {
256
+ // ── Presets list ──────────────────────────────────────────────────
257
+ const presetItems: SettingItem[] = PRESETS.map((name) => {
258
+ const desc = PRESET_DESCRIPTIONS[name]!;
259
+ return {
260
+ id: `preset:${name}`,
261
+ label: name.charAt(0).toUpperCase() + name.slice(1),
262
+ description: desc.summary,
263
+ currentValue: detectPreset(this.config) === name ? "✓ active" : "",
264
+ values: ["apply"],
265
+ };
266
+ });
267
+ // Add per-project override as a setting item
268
+ presetItems.push({
269
+ id: "projectOverride",
270
+ label: "Project Override",
271
+ description: "Override global config for this project only",
272
+ currentValue: this.perProjectOverride ? "enabled" : "disabled",
273
+ values: ["enabled", "disabled"],
274
+ });
275
+
276
+ this.presetList = new SettingsList(
277
+ presetItems,
278
+ 8,
279
+ THEME,
280
+ (id, newValue) => this.onPresetChange(id, newValue),
281
+ () => this.onCancel(),
282
+ );
283
+
284
+ // ── Strategies list ───────────────────────────────────────────────
285
+ const strategyItems: SettingItem[] = STRATEGIES.map((s) => ({
286
+ id: `strategy:${s.key}`,
287
+ label: s.label,
288
+ description: s.description,
289
+ currentValue: this.formatStrategyValue(s),
290
+ values: s.modes,
291
+ }));
292
+
293
+ this.strategyList = new SettingsList(
294
+ strategyItems,
295
+ 12,
296
+ THEME,
297
+ (id, newValue) => this.onStrategyChange(id, newValue),
298
+ () => this.onCancel(),
299
+ { enableSearch: true },
300
+ );
301
+
302
+ // ── Pipeline list ─────────────────────────────────────────────────
303
+ const pipelineItems: SettingItem[] = PIPELINE_ITEMS.map((p) => ({
304
+ id: `pipeline:${p.key}`,
305
+ label: `${p.group}: ${p.label}`,
306
+ description: p.description,
307
+ currentValue: p.getValue(this.config) ? "on" : "off",
308
+ values: ["on", "off"],
309
+ }));
310
+
311
+ this.pipelineList = new SettingsList(
312
+ pipelineItems,
313
+ 8,
314
+ THEME,
315
+ (id, newValue) => this.onPipelineChange(id, newValue),
316
+ () => this.onCancel(),
317
+ );
318
+ }
319
+
320
+ // ─── Current section's list ────────────────────────────────────────
321
+
322
+ private get currentList(): SettingsList {
323
+ if (this.section === "strategies") return this.strategyList;
324
+ if (this.section === "pipeline") return this.pipelineList;
325
+ return this.presetList;
326
+ }
327
+
328
+ // ─── Change handlers ───────────────────────────────────────────────
329
+
330
+ private onPresetChange(id: string, _newValue: string): void {
331
+ if (id === "projectOverride") {
332
+ this.perProjectOverride = _newValue === "enabled";
333
+ if (!this.perProjectOverride) {
334
+ const projPath = projectConfigPath(this.projectDir);
335
+ try { unlinkSync(projPath); } catch { /* ignore */ }
336
+ }
337
+ this.presetList.updateValue("projectOverride", this.perProjectOverride ? "enabled" : "disabled");
338
+ return;
339
+ }
340
+ // Apply the preset
341
+ const presetName = id.replace("preset:", "") as CompactorPreset;
342
+ if (PRESETS.includes(presetName)) {
343
+ this.config = applyPreset(presetName);
344
+ // Update all strategy/pipeline items to reflect new config
345
+ this.refreshStrategyValues();
346
+ this.refreshPipelineValues();
347
+ // Update preset indicators
348
+ for (const name of PRESETS) {
349
+ this.presetList.updateValue(
350
+ `preset:${name}`,
351
+ detectPreset(this.config) === name ? "✓ active" : "",
352
+ );
353
+ }
354
+ }
355
+ }
356
+
357
+ private onStrategyChange(id: string, newValue: string): void {
358
+ const key = id.replace("strategy:", "");
359
+ const strat = STRATEGIES.find((s) => s.key === key);
360
+ if (!strat) return;
361
+
362
+ // Map the cycled value to enabled + mode
363
+ strat.setMode(this.config, newValue);
364
+ // If mode is "off", disable; otherwise enable
365
+ strat.setEnabled(this.config, newValue !== "off");
366
+
367
+ this.strategyList.updateValue(id, this.formatStrategyValue(strat));
368
+
369
+ // Update preset indicators since config may no longer match a preset
370
+ for (const name of PRESETS) {
371
+ this.presetList.updateValue(
372
+ `preset:${name}`,
373
+ detectPreset(this.config) === name ? "✓ active" : "",
374
+ );
375
+ }
376
+ }
377
+
378
+ private onPipelineChange(id: string, newValue: string): void {
379
+ const key = id.replace("pipeline:", "");
380
+ const item = PIPELINE_ITEMS.find((p) => p.key === key);
381
+ if (!item) return;
382
+ item.setValue(this.config, newValue === "on");
383
+ this.pipelineList.updateValue(id, newValue);
384
+
385
+ // Update preset indicators
386
+ for (const name of PRESETS) {
387
+ this.presetList.updateValue(
388
+ `preset:${name}`,
389
+ detectPreset(this.config) === name ? "✓ active" : "",
390
+ );
391
+ }
392
+ }
393
+
394
+ private onCancel(): void {
395
+ this.onClose?.();
179
396
  }
180
397
 
181
- invalidate(): void {}
398
+ // ─── Helpers ───────────────────────────────────────────────────────
399
+
400
+ private formatStrategyValue(s: StrategyDef): string {
401
+ const enabled = s.getEnabled(this.config);
402
+ const mode = s.getMode(this.config);
403
+ if (!enabled) return "off";
404
+ return mode;
405
+ }
406
+
407
+ private refreshStrategyValues(): void {
408
+ for (const s of STRATEGIES) {
409
+ this.strategyList.updateValue(`strategy:${s.key}`, this.formatStrategyValue(s));
410
+ }
411
+ }
412
+
413
+ private refreshPipelineValues(): void {
414
+ for (const p of PIPELINE_ITEMS) {
415
+ this.pipelineList.updateValue(`pipeline:${p.key}`, p.getValue(this.config) ? "on" : "off");
416
+ }
417
+ }
418
+
419
+ // ─── Input handling ────────────────────────────────────────────────
182
420
 
183
421
  handleInput(data: string): void {
184
- switch (data) {
185
- case "\x1b[A": // Up
186
- case "k":
187
- if (this.mode === "strategy") {
188
- this.selectedIndex = (this.selectedIndex - 1 + ALL_ITEMS.length) % ALL_ITEMS.length;
189
- } else {
190
- this.presetIndex = (this.presetIndex - 1 + PRESETS.length) % PRESETS.length;
191
- }
192
- break;
193
- case "\x1b[B": // Down
194
- case "j":
195
- if (this.mode === "strategy") {
196
- this.selectedIndex = (this.selectedIndex + 1) % ALL_ITEMS.length;
197
- } else {
198
- this.presetIndex = (this.presetIndex + 1) % PRESETS.length;
199
- }
200
- break;
201
- case " ": // Space - toggle enabled
202
- if (this.mode === "strategy") {
203
- const item = ALL_ITEMS[this.selectedIndex];
204
- item.setEnabled(this.config, !item.getEnabled(this.config));
205
- }
206
- break;
207
- case "\x1b[C": // Right - cycle mode forward
208
- case "\r": // Enter
209
- if (this.mode === "strategy") {
210
- const strat = ALL_ITEMS[this.selectedIndex];
211
- const modes = strat.modes;
212
- const currentIdx = modes.indexOf(strat.getMode(this.config));
213
- const nextIdx = (currentIdx + 1) % modes.length;
214
- strat.setMode(this.config, modes[nextIdx]);
215
- } else {
216
- // Apply preset
217
- this.config = applyPreset(PRESETS[this.presetIndex]);
218
- this.mode = "strategy";
219
- }
220
- break;
221
- case "\x1b[D": // Left - cycle mode backward
222
- if (this.mode === "strategy") {
223
- const strat2 = ALL_ITEMS[this.selectedIndex];
224
- const modes2 = strat2.modes;
225
- const curIdx = modes2.indexOf(strat2.getMode(this.config));
226
- const prevIdx = (curIdx - 1 + modes2.length) % modes2.length;
227
- strat2.setMode(this.config, modes2[prevIdx]);
228
- }
229
- break;
230
- case "p": // Toggle preset mode
231
- this.mode = this.mode === "preset" ? "strategy" : "preset";
232
- break;
233
- case "s": // Save
234
- saveConfig(this.config);
235
- this.onClose?.();
236
- break;
237
- case "\x1b": // Escape - cancel
238
- this.onClose?.();
239
- break;
422
+ // Tab switches section
423
+ if (data === "\t" || data === "\x1b[Z") {
424
+ const idx = SECTIONS.indexOf(this.section);
425
+ this.section = SECTIONS[(idx + 1) % SECTIONS.length];
426
+ return;
427
+ }
428
+
429
+ // Enter saves and closes
430
+ if (data === "\r") {
431
+ saveConfig(this.config, { perProject: this.perProjectOverride, cwd: this.projectDir });
432
+ this.saved = true;
433
+ setTimeout(() => this.onClose?.(), 400);
434
+ return;
240
435
  }
436
+
437
+ // Escape cancels (but SettingsList also handles it, calling onCancel)
438
+ if (data === "\x1b") {
439
+ this.onClose?.();
440
+ return;
441
+ }
442
+
443
+ // Delegate all other input to the current section's SettingsList
444
+ this.currentList.handleInput(data);
241
445
  }
242
446
 
447
+ // ─── Render ────────────────────────────────────────────────────────
448
+
243
449
  render(width: number): string[] {
450
+ const innerWidth = Math.max(22, width - 2);
244
451
  const lines: string[] = [];
245
- const add = (s: string) => lines.push(truncateToWidth(s, width));
246
452
 
247
453
  // Header
248
- add(`${ansi.bold}${ansi.cyan}🗜️ Compactor Settings${ansi.reset}`);
454
+ lines.push(borderLine(innerWidth, "top"));
455
+ lines.push(frameLine(`\x1b[1m\x1b[36m🗜️ Compactor Settings\x1b[0m`, innerWidth));
456
+
457
+ // Current preset indicator
249
458
  const presetName = detectPreset(this.config);
250
- add(`${ansi.dim}Preset: ${presetName === "custom" ? "custom (modified)" : presetName}${ansi.reset}`);
251
- add("");
252
-
253
- if (this.mode === "preset") {
254
- // Preset selection
255
- add(`${ansi.bold}Select Preset:${ansi.reset}`);
256
- add("");
257
- for (let i = 0; i < PRESETS.length; i++) {
258
- const isSelected = i === this.presetIndex;
259
- const prefix = isSelected ? `${ansi.cyan}▸${ansi.reset}` : " ";
260
- const label = isSelected ? `${ansi.bold}${PRESETS[i]}${ansi.reset}` : PRESETS[i];
261
- add(`${prefix} ${label}`);
262
- }
263
- add("");
264
- add(`${ansi.dim}↑↓ navigate • Enter apply • p back to strategies • s save • Esc cancel${ansi.reset}`);
265
- } else {
266
- // Strategy list (GLOBAL_DEBUG at top, then all strategies)
267
- for (let i = 0; i < ALL_ITEMS.length; i++) {
268
- const item = ALL_ITEMS[i];
269
- const isSelected = i === this.selectedIndex;
270
- const enabled = item.getEnabled(this.config);
271
- const mode = item.getMode(this.config);
272
- const toggle = enabled ? TOGGLE_ON : TOGGLE_OFF;
273
- const labelColor = isSelected ? ansi.bold : "";
274
- const modeColor = ansi.magenta;
275
- const descColor = ansi.gray;
276
-
277
- const cursor = isSelected ? `${ansi.cyan}▸${ansi.reset}` : " ";
278
- add(`${cursor} ${toggle} ${labelColor}${item.label}${ansi.reset} ${modeColor}[${mode}]${ansi.reset}`);
279
- add(` ${descColor}${item.description}${ansi.reset}`);
459
+ const presetLabel = presetName === "custom" ? "custom (modified)" : presetName;
460
+ const overrideLabel = this.perProjectOverride
461
+ ? `\x1b[33mProject override\x1b[0m`
462
+ : `\x1b[2mGlobal config\x1b[0m`;
463
+ lines.push(frameLine(`\x1b[2mPreset: ${presetLabel} · ${overrideLabel}\x1b[0m`, innerWidth));
464
+ lines.push(ruleLine(innerWidth));
465
+
466
+ // Section tabs
467
+ const tabParts = SECTIONS.map((s) => {
468
+ const label = s.charAt(0).toUpperCase() + s.slice(1);
469
+ if (s === this.section) {
470
+ return `\x1b[1m\x1b[36m[${label}]\x1b[0m`;
280
471
  }
472
+ return `\x1b[2m${label}\x1b[0m`;
473
+ });
474
+ lines.push(frameLine(` ${tabParts.join(" ")}`, innerWidth));
475
+ lines.push(ruleLine(innerWidth));
281
476
 
282
- add("");
283
- add(`${ansi.dim}↑↓ navigate Space toggle • ←→ cycle mode • p presets • s save • Esc cancel${ansi.reset}`);
477
+ // Section content (rendered by SettingsList)
478
+ const contentLines = this.currentList.render(innerWidth - 2);
479
+ for (const line of contentLines) {
480
+ lines.push(frameLine(` ${line}`, innerWidth));
284
481
  }
285
482
 
483
+ // Saved indicator
484
+ if (this.saved) {
485
+ lines.push(ruleLine(innerWidth));
486
+ lines.push(frameLine(` \x1b[32m✓ Settings saved\x1b[0m`, innerWidth));
487
+ }
488
+
489
+ // Footer hints
490
+ lines.push(ruleLine(innerWidth));
491
+ const hints = this.section === "strategies"
492
+ ? "↑↓ navigate · Space change · Tab switch · / search · Enter save · Esc cancel"
493
+ : "↑↓ navigate · Space change · Tab switch · Enter save · Esc cancel";
494
+ lines.push(frameLine(`\x1b[2m${hints}\x1b[0m`, innerWidth));
495
+ lines.push(borderLine(innerWidth, "bottom"));
496
+
286
497
  return lines;
287
498
  }
288
499
  }
289
500
 
290
501
  /**
291
502
  * Factory function for ctx.ui.custom() integration.
292
- * Returns a render function compatible with pi-tui's custom overlay API.
293
503
  */
294
- export function renderSettingsOverlay() {
295
- return (tui: any, _theme: any, _kb: any, done: (result: any) => void) => {
296
- const overlay = new CompactorSettingsOverlay();
504
+ export function renderSettingsOverlay(cwd?: string) {
505
+ return (_tui: any, _theme: any, _kb: any, done: (result: any) => void) => {
506
+ const overlay = new CompactorSettingsOverlay({ cwd });
297
507
  overlay.onClose = () => done(overlay);
298
508
 
299
509
  return {
300
510
  render: (width: number) => overlay.render(width),
301
- invalidate: () => {},
511
+ invalidate: () => overlay.invalidate(),
302
512
  handleInput: (data: string) => overlay.handleInput(data),
303
513
  };
304
514
  };