feat: add custom title support for terminal panes (#382)

* WIP: Changes before auto-review fixes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* chore: remove design doc from PR

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Brennan Benson
2026-04-08 00:02:12 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 72890a3c89
commit c533784d65
7 changed files with 428 additions and 25 deletions
+139
View File
@@ -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;
}
@@ -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({
<DropdownMenuShortcut>{`${mod}${shift}`}</DropdownMenuShortcut>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onSetTitle}>
<Pencil />
Set Title
</DropdownMenuItem>
{canClosePane && (
<DropdownMenuItem variant="destructive" onSelect={onClosePane}>
<X />
Close Pane
</DropdownMenuItem>
<>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onSelect={onClosePane}>
<X />
Close Pane
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onClearScreen}>
@@ -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<number | null>(null)
const [terminalError, setTerminalError] = useState<string | null>(null)
// Pane title state — keyed by ephemeral paneId, persisted via titlesByLeafId
// in the layout snapshot. Ref keeps persistLayoutSnapshot closures fresh.
const [paneTitles, setPaneTitles] = useState<Record<number, string>>({})
const paneTitlesRef = useRef<Record<number, string>>({})
paneTitlesRef.current = paneTitles
const [renamingPaneId, setRenamingPaneId] = useState<number | null>(null)
const [renameValue, setRenameValue] = useState('')
const renameInputRef = useRef<HTMLInputElement>(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(
<div className="pane-title-bar" {...(isEditing ? { 'data-editing': '' } : {})}>
{isEditing ? (
<input
ref={renameInputRef}
className="pane-title-input"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleRenameSubmit()
} else if (e.key === 'Escape') {
handleRenameCancel()
}
}}
onBlur={handleRenameSubmit}
/>
) : (
<>
<span className="pane-title-text" onClick={() => handleStartRename(pane.id)}>
{title}
</span>
<button
className="pane-title-close"
onClick={(e) => {
e.stopPropagation()
handleRemoveTitle(pane.id)
}}
aria-label="Remove title"
>
×
</button>
</>
)}
</div>,
pane.container,
`pane-title-${pane.id}`
)
})}
<CloseTerminalDialog
open={closeConfirmPaneId !== null}
onCancel={() => setCloseConfirmPaneId(null)}
@@ -6,6 +6,7 @@ const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus'
type UseTerminalPaneContextMenuDeps = {
managerRef: React.RefObject<PaneManager | null>
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<number | null>(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<HTMLDivElement>): void => {
event.preventDefault()
menuOpenedAtRef.current = Date.now()
@@ -155,6 +165,7 @@ export function useTerminalPaneContextMenu({
onSplitDown,
onClosePane,
onClearScreen,
onToggleExpand
onToggleExpand,
onSetTitle: handleSetTitle
}
}
@@ -53,6 +53,9 @@ type UseTerminalPaneLifecycleDeps = {
setExpandedPane: (paneId: number | null) => void
syncExpandedLayout: () => void
persistLayoutSnapshot: () => void
setPaneTitles: React.Dispatch<React.SetStateAction<Record<number, string>>>
paneTitlesRef: React.RefObject<Record<number, string>>
setRenamingPaneId: React.Dispatch<React.SetStateAction<number | null>>
}
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<number, string> = {}
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)
@@ -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
+3
View File
@@ -91,6 +91,9 @@ export type TerminalLayoutSnapshot = {
expandedLeafId: string | null
/** Serialized terminal buffers per leaf for scrollback restoration on restart. */
buffersByLeafId?: Record<string, string>
/** User-assigned pane titles, keyed by leafId (e.g. "pane:3").
* Persisted alongside buffers via the existing session:set flow. */
titlesByLeafId?: Record<string, string>
}
/** Minimal subset of OpenFile persisted across restarts.