pi-blackhole 0.4.2 → 0.4.3

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 (87) hide show
  1. package/README.md +15 -0
  2. package/dist/index.js +11660 -0
  3. package/dist/index.js.map +1 -0
  4. package/example-config.json +1 -1
  5. package/index.ts +37 -63
  6. package/package.json +21 -9
  7. package/src/commands/cleanup.ts +279 -240
  8. package/src/commands/memory.ts +236 -184
  9. package/src/commands/pi-vcc.ts +202 -152
  10. package/src/commands/vcc-recall.ts +126 -95
  11. package/src/core/brief.ts +167 -33
  12. package/src/core/build-sections.ts +8 -2
  13. package/src/core/config-env.ts +117 -0
  14. package/src/core/content.ts +31 -7
  15. package/src/core/drill-down.ts +41 -11
  16. package/src/core/filter-noise.ts +9 -3
  17. package/src/core/format-recall.ts +15 -6
  18. package/src/core/format.ts +14 -4
  19. package/src/core/lineage.ts +9 -3
  20. package/src/core/load-messages.ts +24 -5
  21. package/src/core/normalize.ts +38 -14
  22. package/src/core/recall-scope.ts +11 -3
  23. package/src/core/render-entries.ts +22 -6
  24. package/src/core/sanitize.ts +5 -1
  25. package/src/core/search-entries.ts +111 -19
  26. package/src/core/settings.ts +1 -3
  27. package/src/core/summarize.ts +42 -21
  28. package/src/core/unified-config.ts +549 -411
  29. package/src/extract/commits.ts +4 -2
  30. package/src/extract/files.ts +10 -5
  31. package/src/extract/goals.ts +7 -2
  32. package/src/hooks/before-compact.ts +210 -88
  33. package/src/om/agents/dropper/agent.ts +380 -265
  34. package/src/om/agents/dropper/coverage.ts +102 -82
  35. package/src/om/agents/observer/agent.ts +242 -206
  36. package/src/om/agents/reflector/agent.ts +212 -153
  37. package/src/om/cleanup.ts +239 -218
  38. package/src/om/clipboard.ts +59 -51
  39. package/src/om/compaction-trigger.ts +448 -333
  40. package/src/om/config.ts +13 -6
  41. package/src/om/configure-overlay.ts +518 -355
  42. package/src/om/consolidation.ts +1460 -953
  43. package/src/om/cooldown.ts +75 -65
  44. package/src/om/debug-log.ts +86 -68
  45. package/src/om/ids.ts +1 -1
  46. package/src/om/ledger/fold.ts +89 -78
  47. package/src/om/ledger/progress.ts +181 -153
  48. package/src/om/ledger/projection.ts +248 -185
  49. package/src/om/ledger/recall.ts +247 -196
  50. package/src/om/ledger/render-summary.ts +79 -50
  51. package/src/om/ledger/types.ts +146 -117
  52. package/src/om/model-budget.ts +23 -13
  53. package/src/om/pending.ts +243 -179
  54. package/src/om/provider-stream.ts +52 -7
  55. package/src/om/retryable-error.ts +12 -16
  56. package/src/om/reverse-recall.ts +97 -91
  57. package/src/om/runtime.ts +474 -375
  58. package/src/om/serialize.ts +190 -166
  59. package/src/om/status-overlay.ts +246 -195
  60. package/src/om/tokens.ts +28 -21
  61. package/src/pi-base/blackhole-settings.ts +437 -0
  62. package/src/pi-base/config-manager.ts +440 -0
  63. package/src/pi-base/config.ts +469 -0
  64. package/src/pi-base/env.ts +43 -0
  65. package/src/pi-base/paths.ts +47 -0
  66. package/src/pi-base/settings/body.ts +1648 -0
  67. package/src/pi-base/settings/fields/action.ts +43 -0
  68. package/src/pi-base/settings/fields/boolean.ts +47 -0
  69. package/src/pi-base/settings/fields/custom.ts +72 -0
  70. package/src/pi-base/settings/fields/enum.ts +310 -0
  71. package/src/pi-base/settings/fields/index.ts +46 -0
  72. package/src/pi-base/settings/fields/model.ts +452 -0
  73. package/src/pi-base/settings/fields/string.ts +527 -0
  74. package/src/pi-base/settings/fields/text.ts +115 -0
  75. package/src/pi-base/settings/frame.ts +197 -0
  76. package/src/pi-base/settings/index.ts +77 -0
  77. package/src/pi-base/settings/inline-edit.ts +313 -0
  78. package/src/pi-base/settings/modal.ts +152 -0
  79. package/src/pi-base/settings/types.ts +500 -0
  80. package/src/pi-base/settings/validate-field.ts +113 -0
  81. package/src/pi-base/shell.ts +117 -0
  82. package/src/pi-base/types.ts +6 -0
  83. package/src/pi-base/ui.ts +32 -0
  84. package/src/tools/recall.ts +347 -225
  85. package/src/types.ts +20 -3
  86. package/tsup.config.ts +23 -0
  87. package/vitest.config.ts +15 -15
@@ -17,19 +17,19 @@ import { dirname } from "node:path";
17
17
  // ---------------------------------------------------------------------------
18
18
 
19
19
  interface FieldDef {
20
- key: string;
21
- label: string;
22
- type: "number" | "boolean" | "enum";
23
- section: string;
24
- enumValues?: string[];
25
- helpText?: string;
20
+ key: string;
21
+ label: string;
22
+ type: "number" | "boolean" | "enum";
23
+ section: string;
24
+ enumValues?: string[];
25
+ helpText?: string;
26
26
  }
27
27
 
28
28
  interface FieldState {
29
- def: FieldDef;
30
- value: string;
31
- editing: boolean;
32
- cursor: number;
29
+ def: FieldDef;
30
+ value: string;
31
+ editing: boolean;
32
+ cursor: number;
33
33
  }
34
34
 
35
35
  // ---------------------------------------------------------------------------
@@ -37,51 +37,169 @@ interface FieldState {
37
37
  // ---------------------------------------------------------------------------
38
38
 
39
39
  const FIELDS: FieldDef[] = [
40
- // ── Compaction ──
41
- { key: "compaction", label: "Compaction mode", type: "enum", section: "Compaction", enumValues: ["auto", "manual", "off"],
42
- helpText: "auto=trigger on threshold, manual=only /blackhole, off=auto:Pi handles, /blackhole:blackhole pipeline" },
43
- { key: "compactionEngine", label: "Compaction engine", type: "enum", section: "Compaction", enumValues: ["blackhole", "pi-default"],
44
- helpText: "blackhole=structured summary+OM, pi-default=built-in Pi summarization" },
45
- { key: "tailBehavior", label: "Visible tail", type: "enum", section: "Compaction", enumValues: ["minimal", "pi-default"],
46
- helpText: "minimal=keep last user message only (default), pi-default=keep Pi's preserved visible context" },
47
- { key: "midRunCompaction", label: "Mid-run compaction", type: "enum", section: "Compaction", enumValues: ["resume", "pause", "off"],
48
- helpText: "resume=compact at threshold during tool loops and continue (default), pause=compact and stop, off=only check when run ends" },
49
- { key: "compactAfterTokens", label: "Auto-compact threshold", type: "number", section: "Compaction",
50
- helpText: "Token count that triggers auto-compaction when reached" },
51
-
52
- // ── Observational Memory ──
53
- { key: "memory", label: "Observational memory", type: "boolean", section: "Observational Memory",
54
- helpText: "Enable OM workers (observer, reflector, dropper) and content injection" },
55
- { key: "sessionFallback", label: "Session model fallback", type: "boolean", section: "Observational Memory",
56
- helpText: "off=skip stage when all OM models fail, instead of falling back to the main coding model" },
57
- { key: "observeAfterTokens", label: "Observer threshold", type: "number", section: "Observational Memory",
58
- helpText: "Tokens accumulated since last observer run before triggering next observe" },
59
- { key: "reflectAfterTokens", label: "Reflect + dropper threshold", type: "number", section: "Observational Memory",
60
- helpText: "Tokens accumulated since last reflect before triggering reflector and dropper" },
61
- { key: "observationsPoolMaxTokens", label: "Observation pool max", type: "number", section: "Observational Memory",
62
- helpText: "Max tokens in observation pool before dropper prunes (fold pressure)" },
63
- { key: "observationsPoolTargetTokens", label: "Observation pool target", type: "number", section: "Observational Memory",
64
- helpText: "Target tokens after dropper prunes (defaults to half of pool max)" },
65
- { key: "reflectorInputMaxTokens", label: "Reflector input max", type: "number", section: "Observational Memory",
66
- helpText: "Max prompt tokens for reflector model input (rolling window cap)" },
67
- { key: "dropperInputMaxTokens", label: "Dropper input max", type: "number", section: "Observational Memory",
68
- helpText: "Max prompt tokens for dropper model input (rolling window cap)" },
69
- { key: "observerChunkMaxTokens", label: "Observer chunk max", type: "number", section: "Observational Memory",
70
- helpText: "Max source entry tokens sent to observer per chunk" },
71
- { key: "observerPreambleMaxTokens", label: "Observer preamble max", type: "number", section: "Observational Memory",
72
- helpText: "Preamble budget in manual compaction mode (0=auto-compute 30% of chunk)" },
73
- { key: "dropperPressureThreshold", label: "Dropper pressure threshold", type: "number", section: "Observational Memory",
74
- helpText: "Fraction of reflectorInputMaxTokens that triggers pressure-driven dropper (0-1, default 0.70)" },
75
- { key: "agentMaxTurns", label: "Max turns per agent", type: "number", section: "Observational Memory",
76
- helpText: "Shared turn cap for background memory agents" },
77
- { key: "fullFoldAlways", label: "Preserve OM on first compaction", type: "boolean", section: "Observational Memory",
78
- helpText: "When true, early reflections/drops survive the first compaction in a fresh session" },
79
-
80
- // ── Debug ──
81
- { key: "debug", label: "Debug snapshots", type: "boolean", section: "Debug",
82
- helpText: "Write detailed debug snapshots to /tmp/pi-blackhole-debug.json" },
83
- { key: "debugLog", label: "Debug JSONL logging", type: "boolean", section: "Debug",
84
- helpText: "Write structured JSONL debug logs to agent directory" },
40
+ // ── Compaction ──
41
+ {
42
+ key: "compaction",
43
+ label: "Compaction mode",
44
+ type: "enum",
45
+ section: "Compaction",
46
+ enumValues: ["auto", "manual", "off"],
47
+ helpText:
48
+ "auto=trigger on threshold, manual=only /blackhole, off=auto:Pi handles, /blackhole:blackhole pipeline",
49
+ },
50
+ {
51
+ key: "compactionEngine",
52
+ label: "Compaction engine",
53
+ type: "enum",
54
+ section: "Compaction",
55
+ enumValues: ["blackhole", "pi-default"],
56
+ helpText:
57
+ "blackhole=structured summary+OM, pi-default=built-in Pi summarization",
58
+ },
59
+ {
60
+ key: "tailBehavior",
61
+ label: "Visible tail",
62
+ type: "enum",
63
+ section: "Compaction",
64
+ enumValues: ["minimal", "pi-default"],
65
+ helpText:
66
+ "minimal=keep last user message only (default), pi-default=keep Pi's preserved visible context",
67
+ },
68
+ {
69
+ key: "midRunCompaction",
70
+ label: "Mid-run compaction",
71
+ type: "enum",
72
+ section: "Compaction",
73
+ enumValues: ["resume", "pause", "off"],
74
+ helpText:
75
+ "resume=compact at threshold during tool loops and continue (default), pause=compact and stop, off=only check when run ends",
76
+ },
77
+ {
78
+ key: "compactAfterTokens",
79
+ label: "Auto-compact threshold",
80
+ type: "number",
81
+ section: "Compaction",
82
+ helpText: "Token count that triggers auto-compaction when reached",
83
+ },
84
+
85
+ // ── Observational Memory ──
86
+ {
87
+ key: "memory",
88
+ label: "Observational memory",
89
+ type: "boolean",
90
+ section: "Observational Memory",
91
+ helpText:
92
+ "Enable OM workers (observer, reflector, dropper) and content injection",
93
+ },
94
+ {
95
+ key: "sessionFallback",
96
+ label: "Session model fallback",
97
+ type: "boolean",
98
+ section: "Observational Memory",
99
+ helpText:
100
+ "off=skip stage when all OM models fail, instead of falling back to the main coding model",
101
+ },
102
+ {
103
+ key: "observeAfterTokens",
104
+ label: "Observer threshold",
105
+ type: "number",
106
+ section: "Observational Memory",
107
+ helpText:
108
+ "Tokens accumulated since last observer run before triggering next observe",
109
+ },
110
+ {
111
+ key: "reflectAfterTokens",
112
+ label: "Reflect + dropper threshold",
113
+ type: "number",
114
+ section: "Observational Memory",
115
+ helpText:
116
+ "Tokens accumulated since last reflect before triggering reflector and dropper",
117
+ },
118
+ {
119
+ key: "observationsPoolMaxTokens",
120
+ label: "Observation pool max",
121
+ type: "number",
122
+ section: "Observational Memory",
123
+ helpText:
124
+ "Max tokens in observation pool before dropper prunes (fold pressure)",
125
+ },
126
+ {
127
+ key: "observationsPoolTargetTokens",
128
+ label: "Observation pool target",
129
+ type: "number",
130
+ section: "Observational Memory",
131
+ helpText:
132
+ "Target tokens after dropper prunes (defaults to half of pool max)",
133
+ },
134
+ {
135
+ key: "reflectorInputMaxTokens",
136
+ label: "Reflector input max",
137
+ type: "number",
138
+ section: "Observational Memory",
139
+ helpText:
140
+ "Max prompt tokens for reflector model input (rolling window cap)",
141
+ },
142
+ {
143
+ key: "dropperInputMaxTokens",
144
+ label: "Dropper input max",
145
+ type: "number",
146
+ section: "Observational Memory",
147
+ helpText: "Max prompt tokens for dropper model input (rolling window cap)",
148
+ },
149
+ {
150
+ key: "observerChunkMaxTokens",
151
+ label: "Observer chunk max",
152
+ type: "number",
153
+ section: "Observational Memory",
154
+ helpText: "Max source entry tokens sent to observer per chunk",
155
+ },
156
+ {
157
+ key: "observerPreambleMaxTokens",
158
+ label: "Observer preamble max",
159
+ type: "number",
160
+ section: "Observational Memory",
161
+ helpText:
162
+ "Preamble budget in manual compaction mode (0=auto-compute 30% of chunk)",
163
+ },
164
+ {
165
+ key: "dropperPressureThreshold",
166
+ label: "Dropper pressure threshold",
167
+ type: "number",
168
+ section: "Observational Memory",
169
+ helpText:
170
+ "Fraction of reflectorInputMaxTokens that triggers pressure-driven dropper (0-1, default 0.70)",
171
+ },
172
+ {
173
+ key: "agentMaxTurns",
174
+ label: "Max turns per agent",
175
+ type: "number",
176
+ section: "Observational Memory",
177
+ helpText: "Shared turn cap for background memory agents",
178
+ },
179
+ {
180
+ key: "fullFoldAlways",
181
+ label: "Preserve OM on first compaction",
182
+ type: "boolean",
183
+ section: "Observational Memory",
184
+ helpText:
185
+ "When true, early reflections/drops survive the first compaction in a fresh session",
186
+ },
187
+
188
+ // ── Debug ──
189
+ {
190
+ key: "debug",
191
+ label: "Debug snapshots",
192
+ type: "boolean",
193
+ section: "Debug",
194
+ helpText: "Write detailed debug snapshots to /tmp/pi-blackhole-debug.json",
195
+ },
196
+ {
197
+ key: "debugLog",
198
+ label: "Debug JSONL logging",
199
+ type: "boolean",
200
+ section: "Debug",
201
+ helpText: "Write structured JSONL debug logs to agent directory",
202
+ },
85
203
  ];
86
204
 
87
205
  // ---------------------------------------------------------------------------
@@ -89,13 +207,13 @@ const FIELDS: FieldDef[] = [
89
207
  // ---------------------------------------------------------------------------
90
208
 
91
209
  function formatValue(def: FieldDef, rawValue: unknown): string {
92
- if (rawValue === undefined || rawValue === null) return "";
93
- switch (def.type) {
94
- case "boolean":
95
- return rawValue ? "on" : "off";
96
- default:
97
- return String(rawValue);
98
- }
210
+ if (rawValue === undefined || rawValue === null) return "";
211
+ switch (def.type) {
212
+ case "boolean":
213
+ return rawValue ? "on" : "off";
214
+ default:
215
+ return String(rawValue);
216
+ }
99
217
  }
100
218
 
101
219
  // ---------------------------------------------------------------------------
@@ -103,11 +221,11 @@ function formatValue(def: FieldDef, rawValue: unknown): string {
103
221
  // ---------------------------------------------------------------------------
104
222
 
105
223
  type ThemeShim = {
106
- fg: (style: string, text: string) => string;
224
+ fg: (style: string, text: string) => string;
107
225
  };
108
226
 
109
227
  const EMPTY_THEME: ThemeShim = {
110
- fg: (_s: string, t: string) => t,
228
+ fg: (_s: string, t: string) => t,
111
229
  };
112
230
 
113
231
  // ---------------------------------------------------------------------------
@@ -115,9 +233,9 @@ const EMPTY_THEME: ThemeShim = {
115
233
  // ---------------------------------------------------------------------------
116
234
 
117
235
  export interface OverlayResult {
118
- saved: boolean;
119
- path: string;
120
- error?: string;
236
+ saved: boolean;
237
+ path: string;
238
+ error?: string;
121
239
  }
122
240
 
123
241
  /**
@@ -132,292 +250,337 @@ export interface OverlayResult {
132
250
  * ```
133
251
  */
134
252
  export function createConfigureOverlay(
135
- configPath: string,
136
- theme: unknown,
137
- tui: { requestRender: () => void },
138
- done: (result: OverlayResult | undefined) => void,
139
- ): { render(width: number): string[]; handleInput(data: string): void; invalidate(): void; dispose(): void } {
140
- const th: ThemeShim = (theme && typeof (theme as Record<string, unknown>).fg === "function")
141
- ? theme as ThemeShim
142
- : EMPTY_THEME;
143
-
144
- // Parse config file
145
- let raw: Record<string, unknown>;
146
- let fileError: string | undefined;
147
- try {
148
- raw = JSON.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>;
149
- } catch (e) {
150
- if (existsSync(configPath)) {
151
- fileError = `Config file has invalid JSON: ${(e as Error).message}. Edit the file directly to fix it.`;
152
- }
153
- raw = {};
154
- }
155
-
156
- const defaults = DEFAULTS as unknown as Record<string, unknown>;
157
- const fields: FieldState[] = FIELDS.map((def) => ({
158
- def,
159
- value: formatValue(def, def.key in raw ? raw[def.key] : defaults[def.key]),
160
- editing: false,
161
- cursor: 0,
162
- }));
163
-
164
- let selectedIndex = 0;
165
- let cachedLines: string[] | undefined;
166
-
167
- function invalidate(): void {
168
- cachedLines = undefined;
169
- try { tui.requestRender(); } catch { /* no-op */ }
170
- }
171
-
172
- function save(): boolean {
173
- try {
174
- const updated = { ...raw };
175
- for (const f of fields) {
176
- const val = f.value;
177
- if (val === "" && !(f.def.key in raw)) continue;
178
- switch (f.def.type) {
179
- case "boolean":
180
- updated[f.def.key] = val === "on";
181
- break;
182
- case "number": {
183
- const num = Number(val);
184
- if (val && !isNaN(num)) {
185
- if (f.def.key === "dropperPressureThreshold") {
186
- // Clamp to (0, 1] — runtime validates on reload
187
- updated[f.def.key] = Math.min(1, Math.max(0.01, num));
188
- } else {
189
- updated[f.def.key] = num;
190
- }
191
- } else {
192
- updated[f.def.key] = raw[f.def.key];
193
- }
194
- break;
195
- }
196
- case "enum":
197
- updated[f.def.key] = val;
198
- break;
199
- default:
200
- updated[f.def.key] = val;
201
- break;
202
- }
203
- }
204
- mkdirSync(dirname(configPath), { recursive: true });
205
- writeFileSync(configPath, JSON.stringify(updated, null, 2) + "\n");
206
- raw = updated;
207
- return true;
208
- } catch {
209
- return false;
210
- }
211
- }
212
-
213
- function handleInput(data: string): void {
214
- const cur = fields[selectedIndex];
215
- if (!cur) return;
216
-
217
- // While editing a number field
218
- if (cur.editing) {
219
- if (matchesKey(data, "escape")) {
220
- cur.value = formatValue(cur.def, raw[cur.def.key]);
221
- cur.editing = false;
222
- invalidate();
223
- return;
224
- }
225
- if (matchesKey(data, "enter") || matchesKey(data, "tab")) {
226
- cur.editing = false;
227
- invalidate();
228
- return;
229
- }
230
- if (matchesKey(data, "backspace")) {
231
- if (cur.cursor > 0) {
232
- cur.value = cur.value.slice(0, cur.cursor - 1) + cur.value.slice(cur.cursor);
233
- cur.cursor--;
234
- invalidate();
235
- }
236
- return;
237
- }
238
- if (matchesKey(data, "left")) {
239
- cur.cursor = Math.max(0, cur.cursor - 1);
240
- invalidate();
241
- return;
242
- }
243
- if (matchesKey(data, "right")) {
244
- cur.cursor = Math.min(cur.value.length, cur.cursor + 1);
245
- invalidate();
246
- return;
247
- }
248
- const digit = decodeKittyPrintable(data) ?? data;
249
- if (digit.length === 1 && digit >= "0" && digit <= "9") {
250
- cur.value = cur.value.slice(0, cur.cursor) + digit + cur.value.slice(cur.cursor);
251
- cur.cursor++;
252
- invalidate();
253
- }
254
- return;
255
- }
256
-
257
- // Not editing — global navigation
258
-
259
- // Ctrl+S → save and close
260
- if (matchesKey(data, "ctrl+s")) {
261
- if (fileError) {
262
- done({ saved: false, path: configPath, error: fileError });
263
- return;
264
- }
265
- const saved = save();
266
- done({ saved, path: configPath, error: saved ? undefined : "Failed to write config file" });
267
- return;
268
- }
269
-
270
- // Esc → close without saving
271
- if (matchesKey(data, "escape")) {
272
- done(undefined);
273
- return;
274
- }
275
-
276
- // Enter/space → edit/toggle
277
- if (matchesKey(data, "enter") || matchesKey(data, "space")) {
278
- switch (cur.def.type) {
279
- case "boolean":
280
- cur.value = cur.value === "on" ? "off" : "on";
281
- invalidate();
282
- break;
283
- case "enum": {
284
- const vals = cur.def.enumValues;
285
- if (!vals || vals.length === 0) return;
286
- const idx = vals.indexOf(cur.value);
287
- cur.value = vals[(idx + 1) % vals.length];
288
- invalidate();
289
- break;
290
- }
291
- case "number":
292
- cur.editing = true;
293
- cur.cursor = cur.value.length;
294
- invalidate();
295
- break;
296
- }
297
- return;
298
- }
299
-
300
- // ↑↓ navigation
301
- if (matchesKey(data, "up")) {
302
- selectedIndex = Math.max(0, selectedIndex - 1);
303
- invalidate();
304
- return;
305
- }
306
- if (matchesKey(data, "down")) {
307
- selectedIndex = Math.min(fields.length - 1, selectedIndex + 1);
308
- invalidate();
309
- return;
310
- }
311
- }
312
-
313
- /** Compute minimum width to fit all content, plus borders. */
314
- function contentWidth(): number {
315
- const longestHelp = FIELDS.reduce((max, f) => {
316
- if (!f.helpText) return max;
317
- return Math.max(max, visibleWidth(f.helpText));
318
- }, 0);
319
- // Help line: │ ${help} ${│ → 4 chars overhead + at least 1 space
320
- const helpOverhead = 4;
321
- const longestLabel = FIELDS.reduce((max, f) => {
322
- return Math.max(max, visibleWidth(f.label) + 3); // prefix + space + label
323
- }, 0);
324
- // Section header: ── N ── → up to ~30, but help text dominates
325
- // Hint line: ~54 visible
326
- return Math.max(54 + 4, longestHelp + helpOverhead, longestLabel + 20);
327
- }
328
-
329
- function render(width: number): string[] {
330
- if (cachedLines) return cachedLines;
331
-
332
- const minW = contentWidth();
333
- const w = Math.max(2, Math.min(width - 2, Math.max(minW, 50)));
334
- const innerW = w - 4;
335
- const fg = (style: string, text: string) => th.fg(style, text);
336
-
337
- const lines: string[] = [];
338
-
339
- // Top border + header
340
- lines.push(fg("border", `╭${"─".repeat(w - 2)}╮`));
341
- lines.push(fg("border", `│ ${fg("accent", "Blackhole Configuration")}${" ".repeat(Math.max(0, innerW + 1 - 24))}│`));
342
- lines.push(fg("border", `├${"─".repeat(w - 2)}┤`));
343
-
344
- // Error banner when config file has invalid JSON
345
- if (fileError) {
346
- lines.push(fg("border", `│${" ".repeat(w - 2)}│`));
347
- const errorPrefix = fg("error", " ERROR:");
348
- const errorText = fg("error", fileError);
349
- const errorLine = `│ ${errorPrefix} ${errorText}${" ".repeat(Math.max(0, innerW - visibleWidth(errorPrefix) - 1 - visibleWidth(errorText)))}│`;
350
- lines.push(errorLine);
351
- lines.push(fg("border", `│${" ".repeat(w - 2)}│`));
352
- }
353
-
354
- let currentSection = "";
355
-
356
- for (let i = 0; i < fields.length; i++) {
357
- const f = fields[i];
358
- const isSelected = i === selectedIndex;
359
- const isEditing = isSelected && f.editing;
360
-
361
- // Section header
362
- if (f.def.section !== currentSection) {
363
- currentSection = f.def.section;
364
- if (i > 0) {
365
- lines.push(fg("border", `│${" ".repeat(w - 2)}│`));
366
- }
367
- lines.push(fg("border", `│ ${fg("dim", `── ${currentSection} ──`)}${" ".repeat(Math.max(0, innerW + 1 - currentSection.length - 6))}│`));
368
- }
369
-
370
- // Field label
371
- const prefix = isSelected ? fg("accent", isEditing ? ">>" : " >") : " ";
372
- const label = isSelected ? fg("accent", `${f.def.label}:`) : fg("text", `${f.def.label}:`);
373
- const labelStr = `${prefix} ${label}`;
374
- const labelVis = visibleWidth(labelStr);
375
-
376
- // Value
377
- let valueStr: string;
378
- if (isEditing) {
379
- const before = f.value.slice(0, f.cursor);
380
- const cursorChar = f.cursor < f.value.length ? f.value[f.cursor] : " ";
381
- const after = f.value.slice(f.cursor + 1);
382
- valueStr = `${before}\x1b[7m${cursorChar}\x1b[27m${after}`;
383
- } else {
384
- switch (f.def.type) {
385
- case "boolean":
386
- valueStr = f.value === "on" ? fg("success", "on") : fg("muted", "off");
387
- break;
388
- default:
389
- valueStr = f.value ? fg("text", f.value) : fg("dim", "(empty)");
390
- break;
391
- }
392
- }
393
-
394
- const valSpace = Math.max(10, innerW - labelVis - 1);
395
- const truncated = visibleWidth(valueStr) > valSpace
396
- ? valueStr.slice(0, Math.max(0, valSpace - 3)) + "..."
397
- : valueStr;
398
- const remaining = innerW + 1 - labelVis - visibleWidth(truncated);
399
-
400
- lines.push(fg("border", `│ ${labelStr}${truncated}${" ".repeat(Math.max(1, remaining))}│`));
401
-
402
- // Help text for selected item
403
- if (isSelected && f.def.helpText && !isEditing) {
404
- const help = fg("dim", f.def.helpText);
405
- const helpRemaining = innerW - visibleWidth(help);
406
- lines.push(fg("border", `│ ${help}${" ".repeat(Math.max(1, helpRemaining))}│`));
407
- }
408
- }
409
-
410
- // Bottom hints
411
- lines.push(fg("border", `│${" ".repeat(w - 2)}│`));
412
- const hintText = fileError
413
- ? " Ctrl+S blocked ↑↓ navigate Esc cancel (fix JSON first) "
414
- : " Ctrl+S save \u2191\u2193 navigate Enter toggle Esc cancel ";
415
- lines.push(fg("border", `│${fg("accent", hintText)}${" ".repeat(Math.max(1, innerW + 2 - visibleWidth(hintText)))}│`));
416
- lines.push(fg("border", `╰${"".repeat(w - 2)}╯`));
417
-
418
- cachedLines = lines;
419
- return lines;
420
- }
421
-
422
- return { render, handleInput, invalidate, dispose: () => {} };
253
+ configPath: string,
254
+ theme: unknown,
255
+ tui: { requestRender: () => void },
256
+ done: (result: OverlayResult | undefined) => void,
257
+ ): {
258
+ render(width: number): string[];
259
+ handleInput(data: string): void;
260
+ invalidate(): void;
261
+ dispose(): void;
262
+ } {
263
+ const th: ThemeShim =
264
+ theme && typeof (theme as Record<string, unknown>).fg === "function"
265
+ ? (theme as ThemeShim)
266
+ : EMPTY_THEME;
267
+
268
+ // Parse config file
269
+ let raw: Record<string, unknown>;
270
+ let fileError: string | undefined;
271
+ try {
272
+ raw = JSON.parse(readFileSync(configPath, "utf8")) as Record<
273
+ string,
274
+ unknown
275
+ >;
276
+ } catch (e) {
277
+ if (existsSync(configPath)) {
278
+ fileError = `Config file has invalid JSON: ${(e as Error).message}. Edit the file directly to fix it.`;
279
+ }
280
+ raw = {};
281
+ }
282
+
283
+ const defaults = DEFAULTS as unknown as Record<string, unknown>;
284
+ const fields: FieldState[] = FIELDS.map((def) => ({
285
+ def,
286
+ value: formatValue(def, def.key in raw ? raw[def.key] : defaults[def.key]),
287
+ editing: false,
288
+ cursor: 0,
289
+ }));
290
+
291
+ let selectedIndex = 0;
292
+ let cachedLines: string[] | undefined;
293
+
294
+ function invalidate(): void {
295
+ cachedLines = undefined;
296
+ try {
297
+ tui.requestRender();
298
+ } catch {
299
+ /* no-op */
300
+ }
301
+ }
302
+
303
+ function save(): boolean {
304
+ try {
305
+ const updated = { ...raw };
306
+ for (const f of fields) {
307
+ const val = f.value;
308
+ if (val === "" && !(f.def.key in raw)) continue;
309
+ switch (f.def.type) {
310
+ case "boolean":
311
+ updated[f.def.key] = val === "on";
312
+ break;
313
+ case "number": {
314
+ const num = Number(val);
315
+ if (val && !isNaN(num)) {
316
+ if (f.def.key === "dropperPressureThreshold") {
317
+ // Clamp to (0, 1] — runtime validates on reload
318
+ updated[f.def.key] = Math.min(1, Math.max(0.01, num));
319
+ } else {
320
+ updated[f.def.key] = num;
321
+ }
322
+ } else {
323
+ updated[f.def.key] = raw[f.def.key];
324
+ }
325
+ break;
326
+ }
327
+ case "enum":
328
+ updated[f.def.key] = val;
329
+ break;
330
+ default:
331
+ updated[f.def.key] = val;
332
+ break;
333
+ }
334
+ }
335
+ mkdirSync(dirname(configPath), { recursive: true });
336
+ writeFileSync(configPath, JSON.stringify(updated, null, 2) + "\n");
337
+ raw = updated;
338
+ return true;
339
+ } catch {
340
+ return false;
341
+ }
342
+ }
343
+
344
+ function handleInput(data: string): void {
345
+ const cur = fields[selectedIndex];
346
+ if (!cur) return;
347
+
348
+ // While editing a number field
349
+ if (cur.editing) {
350
+ if (matchesKey(data, "escape")) {
351
+ cur.value = formatValue(cur.def, raw[cur.def.key]);
352
+ cur.editing = false;
353
+ invalidate();
354
+ return;
355
+ }
356
+ if (matchesKey(data, "enter") || matchesKey(data, "tab")) {
357
+ cur.editing = false;
358
+ invalidate();
359
+ return;
360
+ }
361
+ if (matchesKey(data, "backspace")) {
362
+ if (cur.cursor > 0) {
363
+ cur.value =
364
+ cur.value.slice(0, cur.cursor - 1) + cur.value.slice(cur.cursor);
365
+ cur.cursor--;
366
+ invalidate();
367
+ }
368
+ return;
369
+ }
370
+ if (matchesKey(data, "left")) {
371
+ cur.cursor = Math.max(0, cur.cursor - 1);
372
+ invalidate();
373
+ return;
374
+ }
375
+ if (matchesKey(data, "right")) {
376
+ cur.cursor = Math.min(cur.value.length, cur.cursor + 1);
377
+ invalidate();
378
+ return;
379
+ }
380
+ const digit = decodeKittyPrintable(data) ?? data;
381
+ if (digit.length === 1 && digit >= "0" && digit <= "9") {
382
+ cur.value =
383
+ cur.value.slice(0, cur.cursor) + digit + cur.value.slice(cur.cursor);
384
+ cur.cursor++;
385
+ invalidate();
386
+ }
387
+ return;
388
+ }
389
+
390
+ // Not editing — global navigation
391
+
392
+ // Ctrl+S → save and close
393
+ if (matchesKey(data, "ctrl+s")) {
394
+ if (fileError) {
395
+ done({ saved: false, path: configPath, error: fileError });
396
+ return;
397
+ }
398
+ const saved = save();
399
+ done({
400
+ saved,
401
+ path: configPath,
402
+ error: saved ? undefined : "Failed to write config file",
403
+ });
404
+ return;
405
+ }
406
+
407
+ // Esc → close without saving
408
+ if (matchesKey(data, "escape")) {
409
+ done(undefined);
410
+ return;
411
+ }
412
+
413
+ // Enter/space → edit/toggle
414
+ if (matchesKey(data, "enter") || matchesKey(data, "space")) {
415
+ switch (cur.def.type) {
416
+ case "boolean":
417
+ cur.value = cur.value === "on" ? "off" : "on";
418
+ invalidate();
419
+ break;
420
+ case "enum": {
421
+ const vals = cur.def.enumValues;
422
+ if (!vals || vals.length === 0) return;
423
+ const idx = vals.indexOf(cur.value);
424
+ cur.value = vals[(idx + 1) % vals.length];
425
+ invalidate();
426
+ break;
427
+ }
428
+ case "number":
429
+ cur.editing = true;
430
+ cur.cursor = cur.value.length;
431
+ invalidate();
432
+ break;
433
+ }
434
+ return;
435
+ }
436
+
437
+ // ↑↓ navigation
438
+ if (matchesKey(data, "up")) {
439
+ selectedIndex = Math.max(0, selectedIndex - 1);
440
+ invalidate();
441
+ return;
442
+ }
443
+ if (matchesKey(data, "down")) {
444
+ selectedIndex = Math.min(fields.length - 1, selectedIndex + 1);
445
+ invalidate();
446
+ return;
447
+ }
448
+ }
449
+
450
+ /** Compute minimum width to fit all content, plus borders. */
451
+ function contentWidth(): number {
452
+ const longestHelp = FIELDS.reduce((max, f) => {
453
+ if (!f.helpText) return max;
454
+ return Math.max(max, visibleWidth(f.helpText));
455
+ }, 0);
456
+ // Help line: │ ${help} ${│ → 4 chars overhead + at least 1 space
457
+ const helpOverhead = 4;
458
+ const longestLabel = FIELDS.reduce((max, f) => {
459
+ return Math.max(max, visibleWidth(f.label) + 3); // prefix + space + label
460
+ }, 0);
461
+ // Section header: ── N ── → up to ~30, but help text dominates
462
+ // Hint line: ~54 visible
463
+ return Math.max(54 + 4, longestHelp + helpOverhead, longestLabel + 20);
464
+ }
465
+
466
+ function render(width: number): string[] {
467
+ if (cachedLines) return cachedLines;
468
+
469
+ const minW = contentWidth();
470
+ const w = Math.max(2, Math.min(width - 2, Math.max(minW, 50)));
471
+ const innerW = w - 4;
472
+ const fg = (style: string, text: string) => th.fg(style, text);
473
+
474
+ const lines: string[] = [];
475
+
476
+ // Top border + header
477
+ lines.push(fg("border", `╭${"─".repeat(w - 2)}╮`));
478
+ lines.push(
479
+ fg(
480
+ "border",
481
+ `│ ${fg("accent", "Blackhole Configuration")}${" ".repeat(Math.max(0, innerW + 1 - 24))}│`,
482
+ ),
483
+ );
484
+ lines.push(fg("border", `├${"─".repeat(w - 2)}┤`));
485
+
486
+ // Error banner when config file has invalid JSON
487
+ if (fileError) {
488
+ lines.push(fg("border", `│${" ".repeat(w - 2)}│`));
489
+ const errorPrefix = fg("error", " ERROR:");
490
+ const errorText = fg("error", fileError);
491
+ const errorLine = `│ ${errorPrefix} ${errorText}${" ".repeat(Math.max(0, innerW - visibleWidth(errorPrefix) - 1 - visibleWidth(errorText)))}│`;
492
+ lines.push(errorLine);
493
+ lines.push(fg("border", `│${" ".repeat(w - 2)}│`));
494
+ }
495
+
496
+ let currentSection = "";
497
+
498
+ for (let i = 0; i < fields.length; i++) {
499
+ const f = fields[i];
500
+ const isSelected = i === selectedIndex;
501
+ const isEditing = isSelected && f.editing;
502
+
503
+ // Section header
504
+ if (f.def.section !== currentSection) {
505
+ currentSection = f.def.section;
506
+ if (i > 0) {
507
+ lines.push(fg("border", `│${" ".repeat(w - 2)}│`));
508
+ }
509
+ lines.push(
510
+ fg(
511
+ "border",
512
+ `│ ${fg("dim", `── ${currentSection} ──`)}${" ".repeat(Math.max(0, innerW + 1 - currentSection.length - 6))}│`,
513
+ ),
514
+ );
515
+ }
516
+
517
+ // Field label
518
+ const prefix = isSelected ? fg("accent", isEditing ? ">>" : " >") : " ";
519
+ const label = isSelected
520
+ ? fg("accent", `${f.def.label}:`)
521
+ : fg("text", `${f.def.label}:`);
522
+ const labelStr = `${prefix} ${label}`;
523
+ const labelVis = visibleWidth(labelStr);
524
+
525
+ // Value
526
+ let valueStr: string;
527
+ if (isEditing) {
528
+ const before = f.value.slice(0, f.cursor);
529
+ const cursorChar = f.cursor < f.value.length ? f.value[f.cursor] : " ";
530
+ const after = f.value.slice(f.cursor + 1);
531
+ valueStr = `${before}\x1b[7m${cursorChar}\x1b[27m${after}`;
532
+ } else {
533
+ switch (f.def.type) {
534
+ case "boolean":
535
+ valueStr =
536
+ f.value === "on" ? fg("success", "on") : fg("muted", "off");
537
+ break;
538
+ default:
539
+ valueStr = f.value ? fg("text", f.value) : fg("dim", "(empty)");
540
+ break;
541
+ }
542
+ }
543
+
544
+ const valSpace = Math.max(10, innerW - labelVis - 1);
545
+ const truncated =
546
+ visibleWidth(valueStr) > valSpace
547
+ ? valueStr.slice(0, Math.max(0, valSpace - 3)) + "..."
548
+ : valueStr;
549
+ const remaining = innerW + 1 - labelVis - visibleWidth(truncated);
550
+
551
+ lines.push(
552
+ fg(
553
+ "border",
554
+ `│ ${labelStr}${truncated}${" ".repeat(Math.max(1, remaining))}│`,
555
+ ),
556
+ );
557
+
558
+ // Help text for selected item
559
+ if (isSelected && f.def.helpText && !isEditing) {
560
+ const help = fg("dim", f.def.helpText);
561
+ const helpRemaining = innerW - visibleWidth(help);
562
+ lines.push(
563
+ fg("border", `│ ${help}${" ".repeat(Math.max(1, helpRemaining))}│`),
564
+ );
565
+ }
566
+ }
567
+
568
+ // Bottom hints
569
+ lines.push(fg("border", `│${" ".repeat(w - 2)}│`));
570
+ const hintText = fileError
571
+ ? " Ctrl+S blocked ↑↓ navigate Esc cancel (fix JSON first) "
572
+ : " Ctrl+S save \u2191\u2193 navigate Enter toggle Esc cancel ";
573
+ lines.push(
574
+ fg(
575
+ "border",
576
+ `│${fg("accent", hintText)}${" ".repeat(Math.max(1, innerW + 2 - visibleWidth(hintText)))}│`,
577
+ ),
578
+ );
579
+ lines.push(fg("border", `╰${"─".repeat(w - 2)}╯`));
580
+
581
+ cachedLines = lines;
582
+ return lines;
583
+ }
584
+
585
+ return { render, handleInput, invalidate, dispose: () => {} };
423
586
  }