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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +84 -1
- data/bin/muxr +61 -0
- data/bin/muxr-mcp +412 -0
- data/lib/muxr/application.rb +198 -29
- data/lib/muxr/command_dispatcher.rb +2 -0
- data/lib/muxr/control_server.rb +670 -0
- data/lib/muxr/drawer.rb +9 -2
- data/lib/muxr/input_handler.rb +2 -0
- data/lib/muxr/key_parser.rb +89 -0
- data/lib/muxr/pane.rb +65 -7
- data/lib/muxr/renderer.rb +22 -4
- data/lib/muxr/session.rb +13 -2
- data/lib/muxr/terminal.rb +80 -2
- data/lib/muxr/version.rb +1 -1
- data/lib/muxr.rb +1 -0
- data/muxr.gemspec +3 -1
- data/skills/muxr-control/SKILL.md +190 -0
- metadata +6 -1
|
@@ -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?
|
data/lib/muxr/input_handler.rb
CHANGED