Skip to content

feat(psmux): add native-Windows tmux multiplexer module#10

Merged
jinyeow merged 14 commits into
mainfrom
feat/psmux-safe-plugins
Jul 9, 2026
Merged

feat(psmux): add native-Windows tmux multiplexer module#10
jinyeow merged 14 commits into
mainfrom
feat/psmux-safe-plugins

Conversation

@jinyeow

@jinyeow jinyeow commented Jul 7, 2026

Copy link
Copy Markdown
Owner

What

Adds a psmux (native-Windows tmux clone; winget install marlocarlo.psmux) module for one-key pane equalize (select-layout tiled) — the thing zellij has no native action for. zellij stays installed as a fallback (retirement trigger documented in CLAUDE.md).

Highlights

  • Auto-grid: new panes re-tile to an even grid via a toggled after-split-window hook. Prefix+g toggles live; status bar shows [grid] when armed.
  • Unified Ctrl-hjkl nav across psmux panes + nvim splits. nvim needs no change — psmux sets $TMUX, so the existing keymaps.lua edge-handoff works.
  • Native catppuccin-mocha theme + sensible defaults inlined as set lines (no theme/sensible plugins).
  • fzf session pickers (Enter-/Remove-/Get-PsmuxSession) + native tab-completion in the pwsh profile (Phase 2b).
  • Reboot-persistence via vendored psmux-resurrect + psmux-continuum.

Security (this is the load-bearing part)

A /council review flagged a namespace-hijack RCE: PPM (psmux's plugin manager) resolves @plugin 'psmux-plugins/<name>' by cloning github.com/psmux-plugins/<name>.git first — an unregistered, attacker-registrable org (HTTP 404) — then runs it at startup. Anyone could register that org and get code-exec on install.

Fix: PPM is bypassed entirely. The two persistence plugins are vendored + SHA-pinned (psmux-plugins@0f46ccca) and loaded via source-file of their static plugin.conf — no resolver, no @plugin, no run, no network, no Prefix+I/U/M. (psmux/plugins/ is excluded from the PSScriptAnalyzer gate as vendored third-party.)

Verification

  • Pester 4/4 (incl. a new tests/psmux.Tests.ps1 that loads the real config end-to-end and asserts the persistence binds).
  • PSScriptAnalyzer clean (repo settings, vendored code gated).
  • Vendored-load e2e confirmed: resurrect/continuum source with zero network clone.

Still needs a live check (can't be tested headlessly)

  • Copy-on-select actually reaching the Windows clipboard (OSC52).
  • Reboot → continuum restores open sessions.

Commits

  1. chore(psmux): vendor the two plugins (pinned).
  2. feat(psmux): the module, config, profile helpers, tests, CI, docs.

jinyeow and others added 12 commits July 8, 2026 06:54
…@0f46ccc)

Third-party persistence plugins vendored + SHA-pinned rather than installed via
PPM. PPM's resolver clones each plugin from github.com/psmux-plugins/<name>
first — an unregistered, attacker-registrable org (HTTP 404) — then runs it at
startup (namespace-hijack -> RCE). These are loaded via source-file of their
static plugin.conf instead, so PPM is never used. See psmux/plugins/README.md.
Adopt psmux (winget: marlocarlo.psmux) for one-key pane equalize
(select-layout tiled) that zellij has no native action for. zellij is kept
installed as a fallback; retirement trigger documented in CLAUDE.md.

Config (~/.psmux.conf):
- auto-grid: new panes re-tile to an even grid via a toggled after-split-window
  hook; Prefix+g toggles live, status bar shows [grid] when armed
- unified Ctrl-hjkl navigation shared with nvim (nvim needs no change: psmux
  sets $TMUX, existing keymaps.lua handles the edge handoff)
- sticky resize mode, copy-on-select, native catppuccin-mocha theme + sensible
  defaults inlined (no theme/sensible plugins)
- reboot-persistence via the vendored resurrect/continuum plugins, source-file'd
  directly (no PPM: closes the psmux-plugins namespace-hijack RCE)

- setup.ps1: -Module psmux (symlink ~/.psmux.conf, copy vendored plugins)
- powershell profile Phase 2b: Enter/Remove/Get-PsmuxSession fzf pickers + native
  session-name tab-completion for psmux/pmux/tmux
- tests/psmux.Tests.ps1: loads the config end-to-end and asserts persistence binds
- CI: exclude vendored psmux/plugins from the PSScriptAnalyzer gate
`pane-border-status top` renders a pane-title row that psmux miscounts, so the
shell prompt/cursor lands a row off. Remove the border display; Prefix+T still
sets the pane title, it's just no longer shown in the border.
Replaces the removed pane-border title display. The focused pane's title
appears at the left of the status bar once set via Prefix+T, and is hidden
while it's still the default hostname. Non-intrusive (status bar, not a pane
row), so it doesn't reintroduce the cursor misalignment.
The native completer already filled in session names after attach/kill-session/
etc.; extend it so the subcommand position completes too (`psmux a<TAB>` ->
attach/attach-session), sourced from `list-commands` (names + aliases, works
with no server running).
- Session fzf helpers (Get-/Enter-/Remove-PsmuxSession) were bare `function`
  inside Initialize-DeferredProfileSecondary (Phase 2b), so they scoped local
  and vanished on return. Declare `function global:` (matches the ll/la/lt
  precedent); the tab-completer was unaffected (Register-ArgumentCompleter is
  a global side-effect).
- Resize-mode (Prefix+r then hjkl) relied on a custom key-table
  (switch-client -T resize) that psmux does not support, so it degraded to a
  session-switch. Remove it (native Prefix+Ctrl-arrows resizes) and rebind
  Prefix+r to reload the config.
- auto-grid Prefix+g toggle: switch the parse-time-frozen `if-shell -F` to a
  runtime sh `if-shell`. Still does not re-enable live (known issue, tracked
  for follow-up); auto-grid default-on is unaffected.
- Docs (CLAUDE.md, psmux/README) updated; fix stale README bits (phantom
  move-mode, pane title now shown in the status bar not the pane border).
- Prefix+g auto-grid toggle: switch if-shell -> run-shell. psmux's
  if-shell in a bind always runs the true branch, so it could never
  re-enable. run-shell moves the decision out of psmux; it runs its
  arg as PowerShell (not sh), so the body is pwsh if/else with
  @auto_grid quoted to avoid splatting.
- New panes open in the focused pane's directory: the prompt now emits
  OSC 7 (psmux reads pane_current_path from OSC 7, not OSC 9;9), and
  split/new-window are rebound with -c "#{pane_current_path}".
…s fzf cursor desync

PSFzf launches fzf via a redirected-stdout System.Diagnostics.Process from a
PSReadLine handler, which desyncs fzf's screen/cursor under psmux's ConPTY
(doubled UI, corrupted input). Reimplement the interactive pickers on bare fzf
that SELF-POPULATES so fzf owns the tty (no stdin pipe):
- Ctrl+t: fzf --multi self-populating from FZF_DEFAULT_COMMAND
- Alt+c: temporary FZF_DEFAULT_COMMAND override to the fd dir-walker
- Ctrl+r: stage PSReadLine history to a temp file, fzf self-populates via cmd type
- Alt+b/Alt+g: Invoke-Fzf -> bare fzf
Disable PSFzf's default chord binds (their param defaults clobber ours) and bind
ours after Set-PsFzfOption. Exclude AppData/node_modules from the fd walkers for
speed. TDD pure helpers (quote/join, history dedup); 10/10 Pester pass.

Prompt: fzf's Windows renderer can leave newline-auto-return off and the cursor
mid-row on abort; harden Set-Prompt.ps1 with a leading cursor-to-column-0 and
CR+LF+clear-to-EOL between lines so the prompt renders at the left margin.

KNOWN ISSUE: Ctrl+r still does not spawn fzf (temp-file self-populate not yet working).
…zellij defaults

- windowsterminal: bind ctrl+backspace to sendInput of the Ctrl+W byte (U+0017)
  so backward-delete-word survives a multiplexer dropping the Control modifier
  (upstream of psmux/zellij; PSReadLine/readline/fzf all map Ctrl+W to it)
- psmux: escape-time 0 (a lone Esc reaches TUIs; fixes fzf abort). @auto_grid OFF
  by default -- the after-split-window 'select-layout tiled' hook steals focus
  from the new pane and psmux hooks take only one command; Prefix+Alt+5 equalizes
  on demand, Prefix+g re-enables auto-tile
- zellij: auto_layout false (panes stay put; swap-layout auto-grid abandoned, it
  would not auto-tile on 0.44.3) + locked-mode targeted splits (Alt+- / Alt-bslash)
psmux is the daily driver (Zellij's swap-layout auto-grid won't auto-tile);
document the ConPTY fixes (Ctrl+Backspace, escape-time 0, bare-fzf pickers), the
auto-grid focus tradeoff (@auto_grid off), and zellij auto_layout false.
…modes

The empty-buffer Ctrl+r path built fzf args as `$x = if (...) { @('--no-sort') }`.
PowerShell unwraps a single-element array returned from an `if` block to the scalar
string '--no-sort', so `fzf @x` splatted a string character-by-character and fzf saw
a bare '-' → "unknown option: -" (exit 2, no UI). The 3-element --query branch stayed
an array, so only the common empty-buffer case broke. Use literal fzf args, no splat.

Also bind Ctrl+r into both Vi key-tables (-ViMode Insert/Command) with a FzfHistory
brief-description: EditMode is Vi, and a plain bind lands only in the Insert table,
leaving Command-mode Ctrl+r on the Phase-1 ReverseSearchHistory bind. Now Ctrl+r is
fzf history in either Vi mode, and the duplicate Get-PSReadLineKeyHandler row is gone.
@jinyeow jinyeow self-assigned this Jul 9, 2026
jinyeow added 2 commits July 10, 2026 00:16
Vendor psmux-sidebar (psmux/psmux-plugins@0f46ccc, the same pinned rev as
resurrect/continuum): Prefix+Tab toggles a directory-tree pane, Prefix+Shift+Tab
focuses it. Stateless — static scripts/, PPM loader never run, source-file'd
directly. setup.ps1's generic plugin-copy loop picks it up with no installer edit.

Inline the useful psmux-pain-control keybinds (it ships only bindings, no scripts):
- Prefix+Alt+h/j/k/l repeatable vim-key pane resize (5 cells)
- Prefix+</> repeatable window reorder
- Prefix+| \ (side-by-side) and - _ (top/bottom) mnemonic split aliases, inherit cwd
Skipped its Prefix+hjkl nav (redundant with unified Ctrl-hjkl) and Prefix+c (already bound).

Docs updated: psmux/README.md, psmux/plugins/README.md, CLAUDE.md.
@jinyeow jinyeow merged commit e3f47ab into main Jul 9, 2026
4 checks passed
@jinyeow jinyeow deleted the feat/psmux-safe-plugins branch July 9, 2026 14:29
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.

1 participant