mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-16 14:32:34 +00:00
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:
parent
817969f5a2
commit
1a3b220651
9 changed files with 263 additions and 5 deletions
|
|
@ -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 () =>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ export function TerminalWorkspace({ onAddSelectionToChat }: TerminalWorkspacePro
|
|||
id={term.id}
|
||||
key={term.id}
|
||||
onAddSelectionToChat={onAddSelectionToChat}
|
||||
restoreCwd={term.restoreCwd}
|
||||
reviveBuffer={term.reviveBuffer}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
4
apps/desktop/src/global.d.ts
vendored
4
apps/desktop/src/global.d.ts
vendored
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue