▚AgentSpinnercustom
Neomorphic status line with spinner, sub-task and elapsed timer
$ termino agent-spinner — neomorphism bevel
╭──────────────────────────────────────────────────────────╮
│⠋ reading workspace src/ · app/ · lib/ · tests/ ⏱ 00:00│
╰──────────────────────────────────────────────────────────╯
live phase cycles every 3s — spinner + elapsed timer
##API
Composition-only. Renders a single beveled panel (light top/left edge, dark bottom/right) around a spinner glyph, label, optional sub text and live ⏱ timer. The `neo.ts` helpers (NEO palette, neoPanel, neoInset, neoFill) power all the agents group components.
properties
| prop | type | default | description |
|---|---|---|---|
| label | string | "thinking" | Status text |
| sub | string | - | Dimmed secondary text |
| running | boolean | true | Rotate frames + count time |
| color | string | "#a5d98f" | Spinner color |
| elapsed | boolean | true | Show ⏱ mm:ss |
| frames | string[] | braille spinner | Custom frame set |
| colors | Partial<NeoColors> | NEO | Override bevel palette |
##Keymap
—Display only
running propDrives the frame rotation + elapsed timer
##Notes
- ▸Neomorphism in a terminal = 2-tone bevel: ╭ top/left light (light source), ╰ bottom/right dark (shadow).
- ▸The same bevel language is shared by StatusBar, ToolCall, ApprovalPrompt, TokenMeter, StepList.
##Source
Real implementation — this is the code that runs in your terminal.
1import { createElement as h, useEffect, useMemo, useRef, useState } from "react";2import { Canvas } from "./canvas";3import { cellsWidth, type CursorCell } from "./chart";4import { NEO, SPIN, neoPanel, type NeoColors } from "./neo";56export interface AgentSpinnerProps {7 label?: string;8 sub?: string;9 running?: boolean;10 color?: string;11 elapsed?: boolean;12 frames?: readonly string[];13 colors?: Partial<NeoColors>;14}1516function useTicker(active: boolean, ms: number, fn: () => void) {17 useEffect(() => {18 if (!active) return;19 const timer = setInterval(fn, ms);20 return () => clearInterval(timer);21 }, [active, ms]); // eslint-disable-line react-hooks/exhaustive-deps22}2324function useElapsed(active: boolean) {25 const [secs, setSecs] = useState(0);26 const wasActive = useRef(false);27 useTicker(active, 1000, () => {28 if (!wasActive.current) setSecs(0);29 wasActive.current = true;30 setSecs((s) => s + 1);31 });32 useEffect(() => {33 if (!active) wasActive.current = false;34 }, [active]);35 const mm = String(Math.floor(secs / 60)).padStart(2, "0");36 const ss = String(secs % 60).padStart(2, "0");37 return `⏱ ${mm}:${ss}`;38}3940export function AgentSpinner({41 label = "thinking",42 sub,43 running = true,44 color = "#a5d98f",45 elapsed = true,46 frames = SPIN,47 colors,48}: Readonly<AgentSpinnerProps>) {49 const c = { ...NEO, ...colors };50 const [frame, setFrame] = useState(0);51 const active = running && frames.length > 1;52 useTicker(active, 100, () => setFrame((f) => (f + 1) % frames.length));53 const time = useElapsed(running && elapsed);5455 const rows = useMemo(() => {56 const inner: CursorCell[][] = [];57 const row: CursorCell[] = [58 {59 ch: (running ? frames[frame % frames.length] : "•") ?? "•",60 fg: running ? color : c.dim,61 },62 { ch: " ", fg: c.text },63 { ch: label, fg: running ? c.text : c.dim },64 ];65 if (sub) row.push({ ch: ` ${sub}`, fg: c.dim });66 if (running && elapsed) row.push({ ch: ` ${time}`, fg: c.dim });67 inner.push(row);68 return inner;69 // eslint-disable-next-line react-hooks/exhaustive-deps70 }, [running, frame, label, sub, time, elapsed, color, colors, frames]);7172 const panel = neoPanel(rows, c);73 const width = cellsWidth(panel[0] ?? []);74 return h("box", { flexDirection: "column", gap: 0, width }, h(Canvas, { rows: panel, width }));75}76