▚StepListcustom
Session plan tracker: pending → running → done with live spinner
$ 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
| prop | type | default | description |
|---|---|---|---|
| title | string | - | Header row inside the panel |
| steps | { label, state, detail?, indent? }[] | [] | Plan rows |
| color | string | "#a5d98f" | Running spinner color |
| colors | Partial<NeoColors> | NEO | Bevel palette |
##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.
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";56export type StepState = "pending" | "running" | "done" | "error" | "skipped";78export interface Step {9 label: string;10 state?: StepState;11 detail?: string;12 indent?: boolean;13}1415export interface StepListProps {16 title?: string;17 steps?: Step[];18 color?: string;19 colors?: Partial<NeoColors>;20}2122export 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);3132 useEffect(() => {33 if (!running) return;34 const timer = setInterval(() => setFrame((f) => (f + 1) % SPIN.length), 100);35 return () => clearInterval(timer);36 }, [running]);3738 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-deps64 }, [title, steps, frame, color, colors, running]);6566 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