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