Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3e0a93d
Pin AppBar right-side controls to the window edge.
nedtwigg Apr 16, 2026
d6ea784
Remove project dir from standalone AppBar.
nedtwigg Apr 16, 2026
1d7b7b4
Rework ShellDropdown as [+] [shell-name v] with a selection dropdown.
nedtwigg Apr 16, 2026
fda7670
Left-align the shell dropdown popup.
nedtwigg Apr 16, 2026
3d0ecd0
Merge branch 'main' into feat/win-app-bar
nedtwigg Apr 16, 2026
73828fd
Add native shell-picker and new-terminal buttons to the VSCode view.
nedtwigg Apr 17, 2026
959fe1c
Drop projectDir/homeDir from AppBar stories.
nedtwigg Apr 17, 2026
f5117e9
Show the selected shell name in the MouseTerm view header.
nedtwigg Apr 17, 2026
b5541d9
Fix cold-start race in pty-manager IPC request helpers.
nedtwigg Apr 17, 2026
e8cb75f
Fix Windows shell-detection timeout and cold-start latency.
nedtwigg Apr 17, 2026
b10da5e
Carry the selected shell into split-spawns.
nedtwigg Apr 17, 2026
a917ef7
Apply selected shell to auto-respawn; move picker to status bar.
nedtwigg Apr 17, 2026
dfe905c
Revert shell picker to view/title icon; keep name in description.
nedtwigg Apr 17, 2026
64d4635
Use a gear icon for the shell picker in the view title.
nedtwigg Apr 17, 2026
4ebe49f
Preserve layout across VSCode panel collapse/re-expand.
nedtwigg Apr 17, 2026
9cebd81
Fix layout loss on panel reopen and wrong first-terminal shell.
nedtwigg Apr 17, 2026
a1f37cf
Fix VS Code view reconnect layout
nedtwigg Apr 17, 2026
0e25bd7
Use selected shell for restored sessions
nedtwigg Apr 17, 2026
d9bf0ef
Codex review R1: clear workspace shell override when setting global s…
Apr 20, 2026
d83f313
Claude Code review R1: break circular dep, fix re-entrant addPanel
Apr 20, 2026
6618a10
Merge branch 'main' into feat/win-app-bar
nedtwigg Apr 20, 2026
6369bd0
Add alarmDrainTodoBucket to test PlatformAdapter mocks
nedtwigg Apr 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions docs/specs/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
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
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, 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

### Session UI state

Expand Down
6 changes: 4 additions & 2 deletions docs/specs/vscode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -132,9 +133,10 @@ 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.
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

Expand Down
32 changes: 29 additions & 3 deletions lib/src/components/Pond.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
destroyTerminal,
swapTerminals,
setPendingShellOpts,
getDefaultShellOpts,
type SessionStatus,
isSoftTodo,
isHardTodo,
Expand Down Expand Up @@ -1399,6 +1400,26 @@ 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 });
}
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: '<unnamed>',
position: referencePanel ? { referencePanel: referencePanel.id, direction } : undefined,
});
};

if (layout && restored && restored.length > 0) {
// Cold-start restore: apply saved dockview layout (includes panel arrangement)
try {
Expand All @@ -1407,7 +1428,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: '<unnamed>' });
addTerminalPanel(id);
}
setSelectedId(restored[0]);
}
Expand All @@ -1417,7 +1438,7 @@ export function Pond({
? restored
: [generatePaneId()];
for (const id of paneIds) {
e.api.addPanel({ id, component: 'terminal', tabComponent: 'terminal', title: '<unnamed>' });
addTerminalPanel(id);
}
setSelectedId(paneIds[0]);
}
Expand Down Expand Up @@ -1948,6 +1969,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 });
}
// Horizontal split places the new pane to the right → reveal from its left edge.
// Vertical split places it below → reveal from its top edge.
freshlySpawnedRef.current.set(newId, direction === 'right' ? 'left' : 'top');
Expand All @@ -1960,7 +1986,7 @@ export function Pond({
});
selectPanel(newId);
onEventRef.current?.({ type: 'split', direction: splitDirection, source });
}, [selectPanel]);
}, [selectPanel, generatePaneId]);

// --- Pond actions (for tab buttons) ---

Expand Down
25 changes: 24 additions & 1 deletion lib/src/lib/platform/vscode-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { AlarmStateDetail, PlatformAdapter, PtyInfo } from './types';
import { setDefaultShellOpts } from '../shell-defaults';

export class VSCodeAdapter implements PlatformAdapter {
private vscode: ReturnType<typeof acquireVsCodeApi>;
Expand All @@ -13,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;
Expand Down Expand Up @@ -41,6 +52,12 @@ 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 },
}));
} else if (msg.type === 'mouseterm:selectedShell') {
setDefaultShellOpts(msg.shell ? { shell: msg.shell, args: msg.args } : null);
}
});
}
Expand Down Expand Up @@ -228,6 +245,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;
}
}
161 changes: 161 additions & 0 deletions lib/src/lib/reconnect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
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(),
alarmDrainTodoBucket: 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,
});
});
});
47 changes: 46 additions & 1 deletion lib/src/lib/reconnect.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -71,6 +71,15 @@ function reconnectLivePtys(platform: PlatformAdapter): Promise<ReconnectResult>
});
ids.push(pty.id);
}
// 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: [] });
}

Expand All @@ -79,3 +88,39 @@ function reconnectLivePtys(platform: PlatformAdapter): Promise<ReconnectResult>
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);
}
Loading
Loading