5 Commits

Author SHA1 Message Date
7f6655fbcf 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
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 08:58:56 -07:00
b907ad0239 Add breathing room to terminal bottom-left so STT button clears Claude Code status
All checks were successful
Build App / compute-version (push) Successful in 5s
Build App / build-macos (push) Successful in 2m29s
Build App / build-windows (push) Successful in 4m20s
Build App / build-linux (push) Successful in 5m45s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 11s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 16:27:30 -07:00
de1d809de5 Update Flight Control reference URL to mission-control repo
All checks were successful
Build Container / build-container (push) Successful in 1m13s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 15:45:29 -07:00
3c7852544b Fix TUI fullscreen mode cutting off Claude Code status line
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 3m56s
Build App / build-linux (push) Successful in 5m5s
Build App / create-tag (push) Successful in 6s
Build App / sync-to-github (push) Successful in 16s
Add bottom padding to terminal containers so FitAddon proposes one
fewer row, leaving visible space below Claude Code's mode indicator.
Previously the bottom status line (e.g. "bypass permissions on") was
clipped against the container edge in fullscreen TUI mode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 15:44:10 -07:00
ddf44d97e5 Fix Docker build: manual NodeSource setup + retry loops on all apt-get updates
All checks were successful
Build Container / build-container (push) Successful in 41m2s
The previous fix wasn't enough: the NodeSource setup_22.x script runs its
own internal `apt-get update` without retries. When that hit the Ubuntu
mirror-sync issue (stale Packages.gz with mismatched hash), the script
silently bailed without configuring the NodeSource repo. The next
`apt-get install -y nodejs` then installed Ubuntu's default nodejs 18,
which ships without npm, breaking the `npm install -g pnpm` step.

Changes:
- Replace the `curl ... | bash -` NodeSource setup with manual GPG key +
  repo file configuration, giving us direct control over apt-get update
  retries.
- Add the same 5-attempt retry loop (with 10s sleep and lists cleanup)
  to the Python 3 and Docker CLI steps, since both also do an
  apt-get update and would hit the same failure mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 09:50:42 -07:00
9 changed files with 242 additions and 12 deletions

View File

@@ -159,7 +159,7 @@
position: absolute;
inset: 0;
display: none;
padding: 4px;
padding: 4px 4px 16px 4px;
}
.terminal-container.active { display: block; }

View File

@@ -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 &middot; Ctrl+Shift+Alt+C: copy raw
</span>
</>
)}
</div>

View File

@@ -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}

View 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>
);
}

View File

@@ -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>
);
}

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

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

View File

@@ -58,13 +58,32 @@ RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
&& rm -rf /var/lib/apt/lists/*
# ── 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 \
&& rm -rf /var/lib/apt/lists/* \
&& npm install -g pnpm
# ── 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-pip \
python3-venv \
@@ -77,7 +96,13 @@ RUN install -m 0755 -d /etc/apt/keyrings \
&& 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" \
> /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/*
# ── AWS CLI v2 ───────────────────────────────────────────────────────────────

View File

@@ -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