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.
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 # blue
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 render(session, input_state: :normal, command_buffer: "", search_buffer: "", search_direction: :forward, message: nil, help: false)
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
- content_area = LayoutManager::Rect.new(0, 0, session.width, session.height - 1)
125
+ area = content_area(session)
126
+ layout = LayoutManager.resolve(win.layout, area)
104
127
  rects = LayoutManager.compute(
105
- win.layout,
128
+ layout,
106
129
  win.panes.length,
107
- content_area,
130
+ area,
108
131
  focused_index: win.focused_index,
109
- master_index: win.master_index
132
+ **win.layout_options
110
133
  )
111
134
 
112
- monocle = win.layout == :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.id}" if pane.respond_to?(:id) && pane.id.is_a?(String)
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) : BORDER_UNFOCUSED,
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.x + 1, rect.y + 1)
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.x + 1, rect.y + 1)
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 "^A"
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.layout}"
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 ^a ? "
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
- overlay = ":#{command_buffer}"
298
- overlay = overlay[0, w]
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 = " SCROLLBACK ↑↓/j/k line d/u half f/b page g/G top/bot / search n/N next/prev v select q quit "
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
- elsif message
372
- msg = " #{message} "
373
- start = [w - msg.length, 0].max
374
- msg.each_char.with_index do |ch, i|
375
- x = start + i
376
- next unless x < w
377
- c = frame[y][x]
378
- c.char = ch
379
- c.fg = [:c256, 15]
380
- c.bg = [:c256, 28]
381
- c.attrs = Terminal::BOLD
382
- end
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 t w g m same as normal-mode bindings",
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
- "Commands: layout {tall|wide|columns|rows|grid|spiral|centered|stack|monocle},",
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
- max_len = HELP_LINES.map(&:length).max
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
- HELP_LINES.first(box_h - 2).each_with_index do |line, i|
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
- def copy_terminal(frame, pane, dst_x, dst_y)
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
- rows = term.rows
490
- cols = term.cols
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 only for glyphs whose width every terminal
601
- # agrees on: ASCII (always one column), and the box-drawing / block-element
602
- # band 0x2500–0x259F (reliably one column, and we emit a lot of them for
603
- # borders, so keeping them contiguous matters). Everything else non-ASCII —
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
- cp < 0x80 || (cp >= 0x2500 && cp <= 0x259F)
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
- parts = ["0"]
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.layout.to_s,
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" => @window.panes.map { |p| { "id" => safe_id(p), "cwd" => safe_cwd(p), "private" => safe_private(p) } },
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