fix(terminal): clear the pane directory when a read says the session has none

applyPaneExits() only applied the manager's directory when it returned a
value, so after a successful read a session that was split into several
panes, or was no longer on the socket, kept its stale cwd. The manager
already keeps its map across a failed or empty read, so the server now
applies its answer as given, undefined included, and setCwd() broadcasts
the clear once.

A blank #{pane_current_path} on a single-pane session (tmux could not read
the process cwd that tick) now carries the previous entry forward in
derivePaneCurrentPaths(), so only a split or unlisted session clears.

The server tests now drive the real TmuxManager with only the tmux read
faked. docs/api-reference.md documents SessionState.cwd.
This commit is contained in:
Aamer Akhter committed 2026-10-28 15:28:59 -04:00
1 parent 1607b1e488
commit 54c1692350
5 files changed
+146 -21

No files matched your search

+17 -6
View File
@@ -432,13 +432,20 @@ export function derivePaneExits(rows: PaneRow[], now: number): Map<string, PaneE
* - Only sessions with EXACTLY ONE pane answer, for the reason
* {@link derivePaneExits} gives: a hand-split session has no single "the"
* pane to speak for it.
* - Only absolute paths count. A blank field is a tmux that did not answer.
* - Only absolute paths count. A blank field is a tmux that did not answer
* (it could not read the process cwd that tick), so a single-pane session
* with a blank field keeps its entry from `previous` instead of losing it.
* A session split into several panes, or one the read no longer lists, gets
* no entry: those are authoritative absences, not unanswered questions.
*
* The launch directory is NOT filtered here (unlike the rediscovery path): a
* remote or docker pane that never leaves it is refused by `Session.setCwd()`,
* and a local pane reporting it really is there.
*/
export function derivePaneCurrentPaths(rows: PaneRow[]): Map<string, string> {
export function derivePaneCurrentPaths(
rows: PaneRow[],
previous: ReadonlyMap<string, string> = new Map()
): Map<string, string> {
const panesPerSession = new Map<string, number>();
for (const row of rows) {
panesPerSession.set(row.sessionName, (panesPerSession.get(row.sessionName) ?? 0) + 1);
@@ -446,7 +453,7 @@ export function derivePaneCurrentPaths(rows: PaneRow[]): Map<string, string> {
const paths = new Map<string, string>();
for (const row of rows) {
if (panesPerSession.get(row.sessionName) !== 1) continue;
const path = row.currentPath;
const path = row.currentPath ?? previous.get(row.sessionName);
if (!path || !path.startsWith('/')) continue;
paths.set(row.sessionName, path);
}
@@ -3549,7 +3556,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* The directory the session's pane was in at the last authoritative pane
* read, or `undefined` when unknown. Costs nothing: it reads the map the
* pane-exit watcher's batched `list-panes -a` fills, so no tmux call is made
* per click. A failed or empty read keeps the previous answers.
* per click. A failed or empty read keeps the previous answers, and so does
* a blank `#{pane_current_path}` on a single-pane session. After a successful
* read, `undefined` means that read listed no single directory for the
* session: it was split into several panes or is no longer on the socket.
*/
getPaneCurrentPath(muxName: string): string | undefined {
return this.paneCurrentPaths.get(muxName);
@@ -3618,8 +3628,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (rows.length === 0) return;
// The directory reading rides the same read. It is applied before the
// generation check below: a respawn invalidates an exit observation, but the
// pane's directory at read time is still the best answer there is.
this.paneCurrentPaths = derivePaneCurrentPaths(rows);
// pane's directory at read time is still the best answer there is. A blank
// field on a single-pane session carries the previous answer forward.
this.paneCurrentPaths = derivePaneCurrentPaths(rows, this.paneCurrentPaths);
// A pane was respawned or killed while this read was out, so what it saw is
// already history. Dropping it is what stops a freshly respawned pane from
// being republished as exited.
+6 -4
View File
@@ -2819,10 +2819,12 @@ export class WebServer extends EventEmitter {
// for such a session anyway.
if (!muxName) continue;
// The pane's live directory rides the same read: it is the base a relative
// path printed in the terminal resolves against. A session missing from
// the read keeps its last answer (a failed read reports nothing).
const cwd = getPaneCurrentPath?.(muxName);
const cwdChanged = cwd !== undefined && session.setCwd(cwd);
// path printed in the terminal resolves against. The manager's answer is
// applied as given, `undefined` included: it keeps its map across a failed
// or empty read and carries a blank `#{pane_current_path}` forward, so an
// `undefined` here means an authoritative read listed no single directory
// for the session (split into several panes, or gone from the socket).
const cwdChanged = getPaneCurrentPath ? session.setCwd(getPaneCurrentPath(muxName)) : false;
if (session.setPaneExit(getPaneExit(muxName))) {
this.persistSessionState(session);
} else if (!cwdChanged) {