Compare commits
5 Commits
v0.3.143-m
...
v0.3.4-win
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f6655fbcf | |||
| b907ad0239 | |||
| de1d809de5 | |||
| 3c7852544b | |||
| ddf44d97e5 |
@@ -159,7 +159,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
display: none;
|
display: none;
|
||||||
padding: 4px;
|
padding: 4px 4px 16px 4px;
|
||||||
}
|
}
|
||||||
.terminal-container.active { display: block; }
|
.terminal-container.active { display: block; }
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export default function SttButton({ state, error, onToggle, onCancel }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={handleClick}
|
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 { 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;
|
||||||
@@ -449,8 +471,24 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="w-full h-full"
|
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>
|
</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");
|
||||||
|
}
|
||||||
@@ -58,13 +58,32 @@ RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
|
|||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# ── Node.js LTS (22.x) + pnpm ───────────────────────────────────────────────
|
# ── Node.js LTS (22.x) + pnpm ───────────────────────────────────────────────
|
||||||
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
# Configure NodeSource repo manually (not via their setup_22.x script, which
|
||||||
|
# runs an internal apt-get update without retries and silently falls through
|
||||||
|
# to Ubuntu's default nodejs 18 — missing npm — on mirror-sync failures).
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
|
||||||
|
| gpg --dearmor -o /usr/share/keyrings/nodesource.gpg \
|
||||||
|
&& chmod a+r /usr/share/keyrings/nodesource.gpg \
|
||||||
|
&& echo "deb [signed-by=/usr/share/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" \
|
||||||
|
> /etc/apt/sources.list.d/nodesource.list \
|
||||||
|
&& for i in 1 2 3 4 5; do \
|
||||||
|
apt-get -o Acquire::Retries=3 update && break; \
|
||||||
|
echo "apt-get update failed (attempt $i), retrying in 10s..."; \
|
||||||
|
rm -rf /var/lib/apt/lists/*; \
|
||||||
|
sleep 10; \
|
||||||
|
done \
|
||||||
&& apt-get install -y nodejs \
|
&& apt-get install -y nodejs \
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
&& npm install -g pnpm
|
&& npm install -g pnpm
|
||||||
|
|
||||||
# ── Python 3 + pip + uv + ruff ──────────────────────────────────────────────
|
# ── Python 3 + pip + uv + ruff ──────────────────────────────────────────────
|
||||||
RUN apt-get -o Acquire::Retries=3 update && apt-get install -y --no-install-recommends \
|
RUN for i in 1 2 3 4 5; do \
|
||||||
|
apt-get -o Acquire::Retries=3 update && break; \
|
||||||
|
echo "apt-get update failed (attempt $i), retrying in 10s..."; \
|
||||||
|
rm -rf /var/lib/apt/lists/*; \
|
||||||
|
sleep 10; \
|
||||||
|
done \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
python3 \
|
python3 \
|
||||||
python3-pip \
|
python3-pip \
|
||||||
python3-venv \
|
python3-venv \
|
||||||
@@ -77,7 +96,13 @@ RUN install -m 0755 -d /etc/apt/keyrings \
|
|||||||
&& chmod a+r /etc/apt/keyrings/docker.gpg \
|
&& chmod a+r /etc/apt/keyrings/docker.gpg \
|
||||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
|
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
|
||||||
> /etc/apt/sources.list.d/docker.list \
|
> /etc/apt/sources.list.d/docker.list \
|
||||||
&& apt-get -o Acquire::Retries=3 update && apt-get install -y docker-ce-cli \
|
&& for i in 1 2 3 4 5; do \
|
||||||
|
apt-get -o Acquire::Retries=3 update && break; \
|
||||||
|
echo "apt-get update failed (attempt $i), retrying in 10s..."; \
|
||||||
|
rm -rf /var/lib/apt/lists/*; \
|
||||||
|
sleep 10; \
|
||||||
|
done \
|
||||||
|
&& apt-get install -y docker-ce-cli \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# ── AWS CLI v2 ───────────────────────────────────────────────────────────────
|
# ── AWS CLI v2 ───────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Flight Operations
|
# 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
|
## Contents
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user