Kode-cli/src/components/MessageSelector.tsx
2025-09-20 15:14:39 +08:00

202 lines
5.9 KiB
TypeScript

import { Box, Text, useInput } from 'ink'
import * as React from 'react'
import { useMemo, useState, useEffect } from 'react'
import figures from 'figures'
import { getTheme } from '@utils/theme'
import { Message as MessageComponent } from './Message'
import { randomUUID } from 'crypto'
import { type Tool } from '@tool'
import {
createUserMessage,
isEmptyMessageText,
isNotEmptyMessage,
normalizeMessages,
} from '@utils/messages'
import type { AssistantMessage, UserMessage } from '@query'
import { useExitOnCtrlCD } from '@hooks/useExitOnCtrlCD'
type Props = {
erroredToolUseIDs: Set<string>
messages: (UserMessage | AssistantMessage)[]
onSelect: (message: UserMessage) => void
onEscape: () => void
tools: Tool[]
unresolvedToolUseIDs: Set<string>
}
const MAX_VISIBLE_MESSAGES = 7
export function MessageSelector({
erroredToolUseIDs,
messages,
onSelect,
onEscape,
tools,
unresolvedToolUseIDs,
}: Props): React.ReactNode {
const currentUUID = useMemo(randomUUID, [])
useEffect(() => {}, [])
function handleSelect(message: UserMessage) {
const indexFromEnd = messages.length - 1 - messages.indexOf(message)
onSelect(message)
}
function handleEscape() {
onEscape()
}
// Add current prompt as a virtual message
const allItems = useMemo(
() => [
// Filter out tool results
...messages
.filter(
_ =>
!(
_.type === 'user' &&
Array.isArray(_.message.content) &&
_.message.content[0]?.type === 'tool_result'
),
)
// Filter out assistant messages, until we have a way to kick off the tool use loop from REPL
.filter(_ => _.type !== 'assistant'),
{ ...createUserMessage(''), uuid: currentUUID } as UserMessage,
],
[messages, currentUUID],
)
const [selectedIndex, setSelectedIndex] = useState(allItems.length - 1)
const exitState = useExitOnCtrlCD(() => process.exit(0))
useInput((input, key) => {
if (key.tab || key.escape) {
handleEscape()
return
}
if (key.return) {
handleSelect(allItems[selectedIndex]!)
return
}
if (key.upArrow) {
if (key.ctrl || key.shift || key.meta) {
// Jump to top with any modifier key
setSelectedIndex(0)
} else {
setSelectedIndex(prev => Math.max(0, prev - 1))
}
}
if (key.downArrow) {
if (key.ctrl || key.shift || key.meta) {
// Jump to bottom with any modifier key
setSelectedIndex(allItems.length - 1)
} else {
setSelectedIndex(prev => Math.min(allItems.length - 1, prev + 1))
}
}
// Handle number keys (1-9)
const num = Number(input)
if (!isNaN(num) && num >= 1 && num <= Math.min(9, allItems.length)) {
if (!allItems[num - 1]) {
return
}
handleSelect(allItems[num - 1]!)
}
})
const firstVisibleIndex = Math.max(
0,
Math.min(
selectedIndex - Math.floor(MAX_VISIBLE_MESSAGES / 2),
allItems.length - MAX_VISIBLE_MESSAGES,
),
)
const normalizedMessages = useMemo(
() => normalizeMessages(messages).filter(isNotEmptyMessage),
[messages],
)
return (
<>
<Box
flexDirection="column"
borderStyle="round"
borderColor={getTheme().secondaryBorder}
height={4 + Math.min(MAX_VISIBLE_MESSAGES, allItems.length) * 2}
paddingX={1}
marginTop={1}
>
<Box flexDirection="column" minHeight={2} marginBottom={1}>
<Text bold>Jump to a previous message</Text>
<Text dimColor>This will fork the conversation</Text>
</Box>
{allItems
.slice(firstVisibleIndex, firstVisibleIndex + MAX_VISIBLE_MESSAGES)
.map((msg, index) => {
const actualIndex = firstVisibleIndex + index
const isSelected = actualIndex === selectedIndex
const isCurrent = msg.uuid === currentUUID
return (
<Box key={msg.uuid} flexDirection="row" height={2} minHeight={2}>
<Box width={7}>
{isSelected ? (
<Text color="blue" bold>
{figures.pointer} {firstVisibleIndex + index + 1}{' '}
</Text>
) : (
<Text>
{' '}
{firstVisibleIndex + index + 1}{' '}
</Text>
)}
</Box>
<Box height={1} overflow="hidden" width={100}>
{isCurrent ? (
<Box width="100%">
<Text dimColor italic>
{'(current)'}
</Text>
</Box>
) : Array.isArray(msg.message.content) &&
msg.message.content[0]?.type === 'text' &&
isEmptyMessageText(msg.message.content[0].text) ? (
<Text dimColor italic>
(empty message)
</Text>
) : (
<MessageComponent
message={msg}
messages={normalizedMessages}
addMargin={false}
tools={tools}
verbose={false}
debug={false}
erroredToolUseIDs={erroredToolUseIDs}
inProgressToolUseIDs={new Set()}
unresolvedToolUseIDs={unresolvedToolUseIDs}
shouldAnimate={false}
shouldShowDot={false}
/>
)}
</Box>
</Box>
)
})}
</Box>
<Box marginLeft={3}>
<Text dimColor>
{exitState.pending ? (
<>Press {exitState.keyName} again to exit</>
) : (
<>/ to select · Enter to confirm · Tab/Esc to cancel</>
)}
</Text>
</Box>
</>
)
}