muxr 0.1.4 → 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(
@@ -93,27 +113,48 @@ module Muxr
93
113
  focused = (i == win.focused_index) && !(session.focus_drawer && session.drawer&.visible?)
94
114
  title = "##{i + 1}"
95
115
  title += "/#{win.panes.length}" if monocle
116
+ # Stable id sits after the slot number so monocle reads "#1/3 a3f9b2".
117
+ # respond_to? guard keeps renderer tests (which use simple struct fakes)
118
+ # from blowing up when a pane stand-in doesn't implement #id.
119
+ title += " #{pane.id}" if pane.respond_to?(:id) && pane.id.is_a?(String)
120
+ title += " [P]" if pane.respond_to?(:private?) && pane.private?
96
121
  title += " ★" if i == win.master_index
97
- 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
98
130
  if pane.terminal.scrolled_back?
99
131
  title += " [scrollback #{pane.terminal.view_offset}/#{pane.terminal.scrollback_size}]"
100
132
  end
101
133
  draw_box(frame, rect,
102
- border: focused ? BORDER_FOCUSED : BORDER_UNFOCUSED,
134
+ border: focused ? mode_color(input_state) : BORDER_UNFOCUSED,
103
135
  bold_border: focused,
104
136
  title: title,
105
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
106
143
  copy_terminal(frame, pane, rect.x + 1, rect.y + 1)
107
144
  end
108
145
  end
109
146
 
110
- def compose_drawer(frame, session)
147
+ def compose_drawer(frame, session, input_state: :normal)
111
148
  drawer = session.drawer
112
149
  return unless drawer&.pane
113
150
 
114
151
  w = session.width
115
152
  h = session.height
116
- dh = (h * 0.35).round.clamp(5, h - 2)
153
+ # Drawer height is the larger of "16 rows" or 35% of the screen — the
154
+ # 35% rule is fine on tall terminals but uselessly small on short ones,
155
+ # so we floor it at 16 to keep the drawer practical. Final clamp keeps
156
+ # room for panes + status bar on very small terminals.
157
+ dh = [16, (h * 0.35).round].max.clamp(5, h - 2)
117
158
  dy = h - 1 - dh
118
159
  rect = LayoutManager::Rect.new(0, dy, w, dh)
119
160
  drawer.pane.rect = rect
@@ -141,9 +182,47 @@ module Muxr
141
182
  bold_border: true,
142
183
  title: title,
143
184
  title_focused: focused)
185
+ draw_mode_chip(frame, rect, input_state, title) if focused
144
186
  copy_terminal(frame, drawer.pane, rect.x + 1, rect.y + 1)
145
187
  end
146
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
+
147
226
  def compose_status_bar(frame, session, input_state:, command_buffer:, message:)
148
227
  y = session.height - 1
149
228
  w = session.width
@@ -154,7 +233,8 @@ module Muxr
154
233
  else "hidden"
155
234
  end
156
235
 
157
- left = " [#{session.name}]"
236
+ left = " [#{mode_label(input_state)}]"
237
+ left << " [#{session.name}]"
158
238
  left << " panes:#{win.panes.length}"
159
239
  left << " layout:#{win.layout}"
160
240
  focused_label =
@@ -181,6 +261,21 @@ module Muxr
181
261
  c.attrs = 0
182
262
  end
183
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
+
184
279
  if input_state == :command
185
280
  overlay = ":#{command_buffer}"
186
281
  overlay = overlay[0, w]
@@ -222,7 +317,7 @@ module Muxr
222
317
  mode = focused.terminal.selection_mode == :block ? "BLOCK" : "CHAR"
223
318
  end
224
319
  label = mode ? "SELECTION/#{mode}" : "SELECTION (cursor)"
225
- 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 "
226
321
  overlay = overlay[0, w]
227
322
  overlay.each_char.with_index do |ch, x|
228
323
  c = frame[y][x]
@@ -256,26 +351,33 @@ module Muxr
256
351
  HELP_LINES = [
257
352
  "muxr — keybindings",
258
353
  "",
259
- " C-a c new pane",
260
- " C-a n / p next / prev pane",
261
- " C-a a toggle to previously focused pane",
262
- " C-a 1..9 jump to pane by number",
263
- " C-a k close focused pane",
264
- " C-a Tab cycle layout (tall grid → monocle)",
265
- " C-a Enter promote focused pane to master",
266
- " C-a ~ toggle drawer",
267
- " C-a [ enter scrollback (j/k d/u f g/G C-b/C-f; v→cursor, q quits)",
268
- " cursor: v select, C-v block, y yank, q cancel",
269
- " motions: h/j/k/l 0/^/$ w/e/b W/E/B H/M/L g/G",
270
- " C-a ] paste internal copy buffer",
271
- " C-a d detach (server keeps running)",
272
- " C-a q kill session (asks y/n)",
273
- " C-a : command prompt",
274
- " C-a ? toggle this help",
275
- " 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",
373
+ "",
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",
276
378
  "",
277
379
  "Commands: layout {tall|grid|monocle}, drawer {toggle|show|hide|reset},",
278
- " save, restore, sessions, quit, new, close, next, prev",
380
+ " claude, save, restore, sessions, quit, new, close, next, prev",
279
381
  "",
280
382
  "press any key to dismiss"
281
383
  ].freeze
@@ -356,6 +458,7 @@ module Muxr
356
458
  dst.bg = src.bg
357
459
  dst.attrs = src.attrs
358
460
  dst.attrs |= Terminal::REVERSE if selection && term.selected_at_visible?(r, c)
461
+ dst.hyperlink = src.hyperlink
359
462
  end
360
463
  end
361
464
  end
@@ -382,6 +485,9 @@ module Muxr
382
485
  cur_fg = :unset
383
486
  cur_bg = :unset
384
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
385
491
  last_y = nil
386
492
  last_x = nil
387
493
 
@@ -399,11 +505,17 @@ module Muxr
399
505
  cur_bg = cell.bg
400
506
  cur_attrs = cell.attrs
401
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
402
513
  out << cell.char
403
514
  last_y = y
404
515
  last_x = x + cell.char.length
405
516
  end
406
517
  end
518
+ out << "\e]8;;\e\\" if cur_hyperlink
407
519
  out << "\e[0m"
408
520
  out << cursor_position(session, input_state: input_state, command_buffer: command_buffer)
409
521
  out << "\e[?2026l"
data/lib/muxr/session.rb CHANGED
@@ -45,7 +45,7 @@ module Muxr
45
45
  "focused_index" => @window.focused_index,
46
46
  "master_index" => @window.master_index,
47
47
  "focus_drawer" => @focus_drawer,
48
- "panes" => @window.panes.map { |p| { "cwd" => safe_cwd(p) } },
48
+ "panes" => @window.panes.map { |p| { "id" => safe_id(p), "cwd" => safe_cwd(p), "private" => safe_private(p) } },
49
49
  "drawer" => serialize_drawer
50
50
  }
51
51
  end
@@ -76,11 +76,22 @@ module Muxr
76
76
  pane.respond_to?(:cwd) ? pane.cwd : nil
77
77
  end
78
78
 
79
+ def safe_id(pane)
80
+ return nil unless pane.respond_to?(:id)
81
+ id = pane.id
82
+ id.is_a?(String) ? id : nil
83
+ end
84
+
85
+ def safe_private(pane)
86
+ pane.respond_to?(:private?) && pane.private?
87
+ end
88
+
79
89
  def serialize_drawer
80
90
  return nil unless @drawer
81
91
  {
82
92
  "visible" => @drawer.visible?,
83
- "cwd" => @drawer.cwd
93
+ "cwd" => @drawer.cwd,
94
+ "command" => @drawer.respond_to?(:command) ? @drawer.command : nil
84
95
  }
85
96
  end
86
97
  end
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
@@ -62,6 +76,19 @@ module Muxr
62
76
  @selection_mode = :linear
63
77
  @sync_pending = false
64
78
  @sync_started_at = nil
79
+ @pending_replies = +"".b
80
+ end
81
+
82
+ # Bytes the emulator owes back to the inner program in response to a
83
+ # query (currently DSR / Device Status Report — `\e[5n` and `\e[6n`).
84
+ # The Pane drains this after each feed and writes it to the PTY's input
85
+ # side. Without it, programs like the AWS CLI fall back with a warning
86
+ # ("your terminal doesn't support cursor position requests (CPR)").
87
+ def take_pending_replies!
88
+ return nil if @pending_replies.empty?
89
+ data = @pending_replies
90
+ @pending_replies = +"".b
91
+ data
65
92
  end
66
93
 
67
94
  # True iff the inner program has opened a synchronized-output block
@@ -89,6 +116,20 @@ module Muxr
89
116
  @buffer[r][c]
90
117
  end
91
118
 
119
+ # Return the currently-visible grid as a text string (rows joined by "\n",
120
+ # trailing whitespace stripped on each row). Used by the control surface
121
+ # to expose pane contents to programmatic clients (the MCP bridge in
122
+ # particular). This walks visible_cell so callers see whatever the user
123
+ # is currently looking at, including scrollback.
124
+ def dump_text
125
+ lines = Array.new(@rows) do |r|
126
+ row = String.new(capacity: @cols)
127
+ @cols.times { |c| row << visible_cell(r, c).char }
128
+ row.rstrip
129
+ end
130
+ lines.join("\n")
131
+ end
132
+
92
133
  # Returns the Cell that should be visible at (r, c) given the current
93
134
  # scrollback view_offset. When view_offset == 0 this is the live grid.
94
135
  # When view_offset > 0, rows in the top of the visible area are sourced
@@ -421,7 +462,25 @@ module Muxr
421
462
  private
422
463
 
423
464
  def blank_cell
424
- 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
425
484
  end
426
485
 
427
486
  def process_char(ch)
@@ -435,11 +494,18 @@ module Muxr
435
494
  csi_char(ch, b)
436
495
  when :osc
437
496
  if b == 0x07 || b == 0x9c
497
+ finalize_osc
438
498
  @parser_state = :ground
439
499
  elsif b == 0x1b
440
500
  @parser_state = :osc_esc
501
+ elsif @parser_osc.bytesize < OSC_MAX_LEN
502
+ @parser_osc << ch
441
503
  end
442
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
443
509
  @parser_state = :ground
444
510
  when :charset
445
511
  @parser_state = :ground
@@ -478,6 +544,7 @@ module Muxr
478
544
  @parser_params = +""
479
545
  when 0x5d # ]
480
546
  @parser_state = :osc
547
+ @parser_osc = +""
481
548
  when 0x28, 0x29, 0x2a, 0x2b # ( ) * +
482
549
  @parser_state = :charset
483
550
  when 0x37 # 7 save cursor
@@ -645,6 +712,16 @@ module Muxr
645
712
  @saved_cursor = [@cursor_row, @cursor_col]
646
713
  when "u"
647
714
  @cursor_row, @cursor_col = @saved_cursor
715
+ when "n"
716
+ # DSR — Device Status Report. `\e[5n` asks if the terminal is OK,
717
+ # `\e[6n` (CPR) asks for the cursor position. The reply rides back
718
+ # through the PTY's input side; see take_pending_replies!.
719
+ case pms[0] || 0
720
+ when 5
721
+ @pending_replies << "\e[0n".b
722
+ when 6
723
+ @pending_replies << "\e[#{@cursor_row + 1};#{@cursor_col + 1}R".b
724
+ end
648
725
  when "h", "l"
649
726
  # Non-private mode set/reset — nothing we need to honor. (DEC private
650
727
  # `?`-prefixed mode sequences are short-circuited above.)
@@ -662,6 +739,7 @@ module Muxr
662
739
  cell.fg = @fg
663
740
  cell.bg = @bg
664
741
  cell.attrs = @attrs
742
+ cell.hyperlink = @current_hyperlink
665
743
  if @cursor_col >= @cols - 1
666
744
  @autowrap_pending = true
667
745
  else
@@ -983,6 +1061,7 @@ module Muxr
983
1061
  @scroll_top = 0
984
1062
  @scroll_bottom = @rows - 1
985
1063
  @autowrap_pending = false
1064
+ @current_hyperlink = nil
986
1065
  end
987
1066
  end
988
1067
  end
data/lib/muxr/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Muxr
2
- VERSION = "0.1.4"
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"
@@ -10,6 +11,7 @@ require_relative "muxr/renderer"
10
11
  require_relative "muxr/input_handler"
11
12
  require_relative "muxr/command_dispatcher"
12
13
  require_relative "muxr/protocol"
14
+ require_relative "muxr/control_server"
13
15
  require_relative "muxr/application"
14
16
  require_relative "muxr/client"
15
17
 
data/muxr.gemspec CHANGED
@@ -26,10 +26,12 @@ Gem::Specification.new do |spec|
26
26
  }
27
27
 
28
28
  spec.bindir = "bin"
29
- spec.executables = ["muxr"]
29
+ spec.executables = ["muxr", "muxr-mcp"]
30
30
  spec.files = Dir[
31
31
  "lib/**/*.rb",
32
32
  "bin/muxr",
33
+ "bin/muxr-mcp",
34
+ "skills/**/*",
33
35
  "README.md",
34
36
  "CHANGELOG.md",
35
37
  "LICENSE.txt",