muxr 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,89 @@
1
+ module Muxr
2
+ # Translates an array of "keys" entries (a mix of literal text and vim-style
3
+ # named keys like "<esc>") into a stream of byte segments tagged by kind.
4
+ #
5
+ # Each input element is either:
6
+ #
7
+ # - Literal text (UTF-8) — emitted as [:literal, bytes].
8
+ # - A named key wrapped in angle brackets like "<esc>", "<c-c>", "<up>" —
9
+ # emitted as [:special, bytes] using the appropriate control sequence.
10
+ #
11
+ # The tagged form lets callers wrap *only* literal segments in
12
+ # bracketed-paste markers when desired. Concatenating the bytes of all
13
+ # segments gives the raw byte stream to write to the PTY.
14
+ module KeyParser
15
+ NAMED_KEY_RE = /\A<([^<>]+)>\z/.freeze
16
+
17
+ # CSI = ESC [ , SS3 = ESC O — both standard xterm key encodings.
18
+ CSI = "\e[".b.freeze
19
+ SS3 = "\eO".b.freeze
20
+
21
+ # Single canonical map. Keys are lowercased; aliases live alongside their
22
+ # canonical forms. Values are ASCII byte strings.
23
+ NAMED_KEYS = {
24
+ "esc" => "\e".b,
25
+ "escape" => "\e".b,
26
+ "enter" => "\r".b,
27
+ "cr" => "\r".b,
28
+ "return" => "\r".b,
29
+ "tab" => "\t".b,
30
+ "s-tab" => "#{CSI}Z".b,
31
+ "bs" => "\x7f".b, # what real backspace keys send through a PTY
32
+ "backspace"=> "\x7f".b,
33
+ "space" => " ".b,
34
+ "up" => "#{CSI}A".b,
35
+ "down" => "#{CSI}B".b,
36
+ "right" => "#{CSI}C".b,
37
+ "left" => "#{CSI}D".b,
38
+ "home" => "#{CSI}H".b,
39
+ "end" => "#{CSI}F".b,
40
+ "pageup" => "#{CSI}5~".b,
41
+ "pagedown" => "#{CSI}6~".b,
42
+ "f1" => "#{SS3}P".b,
43
+ "f2" => "#{SS3}Q".b,
44
+ "f3" => "#{SS3}R".b,
45
+ "f4" => "#{SS3}S".b,
46
+ "f5" => "#{CSI}15~".b,
47
+ "f6" => "#{CSI}17~".b,
48
+ "f7" => "#{CSI}18~".b,
49
+ "f8" => "#{CSI}19~".b,
50
+ "f9" => "#{CSI}20~".b,
51
+ "f10" => "#{CSI}21~".b,
52
+ "f11" => "#{CSI}23~".b,
53
+ "f12" => "#{CSI}24~".b
54
+ }.freeze
55
+
56
+ CTRL_RE = /\Ac-([a-z])\z/.freeze
57
+
58
+ module_function
59
+
60
+ # Returns an array of [kind, bytes] pairs. Raises Dispatcher::Error on a
61
+ # non-array input or an unrecognized `<name>`.
62
+ def translate(entries)
63
+ raise Dispatcher::Error.new("`keys` must be an array of strings") unless entries.is_a?(Array)
64
+
65
+ entries.map do |entry|
66
+ raise Dispatcher::Error.new("`keys` entries must be strings") unless entry.is_a?(String)
67
+ classify(entry)
68
+ end
69
+ end
70
+
71
+ def classify(entry)
72
+ m = NAMED_KEY_RE.match(entry)
73
+ return [:literal, entry.b] unless m
74
+
75
+ name = m[1].downcase
76
+ bytes = NAMED_KEYS[name] || ctrl_bytes(name)
77
+ raise Dispatcher::Error.new("unknown named key #{entry.inspect}") unless bytes
78
+
79
+ [:special, bytes]
80
+ end
81
+
82
+ def ctrl_bytes(name)
83
+ m = CTRL_RE.match(name)
84
+ return nil unless m
85
+ # <c-a>=0x01 .. <c-z>=0x1A
86
+ (m[1].ord - "a".ord + 1).chr.b
87
+ end
88
+ end
89
+ end
data/lib/muxr/pane.rb CHANGED
@@ -1,19 +1,56 @@
1
+ require "securerandom"
2
+
1
3
  module Muxr
2
4
  # A Pane bundles a Terminal emulator buffer with the PTYProcess running the
3
5
  # shell that feeds it. The Window keeps a list of panes; the Renderer asks
4
6
  # each pane for its current grid contents and cursor position.
7
+ #
8
+ # Each pane has a stable 6-hex id generated at creation. The id survives
9
+ # promote_to_master and other array reshuffles (since it lives on the Pane,
10
+ # not on the index) and is persisted in the session JSON so it also survives
11
+ # a full cold restart. The drawer pane uses the symbol `:drawer` as its id;
12
+ # the MCP control surface treats it specially and never lists it under
13
+ # `panes.list`.
5
14
  class Pane
6
15
  attr_reader :id, :terminal, :process
7
16
  attr_accessor :rect
8
17
 
9
- def initialize(id:, rows: 24, cols: 80, cwd: nil, command: nil, process: nil)
10
- @id = id
18
+ def initialize(id: nil, rows: 24, cols: 80, cwd: nil, command: nil, env_overrides: nil, process: nil)
19
+ @id = id || SecureRandom.hex(3)
11
20
  @rows = rows
12
21
  @cols = cols
13
22
  @terminal = Terminal.new(rows: rows, cols: cols)
14
- @process = process || PTYProcess.new(rows: rows, cols: cols, cwd: cwd, command: command)
23
+ @process = process || PTYProcess.new(
24
+ rows: rows,
25
+ cols: cols,
26
+ cwd: cwd,
27
+ command: command,
28
+ env_overrides: env_overrides || {}
29
+ )
15
30
  @rect = nil
16
31
  @initial_cwd = cwd || @process.cwd
32
+ @private_flag = false
33
+ end
34
+
35
+ # Private panes are invisible to the MCP control surface — their cwd is
36
+ # stripped from panes.list and pane.read/send_input/run/subscribe/kill
37
+ # all refuse. Toggled by the human via Ctrl-a P or `:private`. Never
38
+ # settable from the control surface itself (so a misbehaving MCP client
39
+ # can't unmark a pane it shouldn't see).
40
+ def private?
41
+ @private_flag
42
+ end
43
+
44
+ def mark_private!
45
+ @private_flag = true
46
+ end
47
+
48
+ def mark_public!
49
+ @private_flag = false
50
+ end
51
+
52
+ def toggle_private!
53
+ @private_flag = !@private_flag
17
54
  end
18
55
 
19
56
  def io
@@ -36,11 +73,32 @@ module Muxr
36
73
  @process.write(data)
37
74
  end
38
75
 
76
+ # Drain everything currently in the PTY's kernel read buffer, feeding
77
+ # each chunk to the Terminal. Coalescing reads here means we render once
78
+ # per fully-formed output burst (fzf re-render, vim cursor+status redraw,
79
+ # etc.) instead of once per ~8 KiB chunk — the latter shows intermediate
80
+ # frames and is the main source of in-pane flicker. Bounded by a byte cap
81
+ # so a runaway producer can't starve other panes on a single tick.
82
+ READ_BUDGET = 1 << 20 # 1 MiB
39
83
  def read_from_pty
40
- data = @process.read_nonblock
41
- return nil unless data
42
- @terminal.feed(data)
43
- data
84
+ total = 0
85
+ while total < READ_BUDGET
86
+ chunk = @process.read_nonblock
87
+ break unless chunk
88
+ @terminal.feed(chunk)
89
+ total += chunk.bytesize
90
+ end
91
+ # The emulator may owe the inner program a reply (DSR / CPR — see
92
+ # Terminal#take_pending_replies!). Ship it back through the PTY's
93
+ # input side as if it had been typed. Failure here is non-fatal: the
94
+ # process can have exited between read and write.
95
+ if (reply = @terminal.take_pending_replies!)
96
+ begin
97
+ @process.write(reply)
98
+ rescue Errno::EIO, Errno::EPIPE
99
+ end
100
+ end
101
+ total.positive? ? total : nil
44
102
  end
45
103
 
46
104
  def resize(rows, cols)
data/lib/muxr/renderer.rb CHANGED
@@ -93,6 +93,11 @@ module Muxr
93
93
  focused = (i == win.focused_index) && !(session.focus_drawer && session.drawer&.visible?)
94
94
  title = "##{i + 1}"
95
95
  title += "/#{win.panes.length}" if monocle
96
+ # Stable id sits after the slot number so monocle reads "#1/3 a3f9b2".
97
+ # respond_to? guard keeps renderer tests (which use simple struct fakes)
98
+ # from blowing up when a pane stand-in doesn't implement #id.
99
+ title += " #{pane.id}" if pane.respond_to?(:id) && pane.id.is_a?(String)
100
+ title += " [P]" if pane.respond_to?(:private?) && pane.private?
96
101
  title += " ★" if i == win.master_index
97
102
  title += " (" + win.layout.to_s + ")" if i == win.focused_index
98
103
  if pane.terminal.scrolled_back?
@@ -113,7 +118,11 @@ module Muxr
113
118
 
114
119
  w = session.width
115
120
  h = session.height
116
- dh = (h * 0.35).round.clamp(5, h - 2)
121
+ # Drawer height is the larger of "16 rows" or 35% of the screen — the
122
+ # 35% rule is fine on tall terminals but uselessly small on short ones,
123
+ # so we floor it at 16 to keep the drawer practical. Final clamp keeps
124
+ # room for panes + status bar on very small terminals.
125
+ dh = [16, (h * 0.35).round].max.clamp(5, h - 2)
117
126
  dy = h - 1 - dh
118
127
  rect = LayoutManager::Rect.new(0, dy, w, dh)
119
128
  drawer.pane.rect = rect
@@ -263,7 +272,9 @@ module Muxr
263
272
  " C-a k close focused pane",
264
273
  " C-a Tab cycle layout (tall → grid → monocle)",
265
274
  " C-a Enter promote focused pane to master",
266
- " C-a ~ toggle drawer",
275
+ " C-a ~ toggle drawer (shell)",
276
+ " C-a C toggle Claude Code drawer (MCP-aware)",
277
+ " C-a P toggle private flag on focused pane (hides from MCP)",
267
278
  " C-a [ enter scrollback (j/k d/u f g/G C-b/C-f; v→cursor, q quits)",
268
279
  " cursor: v select, C-v block, y yank, q cancel",
269
280
  " motions: h/j/k/l 0/^/$ w/e/b W/E/B H/M/L g/G",
@@ -274,7 +285,7 @@ module Muxr
274
285
  " C-a ? toggle this help",
275
286
  " C-a C-a send literal C-a",
276
287
  "",
277
- "Commands: layout {tall|grid|monocle}, drawer {toggle|show|hide|reset},",
288
+ "Commands: layout {tall|grid|monocle}, drawer {toggle|show|hide|reset}, claude,",
278
289
  " save, restore, sessions, quit, new, close, next, prev",
279
290
  "",
280
291
  "press any key to dismiss"
@@ -371,7 +382,13 @@ module Muxr
371
382
  end
372
383
 
373
384
  def emit_frame(frame, session, input_state:, command_buffer:)
374
- out = String.new("\e[0m")
385
+ # \e[?2026h enters synchronized-output mode so terminals that support it
386
+ # (Ghostty, kitty, iTerm2 ≥3.5, WezTerm, Alacritty ≥0.13, foot) present
387
+ # the whole frame atomically instead of repainting incrementally as bytes
388
+ # arrive. \e[?25l hides the cursor for the duration of the diff so it
389
+ # doesn't smear across every \e[y;xH position; cursor_position turns it
390
+ # back on at the final spot.
391
+ out = String.new("\e[?2026h\e[?25l\e[0m")
375
392
  same_size = @prev && @prev_w == frame[0].length && @prev_h == frame.length
376
393
  cur_fg = :unset
377
394
  cur_bg = :unset
@@ -400,6 +417,7 @@ module Muxr
400
417
  end
401
418
  out << "\e[0m"
402
419
  out << cursor_position(session, input_state: input_state, command_buffer: command_buffer)
420
+ out << "\e[?2026l"
403
421
  @out.write(out)
404
422
  @out.flush
405
423
  @prev = frame.map { |row| row.map(&:dup) }
data/lib/muxr/session.rb CHANGED
@@ -45,7 +45,7 @@ module Muxr
45
45
  "focused_index" => @window.focused_index,
46
46
  "master_index" => @window.master_index,
47
47
  "focus_drawer" => @focus_drawer,
48
- "panes" => @window.panes.map { |p| { "cwd" => safe_cwd(p) } },
48
+ "panes" => @window.panes.map { |p| { "id" => safe_id(p), "cwd" => safe_cwd(p), "private" => safe_private(p) } },
49
49
  "drawer" => serialize_drawer
50
50
  }
51
51
  end
@@ -76,11 +76,22 @@ module Muxr
76
76
  pane.respond_to?(:cwd) ? pane.cwd : nil
77
77
  end
78
78
 
79
+ def safe_id(pane)
80
+ return nil unless pane.respond_to?(:id)
81
+ id = pane.id
82
+ id.is_a?(String) ? id : nil
83
+ end
84
+
85
+ def safe_private(pane)
86
+ pane.respond_to?(:private?) && pane.private?
87
+ end
88
+
79
89
  def serialize_drawer
80
90
  return nil unless @drawer
81
91
  {
82
92
  "visible" => @drawer.visible?,
83
- "cwd" => @drawer.cwd
93
+ "cwd" => @drawer.cwd,
94
+ "command" => @drawer.respond_to?(:command) ? @drawer.command : nil
84
95
  }
85
96
  end
86
97
  end
data/lib/muxr/terminal.rb CHANGED
@@ -12,6 +12,14 @@ module Muxr
12
12
 
13
13
  SCROLLBACK_MAX = 5000
14
14
 
15
+ # Inner programs (fzf ≥ 0.41, neovim, helix, …) bracket coherent screen
16
+ # updates with `\e[?2026h … \e[?2026l` (DECSET 2026 — "Synchronized
17
+ # Output"). When we see the open, we know more bytes are coming that
18
+ # belong to the same logical frame; rendering before the close shows a
19
+ # half-painted state. SYNC_TIMEOUT is the safety cap so a crashed inner
20
+ # program (which left ?2026h open) cannot wedge the pane indefinitely.
21
+ SYNC_TIMEOUT = 0.2
22
+
15
23
  Cell = Struct.new(:char, :fg, :bg, :attrs) do
16
24
  def reset!
17
25
  self.char = " "
@@ -52,6 +60,40 @@ module Muxr
52
60
  @selection_anchor = nil
53
61
  @selection_cursor = nil
54
62
  @selection_mode = :linear
63
+ @sync_pending = false
64
+ @sync_started_at = nil
65
+ @pending_replies = +"".b
66
+ end
67
+
68
+ # Bytes the emulator owes back to the inner program in response to a
69
+ # query (currently DSR / Device Status Report — `\e[5n` and `\e[6n`).
70
+ # The Pane drains this after each feed and writes it to the PTY's input
71
+ # side. Without it, programs like the AWS CLI fall back with a warning
72
+ # ("your terminal doesn't support cursor position requests (CPR)").
73
+ def take_pending_replies!
74
+ return nil if @pending_replies.empty?
75
+ data = @pending_replies
76
+ @pending_replies = +"".b
77
+ data
78
+ end
79
+
80
+ # True iff the inner program has opened a synchronized-output block
81
+ # (\e[?2026h) and not yet closed it, and the safety timeout has not
82
+ # elapsed. The Application uses this to defer rendering so the diff lands
83
+ # on a fully-formed frame instead of a half-painted one.
84
+ def sync_pending?
85
+ return false unless @sync_pending
86
+ if @sync_started_at && (Process.clock_gettime(Process::CLOCK_MONOTONIC) - @sync_started_at) > SYNC_TIMEOUT
87
+ @sync_pending = false
88
+ @sync_started_at = nil
89
+ return false
90
+ end
91
+ true
92
+ end
93
+
94
+ def sync_deadline
95
+ return nil unless @sync_pending && @sync_started_at
96
+ @sync_started_at + SYNC_TIMEOUT
55
97
  end
56
98
 
57
99
  attr_reader :selection_mode
@@ -60,6 +102,20 @@ module Muxr
60
102
  @buffer[r][c]
61
103
  end
62
104
 
105
+ # Return the currently-visible grid as a text string (rows joined by "\n",
106
+ # trailing whitespace stripped on each row). Used by the control surface
107
+ # to expose pane contents to programmatic clients (the MCP bridge in
108
+ # particular). This walks visible_cell so callers see whatever the user
109
+ # is currently looking at, including scrollback.
110
+ def dump_text
111
+ lines = Array.new(@rows) do |r|
112
+ row = String.new(capacity: @cols)
113
+ @cols.times { |c| row << visible_cell(r, c).char }
114
+ row.rstrip
115
+ end
116
+ lines.join("\n")
117
+ end
118
+
63
119
  # Returns the Cell that should be visible at (r, c) given the current
64
120
  # scrollback view_offset. When view_offset == 0 this is the live grid.
65
121
  # When view_offset > 0, rows in the top of the visible area are sourced
@@ -527,8 +583,20 @@ module Muxr
527
583
  when ">", "<", "=", "!"
528
584
  return
529
585
  when "?"
530
- # DEC private modes — we treat `h`/`l` as no-ops anyway, so dropping
531
- # everything is safe and avoids `\e[?Nr` colliding with DECSTBM.
586
+ # DEC private modes — most we treat as no-ops, but mode 2026
587
+ # (Synchronized Output) is a render-timing hint we honor so the
588
+ # outer paint lands on fully-formed frames from fzf/nvim/helix.
589
+ if final == "h" || final == "l"
590
+ if csi_params.include?(2026)
591
+ if final == "h"
592
+ @sync_pending = true
593
+ @sync_started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
594
+ else
595
+ @sync_pending = false
596
+ @sync_started_at = nil
597
+ end
598
+ end
599
+ end
532
600
  return
533
601
  end
534
602
 
@@ -604,6 +672,16 @@ module Muxr
604
672
  @saved_cursor = [@cursor_row, @cursor_col]
605
673
  when "u"
606
674
  @cursor_row, @cursor_col = @saved_cursor
675
+ when "n"
676
+ # DSR — Device Status Report. `\e[5n` asks if the terminal is OK,
677
+ # `\e[6n` (CPR) asks for the cursor position. The reply rides back
678
+ # through the PTY's input side; see take_pending_replies!.
679
+ case pms[0] || 0
680
+ when 5
681
+ @pending_replies << "\e[0n".b
682
+ when 6
683
+ @pending_replies << "\e[#{@cursor_row + 1};#{@cursor_col + 1}R".b
684
+ end
607
685
  when "h", "l"
608
686
  # Non-private mode set/reset — nothing we need to honor. (DEC private
609
687
  # `?`-prefixed mode sequences are short-circuited above.)
data/lib/muxr/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Muxr
2
- VERSION = "0.1.3"
2
+ VERSION = "0.1.5"
3
3
  end
data/lib/muxr.rb CHANGED
@@ -10,6 +10,7 @@ require_relative "muxr/renderer"
10
10
  require_relative "muxr/input_handler"
11
11
  require_relative "muxr/command_dispatcher"
12
12
  require_relative "muxr/protocol"
13
+ require_relative "muxr/control_server"
13
14
  require_relative "muxr/application"
14
15
  require_relative "muxr/client"
15
16
 
data/muxr.gemspec CHANGED
@@ -26,10 +26,12 @@ Gem::Specification.new do |spec|
26
26
  }
27
27
 
28
28
  spec.bindir = "bin"
29
- spec.executables = ["muxr"]
29
+ spec.executables = ["muxr", "muxr-mcp"]
30
30
  spec.files = Dir[
31
31
  "lib/**/*.rb",
32
32
  "bin/muxr",
33
+ "bin/muxr-mcp",
34
+ "skills/**/*",
33
35
  "README.md",
34
36
  "CHANGELOG.md",
35
37
  "LICENSE.txt",
@@ -0,0 +1,190 @@
1
+ ---
2
+ name: muxr-control
3
+ description: |
4
+ Use when driving a muxr terminal session — running commands across panes,
5
+ watching long-running processes, capturing terminal output, setting up
6
+ layouts, or working with the muxr drawer. Triggers when MUXR_SESSION is
7
+ set in the environment, or when the user asks to "run X in pane Y",
8
+ "what does pane N show", "switch the muxr layout", etc.
9
+ ---
10
+
11
+ # muxr-control
12
+
13
+ You're driving a [muxr](https://github.com/roelbondoc/muxr) terminal
14
+ multiplexer session through its MCP bridge. muxr is a tiling terminal
15
+ multiplexer (think tmux + xmonad). Each pane is a real shell PTY; you can
16
+ read its current screen contents, send keystrokes, and wait for output to
17
+ settle — without taking control of the user's keyboard.
18
+
19
+ ## First thing: ground yourself
20
+
21
+ Before doing anything else, call **`muxr_session_get`** and
22
+ **`muxr_panes_list`**. These are cheap, idempotent reads. They tell you:
23
+
24
+ - The session name, layout (tall / grid / monocle), and current dimensions.
25
+ - Each pane's stable id (6 hex chars, e.g. `a3f9b2`), its 1-based slot
26
+ number as shown on screen (`#1`, `#2`, …), its cwd, and whether it's the
27
+ focused or master pane.
28
+ - The `focused_pane` field in `session.get` tells you which pane the user
29
+ was last looking at — if the user just said "run X" without naming a
30
+ pane, that's the natural target.
31
+
32
+ ## Pane identity: **always use the id, never the slot**
33
+
34
+ The status bar shows panes as `#1 a3f9b2`, `#2 c2e810`, etc. The number is
35
+ a *slot* — purely positional and tied to where the pane sits in the array.
36
+ The hex string is the *id* — generated once at pane creation and stable
37
+ forever.
38
+
39
+ Slots shift when panes are created, killed, or promoted to master. The id
40
+ never moves. **Every tool call that names a pane should pass the id.** If
41
+ the user says "the second pane", look it up in `muxr_panes_list` and pass
42
+ the id you find at slot 2 — don't pass `2` directly even though it works,
43
+ because by the time the call lands the slots may have changed.
44
+
45
+ ## Recipes
46
+
47
+ ### Run a command and get its output
48
+
49
+ ```
50
+ muxr_pane_run({ "pane": "a3f9b2", "input": "ls -la" })
51
+ ```
52
+
53
+ This sends `ls -la\r` to pane `a3f9b2`, waits for the PTY to go idle (no
54
+ output for 500ms by default), and returns the pane's full visible text
55
+ plus a `timed_out` flag.
56
+
57
+ **Always prefer `muxr_pane_run` over `muxr_pane_send_input` + a separate
58
+ `muxr_pane_read`.** The split version races — the read can fire before
59
+ the shell has redrawn the prompt, and you'll miss the output entirely.
60
+
61
+ ### Tune `idle_ms` for the kind of command
62
+
63
+ - **Fast, simple commands** (`pwd`, `git status`): default 500ms is fine.
64
+ - **Bursty output** (test runners, builds): bump to `idle_ms: 800` or
65
+ `1000`. Test runners often pause briefly between phases; a too-short
66
+ idle window cuts off mid-run.
67
+ - **Interactive REPLs** that you want to type into without waiting for
68
+ completion: use `muxr_pane_send_input` directly with `append_enter:
69
+ false` — don't try to detect idleness on a REPL.
70
+ - **Long builds** (npm install, cargo build): bump `timeout_ms` to
71
+ `120000` or higher. Default is 30s.
72
+
73
+ ### Wait without sending anything
74
+
75
+ ```
76
+ muxr_pane_run({ "pane": "a3f9b2", "input": "", "append_enter": false,
77
+ "idle_ms": 1000, "timeout_ms": 30000 })
78
+ ```
79
+
80
+ Useful when the user has already typed a command and you want to capture
81
+ its output once it finishes.
82
+
83
+ ### Send multi-line input (paste mode)
84
+
85
+ ```
86
+ muxr_pane_send_input({
87
+ "pane": "a3f9b2",
88
+ "data": "def hello\n puts :world\nend\n",
89
+ "bracketed": true
90
+ })
91
+ ```
92
+
93
+ `bracketed: true` wraps the data in `\e[200~` / `\e[201~` so editors and
94
+ REPLs treat it as a single paste rather than N separate keystrokes (which
95
+ fires their auto-indent / autocomplete on every line).
96
+
97
+ ### Look at the drawer without opening it
98
+
99
+ ```
100
+ muxr_drawer_read({})
101
+ ```
102
+
103
+ The drawer's shell process keeps running while hidden — its scrollback
104
+ survives. You can read it any time without disturbing the user's view.
105
+
106
+ ### Set up a layout for a task
107
+
108
+ ```
109
+ muxr_layout_set({ "layout": "tall" })
110
+ muxr_pane_new({}) // create a second pane
111
+ muxr_pane_send_input({ "pane": "<new id>", "data": "npm run dev\n" })
112
+ ```
113
+
114
+ Avoid doing this unsolicited — the human owns the layout. Only restructure
115
+ when the user explicitly asks ("set up a dev environment", "split this
116
+ into 3 panes").
117
+
118
+ ## Gotchas
119
+
120
+ ### Reading is cheap. Writing is destructive.
121
+
122
+ `muxr_pane_read`, `muxr_panes_list`, `muxr_drawer_read`, and
123
+ `muxr_session_get` have zero side effects — call them whenever you need
124
+ to ground yourself. **Mutating tools** (`muxr_pane_send_input`,
125
+ `muxr_pane_run`, `muxr_pane_kill`, `muxr_layout_set`, …) affect the
126
+ user's live session. Before calling any of them:
127
+
128
+ - Confirm the user named the specific pane you're about to act on (or
129
+ agreed implicitly by saying "run X here").
130
+ - Double-check the id by reading `muxr_panes_list` if you haven't done so
131
+ recently.
132
+ - **Never `muxr_pane_kill`** without the user explicitly saying "close
133
+ pane X" — a pane often holds in-progress work that's not in any file.
134
+
135
+ ### `pane.read` returns *visible* text only
136
+
137
+ The result is the pane's current 80×24-or-whatever grid, with trailing
138
+ whitespace trimmed per row. Lines that have scrolled into scrollback are
139
+ not in the response. If you need older output, ask the user to scroll
140
+ the pane up first (they have `Ctrl-a [` for scrollback mode), or watch
141
+ the pane via `muxr_pane_run` while the command is running.
142
+
143
+ ### Private panes
144
+
145
+ The user can mark any pane *private* with `Ctrl-a P` (status bar shows
146
+ `[P]` after the pane id). Private panes appear in `muxr_panes_list` with
147
+ `"private": true` and *no* `cwd`/`rows`/`cols` — `muxr_pane_read`,
148
+ `muxr_pane_send_input`, `muxr_pane_run`, `muxr_pane_subscribe`, and
149
+ `muxr_pane_kill` all refuse with an error message that tells you the
150
+ human-side gesture to undo it.
151
+
152
+ When this happens: **do not retry**. Surface it to the user verbatim
153
+ ("pane #2 a3f9b2 is private; press Ctrl-a P on it to expose it to me").
154
+ The privacy flag is intentionally one-way from MCP's perspective: there
155
+ is no `muxr_pane_unmark_private` tool.
156
+
157
+ `muxr_pane_focus` and `muxr_pane_promote` still work on private panes
158
+ (they're layout ops, not content ops) — useful if the user asks to
159
+ "bring my private pane to the front" without exposing it.
160
+
161
+ ### The drawer might be Claude itself
162
+
163
+ If the bridge sees the env var `MUXR_DRAWER_SELF=1` it refuses
164
+ `muxr_drawer_*` methods — that means the bridge is running *inside* the
165
+ muxr drawer and the call would recurse into your own pty. If you get
166
+ that error, that's why: you can still drive the surrounding tiled panes
167
+ normally, you just can't toggle/read the drawer that's hosting you.
168
+
169
+ ### Don't toggle the drawer just to peek
170
+
171
+ `muxr_drawer_read` works without showing the drawer. Toggling it to
172
+ look, then toggling back, is visible to the user as a flash of overlay
173
+ and is almost never what they wanted.
174
+
175
+ ### Tool errors
176
+
177
+ If a tool call returns `isError: true`, the text usually starts with
178
+ `muxr error <code>: <message>`. Common ones:
179
+
180
+ - `muxr error -32602: pane: no pane with id "…"` — the pane has been
181
+ killed, or you passed a stale id from before a kill/promote. Refetch
182
+ `muxr_panes_list`.
183
+ - `muxr error -32602: layout: unknown layout` — valid layouts are
184
+ `tall`, `grid`, `monocle`.
185
+
186
+ ## Naming muxr in conversation
187
+
188
+ When responding to the user, call panes by **slot first, id second**:
189
+ "pane #2 (a3f9b2) is showing the test failures." That matches what's on
190
+ their status bar and makes the id available for follow-up references.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: muxr
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.1.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roel Bondoc
@@ -46,6 +46,7 @@ email:
46
46
  - rsbondoc@gmail.com
47
47
  executables:
48
48
  - muxr
49
+ - muxr-mcp
49
50
  extensions: []
50
51
  extra_rdoc_files: []
51
52
  files:
@@ -53,12 +54,15 @@ files:
53
54
  - LICENSE.txt
54
55
  - README.md
55
56
  - bin/muxr
57
+ - bin/muxr-mcp
56
58
  - lib/muxr.rb
57
59
  - lib/muxr/application.rb
58
60
  - lib/muxr/client.rb
59
61
  - lib/muxr/command_dispatcher.rb
62
+ - lib/muxr/control_server.rb
60
63
  - lib/muxr/drawer.rb
61
64
  - lib/muxr/input_handler.rb
65
+ - lib/muxr/key_parser.rb
62
66
  - lib/muxr/layout_manager.rb
63
67
  - lib/muxr/pane.rb
64
68
  - lib/muxr/protocol.rb
@@ -69,6 +73,7 @@ files:
69
73
  - lib/muxr/version.rb
70
74
  - lib/muxr/window.rb
71
75
  - muxr.gemspec
76
+ - skills/muxr-control/SKILL.md
72
77
  homepage: https://github.com/roelbondoc/muxr
73
78
  licenses:
74
79
  - MIT