Skip to content

feat: replace terminal launchers with embedded ttyd terminals#156

Merged
neonwatty merged 26 commits intomainfrom
feat/ttyd-terminal
Apr 20, 2026
Merged

feat: replace terminal launchers with embedded ttyd terminals#156
neonwatty merged 26 commits intomainfrom
feat/ttyd-terminal

Conversation

@neonwatty
Copy link
Copy Markdown
Collaborator

Summary

  • Replace Ghostty/iTerm2/Terminal.app launcher system with ttyd-based web terminals embedded directly in the issuectl dashboard
  • Each launched issue spawns a ttyd child process serving Claude Code over HTTP+WebSocket on a dynamic port (7700-7799)
  • Full-viewport slide-out terminal panel with desktop close button (X) + backdrop, mobile swipe handle
  • Desktop inline action bar (Launch with Claude, Re-assign, Close issue) centered at top of issue page
  • Auto-redirect from launch progress page once ttyd is ready
  • Periodic health check polling (10s) detects ttyd crash or natural Claude exit, auto-updates the UI
  • Orphan reconciliation on server startup cleans up stale deployment records

What changed

Removed: TerminalLauncher interface, Ghostty/iTerm2/Terminal.app implementations, terminal_app/terminal_window_title/terminal_tab_title_pattern settings

Added:

  • packages/core/src/launch/ttyd.ts — ttyd process manager (verify, spawn, kill, health check, port allocation, orphan reconciliation)
  • packages/web/components/terminal/ — TerminalPanel slide-out + OpenTerminalButton with health polling
  • DB migration v11 — ttyd_port and ttyd_pid columns on deployments
  • CSP frame-src for ttyd iframe origins
  • checkTtydAlive server action for frontend liveness polling

Security: Shell command escaping, ; exit prevents raw shell exposure after Claude exits, -q flag exits ttyd on disconnect, buildClaudeCommand metachar validation preserved

Test plan

  • pnpm turbo typecheck — 0 errors across all packages
  • pnpm --filter @issuectl/core test — 354 tests passing
  • Playwright E2E: desktop full flow (launch → auto-redirect → open terminal → close → reopen → end session)
  • Playwright E2E: mobile full flow (action sheet → launch → terminal → end session)
  • PR review toolkit: code-reviewer, silent-failure-hunter, pr-test-analyzer, type-design-analyzer, comment-analyzer, code-simplifier — all findings addressed
  • Manual: verify ttyd renders Claude Code TUI correctly in the embedded iframe
  • Manual: verify End Session kills the ttyd process
  • Manual: verify health check detects Claude exit (banner updates within 10s)

Adds two new nullable columns to store the port and PID of the ttyd child
process that serves each Claude Code terminal session. Bumps SCHEMA_VERSION
to 11 and updates the fresh-install schema, DeploymentRow type, rowToDeployment
mapper, Deployment type, and adds updateTtydInfo() to deployments.ts.
Implements the full ttyd lifecycle: verify installation, spawn/kill
processes, allocate ports from 7700-7799 range, and reconcile orphaned
deployments on startup. Includes comprehensive unit tests (16 passing).
Delete the terminal.ts abstraction and all adapters (Ghostty, iTerm2,
macOS Terminal) along with their tests. Remove the three terminal_*
SettingKey entries from types and defaults. Export the new ttyd process
manager from index.ts in place of the old terminal exports.

Remaining errors in launch.ts are expected — will be rewired in Task 4.
Full-viewport panel that slides in from the right, renders the ttyd
process in an iframe, and exposes End Session with Escape-to-close.
Remove terminal_app, terminal_window_title, and terminal_tab_title_pattern
from DB seed fixtures in all 10 E2E specs — these keys are no longer valid
SettingKey values and should not be seeded into test databases.
…eedback

- isTtydAlive: handle EPERM (process alive, different user) and re-throw unexpected errors
- verifyTtyd: distinguish "not installed" (ENOENT/exit 1) from other failures
- spawnTtyd: add post-spawn health check with 300ms delay, now async
- getDb: wrap reconcileOrphanedDeployments in try-catch so startup never crashes
- endSession: wrap killTtyd in try-catch so DB update always proceeds
- TerminalPanel: surface endSession errors in the UI header
The FilterEdgeSwipe was hidden on desktop via a media query from
when launch meant opening a Ghostty window. Now that the terminal
is embedded in the dashboard, the action sheet needs to be
accessible on all viewports.
The FilterEdgeSwipe (swipe-up sheet) is mobile-only. Desktop now
gets a visible button bar at the bottom of the issue detail page
with "Launch with Claude", "Re-assign", and "Close issue" buttons.
The launch button hides when a session is active.
- Move IssueActionSheet above body text so desktop buttons appear after
  metadata, not at the bottom
- Center the desktop action bar with justify-content: center
- Add frame-src CSP directive for ttyd ports 7700-7799 so the terminal
  iframe is not blocked by the browser
The iframe was conditionally rendered with {open && <iframe>}, which
unmounted it when the panel closed. This disconnected the WebSocket
and ttyd's -q flag killed the process. Now the iframe stays mounted
(hidden behind translateX(100%)) so the connection persists across
panel open/close cycles.
Once ttyd has spawned and the deployment is active, redirect to the
issue detail page where the Open Terminal button is ready. The old
progress page was designed for fire-and-forget Ghostty launches —
with embedded ttyd terminals there's no reason to leave the user
staring at a spinner.
- Desktop: replace left-edge swipe handle with visible X button in
  header + clickable backdrop to close the panel
- Mobile: keep the swipe handle (hidden on desktop via media query)
- Close issue now ends the active terminal session before closing
  the GitHub issue, preventing orphaned ttyd processes
Previously the return value of endSession() was silently discarded,
meaning kill failures went unnoticed. Now the result is inspected and
a warning toast is shown if the session could not be stopped cleanly.
Four tests covering the endSession server action: kill succeeds before DB update,
kill failure is non-fatal (DB update still runs), no kill when ttydPid is null,
and graceful handling when the deployment row is missing.
Cover the try/catch at steps 9-10: verify that pending deployment rows
are deleted when allocatePort or spawnTtyd throws, that the slot is
freed for a subsequent launch, and that the original error is propagated
even when the rollback itself fails.
Replace all Ghostty mentions in source files with ttyd or generic
equivalents — JSDoc examples, user-facing placeholder copy, e2e skip
guards, and inline comments.
@neonwatty neonwatty added this pull request to the merge queue Apr 20, 2026
Merged via the queue into main with commit 5b00169 Apr 20, 2026
5 checks passed
@neonwatty neonwatty deleted the feat/ttyd-terminal branch April 20, 2026 00:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant