termino
~/docs/custom/step-list

StepListcustom

Session plan tracker: pending → running → done with live spinner

~/custom/step-list
live
$ termino step-list — session plan tracker
─────────────────────────────────────────────
session plan
clone repo 42ms
install dependencies
scaffold layout
wire theme switcher
build components
run lint + tests
─────────────────────────────────────────────
live steps roll through pending → running → done

##API

Beveled list container with optional title row. Each step: state glyph (· pending, braille spinner running, ✓ green, ✗ red, – skipped), label, right-aligned detail. `indent` adds a sub-step gutter.

properties
proptypedefaultdescription
titlestring-Header row inside the panel
steps{ label, state, detail?, indent? }[][]Plan rows
colorstring"#a5d98f"Running spinner color
colorsPartial<NeoColors>NEOBevel palette

##Keymap

~/keymap
Display only

##Notes

  • Drive states from your task runner — the only moving part is the running row's spinner.
  • A full plan read as beveled steps is the agentic-pairing of ProgressBar.

##Source

Real implementation — this is the code that runs in your terminal.

tsxstep-list.tsx
1import { createElement as h, useEffect, useMemo, useState } from "react";
2import { Canvas } from "./canvas";
3import { cellsWidth } from "./chart";
4import { NEO, SPIN, neoPanel, stateGlyph, type NeoColors } from "./neo";
5
6export type StepState = "pending" | "running" | "done" | "error" | "skipped";
7
8export interface Step {
9 label: string;
10 state?: StepState;
11 detail?: string;
12 indent?: boolean;
13}
14
15export interface StepListProps {
16 title?: string;
17 steps?: Step[];
18 color?: string;
19 colors?: Partial<NeoColors>;
20}
21
22export function StepList({
23 title,
24 steps = [],
25 color = "#a5d98f",
26 colors,
27}: Readonly<StepListProps>) {
28 const c = { ...NEO, ...colors };
29 const running = steps.some((s) => s.state === "running");
30 const [frame, setFrame] = useState(0);
31
32 useEffect(() => {
33 if (!running) return;
34 const timer = setInterval(() => setFrame((f) => (f + 1) % SPIN.length), 100);
35 return () => clearInterval(timer);
36 }, [running]);
37
38 const rows = useMemo(() => {
39 const spinning = { ch: SPIN[frame % SPIN.length] ?? SPIN[0], fg: color };
40 const glyph = (s: Step) => stateGlyph(s.state ?? "pending", spinning, c);
41 const inner: { ch: string; fg: string; bg?: string }[][] = [];
42 if (title) {
43 inner.push([{ ch: `${title}`, fg: c.text }]);
44 }
45 for (const s of steps) {
46 const g = glyph(s);
47 const row: { ch: string; fg: string; bg?: string }[] = [
48 { ch: s.indent ? " " : " ", fg: c.text },
49 g,
50 { ch: ` ${s.label}`, fg: s.state === "pending" ? c.dim : c.text },
51 ];
52 if (s.detail) {
53 let room = 50 - cellsWidth(row) - s.detail.length - 2;
54 while (room > 0) {
55 row.push({ ch: " ", fg: c.text });
56 room--;
57 }
58 row.push({ ch: ` ${s.detail}`, fg: c.dim });
59 }
60 inner.push(row);
61 }
62 return inner;
63 // eslint-disable-next-line react-hooks/exhaustive-deps
64 }, [title, steps, frame, color, colors, running]);
65
66 const panel = neoPanel(rows, c);
67 const width = cellsWidth(panel[0] ?? []);
68 return h("box", { flexDirection: "column", gap: 0, width }, h(Canvas, { rows: panel, width }));
69}
70