feat(terminal): record shell integration (OSC 7 cwd + OSC 133 marks) (#956)

Wire the two escape sequences that TUIs and modern shells use to tell
the terminal about state that xterm.js doesn't handle natively:

- OSC 7  — shell emits its current working directory on every `cd`.
- OSC 133 — FinalTerm / iTerm semantic prompt marks (A/B/C/D with
  optional exit code on D).

Parsing lives in a new pure `shell-integration.ts` helper with 21 unit
tests covering URI decoding, Windows UNC / drive-letter paths, all four
OSC 133 letters, the `key=value` hint suffix some emitters append, and
the ring-buffer cap on recorded marks.

Wiring follows the DEC 2031 / OSC 52 pattern from #896 and #952:

- Handlers registered per-pane in onPaneCreated, disposed symmetrically
  in onPaneClosed.
- Two new refs (`paneCwdRef`, `paneSemanticMarksRef`) live on
  TerminalPane and are passed through UseTerminalPaneLifecycleDeps.
- Semantic marks record the absolute buffer row (`baseY + cursorY`) so
  future block-navigation consumers can scroll to them.
- A 10 000-entry ring buffer caps per-pane memory.

Shipped consumer: the terminal context menu grows a platform-aware
"Reveal in Finder / Show in Explorer / Open Folder" item that opens
the shell's current directory in the native file manager. The item
is hidden when no OSC 7 has been received, consistent with how other
terminal emulators surface shell-integration-dependent features.

No consumer yet for OSC 133 — recording is cheap and the consumer UX
(block-nav keybinds, exit-code gutters) deserves its own design pass.
This commit is contained in:
Neil 2026-04-22 15:07:33 -07:00 committed by GitHub
parent 2819cdc081
commit 953a6cdb4a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 330 additions and 4 deletions

View File

@ -2,6 +2,7 @@ import {
Clipboard,
Copy,
Eraser,
FolderOpen,
Maximize2,
Minimize2,
PanelBottomClose,
@ -35,6 +36,11 @@ type TerminalContextMenuProps = {
onClearScreen: () => void
onToggleExpand: () => void
onSetTitle: () => void
/** Last OSC 7 cwd reported by the shell for the menu-target pane. `null`
* hides the "Reveal" item same behavior as other shell-integration-
* dependent terminal features (tmux/iTerm). */
menuPaneCwd: string | null
onRevealCwd: () => void
}
export default function TerminalContextMenu({
@ -52,11 +58,18 @@ export default function TerminalContextMenu({
onClosePane,
onClearScreen,
onToggleExpand,
onSetTitle
onSetTitle,
menuPaneCwd,
onRevealCwd
}: TerminalContextMenuProps): React.JSX.Element {
const isMac = navigator.userAgent.includes('Mac')
const isWindows = navigator.userAgent.includes('Windows')
const mod = isMac ? '⌘' : 'Ctrl+'
const shift = isMac ? '⇧' : 'Shift+'
// Why platform-specific label: users recognize their OS's file manager by
// name. On Linux the verb is "Open" because there's no single canonical
// name (Nautilus / Files / Dolphin / Thunar).
const revealLabel = isMac ? 'Reveal in Finder' : isWindows ? 'Show in Explorer' : 'Open Folder'
return (
<DropdownMenu
@ -139,6 +152,12 @@ export default function TerminalContextMenu({
<Pencil />
Set Title
</DropdownMenuItem>
{menuPaneCwd && (
<DropdownMenuItem onSelect={onRevealCwd}>
<FolderOpen />
{revealLabel}
</DropdownMenuItem>
)}
{canClosePane && (
<>
<DropdownMenuSeparator />

View File

@ -12,6 +12,7 @@ import {
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import TerminalSearch from '@/components/TerminalSearch'
import type { PtyTransport } from './pty-transport'
import type { RecordedSemanticMark } from './shell-integration'
import { fitPanes, isWindowsUserAgent, shellEscapePath } from './pane-helpers'
import { EMPTY_LAYOUT, paneLeafId, serializeTerminalLayout } from './layout-serialization'
import { createExpandCollapseActions } from './expand-collapse'
@ -65,6 +66,12 @@ export default function TerminalPane({
const paneTransportsRef = useRef<Map<number, PtyTransport>>(new Map())
const paneMode2031Ref = useRef<Map<number, boolean>>(new Map())
const paneLastThemeModeRef = useRef<Map<number, 'dark' | 'light'>>(new Map())
// Why: populated by OSC 7 / OSC 133 handlers registered per-pane in
// use-terminal-pane-lifecycle. Consumers (context menu "Reveal in Finder",
// future block-navigation) read these refs at fire time — no React
// subscription so a chatty `cd` loop doesn't trigger re-renders.
const paneCwdRef = useRef<Map<number, string>>(new Map())
const paneSemanticMarksRef = useRef<Map<number, RecordedSemanticMark[]>>(new Map())
const panePtyBindingsRef = useRef<Map<number, IDisposable>>(new Map())
const pendingWritesRef = useRef<Map<number, string>>(new Map())
const isActiveRef = useRef(isActive)
@ -345,6 +352,8 @@ export default function TerminalPane({
paneTransportsRef,
paneMode2031Ref,
paneLastThemeModeRef,
paneCwdRef,
paneSemanticMarksRef,
panePtyBindingsRef,
pendingWritesRef,
isActiveRef,
@ -791,7 +800,8 @@ export default function TerminalPane({
toggleExpandPane,
onRequestClosePane: handleRequestClosePane,
onSetTitle: handleStartRename,
rightClickToPaste
rightClickToPaste,
paneCwdRef
})
const effectiveAppearance = settings
@ -880,6 +890,8 @@ export default function TerminalPane({
onClearScreen={contextMenu.onClearScreen}
onToggleExpand={contextMenu.onToggleExpand}
onSetTitle={contextMenu.onSetTitle}
menuPaneCwd={contextMenu.menuPaneCwd}
onRevealCwd={contextMenu.onRevealCwd}
/>
{/* Title bar overlays portaled into each pane container that has a title
or is currently being renamed (so the inline input appears even for

View File

@ -0,0 +1,117 @@
import { describe, expect, it } from 'vitest'
import {
parseOsc7Path,
parseOsc133Payload,
pushSemanticMark,
SEMANTIC_MARK_RING_CAPACITY,
type RecordedSemanticMark
} from './shell-integration'
describe('parseOsc7Path', () => {
it('extracts the path from a standard file:// URI', () => {
expect(parseOsc7Path('file://host/Users/alice/code')).toBe('/Users/alice/code')
})
it('ignores the host segment on Unix', () => {
expect(parseOsc7Path('file://remote.example.com/home/bob')).toBe('/home/bob')
})
it('percent-decodes encoded characters', () => {
expect(parseOsc7Path('file:///Users/alice/my%20project/src')).toBe(
'/Users/alice/my project/src'
)
})
it('percent-decodes unicode codepoints', () => {
expect(parseOsc7Path('file:///Users/alice/caf%C3%A9')).toBe('/Users/alice/café')
})
it('returns null for empty input', () => {
expect(parseOsc7Path('')).toBeNull()
})
it('returns null for non-file scheme', () => {
expect(parseOsc7Path('https://example.com/foo')).toBeNull()
})
it('returns null for malformed URIs', () => {
expect(parseOsc7Path('not a uri at all')).toBeNull()
})
})
describe('parseOsc133Payload', () => {
it('maps A to prompt-start', () => {
expect(parseOsc133Payload('A')).toEqual({ kind: 'prompt-start' })
})
it('maps B to prompt-end', () => {
expect(parseOsc133Payload('B')).toEqual({ kind: 'prompt-end' })
})
it('maps C to command-end', () => {
expect(parseOsc133Payload('C')).toEqual({ kind: 'command-end' })
})
it('maps bare D to done with no exit code', () => {
expect(parseOsc133Payload('D')).toEqual({ kind: 'done', exitCode: null })
})
it('maps D;0 to done with exit code 0', () => {
expect(parseOsc133Payload('D;0')).toEqual({ kind: 'done', exitCode: 0 })
})
it('maps D;127 to done with exit code 127', () => {
expect(parseOsc133Payload('D;127')).toEqual({ kind: 'done', exitCode: 127 })
})
it('ignores key=value hints after the leading letter', () => {
// iTerm emits things like A;cl=m to signal the prompt kind — we don't
// consume the hint today, but must not crash on it.
expect(parseOsc133Payload('A;cl=m')).toEqual({ kind: 'prompt-start' })
})
it('ignores extra hints after the exit code on D', () => {
expect(parseOsc133Payload('D;0;cl=m')).toEqual({ kind: 'done', exitCode: 0 })
})
it('returns null for unknown letters', () => {
expect(parseOsc133Payload('Z')).toBeNull()
})
it('returns null for empty input', () => {
expect(parseOsc133Payload('')).toBeNull()
})
it('returns null exit code when the token is not numeric', () => {
expect(parseOsc133Payload('D;oops')).toEqual({ kind: 'done', exitCode: null })
})
})
describe('pushSemanticMark ring buffer', () => {
it('appends marks under the capacity limit', () => {
const marks: RecordedSemanticMark[] = []
pushSemanticMark(marks, { kind: 'prompt-start', row: 1 })
pushSemanticMark(marks, { kind: 'command-end', row: 5 })
expect(marks).toHaveLength(2)
expect(marks[0].row).toBe(1)
expect(marks[1].row).toBe(5)
})
it('evicts oldest marks once past capacity', () => {
const marks: RecordedSemanticMark[] = []
const cap = 3
for (let i = 0; i < 5; i++) {
pushSemanticMark(marks, { kind: 'prompt-start', row: i }, cap)
}
expect(marks).toHaveLength(3)
expect(marks.map((m) => m.row)).toEqual([2, 3, 4])
})
it('uses a sensible default capacity', () => {
// Why: the cap exists to bound long-running-session memory. The exact
// number is a product choice, not a correctness invariant — but it
// must be positive and at least large enough for a realistic day's
// worth of commands.
expect(SEMANTIC_MARK_RING_CAPACITY).toBeGreaterThanOrEqual(1_000)
})
})

View File

@ -0,0 +1,102 @@
// Shell-integration escape sequences that xterm.js does not handle natively.
// Host wires these per-pane in use-terminal-pane-lifecycle.ts (register in
// onPaneCreated, dispose in onPaneClosed) following the DEC 2031 / OSC 52
// pattern. See docs/shell-integration-design.md.
//
// OSC 7: shell cwd notification — emitted on every `cd` by a shell whose
// rc includes a `precmd` hook. Payload is a file:// URI.
// OSC 133: FinalTerm/iTerm semantic prompt marks. Payload starts with one
// of A/B/C/D and may carry `;key=value` suffixes we ignore.
export type SemanticMarkKind = 'prompt-start' | 'prompt-end' | 'command-end' | 'done'
export type SemanticMark =
| { kind: 'prompt-start' }
| { kind: 'prompt-end' }
| { kind: 'command-end' }
| { kind: 'done'; exitCode: number | null }
// Why: URL parsing is the most robust way to strip the file://[host] prefix
// and percent-decode. Matches the main-side parser in
// src/main/daemon/history-manager.ts; the duplication is intentional —
// renderer code does not import from main. A later cleanup can hoist both
// into src/shared/.
export function parseOsc7Path(data: string): string | null {
if (!data) {
return null
}
try {
const url = new URL(data)
if (url.protocol !== 'file:') {
return null
}
const decodedPath = decodeURIComponent(url.pathname)
const isWindows = typeof navigator !== 'undefined' && navigator.userAgent.includes('Windows')
if (!isWindows) {
return decodedPath
}
// Why: OSC 7 on Windows can carry UNC paths (\\host\share) or
// drive-letter paths (/C:/Users/x). Preserve both so "Reveal in
// Explorer" opens the right directory.
if (url.hostname) {
return `\\\\${url.hostname}${decodedPath.replace(/\//g, '\\')}`
}
if (/^\/[A-Za-z]:/.test(decodedPath)) {
return decodedPath.slice(1).replace(/\//g, '\\')
}
return decodedPath.replace(/\//g, '\\')
} catch {
return null
}
}
export function parseOsc133Payload(data: string): SemanticMark | null {
if (!data) {
return null
}
// Why: some emitters append `;key=value` hints (e.g. `A;cl=m`, `D;0`).
// We only care about the leading letter and, for `D`, the first numeric
// token — everything after the first `;` beyond the exit code is hint
// data we do not consume today.
const letter = data[0]
switch (letter) {
case 'A':
return { kind: 'prompt-start' }
case 'B':
return { kind: 'prompt-end' }
case 'C':
return { kind: 'command-end' }
case 'D': {
const rest = data.slice(1)
if (rest === '' || rest[0] !== ';') {
return { kind: 'done', exitCode: null }
}
const codeToken = rest.slice(1).split(';', 1)[0]
const code = Number.parseInt(codeToken, 10)
return { kind: 'done', exitCode: Number.isFinite(code) ? code : null }
}
default:
return null
}
}
export type RecordedSemanticMark = SemanticMark & { row: number }
export const SEMANTIC_MARK_RING_CAPACITY = 10_000
// Why: long-running sessions with millions of commands would grow the array
// without bound. 10 000 marks at ~40 bytes each caps per-pane footprint at
// ~400 KB and matches what block-navigation consumers will reasonably scroll.
export function pushSemanticMark(
marks: RecordedSemanticMark[],
mark: RecordedSemanticMark,
capacity: number = SEMANTIC_MARK_RING_CAPACITY
): RecordedSemanticMark[] {
marks.push(mark)
if (marks.length > capacity) {
marks.splice(0, marks.length - capacity)
}
return marks
}

View File

@ -9,6 +9,10 @@ type UseTerminalPaneContextMenuDeps = {
onRequestClosePane: (paneId: number) => void
onSetTitle: (paneId: number) => void
rightClickToPaste: boolean
/** Populated by the OSC 7 handler in use-terminal-pane-lifecycle. `null` /
* missing when the shell has not emitted a cwd update, which controls
* whether the "Reveal in Finder" context-menu item is shown. */
paneCwdRef: React.RefObject<Map<number, string>>
}
type TerminalMenuState = {
@ -18,6 +22,10 @@ type TerminalMenuState = {
menuOpenedAtRef: React.RefObject<number>
paneCount: number
menuPaneId: number | null
/** Last OSC 7 cwd observed for the menu-target pane, or null if the shell
* has not emitted one. Read at menu-open time so the item reflects the
* pane's current directory without re-rendering on every `cd`. */
menuPaneCwd: string | null
onContextMenuCapture: (event: React.MouseEvent<HTMLDivElement>) => void
onCopy: () => Promise<void>
onPaste: () => Promise<void>
@ -27,6 +35,7 @@ type TerminalMenuState = {
onClearScreen: () => void
onToggleExpand: () => void
onSetTitle: () => void
onRevealCwd: () => void
}
export function useTerminalPaneContextMenu({
@ -34,7 +43,8 @@ export function useTerminalPaneContextMenu({
toggleExpandPane,
onRequestClosePane,
onSetTitle,
rightClickToPaste
rightClickToPaste,
paneCwdRef
}: UseTerminalPaneContextMenuDeps): TerminalMenuState {
const contextPaneIdRef = useRef<number | null>(null)
const menuOpenedAtRef = useRef(0)
@ -150,6 +160,22 @@ export function useTerminalPaneContextMenu({
}
}
const onRevealCwd = (): void => {
const pane = resolveMenuPane()
if (!pane) {
return
}
const cwd = paneCwdRef.current?.get(pane.id)
if (!cwd) {
return
}
// Why: shell.showItemInFolder() selects the *item* in its parent — passing
// a directory path opens the parent with the dir selected, which is what
// users want from "Reveal in Finder/Explorer" and matches native app
// behavior on all three platforms.
void window.api.shell.openPath(cwd)
}
const onContextMenuCapture = (event: React.MouseEvent<HTMLDivElement>): void => {
event.preventDefault()
window.dispatchEvent(new Event(CLOSE_ALL_CONTEXT_MENUS_EVENT))
@ -190,6 +216,7 @@ export function useTerminalPaneContextMenu({
const paneCount = managerRef.current?.getPanes().length ?? 1
const menuPaneId = resolveMenuPane()?.id ?? null
const menuPaneCwd = menuPaneId !== null ? (paneCwdRef.current?.get(menuPaneId) ?? null) : null
return {
open,
@ -198,6 +225,7 @@ export function useTerminalPaneContextMenu({
menuOpenedAtRef,
paneCount,
menuPaneId,
menuPaneCwd,
onContextMenuCapture,
onCopy,
onPaste,
@ -206,6 +234,7 @@ export function useTerminalPaneContextMenu({
onClosePane,
onClearScreen,
onToggleExpand,
onSetTitle: handleSetTitle
onSetTitle: handleSetTitle,
onRevealCwd
}
}

View File

@ -25,6 +25,12 @@ import {
import { applyExpandedLayoutTo, restoreExpandedLayoutFrom } from './expand-collapse'
import { applyTerminalAppearance, mode2031SequenceFor } from './terminal-appearance'
import { parseOsc52 } from './osc52-clipboard'
import {
parseOsc133Payload,
parseOsc7Path,
pushSemanticMark,
type RecordedSemanticMark
} from './shell-integration'
import type { EffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/detect-option-as-alt'
import { resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme'
import { connectPanePty } from './pty-connection'
@ -68,6 +74,8 @@ type UseTerminalPaneLifecycleDeps = {
paneTransportsRef: React.RefObject<Map<number, PtyTransport>>
paneMode2031Ref: React.RefObject<Map<number, boolean>>
paneLastThemeModeRef: React.RefObject<Map<number, 'dark' | 'light'>>
paneCwdRef: React.RefObject<Map<number, string>>
paneSemanticMarksRef: React.RefObject<Map<number, RecordedSemanticMark[]>>
panePtyBindingsRef: React.RefObject<Map<number, IDisposable>>
pendingWritesRef: React.RefObject<Map<number, string>>
isActiveRef: React.RefObject<boolean>
@ -146,6 +154,8 @@ export function useTerminalPaneLifecycle({
paneTransportsRef,
paneMode2031Ref,
paneLastThemeModeRef,
paneCwdRef,
paneSemanticMarksRef,
panePtyBindingsRef,
pendingWritesRef,
isActiveRef,
@ -179,6 +189,7 @@ export function useTerminalPaneLifecycle({
const selectionDisposablesRef = useRef(new Map<number, IDisposable>())
const mode2031DisposablesRef = useRef(new Map<number, IDisposable[]>())
const osc52DisposablesRef = useRef(new Map<number, IDisposable>())
const shellIntegrationDisposablesRef = useRef(new Map<number, IDisposable[]>())
const applyAppearance = (manager: PaneManager): void => {
const currentSettings = settingsRef.current
@ -362,6 +373,33 @@ export function useTerminalPaneLifecycle({
})
osc52DisposablesRef.current.set(pane.id, osc52Disposable)
// Shell integration: OSC 7 (cwd) and OSC 133 (semantic prompt marks).
// Both update per-pane refs — consumers (context menu "Reveal in
// Finder", future block-navigation) read at fire time. Return true
// because we fully own these sequences; no other handler needs them.
const shellIntegrationDisposables: IDisposable[] = [
pane.terminal.parser.registerOscHandler(7, (data) => {
const path = parseOsc7Path(data)
if (path) {
paneCwdRef.current.set(pane.id, path)
}
return true
}),
pane.terminal.parser.registerOscHandler(133, (data) => {
const mark = parseOsc133Payload(data)
if (!mark) {
return true
}
const buffer = pane.terminal.buffer.active
const row = buffer.baseY + buffer.cursorY
const marks = paneSemanticMarksRef.current.get(pane.id) ?? []
pushSemanticMark(marks, { ...mark, row })
paneSemanticMarksRef.current.set(pane.id, marks)
return true
})
]
shellIntegrationDisposablesRef.current.set(pane.id, shellIntegrationDisposables)
const linkProviderDisposable = pane.terminal.registerLinkProvider(
createFilePathLinkProvider(pane.id, linkDeps, pane.linkTooltip, fileOpenLinkHint)
)
@ -443,6 +481,15 @@ export function useTerminalPaneLifecycle({
osc52Disposable.dispose()
osc52DisposablesRef.current.delete(paneId)
}
const shellIntegrationDisposables = shellIntegrationDisposablesRef.current.get(paneId)
if (shellIntegrationDisposables) {
for (const d of shellIntegrationDisposables) {
d.dispose()
}
shellIntegrationDisposablesRef.current.delete(paneId)
}
paneCwdRef.current.delete(paneId)
paneSemanticMarksRef.current.delete(paneId)
const transport = paneTransportsRef.current.get(paneId)
const panePtyBinding = panePtyBindings.get(paneId)
if (panePtyBinding) {