muxr 0.1.11 → 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 +225 -1
- data/README.md +763 -387
- data/bin/muxr +68 -33
- data/bin/muxr-mcp +172 -16
- data/lib/muxr/application.rb +516 -26
- 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 +112 -14
- data/lib/muxr/layout_manager.rb +83 -57
- data/lib/muxr/mouse_report.rb +24 -0
- data/lib/muxr/pane.rb +122 -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 +34 -7
- data/lib/muxr/remote_pane.rb +263 -0
- data/lib/muxr/renderer.rb +259 -76
- data/lib/muxr/session.rb +19 -2
- data/lib/muxr/session_directory.rb +102 -0
- data/lib/muxr/terminal.rb +759 -82
- 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/renderer.rb
CHANGED
|
@@ -10,6 +10,8 @@ module Muxr
|
|
|
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
|
+
BORDER_SYNCED = [:c256, 160].freeze
|
|
14
|
+
SYNC_CHIP = [:c256, 196].freeze
|
|
13
15
|
|
|
14
16
|
# Vim-style mode palette. Used in two places: the focused pane border
|
|
15
17
|
# (so the user can see at a glance what mode they're in) and the
|
|
@@ -27,7 +29,8 @@ module Muxr
|
|
|
27
29
|
selection: [:c256, 201].freeze, # magenta
|
|
28
30
|
confirm_quit: [:c256, 196].freeze, # red
|
|
29
31
|
confirm_close: [:c256, 196].freeze, # red
|
|
30
|
-
help: [:c256, 39].freeze
|
|
32
|
+
help: [:c256, 39].freeze, # blue
|
|
33
|
+
pane_picker: [:c256, 39].freeze # blue
|
|
31
34
|
}.freeze
|
|
32
35
|
|
|
33
36
|
# Background applied to cells that match the active scrollback search.
|
|
@@ -54,6 +57,7 @@ module Muxr
|
|
|
54
57
|
@prev = nil
|
|
55
58
|
@prev_w = 0
|
|
56
59
|
@prev_h = 0
|
|
60
|
+
@scroll_source = :ring
|
|
57
61
|
end
|
|
58
62
|
|
|
59
63
|
def enter_alt_screen
|
|
@@ -74,42 +78,61 @@ module Muxr
|
|
|
74
78
|
@prev = nil
|
|
75
79
|
end
|
|
76
80
|
|
|
77
|
-
def
|
|
81
|
+
def self.prefix_letter(prefix)
|
|
82
|
+
(prefix.ord + 0x60).chr
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def render(session, input_state: :normal, scroll_source: :ring, command_buffer: "", command_completions: nil, search_buffer: "", search_direction: :forward, message: nil, help: false, picker: nil, prefix: "\x01")
|
|
86
|
+
@prefix_letter = self.class.prefix_letter(prefix)
|
|
78
87
|
w = session.width
|
|
79
88
|
h = session.height
|
|
80
89
|
return if w < 4 || h < 3
|
|
81
90
|
|
|
82
91
|
frame = Array.new(h) { Array.new(w) { Cell.new(" ", nil, nil, 0, nil) } }
|
|
83
92
|
|
|
93
|
+
@scroll_source = scroll_source
|
|
84
94
|
compose_panes(frame, session, input_state: input_state)
|
|
85
95
|
compose_drawer(frame, session, input_state: input_state) if session.drawer&.visible?
|
|
86
96
|
compose_status_bar(
|
|
87
97
|
frame, session,
|
|
88
98
|
input_state: input_state,
|
|
89
99
|
command_buffer: command_buffer,
|
|
100
|
+
command_completions: command_completions,
|
|
90
101
|
search_buffer: search_buffer,
|
|
91
102
|
search_direction: search_direction,
|
|
92
103
|
message: message
|
|
93
104
|
)
|
|
94
105
|
compose_help(frame, session) if help
|
|
106
|
+
compose_pane_picker(frame, session, picker) if picker
|
|
95
107
|
|
|
96
108
|
emit_frame(frame, session, input_state: input_state, command_buffer: command_buffer, search_buffer: search_buffer)
|
|
97
109
|
end
|
|
98
110
|
|
|
99
111
|
private
|
|
100
112
|
|
|
113
|
+
def content_area(session)
|
|
114
|
+
LayoutManager::Rect.new(0, 0, session.width, session.height - 1)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def layout_label(win, session)
|
|
118
|
+
return "zoom:#{win.zoom_return}" if win.respond_to?(:zoomed?) && win.zoomed?
|
|
119
|
+
return win.layout.to_s unless win.layout == :auto
|
|
120
|
+
"auto:#{LayoutManager.resolve(:auto, content_area(session))}"
|
|
121
|
+
end
|
|
122
|
+
|
|
101
123
|
def compose_panes(frame, session, input_state: :normal)
|
|
102
124
|
win = session.window
|
|
103
|
-
|
|
125
|
+
area = content_area(session)
|
|
126
|
+
layout = LayoutManager.resolve(win.layout, area)
|
|
104
127
|
rects = LayoutManager.compute(
|
|
105
|
-
|
|
128
|
+
layout,
|
|
106
129
|
win.panes.length,
|
|
107
|
-
|
|
130
|
+
area,
|
|
108
131
|
focused_index: win.focused_index,
|
|
109
|
-
|
|
132
|
+
**win.layout_options
|
|
110
133
|
)
|
|
111
134
|
|
|
112
|
-
monocle =
|
|
135
|
+
monocle = layout == :monocle
|
|
113
136
|
|
|
114
137
|
win.panes.each_with_index do |pane, i|
|
|
115
138
|
rect = rects[i]
|
|
@@ -126,14 +149,18 @@ module Muxr
|
|
|
126
149
|
next if monocle && i != win.focused_index
|
|
127
150
|
|
|
128
151
|
focused = (i == win.focused_index) && !(session.focus_drawer && session.drawer&.visible?)
|
|
129
|
-
title = "##{i + 1}"
|
|
152
|
+
title = "##{i + 1}#{attention_marker(pane)}"
|
|
130
153
|
title += "/#{win.panes.length}" if monocle
|
|
131
154
|
# Stable id sits after the slot number so monocle reads "#1/3 a3f9b2".
|
|
132
155
|
# respond_to? guard keeps renderer tests (which use simple struct fakes)
|
|
133
156
|
# from blowing up when a pane stand-in doesn't implement #id.
|
|
134
|
-
title += " #{pane
|
|
157
|
+
title += " #{pane_label(pane)}" if pane.respond_to?(:id) && pane.id.is_a?(String)
|
|
135
158
|
title += " [P]" if pane.respond_to?(:private?) && pane.private?
|
|
159
|
+
# A borrowed pane names the session that actually owns its shell, so
|
|
160
|
+
# "who am I about to type at" is answerable without leaving the layout.
|
|
161
|
+
title += " @#{pane.origin}" if pane.respond_to?(:origin) && pane.origin
|
|
136
162
|
title += " ★" if i == win.master_index
|
|
163
|
+
title += " [silence #{pane.silence_after}s]" if pane.respond_to?(:silence_after) && pane.silence_after
|
|
137
164
|
# Foreground command (e.g. "npm test", "vim"). Set by the poller
|
|
138
165
|
# thread; nil when the shell itself is foreground. Truncate so a
|
|
139
166
|
# long invocation doesn't push the title past what draw_box will
|
|
@@ -146,7 +173,7 @@ module Muxr
|
|
|
146
173
|
title += " [scrollback #{pane.terminal.view_offset}/#{pane.terminal.scrollback_size}]"
|
|
147
174
|
end
|
|
148
175
|
draw_box(frame, rect,
|
|
149
|
-
border: focused ? mode_color(input_state) :
|
|
176
|
+
border: focused ? mode_color(input_state) : unfocused_border(win),
|
|
150
177
|
bold_border: focused,
|
|
151
178
|
title: title,
|
|
152
179
|
title_focused: focused)
|
|
@@ -155,10 +182,41 @@ module Muxr
|
|
|
155
182
|
# the same edge as the title but on the opposite side keeps both
|
|
156
183
|
# readable without one crowding the other.
|
|
157
184
|
draw_mode_chip(frame, rect, input_state, title) if focused
|
|
158
|
-
copy_terminal(frame, pane, rect
|
|
185
|
+
copy_terminal(frame, pane, rect)
|
|
159
186
|
end
|
|
160
187
|
end
|
|
161
188
|
|
|
189
|
+
def pane_label(pane)
|
|
190
|
+
pane.respond_to?(:name) && pane.name ? pane.name : pane.id
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def unfocused_border(win)
|
|
194
|
+
synchronized?(win) ? BORDER_SYNCED : BORDER_UNFOCUSED
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def synchronized?(win)
|
|
198
|
+
win.respond_to?(:synchronized) && win.synchronized
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def attention_marker(pane)
|
|
202
|
+
if pane.respond_to?(:bell?) && pane.bell?
|
|
203
|
+
"!"
|
|
204
|
+
elsif pane.respond_to?(:silent?) && pane.silent?
|
|
205
|
+
"~"
|
|
206
|
+
elsif pane.respond_to?(:activity?) && pane.activity?
|
|
207
|
+
"•"
|
|
208
|
+
else
|
|
209
|
+
""
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def attention_label(win)
|
|
214
|
+
win.panes.each_with_index.filter_map do |pane, i|
|
|
215
|
+
marker = attention_marker(pane)
|
|
216
|
+
"#{i + 1}#{marker}" unless marker.empty?
|
|
217
|
+
end.join(",")
|
|
218
|
+
end
|
|
219
|
+
|
|
162
220
|
def compose_drawer(frame, session, input_state: :normal)
|
|
163
221
|
drawer = session.drawer
|
|
164
222
|
return unless drawer&.pane
|
|
@@ -198,7 +256,7 @@ module Muxr
|
|
|
198
256
|
title: title,
|
|
199
257
|
title_focused: focused)
|
|
200
258
|
draw_mode_chip(frame, rect, input_state, title) if focused
|
|
201
|
-
copy_terminal(frame, drawer.pane, rect
|
|
259
|
+
copy_terminal(frame, drawer.pane, rect)
|
|
202
260
|
end
|
|
203
261
|
|
|
204
262
|
def mode_color(input_state)
|
|
@@ -228,19 +286,20 @@ module Muxr
|
|
|
228
286
|
case input_state
|
|
229
287
|
when :normal then "NORMAL"
|
|
230
288
|
when :passthrough then "PASS"
|
|
231
|
-
when :prefix then "
|
|
289
|
+
when :prefix then "^#{@prefix_letter.upcase}"
|
|
232
290
|
when :command then "CMD"
|
|
233
|
-
when :scrollback then "SCROLL"
|
|
291
|
+
when :scrollback then @scroll_source == :app ? "SCROLL:APP" : "SCROLL"
|
|
234
292
|
when :search then "SEARCH"
|
|
235
293
|
when :selection then "SEL"
|
|
236
294
|
when :confirm_quit then "QUIT?"
|
|
237
295
|
when :confirm_close then "CLOSE?"
|
|
238
296
|
when :help then "HELP"
|
|
297
|
+
when :pane_picker then "ATTACH"
|
|
239
298
|
else "?"
|
|
240
299
|
end
|
|
241
300
|
end
|
|
242
301
|
|
|
243
|
-
def compose_status_bar(frame, session, input_state:, command_buffer:, search_buffer: "", search_direction: :forward, message: nil)
|
|
302
|
+
def compose_status_bar(frame, session, input_state:, command_buffer:, command_completions: nil, search_buffer: "", search_direction: :forward, message: nil)
|
|
244
303
|
y = session.height - 1
|
|
245
304
|
w = session.width
|
|
246
305
|
win = session.window
|
|
@@ -252,8 +311,9 @@ module Muxr
|
|
|
252
311
|
|
|
253
312
|
left = " [#{mode_label(input_state)}]"
|
|
254
313
|
left << " [#{session.name}]"
|
|
314
|
+
left << " [SYNC]" if synchronized?(win)
|
|
255
315
|
left << " panes:#{win.panes.length}"
|
|
256
|
-
left << " layout:#{win
|
|
316
|
+
left << " layout:#{layout_label(win, session)}"
|
|
257
317
|
focused_label =
|
|
258
318
|
if session.focus_drawer && session.drawer&.visible?
|
|
259
319
|
"drawer"
|
|
@@ -263,9 +323,11 @@ module Muxr
|
|
|
263
323
|
"##{win.focused_index + 1}"
|
|
264
324
|
end
|
|
265
325
|
left << " focused:#{focused_label}"
|
|
326
|
+
alerts = attention_label(win)
|
|
327
|
+
left << " alerts:#{alerts}" unless alerts.empty?
|
|
266
328
|
left << " drawer:#{drawer_state} "
|
|
267
329
|
|
|
268
|
-
right = " muxr
|
|
330
|
+
right = " muxr ^#{@prefix_letter} ? "
|
|
269
331
|
|
|
270
332
|
bar = (left + " " * w)[0, w - right.length] + right
|
|
271
333
|
bar = bar[0, w]
|
|
@@ -292,16 +354,21 @@ module Muxr
|
|
|
292
354
|
c.fg = chip_color
|
|
293
355
|
c.attrs |= Terminal::BOLD
|
|
294
356
|
end
|
|
357
|
+
paint_sync_chip(frame, y, bar) if synchronized?(win)
|
|
295
358
|
|
|
296
359
|
if input_state == :command
|
|
297
|
-
|
|
298
|
-
|
|
360
|
+
prompt = ":#{command_buffer}"
|
|
361
|
+
# Ambiguous Tab-completion candidates trail the prompt in a muted
|
|
362
|
+
# color, e.g. `:layout s spiral stack`. Cosmetic — the cursor
|
|
363
|
+
# stays at the end of the buffer (see #cursor_position).
|
|
364
|
+
hint = command_completions && !command_completions.empty? ? " #{command_completions.join(" ")}" : ""
|
|
365
|
+
overlay = (prompt + hint)[0, w]
|
|
299
366
|
overlay.each_char.with_index do |ch, x|
|
|
300
367
|
c = frame[y][x]
|
|
301
368
|
c.char = ch
|
|
302
|
-
c.fg = [:c256, 232]
|
|
369
|
+
c.fg = x < prompt.length ? [:c256, 232] : [:c256, 240]
|
|
303
370
|
c.bg = [:c256, 226]
|
|
304
|
-
c.attrs = Terminal::BOLD
|
|
371
|
+
c.attrs = x < prompt.length ? Terminal::BOLD : 0
|
|
305
372
|
end
|
|
306
373
|
(overlay.length...w).each do |x|
|
|
307
374
|
c = frame[y][x]
|
|
@@ -311,7 +378,12 @@ module Muxr
|
|
|
311
378
|
c.attrs = 0
|
|
312
379
|
end
|
|
313
380
|
elsif input_state == :scrollback
|
|
314
|
-
overlay =
|
|
381
|
+
overlay =
|
|
382
|
+
if @scroll_source == :app
|
|
383
|
+
" APP SCROLL ↑↓/j/k line d/u half f/b page v select Tab muxr history q quit "
|
|
384
|
+
else
|
|
385
|
+
" SCROLLBACK ↑↓/j/k line d/u half f/b page g/G top/bot / search n/N next/prev v select Tab app scroll q quit "
|
|
386
|
+
end
|
|
315
387
|
overlay = overlay[0, w]
|
|
316
388
|
overlay.each_char.with_index do |ch, x|
|
|
317
389
|
c = frame[y][x]
|
|
@@ -368,18 +440,33 @@ module Muxr
|
|
|
368
440
|
c.bg = [:c256, 156]
|
|
369
441
|
c.attrs = 0
|
|
370
442
|
end
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
draw_status_message(frame, y, w, message) if message
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def paint_sync_chip(frame, y, bar)
|
|
449
|
+
start = bar.index("[SYNC]")
|
|
450
|
+
return unless start
|
|
451
|
+
(start...start + "[SYNC]".length).each do |x|
|
|
452
|
+
c = frame[y][x]
|
|
453
|
+
c.fg = [:c256, 15]
|
|
454
|
+
c.bg = SYNC_CHIP
|
|
455
|
+
c.attrs = Terminal::BOLD
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def draw_status_message(frame, y, w, message)
|
|
460
|
+
msg = " #{message} "
|
|
461
|
+
start = [w - msg.length, 0].max
|
|
462
|
+
msg.each_char.with_index do |ch, i|
|
|
463
|
+
x = start + i
|
|
464
|
+
next unless x < w
|
|
465
|
+
c = frame[y][x]
|
|
466
|
+
c.char = ch
|
|
467
|
+
c.fg = [:c256, 15]
|
|
468
|
+
c.bg = [:c256, 28]
|
|
469
|
+
c.attrs = Terminal::BOLD
|
|
383
470
|
end
|
|
384
471
|
end
|
|
385
472
|
|
|
@@ -393,25 +480,35 @@ module Muxr
|
|
|
393
480
|
" c / x new / close pane (close asks y/n)",
|
|
394
481
|
" t / w / g / m layout: tall / wide / grid / monocle",
|
|
395
482
|
" | - f e S layout: columns / rows / spiral / centered / stack",
|
|
483
|
+
" F layout: auto (spiral on a roomy screen, else stack)",
|
|
396
484
|
" Tab / Enter cycle layout / promote to master",
|
|
485
|
+
" < / > shrink / grow the master area (tall, wide, centered)",
|
|
486
|
+
" , / . one fewer / one more master pane",
|
|
487
|
+
" z zoom the focused pane / restore the layout",
|
|
397
488
|
" a / 1..9 last pane / jump by number",
|
|
398
489
|
" r refresh / redraw (fixes a corrupted pane)",
|
|
399
490
|
" s enter scrollback",
|
|
400
491
|
" ~ / C / P drawer / Claude drawer / toggle private",
|
|
492
|
+
" A share or move a pane from another muxr session",
|
|
401
493
|
" : / ? command prompt / toggle this help",
|
|
402
494
|
" ] / d / q paste buffer / detach / kill session",
|
|
403
495
|
"",
|
|
404
496
|
"PASSTHROUGH mode (keys reach the focused pane; prefix is Ctrl-a)",
|
|
405
497
|
" C-a Esc return to normal mode",
|
|
406
|
-
" C-a c x
|
|
498
|
+
" C-a c / x new / close pane (close asks y/n)",
|
|
407
499
|
" C-a Tab Enter cycle layout / promote master",
|
|
500
|
+
" C-a < > , . master size / master count",
|
|
501
|
+
" C-a z zoom the focused pane / restore the layout",
|
|
408
502
|
" C-a n / p / a next / prev / last pane",
|
|
409
503
|
" C-a r refresh / redraw (fixes a corrupted pane)",
|
|
410
504
|
" C-a [ ] scrollback / paste buffer",
|
|
505
|
+
" C-a A share or move a pane from another muxr session",
|
|
411
506
|
" C-a C-a send literal Ctrl-a to focused pane",
|
|
412
507
|
"",
|
|
413
508
|
"SCROLLBACK mode (pane-bound: follows you as you switch panes)",
|
|
414
509
|
" j/k ↑/↓ d/u f/b g/G scroll C-b/C-f page v→cursor",
|
|
510
|
+
" Tab switch between the app's own scroll and muxr's history",
|
|
511
|
+
" (C-a [ starts on the app's scroll when the pane has one)",
|
|
415
512
|
" / search-fwd ? search-back n/N next/prev match",
|
|
416
513
|
" C-a n/p/a/1-9 switch pane, stay in scrollback (each keeps its pos)",
|
|
417
514
|
" i insert here (keeps scroll pos) q/Esc exit to live bottom",
|
|
@@ -419,17 +516,30 @@ module Muxr
|
|
|
419
516
|
" v select, C-v block, y/Enter yank (stays in scrollback)",
|
|
420
517
|
" q/Esc cancel C-a n/p/a/1-9 switch pane",
|
|
421
518
|
"",
|
|
422
|
-
"
|
|
519
|
+
"COMMAND prompt (: to open; Tab completes, Esc/C-c cancels)",
|
|
520
|
+
"Commands: layout {tall|wide|columns|rows|grid|spiral|centered|stack|monocle|auto},",
|
|
423
521
|
" drawer {toggle|show|hide|reset},",
|
|
424
|
-
" claude, save, restore, sessions, quit, new, close, next, prev",
|
|
522
|
+
" claude, save, restore, sessions, attach, quit, new, close, next, prev,",
|
|
523
|
+
" silence {<secs>|<n>m|off} (alert when the pane goes quiet),",
|
|
524
|
+
" ratio <percent>, masters <n>, zoom,",
|
|
525
|
+
" sync {on|off} (type into every pane at once),",
|
|
526
|
+
" rename [name] (label the focused pane; bare clears it),",
|
|
527
|
+
" reload (re-read ~/.muxr/config.json),",
|
|
528
|
+
" capture [path] (save the pane's history as text)",
|
|
425
529
|
"",
|
|
426
530
|
"press any key to dismiss"
|
|
427
531
|
].freeze
|
|
428
532
|
|
|
533
|
+
def help_lines
|
|
534
|
+
return HELP_LINES if @prefix_letter == "a"
|
|
535
|
+
HELP_LINES.map { |line| line.gsub("C-a", "C-#{@prefix_letter}").gsub("Ctrl-a", "Ctrl-#{@prefix_letter}") }
|
|
536
|
+
end
|
|
537
|
+
|
|
429
538
|
def compose_help(frame, session)
|
|
430
539
|
w = session.width
|
|
431
540
|
h = session.height
|
|
432
|
-
|
|
541
|
+
lines = help_lines
|
|
542
|
+
max_len = lines.map(&:length).max
|
|
433
543
|
box_w = [max_len + 4, w - 4].min
|
|
434
544
|
box_h = [HELP_LINES.length + 2, h - 4].min
|
|
435
545
|
x = (w - box_w) / 2
|
|
@@ -447,7 +557,7 @@ module Muxr
|
|
|
447
557
|
end
|
|
448
558
|
draw_box(frame, rect, border: [:c256, 51], bold_border: true, title: "Help", title_focused: true)
|
|
449
559
|
|
|
450
|
-
|
|
560
|
+
lines.first(box_h - 2).each_with_index do |line, i|
|
|
451
561
|
line[0, box_w - 4].chars.each_with_index do |ch, j|
|
|
452
562
|
c = frame[rect.y + 1 + i][rect.x + 2 + j]
|
|
453
563
|
c.char = ch
|
|
@@ -458,6 +568,75 @@ module Muxr
|
|
|
458
568
|
end
|
|
459
569
|
end
|
|
460
570
|
|
|
571
|
+
PICKER_BG = [:c256, 236].freeze
|
|
572
|
+
PICKER_FG = [:c256, 252].freeze
|
|
573
|
+
PICKER_SESSION_FG = [:c256, 45].freeze
|
|
574
|
+
PICKER_DIM_FG = [:c256, 245].freeze
|
|
575
|
+
PICKER_SELECTED_BG = [:c256, 24].freeze
|
|
576
|
+
PICKER_BORDER = [:c256, 39].freeze
|
|
577
|
+
PICKER_HINT = "j/k select · Enter share · m move here · Esc cancel".freeze
|
|
578
|
+
|
|
579
|
+
def compose_pane_picker(frame, session, picker)
|
|
580
|
+
lines = picker_lines(picker)
|
|
581
|
+
w = session.width
|
|
582
|
+
h = session.height
|
|
583
|
+
box_w = [lines.map { |l| l[0].length }.max + 4, PICKER_HINT.length + 4, 30].max
|
|
584
|
+
box_w = [box_w, w - 4].min
|
|
585
|
+
box_h = [lines.length + 4, h - 4].min
|
|
586
|
+
rect = LayoutManager::Rect.new((w - box_w) / 2, (h - box_h) / 2, box_w, box_h)
|
|
587
|
+
|
|
588
|
+
(rect.y...(rect.y + rect.h)).each do |yy|
|
|
589
|
+
(rect.x...(rect.x + rect.w)).each do |xx|
|
|
590
|
+
set_cell(frame, yy, xx, " ", fg: PICKER_FG, bg: PICKER_BG)
|
|
591
|
+
end
|
|
592
|
+
end
|
|
593
|
+
draw_box(frame, rect, border: PICKER_BORDER, bold_border: true,
|
|
594
|
+
title: "Attach pane", title_focused: true)
|
|
595
|
+
|
|
596
|
+
inner_w = box_w - 4
|
|
597
|
+
visible = picker_window(lines, picker.index, box_h - 4)
|
|
598
|
+
visible.each_with_index do |(text, fg, selected), i|
|
|
599
|
+
bg = selected ? PICKER_SELECTED_BG : PICKER_BG
|
|
600
|
+
row = text[0, inner_w].ljust(inner_w)
|
|
601
|
+
row.chars.each_with_index do |ch, j|
|
|
602
|
+
set_cell(frame, rect.y + 1 + i, rect.x + 2 + j, ch,
|
|
603
|
+
fg: fg, bg: bg, attrs: selected ? Terminal::BOLD : 0)
|
|
604
|
+
end
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
hint_y = rect.y + rect.h - 2
|
|
608
|
+
PICKER_HINT[0, inner_w].chars.each_with_index do |ch, j|
|
|
609
|
+
set_cell(frame, hint_y, rect.x + 2 + j, ch, fg: PICKER_DIM_FG, bg: PICKER_BG)
|
|
610
|
+
end
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
def picker_lines(picker)
|
|
614
|
+
picker.rows.each_with_index.map do |row, i|
|
|
615
|
+
selected = i == picker.index
|
|
616
|
+
if row.selectable?
|
|
617
|
+
entry = row.entry
|
|
618
|
+
text = " ##{entry.slot} #{entry.name || entry.pane_id}"
|
|
619
|
+
text += " #{shorten_path(entry.cwd)}" if entry.cwd
|
|
620
|
+
[text, PICKER_FG, selected]
|
|
621
|
+
else
|
|
622
|
+
["#{row.session}", PICKER_SESSION_FG, false]
|
|
623
|
+
end
|
|
624
|
+
end
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
# Scroll the list so the selected row stays on screen without moving more
|
|
628
|
+
# than it has to — the cursor sits still until it reaches an edge.
|
|
629
|
+
def picker_window(lines, index, height)
|
|
630
|
+
return lines if height <= 0 || lines.length <= height
|
|
631
|
+
start = (index - height / 2).clamp(0, lines.length - height)
|
|
632
|
+
lines[start, height]
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
def shorten_path(path)
|
|
636
|
+
home = Dir.home
|
|
637
|
+
path.to_s.start_with?(home) ? path.to_s.sub(home, "~") : path.to_s
|
|
638
|
+
end
|
|
639
|
+
|
|
461
640
|
def draw_box(frame, rect, border:, bold_border:, title: nil, title_focused: false)
|
|
462
641
|
attrs = bold_border ? Terminal::BOLD : 0
|
|
463
642
|
x2 = rect.x + rect.w - 1
|
|
@@ -484,10 +663,16 @@ module Muxr
|
|
|
484
663
|
end
|
|
485
664
|
end
|
|
486
665
|
|
|
487
|
-
|
|
666
|
+
# Paint a pane's grid into the inside of its box. Clipped to the box, not
|
|
667
|
+
# just to the frame: a mirrored pane's grid is sized by the session that
|
|
668
|
+
# owns it, so it can be a row or column out of step with the box we drew
|
|
669
|
+
# for it here, and overrunning would eat the border.
|
|
670
|
+
def copy_terminal(frame, pane, rect)
|
|
488
671
|
term = pane.terminal
|
|
489
|
-
|
|
490
|
-
|
|
672
|
+
dst_x = rect.x + 1
|
|
673
|
+
dst_y = rect.y + 1
|
|
674
|
+
rows = [term.rows, rect.h - 2].min
|
|
675
|
+
cols = [term.cols, rect.w - 2].min
|
|
491
676
|
selection = term.selection_active?
|
|
492
677
|
search = term.search_active?
|
|
493
678
|
rows.times do |r|
|
|
@@ -544,10 +729,18 @@ module Muxr
|
|
|
544
729
|
last_x = nil
|
|
545
730
|
|
|
546
731
|
frame.each_with_index do |row, y|
|
|
732
|
+
# When the previous glyph on this row had an uncertain display width
|
|
733
|
+
# (set below), the outer terminal may have drawn it two columns wide and
|
|
734
|
+
# stolen the column to its right. Force that next cell to repaint even
|
|
735
|
+
# if our model says it's unchanged, so a phantom second half can't
|
|
736
|
+
# linger until a full repaint. Reset per row — a steal can't cross a
|
|
737
|
+
# line boundary.
|
|
738
|
+
force_next = false
|
|
547
739
|
row.each_with_index do |cell, x|
|
|
548
|
-
if same_size && @prev[y][x] == cell
|
|
740
|
+
if same_size && @prev[y][x] == cell && !force_next
|
|
549
741
|
next
|
|
550
742
|
end
|
|
743
|
+
force_next = false
|
|
551
744
|
# The right half of a wide glyph (char "") is painted by its lead
|
|
552
745
|
# cell to the left, which spans both columns in the outer terminal.
|
|
553
746
|
# Emitting anything here would clobber that glyph, so skip it — and
|
|
@@ -581,6 +774,15 @@ module Muxr
|
|
|
581
774
|
# of columns than we think for this glyph — force an absolute
|
|
582
775
|
# reposition for the next cell so the disagreement can't cascade.
|
|
583
776
|
last_x = nil
|
|
777
|
+
# For a glyph WE model as a single column but whose real width the
|
|
778
|
+
# outer terminal may stretch to two (East Asian Ambiguous symbols
|
|
779
|
+
# like ·, …, ●, and the ⏺/✻/❯ glyphs Claude Code animates in place),
|
|
780
|
+
# also force the next cell to repaint. Otherwise an in-place spinner
|
|
781
|
+
# leaves its unchanged neighbour skipped by the diff, and the glyph's
|
|
782
|
+
# phantom second half sits on that column until a manual refresh.
|
|
783
|
+
# Width-2 glyphs (CJK/emoji) own their continuation cell already, so
|
|
784
|
+
# they don't need this — only the model-1/draw-2 mismatch does.
|
|
785
|
+
force_next = true if Terminal.char_width(cell.char.ord) == 1
|
|
584
786
|
end
|
|
585
787
|
end
|
|
586
788
|
end
|
|
@@ -597,10 +799,11 @@ module Muxr
|
|
|
597
799
|
|
|
598
800
|
# Whether we can trust the outer terminal's cursor to be exactly one
|
|
599
801
|
# display-width past this glyph, so the next contiguous cell needs no
|
|
600
|
-
# cursor-position escape. Safe
|
|
601
|
-
#
|
|
602
|
-
#
|
|
603
|
-
# borders, so keeping them contiguous matters
|
|
802
|
+
# cursor-position escape. Safe for ASCII (always one column), and for the
|
|
803
|
+
# box-drawing / block-element band 0x2500–0x259F unless the width probe
|
|
804
|
+
# measured this terminal drawing that band wide — we emit a lot of those for
|
|
805
|
+
# borders, so keeping them contiguous matters when it's sound. Everything
|
|
806
|
+
# else non-ASCII —
|
|
604
807
|
# CJK, emoji, and East Asian Ambiguous symbols like ·, …, ●, arrows, and
|
|
605
808
|
# the ⏺/✻/❯ glyphs Claude Code's UI is full of — can be drawn two columns
|
|
606
809
|
# wide by some terminals. We can't know which, so we force an absolute
|
|
@@ -610,10 +813,12 @@ module Muxr
|
|
|
610
813
|
def contiguous_after?(char)
|
|
611
814
|
return false if char.length > 1
|
|
612
815
|
cp = char.ord
|
|
613
|
-
|
|
816
|
+
return true if cp < 0x80
|
|
817
|
+
!Terminal.box_wide && Terminal::BOX_RANGES.any? { |r| r.cover?(cp) }
|
|
614
818
|
end
|
|
615
819
|
|
|
616
820
|
def cursor_position(session, input_state:, command_buffer:, search_buffer: "")
|
|
821
|
+
return "\e[?25l" if input_state == :pane_picker
|
|
617
822
|
if input_state == :command
|
|
618
823
|
col = 1 + command_buffer.length + 1 # ':' + buffer
|
|
619
824
|
return "\e[#{session.height};#{col}H\e[?25h"
|
|
@@ -641,40 +846,18 @@ module Muxr
|
|
|
641
846
|
return "\e[#{row};#{col}H\e[?25h"
|
|
642
847
|
end
|
|
643
848
|
return "\e[?25l" if term.scrolled_back?
|
|
849
|
+
# Honor the inner program's own cursor visibility (DECTCEM): Claude Code
|
|
850
|
+
# and other Ink UIs hide the cursor while rendering and only show it at a
|
|
851
|
+
# text prompt. Painting our block at its last write position otherwise
|
|
852
|
+
# leaves a phantom cursor smeared across the animating UI.
|
|
853
|
+
return "\e[?25l" unless term.cursor_visible?
|
|
644
854
|
row = rect.y + 1 + term.cursor_row + 1
|
|
645
855
|
col = rect.x + 1 + term.cursor_col + 1
|
|
646
856
|
"\e[#{row};#{col}H\e[?25h"
|
|
647
857
|
end
|
|
648
858
|
|
|
649
859
|
def sgr(cell)
|
|
650
|
-
|
|
651
|
-
attrs = cell.attrs.to_i
|
|
652
|
-
parts << "1" if (attrs & Terminal::BOLD) != 0
|
|
653
|
-
parts << "2" if (attrs & Terminal::DIM) != 0
|
|
654
|
-
parts << "4" if (attrs & Terminal::UNDERLINE) != 0
|
|
655
|
-
parts << "7" if (attrs & Terminal::REVERSE) != 0
|
|
656
|
-
append_color(parts, cell.fg, true)
|
|
657
|
-
append_color(parts, cell.bg, false)
|
|
658
|
-
"\e[#{parts.join(';')}m"
|
|
659
|
-
end
|
|
660
|
-
|
|
661
|
-
def append_color(parts, color, fg)
|
|
662
|
-
return if color.nil?
|
|
663
|
-
case color
|
|
664
|
-
when Integer
|
|
665
|
-
if color < 8
|
|
666
|
-
parts << ((fg ? 30 : 40) + color).to_s
|
|
667
|
-
else
|
|
668
|
-
parts << ((fg ? 90 : 100) + (color - 8)).to_s
|
|
669
|
-
end
|
|
670
|
-
when Array
|
|
671
|
-
case color[0]
|
|
672
|
-
when :c256
|
|
673
|
-
parts << "#{fg ? 38 : 48};5;#{color[1]}"
|
|
674
|
-
when :rgb
|
|
675
|
-
parts << "#{fg ? 38 : 48};2;#{color[1]};#{color[2]};#{color[3]}"
|
|
676
|
-
end
|
|
677
|
-
end
|
|
860
|
+
Terminal.sgr(cell)
|
|
678
861
|
end
|
|
679
862
|
end
|
|
680
863
|
end
|
data/lib/muxr/session.rb
CHANGED
|
@@ -41,11 +41,13 @@ module Muxr
|
|
|
41
41
|
"name" => @name,
|
|
42
42
|
"width" => @width,
|
|
43
43
|
"height" => @height,
|
|
44
|
-
"layout" => @window.
|
|
44
|
+
"layout" => @window.resting_layout.to_s,
|
|
45
45
|
"focused_index" => @window.focused_index,
|
|
46
46
|
"master_index" => @window.master_index,
|
|
47
|
+
"master_ratio" => @window.master_ratio,
|
|
48
|
+
"master_count" => @window.master_count,
|
|
47
49
|
"focus_drawer" => @focus_drawer,
|
|
48
|
-
"panes" =>
|
|
50
|
+
"panes" => own_panes.map { |p| serialize_pane(p) },
|
|
49
51
|
"drawer" => serialize_drawer
|
|
50
52
|
}
|
|
51
53
|
end
|
|
@@ -72,6 +74,21 @@ module Muxr
|
|
|
72
74
|
|
|
73
75
|
private
|
|
74
76
|
|
|
77
|
+
# Panes borrowed from another session are that session's to restore, not
|
|
78
|
+
# ours: persisting one here would cold-start a second shell in its cwd and
|
|
79
|
+
# quietly fork the thing the user was sharing.
|
|
80
|
+
def own_panes
|
|
81
|
+
@window.panes.reject { |p| p.respond_to?(:mirror?) && p.mirror? }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def serialize_pane(pane)
|
|
85
|
+
entry = { "id" => safe_id(pane), "cwd" => safe_cwd(pane), "private" => safe_private(pane) }
|
|
86
|
+
silence = pane.respond_to?(:silence_after) ? pane.silence_after : nil
|
|
87
|
+
entry["silence"] = silence if silence
|
|
88
|
+
entry["name"] = pane.name if pane.respond_to?(:name) && pane.name
|
|
89
|
+
entry
|
|
90
|
+
end
|
|
91
|
+
|
|
75
92
|
def safe_cwd(pane)
|
|
76
93
|
pane.respond_to?(:cwd) ? pane.cwd : nil
|
|
77
94
|
end
|