feat: add terminal pane title shortcuts (#6508)

Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Matheus Coêlho
2026-06-29 00:13:29 -07:00
committed by GitHub
co-authored by Jinwoo-H Orca
parent b7f7c30904
commit a85218dd60
14 changed files with 284 additions and 68 deletions
@@ -70,6 +70,8 @@ function renderMenu(overrides: Record<string, unknown> = {}): void {
onAddQuickCommand: vi.fn(),
onToggleExpand: vi.fn(),
onSetTitle: vi.fn(),
onClearPaneTitle: vi.fn(),
canClearPaneTitle: false,
onCopyTerminalId: vi.fn(),
onCopyPaneId: vi.fn(),
...overrides
@@ -67,6 +67,8 @@ type TerminalContextMenuProps = {
onAddQuickCommand: () => void
onToggleExpand: () => void
onSetTitle: () => void
onClearPaneTitle: () => void
canClearPaneTitle: boolean
onCopyTerminalId: () => void
onCopyPaneId: () => void
}
@@ -100,6 +102,8 @@ export default function TerminalContextMenu({
onAddQuickCommand,
onToggleExpand,
onSetTitle,
onClearPaneTitle,
canClearPaneTitle,
onCopyTerminalId,
onCopyPaneId
}: TerminalContextMenuProps): React.JSX.Element {
@@ -111,6 +115,8 @@ export default function TerminalContextMenu({
splitDown: formatShortcutLabel('terminal.splitDown', keybindings),
equalize: formatShortcutLabel('terminal.equalizePaneSizes', keybindings),
expand: formatShortcutLabel('terminal.expandPane', keybindings),
setTitle: formatShortcutLabel('terminal.setTitle', keybindings),
clearPaneTitle: formatShortcutLabel('terminal.clearPaneTitle', keybindings),
close: formatShortcutLabel('terminal.closePane', keybindings),
nativeChat: nativeChatToggleShortcutLabel(isMacPlatform())
}),
@@ -118,6 +124,8 @@ export default function TerminalContextMenu({
)
const hasQuickCommands = repoQuickCommands.length > 0 || globalQuickCommands.length > 0
const showEqualizeShortcut = shortcuts.equalize !== 'Unassigned'
const showSetTitleShortcut = shortcuts.setTitle !== 'Unassigned'
const showClearPaneTitleShortcut = shortcuts.clearPaneTitle !== 'Unassigned'
const renderQuickCommandItem = (command: TerminalQuickCommand): React.JSX.Element => (
<DropdownMenuItem key={command.id} onSelect={() => onQuickCommand(command)}>
{isTerminalAgentQuickCommand(command) ? (
@@ -337,7 +345,22 @@ export default function TerminalContextMenu({
>
<Pencil />
{translate('auto.components.terminal.pane.TerminalContextMenu.39809d152f', 'Set Title…')}
{showSetTitleShortcut ? (
<DropdownMenuShortcut>{shortcuts.setTitle}</DropdownMenuShortcut>
) : null}
</DropdownMenuItem>
{canClearPaneTitle ? (
<DropdownMenuItem onSelect={onClearPaneTitle}>
<X />
{translate(
'auto.components.terminal.pane.TerminalContextMenu.clearPaneTitle',
'Clear Pane Title'
)}
{showClearPaneTitleShortcut ? (
<DropdownMenuShortcut>{shortcuts.clearPaneTitle}</DropdownMenuShortcut>
) : null}
</DropdownMenuItem>
) : null}
<DropdownMenuItem onSelect={onCopyTerminalId}>
<Copy />
{translate(
@@ -458,6 +458,65 @@ export default function TerminalPane({
const renameFocusFrameRef = useRef<number | null>(null)
const renameEnableBlurFrameRef = useRef<number | null>(null)
const renameRefocusFrameRef = useRef<number | null>(null)
/**
* Cancels deferred focus/blur work from inline title editing.
* Rename sessions schedule multiple frames because xterm and Radix can both
* move focus after the menu closes.
*/
const cancelPendingRenameFrames = useCallback(() => {
const frameRefs = [renameFocusFrameRef, renameEnableBlurFrameRef, renameRefocusFrameRef]
for (const frameRef of frameRefs) {
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current)
frameRef.current = null
}
}
}, [])
/**
* Invalidates the active inline title edit session before unmount or cancel.
* Session IDs keep stale animation-frame callbacks from committing old titles.
*/
const closeRenameSession = useCallback(() => {
renameSessionIdRef.current += 1
renameBlurCommitEnabledRef.current = true
renameUserRequestedBlurCommitRef.current = false
cancelPendingRenameFrames()
}, [cancelPendingRenameFrames])
/**
* Owns the terminal container ref and closes rename work when that owner
* detaches, preventing delayed focus callbacks from targeting stale DOM.
*/
const setContainerRef = useCallback(
(node: HTMLDivElement | null): void => {
containerRef.current = node
if (node !== null) {
return
}
// Why: inline title rename focus/blur frames are owned by the terminal
// container; invalidate them when that DOM owner detaches.
closeRenameSession()
},
[closeRenameSession]
)
/**
* Starts inline title editing from either the context menu or keyboard
* shortcut while resetting stale blur-submit state from prior sessions.
*/
const handleStartRename = useCallback(
(paneId: number) => {
cancelPendingRenameFrames()
renameSessionIdRef.current += 1
renameBlurCommitEnabledRef.current = false
renameUserRequestedBlurCommitRef.current = false
renameSubmittedRef.current = false
setRenameValue(paneTitlesRef.current[paneId] ?? '')
setRenamingPaneId(paneId)
},
[cancelPendingRenameFrames]
)
const onPtyErrorRef = useRef((_paneId: number, message: string) => {
if (isTerminalSessionStateSaveFailure(message)) {
setTerminalError(null)
@@ -888,6 +947,49 @@ export default function TerminalPane({
[paneTransportsRef, persistLayoutSnapshot]
)
/**
* Removes a custom pane title from React state, the fresh persistence ref,
* and the leaf-id tombstone set so the next layout snapshot stays cleared.
*/
const removePaneTitle = 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
}
const leafId = managerRef.current?.getPanes().find((pane) => pane.id === paneId)?.leafId
if (leafId) {
removedTitleLeafIdsRef.current.add(leafId)
}
persistLayoutSnapshot()
},
[persistLayoutSnapshot]
)
/**
* Ignores clear-title shortcuts for panes already using their automatic
* title, keeping the command idempotent and avoiding unnecessary snapshots.
*/
const handleClearPaneTitleShortcut = useCallback(
(paneId: number) => {
if (!paneTitlesRef.current[paneId]) {
return
}
removePaneTitle(paneId)
},
[removePaneTitle]
)
useEffect(() => {
if (!terminalTab) {
return
@@ -1479,6 +1581,8 @@ export default function TerminalPane({
onSearchSelectedText: handleSearchSelectedText,
onRequestClosePane: handleRequestClosePane,
onClearPaneScrollback: clearPaneScrollback,
onSetTitle: handleStartRename,
onClearPaneTitle: handleClearPaneTitleShortcut,
searchOpenRef,
searchStateRef,
macOptionAsAltRef,
@@ -2143,49 +2247,6 @@ export default function TerminalPane({
}
}, [tabId, worktreeId, setTabLayout])
const cancelPendingRenameFrames = useCallback(() => {
const frameRefs = [renameFocusFrameRef, renameEnableBlurFrameRef, renameRefocusFrameRef]
for (const frameRef of frameRefs) {
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current)
frameRef.current = null
}
}
}, [])
const closeRenameSession = useCallback(() => {
renameSessionIdRef.current += 1
renameBlurCommitEnabledRef.current = true
renameUserRequestedBlurCommitRef.current = false
cancelPendingRenameFrames()
}, [cancelPendingRenameFrames])
const setContainerRef = useCallback(
(node: HTMLDivElement | null): void => {
containerRef.current = node
if (node !== null) {
return
}
// Why: inline title rename focus/blur frames are owned by the terminal
// container; invalidate them when that DOM owner detaches.
closeRenameSession()
},
[closeRenameSession]
)
const handleStartRename = useCallback(
(paneId: number) => {
cancelPendingRenameFrames()
renameSessionIdRef.current += 1
renameBlurCommitEnabledRef.current = false
renameUserRequestedBlurCommitRef.current = false
renameSubmittedRef.current = false
setRenameValue(paneTitlesRef.current[paneId] ?? '')
setRenamingPaneId(paneId)
},
[cancelPendingRenameFrames]
)
useEffect(() => {
if (renamingPaneId === null) {
return
@@ -2212,31 +2273,6 @@ export default function TerminalPane({
}
}, [renamingPaneId])
const removePaneTitle = 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
}
const leafId = managerRef.current?.getPanes().find((pane) => pane.id === paneId)?.leafId
if (leafId) {
removedTitleLeafIdsRef.current.add(leafId)
}
persistLayoutSnapshot()
},
[persistLayoutSnapshot]
)
const handleRenameSubmit = useCallback(() => {
if (renamingPaneId === null || renameSubmittedRef.current) {
return
@@ -2360,6 +2396,7 @@ export default function TerminalPane({
onRequestClosePane: handleRequestClosePane,
onClearPaneScrollback: clearPaneScrollback,
onSetTitle: handleStartRename,
onClearPaneTitle: handleClearPaneTitleShortcut,
onPasteError: setTerminalError,
onAgentSessionForkReady: setAgentSessionFork,
forceBracketedMultilineTextPaste,
@@ -2638,6 +2675,8 @@ export default function TerminalPane({
const activePane = managerRef.current?.getActivePane()
const managedPanes = managerRef.current?.getPanes() ?? []
const menuPaneHasCustomTitle =
contextMenu.menuPaneId !== null && Boolean(paneTitles[contextMenu.menuPaneId])
const chatLeafStillMounted = chatLeafId
? managedPanes.some((pane) => pane.leafId === chatLeafId)
: false
@@ -2814,6 +2853,8 @@ export default function TerminalPane({
}
onToggleExpand={contextMenu.onToggleExpand}
onSetTitle={contextMenu.onSetTitle}
onClearPaneTitle={contextMenu.onClearPaneTitle}
canClearPaneTitle={menuPaneHasCustomTitle}
onCopyTerminalId={() => void contextMenu.onCopyTerminalId()}
onCopyPaneId={contextMenu.onCopyPaneId}
/>
@@ -132,6 +132,8 @@ type KeyboardHandlersDeps = {
onSearchSelectedText: (text: string) => void
onRequestClosePane: (paneId: number) => void
onClearPaneScrollback: (pane: ManagedPane) => void
onSetTitle: (paneId: number) => void
onClearPaneTitle: (paneId: number) => void
searchOpenRef: React.RefObject<boolean>
searchStateRef: React.RefObject<SearchState>
macOptionAsAltRef: React.RefObject<MacOptionAsAlt>
@@ -139,6 +141,11 @@ type KeyboardHandlersDeps = {
terminalShortcutPolicy?: TerminalShortcutPolicy
}
/**
* Installs terminal-pane shortcuts on the tab keyboard scope.
* Uses the shared shortcut policy before forwarding unmatched input to xterm
* so configurable Orca actions remain consistent across local and SSH panes.
*/
export function useTerminalKeyboardShortcuts({
tabId,
isActive,
@@ -157,6 +164,8 @@ export function useTerminalKeyboardShortcuts({
onSearchSelectedText,
onRequestClosePane,
onClearPaneScrollback,
onSetTitle,
onClearPaneTitle,
searchOpenRef,
searchStateRef,
macOptionAsAltRef,
@@ -389,6 +398,28 @@ export function useTerminalKeyboardShortcuts({
return
}
if (action.type === 'setTitle') {
e.preventDefault()
e.stopImmediatePropagation()
const pane = manager.getActivePane() ?? manager.getPanes()[0]
if (!pane) {
return
}
onSetTitle(pane.id)
return
}
if (action.type === 'clearPaneTitle') {
e.preventDefault()
e.stopImmediatePropagation()
const pane = manager.getActivePane() ?? manager.getPanes()[0]
if (!pane) {
return
}
onClearPaneTitle(pane.id)
return
}
// Cmd+W closes the active split pane (or the whole tab when only one
// pane remains). Always intercepted here so the tab-level handler in
// Terminal.tsx never closes the entire tab directly — that would kill
@@ -458,6 +489,8 @@ export function useTerminalKeyboardShortcuts({
onSearchSelectedText,
onRequestClosePane,
onClearPaneScrollback,
onSetTitle,
onClearPaneTitle,
searchOpenRef,
searchStateRef,
macOptionAsAltRef,
@@ -262,6 +262,42 @@ describe('resolveTerminalShortcutAction', () => {
).toEqual({ type: 'equalizePaneSizes' })
})
it('resolves terminal title actions only when users assign them', () => {
expect(
resolveTerminalShortcutAction(event({ key: 't', code: 'KeyT', metaKey: true }), true)
).toBeNull()
expect(
resolveTerminalShortcutAction(
event({ key: 't', code: 'KeyT', metaKey: true }),
true,
'false',
0,
false,
{ 'terminal.setTitle': ['Mod+T'] }
)
).toEqual({ type: 'setTitle' })
expect(
resolveTerminalShortcutAction(
event({ key: 't', code: 'KeyT', metaKey: true, altKey: true }),
true,
'false',
0,
false,
{ 'terminal.clearPaneTitle': ['Mod+Alt+T'] }
)
).toEqual({ type: 'clearPaneTitle' })
expect(
resolveTerminalShortcutAction(
event({ key: 't', code: 'KeyT', metaKey: true, altKey: true, repeat: true }),
true,
'false',
0,
false,
{ 'terminal.clearPaneTitle': ['Mod+Alt+T'] }
)
).toBeNull()
})
it('lets Ctrl+D pass through as EOF on non-Mac, requires Shift for split (#586)', () => {
// Ctrl+D without Shift on Windows/Linux must NOT trigger split — it's EOF
expect(
@@ -35,11 +35,18 @@ export type TerminalShortcutAction =
| { type: 'focusPane'; direction: 'next' | 'previous' }
| { type: 'equalizePaneSizes' }
| { type: 'toggleExpandActivePane' }
| { type: 'setTitle' }
| { type: 'clearPaneTitle' }
| { type: 'closeActivePane' }
| { type: 'splitActivePane'; direction: 'vertical' | 'horizontal' }
| { type: 'scrollViewport'; position: 'top' | 'bottom' }
| { type: 'sendInput'; data: string }
/**
* Resolves terminal keyboard events before xterm receives them.
* Keeps configurable Orca shortcuts and terminal byte fallbacks in one
* platform-aware policy so renderer handlers do not duplicate key checks.
*/
export function resolveTerminalShortcutAction(
event: TerminalShortcutEvent,
isMac: boolean,
@@ -78,6 +85,14 @@ export function resolveTerminalShortcutAction(
return { type: 'toggleExpandActivePane' }
}
if (keybindingMatchesAction('terminal.setTitle', event, platform, keybindings)) {
return { type: 'setTitle' }
}
if (keybindingMatchesAction('terminal.clearPaneTitle', event, platform, keybindings)) {
return { type: 'clearPaneTitle' }
}
if (keybindingMatchesAction('terminal.closePane', event, platform, keybindings)) {
return { type: 'closeActivePane' }
}
@@ -67,6 +67,7 @@ type UseTerminalPaneContextMenuDeps = {
onRequestClosePane: (paneId: number) => void
onClearPaneScrollback: (pane: ManagedPane) => void
onSetTitle: (paneId: number) => void
onClearPaneTitle: (paneId: number) => void
onPasteError: (message: string) => void
onAgentSessionForkReady: (fork: PreparedAgentSessionFork) => void
forceBracketedMultilineTextPaste: boolean
@@ -96,6 +97,7 @@ type TerminalMenuState = {
onQuickCommand: (command: TerminalQuickCommand) => void
onToggleExpand: () => void
onSetTitle: () => void
onClearPaneTitle: () => void
runForPane: <Result>(paneId: number, action: () => Result) => Result
}
@@ -112,6 +114,7 @@ export function useTerminalPaneContextMenu({
onRequestClosePane,
onClearPaneScrollback,
onSetTitle,
onClearPaneTitle,
onPasteError,
onAgentSessionForkReady,
forceBracketedMultilineTextPaste,
@@ -433,6 +436,7 @@ export function useTerminalPaneContextMenu({
}
}
/** Routes title edits through the resolved menu pane instead of active pane. */
const handleSetTitle = (): void => {
const pane = resolveMenuPane()
if (pane) {
@@ -440,6 +444,14 @@ export function useTerminalPaneContextMenu({
}
}
/** Clears the title for the pane that opened the context menu. */
const handleClearPaneTitle = (): void => {
const pane = resolveMenuPane()
if (pane) {
onClearPaneTitle(pane.id)
}
}
const runForPane = <Result>(paneId: number, action: () => Result): Result => {
const previousPaneId = contextPaneIdRef.current
contextPaneIdRef.current = paneId
@@ -548,6 +560,7 @@ export function useTerminalPaneContextMenu({
onQuickCommand,
onToggleExpand,
onSetTitle: handleSetTitle,
onClearPaneTitle: handleClearPaneTitle,
runForPane
}
}
+1
View File
@@ -2403,6 +2403,7 @@
"copyTerminalId": "Copy Terminal ID",
"2cf85a6a55": "Copy Pane ID",
"39809d152f": "Set Title…",
"clearPaneTitle": "Clear Pane Title",
"06c2b0f043": "Equalize Pane Sizes",
"98bccf4fa2": "Split Terminal Down",
"20e565d865": "Split Terminal Right",
+1
View File
@@ -2403,6 +2403,7 @@
"copyTerminalId": "Copiar ID de terminal",
"2cf85a6a55": "Copiar ID del panel",
"39809d152f": "Establecer título…",
"clearPaneTitle": "Borrar título del panel",
"06c2b0f043": "Igualar tamaños de paneles",
"98bccf4fa2": "Terminal dividido hacia abajo",
"20e565d865": "Terminal dividido a la derecha",
+1
View File
@@ -2403,6 +2403,7 @@
"copyTerminalId": "ターミナル ID をコピー",
"2cf85a6a55": "ペインIDのコピー",
"39809d152f": "タイトルを設定…",
"clearPaneTitle": "ペインタイトルをクリア",
"06c2b0f043": "ペインのサイズを均等化する",
"98bccf4fa2": "スプリット Terminal ダウン",
"20e565d865": "分割 Terminal 右",
+1
View File
@@ -2403,6 +2403,7 @@
"copyTerminalId": "터미널 ID 복사",
"2cf85a6a55": "창 ID 복사",
"39809d152f": "제목 설정…",
"clearPaneTitle": "창 제목 지우기",
"06c2b0f043": "창 크기 균등화",
"98bccf4fa2": "Terminal을 아래로 분할",
"20e565d865": "Terminal 오른쪽 분할",
+1
View File
@@ -2403,6 +2403,7 @@
"copyTerminalId": "复制终端 ID",
"2cf85a6a55": "复制窗格 ID",
"39809d152f": "设置标题...",
"clearPaneTitle": "清除窗格标题",
"06c2b0f043": "均衡窗格大小",
"98bccf4fa2": "向下拆分终端",
"20e565d865": "向右拆分终端",
+30
View File
@@ -231,6 +231,7 @@ describe('keybindings', () => {
expect(getEffectiveKeybindingsForAction('workspace.rename', 'darwin')).toEqual(['Mod+Alt+R'])
expect(getEffectiveKeybindingsForAction('workspace.rename', 'linux')).toEqual([])
expect(formatKeybindingList(['Mod+Alt+R'], 'darwin')).toBe('⌘⌥R')
expect(getKeybindingDefinition('tab.rename')?.searchKeywords).not.toContain('set title')
expect(
keybindingMatchesAction(
'tab.rename',
@@ -420,6 +421,35 @@ describe('keybindings', () => {
).toBe(true)
})
it('names terminal title shortcuts after pane menu actions', () => {
const setTitle = getKeybindingDefinition('terminal.setTitle')
const clearTitle = getKeybindingDefinition('terminal.clearPaneTitle')
expect(setTitle?.title).toBe('Set Title…')
expect(setTitle?.group).toBe('Terminal Panes')
expect(setTitle?.scope).toBe('terminal')
expect(setTitle?.searchKeywords).toContain('set title')
expect(getEffectiveKeybindingsForAction('terminal.setTitle', 'darwin')).toEqual([])
expect(getEffectiveKeybindingsForAction('terminal.setTitle', 'linux')).toEqual([])
expect(getEffectiveKeybindingsForAction('terminal.setTitle', 'win32')).toEqual([])
expect(clearTitle?.title).toBe('Clear Pane Title')
expect(clearTitle?.group).toBe('Terminal Panes')
expect(clearTitle?.scope).toBe('terminal')
expect(clearTitle?.searchKeywords).toContain('remove title')
expect(getEffectiveKeybindingsForAction('terminal.clearPaneTitle', 'darwin')).toEqual([])
expect(getEffectiveKeybindingsForAction('terminal.clearPaneTitle', 'linux')).toEqual([])
expect(getEffectiveKeybindingsForAction('terminal.clearPaneTitle', 'win32')).toEqual([])
expect(
keybindingMatchesAction(
'terminal.clearPaneTitle',
{ key: 't', code: 'KeyT', control: false, meta: true, alt: true, shift: false },
'darwin',
{ 'terminal.clearPaneTitle': ['Mod+Alt+T'] }
)
).toBe(true)
})
it('keeps workspace delete unassigned until users customize it', () => {
const binding = {
key: 'Backspace',
+18
View File
@@ -103,6 +103,8 @@ export type KeybindingActionId =
| 'terminal.focusPreviousPane'
| 'terminal.equalizePaneSizes'
| 'terminal.expandPane'
| 'terminal.setTitle'
| 'terminal.clearPaneTitle'
| 'terminal.closePane'
| 'terminal.splitRight'
| 'terminal.splitDown'
@@ -941,6 +943,22 @@ export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [
searchKeywords: ['shortcut', 'pane', 'expand', 'collapse'],
defaultBindings: platformBindings(['Mod+Shift+Enter'])
},
{
id: 'terminal.setTitle',
title: 'Set Title…',
group: 'Terminal Panes',
scope: 'terminal',
searchKeywords: ['shortcut', 'terminal', 'pane', 'set title', 'title', 'rename'],
defaultBindings: platformBindings([])
},
{
id: 'terminal.clearPaneTitle',
title: 'Clear Pane Title',
group: 'Terminal Panes',
scope: 'terminal',
searchKeywords: ['shortcut', 'terminal', 'pane', 'clear title', 'remove title', 'title'],
defaultBindings: platformBindings([])
},
{
id: 'terminal.closePane',
title: 'Close active pane',