muxr 0.1.5 → 0.1.6

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,30 @@ 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
+ selection: [:c256, 201].freeze, # magenta
27
+ confirm_quit: [:c256, 196].freeze, # red
28
+ help: [:c256, 39].freeze # blue
29
+ }.freeze
30
+
14
31
  HORIZONTAL = "─".freeze
15
32
  VERTICAL = "│".freeze
16
33
  TL = "┌".freeze
@@ -18,9 +35,9 @@ module Muxr
18
35
  BL = "└".freeze
19
36
  BR = "┘".freeze
20
37
 
21
- Cell = Struct.new(:char, :fg, :bg, :attrs) do
38
+ Cell = Struct.new(:char, :fg, :bg, :attrs, :hyperlink) do
22
39
  def ==(other)
23
- other.is_a?(Cell) && char == other.char && fg == other.fg && bg == other.bg && attrs == other.attrs
40
+ other.is_a?(Cell) && char == other.char && fg == other.fg && bg == other.bg && attrs == other.attrs && hyperlink == other.hyperlink
24
41
  end
25
42
  end
26
43
 
@@ -32,7 +49,10 @@ module Muxr
32
49
  end
33
50
 
34
51
  def enter_alt_screen
35
- @out.write("\e[?1049h\e[?25l\e[2J\e[H\e[0m")
52
+ # Close any stale OSC 8 hyperlink the outer terminal might be carrying
53
+ # from before we attached, so the first frame's run-tracker matches
54
+ # reality.
55
+ @out.write("\e[?1049h\e[?25l\e[2J\e[H\e[0m\e]8;;\e\\")
36
56
  @out.flush
37
57
  @prev = nil
38
58
  end
@@ -46,15 +66,15 @@ module Muxr
46
66
  @prev = nil
47
67
  end
48
68
 
49
- def render(session, input_state: :idle, command_buffer: "", message: nil, help: false)
69
+ def render(session, input_state: :normal, command_buffer: "", message: nil, help: false)
50
70
  w = session.width
51
71
  h = session.height
52
72
  return if w < 4 || h < 3
53
73
 
54
- frame = Array.new(h) { Array.new(w) { Cell.new(" ", nil, nil, 0) } }
74
+ frame = Array.new(h) { Array.new(w) { Cell.new(" ", nil, nil, 0, nil) } }
55
75
 
56
- compose_panes(frame, session)
57
- compose_drawer(frame, session) if session.drawer&.visible?
76
+ compose_panes(frame, session, input_state: input_state)
77
+ compose_drawer(frame, session, input_state: input_state) if session.drawer&.visible?
58
78
  compose_status_bar(frame, session, input_state: input_state, command_buffer: command_buffer, message: message)
59
79
  compose_help(frame, session) if help
60
80
 
@@ -63,7 +83,7 @@ module Muxr
63
83
 
64
84
  private
65
85
 
66
- def compose_panes(frame, session)
86
+ def compose_panes(frame, session, input_state: :normal)
67
87
  win = session.window
68
88
  content_area = LayoutManager::Rect.new(0, 0, session.width, session.height - 1)
69
89
  rects = LayoutManager.compute(
@@ -99,20 +119,32 @@ module Muxr
99
119
  title += " #{pane.id}" if pane.respond_to?(:id) && pane.id.is_a?(String)
100
120
  title += " [P]" if pane.respond_to?(:private?) && pane.private?
101
121
  title += " ★" if i == win.master_index
102
- title += " (" + win.layout.to_s + ")" if i == win.focused_index
122
+ # Foreground command (e.g. "npm test", "vim"). Set by the poller
123
+ # thread; nil when the shell itself is foreground. Truncate so a
124
+ # long invocation doesn't push the title past what draw_box will
125
+ # render — draw_box silently drops titles that don't fit.
126
+ if pane.respond_to?(:foreground_command) && pane.foreground_command
127
+ cmd = pane.foreground_command.to_s[0, 16]
128
+ title += " · #{cmd}"
129
+ end
103
130
  if pane.terminal.scrolled_back?
104
131
  title += " [scrollback #{pane.terminal.view_offset}/#{pane.terminal.scrollback_size}]"
105
132
  end
106
133
  draw_box(frame, rect,
107
- border: focused ? BORDER_FOCUSED : BORDER_UNFOCUSED,
134
+ border: focused ? mode_color(input_state) : BORDER_UNFOCUSED,
108
135
  bold_border: focused,
109
136
  title: title,
110
137
  title_focused: focused)
138
+ # Mode chip lives in the top-right corner of the focused container
139
+ # (this pane, or the drawer — see compose_drawer). Showing it on
140
+ # the same edge as the title but on the opposite side keeps both
141
+ # readable without one crowding the other.
142
+ draw_mode_chip(frame, rect, input_state, title) if focused
111
143
  copy_terminal(frame, pane, rect.x + 1, rect.y + 1)
112
144
  end
113
145
  end
114
146
 
115
- def compose_drawer(frame, session)
147
+ def compose_drawer(frame, session, input_state: :normal)
116
148
  drawer = session.drawer
117
149
  return unless drawer&.pane
118
150
 
@@ -150,9 +182,47 @@ module Muxr
150
182
  bold_border: true,
151
183
  title: title,
152
184
  title_focused: focused)
185
+ draw_mode_chip(frame, rect, input_state, title) if focused
153
186
  copy_terminal(frame, drawer.pane, rect.x + 1, rect.y + 1)
154
187
  end
155
188
 
189
+ def mode_color(input_state)
190
+ MODE_COLOR[input_state] || BORDER_FOCUSED
191
+ end
192
+
193
+ # Paint the " [MODE] " chip on the top border, hugging the right corner.
194
+ # Skipped when there isn't at least one column of breathing room between
195
+ # the title (anchored at the top-left) and the chip — otherwise long
196
+ # titles + a wide chip would overdraw each other and produce garbage.
197
+ def draw_mode_chip(frame, rect, input_state, title)
198
+ chip = " [#{mode_label(input_state)}] "
199
+ chip_start = rect.x + rect.w - 1 - chip.length
200
+ title_text = " #{title} "
201
+ title_end = rect.x + 2 + title_text.length - 1
202
+ return if chip_start <= title_end + 1
203
+ chip_color = mode_color(input_state)
204
+ chip.each_char.with_index do |ch, j|
205
+ set_cell(frame, rect.y, chip_start + j, ch, fg: chip_color, attrs: Terminal::BOLD)
206
+ end
207
+ end
208
+
209
+ # Two-letter-ish mode label shown in the leftmost slot of the status bar.
210
+ # Lets the user see at a glance whether single-key bindings are active
211
+ # (NORMAL) or every key passes through to the focused pane (PASS).
212
+ def mode_label(input_state)
213
+ case input_state
214
+ when :normal then "NORMAL"
215
+ when :passthrough then "PASS"
216
+ when :prefix then "^A"
217
+ when :command then "CMD"
218
+ when :scrollback then "SCROLL"
219
+ when :selection then "SEL"
220
+ when :confirm_quit then "QUIT?"
221
+ when :help then "HELP"
222
+ else "?"
223
+ end
224
+ end
225
+
156
226
  def compose_status_bar(frame, session, input_state:, command_buffer:, message:)
157
227
  y = session.height - 1
158
228
  w = session.width
@@ -163,7 +233,8 @@ module Muxr
163
233
  else "hidden"
164
234
  end
165
235
 
166
- left = " [#{session.name}]"
236
+ left = " [#{mode_label(input_state)}]"
237
+ left << " [#{session.name}]"
167
238
  left << " panes:#{win.panes.length}"
168
239
  left << " layout:#{win.layout}"
169
240
  focused_label =
@@ -190,6 +261,21 @@ module Muxr
190
261
  c.attrs = 0
191
262
  end
192
263
 
264
+ # Recolor the leading "[MODE]" chip in the mode's accent color. The
265
+ # chip lives at offset 1 (after one leading space) and runs for
266
+ # bracket+label+bracket characters. Full-row overlays below will
267
+ # overwrite this when active (command/scrollback/selection) — that's
268
+ # fine, those modes already convey themselves loudly.
269
+ chip = "[#{mode_label(input_state)}]"
270
+ chip_color = mode_color(input_state)
271
+ chip_start = 1
272
+ chip_end = [chip_start + chip.length, w].min
273
+ (chip_start...chip_end).each do |x|
274
+ c = frame[y][x]
275
+ c.fg = chip_color
276
+ c.attrs |= Terminal::BOLD
277
+ end
278
+
193
279
  if input_state == :command
194
280
  overlay = ":#{command_buffer}"
195
281
  overlay = overlay[0, w]
@@ -231,7 +317,7 @@ module Muxr
231
317
  mode = focused.terminal.selection_mode == :block ? "BLOCK" : "CHAR"
232
318
  end
233
319
  label = mode ? "SELECTION/#{mode}" : "SELECTION (cursor)"
234
- overlay = " #{label} h/j/k/l move v char C-v block y/Enter yank q cancel "
320
+ overlay = " #{label} h/j/k/l move v/space char C-v block y/Enter yank q cancel "
235
321
  overlay = overlay[0, w]
236
322
  overlay.each_char.with_index do |ch, x|
237
323
  c = frame[y][x]
@@ -265,28 +351,33 @@ module Muxr
265
351
  HELP_LINES = [
266
352
  "muxr — keybindings",
267
353
  "",
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",
354
+ "NORMAL mode (default; no prefix)",
355
+ " h / j / k / l focus pane left / down / up / right",
356
+ " i drop into passthrough mode",
357
+ " c / K new / close pane",
358
+ " t / g / m layout: tall / grid / monocle",
359
+ " Tab / Enter cycle layout / promote to master",
360
+ " a / 1..9 last pane / jump by number",
361
+ " s enter scrollback",
362
+ " ~ / C / P drawer / Claude drawer / toggle private",
363
+ " : / ? command prompt / toggle this help",
364
+ " ] / d / q paste buffer / detach / kill session",
365
+ "",
366
+ "PASSTHROUGH mode (keys reach the focused pane; prefix is Ctrl-a)",
367
+ " C-a Esc return to normal mode",
368
+ " C-a c K t g m same as normal-mode bindings",
369
+ " C-a Tab Enter cycle layout / promote master",
370
+ " C-a n / p / a next / prev / last pane",
371
+ " C-a [ ] scrollback / paste buffer",
372
+ " C-a C-a send literal Ctrl-a to focused pane",
287
373
  "",
288
- "Commands: layout {tall|grid|monocle}, drawer {toggle|show|hide|reset}, claude,",
289
- " save, restore, sessions, quit, new, close, next, prev",
374
+ "SCROLLBACK mode (exits to the mode you came from)",
375
+ " j/k d/u f/b g/G scroll C-b/C-f page v→cursor",
376
+ " cursor: h/j/k/l 0/^/$ w/e/b W/E/B H/M/L g/G",
377
+ " v select, C-v block, y/Enter yank, q/Esc cancel",
378
+ "",
379
+ "Commands: layout {tall|grid|monocle}, drawer {toggle|show|hide|reset},",
380
+ " claude, save, restore, sessions, quit, new, close, next, prev",
290
381
  "",
291
382
  "press any key to dismiss"
292
383
  ].freeze
@@ -367,6 +458,7 @@ module Muxr
367
458
  dst.bg = src.bg
368
459
  dst.attrs = src.attrs
369
460
  dst.attrs |= Terminal::REVERSE if selection && term.selected_at_visible?(r, c)
461
+ dst.hyperlink = src.hyperlink
370
462
  end
371
463
  end
372
464
  end
@@ -393,6 +485,9 @@ module Muxr
393
485
  cur_fg = :unset
394
486
  cur_bg = :unset
395
487
  cur_attrs = :unset
488
+ # We close any open hyperlink at end-of-frame, so the outer terminal
489
+ # always starts a new frame in the "no hyperlink" state.
490
+ cur_hyperlink = nil
396
491
  last_y = nil
397
492
  last_x = nil
398
493
 
@@ -410,11 +505,17 @@ module Muxr
410
505
  cur_bg = cell.bg
411
506
  cur_attrs = cell.attrs
412
507
  end
508
+ if cell.hyperlink != cur_hyperlink
509
+ out << "\e]8;;\e\\" if cur_hyperlink
510
+ out << "\e]#{cell.hyperlink}\e\\" if cell.hyperlink
511
+ cur_hyperlink = cell.hyperlink
512
+ end
413
513
  out << cell.char
414
514
  last_y = y
415
515
  last_x = x + cell.char.length
416
516
  end
417
517
  end
518
+ out << "\e]8;;\e\\" if cur_hyperlink
418
519
  out << "\e[0m"
419
520
  out << cursor_position(session, input_state: input_state, command_buffer: command_buffer)
420
521
  out << "\e[?2026l"
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
@@ -448,7 +462,25 @@ module Muxr
448
462
  private
449
463
 
450
464
  def blank_cell
451
- Cell.new(" ", nil, nil, 0)
465
+ Cell.new(" ", nil, nil, 0, nil)
466
+ end
467
+
468
+ # Parse the just-completed OSC payload. We only care about OSC 8
469
+ # (hyperlinks): `8;params;URI`. An empty URI closes the active link.
470
+ # Anything else (window-title OSC 0/1/2, palette OSC 4, …) is silently
471
+ # consumed — the emulator doesn't model it.
472
+ def finalize_osc
473
+ payload = @parser_osc
474
+ @parser_osc = +""
475
+ return if payload.empty?
476
+ return unless payload.start_with?("8;")
477
+ parts = payload.split(";", 3)
478
+ uri = parts[2]
479
+ if uri.nil? || uri.empty?
480
+ @current_hyperlink = nil
481
+ else
482
+ @current_hyperlink = (@hyperlink_intern[payload] ||= payload.dup.freeze)
483
+ end
452
484
  end
453
485
 
454
486
  def process_char(ch)
@@ -462,11 +494,18 @@ module Muxr
462
494
  csi_char(ch, b)
463
495
  when :osc
464
496
  if b == 0x07 || b == 0x9c
497
+ finalize_osc
465
498
  @parser_state = :ground
466
499
  elsif b == 0x1b
467
500
  @parser_state = :osc_esc
501
+ elsif @parser_osc.bytesize < OSC_MAX_LEN
502
+ @parser_osc << ch
468
503
  end
469
504
  when :osc_esc
505
+ # ST is `ESC \`. Anything else is malformed but we still flush — most
506
+ # terminals are lenient here, and being strict would swallow the
507
+ # payload on slightly buggy emitters.
508
+ finalize_osc
470
509
  @parser_state = :ground
471
510
  when :charset
472
511
  @parser_state = :ground
@@ -505,6 +544,7 @@ module Muxr
505
544
  @parser_params = +""
506
545
  when 0x5d # ]
507
546
  @parser_state = :osc
547
+ @parser_osc = +""
508
548
  when 0x28, 0x29, 0x2a, 0x2b # ( ) * +
509
549
  @parser_state = :charset
510
550
  when 0x37 # 7 save cursor
@@ -699,6 +739,7 @@ module Muxr
699
739
  cell.fg = @fg
700
740
  cell.bg = @bg
701
741
  cell.attrs = @attrs
742
+ cell.hyperlink = @current_hyperlink
702
743
  if @cursor_col >= @cols - 1
703
744
  @autowrap_pending = true
704
745
  else
@@ -1020,6 +1061,7 @@ module Muxr
1020
1061
  @scroll_top = 0
1021
1062
  @scroll_bottom = @rows - 1
1022
1063
  @autowrap_pending = false
1064
+ @current_hyperlink = nil
1023
1065
  end
1024
1066
  end
1025
1067
  end
data/lib/muxr/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Muxr
2
- VERSION = "0.1.5"
2
+ VERSION = "0.1.6"
3
3
  end
data/lib/muxr.rb CHANGED
@@ -3,6 +3,7 @@ require_relative "muxr/pty_process"
3
3
  require_relative "muxr/terminal"
4
4
  require_relative "muxr/pane"
5
5
  require_relative "muxr/drawer"
6
+ require_relative "muxr/foreground_command"
6
7
  require_relative "muxr/layout_manager"
7
8
  require_relative "muxr/window"
8
9
  require_relative "muxr/session"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: muxr
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.5
4
+ version: 0.1.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roel Bondoc
@@ -61,6 +61,7 @@ files:
61
61
  - lib/muxr/command_dispatcher.rb
62
62
  - lib/muxr/control_server.rb
63
63
  - lib/muxr/drawer.rb
64
+ - lib/muxr/foreground_command.rb
64
65
  - lib/muxr/input_handler.rb
65
66
  - lib/muxr/key_parser.rb
66
67
  - lib/muxr/layout_manager.rb