pi-voicekit 0.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.
Files changed (34) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +341 -0
  3. package/extensions/voice/config.ts +395 -0
  4. package/extensions/voice/deepgram.ts +33 -0
  5. package/extensions/voice/device.ts +382 -0
  6. package/extensions/voice/hold-to-talk.ts +69 -0
  7. package/extensions/voice/local.ts +1143 -0
  8. package/extensions/voice/model-download.ts +636 -0
  9. package/extensions/voice/onboarding.ts +739 -0
  10. package/extensions/voice/release-controller.ts +55 -0
  11. package/extensions/voice/settings-panel.ts +1602 -0
  12. package/extensions/voice/sherpa-engine.ts +464 -0
  13. package/extensions/voice/sherpa-loader.ts +143 -0
  14. package/extensions/voice/sherpa-onnx-node.d.ts +4 -0
  15. package/extensions/voice/speak.ts +430 -0
  16. package/extensions/voice/tts-deepgram.ts +454 -0
  17. package/extensions/voice/tts-engine.ts +653 -0
  18. package/extensions/voice/tts-install-progress.ts +257 -0
  19. package/extensions/voice/tts-local-models.ts +1255 -0
  20. package/extensions/voice/tts-onboarding-overlay.ts +186 -0
  21. package/extensions/voice/tts-onboarding.ts +87 -0
  22. package/extensions/voice/tts-playback-indicator.ts +127 -0
  23. package/extensions/voice/tts-playback.ts +675 -0
  24. package/extensions/voice/tts-text-filter.ts +404 -0
  25. package/extensions/voice/ui-aura.ts +272 -0
  26. package/extensions/voice/ui-help-overlay.ts +161 -0
  27. package/extensions/voice/ui-icons.ts +124 -0
  28. package/extensions/voice/ui-locale-labels.ts +110 -0
  29. package/extensions/voice/ui-picker.ts +209 -0
  30. package/extensions/voice/ui-render-ticker.ts +171 -0
  31. package/extensions/voice/ui-widget-base.ts +219 -0
  32. package/extensions/voice/ui-width.ts +112 -0
  33. package/extensions/voice.ts +3644 -0
  34. package/package.json +75 -0
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Keyboard / command reference overlay — §11 of the v7.1 plan.
3
+ *
4
+ * Stacks ABOVE any picker/panel via `ctx.ui.custom()`. Resolves with
5
+ * no result on `[esc]` so the caller can simply discard. Width-tier
6
+ * aware: at <60 cols it falls back to a flat list with no chrome.
7
+ *
8
+ * Hotkey routing (§11):
9
+ * - `?` opens help ONLY when no picker is open / no overlay in
10
+ * front. Inside a picker it's a literal search character.
11
+ * Caller is responsible for context-checked routing.
12
+ * - `F1` always opens help.
13
+ * - `h` is intentionally NOT bound (vim users would trigger it).
14
+ */
15
+
16
+ import { matchesKey, Key } from "@earendil-works/pi-tui";
17
+ import type { Theme } from "@earendil-works/pi-coding-agent";
18
+ import { ICON } from "./ui-icons";
19
+ import { isPanelTooNarrow } from "./ui-width";
20
+
21
+ export interface HelpOverlayDeps {
22
+ readonly theme?: Theme;
23
+ }
24
+
25
+ interface HelpEntry {
26
+ readonly key: string;
27
+ readonly desc: string;
28
+ }
29
+ interface HelpSection {
30
+ readonly heading: string;
31
+ readonly entries: ReadonlyArray<HelpEntry>;
32
+ }
33
+
34
+ const HELP_SECTIONS: ReadonlyArray<HelpSection> = [
35
+ {
36
+ heading: "Settings panel",
37
+ entries: [
38
+ { key: "← →", desc: "switch tab" },
39
+ { key: "↑ ↓", desc: "navigate row (skips group headings)" },
40
+ { key: "↵", desc: "select / activate" },
41
+ { key: "esc", desc: "back to main / close panel" },
42
+ { key: "type", desc: "filter (search)" },
43
+ { key: "bksp", desc: "clear last search char" },
44
+ ],
45
+ },
46
+ {
47
+ heading: "Voice (TTS)",
48
+ entries: [
49
+ { key: "/voice-speak <text>", desc: "speak text out loud" },
50
+ { key: "/voice-speak-test", desc: "speak a sample sentence" },
51
+ { key: "/voice-speak-toggle", desc: "enable / disable TTS" },
52
+ { key: "/voice-speak-models", desc: "open model picker" },
53
+ { key: "/voice-speak-info", desc: "diagnose TTS state" },
54
+ ],
55
+ },
56
+ {
57
+ heading: "Voice (STT)",
58
+ entries: [
59
+ { key: "hold space", desc: "push-to-talk recording" },
60
+ { key: "/voice-toggle", desc: "enable / disable STT" },
61
+ { key: "/voice-settings", desc: "open settings panel" },
62
+ { key: "/voice-models", desc: "browse / install local STT models" },
63
+ ],
64
+ },
65
+ {
66
+ heading: "Active widget controls",
67
+ entries: [
68
+ { key: "esc", desc: `cancel active install (when ${ICON.bulletActive} install widget mounted)` },
69
+ { key: "esc", desc: "stop active playback (when no install in front)" },
70
+ ],
71
+ },
72
+ {
73
+ heading: "Help",
74
+ entries: [
75
+ { key: "F1", desc: "open this help (always)" },
76
+ { key: "/voice-help", desc: "open this help via slash command" },
77
+ { key: "esc / ↵", desc: "close help" },
78
+ ],
79
+ },
80
+ ];
81
+
82
+ /**
83
+ * Pi `Component`-shaped class — pass into `ctx.ui.custom()` directly.
84
+ */
85
+ export class HelpOverlay {
86
+ private readonly deps: HelpOverlayDeps;
87
+ private readonly done: () => void;
88
+ private resolved = false;
89
+
90
+ constructor(deps: HelpOverlayDeps, done: () => void) {
91
+ this.deps = deps;
92
+ this.done = done;
93
+ }
94
+
95
+ render(width: number): string[] {
96
+ if (isPanelTooNarrow(width)) return this.renderNarrow();
97
+
98
+ const t = this.deps.theme;
99
+ const dim = (s: string) => (t ? t.fg("dim", s) : s);
100
+ const accent = (s: string) => (t ? t.fg("accent", s) : s);
101
+ const bold = (s: string) => (t ? t.fg("accent", s) : s);
102
+
103
+ const w = Math.max(60, Math.min(width - 2, 90));
104
+ const lines: string[] = [];
105
+ lines.push(
106
+ ` ${bold("pi-listen")} ${dim(ICON.middot)} ${bold("Help")} ${dim(`${ICON.middot} press [esc] to close`)}`
107
+ );
108
+ lines.push(` ${dim(ICON.boxH.repeat(Math.min(w, 60)))}`);
109
+ for (const sec of HELP_SECTIONS) {
110
+ lines.push("");
111
+ lines.push(` ${accent(sec.heading)}`);
112
+ const keyW = Math.max(...sec.entries.map((e) => e.key.length));
113
+ for (const e of sec.entries) {
114
+ const k = e.key.padEnd(keyW);
115
+ lines.push(` ${accent(k)} ${dim(ICON.middot)} ${dim(e.desc)}`);
116
+ }
117
+ }
118
+ lines.push("");
119
+ lines.push(
120
+ ` ${dim("Note: Hindi (Devanagari) and Arabic voices fall back to romanized labels — see /voice-settings → Speak tab for the full voice list.")}`
121
+ );
122
+ return lines;
123
+ }
124
+
125
+ private renderNarrow(): string[] {
126
+ const t = this.deps.theme;
127
+ const dim = (s: string) => (t ? t.fg("dim", s) : s);
128
+ const accent = (s: string) => (t ? t.fg("accent", s) : s);
129
+ const lines: string[] = [];
130
+ lines.push(` ${accent("pi-listen Help")}`);
131
+ for (const sec of HELP_SECTIONS) {
132
+ lines.push("");
133
+ lines.push(` ${accent(sec.heading)}`);
134
+ for (const e of sec.entries) {
135
+ lines.push(` ${accent(e.key)} ${dim(e.desc)}`);
136
+ }
137
+ }
138
+ lines.push("");
139
+ lines.push(` ${dim("[esc] close")}`);
140
+ return lines;
141
+ }
142
+
143
+ handleInput(data: string): void {
144
+ if (this.resolved) return;
145
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.enter)) {
146
+ this.resolved = true;
147
+ try {
148
+ this.done();
149
+ } catch {
150
+ /* never fail closure */
151
+ }
152
+ return;
153
+ }
154
+ // Any other key (including ? and F1 again) is ignored — overlay
155
+ // is read-only.
156
+ }
157
+
158
+ invalidate(): void {
159
+ /* render is uncached */
160
+ }
161
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Shared glyph table — v7.2 visual polish refresh.
3
+ *
4
+ * Design language synthesized from established minimal-TUI references
5
+ * (Charm Bracelet's lipgloss/bubbletea, charm/gum, lazygit, k9s, fzf,
6
+ * and Apple HIG / Material 3 principles translated to terminal):
7
+ *
8
+ * - Spinner: braille 10-frame rotation (Charm convention) — smoother
9
+ * and more modern than 4-frame quarter-circle.
10
+ * - Borders: rounded `╭─╮│╰╯` for modals/overlays (Material modal feel,
11
+ * softer than sharp), sharp for inline tables.
12
+ * - Progress: 8-step subpixel fill via `▏▎▍▌▋▊▉█` for smooth gradient
13
+ * (Charm/lipgloss progress).
14
+ * - Cursor: thin left bar `│` + accent text on selected row, dim on
15
+ * non-selected (HIG "deference": chrome stays subtle).
16
+ * - Status: colored dot + label (e.g. `● ready`, `○ download`).
17
+ *
18
+ * Hard rule unchanged from v7.1: NO emoji. Every glyph is geometric
19
+ * Unicode (U+2500-25FF + braille U+2800-28FF) or one of the small
20
+ * allowlist marks (✓ ✗ ☐ ☑ • · …).
21
+ *
22
+ * Roles are semantic, not visual — call sites use `ICON.activeMarker`
23
+ * and not the literal `"›"` so a future theme swap is one edit.
24
+ */
25
+ export const ICON = {
26
+ // State / status
27
+ checkOk: "✓",
28
+ checkFail: "✗",
29
+ bulletActive: "●",
30
+ bulletInactive: "○",
31
+ bulletDim: "·",
32
+ checkboxOff: "☐",
33
+ checkboxOn: "☑",
34
+
35
+ // Cursors / selection
36
+ activeMarker: "›",
37
+ chevronRight: "›",
38
+ chevronLeft: "‹",
39
+ cursorBar: "│", // v7.2: thin left bar for selected picker rows
40
+
41
+ // Arrows
42
+ arrowRight: "→",
43
+ arrowLeft: "←",
44
+ arrowUp: "↑",
45
+ arrowDown: "↓",
46
+ doubleArrowRight: "⇒",
47
+
48
+ // v7.2 — Spinner frames. Two profiles, both phase-aligned at 10 Hz:
49
+ // - braille (10 frames): the Charm convention. Smooth, minimal.
50
+ // Each glyph is 1 cell wide, suitable for inline status lines.
51
+ // - arc (4 frames): retained as fallback for terminals without
52
+ // good braille font coverage (e.g. Windows default cmd.exe).
53
+ spinnerFrames: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const,
54
+ spinnerFramesArc: ["◐", "◓", "◑", "◒"] as const,
55
+ // Pulse for "alive but waiting" states (e.g. install paused).
56
+ pulseFrames: ["·", "∙", "●", "∙"] as const,
57
+
58
+ // Progress bar
59
+ barFilled: "█",
60
+ barEmpty: "░",
61
+ barPartial: ["▏", "▎", "▍", "▌", "▋", "▊", "▉"] as const, // 1/8 .. 7/8
62
+ // v7.2: thin-line progress for compact widgets — modern alternative
63
+ // to the chunky █░ block bar. Pair with a leading-edge cap for
64
+ // "moving" feel during indeterminate progress.
65
+ barThinFilled: "━",
66
+ barThinEmpty: "─",
67
+ barThinCap: "╾", // leading-edge cap for moving progress
68
+
69
+ // Box drawing (U+2500-257F) for borders / separators
70
+ boxH: "─",
71
+ boxV: "│",
72
+ boxTL: "┌",
73
+ boxTR: "┐",
74
+ boxBL: "└",
75
+ boxBR: "┘",
76
+ boxTeeL: "├",
77
+ boxTeeR: "┤",
78
+ boxTeeT: "┬",
79
+ boxTeeB: "┴",
80
+ boxCross: "┼",
81
+
82
+ // v7.2 — Rounded corners for modal/overlay chrome. Softer "pill"
83
+ // feel that matches Apple HIG sheet/modal aesthetics.
84
+ boxRoundedTL: "╭",
85
+ boxRoundedTR: "╮",
86
+ boxRoundedBL: "╰",
87
+ boxRoundedBR: "╯",
88
+
89
+ // Heavy / double for emphasis
90
+ boxHHeavy: "━",
91
+ boxVHeavy: "┃",
92
+ boxHDouble: "═",
93
+ boxVDouble: "║",
94
+
95
+ // Section dividers
96
+ bullet: "•",
97
+ middot: "·",
98
+ ellipsis: "…",
99
+ } as const;
100
+
101
+ /** Semantic icon role — pick one in widget code, never hardcode the glyph. */
102
+ export type IconRole = keyof typeof ICON;
103
+
104
+ /**
105
+ * Return spinner frame for tick `t` (any non-negative integer). Rotates
106
+ * through `ICON.spinnerFrames` in order. Used by every animated widget
107
+ * driven by §2's RenderTicker, so all spinners stay in phase per frame.
108
+ *
109
+ * v7.2: defaults to the 10-frame braille rotation (Charm convention).
110
+ * Pass `arc` for the legacy 4-frame quarter-circle if a terminal has
111
+ * limited braille font coverage.
112
+ */
113
+ export function spinnerFrame(t: number, profile: "braille" | "arc" = "braille"): string {
114
+ const frames = profile === "arc" ? ICON.spinnerFramesArc : ICON.spinnerFrames;
115
+ return frames[((t % frames.length) + frames.length) % frames.length];
116
+ }
117
+
118
+ /** Pulse frame — for "alive but waiting" states (no rotation, breathing). */
119
+ export function pulseFrame(t: number): string {
120
+ const frames = ICON.pulseFrames;
121
+ // Half-speed pulse: each frame holds for 2 ticks.
122
+ const idx = Math.floor(t / 2) % frames.length;
123
+ return frames[idx]!;
124
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Native-script + gender labels for the Voice picker (§8 of v7.1 plan).
3
+ *
4
+ * Hand-curated for the languages pi-listen ships voices for, using the
5
+ * BCP-47 base-language tag (not script/region) as the lookup key. Three
6
+ * intentional omissions:
7
+ *
8
+ * - `ar` (Arabic): RTL embedding inside fixed-width LTR terminal
9
+ * columns breaks cursor positioning on enough terminals (especially
10
+ * stripped-down SSH PTYs) to make it a stability hazard. Arabic
11
+ * voices fall back to the romanized label.
12
+ * - `hi` (Hindi): Devanagari uses combining marks and zero-width
13
+ * vowels. Without a real grapheme segmenter (which would violate
14
+ * the zero-dependency rule), `visualWidth` would overcount their
15
+ * width and break right-aligned columns. Hindi voices fall back to
16
+ * romanized labels.
17
+ * - Anything else not in this table — fall back to English
18
+ * `(Language · M/F)` via `formatRomanizedLabel()`.
19
+ *
20
+ * Gender-word translations come from the dictionary forms used in
21
+ * mainstream OS locale pickers (macOS Languages & Region, Windows
22
+ * Settings → Language). They're labels, not full phrases — terse on
23
+ * purpose so they fit in a narrow voice-picker column.
24
+ */
25
+
26
+ export interface LocaleLabel {
27
+ /** Native-script language name (e.g. 中文, 日本語, 한국어). */
28
+ readonly nativeName: string;
29
+ /** Native masculine gender word, when available. */
30
+ readonly masc?: string;
31
+ /** Native feminine gender word, when available. */
32
+ readonly fem?: string;
33
+ }
34
+
35
+ /**
36
+ * Keyed by BCP-47 base language. Look up via `localeLabel(baseLang)`
37
+ * — DO NOT index this map directly; the helper handles missing-key
38
+ * fallback uniformly. Twelve scripts intentional; ar/hi intentionally
39
+ * omitted (see file header).
40
+ */
41
+ const LOCALE_LABELS: Record<string, LocaleLabel> = {
42
+ en: { nativeName: "English", masc: "Male", fem: "Female" },
43
+ zh: { nativeName: "中文", masc: "男声", fem: "女声" },
44
+ ja: { nativeName: "日本語", masc: "男性", fem: "女性" },
45
+ ko: { nativeName: "한국어", masc: "남성", fem: "여성" },
46
+ es: { nativeName: "Español", masc: "Masculino", fem: "Femenino" },
47
+ fr: { nativeName: "Français", masc: "Masculin", fem: "Féminin" },
48
+ de: { nativeName: "Deutsch", masc: "Männlich", fem: "Weiblich" },
49
+ it: { nativeName: "Italiano", masc: "Maschile", fem: "Femminile" },
50
+ pt: { nativeName: "Português", masc: "Masculino", fem: "Feminino" },
51
+ ru: { nativeName: "Русский", masc: "Мужской", fem: "Женский" },
52
+ nl: { nativeName: "Nederlands", masc: "Mannelijk", fem: "Vrouwelijk" },
53
+ tr: { nativeName: "Türkçe", masc: "Erkek", fem: "Kadın" },
54
+ };
55
+
56
+ /**
57
+ * Look up native label by BCP-47 base language tag. Pass the base only
58
+ * (e.g. "zh", not "zh-Hant-TW") — variants share native names. Returns
59
+ * `null` for omitted scripts (ar, hi) and unknown tags so the caller can
60
+ * fall back to romanized via `formatRomanizedLabel()`.
61
+ */
62
+ export function localeLabel(baseLang: string): LocaleLabel | null {
63
+ const key = baseLang.toLowerCase();
64
+ return LOCALE_LABELS[key] ?? null;
65
+ }
66
+
67
+ /** English-language base-name map for romanized fallback labels. */
68
+ const ROMANIZED_NAMES: Record<string, string> = {
69
+ en: "English",
70
+ zh: "Chinese",
71
+ ja: "Japanese",
72
+ ko: "Korean",
73
+ es: "Spanish",
74
+ fr: "French",
75
+ de: "German",
76
+ it: "Italian",
77
+ pt: "Portuguese",
78
+ ru: "Russian",
79
+ nl: "Dutch",
80
+ tr: "Turkish",
81
+ ar: "Arabic",
82
+ hi: "Hindi",
83
+ pl: "Polish",
84
+ sv: "Swedish",
85
+ da: "Danish",
86
+ no: "Norwegian",
87
+ fi: "Finnish",
88
+ cs: "Czech",
89
+ uk: "Ukrainian",
90
+ hu: "Hungarian",
91
+ ro: "Romanian",
92
+ el: "Greek",
93
+ he: "Hebrew",
94
+ vi: "Vietnamese",
95
+ th: "Thai",
96
+ id: "Indonesian",
97
+ };
98
+
99
+ /**
100
+ * Romanized fallback label "<Language> · M" or "<Language> · F" — used
101
+ * for ar, hi, and any unknown language tag. Gender is rendered with the
102
+ * same single-letter abbreviation everywhere, so the label width is
103
+ * predictable across all fallback rows.
104
+ */
105
+ export function formatRomanizedLabel(baseLang: string, gender: "M" | "F" | undefined): string {
106
+ const key = baseLang.toLowerCase();
107
+ const name = ROMANIZED_NAMES[key] ?? baseLang.toUpperCase();
108
+ if (!gender) return name;
109
+ return `${name} · ${gender}`;
110
+ }
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Shared picker chassis (§3 of v7.1 plan).
3
+ *
4
+ * Three pickers (Language / Models / Voice) currently duplicate the same
5
+ * state machine in `settings-panel.ts`. This chassis encapsulates the
6
+ * common behavior: heading-aware navigation, search filtering with empty
7
+ * state, cursor preservation across filter changes, and a width-tier
8
+ * compact mode. Pickers feed it data; the chassis owns navigation.
9
+ *
10
+ * Contract (§3):
11
+ * 1. Headings are non-selectable. ↑↓ skip them.
12
+ * 2. Search filtering also skips headings: a heading appears iff at
13
+ * least one row under it matches; otherwise drop the entire group.
14
+ * 3. Cursor restoration: when the search query changes, the cursor
15
+ * moves to the first selectable row of the new view. When the
16
+ * search clears, the cursor returns to the previously-active row
17
+ * if visible, else first selectable.
18
+ * 4. Empty state: render-aware — `getViewModel()` returns
19
+ * `{ kind: "empty" }` when no data rows match.
20
+ * 5. Page bounds: cursor wraps top-to-bottom only across selectable rows.
21
+ * 6. Width fallback: when compact mode is true, headings are dropped
22
+ * from the view (the picker may still show group separators
23
+ * elsewhere) so narrow terminals see only the data rows.
24
+ */
25
+
26
+ /** A row in a picker — either a heading (non-selectable) or a data row. */
27
+ export type PickerRow<T> =
28
+ | { readonly kind: "heading"; readonly label: string }
29
+ | { readonly kind: "data"; readonly value: T; readonly searchKey: string };
30
+
31
+ /** Output passed to the renderer. Either an empty result or a viewport slice. */
32
+ export type PickerView<T> =
33
+ | { readonly kind: "empty"; readonly query: string }
34
+ | {
35
+ readonly kind: "list";
36
+ readonly rows: ReadonlyArray<PickerRow<T>>;
37
+ readonly viewportStart: number;
38
+ readonly viewportEnd: number;
39
+ readonly totalSelectable: number;
40
+ readonly selectedIndex: number;
41
+ /** Index INTO `rows` of the currently-selected data row (≥0). */
42
+ readonly cursorRowIndex: number;
43
+ };
44
+
45
+ /** Per-call render input. */
46
+ export interface PickerRenderInput {
47
+ /** Visible row budget for the body — usually 12 in 24-line overlays. */
48
+ readonly maxVisible: number;
49
+ /** True for narrow-terminal compact mode (drops headings from view). */
50
+ readonly compact: boolean;
51
+ }
52
+
53
+ /**
54
+ * State machine. Pickers create one of these per open session and call:
55
+ * - `setRows(rows)` whenever the data source changes (e.g. after install)
56
+ * - `setSearch(q)` on every keystroke
57
+ * - `moveUp()` / `moveDown()` for navigation
58
+ * - `selected()` to read the currently-highlighted data value
59
+ * - `view(input)` to compute the renderable slice
60
+ *
61
+ * The chassis owns: search query, cursor index (over selectable rows
62
+ * only), viewport scrolling.
63
+ */
64
+ export class PickerChassis<T> {
65
+ private rows: ReadonlyArray<PickerRow<T>> = [];
66
+ private query = "";
67
+ /** Cursor index INTO the filtered selectable subset. */
68
+ private cursor = 0;
69
+ /** Sticky pre-search cursor — used to restore on search clear. */
70
+ private preSearchValue: T | null = null;
71
+
72
+ setRows(rows: ReadonlyArray<PickerRow<T>>): void {
73
+ this.rows = rows;
74
+ this.clampCursor();
75
+ }
76
+
77
+ getQuery(): string {
78
+ return this.query;
79
+ }
80
+
81
+ setSearch(q: string): void {
82
+ const wasEmpty = this.query.length === 0;
83
+ const isEmpty = q.length === 0;
84
+ if (wasEmpty && !isEmpty) {
85
+ // Entering search — remember current selection so we can
86
+ // restore on clear.
87
+ this.preSearchValue = this.selected();
88
+ }
89
+ this.query = q;
90
+ this.cursor = 0; // every search change resets to first match
91
+ if (isEmpty && this.preSearchValue != null) {
92
+ // Restore cursor to previously-active value if still present.
93
+ const filtered = this.filteredSelectable();
94
+ const idx = filtered.findIndex((r) => r.value === this.preSearchValue);
95
+ if (idx >= 0) this.cursor = idx;
96
+ this.preSearchValue = null;
97
+ }
98
+ }
99
+
100
+ appendSearchChar(ch: string): void {
101
+ this.setSearch(this.query + ch);
102
+ }
103
+
104
+ backspaceSearch(): void {
105
+ if (this.query.length === 0) return;
106
+ this.setSearch(this.query.slice(0, -1));
107
+ }
108
+
109
+ clearSearch(): void {
110
+ this.setSearch("");
111
+ }
112
+
113
+ moveUp(): void {
114
+ const n = this.filteredSelectable().length;
115
+ if (n === 0) return;
116
+ this.cursor = this.cursor === 0 ? n - 1 : this.cursor - 1;
117
+ }
118
+
119
+ moveDown(): void {
120
+ const n = this.filteredSelectable().length;
121
+ if (n === 0) return;
122
+ this.cursor = this.cursor === n - 1 ? 0 : this.cursor + 1;
123
+ }
124
+
125
+ /** Move cursor to the first selectable whose `value === target`. */
126
+ selectValue(target: T): void {
127
+ const filtered = this.filteredSelectable();
128
+ const idx = filtered.findIndex((r) => r.value === target);
129
+ if (idx >= 0) this.cursor = idx;
130
+ }
131
+
132
+ /** Currently-highlighted data value, or null when no rows match. */
133
+ selected(): T | null {
134
+ const filtered = this.filteredSelectable();
135
+ return filtered[this.cursor]?.value ?? null;
136
+ }
137
+
138
+ /** Compute the renderable view: heading-aware, viewport-windowed. */
139
+ view(input: PickerRenderInput): PickerView<T> {
140
+ const filteredAll = this.filteredView(input.compact);
141
+ const selectable = filteredAll.filter((r): r is { kind: "data"; value: T; searchKey: string } => r.kind === "data");
142
+ if (selectable.length === 0) {
143
+ return { kind: "empty", query: this.query };
144
+ }
145
+ const sel = Math.min(this.cursor, selectable.length - 1);
146
+ const selectedValue = selectable[sel]!.value;
147
+ const cursorRowIndex = filteredAll.findIndex((r) => r.kind === "data" && r.value === selectedValue);
148
+
149
+ // Center the viewport on the cursor.
150
+ const total = filteredAll.length;
151
+ let start = Math.max(0, cursorRowIndex - Math.floor(input.maxVisible / 2));
152
+ let end = Math.min(start + input.maxVisible, total);
153
+ if (end - start < input.maxVisible) start = Math.max(0, end - input.maxVisible);
154
+
155
+ return {
156
+ kind: "list",
157
+ rows: filteredAll.slice(start, end),
158
+ viewportStart: start,
159
+ viewportEnd: end,
160
+ totalSelectable: selectable.length,
161
+ selectedIndex: sel,
162
+ cursorRowIndex,
163
+ };
164
+ }
165
+
166
+ private clampCursor(): void {
167
+ const n = this.filteredSelectable().length;
168
+ if (n === 0) this.cursor = 0;
169
+ else if (this.cursor >= n) this.cursor = n - 1;
170
+ }
171
+
172
+ private rowMatches(r: PickerRow<T>): boolean {
173
+ if (r.kind === "heading") return false;
174
+ if (this.query.length === 0) return true;
175
+ return r.searchKey.toLowerCase().includes(this.query.toLowerCase());
176
+ }
177
+
178
+ /** Filtered subset, headings retained when at least one child row matches. */
179
+ private filteredView(compact: boolean): ReadonlyArray<PickerRow<T>> {
180
+ const out: PickerRow<T>[] = [];
181
+ // Walk groups: stash the most recent heading; emit it inline at
182
+ // first matching child, then clear so subsequent matches in the
183
+ // same group don't re-emit. In compact mode, never emit
184
+ // headings (narrow terminals get a flat list).
185
+ let pendingHeading: PickerRow<T> | null = null;
186
+ for (const r of this.rows) {
187
+ if (r.kind === "heading") {
188
+ pendingHeading = r;
189
+ continue;
190
+ }
191
+ if (this.rowMatches(r)) {
192
+ if (pendingHeading && !compact) {
193
+ out.push(pendingHeading);
194
+ }
195
+ pendingHeading = null;
196
+ out.push(r);
197
+ }
198
+ }
199
+ return out;
200
+ }
201
+
202
+ private filteredSelectable(): ReadonlyArray<{ kind: "data"; value: T; searchKey: string }> {
203
+ const out: { kind: "data"; value: T; searchKey: string }[] = [];
204
+ for (const r of this.rows) {
205
+ if (r.kind === "data" && this.rowMatches(r)) out.push(r);
206
+ }
207
+ return out;
208
+ }
209
+ }