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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +225 -1
- data/README.md +763 -387
- data/bin/muxr +68 -33
- data/bin/muxr-mcp +172 -16
- data/lib/muxr/application.rb +516 -26
- data/lib/muxr/client.rb +26 -4
- data/lib/muxr/command_dispatcher.rb +69 -0
- data/lib/muxr/config.rb +156 -0
- data/lib/muxr/control_server.rb +301 -3
- data/lib/muxr/history_row.rb +264 -0
- data/lib/muxr/image_store.rb +61 -0
- data/lib/muxr/input_handler.rb +112 -14
- data/lib/muxr/layout_manager.rb +83 -57
- data/lib/muxr/mouse_report.rb +24 -0
- data/lib/muxr/pane.rb +122 -2
- data/lib/muxr/pane_picker.rb +49 -0
- data/lib/muxr/pane_transfer.rb +172 -0
- data/lib/muxr/protocol.rb +51 -5
- data/lib/muxr/pty_process.rb +34 -7
- data/lib/muxr/remote_pane.rb +263 -0
- data/lib/muxr/renderer.rb +259 -76
- data/lib/muxr/session.rb +19 -2
- data/lib/muxr/session_directory.rb +102 -0
- data/lib/muxr/terminal.rb +759 -82
- data/lib/muxr/version.rb +1 -1
- data/lib/muxr/width_probe.rb +121 -0
- data/lib/muxr/window.rb +50 -3
- data/lib/muxr.rb +8 -0
- data/muxr.gemspec +10 -0
- data/skills/muxr-control/SKILL.md +158 -8
- metadata +18 -1
data/lib/muxr/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
|
-
|
|
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
|
|
67
|
-
#
|
|
68
|
-
#
|
|
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
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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 =
|
|
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
|
-
#
|
|
618
|
-
#
|
|
619
|
-
#
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
@feed_remainder = (
|
|
625
|
-
|
|
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 =
|
|
628
|
-
|
|
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 <<
|
|
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(
|
|
1116
|
+
Cell.new(BLANK_CHAR, nil, nil, 0, nil)
|
|
698
1117
|
end
|
|
699
1118
|
|
|
700
|
-
#
|
|
701
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
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
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
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
|
-
|
|
1062
|
-
|
|
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
|
-
|
|
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
|
|
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
|