From 1a3b2206510a836b9da888adf09a28ae6999968e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 12 Jul 2026 02:45:19 -0400 Subject: [PATCH] fix(desktop-terminal): reopen terminal tabs in the last-used directory A reopened tab restarted the shell in its original launch dir, so the fresh prompt showed the wrong folder after a prior `cd` (the issue's "separate thing" note). Track the shell's working directory and restart the PTY there. Two independent signals feed a persisted per-tab restoreCwd: - a main-side PTY cwd probe (shell-agnostic; /proc on Linux, lsof on macOS; Windows has no cheap per-process query so it falls back to the launch dir) - cwd-reporting OSC sequences parsed in the renderer (OSC 7 file URIs, OSC 9;9 ConEmu/Windows-Terminal paths) for shells configured to emit them On relaunch the fresh shell boots in restoreCwd, falling back to the launch cwd (then home) when it no longer exists. --- apps/desktop/electron/main.ts | 57 +++++++++- apps/desktop/electron/preload.ts | 1 + .../app/right-sidebar/terminal/instance.tsx | 11 +- .../terminal/revive-buffer.test.ts | 27 ++++- .../right-sidebar/terminal/terminals.test.ts | 33 ++++++ .../app/right-sidebar/terminal/terminals.ts | 29 +++++ .../terminal/use-terminal-session.ts | 105 +++++++++++++++++- .../app/right-sidebar/terminal/workspace.tsx | 1 + apps/desktop/src/global.d.ts | 4 + 9 files changed, 263 insertions(+), 5 deletions(-) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index a19201147cee..df73863a53c2 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -1,6 +1,6 @@ type Method = string import type { ExecFileSyncOptionsWithStringEncoding } from 'node:child_process' -import { execFileSync, spawn } from 'node:child_process' +import { execFile, execFileSync, spawn } from 'node:child_process' import crypto from 'node:crypto' import fs from 'node:fs' import http from 'node:http' @@ -8353,6 +8353,51 @@ function terminalChannel(id, suffix) { return `hermes:terminal:${id}:${suffix}` } +// Best-effort read of a live PTY child's current working directory so a +// reopened tab can restart the shell where the user last `cd`'d, instead of the +// tab's original launch dir. Shell-agnostic (no prompt/OSC config needed) on +// POSIX; Windows has no cheap per-process cwd query without a native module, so +// it returns null and the caller falls back to the launch cwd. +function readProcessCwd(pid) { + return new Promise(resolve => { + if (!Number.isInteger(pid) || pid <= 0) { + resolve(null) + + return + } + + if (process.platform === 'linux') { + fs.promises + .readlink(`/proc/${pid}/cwd`) + .then(target => resolve(target || null)) + .catch(() => resolve(null)) + + return + } + + if (process.platform === 'darwin') { + // lsof ships with macOS; -Fn emits the cwd fd's path on an `n` line. + execFile('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], { timeout: 2000 }, (err, stdout) => { + if (err) { + resolve(null) + + return + } + + const line = String(stdout || '') + .split('\n') + .find(entry => entry.startsWith('n')) + + resolve(line ? line.slice(1) : null) + }) + + return + } + + resolve(null) + }) +} + function disposeTerminalSession(id) { const sessionInfo = terminalSessions.get(id) @@ -8591,6 +8636,16 @@ ipcMain.handle('hermes:terminal:resize', (_event, id, size = {}) => { return true }) +ipcMain.handle('hermes:terminal:cwd', async (_event, id) => { + const sessionInfo = terminalSessions.get(String(id || '')) + + if (!sessionInfo) { + return null + } + + return readProcessCwd(sessionInfo.pty.pid) +}) + ipcMain.handle('hermes:terminal:dispose', (_event, id) => disposeTerminalSession(String(id || ''))) ipcMain.handle('hermes:updates:check', async () => diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 8b6a14b0cf7c..35d31e2d294b 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -136,6 +136,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { } }, terminal: { + cwd: id => ipcRenderer.invoke('hermes:terminal:cwd', id), dispose: id => ipcRenderer.invoke('hermes:terminal:dispose', id), resize: (id, size) => ipcRenderer.invoke('hermes:terminal:resize', id, size), start: options => ipcRenderer.invoke('hermes:terminal:start', options), diff --git a/apps/desktop/src/app/right-sidebar/terminal/instance.tsx b/apps/desktop/src/app/right-sidebar/terminal/instance.tsx index 399407d81696..933916b640ef 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/instance.tsx +++ b/apps/desktop/src/app/right-sidebar/terminal/instance.tsx @@ -19,12 +19,20 @@ interface TerminalInstanceProps { cwd: string active: boolean onAddSelectionToChat: (text: string, label?: string) => void + restoreCwd?: string reviveBuffer?: string } /** One persistent xterm+PTY. Every open tab stays mounted (so its shell and * scrollback survive tab switches); only the active one is shown. */ -export function TerminalInstance({ id, active, cwd, onAddSelectionToChat, reviveBuffer }: TerminalInstanceProps) { +export function TerminalInstance({ + id, + active, + cwd, + onAddSelectionToChat, + restoreCwd, + reviveBuffer +}: TerminalInstanceProps) { const { t } = useI18n() const { addSelectionToChat, hostRef, selection, selectionStyle, status } = useTerminalSession({ @@ -32,6 +40,7 @@ export function TerminalInstance({ id, active, cwd, onAddSelectionToChat, revive cwd, active, onAddSelectionToChat, + restoreCwd, reviveBuffer, onShell: shell => reportTerminalShell(id, shell) }) diff --git a/apps/desktop/src/app/right-sidebar/terminal/revive-buffer.test.ts b/apps/desktop/src/app/right-sidebar/terminal/revive-buffer.test.ts index aee88ec30e59..9e02179368e2 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/revive-buffer.test.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/revive-buffer.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { cleanReviveSnapshot, isIdlePromptOnly } from './use-terminal-session' +import { cleanReviveSnapshot, isIdlePromptOnly, parseOscCwd } from './use-terminal-session' // A default-PowerShell idle prompt: no blank-line separator before it. const PS_PROMPT = 'PS C:\\Users\\Aleksandr>' @@ -70,3 +70,28 @@ describe('cleanReviveSnapshot', () => { expect(cleanReviveSnapshot('\r\n \r\n')).toBe('') }) }) + +describe('parseOscCwd', () => { + it('parses an OSC 7 file URI and percent-decodes it', () => { + expect(parseOscCwd(7, 'file://host/Users/al/my%20project')).toBe('/Users/al/my project') + }) + + it('strips the leading slash from a Windows OSC 7 file URI', () => { + expect(parseOscCwd(7, 'file:///C:/Users/Aleksandr/project')).toBe('C:/Users/Aleksandr/project') + }) + + it('ignores non-file OSC 7 payloads', () => { + expect(parseOscCwd(7, 'https://example.com')).toBeNull() + expect(parseOscCwd(7, '')).toBeNull() + }) + + it('parses an OSC 9;9 cwd payload and unquotes it', () => { + expect(parseOscCwd(9, '9;"C:\\Users\\Aleksandr"')).toBe('C:\\Users\\Aleksandr') + expect(parseOscCwd(9, '9;/home/al/src')).toBe('/home/al/src') + }) + + it('ignores OSC 9 sub-commands other than 9; (e.g. progress)', () => { + expect(parseOscCwd(9, '4;3')).toBeNull() + expect(parseOscCwd(9, 'some notification')).toBeNull() + }) +}) diff --git a/apps/desktop/src/app/right-sidebar/terminal/terminals.test.ts b/apps/desktop/src/app/right-sidebar/terminal/terminals.test.ts index b04e1e12710c..eb99e7a78211 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/terminals.test.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/terminals.test.ts @@ -87,4 +87,37 @@ describe('terminal store persistence', () => { closeAllTerminals() expect(window.localStorage.getItem(STORAGE_KEY)).toBeNull() }) + + it('restores and persists the last observed cwd so a reopened tab lands where the user cd-d', async () => { + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + activeTerminalId: 'term-one', + terminals: [{ auto: false, cwd: '/repo', id: 'term-one', restoreCwd: '/repo/packages/api', title: 'zsh' }] + }) + ) + + const { $terminals, updateTerminalRestoreCwd } = await loadTerminalStore() + + expect($terminals.get()[0]?.restoreCwd).toBe('/repo/packages/api') + + updateTerminalRestoreCwd('term-one', '/repo/packages/web') + expect($terminals.get()[0]?.restoreCwd).toBe('/repo/packages/web') + expect(JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? '{}').terminals[0].restoreCwd).toBe( + '/repo/packages/web' + ) + }) + + it('never attaches a restore cwd to an agent tab and ignores empty values', async () => { + const { $terminals, createTerminal, ensureAgentTerminal, updateTerminalRestoreCwd } = await loadTerminalStore() + + const userId = createTerminal('/repo') + const agentId = ensureAgentTerminal('proc-1', 'background task')! + + updateTerminalRestoreCwd(agentId, '/somewhere') + updateTerminalRestoreCwd(userId, ' ') + + expect($terminals.get().find(term => term.id === agentId)?.restoreCwd).toBeUndefined() + expect($terminals.get().find(term => term.id === userId)?.restoreCwd).toBeUndefined() + }) }) diff --git a/apps/desktop/src/app/right-sidebar/terminal/terminals.ts b/apps/desktop/src/app/right-sidebar/terminal/terminals.ts index 2d716242ab3b..c86f57aeeae7 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/terminals.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/terminals.ts @@ -19,6 +19,10 @@ export interface TerminalEntry { * (the project root if opened in one, else the backend's default). Switching * sessions never moves or recreates a terminal. */ cwd: string + /** Last observed working directory of the live shell (tracked via the PTY + * cwd probe / OSC 7). Used to reopen the tab where the user last `cd`'d + * rather than the original launch dir. User tabs only. */ + restoreCwd?: string /** Serialized xterm scrollback from the last session, replayed on relaunch so * the tab reopens with its recent history (VS Code parity). Processes are NOT * revived — a fresh shell starts beneath the restored buffer. Captured live @@ -34,6 +38,7 @@ interface PersistedTerminalEntry { auto: boolean cwd: string id: string + restoreCwd?: string reviveBuffer?: string title: string } @@ -59,6 +64,7 @@ function sanitizePersistedTerminal(value: unknown): PersistedTerminalEntry | nul const id = typeof record.id === 'string' ? record.id.trim() : '' const title = typeof record.title === 'string' ? record.title.trim() : '' const cwd = typeof record.cwd === 'string' ? record.cwd : '' + const restoreCwd = typeof record.restoreCwd === 'string' && record.restoreCwd ? record.restoreCwd : undefined const reviveBuffer = typeof record.reviveBuffer === 'string' ? record.reviveBuffer : undefined if (!id) { @@ -69,6 +75,7 @@ function sanitizePersistedTerminal(value: unknown): PersistedTerminalEntry | nul auto: typeof record.auto === 'boolean' ? record.auto : true, cwd, id, + ...(restoreCwd ? { restoreCwd } : {}), ...(reviveBuffer ? { reviveBuffer } : {}), title: title || 'Terminal' } @@ -116,6 +123,7 @@ function persistTerminals(list: readonly TerminalEntry[], activeTerminalId: null auto: term.auto, cwd: term.cwd, id: term.id, + ...(term.restoreCwd ? { restoreCwd: term.restoreCwd } : {}), ...(term.reviveBuffer ? { reviveBuffer: term.reviveBuffer } : {}), title: term.title })) @@ -309,6 +317,27 @@ export function updateTerminalReviveBuffer(id: string, reviveBuffer: string): vo ) } +/** Record the shell's latest working directory for a tab so the next launch can + * restart the PTY there instead of the original launch dir. User tabs only; + * no-ops when the value is empty or unchanged to avoid redundant persistence. */ +export function updateTerminalRestoreCwd(id: string, restoreCwd: string): void { + const next = restoreCwd.trim() + + if (!next) { + return + } + + $terminals.set( + $terminals.get().map(term => { + if (term.id !== id || term.kind !== 'user' || term.restoreCwd === next) { + return term + } + + return { ...term, restoreCwd: next } + }) + ) +} + export function renameTerminal(id: string, title: string): void { const trimmed = title.trim() diff --git a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts index e1c77baaf008..c1b09487c6fa 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts @@ -21,7 +21,7 @@ import { terminalSelectionLabel, terminalTheme } from './selection' -import { closeTerminal, updateTerminalReviveBuffer } from './terminals' +import { closeTerminal, updateTerminalRestoreCwd, updateTerminalReviveBuffer } from './terminals' // How many scrollback lines to serialize for relaunch restore. Mirrors VS Code's // terminal.integrated.persistentSessionScrollback default; the store caps the @@ -33,6 +33,11 @@ const PERSISTENT_SESSION_SCROLLBACK = 200 // renderer tears down), then at most once per window while output streams. const SNAPSHOT_THROTTLE_MS = 750 +// Minimum gap between main-side PTY cwd probes. The probe spawns lsof on macOS, +// so keep it well throttled — cwd only changes on a `cd`, which the reporter +// already reads off the next output snapshot anyway. +const CWD_PROBE_THROTTLE_MS = 2000 + // True once the page/app is tearing down (Cmd+Q, Alt+F4, window close, reload). // App quit kills the PTYs from the main process, which fires onExit in the // renderer — but React skips effect cleanups on teardown, so the per-instance @@ -224,12 +229,52 @@ interface UseTerminalSessionOptions { /** Only the active tab is visible, owns the agent reader, and runs injections. */ active: boolean onAddSelectionToChat: (text: string, label?: string) => void + /** Last observed shell cwd from the previous session; the fresh PTY starts + * here (falling back to `cwd`) so a prior `cd` survives a relaunch. */ + restoreCwd?: string /** Serialized scrollback from the previous session, replayed once on mount. */ reviveBuffer?: string /** Reports the resolved shell name once the PTY is live (for the tab label). */ onShell?: (shell: string) => void } +// Parse a working directory out of a cwd-reporting OSC payload. Covers OSC 7 +// (`file://host/path`, emitted by many bash/zsh integrations) and OSC 9;9 +// (`9;`, ConEmu/Windows-Terminal style some PowerShell profiles emit). +// Returns null for anything unrecognized so callers can ignore it. +export function parseOscCwd(code: 7 | 9, payload: string): string | null { + if (code === 9) { + // OSC 9;9; — the leading "9;" selects the cwd sub-command. + if (!payload.startsWith('9;')) { + return null + } + + const raw = payload.slice(2).trim().replace(/^"|"$/g, '') + + return raw || null + } + + // OSC 7 — a file URI. Strip the scheme + authority and percent-decode. + const match = /^file:\/\/[^/]*(\/.*)$/.exec(payload.trim()) + + if (!match) { + return null + } + + let raw = match[1] + + try { + raw = decodeURIComponent(raw) + } catch { + // Keep the undecoded path if it isn't valid percent-encoding. + } + + // Windows file URIs carry a leading slash before the drive (`/C:/Users`). + const windows = /^\/[A-Za-z]:[\\/]/.exec(raw) + + return (windows ? raw.slice(1) : raw) || null +} + // Bind the palette to the live skin surface so the terminal blends with the app // (and the contrast clamp has a real background to work against). function withSurface(theme: ReturnType) { @@ -331,6 +376,7 @@ export function useTerminalSession({ cwd, active, onAddSelectionToChat, + restoreCwd, reviveBuffer, onShell }: UseTerminalSessionOptions) { @@ -353,6 +399,11 @@ export function useTerminalSession({ // Snapshot the revive buffer once: live snapshots feed updateTerminalReviveBuffer // and would otherwise re-arm replay on every store-driven re-render. const initialReviveBufferRef = useRef(reviveBuffer) + // The cwd to boot the fresh PTY in — the last dir the prior session observed + // (survives a `cd`), captured once so store-driven re-renders don't move it. + const initialRestoreCwdRef = useRef(restoreCwd) + // Latest cwd seen this session; de-dupes redundant store writes. + const lastObservedCwdRef = useRef(null) // Whether the user ever fed input into this session (keystrokes, paste, // drag-and-drop paths, or an injected command). Gates idle-buffer handling in // persistSnapshot so an untouched tab never re-saves an accumulating snapshot. @@ -494,6 +545,50 @@ export function useTerminalSession({ term.write('\r\n') } + // Track the shell's working directory so a reopened tab restarts where the + // user last `cd`'d. Two independent signals feed it: cwd-reporting OSC + // sequences (immediate, for shells configured to emit them) and a periodic + // PTY cwd probe on the main side (shell-agnostic on POSIX). The store + // updater de-dupes, so both feeding it is harmless. + const recordCwd = (next: string | null | undefined) => { + const value = (next ?? '').trim() + + if (!value || value === lastObservedCwdRef.current) { + return + } + + lastObservedCwdRef.current = value + updateTerminalRestoreCwd(id, value) + } + + const cwdOscHandlers = ([7, 9] as const).map(code => + term.parser.registerOscHandler(code, payload => { + recordCwd(parseOscCwd(code, payload)) + + return false // let the sequence propagate; we only observe it + }) + ) + + cleanup.push(() => cwdOscHandlers.forEach(handler => handler.dispose())) + + let cwdProbeAt = 0 + + const probeCwd = () => { + const sessionId = sessionIdRef.current + + if (!sessionId || !terminalApi.cwd || Date.now() - cwdProbeAt < CWD_PROBE_THROTTLE_MS) { + return + } + + cwdProbeAt = Date.now() + void terminalApi + .cwd(sessionId) + .then(recordCwd) + .catch(() => { + // Best-effort: no cwd probe on this platform (e.g. Windows). + }) + } + // Capture the buffer on a leading-edge throttle and persist synchronously via // the store. No unload hook: by the time the user quits, a recent snapshot is // already on disk (the prior beforeunload-based attempt lost the last output). @@ -529,6 +624,9 @@ export function useTerminalSession({ } catch { // Best-effort restore: never let serialization break a live terminal. } + + // A user command may have `cd`'d; refresh the persisted cwd (throttled). + probeCwd() } const scheduleSnapshot = () => { @@ -700,7 +798,10 @@ export function useTerminalSession({ const startSession = () => void terminalApi - .start({ cols: term.cols, cwd, rows: term.rows }) + // Prefer the prior session's last cwd so a reopened tab lands where the + // user last `cd`'d; the main side falls back to the launch cwd (then + // home) if that dir no longer exists. + .start({ cols: term.cols, cwd: initialRestoreCwdRef.current || cwd, rows: term.rows }) .then(session => { if (disposed) { void terminalApi.dispose(session.id) diff --git a/apps/desktop/src/app/right-sidebar/terminal/workspace.tsx b/apps/desktop/src/app/right-sidebar/terminal/workspace.tsx index b8b62f509996..a15899c816b1 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/workspace.tsx +++ b/apps/desktop/src/app/right-sidebar/terminal/workspace.tsx @@ -56,6 +56,7 @@ export function TerminalWorkspace({ onAddSelectionToChat }: TerminalWorkspacePro id={term.id} key={term.id} onAddSelectionToChat={onAddSelectionToChat} + restoreCwd={term.restoreCwd} reviveBuffer={term.reviveBuffer} /> ) diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index a6f82fa309d9..b7d0fd8cd1d9 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -163,6 +163,10 @@ declare global { scanRepos: (roots: string[], options?: { maxDepth?: number }) => Promise<{ root: string; label: string }[]> } terminal: { + /** Best-effort current working directory of the live PTY child (POSIX + * only; null on Windows or when unavailable). Used to reopen a tab + * where the user last `cd`'d. */ + cwd: (id: string) => Promise dispose: (id: string) => Promise onData: (id: string, callback: (payload: string) => void) => () => void onExit: (id: string, callback: (payload: HermesTerminalExit) => void) => () => void