Trim whitespace on terminal copy by default, keep raw copy on Ctrl+Shift+Alt+C and right-click menu
All checks were successful
Build App / compute-version (push) Successful in 2s
Build App / build-macos (push) Successful in 2m31s
Build App / build-windows (push) Successful in 4m39s
Build App / build-linux (push) Successful in 5m42s
Build App / create-tag (push) Successful in 9s
Build App / sync-to-github (push) Successful in 17s
All checks were successful
Build App / compute-version (push) Successful in 2s
Build App / build-macos (push) Successful in 2m31s
Build App / build-windows (push) Successful in 4m39s
Build App / build-linux (push) Successful in 5m42s
Build App / create-tag (push) Successful in 9s
Build App / sync-to-github (push) Successful in 17s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -23,7 +23,9 @@ export default function StatusBar() {
|
|||||||
{terminalHasSelection && (
|
{terminalHasSelection && (
|
||||||
<>
|
<>
|
||||||
<span className="mx-2">|</span>
|
<span className="mx-2">|</span>
|
||||||
<span className="text-[var(--accent)]">Ctrl+Shift+C to copy</span>
|
<span className="text-[var(--accent)]">
|
||||||
|
Ctrl+Shift+C: copy trimmed · Ctrl+Shift+Alt+C: copy raw
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
58
app/src/components/terminal/TerminalContextMenu.tsx
Normal file
58
app/src/components/terminal/TerminalContextMenu.tsx
Normal file
@@ -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<HTMLDivElement>(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 (
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
className="fixed z-[60] min-w-[160px] py-1 rounded-md border border-[#30363d] bg-[#1f2937] shadow-lg text-xs text-[#e6edf3]"
|
||||||
|
style={{ left: x, top: y }}
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="w-full text-left px-3 py-1.5 hover:bg-[#2d3748] cursor-pointer"
|
||||||
|
onClick={onCopyTrimmed}
|
||||||
|
>
|
||||||
|
Copy trimmed
|
||||||
|
<kbd className="float-right ml-3 px-1 py-0.5 text-[10px] bg-[#0d1117] border border-[#30363d] rounded font-mono">
|
||||||
|
Ctrl+Shift+C
|
||||||
|
</kbd>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="w-full text-left px-3 py-1.5 hover:bg-[#2d3748] cursor-pointer"
|
||||||
|
onClick={onCopyRaw}
|
||||||
|
>
|
||||||
|
Copy raw
|
||||||
|
<kbd className="float-right ml-3 px-1 py-0.5 text-[10px] bg-[#0d1117] border border-[#30363d] rounded font-mono">
|
||||||
|
Ctrl+Shift+Alt+C
|
||||||
|
</kbd>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ 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";
|
||||||
|
import { trimSelection } from "./trimSelection";
|
||||||
|
import TerminalContextMenu from "./TerminalContextMenu";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -42,6 +44,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
|
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
|
||||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||||
const [isAutoFollow, setIsAutoFollow] = useState(true);
|
const [isAutoFollow, setIsAutoFollow] = useState(true);
|
||||||
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
||||||
const isAtBottomRef = useRef(true);
|
const isAtBottomRef = useRef(true);
|
||||||
// Tracks user intent to follow output — only set to false by explicit user
|
// Tracks user intent to follow output — only set to false by explicit user
|
||||||
// actions (mouse wheel up), not by xterm scroll events during writes.
|
// 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);
|
term.open(containerRef.current);
|
||||||
|
|
||||||
// Ctrl+Shift+C copies selected terminal text to clipboard.
|
// Ctrl+Shift+C copies the selection with whitespace trimmed (UI padding
|
||||||
// This prevents the keystroke from reaching the container (where
|
// stripped, internal indentation preserved). Ctrl+Shift+Alt+C copies raw.
|
||||||
// Ctrl+C would send SIGINT and cancel running work).
|
// Both prevent the keystroke from reaching the container (where Ctrl+C
|
||||||
|
// would send SIGINT and cancel running work).
|
||||||
term.attachCustomKeyEventHandler((event) => {
|
term.attachCustomKeyEventHandler((event) => {
|
||||||
if (event.type === "keydown" && event.ctrlKey && event.shiftKey && event.key === "C") {
|
if (event.type === "keydown" && event.ctrlKey && event.shiftKey && event.key === "C") {
|
||||||
const sel = term.getSelection();
|
const sel = term.getSelection();
|
||||||
if (sel) {
|
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),
|
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 handleToggleAutoFollow = useCallback(() => {
|
||||||
const next = !autoFollowRef.current;
|
const next = !autoFollowRef.current;
|
||||||
autoFollowRef.current = next;
|
autoFollowRef.current = next;
|
||||||
@@ -450,7 +472,23 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
style={{ padding: "8px 12px 48px 16px" }}
|
style={{ padding: "8px 12px 48px 16px" }}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
/>
|
/>
|
||||||
|
{contextMenu && (
|
||||||
|
<TerminalContextMenu
|
||||||
|
x={contextMenu.x}
|
||||||
|
y={contextMenu.y}
|
||||||
|
onCopyTrimmed={() => {
|
||||||
|
writeSelection("trimmed");
|
||||||
|
setContextMenu(null);
|
||||||
|
}}
|
||||||
|
onCopyRaw={() => {
|
||||||
|
writeSelection("raw");
|
||||||
|
setContextMenu(null);
|
||||||
|
}}
|
||||||
|
onDismiss={() => setContextMenu(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
65
app/src/components/terminal/trimSelection.test.ts
Normal file
65
app/src/components/terminal/trimSelection.test.ts
Normal file
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
42
app/src/components/terminal/trimSelection.ts
Normal file
42
app/src/components/terminal/trimSelection.ts
Normal file
@@ -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");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user