@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.
- package/CHANGELOG.md +120 -0
- package/LICENSE +21 -0
- package/README.md +341 -0
- package/media/demo.mp4 +0 -0
- package/media/screenshot.png +0 -0
- package/package.json +87 -0
- package/src/compaction/brief.ts +841 -0
- package/src/compaction/build-sections.ts +340 -0
- package/src/compaction/causal-keys.ts +138 -0
- package/src/compaction/content.ts +68 -0
- package/src/compaction/extract/commits.ts +78 -0
- package/src/compaction/extract/goals.ts +79 -0
- package/src/compaction/extract/preferences.ts +52 -0
- package/src/compaction/extract/shared-symbols.ts +376 -0
- package/src/compaction/filter-noise.ts +47 -0
- package/src/compaction/format.ts +89 -0
- package/src/compaction/index.ts +38 -0
- package/src/compaction/normalize.ts +73 -0
- package/src/compaction/sanitize.ts +5 -0
- package/src/compaction/sections.ts +19 -0
- package/src/compaction/skill-collapse.ts +35 -0
- package/src/compaction/tool-args.ts +14 -0
- package/src/compaction/types.ts +26 -0
- package/src/core/analyzer.ts +58 -0
- package/src/core/index.ts +8 -0
- package/src/core/inference.ts +77 -0
- package/src/core/prompt-builder.ts +137 -0
- package/src/core/prompt-loader.ts +125 -0
- package/src/core/reframe.ts +27 -0
- package/src/fabric-provider.ts +115 -0
- package/src/global-config.ts +65 -0
- package/src/index.ts +514 -0
- package/src/session/client.ts +46 -0
- package/src/session/response-parser.ts +37 -0
- package/src/session/supervisor-session.ts +102 -0
- package/src/state/manager.ts +133 -0
- package/src/state/mid-run-signals.ts +103 -0
- package/src/state/patterns.ts +82 -0
- package/src/state/reframe.ts +27 -0
- package/src/subagent-detector.ts +94 -0
- package/src/types.ts +42 -0
- package/src/ui/animations.ts +95 -0
- package/src/ui/model-picker.ts +72 -0
- package/src/ui/model-settings-selector.ts +440 -0
- package/src/ui/model-sort.ts +101 -0
- package/src/ui/renderer.ts +314 -0
- package/src/ui/types.ts +48 -0
- package/tests/compaction.test.ts +507 -0
- package/tests/engine.test.ts +622 -0
- package/tests/ephemeral-supervision.test.ts +347 -0
- package/tests/fabric-provider.test.ts +55 -0
- package/tests/full-fidelity-snapshot.test.ts +250 -0
- package/tests/global-config.test.ts +74 -0
- package/tests/model-sort.test.ts +157 -0
- package/tests/parsing.test.ts +303 -0
- package/tests/state.test.ts +474 -0
- package/tests/status-widget.test.ts +539 -0
- package/tests/subagent-detector.test.ts +191 -0
- package/tests/supervise-command.test.ts +363 -0
- package/tests/supervise-model-command.test.ts +184 -0
- package/tsconfig.json +14 -0
- 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
|
+
}
|