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
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renderer for inline-edit text fields: `string`, `number`, `secret`,
|
|
3
|
+
* `path`. They share a single state machine — the only differences are:
|
|
4
|
+
*
|
|
5
|
+
* - `number` parses + validates in `commitFromBuffer`.
|
|
6
|
+
* - `secret` masks the displayed value when not editing.
|
|
7
|
+
* - `path` is identical to `string` today (kept distinct so future
|
|
8
|
+
* completion / validation can hang off the type without
|
|
9
|
+
* another flag).
|
|
10
|
+
*
|
|
11
|
+
* Editing flow:
|
|
12
|
+
* 1. User presses Enter on the row → `setEditing(true)` and seed the
|
|
13
|
+
* buffer from the current value.
|
|
14
|
+
* 2. Subsequent keystrokes are routed to `handleInlineEditInput`.
|
|
15
|
+
* 3. Enter commits, Esc cancels, both call `setEditing(false)`.
|
|
16
|
+
* 4. The modal re-reads the buffer on every render, so the user sees
|
|
17
|
+
* live feedback as they type.
|
|
18
|
+
*
|
|
19
|
+
* The buffer state is stored on the modal side (one InlineEditState per
|
|
20
|
+
* row keyed by `field.key`) so renderers stay stateless.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
matchesKey,
|
|
25
|
+
type Component,
|
|
26
|
+
type SelectItem,
|
|
27
|
+
} from "@earendil-works/pi-tui";
|
|
28
|
+
import { SelectList } from "@earendil-works/pi-tui";
|
|
29
|
+
import { getSelectListTheme } from "@earendil-works/pi-coding-agent";
|
|
30
|
+
import {
|
|
31
|
+
handleInlineEditInput,
|
|
32
|
+
renderInlineEditValue,
|
|
33
|
+
type InlineEditState,
|
|
34
|
+
} from "../inline-edit";
|
|
35
|
+
import type {
|
|
36
|
+
FieldKeyResult,
|
|
37
|
+
FieldRenderer,
|
|
38
|
+
FieldRenderContext,
|
|
39
|
+
NumberField,
|
|
40
|
+
PathField,
|
|
41
|
+
SecretField,
|
|
42
|
+
StringField,
|
|
43
|
+
SubmenuFactory,
|
|
44
|
+
} from "../types";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The modal stores one InlineEditState per editable row. Renderers
|
|
48
|
+
* access it through this lookup, set by the modal in `FieldRenderContext`.
|
|
49
|
+
*
|
|
50
|
+
* We intentionally don't bake the lookup into `FieldRenderContext` to
|
|
51
|
+
* keep the public type surface clean — instead, the modal mutates a
|
|
52
|
+
* weak-keyed registry passed via `(ctx as any).editStates`. The
|
|
53
|
+
* renderers below pull it out in a single helper to keep the cast
|
|
54
|
+
* isolated.
|
|
55
|
+
*/
|
|
56
|
+
function getEditState(
|
|
57
|
+
args: { ctx: unknown },
|
|
58
|
+
key: string,
|
|
59
|
+
): InlineEditState | undefined {
|
|
60
|
+
const registry = (args.ctx as { editStates?: Map<string, InlineEditState> })
|
|
61
|
+
.editStates;
|
|
62
|
+
return registry?.get(key);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function setEditState(
|
|
66
|
+
args: { ctx: unknown },
|
|
67
|
+
key: string,
|
|
68
|
+
state: InlineEditState | undefined,
|
|
69
|
+
): void {
|
|
70
|
+
const registry = (args.ctx as { editStates?: Map<string, InlineEditState> })
|
|
71
|
+
.editStates;
|
|
72
|
+
if (!registry) return;
|
|
73
|
+
if (state === undefined) registry.delete(key);
|
|
74
|
+
else registry.set(key, state);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function maskedSecret(value: string): string {
|
|
78
|
+
if (!value) return "(unset)";
|
|
79
|
+
return "••••••";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function placeholderOrEmpty(
|
|
83
|
+
field: StringField | PathField,
|
|
84
|
+
value: string,
|
|
85
|
+
dim: (s: string) => string,
|
|
86
|
+
): string {
|
|
87
|
+
if (value) return value;
|
|
88
|
+
const placeholder = (field as StringField).placeholder;
|
|
89
|
+
if (placeholder) return dim(placeholder);
|
|
90
|
+
return dim("(unset)");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
94
|
+
// String
|
|
95
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
export const stringRenderer: FieldRenderer<StringField, string> = {
|
|
98
|
+
type: "string",
|
|
99
|
+
renderValue(row, args) {
|
|
100
|
+
const dim = (s: string) => args.ctx.theme.fg("dim", s);
|
|
101
|
+
if (row.field.disabled) {
|
|
102
|
+
const text = placeholderOrEmpty(row.field, row.value, dim);
|
|
103
|
+
return args.ctx.theme.fg("muted", text);
|
|
104
|
+
}
|
|
105
|
+
if (args.isEditing) {
|
|
106
|
+
const state = getEditState(args, row.field.key);
|
|
107
|
+
if (state) {
|
|
108
|
+
return args.ctx.theme.fg("accent", renderInlineEditValue(state));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const text = placeholderOrEmpty(row.field, row.value, dim);
|
|
112
|
+
return args.selected
|
|
113
|
+
? args.ctx.theme.fg("text", text)
|
|
114
|
+
: args.ctx.theme.fg("muted", text);
|
|
115
|
+
},
|
|
116
|
+
hints(row, { isEditing }) {
|
|
117
|
+
if (row.field.disabled) return [];
|
|
118
|
+
if (isEditing) {
|
|
119
|
+
return [
|
|
120
|
+
{ key: "enter", label: "save" },
|
|
121
|
+
{ key: "esc", label: "cancel" },
|
|
122
|
+
{ key: "←/→", label: "move" },
|
|
123
|
+
];
|
|
124
|
+
}
|
|
125
|
+
return [{ key: "enter", label: "edit" }];
|
|
126
|
+
},
|
|
127
|
+
handleKey(row, data, args) {
|
|
128
|
+
if (row.field.disabled) return {};
|
|
129
|
+
return handleStringLikeKey<string>(
|
|
130
|
+
row.field.key,
|
|
131
|
+
row.value,
|
|
132
|
+
data,
|
|
133
|
+
args,
|
|
134
|
+
(buf) => buf,
|
|
135
|
+
);
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
140
|
+
// Path
|
|
141
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
export const pathRenderer: FieldRenderer<PathField, string> = {
|
|
144
|
+
type: "path",
|
|
145
|
+
renderValue(row, args) {
|
|
146
|
+
// Reuse the string renderer's body — `StringField` and `PathField`
|
|
147
|
+
// share identical render shape today; we cross the variant boundary
|
|
148
|
+
// via an `unknown` cast to satisfy TS's nominal discriminator.
|
|
149
|
+
return (
|
|
150
|
+
stringRenderer.renderValue as unknown as FieldRenderer<
|
|
151
|
+
PathField,
|
|
152
|
+
string
|
|
153
|
+
>["renderValue"]
|
|
154
|
+
)(row, args);
|
|
155
|
+
},
|
|
156
|
+
hints(row, args) {
|
|
157
|
+
return (
|
|
158
|
+
stringRenderer.hints as unknown as FieldRenderer<
|
|
159
|
+
PathField,
|
|
160
|
+
string
|
|
161
|
+
>["hints"]
|
|
162
|
+
)(row, args);
|
|
163
|
+
},
|
|
164
|
+
handleKey(row, data, args) {
|
|
165
|
+
if (row.field.disabled) return {};
|
|
166
|
+
return handleStringLikeKey<string>(
|
|
167
|
+
row.field.key,
|
|
168
|
+
row.value,
|
|
169
|
+
data,
|
|
170
|
+
args,
|
|
171
|
+
(buf) => buf,
|
|
172
|
+
);
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
177
|
+
// Secret
|
|
178
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
export const secretRenderer: FieldRenderer<SecretField, string> = {
|
|
181
|
+
type: "secret",
|
|
182
|
+
renderValue(row, args) {
|
|
183
|
+
if (row.field.disabled) {
|
|
184
|
+
const display = maskedSecret(row.value);
|
|
185
|
+
return args.ctx.theme.fg("muted", display);
|
|
186
|
+
}
|
|
187
|
+
if (args.isEditing) {
|
|
188
|
+
const state = getEditState(args, row.field.key);
|
|
189
|
+
if (state) {
|
|
190
|
+
// Mask in place: render the buffer through the cursor block but
|
|
191
|
+
// replace every non-cursor char with `•` so over-the-shoulder
|
|
192
|
+
// viewers can't see the secret as it's being typed.
|
|
193
|
+
const masked = "•".repeat(state.buffer.length);
|
|
194
|
+
const view: { buffer: string; cursor: number } = {
|
|
195
|
+
buffer: masked,
|
|
196
|
+
cursor: state.cursor,
|
|
197
|
+
};
|
|
198
|
+
return args.ctx.theme.fg(
|
|
199
|
+
"accent",
|
|
200
|
+
renderInlineEditValue(view as InlineEditState),
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const display = maskedSecret(row.value);
|
|
205
|
+
return args.selected
|
|
206
|
+
? args.ctx.theme.fg("text", display)
|
|
207
|
+
: args.ctx.theme.fg(row.value ? "success" : "muted", display);
|
|
208
|
+
},
|
|
209
|
+
hints(row, args) {
|
|
210
|
+
return (
|
|
211
|
+
stringRenderer.hints as unknown as FieldRenderer<
|
|
212
|
+
SecretField,
|
|
213
|
+
string
|
|
214
|
+
>["hints"]
|
|
215
|
+
)(row, args);
|
|
216
|
+
},
|
|
217
|
+
handleKey(row, data, args) {
|
|
218
|
+
if (row.field.disabled) return {};
|
|
219
|
+
return handleStringLikeKey<string>(
|
|
220
|
+
row.field.key,
|
|
221
|
+
row.value,
|
|
222
|
+
data,
|
|
223
|
+
args,
|
|
224
|
+
(buf) => buf,
|
|
225
|
+
);
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
230
|
+
// Number — helpers
|
|
231
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
function nextValue(values: readonly number[], current: number): number {
|
|
234
|
+
if (values.length === 0) return current;
|
|
235
|
+
const idx = values.indexOf(current);
|
|
236
|
+
return values[(idx + 1 + values.length) % values.length]!;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function prevValue(values: readonly number[], current: number): number {
|
|
240
|
+
if (values.length === 0) return current;
|
|
241
|
+
const idx = values.indexOf(current);
|
|
242
|
+
return values[(idx - 1 + values.length) % values.length]!;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function stepUp(
|
|
246
|
+
value: number,
|
|
247
|
+
step: number,
|
|
248
|
+
min?: number,
|
|
249
|
+
max?: number,
|
|
250
|
+
): number {
|
|
251
|
+
const next = value + step;
|
|
252
|
+
if (max !== undefined && next > max) return min ?? value;
|
|
253
|
+
return next;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function stepDown(
|
|
257
|
+
value: number,
|
|
258
|
+
step: number,
|
|
259
|
+
min?: number,
|
|
260
|
+
max?: number,
|
|
261
|
+
): number {
|
|
262
|
+
const prev = value - step;
|
|
263
|
+
if (min !== undefined && prev < min) return max ?? value;
|
|
264
|
+
return prev;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function getValueDesc(field: NumberField): string | undefined {
|
|
268
|
+
return field.valueDescriptions?.[String(field.value)];
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function makeNumberValuesSubmenu(
|
|
272
|
+
values: readonly number[],
|
|
273
|
+
current: number,
|
|
274
|
+
_valueDesc: string | undefined,
|
|
275
|
+
ctx: FieldRenderContext,
|
|
276
|
+
): SubmenuFactory<number> {
|
|
277
|
+
return (done) => {
|
|
278
|
+
const items: SelectItem[] = values.map((v) => ({
|
|
279
|
+
value: String(v),
|
|
280
|
+
label: String(v),
|
|
281
|
+
}));
|
|
282
|
+
const list = new SelectList(
|
|
283
|
+
items,
|
|
284
|
+
Math.min(items.length, 12),
|
|
285
|
+
getSelectListTheme(),
|
|
286
|
+
);
|
|
287
|
+
const idx = items.findIndex((i) => Number(i.value) === current);
|
|
288
|
+
list.setSelectedIndex(idx >= 0 ? idx : 0);
|
|
289
|
+
list.onSelect = (item) => done(Number(item.value));
|
|
290
|
+
list.onCancel = () => done();
|
|
291
|
+
|
|
292
|
+
const component: Component = {
|
|
293
|
+
render(width: number): string[] {
|
|
294
|
+
const lines = [...list.render(width)];
|
|
295
|
+
lines.push("");
|
|
296
|
+
return lines;
|
|
297
|
+
},
|
|
298
|
+
invalidate(): void {
|
|
299
|
+
list.invalidate();
|
|
300
|
+
},
|
|
301
|
+
handleInput(data: string): void {
|
|
302
|
+
list.handleInput(data);
|
|
303
|
+
ctx.tui.requestRender();
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
return component;
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
311
|
+
// Number
|
|
312
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
export const numberRenderer: FieldRenderer<NumberField, number> = {
|
|
315
|
+
type: "number",
|
|
316
|
+
renderValue(row, args) {
|
|
317
|
+
if (row.field.disabled) {
|
|
318
|
+
const text = String(row.value);
|
|
319
|
+
return args.ctx.theme.fg("muted", text);
|
|
320
|
+
}
|
|
321
|
+
if (args.isEditing) {
|
|
322
|
+
const state = getEditState(args, row.field.key);
|
|
323
|
+
if (state) {
|
|
324
|
+
return args.ctx.theme.fg("accent", renderInlineEditValue(state));
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const text = String(row.value);
|
|
328
|
+
return args.selected
|
|
329
|
+
? args.ctx.theme.fg("text", text)
|
|
330
|
+
: args.ctx.theme.fg("muted", text);
|
|
331
|
+
},
|
|
332
|
+
hints(row, args) {
|
|
333
|
+
if (row.field.disabled) return [];
|
|
334
|
+
const { values, step } = row.field;
|
|
335
|
+
if (values && values.length > 4)
|
|
336
|
+
return [{ key: "enter", label: "open list" }];
|
|
337
|
+
if (values && values.length > 0) {
|
|
338
|
+
return [
|
|
339
|
+
{ key: "enter/space", label: "cycle" },
|
|
340
|
+
{ key: "←/→", label: "prev/next" },
|
|
341
|
+
];
|
|
342
|
+
}
|
|
343
|
+
if (step !== undefined) {
|
|
344
|
+
return [
|
|
345
|
+
{ key: "enter", label: "edit" },
|
|
346
|
+
{ key: "←/→", label: "step" },
|
|
347
|
+
];
|
|
348
|
+
}
|
|
349
|
+
return (
|
|
350
|
+
stringRenderer.hints as unknown as FieldRenderer<
|
|
351
|
+
NumberField,
|
|
352
|
+
number
|
|
353
|
+
>["hints"]
|
|
354
|
+
)(row, args);
|
|
355
|
+
},
|
|
356
|
+
handleKey(row, data, args) {
|
|
357
|
+
if (row.field.disabled) return {};
|
|
358
|
+
const { values, step } = row.field;
|
|
359
|
+
|
|
360
|
+
// Discrete values: cycle through (like enum) or open submenu
|
|
361
|
+
if (values && values.length > 0) {
|
|
362
|
+
if (
|
|
363
|
+
matchesKey(data, "enter") ||
|
|
364
|
+
matchesKey(data, "return") ||
|
|
365
|
+
data === " "
|
|
366
|
+
) {
|
|
367
|
+
if (values.length > 4) {
|
|
368
|
+
return {
|
|
369
|
+
consumed: true,
|
|
370
|
+
submenu: makeNumberValuesSubmenu(
|
|
371
|
+
values,
|
|
372
|
+
row.value,
|
|
373
|
+
getValueDesc(row.field),
|
|
374
|
+
args.ctx,
|
|
375
|
+
),
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
return { consumed: true, commit: nextValue(values, row.value) };
|
|
379
|
+
}
|
|
380
|
+
if (matchesKey(data, "left")) {
|
|
381
|
+
return { consumed: true, commit: prevValue(values, row.value) };
|
|
382
|
+
}
|
|
383
|
+
if (matchesKey(data, "right")) {
|
|
384
|
+
return { consumed: true, commit: nextValue(values, row.value) };
|
|
385
|
+
}
|
|
386
|
+
return {};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Step-based: ←/→ fine-tune by step (only when not editing — while
|
|
390
|
+
// editing those keys move the cursor). Everything else — Enter to
|
|
391
|
+
// start editing, typing, backspace, escape — goes to the inline
|
|
392
|
+
// string editor so editing mode is never a dead end.
|
|
393
|
+
if (step !== undefined) {
|
|
394
|
+
if (!args.isEditing) {
|
|
395
|
+
if (matchesKey(data, "left")) {
|
|
396
|
+
return {
|
|
397
|
+
consumed: true,
|
|
398
|
+
commit: stepDown(row.value, step, row.field.min, row.field.max),
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
if (matchesKey(data, "right")) {
|
|
402
|
+
return {
|
|
403
|
+
consumed: true,
|
|
404
|
+
commit: stepUp(row.value, step, row.field.min, row.field.max),
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return handleStringLikeKey<number>(
|
|
409
|
+
row.field.key,
|
|
410
|
+
String(row.value),
|
|
411
|
+
data,
|
|
412
|
+
args,
|
|
413
|
+
(buffer) => {
|
|
414
|
+
const trimmed = buffer.trim();
|
|
415
|
+
if (trimmed === "") throw new Error("Expected a number");
|
|
416
|
+
const parsed = Number(trimmed);
|
|
417
|
+
if (!Number.isFinite(parsed))
|
|
418
|
+
throw new Error(`Not a number: '${buffer}'`);
|
|
419
|
+
if (row.field.integer && !Number.isInteger(parsed))
|
|
420
|
+
throw new Error("Expected an integer");
|
|
421
|
+
if (typeof row.field.min === "number" && parsed < row.field.min)
|
|
422
|
+
throw new Error(`Must be ≥ ${row.field.min}`);
|
|
423
|
+
if (typeof row.field.max === "number" && parsed > row.field.max)
|
|
424
|
+
throw new Error(`Must be ≤ ${row.field.max}`);
|
|
425
|
+
return parsed;
|
|
426
|
+
},
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Plain number: inline edit (current behavior)
|
|
431
|
+
return handleStringLikeKey<number>(
|
|
432
|
+
row.field.key,
|
|
433
|
+
String(row.value),
|
|
434
|
+
data,
|
|
435
|
+
args,
|
|
436
|
+
(buffer) => {
|
|
437
|
+
const trimmed = buffer.trim();
|
|
438
|
+
if (trimmed === "") throw new Error("Expected a number");
|
|
439
|
+
const parsed = Number(trimmed);
|
|
440
|
+
if (!Number.isFinite(parsed))
|
|
441
|
+
throw new Error(`Not a number: '${buffer}'`);
|
|
442
|
+
if (row.field.integer && !Number.isInteger(parsed))
|
|
443
|
+
throw new Error("Expected an integer");
|
|
444
|
+
if (typeof row.field.min === "number" && parsed < row.field.min)
|
|
445
|
+
throw new Error(`Must be ≥ ${row.field.min}`);
|
|
446
|
+
if (typeof row.field.max === "number" && parsed > row.field.max)
|
|
447
|
+
throw new Error(`Must be ≤ ${row.field.max}`);
|
|
448
|
+
if (values && !values.includes(parsed))
|
|
449
|
+
throw new Error(`Must be one of: ${values.join(", ")}`);
|
|
450
|
+
return parsed;
|
|
451
|
+
},
|
|
452
|
+
);
|
|
453
|
+
},
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
457
|
+
// Shared edit-key state machine
|
|
458
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
459
|
+
|
|
460
|
+
interface StringLikeArgs {
|
|
461
|
+
isEditing: boolean;
|
|
462
|
+
ctx: unknown;
|
|
463
|
+
setEditing: (v: boolean) => void;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function handleStringLikeKey<V>(
|
|
467
|
+
key: string,
|
|
468
|
+
initialBuffer: string,
|
|
469
|
+
data: string,
|
|
470
|
+
args: StringLikeArgs,
|
|
471
|
+
parse: (buffer: string) => V,
|
|
472
|
+
): FieldKeyResult<V> {
|
|
473
|
+
// Defensive coercion: the caller may pass a non-string value (e.g. an
|
|
474
|
+
// array from config with configFilename + scope tabs). We coerce here
|
|
475
|
+
// so cursor/buffer arithmetic always works on a plain string.
|
|
476
|
+
const initialStr = String(initialBuffer);
|
|
477
|
+
|
|
478
|
+
if (!args.isEditing) {
|
|
479
|
+
if (matchesKey(data, "enter") || matchesKey(data, "return")) {
|
|
480
|
+
// Begin editing — seed the buffer from the current value.
|
|
481
|
+
setEditState(args, key, {
|
|
482
|
+
buffer: initialStr,
|
|
483
|
+
cursor: initialStr.length,
|
|
484
|
+
});
|
|
485
|
+
args.setEditing(true);
|
|
486
|
+
return { consumed: true };
|
|
487
|
+
}
|
|
488
|
+
return {};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Editing mode: Enter commits, Esc cancels.
|
|
492
|
+
if (matchesKey(data, "enter") || matchesKey(data, "return")) {
|
|
493
|
+
const state = getEditState(args, key);
|
|
494
|
+
if (!state) {
|
|
495
|
+
args.setEditing(false);
|
|
496
|
+
return { consumed: true };
|
|
497
|
+
}
|
|
498
|
+
try {
|
|
499
|
+
const value = parse(state.buffer);
|
|
500
|
+
setEditState(args, key, undefined);
|
|
501
|
+
args.setEditing(false);
|
|
502
|
+
return { consumed: true, commit: value };
|
|
503
|
+
} catch (error) {
|
|
504
|
+
// Re-throw so the modal can surface via ctx.ui.notify; keep
|
|
505
|
+
// editing mode active so the user can correct the value.
|
|
506
|
+
throw error;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (matchesKey(data, "escape")) {
|
|
510
|
+
setEditState(args, key, undefined);
|
|
511
|
+
args.setEditing(false);
|
|
512
|
+
return { consumed: true };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const state = getEditState(args, key);
|
|
516
|
+
if (!state) {
|
|
517
|
+
// Defensive: if the registry got out of sync, fall back to a
|
|
518
|
+
// fresh buffer so the user isn't stuck in editing mode with no
|
|
519
|
+
// way to type.
|
|
520
|
+
setEditState(args, key, { buffer: initialStr, cursor: initialStr.length });
|
|
521
|
+
return { consumed: true };
|
|
522
|
+
}
|
|
523
|
+
if (handleInlineEditInput(state, data)) {
|
|
524
|
+
return { consumed: true };
|
|
525
|
+
}
|
|
526
|
+
return { consumed: true }; // swallow stray keys while editing
|
|
527
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renderer for `type: "text"` rows — multiline string values.
|
|
3
|
+
*
|
|
4
|
+
* Editing opens a submenu with a full multiline `Editor`.
|
|
5
|
+
* - Ctrl+S saves and closes.
|
|
6
|
+
* - Escape cancels and closes.
|
|
7
|
+
* - All other input (Enter, Shift+Enter, arrows, typing) is handled by
|
|
8
|
+
* the Editor directly.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Editor, type Component } from "@earendil-works/pi-tui";
|
|
12
|
+
import { getSelectListTheme } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import type { EditorTheme } from "@earendil-works/pi-tui";
|
|
14
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
15
|
+
import type {
|
|
16
|
+
FieldRenderer,
|
|
17
|
+
SubmenuFactory,
|
|
18
|
+
TextField,
|
|
19
|
+
FieldRenderContext,
|
|
20
|
+
FieldKeyHint,
|
|
21
|
+
} from "../types";
|
|
22
|
+
import { formatHintLine } from "../frame";
|
|
23
|
+
|
|
24
|
+
export const textRenderer: FieldRenderer<TextField, string> = {
|
|
25
|
+
type: "text",
|
|
26
|
+
renderValue(row, { selected, ctx }) {
|
|
27
|
+
const display = formatTextPreview(row.value);
|
|
28
|
+
if (row.field.disabled) {
|
|
29
|
+
return ctx.theme.fg("muted", display);
|
|
30
|
+
}
|
|
31
|
+
return ctx.theme.fg(selected ? "accent" : "muted", display);
|
|
32
|
+
},
|
|
33
|
+
hints(row): FieldKeyHint[] {
|
|
34
|
+
if (row.field.disabled) return [];
|
|
35
|
+
return [{ key: "enter", label: "open editor" }];
|
|
36
|
+
},
|
|
37
|
+
handleKey(row, data, { ctx }) {
|
|
38
|
+
if (row.field.disabled) return {};
|
|
39
|
+
if (data === "\r" || data === "\n") {
|
|
40
|
+
return {
|
|
41
|
+
consumed: true,
|
|
42
|
+
submenu: makeTextSubmenu(row.value, ctx),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
return {};
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function formatTextPreview(value: string): string {
|
|
50
|
+
const lineCount = value.split("\n").length;
|
|
51
|
+
const preview = value.replace(/\s+/g, " ").trim();
|
|
52
|
+
const quoted = preview ? JSON.stringify(preview) : '""';
|
|
53
|
+
const suffix = lineCount > 1 ? ` (${lineCount} lines)` : "";
|
|
54
|
+
return `${truncateToWidth(quoted, 48, "...")}${suffix}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function makeTextSubmenu(
|
|
58
|
+
current: string,
|
|
59
|
+
ctx: FieldRenderContext,
|
|
60
|
+
): SubmenuFactory<string> {
|
|
61
|
+
return (done) => {
|
|
62
|
+
const editor = new Editor(
|
|
63
|
+
ctx.tui,
|
|
64
|
+
{
|
|
65
|
+
borderColor: (s: string) => ctx.theme.fg("muted", s),
|
|
66
|
+
selectList: getSelectListTheme(),
|
|
67
|
+
} as EditorTheme,
|
|
68
|
+
{
|
|
69
|
+
paddingX: 0,
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
editor.setText(current);
|
|
73
|
+
editor.focused = true;
|
|
74
|
+
editor.disableSubmit = true;
|
|
75
|
+
editor.onChange = () => ctx.tui.requestRender();
|
|
76
|
+
|
|
77
|
+
const component: Component = {
|
|
78
|
+
render(_width: number): string[] {
|
|
79
|
+
const lines = editor.render(80);
|
|
80
|
+
lines.push("");
|
|
81
|
+
lines.push(
|
|
82
|
+
ctx.theme.fg(
|
|
83
|
+
"dim",
|
|
84
|
+
formatHintLine(
|
|
85
|
+
[
|
|
86
|
+
{ key: "ctrl+s", label: "save" },
|
|
87
|
+
{ key: "esc", label: "cancel" },
|
|
88
|
+
],
|
|
89
|
+
ctx.theme,
|
|
90
|
+
),
|
|
91
|
+
),
|
|
92
|
+
);
|
|
93
|
+
return lines;
|
|
94
|
+
},
|
|
95
|
+
invalidate(): void {
|
|
96
|
+
editor.invalidate();
|
|
97
|
+
},
|
|
98
|
+
handleInput(data: string): void {
|
|
99
|
+
if (data === "\x13") {
|
|
100
|
+
// Ctrl+S
|
|
101
|
+
done(editor.getExpandedText());
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (data === "\x1b") {
|
|
105
|
+
// Escape
|
|
106
|
+
done();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
editor.handleInput(data);
|
|
110
|
+
ctx.tui.requestRender();
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
return component;
|
|
114
|
+
};
|
|
115
|
+
}
|