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

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