mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-01 01:51:44 +00:00
feat: split apart main.tsx
This commit is contained in:
parent
2818dd8611
commit
bbba9ed4f2
16 changed files with 1710 additions and 1653 deletions
113
ui-tui/src/components/branding.tsx
Normal file
113
ui-tui/src/components/branding.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { Box, Text, useStdout } from 'ink'
|
||||
|
||||
import { caduceus, logo, LOGO_WIDTH } from '../banner.js'
|
||||
import { flat } from '../lib/text.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
import type { SessionInfo } from '../types.js'
|
||||
|
||||
export function ArtLines({ lines }: { lines: [string, string][] }) {
|
||||
return (
|
||||
<>
|
||||
{lines.map(([c, text], i) => (
|
||||
<Text color={c} key={i}>
|
||||
{text}
|
||||
</Text>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function Banner({ t }: { t: Theme }) {
|
||||
const cols = useStdout().stdout?.columns ?? 80
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
{cols >= LOGO_WIDTH ? (
|
||||
<ArtLines lines={logo(t.color)} />
|
||||
) : (
|
||||
<Text bold color={t.color.gold}>
|
||||
{t.brand.icon} NOUS HERMES
|
||||
</Text>
|
||||
)}
|
||||
<Text />
|
||||
<Text>
|
||||
<Text color={t.color.amber}>{t.brand.icon} Nous Research</Text>
|
||||
<Text color={t.color.dim}> · Messenger of the Digital Gods</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionPanel({ info, t }: { info: SessionInfo; t: Theme }) {
|
||||
const cols = useStdout().stdout?.columns ?? 100
|
||||
const wide = cols >= 90
|
||||
const w = wide ? cols - 46 : cols - 10
|
||||
const strip = (s: string) => (s.endsWith('_tools') ? s.slice(0, -6) : s)
|
||||
|
||||
const truncLine = (pfx: string, items: string[]) => {
|
||||
let line = ''
|
||||
|
||||
for (const item of items.sort()) {
|
||||
const next = line ? `${line}, ${item}` : item
|
||||
|
||||
if (pfx.length + next.length > w) {
|
||||
return line ? `${line}, …+${items.length - line.split(', ').length}` : `${item}, …`
|
||||
}
|
||||
|
||||
line = next
|
||||
}
|
||||
|
||||
return line
|
||||
}
|
||||
|
||||
const section = (title: string, data: Record<string, string[]>, max = 8) => {
|
||||
const entries = Object.entries(data).sort()
|
||||
const shown = entries.slice(0, max)
|
||||
const overflow = entries.length - max
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold color={t.color.amber}>
|
||||
Available {title}
|
||||
</Text>
|
||||
{shown.map(([k, vs]) => (
|
||||
<Text key={k} wrap="truncate">
|
||||
<Text color={t.color.dim}>{strip(k)}: </Text>
|
||||
<Text color={t.color.cornsilk}>{truncLine(strip(k) + ': ', vs)}</Text>
|
||||
</Text>
|
||||
))}
|
||||
{overflow > 0 && <Text color={t.color.dim}>(and {overflow} more…)</Text>}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box borderColor={t.color.bronze} borderStyle="round" marginBottom={1} paddingX={2} paddingY={1}>
|
||||
{wide && (
|
||||
<Box flexDirection="column" marginRight={2} width={34}>
|
||||
<ArtLines lines={caduceus(t.color)} />
|
||||
<Text />
|
||||
<Text color={t.color.dim}>Nous Research</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box flexDirection="column" width={w}>
|
||||
<Text bold color={t.color.gold}>
|
||||
{t.brand.icon} {t.brand.name}
|
||||
</Text>
|
||||
{section('Tools', info.tools)}
|
||||
{section('Skills', info.skills)}
|
||||
<Text />
|
||||
<Text color={t.color.cornsilk}>
|
||||
{flat(info.tools).length} tools{' · '}
|
||||
{flat(info.skills).length} skills
|
||||
{' · '}
|
||||
<Text color={t.color.dim}>/help for commands</Text>
|
||||
</Text>
|
||||
<Text color={t.color.dim}>
|
||||
{info.model.split('/').pop()}
|
||||
{' · '}Ctrl+C to interrupt
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
25
ui-tui/src/components/commandPalette.tsx
Normal file
25
ui-tui/src/components/commandPalette.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { Box, Text } from 'ink'
|
||||
|
||||
import { COMMANDS } from '../constants.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
|
||||
export function CommandPalette({ filter, t }: { filter: string; t: Theme }) {
|
||||
const matches = COMMANDS.filter(([cmd]) => cmd.startsWith(filter))
|
||||
|
||||
if (!matches.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{matches.map(([cmd, desc]) => (
|
||||
<Text key={cmd}>
|
||||
<Text bold color={t.color.amber}>
|
||||
{cmd}
|
||||
</Text>
|
||||
<Text color={t.color.dim}> — {desc}</Text>
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
152
ui-tui/src/components/markdown.tsx
Normal file
152
ui-tui/src/components/markdown.tsx
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { Box, Text } from 'ink'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import type { Theme } from '../theme.js'
|
||||
|
||||
function MdInline({ t, text }: { t: Theme; text: string }) {
|
||||
const parts: ReactNode[] = []
|
||||
const re = /(\[(.+?)\]\((https?:\/\/[^\s)]+)\)|\*\*(.+?)\*\*|`([^`]+)`|\*(.+?)\*|(https?:\/\/[^\s]+))/g
|
||||
|
||||
let last = 0
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
if (match.index > last) {
|
||||
parts.push(
|
||||
<Text color={t.color.cornsilk} key={parts.length}>
|
||||
{text.slice(last, match.index)}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
if (match[2] && match[3]) {
|
||||
parts.push(
|
||||
<Text color={t.color.amber} key={parts.length} underline>
|
||||
{match[2]}
|
||||
</Text>
|
||||
)
|
||||
} else if (match[4]) {
|
||||
parts.push(
|
||||
<Text bold color={t.color.cornsilk} key={parts.length}>
|
||||
{match[4]}
|
||||
</Text>
|
||||
)
|
||||
} else if (match[5]) {
|
||||
parts.push(
|
||||
<Text color={t.color.amber} dimColor key={parts.length}>
|
||||
{match[5]}
|
||||
</Text>
|
||||
)
|
||||
} else if (match[6]) {
|
||||
parts.push(
|
||||
<Text color={t.color.cornsilk} italic key={parts.length}>
|
||||
{match[6]}
|
||||
</Text>
|
||||
)
|
||||
} else if (match[7]) {
|
||||
parts.push(
|
||||
<Text color={t.color.amber} key={parts.length} underline>
|
||||
{match[7]}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
last = match.index + match[0].length
|
||||
}
|
||||
|
||||
if (last < text.length) {
|
||||
parts.push(
|
||||
<Text color={t.color.cornsilk} key={parts.length}>
|
||||
{text.slice(last)}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
return <Text>{parts.length ? parts : <Text color={t.color.cornsilk}>{text}</Text>}</Text>
|
||||
}
|
||||
|
||||
export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: string }) {
|
||||
const lines = text.split('\n')
|
||||
const nodes: ReactNode[] = []
|
||||
let i = 0
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]!
|
||||
const key = nodes.length
|
||||
|
||||
if (compact && !line.trim()) {
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.startsWith('```')) {
|
||||
const lang = line.slice(3).trim()
|
||||
const block: string[] = []
|
||||
|
||||
for (i++; i < lines.length && !lines[i]!.startsWith('```'); i++) {
|
||||
block.push(lines[i]!)
|
||||
}
|
||||
|
||||
i++
|
||||
nodes.push(
|
||||
<Box flexDirection="column" key={key} paddingLeft={2}>
|
||||
{lang && <Text color={t.color.dim}>{'─ ' + lang}</Text>}
|
||||
{block.map((l, j) => (
|
||||
<Text color={t.color.cornsilk} key={j}>
|
||||
{l}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const heading = line.match(/^#{1,3}\s+(.*)/)
|
||||
|
||||
if (heading) {
|
||||
nodes.push(
|
||||
<Text bold color={t.color.amber} key={key}>
|
||||
{heading[1]}
|
||||
</Text>
|
||||
)
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const bullet = line.match(/^\s*[-*]\s(.*)/)
|
||||
|
||||
if (bullet) {
|
||||
nodes.push(
|
||||
<Text key={key}>
|
||||
<Text color={t.color.dim}> • </Text>
|
||||
<MdInline t={t} text={bullet[1]!} />
|
||||
</Text>
|
||||
)
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const numbered = line.match(/^\s*(\d+)\.\s(.*)/)
|
||||
|
||||
if (numbered) {
|
||||
nodes.push(
|
||||
<Text key={key}>
|
||||
<Text color={t.color.dim}> {numbered[1]}. </Text>
|
||||
<MdInline t={t} text={numbered[2]!} />
|
||||
</Text>
|
||||
)
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
nodes.push(<MdInline key={key} t={t} text={line} />)
|
||||
i++
|
||||
}
|
||||
|
||||
return <Box flexDirection="column">{nodes}</Box>
|
||||
}
|
||||
46
ui-tui/src/components/messageLine.tsx
Normal file
46
ui-tui/src/components/messageLine.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { Box, Text } from 'ink'
|
||||
|
||||
import { LONG_MSG, ROLE } from '../constants.js'
|
||||
import { userDisplay } from '../lib/text.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
import type { Msg } from '../types.js'
|
||||
|
||||
import { Md } from './markdown.js'
|
||||
|
||||
export function MessageLine({ compact, msg, t }: { compact?: boolean; msg: Msg; t: Theme }) {
|
||||
const { body, glyph, prefix } = ROLE[msg.role](t)
|
||||
|
||||
const content = (() => {
|
||||
if (msg.role === 'assistant') {
|
||||
return <Md compact={compact} t={t} text={msg.text} />
|
||||
}
|
||||
|
||||
if (msg.role === 'user' && msg.text.length > LONG_MSG) {
|
||||
const displayed = userDisplay(msg.text)
|
||||
const [head, ...rest] = displayed.split('[long message]')
|
||||
|
||||
return (
|
||||
<Text color={body}>
|
||||
{head}
|
||||
<Text color={t.color.dim} dimColor>
|
||||
[long message]
|
||||
</Text>
|
||||
{rest.join('')}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
return <Text color={body}>{msg.text}</Text>
|
||||
})()
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box width={3}>
|
||||
<Text bold={msg.role === 'user'} color={prefix}>
|
||||
{glyph}{' '}
|
||||
</Text>
|
||||
</Box>
|
||||
{content}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
127
ui-tui/src/components/prompts.tsx
Normal file
127
ui-tui/src/components/prompts.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { Box, Text, useInput } from 'ink'
|
||||
import TextInput from 'ink-text-input'
|
||||
import { useState } from 'react'
|
||||
|
||||
import type { Theme } from '../theme.js'
|
||||
import type { ApprovalReq, ClarifyReq } from '../types.js'
|
||||
|
||||
export function ApprovalPrompt({ onChoice, req, t }: { onChoice: (s: string) => void; req: ApprovalReq; t: Theme }) {
|
||||
const [sel, setSel] = useState(3)
|
||||
const opts = ['once', 'session', 'always', 'deny'] as const
|
||||
const labels = { always: 'Always allow', deny: 'Deny', once: 'Allow once', session: 'Allow this session' } as const
|
||||
|
||||
useInput((ch, key) => {
|
||||
if (key.upArrow && sel > 0) {
|
||||
setSel(s => s - 1)
|
||||
}
|
||||
|
||||
if (key.downArrow && sel < 3) {
|
||||
setSel(s => s + 1)
|
||||
}
|
||||
|
||||
if (key.return) {
|
||||
onChoice(opts[sel]!)
|
||||
}
|
||||
|
||||
if (ch === 'o') {
|
||||
onChoice('once')
|
||||
}
|
||||
|
||||
if (ch === 's') {
|
||||
onChoice('session')
|
||||
}
|
||||
|
||||
if (ch === 'a') {
|
||||
onChoice('always')
|
||||
}
|
||||
|
||||
if (ch === 'd' || key.escape) {
|
||||
onChoice('deny')
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={t.color.warn}>
|
||||
⚠️ DANGEROUS COMMAND: {req.description}
|
||||
</Text>
|
||||
<Text color={t.color.dim}> {req.command}</Text>
|
||||
<Text />
|
||||
{opts.map((o, i) => (
|
||||
<Text key={o}>
|
||||
<Text color={sel === i ? t.color.warn : t.color.dim}>{sel === i ? '▸ ' : ' '}</Text>
|
||||
<Text color={sel === i ? t.color.cornsilk : t.color.dim}>
|
||||
[{o[0]}] {labels[o]}
|
||||
</Text>
|
||||
</Text>
|
||||
))}
|
||||
<Text color={t.color.dim}>↑/↓ select · Enter confirm · o/s/a/d quick pick</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export function ClarifyPrompt({ onAnswer, req, t }: { onAnswer: (s: string) => void; req: ClarifyReq; t: Theme }) {
|
||||
const [sel, setSel] = useState(0)
|
||||
const [custom, setCustom] = useState('')
|
||||
const [typing, setTyping] = useState(false)
|
||||
const choices = req.choices ?? []
|
||||
|
||||
useInput((ch, key) => {
|
||||
if (typing) {
|
||||
return
|
||||
}
|
||||
|
||||
if (key.upArrow && sel > 0) {
|
||||
setSel(s => s - 1)
|
||||
}
|
||||
|
||||
if (key.downArrow && sel < choices.length) {
|
||||
setSel(s => s + 1)
|
||||
}
|
||||
|
||||
if (key.return) {
|
||||
if (sel === choices.length) {
|
||||
setTyping(true)
|
||||
} else if (choices[sel]) {
|
||||
onAnswer(choices[sel]!)
|
||||
}
|
||||
}
|
||||
|
||||
const n = parseInt(ch)
|
||||
|
||||
if (n >= 1 && n <= choices.length) {
|
||||
onAnswer(choices[n - 1]!)
|
||||
}
|
||||
})
|
||||
|
||||
if (typing || !choices.length) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={t.color.amber}>
|
||||
❓ {req.question}
|
||||
</Text>
|
||||
<Box>
|
||||
<Text color={t.color.label}>{'> '}</Text>
|
||||
<TextInput onChange={setCustom} onSubmit={onAnswer} value={custom} />
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={t.color.amber}>
|
||||
❓ {req.question}
|
||||
</Text>
|
||||
{[...choices, 'Other (type your answer)'].map((c, i) => (
|
||||
<Text key={i}>
|
||||
<Text color={sel === i ? t.color.label : t.color.dim}>{sel === i ? '▸ ' : ' '}</Text>
|
||||
<Text color={sel === i ? t.color.cornsilk : t.color.dim}>
|
||||
{i + 1}. {c}
|
||||
</Text>
|
||||
</Text>
|
||||
))}
|
||||
<Text color={t.color.dim}>↑/↓ select · Enter confirm · 1-{choices.length} quick pick</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
53
ui-tui/src/components/queuedMessages.tsx
Normal file
53
ui-tui/src/components/queuedMessages.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { Box, Text } from 'ink'
|
||||
|
||||
import { compactPreview } from '../lib/text.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
|
||||
export function QueuedMessages({
|
||||
cols,
|
||||
queueEditIdx,
|
||||
queued,
|
||||
t
|
||||
}: {
|
||||
cols: number
|
||||
queueEditIdx: number | null
|
||||
queued: string[]
|
||||
t: Theme
|
||||
}) {
|
||||
if (!queued.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const qWindow = 3
|
||||
const qStart = queueEditIdx === null ? 0 : Math.max(0, Math.min(queueEditIdx - 1, queued.length - qWindow))
|
||||
const qEnd = Math.min(queued.length, qStart + qWindow)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={t.color.dim} dimColor>
|
||||
queued ({queued.length}){queueEditIdx !== null ? ` · editing ${queueEditIdx + 1}` : ''}
|
||||
</Text>
|
||||
{qStart > 0 && (
|
||||
<Text color={t.color.dim} dimColor>
|
||||
{' '}
|
||||
…
|
||||
</Text>
|
||||
)}
|
||||
{queued.slice(qStart, qEnd).map((item, i) => {
|
||||
const idx = qStart + i
|
||||
const active = queueEditIdx === idx
|
||||
|
||||
return (
|
||||
<Text color={active ? t.color.amber : t.color.dim} dimColor key={`${idx}-${item.slice(0, 16)}`}>
|
||||
{active ? '▸' : ' '} {idx + 1}. {compactPreview(item, Math.max(16, cols - 10))}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
{qEnd < queued.length && (
|
||||
<Text color={t.color.dim} dimColor>
|
||||
{' '}…and {queued.length - qEnd} more
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
41
ui-tui/src/components/thinking.tsx
Normal file
41
ui-tui/src/components/thinking.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { Box, Text } from 'ink'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { FACES, SPINNER, TOOL_VERBS, VERBS } from '../constants.js'
|
||||
import { pick } from '../lib/text.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
import type { ActiveTool } from '../types.js'
|
||||
|
||||
export function Thinking({ reasoning, t, tools }: { reasoning: string; t: Theme; tools: ActiveTool[] }) {
|
||||
const [frame, setFrame] = useState(0)
|
||||
const [verb] = useState(() => pick(VERBS))
|
||||
const [face] = useState(() => pick(FACES))
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setFrame(f => (f + 1) % SPINNER.length), 80)
|
||||
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{tools.length ? (
|
||||
tools.map(tool => (
|
||||
<Text color={t.color.dim} key={tool.id}>
|
||||
{SPINNER[frame]} {TOOL_VERBS[tool.name] ?? '⚡ ' + tool.name}…
|
||||
</Text>
|
||||
))
|
||||
) : (
|
||||
<Text color={t.color.dim}>
|
||||
{SPINNER[frame]} {face} {verb}…
|
||||
</Text>
|
||||
)}
|
||||
{reasoning && (
|
||||
<Text color={t.color.dim} dimColor wrap="truncate-end">
|
||||
{' 💭 '}
|
||||
{reasoning.slice(-120).replace(/\n/g, ' ')}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue