Initial Release: OpenQode Public Alpha v1.3
This commit is contained in:
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