@bismawy/pi-vision-watcher 1.0.15 โ 1.1.0
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 +99 -59
- package/README.md +137 -137
- package/package.json +72 -71
- package/src/vision-model-selector.ts +103 -39
- package/vision-watcher.ts +1304 -1304
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
* describes images during vision handoff.
|
|
4
4
|
*
|
|
5
5
|
* Uses the same patterns as pi's built-in selectors and pi-hide-providers:
|
|
6
|
-
* - Lists connected (authenticated) models, vision-capable ones first (
|
|
6
|
+
* - Lists connected (authenticated) models, vision-capable ones first (โฆ badge)
|
|
7
7
|
* - A leading "None" row clears the configured vision model
|
|
8
8
|
* - Space selects the highlighted model as the primary describer (toggle)
|
|
9
|
-
* - Ctrl+
|
|
10
|
-
* - Ctrl+
|
|
9
|
+
* - Ctrl+Enter toggles the highlighted model in/out of the failover chain (max 3)
|
|
10
|
+
* - Ctrl+Shift+R clears the failover chain, Ctrl+Shift+T walks the thinking ladder,
|
|
11
|
+
* Alt+A / Ctrl+Alt+A toggles async paste handoff, Alt+โโ reorders rows
|
|
11
12
|
* - Enter or Ctrl+S saves, Esc / Ctrl+C cancels
|
|
12
|
-
* - The primary is marked โ, chain members
|
|
13
|
+
* - The primary is marked โ, chain members โ
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
16
|
import {
|
|
@@ -25,7 +26,7 @@ import {
|
|
|
25
26
|
visibleWidth,
|
|
26
27
|
wrapTextWithAnsi,
|
|
27
28
|
} from "@earendil-works/pi-tui";
|
|
28
|
-
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
29
|
+
import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
29
30
|
import type { ThinkingLevel } from "@earendil-works/pi-ai";
|
|
30
31
|
import { DynamicBorder, keyText } from "@earendil-works/pi-coding-agent";
|
|
31
32
|
import { formatModelRef, isVisionModel, THINKING_LEVELS } from "./index.js";
|
|
@@ -34,14 +35,28 @@ import { formatModelRef, isVisionModel, THINKING_LEVELS } from "./index.js";
|
|
|
34
35
|
* fourth describer would ever be reached. */
|
|
35
36
|
export const MAX_FALLBACKS = 3;
|
|
36
37
|
|
|
37
|
-
/** Key that toggles failover-chain membership
|
|
38
|
+
/** Key that toggles failover-chain membership.
|
|
38
39
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
40
|
+
* `ctrl+enter` is the one chord that survives Windows conhost/Windows Terminal,
|
|
41
|
+
* Linux/X11, macOS, tmux and SSH alike. The previous binding was `ctrl+q`, which
|
|
42
|
+
* collides with POSIX tty flow control (IXON: ctrl+s freezes / ctrl+q resumes
|
|
43
|
+
* output before the app sees it), with pi's own `app.message.followUp`, and โ
|
|
44
|
+
* for the reset chord โ with VTE's window-level "close terminal" accelerator.
|
|
45
|
+
*
|
|
46
|
+
* Reset uses `ctrl+shift+r` because R reads as Reset. It is unbound in every
|
|
47
|
+
* default terminal (VTE, Konsole, Windows Terminal, iTerm2 โ kitty is the lone
|
|
48
|
+
* exception, where it resizes a window) and unbound anywhere in pi. On legacy
|
|
49
|
+
* 7-bit terminals it degrades to plain `ctrl+r`, which the app does not use, so
|
|
50
|
+
* the worst case is a no-op rather than a wrong action. The old `ctrl+shift+q`
|
|
51
|
+
* was impossible to type on those terminals: both it and `ctrl+q` emit 0x11. */
|
|
52
|
+
const FALLBACK_KEY = Key.ctrl("enter");
|
|
53
|
+
const FALLBACK_KEY_HINT = "ctrl+enter";
|
|
54
|
+
const REORDER_UP_KEY = Key.alt("up");
|
|
55
|
+
const REORDER_DOWN_KEY = Key.alt("down");
|
|
56
|
+
/** Hint printed next to the async toggle in the detail pane (and the legend).
|
|
57
|
+
* `ctrl+alt+a` is the Linux-safe half: AltGr occupies alt+a on intl layouts
|
|
58
|
+
* (`@`, `ล`), so the selector accepts both chords. */
|
|
59
|
+
const ASYNC_KEY_HINT = "[Ctrl+alt+a]";
|
|
45
60
|
|
|
46
61
|
/** Provider ids that don't title-case cleanly. Everything else falls back to
|
|
47
62
|
* word-capitalisation (`custom-openrouter-ai` โ "Custom Openrouter AI"). */
|
|
@@ -233,7 +248,27 @@ export class VisionModelSelectorComponent implements Component {
|
|
|
233
248
|
return;
|
|
234
249
|
}
|
|
235
250
|
|
|
251
|
+
// Reorder the highlighted model inside the list. The failover chain is
|
|
252
|
+
// tried in list order, so moving a row is how you change which fallback
|
|
253
|
+
// runs first. Only meaningful on the unfiltered list (the filter is a
|
|
254
|
+
// view, not an ordering); skip edits that would cross the pinned None row.
|
|
255
|
+
if (matchesKey(data, REORDER_UP_KEY) || matchesKey(data, REORDER_DOWN_KEY)) {
|
|
256
|
+
if (this.searchInput.getValue()) return;
|
|
257
|
+
const item = this.filteredItems[this.selectedIndex];
|
|
258
|
+
if (item?.ref) this.moveItem(item.ref, matchesKey(data, REORDER_DOWN_KEY) ? 1 : -1);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Confirming saves the configuration. When a filter query is typed, Enter
|
|
263
|
+
// ALSO adopts the highlighted model as the primary describer โ the search
|
|
264
|
+
// field has already consumed every `space`, so without this the highlighted
|
|
265
|
+
// model could never be picked while filtering and the stale primary would
|
|
266
|
+
// be saved silently.
|
|
236
267
|
if (kb.matches(data, "tui.select.confirm") || matchesKey(data, Key.ctrl("s"))) {
|
|
268
|
+
if (this.searchInput.getValue()) {
|
|
269
|
+
const item = this.filteredItems[this.selectedIndex];
|
|
270
|
+
if (item) this.selectPrimary(item.ref);
|
|
271
|
+
}
|
|
237
272
|
this.save();
|
|
238
273
|
return;
|
|
239
274
|
}
|
|
@@ -268,7 +303,7 @@ export class VisionModelSelectorComponent implements Component {
|
|
|
268
303
|
// chosen together. Intercepted before the search input (like the other ctrl
|
|
269
304
|
// shortcuts) so the key never lands in the filter text. See
|
|
270
305
|
// {@link FALLBACK_KEY} for why it isn't ctrl+f.
|
|
271
|
-
if (matchesKey(data,
|
|
306
|
+
if (matchesKey(data, "ctrl+shift+r")) {
|
|
272
307
|
this.clearFallbacks();
|
|
273
308
|
return;
|
|
274
309
|
}
|
|
@@ -279,7 +314,11 @@ export class VisionModelSelectorComponent implements Component {
|
|
|
279
314
|
return;
|
|
280
315
|
}
|
|
281
316
|
|
|
282
|
-
|
|
317
|
+
// alt+a on Windows/macOS, ctrl+alt+a on Linux where AltGr takes alt+a
|
|
318
|
+
// (intl layouts: `@`, `ล`). `ctrl+a` is the editor's cursorLineStart (Home)
|
|
319
|
+
// and GNU Screen / tmux's command prefix, so it cannot be repurposed here.
|
|
320
|
+
// Intercepted before the search input so it never lands in the filter text.
|
|
321
|
+
if (matchesKey(data, Key.alt("a")) || matchesKey(data, Key.ctrlAlt("a"))) {
|
|
283
322
|
this.asyncClipboardHandoff = !this.asyncClipboardHandoff;
|
|
284
323
|
this.updateList();
|
|
285
324
|
return;
|
|
@@ -288,7 +327,9 @@ export class VisionModelSelectorComponent implements Component {
|
|
|
288
327
|
// ctrl+t walks the whole thinking ladder (off โ minimal โ โฆ โ max โ off) so
|
|
289
328
|
// one key covers on/off *and* effort โ no separate shift+tab binding.
|
|
290
329
|
// Intercepted before the search input so it never lands in the filter text.
|
|
291
|
-
|
|
330
|
+
// `ctrl+t` is readline transpose-chars and pi's app.thinking.toggle (tool
|
|
331
|
+
// output); the ladder gets its own chord instead so both stay usable.
|
|
332
|
+
if (matchesKey(data, Key.ctrlShift("t"))) {
|
|
292
333
|
this.cycleThinking();
|
|
293
334
|
this.updateList();
|
|
294
335
|
return;
|
|
@@ -356,21 +397,35 @@ export class VisionModelSelectorComponent implements Component {
|
|
|
356
397
|
|
|
357
398
|
// One legend line: the count carries the accent colour so it is the first
|
|
358
399
|
// thing the eye lands on, while the keys stay dim so they do not compete
|
|
359
|
-
// with the picker itself.
|
|
360
|
-
//
|
|
400
|
+
// with the picker itself. The chords need roughly 120 columns, which wraps
|
|
401
|
+
// gracefully; the async and reorder keys are hinted beside the state they
|
|
402
|
+
// toggle (and in the README) instead of bloating this strip.
|
|
361
403
|
const confirm = keyText("tui.select.confirm");
|
|
362
404
|
const legend = [
|
|
363
405
|
`[${confirm.charAt(0).toUpperCase()}${confirm.slice(1)}] Done`,
|
|
364
406
|
"[Space] Vision",
|
|
365
|
-
"[Ctrl+
|
|
366
|
-
"[Ctrl+
|
|
367
|
-
"[Ctrl+t] Think",
|
|
407
|
+
"[Ctrl+enter] Fallback",
|
|
408
|
+
"[Ctrl+shift+r] Reset",
|
|
409
|
+
"[Ctrl+shift+t] Think",
|
|
368
410
|
"[Esc] Cancel",
|
|
369
411
|
].join(" ");
|
|
370
412
|
|
|
371
413
|
return `${this.theme.fg("dim", " ")}${this.theme.fg("accent", count)}${this.theme.fg("dim", ` ยท ${legend}`)}`;
|
|
372
414
|
}
|
|
373
415
|
|
|
416
|
+
/** Move `ref` one row up/down in the display list. The None row is pinned at
|
|
417
|
+
* index 0; no move may cross it or run off either end. */
|
|
418
|
+
private moveItem(ref: string, delta: number): void {
|
|
419
|
+
const from = this.allItems.findIndex((i) => i.ref === ref);
|
|
420
|
+
const to = from + delta;
|
|
421
|
+
if (from <= 0 || to <= 0 || to >= this.allItems.length) return;
|
|
422
|
+
const [item] = this.allItems.splice(from, 1);
|
|
423
|
+
this.allItems.splice(to, 0, item!);
|
|
424
|
+
this.filteredItems = this.allItems;
|
|
425
|
+
this.selectedIndex = to;
|
|
426
|
+
this.updateList();
|
|
427
|
+
}
|
|
428
|
+
|
|
374
429
|
private clearFallbacks(): void {
|
|
375
430
|
if (this.fallbacks.size === 0) return;
|
|
376
431
|
this.fallbacks.clear();
|
|
@@ -461,7 +516,7 @@ export class VisionModelSelectorComponent implements Component {
|
|
|
461
516
|
const labelled = isSelected
|
|
462
517
|
? this.theme.fg("accent", item.modelId)
|
|
463
518
|
: item.modelId;
|
|
464
|
-
const badge = item.vision ? this.theme.fg("success", "
|
|
519
|
+
const badge = item.vision ? this.theme.fg("success", " โฆ") : this.theme.fg("muted", " ยท");
|
|
465
520
|
const providerBadge = this.theme.fg("muted", ` [${item.provider}]`);
|
|
466
521
|
label = `${labelled}${providerBadge}${badge}`;
|
|
467
522
|
}
|
|
@@ -476,7 +531,7 @@ export class VisionModelSelectorComponent implements Component {
|
|
|
476
531
|
// chain behind it).
|
|
477
532
|
const fallbackMark =
|
|
478
533
|
item.ref && this.fallbacks.has(item.ref)
|
|
479
|
-
? this.theme.fg("warning", "
|
|
534
|
+
? this.theme.fg("warning", " โ")
|
|
480
535
|
: "";
|
|
481
536
|
|
|
482
537
|
this.listContainer.addChild(new Text(`${prefix}${label}${current}${fallbackMark}`, 0, 0));
|
|
@@ -512,7 +567,7 @@ export class VisionModelSelectorComponent implements Component {
|
|
|
512
567
|
}
|
|
513
568
|
|
|
514
569
|
/** The detail pane summarises the *configuration* (primary, failover chain
|
|
515
|
-
* and toggles) rather than the highlighted row, so each space / ctrl+
|
|
570
|
+
* and toggles) rather than the highlighted row, so each space / ctrl+enter
|
|
516
571
|
* press shows exactly what will be saved.
|
|
517
572
|
*
|
|
518
573
|
* Built per frame rather than cached in a child component because the
|
|
@@ -521,13 +576,21 @@ export class VisionModelSelectorComponent implements Component {
|
|
|
521
576
|
private detailLines(width: number): string[] {
|
|
522
577
|
const out: string[] = [];
|
|
523
578
|
|
|
524
|
-
|
|
525
|
-
|
|
579
|
+
// The label โ including the symbol, when it has one โ wears the same
|
|
580
|
+
// colour as the badge/marker it explains, so the two read as one legend
|
|
581
|
+
// instead of as prose that happens to contain a glyph. The space before
|
|
582
|
+
// `:` keeps the dim label and the symbol grid aligned with the labels
|
|
583
|
+
// below (`Thinking:`, `Async โฆ:`) which have no symbol.
|
|
584
|
+
const label = (text: string, symbol: string, color: ThemeColor) =>
|
|
585
|
+
`${this.theme.fg("dim", ` ${text} `)}${this.theme.fg(color, symbol)}${this.theme.fg("dim", " : ")}`;
|
|
586
|
+
|
|
587
|
+
const value = (labelText: string, text: string) => {
|
|
588
|
+
const indent = " ".repeat(2 + visibleWidth(labelText));
|
|
526
589
|
const wrapped = wrapTextWithAnsi(
|
|
527
|
-
|
|
590
|
+
text,
|
|
528
591
|
Math.max(8, width - visibleWidth(indent)),
|
|
529
592
|
);
|
|
530
|
-
out.push(`${
|
|
593
|
+
out.push(`${labelText}${wrapped[0] ?? ""}`);
|
|
531
594
|
for (const extra of wrapped.slice(1)) out.push(indent + extra);
|
|
532
595
|
};
|
|
533
596
|
|
|
@@ -540,33 +603,34 @@ export class VisionModelSelectorComponent implements Component {
|
|
|
540
603
|
);
|
|
541
604
|
};
|
|
542
605
|
|
|
543
|
-
|
|
544
|
-
"Vision-capable
|
|
606
|
+
value(
|
|
607
|
+
label("Vision-capable", "โฆ", "success"),
|
|
545
608
|
this.currentRef
|
|
546
609
|
? this.refLabel(this.currentRef)
|
|
547
|
-
: this.theme.fg("muted", "none โ vision
|
|
610
|
+
: this.theme.fg("muted", "none โ vision watcher disabled"),
|
|
548
611
|
);
|
|
549
612
|
|
|
550
613
|
const chain = this.orderedFallbacks();
|
|
551
|
-
|
|
552
|
-
"Fallback
|
|
614
|
+
value(
|
|
615
|
+
label("Fallback", "โ", "warning"),
|
|
553
616
|
chain.length
|
|
554
617
|
? `${this.theme.fg("success", "on")} - ${chain.map((r) => this.refLabel(r)).join(", ")}`
|
|
555
618
|
: this.theme.fg("muted", "off"),
|
|
556
619
|
);
|
|
557
620
|
|
|
558
|
-
|
|
559
|
-
"Thinking: ",
|
|
621
|
+
value(
|
|
622
|
+
this.theme.fg("dim", " Thinking: "),
|
|
560
623
|
this.thinking
|
|
561
624
|
? this.theme.fg("success", `on (${this.thinkingLevel})`)
|
|
562
625
|
: this.theme.fg("muted", "off"),
|
|
563
626
|
);
|
|
564
627
|
|
|
565
|
-
|
|
566
|
-
"Async pasted-path fallback: ",
|
|
567
|
-
this.
|
|
568
|
-
?
|
|
569
|
-
|
|
628
|
+
value(
|
|
629
|
+
this.theme.fg("dim", " Async pasted-path fallback: "),
|
|
630
|
+
`${this.theme.fg(
|
|
631
|
+
this.asyncClipboardHandoff ? "success" : "muted",
|
|
632
|
+
this.asyncClipboardHandoff ? "on" : "off",
|
|
633
|
+
)}${this.theme.fg("dim", ` ${ASYNC_KEY_HINT}`)}`,
|
|
570
634
|
);
|
|
571
635
|
|
|
572
636
|
// The warning follows the *highlighted* row: it answers "what happens if I
|