Skip to content

Commit 859e1d7

Browse files
committed
poll terminal session state for non regular shells
1 parent d8f7970 commit 859e1d7

4 files changed

Lines changed: 188 additions & 0 deletions

File tree

apps/desktop/src/main/terminal/index.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,13 @@ const INPUT_ECHO_MS = 250
5858
/** Enough of the screen to show whether the input took, without a wall of it. */
5959
const INPUT_SCREEN_LINES = 60
6060

61+
/**
62+
* How often the active terminal's directory is reconciled against the OS.
63+
* Fast enough that a `cd` renames the tab about as soon as the user looks at
64+
* it, slow enough that the lookup is nowhere near a hot path.
65+
*/
66+
const CWD_POLL_MS = 1_000
67+
6168
function delay(ms: number): Promise<void> {
6269
return new Promise((resolve) => setTimeout(resolve, ms))
6370
}
@@ -117,6 +124,7 @@ export class TerminalService {
117124
private nextId = 1
118125
private sink: TerminalSink | null = null
119126
private lastEmittedTabs: string | null = null
127+
private cwdTimer: NodeJS.Timeout | null = null
120128

121129
constructor(private readonly options: TerminalServiceOptions = {}) {}
122130

@@ -125,6 +133,32 @@ export class TerminalService {
125133
// A new sink has seen nothing, so the dedupe baseline has to reset or the
126134
// panel would wait for an unrelated change before learning the tab list.
127135
this.lastEmittedTabs = null
136+
if (sink) this.startCwdWatch()
137+
else this.stopCwdWatch()
138+
}
139+
140+
/**
141+
* Keeps the visible tab's directory honest without depending on the shell.
142+
*
143+
* Only the active session is polled, and only while a panel is attached and
144+
* nothing is running in the foreground (a running command owns the label
145+
* anyway), so this costs one cheap lookup a second at most — the reason it
146+
* samples rather than watching every keystroke.
147+
*/
148+
private startCwdWatch(): void {
149+
if (this.cwdTimer) return
150+
this.cwdTimer = setInterval(() => {
151+
const active = this.activeId ? this.sessions.get(this.activeId) : null
152+
if (!active || active.isBusy) return
153+
void active.refreshCwd()
154+
}, CWD_POLL_MS)
155+
this.cwdTimer.unref?.()
156+
}
157+
158+
private stopCwdWatch(): void {
159+
if (!this.cwdTimer) return
160+
clearInterval(this.cwdTimer)
161+
this.cwdTimer = null
128162
}
129163

130164
getTabs(): TerminalTabsState {
@@ -173,6 +207,7 @@ export class TerminalService {
173207
}
174208
this.activeId = terminalId
175209
this.emitTabs()
210+
void this.sessions.get(terminalId)?.refreshCwd()
176211
return this.getTabs()
177212
}
178213

@@ -201,6 +236,7 @@ export class TerminalService {
201236
}
202237

203238
dispose(): void {
239+
this.stopCwdWatch()
204240
for (const session of this.sessions.values()) session.dispose()
205241
this.sessions.clear()
206242
this.activeId = null
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { parseLsofCwd, readProcessCwd } from '@/main/terminal/process-cwd'
3+
4+
describe('parseLsofCwd', () => {
5+
it('picks the path out of lsof field output', () => {
6+
expect(parseLsofCwd('p123\nfcwd\nn/Users/me/project\n')).toBe('/Users/me/project')
7+
})
8+
9+
it('keeps paths containing spaces intact', () => {
10+
expect(parseLsofCwd('p1\nfcwd\nn/Users/me/My Code/app\n')).toBe('/Users/me/My Code/app')
11+
})
12+
13+
it('returns null when no path field is present', () => {
14+
expect(parseLsofCwd('')).toBeNull()
15+
expect(parseLsofCwd('p123\nfcwd\n')).toBeNull()
16+
// A field line that is not an absolute path is not a cwd.
17+
expect(parseLsofCwd('p123\nnrelative/path\n')).toBeNull()
18+
})
19+
})
20+
21+
describe('readProcessCwd', () => {
22+
it('rejects invalid pids without touching the OS', async () => {
23+
await expect(readProcessCwd(0)).resolves.toBeNull()
24+
await expect(readProcessCwd(-1)).resolves.toBeNull()
25+
await expect(readProcessCwd(Number.NaN)).resolves.toBeNull()
26+
})
27+
28+
it('resolves this process to a real directory', async () => {
29+
const cwd = await readProcessCwd(process.pid)
30+
// Unsupported platforms report null rather than guessing.
31+
if (process.platform !== 'darwin' && process.platform !== 'linux') {
32+
expect(cwd).toBeNull()
33+
return
34+
}
35+
expect(cwd?.startsWith('/')).toBe(true)
36+
})
37+
38+
it('returns null for a pid that does not exist', async () => {
39+
await expect(readProcessCwd(2_147_483_600)).resolves.toBeNull()
40+
})
41+
})
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* Reads a running process's working directory from the OS.
3+
*
4+
* The shell announces `cd` through its shell-integration hooks, but only when
5+
* those hooks are installed — a shell we cannot instrument (fish, nushell), a
6+
* config that clobbers the prompt hooks, or a shell started before the hooks
7+
* loaded leaves the reported directory frozen at spawn. Asking the OS for the
8+
* shell's real cwd needs no cooperation from the shell at all, so it is the
9+
* source of truth the tab title falls back on. This mirrors how VS Code
10+
* resolves a terminal's cwd (`lsof` on macOS, procfs on Linux).
11+
*/
12+
import { spawn } from 'node:child_process'
13+
import { readlink } from 'node:fs/promises'
14+
import { createLogger } from '@sim/logger'
15+
16+
const logger = createLogger('DesktopTerminalProcessCwd')
17+
18+
/** A hung `lsof` must never wedge the poller; the call is best-effort. */
19+
const LOOKUP_TIMEOUT_MS = 2_000
20+
21+
/**
22+
* The current working directory of `pid`, or null when it cannot be resolved
23+
* (process gone, permission denied, unsupported platform). Never throws.
24+
*/
25+
export async function readProcessCwd(pid: number): Promise<string | null> {
26+
if (!Number.isInteger(pid) || pid <= 0) return null
27+
if (process.platform === 'linux') return readProcCwd(pid)
28+
if (process.platform === 'darwin') return readLsofCwd(pid)
29+
return null
30+
}
31+
32+
/** Linux: the kernel exposes the cwd as a symlink, so no subprocess is needed. */
33+
async function readProcCwd(pid: number): Promise<string | null> {
34+
try {
35+
return await readlink(`/proc/${pid}/cwd`)
36+
} catch {
37+
return null
38+
}
39+
}
40+
41+
/**
42+
* macOS has no procfs, so the cwd comes from `lsof`. The query is narrowed to
43+
* one process and one descriptor (`-a -d cwd -p <pid>`) and asks for field
44+
* output (`-Fn`), which prints `n<path>` — far cheaper than an unfiltered
45+
* `lsof` that would enumerate every open file on the system.
46+
*/
47+
function readLsofCwd(pid: number): Promise<string | null> {
48+
return new Promise((resolve) => {
49+
let child: ReturnType<typeof spawn>
50+
try {
51+
child = spawn('lsof', ['-a', '-d', 'cwd', '-p', String(pid), '-Fn'], {
52+
stdio: ['ignore', 'pipe', 'ignore'],
53+
})
54+
} catch (error) {
55+
logger.warn('Could not run lsof for terminal cwd', { error: (error as Error).message })
56+
resolve(null)
57+
return
58+
}
59+
60+
let stdout = ''
61+
let settled = false
62+
const finish = (value: string | null) => {
63+
if (settled) return
64+
settled = true
65+
clearTimeout(timer)
66+
resolve(value)
67+
}
68+
69+
const timer = setTimeout(() => {
70+
child.kill('SIGKILL')
71+
finish(null)
72+
}, LOOKUP_TIMEOUT_MS)
73+
74+
child.stdout?.on('data', (chunk: Buffer) => {
75+
stdout += chunk.toString()
76+
})
77+
child.on('error', () => finish(null))
78+
child.on('close', () => finish(parseLsofCwd(stdout)))
79+
})
80+
}
81+
82+
/**
83+
* Picks the path out of `lsof -Fn` field output, whose lines are a one-letter
84+
* field type followed by the value (`n/Users/me/project`).
85+
*/
86+
export function parseLsofCwd(stdout: string): string | null {
87+
for (const line of stdout.split('\n')) {
88+
if (line.startsWith('n/')) return line.slice(1)
89+
}
90+
return null
91+
}

apps/desktop/src/main/terminal/session.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
type TerminalTabState,
2525
} from '@sim/terminal-protocol'
2626
import { Terminal as HeadlessTerminal } from '@xterm/headless'
27+
import { readProcessCwd } from '@/main/terminal/process-cwd'
2728
import {
2829
buildShellLaunch,
2930
createNonce,
@@ -317,6 +318,25 @@ export class TerminalSession {
317318
return this.pty.pid
318319
}
319320

321+
/**
322+
* Reconciles the tracked cwd with the shell's real one, read from the OS.
323+
*
324+
* The shell-integration hooks report `cd` instantly when they are installed,
325+
* but they cannot be relied on alone: a shell we cannot instrument, a config
326+
* that overrides the prompt hooks, or a session whose hooks never loaded
327+
* would otherwise leave the tab labelled with the directory the shell
328+
* started in — the user's home, whose basename is their username. Asking the
329+
* OS needs no cooperation from the shell, so it is what makes the label
330+
* correct in every case.
331+
*/
332+
async refreshCwd(): Promise<void> {
333+
if (this.disposed) return
334+
const cwd = await readProcessCwd(this.pty.pid)
335+
if (this.disposed || !cwd || cwd === this.cwd) return
336+
this.cwd = cwd
337+
this.emitState()
338+
}
339+
320340
/**
321341
* The shell's environment. tmux invocations made on this terminal's behalf
322342
* reuse it so they reach the same server socket the user's client is on.

0 commit comments

Comments
 (0)