mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 02:11:28 +00:00
feat(ui): enhance delegation with listr2 task lists and styled output
Phase 5 of CLI UI/UX Enhancement - Delegation + Listr2 Added Listr2 integration for enhanced delegation progress display: - Intelligent renderer selection (TTY/CI/Claude Code context detection) - Styled result boxes and tables using ui layer - Graceful fallback chain: Listr2 → Spinners → Plain text - All delegation formatters converted to async Changes: - src/utils/ui.ts: Added taskList wrapper, isClaudeCodeContext detection - src/delegation/result-formatter.ts: Async formatting with styled boxes/tables - src/delegation/delegation-handler.ts: Async format calls - src/delegation/headless-executor.ts: UI layer progress messages - tests: Updated all formatter tests to handle async - docs: Enhanced code standards and codebase summary All validation passing (157/157 tests) Phase 5 complete - All 5 phases delivered
This commit is contained in:
+3
-1
@@ -29,4 +29,6 @@ dist/
|
||||
|
||||
# Package lock files (keep package-lock.json but ignore others)
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
pnpm-lock.yaml
|
||||
|
||||
.claude/active-plan
|
||||
@@ -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=="],
|
||||
|
||||
+217
-19
@@ -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<string> {
|
||||
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.
|
||||
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.
|
||||
+39
-21
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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;
|
||||
|
||||
+120
-1
@@ -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<void> {
|
||||
|
||||
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<T> {
|
||||
title: string;
|
||||
task: (ctx: T) => Promise<void> | 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<T>(tasks: TaskItem<T>[], options: TaskListOptions = {}): Promise<T> {
|
||||
// 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<T>(tasks: TaskItem<T>[]): Promise<T> {
|
||||
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,
|
||||
|
||||
@@ -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: $'));
|
||||
|
||||
Reference in New Issue
Block a user