@bismawy/pi-vision-watcher 1.0.7
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/LICENSE +21 -0
- package/README.md +140 -0
- package/package.json +68 -0
- package/src/dataloader.ts +383 -0
- package/src/describer.ts +546 -0
- package/src/dispose.ts +61 -0
- package/src/error-log.ts +131 -0
- package/src/image.ts +331 -0
- package/src/index.ts +580 -0
- package/src/prewarm-editor.ts +95 -0
- package/src/usage.ts +261 -0
- package/src/vision-model-selector.ts +452 -0
- package/vision-watcher.ts +1161 -0
- package/vitest.config.ts +15 -0
package/src/usage.ts
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Describer usage + energy capture for pi-vision-watcher.
|
|
3
|
+
*
|
|
4
|
+
* One record is produced per REAL describer provider call (cache hits emit
|
|
5
|
+
* nothing):
|
|
6
|
+
* - model + tokens: from completeSimple()'s AssistantMessage.usage
|
|
7
|
+
* - energy + cost + raw MCR/energy/cost payloads: from Neuralwatt SSE comment
|
|
8
|
+
* lines parsed out of the teed response body (readEnergyFromTee). Present
|
|
9
|
+
* ONLY when the vision model is a Neuralwatt model — non-Neuralwatt models
|
|
10
|
+
* produce no comment lines, so the energy fields are OMITTED (not zeroed)
|
|
11
|
+
* for easy downstream filtering ("no energy" vs "zero energy").
|
|
12
|
+
*
|
|
13
|
+
* The caller (vision-watcher.ts) persists the record via pi.appendEntry (replays
|
|
14
|
+
* on session resume/branch) AND emits it on pi.events so a live consumer can
|
|
15
|
+
* filter on the one channel for tokens AND energy.
|
|
16
|
+
*
|
|
17
|
+
* Split out of vision-watcher.ts so the pure pieces (readEnergyFromTee,
|
|
18
|
+
* buildUsageRecord) and the concurrency-safe fetch interceptor are unit-testable
|
|
19
|
+
* through the normal src/ import path — vision-watcher.ts runs readConfig() at
|
|
20
|
+
* module load and pulls in the TUI selector, so it is not unit-test-friendly.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
24
|
+
import type { Api, AssistantMessage, Model, Usage } from "@earendil-works/pi-ai";
|
|
25
|
+
|
|
26
|
+
/** Custom session-entry type persisted via pi.appendEntry. */
|
|
27
|
+
export const USAGE_ENTRY_TYPE = "vision-watcher-usage";
|
|
28
|
+
|
|
29
|
+
/** Event-bus channel emitted via pi.events. */
|
|
30
|
+
export const USAGE_EVENT_CHANNEL = "vision-watcher:usage";
|
|
31
|
+
|
|
32
|
+
/** Parsed Neuralwatt SSE-comment energy/cost/MCR data for one describer call. */
|
|
33
|
+
export interface VisionHandoffEnergyCapture {
|
|
34
|
+
energyJoules: number;
|
|
35
|
+
costUsd: number;
|
|
36
|
+
energyRaw: Record<string, unknown> | null;
|
|
37
|
+
mcrSessionRaw: Record<string, unknown> | null;
|
|
38
|
+
costRaw: Record<string, unknown> | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Shared empty capture. Safe to share: readEnergyFromTee returns a fresh object
|
|
42
|
+
* and never mutates this; buildUsageRecord only reads from its argument. */
|
|
43
|
+
export const EMPTY_ENERGY_CAPTURE: VisionHandoffEnergyCapture = {
|
|
44
|
+
energyJoules: 0,
|
|
45
|
+
costUsd: 0,
|
|
46
|
+
energyRaw: null,
|
|
47
|
+
mcrSessionRaw: null,
|
|
48
|
+
costRaw: null,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** A single describer-call usage record. Energy fields are present only when
|
|
52
|
+
* Neuralwatt SSE energy comments were captured (omitted, not zeroed, otherwise).
|
|
53
|
+
*
|
|
54
|
+
* One record is emitted per REAL describer provider call (cache hits emit
|
|
55
|
+
* nothing). A batched call that describes several images at once still emits a
|
|
56
|
+
* single record: `imageHash` is the representative (first) member and
|
|
57
|
+
* `imageHashes` lists every image the call covered, so consumers can attribute
|
|
58
|
+
* the call's tokens/energy to each member image without double-counting. */
|
|
59
|
+
export interface VisionHandoffUsageRecord {
|
|
60
|
+
/** Representative image hash (first member of the batch). */
|
|
61
|
+
imageHash: string;
|
|
62
|
+
/** All image hashes covered by this describer call. Present only for batched
|
|
63
|
+
* calls (length > 1); omitted for single-image calls. */
|
|
64
|
+
imageHashes?: string[];
|
|
65
|
+
model: string;
|
|
66
|
+
provider: string;
|
|
67
|
+
responseModel?: string;
|
|
68
|
+
responseId?: string;
|
|
69
|
+
usage: Usage;
|
|
70
|
+
/** Neuralwatt energy — present only when SSE energy comments were captured. */
|
|
71
|
+
energyJoules?: number;
|
|
72
|
+
costUsd?: number;
|
|
73
|
+
energyRaw?: Record<string, unknown> | null;
|
|
74
|
+
mcrSessionRaw?: Record<string, unknown> | null;
|
|
75
|
+
costRaw?: Record<string, unknown> | null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Parse Neuralwatt SSE comment lines (`: energy`, `: cost`, `: mcr-session`)
|
|
80
|
+
* from a teed response body into a fresh capture object. Mirrors
|
|
81
|
+
* pi-neuralwatt-provider's readEnergyFromTee but returns a result instead of
|
|
82
|
+
* mutating module state — vision-watcher describes images concurrently (the
|
|
83
|
+
* before_agent_start warm-up fires several in parallel), so each call needs its
|
|
84
|
+
* own capture routed via {@link describeAls}. For non-Neuralwatt vision models
|
|
85
|
+
* no comment lines are present and the returned capture stays empty.
|
|
86
|
+
*/
|
|
87
|
+
export async function readEnergyFromTee(
|
|
88
|
+
body: ReadableStream<Uint8Array>,
|
|
89
|
+
): Promise<VisionHandoffEnergyCapture> {
|
|
90
|
+
const result: VisionHandoffEnergyCapture = { ...EMPTY_ENERGY_CAPTURE };
|
|
91
|
+
const reader = body.getReader();
|
|
92
|
+
const decoder = new TextDecoder();
|
|
93
|
+
let buffer = "";
|
|
94
|
+
|
|
95
|
+
function processLine(line: string): void {
|
|
96
|
+
const trimmed = line.trim();
|
|
97
|
+
if (trimmed.startsWith(": energy ")) {
|
|
98
|
+
try {
|
|
99
|
+
const energy = JSON.parse(trimmed.slice(9));
|
|
100
|
+
result.energyJoules += energy.energy_joules || 0;
|
|
101
|
+
result.energyRaw = energy;
|
|
102
|
+
} catch {
|
|
103
|
+
// malformed energy comment — ignore
|
|
104
|
+
}
|
|
105
|
+
} else if (trimmed.startsWith(": mcr-session ")) {
|
|
106
|
+
try {
|
|
107
|
+
result.mcrSessionRaw = JSON.parse(trimmed.slice(14));
|
|
108
|
+
} catch {
|
|
109
|
+
// malformed mcr-session comment — ignore
|
|
110
|
+
}
|
|
111
|
+
} else if (trimmed.startsWith(": cost ")) {
|
|
112
|
+
try {
|
|
113
|
+
const cost = JSON.parse(trimmed.slice(7));
|
|
114
|
+
result.costUsd += cost.request_cost_usd || 0;
|
|
115
|
+
result.costRaw = cost;
|
|
116
|
+
} catch {
|
|
117
|
+
// malformed cost comment — ignore
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
while (true) {
|
|
124
|
+
const { done, value } = await reader.read();
|
|
125
|
+
if (done) break;
|
|
126
|
+
buffer += decoder.decode(value, { stream: true });
|
|
127
|
+
const lines = buffer.split("\n");
|
|
128
|
+
buffer = lines.pop() || "";
|
|
129
|
+
for (const line of lines) processLine(line);
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
// tee stream may error if the main stream is aborted — that's fine
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const final = decoder.decode(new Uint8Array(0), { stream: false });
|
|
136
|
+
const remaining = (buffer + final).trim();
|
|
137
|
+
if (remaining) processLine(remaining);
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
reader.releaseLock();
|
|
141
|
+
} catch {
|
|
142
|
+
// ignore
|
|
143
|
+
}
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Build a usage record from a describer response + energy capture. Returns
|
|
149
|
+
* null when there is nothing meaningful to report (e.g. a provider-level
|
|
150
|
+
* failure with zero tokens and no energy) so the caller can skip emitting.
|
|
151
|
+
*
|
|
152
|
+
* Energy fields are OMITTED entirely (not zeroed) when no Neuralwatt SSE energy
|
|
153
|
+
* comments were captured, so consumers can distinguish "no energy" from
|
|
154
|
+
* "zero energy".
|
|
155
|
+
*/
|
|
156
|
+
export function buildUsageRecord(
|
|
157
|
+
response: AssistantMessage,
|
|
158
|
+
capture: VisionHandoffEnergyCapture,
|
|
159
|
+
visionModel: Model<Api>,
|
|
160
|
+
imageHash: string,
|
|
161
|
+
imageHashes?: string[],
|
|
162
|
+
): VisionHandoffUsageRecord | null {
|
|
163
|
+
const hasEnergy = !!(
|
|
164
|
+
capture.energyRaw ||
|
|
165
|
+
capture.costRaw ||
|
|
166
|
+
capture.mcrSessionRaw ||
|
|
167
|
+
capture.energyJoules > 0 ||
|
|
168
|
+
capture.costUsd > 0
|
|
169
|
+
);
|
|
170
|
+
const hasUsage =
|
|
171
|
+
!!response.usage &&
|
|
172
|
+
(response.usage.totalTokens > 0 || response.usage.input > 0 || response.usage.output > 0);
|
|
173
|
+
if (!hasUsage && !hasEnergy) return null;
|
|
174
|
+
|
|
175
|
+
const record: VisionHandoffUsageRecord = {
|
|
176
|
+
imageHash,
|
|
177
|
+
model: response.model || visionModel.id,
|
|
178
|
+
provider: response.provider || visionModel.provider,
|
|
179
|
+
responseModel: response.responseModel,
|
|
180
|
+
responseId: response.responseId,
|
|
181
|
+
usage: response.usage,
|
|
182
|
+
};
|
|
183
|
+
// Present only for genuine batched calls (more than one image). Keeps the
|
|
184
|
+
// single-image record shape unchanged for existing consumers.
|
|
185
|
+
if (imageHashes && imageHashes.length > 1) {
|
|
186
|
+
record.imageHashes = imageHashes;
|
|
187
|
+
}
|
|
188
|
+
if (hasEnergy) {
|
|
189
|
+
record.energyJoules = capture.energyJoules;
|
|
190
|
+
record.costUsd = capture.costUsd;
|
|
191
|
+
record.energyRaw = capture.energyRaw;
|
|
192
|
+
record.mcrSessionRaw = capture.mcrSessionRaw;
|
|
193
|
+
record.costRaw = capture.costRaw;
|
|
194
|
+
}
|
|
195
|
+
return record;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── Concurrency-safe fetch interceptor ─────────────────────────────────────
|
|
199
|
+
//
|
|
200
|
+
// The only body-interception point for completeSimple() is globalThis.fetch (pi-ai's
|
|
201
|
+
// StreamOptions.onResponse exposes headers only, not the body where the
|
|
202
|
+
// `: energy` SSE comments live). before_agent_start fires several describeImage()
|
|
203
|
+
// calls fire-and-forget, so a naïve save/patch/restore of globalThis.fetch would
|
|
204
|
+
// clobber under concurrency. The fix is a refcounted shared interceptor
|
|
205
|
+
// (installed only while ≥1 describe is in flight, so non-describe fetches pass
|
|
206
|
+
// through unmodified) + AsyncLocalStorage to route each teed response body to
|
|
207
|
+
// the describe call that issued it. Nested patches from other extensions (e.g.
|
|
208
|
+
// pi-neuralwatt-provider's streamNeuralwatt, which also tees for its own energy
|
|
209
|
+
// display) chain on top and restore back to this interceptor, so both tees read
|
|
210
|
+
// the same comment lines independently.
|
|
211
|
+
|
|
212
|
+
/** Per-describer-call ALS slot carrying the energy tee reader. */
|
|
213
|
+
export interface DescribeContext {
|
|
214
|
+
energyReader: Promise<VisionHandoffEnergyCapture> | undefined;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Routes each teed response body to the describe call that issued it. */
|
|
218
|
+
export const describeAls = new AsyncLocalStorage<DescribeContext>();
|
|
219
|
+
|
|
220
|
+
let fetchInterceptRefCount = 0;
|
|
221
|
+
let savedRealFetch: typeof globalThis.fetch | null = null;
|
|
222
|
+
|
|
223
|
+
/** Install the globalThis.fetch interceptor. Refcounted: the first caller
|
|
224
|
+
* patches fetch; later callers just bump the count. Idempotent per install. */
|
|
225
|
+
export function installFetchInterceptor(): void {
|
|
226
|
+
if (fetchInterceptRefCount === 0) {
|
|
227
|
+
savedRealFetch = globalThis.fetch;
|
|
228
|
+
globalThis.fetch = interceptedFetch;
|
|
229
|
+
}
|
|
230
|
+
fetchInterceptRefCount++;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Remove the interceptor. Refcounted: only the last caller restores fetch. */
|
|
234
|
+
export function uninstallFetchInterceptor(): void {
|
|
235
|
+
if (fetchInterceptRefCount > 0) fetchInterceptRefCount--;
|
|
236
|
+
if (fetchInterceptRefCount === 0 && savedRealFetch) {
|
|
237
|
+
globalThis.fetch = savedRealFetch;
|
|
238
|
+
savedRealFetch = null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Current refcount — 0 means the interceptor is not installed. Test hook. */
|
|
243
|
+
export function fetchInterceptorRefcount(): number {
|
|
244
|
+
return fetchInterceptRefCount;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function interceptedFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
|
248
|
+
const real = savedRealFetch ?? globalThis.fetch;
|
|
249
|
+
const response = await real(input, init);
|
|
250
|
+
const store = describeAls.getStore();
|
|
251
|
+
// Outside a describer call (no ALS store) or for bodiless responses: pass
|
|
252
|
+
// through untouched.
|
|
253
|
+
if (!store || !response.body) return response;
|
|
254
|
+
const [bodyForSdk, bodyForEnergy] = response.body.tee();
|
|
255
|
+
store.energyReader = readEnergyFromTee(bodyForEnergy);
|
|
256
|
+
return new Response(bodyForSdk, {
|
|
257
|
+
headers: response.headers,
|
|
258
|
+
status: response.status,
|
|
259
|
+
statusText: response.statusText,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VisionModelSelectorComponent — an interactive TUI for choosing which model
|
|
3
|
+
* describes images during vision handoff.
|
|
4
|
+
*
|
|
5
|
+
* Uses the same patterns as pi's built-in selectors and pi-hide-providers:
|
|
6
|
+
* - Lists connected (authenticated) models, vision-capable ones first (👀 badge)
|
|
7
|
+
* - A leading "None" row clears the configured vision model
|
|
8
|
+
* - Search/filter via Input component
|
|
9
|
+
* - Enter or Ctrl+S confirms the highlighted model and saves
|
|
10
|
+
* - Esc / Ctrl+C cancels
|
|
11
|
+
* - The currently configured vision model is marked ✓
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
Container,
|
|
16
|
+
type Component,
|
|
17
|
+
fuzzyFilter,
|
|
18
|
+
getKeybindings,
|
|
19
|
+
Input,
|
|
20
|
+
Key,
|
|
21
|
+
matchesKey,
|
|
22
|
+
Spacer,
|
|
23
|
+
Text,
|
|
24
|
+
truncateToWidth,
|
|
25
|
+
wrapTextWithAnsi,
|
|
26
|
+
} from "@earendil-works/pi-tui";
|
|
27
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
28
|
+
import type { ThinkingLevel } from "@earendil-works/pi-ai";
|
|
29
|
+
import { DynamicBorder, keyText } from "@earendil-works/pi-coding-agent";
|
|
30
|
+
import { formatModelRef, isVisionModel, THINKING_LEVELS } from "./index.js";
|
|
31
|
+
|
|
32
|
+
interface DisplayItem {
|
|
33
|
+
/** "provider/id", or null for the synthetic "None" row. */
|
|
34
|
+
ref: string | null;
|
|
35
|
+
provider: string;
|
|
36
|
+
modelId: string;
|
|
37
|
+
modelName: string;
|
|
38
|
+
vision: boolean;
|
|
39
|
+
/** Whether the model declares reasoning (thinking) support. */
|
|
40
|
+
reasoning: boolean;
|
|
41
|
+
none?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface VisionModelSelectorResult {
|
|
45
|
+
/** The selected "provider/id", or null if the user picked "None" / cancelled. */
|
|
46
|
+
ref: string | null;
|
|
47
|
+
/** True if the user cancelled (esc) — config should not change. */
|
|
48
|
+
cancelled: boolean;
|
|
49
|
+
/** Thinking on/off chosen in the picker. */
|
|
50
|
+
thinking: boolean;
|
|
51
|
+
/** Thinking effort chosen in the picker. */
|
|
52
|
+
thinkingLevel: ThinkingLevel;
|
|
53
|
+
/** Whether pasted paths should be injected if no matching read wins. */
|
|
54
|
+
asyncClipboardHandoff: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class VisionModelSelectorComponent implements Component {
|
|
58
|
+
private theme: Theme;
|
|
59
|
+
private done: (result: VisionModelSelectorResult) => void;
|
|
60
|
+
|
|
61
|
+
private allItems: DisplayItem[];
|
|
62
|
+
private filteredItems: DisplayItem[];
|
|
63
|
+
private selectedIndex = 0;
|
|
64
|
+
private readonly maxVisible = 10;
|
|
65
|
+
private searchInput: Input;
|
|
66
|
+
private listContainer: Container;
|
|
67
|
+
private footerText: Text;
|
|
68
|
+
|
|
69
|
+
private currentRef: string | null;
|
|
70
|
+
private thinking: boolean;
|
|
71
|
+
private thinkingLevel: ThinkingLevel;
|
|
72
|
+
private asyncClipboardHandoff: boolean;
|
|
73
|
+
|
|
74
|
+
private _focused = false;
|
|
75
|
+
get focused(): boolean {
|
|
76
|
+
return this._focused;
|
|
77
|
+
}
|
|
78
|
+
set focused(value: boolean) {
|
|
79
|
+
this._focused = value;
|
|
80
|
+
this.searchInput.focused = value;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
constructor(
|
|
84
|
+
theme: Theme,
|
|
85
|
+
allModels: Array<{
|
|
86
|
+
provider: string;
|
|
87
|
+
id: string;
|
|
88
|
+
name: string;
|
|
89
|
+
input?: ("text" | "image")[];
|
|
90
|
+
reasoning?: boolean;
|
|
91
|
+
}>,
|
|
92
|
+
currentRef: string | null,
|
|
93
|
+
currentThinking: boolean,
|
|
94
|
+
currentThinkingLevel: ThinkingLevel,
|
|
95
|
+
currentAsyncClipboardHandoff: boolean,
|
|
96
|
+
done: (result: VisionModelSelectorResult) => void,
|
|
97
|
+
) {
|
|
98
|
+
this.theme = theme;
|
|
99
|
+
this.done = done;
|
|
100
|
+
this.currentRef = currentRef;
|
|
101
|
+
this.thinking = currentThinking;
|
|
102
|
+
this.thinkingLevel = currentThinkingLevel;
|
|
103
|
+
this.asyncClipboardHandoff = currentAsyncClipboardHandoff;
|
|
104
|
+
this.allItems = this.buildItems(allModels);
|
|
105
|
+
this.filteredItems = this.allItems;
|
|
106
|
+
|
|
107
|
+
const startIdx = this.allItems.findIndex((i) => i.ref === currentRef);
|
|
108
|
+
this.selectedIndex = startIdx >= 0 ? startIdx : 0;
|
|
109
|
+
|
|
110
|
+
this.searchInput = new Input();
|
|
111
|
+
this.listContainer = new Container();
|
|
112
|
+
this.footerText = new Text(this.getFooterText(), 0, 0);
|
|
113
|
+
|
|
114
|
+
this.searchInput.onSubmit = () => {
|
|
115
|
+
const item = this.filteredItems[this.selectedIndex];
|
|
116
|
+
if (item) this.confirm(item);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
this.updateList();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
render(width: number): string[] {
|
|
123
|
+
const lines: string[] = [];
|
|
124
|
+
lines.push(...new DynamicBorder((s) => this.theme.fg("accent", s)).render(width));
|
|
125
|
+
lines.push("");
|
|
126
|
+
lines.push(
|
|
127
|
+
truncateToWidth(
|
|
128
|
+
this.theme.fg("accent", this.theme.bold("Vision Watcher")),
|
|
129
|
+
width,
|
|
130
|
+
"",
|
|
131
|
+
),
|
|
132
|
+
);
|
|
133
|
+
lines.push(
|
|
134
|
+
...wrapTextWithAnsi(
|
|
135
|
+
this.theme.fg(
|
|
136
|
+
"muted",
|
|
137
|
+
"Pick a vision-capable model to describe images for text-only models.",
|
|
138
|
+
),
|
|
139
|
+
width,
|
|
140
|
+
),
|
|
141
|
+
);
|
|
142
|
+
lines.push("");
|
|
143
|
+
lines.push(...this.searchInput.render(width));
|
|
144
|
+
lines.push("");
|
|
145
|
+
lines.push(...this.listContainer.render(width));
|
|
146
|
+
lines.push("");
|
|
147
|
+
lines.push(...this.footerText.render(width));
|
|
148
|
+
lines.push(...new DynamicBorder((s) => this.theme.fg("accent", s)).render(width));
|
|
149
|
+
return lines.map((line) => truncateToWidth(line, width, ""));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
handleInput(data: string): void {
|
|
153
|
+
const kb = getKeybindings();
|
|
154
|
+
|
|
155
|
+
if (kb.matches(data, "tui.select.up")) {
|
|
156
|
+
if (this.filteredItems.length === 0) return;
|
|
157
|
+
this.selectedIndex =
|
|
158
|
+
this.selectedIndex === 0
|
|
159
|
+
? this.filteredItems.length - 1
|
|
160
|
+
: this.selectedIndex - 1;
|
|
161
|
+
this.updateList();
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (kb.matches(data, "tui.select.down")) {
|
|
166
|
+
if (this.filteredItems.length === 0) return;
|
|
167
|
+
this.selectedIndex =
|
|
168
|
+
this.selectedIndex === this.filteredItems.length - 1
|
|
169
|
+
? 0
|
|
170
|
+
: this.selectedIndex + 1;
|
|
171
|
+
this.updateList();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (kb.matches(data, "tui.select.confirm")) {
|
|
176
|
+
const item = this.filteredItems[this.selectedIndex];
|
|
177
|
+
if (item) this.confirm(item);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (matchesKey(data, Key.ctrl("s"))) {
|
|
182
|
+
const item = this.filteredItems[this.selectedIndex];
|
|
183
|
+
if (item) this.confirm(item);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (matchesKey(data, Key.escape)) {
|
|
188
|
+
this.finish(true);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (matchesKey(data, Key.ctrl("c"))) {
|
|
193
|
+
if (this.searchInput.getValue()) {
|
|
194
|
+
this.searchInput.setValue("");
|
|
195
|
+
this.refresh();
|
|
196
|
+
} else {
|
|
197
|
+
this.finish(true);
|
|
198
|
+
}
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (matchesKey(data, Key.ctrl("a"))) {
|
|
203
|
+
this.asyncClipboardHandoff = !this.asyncClipboardHandoff;
|
|
204
|
+
this.updateList();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Thinking controls — reuse pi's own app.thinking.* keybindings so the
|
|
209
|
+
// hints and behaviour match the rest of pi: ctrl+t toggles thinking
|
|
210
|
+
// on/off, shift+tab cycles the effort level. Intercepted before the
|
|
211
|
+
// search input so they never get swallowed as filter text.
|
|
212
|
+
if (kb.matches(data, "app.thinking.toggle")) {
|
|
213
|
+
this.thinking = !this.thinking;
|
|
214
|
+
this.updateList();
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (kb.matches(data, "app.thinking.cycle")) {
|
|
219
|
+
this.cycleThinkingLevel();
|
|
220
|
+
this.updateList();
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
this.searchInput.handleInput(data);
|
|
225
|
+
this.refresh();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
invalidate(): void {
|
|
229
|
+
this.searchInput.invalidate();
|
|
230
|
+
this.listContainer.invalidate();
|
|
231
|
+
this.footerText.invalidate();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Internal helpers
|
|
235
|
+
|
|
236
|
+
private buildItems(
|
|
237
|
+
allModels: Array<{
|
|
238
|
+
provider: string;
|
|
239
|
+
id: string;
|
|
240
|
+
name: string;
|
|
241
|
+
input?: ("text" | "image")[];
|
|
242
|
+
reasoning?: boolean;
|
|
243
|
+
}>,
|
|
244
|
+
): DisplayItem[] {
|
|
245
|
+
const items: DisplayItem[] = [
|
|
246
|
+
{
|
|
247
|
+
ref: null,
|
|
248
|
+
provider: "",
|
|
249
|
+
modelId: "none",
|
|
250
|
+
modelName: "None — disable vision handoff",
|
|
251
|
+
vision: false,
|
|
252
|
+
reasoning: false,
|
|
253
|
+
none: true,
|
|
254
|
+
},
|
|
255
|
+
];
|
|
256
|
+
|
|
257
|
+
const make = (m: {
|
|
258
|
+
provider: string;
|
|
259
|
+
id: string;
|
|
260
|
+
name: string;
|
|
261
|
+
input?: ("text" | "image")[];
|
|
262
|
+
reasoning?: boolean;
|
|
263
|
+
}): DisplayItem => ({
|
|
264
|
+
ref: formatModelRef(m.provider, m.id),
|
|
265
|
+
provider: m.provider,
|
|
266
|
+
modelId: m.id,
|
|
267
|
+
modelName: m.name || m.id,
|
|
268
|
+
vision: isVisionModel(m),
|
|
269
|
+
reasoning: !!m.reasoning,
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// Only vision-capable models are listed — a text-only model can't describe
|
|
273
|
+
// images, so it would only produce "[Image: description unavailable]" errors.
|
|
274
|
+
const visionModels = allModels.filter((m) => isVisionModel(m)).map(make);
|
|
275
|
+
return [...items, ...visionModels];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private getFooterText(): string {
|
|
279
|
+
const totalCount = this.allItems.length - 1; // exclude the None row
|
|
280
|
+
|
|
281
|
+
const current = this.currentRef
|
|
282
|
+
? `current: ${this.currentRef}`
|
|
283
|
+
: "current: none";
|
|
284
|
+
|
|
285
|
+
const parts: string[] = [
|
|
286
|
+
`${keyText("tui.select.confirm")} select`,
|
|
287
|
+
`ctrl+s done`,
|
|
288
|
+
`${keyText("app.thinking.toggle")} thinking`,
|
|
289
|
+
`${keyText("app.thinking.cycle")} effort`,
|
|
290
|
+
`ctrl+a async fallback`,
|
|
291
|
+
`esc cancel`,
|
|
292
|
+
this.searchInput.getValue() ? `${this.filteredItems.length - 1} match` : `${totalCount} vision-capable models`,
|
|
293
|
+
];
|
|
294
|
+
|
|
295
|
+
return this.theme.fg("dim", ` ${parts.join(" · ")} · ${current} `);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private refresh(): void {
|
|
299
|
+
const query = this.searchInput.getValue();
|
|
300
|
+
this.filteredItems = query
|
|
301
|
+
? fuzzyFilter(
|
|
302
|
+
this.allItems,
|
|
303
|
+
query,
|
|
304
|
+
(i) => `${i.provider} ${i.modelId} ${i.ref ?? "none"} ${i.modelName}`,
|
|
305
|
+
)
|
|
306
|
+
: this.allItems;
|
|
307
|
+
this.selectedIndex = Math.min(
|
|
308
|
+
this.selectedIndex,
|
|
309
|
+
Math.max(0, this.filteredItems.length - 1),
|
|
310
|
+
);
|
|
311
|
+
this.updateList();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private updateList(): void {
|
|
315
|
+
this.listContainer.clear();
|
|
316
|
+
|
|
317
|
+
if (this.filteredItems.length === 0) {
|
|
318
|
+
this.listContainer.addChild(
|
|
319
|
+
new Text(this.theme.fg("muted", " No matching models"), 0, 0),
|
|
320
|
+
);
|
|
321
|
+
this.footerText.setText(this.getFooterText());
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const startIndex = Math.max(
|
|
326
|
+
0,
|
|
327
|
+
Math.min(
|
|
328
|
+
this.selectedIndex - Math.floor(this.maxVisible / 2),
|
|
329
|
+
this.filteredItems.length - this.maxVisible,
|
|
330
|
+
),
|
|
331
|
+
);
|
|
332
|
+
const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length);
|
|
333
|
+
|
|
334
|
+
for (let i = startIndex; i < endIndex; i++) {
|
|
335
|
+
const item = this.filteredItems[i];
|
|
336
|
+
if (!item) continue;
|
|
337
|
+
|
|
338
|
+
const isSelected = i === this.selectedIndex;
|
|
339
|
+
const prefix = isSelected ? this.theme.fg("accent", "→ ") : " ";
|
|
340
|
+
|
|
341
|
+
let label: string;
|
|
342
|
+
if (item.none) {
|
|
343
|
+
label = this.theme.fg("warning", item.modelName);
|
|
344
|
+
} else {
|
|
345
|
+
const labelled = isSelected
|
|
346
|
+
? this.theme.fg("accent", item.modelId)
|
|
347
|
+
: item.modelId;
|
|
348
|
+
const badge = item.vision ? this.theme.fg("success", " 👀") : this.theme.fg("muted", " ·");
|
|
349
|
+
const providerBadge = this.theme.fg("muted", ` [${item.provider}]`);
|
|
350
|
+
label = `${labelled}${providerBadge}${badge}`;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const current = item.ref === this.currentRef && item.ref !== null
|
|
354
|
+
? this.theme.fg("success", " ✓")
|
|
355
|
+
: item.none && this.currentRef === null
|
|
356
|
+
? this.theme.fg("success", " ✓")
|
|
357
|
+
: "";
|
|
358
|
+
|
|
359
|
+
this.listContainer.addChild(new Text(`${prefix}${label}${current}`, 0, 0));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (startIndex > 0 || endIndex < this.filteredItems.length) {
|
|
363
|
+
this.listContainer.addChild(
|
|
364
|
+
new Text(
|
|
365
|
+
this.theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredItems.length})`),
|
|
366
|
+
0, 0,
|
|
367
|
+
),
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const selected = this.filteredItems[this.selectedIndex];
|
|
372
|
+
if (selected) {
|
|
373
|
+
this.listContainer.addChild(new Spacer(1));
|
|
374
|
+
if (selected.none) {
|
|
375
|
+
this.listContainer.addChild(
|
|
376
|
+
new Text(this.theme.fg("muted", ` ${selected.modelName}`), 0, 0),
|
|
377
|
+
);
|
|
378
|
+
} else {
|
|
379
|
+
this.listContainer.addChild(
|
|
380
|
+
new Text(this.theme.fg("muted", ` Model Name: ${selected.modelName}`), 0, 0),
|
|
381
|
+
);
|
|
382
|
+
this.listContainer.addChild(
|
|
383
|
+
new Text(this.theme.fg("dim", " 👀 vision-capable — recommended describer"), 0, 0),
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
this.renderThinkingDetail(selected);
|
|
387
|
+
const fallback = this.asyncClipboardHandoff
|
|
388
|
+
? this.theme.fg("success", "on")
|
|
389
|
+
: this.theme.fg("muted", "off");
|
|
390
|
+
this.listContainer.addChild(
|
|
391
|
+
new Text(this.theme.fg("dim", ` Async pasted-path fallback: ${fallback}`), 0, 0),
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
this.footerText.setText(this.getFooterText());
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Append the thinking on/off + effort line to the detail pane, with a
|
|
399
|
+
* warning when the highlighted model can't reason (so the setting would
|
|
400
|
+
* be silently ignored by the describer). */
|
|
401
|
+
private renderThinkingDetail(selected: DisplayItem): void {
|
|
402
|
+
const state = this.thinking
|
|
403
|
+
? this.theme.fg("success", `on (${this.thinkingLevel})`)
|
|
404
|
+
: this.theme.fg("muted", "off");
|
|
405
|
+
this.listContainer.addChild(
|
|
406
|
+
new Text(this.theme.fg("dim", ` Thinking: ${state}`), 0, 0),
|
|
407
|
+
);
|
|
408
|
+
if (this.thinking && !selected.none && !selected.reasoning) {
|
|
409
|
+
this.listContainer.addChild(
|
|
410
|
+
new Text(
|
|
411
|
+
this.theme.fg(
|
|
412
|
+
"warning",
|
|
413
|
+
` ⚠ ${selected.modelId} declares no reasoning — thinking will be ignored`,
|
|
414
|
+
),
|
|
415
|
+
0, 0,
|
|
416
|
+
),
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
private confirm(item: DisplayItem): void {
|
|
422
|
+
this.done({
|
|
423
|
+
ref: item.ref,
|
|
424
|
+
cancelled: false,
|
|
425
|
+
thinking: this.thinking,
|
|
426
|
+
thinkingLevel: this.thinkingLevel,
|
|
427
|
+
asyncClipboardHandoff: this.asyncClipboardHandoff,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
private finish(cancelled: boolean): void {
|
|
432
|
+
this.done({
|
|
433
|
+
ref: null,
|
|
434
|
+
cancelled,
|
|
435
|
+
thinking: this.thinking,
|
|
436
|
+
thinkingLevel: this.thinkingLevel,
|
|
437
|
+
asyncClipboardHandoff: this.asyncClipboardHandoff,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Cycle the thinking effort forward through {@link THINKING_LEVELS},
|
|
442
|
+
* wrapping from the last back to the first. Cycling implicitly turns
|
|
443
|
+
* thinking on (you don't usually cycle a switch you want off) — matching
|
|
444
|
+
* pi's own `app.thinking.cycle` behaviour, which is a no-op only when the
|
|
445
|
+
* active model has no reasoning. */
|
|
446
|
+
private cycleThinkingLevel(): void {
|
|
447
|
+
if (!this.thinking) this.thinking = true;
|
|
448
|
+
const idx = THINKING_LEVELS.indexOf(this.thinkingLevel);
|
|
449
|
+
const next = THINKING_LEVELS[(idx + 1) % THINKING_LEVELS.length]!;
|
|
450
|
+
this.thinkingLevel = next;
|
|
451
|
+
}
|
|
452
|
+
}
|