@kolisachint/hoocode-agent 0.4.104 β 0.4.105
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 +18 -0
- package/dist/modes/interactive/components/voice-panel.d.ts +56 -0
- package/dist/modes/interactive/components/voice-panel.d.ts.map +1 -0
- package/dist/modes/interactive/components/voice-panel.js +195 -0
- package/dist/modes/interactive/components/voice-panel.js.map +1 -0
- package/dist/modes/interactive/interactive-mode.d.ts +39 -3
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +247 -40
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/modes/interactive/voice-transcribe.d.ts +68 -1
- package/dist/modes/interactive/voice-transcribe.d.ts.map +1 -1
- package/dist/modes/interactive/voice-transcribe.js +154 -0
- package/dist/modes/interactive/voice-transcribe.js.map +1 -1
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/with-deps/package.json +1 -1
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.4.105] - 2026-07-01
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Voice-to-text (`ctrl+r`) now uses `voicetools serve` when available: the
|
|
8
|
+
first press loads the model once and keeps it warm for the rest of the
|
|
9
|
+
session, so later presses skip the cold start and jump straight into
|
|
10
|
+
listening. Binaries that don't support `serve` fall back to the previous
|
|
11
|
+
per-press `transcribe` behavior automatically.
|
|
12
|
+
- Live voice-input panel with words-as-you-speak. With a streaming
|
|
13
|
+
`voicetools serve` (v0.1.4+), the multi-line panel shows the transcript
|
|
14
|
+
growing word by word as you talk (`PARTIAL`), alongside a mic glyph, an
|
|
15
|
+
elapsed timer, a scrolling waveform driven by `LEVEL` events, and a
|
|
16
|
+
shrinking "cutting off in Ns" countdown when trailing silence begins. The
|
|
17
|
+
finished utterance (`FINAL`) is committed to the editor in one piece and
|
|
18
|
+
the panel collapses. Older non-streaming binaries just show a spinner for
|
|
19
|
+
the batch phases; the committed text still lands the same way.
|
|
20
|
+
|
|
3
21
|
## [0.4.104] - 2026-07-01
|
|
4
22
|
|
|
5
23
|
### 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.
|
|
298
|
-
*
|
|
299
|
-
*
|
|
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
|
/**
|