diff --git a/docs/superpowers/plans/2026-09-05-mouse-mode-terminal-submit.md b/docs/superpowers/plans/2026-09-05-mouse-mode-terminal-submit.md new file mode 100644 index 00000000..6ca1f21e --- /dev/null +++ b/docs/superpowers/plans/2026-09-05-mouse-mode-terminal-submit.md @@ -0,0 +1,568 @@ +# Mouse Mode Submit for Agent Terminal Panes — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a Mouse Mode-gated Submit button to `AgentTerminalLeaf` that sends the Enter byte (`'\r'`) to the agent PTY through the existing keypress pipeline — no new setting, no layout change when off, plain shells untouched. + +**Architecture:** A self-contained `AgentTerminalActions` row component is mounted in `AgentTerminalLeaf` only when `mouseModeEnabled`. Its click routes through a ref published by the mount effect that mirrors the existing `onData` keypress path (`forwarder` + pre-attach `pendingInput` queue), so a mouse click and a real Enter are byte-identical to the backend. + +**Tech Stack:** React (renderer), xterm + `terminalInputForwarder` (#745), the existing zustand settings store (`mouseModeEnabled`), Vitest renderer project (happy-dom). + +**Related:** Issue #819; design doc `docs/superpowers/specs/2026-09-05-mouse-mode-terminal-submit-design.md`; mouse-first plan PR #617. + +> **Where the implementation diverged from this plan**, so the next reader trusts the code over the doc: +> +> - **The focus test asserts `defaultPrevented` via a dispatched native `MouseEvent`, not `fireEvent`'s return value.** RTL's synthetic mouse-down object reports `undefined` for `defaultPrevented` after React processes the handler in this happy-dom setup. The component behavior is unchanged. +> - **`AgentTerminalActions.renderer.test.tsx` imports `act`** for that dispatched event; everything else matches the plan verbatim. + +--- + +## Environment note (read first) + +This machine's default node is `v25.5.0`. happy-dom 20.9.0's `localStorage` is broken under Node 25 (`storage.setItem is not a function` — 34 renderer tests fail). CI and `.nvmrc` pin **Node 24**. Every test/typecheck command below MUST run with the override: + +```bash +export PATH="$HOME/.nvm/versions/node/v24.14.1/bin:$PATH" +``` + +Verify with `node --version` → `v24.14.1`. + +All work happens in the worktree `.worktrees/mouse-mode-terminal-submit` on `feat/mouse-mode-terminal-submit`. Submodules are checked out and `npm install` has run (baseline: 121 renderer files / 514 tests passing). + +--- + +### Task 1: `AgentTerminalActions` component (test-first) + +**Files:** +- Create: `src/renderer/src/workspace/tile-tree/AgentTerminalActions.tsx` +- Test: `src/renderer/src/workspace/tile-tree/AgentTerminalActions.renderer.test.tsx` + +- [ ] **Step 1: Write the failing test** + +Create `src/renderer/src/workspace/tile-tree/AgentTerminalActions.renderer.test.tsx`: + +```tsx +import { act, fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import { AgentTerminalActions } from './AgentTerminalActions' + +describe('AgentTerminalActions', () => { + it('renders exactly one always-enabled Submit button', () => { + render( {}} />) + const button = screen.getByRole('button', { name: 'Submit' }) + expect(button).not.toBeDisabled() + expect(screen.getAllByRole('button')).toHaveLength(1) + }) + + it('prevents default on mousedown so xterm keeps focus', () => { + render( {}} />) + const button = screen.getByRole('button', { name: 'Submit' }) + // Dispatch a real cancelable mousedown rather than relying on fireEvent's + // return value: RTL's synthetic object does not reflect defaultPrevented + // after React processes the handler in this environment. + const mousedown = new MouseEvent('mousedown', { bubbles: true, cancelable: true }) + act(() => { button.dispatchEvent(mousedown) }) + expect(mousedown.defaultPrevented).toBe(true) + }) + + it('still lets mousedown bubble so the owning leaf engages the session', () => { + const onMouseDown = vi.fn() + render( +
+ {}} /> +
, + ) + fireEvent.mouseDown(screen.getByRole('button', { name: 'Submit' })) + expect(onMouseDown).toHaveBeenCalledTimes(1) + }) + + it('fires onSubmit once per click', () => { + const onSubmit = vi.fn() + render() + const button = screen.getByRole('button', { name: 'Submit' }) + fireEvent.mouseDown(button) + fireEvent.click(button) + fireEvent.click(button) + expect(onSubmit).toHaveBeenCalledTimes(2) + }) + + it('uses the composer control scaffold', () => { + const { container } = render( {}} />) + const button = screen.getByRole('button', { name: 'Submit' }) + expect(button.className).toContain('rounded-control') + expect(button.className).toContain('control-active-bg') + expect(container.firstElementChild!.className).toContain('border-t') + expect(container.firstElementChild!.className).toContain('bg-surface') + }) +}) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: +```bash +export PATH="$HOME/.nvm/versions/node/v24.14.1/bin:$PATH" +npx vitest run --project renderer AgentTerminalActions +``` +Expected: FAIL — module `./AgentTerminalActions` not found (every test fails to import). + +- [ ] **Step 3: Write the minimal implementation** + +Create `src/renderer/src/workspace/tile-tree/AgentTerminalActions.tsx`: + +```tsx +import type { JSX } from 'react' + +// AgentTerminalActions — pointer-clickable Submit for raw agent terminals, +// shown only in Mouse Mode, the terminal-view sibling of ComposerActions. +// +// WHY it exists: AgentTerminalLeaf is a pure PTY view with no composer and no +// draft, so the one thing a mouse-only user is missing is the final Enter +// after dictating or pasting a command into the TUI (dictation intentionally +// never auto-submits — the user reviews, then presses Enter). This row is +// that Enter, nothing more. +// +// WHY it is behind a setting although the row is tiny: same logic as +// ComposerActions. It costs a row of pane height in EVERY agent pane, and a +// keyboard user submits with Enter and gets nothing from it. Mouse mode makes +// the trade worth taking. +// +// WHY Submit is never disabled: the raw PTY's current line lives inside the +// provider's TUI, so there is nothing to read back and nothing to gate on. +// The button must be exactly as conservative as a hardware Enter key — always +// available. This is ComposerActions' "must not be more conservative than +// Enter" rule applied to a surface without a draft. +// +// WHY the row lives in AgentTerminalLeaf and is NOT shared with TerminalLeaf: +// ordinary shells are explicitly out of scope for this feature (issue #819). +// A plain shell pane never had a Send affordance to lose; mounting controls +// there would only add chrome to panes that must stay untouched. + +export type AgentTerminalActionsProps = { + /** Sends the Enter byte to the agent PTY. */ + onSubmit: () => void +} + +export function AgentTerminalActions({ onSubmit }: AgentTerminalActionsProps): JSX.Element { + return ( +
+ +
+ ) +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: +```bash +export PATH="$HOME/.nvm/versions/node/v24.14.1/bin:$PATH" +npx vitest run --project renderer AgentTerminalActions +``` +Expected: PASS — 5 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/renderer/src/workspace/tile-tree/AgentTerminalActions.tsx \ + src/renderer/src/workspace/tile-tree/AgentTerminalActions.renderer.test.tsx +git commit -m "feat(terminal): add Mouse Mode Submit button component (#819)" +``` + +--- + +### Task 2: Wire Submit into `AgentTerminalLeaf` (test-first) + +**Files:** +- Modify: `src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx` (add selector at the top of the component near `dictationEnabled`; add `submitEnterRef` next to the other refs; set the ref inside the mount effect right after the forwarder is created; mount the row above ``) +- Test: `src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.submit.renderer.test.tsx` + +- [ ] **Step 1: Inspect the test harness to copy** + +Read `src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.dimensionOwnership.renderer.test.tsx` in full. The new file reuses its xterm/addon-fit/webgl/theme/dictation mocks and its `api`/`workspace` shapes, with one difference: `useAppStore` must expose **`mouseModeEnabled`** from a mutable holder so the tests can flip it. + +- [ ] **Step 2: Write the failing full-mount wiring test** + +Create `src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.submit.renderer.test.tsx`: + +```tsx +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { emptyRuntime } from '@renderer/session-runtime/state' +import { + AgentTerminalOwnershipProvider, + MountedAgentTerminalOwner, +} from '@renderer/workspace/terminal/AgentTerminalOwnership' +import type { Workspace } from '@renderer/workspace/workspaceStore' +import { AgentTerminalLeaf } from './AgentTerminalLeaf' + +type MockTerminal = Record & { + cols: number + rows: number + container: HTMLElement | null + onDataListener: ((data: string) => void) | null +} + +const xtermHarness = vi.hoisted(() => ({ + cols: 120, + rows: 40, + instances: [] as MockTerminal[], + attachWebgl: vi.fn(), + fit: vi.fn(), +})) + +const settings = vi.hoisted(() => ({ + dictationEnabled: false, + dictationProvider: 'local', + dictationShortcut: 'off', + mouseModeEnabled: false, +})) + +vi.mock('@renderer/workspace/terminal/xtermWebglRenderer', () => ({ + attachXtermWebglRenderer: xtermHarness.attachWebgl, +})) + +vi.mock('@xterm/xterm', () => ({ + Terminal: class { + cols = xtermHarness.cols + rows = xtermHarness.rows + options: Record = {} + container: HTMLElement | null = null + onDataListener: ((data: string) => void) | null = null + dispose = vi.fn() + inputDispose = vi.fn(() => { this.onDataListener = null }) + constructor() { xtermHarness.instances.push(this as unknown as MockTerminal) } + loadAddon() {} + open(container: HTMLElement) { this.container = container } + onData(listener: (data: string) => void) { + this.onDataListener = listener + return { dispose: this.inputDispose } + } + write(_data: string, callback?: () => void) { callback?.() } + focus() {} + }, +})) + +vi.mock('@xterm/addon-fit', () => ({ + FitAddon: class { fit() { xtermHarness.fit() } }, +})) + +vi.mock('@renderer/app-state/hooks', () => ({ + useAppStore: (selector: (state: Record) => unknown) => + selector({ settings }), +})) + +vi.mock('@renderer/app-state/settings/theme', async importOriginal => ({ + ...(await importOriginal>()), + THEME_CHANGED_EVENT: 'agent-code:test-theme-change', + getActiveAppFontFamily: () => 'monospace', +})) + +vi.mock('@renderer/workspace/tile-tree/xtermTheme', () => ({ + readXtermTheme: () => ({}), + syncXtermTheme: () => {}, +})) + +vi.mock('@renderer/workspace/tile-tree/TileLeaf/useComposerDictation', () => ({ + useComposerDictation: () => {}, +})) + +type Deferred = { promise: Promise; resolve: (value: T) => void } +function deferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise(done => { resolve = done }) + return { promise, resolve } +} + +describe('AgentTerminalLeaf Mouse Mode Submit', () => { + let attach: Deferred + let nextFrameId: number + let frames: Map + const resize = vi.fn().mockResolvedValue(undefined) + const sendInput = vi.fn().mockResolvedValue(undefined) + const api = { + attachAgentPty: vi.fn((_id: string) => attach.promise), + detachAgentPty: vi.fn().mockResolvedValue(undefined), + onSessionAgentPtyData: vi.fn(() => () => {}), + onSessionTerminalData: vi.fn(() => () => {}), + resize, + sendInput, + } + const workspace = { + acknowledgeSession: vi.fn(), + ensureSessionLive: vi.fn().mockResolvedValue(undefined), + showPaneToast: vi.fn(), + } as unknown as Workspace + + function flushAnimationFrames() { + const pending = [...frames.values()] + frames.clear() + for (const callback of pending) callback(performance.now()) + } + + function leaf() { + return ( + + + {}} + workspace={workspace} + runtime={{ ...emptyRuntime(), processStatus: 'started' }} + projectDir="/tmp/project" + provider="codex" + /> + + + ) + } + + beforeEach(() => { + settings.mouseModeEnabled = false + attach = deferred() + nextFrameId = 0 + frames = new Map() + xtermHarness.fit.mockClear() + xtermHarness.instances.length = 0 + xtermHarness.attachWebgl.mockReset() + xtermHarness.attachWebgl.mockImplementation(() => ({ + ready: Promise.resolve(true), + dispose: vi.fn(), + })) + api.attachAgentPty.mockReset().mockImplementation(() => attach.promise) + api.detachAgentPty.mockClear() + resize.mockClear() + sendInput.mockClear() + workspace.acknowledgeSession = vi.fn() + + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + const id = ++nextFrameId + frames.set(id, callback) + return id + }) + vi.stubGlobal('cancelAnimationFrame', (id: number) => { frames.delete(id) }) + vi.stubGlobal('ResizeObserver', class { + disconnect = vi.fn() + observe() {} + unobserve() {} + }) + Object.defineProperty(window, 'api', { configurable: true, value: api }) + }) + + afterEach(() => { + cleanup() + Reflect.deleteProperty(window, 'api') + vi.unstubAllGlobals() + }) + + it('hides Submit entirely when Mouse Mode is off', () => { + render(leaf()) + expect(screen.queryByRole('button', { name: 'Submit' })).toBeNull() + }) + + it('sends the Enter byte to the agent PTY after attach when Mouse Mode is on', async () => { + settings.mouseModeEnabled = true + render(leaf()) + act(() => flushAnimationFrames()) + await act(async () => { + attach.resolve('') + await attach.promise + }) + + const button = screen.getByRole('button', { name: 'Submit' }) + expect(button).not.toBeDisabled() + fireEvent.click(button) + await act(async () => { await Promise.resolve() }) + + expect(sendInput).toHaveBeenCalledWith('session-1', '\r') + }) + + it('queues a pre-attach Submit and delivers it once attach lands', async () => { + settings.mouseModeEnabled = true + render(leaf()) + act(() => flushAnimationFrames()) + fireEvent.click(screen.getByRole('button', { name: 'Submit' })) + expect(sendInput).not.toHaveBeenCalled() + + await act(async () => { + attach.resolve('') + await attach.promise + }) + expect(sendInput).toHaveBeenCalledWith('session-1', '\r') + }) +}) +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: +```bash +export PATH="$HOME/.nvm/versions/node/v24.14.1/bin:$PATH" +npx vitest run --project renderer AgentTerminalLeaf.submit +``` +Expected: FAIL — `queryByRole(... 'Submit')` matches nothing because the leaf does not render the row yet. + +- [ ] **Step 4: Wire the component, byte-routing ref, and gating into the leaf** + +Edit `src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx`: + +1. Import `AgentTerminalActions` next to the other tile-tree imports: + +```tsx +import { AgentTerminalActions } from '@renderer/workspace/tile-tree/AgentTerminalActions' +``` + +2. Add the Mouse Mode selector next to the `dictation*` selectors inside the component (after the `dictationShortcut` line, ~line 63): + +```tsx +const mouseModeEnabled = useAppStore(state => state.settings.mouseModeEnabled) +``` + +3. Add the ref next to `showPaneToastRef`: + +```tsx +// Published by the mount effect (which owns the forwarder and pre-attach +// queue) so the Mouse Mode Submit button can inject Enter exactly as the +// Enter key would. A no-op until the effect has run; the effect always re-runs +// on (re)mount and overwrites it. +const submitEnterRef = useRef<() => void>(() => {}) +``` + +4. Inside the mount effect, immediately after the forwarder is created (the `const forwarder = createTerminalInputForwarder(...)` block, ~line 244-246), publish the routing closure: + +```tsx + // WHY the Submit button reuses the keypress pipeline instead of calling + // window.api.sendInput directly: the leaf only forwards keystrokes AFTER + // attach (pendingInput) and only outside the replay window (the + // forwarder latch). A direct call would skip both, so its Enter could + // hit the provider before the PTY exists or while xterm is still parsing + // the attach replay — more powerful than the Enter key it replaces. Pushing + // '\r' down the same path keeps a mouse click and a real keypress + // indistinguishable to the backend. '\r' is xterm's Enter byte here + // because this terminal is created with convertEol: false above. + submitEnterRef.current = () => { + if (forwarder.replaying) return + if (!attachedBackfillDone) { + pendingInput.push('\r') + return + } + forwarder.onData('\r') + } +``` + +5. Mount the row between the terminal box and `` (replace the `` opening context — insert the gated row immediately before it): + +```tsx + {/* Mouse Mode only, mirroring ComposerActions' gating in TileLeaf. A raw + terminal has no composer or draft, so Submit is this surface's only + action — the Enter byte a keyboard user presses after dictating or + pasting. Gated on the setting because the row costs pane height in + every agent pane and a keyboard user gets nothing from it. */} + {mouseModeEnabled ? ( + submitEnterRef.current()} /> + ) : null} + +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: +```bash +export PATH="$HOME/.nvm/versions/node/v24.14.1/bin:$PATH" +npx vitest run --project renderer AgentTerminalLeaf.submit +``` +Expected: PASS — 3 tests. + +- [ ] **Step 6: Run the rest of the terminal/tile-tree renderer tests to catch regressions** + +Run: +```bash +export PATH="$HOME/.nvm/versions/node/v24.14.1/bin:$PATH" +npx vitest run --project renderer AgentTerminalLeaf TerminalLeaf ComposerActions +``` +Expected: PASS — the dimension-ownership harness (whose `useAppStore` mock does NOT provide `mouseModeEnabled`) still passes, proving the new selector degrades to `undefined` (falsy) safely. + +- [ ] **Step 7: Commit** + +```bash +git add src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx \ + src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.submit.renderer.test.tsx +git commit -m "feat(terminal): submit Enter to agent PTY in Mouse Mode (#819)" +``` + +--- + +### Task 3: Full verification, PR + +**Files:** none (read-only checks + git operations) + +- [ ] **Step 1: Confirm the out-of-scope files are byte-identical** + +Run: +```bash +git diff --stat main -- src/renderer/src/workspace/tile-tree/TerminalLeaf.tsx \ + src/renderer/src/workspace/tile-tree/TileLeaf/ComposerActions.tsx +``` +Expected: empty output (no diff). + +- [ ] **Step 2: Typecheck** + +Run: +```bash +export PATH="$HOME/.nvm/versions/node/v24.14.1/bin:$PATH" +npm run typecheck +``` +Expected: completes without errors (tsc `-b` across the project). + +- [ ] **Step 3: Full renderer suite** + +Run: +```bash +export PATH="$HOME/.nvm/versions/node/v24.14.1/bin:$PATH" +npm run test:renderer +``` +Expected: 123 test files pass, 0 failures (121 baseline + the two new files `AgentTerminalActions.renderer.test.tsx` and `AgentTerminalLeaf.submit.renderer.test.tsx`, which add 8 tests: 5 + 3). + +- [ ] **Step 4: Confirm submodule integrity** + +Run: +```bash +node scripts/verify-submodule-checkouts.mjs +``` +Expected: `Verified 6 pinned submodule checkouts.` + +- [ ] **Step 5: Self-review the diff** + +Run: +```bash +git diff main --stat +git diff main -- src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx +``` +Review: only the 5 edit sites from Task 2 changed in the leaf; no other files touched. + +- [ ] **Step 6: Push and open the PR** + +Run: +```bash +git log --oneline main..HEAD +git push -u origin feat/mouse-mode-terminal-submit +gh pr create --repo Juliusolsson05/agent-code \ + --title "feat(terminal): Mouse Mode Submit button for agent terminal panes (#819)" \ + --body "Closes #819. Adds a Mouse Mode-gated Submit row to AgentTerminalLeaf that sends the Enter byte (\\'\\r\\') to the agent PTY through the existing keypress pipeline. See plan docs/superpowers/plans/2026-09-05-mouse-mode-terminal-submit.md." \ + --base main +``` +Expected: PR opened. **Do not merge** — the user must confirm first. \ No newline at end of file diff --git a/docs/superpowers/specs/2026-09-05-mouse-mode-terminal-submit-design.md b/docs/superpowers/specs/2026-09-05-mouse-mode-terminal-submit-design.md new file mode 100644 index 00000000..20483eae --- /dev/null +++ b/docs/superpowers/specs/2026-09-05-mouse-mode-terminal-submit-design.md @@ -0,0 +1,96 @@ +# Mouse Mode Submit for Agent Terminal Panes — Design + +**Date:** 2026-09-05 +**Branch:** `feat/mouse-mode-terminal-submit` +**Status:** Approved for implementation planning +**Issue:** #819 +**Follows:** `docs/superpowers/plans/2026-07-28-mouse-first-workspace.md` (PR #617, IMPLEMENTED) + +## Problem + +Mouse Mode (`mouseModeEnabled`) gates a pointer-clickable **Send** and **Stop** row, but only inside the rendered feed (`ComposerActions` in `TileLeaf.tsx:1021-1047`). When an agent runs in **Terminal view** — `AgentTerminalLeaf` (Claude/Codex TUI, and the OpenCode Terminal runtime where `providerRuntime === 'terminal'`) — there is no composer and therefore no Send, Stop, or any other mouse-reachable way to press Enter. + +The concrete failure: a mouse-only user dictates or pastes a command into the raw agent PTY, then has no way to submit it. Dictation deliberately does **not** auto-submit — `useComposerDictation.ts:334` wraps the final text in bracketed-paste and the user is expected to press Enter. A mouse-only user has no path to that final Enter without the keyboard. + +This is exactly the W5 gap the mouse audit found in the feed ("a mouse user can't interrupt"), carried over into terminal view: **a mouse user can't submit**. + +## Scope + +- **In scope:** `AgentTerminalLeaf` only. When `mouseModeEnabled` is on, render a thin **Submit** action row below the xterm box. Clicking it sends the Enter byte (`'\r'`) to the agent PTY. +- **Explicitly out of scope:** + - **`TerminalLeaf` (plain shell panes, OpenCode shell sessions) — untouched.** Requirement from the user's directive: preserve ordinary shell behavior. Shells keep zero controls. The gap is specifically that *agents* in terminal view lose their composer affordances; a plain shell never had a Send button to lose. + - **A Stop button.** Interrupt semantics differ per TUI (Escape vs Ctrl+C vs internal protocol) and need their own verification pass. Deliberately deferred. + - **Any new setting.** `mouseModeEnabled` is reused. Off = row not mounted = zero layout change. + +## Design + +### Behavior: Submit is a hardware-Enter, nothing more + +The Submit button sends exactly the byte the Enter key produces for xterm: `'\r'` (`AgentTerminalLeaf` creates xterm with `convertEol: false`, so Enter is CR). It is routed through the **same** outgoing path as a real keypress — the `terminalInputForwarder` (#745) and its pre-attach queue — making it byte-for-byte identical to pressing Enter. Rationale: + +- A raw PTY has no readable "current line" (the live buffer lives inside the TUI), so there is nothing to disable against and nothing to read back. The button is always enabled, exactly like a hardware Enter key. This mirrors ComposerActions' "The button must not be more conservative than Enter" rule (`ComposerActions.tsx:60-68`). +- Because it rides the existing forwarder, all the derived invariants hold for free: bytes typed during replay are dropped (the ~100ms attach parse window), bytes before attach are queued in `pendingInput` and flushed on attach, and same-tick coalescing applies. Hand-building a direct `window.api.sendInput(sessionId, '\r')` would skip all three and make the button *more* powerful (or lossy) than the key. +- It must never touch `runtime.draftInput`, compose state, or feed state. Terminal view has no draft by design (`AgentTerminalLeaf.tsx:41-44` — it "deliberately bypasses the Agent Code feed/composer stack"); we are not inventing one. + +### Focus and engagement + +The button's `onMouseDown` does `event.preventDefault()` (the ComposerActions rule), so DOM focus never leaves the terminal. But it does **not** stop bubbling: the outer `AgentTerminalLeaf` div's captured `onMouseDown` (`AgentTerminalLeaf.tsx:453-457`) already handles `onFocusRequest()`, `acknowledgeSession`, and `focusTerminal()`, so clicking Submit re-focuses xterm and marks the session engaged — and a subsequent click delivers the Enter against a freshly focused xterm. No extra wiring needed. + +### Gating + +`const mouseModeEnabled = useAppStore(state => state.settings.mouseModeEnabled)` — the same selector TileLeaf uses (`TileLeaf.tsx:211`). Rendered as: + +```tsx +{mouseModeEnabled ? submitEnterRef.current()} /> : null} +``` + +mounted **below** the xterm box and **above** ``, mirroring ComposerActions' below-the-surface placement. + +### The ref that carries the Enter path + +The forwarder, `pendingInput`, and `attachedBackfillDone` live inside the mount effect's closure (keyed on `sessionId`). The click handler lives in JSX. To bridge them without remounting xterm, the effect publishes a stable closure on a ref: + +```tsx +const submitEnterRef = useRef<() => void>(() => {}) +``` + +set inside the effect right after the forwarder is created: + +```tsx +submitEnterRef.current = () => { + if (forwarder.replaying) return + if (!attachedBackfillDone) { + pendingInput.push('\r') + return + } + forwarder.onData('\r') +} +``` + +This is the exact three-way decision `onData` makes in the existing keypress handler. + +### New component + +`src/renderer/src/workspace/tile-tree/AgentTerminalActions.tsx` — a self-contained row so the surface stays readable, mirroring how `ComposerActions` and `PaneToast` are extracted. Reuses Send's row tokens (`border-t border-border bg-surface px-3 py-1.5`) and button tokens (`rounded-control border border-control-border bg-control-active-bg px-3 py-1 text-[11px] leading-none text-control-active-fg hover:bg-control-hover-bg`) so the two submit affordances read as the same control. Label: **Submit** (not Send — there is no text to send, and the design doc keeps the verb honest about what the byte does). + +### Accept-it-ship-it trace to the mouse-first relationship + +Mouse-first deliberately left terminal view out (its ComposerActions row sits inside TileLeaf and the plan fans out the same affordances there). This is the follow-up that closes the same gap for the `'terminal'` agent view mode (`EffectiveAgentSurface` in `agentDisplayMode.ts`). + +## Testing strategy + +Colocated renderer tests only, matching the suite's conventions (`testing/` is not the default; tests sit next to sources): + +1. `AgentTerminalActions.renderer.test.tsx` — component contract: renders, `mousedown` prevents default (focus preservation), `click` fires `onSubmit` once. Mirrors `PaneToast.renderer.test.tsx`'s style. +2. `AgentTerminalLeaf.submit.renderer.test.tsx` — full-mount wiring test using the xterm-mock harness pattern from `AgentTerminalLeaf.dimensionOwnership.renderer.test.tsx`: + - mouse mode on → button renders; **after** attach completes, clicking it calls `window.api.sendInput(sessionId, '\r')` (proves the byte and the routing). + - mouse mode off → button is absent (`queryByRole` null). This is the acceptance test for "ordinary shell behavior preserved by not mounting" on the agent side. + - click **before** attach resolves → Enter is queued and flushed once attach lands (proves byte-for-byte equality with a keypress in the pre-attach window). + +## Acceptance criteria + +- [ ] `AgentTerminalActions` + colocated renderer tests land. +- [ ] Full-leaf wiring test proves the click sends `'\r'`, is absent when mouse mode is off, and queues pre-attach. +- [ ] `TerminalLeaf.tsx` and `ComposerActions.tsx` are byte-identical after this change. +- [ ] `npm run typecheck` and `npm run test:renderer` pass under Node 24 (the `.nvmrc`/CI version; happy-dom's `localStorage` is broken under Node 25 — see plan Verification section). +- [ ] Open reviewable PR referencing #819; **do not merge** without user confirmation. \ No newline at end of file diff --git a/src/renderer/src/workspace/tile-tree/AgentTerminalActions.renderer.test.tsx b/src/renderer/src/workspace/tile-tree/AgentTerminalActions.renderer.test.tsx new file mode 100644 index 00000000..e89cdb0b --- /dev/null +++ b/src/renderer/src/workspace/tile-tree/AgentTerminalActions.renderer.test.tsx @@ -0,0 +1,54 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import { AgentTerminalActions } from './AgentTerminalActions' + +describe('AgentTerminalActions', () => { + it('renders exactly one always-enabled Submit button', () => { + render( {}} />) + const button = screen.getByRole('button', { name: 'Submit' }) + expect(button).not.toBeDisabled() + expect(screen.getAllByRole('button')).toHaveLength(1) + }) + + it('prevents default on mousedown so xterm keeps focus', () => { + render( {}} />) + const button = screen.getByRole('button', { name: 'Submit' }) + // Dispatch a real cancelable mousedown rather than relying on fireEvent's + // return value: RTL's synthetic object does not reflect defaultPrevented + // after React processes the handler in this environment. + const mousedown = new MouseEvent('mousedown', { bubbles: true, cancelable: true }) + act(() => { button.dispatchEvent(mousedown) }) + expect(mousedown.defaultPrevented).toBe(true) + }) + + it('still lets mousedown bubble so the owning leaf engages the session', () => { + const onMouseDown = vi.fn() + render( +
+ {}} /> +
, + ) + fireEvent.mouseDown(screen.getByRole('button', { name: 'Submit' })) + expect(onMouseDown).toHaveBeenCalledTimes(1) + }) + + it('fires onSubmit once per click', () => { + const onSubmit = vi.fn() + render() + const button = screen.getByRole('button', { name: 'Submit' }) + fireEvent.mouseDown(button) + fireEvent.click(button) + fireEvent.click(button) + expect(onSubmit).toHaveBeenCalledTimes(2) + }) + + it('uses the composer control scaffold', () => { + const { container } = render( {}} />) + const button = screen.getByRole('button', { name: 'Submit' }) + expect(button.className).toContain('rounded-control') + expect(button.className).toContain('control-active-bg') + expect(container.firstElementChild!.className).toContain('border-t') + expect(container.firstElementChild!.className).toContain('bg-surface') + }) +}) \ No newline at end of file diff --git a/src/renderer/src/workspace/tile-tree/AgentTerminalActions.tsx b/src/renderer/src/workspace/tile-tree/AgentTerminalActions.tsx new file mode 100644 index 00000000..cb5e3674 --- /dev/null +++ b/src/renderer/src/workspace/tile-tree/AgentTerminalActions.tsx @@ -0,0 +1,50 @@ +import type { JSX } from 'react' + +// AgentTerminalActions — pointer-clickable Submit for raw agent terminals, +// shown only in Mouse Mode, the terminal-view sibling of ComposerActions. +// +// WHY it exists: AgentTerminalLeaf is a pure PTY view with no composer and no +// draft, so the one thing a mouse-only user is missing is the final Enter +// after dictating or pasting a command into the TUI (dictation intentionally +// never auto-submits — the user reviews, then presses Enter). This row is +// that Enter, nothing more. +// +// WHY it is behind a setting although the row is tiny: same logic as +// ComposerActions. It costs a row of pane height in EVERY agent pane, and a +// keyboard user submits with Enter and gets nothing from it. Mouse mode makes +// the trade worth taking. +// +// WHY Submit is never disabled: the raw PTY's current line lives inside the +// provider's TUI, so there is nothing to read back and nothing to gate on. +// The button must be exactly as conservative as a hardware Enter key — always +// available. This is ComposerActions' "must not be more conservative than +// Enter" rule applied to a surface without a draft. +// +// WHY the row lives in AgentTerminalLeaf and is NOT shared with TerminalLeaf: +// ordinary shells are explicitly out of scope for this feature (issue #819). +// A plain shell pane never had a Send affordance to lose; mounting controls +// there would only add chrome to panes that must stay untouched. + +export type AgentTerminalActionsProps = { + /** Sends the Enter byte to the agent PTY. */ + onSubmit: () => void +} + +export function AgentTerminalActions({ onSubmit }: AgentTerminalActionsProps): JSX.Element { + return ( +
+ +
+ ) +} \ No newline at end of file diff --git a/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.submit.renderer.test.tsx b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.submit.renderer.test.tsx new file mode 100644 index 00000000..51db5aa1 --- /dev/null +++ b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.submit.renderer.test.tsx @@ -0,0 +1,207 @@ +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { emptyRuntime } from '@renderer/session-runtime/state' +import { + AgentTerminalOwnershipProvider, + MountedAgentTerminalOwner, +} from '@renderer/workspace/terminal/AgentTerminalOwnership' +import type { Workspace } from '@renderer/workspace/workspaceStore' +import { AgentTerminalLeaf } from './AgentTerminalLeaf' + +type MockTerminal = Record & { + cols: number + rows: number + container: HTMLElement | null + onDataListener: ((data: string) => void) | null +} + +const xtermHarness = vi.hoisted(() => ({ + cols: 120, + rows: 40, + instances: [] as MockTerminal[], + attachWebgl: vi.fn(), + fit: vi.fn(), +})) + +const settings = vi.hoisted(() => ({ + dictationEnabled: false, + dictationProvider: 'local', + dictationShortcut: 'off', + mouseModeEnabled: false, +})) + +vi.mock('@renderer/workspace/terminal/xtermWebglRenderer', () => ({ + attachXtermWebglRenderer: xtermHarness.attachWebgl, +})) + +vi.mock('@xterm/xterm', () => ({ + Terminal: class { + cols = xtermHarness.cols + rows = xtermHarness.rows + options: Record = {} + container: HTMLElement | null = null + onDataListener: ((data: string) => void) | null = null + dispose = vi.fn() + inputDispose = vi.fn(() => { this.onDataListener = null }) + constructor() { xtermHarness.instances.push(this as unknown as MockTerminal) } + loadAddon() {} + open(container: HTMLElement) { this.container = container } + onData(listener: (data: string) => void) { + this.onDataListener = listener + return { dispose: this.inputDispose } + } + write(_data: string, callback?: () => void) { callback?.() } + focus() {} + }, +})) + +vi.mock('@xterm/addon-fit', () => ({ + FitAddon: class { fit() { xtermHarness.fit() } }, +})) + +vi.mock('@renderer/app-state/hooks', () => ({ + useAppStore: (selector: (state: Record) => unknown) => + selector({ settings }), +})) + +vi.mock('@renderer/app-state/settings/theme', async importOriginal => ({ + ...(await importOriginal>()), + THEME_CHANGED_EVENT: 'agent-code:test-theme-change', + getActiveAppFontFamily: () => 'monospace', +})) + +vi.mock('@renderer/workspace/tile-tree/xtermTheme', () => ({ + readXtermTheme: () => ({}), + syncXtermTheme: () => {}, +})) + +vi.mock('@renderer/workspace/tile-tree/TileLeaf/useComposerDictation', () => ({ + useComposerDictation: () => {}, +})) + +type Deferred = { promise: Promise; resolve: (value: T) => void } +function deferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise(done => { resolve = done }) + return { promise, resolve } +} + +describe('AgentTerminalLeaf Mouse Mode Submit', () => { + let attach: Deferred + let nextFrameId: number + let frames: Map + const resize = vi.fn().mockResolvedValue(undefined) + const sendInput = vi.fn().mockResolvedValue(undefined) + const api = { + attachAgentPty: vi.fn((_id: string) => attach.promise), + detachAgentPty: vi.fn().mockResolvedValue(undefined), + onSessionAgentPtyData: vi.fn(() => () => {}), + onSessionTerminalData: vi.fn(() => () => {}), + resize, + sendInput, + } + const workspace = { + acknowledgeSession: vi.fn(), + ensureSessionLive: vi.fn().mockResolvedValue(undefined), + showPaneToast: vi.fn(), + } as unknown as Workspace + + function flushAnimationFrames() { + const pending = [...frames.values()] + frames.clear() + for (const callback of pending) callback(performance.now()) + } + + function leaf() { + return ( + + + {}} + workspace={workspace} + runtime={{ ...emptyRuntime(), processStatus: 'started' }} + projectDir="/tmp/project" + provider="codex" + /> + + + ) + } + + beforeEach(() => { + settings.mouseModeEnabled = false + attach = deferred() + nextFrameId = 0 + frames = new Map() + xtermHarness.fit.mockClear() + xtermHarness.instances.length = 0 + xtermHarness.attachWebgl.mockReset() + xtermHarness.attachWebgl.mockImplementation(() => ({ + ready: Promise.resolve(true), + dispose: vi.fn(), + })) + api.attachAgentPty.mockReset().mockImplementation(() => attach.promise) + api.detachAgentPty.mockClear() + resize.mockClear() + sendInput.mockClear() + workspace.acknowledgeSession = vi.fn() + + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + const id = ++nextFrameId + frames.set(id, callback) + return id + }) + vi.stubGlobal('cancelAnimationFrame', (id: number) => { frames.delete(id) }) + vi.stubGlobal('ResizeObserver', class { + disconnect = vi.fn() + observe() {} + unobserve() {} + }) + Object.defineProperty(window, 'api', { configurable: true, value: api }) + }) + + afterEach(() => { + cleanup() + Reflect.deleteProperty(window, 'api') + vi.unstubAllGlobals() + }) + + it('hides Submit entirely when Mouse Mode is off', () => { + render(leaf()) + expect(screen.queryByRole('button', { name: 'Submit' })).toBeNull() + }) + + it('sends the Enter byte to the agent PTY after attach when Mouse Mode is on', async () => { + settings.mouseModeEnabled = true + render(leaf()) + act(() => flushAnimationFrames()) + await act(async () => { + attach.resolve('') + await attach.promise + }) + + const button = screen.getByRole('button', { name: 'Submit' }) + expect(button).not.toBeDisabled() + fireEvent.click(button) + await act(async () => { await Promise.resolve() }) + + expect(sendInput).toHaveBeenCalledWith('session-1', '\r') + }) + + it('queues a pre-attach Submit and delivers it once attach lands', async () => { + settings.mouseModeEnabled = true + render(leaf()) + act(() => flushAnimationFrames()) + fireEvent.click(screen.getByRole('button', { name: 'Submit' })) + expect(sendInput).not.toHaveBeenCalled() + + await act(async () => { + attach.resolve('') + await attach.promise + }) + expect(sendInput).toHaveBeenCalledWith('session-1', '\r') + }) +}) \ No newline at end of file diff --git a/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx index fbb99b29..862d0599 100644 --- a/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx +++ b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx @@ -21,6 +21,7 @@ import { subscribeToAgentPtyData } from '@renderer/workspace/terminal/sessionDat import { attachXtermWebglRenderer } from '@renderer/workspace/terminal/xtermWebglRenderer' import { AgentTitleHeader } from '@renderer/workspace/tile-tree/AgentTitleHeader' import { createTerminalInputForwarder } from '@renderer/workspace/tile-tree/terminalInputForwarder' +import { AgentTerminalActions } from '@renderer/workspace/tile-tree/AgentTerminalActions' type Props = { sessionId: SessionId @@ -61,6 +62,7 @@ export function AgentTerminalLeaf({ const dictationEnabled = useAppStore(state => state.settings.dictationEnabled) const dictationProvider = useAppStore(state => state.settings.dictationProvider) const dictationShortcut = useAppStore(state => state.settings.dictationShortcut) + const mouseModeEnabled = useAppStore(state => state.settings.mouseModeEnabled) const acknowledgeSession = workspace.acknowledgeSession const ensureSessionLiveRef = useRef(workspace.ensureSessionLive) ensureSessionLiveRef.current = workspace.ensureSessionLive @@ -70,6 +72,11 @@ export function AgentTerminalLeaf({ runtimeRef.current = runtime const showPaneToastRef = useRef(workspace.showPaneToast) showPaneToastRef.current = workspace.showPaneToast + // Published by the mount effect (which owns the forwarder and the pre-attach + // queue) so the Mouse Mode Submit button can inject Enter exactly as the + // Enter key would. A no-op until the effect has run; the effect always + // overwrites it on (re)mount, keyed as it is on sessionId alone. + const submitEnterRef = useRef<() => void>(() => {}) const containerRef = useRef(null) const termRef = useRef(null) @@ -244,6 +251,23 @@ export function AgentTerminalLeaf({ const forwarder = createTerminalInputForwarder(data => { void window.api.sendInput(sessionId, data) }) + // WHY the Submit button reuses the keypress pipeline instead of calling + // window.api.sendInput directly: the leaf only forwards keystrokes AFTER + // attach (pendingInput) and only outside the replay window (the + // forwarder latch). A direct call would skip both, so its Enter could + // hit the provider before the PTY exists or while xterm is still parsing + // the attach replay — more powerful than the Enter key it replaces. + // Pushing '\r' down the same path keeps a mouse click and a real keypress + // indistinguishable to the backend. '\r' is xterm's Enter byte here + // because this terminal is created with convertEol: false below. + submitEnterRef.current = () => { + if (forwarder.replaying) return + if (!attachedBackfillDone) { + pendingInput.push('\r') + return + } + forwarder.onData('\r') + } onDataDisposable = term.onData(data => { // Transport output also includes xterm-generated query responses. DOM // engagement below owns unread acknowledgement, never these bytes. @@ -490,6 +514,14 @@ export function AgentTerminalLeaf({ className="h-full min-h-0 min-w-0 overflow-hidden relative" /> + {/* Mouse Mode only, mirroring ComposerActions' gating in TileLeaf. A raw + terminal has no composer or draft, so Submit is this surface's only + action — the Enter byte a keyboard user presses after dictating or + pasting. Gated on the setting because the row costs pane height in + every agent pane and a keyboard user gets nothing from it. */} + {mouseModeEnabled ? ( + submitEnterRef.current()} /> + ) : null} {/* WHY terminal mode still renders PaneToast: Pane toasts are runtime feedback from commands/actions, not a feed-only visual. Hybrid can legitimately fall back to AgentTerminalLeaf right