muxr 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/muxr/renderer.rb CHANGED
@@ -4,13 +4,38 @@ module Muxr
4
4
  # new frame against the previous one and only repositions/redraws cells
5
5
  # whose contents changed, keeping output volume low between ticks.
6
6
  class Renderer
7
- BORDER_FOCUSED = [:c256, 11].freeze # yellow
7
+ BORDER_FOCUSED = [:c256, 11].freeze # yellow (fallback when no mode color)
8
8
  BORDER_UNFOCUSED = [:c256, 8].freeze # grey
9
9
  BORDER_DRAWER_FOCUS = [:c256, 13].freeze # magenta
10
10
  BORDER_DRAWER_IDLE = [:c256, 5].freeze # dark magenta
11
11
  STATUS_BG = [:c256, 236].freeze
12
12
  STATUS_FG = [:c256, 252].freeze
13
13
 
14
+ # Vim-style mode palette. Used in two places: the focused pane border
15
+ # (so the user can see at a glance what mode they're in) and the
16
+ # [MODE] chip in the status bar (same color, smaller real estate).
17
+ # :prefix maps to the same green as :passthrough because :prefix is a
18
+ # transient sub-state under passthrough — sharing the color avoids a
19
+ # one-frame border flicker when pressing Ctrl-a.
20
+ MODE_COLOR = {
21
+ normal: [:c256, 51].freeze, # cyan
22
+ passthrough: [:c256, 42].freeze, # green
23
+ prefix: [:c256, 42].freeze, # green (passthrough sub-state)
24
+ command: [:c256, 226].freeze, # yellow
25
+ scrollback: [:c256, 214].freeze, # orange
26
+ search: [:c256, 214].freeze, # orange (scrollback sub-state)
27
+ selection: [:c256, 201].freeze, # magenta
28
+ confirm_quit: [:c256, 196].freeze, # red
29
+ confirm_close: [:c256, 196].freeze, # red
30
+ help: [:c256, 39].freeze # blue
31
+ }.freeze
32
+
33
+ # Background applied to cells that match the active scrollback search.
34
+ # Bright enough to stand out over typical foreground SGRs while leaving
35
+ # the original glyph readable.
36
+ SEARCH_MATCH_BG = [:c256, 226].freeze # yellow
37
+ SEARCH_MATCH_FG = [:c256, 16].freeze # black
38
+
14
39
  HORIZONTAL = "─".freeze
15
40
  VERTICAL = "│".freeze
16
41
  TL = "┌".freeze
@@ -18,9 +43,9 @@ module Muxr
18
43
  BL = "└".freeze
19
44
  BR = "┘".freeze
20
45
 
21
- Cell = Struct.new(:char, :fg, :bg, :attrs) do
46
+ Cell = Struct.new(:char, :fg, :bg, :attrs, :hyperlink) do
22
47
  def ==(other)
23
- other.is_a?(Cell) && char == other.char && fg == other.fg && bg == other.bg && attrs == other.attrs
48
+ other.is_a?(Cell) && char == other.char && fg == other.fg && bg == other.bg && attrs == other.attrs && hyperlink == other.hyperlink
24
49
  end
25
50
  end
26
51
 
@@ -32,7 +57,10 @@ module Muxr
32
57
  end
33
58
 
34
59
  def enter_alt_screen
35
- @out.write("\e[?1049h\e[?25l\e[2J\e[H\e[0m")
60
+ # Close any stale OSC 8 hyperlink the outer terminal might be carrying
61
+ # from before we attached, so the first frame's run-tracker matches
62
+ # reality.
63
+ @out.write("\e[?1049h\e[?25l\e[2J\e[H\e[0m\e]8;;\e\\")
36
64
  @out.flush
37
65
  @prev = nil
38
66
  end
@@ -46,24 +74,31 @@ module Muxr
46
74
  @prev = nil
47
75
  end
48
76
 
49
- def render(session, input_state: :idle, command_buffer: "", message: nil, help: false)
77
+ def render(session, input_state: :normal, command_buffer: "", search_buffer: "", search_direction: :forward, message: nil, help: false)
50
78
  w = session.width
51
79
  h = session.height
52
80
  return if w < 4 || h < 3
53
81
 
54
- frame = Array.new(h) { Array.new(w) { Cell.new(" ", nil, nil, 0) } }
55
-
56
- compose_panes(frame, session)
57
- compose_drawer(frame, session) if session.drawer&.visible?
58
- compose_status_bar(frame, session, input_state: input_state, command_buffer: command_buffer, message: message)
82
+ frame = Array.new(h) { Array.new(w) { Cell.new(" ", nil, nil, 0, nil) } }
83
+
84
+ compose_panes(frame, session, input_state: input_state)
85
+ compose_drawer(frame, session, input_state: input_state) if session.drawer&.visible?
86
+ compose_status_bar(
87
+ frame, session,
88
+ input_state: input_state,
89
+ command_buffer: command_buffer,
90
+ search_buffer: search_buffer,
91
+ search_direction: search_direction,
92
+ message: message
93
+ )
59
94
  compose_help(frame, session) if help
60
95
 
61
- emit_frame(frame, session, input_state: input_state, command_buffer: command_buffer)
96
+ emit_frame(frame, session, input_state: input_state, command_buffer: command_buffer, search_buffer: search_buffer)
62
97
  end
63
98
 
64
99
  private
65
100
 
66
- def compose_panes(frame, session)
101
+ def compose_panes(frame, session, input_state: :normal)
67
102
  win = session.window
68
103
  content_area = LayoutManager::Rect.new(0, 0, session.width, session.height - 1)
69
104
  rects = LayoutManager.compute(
@@ -99,20 +134,32 @@ module Muxr
99
134
  title += " #{pane.id}" if pane.respond_to?(:id) && pane.id.is_a?(String)
100
135
  title += " [P]" if pane.respond_to?(:private?) && pane.private?
101
136
  title += " ★" if i == win.master_index
102
- title += " (" + win.layout.to_s + ")" if i == win.focused_index
137
+ # Foreground command (e.g. "npm test", "vim"). Set by the poller
138
+ # thread; nil when the shell itself is foreground. Truncate so a
139
+ # long invocation doesn't push the title past what draw_box will
140
+ # render — draw_box silently drops titles that don't fit.
141
+ if pane.respond_to?(:foreground_command) && pane.foreground_command
142
+ cmd = pane.foreground_command.to_s[0, 16]
143
+ title += " · #{cmd}"
144
+ end
103
145
  if pane.terminal.scrolled_back?
104
146
  title += " [scrollback #{pane.terminal.view_offset}/#{pane.terminal.scrollback_size}]"
105
147
  end
106
148
  draw_box(frame, rect,
107
- border: focused ? BORDER_FOCUSED : BORDER_UNFOCUSED,
149
+ border: focused ? mode_color(input_state) : BORDER_UNFOCUSED,
108
150
  bold_border: focused,
109
151
  title: title,
110
152
  title_focused: focused)
153
+ # Mode chip lives in the top-right corner of the focused container
154
+ # (this pane, or the drawer — see compose_drawer). Showing it on
155
+ # the same edge as the title but on the opposite side keeps both
156
+ # readable without one crowding the other.
157
+ draw_mode_chip(frame, rect, input_state, title) if focused
111
158
  copy_terminal(frame, pane, rect.x + 1, rect.y + 1)
112
159
  end
113
160
  end
114
161
 
115
- def compose_drawer(frame, session)
162
+ def compose_drawer(frame, session, input_state: :normal)
116
163
  drawer = session.drawer
117
164
  return unless drawer&.pane
118
165
 
@@ -150,10 +197,50 @@ module Muxr
150
197
  bold_border: true,
151
198
  title: title,
152
199
  title_focused: focused)
200
+ draw_mode_chip(frame, rect, input_state, title) if focused
153
201
  copy_terminal(frame, drawer.pane, rect.x + 1, rect.y + 1)
154
202
  end
155
203
 
156
- def compose_status_bar(frame, session, input_state:, command_buffer:, message:)
204
+ def mode_color(input_state)
205
+ MODE_COLOR[input_state] || BORDER_FOCUSED
206
+ end
207
+
208
+ # Paint the " [MODE] " chip on the top border, hugging the right corner.
209
+ # Skipped when there isn't at least one column of breathing room between
210
+ # the title (anchored at the top-left) and the chip — otherwise long
211
+ # titles + a wide chip would overdraw each other and produce garbage.
212
+ def draw_mode_chip(frame, rect, input_state, title)
213
+ chip = " [#{mode_label(input_state)}] "
214
+ chip_start = rect.x + rect.w - 1 - chip.length
215
+ title_text = " #{title} "
216
+ title_end = rect.x + 2 + title_text.length - 1
217
+ return if chip_start <= title_end + 1
218
+ chip_color = mode_color(input_state)
219
+ chip.each_char.with_index do |ch, j|
220
+ set_cell(frame, rect.y, chip_start + j, ch, fg: chip_color, attrs: Terminal::BOLD)
221
+ end
222
+ end
223
+
224
+ # Two-letter-ish mode label shown in the leftmost slot of the status bar.
225
+ # Lets the user see at a glance whether single-key bindings are active
226
+ # (NORMAL) or every key passes through to the focused pane (PASS).
227
+ def mode_label(input_state)
228
+ case input_state
229
+ when :normal then "NORMAL"
230
+ when :passthrough then "PASS"
231
+ when :prefix then "^A"
232
+ when :command then "CMD"
233
+ when :scrollback then "SCROLL"
234
+ when :search then "SEARCH"
235
+ when :selection then "SEL"
236
+ when :confirm_quit then "QUIT?"
237
+ when :confirm_close then "CLOSE?"
238
+ when :help then "HELP"
239
+ else "?"
240
+ end
241
+ end
242
+
243
+ def compose_status_bar(frame, session, input_state:, command_buffer:, search_buffer: "", search_direction: :forward, message: nil)
157
244
  y = session.height - 1
158
245
  w = session.width
159
246
  win = session.window
@@ -163,7 +250,8 @@ module Muxr
163
250
  else "hidden"
164
251
  end
165
252
 
166
- left = " [#{session.name}]"
253
+ left = " [#{mode_label(input_state)}]"
254
+ left << " [#{session.name}]"
167
255
  left << " panes:#{win.panes.length}"
168
256
  left << " layout:#{win.layout}"
169
257
  focused_label =
@@ -190,6 +278,21 @@ module Muxr
190
278
  c.attrs = 0
191
279
  end
192
280
 
281
+ # Recolor the leading "[MODE]" chip in the mode's accent color. The
282
+ # chip lives at offset 1 (after one leading space) and runs for
283
+ # bracket+label+bracket characters. Full-row overlays below will
284
+ # overwrite this when active (command/scrollback/selection) — that's
285
+ # fine, those modes already convey themselves loudly.
286
+ chip = "[#{mode_label(input_state)}]"
287
+ chip_color = mode_color(input_state)
288
+ chip_start = 1
289
+ chip_end = [chip_start + chip.length, w].min
290
+ (chip_start...chip_end).each do |x|
291
+ c = frame[y][x]
292
+ c.fg = chip_color
293
+ c.attrs |= Terminal::BOLD
294
+ end
295
+
193
296
  if input_state == :command
194
297
  overlay = ":#{command_buffer}"
195
298
  overlay = overlay[0, w]
@@ -208,7 +311,25 @@ module Muxr
208
311
  c.attrs = 0
209
312
  end
210
313
  elsif input_state == :scrollback
211
- overlay = " SCROLLBACK j/k line d/u half f/b page g/G top/bot v select q quit "
314
+ overlay = " SCROLLBACK ↑↓/j/k line d/u half f/b page g/G top/bot / search n/N next/prev v select q quit "
315
+ overlay = overlay[0, w]
316
+ overlay.each_char.with_index do |ch, x|
317
+ c = frame[y][x]
318
+ c.char = ch
319
+ c.fg = [:c256, 232]
320
+ c.bg = [:c256, 214]
321
+ c.attrs = Terminal::BOLD
322
+ end
323
+ (overlay.length...w).each do |x|
324
+ c = frame[y][x]
325
+ c.char = " "
326
+ c.fg = nil
327
+ c.bg = [:c256, 214]
328
+ c.attrs = 0
329
+ end
330
+ elsif input_state == :search
331
+ prefix = search_direction == :backward ? "?" : "/"
332
+ overlay = "#{prefix}#{search_buffer}"
212
333
  overlay = overlay[0, w]
213
334
  overlay.each_char.with_index do |ch, x|
214
335
  c = frame[y][x]
@@ -231,7 +352,7 @@ module Muxr
231
352
  mode = focused.terminal.selection_mode == :block ? "BLOCK" : "CHAR"
232
353
  end
233
354
  label = mode ? "SELECTION/#{mode}" : "SELECTION (cursor)"
234
- overlay = " #{label} h/j/k/l move v char C-v block y/Enter yank q cancel "
355
+ overlay = " #{label} h/j/k/l move v/space char C-v block y/Enter yank q cancel "
235
356
  overlay = overlay[0, w]
236
357
  overlay.each_char.with_index do |ch, x|
237
358
  c = frame[y][x]
@@ -265,28 +386,35 @@ module Muxr
265
386
  HELP_LINES = [
266
387
  "muxr — keybindings",
267
388
  "",
268
- " C-a c new pane",
269
- " C-a n / p next / prev pane",
270
- " C-a a toggle to previously focused pane",
271
- " C-a 1..9 jump to pane by number",
272
- " C-a k close focused pane",
273
- " C-a Tab cycle layout (tall grid monocle)",
274
- " C-a Enter promote focused pane to master",
275
- " C-a ~ toggle drawer (shell)",
276
- " C-a C toggle Claude Code drawer (MCP-aware)",
277
- " C-a P toggle private flag on focused pane (hides from MCP)",
278
- " C-a [ enter scrollback (j/k d/u f g/G C-b/C-f; v→cursor, q quits)",
279
- " cursor: v select, C-v block, y yank, q cancel",
280
- " motions: h/j/k/l 0/^/$ w/e/b W/E/B H/M/L g/G",
281
- " C-a ] paste internal copy buffer",
282
- " C-a d detach (server keeps running)",
283
- " C-a q kill session (asks y/n)",
284
- " C-a : command prompt",
285
- " C-a ? toggle this help",
286
- " C-a C-a send literal C-a",
389
+ "NORMAL mode (default; no prefix)",
390
+ " h / j / k / l focus pane left / down / up / right",
391
+ " H / J / K / L move pane left / down / up / right",
392
+ " i drop into passthrough mode",
393
+ " c / x new / close pane (close asks y/n)",
394
+ " t / g / m layout: tall / grid / monocle",
395
+ " Tab / Enter cycle layout / promote to master",
396
+ " a / 1..9 last pane / jump by number",
397
+ " s enter scrollback",
398
+ " ~ / C / P drawer / Claude drawer / toggle private",
399
+ " : / ? command prompt / toggle this help",
400
+ " ] / d / q paste buffer / detach / kill session",
401
+ "",
402
+ "PASSTHROUGH mode (keys reach the focused pane; prefix is Ctrl-a)",
403
+ " C-a Esc return to normal mode",
404
+ " C-a c x t g m same as normal-mode bindings",
405
+ " C-a Tab Enter cycle layout / promote master",
406
+ " C-a n / p / a next / prev / last pane",
407
+ " C-a [ ] scrollback / paste buffer",
408
+ " C-a C-a send literal Ctrl-a to focused pane",
409
+ "",
410
+ "SCROLLBACK mode (exits to the mode you came from)",
411
+ " j/k ↑/↓ d/u f/b g/G scroll C-b/C-f page v→cursor",
412
+ " / search-fwd ? search-back n/N next/prev match",
413
+ " cursor: h/j/k/l 0/^/$ w/e/b W/E/B H/M/L g/G",
414
+ " v select, C-v block, y/Enter yank, q/Esc cancel",
287
415
  "",
288
- "Commands: layout {tall|grid|monocle}, drawer {toggle|show|hide|reset}, claude,",
289
- " save, restore, sessions, quit, new, close, next, prev",
416
+ "Commands: layout {tall|grid|monocle}, drawer {toggle|show|hide|reset},",
417
+ " claude, save, restore, sessions, quit, new, close, next, prev",
290
418
  "",
291
419
  "press any key to dismiss"
292
420
  ].freeze
@@ -354,6 +482,7 @@ module Muxr
354
482
  rows = term.rows
355
483
  cols = term.cols
356
484
  selection = term.selection_active?
485
+ search = term.search_active?
357
486
  rows.times do |r|
358
487
  fy = dst_y + r
359
488
  next if fy < 0 || fy >= frame.length
@@ -366,7 +495,15 @@ module Muxr
366
495
  dst.fg = src.fg
367
496
  dst.bg = src.bg
368
497
  dst.attrs = src.attrs
498
+ if search && term.cell_in_match?(r, c)
499
+ # Highlight wins over the cell's own bg so matches stay visible
500
+ # across whatever SGR the underlying program was using. Selection
501
+ # still applies on top via REVERSE below.
502
+ dst.fg = SEARCH_MATCH_FG
503
+ dst.bg = SEARCH_MATCH_BG
504
+ end
369
505
  dst.attrs |= Terminal::REVERSE if selection && term.selected_at_visible?(r, c)
506
+ dst.hyperlink = src.hyperlink
370
507
  end
371
508
  end
372
509
  end
@@ -381,7 +518,7 @@ module Muxr
381
518
  c.attrs = attrs
382
519
  end
383
520
 
384
- def emit_frame(frame, session, input_state:, command_buffer:)
521
+ def emit_frame(frame, session, input_state:, command_buffer:, search_buffer: "")
385
522
  # \e[?2026h enters synchronized-output mode so terminals that support it
386
523
  # (Ghostty, kitty, iTerm2 ≥3.5, WezTerm, Alacritty ≥0.13, foot) present
387
524
  # the whole frame atomically instead of repainting incrementally as bytes
@@ -393,6 +530,9 @@ module Muxr
393
530
  cur_fg = :unset
394
531
  cur_bg = :unset
395
532
  cur_attrs = :unset
533
+ # We close any open hyperlink at end-of-frame, so the outer terminal
534
+ # always starts a new frame in the "no hyperlink" state.
535
+ cur_hyperlink = nil
396
536
  last_y = nil
397
537
  last_x = nil
398
538
 
@@ -410,13 +550,19 @@ module Muxr
410
550
  cur_bg = cell.bg
411
551
  cur_attrs = cell.attrs
412
552
  end
553
+ if cell.hyperlink != cur_hyperlink
554
+ out << "\e]8;;\e\\" if cur_hyperlink
555
+ out << "\e]#{cell.hyperlink}\e\\" if cell.hyperlink
556
+ cur_hyperlink = cell.hyperlink
557
+ end
413
558
  out << cell.char
414
559
  last_y = y
415
560
  last_x = x + cell.char.length
416
561
  end
417
562
  end
563
+ out << "\e]8;;\e\\" if cur_hyperlink
418
564
  out << "\e[0m"
419
- out << cursor_position(session, input_state: input_state, command_buffer: command_buffer)
565
+ out << cursor_position(session, input_state: input_state, command_buffer: command_buffer, search_buffer: search_buffer)
420
566
  out << "\e[?2026l"
421
567
  @out.write(out)
422
568
  @out.flush
@@ -425,11 +571,15 @@ module Muxr
425
571
  @prev_h = frame.length
426
572
  end
427
573
 
428
- def cursor_position(session, input_state:, command_buffer:)
574
+ def cursor_position(session, input_state:, command_buffer:, search_buffer: "")
429
575
  if input_state == :command
430
576
  col = 1 + command_buffer.length + 1 # ':' + buffer
431
577
  return "\e[#{session.height};#{col}H\e[?25h"
432
578
  end
579
+ if input_state == :search
580
+ col = 1 + search_buffer.length + 1 # '/' or '?' + buffer
581
+ return "\e[#{session.height};#{col}H\e[?25h"
582
+ end
433
583
 
434
584
  target =
435
585
  if session.focus_drawer && session.drawer&.visible? && session.drawer.pane
data/lib/muxr/terminal.rb CHANGED
@@ -12,6 +12,11 @@ module Muxr
12
12
 
13
13
  SCROLLBACK_MAX = 5000
14
14
 
15
+ # Cap on the OSC payload we buffer before parsing. URLs in OSC 8 can be
16
+ # long but rarely exceed a few hundred bytes; 4 KiB lets the parser stay
17
+ # tolerant of weird inputs without giving an attacker an unbounded sink.
18
+ OSC_MAX_LEN = 4096
19
+
15
20
  # Inner programs (fzf ≥ 0.41, neovim, helix, …) bracket coherent screen
16
21
  # updates with `\e[?2026h … \e[?2026l` (DECSET 2026 — "Synchronized
17
22
  # Output"). When we see the open, we know more bytes are coming that
@@ -20,12 +25,13 @@ module Muxr
20
25
  # program (which left ?2026h open) cannot wedge the pane indefinitely.
21
26
  SYNC_TIMEOUT = 0.2
22
27
 
23
- Cell = Struct.new(:char, :fg, :bg, :attrs) do
28
+ Cell = Struct.new(:char, :fg, :bg, :attrs, :hyperlink) do
24
29
  def reset!
25
30
  self.char = " "
26
31
  self.fg = nil
27
32
  self.bg = nil
28
33
  self.attrs = 0
34
+ self.hyperlink = nil
29
35
  end
30
36
 
31
37
  def copy_from(other)
@@ -33,6 +39,7 @@ module Muxr
33
39
  self.fg = other.fg
34
40
  self.bg = other.bg
35
41
  self.attrs = other.attrs
42
+ self.hyperlink = other.hyperlink
36
43
  end
37
44
  end
38
45
 
@@ -53,7 +60,14 @@ module Muxr
53
60
  @scroll_bottom = rows - 1
54
61
  @parser_state = :ground
55
62
  @parser_params = +""
63
+ @parser_osc = +""
56
64
  @feed_remainder = +"".b
65
+ # Currently-active OSC 8 hyperlink body (the "8;params;URI" payload that
66
+ # we'll wrap back around runs of cells when rendering), or nil when no
67
+ # hyperlink is open. Interned via @hyperlink_intern so repeated identical
68
+ # links share one frozen string for fast equality and small memory.
69
+ @current_hyperlink = nil
70
+ @hyperlink_intern = {}
57
71
  @dirty = true
58
72
  @scrollback = []
59
73
  @view_offset = 0
@@ -63,6 +77,10 @@ module Muxr
63
77
  @sync_pending = false
64
78
  @sync_started_at = nil
65
79
  @pending_replies = +"".b
80
+ @search_query = nil
81
+ @search_direction = :forward
82
+ @search_matches = []
83
+ @search_current = nil
66
84
  end
67
85
 
68
86
  # Bytes the emulator owes back to the inner program in response to a
@@ -337,6 +355,81 @@ module Muxr
337
355
  @dirty = true
338
356
  end
339
357
 
358
+ # ---------- search ----------
359
+ #
360
+ # Substring search over the full timeline (scrollback + live buffer).
361
+ # Smart-case: case-insensitive if the query is all-lowercase, sensitive
362
+ # otherwise. Matches are kept in timeline coordinates so they stay
363
+ # anchored to the same text as the user pages history.
364
+
365
+ attr_reader :search_query, :search_matches, :search_current, :search_direction
366
+
367
+ def search(query, direction: :forward)
368
+ query = query.to_s
369
+ if query.empty?
370
+ clear_search
371
+ return 0
372
+ end
373
+ @search_query = query
374
+ @search_direction = direction
375
+ @search_matches = collect_matches(query)
376
+ if @search_matches.empty?
377
+ @search_current = nil
378
+ @dirty = true
379
+ return 0
380
+ end
381
+ @search_current = nearest_match_in_direction(current_search_anchor_row, direction, inclusive: true)
382
+ @search_current ||= direction == :forward ? 0 : @search_matches.length - 1
383
+ scroll_view_to_match(@search_current)
384
+ @dirty = true
385
+ @search_matches.length
386
+ end
387
+
388
+ # Move to the next/previous match in the given direction, wrapping.
389
+ # Returns the new current match index, or nil if there are no matches.
390
+ # Anchors on the current match (not the viewport top) so n always
391
+ # advances even when scroll_view_to_match has centered the previous
392
+ # hit and dragged the viewport top behind it.
393
+ def find_in_direction(direction)
394
+ return nil if @search_matches.empty?
395
+ anchor_tr =
396
+ if @search_current && @search_matches[@search_current]
397
+ @search_matches[@search_current][0]
398
+ else
399
+ current_search_anchor_row
400
+ end
401
+ idx = strict_next_in_direction(anchor_tr, direction)
402
+ if idx.nil?
403
+ # Wrap: pick the far end depending on direction.
404
+ idx = direction == :forward ? 0 : @search_matches.length - 1
405
+ end
406
+ @search_current = idx
407
+ scroll_view_to_match(idx)
408
+ @dirty = true
409
+ idx
410
+ end
411
+
412
+ def cell_in_match?(visible_r, c)
413
+ return false if @search_matches.empty?
414
+ tr = timeline_row_for_visible(visible_r)
415
+ # Linear scan is fine: SCROLLBACK_MAX caps matches at O(rows*cols), and
416
+ # the renderer touches each visible cell once per frame. A row-indexed
417
+ # cache would matter at much larger buffer sizes than ours.
418
+ @search_matches.any? { |mr, sc, ec| mr == tr && c >= sc && c <= ec }
419
+ end
420
+
421
+ def search_active?
422
+ !(@search_query.nil? || @search_matches.empty?)
423
+ end
424
+
425
+ def clear_search
426
+ return if @search_query.nil? && @search_matches.empty?
427
+ @search_query = nil
428
+ @search_matches = []
429
+ @search_current = nil
430
+ @dirty = true
431
+ end
432
+
340
433
  def selected_at_visible?(r, c)
341
434
  return false unless @selection_anchor
342
435
  tr = timeline_row_for_visible(r)
@@ -448,7 +541,25 @@ module Muxr
448
541
  private
449
542
 
450
543
  def blank_cell
451
- Cell.new(" ", nil, nil, 0)
544
+ Cell.new(" ", nil, nil, 0, nil)
545
+ end
546
+
547
+ # Parse the just-completed OSC payload. We only care about OSC 8
548
+ # (hyperlinks): `8;params;URI`. An empty URI closes the active link.
549
+ # Anything else (window-title OSC 0/1/2, palette OSC 4, …) is silently
550
+ # consumed — the emulator doesn't model it.
551
+ def finalize_osc
552
+ payload = @parser_osc
553
+ @parser_osc = +""
554
+ return if payload.empty?
555
+ return unless payload.start_with?("8;")
556
+ parts = payload.split(";", 3)
557
+ uri = parts[2]
558
+ if uri.nil? || uri.empty?
559
+ @current_hyperlink = nil
560
+ else
561
+ @current_hyperlink = (@hyperlink_intern[payload] ||= payload.dup.freeze)
562
+ end
452
563
  end
453
564
 
454
565
  def process_char(ch)
@@ -462,11 +573,18 @@ module Muxr
462
573
  csi_char(ch, b)
463
574
  when :osc
464
575
  if b == 0x07 || b == 0x9c
576
+ finalize_osc
465
577
  @parser_state = :ground
466
578
  elsif b == 0x1b
467
579
  @parser_state = :osc_esc
580
+ elsif @parser_osc.bytesize < OSC_MAX_LEN
581
+ @parser_osc << ch
468
582
  end
469
583
  when :osc_esc
584
+ # ST is `ESC \`. Anything else is malformed but we still flush — most
585
+ # terminals are lenient here, and being strict would swallow the
586
+ # payload on slightly buggy emitters.
587
+ finalize_osc
470
588
  @parser_state = :ground
471
589
  when :charset
472
590
  @parser_state = :ground
@@ -505,6 +623,7 @@ module Muxr
505
623
  @parser_params = +""
506
624
  when 0x5d # ]
507
625
  @parser_state = :osc
626
+ @parser_osc = +""
508
627
  when 0x28, 0x29, 0x2a, 0x2b # ( ) * +
509
628
  @parser_state = :charset
510
629
  when 0x37 # 7 save cursor
@@ -699,6 +818,7 @@ module Muxr
699
818
  cell.fg = @fg
700
819
  cell.bg = @bg
701
820
  cell.attrs = @attrs
821
+ cell.hyperlink = @current_hyperlink
702
822
  if @cursor_col >= @cols - 1
703
823
  @autowrap_pending = true
704
824
  else
@@ -729,6 +849,11 @@ module Muxr
729
849
  @selection_anchor[0] = [@selection_anchor[0] - 1, 0].max
730
850
  @selection_cursor[0] = [@selection_cursor[0] - 1, 0].max
731
851
  end
852
+ unless @search_matches.empty?
853
+ @search_matches.each { |m| m[0] -= 1 }
854
+ @search_matches.reject! { |m| m[0] < 0 }
855
+ @search_current = nil if @search_current && @search_current >= @search_matches.length
856
+ end
732
857
  end
733
858
  # Keep the user's view frozen on the same content when new rows arrive
734
859
  # while they're scrolled back.
@@ -832,6 +957,73 @@ module Muxr
832
957
  end
833
958
  end
834
959
 
960
+ def collect_matches(query)
961
+ case_sensitive = query.match?(/[A-Z]/)
962
+ needle = case_sensitive ? query : query.downcase
963
+ matches = []
964
+ timeline_size.times do |tr|
965
+ row = timeline_row(tr)
966
+ next if row.nil?
967
+ line = String.new(capacity: @cols)
968
+ @cols.times { |c| line << (row[c]&.char || " ") }
969
+ haystack = case_sensitive ? line : line.downcase
970
+ start = 0
971
+ while (idx = haystack.index(needle, start))
972
+ matches << [tr, idx, idx + needle.length - 1]
973
+ # Advance past the start of this match so overlapping needles
974
+ # ("aa" in "aaaa") still emit one match per starting position.
975
+ start = idx + 1
976
+ end
977
+ end
978
+ matches
979
+ end
980
+
981
+ # Top of the current viewport in timeline coordinates. Used as the
982
+ # reference point for "search from where the user is looking now".
983
+ def current_search_anchor_row
984
+ timeline_row_for_visible(0)
985
+ end
986
+
987
+ # Smallest match index whose row is >= anchor_tr (forward) or largest
988
+ # match index whose row is <= anchor_tr (backward). Used by search() so
989
+ # the first jump lands on the nearest match in the search direction
990
+ # without forcing the user to press n.
991
+ def nearest_match_in_direction(anchor_tr, direction, inclusive:)
992
+ if direction == :forward
993
+ @search_matches.each_with_index do |(mr, _, _), i|
994
+ return i if inclusive ? mr >= anchor_tr : mr > anchor_tr
995
+ end
996
+ nil
997
+ else
998
+ best = nil
999
+ @search_matches.each_with_index do |(mr, _, _), i|
1000
+ if (inclusive ? mr <= anchor_tr : mr < anchor_tr)
1001
+ best = i
1002
+ else
1003
+ break
1004
+ end
1005
+ end
1006
+ best
1007
+ end
1008
+ end
1009
+
1010
+ # Strict next match (n/N) — never matches the current row; that would
1011
+ # leave the user stuck on the same line they're already on.
1012
+ def strict_next_in_direction(anchor_tr, direction)
1013
+ nearest_match_in_direction(anchor_tr, direction, inclusive: false)
1014
+ end
1015
+
1016
+ # Center the match in the viewport when possible. set_view_offset clamps
1017
+ # to [0, scrollback.size] so recent matches end up showing the live
1018
+ # buffer at the bottom and very old ones pin to the top.
1019
+ def scroll_view_to_match(idx)
1020
+ match = @search_matches[idx]
1021
+ return unless match
1022
+ tr = match[0]
1023
+ desired = @scrollback.size - tr + (@rows / 2)
1024
+ set_view_offset(desired)
1025
+ end
1026
+
835
1027
  def ensure_selection_cursor_visible
836
1028
  return unless @selection_cursor
837
1029
  tr = @selection_cursor[0]
@@ -1020,6 +1212,7 @@ module Muxr
1020
1212
  @scroll_top = 0
1021
1213
  @scroll_bottom = @rows - 1
1022
1214
  @autowrap_pending = false
1215
+ @current_hyperlink = nil
1023
1216
  end
1024
1217
  end
1025
1218
  end