plans/terminal-feature.md
Generated by swarm planning session on 2026-05-18 (PM, UX, Eng + Will)
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.
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.
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).getDyadAppPath(app.path) for the chat's bound app.npm run dev doesn't die).$SHELL (mac/Linux) and %COMSPEC%
(Windows). Platform fallbacks: /bin/zsh, /bin/bash, cmd.exe. No
PowerShell auto-detection in v1.terminalOpenByChatIdAtom stores the
drawer's open/closed state per chat for the current renderer session. PTY
lifetime is a separate concern.AnimatePresence. Spring-like tween, ~220ms. Reverse on exit.@xterm/addon-fit — auto-size to container@xterm/addon-search — Cmd/Ctrl+F opens floating search@xterm/addon-web-links — Cmd/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 reattachCmd/Ctrl + / - / 0 keyboard shortcut. Persists globally
(not per chat) via a jotai-atom-backed setting.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.$SHELL for v1; add setting
in v1.1).TerminalPanel API to accept size="full" | "split-bottom" so this
isn't blocked, but only "full" ships in v1.npm run dev doesn't die.%COMSPEC% says) so my PATH and aliases work.prefers-reduced-motion, so the feature doesn't make me sick.appPath resolves and exists.SquareTerminal button in the chat header.y: 100% → 0) with a
spring tween of ~220ms total.useScrambleText from
StreamingLoadingAnimation) shows during the brief PTY spawn (~50–
200ms).SquareTerminal.{path}" with "Pick a folder" and
"Back to chat" actions. Never silent fallback to ~.terminalOpenByChatIdAtom entry says.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.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.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.AnimatePresence. Two
absolutely-positioned children inside a relative overflow-hidden
container.
{ opacity: 0, y: 24 }, ~180ms ease-out
([0.22, 1, 0.36, 1]).initial={{ y: "100%" }},
animate={{ y: 0 }}, exit={{ y: "100%" }}, tween ~220ms.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.ResizeObserver on the terminal container → debounced
(50ms) fit() + ipc.terminal.resize.base-ui menu primitive used
elsewhere in the chat panel: Copy / Paste / Clear / Restart shell /
Exit terminal.<button>, aria-label, aria-pressed, visible
focus ring matching other header buttons.<button> with text + icon (never icon-only).role="application",
aria-label="Terminal for {appName}".aria-live="polite"):
"Terminal opened in {appName}, working directory {appPath}".screenReaderMode: true.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.chat.json).useScrambleText.showSuccess from @/lib/toast.src/ to respect
prefers-reduced-motion — introduce the pattern here and document it
in a comment so future animations adopt it.Three layers, summarized:
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.safeSend + ipcRenderer.on
for the high-throughput data plane.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.| 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). |
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.
Control plane (typed, zod-validated):
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.
State machine (per-app PTY session):
spawnPty from pty_command_runner.ts. CWD =
getDyadAppPath(app.path) — validated server-side as an absolute,
existing path before spawn.before-quit (via the
same platform-specific termination already in pty_command_runner.ts:
taskkill /F /T on Windows, kill() elsewhere).false. Does not kill the PTY (so your
dev server isn't accidentally killed).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.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".%COMSPEC%=pwsh.exe themselves; we add a user-configurable shell
setting in v1.1.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:
Cap in-memory at 10 000 lines per session (xterm default). Cap raw byte buffer at 2 MB per session to bound memory.
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:
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.pty.write() it. This forecloses a class of
prompt-injection RCEs cleanly.getDyadAppPath(app.path), validated absolute + exists.open response returns { shell, cwd }
only — never echo process.env.| Risk | Sev | Mitigation |
|---|---|---|
| ConPTY quirks on Windows (resize races, ANSI edge cases) | H | Dogfood 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) | H | onAnimationComplete → fit() |
Memory leak from un-cleaned ipcRenderer.on('terminal:data:<id>') | M | Strict cleanup in useTerminalSession; dev-mode listener-count assertion |
| Dense output overwhelms IPC | M | 8ms coalescing buffer in main |
| Per-app PTY UX confusion (toggle is per-chat) | M | Banner shows app name + path; PM-flagged disambiguation |
| Runaway resource use (many apps with live PTYs) | M | Hard cap = 5; LRU eviction with toast |
| User loses long-running shell on Dyad quit | M | Document; toast on relaunch if a session was killed |
| Drizzle migration on existing user DBs | L | Standard ALTER ADD COLUMN with DEFAULT |
| Bundle size cost of xterm + addons (~600KB gzip) | L | React.lazy TerminalPanel |
| macOS GUI-launch PATH | L | Already solved via shellEnvSync() |
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).useTerminalSession — mount/unmount cleanly,
resize debounce, replay-on-mount, strict listener cleanup
(ipcRenderer.eventNames().length bound in dev).TerminalPanel with a fake IPC
bridge; escape banner renders; toggle updates the atom and DB.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).export FOO=bar, switch
to chat B (same app), reopen terminal — echo $FOO outputs bar.pwd).tput cols matches.prefers-reduced-motion: reduce and assert
animation duration is near-zero.vim /tmp/x, press Esc, assert terminal mode is still
on. Press chord shortcut, assert terminal mode is off.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.@xterm/xterm, @xterm/addon-fit, @xterm/addon-search,
@xterm/addon-web-links, @xterm/addon-unicode11,
@xterm/addon-clipboard, @xterm/addon-serialize.PtySessionManager in
src/ipc/utils/pty_session_manager.ts. Reuse spawnPty from
pty_command_runner.ts. Unit tests with injectable spawner.terminal_handlers.ts: open, close, kill, write, resize,
serialize. Streaming channels via safeSend. 8ms coalescing
buffer.app.on("before-quit") cleanup of all sessions.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. onAnimationComplete → fit(). Respect
prefers-reduced-motion.terminalOpenByChatIdAtom.<kbd> styling for the chord.PtySessionManager (mocked spawner).find /, cat largefile).Cmd/Ctrl+K / toggle button.%COMSPEC% is safe; user-settable shell follows in v1.1.TerminalPanel — most users won't use it every session;
don't pay the bundle cost upfront.src/ to respect prefers-reduced-motion — adopt
the pattern here and document it for future animations.Resolved during planning:
Cmd/Ctrl+K chord (NOT
bare Esc); toggle button stays as always-present header escape.$SHELL / %COMSPEC% with platform
fallbacks; no auto-detect of pwsh in v1.Deferred / to-tune during implementation:
Cmd/Ctrl+K behind a Settings preference (probably
not — chord shortcuts are safe).Still genuinely open (worth a quick check during implementation):
React.lazy keeps the cold path slim.Generated by dyad:swarm-to-plan (PM, UX, Eng + Will)