Initial Release: OpenQode Public Alpha v1.3

This commit is contained in:
Gemini AI
2025-12-14 00:40:14 +04:00
Unverified
commit 8e8d80c110
119 changed files with 31174 additions and 0 deletions

67
bin/auth.js Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

324
bin/smart-agent-flow.mjs Normal file
View 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
View 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
View 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
View 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;

View 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
};

View 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;

View 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;

View 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;

View 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;

View 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
};

View 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 = {
'&#39;': "'",
'&quot;': '"',
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&nbsp;': ' ',
'&#x27;': "'",
'&#x2F;': '/',
};
// ═══════════════════════════════════════════════════════════════
// 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
};