From 953a6cdb4a75edee56bd56b2af6c3c3295a31ff6 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:07:33 -0700 Subject: [PATCH] feat(terminal): record shell integration (OSC 7 cwd + OSC 133 marks) (#956) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../terminal-pane/TerminalContextMenu.tsx | 21 +++- .../components/terminal-pane/TerminalPane.tsx | 14 ++- .../terminal-pane/shell-integration.test.ts | 117 ++++++++++++++++++ .../terminal-pane/shell-integration.ts | 102 +++++++++++++++ .../use-terminal-pane-context-menu.ts | 33 ++++- .../use-terminal-pane-lifecycle.ts | 47 +++++++ 6 files changed, 330 insertions(+), 4 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/shell-integration.test.ts create mode 100644 src/renderer/src/components/terminal-pane/shell-integration.ts diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx index 318fb7a2a..292e7bbcb 100644 --- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx @@ -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 ( Set Title… + {menuPaneCwd && ( + + + {revealLabel} + + )} {canClosePane && ( <> diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 00721eaae..2b94533dc 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -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>(new Map()) const paneMode2031Ref = useRef>(new Map()) const paneLastThemeModeRef = useRef>(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>(new Map()) + const paneSemanticMarksRef = useRef>(new Map()) const panePtyBindingsRef = useRef>(new Map()) const pendingWritesRef = useRef>(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 diff --git a/src/renderer/src/components/terminal-pane/shell-integration.test.ts b/src/renderer/src/components/terminal-pane/shell-integration.test.ts new file mode 100644 index 000000000..6fccf1318 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/shell-integration.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/shell-integration.ts b/src/renderer/src/components/terminal-pane/shell-integration.ts new file mode 100644 index 000000000..99a751618 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/shell-integration.ts @@ -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 +} 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 c5dbc1bef..c4445df66 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 @@ -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> } type TerminalMenuState = { @@ -18,6 +22,10 @@ type TerminalMenuState = { menuOpenedAtRef: React.RefObject 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) => void onCopy: () => Promise onPaste: () => Promise @@ -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(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): 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 } } 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 63399b1c8..038752e02 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 @@ -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> paneMode2031Ref: React.RefObject> paneLastThemeModeRef: React.RefObject> + paneCwdRef: React.RefObject> + paneSemanticMarksRef: React.RefObject> panePtyBindingsRef: React.RefObject> pendingWritesRef: React.RefObject> isActiveRef: React.RefObject @@ -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()) const mode2031DisposablesRef = useRef(new Map()) const osc52DisposablesRef = useRef(new Map()) + const shellIntegrationDisposablesRef = useRef(new Map()) 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) {