Back to Dyad

In-Chat Terminal Drawer

plans/terminal-feature.md

1.3.033.9 KB
Original Source

In-Chat Terminal Drawer

Generated by swarm planning session on 2026-05-18 (PM, UX, Eng + Will)

Summary

Add a terminal drawer to the chat panel, toggled by a new icon button next to the existing preview-toggle in ChatHeader.tsx. The terminal opens at the chat's app appPath, runs in xterm.js against a node-pty shell, persists its per-chat visibility in the DB, and animates in as a drawer rising from the bottom. The PTY process itself is keyed per app, not per chat: switching chats within the same app reuses the same live shell, so npm run dev survives chat switches. PTYs are killed when Dyad quits.

Problem Statement

Dyad's target user — the prosumer/developer building real apps inside Dyad — constantly needs a shell at the app's directory to: run ad-hoc CLIs (supabase, drizzle-kit, prisma, vercel, neon), inspect git, debug install/build failures by re-running commands manually, run tools the AI didn't (or shouldn't) auto-run. Today they alt-tab to iTerm/Windows Terminal/VS Code's terminal, cd into a path they may not even know (Dyad resolves it internally from app.path), and lose the in-Dyad flow.

Strategically, this is also Dyad's biggest credibility gap with VS Code / Cursor for developer users. And a terminal is a platform primitive: once it exists, future features (AI suggests a command → run here, stream test output into chat) become cheap.

Scope

In Scope (MVP+)

  • Terminal toggle button in ChatHeader.tsx, immediately to the left of the existing preview-toggle button (around line 217). Icon: SquareTerminal from lucide-react (distinct from the Terminal icon already used elsewhere in ConfigurePanel.tsx). Active state matches the existing "selected tab" pattern (bg-primary/10 text-primary).
  • xterm.js terminal replaces the chat panel content (MessagesList + ChatInput) when toggled on. The chat header stays mounted; only the message/input region swaps.
  • CWD = getDyadAppPath(app.path) for the chat's bound app.
  • PTY is per-app, not per-chat. Switching chats within the same app reuses the same live PTY (your npm run dev doesn't die).
  • PTY lifecycle: alive until Dyad quits or user explicitly kills it. Survives chat switches across apps too — multiple apps can each have a live shell. A hard cap (max 5 concurrent PTYs) with LRU eviction + toast prevents runaway resource use.
  • Cross-platform shell: respect $SHELL (mac/Linux) and %COMSPEC% (Windows). Platform fallbacks: /bin/zsh, /bin/bash, cmd.exe. No PowerShell auto-detection in v1.
  • Per-chat visibility state: terminalOpenByChatIdAtom stores the drawer's open/closed state per chat for the current renderer session. PTY lifetime is a separate concern.
  • Drawer animation: vertical slide-up from the bottom via framer-motion AnimatePresence. Spring-like tween, ~220ms. Reverse on exit.
  • Escape banner (always visible at top of terminal): "Terminal mode — Click here or press ⌘K to exit" (Mac) / "press Ctrl+K to exit" (Win/Linux). Clickable banner + chord shortcut (NOT bare Esc, since vim / less / fzf / htop all bind Esc — chord avoids the conflict).
  • The terminal toggle button itself toggles state both ways (entering and exiting), so the user always has a header-level escape.
  • xterm addons:
    • @xterm/addon-fit — auto-size to container
    • @xterm/addon-searchCmd/Ctrl+F opens floating search
    • @xterm/addon-web-linksCmd/Ctrl+click URLs to open in system browser
    • @xterm/addon-unicode11 — emoji/CJK width handling
    • @xterm/addon-clipboard — cross-platform copy/paste
    • @xterm/addon-serialize — scrollback replay on reattach
  • Copy on select, right-click context menu (Copy / Paste / Clear / Restart shell / Exit terminal).
  • Font size: Cmd/Ctrl + / - / 0 keyboard shortcut. Persists globally (not per chat) via a jotai-atom-backed setting.
  • Theme reactivity: xterm palette derived from Dyad's current light/dark theme; updates live on theme change.
  • prefers-reduced-motion: replaces slide-up with a 120ms crossfade. This is also the first place in src/ to use this preference — introduce a small shared useReducedMotionPref() hook (or use useReducedMotion from framer-motion directly) that other Dyad animations can adopt later.

Out of Scope (Follow-up)

  • Multiple terminal tabs per chat or per app.
  • Per-app user-configurable shell setting (use $SHELL for v1; add setting in v1.1).
  • Scrollback persistence to disk across Dyad restarts.
  • AI writing to / reading from the terminal (separate, larger feature with its own security review).
  • Split view: chat + terminal visible simultaneously. We pre-design the TerminalPanel API to accept size="full" | "split-bottom" so this isn't blocked, but only "full" ships in v1.
  • "Running command" dot on the terminal toggle button when the PTY is active but collapsed. (Nice-to-have v1; must-have v1.1.)
  • Sidebar live-dot on chats that have a running PTY.
  • Detached / tmux-style session that survives Dyad quit.

User Stories

  • As a Dyad developer, I want to open a terminal at my app's path with one click so I can run git/npm/CLIs without leaving Dyad or remembering the path.
  • As a power user, I want my shell session to survive when I switch chats within the same app, so my running npm run dev doesn't die.
  • As any user, I want an obvious, always-visible way to exit terminal mode, so I never feel trapped.
  • As a Windows user, I want the terminal to use my normal shell (cmd or whatever %COMSPEC% says) so my PATH and aliases work.
  • As a vim/htop user, I want pressing Esc inside the terminal to behave normally (escape insert mode, etc.) and NOT exit Dyad's terminal mode.
  • As a user with motion sensitivity, I want the drawer animation to respect prefers-reduced-motion, so the feature doesn't make me sick.

Success Metrics

  • Adoption: % of weekly-active app builders who open the terminal ≥1× per week. Target: 25% within 30 days.
  • Retention: of users who used it once, % who use it again within 7 days. Target: >50%.
  • Stickiness: median terminal sessions/week among adopters. Target: ≥3.
  • Quality: terminal-related P0/P1 issues in first 60 days. Target: <5 (Windows is the canary).
  • Reverse-bounce: % of users who toggle terminal on then off within 5s without typing. Target: <10% (high values signal discoverability problems).
  • Negative signal: drop in usage of curated "run install"/"run build" buttons — if power users abandon them entirely, we should surface them inside the terminal.

UX Design

Primary user flow

  1. User is in a chat bound to an app whose appPath resolves and exists.
  2. They click the new SquareTerminal button in the chat header.
  3. The button's active state lights up. The chat content (MessagesList + ChatInput) slides down 24px and fades to 0 over ~180ms; in parallel the terminal layer slides up from the bottom (y: 100% → 0) with a spring tween of ~220ms total.
  4. The escape banner fades in at the top of the terminal region: "Terminal mode — Click here or press ⌘K to exit" (chord shortcut adapted to OS).
  5. A subtle initializing placeholder (Dyad's existing loading orb + scrambled verb "summoning shell…", reusing useScrambleText from StreamingLoadingAnimation) shows during the brief PTY spawn (~50– 200ms).
  6. The xterm widget auto-focuses; the user sees their shell prompt at the app's path.
  7. To exit: click the banner, click the terminal toggle button (now "active"), or press the chord shortcut. The drawer slides back down, chat fades up. Focus returns to the ChatInput.

Key states

  • Default (terminal off): standard chat panel. Toggle button shows outline SquareTerminal.
  • Initializing: 50–200ms window between toggle-on and PTY-ready. Placeholder + Dyad orb + scramble verb. Banner already visible.
  • Active: shell prompt, banner pinned at top.
  • Empty / no app bound: the toggle button is disabled with a tooltip ("This chat isn't bound to an app yet"). If somehow forced on, show a centered empty-state card: "This chat isn't linked to an app, or its folder no longer exists at {path}" with "Pick a folder" and "Back to chat" actions. Never silent fallback to ~.
  • Path missing: same empty state as above, with a clear path display and (on macOS/Win) a "Reveal in Finder/Explorer" button.
  • Shell exited / crashed: show exit code in muted text + "Restart shell" (primary) and "Back to chat" (secondary). Don't auto-exit terminal mode — the user may want to copy output. Don't auto-restart — transparent over magical.
  • Switching chats within same app, both terminal-on: the terminal surface stays mounted, xterm crossfades content (100ms). The path label in the banner does a scramble-text reveal (Dyad signature). Same PTY session — so the user sees the same scrollback (this is the point of per-app PTY).
  • Switching chats across different apps: the previous chat's PTY keeps running in the background. The new chat shows whatever its own terminalOpenByChatIdAtom entry says.
  • Narrow width (<480px): hide the path string in the banner, keep the exit affordance always visible.

Interaction details

  • Toggle button: same physical treatment as the existing preview-toggle (p-2 hover:bg-(--background-lightest) rounded-md, size={20} icon). aria-label, aria-pressed. data-testid="toggle-terminal-button". Tooltip via i18n with chord shortcut hint.
  • Escape banner: a slim (~32px) full-width bar at the top of the terminal region, bg-accent/10, full-width inside the terminal. The entire bar is a real <button> (keyboard- and screen-reader-friendly). Hover state highlights as clickable. Keyboard hint uses <kbd> styling consistent with the rest of Dyad.
  • Chord shortcut: Cmd+K on macOS, Ctrl+K on Windows/Linux. Captured by React in the capture phase so it fires even when xterm has focus. Esc is NOT bound (so vim/less/fzf/htop work normally inside the terminal). The chosen banner copy makes this explicit.
  • Drawer animation: framer-motion AnimatePresence. Two absolutely-positioned children inside a relative overflow-hidden container.
    • Chat layer exit: { opacity: 0, y: 24 }, ~180ms ease-out ([0.22, 1, 0.36, 1]).
    • Terminal layer enter: initial={{ y: "100%" }}, animate={{ y: 0 }}, exit={{ y: "100%" }}, tween ~220ms.
    • Reverse uses the same timings.
  • Critical sequencing: do NOT call FitAddon.fit() before the drawer animation completes. The container would still report 0,0 and xterm would render broken. Use onAnimationComplete on the entering motion.div to trigger fit() once.
  • Resize: ResizeObserver on the terminal container → debounced (50ms) fit() + ipc.terminal.resize.
  • Tab key is captured by xterm (essential for shell completion). Document via tooltip: "Tab is captured by the terminal."
  • Right-click context menu uses the same base-ui menu primitive used elsewhere in the chat panel: Copy / Paste / Clear / Restart shell / Exit terminal.

Accessibility

  • Toggle button: real <button>, aria-label, aria-pressed, visible focus ring matching other header buttons.
  • Escape banner: real <button> with text + icon (never icon-only).
  • xterm container: role="application", aria-label="Terminal for {appName}".
  • Screen-reader announcement on entry (aria-live="polite"): "Terminal opened in {appName}, working directory {appPath}".
  • Enable xterm's screenReaderMode: true.
  • Focus management: on open, focus moves to xterm; on close, focus returns to the terminal toggle button (not the document body).
  • prefers-reduced-motion: 120ms crossfade replaces the slide-up. Drop the scramble-text reveal in favor of plain text swap. Drop the loading orb in favor of a static "Loading…" label.
  • Contrast: banner text meets WCAG AA against both Dyad themes.
  • All user-facing strings go through i18next (chat.json).

Consistency notes

  • Icon button placement and styling exactly mirror the existing preview-toggle for muscle-memory consistency.
  • Spring/ease values reuse the same easing tokens as the existing accordion / loading-orb animations.
  • Scramble-text reveal on chat-to-chat path change reuses useScrambleText.
  • Toast on first terminal-ready uses showSuccess from @/lib/toast.
  • Empty-state cards match Dyad's existing rounded-2xl bordered card + muted-foreground + primary/secondary button pattern.
  • This is the first feature in src/ to respect prefers-reduced-motion — introduce the pattern here and document it in a comment so future animations adopt it.

Technical Design

Architecture

Three layers, summarized:

  1. Renderer (React): TerminalPanel component, lazy-loaded (React.lazy) so the xterm bundle cost isn't paid by users who never open it. Owns the xterm.js instance + addons. Subscribes to a per-session IPC channel for output. Reads selectedChatIdAtom and resolves the bound app to know which session to attach to.
  2. IPC bridge: typed zod contracts for control-plane RPCs (open, close, kill, resize, write, serialize). Raw safeSend + ipcRenderer.on for the high-throughput data plane.
  3. Main: PtySessionManager — a singleton owning a Map<appId, PtySession>. Each session wraps a node-pty process, an in-memory scrollback buffer (via SerializeAddon-equivalent or a ring buffer), the subscribed WebContents, and dispose logic.

Components affected

| File | Change | | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | | src/components/chat/ChatHeader.tsx | Add SquareTerminal toggle button at line ~217, left of preview-toggle. Wire to terminalOpenByChatIdAtom. | | src/components/ChatPanel.tsx | Conditionally render lazy TerminalPanel instead of MessagesList+ChatInput. Wrap both branches in AnimatePresence for the drawer. | | src/components/chat/TerminalPanel.tsx | New. xterm.js + addons; subscribes to PTY stream; sends user input back; handles resize, theme, focus. Props: appId, chatId, onExit, size: "full" | "split-bottom"(only"full" used in v1). | | src/components/chat/TerminalEscapeBanner.tsx | New. Slim banner with click-to-exit + chord shortcut display. | | src/atoms/terminalAtoms.ts | New. terminalOpenByChatIdAtom: PrimitiveAtom<Map<number, boolean>>, terminalFontSizeAtom: PrimitiveAtom<number>. | | src/hooks/useTerminalSession.ts | New. Renderer hook to open/attach/detach a session for an appId; manages stream subscription with strict cleanup. | | src/hooks/useReducedMotion.ts | New (or use framer-motion's). Shared hook; document with a comment that future animations should use it. | | src/ipc/utils/pty_session_manager.ts | New. Singleton, owns Map<appId, PtySession>. Reuses spawnPty from pty_command_runner.ts. | | src/ipc/handlers/terminal_handlers.ts | New. Typed handlers: terminal:open, terminal:close, terminal:kill, terminal:write, terminal:resize, terminal:serialize. Registers streaming channels per session. | | src/ipc/contracts/terminal_contracts.ts | New. Zod schemas for the control-plane RPCs above. | | src/ipc/ipc_host.ts | Register registerTerminalHandlers(). | | src/ipc/handlers/app_handlers.ts | On app delete, call PtySessionManager.killForApp(appId). | | src/preload.ts | Expose terminal namespace + stream channel subscription helpers. | | src/i18n/locales/*.json | New strings: toggle button, banner, empty states, errors. | | package.json | Add @xterm/xterm, @xterm/addon-fit, @xterm/addon-search, @xterm/addon-web-links, @xterm/addon-unicode11, @xterm/addon-clipboard, @xterm/addon-serialize. | | forge.config.ts | Verify no extra native build steps (xterm is renderer-only; node-pty already builds). |

Data model changes

None for drawer visibility. It is renderer UI state tracked by terminalOpenByChatIdAtom, not persisted chat data. Push back on richer state for v1 (cwd override, shell preference, scrollback dump): YAGNI; we don't yet know what users will want.

Per-chat persistence + per-app PTY is consistent: this column records "should the drawer be visible when this chat is selected." The PTY itself is keyed by app and lives until Dyad quits or the user explicitly kills it. If chat A and chat B both belong to app 1, opening the terminal in chat A and then opening it in chat B (after switching) attaches to the same PTY session and shows the same scrollback. Matches how VS Code / JetBrains terminals work.

IPC contracts

Control plane (typed, zod-validated):

ts
ipc.terminal.open({ appId, cols, rows })
  -> { sessionId: string, shell: string, cwd: string }
ipc.terminal.close({ sessionId })
  -> { ok: true }                              // hides drawer; does NOT kill
ipc.terminal.kill({ sessionId })
  -> { ok: true }                              // explicit kill from overflow menu
ipc.terminal.resize({ sessionId, cols, rows })
  -> { ok: true }
ipc.terminal.write({ sessionId, data: string })
  -> { ok: true }                              // user keystrokes only
ipc.terminal.serialize({ sessionId })
  -> { scrollback: string }                    // for replay on reattach

Data plane (untyped fire-and-forget, high-throughput):

main → renderer:  "terminal:data:<sessionId>"  payload: { chunk: string }
main → renderer:  "terminal:exit:<sessionId>"  payload: { exitCode, signal }

The per-session channel suffix prevents head-of-line blocking when multiple sessions exist concurrently. The renderer uses the channel name as an identity check before writing to its xterm.

Throughput / backpressure: node-pty onData chunks are 4–16 KB, typically <1 MB/s. Implement an 8ms coalescing buffer in main that batches chunks before safeSend — this cuts IPC overhead ~10× on dense output (npm install, find /, cat largefile). xterm.js has its own write queue so no drop path is needed. If future profiling shows real backpressure, graduate to MessageChannelMain for the data channel only.

Writes (keystrokes) are low-volume — plain ipc.invoke is fine.

PTY lifecycle

State machine (per-app PTY session):

  • Spawn: lazy, on first terminal-open for an app. Uses the existing spawnPty from pty_command_runner.ts. CWD = getDyadAppPath(app.path) — validated server-side as an absolute, existing path before spawn.
  • Survives chat switch within the same app: yes — same PTY, same scrollback.
  • Survives chat switch across apps: yes — the previous app's PTY keeps running in the background. The new chat's Jotai visibility state determines whether its own app's terminal is shown.
  • Survives Dyad quit: NO. All PTYs killed on before-quit (via the same platform-specific termination already in pty_command_runner.ts: taskkill /F /T on Windows, kill() elsewhere).
  • Explicit "Exit terminal" (banner / shortcut / toggle button): sets the chat's atom entry to false. Does not kill the PTY (so your dev server isn't accidentally killed).
  • Explicit "Kill terminal" (right-click menu / overflow): kills the PTY for that app.
  • App deleted: cascade-kills any PTY for that app.
  • Concurrency cap: hard cap of 5 concurrent PTYs. On open of a 6th, LRU-evict the oldest with a toast: "Background shell for {oldestApp} stopped to free resources." Prevents runaway FD/memory usage if the user has many apps.

Cross-platform shell selection

ts
function getDefaultShell(): { shell: string; args: string[] } {
  if (process.platform === "win32") {
    return { shell: process.env.COMSPEC || "cmd.exe", args: [] };
  }
  return { shell: process.env.SHELL || "/bin/bash", args: ["-l"] };
}
  • -l on Unix loads .zshrc/.bashrc and PATH from the user's profile, critical for nvm/pyenv/asdf/Homebrew on macOS.
  • Environment is computed via the existing shellEnvSync() from shell-env (already used in read_env.ts — the canonical solution to macOS GUI-launch PATH issues), merged with process.env. Force TERM: "xterm-256color", COLORTERM: "truecolor".
  • No PowerShell auto-detection in v1. Users can already set %COMSPEC%=pwsh.exe themselves; we add a user-configurable shell setting in v1.1.

Scrollback

In-memory only for v1. Each PtySession keeps a mirror buffer (or SerializeAddon-equivalent serialization) in main. When the renderer reattaches (after the drawer was collapsed or chat was switched), main sends a terminal:replay event with the serialized buffer; xterm write() handles it natively.

Not persisted to DB:

  1. Serialized scrollback can be MB-scale — bloats SQLite quickly.
  2. Anything interactive in the prior session is gone after restart, so the "replay" value is limited.
  3. PII/secret exposure (echoed tokens, env vars) is a real concern with on-disk scrollback.

Cap in-memory at 10 000 lines per session (xterm default). Cap raw byte buffer at 2 MB per session to bound memory.

Security model

Same trust as any IDE terminal — the user already has shell access to their own machine. But two non-obvious invariants must hold from day 1:

  1. AI cannot write to the PTY in v1. There is no IPC contract that pipes AI-generated text into pty.write. The only renderer→PTY data path is real keyboard events captured by xterm's input handler. This is a hard architectural rule, not just "out of scope" — closing the door retroactively is much harder than not opening it.
  2. Chat content cannot reach the PTY. We do NOT take any string from chat state and pty.write() it. This forecloses a class of prompt-injection RCEs cleanly.
  3. CWD is server-side resolved. Renderer cannot pass an arbitrary cwd. Always getDyadAppPath(app.path), validated absolute + exists.
  4. No env leakage: the open response returns { shell, cwd } only — never echo process.env.

Risks & complexity (ranked)

RiskSevMitigation
ConPTY quirks on Windows (resize races, ANSI edge cases)HDogfood on Windows before merge; pin node-pty version; manual smoke for nvim/git log/npm install
Animation+xterm size race (fit() before container is sized → 0,0)HonAnimationCompletefit()
Memory leak from un-cleaned ipcRenderer.on('terminal:data:<id>')MStrict cleanup in useTerminalSession; dev-mode listener-count assertion
Dense output overwhelms IPCM8ms coalescing buffer in main
Per-app PTY UX confusion (toggle is per-chat)MBanner shows app name + path; PM-flagged disambiguation
Runaway resource use (many apps with live PTYs)MHard cap = 5; LRU eviction with toast
User loses long-running shell on Dyad quitMDocument; toast on relaunch if a session was killed
Drizzle migration on existing user DBsLStandard ALTER ADD COLUMN with DEFAULT
Bundle size cost of xterm + addons (~600KB gzip)LReact.lazy TerminalPanel
macOS GUI-launch PATHLAlready solved via shellEnvSync()

Testing plan

  • Unit (vitest): PtySessionManager lifecycle — spawn, write, resize, kill, multi-session, app-deleted cascade, LRU eviction. Mock spawnPty exactly like pty_command_runner.test.ts already does (injectable PtySpawner).
  • Unit (renderer): useTerminalSession — mount/unmount cleanly, resize debounce, replay-on-mount, strict listener cleanup (ipcRenderer.eventNames().length bound in dev).
  • Component (vitest + happy-dom): TerminalPanel with a fake IPC bridge; escape banner renders; toggle updates the atom and DB.
  • E2E (Playwright) — the one that proves it actually works:
    • Smoke: open chat with app, click terminal toggle, type echo hello dyad\n, assert "hello dyad" appears via term.buffer.active.getLine(...) exposed on window in test mode (xterm renders to canvas in prod).
    • Persistence: enable terminal, restart app, chat reopens in terminal mode.
    • Per-app reuse: open terminal in chat A, run export FOO=bar, switch to chat B (same app), reopen terminal — echo $FOO outputs bar.
    • Cross-app isolation: chat in app A vs chat in app B → distinct PTYs, distinct CWDs (assert via pwd).
    • Resize: drag panel, assert tput cols matches.
    • Reduced motion: set prefers-reduced-motion: reduce and assert animation duration is near-zero.
    • Escape: click banner, drawer closes, focus returns to toggle.
    • Vim conflict: vim /tmp/x, press Esc, assert terminal mode is still on. Press chord shortcut, assert terminal mode is off.
    • Windows + macOS + Linux in CI matrix (already configured).
  • Manual smoke (one pass per platform pre-release):
    • npm run dev keeps running across a chat switch and back.
    • nvim file.txt works (interactive TUI).
    • git log --oneline paginates with less.
    • Ctrl+C kills foreground process without killing the shell.

Implementation Plan

Phase 1: Foundation (1–2 days)

  • Add deps: @xterm/xterm, @xterm/addon-fit, @xterm/addon-search, @xterm/addon-web-links, @xterm/addon-unicode11, @xterm/addon-clipboard, @xterm/addon-serialize.
  • Build PtySessionManager in src/ipc/utils/pty_session_manager.ts. Reuse spawnPty from pty_command_runner.ts. Unit tests with injectable spawner.
  • Register terminal_handlers.ts: open, close, kill, write, resize, serialize. Streaming channels via safeSend. 8ms coalescing buffer.
  • app.on("before-quit") cleanup of all sessions.
  • App-delete cascade kills PTY for that app.
  • LRU eviction at 5 concurrent sessions.

Phase 2: Renderer + UI (1–2 days)

  • terminalAtoms.ts: terminalOpenByChatIdAtom, terminalFontSizeAtom.
  • useTerminalSession hook (open/attach/detach/write/resize) with strict listener cleanup.
  • useReducedMotion shared hook (or use framer-motion's directly).
  • TerminalPanel.tsx (lazy-loaded). xterm + all addons. Theme from Dyad theme atom. screenReaderMode: true. Resize via ResizeObserver (debounced 50ms).
  • TerminalEscapeBanner.tsx. Real <button>. Click handler + Cmd/Ctrl+K chord shortcut (capture-phase). Do not bind bare Esc.
  • ChatHeader.tsx: add toggle button at line ~217 with aria-pressed state.
  • ChatPanel.tsx: conditional render with AnimatePresence. Drawer animation. onAnimationCompletefit(). Respect prefers-reduced-motion.
  • Store toggle state in terminalOpenByChatIdAtom.
  • Right-click context menu (Copy / Paste / Clear / Restart / Exit / Kill).

Phase 3: States + Polish (1 day)

  • Empty / no-app state with "Pick a folder" + "Back to chat" actions.
  • Path-missing state with reveal-in-file-manager action.
  • Shell-exited state with "Restart shell".
  • Loading placeholder (orb + scramble verb) during PTY spawn.
  • Theme reactivity (palette updates on theme change).
  • Font-size keyboard shortcuts + persistence.
  • Copy-on-select via clipboard addon.
  • i18n strings for every user-facing string.
  • Banner copy: "Terminal mode — Click here or press ⌘/Ctrl+K to exit" with <kbd> styling for the chord.

Phase 4: Testing + Cross-Platform (1–2 days)

  • Unit tests for PtySessionManager (mocked spawner).
  • Unit + component tests for hooks and panel.
  • Playwright tests: smoke + persistence + per-app reuse + cross-app isolation + resize + reduced-motion + escape + vim-Esc-no-exit.
  • Manual smoke on macOS, Windows, Linux.
  • Profile dense-output scenario (find /, cat largefile).
  • Release notes call out: Esc inside terminal does NOT exit; use the banner / Cmd/Ctrl+K / toggle button.

Decision Log

  • Per-chat persistence + per-app PTY (PM, Eng both pushed hard) — the DB column controls drawer visibility per chat (this is what the user asked for); the PTY itself is keyed by app and survives chat switches. Closes the "switching chats kills my dev server" footgun without changing the user-visible toggle model.
  • PTY survives chat switch across apps; killed only on Dyad quit or explicit "Kill terminal" — chosen by user. Add a hard cap (5 concurrent + LRU eviction with toast) as a safety net so this doesn't silently consume FDs.
  • Banner + chord shortcut, NOT bare Esc — chosen by user, validated by UX/Eng. Vim, less, fzf, htop all bind Esc; hijacking it would break the exact users we're courting.
  • MVP+ scope with search, font size, copy-on-select, web-links, unicode11, clipboard addons — chosen by user; lifts the experience out of "barely usable" without scope creep.
  • No PowerShell auto-detection on Windows in v1 — chosen by user; %COMSPEC% is safe; user-settable shell follows in v1.1.
  • AI cannot write to PTY in v1 — locked in as an architectural invariant from day 1 to prevent prompt-injection RCE. Easier to keep closed than to re-close.
  • In-memory scrollback only — DB persistence is too expensive and too risky (PII/secrets in shell output).
  • Lazy-load TerminalPanel — most users won't use it every session; don't pay the bundle cost upfront.
  • DOM renderer for v1 — Canvas / WebGL addon adds complexity and context-loss handling; profile first, upgrade later.
  • Drawer animation is purposeful novelty — "terminal-as-drawer" is familiar from VS Code/IntelliJ. We do not generalize the slide-up pattern to other features without a separate design pass.
  • First place in src/ to respect prefers-reduced-motion — adopt the pattern here and document it for future animations.

Open Questions

Resolved during planning:

  • PTY lifecycle → keep alive across chat switches, kill on Dyad quit. Plus an explicit "Kill terminal" action and a 5-session LRU cap.
  • MVP scope → "MVP+" with search, font size, copy-on-select, web-links.
  • Escape affordance → persistent banner with Cmd/Ctrl+K chord (NOT bare Esc); toggle button stays as always-present header escape.
  • Default shell → respect $SHELL / %COMSPEC% with platform fallbacks; no auto-detect of pwsh in v1.
  • PTY scope → per-app process, per-chat visibility flag.

Deferred / to-tune during implementation:

  • Exact LRU cap (start at 5; tune if memory tells us otherwise).
  • Exact spring stiffness/duration (start at 220ms tween; tune in PR review).
  • Exact scrollback line cap (start at xterm default 10 000).
  • Whether to gate Cmd/Ctrl+K behind a Settings preference (probably not — chord shortcuts are safe).

Still genuinely open (worth a quick check during implementation):

  • Bundle size delta from xterm + addons — measure during PR. Confirm React.lazy keeps the cold path slim.
  • Should "Shell exited" show a 1s auto-restart on first exit — probably no; explicit restart is more transparent. Verify during manual testing.
  • Should the panel-collapse hide the terminal but keep the PTY signaling "active" via a tiny dot on the toggle button? Cheap to add; flagged for v1.1 per UX.

Generated by dyad:swarm-to-plan (PM, UX, Eng + Will)