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.
This commit is contained in:
Brooklyn Nicholson 2026-07-12 02:45:19 -04:00
parent 817969f5a2
commit 1a3b220651
9 changed files with 263 additions and 5 deletions

View file

@ -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<path>` 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 () =>

View file

@ -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),

View file

@ -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)
})

View file

@ -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;<path> (e.g. progress)', () => {
expect(parseOscCwd(9, '4;3')).toBeNull()
expect(parseOscCwd(9, 'some notification')).toBeNull()
})
})

View file

@ -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()
})
})

View file

@ -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()

View file

@ -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;<path>`, 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;<path> — 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<typeof terminalTheme>) {
@ -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<string | null>(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)

View file

@ -56,6 +56,7 @@ export function TerminalWorkspace({ onAddSelectionToChat }: TerminalWorkspacePro
id={term.id}
key={term.id}
onAddSelectionToChat={onAddSelectionToChat}
restoreCwd={term.restoreCwd}
reviveBuffer={term.reviveBuffer}
/>
)

View file

@ -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<string | null>
dispose: (id: string) => Promise<boolean>
onData: (id: string, callback: (payload: string) => void) => () => void
onExit: (id: string, callback: (payload: HermesTerminalExit) => void) => () => void