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.