diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml index 9a59d76..990f296 100644 --- a/.github/workflows/js.yml +++ b/.github/workflows/js.yml @@ -72,6 +72,45 @@ jobs: GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: node scripts/detect-code-changes.mjs + # === VERSION CHANGE CHECK === + # Prohibit manual version changes in package.json - versions should only be changed by CI/CD + version-check: + name: Check for Manual Version Changes + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check for version changes in package.json + run: | + # Skip check for automated release PRs + if [[ "${{ github.head_ref }}" == "changeset-release/"* ]] || [[ "${{ github.head_ref }}" == "changeset-js-"* ]]; then + echo "Skipping version check for automated release PR" + exit 0 + fi + + # Get the diff for package.json + VERSION_DIFF=$(git diff origin/${{ github.base_ref }}...HEAD -- js/package.json | grep -E '^\+.*"version"' || true) + + if [ -n "$VERSION_DIFF" ]; then + echo "::error::Manual version change detected in js/package.json" + echo "" + echo "Version changes in package.json are prohibited in pull requests." + echo "Versions are managed automatically by the CI/CD pipeline using changesets." + echo "" + echo "To request a release:" + echo " 1. Add a changeset file describing your changes" + echo " 2. The release workflow will automatically bump the version when merged" + echo "" + echo "Detected change:" + echo "$VERSION_DIFF" + exit 1 + fi + + echo "No manual version changes detected - check passed" + # === CHANGESET CHECK === changeset-check: name: Check for Changesets diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 9beba73..75f6850 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -70,6 +70,39 @@ jobs: GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: node scripts/detect-code-changes.mjs + # === VERSION CHANGE CHECK === + # Prohibit manual version changes in Cargo.toml - versions should only be changed by CI/CD + version-check: + name: Check for Manual Version Changes + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check for version changes in Cargo.toml + run: | + # Get the diff for Cargo.toml + VERSION_DIFF=$(git diff origin/${{ github.base_ref }}...HEAD -- rust/Cargo.toml | grep -E '^\+version\s*=' || true) + + if [ -n "$VERSION_DIFF" ]; then + echo "::error::Manual version change detected in rust/Cargo.toml" + echo "" + echo "Version changes in Cargo.toml are prohibited in pull requests." + echo "Versions are managed automatically by the CI/CD pipeline using changelog fragments." + echo "" + echo "To request a release:" + echo " 1. Add a changelog fragment in rust/changelog.d/ describing your changes" + echo " 2. The release workflow will automatically bump the version when merged" + echo "" + echo "Detected change:" + echo "$VERSION_DIFF" + exit 1 + fi + + echo "No manual version changes detected - check passed" + # === CHANGELOG CHECK === changelog: name: Changelog Fragment Check diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ba5d2b7..6ef6d6f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -205,7 +205,7 @@ By default, all isolation environments automatically exit after command completi │ │ Execution │ │ stdout/stderr│ │ Temp Log File │ │ │ └─────────────┘ └──────────────┘ └───────────────────┘ │ │ │ -│ Console Output Format (Status Spine): │ +│ Console Output Format (Timeline): │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │ │ session abc-123-def-456 │ │ │ │ │ start 2024-01-15 10:30:45 │ │ diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 13b88e2..445951a 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -242,14 +242,35 @@ Note: `--auto-remove-docker-container` is only valid with `--isolated docker` an ## Output Format -The output uses a "status spine" format that is width-independent, lossless, and works uniformly in TTY, tmux, SSH, CI, and log files. +The output uses a "timeline" format that is width-independent, lossless, and works uniformly in TTY, tmux, SSH, CI, and log files. Format conventions: -- `│` prefix → tool metadata -- `$` prefix → executed command +- `│` prefix → tool metadata (timeline marker) +- `$` prefix → executed command (can be virtual command for setup steps or user command) - No prefix → program output (stdout/stderr) - `✓` / `✗` → result marker (success/failure) +### Virtual Commands + +When running commands in isolation environments that require setup steps (like Docker pulling an image), the timeline shows these as separate virtual commands. This makes the execution flow transparent: + +``` +$ docker pull alpine:latest ← virtual command (setup step) + +Unable to find image 'alpine:latest' locally +... pull output ... + +✓ +│ +$ echo hi ← user command + +hi + +✓ +``` + +Virtual commands are only displayed when the setup step actually occurs (e.g., `docker pull` is shown only if the image needs to be downloaded). + ### Success Case ``` diff --git a/js/.changeset/issue-70-timeline-virtual-commands.md b/js/.changeset/issue-70-timeline-virtual-commands.md new file mode 100644 index 0000000..13e0225 --- /dev/null +++ b/js/.changeset/issue-70-timeline-virtual-commands.md @@ -0,0 +1,24 @@ +--- +'start-command': minor +--- + +feat: Rename spine to timeline, add virtual command visualization for Docker + +This release updates the internal terminology from "spine" to "timeline" for the status output format, and adds automatic visualization of Docker image pull operations as virtual commands. + +Timeline terminology changes: + +- The `│` prefixed output format is now consistently referred to as "timeline" format throughout the codebase +- All deprecated "spine" names remain available for backwards compatibility +- API changes are reflected in the Rust library (no breaking changes in JS) + +Virtual command visualization for Docker: + +- When Docker isolation requires pulling an image, it now appears as a separate `$ docker pull ` command +- Pull output is streamed in real-time with success (✓) or failure (✗) markers +- Only shown when the image actually needs to be pulled (not when using cached images) +- Provides better visibility into what's happening during Docker-based command execution + +Version bumped to 0.20.0 to match the Rust library version. + +Fixes #70 diff --git a/rust/changelog.d/70.md b/rust/changelog.d/70.md new file mode 100644 index 0000000..1f8dfc9 --- /dev/null +++ b/rust/changelog.d/70.md @@ -0,0 +1,19 @@ +feat: Rename spine to timeline, add virtual command visualization for Docker + +- Renamed "spine" terminology to "timeline" throughout the codebase + - `SPINE` constant → `TIMELINE_MARKER` (old name deprecated) + - `create_spine_line()` → `create_timeline_line()` (old name deprecated) + - `create_empty_spine_line()` → `create_empty_timeline_line()` (old name deprecated) +- Added virtual command visualization for Docker image pulls + - When Docker isolation requires pulling an image, it's shown as `$ docker pull ` + - Pull output is streamed in real-time with result markers (✓/✗) + - Only displayed when image actually needs to be pulled (conditional display) +- New API additions: + - `create_virtual_command_block()` - for formatting virtual commands + - `create_virtual_command_result()` - for result markers + - `docker_image_exists()` - check if image is available locally + - `docker_pull_image()` - pull with streaming output + - `StartBlockOptions.defer_command` - defer command display for multi-step execution +- All deprecated items have backward-compatible aliases for smooth migration + +Fixes #70 diff --git a/rust/src/bin/main.rs b/rust/src/bin/main.rs index 561c836..2acba54 100644 --- a/rust/src/bin/main.rs +++ b/rust/src/bin/main.rs @@ -523,6 +523,8 @@ fn run_with_isolation( } // Print start block with session ID and isolation info + // For Docker isolation, defer the command display since virtual commands may be shown first + let is_docker_isolation = environment == Some("docker"); let extra_lines_refs: Vec<&str> = extra_lines.iter().map(|s| s.as_str()).collect(); println!( "{}", @@ -537,6 +539,7 @@ fn run_with_isolation( }, style: None, width: None, + defer_command: is_docker_isolation, }) ); println!(); @@ -667,6 +670,7 @@ fn run_direct( extra_lines: None, style: None, width: None, + defer_command: false, }) ); println!(); diff --git a/rust/src/lib/isolation.rs b/rust/src/lib/isolation.rs index 05c9af4..8495c4f 100644 --- a/rust/src/lib/isolation.rs +++ b/rust/src/lib/isolation.rs @@ -467,6 +467,83 @@ pub fn run_in_ssh(command: &str, options: &IsolationOptions) -> IsolationResult } } +/// Check if a Docker image exists locally +pub fn docker_image_exists(image: &str) -> bool { + Command::new("docker") + .args(["image", "inspect", image]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +/// Pull a Docker image with output streaming +/// Returns (success, output) tuple +pub fn docker_pull_image(image: &str) -> (bool, String) { + use std::io::{BufRead, BufReader}; + + // Print the virtual command line + println!( + "{}", + crate::output_blocks::create_virtual_command_block(&format!("docker pull {}", image)) + ); + println!(); + + let mut child = match Command::new("docker") + .args(["pull", image]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + { + Ok(c) => c, + Err(e) => { + let error_msg = format!("Failed to run docker pull: {}", e); + eprintln!("{}", error_msg); + println!(); + println!( + "{}", + crate::output_blocks::create_virtual_command_result(false) + ); + return (false, error_msg); + } + }; + + let mut output = String::new(); + + // Read and display stdout + if let Some(stdout) = child.stdout.take() { + let reader = BufReader::new(stdout); + for line in reader.lines().map_while(Result::ok) { + println!("{}", line); + output.push_str(&line); + output.push('\n'); + } + } + + // Read and display stderr + if let Some(stderr) = child.stderr.take() { + let reader = BufReader::new(stderr); + for line in reader.lines().map_while(Result::ok) { + eprintln!("{}", line); + output.push_str(&line); + output.push('\n'); + } + } + + let success = child.wait().map(|s| s.success()).unwrap_or(false); + + // Print result marker and separator + println!(); + println!( + "{}", + crate::output_blocks::create_virtual_command_result(success) + ); + println!("{}", crate::output_blocks::create_timeline_separator()); + + (success, output) +} + /// Run command in Docker container pub fn run_in_docker(command: &str, options: &IsolationOptions) -> IsolationResult { if !is_command_available("docker") { @@ -490,6 +567,19 @@ pub fn run_in_docker(command: &str, options: &IsolationOptions) -> IsolationResu } }; + // Check if image exists locally; if not, pull it as a virtual command + if !docker_image_exists(&image) { + let (pull_success, _pull_output) = docker_pull_image(&image); + if !pull_success { + return IsolationResult { + success: false, + message: format!("Failed to pull Docker image: {}", image), + exit_code: Some(1), + ..Default::default() + }; + } + } + let container_name = options .session .clone() @@ -497,6 +587,10 @@ pub fn run_in_docker(command: &str, options: &IsolationOptions) -> IsolationResu let (_, _) = get_shell(); + // Print the user command (this appears after any virtual commands like docker pull) + println!("{}", crate::output_blocks::create_command_line(command)); + println!(); + if options.detached { let effective_command = if options.keep_alive { format!("{}; exec /bin/sh", command) diff --git a/rust/src/lib/mod.rs b/rust/src/lib/mod.rs index 404e651..c2be7e5 100644 --- a/rust/src/lib/mod.rs +++ b/rust/src/lib/mod.rs @@ -23,17 +23,23 @@ pub use execution_store::{ }; pub use failure_handler::{handle_failure, Config as FailureConfig}; pub use isolation::{ - create_log_footer, create_log_header, create_log_path, get_default_docker_image, get_timestamp, - is_command_available, run_as_isolated_user, run_isolated, write_log_file, IsolationOptions, - IsolationResult, LogHeaderParams, + create_log_footer, create_log_header, create_log_path, docker_image_exists, docker_pull_image, + get_default_docker_image, get_timestamp, is_command_available, run_as_isolated_user, + run_isolated, write_log_file, IsolationOptions, IsolationResult, LogHeaderParams, }; +#[allow(deprecated)] pub use output_blocks::{ - // Status spine format API + // Timeline format API (formerly "status spine") create_command_line, - create_empty_spine_line, + create_empty_spine_line, // deprecated, use create_empty_timeline_line + create_empty_timeline_line, create_finish_block, - create_spine_line, + create_spine_line, // deprecated, use create_timeline_line create_start_block, + create_timeline_line, + create_timeline_separator, + create_virtual_command_block, + create_virtual_command_result, escape_for_links_notation, format_duration, format_value_for_links_notation, @@ -44,8 +50,9 @@ pub use output_blocks::{ IsolationMetadata, StartBlockOptions, FAILURE_MARKER, - SPINE, + SPINE, // deprecated, use TIMELINE_MARKER SUCCESS_MARKER, + TIMELINE_MARKER, }; pub use signal_handler::{clear_current_execution, set_current_execution, setup_signal_handlers}; pub use status_formatter::{ diff --git a/rust/src/lib/output_blocks.rs b/rust/src/lib/output_blocks.rs index dae5f0f..f6f0f9b 100644 --- a/rust/src/lib/output_blocks.rs +++ b/rust/src/lib/output_blocks.rs @@ -1,17 +1,22 @@ //! Output formatting utilities for nicely rendered command blocks //! -//! Provides "status spine" format: a width-independent, lossless output format +//! Provides "timeline" format: a width-independent, lossless output format //! that works in TTY, tmux, SSH, CI, and logs. //! //! Core concepts: -//! - `│` prefix → tool metadata -//! - `$` → executed command +//! - `│` prefix → tool metadata (timeline marker) +//! - `$` → executed command (virtual or user command) //! - No prefix → program output (stdout/stderr) //! - Result marker (`✓` / `✗`) appears after output use regex::Regex; -/// Metadata spine character +/// Timeline marker character (formerly called "spine") +/// Used to prefix metadata lines in the timeline format +pub const TIMELINE_MARKER: &str = "│"; + +/// Alias for backward compatibility +#[deprecated(since = "0.20.0", note = "Use TIMELINE_MARKER instead")] pub const SPINE: &str = "│"; /// Success result marker @@ -20,15 +25,27 @@ pub const SUCCESS_MARKER: &str = "✓"; /// Failure result marker pub const FAILURE_MARKER: &str = "✗"; -/// Create a metadata line with spine prefix -pub fn create_spine_line(label: &str, value: &str) -> String { +/// Create a metadata line with timeline marker prefix +pub fn create_timeline_line(label: &str, value: &str) -> String { // Pad label to 10 characters for alignment - format!("{} {:10}{}", SPINE, label, value) + format!("{} {:10}{}", TIMELINE_MARKER, label, value) +} + +/// Alias for backward compatibility +#[deprecated(since = "0.20.0", note = "Use create_timeline_line instead")] +pub fn create_spine_line(label: &str, value: &str) -> String { + create_timeline_line(label, value) +} + +/// Create an empty timeline line (just the timeline marker character) +pub fn create_empty_timeline_line() -> String { + TIMELINE_MARKER.to_string() } -/// Create an empty spine line (just the spine character) +/// Alias for backward compatibility +#[deprecated(since = "0.20.0", note = "Use create_empty_timeline_line instead")] pub fn create_empty_spine_line() -> String { - SPINE.to_string() + create_empty_timeline_line() } /// Create a command line with $ prefix @@ -36,6 +53,27 @@ pub fn create_command_line(command: &str) -> String { format!("$ {}", command) } +/// Create a virtual command block for setup steps (like docker pull) +/// Virtual commands are displayed separately in the timeline to show +/// intermediate steps that the tool performs automatically +pub fn create_virtual_command_block(command: &str) -> String { + create_command_line(command) +} + +/// Create a result marker line for a virtual command +pub fn create_virtual_command_result(success: bool) -> String { + if success { + SUCCESS_MARKER.to_string() + } else { + FAILURE_MARKER.to_string() + } +} + +/// Create a separator line between virtual commands and user commands +pub fn create_timeline_separator() -> String { + create_empty_timeline_line() +} + /// Get the result marker based on exit code pub fn get_result_marker(exit_code: i32) -> &'static str { if exit_code == 0 { @@ -96,7 +134,7 @@ pub fn parse_isolation_metadata(extra_lines: &[&str]) -> IsolationMetadata { metadata } -/// Generate isolation metadata lines for spine format +/// Generate isolation metadata lines for timeline format pub fn generate_isolation_lines( metadata: &IsolationMetadata, container_or_screen_name: Option<&str>, @@ -104,15 +142,15 @@ pub fn generate_isolation_lines( let mut lines = Vec::new(); if let Some(ref isolation) = metadata.isolation { - lines.push(create_spine_line("isolation", isolation)); + lines.push(create_timeline_line("isolation", isolation)); } if let Some(ref mode) = metadata.mode { - lines.push(create_spine_line("mode", mode)); + lines.push(create_timeline_line("mode", mode)); } if let Some(ref image) = metadata.image { - lines.push(create_spine_line("image", image)); + lines.push(create_timeline_line("image", image)); } // Use provided container/screen name or fall back to metadata.session @@ -123,12 +161,12 @@ pub fn generate_isolation_lines( if let Some(name) = name { match isolation.as_str() { - "docker" => lines.push(create_spine_line("container", &name)), - "screen" => lines.push(create_spine_line("screen", &name)), - "tmux" => lines.push(create_spine_line("tmux", &name)), + "docker" => lines.push(create_timeline_line("container", &name)), + "screen" => lines.push(create_timeline_line("screen", &name)), + "tmux" => lines.push(create_timeline_line("tmux", &name)), "ssh" => { if let Some(ref endpoint) = metadata.endpoint { - lines.push(create_spine_line("endpoint", endpoint)); + lines.push(create_timeline_line("endpoint", endpoint)); } } _ => {} @@ -137,7 +175,7 @@ pub fn generate_isolation_lines( } if let Some(ref user) = metadata.user { - lines.push(create_spine_line("user", user)); + lines.push(create_timeline_line("user", user)); } lines @@ -151,31 +189,36 @@ pub struct StartBlockOptions<'a> { pub extra_lines: Option>, pub style: Option<&'a str>, pub width: Option, + /// If true, the command line is omitted from the start block + /// (useful when virtual commands will be shown before the actual command) + pub defer_command: bool, } -/// Create a start block for command execution using status spine format +/// Create a start block for command execution using timeline format pub fn create_start_block(options: &StartBlockOptions) -> String { let mut lines = Vec::new(); // Header: session and start time - lines.push(create_spine_line("session", options.session_id)); - lines.push(create_spine_line("start", options.timestamp)); + lines.push(create_timeline_line("session", options.session_id)); + lines.push(create_timeline_line("start", options.timestamp)); // Parse and add isolation metadata if present if let Some(ref extra) = options.extra_lines { let metadata = parse_isolation_metadata(extra); if metadata.isolation.is_some() { - lines.push(create_empty_spine_line()); + lines.push(create_empty_timeline_line()); lines.extend(generate_isolation_lines(&metadata, None)); } } - // Empty spine line before command - lines.push(create_empty_spine_line()); + // Empty timeline line before command (always needed for separation) + lines.push(create_empty_timeline_line()); - // Command line - lines.push(create_command_line(options.command)); + // Command line (unless deferred for virtual command handling) + if !options.defer_command { + lines.push(create_command_line(options.command)); + } lines.join("\n") } @@ -208,7 +251,7 @@ pub struct FinishBlockOptions<'a> { pub width: Option, } -/// Create a finish block for command execution using status spine format +/// Create a finish block for command execution using timeline format /// /// Bottom block ordering rules: /// 1. Result marker (✓ or ✗) @@ -216,7 +259,7 @@ pub struct FinishBlockOptions<'a> { /// 3. duration /// 4. exit code /// 5. (repeated isolation metadata, if any) -/// 6. empty spine line +/// 6. empty timeline line /// 7. log path (always second-to-last) /// 8. session ID (always last) pub fn create_finish_block(options: &FinishBlockOptions) -> String { @@ -226,29 +269,32 @@ pub fn create_finish_block(options: &FinishBlockOptions) -> String { lines.push(get_result_marker(options.exit_code).to_string()); // Finish metadata - lines.push(create_spine_line("finish", options.timestamp)); + lines.push(create_timeline_line("finish", options.timestamp)); if let Some(duration_ms) = options.duration_ms { - lines.push(create_spine_line("duration", &format_duration(duration_ms))); + lines.push(create_timeline_line( + "duration", + &format_duration(duration_ms), + )); } - lines.push(create_spine_line("exit", &options.exit_code.to_string())); + lines.push(create_timeline_line("exit", &options.exit_code.to_string())); // Repeat isolation metadata if present if let Some(ref extra) = options.extra_lines { let metadata = parse_isolation_metadata(extra); if metadata.isolation.is_some() { - lines.push(create_empty_spine_line()); + lines.push(create_empty_timeline_line()); lines.extend(generate_isolation_lines(&metadata, None)); } } - // Empty spine line before final two entries - lines.push(create_empty_spine_line()); + // Empty timeline line before final two entries + lines.push(create_empty_timeline_line()); // Log and session are ALWAYS last (in that order) - lines.push(create_spine_line("log", options.log_path)); - lines.push(create_spine_line("session", options.session_id)); + lines.push(create_timeline_line("log", options.log_path)); + lines.push(create_timeline_line("session", options.session_id)); lines.join("\n") } @@ -320,8 +366,8 @@ mod tests { use super::*; #[test] - fn test_create_spine_line() { - let line = create_spine_line("session", "abc123"); + fn test_create_timeline_line() { + let line = create_timeline_line("session", "abc123"); assert!(line.starts_with("│")); assert!(line.contains("session")); assert!(line.contains("abc123")); @@ -466,6 +512,7 @@ mod tests { extra_lines: Some(extra_lines), style: None, width: None, + defer_command: false, }); // Issue #67: The start block should include the session name for reconnection assert!(block.contains("│ session uuid-123")); diff --git a/rust/tests/output_blocks_test.rs b/rust/tests/output_blocks_test.rs index 28bb9de..86b75f5 100644 --- a/rust/tests/output_blocks_test.rs +++ b/rust/tests/output_blocks_test.rs @@ -1,16 +1,16 @@ //! Tests for output_blocks module //! -//! Tests for the "status spine" format: width-independent, lossless output. +//! Tests for the "timeline" format: width-independent, lossless output. use start_command::{ create_finish_block, create_start_block, escape_for_links_notation, format_duration, get_result_marker, parse_isolation_metadata, FinishBlockOptions, StartBlockOptions, - FAILURE_MARKER, SPINE, SUCCESS_MARKER, + FAILURE_MARKER, SUCCESS_MARKER, TIMELINE_MARKER, }; #[test] -fn test_spine_constants() { - assert_eq!(SPINE, "│"); +fn test_timeline_constants() { + assert_eq!(TIMELINE_MARKER, "│"); assert_eq!(SUCCESS_MARKER, "✓"); assert_eq!(FAILURE_MARKER, "✗"); } @@ -46,6 +46,7 @@ fn test_create_start_block() { extra_lines: None, style: Some("rounded"), width: Some(50), + defer_command: false, }); assert!(block.contains("│ session test-uuid")); @@ -66,6 +67,7 @@ fn test_create_start_block_with_isolation() { extra_lines: Some(extra), style: Some("rounded"), width: Some(60), + defer_command: false, }); assert!(block.contains("│ session test-uuid")); @@ -90,6 +92,7 @@ fn test_create_start_block_with_docker_isolation() { extra_lines: Some(extra), style: None, width: None, + defer_command: false, }); assert!(block.contains("│ isolation docker")); @@ -97,6 +100,34 @@ fn test_create_start_block_with_docker_isolation() { assert!(block.contains("│ container docker-container-123")); } +#[test] +fn test_create_start_block_with_defer_command() { + // When defer_command is true, the command line should be omitted + // (used when virtual commands will be shown separately) + let extra = vec![ + "[Isolation] Environment: docker, Mode: attached", + "[Isolation] Image: alpine:latest", + "[Isolation] Session: docker-container-456", + ]; + let block = create_start_block(&StartBlockOptions { + session_id: "test-uuid", + timestamp: "2025-01-01 00:00:00", + command: "echo hello", + extra_lines: Some(extra), + style: None, + width: None, + defer_command: true, + }); + + assert!(block.contains("│ session test-uuid")); + assert!(block.contains("│ isolation docker")); + assert!(block.contains("│ image alpine:latest")); + assert!( + !block.contains("$ echo hello"), + "Command should not appear when defer_command is true" + ); +} + #[test] fn test_create_finish_block() { let block = create_finish_block(&FinishBlockOptions {