diff --git a/internal/web/server.go b/internal/web/server.go
index 2fe4b65b..8c128465 100644
--- a/internal/web/server.go
+++ b/internal/web/server.go
@@ -689,6 +689,43 @@ func (s Server) handleChat(w http.ResponseWriter, r *http.Request) {
return grid.querySelector(".chat-repo-card[aria-expanded='true'] .chat-repo-prompt");
}
+ function activeChatSpeechStream() {
+ const input = activeChatPromptInput();
+ return input ? input.closest(".speech-stream-wrap")?.querySelector(".speech-stream") : null;
+ }
+
+ function clampSpeechStreamLevel(level) {
+ if (!Number.isFinite(level)) return 0;
+ return Math.max(0, Math.min(1, level));
+ }
+
+ function setSpeechStreamActive(stream, active) {
+ if (!(stream instanceof HTMLElement)) return;
+ stream.classList.toggle("is-active", Boolean(active));
+ if (!active) {
+ stream.style.setProperty("--speech-level", "0");
+ }
+ }
+
+ function setSpeechStreamLevel(stream, level) {
+ if (!(stream instanceof HTMLElement)) return;
+ stream.style.setProperty("--speech-level", clampSpeechStreamLevel(level).toFixed(3));
+ }
+
+ function speechStreamLevelFromSamples(input) {
+ if (!input || !input.length) return 0;
+ let sum = 0;
+ let peak = 0;
+ for (let i = 0; i < input.length; i += 1) {
+ const sample = Math.max(-1, Math.min(1, Number(input[i]) || 0));
+ const absSample = Math.abs(sample);
+ if (absSample > peak) peak = absSample;
+ sum += sample * sample;
+ }
+ const rms = Math.sqrt(sum / input.length);
+ return Math.max(peak * 1.8, rms * 16);
+ }
+
function setChatSpeechStatus(tone, message) {
status.textContent = message;
status.dataset.tone = tone || "";
@@ -754,6 +791,7 @@ func (s Server) handleChat(w http.ResponseWriter, r *http.Request) {
setChatSpeechStatus("error", "Open a repository prompt before dictating.");
return;
}
+ const stream = activeChatSpeechStream();
if (!browserSpeechCaptureSupported()) {
setChatSpeechStatus("error", "This browser does not support microphone capture.");
return;
@@ -790,6 +828,7 @@ func (s Server) handleChat(w http.ResponseWriter, r *http.Request) {
const inputBuffer = event.inputBuffer.getChannelData(0);
const downsampled = downsampleSpeech(inputBuffer, speech.audioContext.sampleRate, SPEECH_SAMPLE_RATE);
updateSpeechCaptureStats(downsampled);
+ setSpeechStreamLevel(stream, speechStreamLevelFromSamples(downsampled));
speech.chunks.push(floatToSpeechPCM(downsampled));
};
speech.source.connect(speech.processor);
@@ -797,6 +836,7 @@ func (s Server) handleChat(w http.ResponseWriter, r *http.Request) {
speech.zeroGain.connect(speech.audioContext.destination);
speech.recording = true;
syncChatSpeechButton();
+ setSpeechStreamActive(stream, true);
input.focus();
setChatSpeechStatus("warn", "Listening. Press the mic again to stop.");
trackChatEvent("chat_speech_started");
@@ -871,6 +911,7 @@ func (s Server) handleChat(w http.ResponseWriter, r *http.Request) {
speech.zeroGain = null;
speech.chunks = [];
resetSpeechCaptureStats();
+ setSpeechStreamActive(activeChatSpeechStream(), false);
syncChatSpeechButton();
}
@@ -1078,6 +1119,12 @@ func (s Server) handleChat(w http.ResponseWriter, r *http.Request) {
input.rows = 3;
input.placeholder = "Prompt for default branch";
input.setAttribute("aria-label", "Prompt for " + String(repo.full_name || repo.name || "repository"));
+ const inputWrap = document.createElement("span");
+ inputWrap.className = "speech-stream-wrap chat-repo-prompt-wrap";
+ const speechStream = document.createElement("span");
+ speechStream.className = "speech-stream chat-speech-stream";
+ speechStream.setAttribute("aria-hidden", "true");
+ inputWrap.append(input, speechStream);
const panelStatus = document.createElement("span");
panelStatus.className = "chat-repo-submit-status";
@@ -1096,7 +1143,7 @@ func (s Server) handleChat(w http.ResponseWriter, r *http.Request) {
input.addEventListener("pointerdown", (event) => {
event.stopPropagation();
});
- panel.append(input, panelStatus);
+ panel.append(inputWrap, panelStatus);
card.appendChild(panel);
const openPrompt = () => {
diff --git a/internal/web/static/index.html b/internal/web/static/index.html
index 6322abca..b5f13792 100644
--- a/internal/web/static/index.html
+++ b/internal/web/static/index.html
@@ -185,12 +185,15 @@
@@ -1913,6 +1916,7 @@
Connect to Hub
const localPromptStatusDefaultText = String(localPromptStatus?.dataset.defaultText || "").trim();
let localPromptStatusTimer = 0;
const promptSpeechToggle = document.getElementById("prompt-speech-toggle");
+ const builderSpeechStream = document.getElementById("builder-speech-stream");
const promptModeBuilder = document.getElementById("prompt-mode-builder");
const promptModeLibrary = document.getElementById("prompt-mode-library");
const promptModeJSON = document.getElementById("prompt-mode-json");
@@ -5167,6 +5171,38 @@ Connect to Hub
replaceLucideIcons(promptSpeechToggle);
}
+ function clampSpeechStreamLevel(level) {
+ if (!Number.isFinite(level)) return 0;
+ return Math.max(0, Math.min(1, level));
+ }
+
+ function setSpeechStreamActive(stream, active) {
+ if (!(stream instanceof HTMLElement)) return;
+ stream.classList.toggle("is-active", Boolean(active));
+ if (!active) {
+ stream.style.setProperty("--speech-level", "0");
+ }
+ }
+
+ function setSpeechStreamLevel(stream, level) {
+ if (!(stream instanceof HTMLElement)) return;
+ stream.style.setProperty("--speech-level", clampSpeechStreamLevel(level).toFixed(3));
+ }
+
+ function speechStreamLevelFromSamples(input) {
+ if (!input || !input.length) return 0;
+ let sum = 0;
+ let peak = 0;
+ for (let i = 0; i < input.length; i += 1) {
+ const sample = Math.max(-1, Math.min(1, Number(input[i]) || 0));
+ const absSample = Math.abs(sample);
+ if (absSample > peak) peak = absSample;
+ sum += sample * sample;
+ }
+ const rms = Math.sqrt(sum / input.length);
+ return Math.max(peak * 1.8, rms * 16);
+ }
+
async function refreshSpeechStatus() {
try {
const response = await fetch("/api/speech/status", { cache: "no-store" });
@@ -5239,6 +5275,7 @@ Connect to Hub
const input = event.inputBuffer.getChannelData(0);
const downsampled = downsampleSpeech(input, state.speech.audioContext.sampleRate, SPEECH_SAMPLE_RATE);
updateSpeechCaptureStats(downsampled);
+ setSpeechStreamLevel(builderSpeechStream, speechStreamLevelFromSamples(downsampled));
state.speech.chunks.push(floatToSpeechPCM(downsampled));
};
state.speech.source.connect(state.speech.processor);
@@ -5246,6 +5283,7 @@ Connect to Hub
state.speech.zeroGain.connect(state.speech.audioContext.destination);
state.speech.recording = true;
syncPromptSpeechButton();
+ setSpeechStreamActive(builderSpeechStream, true);
setLocalPromptStatus("warn", "Listening. Press the mic again to stop.");
trackAnalyticsEvent("prompt_speech_started", { prompt_mode: state.promptMode });
} catch (err) {
@@ -5324,6 +5362,7 @@ Connect to Hub
state.speech.zeroGain = null;
state.speech.chunks = [];
resetSpeechCaptureStats();
+ setSpeechStreamActive(builderSpeechStream, false);
syncPromptSpeechButton();
}
@@ -6556,6 +6595,12 @@ Connect to Hub
if (repoKey && state.chatPromptDrafts.has(repoKey)) {
prompt.value = state.chatPromptDrafts.get(repoKey);
}
+ const promptWrap = document.createElement("span");
+ promptWrap.className = "speech-stream-wrap chat-repo-prompt-wrap";
+ const speechStream = document.createElement("span");
+ speechStream.className = "speech-stream chat-speech-stream";
+ speechStream.setAttribute("aria-hidden", "true");
+ promptWrap.append(prompt, speechStream);
const pasteTarget = document.createElement("div");
pasteTarget.className = "prompt-control prompt-action-paste chat-repo-image-paste";
@@ -6634,7 +6679,7 @@ Connect to Hub
event.stopPropagation();
});
imageActions.append(pasteTarget, submitStatus);
- panel.append(promptLog, prompt, imageActions);
+ panel.append(promptLog, promptWrap, imageActions);
card.appendChild(panel);
const openPrompt = () => {
diff --git a/internal/web/static/style.css b/internal/web/static/style.css
index fe1ab343..85fe2eac 100644
--- a/internal/web/static/style.css
+++ b/internal/web/static/style.css
@@ -1711,6 +1711,64 @@ select.prompt-control {
background: var(--surface-control-bg-strong);
}
+.speech-stream-wrap {
+ position: relative;
+ display: block;
+ min-width: 0;
+}
+
+.speech-stream {
+ --speech-level: 0;
+ position: absolute;
+ top: 9px;
+ left: 10px;
+ right: 10px;
+ height: 18px;
+ overflow: hidden;
+ border-radius: 999px;
+ opacity: 0;
+ pointer-events: none;
+ transform: translateY(-2px);
+ transition: opacity 0.16s ease, transform 0.16s ease;
+}
+
+.speech-stream.is-active {
+ opacity: calc(0.28 + (var(--speech-level) * 0.72));
+ transform: translateY(0);
+}
+
+.speech-stream::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ width: 220%;
+ background:
+ linear-gradient(90deg, transparent 0%, rgba(10, 132, 255, 0.2) 12%, rgba(10, 132, 255, 0.68) 22%, transparent 34%),
+ repeating-linear-gradient(90deg, transparent 0 8px, rgba(10, 132, 255, 0.32) 8px 10px, transparent 10px 16px);
+ filter: drop-shadow(0 0 5px rgba(10, 132, 255, 0.28));
+ transform: translateX(0);
+ animation: speech-stream-flow 1.1s linear infinite;
+}
+
+.speech-stream::after {
+ content: "";
+ position: absolute;
+ inset: 4px 0;
+ background: linear-gradient(90deg, transparent, rgba(10, 132, 255, 0.54), transparent);
+ opacity: calc(0.16 + var(--speech-level) * 0.38);
+ transform: scaleY(calc(0.35 + (var(--speech-level) * 0.9)));
+ transform-origin: center;
+}
+
+@keyframes speech-stream-flow {
+ from {
+ transform: translateX(0);
+ }
+ to {
+ transform: translateX(-50%);
+ }
+}
+
#builder-prompt-input,
#local-prompt-input {
width: 100%;
@@ -3699,6 +3757,10 @@ select.prompt-control {
line-height: 1.35;
}
+.chat-repo-prompt-wrap {
+ display: block;
+}
+
.chat-repo-prompt:focus {
border-color: var(--border-strong);
outline: none;