mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-15 14:22:43 +00:00
Merge pull request #63023 from NousResearch/bb/salvage-61584-terminal
fix(desktop-terminal): fix idle prompt accumulation, double prompt, and cwd on relaunch
This commit is contained in:
commit
0dd6ced0cd
9 changed files with 393 additions and 23 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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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;<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
|
||||
|
|
@ -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;<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>) {
|
||||
|
|
@ -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<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.
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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