muxr 0.1.4 → 0.1.6
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 +111 -1
- data/README.md +210 -65
- data/bin/muxr +61 -0
- data/bin/muxr-mcp +412 -0
- data/lib/muxr/application.rb +276 -26
- 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/foreground_command.rb +86 -0
- data/lib/muxr/input_handler.rb +133 -27
- data/lib/muxr/key_parser.rb +89 -0
- data/lib/muxr/layout_manager.rb +59 -0
- data/lib/muxr/pane.rb +60 -3
- data/lib/muxr/renderer.rb +145 -33
- data/lib/muxr/session.rb +13 -2
- data/lib/muxr/terminal.rb +81 -2
- data/lib/muxr/version.rb +1 -1
- data/lib/muxr.rb +2 -0
- data/muxr.gemspec +3 -1
- data/skills/muxr-control/SKILL.md +190 -0
- metadata +7 -1
data/lib/muxr/renderer.rb
CHANGED
|
@@ -4,13 +4,30 @@ module Muxr
|
|
|
4
4
|
# new frame against the previous one and only repositions/redraws cells
|
|
5
5
|
# whose contents changed, keeping output volume low between ticks.
|
|
6
6
|
class Renderer
|
|
7
|
-
BORDER_FOCUSED = [:c256, 11].freeze # yellow
|
|
7
|
+
BORDER_FOCUSED = [:c256, 11].freeze # yellow (fallback when no mode color)
|
|
8
8
|
BORDER_UNFOCUSED = [:c256, 8].freeze # grey
|
|
9
9
|
BORDER_DRAWER_FOCUS = [:c256, 13].freeze # magenta
|
|
10
10
|
BORDER_DRAWER_IDLE = [:c256, 5].freeze # dark magenta
|
|
11
11
|
STATUS_BG = [:c256, 236].freeze
|
|
12
12
|
STATUS_FG = [:c256, 252].freeze
|
|
13
13
|
|
|
14
|
+
# Vim-style mode palette. Used in two places: the focused pane border
|
|
15
|
+
# (so the user can see at a glance what mode they're in) and the
|
|
16
|
+
# [MODE] chip in the status bar (same color, smaller real estate).
|
|
17
|
+
# :prefix maps to the same green as :passthrough because :prefix is a
|
|
18
|
+
# transient sub-state under passthrough — sharing the color avoids a
|
|
19
|
+
# one-frame border flicker when pressing Ctrl-a.
|
|
20
|
+
MODE_COLOR = {
|
|
21
|
+
normal: [:c256, 51].freeze, # cyan
|
|
22
|
+
passthrough: [:c256, 42].freeze, # green
|
|
23
|
+
prefix: [:c256, 42].freeze, # green (passthrough sub-state)
|
|
24
|
+
command: [:c256, 226].freeze, # yellow
|
|
25
|
+
scrollback: [:c256, 214].freeze, # orange
|
|
26
|
+
selection: [:c256, 201].freeze, # magenta
|
|
27
|
+
confirm_quit: [:c256, 196].freeze, # red
|
|
28
|
+
help: [:c256, 39].freeze # blue
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
14
31
|
HORIZONTAL = "─".freeze
|
|
15
32
|
VERTICAL = "│".freeze
|
|
16
33
|
TL = "┌".freeze
|
|
@@ -18,9 +35,9 @@ module Muxr
|
|
|
18
35
|
BL = "└".freeze
|
|
19
36
|
BR = "┘".freeze
|
|
20
37
|
|
|
21
|
-
Cell = Struct.new(:char, :fg, :bg, :attrs) do
|
|
38
|
+
Cell = Struct.new(:char, :fg, :bg, :attrs, :hyperlink) do
|
|
22
39
|
def ==(other)
|
|
23
|
-
other.is_a?(Cell) && char == other.char && fg == other.fg && bg == other.bg && attrs == other.attrs
|
|
40
|
+
other.is_a?(Cell) && char == other.char && fg == other.fg && bg == other.bg && attrs == other.attrs && hyperlink == other.hyperlink
|
|
24
41
|
end
|
|
25
42
|
end
|
|
26
43
|
|
|
@@ -32,7 +49,10 @@ module Muxr
|
|
|
32
49
|
end
|
|
33
50
|
|
|
34
51
|
def enter_alt_screen
|
|
35
|
-
|
|
52
|
+
# Close any stale OSC 8 hyperlink the outer terminal might be carrying
|
|
53
|
+
# from before we attached, so the first frame's run-tracker matches
|
|
54
|
+
# reality.
|
|
55
|
+
@out.write("\e[?1049h\e[?25l\e[2J\e[H\e[0m\e]8;;\e\\")
|
|
36
56
|
@out.flush
|
|
37
57
|
@prev = nil
|
|
38
58
|
end
|
|
@@ -46,15 +66,15 @@ module Muxr
|
|
|
46
66
|
@prev = nil
|
|
47
67
|
end
|
|
48
68
|
|
|
49
|
-
def render(session, input_state: :
|
|
69
|
+
def render(session, input_state: :normal, command_buffer: "", message: nil, help: false)
|
|
50
70
|
w = session.width
|
|
51
71
|
h = session.height
|
|
52
72
|
return if w < 4 || h < 3
|
|
53
73
|
|
|
54
|
-
frame = Array.new(h) { Array.new(w) { Cell.new(" ", nil, nil, 0) } }
|
|
74
|
+
frame = Array.new(h) { Array.new(w) { Cell.new(" ", nil, nil, 0, nil) } }
|
|
55
75
|
|
|
56
|
-
compose_panes(frame, session)
|
|
57
|
-
compose_drawer(frame, session) if session.drawer&.visible?
|
|
76
|
+
compose_panes(frame, session, input_state: input_state)
|
|
77
|
+
compose_drawer(frame, session, input_state: input_state) if session.drawer&.visible?
|
|
58
78
|
compose_status_bar(frame, session, input_state: input_state, command_buffer: command_buffer, message: message)
|
|
59
79
|
compose_help(frame, session) if help
|
|
60
80
|
|
|
@@ -63,7 +83,7 @@ module Muxr
|
|
|
63
83
|
|
|
64
84
|
private
|
|
65
85
|
|
|
66
|
-
def compose_panes(frame, session)
|
|
86
|
+
def compose_panes(frame, session, input_state: :normal)
|
|
67
87
|
win = session.window
|
|
68
88
|
content_area = LayoutManager::Rect.new(0, 0, session.width, session.height - 1)
|
|
69
89
|
rects = LayoutManager.compute(
|
|
@@ -93,27 +113,48 @@ module Muxr
|
|
|
93
113
|
focused = (i == win.focused_index) && !(session.focus_drawer && session.drawer&.visible?)
|
|
94
114
|
title = "##{i + 1}"
|
|
95
115
|
title += "/#{win.panes.length}" if monocle
|
|
116
|
+
# Stable id sits after the slot number so monocle reads "#1/3 a3f9b2".
|
|
117
|
+
# respond_to? guard keeps renderer tests (which use simple struct fakes)
|
|
118
|
+
# from blowing up when a pane stand-in doesn't implement #id.
|
|
119
|
+
title += " #{pane.id}" if pane.respond_to?(:id) && pane.id.is_a?(String)
|
|
120
|
+
title += " [P]" if pane.respond_to?(:private?) && pane.private?
|
|
96
121
|
title += " ★" if i == win.master_index
|
|
97
|
-
|
|
122
|
+
# Foreground command (e.g. "npm test", "vim"). Set by the poller
|
|
123
|
+
# thread; nil when the shell itself is foreground. Truncate so a
|
|
124
|
+
# long invocation doesn't push the title past what draw_box will
|
|
125
|
+
# render — draw_box silently drops titles that don't fit.
|
|
126
|
+
if pane.respond_to?(:foreground_command) && pane.foreground_command
|
|
127
|
+
cmd = pane.foreground_command.to_s[0, 16]
|
|
128
|
+
title += " · #{cmd}"
|
|
129
|
+
end
|
|
98
130
|
if pane.terminal.scrolled_back?
|
|
99
131
|
title += " [scrollback #{pane.terminal.view_offset}/#{pane.terminal.scrollback_size}]"
|
|
100
132
|
end
|
|
101
133
|
draw_box(frame, rect,
|
|
102
|
-
border: focused ?
|
|
134
|
+
border: focused ? mode_color(input_state) : BORDER_UNFOCUSED,
|
|
103
135
|
bold_border: focused,
|
|
104
136
|
title: title,
|
|
105
137
|
title_focused: focused)
|
|
138
|
+
# Mode chip lives in the top-right corner of the focused container
|
|
139
|
+
# (this pane, or the drawer — see compose_drawer). Showing it on
|
|
140
|
+
# the same edge as the title but on the opposite side keeps both
|
|
141
|
+
# readable without one crowding the other.
|
|
142
|
+
draw_mode_chip(frame, rect, input_state, title) if focused
|
|
106
143
|
copy_terminal(frame, pane, rect.x + 1, rect.y + 1)
|
|
107
144
|
end
|
|
108
145
|
end
|
|
109
146
|
|
|
110
|
-
def compose_drawer(frame, session)
|
|
147
|
+
def compose_drawer(frame, session, input_state: :normal)
|
|
111
148
|
drawer = session.drawer
|
|
112
149
|
return unless drawer&.pane
|
|
113
150
|
|
|
114
151
|
w = session.width
|
|
115
152
|
h = session.height
|
|
116
|
-
|
|
153
|
+
# Drawer height is the larger of "16 rows" or 35% of the screen — the
|
|
154
|
+
# 35% rule is fine on tall terminals but uselessly small on short ones,
|
|
155
|
+
# so we floor it at 16 to keep the drawer practical. Final clamp keeps
|
|
156
|
+
# room for panes + status bar on very small terminals.
|
|
157
|
+
dh = [16, (h * 0.35).round].max.clamp(5, h - 2)
|
|
117
158
|
dy = h - 1 - dh
|
|
118
159
|
rect = LayoutManager::Rect.new(0, dy, w, dh)
|
|
119
160
|
drawer.pane.rect = rect
|
|
@@ -141,9 +182,47 @@ module Muxr
|
|
|
141
182
|
bold_border: true,
|
|
142
183
|
title: title,
|
|
143
184
|
title_focused: focused)
|
|
185
|
+
draw_mode_chip(frame, rect, input_state, title) if focused
|
|
144
186
|
copy_terminal(frame, drawer.pane, rect.x + 1, rect.y + 1)
|
|
145
187
|
end
|
|
146
188
|
|
|
189
|
+
def mode_color(input_state)
|
|
190
|
+
MODE_COLOR[input_state] || BORDER_FOCUSED
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Paint the " [MODE] " chip on the top border, hugging the right corner.
|
|
194
|
+
# Skipped when there isn't at least one column of breathing room between
|
|
195
|
+
# the title (anchored at the top-left) and the chip — otherwise long
|
|
196
|
+
# titles + a wide chip would overdraw each other and produce garbage.
|
|
197
|
+
def draw_mode_chip(frame, rect, input_state, title)
|
|
198
|
+
chip = " [#{mode_label(input_state)}] "
|
|
199
|
+
chip_start = rect.x + rect.w - 1 - chip.length
|
|
200
|
+
title_text = " #{title} "
|
|
201
|
+
title_end = rect.x + 2 + title_text.length - 1
|
|
202
|
+
return if chip_start <= title_end + 1
|
|
203
|
+
chip_color = mode_color(input_state)
|
|
204
|
+
chip.each_char.with_index do |ch, j|
|
|
205
|
+
set_cell(frame, rect.y, chip_start + j, ch, fg: chip_color, attrs: Terminal::BOLD)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Two-letter-ish mode label shown in the leftmost slot of the status bar.
|
|
210
|
+
# Lets the user see at a glance whether single-key bindings are active
|
|
211
|
+
# (NORMAL) or every key passes through to the focused pane (PASS).
|
|
212
|
+
def mode_label(input_state)
|
|
213
|
+
case input_state
|
|
214
|
+
when :normal then "NORMAL"
|
|
215
|
+
when :passthrough then "PASS"
|
|
216
|
+
when :prefix then "^A"
|
|
217
|
+
when :command then "CMD"
|
|
218
|
+
when :scrollback then "SCROLL"
|
|
219
|
+
when :selection then "SEL"
|
|
220
|
+
when :confirm_quit then "QUIT?"
|
|
221
|
+
when :help then "HELP"
|
|
222
|
+
else "?"
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
147
226
|
def compose_status_bar(frame, session, input_state:, command_buffer:, message:)
|
|
148
227
|
y = session.height - 1
|
|
149
228
|
w = session.width
|
|
@@ -154,7 +233,8 @@ module Muxr
|
|
|
154
233
|
else "hidden"
|
|
155
234
|
end
|
|
156
235
|
|
|
157
|
-
left = " [#{
|
|
236
|
+
left = " [#{mode_label(input_state)}]"
|
|
237
|
+
left << " [#{session.name}]"
|
|
158
238
|
left << " panes:#{win.panes.length}"
|
|
159
239
|
left << " layout:#{win.layout}"
|
|
160
240
|
focused_label =
|
|
@@ -181,6 +261,21 @@ module Muxr
|
|
|
181
261
|
c.attrs = 0
|
|
182
262
|
end
|
|
183
263
|
|
|
264
|
+
# Recolor the leading "[MODE]" chip in the mode's accent color. The
|
|
265
|
+
# chip lives at offset 1 (after one leading space) and runs for
|
|
266
|
+
# bracket+label+bracket characters. Full-row overlays below will
|
|
267
|
+
# overwrite this when active (command/scrollback/selection) — that's
|
|
268
|
+
# fine, those modes already convey themselves loudly.
|
|
269
|
+
chip = "[#{mode_label(input_state)}]"
|
|
270
|
+
chip_color = mode_color(input_state)
|
|
271
|
+
chip_start = 1
|
|
272
|
+
chip_end = [chip_start + chip.length, w].min
|
|
273
|
+
(chip_start...chip_end).each do |x|
|
|
274
|
+
c = frame[y][x]
|
|
275
|
+
c.fg = chip_color
|
|
276
|
+
c.attrs |= Terminal::BOLD
|
|
277
|
+
end
|
|
278
|
+
|
|
184
279
|
if input_state == :command
|
|
185
280
|
overlay = ":#{command_buffer}"
|
|
186
281
|
overlay = overlay[0, w]
|
|
@@ -222,7 +317,7 @@ module Muxr
|
|
|
222
317
|
mode = focused.terminal.selection_mode == :block ? "BLOCK" : "CHAR"
|
|
223
318
|
end
|
|
224
319
|
label = mode ? "SELECTION/#{mode}" : "SELECTION (cursor)"
|
|
225
|
-
overlay = " #{label} h/j/k/l move v char C-v block y/Enter yank q cancel "
|
|
320
|
+
overlay = " #{label} h/j/k/l move v/space char C-v block y/Enter yank q cancel "
|
|
226
321
|
overlay = overlay[0, w]
|
|
227
322
|
overlay.each_char.with_index do |ch, x|
|
|
228
323
|
c = frame[y][x]
|
|
@@ -256,26 +351,33 @@ module Muxr
|
|
|
256
351
|
HELP_LINES = [
|
|
257
352
|
"muxr — keybindings",
|
|
258
353
|
"",
|
|
259
|
-
"
|
|
260
|
-
"
|
|
261
|
-
"
|
|
262
|
-
"
|
|
263
|
-
"
|
|
264
|
-
"
|
|
265
|
-
"
|
|
266
|
-
"
|
|
267
|
-
"
|
|
268
|
-
"
|
|
269
|
-
"
|
|
270
|
-
"
|
|
271
|
-
"
|
|
272
|
-
" C-a
|
|
273
|
-
" C-a
|
|
274
|
-
" C-a
|
|
275
|
-
" C-a
|
|
354
|
+
"NORMAL mode (default; no prefix)",
|
|
355
|
+
" h / j / k / l focus pane left / down / up / right",
|
|
356
|
+
" i drop into passthrough mode",
|
|
357
|
+
" c / K new / close pane",
|
|
358
|
+
" t / g / m layout: tall / grid / monocle",
|
|
359
|
+
" Tab / Enter cycle layout / promote to master",
|
|
360
|
+
" a / 1..9 last pane / jump by number",
|
|
361
|
+
" s enter scrollback",
|
|
362
|
+
" ~ / C / P drawer / Claude drawer / toggle private",
|
|
363
|
+
" : / ? command prompt / toggle this help",
|
|
364
|
+
" ] / d / q paste buffer / detach / kill session",
|
|
365
|
+
"",
|
|
366
|
+
"PASSTHROUGH mode (keys reach the focused pane; prefix is Ctrl-a)",
|
|
367
|
+
" C-a Esc return to normal mode",
|
|
368
|
+
" C-a c K t g m same as normal-mode bindings",
|
|
369
|
+
" C-a Tab Enter cycle layout / promote master",
|
|
370
|
+
" C-a n / p / a next / prev / last pane",
|
|
371
|
+
" C-a [ ] scrollback / paste buffer",
|
|
372
|
+
" C-a C-a send literal Ctrl-a to focused pane",
|
|
373
|
+
"",
|
|
374
|
+
"SCROLLBACK mode (exits to the mode you came from)",
|
|
375
|
+
" j/k d/u f/b g/G scroll C-b/C-f page v→cursor",
|
|
376
|
+
" cursor: h/j/k/l 0/^/$ w/e/b W/E/B H/M/L g/G",
|
|
377
|
+
" v select, C-v block, y/Enter yank, q/Esc cancel",
|
|
276
378
|
"",
|
|
277
379
|
"Commands: layout {tall|grid|monocle}, drawer {toggle|show|hide|reset},",
|
|
278
|
-
" save, restore, sessions, quit, new, close, next, prev",
|
|
380
|
+
" claude, save, restore, sessions, quit, new, close, next, prev",
|
|
279
381
|
"",
|
|
280
382
|
"press any key to dismiss"
|
|
281
383
|
].freeze
|
|
@@ -356,6 +458,7 @@ module Muxr
|
|
|
356
458
|
dst.bg = src.bg
|
|
357
459
|
dst.attrs = src.attrs
|
|
358
460
|
dst.attrs |= Terminal::REVERSE if selection && term.selected_at_visible?(r, c)
|
|
461
|
+
dst.hyperlink = src.hyperlink
|
|
359
462
|
end
|
|
360
463
|
end
|
|
361
464
|
end
|
|
@@ -382,6 +485,9 @@ module Muxr
|
|
|
382
485
|
cur_fg = :unset
|
|
383
486
|
cur_bg = :unset
|
|
384
487
|
cur_attrs = :unset
|
|
488
|
+
# We close any open hyperlink at end-of-frame, so the outer terminal
|
|
489
|
+
# always starts a new frame in the "no hyperlink" state.
|
|
490
|
+
cur_hyperlink = nil
|
|
385
491
|
last_y = nil
|
|
386
492
|
last_x = nil
|
|
387
493
|
|
|
@@ -399,11 +505,17 @@ module Muxr
|
|
|
399
505
|
cur_bg = cell.bg
|
|
400
506
|
cur_attrs = cell.attrs
|
|
401
507
|
end
|
|
508
|
+
if cell.hyperlink != cur_hyperlink
|
|
509
|
+
out << "\e]8;;\e\\" if cur_hyperlink
|
|
510
|
+
out << "\e]#{cell.hyperlink}\e\\" if cell.hyperlink
|
|
511
|
+
cur_hyperlink = cell.hyperlink
|
|
512
|
+
end
|
|
402
513
|
out << cell.char
|
|
403
514
|
last_y = y
|
|
404
515
|
last_x = x + cell.char.length
|
|
405
516
|
end
|
|
406
517
|
end
|
|
518
|
+
out << "\e]8;;\e\\" if cur_hyperlink
|
|
407
519
|
out << "\e[0m"
|
|
408
520
|
out << cursor_position(session, input_state: input_state, command_buffer: command_buffer)
|
|
409
521
|
out << "\e[?2026l"
|
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,11 @@ module Muxr
|
|
|
12
12
|
|
|
13
13
|
SCROLLBACK_MAX = 5000
|
|
14
14
|
|
|
15
|
+
# Cap on the OSC payload we buffer before parsing. URLs in OSC 8 can be
|
|
16
|
+
# long but rarely exceed a few hundred bytes; 4 KiB lets the parser stay
|
|
17
|
+
# tolerant of weird inputs without giving an attacker an unbounded sink.
|
|
18
|
+
OSC_MAX_LEN = 4096
|
|
19
|
+
|
|
15
20
|
# Inner programs (fzf ≥ 0.41, neovim, helix, …) bracket coherent screen
|
|
16
21
|
# updates with `\e[?2026h … \e[?2026l` (DECSET 2026 — "Synchronized
|
|
17
22
|
# Output"). When we see the open, we know more bytes are coming that
|
|
@@ -20,12 +25,13 @@ module Muxr
|
|
|
20
25
|
# program (which left ?2026h open) cannot wedge the pane indefinitely.
|
|
21
26
|
SYNC_TIMEOUT = 0.2
|
|
22
27
|
|
|
23
|
-
Cell = Struct.new(:char, :fg, :bg, :attrs) do
|
|
28
|
+
Cell = Struct.new(:char, :fg, :bg, :attrs, :hyperlink) do
|
|
24
29
|
def reset!
|
|
25
30
|
self.char = " "
|
|
26
31
|
self.fg = nil
|
|
27
32
|
self.bg = nil
|
|
28
33
|
self.attrs = 0
|
|
34
|
+
self.hyperlink = nil
|
|
29
35
|
end
|
|
30
36
|
|
|
31
37
|
def copy_from(other)
|
|
@@ -33,6 +39,7 @@ module Muxr
|
|
|
33
39
|
self.fg = other.fg
|
|
34
40
|
self.bg = other.bg
|
|
35
41
|
self.attrs = other.attrs
|
|
42
|
+
self.hyperlink = other.hyperlink
|
|
36
43
|
end
|
|
37
44
|
end
|
|
38
45
|
|
|
@@ -53,7 +60,14 @@ module Muxr
|
|
|
53
60
|
@scroll_bottom = rows - 1
|
|
54
61
|
@parser_state = :ground
|
|
55
62
|
@parser_params = +""
|
|
63
|
+
@parser_osc = +""
|
|
56
64
|
@feed_remainder = +"".b
|
|
65
|
+
# Currently-active OSC 8 hyperlink body (the "8;params;URI" payload that
|
|
66
|
+
# we'll wrap back around runs of cells when rendering), or nil when no
|
|
67
|
+
# hyperlink is open. Interned via @hyperlink_intern so repeated identical
|
|
68
|
+
# links share one frozen string for fast equality and small memory.
|
|
69
|
+
@current_hyperlink = nil
|
|
70
|
+
@hyperlink_intern = {}
|
|
57
71
|
@dirty = true
|
|
58
72
|
@scrollback = []
|
|
59
73
|
@view_offset = 0
|
|
@@ -62,6 +76,19 @@ module Muxr
|
|
|
62
76
|
@selection_mode = :linear
|
|
63
77
|
@sync_pending = false
|
|
64
78
|
@sync_started_at = nil
|
|
79
|
+
@pending_replies = +"".b
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Bytes the emulator owes back to the inner program in response to a
|
|
83
|
+
# query (currently DSR / Device Status Report — `\e[5n` and `\e[6n`).
|
|
84
|
+
# The Pane drains this after each feed and writes it to the PTY's input
|
|
85
|
+
# side. Without it, programs like the AWS CLI fall back with a warning
|
|
86
|
+
# ("your terminal doesn't support cursor position requests (CPR)").
|
|
87
|
+
def take_pending_replies!
|
|
88
|
+
return nil if @pending_replies.empty?
|
|
89
|
+
data = @pending_replies
|
|
90
|
+
@pending_replies = +"".b
|
|
91
|
+
data
|
|
65
92
|
end
|
|
66
93
|
|
|
67
94
|
# True iff the inner program has opened a synchronized-output block
|
|
@@ -89,6 +116,20 @@ module Muxr
|
|
|
89
116
|
@buffer[r][c]
|
|
90
117
|
end
|
|
91
118
|
|
|
119
|
+
# Return the currently-visible grid as a text string (rows joined by "\n",
|
|
120
|
+
# trailing whitespace stripped on each row). Used by the control surface
|
|
121
|
+
# to expose pane contents to programmatic clients (the MCP bridge in
|
|
122
|
+
# particular). This walks visible_cell so callers see whatever the user
|
|
123
|
+
# is currently looking at, including scrollback.
|
|
124
|
+
def dump_text
|
|
125
|
+
lines = Array.new(@rows) do |r|
|
|
126
|
+
row = String.new(capacity: @cols)
|
|
127
|
+
@cols.times { |c| row << visible_cell(r, c).char }
|
|
128
|
+
row.rstrip
|
|
129
|
+
end
|
|
130
|
+
lines.join("\n")
|
|
131
|
+
end
|
|
132
|
+
|
|
92
133
|
# Returns the Cell that should be visible at (r, c) given the current
|
|
93
134
|
# scrollback view_offset. When view_offset == 0 this is the live grid.
|
|
94
135
|
# When view_offset > 0, rows in the top of the visible area are sourced
|
|
@@ -421,7 +462,25 @@ module Muxr
|
|
|
421
462
|
private
|
|
422
463
|
|
|
423
464
|
def blank_cell
|
|
424
|
-
Cell.new(" ", nil, nil, 0)
|
|
465
|
+
Cell.new(" ", nil, nil, 0, nil)
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# Parse the just-completed OSC payload. We only care about OSC 8
|
|
469
|
+
# (hyperlinks): `8;params;URI`. An empty URI closes the active link.
|
|
470
|
+
# Anything else (window-title OSC 0/1/2, palette OSC 4, …) is silently
|
|
471
|
+
# consumed — the emulator doesn't model it.
|
|
472
|
+
def finalize_osc
|
|
473
|
+
payload = @parser_osc
|
|
474
|
+
@parser_osc = +""
|
|
475
|
+
return if payload.empty?
|
|
476
|
+
return unless payload.start_with?("8;")
|
|
477
|
+
parts = payload.split(";", 3)
|
|
478
|
+
uri = parts[2]
|
|
479
|
+
if uri.nil? || uri.empty?
|
|
480
|
+
@current_hyperlink = nil
|
|
481
|
+
else
|
|
482
|
+
@current_hyperlink = (@hyperlink_intern[payload] ||= payload.dup.freeze)
|
|
483
|
+
end
|
|
425
484
|
end
|
|
426
485
|
|
|
427
486
|
def process_char(ch)
|
|
@@ -435,11 +494,18 @@ module Muxr
|
|
|
435
494
|
csi_char(ch, b)
|
|
436
495
|
when :osc
|
|
437
496
|
if b == 0x07 || b == 0x9c
|
|
497
|
+
finalize_osc
|
|
438
498
|
@parser_state = :ground
|
|
439
499
|
elsif b == 0x1b
|
|
440
500
|
@parser_state = :osc_esc
|
|
501
|
+
elsif @parser_osc.bytesize < OSC_MAX_LEN
|
|
502
|
+
@parser_osc << ch
|
|
441
503
|
end
|
|
442
504
|
when :osc_esc
|
|
505
|
+
# ST is `ESC \`. Anything else is malformed but we still flush — most
|
|
506
|
+
# terminals are lenient here, and being strict would swallow the
|
|
507
|
+
# payload on slightly buggy emitters.
|
|
508
|
+
finalize_osc
|
|
443
509
|
@parser_state = :ground
|
|
444
510
|
when :charset
|
|
445
511
|
@parser_state = :ground
|
|
@@ -478,6 +544,7 @@ module Muxr
|
|
|
478
544
|
@parser_params = +""
|
|
479
545
|
when 0x5d # ]
|
|
480
546
|
@parser_state = :osc
|
|
547
|
+
@parser_osc = +""
|
|
481
548
|
when 0x28, 0x29, 0x2a, 0x2b # ( ) * +
|
|
482
549
|
@parser_state = :charset
|
|
483
550
|
when 0x37 # 7 save cursor
|
|
@@ -645,6 +712,16 @@ module Muxr
|
|
|
645
712
|
@saved_cursor = [@cursor_row, @cursor_col]
|
|
646
713
|
when "u"
|
|
647
714
|
@cursor_row, @cursor_col = @saved_cursor
|
|
715
|
+
when "n"
|
|
716
|
+
# DSR — Device Status Report. `\e[5n` asks if the terminal is OK,
|
|
717
|
+
# `\e[6n` (CPR) asks for the cursor position. The reply rides back
|
|
718
|
+
# through the PTY's input side; see take_pending_replies!.
|
|
719
|
+
case pms[0] || 0
|
|
720
|
+
when 5
|
|
721
|
+
@pending_replies << "\e[0n".b
|
|
722
|
+
when 6
|
|
723
|
+
@pending_replies << "\e[#{@cursor_row + 1};#{@cursor_col + 1}R".b
|
|
724
|
+
end
|
|
648
725
|
when "h", "l"
|
|
649
726
|
# Non-private mode set/reset — nothing we need to honor. (DEC private
|
|
650
727
|
# `?`-prefixed mode sequences are short-circuited above.)
|
|
@@ -662,6 +739,7 @@ module Muxr
|
|
|
662
739
|
cell.fg = @fg
|
|
663
740
|
cell.bg = @bg
|
|
664
741
|
cell.attrs = @attrs
|
|
742
|
+
cell.hyperlink = @current_hyperlink
|
|
665
743
|
if @cursor_col >= @cols - 1
|
|
666
744
|
@autowrap_pending = true
|
|
667
745
|
else
|
|
@@ -983,6 +1061,7 @@ module Muxr
|
|
|
983
1061
|
@scroll_top = 0
|
|
984
1062
|
@scroll_bottom = @rows - 1
|
|
985
1063
|
@autowrap_pending = false
|
|
1064
|
+
@current_hyperlink = nil
|
|
986
1065
|
end
|
|
987
1066
|
end
|
|
988
1067
|
end
|
data/lib/muxr/version.rb
CHANGED
data/lib/muxr.rb
CHANGED
|
@@ -3,6 +3,7 @@ require_relative "muxr/pty_process"
|
|
|
3
3
|
require_relative "muxr/terminal"
|
|
4
4
|
require_relative "muxr/pane"
|
|
5
5
|
require_relative "muxr/drawer"
|
|
6
|
+
require_relative "muxr/foreground_command"
|
|
6
7
|
require_relative "muxr/layout_manager"
|
|
7
8
|
require_relative "muxr/window"
|
|
8
9
|
require_relative "muxr/session"
|
|
@@ -10,6 +11,7 @@ require_relative "muxr/renderer"
|
|
|
10
11
|
require_relative "muxr/input_handler"
|
|
11
12
|
require_relative "muxr/command_dispatcher"
|
|
12
13
|
require_relative "muxr/protocol"
|
|
14
|
+
require_relative "muxr/control_server"
|
|
13
15
|
require_relative "muxr/application"
|
|
14
16
|
require_relative "muxr/client"
|
|
15
17
|
|
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",
|