muxr 0.1.3 → 0.1.5

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.
@@ -0,0 +1,670 @@
1
+ require "json"
2
+ require "socket"
3
+ require "set"
4
+ require "muxr/key_parser"
5
+
6
+ module Muxr
7
+ # ControlServer is the second listener on the muxr server: it accepts
8
+ # multiple concurrent JSON-RPC clients over a Unix socket at
9
+ # ~/.muxr/sockets/<name>.ctrl.sock and lets them inspect or drive the
10
+ # session. The primary client of this socket is the MCP bridge
11
+ # (bin/muxr-mcp) that exposes the methods as MCP tools for Claude Code,
12
+ # but anything that can read/write NDJSON can drive a session — `nc` is
13
+ # enough for poking around by hand.
14
+ #
15
+ # Wire format: one JSON object per line. Requests carry an `id`; responses
16
+ # echo it back. Server-pushed events (used by `pane.subscribe`) have no
17
+ # `id` and instead set a `method` of `"event.<topic>"`.
18
+ #
19
+ # --> {"id":1,"method":"panes.list"}
20
+ # <-- {"id":1,"result":{"panes":[...]}}
21
+ # <-- {"method":"event.pane.output","params":{"pane":"a3f9b2","data":"..."}}
22
+ #
23
+ # The TTY socket (.sock) and the control socket (.ctrl.sock) are deliberately
24
+ # separate: the TTY socket is single-client (only one human attaches at a
25
+ # time), the control socket is multi-client, and a connected control client
26
+ # never counts as "attached" — Renderer is unaffected.
27
+ class ControlServer
28
+ # JSON-RPC error code conventions.
29
+ PARSE_ERROR = -32700
30
+ INVALID_REQUEST = -32600
31
+ METHOD_NOT_FOUND = -32601
32
+ INVALID_PARAMS = -32602
33
+ INTERNAL_ERROR = -32603
34
+
35
+ READ_CHUNK = 64 * 1024
36
+
37
+ def initialize(app, socket_path)
38
+ @app = app
39
+ @socket_path = socket_path
40
+ @server = nil
41
+ @clients = {} # io => { read_buffer:, write_buffer: }
42
+ @subscriptions = {} # io => Set[pane_id] (populated in step 3)
43
+ @pending_runs = [] # in-flight pane.run waiters (populated in step 3)
44
+ @dispatcher = Dispatcher.new(app, self)
45
+ end
46
+
47
+ attr_reader :app, :socket_path
48
+
49
+ def start
50
+ File.unlink(@socket_path) if File.exist?(@socket_path)
51
+ @server = UNIXServer.new(@socket_path)
52
+ File.chmod(0o600, @socket_path) rescue nil
53
+ end
54
+
55
+ def stop
56
+ @clients.each_key { |c| c.close rescue nil }
57
+ @clients.clear
58
+ @subscriptions.clear
59
+ @pending_runs.clear
60
+ if @server
61
+ @server.close rescue nil
62
+ @server = nil
63
+ end
64
+ File.unlink(@socket_path) if File.exist?(@socket_path)
65
+ end
66
+
67
+ # IO arrays the Application splices into its IO.select read/write sets.
68
+ def read_ios
69
+ return [] unless @server
70
+ ios = [@server]
71
+ ios.concat(@clients.keys)
72
+ ios
73
+ end
74
+
75
+ def write_ios
76
+ @clients.each_with_object([]) do |(io, state), acc|
77
+ acc << io unless state[:write_buffer].empty?
78
+ end
79
+ end
80
+
81
+ def owns?(io)
82
+ io == @server || @clients.key?(io)
83
+ end
84
+
85
+ def handle_read(io)
86
+ if io == @server
87
+ accept_client
88
+ else
89
+ consume_client(io)
90
+ end
91
+ end
92
+
93
+ def handle_write(io)
94
+ drain_client(io)
95
+ end
96
+
97
+ # --- Hooks the Application invokes when interesting things happen. ---
98
+
99
+ # Called whenever a pane's PTY emits output. Drives pane.run idle
100
+ # detection and pane.subscribe streams.
101
+ def on_pane_output(pane_id, _data)
102
+ pid = pane_id.to_s
103
+ now = monotonic_now
104
+ unless @pending_runs.empty?
105
+ @pending_runs.each do |run|
106
+ next unless run[:pane_id] == pid
107
+ run[:last_output_at] = now
108
+ run[:had_output] = true
109
+ end
110
+ end
111
+ unless @subscriptions.empty?
112
+ pane = pane_by_id(pid)
113
+ return unless pane
114
+ text = pane.terminal.dump_text
115
+ cursor = { "row" => pane.terminal.cursor_row, "col" => pane.terminal.cursor_col }
116
+ @subscriptions.each do |io, ids|
117
+ next unless ids.include?(pid)
118
+ emit_event(io, "event.pane.output", { "pane" => pid, "text" => text, "cursor" => cursor })
119
+ end
120
+ end
121
+ end
122
+
123
+ # Called once per IO.select tick. Resolves any pane.run waiters whose
124
+ # idle window has elapsed or whose timeout has fired.
125
+ def tick
126
+ return if @pending_runs.empty?
127
+ now = monotonic_now
128
+ completed = []
129
+ @pending_runs.each do |run|
130
+ if now >= run[:deadline_at]
131
+ complete_run(run, timed_out: true, now: now)
132
+ completed << run
133
+ elsif run[:had_output] && (now - run[:last_output_at]) >= run[:idle_seconds]
134
+ complete_run(run, timed_out: false, now: now)
135
+ completed << run
136
+ end
137
+ end
138
+ @pending_runs -= completed unless completed.empty?
139
+ end
140
+
141
+ # Called from the Dispatcher when a pane.run request arrives. Registers a
142
+ # waiter that #tick later resolves.
143
+ def register_pending_run(client_io:, request_id:, pane_id:, idle_seconds:, timeout_seconds:)
144
+ now = monotonic_now
145
+ @pending_runs << {
146
+ client_io: client_io,
147
+ request_id: request_id,
148
+ pane_id: pane_id.to_s,
149
+ idle_seconds: idle_seconds,
150
+ timeout_seconds: timeout_seconds,
151
+ last_output_at: now,
152
+ had_output: false,
153
+ deadline_at: now + timeout_seconds,
154
+ started_at: now
155
+ }
156
+ end
157
+
158
+ def add_subscription(client_io, pane_id)
159
+ @subscriptions[client_io] ||= Set.new
160
+ @subscriptions[client_io].add(pane_id.to_s)
161
+ end
162
+
163
+ def remove_subscription(client_io, pane_id)
164
+ set = @subscriptions[client_io]
165
+ return false unless set
166
+ removed = set.delete?(pane_id.to_s)
167
+ @subscriptions.delete(client_io) if set.empty?
168
+ !removed.nil?
169
+ end
170
+
171
+ # ----- internals -----
172
+
173
+ def accept_client
174
+ sock = @server.accept
175
+ @clients[sock] = { read_buffer: +"", write_buffer: +"".b }
176
+ rescue StandardError
177
+ # Accept may transiently fail on a closed peer; ignore.
178
+ end
179
+
180
+ def consume_client(io)
181
+ state = @clients[io]
182
+ return unless state
183
+ begin
184
+ chunk = io.read_nonblock(READ_CHUNK)
185
+ rescue IO::WaitReadable
186
+ return
187
+ rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError
188
+ drop_client(io)
189
+ return
190
+ end
191
+ state[:read_buffer] << chunk
192
+ loop do
193
+ nl = state[:read_buffer].index("\n")
194
+ break unless nl
195
+ line = state[:read_buffer].slice!(0..nl)
196
+ line.chomp!
197
+ next if line.empty?
198
+ process_message(io, line)
199
+ end
200
+ end
201
+
202
+ def drain_client(io)
203
+ state = @clients[io]
204
+ return unless state
205
+ buf = state[:write_buffer]
206
+ return if buf.empty?
207
+ loop do
208
+ n = io.write_nonblock(buf)
209
+ if n >= buf.bytesize
210
+ buf.clear
211
+ break
212
+ else
213
+ # write_nonblock returns the count actually written; slice the rest.
214
+ state[:write_buffer] = buf.byteslice(n..-1) || +"".b
215
+ buf = state[:write_buffer]
216
+ end
217
+ end
218
+ rescue IO::WaitWritable
219
+ # Kernel send buffer full; remainder stays queued.
220
+ rescue Errno::EPIPE, Errno::ECONNRESET, IOError
221
+ drop_client(io)
222
+ end
223
+
224
+ def drop_client(io)
225
+ @clients.delete(io)
226
+ @subscriptions.delete(io)
227
+ # Any pane.run waiters owned by this client are silently abandoned —
228
+ # there's nobody to respond to.
229
+ @pending_runs.reject! { |r| r[:client_io] == io } unless @pending_runs.empty?
230
+ io.close rescue nil
231
+ end
232
+
233
+ def monotonic_now
234
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
235
+ end
236
+
237
+ def pane_by_id(id)
238
+ @app.session.window.panes.find { |p| p.id.to_s == id.to_s }
239
+ end
240
+
241
+ def complete_run(run, timed_out:, now:)
242
+ pane = pane_by_id(run[:pane_id])
243
+ unless pane
244
+ respond_error(run[:client_io], id: run[:request_id],
245
+ code: INVALID_PARAMS,
246
+ message: "pane #{run[:pane_id]} no longer exists")
247
+ return
248
+ end
249
+ term = pane.terminal
250
+ respond_result(run[:client_io], id: run[:request_id], result: {
251
+ "pane" => pane.id.to_s,
252
+ "timed_out" => timed_out,
253
+ "had_output" => run[:had_output],
254
+ "elapsed_ms" => ((now - run[:started_at]) * 1000).round,
255
+ "text" => term.dump_text,
256
+ "cursor" => { "row" => term.cursor_row, "col" => term.cursor_col }
257
+ })
258
+ end
259
+
260
+ def process_message(io, line)
261
+ msg = JSON.parse(line)
262
+ rescue JSON::ParserError => e
263
+ respond_error(io, id: nil, code: PARSE_ERROR, message: "Parse error: #{e.message}")
264
+ return
265
+ else
266
+ id = msg["id"]
267
+ method = msg["method"]
268
+ params = msg["params"] || {}
269
+ unless method.is_a?(String)
270
+ respond_error(io, id: id, code: INVALID_REQUEST, message: "missing method")
271
+ return
272
+ end
273
+ begin
274
+ # The dispatcher may either return a Hash (synchronous result) or the
275
+ # symbol :deferred (it will later push a response when ready — used
276
+ # by pane.run / pane.subscribe in step 3).
277
+ result = @dispatcher.call(method: method, params: params, client_io: io, request_id: id)
278
+ respond_result(io, id: id, result: result) if id && result != :deferred
279
+ rescue Dispatcher::Error => e
280
+ respond_error(io, id: id, code: e.code, message: e.message)
281
+ rescue StandardError => e
282
+ respond_error(io, id: id, code: INTERNAL_ERROR, message: "#{e.class}: #{e.message}")
283
+ end
284
+ end
285
+
286
+ public
287
+
288
+ def respond_result(io, id:, result:)
289
+ write_json(io, { "id" => id, "result" => result })
290
+ end
291
+
292
+ def respond_error(io, id:, code:, message:, data: nil)
293
+ err = { "code" => code, "message" => message }
294
+ err["data"] = data unless data.nil?
295
+ write_json(io, { "id" => id, "error" => err })
296
+ end
297
+
298
+ def emit_event(io, method, params)
299
+ write_json(io, { "method" => method, "params" => params })
300
+ end
301
+
302
+ def write_json(io, hash)
303
+ return unless @clients.key?(io)
304
+ line = JSON.generate(hash) + "\n"
305
+ @clients[io][:write_buffer] << line.b
306
+ drain_client(io)
307
+ end
308
+ end
309
+
310
+ # Dispatcher dispatches a single JSON-RPC method name to one of the
311
+ # Application's read/mutate operations. Read-only methods land here in
312
+ # step 2; mutating methods (pane.send_input, layout.set, …) and the
313
+ # asynchronous pane.run / pane.subscribe land in step 3.
314
+ class Dispatcher
315
+ class Error < StandardError
316
+ attr_reader :code
317
+ def initialize(message, code: ControlServer::INVALID_PARAMS)
318
+ super(message)
319
+ @code = code
320
+ end
321
+ end
322
+
323
+ def initialize(app, server)
324
+ @app = app
325
+ @server = server
326
+ end
327
+
328
+ BRACKET_PASTE_START = "\e[200~".b
329
+ BRACKET_PASTE_END = "\e[201~".b
330
+
331
+ DEFAULT_IDLE_MS = 500
332
+ DEFAULT_TIMEOUT_MS = 30_000
333
+ MAX_TIMEOUT_MS = 5 * 60 * 1000 # 5 min cap so a runaway wait can't hold a slot forever.
334
+
335
+ def call(method:, params:, client_io:, request_id:)
336
+ case method
337
+ when "ping" then { "pong" => true }
338
+ when "session.get" then session_get
339
+ when "session.save" then session_save
340
+ when "panes.list" then panes_list
341
+ when "pane.read" then pane_read(params)
342
+ when "pane.send_input" then pane_send_input(params)
343
+ when "pane.focus" then pane_focus(params)
344
+ when "pane.new" then pane_new(params)
345
+ when "pane.kill" then pane_kill(params)
346
+ when "pane.promote" then pane_promote(params)
347
+ when "pane.run" then pane_run(params, client_io, request_id)
348
+ when "pane.subscribe" then pane_subscribe(params, client_io)
349
+ when "pane.unsubscribe" then pane_unsubscribe(params, client_io)
350
+ when "layout.set" then layout_set(params)
351
+ when "layout.cycle" then layout_cycle
352
+ when "drawer.toggle" then drawer_action(:toggle_drawer)
353
+ when "drawer.show" then drawer_action(:show_drawer)
354
+ when "drawer.hide" then drawer_action(:hide_drawer)
355
+ when "drawer.reset" then drawer_action(:reset_drawer)
356
+ when "drawer.send_input" then drawer_send_input(params)
357
+ when "drawer.read" then drawer_read(params)
358
+ else
359
+ raise Error.new("unknown method: #{method}", code: ControlServer::METHOD_NOT_FOUND)
360
+ end
361
+ end
362
+
363
+ private
364
+
365
+ def session_get
366
+ session = @app.session
367
+ win = session.window
368
+ drawer = session.drawer
369
+ {
370
+ "name" => session.name,
371
+ "width" => session.width,
372
+ "height" => session.height,
373
+ "layout" => win.layout.to_s,
374
+ "available_layouts" => Window::LAYOUTS.map(&:to_s),
375
+ "pane_count" => win.panes.length,
376
+ "focused_pane" => focused_pane_id(session),
377
+ "focused_slot" => focused_pane_slot(session),
378
+ "master_slot" => win.panes.empty? ? nil : win.master_index + 1,
379
+ "focus_drawer" => !!session.focus_drawer,
380
+ "drawer" => {
381
+ "present" => !drawer.nil?,
382
+ "visible" => drawer&.visible? || false
383
+ }
384
+ }
385
+ end
386
+
387
+ def panes_list
388
+ win = @app.session.window
389
+ focused_idx = win.focused_index
390
+ master_idx = win.master_index
391
+ panes = win.panes.each_with_index.map do |pane, i|
392
+ private_pane = pane_private?(pane)
393
+ entry = {
394
+ "id" => pane.id.to_s,
395
+ "slot" => i + 1,
396
+ "focused" => i == focused_idx,
397
+ "master" => i == master_idx,
398
+ "alive" => pane.alive?,
399
+ "private" => private_pane
400
+ }
401
+ # On a private pane: don't leak cwd or the screen size (a writeable
402
+ # surface anyone with the MCP could probe). Id+slot are kept so
403
+ # Claude can name the pane in a refusal ("I can't read pane #2,
404
+ # it's private").
405
+ unless private_pane
406
+ entry["cwd"] = safe_cwd(pane)
407
+ entry["rows"] = pane.terminal.rows
408
+ entry["cols"] = pane.terminal.cols
409
+ end
410
+ entry
411
+ end
412
+ { "panes" => panes }
413
+ end
414
+
415
+ def pane_read(params)
416
+ pane = find_pane(params)
417
+ ensure_not_private!(pane, "pane.read")
418
+ term = pane.terminal
419
+ {
420
+ "id" => pane.id.to_s,
421
+ "rows" => term.rows,
422
+ "cols" => term.cols,
423
+ "cursor" => { "row" => term.cursor_row, "col" => term.cursor_col },
424
+ "scrollback_size" => term.scrollback_size,
425
+ "scrolled_back" => term.scrolled_back?,
426
+ "text" => term.dump_text
427
+ }
428
+ end
429
+
430
+ def drawer_read(_params)
431
+ drawer = @app.session.drawer
432
+ pane = drawer&.pane
433
+ unless pane
434
+ return { "present" => false, "visible" => false, "rows" => 0, "cols" => 0, "text" => "" }
435
+ end
436
+ term = pane.terminal
437
+ {
438
+ "present" => true,
439
+ "visible" => drawer.visible?,
440
+ "rows" => term.rows,
441
+ "cols" => term.cols,
442
+ "cursor" => { "row" => term.cursor_row, "col" => term.cursor_col },
443
+ "text" => term.dump_text
444
+ }
445
+ end
446
+
447
+ # ---- mutating methods ----
448
+
449
+ def session_save
450
+ path = @app.session.save
451
+ { "saved_to" => path }
452
+ end
453
+
454
+ def pane_send_input(params)
455
+ pane = find_pane(params)
456
+ ensure_not_private!(pane, "pane.send_input")
457
+ raw, payload = build_input_payload(params, text_key: "data", required: true, bracketed: !!params["bracketed"])
458
+ pane.write(payload)
459
+ { "pane" => pane.id.to_s, "bytes" => raw.bytesize }
460
+ end
461
+
462
+ def pane_focus(params)
463
+ pane = find_pane(params)
464
+ idx = @app.session.window.panes.index(pane)
465
+ @app.session.focus_drawer = false
466
+ @app.session.window.focus_index(idx)
467
+ @app.invalidate
468
+ { "pane" => pane.id.to_s, "slot" => idx + 1 }
469
+ end
470
+
471
+ def pane_new(params)
472
+ cwd = params["cwd"]
473
+ pane = @app.new_pane(cwd: cwd.is_a?(String) ? cwd : nil)
474
+ { "pane" => pane.id.to_s, "slot" => @app.session.window.panes.index(pane) + 1 }
475
+ end
476
+
477
+ def pane_kill(params)
478
+ pane = find_pane(params)
479
+ ensure_not_private!(pane, "pane.kill")
480
+ @app.session.window.remove_pane(pane)
481
+ @app.invalidate
482
+ { "pane" => pane.id.to_s }
483
+ end
484
+
485
+ def pane_promote(params)
486
+ pane = find_pane(params)
487
+ idx = @app.session.window.panes.index(pane)
488
+ # Window#promote_to_master operates on the currently focused pane, so
489
+ # focus the requested pane first to keep the semantics aligned with the
490
+ # human keybinding (Ctrl-a Enter).
491
+ @app.session.window.focus_index(idx)
492
+ @app.session.window.promote_to_master
493
+ @app.invalidate
494
+ { "pane" => pane.id.to_s, "slot" => @app.session.window.panes.index(pane) + 1 }
495
+ end
496
+
497
+ # pane.run is asynchronous: it sends input (optionally) and registers a
498
+ # waiter that the ControlServer's #tick resolves when output goes idle.
499
+ # Returns :deferred so ControlServer skips the synchronous response path;
500
+ # the resolution arrives later via ControlServer#complete_run.
501
+ def pane_run(params, client_io, request_id)
502
+ raise Error.new("pane.run: missing request id (notifications cannot wait)") unless request_id
503
+ pane = find_pane(params)
504
+ ensure_not_private!(pane, "pane.run")
505
+ append_enter = params.fetch("append_enter", true)
506
+ bracketed = !!params["bracketed"]
507
+ idle_ms = clamp_int(params["idle_ms"], min: 50, max: 60_000, default: DEFAULT_IDLE_MS)
508
+ timeout_ms = clamp_int(params["timeout_ms"], min: 100, max: MAX_TIMEOUT_MS, default: DEFAULT_TIMEOUT_MS)
509
+
510
+ _raw, body = build_input_payload(params, text_key: "input", required: false, bracketed: bracketed)
511
+ payload = +"".b
512
+ payload << body
513
+ payload << "\r".b if append_enter
514
+ pane.write(payload) unless payload.empty?
515
+
516
+ @server.register_pending_run(
517
+ client_io: client_io,
518
+ request_id: request_id,
519
+ pane_id: pane.id.to_s,
520
+ idle_seconds: idle_ms / 1000.0,
521
+ timeout_seconds: timeout_ms / 1000.0
522
+ )
523
+ :deferred
524
+ end
525
+
526
+ def pane_subscribe(params, client_io)
527
+ pane = find_pane(params)
528
+ ensure_not_private!(pane, "pane.subscribe")
529
+ @server.add_subscription(client_io, pane.id.to_s)
530
+ { "pane" => pane.id.to_s, "subscribed" => true }
531
+ end
532
+
533
+ def pane_unsubscribe(params, client_io)
534
+ pane = find_pane(params)
535
+ removed = @server.remove_subscription(client_io, pane.id.to_s)
536
+ { "pane" => pane.id.to_s, "subscribed" => false, "was_subscribed" => removed }
537
+ end
538
+
539
+ def layout_set(params)
540
+ name = params["layout"].to_s
541
+ sym = name.to_sym
542
+ unless Window::LAYOUTS.include?(sym)
543
+ raise Error.new("layout: unknown layout #{name.inspect}; want one of #{Window::LAYOUTS.map(&:to_s).join(', ')}")
544
+ end
545
+ @app.session.window.set_layout(sym)
546
+ @app.invalidate
547
+ { "layout" => sym.to_s }
548
+ end
549
+
550
+ def layout_cycle
551
+ @app.session.window.cycle_layout
552
+ @app.invalidate
553
+ { "layout" => @app.session.window.layout.to_s }
554
+ end
555
+
556
+ def drawer_action(method_name)
557
+ @app.public_send(method_name)
558
+ drawer = @app.session.drawer
559
+ {
560
+ "present" => !drawer.nil?,
561
+ "visible" => drawer&.visible? || false
562
+ }
563
+ end
564
+
565
+ def drawer_send_input(params)
566
+ drawer = @app.session.drawer
567
+ raise Error.new("drawer.send_input: no drawer (toggle one open first)") unless drawer&.pane
568
+ raw, payload = build_input_payload(params, text_key: "data", required: true, bracketed: !!params["bracketed"])
569
+ drawer.pane.write(payload)
570
+ { "bytes" => raw.bytesize }
571
+ end
572
+
573
+ def require_string(params, key)
574
+ v = params[key]
575
+ raise Error.new("missing #{key}") unless v.is_a?(String)
576
+ v
577
+ end
578
+
579
+ def wrap_bracketed(data, bracketed)
580
+ return data.b unless bracketed
581
+ BRACKET_PASTE_START + data.b + BRACKET_PASTE_END
582
+ end
583
+
584
+ # Build the bytes to write to a PTY from either a `keys` array (mixed
585
+ # literal text + vim-style named keys) or a plain text field. Returns
586
+ # [raw_bytes, wire_bytes]: raw is the unwrapped concatenation (used for
587
+ # reporting back `bytes`); wire is the same stream with bracketed-paste
588
+ # markers wrapped around literal segments only (named keys are never
589
+ # bracketed — they aren't paste content).
590
+ #
591
+ # text_key — which scalar param to fall back to ("data" or "input").
592
+ # required — when true, raise if both `keys` and text_key are missing.
593
+ def build_input_payload(params, text_key:, required:, bracketed:)
594
+ if params.key?("keys")
595
+ raise Error.new("provide either `#{text_key}` or `keys`, not both") if params[text_key]
596
+ segments = KeyParser.translate(params["keys"])
597
+ raw = +"".b
598
+ wire = +"".b
599
+ segments.each do |kind, bytes|
600
+ raw << bytes
601
+ wire << (kind == :literal && bracketed ? BRACKET_PASTE_START + bytes + BRACKET_PASTE_END : bytes)
602
+ end
603
+ [raw, wire]
604
+ elsif params[text_key].is_a?(String)
605
+ text = params[text_key]
606
+ [text.b, wrap_bracketed(text, bracketed)]
607
+ elsif required
608
+ raise Error.new("missing #{text_key} (or `keys`)")
609
+ else
610
+ ["".b, "".b]
611
+ end
612
+ end
613
+
614
+ def clamp_int(value, min:, max:, default:)
615
+ v = value.is_a?(Integer) ? value : default
616
+ v.clamp(min, max)
617
+ end
618
+
619
+ # ----- helpers shared with step-3 methods -----
620
+
621
+ def find_pane(params)
622
+ id_or_slot = params["pane"] || params["id"] || params["slot"]
623
+ raise Error.new("pane: missing pane id or slot") if id_or_slot.nil?
624
+
625
+ win = @app.session.window
626
+ if id_or_slot.is_a?(Integer)
627
+ idx = id_or_slot - 1
628
+ unless idx >= 0 && idx < win.panes.length
629
+ raise Error.new("pane: no pane at slot #{id_or_slot}")
630
+ end
631
+ win.panes[idx]
632
+ else
633
+ id = id_or_slot.to_s
634
+ 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
637
+ end
638
+ end
639
+
640
+ def safe_cwd(pane)
641
+ pane.respond_to?(:cwd) ? pane.cwd : nil
642
+ rescue StandardError
643
+ nil
644
+ end
645
+
646
+ def pane_private?(pane)
647
+ pane.respond_to?(:private?) && pane.private?
648
+ end
649
+
650
+ # Raise a structured error pointing the MCP client at the user gesture
651
+ # that would un-mark the pane. The skill teaches Claude to surface this
652
+ # to the human rather than retry.
653
+ def ensure_not_private!(pane, method_name)
654
+ return unless pane_private?(pane)
655
+ raise Error.new(
656
+ "#{method_name}: pane #{pane.id} is private; the user must press Ctrl-a P (or type `:private`) on it to expose to MCP"
657
+ )
658
+ end
659
+
660
+ def focused_pane_id(session)
661
+ pane = session.window.focused_pane
662
+ pane&.id&.to_s
663
+ end
664
+
665
+ def focused_pane_slot(session)
666
+ return nil if session.window.panes.empty?
667
+ session.window.focused_index + 1
668
+ end
669
+ end
670
+ end
data/lib/muxr/drawer.rb CHANGED
@@ -5,14 +5,21 @@ module Muxr
5
5
  #
6
6
  # The actual Pane is injected (rather than constructed here) so tests can
7
7
  # exercise the visibility/state machine without spawning real shells.
8
+ #
9
+ # `command` records what kind of shell the drawer is hosting: nil for the
10
+ # default user shell (Ctrl-a ~), or the literal command string used to
11
+ # spawn it (e.g. "claude" for the Ctrl-a C drawer). The Application uses
12
+ # this to decide whether a Ctrl-a ~ / Ctrl-a C press should toggle
13
+ # visibility or tear down and replace the drawer with a different kind.
8
14
  class Drawer
9
15
  attr_accessor :pane, :visible
10
- attr_reader :origin_cwd
16
+ attr_reader :origin_cwd, :command
11
17
 
12
- def initialize(pane: nil, origin_cwd: nil)
18
+ def initialize(pane: nil, origin_cwd: nil, command: nil)
13
19
  @pane = pane
14
20
  @visible = false
15
21
  @origin_cwd = origin_cwd
22
+ @command = command
16
23
  end
17
24
 
18
25
  def visible?
@@ -16,6 +16,8 @@ module Muxr
16
16
  "\r" => :promote_master,
17
17
  "\n" => :promote_master,
18
18
  "~" => :toggle_drawer,
19
+ "C" => :toggle_claude_drawer,
20
+ "P" => :toggle_private_focused,
19
21
  "d" => :detach,
20
22
  "?" => :show_help,
21
23
  "q" => :quit_immediate,