@kolisachint/hoocode-agent 0.4.104 β†’ 0.4.106

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/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.106] - 2026-07-01
4
+
5
+ ### Changed
6
+
7
+ - Voice-to-text (`ctrl+r`) now allows a longer thinking pause before a capture
8
+ auto-stops. The trailing-silence window is raised from 600ms to 3s and is now
9
+ passed through to `voicetools serve` via `--silence-ms`, so the real cutoff
10
+ matches the on-screen countdown (previously the hoocode-side value only drove
11
+ the cosmetic countdown while the binary used its own 600ms default).
12
+ - Voice daemon idle shutdown: after 60s with no capture, the warm `voicetools
13
+ serve` process exits automatically, releasing the ~900 MB resident ASR model
14
+ from memory. The next `ctrl+r` pays a cold-start respawn cost. Controlled
15
+ by `VOICE_IDLE_TIMEOUT_MS` (default 60,000; 0 disables).
16
+
17
+ ## [0.4.105] - 2026-07-01
18
+
19
+ ### Added
20
+
21
+ - Voice-to-text (`ctrl+r`) now uses `voicetools serve` when available: the
22
+ first press loads the model once and keeps it warm for the rest of the
23
+ session, so later presses skip the cold start and jump straight into
24
+ listening. Binaries that don't support `serve` fall back to the previous
25
+ per-press `transcribe` behavior automatically.
26
+ - Live voice-input panel with words-as-you-speak. With a streaming
27
+ `voicetools serve` (v0.1.4+), the multi-line panel shows the transcript
28
+ growing word by word as you talk (`PARTIAL`), alongside a mic glyph, an
29
+ elapsed timer, a scrolling waveform driven by `LEVEL` events, and a
30
+ shrinking "cutting off in Ns" countdown when trailing silence begins. The
31
+ finished utterance (`FINAL`) is committed to the editor in one piece and
32
+ the panel collapses. Older non-streaming binaries just show a spinner for
33
+ the batch phases; the committed text still lands the same way.
34
+
3
35
  ## [0.4.104] - 2026-07-01
4
36
 
5
37
  ### Fixed
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Multi-line voice-input status panel.
3
+ *
4
+ * Renders the live state of a `voicetools serve` capture across its phases so
5
+ * the user always sees what's happening:
6
+ *
7
+ * ```text
8
+ * πŸŽ™ Listening Β· 0:03
9
+ * ▁▂▃▅▇▆▄▂▁▂▄▆▇▅▃▂▁ ← scrolling level history from LEVEL events
10
+ * β€Ί and so my fellow ameri▏ ← live partial transcript (PARTIAL), dim/tentative
11
+ * ctrl+r cancel ← dim key footer
12
+ * ```
13
+ *
14
+ * Phases:
15
+ * - `warming` spinner while the model loads (first press only)
16
+ * - `listening` mic glyph + elapsed timer + waveform + growing partial text
17
+ * - `silence` trailing-silence countdown; waveform dims
18
+ * - `transcribing` spinner while the final decode runs
19
+ *
20
+ * The component self-animates (spinner, timer, countdown) on an internal timer
21
+ * and calls `ui.requestRender()`; the owner feeds it protocol events via
22
+ * `pushLevel` / `setPartial` / `beginSilence` etc. and disposes it on collapse.
23
+ */
24
+ import type { Component, TUI } from "@kolisachint/hoocode-tui";
25
+ export type VoicePanelPhase = "warming" | "listening" | "silence" | "transcribing";
26
+ export declare class VoicePanel implements Component {
27
+ private readonly ui;
28
+ private readonly cancelHint;
29
+ private phase;
30
+ private frame;
31
+ private levels;
32
+ private partial;
33
+ private listeningStartMs;
34
+ private silenceRemainingMs;
35
+ private warmingMessage;
36
+ private timer;
37
+ constructor(ui: TUI | undefined, cancelHint: string);
38
+ private ensureAnimating;
39
+ /** Stop timers. The owner removes the panel from its container to collapse it. */
40
+ dispose(): void;
41
+ /** No cached render state to clear; every `render()` recomputes from live state. */
42
+ invalidate(): void;
43
+ setWarming(message: string): void;
44
+ startListening(): void;
45
+ beginSilence(totalMs: number): void;
46
+ /** Speech resumed before cutoff: return to the listening phase. */
47
+ endSilence(): void;
48
+ setTranscribing(): void;
49
+ pushLevel(rms: number): void;
50
+ setPartial(text: string): void;
51
+ private renderWaveform;
52
+ private renderPartial;
53
+ private elapsedLabel;
54
+ render(width: number): string[];
55
+ }
56
+ //# sourceMappingURL=voice-panel.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"voice-panel.d.ts","sourceRoot":"","sources":["../../../../src/modes/interactive/components/voice-panel.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,GAAG,EAAE,MAAM,0BAA0B,CAAC;AAI/D,MAAM,MAAM,eAAe,GAAG,SAAS,GAAG,WAAW,GAAG,SAAS,GAAG,cAAc,CAAC;AAgCnF,qBAAa,UAAW,YAAW,SAAS;IAW1C,OAAO,CAAC,QAAQ,CAAC,EAAE;IACnB,OAAO,CAAC,QAAQ,CAAC,UAAU;IAX5B,OAAO,CAAC,KAAK,CAA8B;IAC3C,OAAO,CAAC,KAAK,CAAK;IAClB,OAAO,CAAC,MAAM,CAAgB;IAC9B,OAAO,CAAC,OAAO,CAAM;IACrB,OAAO,CAAC,gBAAgB,CAAK;IAC7B,OAAO,CAAC,kBAAkB,CAAK;IAC/B,OAAO,CAAC,cAAc,CAA6B;IACnD,OAAO,CAAC,KAAK,CAA6C;IAE1D,YACkB,EAAE,EAAE,GAAG,GAAG,SAAS,EACnB,UAAU,EAAE,MAAM,EAChC;IAIJ,OAAO,CAAC,eAAe;IAWvB,kFAAkF;IAClF,OAAO,IAAI,IAAI,CAKd;IAED,oFAAoF;IACpF,UAAU,IAAI,IAAI,CAAG;IAIrB,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAKhC;IAED,cAAc,IAAI,IAAI,CAOrB;IAED,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAKlC;IAED,mEAAmE;IACnE,UAAU,IAAI,IAAI,CAKjB;IAED,eAAe,IAAI,IAAI,CAItB;IAID,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAO3B;IAED,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAI7B;IAID,OAAO,CAAC,cAAc;IAQtB,OAAO,CAAC,aAAa;IAOrB,OAAO,CAAC,YAAY;IAOpB,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAgC9B;CACD","sourcesContent":["/**\n * Multi-line voice-input status panel.\n *\n * Renders the live state of a `voicetools serve` capture across its phases so\n * the user always sees what's happening:\n *\n * ```text\n * πŸŽ™ Listening Β· 0:03\n * ▁▂▃▅▇▆▄▂▁▂▄▆▇▅▃▂▁ ← scrolling level history from LEVEL events\n * β€Ί and so my fellow ameri▏ ← live partial transcript (PARTIAL), dim/tentative\n * ctrl+r cancel ← dim key footer\n * ```\n *\n * Phases:\n * - `warming` spinner while the model loads (first press only)\n * - `listening` mic glyph + elapsed timer + waveform + growing partial text\n * - `silence` trailing-silence countdown; waveform dims\n * - `transcribing` spinner while the final decode runs\n *\n * The component self-animates (spinner, timer, countdown) on an internal timer\n * and calls `ui.requestRender()`; the owner feeds it protocol events via\n * `pushLevel` / `setPartial` / `beginSilence` etc. and disposes it on collapse.\n */\n\nimport type { Component, TUI } from \"@kolisachint/hoocode-tui\";\nimport { visibleWidth } from \"@kolisachint/hoocode-tui\";\nimport { theme } from \"../theme/theme.js\";\n\nexport type VoicePanelPhase = \"warming\" | \"listening\" | \"silence\" | \"transcribing\";\n\nconst SPINNER_FRAMES = [\"β ‹\", \"β ™\", \"β Ή\", \"β Έ\", \"β Ό\", \"β ΄\", \"β ¦\", \"β §\", \"β ‡\", \"⠏\"];\n// 8 filled levels; index 0 renders as a baseline dot so an empty history still\n// reads as \"a track\", not a blank line.\nconst WAVE_LEVELS = [\"▁\", \"▁\", \"β–‚\", \"β–ƒ\", \"β–„\", \"β–…\", \"β–†\", \"β–‡\", \"β–ˆ\"];\nconst TICK_MS = 100;\nconst HISTORY_CAP = 64;\n// RMS from voicetools' vad::rms sits well below 1.0 for speech; this gain maps a\n// normal speaking level to roughly the top of the meter without clipping constantly.\nconst LEVEL_GAIN = 6;\n// During trailing silence the binary keeps emitting low LEVELs; only an RMS above\n// this counts as the speaker resuming, so we don't cancel the countdown on noise.\nconst RESUME_RMS = 0.02;\n\n/** Map an RMS energy to a waveform block glyph. */\nfunction levelGlyph(rms: number): string {\n\tconst norm = Math.max(0, Math.min(1, rms * LEVEL_GAIN));\n\tconst idx = Math.round(norm * (WAVE_LEVELS.length - 1));\n\treturn WAVE_LEVELS[idx] ?? WAVE_LEVELS[0]!;\n}\n\n/** Keep the tail (most recent chars) of `text` within `max` visible columns. */\nfunction clampTail(text: string, max: number): string {\n\tif (max <= 0) return \"\";\n\tif (visibleWidth(text) <= max) return text;\n\t// Partial transcripts are effectively plain text; slice by chars from the end\n\t// and prefix an ellipsis so the newest words stay visible as it grows.\n\tconst tail = text.slice(-(max - 1));\n\treturn `…${tail}`;\n}\n\nexport class VoicePanel implements Component {\n\tprivate phase: VoicePanelPhase = \"warming\";\n\tprivate frame = 0;\n\tprivate levels: number[] = [];\n\tprivate partial = \"\";\n\tprivate listeningStartMs = 0;\n\tprivate silenceRemainingMs = 0;\n\tprivate warmingMessage = \"Warming up voice input…\";\n\tprivate timer: ReturnType<typeof setInterval> | undefined;\n\n\tconstructor(\n\t\tprivate readonly ui: TUI | undefined,\n\t\tprivate readonly cancelHint: string,\n\t) {}\n\n\t// ---- lifecycle -------------------------------------------------------\n\n\tprivate ensureAnimating(): void {\n\t\tif (this.timer) return;\n\t\tthis.timer = setInterval(() => {\n\t\t\tthis.frame = (this.frame + 1) % SPINNER_FRAMES.length;\n\t\t\tif (this.phase === \"silence\" && this.silenceRemainingMs > 0) {\n\t\t\t\tthis.silenceRemainingMs = Math.max(0, this.silenceRemainingMs - TICK_MS);\n\t\t\t}\n\t\t\tthis.ui?.requestRender();\n\t\t}, TICK_MS);\n\t}\n\n\t/** Stop timers. The owner removes the panel from its container to collapse it. */\n\tdispose(): void {\n\t\tif (this.timer) {\n\t\t\tclearInterval(this.timer);\n\t\t\tthis.timer = undefined;\n\t\t}\n\t}\n\n\t/** No cached render state to clear; every `render()` recomputes from live state. */\n\tinvalidate(): void {}\n\n\t// ---- phase transitions ----------------------------------------------\n\n\tsetWarming(message: string): void {\n\t\tthis.phase = \"warming\";\n\t\tthis.warmingMessage = message;\n\t\tthis.ensureAnimating();\n\t\tthis.ui?.requestRender();\n\t}\n\n\tstartListening(): void {\n\t\tthis.phase = \"listening\";\n\t\tthis.levels = [];\n\t\tthis.partial = \"\";\n\t\tthis.listeningStartMs = Date.now();\n\t\tthis.ensureAnimating();\n\t\tthis.ui?.requestRender();\n\t}\n\n\tbeginSilence(totalMs: number): void {\n\t\tthis.phase = \"silence\";\n\t\tthis.silenceRemainingMs = totalMs;\n\t\tthis.ensureAnimating();\n\t\tthis.ui?.requestRender();\n\t}\n\n\t/** Speech resumed before cutoff: return to the listening phase. */\n\tendSilence(): void {\n\t\tif (this.phase === \"silence\") {\n\t\t\tthis.phase = \"listening\";\n\t\t\tthis.ui?.requestRender();\n\t\t}\n\t}\n\n\tsetTranscribing(): void {\n\t\tthis.phase = \"transcribing\";\n\t\tthis.ensureAnimating();\n\t\tthis.ui?.requestRender();\n\t}\n\n\t// ---- data feed -------------------------------------------------------\n\n\tpushLevel(rms: number): void {\n\t\tthis.levels.push(rms);\n\t\tif (this.levels.length > HISTORY_CAP) this.levels.shift();\n\t\t// Low levels keep flowing during the silence window; only a loud one means\n\t\t// the speaker resumed and the countdown should be abandoned.\n\t\tif (this.phase === \"silence\" && rms > RESUME_RMS) this.endSilence();\n\t\tthis.ui?.requestRender();\n\t}\n\n\tsetPartial(text: string): void {\n\t\tthis.partial = text;\n\t\tif (this.phase === \"silence\") this.endSilence();\n\t\tthis.ui?.requestRender();\n\t}\n\n\t// ---- rendering -------------------------------------------------------\n\n\tprivate renderWaveform(width: number): string {\n\t\tconst cells = Math.max(1, width - 2);\n\t\tconst recent = this.levels.slice(-cells);\n\t\tconst pad = cells - recent.length;\n\t\tconst glyphs = \" \".repeat(Math.max(0, pad)) + recent.map(levelGlyph).join(\"\");\n\t\treturn this.phase === \"silence\" ? theme.fg(\"dim\", glyphs) : theme.fg(\"accent\", glyphs);\n\t}\n\n\tprivate renderPartial(width: number): string | undefined {\n\t\tif (!this.partial) return undefined;\n\t\tconst body = clampTail(this.partial, Math.max(1, width - 4));\n\t\tconst cursor = this.phase === \"transcribing\" ? \"\" : theme.blink(theme.fg(\"accent\", \"▏\"));\n\t\treturn ` ${theme.fg(\"muted\", \"β€Ί\")} ${theme.fg(\"dim\", body)}${cursor}`;\n\t}\n\n\tprivate elapsedLabel(): string {\n\t\tconst secs = Math.floor((Date.now() - this.listeningStartMs) / 1000);\n\t\tconst m = Math.floor(secs / 60);\n\t\tconst s = secs % 60;\n\t\treturn `${m}:${s.toString().padStart(2, \"0\")}`;\n\t}\n\n\trender(width: number): string[] {\n\t\tconst spinner = SPINNER_FRAMES[this.frame] ?? SPINNER_FRAMES[0]!;\n\t\tconst footer = theme.fg(\"dim\", ` ${this.cancelHint}`);\n\t\tconst lines: string[] = [\"\"];\n\n\t\tif (this.phase === \"warming\") {\n\t\t\tlines.push(` ${theme.fg(\"accent\", spinner)} ${theme.fg(\"muted\", this.warmingMessage)}`);\n\t\t\tlines.push(footer);\n\t\t\treturn lines;\n\t\t}\n\n\t\tif (this.phase === \"transcribing\") {\n\t\t\tlines.push(` ${theme.fg(\"accent\", spinner)} ${theme.fg(\"accent\", \"Transcribing…\")}`);\n\t\t\tconst partial = this.renderPartial(width);\n\t\t\tif (partial) lines.push(partial);\n\t\t\tlines.push(footer);\n\t\t\treturn lines;\n\t\t}\n\n\t\t// listening / silence\n\t\tconst mic = theme.fg(\"error\", \"πŸŽ™\");\n\t\tlet header = `${mic} ${theme.fg(\"accent\", \"Listening\")} ${theme.fg(\"dim\", `Β· ${this.elapsedLabel()}`)}`;\n\t\tif (this.phase === \"silence\") {\n\t\t\tconst remaining = (this.silenceRemainingMs / 1000).toFixed(1);\n\t\t\theader += ` ${theme.fg(\"warning\", `Β· cutting off in ${remaining}s`)}`;\n\t\t}\n\t\tlines.push(` ${header}`);\n\t\tlines.push(` ${this.renderWaveform(width)}`);\n\t\tconst partial = this.renderPartial(width);\n\t\tif (partial) lines.push(partial);\n\t\tlines.push(footer);\n\t\treturn lines;\n\t}\n}\n"]}
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Multi-line voice-input status panel.
3
+ *
4
+ * Renders the live state of a `voicetools serve` capture across its phases so
5
+ * the user always sees what's happening:
6
+ *
7
+ * ```text
8
+ * πŸŽ™ Listening Β· 0:03
9
+ * ▁▂▃▅▇▆▄▂▁▂▄▆▇▅▃▂▁ ← scrolling level history from LEVEL events
10
+ * β€Ί and so my fellow ameri▏ ← live partial transcript (PARTIAL), dim/tentative
11
+ * ctrl+r cancel ← dim key footer
12
+ * ```
13
+ *
14
+ * Phases:
15
+ * - `warming` spinner while the model loads (first press only)
16
+ * - `listening` mic glyph + elapsed timer + waveform + growing partial text
17
+ * - `silence` trailing-silence countdown; waveform dims
18
+ * - `transcribing` spinner while the final decode runs
19
+ *
20
+ * The component self-animates (spinner, timer, countdown) on an internal timer
21
+ * and calls `ui.requestRender()`; the owner feeds it protocol events via
22
+ * `pushLevel` / `setPartial` / `beginSilence` etc. and disposes it on collapse.
23
+ */
24
+ import { visibleWidth } from "@kolisachint/hoocode-tui";
25
+ import { theme } from "../theme/theme.js";
26
+ const SPINNER_FRAMES = ["β ‹", "β ™", "β Ή", "β Έ", "β Ό", "β ΄", "β ¦", "β §", "β ‡", "⠏"];
27
+ // 8 filled levels; index 0 renders as a baseline dot so an empty history still
28
+ // reads as "a track", not a blank line.
29
+ const WAVE_LEVELS = ["▁", "▁", "β–‚", "β–ƒ", "β–„", "β–…", "β–†", "β–‡", "β–ˆ"];
30
+ const TICK_MS = 100;
31
+ const HISTORY_CAP = 64;
32
+ // RMS from voicetools' vad::rms sits well below 1.0 for speech; this gain maps a
33
+ // normal speaking level to roughly the top of the meter without clipping constantly.
34
+ const LEVEL_GAIN = 6;
35
+ // During trailing silence the binary keeps emitting low LEVELs; only an RMS above
36
+ // this counts as the speaker resuming, so we don't cancel the countdown on noise.
37
+ const RESUME_RMS = 0.02;
38
+ /** Map an RMS energy to a waveform block glyph. */
39
+ function levelGlyph(rms) {
40
+ const norm = Math.max(0, Math.min(1, rms * LEVEL_GAIN));
41
+ const idx = Math.round(norm * (WAVE_LEVELS.length - 1));
42
+ return WAVE_LEVELS[idx] ?? WAVE_LEVELS[0];
43
+ }
44
+ /** Keep the tail (most recent chars) of `text` within `max` visible columns. */
45
+ function clampTail(text, max) {
46
+ if (max <= 0)
47
+ return "";
48
+ if (visibleWidth(text) <= max)
49
+ return text;
50
+ // Partial transcripts are effectively plain text; slice by chars from the end
51
+ // and prefix an ellipsis so the newest words stay visible as it grows.
52
+ const tail = text.slice(-(max - 1));
53
+ return `…${tail}`;
54
+ }
55
+ export class VoicePanel {
56
+ ui;
57
+ cancelHint;
58
+ phase = "warming";
59
+ frame = 0;
60
+ levels = [];
61
+ partial = "";
62
+ listeningStartMs = 0;
63
+ silenceRemainingMs = 0;
64
+ warmingMessage = "Warming up voice input…";
65
+ timer;
66
+ constructor(ui, cancelHint) {
67
+ this.ui = ui;
68
+ this.cancelHint = cancelHint;
69
+ }
70
+ // ---- lifecycle -------------------------------------------------------
71
+ ensureAnimating() {
72
+ if (this.timer)
73
+ return;
74
+ this.timer = setInterval(() => {
75
+ this.frame = (this.frame + 1) % SPINNER_FRAMES.length;
76
+ if (this.phase === "silence" && this.silenceRemainingMs > 0) {
77
+ this.silenceRemainingMs = Math.max(0, this.silenceRemainingMs - TICK_MS);
78
+ }
79
+ this.ui?.requestRender();
80
+ }, TICK_MS);
81
+ }
82
+ /** Stop timers. The owner removes the panel from its container to collapse it. */
83
+ dispose() {
84
+ if (this.timer) {
85
+ clearInterval(this.timer);
86
+ this.timer = undefined;
87
+ }
88
+ }
89
+ /** No cached render state to clear; every `render()` recomputes from live state. */
90
+ invalidate() { }
91
+ // ---- phase transitions ----------------------------------------------
92
+ setWarming(message) {
93
+ this.phase = "warming";
94
+ this.warmingMessage = message;
95
+ this.ensureAnimating();
96
+ this.ui?.requestRender();
97
+ }
98
+ startListening() {
99
+ this.phase = "listening";
100
+ this.levels = [];
101
+ this.partial = "";
102
+ this.listeningStartMs = Date.now();
103
+ this.ensureAnimating();
104
+ this.ui?.requestRender();
105
+ }
106
+ beginSilence(totalMs) {
107
+ this.phase = "silence";
108
+ this.silenceRemainingMs = totalMs;
109
+ this.ensureAnimating();
110
+ this.ui?.requestRender();
111
+ }
112
+ /** Speech resumed before cutoff: return to the listening phase. */
113
+ endSilence() {
114
+ if (this.phase === "silence") {
115
+ this.phase = "listening";
116
+ this.ui?.requestRender();
117
+ }
118
+ }
119
+ setTranscribing() {
120
+ this.phase = "transcribing";
121
+ this.ensureAnimating();
122
+ this.ui?.requestRender();
123
+ }
124
+ // ---- data feed -------------------------------------------------------
125
+ pushLevel(rms) {
126
+ this.levels.push(rms);
127
+ if (this.levels.length > HISTORY_CAP)
128
+ this.levels.shift();
129
+ // Low levels keep flowing during the silence window; only a loud one means
130
+ // the speaker resumed and the countdown should be abandoned.
131
+ if (this.phase === "silence" && rms > RESUME_RMS)
132
+ this.endSilence();
133
+ this.ui?.requestRender();
134
+ }
135
+ setPartial(text) {
136
+ this.partial = text;
137
+ if (this.phase === "silence")
138
+ this.endSilence();
139
+ this.ui?.requestRender();
140
+ }
141
+ // ---- rendering -------------------------------------------------------
142
+ renderWaveform(width) {
143
+ const cells = Math.max(1, width - 2);
144
+ const recent = this.levels.slice(-cells);
145
+ const pad = cells - recent.length;
146
+ const glyphs = " ".repeat(Math.max(0, pad)) + recent.map(levelGlyph).join("");
147
+ return this.phase === "silence" ? theme.fg("dim", glyphs) : theme.fg("accent", glyphs);
148
+ }
149
+ renderPartial(width) {
150
+ if (!this.partial)
151
+ return undefined;
152
+ const body = clampTail(this.partial, Math.max(1, width - 4));
153
+ const cursor = this.phase === "transcribing" ? "" : theme.blink(theme.fg("accent", "▏"));
154
+ return ` ${theme.fg("muted", "β€Ί")} ${theme.fg("dim", body)}${cursor}`;
155
+ }
156
+ elapsedLabel() {
157
+ const secs = Math.floor((Date.now() - this.listeningStartMs) / 1000);
158
+ const m = Math.floor(secs / 60);
159
+ const s = secs % 60;
160
+ return `${m}:${s.toString().padStart(2, "0")}`;
161
+ }
162
+ render(width) {
163
+ const spinner = SPINNER_FRAMES[this.frame] ?? SPINNER_FRAMES[0];
164
+ const footer = theme.fg("dim", ` ${this.cancelHint}`);
165
+ const lines = [""];
166
+ if (this.phase === "warming") {
167
+ lines.push(` ${theme.fg("accent", spinner)} ${theme.fg("muted", this.warmingMessage)}`);
168
+ lines.push(footer);
169
+ return lines;
170
+ }
171
+ if (this.phase === "transcribing") {
172
+ lines.push(` ${theme.fg("accent", spinner)} ${theme.fg("accent", "Transcribing…")}`);
173
+ const partial = this.renderPartial(width);
174
+ if (partial)
175
+ lines.push(partial);
176
+ lines.push(footer);
177
+ return lines;
178
+ }
179
+ // listening / silence
180
+ const mic = theme.fg("error", "πŸŽ™");
181
+ let header = `${mic} ${theme.fg("accent", "Listening")} ${theme.fg("dim", `Β· ${this.elapsedLabel()}`)}`;
182
+ if (this.phase === "silence") {
183
+ const remaining = (this.silenceRemainingMs / 1000).toFixed(1);
184
+ header += ` ${theme.fg("warning", `Β· cutting off in ${remaining}s`)}`;
185
+ }
186
+ lines.push(` ${header}`);
187
+ lines.push(` ${this.renderWaveform(width)}`);
188
+ const partial = this.renderPartial(width);
189
+ if (partial)
190
+ lines.push(partial);
191
+ lines.push(footer);
192
+ return lines;
193
+ }
194
+ }
195
+ //# sourceMappingURL=voice-panel.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"voice-panel.js","sourceRoot":"","sources":["../../../../src/modes/interactive/components/voice-panel.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Multi-line voice-input status panel.\n *\n * Renders the live state of a `voicetools serve` capture across its phases so\n * the user always sees what's happening:\n *\n * ```text\n * πŸŽ™ Listening Β· 0:03\n * ▁▂▃▅▇▆▄▂▁▂▄▆▇▅▃▂▁ ← scrolling level history from LEVEL events\n * β€Ί and so my fellow ameri▏ ← live partial transcript (PARTIAL), dim/tentative\n * ctrl+r cancel ← dim key footer\n * ```\n *\n * Phases:\n * - `warming` spinner while the model loads (first press only)\n * - `listening` mic glyph + elapsed timer + waveform + growing partial text\n * - `silence` trailing-silence countdown; waveform dims\n * - `transcribing` spinner while the final decode runs\n *\n * The component self-animates (spinner, timer, countdown) on an internal timer\n * and calls `ui.requestRender()`; the owner feeds it protocol events via\n * `pushLevel` / `setPartial` / `beginSilence` etc. and disposes it on collapse.\n */\n\nimport type { Component, TUI } from \"@kolisachint/hoocode-tui\";\nimport { visibleWidth } from \"@kolisachint/hoocode-tui\";\nimport { theme } from \"../theme/theme.js\";\n\nexport type VoicePanelPhase = \"warming\" | \"listening\" | \"silence\" | \"transcribing\";\n\nconst SPINNER_FRAMES = [\"β ‹\", \"β ™\", \"β Ή\", \"β Έ\", \"β Ό\", \"β ΄\", \"β ¦\", \"β §\", \"β ‡\", \"⠏\"];\n// 8 filled levels; index 0 renders as a baseline dot so an empty history still\n// reads as \"a track\", not a blank line.\nconst WAVE_LEVELS = [\"▁\", \"▁\", \"β–‚\", \"β–ƒ\", \"β–„\", \"β–…\", \"β–†\", \"β–‡\", \"β–ˆ\"];\nconst TICK_MS = 100;\nconst HISTORY_CAP = 64;\n// RMS from voicetools' vad::rms sits well below 1.0 for speech; this gain maps a\n// normal speaking level to roughly the top of the meter without clipping constantly.\nconst LEVEL_GAIN = 6;\n// During trailing silence the binary keeps emitting low LEVELs; only an RMS above\n// this counts as the speaker resuming, so we don't cancel the countdown on noise.\nconst RESUME_RMS = 0.02;\n\n/** Map an RMS energy to a waveform block glyph. */\nfunction levelGlyph(rms: number): string {\n\tconst norm = Math.max(0, Math.min(1, rms * LEVEL_GAIN));\n\tconst idx = Math.round(norm * (WAVE_LEVELS.length - 1));\n\treturn WAVE_LEVELS[idx] ?? WAVE_LEVELS[0]!;\n}\n\n/** Keep the tail (most recent chars) of `text` within `max` visible columns. */\nfunction clampTail(text: string, max: number): string {\n\tif (max <= 0) return \"\";\n\tif (visibleWidth(text) <= max) return text;\n\t// Partial transcripts are effectively plain text; slice by chars from the end\n\t// and prefix an ellipsis so the newest words stay visible as it grows.\n\tconst tail = text.slice(-(max - 1));\n\treturn `…${tail}`;\n}\n\nexport class VoicePanel implements Component {\n\tprivate phase: VoicePanelPhase = \"warming\";\n\tprivate frame = 0;\n\tprivate levels: number[] = [];\n\tprivate partial = \"\";\n\tprivate listeningStartMs = 0;\n\tprivate silenceRemainingMs = 0;\n\tprivate warmingMessage = \"Warming up voice input…\";\n\tprivate timer: ReturnType<typeof setInterval> | undefined;\n\n\tconstructor(\n\t\tprivate readonly ui: TUI | undefined,\n\t\tprivate readonly cancelHint: string,\n\t) {}\n\n\t// ---- lifecycle -------------------------------------------------------\n\n\tprivate ensureAnimating(): void {\n\t\tif (this.timer) return;\n\t\tthis.timer = setInterval(() => {\n\t\t\tthis.frame = (this.frame + 1) % SPINNER_FRAMES.length;\n\t\t\tif (this.phase === \"silence\" && this.silenceRemainingMs > 0) {\n\t\t\t\tthis.silenceRemainingMs = Math.max(0, this.silenceRemainingMs - TICK_MS);\n\t\t\t}\n\t\t\tthis.ui?.requestRender();\n\t\t}, TICK_MS);\n\t}\n\n\t/** Stop timers. The owner removes the panel from its container to collapse it. */\n\tdispose(): void {\n\t\tif (this.timer) {\n\t\t\tclearInterval(this.timer);\n\t\t\tthis.timer = undefined;\n\t\t}\n\t}\n\n\t/** No cached render state to clear; every `render()` recomputes from live state. */\n\tinvalidate(): void {}\n\n\t// ---- phase transitions ----------------------------------------------\n\n\tsetWarming(message: string): void {\n\t\tthis.phase = \"warming\";\n\t\tthis.warmingMessage = message;\n\t\tthis.ensureAnimating();\n\t\tthis.ui?.requestRender();\n\t}\n\n\tstartListening(): void {\n\t\tthis.phase = \"listening\";\n\t\tthis.levels = [];\n\t\tthis.partial = \"\";\n\t\tthis.listeningStartMs = Date.now();\n\t\tthis.ensureAnimating();\n\t\tthis.ui?.requestRender();\n\t}\n\n\tbeginSilence(totalMs: number): void {\n\t\tthis.phase = \"silence\";\n\t\tthis.silenceRemainingMs = totalMs;\n\t\tthis.ensureAnimating();\n\t\tthis.ui?.requestRender();\n\t}\n\n\t/** Speech resumed before cutoff: return to the listening phase. */\n\tendSilence(): void {\n\t\tif (this.phase === \"silence\") {\n\t\t\tthis.phase = \"listening\";\n\t\t\tthis.ui?.requestRender();\n\t\t}\n\t}\n\n\tsetTranscribing(): void {\n\t\tthis.phase = \"transcribing\";\n\t\tthis.ensureAnimating();\n\t\tthis.ui?.requestRender();\n\t}\n\n\t// ---- data feed -------------------------------------------------------\n\n\tpushLevel(rms: number): void {\n\t\tthis.levels.push(rms);\n\t\tif (this.levels.length > HISTORY_CAP) this.levels.shift();\n\t\t// Low levels keep flowing during the silence window; only a loud one means\n\t\t// the speaker resumed and the countdown should be abandoned.\n\t\tif (this.phase === \"silence\" && rms > RESUME_RMS) this.endSilence();\n\t\tthis.ui?.requestRender();\n\t}\n\n\tsetPartial(text: string): void {\n\t\tthis.partial = text;\n\t\tif (this.phase === \"silence\") this.endSilence();\n\t\tthis.ui?.requestRender();\n\t}\n\n\t// ---- rendering -------------------------------------------------------\n\n\tprivate renderWaveform(width: number): string {\n\t\tconst cells = Math.max(1, width - 2);\n\t\tconst recent = this.levels.slice(-cells);\n\t\tconst pad = cells - recent.length;\n\t\tconst glyphs = \" \".repeat(Math.max(0, pad)) + recent.map(levelGlyph).join(\"\");\n\t\treturn this.phase === \"silence\" ? theme.fg(\"dim\", glyphs) : theme.fg(\"accent\", glyphs);\n\t}\n\n\tprivate renderPartial(width: number): string | undefined {\n\t\tif (!this.partial) return undefined;\n\t\tconst body = clampTail(this.partial, Math.max(1, width - 4));\n\t\tconst cursor = this.phase === \"transcribing\" ? \"\" : theme.blink(theme.fg(\"accent\", \"▏\"));\n\t\treturn ` ${theme.fg(\"muted\", \"β€Ί\")} ${theme.fg(\"dim\", body)}${cursor}`;\n\t}\n\n\tprivate elapsedLabel(): string {\n\t\tconst secs = Math.floor((Date.now() - this.listeningStartMs) / 1000);\n\t\tconst m = Math.floor(secs / 60);\n\t\tconst s = secs % 60;\n\t\treturn `${m}:${s.toString().padStart(2, \"0\")}`;\n\t}\n\n\trender(width: number): string[] {\n\t\tconst spinner = SPINNER_FRAMES[this.frame] ?? SPINNER_FRAMES[0]!;\n\t\tconst footer = theme.fg(\"dim\", ` ${this.cancelHint}`);\n\t\tconst lines: string[] = [\"\"];\n\n\t\tif (this.phase === \"warming\") {\n\t\t\tlines.push(` ${theme.fg(\"accent\", spinner)} ${theme.fg(\"muted\", this.warmingMessage)}`);\n\t\t\tlines.push(footer);\n\t\t\treturn lines;\n\t\t}\n\n\t\tif (this.phase === \"transcribing\") {\n\t\t\tlines.push(` ${theme.fg(\"accent\", spinner)} ${theme.fg(\"accent\", \"Transcribing…\")}`);\n\t\t\tconst partial = this.renderPartial(width);\n\t\t\tif (partial) lines.push(partial);\n\t\t\tlines.push(footer);\n\t\t\treturn lines;\n\t\t}\n\n\t\t// listening / silence\n\t\tconst mic = theme.fg(\"error\", \"πŸŽ™\");\n\t\tlet header = `${mic} ${theme.fg(\"accent\", \"Listening\")} ${theme.fg(\"dim\", `Β· ${this.elapsedLabel()}`)}`;\n\t\tif (this.phase === \"silence\") {\n\t\t\tconst remaining = (this.silenceRemainingMs / 1000).toFixed(1);\n\t\t\theader += ` ${theme.fg(\"warning\", `Β· cutting off in ${remaining}s`)}`;\n\t\t}\n\t\tlines.push(` ${header}`);\n\t\tlines.push(` ${this.renderWaveform(width)}`);\n\t\tconst partial = this.renderPartial(width);\n\t\tif (partial) lines.push(partial);\n\t\tlines.push(footer);\n\t\treturn lines;\n\t}\n}\n"]}
@@ -34,8 +34,14 @@ export declare class InteractiveMode {
34
34
  private editor;
35
35
  private editorComponentFactory;
36
36
  private voiceSession;
37
+ private voiceDaemon;
38
+ private voiceDaemonUnsupported;
37
39
  /** True while the voicetools binary is being resolved/downloaded before a session starts. */
38
40
  private voiceStarting;
41
+ /** True while a capture (daemon or legacy) is in flight. */
42
+ private voiceActive;
43
+ /** The live multi-line status panel for the current capture (undefined when idle). */
44
+ private voicePanel;
39
45
  private autocompleteProvider;
40
46
  private autocompleteProviderWrappers;
41
47
  private fdPath;
@@ -294,12 +300,42 @@ export declare class InteractiveMode {
294
300
  * we update the previous status line instead of appending new ones to avoid log spam.
295
301
  */
296
302
  /**
297
- * Toggle voice-to-text capture. First press spawns `voicetools`, streams
298
- * decoded segments into the editor via bracketed paste, and reports state
299
- * through the status line. Pressing the shortcut again stops early.
303
+ * Toggle voice-to-text capture.
304
+ *
305
+ * Prefers a persistent `voicetools serve` daemon: the first press probes for
306
+ * support and (if found) loads models once, showing a "warming up" spinner;
307
+ * every later press reuses the already-warm daemon and jumps straight to
308
+ * Listening. Binaries without `serve` support fall back to spawning
309
+ * `voicetools transcribe` per press, same as before. Pressing the shortcut
310
+ * again while listening (or while still warming up) cancels.
300
311
  */
301
312
  private toggleVoiceTranscribe;
313
+ /**
314
+ * Build the (stable, reused-across-captures) handlers for the daemon.
315
+ *
316
+ * Every handler bails out once `voiceActive` is false: CANCEL is a soft
317
+ * request (unlike the legacy path's `proc.kill()`, it doesn't sever the
318
+ * pipe), so the daemon can still have a trailing PARTIAL/FINAL/DONE for the
319
+ * just-cancelled capture in flight when the user presses cancel. Without
320
+ * this guard that stale text would land in the editor after the panel had
321
+ * already collapsed.
322
+ *
323
+ * v0.1.4 serve streams `PARTIAL <full growing hypothesis>` (live preview,
324
+ * never committed) and ends with a single `FINAL <complete text>` (the one
325
+ * commit to the editor). `SEGMENT` is still handled for the legacy
326
+ * transcribe path and any binary that streams committed chunks directly.
327
+ */
328
+ private buildVoiceDaemonHandlers;
329
+ /** Inject decoded text into the editor via bracketed paste (with a trailing space). */
330
+ private commitVoiceText;
331
+ private beginDaemonVoiceCapture;
332
+ private beginLegacyVoiceCapture;
302
333
  private resolveVoiceBin;
334
+ /** Create (or reuse) the voice panel and mount it in the status container. */
335
+ private showVoicePanel;
336
+ private showVoiceWarming;
337
+ /** Collapse the voice panel back to nothing (idle) and stop its animation. */
338
+ private resetVoiceUI;
303
339
  private showStatus;
304
340
  private addMessageToChat;
305
341
  /**