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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +225 -1
- data/README.md +763 -387
- data/bin/muxr +68 -33
- data/bin/muxr-mcp +172 -16
- data/lib/muxr/application.rb +516 -26
- 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 +112 -14
- data/lib/muxr/layout_manager.rb +83 -57
- data/lib/muxr/mouse_report.rb +24 -0
- data/lib/muxr/pane.rb +122 -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 +34 -7
- data/lib/muxr/remote_pane.rb +263 -0
- data/lib/muxr/renderer.rb +259 -76
- data/lib/muxr/session.rb +19 -2
- data/lib/muxr/session_directory.rb +102 -0
- data/lib/muxr/terminal.rb +759 -82
- 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
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "socket"
|
|
3
|
+
|
|
4
|
+
module Muxr
|
|
5
|
+
# Enumerates the panes of every muxr server currently running on this
|
|
6
|
+
# machine by asking each one over its control socket. Used to populate the
|
|
7
|
+
# attach picker, which is the only place muxr looks outside its own process.
|
|
8
|
+
#
|
|
9
|
+
# Every query is best-effort and deadline-bounded: a session whose server is
|
|
10
|
+
# wedged, mid-shutdown, or newer/older than us simply doesn't appear in the
|
|
11
|
+
# list rather than hanging the event loop that is asking.
|
|
12
|
+
module SessionDirectory
|
|
13
|
+
QUERY_TIMEOUT = 0.5
|
|
14
|
+
|
|
15
|
+
Entry = Struct.new(:session, :socket_path, :pane_id, :slot, :cwd, :rows, :cols, :focused, :name, keyword_init: true) do
|
|
16
|
+
def label
|
|
17
|
+
"#{session}:#{pane_id}"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Session names with a live server, paired with their control socket path.
|
|
22
|
+
# A name is only reported when both its TTY socket and its control socket
|
|
23
|
+
# accept a connection, which is what makes a session actually attachable.
|
|
24
|
+
def self.live_sessions
|
|
25
|
+
dir = Application::SOCKETS_DIR
|
|
26
|
+
return {} unless File.directory?(dir)
|
|
27
|
+
Dir.children(dir).sort.each_with_object({}) do |entry, acc|
|
|
28
|
+
next unless entry.end_with?(".sock")
|
|
29
|
+
next if entry.end_with?(".ctrl.sock")
|
|
30
|
+
name = File.basename(entry, ".sock")
|
|
31
|
+
control = Application.control_socket_path_for(name)
|
|
32
|
+
next unless Application.alive_socket?(File.join(dir, entry))
|
|
33
|
+
next unless Application.alive_socket?(control)
|
|
34
|
+
acc[name] = control
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Every non-private pane of every live session other than +exclude+, in
|
|
39
|
+
# session then slot order. Private panes are omitted for the same reason
|
|
40
|
+
# the MCP surface hides them: the user marked them not-for-sharing.
|
|
41
|
+
def self.panes(exclude: nil)
|
|
42
|
+
live_sessions.flat_map do |name, control|
|
|
43
|
+
next [] if name == exclude
|
|
44
|
+
list = query(control, "panes.list")
|
|
45
|
+
next [] unless list
|
|
46
|
+
(list["panes"] || []).filter_map do |pane|
|
|
47
|
+
next if pane["private"]
|
|
48
|
+
next unless pane["alive"]
|
|
49
|
+
Entry.new(
|
|
50
|
+
session: name,
|
|
51
|
+
socket_path: control,
|
|
52
|
+
pane_id: pane["id"].to_s,
|
|
53
|
+
slot: pane["slot"],
|
|
54
|
+
cwd: pane["cwd"],
|
|
55
|
+
rows: pane["rows"],
|
|
56
|
+
cols: pane["cols"],
|
|
57
|
+
focused: !!pane["focused"],
|
|
58
|
+
name: pane["name"]
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.query(control_path, method, params = {})
|
|
65
|
+
socket = UNIXSocket.new(control_path)
|
|
66
|
+
begin
|
|
67
|
+
socket.write(JSON.generate("id" => 1, "method" => method, "params" => params) + "\n")
|
|
68
|
+
read_response(socket)
|
|
69
|
+
ensure
|
|
70
|
+
socket.close rescue nil
|
|
71
|
+
end
|
|
72
|
+
rescue SystemCallError, IOError
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.read_response(socket)
|
|
77
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + QUERY_TIMEOUT
|
|
78
|
+
buffer = +""
|
|
79
|
+
loop do
|
|
80
|
+
while (line = buffer.slice!(/\A.*\n/))
|
|
81
|
+
msg = begin
|
|
82
|
+
JSON.parse(line)
|
|
83
|
+
rescue JSON::ParserError
|
|
84
|
+
next
|
|
85
|
+
end
|
|
86
|
+
next unless msg["id"] == 1
|
|
87
|
+
return msg["result"]
|
|
88
|
+
end
|
|
89
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
90
|
+
return nil if remaining <= 0
|
|
91
|
+
return nil unless IO.select([socket], nil, nil, remaining)
|
|
92
|
+
begin
|
|
93
|
+
buffer << socket.read_nonblock(4096)
|
|
94
|
+
rescue IO::WaitReadable
|
|
95
|
+
next
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
rescue EOFError, SystemCallError, IOError
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|