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,152 @@
1
+ /**
2
+ * `createSettingsModal` and `openSettingsModal` — the public modal
3
+ * entry points. Both wrap `createSettingsModalBody` and shape it for
4
+ * `ctx.ui.custom`.
5
+ */
6
+
7
+ import { join } from "node:path";
8
+ import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
9
+ import type {
10
+ Component,
11
+ KeybindingsManager,
12
+ OverlayOptions,
13
+ TUI,
14
+ } from "@earendil-works/pi-tui";
15
+ import { createSettingsModalBody } from "./body";
16
+ import {
17
+ getExtensionsDir,
18
+ readConfig,
19
+ writeConfig,
20
+ deleteConfig,
21
+ } from "../config.ts";
22
+ import type {
23
+ Field,
24
+ SettingsModalFactory,
25
+ SettingsModalOptions,
26
+ } from "./types";
27
+
28
+ const DEFAULT_OVERLAY: OverlayOptions = {
29
+ anchor: "center",
30
+ width: "92%",
31
+ maxHeight: "85%",
32
+ };
33
+
34
+ // Singleton guard: track one open modal per ExtensionContext.
35
+ // WeakMap so entries are GC'd when the context is no longer referenced.
36
+ const openModals = new WeakMap<ExtensionContext, (result: void) => void>();
37
+
38
+ /**
39
+ * Build a `ctx.ui.custom`-compatible factory for the settings modal.
40
+ * Useful for callers that already manage their own overlay lifecycle.
41
+ *
42
+ * The returned factory captures `ctx` from `openSettingsModal`'s call
43
+ * site — when used standalone, the caller is expected to invoke it via
44
+ * `ctx.ui.custom(createSettingsModal(opts), …)`, and pi will pass the
45
+ * tui/theme/keybindings/done arguments at mount time.
46
+ */
47
+ export function createSettingsModal<F extends Field>(
48
+ ctx: ExtensionContext,
49
+ options: SettingsModalOptions<F>,
50
+ ): SettingsModalFactory<void> {
51
+ return (
52
+ tui: TUI,
53
+ theme: Theme,
54
+ _keybindings: KeybindingsManager,
55
+ done: (result: void) => void,
56
+ ): Component => {
57
+ const close = (): void => {
58
+ try {
59
+ options.onClose?.();
60
+ } catch {
61
+ // Caller-supplied onClose must not break the modal teardown.
62
+ }
63
+ openModals.delete(ctx);
64
+ done();
65
+ };
66
+
67
+ // Singleton guard: if a modal is already open on this ctx, close
68
+ // it before opening the new one. This prevents two in-memory
69
+ // buffers racing to write.
70
+ const existing = openModals.get(ctx);
71
+ if (existing) {
72
+ try {
73
+ existing(void 0);
74
+ } catch {
75
+ // Defensive: existing modal's close must not break the new one.
76
+ }
77
+ }
78
+ openModals.set(ctx, close);
79
+
80
+ // Auto-inject default scope action handlers when configFilename is
81
+ // set but the callbacks are absent. This ensures that any modal using
82
+ // scope tabs always has reset/delete actions without requiring every
83
+ // caller to supply them explicitly.
84
+ if (options.configFilename) {
85
+ if (!options.onResetScope) {
86
+ options.onResetScope = async (scope: "global" | "project") => {
87
+ const dir =
88
+ scope === "project"
89
+ ? join(ctx.cwd, ".pi")
90
+ : (options.globalConfigDir ?? getExtensionsDir());
91
+ const knownKeys = new Set(Object.keys(options.defaults ?? {}));
92
+ const existing = readConfig<Record<string, unknown>>(
93
+ options.configFilename!,
94
+ dir,
95
+ );
96
+ const unknownKeys: Record<string, unknown> = {};
97
+ if (existing && typeof existing === "object") {
98
+ for (const [key, val] of Object.entries(existing)) {
99
+ if (!knownKeys.has(key)) unknownKeys[key] = val;
100
+ }
101
+ }
102
+ if (Object.keys(unknownKeys).length > 0) {
103
+ writeConfig(options.configFilename!, unknownKeys, dir);
104
+ } else {
105
+ deleteConfig(options.configFilename!, dir);
106
+ }
107
+ };
108
+ }
109
+ if (!options.onDeleteScope) {
110
+ options.onDeleteScope = async (scope: "global" | "project") => {
111
+ const dir =
112
+ scope === "project"
113
+ ? join(ctx.cwd, ".pi")
114
+ : (options.globalConfigDir ?? getExtensionsDir());
115
+ deleteConfig(options.configFilename!, dir);
116
+ };
117
+ }
118
+ }
119
+
120
+ return createSettingsModalBody<F>(options, { tui, theme, ctx, close });
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Convenience: open a settings modal as a centered overlay and resolve
126
+ * when the user closes it. This is the **happy-path** entry point most
127
+ * callers want.
128
+ *
129
+ * Defaults: anchor center, width 92%, maxHeight 85%. Override via
130
+ * `options.overlayOptions`.
131
+ *
132
+ * @example
133
+ * ```ts
134
+ * await openSettingsModal(ctx, {
135
+ * title: "@k0valik/pi-voice",
136
+ * fields: [
137
+ * { key: "muted", type: "boolean", label: "Muted", value: cfg.muted },
138
+ * ],
139
+ * onChange: (key, value) => { cfg[key] = value; saveConfig(cfg); },
140
+ * });
141
+ * ```
142
+ */
143
+ export async function openSettingsModal<F extends Field>(
144
+ ctx: ExtensionContext,
145
+ options: SettingsModalOptions<F>,
146
+ ): Promise<void> {
147
+ const overlayOptions = options.overlayOptions ?? DEFAULT_OVERLAY;
148
+ await ctx.ui.custom<void>(createSettingsModal(ctx, options), {
149
+ overlay: true,
150
+ overlayOptions,
151
+ });
152
+ }
@@ -0,0 +1,500 @@
1
+ /**
2
+ * Public types for the `@k0valik/pi-base` settings modal (originally
3
+ * vendored from wierdbytes/pi-common; since heavily reworked).
4
+ *
5
+ * The modal accepts a flat array of `Field`s (or, when `tabs` is set,
6
+ * one such array per tab) and a single `onChange` callback. Every
7
+ * built-in field is a discriminated-union variant of `Field`. Callers
8
+ * needing more exotic widgets can drop down to the `custom` variant and
9
+ * implement a `FieldRenderer` directly.
10
+ *
11
+ * Persistence is intentionally out of scope here — the caller passes
12
+ * each field's current value at open time and decides what to do in
13
+ * `onChange`. Re-opening the modal re-reads the values.
14
+ */
15
+
16
+ import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
17
+ import type { Api, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
18
+ import type {
19
+ Component,
20
+ KeybindingsManager,
21
+ OverlayOptions,
22
+ TUI,
23
+ } from "@earendil-works/pi-tui";
24
+
25
+ // ─────────────────────────────────────────────────────────────────────
26
+ // Visibility context for visibleWhen
27
+ // ─────────────────────────────────────────────────────────────────────
28
+
29
+ export interface VisibilityContext {
30
+ get(key: string): unknown;
31
+ getScoped(key: string, scope?: string): unknown;
32
+ scope: string;
33
+ }
34
+
35
+ // ─────────────────────────────────────────────────────────────────────
36
+ // Field discriminated union
37
+ // ─────────────────────────────────────────────────────────────────────
38
+
39
+ /** Common shape for every field variant. */
40
+ export interface FieldBase {
41
+ /** Stable id; passed back as the first argument to `onChange`. */
42
+ key: string;
43
+ /** Left-hand label shown in the row. */
44
+ label: string;
45
+ /** Optional help text rendered under the row when it's focused. */
46
+ description?: string;
47
+ /** Optional tab id this field belongs to (when the modal uses tabs). */
48
+ tab?: string;
49
+ /** Disable interactions; the row still renders but Enter is a no-op. */
50
+ disabled?: boolean;
51
+ /** Optional default value used when resetting the field. */
52
+ default?: unknown;
53
+ /**
54
+ * Opt-in to alt+↑ / alt+↓ reorder. When set, the modal swaps this
55
+ * row with the immediate neighbour (in `visibleRowIndices` order)
56
+ * if and only if that neighbour is also reorderable. The caller's
57
+ * `SettingsModalOptions.onReorder` is invoked after the swap so it
58
+ * can mirror the change into persistent state. Non-reorderable
59
+ * neighbours block the move (no skip-and-swap), so callers should
60
+ * group reorderable rows contiguously inside a tab.
61
+ */
62
+ reorderable?: boolean;
63
+ /**
64
+ * Visual nesting level. Rows with `depth > 0` are indented by
65
+ * `depth * 2` spaces. Useful for grouping related fields under a
66
+ * parent concept without a nested schema.
67
+ */
68
+ depth?: number;
69
+ /**
70
+ * Conditionally hide this field in the settings editor. The callback
71
+ * receives a context object that allows reading sibling values and
72
+ * the current scope.
73
+ *
74
+ * Hidden values persist in the config file until explicitly cleared
75
+ * — this is purely a UI affordance, not a data filter.
76
+ */
77
+ visibleWhen?: (ctx: VisibilityContext) => boolean;
78
+ /**
79
+ * Override the row's label color. The default behaviour is
80
+ * `selected ? "text" : "muted"` — fields opt-in here to express
81
+ * "this row is active" (always rendered with the bright `text`
82
+ * color) or "this row is disabled" (always muted, even when
83
+ * focused) regardless of focus state.
84
+ *
85
+ * Accepts a boolean (`true` ⇒ muted, `false` ⇒ active) or a
86
+ * thunk re-evaluated on every render so the color can track live
87
+ * external state (e.g. a per-block visibility toggle that updates
88
+ * via `onChange`).
89
+ *
90
+ * Unset means "follow default".
91
+ */
92
+ dim?: boolean | (() => boolean);
93
+ /**
94
+ * When true, changes to this field require the user to run `/reload`
95
+ * for the new value to take effect. The modal aggregates dirty
96
+ * `requiresReload` fields and shows a hint in the confirm prompt.
97
+ */
98
+ requiresReload?: boolean;
99
+ }
100
+
101
+ export interface BooleanField extends FieldBase {
102
+ type: "boolean";
103
+ value: boolean;
104
+ default?: boolean;
105
+ /** Per-value help text, keyed by "on" or "off". */
106
+ valueDescriptions?: Partial<Record<"on" | "off", string>>;
107
+ }
108
+
109
+ export interface EnumField<T extends string = string> extends FieldBase {
110
+ type: "enum";
111
+ value: T;
112
+ options: readonly T[];
113
+ /** Long label shown in the cycle / submenu (defaults to `value`). */
114
+ optionLabels?: Partial<Record<T, string>>;
115
+ /**
116
+ * When `options.length` is greater than this, Enter opens a
117
+ * `SelectList` submenu instead of cycling. Default `4`.
118
+ */
119
+ cycleThreshold?: number;
120
+ /**
121
+ * Per-value help text shown in the description area when this
122
+ * row is focused. Keyed by option value.
123
+ */
124
+ valueDescriptions?: Partial<Record<T, string>>;
125
+ /**
126
+ * When true, Enter always opens a filterable list submenu (even
127
+ * for short option lists). The user can type to fuzzy-search
128
+ * options instead of cycling through them one by one.
129
+ * Default `false`.
130
+ */
131
+ search?: boolean;
132
+ default?: T;
133
+ }
134
+
135
+ export interface StringField extends FieldBase {
136
+ type: "string";
137
+ value: string;
138
+ /** Optional placeholder shown when the value is empty. */
139
+ placeholder?: string;
140
+ default?: string;
141
+ }
142
+
143
+ export interface TextField extends FieldBase {
144
+ type: "text";
145
+ value: string;
146
+ /** Optional placeholder shown when the value is empty. */
147
+ placeholder?: string;
148
+ default?: string;
149
+ }
150
+
151
+ export interface NumberField extends FieldBase {
152
+ type: "number";
153
+ value: number;
154
+ /** Inclusive bounds; values outside are rejected with a notify(). */
155
+ min?: number;
156
+ max?: number;
157
+ /** When true, rejects non-integers. */
158
+ integer?: boolean;
159
+ /**
160
+ * Step size for arrow-key cycling within the range.
161
+ * When set, up-arrow / down-arrow increment/decrement the value by `step`
162
+ * clamped to `[min, max]`. Mutually exclusive with `values`.
163
+ */
164
+ step?: number;
165
+ /** Discrete list of valid values (mutually exclusive with min/max/integer/step). */
166
+ values?: readonly number[];
167
+ /** Per-value help text, keyed by stringified number. */
168
+ valueDescriptions?: Record<string, string>;
169
+ default?: number;
170
+ }
171
+
172
+ export interface SecretField extends FieldBase {
173
+ type: "secret";
174
+ value: string;
175
+ default?: string;
176
+ }
177
+
178
+ export interface PathField extends FieldBase {
179
+ type: "path";
180
+ value: string;
181
+ default?: string;
182
+ }
183
+
184
+ export interface ActionField extends FieldBase {
185
+ type: "action";
186
+ /** Right-hand display value (defaults to `(run)`). */
187
+ display?: string;
188
+ /** Fired on Enter. */
189
+ onActivate: (ctx: ExtensionContext) => void | Promise<void>;
190
+ }
191
+
192
+ /** Composite value for a model + reasoning-effort field. */
193
+ export interface ModelValue {
194
+ /** Canonical `<provider>/<id>` string, or `""` for "session model". */
195
+ id: string;
196
+ /** One of pi-ai's six levels (`off|minimal|low|medium|high|xhigh`). */
197
+ thinking?: ModelThinkingLevel;
198
+ }
199
+
200
+ /** Pre-resolved model option (skips registry discovery when provided). */
201
+ export interface ModelOption {
202
+ /** Canonical `<provider>/<id>`, or `""` for the "session model" sentinel. */
203
+ value: string;
204
+ /** Human-readable label shown in the SelectList. */
205
+ label: string;
206
+ /** Optional concrete model — used to compute supported effort levels. */
207
+ model?: Model<Api>;
208
+ }
209
+
210
+ export interface ModelField extends FieldBase {
211
+ type: "model";
212
+ value: ModelValue;
213
+ /** Default sentinel label. Defaults to `"(session model)"`. */
214
+ sessionLabel?: string;
215
+ /** Override the auto-discovered list of models. */
216
+ models?: ModelOption[];
217
+ /** Filter the auto-discovered list (ignored when `models` is set). */
218
+ filter?: (model: Model<Api>) => boolean;
219
+ /** Hide the `(session model)` sentinel. Defaults to `false`. */
220
+ hideSession?: boolean;
221
+ /**
222
+ * Hide the reasoning-effort axis. Useful for tools that don't have
223
+ * a thinking concept at all (e.g. Anthropic server-side `web_search`,
224
+ * which only takes a model id). When `true`:
225
+ * - the submenu only renders the model `SelectList` (no effort row);
226
+ * - `←/→` are not consumed;
227
+ * - the saved value's `thinking` is always `undefined`;
228
+ * - the row label drops the `· effort` suffix.
229
+ * Defaults to `false`.
230
+ */
231
+ hideEffort?: boolean;
232
+ default?: ModelValue;
233
+ }
234
+
235
+ /**
236
+ * Escape hatch for arbitrary widgets. The modal calls `render(args)` to
237
+ * draw the row; `handleInput(data, args)` consumes terminal input when
238
+ * the row is focused. If `openSubmenu` is provided, Enter on the row
239
+ * mounts the returned component full-screen-inside-the-frame; the
240
+ * submenu calls `done(value)` to commit (or `done()` to cancel).
241
+ */
242
+ export interface CustomField<T = unknown> extends FieldBase {
243
+ type: "custom";
244
+ value: T;
245
+ /** Render the row's right-hand value cell (left part is the label). */
246
+ render: (args: CustomFieldRenderArgs<T>) => string;
247
+ /** Optional inline-edit input handler. Return true to consume. */
248
+ handleInput?: (data: string, args: CustomFieldRenderArgs<T>) => boolean;
249
+ /**
250
+ * Optional submenu mounted on Enter. The factory receives a `done`
251
+ * callback that commits (`done(value)`) or cancels (`done()`).
252
+ */
253
+ openSubmenu?: (args: CustomFieldSubmenuArgs<T>) => Component;
254
+ /**
255
+ * Override the auto-generated footer hints for this row. The default
256
+ * heuristic in `customRenderer.hints` shows `enter open` when an
257
+ * `openSubmenu` is provided and `enter edit` when only `handleInput`
258
+ * is. Set this to advertise a different set (e.g. `space toggle`
259
+ * when `handleInput` consumes space but Enter is a no-op) without
260
+ * sub-classing the field renderer.
261
+ */
262
+ hints?: FieldKeyHint[];
263
+ }
264
+
265
+ export interface CustomFieldRenderArgs<T> {
266
+ value: T;
267
+ width: number;
268
+ selected: boolean;
269
+ theme: Theme;
270
+ }
271
+
272
+ export interface CustomFieldSubmenuArgs<T> {
273
+ value: T;
274
+ theme: Theme;
275
+ tui: TUI;
276
+ done: (newValue?: T) => void;
277
+ }
278
+
279
+ /** Discriminated union of every field variant. */
280
+ export type Field =
281
+ | BooleanField
282
+ | EnumField
283
+ | StringField
284
+ | TextField
285
+ | NumberField
286
+ | SecretField
287
+ | PathField
288
+ | ActionField
289
+ | ModelField
290
+ | CustomField;
291
+
292
+ // ─────────────────────────────────────────────────────────────────────
293
+ // Renderer interface (internal, but exported for advanced callers)
294
+ // ─────────────────────────────────────────────────────────────────────
295
+
296
+ export interface FieldRenderContext {
297
+ theme: Theme;
298
+ tui: TUI;
299
+ ctx: ExtensionContext;
300
+ requestRender: () => void;
301
+ }
302
+
303
+ export interface FieldKeyHint {
304
+ key: string;
305
+ label: string;
306
+ }
307
+
308
+ /** Live state of a row — passed to renderers and input handlers. */
309
+ export interface FieldRow<F extends Field = Field, V = unknown> {
310
+ field: F;
311
+ /** Current displayed value (may be ahead of disk if onChange is async). */
312
+ value: V;
313
+ }
314
+
315
+ /**
316
+ * A `FieldRenderer` knows how to draw, focus, and edit one variant of
317
+ * `Field`. Built-in renderers live in `./fields/*.ts`; custom callers
318
+ * should use `type: "custom"` instead of implementing this interface
319
+ * directly.
320
+ */
321
+ export interface FieldRenderer<F extends Field = Field, V = unknown> {
322
+ /** The discriminator this renderer handles. */
323
+ type: F["type"];
324
+ /** Render the right-hand value cell for one row. */
325
+ renderValue(
326
+ row: FieldRow<F, V>,
327
+ args: {
328
+ width: number;
329
+ selected: boolean;
330
+ isEditing: boolean;
331
+ ctx: FieldRenderContext;
332
+ },
333
+ ): string;
334
+ /** Footer-hint pieces shown when this row is focused. */
335
+ hints(row: FieldRow<F, V>, args: { isEditing: boolean }): FieldKeyHint[];
336
+ /**
337
+ * Handle a key event. Return `consumed` to suppress default handling
338
+ * (navigation, esc-close); return a `commit` value to persist; return
339
+ * a `submenu` to mount one. Called only when the row is focused.
340
+ */
341
+ handleKey(
342
+ row: FieldRow<F, V>,
343
+ data: string,
344
+ args: {
345
+ isEditing: boolean;
346
+ ctx: FieldRenderContext;
347
+ setEditing: (v: boolean) => void;
348
+ },
349
+ ): FieldKeyResult<V>;
350
+ }
351
+
352
+ /**
353
+ * Submenu factory passed back from a renderer's `handleKey`. The modal
354
+ * supplies the `done` callback when mounting; calling `done(value)`
355
+ * commits and unmounts, calling `done()` cancels and unmounts.
356
+ */
357
+ export type SubmenuFactory<V> = (done: (value?: V) => void) => Component;
358
+
359
+ export interface FieldKeyResult<V> {
360
+ consumed?: boolean;
361
+ /** A new value to commit (modal calls onChange). */
362
+ commit?: V;
363
+ /** Submenu factory to mount; modal supplies the `done` callback. */
364
+ submenu?: SubmenuFactory<V>;
365
+ }
366
+
367
+ // ─────────────────────────────────────────────────────────────────────
368
+ // Tabs and modal options
369
+ // ─────────────────────────────────────────────────────────────────────
370
+
371
+ export interface Tab {
372
+ /** Stable id; `field.tab` matches against this. */
373
+ id: string;
374
+ /** Pill label shown in the tab strip. */
375
+ label: string;
376
+ }
377
+
378
+ export interface SettingsTheme {
379
+ /** Override the default frame title colour. Optional. */
380
+ titleColor?: (text: string) => string;
381
+ /** Override the focused-row background. Optional. */
382
+ rowSelected?: (text: string) => string;
383
+ /** Override the inline-edit value colour. Optional. */
384
+ editingValue?: (text: string) => string;
385
+ }
386
+
387
+ export interface SettingsModalOptions<F extends Field = Field> {
388
+ /** Title rendered in the frame's top border (e.g. `"@k0valik/pi-voice"`). */
389
+ title?: string;
390
+ /** Field rows. May span multiple tabs via each field's optional `tab` id. */
391
+ fields: F[];
392
+ /** Optional tab strip; rendered only when length ≥ 1. */
393
+ tabs?: Tab[];
394
+ /** Optional config filename to enable tabulated global vs project local views. */
395
+ configFilename?: string;
396
+ /** Optional defaults for the configuration to fallback to when loading. */
397
+ defaults?: Record<string, unknown>;
398
+ /** Initial tab id (defaults to the first tab). */
399
+ initialTab?: string;
400
+ /** Show a fuzzy-search bar above the list. */
401
+ enableSearch?: boolean;
402
+ /** Theme overrides (mostly used for callers with fixed-palette aesthetics). */
403
+ theme?: SettingsTheme;
404
+ /** Override the overlay positioning (defaults: anchor center, 92% × 85%). */
405
+ overlayOptions?: OverlayOptions | (() => OverlayOptions);
406
+ /** Override the global config directory used for scope-tab loading. Defaults to `getExtensionsDir()`. */
407
+ globalConfigDir?: string;
408
+ /**
409
+ * Called whenever a field's value changes. The modal calls this
410
+ * synchronously after updating its own row state, so any throw here
411
+ * is surfaced via `ctx.ui.notify` and does NOT roll back the row.
412
+ */
413
+ onChange?: <K extends F["key"]>(
414
+ key: K,
415
+ value: ValueOfField<F, K>,
416
+ field: F,
417
+ ) => void | Promise<void>;
418
+ /**
419
+ * Buffered mode: persist all edits at once on explicit save.
420
+ * When `mode` is `"buffered"`, the modal holds edits in memory and
421
+ * calls `onSave` only when the user confirms. `onChange` is optional
422
+ * and serves only live preview; dirty detection is modal-owned.
423
+ */
424
+ mode?: "immediate" | "buffered";
425
+ /**
426
+ * Called when the user confirms a buffered save. Receives the
427
+ * full buffer (all field values, including untouched defaults) and
428
+ * the chosen scope. May be async.
429
+ */
430
+ onSave?: (
431
+ values: Record<string, unknown>,
432
+ scope: "global" | "project",
433
+ ) => void | Promise<void>;
434
+ /**
435
+ * Called when the user discards buffered edits. The modal closes
436
+ * immediately after.
437
+ */
438
+ onCancel?: () => void;
439
+ /**
440
+ * Called when the user presses alt+↑ / alt+↓ on a `reorderable: true`
441
+ * row. The modal has already swapped its internal `rows` array and
442
+ * moved focus to follow the row by the time this fires; the caller
443
+ * just needs to mirror the change into persistent state.
444
+ *
445
+ * Indices are 0-based positions **within the active tab's visible
446
+ * rows** (so they map directly to e.g. `layout.order` positions for
447
+ * statusline-style use cases). Both `fromIndex` and `toIndex` count
448
+ * only `reorderable: true` peers — non-reorderable rows interleaved
449
+ * with the reorderable ones do NOT participate and do NOT shift the
450
+ * peer index.
451
+ */
452
+ onReorder?: (info: {
453
+ fieldKey: string;
454
+ fromIndex: number;
455
+ toIndex: number;
456
+ }) => void;
457
+ /**
458
+ * Called once when the modal closes. Useful for fire-and-forget
459
+ * cleanup (e.g. saving a debounced config).
460
+ */
461
+ onClose?: () => void;
462
+ /**
463
+ * Infer the default scope for the confirm prompt. Called once at
464
+ * modal mount. Return "project" if a project-local config file
465
+ * exists, "global" otherwise. Optional — defaults to "global".
466
+ *
467
+ * This keeps the modal framework independent of any specific
468
+ * config library (pi-base, custom fs, etc.) — the extension
469
+ * decides what "project-local" means for its own config system.
470
+ */
471
+ inferDefaultScope?: () => "global" | "project";
472
+ /**
473
+ * Called when the user activates "Reset scope to defaults" from
474
+ * the bottom of a scope tab. The callback should delete the
475
+ * scope's config file or otherwise clear all overrides so the
476
+ * next load falls back to defaults entirely.
477
+ *
478
+ * After this callback completes, the modal re-reads configs and
479
+ * updates all rows with their default values.
480
+ */
481
+ onResetScope?: (scope: "global" | "project") => void | Promise<void>;
482
+ /**
483
+ * Called when the user activates "Delete config" from the bottom of
484
+ * a scope tab. The callback should delete the scope's config file
485
+ * entirely (not just reset it).
486
+ */
487
+ onDeleteScope?: (scope: "global" | "project") => void | Promise<void>;
488
+ }
489
+
490
+ /** Value type of the field with key `K` inside a union `F`. */
491
+ export type ValueOfField<F extends Field, K extends string> =
492
+ Extract<F, { key: K }> extends { value: infer V } ? V : never;
493
+
494
+ /** Component factory shape required by `ctx.ui.custom`. */
495
+ export type SettingsModalFactory<T = void> = (
496
+ tui: TUI,
497
+ theme: Theme,
498
+ keybindings: KeybindingsManager,
499
+ done: (result: T) => void,
500
+ ) => Component;