diff --git a/ccw/docs/cli-output-converter-usage.md b/ccw/docs/cli-output-converter-usage.md
new file mode 100644
index 00000000..dcbf0c91
--- /dev/null
+++ b/ccw/docs/cli-output-converter-usage.md
@@ -0,0 +1,338 @@
+# CLI Output Converter Usage Guide
+
+## Overview
+
+The CLI Output Converter provides a unified Intermediate Representation (IR) layer for CLI tool output, enabling clean separation between output parsing and consumption scenarios.
+
+## Architecture
+
+```
+┌─────────────────┐
+│ CLI Tool │
+│ (stdout/stderr)│
+└────────┬────────┘
+ │ Buffer chunks
+ ▼
+┌─────────────────┐
+│ Output Parser │
+│ (text/json-lines)│
+└────────┬────────┘
+ │ CliOutputUnit[]
+ ▼
+┌─────────────────────────────────────┐
+│ Intermediate Representation (IR) │
+│ - type: stdout|stderr|thought|... │
+│ - content: string | object │
+│ - timestamp: ISO 8601 │
+└────┬────────────────────────────┬───┘
+ │ │
+ ▼ ▼
+┌─────────────┐ ┌─────────────┐
+│ Storage │ │ View │
+│ (SQLite) │ │ (Dashboard)│
+└─────────────┘ └─────────────┘
+ │
+ ▼
+ ┌─────────────┐
+ │ Resume │
+ │ (Flatten) │
+ └─────────────┘
+```
+
+## Basic Usage
+
+### 1. Creating Parsers
+
+```typescript
+import { createOutputParser } from './cli-output-converter.js';
+
+// For plain text output (e.g., Gemini/Qwen plain mode)
+const textParser = createOutputParser('text');
+
+// For JSON Lines output (e.g., Codex JSONL format)
+const jsonParser = createOutputParser('json-lines');
+```
+
+### 2. Parsing Stream Chunks
+
+```typescript
+import { spawn } from 'child_process';
+import { createOutputParser } from './cli-output-converter.js';
+
+const parser = createOutputParser('json-lines');
+const allUnits: CliOutputUnit[] = [];
+
+const child = spawn('codex', ['run', 'task']);
+
+child.stdout.on('data', (chunk: Buffer) => {
+ const units = parser.parse(chunk, 'stdout');
+ allUnits.push(...units);
+
+ // Real-time processing
+ for (const unit of units) {
+ console.log(`[${unit.type}] ${unit.content}`);
+ }
+});
+
+child.stderr.on('data', (chunk: Buffer) => {
+ const units = parser.parse(chunk, 'stderr');
+ allUnits.push(...units);
+});
+
+child.on('close', () => {
+ // Flush remaining buffer
+ const remaining = parser.flush();
+ allUnits.push(...remaining);
+
+ // Save to storage
+ saveToDatabase(allUnits);
+});
+```
+
+### 3. Integration with CLI Executor
+
+```typescript
+// In cli-executor-core.ts
+import { createOutputParser, type CliOutputUnit } from './cli-output-converter.js';
+
+async function executeCliTool(params: CliParams) {
+ const parser = createOutputParser(params.format === 'json-lines' ? 'json-lines' : 'text');
+ const structuredOutput: CliOutputUnit[] = [];
+
+ child.stdout.on('data', (data) => {
+ const text = data.toString();
+ stdout += text;
+
+ // Parse into IR
+ const units = parser.parse(data, 'stdout');
+ structuredOutput.push(...units);
+
+ // Existing streaming logic
+ if (onOutput) {
+ onOutput({ type: 'stdout', data: text });
+ }
+ });
+
+ child.on('close', () => {
+ // Flush parser
+ structuredOutput.push(...parser.flush());
+
+ // Create turn with structured output
+ const newTurn: ConversationTurn = {
+ turn: turnNumber,
+ timestamp: new Date().toISOString(),
+ prompt,
+ duration_ms,
+ status,
+ exit_code: code,
+ output: {
+ stdout: stdout.substring(0, 10240),
+ stderr: stderr.substring(0, 2048),
+ truncated: stdout.length > 10240 || stderr.length > 2048,
+ cached: shouldCache,
+ stdout_full: shouldCache ? stdout : undefined,
+ stderr_full: shouldCache ? stderr : undefined,
+ structured: structuredOutput.length > 0 ? structuredOutput : undefined
+ }
+ };
+ });
+}
+```
+
+## Scenario-Specific Usage
+
+### Scenario 1: View (Dashboard Display)
+
+```typescript
+// In dashboard rendering logic
+import { type CliOutputUnit } from '../tools/cli-output-converter.js';
+
+function renderOutputUnits(units: CliOutputUnit[]) {
+ return units.map(unit => {
+ switch (unit.type) {
+ case 'thought':
+ return `
${unit.content}
`;
+ case 'code':
+ return `${unit.content}
`;
+ case 'file_diff':
+ return renderDiff(unit.content);
+ case 'progress':
+ return renderProgress(unit.content);
+ default:
+ return `${unit.content}
`;
+ }
+ }).join('\n');
+}
+```
+
+### Scenario 2: Storage (Backward Compatible)
+
+```typescript
+// In cli-history-store.ts
+function saveConversation(conversation: ConversationRecord) {
+ for (const turn of conversation.turns) {
+ // Save traditional fields
+ db.run(`
+ INSERT INTO turns (stdout, stderr, truncated, ...)
+ VALUES (?, ?, ?, ...)
+ `, turn.output.stdout, turn.output.stderr, turn.output.truncated);
+
+ // Optionally save structured output
+ if (turn.output.structured) {
+ db.run(`
+ INSERT INTO turn_structured_output (turn_id, units)
+ VALUES (?, ?)
+ `, turnId, JSON.stringify(turn.output.structured));
+ }
+ }
+}
+```
+
+### Scenario 3: Resume (Context Concatenation)
+
+```typescript
+// In resume-strategy.ts or cli-prompt-builder.ts
+import { flattenOutputUnits } from './cli-output-converter.js';
+
+function buildContextFromTurns(turns: ConversationTurn[]): string {
+ const lines: string[] = [];
+
+ for (const turn of turns) {
+ lines.push(`USER: ${turn.prompt}`);
+
+ // Use structured output if available
+ if (turn.output.structured) {
+ const assistantText = flattenOutputUnits(turn.output.structured, {
+ excludeTypes: ['metadata', 'system'], // Skip noise
+ includeTypes: ['stdout', 'thought', 'code'] // Keep meaningful content
+ });
+ lines.push(`ASSISTANT: ${assistantText}`);
+ } else {
+ // Fallback to plain stdout
+ lines.push(`ASSISTANT: ${turn.output.stdout}`);
+ }
+ }
+
+ return lines.join('\n\n');
+}
+```
+
+## Advanced Features
+
+### Filtering by Type
+
+```typescript
+import { flattenOutputUnits, extractContent } from './cli-output-converter.js';
+
+// Extract only AI thoughts for analysis
+const thoughts = extractContent(units, 'thought');
+
+// Get only code blocks
+const codeBlocks = extractContent(units, 'code');
+
+// Create clean context (exclude system noise)
+const cleanContext = flattenOutputUnits(units, {
+ excludeTypes: ['metadata', 'system', 'progress']
+});
+```
+
+### Analytics
+
+```typescript
+import { getOutputStats } from './cli-output-converter.js';
+
+const stats = getOutputStats(units);
+console.log(`Total units: ${stats.total}`);
+console.log(`Thoughts: ${stats.byType.thought || 0}`);
+console.log(`Code blocks: ${stats.byType.code || 0}`);
+console.log(`Duration: ${stats.firstTimestamp} - ${stats.lastTimestamp}`);
+```
+
+### Custom Processing
+
+```typescript
+function processUnits(units: CliOutputUnit[]) {
+ for (const unit of units) {
+ switch (unit.type) {
+ case 'file_diff':
+ // Apply diff to file system
+ applyDiff(unit.content.path, unit.content.diff);
+ break;
+ case 'progress':
+ // Update progress bar
+ updateProgress(unit.content.progress, unit.content.total);
+ break;
+ case 'thought':
+ // Log reasoning
+ logThought(unit.content);
+ break;
+ }
+ }
+}
+```
+
+## Type Reference
+
+### CliOutputUnitType
+
+```typescript
+type CliOutputUnitType =
+ | 'stdout' // Standard output text
+ | 'stderr' // Standard error text
+ | 'thought' // AI reasoning/thinking
+ | 'code' // Code block content
+ | 'file_diff' // File modification diff
+ | 'progress' // Progress updates
+ | 'metadata' // Session/execution metadata
+ | 'system'; // System events/messages
+```
+
+### CliOutputUnit
+
+```typescript
+interface CliOutputUnit {
+ type: CliOutputUnitType;
+ content: T; // string for text types, object for structured types
+ timestamp: string; // ISO 8601 format
+}
+```
+
+## Migration Path
+
+### Phase 1: Optional Enhancement (Current)
+- Add `structured` field to `ConversationTurn.output`
+- Populate during parsing (optional)
+- Existing code ignores it (backward compatible)
+
+### Phase 2: View Integration
+- Dashboard uses `structured` when available
+- Falls back to plain `stdout` when not present
+- Better rendering for thoughts, code, diffs
+
+### Phase 3: Resume Optimization
+- Resume logic prefers `structured` for cleaner context
+- Filters out noise (metadata, system events)
+- Reduces token usage while preserving semantics
+
+### Phase 4: Full Adoption
+- All CLI tools use converters
+- Storage optimized for structured data
+- Analytics and insights from IR layer
+
+## Best Practices
+
+1. **Always flush the parser** when stream ends to capture incomplete lines
+2. **Filter by type** for Resume scenarios to reduce token usage
+3. **Use structured content** when available for better display
+4. **Keep backward compatibility** by making `structured` optional
+5. **Handle missing fields** gracefully (not all tools output JSON)
+6. **Validate JSON** before parsing to avoid crashes
+
+## Future Enhancements
+
+1. **Streaming transformers** - Apply transformations during parsing
+2. **Custom type mappers** - Register custom JSON-to-IR mappings
+3. **Compression** - Store structured output more efficiently
+4. **Semantic search** - Index thoughts and code separately
+5. **Diff viewer** - Interactive file_diff rendering
+6. **Progress tracking** - Aggregate progress events across tools
diff --git a/ccw/src/commands/cli.ts b/ccw/src/commands/cli.ts
index 55d749e1..25d3bf13 100644
--- a/ccw/src/commands/cli.ts
+++ b/ccw/src/commands/cli.ts
@@ -6,6 +6,7 @@
import chalk from 'chalk';
import http from 'http';
import inquirer from 'inquirer';
+import type { CliOutputUnit } from '../tools/cli-output-converter.js';
import {
cliExecutorTool,
getCliToolsStatus,
@@ -725,7 +726,8 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec
// Generate execution ID for streaming (use custom ID or timestamp-based)
const executionId = id || `${Date.now()}-${tool}`;
const startTime = Date.now();
- const spinnerBaseText = `Executing ${tool} (${mode} mode${resumeInfo}${nativeMode})${idInfo}...`;
+ const modelInfo = model ? ` @${model}` : '';
+ const spinnerBaseText = `Executing ${tool}${modelInfo} (${mode} mode${resumeInfo}${nativeMode})${idInfo}...`;
console.log();
const spinner = stream ? null : createSpinner(` ${spinnerBaseText}`).start();
@@ -787,20 +789,49 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec
});
// Streaming output handler - broadcasts to dashboard AND writes to stdout
- const onOutput = (chunk: any) => {
+ const onOutput = (unit: CliOutputUnit) => {
// Always broadcast to dashboard for real-time viewing
// Note: /api/hook wraps extraData into payload, so send fields directly
+ // Maintain backward compatibility with frontend expecting { chunkType, data }
+ const content = typeof unit.content === 'string' ? unit.content : JSON.stringify(unit.content);
broadcastStreamEvent('CLI_OUTPUT', {
executionId,
- chunkType: chunk.type,
- data: chunk.data
+ chunkType: unit.type, // For backward compatibility
+ data: content, // For backward compatibility
+ unit // New structured format
});
+
// Write to terminal only when --stream flag is passed
if (stream) {
- process.stdout.write(chunk.data);
+ switch (unit.type) {
+ case 'stdout':
+ case 'code':
+ process.stdout.write(typeof unit.content === 'string' ? unit.content : JSON.stringify(unit.content));
+ break;
+ case 'stderr':
+ process.stderr.write(typeof unit.content === 'string' ? unit.content : JSON.stringify(unit.content));
+ break;
+ case 'thought':
+ // Optional: display thinking process with different color
+ // For now, skip to reduce noise
+ break;
+ case 'progress':
+ // Optional: update progress bar
+ // For now, skip
+ break;
+ default:
+ // Other types: output content if available
+ if (unit.content) {
+ process.stdout.write(typeof unit.content === 'string' ? unit.content : '');
+ }
+ }
}
};
+ // Use JSON-lines parsing by default to enable type badges (thought, code, file_diff, etc.)
+ // All CLI tools may output structured JSON that can be parsed for richer UI
+ const outputFormat = 'json-lines';
+
try {
const result = await cliExecutorTool.execute({
tool,
@@ -813,7 +844,8 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec
resume,
id, // custom execution ID
noNative,
- stream: !!stream // stream=true → streaming enabled (no cache), stream=false → cache output (default)
+ stream: !!stream, // stream=true → streaming enabled (no cache), stream=false → cache output (default)
+ outputFormat // Enable JSONL parsing for tools that support it
}, onOutput); // Always pass onOutput for real-time dashboard streaming
if (elapsedInterval) clearInterval(elapsedInterval);
diff --git a/ccw/src/core/auth/csrf-middleware.ts b/ccw/src/core/auth/csrf-middleware.ts
index d0c85d32..119d1fba 100644
--- a/ccw/src/core/auth/csrf-middleware.ts
+++ b/ccw/src/core/auth/csrf-middleware.ts
@@ -118,8 +118,9 @@ export async function csrfValidation(ctx: CsrfMiddlewareContext): Promise {
stream: false,
category: 'internal',
id: syncId
- }, onOutput);
+ }, (unit) => {
+ // CliOutputUnit handler: convert to string content for broadcast
+ const content = typeof unit.content === 'string' ? unit.content : JSON.stringify(unit.content);
+ broadcastToClients({
+ type: 'CLI_OUTPUT',
+ payload: {
+ executionId: syncId,
+ chunkType: unit.type,
+ data: content
+ }
+ });
+ });
// Broadcast CLI_EXECUTION_COMPLETED event
broadcastToClients({
diff --git a/ccw/src/core/routes/cli-routes.ts b/ccw/src/core/routes/cli-routes.ts
index 01f1506f..c6b4e618 100644
--- a/ccw/src/core/routes/cli-routes.ts
+++ b/ccw/src/core/routes/cli-routes.ts
@@ -195,7 +195,7 @@ export async function handleCliRoutes(ctx: RouteContext): Promise {
}
// API: Get/Update Tool Config
- const configMatch = pathname.match(/^\/api\/cli\/config\/(gemini|qwen|codex)$/);
+ const configMatch = pathname.match(/^\/api\/cli\/config\/(gemini|qwen|codex|claude|opencode)$/);
if (configMatch) {
const tool = configMatch[1];
@@ -216,7 +216,7 @@ export async function handleCliRoutes(ctx: RouteContext): Promise {
if (req.method === 'PUT') {
handlePostRequest(req, res, async (body: unknown) => {
try {
- const updates = body as { enabled?: boolean; primaryModel?: string; secondaryModel?: string };
+ const updates = body as { enabled?: boolean; primaryModel?: string; secondaryModel?: string; tags?: string[] };
const updated = updateToolConfig(initialPath, tool, updates);
// Broadcast config updated event
@@ -559,19 +559,22 @@ export async function handleCliRoutes(ctx: RouteContext): Promise {
category: category || 'user',
parentExecutionId,
stream: true
- }, (chunk) => {
- // Append chunk to active execution buffer
+ }, (unit) => {
+ // CliOutputUnit handler: convert to string content
+ const content = typeof unit.content === 'string' ? unit.content : JSON.stringify(unit.content);
+
+ // Append to active execution buffer
const activeExec = activeExecutions.get(executionId);
if (activeExec) {
- activeExec.output += chunk.data || '';
+ activeExec.output += content || '';
}
broadcastToClients({
type: 'CLI_OUTPUT',
payload: {
executionId,
- chunkType: chunk.type,
- data: chunk.data
+ chunkType: unit.type,
+ data: content
}
});
});
diff --git a/ccw/src/core/routes/memory-routes.ts b/ccw/src/core/routes/memory-routes.ts
index cf8c9a45..24191289 100644
--- a/ccw/src/core/routes/memory-routes.ts
+++ b/ccw/src/core/routes/memory-routes.ts
@@ -1007,7 +1007,18 @@ RULES: Be concise. Focus on practical understanding. Include function signatures
stream: false,
category: 'internal',
id: syncId
- }, onOutput);
+ }, (unit) => {
+ // CliOutputUnit handler: convert to string content for broadcast
+ const content = typeof unit.content === 'string' ? unit.content : JSON.stringify(unit.content);
+ broadcastToClients({
+ type: 'CLI_OUTPUT',
+ payload: {
+ executionId: syncId,
+ chunkType: unit.type,
+ data: content
+ }
+ });
+ });
// Broadcast CLI_EXECUTION_COMPLETED event
broadcastToClients({
diff --git a/ccw/src/core/routes/rules-routes.ts b/ccw/src/core/routes/rules-routes.ts
index f794e443..1e3003fd 100644
--- a/ccw/src/core/routes/rules-routes.ts
+++ b/ccw/src/core/routes/rules-routes.ts
@@ -661,13 +661,15 @@ FILE NAME: ${fileName}`;
// Create onOutput callback for real-time streaming
const onOutput = broadcastToClients
- ? (chunk: { type: string; data: string }) => {
+ ? (unit: import('../../tools/cli-output-converter.js').CliOutputUnit) => {
+ // CliOutputUnit handler: convert to string content for broadcast
+ const content = typeof unit.content === 'string' ? unit.content : JSON.stringify(unit.content);
broadcastToClients({
type: 'CLI_OUTPUT',
payload: {
executionId,
- chunkType: chunk.type,
- data: chunk.data
+ chunkType: unit.type,
+ data: content
}
});
}
@@ -746,13 +748,15 @@ FILE NAME: ${fileName}`;
// Create onOutput callback for review step
const reviewOnOutput = broadcastToClients
- ? (chunk: { type: string; data: string }) => {
+ ? (unit: import('../../tools/cli-output-converter.js').CliOutputUnit) => {
+ // CliOutputUnit handler: convert to string content for broadcast
+ const content = typeof unit.content === 'string' ? unit.content : JSON.stringify(unit.content);
broadcastToClients({
type: 'CLI_OUTPUT',
payload: {
executionId: reviewExecutionId,
- chunkType: chunk.type,
- data: chunk.data
+ chunkType: unit.type,
+ data: content
}
});
}
diff --git a/ccw/src/core/routes/skills-routes.ts b/ccw/src/core/routes/skills-routes.ts
index 0354fb7d..89a702ce 100644
--- a/ccw/src/core/routes/skills-routes.ts
+++ b/ccw/src/core/routes/skills-routes.ts
@@ -579,13 +579,15 @@ Create a new Claude Code skill with the following specifications:
// Create onOutput callback for real-time streaming
const onOutput = broadcastToClients
- ? (chunk: { type: string; data: string }) => {
+ ? (unit: import('../../tools/cli-output-converter.js').CliOutputUnit) => {
+ // CliOutputUnit handler: convert to string content for broadcast
+ const content = typeof unit.content === 'string' ? unit.content : JSON.stringify(unit.content);
broadcastToClients({
type: 'CLI_OUTPUT',
payload: {
executionId,
- chunkType: chunk.type,
- data: chunk.data
+ chunkType: unit.type,
+ data: content
}
});
}
diff --git a/ccw/src/core/server.ts b/ccw/src/core/server.ts
index 2580af8a..6d8a8b8c 100644
--- a/ccw/src/core/server.ts
+++ b/ccw/src/core/server.ts
@@ -357,7 +357,7 @@ export async function startServer(options: ServerOptions = {}): Promise(['/api/auth/token', '/api/csrf-token']);
+ const unauthenticatedPaths = new Set(['/api/auth/token', '/api/csrf-token', '/api/hook']);
const server = http.createServer(async (req, res) => {
const url = new URL(req.url ?? '/', `http://localhost:${serverPort}`);
diff --git a/ccw/src/templates/dashboard-css/10-cli-status.css b/ccw/src/templates/dashboard-css/10-cli-status.css
index 46bb22c4..8522a709 100644
--- a/ccw/src/templates/dashboard-css/10-cli-status.css
+++ b/ccw/src/templates/dashboard-css/10-cli-status.css
@@ -203,6 +203,168 @@
color: hsl(142 76% 36%);
}
+/* Tool Tags - displayed in tool cards */
+.tool-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.25rem;
+ margin-top: 0.25rem;
+}
+
+.tool-tag {
+ font-size: 0.5625rem;
+ font-weight: 500;
+ padding: 0.125rem 0.375rem;
+ background: hsl(var(--primary) / 0.1);
+ color: hsl(var(--primary));
+ border-radius: 0.25rem;
+}
+
+/* Tags Input - used in config modal */
+.tags-input-group {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+/* Unified tag input - tags and input in one container */
+.tags-unified-input {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.375rem;
+ min-height: 2.5rem;
+ padding: 0.375rem 0.5rem;
+ background: hsl(var(--background));
+ border: 1px solid hsl(var(--border));
+ border-radius: 0.5rem;
+ cursor: text;
+ transition: border-color 0.15s ease;
+}
+
+.tags-unified-input:focus-within {
+ border-color: hsl(var(--primary));
+ box-shadow: 0 0 0 2px hsl(var(--primary) / 0.1);
+}
+
+.tag-inline-input {
+ flex: 1;
+ min-width: 120px;
+ border: none;
+ background: transparent;
+ outline: none;
+ font-size: 0.8125rem;
+ color: hsl(var(--foreground));
+ padding: 0.25rem 0;
+}
+
+.tag-inline-input::placeholder {
+ color: hsl(var(--muted-foreground) / 0.6);
+}
+
+.tags-container {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.375rem;
+ min-height: 1.75rem;
+ padding: 0.25rem;
+ background: hsl(var(--muted) / 0.3);
+ border-radius: 0.375rem;
+}
+
+.tag-item {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ font-size: 0.75rem;
+ font-weight: 500;
+ padding: 0.25rem 0.5rem;
+ background: hsl(var(--primary) / 0.15);
+ color: hsl(var(--primary));
+ border-radius: 0.25rem;
+}
+
+.tag-remove {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 1rem;
+ height: 1rem;
+ padding: 0;
+ border: none;
+ background: transparent;
+ color: hsl(var(--primary) / 0.6);
+ cursor: pointer;
+ font-size: 0.875rem;
+ line-height: 1;
+ border-radius: 0.125rem;
+}
+
+.tag-remove:hover {
+ background: hsl(var(--destructive) / 0.2);
+ color: hsl(var(--destructive));
+}
+
+/* Predefined Tags Row - prominent quick add buttons */
+.predefined-tags-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ margin-top: 0.75rem;
+}
+
+.predefined-tag-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.375rem;
+ font-size: 0.75rem;
+ font-weight: 500;
+ padding: 0.375rem 0.625rem;
+ background: hsl(var(--muted));
+ color: hsl(var(--foreground));
+ border: 1px solid hsl(var(--border));
+ border-radius: 0.375rem;
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.predefined-tag-btn:hover {
+ background: hsl(var(--primary) / 0.15);
+ color: hsl(var(--primary));
+ border-color: hsl(var(--primary) / 0.3);
+}
+
+.predefined-tag-btn i {
+ opacity: 0.7;
+}
+
+/* Legacy predefined tags */
+.predefined-tags {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.375rem;
+ margin-top: 0.5rem;
+}
+
+.predefined-tag {
+ font-size: 0.6875rem;
+ font-weight: 500;
+ padding: 0.25rem 0.5rem;
+ background: hsl(var(--muted) / 0.5);
+ color: hsl(var(--muted-foreground));
+ border: 1px dashed hsl(var(--border));
+ border-radius: 0.25rem;
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.predefined-tag:hover {
+ background: hsl(var(--primary) / 0.1);
+ color: hsl(var(--primary));
+ border-color: hsl(var(--primary) / 0.3);
+}
+
.tool-item-right {
display: flex;
align-items: center;
diff --git a/ccw/src/templates/dashboard-css/33-cli-stream-viewer.css b/ccw/src/templates/dashboard-css/33-cli-stream-viewer.css
index cf94f041..b309caf2 100644
--- a/ccw/src/templates/dashboard-css/33-cli-stream-viewer.css
+++ b/ccw/src/templates/dashboard-css/33-cli-stream-viewer.css
@@ -549,6 +549,77 @@
color: hsl(200 70% 70%);
}
+/* ===== Backend ChunkType Badges (CliOutputUnit.type) ===== */
+
+/* Thought/Thinking Message (from JSONL parser) */
+.cli-stream-line.formatted.thought {
+ background: hsl(280 50% 20% / 0.3);
+ border-left: 3px solid hsl(280 70% 65%);
+ font-style: italic;
+}
+
+.cli-msg-badge.cli-msg-thought {
+ background: hsl(280 70% 65% / 0.2);
+ color: hsl(280 70% 75%);
+}
+
+/* Code Block Message */
+.cli-stream-line.formatted.code {
+ background: hsl(220 40% 18% / 0.4);
+ border-left: 3px solid hsl(220 60% 55%);
+ font-family: var(--font-mono, 'Consolas', 'Monaco', 'Courier New', monospace);
+}
+
+.cli-msg-badge.cli-msg-code {
+ background: hsl(220 60% 55% / 0.25);
+ color: hsl(220 60% 70%);
+}
+
+/* File Diff Message */
+.cli-stream-line.formatted.file_diff {
+ background: hsl(35 50% 18% / 0.4);
+ border-left: 3px solid hsl(35 80% 55%);
+}
+
+.cli-msg-badge.cli-msg-file_diff {
+ background: hsl(35 80% 55% / 0.25);
+ color: hsl(35 80% 65%);
+}
+
+/* Progress Message */
+.cli-stream-line.formatted.progress {
+ background: hsl(190 40% 18% / 0.3);
+ border-left: 3px solid hsl(190 70% 50%);
+}
+
+.cli-msg-badge.cli-msg-progress {
+ background: hsl(190 70% 50% / 0.2);
+ color: hsl(190 70% 65%);
+}
+
+/* Metadata Message */
+.cli-stream-line.formatted.metadata {
+ background: hsl(250 30% 18% / 0.3);
+ border-left: 3px solid hsl(250 50% 60%);
+ font-size: 0.85em;
+}
+
+.cli-msg-badge.cli-msg-metadata {
+ background: hsl(250 50% 60% / 0.2);
+ color: hsl(250 50% 75%);
+}
+
+/* Stderr Message (Error) */
+.cli-stream-line.formatted.stderr {
+ background: hsl(0 50% 20% / 0.4);
+ border-left: 3px solid hsl(0 70% 55%);
+}
+
+.cli-msg-badge.cli-msg-stderr {
+ background: hsl(0 70% 55% / 0.25);
+ color: hsl(0 70% 70%);
+}
+
/* Inline Code */
.cli-inline-code {
padding: 1px 5px;
diff --git a/ccw/src/templates/dashboard-js/components/cli-stream-viewer.js b/ccw/src/templates/dashboard-js/components/cli-stream-viewer.js
index 9c87efc4..aa967fa4 100644
--- a/ccw/src/templates/dashboard-js/components/cli-stream-viewer.js
+++ b/ccw/src/templates/dashboard-js/components/cli-stream-viewer.js
@@ -346,16 +346,50 @@ function renderFormattedLine(line, searchFilter) {
// Format inline code
content = content.replace(/`([^`]+)`/g, '$1');
- // Build type badge if has prefix
- const typeBadge = parsed.hasPrefix ?
- `
+ // Type badge icons for backend chunkType (CliOutputUnit.type)
+ const CHUNK_TYPE_ICONS = {
+ thought: 'brain',
+ code: 'code',
+ file_diff: 'git-compare',
+ progress: 'loader',
+ system: 'settings',
+ stderr: 'alert-circle',
+ metadata: 'info'
+ };
+
+ // Type badge labels for backend chunkType
+ const CHUNK_TYPE_LABELS = {
+ thought: 'Thinking',
+ code: 'Code',
+ file_diff: 'Diff',
+ progress: 'Progress',
+ system: 'System',
+ stderr: 'Error',
+ metadata: 'Info'
+ };
+
+ // Build type badge - prioritize content prefix, then fall back to chunkType
+ let typeBadge = '';
+ let lineClass = '';
+
+ if (parsed.hasPrefix) {
+ // Content has Chinese prefix like [系统], [思考], etc.
+ typeBadge = `
${parsed.label}
- ` : '';
-
- // Determine line class based on original type and parsed type
- const lineClass = parsed.hasPrefix ? `cli-stream-line formatted ${parsed.type}` :
- `cli-stream-line ${line.type}`;
+ `;
+ lineClass = `cli-stream-line formatted ${parsed.type}`;
+ } else if (line.type && line.type !== 'stdout' && CHUNK_TYPE_LABELS[line.type]) {
+ // No content prefix, but backend sent a meaningful chunkType
+ typeBadge = `
+
+ ${CHUNK_TYPE_LABELS[line.type]}
+ `;
+ lineClass = `cli-stream-line formatted ${line.type}`;
+ } else {
+ // Plain stdout, no badge
+ lineClass = `cli-stream-line ${line.type || 'stdout'}`;
+ }
return `${typeBadge}${content}
`;
}
diff --git a/ccw/src/templates/dashboard-js/i18n.js b/ccw/src/templates/dashboard-js/i18n.js
index 6fd91450..bdc3a6ee 100644
--- a/ccw/src/templates/dashboard-js/i18n.js
+++ b/ccw/src/templates/dashboard-js/i18n.js
@@ -1721,6 +1721,28 @@ const i18n = {
'apiSettings.modelIdExists': 'Model ID already exists',
'apiSettings.useModelTreeToManage': 'Use the model tree to manage individual models',
+ // CLI Settings
+ 'apiSettings.cliSettings': 'CLI Settings',
+ 'apiSettings.addCliSettings': 'Add CLI Settings',
+ 'apiSettings.editCliSettings': 'Edit CLI Settings',
+ 'apiSettings.noCliSettings': 'No CLI settings configured',
+ 'apiSettings.noCliSettingsSelected': 'No CLI Settings Selected',
+ 'apiSettings.cliSettingsHint': 'Select a CLI settings endpoint or create a new one',
+ 'apiSettings.cliProviderHint': 'Select an Anthropic provider to use its API key and base URL',
+ 'apiSettings.noAnthropicProviders': 'No Anthropic providers configured. Please add one in the Providers tab first.',
+ 'apiSettings.selectProviderFirst': 'Select a provider first',
+ 'apiSettings.providerRequired': 'Provider is required',
+ 'apiSettings.modelRequired': 'Model is required',
+ 'apiSettings.providerNotFound': 'Provider not found',
+ 'apiSettings.settingsSaved': 'Settings saved successfully',
+ 'apiSettings.settingsDeleted': 'Settings deleted successfully',
+ 'apiSettings.confirmDeleteSettings': 'Are you sure you want to delete this CLI settings?',
+ 'apiSettings.endpointName': 'Endpoint Name',
+ 'apiSettings.envSettings': 'Environment Settings',
+ 'apiSettings.settingsFilePath': 'Settings File Path',
+ 'apiSettings.nameRequired': 'Name is required',
+ 'apiSettings.status': 'Status',
+
// Common
'common.cancel': 'Cancel',
'common.optional': '(Optional)',
@@ -3777,6 +3799,29 @@ const i18n = {
'apiSettings.modelIdExists': '模型 ID 已存在',
'apiSettings.useModelTreeToManage': '使用模型树管理各个模型',
+ // CLI Settings
+ 'apiSettings.cliSettings': 'CLI 配置',
+ 'apiSettings.addCliSettings': '添加 CLI 配置',
+ 'apiSettings.editCliSettings': '编辑 CLI 配置',
+ 'apiSettings.noCliSettings': '未配置 CLI 设置',
+ 'apiSettings.noCliSettingsSelected': '未选择 CLI 配置',
+ 'apiSettings.cliSettingsHint': '选择一个 CLI 配置端点或创建新的',
+ 'apiSettings.cliProviderHint': '选择一个 Anthropic 供应商以使用其 API 密钥和基础 URL',
+ 'apiSettings.noAnthropicProviders': '未配置 Anthropic 供应商。请先在供应商标签页中添加。',
+ 'apiSettings.selectProviderFirst': '请先选择供应商',
+ 'apiSettings.providerRequired': '供应商为必填项',
+ 'apiSettings.modelRequired': '模型为必填项',
+ 'apiSettings.providerNotFound': '未找到供应商',
+ 'apiSettings.settingsSaved': '设置保存成功',
+ 'apiSettings.settingsDeleted': '设置删除成功',
+ 'apiSettings.confirmDeleteSettings': '确定要删除此 CLI 配置吗?',
+ 'apiSettings.endpointName': '端点名称',
+ 'apiSettings.envSettings': '环境变量设置',
+ 'apiSettings.settingsFilePath': '配置文件路径',
+ 'apiSettings.nameRequired': '名称为必填项',
+ 'apiSettings.tokenRequired': 'API 令牌为必填项',
+ 'apiSettings.status': '状态',
+
// Common
'common.cancel': '取消',
'common.optional': '(可选)',
diff --git a/ccw/src/templates/dashboard-js/views/api-settings.js b/ccw/src/templates/dashboard-js/views/api-settings.js
index 2bc5a266..10895641 100644
--- a/ccw/src/templates/dashboard-js/views/api-settings.js
+++ b/ccw/src/templates/dashboard-js/views/api-settings.js
@@ -3737,23 +3737,94 @@ function renderCliSettingsEmptyState() {
if (window.lucide) lucide.createIcons();
}
+/**
+ * Get available Anthropic providers
+ */
+function getAvailableAnthropicProviders() {
+ if (!apiSettingsData || !apiSettingsData.providers) {
+ return [];
+ }
+
+ return apiSettingsData.providers.filter(function(p) {
+ return p.type === 'anthropic' && p.enabled;
+ });
+}
+
+/**
+ * Build provider options HTML for CLI Settings
+ */
+function buildCliProviderOptions(selectedProviderId) {
+ var providers = getAvailableAnthropicProviders();
+ var optionsHtml = '';
+
+ providers.forEach(function(provider) {
+ var isSelected = provider.id === selectedProviderId ? ' selected' : '';
+ optionsHtml += '';
+ });
+
+ return optionsHtml;
+}
+
+/**
+ * Build model options HTML for CLI Settings based on selected provider
+ */
+function buildCliModelOptions(providerId, selectedModel) {
+ var providers = getAvailableAnthropicProviders();
+ var provider = providers.find(function(p) { return p.id === providerId; });
+
+ if (!provider || !provider.llmModels || provider.llmModels.length === 0) {
+ return '';
+ }
+
+ var optionsHtml = '';
+ provider.llmModels.forEach(function(model) {
+ var isSelected = model.id === selectedModel ? ' selected' : '';
+ optionsHtml += '';
+ });
+
+ return optionsHtml;
+}
+
+/**
+ * Update CLI Settings model dropdown when provider changes
+ */
+function onCliProviderChange() {
+ var providerId = document.getElementById('cli-settings-provider').value;
+ var modelSelect = document.getElementById('cli-settings-model');
+
+ if (modelSelect) {
+ modelSelect.innerHTML = buildCliModelOptions(providerId, '');
+ }
+}
+
/**
* Show Add CLI Settings Modal
*/
function showAddCliSettingsModal(existingEndpoint) {
var isEdit = !!existingEndpoint;
- var settings = existingEndpoint ? existingEndpoint.settings : { env: {}, model: 'sonnet' };
- var env = settings.env || {};
+ var settings = existingEndpoint ? existingEndpoint.settings : { env: {}, model: '' };
+ var selectedProviderId = settings.providerId || '';
+ var providerOptionsHtml = buildCliProviderOptions(selectedProviderId);
+ var modelOptionsHtml = buildCliModelOptions(selectedProviderId, settings.model);
+
+ // Check if any Anthropic providers are configured
+ var hasProviders = getAvailableAnthropicProviders().length > 0;
+ var noProvidersWarning = !hasProviders ?
+ '' +
+ '' +
+ '' + t('apiSettings.noAnthropicProviders') + '' +
+ '
' : '';
var modalHtml =
- '