Fix xterm clipping; move mic + Jump to Current into status bar #7
+14
-3
@@ -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)} />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user