termino
~/docs/custom/agent-spinner

AgentSpinnercustom

Neomorphic status line with spinner, sub-task and elapsed timer

~/custom/agent-spinner
live
$ 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
proptypedefaultdescription
labelstring"thinking"Status text
substring-Dimmed secondary text
runningbooleantrueRotate frames + count time
colorstring"#a5d98f"Spinner color
elapsedbooleantrueShow ⏱ mm:ss
framesstring[]braille spinnerCustom frame set
colorsPartial<NeoColors>NEOOverride bevel palette

##Keymap

~/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.

tsxagent-spinner.tsx
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";
5
6export 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}
15
16function 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-deps
22}
23
24function 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}
39
40export 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);
54
55 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-deps
70 }, [running, frame, label, sub, time, elapsed, color, colors, frames]);
71
72 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