Release v1.01 Enhanced: Vi Control, TUI Gen5, Core Stability
This commit is contained in:
162
bin/ui/components/AutomationTimeline.mjs
Normal file
162
bin/ui/components/AutomationTimeline.mjs
Normal 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 };
|
||||
106
bin/ui/components/BrowserInspector.mjs
Normal file
106
bin/ui/components/BrowserInspector.mjs
Normal 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 };
|
||||
173
bin/ui/components/ChannelLanes.mjs
Normal file
173
bin/ui/components/ChannelLanes.mjs
Normal 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 };
|
||||
156
bin/ui/components/CleanTodoList.mjs
Normal file
156
bin/ui/components/CleanTodoList.mjs
Normal 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 };
|
||||
120
bin/ui/components/CodeCard.mjs
Normal file
120
bin/ui/components/CodeCard.mjs
Normal 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
|
||||
);
|
||||
};
|
||||
105
bin/ui/components/DesktopInspector.mjs
Normal file
105
bin/ui/components/DesktopInspector.mjs
Normal 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 };
|
||||
@@ -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')
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
41
bin/ui/components/FilePickerOverlay.mjs
Normal file
41
bin/ui/components/FilePickerOverlay.mjs
Normal 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;
|
||||
|
||||
130
bin/ui/components/FilePreviewTabs.mjs
Normal file
130
bin/ui/components/FilePreviewTabs.mjs
Normal 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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
138
bin/ui/components/FlowRibbon.mjs
Normal file
138
bin/ui/components/FlowRibbon.mjs
Normal 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 };
|
||||
101
bin/ui/components/FooterStrip.mjs
Normal file
101
bin/ui/components/FooterStrip.mjs
Normal 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 };
|
||||
94
bin/ui/components/GettingStartedCard.mjs
Normal file
94
bin/ui/components/GettingStartedCard.mjs
Normal 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 };
|
||||
95
bin/ui/components/HeaderStrip.mjs
Normal file
95
bin/ui/components/HeaderStrip.mjs
Normal 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 };
|
||||
139
bin/ui/components/IntentTrace.mjs
Normal file
139
bin/ui/components/IntentTrace.mjs
Normal 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 };
|
||||
201
bin/ui/components/PremiumInputBar.mjs
Normal file
201
bin/ui/components/PremiumInputBar.mjs
Normal 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 };
|
||||
294
bin/ui/components/PremiumMessage.mjs
Normal file
294
bin/ui/components/PremiumMessage.mjs
Normal 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
|
||||
};
|
||||
263
bin/ui/components/PremiumSidebar.mjs
Normal file
263
bin/ui/components/PremiumSidebar.mjs
Normal 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 };
|
||||
187
bin/ui/components/PreviewPlan.mjs
Normal file
187
bin/ui/components/PreviewPlan.mjs
Normal 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 };
|
||||
149
bin/ui/components/RunStrip.mjs
Normal file
149
bin/ui/components/RunStrip.mjs
Normal 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 };
|
||||
105
bin/ui/components/SearchOverlay.mjs
Normal file
105
bin/ui/components/SearchOverlay.mjs
Normal 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;
|
||||
|
||||
117
bin/ui/components/ServerInspector.mjs
Normal file
117
bin/ui/components/ServerInspector.mjs
Normal 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
141
bin/ui/components/Toast.mjs
Normal 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
|
||||
};
|
||||
269
bin/ui/components/ToolRegistry.mjs
Normal file
269
bin/ui/components/ToolRegistry.mjs
Normal 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 };
|
||||
Reference in New Issue
Block a user