feat: cute spinners

This commit is contained in:
Brooklyn Nicholson 2026-04-08 13:45:34 -05:00
parent b50d81f212
commit af0f4a52fe
11 changed files with 1429 additions and 240 deletions

File diff suppressed because it is too large Load diff

View 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>
)
}

View file

@ -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}>

View 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>
)
}

View file

@ -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)
}
}

View file

@ -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>
))}

View file

@ -60,8 +60,6 @@ export const ROLE: Record<Role, (t: Theme) => { body: string; glyph: string; pre
user: t => ({ body: t.color.label, glyph: t.brand.prompt, prefix: t.color.label })
}
export const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
export const TOOL_VERBS: Record<string, string> = {
browser: '🌐 browsing',
clarify: '❓ asking',

View file

@ -4,6 +4,12 @@ export interface ActiveTool {
context?: string
}
export interface ActivityItem {
id: number
text: string
tone: 'error' | 'info' | 'warn'
}
export interface ApprovalReq {
command: string
description: string
@ -53,6 +59,19 @@ export interface SecretReq {
requestId: string
}
export type PasteKind = 'code' | 'log' | 'text'
export type PasteMode = 'attach' | 'excerpt' | 'inline'
export interface PendingPaste {
charCount: number
createdAt: number
id: number
kind: PasteKind
lineCount: number
mode: PasteMode
text: string
}
/** From `commands.catalog` — mirrors hermes_cli.commands COMMANDS + SUBCOMMANDS + skills. */
export interface SlashCatalog {
canon: Record<string, string>