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
@@ -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
+ }