From 3d2d979197b603624392b86671ea860b599ea9a1 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 28 Jun 2026 19:26:05 -0700 Subject: [PATCH 1/2] Fix xterm clipping; move mic + Jump to Current into status bar Terminal layout fixes for the xterm pane: - Stop the terminal grid from clipping its rightmost column / bottom row. The padding was on the element xterm mounts into, which the FitAddon measures; the grid overhang got clipped. Padding now lives on a wrapper and the xterm host fills it with no padding. - Move the STT mic from a floating bottom-left overlay into the status bar (far right). A single useSTT instance bound to the active session now lives in App; Ctrl+Shift+M routes through the store. - Move "Jump to Current" from a floating terminal overlay into the status bar. The active TerminalView surfaces its scroll state and scroll action via the store. - Tighten terminal padding (was 8/12/48/16) now that nothing floats over it, so the terminal claims as much area as possible. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/App.tsx | 17 +++++-- app/src/components/layout/StatusBar.tsx | 43 +++++++++++++++-- app/src/components/terminal/SttButton.tsx | 34 ++++++------- app/src/components/terminal/TerminalView.tsx | 51 ++++++++++---------- app/src/store/appState.ts | 16 ++++++ 5 files changed, 112 insertions(+), 49 deletions(-) diff --git a/app/src/App.tsx b/app/src/App.tsx index 46cc759..79d00ca 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -10,6 +10,8 @@ import { useSettings } from "./hooks/useSettings"; import { useProjects } from "./hooks/useProjects"; import { useMcpServers } from "./hooks/useMcpServers"; import { useUpdates } from "./hooks/useUpdates"; +import { useTerminal } from "./hooks/useTerminal"; +import { useSTT } from "./hooks/useSTT"; import { useAppState } from "./store/appState"; import { reconcileProjectStatuses } from "./lib/tauri-commands"; @@ -19,11 +21,20 @@ export default function App() { const { refresh } = useProjects(); const { refresh: refreshMcp } = useMcpServers(); const { loadVersion, checkForUpdates, checkImageUpdate, startPeriodicCheck } = useUpdates(); - const { sessions, activeSessionId, setProjects } = useAppState( - useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId, setProjects: s.setProjects })) + const { sessions, activeSessionId, setProjects, setSttToggle } = useAppState( + useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId, setProjects: s.setProjects, setSttToggle: s.setSttToggle })) ); const [showInstallDialog, setShowInstallDialog] = useState(false); + // Single STT instance bound to the active session. The mic lives in the + // StatusBar; the terminal's Ctrl+Shift+M shortcut calls stt.toggle via the + // store (registered below). + const { sendInput } = useTerminal(); + const stt = useSTT(activeSessionId ?? "", sendInput); + useEffect(() => { + setSttToggle(stt.toggle); + }, [stt.toggle, setSttToggle]); + // Initialize on mount useEffect(() => { loadSettings(); @@ -82,7 +93,7 @@ export default function App() { )} - + {showInstallDialog && ( setShowInstallDialog(false)} /> )} diff --git a/app/src/components/layout/StatusBar.tsx b/app/src/components/layout/StatusBar.tsx index 41d53df..291ad18 100644 --- a/app/src/components/layout/StatusBar.tsx +++ b/app/src/components/layout/StatusBar.tsx @@ -1,9 +1,26 @@ import { useShallow } from "zustand/react/shallow"; import { useAppState } from "../../store/appState"; +import SttButton from "../terminal/SttButton"; +import type { useSTT } from "../../hooks/useSTT"; -export default function StatusBar() { - const { projects, sessions, terminalHasSelection } = useAppState( - useShallow(s => ({ projects: s.projects, sessions: s.sessions, terminalHasSelection: s.terminalHasSelection })) +interface Props { + stt: ReturnType; +} + +export default function StatusBar({ stt }: Props) { + const { + projects, sessions, terminalHasSelection, activeSessionId, sttEnabled, + terminalAtBottom, scrollActiveToBottom, + } = useAppState( + useShallow(s => ({ + projects: s.projects, + sessions: s.sessions, + terminalHasSelection: s.terminalHasSelection, + activeSessionId: s.activeSessionId, + sttEnabled: s.appSettings?.stt?.enabled, + terminalAtBottom: s.terminalAtBottom, + scrollActiveToBottom: s.scrollActiveToBottom, + })) ); const running = projects.filter((p) => p.status === "running").length; @@ -28,6 +45,26 @@ export default function StatusBar() { )} + {/* Right-aligned controls: Jump to Current + STT mic */} +
+ {activeSessionId && !terminalAtBottom && ( + + )} + {sttEnabled && activeSessionId && ( + + )} +
); } diff --git a/app/src/components/terminal/SttButton.tsx b/app/src/components/terminal/SttButton.tsx index a65f98a..9b1f80a 100644 --- a/app/src/components/terminal/SttButton.tsx +++ b/app/src/components/terminal/SttButton.tsx @@ -62,7 +62,15 @@ export default function SttButton({ state, error, onToggle, onCancel }: Props) { }; return ( -
+
+ {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..36b13eb 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; @@ -393,6 +390,14 @@ 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]); + const writeSelection = useCallback((mode: "trimmed" | "raw") => { const term = termRef.current; if (!term) return; @@ -457,23 +462,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 tight so the terminal claims as much area as possible; right side + leaves a little room beside the scrollbar. */} +
+
+
{contextMenu && ( 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(), -- 2.52.0 From da7b7b9bd5d565a94d1635282d74379d33ee760c Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 28 Jun 2026 19:33:27 -0700 Subject: [PATCH 2/2] Address review: pin STT transcript, clear stale scroll state Follow-up to PR review on terminal-layout-statusbar: - [Major] Pin STT transcripts to the originating terminal. The single useSTT instance is bound to the live active session, which can change mid-recording. Capture the session id at recording start in a ref and inject the transcript there instead of the live sessionId, so text always lands in the terminal where recording began. - [Minor] Clear the status-bar scroll state when the active terminal unmounts, and null out termRef on dispose, so scrollActiveToBottom can't point at a disposed terminal. Tab switches don't unmount, so this only fires when the active session is actually closed. - [Nit] Fix the terminal padding comment to match the symmetric value. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/components/terminal/TerminalView.tsx | 20 ++++++++++++++++++-- app/src/hooks/useSTT.ts | 12 +++++++++--- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/app/src/components/terminal/TerminalView.tsx b/app/src/components/terminal/TerminalView.tsx index 36b13eb..ee871b6 100644 --- a/app/src/components/terminal/TerminalView.tsx +++ b/app/src/components/terminal/TerminalView.tsx @@ -316,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 @@ -398,6 +399,21 @@ export default function TerminalView({ sessionId, active }: Props) { 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; @@ -466,8 +482,8 @@ export default function TerminalView({ sessionId, active }: Props) { 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 tight so the terminal claims as much area as possible; right side - leaves a little room beside the scrollbar. */} + Kept to a tight, even gutter so the terminal claims as much area as + possible while leaving a little breathing room beside the scrollbar. */}
(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(); -- 2.52.0