@monotykamary/pi-supervisor 0.5.9

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 (62) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/LICENSE +21 -0
  3. package/README.md +341 -0
  4. package/media/demo.mp4 +0 -0
  5. package/media/screenshot.png +0 -0
  6. package/package.json +87 -0
  7. package/src/compaction/brief.ts +841 -0
  8. package/src/compaction/build-sections.ts +340 -0
  9. package/src/compaction/causal-keys.ts +138 -0
  10. package/src/compaction/content.ts +68 -0
  11. package/src/compaction/extract/commits.ts +78 -0
  12. package/src/compaction/extract/goals.ts +79 -0
  13. package/src/compaction/extract/preferences.ts +52 -0
  14. package/src/compaction/extract/shared-symbols.ts +376 -0
  15. package/src/compaction/filter-noise.ts +47 -0
  16. package/src/compaction/format.ts +89 -0
  17. package/src/compaction/index.ts +38 -0
  18. package/src/compaction/normalize.ts +73 -0
  19. package/src/compaction/sanitize.ts +5 -0
  20. package/src/compaction/sections.ts +19 -0
  21. package/src/compaction/skill-collapse.ts +35 -0
  22. package/src/compaction/tool-args.ts +14 -0
  23. package/src/compaction/types.ts +26 -0
  24. package/src/core/analyzer.ts +58 -0
  25. package/src/core/index.ts +8 -0
  26. package/src/core/inference.ts +77 -0
  27. package/src/core/prompt-builder.ts +137 -0
  28. package/src/core/prompt-loader.ts +125 -0
  29. package/src/core/reframe.ts +27 -0
  30. package/src/fabric-provider.ts +115 -0
  31. package/src/global-config.ts +65 -0
  32. package/src/index.ts +514 -0
  33. package/src/session/client.ts +46 -0
  34. package/src/session/response-parser.ts +37 -0
  35. package/src/session/supervisor-session.ts +102 -0
  36. package/src/state/manager.ts +133 -0
  37. package/src/state/mid-run-signals.ts +103 -0
  38. package/src/state/patterns.ts +82 -0
  39. package/src/state/reframe.ts +27 -0
  40. package/src/subagent-detector.ts +94 -0
  41. package/src/types.ts +42 -0
  42. package/src/ui/animations.ts +95 -0
  43. package/src/ui/model-picker.ts +72 -0
  44. package/src/ui/model-settings-selector.ts +440 -0
  45. package/src/ui/model-sort.ts +101 -0
  46. package/src/ui/renderer.ts +314 -0
  47. package/src/ui/types.ts +48 -0
  48. package/tests/compaction.test.ts +507 -0
  49. package/tests/engine.test.ts +622 -0
  50. package/tests/ephemeral-supervision.test.ts +347 -0
  51. package/tests/fabric-provider.test.ts +55 -0
  52. package/tests/full-fidelity-snapshot.test.ts +250 -0
  53. package/tests/global-config.test.ts +74 -0
  54. package/tests/model-sort.test.ts +157 -0
  55. package/tests/parsing.test.ts +303 -0
  56. package/tests/state.test.ts +474 -0
  57. package/tests/status-widget.test.ts +539 -0
  58. package/tests/subagent-detector.test.ts +191 -0
  59. package/tests/supervise-command.test.ts +363 -0
  60. package/tests/supervise-model-command.test.ts +184 -0
  61. package/tsconfig.json +14 -0
  62. package/vitest.config.ts +15 -0
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Animation utilities for the supervisor widget.
3
+ */
4
+
5
+ import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
6
+ import type { WidgetState, WidgetAction } from './types.js';
7
+ import type { SupervisorIntervention } from '../types.js';
8
+ import { WIDGET_ID, ANIMATION_STEP_MS } from './types.js';
9
+
10
+ /** Type for the render function callback */
11
+ export type RenderFn = (
12
+ ctx: ExtensionContext,
13
+ snap: { outcome: string; interventions: SupervisorIntervention[] },
14
+ action: WidgetAction,
15
+ lastThinking: string,
16
+ hideFromBottom: number
17
+ ) => void;
18
+
19
+ /** Start the line-by-line clear animation - hides lines from bottom to top */
20
+ export function startLineClearAnimation(
21
+ ctx: ExtensionContext,
22
+ state: WidgetState,
23
+ renderFn: RenderFn
24
+ ): void {
25
+ if (!state.lastActiveState) return;
26
+
27
+ const actionType = state.lastActionType;
28
+ const isSupervisorDone = actionType === 'done';
29
+ const hasThinkingLines = state.lastThinkingLines.length > 0;
30
+ const targetVisibleCount = 0;
31
+
32
+ // Build the completion action based on what we're transitioning to
33
+ const getCompletionAction = (): WidgetAction => {
34
+ if (actionType === 'steering') {
35
+ const reframeTier =
36
+ state.storedAction?.type === 'steering' ? (state.storedAction.reframeTier ?? 0) : 0;
37
+ return { type: 'steering', message: '', reframeTier };
38
+ }
39
+ if (isSupervisorDone) {
40
+ return { type: 'done', reframeTier: 0 };
41
+ }
42
+ if (actionType === 'waiting') {
43
+ const message = state.storedAction?.type === 'waiting' ? state.storedAction.message : '';
44
+ const reframeTier =
45
+ state.storedAction?.type === 'waiting' ? (state.storedAction.reframeTier ?? 0) : 0;
46
+ return { type: 'waiting', message, reframeTier };
47
+ }
48
+ if (actionType === 'inferring') {
49
+ return { type: 'inferring' };
50
+ }
51
+ // watching or other active states
52
+ const reframeTier =
53
+ state.storedAction && 'reframeTier' in state.storedAction
54
+ ? (state.storedAction.reframeTier ?? 0)
55
+ : 0;
56
+ return { type: actionType, reframeTier } as WidgetAction;
57
+ };
58
+
59
+ const animateStep = () => {
60
+ const currentVisible = state.lastThinkingLines.length - state.hiddenFromBottomCount;
61
+
62
+ // No thinking lines: skip straight to completion (clear or final render)
63
+ if (!hasThinkingLines || currentVisible <= targetVisibleCount) {
64
+ // Animation complete
65
+ state.lastThinkingLines = [];
66
+ state.hiddenFromBottomCount = 0;
67
+ state.lastThinking = '';
68
+
69
+ if (isSupervisorDone) {
70
+ // Done: clear the widget entirely
71
+ state.lastActiveState = null;
72
+ state.storedAction = null;
73
+ ctx.ui.setWidget(WIDGET_ID, undefined);
74
+ } else if (actionType === 'inferring') {
75
+ // Inferring: render with empty outcome (no goal yet)
76
+ const inferSnap = {
77
+ outcome: '',
78
+ interventions: state.lastActiveState?.interventions ?? [],
79
+ };
80
+ renderFn(ctx, inferSnap, { type: 'inferring' }, '', 0);
81
+ } else {
82
+ // Steering/watching/other: render the final state with no thinking
83
+ renderFn(ctx, state.lastActiveState!, getCompletionAction(), '', 0);
84
+ }
85
+ return;
86
+ }
87
+
88
+ state.hiddenFromBottomCount++;
89
+ renderFn(ctx, state.lastActiveState!, getCompletionAction(), '', state.hiddenFromBottomCount);
90
+
91
+ state.animationTimer = setTimeout(animateStep, ANIMATION_STEP_MS);
92
+ };
93
+
94
+ animateStep();
95
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * model-picker — opens the supervisor's interactive model selector.
3
+ *
4
+ * Uses our own SupervisorModelSelectorComponent (a copy of pi-core's
5
+ * ModelSelectorComponent) so the supervisor picker has the same look and
6
+ * feel as pi's /model selector — DynamicBorder top/bottom, search, scope
7
+ * toggle, navigation — while keeping the choice isolated from pi's global
8
+ * default model. Selection respects pi-model-sort's last-used ordering.
9
+ *
10
+ * Returns the selected Model, or null if the user cancelled. The caller
11
+ * decides what to do with the choice (e.g. save to supervisor-config.json).
12
+ */
13
+
14
+ import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
15
+ import type { Model } from '@earendil-works/pi-ai';
16
+ import { SupervisorModelSelectorComponent } from './model-settings-selector.js';
17
+
18
+ /**
19
+ * Open the interactive model picker.
20
+ * Returns the selected Model, or null if the user cancelled.
21
+ */
22
+ export async function pickModel(
23
+ ctx: ExtensionContext,
24
+ currentProvider?: string,
25
+ currentModelId?: string
26
+ ): Promise<Model<any> | null> {
27
+ if (ctx.mode !== 'tui') {
28
+ if (!ctx.hasUI) return null;
29
+ const all = ctx.modelRegistry.getAll();
30
+ if (all.length === 0) {
31
+ ctx.ui.notify('No models available.', 'info');
32
+ return null;
33
+ }
34
+ const items = all.map((m) => `${m.name ?? m.id} (${m.provider}/${m.id})`);
35
+ const pick = await ctx.ui.select('Pick a model', items);
36
+ if (pick === undefined) return null;
37
+ const idx = items.indexOf(pick);
38
+ if (idx < 0) return null;
39
+ return all[idx] ?? null;
40
+ }
41
+ // Resolve the currently-selected supervisor model (to pre-highlight it).
42
+ // Falls back to undefined when the provider/modelId is unknown — the
43
+ // selector handles an undefined current model gracefully.
44
+ const currentModel =
45
+ currentProvider && currentModelId
46
+ ? ctx.modelRegistry.find(currentProvider, currentModelId)
47
+ : undefined;
48
+
49
+ return ctx.ui.custom<Model<any> | null>((tui, theme, _kb, done) => {
50
+ const component = new SupervisorModelSelectorComponent(
51
+ tui,
52
+ theme,
53
+ currentModel,
54
+ ctx.modelRegistry,
55
+ [], // no scoped-model cycling — show the full available model list
56
+ (model) => done(model),
57
+ () => done(null)
58
+ );
59
+
60
+ // Give focus so the search input is active immediately
61
+ component.focused = true;
62
+
63
+ return {
64
+ render: (width) => component.render(width),
65
+ invalidate: () => component.invalidate(),
66
+ handleInput: (data) => {
67
+ component.handleInput(data);
68
+ tui.requestRender();
69
+ },
70
+ };
71
+ });
72
+ }
@@ -0,0 +1,440 @@
1
+ /**
2
+ * SupervisorModelSelectorComponent — a copy of pi-core's ModelSelectorComponent
3
+ * used by the supervisor's own model picker (/supervise model).
4
+ *
5
+ * Copies pi-core's /model selector to the tee:
6
+ * - DynamicBorder top + bottom borders
7
+ * - Spacer-separated layout
8
+ * - all/scoped scope toggle (Tab) with hint text
9
+ * - search Input (fuzzy filter, mirrors getModelSelectorSearchText)
10
+ * - up/down navigation with wrap-around
11
+ * - Enter to select, Escape/Ctrl+C to cancel
12
+ * - scroll indicator + selected-model name line
13
+ * - error/empty-state handling
14
+ *
15
+ * Intentional differences from pi-core (the reasons we copy instead of reuse):
16
+ * - Theme is passed in. pi-core imports a global `theme` singleton, which is
17
+ * unsafe under jiti (the extension host keeps a separate module cache and
18
+ * the global theme may be undefined — see DynamicBorder's note). Passing
19
+ * the live theme in keeps borders and colors correct in extension mode.
20
+ * - No SettingsManager. pi-core's handleSelect() writes the choice into pi's
21
+ * global default model (the user's main chat model). The supervisor must
22
+ * NOT change the user's main model — it picks its own supervisor model,
23
+ * persisted by the caller to .pi/supervisor-config.json.
24
+ * - Sort respects pi-model-sort's last-used order when installed (see
25
+ * ./model-sort.ts), so the supervisor picker matches the user's /model
26
+ * ordering. Falls back to pi-core's provider sort otherwise.
27
+ */
28
+
29
+ import {
30
+ Container,
31
+ fuzzyFilter,
32
+ getKeybindings,
33
+ Input,
34
+ Spacer,
35
+ Text,
36
+ type TUI,
37
+ } from '@earendil-works/pi-tui';
38
+ import {
39
+ DynamicBorder,
40
+ keyHint,
41
+ type ModelRegistry,
42
+ type Theme,
43
+ } from '@earendil-works/pi-coding-agent';
44
+ import { modelsAreEqual, type Model } from '@earendil-works/pi-ai';
45
+ import {
46
+ buildModelKey,
47
+ hasUsageData,
48
+ readModelSortLastUsed,
49
+ sortByLastUsed,
50
+ type LastUsedMap,
51
+ } from './model-sort.js';
52
+
53
+ interface ModelItem {
54
+ provider: string;
55
+ id: string;
56
+ model: Model<any>;
57
+ }
58
+
59
+ interface ScopedModel {
60
+ model: Model<any>;
61
+ thinkingLevel?: string;
62
+ }
63
+
64
+ /**
65
+ * Replicates pi-core's internal getModelSelectorSearchText (not exported from
66
+ * the public API). Ranks exact provider-prefixed queries before proxy
67
+ * provider IDs like openrouter/openai/gpt-5, so the bare model ID is kept out
68
+ * of the leading position.
69
+ */
70
+ function getModelSelectorSearchText(item: { id: string; provider: string; name?: string }): string {
71
+ const { id, provider } = item;
72
+ const name = item.name ? ` ${item.name}` : '';
73
+ return `${provider} ${provider}/${id} ${provider} ${id}${name}`;
74
+ }
75
+
76
+ function currentModelKey(model: Model<any> | undefined): string | null {
77
+ if (!model?.provider || !model?.id) return null;
78
+ return buildModelKey(model.provider, model.id);
79
+ }
80
+
81
+ export class SupervisorModelSelectorComponent extends Container {
82
+ // Focusable — propagate to searchInput for IME cursor positioning
83
+ private _focused = false;
84
+ get focused(): boolean {
85
+ return this._focused;
86
+ }
87
+ set focused(value: boolean) {
88
+ this._focused = value;
89
+ this.searchInput.focused = value;
90
+ }
91
+
92
+ private readonly theme: Theme;
93
+ private readonly tui: TUI;
94
+ private readonly modelRegistry: ModelRegistry;
95
+ private currentModel: Model<any> | undefined;
96
+ private scopedModels: ScopedModel[];
97
+
98
+ private readonly searchInput: Input;
99
+ private readonly listContainer: Container;
100
+ private scopeText?: Text;
101
+ private scopeHintText?: Text;
102
+
103
+ private allModels: ModelItem[] = [];
104
+ private scopedModelItems: ModelItem[] = [];
105
+ private activeModels: ModelItem[] = [];
106
+ private filteredModels: ModelItem[] = [];
107
+ private selectedIndex = 0;
108
+ private scope: 'all' | 'scoped' = 'all';
109
+ private errorMessage?: string;
110
+
111
+ private readonly lastUsed: LastUsedMap | null;
112
+ private readonly onSelect: (model: Model<any>) => void;
113
+ private readonly onCancel: () => void;
114
+
115
+ constructor(
116
+ tui: TUI,
117
+ theme: Theme,
118
+ currentModel: Model<any> | undefined,
119
+ modelRegistry: ModelRegistry,
120
+ scopedModels: ScopedModel[],
121
+ onSelect: (model: Model<any>) => void,
122
+ onCancel: () => void,
123
+ initialSearchInput?: string
124
+ ) {
125
+ super();
126
+ this.tui = tui;
127
+ this.theme = theme;
128
+ this.currentModel = currentModel;
129
+ this.modelRegistry = modelRegistry;
130
+ this.scopedModels = scopedModels;
131
+ this.scope = scopedModels.length > 0 ? 'scoped' : 'all';
132
+ this.onSelect = onSelect;
133
+ this.onCancel = onCancel;
134
+ this.lastUsed = readModelSortLastUsed();
135
+
136
+ // Top border
137
+ this.addChild(new DynamicBorder((s) => theme.fg('border', s)));
138
+ this.addChild(new Spacer(1));
139
+
140
+ // Scope hint, or the "configure more providers" warning
141
+ if (scopedModels.length > 0) {
142
+ this.scopeText = new Text(this.getScopeText(), 0, 0);
143
+ this.addChild(this.scopeText);
144
+ this.scopeHintText = new Text(this.getScopeHintText(), 0, 0);
145
+ this.addChild(this.scopeHintText);
146
+ } else {
147
+ const hintText =
148
+ 'Only showing models from configured providers. Use /login to add providers.';
149
+ this.addChild(new Text(theme.fg('warning', hintText), 0, 0));
150
+ }
151
+ this.addChild(new Spacer(1));
152
+
153
+ // Search input
154
+ this.searchInput = new Input();
155
+ if (initialSearchInput) {
156
+ this.searchInput.setValue(initialSearchInput);
157
+ }
158
+ this.searchInput.onSubmit = () => {
159
+ // Enter on the search input selects the highlighted filtered item
160
+ if (this.filteredModels[this.selectedIndex]) {
161
+ this.handleSelect(this.filteredModels[this.selectedIndex].model);
162
+ }
163
+ };
164
+ this.addChild(this.searchInput);
165
+ this.addChild(new Spacer(1));
166
+
167
+ // List
168
+ this.listContainer = new Container();
169
+ this.addChild(this.listContainer);
170
+ this.addChild(new Spacer(1));
171
+
172
+ // Bottom border
173
+ this.addChild(new DynamicBorder((s) => theme.fg('border', s)));
174
+
175
+ // Load models and do the initial render
176
+ this.loadModels().then(() => {
177
+ if (initialSearchInput) {
178
+ this.filterModels(initialSearchInput);
179
+ } else {
180
+ this.updateList();
181
+ }
182
+ this.tui.requestRender();
183
+ });
184
+ }
185
+
186
+ private async loadModels(): Promise<void> {
187
+ let models: ModelItem[];
188
+ // Refresh to pick up any changes to models.json
189
+ await this.modelRegistry.refresh();
190
+ const loadError = this.modelRegistry.getError();
191
+ if (loadError) {
192
+ this.errorMessage = loadError;
193
+ }
194
+
195
+ try {
196
+ const availableModels = this.modelRegistry.getAvailable();
197
+ models = availableModels.map((model) => ({
198
+ provider: model.provider,
199
+ id: model.id,
200
+ model,
201
+ }));
202
+ } catch (error) {
203
+ this.allModels = [];
204
+ this.scopedModelItems = [];
205
+ this.activeModels = [];
206
+ this.filteredModels = [];
207
+ this.errorMessage = error instanceof Error ? error.message : String(error);
208
+ return;
209
+ }
210
+
211
+ this.allModels = this.sortModels(models);
212
+ this.scopedModels = this.scopedModels.map((scoped) => {
213
+ const refreshed = this.modelRegistry.find(scoped.model.provider, scoped.model.id);
214
+ return refreshed ? { ...scoped, model: refreshed } : scoped;
215
+ });
216
+ this.scopedModelItems = this.scopedModels.map((scoped) => ({
217
+ provider: scoped.model.provider,
218
+ id: scoped.model.id,
219
+ model: scoped.model,
220
+ }));
221
+ this.activeModels = this.scope === 'scoped' ? this.scopedModelItems : this.allModels;
222
+ this.filteredModels = this.activeModels;
223
+
224
+ const currentIndex = this.filteredModels.findIndex((item) =>
225
+ modelsAreEqual(this.currentModel, item.model)
226
+ );
227
+ this.selectedIndex =
228
+ currentIndex >= 0
229
+ ? currentIndex
230
+ : Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1));
231
+ }
232
+
233
+ /**
234
+ * Sort the model list. When pi-model-sort is installed and has recorded
235
+ * usage, mirror its last-used order (current model first → most recent →
236
+ * provider/id). Otherwise fall back to pi-core's default: current model
237
+ * first, then by provider.
238
+ */
239
+ private sortModels(models: ModelItem[]): ModelItem[] {
240
+ if (hasUsageData(this.lastUsed)) {
241
+ return sortByLastUsed(models, this.lastUsed, currentModelKey(this.currentModel));
242
+ }
243
+
244
+ const sorted = [...models];
245
+ sorted.sort((a, b) => {
246
+ const aIsCurrent = modelsAreEqual(this.currentModel, a.model);
247
+ const bIsCurrent = modelsAreEqual(this.currentModel, b.model);
248
+ if (aIsCurrent && !bIsCurrent) return -1;
249
+ if (!aIsCurrent && bIsCurrent) return 1;
250
+ return a.provider.localeCompare(b.provider);
251
+ });
252
+ return sorted;
253
+ }
254
+
255
+ private getScopeText(): string {
256
+ const allText =
257
+ this.scope === 'all' ? this.theme.fg('accent', 'all') : this.theme.fg('muted', 'all');
258
+ const scopedText =
259
+ this.scope === 'scoped'
260
+ ? this.theme.fg('accent', 'scoped')
261
+ : this.theme.fg('muted', 'scoped');
262
+ return `${this.theme.fg('muted', 'Scope: ')}${allText}${this.theme.fg('muted', ' | ')}${scopedText}`;
263
+ }
264
+
265
+ private getScopeHintText(): string {
266
+ return keyHint('tui.input.tab', 'scope') + this.theme.fg('muted', ' (all/scoped)');
267
+ }
268
+
269
+ private setScope(scope: 'all' | 'scoped'): void {
270
+ if (this.scope === scope) return;
271
+ this.scope = scope;
272
+ this.activeModels = this.scope === 'scoped' ? this.scopedModelItems : this.allModels;
273
+ const currentIndex = this.activeModels.findIndex((item) =>
274
+ modelsAreEqual(this.currentModel, item.model)
275
+ );
276
+ this.selectedIndex = currentIndex >= 0 ? currentIndex : 0;
277
+ this.filterModels(this.searchInput.getValue());
278
+ if (this.scopeText) {
279
+ this.scopeText.setText(this.getScopeText());
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Filter the active model list by search query. After fuzzyFilter reorders
285
+ * results by match quality, re-apply the last-used sort (mirroring
286
+ * pi-model-sort's filterModels patch) so typing doesn't discard the
287
+ * usage-based order. Re-syncs the cursor onto the current model.
288
+ */
289
+ private filterModels(query: string): void {
290
+ this.filteredModels = query
291
+ ? fuzzyFilter(this.activeModels, query, ({ id, provider, model }) =>
292
+ getModelSelectorSearchText({ id, provider, name: model.name })
293
+ )
294
+ : this.activeModels;
295
+
296
+ if (query && hasUsageData(this.lastUsed) && this.filteredModels.length > 1) {
297
+ this.filteredModels = sortByLastUsed(
298
+ this.filteredModels,
299
+ this.lastUsed,
300
+ currentModelKey(this.currentModel)
301
+ );
302
+ const ck = currentModelKey(this.currentModel);
303
+ if (ck) {
304
+ const newIndex = this.filteredModels.findIndex(
305
+ (item) => buildModelKey(item.provider, item.id) === ck
306
+ );
307
+ if (newIndex >= 0) {
308
+ this.selectedIndex = newIndex;
309
+ }
310
+ }
311
+ }
312
+
313
+ this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1));
314
+ this.updateList();
315
+ }
316
+
317
+ private updateList(): void {
318
+ this.listContainer.clear();
319
+ const maxVisible = 10;
320
+ const startIndex = Math.max(
321
+ 0,
322
+ Math.min(
323
+ this.selectedIndex - Math.floor(maxVisible / 2),
324
+ this.filteredModels.length - maxVisible
325
+ )
326
+ );
327
+ const endIndex = Math.min(startIndex + maxVisible, this.filteredModels.length);
328
+
329
+ for (let i = startIndex; i < endIndex; i++) {
330
+ const item = this.filteredModels[i];
331
+ if (!item) continue;
332
+ const isSelected = i === this.selectedIndex;
333
+ const isCurrent = modelsAreEqual(this.currentModel, item.model);
334
+ let line: string;
335
+ if (isSelected) {
336
+ const prefix = this.theme.fg('accent', '→ ');
337
+ const modelText = `${item.id}`;
338
+ const providerBadge = this.theme.fg('muted', `[${item.provider}]`);
339
+ const checkmark = isCurrent ? this.theme.fg('success', ' ✓') : '';
340
+ line = `${prefix + this.theme.fg('accent', modelText)} ${providerBadge}${checkmark}`;
341
+ } else {
342
+ const modelText = ` ${item.id}`;
343
+ const providerBadge = this.theme.fg('muted', `[${item.provider}]`);
344
+ const checkmark = isCurrent ? this.theme.fg('success', ' ✓') : '';
345
+ line = `${modelText} ${providerBadge}${checkmark}`;
346
+ }
347
+ this.listContainer.addChild(new Text(line, 0, 0));
348
+ }
349
+
350
+ // Scroll indicator when the list is longer than the viewport
351
+ if (startIndex > 0 || endIndex < this.filteredModels.length) {
352
+ this.listContainer.addChild(
353
+ new Text(
354
+ this.theme.fg('muted', ` (${this.selectedIndex + 1}/${this.filteredModels.length})`),
355
+ 0,
356
+ 0
357
+ )
358
+ );
359
+ }
360
+
361
+ // Error / empty state, otherwise the selected model's name line
362
+ if (this.errorMessage) {
363
+ const errorLines = this.errorMessage.split('\n');
364
+ for (const line of errorLines) {
365
+ this.listContainer.addChild(new Text(this.theme.fg('error', line), 0, 0));
366
+ }
367
+ } else if (this.filteredModels.length === 0) {
368
+ this.listContainer.addChild(new Text(this.theme.fg('muted', ' No matching models'), 0, 0));
369
+ } else {
370
+ const selected = this.filteredModels[this.selectedIndex];
371
+ this.listContainer.addChild(new Spacer(1));
372
+ this.listContainer.addChild(
373
+ new Text(this.theme.fg('muted', ` Model Name: ${selected.model.name}`), 0, 0)
374
+ );
375
+ }
376
+ }
377
+
378
+ handleInput(data: string): void {
379
+ const kb = getKeybindings();
380
+
381
+ // Tab — toggle all/scoped scope (only when scoped models exist)
382
+ if (kb.matches(data, 'tui.input.tab')) {
383
+ if (this.scopedModelItems.length > 0) {
384
+ const nextScope = this.scope === 'all' ? 'scoped' : 'all';
385
+ this.setScope(nextScope);
386
+ if (this.scopeHintText) {
387
+ this.scopeHintText.setText(this.getScopeHintText());
388
+ }
389
+ }
390
+ return;
391
+ }
392
+
393
+ // Up — wrap to bottom
394
+ if (kb.matches(data, 'tui.select.up')) {
395
+ if (this.filteredModels.length === 0) return;
396
+ this.selectedIndex =
397
+ this.selectedIndex === 0 ? this.filteredModels.length - 1 : this.selectedIndex - 1;
398
+ this.updateList();
399
+ return;
400
+ }
401
+
402
+ // Down — wrap to top
403
+ if (kb.matches(data, 'tui.select.down')) {
404
+ if (this.filteredModels.length === 0) return;
405
+ this.selectedIndex =
406
+ this.selectedIndex === this.filteredModels.length - 1 ? 0 : this.selectedIndex + 1;
407
+ this.updateList();
408
+ return;
409
+ }
410
+
411
+ // Enter — select highlighted model
412
+ if (kb.matches(data, 'tui.select.confirm')) {
413
+ const selectedModel = this.filteredModels[this.selectedIndex];
414
+ if (selectedModel) {
415
+ this.handleSelect(selectedModel.model);
416
+ }
417
+ return;
418
+ }
419
+
420
+ // Escape / Ctrl+C — cancel
421
+ if (kb.matches(data, 'tui.select.cancel')) {
422
+ this.onCancel();
423
+ return;
424
+ }
425
+
426
+ // Everything else goes to the search input
427
+ this.searchInput.handleInput(data);
428
+ this.filterModels(this.searchInput.getValue());
429
+ }
430
+
431
+ private handleSelect(model: Model<any>): void {
432
+ // Unlike pi-core, do NOT write the user's global default model — the
433
+ // supervisor picks its own model. The caller persists the choice.
434
+ this.onSelect(model);
435
+ }
436
+
437
+ getSearchInput(): Input {
438
+ return this.searchInput;
439
+ }
440
+ }