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,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renderer for `type: "action"` rows. Non-storing — Enter triggers the
|
|
3
|
+
* caller-supplied `onActivate(ctx)` and never changes a value.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { matchesKey } from "@earendil-works/pi-tui";
|
|
7
|
+
import type { ActionField, FieldRenderer } from "../types";
|
|
8
|
+
|
|
9
|
+
export const actionRenderer: FieldRenderer<ActionField, void> = {
|
|
10
|
+
type: "action",
|
|
11
|
+
renderValue(row, { selected, ctx }) {
|
|
12
|
+
const text = row.field.display ?? "(run)";
|
|
13
|
+
if (row.field.disabled) {
|
|
14
|
+
return ctx.theme.fg("muted", text);
|
|
15
|
+
}
|
|
16
|
+
return ctx.theme.fg(selected ? "accent" : "muted", text);
|
|
17
|
+
},
|
|
18
|
+
hints(row) {
|
|
19
|
+
if (row.field.disabled) return [];
|
|
20
|
+
return [{ key: "enter", label: "run" }];
|
|
21
|
+
},
|
|
22
|
+
handleKey(row, data, { ctx }) {
|
|
23
|
+
if (row.field.disabled) return {};
|
|
24
|
+
if (
|
|
25
|
+
matchesKey(data, "enter") ||
|
|
26
|
+
matchesKey(data, "return") ||
|
|
27
|
+
data === " "
|
|
28
|
+
) {
|
|
29
|
+
// Fire and forget — actions are deliberately fire-and-go so the
|
|
30
|
+
// modal stays responsive even when `onActivate` is async.
|
|
31
|
+
try {
|
|
32
|
+
const ret = row.field.onActivate(ctx.ctx);
|
|
33
|
+
if (ret && typeof (ret as Promise<void>).then === "function") {
|
|
34
|
+
(ret as Promise<void>).catch(() => {});
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
// Swallow — actions must never break the modal loop.
|
|
38
|
+
}
|
|
39
|
+
return { consumed: true };
|
|
40
|
+
}
|
|
41
|
+
return {};
|
|
42
|
+
},
|
|
43
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renderer for `type: "boolean"` rows. Enter / Space toggles between
|
|
3
|
+
* `on` and `off`; the value is committed immediately.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { matchesKey } from "@earendil-works/pi-tui";
|
|
7
|
+
import type { BooleanField, FieldRenderer } from "../types";
|
|
8
|
+
|
|
9
|
+
export const booleanRenderer: FieldRenderer<BooleanField, boolean> = {
|
|
10
|
+
type: "boolean",
|
|
11
|
+
renderValue(row, { selected, ctx }) {
|
|
12
|
+
const text = row.value ? "on" : "off";
|
|
13
|
+
if (row.field.disabled) {
|
|
14
|
+
return ctx.theme.fg("muted", text);
|
|
15
|
+
}
|
|
16
|
+
return ctx.theme.fg(
|
|
17
|
+
selected ? "accent" : row.value ? "success" : "muted",
|
|
18
|
+
text,
|
|
19
|
+
);
|
|
20
|
+
},
|
|
21
|
+
hints(row) {
|
|
22
|
+
if (row.field.disabled) return [];
|
|
23
|
+
return [
|
|
24
|
+
{ key: "enter/space", label: row.value ? "turn off" : "turn on" },
|
|
25
|
+
{ key: "←/→", label: "toggle" },
|
|
26
|
+
];
|
|
27
|
+
},
|
|
28
|
+
handleKey(row, data) {
|
|
29
|
+
if (row.field.disabled) return {};
|
|
30
|
+
if (
|
|
31
|
+
matchesKey(data, "enter") ||
|
|
32
|
+
matchesKey(data, "return") ||
|
|
33
|
+
data === " "
|
|
34
|
+
) {
|
|
35
|
+
return { consumed: true, commit: !row.value };
|
|
36
|
+
}
|
|
37
|
+
if (matchesKey(data, "left")) {
|
|
38
|
+
if (row.value === false) return { consumed: true };
|
|
39
|
+
return { consumed: true, commit: false };
|
|
40
|
+
}
|
|
41
|
+
if (matchesKey(data, "right")) {
|
|
42
|
+
if (row.value === true) return { consumed: true };
|
|
43
|
+
return { consumed: true, commit: true };
|
|
44
|
+
}
|
|
45
|
+
return {};
|
|
46
|
+
},
|
|
47
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renderer for `type: "custom"` rows. The caller supplies:
|
|
3
|
+
*
|
|
4
|
+
* - `render(args)` — returns the right-hand value cell as a string.
|
|
5
|
+
* - `handleInput?(data, args)` — optional inline-edit handler; return
|
|
6
|
+
* true to consume the key.
|
|
7
|
+
* - `openSubmenu?(args)` — optional submenu mounted on Enter; the
|
|
8
|
+
* factory receives a `done(value?)` callback the modal supplies.
|
|
9
|
+
*
|
|
10
|
+
* Anything more exotic than the built-in `model` widget belongs here.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { matchesKey } from "@earendil-works/pi-tui";
|
|
14
|
+
import type { CustomField, FieldRenderer, SubmenuFactory } from "../types";
|
|
15
|
+
|
|
16
|
+
export const customRenderer: FieldRenderer<CustomField, unknown> = {
|
|
17
|
+
type: "custom",
|
|
18
|
+
renderValue(row, args) {
|
|
19
|
+
const text = row.field.render({
|
|
20
|
+
value: row.value,
|
|
21
|
+
width: args.width,
|
|
22
|
+
selected: args.selected && !row.field.disabled,
|
|
23
|
+
theme: args.ctx.theme,
|
|
24
|
+
});
|
|
25
|
+
if (row.field.disabled) {
|
|
26
|
+
return args.ctx.theme.fg("muted", text);
|
|
27
|
+
}
|
|
28
|
+
return text;
|
|
29
|
+
},
|
|
30
|
+
hints(row) {
|
|
31
|
+
if (row.field.disabled) return [];
|
|
32
|
+
// Caller-supplied override wins outright — useful when
|
|
33
|
+
// `handleInput` consumes a specific key (e.g. space) but Enter
|
|
34
|
+
// is a no-op, in which case the default "enter edit" heuristic
|
|
35
|
+
// would be misleading.
|
|
36
|
+
if (row.field.hints) return row.field.hints;
|
|
37
|
+
if (row.field.openSubmenu) return [{ key: "enter", label: "open" }];
|
|
38
|
+
if (row.field.handleInput) return [{ key: "enter", label: "edit" }];
|
|
39
|
+
return [];
|
|
40
|
+
},
|
|
41
|
+
handleKey(row, data, args) {
|
|
42
|
+
if (row.field.disabled) return {};
|
|
43
|
+
// Caller-supplied inline-edit handler runs first so it can intercept
|
|
44
|
+
// arrow keys etc. before we treat Enter as "open submenu".
|
|
45
|
+
if (row.field.handleInput) {
|
|
46
|
+
const consumed = row.field.handleInput(data, {
|
|
47
|
+
value: row.value,
|
|
48
|
+
width: 0,
|
|
49
|
+
selected: true,
|
|
50
|
+
theme: args.ctx.theme,
|
|
51
|
+
});
|
|
52
|
+
if (consumed) return { consumed: true };
|
|
53
|
+
}
|
|
54
|
+
if (
|
|
55
|
+
matchesKey(data, "enter") ||
|
|
56
|
+
matchesKey(data, "return") ||
|
|
57
|
+
data === " "
|
|
58
|
+
) {
|
|
59
|
+
if (row.field.openSubmenu) {
|
|
60
|
+
const factory: SubmenuFactory<unknown> = (done) =>
|
|
61
|
+
row.field.openSubmenu!({
|
|
62
|
+
value: row.value,
|
|
63
|
+
theme: args.ctx.theme,
|
|
64
|
+
tui: args.ctx.tui,
|
|
65
|
+
done,
|
|
66
|
+
});
|
|
67
|
+
return { consumed: true, submenu: factory };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return {};
|
|
71
|
+
},
|
|
72
|
+
};
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renderer for `type: "enum"` rows.
|
|
3
|
+
*
|
|
4
|
+
* Short option lists (≤ `cycleThreshold`, default 4) cycle in place via
|
|
5
|
+
* Enter / Space; longer lists open a `SelectList` submenu so users
|
|
6
|
+
* don't have to mash Enter to scroll through 20+ entries.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { getSelectListTheme } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import {
|
|
11
|
+
matchesKey,
|
|
12
|
+
SelectList,
|
|
13
|
+
truncateToWidth,
|
|
14
|
+
type Component,
|
|
15
|
+
type SelectItem,
|
|
16
|
+
} from "@earendil-works/pi-tui";
|
|
17
|
+
import { formatHintLine } from "../frame";
|
|
18
|
+
import type {
|
|
19
|
+
EnumField,
|
|
20
|
+
FieldRenderContext,
|
|
21
|
+
FieldRenderer,
|
|
22
|
+
SubmenuFactory,
|
|
23
|
+
} from "../types";
|
|
24
|
+
|
|
25
|
+
const DEFAULT_CYCLE_THRESHOLD = 4;
|
|
26
|
+
const MAX_VISIBLE_ROWS = 12;
|
|
27
|
+
|
|
28
|
+
function labelFor(field: EnumField, value: string): string {
|
|
29
|
+
return field.optionLabels?.[value] ?? value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function nextCycleValue(field: EnumField, current: string): string {
|
|
33
|
+
if (field.options.length === 0) return current;
|
|
34
|
+
const idx = field.options.indexOf(current as never);
|
|
35
|
+
const nextIdx = (idx + 1 + field.options.length) % field.options.length;
|
|
36
|
+
return field.options[nextIdx]!;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function prevCycleValue(field: EnumField, current: string): string {
|
|
40
|
+
if (field.options.length === 0) return current;
|
|
41
|
+
const idx = field.options.indexOf(current as never);
|
|
42
|
+
const prevIdx = (idx - 1 + field.options.length) % field.options.length;
|
|
43
|
+
return field.options[prevIdx]!;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Build a submenu factory the modal can mount with its own `done`. */
|
|
47
|
+
function makeEnumSubmenu(
|
|
48
|
+
field: EnumField,
|
|
49
|
+
current: string,
|
|
50
|
+
ctx: FieldRenderContext,
|
|
51
|
+
): SubmenuFactory<string> {
|
|
52
|
+
if (field.search) {
|
|
53
|
+
return makeSearchableEnumSubmenu(field, current, ctx);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return (done) => {
|
|
57
|
+
const items: SelectItem[] = field.options.map((value, idx) => ({
|
|
58
|
+
value,
|
|
59
|
+
label: `${idx + 1}. ${labelFor(field, value)}`,
|
|
60
|
+
}));
|
|
61
|
+
const list = new SelectList(
|
|
62
|
+
items,
|
|
63
|
+
Math.min(items.length, MAX_VISIBLE_ROWS),
|
|
64
|
+
getSelectListTheme(),
|
|
65
|
+
);
|
|
66
|
+
const idx = field.options.indexOf(current);
|
|
67
|
+
list.setSelectedIndex(idx >= 0 ? idx : 0);
|
|
68
|
+
list.onSelect = (item) => done(item.value);
|
|
69
|
+
list.onCancel = () => done();
|
|
70
|
+
|
|
71
|
+
const component: Component = {
|
|
72
|
+
render(width: number): string[] {
|
|
73
|
+
const lines = [...list.render(width)];
|
|
74
|
+
lines.push("");
|
|
75
|
+
const hints = [
|
|
76
|
+
{ key: "↑↓", label: "select" },
|
|
77
|
+
...(items.length > 1
|
|
78
|
+
? [{ key: `1-${Math.min(9, items.length)}`, label: "choose" }]
|
|
79
|
+
: []),
|
|
80
|
+
{ key: "enter", label: "save" },
|
|
81
|
+
{ key: "esc", label: "cancel" },
|
|
82
|
+
];
|
|
83
|
+
const hintText = ` ${formatHintLine(hints, ctx.theme)}`;
|
|
84
|
+
lines.push(truncateToWidth(hintText, width, "…", true));
|
|
85
|
+
return lines;
|
|
86
|
+
},
|
|
87
|
+
invalidate(): void {
|
|
88
|
+
list.invalidate();
|
|
89
|
+
},
|
|
90
|
+
handleInput(data: string): void {
|
|
91
|
+
const num = parseInt(data, 10);
|
|
92
|
+
if (
|
|
93
|
+
data.length === 1 &&
|
|
94
|
+
!isNaN(num) &&
|
|
95
|
+
num >= 1 &&
|
|
96
|
+
num <= Math.min(9, items.length)
|
|
97
|
+
) {
|
|
98
|
+
const item = items[num - 1];
|
|
99
|
+
if (item) {
|
|
100
|
+
done(item.value);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
list.handleInput(data);
|
|
105
|
+
ctx.tui.requestRender();
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
return component;
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Build a searchable enum submenu with a filter bar at the top.
|
|
114
|
+
* Used when `field.search === true`.
|
|
115
|
+
*/
|
|
116
|
+
function makeSearchableEnumSubmenu(
|
|
117
|
+
field: EnumField,
|
|
118
|
+
_current: string,
|
|
119
|
+
ctx: FieldRenderContext,
|
|
120
|
+
): SubmenuFactory<string> {
|
|
121
|
+
return (done) => {
|
|
122
|
+
const allItems: SelectItem[] = field.options.map((value) => ({
|
|
123
|
+
value,
|
|
124
|
+
label: labelFor(field, value),
|
|
125
|
+
}));
|
|
126
|
+
|
|
127
|
+
let search = "";
|
|
128
|
+
let selected = 0;
|
|
129
|
+
|
|
130
|
+
function filteredItems(): SelectItem[] {
|
|
131
|
+
if (!search.trim()) return allItems;
|
|
132
|
+
const q = search.toLowerCase();
|
|
133
|
+
return allItems.filter(
|
|
134
|
+
(item) =>
|
|
135
|
+
item.label.toLowerCase().includes(q) ||
|
|
136
|
+
item.value.toLowerCase().includes(q),
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const component: Component = {
|
|
141
|
+
render(width: number): string[] {
|
|
142
|
+
const lines: string[] = [];
|
|
143
|
+
const items = filteredItems();
|
|
144
|
+
const searchPrompt = `Search: ${search}${ctx.theme.inverse(" ")}`;
|
|
145
|
+
lines.push(
|
|
146
|
+
ctx.theme.bg(
|
|
147
|
+
"toolPendingBg",
|
|
148
|
+
truncateToWidth(searchPrompt, width, "…", true),
|
|
149
|
+
),
|
|
150
|
+
);
|
|
151
|
+
lines.push("");
|
|
152
|
+
|
|
153
|
+
if (items.length === 0) {
|
|
154
|
+
lines.push(ctx.theme.fg("muted", " No matching options."));
|
|
155
|
+
} else {
|
|
156
|
+
const maxVisible = MAX_VISIBLE_ROWS;
|
|
157
|
+
const scroll = Math.max(
|
|
158
|
+
0,
|
|
159
|
+
Math.min(
|
|
160
|
+
selected - Math.floor(maxVisible / 2),
|
|
161
|
+
Math.max(0, items.length - maxVisible),
|
|
162
|
+
),
|
|
163
|
+
);
|
|
164
|
+
const slice = items.slice(scroll, scroll + maxVisible);
|
|
165
|
+
for (let i = 0; i < slice.length; i++) {
|
|
166
|
+
const isSelected = scroll + i === selected;
|
|
167
|
+
const prefix = isSelected ? ctx.theme.fg("accent", "▌ ") : " ";
|
|
168
|
+
const display = isSelected
|
|
169
|
+
? ctx.theme.fg("accent", slice[i]!.label)
|
|
170
|
+
: ctx.theme.fg("muted", slice[i]!.label);
|
|
171
|
+
const line = `${prefix}${display}`;
|
|
172
|
+
lines.push(
|
|
173
|
+
isSelected
|
|
174
|
+
? ctx.theme.bg(
|
|
175
|
+
"selectedBg",
|
|
176
|
+
truncateToWidth(line, width, "…", true),
|
|
177
|
+
)
|
|
178
|
+
: truncateToWidth(line, width, "…", true),
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
lines.push("");
|
|
184
|
+
const hints = [
|
|
185
|
+
{ key: "↑↓", label: "select" },
|
|
186
|
+
{ key: "enter", label: "save" },
|
|
187
|
+
{ key: "esc", label: "cancel" },
|
|
188
|
+
];
|
|
189
|
+
if (search) hints.unshift({ key: "type", label: "to filter" });
|
|
190
|
+
lines.push(` ${formatHintLine(hints, ctx.theme)}`);
|
|
191
|
+
return lines;
|
|
192
|
+
},
|
|
193
|
+
invalidate(): void {
|
|
194
|
+
// no external state to invalidate
|
|
195
|
+
},
|
|
196
|
+
handleInput(data: string): void {
|
|
197
|
+
if (matchesKey(data, "up")) {
|
|
198
|
+
selected = Math.max(0, selected - 1);
|
|
199
|
+
ctx.tui.requestRender();
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (matchesKey(data, "down")) {
|
|
203
|
+
const items = filteredItems();
|
|
204
|
+
selected = Math.min(selected + 1, Math.max(0, items.length - 1));
|
|
205
|
+
ctx.tui.requestRender();
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (matchesKey(data, "enter") || matchesKey(data, "return")) {
|
|
209
|
+
const items = filteredItems();
|
|
210
|
+
if (items.length > 0) {
|
|
211
|
+
done(items[Math.min(selected, items.length - 1)]!.value);
|
|
212
|
+
}
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (matchesKey(data, "escape")) {
|
|
216
|
+
done();
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (matchesKey(data, "backspace") || matchesKey(data, "ctrl+h")) {
|
|
220
|
+
search = search.slice(0, -1);
|
|
221
|
+
selected = 0;
|
|
222
|
+
ctx.tui.requestRender();
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (data.length === 1 && data >= " " && data !== "\x7f") {
|
|
226
|
+
search += data;
|
|
227
|
+
selected = 0;
|
|
228
|
+
ctx.tui.requestRender();
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
return component;
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export const enumRenderer: FieldRenderer<EnumField, string> = {
|
|
237
|
+
type: "enum",
|
|
238
|
+
renderValue(row, { selected, ctx }) {
|
|
239
|
+
const text = labelFor(row.field, row.value);
|
|
240
|
+
if (row.field.disabled) {
|
|
241
|
+
return ctx.theme.fg("muted", text);
|
|
242
|
+
}
|
|
243
|
+
return ctx.theme.fg(selected ? "accent" : "muted", text);
|
|
244
|
+
},
|
|
245
|
+
hints(row) {
|
|
246
|
+
if (row.field.disabled) return [];
|
|
247
|
+
// Searchable enums always open a list submenu
|
|
248
|
+
if (row.field.search) {
|
|
249
|
+
return [{ key: "enter", label: "open list" }];
|
|
250
|
+
}
|
|
251
|
+
const threshold = row.field.cycleThreshold ?? DEFAULT_CYCLE_THRESHOLD;
|
|
252
|
+
if (row.field.options.length > threshold) {
|
|
253
|
+
return [{ key: "enter", label: "open list" }];
|
|
254
|
+
}
|
|
255
|
+
return [
|
|
256
|
+
{ key: "enter/space", label: "cycle" },
|
|
257
|
+
{ key: "←/→", label: "prev/next" },
|
|
258
|
+
];
|
|
259
|
+
},
|
|
260
|
+
handleKey(row, data, { ctx }) {
|
|
261
|
+
if (row.field.disabled) return {};
|
|
262
|
+
// Searchable enums always open a submenu on Enter/Space
|
|
263
|
+
if (row.field.search) {
|
|
264
|
+
if (
|
|
265
|
+
matchesKey(data, "enter") ||
|
|
266
|
+
matchesKey(data, "return") ||
|
|
267
|
+
data === " "
|
|
268
|
+
) {
|
|
269
|
+
return {
|
|
270
|
+
consumed: true,
|
|
271
|
+
submenu: makeEnumSubmenu(row.field, row.value, ctx),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
// No cycling for searchable enums — all navigation is in the submenu
|
|
275
|
+
return {};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const threshold = row.field.cycleThreshold ?? DEFAULT_CYCLE_THRESHOLD;
|
|
279
|
+
const isLongList = row.field.options.length > threshold;
|
|
280
|
+
|
|
281
|
+
if (
|
|
282
|
+
matchesKey(data, "enter") ||
|
|
283
|
+
matchesKey(data, "return") ||
|
|
284
|
+
data === " "
|
|
285
|
+
) {
|
|
286
|
+
if (isLongList) {
|
|
287
|
+
return {
|
|
288
|
+
consumed: true,
|
|
289
|
+
submenu: makeEnumSubmenu(row.field, row.value, ctx),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
return { consumed: true, commit: nextCycleValue(row.field, row.value) };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (!isLongList) {
|
|
296
|
+
if (matchesKey(data, "left")) {
|
|
297
|
+
const prev = prevCycleValue(row.field, row.value);
|
|
298
|
+
if (prev === row.value) return { consumed: true };
|
|
299
|
+
return { consumed: true, commit: prev };
|
|
300
|
+
}
|
|
301
|
+
if (matchesKey(data, "right")) {
|
|
302
|
+
const next = nextCycleValue(row.field, row.value);
|
|
303
|
+
if (next === row.value) return { consumed: true };
|
|
304
|
+
return { consumed: true, commit: next };
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return {};
|
|
309
|
+
},
|
|
310
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in field renderers. The modal's `RENDERERS` lookup is
|
|
3
|
+
* constructed from these so user-supplied `type: "custom"` fields can
|
|
4
|
+
* coexist with built-ins without any registration ceremony.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Field, FieldRenderer } from "../types";
|
|
8
|
+
import { actionRenderer } from "./action";
|
|
9
|
+
import { booleanRenderer } from "./boolean";
|
|
10
|
+
import { customRenderer } from "./custom";
|
|
11
|
+
import { enumRenderer } from "./enum";
|
|
12
|
+
import { modelRenderer } from "./model";
|
|
13
|
+
import {
|
|
14
|
+
numberRenderer,
|
|
15
|
+
pathRenderer,
|
|
16
|
+
secretRenderer,
|
|
17
|
+
stringRenderer,
|
|
18
|
+
} from "./string";
|
|
19
|
+
import { textRenderer } from "./text";
|
|
20
|
+
|
|
21
|
+
export {
|
|
22
|
+
actionRenderer,
|
|
23
|
+
booleanRenderer,
|
|
24
|
+
customRenderer,
|
|
25
|
+
enumRenderer,
|
|
26
|
+
modelRenderer,
|
|
27
|
+
numberRenderer,
|
|
28
|
+
pathRenderer,
|
|
29
|
+
secretRenderer,
|
|
30
|
+
stringRenderer,
|
|
31
|
+
textRenderer,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Map of field discriminator → renderer. */
|
|
35
|
+
export const RENDERERS: Record<Field["type"], FieldRenderer<any, any>> = {
|
|
36
|
+
boolean: booleanRenderer,
|
|
37
|
+
enum: enumRenderer,
|
|
38
|
+
string: stringRenderer,
|
|
39
|
+
number: numberRenderer,
|
|
40
|
+
secret: secretRenderer,
|
|
41
|
+
path: pathRenderer,
|
|
42
|
+
text: textRenderer,
|
|
43
|
+
action: actionRenderer,
|
|
44
|
+
model: modelRenderer,
|
|
45
|
+
custom: customRenderer,
|
|
46
|
+
};
|