MouseTerm has two hosting modes: a WebviewView in the bottom panel (alongside Terminal, Problems, Output) and WebviewPanel editor tabs (via mouseterm.open, supports multiple instances). Both restore across "Developer: Reload Window". PTY lifecycle is fully decoupled from the webview — PTYs live in the extension host via pty-manager.ts, survive panel visibility toggling, and replay buffered output on reconnect. Session persistence works across restarts: pane layout, CWD, scrollback, alarm state (enabled/disabled + todo), and resume commands are saved and restored on cold start. The view uses workspaceState for persistence; editor panels use VS Code's per-panel vscode.setState() so multiple panels don't clobber each other. Alarm state is merged into every periodic save (not just deactivate) so it survives even if VS Code kills the extension host before deactivate completes. A WebviewPanelSerializer handles editor tab restoration; onWebviewPanel:mouseterm activation event ensures the extension activates early enough. Theme integration uses a two-layer CSS variable system mapping --vscode-* tokens to semantic --mt-* variables, covering all 16 ANSI colors, surfaces, typography, and borders. CSP is strict with nonce-gated scripts.
Architecture:
Extension Host (vscode-ext/src/)
├── extension.ts — entry point, activate/deactivate, panel setup
├── webview-view-provider.ts — WebviewView in bottom panel
├── message-router.ts — webview <-> host IPC, PTY ownership tracking
├── message-types.ts — bidirectional message type definitions
├── pty-manager.ts — PTY lifecycle, buffering (1M char cap), CWD queries
├── pty-host.js — forked child process wrapping pty-core via node-pty
├── session-state.ts — workspaceState persistence + alarm state merging
├── webview-html.ts — CSP injection, nonce generation, asset URI rewriting
└── log.ts — extension logging
Shared PTY Core (standalone/sidecar/)
└── pty-core.js — node-pty wrapper shared between VS Code and Tauri sidecar
Frontend Library (lib/src/)
├── App.tsx — error boundary wrapper
├── main.tsx — entry point
├── cfg.ts — timing config (marching ants, alarm thresholds)
├── theme.css — --vscode-* -> --mt-* variable system
├── index.css — dockview overrides, marching-ants keyframe
├── components/
│ ├── Pond.tsx — pane manager (dockview), mode system, keyboard shortcuts
│ ├── TerminalPane.tsx — xterm.js mount point with ResizeObserver
│ ├── Baseboard.tsx — detached-pane door carousel
│ └── Door.tsx — individual detached-pane door
└── lib/
├── terminal-registry.ts — global xterm.js registry, theme observer, alarm wiring
├── reconnect.ts — live reconnect + cold-start restore
├── alarm-manager.ts — alarm state machine (portable, no DOM deps)
├── activity-monitor.ts — silence/output pattern detection for alarm
├── session-save.ts — periodic save (debounced 500ms + 30s interval)
├── session-restore.ts — cold-start pane restoration
├── session-types.ts — PersistedSession/PersistedPane/PersistedAlarmState types
├── resume-patterns.ts — detect resumable commands from scrollback
├── spatial-nav.ts — arrow-key panel navigation + restore neighbor lookup
├── layout-snapshot.ts — dockview layout cloning + structure signature
└── platform/
├── types.ts — PlatformAdapter interface
├── index.ts — adapter factory (auto-detects VS Code vs fake)
├── vscode-adapter.ts — VS Code postMessage bridge
└── fake-adapter.ts — mock adapter for testing + website playground
- Save before kill. Deactivate must save session state before killing PTYs. CWD and scrollback queries need live processes. See ordering in
extension.ts:deactivate(). - Alarm state is global. A single
AlarmManagerinstance inmessage-router.tsis shared across all routers and survives router disposal. PTY data feeds into it at module level, regardless of webview visibility. - PTY ownership. Each router tracks its PTYs in
ownedPtyIds. A module-levelglobalOwnedPtyIdsset prevents a reconnecting router from stealing PTYs owned by another webview. - Shell login args are shell-specific. The shared
pty-core.jslaunches POSIX shells with-lonly for shells that accept it.csh/tcshmust be spawned without-lso both the standalone app and VS Code extension can open a usable terminal for users whose login shell is C shell-derived. - mergeAlarmStates on every save path. Both the frontend periodic save (
onSaveStatecallback) and the backend deactivate refresh (refreshSavedSessionStateFromPtys) must merge current alarm states. Missing this causes alarm state to revert on restore. - Scrollback trailing newline. Restored scrollback must end with
\nto avoid zsh printing a%artifact at the top of the terminal. - retainContextWhenHidden. Set on
WebviewPanel(editor tabs) but NOT onWebviewView(bottom panel). The view relies on reconnect/replay when it becomes visible again; the panel keeps its DOM alive. - Two save sources. Session state is saved from two places: the frontend (debounced 500ms + 30s interval via
mouseterm:saveState) and the backend (deactivate flushes webviews then refreshes from live PTYs). Both paths must produce consistent state.
PTYs are managed by the extension host, not by the webview. The webview is a view layer that connects and disconnects from PTYs.
Extension Host (always running while extension is active)
├── pty-manager.ts (forks pty-host.js child process)
│ ├── pty-1 (shell session, alive)
│ ├── pty-2 (shell session, alive)
│ └── pty-3 (shell session, exited)
│
├── WebviewView "MouseTerm" (bottom panel)
│ └── message-router: owns pty-1, pty-2
│
└── WebviewPanel "MouseTerm" (editor tab, optional)
└── message-router: owns pty-3
This means:
- Hiding the MouseTerm panel doesn't kill its PTYs.
- VS Code toggling the panel visibility doesn't destroy sessions.
- When the view becomes visible again, the webview reconnects to still-alive PTYs.
- Each message router tracks which PTYs it owns; PTYs cannot be stolen by another router.
- Multiple VS Code windows each get their own extension host process, and therefore their own pty-host child process.
pty-manager.ts maintains two buffer types per PTY:
- replayChunks: cleared on first consume, used for hot reconnect (webview hidden then shown)
- scrollbackChunks: never cleared, used for re-reconnects and session save
Both are capped at 1M chars per PTY. When the cap is reached, oldest chunks are trimmed.
1. Webview becomes visible (or panel deserializes)
2. Webview sends: { type: 'mouseterm:init' }
3. Extension responds with:
- { type: 'pty:list', ptys: [{ id, alive, exitCode }] } // all owned PTYs
- { type: 'pty:replay', id, data } // buffered output per PTY
4. Webview restores terminals from replay data, resumes live stream
For cold-start restore (no live PTYs), the webview falls back to saved session state: spawns new PTYs in saved CWDs, injects saved scrollback (with trailing newline to avoid zsh % artifact), and restores dockview layout. The reconnect module (reconnect.ts) uses a 500ms timeout when waiting for the PTY list.
All types defined in message-types.ts. Webview-side handling in vscode-adapter.ts; host-side handling in message-router.ts.
Webview -> Extension Host:
| Message | Purpose |
|---|---|
pty:spawn |
Create new PTY (id, optional cols/rows/cwd/shell/args) |
pty:input |
Write data to PTY |
pty:resize |
Resize PTY dimensions |
pty:kill |
Kill PTY and release ownership |
pty:getCwd |
Query PTY working directory (request-response via requestId) |
pty:getScrollback |
Query PTY scrollback buffer (request-response via requestId) |
pty:getShells |
Query available shells (request-response via requestId) |
mouseterm:init |
Trigger reconnection: get PTY list + replay data |
mouseterm:saveState |
Frontend persisting session state |
mouseterm:flushSessionSaveDone |
Ack for deactivate-triggered flush (matched by requestId) |
alarm:toggle |
Toggle alarm enabled/disabled for a PTY |
alarm:disable |
Disable alarm for a PTY |
alarm:dismiss |
Dismiss ringing alarm |
alarm:dismissOrToggle |
Context-dependent: dismiss if ringing, else toggle |
alarm:attend |
Mark user as attending to a PTY |
alarm:remove |
Remove alarm state entirely |
alarm:resize |
Notify alarm of terminal resize (debounce noise) |
alarm:clearAttention |
Clear attention timer |
alarm:toggleTodo |
Toggle TODO (false <-> hard) |
alarm:markTodo |
Set hard TODO |
alarm:clearTodo |
Remove TODO |
Extension Host -> Webview:
| Message | Purpose |
|---|---|
pty:data |
PTY output (routed only to owning router) |
pty:exit |
PTY process exited (with exitCode) |
pty:list |
List of all reconnectable PTYs (response to mouseterm:init) |
pty:replay |
Buffered output since spawn (response to mouseterm:init) |
pty:cwd |
CWD query response (matched by requestId) |
pty:scrollback |
Scrollback query response (matched by requestId) |
pty:shells |
Available shells list response (matched by requestId) |
mouseterm:flushSessionSave |
Request webview to save state now (deactivate trigger, matched by requestId) |
alarm:state |
Alarm state change (status, todo, attentionDismissedRing) |
WebviewPanelSerializer is registered so VS Code can restore editor panels after restart:
activationEvents: ["onWebviewPanel:mouseterm"]
Session structure (from session-types.ts):
interface PersistedSession {
version: 1;
panes: PersistedPane[];
detached?: PersistedDetachedItem[];
layout: unknown; // SerializedDockview
}
interface PersistedPane {
id: string;
cwd: string | null;
title: string;
scrollback: string | null;
resumeCommand: string | null;
alarm?: PersistedAlarmState | null;
}Persistence flow:
- Frontend saves state periodically (debounced 500ms + 30s interval) via
mouseterm:saveStatemessage - Router's
onSaveStatecallback merges in current alarm states viamergeAlarmStates() - WebviewView writes to
workspaceState; WebviewPanels persist viavscode.setState()(per-panel, no clobbering) - On deactivate: flush all sessions from webviews (1s timeout), then refresh from live PTYs (queries CWD + scrollback while processes are still alive)
- Graceful shutdown: save state -> SIGTERM -> 2s wait -> force kill
- On activate: saved state loaded and passed to routers for cold-start restore
Three-layer CSS variable system: VS Code injects --vscode-* tokens; lib/src/theme.css maps them to semantic --mt-* variables with hardcoded fallbacks; a Tailwind v4 @theme block re-exports them as --color-* tokens for use in utility classes.
Example of the pattern:
/* theme.css: --mt-* layer with --vscode-* source and fallback */
--mt-surface: var(--vscode-editor-background, #1e1e1e);
--mt-ansi-red: var(--vscode-terminal-ansiRed, #cd3131);
/* theme.css: Tailwind @theme registration */
--color-surface: var(--mt-surface);Full mapping in lib/src/theme.css covers: surfaces (3), text (2), accent/borders (4), tabs (6), terminal bg/fg/cursor/selection (4), all 16 ANSI colors + bright variants, badges (2), semantic status (3), inputs (2), buttons (3), and selection (2). Dark mode fallbacks (VS Code Dark+ defaults) are in :root; light mode overrides (VS Code Light+ defaults) are in body.vscode-light; a standalone fallback uses @media (prefers-color-scheme: light) for non-VS Code contexts.
A MutationObserver in terminal-registry.ts watches for VS Code theme changes on body/html (class and style attribute mutations) and live-updates all xterm.js instances.
default-src 'none';
style-src ${webview.cspSource} 'unsafe-inline';
script-src 'nonce-${nonce}';
font-src ${webview.cspSource};
img-src ${webview.cspSource} data: blob:;
connect-src ${webview.cspSource};
unsafe-inline for styles is needed because VS Code injects theme CSS variables via inline styles on the body element. Scripts remain nonce-gated (32-char random alphanumeric nonce). The webview HTML is built by Vite from the lib package, then at runtime webview-html.ts rewrites asset URLs to webview URIs, injects the CSP meta tag, applies nonces to all script tags, and injects initial state via a nonce-gated inline script.
pnpm build:vscode =
1. pnpm --filter mouseterm-lib build (TypeScript compile)
2. pnpm --filter mouseterm build:frontend (Vite: lib -> vscode-ext/media/)
3. pnpm --filter mouseterm build (esbuild: extension.ts + pty-host.js -> dist/,
copy node-pty prebuilds -> dist/node-pty)
pnpm dogfood:vscode = build + package VSIX + install locally
(then: Cmd+Shift+P -> "Developer: Reload Window" to pick up changes)
F5 in VS Code = launch Extension Development Host (see .vscode/launch.json)
(runs preLaunchTask "build-mouseterm-vscode" from .vscode/tasks.json,
which just calls `pnpm build:vscode`, then opens a new VS Code window
with the extension loaded)
Dogfooding vs Extension Development Host: Day-to-day development uses pnpm dogfood:vscode to install the extension into your real VS Code instance. This catches real-world issues since you're running with your actual settings, extensions, and workspaces. The F5 Extension Development Host workflow exists for when you need breakpoint debugging of extension host code (extension.ts, message-router.ts, pty-manager.ts, etc.) — it launches a separate VS Code window where the debugger can attach to the extension host process.
The Vite config for the extension (vscode-ext/vite.config.ts) sets root: ../lib and outDir: ./media, building the shared React frontend directly into the extension's media folder.
Set context keys so menus and extensions can target MouseTerm state:
// Set when any MouseTerm webview has focus
vscode.commands.executeCommand('setContext', 'mouseterm.active', true);
// Set when MouseTerm is in passthrough/terminal mode (keys go to PTY)
vscode.commands.executeCommand('setContext', 'mouseterm.mode', 'terminal');
// Set when MouseTerm is in normal/navigation mode (keys go to MouseTerm UI)
vscode.commands.executeCommand('setContext', 'mouseterm.mode', 'normal');| Command | Description |
|---|---|
mouseterm.focus |
Focus the MouseTerm panel view |
mouseterm.newPane |
Split a new pane in MouseTerm |
mouseterm.closePane |
Close the focused pane |
mouseterm.nextPane |
Focus next pane |
mouseterm.prevPane |
Focus previous pane |
mouseterm.enterTerminalMode |
Switch to passthrough mode |
mouseterm.enterNormalMode |
Switch to navigation mode |
mouseterm.listSessions |
Show QuickPick of all live PTY sessions |
mouseterm.reattach |
Reattach a detached PTY to a pane |
TerminalProfileProvidernot registered — MouseTerm doesn't appear in the terminal+dropdown- Context keys not set (
mouseterm.active,mouseterm.mode) — needed for conditional keybindings - Commands not registered:
mouseterm.newPane,closePane,nextPane,prevPane,enterTerminalMode,enterNormalMode,listSessions,reattach - No status bar item showing active session count
- No QuickPick for listing/reattaching PTY sessions
{ "activationEvents": [ "onView:mouseterm.view", "onWebviewPanel:mouseterm" ], "contributes": { "commands": [ { "command": "mouseterm.focus", "title": "MouseTerm: Focus", "icon": { "light": "icon-tiny-light.png", "dark": "icon-tiny-dark.png" } }, { "command": "mouseterm.open", "title": "MouseTerm: Open in Editor" } ], "viewsContainers": { "panel": [ { "id": "mouseterm-panel", "title": "MouseTerm", "icon": "$(terminal)" } ] }, "views": { "mouseterm-panel": [ { "id": "mouseterm.view", "name": "MouseTerm", "type": "webview" } ] } } }