(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");
+}