diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index a19201147ce..df73863a53c 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 8b6a14b0cf7..35d31e2d294 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 399407d8169..933916b640e 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 new file mode 100644 index 00000000000..9e02179368e --- /dev/null +++ b/apps/desktop/src/app/right-sidebar/terminal/revive-buffer.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest' + +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>' + +describe('isIdlePromptOnly', () => { + it('is true for an empty or whitespace-only buffer', () => { + expect(isIdlePromptOnly('')).toBe(true) + expect(isIdlePromptOnly('\r\n \r\n')).toBe(true) + }) + + it('is true for a lone prompt line', () => { + expect(isIdlePromptOnly(PS_PROMPT)).toBe(true) + }) + + it('is true for a buffer that is only repeated identical prompts (accumulation)', () => { + expect(isIdlePromptOnly([PS_PROMPT, PS_PROMPT, PS_PROMPT].join('\r\n'))).toBe(true) + }) + + it('ignores blank gaps between repeated prompts (the "gapped" variant)', () => { + expect(isIdlePromptOnly([PS_PROMPT, '', '', PS_PROMPT].join('\r\n'))).toBe(true) + }) + + it('is false when the buffer holds a real command and output', () => { + expect(isIdlePromptOnly([PS_PROMPT, 'cd project', 'PS C:\\Users\\Aleksandr\\project>'].join('\r\n'))).toBe(false) + }) + + it('is false when two different prompts are present (cwd actually changed)', () => { + expect(isIdlePromptOnly([PS_PROMPT, 'PS C:\\Users\\Aleksandr\\project>'].join('\r\n'))).toBe(false) + }) +}) + +describe('cleanReviveSnapshot', () => { + it('drops a spaced trailing prompt block after a blank separator (starship)', () => { + const snapshot = ['echo hi', 'hi', '', PS_PROMPT].join('\r\n') + + expect(cleanReviveSnapshot(snapshot)).toBe('echo hi\r\nhi') + }) + + it('drops a multi-line prompt block after a blank separator (powerline)', () => { + const snapshot = ['work', '', '┌─ user@host ~/project', '└─$'].join('\r\n') + + expect(cleanReviveSnapshot(snapshot)).toBe('work') + }) + + it('drops a single-line trailing prompt with no preceding blank line (PowerShell)', () => { + // Default PowerShell prints no blank line before its prompt; the fresh shell + // reprints it on boot, so the redundant idle prompt must be trimmed here. + const snapshot = ['echo hi', 'hi', PS_PROMPT].join('\r\n') + + expect(cleanReviveSnapshot(snapshot)).toBe('echo hi\r\nhi') + }) + + it('keeps command output and drops only the trailing prompt on a long history', () => { + const history = ['cmd1', 'out1', 'cmd2', 'out2'] + const snapshot = [...history, PS_PROMPT].join('\r\n') + + expect(cleanReviveSnapshot(snapshot)).toBe(history.join('\r\n')) + }) + + it('reduces a lone prompt to an empty buffer', () => { + expect(cleanReviveSnapshot(PS_PROMPT)).toBe('') + expect(cleanReviveSnapshot([PS_PROMPT, '', ''].join('\r\n'))).toBe('') + }) + + it('returns empty for a blank-only buffer without throwing', () => { + expect(cleanReviveSnapshot('')).toBe('') + 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 b04e1e12710..eb99e7a7821 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 2d716242ab3..c86f57aeeae 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 931b8fec624..c1b09487c6f 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 @@ -168,38 +173,55 @@ function stripInitialPromptGap(data: string) { return prefix } +// A row's content with ANSI escapes and all whitespace stripped — '' for a +// spacer / prompt-gap / zsh `%` marker row. +const visibleText = (line: string) => stripEscapeSequences(line).replace(/[\s%]/g, '') + // Trim the shell's trailing idle prompt from a serialized snapshot before it's // persisted. Without it, the saved buffer ends in the old prompt, so the next -// launch replays it directly above the fresh shell's prompt ("double bar"). The -// prompt is the short block after the last blank line (starship's add_newline -// gap); only a short tail is dropped, so real command output is never trimmed and -// configs without that blank line simply keep the historical prompt (no loss). -function cleanReviveSnapshot(serialized: string): string { - const visible = (line: string) => stripEscapeSequences(line).replace(/[\s%]/g, '') +// launch replays it directly above the fresh shell's prompt ("double bar"). +// +// An interactive shell always reprints its prompt after a command finishes, so +// the tail of an idle buffer is the prompt, never real history. Two prompt +// shapes exist: +// - Spaced/multi-line (starship add_newline, powerline): a blank line sits +// just above the prompt, so the short block after the last blank is dropped. +// - Single-line (default PowerShell `PS C:\..>`, bash `user@host:~$`): no blank +// separator, so the final line itself is the prompt and is dropped. +// The fresh shell reprints the current prompt on boot either way, so only the +// redundant idle prompt is removed — command output is preserved. +export function cleanReviveSnapshot(serialized: string): string { const lines = serialized.split(/\r?\n/) - while (lines.length && visible(lines[lines.length - 1]) === '') { + while (lines.length && !visibleText(lines[lines.length - 1])) { lines.pop() } - let lastBlank = -1 - - for (let i = lines.length - 1; i >= 0; i -= 1) { - if (visible(lines[i]) === '') { - lastBlank = i - - break - } + if (lines.length === 0) { + return '' } - // A prompt is a short block; a long tail after the blank is real output, leave it. - if (lastBlank >= 0 && lines.length - 1 - lastBlank <= 3) { - lines.length = lastBlank - } + const lastBlank = lines.findLastIndex(line => !visibleText(line)) + const spacedPrompt = lastBlank >= 0 && lines.length - 1 - lastBlank <= 3 + + // Spaced prompt (starship/powerline): drop the block after the blank + // separator. Otherwise the last line is the single-line prompt itself. + lines.length = spacedPrompt ? lastBlank : lines.length - 1 return lines.join('\r\n') } +// True when a revive buffer holds no real scrollback: empty, or only repeats of +// one line (the idle prompt). This is the idle-accumulation signature (#61572) — +// each relaunch replayed the saved prompt(s) and the fresh shell printed one more +// below, growing the tab by a line per cycle. Real sessions vary (prompt + +// command + output), so genuine short histories are never mistaken for idle. +export function isIdlePromptOnly(serialized: string): boolean { + const lines = serialized.split(/\r?\n/).map(visibleText).filter(Boolean) + + return lines.length === 0 || lines.every(line => line === lines[0]) +} + interface UseTerminalSessionOptions { /** Renderer-side terminal id (the tab handle), used to key the agent reader. */ id: string @@ -207,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) { @@ -314,6 +376,7 @@ export function useTerminalSession({ cwd, active, onAddSelectionToChat, + restoreCwd, reviveBuffer, onShell }: UseTerminalSessionOptions) { @@ -336,6 +399,15 @@ 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. + const hasSessionActivityRef = useRef(false) const shellNameRef = useRef('shell') const selectionLabelRef = useRef('') const selectionRef = useRef('') @@ -473,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). @@ -486,12 +602,31 @@ export function useTerminalSession({ lastSnapshotAt = Date.now() + // No user input this session: never re-serialize. The live buffer now holds + // the replayed history plus a fresh boot prompt, and re-saving that is + // exactly what grew idle tabs by one prompt line per relaunch (#61572). + // If the buffer we loaded carried no real scrollback (empty, or only a + // repeated prompt), clear it so the next launch shows a single fresh prompt + // and any pre-existing accumulation heals. Otherwise leave the prior + // snapshot untouched so real history from an earlier active session + // survives an idle reopen instead of being overwritten. + if (!hasSessionActivityRef.current) { + if (isIdlePromptOnly(initialReviveBufferRef.current ?? '')) { + updateTerminalReviveBuffer(id, '') + } + + return + } + try { const snapshot = serialize.serialize({ scrollback: PERSISTENT_SESSION_SCROLLBACK }) updateTerminalReviveBuffer(id, cleanReviveSnapshot(snapshot)) } 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 = () => { @@ -544,6 +679,7 @@ export function useTerminalSession({ return } + hasSessionActivityRef.current = true void terminalApi.write(id, `${paths.map(p => quotePathForShell(p, shellNameRef.current)).join(' ')} `) term.focus() triggerHaptic('selection') @@ -640,6 +776,7 @@ export function useTerminalSession({ }) const dataDisposable = term.onData(data => { + hasSessionActivityRef.current = true const id = sessionIdRef.current if (id) { @@ -661,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) @@ -846,6 +986,7 @@ export function useTerminalSession({ return } + hasSessionActivityRef.current = true void window.hermesDesktop?.terminal?.write(sessionId, `${command}\r`) $terminalInjection.set(null) termRef.current?.focus() diff --git a/apps/desktop/src/app/right-sidebar/terminal/workspace.tsx b/apps/desktop/src/app/right-sidebar/terminal/workspace.tsx index b8b62f50999..a15899c816b 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 a6f82fa309d..b7d0fd8cd1d 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