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.
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,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 t w g m same as normal-mode bindings",
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 (exits to the mode you came from)",
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, q/Esc cancel",
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
- "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},",
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
- max_len = HELP_LINES.map(&:length).max
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
- HELP_LINES.first(box_h - 2).each_with_index do |line, i|
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
- 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)
483
671
  term = pane.terminal
484
- rows = term.rows
485
- 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
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
- last_x = x + cell.char.length
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
- parts = ["0"]
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.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