@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.
@@ -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 (๐Ÿ‘€ badge)
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+Alt+F toggles the highlighted model in/out of the failover chain (max 3)
10
- * - Ctrl+T walks the thinking ladder, Ctrl+A toggles async paste handoff
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, and the hint shown for it.
38
+ /** Key that toggles failover-chain membership.
38
39
  *
39
- * Deliberately a single control byte nobody else wants: `ctrl+alt+f` never
40
- * survives Windows conhost/Windows Terminal (AltGr handling drops or downgrades
41
- * it), `alt+f` is pi's editor word-right, and `ctrl+f` is pi's find-text. */
42
- const FALLBACK_KEY = Key.ctrl("q");
43
- const FALLBACK_KEY_HINT = "ctrl+q";
44
- const RESET_FALLBACKS_KEY = Key.ctrlShift("q");
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, RESET_FALLBACKS_KEY)) {
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
- if (matchesKey(data, Key.ctrl("a"))) {
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
- if (kb.matches(data, "app.thinking.toggle") || matchesKey(data, Key.ctrl("t"))) {
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. ctrl+a toggles the async clipboard handoff; it
360
- // is deliberately left out so this line stays readable at 80 columns.
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+q] Fallback",
366
- "[Ctrl+Shift+q] Reset",
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", " ๐Ÿ‘€") : this.theme.fg("muted", " ยท");
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+q
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
- const line = (label: string, value: string) => {
525
- const indent = " ".repeat(2 + visibleWidth(label));
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
- value,
590
+ text,
528
591
  Math.max(8, width - visibleWidth(indent)),
529
592
  );
530
- out.push(`${this.theme.fg("dim", ` ${label}`)}${wrapped[0] ?? ""}`);
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
- line(
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 handoff disabled"),
610
+ : this.theme.fg("muted", "none โ€” vision watcher disabled"),
548
611
  );
549
612
 
550
613
  const chain = this.orderedFallbacks();
551
- line(
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
- line(
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
- line(
566
- "Async pasted-path fallback: ",
567
- this.asyncClipboardHandoff
568
- ? this.theme.fg("success", "on")
569
- : this.theme.fg("muted", "off"),
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