ace-tmux 0.11.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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/tmux/config.yml +11 -0
  3. data/.ace-defaults/tmux/panes/claude.yml +4 -0
  4. data/.ace-defaults/tmux/panes/nvim.yml +4 -0
  5. data/.ace-defaults/tmux/panes/work-on-task-cc.yml +4 -0
  6. data/.ace-defaults/tmux/panes/work-on-task-status.yml +4 -0
  7. data/.ace-defaults/tmux/sessions/default.yml +5 -0
  8. data/.ace-defaults/tmux/windows/cc-ipad.yml +10 -0
  9. data/.ace-defaults/tmux/windows/cc.yml +14 -0
  10. data/.ace-defaults/tmux/windows/work-on-task.yml +16 -0
  11. data/CHANGELOG.md +240 -0
  12. data/LICENSE +21 -0
  13. data/README.md +40 -0
  14. data/Rakefile +12 -0
  15. data/docs/demo/ace-tmux-getting-started.gif +0 -0
  16. data/docs/demo/ace-tmux-getting-started.tape.yml +30 -0
  17. data/docs/getting-started.md +86 -0
  18. data/docs/handbook.md +31 -0
  19. data/docs/usage.md +197 -0
  20. data/exe/ace-tmux +17 -0
  21. data/lib/ace/tmux/atoms/layout_string_builder.rb +172 -0
  22. data/lib/ace/tmux/atoms/preset_resolver.rb +127 -0
  23. data/lib/ace/tmux/atoms/tmux_command_builder.rb +203 -0
  24. data/lib/ace/tmux/cli/commands/list.rb +85 -0
  25. data/lib/ace/tmux/cli/commands/start.rb +82 -0
  26. data/lib/ace/tmux/cli/commands/window.rb +75 -0
  27. data/lib/ace/tmux/cli.rb +64 -0
  28. data/lib/ace/tmux/models/layout_node.rb +53 -0
  29. data/lib/ace/tmux/models/pane.rb +37 -0
  30. data/lib/ace/tmux/models/session.rb +64 -0
  31. data/lib/ace/tmux/models/window.rb +50 -0
  32. data/lib/ace/tmux/molecules/config_loader.rb +46 -0
  33. data/lib/ace/tmux/molecules/preset_loader.rb +73 -0
  34. data/lib/ace/tmux/molecules/session_builder.rb +151 -0
  35. data/lib/ace/tmux/molecules/tmux_executor.rb +74 -0
  36. data/lib/ace/tmux/organisms/session_manager.rb +295 -0
  37. data/lib/ace/tmux/organisms/window_manager.rb +221 -0
  38. data/lib/ace/tmux/version.rb +7 -0
  39. data/lib/ace/tmux.rb +101 -0
  40. metadata +225 -0
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Tmux
5
+ module Models
6
+ # Represents a tmux window configuration
7
+ class Window
8
+ attr_reader :name, :layout, :root, :panes, :pre_window, :focus, :options, :layout_tree
9
+
10
+ # @param name [String, nil] Window name
11
+ # @param layout [String, nil] tmux layout (e.g., "main-vertical", "tiled")
12
+ # @param root [String, nil] Working directory for the window
13
+ # @param panes [Array<Pane>] Pane configurations
14
+ # @param pre_window [String, nil] Command to run before each pane
15
+ # @param focus [Boolean] Whether this window should be focused
16
+ # @param options [Hash] Raw tmux window options (passed to set-window-option)
17
+ # @param layout_tree [Models::LayoutNode, nil] Nested layout tree (nil for flat layouts)
18
+ def initialize(name: nil, layout: nil, root: nil, panes: [], pre_window: nil, focus: false, options: {}, layout_tree: nil)
19
+ @name = name
20
+ @layout = layout
21
+ @root = root
22
+ @panes = panes
23
+ @pre_window = pre_window
24
+ @focus = focus
25
+ @options = options || {}
26
+ @layout_tree = layout_tree
27
+ end
28
+
29
+ # @return [Boolean] true if this window uses a nested layout tree
30
+ def nested_layout?
31
+ !@layout_tree.nil?
32
+ end
33
+
34
+ def focus?
35
+ @focus == true
36
+ end
37
+
38
+ def to_h
39
+ hash = {"name" => @name, "panes" => @panes.map(&:to_h)}
40
+ hash["layout"] = @layout if @layout
41
+ hash["root"] = @root if @root
42
+ hash["pre_window"] = @pre_window if @pre_window
43
+ hash["focus"] = @focus if @focus
44
+ hash["options"] = @options unless @options.empty?
45
+ hash
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/config"
4
+
5
+ module Ace
6
+ module Tmux
7
+ module Molecules
8
+ # Loads general tmux configuration via the ACE config cascade
9
+ module ConfigLoader
10
+ module_function
11
+
12
+ # Load tmux config from cascade
13
+ #
14
+ # @param gem_root [String] Gem root directory
15
+ # @return [Hash] Merged configuration hash
16
+ def load(gem_root:)
17
+ resolver = Ace::Support::Config.create(
18
+ config_dir: ".ace",
19
+ defaults_dir: ".ace-defaults",
20
+ gem_path: gem_root
21
+ )
22
+
23
+ config = resolver.resolve_namespace("tmux")
24
+ config.data
25
+ rescue => e
26
+ warn "ace-tmux: Could not load config: #{e.class} - #{e.message}" if Tmux.debug?
27
+ load_fallback(gem_root)
28
+ end
29
+
30
+ # Load gem defaults directly as fallback
31
+ #
32
+ # @param gem_root [String] Gem root directory
33
+ # @return [Hash] Defaults hash or empty hash
34
+ def load_fallback(gem_root)
35
+ defaults_path = File.join(gem_root, ".ace-defaults", "tmux", "config.yml")
36
+ return {} unless File.exist?(defaults_path)
37
+
38
+ require "yaml"
39
+ YAML.safe_load_file(defaults_path, permitted_classes: [Date], aliases: true) || {}
40
+ rescue
41
+ {}
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "ace/support/config"
5
+
6
+ module Ace
7
+ module Tmux
8
+ module Molecules
9
+ # Finds and loads YAML presets across the ACE config cascade
10
+ #
11
+ # Uses VirtualConfigResolver to discover presets from:
12
+ # 1. Project .ace/tmux/ (highest priority)
13
+ # 2. User ~/.ace/tmux/
14
+ # 3. Gem .ace-defaults/tmux/ (lowest priority)
15
+ class PresetLoader
16
+ PRESET_TYPES = %w[sessions windows panes].freeze
17
+
18
+ # @param gem_root [String] Gem root directory for defaults
19
+ # @param start_path [String, nil] Starting path for cascade traversal
20
+ def initialize(gem_root:, start_path: nil)
21
+ @resolver = Ace::Support::Config.virtual_resolver(
22
+ config_dir: ".ace",
23
+ defaults_dir: ".ace-defaults",
24
+ start_path: start_path,
25
+ gem_path: gem_root
26
+ )
27
+ end
28
+
29
+ # Load a preset by type and name
30
+ #
31
+ # @param type [String] Preset type: "sessions", "windows", or "panes"
32
+ # @param name [String] Preset name (without .yml extension)
33
+ # @return [Hash, nil] Parsed YAML hash, or nil if not found
34
+ def load(type, name)
35
+ relative_path = "tmux/#{type}/#{name}.yml"
36
+ absolute_path = @resolver.resolve_path(relative_path)
37
+ return nil unless absolute_path && File.exist?(absolute_path)
38
+
39
+ YAML.safe_load_file(absolute_path, permitted_classes: [Date], aliases: true) || {}
40
+ end
41
+
42
+ # List available presets for a type
43
+ #
44
+ # @param type [String] Preset type: "sessions", "windows", or "panes"
45
+ # @return [Array<String>] Preset names (without .yml extension)
46
+ def list(type)
47
+ pattern = "tmux/#{type}/*.yml"
48
+ @resolver.glob(pattern).keys.map do |relative_path|
49
+ File.basename(relative_path, ".yml")
50
+ end.sort.uniq
51
+ end
52
+
53
+ # List all preset types and their presets
54
+ #
55
+ # @return [Hash<String, Array<String>>] Map of type => preset names
56
+ def list_all
57
+ PRESET_TYPES.each_with_object({}) do |type, result|
58
+ presets = list(type)
59
+ result[type] = presets unless presets.empty?
60
+ end
61
+ end
62
+
63
+ # Create a lookup proc for PresetResolver
64
+ #
65
+ # @param type [String] Preset type to look up
66
+ # @return [Proc] Proc that takes a name and returns a preset hash
67
+ def to_lookup(type)
68
+ ->(name) { load(type, name) }
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Tmux
5
+ module Molecules
6
+ # Combines PresetLoader + PresetResolver to build fully resolved models
7
+ #
8
+ # Loads a session preset, resolves all nested window/pane presets,
9
+ # and constructs the model hierarchy (Session → Window → Pane).
10
+ class SessionBuilder
11
+ # @param preset_loader [PresetLoader] Loader for finding presets
12
+ def initialize(preset_loader:)
13
+ @preset_loader = preset_loader
14
+ end
15
+
16
+ # Build a fully resolved Session model from a preset name
17
+ #
18
+ # @param preset_name [String] Session preset name
19
+ # @return [Models::Session] Fully resolved session model
20
+ # @raise [PresetNotFoundError] If session preset doesn't exist
21
+ def build(preset_name)
22
+ raw = @preset_loader.load("sessions", preset_name)
23
+ raise PresetNotFoundError, "Session preset not found: #{preset_name}" unless raw
24
+
25
+ # Resolve all preset references
26
+ resolved = Atoms::PresetResolver.resolve_session(
27
+ raw,
28
+ window_lookup: @preset_loader.to_lookup("windows"),
29
+ pane_lookup: @preset_loader.to_lookup("panes")
30
+ )
31
+
32
+ build_session_model(resolved)
33
+ end
34
+
35
+ # Build a fully resolved Window model from a preset name
36
+ #
37
+ # @param preset_name [String] Window preset name
38
+ # @return [Models::Window] Fully resolved window model
39
+ # @raise [PresetNotFoundError] If window preset doesn't exist
40
+ def build_window(preset_name)
41
+ raw = @preset_loader.load("windows", preset_name)
42
+ raise PresetNotFoundError, "Window preset not found: #{preset_name}" unless raw
43
+
44
+ resolved = Atoms::PresetResolver.resolve_window(
45
+ raw,
46
+ pane_lookup: @preset_loader.to_lookup("panes")
47
+ )
48
+
49
+ build_window_model(resolved)
50
+ end
51
+
52
+ private
53
+
54
+ def build_session_model(hash)
55
+ windows = (hash["windows"] || []).map { |w| build_window_model(w) }
56
+
57
+ Models::Session.new(
58
+ name: hash["name"],
59
+ root: hash["root"],
60
+ windows: windows,
61
+ pre_window: hash["pre_window"],
62
+ startup_window: hash["startup_window"],
63
+ on_project_start: hash["on_project_start"],
64
+ on_project_exit: hash["on_project_exit"],
65
+ attach: hash.fetch("attach", true),
66
+ tmux_options: hash["tmux_options"]
67
+ )
68
+ end
69
+
70
+ def build_window_model(hash)
71
+ if nested_layout?(hash)
72
+ build_nested_window_model(hash)
73
+ else
74
+ build_flat_window_model(hash)
75
+ end
76
+ end
77
+
78
+ def build_flat_window_model(hash)
79
+ panes = (hash["panes"] || []).map { |p| build_pane_model(p) }
80
+
81
+ Models::Window.new(
82
+ name: hash["name"],
83
+ layout: hash["layout"],
84
+ root: hash["root"],
85
+ panes: panes,
86
+ pre_window: hash["pre_window"],
87
+ focus: hash["focus"] || false,
88
+ options: hash["options"] || {}
89
+ )
90
+ end
91
+
92
+ def build_nested_window_model(hash)
93
+ direction = parse_direction(hash["direction"] || "horizontal")
94
+ tree = build_layout_tree(hash, direction: direction)
95
+ panes = tree.leaves.map(&:pane)
96
+
97
+ Models::Window.new(
98
+ name: hash["name"],
99
+ root: hash["root"],
100
+ panes: panes,
101
+ pre_window: hash["pre_window"],
102
+ focus: hash["focus"] || false,
103
+ options: hash["options"] || {},
104
+ layout_tree: tree
105
+ )
106
+ end
107
+
108
+ def build_layout_tree(hash, direction:)
109
+ children = (hash["panes"] || []).map do |entry|
110
+ if entry.is_a?(Hash) && entry.key?("direction")
111
+ child_dir = parse_direction(entry["direction"])
112
+ child_tree = build_layout_tree(entry, direction: child_dir)
113
+ child_tree
114
+ else
115
+ pane = build_pane_model(entry)
116
+ Models::LayoutNode.new(pane: pane, size: entry.is_a?(Hash) ? entry["size"] : nil)
117
+ end
118
+ end
119
+
120
+ size = hash.is_a?(Hash) ? hash["size"] : nil
121
+ Models::LayoutNode.new(direction: direction, children: children, size: size)
122
+ end
123
+
124
+ def nested_layout?(hash)
125
+ return true if hash.is_a?(Hash) && hash.key?("direction")
126
+
127
+ panes = hash["panes"]
128
+ return false unless panes.is_a?(Array)
129
+
130
+ panes.any? { |p| p.is_a?(Hash) && p.key?("direction") }
131
+ end
132
+
133
+ def parse_direction(str)
134
+ (str.to_s == "vertical") ? :vertical : :horizontal
135
+ end
136
+
137
+ def build_pane_model(hash)
138
+ hash = Atoms::PresetResolver.normalize_pane(hash)
139
+
140
+ Models::Pane.new(
141
+ commands: hash["commands"] || [],
142
+ focus: hash["focus"] || false,
143
+ root: hash["root"],
144
+ name: hash["name"],
145
+ options: hash["options"] || {}
146
+ )
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Ace
6
+ module Tmux
7
+ module Molecules
8
+ # Executes tmux commands via different execution strategies
9
+ #
10
+ # Provides three modes:
11
+ # - capture: Run and capture stdout/stderr (for queries)
12
+ # - run: Run via system() (for mutations)
13
+ # - exec: Replace process via Kernel.exec (for attach)
14
+ class TmuxExecutor
15
+ # Run a command and capture output
16
+ #
17
+ # @param cmd [Array<String>] Command array
18
+ # @return [ExecutionResult] Result with stdout, stderr, success?
19
+ def capture(cmd)
20
+ stdout, stderr, status = Open3.capture3(*cmd)
21
+ ExecutionResult.new(
22
+ stdout: stdout.strip,
23
+ stderr: stderr.strip,
24
+ success: status.success?,
25
+ exit_code: status.exitstatus
26
+ )
27
+ end
28
+
29
+ # Run a command via system (fire and forget with status)
30
+ #
31
+ # @param cmd [Array<String>] Command array
32
+ # @return [Boolean] true if command succeeded
33
+ def run(cmd)
34
+ system(*cmd)
35
+ end
36
+
37
+ # Replace current process with command (for tmux attach)
38
+ #
39
+ # @param cmd [Array<String>] Command array
40
+ # @return [void] Never returns (replaces process)
41
+ def exec(cmd)
42
+ Kernel.exec(*cmd)
43
+ end
44
+
45
+ # Check if tmux is available
46
+ #
47
+ # @param tmux [String] tmux binary path
48
+ # @return [Boolean]
49
+ def tmux_available?(tmux: "tmux")
50
+ result = capture([tmux, "-V"])
51
+ result.success?
52
+ rescue Errno::ENOENT
53
+ false
54
+ end
55
+ end
56
+
57
+ # Immutable result of a tmux command execution
58
+ class ExecutionResult
59
+ attr_reader :stdout, :stderr, :exit_code
60
+
61
+ def initialize(stdout:, stderr:, success:, exit_code:)
62
+ @stdout = stdout
63
+ @stderr = stderr
64
+ @success = success
65
+ @exit_code = exit_code
66
+ end
67
+
68
+ def success?
69
+ @success
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,295 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Tmux
5
+ module Organisms
6
+ # Orchestrates session creation from a preset
7
+ #
8
+ # Flow:
9
+ # 1. Check if session already exists
10
+ # 2. Run on_project_start hooks
11
+ # 3. Create session (first window implicitly created)
12
+ # 4. Set up additional windows and panes
13
+ # 5. Set layouts and focus
14
+ # 6. Select startup window
15
+ # 7. Attach (unless --detach)
16
+ class SessionManager
17
+ POLLUTING_ENV_VARS = %w[BUNDLE_GEMFILE BUNDLE_BIN_PATH RUBYOPT RUBYLIB].freeze
18
+
19
+ # @param executor [Molecules::TmuxExecutor] Command executor
20
+ # @param session_builder [Molecules::SessionBuilder] Preset resolver/builder
21
+ # @param tmux [String] tmux binary path
22
+ def initialize(executor:, session_builder:, tmux: "tmux")
23
+ @executor = executor
24
+ @session_builder = session_builder
25
+ @tmux = tmux
26
+ end
27
+
28
+ # Start a session from a preset
29
+ #
30
+ # @param preset_name [String] Session preset name
31
+ # @param detach [Boolean] Skip attach after creation
32
+ # @param force [Boolean] Kill existing session and recreate
33
+ # @param root [String, nil] Override working directory for the session
34
+ # @return [void]
35
+ def start(preset_name, detach: false, force: false, root: nil)
36
+ session = @session_builder.build(preset_name)
37
+ session.root = root if root
38
+
39
+ if session_exists?(session.name)
40
+ if force
41
+ kill_session(session.name)
42
+ else
43
+ attach_session(session) unless detach
44
+ return
45
+ end
46
+ end
47
+
48
+ run_hooks(session.on_project_start)
49
+ first_window_target = create_session(session, root_override: root)
50
+ clean_environment(session)
51
+ setup_windows(session)
52
+ select_startup_window(session, first_window_target: first_window_target)
53
+ attach_session(session) unless detach
54
+ end
55
+
56
+ private
57
+
58
+ # Derive the first window name from the effective working directory.
59
+ # Mirrors WindowManager#resolve_window_name: root basename wins over preset name.
60
+ def resolve_first_window_name(preset_window_name, root_override, session_root)
61
+ effective_root = root_override || session_root || Dir.pwd
62
+ File.basename(effective_root)
63
+ end
64
+
65
+ def session_exists?(name)
66
+ cmd = Atoms::TmuxCommandBuilder.has_session(name, tmux: @tmux)
67
+ result = @executor.capture(cmd)
68
+ result.success?
69
+ end
70
+
71
+ def kill_session(name)
72
+ cmd = Atoms::TmuxCommandBuilder.kill_session(name, tmux: @tmux)
73
+ @executor.run(cmd)
74
+ end
75
+
76
+ def create_session(session, root_override: nil)
77
+ first_window = session.windows.first
78
+ first_window_name = resolve_first_window_name(first_window&.name, root_override, session.root)
79
+ cmd = Atoms::TmuxCommandBuilder.new_session(
80
+ session.name,
81
+ root: session.root,
82
+ window_name: first_window_name,
83
+ tmux_options: session.tmux_options,
84
+ print_format: '#{window_id}',
85
+ tmux: @tmux
86
+ )
87
+ result = @executor.capture(cmd)
88
+ window_target = result.stdout.strip
89
+
90
+ # Set up panes for the first window (it was created with the session)
91
+ setup_panes(session, first_window, window_target) if first_window
92
+ window_target
93
+ end
94
+
95
+ def setup_windows(session)
96
+ # Skip first window (already created with session)
97
+ session.windows.drop(1).each do |window|
98
+ window_root = window.root || session.root
99
+ cmd = Atoms::TmuxCommandBuilder.new_window(
100
+ session.name,
101
+ name: window.name,
102
+ root: window_root,
103
+ print_format: '#{window_id}',
104
+ tmux: @tmux
105
+ )
106
+ result = @executor.capture(cmd)
107
+ window_target = result.stdout.strip
108
+ setup_panes(session, window, window_target)
109
+ end
110
+ end
111
+
112
+ def setup_panes(session, window, window_target)
113
+ return unless window.panes.any?
114
+
115
+ if window.nested_layout?
116
+ setup_nested_panes(session, window, window_target)
117
+ else
118
+ setup_flat_panes(session, window, window_target)
119
+ end
120
+ end
121
+
122
+ def setup_flat_panes(session, window, window_target)
123
+ # Create additional panes via split (first pane already exists)
124
+ window.panes.drop(1).each do |pane|
125
+ pane_root = pane.root || window.root || session.root
126
+ cmd = Atoms::TmuxCommandBuilder.split_window(
127
+ window_target,
128
+ root: pane_root,
129
+ tmux: @tmux
130
+ )
131
+ @executor.run(cmd)
132
+ end
133
+
134
+ # Apply window options before layout (e.g., main-pane-width)
135
+ apply_window_options(window, window_target)
136
+
137
+ # Apply layout after all panes are created
138
+ if window.layout
139
+ cmd = Atoms::TmuxCommandBuilder.select_layout(window_target, window.layout, tmux: @tmux)
140
+ @executor.run(cmd)
141
+ end
142
+
143
+ # Send commands to all panes (after layout is stable)
144
+ window.panes.each_with_index do |pane, idx|
145
+ send_pane_commands(session, window, pane, "#{window_target}.#{idx}")
146
+ end
147
+
148
+ # Focus the appropriate pane
149
+ focus_pane = window.panes.index(&:focus?)
150
+ if focus_pane
151
+ cmd = Atoms::TmuxCommandBuilder.select_pane("#{window_target}.#{focus_pane}", tmux: @tmux)
152
+ @executor.run(cmd)
153
+ end
154
+ end
155
+
156
+ def setup_nested_panes(session, window, window_target)
157
+ tree = window.layout_tree
158
+ leaves = tree.leaves
159
+ leaves.length
160
+
161
+ # Create leaf_count - 1 additional panes via flat splits
162
+ # Use per-leaf root when available, falling back to window/session root
163
+ leaves.drop(1).each do |leaf|
164
+ pane_root = leaf.pane.root || window.root || session.root
165
+ cmd = Atoms::TmuxCommandBuilder.split_window(
166
+ window_target,
167
+ root: pane_root,
168
+ tmux: @tmux
169
+ )
170
+ @executor.run(cmd)
171
+ end
172
+
173
+ # Get pane IDs
174
+ pane_ids = query_pane_ids(window_target)
175
+
176
+ # Get window dimensions
177
+ width, height = query_window_dimensions(window_target, pane_ids.first || 0)
178
+
179
+ # Build and apply custom layout string
180
+ layout_string = Atoms::LayoutStringBuilder.build(
181
+ tree, width: width, height: height, pane_ids: pane_ids
182
+ )
183
+ cmd = Atoms::TmuxCommandBuilder.select_layout(window_target, layout_string, tmux: @tmux)
184
+ @executor.run(cmd)
185
+
186
+ # Apply window options
187
+ apply_window_options(window, window_target)
188
+
189
+ # Send commands to each leaf pane
190
+ # First leaf was created with window/session root; cd if it has its own root
191
+ first_leaf = leaves.first
192
+ leaves.each do |leaf|
193
+ pane_target = "#{window_target}.#{leaf.pane_id}"
194
+ if leaf == first_leaf && leaf.pane.root
195
+ cd_cmd = Atoms::TmuxCommandBuilder.send_keys(pane_target, "cd #{leaf.pane.root}", tmux: @tmux)
196
+ @executor.run(cd_cmd)
197
+ end
198
+ send_pane_commands(session, window, leaf.pane, pane_target)
199
+ end
200
+
201
+ # Focus the appropriate leaf pane
202
+ focus_leaf = leaves.find { |l| l.pane.focus? }
203
+ if focus_leaf
204
+ cmd = Atoms::TmuxCommandBuilder.select_pane("#{window_target}.#{focus_leaf.pane_id}", tmux: @tmux)
205
+ @executor.run(cmd)
206
+ end
207
+ end
208
+
209
+ def query_pane_ids(window_target)
210
+ cmd = Atoms::TmuxCommandBuilder.list_panes(window_target, format: '#{pane_index}', tmux: @tmux)
211
+ result = @executor.capture(cmd)
212
+ return [] unless result.success?
213
+
214
+ result.stdout.split("\n").map(&:to_i)
215
+ end
216
+
217
+ def query_window_dimensions(window_target, pane_id)
218
+ cmd = Atoms::TmuxCommandBuilder.display_message_target(
219
+ "#{window_target}.#{pane_id}",
220
+ '#{window_width}x#{window_height}',
221
+ tmux: @tmux
222
+ )
223
+ result = @executor.capture(cmd)
224
+ return [200, 50] unless result.success?
225
+
226
+ parts = result.stdout.strip.split("x")
227
+ [parts[0].to_i, parts[1].to_i]
228
+ end
229
+
230
+ def send_pane_commands(session, window, pane, pane_target)
231
+ # Send pre_window command if set (session-level, then window-level)
232
+ pre_window = window.pre_window || session.pre_window
233
+ if pre_window
234
+ cmd = Atoms::TmuxCommandBuilder.send_keys(pane_target, pre_window, tmux: @tmux)
235
+ @executor.run(cmd)
236
+ end
237
+
238
+ # Apply pane options
239
+ apply_pane_options(pane, pane_target)
240
+
241
+ # Send pane commands
242
+ pane.commands.each do |command|
243
+ cmd = Atoms::TmuxCommandBuilder.send_keys(pane_target, command, tmux: @tmux)
244
+ @executor.run(cmd)
245
+ end
246
+ end
247
+
248
+ def apply_window_options(window, target)
249
+ window.options.each do |option, value|
250
+ cmd = Atoms::TmuxCommandBuilder.set_window_option(target, option, value, tmux: @tmux)
251
+ @executor.run(cmd)
252
+ end
253
+ end
254
+
255
+ def apply_pane_options(pane, target)
256
+ pane.options.each do |option, value|
257
+ cmd = Atoms::TmuxCommandBuilder.set_pane_option(target, option, value, tmux: @tmux)
258
+ @executor.run(cmd)
259
+ end
260
+ end
261
+
262
+ def select_startup_window(session, first_window_target: nil)
263
+ target = if session.startup_window
264
+ "#{session.name}:#{session.startup_window}"
265
+ else
266
+ first_window_target || "#{session.name}:#{session.windows.first&.name || 0}"
267
+ end
268
+
269
+ cmd = Atoms::TmuxCommandBuilder.select_window(target, tmux: @tmux)
270
+ @executor.run(cmd)
271
+ end
272
+
273
+ def attach_session(session)
274
+ cmd = Atoms::TmuxCommandBuilder.attach_session(session.name, tmux: @tmux)
275
+ @executor.exec(cmd)
276
+ end
277
+
278
+ def clean_environment(session)
279
+ POLLUTING_ENV_VARS.each do |var|
280
+ cmd = Atoms::TmuxCommandBuilder.set_environment(session.name, var, unset: true, tmux: @tmux)
281
+ @executor.run(cmd)
282
+ end
283
+ end
284
+
285
+ def run_hooks(commands)
286
+ return if commands.nil? || commands.empty?
287
+
288
+ commands.each do |command|
289
+ system(command)
290
+ end
291
+ end
292
+ end
293
+ end
294
+ end
295
+ end