From 3e0a93db43c6461ac3288108ffa4d2fb59f544b8 Mon Sep 17 00:00:00 2001 From: Edgar Twigg Date: Thu, 16 Apr 2026 15:21:23 -0700 Subject: [PATCH 01/20] Pin AppBar right-side controls to the window edge. The flex-1 grow was trapped inside the Tip wrapper, so the center section never ate the slack and the right group (close button on Windows/Linux) floated next to the project name instead of the window edge. Hoist flex-1 to a direct child of the AppBar row and add ml-auto on the trailing group for good measure. Co-Authored-By: Claude Opus 4.7 (1M context) --- standalone/src/AppBar.tsx | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/standalone/src/AppBar.tsx b/standalone/src/AppBar.tsx index 47e5dac..5d6c28f 100644 --- a/standalone/src/AppBar.tsx +++ b/standalone/src/AppBar.tsx @@ -194,27 +194,29 @@ export function AppBar({ projectDir, homeDir, shells }: AppBarProps) { )} {/* Project directory — centered */} - -
- - {isHome ? '~' : name} - - {!isHome && ( - - {displayDir} +
+ +
+ + {isHome ? '~' : name} - )} -
-
+ {!isHome && ( + + {displayDir} + + )} +
+ +
{/* Shell dropdown on the right (macOS) or window controls (Windows/Linux) */} {IS_MAC ? ( -
+
) : ( -
+
From d6ea78423eb25882e86556125a9a06a574ac8676 Mon Sep 17 00:00:00 2001 From: Edgar Twigg Date: Thu, 16 Apr 2026 16:00:45 -0700 Subject: [PATCH 02/20] Remove project dir from standalone AppBar. Standalone always starts at ~, so there's no meaningful project directory to show. Drop the display from the AppBar center, remove the projectDir/homeDir props, and delete the get_project_dir Tauri command. Center section becomes an empty draggable spacer. Co-Authored-By: Claude Opus 4.7 (1M context) --- standalone/src-tauri/src/lib.rs | 8 ------- standalone/src/AppBar.tsx | 39 +++------------------------------ standalone/src/main.tsx | 8 ++----- 3 files changed, 5 insertions(+), 50 deletions(-) diff --git a/standalone/src-tauri/src/lib.rs b/standalone/src-tauri/src/lib.rs index 7e48f68..99aad47 100644 --- a/standalone/src-tauri/src/lib.rs +++ b/standalone/src-tauri/src/lib.rs @@ -240,13 +240,6 @@ fn kill_process_tree(pid: u32) { } } -#[tauri::command] -fn get_project_dir() -> String { - std::env::var("HOME") - .or_else(|_| std::env::var("USERPROFILE")) - .unwrap_or_default() -} - #[derive(Serialize, Deserialize, Clone)] struct ShellInfo { name: String, @@ -459,7 +452,6 @@ pub fn run() { pty_get_scrollback, pty_request_init, shutdown_sidecar, - get_project_dir, get_available_shells, ]) .build(tauri::generate_context!()) diff --git a/standalone/src/AppBar.tsx b/standalone/src/AppBar.tsx index 5d6c28f..daea010 100644 --- a/standalone/src/AppBar.tsx +++ b/standalone/src/AppBar.tsx @@ -10,8 +10,6 @@ export interface ShellEntry { } interface AppBarProps { - projectDir: string; - homeDir: string; shells: ShellEntry[]; } @@ -20,13 +18,6 @@ const IS_MAC = typeof (navigator as any).userAgentData?.platform === 'string' : /Mac/.test(navigator.platform); const appWindow = getCurrentWindow(); -function abbreviateHome(dir: string, home: string): string { - if (dir === home) return '~'; - if (dir.startsWith(home + '/')) return '~' + dir.slice(home.length); - if (dir.startsWith(home + '\\')) return '~' + dir.slice(home.length); - return dir; -} - // ── Tooltip wrapper ──────────────────────────────────────────────────────── function Tip({ label, children }: { label: string; children: React.ReactNode }) { @@ -165,18 +156,7 @@ function ShellDropdown({ shells }: { shells: ShellEntry[] }) { // ── AppBar ───────────────────────────────────────────────────────────────── -function projectName(dir: string): string { - const sep = dir.includes('\\') ? '\\' : '/'; - const parts = dir.split(sep).filter(Boolean); - return parts[parts.length - 1] ?? dir; -} - -export function AppBar({ projectDir, homeDir, shells }: AppBarProps) { - const displayDir = abbreviateHome(projectDir, homeDir); - const name = projectName(projectDir); - // Show just the directory name when it's the home dir (avoids bare "~") - const isHome = projectDir === homeDir; - +export function AppBar({ shells }: AppBarProps) { return (
)} - {/* Project directory — centered */} -
- -
- - {isHome ? '~' : name} - - {!isHome && ( - - {displayDir} - - )} -
-
-
+ {/* Draggable spacer */} +
{/* Shell dropdown on the right (macOS) or window controls (Windows/Linux) */} {IS_MAC ? ( diff --git a/standalone/src/main.tsx b/standalone/src/main.tsx index d85ec2c..a985583 100644 --- a/standalone/src/main.tsx +++ b/standalone/src/main.tsx @@ -45,16 +45,12 @@ async function bootstrap() { startUpdateCheck(); // Fetch app bar data from Rust backend - const [homeDir, detectedShells] = await Promise.all([ - invoke("get_project_dir"), - invoke("get_available_shells"), - ]); - const projectDir = homeDir; // For now, project dir defaults to home + const detectedShells = await invoke("get_available_shells"); const shells: ShellEntry[] = detectedShells.length > 0 ? detectedShells : [{ name: 'shell', path: '' }]; createRoot(document.getElementById("root")!).render( - + Date: Thu, 16 Apr 2026 16:25:31 -0700 Subject: [PATCH 03/20] Rework ShellDropdown as [+] [shell-name v] with a selection dropdown. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plus button spawns a terminal with the currently selected shell. The shell-name button opens a menu that picks which shell is current — it doesn't spawn. The dropdown sizes to its widest item (w-max + whitespace-nowrap) and the active shell gets a leading check. Co-Authored-By: Claude Opus 4.7 (1M context) --- standalone/src/AppBar.tsx | 56 ++++++++++++++++++++++----------------- 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/standalone/src/AppBar.tsx b/standalone/src/AppBar.tsx index daea010..fdffb2f 100644 --- a/standalone/src/AppBar.tsx +++ b/standalone/src/AppBar.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { getCurrentWindow } from '@tauri-apps/api/window'; -import { CaretDownIcon, MinusIcon, CornersOutIcon, CornersInIcon, XIcon, TerminalWindowIcon, PlusIcon } from '@phosphor-icons/react'; +import { CaretDownIcon, MinusIcon, CornersOutIcon, CornersInIcon, XIcon, PlusIcon, CheckIcon } from '@phosphor-icons/react'; import { ThemePicker } from '../../lib/src/components/ThemePicker'; export interface ShellEntry { @@ -83,11 +83,10 @@ function WinControls() { function ShellDropdown({ shells }: { shells: ShellEntry[] }) { const [open, setOpen] = useState(false); + const [selected, setSelected] = useState(shells[0]); const ref = useRef(null); - const defaultShell = shells[0]; - const handleSelect = useCallback((shell: ShellEntry) => { - setOpen(false); + const spawn = useCallback((shell: ShellEntry) => { window.dispatchEvent(new CustomEvent('mouseterm:new-terminal', { detail: { shell: shell.path, args: shell.args } })); }, []); @@ -113,41 +112,50 @@ function ShellDropdown({ shells }: { shells: ShellEntry[] }) { return (
- {/* Primary action: click to open a new terminal with the default shell */} - + {/* Primary action: [+] spawns a new terminal with the selected shell */} + - {/* Dropdown caret: pick a different shell type */} + {/* Selector: shows current shell name + caret; click to choose a different shell */} {open && ( -
- {shells.map((shell) => ( - - ))} +
+ {shells.map((shell) => { + const isSelected = shell.path === selected?.path; + return ( + + ); + })}
)}
From fda767003fd51bce8d27f1395a882b33b3edffeb Mon Sep 17 00:00:00 2001 From: Edgar Twigg Date: Thu, 16 Apr 2026 16:27:35 -0700 Subject: [PATCH 04/20] Left-align the shell dropdown popup. Co-Authored-By: Claude Opus 4.7 (1M context) --- standalone/src/AppBar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/standalone/src/AppBar.tsx b/standalone/src/AppBar.tsx index fdffb2f..0b2ed87 100644 --- a/standalone/src/AppBar.tsx +++ b/standalone/src/AppBar.tsx @@ -135,7 +135,7 @@ function ShellDropdown({ shells }: { shells: ShellEntry[] }) {
{open && ( -
+
{shells.map((shell) => { const isSelected = shell.path === selected?.path; return ( From 73828fdc2ab7b735afd5a6ddf51824c450aec4e8 Mon Sep 17 00:00:00 2001 From: Edgar Twigg Date: Thu, 16 Apr 2026 19:28:40 -0700 Subject: [PATCH 05/20] Add native shell-picker and new-terminal buttons to the VSCode view. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contribute two commands to the mouseterm.view title bar: [+] which spawns a terminal using the currently selected shell, and a picker that opens showQuickPick to change the selection (with a check on the active shell). Selection persists "global unless trumped by workspace": reads prefer workspaceState then fall back to globalState; writes go to whichever scope the user chose after picking. The shell-picker reuses ptyManager.getAvailableShells() and posts a new mouseterm:newTerminal message, which the webview's VSCodeAdapter translates into the same mouseterm:new-terminal CustomEvent that Pond.tsx already listens for — the spawn path is identical to the standalone AppBar's [+] button. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/lib/platform/vscode-adapter.ts | 4 +++ vscode-ext/package.json | 24 +++++++++++++ vscode-ext/src/extension.ts | 46 +++++++++++++++++++++++++ vscode-ext/src/message-types.ts | 1 + vscode-ext/src/shell-selection.ts | 33 ++++++++++++++++++ vscode-ext/src/webview-view-provider.ts | 5 +++ 6 files changed, 113 insertions(+) create mode 100644 vscode-ext/src/shell-selection.ts diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index 395e65f..1355682 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -41,6 +41,10 @@ export class VSCodeAdapter implements PlatformAdapter { for (const handler of this.alarmStateHandlers) { handler({ id: msg.id, status: msg.status, todo: msg.todo, attentionDismissedRing: msg.attentionDismissedRing }); } + } else if (msg.type === 'mouseterm:newTerminal') { + window.dispatchEvent(new CustomEvent('mouseterm:new-terminal', { + detail: { shell: msg.shell, args: msg.args }, + })); } }); } diff --git a/vscode-ext/package.json b/vscode-ext/package.json index 06ec783..74eb2d0 100644 --- a/vscode-ext/package.json +++ b/vscode-ext/package.json @@ -47,8 +47,32 @@ { "command": "mouseterm.open", "title": "MouseTerm: Open in Editor" + }, + { + "command": "mouseterm.newTerminal", + "title": "MouseTerm: New Terminal", + "icon": "$(add)" + }, + { + "command": "mouseterm.selectShell", + "title": "MouseTerm: Select Shell", + "icon": "$(list-unordered)" } ], + "menus": { + "view/title": [ + { + "command": "mouseterm.newTerminal", + "group": "navigation@1", + "when": "view == mouseterm.view" + }, + { + "command": "mouseterm.selectShell", + "group": "navigation@2", + "when": "view == mouseterm.view" + } + ] + }, "viewsContainers": { "panel": [ { diff --git a/vscode-ext/src/extension.ts b/vscode-ext/src/extension.ts index c59ca4d..87fe777 100644 --- a/vscode-ext/src/extension.ts +++ b/vscode-ext/src/extension.ts @@ -6,6 +6,7 @@ import { attachRouter, flushAllSessions, getAlarmStates } from './message-router import { getWebviewHtml } from './webview-html'; import { log } from './log'; import { getSavedSessionState, isPersistedSession, mergeAlarmStates, refreshSavedSessionStateFromPtys, saveSessionState } from './session-state'; +import { resolveSelectedShell, setSelectedShellPath, getSelectedShellPath } from './shell-selection'; let extensionContext: vscode.ExtensionContext | null = null; @@ -82,6 +83,51 @@ export function activate(context: vscode.ExtensionContext) { ); setupPanel(context, panel); }), + vscode.commands.registerCommand('mouseterm.newTerminal', async () => { + await vscode.commands.executeCommand('mouseterm.view.focus'); + const shells = await ptyManager.getAvailableShells(); + const shell = resolveSelectedShell(context, shells); + await provider.postMessage({ + type: 'mouseterm:newTerminal', + shell: shell?.path, + args: shell?.args, + }); + }), + vscode.commands.registerCommand('mouseterm.selectShell', async () => { + const shells = await ptyManager.getAvailableShells(); + if (shells.length === 0) { + void vscode.window.showWarningMessage('MouseTerm: no shells detected.'); + return; + } + const currentPath = getSelectedShellPath(context) ?? shells[0].path; + const items: (vscode.QuickPickItem & { path: string; args: string[] })[] = shells.map((s) => ({ + label: s.name, + description: s.path, + picked: s.path === currentPath, + path: s.path, + args: s.args, + })); + const picked = await vscode.window.showQuickPick(items, { + title: 'Select default shell for MouseTerm', + placeHolder: 'The [+] button will spawn a terminal with this shell.', + }); + if (!picked) return; + + const hasWorkspace = (vscode.workspace.workspaceFolders?.length ?? 0) > 0; + let scope: 'workspace' | 'global' = 'global'; + if (hasWorkspace) { + const scopeChoice = await vscode.window.showQuickPick( + [ + { label: 'Apply globally (default)', value: 'global' as const }, + { label: 'Apply to this workspace only', value: 'workspace' as const }, + ], + { title: 'Where should this apply?' }, + ); + if (!scopeChoice) return; + scope = scopeChoice.value; + } + await setSelectedShellPath(context, picked.path, scope); + }), ); } diff --git a/vscode-ext/src/message-types.ts b/vscode-ext/src/message-types.ts index 6429833..f200c3c 100644 --- a/vscode-ext/src/message-types.ts +++ b/vscode-ext/src/message-types.ts @@ -40,6 +40,7 @@ export type ExtensionMessage = | { type: 'pty:cwd'; id: string; cwd: string | null; requestId?: string } | { type: 'pty:scrollback'; id: string; data: string | null; requestId?: string } | { type: 'pty:shells'; shells: Array<{ name: string; path: string; args: string[] }>; requestId?: string } + | { type: 'mouseterm:newTerminal'; shell?: string; args?: string[] } | { type: 'mouseterm:flushSessionSave'; requestId: string } // Alarm state updates | { type: 'alarm:state'; id: string; status: SessionStatus; todo: TodoState; attentionDismissedRing: boolean }; diff --git a/vscode-ext/src/shell-selection.ts b/vscode-ext/src/shell-selection.ts new file mode 100644 index 0000000..9bbe329 --- /dev/null +++ b/vscode-ext/src/shell-selection.ts @@ -0,0 +1,33 @@ +import * as vscode from 'vscode'; + +export interface ShellEntry { + name: string; + path: string; + args: string[]; +} + +const KEY = 'mouseterm.selectedShellPath'; + +export function getSelectedShellPath(context: vscode.ExtensionContext): string | undefined { + return context.workspaceState.get(KEY) ?? context.globalState.get(KEY); +} + +export async function setSelectedShellPath( + context: vscode.ExtensionContext, + path: string, + scope: 'workspace' | 'global', +): Promise { + if (scope === 'workspace') { + await context.workspaceState.update(KEY, path); + } else { + await context.globalState.update(KEY, path); + } +} + +export function resolveSelectedShell( + context: vscode.ExtensionContext, + shells: ShellEntry[], +): ShellEntry | undefined { + const saved = getSelectedShellPath(context); + return shells.find((s) => s.path === saved) ?? shells[0]; +} diff --git a/vscode-ext/src/webview-view-provider.ts b/vscode-ext/src/webview-view-provider.ts index 49875a6..8443294 100644 --- a/vscode-ext/src/webview-view-provider.ts +++ b/vscode-ext/src/webview-view-provider.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import { attachRouter, getAlarmStates } from './message-router'; import { getWebviewHtml } from './webview-html'; import { getSavedSessionState, saveSessionState, mergeAlarmStates } from './session-state'; +import type { ExtensionMessage } from './message-types'; export class MouseTermViewProvider implements vscode.WebviewViewProvider { private view: vscode.WebviewView | undefined; @@ -10,6 +11,10 @@ export class MouseTermViewProvider implements vscode.WebviewViewProvider { constructor(private readonly context: vscode.ExtensionContext) {} + postMessage(msg: ExtensionMessage): Thenable { + return this.view?.webview.postMessage(msg) ?? Promise.resolve(false); + } + resolveWebviewView( view: vscode.WebviewView, _context: vscode.WebviewViewResolveContext, From 959fe1c7bbc3e8be353abe0c47e927cb51521c92 Mon Sep 17 00:00:00 2001 From: Edgar Twigg Date: Thu, 16 Apr 2026 19:33:43 -0700 Subject: [PATCH 06/20] Drop projectDir/homeDir from AppBar stories. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/stories/AppBar.stories.tsx | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/lib/src/stories/AppBar.stories.tsx b/lib/src/stories/AppBar.stories.tsx index 2268f74..44cc60f 100644 --- a/lib/src/stories/AppBar.stories.tsx +++ b/lib/src/stories/AppBar.stories.tsx @@ -19,8 +19,6 @@ const meta: Meta = { title: 'Components/AppBar', component: AppBarStory, args: { - projectDir: '/home/user/projects/mouseterm', - homeDir: '/home/user', shells: DEFAULT_SHELLS, }, }; @@ -30,20 +28,6 @@ type Story = StoryObj; export const Default: Story = {}; -export const HomeDirectory: Story = { - args: { - projectDir: '/home/user', - homeDir: '/home/user', - }, -}; - -export const LongPath: Story = { - args: { - projectDir: '/home/user/projects/very-deep/nested/directory/structure/my-project', - homeDir: '/home/user', - }, -}; - export const SingleShell: Story = { args: { shells: [{ name: 'bash', path: '/bin/bash' }], @@ -61,10 +45,3 @@ export const ManyShells: Story = { ], }, }; - -export const AbsolutePathOutsideHome: Story = { - args: { - projectDir: '/var/www/my-site', - homeDir: '/home/user', - }, -}; From f5117e97c032a5671205ed7cd1eadd4ae64e7a9b Mon Sep 17 00:00:00 2001 From: Edgar Twigg Date: Thu, 16 Apr 2026 19:41:15 -0700 Subject: [PATCH 07/20] Show the selected shell name in the MouseTerm view header. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set view.description to the currently selected shell name — it renders next to the view title in muted text, giving the user a persistent indication of which shell [+] will spawn. Reorder the title-bar icons so the shell picker ($(terminal)) sits left of the [+] button, matching the intended [>_] powershell [+] layout. Co-Authored-By: Claude Opus 4.7 (1M context) --- vscode-ext/package.json | 6 +++--- vscode-ext/src/extension.ts | 6 ++++++ vscode-ext/src/webview-view-provider.ts | 7 +++++++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/vscode-ext/package.json b/vscode-ext/package.json index 74eb2d0..5e4e93e 100644 --- a/vscode-ext/package.json +++ b/vscode-ext/package.json @@ -56,18 +56,18 @@ { "command": "mouseterm.selectShell", "title": "MouseTerm: Select Shell", - "icon": "$(list-unordered)" + "icon": "$(terminal)" } ], "menus": { "view/title": [ { - "command": "mouseterm.newTerminal", + "command": "mouseterm.selectShell", "group": "navigation@1", "when": "view == mouseterm.view" }, { - "command": "mouseterm.selectShell", + "command": "mouseterm.newTerminal", "group": "navigation@2", "when": "view == mouseterm.view" } diff --git a/vscode-ext/src/extension.ts b/vscode-ext/src/extension.ts index 87fe777..39a626a 100644 --- a/vscode-ext/src/extension.ts +++ b/vscode-ext/src/extension.ts @@ -59,6 +59,11 @@ export function activate(context: vscode.ExtensionContext) { const provider = new MouseTermViewProvider(context); + // Seed the view description with the currently selected shell name. + void ptyManager.getAvailableShells().then((shells) => { + provider.setDescription(resolveSelectedShell(context, shells)?.name); + }); + context.subscriptions.push( vscode.window.registerWebviewViewProvider('mouseterm.view', provider), vscode.window.registerWebviewPanelSerializer('mouseterm', { @@ -127,6 +132,7 @@ export function activate(context: vscode.ExtensionContext) { scope = scopeChoice.value; } await setSelectedShellPath(context, picked.path, scope); + provider.setDescription(picked.label); }), ); } diff --git a/vscode-ext/src/webview-view-provider.ts b/vscode-ext/src/webview-view-provider.ts index 8443294..8c6864b 100644 --- a/vscode-ext/src/webview-view-provider.ts +++ b/vscode-ext/src/webview-view-provider.ts @@ -8,6 +8,7 @@ import type { ExtensionMessage } from './message-types'; export class MouseTermViewProvider implements vscode.WebviewViewProvider { private view: vscode.WebviewView | undefined; private routerDisposable: vscode.Disposable | undefined; + private description: string | undefined; constructor(private readonly context: vscode.ExtensionContext) {} @@ -15,12 +16,18 @@ export class MouseTermViewProvider implements vscode.WebviewViewProvider { return this.view?.webview.postMessage(msg) ?? Promise.resolve(false); } + setDescription(text: string | undefined): void { + this.description = text; + if (this.view) this.view.description = text; + } + resolveWebviewView( view: vscode.WebviewView, _context: vscode.WebviewViewResolveContext, _token: vscode.CancellationToken, ): void { this.view = view; + if (this.description !== undefined) view.description = this.description; const mediaPath = path.join(this.context.extensionPath, 'media'); From b5541d98cc338f3a1ff0eb878e8d2155a1386be3 Mon Sep 17 00:00:00 2001 From: Edgar Twigg Date: Thu, 16 Apr 2026 19:51:39 -0700 Subject: [PATCH 08/20] Fix cold-start race in pty-manager IPC request helpers. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getAvailableShells and getCwd attached their response listener via child?.on(...) *before* calling sendToChild — but on the first invocation the module-level child is still null, so the listener was never registered and the 5s timeout fired with an empty list. The extension surfaced that as "MouseTerm: no shells detected." Reorder so sendToChild() (which forks the child via ensureChild) runs first, then the listener binds to the now non-null child. Co-Authored-By: Claude Opus 4.7 (1M context) --- vscode-ext/src/pty-manager.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/vscode-ext/src/pty-manager.ts b/vscode-ext/src/pty-manager.ts index 8e11628..661cf30 100644 --- a/vscode-ext/src/pty-manager.ts +++ b/vscode-ext/src/pty-manager.ts @@ -210,6 +210,10 @@ export interface ShellEntry { export function getAvailableShells(): Promise { return new Promise((resolve) => { const requestId = `shells-${Date.now()}`; + // Ensure the child process is forked before attaching the listener — + // otherwise `child` is null on the cold path and the handler is never + // registered, causing the timeout to fire with an empty list. + sendToChild({ type: 'getShells', requestId }); const timeout = setTimeout(() => { child?.off('message', handler); resolve([]); @@ -222,12 +226,12 @@ export function getAvailableShells(): Promise { } }; child?.on('message', handler); - sendToChild({ type: 'getShells', requestId }); }); } export function getCwd(id: string): Promise { return new Promise((resolve) => { + sendToChild({ type: 'getCwd', id }); const timeout = setTimeout(() => { child?.off('message', handler); resolve(null); @@ -240,7 +244,6 @@ export function getCwd(id: string): Promise { } }; child?.on('message', handler); - sendToChild({ type: 'getCwd', id }); }); } From e8cb75fd4a8eeaefa94213d94d78090805e2ef77 Mon Sep 17 00:00:00 2001 From: Edgar Twigg Date: Fri, 17 Apr 2026 11:24:59 -0700 Subject: [PATCH 09/20] Fix Windows shell-detection timeout and cold-start latency. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three compounding issues made getAvailableShells() time out at 5s on Windows, surfacing as "MouseTerm: No shells detected" and a multi-second stall on [+] (which then spawned the platform default because the resolved shell was undefined): - findSystemNode() had no Windows branch: it exhausted Unix paths, failed the /usr/bin/env fallback, and returned the literal "/usr/local/bin/node" as execPath. fork() with that path stalls for several seconds on Windows. Short-circuit to process.execPath (Electron's Node) — VSCode's own terminal uses node-pty against Electron, so it's the right choice. - detectWindowsShells() runs a synchronous wsl.exe -l -q with a 5s internal timeout. Combined with the execPath stall, the outer 5s timeout fired first. Bump the outer timeout to 15s. - The whole detection was re-run on every click. Cache the shells promise at module level; invalidate on child exit or on empty results (so a cold-start miss doesn't pin an empty list). activate() kicks off getAvailableShells() fire-and-forget to warm the cache before the user clicks anything. Co-Authored-By: Claude Opus 4.7 (1M context) --- vscode-ext/src/extension.ts | 5 ++++- vscode-ext/src/pty-manager.ts | 24 ++++++++++++++++++++++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/vscode-ext/src/extension.ts b/vscode-ext/src/extension.ts index 39a626a..cf62124 100644 --- a/vscode-ext/src/extension.ts +++ b/vscode-ext/src/extension.ts @@ -59,7 +59,10 @@ export function activate(context: vscode.ExtensionContext) { const provider = new MouseTermViewProvider(context); - // Seed the view description with the currently selected shell name. + // Warm up shell detection in the background so the picker/+ buttons + // don't pay the cold-start cost (child fork + WSL probe) when the user + // first clicks them. Also seeds the view description with the current + // shell name. void ptyManager.getAvailableShells().then((shells) => { provider.setDescription(resolveSelectedShell(context, shells)?.name); }); diff --git a/vscode-ext/src/pty-manager.ts b/vscode-ext/src/pty-manager.ts index 661cf30..fca7850 100644 --- a/vscode-ext/src/pty-manager.ts +++ b/vscode-ext/src/pty-manager.ts @@ -97,6 +97,15 @@ let cachedNodePath: string | null = null; function findSystemNode(): string { if (cachedNodePath) return cachedNodePath; + // On Windows, use the host's execPath (Electron's Node). VSCode's own + // integrated terminal uses node-pty against Electron, so this works for us + // too — and avoids the bogus Unix-path fallback below that was causing + // multi-second fork stalls. + if (process.platform === 'win32') { + cachedNodePath = process.execPath; + return cachedNodePath; + } + // Try common locations first (avoids shell invocation) const candidates = [ process.env.NVM_BIN && path.join(process.env.NVM_BIN, 'node'), @@ -167,6 +176,7 @@ function ensureChild(extensionPath: string): ChildProcess { child = null; childReady = false; pendingMessages = []; + shellsCache = null; }); child.stderr?.on('data', (data: Buffer) => { @@ -207,8 +217,11 @@ export interface ShellEntry { args: string[]; } +let shellsCache: Promise | null = null; + export function getAvailableShells(): Promise { - return new Promise((resolve) => { + if (shellsCache) return shellsCache; + const pending = new Promise((resolve) => { const requestId = `shells-${Date.now()}`; // Ensure the child process is forked before attaching the listener — // otherwise `child` is null on the cold path and the handler is never @@ -217,7 +230,7 @@ export function getAvailableShells(): Promise { const timeout = setTimeout(() => { child?.off('message', handler); resolve([]); - }, 5000); + }, 15000); const handler = (msg: any) => { if (msg.type === 'shells' && msg.requestId === requestId) { clearTimeout(timeout); @@ -227,6 +240,13 @@ export function getAvailableShells(): Promise { }; child?.on('message', handler); }); + shellsCache = pending; + // Don't pin an empty result in the cache — lets a subsequent call retry + // if the first one timed out or the child was still warming up. + void pending.then((shells) => { + if (shells.length === 0 && shellsCache === pending) shellsCache = null; + }); + return pending; } export function getCwd(id: string): Promise { From b10da5eb379bd0829127bfd1f5de8653b6fc9a29 Mon Sep 17 00:00:00 2001 From: Edgar Twigg Date: Fri, 17 Apr 2026 12:12:02 -0700 Subject: [PATCH 10/20] Carry the selected shell into split-spawns. Splits go through Pond.addSplitPanel which wasn't setting pendingShellOpts, so new panes from a split always spawned the platform default regardless of what the user had picked. Introduce a shared "default shell" slot in terminal-registry (setDefaultShellOpts/getDefaultShellOpts). addSplitPanel reads it and seeds pendingShellOpts for the new pane. Writers: - Standalone: AppBar.ShellDropdown syncs its selection into the slot via a useEffect on `selected`. - VSCode: the extension sends a new mouseterm:selectedShell message on activate, after the shell picker, and whenever a webview re-initialises (so a re-opened view relearns the selection). The webview's VSCodeAdapter writes into the slot. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/components/Pond.tsx | 8 +++++++- lib/src/lib/platform/vscode-adapter.ts | 3 +++ lib/src/lib/terminal-registry.ts | 13 +++++++++++++ standalone/src/AppBar.tsx | 6 ++++++ vscode-ext/src/extension.ts | 14 ++++++++++---- vscode-ext/src/message-router.ts | 12 ++++++++++++ vscode-ext/src/message-types.ts | 1 + vscode-ext/src/webview-view-provider.ts | 15 +++++++++++++++ 8 files changed, 67 insertions(+), 5 deletions(-) diff --git a/lib/src/components/Pond.tsx b/lib/src/components/Pond.tsx index 4e0d478..655ec4f 100644 --- a/lib/src/components/Pond.tsx +++ b/lib/src/components/Pond.tsx @@ -33,6 +33,7 @@ import { destroyTerminal, swapTerminals, setPendingShellOpts, + getDefaultShellOpts, type SessionStatus, } from '../lib/terminal-registry'; import { resolvePanelElement, findPanelInDirection, findRestoreNeighbor, type DetachDirection } from '../lib/spatial-nav'; @@ -1740,6 +1741,11 @@ export function Pond({ if (!api) return; const newId = generatePaneId(); const ref = id && api.getPanel(id) ? id : null; + // Carry the currently-selected shell into the split, same as [+]. + const defaults = getDefaultShellOpts(); + if (defaults?.shell) { + setPendingShellOpts(newId, { shell: defaults.shell, args: defaults.args }); + } api.addPanel({ id: newId, component: 'terminal', @@ -1749,7 +1755,7 @@ export function Pond({ }); selectPanel(newId); onEventRef.current?.({ type: 'split', direction: splitDirection, source }); - }, [selectPanel]); + }, [selectPanel, generatePaneId]); // --- Pond actions (for tab buttons) --- diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index 1355682..e307200 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -1,4 +1,5 @@ import type { AlarmStateDetail, PlatformAdapter, PtyInfo } from './types'; +import { setDefaultShellOpts } from '../terminal-registry'; export class VSCodeAdapter implements PlatformAdapter { private vscode: ReturnType; @@ -45,6 +46,8 @@ export class VSCodeAdapter implements PlatformAdapter { window.dispatchEvent(new CustomEvent('mouseterm:new-terminal', { detail: { shell: msg.shell, args: msg.args }, })); + } else if (msg.type === 'mouseterm:selectedShell') { + setDefaultShellOpts(msg.shell ? { shell: msg.shell, args: msg.args } : null); } }); } diff --git a/lib/src/lib/terminal-registry.ts b/lib/src/lib/terminal-registry.ts index 74d1c4e..f871e82 100644 --- a/lib/src/lib/terminal-registry.ts +++ b/lib/src/lib/terminal-registry.ts @@ -40,6 +40,19 @@ const registry = new Map(); const pendingShellOpts = new Map(); const primedSessionStates = new Map>(); +// Shared "currently selected" shell, used when spawning without an explicit +// choice (e.g. a keyboard-driven split). Updated by AppBar's ShellDropdown in +// standalone and by the VSCode extension pushing mouseterm:selectedShell. +let defaultShellOpts: { shell?: string; args?: string[] } | null = null; + +export function setDefaultShellOpts(opts: { shell?: string; args?: string[] } | null): void { + defaultShellOpts = opts; +} + +export function getDefaultShellOpts(): { shell?: string; args?: string[] } | null { + return defaultShellOpts; +} + // --- Watch for VSCode theme changes and re-apply xterm themes --- // VSCode signals theme changes by updating CSS variables and body classes. let themeObserverStarted = false; diff --git a/standalone/src/AppBar.tsx b/standalone/src/AppBar.tsx index 0b2ed87..af864b3 100644 --- a/standalone/src/AppBar.tsx +++ b/standalone/src/AppBar.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { getCurrentWindow } from '@tauri-apps/api/window'; import { CaretDownIcon, MinusIcon, CornersOutIcon, CornersInIcon, XIcon, PlusIcon, CheckIcon } from '@phosphor-icons/react'; import { ThemePicker } from '../../lib/src/components/ThemePicker'; +import { setDefaultShellOpts } from '../../lib/src/lib/terminal-registry'; export interface ShellEntry { name: string; @@ -86,6 +87,11 @@ function ShellDropdown({ shells }: { shells: ShellEntry[] }) { const [selected, setSelected] = useState(shells[0]); const ref = useRef(null); + // Publish the selection so splits (and other spawn paths) can reuse it. + useEffect(() => { + setDefaultShellOpts(selected ? { shell: selected.path, args: selected.args } : null); + }, [selected]); + const spawn = useCallback((shell: ShellEntry) => { window.dispatchEvent(new CustomEvent('mouseterm:new-terminal', { detail: { shell: shell.path, args: shell.args } })); }, []); diff --git a/vscode-ext/src/extension.ts b/vscode-ext/src/extension.ts index cf62124..905a820 100644 --- a/vscode-ext/src/extension.ts +++ b/vscode-ext/src/extension.ts @@ -21,6 +21,7 @@ function setupPanel( context: vscode.ExtensionContext, panel: vscode.WebviewPanel, savedState?: unknown, + getSelectedShell?: () => { shell?: string; args?: string[] } | null, ) { const mediaPath = path.join(context.extensionPath, 'media'); @@ -46,6 +47,7 @@ function setupPanel( reconnect: !!savedState, killOnDispose: true, savedSession: isPersistedSession(initialState) ? initialState : null, + getSelectedShell, // Panels persist via vscode.setState() (per-panel, managed by VS Code). // Don't write to workspaceState — that's for the WebviewView only. }); @@ -62,16 +64,19 @@ export function activate(context: vscode.ExtensionContext) { // Warm up shell detection in the background so the picker/+ buttons // don't pay the cold-start cost (child fork + WSL probe) when the user // first clicks them. Also seeds the view description with the current - // shell name. + // shell name and publishes it to the webview so split-spawn paths can + // pick it up. void ptyManager.getAvailableShells().then((shells) => { - provider.setDescription(resolveSelectedShell(context, shells)?.name); + const shell = resolveSelectedShell(context, shells); + provider.setDescription(shell?.name); + provider.setSelectedShell(shell ? { shell: shell.path, args: shell.args } : null); }); context.subscriptions.push( vscode.window.registerWebviewViewProvider('mouseterm.view', provider), vscode.window.registerWebviewPanelSerializer('mouseterm', { async deserializeWebviewPanel(panel: vscode.WebviewPanel, state: unknown) { - setupPanel(context, panel, state); + setupPanel(context, panel, state, () => provider.getSelectedShell()); }, }), vscode.commands.registerCommand('mouseterm.focus', () => { @@ -89,7 +94,7 @@ export function activate(context: vscode.ExtensionContext) { localResourceRoots: [vscode.Uri.file(mediaPath)], }, ); - setupPanel(context, panel); + setupPanel(context, panel, undefined, () => provider.getSelectedShell()); }), vscode.commands.registerCommand('mouseterm.newTerminal', async () => { await vscode.commands.executeCommand('mouseterm.view.focus'); @@ -136,6 +141,7 @@ export function activate(context: vscode.ExtensionContext) { } await setSelectedShellPath(context, picked.path, scope); provider.setDescription(picked.label); + provider.setSelectedShell({ shell: picked.path, args: picked.args }); }), ); } diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index 8a0908b..97c53b0 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -52,6 +52,7 @@ export function attachRouter( killOnDispose?: boolean; onSaveState?: (state: unknown) => void; savedSession?: PersistedSession | null; + getSelectedShell?: () => { shell?: string; args?: string[] } | null; }, ): vscode.Disposable { const reconnect = options?.reconnect ?? false; @@ -186,6 +187,17 @@ export function attachRouter( disconnectWebview?.(); disconnectWebview = connectWebview(); + // Re-publish the currently-selected shell so split-spawns in the + // freshly-mounted webview know what to use. + const selected = options?.getSelectedShell?.(); + if (selected) { + webview.postMessage({ + type: 'mouseterm:selectedShell', + shell: selected.shell, + args: selected.args, + } satisfies ExtensionMessage); + } + if (!reconnect) { // Fresh instance — no existing PTYs to restore webview.postMessage({ type: 'pty:list', ptys: [] } satisfies ExtensionMessage); diff --git a/vscode-ext/src/message-types.ts b/vscode-ext/src/message-types.ts index f200c3c..c34753a 100644 --- a/vscode-ext/src/message-types.ts +++ b/vscode-ext/src/message-types.ts @@ -41,6 +41,7 @@ export type ExtensionMessage = | { type: 'pty:scrollback'; id: string; data: string | null; requestId?: string } | { type: 'pty:shells'; shells: Array<{ name: string; path: string; args: string[] }>; requestId?: string } | { type: 'mouseterm:newTerminal'; shell?: string; args?: string[] } + | { type: 'mouseterm:selectedShell'; shell?: string; args?: string[] } | { type: 'mouseterm:flushSessionSave'; requestId: string } // Alarm state updates | { type: 'alarm:state'; id: string; status: SessionStatus; todo: TodoState; attentionDismissedRing: boolean }; diff --git a/vscode-ext/src/webview-view-provider.ts b/vscode-ext/src/webview-view-provider.ts index 8c6864b..ce913b2 100644 --- a/vscode-ext/src/webview-view-provider.ts +++ b/vscode-ext/src/webview-view-provider.ts @@ -9,6 +9,7 @@ export class MouseTermViewProvider implements vscode.WebviewViewProvider { private view: vscode.WebviewView | undefined; private routerDisposable: vscode.Disposable | undefined; private description: string | undefined; + private selectedShell: { shell?: string; args?: string[] } | null = null; constructor(private readonly context: vscode.ExtensionContext) {} @@ -21,6 +22,19 @@ export class MouseTermViewProvider implements vscode.WebviewViewProvider { if (this.view) this.view.description = text; } + setSelectedShell(opts: { shell?: string; args?: string[] } | null): void { + this.selectedShell = opts; + void this.postMessage({ + type: 'mouseterm:selectedShell', + shell: opts?.shell, + args: opts?.args, + }); + } + + getSelectedShell(): { shell?: string; args?: string[] } | null { + return this.selectedShell; + } + resolveWebviewView( view: vscode.WebviewView, _context: vscode.WebviewViewResolveContext, @@ -46,6 +60,7 @@ export class MouseTermViewProvider implements vscode.WebviewViewProvider { onSaveState: (state) => { void saveSessionState(this.context, mergeAlarmStates(state, getAlarmStates())); }, + getSelectedShell: () => this.selectedShell, }); view.onDidDispose(() => { From a917ef7a5b579ab376234b7300d9635db10ea6ff Mon Sep 17 00:00:00 2001 From: Edgar Twigg Date: Fri, 17 Apr 2026 12:46:28 -0700 Subject: [PATCH 11/20] Apply selected shell to auto-respawn; move picker to status bar. Pond had three spawn paths that skipped pendingShellOpts: restore fallback, fresh-start, and the onDidRemovePanel auto-respawn when the last pane is killed/detached. Factor an addTerminalPanel helper that seeds pendingShellOpts from getDefaultShellOpts and route all three through it. For the labelled shell button: VSCode's view/title navigation is icon-only, so a text-bearing button there isn't expressible. Move the picker to a StatusBarItem ("$(terminal) Windows PowerShell") bound to mouseterm.selectShell. Leave [+] in the view title. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/components/Pond.tsx | 18 +++++++++++++++--- vscode-ext/package.json | 7 +------ vscode-ext/src/extension.ts | 30 ++++++++++++++++++++++-------- 3 files changed, 38 insertions(+), 17 deletions(-) diff --git a/lib/src/components/Pond.tsx b/lib/src/components/Pond.tsx index 655ec4f..1081171 100644 --- a/lib/src/components/Pond.tsx +++ b/lib/src/components/Pond.tsx @@ -1210,6 +1210,18 @@ export function Pond({ detachedRef.current = restoredDetached; setDetached(restoredDetached); + // Apply the currently-selected shell to a freshly-added panel. Panels + // that are reconnecting to an existing PTY already have a running shell, + // so their pendingShellOpts are never consumed — only first-time spawns + // use this. + const addTerminalPanel = (id: string) => { + const defaults = getDefaultShellOpts(); + if (defaults?.shell) { + setPendingShellOpts(id, { shell: defaults.shell, args: defaults.args }); + } + e.api.addPanel({ id, component: 'terminal', tabComponent: 'terminal', title: '' }); + }; + if (layout && restored && restored.length > 0) { // Cold-start restore: apply saved dockview layout (includes panel arrangement) try { @@ -1218,7 +1230,7 @@ export function Pond({ } catch { // Layout restore failed — fall back to creating panels manually for (const id of restored) { - e.api.addPanel({ id, component: 'terminal', tabComponent: 'terminal', title: '' }); + addTerminalPanel(id); } setSelectedId(restored[0]); } @@ -1228,7 +1240,7 @@ export function Pond({ ? restored : [generatePaneId()]; for (const id of paneIds) { - e.api.addPanel({ id, component: 'terminal', tabComponent: 'terminal', title: '' }); + addTerminalPanel(id); } setSelectedId(paneIds[0]); } @@ -1297,7 +1309,7 @@ export function Pond({ e.api.onDidRemovePanel(() => { if (e.api.totalPanels === 0 && detachedRef.current.length === 0) { const id = generatePaneId(); - e.api.addPanel({ id, component: 'terminal', tabComponent: 'terminal', title: '' }); + addTerminalPanel(id); selectPanel(id); } }); diff --git a/vscode-ext/package.json b/vscode-ext/package.json index 5e4e93e..08ae11b 100644 --- a/vscode-ext/package.json +++ b/vscode-ext/package.json @@ -61,14 +61,9 @@ ], "menus": { "view/title": [ - { - "command": "mouseterm.selectShell", - "group": "navigation@1", - "when": "view == mouseterm.view" - }, { "command": "mouseterm.newTerminal", - "group": "navigation@2", + "group": "navigation@1", "when": "view == mouseterm.view" } ] diff --git a/vscode-ext/src/extension.ts b/vscode-ext/src/extension.ts index 905a820..f47f127 100644 --- a/vscode-ext/src/extension.ts +++ b/vscode-ext/src/extension.ts @@ -61,15 +61,30 @@ export function activate(context: vscode.ExtensionContext) { const provider = new MouseTermViewProvider(context); + // Text+icon button in the status bar — VSCode's view/title navigation is + // icon-only, so this is the native way to show the current shell name. + const shellStatus = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Left, + 100, + ); + shellStatus.command = 'mouseterm.selectShell'; + shellStatus.tooltip = 'MouseTerm: Select Shell'; + shellStatus.show(); + context.subscriptions.push(shellStatus); + + const applyShell = (shell: { name: string; path: string; args: string[] } | undefined) => { + provider.setDescription(shell?.name); + provider.setSelectedShell(shell ? { shell: shell.path, args: shell.args } : null); + shellStatus.text = shell ? `$(terminal) ${shell.name}` : '$(terminal) Select Shell'; + }; + applyShell(undefined); + // Warm up shell detection in the background so the picker/+ buttons // don't pay the cold-start cost (child fork + WSL probe) when the user - // first clicks them. Also seeds the view description with the current - // shell name and publishes it to the webview so split-spawn paths can - // pick it up. + // first clicks them. Also seeds the view description / status-bar / webview + // state with the current shell. void ptyManager.getAvailableShells().then((shells) => { - const shell = resolveSelectedShell(context, shells); - provider.setDescription(shell?.name); - provider.setSelectedShell(shell ? { shell: shell.path, args: shell.args } : null); + applyShell(resolveSelectedShell(context, shells)); }); context.subscriptions.push( @@ -140,8 +155,7 @@ export function activate(context: vscode.ExtensionContext) { scope = scopeChoice.value; } await setSelectedShellPath(context, picked.path, scope); - provider.setDescription(picked.label); - provider.setSelectedShell({ shell: picked.path, args: picked.args }); + applyShell({ name: picked.label, path: picked.path, args: picked.args }); }), ); } From dfe905c3f0dcb19c6cb3bb1984e5cb0cb2e3eb7d Mon Sep 17 00:00:00 2001 From: Edgar Twigg Date: Fri, 17 Apr 2026 13:16:42 -0700 Subject: [PATCH 12/20] Revert shell picker to view/title icon; keep name in description. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VSCode's view/title navigation is icon-only for extensions — the built-in terminal renders text via internal UI primitives that aren't exposed through the extension API. Revert to the native compromise: icon button next to [+] in the view header, and the current shell name shown via view.description right after the view title ("MOUSETERM Windows PowerShell [>_] [+]"). Remove the StatusBarItem added in a917ef7. Co-Authored-By: Claude Opus 4.7 (1M context) --- vscode-ext/package.json | 7 ++++++- vscode-ext/src/extension.ts | 20 +++++--------------- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/vscode-ext/package.json b/vscode-ext/package.json index 08ae11b..5e4e93e 100644 --- a/vscode-ext/package.json +++ b/vscode-ext/package.json @@ -62,9 +62,14 @@ "menus": { "view/title": [ { - "command": "mouseterm.newTerminal", + "command": "mouseterm.selectShell", "group": "navigation@1", "when": "view == mouseterm.view" + }, + { + "command": "mouseterm.newTerminal", + "group": "navigation@2", + "when": "view == mouseterm.view" } ] }, diff --git a/vscode-ext/src/extension.ts b/vscode-ext/src/extension.ts index f47f127..34ed692 100644 --- a/vscode-ext/src/extension.ts +++ b/vscode-ext/src/extension.ts @@ -61,28 +61,18 @@ export function activate(context: vscode.ExtensionContext) { const provider = new MouseTermViewProvider(context); - // Text+icon button in the status bar — VSCode's view/title navigation is - // icon-only, so this is the native way to show the current shell name. - const shellStatus = vscode.window.createStatusBarItem( - vscode.StatusBarAlignment.Left, - 100, - ); - shellStatus.command = 'mouseterm.selectShell'; - shellStatus.tooltip = 'MouseTerm: Select Shell'; - shellStatus.show(); - context.subscriptions.push(shellStatus); - + // Updates the shell-derived state in one place: the view header (shell + // name appears next to the title via description) and the webview's + // default-shell slot that split-spawns read from. const applyShell = (shell: { name: string; path: string; args: string[] } | undefined) => { provider.setDescription(shell?.name); provider.setSelectedShell(shell ? { shell: shell.path, args: shell.args } : null); - shellStatus.text = shell ? `$(terminal) ${shell.name}` : '$(terminal) Select Shell'; }; - applyShell(undefined); // Warm up shell detection in the background so the picker/+ buttons // don't pay the cold-start cost (child fork + WSL probe) when the user - // first clicks them. Also seeds the view description / status-bar / webview - // state with the current shell. + // first clicks them. Also seeds the view description / webview state + // with the current shell. void ptyManager.getAvailableShells().then((shells) => { applyShell(resolveSelectedShell(context, shells)); }); From 64d46358b18bdc6873ad878b3dd743c147088100 Mon Sep 17 00:00:00 2001 From: Edgar Twigg Date: Fri, 17 Apr 2026 14:06:17 -0700 Subject: [PATCH 13/20] Use a gear icon for the shell picker in the view title. Co-Authored-By: Claude Opus 4.7 (1M context) --- vscode-ext/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vscode-ext/package.json b/vscode-ext/package.json index 5e4e93e..3016848 100644 --- a/vscode-ext/package.json +++ b/vscode-ext/package.json @@ -56,7 +56,7 @@ { "command": "mouseterm.selectShell", "title": "MouseTerm: Select Shell", - "icon": "$(terminal)" + "icon": "$(gear)" } ], "menus": { From 4ebe49f3ecfdb14dc89f72ca19a21ea5055f007b Mon Sep 17 00:00:00 2001 From: Edgar Twigg Date: Fri, 17 Apr 2026 14:58:59 -0700 Subject: [PATCH 14/20] Preserve layout across VSCode panel collapse/re-expand. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VSCodeAdapter.getState() preferred the injected __MOUSETERM_HOST_STATE__ (a frozen HTML blob set at resolveWebviewView time) over vscode.getState() (VSCode's per-webview storage that setState writes to and survives re-mount). When the user split panes after the view was first resolved, the injected blob never updated — and on panel collapse + re-expand the webview re-mounted from it, restoring the pre-split layout and collapsing everything into a single tab group. Flip the order: use vscode.getState() first, fall back to hostState only for the very first load (before any setState has run). Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/lib/platform/vscode-adapter.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index e307200..f86a907 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -231,6 +231,12 @@ export class VSCodeAdapter implements PlatformAdapter { } getState(): unknown { - return this.hostState ?? this.vscode.getState(); + // vscode.getState() is VSCode's own per-webview storage and persists + // across re-mount (e.g. panel collapsed then re-expanded). Prefer it + // so splits made after initial resolve aren't lost — the injected + // hostState only reflects what the extension put in the HTML at the + // first resolveWebviewView call. Fall back to hostState on the very + // first load, before any setState has run. + return this.vscode.getState() ?? this.hostState; } } From 9cebd817f0315007c9f84aa4e3dc1cd1bb4951b7 Mon Sep 17 00:00:00 2001 From: Edgar Twigg Date: Fri, 17 Apr 2026 15:31:12 -0700 Subject: [PATCH 15/20] Fix layout loss on panel reopen and wrong first-terminal shell. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs, one commit because they share the webview-boot path: 1. reconnectLivePtys was returning paneIds without a layout. On panel close+reopen the PTYs stay alive, so reconnection took that path, Pond received restoredLayout=undefined, and fell into the fallback that creates panels positionless (stacked as tabs). Reload Window worked only because deactivate kills the PTYs and the cold-start restoreSession path DOES return layout. Pull saved.layout from platform.getState() and include it — but only when the live id set matches the saved set, otherwise dockview would create ghost panels for dead PTYs. 2. Shell detection is async, so the extension pushed mouseterm:selectedShell after the webview had already mounted and Pond had spawned its first pane — that spawn used the platform default. Inject __MOUSETERM_SELECTED_SHELL__ into the webview HTML alongside __MOUSETERM_HOST_STATE__; VSCodeAdapter's constructor reads it synchronously and seeds defaultShellOpts before Pond mounts. resolveWebviewView now awaits ptyManager.getAvailableShells() (cached) before serving the HTML so the injected value is always known. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/lib/platform/vscode-adapter.ts | 10 ++++++++++ lib/src/lib/reconnect.ts | 17 +++++++++++++++-- vscode-ext/src/extension.ts | 2 +- vscode-ext/src/webview-html.ts | 9 +++++++-- vscode-ext/src/webview-view-provider.ts | 21 ++++++++++++++++++--- 5 files changed, 51 insertions(+), 8 deletions(-) diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index f86a907..46e6f99 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -14,6 +14,16 @@ export class VSCodeAdapter implements PlatformAdapter { constructor() { this.vscode = acquireVsCodeApi(); + // Seed the default shell from the extension-injected global so that + // the first terminal on startup (which spawns synchronously on Pond + // mount) picks up the selected shell, not the platform default. + const injectedShell = (globalThis as typeof globalThis & { + __MOUSETERM_SELECTED_SHELL__?: { shell?: string; args?: string[] } | null; + }).__MOUSETERM_SELECTED_SHELL__; + if (injectedShell?.shell) { + setDefaultShellOpts({ shell: injectedShell.shell, args: injectedShell.args }); + } + window.addEventListener('message', (event: MessageEvent) => { const msg = event.data; if (!msg || !msg.type) return; diff --git a/lib/src/lib/reconnect.ts b/lib/src/lib/reconnect.ts index 501fa4c..f58c994 100644 --- a/lib/src/lib/reconnect.ts +++ b/lib/src/lib/reconnect.ts @@ -1,6 +1,6 @@ import type { PlatformAdapter, PtyInfo } from './platform/types'; import { reconnectTerminal } from './terminal-registry'; -import type { PersistedDetachedItem } from './session-types'; +import type { PersistedDetachedItem, PersistedSession } from './session-types'; import { restoreSession } from './session-restore'; export interface ReconnectResult { @@ -71,7 +71,20 @@ function reconnectLivePtys(platform: PlatformAdapter): Promise }); ids.push(pty.id); } - resolve({ paneIds: ids, detached: [] }); + // Pull the saved layout so reconnect (e.g. after panel close/reopen) + // restores splits instead of stacking every pane into one tab group. + // Only use it if the pane set matches — otherwise dockview would + // create ghost panels for killed PTYs. + const saved = platform.getState() as PersistedSession | null; + const liveSet = new Set(ids); + const savedIds = saved?.panes?.map((p) => p.id) ?? []; + const layoutMatches = + savedIds.length === ids.length && savedIds.every((id) => liveSet.has(id)); + resolve({ + paneIds: ids, + detached: [], + layout: layoutMatches ? saved?.layout : undefined, + }); } platform.onPtyList(handleList); diff --git a/vscode-ext/src/extension.ts b/vscode-ext/src/extension.ts index 34ed692..0c20fc6 100644 --- a/vscode-ext/src/extension.ts +++ b/vscode-ext/src/extension.ts @@ -41,7 +41,7 @@ function setupPanel( light: vscode.Uri.file(path.join(context.extensionPath, 'icon-tiny-light.png')), dark: vscode.Uri.file(path.join(context.extensionPath, 'icon-tiny-dark.png')), }; - panel.webview.html = getWebviewHtml(panel.webview, mediaPath, initialState); + panel.webview.html = getWebviewHtml(panel.webview, mediaPath, initialState, getSelectedShell?.()); const router = attachRouter(panel.webview, { reconnect: !!savedState, diff --git a/vscode-ext/src/webview-html.ts b/vscode-ext/src/webview-html.ts index 6665781..5c0a056 100644 --- a/vscode-ext/src/webview-html.ts +++ b/vscode-ext/src/webview-html.ts @@ -9,7 +9,12 @@ function serializeForInlineScript(value: unknown): string { .replace(/\u2029/g, '\\u2029'); } -export function getWebviewHtml(webview: vscode.Webview, mediaPath: string, initialState?: unknown): string { +export function getWebviewHtml( + webview: vscode.Webview, + mediaPath: string, + initialState?: unknown, + selectedShell?: { shell?: string; args?: string[] } | null, +): string { const indexPath = path.join(mediaPath, 'index.html'); let html = fs.readFileSync(indexPath, 'utf-8'); @@ -40,7 +45,7 @@ export function getWebviewHtml(webview: vscode.Webview, mediaPath: string, initi // get a duplicate nonce attribute from the regex above. html = html.replace( '', - ` \n `, + ` \n `, ); return html; diff --git a/vscode-ext/src/webview-view-provider.ts b/vscode-ext/src/webview-view-provider.ts index ce913b2..15917c3 100644 --- a/vscode-ext/src/webview-view-provider.ts +++ b/vscode-ext/src/webview-view-provider.ts @@ -4,6 +4,8 @@ import { attachRouter, getAlarmStates } from './message-router'; import { getWebviewHtml } from './webview-html'; import { getSavedSessionState, saveSessionState, mergeAlarmStates } from './session-state'; import type { ExtensionMessage } from './message-types'; +import * as ptyManager from './pty-manager'; +import { resolveSelectedShell } from './shell-selection'; export class MouseTermViewProvider implements vscode.WebviewViewProvider { private view: vscode.WebviewView | undefined; @@ -35,11 +37,11 @@ export class MouseTermViewProvider implements vscode.WebviewViewProvider { return this.selectedShell; } - resolveWebviewView( + async resolveWebviewView( view: vscode.WebviewView, _context: vscode.WebviewViewResolveContext, _token: vscode.CancellationToken, - ): void { + ): Promise { this.view = view; if (this.description !== undefined) view.description = this.description; @@ -50,8 +52,21 @@ export class MouseTermViewProvider implements vscode.WebviewViewProvider { localResourceRoots: [vscode.Uri.file(mediaPath)], }; + // Resolve the selected shell before serving the HTML so Pond's + // first-terminal spawn on mount uses the right shell. getAvailableShells + // is cached; this blocks only on a true cold start. + if (!this.selectedShell) { + const shells = await ptyManager.getAvailableShells(); + const shell = resolveSelectedShell(this.context, shells); + this.selectedShell = shell ? { shell: shell.path, args: shell.args } : null; + if (shell) { + this.description = shell.name; + view.description = shell.name; + } + } + const savedSession = getSavedSessionState(this.context); - view.webview.html = getWebviewHtml(view.webview, mediaPath, savedSession); + view.webview.html = getWebviewHtml(view.webview, mediaPath, savedSession, this.selectedShell); this.routerDisposable?.dispose(); this.routerDisposable = attachRouter(view.webview, { From a1f37cfcf07dd0d1d0d5cf802b786d94d405a39a Mon Sep 17 00:00:00 2001 From: Edgar Twigg Date: Fri, 17 Apr 2026 16:16:53 -0700 Subject: [PATCH 16/20] Fix VS Code view reconnect layout --- docs/specs/layout.md | 5 +- docs/specs/vscode.md | 4 +- lib/src/components/Pond.tsx | 10 ++- lib/src/lib/reconnect.test.ts | 160 ++++++++++++++++++++++++++++++++++ lib/src/lib/reconnect.ts | 60 ++++++++++--- vscode-ext/src/pty-manager.ts | 6 ++ 6 files changed, 227 insertions(+), 18 deletions(-) create mode 100644 lib/src/lib/reconnect.test.ts diff --git a/docs/specs/layout.md b/docs/specs/layout.md index f8410ea..c1e867c 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -263,9 +263,10 @@ Pane IDs are session IDs. `TerminalPane` calls `getOrCreateTerminal(id)` on moun Layout, scrollback, cwd, detached items, and alarm state are saved to persistent storage via a debounced save (500ms). Saves are triggered by layout changes, panel add/remove, and a 30s periodic interval. Saves are flushed immediately on PTY exit, `pagehide`, and extension shutdown requests. On startup, recovery is priority-based: -1. **Live PTYs** (webview hidden/shown): request PTY list + replay data from platform, `reconnectTerminal()` for each (500ms timeout) +1. **Live PTYs** (webview hidden/shown): request PTY list + replay data from platform, `reconnectTerminal()` for each (500ms timeout). If the saved session covers every live PTY, restore the saved dockview layout when its visible panel set matches and restore saved detached items as doors. 2. **Saved session** (app restart): restore layout from serialized dockview state, `restoreTerminal()` for each pane with saved cwd + scrollback -3. **Empty state**: create a single new pane +3. **Fallback/manual pane creation**: when no saved layout can be safely applied, add multiple panes as splits from the previous pane rather than tabs +4. **Empty state**: create a single new pane ### Session UI state diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 82528d3..7edf617 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -110,8 +110,9 @@ Extension Host (always running while extension is active) 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. +- When the view becomes visible again, the webview reconnects to still-owned PTYs and reapplies the saved visible-pane layout when the saved session covers the live PTY set and the layout's visible panels match. - Each message router tracks which PTYs it owns; PTYs cannot be stolen by another router. +- Explicitly killed PTYs are tombstoned in the extension host so a late child-process `exit` event cannot recreate their buffer and make them reconnectable. - Multiple VS Code windows each get their own extension host process, and therefore their own pty-host child process. #### PTY buffering @@ -132,6 +133,7 @@ Both are capped at 1M chars per PTY. When the cap is reached, oldest chunks are - { 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 +5. If the saved session covers those live PTYs, the frontend uses the saved dockview layout when its visible panels match and restores saved detached doors; detached PTYs reconnect into the registry but remain doors instead of visible panes ``` 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. diff --git a/lib/src/components/Pond.tsx b/lib/src/components/Pond.tsx index 1081171..7b99637 100644 --- a/lib/src/components/Pond.tsx +++ b/lib/src/components/Pond.tsx @@ -1219,7 +1219,15 @@ export function Pond({ if (defaults?.shell) { setPendingShellOpts(id, { shell: defaults.shell, args: defaults.args }); } - e.api.addPanel({ id, component: 'terminal', tabComponent: 'terminal', title: '' }); + const referencePanel = e.api.panels[e.api.panels.length - 1] ?? null; + const direction = referencePanel && referencePanel.api.width - referencePanel.api.height > 0 ? 'right' : 'below'; + e.api.addPanel({ + id, + component: 'terminal', + tabComponent: 'terminal', + title: '', + position: referencePanel ? { referencePanel: referencePanel.id, direction } : undefined, + }); }; if (layout && restored && restored.length > 0) { diff --git a/lib/src/lib/reconnect.test.ts b/lib/src/lib/reconnect.test.ts new file mode 100644 index 0000000..5657087 --- /dev/null +++ b/lib/src/lib/reconnect.test.ts @@ -0,0 +1,160 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { PlatformAdapter, PtyInfo } from './platform/types'; +import type { PersistedSession } from './session-types'; + +const terminalRegistryMocks = vi.hoisted(() => ({ + reconnectTerminal: vi.fn(), + restoreTerminal: vi.fn(), +})); + +vi.mock('./terminal-registry', () => ({ + reconnectTerminal: terminalRegistryMocks.reconnectTerminal, + restoreTerminal: terminalRegistryMocks.restoreTerminal, +})); + +import { reconnectFromInit } from './reconnect'; + +function createPlatform(ptys: PtyInfo[], savedState: PersistedSession | null): PlatformAdapter { + const listHandlers = new Set<(detail: { ptys: PtyInfo[] }) => void>(); + const replayHandlers = new Set<(detail: { id: string; data: string }) => void>(); + + return { + init: async () => {}, + shutdown: () => {}, + getAvailableShells: vi.fn(async () => []), + spawnPty: vi.fn(), + writePty: vi.fn(), + resizePty: vi.fn(), + killPty: vi.fn(), + getCwd: vi.fn(async () => null), + getScrollback: vi.fn(async () => null), + onPtyData: vi.fn(), + offPtyData: vi.fn(), + onPtyExit: vi.fn(), + offPtyExit: vi.fn(), + requestInit: vi.fn(() => { + for (const handler of listHandlers) handler({ ptys }); + for (const pty of ptys) { + for (const handler of replayHandlers) handler({ id: pty.id, data: `${pty.id}-replay` }); + } + }), + onPtyList: (handler) => { listHandlers.add(handler); }, + offPtyList: (handler) => { listHandlers.delete(handler); }, + onPtyReplay: (handler) => { replayHandlers.add(handler); }, + offPtyReplay: (handler) => { replayHandlers.delete(handler); }, + onRequestSessionFlush: vi.fn(), + offRequestSessionFlush: vi.fn(), + notifySessionFlushComplete: vi.fn(), + alarmRemove: vi.fn(), + alarmToggle: vi.fn(), + alarmDisable: vi.fn(), + alarmDismiss: vi.fn(), + alarmDismissOrToggle: vi.fn(), + alarmAttend: vi.fn(), + alarmResize: vi.fn(), + alarmClearAttention: vi.fn(), + alarmToggleTodo: vi.fn(), + alarmMarkTodo: vi.fn(), + alarmClearTodo: vi.fn(), + onAlarmState: vi.fn(), + offAlarmState: vi.fn(), + saveState: vi.fn(), + getState: vi.fn(() => savedState), + }; +} + +describe('reconnectFromInit', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('restores saved visible layout and detached doors for matching live PTYs', async () => { + const layout = { + panels: { + 'pane-a': {}, + 'pane-b': {}, + }, + }; + const detached = [{ + id: 'pane-c', + title: 'Pane C', + neighborId: 'pane-b', + direction: 'right' as const, + remainingPanelIds: ['pane-a', 'pane-b'], + restoreLayout: layout, + detachedLayoutSignature: 'sig', + }]; + const saved: PersistedSession = { + version: 1, + layout, + detached, + panes: [ + { id: 'pane-a', title: 'Pane A', cwd: null, scrollback: null, resumeCommand: null }, + { id: 'pane-b', title: 'Pane B', cwd: null, scrollback: null, resumeCommand: null }, + { id: 'pane-c', title: 'Pane C', cwd: null, scrollback: null, resumeCommand: null }, + ], + }; + + const result = await reconnectFromInit(createPlatform([ + { id: 'pane-a', alive: true }, + { id: 'pane-b', alive: true }, + { id: 'pane-c', alive: true }, + ], saved)); + + expect(result).toEqual({ + paneIds: ['pane-a', 'pane-b'], + detached, + layout, + }); + expect(terminalRegistryMocks.reconnectTerminal).toHaveBeenCalledWith('pane-c', 'pane-c-replay', { + alive: true, + exitCode: undefined, + }); + }); + + it('does not reuse a saved layout when live PTYs do not match saved panes', async () => { + const saved: PersistedSession = { + version: 1, + layout: { panels: { 'pane-a': {}, 'pane-b': {} } }, + panes: [ + { id: 'pane-a', title: 'Pane A', cwd: null, scrollback: null, resumeCommand: null }, + { id: 'pane-b', title: 'Pane B', cwd: null, scrollback: null, resumeCommand: null }, + ], + }; + + const result = await reconnectFromInit(createPlatform([ + { id: 'pane-a', alive: true }, + { id: 'pane-b', alive: true }, + { id: 'extra-pane', alive: true }, + ], saved)); + + expect(result).toEqual({ + paneIds: ['pane-a', 'pane-b', 'extra-pane'], + detached: [], + }); + }); + + it('ignores stale saved panes when the saved layout still matches live visible panes', async () => { + const layout = { panels: { 'pane-a': {}, 'pane-b': {} } }; + const saved: PersistedSession = { + version: 1, + layout, + panes: [ + { id: 'pane-a', title: 'Pane A', cwd: null, scrollback: null, resumeCommand: null }, + { id: 'pane-b', title: 'Pane B', cwd: null, scrollback: null, resumeCommand: null }, + { id: 'stale-pane', title: 'Stale Pane', cwd: null, scrollback: null, resumeCommand: null }, + ], + }; + + const result = await reconnectFromInit(createPlatform([ + { id: 'pane-a', alive: true }, + { id: 'pane-b', alive: true }, + ], saved)); + + expect(result).toEqual({ + paneIds: ['pane-a', 'pane-b'], + detached: [], + layout, + }); + }); +}); diff --git a/lib/src/lib/reconnect.ts b/lib/src/lib/reconnect.ts index f58c994..0a999e4 100644 --- a/lib/src/lib/reconnect.ts +++ b/lib/src/lib/reconnect.ts @@ -71,20 +71,16 @@ function reconnectLivePtys(platform: PlatformAdapter): Promise }); ids.push(pty.id); } - // Pull the saved layout so reconnect (e.g. after panel close/reopen) - // restores splits instead of stacking every pane into one tab group. - // Only use it if the pane set matches — otherwise dockview would - // create ghost panels for killed PTYs. - const saved = platform.getState() as PersistedSession | null; - const liveSet = new Set(ids); - const savedIds = saved?.panes?.map((p) => p.id) ?? []; - const layoutMatches = - savedIds.length === ids.length && savedIds.every((id) => liveSet.has(id)); - resolve({ - paneIds: ids, - detached: [], - layout: layoutMatches ? saved?.layout : undefined, - }); + // Pull saved visible/detached state so reconnect (e.g. after panel + // close/reopen) restores splits and doors instead of stacking every live + // PTY into one tab group. + const savedPlan = getSavedLiveReconnectPlan(platform.getState(), ids); + if (savedPlan) { + resolve(savedPlan); + return; + } + + resolve({ paneIds: ids, detached: [] }); } platform.onPtyList(handleList); @@ -92,3 +88,39 @@ function reconnectLivePtys(platform: PlatformAdapter): Promise platform.requestInit(); }); } + +function getSavedLiveReconnectPlan(savedState: unknown, liveIds: string[]): ReconnectResult | null { + const saved = savedState as PersistedSession | null; + if (!saved || saved.version !== 1 || !Array.isArray(saved.panes)) return null; + + // Reuse persisted visible/detached state only when every live PTY is covered + // by the saved session. Extra saved panes can be stale, but extra live panes + // have no reliable saved layout position. + const liveSet = new Set(liveIds); + const savedSet = new Set(saved.panes.map((p) => p.id)); + if (!liveIds.every((id) => savedSet.has(id))) return null; + + const detached = (saved.detached ?? []).filter((item) => liveSet.has(item.id)); + const detachedIds = new Set(detached.map((item) => item.id)); + const paneIds = saved.panes + .filter((pane) => liveSet.has(pane.id) && !detachedIds.has(pane.id)) + .map((pane) => pane.id); + const layoutPanelIds = getLayoutPanelIds(saved.layout); + const layoutMatchesVisiblePanes = + !!layoutPanelIds && + layoutPanelIds.length === paneIds.length && + layoutPanelIds.every((id) => paneIds.includes(id)); + + return { + paneIds, + detached, + layout: layoutMatchesVisiblePanes ? saved.layout : undefined, + }; +} + +function getLayoutPanelIds(layout: unknown): string[] | null { + if (!layout || typeof layout !== 'object') return null; + const panels = (layout as { panels?: unknown }).panels; + if (!panels || typeof panels !== 'object' || Array.isArray(panels)) return null; + return Object.keys(panels); +} diff --git a/vscode-ext/src/pty-manager.ts b/vscode-ext/src/pty-manager.ts index fca7850..d552651 100644 --- a/vscode-ext/src/pty-manager.ts +++ b/vscode-ext/src/pty-manager.ts @@ -19,6 +19,7 @@ interface PtyBufferEntry { const MAX_BUFFER_CHARS = 1_000_000; const ptyBuffers = new Map(); +const killedPtyIds = new Set(); function trimChunks(chunks: string[], totalChars: number): number { while (totalChars > MAX_BUFFER_CHARS && chunks.length > 1) { @@ -40,6 +41,7 @@ function createBufferEntry(alive: boolean, exitCode?: number): PtyBufferEntry { } function bufferData(id: string, data: string): void { + if (killedPtyIds.has(id)) return; let entry = ptyBuffers.get(id); if (!entry) { entry = createBufferEntry(true); @@ -55,6 +57,7 @@ function bufferData(id: string, data: string): void { } function bufferExit(id: string, exitCode: number): void { + if (killedPtyIds.has(id)) return; let entry = ptyBuffers.get(id); if (!entry) { entry = createBufferEntry(false, exitCode); @@ -207,6 +210,7 @@ function sendToChild(msg: any): void { } export function spawn(id: string, options?: { cols?: number; rows?: number; cwd?: string; shell?: string; args?: string[] }): void { + killedPtyIds.delete(id); ptyBuffers.set(id, createBufferEntry(true)); sendToChild({ type: 'spawn', id, cols: options?.cols || 80, rows: options?.rows || 30, cwd: options?.cwd, shell: options?.shell, args: options?.args }); } @@ -276,6 +280,7 @@ export function resize(id: string, cols: number, rows: number): void { } export function kill(id: string): void { + killedPtyIds.add(id); ptyBuffers.delete(id); sendToChild({ type: 'kill', id }); } @@ -301,6 +306,7 @@ export function gracefulKillAll(timeoutMs = 2000): Promise { export function killAll(): void { ptyBuffers.clear(); + killedPtyIds.clear(); if (child?.connected) { child.send({ type: 'killAll' }); child.kill(); From 0e25bd735c6ecac8d11182c1ad0125bc4744eb64 Mon Sep 17 00:00:00 2001 From: Edgar Twigg Date: Fri, 17 Apr 2026 16:25:28 -0700 Subject: [PATCH 17/20] Use selected shell for restored sessions --- docs/specs/layout.md | 2 +- docs/specs/vscode.md | 2 +- lib/src/lib/session-restore.test.ts | 86 +++++++++++++++++++++++++++++ lib/src/lib/session-restore.ts | 5 +- lib/src/lib/terminal-registry.ts | 4 +- 5 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 lib/src/lib/session-restore.test.ts diff --git a/docs/specs/layout.md b/docs/specs/layout.md index c1e867c..6834ee4 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -264,7 +264,7 @@ Layout, scrollback, cwd, detached items, and alarm state are saved to persistent On startup, recovery is priority-based: 1. **Live PTYs** (webview hidden/shown): request PTY list + replay data from platform, `reconnectTerminal()` for each (500ms timeout). If the saved session covers every live PTY, restore the saved dockview layout when its visible panel set matches and restore saved detached items as doors. -2. **Saved session** (app restart): restore layout from serialized dockview state, `restoreTerminal()` for each pane with saved cwd + scrollback +2. **Saved session** (app restart): restore layout from serialized dockview state, `restoreTerminal()` for each pane with saved cwd + scrollback, and spawn each PTY with the current default shell selection 3. **Fallback/manual pane creation**: when no saved layout can be safely applied, add multiple panes as splits from the previous pane rather than tabs 4. **Empty state**: create a single new pane diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 7edf617..d6ebef6 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -136,7 +136,7 @@ Both are capped at 1M chars per PTY. When the cap is reached, oldest chunks are 5. If the saved session covers those live PTYs, the frontend uses the saved dockview layout when its visible panels match and restores saved detached doors; detached PTYs reconnect into the registry but remain doors instead of visible panes ``` -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. +For cold-start restore (no live PTYs), the webview falls back to saved session state: spawns new PTYs in saved CWDs using the currently selected MouseTerm shell, 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. ### Message protocol diff --git a/lib/src/lib/session-restore.test.ts b/lib/src/lib/session-restore.test.ts new file mode 100644 index 0000000..a58d4e3 --- /dev/null +++ b/lib/src/lib/session-restore.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { PlatformAdapter } from './platform/types'; +import type { PersistedSession } from './session-types'; + +const terminalRegistryMocks = vi.hoisted(() => ({ + getDefaultShellOpts: vi.fn(), + restoreTerminal: vi.fn(), +})); + +vi.mock('./terminal-registry', () => ({ + getDefaultShellOpts: terminalRegistryMocks.getDefaultShellOpts, + restoreTerminal: terminalRegistryMocks.restoreTerminal, +})); + +import { restoreSession } from './session-restore'; + +function createPlatform(savedState: PersistedSession | null): PlatformAdapter { + return { + init: async () => {}, + shutdown: () => {}, + getAvailableShells: vi.fn(async () => []), + spawnPty: vi.fn(), + writePty: vi.fn(), + resizePty: vi.fn(), + killPty: vi.fn(), + getCwd: vi.fn(async () => null), + getScrollback: vi.fn(async () => null), + onPtyData: vi.fn(), + offPtyData: vi.fn(), + onPtyExit: vi.fn(), + offPtyExit: vi.fn(), + requestInit: vi.fn(), + onPtyList: vi.fn(), + offPtyList: vi.fn(), + onPtyReplay: vi.fn(), + offPtyReplay: vi.fn(), + onRequestSessionFlush: vi.fn(), + offRequestSessionFlush: vi.fn(), + notifySessionFlushComplete: vi.fn(), + alarmRemove: vi.fn(), + alarmToggle: vi.fn(), + alarmDisable: vi.fn(), + alarmDismiss: vi.fn(), + alarmDismissOrToggle: vi.fn(), + alarmAttend: vi.fn(), + alarmResize: vi.fn(), + alarmClearAttention: vi.fn(), + alarmToggleTodo: vi.fn(), + alarmMarkTodo: vi.fn(), + alarmClearTodo: vi.fn(), + onAlarmState: vi.fn(), + offAlarmState: vi.fn(), + saveState: vi.fn(), + getState: vi.fn(() => savedState), + }; +} + +describe('restoreSession', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('spawns restored terminals with the configured default shell', () => { + terminalRegistryMocks.getDefaultShellOpts.mockReturnValue({ + shell: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', + args: ['-NoLogo'], + }); + const saved: PersistedSession = { + version: 1, + layout: { panels: { 'pane-a': {} } }, + panes: [ + { id: 'pane-a', title: 'Pane A', cwd: 'C:\\repo', scrollback: 'hello', resumeCommand: null }, + ], + }; + + restoreSession(createPlatform(saved)); + + expect(terminalRegistryMocks.restoreTerminal).toHaveBeenCalledWith('pane-a', { + cwd: 'C:\\repo', + scrollback: 'hello', + title: 'Pane A', + shell: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', + args: ['-NoLogo'], + }); + }); +}); diff --git a/lib/src/lib/session-restore.ts b/lib/src/lib/session-restore.ts index 96011bf..a5dbb7c 100644 --- a/lib/src/lib/session-restore.ts +++ b/lib/src/lib/session-restore.ts @@ -1,6 +1,6 @@ import type { PlatformAdapter } from './platform/types'; import type { PersistedDetachedItem, PersistedSession } from './session-types'; -import { restoreTerminal } from './terminal-registry'; +import { getDefaultShellOpts, restoreTerminal } from './terminal-registry'; export interface RestoredSession { paneIds: string[]; @@ -13,12 +13,15 @@ export function restoreSession(platform: PlatformAdapter): RestoredSession | nul if (!saved || saved.version !== 1 || !saved.panes || saved.panes.length === 0) return null; const detached = saved.detached ?? []; const detachedIds = new Set(detached.map((item) => item.id)); + const shellOpts = getDefaultShellOpts(); for (const pane of saved.panes) { restoreTerminal(pane.id, { cwd: pane.cwd, scrollback: pane.scrollback, title: pane.title, + shell: shellOpts?.shell, + args: shellOpts?.args, }); } diff --git a/lib/src/lib/terminal-registry.ts b/lib/src/lib/terminal-registry.ts index f871e82..a0a2816 100644 --- a/lib/src/lib/terminal-registry.ts +++ b/lib/src/lib/terminal-registry.ts @@ -467,7 +467,7 @@ export function reconnectTerminal( */ export function restoreTerminal( id: string, - opts: { cwd?: string | null; scrollback?: string | null; title?: string; cwdWarning?: string | null }, + opts: { cwd?: string | null; scrollback?: string | null; title?: string; cwdWarning?: string | null; shell?: string; args?: string[] }, ): TerminalEntry { const existing = registry.get(id); if (existing) return existing; @@ -493,6 +493,8 @@ export function restoreTerminal( cols: dims?.cols || 80, rows: dims?.rows || 30, cwd: opts.cwd ?? undefined, + shell: opts.shell, + args: opts.args, }); return entry; From d9bf0eff97f9b8ecfaca0f15eb0bd199dbc9ace3 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Mon, 20 Apr 2026 21:42:58 +0000 Subject: [PATCH 18/20] Codex review R1: clear workspace shell override when setting global scope --- vscode-ext/src/shell-selection.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/vscode-ext/src/shell-selection.ts b/vscode-ext/src/shell-selection.ts index 9bbe329..7fe861c 100644 --- a/vscode-ext/src/shell-selection.ts +++ b/vscode-ext/src/shell-selection.ts @@ -20,6 +20,9 @@ export async function setSelectedShellPath( if (scope === 'workspace') { await context.workspaceState.update(KEY, path); } else { + // Clear any workspace-scoped value so it doesn't shadow the new global + // setting (getSelectedShellPath checks workspaceState first). + await context.workspaceState.update(KEY, undefined); await context.globalState.update(KEY, path); } } From d83f3133a1cb46f8985fa639678412ce84a14d01 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Mon, 20 Apr 2026 22:56:06 +0000 Subject: [PATCH 19/20] Claude Code review R1: break circular dep, fix re-entrant addPanel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract setDefaultShellOpts/getDefaultShellOpts into shell-defaults.ts to break circular dependency (terminal-registry → platform → vscode-adapter → terminal-registry) that caused all 34 alarm tests to fail. - Update vscode-adapter.ts and standalone AppBar.tsx to import from shell-defaults.ts directly; terminal-registry.ts re-exports for API compat. - Wrap onDidRemovePanel auto-spawn in setTimeout(0) to avoid re-entrant api.addPanel call which dockview silently drops. --- lib/src/components/Pond.tsx | 13 ++++++++++--- lib/src/lib/platform/vscode-adapter.ts | 2 +- lib/src/lib/shell-defaults.ts | 16 ++++++++++++++++ lib/src/lib/terminal-registry.ts | 16 ++++------------ standalone/src/AppBar.tsx | 2 +- 5 files changed, 32 insertions(+), 17 deletions(-) create mode 100644 lib/src/lib/shell-defaults.ts diff --git a/lib/src/components/Pond.tsx b/lib/src/components/Pond.tsx index 7b99637..8ddce11 100644 --- a/lib/src/components/Pond.tsx +++ b/lib/src/components/Pond.tsx @@ -1314,11 +1314,18 @@ export function Pond({ // detachedRef is updated AFTER removePanel returns, so detachedRef.current.length // is still 0 here — which is correct: we want a new pane when the last visible // pane is detached (the door isn't a pane). + // + // Defer via setTimeout so api.addPanel is not called re-entrantly from + // inside the onDidRemovePanel handler — dockview silently drops the spawn + // in that case. e.api.onDidRemovePanel(() => { if (e.api.totalPanels === 0 && detachedRef.current.length === 0) { - const id = generatePaneId(); - addTerminalPanel(id); - selectPanel(id); + setTimeout(() => { + if (e.api.totalPanels > 0) return; + const id = generatePaneId(); + addTerminalPanel(id); + selectPanel(id); + }, 0); } }); diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index 46e6f99..31c84dc 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -1,5 +1,5 @@ import type { AlarmStateDetail, PlatformAdapter, PtyInfo } from './types'; -import { setDefaultShellOpts } from '../terminal-registry'; +import { setDefaultShellOpts } from '../shell-defaults'; export class VSCodeAdapter implements PlatformAdapter { private vscode: ReturnType; diff --git a/lib/src/lib/shell-defaults.ts b/lib/src/lib/shell-defaults.ts new file mode 100644 index 0000000..3b29998 --- /dev/null +++ b/lib/src/lib/shell-defaults.ts @@ -0,0 +1,16 @@ +// Shared "currently selected" shell, used when spawning without an explicit +// choice (e.g. a keyboard-driven split). Updated by AppBar's ShellDropdown in +// standalone and by the VSCode extension pushing mouseterm:selectedShell. +// +// Extracted into its own module to avoid circular dependencies between +// terminal-registry and platform/vscode-adapter. + +let defaultShellOpts: { shell?: string; args?: string[] } | null = null; + +export function setDefaultShellOpts(opts: { shell?: string; args?: string[] } | null): void { + defaultShellOpts = opts; +} + +export function getDefaultShellOpts(): { shell?: string; args?: string[] } | null { + return defaultShellOpts; +} diff --git a/lib/src/lib/terminal-registry.ts b/lib/src/lib/terminal-registry.ts index a0a2816..62c0825 100644 --- a/lib/src/lib/terminal-registry.ts +++ b/lib/src/lib/terminal-registry.ts @@ -40,18 +40,10 @@ const registry = new Map(); const pendingShellOpts = new Map(); const primedSessionStates = new Map>(); -// Shared "currently selected" shell, used when spawning without an explicit -// choice (e.g. a keyboard-driven split). Updated by AppBar's ShellDropdown in -// standalone and by the VSCode extension pushing mouseterm:selectedShell. -let defaultShellOpts: { shell?: string; args?: string[] } | null = null; - -export function setDefaultShellOpts(opts: { shell?: string; args?: string[] } | null): void { - defaultShellOpts = opts; -} - -export function getDefaultShellOpts(): { shell?: string; args?: string[] } | null { - return defaultShellOpts; -} +// Re-export from shell-defaults to preserve the public API surface. +// The actual state lives in shell-defaults.ts to avoid a circular dependency +// (terminal-registry → platform → vscode-adapter → terminal-registry). +export { setDefaultShellOpts, getDefaultShellOpts } from './shell-defaults'; // --- Watch for VSCode theme changes and re-apply xterm themes --- // VSCode signals theme changes by updating CSS variables and body classes. diff --git a/standalone/src/AppBar.tsx b/standalone/src/AppBar.tsx index af864b3..0810996 100644 --- a/standalone/src/AppBar.tsx +++ b/standalone/src/AppBar.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { getCurrentWindow } from '@tauri-apps/api/window'; import { CaretDownIcon, MinusIcon, CornersOutIcon, CornersInIcon, XIcon, PlusIcon, CheckIcon } from '@phosphor-icons/react'; import { ThemePicker } from '../../lib/src/components/ThemePicker'; -import { setDefaultShellOpts } from '../../lib/src/lib/terminal-registry'; +import { setDefaultShellOpts } from '../../lib/src/lib/shell-defaults'; export interface ShellEntry { name: string; From 6369bd0d4f1d812350d2fb4abcafef8b93097c90 Mon Sep 17 00:00:00 2001 From: Edgar Twigg Date: Mon, 20 Apr 2026 17:22:27 -0700 Subject: [PATCH 20/20] Add alarmDrainTodoBucket to test PlatformAdapter mocks The merged leaky-bucket work added alarmDrainTodoBucket to PlatformAdapter but the mocks in reconnect.test.ts and session-restore.test.ts were not updated, breaking tsc. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/lib/reconnect.test.ts | 1 + lib/src/lib/session-restore.test.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/lib/src/lib/reconnect.test.ts b/lib/src/lib/reconnect.test.ts index 5657087..3e55794 100644 --- a/lib/src/lib/reconnect.test.ts +++ b/lib/src/lib/reconnect.test.ts @@ -56,6 +56,7 @@ function createPlatform(ptys: PtyInfo[], savedState: PersistedSession | null): P alarmToggleTodo: vi.fn(), alarmMarkTodo: vi.fn(), alarmClearTodo: vi.fn(), + alarmDrainTodoBucket: vi.fn(), onAlarmState: vi.fn(), offAlarmState: vi.fn(), saveState: vi.fn(), diff --git a/lib/src/lib/session-restore.test.ts b/lib/src/lib/session-restore.test.ts index a58d4e3..5fdc957 100644 --- a/lib/src/lib/session-restore.test.ts +++ b/lib/src/lib/session-restore.test.ts @@ -48,6 +48,7 @@ function createPlatform(savedState: PersistedSession | null): PlatformAdapter { alarmToggleTodo: vi.fn(), alarmMarkTodo: vi.fn(), alarmClearTodo: vi.fn(), + alarmDrainTodoBucket: vi.fn(), onAlarmState: vi.fn(), offAlarmState: vi.fn(), saveState: vi.fn(),