Skip to content

OSC 633 shell integration for PowerShell#147

Merged
nedtwigg merged 10 commits into
mainfrom
powershell-osc633
Jun 18, 2026
Merged

OSC 633 shell integration for PowerShell#147
nedtwigg merged 10 commits into
mainfrom
powershell-osc633

Conversation

@nedtwigg

Copy link
Copy Markdown
Member

What

Extends OSC 633 shell integration to PowerShell (pwsh) and Windows PowerShell (powershell.exe) — bash and zsh already had it. Dormouse now injects prompt/command boundaries so PowerShell sessions get real command history, exit codes, and cwd tracking instead of falling back to the keystroke heuristic.

How

  • New emitter standalone/sidecar/shell-integration/pwsh/shellIntegration.ps1 wraps the user's prompt function to emit the A/B/C/D/E/P;Cwd sequences. PowerShell has no preexec hook, so — like VS Code's PowerShell integration — a command's E/C/D are reported from the next prompt render (command line from Get-History, exit code from $?/$LASTEXITCODE). Boundaries and exit codes are exact; the C/D markers land at the following prompt rather than bracketing output in real time. Escaping matches the bash/zsh emitters (\\, ;\x3b, etc.); Cwd is sent raw because the parser reads it verbatim.
  • standalone/sidecar/pty-core.js: inject via -NoExit -Command ". '<script>'" for both PowerShell flavors (profile still loads, so the user's prompt is defined before we wrap it). Skipped when the caller passed explicit args, mirroring bash. Added a shellStem() helper that handles \ separators and strips .exe so Windows shell paths match; resolveLoginArg reuses it.
  • Spec: docs/specs/terminal-escapes.md marks PowerShell implemented and documents the mechanism + the next-prompt limitation.

Testing

  • New unit tests: pwsh injection, Windows PowerShell injection, explicit-args passthrough, missing-script fallback. All pass.
  • Verified the .ps1 in a real PowerShell session: first prompt emits A/P;Cwd/B; a full command cycle emits E (escaped), C, D;<exit>; failing native command reports the true exit code (D;3); install guard prevents re-wrapping.

Note: the 3 pre-existing pty-core.test.js failures seen locally are Windows-path artifacts (those zsh/bash tests hardcode POSIX /); they pass on Linux CI and are unrelated to this change.

Packaging

No build change needed — the VS Code build already copies shell-integration recursively and standalone ships it via the tauri resources glob, so pwsh/ is bundled automatically. .ps1 isn't a dotfile, so the dotfile-stripping caveat doesn't apply.

🤖 Generated with Claude Code

Inject OSC 633 prompt/command boundaries into pwsh and Windows
powershell.exe, the same way bash and zsh already get them.

- New emitter shellIntegration.ps1 wraps the user's `prompt` function to
  emit the A/B/C/D/E/P;Cwd sequences. PowerShell has no preexec hook, so
  (like VS Code's integration) a command's E/C/D are reported from the
  next prompt render — command line from history, exit code from
  $?/$LASTEXITCODE. Boundaries and exit codes are exact. The escaping
  matches the bash/zsh emitters; Cwd is sent raw since the parser reads
  it verbatim.
- pty-core.js: inject via `-NoExit -Command ". '<script>'"` (profile
  still loads, so the user's prompt is defined before we wrap it).
  Skipped when the caller passed explicit args, like bash. Added a
  shellStem() helper that handles `\` separators and `.exe` so Windows
  shell paths match; resolveLoginArg reuses it.
- Tests for pwsh and Windows PowerShell injection, explicit-args
  passthrough, and missing-script fallback.
- terminal-escapes.md: mark PowerShell implemented.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 18, 2026

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: 3ebbf5e
Status: ✅  Deploy successful!
Preview URL: https://16f2df33.mouseterm.pages.dev
Branch Preview URL: https://powershell-osc633.mouseterm.pages.dev

View logs

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One correctness issue, verified by simulating the history-id state machine in pwsh: the first interactive command of every session never gets its E/C/D reported.

The -1 sentinel is meant to suppress only the genuine first prompt, but it lingers through that first render because $Global:__dormouse_633_last_history_id is assigned only when history is non-empty. At the very first prompt Get-History is empty, so the sentinel stays -1; when the next prompt renders after the user's first command, the guard ... -ne -1 is still false and the command's line/output-start/exit-code are dropped. From the second command onward it works. (This is easy to miss in manual testing — by the time you're inspecting output you've already run several commands.) VS Code's PowerShell integration avoids this by assigning the id unconditionally each render. Inline suggestion below; I confirmed it restores reporting for the first command while still suppressing empty-line / Ctrl+C renders.

Secondary, non-blocking: by the time the user's original prompt is invoked ($Global:__dormouse_633_original_prompt.Invoke()), $? has been clobbered by the intervening Get-History and assignments, so it is always $True there. $LASTEXITCODE survives (nothing reassigns it), so starship-style prompts are fine, but a prompt that colors itself off $? will always see success. Restoring $? before the invoke is awkward in PowerShell — worth a note rather than a required change.

Comment thread standalone/sidecar/shell-integration/pwsh/shellIntegration.ps1 Outdated
@nedtwigg

Copy link
Copy Markdown
Member Author

@dormouse-bot fix and commit

The -1 first-prompt sentinel only cleared when Get-History was non-empty,
so it lingered through the genuine first prompt (history empty) and caused
the first real command's line/output-start/exit-code to be dropped; from
the second command onward it worked. Assign the history id unconditionally
each render (0 when history is still empty) so the sentinel clears after the
first prompt — history ids start at 1, so 0 never matches a real command and
the genuine first prompt's report stays suppressed.

Verified in pwsh by driving the prompt state machine: the first command now
emits E/C/D while the first prompt and empty-line renders stay silent.

Also documents that $? is always $True when the user's original prompt is
invoked (Get-History clobbers it); $LASTEXITCODE survives, so starship-style
prompts are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dormouse-bot

Copy link
Copy Markdown
Collaborator

Done — fixed in ed788e3. The first interactive command's E/C/D are now reported (the -1 sentinel was lingering past the genuine first prompt because the history id was only assigned when Get-History was non-empty). Details in the inline thread. Verified in pwsh, all 53 sidecar unit tests still pass. Watching CI now.

Emitting E/C/D all from the next prompt meant the running command never
showed — it appeared to start and finish at once, after it had already
completed. Wrap PSReadLine's PSConsoleHostReadLine (called just before a
submitted command runs) to emit E (command line) and C (command start)
up front, matching bash/zsh preexec; D (finish + exit code) still comes
from the next prompt like a zsh precmd. Falls back to the next-prompt
E/C/D triple when PSReadLine is absent. The hook is retried from prompt()
since PSReadLine may import after the script dot-sources.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Git Bash is detected with args ['--login', '-i'], which tripped the
"explicit args" guard and skipped bash --init-file injection, so it fell
back to the keystroke heuristic and the command title never updated. Those
flags only select an interactive login shell, which the init-file script
already subsumes (it sources the login profile itself — verified that
bash.exe accepts the Windows-path --init-file and emits the full 633
sequence). Inject whenever bash's args are only interactive/login flags;
keep skipping specific invocations like `-c <cmd>`. Fail-safe preserved:
if the script is missing, the original --login -i args survive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
"Developer PowerShell for VS" is detected as
`-NoExit -Command "& { Import-Module ... }"`, whose explicit args made the
blunt hasExplicitArgs guard skip OSC 633 injection entirely.

Key the decision on interactivity and argument structure instead of "are
there args": for an interactive PowerShell launch (-NoExit), append our
dot-source to an existing -Command (so the dev-shell setup runs first and
our prompt wrapper installs after it) or add one if absent; leave
non-interactive one-offs (-Command/-File/-EncodedCommand without -NoExit)
untouched. Verified the merged `& { ... }; . 'script'` command runs both
halves and emits 633. hasExplicitArgs is now unused and removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
nedtwigg and others added 2 commits June 18, 2026 13:48
…adlock)

The sidecar is spawned CREATE_NO_WINDOW, and on Windows a *synchronous*
child spawn of a console app without that flag deadlocks on console
allocation. So `wsl.exe -l -q` (via execSync/cmd.exe) hung, hit its 5s
timeout, and the catch silently dropped every WSL distro — while every
other shell still showed. It worked from a console (dev tests), which is
why it looked fine.

Two fixes:
- Enumerate WSL distros from the registry (HKCU\...\Lxss\<guid>\
  DistributionName) via reg.exe instead of wsl.exe -l -q — the same source
  Windows Terminal uses, and avoids wsl.exe's console/stdio quirks.
- Pass windowsHide:true (CREATE_NO_WINDOW) on the reg call — the actual
  deadlock fix. Apply it to the open-ports powershell.exe/netstat spawns
  too, which had the identical latent hang.

Diagnosed by logging detection in the real GUI sidecar (reg.exe ETIMEDOUT
without windowsHide; OK in 20ms with it, Ubuntu then appears).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Picking a WSL distro launched the distro's shell with no OSC 633, because
the Windows-side injection (--init-file/env) can't reach inside WSL.

Append a `sh -c` detector to `wsl.exe -d <distro>` (passed as one argv
element, so node-pty hands it to wsl→sh verbatim). It reads the login
shell from /etc/passwd and execs bash with our bash init-file — referenced
via the script's /mnt path so the Linux side can read it — when bash
exists; steps aside for an explicit zsh/fish login shell; and falls back
to the login shell only when bash is absent (e.g. Alpine). Biasing to bash
on empty detection avoids dropping the user into /bin/sh when the (cold-
start-flaky) lookup returns nothing.

Verified end-to-end through resolveSpawnConfig + real node-pty/ConPTY:
spawning Ubuntu emits the full P/A/B/E/C/D 633 lifecycle.

Limitations: bash only; assumes the default /mnt automount root.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
nedtwigg and others added 2 commits June 18, 2026 14:54
Readability cleanup from /simplify: the inline sh detector was built with
`+` concatenation that spliced the mount path mid-statement. Express it as
one sh statement per array element joined with a space (mount via a
template literal). Byte-identical output — the exact-string test is
unchanged and still passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@nedtwigg nedtwigg merged commit e97cbaa into main Jun 18, 2026
4 checks passed
@nedtwigg nedtwigg deleted the powershell-osc633 branch June 18, 2026 22:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants