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/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,7 +13,13 @@ module Muxr
10
13
  REVERSE = 4
11
14
  DIM = 8
12
15
 
13
- SCROLLBACK_MAX = 5000
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
14
23
 
15
24
  # Codepoint ranges that occupy two display columns (East Asian Wide /
16
25
  # Fullwidth per UAX #11, plus the common emoji blocks). A wide glyph is
@@ -62,21 +71,130 @@ module Muxr
62
71
  0xE0100..0xE01EF # variation selectors supplement
63
72
  ].freeze
64
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
+
65
165
  # Display width of a codepoint in terminal columns: 0 (combining /
66
- # zero-width), 2 (East Asian wide / emoji), or 1 (everything else). The
67
- # Renderer uses this to advance its emit cursor by the right number of
68
- # columns; #put_char uses it to lay glyphs into the grid.
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.
69
170
  def self.char_width(cp)
70
171
  return 1 if cp < 0x0300
71
172
  return 0 if ZERO_WIDTH_RANGES.any? { |r| r.cover?(cp) }
173
+ ov = @width_overrides[cp]
174
+ return ov if ov
72
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) }
73
178
  1
74
179
  end
75
180
 
76
- # Cap on the OSC payload we buffer before parsing. URLs in OSC 8 can be
77
- # long but rarely exceed a few hundred bytes; 4 KiB lets the parser stay
78
- # tolerant of weird inputs without giving an attacker an unbounded sink.
79
- OSC_MAX_LEN = 4096
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-"
80
198
 
81
199
  # Match plain-text URLs the inner program printed without wrapping them
82
200
  # in OSC 8. We stamp the matching cells with a synthetic hyperlink so the
@@ -102,9 +220,18 @@ module Muxr
102
220
  # program (which left ?2026h open) cannot wedge the pane indefinitely.
103
221
  SYNC_TIMEOUT = 0.2
104
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
+
105
232
  Cell = Struct.new(:char, :fg, :bg, :attrs, :hyperlink) do
106
233
  def reset!
107
- self.char = " "
234
+ self.char = BLANK_CHAR
108
235
  self.fg = nil
109
236
  self.bg = nil
110
237
  self.attrs = 0
@@ -122,9 +249,12 @@ module Muxr
122
249
 
123
250
  attr_reader :rows, :cols, :cursor_row, :cursor_col, :view_offset
124
251
 
125
- def initialize(rows: 24, cols: 80)
252
+ def initialize(rows: 24, cols: 80, image_store: nil)
126
253
  @rows = rows
127
254
  @cols = cols
255
+ @image_store = image_store || ImageStore.new
256
+ @parser_apc = +""
257
+ @graphics_pending = nil
128
258
  @buffer = Array.new(rows) { Array.new(cols) { blank_cell } }
129
259
  @cursor_row = 0
130
260
  @cursor_col = 0
@@ -135,6 +265,7 @@ module Muxr
135
265
  @autowrap_pending = false
136
266
  @scroll_top = 0
137
267
  @scroll_bottom = rows - 1
268
+ @saved_primary = nil
138
269
  @parser_state = :ground
139
270
  @parser_params = +""
140
271
  @parser_osc = +""
@@ -162,7 +293,33 @@ module Muxr
162
293
  # \e[200~…\e[201~ paste markers the outer terminal wraps around a paste
163
294
  # or strip them — see Application#send_to_focused.
164
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
165
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
166
323
  @search_query = nil
167
324
  @search_direction = :forward
168
325
  @search_matches = []
@@ -208,6 +365,50 @@ module Muxr
208
365
  @bracketed_paste
209
366
  end
210
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
+
211
412
  attr_reader :selection_mode
212
413
 
213
414
  def cell(r, c)
@@ -228,6 +429,192 @@ module Muxr
228
429
  lines.join("\n")
229
430
  end
230
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
+
231
618
  # Returns the Cell that should be visible at (r, c) given the current
232
619
  # scrollback view_offset. When view_offset == 0 this is the live grid.
233
620
  # When view_offset > 0, rows in the top of the visible area are sourced
@@ -237,8 +624,7 @@ module Muxr
237
624
  idx = @scrollback.size - @view_offset + r
238
625
  if idx < @scrollback.size
239
626
  row = @scrollback[idx]
240
- return blank_cell if row.nil? || c >= row.length
241
- row[c]
627
+ (row && row[c]) || blank_cell
242
628
  else
243
629
  @buffer[idx - @scrollback.size][c]
244
630
  end
@@ -280,10 +666,23 @@ module Muxr
280
666
  !@selection_anchor.nil?
281
667
  end
282
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
+
283
682
  # Place the moving cursor at a viewport position without dropping an
284
683
  # anchor — the user is still navigating, not yet selecting.
285
684
  def place_selection_cursor(r, c)
286
- tr = timeline_row_for_visible(r).clamp(0, timeline_size - 1)
685
+ tr = timeline_row_for_visible(r).clamp(selection_floor, timeline_size - 1)
287
686
  tc = c.clamp(0, @cols - 1)
288
687
  @selection_cursor = [tr, tc]
289
688
  @selection_anchor = nil
@@ -317,7 +716,7 @@ module Muxr
317
716
  def move_selection_cursor_by(dr, dc)
318
717
  return unless @selection_cursor
319
718
  tr, tc = @selection_cursor
320
- ntr = (tr + dr).clamp(0, timeline_size - 1)
719
+ ntr = (tr + dr).clamp(selection_floor, timeline_size - 1)
321
720
  ntc = (tc + dc).clamp(0, @cols - 1)
322
721
  return if ntr == tr && ntc == tc
323
722
  @selection_cursor = [ntr, ntc]
@@ -327,7 +726,7 @@ module Muxr
327
726
 
328
727
  def selection_cursor_to(tr, tc)
329
728
  return unless @selection_cursor
330
- ntr = tr.clamp(0, timeline_size - 1)
729
+ ntr = tr.clamp(selection_floor, timeline_size - 1)
331
730
  ntc = tc.clamp(0, @cols - 1)
332
731
  @selection_cursor = [ntr, ntc]
333
732
  ensure_selection_cursor_visible
@@ -368,7 +767,7 @@ module Muxr
368
767
  when :bottom then @rows - 1
369
768
  end
370
769
  return if vr.nil?
371
- tr = timeline_row_for_visible(vr).clamp(0, timeline_size - 1)
770
+ tr = timeline_row_for_visible(vr).clamp(selection_floor, timeline_size - 1)
372
771
  selection_cursor_to(tr, first_non_blank_col(tr))
373
772
  end
374
773
 
@@ -585,16 +984,15 @@ module Muxr
585
984
 
586
985
  def resize(rows, cols)
587
986
  return if rows == @rows && cols == @cols
588
- new_buf = Array.new(rows) { Array.new(cols) { blank_cell } }
589
- keep_rows = [rows, @rows].min
590
- keep_cols = [cols, @cols].min
591
- src_start = @rows - keep_rows
592
- keep_rows.times do |i|
593
- keep_cols.times do |j|
594
- new_buf[i][j].copy_from(@buffer[src_start + i][j])
595
- 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]
596
994
  end
597
- @buffer = new_buf
995
+ @buffer = refit(@buffer, was, shape, history: primary.nil?)
598
996
  @rows = rows
599
997
  @cols = cols
600
998
  @scroll_top = 0
@@ -614,18 +1012,18 @@ module Muxr
614
1012
  @feed_remainder = +"".b
615
1013
  str = bytes.dup.force_encoding(Encoding::UTF_8)
616
1014
  unless str.valid_encoding?
617
- # Find the longest valid UTF-8 prefix and stash the remainder for the
618
- # next feed call so multi-byte characters don't get garbled across PTY
619
- # read boundaries.
620
- raw = bytes.bytes
621
- while raw.any?
622
- candidate = raw.pack("C*").force_encoding(Encoding::UTF_8)
623
- break if candidate.valid_encoding?
624
- @feed_remainder = ([raw.last] + @feed_remainder.bytes).pack("C*").b
625
- 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)
626
1024
  end
627
- str = raw.pack("C*").force_encoding(Encoding::UTF_8)
628
- # 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?
629
1027
  return if str.empty?
630
1028
  end
631
1029
  str.each_char { |c| process_char(c) }
@@ -633,6 +1031,24 @@ module Muxr
633
1031
  @dirty = true
634
1032
  end
635
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
+
636
1052
  # Walk the buffer (plus the last scrollback row so wraps across the
637
1053
  # scrollback boundary still join), find plain-text URLs, and stamp the
638
1054
  # covering cells with an OSC 8 hyperlink carrying an `id=` parameter.
@@ -641,8 +1057,9 @@ module Muxr
641
1057
  # a wrapped URL like https://very.long.example.com/path-that-wraps would
642
1058
  # be detected as two truncated URLs on consecutive lines.
643
1059
  def detect_urls!
1060
+ history = @scrollback.last if @saved_primary.nil? && @scrollback.any?
644
1061
  rows = []
645
- rows << @scrollback.last if @scrollback.any?
1062
+ rows << history if history
646
1063
  rows.concat(@buffer)
647
1064
 
648
1065
  rows.each do |row|
@@ -689,22 +1106,49 @@ module Muxr
689
1106
 
690
1107
  pos = end_off
691
1108
  end
1109
+
1110
+ history&.repack!
692
1111
  end
693
1112
 
694
1113
  private
695
1114
 
696
1115
  def blank_cell
697
- Cell.new(" ", nil, nil, 0, nil)
1116
+ Cell.new(BLANK_CHAR, nil, nil, 0, nil)
698
1117
  end
699
1118
 
700
- # Parse the just-completed OSC payload. We only care about OSC 8
701
- # (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.
702
1135
  # Anything else (window-title OSC 0/1/2, palette OSC 4, …) is silently
703
1136
  # consumed — the emulator doesn't model it.
704
1137
  def finalize_osc
705
1138
  payload = @parser_osc
706
1139
  @parser_osc = +""
707
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
708
1152
  return unless payload.start_with?("8;")
709
1153
  parts = payload.split(";", 3)
710
1154
  uri = parts[2]
@@ -715,6 +1159,107 @@ module Muxr
715
1159
  end
716
1160
  end
717
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
+
718
1263
  def process_char(ch)
719
1264
  b = ch.ord
720
1265
  case @parser_state
@@ -739,6 +1284,23 @@ module Muxr
739
1284
  # payload on slightly buggy emitters.
740
1285
  finalize_osc
741
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
742
1304
  when :charset
743
1305
  @parser_state = :ground
744
1306
  end
@@ -749,7 +1311,7 @@ module Muxr
749
1311
  when 0x1b
750
1312
  @parser_state = :escape
751
1313
  when 0x07 # BEL
752
- # ignore
1314
+ queue_notification("\a")
753
1315
  when 0x08 # BS
754
1316
  @cursor_col -= 1 if @cursor_col > 0
755
1317
  @autowrap_pending = false
@@ -777,13 +1339,18 @@ module Muxr
777
1339
  when 0x5d # ]
778
1340
  @parser_state = :osc
779
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
780
1347
  when 0x28, 0x29, 0x2a, 0x2b # ( ) * +
781
1348
  @parser_state = :charset
782
1349
  when 0x37 # 7 save cursor
783
1350
  @saved_cursor = [@cursor_row, @cursor_col]
784
1351
  @parser_state = :ground
785
1352
  when 0x38 # 8 restore cursor
786
- @cursor_row, @cursor_col = @saved_cursor
1353
+ restore_cursor
787
1354
  @parser_state = :ground
788
1355
  when 0x44 # D index
789
1356
  line_feed
@@ -861,6 +1428,8 @@ module Muxr
861
1428
  # 2004 (Bracketed Paste) — whether the inner program wants pastes
862
1429
  # wrapped in \e[200~…\e[201~; the Application strips those
863
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.
864
1433
  if final == "h" || final == "l"
865
1434
  enabled = (final == "h")
866
1435
  params = csi_params
@@ -869,6 +1438,10 @@ module Muxr
869
1438
  @sync_started_at = enabled ? Process.clock_gettime(Process::CLOCK_MONOTONIC) : nil
870
1439
  end
871
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)
872
1445
  end
873
1446
  return
874
1447
  end
@@ -944,7 +1517,7 @@ module Muxr
944
1517
  when "s"
945
1518
  @saved_cursor = [@cursor_row, @cursor_col]
946
1519
  when "u"
947
- @cursor_row, @cursor_col = @saved_cursor
1520
+ restore_cursor
948
1521
  when "n"
949
1522
  # DSR — Device Status Report. `\e[5n` asks if the terminal is OK,
950
1523
  # `\e[6n` (CPR) asks for the cursor position. The reply rides back
@@ -961,6 +1534,74 @@ module Muxr
961
1534
  end
962
1535
  end
963
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
+
964
1605
  def put_char(ch)
965
1606
  width = self.class.char_width(ch.ord)
966
1607
 
@@ -1001,7 +1642,7 @@ module Muxr
1001
1642
  end
1002
1643
 
1003
1644
  def write_cell(cell, ch)
1004
- cell.char = ch
1645
+ cell.char = self.class.intern_char(ch)
1005
1646
  cell.fg = @fg
1006
1647
  cell.bg = @bg
1007
1648
  cell.attrs = @attrs
@@ -1035,35 +1676,67 @@ module Muxr
1035
1676
  # Only the default full-screen region contributes to scrollback. Partial
1036
1677
  # regions (vi/less status lines) scroll inner content that's not really
1037
1678
  # "off the top of the screen" and shouldn't pollute history.
1038
- if @scroll_top.zero? && @scroll_bottom == @rows - 1
1039
- @scrollback << @buffer[0]
1040
- if @scrollback.size > SCROLLBACK_MAX
1041
- @scrollback.shift
1042
- # Selection coordinates are timeline-indexed; an eviction shifts the
1043
- # whole timeline down by one. Track that or selection points at the
1044
- # wrong row.
1045
- if @selection_anchor
1046
- @selection_anchor[0] = [@selection_anchor[0] - 1, 0].max
1047
- @selection_cursor[0] = [@selection_cursor[0] - 1, 0].max
1048
- end
1049
- unless @search_matches.empty?
1050
- @search_matches.each { |m| m[0] -= 1 }
1051
- @search_matches.reject! { |m| m[0] < 0 }
1052
- @search_current = nil if @search_current && @search_current >= @search_matches.length
1053
- 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])
1054
1695
  end
1055
- # Keep the user's view frozen on the same content when new rows arrive
1056
- # while they're scrolled back.
1057
- if @view_offset.positive?
1058
- @view_offset = (@view_offset + 1).clamp(0, @scrollback.size)
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
1725
+ end
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
1059
1730
  end
1060
1731
  end
1061
- @buffer[@scroll_top, @scroll_bottom - @scroll_top + 1] =
1062
- @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)
1063
1736
  end
1064
1737
 
1065
1738
  def set_view_offset(v)
1066
- new_v = v.clamp(0, @scrollback.size)
1739
+ new_v = @saved_primary ? 0 : v.clamp(0, @scrollback.size)
1067
1740
  return if new_v == @view_offset
1068
1741
  @view_offset = new_v
1069
1742
  @dirty = true
@@ -1154,6 +1827,20 @@ module Muxr
1154
1827
  end
1155
1828
  end
1156
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
+
1157
1844
  def collect_matches(query)
1158
1845
  case_sensitive = query.match?(/[A-Z]/)
1159
1846
  needle = case_sensitive ? query : query.downcase
@@ -1161,25 +1848,13 @@ module Muxr
1161
1848
  timeline_size.times do |tr|
1162
1849
  row = timeline_row(tr)
1163
1850
  next if row.nil?
1164
- # Build the row text and a parallel codepoint→column map so matches can
1165
- # be reported in column coordinates even when wide glyphs (one cell, two
1166
- # columns) and combining marks (multi-codepoint, one cell) break the
1167
- # 1:1 char-index↔column relationship. For all-ASCII rows col_at[i] == i,
1168
- # so this is identical to the old behavior on the common path.
1169
- line = String.new(capacity: @cols)
1170
- col_at = []
1171
- @cols.times do |c|
1172
- ch = row[c]&.char
1173
- next if ch == "" # wide continuation half — occupies no text slot
1174
- ch = " " if ch.nil?
1175
- ch.each_char { col_at << c }
1176
- line << ch
1177
- end
1851
+ line, col_at = row_search_text(row)
1178
1852
  haystack = case_sensitive ? line : line.downcase
1179
1853
  start = 0
1180
1854
  while (idx = haystack.index(needle, start))
1181
1855
  last = idx + needle.length - 1
1182
- matches << [tr, col_at[idx], col_at[last] || col_at.last || idx]
1856
+ matches << [tr, col_at ? col_at[idx] : idx,
1857
+ col_at ? (col_at[last] || col_at.last || idx) : last]
1183
1858
  # Advance past the start of this match so overlapping needles
1184
1859
  # ("aa" in "aaaa") still emit one match per starting position.
1185
1860
  start = idx + 1
@@ -1413,6 +2088,7 @@ module Muxr
1413
2088
  end
1414
2089
 
1415
2090
  def reset_terminal
2091
+ @saved_primary = nil
1416
2092
  @buffer = Array.new(@rows) { Array.new(@cols) { blank_cell } }
1417
2093
  @cursor_row = 0
1418
2094
  @cursor_col = 0
@@ -1423,6 +2099,7 @@ module Muxr
1423
2099
  @scroll_bottom = @rows - 1
1424
2100
  @autowrap_pending = false
1425
2101
  @current_hyperlink = nil
2102
+ @cursor_visible = true
1426
2103
  end
1427
2104
  end
1428
2105
  end