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.
- package/LICENSE +21 -0
- package/README.md +341 -0
- package/extensions/voice/config.ts +395 -0
- package/extensions/voice/deepgram.ts +33 -0
- package/extensions/voice/device.ts +382 -0
- package/extensions/voice/hold-to-talk.ts +69 -0
- package/extensions/voice/local.ts +1143 -0
- package/extensions/voice/model-download.ts +636 -0
- package/extensions/voice/onboarding.ts +739 -0
- package/extensions/voice/release-controller.ts +55 -0
- package/extensions/voice/settings-panel.ts +1602 -0
- package/extensions/voice/sherpa-engine.ts +464 -0
- package/extensions/voice/sherpa-loader.ts +143 -0
- package/extensions/voice/sherpa-onnx-node.d.ts +4 -0
- package/extensions/voice/speak.ts +430 -0
- package/extensions/voice/tts-deepgram.ts +454 -0
- package/extensions/voice/tts-engine.ts +653 -0
- package/extensions/voice/tts-install-progress.ts +257 -0
- package/extensions/voice/tts-local-models.ts +1255 -0
- package/extensions/voice/tts-onboarding-overlay.ts +186 -0
- package/extensions/voice/tts-onboarding.ts +87 -0
- package/extensions/voice/tts-playback-indicator.ts +127 -0
- package/extensions/voice/tts-playback.ts +675 -0
- package/extensions/voice/tts-text-filter.ts +404 -0
- package/extensions/voice/ui-aura.ts +272 -0
- package/extensions/voice/ui-help-overlay.ts +161 -0
- package/extensions/voice/ui-icons.ts +124 -0
- package/extensions/voice/ui-locale-labels.ts +110 -0
- package/extensions/voice/ui-picker.ts +209 -0
- package/extensions/voice/ui-render-ticker.ts +171 -0
- package/extensions/voice/ui-widget-base.ts +219 -0
- package/extensions/voice/ui-width.ts +112 -0
- package/extensions/voice.ts +3644 -0
- 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
|
+
}
|