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.
- package/README.md +15 -0
- package/dist/index.js +11660 -0
- package/dist/index.js.map +1 -0
- package/example-config.json +1 -1
- package/index.ts +37 -63
- package/package.json +21 -9
- package/src/commands/cleanup.ts +279 -240
- package/src/commands/memory.ts +236 -184
- package/src/commands/pi-vcc.ts +202 -152
- package/src/commands/vcc-recall.ts +126 -95
- package/src/core/brief.ts +167 -33
- package/src/core/build-sections.ts +8 -2
- package/src/core/config-env.ts +117 -0
- package/src/core/content.ts +31 -7
- package/src/core/drill-down.ts +41 -11
- package/src/core/filter-noise.ts +9 -3
- package/src/core/format-recall.ts +15 -6
- package/src/core/format.ts +14 -4
- package/src/core/lineage.ts +9 -3
- package/src/core/load-messages.ts +24 -5
- package/src/core/normalize.ts +38 -14
- package/src/core/recall-scope.ts +11 -3
- package/src/core/render-entries.ts +22 -6
- package/src/core/sanitize.ts +5 -1
- package/src/core/search-entries.ts +111 -19
- package/src/core/settings.ts +1 -3
- package/src/core/summarize.ts +42 -21
- package/src/core/unified-config.ts +549 -411
- package/src/extract/commits.ts +4 -2
- package/src/extract/files.ts +10 -5
- package/src/extract/goals.ts +7 -2
- package/src/hooks/before-compact.ts +210 -88
- package/src/om/agents/dropper/agent.ts +380 -265
- package/src/om/agents/dropper/coverage.ts +102 -82
- package/src/om/agents/observer/agent.ts +242 -206
- package/src/om/agents/reflector/agent.ts +212 -153
- package/src/om/cleanup.ts +239 -218
- package/src/om/clipboard.ts +59 -51
- package/src/om/compaction-trigger.ts +448 -333
- package/src/om/config.ts +13 -6
- package/src/om/configure-overlay.ts +518 -355
- package/src/om/consolidation.ts +1460 -953
- package/src/om/cooldown.ts +75 -65
- package/src/om/debug-log.ts +86 -68
- package/src/om/ids.ts +1 -1
- package/src/om/ledger/fold.ts +89 -78
- package/src/om/ledger/progress.ts +181 -153
- package/src/om/ledger/projection.ts +248 -185
- package/src/om/ledger/recall.ts +247 -196
- package/src/om/ledger/render-summary.ts +79 -50
- package/src/om/ledger/types.ts +146 -117
- package/src/om/model-budget.ts +23 -13
- package/src/om/pending.ts +243 -179
- package/src/om/provider-stream.ts +52 -7
- package/src/om/retryable-error.ts +12 -16
- package/src/om/reverse-recall.ts +97 -91
- package/src/om/runtime.ts +474 -375
- package/src/om/serialize.ts +190 -166
- package/src/om/status-overlay.ts +246 -195
- package/src/om/tokens.ts +28 -21
- package/src/pi-base/blackhole-settings.ts +437 -0
- package/src/pi-base/config-manager.ts +440 -0
- package/src/pi-base/config.ts +469 -0
- package/src/pi-base/env.ts +43 -0
- package/src/pi-base/paths.ts +47 -0
- package/src/pi-base/settings/body.ts +1648 -0
- package/src/pi-base/settings/fields/action.ts +43 -0
- package/src/pi-base/settings/fields/boolean.ts +47 -0
- package/src/pi-base/settings/fields/custom.ts +72 -0
- package/src/pi-base/settings/fields/enum.ts +310 -0
- package/src/pi-base/settings/fields/index.ts +46 -0
- package/src/pi-base/settings/fields/model.ts +452 -0
- package/src/pi-base/settings/fields/string.ts +527 -0
- package/src/pi-base/settings/fields/text.ts +115 -0
- package/src/pi-base/settings/frame.ts +197 -0
- package/src/pi-base/settings/index.ts +77 -0
- package/src/pi-base/settings/inline-edit.ts +313 -0
- package/src/pi-base/settings/modal.ts +152 -0
- package/src/pi-base/settings/types.ts +500 -0
- package/src/pi-base/settings/validate-field.ts +113 -0
- package/src/pi-base/shell.ts +117 -0
- package/src/pi-base/types.ts +6 -0
- package/src/pi-base/ui.ts +32 -0
- package/src/tools/recall.ts +347 -225
- package/src/types.ts +20 -3
- package/tsup.config.ts +23 -0
- package/vitest.config.ts +15 -15
|
@@ -17,19 +17,19 @@ import { dirname } from "node:path";
|
|
|
17
17
|
// ---------------------------------------------------------------------------
|
|
18
18
|
|
|
19
19
|
interface FieldDef {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
224
|
+
fg: (style: string, text: string) => string;
|
|
107
225
|
};
|
|
108
226
|
|
|
109
227
|
const EMPTY_THEME: ThemeShim = {
|
|
110
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
): {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
}
|