Compare commits
4 Commits
v0.3.143
...
v0.3.4-win
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f6655fbcf | |||
| b907ad0239 | |||
| de1d809de5 | |||
| 3c7852544b |
@@ -159,7 +159,7 @@
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: none;
|
||||
padding: 4px;
|
||||
padding: 4px 4px 16px 4px;
|
||||
}
|
||||
.terminal-container.active { display: block; }
|
||||
|
||||
|
||||
@@ -23,7 +23,9 @@ export default function StatusBar() {
|
||||
{terminalHasSelection && (
|
||||
<>
|
||||
<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>
|
||||
|
||||
@@ -62,7 +62,7 @@ export default function SttButton({ state, error, onToggle, onCancel }: Props) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-1 left-1 z-50 flex items-center gap-2">
|
||||
<div className="absolute bottom-2 left-2 z-50 flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={handleClick}
|
||||
|
||||
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 { 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<string | null>(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;
|
||||
@@ -449,8 +471,24 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full h-full"
|
||||
style={{ padding: "8px" }}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# Flight Operations
|
||||
|
||||
This directory contains reference materials for the [Flight Control](https://github.com/anthropics/flight-control) development methodology.
|
||||
This directory contains reference materials for the [Flight Control](https://github.com/msieurthenardier/mission-control) development methodology.
|
||||
|
||||
## Contents
|
||||
|
||||
|
||||
Reference in New Issue
Block a user