muxr 0.1.11 → 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.
data/lib/muxr/client.rb CHANGED
@@ -29,11 +29,10 @@ module Muxr
29
29
 
30
30
  # Opens the socket. Returns true on success. Raises Errno::ENOENT /
31
31
  # Errno::ECONNREFUSED to the caller, which is bin/muxr's job to handle by
32
- # spawning a server.
32
+ # spawning a server. HELLO is deferred to #run: it now carries the width
33
+ # probe's verdict, and the probe needs the TTY in raw mode first.
33
34
  def connect
34
35
  @sock = UNIXSocket.new(@socket_path)
35
- rows, cols = terminal_size
36
- Protocol.write(@sock, Protocol::HELLO, Protocol.encode_size(rows, cols))
37
36
  true
38
37
  end
39
38
 
@@ -41,8 +40,9 @@ module Muxr
41
40
  raise "must call #connect first" unless @sock
42
41
 
43
42
  enter_terminal_mode
44
- install_winch_trap
45
43
  @running = true
44
+ send_hello # may clear @running if the socket is already gone
45
+ install_winch_trap
46
46
 
47
47
  begin
48
48
  loop_forever
@@ -139,6 +139,28 @@ module Muxr
139
139
  @running = false
140
140
  end
141
141
 
142
+ # The opening handshake. Sent after the TTY is in raw mode so the width
143
+ # probe can read the terminal's CPR replies; its verdict (e.g. whether the
144
+ # terminal draws ambiguous-width glyphs wide) rides along in the payload so
145
+ # the server can align its emulator/Renderer with this exact terminal.
146
+ def send_hello
147
+ rows, cols = terminal_size
148
+ Protocol.write(@sock, Protocol::HELLO, Protocol.encode_size(rows, cols, probe_caps))
149
+ rescue Errno::EPIPE, Errno::ECONNRESET, IOError
150
+ @running = false
151
+ end
152
+
153
+ # Run the width probe against the controlling terminal. Skipped (→ no caps,
154
+ # server keeps its defaults) when STDIN/STDOUT aren't a TTY — tests, pipes,
155
+ # CI — or if the probe raises for any reason; a failed probe must never stop
156
+ # the client from attaching.
157
+ def probe_caps
158
+ return {} unless STDIN.tty? && STDOUT.tty?
159
+ WidthProbe.run(out: STDOUT, input: STDIN)
160
+ rescue StandardError
161
+ {}
162
+ end
163
+
142
164
  def terminal_size
143
165
  IO.console.winsize
144
166
  rescue StandardError
@@ -4,6 +4,66 @@ module Muxr
4
4
  # rather than a hard error so the user never gets dropped out of the
5
5
  # multiplexer for a typo.
6
6
  class CommandDispatcher
7
+ # Command names offered by Tab-completion. Deliberately the canonical
8
+ # names only — the terse aliases (ls/q/exit/c/k/kill) still *work* when
9
+ # typed, but completing to a shorter alias would be surprising, and
10
+ # offering both halves would clutter the ambiguity list.
11
+ COMPLETIONS = %w[
12
+ layout drawer claude private save restore sessions attach
13
+ new close next prev master detach help quit silence ratio masters zoom sync rename reload capture
14
+ ].freeze
15
+
16
+ # Argument candidates for the commands that take a fixed vocabulary.
17
+ DRAWER_ARGS = %w[toggle show hide reset].freeze
18
+
19
+ # Tab-completion entry point. Given the current command buffer, returns
20
+ # [completed_line, candidates]: `completed_line` extends the active token
21
+ # to the longest common prefix of its matches (plus a trailing space on a
22
+ # unique match), and `candidates` is the full match list so the caller can
23
+ # show it when the completion is ambiguous. A no-match returns the line
24
+ # unchanged with an empty candidate list.
25
+ def self.complete(line)
26
+ line = line.to_s
27
+ words = line.split(/\s+/)
28
+ words.shift if words.first == "" # leading whitespace → drop the empty
29
+ at_new_token = !!(line =~ /\s\z/) || line.empty?
30
+
31
+ index = at_new_token ? words.length : words.length - 1
32
+ prefix = at_new_token ? "" : (words.last || "")
33
+
34
+ matches = candidates_for(index, words).select { |c| c.start_with?(prefix) }.sort
35
+ return [line, []] if matches.empty?
36
+
37
+ head = at_new_token ? words : words[0...index]
38
+ completed = common_prefix(matches)
39
+ new_line = (head + [completed]).join(" ")
40
+ new_line += " " if matches.length == 1
41
+ [new_line, matches]
42
+ end
43
+
44
+ # Candidate list for the token at `index` given the words typed so far.
45
+ # index 0 is the command name; index 1 is an argument keyed off the command.
46
+ def self.candidates_for(index, words)
47
+ return COMPLETIONS if index.zero?
48
+
49
+ case words[0]
50
+ when "layout" then Window::LAYOUTS.map(&:to_s)
51
+ when "drawer" then DRAWER_ARGS
52
+ when "silence" then %w[off]
53
+ when "sync" then %w[on off]
54
+ else []
55
+ end
56
+ end
57
+
58
+ def self.common_prefix(strings)
59
+ ref = strings.min_by(&:length) || ""
60
+ ref.length.downto(0) do |len|
61
+ pre = ref[0, len]
62
+ return pre if strings.all? { |s| s.start_with?(pre) }
63
+ end
64
+ ""
65
+ end
66
+
7
67
  def initialize(app)
8
68
  @app = app
9
69
  end
@@ -21,6 +81,7 @@ module Muxr
21
81
  when "save" then @app.save_session
22
82
  when "restore" then @app.restore_session
23
83
  when "sessions", "ls" then @app.list_sessions
84
+ when "attach" then @app.open_pane_picker
24
85
  when "quit", "q", "exit"
25
86
  @app.quit
26
87
  when "new", "c"
@@ -32,6 +93,14 @@ module Muxr
32
93
  when "master" then @app.promote_master
33
94
  when "help" then @app.show_help
34
95
  when "detach" then @app.detach
96
+ when "silence" then @app.monitor_silence(args[0])
97
+ when "zoom" then @app.toggle_zoom
98
+ when "sync" then @app.set_sync(args[0])
99
+ when "reload" then @app.reload_config
100
+ when "capture" then @app.capture_focused(args.join(" "))
101
+ when "rename" then @app.rename_focused(args.join(" "))
102
+ when "ratio" then @app.set_master_ratio(args[0])
103
+ when "masters" then @app.set_master_count(args[0])
35
104
  else
36
105
  @app.flash("unknown command: #{cmd}")
37
106
  end
@@ -0,0 +1,156 @@
1
+ require "json"
2
+
3
+ module Muxr
4
+ class Config
5
+ DEFAULT_PATH = File.join(Dir.home, ".muxr", "config.json").freeze
6
+
7
+ KEY_NAMES = {
8
+ "Tab" => "\t",
9
+ "Enter" => "\r",
10
+ "Space" => " ",
11
+ "Esc" => "\e"
12
+ }.freeze
13
+
14
+ RESERVED_NORMAL_KEYS = (["i", ":"] + ("1".."9").to_a).freeze
15
+ RESERVED_PREFIX_KEYS = (["\e", ":"] + ("1".."9").to_a).freeze
16
+
17
+ attr_reader :path, :errors, :layout, :scrollback, :master_ratio, :master_count,
18
+ :auto_spiral_min_cols, :auto_spiral_min_rows, :prefix,
19
+ :normal_keys, :prefix_keys
20
+
21
+ def self.path_from_env
22
+ path = ENV["MUXR_CONFIG"]
23
+ path.nil? || path.empty? ? DEFAULT_PATH : path
24
+ end
25
+
26
+ def self.load(path = path_from_env)
27
+ return new({}, path: path) unless File.exist?(path)
28
+ data = JSON.parse(File.read(path))
29
+ return new({}, path: path, errors: ["top level must be an object"]) unless data.is_a?(Hash)
30
+ new(data, path: path)
31
+ rescue JSON::ParserError => e
32
+ new({}, path: path, errors: ["not valid JSON: #{e.message.lines.first.strip}"])
33
+ rescue SystemCallError => e
34
+ new({}, path: path, errors: ["cannot read: #{e.message}"])
35
+ end
36
+
37
+ def self.key_from_name(name)
38
+ return nil unless name.is_a?(String) && !name.empty?
39
+ return KEY_NAMES[name] if KEY_NAMES.key?(name)
40
+ return (name[2].downcase.ord & 0x1f).chr if name.match?(/\AC-[a-zA-Z]\z/)
41
+ name if name.length == 1
42
+ end
43
+
44
+ def self.action_name(binding)
45
+ binding.is_a?(Array) ? binding.join(":") : binding.to_s
46
+ end
47
+
48
+ def self.actions
49
+ @actions ||= (InputHandler::NORMAL_BINDINGS.values + InputHandler::PREFIX_BINDINGS.values)
50
+ .uniq.to_h { |binding| [action_name(binding), binding] }.freeze
51
+ end
52
+
53
+ def initialize(data, path: DEFAULT_PATH, errors: [])
54
+ @path = path
55
+ @errors = errors.dup
56
+ @normal_keys = {}
57
+ @prefix_keys = {}
58
+ @prefix = InputHandler::PREFIX
59
+ parse(data)
60
+ end
61
+
62
+ def empty?
63
+ @layout.nil? && @scrollback.nil? && @master_ratio.nil? && @master_count.nil? &&
64
+ @auto_spiral_min_cols.nil? && @auto_spiral_min_rows.nil? &&
65
+ @prefix == InputHandler::PREFIX && @normal_keys.empty? && @prefix_keys.empty?
66
+ end
67
+
68
+ private
69
+
70
+ KNOWN = %w[layout scrollback master_ratio master_count auto_spiral_min prefix keys].freeze
71
+
72
+ def parse(data)
73
+ (data.keys - KNOWN).each { |k| @errors << "unknown setting #{k.inspect}" }
74
+ parse_layout(data["layout"]) if data.key?("layout")
75
+ @scrollback = positive_integer("scrollback", data["scrollback"]) if data.key?("scrollback")
76
+ parse_ratio(data["master_ratio"]) if data.key?("master_ratio")
77
+ @master_count = positive_integer("master_count", data["master_count"]) if data.key?("master_count")
78
+ parse_auto(data["auto_spiral_min"]) if data.key?("auto_spiral_min")
79
+ parse_prefix(data["prefix"]) if data.key?("prefix")
80
+ parse_keys(data["keys"]) if data.key?("keys")
81
+ end
82
+
83
+ def parse_layout(value)
84
+ if value.is_a?(String) && LayoutManager::LAYOUTS.include?(value.to_sym)
85
+ @layout = value.to_sym
86
+ else
87
+ @errors << "layout: #{value.inspect} is not one of #{LayoutManager::LAYOUTS.join(", ")}"
88
+ end
89
+ end
90
+
91
+ def parse_ratio(value)
92
+ if value.is_a?(Numeric) && LayoutManager::RATIO_BOUNDS.cover?(value)
93
+ @master_ratio = value.to_f
94
+ else
95
+ @errors << "master_ratio: expected a number from #{LayoutManager::RATIO_BOUNDS.min} to #{LayoutManager::RATIO_BOUNDS.max}"
96
+ end
97
+ end
98
+
99
+ def parse_auto(value)
100
+ unless value.is_a?(Hash)
101
+ @errors << "auto_spiral_min: expected {\"cols\": 180, \"rows\": 30}"
102
+ return
103
+ end
104
+ @auto_spiral_min_cols = positive_integer("auto_spiral_min.cols", value["cols"]) if value.key?("cols")
105
+ @auto_spiral_min_rows = positive_integer("auto_spiral_min.rows", value["rows"]) if value.key?("rows")
106
+ end
107
+
108
+ def parse_prefix(value)
109
+ key = self.class.key_from_name(value)
110
+ if key && key.ord < 0x20 && key != "\e"
111
+ @prefix = key
112
+ else
113
+ @errors << "prefix: #{value.inspect} must be a control key like \"C-b\""
114
+ end
115
+ end
116
+
117
+ def parse_keys(value)
118
+ unless value.is_a?(Hash)
119
+ @errors << "keys: expected {\"normal\": {...}, \"prefix\": {...}}"
120
+ return
121
+ end
122
+ (value.keys - %w[normal prefix]).each { |k| @errors << "keys: unknown mode #{k.inspect}" }
123
+ parse_key_table("normal", value["normal"], @normal_keys, RESERVED_NORMAL_KEYS) if value.key?("normal")
124
+ parse_key_table("prefix", value["prefix"], @prefix_keys, RESERVED_PREFIX_KEYS + [@prefix]) if value.key?("prefix")
125
+ end
126
+
127
+ def parse_key_table(mode, table, into, reserved)
128
+ unless table.is_a?(Hash)
129
+ @errors << "keys.#{mode}: expected an object of key => action"
130
+ return
131
+ end
132
+ table.each do |name, action|
133
+ key = self.class.key_from_name(name)
134
+ if key.nil?
135
+ @errors << "keys.#{mode}: #{name.inspect} is not a key (use one character, C-x, Tab, Enter, Space or Esc)"
136
+ elsif reserved.include?(key)
137
+ @errors << "keys.#{mode}: #{name.inspect} is reserved"
138
+ elsif action.nil?
139
+ into[key] = nil
140
+ elsif action.is_a?(String) && action.start_with?(":") && action.length > 1
141
+ into[key] = [:run_command, action[1..]]
142
+ elsif self.class.actions.key?(action)
143
+ into[key] = self.class.actions[action]
144
+ else
145
+ @errors << "keys.#{mode}.#{name}: unknown action #{action.inspect}"
146
+ end
147
+ end
148
+ end
149
+
150
+ def positive_integer(name, value)
151
+ return value if value.is_a?(Integer) && value.positive?
152
+ @errors << "#{name}: expected a positive whole number"
153
+ nil
154
+ end
155
+ end
156
+ end
@@ -1,3 +1,4 @@
1
+ require "base64"
1
2
  require "json"
2
3
  require "socket"
3
4
  require "set"
@@ -33,6 +34,10 @@ module Muxr
33
34
  INTERNAL_ERROR = -32603
34
35
 
35
36
  READ_CHUNK = 64 * 1024
37
+ # How long a half-finished pane move may hold a pane's pty paused before we
38
+ # assume the receiver is never coming back and resume reading it.
39
+ HANDOFF_TIMEOUT = 10.0
40
+ FLUSH_TIMEOUT = 2.0
36
41
 
37
42
  def initialize(app, socket_path)
38
43
  @app = app
@@ -41,6 +46,9 @@ module Muxr
41
46
  @clients = {} # io => { read_buffer:, write_buffer: }
42
47
  @subscriptions = {} # io => Set[pane_id] (populated in step 3)
43
48
  @pending_runs = [] # in-flight pane.run waiters (populated in step 3)
49
+ @mirrors = {} # pane_id => { io => [rows, cols] }
50
+ @mirror_geometry = {} # pane_id => [rows, cols] last announced to mirrors
51
+ @handoffs = {} # io => { pane:, deadline_at: } in-flight pane moves
44
52
  @dispatcher = Dispatcher.new(app, self)
45
53
  end
46
54
 
@@ -57,6 +65,9 @@ module Muxr
57
65
  @clients.clear
58
66
  @subscriptions.clear
59
67
  @pending_runs.clear
68
+ @mirrors.clear
69
+ @handoffs.each_key { |io| abort_handoff(io) }
70
+ @mirror_geometry.clear
60
71
  if @server
61
72
  @server.close rescue nil
62
73
  @server = nil
@@ -120,9 +131,172 @@ module Muxr
120
131
  end
121
132
  end
122
133
 
134
+ def mirrored?(pane_id)
135
+ @mirrors.key?(pane_id.to_s)
136
+ end
137
+
138
+ # Relay one chunk of a pane's raw PTY output to every muxr server mirroring
139
+ # it. Base64 because NDJSON can't carry arbitrary binary; the receiving
140
+ # server feeds the decoded bytes straight into its replica emulator, so the
141
+ # mirror is a byte-exact copy rather than a text snapshot.
142
+ def on_pane_raw(pane_id, chunk)
143
+ viewers = @mirrors[pane_id.to_s]
144
+ return if viewers.nil? || viewers.empty?
145
+ encoded = Base64.strict_encode64(chunk)
146
+ viewers.each_key do |io|
147
+ emit_event(io, "event.pane.mirror", { "pane" => pane_id.to_s, "data" => encoded })
148
+ end
149
+ end
150
+
151
+ def add_mirror(io, pane, rows, cols)
152
+ id = pane.id.to_s
153
+ (@mirrors[id] ||= {})[io] = [rows, cols]
154
+ apply_mirror_constraint(pane)
155
+ end
156
+
157
+ def update_mirror(io, pane, rows, cols)
158
+ viewers = @mirrors[pane.id.to_s]
159
+ return false unless viewers&.key?(io)
160
+ viewers[io] = [rows, cols]
161
+ apply_mirror_constraint(pane)
162
+ true
163
+ end
164
+
165
+ def remove_mirror(io, pane_id)
166
+ viewers = @mirrors[pane_id.to_s]
167
+ return false unless viewers&.delete(io)
168
+ if viewers.empty?
169
+ @mirrors.delete(pane_id.to_s)
170
+ @mirror_geometry.delete(pane_id.to_s)
171
+ end
172
+ pane = pane_by_id(pane_id)
173
+ apply_mirror_constraint(pane) if pane
174
+ true
175
+ end
176
+
177
+ # The PTY has to fit inside every viewport looking at it — this server's own
178
+ # layout and each mirroring server's — so the owner runs it at the smallest.
179
+ # nil once the last mirror leaves, which hands the pane back to the local
180
+ # layout at full size.
181
+ def apply_mirror_constraint(pane)
182
+ viewers = @mirrors[pane.id.to_s]
183
+ if viewers.nil? || viewers.empty?
184
+ pane.mirror_size = nil
185
+ else
186
+ pane.mirror_size = [viewers.values.map(&:first).min, viewers.values.map(&:last).min]
187
+ end
188
+ pane.clamp_to_mirrors!
189
+ @app.invalidate
190
+ end
191
+
192
+ def mirror_geometry_event(pane)
193
+ term = pane.terminal
194
+ {
195
+ "pane" => pane.id.to_s,
196
+ "rows" => term.rows,
197
+ "cols" => term.cols,
198
+ "snapshot" => Base64.strict_encode64(term.dump_ansi)
199
+ }
200
+ end
201
+
202
+ # A mirror's replica emulator must match the owner's geometry exactly — the
203
+ # relayed byte stream addresses absolute rows and columns. Whenever the
204
+ # owner's grid changes shape we push the new size along with a full repaint
205
+ # so the replica can resize and resync in one step.
206
+ def push_mirror_geometry
207
+ return if @mirrors.empty?
208
+ gone = []
209
+ @mirrors.each do |pane_id, viewers|
210
+ pane = pane_by_id(pane_id)
211
+ unless pane
212
+ viewers.each_key { |io| emit_event(io, "event.pane.gone", { "pane" => pane_id }) }
213
+ gone << pane_id
214
+ next
215
+ end
216
+ size = [pane.terminal.rows, pane.terminal.cols]
217
+ next if @mirror_geometry[pane_id] == size
218
+ @mirror_geometry[pane_id] = size
219
+ payload = mirror_geometry_event(pane)
220
+ viewers.each_key { |io| emit_event(io, "event.pane.geometry", payload) }
221
+ end
222
+ gone.each do |pane_id|
223
+ @mirrors.delete(pane_id)
224
+ @mirror_geometry.delete(pane_id)
225
+ end
226
+ end
227
+
123
228
  # Called once per IO.select tick. Resolves any pane.run waiters whose
124
229
  # idle window has elapsed or whose timeout has fired.
230
+ # A pane whose fd has been sent to another server but whose move hasn't
231
+ # been committed yet. It stays in the layout and keeps its Terminal, but we
232
+ # stop reading its pty: two servers reading one master would split the byte
233
+ # stream between them.
234
+ def handing_off?(pane)
235
+ @handoffs.any? { |_io, h| h[:pane].equal?(pane) }
236
+ end
237
+
238
+ def handoff_count
239
+ @handoffs.length
240
+ end
241
+
242
+ # Phase one of a move. The fd goes across first and the emulator state
243
+ # follows, because the receiver has to take the fd out of the socket with
244
+ # recvmsg before any plain read swallows the byte carrying it. Nothing is
245
+ # torn down here — until the far side commits, the pane is still ours and
246
+ # #abort_handoff puts it straight back to work.
247
+ def begin_handoff(io, request_id, pane)
248
+ state = pane.terminal.dump_transfer.merge(
249
+ "pane" => pane.id.to_s,
250
+ "pid" => pane.pid,
251
+ "cwd" => safe_pane_cwd(pane),
252
+ "name" => (pane.name if pane.respond_to?(:name)),
253
+ "session" => @app.session.name
254
+ )
255
+ respond_result(io, id: request_id, result: { "ready" => true })
256
+ return unless flush_blocking(io)
257
+ io.send_io(pane.io)
258
+ @handoffs[io] = { pane: pane, deadline_at: monotonic_now + HANDOFF_TIMEOUT }
259
+ write_json(io, { "id" => request_id, "result" => state })
260
+ @app.invalidate
261
+ true
262
+ rescue SystemCallError, IOError
263
+ abort_handoff(io)
264
+ false
265
+ end
266
+
267
+ # Phase two: the receiver has the fd and a working pane, so let go for
268
+ # real. The pty is closed here and the child is detached rather than
269
+ # killed — it belongs to the other server now.
270
+ def commit_handoff(io)
271
+ handoff = @handoffs.delete(io)
272
+ return nil unless handoff
273
+ pane = handoff[:pane]
274
+ pane.relinquish!
275
+ @app.session.window.remove_pane(pane)
276
+ @app.renderer.reset_frame! if @app.respond_to?(:renderer) && @app.renderer
277
+ @app.invalidate
278
+ pane
279
+ end
280
+
281
+ # The move fell through (receiver died, gave up, or never answered). We
282
+ # never stopped owning the pane, so there is nothing to undo but the
283
+ # read pause.
284
+ def abort_handoff(io)
285
+ handoff = @handoffs.delete(io)
286
+ return false unless handoff
287
+ @app.invalidate
288
+ true
289
+ end
290
+
291
+ def expire_handoffs
292
+ return if @handoffs.empty?
293
+ now = monotonic_now
294
+ @handoffs.select { |_io, h| now >= h[:deadline_at] }.each_key { |io| abort_handoff(io) }
295
+ end
296
+
125
297
  def tick
298
+ expire_handoffs
299
+ push_mirror_geometry
126
300
  return if @pending_runs.empty?
127
301
  now = monotonic_now
128
302
  completed = []
@@ -222,8 +396,12 @@ module Muxr
222
396
  end
223
397
 
224
398
  def drop_client(io)
399
+ abort_handoff(io)
225
400
  @clients.delete(io)
226
401
  @subscriptions.delete(io)
402
+ # A mirroring server that went away releases its claim on pane geometry:
403
+ # the pane snaps back to the owner's own layout size.
404
+ @mirrors.keys.each { |pane_id| remove_mirror(io, pane_id) }
227
405
  # Any pane.run waiters owned by this client are silently abandoned —
228
406
  # there's nobody to respond to.
229
407
  @pending_runs.reject! { |r| r[:client_io] == io } unless @pending_runs.empty?
@@ -299,6 +477,28 @@ module Muxr
299
477
  write_json(io, { "method" => method, "params" => params })
300
478
  end
301
479
 
480
+ # Push everything queued for +io+ out now. Only used ahead of send_io,
481
+ # where the fd has to land after bytes the receiver has already been told
482
+ # to expect; everywhere else the event loop's buffered drain is right.
483
+ def flush_blocking(io)
484
+ state = @clients[io]
485
+ return false unless state
486
+ deadline = monotonic_now + FLUSH_TIMEOUT
487
+ until state[:write_buffer].empty?
488
+ return false if monotonic_now >= deadline
489
+ drain_client(io)
490
+ next if state[:write_buffer].empty?
491
+ IO.select(nil, [io], nil, deadline - monotonic_now)
492
+ end
493
+ true
494
+ end
495
+
496
+ def safe_pane_cwd(pane)
497
+ pane.respond_to?(:cwd) ? pane.cwd : nil
498
+ rescue StandardError
499
+ nil
500
+ end
501
+
302
502
  def write_json(io, hash)
303
503
  return unless @clients.key?(io)
304
504
  line = JSON.generate(hash) + "\n"
@@ -347,6 +547,13 @@ module Muxr
347
547
  when "pane.run" then pane_run(params, client_io, request_id)
348
548
  when "pane.subscribe" then pane_subscribe(params, client_io)
349
549
  when "pane.unsubscribe" then pane_unsubscribe(params, client_io)
550
+ when "pane.mirror" then pane_mirror(params, client_io)
551
+ when "pane.mirror_resize" then pane_mirror_resize(params, client_io)
552
+ when "pane.unmirror" then pane_unmirror(params, client_io)
553
+ when "pane.redraw" then pane_redraw(params)
554
+ when "pane.move" then pane_move(params, client_io, request_id)
555
+ when "pane.move_commit" then pane_move_commit(client_io)
556
+ when "pane.move_abort" then pane_move_abort(client_io)
350
557
  when "layout.set" then layout_set(params)
351
558
  when "layout.cycle" then layout_cycle
352
559
  when "drawer.toggle" then drawer_action(:toggle_drawer)
@@ -406,6 +613,11 @@ module Muxr
406
613
  entry["cwd"] = safe_cwd(pane)
407
614
  entry["rows"] = pane.terminal.rows
408
615
  entry["cols"] = pane.terminal.cols
616
+ # Present on a pane borrowed from another session: "<session>:<id>"
617
+ # there. Reads and input work as usual and reach the real shell, but
618
+ # pane.kill only drops the mirror.
619
+ entry["origin"] = pane.origin if pane.respond_to?(:origin) && pane.origin
620
+ entry["name"] = pane.name if pane.respond_to?(:name) && pane.name
409
621
  end
410
622
  entry
411
623
  end
@@ -536,6 +748,79 @@ module Muxr
536
748
  { "pane" => pane.id.to_s, "subscribed" => false, "was_subscribed" => removed }
537
749
  end
538
750
 
751
+ # Hand a pane to another muxr server as a live mirror: it gets a full ANSI
752
+ # snapshot of the grid now and every subsequent raw PTY byte as it arrives,
753
+ # while the pane itself stays right here, running and usable. Input the
754
+ # mirror collects comes back through pane.send_input.
755
+ def pane_mirror(params, client_io)
756
+ pane = find_pane(params)
757
+ ensure_not_private!(pane, "pane.mirror")
758
+ rows = clamp_int(params["rows"], min: 1, max: 1000, default: pane.terminal.rows)
759
+ cols = clamp_int(params["cols"], min: 1, max: 1000, default: pane.terminal.cols)
760
+ @server.add_mirror(client_io, pane, rows, cols)
761
+ @app.invalidate
762
+ @server.mirror_geometry_event(pane).merge(
763
+ "session" => @app.session.name,
764
+ "cwd" => safe_cwd(pane)
765
+ )
766
+ end
767
+
768
+ def pane_mirror_resize(params, client_io)
769
+ pane = find_pane(params)
770
+ rows = clamp_int(params["rows"], min: 1, max: 1000, default: pane.terminal.rows)
771
+ cols = clamp_int(params["cols"], min: 1, max: 1000, default: pane.terminal.cols)
772
+ unless @server.update_mirror(client_io, pane, rows, cols)
773
+ raise Error.new("pane.mirror_resize: not mirroring pane #{pane.id}")
774
+ end
775
+ { "pane" => pane.id.to_s, "rows" => rows, "cols" => cols }
776
+ end
777
+
778
+ def pane_unmirror(params, client_io)
779
+ pane = find_pane(params)
780
+ removed = @server.remove_mirror(client_io, pane.id)
781
+ @app.invalidate
782
+ { "pane" => pane.id.to_s, "mirrored" => false, "was_mirrored" => removed }
783
+ end
784
+
785
+ # Hand the pane over for good: the pty fd itself crosses the socket, so the
786
+ # shell keeps running with all its state and simply belongs to the other
787
+ # server afterwards. Deferred because the reply, the fd and the emulator
788
+ # state have to be interleaved in one exact order — see #begin_handoff.
789
+ def pane_move(params, client_io, request_id)
790
+ raise Error.new("pane.move: missing request id") unless request_id
791
+ pane = find_pane(params)
792
+ ensure_not_private!(pane, "pane.move")
793
+ if pane.respond_to?(:mirror?) && pane.mirror?
794
+ raise Error.new("pane.move: pane #{pane.id} is itself borrowed from #{pane.origin}; move it from there")
795
+ end
796
+ # Panes already promised to another server don't count as ours: two moves
797
+ # in flight at once must not be able to empty the session between them,
798
+ # which would shut the server down out from under the user.
799
+ if @app.session.window.panes.length - @server.handoff_count <= 1
800
+ raise Error.new("pane.move: pane #{pane.id} is the last pane in session #{@app.session.name}; moving it would end that session")
801
+ end
802
+ raise Error.new("pane.move: pane #{pane.id} is already being moved") if @server.handing_off?(pane)
803
+ @server.begin_handoff(client_io, request_id, pane)
804
+ :deferred
805
+ end
806
+
807
+ def pane_move_commit(client_io)
808
+ pane = @server.commit_handoff(client_io)
809
+ raise Error.new("pane.move_commit: no move in flight") unless pane
810
+ { "pane" => pane.id.to_s, "moved" => true }
811
+ end
812
+
813
+ def pane_move_abort(client_io)
814
+ { "aborted" => @server.abort_handoff(client_io) }
815
+ end
816
+
817
+ def pane_redraw(params)
818
+ pane = find_pane(params)
819
+ ensure_not_private!(pane, "pane.redraw")
820
+ pane.request_redraw
821
+ { "pane" => pane.id.to_s }
822
+ end
823
+
539
824
  def layout_set(params)
540
825
  name = params["layout"].to_s
541
826
  sym = name.to_sym
@@ -602,7 +887,7 @@ module Muxr
602
887
  end
603
888
  [raw, wire]
604
889
  elsif params[text_key].is_a?(String)
605
- text = params[text_key]
890
+ text = decode_text(params[text_key], params["base64"])
606
891
  [text.b, wrap_bracketed(text, bracketed)]
607
892
  elsif required
608
893
  raise Error.new("missing #{text_key} (or `keys`)")
@@ -611,6 +896,16 @@ module Muxr
611
896
  end
612
897
  end
613
898
 
899
+ # Keystrokes relayed from a mirroring server arrive base64-encoded: input
900
+ # bytes are not always valid UTF-8 (Alt- sequences, mouse reports) and JSON
901
+ # strings must be.
902
+ def decode_text(text, base64)
903
+ return text unless base64
904
+ Base64.strict_decode64(text)
905
+ rescue ArgumentError
906
+ raise Error.new("data: malformed base64")
907
+ end
908
+
614
909
  def clamp_int(value, min:, max:, default:)
615
910
  v = value.is_a?(Integer) ? value : default
616
911
  v.clamp(min, max)
@@ -632,8 +927,11 @@ module Muxr
632
927
  else
633
928
  id = id_or_slot.to_s
634
929
  pane = win.panes.find { |p| p.id.to_s == id }
635
- raise Error.new("pane: no pane with id #{id.inspect}") unless pane
636
- pane
930
+ return pane if pane
931
+ named = win.panes.select { |p| p.respond_to?(:name) && p.name == id }
932
+ raise Error.new("pane: #{named.length} panes are named #{id.inspect}; use an id") if named.length > 1
933
+ raise Error.new("pane: no pane with id or name #{id.inspect}") if named.empty?
934
+ named.first
637
935
  end
638
936
  end
639
937