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,452 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renderer for `type: "model"` — the two-axis "model + reasoning
|
|
3
|
+
* effort" widget that voice and web both reinvented before this
|
|
4
|
+
* package existed.
|
|
5
|
+
*
|
|
6
|
+
* UX:
|
|
7
|
+
* - Row collapsed: `<model> · <effort>` (or just `<model>` when
|
|
8
|
+
* `hideEffort` is set).
|
|
9
|
+
* - Enter opens a submenu with:
|
|
10
|
+
* · a typeable filter row at the top — printable keystrokes
|
|
11
|
+
* narrow the model list by name (case-insensitive substring);
|
|
12
|
+
* · `↑/↓` over a `SelectList` of models;
|
|
13
|
+
* · `←/→` over the supported effort ladder for the highlighted
|
|
14
|
+
* model (skipped entirely when `hideEffort` is set).
|
|
15
|
+
* Enter saves both axes atomically; Esc abandons.
|
|
16
|
+
* - Discovery: by default, models come from
|
|
17
|
+
* `pi.modelRegistry.getAvailable()` (filtered to entries the user
|
|
18
|
+
* has authed, matching voice's old behaviour). The caller can
|
|
19
|
+
* narrow with `filter` or replace the list entirely with `models`.
|
|
20
|
+
*
|
|
21
|
+
* The submenu's footer reuses `formatHintLine` so the hot-keys are
|
|
22
|
+
* highlighted with the same accent colour as the main modal's footer
|
|
23
|
+
* — no second-class colour scheme inside submenus.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { getSelectListTheme } from "@earendil-works/pi-coding-agent";
|
|
27
|
+
import type {
|
|
28
|
+
Api,
|
|
29
|
+
Model,
|
|
30
|
+
ModelThinkingLevel,
|
|
31
|
+
ThinkingLevelMap,
|
|
32
|
+
} from "@earendil-works/pi-ai";
|
|
33
|
+
import {
|
|
34
|
+
matchesKey,
|
|
35
|
+
SelectList,
|
|
36
|
+
SelectItem,
|
|
37
|
+
truncateToWidth,
|
|
38
|
+
} from "@earendil-works/pi-tui";
|
|
39
|
+
import { formatHintLine, type KeyHint } from "../frame";
|
|
40
|
+
import { handleInlineEditInput, type InlineEditState } from "../inline-edit";
|
|
41
|
+
import type {
|
|
42
|
+
FieldRenderContext,
|
|
43
|
+
FieldRenderer,
|
|
44
|
+
ModelField,
|
|
45
|
+
ModelOption,
|
|
46
|
+
ModelValue,
|
|
47
|
+
SubmenuFactory,
|
|
48
|
+
} from "../types";
|
|
49
|
+
|
|
50
|
+
const ALL_THINKING_LEVELS: ModelThinkingLevel[] = [
|
|
51
|
+
"off",
|
|
52
|
+
"minimal",
|
|
53
|
+
"low",
|
|
54
|
+
"medium",
|
|
55
|
+
"high",
|
|
56
|
+
"xhigh",
|
|
57
|
+
"max",
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
/** Short display labels for pi's internal thinking levels — used as a
|
|
61
|
+
* fallback when the model's `thinkingLevelMap` doesn't override one.
|
|
62
|
+
* Mirrors `THINK_LABELS` in `packages/statusline/index.ts` so the
|
|
63
|
+
* picker and statusline never disagree on what `medium` looks like. */
|
|
64
|
+
const LEVEL_FALLBACK_LABELS: Record<ModelThinkingLevel, string> = {
|
|
65
|
+
off: "off",
|
|
66
|
+
minimal: "min",
|
|
67
|
+
low: "low",
|
|
68
|
+
medium: "med",
|
|
69
|
+
high: "high",
|
|
70
|
+
xhigh: "xhigh",
|
|
71
|
+
max: "max",
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const DEFAULT_SESSION_LABEL = "(session model)";
|
|
75
|
+
const MAX_VISIBLE_MODELS = 12;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Resolve the human-readable label for a thinking level.
|
|
79
|
+
*
|
|
80
|
+
* Models may override per-level display strings via
|
|
81
|
+
* `thinkingLevelMap` (e.g. Anthropic models map `xhigh` to a token
|
|
82
|
+
* budget like `"60000"`); we honour those overrides exactly the
|
|
83
|
+
* same way `packages/statusline/index.ts` does. Missing keys and
|
|
84
|
+
* non-string values fall back to the short label. Pure presentation
|
|
85
|
+
* — callers still pass / store the canonical level name (`"xhigh"`).
|
|
86
|
+
*/
|
|
87
|
+
function effortDisplayLabel(
|
|
88
|
+
level: ModelThinkingLevel,
|
|
89
|
+
thinkingLevelMap: ThinkingLevelMap | undefined,
|
|
90
|
+
): string {
|
|
91
|
+
const mapped = thinkingLevelMap?.[level];
|
|
92
|
+
if (typeof mapped === "string" && mapped.length > 0) return mapped;
|
|
93
|
+
return LEVEL_FALLBACK_LABELS[level] ?? level;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function listModelOptions(
|
|
97
|
+
field: ModelField,
|
|
98
|
+
ctx: FieldRenderContext,
|
|
99
|
+
): ModelOption[] {
|
|
100
|
+
if (field.models) return field.models;
|
|
101
|
+
const sessionLabel = field.sessionLabel ?? DEFAULT_SESSION_LABEL;
|
|
102
|
+
const available = ctx.ctx.modelRegistry.getAvailable();
|
|
103
|
+
const filtered = field.filter ? available.filter(field.filter) : available;
|
|
104
|
+
const live: ModelOption[] = filtered.map((m) => ({
|
|
105
|
+
value: `${m.provider}/${m.id}`,
|
|
106
|
+
label: `${m.name} [${m.provider}]`,
|
|
107
|
+
model: m,
|
|
108
|
+
}));
|
|
109
|
+
return field.hideSession
|
|
110
|
+
? live
|
|
111
|
+
: [{ value: "", label: sessionLabel }, ...live];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Find the live `Model<Api>` for a stored value.
|
|
116
|
+
*
|
|
117
|
+
* Looks at the caller-provided `field.models` first (so `web` and
|
|
118
|
+
* other extensions that pre-build their option list still get rich
|
|
119
|
+
* label resolution), then falls back to the host model registry.
|
|
120
|
+
* Returns undefined for the `(session model)` sentinel and for any
|
|
121
|
+
* value that no longer matches a registered model.
|
|
122
|
+
*/
|
|
123
|
+
function resolveModelForValue(
|
|
124
|
+
field: ModelField,
|
|
125
|
+
value: ModelValue,
|
|
126
|
+
ctx: FieldRenderContext,
|
|
127
|
+
): Model<Api> | undefined {
|
|
128
|
+
if (!value.id) return undefined;
|
|
129
|
+
if (field.models) {
|
|
130
|
+
const opt = field.models.find((o) => o.value === value.id);
|
|
131
|
+
if (opt?.model) return opt.model;
|
|
132
|
+
// Pre-built option without a backing `Model<Api>` — we can't
|
|
133
|
+
// resolve thinkingLevelMap from here. Fall through to the registry.
|
|
134
|
+
}
|
|
135
|
+
const slash = value.id.indexOf("/");
|
|
136
|
+
if (slash <= 0) return undefined;
|
|
137
|
+
const provider = value.id.slice(0, slash);
|
|
138
|
+
const id = value.id.slice(slash + 1);
|
|
139
|
+
return ctx.ctx.modelRegistry.find(provider, id);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function supportedEfforts(model: Model<Api> | undefined): ModelThinkingLevel[] {
|
|
143
|
+
if (!model || !model.thinkingLevelMap) return ALL_THINKING_LEVELS;
|
|
144
|
+
const map = model.thinkingLevelMap;
|
|
145
|
+
return ALL_THINKING_LEVELS.filter(
|
|
146
|
+
(lvl) => !(lvl in map && map[lvl] === null),
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function clampEffort(
|
|
151
|
+
desired: ModelThinkingLevel | undefined,
|
|
152
|
+
supported: ModelThinkingLevel[],
|
|
153
|
+
): ModelThinkingLevel {
|
|
154
|
+
if (desired && supported.includes(desired)) return desired;
|
|
155
|
+
return supported.includes("medium") ? "medium" : (supported[0] ?? "off");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function modelDisplay(field: ModelField, value: ModelValue): string {
|
|
159
|
+
if (value.id === "") return field.sessionLabel ?? DEFAULT_SESSION_LABEL;
|
|
160
|
+
return value.id;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function rowLabel(
|
|
164
|
+
field: ModelField,
|
|
165
|
+
value: ModelValue,
|
|
166
|
+
ctx: FieldRenderContext,
|
|
167
|
+
): string {
|
|
168
|
+
const left = modelDisplay(field, value);
|
|
169
|
+
if (field.hideEffort) return left;
|
|
170
|
+
if (!value.thinking) return left;
|
|
171
|
+
// Honour the model's `thinkingLevelMap` overrides so the row
|
|
172
|
+
// displays the same label statusline shows (e.g. token-budget
|
|
173
|
+
// numbers for Anthropic) instead of pi's internal level name.
|
|
174
|
+
const model = resolveModelForValue(field, value, ctx);
|
|
175
|
+
const label = effortDisplayLabel(value.thinking, model?.thinkingLevelMap);
|
|
176
|
+
return `${left} · ${label}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Build the submenu component. The submenu owns three pieces of state:
|
|
181
|
+
*
|
|
182
|
+
* 1. `filter` — the typeable name-filter buffer.
|
|
183
|
+
* 2. `effortIndex` / `supported` — current effort axis (only when
|
|
184
|
+
* `hideEffort` is unset).
|
|
185
|
+
* 3. The currently-mounted SelectList. We rebuild it whenever the
|
|
186
|
+
* filter changes so SelectList's internal selectedIndex stays
|
|
187
|
+
* coherent with the visible items.
|
|
188
|
+
*/
|
|
189
|
+
function makeSubmenu(
|
|
190
|
+
field: ModelField,
|
|
191
|
+
current: ModelValue,
|
|
192
|
+
ctx: FieldRenderContext,
|
|
193
|
+
): SubmenuFactory<ModelValue> {
|
|
194
|
+
return (done) => {
|
|
195
|
+
const allOptions = listModelOptions(field, ctx);
|
|
196
|
+
const showEffort = !field.hideEffort;
|
|
197
|
+
|
|
198
|
+
const filter: InlineEditState = { buffer: "", cursor: 0 };
|
|
199
|
+
|
|
200
|
+
let list!: SelectList;
|
|
201
|
+
let visibleOptions: ModelOption[] = [];
|
|
202
|
+
|
|
203
|
+
let effortIndex = 0;
|
|
204
|
+
let supported: ModelThinkingLevel[] = [];
|
|
205
|
+
|
|
206
|
+
const refreshEffort = (preferred: ModelThinkingLevel | undefined): void => {
|
|
207
|
+
if (!showEffort) return;
|
|
208
|
+
const item = list.getSelectedItem();
|
|
209
|
+
const opt = visibleOptions.find((m) => m.value === item?.value);
|
|
210
|
+
supported = supportedEfforts(opt?.model);
|
|
211
|
+
if (supported.length === 0) supported = ["off"];
|
|
212
|
+
const clamped = clampEffort(preferred, supported);
|
|
213
|
+
effortIndex = Math.max(0, supported.indexOf(clamped));
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
/** Build / rebuild the SelectList from the current filter. Tries
|
|
217
|
+
* to keep the highlight on `preserveValue` when possible so
|
|
218
|
+
* cursor focus doesn't jump around on every keystroke. */
|
|
219
|
+
const buildList = (preserveValue: string | undefined): void => {
|
|
220
|
+
const query = filter.buffer.trim().toLowerCase();
|
|
221
|
+
visibleOptions = query
|
|
222
|
+
? allOptions.filter(
|
|
223
|
+
(o) =>
|
|
224
|
+
o.label.toLowerCase().includes(query) ||
|
|
225
|
+
o.value.toLowerCase().includes(query),
|
|
226
|
+
)
|
|
227
|
+
: allOptions;
|
|
228
|
+
|
|
229
|
+
const items: SelectItem[] = visibleOptions.map((m) => ({
|
|
230
|
+
value: m.value,
|
|
231
|
+
label: m.label,
|
|
232
|
+
description: m.value || undefined,
|
|
233
|
+
}));
|
|
234
|
+
list = new SelectList(
|
|
235
|
+
items,
|
|
236
|
+
Math.min(Math.max(items.length, 1), MAX_VISIBLE_MODELS),
|
|
237
|
+
getSelectListTheme(),
|
|
238
|
+
);
|
|
239
|
+
const idx =
|
|
240
|
+
preserveValue !== undefined
|
|
241
|
+
? items.findIndex((i) => i.value === preserveValue)
|
|
242
|
+
: -1;
|
|
243
|
+
list.setSelectedIndex(idx >= 0 ? idx : 0);
|
|
244
|
+
|
|
245
|
+
list.onSelect = (item) => {
|
|
246
|
+
if (showEffort) {
|
|
247
|
+
const effort = supported[effortIndex] ?? "off";
|
|
248
|
+
done({ id: item.value, thinking: effort });
|
|
249
|
+
} else {
|
|
250
|
+
done({ id: item.value });
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
list.onCancel = () => done();
|
|
254
|
+
list.onSelectionChange = () => {
|
|
255
|
+
if (showEffort) {
|
|
256
|
+
const previous = supported[effortIndex];
|
|
257
|
+
refreshEffort(previous);
|
|
258
|
+
}
|
|
259
|
+
ctx.tui.requestRender();
|
|
260
|
+
};
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
buildList(current.id);
|
|
264
|
+
refreshEffort(current.thinking);
|
|
265
|
+
|
|
266
|
+
const renderEffortRow = (width: number): string => {
|
|
267
|
+
const currentLevel = supported[effortIndex] ?? "off";
|
|
268
|
+
// Resolve the override label from the highlighted model's
|
|
269
|
+
// thinkingLevelMap so the user sees what statusline shows
|
|
270
|
+
// (token budget for Anthropic, etc.) rather than pi's
|
|
271
|
+
// internal level name.
|
|
272
|
+
const item = list.getSelectedItem();
|
|
273
|
+
const opt = visibleOptions.find((m) => m.value === item?.value);
|
|
274
|
+
const displayLabel = effortDisplayLabel(
|
|
275
|
+
currentLevel,
|
|
276
|
+
opt?.model?.thinkingLevelMap,
|
|
277
|
+
);
|
|
278
|
+
const left =
|
|
279
|
+
effortIndex > 0
|
|
280
|
+
? ctx.theme.fg("accent", "‹")
|
|
281
|
+
: ctx.theme.fg("dim", "‹");
|
|
282
|
+
const right =
|
|
283
|
+
effortIndex < supported.length - 1
|
|
284
|
+
? ctx.theme.fg("accent", "›")
|
|
285
|
+
: ctx.theme.fg("dim", "›");
|
|
286
|
+
const label = ctx.theme.fg("muted", " effort: ");
|
|
287
|
+
// Show the canonical level name in dim parentheses next to the
|
|
288
|
+
// override so power-users still know which pi level they're
|
|
289
|
+
// selecting (e.g. `60000 (xhigh)`).
|
|
290
|
+
const valueText =
|
|
291
|
+
displayLabel === currentLevel
|
|
292
|
+
? displayLabel
|
|
293
|
+
: `${displayLabel} ${ctx.theme.fg("dim", `(${currentLevel})`)}`;
|
|
294
|
+
const value = ctx.theme.fg("accent", ctx.theme.bold(valueText));
|
|
295
|
+
const counter = ctx.theme.fg(
|
|
296
|
+
"dim",
|
|
297
|
+
` (${effortIndex + 1}/${supported.length})`,
|
|
298
|
+
);
|
|
299
|
+
return truncateToWidth(
|
|
300
|
+
`${label}${left} ${value} ${right}${counter}`,
|
|
301
|
+
width,
|
|
302
|
+
"…",
|
|
303
|
+
true,
|
|
304
|
+
);
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const renderFilterRow = (width: number): string => {
|
|
308
|
+
const cursorBlock = ctx.theme.inverse(" ");
|
|
309
|
+
const buf = filter.buffer;
|
|
310
|
+
const before = buf.slice(0, filter.cursor);
|
|
311
|
+
const after = buf.slice(filter.cursor);
|
|
312
|
+
const placeholder = !buf ? ctx.theme.fg("dim", " type to filter…") : "";
|
|
313
|
+
const hasMatches = visibleOptions.length > 0;
|
|
314
|
+
const colorKey = hasMatches ? "accent" : "warning";
|
|
315
|
+
const text = buf
|
|
316
|
+
? ` ${ctx.theme.fg("muted", "filter:")} ${ctx.theme.fg(colorKey, before)}${cursorBlock}${ctx.theme.fg(colorKey, after)}`
|
|
317
|
+
: ` ${ctx.theme.fg("muted", "filter:")} ${cursorBlock}${placeholder}`;
|
|
318
|
+
return truncateToWidth(text, width, "…", true);
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const renderHints = (width: number): string => {
|
|
322
|
+
const hints: KeyHint[] = [{ key: "↑↓", label: "model" }];
|
|
323
|
+
if (showEffort) hints.push({ key: "←→", label: "effort" });
|
|
324
|
+
hints.push(
|
|
325
|
+
{ key: "type", label: "filter" },
|
|
326
|
+
{ key: "enter", label: "save" },
|
|
327
|
+
);
|
|
328
|
+
if (filter.buffer !== "") {
|
|
329
|
+
hints.push({ key: "esc", label: "clear filter" });
|
|
330
|
+
} else {
|
|
331
|
+
hints.push({ key: "esc", label: "cancel" });
|
|
332
|
+
}
|
|
333
|
+
return truncateToWidth(
|
|
334
|
+
` ${formatHintLine(hints, ctx.theme)}`,
|
|
335
|
+
width,
|
|
336
|
+
"…",
|
|
337
|
+
true,
|
|
338
|
+
);
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
render(width: number): string[] {
|
|
343
|
+
const lines: string[] = [];
|
|
344
|
+
lines.push(renderFilterRow(width));
|
|
345
|
+
lines.push("");
|
|
346
|
+
if (visibleOptions.length === 0) {
|
|
347
|
+
lines.push(
|
|
348
|
+
ctx.theme.fg("muted", " No matching models. (press esc to clear)"),
|
|
349
|
+
);
|
|
350
|
+
} else {
|
|
351
|
+
for (const line of list.render(width)) lines.push(line);
|
|
352
|
+
}
|
|
353
|
+
lines.push("");
|
|
354
|
+
if (showEffort) {
|
|
355
|
+
lines.push(renderEffortRow(width));
|
|
356
|
+
lines.push("");
|
|
357
|
+
}
|
|
358
|
+
lines.push(renderHints(width));
|
|
359
|
+
return lines;
|
|
360
|
+
},
|
|
361
|
+
invalidate(): void {
|
|
362
|
+
list.invalidate();
|
|
363
|
+
},
|
|
364
|
+
handleInput(data: string): void {
|
|
365
|
+
// Effort axis (only when not hidden) — must run before the
|
|
366
|
+
// filter handler so ←/→ aren't typed into the search buffer.
|
|
367
|
+
if (showEffort) {
|
|
368
|
+
if (matchesKey(data, "left")) {
|
|
369
|
+
if (effortIndex > 0) {
|
|
370
|
+
effortIndex -= 1;
|
|
371
|
+
ctx.tui.requestRender();
|
|
372
|
+
}
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
if (matchesKey(data, "right")) {
|
|
376
|
+
if (effortIndex < supported.length - 1) {
|
|
377
|
+
effortIndex += 1;
|
|
378
|
+
ctx.tui.requestRender();
|
|
379
|
+
}
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
// Intercept escape key to clear filter first
|
|
384
|
+
if (matchesKey(data, "escape") && filter.buffer !== "") {
|
|
385
|
+
const previousValue = list.getSelectedItem()?.value;
|
|
386
|
+
filter.buffer = "";
|
|
387
|
+
filter.cursor = 0;
|
|
388
|
+
buildList(previousValue);
|
|
389
|
+
refreshEffort(supported[effortIndex]);
|
|
390
|
+
ctx.tui.requestRender();
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
// Up / Down / Enter / Esc go to the SelectList first so the
|
|
394
|
+
// user can still drive the list while typing.
|
|
395
|
+
if (
|
|
396
|
+
matchesKey(data, "up") ||
|
|
397
|
+
matchesKey(data, "down") ||
|
|
398
|
+
matchesKey(data, "enter") ||
|
|
399
|
+
matchesKey(data, "return") ||
|
|
400
|
+
matchesKey(data, "escape") ||
|
|
401
|
+
matchesKey(data, "ctrl+c")
|
|
402
|
+
) {
|
|
403
|
+
list.handleInput(data);
|
|
404
|
+
ctx.tui.requestRender();
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
// Everything else (printable chars, backspace, ctrl+u, …) is
|
|
408
|
+
// routed to the inline-edit state machine.
|
|
409
|
+
const previousValue = list.getSelectedItem()?.value;
|
|
410
|
+
const consumed = handleInlineEditInput(filter, data);
|
|
411
|
+
if (consumed) {
|
|
412
|
+
buildList(previousValue);
|
|
413
|
+
refreshEffort(supported[effortIndex]);
|
|
414
|
+
ctx.tui.requestRender();
|
|
415
|
+
}
|
|
416
|
+
},
|
|
417
|
+
};
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export const modelRenderer: FieldRenderer<ModelField, ModelValue> = {
|
|
422
|
+
type: "model",
|
|
423
|
+
renderValue(row, { selected, ctx }) {
|
|
424
|
+
const text = rowLabel(row.field, row.value, ctx);
|
|
425
|
+
if (row.field.disabled) {
|
|
426
|
+
return ctx.theme.fg("muted", text);
|
|
427
|
+
}
|
|
428
|
+
return ctx.theme.fg(selected ? "accent" : "muted", text);
|
|
429
|
+
},
|
|
430
|
+
hints(row) {
|
|
431
|
+
if (row.field.disabled) return [];
|
|
432
|
+
// The main-window hint advertises only what the row itself does
|
|
433
|
+
// there: opening the submenu. `↑↓` and `←→` only fire **inside**
|
|
434
|
+
// the submenu — emitting them at row level would mislead the
|
|
435
|
+
// user about what the keys do. The submenu has its own footer.
|
|
436
|
+
return [{ key: "enter", label: "open" }];
|
|
437
|
+
},
|
|
438
|
+
handleKey(row, data, { ctx }) {
|
|
439
|
+
if (row.field.disabled) return {};
|
|
440
|
+
if (
|
|
441
|
+
matchesKey(data, "enter") ||
|
|
442
|
+
matchesKey(data, "return") ||
|
|
443
|
+
data === " "
|
|
444
|
+
) {
|
|
445
|
+
return {
|
|
446
|
+
consumed: true,
|
|
447
|
+
submenu: makeSubmenu(row.field, row.value, ctx),
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
return {};
|
|
451
|
+
},
|
|
452
|
+
};
|