Repository navigation
Conversation
…t directory
A relative path printed in the terminal was resolved against the session's
workingDir, which is fixed at create time and never follows a cd. In a shell
that moved into a subdirectory or a sibling repo, the link opened the wrong
file or reported "File not found".
The pane-exit watcher's batched list-panes read already carries
#{pane_current_path}. derivePaneCurrentPaths() turns those rows into one
directory per single-pane session, TmuxManager.getPaneCurrentPath() serves it
from memory, and the server applies it on the existing paneExitsUpdated tick
as SessionState.cwd, broadcasting only on a change. No new tmux call, no new
SSE event. A failed read keeps the previous answers.
Session.setCwd() uses the paneExit scoping rule: remote and Docker sessions
never get a cwd (their local pane runs the ssh or docker exec client), nor do
direct-PTY or rediscovered sessions. The value is runtime-only.
In the browser, relativeResolutionBase() picks the live cwd when known and
workingDir otherwise. It only changes the base a relative path joins: the
result is routed as before, so a target outside the workspace still goes
through the attachment routes, and file-content/file-raw are unchanged.
|
Thanks for this, aakhter. This PR makes a relative path printed in the terminal resolve against the directory the session's pane is in now ( One change is needed before merge: The session keeps a stale directory when an authoritative read stops answering for it ( The ask:
Optional: Still open: you asked whether the field should be named differently (for example Once the guard and the test are updated, this is ready for another review. |
…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.
|
Thanks for the review. All items are in 54c1692, on top of 1607b1e.
Same class, one step further: I checked every consumer of a per-session answer from the pane read for an Trade-off, stated plainly: a blank field keeps the last directory for as long as tmux leaves it blank, the same rule as a failed read; it does not expire after one tick. Still yours: the field name and whether Evidence:
|
|
Thanks for the second round, aakhter. This PR makes a relative path printed in the terminal or the response viewer resolve against the directory the session's tmux pane is in now, instead of the fixed One change is needed before merge: A workspace reached through a symlink turns every relative link into an out-of-workspace one ( The ask:
Two wording nits, which I can also fix at merge:
Still open: whether the field keeps the name Once the symlinked-workspace case is handled and pinned by a test, this is ready for another review. |
tmux reads a pane's directory from the process cwd, which is always the resolved path, while workingDir keeps the spelling the case was created with. For a workspace reached through a symlink every reading looked like it was outside the workspace, so the browser sent each relative link through the attachment route instead of the workspace routes. Each session now resolves workingDir once in the background, bounded by probePathKind() and a timed realpath(), and setCwd() writes a reading at or under the resolved path back in workingDir's spelling. Until that settles only a reading lexically inside workingDir is taken. An unknown probe or a failed realpath leaves readings untranslated and retries after WORKSPACE_REALPATH_RETRY_MS. Also corrects two comments: cwd is written to state.json but never read back, and a blank pane path is usually a pane whose program exited.
|
Thanks. Round 2 is in f23e18b. Symlinked workspace.
Wording.
Still yours: the field name ( Evidence.
|
|
Thanks for round 3, aakhter. This PR makes a relative path printed in the terminal or the response viewer resolve against the directory the session's tmux pane is in now, instead of the fixed The round-2 change is in and correct. Two small things, which I will apply at merge time, so there is nothing for you to push:
Still open: whether the field keeps the name |
Problem
#686 and #687 made relative paths in terminal output clickable. On master,
resolveLinkedFilePath()joins every relative path to the session'sworkingDir, which is set when the session is created and never changes after acd. Once a shell moves into a subdirectory or a sibling checkout,ls,git status,rg, compiler errors and test output all print paths relative to the new directory. Clicking one then opens the file with the same name in the workspace root, or the preview says "File not found" while the file is right there.Master already reads
#{pane_current_path}in the pane-exit watcher's batchedlist-panes -a(it is the last field ofPANE_LIST_FORMAT), but uses it only to rebuild a rediscovered session's directory at boot.Approach
derivePaneCurrentPaths()(tmux-manager.ts) turns the rows the pane-exit watcher already reads every 2 s into one directory per tmux session. Only sessions with exactly one pane count, for the same reasonderivePaneExits()gives, and only absolute paths are kept. A blank#{pane_current_path}on a single-pane session (tmux could not read the process cwd that tick) carries the previous entry forward; a split or unlisted session gets no entry.refreshPaneExits()saves the result before the generation check, so a respawn that races the read throws away the exit observation but keeps the directory.TmuxManager.getPaneCurrentPath()reads from that map, so a click or hover never runs tmux.workingDirkeeps the spelling the case was created with. Each session resolvesworkingDironce in the background (probePathKind()first, then arealpath()raced againstPATH_PROBE_TIMEOUT_MS, never on the watcher tick), andsetCwd()writes a reading at or under the resolved path back inworkingDir's spelling, so a symlinked~/codeman-cases, a case linked through a symlink or a macOS/tmpworkspace keeps its relative links on the workspace routes. Until the resolution settles, only a reading lexically insideworkingDiris taken. Anunknownprobe or a failed or slowrealpath()leaves readings untranslated and retries afterWORKSPACE_REALPATH_RETRY_MS(60 s).paneExit.Session.setCwd()usespaneExitApplies, so it fails closed for the same four cases: a remote session (its local pane runs the ssh client), a Docker case (its pane runsdocker exec), a direct-PTY session and a rediscovered one. A relative value is refused.applyPaneExits()(server.ts) also setsSessionState.cwdon thepaneExitsUpdatedtick and sendssession:updatedonly when the value changes. A directory change alone never persists. The field is written tostate.jsonwith the rest of the session state, but the constructor never reads it back, so a restart starts with no value. The server applies the manager's answer as given,undefinedincluded. The manager keeps its map across a failed or empty read and carries a blank field forward, so after a successful readundefinedmeans the read listed no single directory for the session: it was split into several panes or is no longer on the socket. Either waycwdis cleared,session:updatedis sent once for the clear, and the browser falls back toworkingDir.relativeResolutionBase(session)(constants.js) returns the livecwdwhen known, otherwiseworkingDir, and checksremote/dockeragain._resolveLinkedFilePath()(terminal-ui.js) uses it, so the terminal provider, the split pane, touch taps and the response viewer all resolve the same way.What can be opened does not change.
_isExternalPreviewPath(),file-content/file-rawand the log viewer'stail -fallowlist all still check againstworkingDir. A relative link under a pane that hascd'd outside the workspace becomes an absolute path outside it, and goes through the attachment routes (notify: false) with the full attachment guard, the same as clicking that absolute path on master.tmux reports the cwd of the pane's foreground process-group leader. A shell's
cdtherefore moves it, but a subprocess that an agent CLI starts (Claude's Bash tool, codex exec) does not. For agent sessions the value stays the CLI's own directory, which is what their relative paths are written against.Verification
Side-by-side sweep vs master. I loaded master's
constants.js,terminal-ui.jsandpanels-ui.js(git show origin/master:...) and this branch's in the same vm harness the tests use. I ranfindFilePathLinks(), then_resolveLinkedFilePath(), then_isExternalPreviewPath()over 188 lines of realistic output:git status/diff --stat,ls/ls -l,rg path:line:, tsc/eslint/rustc/go/gcc/mypy errors, vitest/jest/pytest/go test output and stack frames, build tool output, shell prompts (user@host:~/proj$,/mnt/c/proj ❯), Claude⏺/⎿and Codex•rows, box tables, logs,./,../,~/, absolute paths, URLs, and prose with/. That gave 144 links per scenario. Results by scenario:$HOMEEvery difference is a relative path whose target is now
resolveLinkedFilePath(path, cwd)instead ofresolveLinkedFilePath(path, workingDir). Absolute,~/and URL links are identical, and the set of detected links is identical. When cwd is outside the workspace, the changed links route through the attachment route (marked external) rather than the workspace routes. The sibling count is 5 lower because../xpaths resolve to the same target from both directories.Live pane. I ran an isolated instance (
CODEMAN_INSTANCE, port 5534,--host 127.0.0.1confirmed withss -ltn, no password) with a real bash 5.2.21 shell session on tmux next-3.7, xterm 6.0.0, and Playwright 1.60.0 headless Chromium at DSF 1. The workspace hadnote.md,sub/note.mdandother/note.md, plus../sibling/note.mdoutside it. For eachcdI ranls, called the real_terminalLinkProvider.provideLinks()on the printed row and ranactivate():cd sub:GET /api/sessionsshowedcwd=.../proj/subwithin one tick,workingDirwas unchanged, and the link opened.../proj/sub/note.mdcd ../../sibling(outside the workspace): the link opened.../sibling/note.mdthrough the attachment route, and the preview rendered "SIBLING note"cd ../proj/other: the preview rendered "OTHER note"cd ..: the preview rendered "root note"cds while its parent stays in the foreground (python3 -c "subprocess.Popen(['sh','-c','cd sub; sleep 6'])...") left bothpane_current_pathand the APIcwdon the parent's directory, as the agent-CLI note above expects.Symlinked workspace, live. On tmux next-3.7 a pane opened in a symlink
linktophys, aftercd src, reports#{pane_current_path}asphys/src, confirming the premise. The new tests use a real symlinked temp directory and the realrealpath(): a reading ofphys/srcis stored aslink/src,src/index.tsfrom it stays on the workspace route (_isExternalPreviewPathfalse), and acdto a sibling directory still routes through the attachment route.Mutation. I applied 22 mutations, one at a time, against
test/pane-cwd-tracking.test.ts(41 tests). All 22 fail at least one test (14 measured on the first revision, 5 on the second, the 3 for the symlink rewrite on the current head):undefinedanswer skipped instead of clearing (2); no broadcast on change (1); broadcast on every tick (1); persist on a cwd change (1)workingDir(3)workingDir's spelling (2); a physical reading taken before the resolution settles (3); the rewrite matching a sibling that only shares the prefix,appvsapplication(1)Negatives at every layer: parser (split session, blank, relative, a path that contains the separator); manager (empty read and blank field keep the value, a split session and a session no longer listed are dropped, a remote/Docker-only manager makes no read); Session (remote, Docker, direct-PTY, rediscovered, relative); server, driving the real
TmuxManagerwith onlyreadPaneRowsfaked (single-pane read setscwd, a two-pane read clears it, a read without the session clears it, each clear broadcast once; a blank field and a failed read keep it; remote and Docker publish nothing, no persist, no rebroadcast on an unchanged tick); browser (remote, Docker, relative cwd, absolute and~/passthrough, routing still decided byworkingDir).Bounds: no new tmux, filesystem or network call. The read is the existing async
execAsyncwithEXEC_TIMEOUT_MS, one in flight at a time (paneExitReadInFlight), so a hung tmux cannot stack reads. The map is replaced on each read, so its size is bounded by the panes on the socket. No regex was added.Gates:
typecheck,lint,format:check,check:frontend-syntax,check:public-assets,check:lockfileandcheck:browser-excludesall pass.test:ci on the current head (f23e18b): 13539 passed, 6 failed, 12 skipped (667 files). The 6 failures are all in
test/tmux-prefix-table.test.ts(restrictTmuxPrefixTable against a real tmuxx4,nested Docker/remote tmux prefix tablex2). They come from this machine's tmux next-3.7 and fail on plainorigin/master(f1f7eec) here too, which I compared by name in the previous round. Thegit-fetchtiming test that failed on both in the previous round passed this time.Trade-offs
cdand clicked afterwards resolves against the new directory. On master the same path resolves against the start directory, which is right only if that is where it was printed. Tagging each row with the directory it was printed in would mean xterm markers per row and a pane read per write, which I kept out of scope.cdby up to one watcher tick (2 s).workingDir.#{pane_current_path}keeps the last directory for as long as tmux leaves the field blank, the same way a failed read keeps it; it does not expire after one tick.For review
SessionState.cwd(optional, runtime-only) in/api/v1/sessionsresponses and onsession:updated. It is additive and has no setting, because it only changes which base a relative link joins and the default behaviour does not depend on a choice. The name and whether it belongs on the public session shape are your call and are unchanged in this revision; if you want it named differently (for examplepaneCwd) or kept out of the public state, it can go on a private channel.docs/api-reference.mdnow documents it./tmp) the way the rediscovery path does: a local shell that really is in/tmpshould resolve there, and remote/Docker panes that never leave it are already refused by the scope rule.