Fix xterm clipping; move mic + Jump to Current into status bar #7

Merged
jknapp merged 2 commits from terminal-layout-statusbar into main 2026-06-29 15:23:35 +00:00
6 changed files with 137 additions and 52 deletions
+14 -3
View File
@@ -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() {
)}
</main>
</div>
<StatusBar />
<StatusBar stt={stt} />
{showInstallDialog && (
<DockerInstallDialog onClose={() => setShowInstallDialog(false)} />
)}
+40 -3
View File
@@ -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<typeof useSTT>;
}
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() {
</span>
</>
)}
{/* Right-aligned controls: Jump to Current + STT mic */}
<div className="ml-auto flex items-center gap-3 pl-2">
{activeSessionId && !terminalAtBottom && (
<button
onClick={() => scrollActiveToBottom()}
className="text-[var(--accent)] hover:text-[var(--accent-hover)] cursor-pointer"
title="Scroll the terminal to the latest output"
>
Jump to Current
</button>
)}
{sttEnabled && activeSessionId && (
<SttButton
state={stt.state}
error={stt.error}
onToggle={stt.toggle}
onCancel={stt.cancelRecording}
/>
)}
</div>
</div>
);
}
+16 -18
View File
@@ -62,7 +62,15 @@ export default function SttButton({ state, error, onToggle, onCancel }: Props) {
};
return (
<div className="absolute bottom-2 left-2 z-50 flex items-center gap-2">
<div className="flex items-center gap-1.5">
{state === "recording" && (
<span className="text-[#f85149] font-mono">{formatTime(elapsed)}</span>
)}
{state === "error" && error && (
<span className="text-[#f85149] max-w-[180px] truncate" title={error}>
{error}
</span>
)}
<div className="relative">
<button
onClick={handleClick}
@@ -71,44 +79,34 @@ export default function SttButton({ state, error, onToggle, onCancel }: Props) {
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 ${
className={`w-5 h-5 rounded-full flex items-center justify-center transition-all cursor-pointer ${
state === "recording"
? "bg-[#f85149] text-white shadow-lg animate-pulse"
? "bg-[#f85149] text-white animate-pulse"
: state === "transcribing"
? "bg-[#1f2937] text-[#58a6ff] border border-[#30363d] opacity-80"
: "bg-[#1f2937]/80 text-[#8b949e] border border-[#30363d] hover:text-[#e6edf3] hover:bg-[#2d3748]"
? "text-[#58a6ff] opacity-80"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]"
}`}
>
{state === "transcribing" ? (
<svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none">
<svg className="w-3 h-3 animate-spin" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" opacity="0.25" />
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
) : (
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="currentColor">
<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" />
<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" />
</svg>
)}
</button>
{hovered && state !== "recording" && (
<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">
<div className="absolute bottom-full right-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 z-50">
{state === "transcribing" ? "Transcribing..." : (
<>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></>
)}
</div>
)}
</div>
{state === "recording" && (
<span className="text-xs text-[#f85149] font-mono bg-[#1f2937] px-2 py-0.5 rounded border border-[#30363d]">
{formatTime(elapsed)}
</span>
)}
{state === "error" && error && (
<span className="text-xs text-[#f85149] bg-[#1f2937] px-2 py-0.5 rounded border border-[#30363d] max-w-[200px] truncate">
{error}
</span>
)}
</div>
);
}
+37 -20
View File
@@ -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<UrlDetector | null>(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"}
</button>
{/* STT mic button - bottom left */}
{sttEnabled && <SttButton state={stt.state} error={stt.error} onToggle={stt.toggle} onCancel={stt.cancelRecording} />}
{/* Jump to Current - bottom right, when scrolled up */}
{!isAtBottom && (
<button
onClick={handleScrollToBottom}
className="absolute bottom-4 right-4 z-50 px-3 py-1.5 rounded-md text-xs font-medium bg-[#1f2937] text-[#58a6ff] border border-[#30363d] shadow-lg hover:bg-[#2d3748] transition-colors cursor-pointer"
>
Jump to Current
</button>
)}
{/* 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. */}
<div className="w-full h-full" style={{ padding: "4px 8px 4px 8px" }}>
<div
ref={containerRef}
className="w-full h-full"
style={{ padding: "8px 12px 48px 16px" }}
onContextMenu={handleContextMenu}
/>
</div>
{contextMenu && (
<TerminalContextMenu
x={contextMenu.x}
+9 -3
View File
@@ -13,6 +13,11 @@ export function useSTT(sessionId: string, sendInput: (sessionId: string, data: s
const streamRef = useRef<MediaStream | null>(null);
const workletRef = useRef<AudioWorkletNode | null>(null);
const chunksRef = useRef<Int16Array[]>([]);
// 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();
+16
View File
@@ -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<AppState>((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(),