@kolisachint/hoocode-agent 0.4.113 → 0.4.115

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 (147) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/core/agent-session.d.ts.map +1 -1
  3. package/dist/core/agent-session.js +1 -1
  4. package/dist/core/agent-session.js.map +1 -1
  5. package/dist/core/extensions/plugins/authoring.d.ts +51 -0
  6. package/dist/core/extensions/plugins/authoring.d.ts.map +1 -0
  7. package/dist/core/extensions/plugins/authoring.js +91 -0
  8. package/dist/core/extensions/plugins/authoring.js.map +1 -0
  9. package/dist/core/extensions/plugins/default-marketplace/.agents-plugin/marketplace.json +12 -0
  10. package/dist/core/extensions/plugins/default-marketplace/plugins/hello-world/.agents-plugin/plugin.json +6 -0
  11. package/dist/core/extensions/plugins/default-marketplace/plugins/hello-world/commands/hello.md +5 -0
  12. package/dist/core/extensions/plugins/default-marketplace/plugins/hello-world/skills/greet/SKILL.md +7 -0
  13. package/dist/core/extensions/plugins/formats/agents.d.ts +7 -0
  14. package/dist/core/extensions/plugins/formats/agents.d.ts.map +1 -0
  15. package/dist/core/extensions/plugins/formats/agents.js +14 -0
  16. package/dist/core/extensions/plugins/formats/agents.js.map +1 -0
  17. package/dist/core/extensions/plugins/formats/claude.d.ts +10 -0
  18. package/dist/core/extensions/plugins/formats/claude.d.ts.map +1 -0
  19. package/dist/core/extensions/plugins/formats/claude.js +17 -0
  20. package/dist/core/extensions/plugins/formats/claude.js.map +1 -0
  21. package/dist/core/extensions/plugins/formats/copilot.d.ts +23 -0
  22. package/dist/core/extensions/plugins/formats/copilot.d.ts.map +1 -0
  23. package/dist/core/extensions/plugins/formats/copilot.js +130 -0
  24. package/dist/core/extensions/plugins/formats/copilot.js.map +1 -0
  25. package/dist/core/extensions/plugins/formats/index.d.ts +41 -0
  26. package/dist/core/extensions/plugins/formats/index.d.ts.map +1 -0
  27. package/dist/core/extensions/plugins/formats/index.js +76 -0
  28. package/dist/core/extensions/plugins/formats/index.js.map +1 -0
  29. package/dist/core/extensions/plugins/formats/jsonManifest.d.ts +21 -0
  30. package/dist/core/extensions/plugins/formats/jsonManifest.d.ts.map +1 -0
  31. package/dist/core/extensions/plugins/formats/jsonManifest.js +97 -0
  32. package/dist/core/extensions/plugins/formats/jsonManifest.js.map +1 -0
  33. package/dist/core/extensions/plugins/formats/shared.d.ts +44 -0
  34. package/dist/core/extensions/plugins/formats/shared.d.ts.map +1 -0
  35. package/dist/core/extensions/plugins/formats/shared.js +107 -0
  36. package/dist/core/extensions/plugins/formats/shared.js.map +1 -0
  37. package/dist/core/extensions/plugins/formats/types.d.ts +118 -0
  38. package/dist/core/extensions/plugins/formats/types.d.ts.map +1 -0
  39. package/dist/core/extensions/plugins/formats/types.js +19 -0
  40. package/dist/core/extensions/plugins/formats/types.js.map +1 -0
  41. package/dist/core/extensions/plugins/install.d.ts +73 -0
  42. package/dist/core/extensions/plugins/install.d.ts.map +1 -0
  43. package/dist/core/extensions/plugins/install.js +173 -0
  44. package/dist/core/extensions/plugins/install.js.map +1 -0
  45. package/dist/core/extensions/plugins/manifest.d.ts +25 -7
  46. package/dist/core/extensions/plugins/manifest.d.ts.map +1 -1
  47. package/dist/core/extensions/plugins/manifest.js +16 -91
  48. package/dist/core/extensions/plugins/manifest.js.map +1 -1
  49. package/dist/core/extensions/plugins/marketplace.d.ts +5 -4
  50. package/dist/core/extensions/plugins/marketplace.d.ts.map +1 -1
  51. package/dist/core/extensions/plugins/marketplace.js +8 -13
  52. package/dist/core/extensions/plugins/marketplace.js.map +1 -1
  53. package/dist/core/settings-defaults.d.ts +2 -0
  54. package/dist/core/settings-defaults.d.ts.map +1 -1
  55. package/dist/core/settings-defaults.js +2 -0
  56. package/dist/core/settings-defaults.js.map +1 -1
  57. package/dist/core/settings-manager.d.ts +4 -0
  58. package/dist/core/settings-manager.d.ts.map +1 -1
  59. package/dist/core/settings-manager.js +16 -0
  60. package/dist/core/settings-manager.js.map +1 -1
  61. package/dist/core/settings-types.d.ts +2 -0
  62. package/dist/core/settings-types.d.ts.map +1 -1
  63. package/dist/core/settings-types.js.map +1 -1
  64. package/dist/core/subagent-depth.d.ts +13 -0
  65. package/dist/core/subagent-depth.d.ts.map +1 -1
  66. package/dist/core/subagent-depth.js +15 -0
  67. package/dist/core/subagent-depth.js.map +1 -1
  68. package/dist/core/subagent-pool.d.ts.map +1 -1
  69. package/dist/core/subagent-pool.js +4 -1
  70. package/dist/core/subagent-pool.js.map +1 -1
  71. package/dist/core/tools/find.d.ts +8 -3
  72. package/dist/core/tools/find.d.ts.map +1 -1
  73. package/dist/core/tools/find.js +189 -122
  74. package/dist/core/tools/find.js.map +1 -1
  75. package/dist/core/tools/index.d.ts +0 -4
  76. package/dist/core/tools/index.d.ts.map +1 -1
  77. package/dist/core/tools/index.js +2 -5
  78. package/dist/core/tools/index.js.map +1 -1
  79. package/dist/core/tools/plugin-tool-names.d.ts +21 -0
  80. package/dist/core/tools/plugin-tool-names.d.ts.map +1 -0
  81. package/dist/core/tools/plugin-tool-names.js +31 -0
  82. package/dist/core/tools/plugin-tool-names.js.map +1 -0
  83. package/dist/core/tools/plugins.d.ts +49 -0
  84. package/dist/core/tools/plugins.d.ts.map +1 -0
  85. package/dist/core/tools/plugins.js +186 -0
  86. package/dist/core/tools/plugins.js.map +1 -0
  87. package/dist/core/tools/propose-plugin.d.ts +36 -0
  88. package/dist/core/tools/propose-plugin.d.ts.map +1 -0
  89. package/dist/core/tools/propose-plugin.js +219 -0
  90. package/dist/core/tools/propose-plugin.js.map +1 -0
  91. package/dist/core/tools/subagent.d.ts.map +1 -1
  92. package/dist/core/tools/subagent.js +29 -3
  93. package/dist/core/tools/subagent.js.map +1 -1
  94. package/dist/core/warm-subagent-pool.d.ts.map +1 -1
  95. package/dist/core/warm-subagent-pool.js +4 -1
  96. package/dist/core/warm-subagent-pool.js.map +1 -1
  97. package/dist/extensions/core/marketplace.d.ts +7 -3
  98. package/dist/extensions/core/marketplace.d.ts.map +1 -1
  99. package/dist/extensions/core/marketplace.js +23 -67
  100. package/dist/extensions/core/marketplace.js.map +1 -1
  101. package/dist/extensions/core/mcp-deferred.d.ts +28 -0
  102. package/dist/extensions/core/mcp-deferred.d.ts.map +1 -0
  103. package/dist/extensions/core/mcp-deferred.js +53 -0
  104. package/dist/extensions/core/mcp-deferred.js.map +1 -0
  105. package/dist/extensions/core/mcp-loader.d.ts.map +1 -1
  106. package/dist/extensions/core/mcp-loader.js +132 -78
  107. package/dist/extensions/core/mcp-loader.js.map +1 -1
  108. package/dist/main.d.ts.map +1 -1
  109. package/dist/main.js +19 -1
  110. package/dist/main.js.map +1 -1
  111. package/dist/modes/interactive/brand.d.ts +41 -0
  112. package/dist/modes/interactive/brand.d.ts.map +1 -0
  113. package/dist/modes/interactive/brand.js +40 -0
  114. package/dist/modes/interactive/brand.js.map +1 -0
  115. package/dist/modes/interactive/components/footer.d.ts.map +1 -1
  116. package/dist/modes/interactive/components/footer.js +102 -131
  117. package/dist/modes/interactive/components/footer.js.map +1 -1
  118. package/dist/modes/interactive/components/task-panel.d.ts +6 -5
  119. package/dist/modes/interactive/components/task-panel.d.ts.map +1 -1
  120. package/dist/modes/interactive/components/task-panel.js +13 -9
  121. package/dist/modes/interactive/components/task-panel.js.map +1 -1
  122. package/dist/modes/interactive/resource-display.d.ts.map +1 -1
  123. package/dist/modes/interactive/resource-display.js +131 -86
  124. package/dist/modes/interactive/resource-display.js.map +1 -1
  125. package/dist/modes/interactive/theme/dark.json +1 -1
  126. package/dist/modes/interactive/theme/light.json +1 -1
  127. package/dist/modes/interactive/voice/voice-controller.d.ts.map +1 -1
  128. package/dist/modes/interactive/voice/voice-controller.js +15 -7
  129. package/dist/modes/interactive/voice/voice-controller.js.map +1 -1
  130. package/dist/modes/interactive/voice/voice-panel.d.ts +27 -8
  131. package/dist/modes/interactive/voice/voice-panel.d.ts.map +1 -1
  132. package/dist/modes/interactive/voice/voice-panel.js +89 -27
  133. package/dist/modes/interactive/voice/voice-panel.js.map +1 -1
  134. package/dist/utils/tools-manager.d.ts +8 -2
  135. package/dist/utils/tools-manager.d.ts.map +1 -1
  136. package/dist/utils/tools-manager.js +15 -6
  137. package/dist/utils/tools-manager.js.map +1 -1
  138. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  139. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  140. package/examples/extensions/minimal-mode.ts +1 -1
  141. package/examples/extensions/sandbox/package.json +1 -1
  142. package/examples/extensions/with-deps/package.json +1 -1
  143. package/package.json +5 -5
  144. package/dist/core/tools/glob.d.ts +0 -42
  145. package/dist/core/tools/glob.d.ts.map +0 -1
  146. package/dist/core/tools/glob.js +0 -432
  147. package/dist/core/tools/glob.js.map +0 -1
@@ -26,8 +26,11 @@ const VOICE_SILENCE_MS = 800;
26
26
  * Kept long enough that normal pacing between dictations (reading a reply,
27
27
  * typing) doesn't repeatedly hit the multi-second cold start. */
28
28
  const VOICE_IDLE_TIMEOUT_MS = 300_000;
29
- const VOICE_UNAVAILABLE_MESSAGE = "Voice input failed: voicetools binary unavailable and could not be downloaded. " +
30
- "Install it, set VOICETOOLS_BIN, or ensure a published release exists for this platform.";
29
+ const VOICE_UNAVAILABLE_MESSAGE = [
30
+ `Voice unavailable no voicetools binary for ${process.platform}-${process.arch} (and download failed).`,
31
+ " fix: set VOICETOOLS_BIN=/path/to/voicetools",
32
+ " or: install voicetools from its published release, then press the voice key again",
33
+ ].join("\n");
31
34
  export class VoiceController {
32
35
  deps;
33
36
  /** In-flight legacy (`voicetools transcribe`) capture. */
@@ -69,7 +72,7 @@ export class VoiceController {
69
72
  }
70
73
  if (this.daemonUnsupported) {
71
74
  this.starting = true;
72
- this.showWarming("Starting voice input...");
75
+ this.showWarming("Starting voice input", "first run downloads the voicetools binary and speech model");
73
76
  void this.resolveBin()
74
77
  .then((bin) => {
75
78
  if (!this.starting)
@@ -102,7 +105,7 @@ export class VoiceController {
102
105
  // the legacy fallback (it would just hit the same error) but leaves
103
106
  // daemon mode available to retry on the next press.
104
107
  this.starting = true;
105
- this.showWarming("Warming up voice input...");
108
+ this.showWarming("Warming up voice input", "first run downloads the voicetools binary and speech model");
106
109
  void this.resolveBin()
107
110
  .then(async (bin) => {
108
111
  if (!bin) {
@@ -288,7 +291,12 @@ export class VoiceController {
288
291
  const override = process.env.VOICETOOLS_BIN?.trim();
289
292
  if (override)
290
293
  return override;
291
- return ensureTool("voicetools", true);
294
+ // First run downloads the voicetools release archive; stream the byte counts
295
+ // into the panel so the wait shows a determinate progress bar, not a spinner.
296
+ return ensureTool("voicetools", true, (received, total) => {
297
+ if (this.starting)
298
+ this.panel?.setDownloadProgress(received, total);
299
+ });
292
300
  }
293
301
  /** Create (or reuse) the voice panel and mount it in the status container. */
294
302
  showPanel() {
@@ -300,8 +308,8 @@ export class VoiceController {
300
308
  this.deps.ui.requestRender();
301
309
  return this.panel;
302
310
  }
303
- showWarming(message) {
304
- this.showPanel().setWarming(message);
311
+ showWarming(message, detail) {
312
+ this.showPanel().setWarming(message, detail);
305
313
  }
306
314
  /** Collapse the voice panel back to nothing (idle) and stop its animation. */
307
315
  resetUI() {
@@ -1 +1 @@
1
- {"version":3,"file":"voice-controller.js","sourceRoot":"","sources":["../../../../src/modes/interactive/voice/voice-controller.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Voice-to-text capture control (ctrl+r / app.input.voiceTranscribe).\n *\n * Prefers a persistent `voicetools serve` daemon: the first press probes for\n * support and (if found) loads models once, showing a \"warming up\" spinner;\n * every later press reuses the already-warm daemon and jumps straight to\n * Listening. Binaries without `serve` support fall back to spawning\n * `voicetools transcribe` per press. Pressing the shortcut again while\n * listening (or while still warming up) cancels.\n */\n\nimport type { Container, TUI } from \"@kolisachint/hoocode-tui\";\nimport { ensureTool } from \"../../../utils/tools-manager.js\";\nimport { keyHint } from \"../components/keybinding-hints.js\";\nimport { VoicePanel } from \"./voice-panel.js\";\nimport { startVoiceTranscribe, VoiceDaemon, type VoiceDaemonHandlers, type VoiceSession } from \"./voice-transcribe.js\";\n\n// Trailing-silence window: how long a pause while speaking lasts before the\n// capture auto-stops. Passed to `voicetools serve` via `--silence-ms` (see\n// VoiceDaemon.spawn) so the binary's real cutoff matches the on-screen\n// countdown this same value drives. Every utterance pays this in full before\n// finalization can start, so keep it close to the binary's 600ms default —\n// a slightly longer window tolerates brief mid-sentence pauses without\n// taxing every dictation with seconds of dead air.\nconst VOICE_SILENCE_MS = 800;\n/** How long to keep the warm voice model in memory after the last capture\n * completes. The daemon auto-shuts down after this window, releasing the\n * ~900 MB resident model; the next ctrl+r pays a cold-start respawn cost.\n * Kept long enough that normal pacing between dictations (reading a reply,\n * typing) doesn't repeatedly hit the multi-second cold start. */\nconst VOICE_IDLE_TIMEOUT_MS = 300_000;\nconst VOICE_UNAVAILABLE_MESSAGE =\n\t\"Voice input failed: voicetools binary unavailable and could not be downloaded. \" +\n\t\"Install it, set VOICETOOLS_BIN, or ensure a published release exists for this platform.\";\n\n/** The slice of the interactive mode the voice feature needs. */\nexport interface VoiceControllerDeps {\n\tui: TUI;\n\t/** The status area below the chat; the voice panel mounts here. */\n\tstatusContainer: Container;\n\t/** Feed raw input (bracketed paste) to the prompt editor. */\n\tsendEditorInput(data: string): void;\n\tshowError(message: string): void;\n}\n\nexport class VoiceController {\n\t/** In-flight legacy (`voicetools transcribe`) capture. */\n\tprivate session: VoiceSession | undefined;\n\t// Persistent `voicetools serve` process, once probed successfully. Stays alive\n\t// between captures so the model stays warm.\n\tprivate daemon: VoiceDaemon | undefined;\n\t/** Set when the binary rejected `serve`; future presses use the legacy path. */\n\tprivate daemonUnsupported = false;\n\t/** True while the voicetools binary is being resolved/downloaded before a session starts. */\n\tprivate starting = false;\n\t/** True while a capture is active (listening or transcribing). */\n\tprivate active = false;\n\t/** The status panel while voice input is active. */\n\tprivate panel: VoicePanel | undefined;\n\n\tconstructor(private readonly deps: VoiceControllerDeps) {}\n\n\t/** Toggle voice-to-text capture. */\n\ttoggle(): void {\n\t\tif (this.active) {\n\t\t\tif (this.daemon?.isReady) {\n\t\t\t\tthis.daemon.cancel();\n\t\t\t} else {\n\t\t\t\tthis.session?.stop();\n\t\t\t}\n\t\t\tthis.session = undefined;\n\t\t\tthis.active = false;\n\t\t\tthis.resetUI();\n\t\t\treturn;\n\t\t}\n\n\t\tif (this.starting) {\n\t\t\t// A second press while resolving/warming up: honour the cancel. Any\n\t\t\t// daemon that finishes loading afterwards is kept warm for next time.\n\t\t\tthis.starting = false;\n\t\t\tthis.resetUI();\n\t\t\treturn;\n\t\t}\n\n\t\tif (this.daemonUnsupported) {\n\t\t\tthis.starting = true;\n\t\t\tthis.showWarming(\"Starting voice input...\");\n\t\t\tvoid this.resolveBin()\n\t\t\t\t.then((bin) => {\n\t\t\t\t\tif (!this.starting) return;\n\t\t\t\t\tthis.starting = false;\n\t\t\t\t\tif (!bin) {\n\t\t\t\t\t\tthis.resetUI();\n\t\t\t\t\t\tthis.deps.showError(VOICE_UNAVAILABLE_MESSAGE);\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tthis.beginLegacyCapture(bin);\n\t\t\t\t})\n\t\t\t\t.catch((err: unknown) => {\n\t\t\t\t\tthis.starting = false;\n\t\t\t\t\tthis.resetUI();\n\t\t\t\t\tthis.deps.showError(`Voice input failed: ${err instanceof Error ? err.message : String(err)}`);\n\t\t\t\t});\n\t\t\treturn;\n\t\t}\n\n\t\tif (this.daemon?.isReady) {\n\t\t\tthis.beginDaemonCapture();\n\t\t\treturn;\n\t\t}\n\n\t\t// No daemon yet: resolve the binary, then probe for `serve` support by\n\t\t// spawning it. `VoiceDaemon.spawn` doubles as the probe: it resolves to a\n\t\t// live daemon once READY arrives, to \"unsupported\" if the process exits\n\t\t// with no output at all (an old binary rejecting the unrecognized `serve`\n\t\t// subcommand), or to \"error\" if it printed a real ERROR first (e.g. no\n\t\t// model installed yet) — already surfaced via onError, so that case skips\n\t\t// the legacy fallback (it would just hit the same error) but leaves\n\t\t// daemon mode available to retry on the next press.\n\t\tthis.starting = true;\n\t\tthis.showWarming(\"Warming up voice input...\");\n\t\tvoid this.resolveBin()\n\t\t\t.then(async (bin) => {\n\t\t\t\tif (!bin) {\n\t\t\t\t\tthis.starting = false;\n\t\t\t\t\tthis.resetUI();\n\t\t\t\t\tthis.deps.showError(VOICE_UNAVAILABLE_MESSAGE);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst result = await VoiceDaemon.spawn(bin, this.buildDaemonHandlers(), {\n\t\t\t\t\tsilenceMs: VOICE_SILENCE_MS,\n\t\t\t\t\tidleTimeoutMs: VOICE_IDLE_TIMEOUT_MS,\n\t\t\t\t});\n\t\t\t\tif (!result.ok) {\n\t\t\t\t\tif (result.reason === \"unsupported\") {\n\t\t\t\t\t\tthis.daemonUnsupported = true;\n\t\t\t\t\t\tif (!this.starting) {\n\t\t\t\t\t\t\tthis.resetUI();\n\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthis.starting = false;\n\t\t\t\t\t\tthis.beginLegacyCapture(bin);\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tthis.starting = false;\n\t\t\t\t\tthis.resetUI();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tthis.daemon = result.daemon;\n\t\t\t\tif (!this.starting) {\n\t\t\t\t\t// Cancelled while warming up: keep the loaded daemon warm for next time.\n\t\t\t\t\tthis.resetUI();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tthis.starting = false;\n\t\t\t\tthis.beginDaemonCapture();\n\t\t\t})\n\t\t\t.catch((err: unknown) => {\n\t\t\t\tthis.starting = false;\n\t\t\t\tthis.resetUI();\n\t\t\t\tthis.deps.showError(`Voice input failed: ${err instanceof Error ? err.message : String(err)}`);\n\t\t\t});\n\t}\n\n\t/** Stop any capture, shut down the daemon, and drop the panel. */\n\tdispose(): void {\n\t\tif (this.session?.running) {\n\t\t\tthis.session.stop();\n\t\t\tthis.session = undefined;\n\t\t}\n\t\tthis.daemon?.shutdown();\n\t\tthis.daemon = undefined;\n\t\tif (this.panel) {\n\t\t\tthis.panel.dispose();\n\t\t\tthis.panel = undefined;\n\t\t}\n\t\tthis.active = false;\n\t\tthis.starting = false;\n\t}\n\n\t/**\n\t * Build the (stable, reused-across-captures) handlers for the daemon.\n\t *\n\t * Every handler bails out once `active` is false: CANCEL is a soft\n\t * request (unlike the legacy path's `proc.kill()`, it doesn't sever the\n\t * pipe), so the daemon can still have a trailing PARTIAL/FINAL/DONE for the\n\t * just-cancelled capture in flight when the user presses cancel. Without\n\t * this guard that stale text would land in the editor after the panel had\n\t * already collapsed.\n\t *\n\t * v0.1.4 serve streams `PARTIAL <full growing hypothesis>` (live preview,\n\t * never committed) and ends with a single `FINAL <complete text>` (the one\n\t * commit to the editor). `SEGMENT` is still handled for the legacy\n\t * transcribe path and any binary that streams committed chunks directly.\n\t */\n\tprivate buildDaemonHandlers(): VoiceDaemonHandlers {\n\t\treturn {\n\t\t\tonSegment: (text) => {\n\t\t\t\tif (!this.active) return;\n\t\t\t\tthis.commitText(text);\n\t\t\t},\n\t\t\tonPartial: (text) => {\n\t\t\t\tif (!this.active) return;\n\t\t\t\tthis.panel?.setPartial(text);\n\t\t\t},\n\t\t\tonFinal: (text) => {\n\t\t\t\tif (!this.active) return;\n\t\t\t\tthis.commitText(text);\n\t\t\t},\n\t\t\tonStatus: (status) => {\n\t\t\t\tif (!this.active) return;\n\t\t\t\tif (status === \"done\") {\n\t\t\t\t\tthis.active = false;\n\t\t\t\t\tthis.resetUI();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (status === \"transcribing\") {\n\t\t\t\t\tthis.panel?.setTranscribing();\n\t\t\t\t} else if (status === \"listening\") {\n\t\t\t\t\tthis.panel?.startListening();\n\t\t\t\t}\n\t\t\t},\n\t\t\tonLevel: (rms) => {\n\t\t\t\tif (!this.active) return;\n\t\t\t\tthis.panel?.pushLevel(rms);\n\t\t\t},\n\t\t\tonPhase: (phase) => {\n\t\t\t\tif (!this.active) return;\n\t\t\t\tif (phase === \"silence\") {\n\t\t\t\t\tthis.panel?.beginSilence(VOICE_SILENCE_MS);\n\t\t\t\t} else {\n\t\t\t\t\tthis.panel?.endSilence();\n\t\t\t\t}\n\t\t\t},\n\t\t\tonError: (message) => {\n\t\t\t\tthis.active = false;\n\t\t\t\tthis.resetUI();\n\t\t\t\tthis.deps.showError(`Voice input failed: ${message}`);\n\t\t\t},\n\t\t\tonCrash: (message) => {\n\t\t\t\tthis.active = false;\n\t\t\t\tthis.daemon = undefined;\n\t\t\t\tthis.resetUI();\n\t\t\t\tthis.deps.showError(`Voice input daemon crashed: ${message}. It will restart on next use.`);\n\t\t\t},\n\t\t\tonIdle: () => {\n\t\t\t\t// Daemon auto-shut down after idle timeout: drop the reference so the\n\t\t\t\t// next ctrl+r respawns (cold start). No user-facing message — this is\n\t\t\t\t// an expected memory-reclamation event, not an error.\n\t\t\t\tthis.daemon = undefined;\n\t\t\t},\n\t\t};\n\t}\n\n\t/** Inject decoded text into the editor via bracketed paste (with a trailing space). */\n\tprivate commitText(text: string): void {\n\t\tthis.deps.sendEditorInput(`\\x1b[200~${text} \\x1b[201~`);\n\t}\n\n\tprivate beginDaemonCapture(): void {\n\t\tif (!this.daemon?.isReady) return;\n\t\tthis.active = true;\n\t\tthis.showPanel().startListening();\n\t\tthis.daemon.startCapture();\n\t}\n\n\tprivate beginLegacyCapture(bin: string): void {\n\t\tthis.active = true;\n\t\tconst panel = this.showPanel();\n\t\tpanel.startListening();\n\t\tthis.session = startVoiceTranscribe(bin, {\n\t\t\tonStatus: (status) => {\n\t\t\t\tif (status === \"done\") {\n\t\t\t\t\tthis.active = false;\n\t\t\t\t\tthis.session = undefined;\n\t\t\t\t\tthis.resetUI();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\t// Old binaries emit no LEVEL/PARTIAL, so the panel shows a spinner\n\t\t\t\t// for the batch phases; committed words still stream into the editor.\n\t\t\t\tif (status === \"transcribing\") panel.setTranscribing();\n\t\t\t},\n\t\t\tonSegment: (text) => {\n\t\t\t\tthis.commitText(text);\n\t\t\t},\n\t\t\tonError: (message) => {\n\t\t\t\tthis.active = false;\n\t\t\t\tthis.session = undefined;\n\t\t\t\tthis.resetUI();\n\t\t\t\tthis.deps.showError(`Voice input failed: ${message}`);\n\t\t\t},\n\t\t});\n\t}\n\n\t/** Resolve the `voicetools` binary path. Prefers an explicit VOICETOOLS_BIN\n\t * override, otherwise resolves via the managed tools manager (bin dir / PATH /\n\t * download from the published release). Returns undefined when unavailable.\n\t */\n\tprivate async resolveBin(): Promise<string | undefined> {\n\t\tconst override = process.env.VOICETOOLS_BIN?.trim();\n\t\tif (override) return override;\n\t\treturn ensureTool(\"voicetools\", true);\n\t}\n\n\t/** Create (or reuse) the voice panel and mount it in the status container. */\n\tprivate showPanel(): VoicePanel {\n\t\tif (!this.panel) {\n\t\t\tthis.deps.statusContainer.clear();\n\t\t\tthis.panel = new VoicePanel(this.deps.ui, keyHint(\"app.input.voiceTranscribe\", \"cancel\"));\n\t\t\tthis.deps.statusContainer.addChild(this.panel);\n\t\t}\n\t\tthis.deps.ui.requestRender();\n\t\treturn this.panel;\n\t}\n\n\tprivate showWarming(message: string): void {\n\t\tthis.showPanel().setWarming(message);\n\t}\n\n\t/** Collapse the voice panel back to nothing (idle) and stop its animation. */\n\tprivate resetUI(): void {\n\t\tif (this.panel) {\n\t\t\tthis.panel.dispose();\n\t\t\tthis.panel = undefined;\n\t\t}\n\t\tthis.deps.statusContainer.clear();\n\t\tthis.deps.ui.requestRender();\n\t}\n}\n"]}
1
+ {"version":3,"file":"voice-controller.js","sourceRoot":"","sources":["../../../../src/modes/interactive/voice/voice-controller.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Voice-to-text capture control (ctrl+r / app.input.voiceTranscribe).\n *\n * Prefers a persistent `voicetools serve` daemon: the first press probes for\n * support and (if found) loads models once, showing a \"warming up\" spinner;\n * every later press reuses the already-warm daemon and jumps straight to\n * Listening. Binaries without `serve` support fall back to spawning\n * `voicetools transcribe` per press. Pressing the shortcut again while\n * listening (or while still warming up) cancels.\n */\n\nimport type { Container, TUI } from \"@kolisachint/hoocode-tui\";\nimport { ensureTool } from \"../../../utils/tools-manager.js\";\nimport { keyHint } from \"../components/keybinding-hints.js\";\nimport { VoicePanel } from \"./voice-panel.js\";\nimport { startVoiceTranscribe, VoiceDaemon, type VoiceDaemonHandlers, type VoiceSession } from \"./voice-transcribe.js\";\n\n// Trailing-silence window: how long a pause while speaking lasts before the\n// capture auto-stops. Passed to `voicetools serve` via `--silence-ms` (see\n// VoiceDaemon.spawn) so the binary's real cutoff matches the on-screen\n// countdown this same value drives. Every utterance pays this in full before\n// finalization can start, so keep it close to the binary's 600ms default —\n// a slightly longer window tolerates brief mid-sentence pauses without\n// taxing every dictation with seconds of dead air.\nconst VOICE_SILENCE_MS = 800;\n/** How long to keep the warm voice model in memory after the last capture\n * completes. The daemon auto-shuts down after this window, releasing the\n * ~900 MB resident model; the next ctrl+r pays a cold-start respawn cost.\n * Kept long enough that normal pacing between dictations (reading a reply,\n * typing) doesn't repeatedly hit the multi-second cold start. */\nconst VOICE_IDLE_TIMEOUT_MS = 300_000;\nconst VOICE_UNAVAILABLE_MESSAGE = [\n\t`Voice unavailable — no voicetools binary for ${process.platform}-${process.arch} (and download failed).`,\n\t\" fix: set VOICETOOLS_BIN=/path/to/voicetools\",\n\t\" or: install voicetools from its published release, then press the voice key again\",\n].join(\"\\n\");\n\n/** The slice of the interactive mode the voice feature needs. */\nexport interface VoiceControllerDeps {\n\tui: TUI;\n\t/** The status area below the chat; the voice panel mounts here. */\n\tstatusContainer: Container;\n\t/** Feed raw input (bracketed paste) to the prompt editor. */\n\tsendEditorInput(data: string): void;\n\tshowError(message: string): void;\n}\n\nexport class VoiceController {\n\t/** In-flight legacy (`voicetools transcribe`) capture. */\n\tprivate session: VoiceSession | undefined;\n\t// Persistent `voicetools serve` process, once probed successfully. Stays alive\n\t// between captures so the model stays warm.\n\tprivate daemon: VoiceDaemon | undefined;\n\t/** Set when the binary rejected `serve`; future presses use the legacy path. */\n\tprivate daemonUnsupported = false;\n\t/** True while the voicetools binary is being resolved/downloaded before a session starts. */\n\tprivate starting = false;\n\t/** True while a capture is active (listening or transcribing). */\n\tprivate active = false;\n\t/** The status panel while voice input is active. */\n\tprivate panel: VoicePanel | undefined;\n\n\tconstructor(private readonly deps: VoiceControllerDeps) {}\n\n\t/** Toggle voice-to-text capture. */\n\ttoggle(): void {\n\t\tif (this.active) {\n\t\t\tif (this.daemon?.isReady) {\n\t\t\t\tthis.daemon.cancel();\n\t\t\t} else {\n\t\t\t\tthis.session?.stop();\n\t\t\t}\n\t\t\tthis.session = undefined;\n\t\t\tthis.active = false;\n\t\t\tthis.resetUI();\n\t\t\treturn;\n\t\t}\n\n\t\tif (this.starting) {\n\t\t\t// A second press while resolving/warming up: honour the cancel. Any\n\t\t\t// daemon that finishes loading afterwards is kept warm for next time.\n\t\t\tthis.starting = false;\n\t\t\tthis.resetUI();\n\t\t\treturn;\n\t\t}\n\n\t\tif (this.daemonUnsupported) {\n\t\t\tthis.starting = true;\n\t\t\tthis.showWarming(\"Starting voice input…\", \"first run downloads the voicetools binary and speech model\");\n\t\t\tvoid this.resolveBin()\n\t\t\t\t.then((bin) => {\n\t\t\t\t\tif (!this.starting) return;\n\t\t\t\t\tthis.starting = false;\n\t\t\t\t\tif (!bin) {\n\t\t\t\t\t\tthis.resetUI();\n\t\t\t\t\t\tthis.deps.showError(VOICE_UNAVAILABLE_MESSAGE);\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tthis.beginLegacyCapture(bin);\n\t\t\t\t})\n\t\t\t\t.catch((err: unknown) => {\n\t\t\t\t\tthis.starting = false;\n\t\t\t\t\tthis.resetUI();\n\t\t\t\t\tthis.deps.showError(`Voice input failed: ${err instanceof Error ? err.message : String(err)}`);\n\t\t\t\t});\n\t\t\treturn;\n\t\t}\n\n\t\tif (this.daemon?.isReady) {\n\t\t\tthis.beginDaemonCapture();\n\t\t\treturn;\n\t\t}\n\n\t\t// No daemon yet: resolve the binary, then probe for `serve` support by\n\t\t// spawning it. `VoiceDaemon.spawn` doubles as the probe: it resolves to a\n\t\t// live daemon once READY arrives, to \"unsupported\" if the process exits\n\t\t// with no output at all (an old binary rejecting the unrecognized `serve`\n\t\t// subcommand), or to \"error\" if it printed a real ERROR first (e.g. no\n\t\t// model installed yet) — already surfaced via onError, so that case skips\n\t\t// the legacy fallback (it would just hit the same error) but leaves\n\t\t// daemon mode available to retry on the next press.\n\t\tthis.starting = true;\n\t\tthis.showWarming(\"Warming up voice input…\", \"first run downloads the voicetools binary and speech model\");\n\t\tvoid this.resolveBin()\n\t\t\t.then(async (bin) => {\n\t\t\t\tif (!bin) {\n\t\t\t\t\tthis.starting = false;\n\t\t\t\t\tthis.resetUI();\n\t\t\t\t\tthis.deps.showError(VOICE_UNAVAILABLE_MESSAGE);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst result = await VoiceDaemon.spawn(bin, this.buildDaemonHandlers(), {\n\t\t\t\t\tsilenceMs: VOICE_SILENCE_MS,\n\t\t\t\t\tidleTimeoutMs: VOICE_IDLE_TIMEOUT_MS,\n\t\t\t\t});\n\t\t\t\tif (!result.ok) {\n\t\t\t\t\tif (result.reason === \"unsupported\") {\n\t\t\t\t\t\tthis.daemonUnsupported = true;\n\t\t\t\t\t\tif (!this.starting) {\n\t\t\t\t\t\t\tthis.resetUI();\n\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthis.starting = false;\n\t\t\t\t\t\tthis.beginLegacyCapture(bin);\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tthis.starting = false;\n\t\t\t\t\tthis.resetUI();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tthis.daemon = result.daemon;\n\t\t\t\tif (!this.starting) {\n\t\t\t\t\t// Cancelled while warming up: keep the loaded daemon warm for next time.\n\t\t\t\t\tthis.resetUI();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tthis.starting = false;\n\t\t\t\tthis.beginDaemonCapture();\n\t\t\t})\n\t\t\t.catch((err: unknown) => {\n\t\t\t\tthis.starting = false;\n\t\t\t\tthis.resetUI();\n\t\t\t\tthis.deps.showError(`Voice input failed: ${err instanceof Error ? err.message : String(err)}`);\n\t\t\t});\n\t}\n\n\t/** Stop any capture, shut down the daemon, and drop the panel. */\n\tdispose(): void {\n\t\tif (this.session?.running) {\n\t\t\tthis.session.stop();\n\t\t\tthis.session = undefined;\n\t\t}\n\t\tthis.daemon?.shutdown();\n\t\tthis.daemon = undefined;\n\t\tif (this.panel) {\n\t\t\tthis.panel.dispose();\n\t\t\tthis.panel = undefined;\n\t\t}\n\t\tthis.active = false;\n\t\tthis.starting = false;\n\t}\n\n\t/**\n\t * Build the (stable, reused-across-captures) handlers for the daemon.\n\t *\n\t * Every handler bails out once `active` is false: CANCEL is a soft\n\t * request (unlike the legacy path's `proc.kill()`, it doesn't sever the\n\t * pipe), so the daemon can still have a trailing PARTIAL/FINAL/DONE for the\n\t * just-cancelled capture in flight when the user presses cancel. Without\n\t * this guard that stale text would land in the editor after the panel had\n\t * already collapsed.\n\t *\n\t * v0.1.4 serve streams `PARTIAL <full growing hypothesis>` (live preview,\n\t * never committed) and ends with a single `FINAL <complete text>` (the one\n\t * commit to the editor). `SEGMENT` is still handled for the legacy\n\t * transcribe path and any binary that streams committed chunks directly.\n\t */\n\tprivate buildDaemonHandlers(): VoiceDaemonHandlers {\n\t\treturn {\n\t\t\tonSegment: (text) => {\n\t\t\t\tif (!this.active) return;\n\t\t\t\tthis.commitText(text);\n\t\t\t},\n\t\t\tonPartial: (text) => {\n\t\t\t\tif (!this.active) return;\n\t\t\t\tthis.panel?.setPartial(text);\n\t\t\t},\n\t\t\tonFinal: (text) => {\n\t\t\t\tif (!this.active) return;\n\t\t\t\tthis.commitText(text);\n\t\t\t},\n\t\t\tonStatus: (status) => {\n\t\t\t\tif (!this.active) return;\n\t\t\t\tif (status === \"done\") {\n\t\t\t\t\tthis.active = false;\n\t\t\t\t\tthis.resetUI();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (status === \"transcribing\") {\n\t\t\t\t\tthis.panel?.setTranscribing();\n\t\t\t\t} else if (status === \"listening\") {\n\t\t\t\t\tthis.panel?.startListening();\n\t\t\t\t}\n\t\t\t},\n\t\t\tonLevel: (rms) => {\n\t\t\t\tif (!this.active) return;\n\t\t\t\tthis.panel?.pushLevel(rms);\n\t\t\t},\n\t\t\tonPhase: (phase) => {\n\t\t\t\tif (!this.active) return;\n\t\t\t\tif (phase === \"silence\") {\n\t\t\t\t\tthis.panel?.beginSilence(VOICE_SILENCE_MS);\n\t\t\t\t} else {\n\t\t\t\t\tthis.panel?.endSilence();\n\t\t\t\t}\n\t\t\t},\n\t\t\tonError: (message) => {\n\t\t\t\tthis.active = false;\n\t\t\t\tthis.resetUI();\n\t\t\t\tthis.deps.showError(`Voice input failed: ${message}`);\n\t\t\t},\n\t\t\tonCrash: (message) => {\n\t\t\t\tthis.active = false;\n\t\t\t\tthis.daemon = undefined;\n\t\t\t\tthis.resetUI();\n\t\t\t\tthis.deps.showError(`Voice input daemon crashed: ${message}. It will restart on next use.`);\n\t\t\t},\n\t\t\tonIdle: () => {\n\t\t\t\t// Daemon auto-shut down after idle timeout: drop the reference so the\n\t\t\t\t// next ctrl+r respawns (cold start). No user-facing message — this is\n\t\t\t\t// an expected memory-reclamation event, not an error.\n\t\t\t\tthis.daemon = undefined;\n\t\t\t},\n\t\t};\n\t}\n\n\t/** Inject decoded text into the editor via bracketed paste (with a trailing space). */\n\tprivate commitText(text: string): void {\n\t\tthis.deps.sendEditorInput(`\\x1b[200~${text} \\x1b[201~`);\n\t}\n\n\tprivate beginDaemonCapture(): void {\n\t\tif (!this.daemon?.isReady) return;\n\t\tthis.active = true;\n\t\tthis.showPanel().startListening();\n\t\tthis.daemon.startCapture();\n\t}\n\n\tprivate beginLegacyCapture(bin: string): void {\n\t\tthis.active = true;\n\t\tconst panel = this.showPanel();\n\t\tpanel.startListening();\n\t\tthis.session = startVoiceTranscribe(bin, {\n\t\t\tonStatus: (status) => {\n\t\t\t\tif (status === \"done\") {\n\t\t\t\t\tthis.active = false;\n\t\t\t\t\tthis.session = undefined;\n\t\t\t\t\tthis.resetUI();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\t// Old binaries emit no LEVEL/PARTIAL, so the panel shows a spinner\n\t\t\t\t// for the batch phases; committed words still stream into the editor.\n\t\t\t\tif (status === \"transcribing\") panel.setTranscribing();\n\t\t\t},\n\t\t\tonSegment: (text) => {\n\t\t\t\tthis.commitText(text);\n\t\t\t},\n\t\t\tonError: (message) => {\n\t\t\t\tthis.active = false;\n\t\t\t\tthis.session = undefined;\n\t\t\t\tthis.resetUI();\n\t\t\t\tthis.deps.showError(`Voice input failed: ${message}`);\n\t\t\t},\n\t\t});\n\t}\n\n\t/** Resolve the `voicetools` binary path. Prefers an explicit VOICETOOLS_BIN\n\t * override, otherwise resolves via the managed tools manager (bin dir / PATH /\n\t * download from the published release). Returns undefined when unavailable.\n\t */\n\tprivate async resolveBin(): Promise<string | undefined> {\n\t\tconst override = process.env.VOICETOOLS_BIN?.trim();\n\t\tif (override) return override;\n\t\t// First run downloads the voicetools release archive; stream the byte counts\n\t\t// into the panel so the wait shows a determinate progress bar, not a spinner.\n\t\treturn ensureTool(\"voicetools\", true, (received, total) => {\n\t\t\tif (this.starting) this.panel?.setDownloadProgress(received, total);\n\t\t});\n\t}\n\n\t/** Create (or reuse) the voice panel and mount it in the status container. */\n\tprivate showPanel(): VoicePanel {\n\t\tif (!this.panel) {\n\t\t\tthis.deps.statusContainer.clear();\n\t\t\tthis.panel = new VoicePanel(this.deps.ui, keyHint(\"app.input.voiceTranscribe\", \"cancel\"));\n\t\t\tthis.deps.statusContainer.addChild(this.panel);\n\t\t}\n\t\tthis.deps.ui.requestRender();\n\t\treturn this.panel;\n\t}\n\n\tprivate showWarming(message: string, detail?: string): void {\n\t\tthis.showPanel().setWarming(message, detail);\n\t}\n\n\t/** Collapse the voice panel back to nothing (idle) and stop its animation. */\n\tprivate resetUI(): void {\n\t\tif (this.panel) {\n\t\t\tthis.panel.dispose();\n\t\t\tthis.panel = undefined;\n\t\t}\n\t\tthis.deps.statusContainer.clear();\n\t\tthis.deps.ui.requestRender();\n\t}\n}\n"]}
@@ -5,21 +5,22 @@
5
5
  * the user always sees what's happening:
6
6
  *
7
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
8
+ * Listening · 0:03 esc cancel
9
+ * ▁▂▃▅▇▆▄▂▁▂▄▆▇▅▃▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ← level history, left-anchored on a track
10
+ * › and so my fellow ameri▏ ← live partial transcript (PARTIAL)
12
11
  * ```
13
12
  *
14
13
  * Phases:
15
- * - `warming` spinner while the model loads (first press only)
16
- * - `listening` mic glyph + elapsed timer + waveform + growing partial text
14
+ * - `warming` spinner while the binary resolves / model loads; shows a
15
+ * real progress bar when a download reports byte counts
16
+ * - `listening` recording dot + elapsed timer + waveform + partial text
17
17
  * - `silence` trailing-silence countdown; waveform dims
18
18
  * - `transcribing` spinner while the final decode runs
19
19
  *
20
20
  * The component self-animates (spinner, timer, countdown) on an internal timer
21
21
  * and calls `ui.requestRender()`; the owner feeds it protocol events via
22
- * `pushLevel` / `setPartial` / `beginSilence` etc. and disposes it on collapse.
22
+ * `pushLevel` / `setPartial` / `beginSilence` / `setDownloadProgress` etc. and
23
+ * disposes it on collapse.
23
24
  */
24
25
  import type { Component, TUI } from "@kolisachint/hoocode-tui";
25
26
  export type VoicePanelPhase = "warming" | "listening" | "silence" | "transcribing";
@@ -33,6 +34,10 @@ export declare class VoicePanel implements Component {
33
34
  private listeningStartMs;
34
35
  private silenceRemainingMs;
35
36
  private warmingMessage;
37
+ private warmingDetail;
38
+ private downloadReceived;
39
+ private downloadTotal;
40
+ private downloading;
36
41
  private timer;
37
42
  constructor(ui: TUI | undefined, cancelHint: string);
38
43
  private ensureAnimating;
@@ -40,7 +45,12 @@ export declare class VoicePanel implements Component {
40
45
  dispose(): void;
41
46
  /** No cached render state to clear; every `render()` recomputes from live state. */
42
47
  invalidate(): void;
43
- setWarming(message: string): void;
48
+ setWarming(message: string, detail?: string): void;
49
+ /**
50
+ * Live byte counts while the voicetools binary downloads (first run only).
51
+ * Turns the warming spinner into a determinate progress bar.
52
+ */
53
+ setDownloadProgress(receivedBytes: number, totalBytes: number | null): void;
44
54
  startListening(): void;
45
55
  beginSilence(totalMs: number): void;
46
56
  /** Speech resumed before cutoff: return to the listening phase. */
@@ -48,9 +58,18 @@ export declare class VoicePanel implements Component {
48
58
  setTranscribing(): void;
49
59
  pushLevel(rms: number): void;
50
60
  setPartial(text: string): void;
61
+ /**
62
+ * Level history, LEFT-anchored: the meter grows from the left edge and the
63
+ * unfilled remainder renders as a dim baseline track, so the first seconds of
64
+ * a capture read as a meter filling — not a cluster of glyphs floating at the
65
+ * far right edge over a void (the old right-anchored layout). Once the
66
+ * history exceeds the width it scrolls, keeping the newest samples visible.
67
+ */
51
68
  private renderWaveform;
52
69
  private renderPartial;
53
70
  private elapsedLabel;
71
+ /** Determinate download bar: `·` fill over a dim track with % and MB counts. */
72
+ private renderDownloadBar;
54
73
  render(width: number): string[];
55
74
  }
56
75
  //# sourceMappingURL=voice-panel.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"voice-panel.d.ts","sourceRoot":"","sources":["../../../../src/modes/interactive/voice/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,CAmC9B;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. VS16 (U+FE0F) makes the mic officially\n\t\t// emoji-presentation: terminals render bare U+1F399 double-width anyway,\n\t\t// but without the selector the width math counted one cell (drift); with\n\t\t// it, measurement and rendering agree on two.\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"]}
1
+ {"version":3,"file":"voice-panel.d.ts","sourceRoot":"","sources":["../../../../src/modes/interactive/voice/voice-panel.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;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;AAsDnF,qBAAa,UAAW,YAAW,SAAS;IAe1C,OAAO,CAAC,QAAQ,CAAC,EAAE;IACnB,OAAO,CAAC,QAAQ,CAAC,UAAU;IAf5B,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,aAAa,CAAqB;IAC1C,OAAO,CAAC,gBAAgB,CAAK;IAC7B,OAAO,CAAC,aAAa,CAAuB;IAC5C,OAAO,CAAC,WAAW,CAAS;IAC5B,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,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAMjD;IAED;;;OAGG;IACH,mBAAmB,CAAC,aAAa,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI,CAK1E;IAED,cAAc,IAAI,IAAI,CAQrB;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;;;;;;OAMG;IACH,OAAO,CAAC,cAAc;IAStB,OAAO,CAAC,aAAa;IAOrB,OAAO,CAAC,YAAY;IAOpB,iFAAgF;IAChF,OAAO,CAAC,iBAAiB;IAazB,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CA4C9B;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 esc cancel\n * ▁▂▃▅▇▆▄▂▁▂▄▆▇▅▃▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ← level history, left-anchored on a track\n * › and so my fellow ameri▏ ← live partial transcript (PARTIAL)\n * ```\n *\n * Phases:\n * - `warming` spinner while the binary resolves / model loads; shows a\n * real progress bar when a download reports byte counts\n * - `listening` recording dot + elapsed timer + waveform + 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` / `setDownloadProgress` etc. and\n * disposes it on collapse.\n */\n\nimport type { Component, TUI } from \"@kolisachint/hoocode-tui\";\nimport { truncateToWidth, 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 = 256;\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/** Cells in the download progress bar shown while the voicetools binary fetches. */\nconst PROGRESS_CELLS = 20;\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\n/** `left` flush left, `right` flush right when it fits; otherwise just `left`. */\nfunction lineWithHint(\n\twidth: number,\n\tleftPlain: string,\n\tleftStyled: string,\n\thintPlain: string,\n\thintStyled: string,\n): string {\n\tconst lw = visibleWidth(leftPlain);\n\tif (hintPlain && lw + 2 + visibleWidth(hintPlain) <= width) {\n\t\treturn leftStyled + \" \".repeat(width - lw - visibleWidth(hintPlain)) + hintStyled;\n\t}\n\tif (lw <= width) return leftStyled;\n\treturn truncateToWidth(leftStyled, width, theme.fg(\"dim\", \"…\"));\n}\n\nfunction formatMb(bytes: number): string {\n\treturn `${Math.round(bytes / (1024 * 1024))} MB`;\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 warmingDetail: string | undefined;\n\tprivate downloadReceived = 0;\n\tprivate downloadTotal: number | null = null;\n\tprivate downloading = false;\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, detail?: string): void {\n\t\tthis.phase = \"warming\";\n\t\tthis.warmingMessage = message;\n\t\tthis.warmingDetail = detail;\n\t\tthis.ensureAnimating();\n\t\tthis.ui?.requestRender();\n\t}\n\n\t/**\n\t * Live byte counts while the voicetools binary downloads (first run only).\n\t * Turns the warming spinner into a determinate progress bar.\n\t */\n\tsetDownloadProgress(receivedBytes: number, totalBytes: number | null): void {\n\t\tthis.downloading = true;\n\t\tthis.downloadReceived = receivedBytes;\n\t\tthis.downloadTotal = totalBytes;\n\t\tthis.ui?.requestRender();\n\t}\n\n\tstartListening(): void {\n\t\tthis.phase = \"listening\";\n\t\tthis.downloading = false;\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\t/**\n\t * Level history, LEFT-anchored: the meter grows from the left edge and the\n\t * unfilled remainder renders as a dim baseline track, so the first seconds of\n\t * a capture read as a meter filling — not a cluster of glyphs floating at the\n\t * far right edge over a void (the old right-anchored layout). Once the\n\t * history exceeds the width it scrolls, keeping the newest samples visible.\n\t */\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 wave = recent.map(levelGlyph).join(\"\");\n\t\tconst track = \"▁\".repeat(Math.max(0, cells - recent.length));\n\t\tconst waveStyled = this.phase === \"silence\" ? theme.fg(\"dim\", wave) : theme.fg(\"accent\", wave);\n\t\treturn waveStyled + theme.fg(\"dim\", track);\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\t/** Determinate download bar: `·` fill over a dim track with % and MB counts. */\n\tprivate renderDownloadBar(): string {\n\t\tconst total = this.downloadTotal;\n\t\tif (total === null || total <= 0) {\n\t\t\treturn ` ${theme.fg(\"dim\", `downloaded ${formatMb(this.downloadReceived)}…`)}`;\n\t\t}\n\t\tconst ratio = Math.max(0, Math.min(1, this.downloadReceived / total));\n\t\tconst filled = Math.round(ratio * PROGRESS_CELLS);\n\t\tconst bar = theme.fg(\"accent\", \"·\".repeat(filled)) + theme.fg(\"dim\", \"·\".repeat(PROGRESS_CELLS - filled));\n\t\tconst pct = `${Math.round(ratio * 100)}%`;\n\t\tconst sizes = `${formatMb(this.downloadReceived)} / ${formatMb(total)}`;\n\t\treturn ` ${bar} ${theme.fg(\"muted\", pct)} ${theme.fg(\"dim\", `· ${sizes}`)}`;\n\t}\n\n\trender(width: number): string[] {\n\t\tconst spinner = SPINNER_FRAMES[this.frame] ?? SPINNER_FRAMES[0]!;\n\t\tconst hintPlain = this.cancelHint;\n\t\tconst hintStyled = theme.fg(\"dim\", this.cancelHint);\n\t\tconst lines: string[] = [\"\"];\n\n\t\tif (this.phase === \"warming\") {\n\t\t\tconst headPlain = ` ${spinner} ${this.warmingMessage}`;\n\t\t\tconst headStyled = ` ${theme.fg(\"accent\", spinner)} ${theme.fg(\"text\", this.warmingMessage)}`;\n\t\t\tlines.push(lineWithHint(width, headPlain, headStyled, hintPlain, hintStyled));\n\t\t\tif (this.downloading) {\n\t\t\t\tlines.push(this.renderDownloadBar());\n\t\t\t}\n\t\t\tif (this.warmingDetail) {\n\t\t\t\tlines.push(` ${theme.fg(\"dim\", this.warmingDetail)}`);\n\t\t\t}\n\t\t\treturn lines;\n\t\t}\n\n\t\tif (this.phase === \"transcribing\") {\n\t\t\tconst headPlain = ` ${spinner} Transcribing…`;\n\t\t\tconst headStyled = ` ${theme.fg(\"accent\", spinner)} ${theme.fg(\"accent\", \"Transcribing…\")}`;\n\t\t\tlines.push(lineWithHint(width, headPlain, headStyled, hintPlain, hintStyled));\n\t\t\tconst partial = this.renderPartial(width);\n\t\t\tif (partial) lines.push(partial);\n\t\t\treturn lines;\n\t\t}\n\n\t\t// listening / silence. A single-cell recording dot (●, error red = \"live\")\n\t\t// replaces the old emoji mic, whose emoji-presentation width drift was the\n\t\t// reason for a VS16 workaround; ● needs no such care.\n\t\tconst dot = theme.fg(\"error\", \"●\");\n\t\tlet headPlain = ` ● Listening · ${this.elapsedLabel()}`;\n\t\tlet headStyled = ` ${dot} ${theme.fg(\"text\", \"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\theadPlain += ` · cutting off in ${remaining}s`;\n\t\t\theadStyled += ` ${theme.fg(\"warning\", `· cutting off in ${remaining}s`)}`;\n\t\t}\n\t\tlines.push(lineWithHint(width, headPlain, headStyled, hintPlain, hintStyled));\n\t\tlines.push(` ${this.renderWaveform(width)}`);\n\t\tconst partial = this.renderPartial(width);\n\t\tif (partial) lines.push(partial);\n\t\treturn lines;\n\t}\n}\n"]}
@@ -5,36 +5,39 @@
5
5
  * the user always sees what's happening:
6
6
  *
7
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
8
+ * Listening · 0:03 esc cancel
9
+ * ▁▂▃▅▇▆▄▂▁▂▄▆▇▅▃▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ← level history, left-anchored on a track
10
+ * › and so my fellow ameri▏ ← live partial transcript (PARTIAL)
12
11
  * ```
13
12
  *
14
13
  * Phases:
15
- * - `warming` spinner while the model loads (first press only)
16
- * - `listening` mic glyph + elapsed timer + waveform + growing partial text
14
+ * - `warming` spinner while the binary resolves / model loads; shows a
15
+ * real progress bar when a download reports byte counts
16
+ * - `listening` recording dot + elapsed timer + waveform + partial text
17
17
  * - `silence` trailing-silence countdown; waveform dims
18
18
  * - `transcribing` spinner while the final decode runs
19
19
  *
20
20
  * The component self-animates (spinner, timer, countdown) on an internal timer
21
21
  * and calls `ui.requestRender()`; the owner feeds it protocol events via
22
- * `pushLevel` / `setPartial` / `beginSilence` etc. and disposes it on collapse.
22
+ * `pushLevel` / `setPartial` / `beginSilence` / `setDownloadProgress` etc. and
23
+ * disposes it on collapse.
23
24
  */
24
- import { visibleWidth } from "@kolisachint/hoocode-tui";
25
+ import { truncateToWidth, visibleWidth } from "@kolisachint/hoocode-tui";
25
26
  import { theme } from "../theme/theme.js";
26
27
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
27
28
  // 8 filled levels; index 0 renders as a baseline dot so an empty history still
28
29
  // reads as "a track", not a blank line.
29
30
  const WAVE_LEVELS = ["▁", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
30
31
  const TICK_MS = 100;
31
- const HISTORY_CAP = 64;
32
+ const HISTORY_CAP = 256;
32
33
  // RMS from voicetools' vad::rms sits well below 1.0 for speech; this gain maps a
33
34
  // normal speaking level to roughly the top of the meter without clipping constantly.
34
35
  const LEVEL_GAIN = 6;
35
36
  // During trailing silence the binary keeps emitting low LEVELs; only an RMS above
36
37
  // this counts as the speaker resuming, so we don't cancel the countdown on noise.
37
38
  const RESUME_RMS = 0.02;
39
+ /** Cells in the download progress bar shown while the voicetools binary fetches. */
40
+ const PROGRESS_CELLS = 20;
38
41
  /** Map an RMS energy to a waveform block glyph. */
39
42
  function levelGlyph(rms) {
40
43
  const norm = Math.max(0, Math.min(1, rms * LEVEL_GAIN));
@@ -52,6 +55,19 @@ function clampTail(text, max) {
52
55
  const tail = text.slice(-(max - 1));
53
56
  return `…${tail}`;
54
57
  }
58
+ /** `left` flush left, `right` flush right when it fits; otherwise just `left`. */
59
+ function lineWithHint(width, leftPlain, leftStyled, hintPlain, hintStyled) {
60
+ const lw = visibleWidth(leftPlain);
61
+ if (hintPlain && lw + 2 + visibleWidth(hintPlain) <= width) {
62
+ return leftStyled + " ".repeat(width - lw - visibleWidth(hintPlain)) + hintStyled;
63
+ }
64
+ if (lw <= width)
65
+ return leftStyled;
66
+ return truncateToWidth(leftStyled, width, theme.fg("dim", "…"));
67
+ }
68
+ function formatMb(bytes) {
69
+ return `${Math.round(bytes / (1024 * 1024))} MB`;
70
+ }
55
71
  export class VoicePanel {
56
72
  ui;
57
73
  cancelHint;
@@ -62,6 +78,10 @@ export class VoicePanel {
62
78
  listeningStartMs = 0;
63
79
  silenceRemainingMs = 0;
64
80
  warmingMessage = "Warming up voice input…";
81
+ warmingDetail;
82
+ downloadReceived = 0;
83
+ downloadTotal = null;
84
+ downloading = false;
65
85
  timer;
66
86
  constructor(ui, cancelHint) {
67
87
  this.ui = ui;
@@ -89,14 +109,26 @@ export class VoicePanel {
89
109
  /** No cached render state to clear; every `render()` recomputes from live state. */
90
110
  invalidate() { }
91
111
  // ---- phase transitions ----------------------------------------------
92
- setWarming(message) {
112
+ setWarming(message, detail) {
93
113
  this.phase = "warming";
94
114
  this.warmingMessage = message;
115
+ this.warmingDetail = detail;
95
116
  this.ensureAnimating();
96
117
  this.ui?.requestRender();
97
118
  }
119
+ /**
120
+ * Live byte counts while the voicetools binary downloads (first run only).
121
+ * Turns the warming spinner into a determinate progress bar.
122
+ */
123
+ setDownloadProgress(receivedBytes, totalBytes) {
124
+ this.downloading = true;
125
+ this.downloadReceived = receivedBytes;
126
+ this.downloadTotal = totalBytes;
127
+ this.ui?.requestRender();
128
+ }
98
129
  startListening() {
99
130
  this.phase = "listening";
131
+ this.downloading = false;
100
132
  this.levels = [];
101
133
  this.partial = "";
102
134
  this.listeningStartMs = Date.now();
@@ -139,12 +171,20 @@ export class VoicePanel {
139
171
  this.ui?.requestRender();
140
172
  }
141
173
  // ---- rendering -------------------------------------------------------
174
+ /**
175
+ * Level history, LEFT-anchored: the meter grows from the left edge and the
176
+ * unfilled remainder renders as a dim baseline track, so the first seconds of
177
+ * a capture read as a meter filling — not a cluster of glyphs floating at the
178
+ * far right edge over a void (the old right-anchored layout). Once the
179
+ * history exceeds the width it scrolls, keeping the newest samples visible.
180
+ */
142
181
  renderWaveform(width) {
143
182
  const cells = Math.max(1, width - 2);
144
183
  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);
184
+ const wave = recent.map(levelGlyph).join("");
185
+ const track = "".repeat(Math.max(0, cells - recent.length));
186
+ const waveStyled = this.phase === "silence" ? theme.fg("dim", wave) : theme.fg("accent", wave);
187
+ return waveStyled + theme.fg("dim", track);
148
188
  }
149
189
  renderPartial(width) {
150
190
  if (!this.partial)
@@ -159,39 +199,61 @@ export class VoicePanel {
159
199
  const s = secs % 60;
160
200
  return `${m}:${s.toString().padStart(2, "0")}`;
161
201
  }
202
+ /** Determinate download bar: `·` fill over a dim track with % and MB counts. */
203
+ renderDownloadBar() {
204
+ const total = this.downloadTotal;
205
+ if (total === null || total <= 0) {
206
+ return ` ${theme.fg("dim", `downloaded ${formatMb(this.downloadReceived)}…`)}`;
207
+ }
208
+ const ratio = Math.max(0, Math.min(1, this.downloadReceived / total));
209
+ const filled = Math.round(ratio * PROGRESS_CELLS);
210
+ const bar = theme.fg("accent", "·".repeat(filled)) + theme.fg("dim", "·".repeat(PROGRESS_CELLS - filled));
211
+ const pct = `${Math.round(ratio * 100)}%`;
212
+ const sizes = `${formatMb(this.downloadReceived)} / ${formatMb(total)}`;
213
+ return ` ${bar} ${theme.fg("muted", pct)} ${theme.fg("dim", `· ${sizes}`)}`;
214
+ }
162
215
  render(width) {
163
216
  const spinner = SPINNER_FRAMES[this.frame] ?? SPINNER_FRAMES[0];
164
- const footer = theme.fg("dim", ` ${this.cancelHint}`);
217
+ const hintPlain = this.cancelHint;
218
+ const hintStyled = theme.fg("dim", this.cancelHint);
165
219
  const lines = [""];
166
220
  if (this.phase === "warming") {
167
- lines.push(` ${theme.fg("accent", spinner)} ${theme.fg("muted", this.warmingMessage)}`);
168
- lines.push(footer);
221
+ const headPlain = ` ${spinner} ${this.warmingMessage}`;
222
+ const headStyled = ` ${theme.fg("accent", spinner)} ${theme.fg("text", this.warmingMessage)}`;
223
+ lines.push(lineWithHint(width, headPlain, headStyled, hintPlain, hintStyled));
224
+ if (this.downloading) {
225
+ lines.push(this.renderDownloadBar());
226
+ }
227
+ if (this.warmingDetail) {
228
+ lines.push(` ${theme.fg("dim", this.warmingDetail)}`);
229
+ }
169
230
  return lines;
170
231
  }
171
232
  if (this.phase === "transcribing") {
172
- lines.push(` ${theme.fg("accent", spinner)} ${theme.fg("accent", "Transcribing…")}`);
233
+ const headPlain = ` ${spinner} Transcribing…`;
234
+ const headStyled = ` ${theme.fg("accent", spinner)} ${theme.fg("accent", "Transcribing…")}`;
235
+ lines.push(lineWithHint(width, headPlain, headStyled, hintPlain, hintStyled));
173
236
  const partial = this.renderPartial(width);
174
237
  if (partial)
175
238
  lines.push(partial);
176
- lines.push(footer);
177
239
  return lines;
178
240
  }
179
- // listening / silence. VS16 (U+FE0F) makes the mic officially
180
- // emoji-presentation: terminals render bare U+1F399 double-width anyway,
181
- // but without the selector the width math counted one cell (drift); with
182
- // it, measurement and rendering agree on two.
183
- const mic = theme.fg("error", "🎙️");
184
- let header = `${mic} ${theme.fg("accent", "Listening")} ${theme.fg("dim", `· ${this.elapsedLabel()}`)}`;
241
+ // listening / silence. A single-cell recording dot (●, error red = "live")
242
+ // replaces the old emoji mic, whose emoji-presentation width drift was the
243
+ // reason for a VS16 workaround; needs no such care.
244
+ const dot = theme.fg("error", "●");
245
+ let headPlain = ` ● Listening · ${this.elapsedLabel()}`;
246
+ let headStyled = ` ${dot} ${theme.fg("text", "Listening")} ${theme.fg("dim", `· ${this.elapsedLabel()}`)}`;
185
247
  if (this.phase === "silence") {
186
248
  const remaining = (this.silenceRemainingMs / 1000).toFixed(1);
187
- header += ` ${theme.fg("warning", cutting off in ${remaining}s`)}`;
249
+ headPlain += ` · cutting off in ${remaining}s`;
250
+ headStyled += ` ${theme.fg("warning", `· cutting off in ${remaining}s`)}`;
188
251
  }
189
- lines.push(` ${header}`);
252
+ lines.push(lineWithHint(width, headPlain, headStyled, hintPlain, hintStyled));
190
253
  lines.push(` ${this.renderWaveform(width)}`);
191
254
  const partial = this.renderPartial(width);
192
255
  if (partial)
193
256
  lines.push(partial);
194
- lines.push(footer);
195
257
  return lines;
196
258
  }
197
259
  }
@@ -1 +1 @@
1
- {"version":3,"file":"voice-panel.js","sourceRoot":"","sources":["../../../../src/modes/interactive/voice/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. VS16 (U+FE0F) makes the mic officially\n\t\t// emoji-presentation: terminals render bare U+1F399 double-width anyway,\n\t\t// but without the selector the width math counted one cell (drift); with\n\t\t// it, measurement and rendering agree on two.\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"]}
1
+ {"version":3,"file":"voice-panel.js","sourceRoot":"","sources":["../../../../src/modes/interactive/voice/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 esc cancel\n * ▁▂▃▅▇▆▄▂▁▂▄▆▇▅▃▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ← level history, left-anchored on a track\n * › and so my fellow ameri▏ ← live partial transcript (PARTIAL)\n * ```\n *\n * Phases:\n * - `warming` spinner while the binary resolves / model loads; shows a\n * real progress bar when a download reports byte counts\n * - `listening` recording dot + elapsed timer + waveform + 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` / `setDownloadProgress` etc. and\n * disposes it on collapse.\n */\n\nimport type { Component, TUI } from \"@kolisachint/hoocode-tui\";\nimport { truncateToWidth, 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 = 256;\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/** Cells in the download progress bar shown while the voicetools binary fetches. */\nconst PROGRESS_CELLS = 20;\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\n/** `left` flush left, `right` flush right when it fits; otherwise just `left`. */\nfunction lineWithHint(\n\twidth: number,\n\tleftPlain: string,\n\tleftStyled: string,\n\thintPlain: string,\n\thintStyled: string,\n): string {\n\tconst lw = visibleWidth(leftPlain);\n\tif (hintPlain && lw + 2 + visibleWidth(hintPlain) <= width) {\n\t\treturn leftStyled + \" \".repeat(width - lw - visibleWidth(hintPlain)) + hintStyled;\n\t}\n\tif (lw <= width) return leftStyled;\n\treturn truncateToWidth(leftStyled, width, theme.fg(\"dim\", \"…\"));\n}\n\nfunction formatMb(bytes: number): string {\n\treturn `${Math.round(bytes / (1024 * 1024))} MB`;\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 warmingDetail: string | undefined;\n\tprivate downloadReceived = 0;\n\tprivate downloadTotal: number | null = null;\n\tprivate downloading = false;\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, detail?: string): void {\n\t\tthis.phase = \"warming\";\n\t\tthis.warmingMessage = message;\n\t\tthis.warmingDetail = detail;\n\t\tthis.ensureAnimating();\n\t\tthis.ui?.requestRender();\n\t}\n\n\t/**\n\t * Live byte counts while the voicetools binary downloads (first run only).\n\t * Turns the warming spinner into a determinate progress bar.\n\t */\n\tsetDownloadProgress(receivedBytes: number, totalBytes: number | null): void {\n\t\tthis.downloading = true;\n\t\tthis.downloadReceived = receivedBytes;\n\t\tthis.downloadTotal = totalBytes;\n\t\tthis.ui?.requestRender();\n\t}\n\n\tstartListening(): void {\n\t\tthis.phase = \"listening\";\n\t\tthis.downloading = false;\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\t/**\n\t * Level history, LEFT-anchored: the meter grows from the left edge and the\n\t * unfilled remainder renders as a dim baseline track, so the first seconds of\n\t * a capture read as a meter filling — not a cluster of glyphs floating at the\n\t * far right edge over a void (the old right-anchored layout). Once the\n\t * history exceeds the width it scrolls, keeping the newest samples visible.\n\t */\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 wave = recent.map(levelGlyph).join(\"\");\n\t\tconst track = \"▁\".repeat(Math.max(0, cells - recent.length));\n\t\tconst waveStyled = this.phase === \"silence\" ? theme.fg(\"dim\", wave) : theme.fg(\"accent\", wave);\n\t\treturn waveStyled + theme.fg(\"dim\", track);\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\t/** Determinate download bar: `·` fill over a dim track with % and MB counts. */\n\tprivate renderDownloadBar(): string {\n\t\tconst total = this.downloadTotal;\n\t\tif (total === null || total <= 0) {\n\t\t\treturn ` ${theme.fg(\"dim\", `downloaded ${formatMb(this.downloadReceived)}…`)}`;\n\t\t}\n\t\tconst ratio = Math.max(0, Math.min(1, this.downloadReceived / total));\n\t\tconst filled = Math.round(ratio * PROGRESS_CELLS);\n\t\tconst bar = theme.fg(\"accent\", \"·\".repeat(filled)) + theme.fg(\"dim\", \"·\".repeat(PROGRESS_CELLS - filled));\n\t\tconst pct = `${Math.round(ratio * 100)}%`;\n\t\tconst sizes = `${formatMb(this.downloadReceived)} / ${formatMb(total)}`;\n\t\treturn ` ${bar} ${theme.fg(\"muted\", pct)} ${theme.fg(\"dim\", `· ${sizes}`)}`;\n\t}\n\n\trender(width: number): string[] {\n\t\tconst spinner = SPINNER_FRAMES[this.frame] ?? SPINNER_FRAMES[0]!;\n\t\tconst hintPlain = this.cancelHint;\n\t\tconst hintStyled = theme.fg(\"dim\", this.cancelHint);\n\t\tconst lines: string[] = [\"\"];\n\n\t\tif (this.phase === \"warming\") {\n\t\t\tconst headPlain = ` ${spinner} ${this.warmingMessage}`;\n\t\t\tconst headStyled = ` ${theme.fg(\"accent\", spinner)} ${theme.fg(\"text\", this.warmingMessage)}`;\n\t\t\tlines.push(lineWithHint(width, headPlain, headStyled, hintPlain, hintStyled));\n\t\t\tif (this.downloading) {\n\t\t\t\tlines.push(this.renderDownloadBar());\n\t\t\t}\n\t\t\tif (this.warmingDetail) {\n\t\t\t\tlines.push(` ${theme.fg(\"dim\", this.warmingDetail)}`);\n\t\t\t}\n\t\t\treturn lines;\n\t\t}\n\n\t\tif (this.phase === \"transcribing\") {\n\t\t\tconst headPlain = ` ${spinner} Transcribing…`;\n\t\t\tconst headStyled = ` ${theme.fg(\"accent\", spinner)} ${theme.fg(\"accent\", \"Transcribing…\")}`;\n\t\t\tlines.push(lineWithHint(width, headPlain, headStyled, hintPlain, hintStyled));\n\t\t\tconst partial = this.renderPartial(width);\n\t\t\tif (partial) lines.push(partial);\n\t\t\treturn lines;\n\t\t}\n\n\t\t// listening / silence. A single-cell recording dot (●, error red = \"live\")\n\t\t// replaces the old emoji mic, whose emoji-presentation width drift was the\n\t\t// reason for a VS16 workaround; ● needs no such care.\n\t\tconst dot = theme.fg(\"error\", \"●\");\n\t\tlet headPlain = ` ● Listening · ${this.elapsedLabel()}`;\n\t\tlet headStyled = ` ${dot} ${theme.fg(\"text\", \"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\theadPlain += ` · cutting off in ${remaining}s`;\n\t\t\theadStyled += ` ${theme.fg(\"warning\", `· cutting off in ${remaining}s`)}`;\n\t\t}\n\t\tlines.push(lineWithHint(width, headPlain, headStyled, hintPlain, hintStyled));\n\t\tlines.push(` ${this.renderWaveform(width)}`);\n\t\tconst partial = this.renderPartial(width);\n\t\tif (partial) lines.push(partial);\n\t\treturn lines;\n\t}\n}\n"]}
@@ -1,6 +1,12 @@
1
1
  /** Tools whose binaries hoocode can resolve from PATH or download on demand. */
2
2
  export type ManagedTool = "fd" | "rg" | "webtools" | "filetools" | "browsertools" | "voicetools";
3
3
  export declare function getToolPath(tool: ManagedTool): string | null;
4
- export declare function downloadFile(url: string, dest: string): Promise<void>;
5
- export declare function ensureTool(tool: ManagedTool, silent?: boolean): Promise<string | undefined>;
4
+ /**
5
+ * Live download progress: bytes received so far and the total from
6
+ * Content-Length (null when the server doesn't send one). Callers use it to
7
+ * drive a progress bar instead of an indefinite spinner.
8
+ */
9
+ export type DownloadProgress = (receivedBytes: number, totalBytes: number | null) => void;
10
+ export declare function downloadFile(url: string, dest: string, onProgress?: DownloadProgress): Promise<void>;
11
+ export declare function ensureTool(tool: ManagedTool, silent?: boolean, onProgress?: DownloadProgress): Promise<string | undefined>;
6
12
  //# sourceMappingURL=tools-manager.d.ts.map