@jopqior/pi-subagents-model-selector 0.1.0 → 1.0.1
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 +11 -10
- package/package.json +4 -2
- package/src/index.ts +30 -3
- package/src/model-search-text.ts +19 -0
- package/src/model-selector.ts +34 -77
- package/src/selection-form-component.ts +242 -0
- package/src/selection-form.ts +411 -0
- package/src/selection-labels.ts +17 -0
package/README.md
CHANGED
|
@@ -42,21 +42,24 @@ If the core is missing, failed to load, or lacks `registerSpawnSelectionProvider
|
|
|
42
42
|
|
|
43
43
|
## Behavior
|
|
44
44
|
|
|
45
|
-
Every **new** run in an enabled root's in-process tree
|
|
45
|
+
Every **new** run in an enabled root's in-process tree opens one `/model`-style form: model, thinking, and Submit.
|
|
46
46
|
Foreground tool calls, background tool calls, and `SubagentsService.spawn()` all go through the same gate.
|
|
47
47
|
|
|
48
48
|
The operator's pair is applied after ordinary call and config resolution.
|
|
49
49
|
It overrides defaults, explicit `model`/`thinking` arguments, and `locked:` values for those two fields only.
|
|
50
50
|
Other locked fields are unchanged.
|
|
51
51
|
|
|
52
|
-
The catalogue is every authenticated available model of the session whose manager is spawning — not a process-global list
|
|
53
|
-
|
|
52
|
+
The all-tab catalogue is every authenticated available model of the session whose manager is spawning — not a process-global list.
|
|
53
|
+
The scoped tab is `ctx.scopedModels` intersected with that catalogue, and is hidden when the intersection is empty.
|
|
54
|
+
Ctrl+S toggles all/scoped when the scoped tab exists.
|
|
55
|
+
A model that supports only `off` still shows `off` and requires an explicit choice on the thinking tab.
|
|
54
56
|
|
|
55
57
|
Concurrent requests are FIFO at the root chooser.
|
|
56
|
-
A queued run does not open
|
|
57
|
-
Cancel
|
|
58
|
+
A queued run does not open the form until it is admitted.
|
|
59
|
+
Cancel the form stops that run without creating a workspace or child session.
|
|
58
60
|
|
|
59
|
-
|
|
61
|
+
A missing TUI, a UI that is not yet attached, an empty catalogue, or an invalid selection fails that run explicitly.
|
|
62
|
+
Print, JSON, and RPC sessions fail closed — there is no `ui.select` fallback.
|
|
60
63
|
There is no default, timeout, remembered choice, or retry.
|
|
61
64
|
|
|
62
65
|
Nested children still route to the root UI, including when this package is excluded from them.
|
|
@@ -68,15 +71,13 @@ The parent's active model, thinking level, agent files, and tool-call arguments
|
|
|
68
71
|
## Lifecycle
|
|
69
72
|
|
|
70
73
|
The extension captures `getSubagentsService()` once during initialization and registers the provider immediately, before any `session_start` handler can spawn.
|
|
71
|
-
It attaches `ctx.ui.
|
|
74
|
+
It attaches `ctx.ui.custom` at `session_start` when `ctx.mode` is `tui`, and closes the chooser at `session_shutdown`.
|
|
72
75
|
A request that arrives before the UI is attached fails closed rather than waiting for a later sequential handler.
|
|
73
76
|
|
|
74
|
-
Print, JSON, and other non-interactive sessions cannot satisfy the selection requirement.
|
|
75
|
-
|
|
76
77
|
## Limitations
|
|
77
78
|
|
|
78
79
|
- Out-of-process children and third-party session factories are not covered.
|
|
79
|
-
- The
|
|
80
|
+
- The form rebuilds the `/model` experience without mounting Pi's `ModelSelectorComponent` (no type-to-filter on the thinking tab, no set-as-default).
|
|
80
81
|
- Public task status stays `running` while waiting.
|
|
81
82
|
Pending selection is private widget, foreground, and background wording.
|
|
82
83
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jopqior/pi-subagents-model-selector",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "Pi extension that asks for model and thinking before every new pi-subagents run.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"imports": {
|
|
@@ -44,7 +44,8 @@
|
|
|
44
44
|
},
|
|
45
45
|
"peerDependencies": {
|
|
46
46
|
"@earendil-works/pi-ai": ">=0.84.4",
|
|
47
|
-
"@earendil-works/pi-coding-agent": ">=0.84.4"
|
|
47
|
+
"@earendil-works/pi-coding-agent": ">=0.84.4",
|
|
48
|
+
"@earendil-works/pi-tui": ">=0.84.4"
|
|
48
49
|
},
|
|
49
50
|
"dependencies": {
|
|
50
51
|
"@jopqior/pi-subagents": "^1.0.0"
|
|
@@ -53,6 +54,7 @@
|
|
|
53
54
|
"@biomejs/biome": "^2.5.11",
|
|
54
55
|
"@earendil-works/pi-ai": "0.84.4",
|
|
55
56
|
"@earendil-works/pi-coding-agent": "0.84.4",
|
|
57
|
+
"@earendil-works/pi-tui": "0.84.4",
|
|
56
58
|
"@types/node": "^22.15.3",
|
|
57
59
|
"rumdl": "0.2.24",
|
|
58
60
|
"typescript": "^6.0.3",
|
package/src/index.ts
CHANGED
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
10
11
|
import { getSubagentsService } from "@jopqior/pi-subagents";
|
|
11
12
|
import { ModelSelector } from "./model-selector";
|
|
13
|
+
import { presentSelectionForm } from "./selection-form-component";
|
|
12
14
|
|
|
13
15
|
const MISSING_CORE_MESSAGE =
|
|
14
16
|
"@jopqior/pi-subagents-model-selector requires @jopqior/pi-subagents with registerSpawnSelectionProvider, loaded before this extension.";
|
|
@@ -34,9 +36,18 @@ export default function piSubagentsModelSelectorExtension(
|
|
|
34
36
|
|
|
35
37
|
pi.on("session_start", (_event, ctx) => {
|
|
36
38
|
chooser.attachUI({
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
ctx.
|
|
39
|
+
isTui: ctx.mode === "tui",
|
|
40
|
+
sessionFacts: () => ({
|
|
41
|
+
currentModel: ctx.model,
|
|
42
|
+
scopedModels: ctx.scopedModels,
|
|
43
|
+
defaultModel: readDefaultModel(ctx.cwd),
|
|
44
|
+
}),
|
|
45
|
+
presentForm: (input, signal) =>
|
|
46
|
+
presentSelectionForm(
|
|
47
|
+
(factory, options) => ctx.ui.custom(factory, options),
|
|
48
|
+
input,
|
|
49
|
+
signal,
|
|
50
|
+
),
|
|
40
51
|
});
|
|
41
52
|
});
|
|
42
53
|
|
|
@@ -45,3 +56,19 @@ export default function piSubagentsModelSelectorExtension(
|
|
|
45
56
|
registration.dispose();
|
|
46
57
|
});
|
|
47
58
|
}
|
|
59
|
+
|
|
60
|
+
function readDefaultModel(
|
|
61
|
+
cwd: string,
|
|
62
|
+
): { provider: string; id: string } | undefined {
|
|
63
|
+
try {
|
|
64
|
+
const settings = SettingsManager.create(cwd);
|
|
65
|
+
const provider = settings.getDefaultProvider();
|
|
66
|
+
const id = settings.getDefaultModel();
|
|
67
|
+
if (!provider || !id) {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
return { provider, id };
|
|
71
|
+
} catch {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* model-search-text.ts — Haystack for the spawn form's model filter.
|
|
3
|
+
*
|
|
4
|
+
* Copied from Pi 0.84.4 `getModelSelectorSearchText`, which is not a public
|
|
5
|
+
* SDK export. The bare model id is kept out of the leading position so a
|
|
6
|
+
* provider-prefixed query ranks before proxy-provider ids.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface ModelSearchItem {
|
|
10
|
+
readonly id: string;
|
|
11
|
+
readonly provider: string;
|
|
12
|
+
readonly name?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function getModelSelectorSearchText(item: ModelSearchItem): string {
|
|
16
|
+
const { id, provider } = item;
|
|
17
|
+
const name = item.name ? ` ${item.name}` : "";
|
|
18
|
+
return `${provider} ${provider}/${id} ${provider} ${id}${name}`;
|
|
19
|
+
}
|
package/src/model-selector.ts
CHANGED
|
@@ -1,36 +1,45 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* model-selector.ts —
|
|
2
|
+
* model-selector.ts — One TUI form for spawn model and thinking.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Missing TUI is an error, not an approval. Cancel returns undefined and
|
|
5
|
+
* creates no child. Concurrent requests stay FIFO at the queue.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
9
9
|
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
|
|
10
|
+
import type { ScopedModel } from "@earendil-works/pi-coding-agent";
|
|
10
11
|
import type {
|
|
11
12
|
SpawnSelection,
|
|
12
13
|
SpawnSelectionProvider,
|
|
13
14
|
SpawnSelectionRequest,
|
|
14
15
|
} from "@jopqior/pi-subagents";
|
|
16
|
+
import type { SelectionFormInput, SelectionFormResult } from "./selection-form";
|
|
17
|
+
import { modelTitle } from "./selection-labels";
|
|
15
18
|
import {
|
|
16
19
|
SelectionQueue,
|
|
17
20
|
SelectionQueueCancelledError,
|
|
18
21
|
SelectionQueueClosedError,
|
|
19
22
|
} from "./selection-queue";
|
|
20
23
|
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
const NO_UI = "Spawn model selection requires an interactive UI.";
|
|
24
|
+
const NO_UI = "Spawn model selection requires a TUI.";
|
|
24
25
|
const NO_MODELS = "No models are available to select.";
|
|
25
26
|
const CLOSED = "Spawn model selection is closed.";
|
|
26
27
|
|
|
28
|
+
export interface SelectionSessionFacts {
|
|
29
|
+
readonly currentModel: Model<Api> | undefined;
|
|
30
|
+
readonly scopedModels: readonly ScopedModel[];
|
|
31
|
+
readonly defaultModel:
|
|
32
|
+
| { readonly provider: string; readonly id: string }
|
|
33
|
+
| undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
27
36
|
export interface SelectionUIPort {
|
|
28
|
-
readonly
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
37
|
+
readonly isTui: boolean;
|
|
38
|
+
sessionFacts(): SelectionSessionFacts;
|
|
39
|
+
presentForm(
|
|
40
|
+
input: SelectionFormInput,
|
|
32
41
|
signal: AbortSignal,
|
|
33
|
-
): Promise<
|
|
42
|
+
): Promise<SelectionFormResult>;
|
|
34
43
|
}
|
|
35
44
|
|
|
36
45
|
export class ModelSelector implements SpawnSelectionProvider {
|
|
@@ -84,7 +93,7 @@ export class ModelSelector implements SpawnSelectionProvider {
|
|
|
84
93
|
if (this.queue.closed) {
|
|
85
94
|
throw new Error(CLOSED);
|
|
86
95
|
}
|
|
87
|
-
if (!this.ui?.
|
|
96
|
+
if (!this.ui?.isTui) {
|
|
88
97
|
throw new Error(NO_UI);
|
|
89
98
|
}
|
|
90
99
|
if (request.availableModels.length === 0) {
|
|
@@ -101,54 +110,28 @@ export class ModelSelector implements SpawnSelectionProvider {
|
|
|
101
110
|
return undefined;
|
|
102
111
|
}
|
|
103
112
|
const ui = this.ui;
|
|
104
|
-
if (!ui?.
|
|
113
|
+
if (!ui?.isTui) {
|
|
105
114
|
throw new Error(NO_UI);
|
|
106
115
|
}
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
116
|
+
const facts = ui.sessionFacts();
|
|
117
|
+
const result = await ui.presentForm(
|
|
118
|
+
{
|
|
119
|
+
title: modelTitle(request),
|
|
120
|
+
availableModels: request.availableModels,
|
|
121
|
+
currentModel: facts.currentModel,
|
|
122
|
+
scopedModels: facts.scopedModels,
|
|
123
|
+
defaultModel: facts.defaultModel,
|
|
124
|
+
levelsFor: this.levelsFor,
|
|
125
|
+
},
|
|
112
126
|
dialogSignal,
|
|
113
127
|
);
|
|
114
128
|
if (
|
|
115
|
-
|
|
129
|
+
result.kind === "cancel" ||
|
|
116
130
|
this.abandoned(requestSignal, dialogSignal)
|
|
117
131
|
) {
|
|
118
132
|
return undefined;
|
|
119
133
|
}
|
|
120
|
-
|
|
121
|
-
const model =
|
|
122
|
-
modelIndex < 0 ? undefined : request.availableModels[modelIndex];
|
|
123
|
-
if (model === undefined) {
|
|
124
|
-
throw new Error(
|
|
125
|
-
`Selected option ${JSON.stringify(modelChoice)} does not match any offered model.`,
|
|
126
|
-
);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const levels = [...this.levelsFor(model)];
|
|
130
|
-
if (levels.length === 0) {
|
|
131
|
-
throw new Error(
|
|
132
|
-
`Model ${model.provider}/${model.id} has no supported thinking levels.`,
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
const thinkingChoice = await ui.select(
|
|
136
|
-
thinkingTitle(model),
|
|
137
|
-
[...levels],
|
|
138
|
-
dialogSignal,
|
|
139
|
-
);
|
|
140
|
-
if (
|
|
141
|
-
thinkingChoice === undefined ||
|
|
142
|
-
this.abandoned(requestSignal, dialogSignal)
|
|
143
|
-
) {
|
|
144
|
-
return undefined;
|
|
145
|
-
}
|
|
146
|
-
if (!isListedLevel(thinkingChoice, levels)) {
|
|
147
|
-
throw new Error(
|
|
148
|
-
`Selected thinking level ${JSON.stringify(thinkingChoice)} is not supported.`,
|
|
149
|
-
);
|
|
150
|
-
}
|
|
151
|
-
return { model, thinkingLevel: thinkingChoice };
|
|
134
|
+
return { model: result.model, thinkingLevel: result.thinkingLevel };
|
|
152
135
|
}
|
|
153
136
|
|
|
154
137
|
private abandoned(
|
|
@@ -158,29 +141,3 @@ export class ModelSelector implements SpawnSelectionProvider {
|
|
|
158
141
|
return this.queue.closed || requestSignal.aborted || dialogSignal.aborted;
|
|
159
142
|
}
|
|
160
143
|
}
|
|
161
|
-
|
|
162
|
-
function isListedLevel(
|
|
163
|
-
value: string,
|
|
164
|
-
levels: readonly SpawnSelection["thinkingLevel"][],
|
|
165
|
-
): value is SpawnSelection["thinkingLevel"] {
|
|
166
|
-
return levels.some((level) => level === value);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function modelLabel(model: Model<Api>): string {
|
|
170
|
-
return `${model.provider}/${model.id} — ${model.name}`;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function boundDescription(description: string): string {
|
|
174
|
-
if (description.length <= DESCRIPTION_LIMIT) {
|
|
175
|
-
return description;
|
|
176
|
-
}
|
|
177
|
-
return `${description.slice(0, DESCRIPTION_LIMIT - 1)}…`;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function modelTitle(request: SpawnSelectionRequest): string {
|
|
181
|
-
return `Select model for ${request.agentType} ${request.agentId} — ${boundDescription(request.description)}`;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function thinkingTitle(model: Model<Api>): string {
|
|
185
|
-
return `Select thinking level for ${model.provider}/${model.id} (${model.name})`;
|
|
186
|
-
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* selection-form-component.ts — TUI adapter for the spawn selection form.
|
|
3
|
+
*
|
|
4
|
+
* Maps raw keys onto form events, owns the search Input, and bridges the
|
|
5
|
+
* queue's abort signal to `done` because `ui.custom` has no `signal` option.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
9
|
+
import { modelsAreEqual } from "@earendil-works/pi-ai";
|
|
10
|
+
import { type Component, Input, matchesKey } from "@earendil-works/pi-tui";
|
|
11
|
+
import {
|
|
12
|
+
createSelectionFormState,
|
|
13
|
+
reduceSelectionForm,
|
|
14
|
+
type SelectionFormEvent,
|
|
15
|
+
type SelectionFormInput,
|
|
16
|
+
type SelectionFormResult,
|
|
17
|
+
type SelectionFormState,
|
|
18
|
+
viewSelectionForm,
|
|
19
|
+
} from "./selection-form";
|
|
20
|
+
|
|
21
|
+
const MAX_VISIBLE = 10;
|
|
22
|
+
|
|
23
|
+
interface FormTheme {
|
|
24
|
+
fg(color: string, text: string): string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface FormKeybindings {
|
|
28
|
+
matches(data: string, action: string): boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type FormCustom = (
|
|
32
|
+
factory: (
|
|
33
|
+
tui: { requestRender: () => void },
|
|
34
|
+
theme: FormTheme,
|
|
35
|
+
keybindings: FormKeybindings,
|
|
36
|
+
done: (result: SelectionFormResult) => void,
|
|
37
|
+
) => Component,
|
|
38
|
+
options?: { overlay?: boolean },
|
|
39
|
+
) => Promise<SelectionFormResult>;
|
|
40
|
+
|
|
41
|
+
export function presentSelectionForm(
|
|
42
|
+
custom: FormCustom,
|
|
43
|
+
input: SelectionFormInput,
|
|
44
|
+
signal: AbortSignal,
|
|
45
|
+
): Promise<SelectionFormResult> {
|
|
46
|
+
if (signal.aborted) {
|
|
47
|
+
return Promise.resolve({ kind: "cancel" });
|
|
48
|
+
}
|
|
49
|
+
return custom(
|
|
50
|
+
(tui, theme, keybindings, done) => {
|
|
51
|
+
let closed = false;
|
|
52
|
+
const finish = (result: SelectionFormResult): void => {
|
|
53
|
+
if (closed) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
closed = true;
|
|
57
|
+
signal.removeEventListener("abort", onAbort);
|
|
58
|
+
done(result);
|
|
59
|
+
};
|
|
60
|
+
const onAbort = (): void => {
|
|
61
|
+
finish({ kind: "cancel" });
|
|
62
|
+
};
|
|
63
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
64
|
+
return new SelectionFormComponent(
|
|
65
|
+
input,
|
|
66
|
+
theme,
|
|
67
|
+
keybindings,
|
|
68
|
+
() => {
|
|
69
|
+
tui.requestRender();
|
|
70
|
+
},
|
|
71
|
+
finish,
|
|
72
|
+
);
|
|
73
|
+
},
|
|
74
|
+
{ overlay: false },
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
class SelectionFormComponent implements Component {
|
|
79
|
+
private state: SelectionFormState;
|
|
80
|
+
private readonly search = new Input();
|
|
81
|
+
|
|
82
|
+
constructor(
|
|
83
|
+
private readonly input: SelectionFormInput,
|
|
84
|
+
private readonly theme: FormTheme,
|
|
85
|
+
private readonly keybindings: FormKeybindings,
|
|
86
|
+
private readonly requestRender: () => void,
|
|
87
|
+
private readonly finish: (result: SelectionFormResult) => void,
|
|
88
|
+
) {
|
|
89
|
+
this.state = createSelectionFormState(input);
|
|
90
|
+
this.search.focused = true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
invalidate(): void {
|
|
94
|
+
// No cached rendering state to clear.
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
render(width: number): string[] {
|
|
98
|
+
const view = viewSelectionForm(this.state, this.input);
|
|
99
|
+
const lines: string[] = [this.theme.fg("accent", this.input.title), ""];
|
|
100
|
+
if (view.hasScoped) {
|
|
101
|
+
const allText =
|
|
102
|
+
view.scope === "all"
|
|
103
|
+
? this.theme.fg("accent", "all")
|
|
104
|
+
: this.theme.fg("muted", "all");
|
|
105
|
+
const scopedText =
|
|
106
|
+
view.scope === "scoped"
|
|
107
|
+
? this.theme.fg("accent", "scoped")
|
|
108
|
+
: this.theme.fg("muted", "scoped");
|
|
109
|
+
lines.push(
|
|
110
|
+
`${this.theme.fg("muted", "Scope: ")}${allText}${this.theme.fg("muted", " | ")}${scopedText}`,
|
|
111
|
+
);
|
|
112
|
+
lines.push(this.theme.fg("muted", "Ctrl+S scope (all/scoped)"));
|
|
113
|
+
} else {
|
|
114
|
+
lines.push(
|
|
115
|
+
this.theme.fg(
|
|
116
|
+
"warning",
|
|
117
|
+
"Only showing models from configured providers. Use /login to add providers.",
|
|
118
|
+
),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
lines.push("");
|
|
122
|
+
if (view.tab === "model") {
|
|
123
|
+
this.search.focused = true;
|
|
124
|
+
lines.push(...this.search.render(width));
|
|
125
|
+
lines.push("");
|
|
126
|
+
lines.push(...this.renderModelRows(view.models, view.highlightedIndex));
|
|
127
|
+
} else if (view.tab === "thinking") {
|
|
128
|
+
this.search.focused = false;
|
|
129
|
+
for (const [index, level] of view.thinkingLevels.entries()) {
|
|
130
|
+
const selected = index === view.thinkingHighlight;
|
|
131
|
+
const chosen =
|
|
132
|
+
level === view.thinkingLevel ? this.theme.fg("success", " ✓") : "";
|
|
133
|
+
const prefix = selected ? this.theme.fg("accent", "→ ") : " ";
|
|
134
|
+
const label = selected ? this.theme.fg("accent", level) : level;
|
|
135
|
+
lines.push(`${prefix}${label}${chosen}`);
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
const model = view.pendingModel;
|
|
139
|
+
const modelLabel = model ? `${model.id} [${model.provider}]` : "(none)";
|
|
140
|
+
lines.push(`Model: ${modelLabel}`);
|
|
141
|
+
lines.push(`Thinking: ${view.thinkingLevel ?? "(none)"}`);
|
|
142
|
+
if (view.submitMessage) {
|
|
143
|
+
lines.push(this.theme.fg("warning", view.submitMessage));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
lines.push("");
|
|
147
|
+
lines.push(this.theme.fg("dim", "Tab cycle · Enter confirm · Esc cancel"));
|
|
148
|
+
return lines;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
handleInput(data: string): void {
|
|
152
|
+
if (matchesKey(data, "ctrl+s")) {
|
|
153
|
+
this.apply({ type: "toggleScope" });
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (this.keybindings.matches(data, "tui.select.cancel")) {
|
|
157
|
+
this.apply({ type: "cancel" });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (this.keybindings.matches(data, "tui.input.tab")) {
|
|
161
|
+
this.apply({ type: "moveTab", direction: "next" });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (matchesKey(data, "shift+tab")) {
|
|
165
|
+
this.apply({ type: "moveTab", direction: "prev" });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (this.keybindings.matches(data, "tui.select.up")) {
|
|
169
|
+
this.apply({ type: "moveRow", direction: "up" });
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (this.keybindings.matches(data, "tui.select.down")) {
|
|
173
|
+
this.apply({ type: "moveRow", direction: "down" });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (this.keybindings.matches(data, "tui.select.confirm")) {
|
|
177
|
+
this.apply({ type: "confirmTab" });
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (this.state.tab === "model") {
|
|
181
|
+
this.search.handleInput(data);
|
|
182
|
+
this.apply({ type: "filter", query: this.search.getValue() });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private apply(event: SelectionFormEvent): void {
|
|
187
|
+
this.state = reduceSelectionForm(this.state, event, this.input);
|
|
188
|
+
if (this.state.status.kind !== "open") {
|
|
189
|
+
this.finish(this.state.status);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
this.requestRender();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private renderModelRows(
|
|
196
|
+
models: readonly Model<Api>[],
|
|
197
|
+
selectedIndex: number,
|
|
198
|
+
): string[] {
|
|
199
|
+
const lines: string[] = [];
|
|
200
|
+
if (models.length === 0) {
|
|
201
|
+
lines.push(this.theme.fg("muted", " No matching models"));
|
|
202
|
+
return lines;
|
|
203
|
+
}
|
|
204
|
+
const startIndex = Math.max(
|
|
205
|
+
0,
|
|
206
|
+
Math.min(
|
|
207
|
+
selectedIndex - Math.floor(MAX_VISIBLE / 2),
|
|
208
|
+
models.length - MAX_VISIBLE,
|
|
209
|
+
),
|
|
210
|
+
);
|
|
211
|
+
const endIndex = Math.min(startIndex + MAX_VISIBLE, models.length);
|
|
212
|
+
for (let index = startIndex; index < endIndex; index++) {
|
|
213
|
+
const model = models[index];
|
|
214
|
+
const isSelected = index === selectedIndex;
|
|
215
|
+
const isCurrent = modelsAreEqual(this.input.currentModel, model);
|
|
216
|
+
const isDefault =
|
|
217
|
+
this.input.defaultModel?.provider === model.provider &&
|
|
218
|
+
this.input.defaultModel.id === model.id;
|
|
219
|
+
const defaultBadge = isDefault
|
|
220
|
+
? this.theme.fg("muted", " · default")
|
|
221
|
+
: "";
|
|
222
|
+
const checkmark = isCurrent ? this.theme.fg("success", " ✓") : "";
|
|
223
|
+
const providerBadge = this.theme.fg("muted", `[${model.provider}]`);
|
|
224
|
+
if (isSelected) {
|
|
225
|
+
lines.push(
|
|
226
|
+
`${this.theme.fg("accent", "→ ")}${this.theme.fg("accent", model.id)} ${providerBadge}${defaultBadge}${checkmark}`,
|
|
227
|
+
);
|
|
228
|
+
} else {
|
|
229
|
+
lines.push(` ${model.id} ${providerBadge}${defaultBadge}${checkmark}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (startIndex > 0 || endIndex < models.length) {
|
|
233
|
+
lines.push(
|
|
234
|
+
this.theme.fg("muted", ` (${selectedIndex + 1}/${models.length})`),
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
const selected = models[selectedIndex];
|
|
238
|
+
lines.push("");
|
|
239
|
+
lines.push(this.theme.fg("muted", ` Model Name: ${selected.name}`));
|
|
240
|
+
return lines;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* selection-form.ts — Pure spawn-selection form state.
|
|
3
|
+
*
|
|
4
|
+
* Reduce over decoded events. Rendering and keymaps live in the TUI adapter.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
8
|
+
import { modelsAreEqual } from "@earendil-works/pi-ai";
|
|
9
|
+
import { fuzzyFilter } from "@earendil-works/pi-tui";
|
|
10
|
+
import type { SpawnSelection } from "@jopqior/pi-subagents";
|
|
11
|
+
import { getModelSelectorSearchText } from "./model-search-text";
|
|
12
|
+
|
|
13
|
+
const TABS = ["model", "thinking", "submit"] as const;
|
|
14
|
+
|
|
15
|
+
export type SelectionTab = (typeof TABS)[number];
|
|
16
|
+
export type SelectionScope = "all" | "scoped";
|
|
17
|
+
|
|
18
|
+
export type SelectionFormResult =
|
|
19
|
+
| {
|
|
20
|
+
readonly kind: "submit";
|
|
21
|
+
readonly model: Model<Api>;
|
|
22
|
+
readonly thinkingLevel: SpawnSelection["thinkingLevel"];
|
|
23
|
+
}
|
|
24
|
+
| { readonly kind: "cancel" };
|
|
25
|
+
|
|
26
|
+
export type SelectionFormStatus =
|
|
27
|
+
| { readonly kind: "open" }
|
|
28
|
+
| SelectionFormResult;
|
|
29
|
+
|
|
30
|
+
export type SelectionFormEvent =
|
|
31
|
+
| { readonly type: "toggleScope" }
|
|
32
|
+
| { readonly type: "moveRow"; readonly direction: "up" | "down" }
|
|
33
|
+
| { readonly type: "filter"; readonly query: string }
|
|
34
|
+
| { readonly type: "moveTab"; readonly direction: "next" | "prev" }
|
|
35
|
+
| { readonly type: "confirmTab" }
|
|
36
|
+
| { readonly type: "cancel" };
|
|
37
|
+
|
|
38
|
+
export interface SelectionFormInput {
|
|
39
|
+
readonly title: string;
|
|
40
|
+
readonly availableModels: readonly Model<Api>[];
|
|
41
|
+
readonly currentModel: Model<Api> | undefined;
|
|
42
|
+
readonly scopedModels: readonly { readonly model: Model<Api> }[];
|
|
43
|
+
readonly defaultModel:
|
|
44
|
+
| { readonly provider: string; readonly id: string }
|
|
45
|
+
| undefined;
|
|
46
|
+
readonly levelsFor: (
|
|
47
|
+
model: Model<Api>,
|
|
48
|
+
) => readonly SpawnSelection["thinkingLevel"][];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface SelectionFormState {
|
|
52
|
+
readonly tab: SelectionTab;
|
|
53
|
+
readonly scope: SelectionScope;
|
|
54
|
+
readonly query: string;
|
|
55
|
+
readonly highlightedIndex: number;
|
|
56
|
+
readonly thinkingHighlight: number;
|
|
57
|
+
readonly thinkingLevel: SpawnSelection["thinkingLevel"] | undefined;
|
|
58
|
+
readonly submitMessage: string | undefined;
|
|
59
|
+
readonly status: SelectionFormStatus;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface SelectionFormView extends SelectionFormState {
|
|
63
|
+
readonly hasScoped: boolean;
|
|
64
|
+
readonly models: readonly Model<Api>[];
|
|
65
|
+
readonly pendingModel: Model<Api> | undefined;
|
|
66
|
+
readonly thinkingLevels: readonly SpawnSelection["thinkingLevel"][];
|
|
67
|
+
readonly canSubmit: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function createSelectionFormState(
|
|
71
|
+
input: SelectionFormInput,
|
|
72
|
+
): SelectionFormState {
|
|
73
|
+
const scope: SelectionScope =
|
|
74
|
+
scopedCatalogue(input).length > 0 ? "scoped" : "all";
|
|
75
|
+
const active = activeModels(scope, input);
|
|
76
|
+
const currentIndex = active.findIndex((model) =>
|
|
77
|
+
modelsAreEqual(input.currentModel, model),
|
|
78
|
+
);
|
|
79
|
+
return {
|
|
80
|
+
tab: "model",
|
|
81
|
+
scope,
|
|
82
|
+
query: "",
|
|
83
|
+
highlightedIndex: currentIndex >= 0 ? currentIndex : 0,
|
|
84
|
+
thinkingHighlight: 0,
|
|
85
|
+
thinkingLevel: undefined,
|
|
86
|
+
submitMessage: undefined,
|
|
87
|
+
status: { kind: "open" },
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function reduceSelectionForm(
|
|
92
|
+
state: SelectionFormState,
|
|
93
|
+
event: SelectionFormEvent,
|
|
94
|
+
input: SelectionFormInput,
|
|
95
|
+
): SelectionFormState {
|
|
96
|
+
if (state.status.kind !== "open" && event.type !== "cancel") {
|
|
97
|
+
return state;
|
|
98
|
+
}
|
|
99
|
+
switch (event.type) {
|
|
100
|
+
case "toggleScope":
|
|
101
|
+
return toggleScope(state, input);
|
|
102
|
+
case "moveRow":
|
|
103
|
+
return moveRow(state, event.direction, input);
|
|
104
|
+
case "filter":
|
|
105
|
+
return applyFilter(state, event.query, input);
|
|
106
|
+
case "moveTab":
|
|
107
|
+
return moveTab(state, event.direction);
|
|
108
|
+
case "confirmTab":
|
|
109
|
+
return confirmTab(state, input);
|
|
110
|
+
case "cancel":
|
|
111
|
+
return { ...state, status: { kind: "cancel" } };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function viewSelectionForm(
|
|
116
|
+
state: SelectionFormState,
|
|
117
|
+
input: SelectionFormInput,
|
|
118
|
+
): SelectionFormView {
|
|
119
|
+
const models = visibleModels(state, input);
|
|
120
|
+
const pendingModel = modelAt(models, state.highlightedIndex);
|
|
121
|
+
const thinkingLevels = pendingModel ? input.levelsFor(pendingModel) : [];
|
|
122
|
+
return {
|
|
123
|
+
...state,
|
|
124
|
+
hasScoped: scopedCatalogue(input).length > 0,
|
|
125
|
+
models,
|
|
126
|
+
pendingModel,
|
|
127
|
+
thinkingLevels,
|
|
128
|
+
canSubmit: pendingModel !== undefined && state.thinkingLevel !== undefined,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function toggleScope(
|
|
133
|
+
state: SelectionFormState,
|
|
134
|
+
input: SelectionFormInput,
|
|
135
|
+
): SelectionFormState {
|
|
136
|
+
if (scopedCatalogue(input).length === 0) {
|
|
137
|
+
return state;
|
|
138
|
+
}
|
|
139
|
+
const scope: SelectionScope = state.scope === "all" ? "scoped" : "all";
|
|
140
|
+
const active = activeModels(scope, input);
|
|
141
|
+
const currentIndex = active.findIndex((model) =>
|
|
142
|
+
modelsAreEqual(input.currentModel, model),
|
|
143
|
+
);
|
|
144
|
+
const highlightedIndex = state.query
|
|
145
|
+
? 0
|
|
146
|
+
: currentIndex >= 0
|
|
147
|
+
? currentIndex
|
|
148
|
+
: 0;
|
|
149
|
+
return syncModelCursor({ ...state, scope, highlightedIndex }, input);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function applyFilter(
|
|
153
|
+
state: SelectionFormState,
|
|
154
|
+
query: string,
|
|
155
|
+
input: SelectionFormInput,
|
|
156
|
+
): SelectionFormState {
|
|
157
|
+
return syncModelCursor(
|
|
158
|
+
{
|
|
159
|
+
...state,
|
|
160
|
+
query,
|
|
161
|
+
highlightedIndex: query ? 0 : state.highlightedIndex,
|
|
162
|
+
submitMessage: undefined,
|
|
163
|
+
},
|
|
164
|
+
input,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function moveRow(
|
|
169
|
+
state: SelectionFormState,
|
|
170
|
+
direction: "up" | "down",
|
|
171
|
+
input: SelectionFormInput,
|
|
172
|
+
): SelectionFormState {
|
|
173
|
+
if (state.tab === "thinking") {
|
|
174
|
+
const levels = currentThinkingLevels(state, input);
|
|
175
|
+
if (levels.length === 0) {
|
|
176
|
+
return state;
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
...state,
|
|
180
|
+
thinkingHighlight: wrapIndex(
|
|
181
|
+
state.thinkingHighlight,
|
|
182
|
+
levels.length,
|
|
183
|
+
direction,
|
|
184
|
+
),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
if (state.tab !== "model") {
|
|
188
|
+
return state;
|
|
189
|
+
}
|
|
190
|
+
const models = visibleModels(state, input);
|
|
191
|
+
if (models.length === 0) {
|
|
192
|
+
return state;
|
|
193
|
+
}
|
|
194
|
+
return syncModelCursor(
|
|
195
|
+
{
|
|
196
|
+
...state,
|
|
197
|
+
highlightedIndex: wrapIndex(
|
|
198
|
+
state.highlightedIndex,
|
|
199
|
+
models.length,
|
|
200
|
+
direction,
|
|
201
|
+
),
|
|
202
|
+
},
|
|
203
|
+
input,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function moveTab(
|
|
208
|
+
state: SelectionFormState,
|
|
209
|
+
direction: "next" | "prev",
|
|
210
|
+
): SelectionFormState {
|
|
211
|
+
const index = TABS.indexOf(state.tab);
|
|
212
|
+
const next =
|
|
213
|
+
direction === "next"
|
|
214
|
+
? (index + 1) % TABS.length
|
|
215
|
+
: (index + TABS.length - 1) % TABS.length;
|
|
216
|
+
return { ...state, tab: TABS[next], submitMessage: undefined };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function confirmTab(
|
|
220
|
+
state: SelectionFormState,
|
|
221
|
+
input: SelectionFormInput,
|
|
222
|
+
): SelectionFormState {
|
|
223
|
+
if (state.tab === "model") {
|
|
224
|
+
if (
|
|
225
|
+
modelAt(visibleModels(state, input), state.highlightedIndex) === undefined
|
|
226
|
+
) {
|
|
227
|
+
return state;
|
|
228
|
+
}
|
|
229
|
+
return { ...state, tab: "thinking", submitMessage: undefined };
|
|
230
|
+
}
|
|
231
|
+
if (state.tab === "thinking") {
|
|
232
|
+
const levels = currentThinkingLevels(state, input);
|
|
233
|
+
const chosen = modelAt(levels, state.thinkingHighlight);
|
|
234
|
+
if (chosen === undefined) {
|
|
235
|
+
return state;
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
...state,
|
|
239
|
+
thinkingLevel: chosen,
|
|
240
|
+
tab: "submit",
|
|
241
|
+
submitMessage: undefined,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
const model = modelAt(visibleModels(state, input), state.highlightedIndex);
|
|
245
|
+
if (model === undefined) {
|
|
246
|
+
return { ...state, submitMessage: "Select a model." };
|
|
247
|
+
}
|
|
248
|
+
if (state.thinkingLevel === undefined) {
|
|
249
|
+
return { ...state, submitMessage: "Select a thinking level." };
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
...state,
|
|
253
|
+
submitMessage: undefined,
|
|
254
|
+
status: { kind: "submit", model, thinkingLevel: state.thinkingLevel },
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function syncModelCursor(
|
|
259
|
+
state: SelectionFormState,
|
|
260
|
+
input: SelectionFormInput,
|
|
261
|
+
): SelectionFormState {
|
|
262
|
+
const models = visibleModels(state, input);
|
|
263
|
+
const highlightedIndex = Math.min(
|
|
264
|
+
state.highlightedIndex,
|
|
265
|
+
Math.max(0, models.length - 1),
|
|
266
|
+
);
|
|
267
|
+
const next = { ...state, highlightedIndex };
|
|
268
|
+
const levels = currentThinkingLevels(next, input);
|
|
269
|
+
const kept =
|
|
270
|
+
next.thinkingLevel !== undefined && levels.includes(next.thinkingLevel)
|
|
271
|
+
? next.thinkingLevel
|
|
272
|
+
: undefined;
|
|
273
|
+
return {
|
|
274
|
+
...next,
|
|
275
|
+
thinkingLevel: kept,
|
|
276
|
+
thinkingHighlight:
|
|
277
|
+
kept === undefined ? 0 : Math.max(0, levels.indexOf(kept)),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function currentThinkingLevels(
|
|
282
|
+
state: SelectionFormState,
|
|
283
|
+
input: SelectionFormInput,
|
|
284
|
+
): readonly SpawnSelection["thinkingLevel"][] {
|
|
285
|
+
const model = modelAt(visibleModels(state, input), state.highlightedIndex);
|
|
286
|
+
return model ? input.levelsFor(model) : [];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function visibleModels(
|
|
290
|
+
state: SelectionFormState,
|
|
291
|
+
input: SelectionFormInput,
|
|
292
|
+
): readonly Model<Api>[] {
|
|
293
|
+
return filterModels(activeModels(state.scope, input), state.query, input);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function activeModels(
|
|
297
|
+
scope: SelectionScope,
|
|
298
|
+
input: SelectionFormInput,
|
|
299
|
+
): readonly Model<Api>[] {
|
|
300
|
+
if (scope === "scoped") {
|
|
301
|
+
const scoped = scopedCatalogue(input);
|
|
302
|
+
if (scoped.length > 0) {
|
|
303
|
+
return scoped;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return sortModels(input.availableModels, input);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function scopedCatalogue(input: SelectionFormInput): readonly Model<Api>[] {
|
|
310
|
+
const matched: Model<Api>[] = [];
|
|
311
|
+
for (const scoped of input.scopedModels) {
|
|
312
|
+
const catalogue = input.availableModels.find(
|
|
313
|
+
(model) =>
|
|
314
|
+
model.provider === scoped.model.provider &&
|
|
315
|
+
model.id === scoped.model.id,
|
|
316
|
+
);
|
|
317
|
+
if (
|
|
318
|
+
catalogue &&
|
|
319
|
+
!matched.some(
|
|
320
|
+
(model) =>
|
|
321
|
+
model.provider === catalogue.provider && model.id === catalogue.id,
|
|
322
|
+
)
|
|
323
|
+
) {
|
|
324
|
+
matched.push(catalogue);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return sortModels(matched, input);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function sortModels(
|
|
331
|
+
models: readonly Model<Api>[],
|
|
332
|
+
input: SelectionFormInput,
|
|
333
|
+
): Model<Api>[] {
|
|
334
|
+
return [...models].sort((a, b) => {
|
|
335
|
+
const aCurrent = modelsAreEqual(input.currentModel, a);
|
|
336
|
+
const bCurrent = modelsAreEqual(input.currentModel, b);
|
|
337
|
+
if (aCurrent !== bCurrent) {
|
|
338
|
+
return aCurrent ? -1 : 1;
|
|
339
|
+
}
|
|
340
|
+
const aDefault = isDefaultModel(a, input.defaultModel);
|
|
341
|
+
const bDefault = isDefaultModel(b, input.defaultModel);
|
|
342
|
+
if (aDefault !== bDefault) {
|
|
343
|
+
return aDefault ? -1 : 1;
|
|
344
|
+
}
|
|
345
|
+
return a.provider.localeCompare(b.provider);
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function filterModels(
|
|
350
|
+
models: readonly Model<Api>[],
|
|
351
|
+
query: string,
|
|
352
|
+
input: SelectionFormInput,
|
|
353
|
+
): readonly Model<Api>[] {
|
|
354
|
+
if (!query) {
|
|
355
|
+
return models;
|
|
356
|
+
}
|
|
357
|
+
const filtered = fuzzyFilter([...models], query, (model) =>
|
|
358
|
+
modelHaystack(model, input),
|
|
359
|
+
);
|
|
360
|
+
if (!isDefaultSearch(query)) {
|
|
361
|
+
return filtered;
|
|
362
|
+
}
|
|
363
|
+
const defaults = models.filter((model) =>
|
|
364
|
+
isDefaultModel(model, input.defaultModel),
|
|
365
|
+
);
|
|
366
|
+
const keys = new Set(
|
|
367
|
+
defaults.map((model) => `${model.provider}\0${model.id}`),
|
|
368
|
+
);
|
|
369
|
+
return [
|
|
370
|
+
...defaults,
|
|
371
|
+
...filtered.filter((model) => !keys.has(`${model.provider}\0${model.id}`)),
|
|
372
|
+
];
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function modelHaystack(model: Model<Api>, input: SelectionFormInput): string {
|
|
376
|
+
const text = getModelSelectorSearchText({
|
|
377
|
+
id: model.id,
|
|
378
|
+
provider: model.provider,
|
|
379
|
+
name: model.name,
|
|
380
|
+
});
|
|
381
|
+
return isDefaultModel(model, input.defaultModel) ? `${text} default` : text;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function isDefaultSearch(query: string): boolean {
|
|
385
|
+
const normalized = query.trim().toLowerCase();
|
|
386
|
+
return normalized.length > 0 && "default".startsWith(normalized);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function isDefaultModel(
|
|
390
|
+
model: Model<Api>,
|
|
391
|
+
defaultModel: SelectionFormInput["defaultModel"],
|
|
392
|
+
): boolean {
|
|
393
|
+
return (
|
|
394
|
+
defaultModel?.provider === model.provider && defaultModel.id === model.id
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function modelAt<T>(items: readonly T[], index: number): T | undefined {
|
|
399
|
+
return index >= 0 && index < items.length ? items[index] : undefined;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function wrapIndex(
|
|
403
|
+
index: number,
|
|
404
|
+
length: number,
|
|
405
|
+
direction: "up" | "down",
|
|
406
|
+
): number {
|
|
407
|
+
if (direction === "down") {
|
|
408
|
+
return index === length - 1 ? 0 : index + 1;
|
|
409
|
+
}
|
|
410
|
+
return index === 0 ? length - 1 : index - 1;
|
|
411
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* selection-labels.ts — Title and description bounds for the spawn form.
|
|
3
|
+
*/
|
|
4
|
+
import type { SpawnSelectionRequest } from "@jopqior/pi-subagents";
|
|
5
|
+
|
|
6
|
+
const DESCRIPTION_LIMIT = 80;
|
|
7
|
+
|
|
8
|
+
export function boundDescription(description: string): string {
|
|
9
|
+
if (description.length <= DESCRIPTION_LIMIT) {
|
|
10
|
+
return description;
|
|
11
|
+
}
|
|
12
|
+
return `${description.slice(0, DESCRIPTION_LIMIT - 1)}…`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function modelTitle(request: SpawnSelectionRequest): string {
|
|
16
|
+
return `Select model for ${request.agentType} ${request.agentId} — ${boundDescription(request.description)}`;
|
|
17
|
+
}
|