@juicesharp/rpiv-voice 1.4.2

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 (53) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/LICENSE +21 -0
  3. package/README.md +116 -0
  4. package/audio/error-log.ts +37 -0
  5. package/audio/hallucination-filter.ts +71 -0
  6. package/audio/mic-source.ts +38 -0
  7. package/audio/model-download.ts +268 -0
  8. package/audio/pcm.ts +45 -0
  9. package/audio/sherpa-onnx-node.d.ts +55 -0
  10. package/audio/stt-engine.ts +117 -0
  11. package/command/pipeline-runner.ts +238 -0
  12. package/command/splash-runner.ts +72 -0
  13. package/command/voice-command.ts +251 -0
  14. package/config/voice-config.ts +80 -0
  15. package/docs/cover.png +0 -0
  16. package/docs/cover.svg +173 -0
  17. package/docs/equalizer.svg +86 -0
  18. package/docs/overlay.jpg +0 -0
  19. package/docs/overlay.png +0 -0
  20. package/docs/vertical-cover.png +0 -0
  21. package/docs/vertical-cover.svg +239 -0
  22. package/index.ts +66 -0
  23. package/locales/de.json +39 -0
  24. package/locales/en.json +42 -0
  25. package/locales/es.json +39 -0
  26. package/locales/fr.json +39 -0
  27. package/locales/pt-BR.json +39 -0
  28. package/locales/pt.json +39 -0
  29. package/locales/ru.json +39 -0
  30. package/locales/uk.json +39 -0
  31. package/package.json +94 -0
  32. package/state/i18n-bridge.ts +51 -0
  33. package/state/key-router.ts +46 -0
  34. package/state/screen-intent.ts +27 -0
  35. package/state/selectors/contract.ts +13 -0
  36. package/state/selectors/derivations.ts +9 -0
  37. package/state/selectors/focus.ts +6 -0
  38. package/state/selectors/projections.ts +112 -0
  39. package/state/state-reducer.ts +197 -0
  40. package/state/state.ts +48 -0
  41. package/state/status-intent.ts +23 -0
  42. package/state/voice-session.ts +176 -0
  43. package/view/component-binding.ts +24 -0
  44. package/view/components/equalizer-view.ts +237 -0
  45. package/view/components/settings-field-view.ts +77 -0
  46. package/view/components/settings-form-view.ts +26 -0
  47. package/view/components/splash-view.ts +98 -0
  48. package/view/components/status-bar-view.ts +112 -0
  49. package/view/components/transcript-view.ts +50 -0
  50. package/view/overlay-view.ts +82 -0
  51. package/view/props-adapter.ts +29 -0
  52. package/view/screen-content-strategy.ts +58 -0
  53. package/view/stateful-view.ts +7 -0
@@ -0,0 +1,176 @@
1
+ import { DynamicBorder, type Theme } from "@earendil-works/pi-coding-agent";
2
+ import { getKeybindings } from "@earendil-works/pi-tui";
3
+ import type { VoiceConfig } from "../config/voice-config.js";
4
+ import { saveVoiceConfig } from "../config/voice-config.js";
5
+ import { globalBinding } from "../view/component-binding.js";
6
+ import { EqualizerView } from "../view/components/equalizer-view.js";
7
+ import { SettingsFieldView } from "../view/components/settings-field-view.js";
8
+ import { SettingsFormView } from "../view/components/settings-form-view.js";
9
+ import { StatusBarView } from "../view/components/status-bar-view.js";
10
+ import { TranscriptView } from "../view/components/transcript-view.js";
11
+ import { OverlayView } from "../view/overlay-view.js";
12
+ import { VoiceOverlayPropsAdapter } from "../view/props-adapter.js";
13
+ import { DictationScreenStrategy, SettingsScreenStrategy } from "../view/screen-content-strategy.js";
14
+ import { routeKey, type VoiceAction } from "./key-router.js";
15
+ import {
16
+ selectEqualizerFieldProps,
17
+ selectEqualizerProps,
18
+ selectHallucinationFilterFieldProps,
19
+ selectLanguageReadonlyFieldProps,
20
+ selectMicReadonlyFieldProps,
21
+ selectStatusBarProps,
22
+ selectTranscriptProps,
23
+ } from "./selectors/projections.js";
24
+ import { initialVoiceState, type VoiceState } from "./state.js";
25
+ import { type ApplyContext, draftFromConfig, type Effect, reduce, type VoiceResult } from "./state-reducer.js";
26
+
27
+ export interface VoiceSessionDeps {
28
+ pasteToEditor: (text: string) => void;
29
+ notify: (message: string, level: "error" | "info") => void;
30
+ abort: () => void;
31
+ stopMic: () => void;
32
+ setPipelinePaused: (paused: boolean) => void;
33
+ setHallucinationFilterEnabled: (enabled: boolean) => void;
34
+ }
35
+
36
+ export interface VoiceSessionConfig {
37
+ tui: { terminal: { columns: number; rows?: number }; requestRender(): void };
38
+ theme: Theme;
39
+ persistedConfig: VoiceConfig;
40
+ deps: VoiceSessionDeps;
41
+ done: (result: VoiceResult) => void;
42
+ }
43
+
44
+ export interface VoiceSessionComponent {
45
+ render(width: number): string[];
46
+ invalidate(): void;
47
+ handleInput(data: string): void;
48
+ }
49
+
50
+ export class VoiceSession {
51
+ private state: VoiceState;
52
+ private readonly persistedConfig: VoiceConfig;
53
+ private readonly adapter: VoiceOverlayPropsAdapter;
54
+ private readonly overlay: OverlayView;
55
+ private readonly tui: VoiceSessionConfig["tui"];
56
+ private readonly deps: VoiceSessionDeps;
57
+ private readonly done: (result: VoiceResult) => void;
58
+ private readonly statusBar: StatusBarView;
59
+
60
+ readonly component: VoiceSessionComponent;
61
+
62
+ constructor(config: VoiceSessionConfig) {
63
+ this.tui = config.tui;
64
+ this.deps = config.deps;
65
+ this.done = config.done;
66
+ this.persistedConfig = config.persistedConfig;
67
+ this.state = initialVoiceState(draftFromConfig(config.persistedConfig));
68
+
69
+ const transcript = new TranscriptView(config.theme);
70
+ const divider = new DynamicBorder((s) => config.theme.fg("accent", s));
71
+ const equalizer = new EqualizerView(config.theme);
72
+ const statusBar = new StatusBarView(config.theme);
73
+ this.statusBar = statusBar;
74
+ const micField = new SettingsFieldView(config.theme);
75
+ const languageField = new SettingsFieldView(config.theme);
76
+ const hallucinationField = new SettingsFieldView(config.theme);
77
+ const equalizerField = new SettingsFieldView(config.theme);
78
+ const settingsForm = new SettingsFormView({
79
+ fields: [micField, languageField, hallucinationField, equalizerField],
80
+ });
81
+
82
+ const dictation = new DictationScreenStrategy({ transcript, divider, equalizer, statusBar });
83
+ const settings = new SettingsScreenStrategy({ settingsForm, divider, equalizer, statusBar });
84
+ this.overlay = new OverlayView({
85
+ tui: config.tui,
86
+ dictation,
87
+ settings,
88
+ });
89
+
90
+ // Predicates are intentionally absent: OverlayView pre-renders the inactive
91
+ // strategy on every tick to compute the height-pad target, so every
92
+ // component must hold fresh props regardless of which screen is visible.
93
+ const bindings = [
94
+ globalBinding({ component: statusBar, select: selectStatusBarProps }),
95
+ globalBinding({ component: transcript, select: selectTranscriptProps }),
96
+ globalBinding({ component: equalizer, select: selectEqualizerProps }),
97
+ globalBinding({ component: micField, select: selectMicReadonlyFieldProps }),
98
+ globalBinding({ component: languageField, select: selectLanguageReadonlyFieldProps }),
99
+ globalBinding({ component: hallucinationField, select: selectHallucinationFilterFieldProps }),
100
+ globalBinding({ component: equalizerField, select: selectEqualizerFieldProps }),
101
+ globalBinding({ component: this.overlay, select: (state) => ({ state }) }),
102
+ ];
103
+
104
+ this.adapter = new VoiceOverlayPropsAdapter({ tui: config.tui, bindings });
105
+
106
+ this.component = {
107
+ render: (w) => this.overlay.render(w),
108
+ invalidate: () => this.adapter.invalidate(),
109
+ handleInput: (data) => this.dispatch(data),
110
+ };
111
+
112
+ this.adapter.apply(this.state);
113
+ }
114
+
115
+ dispatchAction(action: VoiceAction): void {
116
+ this.commit(action);
117
+ }
118
+
119
+ tickPulse(): void {
120
+ this.statusBar.tickPulse();
121
+ this.tui.requestRender();
122
+ }
123
+
124
+ dispatch(data: string): void {
125
+ const action = routeKey(data, this.state, this.runtime());
126
+ if (action.kind === "ignore") return;
127
+ this.commit(action);
128
+ }
129
+
130
+ private commit(action: VoiceAction): void {
131
+ const result = reduce(this.state, action, this.applyContext());
132
+ this.state = result.state;
133
+ for (const e of result.effects) this.runEffect(e);
134
+ this.adapter.apply(this.state);
135
+ }
136
+
137
+ private runEffect(effect: Effect): void {
138
+ switch (effect.kind) {
139
+ case "request_render":
140
+ this.tui.requestRender();
141
+ return;
142
+ case "paste_to_editor":
143
+ this.deps.pasteToEditor(effect.text);
144
+ return;
145
+ case "notify":
146
+ this.deps.notify(effect.message, effect.level);
147
+ return;
148
+ case "abort_session":
149
+ this.deps.abort();
150
+ return;
151
+ case "stop_mic":
152
+ this.deps.stopMic();
153
+ return;
154
+ case "set_pipeline_paused":
155
+ this.deps.setPipelinePaused(effect.paused);
156
+ return;
157
+ case "set_hallucination_filter":
158
+ this.deps.setHallucinationFilterEnabled(effect.enabled);
159
+ return;
160
+ case "save_config":
161
+ saveVoiceConfig(effect.config);
162
+ return;
163
+ case "done":
164
+ this.done(effect.result);
165
+ return;
166
+ }
167
+ }
168
+
169
+ private runtime() {
170
+ return { keybindings: getKeybindings() };
171
+ }
172
+
173
+ private applyContext(): ApplyContext {
174
+ return { persistedConfig: this.persistedConfig };
175
+ }
176
+ }
@@ -0,0 +1,24 @@
1
+ import type { BindingContext, GlobalSelector } from "../state/selectors/contract.js";
2
+ import type { VoiceState } from "../state/state.js";
3
+ import type { StatefulView } from "./stateful-view.js";
4
+
5
+ export interface ComponentBinding<P> {
6
+ readonly component: StatefulView<P>;
7
+ readonly select: GlobalSelector<P>;
8
+ readonly predicate?: (state: VoiceState, ctx: BindingContext) => boolean;
9
+ }
10
+
11
+ export interface BoundGlobalBinding {
12
+ apply(state: VoiceState, ctx: BindingContext): void;
13
+ invalidate(): void;
14
+ }
15
+
16
+ export function globalBinding<P>(spec: ComponentBinding<P>): BoundGlobalBinding {
17
+ return {
18
+ apply: (state, ctx) => {
19
+ if (spec.predicate && !spec.predicate(state, ctx)) return;
20
+ spec.component.setProps(spec.select(state, ctx));
21
+ },
22
+ invalidate: () => spec.component.invalidate(),
23
+ };
24
+ }
@@ -0,0 +1,237 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import type { RecordingStatus } from "../../state/state.js";
3
+ import type { StatefulView } from "../stateful-view.js";
4
+
5
+ const COLOR_ACCENT = "accent";
6
+ const COLOR_MUTED = "muted";
7
+ const COLOR_DIM = "dim";
8
+
9
+ // Lower-block glyph ladder: each cell fills from its baseline upward in
10
+ // eighths. Used for BOTH cell rows now — the bottom row fills first, the top
11
+ // row continues filling above it, giving us 16 distinct amplitude steps
12
+ // (vs. 8 in the old mirrored design) for the same two-row chrome height.
13
+ const BAR_GLYPHS = [" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"] as const;
14
+ const SUB_LEVELS_PER_ROW = BAR_GLYPHS.length - 1; // 8 — the highest non-empty index
15
+ const MAX_AMP = SUB_LEVELS_PER_ROW * 2; // 16 sub-levels stacked across two rows
16
+
17
+ // Perceptual gain on the live RMS reading. RMS for normal speech sits around
18
+ // 0.02–0.15; sqrt(level * 5) reaches roughly 0.7 at quiet talking volume and
19
+ // saturates on loud peaks.
20
+ const PERCEPTUAL_GAIN = 5;
21
+
22
+ // Single-pole low-pass on `level`. Half-life ~25 ms at 10 Hz updates — fast
23
+ // enough that loud peaks visibly punch through, slow enough to suppress the
24
+ // glyph-quantization shimmer.
25
+ const SMOOTHING = 0.3;
26
+
27
+ // Trapezoid envelope: flat plateau across the middle PLATEAU_HALF_WIDTH × 2 of
28
+ // the row, linear fade to zero over FADE_WIDTH on each side, hard zero past
29
+ // that. This is the cover-art silhouette: confident centred bell with tails
30
+ // that go fully blank, so the equalizer never reads as filling the whole row.
31
+ const PLATEAU_HALF_WIDTH = 0.4;
32
+ const FADE_WIDTH = 0.08;
33
+
34
+ // Per-column noise floor on the static envelope. Keeps adjacent bars at
35
+ // different heights even when held at peak so the silhouette has texture.
36
+ const SHAPE_FLOOR = 0.15;
37
+
38
+ // Per-tick fall range. Each column draws a deterministic decay rate from
39
+ // [FALL_MIN, FALL_MAX] — that's what makes adjacent bars fall back at
40
+ // different speeds after a loud onset, so the lattice dances instead of
41
+ // pulsing in lockstep. Range is tuned to the canonical voice-meter release
42
+ // window: at 10 Hz update rate, FALL_MIN=0.10 ⇒ ~1.0 s tail, FALL_MAX=0.22 ⇒
43
+ // ~450 ms. Audio-engineering literature (IEC 60268-17 / BS 6840 PPM) puts the
44
+ // "feels alive on speech" sweet spot at 300–500 ms release, faster than VU's
45
+ // 300 ms symmetric and slower than PPM's 1.5 s decay-to-−20 dB.
46
+ const FALL_MIN = 0.1;
47
+ const FALL_MAX = 0.22;
48
+
49
+ // Peak-hold latency: every new local maximum latches the column at its peak
50
+ // height for HOLD_TICKS frames before the per-column gravity kicks in. At
51
+ // 10 Hz tick that is ~600 ms — the recognisable "memory of the loudest recent
52
+ // moment" you see in Winamp / classic VU plug-ins. We can't dedicate a
53
+ // separate cap row in two-row chrome, so the ballistic lives inside the bar
54
+ // itself: the bar freezes briefly at its peak, then resumes its asymmetric
55
+ // release.
56
+ const HOLD_TICKS = 6;
57
+
58
+ interface ColumnTuning {
59
+ envelope: number;
60
+ fall: number;
61
+ }
62
+
63
+ export interface EqualizerViewProps {
64
+ level: number;
65
+ status: RecordingStatus;
66
+ enabled: boolean;
67
+ }
68
+
69
+ export class EqualizerView implements StatefulView<EqualizerViewProps> {
70
+ private props: EqualizerViewProps = { level: 0, status: "recording", enabled: false };
71
+ private smoothedLevel = 0;
72
+ private tuning: ColumnTuning[] = [];
73
+ private level: Float64Array = new Float64Array(0);
74
+ // Ticks remaining in each column's peak-hold latch. Reset to HOLD_TICKS
75
+ // every time level[i] rises to a new local max; counts down on every
76
+ // non-rise tick. While > 0 the column's gravity is suppressed.
77
+ private holdLeft: Uint8Array = new Uint8Array(0);
78
+ // Each setProps tick that should advance the lattice queues one step here;
79
+ // render() consumes them. Decoupling keeps render() idempotent for the same
80
+ // audio frame even if the TUI calls it more than once.
81
+ private pendingTicks = 0;
82
+
83
+ constructor(private readonly theme: Theme) {}
84
+
85
+ setProps(props: EqualizerViewProps): void {
86
+ // Live RMS only feeds the smoother + lattice while recording AND enabled.
87
+ // Pausing freezes the silhouette at its last shape (the dim colour carries
88
+ // the paused state). Disabling drops to zero rows from render() so the
89
+ // dictation pane reclaims the space.
90
+ if (props.enabled && props.status === "recording") {
91
+ this.smoothedLevel = (1 - SMOOTHING) * this.smoothedLevel + SMOOTHING * props.level;
92
+ this.pendingTicks += 1;
93
+ }
94
+ this.props = props;
95
+ }
96
+
97
+ handleInput(_data: string): void {}
98
+
99
+ invalidate(): void {}
100
+
101
+ render(width: number): string[] {
102
+ if (!this.props.enabled) return [];
103
+ if (width <= 0) return ["", ""];
104
+
105
+ if (this.tuning.length !== width) {
106
+ this.tuning = buildTuning(width);
107
+ this.level = new Float64Array(width);
108
+ this.holdLeft = new Uint8Array(width);
109
+ }
110
+
111
+ const recording = this.props.status === "recording";
112
+ const gain = recording ? Math.min(1, Math.sqrt(this.smoothedLevel * PERCEPTUAL_GAIN)) : 0;
113
+ // Drain queued ticks; while paused pendingTicks is always 0, so the level
114
+ // array holds whatever shape it had at the moment of pause.
115
+ for (let n = 0; n < this.pendingTicks; n++) advanceLevels(this.level, this.holdLeft, this.tuning, gain);
116
+ this.pendingTicks = 0;
117
+
118
+ const amps = new Uint8Array(width);
119
+ let topRow = "";
120
+ let botRow = "";
121
+ for (let i = 0; i < width; i++) {
122
+ const amp = quantize(this.level[i] ?? 0);
123
+ amps[i] = amp;
124
+ // Bottom row holds the first SUB_LEVELS_PER_ROW eighths; once it's
125
+ // saturated, the top row picks up the rest. Both rows draw from the
126
+ // same lower-block ladder so the bar visibly grows from the bottom.
127
+ const botIdx = amp >= SUB_LEVELS_PER_ROW ? SUB_LEVELS_PER_ROW : amp;
128
+ const topIdx = amp > SUB_LEVELS_PER_ROW ? amp - SUB_LEVELS_PER_ROW : 0;
129
+ botRow += BAR_GLYPHS[botIdx]!;
130
+ topRow += BAR_GLYPHS[topIdx]!;
131
+ }
132
+
133
+ return [this.paint(topRow, amps, recording), this.paint(botRow, amps, recording)];
134
+ }
135
+
136
+ // Run-length encode the row into theme.fg() segments — one segment per
137
+ // contiguous run of cells that share the same shade tier. Paused state
138
+ // collapses to a single dim segment, matching the "frozen, gone quiet"
139
+ // semantic the rest of the overlay uses.
140
+ private paint(row: string, amps: Uint8Array, recording: boolean): string {
141
+ if (!recording) return this.theme.fg(COLOR_DIM, row);
142
+ const width = row.length;
143
+ if (width === 0) return "";
144
+ const cells = [...row];
145
+ let out = "";
146
+ let runStart = 0;
147
+ let currentShade = pickShade(amps[0] ?? 0);
148
+ for (let i = 1; i < width; i++) {
149
+ const shade = pickShade(amps[i] ?? 0);
150
+ if (shade !== currentShade) {
151
+ out += this.theme.fg(currentShade, cells.slice(runStart, i).join(""));
152
+ runStart = i;
153
+ currentShade = shade;
154
+ }
155
+ }
156
+ out += this.theme.fg(currentShade, cells.slice(runStart).join(""));
157
+ return out;
158
+ }
159
+ }
160
+
161
+ // Three-tier shade picker over the 0..MAX_AMP amplitude range. Splits roughly
162
+ // thirds: lower bottom-row eighths render dim, the upper bottom-row + lower
163
+ // top-row eighths render muted, anything in the upper top-row burns accent.
164
+ function pickShade(amp: number): "dim" | "muted" | "accent" {
165
+ if (amp <= 5) return COLOR_DIM;
166
+ if (amp <= 10) return COLOR_MUTED;
167
+ return COLOR_ACCENT;
168
+ }
169
+
170
+ function buildTuning(width: number): ColumnTuning[] {
171
+ const out: ColumnTuning[] = new Array(width);
172
+ for (let i = 0; i < width; i++) {
173
+ const t = width === 1 ? 0.5 : i / (width - 1);
174
+ const envelope = trapezoidEnvelope(t) * columnShape(i);
175
+ const fall = FALL_MIN + columnHash(i) * (FALL_MAX - FALL_MIN);
176
+ out[i] = { envelope, fall };
177
+ }
178
+ return out;
179
+ }
180
+
181
+ // Rise-fast / hold / fall-slow per column. The latch refreshes on every
182
+ // rising-or-stable tick so sustained loud audio keeps the column pinned to
183
+ // peak; only when target drops below prev does the HOLD_TICKS countdown
184
+ // start, and only after it expires does the column-specific gravity (release
185
+ // time) take over. End result: bars freeze at the loudest recent moment for
186
+ // ~600 ms, then start dropping at their per-column rates — the classic
187
+ // peak-hold ballistic of Winamp-class meters.
188
+ function advanceLevels(level: Float64Array, holdLeft: Uint8Array, tuning: ColumnTuning[], gain: number): void {
189
+ for (let i = 0; i < level.length; i++) {
190
+ const t = tuning[i]!;
191
+ const target = gain * t.envelope;
192
+ const prev = level[i]!;
193
+ if (target >= prev) {
194
+ level[i] = target;
195
+ holdLeft[i] = HOLD_TICKS;
196
+ } else if (holdLeft[i]! > 0) {
197
+ holdLeft[i] = holdLeft[i]! - 1;
198
+ } else {
199
+ level[i] = Math.max(0, prev - t.fall);
200
+ }
201
+ }
202
+ }
203
+
204
+ // Trapezoid centered at t=0.5: flat 1.0 across [0.5±PLATEAU_HALF_WIDTH], linear
205
+ // fade to 0 over the next FADE_WIDTH, then hard zero at the very edges.
206
+ function trapezoidEnvelope(t: number): number {
207
+ const d = Math.abs(t - 0.5);
208
+ if (d <= PLATEAU_HALF_WIDTH) return 1;
209
+ const fadeT = (d - PLATEAU_HALF_WIDTH) / FADE_WIDTH;
210
+ if (fadeT >= 1) return 0;
211
+ return 1 - fadeT;
212
+ }
213
+
214
+ // Deterministic per-column multiplier in [SHAPE_FLOOR, 1]. Two hash phases are
215
+ // summed so adjacent columns vary like a frozen audio snapshot — dense peaks
216
+ // and dips, no obvious periodicity.
217
+ function columnShape(i: number): number {
218
+ const a = Math.sin(i * 12.9898 + 78.233) * 43758.5453;
219
+ const b = Math.sin(i * 7.523 + 41.31) * 13371.337;
220
+ const fa = a - Math.floor(a);
221
+ const fb = b - Math.floor(b);
222
+ const f = (fa + fb) / 2;
223
+ return SHAPE_FLOOR + (1 - SHAPE_FLOOR) * f;
224
+ }
225
+
226
+ // Independent per-column [0, 1) hash for fall rates — different sine phases
227
+ // from columnShape so envelope-tall columns don't all share a single fall
228
+ // speed.
229
+ function columnHash(i: number): number {
230
+ const a = Math.sin(i * 97.13 + 12.345) * 43758.5453;
231
+ return a - Math.floor(a);
232
+ }
233
+
234
+ function quantize(level: number): number {
235
+ const idx = Math.round(level * MAX_AMP);
236
+ return idx < 0 ? 0 : idx > MAX_AMP ? MAX_AMP : idx;
237
+ }
@@ -0,0 +1,77 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
+ import type { StatefulView } from "../stateful-view.js";
4
+
5
+ // Rows start at col 0 so the settings surface aligns with dictation
6
+ // (transcript + status bar are also flush-left). The pointer occupies a fixed
7
+ // 2-col slot so active/inactive rows share the same label start column.
8
+ const ACTIVE_POINTER = "❯ ";
9
+ const INACTIVE_POINTER = " ";
10
+ const VALUE_SEPARATOR = ": ";
11
+ const EMPTY_PLACEHOLDER = "<unset>";
12
+ const TRUNCATE_ELLIPSIS = "…";
13
+
14
+ const COLOR_ACCENT = "accent";
15
+ const COLOR_MUTED = "muted";
16
+ const COLOR_DIM = "dim";
17
+ const COLOR_SUCCESS = "success";
18
+
19
+ export type SettingsFieldKind = { kind: "readonly"; value: string } | { kind: "toggle"; enabled: boolean };
20
+
21
+ export interface SettingsFieldViewProps {
22
+ label: string;
23
+ active: boolean;
24
+ field: SettingsFieldKind;
25
+ /** Optional muted hint rendered on the next line when active. */
26
+ hint?: string;
27
+ }
28
+
29
+ export class SettingsFieldView implements StatefulView<SettingsFieldViewProps> {
30
+ private props: SettingsFieldViewProps = {
31
+ label: "",
32
+ active: false,
33
+ field: { kind: "readonly", value: "" },
34
+ };
35
+
36
+ constructor(private readonly theme: Theme) {}
37
+
38
+ setProps(props: SettingsFieldViewProps): void {
39
+ this.props = props;
40
+ }
41
+
42
+ handleInput(_data: string): void {}
43
+
44
+ invalidate(): void {}
45
+
46
+ render(width: number): string[] {
47
+ const pointer = this.props.active ? this.theme.fg(COLOR_ACCENT, ACTIVE_POINTER) : INACTIVE_POINTER;
48
+ const labelText = this.props.active
49
+ ? this.theme.fg(COLOR_ACCENT, this.theme.bold(this.props.label))
50
+ : this.props.label;
51
+ const head = `${pointer}${labelText}${VALUE_SEPARATOR}`;
52
+ const valueText = this.renderValue();
53
+ const headWidth = visibleWidth(head);
54
+ const valueWidth = Math.max(1, width - headWidth);
55
+ const clamped = truncateToWidth(valueText, valueWidth, TRUNCATE_ELLIPSIS, false);
56
+ const lines = [`${head}${clamped}`];
57
+ // Hints render unconditionally — gating on `active` made the form's row
58
+ // count change as focus moved between toggles, which translated to a
59
+ // visible jump of the bottom chrome each time the user pressed an arrow.
60
+ // A stable height is worth the extra noise of inactive hints.
61
+ const hint = this.props.hint;
62
+ if (hint) {
63
+ const hintIndent = " ".repeat(visibleWidth(ACTIVE_POINTER));
64
+ const hintLine = `${hintIndent}${this.theme.fg(COLOR_DIM, hint)}`;
65
+ lines.push(truncateToWidth(hintLine, width, TRUNCATE_ELLIPSIS, false));
66
+ }
67
+ return lines;
68
+ }
69
+
70
+ private renderValue(): string {
71
+ const f = this.props.field;
72
+ if (f.kind === "readonly") {
73
+ return f.value.length > 0 ? this.theme.fg(COLOR_MUTED, f.value) : this.theme.fg(COLOR_DIM, EMPTY_PLACEHOLDER);
74
+ }
75
+ return f.enabled ? this.theme.fg(COLOR_SUCCESS, "[ on ]") : this.theme.fg(COLOR_DIM, "[ off ]");
76
+ }
77
+ }
@@ -0,0 +1,26 @@
1
+ import type { StatefulView } from "../stateful-view.js";
2
+ import type { SettingsFieldView } from "./settings-field-view.js";
3
+
4
+ // The settings screen is a flat stack of rows: a read-only mic line and a
5
+ // hallucination-filter toggle. No tabs — too few rows to justify chrome.
6
+ export interface SettingsFormViewProps {}
7
+
8
+ export interface SettingsFormViewConfig {
9
+ fields: ReadonlyArray<SettingsFieldView>;
10
+ }
11
+
12
+ export class SettingsFormView implements StatefulView<SettingsFormViewProps> {
13
+ constructor(private readonly config: SettingsFormViewConfig) {}
14
+
15
+ setProps(_props: SettingsFormViewProps): void {}
16
+
17
+ handleInput(_data: string): void {}
18
+
19
+ invalidate(): void {}
20
+
21
+ render(width: number): string[] {
22
+ const lines: string[] = [];
23
+ for (const view of this.config.fields) lines.push(...view.render(width));
24
+ return lines;
25
+ }
26
+ }
@@ -0,0 +1,98 @@
1
+ import { DynamicBorder, type Theme } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth } from "@earendil-works/pi-tui";
3
+ import { t } from "../../state/i18n-bridge.js";
4
+ import type { StatefulView } from "../stateful-view.js";
5
+
6
+ export const SPLASH_FRAMES = ["⠴", "⠦", "⠖", "⠲"] as const;
7
+ export const SPLASH_FRAME_INTERVAL_MS = 160;
8
+
9
+ export type SplashPhase =
10
+ | {
11
+ kind: "downloading";
12
+ message: string;
13
+ /** 0-100 integer when Content-Length was provided. */
14
+ percent?: number;
15
+ bytesReceived?: number;
16
+ totalBytes?: number;
17
+ }
18
+ | { kind: "extracting"; message: string }
19
+ | { kind: "verifying"; message: string }
20
+ | { kind: "loading_engine" }
21
+ | { kind: "initializing_mic" };
22
+
23
+ export interface SplashViewProps {
24
+ phase: SplashPhase;
25
+ frame: number;
26
+ }
27
+
28
+ const TRUNCATE_ELLIPSIS = "…";
29
+
30
+ const COLOR_ACCENT = "accent";
31
+ const COLOR_MUTED = "muted";
32
+
33
+ function phaseLabel(phase: SplashPhase): string {
34
+ switch (phase.kind) {
35
+ case "downloading":
36
+ return appendDownloadProgress(phase.message, phase);
37
+ case "extracting":
38
+ case "verifying":
39
+ return phase.message;
40
+ case "loading_engine":
41
+ return t("splash.loading_engine", "Loading speech model…");
42
+ case "initializing_mic":
43
+ return t("splash.initializing_mic", "Initializing microphone…");
44
+ }
45
+ }
46
+
47
+ const BYTES_PER_MB = 1024 * 1024;
48
+
49
+ function formatMB(bytes: number): string {
50
+ return `${(bytes / BYTES_PER_MB).toFixed(1)} MB`;
51
+ }
52
+
53
+ // Decorate the downloading label with whatever progress information we have:
54
+ // - percent + bytes when Content-Length was present
55
+ // - byte counter only when the server didn't send Content-Length
56
+ // - bare label on the very first emit before any chunk has arrived
57
+ function appendDownloadProgress(
58
+ base: string,
59
+ progress: { percent?: number; bytesReceived?: number; totalBytes?: number },
60
+ ): string {
61
+ if (progress.bytesReceived === undefined) return base;
62
+ if (progress.totalBytes && progress.percent !== undefined) {
63
+ return `${base} ${progress.percent}% (${formatMB(progress.bytesReceived)} / ${formatMB(progress.totalBytes)})`;
64
+ }
65
+ return `${base} ${formatMB(progress.bytesReceived)}`;
66
+ }
67
+
68
+ /**
69
+ * Splash chrome mirrors the in-session layout: a divider line on top followed
70
+ * by a single status line. The status line uses the same `${glyph} ${label}`
71
+ * shape as `StatusBarView` (`● 0:42 …`) — a leading colored glyph, single
72
+ * space, then a muted label — so the splash feels like a quieter sibling of
73
+ * the dictation/settings chrome rather than a separate widget.
74
+ */
75
+ export class SplashView implements StatefulView<SplashViewProps> {
76
+ private readonly divider: DynamicBorder;
77
+ private props: SplashViewProps = { phase: { kind: "loading_engine" }, frame: 0 };
78
+
79
+ constructor(private readonly theme: Theme) {
80
+ this.divider = new DynamicBorder((s) => theme.fg(COLOR_ACCENT, s));
81
+ }
82
+
83
+ setProps(props: SplashViewProps): void {
84
+ this.props = props;
85
+ }
86
+
87
+ handleInput(_data: string): void {}
88
+
89
+ invalidate(): void {}
90
+
91
+ render(width: number): string[] {
92
+ const frameChar = SPLASH_FRAMES[this.props.frame % SPLASH_FRAMES.length];
93
+ const spinner = this.theme.fg(COLOR_ACCENT, frameChar);
94
+ const label = this.theme.fg(COLOR_MUTED, phaseLabel(this.props.phase));
95
+ const statusLine = `${spinner} ${label}`;
96
+ return [...this.divider.render(width), truncateToWidth(statusLine, width, TRUNCATE_ELLIPSIS, false)];
97
+ }
98
+ }