diff --git a/src/design/motion.css b/src/design/motion.css
index cf2aaaf..0b6c660 100644
--- a/src/design/motion.css
+++ b/src/design/motion.css
@@ -27,17 +27,6 @@
will-change: opacity;
}
-/* ------------------------------------------------------------------
- Spinner rotate. Used by AgentChrome's status indicator when a
- Claude/Codex/Gemini session is in `working` or `compacting`. The
- spinner is a CSS ring with a transparent right border; rotating
- the whole ring reads as the standard "thinking" affordance.
- ------------------------------------------------------------------ */
-@keyframes goonware-spin {
- 0% { transform: rotate(0deg); }
- 100% { transform: rotate(360deg); }
-}
-
/* ------------------------------------------------------------------
Three-dot loading indicator.
Used inside: highlight-and-ask card while waiting for Gemini,
diff --git a/src/state/agentActivityStore.test.ts b/src/state/agentActivityStore.test.ts
index bb32018..da99614 100644
--- a/src/state/agentActivityStore.test.ts
+++ b/src/state/agentActivityStore.test.ts
@@ -365,7 +365,7 @@ describe("forceEvictForCwd — SIGKILL drops sessions definitively", () => {
});
});
-describe("resolveSessionForCwd — AgentChrome picks the right session", () => {
+describe("resolveSessionForCwd — per-pane lookup picks the right session", () => {
test("returns null for empty cwd", () => {
expect(__internals.resolveSessionForCwd("")).toBeNull();
});
diff --git a/src/state/agentActivityStore.ts b/src/state/agentActivityStore.ts
index fd6b4a2..40c376c 100644
--- a/src/state/agentActivityStore.ts
+++ b/src/state/agentActivityStore.ts
@@ -287,7 +287,7 @@ export function forceIdleForCwd(cwd: string): void {
* hook will never arrive. Without this, the next time the user runs
* `claude` in the same pane the old record is still in the map; the
* sidebar spinner stays on (any working session counts) and the
- * per-pane AgentChrome can briefly show the killed agent's last
+ * per-pane session consumers can briefly show the killed agent's last
* status before the new SessionStart record overrides it.
*
* This is intentionally more aggressive than `forceIdleForCwd`. That
@@ -331,7 +331,7 @@ export function useTrackAgentActivity(_worktreeId: string, cwd: string): boolean
/**
* Return the most-recently-updated SessionRecord whose cwd is at or
- * below `cwd`, or null if none. Used by the per-pane AgentChrome to
+ * below `cwd`, or null if none. Used by per-pane session consumers to
* show "Claude is using Read" / "waiting for permission" / etc.
*
* "Most recent" matters when the user has multiple agents touching
diff --git a/src/terminal/AgentChrome.tsx b/src/terminal/AgentChrome.tsx
deleted file mode 100644
index 05a5692..0000000
--- a/src/terminal/AgentChrome.tsx
+++ /dev/null
@@ -1,290 +0,0 @@
-import { useAgentSessionForCwd } from "@/state/agentActivityStore";
-import type { SessionRecord } from "@/state/agentActivityStore";
-
-interface Props {
- /**
- * Pane's live cwd. AgentChrome resolves this to a SessionRecord via
- * the same prefix-match logic the worktree spinner uses, so an
- * agent that cd'd into a subdirectory still lights this chrome.
- */
- cwd?: string;
- /**
- * CLI detected from the pane's command line, shown while no hook
- * session exists yet (the window between launching the agent and
- * its first SessionStart event). Keeping the strip mounted during
- * that window keeps its 32px height invariant for the whole
- * agent-mode lifetime — the PTY-dimension reserve in BlockTerminal
- * never has to reflow when the first event lands. `null` renders a
- * generic "agent" badge (aider and friends have no hook system).
- */
- pendingCli?: SessionRecord["provider"] | null;
-}
-
-/**
- * Pinned strip height in CSS pixels. MUST stay in sync with the
- * `agentChromeHeight` constant in BlockTerminal.tsx — that file's
- * PTY-dimension calc reserves this exact number, and any drift between
- * the two reserves either an empty stripe under the canvas or sends
- * the canvas under the chrome.
- *
- * The strip is rendered as a fixed-height box (height + boxSizing:
- * border-box) so its rendered height is INVARIANT across status
- * transitions. Earlier versions sized to content — the "permission
- * requested" pill (12px SVG + semibold text) made the strip a few
- * pixels taller than the working/idle states, which fired
- * CanvasGrid's inner ResizeObserver, reconfigured the WebGPU
- * swapchain mid-frame, and produced the "agent pane goes blank when
- * Claude asks for permission" symptom. Pinning the height prevents
- * the layout shift at its source.
- */
-export const AGENT_CHROME_HEIGHT_PX = 32;
-
-/**
- * Warp-style status strip rendered above the agent's TUI / live block.
- * Reads the hook-driven SessionRecord for this pane's cwd and surfaces:
- *
- * - Provider badge (claude / codex / gemini) — first-class identity.
- * - Status indicator + label (spinner while working, ⏸ when idle,
- * ⚠ when waiting on permission, ⏵ during compaction).
- * - Active tool name when the agent is mid-turn — "Claude is using
- * Read". We don't yet have the tool input payload, so we stop at
- * the tool name; a Phase-3-polish hook envelope enrichment can
- * promote this to "Claude is reading foo.ts" without changing the
- * component contract.
- *
- * Renders nothing when there's no matching session — so a plain shell
- * pane (or an agent the user has just launched but hasn't fired its
- * first hook from) sees zero visual overhead.
- *
- * Visual reference: Warp's `use_agent_footer` panel
- * (/tmp/warp-check/app/src/terminal/view/use_agent_footer/mod.rs).
- */
-export function AgentChrome({ cwd, pendingCli }: Props) {
- const session = useAgentSessionForCwd(cwd ?? "");
- if (!session || session.status === "ended") {
- // No hook session (yet). When the caller told us which agent is
- // launching, hold the strip's slot with a quiet "starting" state
- // instead of unmounting — see the pendingCli prop doc.
- return ;
- }
-
- return (
-
-
-
-
- {session.status === "waiting" && (
-
-
- permission requested
-
- )}
-
- );
-}
-
-/**
- * Shared 32px strip shell. BOTH the live and pending states render
- * through this so the pinned-height contract lives in exactly one
- * place and can't drift between them.
- */
-function StripShell({ children }: { children: React.ReactNode }) {
- return (
-
- {children}
-
- );
-}
-
-/**
- * Pre-session state: the agent was just launched and hasn't fired its
- * first hook yet (or has no hook system at all — aider). Occupies the
- * same 32px slot so the PTY grid below never reflows when the real
- * session record arrives.
- */
-function PendingStrip({ cli }: { cli: SessionRecord["provider"] | null }) {
- return (
-
- {cli ? (
-
- ) : (
-
- agent
-
- )}
- starting…
-
- );
-}
-
-/** Single source of truth for provider display. Exhaustive switch so
- a future provider added to the union surfaces a TS error here
- instead of silently falling through to the wrong color. */
-function providerDisplay(provider: SessionRecord["provider"]): {
- label: string;
- accent: string;
-} {
- switch (provider) {
- case "claude":
- return { label: "Claude", accent: "var(--state-warning)" };
- case "codex":
- return { label: "Codex", accent: "var(--state-info)" };
- case "gemini":
- return { label: "Gemini", accent: "var(--accent-bright)" };
- }
-}
-
-function ProviderBadge({ provider }: { provider: SessionRecord["provider"] }) {
- const { label, accent } = providerDisplay(provider);
- return (
-
- {label}
-
- );
-}
-
-function StatusGlyph({ status }: { status: SessionRecord["status"] }) {
- // Spinner during active work; static glyph otherwise. Driven by
- // pure CSS (motion.css owns the keyframes); no JS interval.
- switch (status) {
- case "working":
- case "compacting":
- return (
-
- );
- case "waiting":
- return (
-
- ⏸
-
- );
- case "idle":
- case "ended":
- return (
-
- ✓
-
- );
- }
-}
-
-function StatusLabel({ session }: { session: SessionRecord }) {
- const { label: providerName } = providerDisplay(session.provider);
- switch (session.status) {
- case "compacting":
- return {providerName} is compacting context;
- case "working":
- if (session.last_tool && session.last_tool.length > 0) {
- return (
-
- {providerName} is using{" "}
-
- {session.last_tool}
-
-
- );
- }
- return {providerName} is working;
- case "waiting":
- return {providerName} is waiting;
- case "idle":
- case "ended":
- return {providerName} is idle;
- }
-}
-
-function WarnIcon() {
- return (
-
- );
-}
diff --git a/src/terminal/BlockTerminal.tsx b/src/terminal/BlockTerminal.tsx
index 4c65578..d51a1ad 100644
--- a/src/terminal/BlockTerminal.tsx
+++ b/src/terminal/BlockTerminal.tsx
@@ -29,7 +29,6 @@ import {
clearTerminalRunning,
} from "./terminalActivityStore";
import { detectAgentBanner } from "@/lib/claudeUsage";
-import { AgentChrome, AGENT_CHROME_HEIGHT_PX } from "./AgentChrome";
import { termKillForeground, termResetGrid } from "@/lib/tauri/term";
import { writeClipboardTextWithFallback } from "./clipboardWrite";
import { decideSoftResetAction } from "./softReset";
@@ -39,6 +38,8 @@ import {
shouldRenderBlockList,
} from "./agentScrollLayout";
import { deriveInputMode, nextRawLatch } from "./inputModeDecision";
+import { makeCodexScrollable } from "./agentCommand";
+import { shouldPreserveTerminalSelection } from "./terminalClickFocus";
/** Command names that always run as an interactive TUI agent. */
function isAgentCommand(command: string): boolean {
@@ -359,6 +360,17 @@ export function BlockTerminal({
// `passthroughActive` is computed.
const passthroughActiveRef = useRef(false);
+ // One focus path for tab switches and pointer clicks. The refs let this
+ // always target whichever input layer is mounted right now, including raw
+ // inline prompts that use PtyPassthrough without setting agent mode.
+ const focusTerminalInput = useCallback(() => {
+ if (foregroundIsAgentRef.current || passthroughActiveRef.current) {
+ passthroughRef.current?.focus();
+ } else {
+ promptRef.current?.focus();
+ }
+ }, []);
+
// Register a focus function for this terminal's tab id, so the
// global `useFocusActiveTerminal` hook can send focus here whenever
// the user switches to this terminal's worktree. Mirrors the
@@ -366,15 +378,9 @@ export function BlockTerminal({
// we focus the PtyPassthrough invisible input (forwards every key
// straight to the PTY); in shell mode we focus PromptInput's textarea.
useEffect(() => {
- registerTerminalFocus(id, () => {
- if (foregroundIsAgentRef.current || passthroughActiveRef.current) {
- passthroughRef.current?.focus();
- } else {
- promptRef.current?.focus();
- }
- });
+ registerTerminalFocus(id, focusTerminalInput);
return () => unregisterTerminalFocus(id);
- }, [id]);
+ }, [id, focusTerminalInput]);
// While an agent session is foregrounded, the launch command
// ("claude" / "codex" / "gemini") tells you nothing about what's
@@ -527,8 +533,8 @@ export function BlockTerminal({
// Sustained anchor while stick-to-bottom is true: ANY growth of the
// scroll container's content (BlockList row added, LiveBlock body
- // tall canvas mid-bootstrap, AgentChrome rendering for the first
- // time, etc.) re-snaps scrollTop to the new scrollHeight. Without
+ // tall canvas mid-bootstrap, etc.) re-snaps scrollTop to the new
+ // scrollHeight. Without
// this, the user-reported "open a new claude and it glitches
// halfway up the pane" bug fires whenever the LiveBlock fill mode
// settles AFTER the layout-effect snap above has already run — the
@@ -1319,10 +1325,10 @@ export function BlockTerminal({
// Report this scroll container's region (top offset within the pane + height)
// to the native surface, so native content pins to exactly it: the shell
- // transcript sits just above the input bar, and an agent grid sits just below
- // the AgentChrome strip — never behind either. The native surface still spans
- // the whole pane; this only bounds the content. Covers shell AND agent (the top
- // offset differs); re-reports on resize and when agent mode toggles the strip.
+ // transcript sits just above the input bar, while an agent grid claims the
+ // full terminal viewport. The native surface still spans the whole pane; this
+ // only bounds the content. Covers shell AND agent and re-reports on resize or
+ // mode changes.
useEffect(() => {
const el = scrollContainerRef.current;
if (!el || !nativeActive) return;
@@ -1424,23 +1430,14 @@ export function BlockTerminal({
// the terminal grid (38px + a 6px breathing strip = 44).
const inputChrome = agentMode ? 44 : 80;
const liveBlockChrome = 50;
- // The AgentChrome strip is mounted whenever a known agent CLI
- // is foregrounded (same flag as the JSX mount below — keep them
- // in sync or the PTY either clips its bottom row under the
- // input box or leaves a 32px black stripe). The pendingCli
- // fallback inside AgentChrome guarantees the strip renders for
- // the whole foregroundIsAgent lifetime, so this reserve is
- // exact, not conditional on hook events having arrived.
- const agentChromeHeight = foregroundIsAgent ? AGENT_CHROME_HEIGHT_PX : 0;
- // On a NATIVE agent pane the only real chrome is the AgentChrome strip:
- // the input bar is hidden and the live block is opacity:0, so reserving
- // their heights (inputChrome + liveBlockChrome) would shrink Claude's PTY
- // below the pane the native surface actually fills — leaving black space
- // above the agent. Reserve only the strip there so Claude fills the pane.
+ // A NATIVE agent pane has no terminal chrome: the input bar is hidden and
+ // the live block is opacity:0. Reserving their heights would shrink the
+ // agent PTY below the pane the native surface actually fills and leave a
+ // black gap above it, so native agents reserve zero pixels here.
const reserved =
agentMode && nativeSurface
- ? agentChromeHeight
- : inputChrome + liveBlockChrome + agentChromeHeight;
+ ? 0
+ : inputChrome + liveBlockChrome;
const usableHeight = Math.max(120, rect.height - reserved);
// Match the native renderer's row pitch (LINE_HEIGHT_RATIO 1.3 in
// warp_term.rs) on native panes so PTY rows × pitch == the painted grid
@@ -1830,7 +1827,13 @@ export function BlockTerminal({
sniffBufferRef.current = "";
setForegroundIsAgent(true);
}
- void sendLine(text);
+ // Codex defaults to the alternate screen but does not enable mouse
+ // reporting there, making wheel gestures a no-op. Its supported inline
+ // mode writes the conversation into normal PTY scrollback, which our
+ // native transcript already scrolls smoothly (the same path Claude uses).
+ // Keep `text` as the display/history value; only the bytes sent to the
+ // shell receive the integration flag.
+ void sendLine(text, makeCodexScrollable(text));
if (text.trim().length > 0) {
setHistory((prev) => {
const next = [text, ...prev];
@@ -2085,22 +2088,31 @@ export function BlockTerminal({
[history],
);
- // Don't steal focus from a text selection. Click on a block to copy
- // → the selection survives. Click into empty terminal space → focus
- // the active input (PromptInput in shell mode, PtyPassthrough in
- // agent mode) so typing "just works".
+ // Don't steal focus from a text selection made in THIS terminal. A selection
+ // left behind in another pane must not block this terminal from taking focus;
+ // that was the intermittent "clicked main, still typing in the side pane"
+ // failure. Click into empty terminal space → focus the currently-mounted
+ // input layer so typing "just works".
const onContainerMouseUp = (e: MouseEvent) => {
const sel = window.getSelection();
- if (sel && !sel.isCollapsed && sel.toString().length > 0) {
- return;
+ const container = containerRef.current;
+ if (sel && container) {
+ const anchorInside = !!sel.anchorNode && container.contains(sel.anchorNode);
+ const focusInside = !!sel.focusNode && container.contains(sel.focusNode);
+ if (
+ shouldPreserveTerminalSelection({
+ collapsed: sel.isCollapsed,
+ textLength: sel.toString().length,
+ anchorInside,
+ focusInside,
+ })
+ ) {
+ return;
+ }
}
// Only refocus on plain left-clicks; right-click opens context menus.
if (e.button !== 0) return;
- if (foregroundIsAgent) {
- passthroughRef.current?.focus();
- } else {
- promptRef.current?.focus();
- }
+ focusTerminalInput();
};
// Mark this terminal as the most-recently-interacted one so the
@@ -2392,25 +2404,6 @@ export function BlockTerminal({
)}
- {/* Warp-style agent status strip: hook-driven spinner + "Codex is
- using Read" + the waiting-on-permission pill. An earlier pass
- removed it as redundant with the agent's own TUI, but the TUI
- shows nothing when the pane is scrolled away from the input
- box or the agent silently waits on a permission prompt — the
- strip is the one always-visible truth. Mounted ONLY for known
- agent CLIs (foregroundIsAgent), never for vim/fzf/alt-screen
- — deriveInputMode's agentMode is deliberately NOT the gate
- here. computeDims reserves AGENT_CHROME_HEIGHT_PX under the
- same flag, and the pendingCli fallback keeps the strip's
- 32px present from launch, so the PTY grid never reflows when
- the first hook event lands. */}
- {foregroundIsAgent && (
-
- )}
-
{/* Alt-screen TUIs (vim, htop, claude-in-alt-screen) render on the
native Metal surface. The previous React WebGPU CanvasGrid
alt-screen path has been retired now that the native surface
diff --git a/src/terminal/LiveBlock.tsx b/src/terminal/LiveBlock.tsx
index b86e7bc..1ef459d 100644
--- a/src/terminal/LiveBlock.tsx
+++ b/src/terminal/LiveBlock.tsx
@@ -307,9 +307,9 @@ export function LiveBlock({
// `renderer.resize()` and the bottom rows of the canvas
// (claude's input box + footer) would simply never be
// painted. flex-end keeps the canvas hugging the bottom of
- // the body whenever it IS shorter than the body — so a
- // freshly-started agent's input row sits flush against the
- // agent status bar below instead of floating in the middle.
+ // the body whenever it IS shorter than the body, so a
+ // freshly-started agent's input row sits at the pane bottom
+ // instead of floating in the middle.
flex: fill ? "1 1 auto" : undefined,
display: fill ? "flex" : undefined,
flexDirection: fill ? "column" : undefined,
diff --git a/src/terminal/agentCommand.test.ts b/src/terminal/agentCommand.test.ts
new file mode 100644
index 0000000..b9b8a5a
--- /dev/null
+++ b/src/terminal/agentCommand.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, test } from "bun:test";
+import { makeCodexScrollable } from "./agentCommand";
+
+describe("makeCodexScrollable", () => {
+ test.each([
+ ["codex", "codex --no-alt-screen"],
+ ["codex resume --last", "codex --no-alt-screen resume --last"],
+ [
+ "/opt/homebrew/bin/codex --search",
+ "/opt/homebrew/bin/codex --no-alt-screen --search",
+ ],
+ ["codex-cli", "codex-cli --no-alt-screen"],
+ [
+ "DEBUG_LABEL='one two' codex -m gpt-5",
+ "DEBUG_LABEL='one two' codex --no-alt-screen -m gpt-5",
+ ],
+ [
+ '"/opt/homebrew/bin/codex" resume',
+ '"/opt/homebrew/bin/codex" --no-alt-screen resume',
+ ],
+ ])("adds inline mode to %p", (input, expected) => {
+ expect(makeCodexScrollable(input)).toBe(expected);
+ });
+
+ test.each([
+ "codex --no-alt-screen",
+ "codex --no-alt-screen resume --last",
+ "claude",
+ "echo codex",
+ "",
+ ])("leaves %p unchanged", (input) => {
+ expect(makeCodexScrollable(input)).toBe(input);
+ });
+});
diff --git a/src/terminal/agentCommand.ts b/src/terminal/agentCommand.ts
new file mode 100644
index 0000000..26b12bd
--- /dev/null
+++ b/src/terminal/agentCommand.ts
@@ -0,0 +1,91 @@
+/**
+ * Split a shell command into word spans without changing the original text.
+ * This is deliberately a small lexer rather than a shell parser: we only need
+ * to find the first executable token while respecting quoted env values such
+ * as `DEBUG_LABEL='one two' codex`.
+ */
+function shellWordSpans(input: string): Array<{ start: number; end: number }> {
+ const spans: Array<{ start: number; end: number }> = [];
+ let start = -1;
+ let quote: "'" | '"' | null = null;
+ let escaped = false;
+
+ for (let i = 0; i < input.length; i += 1) {
+ const char = input[i];
+ if (start === -1) {
+ if (/\s/.test(char)) continue;
+ start = i;
+ }
+
+ if (escaped) {
+ escaped = false;
+ continue;
+ }
+ if (char === "\\" && quote !== "'") {
+ escaped = true;
+ continue;
+ }
+ if (quote) {
+ if (char === quote) quote = null;
+ continue;
+ }
+ if (char === "'" || char === '"') {
+ quote = char;
+ continue;
+ }
+ if (/\s/.test(char)) {
+ spans.push({ start, end: i });
+ start = -1;
+ }
+ }
+
+ if (start !== -1) spans.push({ start, end: input.length });
+ return spans;
+}
+
+function unquoteShellWord(word: string): string {
+ if (word.length >= 2) {
+ const first = word[0];
+ const last = word[word.length - 1];
+ if ((first === "'" || first === '"') && first === last) {
+ return word.slice(1, -1);
+ }
+ }
+ return word;
+}
+
+/**
+ * Make Codex use the terminal's normal buffer so its conversation participates
+ * in Goonware's native transcript scrollback, just like Claude's output does.
+ *
+ * Codex defaults to the alternate screen. It does not enable mouse reporting,
+ * so Goonware's safe alt-screen wheel bridge has nothing to forward and a wheel
+ * gesture is a no-op. Codex's supported `--no-alt-screen` switch is the correct
+ * integration point: output then enters alacritty scrollback and is handled by
+ * the existing `term_native_scroll` path.
+ */
+export function makeCodexScrollable(input: string): string {
+ const spans = shellWordSpans(input);
+ let executable: { start: number; end: number } | undefined;
+
+ for (const span of spans) {
+ const word = input.slice(span.start, span.end);
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) continue;
+ executable = span;
+ break;
+ }
+ if (!executable) return input;
+
+ const rawExecutable = unquoteShellWord(
+ input.slice(executable.start, executable.end),
+ );
+ const basename = rawExecutable.split("/").pop() ?? rawExecutable;
+ if (basename !== "codex" && basename !== "codex-cli") return input;
+
+ const alreadyInline = spans.some(
+ ({ start, end }) => input.slice(start, end) === "--no-alt-screen",
+ );
+ if (alreadyInline) return input;
+
+ return `${input.slice(0, executable.end)} --no-alt-screen${input.slice(executable.end)}`;
+}
diff --git a/src/terminal/terminalClickFocus.test.ts b/src/terminal/terminalClickFocus.test.ts
new file mode 100644
index 0000000..befaeb7
--- /dev/null
+++ b/src/terminal/terminalClickFocus.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, test } from "bun:test";
+import { shouldPreserveTerminalSelection } from "./terminalClickFocus";
+
+describe("shouldPreserveTerminalSelection", () => {
+ test("preserves a selection made in the clicked terminal", () => {
+ expect(
+ shouldPreserveTerminalSelection({
+ collapsed: false,
+ textLength: 12,
+ anchorInside: true,
+ focusInside: true,
+ }),
+ ).toBe(true);
+ });
+
+ test("allows focus when the selection belongs to another pane", () => {
+ expect(
+ shouldPreserveTerminalSelection({
+ collapsed: false,
+ textLength: 12,
+ anchorInside: false,
+ focusInside: false,
+ }),
+ ).toBe(false);
+ });
+
+ test("allows focus when there is no active selection", () => {
+ expect(
+ shouldPreserveTerminalSelection({
+ collapsed: true,
+ textLength: 0,
+ anchorInside: true,
+ focusInside: true,
+ }),
+ ).toBe(false);
+ });
+
+ test("preserves a selection dragged beyond the terminal edge", () => {
+ expect(
+ shouldPreserveTerminalSelection({
+ collapsed: false,
+ textLength: 12,
+ anchorInside: true,
+ focusInside: false,
+ }),
+ ).toBe(true);
+ });
+});
diff --git a/src/terminal/terminalClickFocus.ts b/src/terminal/terminalClickFocus.ts
new file mode 100644
index 0000000..407a8a3
--- /dev/null
+++ b/src/terminal/terminalClickFocus.ts
@@ -0,0 +1,20 @@
+export interface TerminalSelectionState {
+ collapsed: boolean;
+ textLength: number;
+ anchorInside: boolean;
+ focusInside: boolean;
+}
+
+/**
+ * Keep a drag selection only when it belongs to the terminal being clicked.
+ * A selection left behind in another pane must not stop this terminal from
+ * taking keyboard focus.
+ */
+export function shouldPreserveTerminalSelection({
+ collapsed,
+ textLength,
+ anchorInside,
+ focusInside,
+}: TerminalSelectionState): boolean {
+ return !collapsed && textLength > 0 && (anchorInside || focusInside);
+}
diff --git a/src/terminal/useTerminalSession.ts b/src/terminal/useTerminalSession.ts
index be277ad..1d511b9 100644
--- a/src/terminal/useTerminalSession.ts
+++ b/src/terminal/useTerminalSession.ts
@@ -85,8 +85,12 @@ interface SessionApi {
cwd: string | null;
/** Bell event counter — increments on every BEL. Frontend animates on diff. */
bellTick: number;
- /** Submit a line + trailing \n. Used by the block-mode prompt input. */
- sendLine: (text: string) => Promise;
+ /**
+ * Submit a line + trailing \n. `wireText` may differ when Goonware needs an
+ * integration-only CLI flag while keeping the user's original command in
+ * block history.
+ */
+ sendLine: (text: string, wireText?: string) => Promise;
/** Send raw bytes (passthrough for ⌃C, alt-screen keystrokes, etc.). */
sendBytes: (bytes: Uint8Array) => Promise;
/** Tell Rust the cell-grid size changed. */
@@ -591,12 +595,12 @@ export function useTerminalSession(opts: Args): SessionApi {
});
};
- const sendLine = async (text: string) => {
+ const sendLine = async (text: string, wireText = text) => {
// Push to the pending queue BEFORE sending bytes — there's a real
// (if tiny) chance the block-close event lands before this function
// resolves on a fast machine, so the queue must be primed first.
pendingInputsRef.current.push(text);
- const bytes = encoder.encode(text + "\n");
+ const bytes = encoder.encode(wireText + "\n");
await sendBytes(bytes);
};