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 { useProjects } from "./hooks/useProjects";
|
||||||
import { useMcpServers } from "./hooks/useMcpServers";
|
import { useMcpServers } from "./hooks/useMcpServers";
|
||||||
import { useUpdates } from "./hooks/useUpdates";
|
import { useUpdates } from "./hooks/useUpdates";
|
||||||
|
import { useTerminal } from "./hooks/useTerminal";
|
||||||
|
import { useSTT } from "./hooks/useSTT";
|
||||||
import { useAppState } from "./store/appState";
|
import { useAppState } from "./store/appState";
|
||||||
import { reconcileProjectStatuses } from "./lib/tauri-commands";
|
import { reconcileProjectStatuses } from "./lib/tauri-commands";
|
||||||
|
|
||||||
@@ -19,11 +21,20 @@ export default function App() {
|
|||||||
const { refresh } = useProjects();
|
const { refresh } = useProjects();
|
||||||
const { refresh: refreshMcp } = useMcpServers();
|
const { refresh: refreshMcp } = useMcpServers();
|
||||||
const { loadVersion, checkForUpdates, checkImageUpdate, startPeriodicCheck } = useUpdates();
|
const { loadVersion, checkForUpdates, checkImageUpdate, startPeriodicCheck } = useUpdates();
|
||||||
const { sessions, activeSessionId, setProjects } = useAppState(
|
const { sessions, activeSessionId, setProjects, setSttToggle } = useAppState(
|
||||||
useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId, setProjects: s.setProjects }))
|
useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId, setProjects: s.setProjects, setSttToggle: s.setSttToggle }))
|
||||||
);
|
);
|
||||||
const [showInstallDialog, setShowInstallDialog] = useState(false);
|
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
|
// Initialize on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSettings();
|
loadSettings();
|
||||||
@@ -82,7 +93,7 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<StatusBar />
|
<StatusBar stt={stt} />
|
||||||
{showInstallDialog && (
|
{showInstallDialog && (
|
||||||
<DockerInstallDialog onClose={() => setShowInstallDialog(false)} />
|
<DockerInstallDialog onClose={() => setShowInstallDialog(false)} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,26 @@
|
|||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useAppState } from "../../store/appState";
|
import { useAppState } from "../../store/appState";
|
||||||
|
import SttButton from "../terminal/SttButton";
|
||||||
|
import type { useSTT } from "../../hooks/useSTT";
|
||||||
|
|
||||||
export default function StatusBar() {
|
interface Props {
|
||||||
const { projects, sessions, terminalHasSelection } = useAppState(
|
stt: ReturnType<typeof useSTT>;
|
||||||
useShallow(s => ({ projects: s.projects, sessions: s.sessions, terminalHasSelection: s.terminalHasSelection }))
|
}
|
||||||
|
|
||||||
|
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;
|
const running = projects.filter((p) => p.status === "running").length;
|
||||||
|
|
||||||
@@ -28,6 +45,26 @@ export default function StatusBar() {
|
|||||||
</span>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,15 @@ export default function SttButton({ state, error, onToggle, onCancel }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
@@ -71,44 +79,34 @@ export default function SttButton({ state, error, onToggle, onCancel }: Props) {
|
|||||||
onMouseEnter={() => setHovered(true)}
|
onMouseEnter={() => setHovered(true)}
|
||||||
onMouseLeave={() => setHovered(false)}
|
onMouseLeave={() => setHovered(false)}
|
||||||
disabled={state === "transcribing"}
|
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"
|
state === "recording"
|
||||||
? "bg-[#f85149] text-white shadow-lg animate-pulse"
|
? "bg-[#f85149] text-white animate-pulse"
|
||||||
: state === "transcribing"
|
: state === "transcribing"
|
||||||
? "bg-[#1f2937] text-[#58a6ff] border border-[#30363d] opacity-80"
|
? "text-[#58a6ff] opacity-80"
|
||||||
: "bg-[#1f2937]/80 text-[#8b949e] border border-[#30363d] hover:text-[#e6edf3] hover:bg-[#2d3748]"
|
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{state === "transcribing" ? (
|
{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" />
|
<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" />
|
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
</svg>
|
</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="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" />
|
<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>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{hovered && state !== "recording" && (
|
{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..." : (
|
{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></>
|
<>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>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ 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 { awsSsoRefresh } from "../../lib/tauri-commands";
|
import { awsSsoRefresh } from "../../lib/tauri-commands";
|
||||||
import { UrlDetector } from "../../lib/urlDetector";
|
import { UrlDetector } from "../../lib/urlDetector";
|
||||||
import UrlToast from "./UrlToast";
|
import UrlToast from "./UrlToast";
|
||||||
@@ -29,10 +27,8 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
const detectorRef = useRef<UrlDetector | null>(null);
|
const detectorRef = useRef<UrlDetector | null>(null);
|
||||||
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 setTerminalAtBottom = useAppState(s => s.setTerminalAtBottom);
|
||||||
const stt = useSTT(sessionId, sendInput);
|
const setScrollActiveToBottom = useAppState(s => s.setScrollActiveToBottom);
|
||||||
const sttToggleRef = useRef(stt.toggle);
|
|
||||||
sttToggleRef.current = stt.toggle;
|
|
||||||
|
|
||||||
const ssoBufferRef = useRef("");
|
const ssoBufferRef = useRef("");
|
||||||
const ssoTriggeredRef = useRef(false);
|
const ssoTriggeredRef = useRef(false);
|
||||||
@@ -111,9 +107,10 @@ 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
|
// 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") {
|
if (event.type === "keydown" && event.ctrlKey && event.shiftKey && event.key === "M") {
|
||||||
sttToggleRef.current();
|
useAppState.getState().sttToggle();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
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 writeSelection = useCallback((mode: "trimmed" | "raw") => {
|
||||||
const term = termRef.current;
|
const term = termRef.current;
|
||||||
if (!term) return;
|
if (!term) return;
|
||||||
@@ -457,23 +462,19 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
>
|
>
|
||||||
{isAutoFollow ? "▼ Following" : "▽ Paused"}
|
{isAutoFollow ? "▼ Following" : "▽ Paused"}
|
||||||
</button>
|
</button>
|
||||||
{/* STT mic button - bottom left */}
|
{/* Padding lives on this wrapper, NOT on the xterm host element. xterm's
|
||||||
{sttEnabled && <SttButton state={stt.state} error={stt.error} onToggle={stt.toggle} onCancel={stt.cancelRecording} />}
|
FitAddon measures the host element it's mounted into; padding there
|
||||||
{/* Jump to Current - bottom right, when scrolled up */}
|
causes the grid to overhang and clip the rightmost column / bottom
|
||||||
{!isAtBottom && (
|
row. The host below fills this wrapper's content box with no padding.
|
||||||
<button
|
Kept tight so the terminal claims as much area as possible; right side
|
||||||
onClick={handleScrollToBottom}
|
leaves a little room beside the scrollbar. */}
|
||||||
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"
|
<div className="w-full h-full" style={{ padding: "4px 8px 4px 8px" }}>
|
||||||
>
|
|
||||||
Jump to Current ↓
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
style={{ padding: "8px 12px 48px 16px" }}
|
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
{contextMenu && (
|
{contextMenu && (
|
||||||
<TerminalContextMenu
|
<TerminalContextMenu
|
||||||
x={contextMenu.x}
|
x={contextMenu.x}
|
||||||
|
|||||||
@@ -44,6 +44,16 @@ interface AppState {
|
|||||||
// UI state
|
// UI state
|
||||||
terminalHasSelection: boolean;
|
terminalHasSelection: boolean;
|
||||||
setTerminalHasSelection: (has: boolean) => void;
|
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";
|
sidebarView: "projects" | "mcp" | "settings";
|
||||||
setSidebarView: (view: "projects" | "mcp" | "settings") => void;
|
setSidebarView: (view: "projects" | "mcp" | "settings") => void;
|
||||||
sidebarCollapsed: boolean;
|
sidebarCollapsed: boolean;
|
||||||
@@ -125,6 +135,12 @@ export const useAppState = create<AppState>((set) => ({
|
|||||||
// UI state
|
// UI state
|
||||||
terminalHasSelection: false,
|
terminalHasSelection: false,
|
||||||
setTerminalHasSelection: (has) => set({ terminalHasSelection: has }),
|
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",
|
sidebarView: "projects",
|
||||||
setSidebarView: (view) => set({ sidebarView: view }),
|
setSidebarView: (view) => set({ sidebarView: view }),
|
||||||
sidebarCollapsed: loadSidebarCollapsed(),
|
sidebarCollapsed: loadSidebarCollapsed(),
|
||||||
|
|||||||
Reference in New Issue
Block a user