Skip to content

Commit 65db02f

Browse files
shmuel hizmiclaude
andcommitted
Persist terminal history across tab switches
Render all view children always — each component returns null when inactive instead of being unmounted. This keeps WmuxTerminal instances mounted so their state, subscriptions, and buffers survive tab switches. No replay flash, no hidden elements, no external state management. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 879d694 commit 65db02f

File tree

5 files changed

+43
-21
lines changed

5 files changed

+43
-21
lines changed

packages/wmux-client-terminal/src/components/WmuxApp.tsx

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -335,23 +335,18 @@ export const WmuxApp = (props: {
335335
width={SIDEBAR_WIDTH}
336336
/>
337337

338-
{/* Content area — all children stay mounted, inactive ones hidden */}
338+
{/* Content area — children stay mounted, return null when inactive */}
339339
<box flexGrow={1} flexDirection="column">
340-
{searchOpen ? (
340+
{searchOpen && (
341341
<SearchOverlay
342342
categories={categories}
343343
onSelectCategory={selectCategory}
344344
onSelectTab={selectTab}
345345
onOpenFile={openFile}
346346
onClose={handleSearchClose}
347347
/>
348-
) : null}
349-
{React.Children.map(children, (child) => {
350-
const childId = ((child as React.ReactElement).props as { id?: string }).id;
351-
if (!childId) return null;
352-
if (searchOpen || childId !== activeTabId) return null;
353-
return child;
354-
})}
348+
)}
349+
{children}
355350
{!searchOpen && !hasActiveChild && (
356351
activeTabId.startsWith("file::") ? (
357352
<LocalFileViewer filePath={activeTabId.slice(6)} />

packages/wmux-client-terminal/src/components/WmuxFileContent.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/** @jsxImportSource @opentui/react */
22
import type { ReactNode } from "react";
3+
import { usePrefixContext } from "./FocusContext";
34

45
interface WmuxFileContentProps {
56
readonly id: string;
@@ -9,7 +10,9 @@ interface WmuxFileContentProps {
910
readonly children?: ReactNode;
1011
}
1112

12-
export const WmuxFileContent = ({ name, content }: WmuxFileContentProps): ReactNode => {
13+
export const WmuxFileContent = ({ id, name, content }: WmuxFileContentProps): ReactNode => {
14+
const { activeTabId } = usePrefixContext();
15+
if (activeTabId !== id) return null;
1316
const lines = content.split("\n");
1417
const gutterWidth = String(lines.length).length + 1;
1518

packages/wmux-client-terminal/src/components/WmuxIframe.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/** @jsxImportSource @opentui/react */
22
import type { ReactNode } from "react";
3+
import { usePrefixContext } from "./FocusContext";
34

45
interface WmuxIframeProps {
56
readonly id: string;
@@ -8,7 +9,10 @@ interface WmuxIframeProps {
89
readonly children?: ReactNode;
910
}
1011

11-
export const WmuxIframe = ({ name, url }: WmuxIframeProps): ReactNode => (
12+
export const WmuxIframe = ({ id, name, url }: WmuxIframeProps): ReactNode => {
13+
const { activeTabId } = usePrefixContext();
14+
if (activeTabId !== id) return null;
15+
return (
1216
<box flexGrow={1} justifyContent="center" alignItems="center" flexDirection="column" gap={1}>
1317
<text fg="#98989d">
1418
<strong>{name}</strong>
@@ -18,4 +22,5 @@ export const WmuxIframe = ({ name, url }: WmuxIframeProps): ReactNode => (
1822
</text>
1923
<text fg="#636366">Open in browser to view</text>
2024
</box>
21-
);
25+
);
26+
};

packages/wmux-client-terminal/src/components/WmuxTerminal.tsx

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import { useState, useEffect, useRef, useCallback, type ReactNode } from "react";
33
import { useKeyboard, useTerminalDimensions } from "@opentui/react";
44
import { fromBase64, toBase64 } from "../utils/base64";
5-
import { TerminalBuffer, type StyledLine, type StyledSegment } from "../utils/ansi";
5+
import { getOrCreateBuffer, type StyledLine, type StyledSegment } from "../utils/ansi";
66
import { usePrefixContext } from "./FocusContext";
77

88
const SIDEBAR_WIDTH = 30;
@@ -49,15 +49,11 @@ export const WmuxTerminal = (props: WmuxTerminalProps): ReactNode => {
4949
const [lines, setLines] = useState<readonly StyledLine[]>([]);
5050
const { width, height } = useTerminalDimensions();
5151
const sentResizeRef = useRef<string>("");
52-
const termBufRef = useRef<TerminalBuffer | null>(null);
5352

54-
const getTerminalBuffer = useCallback((): TerminalBuffer => {
55-
if (!termBufRef.current) {
56-
const cols = Math.max(10, width - SIDEBAR_WIDTH - 2);
57-
termBufRef.current = new TerminalBuffer(cols);
58-
}
59-
return termBufRef.current;
60-
}, [width]);
53+
const getTerminalBuffer = useCallback(() => {
54+
const cols = Math.max(10, width - SIDEBAR_WIDTH - 2);
55+
return getOrCreateBuffer(id, cols);
56+
}, [width, id]);
6157

6258
const isActiveTerminal = activeTabId === id && status === "running";
6359

@@ -106,6 +102,9 @@ export const WmuxTerminal = (props: WmuxTerminalProps): ReactNode => {
106102
sendResize({ cols: contentWidth, rows: contentHeight });
107103
}, [width, height, sendResize, getTerminalBuffer]);
108104

105+
// Component stays mounted for state persistence — return null when not visible
106+
if (activeTabId !== id) return null;
107+
109108
if (status === "idle") {
110109
return (
111110
<box flexGrow={1} justifyContent="center" alignItems="center" flexDirection="column" gap={1}>

packages/wmux-client-terminal/src/utils/ansi.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,3 +488,23 @@ export const parseAnsiOutput = (raw: string): readonly StyledLine[] => {
488488
export const createDefaultState = (): { fg: undefined; bg: undefined; bold: false; italic: false; underline: false } => ({
489489
fg: undefined, bg: undefined, bold: false, italic: false, underline: false,
490490
});
491+
492+
// ── Persistent buffer registry ──────────────────────────────
493+
// Buffers survive component unmount/remount so terminal history
494+
// is preserved across tab switches.
495+
496+
const bufferRegistry = new Map<string, TerminalBuffer>();
497+
498+
export const getOrCreateBuffer = (id: string, cols?: number): TerminalBuffer => {
499+
const existing = bufferRegistry.get(id);
500+
if (existing) return existing;
501+
const buf = new TerminalBuffer(cols);
502+
bufferRegistry.set(id, buf);
503+
return buf;
504+
};
505+
506+
export const resetBuffer = (id: string, cols?: number): TerminalBuffer => {
507+
const buf = new TerminalBuffer(cols);
508+
bufferRegistry.set(id, buf);
509+
return buf;
510+
};

0 commit comments

Comments
 (0)