@kolisachint/hoocode-agent 0.4.104 → 0.4.106

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -59,8 +59,9 @@ import { ToolExecutionComponent } from "./components/tool-execution.js";
59
59
  import { TreeSelectorComponent } from "./components/tree-selector.js";
60
60
  import { UserMessageComponent } from "./components/user-message.js";
61
61
  import { UserMessageSelectorComponent } from "./components/user-message-selector.js";
62
+ import { VoicePanel } from "./components/voice-panel.js";
62
63
  import { getAvailableThemes, getAvailableThemesWithPaths, getEditorTheme, getMarkdownTheme, getThemeByName, initTheme, onThemeChange, setRegisteredThemes, setTheme, setThemeInstance, stopThemeWatcher, Theme, theme, } from "./theme/theme.js";
63
- import { startVoiceTranscribe } from "./voice-transcribe.js";
64
+ import { startVoiceTranscribe, VoiceDaemon } from "./voice-transcribe.js";
64
65
  function isExpandable(obj) {
65
66
  return typeof obj === "object" && obj !== null && "setExpanded" in obj && typeof obj.setExpanded === "function";
66
67
  }
@@ -84,6 +85,18 @@ function isDeadTerminalError(error) {
84
85
  const code = error.code;
85
86
  return code !== undefined && DEAD_TERMINAL_ERROR_CODES.has(code);
86
87
  }
88
+ // Trailing-silence window: how long a pause while speaking lasts before the
89
+ // capture auto-stops. Passed to `voicetools serve` via `--silence-ms` (see
90
+ // VoiceDaemon.spawn) so the binary's real cutoff matches the on-screen
91
+ // countdown this same value drives. Kept generous so a thinking pause mid-
92
+ // sentence doesn't cut the user off (the binary's own default is 600ms).
93
+ const VOICE_SILENCE_MS = 3000;
94
+ /** How long to keep the warm voice model in memory after the last capture
95
+ * completes. The daemon auto-shuts down after this window, releasing the
96
+ * ~900 MB resident model; the next ctrl+r pays a cold-start respawn cost. */
97
+ const VOICE_IDLE_TIMEOUT_MS = 60_000;
98
+ const VOICE_UNAVAILABLE_MESSAGE = "Voice input failed: voicetools binary unavailable and could not be downloaded. " +
99
+ "Install it, set VOICETOOLS_BIN, or ensure a published release exists for this platform.";
87
100
  const ANTHROPIC_SUBSCRIPTION_AUTH_WARNING = "Anthropic subscription auth: billed per token as extra usage, not plan limits.";
88
101
  function isAnthropicSubscriptionAuthKey(apiKey) {
89
102
  return typeof apiKey === "string" && apiKey.startsWith("sk-ant-oat");
@@ -116,8 +129,18 @@ export class InteractiveMode {
116
129
  editor;
117
130
  editorComponentFactory;
118
131
  voiceSession;
132
+ // Persistent `voicetools serve` process, once probed successfully. Stays alive
133
+ // (and warm) across captures for the life of the interactive session.
134
+ voiceDaemon;
135
+ // Sticky for the session: set once `serve` is confirmed unsupported (old binary)
136
+ // so later presses skip straight to the per-press `transcribe` fallback.
137
+ voiceDaemonUnsupported = false;
119
138
  /** True while the voicetools binary is being resolved/downloaded before a session starts. */
120
139
  voiceStarting = false;
140
+ /** True while a capture (daemon or legacy) is in flight. */
141
+ voiceActive = false;
142
+ /** The live multi-line status panel for the current capture (undefined when idle). */
143
+ voicePanel;
121
144
  autocompleteProvider;
122
145
  autocompleteProviderWrappers = [];
123
146
  fdPath;
@@ -2762,64 +2785,234 @@ export class InteractiveMode {
2762
2785
  * we update the previous status line instead of appending new ones to avoid log spam.
2763
2786
  */
2764
2787
  /**
2765
- * Toggle voice-to-text capture. First press spawns `voicetools`, streams
2766
- * decoded segments into the editor via bracketed paste, and reports state
2767
- * through the status line. Pressing the shortcut again stops early.
2788
+ * Toggle voice-to-text capture.
2789
+ *
2790
+ * Prefers a persistent `voicetools serve` daemon: the first press probes for
2791
+ * support and (if found) loads models once, showing a "warming up" spinner;
2792
+ * every later press reuses the already-warm daemon and jumps straight to
2793
+ * Listening. Binaries without `serve` support fall back to spawning
2794
+ * `voicetools transcribe` per press, same as before. Pressing the shortcut
2795
+ * again while listening (or while still warming up) cancels.
2768
2796
  */
2769
2797
  toggleVoiceTranscribe() {
2770
- if (this.voiceSession?.running || this.voiceStarting) {
2771
- this.voiceSession?.stop();
2798
+ if (this.voiceActive) {
2799
+ if (this.voiceDaemon?.isReady) {
2800
+ this.voiceDaemon.cancel();
2801
+ }
2802
+ else {
2803
+ this.voiceSession?.stop();
2804
+ }
2772
2805
  this.voiceSession = undefined;
2806
+ this.voiceActive = false;
2807
+ this.resetVoiceUI();
2808
+ return;
2809
+ }
2810
+ if (this.voiceStarting) {
2811
+ // A second press while resolving/warming up: honour the cancel. Any
2812
+ // daemon that finishes loading afterwards is kept warm for next time.
2773
2813
  this.voiceStarting = false;
2774
- this.showStatus("Voice input cancelled");
2814
+ this.resetVoiceUI();
2775
2815
  return;
2776
2816
  }
2777
- const statusLabels = {
2778
- recording: "Recording... speak now (press again to cancel)",
2779
- transcribing: "Transcribing...",
2780
- done: "Voice input done",
2781
- };
2782
- // Resolve the binary the same way as the other managed tools (webtools etc.):
2783
- // an explicit VOICETOOLS_BIN wins, otherwise resolve from bin/PATH and download
2784
- // from the published release on demand. This runs async, so guard re-entry with
2785
- // `voiceStarting` and let a second press before it resolves cancel the start.
2817
+ if (this.voiceDaemonUnsupported) {
2818
+ this.voiceStarting = true;
2819
+ this.showVoiceWarming("Starting voice input...");
2820
+ void this.resolveVoiceBin()
2821
+ .then((bin) => {
2822
+ if (!this.voiceStarting)
2823
+ return;
2824
+ this.voiceStarting = false;
2825
+ if (!bin) {
2826
+ this.resetVoiceUI();
2827
+ this.showError(VOICE_UNAVAILABLE_MESSAGE);
2828
+ return;
2829
+ }
2830
+ this.beginLegacyVoiceCapture(bin);
2831
+ })
2832
+ .catch((err) => {
2833
+ this.voiceStarting = false;
2834
+ this.resetVoiceUI();
2835
+ this.showError(`Voice input failed: ${err instanceof Error ? err.message : String(err)}`);
2836
+ });
2837
+ return;
2838
+ }
2839
+ if (this.voiceDaemon?.isReady) {
2840
+ this.beginDaemonVoiceCapture();
2841
+ return;
2842
+ }
2843
+ // No daemon yet: resolve the binary, then probe for `serve` support by
2844
+ // spawning it. `VoiceDaemon.spawn` doubles as the probe: it resolves to a
2845
+ // live daemon once READY arrives, to "unsupported" if the process exits
2846
+ // with no output at all (an old binary rejecting the unrecognized `serve`
2847
+ // subcommand), or to "error" if it printed a real ERROR first (e.g. no
2848
+ // model installed yet) — already surfaced via onError, so that case skips
2849
+ // the legacy fallback (it would just hit the same error) but leaves
2850
+ // daemon mode available to retry on the next press.
2786
2851
  this.voiceStarting = true;
2787
- this.showStatus("Preparing voice input...");
2852
+ this.showVoiceWarming("Warming up voice input...");
2788
2853
  void this.resolveVoiceBin()
2789
- .then((bin) => {
2790
- // A second key press while resolving cleared the flag: honour the cancel.
2791
- if (!this.voiceStarting)
2792
- return;
2793
- this.voiceStarting = false;
2854
+ .then(async (bin) => {
2794
2855
  if (!bin) {
2795
- this.showError("Voice input failed: voicetools binary unavailable and could not be downloaded. " +
2796
- "Install it, set VOICETOOLS_BIN, or ensure a published release exists for this platform.");
2856
+ this.voiceStarting = false;
2857
+ this.resetVoiceUI();
2858
+ this.showError(VOICE_UNAVAILABLE_MESSAGE);
2797
2859
  return;
2798
2860
  }
2799
- this.voiceSession = startVoiceTranscribe(bin, {
2800
- onStatus: (status) => {
2801
- this.showStatus(statusLabels[status] ?? status);
2802
- if (status === "done") {
2803
- this.voiceSession = undefined;
2804
- }
2805
- },
2806
- onSegment: (text) => {
2807
- // Inject via bracketed paste so the editor treats it as pasted text.
2808
- this.editor.handleInput(`\x1b[200~${text} \x1b[201~`);
2809
- },
2810
- onError: (message) => {
2811
- this.voiceSession = undefined;
2812
- this.showError(`Voice input failed: ${message}`);
2813
- },
2861
+ const result = await VoiceDaemon.spawn(bin, this.buildVoiceDaemonHandlers(), {
2862
+ silenceMs: VOICE_SILENCE_MS,
2863
+ idleTimeoutMs: VOICE_IDLE_TIMEOUT_MS,
2814
2864
  });
2865
+ if (!result.ok) {
2866
+ if (result.reason === "unsupported") {
2867
+ this.voiceDaemonUnsupported = true;
2868
+ if (!this.voiceStarting) {
2869
+ this.resetVoiceUI();
2870
+ return;
2871
+ }
2872
+ this.voiceStarting = false;
2873
+ this.beginLegacyVoiceCapture(bin);
2874
+ return;
2875
+ }
2876
+ this.voiceStarting = false;
2877
+ this.resetVoiceUI();
2878
+ return;
2879
+ }
2880
+ this.voiceDaemon = result.daemon;
2881
+ if (!this.voiceStarting) {
2882
+ // Cancelled while warming up: keep the loaded daemon warm for next time.
2883
+ this.resetVoiceUI();
2884
+ return;
2885
+ }
2886
+ this.voiceStarting = false;
2887
+ this.beginDaemonVoiceCapture();
2815
2888
  })
2816
2889
  .catch((err) => {
2817
2890
  this.voiceStarting = false;
2891
+ this.resetVoiceUI();
2818
2892
  this.showError(`Voice input failed: ${err instanceof Error ? err.message : String(err)}`);
2819
2893
  });
2820
2894
  }
2821
2895
  /**
2822
- * Resolve the `voicetools` binary path. Prefers an explicit VOICETOOLS_BIN
2896
+ * Build the (stable, reused-across-captures) handlers for the daemon.
2897
+ *
2898
+ * Every handler bails out once `voiceActive` is false: CANCEL is a soft
2899
+ * request (unlike the legacy path's `proc.kill()`, it doesn't sever the
2900
+ * pipe), so the daemon can still have a trailing PARTIAL/FINAL/DONE for the
2901
+ * just-cancelled capture in flight when the user presses cancel. Without
2902
+ * this guard that stale text would land in the editor after the panel had
2903
+ * already collapsed.
2904
+ *
2905
+ * v0.1.4 serve streams `PARTIAL <full growing hypothesis>` (live preview,
2906
+ * never committed) and ends with a single `FINAL <complete text>` (the one
2907
+ * commit to the editor). `SEGMENT` is still handled for the legacy
2908
+ * transcribe path and any binary that streams committed chunks directly.
2909
+ */
2910
+ buildVoiceDaemonHandlers() {
2911
+ return {
2912
+ onSegment: (text) => {
2913
+ if (!this.voiceActive)
2914
+ return;
2915
+ this.commitVoiceText(text);
2916
+ },
2917
+ onPartial: (text) => {
2918
+ if (!this.voiceActive)
2919
+ return;
2920
+ this.voicePanel?.setPartial(text);
2921
+ },
2922
+ onFinal: (text) => {
2923
+ if (!this.voiceActive)
2924
+ return;
2925
+ this.commitVoiceText(text);
2926
+ },
2927
+ onStatus: (status) => {
2928
+ if (!this.voiceActive)
2929
+ return;
2930
+ if (status === "done") {
2931
+ this.voiceActive = false;
2932
+ this.resetVoiceUI();
2933
+ return;
2934
+ }
2935
+ if (status === "transcribing") {
2936
+ this.voicePanel?.setTranscribing();
2937
+ }
2938
+ else if (status === "listening") {
2939
+ this.voicePanel?.startListening();
2940
+ }
2941
+ },
2942
+ onLevel: (rms) => {
2943
+ if (!this.voiceActive)
2944
+ return;
2945
+ this.voicePanel?.pushLevel(rms);
2946
+ },
2947
+ onPhase: (phase) => {
2948
+ if (!this.voiceActive)
2949
+ return;
2950
+ if (phase === "silence") {
2951
+ this.voicePanel?.beginSilence(VOICE_SILENCE_MS);
2952
+ }
2953
+ else {
2954
+ this.voicePanel?.endSilence();
2955
+ }
2956
+ },
2957
+ onError: (message) => {
2958
+ this.voiceActive = false;
2959
+ this.resetVoiceUI();
2960
+ this.showError(`Voice input failed: ${message}`);
2961
+ },
2962
+ onCrash: (message) => {
2963
+ this.voiceActive = false;
2964
+ this.voiceDaemon = undefined;
2965
+ this.resetVoiceUI();
2966
+ this.showError(`Voice input daemon crashed: ${message}. It will restart on next use.`);
2967
+ },
2968
+ onIdle: () => {
2969
+ // Daemon auto-shut down after idle timeout: drop the reference so the
2970
+ // next ctrl+r respawns (cold start). No user-facing message — this is
2971
+ // an expected memory-reclamation event, not an error.
2972
+ this.voiceDaemon = undefined;
2973
+ },
2974
+ };
2975
+ }
2976
+ /** Inject decoded text into the editor via bracketed paste (with a trailing space). */
2977
+ commitVoiceText(text) {
2978
+ this.editor.handleInput(`\x1b[200~${text} \x1b[201~`);
2979
+ }
2980
+ beginDaemonVoiceCapture() {
2981
+ if (!this.voiceDaemon?.isReady)
2982
+ return;
2983
+ this.voiceActive = true;
2984
+ this.showVoicePanel().startListening();
2985
+ this.voiceDaemon.startCapture();
2986
+ }
2987
+ beginLegacyVoiceCapture(bin) {
2988
+ this.voiceActive = true;
2989
+ const panel = this.showVoicePanel();
2990
+ panel.startListening();
2991
+ this.voiceSession = startVoiceTranscribe(bin, {
2992
+ onStatus: (status) => {
2993
+ if (status === "done") {
2994
+ this.voiceActive = false;
2995
+ this.voiceSession = undefined;
2996
+ this.resetVoiceUI();
2997
+ return;
2998
+ }
2999
+ // Old binaries emit no LEVEL/PARTIAL, so the panel shows a spinner
3000
+ // for the batch phases; committed words still stream into the editor.
3001
+ if (status === "transcribing")
3002
+ panel.setTranscribing();
3003
+ },
3004
+ onSegment: (text) => {
3005
+ this.commitVoiceText(text);
3006
+ },
3007
+ onError: (message) => {
3008
+ this.voiceActive = false;
3009
+ this.voiceSession = undefined;
3010
+ this.resetVoiceUI();
3011
+ this.showError(`Voice input failed: ${message}`);
3012
+ },
3013
+ });
3014
+ }
3015
+ /** Resolve the `voicetools` binary path. Prefers an explicit VOICETOOLS_BIN
2823
3016
  * override, otherwise resolves via the managed tools manager (bin dir / PATH /
2824
3017
  * download from the published release). Returns undefined when unavailable.
2825
3018
  */
@@ -2829,6 +3022,28 @@ export class InteractiveMode {
2829
3022
  return override;
2830
3023
  return ensureTool("voicetools", true);
2831
3024
  }
3025
+ /** Create (or reuse) the voice panel and mount it in the status container. */
3026
+ showVoicePanel() {
3027
+ if (!this.voicePanel) {
3028
+ this.statusContainer.clear();
3029
+ this.voicePanel = new VoicePanel(this.ui, keyHint("app.input.voiceTranscribe", "cancel"));
3030
+ this.statusContainer.addChild(this.voicePanel);
3031
+ }
3032
+ this.ui.requestRender();
3033
+ return this.voicePanel;
3034
+ }
3035
+ showVoiceWarming(message) {
3036
+ this.showVoicePanel().setWarming(message);
3037
+ }
3038
+ /** Collapse the voice panel back to nothing (idle) and stop its animation. */
3039
+ resetVoiceUI() {
3040
+ if (this.voicePanel) {
3041
+ this.voicePanel.dispose();
3042
+ this.voicePanel = undefined;
3043
+ }
3044
+ this.statusContainer.clear();
3045
+ this.ui.requestRender();
3046
+ }
2832
3047
  showStatus(message) {
2833
3048
  const children = this.chatContainer.children;
2834
3049
  const last = children.length > 0 ? children[children.length - 1] : undefined;
@@ -4507,6 +4722,13 @@ export class InteractiveMode {
4507
4722
  this.voiceSession.stop();
4508
4723
  this.voiceSession = undefined;
4509
4724
  }
4725
+ this.voiceDaemon?.shutdown();
4726
+ this.voiceDaemon = undefined;
4727
+ if (this.voicePanel) {
4728
+ this.voicePanel.dispose();
4729
+ this.voicePanel = undefined;
4730
+ }
4731
+ this.voiceActive = false;
4510
4732
  this.voiceStarting = false;
4511
4733
  if (this.settingsManager.getShowTerminalProgress()) {
4512
4734
  this.ui.terminal.setProgress(false);