@oh-my-pi/pi-coding-agent 16.4.5 → 16.4.8
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 +54 -0
- package/dist/cli.js +3271 -3224
- package/dist/types/cli/bench-cli.d.ts +1 -7
- package/dist/types/cli/usage-cli.d.ts +1 -0
- package/dist/types/commands/usage.d.ts +7 -0
- package/dist/types/config/settings-schema.d.ts +1 -1
- package/dist/types/modes/components/custom-editor.d.ts +3 -8
- package/dist/types/modes/components/model-browser.d.ts +14 -1
- package/dist/types/modes/components/model-hub.d.ts +4 -3
- package/dist/types/modes/components/plan-review-overlay.d.ts +2 -0
- package/dist/types/modes/components/welcome.d.ts +4 -0
- package/dist/types/modes/controllers/input-controller.d.ts +2 -0
- package/dist/types/modes/interactive-mode.d.ts +2 -0
- package/dist/types/modes/queue-input.d.ts +8 -0
- package/dist/types/modes/types.d.ts +2 -0
- package/dist/types/session/agent-storage.d.ts +57 -0
- package/package.json +12 -12
- package/scripts/build-binary.ts +0 -1
- package/scripts/compile-binary.ts +4 -3
- package/src/cli/bench-cli.ts +7 -26
- package/src/cli/usage-cli.ts +11 -0
- package/src/commands/usage.ts +13 -2
- package/src/config/settings-schema.ts +1 -1
- package/src/eval/js/shared/rewrite-imports.ts +31 -13
- package/src/modes/components/advisor-config.ts +3 -1
- package/src/modes/components/custom-editor.test.ts +58 -1
- package/src/modes/components/custom-editor.ts +42 -11
- package/src/modes/components/model-browser.ts +154 -60
- package/src/modes/components/model-hub.ts +475 -122
- package/src/modes/components/plan-review-overlay.ts +7 -0
- package/src/modes/components/tips.txt +2 -1
- package/src/modes/components/usage-row.ts +5 -6
- package/src/modes/components/welcome.ts +13 -14
- package/src/modes/controllers/input-controller.ts +140 -6
- package/src/modes/controllers/selector-controller.ts +20 -13
- package/src/modes/controllers/todo-command-controller.ts +1 -2
- package/src/modes/interactive-mode.ts +18 -0
- package/src/modes/queue-input.ts +132 -0
- package/src/modes/types.ts +2 -0
- package/src/modes/utils/ui-helpers.ts +19 -20
- package/src/session/agent-session.ts +184 -48
- package/src/session/agent-storage.ts +330 -3
- package/src/session/history-storage.ts +1 -34
- package/src/slash-commands/builtin-registry.ts +9 -0
- package/src/web/search/providers/perplexity.ts +18 -2
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test";
|
|
2
|
+
import { CURSOR_MARKER } from "@oh-my-pi/pi-tui";
|
|
2
3
|
import { setKittyProtocolActive } from "@oh-my-pi/pi-tui/keys";
|
|
3
4
|
import { $ } from "bun";
|
|
4
5
|
import { getDefaultPasteImageKeys } from "../../config/keybindings";
|
|
5
|
-
import { getEditorTheme, initTheme } from "../theme/theme";
|
|
6
|
+
import { getEditorTheme, initTheme, theme } from "../theme/theme";
|
|
6
7
|
import {
|
|
7
8
|
CustomEditor,
|
|
8
9
|
extractBracketedImagePastePaths,
|
|
@@ -78,6 +79,62 @@ describe("CustomEditor placeholder decoration", () => {
|
|
|
78
79
|
});
|
|
79
80
|
});
|
|
80
81
|
|
|
82
|
+
describe("CustomEditor queue shorthand decoration", () => {
|
|
83
|
+
beforeAll(async () => {
|
|
84
|
+
await initTheme();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("reserves the first line as soon as either queue prefix is completed", () => {
|
|
88
|
+
for (const prefix of ["->", "=>"]) {
|
|
89
|
+
const editor = new CustomEditor(getEditorTheme());
|
|
90
|
+
editor.handleInput(prefix[0] ?? "");
|
|
91
|
+
expect(editor.getText()).toBe(prefix[0]);
|
|
92
|
+
|
|
93
|
+
editor.handleInput(prefix[1] ?? "");
|
|
94
|
+
expect(editor.getText()).toBe(`${prefix}\n`);
|
|
95
|
+
expect(editor.getCursor()).toEqual({ line: 1, col: 0 });
|
|
96
|
+
|
|
97
|
+
editor.handleInput("\x7f");
|
|
98
|
+
expect(editor.getText()).toBe(`${prefix}\n`);
|
|
99
|
+
expect(editor.getCursor()).toEqual({ line: 1, col: 0 });
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("renders the reserved line as a dim Queueing header", () => {
|
|
104
|
+
for (const prefix of ["->", "=>"]) {
|
|
105
|
+
const editor = new CustomEditor(getEditorTheme());
|
|
106
|
+
editor.setText(`${prefix}\nqueue this`);
|
|
107
|
+
|
|
108
|
+
expect(editor.decorateText(prefix)).toBe(theme.fg("dim", `Queueing ${theme.nav.selected}`));
|
|
109
|
+
editor.focused = true;
|
|
110
|
+
const rendered = editor.render(40).map(line => Bun.stripANSI(line.replace(CURSOR_MARKER, "")));
|
|
111
|
+
expect(rendered.some(line => line.includes(`Queueing ${theme.nav.selected}`))).toBe(true);
|
|
112
|
+
expect(rendered.every(line => Bun.stringWidth(line) === 40)).toBe(true);
|
|
113
|
+
expect(rendered.some(line => line.includes("queue this"))).toBe(true);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("highlights dot and parenthesis markers only for detected queue lists", () => {
|
|
118
|
+
for (const [input, marker] of [
|
|
119
|
+
["=>\n1. first\n2. second", "1."],
|
|
120
|
+
["=>\n1) first\n2) second", "1)"],
|
|
121
|
+
]) {
|
|
122
|
+
const editor = new CustomEditor(getEditorTheme());
|
|
123
|
+
editor.setText(input);
|
|
124
|
+
expect(editor.decorateText(`${marker} first`).startsWith(theme.fg("accent", marker))).toBe(true);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const unfinished = new CustomEditor(getEditorTheme());
|
|
128
|
+
unfinished.setText("=>\n1. first\n2. second\n3. third\n4.");
|
|
129
|
+
expect(unfinished.decorateText("1. first").startsWith(theme.fg("accent", "1."))).toBe(true);
|
|
130
|
+
expect(unfinished.decorateText("4.").startsWith(theme.fg("accent", "4."))).toBe(true);
|
|
131
|
+
|
|
132
|
+
const editor = new CustomEditor(getEditorTheme());
|
|
133
|
+
editor.setText("=>\n1. first\n3. third");
|
|
134
|
+
expect(editor.decorateText("1. first")).toBe("1. first");
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
81
138
|
describe("CustomEditor bracketed path paste", () => {
|
|
82
139
|
it("leaves a pasted bare .png filename on the normal text path", () => {
|
|
83
140
|
expect(extractBracketedImagePastePaths(bracketedPaste("icon-photo-default.png"))).toBeUndefined();
|
|
@@ -6,7 +6,8 @@ import type { AppKeybinding } from "../../config/keybindings";
|
|
|
6
6
|
import { isSettingsInitialized, settings } from "../../config/settings";
|
|
7
7
|
import { imageReferenceHyperlink, PLACEHOLDER_REGEX, renderPlaceholders } from "../image-references";
|
|
8
8
|
import { hasMagicKeyword, highlightMagicKeywords } from "../magic-keywords";
|
|
9
|
-
import {
|
|
9
|
+
import { isQueuedMessageList, parseQueueShorthand, QUEUE_LIST_MARKER_RE } from "../queue-input";
|
|
10
|
+
import { fgOrPlain, theme } from "../theme/theme";
|
|
10
11
|
|
|
11
12
|
type ConfigurableEditorAction = Extract<
|
|
12
13
|
AppKeybinding,
|
|
@@ -326,21 +327,41 @@ export class CustomEditor extends Editor {
|
|
|
326
327
|
* timer to request the next animation frame. Undefined when nobody is
|
|
327
328
|
* listening (tests, headless callers); the timer chain still self-cleans. */
|
|
328
329
|
#requestShimmerRepaint: (() => void) | undefined;
|
|
330
|
+
#queueDecorationText: string | undefined;
|
|
331
|
+
#queueShorthandActive = false;
|
|
332
|
+
#queueListActive = false;
|
|
329
333
|
|
|
330
|
-
/**
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
* When the editor is focused, the buffer contains a magic keyword, and `magicKeywords.enabled`
|
|
334
|
-
* is on, the gradient shifts every frame to produce a Claude-Code-style shimmer; each render
|
|
335
|
-
* schedules the next frame, so losing focus, deleting the keyword, or flipping the setting
|
|
336
|
-
* stops the animation on its own. The static glow itself runs even when shimmering is gated
|
|
337
|
-
* off, matching existing behavior for the editor and sent bubbles. */
|
|
334
|
+
/** Decorate magic keywords, attachments, and the queue-composer header/list markers.
|
|
335
|
+
* Queue shorthand reserves its first logical line as a dim `Queueing` label; sequential
|
|
336
|
+
* item markers use the accent color so separate follow-ups remain visible while composing. */
|
|
338
337
|
decorateText = (text: string): string => {
|
|
339
|
-
const
|
|
338
|
+
const editorText = this.getText();
|
|
339
|
+
const animated = this.focused && this.#shimmerEnabled() && hasMagicKeyword(editorText);
|
|
340
340
|
const phase = animated ? (Date.now() % CustomEditor.SHIMMER_PERIOD_MS) / CustomEditor.SHIMMER_PERIOD_MS : 0;
|
|
341
341
|
if (animated) this.#scheduleShimmerFrame();
|
|
342
|
+
if (this.#queueDecorationText !== editorText) {
|
|
343
|
+
this.#queueDecorationText = editorText;
|
|
344
|
+
const queueBody = parseQueueShorthand(editorText);
|
|
345
|
+
this.#queueShorthandActive = queueBody !== undefined;
|
|
346
|
+
this.#queueListActive = queueBody !== undefined && isQueuedMessageList(queueBody);
|
|
347
|
+
}
|
|
342
348
|
return renderPlaceholders(text, {
|
|
343
|
-
renderText: value =>
|
|
349
|
+
renderText: value => {
|
|
350
|
+
const highlighted = highlightMagicKeywords(value, undefined, phase);
|
|
351
|
+
if (this.#queueShorthandActive && (value.startsWith("->") || value.startsWith("=>"))) {
|
|
352
|
+
const icon = typeof theme === "undefined" ? "➤" : theme.nav.selected;
|
|
353
|
+
return `${fgOrPlain("dim", `Queueing ${icon}`)}${highlighted.slice(2)}`;
|
|
354
|
+
}
|
|
355
|
+
if (this.#queueListActive) {
|
|
356
|
+
const markerMatch = QUEUE_LIST_MARKER_RE.exec(value);
|
|
357
|
+
if (markerMatch) {
|
|
358
|
+
const indent = markerMatch[1] ?? "";
|
|
359
|
+
const markerEnd = markerMatch[0].length;
|
|
360
|
+
return `${indent}${fgOrPlain("accent", value.slice(indent.length, markerEnd))}${highlighted.slice(markerEnd)}`;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return highlighted;
|
|
364
|
+
},
|
|
344
365
|
renderReference: (value, kind, index) =>
|
|
345
366
|
kind === "image"
|
|
346
367
|
? imageReferenceHyperlink(value, index, this.imageLinks, label =>
|
|
@@ -628,6 +649,7 @@ export class CustomEditor extends Editor {
|
|
|
628
649
|
this.#pendingInput.push(data);
|
|
629
650
|
return;
|
|
630
651
|
}
|
|
652
|
+
const hadBareQueuePrefix = this.getText() === "->" || this.getText() === "=>";
|
|
631
653
|
const kittyParsed = parseKittySequence(data);
|
|
632
654
|
if (kittyParsed && (kittyParsed.modifier & 64) !== 0 && this.onCapsLock) {
|
|
633
655
|
// Caps Lock is modifier bit 64
|
|
@@ -831,5 +853,14 @@ export class CustomEditor extends Editor {
|
|
|
831
853
|
|
|
832
854
|
// Pass to parent for normal handling
|
|
833
855
|
super.handleInput(data);
|
|
856
|
+
const cursor = this.getCursor();
|
|
857
|
+
if (
|
|
858
|
+
!hadBareQueuePrefix &&
|
|
859
|
+
(this.getText() === "->" || this.getText() === "=>") &&
|
|
860
|
+
cursor.line === 0 &&
|
|
861
|
+
cursor.col === 2
|
|
862
|
+
) {
|
|
863
|
+
this.insertText("\n");
|
|
864
|
+
}
|
|
834
865
|
}
|
|
835
866
|
}
|
|
@@ -13,7 +13,7 @@ import { buildModel } from "@oh-my-pi/pi-catalog/build";
|
|
|
13
13
|
import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
|
|
14
14
|
import {
|
|
15
15
|
type Component,
|
|
16
|
-
|
|
16
|
+
fuzzyRank,
|
|
17
17
|
Input,
|
|
18
18
|
matchesKey,
|
|
19
19
|
ScrollView,
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
import { formatNumber } from "@oh-my-pi/pi-utils";
|
|
25
25
|
import { getRoleInfo, MODEL_ROLE_IDS } from "../../config/model-roles";
|
|
26
26
|
import type { Settings } from "../../config/settings";
|
|
27
|
+
import type { ModelPerfStats } from "../../session/agent-storage";
|
|
27
28
|
import { AUTO_THINKING, type ConfiguredThinkingLevel } from "../../thinking";
|
|
28
29
|
import { theme } from "../theme/theme";
|
|
29
30
|
import {
|
|
@@ -227,6 +228,18 @@ function formatContext(model: Model): string {
|
|
|
227
228
|
return `${formatNumber(ctx).toLowerCase()} ${theme.icon.context.replace(/:$/, "")}`;
|
|
228
229
|
}
|
|
229
230
|
|
|
231
|
+
/** `118t/s` average output speed; one decimal below 10 t/s. */
|
|
232
|
+
function formatTps(tps: number): string {
|
|
233
|
+
const value = tps >= 10 ? String(Math.round(tps)) : tps.toFixed(1);
|
|
234
|
+
return `${value}t/s`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** `0.9s` average time-to-first-token; whole seconds from 10s up. */
|
|
238
|
+
function formatTtft(ms: number): string {
|
|
239
|
+
const seconds = ms / 1000;
|
|
240
|
+
return seconds >= 10 ? `${Math.round(seconds)}s` : `${seconds.toFixed(1)}s`;
|
|
241
|
+
}
|
|
242
|
+
|
|
230
243
|
/** Pad `text` on the left to `width` terminal columns (ANSI/emoji aware). */
|
|
231
244
|
function padLeftVisible(text: string, width: number): string {
|
|
232
245
|
const missing = width - visibleWidth(text);
|
|
@@ -250,6 +263,12 @@ export interface ModelBrowserOptions {
|
|
|
250
263
|
const LIST_ROW_START = 2;
|
|
251
264
|
/** Rendered rows after the list window: blank + two detail rows. */
|
|
252
265
|
const DETAIL_ROWS = 3;
|
|
266
|
+
/** Row width from which the measured-perf column appears (TPS only). */
|
|
267
|
+
const PERF_TPS_MIN_WIDTH = 76;
|
|
268
|
+
/** Row width from which the perf column also includes TTFT. */
|
|
269
|
+
const PERF_FULL_MIN_WIDTH = 96;
|
|
270
|
+
/** What the per-row perf column shows at the current width. */
|
|
271
|
+
type PerfMode = "off" | "tps" | "full";
|
|
253
272
|
|
|
254
273
|
/**
|
|
255
274
|
* The reusable browser component. Renders a fixed-height block
|
|
@@ -263,6 +282,7 @@ export class ModelBrowser implements Component {
|
|
|
263
282
|
#visibleItems: ModelBrowserItem[] = [];
|
|
264
283
|
#roles: RoleAssignments = {};
|
|
265
284
|
#mruOrder: ReadonlyArray<string> = [];
|
|
285
|
+
#perf: ReadonlyMap<string, ModelPerfStats> = new Map();
|
|
266
286
|
#selectedIndex = 0;
|
|
267
287
|
#hoveredIndex: number | null = null;
|
|
268
288
|
#maxVisible = 10;
|
|
@@ -270,8 +290,11 @@ export class ModelBrowser implements Component {
|
|
|
270
290
|
#currentContextTokens: number;
|
|
271
291
|
#disableOverContext: boolean;
|
|
272
292
|
#emptyText?: () => string | undefined;
|
|
293
|
+
/** First visible list row; panned by the wheel, snapped to the selection on keyboard navigation. */
|
|
273
294
|
#windowStart = 0;
|
|
274
295
|
#windowCount = 0;
|
|
296
|
+
/** Whether the host pane owns arrow keys; drives cursor strength and the selected-row band. */
|
|
297
|
+
#focused = true;
|
|
275
298
|
|
|
276
299
|
/** Enter or click-on-selected. */
|
|
277
300
|
onActivate?: (item: ModelBrowserItem) => void;
|
|
@@ -310,13 +333,24 @@ export class ModelBrowser implements Component {
|
|
|
310
333
|
this.#mruOrder = order;
|
|
311
334
|
}
|
|
312
335
|
|
|
336
|
+
/** Measured TPS/TTFT averages keyed by `provider/id` selector (see AgentStorage.getModelPerf). */
|
|
337
|
+
setPerfStats(perf: ReadonlyMap<string, ModelPerfStats>): void {
|
|
338
|
+
this.#perf = perf;
|
|
339
|
+
}
|
|
340
|
+
|
|
313
341
|
setMaxVisible(rows: number): void {
|
|
342
|
+
// No selection snap here: hosts call this on every render, and it must
|
|
343
|
+
// not undo wheel panning. render() re-clamps the window.
|
|
314
344
|
this.#maxVisible = Math.max(1, rows);
|
|
315
345
|
}
|
|
316
346
|
|
|
317
347
|
setShowProvider(show: boolean): void {
|
|
318
348
|
this.#showProvider = show;
|
|
319
349
|
}
|
|
350
|
+
/** Focused: accent cursor + selected-row background band. Unfocused: dim cursor, no band. */
|
|
351
|
+
setFocused(focused: boolean): void {
|
|
352
|
+
this.#focused = focused;
|
|
353
|
+
}
|
|
320
354
|
|
|
321
355
|
/** Total rendered height for the current `maxVisible` (host layout budgeting). */
|
|
322
356
|
get renderedRows(): number {
|
|
@@ -345,6 +379,7 @@ export class ModelBrowser implements Component {
|
|
|
345
379
|
const index = this.#visibleItems.findIndex(item => item.selector === selector);
|
|
346
380
|
if (index < 0) return false;
|
|
347
381
|
this.#selectedIndex = this.#coerceSelectedIndex(index);
|
|
382
|
+
this.#ensureSelectedVisible();
|
|
348
383
|
return true;
|
|
349
384
|
}
|
|
350
385
|
|
|
@@ -372,23 +407,48 @@ export class ModelBrowser implements Component {
|
|
|
372
407
|
return clamped;
|
|
373
408
|
}
|
|
374
409
|
|
|
375
|
-
|
|
410
|
+
/** Clamp a window start into `[0, total - maxVisible]`. */
|
|
411
|
+
#clampWindowStart(start: number): number {
|
|
412
|
+
return Math.max(0, Math.min(start, this.#visibleItems.length - this.#maxVisible));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Scroll just enough to keep the selected row inside the window. */
|
|
416
|
+
#ensureSelectedVisible(): void {
|
|
417
|
+
if (this.#selectedIndex < this.#windowStart) {
|
|
418
|
+
this.#windowStart = this.#selectedIndex;
|
|
419
|
+
} else if (this.#selectedIndex >= this.#windowStart + this.#maxVisible) {
|
|
420
|
+
this.#windowStart = this.#selectedIndex - this.#maxVisible + 1;
|
|
421
|
+
}
|
|
422
|
+
this.#windowStart = this.#clampWindowStart(this.#windowStart);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Move the selection by `delta` rows, skipping disabled rows. Single steps
|
|
427
|
+
* wrap at the ends; `wrap: false` (page/home/end jumps) clamps instead.
|
|
428
|
+
*/
|
|
429
|
+
moveSelection(delta: number, options: { wrap?: boolean } = {}): void {
|
|
376
430
|
const count = this.#visibleItems.length;
|
|
377
431
|
if (count === 0) return;
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
this.#
|
|
384
|
-
|
|
432
|
+
if (options.wrap ?? true) {
|
|
433
|
+
let index = this.#selectedIndex;
|
|
434
|
+
for (let step = 0; step < count; step++) {
|
|
435
|
+
index = (index + delta + count) % count;
|
|
436
|
+
const item = this.#visibleItems[index];
|
|
437
|
+
if (item && !this.#isDisabled(item)) {
|
|
438
|
+
this.#setSelectedIndex(index);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
385
441
|
}
|
|
442
|
+
return;
|
|
386
443
|
}
|
|
444
|
+
const target = Math.max(0, Math.min(this.#selectedIndex + delta, count - 1));
|
|
445
|
+
this.#setSelectedIndex(this.#coerceSelectedIndex(target));
|
|
387
446
|
}
|
|
388
447
|
|
|
389
448
|
#setSelectedIndex(index: number): void {
|
|
390
449
|
if (index === this.#selectedIndex) return;
|
|
391
450
|
this.#selectedIndex = index;
|
|
451
|
+
this.#ensureSelectedVisible();
|
|
392
452
|
this.onSelectionChange?.(this.getSelected());
|
|
393
453
|
}
|
|
394
454
|
|
|
@@ -432,16 +492,26 @@ export class ModelBrowser implements Component {
|
|
|
432
492
|
if (query.trim()) {
|
|
433
493
|
// Match against the displayed "provider/id" string so the user can
|
|
434
494
|
// type what they see: bare names, provider prefixes, or scoped
|
|
435
|
-
// queries all flow through the same fuzzy matcher.
|
|
436
|
-
|
|
437
|
-
const matches =
|
|
495
|
+
// queries all flow through the same fuzzy matcher.
|
|
496
|
+
const ranked = fuzzyRank(this.#baseItems, query, ({ provider, id }) => `${provider}/${id}`);
|
|
497
|
+
const matches = ranked.map(result => result.item);
|
|
498
|
+
// Match quality is the primary key while searching: an exact
|
|
499
|
+
// "gpt-5.5" must beat the MRU (or role-assigned) "gpt-5.6", so
|
|
500
|
+
// role rank is skipped and MRU only breaks ties. Scores are
|
|
501
|
+
// bucketed so sub-point position noise (provider-name length)
|
|
502
|
+
// can't split equally good matches; within a bucket the stable
|
|
503
|
+
// sort keeps sortModelItems' MRU/version order.
|
|
438
504
|
sortModelItems(matches, { roles: this.#roles, mruOrder: this.#mruOrder, skipRoleRank: true });
|
|
505
|
+
const buckets = new Map<ModelBrowserItem, number>();
|
|
506
|
+
for (const result of ranked) buckets.set(result.item, Math.round(result.score / 10));
|
|
507
|
+
matches.sort((a, b) => (buckets.get(a) ?? 0) - (buckets.get(b) ?? 0));
|
|
439
508
|
items = matches;
|
|
440
509
|
} else {
|
|
441
510
|
items = this.#baseItems;
|
|
442
511
|
}
|
|
443
512
|
this.#visibleItems = this.#insertSeparator(items);
|
|
444
513
|
this.#selectedIndex = this.#coerceSelectedIndex(Math.min(this.#selectedIndex, this.#visibleItems.length - 1));
|
|
514
|
+
this.#ensureSelectedVisible();
|
|
445
515
|
this.onSelectionChange?.(this.getSelected());
|
|
446
516
|
}
|
|
447
517
|
|
|
@@ -459,11 +529,19 @@ export class ModelBrowser implements Component {
|
|
|
459
529
|
return;
|
|
460
530
|
}
|
|
461
531
|
if (matchesSelectPageUp(data)) {
|
|
462
|
-
this.moveSelection(-this.#maxVisible);
|
|
532
|
+
this.moveSelection(-this.#maxVisible, { wrap: false });
|
|
463
533
|
return;
|
|
464
534
|
}
|
|
465
535
|
if (matchesSelectPageDown(data)) {
|
|
466
|
-
this.moveSelection(this.#maxVisible);
|
|
536
|
+
this.moveSelection(this.#maxVisible, { wrap: false });
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
if (matchesKey(data, "home")) {
|
|
540
|
+
this.moveSelection(-this.#visibleItems.length, { wrap: false });
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
if (matchesKey(data, "end")) {
|
|
544
|
+
this.moveSelection(this.#visibleItems.length, { wrap: false });
|
|
467
545
|
return;
|
|
468
546
|
}
|
|
469
547
|
if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
|
|
@@ -499,50 +577,49 @@ export class ModelBrowser implements Component {
|
|
|
499
577
|
*/
|
|
500
578
|
routeMouse(event: SgrMouseEvent, line: number): void {
|
|
501
579
|
if (event.wheel !== null) {
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
const listLine = line - LIST_ROW_START;
|
|
506
|
-
if (listLine < 0 || listLine >= this.#windowCount) {
|
|
507
|
-
if (event.motion && this.#hoveredIndex !== null) {
|
|
508
|
-
this.#hoveredIndex = null;
|
|
509
|
-
}
|
|
510
|
-
return;
|
|
511
|
-
}
|
|
512
|
-
const index = this.#windowStart + listLine;
|
|
513
|
-
const item = this.#visibleItems[index];
|
|
514
|
-
if (!item || this.#isDisabled(item)) {
|
|
515
|
-
this.#hoveredIndex = null;
|
|
580
|
+
// Wheel pans the window; it never moves the selection and never wraps.
|
|
581
|
+
this.#windowStart = this.#clampWindowStart(this.#windowStart + event.wheel);
|
|
582
|
+
this.#hoveredIndex = this.#hoverIndexAt(line);
|
|
516
583
|
return;
|
|
517
584
|
}
|
|
518
585
|
if (event.motion) {
|
|
519
|
-
this.#hoveredIndex =
|
|
586
|
+
this.#hoveredIndex = this.#hoverIndexAt(line);
|
|
520
587
|
return;
|
|
521
588
|
}
|
|
522
|
-
if (event.leftClick)
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
589
|
+
if (!event.leftClick) return;
|
|
590
|
+
const index = this.#hoverIndexAt(line);
|
|
591
|
+
const item = index !== null ? this.#visibleItems[index] : undefined;
|
|
592
|
+
if (index === null || !item) return;
|
|
593
|
+
// Settings idiom: click selects, click-again activates.
|
|
594
|
+
if (index === this.#selectedIndex) {
|
|
595
|
+
this.onActivate?.(item);
|
|
596
|
+
} else {
|
|
597
|
+
this.#setSelectedIndex(index);
|
|
529
598
|
}
|
|
530
599
|
}
|
|
600
|
+
/** Drop the hover band. Hosts call this when the pointer leaves the browser pane. */
|
|
601
|
+
clearHover(): void {
|
|
602
|
+
this.#hoveredIndex = null;
|
|
603
|
+
}
|
|
531
604
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
const
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
605
|
+
/** List index under a frame-local row, or null when off-list or on a disabled row. */
|
|
606
|
+
#hoverIndexAt(line: number): number | null {
|
|
607
|
+
const listLine = line - LIST_ROW_START;
|
|
608
|
+
if (listLine < 0 || listLine >= this.#windowCount) return null;
|
|
609
|
+
const index = this.#windowStart + listLine;
|
|
610
|
+
const item = this.#visibleItems[index];
|
|
611
|
+
if (!item || this.#isDisabled(item)) return null;
|
|
612
|
+
return index;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** `0.9s 118t/s` measured-perf cell for the row's meta block; empty when unmeasured or the column is off. */
|
|
616
|
+
#perfCell(item: ModelBrowserItem, mode: PerfMode): string {
|
|
617
|
+
if (mode === "off") return "";
|
|
618
|
+
const perf = this.#perf.get(item.selector);
|
|
619
|
+
if (!perf) return "";
|
|
620
|
+
const tps = formatTps(perf.tps);
|
|
621
|
+
if (mode === "full" && perf.ttftMs !== null) return `${formatTtft(perf.ttftMs)} ${tps}`;
|
|
622
|
+
return tps;
|
|
546
623
|
}
|
|
547
624
|
|
|
548
625
|
#renderRow(
|
|
@@ -552,6 +629,8 @@ export class ModelBrowser implements Component {
|
|
|
552
629
|
hovered: boolean,
|
|
553
630
|
ctxWidth: number,
|
|
554
631
|
costWidth: number,
|
|
632
|
+
perfWidth: number,
|
|
633
|
+
perfMode: PerfMode,
|
|
555
634
|
): string {
|
|
556
635
|
if (item.id === "separator") {
|
|
557
636
|
const dashCount = Math.max(0, width - 4);
|
|
@@ -559,16 +638,19 @@ export class ModelBrowser implements Component {
|
|
|
559
638
|
return ` ${line} `;
|
|
560
639
|
}
|
|
561
640
|
const disabled = this.#isDisabled(item);
|
|
562
|
-
const prefix = selected ? `${theme.fg("accent", theme.nav.cursor)} ` : " ";
|
|
641
|
+
const prefix = selected && this.#focused ? `${theme.fg("accent", theme.nav.cursor)} ` : " ";
|
|
563
642
|
const providerPrefix = this.#showProvider ? theme.fg("dim", `${item.provider}/`) : "";
|
|
564
643
|
const name = selected ? theme.fg("accent", item.id) : item.id;
|
|
565
644
|
const overLimit = disabled
|
|
566
645
|
? ` ${theme.status.disabled} context>${formatNumber(item.model.contextWindow ?? 0).toLowerCase()}`
|
|
567
646
|
: "";
|
|
568
|
-
let left = `${prefix}${providerPrefix}${name}${
|
|
647
|
+
let left = `${prefix}${providerPrefix}${name}${overLimit}`;
|
|
569
648
|
|
|
570
|
-
|
|
571
|
-
const
|
|
649
|
+
// Perf column collapses entirely when no visible row has measurements.
|
|
650
|
+
const perfCol =
|
|
651
|
+
perfWidth > 0 ? `${theme.fg("dim", padLeftVisible(this.#perfCell(item, perfMode), perfWidth))} ` : "";
|
|
652
|
+
const meta = `${perfCol}${theme.fg("dim", padLeftVisible(formatContext(item.model), ctxWidth))} ${theme.fg("dim", padLeftVisible(formatCostPair(item.model), costWidth))}`;
|
|
653
|
+
const metaWidth = ctxWidth + costWidth + 2 + (perfWidth > 0 ? perfWidth + 2 : 0);
|
|
572
654
|
const available = Math.max(1, width - metaWidth - 1);
|
|
573
655
|
left = truncateToWidth(left, available);
|
|
574
656
|
const gap = Math.max(0, available - visibleWidth(left));
|
|
@@ -577,7 +659,9 @@ export class ModelBrowser implements Component {
|
|
|
577
659
|
if (disabled) {
|
|
578
660
|
line = theme.fg("dim", Bun.stripANSI(line));
|
|
579
661
|
}
|
|
580
|
-
|
|
662
|
+
// The bg band is reserved for the mouse: it marks hover, nothing else.
|
|
663
|
+
// Keyboard selection is the cursor glyph + accent name.
|
|
664
|
+
if (hovered && !disabled) {
|
|
581
665
|
line = theme.bg("selectedBg", line);
|
|
582
666
|
}
|
|
583
667
|
return line;
|
|
@@ -594,6 +678,11 @@ export class ModelBrowser implements Component {
|
|
|
594
678
|
facts.push(`${formatCostPair(model)} per M`);
|
|
595
679
|
if (model.reasoning) facts.push("reasoning");
|
|
596
680
|
if (model.input.includes("image")) facts.push("vision");
|
|
681
|
+
const perf = this.#perf.get(selected.selector);
|
|
682
|
+
if (perf) {
|
|
683
|
+
facts.push(`~${formatTps(perf.tps)}`);
|
|
684
|
+
if (perf.ttftMs !== null) facts.push(`${formatTtft(perf.ttftMs)} ttft`);
|
|
685
|
+
}
|
|
597
686
|
const line1 = truncateToWidth(theme.fg("muted", ` ${facts.join(" · ")}`), width);
|
|
598
687
|
|
|
599
688
|
if (this.#isDisabled(selected)) {
|
|
@@ -626,12 +715,12 @@ export class ModelBrowser implements Component {
|
|
|
626
715
|
lines.push("");
|
|
627
716
|
|
|
628
717
|
const total = this.#visibleItems.length;
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
);
|
|
718
|
+
// The window is persistent state: wheel scrolling panned it, keyboard
|
|
719
|
+
// navigation snapped it to the selection. Re-clamp here because items
|
|
720
|
+
// or maxVisible may have changed since.
|
|
721
|
+
this.#windowStart = this.#clampWindowStart(this.#windowStart);
|
|
722
|
+
const startIndex = this.#windowStart;
|
|
633
723
|
const endIndex = Math.min(startIndex + this.#maxVisible, total);
|
|
634
|
-
this.#windowStart = startIndex;
|
|
635
724
|
this.#windowCount = Math.max(0, endIndex - startIndex);
|
|
636
725
|
|
|
637
726
|
if (total === 0) {
|
|
@@ -644,11 +733,14 @@ export class ModelBrowser implements Component {
|
|
|
644
733
|
// scanning the entire catalog on every render.
|
|
645
734
|
let ctxWidth = 0;
|
|
646
735
|
let costWidth = 0;
|
|
736
|
+
const perfMode: PerfMode = width >= PERF_FULL_MIN_WIDTH ? "full" : width >= PERF_TPS_MIN_WIDTH ? "tps" : "off";
|
|
737
|
+
let perfWidth = 0;
|
|
647
738
|
for (let i = startIndex; i < endIndex; i++) {
|
|
648
739
|
const item = this.#visibleItems[i];
|
|
649
740
|
if (!item) continue;
|
|
650
741
|
ctxWidth = Math.max(ctxWidth, visibleWidth(formatContext(item.model)));
|
|
651
742
|
costWidth = Math.max(costWidth, visibleWidth(formatCostPair(item.model)));
|
|
743
|
+
perfWidth = Math.max(perfWidth, visibleWidth(this.#perfCell(item, perfMode)));
|
|
652
744
|
}
|
|
653
745
|
|
|
654
746
|
const rows: string[] = [];
|
|
@@ -663,6 +755,8 @@ export class ModelBrowser implements Component {
|
|
|
663
755
|
i === this.#hoveredIndex,
|
|
664
756
|
ctxWidth,
|
|
665
757
|
costWidth,
|
|
758
|
+
perfWidth,
|
|
759
|
+
perfMode,
|
|
666
760
|
),
|
|
667
761
|
);
|
|
668
762
|
}
|