+ {state === "recording" && (
+
{formatTime(elapsed)}
+ )}
+ {state === "error" && error && (
+
+ {error}
+
+ )}
{hovered && state !== "recording" && (
-
+
{state === "transcribing" ? "Transcribing..." : (
<>Speech to text Ctrl+Shift+M>
)}
)}
- {state === "recording" && (
-
- {formatTime(elapsed)}
-
- )}
- {state === "error" && error && (
-
- {error}
-
- )}
);
}
diff --git a/app/src/components/terminal/TerminalView.tsx b/app/src/components/terminal/TerminalView.tsx
index 083b556..ee871b6 100644
--- a/app/src/components/terminal/TerminalView.tsx
+++ b/app/src/components/terminal/TerminalView.tsx
@@ -7,8 +7,6 @@ import { openUrl } from "@tauri-apps/plugin-opener";
import "@xterm/xterm/css/xterm.css";
import { useTerminal } from "../../hooks/useTerminal";
import { useAppState } from "../../store/appState";
-import { useSTT } from "../../hooks/useSTT";
-import SttButton from "./SttButton";
import { awsSsoRefresh } from "../../lib/tauri-commands";
import { UrlDetector } from "../../lib/urlDetector";
import UrlToast from "./UrlToast";
@@ -29,10 +27,8 @@ export default function TerminalView({ sessionId, active }: Props) {
const detectorRef = useRef
(null);
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
const setTerminalHasSelection = useAppState(s => s.setTerminalHasSelection);
- const sttEnabled = useAppState(s => s.appSettings?.stt?.enabled);
- const stt = useSTT(sessionId, sendInput);
- const sttToggleRef = useRef(stt.toggle);
- sttToggleRef.current = stt.toggle;
+ const setTerminalAtBottom = useAppState(s => s.setTerminalAtBottom);
+ const setScrollActiveToBottom = useAppState(s => s.setScrollActiveToBottom);
const ssoBufferRef = useRef("");
const ssoTriggeredRef = useRef(false);
@@ -111,9 +107,10 @@ export default function TerminalView({ sessionId, active }: Props) {
}
return false; // prevent xterm from processing this key
}
- // Ctrl+Shift+M toggles speech-to-text recording
+ // Ctrl+Shift+M toggles speech-to-text recording (mic lives in the status
+ // bar, bound to the active session; trigger it via the store).
if (event.type === "keydown" && event.ctrlKey && event.shiftKey && event.key === "M") {
- sttToggleRef.current();
+ useAppState.getState().sttToggle();
return false;
}
return true;
@@ -319,6 +316,7 @@ export default function TerminalView({ sessionId, active }: Props) {
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
webglRef.current = null;
term.dispose();
+ termRef.current = null;
};
}, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -393,6 +391,29 @@ export default function TerminalView({ sessionId, active }: Props) {
}
}, []);
+ // Surface this terminal's scroll state to the status bar's "Jump to Current"
+ // control, but only while it's the active (visible) terminal.
+ useEffect(() => {
+ if (!active) return;
+ setTerminalAtBottom(isAtBottom);
+ setScrollActiveToBottom(handleScrollToBottom);
+ }, [active, isAtBottom, handleScrollToBottom, setTerminalAtBottom, setScrollActiveToBottom]);
+
+ // On unmount, if this was the active terminal, clear the status-bar scroll
+ // state so it doesn't point at a disposed terminal. (Tab switches don't
+ // unmount — the deactivating terminal stays mounted but hidden — so this
+ // only fires when the active session is actually closed.)
+ const activeRef = useRef(active);
+ activeRef.current = active;
+ useEffect(() => {
+ return () => {
+ if (activeRef.current) {
+ setTerminalAtBottom(true);
+ setScrollActiveToBottom(() => {});
+ }
+ };
+ }, [setTerminalAtBottom, setScrollActiveToBottom]);
+
const writeSelection = useCallback((mode: "trimmed" | "raw") => {
const term = termRef.current;
if (!term) return;
@@ -457,23 +478,19 @@ export default function TerminalView({ sessionId, active }: Props) {
>
{isAutoFollow ? "▼ Following" : "▽ Paused"}
- {/* STT mic button - bottom left */}
- {sttEnabled && }
- {/* Jump to Current - bottom right, when scrolled up */}
- {!isAtBottom && (
-
- )}
-
+ {/* Padding lives on this wrapper, NOT on the xterm host element. xterm's
+ FitAddon measures the host element it's mounted into; padding there
+ causes the grid to overhang and clip the rightmost column / bottom
+ row. The host below fills this wrapper's content box with no padding.
+ Kept to a tight, even gutter so the terminal claims as much area as
+ possible while leaving a little breathing room beside the scrollbar. */}
+
{contextMenu && (
(null);
const workletRef = useRef(null);
const chunksRef = useRef([]);
+ // Pin the transcript to the terminal that was active when recording STARTED.
+ // The hook is bound to the live active session, which can change mid-recording
+ // (the user switches tabs); without this the transcript would land in whatever
+ // tab is active at stop time.
+ const recordingSessionIdRef = useRef(sessionId);
const appSettings = useAppState((s) => s.appSettings);
const deviceId = appSettings?.default_microphone;
@@ -22,6 +27,7 @@ export function useSTT(sessionId: string, sendInput: (sessionId: string, data: s
setState("recording");
setError(null);
chunksRef.current = [];
+ recordingSessionIdRef.current = sessionId;
try {
const audioConstraints: MediaTrackConstraints = {
@@ -57,7 +63,7 @@ export function useSTT(sessionId: string, sendInput: (sessionId: string, data: s
setError(msg);
setState("error");
}
- }, [state, deviceId]);
+ }, [state, deviceId, sessionId]);
const stopRecording = useCallback(async () => {
if (state !== "recording") return;
@@ -102,7 +108,7 @@ export function useSTT(sessionId: string, sendInput: (sessionId: string, data: s
const text = await commands.transcribeAudio(audioData);
if (text) {
- await sendInput(sessionId, text);
+ await sendInput(recordingSessionIdRef.current, text);
}
setState("idle");
} catch (e) {
@@ -112,7 +118,7 @@ export function useSTT(sessionId: string, sendInput: (sessionId: string, data: s
// Reset to idle after a brief delay so the UI shows the error
setTimeout(() => setState("idle"), 3000);
}
- }, [state, sessionId, sendInput]);
+ }, [state, sendInput]);
const cancelRecording = useCallback(async () => {
workletRef.current?.disconnect();
diff --git a/app/src/store/appState.ts b/app/src/store/appState.ts
index bf9cdb0..37a409e 100644
--- a/app/src/store/appState.ts
+++ b/app/src/store/appState.ts
@@ -44,6 +44,16 @@ interface AppState {
// UI state
terminalHasSelection: boolean;
setTerminalHasSelection: (has: boolean) => void;
+ // STT toggle for the active session, registered by App so the terminal's
+ // Ctrl+Shift+M shortcut can trigger the single status-bar mic instance.
+ sttToggle: () => void;
+ setSttToggle: (fn: () => void) => void;
+ // Active terminal scroll state, surfaced so the status bar can host the
+ // "Jump to Current" control. Only the active TerminalView writes these.
+ terminalAtBottom: boolean;
+ setTerminalAtBottom: (v: boolean) => void;
+ scrollActiveToBottom: () => void;
+ setScrollActiveToBottom: (fn: () => void) => void;
sidebarView: "projects" | "mcp" | "settings";
setSidebarView: (view: "projects" | "mcp" | "settings") => void;
sidebarCollapsed: boolean;
@@ -125,6 +135,12 @@ export const useAppState = create((set) => ({
// UI state
terminalHasSelection: false,
setTerminalHasSelection: (has) => set({ terminalHasSelection: has }),
+ sttToggle: () => {},
+ setSttToggle: (fn) => set({ sttToggle: fn }),
+ terminalAtBottom: true,
+ setTerminalAtBottom: (v) => set({ terminalAtBottom: v }),
+ scrollActiveToBottom: () => {},
+ setScrollActiveToBottom: (fn) => set({ scrollActiveToBottom: fn }),
sidebarView: "projects",
setSidebarView: (view) => set({ sidebarView: view }),
sidebarCollapsed: loadSidebarCollapsed(),