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,171 @@
1
+ /**
2
+ * Single shared 10 Hz frame coalescer for animated widgets — §2 of the
3
+ * v7.1 plan.
4
+ *
5
+ * Why this exists: three independent `setInterval(…, 100ms)` widgets
6
+ * drift relative to each other and each call `ctx.ui.setWidget()`
7
+ * separately, causing 20-30 partial re-renders per second instead of 10
8
+ * cohesive ones. With this ticker, every animated widget subscribes to
9
+ * a single `setInterval`; all `setWidget` calls land in the same JS
10
+ * turn so the Pi TUI sees one batch of slot updates per tick.
11
+ *
12
+ * Failure isolation (Codex v4 #2 + Gemini v3 #2):
13
+ * - Subscriber `tick()` is wrapped in try/catch — one throwing widget
14
+ * cannot crash the loop.
15
+ * - 3 consecutive throws auto-unsubscribe the widget. The ticker
16
+ * then calls `sub.dispose?.()` to evict its slot — but the
17
+ * `dispose()` invocation is ITSELF wrapped in try/catch, so a
18
+ * widget whose state is corrupted enough to throw on render
19
+ * (likely also throws on dispose) does not crash the ticker.
20
+ *
21
+ * Lifetime:
22
+ * - Lazy start: `setInterval` is created on first subscriber.
23
+ * - Lazy stop: `setInterval` is cleared on last unsubscribe.
24
+ * - `dispose()` clears all subscribers + the interval. Idempotent.
25
+ */
26
+
27
+ import * as fs from "node:fs";
28
+ import * as os from "node:os";
29
+ import * as path from "node:path";
30
+
31
+ const VOICE_DEBUG = !!process.env.PI_VOICE_DEBUG;
32
+ const VOICE_LOG_FILE = path.join(os.tmpdir(), "pi-voice-debug.log");
33
+
34
+ function debug(...args: unknown[]) {
35
+ if (!VOICE_DEBUG) return;
36
+ const ts = new Date().toISOString().split("T")[1];
37
+ const line = `[voice-ticker ${ts}] ${args.map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a))).join(" ")}\n`;
38
+ try {
39
+ fs.appendFileSync(VOICE_LOG_FILE, line);
40
+ } catch {
41
+ /* best-effort */
42
+ }
43
+ }
44
+
45
+ /** Tick frequency — 10 Hz keeps animation smooth without burning CPU. */
46
+ const TICK_INTERVAL_MS = 100;
47
+
48
+ /** Throws-in-a-row threshold before auto-eviction. */
49
+ const THROW_THRESHOLD = 3;
50
+
51
+ /**
52
+ * Subscriber descriptor — explicit ownership (Codex v3 #2). Pass
53
+ * `dispose` only when the ticker should evict this subscriber's slot
54
+ * after auto-unsubscribe; ad-hoc test subscribers can omit it.
55
+ */
56
+ export interface TickerSubscriber {
57
+ /** Called once per tick (10 Hz). May throw; the ticker isolates. */
58
+ readonly tick: () => void;
59
+ /**
60
+ * Optional ownership hook. If present and the subscriber is
61
+ * auto-unsubscribed after `THROW_THRESHOLD` consecutive throws,
62
+ * the ticker calls `dispose()` to tear down the broken widget's
63
+ * slot. The call is wrapped in `try/catch` so a corrupted widget
64
+ * cannot crash the ticker.
65
+ */
66
+ readonly dispose?: () => void;
67
+ /** Optional debug label, surfaced in `voiceDebug()` output. */
68
+ readonly label?: string;
69
+ }
70
+
71
+ export interface RenderTicker {
72
+ /** Subscribe. Returns an unsubscribe fn — calling it is idempotent. */
73
+ subscribe(subscriber: TickerSubscriber): () => void;
74
+ /** Active subscriber count. Test/debug. */
75
+ refCount(): number;
76
+ /** Tear down the ticker — clears interval + subscribers. Idempotent. */
77
+ dispose(): void;
78
+ }
79
+
80
+ interface SubEntry {
81
+ readonly sub: TickerSubscriber;
82
+ throwsInARow: number;
83
+ unsubscribed: boolean;
84
+ }
85
+
86
+ class RenderTickerImpl implements RenderTicker {
87
+ private readonly entries = new Set<SubEntry>();
88
+ private timer: ReturnType<typeof setInterval> | null = null;
89
+ private disposed = false;
90
+
91
+ subscribe(subscriber: TickerSubscriber): () => void {
92
+ if (this.disposed) {
93
+ // Late subscriber after dispose — give them a no-op
94
+ // unsubscriber and don't start the timer.
95
+ return () => {};
96
+ }
97
+ const entry: SubEntry = { sub: subscriber, throwsInARow: 0, unsubscribed: false };
98
+ this.entries.add(entry);
99
+ if (this.timer == null) this.startTimer();
100
+ return () => {
101
+ if (entry.unsubscribed) return;
102
+ entry.unsubscribed = true;
103
+ this.entries.delete(entry);
104
+ if (this.entries.size === 0) this.stopTimer();
105
+ };
106
+ }
107
+
108
+ refCount(): number {
109
+ return this.entries.size;
110
+ }
111
+
112
+ dispose(): void {
113
+ if (this.disposed) return;
114
+ this.disposed = true;
115
+ this.stopTimer();
116
+ // Mark all entries unsubscribed so any retained closure that
117
+ // still calls its unsubscribe fn is a no-op.
118
+ for (const entry of this.entries) entry.unsubscribed = true;
119
+ this.entries.clear();
120
+ }
121
+
122
+ private startTimer(): void {
123
+ this.timer = setInterval(() => this.runTick(), TICK_INTERVAL_MS);
124
+ // Don't keep the event loop alive just for animation.
125
+ (this.timer as any)?.unref?.();
126
+ }
127
+
128
+ private stopTimer(): void {
129
+ if (this.timer != null) {
130
+ clearInterval(this.timer);
131
+ this.timer = null;
132
+ }
133
+ }
134
+
135
+ private runTick(): void {
136
+ // Snapshot entries before iterating — auto-eviction during the
137
+ // loop would otherwise mutate the live Set. Snapshot also lets
138
+ // us safely call dispose() on evicted entries after the loop.
139
+ const snapshot = Array.from(this.entries);
140
+ const evict: SubEntry[] = [];
141
+ for (const entry of snapshot) {
142
+ if (entry.unsubscribed) continue;
143
+ try {
144
+ entry.sub.tick();
145
+ entry.throwsInARow = 0;
146
+ } catch (err) {
147
+ entry.throwsInARow++;
148
+ debug("tick threw", entry.sub.label ?? "(unlabeled)", entry.throwsInARow, "/", THROW_THRESHOLD, String(err));
149
+ if (entry.throwsInARow >= THROW_THRESHOLD) evict.push(entry);
150
+ }
151
+ }
152
+ for (const entry of evict) {
153
+ if (entry.unsubscribed) continue;
154
+ entry.unsubscribed = true;
155
+ this.entries.delete(entry);
156
+ // Eviction dispose() — Gemini v3 #2: try/catch around
157
+ // dispose itself so a broken widget can't crash the ticker.
158
+ try {
159
+ entry.sub.dispose?.();
160
+ } catch (err) {
161
+ debug("eviction dispose threw", entry.sub.label ?? "(unlabeled)", String(err));
162
+ }
163
+ }
164
+ if (this.entries.size === 0) this.stopTimer();
165
+ }
166
+ }
167
+
168
+ /** Construct a fresh ticker. One per session is the expected shape. */
169
+ export function makeRenderTicker(): RenderTicker {
170
+ return new RenderTickerImpl();
171
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Widget ownership model for the v7.1 Settings UI redesign — §1 of the
3
+ * approved plan, hardened across v3/v4/v5 reviews.
4
+ *
5
+ * Why this exists: `ctx.ui.setWidget(key, content)` writes to a key-slotted
6
+ * store. Two widgets with different keys coexist; two widgets with the
7
+ * same key overwrite. v7.1 introduces install + playback widgets that
8
+ * must coexist with the recording widget, so we need a registry that
9
+ * (a) prevents same-key collision, (b) lets completed widgets evict
10
+ * themselves so the registry doesn't leak across long sessions, and
11
+ * (c) survives misbehaving widgets that throw on disposal.
12
+ *
13
+ * The contract was reviewed and SHIP'd by both Codex and Gemini at v5.
14
+ * Six load-bearing properties of the implementation:
15
+ *
16
+ * 1. `register(w)` synchronously calls `existing.dispose()` before
17
+ * swapping in `w`, so a same-key handover never races.
18
+ * 2. `unregister(key, owner)` is OWNER-CHECKED — only deletes the Map
19
+ * entry if it currently points to `owner`. A stale dispose path
20
+ * (e.g. an old install widget's queued cleanup running after a
21
+ * newer same-key widget was registered) cannot evict the
22
+ * successor.
23
+ * 3. `disposeAll()` iterates a CLONED snapshot of values, because
24
+ * each widget's `dispose()` will synchronously call back into
25
+ * `unregister(key, this)`, mutating the underlying Map.
26
+ * 4. Each `dispose()` call inside `disposeAll()` is wrapped in
27
+ * `try/catch` so one throwing widget cannot abort cleanup for
28
+ * the rest.
29
+ * 5. `installWidgetKey(modelId)` produces per-model-id keys so two
30
+ * installs for different models occupy different slots.
31
+ * 6. The base class's `dispose()` ordering — set `disposed` true
32
+ * before any cleanup work — is enforced via `BaseDisposableWidget`,
33
+ * so subclasses cannot accidentally clear timers / slots before
34
+ * flipping the flag.
35
+ */
36
+
37
+ import * as fs from "node:fs";
38
+ import * as os from "node:os";
39
+ import * as path from "node:path";
40
+
41
+ const VOICE_DEBUG = !!process.env.PI_VOICE_DEBUG;
42
+ const VOICE_LOG_FILE = path.join(os.tmpdir(), "pi-voice-debug.log");
43
+
44
+ function debug(...args: unknown[]) {
45
+ if (!VOICE_DEBUG) return;
46
+ const ts = new Date().toISOString().split("T")[1];
47
+ const line = `[voice-ui ${ts}] ${args.map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a))).join(" ")}\n`;
48
+ try {
49
+ fs.appendFileSync(VOICE_LOG_FILE, line);
50
+ } catch {
51
+ /* best-effort */
52
+ }
53
+ }
54
+
55
+ /** A widget that owns one slot key and can be torn down. */
56
+ export interface DisposableWidget {
57
+ /**
58
+ * Stable widget-key the slot writes to. Install widgets use
59
+ * `installWidgetKey(modelId)`; recording uses `"voice-recording"`;
60
+ * playback uses `"voice-tts-playback"`.
61
+ */
62
+ readonly key: string;
63
+ /** Tear down the widget. MUST be idempotent (re-entry returns early). */
64
+ dispose(): void;
65
+ }
66
+
67
+ /**
68
+ * Registry that owns lifetime of every active widget. One per session,
69
+ * created in `session_start` and drained in `voiceCleanup`.
70
+ */
71
+ export interface WidgetRegistry {
72
+ /**
73
+ * Register a widget under its `key`. If a widget with the same
74
+ * key is already registered, that incumbent's `dispose()` is
75
+ * called synchronously BEFORE `w` takes the slot — preventing
76
+ * same-key collision.
77
+ */
78
+ register(w: DisposableWidget): void;
79
+ /**
80
+ * Owner-checked eviction (Codex v4 #3). Removes the Map entry for
81
+ * `key` ONLY if the current entry is `owner` (identity-equal). If
82
+ * a stale dispose path runs after a NEW widget has taken the same
83
+ * key, the unregister is a no-op and the new widget stays bound.
84
+ * Calls with absent `key` or mismatched owner are no-ops.
85
+ */
86
+ unregister(key: string, owner: DisposableWidget): void;
87
+ /**
88
+ * Drains the registry. Iterates a CLONED snapshot of values
89
+ * (Gemini v4 implementation note) because each `dispose()`
90
+ * re-enters `unregister`. Each `dispose()` is wrapped in
91
+ * `try/catch` so one throwing widget cannot abort cleanup for
92
+ * the rest. Idempotent — safe to call twice.
93
+ */
94
+ disposeAll(): void;
95
+ /** Active widget count. Test/debug only. */
96
+ size(): number;
97
+ }
98
+
99
+ class WidgetRegistryImpl implements WidgetRegistry {
100
+ private readonly entries = new Map<string, DisposableWidget>();
101
+
102
+ register(w: DisposableWidget): void {
103
+ const existing = this.entries.get(w.key);
104
+ if (existing && existing !== w) {
105
+ // Synchronous dispose of incumbent before swap — same-key
106
+ // handover never races. Wrap in try/catch so a throwing
107
+ // incumbent cannot prevent the new widget from registering.
108
+ try {
109
+ existing.dispose();
110
+ } catch (err) {
111
+ debug("register: incumbent dispose threw", w.key, String(err));
112
+ }
113
+ }
114
+ this.entries.set(w.key, w);
115
+ }
116
+
117
+ unregister(key: string, owner: DisposableWidget): void {
118
+ const cur = this.entries.get(key);
119
+ if (cur === owner) this.entries.delete(key);
120
+ // else: stale call — successor already took the slot, do nothing.
121
+ }
122
+
123
+ disposeAll(): void {
124
+ // Cloned snapshot — each dispose() re-enters unregister() and
125
+ // mutates the underlying Map. Iterating a snapshot guarantees
126
+ // no widget is skipped during the cascade.
127
+ const snapshot = Array.from(this.entries.values());
128
+ for (const w of snapshot) {
129
+ try {
130
+ w.dispose();
131
+ } catch (err) {
132
+ debug("disposeAll: dispose threw", w.key, String(err));
133
+ }
134
+ }
135
+ // Defensive: any widget that didn't unregister itself (buggy
136
+ // dispose) is force-evicted so a later disposeAll() is truly idempotent.
137
+ this.entries.clear();
138
+ }
139
+
140
+ size(): number {
141
+ return this.entries.size;
142
+ }
143
+ }
144
+
145
+ /** Construct a fresh registry. One per session is the expected shape. */
146
+ export function makeWidgetRegistry(): WidgetRegistry {
147
+ return new WidgetRegistryImpl();
148
+ }
149
+
150
+ /**
151
+ * Per-instance install widget key. Concurrent installs of different
152
+ * model ids occupy different slots (`voice-tts-install:kitten-…` vs
153
+ * `voice-tts-install:kokoro-…`). Same-id installs are already
154
+ * serialized by `ensureTtsModelInstalled`'s in-flight Map.
155
+ */
156
+ export function installWidgetKey(modelId: string): string {
157
+ return `voice-tts-install:${modelId}`;
158
+ }
159
+
160
+ /** Stable widget keys for non-install widgets. */
161
+ export const WIDGET_KEY = {
162
+ recording: "voice-recording",
163
+ ttsPlayback: "voice-tts-playback",
164
+ } as const;
165
+
166
+ /**
167
+ * Optional base class enforcing the §1 dispose ordering. Subclasses
168
+ * implement `onDispose()` for their type-specific cleanup; the base
169
+ * runs the universal sequence around it.
170
+ *
171
+ * Order (Codex v4 #2 + Codex v5 nit):
172
+ * (a) check `if (this.disposed) return;` — idempotency on PRIOR state
173
+ * (b) set `this.disposed = true` BEFORE any cleanup work
174
+ * (c) call `this.unsubTicker?.()` so ticker refcount drops
175
+ * (d) `onDispose()` for subclass-specific cleanup (timers, etc.)
176
+ * (e) clear the slot via the injected `clearSlot()` callback
177
+ * (f) `registry.unregister(this.key, this)` — owner-checked eviction
178
+ */
179
+ export abstract class BaseDisposableWidget implements DisposableWidget {
180
+ abstract readonly key: string;
181
+ protected disposed = false;
182
+ protected unsubTicker: (() => void) | null = null;
183
+ protected readonly registry: WidgetRegistry;
184
+ protected readonly clearSlot: () => void;
185
+
186
+ constructor(registry: WidgetRegistry, clearSlot: () => void) {
187
+ this.registry = registry;
188
+ this.clearSlot = clearSlot;
189
+ }
190
+
191
+ /** Subclass cleanup hook. Called between unsubTicker and clearSlot. */
192
+ protected onDispose(): void {}
193
+
194
+ dispose(): void {
195
+ if (this.disposed) return;
196
+ this.disposed = true;
197
+ try {
198
+ this.unsubTicker?.();
199
+ } catch (err) {
200
+ debug("dispose: unsubTicker threw", this.key, String(err));
201
+ }
202
+ this.unsubTicker = null;
203
+ try {
204
+ this.onDispose();
205
+ } catch (err) {
206
+ debug("dispose: onDispose threw", this.key, String(err));
207
+ }
208
+ try {
209
+ this.clearSlot();
210
+ } catch (err) {
211
+ debug("dispose: clearSlot threw", this.key, String(err));
212
+ }
213
+ try {
214
+ this.registry.unregister(this.key, this);
215
+ } catch (err) {
216
+ debug("dispose: unregister threw", this.key, String(err));
217
+ }
218
+ }
219
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Visual-width and width-tier helpers for the v7.1 Settings UI.
3
+ *
4
+ * `visualWidth(s)` returns terminal column count for a string, treating
5
+ * East-Asian Wide / Fullwidth codepoints as 2 columns and everything else
6
+ * as 1. Iteration is done in UTF-32 code points (via `for…of`) so surrogate
7
+ * pairs (`String.length 2`, single code point) count as one wide unit, not
8
+ * two halves of nothing.
9
+ *
10
+ * Hand-curated EAW Wide/Fullwidth ranges from Unicode 15.1 EastAsianWidth.txt
11
+ * — only the blocks pi-listen actually ships labels for (CJK, Hangul,
12
+ * Hiragana/Katakana, fullwidth ASCII). Hindi/Devanagari and Arabic
13
+ * intentionally NOT covered: per the v7.1 plan their voices are rendered
14
+ * with romanized labels, so a precise width here is unnecessary and a
15
+ * partial width table would silently mis-align their rows. Keeping the
16
+ * table small also keeps the zero-dependency promise.
17
+ *
18
+ * Width tiers (§10): three buckets — "wide" (≥80), "mid" (60..79), "narrow"
19
+ * (<60 — hard block at the panel level).
20
+ */
21
+
22
+ // Hand-curated EAW Wide / Fullwidth ranges, sorted ascending by `lo` so the
23
+ // scanner can early-exit on `cp < lo`. Comments label the Unicode block.
24
+ const EAW_WIDE_RANGES: ReadonlyArray<readonly [number, number]> = [
25
+ [0x1100, 0x11ff], // Hangul Jamo
26
+ [0x3000, 0x30ff], // CJK Symbols & Punctuation, Hiragana, Katakana
27
+ [0x3100, 0x312f], // Bopomofo
28
+ [0x3130, 0x318f], // Hangul Compatibility Jamo
29
+ [0x31a0, 0x31bf], // Bopomofo Extended
30
+ [0x31c0, 0x31ef], // CJK Strokes
31
+ [0x31f0, 0x31ff], // Katakana Phonetic Extensions
32
+ [0x3400, 0x4dbf], // CJK Unified Ideographs Extension A
33
+ [0x4e00, 0x9fff], // CJK Unified Ideographs (main block)
34
+ [0xac00, 0xd7a3], // Hangul Syllables (Korean)
35
+ [0xf900, 0xfaff], // CJK Compatibility Ideographs
36
+ [0xff01, 0xff60], // Fullwidth ASCII
37
+ [0xffe0, 0xffe6], // Fullwidth signs
38
+ [0x20000, 0x2fffd], // CJK Unified Ideographs Extension B–F
39
+ [0x30000, 0x3fffd], // CJK Unified Ideographs Extension G
40
+ ];
41
+
42
+ function isWide(cp: number): boolean {
43
+ for (const [lo, hi] of EAW_WIDE_RANGES) {
44
+ if (cp >= lo && cp <= hi) return true;
45
+ if (cp < lo) return false; // ranges are sorted
46
+ }
47
+ return false;
48
+ }
49
+
50
+ /**
51
+ * Visual width of `s` in terminal columns. Surrogate pairs count as one
52
+ * code point. EAW Wide/Fullwidth code points count as 2; everything else
53
+ * as 1. Combining marks are NOT subtracted (Devanagari/Arabic are out of
54
+ * scope per §8) — pass romanized labels for those scripts.
55
+ */
56
+ export function visualWidth(s: string): number {
57
+ let w = 0;
58
+ for (const ch of s) {
59
+ const cp = ch.codePointAt(0) ?? 0;
60
+ w += isWide(cp) ? 2 : 1;
61
+ }
62
+ return w;
63
+ }
64
+
65
+ /**
66
+ * Truncate `s` to fit within `max` columns, appending `…` (1 column) when
67
+ * truncation occurs. Uses code-point iteration so surrogate pairs and
68
+ * wide characters are never sliced mid-glyph.
69
+ */
70
+ export function truncateToVisualWidth(s: string, max: number): string {
71
+ if (max <= 0) return "";
72
+ if (visualWidth(s) <= max) return s;
73
+ if (max === 1) return "…";
74
+ let w = 0;
75
+ let out = "";
76
+ for (const ch of s) {
77
+ const cw = isWide(ch.codePointAt(0) ?? 0) ? 2 : 1;
78
+ if (w + cw > max - 1) break; // reserve 1 col for ellipsis
79
+ out += ch;
80
+ w += cw;
81
+ }
82
+ return out + "…";
83
+ }
84
+
85
+ /** Pad `s` on the right (with spaces) to occupy exactly `width` columns. */
86
+ export function padRightVisual(s: string, width: number): string {
87
+ const w = visualWidth(s);
88
+ if (w >= width) return s;
89
+ return s + " ".repeat(width - w);
90
+ }
91
+
92
+ /** Pad `s` on the left (with spaces) to occupy exactly `width` columns. */
93
+ export function padLeftVisual(s: string, width: number): string {
94
+ const w = visualWidth(s);
95
+ if (w >= width) return s;
96
+ return " ".repeat(width - w) + s;
97
+ }
98
+
99
+ /** Width tier labels — see §10 of the v7.1 plan. */
100
+ export type WidthTier = "wide" | "mid" | "narrow";
101
+
102
+ /** Map a column count to a tier. ≥80 wide, 60..79 mid, <60 narrow (block). */
103
+ export function widthTier(cols: number): WidthTier {
104
+ if (cols >= 80) return "wide";
105
+ if (cols >= 60) return "mid";
106
+ return "narrow";
107
+ }
108
+
109
+ /** True when the panel should hard-block rendering (§10). */
110
+ export function isPanelTooNarrow(cols: number): boolean {
111
+ return widthTier(cols) === "narrow";
112
+ }