295 lines
7.9 KiB
JavaScript
295 lines
7.9 KiB
JavaScript
/**
|
|
* 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
|
|
};
|