mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-30 01:41:43 +00:00
feat: cute spinners
This commit is contained in:
parent
b50d81f212
commit
af0f4a52fe
11 changed files with 1429 additions and 240 deletions
26
ui-tui/src/components/activityLane.tsx
Normal file
26
ui-tui/src/components/activityLane.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { Box, Text } from 'ink'
|
||||
|
||||
import type { Theme } from '../theme.js'
|
||||
import type { ActivityItem } from '../types.js'
|
||||
|
||||
export function ActivityLane({ items, t }: { items: ActivityItem[]; t: Theme }) {
|
||||
if (!items.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const visible = items.slice(-4)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{visible.map(item => {
|
||||
const color = item.tone === 'error' ? t.color.error : item.tone === 'warn' ? t.color.warn : t.color.dim
|
||||
|
||||
return (
|
||||
<Text color={color} dimColor={item.tone === 'info'} key={item.id}>
|
||||
{t.brand.tool} {item.text}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -53,9 +53,7 @@ export const MessageLine = memo(function MessageLine({
|
|||
})()
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{(msg.role === 'user' || msg.role === 'assistant') && <Text> </Text>}
|
||||
|
||||
<Box flexDirection="column" marginTop={msg.role === 'user' ? 1 : 0}>
|
||||
<Box>
|
||||
<Box flexShrink={0} width={3}>
|
||||
<Text bold={msg.role === 'user'} color={prefix}>
|
||||
|
|
|
|||
47
ui-tui/src/components/pasteShelf.tsx
Normal file
47
ui-tui/src/components/pasteShelf.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { Box, Text } from 'ink'
|
||||
|
||||
import { compactPreview } from '../lib/text.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
import type { PendingPaste } from '../types.js'
|
||||
|
||||
const TOKEN_RE = /\[\[paste:(\d+)\]\]/g
|
||||
|
||||
const modeLabel = {
|
||||
attach: 'attach',
|
||||
excerpt: 'excerpt',
|
||||
inline: 'inline'
|
||||
} as const
|
||||
|
||||
export function PasteShelf({ draft, pastes, t }: { draft: string; pastes: PendingPaste[]; t: Theme }) {
|
||||
if (!pastes.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const inDraft = new Set<number>()
|
||||
|
||||
for (const m of draft.matchAll(TOKEN_RE)) {
|
||||
inDraft.add(parseInt(m[1] ?? '-1', 10))
|
||||
}
|
||||
|
||||
return (
|
||||
<Box borderColor={t.color.bronze} borderStyle="round" flexDirection="column" marginTop={1} paddingX={1}>
|
||||
<Text color={t.color.amber}>Paste shelf ({pastes.length})</Text>
|
||||
{pastes.slice(-4).map(paste => (
|
||||
<Text color={t.color.dim} key={paste.id}>
|
||||
#{paste.id} {modeLabel[paste.mode]} · {paste.lineCount}L · {paste.kind}
|
||||
{inDraft.has(paste.id) ? <Text color={t.color.label}> · in draft</Text> : ''}
|
||||
{' · '}
|
||||
<Text color={t.color.cornsilk}>{compactPreview(paste.text, 44) || '(empty)'}</Text>
|
||||
</Text>
|
||||
))}
|
||||
{pastes.length > 4 && (
|
||||
<Text color={t.color.dim} dimColor>
|
||||
…and {pastes.length - 4} more
|
||||
</Text>
|
||||
)}
|
||||
<Text color={t.color.dim} dimColor>
|
||||
/paste mode {'<id>'} {'<attach|excerpt|inline>'} · /paste drop {'<id>'} · /paste clear
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -41,12 +41,12 @@ interface Props {
|
|||
value: string
|
||||
onChange: (v: string) => void
|
||||
onSubmit?: (v: string) => void
|
||||
onLargePaste?: (text: string) => string
|
||||
onPaste?: (data: { cursor: number; text: string; value: string }) => { cursor: number; value: string } | null
|
||||
placeholder?: string
|
||||
focus?: boolean
|
||||
}
|
||||
|
||||
export function TextInput({ value, onChange, onSubmit, onLargePaste, placeholder = '', focus = true }: Props) {
|
||||
export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '', focus = true }: Props) {
|
||||
const [cur, setCur] = useState(value.length)
|
||||
const vRef = useRef(value)
|
||||
const selfChange = useRef(false)
|
||||
|
|
@ -74,22 +74,21 @@ export function TextInput({ value, onChange, onSubmit, onLargePaste, placeholder
|
|||
}
|
||||
|
||||
const v = vRef.current
|
||||
const handled = onPaste?.({ cursor: at, text: pasted, value: v })
|
||||
|
||||
if (pasted.split('\n').length >= 5 || pasted.length > 500) {
|
||||
const ph = onLargePaste?.(pasted) ?? pasted.replace(/\n/g, ' ')
|
||||
const nv = v.slice(0, at) + ph + v.slice(at)
|
||||
if (handled) {
|
||||
selfChange.current = true
|
||||
onChange(handled.value)
|
||||
setCur(handled.cursor)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (pasted.length && PRINTABLE.test(pasted)) {
|
||||
const nv = v.slice(0, at) + pasted + v.slice(at)
|
||||
selfChange.current = true
|
||||
onChange(nv)
|
||||
setCur(at + ph.length)
|
||||
} else {
|
||||
const clean = pasted.replace(/\n/g, ' ')
|
||||
|
||||
if (clean.length && PRINTABLE.test(clean)) {
|
||||
const nv = v.slice(0, at) + clean + v.slice(at)
|
||||
selfChange.current = true
|
||||
onChange(nv)
|
||||
setCur(at + clean.length)
|
||||
}
|
||||
setCur(at + pasted.length)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,21 +1,27 @@
|
|||
import { Text } from 'ink'
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
import spinners, { type BrailleSpinnerName } from 'unicode-animations'
|
||||
|
||||
import { FACES, SPINNER, TOOL_VERBS, VERBS } from '../constants.js'
|
||||
import { pick } from '../lib/text.js'
|
||||
import { FACES, TOOL_VERBS, VERBS } from '../constants.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
import type { ActiveTool } from '../types.js'
|
||||
|
||||
function Spinner({ color }: { color: string }) {
|
||||
const THINK_POOL: BrailleSpinnerName[] = ['helix', 'breathe', 'orbit', 'dna', 'waverows', 'snake', 'pulse']
|
||||
const TOOL_POOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep', 'rain', 'columns', 'sparkle']
|
||||
|
||||
const pick = <T,>(arr: T[]) => arr[Math.floor(Math.random() * arr.length)]!
|
||||
|
||||
function Spinner({ color, variant = 'think' }: { color: string; variant?: 'think' | 'tool' }) {
|
||||
const [spin] = useState(() => spinners[pick(variant === 'tool' ? TOOL_POOL : THINK_POOL)])
|
||||
const [i, setI] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setI(p => (p + 1) % SPINNER.length), 80)
|
||||
const id = setInterval(() => setI(p => (p + 1) % spin.frames.length), spin.interval)
|
||||
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
}, [spin])
|
||||
|
||||
return <Text color={color}>{SPINNER[i]}</Text>
|
||||
return <Text color={color}>{spin.frames[i]}</Text>
|
||||
}
|
||||
|
||||
export const Thinking = memo(function Thinking({
|
||||
|
|
@ -27,25 +33,25 @@ export const Thinking = memo(function Thinking({
|
|||
t: Theme
|
||||
tools: ActiveTool[]
|
||||
}) {
|
||||
const [verb, setVerb] = useState(() => pick(VERBS))
|
||||
const [face, setFace] = useState(() => pick(FACES))
|
||||
const [tick, setTick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
setVerb(pick(VERBS))
|
||||
setFace(pick(FACES))
|
||||
setTick(v => v + 1)
|
||||
}, 1100)
|
||||
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
const verb = VERBS[tick % VERBS.length] ?? 'thinking'
|
||||
const face = FACES[tick % FACES.length] ?? '(•_•)'
|
||||
const tail = reasoning.slice(-160).replace(/\n/g, ' ')
|
||||
|
||||
return (
|
||||
<>
|
||||
{tools.map(tool => (
|
||||
<Text color={t.color.dim} key={tool.id}>
|
||||
<Spinner color={t.color.amber} /> {TOOL_VERBS[tool.name] ?? tool.name}
|
||||
<Spinner color={t.color.amber} variant="tool" /> {TOOL_VERBS[tool.name] ?? tool.name}
|
||||
{tool.context ? `: ${tool.context}` : ''}
|
||||
</Text>
|
||||
))}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue