From c533784d65b57ee4ee1be1d92daaa815fb6872e5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 8 Apr 2026 00:02:12 -0700 Subject: [PATCH] feat: add custom title support for terminal panes (#382) * WIP: Changes before auto-review fixes Co-Authored-By: Claude Opus 4.6 * feat: add custom title support for terminal panes Users can set a custom title on individual terminal panes via the right-click context menu ("Set Title...") or by clicking an existing title. Titles display as a minimal floating label at the top of each pane with inline editing (Enter to save, Escape to cancel). An X button on hover removes the title. Titles persist across restarts via titlesByLeafId in TerminalLayoutSnapshot, piggybacking on the existing session:set flow. Co-Authored-By: Claude Opus 4.6 * fix: address review findings for pane title feature - Guard rename input against double-submit on Enter/Escape + blur race - Memoize persistLayoutSnapshot with useCallback to prevent keyboard listener re-registration on every render - Eagerly update paneTitlesRef in onPaneClosed so persistLayoutSnapshot sees correct data before React flushes state - Document portal rendering's implicit dependency on paneTitles state Co-Authored-By: Claude Opus 4.6 * fix: write pane titles in shutdown snapshot even when buffers are empty captureBuffers had an early return when no terminal scrollback content existed, which skipped writing titlesByLeafId to the layout snapshot. This caused titles to be lost on restart for fresh or cleared panes. Co-Authored-By: Claude Opus 4.6 * chore: remove design doc from PR Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- src/renderer/src/assets/main.css | 139 +++++++++++ .../terminal-pane/TerminalContextMenu.tsx | 21 +- .../components/terminal-pane/TerminalPane.tsx | 219 +++++++++++++++++- .../use-terminal-pane-context-menu.ts | 15 +- .../use-terminal-pane-lifecycle.ts | 46 +++- .../src/lib/pane-manager/pane-lifecycle.ts | 10 +- src/shared/types.ts | 3 + 7 files changed, 428 insertions(+), 25 deletions(-) diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 5cde0494ca0..6892303f17d 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1712,6 +1712,145 @@ height 80ms ease; } +/* ── Pane title bar ──────────────────────────────────── */ + +/* Floating tab style — no full-width background bar. The title sits as a + lightweight inline label so the terminal content isn't visually "capped." + Uses muted grey + monospace to read as metadata, not actionable text. */ +.pane-title-bar { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 20px; + z-index: 5; /* below drag handle (z:10) */ + display: flex; + align-items: center; + padding: 0 8px; + font-size: 13px; + font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace; + color: rgba(255, 255, 255, 0.52); + background: transparent; + border-bottom: none; + cursor: pointer; /* click to edit title */ + user-select: none; +} + +.pane-title-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; +} + +.pane-title-close { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + margin-left: auto; + padding: 0; + border: none; + background: transparent; + color: rgba(255, 255, 255, 0.3); + font-size: 14px; + line-height: 14px; + transform: translateY(-1px); + cursor: pointer; + opacity: 0; + transition: + opacity 120ms ease, + color 120ms ease; +} + +.pane-title-bar:hover .pane-title-close { + opacity: 1; +} + +.pane-title-close:hover { + color: rgba(255, 255, 255, 0.8); +} + +/* Inline edit input — matches the title text styling exactly to prevent + layout shift. A subtle bottom border indicates active edit mode. */ +/* When inline-editing, remove the bar's side padding so the input + stretches edge-to-edge; the input carries its own left padding. */ +.pane-title-bar[data-editing] { + padding: 0; +} + +.pane-title-input { + flex: 1; + min-width: 0; + width: 100%; + height: 20px; + box-sizing: border-box; + padding: 0 8px; + margin: 0; + border: none; + border-radius: 0; + background: transparent; + outline: none; + font-size: 13px; + line-height: 20px; + font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace; + color: rgba(255, 255, 255, 0.7); + caret-color: rgba(255, 255, 255, 0.6); +} + +/* Baseline xterm-container layout — the 4px inset padding that pane-lifecycle.ts + previously set as inline styles now lives here so that the data-has-title + CSS attribute selector override can take effect (CSS attribute selectors + cannot override inline styles). */ +.xterm-container { + position: relative; + width: calc(100% - 4px); + height: calc(100% - 4px); + margin-top: 4px; + margin-left: 4px; +} + +/* When a pane has a title, shift the terminal content down to make room. + The .pane container uses position: relative, so the absolutely-positioned + title bar occupies the top 20px. Height is reduced by the full 24px + (20px title bar + 4px base margin-top) to prevent overflow/clipping. */ +.pane[data-has-title] .xterm-container { + margin-top: 24px; /* 20px title bar + 4px base margin-top */ + height: calc(100% - 24px); /* must match margin-top to avoid overflow */ +} + +/* Override the inline overflow:hidden set by pane-manager so the title + separator line can extend into the divider hit-padding zone. + overflow:clip behaves identically to overflow:hidden for content + clipping but supports overflow-clip-margin to widen the clip region. + The 5px margin matches the divider's hit-padding (10px total / 2). */ +.pane[data-has-title] { + overflow: clip !important; + overflow-clip-margin: 5px; +} + +/* Title separator line — rendered on the .pane itself (not the portaled + title bar) so the line spans the full pane width into the divider zone. */ +.pane[data-has-title]::after { + content: ''; + position: absolute; + top: 20px; + left: -5px; + right: -5px; + height: 1px; + background: rgba(255, 255, 255, 0.06); + z-index: 6; + pointer-events: none; +} + +/* Hide the drag handle on titled panes — the title bar already provides + visual identification, and a grab-cursor strip right at the border + creates a confusing "looks draggable" affordance that misleads users. */ +.pane[data-has-title] .pane-drag-handle { + display: none; +} + .number-input-clean { appearance: textfield; } diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx index 7f7f9079ce8..5a46f4869da 100644 --- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx @@ -6,6 +6,7 @@ import { Minimize2, PanelBottomOpen, PanelRightOpen, + Pencil, X } from 'lucide-react' import { @@ -32,6 +33,7 @@ type TerminalContextMenuProps = { onClosePane: () => void onClearScreen: () => void onToggleExpand: () => void + onSetTitle: () => void } export default function TerminalContextMenu({ @@ -48,7 +50,8 @@ export default function TerminalContextMenu({ onSplitDown, onClosePane, onClearScreen, - onToggleExpand + onToggleExpand, + onSetTitle }: TerminalContextMenuProps): React.JSX.Element { const isMac = navigator.userAgent.includes('Mac') const mod = isMac ? '⌘' : 'Ctrl+' @@ -116,11 +119,19 @@ export default function TerminalContextMenu({ {`${mod}${shift}↩`} )} + + + + Set Title… + {canClosePane && ( - - - Close Pane - + <> + + + + Close Pane + + )} diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 4630539f965..e416c5bb351 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +/* eslint-disable max-lines -- Why: terminal pane component co-locates title state, layout serialization, and portal rendering to keep pane lifecycle consistent. */ +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import type { CSSProperties } from 'react' import { useAppStore } from '../../store' @@ -10,7 +11,7 @@ import { import type { PaneManager } from '@/lib/pane-manager/pane-manager' import TerminalSearch from '@/components/TerminalSearch' import type { PtyTransport } from './pty-transport' -import { shellEscapePath } from './pane-helpers' +import { fitPanes, shellEscapePath } from './pane-helpers' import { EMPTY_LAYOUT, paneLeafId, serializeTerminalLayout } from './layout-serialization' import { createExpandCollapseActions } from './expand-collapse' import { useTerminalKeyboardShortcuts, useTerminalFontZoom } from './keyboard-handlers' @@ -63,6 +64,20 @@ export default function TerminalPane({ const [searchOpen, setSearchOpen] = useState(false) const [closeConfirmPaneId, setCloseConfirmPaneId] = useState(null) const [terminalError, setTerminalError] = useState(null) + + // Pane title state — keyed by ephemeral paneId, persisted via titlesByLeafId + // in the layout snapshot. Ref keeps persistLayoutSnapshot closures fresh. + const [paneTitles, setPaneTitles] = useState>({}) + const paneTitlesRef = useRef>({}) + paneTitlesRef.current = paneTitles + const [renamingPaneId, setRenamingPaneId] = useState(null) + const [renameValue, setRenameValue] = useState('') + const renameInputRef = useRef(null) + // Guard against double-submit: when the user presses Enter, handleRenameSubmit + // runs and then the input unmounts causing onBlur to fire handleRenameSubmit + // again. Similarly, pressing Escape runs handleRenameCancel but blur would + // then call handleRenameSubmit, saving the title the user wanted to discard. + const renameSubmittedRef = useRef(false) const onPtyErrorRef = useRef((_paneId: number, message: string) => { setTerminalError((prev) => (prev ? `${prev}\n${message}` : message)) }) @@ -94,7 +109,12 @@ export default function TerminalPane({ const systemPrefersDark = useSystemPrefersDark() const dispatchNotification = useNotificationDispatch(worktreeId) - const persistLayoutSnapshot = (): void => { + // Memoized with useCallback so downstream hooks (useTerminalKeyboardShortcuts, + // useTerminalPaneLifecycle, createExpandCollapseActions) don't tear down and + // re-register event listeners on every render. All data it reads comes from + // refs (managerRef, containerRef, expandedPaneIdRef, paneTitlesRef) or + // stable values (tabId, setTabLayout), so the dependency array is minimal. + const persistLayoutSnapshot = useCallback((): void => { const manager = managerRef.current const container = containerRef.current if (!manager || !container) { @@ -111,8 +131,19 @@ export default function TerminalPane({ Object.entries(existing.buffersByLeafId).filter(([id]) => currentLeafIds.has(id)) ) } + // Preserve pane titles — uses the live React state (via ref) rather than + // the stale Zustand value because React state reflects in-flight title + // edits that haven't been persisted yet. + const currentPanes = manager.getPanes() + const titles = paneTitlesRef.current + const titleEntries = currentPanes + .filter((p) => titles[p.id]) + .map((p) => [paneLeafId(p.id), titles[p.id]] as const) + if (titleEntries.length > 0) { + layout.titlesByLeafId = Object.fromEntries(titleEntries) + } setTabLayout(tabId, layout) - } + }, [tabId, setTabLayout]) const { setExpandedPane, @@ -205,7 +236,10 @@ export default function TerminalPane({ setTabCanExpandPane, setExpandedPane, syncExpandedLayout, - persistLayoutSnapshot + persistLayoutSnapshot, + setPaneTitles, + paneTitlesRef, + setRenamingPaneId }) useTerminalFontZoom({ isActive, managerRef, paneFontSizesRef, settingsRef }) @@ -278,6 +312,36 @@ export default function TerminalPane({ return () => container.removeEventListener('paste', onPaste, { capture: true }) }, [isActive]) + // Sync the data-has-title attribute on pane containers when titles change, + // and reflow terminals so safeFit() sees the correct available height. + // useLayoutEffect (not useEffect) ensures the attribute and refit happen + // synchronously after React commits but before the browser paints, so the + // title bar offset is applied before the first visible frame and before + // any pending requestAnimationFrame (e.g. queueResizeAll) measures dims. + useLayoutEffect(() => { + const manager = managerRef.current + if (!manager) { + return + } + let needsFit = false + for (const pane of manager.getPanes()) { + // Show the title bar space when the pane has a title OR is being + // inline-edited (so the input appears even for untitled panes). + const shouldShow = !!paneTitles[pane.id] || renamingPaneId === pane.id + const hadTitle = pane.container.hasAttribute('data-has-title') + if (shouldShow && !hadTitle) { + pane.container.setAttribute('data-has-title', '') + needsFit = true + } else if (!shouldShow && hadTitle) { + pane.container.removeAttribute('data-has-title') + needsFit = true + } + } + if (needsFit) { + fitPanes(manager) + } + }, [paneTitles, renamingPaneId]) + // Register a capture callback for shutdown. The beforeunload handler in // App.tsx calls all registered callbacks to serialize terminal buffers. useEffect(() => { @@ -330,12 +394,22 @@ export default function TerminalPane({ // Serialization failure for one pane should not block others. } } - if (Object.keys(buffers).length === 0) { - return - } const activePaneId = manager.getActivePane()?.id ?? panes[0]?.id ?? null const layout = serializeTerminalLayout(container, activePaneId, expandedPaneIdRef.current) - setTabLayout(tabId, { ...layout, buffersByLeafId: buffers }) + if (Object.keys(buffers).length > 0) { + layout.buffersByLeafId = buffers + } + // Merge pane titles so the shutdown snapshot doesn't silently drop them. + // Why: the old early-return on empty buffers skipped this entirely, which + // meant titles were lost on restart when the terminal had no scrollback + // content (e.g. fresh pane, cleared screen). + const titleEntries = panes + .filter((p) => paneTitlesRef.current[p.id]) + .map((p) => [paneLeafId(p.id), paneTitlesRef.current[p.id]] as const) + if (titleEntries.length > 0) { + layout.titlesByLeafId = Object.fromEntries(titleEntries) + } + setTabLayout(tabId, layout) } shutdownBufferCaptures.add(captureBuffers) return () => { @@ -343,9 +417,77 @@ export default function TerminalPane({ } }, [tabId, setTabLayout]) + const handleStartRename = useCallback((paneId: number) => { + setRenameValue(paneTitlesRef.current[paneId] ?? '') + setRenamingPaneId(paneId) + }, []) + + const handleRenameSubmit = useCallback(() => { + if (renamingPaneId === null || renameSubmittedRef.current) { + return + } + renameSubmittedRef.current = true + const trimmed = renameValue.trim() + if (trimmed.length === 0) { + // Empty input — just cancel, don't change anything. + setRenamingPaneId(null) + return + } + setPaneTitles((prev) => ({ ...prev, [renamingPaneId]: trimmed })) + // Eagerly update the ref so persistLayoutSnapshot (which reads + // paneTitlesRef.current) sees the new title immediately, without + // waiting for React to re-render and assign it during the next + // render pass. + paneTitlesRef.current = { ...paneTitlesRef.current, [renamingPaneId]: trimmed } + setRenamingPaneId(null) + // Persist immediately so the title survives restarts. + persistLayoutSnapshot() + }, [renamingPaneId, renameValue, persistLayoutSnapshot]) + + const handleRenameCancel = useCallback(() => { + renameSubmittedRef.current = true + setRenamingPaneId(null) + }, []) + + const handleRemoveTitle = useCallback( + (paneId: number) => { + setPaneTitles((prev) => { + if (!(paneId in prev)) { + return prev + } + const next = { ...prev } + delete next[paneId] + return next + }) + // Eagerly remove from the ref so persistLayoutSnapshot sees the change. + if (paneId in paneTitlesRef.current) { + const next = { ...paneTitlesRef.current } + delete next[paneId] + paneTitlesRef.current = next + } + persistLayoutSnapshot() + }, + [persistLayoutSnapshot] + ) + + // Auto-focus and select-all in the rename input when the dialog opens. + // Also reset the submit guard so the new rename session can accept input. + useEffect(() => { + if (renamingPaneId === null) { + return + } + renameSubmittedRef.current = false + const frame = requestAnimationFrame(() => { + renameInputRef.current?.focus() + renameInputRef.current?.select() + }) + return () => cancelAnimationFrame(frame) + }, [renamingPaneId]) + const contextMenu = useTerminalPaneContextMenu({ managerRef, - toggleExpandPane + toggleExpandPane, + onSetTitle: handleStartRename }) const effectiveAppearance = settings @@ -429,7 +571,64 @@ export default function TerminalPane({ onClosePane={contextMenu.onClosePane} onClearScreen={contextMenu.onClearScreen} onToggleExpand={contextMenu.onToggleExpand} + onSetTitle={contextMenu.onSetTitle} /> + {/* Title bar overlays — portaled into each pane container that has a title + or is currently being renamed (so the inline input appears even for + untitled panes when "Set Title..." is triggered). + + Note: managerRef is a React ref, so reading .getPanes() here does not + by itself trigger re-renders when the pane list changes. This works + because every operation that affects the pane list also updates React + state — title operations update `paneTitles` or `renamingPaneId`, + and structural changes (split, close) update those same signals via + onPaneClosed / onPaneCreated callbacks — so React always re-renders + this block when .getPanes() would return a different result. */} + {managerRef.current?.getPanes().map((pane) => { + const title = paneTitles[pane.id] + const isEditing = renamingPaneId === pane.id + if (!title && !isEditing) { + return null + } + return createPortal( +
+ {isEditing ? ( + setRenameValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleRenameSubmit() + } else if (e.key === 'Escape') { + handleRenameCancel() + } + }} + onBlur={handleRenameSubmit} + /> + ) : ( + <> + handleStartRename(pane.id)}> + {title} + + + + )} +
, + pane.container, + `pane-title-${pane.id}` + ) + })} setCloseConfirmPaneId(null)} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts index fb7c6301aba..20e3eaf052b 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts @@ -6,6 +6,7 @@ const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus' type UseTerminalPaneContextMenuDeps = { managerRef: React.RefObject toggleExpandPane: (paneId: number) => void + onSetTitle: (paneId: number) => void } type TerminalMenuState = { @@ -23,11 +24,13 @@ type TerminalMenuState = { onClosePane: () => void onClearScreen: () => void onToggleExpand: () => void + onSetTitle: () => void } export function useTerminalPaneContextMenu({ managerRef, - toggleExpandPane + toggleExpandPane, + onSetTitle }: UseTerminalPaneContextMenuDeps): TerminalMenuState { const contextPaneIdRef = useRef(null) const menuOpenedAtRef = useRef(0) @@ -117,6 +120,13 @@ export function useTerminalPaneContextMenu({ } } + const handleSetTitle = (): void => { + const pane = resolveMenuPane() + if (pane) { + onSetTitle(pane.id) + } + } + const onContextMenuCapture = (event: React.MouseEvent): void => { event.preventDefault() menuOpenedAtRef.current = Date.now() @@ -155,6 +165,7 @@ export function useTerminalPaneContextMenu({ onSplitDown, onClosePane, onClearScreen, - onToggleExpand + onToggleExpand, + onSetTitle: handleSetTitle } } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 05a06efc49b..001685f5885 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -53,6 +53,9 @@ type UseTerminalPaneLifecycleDeps = { setExpandedPane: (paneId: number | null) => void syncExpandedLayout: () => void persistLayoutSnapshot: () => void + setPaneTitles: React.Dispatch>> + paneTitlesRef: React.RefObject> + setRenamingPaneId: React.Dispatch> } export function useTerminalPaneLifecycle({ @@ -83,7 +86,10 @@ export function useTerminalPaneLifecycle({ setTabCanExpandPane, setExpandedPane, syncExpandedLayout, - persistLayoutSnapshot + persistLayoutSnapshot, + setPaneTitles, + paneTitlesRef, + setRenamingPaneId }: UseTerminalPaneLifecycleDeps): void { const systemPrefersDarkRef = useRef(systemPrefersDark) systemPrefersDarkRef.current = systemPrefersDark @@ -226,6 +232,26 @@ export function useTerminalPaneLifecycle({ } paneFontSizesRef.current.delete(paneId) pendingWritesRef.current.delete(paneId) + // Clean up pane title state so closed panes don't leave stale entries. + setPaneTitles((prev) => { + if (!(paneId in prev)) { + return prev + } + const next = { ...prev } + delete next[paneId] + return next + }) + // Eagerly update the ref so persistLayoutSnapshot (called from + // onLayoutChanged which fires right after onPaneClosed) reads the + // correct titles without waiting for React's async state flush. + if (paneId in paneTitlesRef.current) { + const next = { ...paneTitlesRef.current } + delete next[paneId] + paneTitlesRef.current = next + } + // Dismiss the rename dialog if it was open for the closed pane, + // otherwise it would submit against a non-existent pane. + setRenamingPaneId((prev) => (prev === paneId ? null : prev)) scheduleRuntimeGraphSync() }, onActivePaneChange: () => { @@ -279,6 +305,24 @@ export function useTerminalPaneLifecycle({ restoredPaneByLeafId ) + // Seed pane titles from the persisted snapshot using the same + // old-leafId → new-paneId mapping used for buffer restore. + const savedTitles = initialLayoutRef.current.titlesByLeafId + if (savedTitles) { + const restored: Record = {} + for (const [oldLeafId, title] of Object.entries(savedTitles)) { + const newPaneId = restoredPaneByLeafId.get(oldLeafId) + if (newPaneId != null && title) { + restored[newPaneId] = title + } + } + if (Object.keys(restored).length > 0) { + // Merge (not replace) so we don't discard any concurrent state + // updates from onPaneClosed that React may have batched. + setPaneTitles((prev) => ({ ...prev, ...restored })) + } + } + const restoredActivePaneId = (initialLayoutRef.current.activeLeafId ? restoredPaneByLeafId.get(initialLayoutRef.current.activeLeafId) diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index c60349925a8..3c19afd20d9 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -17,7 +17,6 @@ import { safeFit } from './pane-tree-ops' // Pane creation, terminal open/close, addon management // --------------------------------------------------------------------------- -const TERMINAL_PADDING = 4 const ENABLE_WEBGL_RENDERER = true export function createPaneDOM( @@ -32,14 +31,11 @@ export function createPaneDOM( container.className = 'pane' container.dataset.paneId = String(id) - // Create .xterm-container with small inset padding + // Create .xterm-container — baseline layout (position, width, height, margin) + // is CSS-driven (see main.css .xterm-container) so that the data-has-title + // attribute override can shift the terminal down without racing safeFit(). const xtermContainer = document.createElement('div') xtermContainer.className = 'xterm-container' - xtermContainer.style.width = `calc(100% - ${TERMINAL_PADDING}px)` - xtermContainer.style.height = `calc(100% - ${TERMINAL_PADDING}px)` - xtermContainer.style.marginTop = `${TERMINAL_PADDING}px` - xtermContainer.style.marginLeft = `${TERMINAL_PADDING}px` - xtermContainer.style.position = 'relative' container.appendChild(xtermContainer) // Build terminal options diff --git a/src/shared/types.ts b/src/shared/types.ts index ef586563909..8bf969201df 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -91,6 +91,9 @@ export type TerminalLayoutSnapshot = { expandedLeafId: string | null /** Serialized terminal buffers per leaf for scrollback restoration on restart. */ buffersByLeafId?: Record + /** User-assigned pane titles, keyed by leafId (e.g. "pane:3"). + * Persisted alongside buffers via the existing session:set flow. */ + titlesByLeafId?: Record } /** Minimal subset of OpenFile persisted across restarts.