Last updated: 2026-06-29
Applies to: Zync v2.19.1+
This document describes how Zync’s integrated terminal works today — local and remote shells, stack choices, architecture, IPC, renderer, lifecycle, ghost suggestions, settings, and code layout. It is the single place to learn what the terminal system is and how it behaves, not a development plan or backlog.
For workspace-wide tab/session restore, see SESSION_PERSISTENCE.md. Keyboard ownership (Zync command vs PTY vs field) is SHORTCUTS.md.
- Executive summary
- Technology stack
- Architecture overview
- UI layer
- Frontend terminal library (
src/lib/terminal) - Backend PTY (Rust)
- IPC & event model
- Renderer: WebGL & DOM
- Input pipeline
- Output streaming
- PTY lifecycle & lazy spawn
- Idle host PTY suspend
- Resize & layout
- Theme, typography & transparency
- Ghost suggestions
- Session persistence (terminal)
- Settings layout
- Design decisions & policies
- Testing
- Known gaps & optional polish
- File map
Zync embeds a full terminal per workspace connection (plus a local shell) using xterm.js 6.x inside a Tauri desktop app. The stack optimizes for:
- Tab persistence — scrollback and xterm instances survive tab switches via a module-level cache
- Lazy PTY spawn — backend shells start when a shell tab is first selected
- Generation-gated IPC — stale output/exit events are ignored after respawn or suspend
- Batched I/O — input and output coalescing on frontend and Rust paths
- GPU rendering — WebGL2 primary with automatic DOM fallback
- Opt-in resource reclaim — background remote host PTYs can suspend after idle timeout
Each workspace can have multiple shell tabs. A local shell (LOCAL_TERMINAL_CONNECTION_ID) runs without SSH; remote shells attach to the active host connection. By default only the active shell is mounted; a split can mount up to four nested visible shells (side by side first, or stacked). Inactive tabs keep their xterm instance and scrollback in terminalCache. Pane layout lives in src/lib/paneLayout (tree + cap). Files / Port Forwarding / Dashboard stay full-view.
| Layer | Choice | Notes |
|---|---|---|
| Terminal UI | @xterm/xterm ^6.0.0 |
Core emulator |
| Addons (always) | fit, search, web-links |
Loaded per instance in lifecycle hook |
| GPU | @xterm/addon-webgl ^0.19.0 |
Lazy-loaded; primary renderer when enabled |
| Ligatures | @xterm/addon-ligatures ^0.10.0 |
Compatible with WebGL via reactivate-after-ligatures order |
| Fallback renderer | xterm built-in DOM | GPU off, WebGL init failure, or context loss |
| Desktop bridge | Tauri 2.x | terminal:* commands + Channel for PTY output |
| Local PTY | portable-pty (Rust) |
Windows ConPTY, Unix pseudoterminals |
| Remote PTY | SSH channel in pty.rs |
Batched read/write; resize coalescing |
| Process probe | sysinfo (local) |
Child-tree scan for idle-suspend deferral (fail-closed) |
| State | Zustand terminalSlice + module terminalCache |
Store owns tab metadata; cache owns live xterm/PTY binding |
| Ghost engine | Rust src-tauri/src/ghost/* + TS src/lib/ghostSuggestions/* |
History frecency + filesystem path completion |
| Item | Reason |
|---|---|
@xterm/addon-canvas |
Removed — xterm 6 dropped canvas addon; DOM is the non-WebGL path |
terminal-output-{sessionId} Tauri events |
Removed — replaced by Tauri Channel frames (v2.19.x) |
Base64 JSON number[] as primary output transport |
Legacy decode kept in terminalOutputPayload.ts for dev safety only |
Private xterm APIs (_core._renderService) |
Removed — ghost sizing uses char-measure for width and viewport (screen/rows) for height (char-measure alone underestimates row pitch when lineHeight > 1) |
windowsMode / fastScrollModifier xterm options |
Removed in xterm 6; not reintroduced |
Canvas renderer aliases (activateCanvasRenderer exports) |
Removed in Phase 7; use DOM APIs |
| Auto SSH respawn after idle suspend | Rejected — user presses Enter to resume; scrollback preserved |
| Immediate PTY kill on sidebar host switch | Rejected — remote hosts share one idle timer when opt-in suspend is enabled |
| Scrollback reflow on window resize | Intentionally off (reflowCursorLine: false) — correct terminal semantics |
| Injected shell integration for CWD | Passive OSC 7 only — no prompt injection |
| Idle suspend on local workspace shell | Excluded by policy — LOCAL_TERMINAL_CONNECTION_ID never idle-suspended |
flowchart TB
subgraph UI["React UI"]
TM[TerminalManager]
TC[Terminal.tsx hooks]
TH[TerminalHost.tsx]
TM --> TC --> TH
end
subgraph Lib["src/lib/terminal"]
Cache[terminalCache]
Life[ptyLifecycle / spawn / lazy]
In[inputPipeline + inputQueue]
Out[terminalOutputStream]
Rend[rendererController / policy]
Svc[terminalService]
Idle[terminalIdlePty]
end
subgraph Rust["src-tauri"]
PTY[pty.rs]
Cmd[commands.rs]
Ghost[ghost/*]
end
TH --> Cache
TC --> Life
TC --> In
TC --> Rend
Out --> PTY
In --> Cmd
Life --> Cmd
Cmd --> PTY
TC --> Ghost
Svc --> Cache
Idle --> Svc
Data ownership:
terminalSlice— tab list per connection, titles,pendingRestore, synced terminal idterminalCache— liveXTerm, addons, generation, spawn flags, output channel, renderer sessionterminalService— store-facing destroy/suspend/close-tab-on-exit API (decouples slice from React)
| File | Role |
|---|---|
TerminalManager.tsx |
Mounts one to four visible shells; keeps inactive tabs warm; routes snippet/plugin writes through queueTerminalInput |
PaneLayoutView.tsx / PaneDivider.tsx |
Split tree renderer; 1px seams; accent on the focused pane's inner edges only; drag to resize; double-click a seam to even both sides |
paneLayout/nav.ts |
Spatial neighbor for Ctrl+Alt+arrows |
Terminal.tsx |
Hook wiring (~270 lines): lifecycle, theme, search, ghost, keybindings, global shortcuts |
TerminalHost.tsx |
Connected-state presentation: search bar, context menu, ghost overlays, xterm container |
TerminalDisconnectedView.tsx |
Connecting / error / reconnect UI for remote hosts |
TerminalSearchBar.tsx |
Find UI; removed from DOM when closed (a11y) |
TerminalContextMenu.tsx |
Copy/paste via shared clipboard helper; Open File Manager Here jumps to Files at the shell cwd |
GhostSuggestionOverlay.tsx |
Inline ghost suffix at cursor |
GhostSuggestionListOverlay.tsx |
Popup list (portal to document.body) |
useTerminalLifecycle.ts |
xterm init, spawn/suspend, resize scheduler, renderer sync, output channel attach |
useTerminalTheme.ts |
Live theme/accent/opacity sync to open terminals |
useTerminalSearch.ts |
Search addon state |
useTerminalGhost.ts |
Ghost runtime binding |
useTerminalKeybindings.ts |
PTY table (Ctrl+/ → ^_), skip if dispatcher already consumed the event, terminal font zoom, search Escape. See SHORTCUTS.md |
useTerminalGlobalShortcuts.ts |
App-level paste/find guards when xterm focused |
ShortcutManager + features/shortcuts |
Window capture → command catalog dispatcher. See SHORTCUTS.md |
terminalTheme.ts |
xterm theme resolution, transparency host styles |
Connection identity:
LOCAL_TERMINAL_CONNECTION_IDinconnectionIds.ts(canonical; re-exported fromtabService.ts)- Remote terminals use connection id as
terminalKey/sessionId
Public surface exported from index.ts. Key modules:
| Module | Responsibility |
|---|---|
terminalCache.ts |
Module-level Map<sessionId, TerminalCache> — xterm, fit/search addons, generation, flags, output channel |
xtermOptions.ts |
Central buildXtermOptions() — scrollback 5000, reflowCursorLine: false, windowsPty for local Win only |
ptyLifecycle.ts |
spawnTerminalSession, suspendTerminalPty |
spawnContext.ts |
CWD / shell resolution for spawn |
terminalSpawn.ts |
spawnTerminalFromStoreContext — store-aware spawn entry |
terminalLazyPty.ts |
resolveLazyPtyAction — defer spawn until active shell tab |
inputPipeline.ts |
4ms / 64-byte input batching; ready/suspend gating; queueTerminalInput |
inputQueue.ts |
Serialized async onData + ghost middleware; epoch bump on suspend/destroy |
terminalOutputStream.ts |
Tauri Channel attach; u32 LE generation + raw bytes decode |
terminalOutputPayload.ts |
Legacy base64/array decode (dev fallback) |
terminalLifecycleListeners.ts |
terminal-ready / terminal-exit listeners; generation match; close tab on natural exit |
terminalConnectionWakeup.ts |
SSH reconnect wakeup dispatch + handler |
terminalResizeSync.ts |
Deduped PTY resize IPC |
terminalFit.ts |
createResizeScheduler (60ms trailing), safeFitTerminal |
terminalPanelRestore.ts |
Refit/redraw after Files/Dashboard overlay |
rendererPolicy.ts |
Pure WebGL vs DOM resolution |
rendererController.ts |
Lazy WebGL load, in-flight race guards, syncTerminalRenderer |
rendererSession.ts |
Per-session renderer state |
rendererLifecycle.ts |
DOM fallback activation, dispose, screen refresh |
rendererSetup.ts |
GPU + ligatures activation order |
rendererDiagnostics.ts |
Settings → Terminal renderer status panel |
webglCapability.ts |
Cached WebGL2 probe |
ligatures.ts |
LigaturesAddon load/dispose |
instanceApi.ts |
destroyTerminalInstance, getTerminalRecentLines |
terminalService.ts |
destroy, suspendAllForConnection, closeTabOnShellExit, getRecentLines |
terminalIdlePty.ts |
Idle-host suspend scheduler (wired from MainLayout) |
terminalIdleSuspendNotice.ts |
Synchronous suspend banner (no terminal-exit on kill) |
terminalActivity.ts |
Activity timestamps for busy deferral |
terminalProcessActivity.ts |
IPC wrapper for terminal_has_active_processes |
terminalSpawnErrors.ts |
User-friendly unreachable-host messages |
terminalClipboard.ts |
Tauri + browser fallback clipboard |
terminalTypography.ts |
Font weight bold pairing for GPU atlas |
suspendAllTerminals.ts |
Connection-scoped suspend |
terminalReloadTeardown.ts |
Dev HMR channel callback revoke |
| File | Responsibility |
|---|---|
src-tauri/src/pty.rs |
PtyManager: local spawn/read/write/resize/close; remote SSH reader; output batching (8ms / 4KB); explicit child.kill() on close |
src-tauri/src/commands.rs |
terminal_create (accepts output Channel), terminal_write, terminal_resize, terminal_has_active_processes, close variants |
src-tauri/src/ghost/* |
Ghost suggestion persistence, parser, ranking, Tauri commands |
Output batching (remote & local): REMOTE_OUTPUT_BATCH_MS / OUTPUT_FLUSH_THRESHOLD coalesce before sending to frontend channel.
Resize (remote): SSH resize channel drains to latest cols/rows (trailing coalesce).
terminal:create— spawns PTY; frontend passesChannelfor outputterminal:write— batched input frominputPipelineterminal:resize— cols/rows from unified resize schedulerterminal:close/terminal:close_by_connection— programmatic teardown (noterminal-exit)terminal_has_active_processes— local sysinfo child-tree probe
| Event | Purpose |
|---|---|
terminal-ready-{sessionId} |
{ generation } — flushes input buffer; clears idle guard |
terminal-exit-{sessionId} |
{ generation, exit_code? } — natural shell exit only; closes tab or shows idle notice |
Each spawn/suspend bumps generation on the cache entry. Output channel frames carry generation in the first 4 bytes (u32 LE). Stale frames and exit events are dropped after restart.
| Path | terminal-exit emitted? |
Frontend behavior |
|---|---|---|
User types exit / Ctrl+D / shell ends |
Yes | Close that pane (or the tab if it is the last pane) via closePaneOnShellExit |
| Idle suspend kill | No | Write suspend notice; suspendedByIdle flag |
| Panel overlay suspend | No | suspendedByPanel; respawn on return |
| Programmatic close | No | Tear down handles only |
gpuAcceleration off → DOM
webglContextLossBlocked → DOM
otherwise → WebGL (if WebGL2 probe passes)
Ligatures: Not mutually exclusive with WebGL. Activation order: WebGL → LigaturesAddon → WebGL reactivate so font-feature-settings reach the glyph atlas.
WebGL → DOM → log warning — terminal never blanks on renderer failure.
Switching shell tabs re-applies WebGL on the active tab (syncTerminalRenderer). Background tabs may stay on DOM until reselected.
settings.terminal.gpuAcceleration(defaulttrue) — Settings → Terminal- Live renderer status panel on same tab (
TerminalRendererStatus.tsx)
npm run test:terminal-renderer — policy, probe cache, session ownership, controller sync, diagnostics, setup helper.
xterm.onData
→ inputQueue (serialized tasks, ghost middleware)
→ inputPipeline.queueTerminalInput
→ batch 4ms / 64 bytes (immediate flush for \r \n \x03 \x04 \x1b)
→ terminal:write IPC (only if spawned && !starting)
Ready gating: Input buffers while starting or !spawned until terminal-ready with matching generation.
External writes: Snippets, plugins, command palette route through queueTerminalInput (not raw terminal:write).
Ghost IPC: Skipped when shell tab is hidden (isVisibleRef).
Current (v2.19.x): Tauri Channel registered before terminal:create. Each message:
[ u32 generation (LE) ][ raw PTY bytes... ]
Decoded in terminalOutputStream.ts → term.write() after generation check.
Legacy: terminal-output-* events removed. terminalOutputPayload.ts retains base64/array decode for older dev builds.
| Visibility | Action |
|---|---|
| Workspace inactive | none |
| Not terminal view (Files/Dashboard overlay) | none — PTY stays alive; panel hidden via CSS |
| Not active shell tab | none — defer spawn |
| Active shell tab, not spawned | spawn |
| Active shell tab, spawned | none |
Split extras pass isActiveTab so every visible pane still spawns a PTY. Only isFocused takes DOM focus (blinking cursor). After restore, focusedTermIdForRestore aligns activeTerminalIds with the layout's focused leaf.
Intentional: Switching sidebar hosts or internal shell tabs keeps PTYs alive (scrollback + running processes). Opt-in idle suspend (§12) is the separate background reclaim path.
spawnTerminalFromStoreContextresolves CWD/shellattachTerminalOutputChannelregisters Channelterminal:createIPCattachTerminalLifecycleListenersonce per cache entryterminal-ready→ flush input, apply renderer, fit
- Panel leave (
suspendTerminalPty): setssuspendedByPanel; kills PTY; preserves cache scrollback - Idle suspend (§12): sets
suspendedByIdle; synchronous notice; no tab close
Opt-in: Settings → Terminal → Suspend idle host shells (default off).
| Rule | Behavior |
|---|---|
| Scope | Remote workspace hosts only — local shell excluded |
| Timer | Configurable minutes (1–60, default 2); shared across remote host shell tabs on that connection |
| Busy deferral | Recent output/input since backgrounding resets quiet window |
| Process deferral | terminal_has_active_processes (local sysinfo, fail-closed) retries with minimum delay |
| On fire | suspendAllTerminalsForConnection — kill PTY, keep terminalCache scrollback |
| Resume | User presses Enter in suspended shell → lazy respawn on active tab (no auto SSH reconnect loop) |
Wired from MainLayout.tsx when setting enabled. Tests: terminalIdlePty.test.mjs, terminalIdleHostSuspend.test.mjs.
Historical scrollback does not reflow when the window is resized. Lines written at a narrow width stay wrapped; new input uses the new column count. xterm on Windows ConPTY disables scrollback reflow by design (reflowCursorLine: false, windowsPty for local only).
ResizeObserveron terminal container — gated onisVisibleRef- Window resize
- Layout transitions (sidebar, panel) — visual
fit()immediately; PTY IPC deferred until settle (500ms safety timeout) - Split divider drag — visual
fit()while dragging; one PTY resize (SIGWINCH) on pointer up (zync:pane-resize-end). Double-click a seam to even both sides, then the same pointer-up resize path runs. - Renderer kind changes — refit + screen refresh
- Files/Dashboard return —
terminalPanelRestore+isTerminalDomMeasurable
Scheduler: createResizeScheduler — 60ms trailing edge in terminalFit.ts.
Desired-size flush (issue #101): syncTerminalResize always records desiredResize from xterm. While the PTY is starting (or not yet spawned), IPC is deferred — size is not dropped. On terminal-ready, flushTerminalResize fits and sends the latest size (same pending→flush pattern as input). Manual window resize still force-syncs via lastResize clear.
| Symptom | Mitigation |
|---|---|
| Black margins around xterm | .terminal-container fill CSS, safeFitTerminal |
| Garbled text after resize | Trailing scheduler + post-fit refresh |
| PTY cols/rows drift | Hidden-tab gate; defer IPC until layout settle; desired-size flush on ready |
| tmux small until window resize (#101) | Retain desiredResize while starting; flushTerminalResize on ready |
| Double framebuffer | Single active renderer path (WebGL or DOM) |
Theme, accent, and opacity changes apply to open terminals immediately (useTerminalTheme + cache refresh), including the local shell with no active workspace connection.
Terminal ANSI palette follows theme plugin manifest mode (with luminance fallback). Light themes use high-contrast ANSI defaults.
- Font weight setting pairs regular + bold weights for GPU atlas rebuild (
terminalTypography.ts) - Settings → Appearance → Terminal (font family, weight, size, padding, line height, ligatures)
- Windows recommended reset: Consolas-first, 15px, medium (500)
allowTransparency: trueon xterm; host-levelcolor-mixbackground viabuildTerminalHostStyle- xterm 6 default
#000viewport overridden inindex.cssfor vibrancy - Opacity + desktop transparency under Appearance → Terminal
Zync provides fish-style inline ghost text, per-scope command history (Rust frecency), and filesystem path completion (local, WSL, SSH). Tab always goes to the shell; → accepts the ghost suffix. Ghost input runs through inputQueue so it cannot reorder shell keystrokes.
Full documentation: TERMINAL_GHOST.md — architecture, suggestion engine, key bindings, settings, WSL/SSH behavior, and future plans.
At a glance:
- UI:
useTerminalGhost.ts,GhostSuggestionOverlay.tsx - Logic:
src/lib/ghostSuggestions/* - Backend:
src-tauri/src/ghost/*(ghost_suggest_v2decision engine) - Settings: Settings → Terminal → Ghost suggestions
- IPC skipped when shell tab is hidden
Terminal tabs are restored from session.json per connection scope. See SESSION_PERSISTENCE.md for full workspace restore flow.
Terminal-specific rules:
- SSH tabs restore with
pendingRestore: true— reconnect before PTY spawn;TerminalDisconnectedViewUI - CWD captured passively via OSC 7 in
Terminal.tsx(starship, oh-my-posh, fish, etc.) setTerminalCwddebounced 1s into session saveclearPendingRestore()after successful SSH reconnect
Post–v2.19 settings reorganization:
| Location | Controls |
|---|---|
| Settings → Appearance → App | Theme, accent, global UI font/size, compact mode |
| Settings → Appearance → Terminal | Monospace font, weight, size, padding, line height, ligatures, opacity, transparency, cursor style |
| Settings → Terminal | GPU acceleration, renderer status, idle host suspend, Windows default shell, ghost suggestions |
Terminal tab intro links jump to Appearance for look-and-feel.
| Decision | Rationale |
|---|---|
Module-level terminalCache vs per-component xterm |
Preserve scrollback across tab remounts without duplicating PTY |
| Lazy spawn on active tab only | Avoid N live PTYs for N background shell tabs |
| Keep PTYs on host/shell tab switch | User expectation: running processes and scrollback survive |
| Channel output vs per-chunk events | Lower IPC overhead; binary framing with generation |
terminal-exit only on natural exit |
Distinguish user exit from programmatic kill/suspend |
| Enter-to-resume after idle suspend | Avoid surprise SSH respawn storms |
| Local excluded from idle suspend | Local shell is the default workspace; killing it is disruptive |
reflowCursorLine: false |
Correct scrollback semantics on resize (§13) |
| WebGL + ligatures together | xterm-recommended order; better than mutual exclusion |
terminalService facade |
Decouple terminalSlice from React component exports |
| Passive OSC 7 only | No shell injection; works when prompt emits OSC 7 |
| Fail-closed process probe | If sysinfo fails, defer suspend rather than kill busy shell |
| Command | Coverage |
|---|---|
npm run test:terminal-renderer |
Renderer policy, WebGL probe, controller, diagnostics, setup |
npm run test:all-agent (terminal subset) |
Lifecycle, idle suspend, output stream, spawn errors, tab close, xterm options, PTY lifecycle, reconnect |
tests/ghostSuggestionsHelpers.test.mjs |
Ghost controller/runtime/path/tab behavior |
tests/sessionPersistence.test.mjs |
Session snapshot / terminal tab caps |
Manual QA matrix (signed off v2.18–2.19): WebGL default, GPU off → DOM, ligatures, transparency, resize/maximize, context loss, Files ↔ Terminal, Shell tab switch GPU restore, large output cat, idle suspend opt-in, theme live sync.
Minor items that do not change core shell behavior today:
- Ghost suggestion behavior and edge cases — see TERMINAL_GHOST.md
- Rare Windows ConPTY edge cases (
windowsPty/reflowCursorLinedefaults inxtermOptions.ts)
src/components/terminal/
Terminal.tsx, TerminalHost.tsx, TerminalManager.tsx
PaneLayoutView.tsx, PaneDivider.tsx
TerminalDisconnectedView.tsx, TerminalSearchBar.tsx, TerminalContextMenu.tsx
GhostSuggestionOverlay.tsx
useTerminalLifecycle.ts, useTerminalTheme.ts, useTerminalSearch.ts
useTerminalGhost.ts, useTerminalKeybindings.ts, useTerminalGlobalShortcuts.ts
terminalTheme.ts
src/lib/paneLayout/ # Split tree, cap, persist helpers
src/lib/terminal/ # See §5 — 38 modules, index.ts public API
src/lib/ghostSuggestions/ # See §15
src/store/terminalSlice.ts
src/components/settings/tabs/TerminalTab.tsx
src/components/settings/tabs/appearance/AppearanceTerminal*.tsx
src/index.css # .terminal-container, xterm 6 viewport overrides
src-tauri/src/pty.rs
src-tauri/src/commands.rs
src-tauri/src/ghost/
tests/terminal*.test.mjs
tests/ghostSuggestionsHelpers.test.mjs
tests/runTerminalRendererTests.mjs
- TERMINAL_GHOST.md — ghost completion system (inline, history, paths, AI integration plans)
- SESSION_PERSISTENCE.md — workspace tab/session restore (includes terminal snapshots)
- SETTINGS_SYSTEM.md — global settings persistence and
settings.terminalschema
When changing terminal behavior, update this document (and the ghost doc if suggestions change) in the same change.