muxr 0.1.10 → 0.2.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +275 -1
- data/README.md +763 -387
- data/bin/muxr +68 -33
- data/bin/muxr-mcp +172 -16
- data/lib/muxr/application.rb +639 -35
- data/lib/muxr/client.rb +26 -4
- data/lib/muxr/command_dispatcher.rb +69 -0
- data/lib/muxr/config.rb +156 -0
- data/lib/muxr/control_server.rb +301 -3
- data/lib/muxr/history_row.rb +264 -0
- data/lib/muxr/image_store.rb +61 -0
- data/lib/muxr/input_handler.rb +169 -17
- data/lib/muxr/layout_manager.rb +83 -57
- data/lib/muxr/mouse_report.rb +24 -0
- data/lib/muxr/pane.rb +128 -2
- data/lib/muxr/pane_picker.rb +49 -0
- data/lib/muxr/pane_transfer.rb +172 -0
- data/lib/muxr/protocol.rb +51 -5
- data/lib/muxr/pty_process.rb +54 -7
- data/lib/muxr/remote_pane.rb +263 -0
- data/lib/muxr/renderer.rb +297 -74
- data/lib/muxr/session.rb +19 -2
- data/lib/muxr/session_directory.rb +102 -0
- data/lib/muxr/terminal.rb +903 -87
- data/lib/muxr/version.rb +1 -1
- data/lib/muxr/width_probe.rb +121 -0
- data/lib/muxr/window.rb +50 -3
- data/lib/muxr.rb +8 -0
- data/muxr.gemspec +10 -0
- data/skills/muxr-control/SKILL.md +158 -8
- metadata +18 -1
data/lib/muxr/version.rb
CHANGED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
module Muxr
|
|
2
|
+
# Measures how the *outer* terminal actually draws glyphs whose width muxr
|
|
3
|
+
# would otherwise have to guess, so the emulator and Renderer can be tuned to
|
|
4
|
+
# match it instead of disagreeing (the disagreement is what corrupts in-place
|
|
5
|
+
# animations — see Terminal::AMBIGUOUS_RANGES and Renderer#contiguous_after?).
|
|
6
|
+
#
|
|
7
|
+
# The technique is the classic cursor-position probe: print a test glyph at a
|
|
8
|
+
# known column, ask the terminal where its cursor landed with DSR-CPR
|
|
9
|
+
# (`\e[6n` → `\e[<row>;<col>R`), and the column delta is the glyph's real
|
|
10
|
+
# display width. This only works against a live TTY in raw mode, which is why
|
|
11
|
+
# it runs in the client (the piece that owns the terminal) and ships its
|
|
12
|
+
# verdict to the server in the HELLO handshake.
|
|
13
|
+
module WidthProbe
|
|
14
|
+
# Formal East Asian Ambiguous glyphs (all > U+0300 so the class toggle can
|
|
15
|
+
# actually reach them). A terminal in "ambiguous = wide" mode draws all of
|
|
16
|
+
# these two columns wide; a narrow terminal draws them one. We sample several
|
|
17
|
+
# and take a majority vote — the verdict configures Terminal.ambiguous_wide,
|
|
18
|
+
# which covers the long tail of ambiguous glyphs we don't sample by hand.
|
|
19
|
+
AMBIGUOUS_SAMPLES = ["…", "●", "→", "★", "◆"].freeze
|
|
20
|
+
|
|
21
|
+
# Specific glyphs whose width no Unicode class reliably predicts because a
|
|
22
|
+
# font may give them emoji presentation (drawn two wide) regardless of the
|
|
23
|
+
# terminal's ambiguous setting. These are exactly the glyphs Claude Code's
|
|
24
|
+
# UI animates in place. We measure each one individually and record its
|
|
25
|
+
# exact width as a per-codepoint override — ground truth beats any heuristic.
|
|
26
|
+
GLYPH_SAMPLES = ["⏺", "✻", "❯", "✦", "✳", "◼", "▪"].freeze
|
|
27
|
+
|
|
28
|
+
# Box-drawing and block elements (Terminal::BOX_RANGES). Formally Ambiguous,
|
|
29
|
+
# but many terminals keep them narrow even in ambiguous-as-wide mode, so the
|
|
30
|
+
# AMBIGUOUS_SAMPLES verdict can't speak for them. They need their own vote
|
|
31
|
+
# because Renderer#contiguous_after? relies on this band being narrow to skip
|
|
32
|
+
# cursor repositioning — if the terminal actually draws them wide, every
|
|
33
|
+
# glyph after one on the same line lands a column off, which is what mangles
|
|
34
|
+
# a border-heavy TUI like Claude Code.
|
|
35
|
+
BOX_SAMPLES = ["─", "│", "█", "▌"].freeze
|
|
36
|
+
|
|
37
|
+
# Overall wall-clock budget for the whole probe. A terminal that never
|
|
38
|
+
# answers DSR (rare, but possible over flaky ttys / odd emulators) must not
|
|
39
|
+
# wedge attach — we give up and fall back to defaults.
|
|
40
|
+
TIMEOUT = 0.3
|
|
41
|
+
|
|
42
|
+
# Probe the terminal reachable via +out+ (writable) and +input+ (readable),
|
|
43
|
+
# which must already be in raw mode. Returns a capabilities hash suitable
|
|
44
|
+
# for Protocol.encode_caps, e.g. {ambiguous: 2, glyphs: {0x23FA => 2}}.
|
|
45
|
+
# Returns {} when the terminal doesn't answer (callers treat that as "use
|
|
46
|
+
# defaults").
|
|
47
|
+
def self.run(out: $stdout, input: $stdin, timeout: TIMEOUT)
|
|
48
|
+
deadline = now + timeout
|
|
49
|
+
caps = {}
|
|
50
|
+
|
|
51
|
+
amb = AMBIGUOUS_SAMPLES.filter_map { |g| measure(g, out, input, deadline) }
|
|
52
|
+
# No answers at all → terminal doesn't speak CPR; don't claim to know.
|
|
53
|
+
return caps if amb.empty?
|
|
54
|
+
wide = amb.count { |w| w >= 2 }
|
|
55
|
+
caps[:ambiguous] = wide > (amb.length - wide) ? 2 : 1
|
|
56
|
+
|
|
57
|
+
box = BOX_SAMPLES.filter_map { |g| measure(g, out, input, deadline) }
|
|
58
|
+
unless box.empty?
|
|
59
|
+
box_wide = box.count { |w| w >= 2 }
|
|
60
|
+
caps[:box] = box_wide > (box.length - box_wide) ? 2 : 1
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
glyphs = {}
|
|
64
|
+
GLYPH_SAMPLES.each do |g|
|
|
65
|
+
w = measure(g, out, input, deadline)
|
|
66
|
+
# Clamp to the 1/2 the grid understands; ignore non-answers and any
|
|
67
|
+
# zero-advance oddity (an unrenderable glyph the terminal swallowed).
|
|
68
|
+
glyphs[g.ord] = (w >= 2 ? 2 : 1) if w && w >= 1
|
|
69
|
+
end
|
|
70
|
+
caps[:glyphs] = glyphs unless glyphs.empty?
|
|
71
|
+
caps
|
|
72
|
+
ensure
|
|
73
|
+
# Wipe whatever the probe painted; the server's first frame repaints all.
|
|
74
|
+
out.write("\e[H\e[2J")
|
|
75
|
+
out.flush
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Print +glyph+ at column 1 of the home row, then read the cursor column.
|
|
79
|
+
# Returns the glyph's display width (col - 1) or nil if no CPR came back in
|
|
80
|
+
# time. Latin-1 / ASCII control bytes in the glyph would skew the result,
|
|
81
|
+
# so callers pass only printable test glyphs.
|
|
82
|
+
def self.measure(glyph, out, input, deadline)
|
|
83
|
+
out.write("\e[H#{glyph}\e[6n")
|
|
84
|
+
out.flush
|
|
85
|
+
col = read_cpr_col(input, deadline)
|
|
86
|
+
return nil unless col
|
|
87
|
+
col - 1
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Read a DSR-CPR reply (`\e[<row>;<col>R`, optionally `\e[?<row>;<col>R`)
|
|
91
|
+
# from +input+, honoring the shared deadline. Bytes that aren't part of the
|
|
92
|
+
# reply (none are expected this early in attach) are discarded. Returns the
|
|
93
|
+
# column, or nil on timeout / closed input.
|
|
94
|
+
def self.read_cpr_col(input, deadline)
|
|
95
|
+
buf = +"".b
|
|
96
|
+
loop do
|
|
97
|
+
remaining = deadline - now
|
|
98
|
+
return nil if remaining <= 0
|
|
99
|
+
ready, = IO.select([input], nil, nil, remaining)
|
|
100
|
+
return nil unless ready
|
|
101
|
+
begin
|
|
102
|
+
chunk = input.read_nonblock(64)
|
|
103
|
+
rescue IO::WaitReadable
|
|
104
|
+
next
|
|
105
|
+
rescue EOFError, IOError, Errno::EIO
|
|
106
|
+
return nil
|
|
107
|
+
end
|
|
108
|
+
buf << chunk
|
|
109
|
+
if (m = buf.match(/\e\[\??\d+;(\d+)R/))
|
|
110
|
+
return m[1].to_i
|
|
111
|
+
end
|
|
112
|
+
# Guard against a stream that never contains a terminator.
|
|
113
|
+
return nil if buf.bytesize > 256
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def self.now
|
|
118
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
data/lib/muxr/window.rb
CHANGED
|
@@ -5,8 +5,10 @@ module Muxr
|
|
|
5
5
|
class Window
|
|
6
6
|
LAYOUTS = LayoutManager::LAYOUTS
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
RATIO_STEP = 0.05
|
|
9
|
+
|
|
10
|
+
attr_accessor :name, :layout, :master_index, :synchronized
|
|
11
|
+
attr_reader :panes, :focused_index, :master_ratio, :master_count
|
|
10
12
|
|
|
11
13
|
def initialize(name: "main")
|
|
12
14
|
@name = name
|
|
@@ -14,7 +16,30 @@ module Muxr
|
|
|
14
16
|
@focused_index = 0
|
|
15
17
|
@last_focused_pane = nil
|
|
16
18
|
@master_index = 0
|
|
17
|
-
@layout = :
|
|
19
|
+
@layout = :auto
|
|
20
|
+
@master_ratio = LayoutManager::DEFAULT_RATIO
|
|
21
|
+
@master_count = 1
|
|
22
|
+
@synchronized = false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def master_ratio=(value)
|
|
26
|
+
@master_ratio = value.to_f.clamp(LayoutManager::RATIO_BOUNDS).round(2)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def master_count=(value)
|
|
30
|
+
@master_count = [value.to_i, 1].max
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def adjust_master_ratio(delta)
|
|
34
|
+
self.master_ratio = @master_ratio + delta
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def adjust_master_count(delta)
|
|
38
|
+
self.master_count = (@master_count + delta).clamp(1, [@panes.length, 1].max)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def layout_options
|
|
42
|
+
{ master_index: @master_index, ratio: @master_ratio, nmaster: @master_count }
|
|
18
43
|
end
|
|
19
44
|
|
|
20
45
|
# Setter records the outgoing focused pane (by reference) so focus_last can
|
|
@@ -100,6 +125,7 @@ module Muxr
|
|
|
100
125
|
end
|
|
101
126
|
|
|
102
127
|
def cycle_layout
|
|
128
|
+
@zoom_return = nil
|
|
103
129
|
i = LAYOUTS.index(@layout) || 0
|
|
104
130
|
@layout = LAYOUTS[(i + 1) % LAYOUTS.length]
|
|
105
131
|
end
|
|
@@ -107,9 +133,30 @@ module Muxr
|
|
|
107
133
|
def set_layout(layout)
|
|
108
134
|
layout = layout.to_sym
|
|
109
135
|
raise ArgumentError, "Unknown layout: #{layout}" unless LAYOUTS.include?(layout)
|
|
136
|
+
@zoom_return = nil
|
|
110
137
|
@layout = layout
|
|
111
138
|
end
|
|
112
139
|
|
|
140
|
+
def zoomed?
|
|
141
|
+
!@zoom_return.nil?
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
attr_reader :zoom_return
|
|
145
|
+
|
|
146
|
+
def toggle_zoom
|
|
147
|
+
if zoomed?
|
|
148
|
+
@layout = @zoom_return
|
|
149
|
+
@zoom_return = nil
|
|
150
|
+
elsif @layout != :monocle
|
|
151
|
+
@zoom_return = @layout
|
|
152
|
+
@layout = :monocle
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def resting_layout
|
|
157
|
+
@zoom_return || @layout
|
|
158
|
+
end
|
|
159
|
+
|
|
113
160
|
def clamp_indices!
|
|
114
161
|
if @panes.empty?
|
|
115
162
|
@focused_index = 0
|
data/lib/muxr.rb
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
require_relative "muxr/version"
|
|
2
2
|
require_relative "muxr/pty_process"
|
|
3
|
+
require_relative "muxr/image_store"
|
|
4
|
+
require_relative "muxr/mouse_report"
|
|
3
5
|
require_relative "muxr/terminal"
|
|
6
|
+
require_relative "muxr/remote_pane"
|
|
4
7
|
require_relative "muxr/pane"
|
|
8
|
+
require_relative "muxr/pane_transfer"
|
|
5
9
|
require_relative "muxr/drawer"
|
|
6
10
|
require_relative "muxr/foreground_command"
|
|
7
11
|
require_relative "muxr/layout_manager"
|
|
8
12
|
require_relative "muxr/window"
|
|
9
13
|
require_relative "muxr/session"
|
|
14
|
+
require_relative "muxr/session_directory"
|
|
15
|
+
require_relative "muxr/pane_picker"
|
|
10
16
|
require_relative "muxr/renderer"
|
|
11
17
|
require_relative "muxr/input_handler"
|
|
12
18
|
require_relative "muxr/command_dispatcher"
|
|
19
|
+
require_relative "muxr/config"
|
|
13
20
|
require_relative "muxr/protocol"
|
|
21
|
+
require_relative "muxr/width_probe"
|
|
14
22
|
require_relative "muxr/control_server"
|
|
15
23
|
require_relative "muxr/application"
|
|
16
24
|
require_relative "muxr/client"
|
data/muxr.gemspec
CHANGED
|
@@ -18,6 +18,7 @@ Gem::Specification.new do |spec|
|
|
|
18
18
|
spec.required_ruby_version = ">= 3.4"
|
|
19
19
|
|
|
20
20
|
spec.metadata = {
|
|
21
|
+
"homepage_uri" => "https://roelbondoc.github.io/muxr/",
|
|
21
22
|
"source_code_uri" => "https://github.com/roelbondoc/muxr",
|
|
22
23
|
"bug_tracker_uri" => "https://github.com/roelbondoc/muxr/issues",
|
|
23
24
|
"changelog_uri" => "https://github.com/roelbondoc/muxr/blob/main/CHANGELOG.md",
|
|
@@ -39,6 +40,15 @@ Gem::Specification.new do |spec|
|
|
|
39
40
|
]
|
|
40
41
|
spec.require_paths = ["lib"]
|
|
41
42
|
|
|
43
|
+
spec.post_install_message = <<~MSG
|
|
44
|
+
muxr ships a Claude Code skill for driving sessions over MCP. To install it
|
|
45
|
+
into ~/.claude/skills and print the `claude mcp add` line for the bridge:
|
|
46
|
+
|
|
47
|
+
muxr --install-skill
|
|
48
|
+
|
|
49
|
+
Re-run it after each `gem update muxr` to refresh the skill contents.
|
|
50
|
+
MSG
|
|
51
|
+
|
|
42
52
|
spec.add_development_dependency "minitest", "~> 5.0"
|
|
43
53
|
spec.add_development_dependency "rake", "~> 13.0"
|
|
44
54
|
end
|
|
@@ -3,9 +3,11 @@ name: muxr-control
|
|
|
3
3
|
description: |
|
|
4
4
|
Use when driving a muxr terminal session — running commands across panes,
|
|
5
5
|
watching long-running processes, capturing terminal output, setting up
|
|
6
|
-
layouts,
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
layouts, working with the muxr drawer, or driving a full-screen TUI app
|
|
7
|
+
(vim, a notebook, lazygit) that is already running inside a pane.
|
|
8
|
+
Triggers when MUXR_SESSION is set in the environment, or when the user
|
|
9
|
+
asks to "run X in pane Y", "what does pane N show", "drive the editor in
|
|
10
|
+
pane 1", "switch the muxr layout", etc.
|
|
9
11
|
---
|
|
10
12
|
|
|
11
13
|
# muxr-control
|
|
@@ -21,7 +23,8 @@ settle — without taking control of the user's keyboard.
|
|
|
21
23
|
Before doing anything else, call **`muxr_session_get`** and
|
|
22
24
|
**`muxr_panes_list`**. These are cheap, idempotent reads. They tell you:
|
|
23
25
|
|
|
24
|
-
- The session name, layout
|
|
26
|
+
- The session name, current layout, the full `available_layouts` list, and
|
|
27
|
+
the session dimensions.
|
|
25
28
|
- Each pane's stable id (6 hex chars, e.g. `a3f9b2`), its 1-based slot
|
|
26
29
|
number as shown on screen (`#1`, `#2`, …), its cwd, and whether it's the
|
|
27
30
|
focused or master pane.
|
|
@@ -42,6 +45,13 @@ the user says "the second pane", look it up in `muxr_panes_list` and pass
|
|
|
42
45
|
the id you find at slot 2 — don't pass `2` directly even though it works,
|
|
43
46
|
because by the time the call lands the slots may have changed.
|
|
44
47
|
|
|
48
|
+
A pane the human has named with `:rename` shows the name in place of the id
|
|
49
|
+
(`#1 api`), and `muxr_panes_list` reports it as `name`. When the user refers
|
|
50
|
+
to a pane by that name ("run the tests in `api`"), you may pass the name as
|
|
51
|
+
`pane`. A name that is ambiguous, or that has since been changed, is refused
|
|
52
|
+
rather than guessed, so the id is still the safer choice for any call you
|
|
53
|
+
make after a list.
|
|
54
|
+
|
|
45
55
|
## Recipes
|
|
46
56
|
|
|
47
57
|
### Run a command and get its output
|
|
@@ -70,7 +80,7 @@ the shell has redrawn the prompt, and you'll miss the output entirely.
|
|
|
70
80
|
- **Long builds** (npm install, cargo build): bump `timeout_ms` to
|
|
71
81
|
`120000` or higher. Default is 30s.
|
|
72
82
|
|
|
73
|
-
### Wait
|
|
83
|
+
### Wait for something already running
|
|
74
84
|
|
|
75
85
|
```
|
|
76
86
|
muxr_pane_run({ "pane": "a3f9b2", "input": "", "append_enter": false,
|
|
@@ -78,7 +88,19 @@ muxr_pane_run({ "pane": "a3f9b2", "input": "", "append_enter": false,
|
|
|
78
88
|
```
|
|
79
89
|
|
|
80
90
|
Useful when the user has already typed a command and you want to capture
|
|
81
|
-
its output once it finishes
|
|
91
|
+
its output once it finishes — but **only if output is still coming.**
|
|
92
|
+
|
|
93
|
+
The idle timer is gated on having seen output at all: `pane.run` resolves
|
|
94
|
+
early only once the pane has emitted *something* and then gone quiet. On a
|
|
95
|
+
pane that stays silent for the whole wait, the only exit is the deadline,
|
|
96
|
+
so the call blocks for the full `timeout_ms` and returns
|
|
97
|
+
`timed_out: true` — which looks like a failure but just means "nothing
|
|
98
|
+
happened." A 30s timeout on an already-finished command costs you 30s.
|
|
99
|
+
|
|
100
|
+
So for a pane that may already be quiet, **poll with `muxr_pane_read`
|
|
101
|
+
instead** — it returns instantly, has no side effects, and re-reading a
|
|
102
|
+
few times is cheaper than one mis-sized wait. Reserve `pane.run` for input
|
|
103
|
+
you expect to produce output.
|
|
82
104
|
|
|
83
105
|
### Send multi-line input (paste mode)
|
|
84
106
|
|
|
@@ -115,6 +137,79 @@ Avoid doing this unsolicited — the human owns the layout. Only restructure
|
|
|
115
137
|
when the user explicitly asks ("set up a dev environment", "split this
|
|
116
138
|
into 3 panes").
|
|
117
139
|
|
|
140
|
+
## Driving a full-screen TUI app
|
|
141
|
+
|
|
142
|
+
A pane may hold a full-screen application (vim, euporie, lazygit, htop, a
|
|
143
|
+
TUI notebook) rather than a shell prompt. Everything below is about those;
|
|
144
|
+
shells are more forgiving.
|
|
145
|
+
|
|
146
|
+
### One key per call. Never batch repeats.
|
|
147
|
+
|
|
148
|
+
Both `pane.send_input` and `pane.run` concatenate the entire `keys` array
|
|
149
|
+
into one payload and hand it to the PTY in a **single write**, with no
|
|
150
|
+
pacing between keys. A shell's line editor handles that fine. An app that
|
|
151
|
+
kicks off async work per keypress often does not: sending
|
|
152
|
+
`["<c-r>", "<c-r>", … ]` nine times to a TUI notebook produced **three**
|
|
153
|
+
actions, not nine — the rest were swallowed while the app was mid-render.
|
|
154
|
+
Sending the same key one call at a time worked every time.
|
|
155
|
+
|
|
156
|
+
Batching is safe for a *heterogeneous* scripted sequence where each key
|
|
157
|
+
does something different and cheap:
|
|
158
|
+
|
|
159
|
+
```
|
|
160
|
+
muxr_pane_send_input({ "pane": "a3f9b2",
|
|
161
|
+
"keys": ["G", "o", "hello world", "<esc>", ":w", "<enter>"] })
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Batching is **not** safe for "do this N times." Loop the call instead, and
|
|
165
|
+
confirm the app actually advanced between iterations (see below).
|
|
166
|
+
|
|
167
|
+
### The status bar is ground truth, not the layout
|
|
168
|
+
|
|
169
|
+
Infer app state from whatever the app *prints* about itself — the status
|
|
170
|
+
line, a mode indicator, an execution counter — never from where borders or
|
|
171
|
+
highlights appear to be drawn. Box-drawing and reverse-video regions are
|
|
172
|
+
easy to misread, and the cell/buffer/pane you think is selected is
|
|
173
|
+
routinely not the one you think. If the app tells you `Cell 9`, believe
|
|
174
|
+
that over a box that looks like it surrounds cell 8.
|
|
175
|
+
|
|
176
|
+
This is also how you verify the previous point: read the indicator, send
|
|
177
|
+
one key, re-read, confirm it moved.
|
|
178
|
+
|
|
179
|
+
### Use the `cursor` field to find focus in a dialog
|
|
180
|
+
|
|
181
|
+
`pane.read` and `pane.run` both return `cursor: {row, col}`. In a modal
|
|
182
|
+
dialog from prompt_toolkit, ncurses, and friends, the cursor parks on the
|
|
183
|
+
**focused widget** — so it is the reliable way to tell which of
|
|
184
|
+
`[ Yes ] [ No ] [ Cancel ]` is armed, when the rendered text gives you
|
|
185
|
+
nothing to go on.
|
|
186
|
+
|
|
187
|
+
The safe gesture for any dialog you did not expect:
|
|
188
|
+
|
|
189
|
+
1. `pane_read` — note `cursor`.
|
|
190
|
+
2. Send one `<tab>` or `<right>`.
|
|
191
|
+
3. Re-read and confirm the cursor **moved**.
|
|
192
|
+
4. Only then press `<enter>`.
|
|
193
|
+
|
|
194
|
+
Use cursor **deltas**, never absolute column arithmetic. `text` is trimmed
|
|
195
|
+
of trailing whitespace per row, and double-width glyphs (box-drawing,
|
|
196
|
+
block elements, emoji — heavily used by TUI dialogs) desync any attempt to
|
|
197
|
+
map a `col` onto an index into the row string. Two adjacent buttons in one
|
|
198
|
+
real dialog reported cols 63 and 75.
|
|
199
|
+
|
|
200
|
+
### Scroll with the app's own keys, not muxr scrollback
|
|
201
|
+
|
|
202
|
+
`pane.read` is viewport-only, and a full-screen app *owns* its viewport: it
|
|
203
|
+
paints one screenful and keeps the rest in its own internal buffer. Content
|
|
204
|
+
below the fold is invisible to `pane.read` **and** absent from muxr's
|
|
205
|
+
scrollback, because it was never emitted as scrolled-off terminal output.
|
|
206
|
+
`Ctrl-a [` will not find it.
|
|
207
|
+
|
|
208
|
+
So to see the rest, drive the app's own scroll binding (euporie `]`/`}`,
|
|
209
|
+
vim `Ctrl-d`, less `space`) and re-read. Corollary: a command's output can
|
|
210
|
+
be sitting in the pane, already complete, and still absent from your last
|
|
211
|
+
read — check the app's own indicator before concluding a step didn't run.
|
|
212
|
+
|
|
118
213
|
## Gotchas
|
|
119
214
|
|
|
120
215
|
### Reading is cheap. Writing is destructive.
|
|
@@ -140,6 +235,9 @@ not in the response. If you need older output, ask the user to scroll
|
|
|
140
235
|
the pane up first (they have `Ctrl-a [` for scrollback mode), or watch
|
|
141
236
|
the pane via `muxr_pane_run` while the command is running.
|
|
142
237
|
|
|
238
|
+
If the pane holds a full-screen app, `Ctrl-a [` won't help either — see
|
|
239
|
+
"Scroll with the app's own keys" above.
|
|
240
|
+
|
|
143
241
|
### Private panes
|
|
144
242
|
|
|
145
243
|
The user can mark any pane *private* with `Ctrl-a P` (status bar shows
|
|
@@ -158,6 +256,41 @@ is no `muxr_pane_unmark_private` tool.
|
|
|
158
256
|
(they're layout ops, not content ops) — useful if the user asks to
|
|
159
257
|
"bring my private pane to the front" without exposing it.
|
|
160
258
|
|
|
259
|
+
### Borrowed panes belong to another session
|
|
260
|
+
|
|
261
|
+
A pane the user attached from another muxr session (`A` / `C-a A`) shows
|
|
262
|
+
up in `muxr_panes_list` with an `"origin"` of `"<session>:<pane id>"`,
|
|
263
|
+
and in the pane title as `@work:6021b5`. It behaves like any other pane
|
|
264
|
+
for reads and input — those reach the real shell, in the session that
|
|
265
|
+
owns it — with two differences worth knowing:
|
|
266
|
+
|
|
267
|
+
- `muxr_pane_kill` only detaches the mirror. The shell keeps running in
|
|
268
|
+
its home session. If the user asks you to kill it, say that's what
|
|
269
|
+
happened rather than reporting the process gone.
|
|
270
|
+
- It can vanish without dying: if the owning session goes away, or the
|
|
271
|
+
pane is moved somewhere else, it disappears from `muxr_panes_list`.
|
|
272
|
+
Re-read the list rather than assuming a stale id.
|
|
273
|
+
- Its size is negotiated with the owner, so `muxr_layout_set` may not
|
|
274
|
+
give it the dimensions you'd expect from the layout alone.
|
|
275
|
+
|
|
276
|
+
Anything you type there is visible to whoever is looking at the owning
|
|
277
|
+
session, live. Treat it the way you'd treat a shared screen.
|
|
278
|
+
|
|
279
|
+
A pane the user *moved* here (rather than shared) has no `origin` — it is
|
|
280
|
+
an ordinary local pane, even though the shell inside it has been running
|
|
281
|
+
since before it arrived. Don't assume a pane's history started in this
|
|
282
|
+
session.
|
|
283
|
+
|
|
284
|
+
### You are running in one of these panes
|
|
285
|
+
|
|
286
|
+
`MUXR_PANE` in your environment is the id of the pane hosting you. The
|
|
287
|
+
bridge refuses `muxr_pane_read`, `muxr_pane_send_input`, `muxr_pane_run`
|
|
288
|
+
and `muxr_pane_kill` on that id — driving your own pty feeds your output
|
|
289
|
+
back to you, and reading it just returns your own UI. Check `MUXR_PANE`
|
|
290
|
+
before picking a target, and don't try to route around the refusal by
|
|
291
|
+
re-running the command in a pane you then read; ask the user to open
|
|
292
|
+
another pane if you need somewhere to work.
|
|
293
|
+
|
|
161
294
|
### The drawer might be Claude itself
|
|
162
295
|
|
|
163
296
|
If the bridge sees the env var `MUXR_DRAWER_SELF=1` it refuses
|
|
@@ -166,6 +299,21 @@ muxr drawer and the call would recurse into your own pty. If you get
|
|
|
166
299
|
that error, that's why: you can still drive the surrounding tiled panes
|
|
167
300
|
normally, you just can't toggle/read the drawer that's hosting you.
|
|
168
301
|
|
|
302
|
+
### The session you reach may not be the one in your env
|
|
303
|
+
|
|
304
|
+
A pane can be moved between sessions while its shell keeps running, so
|
|
305
|
+
`MUXR_SESSION` can name a session you have since left. The bridge follows
|
|
306
|
+
`MUXR_PANE` to whichever server actually owns you and re-checks every ten
|
|
307
|
+
seconds — so trust `muxr_session_get` over the environment if the two
|
|
308
|
+
disagree, and re-list panes rather than reusing ids you cached from a
|
|
309
|
+
session you are no longer in.
|
|
310
|
+
|
|
311
|
+
### No tools at all means no session
|
|
312
|
+
|
|
313
|
+
If `muxr_*` tools aren't listed, this claude isn't inside muxr (or its
|
|
314
|
+
server isn't running). The bridge advertises nothing rather than failing
|
|
315
|
+
to start. Nothing to fix — just don't claim you can drive panes.
|
|
316
|
+
|
|
169
317
|
### Don't toggle the drawer just to peek
|
|
170
318
|
|
|
171
319
|
`muxr_drawer_read` works without showing the drawer. Toggling it to
|
|
@@ -180,8 +328,10 @@ If a tool call returns `isError: true`, the text usually starts with
|
|
|
180
328
|
- `muxr error -32602: pane: no pane with id "…"` — the pane has been
|
|
181
329
|
killed, or you passed a stale id from before a kill/promote. Refetch
|
|
182
330
|
`muxr_panes_list`.
|
|
183
|
-
- `muxr error -32602: layout: unknown layout` — valid layouts are
|
|
184
|
-
`tall`, `grid`, `
|
|
331
|
+
- `muxr error -32602: layout: unknown layout` — the ten valid layouts are
|
|
332
|
+
`tall`, `wide`, `columns`, `rows`, `grid`, `spiral`, `centered`, `stack`,
|
|
333
|
+
`monocle`, `auto`. `muxr_session_get` returns the live list in
|
|
334
|
+
`available_layouts`; trust that over any list written down here.
|
|
185
335
|
|
|
186
336
|
## Naming muxr in conversation
|
|
187
337
|
|
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.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Roel Bondoc
|
|
@@ -59,19 +59,28 @@ files:
|
|
|
59
59
|
- lib/muxr/application.rb
|
|
60
60
|
- lib/muxr/client.rb
|
|
61
61
|
- lib/muxr/command_dispatcher.rb
|
|
62
|
+
- lib/muxr/config.rb
|
|
62
63
|
- lib/muxr/control_server.rb
|
|
63
64
|
- lib/muxr/drawer.rb
|
|
64
65
|
- lib/muxr/foreground_command.rb
|
|
66
|
+
- lib/muxr/history_row.rb
|
|
67
|
+
- lib/muxr/image_store.rb
|
|
65
68
|
- lib/muxr/input_handler.rb
|
|
66
69
|
- lib/muxr/key_parser.rb
|
|
67
70
|
- lib/muxr/layout_manager.rb
|
|
71
|
+
- lib/muxr/mouse_report.rb
|
|
68
72
|
- lib/muxr/pane.rb
|
|
73
|
+
- lib/muxr/pane_picker.rb
|
|
74
|
+
- lib/muxr/pane_transfer.rb
|
|
69
75
|
- lib/muxr/protocol.rb
|
|
70
76
|
- lib/muxr/pty_process.rb
|
|
77
|
+
- lib/muxr/remote_pane.rb
|
|
71
78
|
- lib/muxr/renderer.rb
|
|
72
79
|
- lib/muxr/session.rb
|
|
80
|
+
- lib/muxr/session_directory.rb
|
|
73
81
|
- lib/muxr/terminal.rb
|
|
74
82
|
- lib/muxr/version.rb
|
|
83
|
+
- lib/muxr/width_probe.rb
|
|
75
84
|
- lib/muxr/window.rb
|
|
76
85
|
- muxr.gemspec
|
|
77
86
|
- skills/muxr-control/SKILL.md
|
|
@@ -79,11 +88,19 @@ homepage: https://github.com/roelbondoc/muxr
|
|
|
79
88
|
licenses:
|
|
80
89
|
- MIT
|
|
81
90
|
metadata:
|
|
91
|
+
homepage_uri: https://roelbondoc.github.io/muxr/
|
|
82
92
|
source_code_uri: https://github.com/roelbondoc/muxr
|
|
83
93
|
bug_tracker_uri: https://github.com/roelbondoc/muxr/issues
|
|
84
94
|
changelog_uri: https://github.com/roelbondoc/muxr/blob/main/CHANGELOG.md
|
|
85
95
|
rubygems_mfa_required: 'true'
|
|
86
96
|
allowed_push_host: https://rubygems.org
|
|
97
|
+
post_install_message: |
|
|
98
|
+
muxr ships a Claude Code skill for driving sessions over MCP. To install it
|
|
99
|
+
into ~/.claude/skills and print the `claude mcp add` line for the bridge:
|
|
100
|
+
|
|
101
|
+
muxr --install-skill
|
|
102
|
+
|
|
103
|
+
Re-run it after each `gem update muxr` to refresh the skill contents.
|
|
87
104
|
rdoc_options: []
|
|
88
105
|
require_paths:
|
|
89
106
|
- lib
|