diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml index efeec67..9a59d76 100644 --- a/.github/workflows/js.yml +++ b/.github/workflows/js.yml @@ -161,13 +161,13 @@ jobs: with: bun-version: latest - - name: Install screen (Linux) + - name: Install screen and tmux (Linux) if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y screen + run: sudo apt-get update && sudo apt-get install -y screen tmux - - name: Install screen (macOS) + - name: Install screen and tmux (macOS) if: runner.os == 'macOS' - run: brew install screen + run: brew install screen tmux - name: Setup .NET for clink (Linux) if: runner.os == 'Linux' @@ -247,6 +247,28 @@ jobs: cat "$USERPROFILE/.start-command/executions.lino" echo "Execution tracking test passed" + # Integration tests for isolation modes - Linux only + - name: Test screen isolation mode (Linux) + if: runner.os == 'Linux' + working-directory: js + run: | + bun run src/bin/cli.js --isolated screen -- echo "Testing screen isolation" + echo "Screen isolation test passed" + + - name: Test tmux isolation mode (Linux) + if: runner.os == 'Linux' + working-directory: js + run: | + bun run src/bin/cli.js --isolated tmux -d -- echo "Testing tmux isolation" + echo "Tmux isolation test passed" + + - name: Test docker isolation mode (Linux) + if: runner.os == 'Linux' + working-directory: js + run: | + bun run src/bin/cli.js --isolated docker -d --image alpine:latest -- echo "Testing docker isolation" + echo "Docker isolation test passed" + # SSH Integration Tests - Linux only - name: Setup SSH server for integration tests (Linux) if: runner.os == 'Linux' diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 515ded1..9beba73 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -83,20 +83,25 @@ jobs: - name: Check for changelog fragments run: | - # Get list of fragment files (excluding README and template) - FRAGMENTS=$(find rust/changelog.d -name "*.md" ! -name "README.md" 2>/dev/null | wc -l) - # Get changed files in PR CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD) # Check if any Rust source files changed SOURCE_CHANGED=$(echo "$CHANGED_FILES" | grep -E "^rust/(src/|tests/|Cargo\.toml)" | wc -l) - if [ "$SOURCE_CHANGED" -gt 0 ] && [ "$FRAGMENTS" -eq 0 ]; then - echo "::error::No changelog fragment found. Please add a changelog entry in rust/changelog.d/" + # Check if a changelog fragment was ADDED in this PR (not just existing) + # We need to check the diff, not just the directory contents + FRAGMENT_ADDED=$(echo "$CHANGED_FILES" | grep -E "^rust/changelog\.d/.*\.md$" | grep -v "README.md" | wc -l) + + echo "Source files changed: $SOURCE_CHANGED" + echo "Changelog fragments added in PR: $FRAGMENT_ADDED" + + if [ "$SOURCE_CHANGED" -gt 0 ] && [ "$FRAGMENT_ADDED" -eq 0 ]; then + echo "::error::No changelog fragment found in this PR. Please add a changelog entry in rust/changelog.d/" echo "" echo "To create a changelog fragment:" echo " Create a new .md file in rust/changelog.d/ with your changes" + echo " Name it after your PR number, e.g., '123.md'" echo "" echo "See rust/changelog.d/README.md for more information." exit 1 @@ -218,11 +223,12 @@ jobs: echo "Execution tracking test passed" # === BUILD === + # Only build on push to main, not on PRs (tests already verify the code builds) build: name: Build Package runs-on: ubuntu-latest needs: [lint, test] - if: always() && needs.lint.result == 'success' && needs.test.result == 'success' + if: always() && github.event_name == 'push' && needs.lint.result == 'success' && needs.test.result == 'success' steps: - uses: actions/checkout@v4 diff --git a/js/.changeset/status-spine-format.md b/js/.changeset/status-spine-format.md new file mode 100644 index 0000000..9172e32 --- /dev/null +++ b/js/.changeset/status-spine-format.md @@ -0,0 +1,12 @@ +--- +'start-command': minor +--- + +Replace fixed-width box output with status spine format + +- Width-independent output that doesn't truncate or create jagged boxes +- All metadata visible and copy-pasteable (log paths, session IDs) +- Works uniformly in TTY, tmux, SSH, CI, and log files +- Clear visual distinction: │ for metadata, $ for command, no prefix for output +- Result markers ✓ and ✗ for success/failure +- Isolation metadata repeated in footer for context diff --git a/js/src/bin/cli.js b/js/src/bin/cli.js index b319dfb..2e473b4 100644 --- a/js/src/bin/cli.js +++ b/js/src/bin/cli.js @@ -623,7 +623,7 @@ async function runWithIsolation( console.log(''); } - // Print finish block with result message inside + // Print finish block with isolation metadata repeated // Add empty line before finish block for visual separation console.log(''); const durationMs = Date.now() - startTimeMs; @@ -634,7 +634,7 @@ async function runWithIsolation( exitCode, logPath: logFilePath, durationMs, - resultMessage: result.message, + extraLines, // Pass extraLines for isolation metadata repetition in footer }) ); diff --git a/js/src/lib/output-blocks.js b/js/src/lib/output-blocks.js index bf6b266..7672fe4 100644 --- a/js/src/lib/output-blocks.js +++ b/js/src/lib/output-blocks.js @@ -1,189 +1,205 @@ /** * Output formatting utilities for nicely rendered command blocks * - * Provides various styles for start/finish blocks to distinguish - * command output from the $ wrapper output. + * Provides "status spine" format: a width-independent, lossless output format + * that works in TTY, tmux, SSH, CI, and logs. * - * Available styles: - * 1. 'rounded' (default): Rounded unicode box borders (╭─╮ ╰─╯) - * 2. 'heavy': Heavy unicode box borders (┏━┓ ┗━┛) - * 3. 'double': Double line box borders (╔═╗ ╚═╝) - * 4. 'simple': Simple dash lines (────────) - * 5. 'ascii': Pure ASCII compatible (-------- +------+) + * Core concepts: + * - `│` prefix → tool metadata + * - `$` → executed command + * - No prefix → program output (stdout/stderr) + * - Result marker (`✓` / `✗`) appears after output */ -// Box drawing characters for different styles -const BOX_STYLES = { - rounded: { - topLeft: '╭', - topRight: '╮', - bottomLeft: '╰', - bottomRight: '╯', - horizontal: '─', - vertical: '│', - }, - heavy: { - topLeft: '┏', - topRight: '┓', - bottomLeft: '┗', - bottomRight: '┛', - horizontal: '━', - vertical: '┃', - }, - double: { - topLeft: '╔', - topRight: '╗', - bottomLeft: '╚', - bottomRight: '╝', - horizontal: '═', - vertical: '║', - }, - simple: { - topLeft: '', - topRight: '', - bottomLeft: '', - bottomRight: '', - horizontal: '─', - vertical: '', - }, - ascii: { - topLeft: '+', - topRight: '+', - bottomLeft: '+', - bottomRight: '+', - horizontal: '-', - vertical: '|', - }, -}; - -// Default style (can be overridden via environment variable) -const DEFAULT_STYLE = process.env.START_OUTPUT_STYLE || 'rounded'; +// Metadata spine character +const SPINE = '│'; -// Default block width -const DEFAULT_WIDTH = 60; +// Result markers +const SUCCESS_MARKER = '✓'; +const FAILURE_MARKER = '✗'; /** - * Get the box style configuration - * @param {string} [styleName] - Style name (rounded, heavy, double, simple, ascii) - * @returns {object} Box style configuration + * Create a metadata line with spine prefix + * @param {string} label - Label (e.g., 'session', 'start', 'exit') + * @param {string} value - Value for the label + * @returns {string} Formatted line with spine prefix */ -function getBoxStyle(styleName = DEFAULT_STYLE) { - return BOX_STYLES[styleName] || BOX_STYLES.rounded; +function createSpineLine(label, value) { + // Pad label to 10 characters for alignment + const paddedLabel = label.padEnd(10); + return `${SPINE} ${paddedLabel}${value}`; } /** - * Create a horizontal line - * @param {number} width - Line width - * @param {object} style - Box style - * @returns {string} Horizontal line + * Create an empty spine line (just the spine character) + * @returns {string} Empty spine line */ -function createHorizontalLine(width, style) { - return style.horizontal.repeat(width); +function createEmptySpineLine() { + return SPINE; } /** - * Pad or truncate text to fit a specific width - * @param {string} text - Text to pad - * @param {number} width - Target width - * @param {boolean} [allowOverflow=false] - If true, don't truncate long text - * @returns {string} Padded text + * Create a command line with $ prefix + * @param {string} command - The command being executed + * @returns {string} Formatted command line */ -function padText(text, width, allowOverflow = false) { - if (text.length >= width) { - // If overflow is allowed, return text as-is (for copyable content like paths) - if (allowOverflow) { - return text; - } - return text.substring(0, width); - } - return text + ' '.repeat(width - text.length); +function createCommandLine(command) { + return `$ ${command}`; } /** - * Create a bordered line with text - * @param {string} text - Text content - * @param {number} width - Total width (including borders) - * @param {object} style - Box style - * @param {boolean} [allowOverflow=false] - If true, allow text to overflow (for copyable content) - * @returns {string} Bordered line + * Get the result marker based on exit code + * @param {number} exitCode - Exit code (0 = success) + * @returns {string} Result marker (✓ or ✗) */ -function createBorderedLine(text, width, style, allowOverflow = false) { - if (style.vertical) { - const innerWidth = width - 4; // 2 for borders, 2 for padding - const paddedText = padText(text, innerWidth, allowOverflow); - // If text overflows, extend the right border position - if (allowOverflow && text.length > innerWidth) { - return `${style.vertical} ${paddedText} ${style.vertical}`; - } - return `${style.vertical} ${paddedText} ${style.vertical}`; - } - return text; +function getResultMarker(exitCode) { + return exitCode === 0 ? SUCCESS_MARKER : FAILURE_MARKER; } /** - * Create the top border of a box - * @param {number} width - Box width - * @param {object} style - Box style - * @returns {string} Top border + * Parse isolation metadata from extraLines + * Extracts key-value pairs from lines like "[Isolation] Environment: docker, Mode: attached" + * @param {string[]} extraLines - Extra lines containing isolation info + * @returns {object} Parsed isolation metadata */ -function createTopBorder(width, style) { - if (style.topLeft) { - const lineWidth = width - 2; // Subtract corners - return `${style.topLeft}${createHorizontalLine(lineWidth, style)}${style.topRight}`; +function parseIsolationMetadata(extraLines) { + const metadata = { + isolation: null, + mode: null, + image: null, + container: null, + screen: null, + session: null, + endpoint: null, + user: null, + }; + + for (const line of extraLines) { + // Parse [Isolation] Environment: docker, Mode: attached + const envModeMatch = line.match( + /\[Isolation\] Environment: (\w+), Mode: (\w+)/ + ); + if (envModeMatch) { + metadata.isolation = envModeMatch[1]; + metadata.mode = envModeMatch[2]; + continue; + } + + // Parse [Isolation] Session: name + const sessionMatch = line.match(/\[Isolation\] Session: (.+)/); + if (sessionMatch) { + metadata.session = sessionMatch[1]; + continue; + } + + // Parse [Isolation] Image: name + const imageMatch = line.match(/\[Isolation\] Image: (.+)/); + if (imageMatch) { + metadata.image = imageMatch[1]; + continue; + } + + // Parse [Isolation] Endpoint: user@host + const endpointMatch = line.match(/\[Isolation\] Endpoint: (.+)/); + if (endpointMatch) { + metadata.endpoint = endpointMatch[1]; + continue; + } + + // Parse [Isolation] User: name (isolated) + const userMatch = line.match(/\[Isolation\] User: (\w+)/); + if (userMatch) { + metadata.user = userMatch[1]; + continue; + } } - return createHorizontalLine(width, style); + + return metadata; } /** - * Create the bottom border of a box - * @param {number} width - Box width - * @param {object} style - Box style - * @returns {string} Bottom border + * Generate isolation metadata lines for spine format + * @param {object} metadata - Parsed isolation metadata + * @param {string} [containerOrScreenName] - Container or screen session name + * @returns {string[]} Array of spine-formatted isolation lines */ -function createBottomBorder(width, style) { - if (style.bottomLeft) { - const lineWidth = width - 2; // Subtract corners - return `${style.bottomLeft}${createHorizontalLine(lineWidth, style)}${style.bottomRight}`; +function generateIsolationLines(metadata, containerOrScreenName = null) { + const lines = []; + + if (metadata.isolation) { + lines.push(createSpineLine('isolation', metadata.isolation)); + } + + if (metadata.mode) { + lines.push(createSpineLine('mode', metadata.mode)); + } + + if (metadata.image) { + lines.push(createSpineLine('image', metadata.image)); } - return createHorizontalLine(width, style); + + // Use provided container/screen name or fall back to metadata.session + if (metadata.isolation === 'docker') { + const containerName = containerOrScreenName || metadata.session; + if (containerName) { + lines.push(createSpineLine('container', containerName)); + } + } else if (metadata.isolation === 'screen') { + const screenName = containerOrScreenName || metadata.session; + if (screenName) { + lines.push(createSpineLine('screen', screenName)); + } + } else if (metadata.isolation === 'tmux') { + const tmuxName = containerOrScreenName || metadata.session; + if (tmuxName) { + lines.push(createSpineLine('tmux', tmuxName)); + } + } else if (metadata.isolation === 'ssh') { + if (metadata.endpoint) { + lines.push(createSpineLine('endpoint', metadata.endpoint)); + } + } + + if (metadata.user) { + lines.push(createSpineLine('user', metadata.user)); + } + + return lines; } /** - * Create a start block for command execution + * Create a start block for command execution using status spine format * @param {object} options - Options for the block * @param {string} options.sessionId - Session UUID * @param {string} options.timestamp - Timestamp string * @param {string} options.command - Command being executed - * @param {string[]} [options.extraLines] - Additional lines to show after the command line - * @param {string} [options.style] - Box style name - * @param {number} [options.width] - Box width - * @returns {string} Formatted start block + * @param {string[]} [options.extraLines] - Additional lines with isolation info + * @param {string} [options.style] - Ignored (kept for backward compatibility) + * @param {number} [options.width] - Ignored (kept for backward compatibility) + * @returns {string} Formatted start block in spine format */ function createStartBlock(options) { - const { - sessionId, - timestamp, - command, - extraLines = [], - style: styleName = DEFAULT_STYLE, - width = DEFAULT_WIDTH, - } = options; + const { sessionId, timestamp, command, extraLines = [] } = options; - const style = getBoxStyle(styleName); const lines = []; - lines.push(createTopBorder(width, style)); - lines.push(createBorderedLine(`Session ID: ${sessionId}`, width, style)); - lines.push( - createBorderedLine(`Starting at ${timestamp}: ${command}`, width, style) - ); + // Header: session and start time + lines.push(createSpineLine('session', sessionId)); + lines.push(createSpineLine('start', timestamp)); - // Add extra lines (e.g., isolation info, docker image, etc.) - for (const line of extraLines) { - lines.push(createBorderedLine(line, width, style)); + // Parse and add isolation metadata if present + const metadata = parseIsolationMetadata(extraLines); + + if (metadata.isolation) { + lines.push(createEmptySpineLine()); + lines.push(...generateIsolationLines(metadata)); } - lines.push(createBottomBorder(width, style)); + // Empty spine line before command + lines.push(createEmptySpineLine()); + + // Command line + lines.push(createCommandLine(command)); return lines.join('\n'); } @@ -191,34 +207,46 @@ function createStartBlock(options) { /** * Format duration in seconds with appropriate precision * @param {number} durationMs - Duration in milliseconds - * @returns {string} Formatted duration string + * @returns {string} Formatted duration string (e.g., "0.273s") */ function formatDuration(durationMs) { const seconds = durationMs / 1000; if (seconds < 0.001) { - return '0.001'; + return '0.001s'; } else if (seconds < 10) { // For durations under 10 seconds, show 3 decimal places - return seconds.toFixed(3); + return `${seconds.toFixed(3)}s`; } else if (seconds < 100) { - return seconds.toFixed(2); + return `${seconds.toFixed(2)}s`; } else { - return seconds.toFixed(1); + return `${seconds.toFixed(1)}s`; } } /** - * Create a finish block for command execution + * Create a finish block for command execution using status spine format + * + * Bottom block ordering rules: + * 1. Result marker (✓ or ✗) + * 2. finish timestamp + * 3. duration + * 4. exit code + * 5. (repeated isolation metadata, if any) + * 6. empty spine line + * 7. log path (always second-to-last) + * 8. session ID (always last) + * * @param {object} options - Options for the block * @param {string} options.sessionId - Session UUID * @param {string} options.timestamp - Timestamp string * @param {number} options.exitCode - Exit code * @param {string} options.logPath - Path to log file * @param {number} [options.durationMs] - Duration in milliseconds - * @param {string} [options.resultMessage] - Result message (e.g., "Screen session exited...") - * @param {string} [options.style] - Box style name - * @param {number} [options.width] - Box width - * @returns {string} Formatted finish block + * @param {string} [options.resultMessage] - Result message (ignored in new format) + * @param {string[]} [options.extraLines] - Isolation info for repetition in footer + * @param {string} [options.style] - Ignored (kept for backward compatibility) + * @param {number} [options.width] - Ignored (kept for backward compatibility) + * @returns {string} Formatted finish block in spine format */ function createFinishBlock(options) { const { @@ -227,36 +255,36 @@ function createFinishBlock(options) { exitCode, logPath, durationMs, - resultMessage, - style: styleName = DEFAULT_STYLE, - width = DEFAULT_WIDTH, + extraLines = [], } = options; - const style = getBoxStyle(styleName); const lines = []; - // Format the finished message with optional duration - let finishedMsg = `Finished at ${timestamp}`; + // Result marker appears first in footer (after program output) + lines.push(getResultMarker(exitCode)); + + // Finish metadata + lines.push(createSpineLine('finish', timestamp)); + if (durationMs !== undefined && durationMs !== null) { - finishedMsg += ` in ${formatDuration(durationMs)} seconds`; + lines.push(createSpineLine('duration', formatDuration(durationMs))); } - lines.push(createTopBorder(width, style)); + lines.push(createSpineLine('exit', String(exitCode))); - // Add result message first if provided (e.g., "Docker container exited...") - // Allow overflow so the full message is visible and copyable - if (resultMessage) { - lines.push(createBorderedLine(resultMessage, width, style, true)); + // Repeat isolation metadata if present + const metadata = parseIsolationMetadata(extraLines); + if (metadata.isolation) { + lines.push(createEmptySpineLine()); + lines.push(...generateIsolationLines(metadata)); } - lines.push(createBorderedLine(finishedMsg, width, style)); - lines.push(createBorderedLine(`Exit code: ${exitCode}`, width, style)); - // Allow overflow for log path and session ID so they can be copied completely - lines.push(createBorderedLine(`Log: ${logPath}`, width, style, true)); - lines.push( - createBorderedLine(`Session ID: ${sessionId}`, width, style, true) - ); - lines.push(createBottomBorder(width, style)); + // Empty spine line before final two entries + lines.push(createEmptySpineLine()); + + // Log and session are ALWAYS last (in that order) + lines.push(createSpineLine('log', logPath)); + lines.push(createSpineLine('session', sessionId)); return lines.join('\n'); } @@ -371,17 +399,23 @@ function formatAsNestedLinksNotation(obj, indent = 2, depth = 0) { } module.exports = { - BOX_STYLES, - DEFAULT_STYLE, - DEFAULT_WIDTH, - getBoxStyle, - createHorizontalLine, - createBorderedLine, - createTopBorder, - createBottomBorder, + // Status spine format API + SPINE, + SUCCESS_MARKER, + FAILURE_MARKER, + createSpineLine, + createEmptySpineLine, + createCommandLine, + getResultMarker, + parseIsolationMetadata, + generateIsolationLines, + + // Main block creation functions createStartBlock, createFinishBlock, formatDuration, + + // Links notation utilities escapeForLinksNotation, formatAsNestedLinksNotation, }; diff --git a/js/test/echo-integration.test.js b/js/test/echo-integration.test.js index 62fc754..942af55 100644 --- a/js/test/echo-integration.test.js +++ b/js/test/echo-integration.test.js @@ -55,17 +55,19 @@ function runCli(args, options = {}) { } // Verify output contains expected structure for attached modes (shows finish block) +// Uses status spine format only function verifyAttachedModeOutput(output, expectedOutputText = 'hi') { - // Should contain start block + // Should contain start block with spine format assert.ok( - output.includes('╭'), - 'Output should contain start block top border' + output.includes('│ session') && output.includes('│ start'), + 'Output should contain start block with spine format' ); - assert.ok(output.includes('╰'), 'Output should contain block bottom border'); - assert.ok(output.includes('Session ID:'), 'Output should contain Session ID'); + + // Should contain session info + assert.ok(output.includes('│ session'), 'Output should contain session ID'); assert.ok( - output.includes('Starting at'), - 'Output should contain Starting at timestamp' + output.includes('│ start'), + 'Output should contain start timestamp' ); // Should contain command output @@ -74,13 +76,19 @@ function verifyAttachedModeOutput(output, expectedOutputText = 'hi') { `Output should contain the "${expectedOutputText}" command output` ); - // Should contain finish block (for attached modes) + // Should contain finish block + assert.ok( + output.includes('│ finish'), + 'Output should contain finish timestamp' + ); + assert.ok(output.includes('│ exit'), 'Output should contain exit code'); + assert.ok(output.includes('│ log'), 'Output should contain log path'); + + // Should contain result marker assert.ok( - output.includes('Finished at'), - 'Output should contain Finished at timestamp' + output.includes('✓') || output.includes('✗'), + 'Output should contain result marker (✓ or ✗)' ); - assert.ok(output.includes('Exit code:'), 'Output should contain Exit code'); - assert.ok(output.includes('Log:'), 'Output should contain Log path'); // Verify there are empty lines around output (structure check) const lines = output.split('\n'); @@ -89,66 +97,70 @@ function verifyAttachedModeOutput(output, expectedOutputText = 'hi') { if (outputIndex > 0) { // Check for empty line before output const lineBefore = lines[outputIndex - 1]; - // Line before should be empty or end of start block + // Line before should be empty assert.ok( - lineBefore.trim() === '' || lineBefore.includes('╰'), - `Expected empty line or block end before output, got: "${lineBefore}"` + lineBefore.trim() === '', + `Expected empty line before output, got: "${lineBefore}"` ); } if (outputIndex >= 0 && outputIndex < lines.length - 1) { // Check for empty line after output const lineAfter = lines[outputIndex + 1]; - // Line after should be empty or start of finish block + // Line after should be empty or result marker (✓/✗) assert.ok( - lineAfter.trim() === '' || lineAfter.includes('╭'), - `Expected empty line or block start after output, got: "${lineAfter}"` + lineAfter.trim() === '' || + lineAfter.includes('✓') || + lineAfter.includes('✗'), + `Expected empty line or result marker after output, got: "${lineAfter}"` ); } } // Verify output for detached modes (only start block, no finish block) +// Uses status spine format only function verifyDetachedModeOutput(output) { - // Should contain start block + // Should contain start block with spine format assert.ok( - output.includes('╭'), - 'Output should contain start block top border' + output.includes('│ session') && output.includes('│ start'), + 'Output should contain start block with spine format' ); - assert.ok(output.includes('╰'), 'Output should contain block bottom border'); - assert.ok(output.includes('Session ID:'), 'Output should contain Session ID'); + + // Should contain session info + assert.ok(output.includes('│ session'), 'Output should contain session ID'); assert.ok( - output.includes('Starting at'), - 'Output should contain Starting at timestamp' + output.includes('│ start'), + 'Output should contain start timestamp' ); // Should show detached mode info assert.ok( - output.includes('Mode: detached') || output.includes('Reattach with'), + output.includes('│ mode detached') || output.includes('Reattach with'), 'Output should indicate detached mode or show reattach instructions' ); } // Verify log path is not truncated +// Uses status spine format only function verifyLogPathNotTruncated(output) { - const logMatch = output.match(/Log: (.+)/); - assert.ok(logMatch, 'Should have Log line'); + const logMatch = output.match(/│ log\s+(.+)/); + assert.ok(logMatch, 'Should have log line with spine format'); const logPath = logMatch[1].trim(); - // Remove trailing box border character if present - const cleanPath = logPath.replace(/\s*│\s*$/, '').trim(); // Log path should end with .log extension assert.ok( - cleanPath.endsWith('.log'), - `Log path should end with .log extension, got: "${cleanPath}"` + logPath.endsWith('.log'), + `Log path should end with .log extension, got: "${logPath}"` ); } // Verify session ID is a valid UUID +// Uses status spine format only function verifySessionId(output) { - const sessionMatches = output.match(/Session ID: ([a-f0-9-]+)/g); + const sessionMatches = output.match(/│ session\s+([a-f0-9-]+)/g); assert.ok( sessionMatches && sessionMatches.length >= 1, - 'Should have Session ID' + 'Should have session ID with spine format' ); // Extract UUID from first match @@ -198,11 +210,11 @@ describe('Echo Integration Tests - Issue #55', () => { assert.ok(result.success, 'Command should succeed'); // The pattern should be: - // [start block] + // [start block with spine] // [empty line] // hi // [empty line] - // [finish block] + // [result marker and finish block] const lines = result.output.split('\n'); let foundHi = false; @@ -218,21 +230,18 @@ describe('Echo Integration Tests - Issue #55', () => { assert.ok(foundHi, 'Should find "hi" output on its own line'); - // Check line before hi + // Check line before hi - should be empty if (hiIndex > 0) { const prevLine = lines[hiIndex - 1].trim(); - assert.ok( - prevLine === '' || prevLine.startsWith('╰'), - `Line before "hi" should be empty or end of start block` - ); + assert.ok(prevLine === '', `Line before "hi" should be empty`); } - // Check line after hi + // Check line after hi - should be empty or result marker if (hiIndex < lines.length - 1) { const nextLine = lines[hiIndex + 1].trim(); assert.ok( - nextLine === '' || nextLine.startsWith('╭'), - `Line after "hi" should be empty or start of finish block` + nextLine === '' || nextLine.includes('✓') || nextLine.includes('✗'), + `Line after "hi" should be empty or result marker` ); } @@ -268,13 +277,13 @@ describe('Echo Integration Tests - Issue #55', () => { verifyLogPathNotTruncated(result.output); verifySessionId(result.output); - // Should show isolation info + // Should show isolation info with spine format assert.ok( - result.output.includes('[Isolation] Environment: screen'), + result.output.includes('│ isolation screen'), 'Should show screen isolation info' ); assert.ok( - result.output.includes('Mode: attached'), + result.output.includes('│ mode attached'), 'Should show attached mode' ); @@ -305,13 +314,12 @@ describe('Echo Integration Tests - Issue #55', () => { assert.ok(result.success, 'Command should succeed'); assert.ok( - result.output.includes('Exit code: 0'), - 'Should show exit code 0' + result.output.includes('│ exit 0'), + 'Should show exit code 0 with spine format' ); assert.ok( - result.output.includes('exited with code 0') || - result.output.includes('Finished at'), - 'Should show completion info' + result.output.includes('│ finish'), + 'Should show finish timestamp with spine format' ); console.log( @@ -336,13 +344,13 @@ describe('Echo Integration Tests - Issue #55', () => { verifyDetachedModeOutput(result.output); verifySessionId(result.output); - // Should show screen isolation info with detached mode + // Should show screen isolation info with detached mode (spine format) assert.ok( - result.output.includes('[Isolation] Environment: screen'), + result.output.includes('│ isolation screen'), 'Should show screen isolation info' ); assert.ok( - result.output.includes('Mode: detached'), + result.output.includes('│ mode detached'), 'Should show detached mode' ); @@ -358,7 +366,7 @@ describe('Echo Integration Tests - Issue #55', () => { ); }); - it('should provide reattach instructions in detached screen mode', () => { + it('should show session name in detached screen mode for reattaching', () => { const sessionName = `test-screen-reattach-${Date.now()}`; const result = runCli( `--isolated screen -d --session ${sessionName} -- echo hi`, @@ -366,10 +374,11 @@ describe('Echo Integration Tests - Issue #55', () => { ); assert.ok(result.success, 'Command should succeed'); + // Session name is shown in spine format, user can use it to reattach with: screen -r assert.ok( - result.output.includes('Reattach with') || - result.output.includes('screen -r'), - 'Should show reattach instructions' + result.output.includes(`│ session`) && + result.output.includes(sessionName.substring(0, 10)), + 'Should show session name for reattaching' ); // Cleanup @@ -380,7 +389,7 @@ describe('Echo Integration Tests - Issue #55', () => { } console.log( - ' ✓ Screen isolation (detached): reattach instructions displayed' + ' ✓ Screen isolation (detached): session name displayed for reattaching' ); }); }); @@ -410,7 +419,7 @@ describe('Echo Integration Tests - Issue #55', () => { if (result.success) { assert.ok(result.output.includes('hi'), 'Output should contain "hi"'); assert.ok( - result.output.includes('[Isolation] Environment: tmux'), + result.output.includes('│ isolation tmux'), 'Should show tmux isolation info' ); console.log(' ✓ Tmux isolation (attached): echo hi works correctly'); @@ -440,13 +449,13 @@ describe('Echo Integration Tests - Issue #55', () => { verifyDetachedModeOutput(result.output); verifySessionId(result.output); - // Should show tmux isolation info + // Should show tmux isolation info (spine format) assert.ok( - result.output.includes('[Isolation] Environment: tmux'), + result.output.includes('│ isolation tmux'), 'Should show tmux isolation info' ); assert.ok( - result.output.includes('Mode: detached'), + result.output.includes('│ mode detached'), 'Should show detached mode' ); @@ -460,7 +469,7 @@ describe('Echo Integration Tests - Issue #55', () => { console.log(' ✓ Tmux isolation (detached): echo hi starts correctly'); }); - it('should provide reattach instructions in detached tmux mode', () => { + it('should show session name in detached tmux mode for reattaching', () => { const sessionName = `test-tmux-reattach-${Date.now()}`; const result = runCli( `--isolated tmux -d --session ${sessionName} -- echo hi`, @@ -468,10 +477,11 @@ describe('Echo Integration Tests - Issue #55', () => { ); assert.ok(result.success, 'Command should succeed'); + // Session name is shown in spine format, user can use it to reattach with: tmux attach -t assert.ok( - result.output.includes('Reattach with') || - result.output.includes('tmux attach'), - 'Should show reattach instructions' + result.output.includes(`│ session`) && + result.output.includes(sessionName.substring(0, 10)), + 'Should show session name for reattaching' ); // Cleanup @@ -482,7 +492,7 @@ describe('Echo Integration Tests - Issue #55', () => { } console.log( - ' ✓ Tmux isolation (detached): reattach instructions displayed' + ' ✓ Tmux isolation (detached): session name displayed for reattaching' ); }); @@ -542,13 +552,13 @@ describe('Echo Integration Tests - Issue #55', () => { verifyLogPathNotTruncated(result.output); verifySessionId(result.output); - // Should show docker isolation info + // Should show docker isolation info (spine format) assert.ok( - result.output.includes('[Isolation] Environment: docker'), + result.output.includes('│ isolation docker'), 'Should show docker isolation info' ); assert.ok( - result.output.includes('[Isolation] Image: alpine:latest'), + result.output.includes('│ image alpine'), 'Should show docker image info' ); @@ -583,13 +593,12 @@ describe('Echo Integration Tests - Issue #55', () => { assert.ok(result.success, 'Command should succeed'); assert.ok( - result.output.includes('Exit code: 0'), - 'Should show exit code 0' + result.output.includes('│ exit 0'), + 'Should show exit code 0 with spine format' ); assert.ok( - result.output.includes('exited with code 0') || - result.output.includes('Finished at'), - 'Should show completion info' + result.output.includes('│ finish'), + 'Should show finish timestamp with spine format' ); console.log( @@ -614,13 +623,13 @@ describe('Echo Integration Tests - Issue #55', () => { verifyDetachedModeOutput(result.output); verifySessionId(result.output); - // Should show docker isolation info with detached mode + // Should show docker isolation info with detached mode (spine format) assert.ok( - result.output.includes('[Isolation] Environment: docker'), + result.output.includes('│ isolation docker'), 'Should show docker isolation info' ); assert.ok( - result.output.includes('Mode: detached'), + result.output.includes('│ mode detached'), 'Should show detached mode' ); @@ -636,7 +645,7 @@ describe('Echo Integration Tests - Issue #55', () => { ); }); - it('should provide reattach instructions in detached docker mode', () => { + it('should show session/container name in detached docker mode for reattaching', () => { const containerName = `test-docker-reattach-${Date.now()}`; const result = runCli( `--isolated docker -d --image alpine:latest --session ${containerName} -- echo hi`, @@ -644,11 +653,11 @@ describe('Echo Integration Tests - Issue #55', () => { ); assert.ok(result.success, 'Command should succeed'); + // Session/container info is shown in spine format, user can use it to reattach with: docker attach assert.ok( - result.output.includes('Reattach with') || - result.output.includes('docker attach') || - result.output.includes('docker logs'), - 'Should show reattach instructions' + result.output.includes(`│ session`) && + result.output.includes(containerName.substring(0, 10)), + 'Should show session/container name for reattaching' ); // Cleanup @@ -659,7 +668,7 @@ describe('Echo Integration Tests - Issue #55', () => { } console.log( - ' ✓ Docker isolation (detached): reattach instructions displayed' + ' ✓ Docker isolation (detached): session/container name displayed for reattaching' ); }); @@ -696,14 +705,14 @@ describe('Echo Integration Tests - Issue #55', () => { assert.ok(result.success, 'Command should succeed'); - // Get the log path line - const logMatch = result.output.match(/Log: (.+)/); - assert.ok(logMatch, 'Should have Log line'); + // Get the log path line with spine format + const logMatch = result.output.match(/│ log\s+(.+)/); + assert.ok(logMatch, 'Should have log line with spine format'); - const logLine = logMatch[0]; + const logPath = logMatch[1]; // Log line should contain full path ending in .log assert.ok( - logLine.includes('.log'), + logPath.includes('.log'), 'Log path should be complete and not truncated' ); @@ -716,10 +725,10 @@ describe('Echo Integration Tests - Issue #55', () => { assert.ok(result.success, 'Command should succeed'); // Get session IDs from output (should appear twice: start and finish block) - const sessionMatches = result.output.match(/Session ID: ([a-f0-9-]+)/g); + const sessionMatches = result.output.match(/│ session\s+([a-f0-9-]+)/g); assert.ok( sessionMatches && sessionMatches.length >= 2, - 'Should have Session ID in both blocks' + 'Should have session ID in both blocks with spine format' ); // Extract UUID from first match @@ -736,8 +745,8 @@ describe('Echo Integration Tests - Issue #55', () => { assert.ok(result.success, 'Command should succeed'); assert.ok( - result.output.includes('Exit code: 0'), - 'Should show "Exit code: 0" for successful command' + result.output.includes('│ exit 0'), + 'Should show exit code with spine format' ); console.log(' ✓ Exit code formatting is consistent'); @@ -748,8 +757,8 @@ describe('Echo Integration Tests - Issue #55', () => { assert.ok(result.success, 'Command should succeed'); assert.ok( - result.output.includes('seconds') || result.output.includes('in 0.'), - 'Should include timing information' + result.output.includes('│ duration'), + 'Should include duration with spine format' ); console.log(' ✓ Timing information is present in finish block'); diff --git a/js/test/output-blocks.test.js b/js/test/output-blocks.test.js index 7e3681d..1c97b5c 100644 --- a/js/test/output-blocks.test.js +++ b/js/test/output-blocks.test.js @@ -1,84 +1,181 @@ /** * Tests for output-blocks module + * + * Tests the "status spine" format: width-independent, lossless output */ const { describe, it, expect } = require('bun:test'); const { - BOX_STYLES, - DEFAULT_STYLE, - DEFAULT_WIDTH, - getBoxStyle, + // Spine format exports + SPINE, + SUCCESS_MARKER, + FAILURE_MARKER, + createSpineLine, + createEmptySpineLine, + createCommandLine, + getResultMarker, + parseIsolationMetadata, + generateIsolationLines, + + // Main block functions createStartBlock, createFinishBlock, formatDuration, + + // Links notation utilities escapeForLinksNotation, formatAsNestedLinksNotation, } = require('../src/lib/output-blocks'); describe('output-blocks module', () => { - describe('BOX_STYLES', () => { - it('should have all expected styles', () => { - expect(BOX_STYLES).toHaveProperty('rounded'); - expect(BOX_STYLES).toHaveProperty('heavy'); - expect(BOX_STYLES).toHaveProperty('double'); - expect(BOX_STYLES).toHaveProperty('simple'); - expect(BOX_STYLES).toHaveProperty('ascii'); + describe('spine format constants', () => { + it('should export spine character', () => { + expect(SPINE).toBe('│'); + }); + + it('should export result markers', () => { + expect(SUCCESS_MARKER).toBe('✓'); + expect(FAILURE_MARKER).toBe('✗'); + }); + }); + + describe('createSpineLine', () => { + it('should create a line with spine prefix and padded label', () => { + const line = createSpineLine('session', 'abc-123'); + expect(line).toBe('│ session abc-123'); + }); + + it('should pad labels to 10 characters', () => { + const shortLabel = createSpineLine('exit', '0'); + expect(shortLabel).toBe('│ exit 0'); + + const longLabel = createSpineLine('isolation', 'docker'); + expect(longLabel).toBe('│ isolation docker'); + }); + }); + + describe('createEmptySpineLine', () => { + it('should create just the spine character', () => { + expect(createEmptySpineLine()).toBe('│'); + }); + }); + + describe('createCommandLine', () => { + it('should create a line with $ prefix', () => { + expect(createCommandLine('echo hi')).toBe('$ echo hi'); + }); + }); + + describe('getResultMarker', () => { + it('should return success marker for exit code 0', () => { + expect(getResultMarker(0)).toBe('✓'); }); - it('should have correct rounded style characters', () => { - expect(BOX_STYLES.rounded.topLeft).toBe('╭'); - expect(BOX_STYLES.rounded.topRight).toBe('╮'); - expect(BOX_STYLES.rounded.bottomLeft).toBe('╰'); - expect(BOX_STYLES.rounded.bottomRight).toBe('╯'); + it('should return failure marker for non-zero exit codes', () => { + expect(getResultMarker(1)).toBe('✗'); + expect(getResultMarker(127)).toBe('✗'); + expect(getResultMarker(-1)).toBe('✗'); }); }); - describe('getBoxStyle', () => { - it('should return rounded style by default', () => { - const style = getBoxStyle(); - expect(style).toEqual(BOX_STYLES.rounded); + describe('parseIsolationMetadata', () => { + it('should parse environment and mode', () => { + const extraLines = ['[Isolation] Environment: docker, Mode: attached']; + const metadata = parseIsolationMetadata(extraLines); + + expect(metadata.isolation).toBe('docker'); + expect(metadata.mode).toBe('attached'); + }); + + it('should parse session name', () => { + const extraLines = ['[Isolation] Session: my-session']; + const metadata = parseIsolationMetadata(extraLines); + + expect(metadata.session).toBe('my-session'); + }); + + it('should parse docker image', () => { + const extraLines = ['[Isolation] Image: ubuntu:latest']; + const metadata = parseIsolationMetadata(extraLines); + + expect(metadata.image).toBe('ubuntu:latest'); + }); + + it('should parse all fields together', () => { + const extraLines = [ + '[Isolation] Environment: docker, Mode: detached', + '[Isolation] Session: docker-container-123', + '[Isolation] Image: node:18-alpine', + '[Isolation] User: testuser (isolated)', + ]; + const metadata = parseIsolationMetadata(extraLines); + + expect(metadata.isolation).toBe('docker'); + expect(metadata.mode).toBe('detached'); + expect(metadata.session).toBe('docker-container-123'); + expect(metadata.image).toBe('node:18-alpine'); + expect(metadata.user).toBe('testuser'); }); + }); - it('should return requested style', () => { - expect(getBoxStyle('heavy')).toEqual(BOX_STYLES.heavy); - expect(getBoxStyle('double')).toEqual(BOX_STYLES.double); - expect(getBoxStyle('ascii')).toEqual(BOX_STYLES.ascii); + describe('generateIsolationLines', () => { + it('should generate lines for docker isolation', () => { + const metadata = { + isolation: 'docker', + mode: 'attached', + image: 'ubuntu', + session: 'docker-container-1', + }; + const lines = generateIsolationLines(metadata); + + expect(lines).toContain('│ isolation docker'); + expect(lines).toContain('│ mode attached'); + expect(lines).toContain('│ image ubuntu'); + expect(lines).toContain('│ container docker-container-1'); }); - it('should return rounded for unknown style', () => { - const style = getBoxStyle('unknown'); - expect(style).toEqual(BOX_STYLES.rounded); + it('should generate lines for screen isolation', () => { + const metadata = { + isolation: 'screen', + mode: 'attached', + session: 'screen-session-1', + }; + const lines = generateIsolationLines(metadata); + + expect(lines).toContain('│ isolation screen'); + expect(lines).toContain('│ mode attached'); + expect(lines).toContain('│ screen screen-session-1'); }); }); describe('createStartBlock', () => { - it('should create a start block with session ID', () => { + it('should create a start block with session and timestamp', () => { const block = createStartBlock({ sessionId: 'test-uuid-1234', timestamp: '2025-01-01 00:00:00', command: 'echo hello', }); - expect(block).toContain('╭'); - expect(block).toContain('╰'); - expect(block).toContain('Session ID: test-uuid-1234'); - expect(block).toContain('Starting at 2025-01-01 00:00:00: echo hello'); + expect(block).toContain('│ session test-uuid-1234'); + expect(block).toContain('│ start 2025-01-01 00:00:00'); + expect(block).toContain('$ echo hello'); }); - it('should use specified style', () => { + it('should include empty spine line before command', () => { const block = createStartBlock({ sessionId: 'test-uuid', timestamp: '2025-01-01 00:00:00', command: 'echo hello', - style: 'ascii', }); - expect(block).toContain('+'); - expect(block).toContain('-'); + const lines = block.split('\n'); + // Last line should be command, second-to-last should be empty spine + expect(lines[lines.length - 1]).toBe('$ echo hello'); + expect(lines[lines.length - 2]).toBe('│'); }); - it('should include extra lines when provided', () => { + it('should include isolation metadata when provided', () => { const block = createStartBlock({ sessionId: 'test-uuid', timestamp: '2025-01-01 00:00:00', @@ -89,18 +186,33 @@ describe('output-blocks module', () => { ], }); - expect(block).toContain('╭'); - expect(block).toContain('╰'); - expect(block).toContain('Session ID: test-uuid'); - expect(block).toContain( - '[Isolation] Environment: screen, Mode: attached' - ); - expect(block).toContain('[Isolation] Session: my-session'); + expect(block).toContain('│ session test-uuid'); + expect(block).toContain('│ isolation screen'); + expect(block).toContain('│ mode attached'); + expect(block).toContain('│ screen my-session'); + expect(block).toContain('$ echo hello'); + }); + + it('should include docker metadata correctly', () => { + const block = createStartBlock({ + sessionId: 'test-uuid', + timestamp: '2025-01-01 00:00:00', + command: 'echo hello', + extraLines: [ + '[Isolation] Environment: docker, Mode: attached', + '[Isolation] Image: ubuntu', + '[Isolation] Session: docker-container-123', + ], + }); + + expect(block).toContain('│ isolation docker'); + expect(block).toContain('│ image ubuntu'); + expect(block).toContain('│ container docker-container-123'); }); }); describe('createFinishBlock', () => { - it('should create a finish block with session ID and exit code', () => { + it('should create a finish block with result marker and metadata', () => { const block = createFinishBlock({ sessionId: 'test-uuid-1234', timestamp: '2025-01-01 00:00:01', @@ -109,17 +221,28 @@ describe('output-blocks module', () => { durationMs: 17, }); - expect(block).toContain('╭'); - expect(block).toContain('╰'); - expect(block).toContain('Session ID: test-uuid-1234'); - expect(block).toContain( - 'Finished at 2025-01-01 00:00:01 in 0.017 seconds' - ); - expect(block).toContain('Exit code: 0'); - expect(block).toContain('Log: /tmp/test.log'); + expect(block).toContain('✓'); + expect(block).toContain('│ finish 2025-01-01 00:00:01'); + expect(block).toContain('│ duration 0.017s'); + expect(block).toContain('│ exit 0'); + expect(block).toContain('│ log /tmp/test.log'); + expect(block).toContain('│ session test-uuid-1234'); + }); + + it('should use failure marker for non-zero exit code', () => { + const block = createFinishBlock({ + sessionId: 'test-uuid', + timestamp: '2025-01-01 00:00:01', + exitCode: 1, + logPath: '/tmp/test.log', + durationMs: 100, + }); + + expect(block).toContain('✗'); + expect(block).toContain('│ exit 1'); }); - it('should create a finish block without duration when not provided', () => { + it('should omit duration when not provided', () => { const block = createFinishBlock({ sessionId: 'test-uuid-1234', timestamp: '2025-01-01 00:00:01', @@ -127,49 +250,67 @@ describe('output-blocks module', () => { logPath: '/tmp/test.log', }); - expect(block).toContain('Finished at 2025-01-01 00:00:01'); - expect(block).not.toContain('seconds'); + expect(block).not.toContain('duration'); + expect(block).toContain('│ finish 2025-01-01 00:00:01'); }); - it('should include result message when provided', () => { + it('should repeat isolation metadata in footer', () => { const block = createFinishBlock({ - sessionId: 'test-uuid-1234', + sessionId: 'test-uuid', timestamp: '2025-01-01 00:00:01', exitCode: 0, logPath: '/tmp/test.log', durationMs: 17, - resultMessage: 'Screen session "my-session" exited with code 0', + extraLines: [ + '[Isolation] Environment: docker, Mode: attached', + '[Isolation] Image: ubuntu', + '[Isolation] Session: docker-container-123', + ], + }); + + expect(block).toContain('│ isolation docker'); + expect(block).toContain('│ mode attached'); + expect(block).toContain('│ image ubuntu'); + expect(block).toContain('│ container docker-container-123'); + }); + + it('should have log and session as the last two lines', () => { + const block = createFinishBlock({ + sessionId: 'test-uuid', + timestamp: '2025-01-01 00:00:01', + exitCode: 0, + logPath: '/tmp/test.log', + durationMs: 17, + extraLines: [ + '[Isolation] Environment: screen, Mode: attached', + '[Isolation] Session: my-screen', + ], }); - expect(block).toContain('╭'); - expect(block).toContain('╰'); - expect(block).toContain('Screen session'); - expect(block).toContain('exited with code 0'); - expect(block).toContain('Session ID: test-uuid-1234'); - expect(block).toContain( - 'Finished at 2025-01-01 00:00:01 in 0.017 seconds' - ); + const lines = block.split('\n'); + expect(lines[lines.length - 1]).toBe('│ session test-uuid'); + expect(lines[lines.length - 2]).toBe('│ log /tmp/test.log'); }); }); describe('formatDuration', () => { it('should format very small durations', () => { - expect(formatDuration(0.5)).toBe('0.001'); + expect(formatDuration(0.5)).toBe('0.001s'); }); it('should format millisecond durations', () => { - expect(formatDuration(17)).toBe('0.017'); - expect(formatDuration(500)).toBe('0.500'); + expect(formatDuration(17)).toBe('0.017s'); + expect(formatDuration(500)).toBe('0.500s'); }); it('should format second durations', () => { - expect(formatDuration(1000)).toBe('1.000'); - expect(formatDuration(5678)).toBe('5.678'); + expect(formatDuration(1000)).toBe('1.000s'); + expect(formatDuration(5678)).toBe('5.678s'); }); it('should format longer durations with less precision', () => { - expect(formatDuration(12345)).toBe('12.35'); - expect(formatDuration(123456)).toBe('123.5'); + expect(formatDuration(12345)).toBe('12.35s'); + expect(formatDuration(123456)).toBe('123.5s'); }); }); diff --git a/js/test/ssh-integration.test.js b/js/test/ssh-integration.test.js index 5585f8b..5572fc3 100644 --- a/js/test/ssh-integration.test.js +++ b/js/test/ssh-integration.test.js @@ -315,13 +315,14 @@ describe('SSH CLI Integration', () => { console.log(` CLI exit code: ${result.status}`); if (result.status === 0) { + // Check for isolation info with spine format assert.ok( - result.stdout.includes('[Isolation]'), - 'Should show isolation info' + result.stdout.includes('│ isolation ssh'), + 'Should show SSH isolation info with spine format' ); assert.ok( - result.stdout.includes('ssh') || result.stdout.includes('SSH'), - 'Should mention SSH' + result.stdout.includes('│ endpoint') || result.stdout.includes('ssh'), + 'Should mention SSH or endpoint' ); } }); diff --git a/rust/changelog.d/64.md b/rust/changelog.d/64.md new file mode 100644 index 0000000..b65c89e --- /dev/null +++ b/rust/changelog.d/64.md @@ -0,0 +1,9 @@ +feat: Replace fixed-width box output with status spine format + +- Replaced box-style output format with spine format using `|`, `$`, `✓`, and `✗` symbols +- Removed all legacy BoxStyle, get_box_style(), and box-drawing functions +- Added new spine format functions: create_spine_line, create_empty_spine_line, create_command_line +- Added get_result_marker function returning success/failure symbols +- Added IsolationMetadata struct and parsing for isolation environment info +- Updated create_start_block and create_finish_block to use spine format +- Format is width-independent, lossless, and portable across all terminal environments diff --git a/rust/src/bin/main.rs b/rust/src/bin/main.rs index 4617c3c..9eceb36 100644 --- a/rust/src/bin/main.rs +++ b/rust/src/bin/main.rs @@ -614,6 +614,8 @@ fn run_with_isolation( // Add empty line before finish block for visual separation println!(); let duration_ms = start_instant.elapsed().as_secs_f64() * 1000.0; + // Convert extra_lines to &str references for the finish block + let extra_lines_refs: Vec<&str> = extra_lines.iter().map(|s| s.as_str()).collect(); println!( "{}", create_finish_block(&FinishBlockOptions { @@ -623,6 +625,7 @@ fn run_with_isolation( log_path: &log_file_path.to_string_lossy(), duration_ms: Some(duration_ms), result_message: Some(&result.message), + extra_lines: Some(extra_lines_refs), style: None, width: None, }) @@ -797,6 +800,7 @@ fn run_direct( log_path: &log_file_path.to_string_lossy(), duration_ms: Some(duration_ms), result_message: None, + extra_lines: None, style: None, width: None, }) @@ -884,6 +888,7 @@ fn run_direct( log_path: &log_file_path.to_string_lossy(), duration_ms: Some(duration_ms), result_message: None, + extra_lines: None, style: None, width: None, }) diff --git a/rust/src/lib/mod.rs b/rust/src/lib/mod.rs index 02cc031..404e651 100644 --- a/rust/src/lib/mod.rs +++ b/rust/src/lib/mod.rs @@ -28,9 +28,24 @@ pub use isolation::{ IsolationResult, LogHeaderParams, }; pub use output_blocks::{ - create_finish_block, create_start_block, escape_for_links_notation, format_duration, - format_value_for_links_notation, get_box_style, BoxStyle, FinishBlockOptions, - StartBlockOptions, DEFAULT_WIDTH, + // Status spine format API + create_command_line, + create_empty_spine_line, + create_finish_block, + create_spine_line, + create_start_block, + escape_for_links_notation, + format_duration, + format_value_for_links_notation, + generate_isolation_lines, + get_result_marker, + parse_isolation_metadata, + FinishBlockOptions, + IsolationMetadata, + StartBlockOptions, + FAILURE_MARKER, + SPINE, + SUCCESS_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 5df89a2..03361df 100644 --- a/rust/src/lib/output_blocks.rs +++ b/rust/src/lib/output_blocks.rs @@ -1,156 +1,146 @@ //! Output formatting utilities for nicely rendered command blocks //! -//! Provides various styles for start/finish blocks to distinguish -//! command output from the $ wrapper output. +//! Provides "status spine" format: a width-independent, lossless output format +//! that works in TTY, tmux, SSH, CI, and logs. //! -//! Available styles: -//! - `rounded` (default): Rounded unicode box borders (╭─╮ ╰─╯) -//! - `heavy`: Heavy unicode box borders (┏━┓ ┗━┛) -//! - `double`: Double line box borders (╔═╗ ╚═╝) -//! - `simple`: Simple dash lines (────────) -//! - `ascii`: Pure ASCII compatible (+--------+) - -use std::env; - -/// Box drawing characters for different styles -#[derive(Clone, Copy)] -pub struct BoxStyle { - pub top_left: &'static str, - pub top_right: &'static str, - pub bottom_left: &'static str, - pub bottom_right: &'static str, - pub horizontal: &'static str, - pub vertical: &'static str, -} +//! Core concepts: +//! - `│` prefix → tool metadata +//! - `$` → executed command +//! - No prefix → program output (stdout/stderr) +//! - Result marker (`✓` / `✗`) appears after output + +use regex::Regex; + +/// Metadata spine character +pub const SPINE: &str = "│"; -impl BoxStyle { - pub const ROUNDED: BoxStyle = BoxStyle { - top_left: "╭", - top_right: "╮", - bottom_left: "╰", - bottom_right: "╯", - horizontal: "─", - vertical: "│", - }; - - pub const HEAVY: BoxStyle = BoxStyle { - top_left: "┏", - top_right: "┓", - bottom_left: "┗", - bottom_right: "┛", - horizontal: "━", - vertical: "┃", - }; - - pub const DOUBLE: BoxStyle = BoxStyle { - top_left: "╔", - top_right: "╗", - bottom_left: "╚", - bottom_right: "╝", - horizontal: "═", - vertical: "║", - }; - - pub const SIMPLE: BoxStyle = BoxStyle { - top_left: "", - top_right: "", - bottom_left: "", - bottom_right: "", - horizontal: "─", - vertical: "", - }; - - pub const ASCII: BoxStyle = BoxStyle { - top_left: "+", - top_right: "+", - bottom_left: "+", - bottom_right: "+", - horizontal: "-", - vertical: "|", - }; +/// Success result marker +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 { + // Pad label to 10 characters for alignment + format!("{} {:10}{}", SPINE, label, value) } -/// Default block width -pub const DEFAULT_WIDTH: usize = 60; +/// Create an empty spine line (just the spine character) +pub fn create_empty_spine_line() -> String { + SPINE.to_string() +} -/// Get the box style configuration from environment or default -pub fn get_box_style(style_name: Option<&str>) -> BoxStyle { - let env_style = env::var("START_OUTPUT_STYLE").ok(); - let name = style_name.or(env_style.as_deref()).unwrap_or("rounded"); +/// Create a command line with $ prefix +pub fn create_command_line(command: &str) -> String { + format!("$ {}", command) +} - match name { - "heavy" => BoxStyle::HEAVY, - "double" => BoxStyle::DOUBLE, - "simple" => BoxStyle::SIMPLE, - "ascii" => BoxStyle::ASCII, - _ => BoxStyle::ROUNDED, +/// Get the result marker based on exit code +pub fn get_result_marker(exit_code: i32) -> &'static str { + if exit_code == 0 { + SUCCESS_MARKER + } else { + FAILURE_MARKER } } -/// Create a horizontal line -fn create_horizontal_line(width: usize, style: &BoxStyle) -> String { - style.horizontal.repeat(width) +/// Parsed isolation metadata +#[derive(Default)] +pub struct IsolationMetadata { + pub isolation: Option, + pub mode: Option, + pub image: Option, + pub session: Option, + pub endpoint: Option, + pub user: Option, } -/// Pad or truncate text to fit a specific width -/// If allow_overflow is true, long text is not truncated (for copyable content) -fn pad_text(text: &str, width: usize, allow_overflow: bool) -> String { - if text.len() >= width { - // If overflow is allowed, return text as-is (for copyable content like paths) - if allow_overflow { - return text.to_string(); +/// Parse isolation metadata from extra lines +pub fn parse_isolation_metadata(extra_lines: &[&str]) -> IsolationMetadata { + let mut metadata = IsolationMetadata::default(); + + let env_mode_re = Regex::new(r"\[Isolation\] Environment: (\w+), Mode: (\w+)").unwrap(); + let session_re = Regex::new(r"\[Isolation\] Session: (.+)").unwrap(); + let image_re = Regex::new(r"\[Isolation\] Image: (.+)").unwrap(); + let endpoint_re = Regex::new(r"\[Isolation\] Endpoint: (.+)").unwrap(); + let user_re = Regex::new(r"\[Isolation\] User: (\w+)").unwrap(); + + for line in extra_lines { + if let Some(caps) = env_mode_re.captures(line) { + metadata.isolation = Some(caps[1].to_string()); + metadata.mode = Some(caps[2].to_string()); + continue; + } + + if let Some(caps) = session_re.captures(line) { + metadata.session = Some(caps[1].to_string()); + continue; + } + + if let Some(caps) = image_re.captures(line) { + metadata.image = Some(caps[1].to_string()); + continue; + } + + if let Some(caps) = endpoint_re.captures(line) { + metadata.endpoint = Some(caps[1].to_string()); + continue; + } + + if let Some(caps) = user_re.captures(line) { + metadata.user = Some(caps[1].to_string()); } - text[..width].to_string() - } else { - format!("{}{}", text, " ".repeat(width - text.len())) } + + metadata } -/// Create a bordered line with text -/// If allow_overflow is true, long text is not truncated (for copyable content) -fn create_bordered_line( - text: &str, - width: usize, - style: &BoxStyle, - allow_overflow: bool, -) -> String { - if !style.vertical.is_empty() { - let inner_width = width.saturating_sub(4); // 2 for borders, 2 for padding - let padded_text = pad_text(text, inner_width, allow_overflow); - format!("{} {} {}", style.vertical, padded_text, style.vertical) - } else { - text.to_string() +/// Generate isolation metadata lines for spine format +pub fn generate_isolation_lines( + metadata: &IsolationMetadata, + container_or_screen_name: Option<&str>, +) -> Vec { + let mut lines = Vec::new(); + + if let Some(ref isolation) = metadata.isolation { + lines.push(create_spine_line("isolation", isolation)); } -} -/// Create the top border of a box -fn create_top_border(width: usize, style: &BoxStyle) -> String { - if !style.top_left.is_empty() { - let line_width = width.saturating_sub(2); // Subtract corners - format!( - "{}{}{}", - style.top_left, - create_horizontal_line(line_width, style), - style.top_right - ) - } else { - create_horizontal_line(width, style) + if let Some(ref mode) = metadata.mode { + lines.push(create_spine_line("mode", mode)); } -} -/// Create the bottom border of a box -fn create_bottom_border(width: usize, style: &BoxStyle) -> String { - if !style.bottom_left.is_empty() { - let line_width = width.saturating_sub(2); // Subtract corners - format!( - "{}{}{}", - style.bottom_left, - create_horizontal_line(line_width, style), - style.bottom_right - ) - } else { - create_horizontal_line(width, style) + if let Some(ref image) = metadata.image { + lines.push(create_spine_line("image", image)); + } + + // Use provided container/screen name or fall back to metadata.session + if let Some(ref isolation) = metadata.isolation { + let name = container_or_screen_name + .map(String::from) + .or_else(|| metadata.session.clone()); + + 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)), + "ssh" => { + if let Some(ref endpoint) = metadata.endpoint { + lines.push(create_spine_line("endpoint", endpoint)); + } + } + _ => {} + } + } } + + if let Some(ref user) = metadata.user { + lines.push(create_spine_line("user", user)); + } + + lines } /// Options for creating a start block @@ -163,35 +153,29 @@ pub struct StartBlockOptions<'a> { pub width: Option, } -/// Create a start block for command execution +/// Create a start block for command execution using status spine format pub fn create_start_block(options: &StartBlockOptions) -> String { - let width = options.width.unwrap_or(DEFAULT_WIDTH); - let style = get_box_style(options.style); - let mut lines = Vec::new(); - lines.push(create_top_border(width, &style)); - lines.push(create_bordered_line( - &format!("Session ID: {}", options.session_id), - width, - &style, - false, - )); - lines.push(create_bordered_line( - &format!("Starting at {}: {}", options.timestamp, options.command), - width, - &style, - false, - )); - - // Add extra lines (e.g., isolation info, docker image, etc.) + // Header: session and start time + lines.push(create_spine_line("session", options.session_id)); + lines.push(create_spine_line("start", options.timestamp)); + + // Parse and add isolation metadata if present if let Some(ref extra) = options.extra_lines { - for line in extra { - lines.push(create_bordered_line(line, width, &style, false)); + let metadata = parse_isolation_metadata(extra); + + if metadata.isolation.is_some() { + lines.push(create_empty_spine_line()); + lines.extend(generate_isolation_lines(&metadata, None)); } } - lines.push(create_bottom_border(width, &style)); + // Empty spine line before command + lines.push(create_empty_spine_line()); + + // Command line + lines.push(create_command_line(options.command)); lines.join("\n") } @@ -200,14 +184,14 @@ pub fn create_start_block(options: &StartBlockOptions) -> String { pub fn format_duration(duration_ms: f64) -> String { let seconds = duration_ms / 1000.0; if seconds < 0.001 { - "0.001".to_string() + "0.001s".to_string() } else if seconds < 10.0 { // For durations under 10 seconds, show 3 decimal places - format!("{:.3}", seconds) + format!("{:.3}s", seconds) } else if seconds < 100.0 { - format!("{:.2}", seconds) + format!("{:.2}s", seconds) } else { - format!("{:.1}", seconds) + format!("{:.1}s", seconds) } } @@ -219,57 +203,52 @@ pub struct FinishBlockOptions<'a> { pub log_path: &'a str, pub duration_ms: Option, pub result_message: Option<&'a str>, + pub extra_lines: Option>, pub style: Option<&'a str>, pub width: Option, } -/// Create a finish block for command execution +/// Create a finish block for command execution using status spine format +/// +/// Bottom block ordering rules: +/// 1. Result marker (✓ or ✗) +/// 2. finish timestamp +/// 3. duration +/// 4. exit code +/// 5. (repeated isolation metadata, if any) +/// 6. empty spine line +/// 7. log path (always second-to-last) +/// 8. session ID (always last) pub fn create_finish_block(options: &FinishBlockOptions) -> String { - let width = options.width.unwrap_or(DEFAULT_WIDTH); - let style = get_box_style(options.style); - let mut lines = Vec::new(); - // Format the finished message with optional duration - let finished_msg = if let Some(duration_ms) = options.duration_ms { - format!( - "Finished at {} in {} seconds", - options.timestamp, - format_duration(duration_ms) - ) - } else { - format!("Finished at {}", options.timestamp) - }; + // Result marker appears first in footer (after program output) + lines.push(get_result_marker(options.exit_code).to_string()); - lines.push(create_top_border(width, &style)); + // Finish metadata + lines.push(create_spine_line("finish", options.timestamp)); - // Add result message first if provided (e.g., "Docker container exited...") - // Allow overflow so the full message is visible and copyable - if let Some(result_msg) = options.result_message { - lines.push(create_bordered_line(result_msg, width, &style, true)); + if let Some(duration_ms) = options.duration_ms { + lines.push(create_spine_line("duration", &format_duration(duration_ms))); } - lines.push(create_bordered_line(&finished_msg, width, &style, false)); - lines.push(create_bordered_line( - &format!("Exit code: {}", options.exit_code), - width, - &style, - false, - )); - // Allow overflow for log path and session ID so they can be copied completely - lines.push(create_bordered_line( - &format!("Log: {}", options.log_path), - width, - &style, - true, - )); - lines.push(create_bordered_line( - &format!("Session ID: {}", options.session_id), - width, - &style, - true, - )); - lines.push(create_bottom_border(width, &style)); + lines.push(create_spine_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.extend(generate_isolation_lines(&metadata, None)); + } + } + + // Empty spine line before final two entries + lines.push(create_empty_spine_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.join("\n") } diff --git a/rust/tests/output_blocks_test.rs b/rust/tests/output_blocks_test.rs index ce465a9..28bb9de 100644 --- a/rust/tests/output_blocks_test.rs +++ b/rust/tests/output_blocks_test.rs @@ -1,12 +1,42 @@ //! Tests for output_blocks module //! -//! Tests for nicely rendered command output blocks and formatting utilities. +//! Tests for the "status spine" format: width-independent, lossless output. use start_command::{ create_finish_block, create_start_block, escape_for_links_notation, format_duration, - get_box_style, FinishBlockOptions, StartBlockOptions, + get_result_marker, parse_isolation_metadata, FinishBlockOptions, StartBlockOptions, + FAILURE_MARKER, SPINE, SUCCESS_MARKER, }; +#[test] +fn test_spine_constants() { + assert_eq!(SPINE, "│"); + assert_eq!(SUCCESS_MARKER, "✓"); + assert_eq!(FAILURE_MARKER, "✗"); +} + +#[test] +fn test_get_result_marker() { + assert_eq!(get_result_marker(0), "✓"); + assert_eq!(get_result_marker(1), "✗"); + assert_eq!(get_result_marker(127), "✗"); +} + +#[test] +fn test_parse_isolation_metadata() { + let extra_lines = vec![ + "[Isolation] Environment: docker, Mode: attached", + "[Isolation] Session: docker-container-123", + "[Isolation] Image: ubuntu:latest", + ]; + let metadata = parse_isolation_metadata(&extra_lines); + + assert_eq!(metadata.isolation, Some("docker".to_string())); + assert_eq!(metadata.mode, Some("attached".to_string())); + assert_eq!(metadata.session, Some("docker-container-123".to_string())); + assert_eq!(metadata.image, Some("ubuntu:latest".to_string())); +} + #[test] fn test_create_start_block() { let block = create_start_block(&StartBlockOptions { @@ -18,14 +48,13 @@ fn test_create_start_block() { width: Some(50), }); - assert!(block.contains("╭")); - assert!(block.contains("╰")); - assert!(block.contains("Session ID: test-uuid")); - assert!(block.contains("Starting at 2025-01-01 00:00:00: echo hello")); + assert!(block.contains("│ session test-uuid")); + assert!(block.contains("│ start 2025-01-01 00:00:00")); + assert!(block.contains("$ echo hello")); } #[test] -fn test_create_start_block_with_extra_lines() { +fn test_create_start_block_with_isolation() { let extra = vec![ "[Isolation] Environment: screen, Mode: attached", "[Isolation] Session: my-session", @@ -39,12 +68,33 @@ fn test_create_start_block_with_extra_lines() { width: Some(60), }); - assert!(block.contains("╭")); - assert!(block.contains("╰")); - assert!(block.contains("Session ID: test-uuid")); - assert!(block.contains("Starting at 2025-01-01 00:00:00: echo hello")); - assert!(block.contains("[Isolation] Environment: screen, Mode: attached")); - assert!(block.contains("[Isolation] Session: my-session")); + assert!(block.contains("│ session test-uuid")); + assert!(block.contains("│ start 2025-01-01 00:00:00")); + assert!(block.contains("│ isolation screen")); + assert!(block.contains("│ mode attached")); + assert!(block.contains("│ screen my-session")); + assert!(block.contains("$ echo hello")); +} + +#[test] +fn test_create_start_block_with_docker_isolation() { + let extra = vec![ + "[Isolation] Environment: docker, Mode: attached", + "[Isolation] Image: ubuntu", + "[Isolation] Session: docker-container-123", + ]; + 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, + }); + + assert!(block.contains("│ isolation docker")); + assert!(block.contains("│ image ubuntu")); + assert!(block.contains("│ container docker-container-123")); } #[test] @@ -56,37 +106,60 @@ fn test_create_finish_block() { log_path: "/tmp/test.log", duration_ms: Some(17.0), result_message: None, + extra_lines: None, style: Some("rounded"), width: Some(60), }); - assert!(block.contains("╭")); - assert!(block.contains("╰")); - assert!(block.contains("Finished at 2025-01-01 00:00:01 in 0.017 seconds")); - assert!(block.contains("Exit code: 0")); - assert!(block.contains("Session ID: test-uuid")); + assert!(block.contains("✓")); + assert!(block.contains("│ finish 2025-01-01 00:00:01")); + assert!(block.contains("│ duration 0.017s")); + assert!(block.contains("│ exit 0")); + assert!(block.contains("│ log /tmp/test.log")); + assert!(block.contains("│ session test-uuid")); +} + +#[test] +fn test_create_finish_block_failure() { + let block = create_finish_block(&FinishBlockOptions { + session_id: "test-uuid", + timestamp: "2025-01-01 00:00:01", + exit_code: 1, + log_path: "/tmp/test.log", + duration_ms: Some(100.0), + result_message: None, + extra_lines: None, + style: None, + width: None, + }); + + assert!(block.contains("✗")); + assert!(block.contains("│ exit 1")); } #[test] -fn test_create_finish_block_with_result_message() { +fn test_create_finish_block_with_isolation_repeated() { + let extra = vec![ + "[Isolation] Environment: docker, Mode: attached", + "[Isolation] Image: ubuntu", + "[Isolation] Session: docker-container-123", + ]; let block = create_finish_block(&FinishBlockOptions { session_id: "test-uuid", timestamp: "2025-01-01 00:00:01", exit_code: 0, log_path: "/tmp/test.log", duration_ms: Some(17.0), - result_message: Some("Screen session \"my-session\" exited with code 0"), - style: Some("rounded"), - width: Some(60), + result_message: None, + extra_lines: Some(extra), + style: None, + width: None, }); - assert!(block.contains("╭")); - assert!(block.contains("╰")); - assert!(block.contains("Screen session")); - assert!(block.contains("exited with code 0")); - assert!(block.contains("Finished at 2025-01-01 00:00:01 in 0.017 seconds")); - assert!(block.contains("Exit code: 0")); - assert!(block.contains("Session ID: test-uuid")); + assert!(block.contains("│ isolation docker")); + assert!(block.contains("│ mode attached")); + assert!(block.contains("│ image ubuntu")); + assert!(block.contains("│ container docker-container-123")); } #[test] @@ -98,23 +171,47 @@ fn test_create_finish_block_without_duration() { log_path: "/tmp/test.log", duration_ms: None, result_message: None, + extra_lines: None, style: Some("rounded"), width: Some(50), }); - assert!(block.contains("Finished at 2025-01-01 00:00:01")); - assert!(!block.contains("seconds")); + assert!(block.contains("│ finish 2025-01-01 00:00:01")); + assert!(!block.contains("duration")); +} + +#[test] +fn test_finish_block_log_session_last() { + let extra = vec![ + "[Isolation] Environment: screen, Mode: attached", + "[Isolation] Session: my-screen", + ]; + let block = create_finish_block(&FinishBlockOptions { + session_id: "test-uuid", + timestamp: "2025-01-01 00:00:01", + exit_code: 0, + log_path: "/tmp/test.log", + duration_ms: Some(17.0), + result_message: None, + extra_lines: Some(extra), + style: None, + width: None, + }); + + let lines: Vec<&str> = block.lines().collect(); + assert_eq!(lines[lines.len() - 1], "│ session test-uuid"); + assert_eq!(lines[lines.len() - 2], "│ log /tmp/test.log"); } #[test] fn test_format_duration() { - assert_eq!(format_duration(0.5), "0.001"); - assert_eq!(format_duration(17.0), "0.017"); - assert_eq!(format_duration(500.0), "0.500"); - assert_eq!(format_duration(1000.0), "1.000"); - assert_eq!(format_duration(5678.0), "5.678"); - assert_eq!(format_duration(12345.0), "12.35"); - assert_eq!(format_duration(123456.0), "123.5"); + assert_eq!(format_duration(0.5), "0.001s"); + assert_eq!(format_duration(17.0), "0.017s"); + assert_eq!(format_duration(500.0), "0.500s"); + assert_eq!(format_duration(1000.0), "1.000s"); + assert_eq!(format_duration(5678.0), "5.678s"); + assert_eq!(format_duration(12345.0), "12.35s"); + assert_eq!(format_duration(123456.0), "123.5s"); } #[test] @@ -145,18 +242,3 @@ fn test_escape_for_links_notation_with_double_quotes() { fn test_escape_for_links_notation_with_single_quotes() { assert_eq!(escape_for_links_notation("it's cool"), "\"it's cool\""); } - -#[test] -fn test_box_styles() { - let rounded = get_box_style(Some("rounded")); - assert_eq!(rounded.top_left, "╭"); - - let heavy = get_box_style(Some("heavy")); - assert_eq!(heavy.top_left, "┏"); - - let double = get_box_style(Some("double")); - assert_eq!(double.top_left, "╔"); - - let ascii = get_box_style(Some("ascii")); - assert_eq!(ascii.top_left, "+"); -}