diff --git a/app/src/components/layout/StatusBar.tsx b/app/src/components/layout/StatusBar.tsx index 2ff04fc..41d53df 100644 --- a/app/src/components/layout/StatusBar.tsx +++ b/app/src/components/layout/StatusBar.tsx @@ -23,7 +23,9 @@ export default function StatusBar() { {terminalHasSelection && ( <> | - Ctrl+Shift+C to copy + + Ctrl+Shift+C: copy trimmed · Ctrl+Shift+Alt+C: copy raw + )} diff --git a/app/src/components/terminal/TerminalContextMenu.tsx b/app/src/components/terminal/TerminalContextMenu.tsx new file mode 100644 index 0000000..fb73bc1 --- /dev/null +++ b/app/src/components/terminal/TerminalContextMenu.tsx @@ -0,0 +1,58 @@ +import { useEffect, useRef } from "react"; + +interface Props { + x: number; + y: number; + onCopyTrimmed: () => void; + onCopyRaw: () => void; + onDismiss: () => void; +} + +export default function TerminalContextMenu({ x, y, onCopyTrimmed, onCopyRaw, onDismiss }: Props) { + const menuRef = useRef(null); + + useEffect(() => { + const handleOutsideClick = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + onDismiss(); + } + }; + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onDismiss(); + }; + document.addEventListener("mousedown", handleOutsideClick, true); + document.addEventListener("keydown", handleKey); + return () => { + document.removeEventListener("mousedown", handleOutsideClick, true); + document.removeEventListener("keydown", handleKey); + }; + }, [onDismiss]); + + return ( +
e.preventDefault()} + > + + +
+ ); +} diff --git a/app/src/components/terminal/TerminalView.tsx b/app/src/components/terminal/TerminalView.tsx index 0a8ae88..083b556 100644 --- a/app/src/components/terminal/TerminalView.tsx +++ b/app/src/components/terminal/TerminalView.tsx @@ -12,6 +12,8 @@ import SttButton from "./SttButton"; import { awsSsoRefresh } from "../../lib/tauri-commands"; import { UrlDetector } from "../../lib/urlDetector"; import UrlToast from "./UrlToast"; +import { trimSelection } from "./trimSelection"; +import TerminalContextMenu from "./TerminalContextMenu"; interface Props { sessionId: string; @@ -42,6 +44,7 @@ export default function TerminalView({ sessionId, active }: Props) { const [imagePasteMsg, setImagePasteMsg] = useState(null); const [isAtBottom, setIsAtBottom] = useState(true); const [isAutoFollow, setIsAutoFollow] = useState(true); + const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); const isAtBottomRef = useRef(true); // Tracks user intent to follow output — only set to false by explicit user // actions (mouse wheel up), not by xterm scroll events during writes. @@ -93,14 +96,16 @@ export default function TerminalView({ sessionId, active }: Props) { term.open(containerRef.current); - // Ctrl+Shift+C copies selected terminal text to clipboard. - // This prevents the keystroke from reaching the container (where - // Ctrl+C would send SIGINT and cancel running work). + // Ctrl+Shift+C copies the selection with whitespace trimmed (UI padding + // stripped, internal indentation preserved). Ctrl+Shift+Alt+C copies raw. + // Both prevent the keystroke from reaching the container (where Ctrl+C + // would send SIGINT and cancel running work). term.attachCustomKeyEventHandler((event) => { if (event.type === "keydown" && event.ctrlKey && event.shiftKey && event.key === "C") { const sel = term.getSelection(); if (sel) { - navigator.clipboard.writeText(sel).catch((e) => + const out = event.altKey ? sel : trimSelection(sel); + navigator.clipboard.writeText(out).catch((e) => console.error("Ctrl+Shift+C clipboard write failed:", e), ); } @@ -388,6 +393,23 @@ export default function TerminalView({ sessionId, active }: Props) { } }, []); + const writeSelection = useCallback((mode: "trimmed" | "raw") => { + const term = termRef.current; + if (!term) return; + const sel = term.getSelection(); + if (!sel) return; + const out = mode === "raw" ? sel : trimSelection(sel); + navigator.clipboard.writeText(out).catch((e) => + console.error("Context menu clipboard write failed:", e), + ); + }, []); + + const handleContextMenu = useCallback((e: React.MouseEvent) => { + if (!termRef.current?.hasSelection()) return; // let default menu happen + e.preventDefault(); + setContextMenu({ x: e.clientX, y: e.clientY }); + }, []); + const handleToggleAutoFollow = useCallback(() => { const next = !autoFollowRef.current; autoFollowRef.current = next; @@ -450,7 +472,23 @@ export default function TerminalView({ sessionId, active }: Props) { ref={containerRef} className="w-full h-full" style={{ padding: "8px 12px 48px 16px" }} + onContextMenu={handleContextMenu} /> + {contextMenu && ( + { + writeSelection("trimmed"); + setContextMenu(null); + }} + onCopyRaw={() => { + writeSelection("raw"); + setContextMenu(null); + }} + onDismiss={() => setContextMenu(null)} + /> + )} ); } diff --git a/app/src/components/terminal/trimSelection.test.ts b/app/src/components/terminal/trimSelection.test.ts new file mode 100644 index 0000000..9e48ca0 --- /dev/null +++ b/app/src/components/terminal/trimSelection.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from "vitest"; +import { trimSelection } from "./trimSelection"; + +describe("trimSelection", () => { + it("returns empty string unchanged", () => { + expect(trimSelection("")).toBe(""); + }); + + it("trims leading and trailing whitespace on a single line", () => { + expect(trimSelection(" hello ")).toBe("hello"); + }); + + it("dedents common leading whitespace while preserving inner indent", () => { + const input = " def foo():\n return 1\n"; + expect(trimSelection(input)).toBe("def foo():\n return 1"); + }); + + it("strips leading and trailing blank lines", () => { + const input = "\n\n hello\n\n"; + expect(trimSelection(input)).toBe("hello"); + }); + + it("preserves interior blank lines", () => { + const input = " line1\n\n line2"; + expect(trimSelection(input)).toBe("line1\n\nline2"); + }); + + it("is idempotent on already-clean text", () => { + const clean = "def foo():\n return 1"; + expect(trimSelection(clean)).toBe(clean); + expect(trimSelection(trimSelection(clean))).toBe(clean); + }); + + it("ignores blank lines when computing the common indent", () => { + // The blank line has 0 leading whitespace but shouldn't force minIndent to 0. + const input = " a\n\n b"; + expect(trimSelection(input)).toBe("a\n\nb"); + }); + + it("strips trailing whitespace per line", () => { + const input = "alpha \nbeta\t\t\ngamma"; + expect(trimSelection(input)).toBe("alpha\nbeta\ngamma"); + }); + + it("handles mixed-width padding (pads to min)", () => { + const input = " one\n two\n three"; + // minIndent = 2 + expect(trimSelection(input)).toBe(" one\ntwo\n three"); + }); + + it("handles tabs as leading whitespace", () => { + const input = "\tfoo\n\t\tbar"; + // minIndent = 1 tab + expect(trimSelection(input)).toBe("foo\n\tbar"); + }); + + it("returns empty when input is only whitespace", () => { + expect(trimSelection(" \n \n")).toBe(""); + }); + + it("leaves a zero-indent line alone (no false dedent)", () => { + const input = "no-indent\n indented"; + expect(trimSelection(input)).toBe("no-indent\n indented"); + }); +}); diff --git a/app/src/components/terminal/trimSelection.ts b/app/src/components/terminal/trimSelection.ts new file mode 100644 index 0000000..0c28451 --- /dev/null +++ b/app/src/components/terminal/trimSelection.ts @@ -0,0 +1,42 @@ +/** + * Cleans up terminal selections for pasting into other tools. + * + * Terminal UI padding (left margin from the xterm container, alignment spaces + * at end of line) ends up in the copied text. This helper removes that cruft + * while preserving the *relative* indentation of the content — so code blocks + * keep their shape but lose the wrapper padding. + * + * Steps: + * 1. Dedent — strip the common leading whitespace count from every line. + * 2. trimEnd — drop trailing whitespace per line. + * 3. Drop fully-blank leading and trailing lines. + * + * Internal newlines and relative indentation are preserved. Pure function. + */ +export function trimSelection(text: string): string { + if (!text) return text; + + const lines = text.split("\n"); + + let minIndent = Infinity; + for (const line of lines) { + if (line.trim() === "") continue; + const match = line.match(/^[ \t]*/); + const indent = match ? match[0].length : 0; + if (indent < minIndent) minIndent = indent; + if (minIndent === 0) break; + } + if (!Number.isFinite(minIndent)) minIndent = 0; + + const processed = lines.map((line) => { + const afterDedent = line.length >= minIndent ? line.slice(minIndent) : ""; + return afterDedent.trimEnd(); + }); + + let start = 0; + let end = processed.length; + while (start < end && processed[start] === "") start++; + while (end > start && processed[end - 1] === "") end--; + + return processed.slice(start, end).join("\n"); +}