feat(terminal): restore each project's terminal tabs (#1526)

Restarting the app dropped every open terminal. Each project now remembers its terminal tabs: their working directories, renames and the split layout. They come back as new shells in the saved directories (scrollback is not kept; a predefined shell is started again, a one-shot command is not). Switching projects in a window saves the old project's tabs and opens the new one's.

xterm now opens only once its pane has a size: opening it while the tool window was still 0x0 (its first layout, or a tab under display:none) measured a zero cell and left the first tab unable to take keys. A hidden tab stays closed until it is shown, and focus requested before xterm opens is applied when it does.

Co-authored-by: tomgor <[email protected]>
Цей коміт міститься в:
tomgorтаtomgor authored and GitHub committed 2026-11-05 21:06:10 +08:00
1 джерело 253fcfb7e0
коміт 8c915664e9
9 files changed
+466 -29

No files matched your search

+15 -4
Переглянути файл
@@ -32,6 +32,8 @@ import {
moveTabToSplit,
newTab,
publishFocus,
refitSessions,
syncTerminalProject,
renameTab,
requestCloseSession,
requestCloseTab,
@@ -61,18 +63,21 @@ function tabTooltip(tab: TerminalTab, sessions: Parameters<typeof tabTitle>[1]):
return cwd ? `${full}\n${cwd}` : full;
}
function TerminalPane({ sessionKey, autofocus }: { sessionKey: string; autofocus: boolean }) {
function TerminalPane({ sessionKey, active, autofocus }: { sessionKey: string; active: boolean; autofocus: boolean }) {
const ref = useRef<HTMLDivElement>(null);
const status = useTerminals((s) => s.sessions[sessionKey]?.status);
const follow = useTerminals((s) => s.sessions[sessionKey]?.follow ?? true);
// Hidden tabs stay unopened. xterm.open() under display:none measures a 0 cell
// and the textarea never accepts input, even after the tab is shown.
useLayoutEffect(() => {
if (!active) return;
const el = ref.current;
const session = getSession(sessionKey);
if (!el || !session) return;
session.attach(el);
return () => session.detach(el);
}, [sessionKey]);
}, [sessionKey, active]);
return (
<div
@@ -231,7 +236,7 @@ function TabGroup({ group, corners }: { group: string; corners: Corners | null }
className={styles.panel}
hidden={t.id !== selected}
>
<TerminalPane sessionKey={t.session} autofocus={t.id === activeTab} />
<TerminalPane sessionKey={t.session} active={t.id === selected} autofocus={t.id === activeTab} />
</div>
))}
</div>
@@ -278,7 +283,13 @@ export function TerminalView() {
const el = rootRef.current;
if (!root || !el) return;
const ensureTab = () => {
if (el.clientHeight > 0 && useTerminals.getState().tabs.length === 0) newTab(root);
if (el.clientHeight <= 0) return;
// Restore this project's tabs once the pane is actually visible. A project
// switch while the runtime is loaded restores from the project subscription.
syncTerminalProject(root);
if (useTerminals.getState().tabs.length === 0) newTab(root);
// Sessions created while this window was hidden open once it actually has a box.
refitSessions();
};
ensureTab();
if (typeof ResizeObserver === "undefined") return;
+3 -3
Переглянути файл
@@ -214,12 +214,12 @@ export function activate(): () => void {
if (s.settings?.editor !== prev.settings?.editor) terminalRuntime()?.applySettingsToAll();
}),
onIdeEvent("terminalExited", (e) => terminalRuntime()?.sessionForTerminalId(e.terminal)?.exited(e.code)),
// Sessions belong to the project; closing / switching it ends them.
// Sessions belong to the project. Remember this project's tabs, then open the next one's.
useProject.subscribe((s, prev) => {
const rt = terminalRuntime();
if (rt && s.project?.root !== prev.project?.root) {
if (rt.useTerminalFind.getState().open) rt.closeTerminalFind();
rt.closeAll();
rt.syncTerminalProject(s.project?.root ?? null);
}
}),
];
@@ -242,6 +242,6 @@ export function activate(): () => void {
return () => {
disposers.reverse().forEach((d) => d());
terminalRuntime()?.closeAll();
terminalRuntime()?.unbindTerminals();
};
}
+102
Переглянути файл
@@ -0,0 +1,102 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { decode } from "./persist";
import {
closeAll,
getSession,
layoutGroups,
newTab,
renameTab,
splitTab,
syncTerminalProject,
unbindTerminals,
useTerminals,
} from "./store";
vi.mock("./session", () => ({
TerminalSession: class {
key: string;
state: { cwd: string };
command: { program: string; args: string[]; shell?: boolean } | undefined;
constructor(opts: { key: string; cwd: string; command?: { program: string; args: string[]; shell?: boolean } }) {
this.key = opts.key;
this.state = { cwd: opts.cwd };
this.command = opts.command;
}
dispose() {}
},
}));
const state = () => useTerminals.getState();
const names = () => state().tabs.map((t) => t.customName ?? t.name);
beforeEach(() => {
unbindTerminals();
localStorage.clear();
closeAll();
});
describe("terminal tab memory", () => {
it("rejects a stored value that is not this version", () => {
expect(decode(null)).toBeNull();
expect(decode({ v: 2, tabs: [], layout: "g" })).toBeNull();
expect(decode({ v: 1, tabs: [{}], layout: "g" })).toMatchObject({ tabs: [] });
expect(decode({ v: 1, tabs: "nope", layout: "g" })).toBeNull();
});
it("restores each project's tabs, working directories, renames and splits", () => {
syncTerminalProject("/repo");
const app = newTab("/repo");
renameTab(app.id, "Server");
const logs = newTab("/repo/logs");
const side = splitTab(logs.id, "horizontal")!;
expect(state().activeTab).toBe(side);
getSession(logs.session)!.state.cwd = "/repo/logs/today";
useTerminals.setState((s) => ({
sessions: { ...s.sessions, [logs.session]: { ...s.sessions[logs.session]!, cwd: "/repo/logs/today" } },
}));
syncTerminalProject("/other");
expect(state().tabs).toEqual([]);
newTab("/other");
syncTerminalProject("/repo");
expect(names()).toEqual(["Server", "Local (2)", "Local (3)"]);
const restored = state().tabs;
expect(getSession(restored[0]!.session)?.state.cwd).toBe("/repo");
expect(getSession(restored[1]!.session)?.state.cwd).toBe("/repo/logs/today");
expect(getSession(restored[2]!.session)?.state.cwd).toBe("/repo/logs");
expect(layoutGroups(state().layout)).toHaveLength(2);
expect(restored[2]!.group).not.toBe(restored[1]!.group);
expect(state().activeTab).toBe(restored[2]!.id);
syncTerminalProject("/other");
expect(names()).toEqual(["Local"]);
expect(getSession(state().tabs[0]!.session)?.state.cwd).toBe("/other");
});
it("reopens a predefined shell, and a one-shot command as a plain shell in the same directory", () => {
syncTerminalProject("/repo");
newTab("/repo", "zsh", { program: "/bin/zsh", args: [], shell: true });
newTab("/repo/app", "build", { program: "npm", args: ["test"] });
syncTerminalProject(null);
syncTerminalProject("/repo");
const [shell, command] = state().tabs;
const started = (key: string) =>
getSession(key) as { command?: { program: string; args: string[]; shell?: boolean } } | undefined;
expect(started(shell!.session)?.command).toEqual({ program: "/bin/zsh", args: [], shell: true });
expect(started(command!.session)?.command).toBeUndefined();
expect(getSession(command!.session)?.state.cwd).toBe("/repo/app");
expect(command!.name).toBe("build");
});
it("keeps the saved tabs when the terminal runtime is dropped", () => {
syncTerminalProject("/repo");
const tab = newTab("/repo");
renameTab(tab.id, "Ship");
unbindTerminals();
expect(state().tabs).toEqual([]);
syncTerminalProject("/repo");
expect(names()).toEqual(["Ship"]);
expect(getSession(state().tabs[0]!.session)?.state.cwd).toBe("/repo");
});
});
+107
Переглянути файл
@@ -0,0 +1,107 @@
/**
* Terminal tabs remembered per project: which tabs were open, each one's
* working directory and rename, and how the tab groups were split. Shell
* scrollback is not kept; a tab comes back as a new session in that directory.
*/
import type { GroupLayout, SplitOrientation } from "./store";
const STORAGE_PREFIX = "openidea.terminal.tabs.v1:";
interface StoredTab {
id: string;
name: string;
customName: string | null;
cwd: string;
group: string;
/** Predefined shell program. A one-shot command is not replayed. */
shell: string | null;
}
export interface StoredTerminals {
v: 1;
tabs: StoredTab[];
layout: GroupLayout;
selected: Record<string, string>;
activeTab: string | null;
}
const MAX_TABS = 32;
export function storageKey(root: string): string {
return STORAGE_PREFIX + root;
}
export function readStored(root: string): StoredTerminals | null {
try {
return decode(JSON.parse(localStorage.getItem(storageKey(root)) ?? "null"));
} catch {
return null;
}
}
export function writeStored(root: string, stored: StoredTerminals) {
try {
localStorage.setItem(storageKey(root), JSON.stringify(stored));
} catch {
/* storage unavailable or full: the tabs just aren't remembered */
}
}
export function decode(raw: unknown): StoredTerminals | null {
if (!raw || typeof raw !== "object") return null;
const v = raw as Record<string, unknown>;
if (v.v !== 1 || !Array.isArray(v.tabs)) return null;
const layout = decodeLayout(v.layout);
if (!layout) return null;
const groups = new Set(groupIds(layout));
const fallback = groupIds(layout)[0];
if (!fallback) return null;
const tabs: StoredTab[] = [];
const ids = new Set<string>();
for (const item of v.tabs) {
if (tabs.length >= MAX_TABS) break;
const tab = decodeTab(item, groups, fallback);
if (!tab || ids.has(tab.id)) continue;
ids.add(tab.id);
tabs.push(tab);
}
const selected: Record<string, string> = {};
if (v.selected && typeof v.selected === "object") {
for (const [group, id] of Object.entries(v.selected as Record<string, unknown>)) {
if (groups.has(group) && typeof id === "string" && ids.has(id)) selected[group] = id;
}
}
const activeTab = typeof v.activeTab === "string" && ids.has(v.activeTab) ? v.activeTab : null;
return { v: 1, tabs, layout, selected, activeTab };
}
function decodeTab(item: unknown, groups: Set<string>, fallback: string): StoredTab | null {
if (!item || typeof item !== "object") return null;
const t = item as Record<string, unknown>;
if (typeof t.id !== "string" || !t.id || typeof t.name !== "string" || !t.name.trim()) return null;
if (typeof t.cwd !== "string" || !t.cwd) return null;
const group = typeof t.group === "string" && groups.has(t.group) ? t.group : fallback;
const customName = typeof t.customName === "string" && t.customName.trim() ? t.customName.trim() : null;
const shell = typeof t.shell === "string" && t.shell ? t.shell : null;
return { id: t.id, name: t.name.trim(), customName, cwd: t.cwd, group, shell };
}
function decodeLayout(raw: unknown): GroupLayout | null {
if (typeof raw === "string") return raw || null;
if (!raw || typeof raw !== "object") return null;
const node = raw as Record<string, unknown>;
if (typeof node.id !== "string" || !node.id) return null;
if (node.orientation !== "horizontal" && node.orientation !== "vertical") return null;
if (!Array.isArray(node.children) || node.children.length < 2) return null;
const children: GroupLayout[] = [];
for (const child of node.children) {
const decoded = decodeLayout(child);
if (!decoded) return null;
children.push(decoded);
}
return { id: node.id, orientation: node.orientation as SplitOrientation, children };
}
function groupIds(node: GroupLayout): string[] {
return typeof node === "string" ? [node] : node.children.flatMap(groupIds);
}
+2
Переглянути файл
@@ -5,6 +5,8 @@ export {
applyThemeToAll,
closeAll,
newTab,
syncTerminalProject,
unbindTerminals,
newTabAt,
requestCloseSession,
sessionForTerminalId,
+36 -2
Переглянути файл
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { FIT_INTERVAL_MS } from "./fit";
import { findTerminalLinks, type TerminalLink } from "./links";
import { openFileLink } from "./session";
import { openFileLink, TerminalSession } from "./session";
const ipc = vi.hoisted(() => ({
call: vi.fn(async (v: unknown) => v),
@@ -72,3 +73,36 @@ describe("openFileLink", () => {
expect(workbench.notify).toHaveBeenCalledWith({ level: "warning", title: "File not found", body: "gone.php" });
});
});
describe("terminal open", () => {
afterEach(() => vi.useRealTimers());
it("waits to open xterm until the pane has a size", () => {
vi.useFakeTimers();
const container = document.createElement("div");
document.body.appendChild(container);
const session = new TerminalSession({
key: "s-open",
cwd: "/tmp",
onChange: () => undefined,
onFocus: () => undefined,
});
let width = 0;
let height = 0;
Object.defineProperty(session.host, "offsetWidth", { configurable: true, get: () => width });
Object.defineProperty(session.host, "offsetHeight", { configurable: true, get: () => height });
// jsdom has no canvas, so xterm's real open() cannot build a renderer.
const open = vi.spyOn(session.term, "open").mockImplementation(() => undefined);
session.attach(container);
session.focus();
vi.advanceTimersToNextFrame();
expect(open).not.toHaveBeenCalled();
width = 400;
height = 300;
vi.advanceTimersByTime(FIT_INTERVAL_MS);
expect(open).toHaveBeenCalledTimes(1);
session.dispose();
container.remove();
});
});
+63 -18
Переглянути файл
@@ -181,6 +181,10 @@ export class TerminalSession {
private snapshot: SessionSnapshot;
private ptyId: TerminalId | null = null;
private opened = false;
/** `focus()` arrived before the textarea existed (the pane is focused while xterm is still closed). */
private pendingFocus = false;
/** Bounds retries when the pane has no box yet, or xterm cannot measure a cell. */
private fitAttempts = 0;
private disposed = false;
private disposables: IDisposable[] = [];
private resizeObserver: ResizeObserver | null = null;
@@ -379,30 +383,43 @@ export class TerminalSession {
}));
}
/** Moves the host into `container` and opens / fits / starts as needed. */
/** Moves the host into `container` and fits / starts once the pane has a size. */
attach(container: HTMLElement) {
if (this.disposed) return;
if (this.host.parentElement !== container) container.appendChild(this.host);
if (!this.opened) {
this.opened = true;
// xterm's DOM renderer: the WebGL one rasterizes glyphs per background, so
// selected text rendered at a visibly different weight than the rest.
this.term.open(this.host);
remeasureWhenFontsLoad(
this.term,
fontFor(workflowSettings().terminal),
() => !this.disposed,
() => this.scheduleFit(),
);
this.term.textarea?.addEventListener("focus", () => this.opts.onFocus(this.key));
this.watchImeCommit();
}
this.resizeObserver?.disconnect();
this.resizeObserver = new ResizeObserver(() => this.scheduleFit());
this.resizeObserver = new ResizeObserver(() => {
this.fitAttempts = 0;
this.scheduleFit();
});
this.resizeObserver.observe(container);
this.scheduleFit();
}
/**
* Opens xterm only after the host has a box. `open()` measures the cell size
* immediately; doing it while the tool window is still 0×0 (the first layout
* pass, or a tab under `display: none`) leaves the helper textarea unable to
* take keys for the rest of the session.
*/
private openIfReady() {
if (this.opened || this.disposed) return;
if (this.host.offsetWidth === 0 || this.host.offsetHeight === 0) return;
// xterm's DOM renderer: the WebGL one rasterizes glyphs per background, so
// selected text rendered at a visibly different weight than the rest.
this.term.open(this.host);
this.opened = true;
remeasureWhenFontsLoad(
this.term,
fontFor(workflowSettings().terminal),
() => !this.disposed,
() => this.scheduleFit(),
);
this.term.textarea?.addEventListener("focus", () => this.opts.onFocus(this.key));
this.watchImeCommit();
if (this.pendingFocus) this.focus();
}
detach(container: HTMLElement) {
this.resizeObserver?.disconnect();
this.resizeObserver = null;
@@ -463,9 +480,21 @@ export class TerminalSession {
private doFit() {
if (this.disposed || !this.host.isConnected) return;
if (this.host.offsetWidth === 0 || this.host.offsetHeight === 0) return;
// The grid row is still 0 on the first layout pass (the track is set by a
// parent layout effect after this one). Keep asking until it has a box;
// ResizeObserver also resets the budget when the size actually changes.
if (this.host.offsetWidth === 0 || this.host.offsetHeight === 0) {
if (this.fitAttempts++ < 20) this.scheduleFit();
return;
}
this.openIfReady();
if (!this.opened) return;
const dims = this.fit.proposeDimensions();
if (!dims || !Number.isFinite(dims.cols) || !Number.isFinite(dims.rows)) return;
if (!dims || !Number.isFinite(dims.cols) || !Number.isFinite(dims.rows)) {
if (this.fitAttempts++ < 20) this.scheduleFit();
return;
}
this.fitAttempts = 0;
if (dims.cols !== this.term.cols || dims.rows !== this.term.rows) this.term.resize(dims.cols, dims.rows);
if (this.snapshot.status === "idle") {
void this.start();
@@ -526,6 +555,7 @@ export class TerminalSession {
this.update({ status: "running", title: info.title || "Local" });
this.flushPendingInput();
} catch (e) {
this.pendingInput = "";
this.term.writeln(`\x1b[31mFailed to start terminal: ${e instanceof Error ? e.message : String(e)}\x1b[0m`);
this.term.writeln(RESTART_HINT);
this.update({ status: "error" });
@@ -556,6 +586,7 @@ export class TerminalSession {
exited(code: number | null) {
if (this.disposed) return;
this.ptyId = null;
this.pendingInput = "";
this.update({ status: "exited", exitCode: code });
// Let output still in flight on the channel land first.
setTimeout(() => {
@@ -637,10 +668,24 @@ export class TerminalSession {
if (follow !== this.snapshot.follow) this.update({ follow });
}
/** Asks xterm to take the caret. Remembered when the textarea does not exist yet. */
focus() {
if (this.disposed) return;
if (!this.opened || !this.term.textarea) {
this.pendingFocus = true;
this.scheduleFit();
return;
}
this.pendingFocus = false;
this.term.focus();
}
/** Fits again after the tool window is shown or its box changes. */
refit() {
this.fitAttempts = 0;
this.scheduleFit();
}
applyTheme() {
this.term.options.theme = readTerminalTheme();
this.searchDecorations = null;
+137 -1
Переглянути файл
@@ -9,6 +9,7 @@ import { t } from "@/platform/i18n";
import { basename } from "@/platform/project";
import { workflowSettings } from "@/platform/workflowSettings";
import { confirm } from "@/workbench/messages";
import { readStored, writeStored, type StoredTerminals } from "./persist";
import { TerminalSession, type SessionOptions, type SessionSnapshot } from "./session";
export type SplitOrientation = "horizontal" | "vertical";
@@ -27,6 +28,8 @@ export interface TerminalTab {
name: string;
/** Set by the user via rename; wins over `name`. */
customName: string | null;
/** Predefined shell program. Absent for the default shell and for one-shot commands. */
shell: string | null;
/** Key of the tab's session. */
session: string;
/** The tab group showing it. */
@@ -70,6 +73,11 @@ export function allSessions(): TerminalSession[] {
return [...sessions.values()];
}
/** Refits every live session. The terminal tool window calls this when it gains a box. */
export function refitSessions() {
for (const session of sessions.values()) session.refit();
}
export function sessionForTerminalId(id: number): TerminalSession | undefined {
return allSessions().find((s) => s.terminalId === id);
}
@@ -177,6 +185,7 @@ export function newTab(cwd: string, name?: string, command?: SessionOptions["com
id: `t${seq}`,
name: defaultTabName(name),
customName: null,
shell: command?.shell ? command.program : null,
session: session.key,
group: tabs.find((t) => t.id === activeTab)?.group ?? layoutGroups(layout)[0]!,
};
@@ -373,7 +382,14 @@ export function splitTab(id: string, orientation: SplitOrientation, move = false
return id;
}
const session = createSession(sessions.get(tab.session)?.state.cwd ?? "/");
const copy: TerminalTab = { id: `t${seq}`, name: defaultTabName(), customName: null, session: session.key, group };
const copy: TerminalTab = {
id: `t${seq}`,
name: defaultTabName(),
customName: null,
shell: null,
session: session.key,
group,
};
useTerminals.setState((st) => ({
tabs: [...st.tabs, copy],
layout,
@@ -492,3 +508,123 @@ export function applyThemeToAll() {
export function publishFocus(focused: boolean) {
setContext("terminalFocus", focused);
}
/** Project whose tabs `useTerminals` currently holds. Null before the first bind and after unbind. */
let boundProject: string | null = null;
/** Set while swapping projects, so closing the old tabs is not saved as the new project's layout. */
let restoring = false;
function sessionCwd(key: string): string {
return sessions.get(key)?.state.cwd ?? "";
}
function capture(): StoredTerminals {
const s = useTerminals.getState();
const tabs = s.tabs.flatMap((t) => {
const cwd = sessionCwd(t.session);
return cwd ? [{ id: t.id, name: t.name, customName: t.customName, cwd, group: t.group, shell: t.shell }] : [];
});
return { v: 1, tabs, layout: s.layout, selected: s.selected, activeTab: s.activeTab };
}
function remember() {
if (restoring || !boundProject) return;
writeStored(boundProject, capture());
}
function cwdSignature(s: TerminalState): string {
return s.tabs.map((t) => s.sessions[t.session]?.cwd ?? "").join("\n");
}
useTerminals.subscribe((s, prev) => {
if (restoring || !boundProject) return;
if (
s.tabs === prev.tabs &&
s.layout === prev.layout &&
s.selected === prev.selected &&
s.activeTab === prev.activeTab &&
cwdSignature(s) === cwdSignature(prev)
) {
return;
}
remember();
});
function remapLayout(node: GroupLayout, mapGroup: (group: string) => string): GroupLayout {
if (typeof node === "string") return mapGroup(node);
return {
id: `split${++splitSeq}`,
orientation: node.orientation,
children: node.children.map((child) => remapLayout(child, mapGroup)),
};
}
/** Opens the saved tabs in fresh sessions. Group ids are new, so they don't collide with ones minted later. */
function restore(saved: StoredTerminals) {
const groupOf = new Map<string, string>();
const mapGroup = (group: string) => {
let mapped = groupOf.get(group);
if (!mapped) groupOf.set(group, (mapped = newGroupId()));
return mapped;
};
const layout = remapLayout(saved.layout, mapGroup);
const idOf = new Map<string, string>();
const tabs: TerminalTab[] = [];
for (const savedTab of saved.tabs) {
const session = createSession(
savedTab.cwd,
savedTab.shell ? { program: savedTab.shell, args: [], shell: true } : undefined,
);
const id = `t${seq}`;
idOf.set(savedTab.id, id);
tabs.push({
id,
name: savedTab.name,
customName: savedTab.customName,
shell: savedTab.shell,
session: session.key,
group: mapGroup(savedTab.group),
});
}
const selected: Record<string, string> = {};
for (const group of layoutGroups(layout)) {
const savedGroup = [...groupOf.entries()].find(([, mapped]) => mapped === group)?.[0];
const wanted = savedGroup ? idOf.get(saved.selected[savedGroup] ?? "") : undefined;
const inGroup = tabs.filter((t) => t.group === group);
const id = (wanted && inGroup.some((t) => t.id === wanted) ? wanted : inGroup[0]?.id) ?? "";
if (id) selected[group] = id;
}
const activeTab = (saved.activeTab && idOf.get(saved.activeTab)) || tabs[0]?.id || null;
useTerminals.setState({ tabs, layout, selected, activeTab });
}
/**
* Points the terminal at `root`, remembering the project it was bound to.
* The same root again is a no-op, so a tool window re-render doesn't reopen tabs.
*/
export function syncTerminalProject(root: string | null) {
if (root === boundProject) return;
if (boundProject) writeStored(boundProject, capture());
restoring = true;
boundProject = root;
try {
closeAll();
if (!root) return;
const saved = readStored(root);
if (saved?.tabs.length) restore(saved);
} finally {
restoring = false;
}
}
/** Drops the in-memory sessions without writing the empty layout over the project that owned them. */
export function unbindTerminals() {
if (boundProject) writeStored(boundProject, capture());
restoring = true;
boundProject = null;
try {
closeAll();
} finally {
restoring = false;
}
}
+1 -1
Переглянути файл
@@ -387,7 +387,7 @@ describe("terminalFont", () => {
});
describe("terminal tab titles", () => {
const tab: TerminalTab = { id: "t1", name: "Local", customName: null, session: "s1", group: "g1" };
const tab: TerminalTab = { id: "t1", name: "Local", customName: null, shell: null, session: "s1", group: "g1" };
const snap = (appTitle: string | null, commandRunning = true): Record<string, SessionSnapshot> => ({
s1: { status: "running", exitCode: null, title: "zsh", appTitle, commandRunning, cwd: "/p", follow: true },
});