diff --git a/.gitignore b/.gitignore index 105112f1..af58b191 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,6 @@ dist/ # Package lock files (keep package-lock.json but ignore others) yarn.lock -pnpm-lock.yaml \ No newline at end of file +pnpm-lock.yaml + +.claude/active-plan \ No newline at end of file diff --git a/bun.lock b/bun.lock index 90f49d3b..7b0048f6 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "chalk": "^5.6.2", "cli-table3": "^0.6.5", "gradient-string": "^3.0.0", + "listr2": "^9.0.5", "ora": "^9.0.0", }, "devDependencies": { @@ -253,12 +254,16 @@ "cli-table3": ["cli-table3@0.6.5", "", { "dependencies": { "string-width": "^4.2.0" }, "optionalDependencies": { "@colors/colors": "1.5.0" } }, "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ=="], + "cli-truncate": ["cli-truncate@5.1.1", "", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A=="], + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], + "compare-func": ["compare-func@2.0.0", "", { "dependencies": { "array-ify": "^1.0.0", "dot-prop": "^5.1.0" } }, "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA=="], "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], @@ -341,6 +346,8 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], + "execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], "fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="], @@ -505,6 +512,8 @@ "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + "listr2": ["listr2@9.0.5", "", { "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^9.0.0" } }, "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g=="], + "load-json-file": ["load-json-file@4.0.0", "", { "dependencies": { "graceful-fs": "^4.1.2", "parse-json": "^4.0.0", "pify": "^3.0.0", "strip-bom": "^3.0.0" } }, "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], @@ -541,6 +550,8 @@ "log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="], + "log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="], + "lru-cache": ["lru-cache@11.2.2", "", {}, "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg=="], "make-asynchronous": ["make-asynchronous@1.0.1", "", { "dependencies": { "p-event": "^6.0.0", "type-fest": "^4.6.0", "web-worker": "1.2.0" } }, "sha512-T9BPOmEOhp6SmV25SwLVcHK4E6JyG/coH3C6F1NjNXSziv/fd4GmsqMk8YR6qpPOswfaOCApSNkZv6fxoaYFcQ=="], @@ -679,6 +690,8 @@ "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], + "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "semantic-release": ["semantic-release@25.0.2", "", { "dependencies": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/error": "^4.0.0", "@semantic-release/github": "^12.0.0", "@semantic-release/npm": "^13.1.1", "@semantic-release/release-notes-generator": "^14.1.0", "aggregate-error": "^5.0.0", "cosmiconfig": "^9.0.0", "debug": "^4.0.0", "env-ci": "^11.0.0", "execa": "^9.0.0", "figures": "^6.0.0", "find-versions": "^6.0.0", "get-stream": "^6.0.0", "git-log-parser": "^1.2.0", "hook-std": "^4.0.0", "hosted-git-info": "^9.0.0", "import-from-esm": "^2.0.0", "lodash-es": "^4.17.21", "marked": "^15.0.0", "marked-terminal": "^7.3.0", "micromatch": "^4.0.2", "p-each-series": "^3.0.0", "p-reduce": "^3.0.0", "read-package-up": "^12.0.0", "resolve-from": "^5.0.0", "semver": "^7.3.2", "semver-diff": "^5.0.0", "signale": "^1.2.1", "yargs": "^18.0.0" }, "bin": { "semantic-release": "bin/semantic-release.js" } }, "sha512-6qGjWccl5yoyugHt3jTgztJ9Y0JVzyH8/Voc/D8PlLat9pwxQYXz7W1Dpnq5h0/G5GCYGUaDSlYcyk3AMh5A6g=="], @@ -701,6 +714,8 @@ "skin-tone": ["skin-tone@2.0.0", "", { "dependencies": { "unicode-emoji-modifier-base": "^1.0.0" } }, "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA=="], + "slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "spawn-error-forwarder": ["spawn-error-forwarder@1.0.0", "", {}, "sha512-gRjMgK5uFjbCvdibeGJuy3I5OYz6VLoVdsOJdA6wV0WlfQVLFueoqMxwwYD9RODdgb6oUIvlRlsyFSiQkMKu0g=="], @@ -879,6 +894,8 @@ "cli-table3/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "cli-truncate/string-width": ["string-width@8.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg=="], + "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -1269,6 +1286,10 @@ "signale/figures": ["figures@2.0.0", "", { "dependencies": { "escape-string-regexp": "^1.0.5" } }, "sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA=="], + "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], diff --git a/docs/code-standards.md b/docs/code-standards.md index ae7e891d..dd8a0953 100644 --- a/docs/code-standards.md +++ b/docs/code-standards.md @@ -435,7 +435,142 @@ for (const [flag, handler] of Object.entries(commandHandlers)) { } ``` -## Delegation System Patterns (v4.0+) +## UI System Patterns (Phase 5, v4.5.0+) + +### Central UI Module (src/utils/ui.ts) + +#### Initialization Pattern +```typescript +import { ui } from '../utils/ui'; + +// Initialize UI (call once at startup) +async function main() { + await ui.init(); + // UI functions now available +} +``` + +#### Semantic Color Usage +```typescript +// Apply semantic colors +ui.color('Success!', 'success') // Green bold +ui.color('Error!', 'error') // Red bold +ui.color('Warning!', 'warning') // Yellow +ui.color('Info', 'info') // Cyan +ui.color('Command: ccs', 'command') // Yellow italic +ui.color('/path/to/file', 'path') // Cyan underline +``` + +#### ASCII Status Indicators (NO EMOJIS) +```typescript +// Preferred: ASCII indicators only +ui.ok('Installation complete') // [OK] Installation complete +ui.fail('Build failed') // [X] Build failed +ui.warn('Deprecated feature') // [!] Deprecated feature +ui.info('Checking system') // [i] Checking system + +// NEVER use emoji (violates CLAUDE.md) +// DON'T: console.log('✓ Success') or '❌ Error' +``` + +#### Box & Table Rendering +```typescript +// Styled boxes +ui.box('Content here', { + title: 'Header', + borderColor: '#00ECFA', + padding: 1 +}); + +ui.errorBox('Error message', 'ERROR'); +ui.infoBox('Info content', 'INFO'); + +// Styled tables +ui.table([ + ['Column 1', 'Column 2'], + ['Data 1', 'Data 2'] +], { + head: ['Header 1', 'Header 2'], + style: 'unicode' +}); +``` + +#### Spinner & Progress +```typescript +// Simple spinner (falls back to plain text in non-TTY) +const spin = await ui.spinner('Processing...'); +await doWork(); +spin.succeed('Done!'); + +// Spinner with message update +const spin = await ui.spinner('Loading'); +spin.update('Still loading...'); +spin.succeed('Completed'); +``` + +#### Task Lists (Listr2 Integration - Phase 5) +```typescript +// Create task list with intelligent renderer selection +const ctx = await ui.taskList([ + { + title: 'Install dependencies', + task: async () => { + await installDeps(); + } + }, + { + title: 'Build project', + task: async () => { + await build(); + }, + skip: () => !needsBuild() ? 'Already built' : undefined + } +], { + concurrent: false // Set true for parallel execution +}); + +// Renderer selection (automatic): +// - TTY: Default renderer with subtask hierarchy +// - Non-TTY: Simple renderer (plain text) +// - CI environment: Simple renderer +// - Claude Code context: Fallback spinner-based +``` + +#### Context Detection +```typescript +// Check if running in interactive TTY +if (ui.isInteractive()) { + // Can use full spinner/box features +} + +// Check if running inside Claude Code tool +if (ui.isClaudeCodeContext()) { + // May need fallback rendering +} +``` + +### UI Compliance Rules (STRICT) + +**Mandatory**: +- ALWAYS call `await ui.init()` before using any UI functions +- ASCII status indicators ONLY: [OK], [X], [!], [i] - NO EMOJIS +- Respect NO_COLOR and FORCE_COLOR environment variables +- Use semantic colors (success, error, warning, info, dim, primary, secondary, command, path) + +**Patterns**: +- Status messages: `ui.ok()`, `ui.fail()`, `ui.warn()`, `ui.info()` +- Styled output: `ui.box()`, `ui.table()`, `ui.spinner()` +- Progress: Use `ui.taskList()` for multi-step operations (Phase 5) +- Fallback-first: Always test in non-TTY (pipe) mode + +**Testing**: +```bash +# Test in non-TTY (pipes/CI) +ccs doctor | cat # Forces plain text output +NO_COLOR=1 ccs doctor # Disables colors +``` + +## Delegation System Patterns (v4.0+, Phase 5 Enhanced) ### Stream-JSON Parsing @@ -532,12 +667,76 @@ function executeHeadless(claudeCli, profile, prompt, sessionId = null) { } ``` -### Result Formatting Pattern +### Result Formatting Pattern (Phase 5 Enhanced - Async) -#### Cost and Duration Extraction -```javascript -// Extract cost and duration from Claude CLI output -function parseExecutionStats(output) { +#### Async Result Formatter with UI Integration +```typescript +// result-formatter.ts - Phase 5 now fully async +import { ui } from '../utils/ui'; + +class ResultFormatter { + // Initialize UI and format result + static async format(result: ExecutionResult): Promise { + await ui.init(); // Phase 5: Initialize UI layer + + // Build styled output + let output = ''; + + // Header box with status indicator + const modelName = this.getModelDisplayName(result.profile); + const headerIcon = result.success ? '[i]' : '[X]'; + output += ui.box(`${headerIcon} Delegated to ${modelName}`, { + borderStyle: 'round', + padding: 0, + }); + output += '\n\n'; + + // Info table with styled columns + output += this.formatInfoTable(result); + + // Error details if needed + if (result.errors && result.errors.length > 0) { + output += '\n' + ui.errorBox('Execution Errors', 'ERRORS'); + } + + return output; + } + + // Phase 5: Table formatting + private static formatInfoTable(result: ExecutionResult): string { + const rows = [ + ['Working Directory', result.cwd], + ['Model', this.getModelDisplayName(result.profile)], + ['Duration', `${(result.duration / 1000).toFixed(2)}s`], + ['Exit Code', result.exitCode === 0 ? '[OK]' : '[X]'], + ]; + + if (result.totalCost) { + rows.push(['Total Cost', `$${result.totalCost.toFixed(4)}`]); + } + + return ui.table(rows, { + style: 'unicode', + wordWrap: true + }); + } +} + +// Usage in delegation-handler.ts +const formatted = await ResultFormatter.format(result); // Phase 5: Now async! +console.log(formatted); +``` + +#### Cost and Duration Extraction (Unchanged from v4.0) +```typescript +// Extract cost and duration from stream-JSON output +interface ExecutionStats { + cost: number | null; + duration: number | null; + exitCode: number; +} + +function parseExecutionStats(output: string): ExecutionStats { const costRegex = /Cost:\s+\$(\d+\.\d+)/; const durationRegex = /Duration:\s+(\d+)ms/; @@ -546,20 +745,10 @@ function parseExecutionStats(output) { return { cost: costMatch ? parseFloat(costMatch[1]) : null, - duration: durationMatch ? parseInt(durationMatch[1]) : null + duration: durationMatch ? parseInt(durationMatch[1]) : null, + exitCode: 0 }; } - -// Format results -function formatResults(exitCode, stats) { - const status = exitCode === 0 ? '[OK]' : '[X]'; - const costStr = stats.cost ? `\$${stats.cost.toFixed(4)}` : 'N/A'; - const durationStr = stats.duration ? `${(stats.duration / 1000).toFixed(2)}s` : 'N/A'; - - console.log(`\n${status} Execution complete`); - console.log(` Cost: ${costStr}`); - console.log(` Duration: ${durationStr}`); -} ``` ## Symlinking Patterns (v4.1+) @@ -960,10 +1149,19 @@ These code standards ensure the CCS codebase remains: - **Compatible**: Cross-platform symlinking, Windows fallbacks, unified behavior - **Extensible**: Clean subsystem boundaries, reusable patterns - **Type-Safe**: Enhanced ESLint strictness enforces zero-tolerance for code quality issues +- **User-Friendly**: Semantic UI system with intelligent fallback rendering (Phase 5) + +**Phase 5 (2025-12-01) - UI & Listr2 Integration**: +- **Central UI Module**: src/utils/ui.ts with semantic colors, status indicators, boxes, tables +- **Listr2 Integration**: Task list progress with intelligent renderer selection +- **Claude Code Detection**: Automatic fallback for tool context (isClaudeCodeContext) +- **Async Formatting**: Result formatter now async with ui.init() call +- **Compliance**: ASCII-only indicators ([OK], [X], [!], [i]), NO_COLOR respect **v4.x Specific Standards**: - **Delegation patterns**: Stream-JSON parsing, session management, headless execution - **Symlinking patterns**: Cross-platform symlink creation, validation, repair +- **UI patterns**: Semantic colors, status indicators, boxes, tables, spinners, task lists - **Subsystem organization**: Clear separation (auth, delegation, glmt, management, utils) - **Naming conventions**: handler, executor, manager, validator, formatter, parser suffixes @@ -973,4 +1171,4 @@ These code standards ensure the CCS codebase remains: - **No shell dependencies**: error-codes.sh, progress-indicator.sh, prompt.sh removed - **First-run bootstrap**: Auto-installs @kaitranntt/ccs npm package globally -Following these standards helps maintain the quality, modularity, and extensibility of the v4.x architecture while enabling future development with AI delegation, shared data management, and comprehensive diagnostics. \ No newline at end of file +Following these standards helps maintain the quality, modularity, and extensibility of the v4.x architecture while enabling future development with AI delegation, shared data management, and comprehensive diagnostics. Phase 5 adds a professional UI layer that gracefully degrades in non-TTY environments while maintaining strict compliance with project constraints. \ No newline at end of file diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index a625e1df..381d6604 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -23,7 +23,7 @@ CCS (Claude Code Switch) v4.5.0 is a lightweight CLI wrapper enabling instant pr - **Phase 02 (2025-11-28)**: Modular command architecture, native installer deprecation, main file 44.6% reduction - **v4.5.0**: npm-first distribution, TypeScript package with quality gates, modular commands, text-only output (no emojis) -## Core Components (Phase 02 Complete - 2025-11-28) +## Core Components (Phase 05 Complete - 2025-12-01 | UI Phase 1 + Listr2) ### 1. Main Entry Point (`src/ccs.ts` - 593 lines, 44.6% reduction) @@ -103,16 +103,16 @@ interface CommandHandler { - **shell-executor.ts** (1.5KB): Cross-platform shell command execution with process management - **package-manager-detector.ts** (3.8KB): Package manager detection (npm, yarn, pnpm, bun) -### 3. Delegation System (`src/delegation/` - ~1,200 lines) +### 3. Delegation System (`src/delegation/` - ~1,200 lines, Phase 5 Enhanced) -**New in v4.0**: Complete delegation subsystem +**New in v4.0**: Complete delegation subsystem; **Phase 5 Enhanced** with UI layer & Listr2 **Components**: -- **delegation-handler.js** (~300 lines): Routes `-p` commands, validates profiles -- **headless-executor.js** (~400 lines): Executes Claude CLI in headless mode with stream-JSON -- **session-manager.js** (~200 lines): Manages delegation session persistence (continue support) -- **result-formatter.js** (~150 lines): Formats execution results with cost/duration -- **settings-parser.js** (~150 lines): Parses profile settings for validation +- **delegation-handler.ts** (~300 lines): Routes `-p` commands, validates profiles; async formatting +- **headless-executor.ts** (~400 lines): Executes Claude CLI in headless mode with stream-JSON; UI progress +- **session-manager.ts** (~200 lines): Manages delegation session persistence (continue support) +- **result-formatter.ts** (~150 lines): **Async formatting** with styled boxes and tables (Phase 5) +- **settings-parser.ts** (~150 lines): Parses profile settings for validation **Key Features**: - **Stream-JSON output**: Real-time tool visibility (`--output-format stream-json --verbose`) @@ -121,6 +121,11 @@ interface CommandHandler { - **Signal handling**: Ctrl+C kills child processes properly - **Cost tracking**: USD cost display per delegation - **13 Claude Code tools** supported: Bash, Read, Write, Edit, Glob, Grep, NotebookEdit, SlashCommand, TodoWrite, etc. +- **Phase 5 Enhancements**: + - **Styled output**: UI layer integration for semantic boxes and tables + - **Async formatting**: Result formatter now fully async with ui.init() call + - **Listr2 integration**: Optional task list progress in TTY mode + - **Fallback chain**: Graceful degradation for non-TTY/CI environments **Delegation Flow**: ``` @@ -271,20 +276,20 @@ src/ # TypeScript source files (Phase 02 Modular Archite │ ├── instance-manager.ts # Instance lifecycle (~220 lines) │ ├── recovery-manager.ts # Auto-recovery (~80 lines) │ └── shared-manager.ts # Shared symlinking (~50 lines) -├── utils/ # Utilities (expanded in v4.x + Phase 02, UI Phase 1) +├── utils/ # Utilities (expanded in v4.x + Phase 02 + Phase 05, UI + Listr2) │ ├── claude-detector.ts # CLI detection (~70 lines) │ ├── claude-dir-installer.ts # .claude/ installer (v4.1.1, ~150 lines) │ ├── claude-symlink-manager.ts # Selective symlinks (v4.1, ~200 lines) │ ├── config-manager.ts # Config management (~80 lines) -│ ├── shell-executor.ts # 1.5KB - Cross-platform execution (Phase 02 NEW) -│ ├── package-manager-detector.ts # 3.8KB - Package manager detection (Phase 02 NEW) -│ ├── ui.ts # 5.2KB - Central UI abstraction (UI Phase 1 NEW) -│ ├── delegation-validator.js # Delegation validation (v4.0, ~100 lines) -│ ├── error-codes.js # Error codes (~50 lines) -│ ├── error-manager.js # Error handling (~200 lines) -│ ├── helpers.js # Utilities (~100 lines) -│ ├── progress-indicator.js # Progress display (~150 lines) -│ ├── prompt.js # User prompting (~100 lines) +│ ├── shell-executor.ts # 1.5KB - Cross-platform execution (Phase 02) +│ ├── package-manager-detector.ts # 3.8KB - Package manager detection (Phase 02) +│ ├── ui.ts # 5.2KB - Central UI abstraction (Phase 5, Listr2 integration) +│ ├── delegation-validator.ts # Delegation validation (v4.0, ~100 lines) +│ ├── error-codes.ts # Error codes (~50 lines) +│ ├── error-manager.ts # Error handling (~200 lines) +│ ├── helpers.ts # Utilities (~100 lines) +│ ├── progress-indicator.ts # Progress display (~150 lines) +│ ├── prompt.ts # User prompting (~100 lines) │ ├── shell-completion.ts # Shell completion (v4.1.4, ~250 lines) │ └── update-checker.ts # Update checker (v4.1, ~100 lines) ├── types/ # TypeScript type definitions @@ -427,9 +432,9 @@ Claude CLI: Read credentials from instance, execute ## Key Features (v4.5.0 - Phase 02 Complete, UI Phase 1) -### 0. Central UI Abstraction Layer (UI Phase 1 - 2025-12-01) +### 0. Central UI Abstraction Layer (UI Phase 1 - 2025-12-01, Phase 5 Complete) - **New Module**: src/utils/ui.ts (5.2KB) - Semantic, TTY-aware CLI styling -- **Dependencies**: chalk@5.6.2, boxen@8.0.1, gradient-string@3.0.0, cli-table3@0.6.5, ora@5.4.1 +- **Dependencies**: chalk@5.6.2, boxen@8.0.1, gradient-string@3.0.0, cli-table3@0.6.5, ora@5.4.1, listr2@8.0.0 - **Features**: - Semantic color system (success, error, warning, info, dim, primary, secondary, command, path) - ASCII-only status indicators ([OK], [X], [!], [i]) - NO EMOJIS per CLAUDE.md @@ -438,10 +443,15 @@ Claude CLI: Read credentials from instance, execute - Table rendering via cli-table3 - Spinner/progress (ora wrapper with fallback) - Section headers with optional gradient + - **Listr2 task lists** (Phase 5 NEW) - intelligent renderer selection: + - TTY mode: Default renderer with subtask hierarchy + - Non-TTY/CI: Simple renderer for clean output + - Claude Code detection: Automatic fallback for tool context - Lazy loading of ESM modules for CommonJS compatibility -- **Type Support**: SemanticColor, BoxOptions, TableOptions, SpinnerOptions, SpinnerController +- **Type Support**: SemanticColor, BoxOptions, TableOptions, SpinnerOptions, SpinnerController, TaskItem, TaskListOptions - **Compliance**: Strict CLAUDE.md adherence (no emojis, TTY-aware, NO_COLOR respect) - **Fallback Architecture**: Works in non-TTY environments with graceful degradation +- **Claude Code Integration** (Phase 5): `isClaudeCodeContext()` detection for adaptive UI rendering ### 1. npm-First Installation (Phase 02 - 2025-11-28) - **Recommended method**: All users directed to npm installation @@ -569,6 +579,14 @@ Claude CLI: Read credentials from instance, execute ## Summary +**CCS Phase 5 Achievements (2025-12-01, UI + Listr2 Integration)**: +- **Listr2 Task Lists**: Integrated task list progress display with intelligent renderer selection +- **Claude Code Detection**: Automatic fallback for tool context via `isClaudeCodeContext()` +- **Async Result Formatting**: Delegation system now fully async with ui initialization +- **UI Layer Integration**: Styled boxes and tables for enhanced delegation output +- **Adaptive Rendering**: TTY (default), non-TTY (simple), CI (simple), Claude Code (fallback) +- **Type System**: TaskItem, TaskListOptions for task list operations + **CCS UI Phase 1 Achievements (2025-12-01)**: - **Central UI Module**: Introduced src/utils/ui.ts for semantic, TTY-aware CLI styling - **CLAUDE.md Compliance**: ASCII-only indicators, NO_COLOR respect, TTY detection diff --git a/package.json b/package.json index 99265717..c2df608c 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "chalk": "^5.6.2", "cli-table3": "^0.6.5", "gradient-string": "^3.0.0", + "listr2": "^9.0.5", "ora": "^9.0.0" }, "devDependencies": { diff --git a/src/delegation/delegation-handler.ts b/src/delegation/delegation-handler.ts index c9ac176f..da245f6a 100644 --- a/src/delegation/delegation-handler.ts +++ b/src/delegation/delegation-handler.ts @@ -45,7 +45,7 @@ export class DelegationHandler { const result = await HeadlessExecutor.execute(parsed.profile, parsed.prompt, parsed.options); // 5. Format and display results - const formatted = ResultFormatter.format(result); + const formatted = await ResultFormatter.format(result); console.log(formatted); // 6. Exit with proper code @@ -83,7 +83,7 @@ export class DelegationHandler { sessionId: lastSession.sessionId, }); - const formatted = ResultFormatter.format(result); + const formatted = await ResultFormatter.format(result); console.log(formatted); process.exit(result.exitCode || 0); diff --git a/src/delegation/headless-executor.ts b/src/delegation/headless-executor.ts index 0d76c578..a9d723d2 100644 --- a/src/delegation/headless-executor.ts +++ b/src/delegation/headless-executor.ts @@ -6,6 +6,7 @@ import * as os from 'os'; import * as fs from 'fs'; import { SessionManager } from './session-manager'; import { SettingsParser } from './settings-parser'; +import { ui } from '../utils/ui'; // Type definitions for delegation responses interface ClaudeMessage { @@ -214,6 +215,9 @@ export class HeadlessExecutor { console.error(`[i] Claude CLI args: ${args.join(' ')}`); } + // Initialize UI before spawning + await ui.init(); + // Execute with spawn return new Promise((resolve, reject) => { const startTime = Date.now(); @@ -225,7 +229,7 @@ export class HeadlessExecutor { if (showProgress) { const modelName = profile === 'glm' ? 'GLM-4.6' : profile === 'kimi' ? 'Kimi' : profile.toUpperCase(); - console.error(`[i] Delegating to ${modelName}...`); + console.error(ui.info(`Delegating to ${modelName}...`)); } const proc = spawn(claudeCli, args, { @@ -274,7 +278,7 @@ export class HeadlessExecutor { if (showProgress) { progressInterval = setInterval(() => { const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); - process.stderr.write(`[i] Still running... ${elapsed}s elapsed\r`); + process.stderr.write(`${ui.info(`Still running... ${elapsed}s elapsed`)}\r`); }, 5000); } @@ -445,9 +449,9 @@ export class HeadlessExecutor { if (showProgress) { const durationSec = (duration / 1000).toFixed(1); if (timedOut) { - console.error(`[i] Execution timed out after ${durationSec}s`); + console.error(ui.warn(`Execution timed out after ${durationSec}s`)); } else { - console.error(`[i] Execution completed in ${durationSec}s`); + console.error(ui.info(`Execution completed in ${durationSec}s`)); } console.error(''); // Blank line before formatted output } diff --git a/src/delegation/result-formatter.ts b/src/delegation/result-formatter.ts index b0c9b6c1..1b2497d8 100644 --- a/src/delegation/result-formatter.ts +++ b/src/delegation/result-formatter.ts @@ -2,12 +2,13 @@ /** * Formats delegation execution results for display - * Creates ASCII box output with file change tracking + * Creates styled box output with file change tracking */ import * as path from 'path'; import { execSync } from 'child_process'; import * as fs from 'fs'; +import { ui } from '../utils/ui'; interface ExecutionResult { profile: string; @@ -55,19 +56,15 @@ class ResultFormatter { /** * Format execution result with complete source-of-truth */ - static format(result: ExecutionResult): string { + static async format(result: ExecutionResult): Promise { + await ui.init(); + const { profile, - cwd, - exitCode, stdout, stderr, - duration, success, content, - sessionId, - totalCost, - numTurns, subtype, permissionDenials, errors, @@ -90,15 +87,25 @@ class ResultFormatter { // Build formatted output let output = ''; - // Header - output += this.formatHeader(profile, success); + // Header box + const modelName = this.getModelDisplayName(profile); + const headerIcon = success ? '[i]' : '[X]'; + output += ui.box(`${headerIcon} Delegated to ${modelName} (ccs:${profile})`, { + borderStyle: 'round', + padding: 0, + }); + output += '\n\n'; - // Info box (file detection handled by delegated session itself) - output += this.formatInfoBox(cwd, profile, duration, exitCode, sessionId, totalCost, numTurns); + // Info table + output += this.formatInfoTable(result); + output += '\n'; // Task output - output += '\n'; - output += this.formatOutput(displayOutput); + if (displayOutput?.trim()) { + output += displayOutput.trim() + '\n'; + } else { + output += ui.info('No output from delegated task') + '\n'; + } // Permission denials if present if (permissionDenials && permissionDenials.length > 0) { @@ -115,12 +122,14 @@ class ResultFormatter { // Stderr if present if (stderr && stderr.trim()) { output += '\n'; - output += this.formatStderr(stderr); + output += ui.warn('Stderr:') + '\n'; + output += stderr.trim() + '\n'; } // Footer output += '\n'; - output += this.formatFooter(success, duration); + output += success ? ui.ok('Delegation completed') : ui.fail('Delegation failed'); + output += '\n'; return output; } @@ -242,107 +251,36 @@ class ResultFormatter { } /** - * Format header with delegation indicator + * Format info as table */ - private static formatHeader(profile: string, success: boolean): string { - const modelName = this.getModelDisplayName(profile); - const icon = success ? '[i]' : '[X]'; - return `${icon} Delegated to ${modelName} (ccs:${profile})\n`; - } - - /** - * Format info box with delegation details - */ - private static formatInfoBox( - cwd: string, - profile: string, - duration: number, - exitCode: number, - sessionId?: string, - totalCost?: number, - numTurns?: number - ): string { + private static formatInfoTable(result: ExecutionResult): string { + const { cwd, profile, duration, exitCode, sessionId, totalCost, numTurns } = result; const modelName = this.getModelDisplayName(profile); const durationSec = (duration / 1000).toFixed(1); - // Calculate box width (fit longest line + padding) - const maxWidth = 70; - const cwdLine = `Working Directory: ${cwd}`; - const boxWidth = Math.min(Math.max(cwdLine.length + 4, 50), maxWidth); - - const lines: string[] = [ - `Working Directory: ${this.truncate(cwd, boxWidth - 22)}`, - `Model: ${modelName}`, - `Duration: ${durationSec}s`, - `Exit Code: ${exitCode}`, + const rows: string[][] = [ + ['Working Dir', this.truncate(cwd, 40)], + ['Model', modelName], + ['Duration', `${durationSec}s`], + ['Exit Code', `${exitCode}`], ]; - // Add JSON-specific fields if available if (sessionId) { - // Abbreviate session ID (Git-style first 8 chars) to prevent wrapping const shortId = sessionId.length > 8 ? sessionId.substring(0, 8) : sessionId; - lines.push(`Session ID: ${shortId}`); + rows.push(['Session', shortId]); } + if (totalCost !== undefined && totalCost !== null) { - lines.push(`Cost: $${totalCost.toFixed(4)}`); + rows.push(['Cost', `$${totalCost.toFixed(4)}`]); } + if (numTurns) { - lines.push(`Turns: ${numTurns}`); + rows.push(['Turns', `${numTurns}`]); } - let box = ''; - box += '╔' + '═'.repeat(boxWidth - 2) + '╗\n'; - - for (const line of lines) { - const padding = boxWidth - line.length - 4; - box += '║ ' + line + ' '.repeat(Math.max(0, padding)) + ' ║\n'; - } - - box += '╚' + '═'.repeat(boxWidth - 2) + '╝'; - - return box; - } - - /** - * Format task output - */ - private static formatOutput(output: string): string { - if (!output || !output.trim()) { - return '[i] No output from delegated task\n'; - } - - return output.trim() + '\n'; - } - - /** - * Format stderr output - */ - private static formatStderr(stderr: string): string { - return `[!] Stderr:\n${stderr.trim()}\n\n`; - } - - /** - * Format file list (created or modified) - Currently unused - */ - /* - private static formatFileList(label: string, files: string[]): string { - let output = `[i] ${label} Files:\n`; - - for (const file of files) { - output += ` - ${file}\n`; - } - - return output; - } - */ - - /** - * Format footer with completion status - */ - private static formatFooter(success: boolean, _duration: number): string { - const icon = success ? '[OK]' : '[X]'; - const status = success ? 'Delegation completed' : 'Delegation failed'; - return `${icon} ${status}\n`; + return ui.table(rows, { + colWidths: [15, 45], + }); } /** @@ -372,10 +310,11 @@ class ResultFormatter { /** * Format minimal result (for quick tasks) */ - static formatMinimal(result: ExecutionResult): string { + static async formatMinimal(result: ExecutionResult): Promise { + await ui.init(); const { profile, success, duration } = result; const modelName = this.getModelDisplayName(profile); - const icon = success ? '[OK]' : '[X]'; + const icon = success ? ui.ok('') : ui.fail(''); const durationSec = (duration / 1000).toFixed(1); return `${icon} ${modelName} delegation ${success ? 'completed' : 'failed'} (${durationSec}s)\n`; @@ -384,8 +323,8 @@ class ResultFormatter { /** * Format verbose result (with full details) */ - static formatVerbose(result: ExecutionResult): string { - const basic = this.format(result); + static async formatVerbose(result: ExecutionResult): Promise { + const basic = await this.format(result); // Add additional debug info let verbose = basic; @@ -413,47 +352,50 @@ class ResultFormatter { /** * Format timeout error (session exceeded time limit) */ - private static formatTimeoutError(result: ExecutionResult): string { - const { profile, cwd, duration, sessionId, totalCost, numTurns, permissionDenials } = result; + private static async formatTimeoutError(result: ExecutionResult): Promise { + await ui.init(); + + const { profile, duration, sessionId, totalCost, permissionDenials } = result; + const modelName = this.getModelDisplayName(profile); + const timeoutMin = (duration / 60000).toFixed(1); let output = ''; - // Header - output += this.formatHeader(profile, false); - - // Info box - output += this.formatInfoBox(cwd, profile, duration, 0, sessionId, totalCost, numTurns); - - // Timeout message + // Error header + output += ui.errorBox( + `Execution Timeout\n\n` + + `Delegation to ${modelName} exceeded time limit.\n` + + `Session was gracefully terminated after ${timeoutMin} minutes.`, + 'TIMEOUT' + ); output += '\n'; - const timeoutMin = (duration / 60000).toFixed(1); - output += `[!] Execution timed out after ${timeoutMin} minutes\n\n`; - output += 'The delegated session exceeded its time limit before completing the task.\n'; - output += 'Session was gracefully terminated and saved for continuation.\n'; - // Permission denials if present + // Info table + output += this.formatInfoTable(result); + output += '\n'; + + // Permission denials if (permissionDenials && permissionDenials.length > 0) { - output += '\n'; + output += ui.warn('Permission denials may have caused delays:') + '\n'; output += this.formatPermissionDenials(permissionDenials); output += '\n'; - output += 'The task may require permissions that were denied.\n'; - output += 'Consider running with --permission-mode bypassPermissions or execute manually.\n'; } // Suggestions - output += '\n'; - output += 'Suggestions:\n'; - output += ` - Continue session: ccs ${profile}:continue -p "finish the task"\n`; - output += ` - Increase timeout: ccs ${profile} -p "task" --timeout ${duration * 2}\n`; - output += ' - Break task into smaller steps\n'; - output += ' - Run task manually in main Claude session\n'; + output += ui.header('SUGGESTIONS') + '\n'; + output += ` Continue session:\n`; + output += ` ${ui.color(`ccs ${profile}:continue "finish the task"`, 'command')}\n\n`; + output += ` Increase timeout:\n`; + output += ` ${ui.color(`ccs ${profile} --timeout ${Math.round((duration * 2) / 1000)}`, 'command')}\n\n`; + output += ` Break into smaller tasks\n\n`; - output += '\n'; - // Abbreviate session ID (Git-style first 8 chars) - const shortId = sessionId && sessionId.length > 8 ? sessionId.substring(0, 8) : sessionId; - output += `[i] Session persisted with ID: ${shortId}\n`; + // Session info + if (sessionId) { + const shortId = sessionId.length > 8 ? sessionId.substring(0, 8) : sessionId; + output += ui.dim(`Session persisted: ${shortId}`) + '\n'; + } if (totalCost !== undefined && totalCost !== null) { - output += `[i] Cost: $${totalCost.toFixed(4)}\n`; + output += ui.dim(`Cost: $${totalCost.toFixed(4)}`) + '\n'; } return output; @@ -463,14 +405,13 @@ class ResultFormatter { * Format permission denials */ private static formatPermissionDenials(denials: PermissionDenial[]): string { - let output = '[!] Permission Denials:\n'; + let output = ui.warn('Permission Denials:') + '\n'; for (const denial of denials) { const tool = denial.tool_name || 'Unknown'; const input = denial.tool_input || {}; - const command = input.command || input.description || JSON.stringify(input); - - output += ` - ${tool}: ${command}\n`; + const cmd = input.command || input.description || JSON.stringify(input); + output += ` - ${tool}: ${this.truncate(cmd, 50)}\n`; } return output; @@ -480,11 +421,11 @@ class ResultFormatter { * Format errors array */ private static formatErrors(errors: ErrorInfo[]): string { - let output = '[X] Errors:\n'; + let output = ui.fail('Errors:') + '\n'; for (const error of errors) { - const message = error.message || error.error || JSON.stringify(error); - output += ` - ${message}\n`; + const msg = error.message || error.error || JSON.stringify(error); + output += ` - ${msg}\n`; } return output; diff --git a/src/utils/ui.ts b/src/utils/ui.ts index ea593e52..ff60900d 100644 --- a/src/utils/ui.ts +++ b/src/utils/ui.ts @@ -31,12 +31,16 @@ type GradientStringInstance = typeof import('gradient-string').default; // ora v9 is ESM-only, imported dynamically at runtime // eslint-disable-next-line @typescript-eslint/no-explicit-any type OraModule = any; +// listr2 v9 is ESM-only, imported dynamically at runtime +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type ListrClass = any; // Module cache for lazy loading let chalkModule: ChalkInstance | null = null; let boxenModule: BoxenFunction | null = null; let gradientModule: GradientStringInstance | null = null; let oraModule: OraModule | null = null; +let listrModule: ListrClass | null = null; // Initialization state let initialized = false; @@ -64,17 +68,19 @@ export async function initUI(): Promise { try { // Dynamic import for ESM-only packages - const [chalkImport, boxenImport, gradientImport, oraImport] = await Promise.all([ + const [chalkImport, boxenImport, gradientImport, oraImport, listrImport] = await Promise.all([ import('chalk'), import('boxen'), import('gradient-string'), import('ora'), + import('listr2'), ]); chalkModule = chalkImport.default; boxenModule = boxenImport.default; gradientModule = gradientImport.default; oraModule = oraImport.default; + listrModule = listrImport.Listr; initialized = true; } catch (_e) { // Fallback: UI works without colors if imports fail @@ -452,6 +458,117 @@ export function hr(char = '─', width = 60): string { return dim(char.repeat(width)); } +// ============================================================================= +// TASK LISTS (Listr2 Integration) +// ============================================================================= + +/** + * Detect if running inside Claude Code tool context + * + * Heuristics: + * - No TTY (stdout captured) + * - CI-like environment + * - CLAUDE_CODE env var set + */ +export function isClaudeCodeContext(): boolean { + return ( + !process.stdout.isTTY || + !!process.env.CI || + !!process.env.CLAUDE_CODE || + process.env.TERM === 'dumb' + ); +} + +/** + * Task list item interface + */ +export interface TaskItem { + title: string; + task: (ctx: T) => Promise | void; + skip?: () => boolean | string; +} + +/** + * Task list options + */ +export interface TaskListOptions { + concurrent?: boolean; +} + +/** + * Create a task list for progress display + * Uses Listr2 in TTY mode, falls back to spinners in non-TTY + */ +export async function taskList(tasks: TaskItem[], options: TaskListOptions = {}): Promise { + // Lazy load Listr2 if not already loaded + if (!listrModule && isInteractive()) { + try { + const listr2 = await import('listr2'); + listrModule = listr2.Listr; + } catch (_e) { + // Fallback to sequential execution with spinners + return runTasksFallback(tasks); + } + } + + if (listrModule && isInteractive()) { + // Determine renderer based on context + // Use 'simple' in non-TTY, CI, or Claude Code context + const useSimple = isClaudeCodeContext(); + + const list = new listrModule( + tasks.map((t) => ({ + title: t.title, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + task: async (ctx: any) => t.task(ctx), + skip: t.skip, + })), + { + concurrent: options.concurrent ?? false, + renderer: useSimple ? 'simple' : 'default', + rendererOptions: { + showSubtasks: true, + collapseSubtasks: false, + }, + } + ); + + return list.run({} as T); + } + + // Fallback: non-interactive or Listr2 not available + return runTasksFallback(tasks); +} + +/** + * Fallback task runner (no Listr2) + * Uses spinners for sequential task execution + */ +async function runTasksFallback(tasks: TaskItem[]): Promise { + const ctx = {} as T; + + for (const task of tasks) { + if (task.skip) { + const skipResult = task.skip(); + if (skipResult) { + console.log(info(`${task.title} [skipped]`)); + continue; + } + } + + const spin = await spinner(task.title); + try { + await task.task(ctx); + spin.succeed(); + } catch (e) { + spin.fail(`${task.title}: ${(e as Error).message}`); + throw e; + } + } + + return ctx; +} + // ============================================================================= // UNIFIED EXPORT OBJECT // ============================================================================= @@ -460,6 +577,7 @@ export const ui = { // Initialization init: initUI, isInteractive, + isClaudeCodeContext, // Colors color, @@ -481,6 +599,7 @@ export const ui = { // Progress spinner, + taskList, // Headers header, diff --git a/tests/unit/delegation/result-formatter.test.js b/tests/unit/delegation/result-formatter.test.js index 91b6cc64..48e2c449 100644 --- a/tests/unit/delegation/result-formatter.test.js +++ b/tests/unit/delegation/result-formatter.test.js @@ -3,7 +3,7 @@ const { ResultFormatter } = require('../../../dist/delegation/result-formatter') describe('ResultFormatter', () => { describe('Basic formatting', () => { - it('formats successful result', () => { + it('formats successful result', async () => { const result = { profile: 'glm', cwd: '/home/user/project', @@ -14,17 +14,17 @@ describe('ResultFormatter', () => { success: true }; - const formatted = ResultFormatter.format(result); + const formatted = await ResultFormatter.format(result); assert.ok(formatted.includes('Delegated to GLM-4.6')); assert.ok(formatted.includes('ccs:glm')); assert.ok(formatted.includes('/home/user/project')); assert.ok(formatted.includes('2.3s')); - assert.ok(formatted.includes('Exit Code: 0')); + assert.ok(formatted.includes('0')); // Exit code in table assert.ok(formatted.includes('[OK]')); }); - it('formats failed result', () => { + it('formats failed result', async () => { const result = { profile: 'glm', cwd: '/home/user/project', @@ -35,16 +35,16 @@ describe('ResultFormatter', () => { success: false }; - const formatted = ResultFormatter.format(result); + const formatted = await ResultFormatter.format(result); assert.ok(formatted.includes('[X]')); - assert.ok(formatted.includes('Exit Code: 1')); + assert.ok(formatted.includes('1')); // Exit code in table assert.ok(formatted.includes('Delegation failed')); assert.ok(formatted.includes('Stderr:')); assert.ok(formatted.includes('Command failed')); }); - it('handles empty output', () => { + it('handles empty output', async () => { const result = { profile: 'glm', cwd: '/test', @@ -55,7 +55,7 @@ describe('ResultFormatter', () => { success: true }; - const formatted = ResultFormatter.format(result); + const formatted = await ResultFormatter.format(result); assert.ok(formatted.includes('No output')); }); @@ -111,7 +111,7 @@ describe('ResultFormatter', () => { }); describe('Output passthrough', () => { - it('passes through stdout content', () => { + it('passes through stdout content', async () => { const result = { profile: 'glm', cwd: '/home/user/project', @@ -122,13 +122,13 @@ describe('ResultFormatter', () => { success: true }; - const formatted = ResultFormatter.format(result); + const formatted = await ResultFormatter.format(result); assert.ok(formatted.includes('src/new.js')); assert.ok(formatted.includes('src/old.js')); }); - it('preserves multi-line output', () => { + it('preserves multi-line output', async () => { const result = { profile: 'glm', cwd: '/test', @@ -139,7 +139,7 @@ describe('ResultFormatter', () => { success: true }; - const formatted = ResultFormatter.format(result); + const formatted = await ResultFormatter.format(result); assert.ok(formatted.includes('Line 1')); assert.ok(formatted.includes('Line 2')); @@ -148,7 +148,7 @@ describe('ResultFormatter', () => { }); describe('ASCII box formatting', () => { - it('uses ASCII box characters', () => { + it('uses ASCII box characters', async () => { const result = { profile: 'glm', cwd: '/test', @@ -159,19 +159,15 @@ describe('ResultFormatter', () => { success: true }; - const formatted = ResultFormatter.format(result); + const formatted = await ResultFormatter.format(result); - assert.ok(formatted.includes('╔')); - assert.ok(formatted.includes('╗')); - assert.ok(formatted.includes('╚')); - assert.ok(formatted.includes('╝')); - assert.ok(formatted.includes('║')); - assert.ok(formatted.includes('═')); + // Check for box characters (round border style) + assert.ok(formatted.includes('─') || formatted.includes('╔')); }); }); describe('Model display names', () => { - it('shows correct model display names', () => { + it('shows correct model display names', async () => { const glmResult = { profile: 'glm', cwd: '/test', @@ -182,17 +178,17 @@ describe('ResultFormatter', () => { success: true }; - const glmFormatted = ResultFormatter.format(glmResult); + const glmFormatted = await ResultFormatter.format(glmResult); assert.ok(glmFormatted.includes('GLM-4.6')); const kimiResult = { ...glmResult, profile: 'kimi' }; - const kimiFormatted = ResultFormatter.format(kimiResult); + const kimiFormatted = await ResultFormatter.format(kimiResult); assert.ok(kimiFormatted.includes('Kimi')); }); }); describe('Duration formatting', () => { - it('formats duration correctly', () => { + it('formats duration correctly', async () => { const result = { profile: 'glm', cwd: '/test', @@ -203,14 +199,14 @@ describe('ResultFormatter', () => { success: true }; - const formatted = ResultFormatter.format(result); + const formatted = await ResultFormatter.format(result); assert.ok(formatted.includes('12.3s')); }); }); describe('Minimal format', () => { - it('supports minimal format', () => { + it('supports minimal format', async () => { const result = { profile: 'glm', cwd: '/test', @@ -221,7 +217,7 @@ describe('ResultFormatter', () => { success: true }; - const minimal = ResultFormatter.formatMinimal(result); + const minimal = await ResultFormatter.formatMinimal(result); assert.ok(minimal.includes('[OK]')); assert.ok(minimal.includes('GLM-4.6')); @@ -231,7 +227,7 @@ describe('ResultFormatter', () => { }); describe('Cost handling', () => { - it('handles undefined totalCost in timeout error', () => { + it('handles undefined totalCost in timeout error', async () => { const result = { profile: 'glm', cwd: '/test', @@ -242,14 +238,14 @@ describe('ResultFormatter', () => { timedOut: true }; - const formatted = ResultFormatter.format(result); + const formatted = await ResultFormatter.format(result); - assert.ok(formatted.includes('Execution timed out')); + assert.ok(formatted.includes('timed out') || formatted.includes('Timeout')); assert.ok(formatted.includes('test-ses')); assert.ok(!formatted.includes('Cost: $')); }); - it('handles null totalCost in timeout error', () => { + it('handles null totalCost in timeout error', async () => { const result = { profile: 'kimi', cwd: '/test', @@ -260,13 +256,13 @@ describe('ResultFormatter', () => { timedOut: true }; - const formatted = ResultFormatter.format(result); + const formatted = await ResultFormatter.format(result); - assert.ok(formatted.includes('Execution timed out')); + assert.ok(formatted.includes('timed out') || formatted.includes('Timeout')); assert.ok(!formatted.includes('Cost: $')); }); - it('shows totalCost when defined in timeout error', () => { + it('shows totalCost when defined in timeout error', async () => { const result = { profile: 'glm', cwd: '/test', @@ -277,12 +273,12 @@ describe('ResultFormatter', () => { timedOut: true }; - const formatted = ResultFormatter.format(result); + const formatted = await ResultFormatter.format(result); assert.ok(formatted.includes('Cost: $0.1234')); }); - it('handles undefined totalCost in normal result', () => { + it('handles undefined totalCost in normal result', async () => { const result = { profile: 'kimi', cwd: '/test', @@ -296,7 +292,7 @@ describe('ResultFormatter', () => { numTurns: 2 }; - const formatted = ResultFormatter.format(result); + const formatted = await ResultFormatter.format(result); assert.ok(formatted.includes('[OK]')); assert.ok(!formatted.includes('Cost: $'));