diff --git a/web/package-lock.json b/web/package-lock.json index 67fdaf98..9349c86a 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -13,15 +13,16 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-tooltip": "^1.2.8", + "@xterm/addon-clipboard": "^0.2.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.468.0", "react": "^18.3.1", "react-dom": "^18.3.1", "simple-icons": "^16.11.0", - "tailwind-merge": "^2.5.4", - "xterm": "^5.3.0", - "xterm-addon-fit": "^0.8.0" + "tailwind-merge": "^2.5.4" }, "devDependencies": { "@eslint/js": "^9.39.4", @@ -3814,6 +3815,30 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@xterm/addon-clipboard": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.2.0.tgz", + "integrity": "sha512-Dl31BCtBhLaUEECUbEiVcCLvLBbaeGYdT7NofB8OJkGTD3MWgBsaLjXvfGAD4tQNHhm6mbKyYkR7XD8kiZsdNg==", + "license": "MIT", + "dependencies": { + "js-base64": "^3.7.5" + } + }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -6341,6 +6366,12 @@ "jiti": "bin/jiti.js" } }, + "node_modules/js-base64": { + "version": "3.7.8", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz", + "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==", + "license": "BSD-3-Clause" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -9099,21 +9130,6 @@ "workbox-core": "7.4.0" } }, - "node_modules/xterm": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz", - "integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==", - "deprecated": "This package is now deprecated. Move to @xterm/xterm instead." - }, - "node_modules/xterm-addon-fit": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.8.0.tgz", - "integrity": "sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==", - "deprecated": "This package is now deprecated. Move to @xterm/addon-fit instead.", - "peerDependencies": { - "xterm": "^5.0.0" - } - }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -11419,6 +11435,24 @@ "react-refresh": "^0.17.0" } }, + "@xterm/addon-clipboard": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.2.0.tgz", + "integrity": "sha512-Dl31BCtBhLaUEECUbEiVcCLvLBbaeGYdT7NofB8OJkGTD3MWgBsaLjXvfGAD4tQNHhm6mbKyYkR7XD8kiZsdNg==", + "requires": { + "js-base64": "^3.7.5" + } + }, + "@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==" + }, + "@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==" + }, "acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -13147,6 +13181,11 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true }, + "js-base64": { + "version": "3.7.8", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz", + "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==" + }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -15006,17 +15045,6 @@ "workbox-core": "7.4.0" } }, - "xterm": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz", - "integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==" - }, - "xterm-addon-fit": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.8.0.tgz", - "integrity": "sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==", - "requires": {} - }, "yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/web/package.json b/web/package.json index 58ba79ae..8a5463bb 100644 --- a/web/package.json +++ b/web/package.json @@ -16,15 +16,16 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-tooltip": "^1.2.8", + "@xterm/addon-clipboard": "^0.2.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.468.0", "react": "^18.3.1", "react-dom": "^18.3.1", "simple-icons": "^16.11.0", - "tailwind-merge": "^2.5.4", - "xterm": "^5.3.0", - "xterm-addon-fit": "^0.8.0" + "tailwind-merge": "^2.5.4" }, "devDependencies": { "@eslint/js": "^9.39.4", diff --git a/web/src/App.tsx b/web/src/App.tsx index 4799af02..8d0d0091 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,7 +1,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Terminal as XTerm } from "xterm"; -import { FitAddon } from "xterm-addon-fit"; -import "xterm/css/xterm.css"; +import { Terminal as XTerm } from "@xterm/xterm"; +import { FitAddon } from "@xterm/addon-fit"; +import { ClipboardAddon } from "@xterm/addon-clipboard"; +import "@xterm/xterm/css/xterm.css"; import { AgentSidebar, AgentSidebarContent } from "@/components/app/agent-sidebar"; import { AppHeader } from "@/components/app/app-header"; import { SettingsPane } from "@/components/app/settings-pane"; @@ -47,6 +48,15 @@ const DEFAULT_WORKTREE_MODE: WorktreeMode = "ask"; // Treat coarse/non-hover input as mobile as well so drawer behavior stays consistent. const MOBILE_BREAKPOINT_QUERY = "(max-width: 767px), (pointer: coarse), (hover: none)"; +/** Strip terminal line-wrap artifacts from copied text. */ +function cleanCopiedText(text: string): string { + const joined = text.replace(/[ \t]*\r?\n[ \t]*/g, ""); + if (/^https?:\/\//.test(joined) || (/\S/.test(joined) && !joined.includes(" "))) { + return joined; + } + return text; +} + function readLastUsedCwd(): string { if (typeof window === "undefined") { return DEFAULT_CWD; @@ -188,11 +198,12 @@ export function App(): JSX.Element { const [apiState, setApiState] = useState("checking"); const [dbState, setDbState] = useState("checking"); - const [mediaState, setMediaState] = useState("checking"); + const [_mediaState, setMediaState] = useState("checking"); const terminalHostRef = useRef(null); const terminalRef = useRef(null); const fitAddonRef = useRef(null); + const ctrlPendingRef = useRef(false); const mediaViewportRef = useRef(null); const wsRef = useRef(null); @@ -742,12 +753,16 @@ export function App(): JSX.Element { return; } + const isTouchDevice = window.matchMedia("(pointer: coarse)").matches; + const term = new XTerm({ convertEol: false, cursorBlink: true, fontFamily: "JetBrains Mono, Menlo, monospace", fontSize: 13, scrollback: 5000, + macOptionClickForcesSelection: true, + screenReaderMode: isTouchDevice, theme: { foreground: "#f8f8f2", background: "#141414", @@ -779,14 +794,102 @@ export function App(): JSX.Element { terminalRef.current = term; fitAddonRef.current = fit; term.loadAddon(fit); + try { term.loadAddon(new ClipboardAddon()); } catch (e) { console.warn("ClipboardAddon failed:", e); } term.open(host); fit.fit(); + // Intercept copy event to clean terminal line-wrap artifacts + const handleCopy = (e: ClipboardEvent) => { + if (term.hasSelection()) { + e.preventDefault(); + e.stopPropagation(); + e.clipboardData?.setData("text/plain", cleanCopiedText(term.getSelection())); + } + }; + host.addEventListener("copy", handleCopy, true); + + // Touch scroll: .xterm sets touch-action:none which prevents native + // scrolling on mobile. Convert finger drags into synthetic WheelEvents + // so tmux enters copy-mode and scrolls its scrollback buffer. + const screenEl = host.querySelector(".xterm-screen") as HTMLElement | null; + let touchY = 0; + let touchAccum = 0; + const SCROLL_SENSITIVITY = 30; // px per wheel tick + const onTouchStart = (e: TouchEvent) => { + if (e.touches.length === 1) { + touchY = e.touches[0].clientY; + touchAccum = 0; + } + }; + const onTouchMove = (e: TouchEvent) => { + if (e.touches.length !== 1 || !screenEl) return; + const currentY = e.touches[0].clientY; + const delta = touchY - currentY; // positive = finger up = scroll down + touchY = currentY; + touchAccum += delta; + while (Math.abs(touchAccum) >= SCROLL_SENSITIVITY) { + const direction = touchAccum > 0 ? 1 : -1; + touchAccum -= direction * SCROLL_SENSITIVITY; + screenEl.dispatchEvent(new WheelEvent("wheel", { + deltaY: direction * 100, + deltaMode: 0, + bubbles: true, + cancelable: true, + })); + } + }; + host.addEventListener("touchstart", onTouchStart, { passive: true }); + host.addEventListener("touchmove", onTouchMove, { passive: true }); + + // Enable text selection: intercept mousedown on the terminal screen + // and re-dispatch with modifier keys that tell xterm.js to handle + // selection locally instead of forwarding mouse events to tmux. + // xterm.js shouldForceSelection() checks: + // Mac: event.altKey && macOptionClickForcesSelection + // Non-Mac: event.shiftKey + // We inject both so it works on all platforms. + // Wheel events are unaffected — tmux scrollback scrolling still works. + let dispatchingMouseDown = false; + const onMouseDown = (e: MouseEvent) => { + if (dispatchingMouseDown) return; + if (e.button !== 0 || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return; + e.stopPropagation(); + e.preventDefault(); + dispatchingMouseDown = true; + (e.target as HTMLElement).dispatchEvent(new MouseEvent("mousedown", { + bubbles: true, + cancelable: true, + view: window, + detail: e.detail, + screenX: e.screenX, + screenY: e.screenY, + clientX: e.clientX, + clientY: e.clientY, + button: e.button, + buttons: e.buttons, + relatedTarget: e.relatedTarget, + shiftKey: true, + altKey: true, + })); + dispatchingMouseDown = false; + }; + if (screenEl) { + screenEl.addEventListener("mousedown", onMouseDown, true); + } + const disposable = term.onData((data) => { const ws = wsRef.current; - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: "input", data })); + if (!ws || ws.readyState !== WebSocket.OPEN) return; + if (ctrlPendingRef.current && data.length === 1) { + const code = data.toUpperCase().charCodeAt(0); + if (code >= 65 && code <= 90) { + ctrlPendingRef.current = false; + window.dispatchEvent(new Event("ctrl-consumed")); + ws.send(JSON.stringify({ type: "input", data: String.fromCharCode(code - 64) })); + return; + } } + ws.send(JSON.stringify({ type: "input", data })); }); const onResize = () => { @@ -798,6 +901,10 @@ export function App(): JSX.Element { return () => { disposable.dispose(); + host.removeEventListener("copy", handleCopy, true); + host.removeEventListener("touchstart", onTouchStart); + host.removeEventListener("touchmove", onTouchMove); + if (screenEl) screenEl.removeEventListener("mousedown", onMouseDown, true); window.removeEventListener("resize", onResize); term.dispose(); terminalRef.current = null; @@ -1335,6 +1442,9 @@ export function App(): JSX.Element { } if (connState === "connected" && connectedAgent) { + if (connectedAgent.latestEvent?.message) { + return connectedAgent.latestEvent.message; + } return `Connected to session ${connectedAgent.name}`; } @@ -1457,13 +1567,12 @@ export function App(): JSX.Element { terminalHostRef={terminalHostRef} /> - {isMobile ? : null} + {isMobile ? : null} diff --git a/web/src/components/app/mobile-terminal-toolbar.tsx b/web/src/components/app/mobile-terminal-toolbar.tsx index 1a94a17b..4776443a 100644 --- a/web/src/components/app/mobile-terminal-toolbar.tsx +++ b/web/src/components/app/mobile-terminal-toolbar.tsx @@ -1,42 +1,228 @@ +import { type MutableRefObject, useCallback, useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; - -type TerminalHelper = { - label: string; - keyInput: string; - ariaLabel: string; -}; - -const TERMINAL_HELPERS: TerminalHelper[] = [ - { label: "Ctrl+C", keyInput: "\u0003", ariaLabel: "Send Control C" }, - { label: "Tab", keyInput: "\t", ariaLabel: "Send Tab" }, - { label: "Esc", keyInput: "\u001b", ariaLabel: "Send Escape" }, - { label: "\u2191", keyInput: "\u001b[A", ariaLabel: "Send Arrow Up" }, - { label: "\u2193", keyInput: "\u001b[B", ariaLabel: "Send Arrow Down" }, - { label: "Enter", keyInput: "\r", ariaLabel: "Send Enter" } -]; +import { cn } from "@/lib/utils"; type MobileTerminalToolbarProps = { onSendInput: (data: string) => void; + ctrlPendingRef: MutableRefObject; }; -export function MobileTerminalToolbar({ onSendInput }: MobileTerminalToolbarProps): JSX.Element { +export function MobileTerminalToolbar({ onSendInput, ctrlPendingRef }: MobileTerminalToolbarProps): JSX.Element { + const [inputOpen, setInputOpen] = useState(false); + const [ctrlActive, setCtrlActive] = useState(false); + const inputRef = useRef(null); + + // Clear visual state when the terminal onData handler consumes the ctrl modifier + useEffect(() => { + const onConsumed = () => setCtrlActive(false); + window.addEventListener("ctrl-consumed", onConsumed); + return () => window.removeEventListener("ctrl-consumed", onConsumed); + }, []); + + const toggleCtrl = useCallback(() => { + setCtrlActive((v) => { + const next = !v; + ctrlPendingRef.current = next; + return next; + }); + }, [ctrlPendingRef]); + + const sendKey = useCallback((key: string) => { + onSendInput(key); + // After any toolbar key press, clear ctrl + if (ctrlActive) { + setCtrlActive(false); + ctrlPendingRef.current = false; + } + }, [ctrlActive, ctrlPendingRef, onSendInput]); + + const openInput = useCallback(() => { + setInputOpen(true); + requestAnimationFrame(() => inputRef.current?.focus()); + }, []); + + const submitInput = useCallback(() => { + const text = inputRef.current?.value; + if (text) { + onSendInput(text + "\r"); + if (inputRef.current) inputRef.current.value = ""; + } + setInputOpen(false); + }, [onSendInput]); + return ( -
-
- {TERMINAL_HELPERS.map((helper) => ( + <> +
+ {/* Row 1: modifier + action keys + keyboard button */} +
+ + + + +
+ + {/* Row 2: arrow keys */} +
+ - ))} + + + +
-
+ + {/* Full-screen text input modal */} + {inputOpen ? ( +
+
+ + Terminal Input + +
+
+