Release v1.01 Enhanced: Vi Control, TUI Gen5, Core Stability

This commit is contained in:
Gemini AI
2025-12-20 01:12:45 +04:00
Unverified
parent 2407c42eb9
commit 142aaeee1e
254 changed files with 44888 additions and 31025 deletions

View File

@@ -0,0 +1,162 @@
/**
* Automation Timeline Component
*
* Shows Observe → Intent → Actions → Verify for each automation step
*
* Credits: Windows-Use verification loop, Browser-Use agent
*/
import React, { useState } from 'react';
import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
import { colors } from '../../tui-theme.mjs';
import { getCapabilities } from '../../terminal-profile.mjs';
const h = React.createElement;
// Step phases
export const STEP_PHASES = {
OBSERVE: 'observe',
INTENT: 'intent',
ACTIONS: 'actions',
VERIFY: 'verify'
};
/**
* Single timeline step
*/
const TimelineStep = ({
stepNumber,
observe = null, // "What I see now"
intent = null, // "What I'm trying next"
actions = [], // Array of action descriptions
verify = null, // { passed, message }
isActive = false,
isExpanded = false,
width = 60
}) => {
const caps = getCapabilities();
const railV = caps.unicodeOK ? '│' : '|';
const bullet = caps.unicodeOK ? '●' : '*';
const checkmark = caps.unicodeOK ? '✓' : '+';
const crossmark = caps.unicodeOK ? '✗' : 'X';
// Collapsed view: just step number + status
if (!isExpanded && !isActive) {
const status = verify
? (verify.passed ? 'passed' : 'failed')
: 'pending';
const statusIcon = verify?.passed ? checkmark : (verify ? crossmark : '…');
const statusColor = verify?.passed ? colors.success : (verify ? colors.error : colors.muted);
return h(Box, { flexDirection: 'row' },
h(Text, { color: colors.muted }, `Step ${stepNumber}: `),
h(Text, { color: statusColor }, statusIcon),
h(Text, { color: colors.muted, dimColor: true },
intent ? ` ${intent.slice(0, width - 20)}` : ''
)
);
}
// Expanded/active view
return h(Box, { flexDirection: 'column', marginY: 0 },
// Step header
h(Box, { flexDirection: 'row' },
h(Text, { color: isActive ? colors.accent : colors.muted, bold: isActive },
`Step ${stepNumber}`
),
isActive ? h(Box, { marginLeft: 1 },
h(Spinner, { type: 'dots' })
) : null
),
// Observe section
observe ? h(Box, { flexDirection: 'row', paddingLeft: 2 },
h(Text, { color: 'cyan' }, `${railV} Observe: `),
h(Text, { color: colors.muted, wrap: 'truncate' },
observe.slice(0, width - 15)
)
) : null,
// Intent section
intent ? h(Box, { flexDirection: 'row', paddingLeft: 2 },
h(Text, { color: 'yellow' }, `${railV} Intent: `),
h(Text, { color: colors.fg }, intent.slice(0, width - 15))
) : null,
// Actions section
actions.length > 0 ? h(Box, { flexDirection: 'column', paddingLeft: 2 },
h(Text, { color: 'magenta' }, `${railV} Actions:`),
...actions.slice(0, 5).map((action, i) =>
h(Text, { key: i, color: colors.muted, dimColor: true },
`${railV} ${i + 1}. ${action.slice(0, width - 10)}`
)
),
actions.length > 5 ? h(Text, { color: colors.muted, dimColor: true },
`${railV} +${actions.length - 5} more`
) : null
) : null,
// Verify section
verify ? h(Box, { flexDirection: 'row', paddingLeft: 2 },
h(Text, { color: verify.passed ? colors.success : colors.error },
`${railV} Verify: ${verify.passed ? checkmark : crossmark} `
),
h(Text, { color: colors.muted }, verify.message || '')
) : null
);
};
/**
* Automation Timeline
*
* Props:
* - steps: array of step objects
* - activeStepIndex: currently executing step (-1 if none)
* - isExpanded: show all details
* - width: available width
*/
const AutomationTimeline = ({
steps = [],
activeStepIndex = -1,
isExpanded = false,
title = 'Automation',
width = 80
}) => {
const caps = getCapabilities();
if (steps.length === 0) return null;
// Count stats
const verified = steps.filter(s => s.verify?.passed).length;
const failed = steps.filter(s => s.verify && !s.verify.passed).length;
const pending = steps.length - verified - failed;
return h(Box, { flexDirection: 'column' },
// Header with stats
h(Box, { flexDirection: 'row', marginBottom: 0 },
h(Text, { color: colors.muted, bold: true }, `${title} `),
h(Text, { color: colors.success }, `${verified}`),
failed > 0 ? h(Text, { color: colors.error }, `${failed}`) : null,
pending > 0 ? h(Text, { color: colors.muted }, `${pending}`) : null
),
// Steps
...steps.map((step, i) =>
h(TimelineStep, {
key: i,
stepNumber: i + 1,
observe: step.observe,
intent: step.intent,
actions: step.actions || [],
verify: step.verify,
isActive: i === activeStepIndex,
isExpanded: isExpanded || i === activeStepIndex,
width: width - 4
})
)
);
};
export default AutomationTimeline;
export { AutomationTimeline, TimelineStep };

View File

@@ -0,0 +1,106 @@
/**
* Browser Inspector - Browser-Use inspired
*
* Shows: URL, title, tabs, page stats, interactive elements
*
* Credit: https://github.com/browser-use/browser-use
*/
import React, { useState } from 'react';
import { Box, Text } from 'ink';
import { colors } from '../../tui-theme.mjs';
import { getCapabilities } from '../../terminal-profile.mjs';
const h = React.createElement;
/**
* Browser Inspector Component
*/
const BrowserInspector = ({
url = null,
title = null,
tabs = [],
pageInfo = null,
pageStats = null,
interactiveElements = [],
screenshot = null,
isExpanded = false,
width = 40
}) => {
const caps = getCapabilities();
const [expanded, setExpanded] = useState(isExpanded);
// Truncate URL for display
const displayUrl = url
? (url.length > width - 10 ? url.slice(0, width - 13) + '...' : url)
: 'No page';
// Collapsed view
if (!expanded) {
return h(Box, { flexDirection: 'row' },
h(Text, { color: colors.muted, bold: true }, '🌐 Browser: '),
h(Text, { color: colors.accent }, displayUrl)
);
}
// Expanded view
return h(Box, { flexDirection: 'column', width },
// Header
h(Text, { color: colors.accent, bold: true }, '🌐 Browser Inspector'),
// URL
h(Box, { flexDirection: 'row', paddingLeft: 1 },
h(Text, { color: colors.muted }, 'URL: '),
h(Text, { color: colors.accent }, displayUrl)
),
// Title
title ? h(Box, { flexDirection: 'row', paddingLeft: 1 },
h(Text, { color: colors.muted }, 'Title: '),
h(Text, { color: colors.fg }, title.slice(0, width - 10))
) : null,
// Tabs
tabs.length > 0 ? h(Box, { flexDirection: 'column', paddingLeft: 1 },
h(Text, { color: colors.muted }, `Tabs (${tabs.length}):`),
...tabs.slice(0, 3).map((tab, i) =>
h(Text, { key: i, color: colors.muted, dimColor: true },
` ${i + 1}. ${(tab.title || tab.url || '').slice(0, width - 8)}`
)
),
tabs.length > 3
? h(Text, { color: colors.muted, dimColor: true }, ` +${tabs.length - 3} more`)
: null
) : null,
// Page stats
pageStats ? h(Box, { flexDirection: 'row', paddingLeft: 1 },
h(Text, { color: colors.muted }, 'Stats: '),
h(Text, { color: colors.muted, dimColor: true },
`${pageStats.links || 0} links, ${pageStats.buttons || 0} buttons, ${pageStats.inputs || 0} inputs`
)
) : null,
// Interactive elements (first 5)
interactiveElements.length > 0 ? h(Box, { flexDirection: 'column', paddingLeft: 1 },
h(Text, { color: colors.muted }, 'Elements:'),
...interactiveElements.slice(0, 5).map((el, i) =>
h(Text, { key: i, color: colors.muted, dimColor: true },
` [${el.id || i}] ${el.tag}: ${(el.text || el.name || '').slice(0, 25)}`
)
),
interactiveElements.length > 5
? h(Text, { color: colors.muted, dimColor: true }, ` +${interactiveElements.length - 5} more`)
: null
) : null,
// Screenshot link
screenshot ? h(Box, { flexDirection: 'row', paddingLeft: 1 },
h(Text, { color: colors.muted }, '📷 '),
h(Text, { color: colors.accent, underline: true }, 'View screenshot')
) : null
);
};
export default BrowserInspector;
export { BrowserInspector };

View File

@@ -0,0 +1,173 @@
/**
* Channel Components - Separate lanes for different content types
*
* CHANNEL SEPARATION:
* - ChatLane: user + assistant prose only
* - ToolLane: tool calls, auto-heal, IQ exchange (collapsed by default)
* - ErrorLane: short summary + expandable details
*/
import React, { useEffect, useState } from 'react';
import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
import { colors } from '../../tui-theme.mjs';
import { icon } from '../../icons.mjs';
import { getCapabilities } from '../../terminal-profile.mjs';
const h = React.createElement;
/**
* ToolLane - Collapsed tool/command output
* Expands on demand, doesn't pollute chat
*/
const ToolLane = ({
name,
status = 'running', // running, done, failed
summary = null,
output = null,
isExpanded = false,
onToggle = null,
width = 80
}) => {
const caps = getCapabilities();
const [expanded, setExpanded] = useState(isExpanded);
useEffect(() => {
setExpanded(isExpanded);
}, [isExpanded]);
const statusConfig = {
running: { color: colors.accent, icon: null, showSpinner: true },
done: { color: colors.success, icon: caps.unicodeOK ? '✓' : '+', showSpinner: false },
failed: { color: colors.error, icon: caps.unicodeOK ? '✗' : 'X', showSpinner: false }
};
const config = statusConfig[status] || statusConfig.running;
const railChar = caps.unicodeOK ? '│' : '|';
// Header line (always shown)
const header = h(Box, { flexDirection: 'row' },
// Rail
h(Text, { color: 'magenta' }, railChar + ' '),
// Spinner or icon
config.showSpinner
? h(Spinner, { type: 'dots' })
: h(Text, { color: config.color }, config.icon),
h(Text, {}, ' '),
// Tool name
h(Text, { color: config.color, bold: true }, name),
// Summary (if any)
summary ? h(Text, { color: colors.muted }, ` ${summary}`) : null,
// Expand hint (if has output)
output && !expanded ? h(Text, { color: colors.muted, dimColor: true },
` [${caps.unicodeOK ? '▼' : 'v'} expand]`
) : null
);
if (!expanded || !output) {
return header;
}
// Expanded view with output
return h(Box, { flexDirection: 'column' },
header,
h(Box, { paddingLeft: 4, marginTop: 0, marginBottom: 1 },
h(Text, {
color: colors.muted,
dimColor: true,
wrap: 'wrap'
}, output.length > 200 ? output.slice(0, 200) + '...' : output)
)
);
};
/**
* ErrorLane - Compact error display
* Short summary line + expandable details
*/
const ErrorLane = ({
message,
details = null,
isExpanded = false,
width = 80
}) => {
const caps = getCapabilities();
const [expanded, setExpanded] = useState(isExpanded);
useEffect(() => {
setExpanded(isExpanded);
}, [isExpanded]);
const railChar = caps.unicodeOK ? '│' : '|';
const errorIcon = caps.unicodeOK ? '✗' : 'X';
// Summary line (always shown)
const summary = h(Box, { flexDirection: 'row' },
h(Text, { color: colors.error }, railChar + ' '),
h(Text, { color: colors.error }, errorIcon + ' '),
h(Text, { color: colors.error, bold: true }, 'Error: '),
h(Text, { color: colors.fg, wrap: 'truncate' },
message.length > 60 ? message.slice(0, 57) + '...' : message
),
details && !expanded ? h(Text, { color: colors.muted, dimColor: true },
` [${caps.unicodeOK ? '▼' : 'v'} details]`
) : null
);
if (!expanded || !details) {
return summary;
}
// Expanded with details
return h(Box, { flexDirection: 'column' },
summary,
h(Box, { paddingLeft: 4, marginTop: 0, marginBottom: 1 },
h(Text, { color: colors.muted, wrap: 'wrap' }, details)
)
);
};
/**
* SystemChip - Single-line system message
* Minimal, doesn't interrupt conversation flow
*/
const SystemChip = ({ message, type = 'info' }) => {
const caps = getCapabilities();
const railChar = caps.unicodeOK ? '│' : '|';
const typeConfig = {
info: { color: colors.accent, icon: caps.unicodeOK ? '' : 'i' },
success: { color: colors.success, icon: caps.unicodeOK ? '✓' : '+' },
warning: { color: colors.warning, icon: caps.unicodeOK ? '⚠' : '!' }
};
const config = typeConfig[type] || typeConfig.info;
return h(Box, { flexDirection: 'row' },
h(Text, { color: config.color, dimColor: true }, railChar + ' '),
h(Text, { color: config.color, dimColor: true }, config.icon + ' '),
h(Text, { color: colors.muted, dimColor: true }, message)
);
};
/**
* IQExchangeChip - IQ Exchange status (single line)
*/
const IQExchangeChip = ({ message, isActive = true }) => {
const caps = getCapabilities();
const railChar = caps.unicodeOK ? '│' : '|';
return h(Box, { flexDirection: 'row' },
h(Text, { color: 'magenta' }, railChar + ' '),
isActive ? h(Spinner, { type: 'dots' }) : null,
isActive ? h(Text, {}, ' ') : null,
h(Text, { color: 'magenta', bold: true }, 'IQ Exchange: '),
h(Text, { color: colors.muted }, message)
);
};
export { ToolLane, ErrorLane, SystemChip, IQExchangeChip };
export default { ToolLane, ErrorLane, SystemChip, IQExchangeChip };

View File

@@ -0,0 +1,156 @@
/**
* Clean TODO Checklist Component
*
* Based on sst/opencode todowrite rendering
* Credit: https://github.com/sst/opencode
*
* Clean [ ]/[x] checklist with status highlighting
*/
import React from 'react';
import { Box, Text } from 'ink';
import { colors } from '../../tui-theme.mjs';
import { getCapabilities } from '../../terminal-profile.mjs';
const h = React.createElement;
// TODO status types
export const TODO_STATUS = {
PENDING: 'pending',
IN_PROGRESS: 'in_progress',
DONE: 'done'
};
/**
* Single TODO item
*/
const TodoItem = ({ text, status = TODO_STATUS.PENDING, width = 80 }) => {
const caps = getCapabilities();
// Checkbox
const checkbox = status === TODO_STATUS.DONE
? '[x]'
: status === TODO_STATUS.IN_PROGRESS
? '[/]'
: '[ ]';
// Status-based styling
const textColor = status === TODO_STATUS.DONE
? colors.muted
: status === TODO_STATUS.IN_PROGRESS
? colors.success
: colors.fg;
const isDim = status === TODO_STATUS.DONE;
// Truncate text if needed
const maxTextWidth = width - 5;
const displayText = text.length > maxTextWidth
? text.slice(0, maxTextWidth - 1) + '…'
: text;
return h(Box, { flexDirection: 'row' },
h(Text, {
color: status === TODO_STATUS.IN_PROGRESS ? colors.success : colors.muted
}, checkbox + ' '),
h(Text, {
color: textColor,
dimColor: isDim,
strikethrough: status === TODO_STATUS.DONE
}, displayText)
);
};
/**
* Clean TODO List - OpenCode style
*
* Props:
* - items: array of { text, status }
* - isExpanded: show full list or summary
* - width: available width
*/
const CleanTodoList = ({
items = [],
isExpanded = false,
title = 'Tasks',
width = 80
}) => {
const caps = getCapabilities();
// Count stats
const total = items.length;
const done = items.filter(i => i.status === TODO_STATUS.DONE).length;
const inProgress = items.filter(i => i.status === TODO_STATUS.IN_PROGRESS).length;
// Summary line
const summaryText = `${done}/${total} done`;
const progressIcon = caps.unicodeOK ? '▰' : '#';
const emptyIcon = caps.unicodeOK ? '▱' : '-';
// Progress bar (visual)
const progressWidth = Math.min(10, width - 20);
const filledCount = total > 0 ? Math.round((done / total) * progressWidth) : 0;
const progressBar = progressIcon.repeat(filledCount) + emptyIcon.repeat(progressWidth - filledCount);
// Collapsed view: just summary
if (!isExpanded && total > 3) {
return h(Box, { flexDirection: 'column' },
h(Box, { flexDirection: 'row' },
h(Text, { color: colors.muted, bold: true }, title + ': '),
h(Text, { color: colors.accent }, progressBar),
h(Text, { color: colors.muted }, ` ${summaryText}`)
),
// Show in-progress items even when collapsed
...items
.filter(i => i.status === TODO_STATUS.IN_PROGRESS)
.slice(0, 2)
.map((item, i) => h(TodoItem, {
key: i,
text: item.text,
status: item.status,
width
}))
);
}
// Expanded view: full list
return h(Box, { flexDirection: 'column' },
// Header
h(Box, { flexDirection: 'row', marginBottom: 0 },
h(Text, { color: colors.muted, bold: true }, title + ' '),
h(Text, { color: colors.accent }, progressBar),
h(Text, { color: colors.muted }, ` ${summaryText}`)
),
// Items
...items.map((item, i) => h(TodoItem, {
key: i,
text: item.text,
status: item.status,
width
}))
);
};
/**
* Convert legacy todo format to clean format
*/
function normalizeTodos(todos) {
if (!Array.isArray(todos)) return [];
return todos.map(todo => {
// Handle string items
if (typeof todo === 'string') {
return { text: todo, status: TODO_STATUS.PENDING };
}
// Handle object items
return {
text: todo.text || todo.content || todo.title || String(todo),
status: todo.status || (todo.done ? TODO_STATUS.DONE : TODO_STATUS.PENDING)
};
});
}
export default CleanTodoList;
export { CleanTodoList, TodoItem, normalizeTodos };

View File

@@ -0,0 +1,120 @@
/**
* CodeCard Component (SnippetBlock)
*
* Renders code blocks with a Discord-style header and Google-style friendly paths.
* Supports syntax highlighting via ink-markdown and smart collapsing.
*/
import React, { useState, useMemo } from 'react';
import { Box, Text } from 'ink';
import Markdown from '../../ink-markdown-esm.mjs';
import path from 'path';
const h = React.createElement;
export const CodeCard = ({ language, filename, content, width, isStreaming, project }) => {
const lineCount = content ? content.split('\n').length : 0;
const [isExpanded, setIsExpanded] = useState(false);
// Calculate safe content width accounting for spacing
const contentWidth = width ? width - 4 : 60; // Account for left gutter (2) and spacing (2)
// SMART PATH RESOLUTION
// Resolve the display path relative to the project root for a "Friendly" view
const displayPath = useMemo(() => {
if (!filename || filename === 'snippet.txt') return { dir: '', base: filename || 'snippet' };
// If we have a project root, try to resolve relative path
if (project && filename) {
try {
// If it's absolute, make it relative to project
if (path.isAbsolute(filename)) {
const rel = path.relative(project, filename);
if (!rel.startsWith('..') && !path.isAbsolute(rel)) {
return { dir: path.dirname(rel), base: path.basename(rel) };
}
}
// If it's already relative (likely from AI response like 'src/index.js')
// Check if it has directory limits
if (filename.includes('/') || filename.includes('\\')) {
return { dir: path.dirname(filename), base: path.basename(filename) };
}
} catch (e) { /* ignore path errors */ }
}
return { dir: '', base: filename };
}, [filename, project]);
// Determine if we should show the expand/collapse functionality
// Smart Streaming Tail: If streaming and very long, collapse middle to show progress
const STREAMING_MAX_LINES = 20;
const STATIC_MAX_LINES = 10;
// Always allow expansion if long enough
const isLong = lineCount > (isStreaming ? STREAMING_MAX_LINES : STATIC_MAX_LINES);
const renderContent = () => {
if (isExpanded || !isLong) {
return h(Markdown, { syntaxTheme: 'github', width: contentWidth }, `\`\`\`${language || ''}\n${content}\n\`\`\``);
}
const lines = content.split('\n');
// Collapsed Logic
let firstLines, lastLines, hiddenCount;
if (isStreaming) {
// Streaming Mode: Show Head + Active Tail
// This ensures user sees the code BEING written
firstLines = lines.slice(0, 5).join('\n');
lastLines = lines.slice(-10).join('\n'); // Show last 10 lines for context
hiddenCount = lineCount - 15;
} else {
// Static Mode: Show Head + Foot
firstLines = lines.slice(0, 5).join('\n');
lastLines = lines.slice(-3).join('\n');
hiddenCount = lineCount - 8;
}
const previewContent = `${firstLines}\n\n// ... (${hiddenCount} lines hidden) ...\n\n${lastLines}`;
return h(Markdown, { syntaxTheme: 'github', width: contentWidth }, `\`\`\`${language || ''}\n${previewContent}\n\`\`\``);
};
return h(Box, {
flexDirection: 'column',
width: width,
marginLeft: 2,
marginBottom: 1
},
// SMART HEADER with Friendly Path
h(Box, {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 0.5
},
h(Box, { flexDirection: 'row' },
displayPath.dir && displayPath.dir !== '.' ?
h(Text, { color: 'gray', dimColor: true }, `📂 ${displayPath.dir} / `) : null,
h(Text, { color: 'cyan', bold: true }, `📄 ${displayPath.base}`),
h(Text, { color: 'gray', dimColor: true }, ` (${language})`)
),
h(Text, { color: 'gray', dimColor: true }, `${lineCount} lines`)
),
// Content area - no borders
h(Box, {
borderStyle: 'single',
borderColor: 'gray',
padding: 1
},
renderContent()
),
// Expand/collapse control
isLong ? h(Box, {
flexDirection: 'row',
justifyContent: 'flex-end',
marginTop: 0.5
},
h(Text, { color: 'cyan', dimColor: true }, isExpanded ? '▼ collapse' : (isStreaming ? '▼ auto-scroll (expand to view all)' : '▶ expand'))
) : null
);
};

View File

@@ -0,0 +1,105 @@
/**
* Desktop Inspector - Windows-Use inspired
*
* Shows: foreground app, cursor, apps list, interactive elements
*
* Credit: https://github.com/CursorTouch/Windows-Use
*/
import React, { useState } from 'react';
import { Box, Text } from 'ink';
import { colors } from '../../tui-theme.mjs';
import { getCapabilities } from '../../terminal-profile.mjs';
const h = React.createElement;
/**
* Desktop Inspector Component
*/
const DesktopInspector = ({
foregroundApp = null,
cursorPosition = null,
runningApps = [],
interactiveElements = [],
lastScreenshot = null,
lastVerification = null,
isExpanded = false,
width = 40
}) => {
const caps = getCapabilities();
const [expanded, setExpanded] = useState(isExpanded);
const railV = caps.unicodeOK ? '│' : '|';
const checkmark = caps.unicodeOK ? '✓' : '+';
const crossmark = caps.unicodeOK ? '✗' : 'X';
// Collapsed view
if (!expanded) {
return h(Box, { flexDirection: 'row' },
h(Text, { color: colors.muted, bold: true }, '🖥️ Desktop: '),
h(Text, { color: colors.fg }, foregroundApp || 'Unknown'),
interactiveElements.length > 0
? h(Text, { color: colors.muted }, ` (${interactiveElements.length} elements)`)
: null
);
}
// Expanded view
return h(Box, { flexDirection: 'column', width },
// Header
h(Text, { color: colors.accent, bold: true }, '🖥️ Desktop Inspector'),
// Foreground app
h(Box, { flexDirection: 'row', paddingLeft: 1 },
h(Text, { color: colors.muted }, 'App: '),
h(Text, { color: colors.fg }, foregroundApp || 'Unknown')
),
// Cursor position
cursorPosition ? h(Box, { flexDirection: 'row', paddingLeft: 1 },
h(Text, { color: colors.muted }, 'Cursor: '),
h(Text, { color: colors.muted }, `(${cursorPosition.x}, ${cursorPosition.y})`)
) : null,
// Running apps (first 5)
runningApps.length > 0 ? h(Box, { flexDirection: 'column', paddingLeft: 1 },
h(Text, { color: colors.muted }, 'Apps:'),
...runningApps.slice(0, 5).map((app, i) =>
h(Text, { key: i, color: colors.muted, dimColor: true }, ` ${i + 1}. ${app}`)
),
runningApps.length > 5
? h(Text, { color: colors.muted, dimColor: true }, ` +${runningApps.length - 5} more`)
: null
) : null,
// Last screenshot path
lastScreenshot ? h(Box, { flexDirection: 'column', paddingLeft: 1 },
h(Text, { color: colors.muted }, 'Screenshot:'),
h(Text, { color: colors.muted, dimColor: true, wrap: 'truncate' }, lastScreenshot)
) : null,
// Interactive elements (first 5)
interactiveElements.length > 0 ? h(Box, { flexDirection: 'column', paddingLeft: 1 },
h(Text, { color: colors.muted }, 'Elements:'),
...interactiveElements.slice(0, 5).map((el, i) =>
h(Text, { key: i, color: colors.muted, dimColor: true },
` [${el.id || i}] ${el.type}: ${(el.text || '').slice(0, 20)}`
)
),
interactiveElements.length > 5
? h(Text, { color: colors.muted, dimColor: true }, ` +${interactiveElements.length - 5} more`)
: null
) : null,
// Last verification
lastVerification ? h(Box, { flexDirection: 'row', paddingLeft: 1 },
h(Text, { color: lastVerification.passed ? colors.success : colors.error },
lastVerification.passed ? checkmark : crossmark
),
h(Text, { color: colors.muted }, ` Verify: ${lastVerification.message || ''}`)
) : null
);
};
export default DesktopInspector;
export { DesktopInspector };

View File

@@ -4,53 +4,169 @@ import * as Diff from 'diff';
const h = React.createElement;
const normalizeNewlines = (text) => String(text ?? '').replace(/\r\n/g, '\n');
const applySelectedHunks = (originalText, patch, enabledHunkIds) => {
const original = normalizeNewlines(originalText);
const hadTrailingNewline = original.endsWith('\n');
const originalLines = original.split('\n');
if (hadTrailingNewline) originalLines.pop();
const out = [];
let i = 0; // index into originalLines
const hunks = Array.isArray(patch?.hunks) ? patch.hunks : [];
for (let h = 0; h < hunks.length; h++) {
const hunk = hunks[h];
const id = `${hunk.oldStart}:${hunk.oldLines}->${hunk.newStart}:${hunk.newLines}`;
const enabled = enabledHunkIds.has(id);
const oldStartIdx = Math.max(0, (hunk.oldStart || 1) - 1);
const oldEndIdx = oldStartIdx + (hunk.oldLines || 0);
// Add unchanged segment before hunk
while (i < oldStartIdx && i < originalLines.length) {
out.push(originalLines[i]);
i++;
}
if (!enabled) {
// Keep original segment for this hunk range
while (i < oldEndIdx && i < originalLines.length) {
out.push(originalLines[i]);
i++;
}
continue;
}
// Apply hunk lines
const lines = Array.isArray(hunk.lines) ? hunk.lines : [];
for (const line of lines) {
if (!line) continue;
const prefix = line[0];
const content = line.slice(1);
if (prefix === ' ') {
// context line: consume from original and emit original content (safer than trusting patch line)
if (i < originalLines.length) {
out.push(originalLines[i]);
i++;
} else {
out.push(content);
}
} else if (prefix === '-') {
// deletion: consume original line, emit nothing
if (i < originalLines.length) i++;
} else if (prefix === '+') {
// addition: emit new content
out.push(content);
} else if (prefix === '\\') {
// "\ No newline at end of file" marker: ignore
} else {
// Unknown prefix: best-effort emit
out.push(line);
}
}
// After applying enabled hunk, ensure we've consumed the expected old range
i = Math.max(i, oldEndIdx);
}
// Append remaining original
while (i < originalLines.length) {
out.push(originalLines[i]);
i++;
}
const joined = out.join('\n') + (hadTrailingNewline ? '\n' : '');
return joined;
};
const DiffView = ({
original = '',
modified = '',
file = 'unknown.js',
onApply,
onApplyAndOpen,
onApplyAndTest,
onSkip,
width = 80,
height = 20
}) => {
// Generate diff objects
// [{ value: 'line', added: boolean, removed: boolean }]
const diff = Diff.diffLines(original, modified);
const normalizedOriginal = normalizeNewlines(original);
const normalizedModified = normalizeNewlines(modified);
const patch = Diff.structuredPatch(file, file, normalizedOriginal, normalizedModified, '', '', { context: 3 });
const hunks = Array.isArray(patch?.hunks) ? patch.hunks : [];
const hunkIds = hunks.map(h => `${h.oldStart}:${h.oldLines}->${h.newStart}:${h.newLines}`);
const [enabledHunks, setEnabledHunks] = useState(() => new Set(hunkIds));
const [mode, setMode] = useState('diff'); // 'diff' | 'hunks'
const [activeHunkIndex, setActiveHunkIndex] = useState(0);
// 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 diffForRender = Diff.diffLines(normalizedOriginal, normalizedModified);
const totalLines = diffForRender.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));
const maxScroll = Math.max(0, totalLines - visibleLines);
if (key.tab) {
setMode(m => (m === 'diff' ? 'hunks' : 'diff'));
return;
}
if (input === 'y' || input === 'Y' || key.return) {
onApply();
}
if (input === 'n' || input === 'N' || key.escape) {
onSkip();
if (mode === 'hunks') {
if (key.upArrow) setActiveHunkIndex(v => Math.max(0, v - 1));
if (key.downArrow) setActiveHunkIndex(v => Math.min(Math.max(0, hunks.length - 1), v + 1));
if (input.toLowerCase() === 't') {
const id = hunkIds[activeHunkIndex];
if (!id) return;
setEnabledHunks(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
if (input.toLowerCase() === 'a') {
setEnabledHunks(new Set(hunkIds));
}
if (input.toLowerCase() === 'x') {
setEnabledHunks(new Set());
}
if (key.escape) onSkip?.();
if (key.return) {
const nextContent = applySelectedHunks(normalizedOriginal, patch, enabledHunks);
onApply?.(nextContent);
}
return;
}
// diff scroll mode
if (key.upArrow) setScrollTop(prev => Math.max(0, prev - 1));
if (key.downArrow) setScrollTop(prev => Math.min(maxScroll, prev + 1));
if (key.pageUp) setScrollTop(prev => Math.max(0, prev - visibleLines));
if (key.pageDown) setScrollTop(prev => Math.min(maxScroll, prev + visibleLines));
const nextContent = applySelectedHunks(normalizedOriginal, patch, enabledHunks);
if (input === 'y' || input === 'Y' || key.return) onApply?.(nextContent);
if (input === 'r' || input === 'R') onApplyAndOpen?.(nextContent);
if (input === 'v' || input === 'V') onApplyAndTest?.(nextContent);
if (input === 'n' || input === 'N' || key.escape) onSkip?.();
});
// Render Logic
let currentLine = 0;
const renderedLines = [];
diff.forEach((part) => {
diffForRender.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();
@@ -94,11 +210,30 @@ const DiffView = ({
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')
h(Text, { dimColor: true }, `Hunks: ${hunks.length} | Selected: ${enabledHunks.size} | Lines: ${totalLines}`),
h(Text, { color: 'blue' }, mode === 'hunks' ? 'TAB diff | T toggle | A all | X none' : 'UP/DOWN scroll | TAB hunks | R reopen | V test')
)
),
mode === 'hunks'
? h(Box, { flexDirection: 'column', flexGrow: 1, paddingX: 1, paddingTop: 1 },
hunks.length === 0
? h(Text, { color: 'gray' }, 'No hunks (files are identical).')
: hunks.slice(0, Math.max(1, height - 6)).map((hunk, idx) => {
const id = hunkIds[idx];
const enabled = enabledHunks.has(id);
const isActive = idx === activeHunkIndex;
const label = `${enabled ? '[x]' : '[ ]'} @@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`;
return h(Text, {
key: id,
color: isActive ? 'cyan' : (enabled ? 'green' : 'gray'),
backgroundColor: isActive ? 'black' : undefined,
bold: isActive,
wrap: 'truncate-end'
}, label);
})
)
:
// Diff Content
h(Box, { flexDirection: 'column', flexGrow: 1, paddingX: 1 },
renderedLines.length > 0
@@ -117,8 +252,10 @@ const DiffView = ({
justifyContent: 'center',
gap: 4
},
h(Text, { color: 'green', bold: true }, '[Y] Apply Changes'),
h(Text, { color: 'red', bold: true }, '[N] Discard/Skip')
h(Text, { color: 'green', bold: true }, '[Y/Enter] Apply Selected'),
h(Text, { color: 'cyan', bold: true }, '[R] Apply + Reopen'),
h(Text, { color: 'magenta', bold: true }, '[V] Apply + Run Tests'),
h(Text, { color: 'red', bold: true }, '[N/Esc] Skip')
)
);
};

View File

@@ -0,0 +1,41 @@
import React from 'react';
import { Box, Text } from 'ink';
import SelectInput from 'ink-select-input';
import { colors } from '../../tui-theme.mjs';
const h = React.createElement;
const FilePickerOverlay = ({
title = 'Files',
hint = 'Enter open · Esc close',
items = [],
onSelect,
width = 80,
height = 24
}) => {
return h(Box, {
flexDirection: 'column',
width,
height,
borderStyle: 'double',
borderColor: 'cyan',
paddingX: 1,
paddingY: 0
},
h(Box, { justifyContent: 'space-between' },
h(Text, { color: 'cyan', bold: true }, title),
h(Text, { color: 'gray', dimColor: true }, hint)
),
h(Box, { flexDirection: 'column', marginTop: 1, flexGrow: 1 },
items.length > 0
? h(SelectInput, {
items,
onSelect
})
: h(Text, { color: colors.muted, dimColor: true }, '(empty)')
)
);
};
export default FilePickerOverlay;

View File

@@ -0,0 +1,130 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Box, Text, useInput } from 'ink';
import path from 'path';
const h = React.createElement;
const clamp = (n, min, max) => Math.max(min, Math.min(max, n));
const renderTabTitle = (tab, maxLen) => {
const base = tab.title || path.basename(tab.path || '') || 'untitled';
if (base.length <= maxLen) return base;
return base.slice(0, Math.max(1, maxLen - 1)) + '…';
};
const FilePreviewTabs = ({
tabs = [],
activeId = null,
onActivate,
onClose,
isActive = false,
width = 80,
height = 10
}) => {
const activeTab = tabs.find(t => t.id === activeId) || tabs[0] || null;
const [scrollTop, setScrollTop] = useState(0);
useEffect(() => {
setScrollTop(0);
}, [activeId]);
const contentLines = useMemo(() => {
if (!activeTab?.content) return [];
return activeTab.content.replace(/\r\n/g, '\n').split('\n');
}, [activeTab?.content]);
const headerRows = 2;
const footerRows = 1;
const bodyRows = Math.max(3, height - headerRows - footerRows);
const maxScroll = Math.max(0, contentLines.length - bodyRows);
const safeScrollTop = clamp(scrollTop, 0, maxScroll);
useEffect(() => {
if (safeScrollTop !== scrollTop) setScrollTop(safeScrollTop);
}, [safeScrollTop, scrollTop]);
useInput((input, key) => {
if (!isActive) return;
if (!activeTab) return;
if (key.escape) {
return;
}
if (key.upArrow) setScrollTop(v => Math.max(0, v - 1));
if (key.downArrow) setScrollTop(v => Math.min(maxScroll, v + 1));
if (key.pageUp) setScrollTop(v => Math.max(0, v - bodyRows));
if (key.pageDown) setScrollTop(v => Math.min(maxScroll, v + bodyRows));
if (key.home) setScrollTop(0);
if (key.end) setScrollTop(maxScroll);
if (key.leftArrow) {
const idx = tabs.findIndex(t => t.id === activeTab.id);
const prev = idx > 0 ? tabs[idx - 1] : tabs[tabs.length - 1];
if (prev && typeof onActivate === 'function') onActivate(prev.id);
}
if (key.rightArrow) {
const idx = tabs.findIndex(t => t.id === activeTab.id);
const next = idx >= 0 && idx < tabs.length - 1 ? tabs[idx + 1] : tabs[0];
if (next && typeof onActivate === 'function') onActivate(next.id);
}
if (key.ctrl && input.toLowerCase() === 'w') {
if (typeof onClose === 'function') onClose(activeTab.id);
}
}, { isActive });
const tabRow = useMemo(() => {
if (tabs.length === 0) return '';
const pad = 1;
const maxTitleLen = Math.max(6, Math.floor(width / Math.max(1, tabs.length)) - 6);
const parts = tabs.map(t => {
const title = renderTabTitle(t, maxTitleLen);
const dirty = t.dirty ? '*' : '';
return (t.id === activeId ? `[${title}${dirty}]` : ` ${title}${dirty} `);
});
const joined = parts.join(' ');
const truncated = joined.length > width - pad ? joined.slice(0, Math.max(0, width - pad - 1)) + '…' : joined;
return truncated;
}, [tabs, activeId, width]);
const lineNoWidth = Math.max(4, String(safeScrollTop + bodyRows).length + 1);
const contentWidth = Math.max(10, width - lineNoWidth - 2);
const visible = contentLines.slice(safeScrollTop, safeScrollTop + bodyRows);
return h(Box, {
flexDirection: 'column',
width,
height,
borderStyle: 'single',
borderColor: isActive ? 'cyan' : 'gray'
},
h(Box, { paddingX: 1, justifyContent: 'space-between' },
h(Text, { color: 'cyan', bold: true }, 'Files'),
h(Text, { color: 'gray', dimColor: true }, isActive ? '↑↓ scroll ←→ tabs Ctrl+W close Esc focus chat' : 'Ctrl+O open Ctrl+Shift+F search Tab focus')
),
h(Box, { paddingX: 1 },
h(Text, { color: 'white', wrap: 'truncate-end' }, tabRow || '(no tabs)')
),
h(Box, { flexDirection: 'column', flexGrow: 1, paddingX: 1 },
activeTab ? visible.map((line, i) => {
const lineNo = safeScrollTop + i + 1;
const no = String(lineNo).padStart(lineNoWidth - 1) + ' ';
return h(Box, { key: `${activeTab.id}:${lineNo}`, width: '100%' },
h(Text, { color: 'gray', dimColor: true }, no),
h(Text, { color: 'white', wrap: 'truncate-end' }, (line || '').slice(0, contentWidth))
);
}) : h(Text, { color: 'gray', dimColor: true }, 'Open a file to preview it here.')
),
h(Box, { paddingX: 1, justifyContent: 'space-between' },
h(Text, { color: 'gray', dimColor: true, wrap: 'truncate' }, activeTab?.relPath ? activeTab.relPath : ''),
activeTab ? h(Text, { color: 'gray', dimColor: true }, `${safeScrollTop + 1}-${Math.min(contentLines.length, safeScrollTop + bodyRows)} / ${contentLines.length}`) : null
)
);
};
export default FilePreviewTabs;

View File

@@ -25,6 +25,7 @@ const sortFiles = (files, dirPath) => {
const FileTree = ({
rootPath,
onSelect,
onOpen,
selectedFiles = new Set(),
isActive = false,
height = 20,
@@ -103,6 +104,11 @@ const FileTree = ({
if (!expanded.has(item.path)) {
setExpanded(prev => new Set([...prev, item.path]));
}
} else if (key.return) {
const selectedItem = flatList[currentIndex];
if (selectedItem && !selectedItem.isDir && typeof onOpen === 'function') {
onOpen(selectedItem.path);
}
}
}

View File

@@ -0,0 +1,138 @@
/**
* Flow Ribbon Component - "Ask → Preview → Run → Verify → Done"
*
* NOOB-PROOF: Always shows current phase and what to do next
*
* Credit: OpenCode-inspired phase ribbon
*/
import React from 'react';
import { Box, Text } from 'ink';
import { colors } from '../../tui-theme.mjs';
import { getCapabilities } from '../../terminal-profile.mjs';
const h = React.createElement;
// Flow phases
export const FLOW_PHASES = {
ASK: 'ask',
PREVIEW: 'preview',
RUN: 'run',
VERIFY: 'verify',
DONE: 'done'
};
// Phase display config
const PHASE_CONFIG = {
[FLOW_PHASES.ASK]: {
label: 'Ask',
hint: 'Describe what you want to do',
icon: '?'
},
[FLOW_PHASES.PREVIEW]: {
label: 'Preview',
hint: 'Review planned actions — Enter to run, or edit',
icon: '⊙'
},
[FLOW_PHASES.RUN]: {
label: 'Run',
hint: 'Executing actions...',
icon: '▶'
},
[FLOW_PHASES.VERIFY]: {
label: 'Verify',
hint: 'Checking results...',
icon: '✓?'
},
[FLOW_PHASES.DONE]: {
label: 'Done',
hint: 'Task completed',
icon: '✓'
}
};
/**
* Single phase pill
*/
const PhasePill = ({ phase, isActive, isPast, isFuture, useAscii }) => {
const config = PHASE_CONFIG[phase];
let color = colors.muted;
let dimColor = true;
if (isActive) {
color = colors.accent;
dimColor = false;
} else if (isPast) {
color = colors.success;
dimColor = true;
}
const icon = useAscii
? (isActive ? '*' : isPast ? '+' : '-')
: config.icon;
return h(Box, { flexDirection: 'row' },
h(Text, { color, dimColor, bold: isActive },
`${icon} ${config.label}`
)
);
};
/**
* Flow Ribbon - Shows current phase in workflow
*
* Props:
* - currentPhase: one of FLOW_PHASES
* - showHint: whether to show "what to do next" hint
* - width: ribbon width
*/
const FlowRibbon = ({
currentPhase = FLOW_PHASES.ASK,
showHint = true,
width = 80
}) => {
const caps = getCapabilities();
const phases = Object.values(FLOW_PHASES);
const currentIndex = phases.indexOf(currentPhase);
const separator = caps.unicodeOK ? ' → ' : ' > ';
const config = PHASE_CONFIG[currentPhase];
return h(Box, {
flexDirection: 'column',
width: width
},
// Phase pills
h(Box, { flexDirection: 'row' },
...phases.map((phase, i) => {
const isActive = phase === currentPhase;
const isPast = i < currentIndex;
const isFuture = i > currentIndex;
return h(Box, { key: phase, flexDirection: 'row' },
h(PhasePill, {
phase,
isActive,
isPast,
isFuture,
useAscii: !caps.unicodeOK
}),
i < phases.length - 1
? h(Text, { color: colors.muted, dimColor: true }, separator)
: null
);
})
),
// Hint line (what to do next)
showHint && config.hint ? h(Box, { marginTop: 0 },
h(Text, { color: colors.muted, dimColor: true },
`${config.hint}`
)
) : null
);
};
export default FlowRibbon;
export { FlowRibbon, PHASE_CONFIG };

View File

@@ -0,0 +1,101 @@
/**
* Footer Strip Component - Fixed-height session footer
*
* Based on sst/opencode footer pattern
* Credit: https://github.com/sst/opencode
*
* Shows: cwd + status counters + hints
*/
import React from 'react';
import { Box, Text } from 'ink';
import { colors } from '../../tui-theme.mjs';
import { icon } from '../../icons.mjs';
import { getCapabilities } from '../../terminal-profile.mjs';
import path from 'path';
const h = React.createElement;
/**
* FooterStrip - Bottom fixed-height zone
*
* Props:
* - cwd: current working directory
* - gitBranch: current git branch
* - messageCount: number of messages
* - toolCount: number of tool calls
* - errorCount: number of errors
* - hints: array of hint strings
* - width: strip width
*/
const FooterStrip = ({
cwd = null,
gitBranch = null,
messageCount = 0,
toolCount = 0,
errorCount = 0,
hints = [],
showDetails = false,
showThinking = false,
width = 80
}) => {
const caps = getCapabilities();
const separator = caps.unicodeOK ? '│' : '|';
const branchIcon = caps.unicodeOK ? '' : '@';
const msgIcon = caps.unicodeOK ? '💬' : 'M';
const toolIcon = caps.unicodeOK ? '⚙' : 'T';
const errIcon = caps.unicodeOK ? '✗' : 'X';
// Truncate cwd for display
const cwdDisplay = cwd
? (cwd.length > 30 ? '…' + cwd.slice(-28) : cwd)
: '.';
return h(Box, {
flexDirection: 'row',
justifyContent: 'space-between',
width: width,
height: 1,
flexShrink: 0,
paddingX: 1
},
// Left: CWD + branch
h(Box, { flexDirection: 'row' },
h(Text, { color: colors.muted }, cwdDisplay),
gitBranch ? h(Text, { color: colors.muted }, ` ${branchIcon}${gitBranch}`) : null
),
// Center: Toggle status
h(Box, { flexDirection: 'row' },
h(Text, { color: showDetails ? colors.success : colors.muted, dimColor: !showDetails },
'details'
),
h(Text, { color: colors.muted }, ' '),
h(Text, { color: showThinking ? colors.success : colors.muted, dimColor: !showThinking },
'thinking'
)
),
// Right: Counters
h(Box, { flexDirection: 'row' },
// Messages
h(Text, { color: colors.muted }, msgIcon + ' '),
h(Text, { color: colors.muted }, String(messageCount)),
h(Text, { color: colors.muted }, ` ${separator} `),
// Tools
h(Text, { color: colors.muted }, toolIcon + ' '),
h(Text, { color: colors.muted }, String(toolCount)),
// Errors (only if > 0)
errorCount > 0 ? h(Box, { flexDirection: 'row' },
h(Text, { color: colors.muted }, ` ${separator} `),
h(Text, { color: colors.error }, errIcon + ' '),
h(Text, { color: colors.error }, String(errorCount))
) : null
)
);
};
export default FooterStrip;
export { FooterStrip };

View File

@@ -0,0 +1,94 @@
/**
* Getting Started Card Component
*
* Based on sst/opencode sidebar onboarding pattern
* Credit: https://github.com/sst/opencode
*
* Dismissible card for new users
*/
import React, { useState } from 'react';
import { Box, Text } from 'ink';
import { colors } from '../../tui-theme.mjs';
import { icon } from '../../icons.mjs';
import { getCapabilities } from '../../terminal-profile.mjs';
const h = React.createElement;
/**
* Getting Started Card - Noob-friendly onboarding
*/
const GettingStartedCard = ({
isDismissed = false,
onDismiss = null,
width = 24
}) => {
const caps = getCapabilities();
const [dismissed, setDismissed] = useState(isDismissed);
if (dismissed) return null;
const borderH = caps.unicodeOK ? '─' : '-';
const cornerTL = caps.unicodeOK ? '╭' : '+';
const cornerTR = caps.unicodeOK ? '╮' : '+';
const cornerBL = caps.unicodeOK ? '╰' : '+';
const cornerBR = caps.unicodeOK ? '╯' : '+';
const railV = caps.unicodeOK ? '│' : '|';
const sparkle = caps.unicodeOK ? '✨' : '*';
const contentWidth = Math.max(10, width - 4);
const handleDismiss = () => {
setDismissed(true);
onDismiss?.();
};
return h(Box, { flexDirection: 'column', marginY: 1 },
// Top border with title
h(Text, { color: colors.accent },
cornerTL + borderH + ` ${sparkle} Welcome ` + borderH.repeat(Math.max(0, contentWidth - 11)) + cornerTR
),
// Content
h(Box, { flexDirection: 'column', paddingX: 1 },
h(Text, { color: colors.fg }, 'Quick Start:'),
h(Text, { color: colors.muted }, ''),
h(Text, { color: colors.muted }, `${icon('arrow')} Type a message to chat`),
h(Text, { color: colors.muted }, `${icon('arrow')} /help for commands`),
h(Text, { color: colors.muted }, `${icon('arrow')} /settings to configure`),
h(Text, { color: colors.muted }, ''),
h(Text, { color: colors.muted }, 'Keyboard:'),
h(Text, { color: colors.muted }, ` Tab - Toggle sidebar`),
h(Text, { color: colors.muted }, ` Ctrl+P - Command palette`),
h(Text, { color: colors.muted }, ` Ctrl+C - Exit`)
),
// Bottom border with dismiss hint
h(Text, { color: colors.muted, dimColor: true },
cornerBL + borderH.repeat(contentWidth - 10) + ` [x] dismiss ` + cornerBR
)
);
};
/**
* CommandHints - Compact keyboard hints
*/
const CommandHints = ({ width = 24 }) => {
const caps = getCapabilities();
return h(Box, { flexDirection: 'column' },
h(Text, { color: colors.muted, bold: true }, 'Commands'),
h(Text, { color: colors.muted }, '/help show help'),
h(Text, { color: colors.muted }, '/details toggle details'),
h(Text, { color: colors.muted }, '/think toggle thinking'),
h(Text, { color: colors.muted }, '/clear clear chat'),
h(Text, { color: colors.muted }, ''),
h(Text, { color: colors.muted, bold: true }, 'Keys'),
h(Text, { color: colors.muted }, 'Tab sidebar'),
h(Text, { color: colors.muted }, 'Ctrl+P palette'),
h(Text, { color: colors.muted }, 'Ctrl+C exit')
);
};
export default GettingStartedCard;
export { GettingStartedCard, CommandHints };

View File

@@ -0,0 +1,95 @@
/**
* Header Strip Component - Fixed-height session header
*
* Based on sst/opencode header pattern
* Credit: https://github.com/sst/opencode
*
* Shows: session title + context tokens + cost
*/
import React from 'react';
import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
import { colors } from '../../tui-theme.mjs';
import { icon } from '../../icons.mjs';
import { getCapabilities } from '../../terminal-profile.mjs';
const h = React.createElement;
/**
* HeaderStrip - Top fixed-height zone
*
* Props:
* - sessionName: current session/project name
* - tokens: token count (in/out)
* - cost: optional cost display
* - isConnected: API connection status
* - width: strip width
*/
const HeaderStrip = ({
sessionName = 'OpenQode',
agentMode = 'build',
model = null,
tokens = { in: 0, out: 0 },
cost = null,
isConnected = true,
isThinking = false,
width = 80
}) => {
const caps = getCapabilities();
const separator = caps.unicodeOK ? '│' : '|';
const dotIcon = caps.unicodeOK ? '●' : '*';
// Format token count
const tokenStr = tokens.in > 0 || tokens.out > 0
? `${Math.round(tokens.in / 1000)}k/${Math.round(tokens.out / 1000)}k`
: null;
// Format cost
const costStr = cost ? `$${cost.toFixed(4)}` : null;
return h(Box, {
flexDirection: 'row',
justifyContent: 'space-between',
width: width,
height: 1,
flexShrink: 0,
paddingX: 1
},
// Left: Session name + agent mode
h(Box, { flexDirection: 'row' },
h(Text, { color: colors.accent, bold: true }, sessionName),
h(Text, { color: colors.muted }, ` ${separator} `),
h(Text, { color: 'magenta' }, agentMode.toUpperCase()),
// Thinking indicator (if active)
isThinking ? h(Box, { flexDirection: 'row', marginLeft: 1 },
h(Spinner, { type: 'dots' }),
h(Text, { color: 'yellow' }, ' thinking...')
) : null
),
// Right: Stats
h(Box, { flexDirection: 'row' },
// Model name
model ? h(Text, { color: colors.muted, dimColor: true },
model.length > 15 ? model.slice(0, 13) + '…' : model
) : null,
model && tokenStr ? h(Text, { color: colors.muted }, ` ${separator} `) : null,
// Token count
tokenStr ? h(Text, { color: colors.muted }, tokenStr) : null,
tokenStr && costStr ? h(Text, { color: colors.muted }, ` ${separator} `) : null,
// Cost
costStr ? h(Text, { color: colors.success }, costStr) : null,
// Connection indicator
h(Text, { color: colors.muted }, ` ${separator} `),
h(Text, { color: isConnected ? colors.success : colors.error }, dotIcon)
)
);
};
export default HeaderStrip;
export { HeaderStrip };

View File

@@ -0,0 +1,139 @@
/**
* IntentTrace Component - Premium thinking display
*
* DESIGN:
* - Default: hidden or 1-line summary
* - When shown: Intent / Next / Why + "+N more"
* - Never spam raw logs into transcript
*/
import React, { useState } from 'react';
import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
import { colors } from '../../tui-theme.mjs';
import { icon } from '../../icons.mjs';
import { getCapabilities } from '../../terminal-profile.mjs';
const h = React.createElement;
/**
* IntentTrace - Collapsible thinking summary
*
* Props:
* - intent: current intent (1 line)
* - next: next action (1 line)
* - why: optional reasoning (1 line)
* - steps: array of step strings
* - isThinking: show spinner
* - verbosity: 'off' | 'brief' | 'detailed'
*/
const IntentTrace = ({
intent = null,
next = null,
why = null,
steps = [],
isThinking = false,
verbosity = 'brief',
width = 80
}) => {
const caps = getCapabilities();
const [expanded, setExpanded] = useState(false);
// Off mode = nothing shown
if (verbosity === 'off' && !isThinking) return null;
const railChar = caps.unicodeOK ? '┊' : ':';
const railColor = colors.muted;
// Brief mode: just intent + next
if (verbosity === 'brief' || !expanded) {
return h(Box, {
flexDirection: 'column',
marginY: 0
},
// Header with spinner
h(Box, { flexDirection: 'row' },
h(Text, { color: railColor, dimColor: true }, railChar + ' '),
isThinking ? h(Spinner, { type: 'dots' }) : null,
isThinking ? h(Text, {}, ' ') : null,
h(Text, { color: colors.muted, dimColor: true },
isThinking ? 'thinking...' : 'thought'
)
),
// Intent line
intent ? h(Box, { flexDirection: 'row' },
h(Text, { color: railColor, dimColor: true }, railChar + ' '),
h(Text, { color: colors.muted, bold: true }, 'Intent: '),
h(Text, { color: colors.muted },
intent.length > width - 15 ? intent.slice(0, width - 18) + '...' : intent
)
) : null,
// Next line
next ? h(Box, { flexDirection: 'row' },
h(Text, { color: railColor, dimColor: true }, railChar + ' '),
h(Text, { color: colors.muted, bold: true }, 'Next: '),
h(Text, { color: colors.muted },
next.length > width - 13 ? next.slice(0, width - 16) + '...' : next
)
) : null,
// Expand hint (if more steps)
steps.length > 0 ? h(Box, { flexDirection: 'row' },
h(Text, { color: railColor, dimColor: true }, railChar + ' '),
h(Text, { color: colors.muted, dimColor: true },
`+${steps.length} more`
)
) : null
);
}
// Detailed mode: show all
return h(Box, { flexDirection: 'column', marginY: 0 },
// Header
h(Box, { flexDirection: 'row' },
h(Text, { color: railColor, dimColor: true }, railChar + ' '),
isThinking ? h(Spinner, { type: 'dots' }) : null,
isThinking ? h(Text, {}, ' ') : null,
h(Text, { color: colors.muted }, 'Intent Trace')
),
// Intent
intent ? h(Box, { flexDirection: 'row' },
h(Text, { color: railColor, dimColor: true }, railChar + ' '),
h(Text, { color: colors.accent }, 'Intent: '),
h(Text, { color: colors.fg }, intent)
) : null,
// Next
next ? h(Box, { flexDirection: 'row' },
h(Text, { color: railColor, dimColor: true }, railChar + ' '),
h(Text, { color: colors.accent }, 'Next: '),
h(Text, { color: colors.fg }, next)
) : null,
// Why
why ? h(Box, { flexDirection: 'row' },
h(Text, { color: railColor, dimColor: true }, railChar + ' '),
h(Text, { color: colors.muted }, 'Why: '),
h(Text, { color: colors.muted }, why)
) : null,
// Steps
...steps.map((step, i) =>
h(Box, { key: i, flexDirection: 'row' },
h(Text, { color: railColor, dimColor: true }, railChar + ' '),
h(Text, { color: colors.muted, dimColor: true }, `${i + 1}. ${step}`)
)
),
// Collapse hint
h(Box, { flexDirection: 'row' },
h(Text, { color: railColor, dimColor: true }, railChar + ' '),
h(Text, { color: colors.muted, dimColor: true }, '[collapse]')
)
);
};
export default IntentTrace;
export { IntentTrace };

View File

@@ -0,0 +1,201 @@
/**
* Premium Input Bar Component
*
* STABILITY RULES:
* 1. Fixed height in ALL states (idle, streaming, approval, diff review)
* 2. Minimal "generating" indicator (no height changes)
* 3. Status strip above input (single line)
* 4. Never causes layout shifts
*/
import React, { useState } from 'react';
import { Box, Text } from 'ink';
import TextInput from 'ink-text-input';
import Spinner from 'ink-spinner';
import { colors, layout } from '../../tui-theme.mjs';
import { icon } from '../../icons.mjs';
import { getCapabilities } from '../../terminal-profile.mjs';
const h = React.createElement;
/**
* Status Strip - Single line above input showing current state
*/
const StatusStrip = ({
isStreaming = false,
model = null,
agent = null,
cwd = null,
tokensPerSec = 0
}) => {
const caps = getCapabilities();
const separator = caps.unicodeOK ? '│' : '|';
const parts = [];
// Streaming indicator
if (isStreaming) {
parts.push(h(Box, { key: 'stream', flexDirection: 'row' },
h(Spinner, { type: 'dots' }),
h(Text, { color: colors.accent }, ' generating')
));
if (tokensPerSec > 0) {
parts.push(h(Text, { key: 'tps', color: colors.muted }, ` ${tokensPerSec} tok/s`));
}
}
// Model
if (model) {
parts.push(h(Text, { key: 'model', color: colors.muted }, ` ${separator} ${model}`));
}
// Agent
if (agent) {
parts.push(h(Text, { key: 'agent', color: colors.muted }, ` ${separator} ${agent}`));
}
return h(Box, {
flexDirection: 'row',
height: 1,
paddingX: 1
}, ...parts);
};
/**
* Input Prompt - The actual text input with prompt icon
*/
const InputPrompt = ({
value,
onChange,
onSubmit,
placeholder = 'Type a message...',
isDisabled = false,
width = 80
}) => {
const caps = getCapabilities();
const promptIcon = caps.unicodeOK ? '' : '>';
return h(Box, {
flexDirection: 'row',
paddingX: 1,
height: 1
},
h(Text, { color: isDisabled ? colors.muted : colors.accent }, `${promptIcon} `),
isDisabled
? h(Text, { color: colors.muted, dimColor: true }, 'waiting for response...')
: h(TextInput, {
value,
onChange,
onSubmit,
placeholder,
focus: true
})
);
};
/**
* Action Hint - Shows keyboard shortcuts when relevant
*/
const ActionHint = ({ hints = [] }) => {
if (hints.length === 0) return null;
return h(Box, {
flexDirection: 'row',
height: 1,
paddingX: 1,
justifyContent: 'flex-end'
},
hints.map((hint, i) =>
h(Text, { key: i, color: colors.muted, dimColor: true },
i > 0 ? ' | ' : '',
hint
)
)
);
};
/**
* Premium Input Bar - Fixed height, stable layout
*
* Structure:
* Row 1: Status strip (model, agent, streaming indicator)
* Row 2: Input prompt with text input
* Row 3: Action hints (context-sensitive)
*
* Total: 3 rows ALWAYS
*/
const PremiumInputBar = ({
// Input state
value = '',
onChange,
onSubmit,
placeholder = 'Type a message...',
// Status
isStreaming = false,
isApprovalMode = false,
isDiffMode = false,
// Context
model = null,
agent = null,
cwd = null,
tokensPerSec = 0,
// Layout
width = 80
}) => {
// Build context-sensitive hints
const hints = [];
if (isStreaming) {
hints.push('type to interrupt');
} else if (isApprovalMode) {
hints.push('y: approve', 'n: reject');
} else if (isDiffMode) {
hints.push('a: apply', 's: skip', 'q: quit');
} else {
hints.push('/ for commands', 'Ctrl+P palette');
}
// Border character
const caps = getCapabilities();
const borderChar = caps.unicodeOK ? '─' : '-';
return h(Box, {
flexDirection: 'column',
width: width,
height: layout.inputBar.height, // FIXED HEIGHT
borderStyle: undefined, // No nested borders
flexShrink: 0
},
// Top border line
h(Text, { color: colors.border, dimColor: true },
borderChar.repeat(Math.min(width, 200))
),
// Status strip
h(StatusStrip, {
isStreaming,
model,
agent,
cwd,
tokensPerSec
}),
// Input prompt
h(InputPrompt, {
value,
onChange,
onSubmit,
placeholder,
isDisabled: isStreaming,
width
}),
// Action hints (only show when space available)
width > 60 ? h(ActionHint, { hints }) : null
);
};
export default PremiumInputBar;
export { PremiumInputBar, StatusStrip, InputPrompt, ActionHint };

View File

@@ -0,0 +1,294 @@
/**
* Premium Message Component
*
* DESIGN PRINCIPLES:
* 1. Single rail-based layout for ALL roles (user, assistant, system, tool, error)
* 2. NO message borders - uses left rail + role label line + body
* 3. Max readable line width (clamped)
* 4. Width-aware wrapping
* 5. ASCII-safe icons
*/
import React from 'react';
import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
import { colors, layout } from '../../tui-theme.mjs';
import { icon, roleIcon } from '../../icons.mjs';
import { getCapabilities } from '../../terminal-profile.mjs';
const h = React.createElement;
/**
* Rail character for message left border
*/
const getRailChar = (isStreaming = false) => {
const caps = getCapabilities();
if (!caps.unicodeOK) return '|';
return isStreaming ? '┃' : '│';
};
/**
* Get rail color by role
*/
const getRailColor = (role) => {
const roleColors = {
user: colors.rail.user,
assistant: colors.rail.assistant,
system: colors.rail.system,
tool: colors.rail.tool,
error: colors.rail.error,
thinking: colors.rail.thinking
};
return roleColors[role] || colors.muted;
};
/**
* Role label (first line of message)
*/
const RoleLabel = ({ role, isStreaming = false, timestamp = null }) => {
const caps = getCapabilities();
const labels = {
user: 'You',
assistant: 'Assistant',
system: 'System',
tool: 'Tool',
error: 'Error',
thinking: 'Thinking'
};
const label = labels[role] || role;
const labelIcon = roleIcon(role);
const color = getRailColor(role);
return h(Box, { flexDirection: 'row' },
h(Text, { color, bold: true }, `${labelIcon} ${label}`),
isStreaming ? h(Box, { marginLeft: 1 },
h(Spinner, { type: 'dots' }),
h(Text, { color: colors.muted }, ' generating...')
) : null,
timestamp ? h(Text, { color: colors.muted, dimColor: true, marginLeft: 1 }, timestamp) : null
);
};
/**
* Message body with proper wrapping
*/
const MessageBody = ({ content, width, color = colors.fg }) => {
// Clamp width for readability
const maxWidth = Math.min(width, layout.transcript.maxLineWidth);
return h(Box, { flexDirection: 'column', width: maxWidth },
h(Text, { color, wrap: 'wrap' }, content)
);
};
/**
* Status Chip for short system/tool messages
* Single-line, minimal interruption
*/
const StatusChip = ({ message, type = 'info', showSpinner = false }) => {
const chipColors = {
info: colors.accent,
success: colors.success,
warning: colors.warning,
error: colors.error
};
const chipColor = chipColors[type] || colors.muted;
return h(Box, { flexDirection: 'row', marginY: 0 },
h(Text, { color: colors.muted }, getRailChar()),
h(Text, {}, ' '),
showSpinner ? h(Spinner, { type: 'dots' }) : null,
showSpinner ? h(Text, {}, ' ') : null,
h(Text, { color: chipColor }, message)
);
};
/**
* Premium Message - Unified rail-based layout
*/
const PremiumMessage = ({
role = 'assistant',
content = '',
isStreaming = false,
timestamp = null,
width = 80,
// For tool messages
toolName = null,
isCollapsed = false,
onToggle = null,
// For status chips (short messages)
isChip = false,
chipType = 'info'
}) => {
// Short status messages use chip style
if (isChip || (role === 'system' && content.length < 60)) {
return h(StatusChip, {
message: content,
type: chipType,
showSpinner: isStreaming
});
}
const railColor = getRailColor(role);
const railChar = getRailChar(isStreaming);
// Calculate body width (minus rail + spacing)
const bodyWidth = Math.max(20, width - 4);
return h(Box, {
flexDirection: 'row',
marginY: role === 'user' ? 1 : 0
},
// Left Rail
h(Box, { width: 2, flexShrink: 0 },
h(Text, { color: railColor }, railChar)
),
// Content area
h(Box, { flexDirection: 'column', flexGrow: 1, width: bodyWidth },
// Role label line
h(RoleLabel, {
role,
isStreaming,
timestamp
}),
// Tool name (if applicable)
toolName ? h(Text, { color: colors.muted, dimColor: true },
`${icon('tool')} ${toolName}`
) : null,
// Message body
h(MessageBody, {
content,
width: bodyWidth,
color: role === 'error' ? colors.error : colors.fg
})
)
);
};
/**
* Thinking Block - Collapsible intent trace
*/
const ThinkingBlock = ({
lines = [],
isThinking = false,
showFull = false,
width = 80
}) => {
const visibleLines = showFull ? lines : lines.slice(-3);
const hiddenCount = Math.max(0, lines.length - visibleLines.length);
if (lines.length === 0 && !isThinking) return null;
const railChar = getRailChar(isThinking);
const railColor = getRailColor('thinking');
return h(Box, {
flexDirection: 'row',
marginY: 0
},
// Left Rail
h(Box, { width: 2, flexShrink: 0 },
h(Text, { color: railColor, dimColor: true }, railChar)
),
// Content
h(Box, { flexDirection: 'column' },
// Header
h(Box, { flexDirection: 'row' },
isThinking ? h(Spinner, { type: 'dots' }) : null,
isThinking ? h(Text, {}, ' ') : null,
h(Text, { color: colors.muted, dimColor: true },
isThinking ? 'thinking...' : 'thought'
)
),
// Visible lines
...visibleLines.map((line, i) =>
h(Text, {
key: i,
color: colors.muted,
dimColor: true,
wrap: 'truncate'
}, ` ${line.slice(0, width - 6)}`)
),
// Hidden count
hiddenCount > 0 ? h(Text, {
color: colors.muted,
dimColor: true
}, ` +${hiddenCount} more`) : null
)
);
};
/**
* Tool Call Card - Collapsed by default
*/
const ToolCard = ({
name,
status = 'running', // running, done, failed
output = '',
isExpanded = false,
onToggle = null,
width = 80
}) => {
const statusColors = {
running: colors.accent,
done: colors.success,
failed: colors.error
};
const statusIcons = {
running: icon('running'),
done: icon('done'),
failed: icon('failed')
};
const railColor = colors.rail.tool;
const railChar = getRailChar(status === 'running');
return h(Box, {
flexDirection: 'row',
marginY: 0
},
// Left Rail
h(Box, { width: 2, flexShrink: 0 },
h(Text, { color: railColor }, railChar)
),
// Content
h(Box, { flexDirection: 'column' },
// Header line
h(Box, { flexDirection: 'row' },
status === 'running' ? h(Spinner, { type: 'dots' }) : null,
status === 'running' ? h(Text, {}, ' ') : null,
h(Text, { color: statusColors[status] },
`${statusIcons[status]} ${name}`
),
!isExpanded && output ? h(Text, { color: colors.muted, dimColor: true },
` [${icon('expand')} expand]`
) : null
),
// Expanded output
isExpanded && output ? h(Box, { marginTop: 0, paddingLeft: 2 },
h(Text, { color: colors.muted, wrap: 'wrap' }, output)
) : null
)
);
};
export default PremiumMessage;
export {
PremiumMessage,
StatusChip,
ThinkingBlock,
ToolCard,
RoleLabel,
MessageBody,
getRailColor,
getRailChar
};

View File

@@ -0,0 +1,263 @@
/**
* Premium Sidebar Component
*
* DESIGN PRINCIPLES:
* 1. NO nested borders (one-frame rule)
* 2. Three clean sections: Project, Session, Shortcuts
* 3. Headers + subtle dividers (not boxed widgets)
* 4. Consistent typography and alignment
* 5. ASCII-safe icons
*/
import React from 'react';
import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
import path from 'path';
import { theme, colors, layout } from '../../tui-theme.mjs';
import { icon, roleIcon } from '../../icons.mjs';
import { getCapabilities, PROFILE } from '../../terminal-profile.mjs';
import FileTree from './FileTree.mjs';
const h = React.createElement;
/**
* Section Header (no border, just styled text)
*/
const SectionHeader = ({ title, color = colors.muted }) => {
return h(Text, { color, bold: true }, title);
};
/**
* Divider line (subtle, no heavy borders)
*/
const Divider = ({ width, color = colors.border }) => {
const caps = getCapabilities();
const char = caps.unicodeOK ? '─' : '-';
return h(Text, { color, dimColor: true }, char.repeat(Math.max(1, width)));
};
/**
* Label-Value row (consistent alignment)
*/
const LabelValue = ({ label, value, valueColor = colors.fg }) => {
return h(Box, { flexDirection: 'row' },
h(Text, { color: colors.muted }, `${label}: `),
h(Text, { color: valueColor }, value)
);
};
/**
* Toggle indicator (ON/OFF)
*/
const Toggle = ({ label, value, onColor = 'green' }) => {
return h(Box, { flexDirection: 'row' },
h(Text, { color: colors.muted }, `${label} `),
value
? h(Text, { color: onColor }, 'ON')
: h(Text, { color: colors.muted, dimColor: true }, 'off')
);
};
/**
* Status chip (single line, minimal)
*/
const StatusChip = ({ message, type = 'info', showSpinner = false }) => {
const chipColor = type === 'error' ? colors.error
: type === 'success' ? colors.success
: type === 'warning' ? colors.warning
: colors.accent;
return h(Box, { flexDirection: 'row' },
showSpinner ? h(Text, { color: 'gray', dimColor: true }, '...') : null,
showSpinner ? h(Text, {}, ' ') : null,
h(Text, { color: chipColor, wrap: 'truncate' }, message)
);
};
/**
* Premium Sidebar - Clean 3-section layout
*/
const PremiumSidebar = ({
// Project info
project,
gitBranch,
// Session info
agent,
activeModel,
// Feature toggles
contextEnabled = false,
multiAgentEnabled = false,
exposedThinking = false,
soloMode = false,
autoApprove = false,
// Status indicators
systemStatus = null,
iqStatus = null,
thinkingStats = null,
indexStatus = null,
// Layout
width = 24,
height = 0,
// Explorer
showFileManager = false,
explorerRoot = null,
selectedFiles = new Set(),
onToggleFile = null,
onOpenFile = null,
recentFiles = [],
hotFiles = [],
// Interaction
isFocused = false,
showHint = false,
reduceMotion = true
}) => {
if (width === 0) return null;
const caps = getCapabilities();
const contentWidth = Math.max(10, width - 2);
// Truncate helper
const truncate = (str, len) => {
if (!str) return '';
return str.length > len ? str.slice(0, len - 1) + '…' : str;
};
// Derived values
const projectName = project ? truncate(path.basename(project), contentWidth) : 'None';
const branchName = truncate(gitBranch || 'main', contentWidth);
const agentName = (agent || 'build').toUpperCase();
const modelName = activeModel?.name || 'Not connected';
// Streaming stats
const isStreaming = thinkingStats?.chars > 0;
const explorerHeight = Math.max(8, Math.min(22, (height || 0) - 24));
return h(Box, {
flexDirection: 'column',
width: width,
paddingX: 1,
flexShrink: 0
},
// ═══════════════════════════════════════════════════════════
// BRANDING - Minimal, not animated
// ═══════════════════════════════════════════════════════════
h(Text, { color: colors.accent, bold: true }, 'OpenQode'),
h(Text, { color: colors.muted }, `${agentName} ${icon('branch')} ${branchName}`),
h(Box, { marginTop: 1 }),
// ═══════════════════════════════════════════════════════════
// SECTION 1: PROJECT
// ═══════════════════════════════════════════════════════════
h(SectionHeader, { title: 'PROJECT' }),
h(Divider, { width: contentWidth }),
h(LabelValue, { label: icon('folder'), value: projectName }),
h(LabelValue, { label: icon('branch'), value: branchName }),
// System status (if any)
systemStatus ? h(StatusChip, {
message: systemStatus.message,
type: systemStatus.type
}) : null,
// Index status (if any)
indexStatus ? h(StatusChip, {
message: indexStatus.message,
type: indexStatus.type
}) : null,
// IQ Exchange status (if active)
iqStatus ? h(StatusChip, {
message: iqStatus.message || 'Processing...',
type: 'info',
showSpinner: true
}) : null,
h(Box, { marginTop: 1 }),
// ═══════════════════════════════════════════════════════════
// SECTION 2: SESSION
// ═══════════════════════════════════════════════════════════
h(SectionHeader, { title: 'SESSION' }),
h(Divider, { width: contentWidth }),
h(LabelValue, {
label: icon('model'),
value: truncate(modelName, contentWidth - 3),
valueColor: activeModel?.isFree ? colors.success : colors.accent
}),
// Streaming indicator (only when active)
isStreaming ? h(Box, { flexDirection: 'row' },
reduceMotion ? h(Text, { color: colors.muted, dimColor: true }, '...') : h(Spinner, { type: 'dots' }),
h(Text, { color: colors.muted }, ` ${thinkingStats.chars} chars`)
) : null,
// Feature toggles (compact row)
h(Box, { marginTop: 1, flexDirection: 'column' },
h(Toggle, { label: 'Ctx', value: contextEnabled }),
h(Toggle, { label: 'Multi', value: multiAgentEnabled }),
h(Toggle, { label: 'Think', value: exposedThinking }),
soloMode ? h(Toggle, { label: 'SmartX', value: soloMode, onColor: 'magenta' }) : null,
autoApprove ? h(Toggle, { label: 'Auto', value: autoApprove, onColor: 'yellow' }) : null
),
h(Box, { marginTop: 1 }),
// ═══════════════════════════════════════════════════════════
// SECTION 3: SHORTCUTS
// ═══════════════════════════════════════════════════════════
h(SectionHeader, { title: 'SHORTCUTS' }),
h(Divider, { width: contentWidth }),
h(Text, { color: colors.accent }, '/help'),
h(Text, { color: colors.muted }, '/settings'),
h(Text, { color: colors.muted }, '/theme'),
h(Text, { color: colors.muted, dimColor: true }, 'Ctrl+P commands'),
h(Text, { color: colors.muted, dimColor: true }, 'Ctrl+E explorer'),
h(Text, { color: colors.muted, dimColor: true }, 'Ctrl+R recent'),
h(Text, { color: colors.muted, dimColor: true }, 'Ctrl+H hot'),
// Focus hint
showHint ? h(Box, { marginTop: 1 },
h(Text, { color: colors.muted, dimColor: true }, '[Tab] browse files')
) : null
,
// SECTION 4: EXPLORER (IDE-style file tree)
explorerRoot ? h(Box, { marginTop: 1, flexDirection: 'column' },
h(SectionHeader, { title: 'EXPLORER' }),
h(Divider, { width: contentWidth }),
!showFileManager ? h(Text, { color: colors.muted, dimColor: true, wrap: 'truncate' }, 'Hidden (Ctrl+E or /explorer on)') : null,
showFileManager && recentFiles && recentFiles.length > 0 ? h(Box, { flexDirection: 'column', marginBottom: 1 },
h(Text, { color: colors.muted, dimColor: true }, 'Recent:'),
recentFiles.slice(0, 3).map((f) => h(Text, { key: `recent:${f}`, color: colors.muted, wrap: 'truncate' }, ` ${f}`))
) : null,
showFileManager && hotFiles && hotFiles.length > 0 ? h(Box, { flexDirection: 'column', marginBottom: 1 },
h(Text, { color: colors.muted, dimColor: true }, 'Hot:'),
hotFiles.slice(0, 3).map((f) => h(Text, { key: `hot:${f}`, color: colors.muted, wrap: 'truncate' }, ` ${f}`))
) : null,
showFileManager && h(FileTree, {
rootPath: explorerRoot,
selectedFiles,
onSelect: onToggleFile,
onOpen: onOpenFile,
isActive: Boolean(isFocused),
height: explorerHeight,
width: contentWidth
}),
h(Text, { color: colors.muted, dimColor: true }, '↑↓ navigate • Enter open • Space select')
) : null
);
};
export default PremiumSidebar;
export { PremiumSidebar, SectionHeader, Divider, LabelValue, Toggle, StatusChip };

View File

@@ -0,0 +1,187 @@
/**
* Preview Plan Component - Noob-proof action preview
*
* CORE NOOB-PROOF FEATURE:
* Before running actions, show a numbered list with:
* - Risk labels (Safe / Needs approval / Manual)
* - Edit options per step
* - Default actions: Run / Step-by-step / Edit / Cancel
*
* Credit: OpenCode patterns + Windows-Use verification
*/
import React, { useState } from 'react';
import { Box, Text } from 'ink';
import { colors } from '../../tui-theme.mjs';
import { getCapabilities } from '../../terminal-profile.mjs';
const h = React.createElement;
// Risk levels
export const RISK_LEVELS = {
SAFE: 'safe',
NEEDS_APPROVAL: 'needs_approval',
MANUAL: 'manual'
};
const RISK_CONFIG = {
[RISK_LEVELS.SAFE]: {
label: 'Safe',
color: 'green',
icon: '✓',
iconAscii: '+'
},
[RISK_LEVELS.NEEDS_APPROVAL]: {
label: 'Approval',
color: 'yellow',
icon: '⚠',
iconAscii: '!'
},
[RISK_LEVELS.MANUAL]: {
label: 'Manual',
color: 'magenta',
icon: '👤',
iconAscii: '*'
}
};
/**
* Single step in preview
*/
const PreviewStep = ({
index,
description,
risk = RISK_LEVELS.SAFE,
isSelected = false,
width = 60
}) => {
const caps = getCapabilities();
const riskConfig = RISK_CONFIG[risk];
const riskIcon = caps.unicodeOK ? riskConfig.icon : riskConfig.iconAscii;
// Truncate description
const maxDescWidth = width - 15;
const desc = description.length > maxDescWidth
? description.slice(0, maxDescWidth - 1) + '…'
: description;
return h(Box, { flexDirection: 'row' },
// Selection indicator
h(Text, { color: isSelected ? colors.accent : colors.muted },
isSelected ? '▸ ' : ' '
),
// Step number
h(Text, { color: colors.muted }, `${index + 1}) `),
// Description
h(Text, { color: colors.fg }, desc),
// Risk label
h(Text, { color: riskConfig.color, dimColor: risk === RISK_LEVELS.SAFE },
` [${riskIcon} ${riskConfig.label}]`
)
);
};
/**
* Action buttons at bottom
*/
const PreviewActions = ({ onRun, onStepByStep, onEdit, onCancel }) => {
const caps = getCapabilities();
const separator = caps.unicodeOK ? '│' : '|';
return h(Box, { flexDirection: 'row', marginTop: 1, gap: 1 },
h(Text, { color: colors.success, bold: true }, '[Enter] Run'),
h(Text, { color: colors.muted }, separator),
h(Text, { color: colors.accent }, '[s] Step-by-step'),
h(Text, { color: colors.muted }, separator),
h(Text, { color: 'yellow' }, '[e] Edit'),
h(Text, { color: colors.muted }, separator),
h(Text, { color: colors.error }, '[Esc] Cancel')
);
};
/**
* Preview Plan Component
*
* Props:
* - steps: array of { description, risk, target }
* - title: optional title
* - selectedIndex: currently selected step (for editing)
* - onRun: callback when user confirms run
* - onStepByStep: callback for step-by-step mode
* - onEdit: callback for edit mode
* - onCancel: callback for cancel
* - width: available width
*/
const PreviewPlan = ({
steps = [],
title = 'Preview Plan',
selectedIndex = -1,
onRun = null,
onStepByStep = null,
onEdit = null,
onCancel = null,
width = 80
}) => {
const caps = getCapabilities();
// Border characters
const borderH = caps.unicodeOK ? '─' : '-';
const cornerTL = caps.unicodeOK ? '┌' : '+';
const cornerTR = caps.unicodeOK ? '┐' : '+';
const cornerBL = caps.unicodeOK ? '└' : '+';
const cornerBR = caps.unicodeOK ? '┘' : '+';
const contentWidth = width - 4;
// Count risks
const needsApproval = steps.filter(s => s.risk === RISK_LEVELS.NEEDS_APPROVAL).length;
const manualSteps = steps.filter(s => s.risk === RISK_LEVELS.MANUAL).length;
return h(Box, { flexDirection: 'column', marginY: 1 },
// Header
h(Text, { color: colors.accent },
cornerTL + borderH + ` ${title} (${steps.length} steps) ` +
borderH.repeat(Math.max(0, contentWidth - title.length - 12)) + cornerTR
),
// Steps list
h(Box, { flexDirection: 'column', paddingX: 1 },
...steps.map((step, i) =>
h(PreviewStep, {
key: i,
index: i,
description: step.description,
risk: step.risk || RISK_LEVELS.SAFE,
isSelected: i === selectedIndex,
width: contentWidth
})
)
),
// Risk summary (if any)
(needsApproval > 0 || manualSteps > 0) ? h(Box, { paddingX: 1, marginTop: 0 },
needsApproval > 0 ? h(Text, { color: 'yellow', dimColor: true },
`${needsApproval} step(s) need approval `
) : null,
manualSteps > 0 ? h(Text, { color: 'magenta', dimColor: true },
`${manualSteps} manual step(s)`
) : null
) : null,
// Action buttons
h(Box, { paddingX: 1 },
h(PreviewActions, { onRun, onStepByStep, onEdit, onCancel })
),
// Bottom border
h(Text, { color: colors.accent },
cornerBL + borderH.repeat(contentWidth) + cornerBR
)
);
};
export default PreviewPlan;
export { PreviewPlan, PreviewStep };

View File

@@ -0,0 +1,149 @@
/**
* RunStrip Component
*
* SINGLE STATE SURFACE: One place for all run state
* - thinking / streaming / waiting / failed / idle
*
* DESIGN:
* - Compact single-line strip at top of main panel
* - Shows: state • agent • model • cwd
* - Never reflows, fixed height (1 row)
*/
import React from 'react';
import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
import { colors } from '../../tui-theme.mjs';
import { icon, statusIcon } from '../../icons.mjs';
import { getCapabilities } from '../../terminal-profile.mjs';
const h = React.createElement;
// Run states
const RUN_STATES = {
IDLE: 'idle',
THINKING: 'thinking',
STREAMING: 'streaming',
WAITING: 'waiting',
TOOL: 'tool',
FAILED: 'failed',
SUCCESS: 'success'
};
/**
* Get state display info
*/
const getStateDisplay = (state, message) => {
const caps = getCapabilities();
const displays = {
[RUN_STATES.IDLE]: {
icon: caps.unicodeOK ? '●' : '*',
color: colors.success,
text: 'Ready'
},
[RUN_STATES.THINKING]: {
icon: null, // spinner
color: 'yellow',
text: message || 'Thinking...',
showSpinner: true
},
[RUN_STATES.STREAMING]: {
icon: null,
color: colors.accent,
text: message || 'Generating...',
showSpinner: true
},
[RUN_STATES.WAITING]: {
icon: caps.unicodeOK ? '◐' : '~',
color: 'yellow',
text: message || 'Waiting...'
},
[RUN_STATES.TOOL]: {
icon: null,
color: 'magenta',
text: message || 'Running tool...',
showSpinner: true
},
[RUN_STATES.FAILED]: {
icon: caps.unicodeOK ? '✗' : 'X',
color: colors.error,
text: message || 'Failed'
},
[RUN_STATES.SUCCESS]: {
icon: caps.unicodeOK ? '✓' : '+',
color: colors.success,
text: message || 'Done'
}
};
return displays[state] || displays[RUN_STATES.IDLE];
};
/**
* RunStrip - Compact run state indicator
*
* Props:
* - state: one of RUN_STATES
* - message: optional status message
* - agent: current agent name
* - model: current model name
* - tokensPerSec: streaming speed
* - width: strip width
*/
const RunStrip = ({
state = RUN_STATES.IDLE,
message = null,
agent = null,
model = null,
tokensPerSec = 0,
width = 80
}) => {
const caps = getCapabilities();
const display = getStateDisplay(state, message);
const separator = caps.unicodeOK ? '│' : '|';
// Build parts
const parts = [];
// State indicator
if (display.showSpinner) {
parts.push(h(Spinner, { key: 'spin', type: 'dots' }));
parts.push(h(Text, { key: 'space1' }, ' '));
} else if (display.icon) {
parts.push(h(Text, { key: 'icon', color: display.color }, display.icon + ' '));
}
// State text
parts.push(h(Text, { key: 'state', color: display.color }, display.text));
// Tokens per second (only when streaming)
if (state === RUN_STATES.STREAMING && tokensPerSec > 0) {
parts.push(h(Text, { key: 'tps', color: colors.muted }, ` ${tokensPerSec} tok/s`));
}
// Separator + Agent
if (agent) {
parts.push(h(Text, { key: 'sep1', color: colors.muted }, ` ${separator} `));
parts.push(h(Text, { key: 'agent', color: colors.muted }, agent.toUpperCase()));
}
// Separator + Model
if (model) {
parts.push(h(Text, { key: 'sep2', color: colors.muted }, ` ${separator} `));
parts.push(h(Text, { key: 'model', color: colors.muted, dimColor: true },
model.length > 20 ? model.slice(0, 18) + '…' : model
));
}
return h(Box, {
flexDirection: 'row',
width: width,
height: 1,
flexShrink: 0,
paddingX: 1
}, ...parts);
};
export default RunStrip;
export { RunStrip, RUN_STATES, getStateDisplay };

View File

@@ -0,0 +1,105 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Box, Text, useInput } from 'ink';
import TextInput from 'ink-text-input';
import SelectInput from 'ink-select-input';
const h = React.createElement;
const SearchOverlay = ({
isOpen = false,
initialQuery = '',
results = [],
isSearching = false,
error = null,
onClose,
onSearch,
onOpenResult,
width = 80,
height = 24
}) => {
const [query, setQuery] = useState(initialQuery || '');
const [mode, setMode] = useState('query'); // 'query' | 'results'
useEffect(() => {
if (!isOpen) return;
setQuery(initialQuery || '');
setMode('query');
}, [isOpen, initialQuery]);
useInput((input, key) => {
if (!isOpen) return;
if (key.escape) {
if (typeof onClose === 'function') onClose();
}
if (key.tab) {
setMode(m => (m === 'query' ? 'results' : 'query'));
}
if (key.ctrl && input.toLowerCase() === 'c') {
if (typeof onClose === 'function') onClose();
}
}, { isActive: isOpen });
const items = useMemo(() => {
const max = Math.max(0, height - 8);
return results.slice(0, Math.min(200, max)).map((r, idx) => ({
label: `${r.rel}:${r.line}${r.text ? ` ${r.text}` : ''}`.slice(0, Math.max(10, width - 6)),
value: idx
}));
}, [results, width, height]);
if (!isOpen) return null;
return h(Box, {
flexDirection: 'column',
width,
height,
borderStyle: 'double',
borderColor: 'magenta',
paddingX: 1,
paddingY: 0
},
h(Box, { justifyContent: 'space-between' },
h(Text, { color: 'magenta', bold: true }, 'Search (ripgrep)'),
h(Text, { color: 'gray', dimColor: true }, 'Esc close · Enter search/open · Tab switch')
),
h(Box, { marginTop: 1, flexDirection: 'row' },
h(Text, { color: 'yellow' }, 'Query: '),
h(Box, { flexGrow: 1 },
h(TextInput, {
value: query,
focus: mode === 'query',
onChange: setQuery,
onSubmit: async () => {
if (typeof onSearch === 'function') {
setMode('results');
await onSearch(query);
}
},
placeholder: 'e.g. function handleSubmit'
})
)
),
h(Box, { marginTop: 1 },
isSearching ? h(Text, { color: 'yellow' }, 'Searching...') : null,
error ? h(Text, { color: 'red' }, error) : null,
(!isSearching && !error) ? h(Text, { color: 'gray', dimColor: true }, `${results.length} result(s)`) : null
),
h(Box, { flexDirection: 'column', flexGrow: 1, marginTop: 1 },
items.length > 0
? h(SelectInput, {
items,
isFocused: mode === 'results',
onSelect: (item) => {
const r = results[item.value];
if (r && typeof onOpenResult === 'function') onOpenResult(r);
},
itemComponent: ({ isSelected, label }) =>
h(Text, { color: isSelected ? 'cyan' : 'white', bold: isSelected, wrap: 'truncate-end' }, label)
})
: h(Text, { color: 'gray', dimColor: true }, 'No results yet. Type a query and press Enter.')
)
);
};
export default SearchOverlay;

View File

@@ -0,0 +1,117 @@
/**
* Server Inspector - Ops Console
*
* Shows: host, cwd, env, command queue, log tail
*
* Credit: Based on ops console patterns
*/
import React, { useState } from 'react';
import { Box, Text } from 'ink';
import { colors } from '../../tui-theme.mjs';
import { getCapabilities } from '../../terminal-profile.mjs';
const h = React.createElement;
/**
* Server Inspector Component
*/
const ServerInspector = ({
host = null,
user = null,
cwd = null,
env = {},
commandQueue = [],
logTail = [],
lastExitCode = null,
healthStatus = null,
isExpanded = false,
width = 40
}) => {
const caps = getCapabilities();
const [expanded, setExpanded] = useState(isExpanded);
const checkmark = caps.unicodeOK ? '✓' : '+';
const crossmark = caps.unicodeOK ? '✗' : 'X';
// Collapsed view
if (!expanded) {
const statusIcon = healthStatus === 'healthy' ? checkmark :
healthStatus === 'unhealthy' ? crossmark : '?';
const statusColor = healthStatus === 'healthy' ? colors.success :
healthStatus === 'unhealthy' ? colors.error : colors.muted;
return h(Box, { flexDirection: 'row' },
h(Text, { color: colors.muted, bold: true }, '🖥️ Server: '),
h(Text, { color: colors.fg }, `${user || 'user'}@${host || 'localhost'}`),
h(Text, { color: statusColor }, ` ${statusIcon}`)
);
}
// Expanded view
return h(Box, { flexDirection: 'column', width },
// Header
h(Text, { color: colors.accent, bold: true }, '🖥️ Server Inspector'),
// Connection
h(Box, { flexDirection: 'row', paddingLeft: 1 },
h(Text, { color: colors.muted }, 'Host: '),
h(Text, { color: colors.fg }, `${user || 'user'}@${host || 'localhost'}`)
),
// CWD
cwd ? h(Box, { flexDirection: 'row', paddingLeft: 1 },
h(Text, { color: colors.muted }, 'CWD: '),
h(Text, { color: colors.muted, dimColor: true }, cwd)
) : null,
// Environment (if any interesting vars)
Object.keys(env).length > 0 ? h(Box, { flexDirection: 'column', paddingLeft: 1 },
h(Text, { color: colors.muted }, 'Env:'),
...Object.entries(env).slice(0, 3).map(([k, v], i) =>
h(Text, { key: i, color: colors.muted, dimColor: true },
` ${k}=${String(v).slice(0, 20)}`
)
)
) : null,
// Command queue
commandQueue.length > 0 ? h(Box, { flexDirection: 'column', paddingLeft: 1 },
h(Text, { color: colors.muted }, `Queue (${commandQueue.length}):`),
...commandQueue.slice(0, 3).map((cmd, i) =>
h(Text, { key: i, color: colors.muted, dimColor: true },
` ${i + 1}. ${cmd.slice(0, width - 8)}`
)
)
) : null,
// Log tail (last 5 lines)
logTail.length > 0 ? h(Box, { flexDirection: 'column', paddingLeft: 1 },
h(Text, { color: colors.muted }, 'Logs:'),
...logTail.slice(-5).map((line, i) =>
h(Text, { key: i, color: colors.muted, dimColor: true },
` ${line.slice(0, width - 4)}`
)
)
) : null,
// Last exit code
lastExitCode !== null ? h(Box, { flexDirection: 'row', paddingLeft: 1 },
h(Text, { color: lastExitCode === 0 ? colors.success : colors.error },
lastExitCode === 0 ? checkmark : crossmark
),
h(Text, { color: colors.muted }, ` Exit: ${lastExitCode}`)
) : null,
// Health status
healthStatus ? h(Box, { flexDirection: 'row', paddingLeft: 1 },
h(Text, { color: colors.muted }, 'Health: '),
h(Text, { color: healthStatus === 'healthy' ? colors.success : colors.error },
healthStatus
)
) : null
);
};
export default ServerInspector;
export { ServerInspector };

141
bin/ui/components/Toast.mjs Normal file
View File

@@ -0,0 +1,141 @@
/**
* Toast Component - Minimal confirmations
*
* DESIGN:
* - Copy/applied/saved/reverted appear as brief toasts
* - Don't add to transcript (displayed separately)
* - Auto-dismiss after timeout
*/
import React, { useState, useEffect } from 'react';
import { Box, Text } from 'ink';
import { colors } from '../../tui-theme.mjs';
import { icon } from '../../icons.mjs';
import { getCapabilities } from '../../terminal-profile.mjs';
const h = React.createElement;
/**
* Toast - Single toast notification
*/
const Toast = ({
message,
type = 'info', // info, success, warning, error
duration = 3000,
onDismiss = null
}) => {
const caps = getCapabilities();
const [visible, setVisible] = useState(true);
useEffect(() => {
const timer = setTimeout(() => {
setVisible(false);
onDismiss?.();
}, duration);
return () => clearTimeout(timer);
}, [duration, onDismiss]);
if (!visible) return null;
const typeConfig = {
info: { color: colors.accent, icon: caps.unicodeOK ? '' : 'i' },
success: { color: colors.success, icon: caps.unicodeOK ? '✓' : '+' },
warning: { color: colors.warning, icon: caps.unicodeOK ? '⚠' : '!' },
error: { color: colors.error, icon: caps.unicodeOK ? '✗' : 'X' }
};
const config = typeConfig[type] || typeConfig.info;
return h(Box, {
flexDirection: 'row',
justifyContent: 'flex-end',
paddingX: 1
},
h(Text, { color: config.color }, config.icon + ' '),
h(Text, { color: config.color }, message)
);
};
/**
* ToastContainer - Manages multiple toasts
*/
const ToastContainer = ({ toasts = [], onDismiss }) => {
if (toasts.length === 0) return null;
return h(Box, {
flexDirection: 'column',
position: 'absolute',
right: 0,
top: 0
},
...toasts.map((toast, i) =>
h(Toast, {
key: toast.id || i,
message: toast.message,
type: toast.type,
duration: toast.duration,
onDismiss: () => onDismiss?.(toast.id || i)
})
)
);
};
/**
* useToasts - Hook for managing toasts
*/
const createToastManager = () => {
let toasts = [];
let listeners = [];
let nextId = 0;
const subscribe = (listener) => {
listeners.push(listener);
return () => {
listeners = listeners.filter(l => l !== listener);
};
};
const notify = () => {
listeners.forEach(l => l(toasts));
};
const add = (message, type = 'info', duration = 3000) => {
const id = nextId++;
toasts = [...toasts, { id, message, type, duration }];
notify();
setTimeout(() => {
toasts = toasts.filter(t => t.id !== id);
notify();
}, duration);
return id;
};
const dismiss = (id) => {
toasts = toasts.filter(t => t.id !== id);
notify();
};
return { subscribe, add, dismiss, get: () => toasts };
};
// Global toast manager (singleton)
const toastManager = createToastManager();
// Convenience methods
const showToast = (message, type, duration) => toastManager.add(message, type, duration);
const showSuccess = (message) => showToast(message, 'success', 2000);
const showError = (message) => showToast(message, 'error', 4000);
const showInfo = (message) => showToast(message, 'info', 3000);
export default Toast;
export {
Toast,
ToastContainer,
toastManager,
showToast,
showSuccess,
showError,
showInfo
};

View File

@@ -0,0 +1,269 @@
/**
* Tool Registry - Renders tool-specific UI
*
* Based on sst/opencode ToolRegistry pattern
* Credit: https://github.com/sst/opencode
*
* Each tool has a dedicated renderer for consistent output
*/
import React from 'react';
import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
import { colors } from '../../tui-theme.mjs';
import { icon } from '../../icons.mjs';
import { getCapabilities } from '../../terminal-profile.mjs';
const h = React.createElement;
// Registry of tool renderers
const toolRenderers = new Map();
/**
* Register a tool renderer
* @param {string} toolName - Tool identifier
* @param {object} renderer - { icon, title, renderSummary, renderDetails }
*/
export function registerTool(toolName, renderer) {
toolRenderers.set(toolName, {
icon: renderer.icon || '⚙',
iconAscii: renderer.iconAscii || '*',
title: renderer.title || toolName,
color: renderer.color || colors.accent,
renderSummary: renderer.renderSummary || defaultRenderSummary,
renderDetails: renderer.renderDetails || defaultRenderDetails
});
}
/**
* Get renderer for a tool
*/
export function getToolRenderer(toolName) {
return toolRenderers.get(toolName) || {
icon: '⚙',
iconAscii: '*',
title: toolName,
color: colors.muted,
renderSummary: defaultRenderSummary,
renderDetails: defaultRenderDetails
};
}
/**
* Default summary renderer
*/
function defaultRenderSummary(args, status) {
const summary = Object.entries(args || {})
.slice(0, 2)
.map(([k, v]) => `${k}=${String(v).slice(0, 20)}`)
.join(', ');
return summary || 'Running...';
}
/**
* Default details renderer
*/
function defaultRenderDetails(args, result) {
if (result?.output) {
return result.output.slice(0, 500);
}
return JSON.stringify(args, null, 2).slice(0, 500);
}
// ============================================
// BUILT-IN TOOL RENDERERS
// ============================================
// File read tool
registerTool('read_file', {
icon: '📄',
iconAscii: '[R]',
title: 'Read File',
color: colors.accent,
renderSummary: (args) => args?.path || 'reading...',
renderDetails: (args, result) => result?.content?.slice(0, 500) || ''
});
// File write tool
registerTool('write_file', {
icon: '✏️',
iconAscii: '[W]',
title: 'Write File',
color: 'green',
renderSummary: (args) => args?.path || 'writing...',
renderDetails: (args) => `${args?.content?.split('\n').length || 0} lines`
});
// Edit file tool
registerTool('edit_file', {
icon: '📝',
iconAscii: '[E]',
title: 'Edit File',
color: 'yellow',
renderSummary: (args) => args?.path || 'editing...',
renderDetails: (args) => args?.description || ''
});
// Delete file tool
registerTool('delete_file', {
icon: '🗑️',
iconAscii: '[D]',
title: 'Delete File',
color: 'red',
renderSummary: (args) => args?.path || 'deleting...',
renderDetails: () => ''
});
// Shell/command tool
registerTool('shell', {
icon: '💻',
iconAscii: '>',
title: 'Shell',
color: 'magenta',
renderSummary: (args) => {
const cmd = args?.command || args?.cmd || '';
return cmd.length > 40 ? cmd.slice(0, 37) + '...' : cmd;
},
renderDetails: (args, result) => result?.output?.slice(0, 1000) || ''
});
registerTool('run_command', {
icon: '💻',
iconAscii: '>',
title: 'Command',
color: 'magenta',
renderSummary: (args) => {
const cmd = args?.command || args?.CommandLine || '';
return cmd.length > 40 ? cmd.slice(0, 37) + '...' : cmd;
},
renderDetails: (args, result) => result?.output?.slice(0, 1000) || ''
});
// Search tool
registerTool('search', {
icon: '🔍',
iconAscii: '?',
title: 'Search',
color: colors.accent,
renderSummary: (args) => args?.query || args?.pattern || 'searching...',
renderDetails: (args, result) => `${result?.matches?.length || 0} matches`
});
registerTool('grep_search', {
icon: '🔍',
iconAscii: '?',
title: 'Grep',
color: colors.accent,
renderSummary: (args) => args?.Query || 'searching...',
renderDetails: (args, result) => `${result?.matches?.length || 0} matches`
});
// List files tool
registerTool('list_files', {
icon: '📁',
iconAscii: '[L]',
title: 'List Files',
color: colors.muted,
renderSummary: (args) => args?.path || args?.directory || '.',
renderDetails: (args, result) => `${result?.files?.length || 0} items`
});
registerTool('list_dir', {
icon: '📁',
iconAscii: '[L]',
title: 'List Dir',
color: colors.muted,
renderSummary: (args) => args?.DirectoryPath || '.',
renderDetails: (args, result) => `${result?.children?.length || 0} items`
});
// TODO/task tool
registerTool('todowrite', {
icon: '✅',
iconAscii: '[T]',
title: 'Tasks',
color: 'green',
renderSummary: (args) => {
const todos = args?.todos || [];
const done = todos.filter(t => t.status === 'done').length;
return `${done}/${todos.length} done`;
},
renderDetails: (args) => {
const todos = args?.todos || [];
return todos.map(t =>
`[${t.status === 'done' ? 'x' : t.status === 'in_progress' ? '/' : ' '}] ${t.text}`
).join('\n');
}
});
// Web search tool
registerTool('web_search', {
icon: '🌐',
iconAscii: '[W]',
title: 'Web Search',
color: colors.accent,
renderSummary: (args) => args?.query || 'searching...',
renderDetails: (args, result) => result?.summary?.slice(0, 300) || ''
});
// Browser tool
registerTool('browser', {
icon: '🌐',
iconAscii: '[B]',
title: 'Browser',
color: colors.accent,
renderSummary: (args) => args?.url || 'browsing...',
renderDetails: () => ''
});
/**
* ToolBlock Component - Renders a tool invocation
*/
export const ToolBlock = ({
toolName,
args = {},
status = 'running', // running | done | failed
result = null,
isExpanded = false,
width = 80
}) => {
const caps = getCapabilities();
const renderer = getToolRenderer(toolName);
const railChar = caps.unicodeOK ? '│' : '|';
const toolIcon = caps.unicodeOK ? renderer.icon : renderer.iconAscii;
const statusConfig = {
running: { color: renderer.color, showSpinner: true },
done: { color: colors.success, showSpinner: false },
failed: { color: colors.error, showSpinner: false }
};
const config = statusConfig[status] || statusConfig.running;
// Summary line
const summary = renderer.renderSummary(args, status);
return h(Box, { flexDirection: 'column' },
// Header line
h(Box, { flexDirection: 'row' },
h(Text, { color: 'magenta' }, railChar + ' '),
config.showSpinner
? h(Spinner, { type: 'dots' })
: h(Text, { color: config.color }, toolIcon),
h(Text, {}, ' '),
h(Text, { color: config.color, bold: true }, renderer.title),
h(Text, { color: colors.muted }, ': '),
h(Text, { color: colors.muted, wrap: 'truncate' },
summary.length > width - 25 ? summary.slice(0, width - 28) + '...' : summary
)
),
// Details (if expanded)
isExpanded && result ? h(Box, { paddingLeft: 4 },
h(Text, { color: colors.muted, dimColor: true, wrap: 'wrap' },
renderer.renderDetails(args, result).slice(0, 500)
)
) : null
);
};
export default { registerTool, getToolRenderer, ToolBlock };

232
bin/ui/models/PartModel.mjs Normal file
View File

@@ -0,0 +1,232 @@
/**
* Part Model - Structured message parts
*
* Based on sst/opencode message architecture
* Credit: https://github.com/sst/opencode
*
* Normalizes all content into typed parts for consistent rendering
*/
// Part types enum
export const PART_TYPES = {
TEXT: 'text', // User/assistant prose
REASONING: 'reasoning', // Thinking/intent trace
TOOL_CALL: 'tool_call', // Tool invocation
TOOL_RESULT: 'tool_result', // Tool output
FILE_SNIPPET: 'file_snippet', // Code file content
DIFF: 'diff', // Diff view
TODO: 'todo', // Todo/checklist
SYSTEM: 'system', // System message
ERROR: 'error' // Error message
};
/**
* Create a text part (user/assistant prose)
*/
export function createTextPart(content, role = 'assistant') {
return {
type: PART_TYPES.TEXT,
role,
content,
timestamp: Date.now()
};
}
/**
* Create a reasoning part (thinking/intent trace)
*/
export function createReasoningPart(intent, next, why = null, steps = []) {
return {
type: PART_TYPES.REASONING,
intent,
next,
why,
steps,
timestamp: Date.now()
};
}
/**
* Create a tool call part
*/
export function createToolCallPart(toolName, args = {}, status = 'running') {
return {
type: PART_TYPES.TOOL_CALL,
toolName,
args,
status, // 'running' | 'done' | 'failed'
timestamp: Date.now()
};
}
/**
* Create a tool result part
*/
export function createToolResultPart(toolName, output, summary = null, exitCode = 0) {
return {
type: PART_TYPES.TOOL_RESULT,
toolName,
output,
summary,
exitCode,
isError: exitCode !== 0,
timestamp: Date.now()
};
}
/**
* Create a file snippet part
*/
export function createFileSnippetPart(filename, content, language = null, startLine = 1) {
const lines = content.split('\n').length;
return {
type: PART_TYPES.FILE_SNIPPET,
filename,
content,
language: language || detectLanguage(filename),
startLine,
lineCount: lines,
timestamp: Date.now()
};
}
/**
* Create a diff part
*/
export function createDiffPart(filename, diff, mode = 'unified') {
return {
type: PART_TYPES.DIFF,
filename,
diff,
mode, // 'unified' | 'split'
timestamp: Date.now()
};
}
/**
* Create a todo part
*/
export function createTodoPart(items) {
return {
type: PART_TYPES.TODO,
items: items.map(item => ({
text: item.text,
status: item.status || 'pending', // 'pending' | 'in_progress' | 'done'
checked: item.status === 'done'
})),
timestamp: Date.now()
};
}
/**
* Create a system part
*/
export function createSystemPart(content, subtype = 'info') {
return {
type: PART_TYPES.SYSTEM,
content,
subtype, // 'info' | 'warning' | 'success'
timestamp: Date.now()
};
}
/**
* Create an error part
*/
export function createErrorPart(message, details = null, stack = null) {
return {
type: PART_TYPES.ERROR,
message,
details,
stack,
timestamp: Date.now()
};
}
/**
* Detect language from filename
*/
function detectLanguage(filename) {
if (!filename) return null;
const ext = filename.split('.').pop()?.toLowerCase();
const langMap = {
js: 'javascript', mjs: 'javascript', cjs: 'javascript',
ts: 'typescript', tsx: 'typescript',
py: 'python',
rb: 'ruby',
go: 'go',
rs: 'rust',
java: 'java',
c: 'c', cpp: 'cpp', h: 'c',
cs: 'csharp',
php: 'php',
sh: 'bash', bash: 'bash',
ps1: 'powershell',
json: 'json',
yaml: 'yaml', yml: 'yaml',
md: 'markdown',
html: 'html',
css: 'css',
sql: 'sql'
};
return langMap[ext] || null;
}
/**
* Parse raw message content into parts
* Extracts code blocks, tool calls, etc.
*/
export function parseContentToParts(content, role = 'assistant') {
const parts = [];
// Simple parsing - extract code blocks
const codeBlockRegex = /```(\w+)?\n([\s\S]*?)```/g;
let lastIndex = 0;
let match;
while ((match = codeBlockRegex.exec(content)) !== null) {
// Text before code block
if (match.index > lastIndex) {
const text = content.slice(lastIndex, match.index).trim();
if (text) {
parts.push(createTextPart(text, role));
}
}
// Code block
const lang = match[1] || null;
const code = match[2];
parts.push(createFileSnippetPart(null, code, lang));
lastIndex = match.index + match[0].length;
}
// Remaining text after last code block
if (lastIndex < content.length) {
const text = content.slice(lastIndex).trim();
if (text) {
parts.push(createTextPart(text, role));
}
}
// If no parts created, treat entire content as text
if (parts.length === 0) {
parts.push(createTextPart(content, role));
}
return parts;
}
export default {
PART_TYPES,
createTextPart,
createReasoningPart,
createToolCallPart,
createToolResultPart,
createFileSnippetPart,
createDiffPart,
createTodoPart,
createSystemPart,
createErrorPart,
parseContentToParts
};

212
bin/ui/run-events.mjs Normal file
View File

@@ -0,0 +1,212 @@
/**
* Run Events Model - Append-only event stream with strict channel routing
*
* CORE DATA MODEL:
* - Run: tracks automation state
* - RunEvent: append-only stream with type-based routing
*
* Credits: sst/opencode session model, Windows-Use verification loop
*/
// Run states
export const RUN_STATE = {
IDLE: 'idle',
ASKING: 'asking',
PREVIEWING: 'previewing',
RUNNING: 'running',
VERIFYING: 'verifying',
DONE: 'done',
FAILED: 'failed',
BLOCKED: 'blocked' // needs human intervention
};
// Event types with strict channel routing
export const EVENT_TYPES = {
// CHAT lane (user sees these as conversation)
CHAT_USER: 'chat_user',
CHAT_ASSISTANT: 'chat_assistant',
// AUTOMATION lane (collapsed by default)
OBSERVE_SNAPSHOT: 'observe_snapshot',
INTENT_TRACE: 'intent_trace',
PREVIEW_PLAN: 'preview_plan',
ACTION_BATCH: 'action_batch',
ACTION_RESULT: 'action_result',
VERIFICATION: 'verification',
// TOOL lane (hidden by default)
TOOL_SUMMARY: 'tool_summary',
TOOL_DETAILS: 'tool_details',
// ARTIFACT lane (clickable items)
ARTIFACT: 'artifact',
TODO_UPDATE: 'todo_update',
// TOAST (ephemeral, not in transcript)
TOAST: 'toast',
// ERROR (collapsed by default)
ERROR: 'error'
};
// Channel routing map
export const CHANNEL_MAP = {
[EVENT_TYPES.CHAT_USER]: 'chat',
[EVENT_TYPES.CHAT_ASSISTANT]: 'chat',
[EVENT_TYPES.OBSERVE_SNAPSHOT]: 'automation',
[EVENT_TYPES.INTENT_TRACE]: 'automation',
[EVENT_TYPES.PREVIEW_PLAN]: 'automation',
[EVENT_TYPES.ACTION_BATCH]: 'automation',
[EVENT_TYPES.ACTION_RESULT]: 'automation',
[EVENT_TYPES.VERIFICATION]: 'automation',
[EVENT_TYPES.TOOL_SUMMARY]: 'tool',
[EVENT_TYPES.TOOL_DETAILS]: 'tool',
[EVENT_TYPES.ARTIFACT]: 'artifact',
[EVENT_TYPES.TODO_UPDATE]: 'artifact',
[EVENT_TYPES.TOAST]: 'toast',
[EVENT_TYPES.ERROR]: 'error'
};
/**
* Create a new Run
*/
export function createRun(goal, mode = 'chat') {
return {
runId: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
mode, // 'chat' | 'desktop' | 'browser' | 'server'
goal,
state: RUN_STATE.ASKING,
stepIndex: 0,
maxSteps: 25,
failureCount: 0,
maxFailures: 3,
activeTarget: null,
confidence: 1.0,
events: [],
createdAt: Date.now()
};
}
/**
* Create a RunEvent
*/
export function createEvent(type, payload) {
if (!EVENT_TYPES[type] && !Object.values(EVENT_TYPES).includes(type)) {
console.warn(`Unknown event type: ${type}`);
}
return {
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 4),
type,
channel: CHANNEL_MAP[type] || 'tool',
payload,
timestamp: Date.now()
};
}
/**
* Append event to run (immutable)
*/
export function appendEvent(run, event) {
return {
...run,
events: [...run.events, event]
};
}
/**
* Update run state
*/
export function updateRunState(run, newState, updates = {}) {
return {
...run,
state: newState,
...updates
};
}
/**
* Get events by channel
*/
export function getEventsByChannel(run, channel) {
return run.events.filter(e => e.channel === channel);
}
/**
* Get chat events only (for CHAT lane)
*/
export function getChatEvents(run) {
return getEventsByChannel(run, 'chat');
}
/**
* Get automation events (for timeline)
*/
export function getAutomationEvents(run) {
return getEventsByChannel(run, 'automation');
}
/**
* Check if run needs human intervention
*/
export function isBlocked(run) {
return run.state === RUN_STATE.BLOCKED;
}
/**
* Check if run is complete
*/
export function isComplete(run) {
return run.state === RUN_STATE.DONE || run.state === RUN_STATE.FAILED;
}
/**
* Record a verification result
*/
export function createVerification(passed, expected, observed, message = null) {
return createEvent(EVENT_TYPES.VERIFICATION, {
passed,
expected,
observed,
message
});
}
/**
* Record an observation snapshot
*/
export function createObservation(snapshotData, snapshotType = 'desktop') {
return createEvent(EVENT_TYPES.OBSERVE_SNAPSHOT, {
type: snapshotType,
data: snapshotData,
timestamp: Date.now()
});
}
/**
* Create a toast event (for confirmations)
*/
export function createToast(message, type = 'info') {
return createEvent(EVENT_TYPES.TOAST, {
message,
type // 'info' | 'success' | 'warning' | 'error'
});
}
export default {
RUN_STATE,
EVENT_TYPES,
CHANNEL_MAP,
createRun,
createEvent,
appendEvent,
updateRunState,
getEventsByChannel,
getChatEvents,
getAutomationEvents,
isBlocked,
isComplete,
createVerification,
createObservation,
createToast
};