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/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,38 +480,66 @@ 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",
|
|
489
|
+
" r refresh / redraw (fixes a corrupted pane)",
|
|
398
490
|
" s enter scrollback",
|
|
399
491
|
" ~ / C / P drawer / Claude drawer / toggle private",
|
|
492
|
+
" A share or move a pane from another muxr session",
|
|
400
493
|
" : / ? command prompt / toggle this help",
|
|
401
494
|
" ] / d / q paste buffer / detach / kill session",
|
|
402
495
|
"",
|
|
403
496
|
"PASSTHROUGH mode (keys reach the focused pane; prefix is Ctrl-a)",
|
|
404
497
|
" C-a Esc return to normal mode",
|
|
405
|
-
" C-a c x
|
|
498
|
+
" C-a c / x new / close pane (close asks y/n)",
|
|
406
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",
|
|
407
502
|
" C-a n / p / a next / prev / last pane",
|
|
503
|
+
" C-a r refresh / redraw (fixes a corrupted pane)",
|
|
408
504
|
" C-a [ ] scrollback / paste buffer",
|
|
505
|
+
" C-a A share or move a pane from another muxr session",
|
|
409
506
|
" C-a C-a send literal Ctrl-a to focused pane",
|
|
410
507
|
"",
|
|
411
|
-
"SCROLLBACK mode (
|
|
508
|
+
"SCROLLBACK mode (pane-bound: follows you as you switch panes)",
|
|
412
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)",
|
|
413
512
|
" / search-fwd ? search-back n/N next/prev match",
|
|
513
|
+
" C-a n/p/a/1-9 switch pane, stay in scrollback (each keeps its pos)",
|
|
514
|
+
" i insert here (keeps scroll pos) q/Esc exit to live bottom",
|
|
414
515
|
" cursor: h/j/k/l 0/^/$ w/e/b W/E/B H/M/L g/G",
|
|
415
|
-
" v select, C-v block, y/Enter yank
|
|
516
|
+
" v select, C-v block, y/Enter yank (stays in scrollback)",
|
|
517
|
+
" q/Esc cancel C-a n/p/a/1-9 switch pane",
|
|
416
518
|
"",
|
|
417
|
-
"
|
|
519
|
+
"COMMAND prompt (: to open; Tab completes, Esc/C-c cancels)",
|
|
520
|
+
"Commands: layout {tall|wide|columns|rows|grid|spiral|centered|stack|monocle|auto},",
|
|
418
521
|
" drawer {toggle|show|hide|reset},",
|
|
419
|
-
" 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)",
|
|
420
529
|
"",
|
|
421
530
|
"press any key to dismiss"
|
|
422
531
|
].freeze
|
|
423
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
|
+
|
|
424
538
|
def compose_help(frame, session)
|
|
425
539
|
w = session.width
|
|
426
540
|
h = session.height
|
|
427
|
-
|
|
541
|
+
lines = help_lines
|
|
542
|
+
max_len = lines.map(&:length).max
|
|
428
543
|
box_w = [max_len + 4, w - 4].min
|
|
429
544
|
box_h = [HELP_LINES.length + 2, h - 4].min
|
|
430
545
|
x = (w - box_w) / 2
|
|
@@ -442,7 +557,7 @@ module Muxr
|
|
|
442
557
|
end
|
|
443
558
|
draw_box(frame, rect, border: [:c256, 51], bold_border: true, title: "Help", title_focused: true)
|
|
444
559
|
|
|
445
|
-
|
|
560
|
+
lines.first(box_h - 2).each_with_index do |line, i|
|
|
446
561
|
line[0, box_w - 4].chars.each_with_index do |ch, j|
|
|
447
562
|
c = frame[rect.y + 1 + i][rect.x + 2 + j]
|
|
448
563
|
c.char = ch
|
|
@@ -453,6 +568,75 @@ module Muxr
|
|
|
453
568
|
end
|
|
454
569
|
end
|
|
455
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
|
+
|
|
456
640
|
def draw_box(frame, rect, border:, bold_border:, title: nil, title_focused: false)
|
|
457
641
|
attrs = bold_border ? Terminal::BOLD : 0
|
|
458
642
|
x2 = rect.x + rect.w - 1
|
|
@@ -479,10 +663,16 @@ module Muxr
|
|
|
479
663
|
end
|
|
480
664
|
end
|
|
481
665
|
|
|
482
|
-
|
|
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)
|
|
483
671
|
term = pane.terminal
|
|
484
|
-
|
|
485
|
-
|
|
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
|
|
486
676
|
selection = term.selection_active?
|
|
487
677
|
search = term.search_active?
|
|
488
678
|
rows.times do |r|
|
|
@@ -539,10 +729,23 @@ module Muxr
|
|
|
539
729
|
last_x = nil
|
|
540
730
|
|
|
541
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
|
|
542
739
|
row.each_with_index do |cell, x|
|
|
543
|
-
if same_size && @prev[y][x] == cell
|
|
740
|
+
if same_size && @prev[y][x] == cell && !force_next
|
|
544
741
|
next
|
|
545
742
|
end
|
|
743
|
+
force_next = false
|
|
744
|
+
# The right half of a wide glyph (char "") is painted by its lead
|
|
745
|
+
# cell to the left, which spans both columns in the outer terminal.
|
|
746
|
+
# Emitting anything here would clobber that glyph, so skip it — and
|
|
747
|
+
# leave last_x untouched, since we didn't move the outer cursor.
|
|
748
|
+
next if cell.char.empty?
|
|
546
749
|
if last_y != y || last_x != x
|
|
547
750
|
out << "\e[#{y + 1};#{x + 1}H"
|
|
548
751
|
end
|
|
@@ -559,7 +762,28 @@ module Muxr
|
|
|
559
762
|
end
|
|
560
763
|
out << cell.char
|
|
561
764
|
last_y = y
|
|
562
|
-
|
|
765
|
+
if contiguous_after?(cell.char)
|
|
766
|
+
# Advance by the glyph's display width, not its codepoint count: a
|
|
767
|
+
# wide char ("中") moves the outer cursor two columns though it's
|
|
768
|
+
# one codepoint, and a base+combining cell ("é") moves one though
|
|
769
|
+
# it's two. Keeping last_x in sync with the real cursor lets the
|
|
770
|
+
# next cell skip a redundant CUP.
|
|
771
|
+
last_x = x + Terminal.char_width(cell.char.ord)
|
|
772
|
+
else
|
|
773
|
+
# The outer terminal might advance its cursor by a different number
|
|
774
|
+
# of columns than we think for this glyph — force an absolute
|
|
775
|
+
# reposition for the next cell so the disagreement can't cascade.
|
|
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
|
|
786
|
+
end
|
|
563
787
|
end
|
|
564
788
|
end
|
|
565
789
|
out << "\e]8;;\e\\" if cur_hyperlink
|
|
@@ -573,7 +797,28 @@ module Muxr
|
|
|
573
797
|
@prev_h = frame.length
|
|
574
798
|
end
|
|
575
799
|
|
|
800
|
+
# Whether we can trust the outer terminal's cursor to be exactly one
|
|
801
|
+
# display-width past this glyph, so the next contiguous cell needs no
|
|
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 —
|
|
807
|
+
# CJK, emoji, and East Asian Ambiguous symbols like ·, …, ●, arrows, and
|
|
808
|
+
# the ⏺/✻/❯ glyphs Claude Code's UI is full of — can be drawn two columns
|
|
809
|
+
# wide by some terminals. We can't know which, so we force an absolute
|
|
810
|
+
# reposition after them: a width disagreement then clips a single glyph
|
|
811
|
+
# instead of shifting the whole rest of the line. A base+combining cell
|
|
812
|
+
# (multi-codepoint) is treated the same way out of caution.
|
|
813
|
+
def contiguous_after?(char)
|
|
814
|
+
return false if char.length > 1
|
|
815
|
+
cp = char.ord
|
|
816
|
+
return true if cp < 0x80
|
|
817
|
+
!Terminal.box_wide && Terminal::BOX_RANGES.any? { |r| r.cover?(cp) }
|
|
818
|
+
end
|
|
819
|
+
|
|
576
820
|
def cursor_position(session, input_state:, command_buffer:, search_buffer: "")
|
|
821
|
+
return "\e[?25l" if input_state == :pane_picker
|
|
577
822
|
if input_state == :command
|
|
578
823
|
col = 1 + command_buffer.length + 1 # ':' + buffer
|
|
579
824
|
return "\e[#{session.height};#{col}H\e[?25h"
|
|
@@ -601,40 +846,18 @@ module Muxr
|
|
|
601
846
|
return "\e[#{row};#{col}H\e[?25h"
|
|
602
847
|
end
|
|
603
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?
|
|
604
854
|
row = rect.y + 1 + term.cursor_row + 1
|
|
605
855
|
col = rect.x + 1 + term.cursor_col + 1
|
|
606
856
|
"\e[#{row};#{col}H\e[?25h"
|
|
607
857
|
end
|
|
608
858
|
|
|
609
859
|
def sgr(cell)
|
|
610
|
-
|
|
611
|
-
attrs = cell.attrs.to_i
|
|
612
|
-
parts << "1" if (attrs & Terminal::BOLD) != 0
|
|
613
|
-
parts << "2" if (attrs & Terminal::DIM) != 0
|
|
614
|
-
parts << "4" if (attrs & Terminal::UNDERLINE) != 0
|
|
615
|
-
parts << "7" if (attrs & Terminal::REVERSE) != 0
|
|
616
|
-
append_color(parts, cell.fg, true)
|
|
617
|
-
append_color(parts, cell.bg, false)
|
|
618
|
-
"\e[#{parts.join(';')}m"
|
|
619
|
-
end
|
|
620
|
-
|
|
621
|
-
def append_color(parts, color, fg)
|
|
622
|
-
return if color.nil?
|
|
623
|
-
case color
|
|
624
|
-
when Integer
|
|
625
|
-
if color < 8
|
|
626
|
-
parts << ((fg ? 30 : 40) + color).to_s
|
|
627
|
-
else
|
|
628
|
-
parts << ((fg ? 90 : 100) + (color - 8)).to_s
|
|
629
|
-
end
|
|
630
|
-
when Array
|
|
631
|
-
case color[0]
|
|
632
|
-
when :c256
|
|
633
|
-
parts << "#{fg ? 38 : 48};5;#{color[1]}"
|
|
634
|
-
when :rgb
|
|
635
|
-
parts << "#{fg ? 38 : 48};2;#{color[1]};#{color[2]};#{color[3]}"
|
|
636
|
-
end
|
|
637
|
-
end
|
|
860
|
+
Terminal.sgr(cell)
|
|
638
861
|
end
|
|
639
862
|
end
|
|
640
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
|