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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +84 -1
- data/bin/muxr +61 -0
- data/bin/muxr-mcp +412 -0
- data/lib/muxr/application.rb +198 -29
- data/lib/muxr/command_dispatcher.rb +2 -0
- data/lib/muxr/control_server.rb +670 -0
- data/lib/muxr/drawer.rb +9 -2
- data/lib/muxr/input_handler.rb +2 -0
- data/lib/muxr/key_parser.rb +89 -0
- data/lib/muxr/pane.rb +65 -7
- data/lib/muxr/renderer.rb +22 -4
- data/lib/muxr/session.rb +13 -2
- data/lib/muxr/terminal.rb +80 -2
- data/lib/muxr/version.rb +1 -1
- data/lib/muxr.rb +1 -0
- data/muxr.gemspec +3 -1
- data/skills/muxr-control/SKILL.md +190 -0
- metadata +6 -1
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
531
|
-
#
|
|
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
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.
|
|
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
|