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

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
# 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
  @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 = +""
  @last_render_at = nil
  @foreground_poller = nil
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.



84
85
86
# File 'lib/muxr/application.rb', line 84

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_quitObject



306
307
308
309
310
311
# File 'lib/muxr/application.rb', line 306

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

#close_focusedObject



205
206
207
208
209
210
211
212
213
214
# File 'lib/muxr/application.rb', line 205

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

#confirm_quitObject



302
303
304
# File 'lib/muxr/application.rb', line 302

def confirm_quit
  shutdown_server
end

#cycle_layoutObject



216
217
218
219
220
# File 'lib/muxr/application.rb', line 216

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.



501
502
503
504
505
# File 'lib/muxr/application.rb', line 501

def deliver_output(bytes)
  return unless @current_client
  @client_write_buffer << Protocol.frame(Protocol::OUTPUT, bytes)
  drain_client_writes
end

#detachObject



278
279
280
281
282
# File 'lib/muxr/application.rb', line 278

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

#dismiss_helpObject



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

def dismiss_help
  @help_visible = false
  invalidate
end

#drain_client_writesObject



507
508
509
510
511
512
513
514
515
516
517
518
519
# File 'lib/muxr/application.rb', line 507

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.



199
200
201
202
203
# File 'lib/muxr/application.rb', line 199

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.



192
193
194
195
196
# File 'lib/muxr/application.rb', line 192

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

#enter_scrollbackObject



329
330
331
332
333
334
335
# File 'lib/muxr/application.rb', line 329

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

#enter_selectionObject



363
364
365
366
367
368
369
370
371
372
373
# File 'lib/muxr/application.rb', line 363

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.
  target.terminal.place_selection_cursor(0, 0)
  @input.enter_selection_mode
  @renderer.reset_frame!
  invalidate
end

#exit_scrollbackObject



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

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

#exit_selection(yank:) ⇒ Object



392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
# File 'lib/muxr/application.rb', line 392

def exit_selection(yank:)
  target = focused_target
  term = target&.terminal
  yanked = false
  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")
      yanked = true
    end
  end
  term&.clear_selection
  if yanked
    # vim-style: yanking drops you straight back to "normal" (idle),
    # not back into scrollback navigation.
    term&.scroll_to_bottom
    @input.enter_idle_mode
  else
    @input.enter_scrollback_mode
  end
  @renderer.reset_frame!
  invalidate
end

#flash(msg) ⇒ Object



460
461
462
463
464
# File 'lib/muxr/application.rb', line 460

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.



156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
# File 'lib/muxr/application.rb', line 156

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
    invalidate
    return
  end

  return unless idx
  win.focus_index(idx)
  invalidate
end

#focus_lastObject



132
133
134
135
136
137
138
139
140
# File 'lib/muxr/application.rb', line 132

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
  invalidate
end

#focus_nextObject



112
113
114
115
116
117
118
119
120
# File 'lib/muxr/application.rb', line 112

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
  invalidate
end

#focus_pane_number(n) ⇒ Object



142
143
144
145
146
147
148
149
# File 'lib/muxr/application.rb', line 142

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)
  invalidate
end

#focus_prevObject



122
123
124
125
126
127
128
129
130
# File 'lib/muxr/application.rb', line 122

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
  invalidate
end

#hide_drawerObject



259
260
261
262
263
264
265
# File 'lib/muxr/application.rb', line 259

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

#invalidateObject



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

def invalidate
  @needs_render = true
end

#list_sessionsObject



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

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_selection(action) ⇒ Object



421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
# File 'lib/muxr/application.rb', line 421

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



102
103
104
105
106
107
108
109
110
# File 'lib/muxr/application.rb', line 102

def new_pane(cwd: nil)
  cwd ||= focused_pane&.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

#paste_from_bufferObject



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

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

#promote_masterObject



222
223
224
225
# File 'lib/muxr/application.rb', line 222

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).



287
288
289
# File 'lib/muxr/application.rb', line 287

def quit
  request_quit
end

#quit_immediateObject



291
292
293
# File 'lib/muxr/application.rb', line 291

def quit_immediate
  request_quit
end

#request_quitObject



295
296
297
298
299
300
# File 'lib/muxr/application.rb', line 295

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

#reset_drawerObject



267
268
269
270
271
272
273
274
275
276
# File 'lib/muxr/application.rb', line 267

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



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

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



86
87
88
89
90
91
92
93
# File 'lib/muxr/application.rb', line 86

def run
  setup
  begin
    loop_forever
  ensure
    teardown
  end
end

#run_command(cmd_line) ⇒ Object



313
314
315
316
# File 'lib/muxr/application.rb', line 313

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

#save_sessionObject



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

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

#scroll_focused(action) ⇒ Object



345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
# File 'lib/muxr/application.rb', line 345

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

———- public action API (called from InputHandler / CommandDispatcher) ———-



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

def send_to_focused(data)
  target = focused_target
  target&.write(data)
end

#set_layout(layout) ⇒ Object

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



182
183
184
185
186
187
188
# File 'lib/muxr/application.rb', line 182

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

#show_drawerObject



251
252
253
254
255
256
257
# File 'lib/muxr/application.rb', line 251

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

#show_helpObject



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

def show_help
  @help_visible = true
  @input.enter_help_mode
  invalidate
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.



247
248
249
# File 'lib/muxr/application.rb', line 247

def toggle_claude_drawer
  toggle_drawer_kind(command: "claude")
end

#toggle_drawerObject



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

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.



231
232
233
234
235
236
237
# File 'lib/muxr/application.rb', line 231

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



375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
# File 'lib/muxr/application.rb', line 375

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