(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.