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/terminal.rb CHANGED
@@ -1,3 +1,6 @@
1
+ require_relative "image_store"
2
+ require_relative "history_row"
3
+
1
4
  module Muxr
2
5
  # A minimal VT100/ANSI terminal emulator. It maintains a fixed grid of cells
3
6
  # plus a cursor and parser state. Bytes fed from a PTY are interpreted into
@@ -10,12 +13,188 @@ module Muxr
10
13
  REVERSE = 4
11
14
  DIM = 8
12
15
 
13
- SCROLLBACK_MAX = 5000
14
-
15
- # Cap on the OSC payload we buffer before parsing. URLs in OSC 8 can be
16
- # long but rarely exceed a few hundred bytes; 4 KiB lets the parser stay
17
- # tolerant of weird inputs without giving an attacker an unbounded sink.
18
- OSC_MAX_LEN = 4096
16
+ SCROLLBACK_DEFAULT = 50_000
17
+ SCROLLBACK_BOUNDS = 100..500_000
18
+
19
+ ALT_SCREEN_MODES = [47, 1047, 1049].freeze
20
+ MOUSE_TRACKING_MODES = [1000, 1002, 1003].freeze
21
+ MOUSE_SGR_MODE = 1006
22
+ APP_CURSOR_KEYS_MODE = 1
23
+
24
+ # Codepoint ranges that occupy two display columns (East Asian Wide /
25
+ # Fullwidth per UAX #11, plus the common emoji blocks). A wide glyph is
26
+ # stored in its lead cell with a continuation cell (char "") to its right
27
+ # reserving the second column — see #put_char. Kept as a flat, sorted list
28
+ # of ranges; #char_width only consults it for codepoints >= 0x300, so the
29
+ # ASCII/Latin-1 fast path never pays for the scan.
30
+ WIDE_RANGES = [
31
+ 0x1100..0x115F, # Hangul Jamo
32
+ 0x2329..0x232A, # angle brackets
33
+ 0x2E80..0x303E, # CJK radicals, Kangxi, CJK symbols & punctuation
34
+ 0x3041..0x33FF, # Hiragana … CJK compatibility
35
+ 0x3400..0x4DBF, # CJK Unified Ext A
36
+ 0x4E00..0x9FFF, # CJK Unified Ideographs
37
+ 0xA000..0xA4CF, # Yi
38
+ 0xA960..0xA97F, # Hangul Jamo Ext-A
39
+ 0xAC00..0xD7A3, # Hangul Syllables
40
+ 0xF900..0xFAFF, # CJK Compatibility Ideographs
41
+ 0xFE10..0xFE19, # vertical forms
42
+ 0xFE30..0xFE6F, # CJK compatibility / small form variants
43
+ 0xFF00..0xFF60, # Fullwidth Forms
44
+ 0xFFE0..0xFFE6, # Fullwidth signs
45
+ 0x1B000..0x1B16F, # Kana supplement / extended
46
+ 0x1F300..0x1F64F, # Misc symbols & pictographs, emoticons
47
+ 0x1F680..0x1F6FF, # transport & map symbols
48
+ 0x1F900..0x1F9FF, # supplemental symbols & pictographs
49
+ 0x1FA70..0x1FAFF, # symbols & pictographs extended-A
50
+ 0x20000..0x3FFFD # CJK Unified Ext B and beyond
51
+ ].freeze
52
+
53
+ # Codepoint ranges that occupy zero display columns: combining marks,
54
+ # variation selectors, and zero-width formatting characters. These fold
55
+ # onto the preceding glyph rather than consuming a column (#attach_combining)
56
+ # so the cursor stays aligned with what a real terminal would do.
57
+ ZERO_WIDTH_RANGES = [
58
+ 0x0300..0x036F, # combining diacritical marks
59
+ 0x0483..0x0489, # Cyrillic combining
60
+ 0x0591..0x05BD, 0x05BF..0x05BF, 0x05C1..0x05C2, 0x05C4..0x05C5,
61
+ 0x0610..0x061A, 0x064B..0x065F, 0x0670..0x0670,
62
+ 0x06D6..0x06DC, 0x06DF..0x06E4, 0x06E7..0x06E8, 0x06EA..0x06ED,
63
+ 0x0711..0x0711, 0x0730..0x074A,
64
+ 0x200B..0x200F, # zero-width space/joiner/non-joiner, marks
65
+ 0x2028..0x202E, 0x2060..0x2064,
66
+ 0x20D0..0x20FF, # combining marks for symbols
67
+ 0x1AB0..0x1AFF, 0x1DC0..0x1DFF, # combining extensions
68
+ 0xFE00..0xFE0F, # variation selectors
69
+ 0xFE20..0xFE2F, # combining half marks
70
+ 0xFEFF..0xFEFF, # BOM / zero-width no-break space
71
+ 0xE0100..0xE01EF # variation selectors supplement
72
+ ].freeze
73
+
74
+ # East Asian *Ambiguous* width (UAX #11 class "A"): codepoints that some
75
+ # terminals draw one column wide and others two, depending on the font and
76
+ # the terminal's "ambiguous-as-wide" setting. This is where muxr and the
77
+ # outer terminal most often disagree — and the disagreement is exactly what
78
+ # corrupts in-place animations (Claude Code's ⏺/✻/❯ spinner, ·, …, ●, arrows).
79
+ # We don't hardcode a width for them: #char_width consults `ambiguous_wide`,
80
+ # which the width probe (see WidthProbe / the client handshake) sets to
81
+ # match the actual outer terminal. Curated to the BMP symbol/punctuation
82
+ # ranges that show up in practice rather than the full UAX #11 table.
83
+ AMBIGUOUS_RANGES = [
84
+ 0x00A1..0x00A1, 0x00A4..0x00A4, 0x00A7..0x00A8, 0x00AA..0x00AA,
85
+ 0x00AD..0x00AE, 0x00B0..0x00B4, 0x00B6..0x00BA, 0x00BC..0x00BF,
86
+ 0x00C6..0x00C6, 0x00D0..0x00D0, 0x00D7..0x00D8, 0x00DE..0x00E1,
87
+ 0x00E6..0x00E6, 0x00E8..0x00EA, 0x00EC..0x00ED, 0x00F0..0x00F0,
88
+ 0x00F2..0x00F3, 0x00F7..0x00FA, 0x00FC..0x00FC, 0x00FE..0x00FE,
89
+ 0x2010..0x2010, 0x2013..0x2016, 0x2018..0x2019, 0x201C..0x201D,
90
+ 0x2020..0x2022, 0x2024..0x2027, 0x2030..0x2030, 0x2032..0x2033,
91
+ 0x2035..0x2035, 0x203B..0x203B, 0x203E..0x203E, 0x2074..0x2074,
92
+ 0x207F..0x207F, 0x2081..0x2084, 0x20AC..0x20AC, 0x2103..0x2103,
93
+ 0x2105..0x2105, 0x2109..0x2109, 0x2113..0x2113, 0x2116..0x2116,
94
+ 0x2121..0x2122, 0x2126..0x2126, 0x212B..0x212B, 0x2153..0x2154,
95
+ 0x215B..0x215E, 0x2160..0x216B, 0x2170..0x2179, 0x2189..0x2189,
96
+ 0x2190..0x2199, 0x21B8..0x21B9, 0x21D2..0x21D2, 0x21D4..0x21D4,
97
+ 0x21E7..0x21E7, 0x2200..0x2200, 0x2202..0x2203, 0x2207..0x2208,
98
+ 0x220B..0x220B, 0x220F..0x220F, 0x2211..0x2211, 0x2215..0x2215,
99
+ 0x221A..0x221A, 0x221D..0x2220, 0x2223..0x2223, 0x2225..0x2225,
100
+ 0x2227..0x222C, 0x222E..0x222E, 0x2234..0x2237, 0x223C..0x223D,
101
+ 0x2248..0x2248, 0x224C..0x224C, 0x2252..0x2252, 0x2260..0x2261,
102
+ 0x2264..0x2267, 0x226A..0x226B, 0x226E..0x226F, 0x2282..0x2283,
103
+ 0x2286..0x2287, 0x2295..0x2295, 0x2299..0x2299, 0x22A5..0x22A5,
104
+ 0x22BF..0x22BF, 0x2312..0x2312, 0x2460..0x24E9, 0x24EB..0x24FF,
105
+ # NOTE: the box-drawing / block-element band 0x2500-0x259F is excluded here
106
+ # because it gets its own measured class — see BOX_RANGES / box_wide.
107
+ # Geometric shapes (0x25A0+) are fair game.
108
+ 0x25A0..0x25A1,
109
+ 0x25A3..0x25A9, 0x25B2..0x25B3, 0x25B6..0x25B7, 0x25BC..0x25BD,
110
+ 0x25C0..0x25C1, 0x25C6..0x25C8, 0x25CB..0x25CB, 0x25CE..0x25D1,
111
+ 0x25E2..0x25E5, 0x25EF..0x25EF, 0x2605..0x2606, 0x2609..0x2609,
112
+ 0x260E..0x260F, 0x261C..0x261C, 0x261E..0x261E, 0x2640..0x2640,
113
+ 0x2642..0x2642, 0x2660..0x2661, 0x2663..0x2665, 0x2667..0x266A,
114
+ 0x266C..0x266D, 0x266F..0x266F, 0x269E..0x269F, 0x26BF..0x26BF,
115
+ 0x26C6..0x26CD, 0x26CF..0x26D3, 0x26D5..0x26E1, 0x273D..0x273D,
116
+ 0x2776..0x277F, 0xFFFD..0xFFFD
117
+ ].freeze
118
+
119
+ # Box-drawing and block elements. Much of this band is formally East Asian
120
+ # Ambiguous, so a terminal set to draw ambiguous glyphs wide draws these two
121
+ # columns wide too — but plenty of terminals special-case the band and keep
122
+ # it narrow even then, so it can't ride on `ambiguous_wide`. It gets its own
123
+ # probed verdict (`box_wide`) instead of a guess, because Renderer's
124
+ # contiguity shortcut trusts this band and a wrong guess here shifts every
125
+ # subsequent cell on the line: it's the band TUI borders are built from, and
126
+ # Claude Code's UI is mostly borders.
127
+ BOX_RANGES = [0x2500..0x259F].freeze
128
+
129
+ class << self
130
+ # Whether the outer terminal draws East Asian Ambiguous glyphs two columns
131
+ # wide. Set per attach by the width probe (default narrow, matching most
132
+ # modern terminals). Covers the long tail of ambiguous glyphs the probe
133
+ # doesn't sample individually.
134
+ attr_accessor :ambiguous_wide
135
+ # Whether the outer terminal draws the box-drawing / block band two columns
136
+ # wide. Set per attach by the width probe; default narrow, which is what
137
+ # nearly every terminal does.
138
+ attr_accessor :box_wide
139
+ # Exact per-codepoint widths the probe measured against the live terminal,
140
+ # cp => 1|2. These WIN over every heuristic below, because a direct
141
+ # measurement is ground truth. This is what catches glyphs whose width no
142
+ # class captures — Claude Code's ⏺/✻/❯, which a font may draw two columns
143
+ # wide via emoji presentation even when the terminal's ambiguous setting
144
+ # is narrow (the exact case the class toggle alone would miss).
145
+ attr_reader :width_overrides
146
+ attr_reader :scrollback_max
147
+ end
148
+ # Both are process-global: the server hosts exactly one session / outer
149
+ # terminal at a time, and both #char_width call sites (the emulator and the
150
+ # Renderer) must agree.
151
+ self.ambiguous_wide = false
152
+ self.box_wide = false
153
+ @width_overrides = {}
154
+
155
+ def self.width_overrides=(map)
156
+ @width_overrides = map || {}
157
+ end
158
+
159
+ def self.scrollback_max=(rows)
160
+ n = rows.to_i
161
+ @scrollback_max = n.positive? ? n.clamp(SCROLLBACK_BOUNDS) : SCROLLBACK_DEFAULT
162
+ end
163
+ self.scrollback_max = ENV["MUXR_SCROLLBACK"]
164
+
165
+ # Display width of a codepoint in terminal columns: 0 (combining /
166
+ # zero-width), 2 (East Asian wide / emoji, plus anything the probe measured
167
+ # or Ambiguous when the probed terminal draws those wide), or 1 (everything
168
+ # else). The Renderer uses this to advance its emit cursor by the right
169
+ # number of columns; #put_char uses it to lay glyphs into the grid.
170
+ def self.char_width(cp)
171
+ return 1 if cp < 0x0300
172
+ return 0 if ZERO_WIDTH_RANGES.any? { |r| r.cover?(cp) }
173
+ ov = @width_overrides[cp]
174
+ return ov if ov
175
+ return 2 if WIDE_RANGES.any? { |r| r.cover?(cp) }
176
+ return 2 if @ambiguous_wide && AMBIGUOUS_RANGES.any? { |r| r.cover?(cp) }
177
+ return 2 if @box_wide && BOX_RANGES.any? { |r| r.cover?(cp) }
178
+ 1
179
+ end
180
+
181
+ # Cap on the OSC payload we buffer before parsing. URLs in OSC 8 rarely
182
+ # exceed a few hundred bytes, but OSC 52 clipboard writes (a vim yank, etc.)
183
+ # carry the base64 of whatever the inner program copied, so the cap doubles
184
+ # as the practical clipboard size limit. 100 KiB stays bounded per parser
185
+ # (it's freed on finalize) while comfortably fitting normal yanks; a payload
186
+ # longer than this is truncated rather than copied wholesale.
187
+ OSC_MAX_LEN = 100 * 1024
188
+
189
+ # Cap on the outbound notification queue (bell + notification OSCs). While a
190
+ # client is attached the Application drains it every read, so it stays tiny;
191
+ # the cap only matters for a detached session, where nobody is listening and
192
+ # a noisy inner program would otherwise grow it without bound.
193
+ NOTIFY_MAX = 64 * 1024
194
+
195
+ GRAPHICS_MAX_LEN = 8 * 1024 * 1024
196
+
197
+ IMAGE_LINK_PREFIX = "8;id=muxr-img-"
19
198
 
20
199
  # Match plain-text URLs the inner program printed without wrapping them
21
200
  # in OSC 8. We stamp the matching cells with a synthetic hyperlink so the
@@ -41,9 +220,18 @@ module Muxr
41
220
  # program (which left ?2026h open) cannot wedge the pane indefinitely.
42
221
  SYNC_TIMEOUT = 0.2
43
222
 
223
+ BLANK_CHAR = " ".freeze
224
+ CONTINUATION_CHAR = "".freeze
225
+ ASCII_CHARS = (0...128).map { |cp| cp.chr.freeze }.freeze
226
+
227
+ def self.intern_char(ch)
228
+ return CONTINUATION_CHAR if ch.empty?
229
+ ch.bytesize == 1 ? ASCII_CHARS[ch.getbyte(0)] : ch
230
+ end
231
+
44
232
  Cell = Struct.new(:char, :fg, :bg, :attrs, :hyperlink) do
45
233
  def reset!
46
- self.char = " "
234
+ self.char = BLANK_CHAR
47
235
  self.fg = nil
48
236
  self.bg = nil
49
237
  self.attrs = 0
@@ -61,9 +249,12 @@ module Muxr
61
249
 
62
250
  attr_reader :rows, :cols, :cursor_row, :cursor_col, :view_offset
63
251
 
64
- def initialize(rows: 24, cols: 80)
252
+ def initialize(rows: 24, cols: 80, image_store: nil)
65
253
  @rows = rows
66
254
  @cols = cols
255
+ @image_store = image_store || ImageStore.new
256
+ @parser_apc = +""
257
+ @graphics_pending = nil
67
258
  @buffer = Array.new(rows) { Array.new(cols) { blank_cell } }
68
259
  @cursor_row = 0
69
260
  @cursor_col = 0
@@ -74,6 +265,7 @@ module Muxr
74
265
  @autowrap_pending = false
75
266
  @scroll_top = 0
76
267
  @scroll_bottom = rows - 1
268
+ @saved_primary = nil
77
269
  @parser_state = :ground
78
270
  @parser_params = +""
79
271
  @parser_osc = +""
@@ -96,7 +288,38 @@ module Muxr
96
288
  @selection_mode = :linear
97
289
  @sync_pending = false
98
290
  @sync_started_at = nil
291
+ # True once the inner program enables bracketed-paste mode (DECSET
292
+ # 2004). The Application consults this to decide whether to forward the
293
+ # \e[200~…\e[201~ paste markers the outer terminal wraps around a paste
294
+ # or strip them — see Application#send_to_focused.
295
+ @bracketed_paste = false
296
+ @mouse_tracking = []
297
+ @mouse_sgr = false
298
+ @app_cursor_keys = false
299
+ @selection_screen_only = false
300
+ # Whether the inner program wants its cursor shown (DECTCEM, DEC private
301
+ # mode 25). Claude Code and other Ink UIs hide the cursor (\e[?25l) for
302
+ # the whole render and only show it (\e[?25h) at a text-input prompt —
303
+ # the Renderer consults this so we don't paint a stray block cursor on
304
+ # top of an animating progress line.
305
+ @cursor_visible = true
99
306
  @pending_replies = +"".b
307
+ # Out-of-band bytes the emulator owes the OUTER terminal (not the inner
308
+ # program): the bell and desktop-notification OSCs (OSC 9, OSC 777) that
309
+ # Claude Code emits to get your attention. We don't model these on the
310
+ # grid — they're forwarded verbatim so the user's real terminal rings /
311
+ # raises the notification, even when the emitting pane isn't focused.
312
+ # The Application drains this in consume_pane_io. Capped so a detached
313
+ # session (nobody to forward to) can't grow it without bound.
314
+ @pending_notifications = +"".b
315
+ # The most recent clipboard write the inner program requested via OSC 52
316
+ # (`\e]52;c;<base64>\a` — what vim/tmux/Ghostty emit on a "copy to system
317
+ # clipboard" yank), already base64-decoded to raw bytes. Not grid state;
318
+ # the Application drains it in consume_pane_io and pipes it to pbcopy.
319
+ # Last-write-wins (a clipboard holds one value) so this can't grow without
320
+ # bound, and it's drained even while detached since pbcopy is local to the
321
+ # server host and doesn't need an attached client.
322
+ @pending_clipboard = nil
100
323
  @search_query = nil
101
324
  @search_direction = :forward
102
325
  @search_matches = []
@@ -134,6 +357,58 @@ module Muxr
134
357
  @sync_started_at + SYNC_TIMEOUT
135
358
  end
136
359
 
360
+ # True iff the inner program has enabled bracketed-paste mode (DECSET
361
+ # 2004). When false, the Application strips paste markers before writing
362
+ # so a program that doesn't speak bracketed paste never prints a literal
363
+ # "^[[200~".
364
+ def bracketed_paste?
365
+ @bracketed_paste
366
+ end
367
+
368
+ # True iff the inner program currently wants its cursor shown (DEC private
369
+ # mode 25, DECTCEM). The Renderer suppresses the focused pane's cursor when
370
+ # this is false so a hidden-cursor UI (Claude Code mid-render, a spinner)
371
+ # doesn't get a phantom block painted at its last write position.
372
+ def cursor_visible?
373
+ @cursor_visible
374
+ end
375
+
376
+ def alt_screen?
377
+ !@saved_primary.nil?
378
+ end
379
+
380
+ def mouse_tracking?
381
+ !@mouse_tracking.empty?
382
+ end
383
+
384
+ def mouse_encoding
385
+ @mouse_sgr ? :sgr : :x10
386
+ end
387
+
388
+ def app_cursor_keys?
389
+ @app_cursor_keys
390
+ end
391
+
392
+ # Bytes the emulator owes the OUTER terminal: bell + desktop-notification
393
+ # OSCs the inner program emitted. The Application drains this after each
394
+ # read and forwards it to the attached client so the user's real terminal
395
+ # rings / notifies. Returns nil when empty (mirrors take_pending_replies!).
396
+ def take_pending_notifications!
397
+ return nil if @pending_notifications.empty?
398
+ data = @pending_notifications
399
+ @pending_notifications = +"".b
400
+ data
401
+ end
402
+
403
+ # The latest OSC 52 clipboard write (raw, already base64-decoded), or nil if
404
+ # the inner program hasn't asked to set the clipboard since the last drain.
405
+ # The Application pulls this in consume_pane_io and hands it to pbcopy.
406
+ def take_pending_clipboard!
407
+ data = @pending_clipboard
408
+ @pending_clipboard = nil
409
+ data
410
+ end
411
+
137
412
  attr_reader :selection_mode
138
413
 
139
414
  def cell(r, c)
@@ -154,6 +429,192 @@ module Muxr
154
429
  lines.join("\n")
155
430
  end
156
431
 
432
+ def dump_history_text
433
+ screen = @saved_primary ? @saved_primary[:buffer] : @buffer
434
+ lines = (@scrollback + screen).map { |row| row_search_text(row)[0].rstrip }
435
+ lines.pop while lines.last&.empty?
436
+ lines.empty? ? "" : lines.join("\n") << "\n"
437
+ end
438
+
439
+ # Serialize the live grid as a self-contained ANSI repaint: feeding these
440
+ # bytes to a blank emulator of the same size reproduces this screen exactly.
441
+ # Seeds a mirrored copy of the pane in another muxr server, which then keeps
442
+ # up from the raw PTY byte stream alone.
443
+ def dump_ansi
444
+ out = +""
445
+ out << primary_repaint if @saved_primary
446
+ out << "\e[0m\e[H\e[2J"
447
+ emit_grid(out, @buffer)
448
+ out << dump_modes
449
+ out << "\e[#{@cursor_row + 1};#{@cursor_col + 1}H"
450
+ out << (@cursor_visible ? "\e[?25h" : "\e[?25l")
451
+ out << self.class.sgr(Cell.new(" ", @fg, @bg, @attrs, nil))
452
+ out
453
+ end
454
+
455
+ # Modes a repaint has to carry alongside the cells. The scroll region is
456
+ # the load-bearing one: a full-screen program sets DECSTBM once and then
457
+ # relies on plain index/reverse-index to scroll only that band, so a copy
458
+ # of this screen that missed it scrolls the whole display instead.
459
+ # DECSTBM homes the cursor, hence emitting this before the final position.
460
+ def dump_modes
461
+ out = +""
462
+ unless @scroll_top.zero? && @scroll_bottom == @rows - 1
463
+ out << "\e[#{@scroll_top + 1};#{@scroll_bottom + 1}r"
464
+ end
465
+ out << "\e[?2004h" if @bracketed_paste
466
+ out << "\e[?1h" if @app_cursor_keys
467
+ @mouse_tracking.each { |mode| out << "\e[?#{mode}h" }
468
+ out << "\e[?#{MOUSE_SGR_MODE}h" if @mouse_sgr
469
+ out
470
+ end
471
+
472
+ # Everything a blank emulator needs to become this one, including the
473
+ # history above the screen. Only used when a pane moves between servers —
474
+ # dropping scrollback there would put exactly the kind of hole in the
475
+ # timeline that #resize goes out of its way to avoid.
476
+ def dump_transfer
477
+ {
478
+ "rows" => @rows,
479
+ "cols" => @cols,
480
+ "screen" => dump_ansi,
481
+ "scrollback" => @scrollback.map { |row| row_ansi(row) }
482
+ }
483
+ end
484
+
485
+ def restore_transfer!(state)
486
+ restore_scrollback!(state["scrollback"])
487
+ feed(state["screen"].to_s)
488
+ end
489
+
490
+ # Rebuild the scrollback ring by replaying each row through a one-row
491
+ # emulator, which keeps the cell attributes the ANSI encoding carries
492
+ # without duplicating the parser here.
493
+ def restore_scrollback!(lines)
494
+ return if lines.nil? || lines.empty?
495
+ scratch = self.class.new(rows: 1, cols: @cols)
496
+ @scrollback = lines.last(self.class.scrollback_max).map do |line|
497
+ scratch.feed("\e[0m\e[H\e[2K")
498
+ scratch.feed(line.to_s)
499
+ pack_row(Array.new(@cols) { |c| scratch.cell(0, c) })
500
+ end
501
+ @view_offset = 0
502
+ end
503
+
504
+ def row_ansi(row)
505
+ return row.to_ansi if row.is_a?(HistoryRow)
506
+ last = last_significant_column(row)
507
+ return "" if last.nil?
508
+ out = +""
509
+ _sgr, link = emit_row(out, row, last, nil, nil)
510
+ out << "\e]8;;\e\\" if link
511
+ out
512
+ end
513
+
514
+ def emit_grid(out, buffer)
515
+ sgr = nil
516
+ link = nil
517
+ buffer.each_with_index do |row, r|
518
+ last = last_significant_column(row)
519
+ next if last.nil?
520
+ out << "\e[#{r + 1};1H"
521
+ sgr, link = emit_row(out, row, last, sgr, link)
522
+ end
523
+ out << "\e]8;;\e\\" if link
524
+ out << "\e[0m"
525
+ end
526
+
527
+ def primary_repaint
528
+ out = +"\e[0m\e[H\e[2J"
529
+ emit_grid(out, @saved_primary[:buffer])
530
+ row, col = @saved_primary[:cursor]
531
+ out << "\e[#{row + 1};#{col + 1}H\e[?1049h"
532
+ out
533
+ end
534
+
535
+ # Append cells 0..last of +row+ to +out+, carrying the caller's current SGR
536
+ # and hyperlink so a multi-row dump only re-states them when they change.
537
+ # Returns the state it left the stream in.
538
+ def emit_row(out, row, last, sgr, link)
539
+ skip = 0
540
+ (0..last).each do |c|
541
+ if skip.positive?
542
+ skip -= 1
543
+ next
544
+ end
545
+ cell = row[c] || blank_cell
546
+ char = cell.char.to_s
547
+ if (s = self.class.sgr(cell)) != sgr
548
+ out << s
549
+ sgr = s
550
+ end
551
+ if cell.hyperlink != link
552
+ out << "\e]8;;\e\\" if link
553
+ out << "\e]#{cell.hyperlink}\e\\" if cell.hyperlink
554
+ link = cell.hyperlink
555
+ end
556
+ if char.empty?
557
+ out << " "
558
+ else
559
+ out << char
560
+ skip = 1 if self.class.char_width(char.codepoints.first) == 2
561
+ end
562
+ end
563
+ [sgr, link]
564
+ end
565
+
566
+ # Rightmost cell in +row+ that differs from a freshly-erased one, or nil
567
+ # when the whole row is blank. A dump opens with \e[2J, so blank tails
568
+ # (the common case) cost nothing on the wire.
569
+ def last_significant_column(row)
570
+ (row.length - 1).downto(0) do |c|
571
+ cell = row[c]
572
+ next if cell.nil?
573
+ next if cell.fg.nil? && cell.bg.nil? && cell.attrs.to_i.zero? &&
574
+ cell.hyperlink.nil? && (cell.char == " " || cell.char == "")
575
+ return c
576
+ end
577
+ nil
578
+ end
579
+
580
+ # SGR prelude that puts a terminal into the cell's colors and attributes.
581
+ # Shared with the Renderer's diff-emit so a snapshot and an incremental
582
+ # frame describe the same cell identically.
583
+ def self.sgr(cell)
584
+ sgr_for(cell.fg, cell.bg, cell.attrs)
585
+ end
586
+
587
+ def self.sgr_for(fg, bg, attrs)
588
+ attrs = attrs.to_i
589
+ parts = ["0"]
590
+ parts << "1" if (attrs & BOLD) != 0
591
+ parts << "2" if (attrs & DIM) != 0
592
+ parts << "4" if (attrs & UNDERLINE) != 0
593
+ parts << "7" if (attrs & REVERSE) != 0
594
+ append_color(parts, fg, true)
595
+ append_color(parts, bg, false)
596
+ "\e[#{parts.join(';')}m"
597
+ end
598
+
599
+ def self.append_color(parts, color, fg)
600
+ return if color.nil?
601
+ case color
602
+ when Integer
603
+ if color < 8
604
+ parts << ((fg ? 30 : 40) + color).to_s
605
+ else
606
+ parts << ((fg ? 90 : 100) + (color - 8)).to_s
607
+ end
608
+ when Array
609
+ case color[0]
610
+ when :c256
611
+ parts << "#{fg ? 38 : 48};5;#{color[1]}"
612
+ when :rgb
613
+ parts << "#{fg ? 38 : 48};2;#{color[1]};#{color[2]};#{color[3]}"
614
+ end
615
+ end
616
+ end
617
+
157
618
  # Returns the Cell that should be visible at (r, c) given the current
158
619
  # scrollback view_offset. When view_offset == 0 this is the live grid.
159
620
  # When view_offset > 0, rows in the top of the visible area are sourced
@@ -163,8 +624,7 @@ module Muxr
163
624
  idx = @scrollback.size - @view_offset + r
164
625
  if idx < @scrollback.size
165
626
  row = @scrollback[idx]
166
- return blank_cell if row.nil? || c >= row.length
167
- row[c]
627
+ (row && row[c]) || blank_cell
168
628
  else
169
629
  @buffer[idx - @scrollback.size][c]
170
630
  end
@@ -206,10 +666,23 @@ module Muxr
206
666
  !@selection_anchor.nil?
207
667
  end
208
668
 
669
+ def confine_selection_to_screen!(confined)
670
+ @selection_screen_only = confined
671
+ set_view_offset(0) if confined
672
+ end
673
+
674
+ def selection_confined_to_screen?
675
+ @selection_screen_only
676
+ end
677
+
678
+ def selection_floor
679
+ @selection_screen_only ? @scrollback.size : 0
680
+ end
681
+
209
682
  # Place the moving cursor at a viewport position without dropping an
210
683
  # anchor — the user is still navigating, not yet selecting.
211
684
  def place_selection_cursor(r, c)
212
- tr = timeline_row_for_visible(r).clamp(0, timeline_size - 1)
685
+ tr = timeline_row_for_visible(r).clamp(selection_floor, timeline_size - 1)
213
686
  tc = c.clamp(0, @cols - 1)
214
687
  @selection_cursor = [tr, tc]
215
688
  @selection_anchor = nil
@@ -243,7 +716,7 @@ module Muxr
243
716
  def move_selection_cursor_by(dr, dc)
244
717
  return unless @selection_cursor
245
718
  tr, tc = @selection_cursor
246
- ntr = (tr + dr).clamp(0, timeline_size - 1)
719
+ ntr = (tr + dr).clamp(selection_floor, timeline_size - 1)
247
720
  ntc = (tc + dc).clamp(0, @cols - 1)
248
721
  return if ntr == tr && ntc == tc
249
722
  @selection_cursor = [ntr, ntc]
@@ -253,7 +726,7 @@ module Muxr
253
726
 
254
727
  def selection_cursor_to(tr, tc)
255
728
  return unless @selection_cursor
256
- ntr = tr.clamp(0, timeline_size - 1)
729
+ ntr = tr.clamp(selection_floor, timeline_size - 1)
257
730
  ntc = tc.clamp(0, @cols - 1)
258
731
  @selection_cursor = [ntr, ntc]
259
732
  ensure_selection_cursor_visible
@@ -294,7 +767,7 @@ module Muxr
294
767
  when :bottom then @rows - 1
295
768
  end
296
769
  return if vr.nil?
297
- tr = timeline_row_for_visible(vr).clamp(0, timeline_size - 1)
770
+ tr = timeline_row_for_visible(vr).clamp(selection_floor, timeline_size - 1)
298
771
  selection_cursor_to(tr, first_non_blank_col(tr))
299
772
  end
300
773
 
@@ -511,16 +984,15 @@ module Muxr
511
984
 
512
985
  def resize(rows, cols)
513
986
  return if rows == @rows && cols == @cols
514
- new_buf = Array.new(rows) { Array.new(cols) { blank_cell } }
515
- keep_rows = [rows, @rows].min
516
- keep_cols = [cols, @cols].min
517
- src_start = @rows - keep_rows
518
- keep_rows.times do |i|
519
- keep_cols.times do |j|
520
- new_buf[i][j].copy_from(@buffer[src_start + i][j])
521
- end
987
+ was = [@rows, @cols]
988
+ shape = [rows, cols]
989
+ if (primary = @saved_primary)
990
+ primary[:buffer] = refit(primary[:buffer], was, shape, history: true)
991
+ primary[:cursor] = clamp_within(primary[:cursor], shape)
992
+ primary[:saved_cursor] = clamp_within(primary[:saved_cursor], shape)
993
+ primary[:scroll] = [0, rows - 1]
522
994
  end
523
- @buffer = new_buf
995
+ @buffer = refit(@buffer, was, shape, history: primary.nil?)
524
996
  @rows = rows
525
997
  @cols = cols
526
998
  @scroll_top = 0
@@ -540,18 +1012,18 @@ module Muxr
540
1012
  @feed_remainder = +"".b
541
1013
  str = bytes.dup.force_encoding(Encoding::UTF_8)
542
1014
  unless str.valid_encoding?
543
- # Find the longest valid UTF-8 prefix and stash the remainder for the
544
- # next feed call so multi-byte characters don't get garbled across PTY
545
- # read boundaries.
546
- raw = bytes.bytes
547
- while raw.any?
548
- candidate = raw.pack("C*").force_encoding(Encoding::UTF_8)
549
- break if candidate.valid_encoding?
550
- @feed_remainder = ([raw.last] + @feed_remainder.bytes).pack("C*").b
551
- raw.pop
1015
+ # Hold back only a genuinely truncated multi-byte character at the very
1016
+ # end, so a glyph split across PTY read boundaries still joins up. Any
1017
+ # other malformed byte is substituted rather than deferred: deferring it
1018
+ # would prepend the same bad byte to every later feed, so the stream
1019
+ # could never become valid and the pane would stall forever.
1020
+ hold = incomplete_tail_length(bytes)
1021
+ if hold.positive?
1022
+ @feed_remainder = bytes.byteslice(bytes.bytesize - hold, hold).b
1023
+ bytes = bytes.byteslice(0, bytes.bytesize - hold)
552
1024
  end
553
- str = raw.pack("C*").force_encoding(Encoding::UTF_8)
554
- # Bail out completely if we couldn't decode anything yet.
1025
+ str = bytes.dup.force_encoding(Encoding::UTF_8)
1026
+ str = str.scrub("�") unless str.valid_encoding?
555
1027
  return if str.empty?
556
1028
  end
557
1029
  str.each_char { |c| process_char(c) }
@@ -559,6 +1031,24 @@ module Muxr
559
1031
  @dirty = true
560
1032
  end
561
1033
 
1034
+ # Byte length of an incomplete UTF-8 character at the end of +bytes+, or 0
1035
+ # when the tail isn't a truncated character. A UTF-8 character is at most 4
1036
+ # bytes, so only the last 3 can be waiting on continuation bytes.
1037
+ def incomplete_tail_length(bytes)
1038
+ max = [3, bytes.bytesize].min
1039
+ (1..max).each do |back|
1040
+ b = bytes.getbyte(bytes.bytesize - back)
1041
+ return 0 if b < 0x80
1042
+ next if b < 0xc0
1043
+ needed = if b >= 0xf0 then 4
1044
+ elsif b >= 0xe0 then 3
1045
+ else 2
1046
+ end
1047
+ return needed > back ? back : 0
1048
+ end
1049
+ 0
1050
+ end
1051
+
562
1052
  # Walk the buffer (plus the last scrollback row so wraps across the
563
1053
  # scrollback boundary still join), find plain-text URLs, and stamp the
564
1054
  # covering cells with an OSC 8 hyperlink carrying an `id=` parameter.
@@ -567,8 +1057,9 @@ module Muxr
567
1057
  # a wrapped URL like https://very.long.example.com/path-that-wraps would
568
1058
  # be detected as two truncated URLs on consecutive lines.
569
1059
  def detect_urls!
1060
+ history = @scrollback.last if @saved_primary.nil? && @scrollback.any?
570
1061
  rows = []
571
- rows << @scrollback.last if @scrollback.any?
1062
+ rows << history if history
572
1063
  rows.concat(@buffer)
573
1064
 
574
1065
  rows.each do |row|
@@ -578,12 +1069,20 @@ module Muxr
578
1069
  end
579
1070
  end
580
1071
 
1072
+ # Build the scan text alongside a codepoint→cell map. A wide
1073
+ # continuation half (char "") contributes no codepoints, and a
1074
+ # base+combining cell contributes more than one, so we can't assume the
1075
+ # old 1:1 cell↔codepoint indexing — map every codepoint back to its
1076
+ # source cell instead. URLs are ASCII, but a wide glyph earlier on the
1077
+ # line would otherwise shift every later offset off its cell.
581
1078
  text = String.new(capacity: rows.length * @cols)
582
1079
  cells = []
583
1080
  rows.each do |row|
584
1081
  row.each do |cell|
585
- text << cell.char
586
- cells << cell
1082
+ ch = cell.char
1083
+ next if ch.empty?
1084
+ ch.each_char { cells << cell }
1085
+ text << ch
587
1086
  end
588
1087
  end
589
1088
 
@@ -607,22 +1106,49 @@ module Muxr
607
1106
 
608
1107
  pos = end_off
609
1108
  end
1109
+
1110
+ history&.repack!
610
1111
  end
611
1112
 
612
1113
  private
613
1114
 
614
1115
  def blank_cell
615
- Cell.new(" ", nil, nil, 0, nil)
1116
+ Cell.new(BLANK_CHAR, nil, nil, 0, nil)
616
1117
  end
617
1118
 
618
- # Parse the just-completed OSC payload. We only care about OSC 8
619
- # (hyperlinks): `8;params;URI`. An empty URI closes the active link.
1119
+ # Queue bytes for the outer terminal (bell / notification OSC). Dropped once
1120
+ # the buffer is full so a detached, never-drained session can't grow without
1121
+ # bound — see NOTIFY_MAX.
1122
+ def queue_notification(bytes)
1123
+ return if @pending_notifications.bytesize >= NOTIFY_MAX
1124
+ @pending_notifications << bytes
1125
+ end
1126
+
1127
+ # Parse the just-completed OSC payload. We care about three families:
1128
+ # OSC 8 (hyperlinks): `8;params;URI` — modeled on the grid (below).
1129
+ # OSC 52 (clipboard): `52;<targets>;<base64>` — decoded and queued for
1130
+ # pbcopy (a vim yank with OSC 52 enabled lands here).
1131
+ # OSC 9 / OSC 777 (desktop notifications) — not grid state; forwarded to
1132
+ # the outer terminal verbatim so the user's real terminal raises the
1133
+ # notification. Claude Code emits these (alongside the bell) when it
1134
+ # wants your attention.
620
1135
  # Anything else (window-title OSC 0/1/2, palette OSC 4, …) is silently
621
1136
  # consumed — the emulator doesn't model it.
622
1137
  def finalize_osc
623
1138
  payload = @parser_osc
624
1139
  @parser_osc = +""
625
1140
  return if payload.empty?
1141
+ if payload.start_with?("9;", "777;")
1142
+ # Re-wrap with a BEL terminator (universally accepted) — the original
1143
+ # ST/BEL was consumed by the parser. The OUTPUT path carries raw bytes,
1144
+ # so this reaches the outer terminal unchanged.
1145
+ queue_notification("\e]#{payload}\a")
1146
+ return
1147
+ end
1148
+ if payload.start_with?("52;")
1149
+ finalize_clipboard(payload)
1150
+ return
1151
+ end
626
1152
  return unless payload.start_with?("8;")
627
1153
  parts = payload.split(";", 3)
628
1154
  uri = parts[2]
@@ -633,6 +1159,107 @@ module Muxr
633
1159
  end
634
1160
  end
635
1161
 
1162
+ # Decode an OSC 52 clipboard write and stash it for the Application to pipe
1163
+ # to pbcopy. The payload is `52;<targets>;<base64>`; <targets> selects which
1164
+ # X-style clipboard ("c" clipboard, "p" primary, …) and is irrelevant here
1165
+ # since the host has one system clipboard. A base64 of "?" is a *query*
1166
+ # ("tell me the clipboard"), and an empty body is a clear request — we honor
1167
+ # neither (we can't safely read the host clipboard, and silently wiping it on
1168
+ # an inner program's whim would be surprising), so both are dropped.
1169
+ def finalize_clipboard(payload)
1170
+ b64 = payload.split(";", 3)[2]
1171
+ return if b64.nil? || b64.empty? || b64 == "?"
1172
+ decoded = b64.unpack1("m")
1173
+ @pending_clipboard = decoded unless decoded.nil? || decoded.empty?
1174
+ end
1175
+
1176
+ def finalize_apc
1177
+ payload = @parser_apc
1178
+ @parser_apc = +""
1179
+ return unless payload.start_with?("G")
1180
+ control, _, data = payload[1..].partition(";")
1181
+ opts = parse_graphics_keys(control)
1182
+ return reply_graphics_query(opts) if opts["a"] == "q"
1183
+ if @graphics_pending
1184
+ @graphics_pending[:data] << data
1185
+ else
1186
+ @graphics_pending = { opts: opts, data: +data }
1187
+ end
1188
+ return if opts["m"] == "1"
1189
+ image = @graphics_pending
1190
+ @graphics_pending = nil
1191
+ save_graphics(image)
1192
+ end
1193
+
1194
+ def parse_graphics_keys(control)
1195
+ control.split(",").each_with_object({}) do |pair, acc|
1196
+ key, _, value = pair.partition("=")
1197
+ acc[key] = value unless key.empty?
1198
+ end
1199
+ end
1200
+
1201
+ def reply_graphics_query(opts)
1202
+ ids = %w[i I].filter_map { |k| "#{k}=#{opts[k]}" if opts[k] }
1203
+ ids << "i=0" if ids.empty?
1204
+ @pending_replies << "\e_G#{ids.join(',')};OK\e\\"
1205
+ end
1206
+
1207
+ def save_graphics(image)
1208
+ bytes = graphics_bytes(image[:opts], image[:data])
1209
+ return if bytes.nil? || bytes.empty?
1210
+ announce_image(@image_store.write(bytes), bytes)
1211
+ rescue SystemCallError
1212
+ write_announcement("[image: could not be saved]", nil)
1213
+ end
1214
+
1215
+ def graphics_bytes(opts, data)
1216
+ raw = decode_graphics_data(opts, data)
1217
+ return nil if raw.nil? || raw.empty?
1218
+ case opts["f"] || "32"
1219
+ when "100" then raw
1220
+ when "24" then ImageStore.encode_png(raw, opts["s"].to_i, opts["v"].to_i, 3)
1221
+ when "32" then ImageStore.encode_png(raw, opts["s"].to_i, opts["v"].to_i, 4)
1222
+ end
1223
+ end
1224
+
1225
+ def decode_graphics_data(opts, data)
1226
+ return nil if data.empty?
1227
+ decoded = data.unpack1("m")
1228
+ return decoded unless %w[f t].include?(opts["t"])
1229
+ return nil unless decoded && File.file?(decoded)
1230
+ File.binread(decoded)
1231
+ rescue SystemCallError
1232
+ nil
1233
+ end
1234
+
1235
+ def announce_image(path, bytes)
1236
+ width, height = ImageStore.png_dimensions(bytes)
1237
+ size = width && height ? " #{width}×#{height}" : ""
1238
+ write_announcement("[image#{size} → #{abbreviate_home(path)}]", path)
1239
+ end
1240
+
1241
+ def write_announcement(text, path)
1242
+ if @cursor_col.positive? || @autowrap_pending
1243
+ @cursor_col = 0
1244
+ line_feed
1245
+ @autowrap_pending = false
1246
+ end
1247
+ saved_hyperlink = @current_hyperlink
1248
+ if path
1249
+ link = "#{IMAGE_LINK_PREFIX}#{path.hash.abs.to_s(16)};file://#{path}"
1250
+ @current_hyperlink = (@hyperlink_intern[link] ||= link.freeze)
1251
+ end
1252
+ text.each_char { |c| put_char(c) }
1253
+ @current_hyperlink = saved_hyperlink
1254
+ @cursor_col = 0
1255
+ @autowrap_pending = false
1256
+ line_feed
1257
+ end
1258
+
1259
+ def abbreviate_home(path)
1260
+ path.start_with?(Dir.home) ? path.sub(Dir.home, "~") : path
1261
+ end
1262
+
636
1263
  def process_char(ch)
637
1264
  b = ch.ord
638
1265
  case @parser_state
@@ -657,6 +1284,23 @@ module Muxr
657
1284
  # payload on slightly buggy emitters.
658
1285
  finalize_osc
659
1286
  @parser_state = :ground
1287
+ when :apc
1288
+ if b == 0x9c
1289
+ finalize_apc
1290
+ @parser_state = :ground
1291
+ elsif b == 0x1b
1292
+ @parser_state = :apc_esc
1293
+ elsif @parser_apc.bytesize < GRAPHICS_MAX_LEN
1294
+ @parser_apc << ch
1295
+ end
1296
+ when :apc_esc
1297
+ finalize_apc
1298
+ @parser_state = :ground
1299
+ when :string_discard
1300
+ @parser_state = :string_discard_esc if b == 0x1b
1301
+ @parser_state = :ground if b == 0x9c
1302
+ when :string_discard_esc
1303
+ @parser_state = :ground
660
1304
  when :charset
661
1305
  @parser_state = :ground
662
1306
  end
@@ -667,7 +1311,7 @@ module Muxr
667
1311
  when 0x1b
668
1312
  @parser_state = :escape
669
1313
  when 0x07 # BEL
670
- # ignore
1314
+ queue_notification("\a")
671
1315
  when 0x08 # BS
672
1316
  @cursor_col -= 1 if @cursor_col > 0
673
1317
  @autowrap_pending = false
@@ -695,13 +1339,18 @@ module Muxr
695
1339
  when 0x5d # ]
696
1340
  @parser_state = :osc
697
1341
  @parser_osc = +""
1342
+ when 0x5f # _
1343
+ @parser_state = :apc
1344
+ @parser_apc = +""
1345
+ when 0x50, 0x58, 0x5e # P X ^
1346
+ @parser_state = :string_discard
698
1347
  when 0x28, 0x29, 0x2a, 0x2b # ( ) * +
699
1348
  @parser_state = :charset
700
1349
  when 0x37 # 7 save cursor
701
1350
  @saved_cursor = [@cursor_row, @cursor_col]
702
1351
  @parser_state = :ground
703
1352
  when 0x38 # 8 restore cursor
704
- @cursor_row, @cursor_col = @saved_cursor
1353
+ restore_cursor
705
1354
  @parser_state = :ground
706
1355
  when 0x44 # D index
707
1356
  line_feed
@@ -773,19 +1422,26 @@ module Muxr
773
1422
  when ">", "<", "=", "!"
774
1423
  return
775
1424
  when "?"
776
- # DEC private modes — most we treat as no-ops, but mode 2026
777
- # (Synchronized Output) is a render-timing hint we honor so the
778
- # outer paint lands on fully-formed frames from fzf/nvim/helix.
1425
+ # DEC private modes — most we treat as no-ops, but two we track:
1426
+ # 2026 (Synchronized Output) — a render-timing hint we honor so the
1427
+ # outer paint lands on fully-formed frames from fzf/nvim/helix.
1428
+ # 2004 (Bracketed Paste) — whether the inner program wants pastes
1429
+ # wrapped in \e[200~…\e[201~; the Application strips those
1430
+ # markers when it's off (see Application#send_to_focused).
1431
+ # 25 (DECTCEM) — cursor show/hide; the Renderer honors it so a
1432
+ # hidden-cursor UI doesn't get a phantom block painted on it.
779
1433
  if final == "h" || final == "l"
780
- if csi_params.include?(2026)
781
- if final == "h"
782
- @sync_pending = true
783
- @sync_started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
784
- else
785
- @sync_pending = false
786
- @sync_started_at = nil
787
- end
1434
+ enabled = (final == "h")
1435
+ params = csi_params
1436
+ if params.include?(2026)
1437
+ @sync_pending = enabled
1438
+ @sync_started_at = enabled ? Process.clock_gettime(Process::CLOCK_MONOTONIC) : nil
788
1439
  end
1440
+ @bracketed_paste = enabled if params.include?(2004)
1441
+ @cursor_visible = enabled if params.include?(25)
1442
+ @app_cursor_keys = enabled if params.include?(APP_CURSOR_KEYS_MODE)
1443
+ apply_mouse_modes(params, enabled)
1444
+ apply_screen_modes(params, enabled)
789
1445
  end
790
1446
  return
791
1447
  end
@@ -861,7 +1517,7 @@ module Muxr
861
1517
  when "s"
862
1518
  @saved_cursor = [@cursor_row, @cursor_col]
863
1519
  when "u"
864
- @cursor_row, @cursor_col = @saved_cursor
1520
+ restore_cursor
865
1521
  when "n"
866
1522
  # DSR — Device Status Report. `\e[5n` asks if the terminal is OK,
867
1523
  # `\e[6n` (CPR) asks for the cursor position. The reply rides back
@@ -878,23 +1534,134 @@ module Muxr
878
1534
  end
879
1535
  end
880
1536
 
1537
+ def apply_mouse_modes(params, enabled)
1538
+ (params & MOUSE_TRACKING_MODES).each do |mode|
1539
+ enabled ? (@mouse_tracking |= [mode]) : @mouse_tracking.delete(mode)
1540
+ end
1541
+ @mouse_sgr = enabled if params.include?(MOUSE_SGR_MODE)
1542
+ end
1543
+
1544
+ def apply_screen_modes(params, enabled)
1545
+ if params.include?(1048)
1546
+ enabled ? @saved_cursor = [@cursor_row, @cursor_col] : restore_cursor
1547
+ end
1548
+ return if (params & ALT_SCREEN_MODES).empty?
1549
+ enabled ? enter_alt_screen : leave_alt_screen
1550
+ end
1551
+
1552
+ def enter_alt_screen
1553
+ return if @saved_primary
1554
+ scroll_to_bottom
1555
+ @saved_primary = {
1556
+ buffer: @buffer,
1557
+ cursor: [@cursor_row, @cursor_col],
1558
+ saved_cursor: @saved_cursor,
1559
+ scroll: [@scroll_top, @scroll_bottom],
1560
+ fg: @fg,
1561
+ bg: @bg,
1562
+ attrs: @attrs,
1563
+ hyperlink: @current_hyperlink
1564
+ }
1565
+ @buffer = Array.new(@rows) { Array.new(@cols) { blank_cell } }
1566
+ @scroll_top = 0
1567
+ @scroll_bottom = @rows - 1
1568
+ @saved_cursor = [@cursor_row, @cursor_col]
1569
+ @autowrap_pending = false
1570
+ @dirty = true
1571
+ end
1572
+
1573
+ def leave_alt_screen
1574
+ primary = @saved_primary
1575
+ return unless primary
1576
+ @saved_primary = nil
1577
+ @buffer = primary[:buffer]
1578
+ @cursor_row, @cursor_col = clamp_to_grid(primary[:cursor])
1579
+ @saved_cursor = clamp_to_grid(primary[:saved_cursor])
1580
+ @scroll_top = primary[:scroll][0].clamp(0, @rows - 1)
1581
+ @scroll_bottom = primary[:scroll][1].clamp(@scroll_top, @rows - 1)
1582
+ @fg = primary[:fg]
1583
+ @bg = primary[:bg]
1584
+ @attrs = primary[:attrs]
1585
+ @current_hyperlink = primary[:hyperlink]
1586
+ @autowrap_pending = false
1587
+ @dirty = true
1588
+ end
1589
+
1590
+ def clamp_to_grid(position)
1591
+ clamp_within(position, [@rows, @cols])
1592
+ end
1593
+
1594
+ def clamp_within(position, shape)
1595
+ [position[0].clamp(0, shape[0] - 1), position[1].clamp(0, shape[1] - 1)]
1596
+ end
1597
+
1598
+ def restore_cursor
1599
+ row, col = @saved_cursor
1600
+ @cursor_row = row.clamp(0, @rows - 1)
1601
+ @cursor_col = col.clamp(0, @cols - 1)
1602
+ @autowrap_pending = false
1603
+ end
1604
+
881
1605
  def put_char(ch)
1606
+ width = self.class.char_width(ch.ord)
1607
+
1608
+ # Zero-width: fold the mark onto the preceding glyph instead of taking a
1609
+ # column, so the cursor stays where a real terminal would leave it.
1610
+ return attach_combining(ch) if width.zero?
1611
+
882
1612
  if @autowrap_pending
883
1613
  @cursor_col = 0
884
1614
  line_feed
885
1615
  @autowrap_pending = false
886
1616
  end
887
- cell = @buffer[@cursor_row][@cursor_col]
888
- cell.char = ch
1617
+
1618
+ # A wide glyph needs two columns; if only the last column is free, leave
1619
+ # it blank and wrap first (matching xterm/VTE deferral behavior).
1620
+ if width == 2 && @cursor_col == @cols - 1
1621
+ @buffer[@cursor_row][@cursor_col].reset!
1622
+ @cursor_col = 0
1623
+ line_feed
1624
+ end
1625
+
1626
+ c = @cursor_col
1627
+ write_cell(@buffer[@cursor_row][c], ch)
1628
+ if width == 2
1629
+ # The continuation half carries no glyph (char "") but inherits the
1630
+ # lead's colors so a styled wide cell paints both columns; the Renderer
1631
+ # skips emitting it since the lead already covers both columns.
1632
+ write_cell(@buffer[@cursor_row][c + 1], "")
1633
+ end
1634
+
1635
+ last_col = c + width - 1
1636
+ if last_col >= @cols - 1
1637
+ @cursor_col = @cols - 1
1638
+ @autowrap_pending = true
1639
+ else
1640
+ @cursor_col = last_col + 1
1641
+ end
1642
+ end
1643
+
1644
+ def write_cell(cell, ch)
1645
+ cell.char = self.class.intern_char(ch)
889
1646
  cell.fg = @fg
890
1647
  cell.bg = @bg
891
1648
  cell.attrs = @attrs
892
1649
  cell.hyperlink = @current_hyperlink
893
- if @cursor_col >= @cols - 1
894
- @autowrap_pending = true
895
- else
896
- @cursor_col += 1
897
- end
1650
+ end
1651
+
1652
+ # Fold a zero-width mark (combining accent, variation selector, …) onto the
1653
+ # glyph in the cell the cursor just left, so the outer terminal composes
1654
+ # them (e + ◌́ → é) without the mark consuming a column. Marks with nothing
1655
+ # to attach to — start of line, or landing on a wide continuation half —
1656
+ # are dropped; column alignment matters more than the lost accent.
1657
+ def attach_combining(ch)
1658
+ target =
1659
+ if @autowrap_pending then @buffer[@cursor_row][@cols - 1]
1660
+ elsif @cursor_col > 0 then @buffer[@cursor_row][@cursor_col - 1]
1661
+ end
1662
+ return unless target
1663
+ return if target.char.empty?
1664
+ target.char += ch
898
1665
  end
899
1666
 
900
1667
  def line_feed
@@ -909,35 +1676,67 @@ module Muxr
909
1676
  # Only the default full-screen region contributes to scrollback. Partial
910
1677
  # regions (vi/less status lines) scroll inner content that's not really
911
1678
  # "off the top of the screen" and shouldn't pollute history.
912
- if @scroll_top.zero? && @scroll_bottom == @rows - 1
913
- @scrollback << @buffer[0]
914
- if @scrollback.size > SCROLLBACK_MAX
915
- @scrollback.shift
916
- # Selection coordinates are timeline-indexed; an eviction shifts the
917
- # whole timeline down by one. Track that or selection points at the
918
- # wrong row.
919
- if @selection_anchor
920
- @selection_anchor[0] = [@selection_anchor[0] - 1, 0].max
921
- @selection_cursor[0] = [@selection_cursor[0] - 1, 0].max
922
- end
923
- unless @search_matches.empty?
924
- @search_matches.each { |m| m[0] -= 1 }
925
- @search_matches.reject! { |m| m[0] < 0 }
926
- @search_current = nil if @search_current && @search_current >= @search_matches.length
927
- end
1679
+ push_scrollback(@buffer[0]) if history_scrolls?
1680
+ @buffer[@scroll_top, @scroll_bottom - @scroll_top + 1] =
1681
+ @buffer[(@scroll_top + 1)..@scroll_bottom] + [Array.new(@cols) { blank_cell }]
1682
+ end
1683
+
1684
+ def refit(buffer, old_shape, new_shape, history:)
1685
+ old_rows, old_cols = old_shape
1686
+ rows, cols = new_shape
1687
+ grid = Array.new(rows) { Array.new(cols) { blank_cell } }
1688
+ keep_rows = [rows, old_rows].min
1689
+ keep_cols = [cols, old_cols].min
1690
+ src_start = old_rows - keep_rows
1691
+ src_start.times { |i| push_scrollback(buffer[i]) } if history
1692
+ keep_rows.times do |i|
1693
+ keep_cols.times do |j|
1694
+ grid[i][j].copy_from(buffer[src_start + i][j])
1695
+ end
1696
+ end
1697
+ grid
1698
+ end
1699
+
1700
+ def pack_row(row)
1701
+ last = last_significant_column(row)
1702
+ last += 1 if last && row[last + 1] && wide_lead?(row[last])
1703
+ HistoryRow.pack(row, last)
1704
+ end
1705
+
1706
+ def wide_lead?(cell)
1707
+ ch = cell.char
1708
+ !ch.empty? && self.class.char_width(ch.ord) == 2
1709
+ end
1710
+
1711
+ def history_scrolls?
1712
+ @saved_primary.nil? && @scroll_top.zero? && @scroll_bottom == @rows - 1
1713
+ end
1714
+
1715
+ def push_scrollback(row)
1716
+ @scrollback << pack_row(row)
1717
+ if @scrollback.size > self.class.scrollback_max
1718
+ @scrollback.shift
1719
+ # Selection coordinates are timeline-indexed; an eviction shifts the
1720
+ # whole timeline down by one. Track that or selection points at the
1721
+ # wrong row.
1722
+ if @selection_anchor
1723
+ @selection_anchor[0] = [@selection_anchor[0] - 1, 0].max
1724
+ @selection_cursor[0] = [@selection_cursor[0] - 1, 0].max
928
1725
  end
929
- # Keep the user's view frozen on the same content when new rows arrive
930
- # while they're scrolled back.
931
- if @view_offset.positive?
932
- @view_offset = (@view_offset + 1).clamp(0, @scrollback.size)
1726
+ unless @search_matches.empty?
1727
+ @search_matches.each { |m| m[0] -= 1 }
1728
+ @search_matches.reject! { |m| m[0] < 0 }
1729
+ @search_current = nil if @search_current && @search_current >= @search_matches.length
933
1730
  end
934
1731
  end
935
- @buffer[@scroll_top, @scroll_bottom - @scroll_top + 1] =
936
- @buffer[(@scroll_top + 1)..@scroll_bottom] + [Array.new(@cols) { blank_cell }]
1732
+ # Keep the user's view frozen on the same content when new rows arrive
1733
+ # while they're scrolled back.
1734
+ return unless @view_offset.positive?
1735
+ @view_offset = (@view_offset + 1).clamp(0, @scrollback.size)
937
1736
  end
938
1737
 
939
1738
  def set_view_offset(v)
940
- new_v = v.clamp(0, @scrollback.size)
1739
+ new_v = @saved_primary ? 0 : v.clamp(0, @scrollback.size)
941
1740
  return if new_v == @view_offset
942
1741
  @view_offset = new_v
943
1742
  @dirty = true
@@ -1028,6 +1827,20 @@ module Muxr
1028
1827
  end
1029
1828
  end
1030
1829
 
1830
+ def row_search_text(row)
1831
+ return row.search_text(@cols) if row.is_a?(HistoryRow)
1832
+ line = String.new(capacity: @cols)
1833
+ col_at = []
1834
+ @cols.times do |c|
1835
+ ch = row[c]&.char
1836
+ next if ch == ""
1837
+ ch = BLANK_CHAR if ch.nil?
1838
+ ch.each_char { col_at << c }
1839
+ line << ch
1840
+ end
1841
+ [line, col_at]
1842
+ end
1843
+
1031
1844
  def collect_matches(query)
1032
1845
  case_sensitive = query.match?(/[A-Z]/)
1033
1846
  needle = case_sensitive ? query : query.downcase
@@ -1035,12 +1848,13 @@ module Muxr
1035
1848
  timeline_size.times do |tr|
1036
1849
  row = timeline_row(tr)
1037
1850
  next if row.nil?
1038
- line = String.new(capacity: @cols)
1039
- @cols.times { |c| line << (row[c]&.char || " ") }
1851
+ line, col_at = row_search_text(row)
1040
1852
  haystack = case_sensitive ? line : line.downcase
1041
1853
  start = 0
1042
1854
  while (idx = haystack.index(needle, start))
1043
- matches << [tr, idx, idx + needle.length - 1]
1855
+ last = idx + needle.length - 1
1856
+ matches << [tr, col_at ? col_at[idx] : idx,
1857
+ col_at ? (col_at[last] || col_at.last || idx) : last]
1044
1858
  # Advance past the start of this match so overlapping needles
1045
1859
  # ("aa" in "aaaa") still emit one match per starting position.
1046
1860
  start = idx + 1
@@ -1274,6 +2088,7 @@ module Muxr
1274
2088
  end
1275
2089
 
1276
2090
  def reset_terminal
2091
+ @saved_primary = nil
1277
2092
  @buffer = Array.new(@rows) { Array.new(@cols) { blank_cell } }
1278
2093
  @cursor_row = 0
1279
2094
  @cursor_col = 0
@@ -1284,6 +2099,7 @@ module Muxr
1284
2099
  @scroll_bottom = @rows - 1
1285
2100
  @autowrap_pending = false
1286
2101
  @current_hyperlink = nil
2102
+ @cursor_visible = true
1287
2103
  end
1288
2104
  end
1289
2105
  end