Skip to content

mux: pluggable sidebar — host external TUI plugins in the sidebar - #7695

Merged
lawrencecchen merged 4 commits into
mainfrom
feat-mux-sidebar-host
Jul 9, 2026
Merged

lawrencecchen merged 4 commits into
mainfrom
feat-mux-sidebar-host

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jul 9, 2026 •

Copy link
Copy Markdown
Contributor

Implements the mux side of the sidebar-plugin contract (reference plugin: https://github.com/manaflow-ai/cmux-sidebar-fzf, already live-tested against the control socket).

A sidebar plugin is an ordinary TUI program: mux runs it in a PTY sized to the sidebar and renders it via the existing ghostty-vt pipeline (shared ui/terminal_grid.rs, factored from the pane renderer — no second VT path). Env contract: CMUX_MUX_SOCKET + CMUX_SIDEBAR=1; resize is plain SIGWINCH. prefix S focuses the sidebar; keys forward verbatim, the global prefix escapes back. Plugins survive sidebar hide/show, restart with bounded backoff on exit, and die with the server. Attach clients get the plugin surface via a new additive protocol command. Built-in sidebar is untouched when no plugin is configured.

spec/plugins.md documents the contract incl. the cmux-plugin.toml manifest for the follow-up plugin-manager round. smoke-tui.py now boots a trivial echo plugin, focuses it, types, asserts the echo renders, then reload-configs back to the built-in sidebar.

Local compile fully blocked (host ghostty zig issue) — CI is the compile gate.


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Note

Medium Risk
Spawns and manages an extra server-owned PTY with new input-focus routing and attach-client behavior; impact is mostly scoped to sessions that enable sidebar.plugin, but failures or focus bugs could affect the whole TUI frame.

Overview
Adds an optional sidebar plugin path: when sidebar.plugin is set in mux.json, the mux server runs that command in a dedicated PTY (with CMUX_SIDEBAR=1 and normal resize), instead of the built-in workspace list.

Mux core gains configure_sidebar_plugin / ensure_sidebar_plugin with exponential backoff on start/exit failures; plugin surfaces are kept outside the pane tree and reaped specially on exit. The control socket exposes an additive sidebar-plugin command (protocol remains v6) returning surface id, error, and retry_after_ms.

TUI syncs plugin size each frame, draws it through new shared ui/terminal_grid (VT painting factored out of pane rendering), and adds focus-sidebar (prefix S) plus click-to-focus with keys/paste forwarded to the plugin while focused; prefix still escapes back to panes. Attached clients call sidebar-plugin over RPC and attach the returned surface.

Config reload applies plugin changes; smoke tests cover plugin render, focus/echo, and fallback to the built-in sidebar. spec/plugins.md documents the contract.

Reviewed by Cursor Bugbot for commit 43fcbf8. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Adds a pluggable sidebar that runs an external TUI in a server-owned PTY and renders it via a shared VT grid. Introduces an additive sidebar-plugin socket command (protocol stays v6); clicking outside the plugin now returns focus to panes.

  • New Features

    • Server: runs a sidebar plugin PTY sized to the sidebar; sets CMUX_MUX_SOCKET and CMUX_SIDEBAR=1; survives hide/show; restarts with bounded backoff; exits on shutdown or config change.
    • TUI: focus sidebar with prefix S or click; keys and paste forward verbatim while focused; prefix escapes; click outside unfocuses; drawing uses shared ui/terminal_grid.
    • Config: sidebar.plugin in mux.json with command and optional cwd; reload-config applies; when unset, the built‑in sidebar is used.
    • Attach/protocol: new sidebar-plugin command returns {surface,error,retry_after_ms}; clients render via attach-surface; docs/spec/tests updated (no CLI verb); protocol remains v6.
  • Migration

    • To enable: set sidebar.plugin.command in ~/.config/cmux/mux.json (e.g. ["/path/to/plugin"]), then reload-config or restart the server.
    • Attached clients must support protocol v6 and call sidebar-plugin to obtain the server-owned surface before attaching.
    • No changes if no plugin is configured; falls back to the built‑in sidebar.

Written for commit 43fcbf8. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Added support for an optional sidebar plugin, including configuration, startup, resizing, focus handling, and input forwarding.
    • Introduced a new sidebar-focused keyboard shortcut to switch focus to the sidebar plugin.
    • Added a control-socket command for clients to request sidebar plugin status.
  • Bug Fixes

    • Improved sidebar plugin recovery with retry/backoff behavior after failures or exits.
    • Updated sidebar rendering to show plugin output or a helpful error/unavailable message when needed.
  • Documentation

    • Updated configuration, keyboard, and protocol docs to describe the new sidebar plugin behavior and controls.

sidebar.plugin in mux.json runs an external TUI program in a server-owned
PTY sized to the sidebar; output renders through the shared ghostty-vt grid
path (new ui/terminal_grid.rs factored from the pane renderer). Env contract:
CMUX_MUX_SOCKET + CMUX_SIDEBAR=1. prefix S focuses the sidebar (keys forward
verbatim; prefix returns focus). Plugin survives hide/show, restarts with
backoff on exit, dies with the server. Attach clients render it via the new
sidebar-plugin command. spec/plugins.md documents the contract; smoke-tui.py
drives a trivial echo plugin end to end incl. reload-config back to builtin.
Reference plugin: https://github.com/manaflow-ai/cmux-sidebar-fzf
@vercel

vercel Bot commented Jul 9, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Jul 9, 2026 11:40am
cmux-staging Building Building Preview, Comment Jul 9, 2026 11:40am

@coderabbitai

coderabbitai Bot commented Jul 9, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds a sidebar plugin feature to mux: a configurable external process spawned in a PTY and rendered in the sidebar. It adds mux-core types/lifecycle logic with retry backoff, a control-socket command, TUI config parsing, focus/input wiring, a shared terminal-grid renderer, protocol bump to v7, and spec/docs/tests updates.

Changes

Sidebar plugin feature

Layer / File(s) Summary
Sidebar plugin types and lifecycle in Mux core
mux/crates/mux-core/src/mux.rs, mux/crates/mux-core/src/lib.rs
Adds SidebarPluginOptions, SidebarPluginStatus, an internal SidebarPluginRuntime, spawn/configure/ensure methods with retry/backoff, exit handling, and public re-exports.
SidebarPlugin control-socket command
mux/crates/mux-core/src/server.rs
Adds the SidebarPlugin command variant, JSON status serialization, and handle_command dispatch calling ensure_sidebar_plugin.
TUI config: sidebar plugin options and focus-sidebar action
mux/crates/mux-tui/src/config.rs, mux/crates/mux-tui/src/main.rs
Parses sidebar.plugin from mux.json into SidebarPluginOptions, extends the Sidebar struct, adds Action::FocusSidebar with a default prefix+S binding, and wires configure_sidebar_plugin at server startup.
Session-level sidebar plugin bridge
mux/crates/mux-tui/src/session/mod.rs, mux/crates/mux-tui/src/session/remote.rs
Adds SidebarPluginSurface and Session::sidebar_plugin for local/remote plugin access, bumps the client protocol check to version 7, and refactors ensure_surface into ensure_surface_with_kind.
Shared terminal-grid rendering extraction
mux/crates/mux-tui/src/ui/terminal_grid.rs, mux/crates/mux-tui/src/ui/pane.rs, mux/crates/mux-tui/src/ui/mod.rs
Introduces draw_render_state/PaletteResolver/apply_cell in a new module and removes duplicate rendering logic from pane drawing in favor of delegation.
Sidebar draw dispatch
mux/crates/mux-tui/src/ui/sidebar.rs
Routes sidebar drawing between a new plugin renderer (using the shared terminal grid) and the existing built-in workspace renderer.
App-level focus, input, and layout wiring
mux/crates/mux-tui/src/app.rs
Tracks the plugin surface/error/retry state, syncs plugin geometry on layout/resize, forwards keys/paste/clicks to the plugin when focused, and clears focus on exit or plugin removal.
Protocol v7, tests, smoke script, and spec/docs updates
mux/spec/README.md, mux/spec/commands.md, mux/spec/cli.md, mux/spec/plugins.md, mux/docs/configuration.md, mux/docs/keyboard.md, mux/scripts/smoke-tui.py
Documents protocol v7 and the sidebar-plugin command/contract, adds plugins.md, and extends the smoke test to exercise sidebar plugin rendering/focus.

Estimated code review effort: 4 (Complex) | ~75 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant App
  participant Session
  participant Mux
  participant PluginSurface

  User->>App: press prefix+S (focus-sidebar)
  App->>App: toggle_sidebar_focus
  App->>Session: sidebar_plugin(size, relaunch)
  Session->>Mux: ensure_sidebar_plugin(cols, rows, relaunch)
  Mux->>PluginSurface: spawn or resize
  PluginSurface-->>Mux: status
  Mux-->>Session: SidebarPluginStatus
  Session-->>App: SidebarPluginSurface
  App->>PluginSurface: forward_sidebar_key / paste_sidebar
  App->>App: draw_plugin via terminal_grid
Loading

Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (1 error, 1 warning)

Check name Status Explanation Resolution
Cmux User-Facing Error Privacy ❌ Error FAIL: the new sidebar-plugin launch failure path surfaces sidebar plugin failed to start: {err} to the sidebar and attach API, exposing raw underlying error text. Replace it with a generic product message (e.g. 'sidebar plugin failed to start') and keep the detailed spawn error in internal logs/telemetry only.
Docstring Coverage ⚠️ Warning Docstring coverage is 48.15% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (23 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed The PR diff touches only Rust/docs/Python; no Swift declarations changed, so it doesn’t introduce or worsen Swift actor-isolation issues.
Cmux Swift Blocking Runtime ✅ Passed No Swift files or Swift runtime sync changes are in this diff; only Rust, docs, and a Python smoke test changed, so the rule is not triggered.
Cmux Browser Automation Off-Main ✅ Passed PASS: The PR only changes sidebar/plugin docs, protocol assertions, and TUI focus behavior; it does not touch browser socket automation routing or WebKit/main-actor code.
Cmux Expensive Synchronous Load ✅ Passed The diff touches only Rust/docs/scripts; no Swift files or main-actor/UI load sites were added or moved, so the Swift sync-load rule is not applicable.
Cmux Cache Substitution Correctness ✅ Passed No Swift/TypeScript/JavaScript files changed, so the cache-substitution rule is not applicable.
Cmux No Hacky Sleeps ✅ Passed Only waits/polling are in smoke-tui.py test scaffolding; no covered TS/JS/shell/runtime-script sleeps were added, and plugin backoff is intentional product behavior.
Cmux Algorithmic Complexity ✅ Passed PASS: the diff only changes click-out unfocus and protocol text; no new scalable collection scans or batch rescans were introduced.
Cmux Swift Concurrency ✅ Passed Full PR diff shows only Rust/docs/Python changes; no .swift files were modified, so the Swift concurrency rule is not applicable.
Cmux Swift @Concurrent ✅ Passed No Swift files are changed in the PR diff, so the @concurrent review rule is not applicable.
Cmux Swift File And Package Boundaries ✅ Passed The PR touches only Rust, docs, and Python; no Swift files or SwiftPM package-boundary changes are in the diff.
Cmux Swiftpm Lockfiles ✅ Passed PASS: The commit only changes Rust/TUI/docs/tests; no cmux-owned .gitignore, Package.resolved, Xcode project, or workflow/dependency files are touched.
Cmux Swift Logging ✅ Passed PASS: The PR commit changes only Rust/docs/tests/Python files; no Swift runtime code or logging statements were added or changed.
Cmux Full Internationalization ✅ Passed No production i18n surface was changed; edits are Rust docs/tests/config tokens, with no Swift, web/messages, or locale catalog files touched.
Cmux Swiftui State Layout ✅ Passed No SwiftUI files were changed in this diff, so the SwiftUI state/layout rules are not applicable.
Cmux Architecture Rethink ✅ Passed PASS: The PR touches only Rust, docs, and Python; no .swift files changed, so the Swift architectural-rethink rules are not applicable.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed No Swift files or window code were changed; this PR is Rust/docs only, so the auxiliary-window close-shortcut rule is not applicable.
Cmux Source Artifacts ✅ Passed Touched paths are source, tests, docs, and scripts only; no temp dirs, logs, build output, caches, or other artifacts were added.
Cmux No Test Or Debug Seam In Production Source ✅ Passed No changed production Swift Sources/ files; diff is Rust/docs/tests only, so the seam rule is not applicable.
Cmux No Ambient Global State ✅ Passed No Swift changes in this diff; the new state lives in Mux/App/Session structs, with only file-scoped helpers/constants added.
Title check ✅ Passed The title is concise and accurately summarizes the main change: adding a pluggable sidebar for external TUI plugins.
Description check ✅ Passed The description covers summary and testing well, but it omits the template’s Demo Video, Review Trigger, and Checklist sections.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-mux-sidebar-host

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Jul 9, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds support for running external TUI plugins inside the mux sidebar. The main changes are:

  • Server-owned sidebar plugin PTY lifecycle and retry handling.
  • New attach command for discovering and sizing the plugin surface.
  • TUI focus, input forwarding, paste, resize, and rendering paths for the plugin.
  • Shared terminal-grid rendering used by panes and the sidebar.
  • Config, docs, and smoke coverage for the plugin contract.

Confidence Score: 4/5

These issues should be fixed before merging.

  • Remote attach clients can still miss a server-configured sidebar plugin.
  • The sidebar can still render the built-in view even when a server plugin surface exists.
  • Plugin command parsing can still change the user's configured argv.

mux/crates/mux-tui/src/app.rs, mux/crates/mux-tui/src/ui/sidebar.rs, mux/crates/mux-tui/src/config.rs

Important Files Changed

Filename Overview
mux/crates/mux-tui/src/app.rs Adds plugin focus, sync, input forwarding, and click handling; remote plugin discovery can still be skipped by local config.
mux/crates/mux-tui/src/ui/sidebar.rs Adds plugin drawing; remote plugin rendering can still fall back to the built-in sidebar based on local config.
mux/crates/mux-tui/src/config.rs Adds sidebar plugin config parsing and shortcut support; command parsing still changes empty argv entries.
mux/crates/mux-core/src/mux.rs Adds server-side plugin runtime state, spawning, resizing, exit handling, and retry tracking.
mux/crates/mux-core/src/server.rs Adds the sidebar-plugin control command and status response.
mux/crates/mux-tui/src/session/mod.rs Adds local and remote session plumbing for requesting a sidebar plugin surface.

Reviews (4): Last reviewed commit: "mux: judge fixes — protocol stays 6, cli..." | Re-trigger Greptile

}
}

fn sync_sidebar_plugin(&mut self, relaunch: bool) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Remote Plugin Hidden By Local Config

When an attached TUI connects to a server that has sidebar.plugin configured, this local config check returns before sending the new sidebar-plugin request unless the attach client has the same config file. The server-owned plugin surface is never discovered, so attach clients keep rendering the built-in sidebar instead of the configured plugin.

Comment on lines 37 to +40
pub fn draw(app: &mut App, frame: &mut Frame) {
if app.config.sidebar.plugin.is_some() {
draw_plugin(app, frame);
return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Remote Plugin Draw Path Skipped

This chooses the plugin renderer from the attach client's local config, but the plugin process is configured and hosted by the server. A remote client without a matching local sidebar.plugin entry always calls draw_builtin(), so even a valid server-returned plugin surface cannot be displayed.

Comment on lines +814 to +819
let command = plugin
.command
.unwrap_or_default()
.into_iter()
.filter(|arg| !arg.is_empty())
.collect::<Vec<_>>();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Empty Arguments Are Dropped

A plugin command can validly include an empty argument, such as an empty query, delimiter, or positional value. This filter removes those argv entries before spawning the process, so the plugin receives a different command line than the user configured.

Suggested change
let command = plugin
.command
.unwrap_or_default()
.into_iter()
.filter(|arg| !arg.is_empty())
.collect::<Vec<_>>();
let command = plugin.command.unwrap_or_default();

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment thread mux/crates/mux-tui/src/ui/mod.rs
Comment thread mux/crates/mux-core/src/mux.rs
Comment thread mux/crates/mux-tui/src/ui/sidebar.rs
@blacksmith-sh

This comment has been minimized.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@mux/crates/mux-core/src/mux.rs`:
- Around line 357-383: spawn_sidebar_plugin_surface duplicates the id creation,
test/non-test Surface::spawn dispatch, and state insertion logic already handled
by spawn_surface_with. Extract the shared spawn bookkeeping into a helper such
as finish_spawn on mux::Mux that takes the SurfaceId and SurfaceOptions,
performs the cfg(test) Surface::spawn_for_test vs Surface::spawn selection,
inserts into state.surfaces, and returns the Arc<Surface>; then have
spawn_sidebar_plugin_surface only build its overridden opts and delegate to that
helper.
- Around line 612-677: ensure_sidebar_plugin is racing because it drops
sidebar_plugin before spawn_sidebar_plugin_surface, allowing multiple callers to
start duplicate sidebar processes. Keep the sidebar_plugin slot exclusively
claimed under the same lock during startup, or introduce a spawning sentinel in
the runtime state so only one thread can spawn at a time. Update
ensure_sidebar_plugin and the SidebarPluginRuntime flow to serialize startup,
then set surface/last_error/retry_at only after the single spawn completes.

In `@mux/crates/mux-tui/src/app.rs`:
- Line 879: `sync_layout()` is re-querying the remote sidebar plugin on every
Draw because it always calls `sync_sidebar_plugin(false)`, which can cause
repeated blocking requests. Update the `App::sync_sidebar_plugin` flow to cache
the last plugin status and honor `sidebar_plugin_retry_after_ms` so `request()`
is skipped until the retry window expires, geometry changes, relaunch is
requested, or the plugin exits. Use the existing `sync_layout`,
`sync_sidebar_plugin`, and `sidebar_plugin_retry_after_ms` symbols to wire the
backoff logic into the draw path.

In `@mux/crates/mux-tui/src/ui/sidebar.rs`:
- Around line 66-67: The sidebar rendering path in sidebar.rs is returning too
early when app.sidebar_plugin_surface is set but app.session.surface(surface_id)
is missing, which prevents the fallback error message from being shown. Update
the sidebar rendering logic around the surface lookup to avoid the let-else
early return and instead fall through to the existing unavailable/error-message
branch, using nested if-let or equivalent control flow so the sidebar can
display the configured “plugin unavailable” message.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6bf9471d-475c-436f-9dfa-71f3ed161776

📥 Commits

Reviewing files that changed from the base of the PR and between 4deb431 and b3260b2.

📒 Files selected for processing (17)
  • mux/crates/mux-core/src/lib.rs
  • mux/crates/mux-core/src/mux.rs
  • mux/crates/mux-core/src/server.rs
  • mux/crates/mux-tui/src/app.rs
  • mux/crates/mux-tui/src/config.rs
  • mux/crates/mux-tui/src/main.rs
  • mux/crates/mux-tui/src/session/mod.rs
  • mux/crates/mux-tui/src/session/remote.rs
  • mux/crates/mux-tui/src/ui/mod.rs
  • mux/crates/mux-tui/src/ui/pane.rs
  • mux/crates/mux-tui/src/ui/sidebar.rs
  • mux/crates/mux-tui/src/ui/terminal_grid.rs
  • mux/crates/mux-tui/tests/cli.rs
  • mux/scripts/smoke-tui.py
  • mux/spec/README.md
  • mux/spec/commands.md
  • mux/spec/plugins.md

Comment on lines +357 to +383
fn spawn_sidebar_plugin_surface(
self: &Arc<Self>,
options: &SidebarPluginOptions,
size: (u16, u16),
) -> anyhow::Result<Arc<Surface>> {
if options.command.is_empty() {
anyhow::bail!("sidebar plugin command is empty");
}
let id = self.next_id();
let mut opts = self.surface_options.lock().unwrap().clone();
opts.command = Some(options.command.clone());
opts.cwd = options.cwd.clone();
opts.cols = size.0.max(1);
opts.rows = size.1.max(1);
opts.extra_env.push(("CMUX_SIDEBAR".to_string(), "1".to_string()));
#[cfg(test)]
let surface = if self.test_surface_runtime {
Surface::spawn_for_test(id, opts, Arc::downgrade(self))?
} else {
Surface::spawn(id, opts, Arc::downgrade(self))?
};
#[cfg(not(test))]
let surface = Surface::spawn(id, opts, Arc::downgrade(self))?;
self.state.lock().unwrap().surfaces.insert(id, surface.clone());
Ok(surface)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated surface-spawn boilerplate.

spawn_sidebar_plugin_surface repeats spawn_surface_with's id-generation / #[cfg(test)] spawn dispatch / state-insert block almost verbatim — only the opts field overrides differ. Consider extracting a shared fn finish_spawn(self: &Arc<Self>, id: SurfaceId, opts: SurfaceOptions) -> anyhow::Result<Arc<Surface>> helper that both callers build opts for and delegate to, so future changes to spawn bookkeeping (e.g. additional insert-time side effects) don't need to be kept in sync across two near-identical copies.

♻️ Sketch of a shared helper
+    fn finish_spawn(self: &Arc<Self>, id: u64, opts: SurfaceOptions) -> anyhow::Result<Arc<Surface>> {
+        #[cfg(test)]
+        let surface = if self.test_surface_runtime {
+            Surface::spawn_for_test(id, opts, Arc::downgrade(self))?
+        } else {
+            Surface::spawn(id, opts, Arc::downgrade(self))?
+        };
+        #[cfg(not(test))]
+        let surface = Surface::spawn(id, opts, Arc::downgrade(self))?;
+        self.state.lock().unwrap().surfaces.insert(id, surface.clone());
+        Ok(surface)
+    }
+
     fn spawn_sidebar_plugin_surface(
         self: &Arc<Self>,
         options: &SidebarPluginOptions,
         size: (u16, u16),
     ) -> anyhow::Result<Arc<Surface>> {
         if options.command.is_empty() {
             anyhow::bail!("sidebar plugin command is empty");
         }
         let id = self.next_id();
         let mut opts = self.surface_options.lock().unwrap().clone();
         opts.command = Some(options.command.clone());
         opts.cwd = options.cwd.clone();
         opts.cols = size.0.max(1);
         opts.rows = size.1.max(1);
         opts.extra_env.push(("CMUX_SIDEBAR".to_string(), "1".to_string()));
-        #[cfg(test)]
-        let surface = if self.test_surface_runtime {
-            Surface::spawn_for_test(id, opts, Arc::downgrade(self))?
-        } else {
-            Surface::spawn(id, opts, Arc::downgrade(self))?
-        };
-        #[cfg(not(test))]
-        let surface = Surface::spawn(id, opts, Arc::downgrade(self))?;
-        self.state.lock().unwrap().surfaces.insert(id, surface.clone());
-        Ok(surface)
+        self.finish_spawn(id, opts)
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn spawn_sidebar_plugin_surface(
self: &Arc<Self>,
options: &SidebarPluginOptions,
size: (u16, u16),
) -> anyhow::Result<Arc<Surface>> {
if options.command.is_empty() {
anyhow::bail!("sidebar plugin command is empty");
}
let id = self.next_id();
let mut opts = self.surface_options.lock().unwrap().clone();
opts.command = Some(options.command.clone());
opts.cwd = options.cwd.clone();
opts.cols = size.0.max(1);
opts.rows = size.1.max(1);
opts.extra_env.push(("CMUX_SIDEBAR".to_string(), "1".to_string()));
#[cfg(test)]
let surface = if self.test_surface_runtime {
Surface::spawn_for_test(id, opts, Arc::downgrade(self))?
} else {
Surface::spawn(id, opts, Arc::downgrade(self))?
};
#[cfg(not(test))]
let surface = Surface::spawn(id, opts, Arc::downgrade(self))?;
self.state.lock().unwrap().surfaces.insert(id, surface.clone());
Ok(surface)
}
fn finish_spawn(self: &Arc<Self>, id: u64, opts: SurfaceOptions) -> anyhow::Result<Arc<Surface>> {
#[cfg(test)]
let surface = if self.test_surface_runtime {
Surface::spawn_for_test(id, opts, Arc::downgrade(self))?
} else {
Surface::spawn(id, opts, Arc::downgrade(self))?
};
#[cfg(not(test))]
let surface = Surface::spawn(id, opts, Arc::downgrade(self))?;
self.state.lock().unwrap().surfaces.insert(id, surface.clone());
Ok(surface)
}
fn spawn_sidebar_plugin_surface(
self: &Arc<Self>,
options: &SidebarPluginOptions,
size: (u16, u16),
) -> anyhow::Result<Arc<Surface>> {
if options.command.is_empty() {
anyhow::bail!("sidebar plugin command is empty");
}
let id = self.next_id();
let mut opts = self.surface_options.lock().unwrap().clone();
opts.command = Some(options.command.clone());
opts.cwd = options.cwd.clone();
opts.cols = size.0.max(1);
opts.rows = size.1.max(1);
opts.extra_env.push(("CMUX_SIDEBAR".to_string(), "1".to_string()));
self.finish_spawn(id, opts)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mux/crates/mux-core/src/mux.rs` around lines 357 - 383,
spawn_sidebar_plugin_surface duplicates the id creation, test/non-test
Surface::spawn dispatch, and state insertion logic already handled by
spawn_surface_with. Extract the shared spawn bookkeeping into a helper such as
finish_spawn on mux::Mux that takes the SurfaceId and SurfaceOptions, performs
the cfg(test) Surface::spawn_for_test vs Surface::spawn selection, inserts into
state.surfaces, and returns the Arc<Surface>; then have
spawn_sidebar_plugin_surface only build its overridden opts and delegate to that
helper.

Comment on lines +612 to +677
pub fn ensure_sidebar_plugin(
self: &Arc<Self>,
cols: u16,
rows: u16,
relaunch: bool,
) -> SidebarPluginStatus {
let now = Instant::now();
let size = (cols.max(1), rows.max(1));
let spawn_options = {
let mut runtime = self.sidebar_plugin.lock().unwrap();
let Some(options) = runtime.options.clone() else {
return SidebarPluginStatus { surface: None, error: None, retry_after: None };
};
if let Some(surface_id) = runtime.surface {
if let Some(surface) = self.surface(surface_id).filter(|surface| !surface.is_dead())
{
drop(runtime);
let _ = self.resize_surface(surface_id, size.0, size.1);
drop(surface);
return SidebarPluginStatus {
surface: Some(surface_id),
error: None,
retry_after: None,
};
}
runtime.surface = None;
}
if let Some(error) = runtime.last_error.clone() {
let retry_after = runtime.retry_at.and_then(|retry_at| {
(retry_at > now).then_some(retry_at.saturating_duration_since(now))
});
if !relaunch || retry_after.is_some() {
return SidebarPluginStatus { surface: None, error: Some(error), retry_after };
}
}
options
};
match self.spawn_sidebar_plugin_surface(&spawn_options, size) {
Ok(surface) => {
let surface_id = surface.id;
{
let mut runtime = self.sidebar_plugin.lock().unwrap();
runtime.surface = Some(surface_id);
runtime.last_error = None;
runtime.failures = 0;
runtime.retry_at = None;
}
self.reap_if_dead(&surface);
SidebarPluginStatus { surface: Some(surface_id), error: None, retry_after: None }
}
Err(err) => {
let mut runtime = self.sidebar_plugin.lock().unwrap();
runtime.surface = None;
runtime.failures = runtime.failures.saturating_add(1);
let delay = sidebar_retry_delay(runtime.failures);
let message = format!("sidebar plugin failed to start: {err}");
runtime.last_error = Some(message.clone());
runtime.retry_at = Some(now + delay);
SidebarPluginStatus {
surface: None,
error: Some(message),
retry_after: Some(delay),
}
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm each socket connection is handled on its own thread (so
# concurrent SidebarPlugin commands from different clients are plausible).
rg -n 'thread::Builder|incoming\(\)|accept\(\)' mux/crates/mux-core/src/server.rs -A3

Repository: manaflow-ai/cmux

Length of output: 1575


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant source first, then inspect the sidebar-plugin call path.
ast-grep outline mux/crates/mux-core/src/mux.rs --view expanded | sed -n '1,220p'

printf '\n--- ensure_sidebar_plugin references ---\n'
rg -n 'ensure_sidebar_plugin\(' mux/crates/mux-core/src -A3 -B3

printf '\n--- sidebar plugin command path ---\n'
rg -n 'sidebar-plugin|SidebarPlugin|ensure_sidebar_plugin' mux/crates/mux-core/src/server.rs mux/crates/mux-core/src -A4 -B4

Repository: manaflow-ai/cmux

Length of output: 25539


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the sidebar plugin spawn path and any state registration/cleanup it triggers.
sed -n '307,390p' mux/crates/mux-core/src/mux.rs

printf '\n--- surface insertion/cleanup helpers ---\n'
sed -n '1338,1370p' mux/crates/mux-core/src/mux.rs
sed -n '1813,1848p' mux/crates/mux-core/src/mux.rs

Repository: manaflow-ai/cmux

Length of output: 5710


Serialize sidebar plugin startup mux/crates/mux-core/src/mux.rs:620-660 — ensure_sidebar_plugin releases sidebar_plugin before spawning, so two connection threads can both see no live surface and start separate sidebar processes. The later surface id wins and the other stays in state.surfaces without a cleanup path. Keep the slot claimed under the same lock (or add a “spawning” sentinel) until spawn completes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mux/crates/mux-core/src/mux.rs` around lines 612 - 677, ensure_sidebar_plugin
is racing because it drops sidebar_plugin before spawn_sidebar_plugin_surface,
allowing multiple callers to start duplicate sidebar processes. Keep the
sidebar_plugin slot exclusively claimed under the same lock during startup, or
introduce a spawning sentinel in the runtime state so only one thread can spawn
at a time. Update ensure_sidebar_plugin and the SidebarPluginRuntime flow to
serialize startup, then set surface/last_error/retry_at only after the single
spawn completes.

height: height.saturating_sub(1), // status bar
};
self.content_area = area;
self.sync_sidebar_plugin(false);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm sync_layout runs on every Draw and sync_sidebar_plugin has no request gating.
rg -nP -C3 'fn sync_layout' mux/crates/mux-tui/src/app.rs
rg -nP -C2 'sync_sidebar_plugin|RenderAction::Draw' mux/crates/mux-tui/src/app.rs
# Contrast with tree() caching in remote session.
rg -nP -C3 'tree_stale' mux/crates/mux-tui/src/session/remote.rs

Repository: manaflow-ai/cmux

Length of output: 17654


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the sidebar sync path and the remote implementation it calls.
sed -n '936,980p' mux/crates/mux-tui/src/app.rs
printf '\n---\n'
rg -n -C3 'fn sidebar_plugin|sidebar_plugin_surface|request\(' mux/crates/mux-tui/src/session/remote.rs mux/crates/mux-tui/src/session.rs mux/crates/mux-tui/src -g '!**/target/**'

Repository: manaflow-ai/cmux

Length of output: 38720


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the remote request implementation and whether retry_after_ms is used to avoid re-polling.
sed -n '404,436p' mux/crates/mux-tui/src/session/remote.rs
printf '\n---\n'
rg -n -C2 'sidebar_plugin_retry_after_ms|retry_after_ms' mux/crates/mux-tui/src/app.rs mux/crates/mux-tui/src/session/mod.rs mux/crates/mux-tui/src/session/remote.rs

Repository: manaflow-ai/cmux

Length of output: 5140


Throttle remote sidebar-plugin lookups on draw
sync_layout() calls sync_sidebar_plugin(false) on every Draw, and the remote sidebar_plugin path issues a blocking request() each time. sidebar_plugin_retry_after_ms is captured but never used to back off, so a missing or slow sidebar plugin can turn redraws into repeated socket round-trips. Cache the last status and skip re-polling until geometry changes, relaunch is requested, the plugin exits, or the retry window expires.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mux/crates/mux-tui/src/app.rs` at line 879, `sync_layout()` is re-querying
the remote sidebar plugin on every Draw because it always calls
`sync_sidebar_plugin(false)`, which can cause repeated blocking requests. Update
the `App::sync_sidebar_plugin` flow to cache the last plugin status and honor
`sidebar_plugin_retry_after_ms` so `request()` is skipped until the retry window
expires, geometry changes, relaunch is requested, or the plugin exits. Use the
existing `sync_layout`, `sync_sidebar_plugin`, and
`sidebar_plugin_retry_after_ms` symbols to wire the backoff logic into the draw
path.

Comment on lines +66 to +67
if let Some(surface_id) = app.sidebar_plugin_surface {
let Some(surface) = app.session.surface(surface_id) else { return };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Missing surface should fall through to error message, not return early.

When sidebar_plugin_surface is set but the surface doesn't exist (e.g., during plugin restart backoff), the let-else at line 67 returns early, skipping the error message fallback at lines 87-99. The user sees stale content from the previous plugin frame instead of a meaningful "sidebar plugin unavailable" or configured error message.

🔧 Proposed fix: restructure as nested if-let to fall through
     if let Some(surface_id) = app.sidebar_plugin_surface {
-        let Some(surface) = app.session.surface(surface_id) else { return };
-        surface.take_dirty();
-        let theme = app.config.theme;
-        let rs = app
-            .render_states
-            .entry(surface_id)
-            .or_insert_with(|| ghostty_vt::RenderState::new().expect("render state alloc"));
-        if surface.snapshot(rs).is_ok() {
-            rs.set_clean();
-            let _ =
-                super::terminal_grid::draw_render_state(frame, content, rs, &theme, |_, _| false);
-            {
-                let buf = frame.buffer_mut();
-                for y in 0..height {
-                    buf[(border_x, y)].set_symbol("│").set_style(border_style);
-                }
+        if let Some(surface) = app.session.surface(surface_id) {
+            surface.take_dirty();
+            let theme = app.config.theme;
+            let rs = app
+                .render_states
+                .entry(surface_id)
+                .or_insert_with(|| ghostty_vt::RenderState::new().expect("render state alloc"));
+            if surface.snapshot(rs).is_ok() {
+                rs.set_clean();
+                let _ =
+                    super::terminal_grid::draw_render_state(frame, content, rs, &theme, |_, _| false);
+                {
+                    let buf = frame.buffer_mut();
+                    for y in 0..height {
+                        buf[(border_x, y)].set_symbol("│").set_style(border_style);
+                    }
+                }
+                return;
             }
-            return;
         }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if let Some(surface_id) = app.sidebar_plugin_surface {
let Some(surface) = app.session.surface(surface_id) else { return };
if let Some(surface_id) = app.sidebar_plugin_surface {
if let Some(surface) = app.session.surface(surface_id) {
surface.take_dirty();
let theme = app.config.theme;
let rs = app
.render_states
.entry(surface_id)
.or_insert_with(|| ghostty_vt::RenderState::new().expect("render state alloc"));
if surface.snapshot(rs).is_ok() {
rs.set_clean();
let _ =
super::terminal_grid::draw_render_state(frame, content, rs, &theme, |_, _| false);
{
let buf = frame.buffer_mut();
for y in 0..height {
buf[(border_x, y)].set_symbol("│").set_style(border_style);
}
}
return;
}
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mux/crates/mux-tui/src/ui/sidebar.rs` around lines 66 - 67, The sidebar
rendering path in sidebar.rs is returning too early when
app.sidebar_plugin_surface is set but app.session.surface(surface_id) is
missing, which prevents the fallback error message from being shown. Update the
sidebar rendering logic around the surface lookup to avoid the let-else early
return and instead fall through to the existing unavailable/error-message
branch, using nested if-let or equivalent control flow so the sidebar can
display the configured “plugin unavailable” message.

Every additive command batch (wait-for/run/notify verbs, WM ops,
server-control) kept protocol 6 per spec convention; the bump broke the
conformance fixtures that pin protocol 6 (bindings-e2e).
Comment thread mux/crates/mux-core/src/server.rs
}

fn sync_sidebar_plugin(&mut self, relaunch: bool) {
if self.config.sidebar.plugin.is_none() || self.sidebar_width < 3 || !self.sidebar_visible {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Remote plugin skipped

This local config check still stops remote plugin discovery before the client asks the server for its sidebar surface. When an attached client has no local sidebar.plugin entry but the server does, this branch clears sidebar_plugin_surface and returns before sending sidebar-plugin. The sidebar then stays on the built-in workspace view even though the server-owned plugin is configured and available.

.command
.unwrap_or_default()
.into_iter()
.filter(|arg| !arg.is_empty())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Empty argv dropped

This still removes valid empty string arguments from sidebar.plugin.command before the command reaches the spawn path. A configured command like [/path/plugin, "", --flag] is a valid argv shape, and the later surface spawn code only checks whether the whole vector is empty. Dropping the empty positional argument shifts the remaining arguments, so the plugin can receive a different command line than the user configured.

…ened smoke, docs

Revert protocol to 6 everywhere (additive command; bump broke the published
SDKs' version checks and the conformance fixtures). Clicking outside the
sidebar plugin returns keyboard focus to the panes. Smoke plugin now
awk-prefixes forwarded input (only real PTY forwarding can produce the
asserted string) and verifies prefix-S returns focus to the pane.
configuration.md/keyboard.md document sidebar.plugin.* and focus-sidebar;
spec notes sidebar-plugin has no CLI verb (client-internal).
Comment on lines +936 to +945
fn sync_sidebar_plugin(&mut self, relaunch: bool) {
if self.config.sidebar.plugin.is_none() || self.sidebar_width < 3 || !self.sidebar_visible {
self.sidebar_plugin_surface = None;
self.sidebar_plugin_error = None;
self.sidebar_plugin_retry_after_ms = None;
self.sidebar_focused = false;
return;
}
let rect = self.sidebar_plugin_rect();
if rect.width == 0 || rect.height == 0 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Remote plugin skipped

This branch still uses the attach client's local mux.json to decide whether to ask the server for the sidebar plugin. When a remote client connects without a local sidebar.plugin entry, but the mux server has one configured, this returns early, clears sidebar_plugin_surface, and never sends the sidebar-plugin request. The attached client keeps showing the built-in sidebar instead of the server-owned plugin.

Comment on lines 37 to +42
pub fn draw(app: &mut App, frame: &mut Frame) {
if app.config.sidebar.plugin.is_some() {
draw_plugin(app, frame);
return;
}
draw_builtin(app, frame);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Plugin draw skipped

This render choice is still based on the attach client's local config, but the plugin surface comes from the server. A remote client without a local sidebar.plugin entry always calls draw_builtin(), so a valid server-returned plugin surface is never rendered. Keying this path off the discovered plugin surface or server status would keep remote attach clients from falling back to the built-in sidebar incorrectly.

Comment on lines +814 to +819
let command = plugin
.command
.unwrap_or_default()
.into_iter()
.filter(|arg| !arg.is_empty())
.collect::<Vec<_>>();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Empty argv dropped

This still removes valid empty string arguments from sidebar.plugin.command. A configured command like ["/path/plugin", "", "--flag"] is a valid argv shape, but this rewrites it to ["/path/plugin", "--flag"] before spawn_sidebar_plugin_surface passes the vector to the child process. That shifts positional arguments and can make the plugin receive a different command line than the user configured.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 43fcbf8. Configure here.

.and_then(serde_json::Value::as_str)
.map(str::to_string),
retry_after_ms: data.get("retry_after_ms").and_then(serde_json::Value::as_u64),
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale sidebar id after attach

Medium Severity

On a remote session, when sidebar-plugin returns a surface id but attach-surface fails, the client still stores that id. The plugin drawer then exits early because no mirror exists, so the sidebar stays blank even when the RPC included an error.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 43fcbf8. Configure here.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
mux/crates/mux-tui/src/app.rs (3)

1870-1908: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

paste and paste_sidebar duplicate the bracketed-paste encoding.

The bracketed-paste-mode check and byte assembly (\x1b[200~...\x1b[201~) is copy-pasted verbatim between paste and paste_sidebar. Same pattern as forward_key/forward_sidebar_key above — extract a shared write_pasted_text(surface: &SurfaceHandle, text: &str) helper so the two entrypoints can't silently diverge.

♻️ Proposed extraction
+    fn write_pasted_text(surface: &SurfaceHandle, text: &str) {
+        let Some(bracketed) = surface.with_terminal(|t| t.mode(2004, false)) else {
+            return;
+        };
+        if bracketed {
+            let mut bytes = Vec::with_capacity(text.len() + 12);
+            bytes.extend_from_slice(b"\x1b[200~");
+            bytes.extend_from_slice(text.as_bytes());
+            bytes.extend_from_slice(b"\x1b[201~");
+            surface.write_bytes(&bytes);
+        } else {
+            surface.write_bytes(text.as_bytes());
+        }
+    }
+
     fn paste_sidebar(&mut self, text: &str) {
         let Some(surface) = self.sidebar_surface_handle() else { return };
-        let Some(bracketed) = surface.with_terminal(|t| t.mode(2004, false)) else {
-            return;
-        };
-        if bracketed {
-            let mut bytes = Vec::with_capacity(text.len() + 12);
-            bytes.extend_from_slice(b"\x1b[200~");
-            bytes.extend_from_slice(text.as_bytes());
-            bytes.extend_from_slice(b"\x1b[201~");
-            surface.write_bytes(&bytes);
-        } else {
-            surface.write_bytes(text.as_bytes());
-        }
+        Self::write_pasted_text(&surface, text);
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mux/crates/mux-tui/src/app.rs` around lines 1870 - 1908, `paste` and
`paste_sidebar` duplicate the same bracketed-paste handling logic, so refactor
the shared `mode(2004, false)` check and `\x1b[200~...\x1b[201~` byte assembly
into a common helper such as `write_pasted_text` that accepts a surface handle
and text. Update both `paste` and `paste_sidebar` to call the helper, matching
the existing `forward_key`/`forward_sidebar_key` pattern so the two paths stay
in sync.

Source: Coding guidelines


1343-1375: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

handle_prefixed's FocusSidebar special-case is convoluted.

Clearing sidebar_focused for every action and then guarding with was_sidebar_focused && action == Action::FocusSidebar to suppress the later run_action call works, but it's easy to break with future edits (any new early-return inserted between the clear and the guard would silently change behavior). Handling Action::FocusSidebar first, before the generic clear, removes the need for was_sidebar_focused entirely.

♻️ Proposed simplification
     let Some(action) = self.config.keys.action_for(&key) else {
         if self.sidebar_focused {
             self.sidebar_focused = false;
         }
         return Ok(RenderAction::Draw); // unknown prefix command: swallow, redraw indicator
     };
-    let was_sidebar_focused = self.sidebar_focused;
+    if action == Action::FocusSidebar {
+        self.toggle_sidebar_focus();
+        return Ok(RenderAction::Draw);
+    }
     if self.sidebar_focused {
         self.sidebar_focused = false;
     }
-    if was_sidebar_focused && action == Action::FocusSidebar {
-        return Ok(RenderAction::Draw);
-    }
     if browser_only_action(action)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mux/crates/mux-tui/src/app.rs` around lines 1343 - 1375, The
`handle_prefixed` logic around `Action::FocusSidebar` is overly fragile because
it clears `sidebar_focused` generically and then relies on `was_sidebar_focused`
to suppress `run_action`. Update `handle_prefixed` in `app.rs` to handle
`Action::FocusSidebar` as a dedicated early case before the blanket sidebar
state reset, so the focused-sidebar toggle is explicit and `was_sidebar_focused`
is no longer needed. Keep the existing prefix forwarding and browser-only action
behavior unchanged.

1784-1821: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

forward_key and forward_sidebar_key duplicate the encode/write body.

Everything from self.encode_buf.clear(); through surface.write_bytes(&self.encode_buf); is identical between the two functions, differing only in how surface is obtained (active_surface_handle() vs sidebar_surface_handle()). Extract a shared helper taking the resolved SurfaceHandle to avoid future divergence between the two paths. As per coding guidelines, "implement one shared action/model path and verify every entrypoint that should invoke it... Do not patch one surface while leaving the others with duplicated logic."

♻️ Proposed extraction
+    fn encode_and_write(&mut self, key: &KeyEvent, surface: &SurfaceHandle) {
+        let Some(input) = keys::key_input_from(key) else { return };
+        self.encode_buf.clear();
+        let _ = surface.scroll_to_bottom();
+        let Some(encoded) = surface.with_terminal(|term| {
+            self.encoder.sync_from_terminal(term);
+            self.encoder.encode(&input, &mut self.encode_buf)
+        }) else {
+            return;
+        };
+        if encoded.is_ok() && !self.encode_buf.is_empty() {
+            surface.write_bytes(&self.encode_buf);
+        }
+    }
+
     fn forward_key(&mut self, key: &KeyEvent) {
         if self
             .active_surface_handle()
             .is_some_and(|surface| surface.kind() == SurfaceKind::Browser)
         {
             self.forward_browser_key(key);
             return;
         }
-        let Some(input) = keys::key_input_from(key) else { return };
         let Some(surface) = self.active_surface_handle() else { return };
-        self.encode_buf.clear();
-        let _ = surface.scroll_to_bottom();
-        let Some(encoded) = surface.with_terminal(|term| {
-            self.encoder.sync_from_terminal(term);
-            self.encoder.encode(&input, &mut self.encode_buf)
-        }) else {
-            return;
-        };
-        if encoded.is_ok() && !self.encode_buf.is_empty() {
-            surface.write_bytes(&self.encode_buf);
-        }
+        self.encode_and_write(key, &surface);
     }

     fn forward_sidebar_key(&mut self, key: &KeyEvent) {
-        let Some(input) = keys::key_input_from(key) else { return };
         let Some(surface) = self.sidebar_surface_handle() else { return };
-        self.encode_buf.clear();
-        let _ = surface.scroll_to_bottom();
-        let Some(encoded) = surface.with_terminal(|term| {
-            self.encoder.sync_from_terminal(term);
-            self.encoder.encode(&input, &mut self.encode_buf)
-        }) else {
-            return;
-        };
-        if encoded.is_ok() && !self.encode_buf.is_empty() {
-            surface.write_bytes(&self.encode_buf);
-        }
+        self.encode_and_write(key, &surface);
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mux/crates/mux-tui/src/app.rs` around lines 1784 - 1821, The key forwarding
logic in forward_key and forward_sidebar_key is duplicated after the surface
lookup, so extract the shared encode/write flow into a helper that accepts a
resolved SurfaceHandle. Keep forward_key and forward_sidebar_key focused on
selecting the correct surface (active_surface_handle vs sidebar_surface_handle)
and then delegate the common self.encode_buf.clear(), scroll_to_bottom,
with_terminal, encoder.encode, and write_bytes sequence to the shared helper to
prevent future divergence.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@mux/crates/mux-tui/src/app.rs`:
- Around line 1870-1908: `paste` and `paste_sidebar` duplicate the same
bracketed-paste handling logic, so refactor the shared `mode(2004, false)` check
and `\x1b[200~...\x1b[201~` byte assembly into a common helper such as
`write_pasted_text` that accepts a surface handle and text. Update both `paste`
and `paste_sidebar` to call the helper, matching the existing
`forward_key`/`forward_sidebar_key` pattern so the two paths stay in sync.
- Around line 1343-1375: The `handle_prefixed` logic around
`Action::FocusSidebar` is overly fragile because it clears `sidebar_focused`
generically and then relies on `was_sidebar_focused` to suppress `run_action`.
Update `handle_prefixed` in `app.rs` to handle `Action::FocusSidebar` as a
dedicated early case before the blanket sidebar state reset, so the
focused-sidebar toggle is explicit and `was_sidebar_focused` is no longer
needed. Keep the existing prefix forwarding and browser-only action behavior
unchanged.
- Around line 1784-1821: The key forwarding logic in forward_key and
forward_sidebar_key is duplicated after the surface lookup, so extract the
shared encode/write flow into a helper that accepts a resolved SurfaceHandle.
Keep forward_key and forward_sidebar_key focused on selecting the correct
surface (active_surface_handle vs sidebar_surface_handle) and then delegate the
common self.encode_buf.clear(), scroll_to_bottom, with_terminal, encoder.encode,
and write_bytes sequence to the shared helper to prevent future divergence.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 748a6fbb-9382-48e3-9f18-5c742b81c766

📥 Commits

Reviewing files that changed from the base of the PR and between fca23bb and 43fcbf8.

📒 Files selected for processing (9)
  • mux/crates/mux-core/src/server.rs
  • mux/crates/mux-tui/src/app.rs
  • mux/crates/mux-tui/src/session/remote.rs
  • mux/docs/configuration.md
  • mux/docs/keyboard.md
  • mux/scripts/smoke-tui.py
  • mux/spec/README.md
  • mux/spec/cli.md
  • mux/spec/commands.md

This branch was successfully deployed

1 active deployment
Preview – cmux — 43fcbf8a Deployed Jul 9, 2026 by vercel[bot]
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