muxr 0.1.10 → 0.2.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +275 -1
- data/README.md +763 -387
- data/bin/muxr +68 -33
- data/bin/muxr-mcp +172 -16
- data/lib/muxr/application.rb +639 -35
- data/lib/muxr/client.rb +26 -4
- data/lib/muxr/command_dispatcher.rb +69 -0
- data/lib/muxr/config.rb +156 -0
- data/lib/muxr/control_server.rb +301 -3
- data/lib/muxr/history_row.rb +264 -0
- data/lib/muxr/image_store.rb +61 -0
- data/lib/muxr/input_handler.rb +169 -17
- data/lib/muxr/layout_manager.rb +83 -57
- data/lib/muxr/mouse_report.rb +24 -0
- data/lib/muxr/pane.rb +128 -2
- data/lib/muxr/pane_picker.rb +49 -0
- data/lib/muxr/pane_transfer.rb +172 -0
- data/lib/muxr/protocol.rb +51 -5
- data/lib/muxr/pty_process.rb +54 -7
- data/lib/muxr/remote_pane.rb +263 -0
- data/lib/muxr/renderer.rb +297 -74
- data/lib/muxr/session.rb +19 -2
- data/lib/muxr/session_directory.rb +102 -0
- data/lib/muxr/terminal.rb +903 -87
- data/lib/muxr/version.rb +1 -1
- data/lib/muxr/width_probe.rb +121 -0
- data/lib/muxr/window.rb +50 -3
- data/lib/muxr.rb +8 -0
- data/muxr.gemspec +10 -0
- data/skills/muxr-control/SKILL.md +158 -8
- metadata +18 -1
data/lib/muxr/application.rb
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
require "socket"
|
|
2
2
|
require "fileutils"
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "muxr/remote_pane"
|
|
5
|
+
require "muxr/pane_transfer"
|
|
6
|
+
require "muxr/pane_picker"
|
|
7
|
+
require "muxr/session_directory"
|
|
8
|
+
require "muxr/config"
|
|
3
9
|
|
|
4
10
|
module Muxr
|
|
5
11
|
# The Application is the muxr server. It owns the Session, panes, Renderer,
|
|
@@ -22,26 +28,40 @@ module Muxr
|
|
|
22
28
|
# showing through.
|
|
23
29
|
MIN_FRAME_INTERVAL = 1.0 / 60
|
|
24
30
|
SOCKETS_DIR = File.join(Dir.home, ".muxr", "sockets").freeze
|
|
31
|
+
CAPTURES_DIR = File.join(Dir.home, ".muxr", "captures").freeze
|
|
25
32
|
DEFAULT_WIDTH = 80
|
|
26
33
|
DEFAULT_HEIGHT = 24
|
|
27
34
|
|
|
28
|
-
attr_reader :session, :renderer, :input, :session_name, :control_server
|
|
35
|
+
attr_reader :session, :renderer, :input, :session_name, :control_server, :pane_picker
|
|
29
36
|
|
|
30
37
|
def self.socket_path_for(name)
|
|
31
38
|
File.join(SOCKETS_DIR, "#{name}.sock")
|
|
32
39
|
end
|
|
33
40
|
|
|
41
|
+
# The name a bare `muxr` (no name argument) resolves to: a slug of the
|
|
42
|
+
# current working directory, so re-running `muxr` in the same directory
|
|
43
|
+
# reattaches the same session. The name is interpolated straight into
|
|
44
|
+
# <name>.sock / <name>.json / <name>.log filenames, so the path's slashes
|
|
45
|
+
# (and any other filesystem-unfriendly bytes) are folded to "-"; the
|
|
46
|
+
# leading "-" from the root slash is trimmed for readability in --list.
|
|
47
|
+
def self.default_session_name(dir = Dir.pwd)
|
|
48
|
+
slug = dir.gsub(%r{/}, "-").gsub(/[^A-Za-z0-9._-]/, "-").sub(/\A-+/, "")
|
|
49
|
+
slug.empty? ? "default" : slug
|
|
50
|
+
end
|
|
51
|
+
|
|
34
52
|
def self.control_socket_path_for(name)
|
|
35
53
|
File.join(SOCKETS_DIR, "#{name}.ctrl.sock")
|
|
36
54
|
end
|
|
37
55
|
|
|
38
56
|
# Names of sessions whose server socket is currently accepting connections.
|
|
39
57
|
# Stale sockets (file exists, no listener) are skipped but left in place;
|
|
40
|
-
# cleanup happens on the next attach attempt.
|
|
58
|
+
# cleanup happens on the next attach attempt. The sibling control socket
|
|
59
|
+
# lives in the same directory under <name>.ctrl.sock and is not a session.
|
|
41
60
|
def self.list_active
|
|
42
61
|
return [] unless File.directory?(SOCKETS_DIR)
|
|
43
62
|
Dir.children(SOCKETS_DIR).filter_map do |entry|
|
|
44
63
|
next unless entry.end_with?(".sock")
|
|
64
|
+
next if entry.end_with?(".ctrl.sock")
|
|
45
65
|
path = File.join(SOCKETS_DIR, entry)
|
|
46
66
|
next unless alive_socket?(path)
|
|
47
67
|
File.basename(entry, ".sock")
|
|
@@ -64,15 +84,40 @@ module Muxr
|
|
|
64
84
|
@message = nil
|
|
65
85
|
@message_expires = nil
|
|
66
86
|
@help_visible = false
|
|
87
|
+
@pane_picker = nil
|
|
67
88
|
@current_client = nil
|
|
68
89
|
@client_write_buffer = +"".b
|
|
69
90
|
@listening_socket = nil
|
|
91
|
+
# The directory bin/muxr was launched from — Process.daemon(true, ...)
|
|
92
|
+
# preserves it across daemonization. Every new pane (and the drawer)
|
|
93
|
+
# starts here, treating it as the session's project root regardless of
|
|
94
|
+
# where the focused pane's shell has wandered.
|
|
95
|
+
@origin_cwd = Dir.pwd
|
|
70
96
|
@socket_path = self.class.socket_path_for(@session_name)
|
|
71
97
|
@control_socket_path = self.class.control_socket_path_for(@session_name)
|
|
72
98
|
@control_server = nil
|
|
73
99
|
@paste_buffer = +""
|
|
100
|
+
# Trailing bytes of an in-flight INPUT chunk that look like the start of
|
|
101
|
+
# a bracketed-paste marker but were cut off by the 4 KiB read boundary.
|
|
102
|
+
# Held back and prepended to the next chunk so a split marker still gets
|
|
103
|
+
# recognized — see #strip_bracketed_paste_markers.
|
|
104
|
+
@paste_marker_tail = +"".b
|
|
74
105
|
@last_render_at = nil
|
|
75
106
|
@foreground_poller = nil
|
|
107
|
+
# Opt-in diagnostic tap. When MUXR_TRACE_OUTPUT names a writable path, the
|
|
108
|
+
# server appends every byte it sends to the client — i.e. exactly what the
|
|
109
|
+
# outer terminal receives. Replaying it (`cat` it into a fresh terminal, or
|
|
110
|
+
# feed it to a reference emulator) reproduces a rendering bug from the byte
|
|
111
|
+
# stream alone, which tells us whether corruption is in muxr's emitted
|
|
112
|
+
# output or somewhere downstream. Off unless the env var is set.
|
|
113
|
+
@trace_output = open_trace(ENV["MUXR_TRACE_OUTPUT"])
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def open_trace(path)
|
|
117
|
+
return nil if path.nil? || path.empty?
|
|
118
|
+
File.open(path, "ab")
|
|
119
|
+
rescue SystemCallError
|
|
120
|
+
nil
|
|
76
121
|
end
|
|
77
122
|
|
|
78
123
|
# Interval for the background thread that refreshes each pane's
|
|
@@ -94,13 +139,117 @@ module Muxr
|
|
|
94
139
|
|
|
95
140
|
# ---------- public action API (called from InputHandler / CommandDispatcher) ----------
|
|
96
141
|
|
|
142
|
+
# Bytes the outer terminal wraps around a paste once bracketed-paste mode
|
|
143
|
+
# is on (the client enables it unconditionally — see
|
|
144
|
+
# Client#enter_terminal_mode).
|
|
145
|
+
BRACKETED_PASTE_MARKERS = ["\e[200~".b, "\e[201~".b].freeze
|
|
146
|
+
|
|
97
147
|
def send_to_focused(data)
|
|
98
148
|
target = focused_target
|
|
99
|
-
target
|
|
149
|
+
return unless target
|
|
150
|
+
data = strip_bracketed_paste_markers(data, target)
|
|
151
|
+
input_targets.each { |pane| pane.write(data) } unless data.empty?
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def input_targets
|
|
155
|
+
target = focused_target
|
|
156
|
+
return [] unless target
|
|
157
|
+
return [target] unless broadcasting?
|
|
158
|
+
@session.window.panes.select { |pane| pane.alive? && !handing_off?(pane) }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def broadcasting?
|
|
162
|
+
@session.window.synchronized && !(@session.focus_drawer && @session.drawer&.visible?)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def capture_focused(path = nil)
|
|
166
|
+
target = focused_target
|
|
167
|
+
return unless target
|
|
168
|
+
destination = capture_path(target, path)
|
|
169
|
+
text = target.terminal.dump_history_text
|
|
170
|
+
FileUtils.mkdir_p(File.dirname(destination))
|
|
171
|
+
File.write(destination, text)
|
|
172
|
+
flash("captured #{text.count("\n")} lines to #{shorten_home(destination)}")
|
|
173
|
+
destination
|
|
174
|
+
rescue SystemCallError => e
|
|
175
|
+
flash("capture failed: #{e.message}")
|
|
176
|
+
nil
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def capture_path(target, path)
|
|
180
|
+
return File.expand_path(path, @origin_cwd) if path && !path.empty?
|
|
181
|
+
label = target.respond_to?(:label) ? target.label : target.id.to_s
|
|
182
|
+
stamp = Time.now.strftime("%Y%m%d-%H%M%S")
|
|
183
|
+
File.join(CAPTURES_DIR, "#{@session_name}-#{label}-#{stamp}.txt".gsub(/[^A-Za-z0-9._-]/, "-"))
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def rename_focused(name)
|
|
187
|
+
pane = focused_pane
|
|
188
|
+
return unless pane
|
|
189
|
+
pane.name = name
|
|
190
|
+
flash(pane.name ? "pane ##{@session.window.focused_index + 1} is now #{pane.name}" : "pane ##{@session.window.focused_index + 1} name cleared")
|
|
191
|
+
invalidate
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def set_sync(arg)
|
|
195
|
+
win = @session.window
|
|
196
|
+
case arg
|
|
197
|
+
when nil then win.synchronized = !win.synchronized
|
|
198
|
+
when "on" then win.synchronized = true
|
|
199
|
+
when "off" then win.synchronized = false
|
|
200
|
+
else return flash("sync: expected on or off")
|
|
201
|
+
end
|
|
202
|
+
flash(win.synchronized ? "sync on: typing reaches all #{win.panes.length} panes" : "sync off")
|
|
203
|
+
invalidate
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# The client turns bracketed-paste mode on for the *outer* terminal so big
|
|
207
|
+
# pastes arrive wrapped in \e[200~…\e[201~ (which lets shells/editors that
|
|
208
|
+
# speak the protocol collapse them). But the focused program may not speak
|
|
209
|
+
# it — in that case the markers would print as a literal "^[[200~" before
|
|
210
|
+
# and after the text. So: forward the markers untouched when the focused
|
|
211
|
+
# program enabled DECSET 2004, strip them otherwise.
|
|
212
|
+
#
|
|
213
|
+
# A marker can straddle a 4 KiB read boundary, so any trailing bytes that
|
|
214
|
+
# form a partial marker (but not a bare ESC, which must reach the program
|
|
215
|
+
# immediately as the Escape key) are held back and prepended next chunk.
|
|
216
|
+
def strip_bracketed_paste_markers(data, target)
|
|
217
|
+
data = data.b
|
|
218
|
+
term = target.respond_to?(:terminal) ? target.terminal : nil
|
|
219
|
+
buf = @paste_marker_tail + data
|
|
220
|
+
@paste_marker_tail = +"".b
|
|
221
|
+
|
|
222
|
+
if term&.bracketed_paste?
|
|
223
|
+
# Program wants the markers — hand back everything, partial included.
|
|
224
|
+
return buf
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
hold = pending_marker_prefix(buf)
|
|
228
|
+
if hold.positive?
|
|
229
|
+
@paste_marker_tail = buf.byteslice(buf.bytesize - hold, hold)
|
|
230
|
+
buf = buf.byteslice(0, buf.bytesize - hold) || +"".b
|
|
231
|
+
end
|
|
232
|
+
BRACKETED_PASTE_MARKERS.each { |m| buf = buf.gsub(m, "") }
|
|
233
|
+
buf
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Length (2..5) of the longest suffix of `buf` that is a proper prefix of a
|
|
237
|
+
# bracketed-paste marker, so the remainder can arrive in the next chunk. A
|
|
238
|
+
# bare trailing ESC (length 1) is deliberately not held: it's almost always
|
|
239
|
+
# the Escape key and the program must see it without waiting on the next
|
|
240
|
+
# keystroke. Worst case a marker split right after its ESC leaks a few
|
|
241
|
+
# bytes, which the program reads as a harmless unknown escape.
|
|
242
|
+
def pending_marker_prefix(buf)
|
|
243
|
+
max = [buf.bytesize, 5].min
|
|
244
|
+
max.downto(2) do |k|
|
|
245
|
+
tail = buf.byteslice(buf.bytesize - k, k)
|
|
246
|
+
return k if BRACKETED_PASTE_MARKERS.any? { |m| m.byteslice(0, k) == tail }
|
|
247
|
+
end
|
|
248
|
+
0
|
|
100
249
|
end
|
|
101
250
|
|
|
102
251
|
def new_pane(cwd: nil)
|
|
103
|
-
cwd ||=
|
|
252
|
+
cwd ||= @origin_cwd
|
|
104
253
|
pane = make_pane(cwd: cwd)
|
|
105
254
|
@session.window.add_pane(pane)
|
|
106
255
|
@session.focus_drawer = false
|
|
@@ -116,6 +265,7 @@ module Muxr
|
|
|
116
265
|
else
|
|
117
266
|
@session.window.focus_next
|
|
118
267
|
end
|
|
268
|
+
sync_input_mode_to_focus
|
|
119
269
|
invalidate
|
|
120
270
|
end
|
|
121
271
|
|
|
@@ -126,6 +276,7 @@ module Muxr
|
|
|
126
276
|
else
|
|
127
277
|
@session.window.focus_prev
|
|
128
278
|
end
|
|
279
|
+
sync_input_mode_to_focus
|
|
129
280
|
invalidate
|
|
130
281
|
end
|
|
131
282
|
|
|
@@ -136,6 +287,7 @@ module Muxr
|
|
|
136
287
|
else
|
|
137
288
|
@session.window.focus_last
|
|
138
289
|
end
|
|
290
|
+
sync_input_mode_to_focus
|
|
139
291
|
invalidate
|
|
140
292
|
end
|
|
141
293
|
|
|
@@ -145,9 +297,27 @@ module Muxr
|
|
|
145
297
|
return unless idx >= 0 && idx < @session.window.panes.length
|
|
146
298
|
@session.focus_drawer = false
|
|
147
299
|
@session.window.focus_index(idx)
|
|
300
|
+
sync_input_mode_to_focus
|
|
148
301
|
invalidate
|
|
149
302
|
end
|
|
150
303
|
|
|
304
|
+
# After a focus change, reconcile the input mode with the newly-focused
|
|
305
|
+
# pane: if it was left scrolled back, re-enter scrollback so the user
|
|
306
|
+
# lands exactly where they were reading ("navigating back to the scrolled
|
|
307
|
+
# pane puts you back into scrollback"). We only ever auto-ENTER here —
|
|
308
|
+
# the InputHandler's @prefix_return is what keeps you in scrollback when
|
|
309
|
+
# you hop onto a live pane, so we never auto-leave.
|
|
310
|
+
def sync_input_mode_to_focus
|
|
311
|
+
target = focused_target
|
|
312
|
+
return unless target
|
|
313
|
+
if target.terminal.scrolled_back?
|
|
314
|
+
@input.enter_scrollback_mode(source: :ring)
|
|
315
|
+
@renderer.reset_frame!
|
|
316
|
+
elsif @input.state == :scrollback
|
|
317
|
+
@input.enter_scrollback_mode(source: default_scroll_source(target))
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
151
321
|
# Move focus to the pane spatially adjacent in `direction` (:left/:right/
|
|
152
322
|
# :up/:down). Called by the normal-mode hjkl bindings. Pulling the live
|
|
153
323
|
# layout rects keeps this in sync with whatever the renderer is showing.
|
|
@@ -168,12 +338,14 @@ module Muxr
|
|
|
168
338
|
when :right, :down then win.focus_next
|
|
169
339
|
when :left, :up then win.focus_prev
|
|
170
340
|
end
|
|
341
|
+
sync_input_mode_to_focus
|
|
171
342
|
invalidate
|
|
172
343
|
return
|
|
173
344
|
end
|
|
174
345
|
|
|
175
346
|
return unless idx
|
|
176
347
|
win.focus_index(idx)
|
|
348
|
+
sync_input_mode_to_focus
|
|
177
349
|
invalidate
|
|
178
350
|
end
|
|
179
351
|
|
|
@@ -222,7 +394,7 @@ module Muxr
|
|
|
222
394
|
# Ctrl-a-prefixed multiplexer mode.
|
|
223
395
|
def enter_passthrough_mode
|
|
224
396
|
@input.enter_passthrough_mode
|
|
225
|
-
flash("passthrough mode (
|
|
397
|
+
flash("passthrough mode (^#{Renderer.prefix_letter(@input.prefix)} esc to return)")
|
|
226
398
|
invalidate
|
|
227
399
|
end
|
|
228
400
|
|
|
@@ -269,17 +441,86 @@ module Muxr
|
|
|
269
441
|
invalidate
|
|
270
442
|
end
|
|
271
443
|
|
|
444
|
+
def toggle_zoom
|
|
445
|
+
win = @session.window
|
|
446
|
+
return flash("already in monocle") if win.layout == :monocle && !win.zoomed?
|
|
447
|
+
win.toggle_zoom
|
|
448
|
+
flash(win.zoomed? ? "zoomed (z to restore #{win.zoom_return})" : "layout: #{win.layout}")
|
|
449
|
+
@renderer.reset_frame!
|
|
450
|
+
invalidate
|
|
451
|
+
end
|
|
452
|
+
|
|
272
453
|
def cycle_layout
|
|
273
454
|
@session.window.cycle_layout
|
|
274
455
|
flash("layout: #{@session.window.layout}")
|
|
275
456
|
invalidate
|
|
276
457
|
end
|
|
277
458
|
|
|
459
|
+
# Bound to `r` (normal) / `Ctrl-a r` (passthrough). Two-layer repaint to
|
|
460
|
+
# recover from a corrupted display, whichever layer drifted:
|
|
461
|
+
# 1. Nudge the focused program to redraw itself (SIGWINCH wiggle). This
|
|
462
|
+
# fixes muxr's own Terminal grid when an unhandled or wide glyph
|
|
463
|
+
# desynced the cursor — reset_frame! alone can't, since it would just
|
|
464
|
+
# faithfully re-emit the wrong grid.
|
|
465
|
+
# 2. Force a full re-emit of our composed frame to the outer terminal,
|
|
466
|
+
# fixing the case where the outer display lost/garbled bytes but our
|
|
467
|
+
# grid is correct.
|
|
468
|
+
def refresh_focused
|
|
469
|
+
target = focused_target
|
|
470
|
+
target.request_redraw if target.respond_to?(:request_redraw)
|
|
471
|
+
@renderer.reset_frame!
|
|
472
|
+
flash("refreshed")
|
|
473
|
+
invalidate
|
|
474
|
+
end
|
|
475
|
+
|
|
278
476
|
def promote_master
|
|
279
477
|
@session.window.promote_to_master
|
|
280
478
|
invalidate
|
|
281
479
|
end
|
|
282
480
|
|
|
481
|
+
def grow_master
|
|
482
|
+
resize_master(Window::RATIO_STEP)
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def shrink_master
|
|
486
|
+
resize_master(-Window::RATIO_STEP)
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
def resize_master(delta)
|
|
490
|
+
@session.window.adjust_master_ratio(delta)
|
|
491
|
+
flash_master_shape
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def add_master
|
|
495
|
+
@session.window.adjust_master_count(1)
|
|
496
|
+
flash_master_shape
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def remove_master
|
|
500
|
+
@session.window.adjust_master_count(-1)
|
|
501
|
+
flash_master_shape
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def set_master_ratio(arg)
|
|
505
|
+
value = Float(arg.to_s.delete_suffix("%"), exception: false)
|
|
506
|
+
return flash("ratio: expected a percentage like 60") unless value&.positive?
|
|
507
|
+
@session.window.master_ratio = value > 1 ? value / 100.0 : value
|
|
508
|
+
flash_master_shape
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def set_master_count(arg)
|
|
512
|
+
value = Integer(arg.to_s, exception: false)
|
|
513
|
+
return flash("masters: expected a number like 2") unless value&.positive?
|
|
514
|
+
@session.window.master_count = value
|
|
515
|
+
flash_master_shape
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
def flash_master_shape
|
|
519
|
+
win = @session.window
|
|
520
|
+
flash("master #{(win.master_ratio * 100).round}% · masters #{win.master_count}")
|
|
521
|
+
invalidate
|
|
522
|
+
end
|
|
523
|
+
|
|
283
524
|
# Toggle the privacy flag on the focused pane. Private panes are
|
|
284
525
|
# redacted from the MCP control surface (panes.list strips cwd; read /
|
|
285
526
|
# send_input / run / subscribe / kill all refuse). Only the human can
|
|
@@ -371,6 +612,103 @@ module Muxr
|
|
|
371
612
|
invalidate
|
|
372
613
|
end
|
|
373
614
|
|
|
615
|
+
# Open the attach overlay: every pane every other live muxr server is
|
|
616
|
+
# willing to share, grouped by session. Building the list means a short
|
|
617
|
+
# blocking round-trip to each server's control socket, which is fine for a
|
|
618
|
+
# deliberate keypress and bounded by SessionDirectory::QUERY_TIMEOUT.
|
|
619
|
+
def open_pane_picker
|
|
620
|
+
entries = SessionDirectory.panes(exclude: @session_name)
|
|
621
|
+
if entries.empty?
|
|
622
|
+
flash("no panes to attach (no other muxr sessions running)")
|
|
623
|
+
return
|
|
624
|
+
end
|
|
625
|
+
@pane_picker = PanePicker.new(entries)
|
|
626
|
+
@input.enter_pane_picker_mode
|
|
627
|
+
invalidate
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
def move_pane_picker(delta)
|
|
631
|
+
@pane_picker&.move(delta)
|
|
632
|
+
invalidate
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
def cancel_pane_picker
|
|
636
|
+
@pane_picker = nil
|
|
637
|
+
@renderer.reset_frame!
|
|
638
|
+
invalidate
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
def confirm_pane_picker(move: false)
|
|
642
|
+
entry = @pane_picker&.selected
|
|
643
|
+
cancel_pane_picker
|
|
644
|
+
return unless entry
|
|
645
|
+
move ? move_remote_pane(entry) : attach_remote_pane(entry)
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
# Take the pane away from its session rather than sharing it. The pty fd
|
|
649
|
+
# itself crosses over, so the shell and everything running under it carry
|
|
650
|
+
# on uninterrupted — it just answers to this session now, and disappears
|
|
651
|
+
# from the one it came from.
|
|
652
|
+
def move_remote_pane(entry)
|
|
653
|
+
result = PaneTransfer.claim(socket_path: entry.socket_path, pane_id: entry.pane_id)
|
|
654
|
+
pane = result.pane
|
|
655
|
+
pane.foreground_command = nil
|
|
656
|
+
@session.window.add_pane(pane)
|
|
657
|
+
@session.focus_drawer = false
|
|
658
|
+
@session.window.focused_index = @session.window.panes.length - 1
|
|
659
|
+
@renderer.reset_frame!
|
|
660
|
+
flash("moved #{result.session}:#{pane.id} here")
|
|
661
|
+
invalidate
|
|
662
|
+
pane
|
|
663
|
+
rescue PaneTransfer::Error => e
|
|
664
|
+
flash("move failed: #{e.message}")
|
|
665
|
+
nil
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
# Mount another session's pane here as a live mirror. The pane keeps
|
|
669
|
+
# running where it is — both sessions see the same shell, and either can
|
|
670
|
+
# type into it. Closing it here (or quitting) only drops the mirror; losing
|
|
671
|
+
# the owning server is what makes the pane go away, and prune_dead_panes
|
|
672
|
+
# takes care of that.
|
|
673
|
+
def attach_remote_pane(entry)
|
|
674
|
+
remote = RemotePane.connect(
|
|
675
|
+
socket_path: entry.socket_path,
|
|
676
|
+
pane_id: entry.pane_id,
|
|
677
|
+
rows: mirror_viewport[0],
|
|
678
|
+
cols: mirror_viewport[1]
|
|
679
|
+
)
|
|
680
|
+
pane = Pane.new(rows: remote.rows, cols: remote.cols, process: remote)
|
|
681
|
+
remote.bind(pane.terminal)
|
|
682
|
+
pane.origin = remote.origin
|
|
683
|
+
pane.name = entry.name if entry.respond_to?(:name)
|
|
684
|
+
@session.window.add_pane(pane)
|
|
685
|
+
@session.focus_drawer = false
|
|
686
|
+
@session.window.focused_index = @session.window.panes.length - 1
|
|
687
|
+
@renderer.reset_frame!
|
|
688
|
+
flash("attached #{remote.origin}")
|
|
689
|
+
invalidate
|
|
690
|
+
pane
|
|
691
|
+
rescue RemotePane::Error => e
|
|
692
|
+
flash("attach failed: #{e.message}")
|
|
693
|
+
nil
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
# Size to ask the owner for before the Renderer has laid the new pane out.
|
|
697
|
+
# One more pane in the current layout is the honest guess, and the very next
|
|
698
|
+
# frame corrects it through Pane#resize.
|
|
699
|
+
def mirror_viewport
|
|
700
|
+
rects = LayoutManager.compute(
|
|
701
|
+
@session.window.layout,
|
|
702
|
+
@session.window.panes.length + 1,
|
|
703
|
+
LayoutManager::Rect.new(0, 0, @session.width, @session.height - 1),
|
|
704
|
+
focused_index: @session.window.panes.length,
|
|
705
|
+
**@session.window.layout_options
|
|
706
|
+
)
|
|
707
|
+
rect = rects.last
|
|
708
|
+
return [DEFAULT_HEIGHT, DEFAULT_WIDTH] unless rect
|
|
709
|
+
[[rect.h - 2, 1].max, [rect.w - 2, 1].max]
|
|
710
|
+
end
|
|
711
|
+
|
|
374
712
|
def show_help
|
|
375
713
|
@help_visible = true
|
|
376
714
|
@input.enter_help_mode
|
|
@@ -385,13 +723,47 @@ module Muxr
|
|
|
385
723
|
def enter_scrollback
|
|
386
724
|
target = focused_target
|
|
387
725
|
return unless target
|
|
388
|
-
@input.enter_scrollback_mode
|
|
726
|
+
@input.enter_scrollback_mode(source: default_scroll_source(target))
|
|
727
|
+
@renderer.reset_frame!
|
|
728
|
+
invalidate
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
def app_scroll_available?(target)
|
|
732
|
+
term = target&.terminal
|
|
733
|
+
return false unless term
|
|
734
|
+
term.mouse_tracking? || term.alt_screen?
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
def default_scroll_source(target)
|
|
738
|
+
app_scroll_available?(target) ? :app : :ring
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
def toggle_scroll_source
|
|
742
|
+
target = focused_target
|
|
743
|
+
return unless target
|
|
744
|
+
if @input.scroll_source == :app
|
|
745
|
+
if target.terminal.alt_screen?
|
|
746
|
+
flash("no history while a full-screen app is running")
|
|
747
|
+
return
|
|
748
|
+
end
|
|
749
|
+
@input.enter_scrollback_mode(source: :ring)
|
|
750
|
+
flash("scrolling muxr history")
|
|
751
|
+
else
|
|
752
|
+
unless app_scroll_available?(target)
|
|
753
|
+
flash("this pane has no scroll of its own")
|
|
754
|
+
return
|
|
755
|
+
end
|
|
756
|
+
target.terminal.scroll_to_bottom
|
|
757
|
+
@input.enter_scrollback_mode(source: :app)
|
|
758
|
+
flash("scrolling the app")
|
|
759
|
+
end
|
|
389
760
|
@renderer.reset_frame!
|
|
390
761
|
invalidate
|
|
391
762
|
end
|
|
392
763
|
|
|
393
764
|
def exit_scrollback
|
|
394
765
|
target = focused_target
|
|
766
|
+
target&.terminal&.confine_selection_to_screen!(false)
|
|
395
767
|
target&.terminal&.clear_selection
|
|
396
768
|
target&.terminal&.clear_search
|
|
397
769
|
target&.terminal&.scroll_to_bottom
|
|
@@ -403,6 +775,10 @@ module Muxr
|
|
|
403
775
|
# the user into a buffered prompt; commit_search / cancel_search exit
|
|
404
776
|
# back to scrollback.
|
|
405
777
|
def enter_search(direction: :forward)
|
|
778
|
+
if @input.scroll_source == :app
|
|
779
|
+
flash("search reads muxr history — Tab to switch")
|
|
780
|
+
return
|
|
781
|
+
end
|
|
406
782
|
@input.enter_search_mode(direction: direction)
|
|
407
783
|
invalidate
|
|
408
784
|
end
|
|
@@ -441,6 +817,10 @@ module Muxr
|
|
|
441
817
|
def step_search(direction)
|
|
442
818
|
target = focused_target
|
|
443
819
|
return unless target
|
|
820
|
+
if @input.scroll_source == :app
|
|
821
|
+
flash("search reads muxr history — Tab to switch")
|
|
822
|
+
return
|
|
823
|
+
end
|
|
444
824
|
term = target.terminal
|
|
445
825
|
if term.search_matches.empty?
|
|
446
826
|
flash("no search active")
|
|
@@ -450,9 +830,12 @@ module Muxr
|
|
|
450
830
|
invalidate
|
|
451
831
|
end
|
|
452
832
|
|
|
833
|
+
WHEEL_BURST_MAX = 200
|
|
834
|
+
|
|
453
835
|
def scroll_focused(action)
|
|
454
836
|
target = focused_target
|
|
455
837
|
return unless target
|
|
838
|
+
return scroll_app(target, action) if @input.scroll_source == :app
|
|
456
839
|
term = target.terminal
|
|
457
840
|
rows = term.rows
|
|
458
841
|
case action
|
|
@@ -468,6 +851,48 @@ module Muxr
|
|
|
468
851
|
invalidate
|
|
469
852
|
end
|
|
470
853
|
|
|
854
|
+
def scroll_app(target, action)
|
|
855
|
+
term = target.terminal
|
|
856
|
+
rows = term.rows
|
|
857
|
+
direction, count =
|
|
858
|
+
case action
|
|
859
|
+
when :line_back then [:up, 1]
|
|
860
|
+
when :line_forward then [:down, 1]
|
|
861
|
+
when :half_back then [:up, [rows / 2, 1].max]
|
|
862
|
+
when :half_forward then [:down, [rows / 2, 1].max]
|
|
863
|
+
when :full_back then [:up, [rows - 1, 1].max]
|
|
864
|
+
when :full_forward then [:down, [rows - 1, 1].max]
|
|
865
|
+
end
|
|
866
|
+
unless direction
|
|
867
|
+
flash("only the app knows where its history starts")
|
|
868
|
+
return
|
|
869
|
+
end
|
|
870
|
+
target.write(app_scroll_bytes(term, direction, count))
|
|
871
|
+
invalidate
|
|
872
|
+
end
|
|
873
|
+
|
|
874
|
+
def app_scroll_bytes(term, direction, count)
|
|
875
|
+
count = count.clamp(1, WHEEL_BURST_MAX)
|
|
876
|
+
if term.mouse_tracking?
|
|
877
|
+
MouseReport.wheel(
|
|
878
|
+
direction,
|
|
879
|
+
row: [term.rows / 2 + 1, 1].max,
|
|
880
|
+
col: [term.cols / 2 + 1, 1].max,
|
|
881
|
+
encoding: term.mouse_encoding
|
|
882
|
+
) * count
|
|
883
|
+
else
|
|
884
|
+
arrow_key(term, direction) * count
|
|
885
|
+
end
|
|
886
|
+
end
|
|
887
|
+
|
|
888
|
+
def arrow_key(term, direction)
|
|
889
|
+
if term.app_cursor_keys?
|
|
890
|
+
direction == :up ? "\eOA".b : "\eOB".b
|
|
891
|
+
else
|
|
892
|
+
direction == :up ? "\e[A".b : "\e[B".b
|
|
893
|
+
end
|
|
894
|
+
end
|
|
895
|
+
|
|
471
896
|
def enter_selection
|
|
472
897
|
target = focused_target
|
|
473
898
|
return unless target
|
|
@@ -476,6 +901,7 @@ module Muxr
|
|
|
476
901
|
# anchor. Start at the live cursor's visible position so the user lands
|
|
477
902
|
# where their attention already is, instead of the top-left corner.
|
|
478
903
|
term = target.terminal
|
|
904
|
+
term.confine_selection_to_screen!(@input.scroll_source == :app)
|
|
479
905
|
term.place_selection_cursor(term.cursor_row, term.cursor_col)
|
|
480
906
|
@input.enter_selection_mode
|
|
481
907
|
@renderer.reset_frame!
|
|
@@ -502,7 +928,6 @@ module Muxr
|
|
|
502
928
|
def exit_selection(yank:)
|
|
503
929
|
target = focused_target
|
|
504
930
|
term = target&.terminal
|
|
505
|
-
yanked = false
|
|
506
931
|
if yank
|
|
507
932
|
# No anchor → no-op. User is still positioning; they can press v
|
|
508
933
|
# first, then yank. Esc/q is the way to exit from navigation.
|
|
@@ -512,18 +937,14 @@ module Muxr
|
|
|
512
937
|
@paste_buffer = text
|
|
513
938
|
spawn_pbcopy(text)
|
|
514
939
|
flash("yanked #{text.bytesize} bytes")
|
|
515
|
-
yanked = true
|
|
516
940
|
end
|
|
517
941
|
end
|
|
518
942
|
term&.clear_selection
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
else
|
|
525
|
-
@input.enter_scrollback_mode
|
|
526
|
-
end
|
|
943
|
+
# Drop back into scrollback at the current position whether or not we
|
|
944
|
+
# yanked. We no longer snap to the live bottom on yank — the user stays
|
|
945
|
+
# where they were reading so they can keep selecting or scrolling, and
|
|
946
|
+
# `q`/Esc is still there when they want to return to the bottom.
|
|
947
|
+
@input.enter_scrollback_mode
|
|
527
948
|
@renderer.reset_frame!
|
|
528
949
|
invalidate
|
|
529
950
|
end
|
|
@@ -561,10 +982,33 @@ module Muxr
|
|
|
561
982
|
invalidate
|
|
562
983
|
end
|
|
563
984
|
|
|
985
|
+
SILENCE_ARG = /\A(\d+)(s|m)?\z/
|
|
986
|
+
|
|
987
|
+
def monitor_silence(arg)
|
|
988
|
+
pane = focused_pane
|
|
989
|
+
return unless pane
|
|
990
|
+
if arg.nil?
|
|
991
|
+
flash(pane.silence_after ? "silence: #{format_seconds(pane.silence_after)}" : "silence: off")
|
|
992
|
+
elsif arg == "off"
|
|
993
|
+
pane.watch_silence(nil)
|
|
994
|
+
flash("silence monitor off")
|
|
995
|
+
elsif (m = SILENCE_ARG.match(arg)) && m[1].to_i.positive?
|
|
996
|
+
seconds = m[1].to_i * (m[2] == "m" ? 60 : 1)
|
|
997
|
+
pane.watch_silence(seconds)
|
|
998
|
+
flash("alert when pane ##{@session.window.focused_index + 1} is silent for #{format_seconds(seconds)}")
|
|
999
|
+
else
|
|
1000
|
+
flash("silence: expected seconds (30, 30s, 2m) or off")
|
|
1001
|
+
end
|
|
1002
|
+
invalidate
|
|
1003
|
+
end
|
|
1004
|
+
|
|
1005
|
+
def format_seconds(seconds)
|
|
1006
|
+
seconds % 60 == 0 && seconds >= 60 ? "#{seconds / 60}m" : "#{seconds}s"
|
|
1007
|
+
end
|
|
1008
|
+
|
|
564
1009
|
def paste_from_buffer
|
|
565
1010
|
return if @paste_buffer.nil? || @paste_buffer.empty?
|
|
566
|
-
|
|
567
|
-
target&.write(@paste_buffer)
|
|
1011
|
+
input_targets.each { |pane| pane.write(@paste_buffer) }
|
|
568
1012
|
end
|
|
569
1013
|
|
|
570
1014
|
def flash(msg)
|
|
@@ -577,6 +1021,43 @@ module Muxr
|
|
|
577
1021
|
@needs_render = true
|
|
578
1022
|
end
|
|
579
1023
|
|
|
1024
|
+
def reload_config
|
|
1025
|
+
config = Config.load
|
|
1026
|
+
apply_config(config)
|
|
1027
|
+
if config.errors.empty?
|
|
1028
|
+
flash("reloaded #{shorten_home(config.path)}")
|
|
1029
|
+
else
|
|
1030
|
+
report_config_problems
|
|
1031
|
+
end
|
|
1032
|
+
end
|
|
1033
|
+
|
|
1034
|
+
def apply_config(config)
|
|
1035
|
+
@config = config
|
|
1036
|
+
config.errors.each { |e| warn("muxr: #{config.path}: #{e}") }
|
|
1037
|
+
@input.configure(config)
|
|
1038
|
+
Terminal.scrollback_max = config.scrollback if config.scrollback && !ENV["MUXR_SCROLLBACK"]
|
|
1039
|
+
LayoutManager.auto_spiral_min_cols = config.auto_spiral_min_cols
|
|
1040
|
+
LayoutManager.auto_spiral_min_rows = config.auto_spiral_min_rows
|
|
1041
|
+
invalidate
|
|
1042
|
+
end
|
|
1043
|
+
|
|
1044
|
+
def apply_window_defaults
|
|
1045
|
+
win = @session.window
|
|
1046
|
+
win.set_layout(@config.layout) if @config.layout
|
|
1047
|
+
win.master_ratio = @config.master_ratio if @config.master_ratio
|
|
1048
|
+
win.master_count = @config.master_count if @config.master_count
|
|
1049
|
+
end
|
|
1050
|
+
|
|
1051
|
+
def report_config_problems
|
|
1052
|
+
return if @config.nil? || @config.errors.empty?
|
|
1053
|
+
count = @config.errors.length
|
|
1054
|
+
flash("config: #{@config.errors.first}#{count > 1 ? " (+#{count - 1} more in the log)" : ""}")
|
|
1055
|
+
end
|
|
1056
|
+
|
|
1057
|
+
def shorten_home(path)
|
|
1058
|
+
path.to_s.start_with?(Dir.home) ? path.to_s.sub(Dir.home, "~") : path.to_s
|
|
1059
|
+
end
|
|
1060
|
+
|
|
580
1061
|
def save_session
|
|
581
1062
|
path = @session.save
|
|
582
1063
|
flash("saved: #{path}")
|
|
@@ -592,7 +1073,7 @@ module Muxr
|
|
|
592
1073
|
end
|
|
593
1074
|
|
|
594
1075
|
def list_sessions
|
|
595
|
-
names = Session.list
|
|
1076
|
+
names = (Session.list | self.class.list_active).sort
|
|
596
1077
|
if names.empty?
|
|
597
1078
|
flash("no saved sessions")
|
|
598
1079
|
else
|
|
@@ -610,6 +1091,9 @@ module Muxr
|
|
|
610
1091
|
# the server is also trying to read from that same client.
|
|
611
1092
|
def deliver_output(bytes)
|
|
612
1093
|
return unless @current_client
|
|
1094
|
+
if @trace_output
|
|
1095
|
+
@trace_output.write(bytes) rescue nil
|
|
1096
|
+
end
|
|
613
1097
|
@client_write_buffer << Protocol.frame(Protocol::OUTPUT, bytes)
|
|
614
1098
|
drain_client_writes
|
|
615
1099
|
end
|
|
@@ -645,7 +1129,7 @@ module Muxr
|
|
|
645
1129
|
if idx && argv[idx + 1]
|
|
646
1130
|
argv[idx + 1]
|
|
647
1131
|
else
|
|
648
|
-
argv.find { |a| !a.start_with?("-") } ||
|
|
1132
|
+
argv.find { |a| !a.start_with?("-") } || self.class.default_session_name
|
|
649
1133
|
end
|
|
650
1134
|
end
|
|
651
1135
|
|
|
@@ -667,7 +1151,7 @@ module Muxr
|
|
|
667
1151
|
win.panes.length,
|
|
668
1152
|
area,
|
|
669
1153
|
focused_index: win.focused_index,
|
|
670
|
-
|
|
1154
|
+
**win.layout_options
|
|
671
1155
|
)
|
|
672
1156
|
end
|
|
673
1157
|
|
|
@@ -694,6 +1178,8 @@ module Muxr
|
|
|
694
1178
|
@session = Session.new(name: @session_name, width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT)
|
|
695
1179
|
@renderer = Renderer.new(out: FramedOutput.new(self))
|
|
696
1180
|
@input = InputHandler.new(self)
|
|
1181
|
+
apply_config(Config.load)
|
|
1182
|
+
apply_window_defaults
|
|
697
1183
|
|
|
698
1184
|
saved = Session.load(@session_name)
|
|
699
1185
|
first_id = saved && saved.dig("panes", 0, "id")
|
|
@@ -718,20 +1204,24 @@ module Muxr
|
|
|
718
1204
|
end
|
|
719
1205
|
@session&.window&.panes&.each(&:close)
|
|
720
1206
|
@session&.drawer&.close
|
|
1207
|
+
if @trace_output
|
|
1208
|
+
@trace_output.close rescue nil
|
|
1209
|
+
@trace_output = nil
|
|
1210
|
+
end
|
|
721
1211
|
end
|
|
722
1212
|
|
|
723
1213
|
def loop_forever
|
|
724
1214
|
while @running
|
|
725
1215
|
read_ios = [@listening_socket]
|
|
726
1216
|
read_ios << @current_client if @current_client
|
|
727
|
-
@session.window.panes.each { |p| read_ios << p.io if p.alive? }
|
|
1217
|
+
@session.window.panes.each { |p| read_ios << p.io if p.alive? && !handing_off?(p) }
|
|
728
1218
|
drawer_pane = @session.drawer&.pane
|
|
729
1219
|
read_ios << drawer_pane.io if drawer_pane&.alive?
|
|
730
1220
|
read_ios.concat(@control_server.read_ios) if @control_server
|
|
731
1221
|
|
|
732
1222
|
write_ios = []
|
|
733
1223
|
@session.window.panes.each do |p|
|
|
734
|
-
write_ios << p.writer_io if p.alive? && p.pending_write?
|
|
1224
|
+
write_ios << p.writer_io if p.alive? && p.pending_write? && !handing_off?(p)
|
|
735
1225
|
end
|
|
736
1226
|
if drawer_pane&.alive? && drawer_pane.pending_write?
|
|
737
1227
|
write_ios << drawer_pane.writer_io
|
|
@@ -783,6 +1273,7 @@ module Muxr
|
|
|
783
1273
|
|
|
784
1274
|
prune_dead_panes
|
|
785
1275
|
prune_dead_drawer
|
|
1276
|
+
report_silent_panes
|
|
786
1277
|
expire_message
|
|
787
1278
|
|
|
788
1279
|
if @session.window.panes.empty?
|
|
@@ -840,12 +1331,30 @@ module Muxr
|
|
|
840
1331
|
|
|
841
1332
|
size = Protocol.decode_size(payload)
|
|
842
1333
|
apply_size(*size) if size
|
|
1334
|
+
apply_caps(Protocol.decode_caps(payload))
|
|
843
1335
|
|
|
844
1336
|
@current_client = sock
|
|
845
1337
|
@renderer.reset_frame!
|
|
1338
|
+
report_config_problems
|
|
846
1339
|
invalidate
|
|
847
1340
|
end
|
|
848
1341
|
|
|
1342
|
+
# Apply the client's width-probe verdict so the emulator and Renderer measure
|
|
1343
|
+
# glyphs exactly as this terminal draws them, eliminating the width
|
|
1344
|
+
# disagreement that smears in-place animations:
|
|
1345
|
+
# ambiguous (1 narrow / 2 wide) — tunes the broad East Asian Ambiguous
|
|
1346
|
+
# class for the long tail of glyphs the probe didn't sample by hand.
|
|
1347
|
+
# glyphs ({codepoint => width}) — exact per-glyph overrides for the
|
|
1348
|
+
# emoji-presentation glyphs (Claude Code's ⏺/✻/❯) no class predicts.
|
|
1349
|
+
# A reattaching client re-probes, so a different terminal re-tunes; the full
|
|
1350
|
+
# repaint on attach absorbs the change.
|
|
1351
|
+
def apply_caps(caps)
|
|
1352
|
+
return if caps.nil? || caps.empty?
|
|
1353
|
+
Terminal.ambiguous_wide = (caps[:ambiguous] == 2) if caps.key?(:ambiguous)
|
|
1354
|
+
Terminal.box_wide = (caps[:box] == 2) if caps.key?(:box)
|
|
1355
|
+
Terminal.width_overrides = caps[:glyphs] if caps.key?(:glyphs)
|
|
1356
|
+
end
|
|
1357
|
+
|
|
849
1358
|
def consume_client_frame
|
|
850
1359
|
type, payload = Protocol.read(@current_client)
|
|
851
1360
|
if type.nil?
|
|
@@ -874,15 +1383,68 @@ module Muxr
|
|
|
874
1383
|
def consume_pane_io(io)
|
|
875
1384
|
pane = pane_for_io(io)
|
|
876
1385
|
return unless pane
|
|
877
|
-
|
|
1386
|
+
control = @control_server
|
|
1387
|
+
relay = pane.id.is_a?(String) && control&.mirrored?(pane.id)
|
|
1388
|
+
data =
|
|
1389
|
+
if relay
|
|
1390
|
+
pane.read_from_pty { |chunk| control.on_pane_raw(pane.id, chunk) }
|
|
1391
|
+
else
|
|
1392
|
+
pane.read_from_pty
|
|
1393
|
+
end
|
|
878
1394
|
if data
|
|
879
1395
|
invalidate
|
|
1396
|
+
pane.note_output(attended: attended?(pane))
|
|
880
1397
|
# Notify the control surface so any pending pane.run waiters reset
|
|
881
1398
|
# their idle window and any pane.subscribe clients get a new frame.
|
|
882
1399
|
# read_from_pty already fed the bytes into the Terminal; the control
|
|
883
1400
|
# server pulls the resulting text out of pane.terminal.dump_text.
|
|
884
1401
|
@control_server&.on_pane_output(pane.id, data) if pane.id.is_a?(String)
|
|
885
1402
|
end
|
|
1403
|
+
forward_notifications(pane)
|
|
1404
|
+
forward_clipboard(pane)
|
|
1405
|
+
end
|
|
1406
|
+
|
|
1407
|
+
# Push any bell / desktop-notification bytes the pane's emulator collected
|
|
1408
|
+
# straight to the outer terminal — out of band from the rendered frame, so a
|
|
1409
|
+
# background pane (an unfocused Claude Code finishing a task) still alerts
|
|
1410
|
+
# the user. When no client is attached deliver_output is a no-op; the queue
|
|
1411
|
+
# is still drained so it can't accumulate while detached.
|
|
1412
|
+
def forward_notifications(pane)
|
|
1413
|
+
bytes = pane.terminal.take_pending_notifications!
|
|
1414
|
+
return unless bytes
|
|
1415
|
+
pane.note_bell unless attended?(pane)
|
|
1416
|
+
deliver_output(bytes) if @current_client
|
|
1417
|
+
end
|
|
1418
|
+
|
|
1419
|
+
def report_silent_panes
|
|
1420
|
+
now = Pane.now
|
|
1421
|
+
@session.window.panes.each_with_index do |pane, i|
|
|
1422
|
+
next unless pane.silence_due?(now)
|
|
1423
|
+
pane.note_silence!
|
|
1424
|
+
flash("pane ##{i + 1} silent for #{format_seconds(pane.silence_after)}")
|
|
1425
|
+
deliver_output("\a".b) if @current_client
|
|
1426
|
+
end
|
|
1427
|
+
end
|
|
1428
|
+
|
|
1429
|
+
def attended?(pane)
|
|
1430
|
+
!@current_client.nil? && pane.equal?(focused_target)
|
|
1431
|
+
end
|
|
1432
|
+
|
|
1433
|
+
def clear_focused_attention
|
|
1434
|
+
target = focused_target
|
|
1435
|
+
target.clear_attention! if target.respond_to?(:clear_attention!)
|
|
1436
|
+
end
|
|
1437
|
+
|
|
1438
|
+
# Copy any OSC 52 clipboard write the pane's emulator collected to the
|
|
1439
|
+
# system clipboard, and mirror it into the internal paste buffer so Ctrl-a p
|
|
1440
|
+
# pastes the same text (same as copy-mode's yank). Unlike notifications this
|
|
1441
|
+
# runs even when no client is attached — pbcopy is local to the server host,
|
|
1442
|
+
# so a background pane's yank still lands on the clipboard.
|
|
1443
|
+
def forward_clipboard(pane)
|
|
1444
|
+
text = pane.terminal.take_pending_clipboard!
|
|
1445
|
+
return if text.nil? || text.empty?
|
|
1446
|
+
@paste_buffer = text
|
|
1447
|
+
spawn_pbcopy(text)
|
|
886
1448
|
end
|
|
887
1449
|
|
|
888
1450
|
def pane_for_io(io)
|
|
@@ -899,8 +1461,15 @@ module Muxr
|
|
|
899
1461
|
nil
|
|
900
1462
|
end
|
|
901
1463
|
|
|
1464
|
+
# A pane whose pty has been sent to another server but whose move is not
|
|
1465
|
+
# committed yet. Its fd stays out of the select sets: two servers reading
|
|
1466
|
+
# one master would split the byte stream between them.
|
|
1467
|
+
def handing_off?(pane)
|
|
1468
|
+
!!@control_server&.handing_off?(pane)
|
|
1469
|
+
end
|
|
1470
|
+
|
|
902
1471
|
def prune_dead_panes
|
|
903
|
-
dead = @session.window.panes.reject
|
|
1472
|
+
dead = @session.window.panes.reject { |p| p.alive? || handing_off?(p) }
|
|
904
1473
|
return if dead.empty?
|
|
905
1474
|
dead.each { |p| @session.window.remove_pane(p) }
|
|
906
1475
|
invalidate
|
|
@@ -940,17 +1509,40 @@ module Muxr
|
|
|
940
1509
|
end
|
|
941
1510
|
|
|
942
1511
|
def render
|
|
1512
|
+
leave_stale_scrollback
|
|
1513
|
+
clear_focused_attention
|
|
943
1514
|
@renderer.render(
|
|
944
1515
|
@session,
|
|
945
1516
|
input_state: @input.state,
|
|
1517
|
+
scroll_source: @input.scroll_source,
|
|
946
1518
|
command_buffer: @input.command_buffer,
|
|
1519
|
+
command_completions: @input.command_completions,
|
|
947
1520
|
search_buffer: @input.search_buffer,
|
|
948
1521
|
search_direction: @input.search_direction,
|
|
949
1522
|
message: @message,
|
|
950
|
-
help: @help_visible
|
|
1523
|
+
help: @help_visible,
|
|
1524
|
+
picker: @pane_picker,
|
|
1525
|
+
prefix: @input.prefix
|
|
951
1526
|
)
|
|
952
1527
|
end
|
|
953
1528
|
|
|
1529
|
+
def leave_stale_scrollback
|
|
1530
|
+
return unless @input.state == :scrollback
|
|
1531
|
+
target = focused_target
|
|
1532
|
+
term = target&.terminal
|
|
1533
|
+
return unless term
|
|
1534
|
+
if @input.scroll_source == :app
|
|
1535
|
+
return if app_scroll_available?(target)
|
|
1536
|
+
@input.enter_scrollback_mode(source: :ring)
|
|
1537
|
+
flash("app exited — scrolling muxr history")
|
|
1538
|
+
@renderer.reset_frame!
|
|
1539
|
+
return
|
|
1540
|
+
end
|
|
1541
|
+
return unless term.alt_screen?
|
|
1542
|
+
@input.enter_idle_mode
|
|
1543
|
+
@renderer.reset_frame!
|
|
1544
|
+
end
|
|
1545
|
+
|
|
954
1546
|
def disconnect_client(reason: nil)
|
|
955
1547
|
return unless @current_client
|
|
956
1548
|
# Best-effort: drop any queued OUTPUT (the client is going away),
|
|
@@ -1044,12 +1636,13 @@ module Muxr
|
|
|
1044
1636
|
end
|
|
1045
1637
|
|
|
1046
1638
|
def make_pane(cwd: nil, id: nil)
|
|
1047
|
-
|
|
1639
|
+
pane_id = id || SecureRandom.hex(3)
|
|
1640
|
+
Pane.new(id: pane_id, rows: 24, cols: 80, cwd: cwd, env_overrides: pane_env(pane_id))
|
|
1048
1641
|
end
|
|
1049
1642
|
|
|
1050
1643
|
def ensure_drawer(command: nil)
|
|
1051
1644
|
return if @session.drawer
|
|
1052
|
-
cwd =
|
|
1645
|
+
cwd = @origin_cwd
|
|
1053
1646
|
pane = Pane.new(
|
|
1054
1647
|
id: :drawer,
|
|
1055
1648
|
rows: 10,
|
|
@@ -1084,15 +1677,19 @@ module Muxr
|
|
|
1084
1677
|
invalidate
|
|
1085
1678
|
end
|
|
1086
1679
|
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
# drawer.* methods so a claude drawer can't recurse into its own PTY.
|
|
1090
|
-
def drawer_env
|
|
1091
|
-
env = {
|
|
1680
|
+
def session_env
|
|
1681
|
+
{
|
|
1092
1682
|
"MUXR_SESSION" => @session_name.to_s,
|
|
1093
|
-
"MUXR_CONTROL_SOCKET" => @control_socket_path.to_s
|
|
1094
|
-
"MUXR_DRAWER_SELF" => "1"
|
|
1683
|
+
"MUXR_CONTROL_SOCKET" => @control_socket_path.to_s
|
|
1095
1684
|
}
|
|
1685
|
+
end
|
|
1686
|
+
|
|
1687
|
+
def pane_env(pane_id)
|
|
1688
|
+
session_env.merge("MUXR_PANE" => pane_id.to_s)
|
|
1689
|
+
end
|
|
1690
|
+
|
|
1691
|
+
def drawer_env
|
|
1692
|
+
env = session_env.merge("MUXR_DRAWER_SELF" => "1")
|
|
1096
1693
|
focused = focused_pane
|
|
1097
1694
|
env["MUXR_FOCUSED_PANE"] = focused.id.to_s if focused&.id.is_a?(String)
|
|
1098
1695
|
env
|
|
@@ -1117,6 +1714,11 @@ module Muxr
|
|
|
1117
1714
|
pane.mark_private! if entry["private"]
|
|
1118
1715
|
@session.window.add_pane(pane)
|
|
1119
1716
|
end
|
|
1717
|
+
panes_data.each_with_index do |entry, i|
|
|
1718
|
+
pane = @session.window.panes[i]
|
|
1719
|
+
pane.name = entry["name"] if pane && entry["name"]
|
|
1720
|
+
pane.watch_silence(entry["silence"]) if pane && entry["silence"].is_a?(Integer) && entry["silence"].positive?
|
|
1721
|
+
end
|
|
1120
1722
|
|
|
1121
1723
|
if data["drawer"]
|
|
1122
1724
|
cwd = data["drawer"]["cwd"]
|
|
@@ -1137,6 +1739,8 @@ module Muxr
|
|
|
1137
1739
|
|
|
1138
1740
|
@session.window.focused_index = (data["focused_index"] || 0).clamp(0, @session.window.panes.length - 1)
|
|
1139
1741
|
@session.window.master_index = (data["master_index"] || 0).clamp(0, @session.window.panes.length - 1)
|
|
1742
|
+
@session.window.master_ratio = data["master_ratio"] if data["master_ratio"].is_a?(Numeric)
|
|
1743
|
+
@session.window.master_count = data["master_count"] if data["master_count"].is_a?(Integer)
|
|
1140
1744
|
flash("session restored")
|
|
1141
1745
|
end
|
|
1142
1746
|
|