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.
@@ -0,0 +1,523 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Tmux
5
+ module Organisms
6
+ class ControlSurface
7
+ DEFAULT_LINES = 40
8
+ DEFAULT_TIMEOUT = 10.0
9
+ DEFAULT_INTERVAL = 0.2
10
+ DEFAULT_SETTLE = 1.0
11
+ INTERACTIVE_SUBMIT_DELAY = 0.15
12
+ INTERACTIVE_CLI_COMMANDS = %w[codex claude pi].freeze
13
+ INTERACTIVE_BUSY_PATTERNS = [
14
+ /esc to interrupt/i,
15
+ /\bWorking \(/,
16
+ /Booting MCP server:/i
17
+ ].freeze
18
+ PANE_PROFILE_FORMAT = '#{pane_current_command}' + "\t" + '#{pane_height}' + "\t" + '#{alternate_on}' + "\t" + '#{pane_pid}'
19
+ SESSION_LIST_FORMAT = '#{session_name}' + "\t" + '#{session_attached}' + "\t" + '#{session_windows}'
20
+ WINDOW_LIST_FORMAT = '#{window_active}' + "\t" + '#{window_id}' + "\t" + '#{session_name}' + "\t" + '#{window_index}' + "\t" + '#{window_name}' + "\t" + '#{window_panes}'
21
+ PANE_LIST_FORMAT = '#{pane_active}' + "\t" + '#{pane_id}' + "\t" + '#{session_name}' + "\t" + '#{window_id}' + "\t" + '#{window_index}' + "\t" + '#{window_name}' + "\t" + '#{pane_index}' + "\t" + '#{pane_current_command}' + "\t" + '#{pane_current_path}'
22
+
23
+ def initialize(executor: Ace::Tmux::Molecules::TmuxExecutor.new, resolver: nil, process_inspector: nil, tmux: "tmux")
24
+ @executor = executor
25
+ @tmux = tmux
26
+ @resolver = resolver || Molecules::RuntimeTargetResolver.new(executor: executor, tmux: tmux)
27
+ @process_inspector = process_inspector || Molecules::LocalProcessInspector.new
28
+ end
29
+
30
+ def send_command(session: nil, window: nil, pane: nil, command:)
31
+ raise Ace::Tmux::ValidationError, "--cmd is required" if command.to_s.strip.empty?
32
+
33
+ send_sequence(session: session, window: window, pane: pane, command: command)
34
+ end
35
+
36
+ def send_text(session: nil, window: nil, pane: nil, text:)
37
+ raise Ace::Tmux::ValidationError, "--msg is required" if text.to_s.empty?
38
+
39
+ target = resolver.resolve_pane(session: session, window: window, pane: pane)
40
+ send_text_to_target(target.pane_target, text)
41
+ end
42
+
43
+ def send_key(session: nil, window: nil, pane: nil, key:)
44
+ send_sequence(session: session, window: window, pane: pane, keys: [key])
45
+ end
46
+
47
+ def send_sequence(session: nil, window: nil, pane: nil, command: nil, messages: [], keys: [])
48
+ target = resolver.resolve_pane(session: session, window: window, pane: pane)
49
+ pane_profile = fetch_pane_profile(target.pane_target)
50
+ normalized_command = normalize_optional_text(command)
51
+ normalized_messages = Array(messages).map(&:to_s)
52
+ normalized_keys = Array(keys)
53
+ text_pending_submit = false
54
+
55
+ normalized_messages.each do |message|
56
+ send_text_to_target(target.pane_target, message)
57
+ text_pending_submit = true
58
+ end
59
+
60
+ if normalized_command
61
+ send_text_to_target(target.pane_target, normalized_command)
62
+ pause_before_interactive_submit(pane_profile, "Enter")
63
+ send_named_key_to_target(target.pane_target, "Enter")
64
+ text_pending_submit = false
65
+ end
66
+
67
+ normalized_keys.each_with_index do |key, index|
68
+ pause_before_interactive_submit(pane_profile, key) if text_pending_submit && index.zero?
69
+ send_named_key_to_target(target.pane_target, key)
70
+ end
71
+ end
72
+
73
+ def capture_recent_output(session: nil, window: nil, pane: nil, lines: DEFAULT_LINES)
74
+ target = resolver.resolve_pane(session: session, window: window, pane: pane)
75
+ pane_profile = fetch_pane_profile(target.pane_target)
76
+ capture_output_for_target(target.pane_target, pane_profile: pane_profile, lines: lines)
77
+ end
78
+
79
+ def wait_for_condition(
80
+ condition:,
81
+ session: nil,
82
+ window: nil,
83
+ pane: nil,
84
+ pattern: nil,
85
+ timeout: DEFAULT_TIMEOUT,
86
+ interval: DEFAULT_INTERVAL,
87
+ lines: DEFAULT_LINES,
88
+ settle: DEFAULT_SETTLE,
89
+ baseline_output: nil,
90
+ require_change: false
91
+ )
92
+ normalized = Molecules::WaitConditionValidator.validate!(condition: condition, pattern: pattern)
93
+ deadline = monotonic_now + timeout.to_f
94
+
95
+ if normalized == "agent"
96
+ return wait_for_agent_condition(
97
+ session: session,
98
+ window: window,
99
+ pane: pane,
100
+ timeout_deadline: deadline,
101
+ interval: interval.to_f,
102
+ lines: lines,
103
+ settle: settle.to_f,
104
+ baseline_output: baseline_output,
105
+ require_change: require_change
106
+ )
107
+ end
108
+
109
+ if normalized == "output" && !baseline_output.nil?
110
+ return wait_for_output_condition(
111
+ session: session,
112
+ window: window,
113
+ pane: pane,
114
+ pattern: pattern,
115
+ timeout_deadline: deadline,
116
+ interval: interval.to_f,
117
+ lines: lines,
118
+ baseline_output: baseline_output
119
+ )
120
+ end
121
+
122
+ loop do
123
+ return true if condition_met?(normalized, session: session, window: window, pane: pane, pattern: pattern)
124
+
125
+ raise_wait_timeout(normalized) if monotonic_now >= deadline
126
+
127
+ sleep(interval.to_f)
128
+ end
129
+ end
130
+
131
+ def attach_session(session: nil)
132
+ target = resolver.resolve_session(session: session)
133
+ executor.exec(Atoms::TmuxCommandBuilder.attach_session(target.session_target, tmux: tmux))
134
+ end
135
+
136
+ def detach_session(session: nil)
137
+ target = resolver.resolve_session(session: session)
138
+ run!(
139
+ Atoms::TmuxCommandBuilder.detach_client(target.session_target, tmux: tmux),
140
+ "detach clients from #{target.session_target}"
141
+ )
142
+ target.session_target
143
+ end
144
+
145
+ def list_sessions
146
+ result = executor.capture(
147
+ Atoms::TmuxCommandBuilder.list_sessions(format: SESSION_LIST_FORMAT, tmux: tmux)
148
+ )
149
+ return [] unless result.success?
150
+
151
+ result.stdout.split("\n").map { |line| parse_session_row(line) }.compact.sort_by { |entry| entry[:session] }
152
+ end
153
+
154
+ def list_windows(session: nil)
155
+ target = resolver.resolve_session(session: session)
156
+ list_window_rows(target.session_target)
157
+ end
158
+
159
+ def list_panes(session: nil, window: nil, all_panes: false)
160
+ if all_panes
161
+ target = resolver.resolve_session(session: session)
162
+ return window_identities_for_session(target.session_target).flat_map { |entry| pane_rows_for_window(entry[:id]) }
163
+ end
164
+
165
+ target = resolver.resolve_window(session: session, window: window)
166
+ pane_rows_for_window(target.window_target)
167
+ end
168
+
169
+ private
170
+
171
+ attr_reader :executor, :resolver, :process_inspector, :tmux
172
+
173
+ def condition_met?(condition, session:, window:, pane:, pattern:)
174
+ case condition
175
+ when "output"
176
+ capture_recent_output(session: session, window: window, pane: pane).include?(pattern)
177
+ when "window-exists"
178
+ target = resolver.resolve_window(session: session, window: window)
179
+ window_rows = list_window_rows(target.session_target)
180
+ window_target_exists?(target, window_rows)
181
+ when "window-active"
182
+ target = resolver.resolve_window(session: session, window: window)
183
+ window_active?(target, list_window_rows(target.session_target))
184
+ when "pane-exists"
185
+ pane_exists_for?(session: session, window: window, pane: pane)
186
+ when "pane-exited"
187
+ pane_exited?(session: session, window: window, pane: pane)
188
+ else
189
+ false
190
+ end
191
+ end
192
+
193
+ def wait_for_output_condition(session:, window:, pane:, pattern:, timeout_deadline:, interval:, lines:, baseline_output:)
194
+ baseline_count = pattern_match_count(baseline_output.to_s, pattern)
195
+
196
+ loop do
197
+ current_output = capture_recent_output(session: session, window: window, pane: pane, lines: lines)
198
+ return true if pattern_match_count(current_output, pattern) > baseline_count
199
+
200
+ raise_wait_timeout("output") if monotonic_now >= timeout_deadline
201
+
202
+ sleep(interval)
203
+ end
204
+ end
205
+
206
+ def window_identities_for_session(session)
207
+ result = executor.capture(
208
+ Atoms::TmuxCommandBuilder.list_windows(session, format: WINDOW_LIST_FORMAT, tmux: tmux)
209
+ )
210
+ return [] unless result.success?
211
+
212
+ result.stdout.split("\n").map { |line| parse_window_row(line) }.compact.sort_by { |entry| [entry[:index], entry[:name]] }
213
+ end
214
+
215
+ def list_window_rows(session)
216
+ window_identities_for_session(session)
217
+ end
218
+
219
+ def pane_exists?(pane_target)
220
+ result = executor.capture(
221
+ Atoms::TmuxCommandBuilder.display_message_target(pane_target, '#{pane_id}', tmux: tmux)
222
+ )
223
+ result.success? && !result.stdout.to_s.strip.empty?
224
+ end
225
+
226
+ def pane_exists_for?(session:, window:, pane:)
227
+ target = resolver.resolve_pane(session: session, window: window, pane: pane)
228
+ pane_exists?(target.pane_target)
229
+ rescue Ace::Tmux::TargetResolutionError
230
+ false
231
+ end
232
+
233
+ def pane_exited?(session:, window:, pane:)
234
+ target = resolver.resolve_pane(session: session, window: window, pane: pane)
235
+ result = executor.capture(
236
+ Atoms::TmuxCommandBuilder.display_message_target(target.pane_target, '#{pane_dead}', tmux: tmux)
237
+ )
238
+ !result.success? || result.stdout == "1"
239
+ rescue Ace::Tmux::TargetResolutionError
240
+ true
241
+ end
242
+
243
+ def window_target_exists?(target, rows)
244
+ if target.window_target&.start_with?("@")
245
+ rows.any? { |entry| entry[:id] == target.window_target }
246
+ else
247
+ rows.any? { |entry| entry[:name] == target.window }
248
+ end
249
+ end
250
+
251
+ def window_active?(target, rows)
252
+ if target.window_target&.start_with?("@")
253
+ rows.any? { |entry| entry[:id] == target.window_target && entry[:active] }
254
+ else
255
+ rows.any? { |entry| entry[:name] == target.window && entry[:active] }
256
+ end
257
+ end
258
+
259
+ def pane_rows_for_window(window_target)
260
+ result = executor.capture(
261
+ Atoms::TmuxCommandBuilder.list_panes(window_target, format: PANE_LIST_FORMAT, tmux: tmux)
262
+ )
263
+ return [] unless result.success?
264
+
265
+ result.stdout.split("\n").map { |line| parse_pane_row(line) }.compact.sort_by do |entry|
266
+ [entry[:window_index], entry[:pane_index]]
267
+ end
268
+ end
269
+
270
+ def parse_session_row(line)
271
+ session, attached, windows = line.to_s.split("\t", 3)
272
+ return nil if session.to_s.empty?
273
+
274
+ {
275
+ session: session,
276
+ attached_clients: integer_or_zero(attached),
277
+ window_count: integer_or_zero(windows)
278
+ }
279
+ end
280
+
281
+ def parse_window_row(line)
282
+ active, id, session, index, name, panes = line.to_s.split("\t", 6)
283
+ return nil if [id, session, index, name].any? { |value| value.to_s.empty? }
284
+
285
+ {
286
+ active: active == "1",
287
+ id: id,
288
+ session: session,
289
+ index: integer_or_zero(index),
290
+ name: name,
291
+ pane_count: integer_or_zero(panes)
292
+ }
293
+ end
294
+
295
+ def parse_pane_row(line)
296
+ active, pane_id, session, window_id, window_index, window_name, pane_index, command, path = line.to_s.split("\t", 9)
297
+ return nil if [pane_id, session, window_id, window_index, window_name, pane_index].any? { |value| value.to_s.empty? }
298
+
299
+ {
300
+ active: active == "1",
301
+ pane_id: pane_id,
302
+ session: session,
303
+ window_id: window_id,
304
+ window_index: integer_or_zero(window_index),
305
+ window_name: window_name,
306
+ pane_index: integer_or_zero(pane_index),
307
+ target: "#{session}:#{window_name}.#{pane_index}",
308
+ command: normalize_optional_text(command) || "-",
309
+ cwd: path_basename(path)
310
+ }
311
+ end
312
+
313
+ def integer_or_zero(value)
314
+ Integer(value.to_s.strip)
315
+ rescue ArgumentError
316
+ 0
317
+ end
318
+
319
+ def path_basename(path)
320
+ value = path.to_s.strip
321
+ return "-" if value.empty?
322
+
323
+ File.basename(value)
324
+ end
325
+
326
+ def pattern_match_count(output, pattern)
327
+ text = output.to_s
328
+ return 0 if text.empty?
329
+
330
+ Regexp.new(Regexp.escape(pattern.to_s)).match(text) ? text.scan(pattern.to_s).length : 0
331
+ end
332
+
333
+ def normalize_optional_text(value)
334
+ text = value.to_s
335
+ return nil if text.strip.empty?
336
+
337
+ text
338
+ end
339
+
340
+ def fetch_pane_profile(pane_target)
341
+ result = executor.capture(
342
+ Atoms::TmuxCommandBuilder.display_message_target(pane_target, PANE_PROFILE_FORMAT, tmux: tmux)
343
+ )
344
+ return default_pane_profile unless result.success?
345
+
346
+ parse_pane_profile(result.stdout)
347
+ rescue Errno::ENOENT
348
+ default_pane_profile
349
+ end
350
+
351
+ def parse_pane_profile(stdout)
352
+ command, height, alternate_on, pane_pid = stdout.to_s.split("\t", 4)
353
+ normalized_command = normalize_command(command)
354
+ normalized_height = Integer(height.to_s.strip)
355
+
356
+ build_pane_profile(
357
+ command: normalized_command,
358
+ height: normalized_height,
359
+ alternate_on: alternate_on.to_s.strip == "1",
360
+ pane_pid: pane_pid
361
+ )
362
+ rescue ArgumentError
363
+ default_pane_profile(command: command, pane_pid: pane_pid)
364
+ end
365
+
366
+ def default_pane_profile(command: nil, pane_pid: nil)
367
+ normalized_command = normalize_command(command)
368
+ interactive_command = resolve_interactive_cli_command(command: normalized_command, pane_pid: pane_pid)
369
+
370
+ {
371
+ command: interactive_command || normalized_command,
372
+ height: nil,
373
+ alternate_on: false,
374
+ pane_pid: normalize_pid(pane_pid),
375
+ interactive_cli: !interactive_command.nil?
376
+ }
377
+ end
378
+
379
+ def wait_for_agent_condition(session:, window:, pane:, timeout_deadline:, interval:, lines:, settle:, baseline_output:, require_change:)
380
+ target = resolver.resolve_pane(session: session, window: window, pane: pane)
381
+ pane_profile = fetch_pane_profile(target.pane_target)
382
+ unless pane_profile[:interactive_cli]
383
+ raise Ace::Tmux::ValidationError, "--for agent requires an interactive CLI pane such as codex, claude, or pi."
384
+ end
385
+
386
+ previous_output = baseline_output.nil? ? capture_output_for_target(target.pane_target, pane_profile: pane_profile, lines: lines) : baseline_output.to_s
387
+ baseline = previous_output
388
+ change_observed = !require_change
389
+ stable_since = nil
390
+
391
+ loop do
392
+ current_output = capture_output_for_target(target.pane_target, pane_profile: pane_profile, lines: lines)
393
+ busy = interactive_agent_busy?(current_output)
394
+
395
+ if current_output != previous_output
396
+ previous_output = current_output
397
+ change_observed ||= current_output != baseline
398
+ stable_since = nil
399
+ elsif change_observed && !busy
400
+ stable_since ||= monotonic_now
401
+ return true if monotonic_now - stable_since >= settle
402
+ else
403
+ stable_since = nil
404
+ end
405
+
406
+ raise_wait_timeout("agent") if monotonic_now >= timeout_deadline
407
+
408
+ sleep(interval)
409
+ end
410
+ end
411
+
412
+ def interactive_agent_busy?(output)
413
+ text = output.to_s
414
+ INTERACTIVE_BUSY_PATTERNS.any? { |pattern| text.match?(pattern) }
415
+ end
416
+
417
+ def build_pane_profile(command:, height:, alternate_on:, pane_pid:)
418
+ interactive_command = resolve_interactive_cli_command(command: command, pane_pid: pane_pid)
419
+
420
+ {
421
+ command: interactive_command || command,
422
+ height: height,
423
+ alternate_on: alternate_on,
424
+ pane_pid: normalize_pid(pane_pid),
425
+ interactive_cli: !interactive_command.nil?
426
+ }
427
+ end
428
+
429
+ def resolve_interactive_cli_command(command:, pane_pid:)
430
+ normalized_command = normalize_command(command)
431
+ return normalized_command if INTERACTIVE_CLI_COMMANDS.include?(normalized_command)
432
+
433
+ process_inspector.find_descendant_command(normalize_pid(pane_pid), allowed_commands: INTERACTIVE_CLI_COMMANDS)
434
+ end
435
+
436
+ def normalize_command(value)
437
+ value.to_s.strip.downcase
438
+ end
439
+
440
+ def normalize_pid(value)
441
+ candidate = value.to_s.strip
442
+ return nil if candidate.empty?
443
+
444
+ Integer(candidate).to_s
445
+ rescue ArgumentError
446
+ nil
447
+ end
448
+
449
+ def monotonic_now
450
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
451
+ end
452
+
453
+ def raise_wait_timeout(condition)
454
+ raise Ace::Tmux::WaitTimeoutError, "Timed out waiting for tmux condition '#{condition}'."
455
+ end
456
+
457
+ def pause_before_interactive_submit(pane_profile, key)
458
+ return unless pane_profile[:interactive_cli]
459
+ return unless submit_key?(key)
460
+
461
+ sleep(INTERACTIVE_SUBMIT_DELAY)
462
+ end
463
+
464
+ def submit_key?(key)
465
+ Atoms::NamedKeyRegistry.normalize(key) == "Enter"
466
+ rescue Ace::Tmux::ValidationError
467
+ false
468
+ end
469
+
470
+ def capture_command_for(pane_target, pane_profile:, lines:)
471
+ normalized_lines = [Integer(lines.to_i.abs), 1].max
472
+ return Atoms::TmuxCommandBuilder.capture_pane(pane_target, lines: normalized_lines, tmux: tmux) unless pane_profile[:interactive_cli]
473
+
474
+ pane_height = pane_profile[:height].to_i
475
+ return Atoms::TmuxCommandBuilder.capture_pane(pane_target, lines: normalized_lines, tmux: tmux) unless pane_height.positive?
476
+
477
+ visible_lines = [normalized_lines, pane_height].min
478
+ start_line = [pane_height - visible_lines, 0].max
479
+ end_line = pane_height - 1
480
+
481
+ Atoms::TmuxCommandBuilder.capture_pane_visible(
482
+ pane_target,
483
+ start_line: start_line,
484
+ end_line: end_line,
485
+ include_alternate: pane_profile[:alternate_on],
486
+ tmux: tmux
487
+ )
488
+ end
489
+
490
+ def capture_output_for_target(pane_target, pane_profile:, lines:)
491
+ result = executor.capture(
492
+ capture_command_for(pane_target, pane_profile: pane_profile, lines: lines)
493
+ )
494
+ raise Ace::Tmux::Error, "Failed to capture pane #{pane_target}: #{result.stderr}" unless result.success?
495
+
496
+ result.stdout
497
+ end
498
+
499
+ def send_text_to_target(target, text)
500
+ run!(
501
+ Atoms::TmuxCommandBuilder.send_raw_keys(target, text, tmux: tmux),
502
+ "send text to #{target}"
503
+ )
504
+ end
505
+
506
+ def send_named_key_to_target(target, key)
507
+ normalized_key = Atoms::NamedKeyRegistry.normalize(key)
508
+ run!(
509
+ Atoms::TmuxCommandBuilder.send_raw_keys(target, normalized_key, tmux: tmux),
510
+ "send key to #{target}"
511
+ )
512
+ end
513
+
514
+ def run!(cmd, action)
515
+ result = executor.run(cmd)
516
+ return true if result
517
+
518
+ raise Ace::Tmux::Error, "Failed to #{action}"
519
+ end
520
+ end
521
+ end
522
+ end
523
+ end
@@ -23,6 +23,7 @@ module Ace
23
23
  @executor = executor
24
24
  @session_builder = session_builder
25
25
  @tmux = tmux
26
+ @window_targets = {}
26
27
  end
27
28
 
28
29
  # Start a session from a preset
@@ -37,6 +38,7 @@ module Ace
37
38
  session = @session_builder.build(preset_name)
38
39
  session.name = name if name
39
40
  session.root = root if root
41
+ @window_targets = {}
40
42
 
41
43
  if session_exists?(session.name)
42
44
  if force
@@ -61,7 +63,7 @@ module Ace
61
63
  # Mirrors WindowManager#resolve_window_name: root basename wins over preset name.
62
64
  def resolve_first_window_name(preset_window_name, root_override, session_root)
63
65
  effective_root = root_override || session_root || Dir.pwd
64
- File.basename(effective_root)
66
+ Atoms::WindowNameSanitizer.call(File.basename(effective_root), fallback: preset_window_name || "window")
65
67
  end
66
68
 
67
69
  def session_exists?(name)
@@ -88,6 +90,8 @@ module Ace
88
90
  )
89
91
  result = @executor.capture(cmd)
90
92
  window_target = result.stdout.strip
93
+ record_window_target(first_window_name, window_target)
94
+ record_window_target(first_window&.name, window_target) if first_window
91
95
 
92
96
  # Set up panes for the first window (it was created with the session)
93
97
  setup_panes(session, first_window, window_target) if first_window
@@ -98,15 +102,18 @@ module Ace
98
102
  # Skip first window (already created with session)
99
103
  session.windows.drop(1).each do |window|
100
104
  window_root = window.root || session.root
105
+ window_name = Atoms::WindowNameSanitizer.call(window.name)
101
106
  cmd = Atoms::TmuxCommandBuilder.new_window(
102
107
  session.name,
103
- name: window.name,
108
+ name: window_name,
104
109
  root: window_root,
105
110
  print_format: '#{window_id}',
106
111
  tmux: @tmux
107
112
  )
108
113
  result = @executor.capture(cmd)
109
114
  window_target = result.stdout.strip
115
+ record_window_target(window_name, window_target)
116
+ record_window_target(window.name, window_target)
110
117
  setup_panes(session, window, window_target)
111
118
  end
112
119
  end
@@ -263,7 +270,8 @@ module Ace
263
270
 
264
271
  def select_startup_window(session, first_window_target: nil)
265
272
  target = if session.startup_window
266
- "#{session.name}:#{session.startup_window}"
273
+ startup_window = Atoms::WindowNameSanitizer.call(session.startup_window)
274
+ @window_targets[startup_window] || @window_targets[session.startup_window] || "#{session.name}:#{startup_window}"
267
275
  else
268
276
  first_window_target || "#{session.name}:#{session.windows.first&.name || 0}"
269
277
  end
@@ -272,6 +280,12 @@ module Ace
272
280
  @executor.run(cmd)
273
281
  end
274
282
 
283
+ def record_window_target(name, target)
284
+ return if name.to_s.empty? || target.to_s.empty?
285
+
286
+ @window_targets[name] = target
287
+ end
288
+
275
289
  def attach_session(session)
276
290
  cmd = Atoms::TmuxCommandBuilder.attach_session(session.name, tmux: @tmux)
277
291
  @executor.exec(cmd)
@@ -59,10 +59,15 @@ module Ace
59
59
  private
60
60
 
61
61
  def resolve_window_name(explicit_name, root, preset_name)
62
- return explicit_name if explicit_name
63
- return File.basename(root) if root
62
+ raw_name = if explicit_name
63
+ explicit_name
64
+ elsif root
65
+ File.basename(root)
66
+ else
67
+ preset_name
68
+ end
64
69
 
65
- preset_name
70
+ Atoms::WindowNameSanitizer.call(raw_name)
66
71
  end
67
72
 
68
73
  def detect_current_session
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Tmux
5
- VERSION = '0.14.3'
5
+ VERSION = '0.17.2'
6
6
  end
7
7
  end
data/lib/ace/tmux.rb CHANGED
@@ -13,6 +13,10 @@ module Ace
13
13
 
14
14
  # Raised when not running inside tmux
15
15
  class NotInTmuxError < Error; end
16
+
17
+ class TargetResolutionError < Error; end
18
+ class WaitTimeoutError < Error; end
19
+ class ValidationError < Error; end
16
20
  end
17
21
  end
18
22
 
@@ -21,13 +25,20 @@ require_relative "tmux/models/pane"
21
25
  require_relative "tmux/models/layout_node"
22
26
  require_relative "tmux/models/window"
23
27
  require_relative "tmux/models/session"
28
+ require_relative "tmux/models/runtime_target"
24
29
  require_relative "tmux/atoms/tmux_command_builder"
30
+ require_relative "tmux/atoms/named_key_registry"
25
31
  require_relative "tmux/atoms/layout_string_builder"
26
32
  require_relative "tmux/atoms/preset_resolver"
33
+ require_relative "tmux/atoms/window_name_sanitizer"
27
34
  require_relative "tmux/molecules/config_loader"
35
+ require_relative "tmux/molecules/local_process_inspector"
28
36
  require_relative "tmux/molecules/preset_loader"
29
37
  require_relative "tmux/molecules/tmux_executor"
38
+ require_relative "tmux/molecules/runtime_target_resolver"
30
39
  require_relative "tmux/molecules/session_builder"
40
+ require_relative "tmux/molecules/wait_condition_validator"
41
+ require_relative "tmux/organisms/control_surface"
31
42
  require_relative "tmux/organisms/session_manager"
32
43
  require_relative "tmux/organisms/window_manager"
33
44
  require_relative "tmux/cli"