Initial Release: OpenQode Public Alpha v1.3
This commit is contained in:
67
bin/auth.js
Normal file
67
bin/auth.js
Normal file
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* OpenQode Authentication Helper
|
||||
* Handles the Vision API OAuth flow during installation.
|
||||
*/
|
||||
|
||||
const { QwenOAuth } = require('../qwen-oauth');
|
||||
const readline = require('readline');
|
||||
const { exec } = require('child_process');
|
||||
const os = require('os');
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
const oauth = new QwenOAuth();
|
||||
|
||||
async function openBrowser(url) {
|
||||
const platform = os.platform();
|
||||
let command;
|
||||
|
||||
if (platform === 'win32') {
|
||||
command = `start "${url}"`;
|
||||
} else if (platform === 'darwin') {
|
||||
command = `open "${url}"`;
|
||||
} else {
|
||||
command = `xdg-open "${url}"`;
|
||||
}
|
||||
|
||||
exec(command, (error) => {
|
||||
if (error) {
|
||||
console.log(' (Please open the URL manually if it didn\'t open)');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n========================================================');
|
||||
console.log(' OpenQode Vision API Authentication');
|
||||
console.log('========================================================\n');
|
||||
console.log('This step authorizes OpenQode to see images (Vision features).');
|
||||
console.log('You will also be asked to login to the CLI separately if needed.\n');
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const flow = await oauth.startDeviceFlow();
|
||||
|
||||
console.log(`\n 1. Your User Code is: \x1b[1;33m${flow.userCode}\x1b[0m`);
|
||||
console.log(` 2. Please verify at: \x1b[1;36m${flow.verificationUri}\x1b[0m`);
|
||||
console.log('\n Opening browser...');
|
||||
|
||||
openBrowser(flow.verificationUriComplete || flow.verificationUri);
|
||||
|
||||
console.log('\n Waiting for you to complete login in the browser...');
|
||||
|
||||
const tokens = await oauth.pollForTokens();
|
||||
|
||||
console.log('\n\x1b[1;32m Success! Vision API authenticated.\x1b[0m');
|
||||
console.log(' Tokens saved to .qwen-tokens.json\n');
|
||||
|
||||
} catch (error) {
|
||||
console.error(`\n\x1b[1;31m Authentication failed: ${error.message}\x1b[0m\n`);
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
})();
|
||||
332
bin/ink-markdown-esm.mjs
Normal file
332
bin/ink-markdown-esm.mjs
Normal file
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* Block-Based Markdown Renderer for Ink
|
||||
*
|
||||
* CRITICAL FIX: This renderer ensures headings, paragraphs, and lists
|
||||
* are NEVER merged into the same line. Each block is a separate Box.
|
||||
*
|
||||
* The previous bug: "## Initial Observationssome general thoughts"
|
||||
* happened because inline rendering merged blocks.
|
||||
*
|
||||
* This renderer:
|
||||
* 1. Parses markdown into AST using remark
|
||||
* 2. Converts AST to block array
|
||||
* 3. Renders each block as a separate Ink Box with spacing
|
||||
*/
|
||||
|
||||
import { unified } from 'unified';
|
||||
import remarkParse from 'remark-parse';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import Highlight from 'ink-syntax-highlight';
|
||||
import he from 'he';
|
||||
import { theme } from './tui-theme.mjs';
|
||||
|
||||
const h = React.createElement;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// BLOCK TYPES
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Block types that get their own Box with spacing:
|
||||
* - heading: #, ##, ###
|
||||
* - paragraph: plain text blocks
|
||||
* - code: fenced code blocks
|
||||
* - list: ul/ol with items
|
||||
* - quote: blockquotes
|
||||
* - thematicBreak: horizontal rule
|
||||
*/
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// AST TO TEXT EXTRACTION (for inline content)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
function extractText(node) {
|
||||
if (!node) return '';
|
||||
|
||||
if (node.type === 'text') {
|
||||
return he.decode(node.value || '');
|
||||
}
|
||||
|
||||
if (node.type === 'inlineCode') {
|
||||
return node.value || '';
|
||||
}
|
||||
|
||||
if (node.children && Array.isArray(node.children)) {
|
||||
return node.children.map(extractText).join('');
|
||||
}
|
||||
|
||||
return node.value ? he.decode(node.value) : '';
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// INLINE CONTENT RENDERER (for text inside blocks)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
function renderInline(node, key = 0) {
|
||||
if (!node) return null;
|
||||
|
||||
switch (node.type) {
|
||||
case 'text':
|
||||
return he.decode(node.value || '');
|
||||
|
||||
case 'strong':
|
||||
return h(Text, { key, bold: true },
|
||||
node.children?.map((c, i) => renderInline(c, i)));
|
||||
|
||||
case 'emphasis':
|
||||
return h(Text, { key, italic: true },
|
||||
node.children?.map((c, i) => renderInline(c, i)));
|
||||
|
||||
case 'inlineCode':
|
||||
return h(Text, {
|
||||
key,
|
||||
color: theme.colors.warning,
|
||||
backgroundColor: 'blackBright'
|
||||
}, ` ${node.value} `);
|
||||
|
||||
case 'link':
|
||||
return h(Text, { key, color: theme.colors.info, underline: true },
|
||||
`${extractText(node)} (${node.url || ''})`);
|
||||
|
||||
case 'paragraph':
|
||||
case 'heading':
|
||||
// For nested content, just extract children
|
||||
return node.children?.map((c, i) => renderInline(c, i));
|
||||
|
||||
default:
|
||||
if (node.children) {
|
||||
return node.children.map((c, i) => renderInline(c, i));
|
||||
}
|
||||
return node.value ? he.decode(node.value) : null;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// BLOCK RENDERERS - Each block gets its own Box with spacing
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
function renderHeading(node, index, width) {
|
||||
const depth = node.depth || 1;
|
||||
const colors = ['cyan', 'green', 'yellow', 'magenta', 'blue', 'white'];
|
||||
const color = colors[Math.min(depth - 1, 5)];
|
||||
const prefix = '#'.repeat(depth);
|
||||
const text = extractText(node);
|
||||
|
||||
// CRITICAL: marginTop AND marginBottom ensure separation
|
||||
return h(Box, {
|
||||
key: `heading-${index}`,
|
||||
marginTop: 1,
|
||||
marginBottom: 1,
|
||||
flexDirection: 'column',
|
||||
width: width // Enforce width
|
||||
},
|
||||
h(Text, { bold: true, color, wrap: 'wrap' }, `${prefix} ${text}`)
|
||||
);
|
||||
}
|
||||
|
||||
function renderParagraph(node, index, width) {
|
||||
// CRITICAL: marginBottom ensures paragraphs don't merge
|
||||
return h(Box, {
|
||||
key: `para-${index}`,
|
||||
marginBottom: 1,
|
||||
flexDirection: 'column',
|
||||
width: width // Enforce width
|
||||
},
|
||||
h(Text, { wrap: 'wrap' },
|
||||
node.children?.map((c, i) => renderInline(c, i)))
|
||||
);
|
||||
}
|
||||
|
||||
function renderCode(node, index, width) {
|
||||
const lang = node.lang || 'text';
|
||||
const code = he.decode(node.value || '');
|
||||
|
||||
// Supported languages
|
||||
const SUPPORTED = ['javascript', 'typescript', 'python', 'java', 'html',
|
||||
'css', 'json', 'yaml', 'bash', 'shell', 'sql', 'go', 'rust', 'plaintext'];
|
||||
const safeLang = SUPPORTED.includes(lang.toLowerCase()) ? lang.toLowerCase() : 'plaintext';
|
||||
|
||||
try {
|
||||
return h(Box, {
|
||||
key: `code-${index}`,
|
||||
marginTop: 1,
|
||||
marginBottom: 1,
|
||||
flexDirection: 'column',
|
||||
width: width // Enforce width
|
||||
},
|
||||
h(Box, {
|
||||
borderStyle: theme.borders.round,
|
||||
borderColor: theme.colors.muted,
|
||||
flexDirection: 'column',
|
||||
paddingX: 1
|
||||
},
|
||||
h(Box, { marginBottom: 0 },
|
||||
h(Text, { color: theme.colors.info, bold: true },
|
||||
`${theme.icons.info} ${lang}`)
|
||||
),
|
||||
h(Highlight, { code, language: safeLang, theme: 'dracula' })
|
||||
)
|
||||
);
|
||||
} catch (e) {
|
||||
return h(Box, {
|
||||
key: `code-${index}`,
|
||||
marginTop: 1,
|
||||
marginBottom: 1,
|
||||
flexDirection: 'column'
|
||||
},
|
||||
h(Box, {
|
||||
borderStyle: theme.borders.single,
|
||||
borderColor: theme.colors.muted,
|
||||
paddingX: 1
|
||||
},
|
||||
h(Text, {}, code)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function renderList(node, index, width) {
|
||||
const ordered = node.ordered || false;
|
||||
const items = node.children || [];
|
||||
|
||||
// Hanging indent: bullet in fixed-width column, text wraps aligned
|
||||
return h(Box, {
|
||||
key: `list-${index}`,
|
||||
marginTop: 1,
|
||||
marginBottom: 1,
|
||||
flexDirection: 'column',
|
||||
width: width // Enforce width
|
||||
},
|
||||
items.map((item, i) => {
|
||||
const bullet = ordered ? `${i + 1}.` : '•';
|
||||
const bulletWidth = ordered ? 4 : 3; // Fixed width for alignment
|
||||
|
||||
return h(Box, {
|
||||
key: `item-${i}`,
|
||||
flexDirection: 'row'
|
||||
},
|
||||
// Fixed-width bullet column for hanging indent
|
||||
h(Box, { width: bulletWidth, flexShrink: 0 },
|
||||
h(Text, { color: theme.colors.info }, bullet)
|
||||
),
|
||||
// Content wraps but stays aligned past bullet
|
||||
h(Box, { flexDirection: 'column', flexGrow: 1, flexShrink: 1 },
|
||||
item.children?.map((child, j) => {
|
||||
if (child.type === 'paragraph') {
|
||||
return h(Text, { key: j, wrap: 'wrap' },
|
||||
child.children?.map((c, k) => renderInline(c, k)));
|
||||
}
|
||||
return renderBlock(child, j);
|
||||
})
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function renderBlockquote(node, index, width) {
|
||||
// Decrease width for children by padding
|
||||
const innerWidth = width ? width - 2 : undefined;
|
||||
|
||||
return h(Box, {
|
||||
key: `quote-${index}`,
|
||||
marginTop: 1,
|
||||
marginBottom: 1,
|
||||
flexDirection: 'row',
|
||||
paddingLeft: 2,
|
||||
width: width // Enforce width
|
||||
},
|
||||
h(Text, { color: theme.colors.muted }, '│ '),
|
||||
h(Box, { flexDirection: 'column', dimColor: true, width: innerWidth },
|
||||
node.children?.map((child, i) => renderBlock(child, i, innerWidth))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function renderThematicBreak(index) {
|
||||
return h(Box, { key: `hr-${index}`, marginTop: 1, marginBottom: 1 },
|
||||
h(Text, { color: theme.colors.muted }, '─'.repeat(40))
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// MAIN BLOCK DISPATCHER
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
function renderBlock(node, index, width) {
|
||||
if (!node) return null;
|
||||
|
||||
switch (node.type) {
|
||||
case 'heading':
|
||||
return renderHeading(node, index, width);
|
||||
|
||||
case 'paragraph':
|
||||
return renderParagraph(node, index, width);
|
||||
|
||||
case 'code':
|
||||
return renderCode(node, index, width);
|
||||
|
||||
case 'list':
|
||||
return renderList(node, index, width);
|
||||
|
||||
case 'blockquote':
|
||||
return renderBlockquote(node, index, width);
|
||||
|
||||
case 'thematicBreak':
|
||||
return renderThematicBreak(index);
|
||||
|
||||
case 'html':
|
||||
// Skip HTML nodes
|
||||
return null;
|
||||
|
||||
default:
|
||||
// For unknown types, try to extract text
|
||||
const text = extractText(node);
|
||||
if (text) {
|
||||
return h(Box, { key: `unknown-${index}`, marginBottom: 1, width: width },
|
||||
h(Text, { wrap: 'wrap' }, text)
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// MAIN MARKDOWN COMPONENT
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const Markdown = ({ children, syntaxTheme = 'dracula', width }) => {
|
||||
if (!children || typeof children !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = children.trim();
|
||||
if (!content) return null;
|
||||
|
||||
try {
|
||||
// Parse markdown into AST
|
||||
const processor = unified().use(remarkParse).use(remarkGfm);
|
||||
const tree = processor.parse(he.decode(content));
|
||||
|
||||
// Get root children (top-level blocks)
|
||||
const blocks = tree.children || [];
|
||||
|
||||
if (blocks.length === 0) {
|
||||
return h(Box, { width },
|
||||
h(Text, { wrap: 'wrap' }, content)
|
||||
);
|
||||
}
|
||||
|
||||
// Render each block with proper spacing
|
||||
return h(Box, { flexDirection: 'column', width },
|
||||
blocks.map((block, i) => renderBlock(block, i, width)).filter(Boolean)
|
||||
);
|
||||
} catch (err) {
|
||||
// Fallback: render as plain text
|
||||
return h(Text, { wrap: 'wrap' }, he.decode(content));
|
||||
}
|
||||
};
|
||||
|
||||
export default Markdown;
|
||||
3668
bin/opencode-ink.mjs
Normal file
3668
bin/opencode-ink.mjs
Normal file
File diff suppressed because it is too large
Load Diff
2108
bin/opencode-ink.mjs.bak
Normal file
2108
bin/opencode-ink.mjs.bak
Normal file
File diff suppressed because it is too large
Load Diff
1146
bin/opencode-tui.cjs
Normal file
1146
bin/opencode-tui.cjs
Normal file
File diff suppressed because it is too large
Load Diff
324
bin/smart-agent-flow.mjs
Normal file
324
bin/smart-agent-flow.mjs
Normal file
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* Smart Agent Flow - Multi-Agent Routing System
|
||||
*
|
||||
* Enables Qwen to:
|
||||
* 1. Read available agents (names, roles, capabilities)
|
||||
* 2. Use multiple agents in a single task by delegating sub-tasks
|
||||
* 3. Merge results back into the main response
|
||||
*
|
||||
* Components:
|
||||
* - Agent Registry: Available agents with metadata
|
||||
* - Orchestrator: Decides which agents to use
|
||||
* - Router: Routes sub-tasks to agents
|
||||
* - Merger: Combines agent outputs
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// AGENT REGISTRY
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Built-in agents with their capabilities
|
||||
*/
|
||||
const BUILTIN_AGENTS = {
|
||||
build: {
|
||||
id: 'build',
|
||||
name: 'Build Agent',
|
||||
role: 'Full-stack development',
|
||||
capabilities: ['coding', 'debugging', 'implementation', 'refactoring'],
|
||||
whenToUse: 'General development tasks, implementing features, fixing bugs',
|
||||
priority: 1
|
||||
},
|
||||
plan: {
|
||||
id: 'plan',
|
||||
name: 'Planning Agent',
|
||||
role: 'Architecture and planning',
|
||||
capabilities: ['architecture', 'design', 'task-breakdown', 'estimation'],
|
||||
whenToUse: 'Complex features requiring upfront design, multi-step tasks',
|
||||
priority: 2
|
||||
},
|
||||
test: {
|
||||
id: 'test',
|
||||
name: 'Testing Agent',
|
||||
role: 'Quality assurance',
|
||||
capabilities: ['unit-tests', 'integration-tests', 'test-strategy', 'coverage'],
|
||||
whenToUse: 'Writing tests, improving coverage, test-driven development',
|
||||
priority: 3
|
||||
},
|
||||
docs: {
|
||||
id: 'docs',
|
||||
name: 'Documentation Agent',
|
||||
role: 'Technical writing',
|
||||
capabilities: ['documentation', 'comments', 'readme', 'api-docs'],
|
||||
whenToUse: 'Writing docs, improving comments, creating READMEs',
|
||||
priority: 4
|
||||
},
|
||||
security: {
|
||||
id: 'security',
|
||||
name: 'Security Reviewer',
|
||||
role: 'Security analysis',
|
||||
capabilities: ['vulnerability-scan', 'auth-review', 'input-validation', 'secrets'],
|
||||
whenToUse: 'Auth changes, handling sensitive data, security-critical code',
|
||||
priority: 5
|
||||
},
|
||||
refactor: {
|
||||
id: 'refactor',
|
||||
name: 'Refactoring Agent',
|
||||
role: 'Code improvement',
|
||||
capabilities: ['cleanup', 'optimization', 'patterns', 'technical-debt'],
|
||||
whenToUse: 'Improving code quality, reducing tech debt, applying patterns',
|
||||
priority: 6
|
||||
}
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ORCHESTRATOR CONFIGURATION
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
enabled: true,
|
||||
maxAgentsPerRequest: 3,
|
||||
maxTokensPerAgent: 2000,
|
||||
mergeStrategy: 'advisory', // 'advisory' = main model merges, 'sequential' = chain outputs
|
||||
autoDetect: true, // Automatically detect when to use multiple agents
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// SMART AGENT FLOW CLASS
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export class SmartAgentFlow {
|
||||
constructor(config = {}) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
this.agents = { ...BUILTIN_AGENTS };
|
||||
this.activeAgents = [];
|
||||
this.agentOutputs = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load custom agents from .opencode/agent directory
|
||||
*/
|
||||
loadCustomAgents(projectPath) {
|
||||
const agentDir = path.join(projectPath, '.opencode', 'agent');
|
||||
if (!fs.existsSync(agentDir)) return;
|
||||
|
||||
const files = fs.readdirSync(agentDir).filter(f => f.endsWith('.md'));
|
||||
for (const file of files) {
|
||||
const content = fs.readFileSync(path.join(agentDir, file), 'utf8');
|
||||
const name = path.basename(file, '.md');
|
||||
|
||||
// Parse agent metadata from markdown frontmatter or content
|
||||
this.agents[name] = {
|
||||
id: name,
|
||||
name: this.extractTitle(content) || name,
|
||||
role: 'Custom agent',
|
||||
capabilities: this.extractCapabilities(content),
|
||||
whenToUse: this.extractWhenToUse(content),
|
||||
priority: 10,
|
||||
custom: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
extractTitle(content) {
|
||||
const match = content.match(/^#\s+(.+)$/m);
|
||||
return match ? match[1].trim() : null;
|
||||
}
|
||||
|
||||
extractCapabilities(content) {
|
||||
const match = content.match(/capabilities?:?\s*(.+)/i);
|
||||
if (match) {
|
||||
return match[1].split(/[,;]/).map(c => c.trim().toLowerCase());
|
||||
}
|
||||
return ['general'];
|
||||
}
|
||||
|
||||
extractWhenToUse(content) {
|
||||
const match = content.match(/when\s*to\s*use:?\s*(.+)/i);
|
||||
return match ? match[1].trim() : 'Custom agent for specialized tasks';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available agents
|
||||
*/
|
||||
getAgents() {
|
||||
return Object.values(this.agents);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent by ID
|
||||
*/
|
||||
getAgent(id) {
|
||||
return this.agents[id] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze request to determine if multi-agent mode is beneficial
|
||||
*/
|
||||
analyzeRequest(request) {
|
||||
if (!this.config.autoDetect || !this.config.enabled) {
|
||||
return { useMultiAgent: false, reason: 'Multi-agent mode disabled' };
|
||||
}
|
||||
|
||||
const requestLower = request.toLowerCase();
|
||||
|
||||
// Patterns that suggest multi-agent mode
|
||||
const patterns = {
|
||||
multiDiscipline: /\b(frontend|backend|database|api|ui|server|client)\b.*\b(and|with|plus)\b.*\b(frontend|backend|database|api|ui|server|client)\b/i,
|
||||
highRisk: /\b(auth|authentication|authorization|permission|password|secret|token|security)\b/i,
|
||||
largeRefactor: /\b(refactor|rewrite|restructure|reorganize)\b.*\b(entire|whole|all|complete)\b/i,
|
||||
needsReview: /\b(review|check|verify|validate|audit)\b.*\b(security|code|implementation)\b/i,
|
||||
needsPlanning: /\b(plan|design|architect|strategy)\b.*\b(before|first|then)\b/i,
|
||||
needsTests: /\b(test|coverage|tdd|unit test|integration)\b/i,
|
||||
needsDocs: /\b(document|readme|api docs|comments)\b/i
|
||||
};
|
||||
|
||||
const detectedPatterns = [];
|
||||
const suggestedAgents = new Set(['build']); // Always include build
|
||||
|
||||
if (patterns.multiDiscipline.test(requestLower)) {
|
||||
detectedPatterns.push('multi-discipline');
|
||||
suggestedAgents.add('plan');
|
||||
}
|
||||
if (patterns.highRisk.test(requestLower)) {
|
||||
detectedPatterns.push('security-sensitive');
|
||||
suggestedAgents.add('security');
|
||||
}
|
||||
if (patterns.largeRefactor.test(requestLower)) {
|
||||
detectedPatterns.push('large-refactor');
|
||||
suggestedAgents.add('refactor');
|
||||
suggestedAgents.add('plan');
|
||||
}
|
||||
if (patterns.needsReview.test(requestLower)) {
|
||||
detectedPatterns.push('needs-review');
|
||||
suggestedAgents.add('security');
|
||||
}
|
||||
if (patterns.needsPlanning.test(requestLower)) {
|
||||
detectedPatterns.push('needs-planning');
|
||||
suggestedAgents.add('plan');
|
||||
}
|
||||
if (patterns.needsTests.test(requestLower)) {
|
||||
detectedPatterns.push('needs-tests');
|
||||
suggestedAgents.add('test');
|
||||
}
|
||||
if (patterns.needsDocs.test(requestLower)) {
|
||||
detectedPatterns.push('needs-docs');
|
||||
suggestedAgents.add('docs');
|
||||
}
|
||||
|
||||
// Use multi-agent if more than one pattern detected
|
||||
const useMultiAgent = suggestedAgents.size > 1 && detectedPatterns.length > 0;
|
||||
|
||||
return {
|
||||
useMultiAgent,
|
||||
reason: useMultiAgent
|
||||
? `Detected: ${detectedPatterns.join(', ')}`
|
||||
: 'Single-agent sufficient for this request',
|
||||
suggestedAgents: Array.from(suggestedAgents).slice(0, this.config.maxAgentsPerRequest),
|
||||
patterns: detectedPatterns
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start multi-agent flow for a request
|
||||
*/
|
||||
startFlow(agentIds) {
|
||||
this.activeAgents = agentIds.map(id => this.agents[id]).filter(Boolean);
|
||||
this.agentOutputs = [];
|
||||
return this.activeAgents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record agent output
|
||||
*/
|
||||
recordOutput(agentId, output) {
|
||||
this.agentOutputs.push({
|
||||
agentId,
|
||||
agent: this.agents[agentId],
|
||||
output,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary for UI display
|
||||
*/
|
||||
getFlowStatus() {
|
||||
return {
|
||||
active: this.activeAgents.length > 0,
|
||||
agents: this.activeAgents.map(a => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
role: a.role
|
||||
})),
|
||||
outputs: this.agentOutputs.length,
|
||||
enabled: this.config.enabled
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build context for model about available agents
|
||||
*/
|
||||
buildAgentContext() {
|
||||
const agents = this.getAgents();
|
||||
let context = `\n## Available Agents\n\nYou have access to the following specialized agents:\n\n`;
|
||||
|
||||
for (const agent of agents) {
|
||||
context += `### ${agent.name} (${agent.id})\n`;
|
||||
context += `- **Role**: ${agent.role}\n`;
|
||||
context += `- **Capabilities**: ${agent.capabilities.join(', ')}\n`;
|
||||
context += `- **When to use**: ${agent.whenToUse}\n\n`;
|
||||
}
|
||||
|
||||
context += `## Multi-Agent Guidelines\n\n`;
|
||||
context += `Use multiple agents when:\n`;
|
||||
context += `- The request spans multiple disciplines (UI + backend + DB + deployment)\n`;
|
||||
context += `- Risk is high (auth, permissions, data loss)\n`;
|
||||
context += `- Large refactor needed and you want a review pass\n\n`;
|
||||
context += `Do NOT use multiple agents when:\n`;
|
||||
context += `- Small changes or trivial questions\n`;
|
||||
context += `- User asked for speed or minimal output\n`;
|
||||
context += `- No clear benefit from additional perspectives\n`;
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle multi-agent mode
|
||||
*/
|
||||
toggle(enabled = null) {
|
||||
if (enabled === null) {
|
||||
this.config.enabled = !this.config.enabled;
|
||||
} else {
|
||||
this.config.enabled = enabled;
|
||||
}
|
||||
return this.config.enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset flow state
|
||||
*/
|
||||
reset() {
|
||||
this.activeAgents = [];
|
||||
this.agentOutputs = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let smartAgentFlowInstance = null;
|
||||
|
||||
export function getSmartAgentFlow(config) {
|
||||
if (!smartAgentFlowInstance) {
|
||||
smartAgentFlowInstance = new SmartAgentFlow(config);
|
||||
}
|
||||
return smartAgentFlowInstance;
|
||||
}
|
||||
|
||||
export default SmartAgentFlow;
|
||||
219
bin/tui-layout.mjs
Normal file
219
bin/tui-layout.mjs
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Responsive Layout Module for OpenQode TUI
|
||||
* Handles terminal size breakpoints, sidebar sizing, and layout modes
|
||||
*
|
||||
* Breakpoints:
|
||||
* - Wide: columns >= 120 (full sidebar)
|
||||
* - Medium: 90 <= columns < 120 (narrower sidebar)
|
||||
* - Narrow: 60 <= columns < 90 (collapsed sidebar, Tab toggle)
|
||||
* - Tiny: columns < 60 OR rows < 20 (minimal chrome)
|
||||
*/
|
||||
|
||||
import stringWidth from 'string-width';
|
||||
import cliTruncate from 'cli-truncate';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// LAYOUT MODE DETECTION
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Compute layout mode based on terminal dimensions
|
||||
* @param {number} cols - Terminal columns
|
||||
* @param {number} rows - Terminal rows
|
||||
* @returns {Object} Layout configuration
|
||||
*/
|
||||
export function computeLayoutMode(cols, rows) {
|
||||
const c = cols ?? 80;
|
||||
const r = rows ?? 24;
|
||||
|
||||
// Tiny mode: very small terminal
|
||||
if (c < 60 || r < 20) {
|
||||
return {
|
||||
mode: 'tiny',
|
||||
cols: c,
|
||||
rows: r,
|
||||
sidebarWidth: 0,
|
||||
sidebarCollapsed: true,
|
||||
showBorders: false,
|
||||
paddingX: 0,
|
||||
paddingY: 0
|
||||
};
|
||||
}
|
||||
|
||||
// Narrow mode: sidebar collapsed by default but toggleable
|
||||
if (c < 90) {
|
||||
return {
|
||||
mode: 'narrow',
|
||||
cols: c,
|
||||
rows: r,
|
||||
sidebarWidth: 0, // collapsed by default
|
||||
sidebarCollapsedDefault: true,
|
||||
sidebarExpandedWidth: Math.min(24, Math.floor(c * 0.28)),
|
||||
showBorders: true,
|
||||
paddingX: 1,
|
||||
paddingY: 0
|
||||
};
|
||||
}
|
||||
|
||||
// Medium mode: narrower sidebar
|
||||
if (c < 120) {
|
||||
return {
|
||||
mode: 'medium',
|
||||
cols: c,
|
||||
rows: r,
|
||||
sidebarWidth: Math.min(26, Math.floor(c * 0.25)),
|
||||
sidebarCollapsed: false,
|
||||
showBorders: true,
|
||||
paddingX: 1,
|
||||
paddingY: 0
|
||||
};
|
||||
}
|
||||
|
||||
// Wide mode: full sidebar
|
||||
return {
|
||||
mode: 'wide',
|
||||
cols: c,
|
||||
rows: r,
|
||||
sidebarWidth: Math.min(32, Math.floor(c * 0.25)),
|
||||
sidebarCollapsed: false,
|
||||
showBorders: true,
|
||||
paddingX: 1,
|
||||
paddingY: 0
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// SIDEBAR UTILITIES
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Get sidebar width for current mode and toggle state
|
||||
* @param {Object} layout - Layout configuration
|
||||
* @param {boolean} isExpanded - Whether sidebar is manually expanded
|
||||
* @returns {number} Sidebar width in columns
|
||||
*/
|
||||
export function getSidebarWidth(layout, isExpanded) {
|
||||
if (layout.mode === 'tiny') return 0;
|
||||
|
||||
if (layout.mode === 'narrow') {
|
||||
return isExpanded ? (layout.sidebarExpandedWidth || 24) : 0;
|
||||
}
|
||||
|
||||
return layout.sidebarWidth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get main content width
|
||||
* @param {Object} layout - Layout configuration
|
||||
* @param {number} sidebarWidth - Current sidebar width
|
||||
* @returns {number} Main content width
|
||||
*/
|
||||
export function getMainWidth(layout, sidebarWidth) {
|
||||
const borders = sidebarWidth > 0 ? 6 : 4; // increased safety margin (was 4:2, now 6:4)
|
||||
return Math.max(20, layout.cols - sidebarWidth - borders);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// TEXT UTILITIES (using string-width for accuracy)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Truncate text to fit width (unicode-aware)
|
||||
* @param {string} text - Text to truncate
|
||||
* @param {number} width - Maximum width
|
||||
* @returns {string} Truncated text
|
||||
*/
|
||||
export function truncateText(text, width) {
|
||||
if (!text) return '';
|
||||
return cliTruncate(String(text), width, { position: 'end' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get visual width of text (unicode-aware)
|
||||
* @param {string} text - Text to measure
|
||||
* @returns {number} Visual width
|
||||
*/
|
||||
export function getTextWidth(text) {
|
||||
if (!text) return 0;
|
||||
return stringWidth(String(text));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pad text to specific width
|
||||
* @param {string} text - Text to pad
|
||||
* @param {number} width - Target width
|
||||
* @param {string} char - Padding character
|
||||
* @returns {string} Padded text
|
||||
*/
|
||||
export function padText(text, width, char = ' ') {
|
||||
if (!text) return char.repeat(width);
|
||||
const currentWidth = getTextWidth(text);
|
||||
if (currentWidth >= width) return truncateText(text, width);
|
||||
return text + char.repeat(width - currentWidth);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// VIEWPORT HEIGHT CALCULATION
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Calculate viewport dimensions for message list
|
||||
* @param {Object} layout - Layout configuration
|
||||
* @param {Object} options - Additional options
|
||||
* @returns {Object} Viewport dimensions
|
||||
*/
|
||||
export function calculateViewport(layout, options = {}) {
|
||||
const {
|
||||
headerRows = 0,
|
||||
inputRows = 3,
|
||||
thinkingRows = 0,
|
||||
marginsRows = 2
|
||||
} = options;
|
||||
|
||||
const totalReserved = headerRows + inputRows + thinkingRows + marginsRows;
|
||||
const messageViewHeight = Math.max(4, layout.rows - totalReserved);
|
||||
|
||||
// Estimate how many messages fit (conservative: ~4 lines per message avg)
|
||||
const linesPerMessage = 4;
|
||||
const maxVisibleMessages = Math.max(2, Math.floor(messageViewHeight / linesPerMessage));
|
||||
|
||||
return {
|
||||
viewHeight: messageViewHeight,
|
||||
maxMessages: maxVisibleMessages,
|
||||
inputRows,
|
||||
headerRows
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// LAYOUT CONSTANTS
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export const LAYOUT_CONSTANTS = {
|
||||
// Minimum dimensions
|
||||
MIN_SIDEBAR_WIDTH: 20,
|
||||
MIN_MAIN_WIDTH: 40,
|
||||
MIN_MESSAGE_VIEW_HEIGHT: 4,
|
||||
|
||||
// Default padding
|
||||
DEFAULT_PADDING_X: 1,
|
||||
DEFAULT_PADDING_Y: 0,
|
||||
|
||||
// Message estimation
|
||||
LINES_PER_MESSAGE: 4,
|
||||
|
||||
// Input area
|
||||
INPUT_BOX_HEIGHT: 3,
|
||||
INPUT_BORDER_HEIGHT: 2
|
||||
};
|
||||
|
||||
export default {
|
||||
computeLayoutMode,
|
||||
getSidebarWidth,
|
||||
getMainWidth,
|
||||
truncateText,
|
||||
getTextWidth,
|
||||
padText,
|
||||
calculateViewport,
|
||||
LAYOUT_CONSTANTS
|
||||
};
|
||||
107
bin/tui-stream-buffer.mjs
Normal file
107
bin/tui-stream-buffer.mjs
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Streaming Buffer Hook for OpenQode TUI
|
||||
*
|
||||
* Prevents "reflow per token" chaos by:
|
||||
* 1. Buffering incoming tokens
|
||||
* 2. Flushing on newlines or after 50ms interval
|
||||
* 3. Providing stable committed content for rendering
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* useStreamBuffer - Stable streaming text buffer
|
||||
*
|
||||
* Instead of re-rendering on every token, this hook:
|
||||
* - Accumulates tokens in a pending buffer
|
||||
* - Commits to state on newlines or 50ms timeout
|
||||
* - Prevents mid-word reflows and jitter
|
||||
*
|
||||
* @returns {Object} { committed, pushToken, flushNow, reset }
|
||||
*/
|
||||
export function useStreamBuffer(flushInterval = 50) {
|
||||
const [committed, setCommitted] = useState('');
|
||||
const pendingRef = useRef('');
|
||||
const flushTimerRef = useRef(null);
|
||||
|
||||
// Push a token to the pending buffer
|
||||
const pushToken = useCallback((token) => {
|
||||
pendingRef.current += token;
|
||||
|
||||
// Flush immediately on newline
|
||||
if (token.includes('\n')) {
|
||||
if (flushTimerRef.current) {
|
||||
clearTimeout(flushTimerRef.current);
|
||||
flushTimerRef.current = null;
|
||||
}
|
||||
setCommitted(prev => prev + pendingRef.current);
|
||||
pendingRef.current = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Schedule flush if not already pending
|
||||
if (!flushTimerRef.current) {
|
||||
flushTimerRef.current = setTimeout(() => {
|
||||
setCommitted(prev => prev + pendingRef.current);
|
||||
pendingRef.current = '';
|
||||
flushTimerRef.current = null;
|
||||
}, flushInterval);
|
||||
}
|
||||
}, [flushInterval]);
|
||||
|
||||
// Force immediate flush
|
||||
const flushNow = useCallback(() => {
|
||||
if (flushTimerRef.current) {
|
||||
clearTimeout(flushTimerRef.current);
|
||||
flushTimerRef.current = null;
|
||||
}
|
||||
if (pendingRef.current) {
|
||||
setCommitted(prev => prev + pendingRef.current);
|
||||
pendingRef.current = '';
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Reset buffer (for new messages)
|
||||
const reset = useCallback(() => {
|
||||
if (flushTimerRef.current) {
|
||||
clearTimeout(flushTimerRef.current);
|
||||
flushTimerRef.current = null;
|
||||
}
|
||||
pendingRef.current = '';
|
||||
setCommitted('');
|
||||
}, []);
|
||||
|
||||
// Get current total (committed + pending, for display during active streaming)
|
||||
const getTotal = useCallback(() => {
|
||||
return committed + pendingRef.current;
|
||||
}, [committed]);
|
||||
|
||||
return {
|
||||
committed,
|
||||
pushToken,
|
||||
flushNow,
|
||||
reset,
|
||||
getTotal,
|
||||
isPending: pendingRef.current.length > 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize debounce hook
|
||||
* Only reflows content after terminal resize settles
|
||||
*/
|
||||
export function useResizeDebounce(callback, delay = 150) {
|
||||
const timerRef = useRef(null);
|
||||
|
||||
return useCallback((cols, rows) => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
timerRef.current = setTimeout(() => {
|
||||
callback(cols, rows);
|
||||
timerRef.current = null;
|
||||
}, delay);
|
||||
}, [callback, delay]);
|
||||
}
|
||||
|
||||
export default { useStreamBuffer, useResizeDebounce };
|
||||
90
bin/tui-theme.mjs
Normal file
90
bin/tui-theme.mjs
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* TUI Theme Module - Centralized styling for OpenQode TUI
|
||||
* Provides consistent colors, spacing, and border styles
|
||||
* With capability detection for cross-platform compatibility
|
||||
*/
|
||||
|
||||
// Capability detection
|
||||
const hasUnicode = process.platform !== 'win32' ||
|
||||
process.env.WT_SESSION || // Windows Terminal
|
||||
process.env.TERM_PROGRAM === 'vscode'; // VS Code integrated terminal
|
||||
|
||||
// Theme configuration
|
||||
export const theme = {
|
||||
// Spacing scale (terminal rows/chars)
|
||||
spacing: {
|
||||
xs: 0,
|
||||
sm: 1,
|
||||
md: 2,
|
||||
lg: 3
|
||||
},
|
||||
|
||||
// Semantic colors
|
||||
colors: {
|
||||
fg: 'white',
|
||||
muted: 'gray',
|
||||
border: 'gray',
|
||||
info: 'cyan',
|
||||
success: 'green',
|
||||
warning: 'yellow',
|
||||
error: 'red',
|
||||
accent: 'magenta',
|
||||
user: 'cyan',
|
||||
assistant: 'white',
|
||||
system: 'yellow'
|
||||
},
|
||||
|
||||
// Border styles with fallback
|
||||
borders: {
|
||||
default: hasUnicode ? 'round' : 'single',
|
||||
single: 'single',
|
||||
round: hasUnicode ? 'round' : 'single',
|
||||
double: hasUnicode ? 'double' : 'single'
|
||||
},
|
||||
|
||||
// Card-specific styles
|
||||
cards: {
|
||||
system: {
|
||||
borderStyle: hasUnicode ? 'round' : 'single',
|
||||
borderColor: 'yellow',
|
||||
paddingX: 1,
|
||||
paddingY: 0,
|
||||
marginBottom: 1
|
||||
},
|
||||
user: {
|
||||
marginTop: 1,
|
||||
marginBottom: 1,
|
||||
promptIcon: hasUnicode ? '❯' : '>',
|
||||
promptColor: 'cyan'
|
||||
},
|
||||
assistant: {
|
||||
borderStyle: 'single',
|
||||
borderColor: 'gray',
|
||||
paddingX: 1,
|
||||
paddingY: 0,
|
||||
marginBottom: 1,
|
||||
divider: hasUnicode ? '── Assistant ──' : '-- Assistant --'
|
||||
},
|
||||
error: {
|
||||
borderStyle: hasUnicode ? 'round' : 'single',
|
||||
borderColor: 'red',
|
||||
paddingX: 1,
|
||||
paddingY: 0,
|
||||
marginBottom: 1,
|
||||
icon: hasUnicode ? '⚠' : '!'
|
||||
}
|
||||
},
|
||||
|
||||
// Icons with fallback
|
||||
icons: {
|
||||
info: hasUnicode ? 'ℹ' : 'i',
|
||||
warning: hasUnicode ? '⚠' : '!',
|
||||
error: hasUnicode ? '✗' : 'X',
|
||||
success: hasUnicode ? '✓' : 'OK',
|
||||
bullet: hasUnicode ? '•' : '-',
|
||||
arrow: hasUnicode ? '→' : '->',
|
||||
prompt: hasUnicode ? '❯' : '>'
|
||||
}
|
||||
};
|
||||
|
||||
export default theme;
|
||||
183
bin/ui/components/AgentRail.mjs
Normal file
183
bin/ui/components/AgentRail.mjs
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* AgentRail Component - "Pro" Protocol
|
||||
* Minimalist left-rail layout for messages (Claude Code / Codex CLI style)
|
||||
*
|
||||
* @module ui/components/AgentRail
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
|
||||
const h = React.createElement;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ROLE COLORS - Color-coded vertical rail by role
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export const RAIL_COLORS = {
|
||||
system: 'yellow',
|
||||
user: 'cyan',
|
||||
assistant: 'gray',
|
||||
error: 'red',
|
||||
thinking: 'magenta',
|
||||
tool: 'blue'
|
||||
};
|
||||
|
||||
export const RAIL_ICONS = {
|
||||
system: 'ℹ',
|
||||
user: '❯',
|
||||
assistant: '◐',
|
||||
error: '!',
|
||||
thinking: '◌',
|
||||
tool: '⚙'
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// SYSTEM MESSAGE - Compact single-line format
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* SystemMessage - Compact system notification
|
||||
* Format: "ℹ SYSTEM: Message here"
|
||||
*/
|
||||
export const SystemMessage = ({ content, title = 'SYSTEM' }) => {
|
||||
return h(Box, { marginY: 0 },
|
||||
h(Text, { color: RAIL_COLORS.system }, `${RAIL_ICONS.system} `),
|
||||
h(Text, { color: RAIL_COLORS.system, bold: true }, `${title}: `),
|
||||
h(Text, { color: 'gray' }, content)
|
||||
);
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// USER MESSAGE - Clean prompt style
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* UserMessage - Clean prompt indicator
|
||||
* Format: "❯ user message"
|
||||
*/
|
||||
export const UserMessage = ({ content }) => {
|
||||
return h(Box, { marginTop: 1, marginBottom: 0 },
|
||||
h(Text, { color: RAIL_COLORS.user, bold: true }, `${RAIL_ICONS.user} `),
|
||||
h(Text, { color: 'white', wrap: 'wrap' }, content)
|
||||
);
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ASSISTANT MESSAGE - Left rail with content
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* AssistantMessage - Rail-based layout (no box borders)
|
||||
* Uses vertical line instead of full border
|
||||
*/
|
||||
export const AssistantMessage = ({ content, isStreaming = false, children }) => {
|
||||
const railChar = isStreaming ? '┃' : '│';
|
||||
const railColor = isStreaming ? 'yellow' : RAIL_COLORS.assistant;
|
||||
|
||||
return h(Box, {
|
||||
flexDirection: 'row',
|
||||
marginTop: 1,
|
||||
marginBottom: 1
|
||||
},
|
||||
// Left rail (vertical line)
|
||||
h(Box, {
|
||||
width: 2,
|
||||
flexShrink: 0,
|
||||
flexDirection: 'column'
|
||||
},
|
||||
h(Text, { color: railColor }, railChar)
|
||||
),
|
||||
// Content area
|
||||
h(Box, {
|
||||
flexDirection: 'column',
|
||||
flexGrow: 1,
|
||||
paddingLeft: 1
|
||||
},
|
||||
children || h(Text, { wrap: 'wrap' }, content)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// THINKING INDICATOR - Dimmed spinner style
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* ThinkingIndicator - Shows AI reasoning steps
|
||||
*/
|
||||
export const ThinkingIndicator = ({ steps = [] }) => {
|
||||
if (!steps || steps.length === 0) return null;
|
||||
|
||||
return h(Box, {
|
||||
flexDirection: 'column',
|
||||
marginBottom: 1,
|
||||
paddingLeft: 2
|
||||
},
|
||||
h(Text, { color: RAIL_COLORS.thinking, dimColor: true },
|
||||
`${RAIL_ICONS.thinking} Thinking (${steps.length} steps)`),
|
||||
...steps.slice(-3).map((step, i) =>
|
||||
h(Text, {
|
||||
key: i,
|
||||
color: 'gray',
|
||||
dimColor: true,
|
||||
wrap: 'truncate-end'
|
||||
}, ` ${step.slice(0, 60)}${step.length > 60 ? '...' : ''}`)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ERROR MESSAGE - Red rail with error content
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* ErrorMessage - Red-railed error display
|
||||
*/
|
||||
export const ErrorMessage = ({ content, title = 'Error' }) => {
|
||||
return h(Box, {
|
||||
flexDirection: 'row',
|
||||
marginTop: 1
|
||||
},
|
||||
h(Box, { width: 2, flexShrink: 0 },
|
||||
h(Text, { color: RAIL_COLORS.error }, '│')
|
||||
),
|
||||
h(Box, { flexDirection: 'column', paddingLeft: 1 },
|
||||
h(Text, { color: RAIL_COLORS.error, bold: true }, `${RAIL_ICONS.error} ${title}`),
|
||||
h(Text, { color: RAIL_COLORS.error, wrap: 'wrap' }, content)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// MESSAGE WRAPPER - Auto-selects component by role
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* MessageWrapper - Routes to correct component by role
|
||||
*/
|
||||
export const MessageWrapper = ({ role, content, meta, isStreaming, children }) => {
|
||||
switch (role) {
|
||||
case 'system':
|
||||
return h(SystemMessage, { content, title: meta?.title });
|
||||
case 'user':
|
||||
return h(UserMessage, { content });
|
||||
case 'assistant':
|
||||
return h(AssistantMessage, { content, isStreaming, children });
|
||||
case 'error':
|
||||
return h(ErrorMessage, { content, title: meta?.title });
|
||||
default:
|
||||
return h(Text, { wrap: 'wrap' }, content);
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
RAIL_COLORS,
|
||||
RAIL_ICONS,
|
||||
SystemMessage,
|
||||
UserMessage,
|
||||
AssistantMessage,
|
||||
ThinkingIndicator,
|
||||
ErrorMessage,
|
||||
MessageWrapper
|
||||
};
|
||||
55
bin/ui/components/ChatBubble.mjs
Normal file
55
bin/ui/components/ChatBubble.mjs
Normal file
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
|
||||
const h = React.createElement;
|
||||
|
||||
const ChatBubble = ({ role, content, meta, width, children }) => {
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// USER MESSAGE (RIGHT ALIGNED) - RAIL STYLE
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
if (role === 'user') {
|
||||
return h(Box, { width: width, flexDirection: 'row', justifyContent: 'flex-end', marginBottom: 1, overflow: 'hidden' },
|
||||
h(Box, { flexDirection: 'row', paddingRight: 1 },
|
||||
h(Text, { color: 'cyan', wrap: 'wrap' }, content),
|
||||
h(Box, { marginLeft: 1, borderStyle: 'single', borderLeft: false, borderTop: false, borderBottom: false, borderRightColor: 'cyan' })
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// SYSTEM - MINIMALIST TOAST
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
if (role === 'system') {
|
||||
return h(Box, { width: width, justifyContent: 'center', marginBottom: 1 },
|
||||
h(Text, { color: 'gray', dimColor: true }, ` ${content} `)
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ERROR - RED GUTTER
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
if (role === 'error') {
|
||||
// Strip redundant "Error: " prefix if present in content
|
||||
const cleanContent = content.replace(/^Error:\s*/i, '');
|
||||
return h(Box, { width: width, flexDirection: 'row', marginBottom: 1, overflow: 'hidden' },
|
||||
h(Box, { marginRight: 1, borderStyle: 'single', borderRight: false, borderTop: false, borderBottom: false, borderLeftColor: 'red' }),
|
||||
h(Text, { color: 'red', wrap: 'wrap' }, cleanContent)
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ASSISTANT - LEFT GUTTER RAIL
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
return h(Box, { width: width, flexDirection: 'row', marginBottom: 1, overflow: 'hidden' },
|
||||
// Left Gutter
|
||||
h(Box, { marginRight: 1, borderStyle: 'single', borderRight: false, borderTop: false, borderBottom: false, borderLeftColor: 'green' }),
|
||||
|
||||
// Content
|
||||
h(Box, { flexDirection: 'column', paddingRight: 2, flexGrow: 1 },
|
||||
children ? children : h(Text, { wrap: 'wrap' }, content)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatBubble;
|
||||
126
bin/ui/components/DiffView.mjs
Normal file
126
bin/ui/components/DiffView.mjs
Normal file
@@ -0,0 +1,126 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import * as Diff from 'diff';
|
||||
|
||||
const h = React.createElement;
|
||||
|
||||
const DiffView = ({
|
||||
original = '',
|
||||
modified = '',
|
||||
file = 'unknown.js',
|
||||
onApply,
|
||||
onSkip,
|
||||
width = 80,
|
||||
height = 20
|
||||
}) => {
|
||||
// Generate diff objects
|
||||
// [{ value: 'line', added: boolean, removed: boolean }]
|
||||
const diff = Diff.diffLines(original, modified);
|
||||
|
||||
// Scroll state
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
|
||||
// Calculate total lines for scrolling
|
||||
const totalLines = diff.reduce((acc, part) => acc + part.value.split('\n').length - 1, 0);
|
||||
const visibleLines = height - 4; // Header + Footer space
|
||||
|
||||
useInput((input, key) => {
|
||||
if (key.upArrow) {
|
||||
setScrollTop(prev => Math.max(0, prev - 1));
|
||||
}
|
||||
if (key.downArrow) {
|
||||
setScrollTop(prev => Math.min(totalLines - visibleLines, prev + 1));
|
||||
}
|
||||
if (key.pageUp) {
|
||||
setScrollTop(prev => Math.max(0, prev - visibleLines));
|
||||
}
|
||||
if (key.pageDown) {
|
||||
setScrollTop(prev => Math.min(totalLines - visibleLines, prev + visibleLines));
|
||||
}
|
||||
|
||||
if (input === 'y' || input === 'Y' || key.return) {
|
||||
onApply();
|
||||
}
|
||||
if (input === 'n' || input === 'N' || key.escape) {
|
||||
onSkip();
|
||||
}
|
||||
});
|
||||
|
||||
// Render Logic
|
||||
let currentLine = 0;
|
||||
const renderedLines = [];
|
||||
|
||||
diff.forEach((part) => {
|
||||
const lines = part.value.split('\n');
|
||||
// last element of split is often empty if value ends with newline
|
||||
if (lines[lines.length - 1] === '') lines.pop();
|
||||
|
||||
lines.forEach((line) => {
|
||||
currentLine++;
|
||||
// Check visibility
|
||||
if (currentLine <= scrollTop || currentLine > scrollTop + visibleLines) {
|
||||
return;
|
||||
}
|
||||
|
||||
let color = 'gray'; // Unchanged
|
||||
let prefix = ' ';
|
||||
let bg = undefined;
|
||||
|
||||
if (part.added) {
|
||||
color = 'green';
|
||||
prefix = '+ ';
|
||||
} else if (part.removed) {
|
||||
color = 'red';
|
||||
prefix = '- ';
|
||||
}
|
||||
|
||||
renderedLines.push(
|
||||
h(Box, { key: currentLine, width: '100%' },
|
||||
h(Text, { color: 'gray', dimColor: true }, `${currentLine.toString().padEnd(4)} `),
|
||||
h(Text, { color: color, backgroundColor: bg, wrap: 'truncate-end' }, prefix + line)
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
return h(Box, {
|
||||
flexDirection: 'column',
|
||||
width: width,
|
||||
height: height,
|
||||
borderStyle: 'double',
|
||||
borderColor: 'yellow'
|
||||
},
|
||||
// Header
|
||||
h(Box, { flexDirection: 'column', paddingX: 1, borderStyle: 'single', borderBottom: true, borderTop: false, borderLeft: false, borderRight: false },
|
||||
h(Text, { bold: true, color: 'yellow' }, `Reviewing: ${file}`),
|
||||
h(Box, { justifyContent: 'space-between' },
|
||||
h(Text, { dimColor: true }, `Lines: ${totalLines} | Changes: ${diff.filter(p => p.added || p.removed).length} blocks`),
|
||||
h(Text, { color: 'blue' }, 'UP/DOWN to scroll')
|
||||
)
|
||||
),
|
||||
|
||||
// Diff Content
|
||||
h(Box, { flexDirection: 'column', flexGrow: 1, paddingX: 1 },
|
||||
renderedLines.length > 0
|
||||
? renderedLines
|
||||
: h(Text, { color: 'gray' }, 'No changes detected (Files are identical)')
|
||||
),
|
||||
|
||||
// Footer Actions
|
||||
h(Box, {
|
||||
borderStyle: 'single',
|
||||
borderTop: true,
|
||||
borderBottom: false,
|
||||
borderLeft: false,
|
||||
borderRight: false,
|
||||
paddingX: 1,
|
||||
justifyContent: 'center',
|
||||
gap: 4
|
||||
},
|
||||
h(Text, { color: 'green', bold: true }, '[Y] Apply Changes'),
|
||||
h(Text, { color: 'red', bold: true }, '[N] Discard/Skip')
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default DiffView;
|
||||
177
bin/ui/components/FileTree.mjs
Normal file
177
bin/ui/components/FileTree.mjs
Normal file
@@ -0,0 +1,177 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const h = React.createElement;
|
||||
|
||||
// Helper to sort: folders first
|
||||
const sortFiles = (files, dirPath) => {
|
||||
return files.sort((a, b) => {
|
||||
const pathA = path.join(dirPath, a);
|
||||
const pathB = path.join(dirPath, b);
|
||||
try {
|
||||
const statA = fs.statSync(pathA);
|
||||
const statB = fs.statSync(pathB);
|
||||
if (statA.isDirectory() && !statB.isDirectory()) return -1;
|
||||
if (!statA.isDirectory() && statB.isDirectory()) return 1;
|
||||
return a.localeCompare(b);
|
||||
} catch (e) {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const FileTree = ({
|
||||
rootPath,
|
||||
onSelect,
|
||||
selectedFiles = new Set(),
|
||||
isActive = false,
|
||||
height = 20,
|
||||
width = 30
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState(new Set([rootPath])); // Expanded folders
|
||||
const [cursor, setCursor] = useState(rootPath); // Currently highlighted path
|
||||
const [flatList, setFlatList] = useState([]); // Computed flat list for rendering (calc'd from expanded)
|
||||
|
||||
// Ignore list
|
||||
const IGNORE_DIRS = new Set(['.git', 'node_modules', '.opencode', 'dist', 'build', 'coverage']);
|
||||
|
||||
// Rebuild flat list when expanded changes
|
||||
// Returns array of { path, name, isDir, depth, isExpanded, hasChildren }
|
||||
const buildFlatList = useCallback(() => {
|
||||
const list = [];
|
||||
|
||||
const traverse = (currentPath, depth) => {
|
||||
if (depth > 10) return; // Safety
|
||||
|
||||
const name = path.basename(currentPath) || (currentPath === rootPath ? '/' : currentPath);
|
||||
let isDir = false;
|
||||
try {
|
||||
isDir = fs.statSync(currentPath).isDirectory();
|
||||
} catch (e) { return; }
|
||||
|
||||
const isExpanded = expanded.has(currentPath);
|
||||
|
||||
list.push({
|
||||
path: currentPath,
|
||||
name: name,
|
||||
isDir: isDir,
|
||||
depth: depth,
|
||||
isExpanded: isExpanded
|
||||
});
|
||||
|
||||
if (isDir && isExpanded) {
|
||||
try {
|
||||
const children = fs.readdirSync(currentPath).filter(f => !IGNORE_DIRS.has(f) && !f.startsWith('.'));
|
||||
const sorted = sortFiles(children, currentPath);
|
||||
for (const child of sorted) {
|
||||
traverse(path.join(currentPath, child), depth + 1);
|
||||
}
|
||||
} catch (e) {
|
||||
// Permission error or file delete race condition
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
traverse(rootPath, 0);
|
||||
return list;
|
||||
}, [expanded, rootPath]);
|
||||
|
||||
useEffect(() => {
|
||||
setFlatList(buildFlatList());
|
||||
}, [buildFlatList]);
|
||||
|
||||
useInput((input, key) => {
|
||||
if (!isActive) return;
|
||||
|
||||
const currentIndex = flatList.findIndex(item => item.path === cursor);
|
||||
|
||||
if (key.downArrow) {
|
||||
const nextIndex = Math.min(flatList.length - 1, currentIndex + 1);
|
||||
setCursor(flatList[nextIndex].path);
|
||||
}
|
||||
|
||||
if (key.upArrow) {
|
||||
const prevIndex = Math.max(0, currentIndex - 1);
|
||||
setCursor(flatList[prevIndex].path);
|
||||
}
|
||||
|
||||
if (key.rightArrow || key.return) {
|
||||
const item = flatList[currentIndex];
|
||||
if (item && item.isDir) {
|
||||
if (!expanded.has(item.path)) {
|
||||
setExpanded(prev => new Set([...prev, item.path]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (key.leftArrow) {
|
||||
const item = flatList[currentIndex];
|
||||
if (item && item.isDir && expanded.has(item.path)) {
|
||||
const newExpanded = new Set(expanded);
|
||||
newExpanded.delete(item.path);
|
||||
setExpanded(newExpanded);
|
||||
} else {
|
||||
// Determine parent path to jump up
|
||||
const parentPath = path.dirname(item.path);
|
||||
if (parentPath && parentPath.length >= rootPath.length) {
|
||||
setCursor(parentPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (input === ' ') {
|
||||
const item = flatList[currentIndex];
|
||||
if (item && !item.isDir) {
|
||||
// Toggle selection
|
||||
if (onSelect) {
|
||||
onSelect(item.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate viewport based on cursor
|
||||
const cursorIndex = flatList.findIndex(item => item.path === cursor);
|
||||
// Ensure height is valid number
|
||||
const safeHeight = Math.max(5, height || 20);
|
||||
const renderStart = Math.max(0, Math.min(cursorIndex - Math.floor(safeHeight / 2), flatList.length - safeHeight));
|
||||
const renderEnd = Math.min(flatList.length, renderStart + safeHeight);
|
||||
|
||||
const visibleItems = flatList.slice(renderStart, renderEnd);
|
||||
|
||||
return h(Box, { flexDirection: 'column', width: width, height: safeHeight },
|
||||
visibleItems.map((item) => {
|
||||
const isSelected = selectedFiles.has(item.path);
|
||||
const isCursor = item.path === cursor;
|
||||
|
||||
// Indentation
|
||||
const indent = ' '.repeat(Math.max(0, item.depth));
|
||||
|
||||
// Icon
|
||||
let icon = item.isDir
|
||||
? (item.isExpanded ? '▼ ' : '▶ ')
|
||||
: (isSelected ? '[x] ' : '[ ] ');
|
||||
|
||||
// Color logic
|
||||
let color = 'white';
|
||||
if (item.isDir) color = 'cyan';
|
||||
if (isSelected) color = 'green';
|
||||
|
||||
// Cursor style
|
||||
const bg = isCursor ? 'blue' : undefined;
|
||||
const textColor = isCursor ? 'white' : color;
|
||||
|
||||
return h(Box, { key: item.path, width: '100%' },
|
||||
h(Text, {
|
||||
backgroundColor: bg,
|
||||
color: textColor,
|
||||
wrap: 'truncate'
|
||||
}, `${indent}${icon}${item.name}`)
|
||||
);
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
export default FileTree;
|
||||
42
bin/ui/components/ThinkingBlock.mjs
Normal file
42
bin/ui/components/ThinkingBlock.mjs
Normal file
@@ -0,0 +1,42 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
|
||||
const h = React.createElement;
|
||||
|
||||
const ThinkingBlock = ({
|
||||
lines = [],
|
||||
isThinking = false,
|
||||
stats = { chars: 0 },
|
||||
width = 80
|
||||
}) => {
|
||||
// If no thinking lines and not thinking, show nothing
|
||||
if (lines.length === 0 && !isThinking) return null;
|
||||
|
||||
// Show only last few lines to avoid clutter
|
||||
const visibleLines = lines.slice(-3);
|
||||
const hiddenCount = Math.max(0, lines.length - 3);
|
||||
|
||||
return h(Box, {
|
||||
flexDirection: 'row',
|
||||
width: width,
|
||||
marginBottom: 1,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
// Left Gutter (Dimmed)
|
||||
h(Box, { marginRight: 1, borderStyle: 'single', borderRight: false, borderTop: false, borderBottom: false, borderLeftColor: 'gray', borderDimColor: true }),
|
||||
|
||||
h(Box, { flexDirection: 'column' },
|
||||
h(Text, { color: 'gray', dimColor: true },
|
||||
isThinking
|
||||
? `🧠 Thinking${stats.activeAgent ? ` (${stats.activeAgent})` : ''}... (${stats.chars} chars)`
|
||||
: `💭 Thought Process (${stats.chars} chars)`
|
||||
),
|
||||
visibleLines.map((line, i) =>
|
||||
h(Text, { key: i, color: 'gray', dimColor: true, wrap: 'truncate' }, ` ${line}`)
|
||||
),
|
||||
hiddenCount > 0 ? h(Text, { color: 'gray', dimColor: true, italic: true }, ` ...${hiddenCount} more`) : null
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default ThinkingBlock;
|
||||
208
bin/ui/components/TimeoutRow.mjs
Normal file
208
bin/ui/components/TimeoutRow.mjs
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* TimeoutRow Component - "Pro" Protocol
|
||||
* Interactive component for timeout recovery actions
|
||||
*
|
||||
* @module ui/components/TimeoutRow
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
|
||||
const { useState } = React;
|
||||
const h = React.createElement;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// TIMEOUT ROW - Non-destructive timeout handling
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* TimeoutRow Component
|
||||
* Displays interactive recovery options when a request times out
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {Function} props.onRetry - Called when user selects Retry
|
||||
* @param {Function} props.onCancel - Called when user selects Cancel
|
||||
* @param {Function} props.onSaveLogs - Called when user selects Save Logs
|
||||
* @param {string} props.lastGoodText - Last successful text before timeout
|
||||
* @param {number} props.elapsedTime - Time elapsed before timeout (seconds)
|
||||
*/
|
||||
export const TimeoutRow = ({
|
||||
onRetry,
|
||||
onCancel,
|
||||
onSaveLogs,
|
||||
lastGoodText = '',
|
||||
elapsedTime = 120
|
||||
}) => {
|
||||
const [selectedAction, setSelectedAction] = useState(0);
|
||||
const actions = [
|
||||
{ key: 'r', label: '[R]etry', color: 'yellow', action: onRetry },
|
||||
{ key: 'c', label: '[C]ancel', color: 'gray', action: onCancel },
|
||||
{ key: 's', label: '[S]ave Logs', color: 'blue', action: onSaveLogs }
|
||||
];
|
||||
|
||||
// Handle keyboard input
|
||||
useInput((input, key) => {
|
||||
const lowerInput = input.toLowerCase();
|
||||
|
||||
// Direct key shortcuts
|
||||
if (lowerInput === 'r' && onRetry) {
|
||||
onRetry();
|
||||
return;
|
||||
}
|
||||
if (lowerInput === 'c' && onCancel) {
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
if (lowerInput === 's' && onSaveLogs) {
|
||||
onSaveLogs();
|
||||
return;
|
||||
}
|
||||
|
||||
// Arrow key navigation
|
||||
if (key.leftArrow) {
|
||||
setSelectedAction(prev => Math.max(0, prev - 1));
|
||||
}
|
||||
if (key.rightArrow) {
|
||||
setSelectedAction(prev => Math.min(actions.length - 1, prev + 1));
|
||||
}
|
||||
|
||||
// Enter to confirm selected action
|
||||
if (key.return) {
|
||||
const action = actions[selectedAction]?.action;
|
||||
if (action) action();
|
||||
}
|
||||
});
|
||||
|
||||
return h(Box, {
|
||||
flexDirection: 'column',
|
||||
marginTop: 1,
|
||||
paddingLeft: 2
|
||||
},
|
||||
// Warning indicator
|
||||
h(Box, { marginBottom: 0 },
|
||||
h(Text, { color: 'yellow', bold: true }, '⚠ '),
|
||||
h(Text, { color: 'yellow' }, `Request timed out (${elapsedTime}s)`)
|
||||
),
|
||||
|
||||
// Action buttons
|
||||
h(Box, { marginTop: 0 },
|
||||
...actions.map((action, i) =>
|
||||
h(Box, { key: action.key, marginRight: 2 },
|
||||
h(Text, {
|
||||
color: i === selectedAction ? 'white' : action.color,
|
||||
inverse: i === selectedAction,
|
||||
bold: i === selectedAction
|
||||
}, ` ${action.label} `)
|
||||
)
|
||||
)
|
||||
),
|
||||
|
||||
// Context info (dimmed)
|
||||
lastGoodText ? h(Box, { marginTop: 0 },
|
||||
h(Text, { color: 'gray', dimColor: true },
|
||||
`${lastGoodText.split('\n').length} paragraphs preserved`)
|
||||
) : null
|
||||
);
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// RUN STATES - State machine for assistant responses
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export const RUN_STATES = {
|
||||
IDLE: 'idle',
|
||||
STREAMING: 'streaming',
|
||||
WAITING_FOR_TOOL: 'waiting_for_tool',
|
||||
COMPLETE: 'complete',
|
||||
TIMED_OUT: 'timed_out',
|
||||
CANCELLED: 'cancelled'
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new Run object
|
||||
* @param {string} id - Unique run ID
|
||||
* @param {string} prompt - Original user prompt
|
||||
* @returns {Object} New run object
|
||||
*/
|
||||
export function createRun(id, prompt) {
|
||||
return {
|
||||
id,
|
||||
prompt,
|
||||
state: RUN_STATES.IDLE,
|
||||
partialText: '',
|
||||
lastCheckpoint: '',
|
||||
startTime: Date.now(),
|
||||
lastActivityTime: Date.now(),
|
||||
tokensReceived: 0,
|
||||
error: null,
|
||||
metadata: {}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update run state with new data
|
||||
* @param {Object} run - Current run object
|
||||
* @param {Object} updates - Updates to apply
|
||||
* @returns {Object} Updated run object
|
||||
*/
|
||||
export function updateRun(run, updates) {
|
||||
return {
|
||||
...run,
|
||||
...updates,
|
||||
lastActivityTime: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkpoint the run for potential resume
|
||||
* @param {Object} run - Current run object
|
||||
* @returns {Object} Run with checkpoint set
|
||||
*/
|
||||
export function checkpointRun(run) {
|
||||
// Find last complete paragraph for clean resume point
|
||||
const text = run.partialText || '';
|
||||
const paragraphs = text.split('\n\n');
|
||||
const completeParagraphs = paragraphs.slice(0, -1).join('\n\n');
|
||||
|
||||
return {
|
||||
...run,
|
||||
lastCheckpoint: completeParagraphs || text
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate overlap for resume deduplication
|
||||
* @param {string} checkpoint - Last checkpointed text
|
||||
* @param {string} newText - New text from resumed generation
|
||||
* @returns {string} Deduplicated combined text
|
||||
*/
|
||||
export function deduplicateResume(checkpoint, newText) {
|
||||
if (!checkpoint || !newText) return newText || checkpoint || '';
|
||||
|
||||
// Find overlap at end of checkpoint / start of newText
|
||||
const checkpointLines = checkpoint.split('\n');
|
||||
const newLines = newText.split('\n');
|
||||
|
||||
// Look for matching lines to find overlap point
|
||||
let overlapStart = 0;
|
||||
for (let i = 0; i < newLines.length && i < 10; i++) {
|
||||
const line = newLines[i].trim();
|
||||
if (line && checkpointLines.some(cl => cl.trim() === line)) {
|
||||
overlapStart = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Return checkpoint + non-overlapping new content
|
||||
const uniqueNewContent = newLines.slice(overlapStart).join('\n');
|
||||
return checkpoint + (uniqueNewContent ? '\n\n' + uniqueNewContent : '');
|
||||
}
|
||||
|
||||
export default {
|
||||
TimeoutRow,
|
||||
RUN_STATES,
|
||||
createRun,
|
||||
updateRun,
|
||||
checkpointRun,
|
||||
deduplicateResume
|
||||
};
|
||||
174
bin/ui/utils/textFormatter.mjs
Normal file
174
bin/ui/utils/textFormatter.mjs
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Text Formatter Utilities - "Pro" Protocol
|
||||
* Sanitizes text before rendering to remove debug noise and HTML entities
|
||||
*
|
||||
* @module ui/utils/textFormatter
|
||||
*/
|
||||
|
||||
import he from 'he';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// SANITIZATION PATTERNS
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// Debug log patterns to strip
|
||||
const DEBUG_PATTERNS = [
|
||||
/\d+\s+[A-Z]:\\[^\n]+/g, // Windows paths: "xx E:\path\to\file"
|
||||
/\[\d{4}-\d{2}-\d{2}[^\]]+\]/g, // Timestamps: "[2024-01-01 12:00:00]"
|
||||
/DEBUG:\s*[^\n]+/gi, // DEBUG: messages
|
||||
/^>\s*undefined$/gm, // Stray undefined
|
||||
/^\s*at\s+[^\n]+$/gm, // Stack trace lines
|
||||
];
|
||||
|
||||
// HTML entities that commonly appear in AI output
|
||||
const ENTITY_MAP = {
|
||||
''': "'",
|
||||
'"': '"',
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
' ': ' ',
|
||||
''': "'",
|
||||
'/': '/',
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// CORE SANITIZERS
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Decode HTML entities to clean text
|
||||
* @param {string} text - Raw text with possible HTML entities
|
||||
* @returns {string} Clean text
|
||||
*/
|
||||
export function decodeEntities(text) {
|
||||
if (!text || typeof text !== 'string') return '';
|
||||
|
||||
// First pass: common entities via map
|
||||
let result = text;
|
||||
for (const [entity, char] of Object.entries(ENTITY_MAP)) {
|
||||
result = result.replace(new RegExp(entity, 'g'), char);
|
||||
}
|
||||
|
||||
// Second pass: use 'he' library for comprehensive decoding
|
||||
try {
|
||||
result = he.decode(result);
|
||||
} catch (e) {
|
||||
// Fallback if he fails
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip debug noise from text
|
||||
* @param {string} text - Text with possible debug output
|
||||
* @returns {string} Clean text without debug noise
|
||||
*/
|
||||
export function stripDebugNoise(text) {
|
||||
if (!text || typeof text !== 'string') return '';
|
||||
|
||||
let result = text;
|
||||
for (const pattern of DEBUG_PATTERNS) {
|
||||
result = result.replace(pattern, '');
|
||||
}
|
||||
|
||||
// Clean up resulting empty lines
|
||||
result = result.replace(/\n{3,}/g, '\n\n');
|
||||
|
||||
return result.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix broken list formatting
|
||||
* @param {string} text - Text with potentially broken lists
|
||||
* @returns {string} Text with fixed list formatting
|
||||
*/
|
||||
export function fixListFormatting(text) {
|
||||
if (!text || typeof text !== 'string') return '';
|
||||
|
||||
// Fix bullet points that got mangled
|
||||
let result = text
|
||||
.replace(/•\s*([a-z])/g, '• $1') // Fix stuck bullets
|
||||
.replace(/(\d+)\.\s*([a-z])/g, '$1. $2') // Fix numbered lists
|
||||
.replace(/:\s*\n\s*•/g, ':\n\n•') // Add spacing before lists
|
||||
.replace(/([.!?])\s*•/g, '$1\n\n•'); // Add line break before bullets
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure proper paragraph spacing
|
||||
* @param {string} text - Text to process
|
||||
* @returns {string} Text with proper paragraph breaks
|
||||
*/
|
||||
export function ensureParagraphSpacing(text) {
|
||||
if (!text || typeof text !== 'string') return '';
|
||||
|
||||
// Ensure sentences starting new topics get proper breaks
|
||||
let result = text
|
||||
.replace(/([.!?])\s*([A-Z][a-z])/g, '$1\n\n$2') // New sentence, new paragraph
|
||||
.replace(/\n{4,}/g, '\n\n\n'); // Max 3 newlines
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// MAIN PIPELINE
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Full sanitization pipeline for content before rendering
|
||||
* @param {string} text - Raw text from AI or system
|
||||
* @returns {string} Clean, formatted text ready for display
|
||||
*/
|
||||
export function cleanContent(text) {
|
||||
if (!text || typeof text !== 'string') return '';
|
||||
|
||||
let result = text;
|
||||
|
||||
// Step 1: Decode HTML entities
|
||||
result = decodeEntities(result);
|
||||
|
||||
// Step 2: Strip debug noise
|
||||
result = stripDebugNoise(result);
|
||||
|
||||
// Step 3: Fix list formatting
|
||||
result = fixListFormatting(result);
|
||||
|
||||
// Step 4: Normalize whitespace
|
||||
result = result.replace(/\r\n/g, '\n').trim();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format for single-line display (status messages, etc)
|
||||
* @param {string} text - Text to format
|
||||
* @param {number} maxLength - Maximum length before truncation
|
||||
* @returns {string} Single-line formatted text
|
||||
*/
|
||||
export function formatSingleLine(text, maxLength = 80) {
|
||||
if (!text || typeof text !== 'string') return '';
|
||||
|
||||
let result = cleanContent(text);
|
||||
|
||||
// Collapse to single line
|
||||
result = result.replace(/\n+/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
|
||||
// Truncate if needed
|
||||
if (result.length > maxLength) {
|
||||
result = result.slice(0, maxLength - 3) + '...';
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export default {
|
||||
cleanContent,
|
||||
decodeEntities,
|
||||
stripDebugNoise,
|
||||
fixListFormatting,
|
||||
ensureParagraphSpacing,
|
||||
formatSingleLine
|
||||
};
|
||||
Reference in New Issue
Block a user