Skip to content

Right side panel

mnml’s chrome wraps the editor on three sides: the file rail and activity bar on the left (toggled by Ctrl+B), the statusline + bufferline at top and bottom, and — since 2026-06-28 — a right side panel that mirrors the rail on the opposite edge. The right panel is the home for panes that read better as a vertical sidebar than as a horizontal split: the Outline (outline.show) and the project-wide Diagnostics list (lsp.diagnostics). When the panel is visible, those two commands route their pane into the panel instead of carving a split out of the editor body.

right side panel toggle: empty state → outline hosted → close evicts the pane

The panel is opt-in. It opens with Ctrl+Shift+B (the natural mirror of Ctrl+B), it can be sized by dragging its left edge, it persists across restarts via session.json, and it has a one-cell × close button on its header so you can evict the hosted pane without closing the column itself.

GestureWhat it does
Ctrl+Shift+BToggle the panel. Fires view.toggle_right_panel.
:set rightpanelOpen the panel (idempotent — already-open stays open).
:set rightpanel!Toggle (vim-style invrightpanel is the long form; :set rp! is the short form).
:set norightpanelClose the panel.
Click the palette-bar’s right-panel chipToggle. Same command.

Ctrl+Shift+B is VS Code’s “Run Build Task” chord. mnml has no build-task concept yet, so the chord is repurposed for the panel toggle; if a task runner lands later, the binding may need revisiting (Ctrl+Alt+B is the next pick). The palette command (view.toggle_right_panel) calls this out in its title so a porter sees the conflict.

The panel’s visibility, width, and currently-hosted pane id all round-trip through <workspace>/.mnml/session.json, so restart mnml and the panel comes back exactly as you left it.

open lib.rs, open the panel, outline.show + lsp.diagnostics both host as TABS, click between them or use <leader>t[ / <leader>t] to cycle, × closes the active tab, panel stays open

Three commands route their pane into the panel as a tab when it’s open:

  • outline.show — the LSP Outline pane (symbols sidebar for the active file). Default chord <leader>lo (vim) or palette. Tab label shows <filename> ⌥<symbol-count>.
  • lsp.diagnostics — the workspace-wide Problems list. Default chord <leader>le (vim) or palette. Tab label shows problems ✗<errors> ⚠<warnings>.
  • ai.chat / ai.ask / ai.explain / … — the AI chat pane (v4, 2026-06-28). Tab label shows <title> ●/✦/✗/… (state-marker). When the panel column is narrower than 40 cells, a one-line hint at the top of the chat body suggests dragging the edge wider.

When the panel is closed, these commands fall back to their pre-panel behavior: Outline splits horizontally above the active editor; Diagnostics opens a vertical split below the focused leaf; AI opens as a horizontal split next to the editor. When the panel is open, the pane is pushed into app.right_panel_panes as a new tab — the editor body keeps its full width, and the panel renders the active tab inside its column.

The tab strip caps at 3 simultaneous tabs. Pushing a 4th displaces the oldest (FIFO) with a toast so you know what happened. Tabs you don’t see scroll off the right edge — the label truncates with so long titles still fit.

Each chip gets a per-chip budget (roughly column_width / n_tabs - gap). When the full title fits, the chip paints it verbatim. When it would truncate past the count or status glyph — the part that actually changes over time — the chip falls back to a short form that keeps the live information instead:

PaneFull labelShort form when tight
Outlinemain.rs ⌥42main.rs (file stem only — truncated to budget if even that doesn’t fit)
Diagnosticsproblems ✗2 ⚠1✗2⚠1 (or ✗2 / ⚠1 / depending on counts)
Teststests Done ✓15 ✗0✓15 (passes), ✗1 (any failures), (running)
Grepgrep:query (24)que… 24 at budget ≥ 5, (24) at the tightest
AIAI: explain — doneAI ✦ at budget ≥ 4, / / / alone otherwise

Status glyphs across all five: running / asking, live, done, failed, passed. The point is that when you can’t see the noun, you can still see the state — three Diagnostics tabs read ✗2 / ✗1 / ✓ at a glance even when each chip is only 4 cells wide.

GestureWhat it does
<leader>t]Switch to the next tab in the strip
<leader>t[Previous tab
<leader>tx / Ctrl+Alt+WClose the active tab (view.right_panel_close_tab)
Click a tab chipSwitch to that tab
Click the × buttonClose the active tab
Right-click the × buttonSame context menu as right-clicking the active tab
Right-click an inactive tabMenu: Switch to this tab / Close tab / Close other tabs / Close all tabs / Hide side panel
Right-click the active tabMenu: Close tab / Close other tabs / Close all tabs / Hide side panel
Click :outline.show / :lsp.diagnostics / :ai.chat / :find.grep / :test.run in the empty stateFires that command directly — mouse path to populate the panel

Ctrl+Alt+W deliberately steers clear of vim NORMAL’s Ctrl+W window prefix (which would be eaten by the vim handler before reaching the global keymap) and isn’t a VS Code chord for anything else, so it’s safe in both input modes. The Close other tabs and Close all tabs items show up only when there are at least two tabs — the menu prunes them when there’s nothing to close.

right-click an inactive tab, pick "Close other tabs" — only the right-clicked tab survives. Re-add a second tab and Ctrl+Alt+W closes the active one

With the panel open but no pane hosted, the body paints a faint hint listing every command that routes into the panel when visible:

right panel
Nothing here yet.
:outline.show
:lsp.diagnostics
:ai.chat
:find.grep
:test.run
Hide: Ctrl+Shift+B or :set norp

The header reads right panel in lowercase — same vocabulary the palette title, the tooltips, whichkey, the context menu, and the toast all use, so a bold modifier alone is enough hierarchy. The body hint is comment-colored on the panel’s slightly-darker background. All five ex commands are real and clickable — click the row and that command fires immediately. Or copy-and-paste them into the cmdline.

When a pane is hosted, the panel’s header shows the tab strip. The far-right cell is a clickable × (or x under [ui] ascii_icons = true) — left-click evicts the active hosted tab but keeps the panel open. Right-click on the × opens the same context menu the active tab would (Close tab / Close other tabs / Close all tabs / Hide side panel).

The × paints in two visually distinct states so you can tell at a glance which tab it acts on:

  • Active tab is the rightmost chip — the cells between the chip’s right edge and the × fill with the chip’s active background (bg2) and the × itself paints in fg on bg2. The bridge makes the × read as the chip’s close button.
  • Active tab is NOT rightmost (e.g. you cycled to a tab that’s followed by another) — no bridge paints, and the × switches to comment on bg_dark (the inactive-chip styling). It’s still the same action — close the active tab — but the visual signals “mode-dependent” instead of “local to the chip next to me”, so you don’t read it as a close-this-inactive-chip button.

This is the eviction split mnml makes for the panel:

GesturePanelHosted pane
Click the header’s ×Stays open (empty state)Evicted from the panel (still in app.panes)
Ctrl+Shift+B (toggle off)ClosesEvicted from the panel
:set norightpanelClosesEvicted from the panel
view.toggle_right_panel from the paletteTogglesEvicted on close

Either close path nulls right_panel_pane_id. Re-opening the panel returns to the empty-state hint — your hosted pane isn’t auto-restored, because the design assumes you’ll re-fire outline.show or lsp.diagnostics for whatever you want next.

right panel tabs: outline.show + lsp.diagnostics host as two tabs; Ctrl+Shift+next / Ctrl+Shift+prev cycle between them; × closes the active tab and the next one falls into focus, then returns to the empty state

A walkthrough of the v3 tab-strip: open the panel (empty state), fire outline.show (Outline becomes tab #1), fire lsp.diagnostics (Diagnostics joins as tab #2 — strip now shows both chips), cycle with <leader>t[ (Outline active) and <leader>t] (Diagnostics active), then click the × button twice — first close evicts Diagnostics and the Outline takes over as the active tab; second close empties the panel back to the hint state. The panel column stays open the whole time; only the hosted panes come and go.

right panel AI chat: open lib.rs, open the panel, fire ai.explain — the AI chat lands in the panel as a tab alongside Outline/Diagnostics; "AI chat reads better at 40+ cells" hint shows when narrow; pane streams from Asking through to Done

ai.chat / ai.ask / ai.explain / ai.fix / ai.refactor / ai.write_tests all route into the side panel as a tab when it’s open (instead of splitting the editor body). The tab label carries the prompt title (AI: explain, AI: ask…, …) and a one-glyph state marker: asking, streaming, done, error. Body shows the prompt block at the top followed by the streaming answer; the standard AI-pane bindings (a apply, r re-ask, x cancel) work the same as in a split. When the panel column is narrower than 40 cells, the body paints a AI chat reads better at 40+ cells — drag edge wider hint above the prompt — a softer nudge than the Outline / Diagnostics “too narrow” hint at 16 cells, because the chat can still render at narrow widths, it just reads worse.

The recording uses [ai] backend = "cli" (mnml’s default), which shells claude -p — no ANTHROPIC_API_KEY needed.

The panel takes a fixed cell width carved off the right of the workspace area before the rail’s left split happens, so the rail and the panel size independently. Default width comes from [ui] right_panel_width in TOML (auto-default if unset).

The panel’s left edge has a 2-row visible grip ( / | in ASCII mode) centered vertically. The hit zone is 3 cells wide × 4 rows tall — bigger than the visible grip so trackpad users don’t miss. Click the grip and drag horizontally to resize; release commits the width to app.right_panel_width and the next session save.

Width is clamped at render time: the panel can’t shrink the editor’s middle column below 20 cells, and the panel itself can’t go below 8 cells.

Below 16 cells the panel’s body would render an unusable squeeze of the Outline or Diagnostics — gutter plus a fragment of each line, no room for the symbol names. Instead of painting that, the panel shows:

OUTLINE
too narrow — drag edge wider

The header still renders so you can see what’s hosted. The body’s hint stays until you drag the grip back past 16 cells or close the panel.

The panel column is independent of the split tree. Splits inside the editor body don’t propagate into the panel — a vertical split is still a vertical split, just within a slightly-narrower middle column. The hosted Outline / Diagnostics pane lives in app.panes like any other pane, but its layout cell is the panel column instead of a leaf in app.layouts.

Practically, this means:

  • The bufferline (when visible) spans the editor’s middle column only, not the panel.
  • Per-leaf tab strips (when splits exist) stay within their splits — the panel column has its own one-line header instead.
  • Focus moves into the panel the same way it moves into other panes — clicking inside it sets app.active to the hosted pane id; Esc from inside the pane returns focus to the previous editor leaf.
  • The drag-resize grip is checked before the left rail’s grip in the layout-rect dispatcher, so dragging never accidentally moves the wrong column.

Two TOML keys cover the panel’s defaults:

~/.config/mnml/config.toml
[ui]
right_panel_visible = false # open the panel on startup
right_panel_width = 32 # initial width in cells

Both also round-trip through <workspace>/.mnml/session.json — the workspace’s last-saved width and visibility override the config defaults on re-open. Delete the session file (rm .mnml/session.json) to fall back to the TOML defaults.

The hosted-tab list is persisted across restarts. session.json stores each tab by KIND ("outline" / "diagnostics"), so re-opening the workspace re-fires those commands in order and the panel comes back exactly as you left it. AI chat tabs are deliberately skipped (live state + auth context can shift between sessions); re-fire :ai.ask etc. to restore one.

  • Pluggable hosts. Only Outline, Diagnostics, and AI route into the panel today. Other panes (test output, grep, browser) are candidates for v5.
  • Overflow chevrons. The 3-tab cap displaces FIFO with a toast; lifting the cap needs left/right scroll chevrons on the tab strip (same shape as the bufferline).
  • src/app/mod.rsright_panel_visible, right_panel_width, right_panel_panes, right_panel_active_idx, right_panel_push, close_right_panel_hosted_panes, RIGHT_PANEL_MAX_TABS.
  • src/ui/mod.rs — the layout split that carves the column (right_panel_area), the tab-strip painter, the header / × / too-narrow / empty-state painters, the drag-grip indicator.
  • src/app/ex_commands.rs:set rightpanel / :set rightpanel! / :set norightpanel.
  • src/command.rsview.toggle_right_panel (Ctrl+Shift+B), view.right_panel_{next,prev,close}_tab.
  • src/app/lsp.rsopen_outline_pane and open_diagnostics_pane (route into the panel when visible).
  • src/app/ai.rsask_ai (routes AI chat into the panel when visible).
  • src/app/mod.rs::open_diagnostics_pane — same routing for lsp.diagnostics.
  • src/app/session.rs — visibility + width round-trip.
  • LSP — the symbols, outline, and diagnostics surfaces that the panel hosts
  • Activity bar — the chrome on the opposite edge (rail + activity strip)
  • Dock widgets — the corner-pinned mini-panels above the editor body
  • Settings & configuration[ui] keys including right_panel_visible / right_panel_width
  • Chord chains — how Ctrl+Shift+B and the rest of the keymap resolve