Class: Muxr::Application

Inherits:
Object
  • Object
show all
Defined in:
lib/muxr/application.rb

Overview

The Application is the muxr server. It owns the Session, panes, Renderer, and InputHandler, and listens on a Unix socket at ~/.muxr/sockets/<name>.sock for a Client to attach. Shells and other PTY processes survive client detach/reattach — only the listening socket and the one currently-attached client come and go.

The Renderer’s output sink is a small adapter that frames its bytes into OUTPUT messages on the attached client; when no client is attached the bytes are silently dropped (we also skip the render entirely in that case). PTY data still gets drained even with no client, so the in-memory Terminal grids stay up to date and are repainted in full on the next attach via Renderer#reset_frame!.

Defined Under Namespace

Classes: FramedOutput

Constant Summary collapse

SELECT_TIMEOUT =
0.05
MIN_FRAME_INTERVAL =

~60 Hz cap on full repaints. Keystrokes in fzf or vim navigation can trigger PTY bursts faster than the terminal can usefully display them; the cap collapses those bursts and stops intermediate frames from showing through.

1.0 / 60
SOCKETS_DIR =
File.join(Dir.home, ".muxr", "sockets").freeze
DEFAULT_WIDTH =
80
DEFAULT_HEIGHT =
24
FOREGROUND_POLL_INTERVAL =

Interval for the background thread that refreshes each pane’s foreground-command label. Picked to feel responsive (a long-running ‘npm test` shows up within a second of starting) without burning CPU on macOS, where each tick costs a `ps` fork+exec per pane.

0.75
BRACKETED_PASTE_MARKERS =

Bytes the outer terminal wraps around a paste once bracketed-paste mode is on (the client enables it unconditionally — see Client#enter_terminal_mode).

["\e[200~".b, "\e[201~".b].freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(argv = []) ⇒ Application

Returns a new instance of Application.



59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
# File 'lib/muxr/application.rb', line 59

def initialize(argv = [])
  @argv = argv
  @session_name = parse_session_name(argv)
  @running = false
  @needs_render = true
  @message = nil
  @message_expires = nil
  @help_visible = false
  @current_client = nil
  @client_write_buffer = +"".b
  @listening_socket = nil
  # The directory bin/muxr was launched from — Process.daemon(true, ...)
  # preserves it across daemonization. Every new pane (and the drawer)
  # starts here, treating it as the session's project root regardless of
  # where the focused pane's shell has wandered.
  @origin_cwd = Dir.pwd
  @socket_path = self.class.socket_path_for(@session_name)
  @control_socket_path = self.class.control_socket_path_for(@session_name)
  @control_server = nil
  @paste_buffer = +""
  # Trailing bytes of an in-flight INPUT chunk that look like the start of
  # a bracketed-paste marker but were cut off by the 4 KiB read boundary.
  # Held back and prepended to the next chunk so a split marker still gets
  # recognized — see #strip_bracketed_paste_markers.
  @paste_marker_tail = +"".b
  @last_render_at = nil
  @foreground_poller = nil
  # Opt-in diagnostic tap. When MUXR_TRACE_OUTPUT names a writable path, the
  # server appends every byte it sends to the client — i.e. exactly what the
  # outer terminal receives. Replaying it (`cat` it into a fresh terminal, or
  # feed it to a reference emulator) reproduces a rendering bug from the byte
  # stream alone, which tells us whether corruption is in muxr's emitted
  # output or somewhere downstream. Off unless the env var is set.
  @trace_output = open_trace(ENV["MUXR_TRACE_OUTPUT"])
end

Instance Attribute Details

#control_serverObject (readonly)

Returns the value of attribute control_server.



28
29
30
# File 'lib/muxr/application.rb', line 28

def control_server
  @control_server
end

#inputObject (readonly)

Returns the value of attribute input.



28
29
30
# File 'lib/muxr/application.rb', line 28

def input
  @input
end

#paste_bufferObject (readonly)

Returns the value of attribute paste_buffer.



108
109
110
# File 'lib/muxr/application.rb', line 108

def paste_buffer
  @paste_buffer
end

#rendererObject (readonly)

Returns the value of attribute renderer.



28
29
30
# File 'lib/muxr/application.rb', line 28

def renderer
  @renderer
end

#sessionObject (readonly)

Returns the value of attribute session.



28
29
30
# File 'lib/muxr/application.rb', line 28

def session
  @session
end

#session_nameObject (readonly)

Returns the value of attribute session_name.



28
29
30
# File 'lib/muxr/application.rb', line 28

def session_name
  @session_name
end

Class Method Details

.alive_socket?(path) ⇒ Boolean

Returns:

  • (Boolean)


51
52
53
54
55
56
57
# File 'lib/muxr/application.rb', line 51

def self.alive_socket?(path)
  return false unless File.exist?(path)
  UNIXSocket.new(path).close
  true
rescue SystemCallError
  false
end

.control_socket_path_for(name) ⇒ Object



34
35
36
# File 'lib/muxr/application.rb', line 34

def self.control_socket_path_for(name)
  File.join(SOCKETS_DIR, "#{name}.ctrl.sock")
end

.list_activeObject

Names of sessions whose server socket is currently accepting connections. Stale sockets (file exists, no listener) are skipped but left in place; cleanup happens on the next attach attempt.



41
42
43
44
45
46
47
48
49
# File 'lib/muxr/application.rb', line 41

def self.list_active
  return [] unless File.directory?(SOCKETS_DIR)
  Dir.children(SOCKETS_DIR).filter_map do |entry|
    next unless entry.end_with?(".sock")
    path = File.join(SOCKETS_DIR, entry)
    next unless alive_socket?(path)
    File.basename(entry, ".sock")
  end.sort
end

.socket_path_for(name) ⇒ Object



30
31
32
# File 'lib/muxr/application.rb', line 30

def self.socket_path_for(name)
  File.join(SOCKETS_DIR, "#{name}.sock")
end

Instance Method Details

#cancel_closeObject



349
350
351
352
353
354
# File 'lib/muxr/application.rb', line 349

def cancel_close
  @message = nil
  @message_expires = nil
  flash("cancelled")
  invalidate
end

#cancel_quitObject



474
475
476
477
478
479
# File 'lib/muxr/application.rb', line 474

def cancel_quit
  @message = nil
  @message_expires = nil
  flash("cancelled")
  invalidate
end

#cancel_searchObject



540
541
542
543
# File 'lib/muxr/application.rb', line 540

def cancel_search
  @renderer.reset_frame!
  invalidate
end

#close_focusedObject



356
357
358
359
360
361
362
363
364
365
# File 'lib/muxr/application.rb', line 356

def close_focused
  if @session.focus_drawer && @session.drawer&.visible?
    hide_drawer
    return
  end
  pane = focused_pane
  return unless pane
  @session.window.remove_pane(pane)
  invalidate
end

#commit_search(query) ⇒ Object



522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
# File 'lib/muxr/application.rb', line 522

def commit_search(query)
  target = focused_target
  return unless target
  term = target.terminal
  direction = @input.search_direction
  count = term.search(query, direction: direction)
  if query.empty?
    # Empty query just dismisses the prompt; leave the prior search
    # state alone (term.search already cleared it though).
  elsif count.zero?
    flash("not found: #{query}")
  else
    flash("#{count} match#{count == 1 ? "" : "es"} (n/N to navigate)")
  end
  @renderer.reset_frame!
  invalidate
end

#confirm_closeObject



345
346
347
# File 'lib/muxr/application.rb', line 345

def confirm_close
  close_focused
end

#confirm_quitObject



470
471
472
# File 'lib/muxr/application.rb', line 470

def confirm_quit
  shutdown_server
end

#cycle_layoutObject



367
368
369
370
371
# File 'lib/muxr/application.rb', line 367

def cycle_layout
  @session.window.cycle_layout
  flash("layout: #{@session.window.layout}")
  invalidate
end

#deliver_output(bytes) ⇒ Object

Called by the FramedOutput adapter; queues one OUTPUT frame to the currently attached client and tries to push as much as the socket will take without blocking. Anything left over stays in socket reports writable. This prevents a slow client (or slow terminal upstream of the client) from deadlocking the server when the server is also trying to read from that same client.



718
719
720
721
722
723
724
725
# File 'lib/muxr/application.rb', line 718

def deliver_output(bytes)
  return unless @current_client
  if @trace_output
    @trace_output.write(bytes) rescue nil
  end
  @client_write_buffer << Protocol.frame(Protocol::OUTPUT, bytes)
  drain_client_writes
end

#detachObject



446
447
448
449
450
# File 'lib/muxr/application.rb', line 446

def detach
  flash("detached")
  disconnect_client(reason: "detached")
  # Server keeps running. Next `bin/muxr <name>` invocation will re-attach.
end

#dismiss_helpObject



492
493
494
495
# File 'lib/muxr/application.rb', line 492

def dismiss_help
  @help_visible = false
  invalidate
end

#drain_client_writesObject



727
728
729
730
731
732
733
734
735
736
737
738
739
# File 'lib/muxr/application.rb', line 727

def drain_client_writes
  return unless @current_client
  return if @client_write_buffer.empty?
  loop do
    n = @current_client.write_nonblock(@client_write_buffer)
    @client_write_buffer = @client_write_buffer.byteslice(n..-1) || +"".b
    break if @client_write_buffer.empty?
  end
rescue IO::WaitWritable
  # Socket send buffer is full; the rest stays queued.
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
  drop_client_silently
end

#enter_normal_modeObject

Bound to ‘Ctrl-a Esc` from passthrough — return to normal mode.



325
326
327
328
329
# File 'lib/muxr/application.rb', line 325

def enter_normal_mode
  @input.enter_normal_mode
  flash("normal mode")
  invalidate
end

#enter_passthrough_modeObject

Bound to ‘i` in normal mode — drops the user into the historical Ctrl-a-prefixed multiplexer mode.



318
319
320
321
322
# File 'lib/muxr/application.rb', line 318

def enter_passthrough_mode
  @input.enter_passthrough_mode
  flash("passthrough mode (^a esc to return)")
  invalidate
end

#enter_scrollbackObject



497
498
499
500
501
502
503
# File 'lib/muxr/application.rb', line 497

def enter_scrollback
  target = focused_target
  return unless target
  @input.enter_scrollback_mode
  @renderer.reset_frame!
  invalidate
end

#enter_search(direction: :forward) ⇒ Object

Bound to ‘/` (forward) and `?` (backward) in scrollback mode. Drops the user into a buffered prompt; commit_search / cancel_search exit back to scrollback.



517
518
519
520
# File 'lib/muxr/application.rb', line 517

def enter_search(direction: :forward)
  @input.enter_search_mode(direction: direction)
  invalidate
end

#enter_selectionObject



583
584
585
586
587
588
589
590
591
592
593
594
595
# File 'lib/muxr/application.rb', line 583

def enter_selection
  target = focused_target
  return unless target
  # Vim-style: drop the user at a movable cursor with NO selection yet.
  # They navigate with h/j/k/l, then press v (linear) or C-v (block) to
  # anchor. Start at the live cursor's visible position so the user lands
  # where their attention already is, instead of the top-left corner.
  term = target.terminal
  term.place_selection_cursor(term.cursor_row, term.cursor_col)
  @input.enter_selection_mode
  @renderer.reset_frame!
  invalidate
end

#exit_scrollbackObject



505
506
507
508
509
510
511
512
# File 'lib/muxr/application.rb', line 505

def exit_scrollback
  target = focused_target
  target&.terminal&.clear_selection
  target&.terminal&.clear_search
  target&.terminal&.scroll_to_bottom
  @renderer.reset_frame!
  invalidate
end

#exit_selection(yank:) ⇒ Object



614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
# File 'lib/muxr/application.rb', line 614

def exit_selection(yank:)
  target = focused_target
  term = target&.terminal
  if yank
    # No anchor → no-op. User is still positioning; they can press v
    # first, then yank. Esc/q is the way to exit from navigation.
    return unless term&.selection_active?
    text = term.extract_selection_text
    unless text.empty?
      @paste_buffer = text
      spawn_pbcopy(text)
      flash("yanked #{text.bytesize} bytes")
    end
  end
  term&.clear_selection
  # Drop back into scrollback at the current position whether or not we
  # yanked. We no longer snap to the live bottom on yank — the user stays
  # where they were reading so they can keep selecting or scrolling, and
  # `q`/Esc is still there when they want to return to the bottom.
  @input.enter_scrollback_mode
  @renderer.reset_frame!
  invalidate
end

#find_nextObject



545
546
547
# File 'lib/muxr/application.rb', line 545

def find_next
  step_search(@input.search_direction)
end

#find_prevObject



549
550
551
# File 'lib/muxr/application.rb', line 549

def find_prev
  step_search(@input.search_direction == :forward ? :backward : :forward)
end

#flash(msg) ⇒ Object



677
678
679
680
681
# File 'lib/muxr/application.rb', line 677

def flash(msg)
  @message = msg
  @message_expires = Time.now + 2.5
  invalidate
end

#focus_direction(direction) ⇒ Object

Move focus to the pane spatially adjacent in ‘direction` (:left/:right/ :up/:down). Called by the normal-mode hjkl bindings. Pulling the live layout rects keeps this in sync with whatever the renderer is showing. Monocle has no meaningful direction (every rect is identical) so we fall back to linear nav so hjkl still does something.



249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
# File 'lib/muxr/application.rb', line 249

def focus_direction(direction)
  return if @session.window.panes.empty?
  if @session.focus_drawer && @session.drawer&.visible?
    @session.focus_drawer = false
    invalidate
    return
  end

  win = @session.window
  idx = LayoutManager.neighbor(current_pane_rects, win.focused_index, direction)
  if idx.nil? && win.layout == :monocle
    case direction
    when :right, :down then win.focus_next
    when :left, :up    then win.focus_prev
    end
    sync_input_mode_to_focus
    invalidate
    return
  end

  return unless idx
  win.focus_index(idx)
  sync_input_mode_to_focus
  invalidate
end

#focus_lastObject



210
211
212
213
214
215
216
217
218
219
# File 'lib/muxr/application.rb', line 210

def focus_last
  return if @session.window.panes.empty?
  if @session.focus_drawer && @session.drawer&.visible?
    @session.focus_drawer = false
  else
    @session.window.focus_last
  end
  sync_input_mode_to_focus
  invalidate
end

#focus_nextObject



188
189
190
191
192
193
194
195
196
197
# File 'lib/muxr/application.rb', line 188

def focus_next
  return if @session.window.panes.empty?
  if @session.focus_drawer && @session.drawer&.visible?
    @session.focus_drawer = false
  else
    @session.window.focus_next
  end
  sync_input_mode_to_focus
  invalidate
end

#focus_pane_number(n) ⇒ Object



221
222
223
224
225
226
227
228
229
# File 'lib/muxr/application.rb', line 221

def focus_pane_number(n)
  return if @session.window.panes.empty?
  idx = n - 1
  return unless idx >= 0 && idx < @session.window.panes.length
  @session.focus_drawer = false
  @session.window.focus_index(idx)
  sync_input_mode_to_focus
  invalidate
end

#focus_prevObject



199
200
201
202
203
204
205
206
207
208
# File 'lib/muxr/application.rb', line 199

def focus_prev
  return if @session.window.panes.empty?
  if @session.focus_drawer && @session.drawer&.visible?
    @session.focus_drawer = false
  else
    @session.window.focus_prev
  end
  sync_input_mode_to_focus
  invalidate
end

#hide_drawerObject



427
428
429
430
431
432
433
# File 'lib/muxr/application.rb', line 427

def hide_drawer
  return unless @session.drawer&.visible?
  @session.drawer.hide!
  @session.focus_drawer = false
  renderer.reset_frame!
  invalidate
end

#invalidateObject



683
684
685
# File 'lib/muxr/application.rb', line 683

def invalidate
  @needs_render = true
end

#list_sessionsObject



701
702
703
704
705
706
707
708
709
# File 'lib/muxr/application.rb', line 701

def list_sessions
  names = Session.list
  if names.empty?
    flash("no saved sessions")
  else
    marker = ->(n) { n == @session_name ? "*#{n}" : n }
    flash("sessions: #{names.map(&marker).join(", ")}")
  end
end

#move_direction(direction) ⇒ Object

Swap the focused pane with its spatial neighbor in ‘direction`. Bound to shift-HJKL in normal mode. Mirrors focus_direction’s geometry-aware lookup so the same “what does my arrow point at” intuition decides which neighbor gets bumped. Monocle has no spatial layout, so HJKL falls back to reordering by linear next/prev — useful for shuffling the master before flipping back to tall/grid.



281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
# File 'lib/muxr/application.rb', line 281

def move_direction(direction)
  return if @session.window.panes.empty?
  # The drawer isn't part of the tiled pane list; HJKL while focused on
  # it would be ambiguous. No-op.
  return if @session.focus_drawer && @session.drawer&.visible?

  win = @session.window
  idx = LayoutManager.neighbor(current_pane_rects, win.focused_index, direction)
  if idx.nil? && win.layout == :monocle
    target = case direction
             when :right, :down then (win.focused_index + 1) % win.panes.length
             when :left, :up    then (win.focused_index - 1) % win.panes.length
             end
    if target && target != win.focused_index
      win.move_focused_to(target)
      invalidate
    end
    return
  end

  return unless idx
  win.move_focused_to(idx)
  invalidate
end

#move_selection(action) ⇒ Object



638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
# File 'lib/muxr/application.rb', line 638

def move_selection(action)
  target = focused_target
  return unless target
  term = target.terminal
  rows = term.rows
  cols = term.cols
  case action
  when :left       then term.move_selection_cursor_by(0, -1)
  when :right      then term.move_selection_cursor_by(0, 1)
  when :up         then term.move_selection_cursor_by(-1, 0)
  when :down       then term.move_selection_cursor_by(1, 0)
  when :half_up    then term.move_selection_cursor_by(-[rows / 2, 1].max, 0)
  when :half_down  then term.move_selection_cursor_by([rows / 2, 1].max, 0)
  when :full_up    then term.move_selection_cursor_by(-[rows - 1, 1].max, 0)
  when :full_down  then term.move_selection_cursor_by([rows - 1, 1].max, 0)
  when :line_start then term.selection_cursor_to_line_start
  when :line_end   then term.selection_cursor_to_line_end
  when :line_first_nonblank then term.selection_cursor_to_first_non_blank
  when :top              then term.selection_cursor_to_top
  when :bottom           then term.selection_cursor_to_bottom
  when :screen_top       then term.selection_cursor_to_viewport(:top)
  when :screen_middle    then term.selection_cursor_to_viewport(:middle)
  when :screen_bottom    then term.selection_cursor_to_viewport(:bottom)
  when :word_forward      then term.selection_cursor_word_forward(big: false)
  when :word_forward_big  then term.selection_cursor_word_forward(big: true)
  when :word_end          then term.selection_cursor_word_end(big: false)
  when :word_end_big      then term.selection_cursor_word_end(big: true)
  when :word_backward     then term.selection_cursor_word_backward(big: false)
  when :word_backward_big then term.selection_cursor_word_backward(big: true)
  end
  invalidate
end

#new_pane(cwd: nil) ⇒ Object



178
179
180
181
182
183
184
185
186
# File 'lib/muxr/application.rb', line 178

def new_pane(cwd: nil)
  cwd ||= @origin_cwd
  pane = make_pane(cwd: cwd)
  @session.window.add_pane(pane)
  @session.focus_drawer = false
  @session.window.focused_index = @session.window.panes.length - 1
  invalidate
  pane
end

#open_trace(path) ⇒ Object



95
96
97
98
99
100
# File 'lib/muxr/application.rb', line 95

def open_trace(path)
  return nil if path.nil? || path.empty?
  File.open(path, "ab")
rescue SystemCallError
  nil
end

#paste_from_bufferObject



671
672
673
674
675
# File 'lib/muxr/application.rb', line 671

def paste_from_buffer
  return if @paste_buffer.nil? || @paste_buffer.empty?
  target = focused_target
  target&.write(@paste_buffer)
end

#pending_marker_prefix(buf) ⇒ Object

Length (2..5) of the longest suffix of ‘buf` that is a proper prefix of a bracketed-paste marker, so the remainder can arrive in the next chunk. A bare trailing ESC (length 1) is deliberately not held: it’s almost always the Escape key and the program must see it without waiting on the next keystroke. Worst case a marker split right after its ESC leaks a few bytes, which the program reads as a harmless unknown escape.



169
170
171
172
173
174
175
176
# File 'lib/muxr/application.rb', line 169

def pending_marker_prefix(buf)
  max = [buf.bytesize, 5].min
  max.downto(2) do |k|
    tail = buf.byteslice(buf.bytesize - k, k)
    return k if BRACKETED_PASTE_MARKERS.any? { |m| m.byteslice(0, k) == tail }
  end
  0
end

#promote_masterObject



390
391
392
393
# File 'lib/muxr/application.rb', line 390

def promote_master
  @session.window.promote_to_master
  invalidate
end

#quitObject

Both Ctrl-a q and :quit funnel through here. We don’t kill the server immediately — InputHandler enters a confirmation state and the user has to press ‘y’ to actually shut down (see :request_quit_confirmed).



455
456
457
# File 'lib/muxr/application.rb', line 455

def quit
  request_quit
end

#quit_immediateObject



459
460
461
# File 'lib/muxr/application.rb', line 459

def quit_immediate
  request_quit
end

#refresh_focusedObject

Bound to ‘r` (normal) / `Ctrl-a r` (passthrough). Two-layer repaint to recover from a corrupted display, whichever layer drifted:

1. Nudge the focused program to redraw itself (SIGWINCH wiggle). This
   fixes muxr's own Terminal grid when an unhandled or wide glyph
   desynced the cursor — reset_frame! alone can't, since it would just
   faithfully re-emit the wrong grid.
2. Force a full re-emit of our composed frame to the outer terminal,
   fixing the case where the outer display lost/garbled bytes but our
   grid is correct.


382
383
384
385
386
387
388
# File 'lib/muxr/application.rb', line 382

def refresh_focused
  target = focused_target
  target.request_redraw if target.respond_to?(:request_redraw)
  @renderer.reset_frame!
  flash("refreshed")
  invalidate
end

#request_closeObject

Two-step close — same shape as the quit flow. Hiding the drawer is cheap and reversible, so we skip the prompt for the drawer case.



333
334
335
336
337
338
339
340
341
342
343
# File 'lib/muxr/application.rb', line 333

def request_close
  if @session.focus_drawer && @session.drawer&.visible?
    hide_drawer
    return
  end
  return unless focused_pane
  return if @input.state == :confirm_close
  @input.enter_confirm_close
  flash("close pane? (y/n)")
  invalidate
end

#request_quitObject



463
464
465
466
467
468
# File 'lib/muxr/application.rb', line 463

def request_quit
  return if @input.state == :confirm_quit
  @input.enter_confirm_quit
  flash("kill session? (y/n)")
  invalidate
end

#reset_drawerObject



435
436
437
438
439
440
441
442
443
444
# File 'lib/muxr/application.rb', line 435

def reset_drawer
  if @session.drawer
    @session.drawer.close
    @session.drawer = nil
  end
  @session.focus_drawer = false
  renderer.reset_frame!
  flash("drawer reset")
  invalidate
end

#restore_sessionObject



692
693
694
695
696
697
698
699
# File 'lib/muxr/application.rb', line 692

def restore_session
  data = Session.load(@session_name)
  if data
    flash("session file: #{Session.save_path_for(@session_name)}")
  else
    flash("no saved session")
  end
end

#runObject



110
111
112
113
114
115
116
117
# File 'lib/muxr/application.rb', line 110

def run
  setup
  begin
    loop_forever
  ensure
    teardown
  end
end

#run_command(cmd_line) ⇒ Object



481
482
483
484
# File 'lib/muxr/application.rb', line 481

def run_command(cmd_line)
  CommandDispatcher.new(self).dispatch(cmd_line)
  invalidate
end

#save_sessionObject



687
688
689
690
# File 'lib/muxr/application.rb', line 687

def save_session
  path = @session.save
  flash("saved: #{path}")
end

#scroll_focused(action) ⇒ Object



565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
# File 'lib/muxr/application.rb', line 565

def scroll_focused(action)
  target = focused_target
  return unless target
  term = target.terminal
  rows = term.rows
  case action
  when :line_back     then term.scroll_back(1)
  when :line_forward  then term.scroll_forward(1)
  when :half_back     then term.scroll_back([rows / 2, 1].max)
  when :half_forward  then term.scroll_forward([rows / 2, 1].max)
  when :full_back     then term.scroll_back([rows - 1, 1].max)
  when :full_forward  then term.scroll_forward([rows - 1, 1].max)
  when :top           then term.scroll_to_top
  when :bottom        then term.scroll_to_bottom
  end
  invalidate
end

#send_to_focused(data) ⇒ Object



126
127
128
129
130
131
# File 'lib/muxr/application.rb', line 126

def send_to_focused(data)
  target = focused_target
  return unless target
  data = strip_bracketed_paste_markers(data, target)
  target.write(data) unless data.empty?
end

#set_layout(layout) ⇒ Object

Explicit layout set, used by the normal-mode t/g/m bindings and the ‘:layout <name>` command.



308
309
310
311
312
313
314
# File 'lib/muxr/application.rb', line 308

def set_layout(layout)
  @session.window.set_layout(layout)
  flash("layout: #{@session.window.layout}")
  invalidate
rescue ArgumentError => e
  flash(e.message)
end

#show_drawerObject



419
420
421
422
423
424
425
# File 'lib/muxr/application.rb', line 419

def show_drawer
  ensure_drawer
  @session.drawer.show!
  @session.focus_drawer = true
  renderer.reset_frame!
  invalidate
end

#show_helpObject



486
487
488
489
490
# File 'lib/muxr/application.rb', line 486

def show_help
  @help_visible = true
  @input.enter_help_mode
  invalidate
end

#step_search(direction) ⇒ Object



553
554
555
556
557
558
559
560
561
562
563
# File 'lib/muxr/application.rb', line 553

def step_search(direction)
  target = focused_target
  return unless target
  term = target.terminal
  if term.search_matches.empty?
    flash("no search active")
    return
  end
  term.find_in_direction(direction)
  invalidate
end

#strip_bracketed_paste_markers(data, target) ⇒ Object

The client turns bracketed-paste mode on for the outer terminal so big pastes arrive wrapped in e[200~…e[201~ (which lets shells/editors that speak the protocol collapse them). But the focused program may not speak it — in that case the markers would print as a literal “^[[200~” before and after the text. So: forward the markers untouched when the focused program enabled DECSET 2004, strip them otherwise.

A marker can straddle a 4 KiB read boundary, so any trailing bytes that form a partial marker (but not a bare ESC, which must reach the program immediately as the Escape key) are held back and prepended next chunk.



143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
# File 'lib/muxr/application.rb', line 143

def strip_bracketed_paste_markers(data, target)
  data = data.b
  term = target.respond_to?(:terminal) ? target.terminal : nil
  buf = @paste_marker_tail + data
  @paste_marker_tail = +"".b

  if term&.bracketed_paste?
    # Program wants the markers — hand back everything, partial included.
    return buf
  end

  hold = pending_marker_prefix(buf)
  if hold.positive?
    @paste_marker_tail = buf.byteslice(buf.bytesize - hold, hold)
    buf = buf.byteslice(0, buf.bytesize - hold) || +"".b
  end
  BRACKETED_PASTE_MARKERS.each { |m| buf = buf.gsub(m, "") }
  buf
end

#sync_input_mode_to_focusObject

After a focus change, reconcile the input mode with the newly-focused pane: if it was left scrolled back, re-enter scrollback so the user lands exactly where they were reading (“navigating back to the scrolled pane puts you back into scrollback”). We only ever auto-ENTER here —the InputHandler’s @prefix_return is what keeps you in scrollback when you hop onto a live pane, so we never auto-leave.



237
238
239
240
241
242
# File 'lib/muxr/application.rb', line 237

def sync_input_mode_to_focus
  target = focused_target
  return unless target&.terminal&.scrolled_back?
  @input.enter_scrollback_mode
  @renderer.reset_frame!
end

#toggle_claude_drawerObject

Ctrl-a C / :claude — opens a drawer whose shell is ‘claude`, with MUXR_SESSION + MUXR_CONTROL_SOCKET + MUXR_FOCUSED_PANE in the env so the muxr-mcp bridge inside that claude process auto-attaches to this session.



415
416
417
# File 'lib/muxr/application.rb', line 415

def toggle_claude_drawer
  toggle_drawer_kind(command: "claude")
end

#toggle_drawerObject



407
408
409
# File 'lib/muxr/application.rb', line 407

def toggle_drawer
  toggle_drawer_kind(command: nil)
end

#toggle_private_focusedObject

Toggle the privacy flag on the focused pane. Private panes are redacted from the MCP control surface (panes.list strips cwd; read / send_input / run / subscribe / kill all refuse). Only the human can flip this — there is intentionally no control method to do it.



399
400
401
402
403
404
405
# File 'lib/muxr/application.rb', line 399

def toggle_private_focused
  pane = focused_pane
  return unless pane
  pane.toggle_private!
  flash(pane.private? ? "pane #{pane.id} marked private (hidden from MCP)" : "pane #{pane.id} unmarked private")
  invalidate
end

#toggle_selection(mode) ⇒ Object



597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
# File 'lib/muxr/application.rb', line 597

def toggle_selection(mode)
  target = focused_target
  return unless target
  term = target.terminal
  if term.selection_active? && term.selection_mode == mode
    # Same mode pressed again — drop the anchor, return to navigation.
    term.clear_anchor!
  else
    # No anchor, or switching between linear/block — anchor at the
    # current cursor in the requested mode (vim keeps the visual range
    # when switching shapes, and we mirror that by not moving the
    # cursor).
    term.anchor_selection!(mode: mode)
  end
  invalidate
end