Context bar: match panes by live cwd via OSC 7 (was keyed on unset leaf.cwd)

The context indicator never showed because it matched on leaf.cwd, which
is almost always undefined (newLeaf sets none; the shell picker never
supplies one) — so the cwd<->transcript match never hit.

Fix: report each WSL pane's real working directory.
- pty.rs: inject PROMPT_COMMAND (forwarded via WSLENV) so the WSL shell
  emits OSC 7 (file://host/path) on every prompt. Default Ubuntu bash
  inherits an env-provided PROMPT_COMMAND; a shell that hard-assigns it,
  or a non-bash login shell, just won't report (indicator stays hidden,
  no breakage).
- XtermPane: register an OSC 7 handler, decode the path, emit onCwd.
- LeafPane: track liveCwd from onCwd and match the session on
  (liveCwd ?? leaf.cwd). OSC 7 fires at the bash prompt right before
  'claude' launches, so liveCwd is exactly claude's launch cwd; it also
  follows 'cd'.

tsc clean. Rust builds on the Windows host; needs runtime verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-05-28 22:57:53 +01:00
parent 24ab7f067f
commit d776f962da
3 changed files with 56 additions and 1 deletions

View file

@ -354,6 +354,23 @@ fn build_command(spec: &SpawnSpec) -> Result<(CommandBuilder, &'static str)> {
let resolved_cwd = cwd.as_deref().unwrap_or("~");
c.arg("--cd");
c.arg(resolved_cwd);
// Make the shell report its working directory via OSC 7 on every
// prompt, so the frontend can map this pane to the claude session
// running in it (the context-fill indicator; see usage.rs +
// LeafPane). We set PROMPT_COMMAND in the environment and forward it
// through WSLENV — default Ubuntu bash inherits an env-provided
// PROMPT_COMMAND. A user shell that hard-assigns PROMPT_COMMAND (or
// a non-bash login shell) simply won't report, and the indicator
// stays hidden for that pane — no breakage either way.
c.env(
"PROMPT_COMMAND",
r#"printf '\033]7;file://%s%s\033\\' "$HOSTNAME" "$PWD""#,
);
let wslenv = match std::env::var("WSLENV") {
Ok(v) if !v.is_empty() => format!("{v}:PROMPT_COMMAND/u"),
_ => "PROMPT_COMMAND/u".to_string(),
};
c.env("WSLENV", wslenv);
Ok((c, "failed to spawn wsl.exe; is WSL installed?"))
}
SpawnSpec::Powershell => {

View file

@ -82,6 +82,10 @@ interface XtermPaneProps {
* Defined as an optional callback so single-pane windows don't require
* wiring it up. */
onNavigate?: (intent: NavigateIntent) => void;
/** Fired with the shell's reported working directory (from an OSC 7 escape,
* which WSL panes emit via an injected PROMPT_COMMAND see pty.rs). Used to
* map the pane to the claude session running in it. */
onCwd?: (cwd: string) => void;
}
const DEFAULT_XTERM_FONT_SIZE = 13;
@ -101,6 +105,7 @@ export default function XtermPane({
focusTrigger = 0,
fontSize,
onNavigate,
onCwd,
}: XtermPaneProps) {
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<Terminal | null>(null);
@ -121,6 +126,7 @@ export default function XtermPane({
const onDataReceivedRef = useRef(onDataReceived);
const onFocusRef = useRef(onFocus);
const onNavigateRef = useRef(onNavigate);
const onCwdRef = useRef(onCwd);
// Stable ref for setSearchOpen so it can be called from inside the
// attachCustomKeyEventHandler closure without the closure going stale.
const setSearchOpenRef = useRef<(v: boolean) => void>(setSearchOpen);
@ -131,6 +137,7 @@ export default function XtermPane({
useEffect(() => { onDataReceivedRef.current = onDataReceived; }, [onDataReceived]);
useEffect(() => { onFocusRef.current = onFocus; }, [onFocus]);
useEffect(() => { onNavigateRef.current = onNavigate; }, [onNavigate]);
useEffect(() => { onCwdRef.current = onCwd; }, [onCwd]);
useEffect(() => { setSearchOpenRef.current = setSearchOpen; }, [setSearchOpen]);
// -------------------------------------------------------------------------
@ -204,6 +211,24 @@ export default function XtermPane({
searchAddonRef.current = search;
term.loadAddon(search);
// OSC 7 (cwd reporting): WSL panes emit `\e]7;file://<host><path>\e\\` on
// every prompt (via the PROMPT_COMMAND we inject at spawn). Capture the
// path and report it up so the pane can be matched to its claude session.
// Registered before data flows so the first prompt's cwd is caught.
term.parser.registerOscHandler(7, (data) => {
const m = /^file:\/\/[^/]*(\/.*)$/.exec(data);
if (m) {
let path = m[1];
try {
path = decodeURIComponent(path);
} catch {
/* leave raw if it isn't valid percent-encoding */
}
onCwdRef.current?.(path);
}
return true;
});
// Initial size — fit before asking the PTY for its dimensions.
fit.fit();

View file

@ -179,6 +179,15 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
return () => ro.disconnect();
}, []);
// Live cwd reported by the shell via OSC 7 (WSL panes). Used to match this
// pane to the claude session running in it — more reliable than leaf.cwd,
// which is the (often unset) spawn cwd and doesn't follow `cd`.
const [liveCwd, setLiveCwd] = useState<string | null>(null);
const onPaneCwd = useCallback(
(cwd: string) => setLiveCwd((cur) => (cur === cwd ? cur : cwd)),
[],
);
// ---- broadcast ---------------------------------------------------------
const onTerminalInput = useCallback(
(b64: string) => {
@ -409,8 +418,11 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
};
})();
const matchCwd = liveCwd ?? leaf.cwd;
const ctx =
leaf.shellKind === "wsl" && leaf.cwd ? orch.paneContext.get(leaf.cwd) : undefined;
leaf.shellKind === "wsl" && matchCwd
? orch.paneContext.get(matchCwd)
: undefined;
return (
<div
@ -629,6 +641,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
onDataReceived={onDataReceived}
onFocus={onXtermFocus}
onNavigate={onPaneNavigate}
onCwd={onPaneCwd}
focusTrigger={focusTrigger}
fontSize={resolveFontSize(leaf.fontSizeOffset)}
/>