ace-tmux 0.14.3 → 0.17.2
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 +58 -0
- data/README.md +11 -3
- data/docs/demo/ace-tmux-getting-started.tape.yml +5 -1
- data/docs/getting-started.md +7 -3
- data/docs/usage.md +168 -10
- data/lib/ace/tmux/atoms/named_key_registry.rb +34 -0
- data/lib/ace/tmux/atoms/tmux_command_builder.rb +28 -1
- data/lib/ace/tmux/atoms/window_name_sanitizer.rb +25 -0
- data/lib/ace/tmux/cli/commands/attach.rb +26 -0
- data/lib/ace/tmux/cli/commands/capture.rb +41 -0
- data/lib/ace/tmux/cli/commands/detach.rb +28 -0
- data/lib/ace/tmux/cli/commands/list.rb +113 -48
- data/lib/ace/tmux/cli/commands/list_presets.rb +67 -0
- data/lib/ace/tmux/cli/commands/send.rb +169 -0
- data/lib/ace/tmux/cli/commands/wait.rb +57 -0
- data/lib/ace/tmux/cli/commands/window.rb +2 -3
- data/lib/ace/tmux/cli.rb +27 -2
- data/lib/ace/tmux/models/runtime_target.rb +46 -0
- data/lib/ace/tmux/molecules/local_process_inspector.rb +84 -0
- data/lib/ace/tmux/molecules/runtime_target_resolver.rb +267 -0
- data/lib/ace/tmux/molecules/tmux_executor.rb +8 -0
- data/lib/ace/tmux/molecules/wait_condition_validator.rb +25 -0
- data/lib/ace/tmux/organisms/control_surface.rb +523 -0
- data/lib/ace/tmux/organisms/session_manager.rb +17 -3
- data/lib/ace/tmux/organisms/window_manager.rb +8 -3
- data/lib/ace/tmux/version.rb +1 -1
- data/lib/ace/tmux.rb +11 -0
- metadata +15 -2
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Tmux
|
|
7
|
+
module Molecules
|
|
8
|
+
class LocalProcessInspector
|
|
9
|
+
def initialize(command_runner: nil)
|
|
10
|
+
@command_runner = command_runner || method(:run_command)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def find_descendant_command(root_pid, allowed_commands:)
|
|
14
|
+
normalized_root = normalize_pid(root_pid)
|
|
15
|
+
allowed = Array(allowed_commands).map { |command| normalize_command(command) }.reject(&:empty?)
|
|
16
|
+
return nil if normalized_root.nil? || allowed.empty?
|
|
17
|
+
|
|
18
|
+
queue = [normalized_root]
|
|
19
|
+
visited = {}
|
|
20
|
+
|
|
21
|
+
until queue.empty?
|
|
22
|
+
parent_pid = queue.shift
|
|
23
|
+
next if visited[parent_pid]
|
|
24
|
+
|
|
25
|
+
visited[parent_pid] = true
|
|
26
|
+
|
|
27
|
+
child_processes(parent_pid).each do |child|
|
|
28
|
+
return child[:command] if allowed.include?(child[:command])
|
|
29
|
+
|
|
30
|
+
queue << child[:pid]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
attr_reader :command_runner
|
|
40
|
+
|
|
41
|
+
def child_processes(parent_pid)
|
|
42
|
+
result = command_runner.call(["ps", "-o", "pid=,ppid=,comm=", "--ppid", parent_pid])
|
|
43
|
+
return [] unless result[:success]
|
|
44
|
+
|
|
45
|
+
result[:stdout].to_s.split("\n").map { |line| parse_process_line(line) }.compact
|
|
46
|
+
rescue Errno::ENOENT
|
|
47
|
+
[]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def parse_process_line(line)
|
|
51
|
+
pid, _ppid, command = line.to_s.strip.split(/\s+/, 3)
|
|
52
|
+
return nil if pid.to_s.empty? || command.to_s.empty?
|
|
53
|
+
|
|
54
|
+
{
|
|
55
|
+
pid: normalize_pid(pid),
|
|
56
|
+
command: normalize_command(command)
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def run_command(cmd)
|
|
61
|
+
stdout, stderr, status = Open3.capture3(*cmd)
|
|
62
|
+
{
|
|
63
|
+
success: status.success?,
|
|
64
|
+
stdout: stdout,
|
|
65
|
+
stderr: stderr
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def normalize_pid(value)
|
|
70
|
+
candidate = value.to_s.strip
|
|
71
|
+
return nil if candidate.empty?
|
|
72
|
+
|
|
73
|
+
Integer(candidate).to_s
|
|
74
|
+
rescue ArgumentError
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def normalize_command(value)
|
|
79
|
+
value.to_s.strip.downcase
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Tmux
|
|
5
|
+
module Molecules
|
|
6
|
+
class RuntimeTargetResolver
|
|
7
|
+
def initialize(executor: Ace::Tmux::Molecules::TmuxExecutor.new, tmux: "tmux", env: ENV)
|
|
8
|
+
@executor = executor
|
|
9
|
+
@tmux = tmux
|
|
10
|
+
@env = env
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def resolve_session(session: nil)
|
|
14
|
+
explicit = normalize(session)
|
|
15
|
+
return Models::RuntimeTarget.new(session: explicit, source: "explicit") if explicit
|
|
16
|
+
|
|
17
|
+
env_session = normalize(env["ACE_TMUX_SESSION"])
|
|
18
|
+
return Models::RuntimeTarget.new(session: env_session, source: "env") if env_session
|
|
19
|
+
|
|
20
|
+
live_session = resolve_live_session
|
|
21
|
+
return Models::RuntimeTarget.new(session: live_session, source: "live") if live_session
|
|
22
|
+
|
|
23
|
+
raise Ace::Tmux::TargetResolutionError, "Could not resolve tmux session from flags, ACE_TMUX_SESSION, or live tmux context."
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def resolve_window(session: nil, window: nil)
|
|
27
|
+
session_target = resolve_session(session: session)
|
|
28
|
+
|
|
29
|
+
explicit_window = normalize(window)
|
|
30
|
+
if explicit_window
|
|
31
|
+
return build_window_target(session: session_target.session, raw_window: explicit_window, source: "explicit")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
env_window = normalize(env["ACE_TMUX_WINDOW"])
|
|
35
|
+
if env_window
|
|
36
|
+
return build_window_target(session: session_target.session, raw_window: env_window, source: "env")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
live_window = resolve_live_window(session: session_target.session)
|
|
40
|
+
return Models::RuntimeTarget.new(
|
|
41
|
+
session: session_target.session,
|
|
42
|
+
window: live_window[:name],
|
|
43
|
+
raw_window_target: live_window[:id],
|
|
44
|
+
source: "live"
|
|
45
|
+
) if live_window
|
|
46
|
+
|
|
47
|
+
raise Ace::Tmux::TargetResolutionError, "Could not resolve tmux window from flags, ACE_TMUX_WINDOW, or live tmux context."
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def resolve_pane(session: nil, window: nil, pane: nil)
|
|
51
|
+
explicit = normalize(pane)
|
|
52
|
+
return build_pane_target(explicit, session: session, window: window, source: "explicit") if explicit
|
|
53
|
+
|
|
54
|
+
env_pane = normalize(env["ACE_TMUX_PANE"])
|
|
55
|
+
return build_pane_target(env_pane, session: session, window: window, source: "env") if env_pane
|
|
56
|
+
|
|
57
|
+
window_target = resolve_window(session: session, window: window)
|
|
58
|
+
live_pane = resolve_live_pane(window_target.window_target)
|
|
59
|
+
return Models::RuntimeTarget.new(
|
|
60
|
+
session: window_target.session,
|
|
61
|
+
window: window_target.window,
|
|
62
|
+
pane: live_pane,
|
|
63
|
+
raw_window_target: window_target.window_target,
|
|
64
|
+
source: "live"
|
|
65
|
+
) if live_pane
|
|
66
|
+
|
|
67
|
+
raise Ace::Tmux::TargetResolutionError, "Could not resolve tmux pane from flags, ACE_TMUX_PANE, or live tmux context."
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
attr_reader :executor, :tmux, :env
|
|
73
|
+
|
|
74
|
+
def build_window_target(session:, raw_window:, source:)
|
|
75
|
+
identity = resolve_window_reference(session, raw_window)
|
|
76
|
+
unless identity
|
|
77
|
+
return Models::RuntimeTarget.new(
|
|
78
|
+
session: session,
|
|
79
|
+
window: raw_window,
|
|
80
|
+
raw_window_target: raw_window.start_with?("@") ? raw_window : nil,
|
|
81
|
+
source: source
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
Models::RuntimeTarget.new(
|
|
86
|
+
session: session,
|
|
87
|
+
window: identity[:name],
|
|
88
|
+
raw_window_target: identity[:id],
|
|
89
|
+
source: source
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def build_pane_target(raw_pane, session:, window:, source:)
|
|
94
|
+
if raw_pane.start_with?("%") || raw_window_pane_target?(raw_pane)
|
|
95
|
+
return Models::RuntimeTarget.new(pane: raw_pane, source: source)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
if (full_target = parse_full_pane_target(raw_pane))
|
|
99
|
+
window_target = resolve_window(session: full_target[:session], window: full_target[:window])
|
|
100
|
+
return Models::RuntimeTarget.new(
|
|
101
|
+
session: window_target.session,
|
|
102
|
+
window: window_target.window,
|
|
103
|
+
pane: full_target[:pane],
|
|
104
|
+
raw_window_target: window_target.window_target,
|
|
105
|
+
source: source
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
shorthand = pane_index_shorthand(raw_pane)
|
|
110
|
+
if shorthand
|
|
111
|
+
window_target = resolve_window(session: session, window: window)
|
|
112
|
+
return Models::RuntimeTarget.new(
|
|
113
|
+
session: window_target.session,
|
|
114
|
+
window: window_target.window,
|
|
115
|
+
pane: shorthand,
|
|
116
|
+
raw_window_target: window_target.window_target,
|
|
117
|
+
source: source
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
raise Ace::Tmux::ValidationError, invalid_pane_target_message(raw_pane)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def raw_window_pane_target?(raw_pane)
|
|
125
|
+
raw_pane.match?(/\A@\d+\.\d+\z/)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def parse_full_pane_target(raw_pane)
|
|
129
|
+
session_name, rest = raw_pane.split(":", 2)
|
|
130
|
+
return nil unless rest
|
|
131
|
+
|
|
132
|
+
separator = rest.rindex(".")
|
|
133
|
+
return nil unless separator
|
|
134
|
+
|
|
135
|
+
window_name = rest[0...separator]
|
|
136
|
+
pane_index = rest[(separator + 1)..]
|
|
137
|
+
return nil unless pane_index
|
|
138
|
+
return nil if session_name.to_s.empty? || window_name.to_s.empty? || pane_index.to_s.empty?
|
|
139
|
+
|
|
140
|
+
{
|
|
141
|
+
session: session_name,
|
|
142
|
+
window: window_name,
|
|
143
|
+
pane: pane_index
|
|
144
|
+
}
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def pane_index_shorthand(raw_pane)
|
|
148
|
+
return raw_pane if raw_pane.match?(/\A\d+\z/)
|
|
149
|
+
return raw_pane[1..] if raw_pane.match?(/\A\.\d+\z/)
|
|
150
|
+
|
|
151
|
+
nil
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def invalid_pane_target_message(raw_pane)
|
|
155
|
+
colon_form = raw_pane.match(/\A([^:\s]+):([^:\s]+):([^:\s]+)\z/)
|
|
156
|
+
if colon_form
|
|
157
|
+
corrected = "#{colon_form[1]}:#{colon_form[2]}.#{colon_form[3]}"
|
|
158
|
+
return "Invalid pane target '#{raw_pane}'. Use '%8', '#{corrected}', '.1', or '--window #{colon_form[2]} --pane #{colon_form[3]}'."
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
if raw_pane.start_with?("@") && !raw_pane.include?(".")
|
|
162
|
+
return "Invalid pane target '#{raw_pane}'. Use '%8', 'default:3.1', '.1', or '--window 3 --pane 1'. '#{raw_pane}' is a tmux window id, not a pane target."
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
"Invalid pane target '#{raw_pane}'. Use '%8', 'default:3.1', '.1', or '--window 3 --pane 1'."
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def resolve_window_reference(session, raw_window)
|
|
169
|
+
windows = list_window_identities(session)
|
|
170
|
+
|
|
171
|
+
if raw_window.start_with?("@")
|
|
172
|
+
windows.find { |entry| entry[:id] == raw_window }
|
|
173
|
+
elsif raw_window.match?(/\A\d+\z/)
|
|
174
|
+
windows.find { |entry| entry[:index] == raw_window }
|
|
175
|
+
else
|
|
176
|
+
windows.find { |entry| entry[:name] == raw_window }
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def list_window_identities(session)
|
|
181
|
+
result = executor.capture(
|
|
182
|
+
Atoms::TmuxCommandBuilder.list_windows(
|
|
183
|
+
session,
|
|
184
|
+
format: '#{window_id}' + "\t" + '#{window_index}' + "\t" + '#{window_name}' + "\t" + '#{window_active}',
|
|
185
|
+
tmux: tmux
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
return [] unless result.success?
|
|
189
|
+
|
|
190
|
+
result.stdout.split("\n").map { |line| parse_window_identity(line) }.compact
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def parse_window_identity(line)
|
|
194
|
+
id, index, name, active = line.to_s.split("\t", 4)
|
|
195
|
+
return nil if [id, index, name].any? { |value| value.to_s.empty? }
|
|
196
|
+
|
|
197
|
+
{
|
|
198
|
+
id: id,
|
|
199
|
+
index: index,
|
|
200
|
+
name: name,
|
|
201
|
+
active: active == "1"
|
|
202
|
+
}
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def resolve_live_session
|
|
206
|
+
return nil if normalize(env["TMUX"]).nil?
|
|
207
|
+
|
|
208
|
+
result = executor.capture(Atoms::TmuxCommandBuilder.display_message("#S", tmux: tmux))
|
|
209
|
+
result.success? ? normalize(result.stdout) : nil
|
|
210
|
+
rescue Errno::ENOENT
|
|
211
|
+
nil
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def resolve_live_window(session:)
|
|
215
|
+
result = executor.capture(
|
|
216
|
+
Atoms::TmuxCommandBuilder.display_message_target(
|
|
217
|
+
"#{session}:",
|
|
218
|
+
'#{window_id}' + "\t" + '#{window_index}' + "\t" + '#{window_name}',
|
|
219
|
+
tmux: tmux
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
identity = parse_window_identity(result.stdout)
|
|
223
|
+
return identity if identity
|
|
224
|
+
|
|
225
|
+
explicit_session = normalize(env["ACE_TMUX_SESSION"])
|
|
226
|
+
if explicit_session == session
|
|
227
|
+
fallback = active_window_identity(session)
|
|
228
|
+
return fallback if fallback
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
return nil if normalize(env["TMUX"]).nil?
|
|
232
|
+
|
|
233
|
+
fallback = executor.capture(
|
|
234
|
+
Atoms::TmuxCommandBuilder.display_message(
|
|
235
|
+
'#{window_id}' + "\t" + '#{window_index}' + "\t" + '#{window_name}',
|
|
236
|
+
tmux: tmux
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
parse_window_identity(fallback.stdout)
|
|
240
|
+
rescue Errno::ENOENT
|
|
241
|
+
nil
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def resolve_live_pane(window_target)
|
|
245
|
+
return nil if normalize(env["TMUX"]).nil?
|
|
246
|
+
|
|
247
|
+
result = executor.capture(
|
|
248
|
+
Atoms::TmuxCommandBuilder.display_message_target(window_target, '#{pane_index}', tmux: tmux)
|
|
249
|
+
)
|
|
250
|
+
result.success? ? normalize(result.stdout) : nil
|
|
251
|
+
rescue Errno::ENOENT
|
|
252
|
+
nil
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def normalize(value)
|
|
256
|
+
candidate = value.to_s.strip
|
|
257
|
+
candidate.empty? ? nil : candidate
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def active_window_identity(session)
|
|
261
|
+
windows = list_window_identities(session)
|
|
262
|
+
windows.find { |entry| entry[:active] } || windows.first
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
@@ -57,6 +57,7 @@ module Ace
|
|
|
57
57
|
|
|
58
58
|
def with_socket_target(cmd)
|
|
59
59
|
return cmd unless tmux_command?(cmd)
|
|
60
|
+
return cmd if in_tmux_session?
|
|
60
61
|
|
|
61
62
|
socket = explicit_socket_path
|
|
62
63
|
return cmd unless socket
|
|
@@ -72,6 +73,13 @@ module Ace
|
|
|
72
73
|
Array(cmd).first == "tmux"
|
|
73
74
|
end
|
|
74
75
|
|
|
76
|
+
def in_tmux_session?
|
|
77
|
+
tmux_env = ENV["TMUX"]
|
|
78
|
+
return false if tmux_env.nil? || tmux_env.empty?
|
|
79
|
+
|
|
80
|
+
true
|
|
81
|
+
end
|
|
82
|
+
|
|
75
83
|
def explicit_socket_path
|
|
76
84
|
base = ENV["TMUX_TMPDIR"].to_s.strip
|
|
77
85
|
base = ENV["XDG_RUNTIME_DIR"].to_s.strip if base.empty?
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Tmux
|
|
5
|
+
module Molecules
|
|
6
|
+
module WaitConditionValidator
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
ALLOWED = %w[agent output window-exists window-active pane-exists pane-exited].freeze
|
|
10
|
+
|
|
11
|
+
def validate!(condition:, pattern:)
|
|
12
|
+
normalized = condition.to_s.strip
|
|
13
|
+
raise Ace::Tmux::ValidationError, "--for is required" if normalized.empty?
|
|
14
|
+
raise Ace::Tmux::ValidationError, "Unsupported wait condition: #{condition}" unless ALLOWED.include?(normalized)
|
|
15
|
+
|
|
16
|
+
if normalized == "output" && pattern.to_s.strip.empty?
|
|
17
|
+
raise Ace::Tmux::ValidationError, "--pattern is required when waiting for output"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
normalized
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|