ace-demo 0.25.1 → 0.25.6

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ae9f0aced46022fd683e573a13e899c0e0e5d119e1d50cd65903f0e1188983ec
4
- data.tar.gz: 769e24a54a5c0162e30fa691ad378161c917f3774f87e1ed066f3a39a68a58ca
3
+ metadata.gz: 3dc9853e3f70693cc0dc687ab61f645ed432d0afae57a7dd2d7e87c746da9236
4
+ data.tar.gz: 4f85e3e89c5efefdbab60d3776e4a2c6b92feb2067a1bdbdc7954723caada2c4
5
5
  SHA512:
6
- metadata.gz: a1d750f6d1c26789d9ed615352536aece06fd42dbf2b9b4619aa8db5bb6d889cbb629034f2217ba81c4630c59b77360bc1a76cf6adb638a5e6a8e828fb0bc606
7
- data.tar.gz: '0368a6c97f32dcccd9a49e429e65b8b90ba45f0590d6dbc9bc3acd6ff9560ed04f4d7c630d2ea3dedb83de0356bf718f3aef0b9090baa157326a446ee9ce509f'
6
+ metadata.gz: fff3491b92a232bbbfe39aaf01ddf6f0e3924ff0d9644609572248d4338b0ecceadbcbd5a19f0854398e43c482864d2336eea6a9aaceb97e4ba9a9dda6a654f2
7
+ data.tar.gz: dada165a45b5b184b95f5a49cf30775a8e4290530f13e4ea75337aacfcf2cb4cb4267da621a3311ac2620b82c6d8c71c22a680acc3cb89e0fdc78cb8f9ff342c
data/CHANGELOG.md CHANGED
@@ -6,6 +6,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
  ## [Unreleased]
9
+ ### Fixed
10
+ - Failed YAML tape verification by default when the final asciinema shell exit is non-zero, unless `verify.allow_nonzero_exit: true` opts out explicitly.
11
+ - Surfaced non-zero cast exit codes in `record` / `verify` output and verification reports so broken recordings are diagnosable without opening the raw cast.
12
+
13
+ ## [0.25.6] - 2026-04-16
14
+
15
+ ### Added
16
+ - Added additive YAML `tmux:` recorder-control directives for asciinema-backed demo tapes, covering shared-surface `attach`, `detach`, `wait`, `send`, and optional `capture` actions alongside existing visible `type:` commands.
17
+
18
+ ### Changed
19
+ - Updated `ace-demo` recording/docs to treat tmux orchestration as structured recorder control instead of canonical raw tmux shell glue.
20
+
21
+ ### Fixed
22
+ - Updated the `ace-tmux` runtime dependency constraint to `~> 0.17` so recorder-side tmux directives stay aligned with the new runtime inspection release line.
23
+ - Rejected unsupported YAML `tmux.action: capture` directives during parsing, forwarded recording env values into recorder-side tmux control resolution, and clarified that tmux directives are supported only for the asciinema backend.
24
+ - Rejected YAML `tmux:` directives when the resolved recording backend is `vhs` so unsupported backend/control-surface combinations fail fast instead of being silently ignored during VHS compilation.
9
25
 
10
26
  ## [0.25.1] - 2026-04-16
11
27
 
data/README.md CHANGED
@@ -18,7 +18,7 @@
18
18
 
19
19
  ![ace-demo demo](docs/demo/ace-demo-getting-started.gif)
20
20
 
21
- `ace-demo` records terminal sessions as proof-of-work evidence for agent-driven workflows. Tapes define what to capture — either as simple [VHS](https://github.com/charmbracelet/vhs) scripts (`.tape`) or as YAML specs (`.tape.yml`) with sandbox setup, scenes, and teardown.
21
+ `ace-demo` records terminal sessions as proof-of-work evidence for agent-driven workflows. Tapes define what to capture — either as simple [VHS](https://github.com/charmbracelet/vhs) scripts (`.tape`) or as YAML specs (`.tape.yml`) with sandbox setup, scenes, teardown, and optional tmux recorder-control directives.
22
22
 
23
23
  Recordings attach directly to GitHub pull requests as reviewable evidence. Requires `vhs`, `chromium`, and `ttyd` for deterministic rendering (see [setup requirements](docs/setup.md)).
24
24
 
@@ -43,6 +43,11 @@ scenes:
43
43
  commands:
44
44
  - type: ace-demo list
45
45
  sleep: 4s
46
+ - tmux:
47
+ action: wait
48
+ for: window-active
49
+ session: fork-demo
50
+ window: work
46
51
  - type: ace-demo record hello
47
52
  sleep: 6s
48
53
 
@@ -51,10 +56,12 @@ teardown:
51
56
  ```
52
57
 
53
58
  - **setup** — sandbox isolation, git init, fixture copying, or arbitrary shell via `run: <cmd>`
54
- - **scenes** — named command sequences compiled to VHS directives (`Type`, `Enter`, `Sleep`)
59
+ - **scenes** — named command sequences with visible `type:` shell commands and optional `tmux:` recorder-control directives for asciinema-backed recordings
55
60
  - **teardown** — cleanup directives that always run (even on failure)
56
61
  - **settings** — optional `font_size`, `width`, `height`, `format` overrides
57
62
 
63
+ Use `type:` for on-camera shell commands that should appear in the recording. Use `tmux:` only with the asciinema backend for recorder-control actions such as `attach`, `detach`, `wait`, and `send` when the demo needs deterministic tmux choreography without raw shell glue. YAML tapes recorded with the `vhs` backend must not include `tmux:` directives.
64
+
58
65
  Legacy `.tape` files use raw VHS syntax directly. See the [Usage Guide](docs/usage.md) for the full tape specification.
59
66
 
60
67
  ## Use Cases
@@ -27,6 +27,8 @@ module Ace
27
27
  lines << "# Scene: #{scene_name}" unless scene_name.to_s.strip.empty?
28
28
 
29
29
  scene.fetch("commands", []).each do |command|
30
+ next unless command["type"]
31
+
30
32
  lines << command.fetch("type")
31
33
  sleep_value = validate_sleep!(command["sleep"] || default_timeout)
32
34
  lines << "sleep #{sleep_value}"
@@ -170,6 +170,13 @@ module Ace
170
170
  raise DemoYamlParseError, "verify must be a map in #{source_path}" unless verify.is_a?(Hash)
171
171
 
172
172
  normalized = {}
173
+ if verify.key?("allow_nonzero_exit")
174
+ normalized["allow_nonzero_exit"] = normalize_boolean(
175
+ verify["allow_nonzero_exit"],
176
+ "verify.allow_nonzero_exit",
177
+ source_path
178
+ )
179
+ end
173
180
  normalized["require_vars"] = normalize_string_list(verify["require_vars"], "verify.require_vars", source_path) if verify.key?("require_vars")
174
181
  normalized["require_output"] = normalize_string_list(verify["require_output"], "verify.require_output", source_path) if verify.key?("require_output")
175
182
  if verify.key?("require_output_sequence")
@@ -185,6 +192,13 @@ module Ace
185
192
  end
186
193
  private_class_method :normalize_verify
187
194
 
195
+ def normalize_boolean(value, field, source_path)
196
+ return value if value == true || value == false
197
+
198
+ raise DemoYamlParseError, "#{field} must be a boolean in #{source_path}"
199
+ end
200
+ private_class_method :normalize_boolean
201
+
188
202
  def normalize_string_list(value, field, source_path)
189
203
  raise DemoYamlParseError, "#{field} must be an array in #{source_path}" unless value.is_a?(Array)
190
204
 
@@ -205,18 +219,88 @@ module Ace
205
219
  "scenes[#{scene_index}].commands[#{command_index}] must be a map in #{source_path}"
206
220
  end
207
221
 
208
- type = command["type"]&.to_s
209
- if type.nil? || type.strip.empty?
222
+ normalized = {"sleep" => command["sleep"]&.to_s}.compact
223
+ has_type = !command["type"].to_s.strip.empty?
224
+ has_tmux = command["tmux"].is_a?(Hash)
225
+
226
+ if has_type && has_tmux
210
227
  raise DemoYamlParseError,
211
- "scenes[#{scene_index}].commands[#{command_index}].type is required in #{source_path}"
228
+ "scenes[#{scene_index}].commands[#{command_index}] cannot define both type and tmux in #{source_path}"
212
229
  end
213
230
 
214
- {
215
- "type" => type,
216
- "sleep" => command["sleep"]&.to_s
217
- }
231
+ if has_type
232
+ normalized["type"] = command["type"].to_s
233
+ return normalized
234
+ end
235
+
236
+ if has_tmux
237
+ normalized["tmux"] = normalize_tmux_command(
238
+ command["tmux"],
239
+ scene_index,
240
+ command_index,
241
+ source_path: source_path
242
+ )
243
+ return normalized
244
+ end
245
+
246
+ raise DemoYamlParseError,
247
+ "scenes[#{scene_index}].commands[#{command_index}] must define either type or tmux in #{source_path}"
218
248
  end
219
249
  private_class_method :normalize_command
250
+
251
+ def normalize_tmux_command(tmux, scene_index, command_index, source_path:)
252
+ directive = tmux.transform_keys(&:to_s)
253
+ action = directive["action"]&.to_s&.strip
254
+ if action.nil? || action.empty?
255
+ raise DemoYamlParseError,
256
+ "scenes[#{scene_index}].commands[#{command_index}].tmux.action is required in #{source_path}"
257
+ end
258
+
259
+ allowed = %w[attach detach wait send]
260
+ unless allowed.include?(action)
261
+ raise DemoYamlParseError,
262
+ "Unknown tmux action '#{action}' in #{source_path}. Allowed: #{allowed.join(', ')}"
263
+ end
264
+
265
+ normalized = {"action" => action}
266
+ %w[session window pane pattern].each do |field|
267
+ normalized[field] = directive[field].to_s if directive.key?(field)
268
+ end
269
+
270
+ case action
271
+ when "attach", "detach"
272
+ normalized["session"] = required_string!(directive, "session", scene_index, command_index, source_path)
273
+ when "wait"
274
+ normalized["for"] = required_string!(directive, "for", scene_index, command_index, source_path)
275
+ normalized["pattern"] = normalized["pattern"] if normalized["pattern"]
276
+ normalized["timeout"] = Float(directive["timeout"]) if directive.key?("timeout")
277
+ when "send"
278
+ command_text = directive["command"]&.to_s&.strip
279
+ key_text = directive["key"]&.to_s&.strip
280
+ if command_text.to_s.empty? == key_text.to_s.empty?
281
+ raise DemoYamlParseError,
282
+ "tmux send must define exactly one of command or key in #{source_path}"
283
+ end
284
+ normalized["command"] = command_text unless command_text.to_s.empty?
285
+ normalized["key"] = key_text unless key_text.to_s.empty?
286
+ end
287
+
288
+ normalized
289
+ rescue ArgumentError, TypeError => e
290
+ raise DemoYamlParseError, "#{e.message} (#{source_path})"
291
+ end
292
+ private_class_method :normalize_tmux_command
293
+
294
+ def required_string!(directive, field, scene_index, command_index, source_path)
295
+ value = directive[field]&.to_s&.strip
296
+ if value.nil? || value.empty?
297
+ raise DemoYamlParseError,
298
+ "scenes[#{scene_index}].commands[#{command_index}].tmux.#{field} is required in #{source_path}"
299
+ end
300
+
301
+ value
302
+ end
303
+ private_class_method :required_string!
220
304
  end
221
305
  end
222
306
  end
@@ -34,11 +34,25 @@ module Ace
34
34
  raise ArgumentError, "Format 'webm' requires --backend vhs when recording YAML tapes"
35
35
  end
36
36
 
37
+ def validate_yaml_backend_capabilities!(backend:, spec:)
38
+ return unless backend == "vhs"
39
+ return unless yaml_uses_tmux_directives?(spec)
40
+
41
+ raise ArgumentError, "Backend 'vhs' does not support tmux directives in YAML tapes; use backend 'asciinema' or remove tmux commands"
42
+ end
43
+
37
44
  def validate_raw_tape_backend!(backend:)
38
45
  return if backend.nil? || backend == "vhs"
39
46
 
40
47
  raise ArgumentError, "Raw .tape recordings support backend 'vhs' only"
41
48
  end
49
+
50
+ def yaml_uses_tmux_directives?(spec)
51
+ Array(spec["scenes"]).any? do |scene|
52
+ Array(scene["commands"]).any? { |command| command.is_a?(Hash) && command["tmux"].is_a?(Hash) }
53
+ end
54
+ end
55
+ private_class_method :yaml_uses_tmux_directives?
42
56
  end
43
57
  end
44
58
  end
@@ -26,6 +26,8 @@ module Ace
26
26
  lines << "# Scene: #{scene_name}" unless scene_name.to_s.strip.empty?
27
27
 
28
28
  scene.fetch("commands", []).each do |command|
29
+ next unless command["type"]
30
+
29
31
  type_text = command.fetch("type")
30
32
  if type_text.include?('"') || type_text.include?("$") || type_text.include?("\\")
31
33
  lines << "Type `#{type_text}`"
@@ -28,6 +28,7 @@ module Ace
28
28
  allow_nil: false
29
29
  )
30
30
  RecordOptionValidator.validate_yaml_backend_format!(backend: selected_backend, format: selected_format)
31
+ RecordOptionValidator.validate_yaml_backend_capabilities!(backend: selected_backend, spec: spec)
31
32
 
32
33
  selected_speed = playback_speed.nil? ? settings["playback_speed"] : playback_speed
33
34
  selected_speed = Atoms::PlaybackSpeedParser.parse(selected_speed)
@@ -322,6 +322,8 @@ module Ace
322
322
  puts "Missing output: #{missing_output.join(', ')}" unless missing_output.empty?
323
323
  missing_sequence = verification.details&.fetch(:missing_output_sequence, []) || []
324
324
  puts "Missing output sequence: #{missing_sequence.join(' -> ')}" unless missing_sequence.empty?
325
+ exit_code = verification.details&.fetch(:exit_code, nil)
326
+ puts "Exit code: #{exit_code}" unless exit_code.nil? || exit_code.zero?
325
327
  puts "Assertions replay: skipped" if verification.details&.fetch(:assertions_skipped, false)
326
328
  end
327
329
 
@@ -61,6 +61,8 @@ module Ace
61
61
  puts "Missing output: #{missing_output.join(', ')}" unless missing_output.empty?
62
62
  missing_sequence = verification.details&.fetch(:missing_output_sequence, [])
63
63
  puts "Missing output sequence: #{missing_sequence.join(' -> ')}" unless missing_sequence.empty?
64
+ exit_code = verification.details&.fetch(:exit_code, nil)
65
+ puts "Exit code: #{exit_code}" unless exit_code.nil? || exit_code.zero?
64
66
  puts "Assertions replay: skipped" if verification.details&.fetch(:assertions_skipped, false)
65
67
  end
66
68
  end
@@ -42,7 +42,7 @@ module Ace
42
42
  raise AsciinemaNotFoundError, "Asciinema not found (#{effective_bin}). Install: #{INSTALL_URL}"
43
43
  end
44
44
 
45
- def run_interactive(cmd, commands:, env: {}, asciinema_bin: "asciinema", chdir: nil)
45
+ def run_interactive(cmd, commands:, env: {}, handler: nil, asciinema_bin: "asciinema", chdir: nil)
46
46
  effective_bin = cmd.first || asciinema_bin
47
47
  options = {}
48
48
  options[:chdir] = chdir if chdir
@@ -80,8 +80,16 @@ module Ace
80
80
  wait_for_prompt(buffer, buffer_mutex, buffer_cv)
81
81
 
82
82
  commands.each do |command|
83
- write_io.write("#{command.fetch(:command)}\n")
84
- write_io.flush
83
+ if handler && command[:kind] != :shell
84
+ outcome = handler.call(command, write_io: write_io)
85
+ if outcome.is_a?(Hash) && outcome[:shell_command]
86
+ write_io.write("#{outcome[:shell_command]}\n")
87
+ write_io.flush
88
+ end
89
+ else
90
+ write_io.write("#{command.fetch(:command)}\n")
91
+ write_io.flush
92
+ end
85
93
  @sleeper.sleep(command.fetch(:sleep))
86
94
  end
87
95
 
@@ -26,6 +26,8 @@ module Ace
26
26
  recorded_commands = (recorded_inputs + echoed_commands + script_commands).uniq
27
27
  verification_spec = tape_spec.fetch("verify", {})
28
28
  captured_vars = extract_output_vars(echoed_commands)
29
+ exit_code = final_exit_code(recording.events)
30
+ nonzero_exit_disallowed = exit_code && exit_code != 0 && !verification_spec.fetch("allow_nonzero_exit", false)
29
31
 
30
32
  commands_found = expected.select { |command| include_command?(recorded_commands, command) }
31
33
  commands_missing = expected - commands_found
@@ -43,14 +45,16 @@ module Ace
43
45
  )
44
46
 
45
47
  success = commands_missing.empty? && missing_vars.empty? && missing_output.empty? &&
46
- missing_output_sequence.empty? && forbidden_hits.empty? && assertion_failures.empty?
48
+ missing_output_sequence.empty? && forbidden_hits.empty? && assertion_failures.empty? &&
49
+ !nonzero_exit_disallowed
47
50
  classification, status, summary, retryable = classify(
48
51
  commands_missing: commands_missing,
49
52
  missing_vars: missing_vars,
50
53
  missing_output: missing_output,
51
54
  missing_output_sequence: missing_output_sequence,
52
55
  forbidden_hits: forbidden_hits,
53
- assertion_failures: assertion_failures
56
+ assertion_failures: assertion_failures,
57
+ nonzero_exit_disallowed: nonzero_exit_disallowed
54
58
  )
55
59
 
56
60
  Models::VerificationResult.new(
@@ -68,6 +72,7 @@ module Ace
68
72
  script_commands_recorded: script_commands.length,
69
73
  commands_expected: expected.length,
70
74
  captured_vars: captured_vars,
75
+ exit_code: exit_code,
71
76
  missing_vars: missing_vars,
72
77
  missing_output: missing_output,
73
78
  missing_output_sequence: missing_output_sequence,
@@ -117,10 +122,12 @@ module Ace
117
122
  end
118
123
  end
119
124
 
120
- def classify(commands_missing:, missing_vars:, missing_output:, missing_output_sequence:, forbidden_hits:, assertion_failures:)
125
+ def classify(commands_missing:, missing_vars:, missing_output:, missing_output_sequence:, forbidden_hits:, assertion_failures:, nonzero_exit_disallowed:)
121
126
  return ["pass", "pass", "Verification passed", false] if commands_missing.empty? && missing_vars.empty? &&
122
- missing_output.empty? && missing_output_sequence.empty? && forbidden_hits.empty? && assertion_failures.empty?
123
- unless commands_missing.empty? && missing_vars.empty? && missing_output.empty? && missing_output_sequence.empty?
127
+ missing_output.empty? && missing_output_sequence.empty? && forbidden_hits.empty? && assertion_failures.empty? &&
128
+ !nonzero_exit_disallowed
129
+ unless commands_missing.empty? && missing_vars.empty? && missing_output.empty? && missing_output_sequence.empty? &&
130
+ !nonzero_exit_disallowed
124
131
  return ["scenario_defect", "scenario-defect", "Recording scenario failed verification", true]
125
132
  end
126
133
 
@@ -226,6 +233,15 @@ module Ace
226
233
  []
227
234
  end
228
235
 
236
+ def final_exit_code(events)
237
+ exit_event = events.reverse.find { |event| event.type == "x" }
238
+ return nil unless exit_event
239
+
240
+ Integer(exit_event.data)
241
+ rescue ArgumentError, TypeError
242
+ nil
243
+ end
244
+
229
245
  ANSI_ESCAPE_PATTERN = /\e\[[0-9;?]*[A-Za-z]/.freeze
230
246
  private_constant :ANSI_ESCAPE_PATTERN
231
247
 
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+
5
+ module Ace
6
+ module Demo
7
+ module Molecules
8
+ class TmuxDirectiveExecutor
9
+ def initialize(control_surface: nil, executor: nil, tmux: "tmux")
10
+ @control_surface = control_surface
11
+ @executor = executor || Ace::Tmux::Molecules::TmuxExecutor.new
12
+ @tmux = tmux
13
+ end
14
+
15
+ def execute(command, env = nil)
16
+ directive = command.fetch("tmux")
17
+ action = directive.fetch("action")
18
+ surface = control_surface_for(env: env)
19
+
20
+ case action
21
+ when "attach"
22
+ session = directive.fetch("session")
23
+ {shell_command: [tmux, "attach-session", "-t", session].map { |part| Shellwords.escape(part) }.join(" ")}
24
+ when "detach"
25
+ surface.detach_session(session: directive["session"])
26
+ nil
27
+ when "wait"
28
+ surface.wait_for_condition(
29
+ condition: directive.fetch("for"),
30
+ session: directive["session"],
31
+ window: directive["window"],
32
+ pane: directive["pane"],
33
+ pattern: directive["pattern"],
34
+ timeout: directive.fetch("timeout", Ace::Tmux::Organisms::ControlSurface::DEFAULT_TIMEOUT)
35
+ )
36
+ nil
37
+ when "send"
38
+ if directive["command"]
39
+ surface.send_command(
40
+ session: directive["session"],
41
+ window: directive["window"],
42
+ pane: directive["pane"],
43
+ command: directive["command"]
44
+ )
45
+ else
46
+ surface.send_key(
47
+ session: directive["session"],
48
+ window: directive["window"],
49
+ pane: directive["pane"],
50
+ key: directive.fetch("key")
51
+ )
52
+ end
53
+ nil
54
+ else
55
+ raise ArgumentError, "Unsupported tmux action '#{action}'"
56
+ end
57
+ rescue KeyError => e
58
+ raise ArgumentError, "Invalid tmux directive: missing #{e.key}"
59
+ rescue Ace::Tmux::Error => e
60
+ raise Ace::Demo::Error, e.message
61
+ end
62
+
63
+ private
64
+
65
+ attr_reader :control_surface, :executor, :tmux
66
+
67
+ def control_surface_for(env:)
68
+ return control_surface if control_surface
69
+
70
+ resolver = Ace::Tmux::Molecules::RuntimeTargetResolver.new(
71
+ executor: executor,
72
+ tmux: tmux,
73
+ env: env || ENV
74
+ )
75
+ Ace::Tmux::Organisms::ControlSurface.new(executor: executor, resolver: resolver, tmux: tmux)
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -41,6 +41,7 @@ module Ace
41
41
  append_list(lines, "Missing Vars", details[:missing_vars])
42
42
  append_list(lines, "Missing Output", details[:missing_output])
43
43
  append_list(lines, "Missing Output Sequence", details[:missing_output_sequence])
44
+ append_exit_code(lines, details[:exit_code])
44
45
  append_hits(lines, "Forbidden Output Hits", details[:forbidden_hits])
45
46
  append_assertion_skip(lines, details[:assertions_skipped])
46
47
  append_assertions(lines, details[:assertion_failures])
@@ -90,6 +91,15 @@ module Ace
90
91
  lines << ""
91
92
  end
92
93
 
94
+ def append_exit_code(lines, exit_code)
95
+ return if exit_code.nil? || exit_code.to_i.zero?
96
+
97
+ lines << "## Exit Code"
98
+ lines << ""
99
+ lines << "- `#{exit_code}`"
100
+ lines << ""
101
+ end
102
+
93
103
  def append_assertions(lines, assertions)
94
104
  return if assertions.nil? || assertions.empty?
95
105
 
@@ -19,6 +19,7 @@ module Ace
19
19
  yaml_compiler: Atoms::VhsTapeCompiler,
20
20
  asciinema_tape_compiler: Atoms::AsciinemaTapeCompiler,
21
21
  media_retimer: Molecules::MediaRetimer.new,
22
+ tmux_directive_executor: Molecules::TmuxDirectiveExecutor.new,
22
23
  sandbox_builder: Molecules::DemoSandboxBuilder.new,
23
24
  teardown_executor: Molecules::DemoTeardownExecutor.new,
24
25
  output_dir: Demo.config["output_dir"],
@@ -37,6 +38,7 @@ module Ace
37
38
  @yaml_compiler = yaml_compiler
38
39
  @asciinema_tape_compiler = asciinema_tape_compiler
39
40
  @media_retimer = media_retimer
41
+ @tmux_directive_executor = tmux_directive_executor
40
42
  @sandbox_builder = sandbox_builder
41
43
  @teardown_executor = teardown_executor
42
44
  @output_dir = output_dir || ".ace-local/demo"
@@ -176,7 +178,15 @@ module Ace
176
178
  tty_size: settings.fetch("tty_size", "80x24"),
177
179
  asciinema_bin: @asciinema_bin
178
180
  )
179
- @asciinema_executor.run_interactive(record_cmd, commands: commands, env: env, chdir: sandbox[:path])
181
+ @asciinema_executor.run_interactive(
182
+ record_cmd,
183
+ commands: commands,
184
+ env: env,
185
+ chdir: sandbox[:path],
186
+ handler: lambda { |command, write_io:|
187
+ handle_interactive_command(command, write_io: write_io, env: env)
188
+ }
189
+ )
180
190
  normalize_cast_terminal_size(cast_output_path, tty_size: settings.fetch("tty_size", "80x24"))
181
191
 
182
192
  convert_cmd = Atoms::AggCommandBuilder.build(
@@ -230,14 +240,38 @@ module Ace
230
240
  def interactive_commands_for(spec:)
231
241
  spec.fetch("scenes", []).flat_map do |scene|
232
242
  scene.fetch("commands", []).map do |command|
233
- {
234
- command: command.fetch("type"),
235
- sleep: sleep_seconds(command["sleep"] || "2s")
236
- }
243
+ normalize_interactive_command(command)
237
244
  end
238
245
  end
239
246
  end
240
247
 
248
+ def normalize_interactive_command(command)
249
+ if command["type"]
250
+ return {
251
+ kind: :shell,
252
+ command: command.fetch("type"),
253
+ sleep: sleep_seconds(command["sleep"] || "2s")
254
+ }
255
+ end
256
+
257
+ {
258
+ kind: :tmux,
259
+ directive: command.fetch("tmux"),
260
+ sleep: sleep_seconds(command["sleep"] || "2s")
261
+ }
262
+ end
263
+
264
+ def handle_interactive_command(command, write_io:, env:)
265
+ case command[:kind]
266
+ when :shell
267
+ write_io.write("#{command.fetch(:command)}\n")
268
+ write_io.flush
269
+ nil
270
+ when :tmux
271
+ @tmux_directive_executor.execute({"tmux" => command.fetch(:directive)}, env)
272
+ end
273
+ end
274
+
241
275
  def sleep_seconds(value)
242
276
  text = value.to_s.strip
243
277
  match = text.match(/\A(?<number>\d+(?:\.\d+)?)(?<unit>ms|s|m|h)?\z/)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Demo
5
- VERSION = '0.25.1'
5
+ VERSION = '0.25.6'
6
6
  end
7
7
  end
data/lib/ace/demo.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "fileutils"
4
4
  require "ace/support/config"
5
+ require "ace/tmux"
5
6
 
6
7
  require_relative "demo/version"
7
8
  require_relative "demo/atoms/vhs_command_builder"
@@ -40,6 +41,7 @@ require_relative "demo/molecules/media_retimer"
40
41
  require_relative "demo/molecules/demo_comment_poster"
41
42
  require_relative "demo/molecules/verification_report_writer"
42
43
  require_relative "demo/molecules/recording_manifest_writer"
44
+ require_relative "demo/molecules/tmux_directive_executor"
43
45
  require_relative "demo/organisms/demo_recorder"
44
46
  require_relative "demo/organisms/demo_attacher"
45
47
  require_relative "demo/organisms/tape_creator"
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-demo
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.25.1
4
+ version: 0.25.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-04-20 00:00:00.000000000 Z
10
+ date: 2026-04-26 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-support-cli
@@ -65,6 +65,20 @@ dependencies:
65
65
  - - "~>"
66
66
  - !ruby/object:Gem::Version
67
67
  version: '0.13'
68
+ - !ruby/object:Gem::Dependency
69
+ name: ace-tmux
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '0.17'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '0.17'
68
82
  - !ruby/object:Gem::Dependency
69
83
  name: ace-support-test-helpers
70
84
  requirement: !ruby/object:Gem::Requirement
@@ -189,6 +203,7 @@ files:
189
203
  - lib/ace/demo/molecules/tape_resolver.rb
190
204
  - lib/ace/demo/molecules/tape_scanner.rb
191
205
  - lib/ace/demo/molecules/tape_writer.rb
206
+ - lib/ace/demo/molecules/tmux_directive_executor.rb
192
207
  - lib/ace/demo/molecules/verification_report_writer.rb
193
208
  - lib/ace/demo/molecules/vhs_executor.rb
194
209
  - lib/ace/demo/organisms/demo_attacher.rb