Compare commits

...
5 Commits
Author SHA1 Message Date
jknapp 17c5d699f9 Merge pull request 'STT improvements: hotkey, button position, and hover tooltip' (#2) from feature/stt into main
Build App / compute-version (push) Successful in 2s
Build App / build-macos (push) Successful in 2m27s
Build App / build-windows (push) Successful in 4m4s
Build App / build-linux (push) Successful in 4m56s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 11s
Reviewed-on: #2
2026-04-13 13:02:53 +00:00
shadow-test e62af502d3 Merge remote-tracking branch 'origin/main' into feature/stt
Build App / compute-version (pull_request) Successful in 2s
Build App / build-macos (pull_request) Successful in 2m23s
Build App / build-windows (pull_request) Successful in 3m53s
Build App / build-linux (pull_request) Successful in 4m47s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
2026-04-13 06:02:27 -07:00
shadow-testandClaude Opus 4.6 3e9053946f Add styled hover tooltip to STT button showing Ctrl+Shift+M shortcut
Build App / compute-version (pull_request) Successful in 3s
Build App / build-macos (pull_request) Successful in 2m25s
Build App / build-windows (pull_request) Successful in 4m36s
Build App / build-linux (pull_request) Successful in 4m43s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
Replaces the native title attribute with a custom tooltip that appears
instantly on hover, displaying the shortcut in a styled kbd element.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 05:58:29 -07:00
shadow-testandClaude Opus 4.6 3bbd7fd55f Move STT mic button to bottom-left corner to avoid clipping Claude Code status line
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 05:56:59 -07:00
shadow-testandClaude Opus 4.6 49d09e4447 Add Ctrl+Shift+M hotkey for speech-to-text toggle
Lifts useSTT hook from SttButton into TerminalView so both the hotkey
and the button share the same recording state. The hotkey keeps terminal
focus so after transcription the user just presses Enter. The button
also no longer steals focus via onMouseDown preventDefault.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 05:55:52 -07:00
2 changed files with 51 additions and 35 deletions
+41 -34
View File
@@ -1,15 +1,17 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useSTT } from "../../hooks/useSTT"; import type { SttState } from "../../hooks/useSTT";
import * as commands from "../../lib/tauri-commands"; import * as commands from "../../lib/tauri-commands";
interface Props { interface Props {
sessionId: string; state: SttState;
sendInput: (sessionId: string, data: string) => Promise<void>; error: string | null;
onToggle: () => Promise<void>;
onCancel: () => Promise<void>;
} }
export default function SttButton({ sessionId, sendInput }: Props) { export default function SttButton({ state, error, onToggle, onCancel }: Props) {
const { state, error, toggle, cancelRecording } = useSTT(sessionId, sendInput);
const [elapsed, setElapsed] = useState(0); const [elapsed, setElapsed] = useState(0);
const [hovered, setHovered] = useState(false);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null); const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Track recording duration // Track recording duration
@@ -40,17 +42,17 @@ export default function SttButton({ sessionId, sendInput }: Props) {
// Container start failed, toggle will still attempt transcription // Container start failed, toggle will still attempt transcription
} }
} }
await toggle(); await onToggle();
}, [state, toggle]); }, [state, onToggle]);
const handleContextMenu = useCallback( const handleContextMenu = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
if (state === "recording") { if (state === "recording") {
cancelRecording(); onCancel();
} }
}, },
[state, cancelRecording], [state, onCancel],
); );
const formatTime = (seconds: number) => { const formatTime = (seconds: number) => {
@@ -60,38 +62,43 @@ export default function SttButton({ sessionId, sendInput }: Props) {
}; };
return ( return (
<div className="absolute bottom-4 left-4 z-50 flex items-center gap-2"> <div className="absolute bottom-1 left-1 z-50 flex items-center gap-2">
<button <div className="relative">
onClick={handleClick} <button
onContextMenu={handleContextMenu} onClick={handleClick}
disabled={state === "transcribing"} onContextMenu={handleContextMenu}
className={`w-8 h-8 rounded-full flex items-center justify-center transition-all cursor-pointer ${ onMouseDown={(e) => e.preventDefault()} // prevent stealing focus from terminal
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
disabled={state === "transcribing"}
className={`w-8 h-8 rounded-full flex items-center justify-center transition-all cursor-pointer ${
state === "recording" state === "recording"
? "bg-[#f85149] text-white shadow-lg animate-pulse" ? "bg-[#f85149] text-white shadow-lg animate-pulse"
: state === "transcribing" : state === "transcribing"
? "bg-[#1f2937] text-[#58a6ff] border border-[#30363d] opacity-80" ? "bg-[#1f2937] text-[#58a6ff] border border-[#30363d] opacity-80"
: "bg-[#1f2937]/80 text-[#8b949e] border border-[#30363d] hover:text-[#e6edf3] hover:bg-[#2d3748]" : "bg-[#1f2937]/80 text-[#8b949e] border border-[#30363d] hover:text-[#e6edf3] hover:bg-[#2d3748]"
}`} }`}
title={ >
state === "recording" {state === "transcribing" ? (
? "Click to stop and transcribe (right-click to cancel)" <svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none">
: state === "transcribing" <circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" opacity="0.25" />
? "Transcribing..." <path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
: "Speech to text" </svg>
} ) : (
> <svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
{state === "transcribing" ? ( <path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
<svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none"> <path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" opacity="0.25" /> </svg>
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /> )}
</svg> </button>
) : ( {hovered && state !== "recording" && (
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"> <div className="absolute bottom-full left-0 mb-1.5 px-2 py-1 text-[11px] leading-snug text-[#e6edf3] bg-[#21262d] border border-[#30363d] rounded shadow-lg whitespace-nowrap pointer-events-none">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" /> {state === "transcribing" ? "Transcribing..." : (
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" /> <>Speech to text <kbd className="ml-1 px-1 py-0.5 text-[10px] bg-[#0d1117] border border-[#30363d] rounded font-mono">Ctrl+Shift+M</kbd></>
</svg> )}
</div>
)} )}
</button> </div>
{state === "recording" && ( {state === "recording" && (
<span className="text-xs text-[#f85149] font-mono bg-[#1f2937] px-2 py-0.5 rounded border border-[#30363d]"> <span className="text-xs text-[#f85149] font-mono bg-[#1f2937] px-2 py-0.5 rounded border border-[#30363d]">
{formatTime(elapsed)} {formatTime(elapsed)}
+10 -1
View File
@@ -7,6 +7,7 @@ import { openUrl } from "@tauri-apps/plugin-opener";
import "@xterm/xterm/css/xterm.css"; import "@xterm/xterm/css/xterm.css";
import { useTerminal } from "../../hooks/useTerminal"; import { useTerminal } from "../../hooks/useTerminal";
import { useAppState } from "../../store/appState"; import { useAppState } from "../../store/appState";
import { useSTT } from "../../hooks/useSTT";
import SttButton from "./SttButton"; import SttButton from "./SttButton";
import { awsSsoRefresh } from "../../lib/tauri-commands"; import { awsSsoRefresh } from "../../lib/tauri-commands";
import { UrlDetector } from "../../lib/urlDetector"; import { UrlDetector } from "../../lib/urlDetector";
@@ -27,6 +28,9 @@ export default function TerminalView({ sessionId, active }: Props) {
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal(); const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
const setTerminalHasSelection = useAppState(s => s.setTerminalHasSelection); const setTerminalHasSelection = useAppState(s => s.setTerminalHasSelection);
const sttEnabled = useAppState(s => s.appSettings?.stt?.enabled); const sttEnabled = useAppState(s => s.appSettings?.stt?.enabled);
const stt = useSTT(sessionId, sendInput);
const sttToggleRef = useRef(stt.toggle);
sttToggleRef.current = stt.toggle;
const ssoBufferRef = useRef(""); const ssoBufferRef = useRef("");
const ssoTriggeredRef = useRef(false); const ssoTriggeredRef = useRef(false);
@@ -102,6 +106,11 @@ export default function TerminalView({ sessionId, active }: Props) {
} }
return false; // prevent xterm from processing this key return false; // prevent xterm from processing this key
} }
// Ctrl+Shift+M toggles speech-to-text recording
if (event.type === "keydown" && event.ctrlKey && event.shiftKey && event.key === "M") {
sttToggleRef.current();
return false;
}
return true; return true;
}); });
@@ -427,7 +436,7 @@ export default function TerminalView({ sessionId, active }: Props) {
{isAutoFollow ? "▼ Following" : "▽ Paused"} {isAutoFollow ? "▼ Following" : "▽ Paused"}
</button> </button>
{/* STT mic button - bottom left */} {/* STT mic button - bottom left */}
{sttEnabled && <SttButton sessionId={sessionId} sendInput={sendInput} />} {sttEnabled && <SttButton state={stt.state} error={stt.error} onToggle={stt.toggle} onCancel={stt.cancelRecording} />}
{/* Jump to Current - bottom right, when scrolled up */} {/* Jump to Current - bottom right, when scrolled up */}
{!isAtBottom && ( {!isAtBottom && (
<button <button