SLOPSHOPPER

LitClaude

Claude Code-native workflow plugin with a bounded-authority start-work lifecycle.

panebandspinnerguardcommandtoast
★ 3v1.0.40MITupdated 2026-10-26wjgoarxiv/litclaude/plugins/litclaude
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · litclaude
│ ┃ LIT evidence ✕ › fix the failing auth test and add an audit log call │ ┃ 1: goal 2: lanes 3: gates │ ┃ No litgoal goal in this project for this se… ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /lit-evidence │ │ ▣ client module ./lit-band-client.mjs ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▣ client module ./lit-band-client.mjs ⟨Claude Code's own drawing⟩
Pane · LIT evidence
1: goal 2: lanes 3: gates No litgoal goal in this project for this session.
README

<h1 align="center">LitClaude</h1>

<a href="https://www.npmjs.com/package/@litfamily/litclaude"><img src="https://img.shields.io/npm/v/@litfamily/litclaude?color=FF6337&amp;label=npm" alt="npm version" /></a> <a href="https://www.npmjs.com/package/@litfamily/litclaude"><img src="https://img.shields.io/npm/dm/@litfamily/litclaude?color=D7F75B" alt="npm downloads per month" /></a> <a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license" /></a> <a href="https://github.com/wjgoarxiv/litclaude/stargazers"><img src="https://img.shields.io/github/stars/wjgoarxiv/litclaude?style=flat" alt="GitHub stars" /></a>

Add lit to a Claude Code prompt and Claude pins the goal, writes a failing test first, checks the real result and writes down what it found. Type handoff when you stop, and the next session starts from that note instead of from scratch. To try it, install once and give Claude a task:

npx @litfamily/litclaude@latest install   # then open a new Claude Code session
lit fix the date parsing bug in src/dates.js, with a test

<!-- demo-gif --><p align="center"><img src="./docs/assets/demo.webp" width="960" alt="The film opens on a title card with the LIT mark, LitClaude and The lit workflow for Claude Code, then cuts to a Claude Code session in a small Node project with LitClaude installed, where a numbered two-line caption names each step. The prompt &quot;lit add a --shout flag that prints the greeting in capitals, with a test&quot; is sent, and the hook answers with the LIT mark and LIT IGNITED · lit-loop. The LIT card above the prompt switches from ready to lit-loop and draws its workflow map, PIN, RED, GREEN, VERIFY, REVIEW and RECORD, with PIN lit. Claude appends the new test through the shell and runs it; the test fails and the map turns RED. While Claude changes greet.js, the live step on the card names the running tool and how long it has run, Bash · 0:00. When the command finishes the tests pass, and the map checks GREEN and lights VERIFY. The live step clears, the card goes idle, and the film ends on the LitClaude mark and the install command npx @litfamily/litclaude@latest install." /></p>

What you can make

Every picture below is a real output of LitClaude. Where a prompt sits under a picture, that request produced it in a Claude Code session (long ones are cut short with …); where none does, the skill's own engine made it from the example the skill ships with. None of them is a mock-up, so typing the same line with LitClaude installed gets you the same kind of result. The skills that make things aim for a full, dense result by default; say calm or minimal and they hold back.

Draw it

Equipment, systems and data, each drawn by the skill that knows its rules: ISA-5.1 tags and a title block on a P&ID, material colours on a cutaway, numbered flows on an architecture, error bands on a figure.

<table> <tr><td width="50%" valign="top"><img src="./docs/assets/showcase/pid-column.webp" width="400" alt="P&amp;ID of a distillation column overhead section: column, shell-and-tube condenser, reflux drum, two reflux pumps, ISA instrument bubbles, a legend column on the right and a title block at the bottom." /><br /><b>lit-schematic</b> · P&amp;ID on one A3 sheet<br /><code>lit draw a detailed P&amp;ID of a distillation column overhead section on one A3 sheet: shell-and-tube overhead condenser, reflux drum, duty and standby reflux pumps, LIC to FIC cascade …</code></td><td width="50%" valign="top"><img src="./docs/assets/showcase/cutaway-reactor.webp" width="400" alt="Isometric cutaway of a jacketed stirred batch reactor in material colours: motor on top, steel shell cut open to show the blue liquid, impeller and coil, green legs on a concrete pad, Korean callouts on both sides." /><br /><b>lit-schematic</b> · isometric cutaway<br /><code>lit-schematic 교반식 재킷 회분 반응기(jacketed stirred batch reactor)의 등각 단면 사시도를 그려줘</code></td></tr> <tr><td width="50%" valign="top"><img src="./docs/assets/showcase/architecture.webp" width="400" alt="Korean architecture diagram of a food delivery platform: client apps, an API gateway, order, payment, dispatch and tracking services, Kafka, PostgreSQL and Redis, with numbered request flows in five colours and a legend." /><br /><b>lit-diagram-drawer</b> · system architecture<br /><code>lit-diagram-drawer 음식 배달 플랫폼의 시스템 아키텍처 다이어그램을 그려줘</code></td><td width="50%" valign="top"><img src="./docs/assets/showcase/isotherm-figure.webp" width="400" alt="Two-panel figure: (a) CO2 uptake against pressure at 273, 298 and 323 K with replicate markers, Langmuir fits and an inset; (b) isosteric heat against loading with a 95% interval band." /><br /><b>lit-scientific-visualization</b> · two-panel journal figure<br /><code>/litclaude:lit-scientific-visualization 현재 폴더의 data.csv(세 온도에서 측정한 CO2 흡착 등온선, 반복 2회)를 논문용 그림으로 만들어줘</code></td></tr> <tr><td width="50%" valign="top"><img src="./docs/assets/showcase/optics-bench.webp" width="400" alt="Isometric optical bench: a He-Ne laser, beam expander, two mirrors, a beam splitter, a lens, the sample and two photodetectors, with the orange beam path and labelled callouts." /><br /><b>lit-schematic</b> · apparatus figure<br /><code>lit 레이저 광학 벤치 실험 장치를 저널 double column 그림 한 장으로 그려줘 …</code></td><td width="50%" valign="top"><img src="./docs/assets/showcase/cutaway-loop.webp" width="400" alt="Animated cutaway of a stirred autoclave: the parts assemble, the impeller turns and coolant flows through the internal coil." /><br /><b>lit-schematic</b> · cutaway as a loop<br /><code>lit … 오토클레이브 컷어웨이를 짧은 루프 모션 영상으로 만들어줘: 분해된 부품이 조립되고, 임펠러가 돌고, 냉각수가 코일을 따라 흐르게</code></td></tr> </table>

Present it

Name a style, or let lit-pptx choose one from thirteen. Every deck has native, editable charts and KPI rows, Pretendard embedded, and passed a gate for overflow, contrast and empty slides.

<table> <tr><td width="50%" valign="top"><img src="./docs/assets/showcase/deck-newsroom.webp" width="400" alt="Contact sheet of a ten-slide newsroom-style deck: black masthead bands, an orange data series, bar and line charts and big figures." /><br /><b>lit-pptx</b> · newsroom, 10 slides<br /><code>lit 우리 구 주민에게 보여줄 2026년 대중교통 이용 데이터 브리핑 발표자료 10장, 뉴스룸 스타일로</code></td><td width="50%" valign="top"><img src="./docs/assets/showcase/deck-neo.webp" width="400" alt="Contact sheet of a ten-slide neo-brutalism pitch deck: blue and yellow blocks, thick outlines, hard shadows and large bold numbers." /><br /><b>lit-pptx</b> · neo-brutalism pitch<br /><code>lit 신규 서비스 투자 피칭 덱 10장, 네오 브루탈리즘 스타일로. 글자와 숫자는 크고 굵게</code></td></tr> <tr><td width="50%" valign="top"><img src="./docs/assets/showcase/deck-themes.webp" width="400" alt="Grid of the same deck in thirteen tonalities, one row per tonality, from light paper styles to dark night and teal themes." /><br /><b>lit-pptx</b> · one deck, 13 tonalities<br /><sub>The same sample deck rendered by the engine in all thirteen tonalities, from paper and newsroom to riso, night and ledger.</sub></td></tr> </table>

Write it

lit-docx writes a Word file you can keep editing: a cover, key figures with their basis, tables with right-aligned numbers and a decision box, then renders the pages and looks at them before it hands the file over.

<table> <tr><td width="50%" valign="top"><img src="./docs/assets/showcase/docx-report.webp" width="400" alt="Three pages of a Korean operations report: a title page with three key figures and an approval box, then pages with ruled tables and numbered sections." /><br /><b>lit-docx</b> · Korean report, Report tonality<br /><sub>Built from the skill's bundled example <code>references/examples/01-report-ko.md</code> by its own converter; the layout gate passed.</sub></td><td width="50%" valign="top"><img src="./docs/assets/showcase/docx-mix.webp" width="400" alt="Pages from an English quarterly support report and a Korean pilot-project proposal, each with its own cover treatment." /><br /><b>lit-docx</b> · English report and a proposal<br /><sub>Two more bundled examples: <code>07-report-en.md</code> (Report) and <code>04-proposal-ko.md</code> (Proposal).</sub></td></tr> </table>

Build it

frontend-ui-ux locks a house style before it writes markup, builds the page, then opens it at several widths and reviews its own screenshots. readme-studio does the same for a project cover.

<table> <tr><td width="50%" valign="top"><img src="./docs/assets/showcase/ui-dashboard.webp" width="400" alt="Coldline operations dashboard: four figure cards (on-time rate 91.7%, five temperature excursions, trucks on the road, deliveries), a planned-versus-delivered bar chart with a now marker and an excursion list." /><br /><b>frontend-ui-ux</b> · operations dashboard<br /><code>Build the operations dashboard for Coldline, a regional delivery company that runs a fleet of refrigerated trucks. …</code></td><td width="50%" valign="top"><img src="./docs/assets/showcase/ui-landing-neo.webp" width="400" alt="Ledgerline landing page on lavender: a huge black headline, yellow buttons, and an invoice card and a fee-budget card with thick outlines and hard shadows." /><br /><b>frontend-ui-ux</b> · landing page, neo-brutalism<br /><code>Build the marketing landing page for Ledgerline … Make it neo-brutalism: loud, friendly, bold outlines and hard shadows.</code></td></tr> <tr><td width="50%" valign="top"><img src="./docs/assets/showcase/ui-landing-brutalist.webp" width="400" alt="Ledgerline landing page as a spec sheet: a grid background, a stacked capital headline, and a project monitor panel reading 115% over budget." /><br /><b>frontend-ui-ux</b> · landing page, computational brutalism<br /><code>Build the marketing landing page for Ledgerline … Make it retro-futurist computational brutalism: raw, mechanical, like an instrument or a spec sheet.</code></td><td width="50%" valign="top"><img src="./docs/assets/showcase/readme-cover.webp" width="400" alt="Dark README cover for a sample project, hydrate-kit: a label line, a large outlined title, an orange rule and a subtitle over a blurred reactor drawing, with a pixel notebook motif on the right." /><br /><b>readme-studio</b> · README cover<br /><sub>The skill's HyperFrames cover template with every letter outlined by its own type tool, for a sample project.</sub></td></tr> </table>

Film it

lit-typographic-motion writes a treatment first, then renders the film frame by frame with a sound bed and checks flashes, legibility and the loop seam. The prompt below names no tool and never asks for a dense look; the layering, colour and sound came from the defaults.

<table> <tr><td width="50%" valign="top"><img src="./docs/assets/showcase/film-hero.webp" width="400" alt="Poster frame of the film: a glowing flame keycap on a dark teal field of keys, the word LitClaude, and a stage bar reading PROMPT, PLAN, WORK 2/2, REVIEW and DONE." /><br /><b>lit-typographic-motion</b> · 13-second hero film, poster<br /><code>lit LitClaude 히어로 영상 13초짜리 만들어줘. LIT 불꽃 키가 주인공으로, 프롬프트 → 계획 → 작업 → 리뷰 → 완료로 lit-loop가 돌아가는 세계를 고퀄리티 3D로 보여줘.</code></td><td width="50%" valign="top"><img src="./docs/assets/showcase/film-frames.webp" width="400" alt="Twelve frames of the hero film in a grid: the keycap lights up, Korean captions for each stage appear, and the last row lands on the LitClaude title." /><br /><b>lit-typographic-motion</b> · frames from the same film<br /><sub>Twelve frames of that film, from the dark opening to the LitClaude title.</sub></td></tr> </table>

Keep track of it

When two workflows and three helpers run at once, the LIT card draws them as threads under the prompt. Type <code>lit adhd</code> after a scattered day and you get one brief: what you asked, what got done, what is paused and the next step. <code>go</code> then picks up the single open plan.

<table> <tr><td width="50%" valign="top"><img src="./docs/assets/card/threads.webp" width="400" alt="The LIT card with thread rows: lit-loop paused at GREEN 2/5, start-work running at 3/6 beneath it, and a fork into executor, librarian-researcher and lit-verifier lanes." /><br /><b>LIT card</b> · thread rows<br /><sub>Drawn by the card's own renderer: lit-loop paused at GREEN, start-work running under it, three lanes in parallel.</sub></td><td width="50%" valign="top"><img src="./docs/assets/showcase/adhd-brief.webp" width="400" alt="Terminal with the lit-adhd brief: a headline with four sessions and the next step, one card per active session with its last ask, finished work and commit, a roadmap with two commits and the clamp plan at 0/6, and a boxed recommended next step, with the rainbow REWIND card below." /><br /><b>lit-adhd</b> · the brief<br /><code>lit adhd</code></td></tr> <tr><td width="50%" valign="top"><img src="./docs/assets/showcase/adhd-go.webp" width="400" alt="Terminal after typing go: start-work runs the open clamp plan, the tests pass, and the card shows start-work at review." /><br /><b>lit-adhd</b> · go resumes the plan<br /><code>go</code></td></tr> </table>

Learn from others

Point <code>lit hijack</code> at an article, a post, a PDF or a repository and name your own skill. It records where each source came from and what its licence allows, writes a spec in its own words, has it built in isolation, runs the old and new skill on the same held-out prompt, and ends with a port report.

<table> <tr><td width="50%" valign="top"><img src="./docs/assets/showcase/hijack-report.webp" width="400" alt="The port report: five mechanisms taken with their parity results (2/2 new runs against 0/2 old), six left with reasons, a provenance table marking the source ideas-only, and exposure, credit and review-by sections." /><br /><b>lit-hijack</b> · port report<br /><code>lit hijack https://x.com/… into .claude/skills/promo-brief</code></td><td width="50%" valign="top"><img src="./docs/assets/showcase/hijack-ab.webp" width="400" alt="Contact sheet of four promo briefs side by side, two from the old skill and two from the new one, with the new briefs carrying takeaway, real assets, timed scenes and reject-if sections." /><br /><b>lit-hijack</b> · old skill against new<br /><sub>The proof: two runs of the old skill beside two of the new one on the same prompt. The old briefs invent product features; the new ones ask instead.</sub></td></tr> </table>

Why LitClaude

You ask Claude Code for something: a bug fixed, a screen built, a project finished. Starting takes one sentence. The trouble comes later. The conversation grows, the context gets compacted, the session ends, and next time you have to work out where you left off: what you decided, what you checked, what comes next. And "done" often means the model said so.

LitClaude gives each session a routine to follow. Add lit to a request and Claude pins the goal, writes a failing test first, checks the real thing it built, and records what it found. For a bigger change, lit plan writes a plan you approve before anything is edited. When you stop, a handoff file keeps the goal, the results and the next step in your project, where the next session can read them. Claude Code still runs the session and picks the model, and LitClaude adds the way of working on top.

In one line, for a friend: put lit in front of a Claude Code task and it writes the failing test first, checks the result, and leaves tomorrow's session a note.

Who it's for

LitClaude suits people who already use Claude Code on real projects and want two things from it: proof before "done", and a session that can pick up where the last one stopped. It helps most on work that runs longer than one sitting, such as a feature with tests, a refactor or a research question. It also builds slides and Word reports and cleans up stiff Korean or English prose, so it is useful outside code as well.

What a session looks like

This is the demo above in three lines. You type the first one, LitClaude's prompt hook answers with the second, and the card above the prompt walks the third as the work happens:

lit add a --shout flag that prints the greeting in capitals, with a test
LIT IGNITED · lit-loop
PIN → RED (the new test fails) → GREEN (the tests pass) → VERIFY

Install

You need Node.js with npm, and Claude Code. Run this in any terminal:

npx @litfamily/litclaude@latest install

The first time, npx asks whether to install the package; answer y. The installer registers the plugin, adds the LitClaude status line, and asks you two things: the status-line colour and an optional output style. A new install starts in the safe permission mode, which adds no permission rules, and leaves the rest of your Claude settings as they are. It also downloads the rendering engine and fonts the video skill needs, so the first install takes a little longer. Quit Claude Code and start it again afterwards so the plugin loads.

Two switches change how the install runs:

  • --yes after install keeps the default colour and style, for when you would rather not answer questions.
  • --dry-run before install prints every change and makes none, for when you want to look first: npx @litfamily/litclaude@latest --dry-run install.

Permission modes, version pins, global installs and every other switch.

If you only want the plugin, without the status line or the installer, Claude Code can add it from the LitFamily marketplace:

/plugin marketplace add wjgoarxiv/litfamily
/plugin install litclaude@litfamily

Pick one of the two routes. Enabling the plugin both ways loads its hooks twice. Choosing between the npm and marketplace routes.

You can also hand the install to a coding agent. Paste this prompt into it; the agent checks your setup, shows you what would change and waits for your yes before it installs anything:

Please install LitClaude, a plugin for Claude Code, on this computer.

- Use only the official npm package @litfamily/litclaude. Do not clone repositories or
  install anything else.
- Before you change anything, check that Node.js, npm and Claude Code are installed, then run
  npx @litfamily/litclaude@latest --dry-run install --yes
  Show me what it would change and ask whether to go ahead. Wait for my answer.
- When I say yes, run npx @litfamily/litclaude@latest install --yes
  (--yes keeps the default status-line colour and output style; I can run the install again
  in my own terminal later to pick them.)
- Then run npx @litfamily/litclaude@latest doctor and tell me the result. It should end in
  DOCTOR_PASS. If it does not, show me the lines that failed and stop there.
- Finally, remind me to quit Claude Code and start it again so the plugin loads.

Quick start

Start Claude Code in a project folder the usual way, then type lit:

claude
lit

You will see the activation notice from the picture above. Now hand it one small task:

Build a to-do list in a single HTML file with no external dependencies.
Implement add, complete, and delete. Record what you checked and the next step.

Claude's reply opens with 🔥 LIT IGNITED · lit-loop 🔥, works through the goal and its checks, and ends with what it checked and what comes next. Then open the HTML yourself and click through all three actions; that is the only way to know the buttons work. If Claude had no browser to test with, ask it to mark the look and the clicks as unverified. Pictures of the installer, doctor and status line.

When you stop for the day, type handoff and keep the file path it gives you. In the next session, open the same project and ask Claude to read that file, check the current state and name the next step.

The LIT card

While you work, LitClaude keeps a small card just above the prompt. It shows where you are in the LIT workflow, what Claude is doing at this moment and which skills it has used, so you can follow Claude without scrolling back through the conversation.

Read it from top to bottom:

  • The top edge names the workflow and its stage.
  • The first row is a map of that workflow. A request runs from the interview through the plan, the work and the review to done, a research run goes wave by wave, and a lit-loop criterion steps from PIN through RED and GREEN to RECORD. Stages behind you carry a check and the one you are in is lit. A request that fits none of these keeps the simpler PLAN, WORK and REVIEW track.
  • The second row says more about that stage: the task count and the next task, the review lanes that have passed, or the interview round. On its right sits the tool Claude is running and for how long. Only the tool's name appears there; what the tool was given stays out.
  • The third row holds a chip for each LitFamily skill that fired.
  • The bottom edge repeats the context, the five-hour and weekly usage and the Git branch from the status line.

Before anything runs, the card rests and tells you how to begin:

Give it a request with lit, and the card names the workflow, lights the stage it is in and a chip for the skill that took the request. LitClaude's own hooks move the map on as the work happens, when the plan is saved, a task is ticked or a reviewer reports, so it keeps up without Claude having to report each step:

While Claude is busy, the card does not sit still. The LIT pill slowly glows from pink to orange and back, a light passes over the stage it is in and over the skill chip, a bright point travels along the map toward the next stage, and a spark runs along the top edge and back along the bottom. A glance is enough to tell that LitClaude is still at work.

Real work rarely runs one thing

Source 16 files
hooks/lit-mod.mjs 509 lines
1// LitClaude's Claude Code mod (hooks/hooks.json `modules`). It draws the LIT card above the prompt
2// for the whole session (the `band` setting can limit it to active work or turn it off): where the
3// session stands in the LIT workflow, the tool the main loop is running and for how long, a chip
4// for each LitFamily skill that fires. It adds the discipline and progress to the spinner, raises
5// a toast when the LIT status changes (at most one each three seconds) and gate notices, serves /lit-evidence and, when the `notify` option asks, raises a notification
6// when LIT work completes or a long LIT turn ends. It is read-only: it reads LitClaude's own state
7// files and the Git HEAD through lib/mod/state.mjs, the settings for the status line's accent, and
8// the host's live figures, and never writes, spawns, fetches or calls a model. Of a tool call it
9// keeps the tool's name and start time, never its arguments, with three exceptions that keep counts
10// and names only: the statuses of a TodoWrite or a task-list call (each loop's k/n on the card), and
11// the task id and description a background Bash or Monitor call answers (a thread row until its
12// notification, which is read for the task's id and status alone).
13// Claude Code releases before mods ignore the `modules` key and keep running the settings hooks.
14//
15// The mods loader only follows `$` into functions declared at the top of this file, so each helper
16// below takes `$` and the per-load `live` record explicitly instead of closing over them.
17
18import { composeBand, composeEvidence } from "../lib/mod/frame.mjs";
19import { TIMINGS } from "../lib/mod/motion.mjs";
20import { renderBand, renderRows } from "../lib/mod/render.mjs";
21import { accentFromSettings, effectiveMotion, normalizeModSettings, resolveGlyphs } from "../lib/mod/settings.mjs";
22import { composeSvgCard } from "../lib/mod/svg-card.mjs";
23import { chipStates, litSkillId, noteSkill } from "../lib/mod/skills.mjs";
24import { TOAST_GAP_MS, gateToast, statusChanges, statusOf } from "../lib/mod/toasts.mjs";
25import { detectIconFont, readGitBranch, readLedgerTail, readLitSnapshot } from "../lib/mod/state.mjs";
26import { settledSample } from "../lib/mod/timeline.mjs";
27import {
28  EMPTY_ACTIVITY,
29  EMPTY_LISTS,
30  EMPTY_TASKS,
31  buildBandModel,
32  buildEvidenceModel,
33  detectCompletion,
34  diffNotices,
35  endBackgroundTask,
36  endedTaskIds,
37  formatDuration,
38  mergeSpawned,
39  noteAgentActivity,
40  noteBackgroundTask,
41  noteLoopCall,
42  noteSpawn,
43  noteSpawnActivity,
44  noteSpawnEnded,
45  spinnerSuffix,
46  trackAgents,
47} from "../lib/mod/view.mjs";
48
49const PANE = "lit-evidence";
50const POLL_MS = 2000;
51const QUICK_REFRESH_MS = 250;
52const TABS = Object.freeze([
53  { id: "goal", label: "goal", hotkey: "1" },
54  { id: "lanes", label: "lanes", hotkey: "2" },
55  { id: "gates", label: "gates", hotkey: "3" },
56]);
57const NO_SNAPSHOT = Object.freeze({ ignition: null, work: null, completedWorkIds: [], goal: null, autoloop: null, litPlan: null, probe: null, motion: null, handoff: null, workflow: null });
58// A LIT turn at least this long notifies when it ends, under `notify`.
59const LONG_TURN_MS = 3 * 60 * 1000;
60
61const createLive = (options) => ({
62  band: normalizeModSettings(options).band,
63  motion: normalizeModSettings(options).motion,
64  drawnOn: {},
65  iconFont: { present: false, path: null, note: "basic: not checked yet" },
66  env: {},
67  snapshot: null,
68  model: null,
69  agents: [],
70  spawned: {},
71  usage: null,
72  activity: EMPTY_ACTIVITY,
73  statusById: {},
74  isWorking: false,
75  notice: null,
76  completion: null,
77  status: null,
78  toast: { lastAt: Number.NEGATIVE_INFINITY, pending: [] },
79  toastWaiting: false,
80  exiting: false,
81  clientFailed: false,
82  refreshing: false,
83  quickPending: false,
84  paneOpen: false,
85  evidenceTab: "goal",
86  ledger: [],
87  celebrated: new Set(),
88  accent: undefined,
89  git: null,
90  fired: [],
91  settledAt: 0,
92  ignitionAt: undefined,
93  lastDiscipline: null,
94  step: null,
95  mainToolCalls: 0,
96  turnStartedAt: null,
97  turnNotified: false,
98  lists: EMPTY_LISTS,
99  tasks: EMPTY_TASKS,
100});
101
102const stateIo = ($) => ({
103  stat: (path) => $.fs.stat(path, { resolve: true }),
104  read: (path) => $.fs.read(path),
105  list: (path) => $.fs.list(path),
106});
107
108const noticeEntry = (raised) => ({ kind: "notice", text: `${raised.title}${raised.detail ? ` · ${raised.detail}` : ""}` });
109
110// Every toast passes the gate (toasts.mjs): one now, or the lot as one line once the gap is over.
111function raiseToasts($, live, entries, now) {
112  const { gate, show, waitMs } = gateToast(live.toast, entries, now);
113  live.toast = gate;
114  if (show) $.ui.toast(show);
115  if (waitMs === null || live.toastWaiting) return;
116  live.toastWaiting = true;
117  $.clock.after(waitMs, async () => {
118    live.toastWaiting = false;
119    raiseToasts($, live, [], Math.max(await $.clock.now(), live.toast.lastAt + TOAST_GAP_MS));
120  });
121}
122
123// The model less what moves with time alone: the step clock, the loop countdown, the lanes' run times.
124const stillModel = (model) => model && {
125  ...model,
126  step: model.step && { name: model.step.name },
127  autoloop: model.autoloop && { n: model.autoloop.n, max: model.autoloop.max },
128  lanes: { ...model.lanes, list: model.lanes.list.map(({ elapsedMs, ...lane }) => lane) },
129};
130
131// What the card would show on each surface still attached. Invalidation redraws every surface at
132// once, and the desktop's Svg is loaded afresh with each new source, restarting its animations; so
133// while a drawing surface is attached, time alone never redraws: it is compared by its drawing and
134// the terminal by the still model (its surface module counts the step clock on by itself). A
135// terminal on its own is compared by the whole model, so its countdown and lane times move too.
136function drawnKey(live, model) {
137  const kinds = new Set(Object.values(live.drawnOn));
138  if (kinds.size === 0) return JSON.stringify(model);
139  const withSvg = kinds.has("svg");
140  return [...kinds].map((kind) => (kind === "svg"
141    ? (model?.visible ? composeSvgCard(model, { motion: live.motion, accent: live.accent }).source : "hidden")
142    : JSON.stringify(withSvg ? stillModel(model) : model))).join("\n");
143}
144
145async function rebuild($, live) {
146  const now = await $.clock.now();
147  const previous = live.model;
148  const next = buildBandModel({
149    snapshot: live.snapshot ?? NO_SNAPSHOT,
150    agents: live.agents,
151    activity: live.activity,
152    usage: live.usage,
153    now,
154    band: live.band,
155    isWorking: live.isWorking,
156    notice: live.notice,
157    completion: live.completion,
158    skills: chipStates(live.fired, live.settledAt),
159    lastDiscipline: live.lastDiscipline,
160    git: live.git,
161    step: live.step,
162    lists: live.lists,
163    tasks: live.tasks,
164    // The thread rows stay reserved while threads are live, so the card does not shrink between repaints.
165    threadReserve: previous?.threads?.reserve ?? 0,
166  });
167  live.model = next;
168  // The status is compared from the first snapshot on, so what was already there is not announced.
169  if (live.snapshot) {
170    const status = statusOf({ discipline: live.lastDiscipline, model: next, snapshot: live.snapshot }, live.status);
171    const changes = statusChanges(live.status, status);
172    live.status = status;
173    if (changes.length > 0) raiseToasts($, live, changes, now);
174  }
175  if (previous?.visible && !next.visible && !live.exiting) {
176    // Keep the band mounted while its surface module plays the exit, then let it go.
177    live.exiting = true;
178    $.clock.after(TIMINGS.exit + 80, () => {
179      live.exiting = false;
180      $.ui.invalidate("ui.render");
181    });
182  }
183  if (drawnKey(live, previous) !== drawnKey(live, next)) $.ui.invalidate("ui.render");
184  return next;
185}
186
187function showNotice($, live, raised) {
188  live.notice = raised;
189  $.clock.after(TIMINGS.noticeHold, () => {
190    if (live.notice?.id !== raised.id) return;
191    live.notice = null;
192    void rebuild($, live);
193  });
194}
195
196// The opt-in finish notification. No sound file ships yet, so `finish+sound` notifies like `finish`.
197function notifyFinish($, options, text) {
198  if (normalizeModSettings(options).notify === "off") return;
199  $.ui.notify(text, { title: "LitClaude" }).catch(() => undefined);
200}
201
202function celebrate($, live, finish, options) {
203  if (live.celebrated.has(finish.id)) return;
204  live.celebrated.add(finish.id);
205  live.completion = finish;
206  live.turnNotified = true;
207  notifyFinish($, options, `LIT complete · ${finish.detail}`);
208  $.clock.after(TIMINGS.completion + 200, () => {
209    if (live.completion?.id !== finish.id) return;
210    live.completion = null;
211    void rebuild($, live);
212  });
213}
214
215// A firing of a skill or command by name; anything that is not LitClaude's is ignored.
216async function noteFiring($, live, name) {
217  const id = litSkillId(name);
218  if (!id) return;
219  live.fired = noteSkill(live.fired, id, await $.clock.now());
220  live.lastDiscipline = id;
221  await rebuild($, live);
222}
223
224// The prompt hook rewrites the ignition record on every prompt; a new record naming a discipline is
225// a bare `lit` (or slash) routing, and lights that skill's chip at the time the hook wrote it.
226function noteIgnition(live, ignition, now) {
227  if (!ignition || ignition.at === live.ignitionAt) return;
228  live.ignitionAt = ignition.at;
229  live.lastDiscipline = ignition.discipline;
230  const id = litSkillId(ignition.discipline);
231  if (id) live.fired = noteSkill(live.fired, id, ignition.at ?? now);
232}
233
234// What a finished call says of the thread rows: its loop's list counts, a background task it
235// started, or one it stopped. A refused or failed call starts nothing.
236function noteToolOutcome(live, e, outcome, now) {
237  if (!outcome || outcome.deny !== undefined) return;
238  const output = outcome.isError ? null : outcome.result;
239  live.lists = noteLoopCall(live.lists, typeof e.agentId === "string" ? e.agentId : "main", e, output);
240  if (!output || typeof output !== "object") return;
241  if (e.tool === "Bash" && typeof output.backgroundTaskId === "string") {
242    live.tasks = noteBackgroundTask(live.tasks, { id: output.backgroundTaskId, kind: "shell", name: e.description, at: now });
243  } else if (e.tool === "Monitor" && typeof output.taskId === "string") {
244    const timeout = Number.isFinite(output.timeoutMs) && output.timeoutMs > 0 ? output.timeoutMs : null;
245    live.tasks = noteBackgroundTask(live.tasks, { id: output.taskId, kind: "monitor", name: e.description, at: now, until: output.persistent || timeout === null ? null : now + timeout + 5000 });
246  } else if (e.tool === "TaskStop") {
247    live.tasks = endBackgroundTask(live.tasks, output.task_id ?? e.task_id ?? e.shell_id);
248  }
249}
250
251async function refresh($, live, options) {
252  if (live.refreshing) return;
253  live.refreshing = true;
254  try {
255    const [cwd, sessionId] = await Promise.all([$.session.cwd(), $.session.id()]);
256    const fresh = await readLitSnapshot(stateIo($), { cwd, sessionId, env: live.env });
257    live.git = await readGitBranch(stateIo($), cwd).catch(() => live.git);
258    const listed = await $.agent.list().catch(() => []);
259    live.usage = await $.session.usage().catch(() => live.usage);
260    const now = await $.clock.now();
261    ({ agents: live.agents, spawned: live.spawned } = mergeSpawned(listed, live.spawned, now));
262    live.activity = trackAgents(live.activity, live.agents, live.statusById, now);
263    live.statusById = Object.fromEntries(live.agents.map((agent) => [agent.id, agent.status]));
264    const before = live.snapshot;
265    const previousModel = live.model;
266    live.snapshot = fresh;
267    noteIgnition(live, fresh.ignition, now);
268    const finish = detectCompletion(before, fresh);
269    if (finish) celebrate($, live, finish, options);
270    const next = await rebuild($, live);
271    const raised = diffNotices(before, fresh, previousModel, next);
272    if (raised.length > 0) {
273      raiseToasts($, live, raised.map(noticeEntry), now);
274      showNotice($, live, raised.at(-1));
275      await rebuild($, live);
276    }
277    if (live.paneOpen) live.ledger = await readLedgerTail(stateIo($), fresh.stateRoot);
278  } catch {
279    // A failed read leaves the last good band in place; the next poll tries again.
280  } finally {
281    live.refreshing = false;
282  }
283}
284
285function refreshSoon($, live, options) {
286  if (live.quickPending) return;
287  live.quickPending = true;
288  $.clock.after(QUICK_REFRESH_MS, () => {
289    live.quickPending = false;
290    void refresh($, live, options);
291  });
292}
293
294export const register = (on, options) => {
295  const live = createLive(options);
296
297  on("session.start", async ($, e, next) => {
298    live.env = {
299      TMPDIR: await $.env.get("TMPDIR"),
300      LITCLAUDE_HUD_STATE_ROOT: await $.env.get("LITCLAUDE_HUD_STATE_ROOT"),
301      LITCLAUDE_AUTO_HANDOFF: await $.env.get("LITCLAUDE_AUTO_HANDOFF"),
302      LITCLAUDE_AUTO_HANDOFF_PERCENT: await $.env.get("LITCLAUDE_AUTO_HANDOFF_PERCENT"),
303    };
304    live.iconFont = await detectIconFont(stateIo($), {
305      HOME: await $.env.get("HOME"),
306      XDG_DATA_HOME: await $.env.get("XDG_DATA_HOME"),
307      LOCALAPPDATA: await $.env.get("LOCALAPPDATA"),
308    });
309    live.settledAt = await $.clock.now();
310    const hostSettings = await $.settings.read().catch(() => null);
311    live.accent = accentFromSettings(hostSettings, await $.env.get("LITCLAUDE_HUD_ACCENT"));
312    live.reducedMotion = hostSettings?.prefersReducedMotion === true;
313    live.motion = effectiveMotion(live.motion, hostSettings);
314    await $.command.register({ name: PANE, description: "Show LitClaude goal criteria, evidence, lanes and gates in a pane" });
315    await refresh($, live, options);
316    $.clock.every(POLL_MS, () => {
317      void refresh($, live, options);
318    });
319    return next(e);
320  });
321
322  on("turn.start", async ($, e, next) => {
323    live.isWorking = true;
324    live.mainToolCalls = 0;
325    live.turnStartedAt = await $.clock.now();
326    live.turnNotified = false;
327    refreshSoon($, live, options);
328    return next(e);
329  });
330
331  on("turn.complete", async ($, e, next) => {
332    // A subagent's or teammate's turn ends its run, not the main turn the card follows.
333    if (typeof e.agentId === "string") {
334      live.spawned = noteSpawnEnded(live.spawned, e.agentId);
335      refreshSoon($, live, options);
336      return next(e);
337    }
338    live.isWorking = false;
339    live.step = null;
340    live.mainToolCalls = 0;
341    const now = await $.clock.now();
342    const took = Number.isFinite(e.durationMs) ? e.durationMs : now - (live.turnStartedAt ?? now);
343    const lit = live.model && (live.model.discipline || live.model.workflow || live.model.skills.some(({ state }) => state === "current"));
344    if (lit && !live.turnNotified && took >= LONG_TURN_MS) {
345      live.turnNotified = true;
346      notifyFinish($, options, `LIT turn finished · ${live.model.title ?? "LitClaude"} · ${formatDuration(took)}`);
347    }
348    // The request is over: its chips dim to the recent list until a skill fires again.
349    live.settledAt = now;
350    refreshSoon($, live, options);
351    return next(e);
352  });
353
354  on("session.measure", async ($, e, next) => {
355    live.usage = { context: e.context, rateLimits: e.rateLimits };
356    if (e.changed.includes("context")) refreshSoon($, live, options);
357    return next(e);
358  });
359
360  on("tool.call", async ($, e, next) => {
361    // The card follows the main loop: a skill a subagent loads (a reviewer reading `rules`) neither
362    // lights a chip nor retitles the card.
363    if (e.tool === "Skill" && typeof e.agentId !== "string") await noteFiring($, live, e.skill);
364    // A call in flight keeps its agent alive however long it runs (view.mjs mergeSpawned).
365    if (typeof e.agentId === "string") live.spawned = noteSpawnActivity(live.spawned, e.agentId, await $.clock.now(), 1);
366    // The live step: the main loop's tool by name and since when, nothing else of the call.
367    const step = typeof e.agentId === "string" ? null : { name: e.tool, at: await $.clock.now() };
368    if (step) live.mainToolCalls += 1;
369    if (step) {
370      live.step = step;
371      await rebuild($, live);
372    }
373    let result;
374    try {
375      result = await next(e);
376    } finally {
377      if (step && live.step === step) live.step = live.isWorking ? { name: "thinking", at: await $.clock.now() } : null;
378      if (typeof e.agentId === "string") {
379        const now = await $.clock.now();
380        live.activity = noteAgentActivity(live.activity, e.agentId, now);
381        live.spawned = noteSpawnActivity(live.spawned, e.agentId, now, -1);
382      }
383    }
384    noteToolOutcome(live, e, result, await $.clock.now());
385    refreshSoon($, live, options);
386    return result;
387  }).catch(($, e, next) => next(e));
388
389  // A background task's notification ends its thread row: its text is read for ids alone.
390  on("prompt.submit", async ($, e, next) => {
391    if (e.origin?.kind === "task-notification") {
392      for (const id of endedTaskIds(e.text)) live.tasks = endBackgroundTask(live.tasks, id);
393      refreshSoon($, live, options);
394    }
395    return next(e);
396  }).catch(($, e, next) => next(e));
397
398  // A model request on the main loop: the step reads "thinking" until a tool call names itself.
399  on("turn.step", async function* ($, e, next) {
400    if (typeof e.agentId !== "string" && live.isWorking && live.step?.name !== "thinking") live.step = { name: "thinking", at: await $.clock.now() };
401    if (typeof e.agentId === "string") live.spawned = noteSpawnActivity(live.spawned, e.agentId, await $.clock.now());
402    return yield* next(e);
403  });
404
405  // Every subagent, teammate and workflow agent starts here. A workflow's never shows in
406  // `$.agent.list()`, so the card keeps its own record until the agent's turn.complete.
407  on("agent.spawn", async ($, e, next) => {
408    const result = await next(e);
409    if (typeof result?.agentId === "string") {
410      live.spawned = noteSpawn(live.spawned, { id: result.agentId, description: e.description, type: e.subagentType, at: await $.clock.now() });
411      refreshSoon($, live, options);
412    }
413    return result;
414  }).catch(($, e, next) => next(e));
415
416  // A typed `/litclaude:<name>` and the Skill tool both expand the skill's prompt here; the text
417  // passes through untouched. So does a subagent's preloaded skill (an agent's `skills:` list),
418  // and the event does not say whose it is: a firing counts only before the main loop's first tool
419  // call of the turn (a typed command) or inside its Skill call.
420  on("skill.prompt", async ($, e, next) => {
421    if (live.mainToolCalls === 0 || live.step?.name === "Skill") await noteFiring($, live, e.skill);
422    return next(e);
423  }).catch(($, e, next) => next(e));
424
425  // A client left: once no client of its surface remains, that surface stops counting.
426  on("session.detach", async ($, e, next) => {
427    const result = await next(e);
428    const roster = await $.session.surfaces();
429    if (!roster.includes(e.surface)) delete live.drawnOn[e.surface];
430    return result;
431  }).catch(($, e, next) => next(e));
432
433  on("ui.fault", async ($, e, next) => {
434    if (e.component === "AbovePrompt") live.clientFailed = true;
435    return next(e);
436  });
437
438  on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
439    const settings = normalizeModSettings(options);
440    const motion = effectiveMotion(settings.motion, { prefersReducedMotion: live.reducedMotion });
441    const glyphs = resolveGlyphs(settings.glyphs, e.surface, live.iconFont.present);
442    const model = live.model;
443    if (!model || settings.band === "off" || e.props.hasSurvey || (!model.visible && !live.exiting)) return next(e);
444    const elements = $.ui.resolve(e);
445    const columns = Math.max(20, e.props.bodyColumns ?? e.viewport?.columns ?? 80);
446    let drawn = null;
447    live.drawnOn[e.surface] = e.surface !== "terminal" && elements.Svg ? "svg" : "cells";
448    if (e.surface !== "terminal" && elements.Svg) {
449      // A desktop or editor draws Text in a proportional font, so the cell-built card comes apart
450      // there; those surfaces get the card as one drawing instead.
451      if (model.visible) {
452        const { source, alt, animated } = composeSvgCard(model, { motion, accent: live.accent });
453        drawn = elements.Svg({ source, alt, ...(animated ? { isInteractive: true } : {}) });
454      }
455    } else if (elements.Client && !live.clientFailed) {
456      drawn = elements.Client({ key: "lit-band", module: "./lit-band-client.mjs", props: { model, columns, glyphs, motion, accent: live.accent } });
457    } else if (model.visible) {
458      drawn = renderBand(elements, composeBand(model, settledSample(model), { columns, glyphs, accent: live.accent }));
459    }
460    const below = await next(e);
461    if (!drawn) return below;
462    return below ? elements.Box({ flexDirection: "column", children: [drawn, below] }) : drawn;
463  });
464
465  on("ui.render", { component: "Spinner" }, async ($, e, next) => {
466    const suffix = spinnerSuffix(live.model, e.props.suffix);
467    return suffix ? next({ ...e, props: { ...e.props, suffix } }) : next(e);
468  });
469
470  on("command.run", { command: PANE }, async ($) => {
471    live.paneOpen = true;
472    if (live.snapshot) live.ledger = await readLedgerTail(stateIo($), live.snapshot.stateRoot);
473    await $.ui.open({ id: PANE, title: "LIT evidence", focus: true, closeOnEscape: true });
474    return {};
475  }).catch(() => ({ text: "The LIT evidence pane could not open; LitClaude state is unchanged." }));
476
477  on("ui.close", async ($, e, next) => {
478    if (e.id === PANE) live.paneOpen = false;
479    return next(e);
480  }).catch(($, e, next) => next(e));
481
482  on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
483    const settings = normalizeModSettings(options);
484    const glyphs = resolveGlyphs(settings.glyphs, e.surface, live.iconFont.present);
485    const elements = $.ui.resolve(e);
486    const columns = Math.max(20, e.props.bodyColumns ?? 60);
487    const now = await $.clock.now();
488    const evidence = buildEvidenceModel({ snapshot: live.snapshot ?? NO_SNAPSHOT, ledger: live.ledger, agents: live.agents, activity: live.activity, now, icons: `${glyphs} glyphs (${settings.glyphs}); ${live.iconFont.note}` });
489    const tabs = TABS.map((tab) => elements.Button({
490      key: `tab-${tab.id}`,
491      label: tab.label,
492      hotkey: tab.hotkey,
493      plain: true,
494      dimColor: tab.id !== live.evidenceTab,
495      onPress: () => {
496        live.evidenceTab = tab.id;
497        $.ui.invalidate("ui.render");
498      },
499    }));
500    return elements.Box({
501      flexDirection: "column",
502      children: [
503        elements.Box({ flexDirection: "row", gap: 2, children: tabs }),
504        ...renderRows(elements, composeEvidence(evidence, live.evidenceTab, { columns, glyphs })),
505      ],
506    });
507  });
508};
509
lib/mod/frame.mjs 887 lines
1// Turns a band model and one sampled frame into the LIT card: five rows of coloured spans. A span is
2// { text, fg?, bg?, bold? }; the surface module draws each row as a line of Text elements. The card
3// draws its own rounded border so the top edge can carry the title and the bottom edge the live
4// figures, and every row is fitted to exactly the card's width, counting Hangul and emoji as two
5// cells, so nothing wraps and the card keeps its height whether LitClaude is working or idle.
6//
7//   ╭─ [🔥 LIT] · start-work ───────────────────────────────── WORK ● ─╮
8//   │ interview ✓ ━━ plan ✓ ━━ [work 3/7] ━━ review · ── done ·  1 lane │
9//   │ └ task 3/7 ███░░░░░ · next ▸ 4. README demo        - Bash · 0:12 │
10//   │ skills [⚡ start-work] [↯ lit-plan]                               │
11//   ╰─ ctx 42% ▰▰▱▱▱ · 5h 31% · 1w 12% · git main ─────────────────────╯
12//
13// The second row is the workflow map (workflow.mjs): done stages carry a check in a colour graded
14// along the rail from flame orange through pink to the accent, the current stage is a pill, the
15// stages ahead are dim. The rail leaving the current stage fills with its count and, while
16// LitClaude works, carries a bright head; the rails ahead are dim `─`. A narrow card drops the
17// rails, then shortens the names, then shows only the current stage and its neighbours. With no
18// family to place the session in, the row is the PLAN / WORK / REVIEW track instead. The third row
19// is the stage's detail and, on the right, the tool the main loop is running and for how long.
20//
21// Each bracket pair stands for the two end cells of a filled pill: the brand on pink, the current
22// stage and this request's skills on the accent with dark text, skills from earlier requests on
23// dark grey. Those end cells are rounded Nerd Font caps in the pill's colour, or one cell of
24// padding in that colour with basic glyphs, so a pill is as wide in either glyph set.
25//
26// In a real Claude Code 2.1.292 terminal, a row whose only change is a non-ASCII glyph (a block,
27// symbol, Hangul or Nerd Font cell) keeps showing the old glyph; a change to ASCII text, or to the
28// colour of an ASCII cell, repaints. So every change is carried by ASCII: a bar cell is a space on
29// its colour and the leading cell fades in by colour, embers are ASCII sparks, labels pop in letter
30// by letter, a notice slides by its leading spaces, a chip's light moves over its ASCII name, and a
31// glyph swaps only together with the colour of its words. The map's rail cells keep their glyph
32// and move by colour alone (a colour change on `─` or `━` repaints), and the live step's spinner
33// and clock are ASCII.
34
35import { gauge, knownFacts } from "../rewind/gauge.mjs";
36import { textWidth, truncateToWidth } from "./layout.mjs";
37import { barCells, gradientAt, lerpColor, lookOf, rainbowAt } from "./motion.mjs";
38import { glyphSet } from "./settings.mjs";
39import { formatDuration } from "./view.mjs";
40
41export const PALETTE = Object.freeze({
42  pink: "#ff2d95",
43  pinkText: "#ff6fae",
44  orange: "#ff6337",
45  cyan: "#00e5ff",
46  accent: "#5fd7ff",
47  green: "#6bdb8f",
48  amber: "#ffaf3c",
49  red: "#ff5f5f",
50  violet: "#b48cff",
51  white: "#e6edf3",
52  dim: "#6e7681",
53  faint: "#30363d",
54});
55
56// The status line's level colours (xterm 71, 178, 167) and its empty bar cells (238).
57const HUD = Object.freeze({ green: "#5faf5f", yellow: "#d7af00", red: "#d75f5f", empty: "#444444" });
58// Past this many columns the card stops growing, so a wide terminal does not stretch it thin.
59export const CARD_MAX = 120;
60// Pill colours: dark text on the bright fills, the muted pill for earlier skills and +N, the brand
61// pink of the status line's prefix, and the connectors between phases.
62const PILL = Object.freeze({ ink: "#0b0f14", muted: "#2d333b", mutedText: "#adb5bf", brand: PALETTE.pink, rail: "#46505c" });
63const TONE = Object.freeze({
64  pink: { bar: PALETTE.pink, title: PALETTE.pinkText, icon: "🔥" },
65  amber: { bar: PALETTE.amber, title: PALETTE.amber, icon: "⚠" },
66  red: { bar: PALETTE.red, title: PALETTE.red, icon: "✗" },
67});
68const LABELS = Object.freeze({ plan: "PLAN", work: "WORK", review: "REVIEW" });
69const PHASES = Object.freeze(["plan", "work", "review"]);
70
71const span = (text, style = {}) => ({ text, ...style });
72export const rowWidth = (row) => row.reduce((sum, part) => sum + textWidth(part.text), 0);
73export const rowText = (row) => row.map(({ text }) => text).join("");
74
75// Cuts a row to `cells`, splitting the span that crosses the edge.
76export const clipRow = (row, cells) => {
77  const out = [];
78  let room = cells;
79  for (const part of row) {
80    if (room <= 0) break;
81    const width = textWidth(part.text);
82    if (width <= room) {
83      out.push(part);
84      room -= width;
85    } else {
86      out.push({ ...part, text: truncateToWidth(part.text, room, "") });
87      room = 0;
88    }
89  }
90  return out;
91};
92
93// Clips a row to `cells` and pads it with spaces to exactly that width.
94const fitRow = (row, cells) => {
95  const clipped = clipRow(row, cells);
96  const missing = cells - rowWidth(clipped);
97  return missing > 0 ? [...clipped, span(" ".repeat(missing))] : clipped;
98};
99
100// A label popping in: the first `reveal` share of its characters, and while ease-out-back carries
101// the reveal past 1, a cell or two of extra padding so the label visibly overshoots and settles.
102export const revealText = (label, reveal) => {
103  if (!(reveal > 0)) return null;
104  const chars = [...label];
105  const count = Math.max(1, Math.round(reveal * chars.length));
106  return chars.slice(0, Math.min(count, chars.length)).join("") + " ".repeat(Math.max(0, count - chars.length));
107};
108
109// Text inside a pill, one span per character, when a light is passing: the fill brightens around
110// `shimmer` (0 to 1 across the visible characters). Without a light the text stays one span.
111const litText = (text, style, shimmer) => {
112  if (shimmer === null || shimmer === undefined) return [span(text, style)];
113  const chars = [...text];
114  const visible = chars.filter((ch) => ch.trim()).length;
115  let k = 0;
116  return chars.map((ch) => {
117    if (!ch.trim()) return span(ch, { ...style });
118    const position = visible <= 1 ? 0 : k / (visible - 1);
119    k += 1;
120    const near = Math.abs(position - shimmer);
121    return span(ch, near < 0.18 ? { ...style, bg: lerpColor(style.bg, "#ffffff", (1 - near / 0.18) * 0.6) } : { ...style });
122  });
123};
124
125// A filled pill around `body` (spans that already carry the fill as `bg`): rounded caps in the
126// fill colour where the glyph set has them, one cell of padding in the fill otherwise.
127const pill = (body, fill, glyphs) => (glyphs.capLeft
128  ? [span(glyphs.capLeft, { fg: fill }), ...body, span(glyphs.capRight, { fg: fill })]
129  : [span(" ", { bg: fill }), ...body, span(" ", { bg: fill })]);
130
131// The rewind look's pills: each cell a step further round the hue wheel, lightened so the dark ink
132// keeps its contrast, with each cap in the colour of the cell beside it.
133const RAINBOW_SPREAD = 0.35;
134const rainbowCell = (rainbow, at) => lerpColor(rainbow(at), "#ffffff", 0.3);
135const rainbowPill = (text, rainbow, from, glyphs, bold = true) => {
136  const chars = [...text];
137  const at = (i) => from + (chars.length <= 1 ? 0 : (i / (chars.length - 1)) * RAINBOW_SPREAD);
138  const body = chars.map((ch, i) => span(ch, { fg: PILL.ink, bg: rainbowCell(rainbow, at(i)), bold }));
139  return glyphs.capLeft
140    ? [span(glyphs.capLeft, { fg: rainbowCell(rainbow, at(0)) }), ...body, span(glyphs.capRight, { fg: rainbowCell(rainbow, at(chars.length - 1)) })]
141    : [span(" ", { bg: rainbowCell(rainbow, at(0)) }), ...body, span(" ", { bg: rainbowCell(rainbow, at(chars.length - 1)) })];
142};
143
144const inkOf = (accent) => ({ done: lerpColor(accent, PALETTE.dim, 0.45), now: accent, todo: PALETTE.dim, warn: PALETTE.amber });
145
146// Colour of one phase in this frame: the crossfade from its previous state, then the amber mix for
147// the work phase while the context warning is up.
148const phaseColor = (id, sample, ink) => {
149  const { from, to, mix } = sample.phases[id];
150  const color = lerpColor(ink[from], ink[to], mix);
151  return id === "work" && sample.warn > 0 ? lerpColor(color, ink.warn, sample.warn) : color;
152};
153
154// The warning glyph swaps on the same frame the ASCII `ctx` text appears, so the row repaints.
155const warned = (id, sample) => id === "work" && sample.warn > 0.02;
156
157const segment = (id, priority, spans, joiner = "  ") => (spans && spans.length ? { id, priority, spans, joiner, width: rowWidth(spans) } : null);
158
159// Keeps segments in display order, dropping the highest priority numbers first until the joined
160// row fits `room`; each kept segment after the first is preceded by its own joiner.
161const fitJoined = (segments, room) => {
162  let kept = segments.filter(Boolean);
163  const total = () => kept.reduce((sum, part, index) => sum + part.width + (index > 0 ? textWidth(part.joiner) : 0), 0);
164  while (kept.length > 0 && total() > room) {
165    const worst = kept.reduce((a, b) => (b.priority >= a.priority ? b : a));
166    kept = kept.filter((part) => part !== worst);
167  }
168  return kept;
169};
170
171const joinSegments = (kept, joinerColor) => kept.flatMap((part, index) => (index > 0 ? [span(part.joiner, { fg: joinerColor }), ...part.spans] : part.spans));
172
173const COMET_TAIL = 12;
174
175// The comet's colour `k` of the way from its tail (0) to its head (1): flame orange fading in, pink
176// through the body and white at the head, laid over the rule's own colour.
177const cometColor = (base, k) => {
178  const hue = k > 0.8 ? lerpColor(PALETTE.pink, "#ffffff", (k - 0.8) / 0.2) : lerpColor(PALETTE.orange, PALETTE.pink, k / 0.8);
179  return lerpColor(base, hue, Math.min(1, k * 1.6));
180};
181
182// The rule of an edge, `count` cells from column `start`. With a comet ({ x, dir }: its head's
183// column and travel direction) the cells behind the head light up, brightest at the head, and the
184// rule becomes one span per cell; without one it stays a single span. `paint` (column to colour)
185// gives every cell its own colour; the rewind sweep's comet (`arrow`) draws its head as ASCII `<<`.
186const ruleSpans = (count, color, start, comet, paint = null) => {
187  if (!comet && !paint) return [span("─".repeat(count), { fg: color })];
188  return Array.from({ length: count }, (_, i) => {
189    const base = paint ? paint(start + i) : color;
190    if (!comet) return span("─", { fg: base });
191    const behind = comet.dir * (comet.x - (start + i));
192    const k = behind >= 0 && behind <= COMET_TAIL ? 1 - behind / COMET_TAIL : 0;
193    return span(comet.arrow && behind >= 0 && behind < 2 ? "<" : "─", { fg: k > 0 ? cometColor(base, k) : base });
194  });
195};
196
197// An edge of the card: `open` and `close` are its corners, `left` its title or figures and
198// `right` the phase indicator, with the rule filling whatever room is left.
199const edgeRow = (open, left, right, close, width, color, comet = null, paint = null) => {
200  const piece = (text, at) => (paint ? [...text].map((ch, i) => span(ch, { fg: paint(at + i) })) : [span(text, { fg: color })]);
201  const tail = right.length ? [span(" "), ...right, ...piece(` ${close}`, width - textWidth(close) - 1)] : piece(close, width - textWidth(close));
202  const fill = Math.max(1, width - textWidth(open) - rowWidth(left) - 1 - rowWidth(tail));
203  const start = textWidth(open) + rowWidth(left) + 1;
204  return clipRow([...piece(open, 0), ...left, span(" "), ...ruleSpans(fill, color, start, comet, paint), ...tail], width);
205};
206
207// Where the ambient comet is on this card: along the top rule left to right for the first half of
208// its loop, then back along the bottom rule, starting and ending just off the card. The rewind look
209// spends the whole loop sweeping the bottom rule right to left.
210const cometOn = (sample, edge, width, look = null) => {
211  const u = sample.ambient?.comet;
212  if (u === null || u === undefined) return null;
213  const span = width + 2 * COMET_TAIL;
214  if (look === "rewind") return edge === "bottom" ? { x: width + COMET_TAIL - u * span, dir: -1, arrow: true } : null;
215  if (edge === "top") return u < 0.5 ? { x: u * 2 * span - COMET_TAIL, dir: 1 } : null;
216  return u >= 0.5 ? { x: width + COMET_TAIL - (u - 0.5) * 2 * span, dir: -1 } : null;
217};
218
219// While LitClaude works the brand pill breathes from its pink toward the flame orange and back.
220// The rewind look turns it through the rainbow instead, lightened so its dark ink stays readable.
221const brandSpans = (sample, glyphs, rainbow = null) => {
222  const reveal = revealText("🔥 LIT", sample.pills[0]);
223  if (reveal === null) return [];
224  if (rainbow) return rainbowPill(reveal, rainbow, 0, glyphs);
225  const fill = sample.ambient ? lerpColor(PILL.brand, PALETTE.orange, 0.85 * sample.ambient.breath) : PILL.brand;
226  return pill(litText(reveal, { fg: PILL.ink, bg: fill, bold: true }, sample.completion?.shimmer ?? null), fill, glyphs);
227};
228// `REWIND <<` while the rewind look is on, each letter a step further round the hue wheel, lightened
229// to keep 4.5:1 on the card; the letters stay put and only their colours move.
230const rewindSpans = (rainbow) => [..."REWIND <<"].map((ch, i) => span(ch, { fg: lerpColor(rainbow(i / 9), "#ffffff", 0.4), bold: true }));
231const indicatorSpans = (model, sample, glyphs, accent, rainbow = null) => {
232  if (sample.completion) return [span(`DONE ${glyphs.done}`, { fg: PALETTE.green, bold: true })];
233  if (rainbow) return rewindSpans(rainbow);
234  if (model.idle) return [span("idle", { fg: PALETTE.dim })];
235  const flow = model.workflow;
236  if (flow && flow.current >= 0) {
237    const warn = model.ctxWarning && sample.warn > 0.02;
238    if (flow.paused) return [span(`${flow.stages[flow.current].id.toUpperCase()} ||`, { fg: PALETTE.dim, bold: true })];
239    return [span(`${flow.stages[flow.current].id.toUpperCase()} ${warn ? glyphs.warn : glyphs.now}`, { fg: warn ? lerpColor(accent, PALETTE.amber, sample.warn) : accent, bold: true })];
240  }
241  if (flow && model.lanes.count === 0) return [span(`DONE ${glyphs.done}`, { fg: PALETTE.green, bold: true })];
242  const current = PHASES.find((id) => model.phases[id] === "now");
243  if (current) return [span(`${LABELS[current]} ${warned(current, sample) ? glyphs.warn : glyphs.now}`, { fg: phaseColor(current, sample, inkOf(accent)), bold: true })];
244  if (PHASES.every((id) => model.phases[id] === "done") && model.lanes.count === 0) return [span(`DONE ${glyphs.done}`, { fg: PALETTE.green, bold: true })];
245  return [span(`LIVE ${glyphs.now}`, { fg: accent, bold: true })];
246};
247
248// ╭─ [🔥 LIT] · <discipline> ──── <PHASE ●> ─╮. On a narrow card the phase indicator goes first,
249// then the discipline shortens, then it goes too.
250const topEdge = (model, sample, { width, glyphs, accent, edge, look, paint, rainbow }) => {
251  const brand = brandSpans(sample, glyphs, rainbow);
252  const right = indicatorSpans(model, sample, glyphs, accent, rainbow);
253  const name = model.title ?? "ready";
254  const nameStyle = model.idle && !rainbow ? { fg: PALETTE.dim } : { fg: accent, bold: true };
255  const title = (text) => (text ? [span(" · ", { fg: PALETTE.dim }), span(text, nameStyle)] : []);
256  const fixed = textWidth("╭─ ") + rowWidth(brand) + 1 + 1;
257  const withRight = fixed + rowWidth(title(name)) + 1 + rowWidth(right) + textWidth(" ─╮");
258  const comet = cometOn(sample, "top", width, look);
259  if (withRight <= width) return edgeRow("╭─ ", [...brand, ...title(name)], right, "─╮", width, edge, comet, paint[0]);
260  const room = width - fixed - textWidth("─╮") - textWidth(" · ");
261  const shown = room >= 4 ? truncateToWidth(name, room) : "";
262  return edgeRow("╭─ ", [...brand, ...title(shown)], [], "─╮", width, edge, comet, paint[0]);
263};
264
265const barSpans = (fraction, sample, cells) => barCells(fraction, cells, sample.fillGlow ?? 0)
266  .map(({ glyph, color }) => (glyph === "█" ? span(" ", { bg: color }) : span(glyph, { fg: color })));
267
268// ●● 2 lanes 1:07: a dot per running lane (amber once stalled), the count and the longest lane's
269// clock. While LitClaude works a light walks across the dots (full motion) or they breathe (subtle),
270// so running lanes move while the main turn waits; a dot changes only by colour, which repaints.
271const laneSegment = (model, sample, glyphs) => {
272  const list = model.lanes.list.slice(0, 4);
273  const ambient = model.idle ? null : sample.ambient;
274  const dots = list.map((lane, index) => {
275    const base = lane.status === "stalled" ? PALETTE.amber : PALETTE.green;
276    let light = 0;
277    if (ambient?.flow !== null && ambient?.flow !== undefined) light = 0.8 * Math.max(0, 1 - Math.abs(ambient.flow * (list.length + 1) - 0.5 - index));
278    else if (ambient) light = 0.5 * ambient.breath;
279    return span(glyphs.lane, { fg: lerpColor(base, "#ffffff", light) });
280  });
281  const clock = sample.lanes ? ` ${formatClock(sample.lanes.elapsedMs)}` : "";
282  return segment("lanes", 3, [...dots, span(` ${model.lanes.count} ${model.lanes.count === 1 ? "lane" : "lanes"}${clock}`, { fg: PALETTE.dim })]);
283};
284
285// The right half of the phase row: a context warning, the lanes, the autoloop budget and the
286// progress of the plan or goal, dropped in that priority order when the row is narrow.
287const progressSegment = (model, sample, glyphs, cells) => {
288  if (model.progress) {
289    const label = model.progress.unit === "tasks" ? "tasks" : "goal";
290    return segment("progress", 1, [
291      span(`${label} `, { fg: PALETTE.dim }),
292      ...barSpans(sample.fill, sample, cells),
293      span(" "),
294      span(String(model.progress.done), { fg: lerpColor(PALETTE.white, PALETTE.cyan, Math.max(sample.flash, sample.fillGlow ?? 0)), bold: true }),
295      span(`/${model.progress.total}`, { fg: PALETTE.dim }),
296    ]);
297  }
298  const goal = model.goal;
299  if (!goal) return null;
300  const met = goal.status === "complete";
301  return segment("progress", 1, [
302    span("goal ", { fg: PALETTE.dim }),
303    span(met ? `${glyphs.done} ` : "", { fg: PALETTE.green }),
304    span(`${goal.passed}/${goal.total}`, { fg: met ? PALETTE.green : PALETTE.white, bold: met }),
305  ]);
306};
307
308// ● PLAN ━━ [WORK] ━━ ○ REVIEW: the active phase is a pill on the accent (amber while the context
309// warning is up), done phases a dim-accent dot and the rest a dim ring. Each form of a label is
310// as wide as the others, so a phase changing state never moves the row.
311const phaseTrack = (model, sample, { glyphs, accent, rail, short, rainbow }) => {
312  const ink = inkOf(accent);
313  const out = [];
314  PHASES.forEach((id, index) => {
315    const label = short ? LABELS[id][0] : LABELS[id];
316    const state = sample.phases[id].to;
317    const shown = revealText(state === "now" ? label : `${state === "done" ? "●" : "○"} ${label}`, sample.pills[index + 1]);
318    if (shown === null) return;
319    if (out.length) out.push(span(` ${glyphs.rail.repeat(rail)} `, { fg: rainbow ? rainbow(index / PHASES.length) : sample.phases[PHASES[index - 1]].to === "done" ? ink.done : PILL.rail }));
320    if (state === "now" && rainbow) {
321      out.push(...rainbowPill(shown.slice(0, label.length).padEnd(label.length), rainbow, index / PHASES.length, glyphs));
322    } else if (state === "now") {
323      const fill = model.idle ? lerpColor(phaseColor(id, sample, ink), PILL.muted, 0.5) : phaseColor(id, sample, ink);
324      const sweep = model.idle ? null : sample.ambient?.sweep ?? null;
325      out.push(...pill(litText(shown.slice(0, label.length).padEnd(label.length), { fg: PILL.ink, bg: fill, bold: true }, sweep), fill, glyphs));
326    } else {
327      out.push(span(warned(id, sample) ? shown.replace(/^[●○]/u, glyphs.warn) : shown, { fg: rainbow && !warned(id, sample) ? rainbow(index / PHASES.length + 0.15) : phaseColor(id, sample, ink), bold: Boolean(rainbow) }));
328    }
329  });
330  return out;
331};
332
333const phaseRow = (model, sample, { inner, glyphs, accent, rainbow }) => {
334  const variants = [{ rail: 2, short: false }, { rail: 1, short: false }, { rail: 1, short: true }];
335  const tracks = variants.map((variant) => phaseTrack(model, sample, { glyphs, accent, rainbow, ...variant }));
336  const right = [];
337  if (sample.rest > 0) {
338    const warning = model.ctxWarning && sample.warn > 0.02
339      ? [span(`ctx ${model.ctxWarning.percent}% → ${model.ctxWarning.threshold}%`, { fg: lerpColor(PALETTE.dim, PALETTE.amber, sample.warn), bold: sample.warn > 0.5 })]
340      : null;
341    right.push(segment("ctx", 1, warning));
342    if (model.lanes.count > 0) {
343      right.push(laneSegment(model, sample, glyphs));
344    }
345    if (model.autoloop) {
346      right.push(segment("loop", 4, [span(`${glyphs.loop} ${model.autoloop.n}/${model.autoloop.max}`, { fg: PALETTE.violet }), span(` · ${formatDuration(model.autoloop.leftMs)}`, { fg: PALETTE.dim })]));
347    }
348    right.push(progressSegment(model, sample, glyphs, inner >= 70 ? 8 : 5));
349  }
350  const track = tracks.find((candidate) => rowWidth(candidate) + 2 + 10 <= inner) ?? tracks.at(-1);
351  const kept = fitJoined(right, inner - rowWidth(track) - 2);
352  const tail = joinSegments(kept, PALETTE.faint);
353  if (!tail.length) return fitRow(track, inner);
354  return fitRow([...track, span(" ".repeat(Math.max(2, inner - rowWidth(track) - rowWidth(tail)))), ...tail], inner);
355};
356
357// The colour of a point `position` (0 to 1) of the way along the map: flame orange, pink, accent.
358const mapColor = (accent, position) => gradientAt([PALETTE.orange, PALETTE.pink, accent], position);
359
360// A stage name cut to its first three letters, its count kept: `work 3/7` becomes `wor 3/7`.
361const shortLabel = (label) => label.replace(/^[A-Za-z]+/u, (word) => word.slice(0, 3));
362
363// The rail from stage `index - 1` to stage `index`, `cells` wide. Behind the current stage it is
364// `━` graded along the map; leaving it, `━` filled by the stage's count with a bright head while
365// LitClaude works; ahead, dim `─`. With no room for rails, one space. The rewind look grades the
366// `━` cells by its `rainbow` instead.
367const railSpans = (model, sample, index, cells, accent, rainbow = null) => {
368  if (cells === 0) return [span(" ")];
369  const grade = rainbow ?? ((position) => mapColor(accent, position));
370  const { stages } = model.workflow;
371  const last = Math.max(1, stages.length - 1);
372  const at = (cell) => (index - 1 + (cell + 0.5) / cells) / last;
373  const into = stages[index].state;
374  const from = stages[index - 1].state;
375  let body;
376  if (into === "done" || into === "now") {
377    body = Array.from({ length: cells }, (_, cell) => span("━", { fg: grade(at(cell)) }));
378  } else if (from === "now") {
379    const filled = model.workflow.count ? sample.fill * cells : 0;
380    const flow = model.idle || model.workflow.paused ? null : sample.ambient?.flow ?? null;
381    const head = flow === null ? -1 : Math.floor(flow * cells);
382    body = Array.from({ length: cells }, (_, cell) => {
383      const base = cell < filled ? grade(at(cell)) : PILL.rail;
384      return span("━", { fg: cell === head ? lerpColor(base, "#ffffff", 0.75) : base });
385    });
386  } else {
387    body = [span("─".repeat(cells), { fg: PILL.rail })];
388  }
389  return [span(" "), ...body, span(" ")];
390};
391
392// interview ✓ ━━ plan ✓ ━━ [work 3/7] ━━ review · ── done ·
393const mapTrack = (model, sample, { glyphs, accent, rail, short = false, window = false, rainbow = null }) => {
394  const { stages, current } = model.workflow;
395  const last = Math.max(1, stages.length - 1);
396  const focus = current < 0 ? stages.length - 1 : current;
397  const shown = stages.map((_, index) => index).filter((index) => !window || Math.abs(index - focus) <= 1);
398  const out = [];
399  if (window && shown[0] > 0) out.push(span("… ", { fg: PALETTE.dim }));
400  shown.forEach((index) => {
401    const stage = stages[index];
402    const label = short ? shortLabel(stage.label) : stage.label;
403    const text = stage.state === "now" ? label : `${label} ${stage.state === "done" ? glyphs.done : "·"}`;
404    const revealed = revealText(text, sample.pills[Math.min(3, 1 + Math.floor(index / 2))]);
405    if (revealed === null) return;
406    if (out.length && !(window && out.length === 1 && shown[0] > 0)) out.push(...railSpans(model, sample, index, rail, accent, rainbow));
407    if (stage.state === "now" && model.workflow.paused) {
408      // A paused stage holds still: the muted pill, no breath and no light along it.
409      out.push(...pill([span(revealed.slice(0, label.length).padEnd(label.length), { fg: PILL.mutedText, bg: PILL.muted, bold: true })], PILL.muted, glyphs));
410    } else if (stage.state === "now") {
411      let fill = model.idle ? lerpColor(accent, PILL.muted, 0.5) : accent;
412      if (model.ctxWarning) fill = lerpColor(fill, PALETTE.amber, sample.warn);
413      if (sample.ambient && !model.idle) fill = lerpColor(fill, "#ffffff", 0.2 * sample.ambient.breath);
414      const sweep = model.idle ? null : sample.ambient?.sweep ?? null;
415      out.push(...pill(litText(revealed.slice(0, label.length).padEnd(label.length), { fg: PILL.ink, bg: fill, bold: true }, sweep), fill, glyphs));
416    } else {
417      out.push(span(revealed, { fg: stage.state === "done" ? mapColor(accent, index / last) : PALETTE.dim }));
418    }
419  });
420  if (window && shown.at(-1) < stages.length - 1) out.push(span(" …", { fg: PALETTE.dim }));
421  return out;
422};
423
424const MAP_VARIANTS = Object.freeze([
425  { rail: 2 },
426  { rail: 1 },
427  { rail: 0 },
428  { rail: 0, short: true },
429  { rail: 0, short: true, window: true },
430]);
431
432// The map, then the context warning, the lanes and the autoloop budget while there is room.
433const mapRow = (model, sample, { inner, glyphs, accent, rainbow }) => {
434  const tracks = MAP_VARIANTS.map((variant) => mapTrack(model, sample, { glyphs, accent, rainbow, ...variant }));
435  const track = tracks.find((candidate) => rowWidth(candidate) <= inner) ?? tracks.at(-1);
436  const right = [];
437  if (sample.rest > 0) {
438    if (model.ctxWarning && sample.warn > 0.02) {
439      right.push(segment("ctx", 1, [span(`ctx ${model.ctxWarning.percent}% → ${model.ctxWarning.threshold}%`, { fg: lerpColor(PALETTE.dim, PALETTE.amber, sample.warn), bold: sample.warn > 0.5 })]));
440    }
441    if (model.lanes.count > 0) {
442      right.push(laneSegment(model, sample, glyphs));
443    }
444    if (model.autoloop) {
445      right.push(segment("loop", 4, [span(`${glyphs.loop} ${model.autoloop.n}/${model.autoloop.max}`, { fg: PALETTE.violet }), span(` · ${formatDuration(model.autoloop.leftMs)}`, { fg: PALETTE.dim })]));
446    }
447  }
448  const kept = fitJoined(right, inner - rowWidth(track) - 2);
449  const tail = joinSegments(kept, PALETTE.faint);
450  if (!tail.length) return fitRow(track, inner);
451  return fitRow([...track, span(" ".repeat(Math.max(2, inner - rowWidth(track) - rowWidth(tail)))), ...tail], inner);
452};
453
454const SPINNER = Object.freeze(["-", "\\", "|", "/"]);
455
456// m:ss, or h:mm:ss past the hour.
457export const formatClock = (ms) => {
458  const seconds = Math.max(0, Math.floor(ms / 1000));
459  const minutes = Math.floor(seconds / 60);
460  const ss = String(seconds % 60).padStart(2, "0");
461  return minutes >= 60 ? `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, "0")}:${ss}` : `${minutes}:${ss}`;
462};
463
464// The live step: an ASCII spinner, the tool's name and how long it has run. Nothing else of the call.
465const stepSpans = (sample, accent) => {
466  if (!sample.step) return [];
467  return [
468    span(`${SPINNER[sample.spin ?? 0]} `, { fg: accent, bold: true }),
469    span(sample.step.name, { fg: PALETTE.white }),
470    span(` · ${formatClock(sample.step.elapsedMs)}`, { fg: PALETTE.dim }),
471  ];
472};
473
474const VERDICT_STYLE = Object.freeze({
475  pass: (glyphs) => [glyphs.done, PALETTE.green],
476  fail: (glyphs) => [glyphs.fail, PALETTE.red],
477  pending: () => ["·", PALETTE.dim],
478  running: (glyphs) => [glyphs.lane, PALETTE.amber],
479});
480
481// The review lanes or a crucible verdict: each name in its verdict's colour (a lane under review
482// amber), so a change repaints.
483// `cut` shortens the names to that many letters; 0 drops them and leaves the marks.
484const verdictSpans = (verdicts, glyphs, cut = Infinity) => verdicts.flatMap(({ name, state }, index) => {
485  const [mark, color] = VERDICT_STYLE[state](glyphs);
486  const text = cut === 0 ? mark : `${name.slice(0, cut)} ${mark}`;
487  return [...(index > 0 ? [span(" ")] : []), span(text, { fg: color })];
488});
489
490// └ task 3/7 ███░░░░░ · paused · next ▸ <task>                    - Bash · 0:12
491const detailRow = (model, sample, { inner, glyphs, accent }) => {
492  const flow = model.workflow;
493  const right = stepSpans(sample, accent);
494  const segments = [];
495  if (flow?.count) {
496    const { done, total, unit } = flow.count;
497    const glow = sample.ambient && !model.idle ? 0.5 * sample.ambient.breath : sample.fillGlow ?? 0;
498    const bar = barCells(sample.fill, inner >= 70 ? 8 : 5, glow).map(({ glyph, color }) => (glyph === "█" ? span(" ", { bg: color }) : span(glyph, { fg: color })));
499    segments.push(segment("count", 1, [
500      span(`${unit} `, { fg: PALETTE.dim }),
501      span(String(done), { fg: lerpColor(PALETTE.white, PALETTE.cyan, Math.max(sample.flash, sample.fillGlow ?? 0)), bold: true }),
502      span(`/${total} `, { fg: PALETTE.dim }),
503      ...bar,
504    ], " · "));
505  }
506  const lead = [span("└ ", { fg: PALETTE.dim })];
507  const room = inner - rowWidth(lead) - (right.length ? rowWidth(right) + 2 : 0);
508  if (flow?.verdicts) {
509    const before = segments.reduce((sum, part) => sum + part.width + textWidth(part.joiner), 0);
510    const forms = [Infinity, 3, 0].map((cut) => segment("verdicts", 2, verdictSpans(flow.verdicts, glyphs, cut), " · "));
511    segments.push(forms.find((form) => before + form.width <= room) ?? forms.at(-1));
512  }
513  (flow?.items ?? []).forEach((item, index) => segments.push(segment(`item-${index}`, 3 + index, [span(item, { fg: PALETTE.white })], " · ")));
514  let left = joinSegments(fitJoined(segments, room), PALETTE.faint);
515  if (flow?.next) {
516    const gap = left.length ? [span(" · ", { fg: PALETTE.faint })] : [];
517    const label = [span("next ", { fg: PALETTE.dim }), span(`${glyphs.next} `, { fg: accent })];
518    const avail = room - rowWidth(left) - rowWidth(gap) - rowWidth(label);
519    if (avail >= 6) left = [...left, ...gap, ...label, span(truncateToWidth(flow.next, avail), { fg: PALETTE.white })];
520  }
521  if (!left.length && !right.length) return fitRow([], inner);
522  const body = [...lead, ...left];
523  if (!right.length) return fitRow(body, inner);
524  return fitRow([...body, span(" ".repeat(Math.max(1, inner - rowWidth(body) - rowWidth(right)))), ...right], inner);
525};
526
527// A thread's state: its glyph, its colour and the word written beside it. The word is ASCII, so a
528// state change always repaints the glyph with it. Paused is an ASCII bar: `‖` is ambiguous-width
529// and some fonts draw it two cells wide, which pushes the card's right edge out.
530const THREAD_STATE = Object.freeze({
531  running: (glyphs) => [glyphs.lane, PALETTE.green],
532  paused: () => ["|", PALETTE.dim],
533  blocked: () => ["!", PALETTE.amber],
534  idle: () => ["-", PALETTE.dim],
535  done: (glyphs) => [glyphs.done, lerpColor(PALETTE.green, PALETTE.dim, 0.4)],
536});
537// A background task's glyph says its kind (ASCII, so it repaints with the row): a shell or a monitor.
538const TASK_GLYPH = Object.freeze({ shell: "$", monitor: "~" });
539const plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
540const parallelCount = ({ laneCount = 0, taskCount = 0 }) => laneCount + taskCount;
541/** `3 lanes in parallel`, `2 lanes · 1 task in parallel`, `2 tasks in parallel`. */
542export const forkLabel = ({ laneCount = 0, taskCount = 0 }) => `${[laneCount ? plural(laneCount, "lane") : null, taskCount ? plural(taskCount, "task") : null].filter(Boolean).join(" · ")} in parallel`;
543// The last child of a branch draws `└─` and the others `├─`. Those are not ASCII, so the name beside
544// them takes a slightly different white: the row repaints when a branch's last child changes.
545const THREAD_NAME = Object.freeze({ mid: PALETTE.white, last: "#d8e0e8" });
546
547/**
548 * The thread rows (view.mjs threadsOf), `model.threads.reserve` of them, blank rows filling the
549 * rest. The stack goes parent first, each child under the one before it; lanes hang under the top
550 * frame, with a fork line over them when two or more run in parallel; other sessions come last.
551 *
552 *   | lit-plan   plan  [▉▉░░░] 2/5 paused
553 *   └─ ● lit-loop RED   [▉░░░] 1/4 running
554 *      ┬ 2 lanes · 1 task in parallel
555 *      ├─ ● executor    [▉▉░] 2/3 running
556 *      ├─ ● reviewer    ━━━━━ running · 7 steps · 2m · last: Read a.mjs
557 *      └─ $ dev server shell ━━━━━ running 4m
558 *
559 * A thread with two real numbers draws its gauge. One without a total never draws a guess: an
560 * indeterminate pulse (fixed `━` cells whose light moves by colour while the card animates) and
561 * what is known in words. The rewind look turns the gauge cells and the pulse through its rainbow.
562 */
563const PULSE_CELLS = 5;
564const pulseSpans = (sample, rainbow, index) => {
565  const ambient = sample.ambient;
566  const at = ambient?.flow ?? (ambient ? ambient.breath : null);
567  const head = at === null ? -1 : Math.floor(((at + index * 0.17) % 1) * PULSE_CELLS);
568  return Array.from({ length: PULSE_CELLS }, (_, cell) => {
569    const base = rainbow ? rainbow(cell / PULSE_CELLS + index * 0.1) : PILL.rail;
570    return span("━", { fg: cell === head ? lerpColor(base, "#ffffff", rainbow ? 0.45 : 0.7) : rainbow ? lerpColor(base, PALETTE.faint, 0.35) : base });
571  });
572};
573const gaugeSpans = (text, rainbow, index) => {
574  if (!rainbow) return [span(text, { fg: PALETTE.white })];
575  const [, cells, count] = /^\[([▉░]+)\](.*)$/u.exec(text);
576  return [
577    span("[", { fg: PALETTE.white }),
578    ...[...cells].map((cell, i) => span(cell, { fg: cell === "▉" ? rainbow(i / cells.length + index * 0.1) : PALETTE.dim })),
579    span(`]${count}`, { fg: PALETTE.white }),
580  ];
581};
582const progressSpans = (thread, sample, rainbow, index, color) => {
583  const drawn = gauge(thread.done, thread.total);
584  if (drawn) return [span(" "), ...gaugeSpans(drawn, rainbow, index), span(` ${thread.state}`, { fg: color })];
585  const facts = knownFacts({ steps: thread.steps, elapsedMs: thread.elapsedMs, last: thread.last, state: thread.state });
586  const words = facts.startsWith(thread.state)
587    ? [span(facts, { fg: color })]
588    : [span(thread.state, { fg: color }), span(` · ${facts}`, { fg: PALETTE.dim })];
589  return [span(" "), ...pulseSpans(sample, rainbow, index), span(" "), ...words];
590};
591
592const threadRows = (model, sample, { inner, glyphs, rainbow }) => {
593  const { chain, lanes, others, more, reserve } = model.threads;
594  const lines = [];
595  chain.forEach((thread, index) => lines.push({ thread, lead: index === 0 ? "" : `${"   ".repeat(index - 1)}└─ `, last: true }));
596  // Lanes and background tasks are children of the top frame, so they hang at its indent.
597  const under = "   ".repeat(Math.max(0, chain.length - 1));
598  if (parallelCount(model.threads) >= 2) {
599    lines.push({ fork: `${under}┬ ${forkLabel(model.threads)}` });
600    lanes.forEach((thread, index) => {
601      const last = index === lanes.length - 1;
602      lines.push({ thread, lead: `${under}${last ? "└─" : "├─"} `, last });
603    });
604  } else {
605    lanes.forEach((thread) => lines.push({ thread, lead: chain.length ? `${under}└─ ` : "", last: true }));
606  }
607  others.forEach((thread) => lines.push({ thread, lead: "", last: true }));
608  const rows = lines.map((line) => line.thread ?? null).filter(Boolean);
609  // A wide card spends its room on names, so lanes that share a prefix stay apart.
610  const nameWidth = Math.min(inner >= 90 ? 28 : 18, Math.max(...rows.map(({ name }) => textWidth(name))));
611  const stageWidth = Math.min(10, Math.max(0, ...rows.map(({ stage }) => (stage ? textWidth(stage) : 0))));
612  const lastIndex = lines.length - 1;
613  const out = lines.map((line, index) => {
614    const tail = index === lastIndex && more > 0 ? [span(`+${more} more`, { fg: PALETTE.dim })] : [];
615    let body;
616    if (line.fork) {
617      body = [span(line.fork, { fg: PALETTE.dim })];
618    } else {
619      const { thread } = line;
620      const [stateGlyph, color] = THREAD_STATE[thread.state](glyphs);
621      const glyph = thread.task ? TASK_GLYPH[thread.task] ?? "$" : stateGlyph;
622      const name = truncateToWidth(thread.name, nameWidth);
623      body = [
624        span(line.lead, { fg: PALETTE.dim }),
625        span(`${glyph} `, { fg: color }),
626        span(name + " ".repeat(Math.max(0, nameWidth - textWidth(name))), { fg: line.last ? THREAD_NAME.last : THREAD_NAME.mid, bold: thread.active }),
627        ...(stageWidth ? [span(` ${(thread.stage ?? "").slice(0, stageWidth).padEnd(stageWidth)}`, { fg: PALETTE.dim })] : []),
628        ...progressSpans(thread, sample, rainbow, index, color),
629        ...(thread.session ? [span(` · other session ${thread.session}`, { fg: PALETTE.dim })] : []),
630      ];
631    }
632    if (!tail.length) return fitRow(body, inner);
633    const room = inner - rowWidth(tail) - 1;
634    const clipped = clipRow(body, room);
635    return fitRow([...clipped, span(" ".repeat(Math.max(1, inner - rowWidth(clipped) - rowWidth(tail)))), ...tail], inner);
636  });
637  while (out.length < reserve) out.push(fitRow([], inner));
638  return out;
639};
640
641const CHIP_TONE = Object.freeze({
642  current: (accent) => ({ fill: accent, text: PILL.ink }),
643  recent: () => ({ fill: PILL.muted, text: PILL.mutedText }),
644});
645
646// One chip pill: the bolt and the skill name, on the accent for this request or muted for an
647// earlier one. A chip that just arrived carries full motion's light along its name, or fills up
648// from the muted pill under subtle; a chip changing state crossfades its colours.
649const chipSpans = (chip, timed, glyphs, accent, sweep = null) => {
650  const to = timed?.to ?? chip.state;
651  const from = timed?.from ?? to;
652  const mix = timed?.mix ?? 1;
653  const arrive = timed?.arrive ?? 1;
654  let fill = lerpColor(CHIP_TONE[from](accent).fill, CHIP_TONE[to](accent).fill, mix);
655  const text = lerpColor(CHIP_TONE[from](accent).text, CHIP_TONE[to](accent).text, mix);
656  if (arrive < 1 && (timed?.shimmer === null || timed?.shimmer === undefined)) fill = lerpColor(PILL.muted, fill, arrive);
657  const bold = to === "current";
658  return pill([
659    span(`${to === "current" ? glyphs.bolt : glyphs.recentBolt} `, { fg: text, bg: fill }),
660    ...litText(chip.id, { fg: text, bg: fill, bold }, timed?.shimmer ?? (to === "current" && arrive >= 1 ? sweep : null)),
661  ], fill, glyphs);
662};
663
664const mutedPill = (text, glyphs) => pill([span(text, { fg: PALETTE.dim, bg: PILL.muted })], PILL.muted, glyphs);
665
666const REWIND_SPARKS = Object.freeze([".", "*", "+", "'"]);
667
668// The rewind look's sparks, drifting through the cells the chips leave free (one gap cell kept).
669const sparkled = (row, inner, sample, rainbow) => {
670  const sparks = sample.ambient?.sparks;
671  const spare = inner - rowWidth(row) - 1;
672  if (!rainbow || !sparks || spare < 3) return fitRow(row, inner);
673  const sky = Array.from({ length: spare }, () => span(" "));
674  for (const { x, glyph } of sparks) sky[Math.min(spare - 1, Math.floor(x * spare))] = span(REWIND_SPARKS[glyph], { fg: rainbow(x) });
675  return fitRow([...row, span(" "), ...sky], inner);
676};
677
678// After a break the idle card offers the cross-session brief, in the longest form that fits.
679const AWAY_HINTS = Object.freeze([[" · back? type ", "lit adhd", " to catch up"], [" · back? ", "lit adhd", ""], [" ", "lit adhd", ""]]);
680const awayHint = (room, accent) => {
681  const hint = AWAY_HINTS.find((parts) => textWidth(parts.join("")) <= room);
682  return hint ? [span(hint[0], { fg: PALETTE.dim }), span(hint[1], { fg: accent }), ...(hint[2] ? [span(hint[2], { fg: PALETTE.dim })] : [])] : [];
683};
684
685const chipsRow = (model, sample, { inner, glyphs, accent, rainbow }) => {
686  const chips = model.skills;
687  if (chips.length === 0) {
688    if (!model.idle && model.next && !model.workflow?.next) return fitRow([span("▸ ", { fg: accent }), span(truncateToWidth(model.next, inner - 2), { fg: PALETTE.white })], inner);
689    if (!model.idle) return fitRow([span("no LitFamily skill yet in this request", { fg: PALETTE.dim })], inner);
690    const hints = [
691      [span("no LitFamily skill yet · type ", { fg: PALETTE.dim }), span("lit <task>", { fg: accent }), span(" to start one", { fg: PALETTE.dim })],
692      [span("no skill yet · type ", { fg: PALETTE.dim }), span("lit <task>", { fg: accent })],
693      [span("type ", { fg: PALETTE.dim }), span("lit <task>", { fg: accent })],
694    ];
695    return fitRow(hints.find((hint) => rowWidth(hint) <= inner) ?? hints.at(-1), inner);
696  }
697  const row = inner >= 30 ? [span("skills", { fg: PALETTE.dim })] : [];
698  let shown = 0;
699  for (const chip of chips) {
700    // While LitClaude works the light that crosses the active phase crosses this request's chips too.
701    const entry = rainbow && lookOf(chip.id) === "rewind"
702      ? rainbowPill(`${chip.state === "current" ? glyphs.bolt : glyphs.recentBolt} ${chip.id}`, rainbow, 0.5, glyphs)
703      : chipSpans(chip, sample.chips?.[chip.id], glyphs, accent, model.idle ? null : sample.ambient?.sweep ?? null);
704    const gap = row.length ? [span(" ")] : [];
705    const rest = chips.length - shown - 1;
706    const reserve = rest > 0 ? 1 + rowWidth(mutedPill(`+${rest}`, glyphs)) : 0;
707    if (rowWidth(row) + rowWidth(gap) + rowWidth(entry) + reserve > inner) break;
708    row.push(...gap, ...entry);
709    shown += 1;
710  }
711  if (shown < chips.length) row.push(...(row.length ? [span(" ")] : []), ...mutedPill(`+${chips.length - shown}`, glyphs));
712  if (model.away) return fitRow([...row, ...awayHint(inner - rowWidth(row), accent)], inner);
713  return sparkled(row, inner, sample, rainbow);
714};
715
716const noticeRow = (sample, inner) => {
717  const { data, offset } = sample.notice;
718  const tone = TONE[data.tone] ?? TONE.pink;
719  return fitRow([
720    span(" ".repeat(offset)),
721    span("▎", { fg: tone.bar }),
722    span(` ${tone.icon} ${data.title}`, { fg: tone.title, bold: true }),
723    ...(data.detail ? [span(`  · ${data.detail}`, { fg: PALETTE.dim })] : []),
724  ], inner);
725};
726
727const SPARKS = [".", "+", "*"];
728
729const completionRow = (sample, inner) => {
730  const completion = sample.completion;
731  const glow = completion.embers === null ? 0 : 0.6 * Math.sin(Math.PI * completion.embers);
732  const words = [
733    span("COMPLETE", { fg: lerpColor(lerpColor(PALETTE.dim, PALETTE.white, completion.title), PALETTE.pinkText, glow), bold: true }),
734    span(` · ${completion.data.detail}`, { fg: lerpColor(PALETTE.dim, PALETTE.green, completion.detail) }),
735  ];
736  const row = [];
737  if (completion.embers !== null) {
738    const count = Math.max(0, Math.min(18, Math.floor((inner - rowWidth(words) - 2) / 2)));
739    for (let i = 0; i < count; i += 1) {
740      const life = Math.min(1, Math.max(0, completion.embers * 1.6 - i * 0.04));
741      const alpha = life <= 0 || life >= 1 ? 0 : Math.sin(life * Math.PI);
742      const glyph = alpha < 0.05 ? " " : SPARKS[Math.min(2, Math.floor(alpha * 3))];
743      row.push(span(glyph, { fg: lerpColor(PALETTE.faint, i % 3 ? PALETTE.pink : PALETTE.amber, alpha) }), span(" "));
744    }
745    row.push(span("  "));
746  } else {
747    row.push(span("✦ ", { fg: PALETTE.pink }));
748  }
749  return fitRow([...row, ...words], inner);
750};
751
752const levelColor = (percent) => (percent >= 90 ? HUD.red : percent >= 70 ? HUD.yellow : HUD.green);
753
754// ╰─ ctx 42% ▰▰▱▱▱ · 5h 31% · 1w 12% · git main ───╯, or a warning first when one is up. The branch goes
755// first on a narrow card, then the weekly window, the five-hour one and the context bar.
756const bottomEdge = (model, sample, { width, glyphs, accent, edge, look, paint, rainbow }) => {
757  const { context, rates, git } = model.figures;
758  const warning = model.warnings[0];
759  const segments = [];
760  if (warning) {
761    const color = warning.tone === "red" ? PALETTE.red : PALETTE.amber;
762    segments.push(segment("warning", 0, [span(`${warning.tone === "red" ? glyphs.fail : "⚠"} ${warning.subject}`, { fg: color, bold: true }), span(` ${warning.text}`, { fg: color })], " · "));
763  }
764  if (context !== null) {
765    segments.push(segment("ctx", 1, [span("ctx ", { fg: PALETTE.dim }), span(`${context}%`, { fg: rainbow ? rainbow(0.8) : accent, bold: Boolean(rainbow) })], " · "));
766    const filled = Math.max(0, Math.min(5, Math.round(context / 20)));
767    // The rewind look turns every cell of the bar, filled or not, so even 0% shows the colour.
768    const cells = rainbow
769      ? [...Array(5)].map((_, i) => span(i < filled ? "▰" : "▱", { fg: i < filled ? rainbow(i / 5) : lerpColor(rainbow(i / 5), HUD.empty, 0.5) }))
770      : [span("▰".repeat(filled), { fg: accent }), span("▱".repeat(5 - filled), { fg: HUD.empty })];
771    segments.push(segment("ctxbar", 2, cells, " "));
772  }
773  rates.forEach((rate, index) => segments.push(segment(`rate-${rate.label}`, 3 + index * 0.5, [span(`${rate.label} `, { fg: PALETTE.dim }), span(`${rate.percent}%`, { fg: levelColor(rate.percent) })], " · ")));
774  if (git) segments.push(segment("git", 4, [span(`${glyphs.branch} `, { fg: PALETTE.dim }), span(git, { fg: accent })], " · "));
775  const room = width - textWidth("╰─ ") - 1 - 1 - textWidth("─╯");
776  let kept = fitJoined(segments, room);
777  if (kept[0]?.id === "ctxbar") kept = kept.slice(1);
778  if (kept.length === 0 && warning) kept = [{ ...segments[0], spans: clipRow(segments[0].spans, room) }];
779  return edgeRow("╰─ ", joinSegments(kept, edge), [], "─╯", width, edge, cometOn(sample, "bottom", width, look), paint[4]);
780};
781
782// The border: red or amber under a warning, otherwise the accent. Idle it is a calm, dim accent;
783// while LitClaude works it sits a step above the accent and breathes brighter toward white with the
784// brand pill.
785const edgeColor = (model, sample, accent) => {
786  if (model.warnings.some(({ tone }) => tone === "red")) return PALETTE.red;
787  if (model.warnings.length) return PALETTE.amber;
788  if (model.idle) return lerpColor(PALETTE.faint, accent, 0.35);
789  return lerpColor(accent, "#ffffff", 0.15 + 0.3 * (sample.ambient?.breath ?? 0));
790};
791
792/**
793 * The card for one frame: `{ mode: "card", border, width, rows }`, five rows (top edge, workflow
794 * map or phase track, detail line, chips row, bottom edge), with the thread rows between the detail
795 * line and the chips while more than one thread is live, each exactly `width` cells:
796 * the terminal's columns up to CARD_MAX. `accent` is the status line's accent colour
797 * (settings.mjs accentFromSettings).
798 */
799export const composeBand = (model, sample, { columns, glyphs: glyphMode = "basic", accent = PALETTE.accent }) => {
800  const glyphs = glyphSet(glyphMode);
801  const width = Math.max(12, Math.min(CARD_MAX, columns));
802  const inner = width - 4;
803  const edge = edgeColor(model, sample, accent);
804  // The rewind look's rainbow turns with the ambient hue (still at 0 with motion off). A warning
805  // keeps the border its warning colour; the rails stay rainbow.
806  const look = model.rewind ? "rewind" : model.idle ? null : lookOf(model.discipline);
807  const hue = sample.ambient?.hue ?? 0;
808  const rainbow = look === "rewind" ? (position) => rainbowAt(position + hue) : null;
809  const painted = rainbow && model.warnings.length === 0;
810  const paint = [0, 1, 2, 3, 4].map((r) => (painted ? (col) => rainbow(col / width + r * 0.04) : null));
811  const side = (row, r) => (paint[r]
812    ? [span("│", { fg: paint[r](0) }), span(" "), ...row, span(" "), span("│", { fg: paint[r](width - 1) })]
813    : [span("│ ", { fg: edge }), ...row, span(" │", { fg: edge })]);
814  const options = { width, inner, glyphs, accent, edge, look, rainbow, paint };
815  const middle = sample.completion ? completionRow(sample, inner) : sample.notice ? noticeRow(sample, inner) : chipsRow(model, sample, options);
816  return {
817    mode: "card",
818    border: edge,
819    width,
820    look,
821    rows: [
822      topEdge(model, sample, options),
823      side(model.workflow ? mapRow(model, sample, options) : phaseRow(model, sample, options), 1),
824      side(detailRow(model, sample, options), 2),
825      ...(model.threads ? threadRows(model, sample, options).map((row) => side(row, 2)) : []),
826      side(middle, 3),
827      bottomEdge(model, sample, options),
828    ],
829  };
830};
831
832const GATE_STYLE = Object.freeze({
833  ok: (glyphs) => ({ glyph: glyphs.done, fg: PALETTE.green }),
834  pending: () => ({ glyph: "…", fg: PALETTE.amber }),
835  fail: (glyphs) => ({ glyph: glyphs.fail, fg: PALETTE.red }),
836});
837
838const gateEntries = (gates, glyphs) => gates.map((gate) => {
839  const style = GATE_STYLE[gate.state](glyphs);
840  return [span(style.glyph, { fg: style.fg }), span(` ${gate.label}`, { fg: PALETTE.dim })];
841});
842
843/** Rows for the evidence pane's tab (`goal`, `lanes` or `gates`), fitted to `columns`. */
844export const composeEvidence = (evidence, tab, { columns, glyphs: glyphMode = "basic" }) => {
845  const glyphs = glyphSet(glyphMode);
846  const rows = [];
847  const line = (...parts) => rows.push(clipRow(parts, columns));
848  if (tab === "lanes") {
849    if (evidence.lanes.length === 0) line(span("No subagents in this session.", { fg: PALETTE.dim }));
850    for (const lane of evidence.lanes) {
851      const color = lane.status === "failed" ? PALETTE.red : lane.status === "running" ? PALETTE.green : PALETTE.dim;
852      line(span(`${lane.status === "failed" ? glyphs.fail : glyphs.lane} `, { fg: color }), span(lane.name, { fg: PALETTE.white }), span(`  ${lane.type} · ${lane.status}${lane.quiet ? ` · quiet ${lane.quiet}` : ""}`, { fg: PALETTE.dim }));
853    }
854    return rows;
855  }
856  if (tab === "gates") {
857    if (evidence.work) line(span("plan  ", { fg: PALETTE.dim }), span(`${evidence.work.plan ?? "plan"} · ${evidence.work.done}/${evidence.work.total}`, { fg: PALETTE.white }));
858    if (evidence.gates.length === 0) line(span("No gate is armed in this session.", { fg: PALETTE.dim }));
859    for (const entry of gateEntries(evidence.gates, glyphs)) line(...entry);
860    if (evidence.icons) line(span("icons ", { fg: PALETTE.dim }), span(evidence.icons, { fg: PALETTE.dim }));
861    return rows;
862  }
863  const goal = evidence.goal;
864  if (!goal) {
865    line(span("No litgoal goal in this project for this session.", { fg: PALETTE.dim }));
866  } else {
867    line(span("goal  ", { fg: PALETTE.dim }), span(goal.objective, { fg: PALETTE.white, bold: true }), span(`  ${goal.status}`, { fg: PALETTE.dim }));
868    line(span(""));
869    for (const criterion of goal.criteria) {
870      const mark = criterion.status === "pass" ? [glyphs.done, PALETTE.green] : criterion.status === "fail" || criterion.status === "blocked" ? [glyphs.fail, PALETTE.red] : ["·", PALETTE.faint];
871      const tail = `  ${criterion.ago}`;
872      const description = truncateToWidth(criterion.description, Math.max(8, columns - textWidth(criterion.id) - textWidth(tail) - 5));
873      line(span(`${mark[0]} `, { fg: mark[1] }), span(criterion.id, { fg: PALETTE.white }), span(`  ${description}`, { fg: PALETTE.dim }), span(tail, { fg: PALETTE.dim }));
874    }
875    for (const blocker of goal.blockers) line(span(`${glyphs.flag} `, { fg: PALETTE.amber }), span(blocker, { fg: PALETTE.amber }));
876    if (goal.reviewBlocked) line(span(`${glyphs.flag} `, { fg: PALETTE.amber }), span("review: blocked", { fg: PALETTE.amber }));
877  }
878  if (evidence.timeline.length) {
879    line(span(""));
880    for (const entry of evidence.timeline) {
881      const color = entry.event.startsWith("evidence") ? PALETTE.accent : entry.event.startsWith("checkpoint") ? PALETTE.violet : PALETTE.green;
882      line(span(entry.ago.padEnd(10), { fg: PALETTE.dim }), span(entry.event.padEnd(20), { fg: color }), span(entry.detail, { fg: PALETTE.dim }));
883    }
884  }
885  return rows;
886};
887
lib/mod/motion.mjs 132 lines
1// Motion vocabulary for the LIT band: easing curves, the approved timings, animated channels and
2// colour mixing. Entrances pop with ease-out-back, fills and colour changes run ease-in-out, exits
3// accelerate with ease-in. A channel is plain data ({ from, to, at, dur }) sampled against the
4// surface's frame clock, so the band module keeps no timers beyond the frames it is animating.
5
6export const EASE = Object.freeze({
7  linear: (x) => x,
8  outCubic: (x) => 1 - (1 - x) ** 3,
9  inCubic: (x) => x ** 3,
10  inOutCubic: (x) => (x < 0.5 ? 4 * x ** 3 : 1 - (-2 * x + 2) ** 3 / 2),
11  outBack: (x) => 1 + 2.7 * (x - 1) ** 3 + 1.7 * (x - 1) ** 2,
12});
13
14// Milliseconds. The user approved this faster set in the motion preview; `completion` bounds the
15// whole finish moment (shimmer plus embers) and `frame` is the redraw interval while animating.
16export const TIMINGS = Object.freeze({
17  stagger: 60,
18  entry: 160,
19  fill: 300,
20  flash: 400,
21  noticeIn: 220,
22  noticeOut: 150,
23  noticeHold: 4000,
24  crossfade: 140,
25  shimmer: 400,
26  embers: 1000,
27  completion: 1200,
28  exit: 150,
29  frame: 33,
30  breath: 2400,
31  sweep: 1800,
32  comet: 2600,
33  flow: 1400,
34  spin: 120,
35  ambientFrame: 66,
36  clock: 1000,
37  hue: 4800,
38  sparks: 3600,
39});
40
41// full: the approved motion, and while LitClaude works the brand breathes, a light sweeps the active
42// phase and a comet runs around the card's rules. subtle: no overshoot, no shimmer or embers, three
43// quarters the time, and only the breathing while working. off: every duration is zero, so the band
44// draws its final frame and starts no frame timer; a clock on the card still ticks each second.
45export const motionProfile = (mode) => {
46  if (mode === "off") {
47    return { mode, duration: () => 0, enterEase: EASE.linear, noticeEase: EASE.linear, shimmer: false, embers: false, ambient: null };
48  }
49  if (mode === "subtle") {
50    return { mode, duration: (ms) => Math.round(ms * 0.75), enterEase: EASE.outCubic, noticeEase: EASE.outCubic, shimmer: false, embers: false, ambient: { sweep: false, comet: false } };
51  }
52  return { mode: "full", duration: (ms) => ms, enterEase: EASE.outBack, noticeEase: EASE.outBack, shimmer: true, embers: true, ambient: { sweep: true, comet: true } };
53};
54
55// The rewind look (plans/lit-adhd.md D7): while lit-adhd runs, the card's border and rails take a
56// cycling rainbow, ASCII sparks drift through the skills row and a `<<` comet sweeps the bottom rule
57// right to left. It is keyed on the ignition discipline alone; every other discipline keeps its look.
58const REWIND = new Set(["lit-adhd", "lit-rewind"]);
59export const lookOf = (discipline) => (REWIND.has(discipline) ? "rewind" : null);
60
61export const clamp01 = (x) => (x <= 0 ? 0 : x >= 1 ? 1 : x);
62
63// Progress through a window that opens at `at` and lasts `dur`; a zero-length window is complete.
64export const phase = (now, at, dur) => (dur <= 0 ? (now >= at ? 1 : 0) : clamp01((now - at) / dur));
65
66export const still = (value) => ({ from: value, to: value, at: 0, dur: 0 });
67
68export const sample = (channel, now, ease = EASE.inOutCubic) =>
69  channel.from + (channel.to - channel.from) * ease(phase(now, channel.at, channel.dur));
70
71export const retarget = (channel, to, now, dur) => {
72  if (channel.to === to) return channel;
73  const from = sample(channel, now);
74  return from === to ? still(to) : { from, to, at: now, dur };
75};
76
77export const isSettled = (channel, now) => channel.from === channel.to || now >= channel.at + channel.dur;
78
79const hexToRgb = (hex) => {
80  const value = String(hex).replace(/^#/u, "");
81  return [0, 2, 4].map((i) => Number.parseInt(value.slice(i, i + 2), 16) || 0);
82};
83
84export const lerpColor = (a, b, t) => {
85  const from = hexToRgb(a);
86  const to = hexToRgb(b);
87  const k = clamp01(t);
88  return `#${from.map((v, i) => Math.round(v + (to[i] - v) * k).toString(16).padStart(2, "0")).join("")}`;
89};
90
91export const gradientAt = (stops, position) => {
92  if (stops.length === 1) return lerpColor(stops[0], stops[0], 0);
93  const scaled = clamp01(position) * (stops.length - 1);
94  const index = Math.min(stops.length - 2, Math.floor(scaled));
95  return lerpColor(stops[index], stops[index + 1], scaled - index);
96};
97
98// A colour `position` of the way around the hue wheel (wrapping past 1), saturated and light enough
99// to stay bright on the dark card. The rewind look draws only fixed glyphs and ASCII with it.
100export const rainbowAt = (position) => {
101  const h = (((position % 1) + 1) % 1) * 6;
102  const s = 0.9;
103  const l = 0.62;
104  const c = (1 - Math.abs(2 * l - 1)) * s;
105  const x = c * (1 - Math.abs((h % 2) - 1));
106  const [r, g, b] = [[c, x, 0], [x, c, 0], [0, c, x], [0, x, c], [x, 0, c], [c, 0, x]][Math.min(5, Math.floor(h))];
107  const m = l - c / 2;
108  return `#${[r, g, b].map((v) => Math.round((v + m) * 255).toString(16).padStart(2, "0")).join("")}`;
109};
110
111export const BAR_STOPS = Object.freeze(["#ff2d95", "#00e5ff"]);
112export const BAR_TRACK = "#30363d";
113
114// A bar of `cells` cells filled to `fraction`. Whole cells take the pink-to-cyan gradient; the
115// cell the fill is inside fades from the track colour to its gradient colour by how far it is
116// filled, so the bar moves continuously in colour rather than in eighth-cell glyphs (a glyph-only
117// change is not repainted inside a Claude Code Client region). While the bar moves, `glow` (0 to 1)
118// brightens that leading cell. Every filled or fading cell is a solid `█`; the rest is the track.
119export const barCells = (fraction, cells, glow = 0) => {
120  const filled = clamp01(fraction) * cells;
121  const full = Math.floor(filled + 1e-9);
122  const part = full < cells ? filled - full : 0;
123  const head = part > 1e-6 ? full : full - 1;
124  const gradient = (i) => gradientAt(BAR_STOPS, cells <= 1 ? 0 : i / (cells - 1));
125  const shine = (i, color) => (i === head && glow > 0 ? lerpColor(color, "#ffffff", glow * 0.7) : color);
126  return Array.from({ length: cells }, (_, i) => {
127    if (i < full) return { glyph: "█", color: shine(i, gradient(i)) };
128    if (i === full && part > 1e-6) return { glyph: "█", color: shine(i, lerpColor(BAR_TRACK, gradient(i), part)) };
129    return { glyph: "░", color: BAR_TRACK };
130  });
131};
132
lib/mod/render.mjs 21 lines
1// Draws composed rows (frame.mjs) with a surface's element table: one row Box of Text spans per
2// line. The LIT card draws its own border as text, so its title and figures sit in the edges. It
3// calls the element constructors directly, so the band's surface module, the mod's static fallback
4// and the tests share it.
5
6const textProps = (part) => {
7  const props = { children: part.text, wrap: "truncate-end" };
8  if (part.fg) props.color = part.fg;
9  if (part.bg) props.backgroundColor = part.bg;
10  if (part.bold) props.bold = true;
11  return props;
12};
13
14export const renderRows = ({ Box, Text }, rows) =>
15  rows.map((row) => {
16    const parts = row.filter(({ text }) => text.length > 0);
17    return Box({ flexDirection: "row", children: parts.length ? parts.map((part) => Text(textProps(part))) : [Text({ children: " " })] });
18  });
19
20export const renderBand = (elements, frame) => elements.Box({ flexDirection: "column", children: renderRows(elements, frame.rows) });
21
lib/mod/settings.mjs 114 lines
1// The LIT band's four userConfig options. The manifest declares them as plain strings without an
2// `options` list, because Claude Code 2.1.270 and older refuse a whole plugin whose userConfig has
3// one; the values are checked here instead, and anything unknown falls back to the default (the
4// first choice). `notify` raises a notification through the person's own channel when LIT work
5// completes or a LIT turn longer than three minutes ends; `finish+sound` adds a sound only where
6// the plugin ships one, and none ships yet, so it notifies like `finish`.
7
8export const MOD_CHOICES = Object.freeze({
9  motion: ["full", "subtle", "off"],
10  glyphs: ["auto", "nerd", "basic"],
11  band: ["always", "auto", "off"],
12  notify: ["off", "finish", "finish+sound"],
13});
14
15const pick = (value, choices) => {
16  const normalized = String(value ?? "").trim().toLowerCase();
17  return choices.includes(normalized) ? normalized : choices[0];
18};
19
20export const normalizeModSettings = (options) => ({
21  motion: pick(options?.motion, MOD_CHOICES.motion),
22  glyphs: pick(options?.glyphs, MOD_CHOICES.glyphs),
23  band: pick(options?.band, MOD_CHOICES.band),
24  notify: pick(options?.notify, MOD_CHOICES.notify),
25});
26
27// Claude Code's own `prefersReducedMotion` setting (/config "Reduce motion") turns the band's motion
28// off, whatever the plugin's `motion` option says; unset or false leaves that option in charge.
29export const effectiveMotion = (motion, hostSettings) => (hostSettings?.prefersReducedMotion === true ? "off" : motion);
30
31// The glyph set one surface draws. Private-use Nerd Font glyphs only reach the terminal, which
32// draws with the person's own fonts; `auto` uses them there when the icon font is installed. The
33// desktop app and the other surfaces always get the basic set.
34export const resolveGlyphs = (preference, surface, iconFontPresent) => {
35  if (surface !== "terminal") return "basic";
36  if (preference === "auto") return iconFontPresent ? "nerd" : "basic";
37  return preference === "nerd" ? "nerd" : "basic";
38};
39
40const BASIC = Object.freeze({
41  capLeft: "",
42  capRight: "",
43  done: "✓",
44  now: "●",
45  warn: "⚠",
46  link: "─",
47  rail: "━",
48  next: "▸",
49  lane: "●",
50  idleLane: "◌",
51  loop: "⟳",
52  fail: "✗",
53  flag: "⚑",
54  bolt: "⚡",
55  recentBolt: "↯",
56  branch: "git",
57});
58
59const NERD = Object.freeze({
60  ...BASIC,
61  capLeft: "",
62  capRight: "",
63  done: "",
64  warn: "",
65  bolt: "",
66  recentBolt: "",
67  branch: "",
68});
69
70export const glyphSet = (glyphs) => (glyphs === "nerd" ? NERD : BASIC);
71
72// The status line's accents (hud-accent-palette.mjs HUD_ACCENT_THEMES, kept equal by a test) as
73// xterm 256-colour codes. The mod cannot import that file, so the card reads the same names here.
74export const ACCENT_CODES = Object.freeze({
75  cyan: 81,
76  blue: 39,
77  teal: 49,
78  green: 118,
79  lavender: 177,
80  rose: 198,
81  gold: 220,
82  orange: 208,
83  slate: 75,
84  gray: 231,
85});
86
87const CUBE = [0, 95, 135, 175, 215, 255];
88const hex2 = (value) => value.toString(16).padStart(2, "0");
89
90export const xterm256Hex = (code) => {
91  if (code >= 232) {
92    const level = 8 + (code - 232) * 10;
93    return `#${hex2(level).repeat(3)}`;
94  }
95  const index = code - 16;
96  return `#${[Math.floor(index / 36), Math.floor(index / 6) % 6, index % 6].map((step) => hex2(CUBE[step])).join("")}`;
97};
98
99export const accentHex = (name) => xterm256Hex(ACCENT_CODES[String(name ?? "").trim().toLowerCase()] ?? ACCENT_CODES.cyan);
100
101const knownAccent = (name) => {
102  const normalized = String(name ?? "").trim().toLowerCase();
103  return Object.hasOwn(ACCENT_CODES, normalized) ? normalized : null;
104};
105
106// The accent the status line draws with: the installer writes it into the statusLine command
107// (`LITCLAUDE_HUD_ACCENT=<name> node …`) and records it under the `litclaude` settings key; an
108// accent exported in the shell is the last resort, as for a hand-written status line.
109export const accentFromSettings = (settings, envValue) => {
110  const command = typeof settings?.statusLine?.command === "string" ? settings.statusLine.command : "";
111  const inline = /\bLITCLAUDE_HUD_ACCENT=([A-Za-z]+)/u.exec(command)?.[1];
112  return accentHex(knownAccent(inline) ?? knownAccent(settings?.litclaude?.hudAccent) ?? knownAccent(envValue) ?? "cyan");
113};
114
lib/mod/svg-card.mjs 562 lines
1// The LIT card as one SVG drawing, for the surfaces that are not a terminal grid. The Claude desktop
2// app draws a mod's Text in a proportional font and cuts each span to the cells it was given, so the
3// terminal card's text border and its padded pills come apart there. Those surfaces carry an `Svg`
4// element instead, and this module draws the same card into it: the brand pill, the discipline and
5// the stage indicator on top, the workflow map (or the phase track and progress when no family
6// places the session), the detail line with the live step, the thread rows while more than one
7// thread is live (the terminal card's rows, drawn the same way), the skill chips (or the notice or
8// the finish), and the live figures at the bottom.
9//
10// It draws the settled state of the model; the terminal's entrances and crossfades stay terminal
11// only. While LitClaude works and motion allows, SMIL animation in the drawing keeps it moving: the
12// brand pill and the border breathe (full and subtle), a light crosses the current stage and this
13// request's chips, a bright head runs along the rail leaving the current stage, the count bar's
14// head shimmers, the step spinner turns and a comet runs around the border (full only). Idle, it is
15// still. The drawing carries no step clock and no loop countdown: the surface loads each new
16// drawing afresh, restarting every animation, so a figure that moves with time alone would make the
17// card blink on every poll. Each loop opens mid-cycle (see `loop`), so a fresh load starts on a
18// frame of the running card rather than on a dim border and a comet at the corner.
19//
20// Text is set in a monospace face and placed by counted cells (Hangul and emoji as two), so the
21// layout does not depend on measuring a font the drawing cannot see.
22
23import { gauge, knownFacts } from "../rewind/gauge.mjs";
24import { textWidth, truncateToWidth } from "./layout.mjs";
25import { TIMINGS, barCells, gradientAt, lookOf, rainbowAt } from "./motion.mjs";
26
27const C = Object.freeze({
28  bg: "#0d1117",
29  pink: "#ff2d95",
30  orange: "#ff6337",
31  green: "#6bdb8f",
32  amber: "#ffaf3c",
33  red: "#ff5f5f",
34  violet: "#b48cff",
35  white: "#e6edf3",
36  dim: "#7d8590",
37  faint: "#30363d",
38  rail: "#46505c",
39  ink: "#0b0f14",
40  muted: "#2d333b",
41  mutedText: "#adb5bf",
42  empty: "#3a3f47",
43  hudGreen: "#5faf5f",
44  hudYellow: "#d7af00",
45  hudRed: "#d75f5f",
46});
47const LABELS = Object.freeze({ plan: "PLAN", work: "WORK", review: "REVIEW" });
48const PHASES = Object.freeze(["plan", "work", "review"]);
49
50// Drawing units: CSS pixels at the card's natural size; the surface draws it at that width, up to its slot.
51export const SVG_CARD = Object.freeze({ width: 880, height: 160, font: 13, cell: 7.8, pad: 18 });
52const ROW_Y = Object.freeze([28, 58, 86, 114, 142]);
53// Each reserved thread row adds this much height, between the detail line and the chips.
54export const THREAD_ROW_H = 22;
55const PILL_H = 20;
56const FONT = "ui-monospace, 'SF Mono', SFMono-Regular, Menlo, 'JetBrains Mono', Consolas, monospace";
57
58// Timing of one looping animation, started `phase` of the way into its cycle. The surface loads
59// each new drawing afresh, so every loop would otherwise open on its first frame: the breathing at
60// its dimmest, the light off the pill's left edge, the comet parked at the top-left corner. A
61// negative begin opens each loop mid-cycle, in a frame that looks like the card already running.
62const loop = (ms, phase) => `dur="${ms}ms" begin="-${Math.round(ms * phase)}ms" repeatCount="indefinite"`;
63// The breathing pieces share one phase so the pill, the border and the count bar's head stay in step.
64const BREATH_PHASE = 0.25;
65
66const esc = (text) => String(text).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
67const widthOf = (text) => textWidth(text) * SVG_CARD.cell;
68const levelColor = (percent) => (percent >= 90 ? C.hudRed : percent >= 70 ? C.hudYellow : C.hudGreen);
69
70// Edge spaces become offsets: an SVG text element drops leading and trailing whitespace.
71const text = (x, y, body, { fill = C.white, bold = false, anchor = "start" } = {}) => {
72  const lead = String(body).length - String(body).trimStart().length;
73  const trimmed = String(body).trim();
74  if (!trimmed) return "";
75  const at = anchor === "start" ? x + lead * SVG_CARD.cell : x;
76  return `<text x="${at.toFixed(1)}" y="${y}" fill="${fill}"${bold ? ' font-weight="700"' : ""}${anchor === "start" ? "" : ` text-anchor="${anchor}"`}>${esc(trimmed)}</text>`;
77};
78
79// A run of coloured pieces on one baseline, left to right from `x`; returns the markup and where it ends.
80const run = (x, y, pieces) => {
81  let at = x;
82  const out = pieces.map(([body, style]) => {
83    const markup = text(at, y, body, style);
84    at += widthOf(body);
85    return markup;
86  });
87  return { markup: out.join(""), end: at };
88};
89
90// A filled pill holding `label`, its left edge at `x`. `sweep` lays the moving light over it.
91// `veil` lays white over a rainbow fill so the dark ink keeps its contrast on every hue.
92const pill = (x, y, label, { fill, ink, bold = true, sweep = false, breath = null, breathPeak = 0.85, veil = false }) => {
93  const w = widthOf(label) + 16;
94  const top = y - PILL_H + 6;
95  const parts = [`<rect x="${x.toFixed(1)}" y="${top}" width="${w.toFixed(1)}" height="${PILL_H}" rx="${PILL_H / 2}" fill="${fill}"/>`];
96  if (veil) parts.push(`<rect x="${x.toFixed(1)}" y="${top}" width="${w.toFixed(1)}" height="${PILL_H}" rx="${PILL_H / 2}" fill="#ffffff" opacity="0.3"/>`);
97  if (breath) parts.push(`<rect x="${x.toFixed(1)}" y="${top}" width="${w.toFixed(1)}" height="${PILL_H}" rx="${PILL_H / 2}" fill="${breath}" opacity="0"><animate attributeName="opacity" values="0;${breathPeak};0" ${loop(TIMINGS.breath, BREATH_PHASE)}/></rect>`);
98  if (sweep) parts.push(`<rect x="${x.toFixed(1)}" y="${top}" width="${w.toFixed(1)}" height="${PILL_H}" rx="${PILL_H / 2}" fill="url(#lit-sweep)"/>`);
99  parts.push(text(x + 8, y, label, { fill: ink, bold }));
100  return { markup: parts.join(""), end: x + w };
101};
102
103const indicatorOf = (model, accent) => {
104  if (model.completion) return { label: "DONE ✓", fill: C.green };
105  if (model.idle) return { label: "idle", fill: C.dim, plain: true };
106  const flow = model.workflow;
107  if (flow?.paused && flow.current >= 0) return { label: `${flow.stages[flow.current].id.toUpperCase()} ||`, fill: C.dim };
108  if (flow && flow.current >= 0) return { label: `${flow.stages[flow.current].id.toUpperCase()} ●`, fill: model.ctxWarning ? C.amber : accent };
109  if (flow && model.lanes.count === 0) return { label: "DONE ✓", fill: C.green };
110  const current = PHASES.find((id) => model.phases[id] === "now");
111  if (current) return { label: `${LABELS[current]} ●`, fill: model.ctxWarning && current === "work" ? C.amber : accent };
112  if (PHASES.every((id) => model.phases[id] === "done") && model.lanes.count === 0) return { label: "DONE ✓", fill: C.green };
113  return { label: "LIVE ●", fill: accent };
114};
115
116// The rewind look draws the LIT pill and a `REWIND <<` indicator in the turning rainbow.
117const topRow = (model, accent, animate, rewind = false) => {
118  const { width, pad } = SVG_CARD;
119  const y = ROW_Y[0];
120  const brand = rewind
121    ? pill(pad, y, "🔥 LIT", { fill: "url(#lit-rainbow)", ink: C.ink, veil: true })
122    : pill(pad, y, "🔥 LIT", { fill: C.pink, ink: C.ink, breath: animate.breath ? C.orange : null });
123  const indicator = rewind && !model.completion ? { label: "REWIND <<", fill: "url(#lit-rainbow)" } : indicatorOf(model, accent);
124  const right = text(width - pad, y, indicator.label, { fill: indicator.fill, bold: !indicator.plain, anchor: "end" });
125  const room = width - pad - widthOf(indicator.label) - 16 - brand.end - widthOf(" · ");
126  const name = truncateToWidth(model.title ?? "ready", Math.max(4, Math.floor(room / SVG_CARD.cell)));
127  const title = run(brand.end + 4, y, [[" · ", { fill: C.dim }], [name, model.idle && !rewind ? { fill: C.dim } : { fill: accent, bold: true }]]);
128  return brand.markup + title.markup + right;
129};
130
131const rightOfPhases = (model, withProgress = true) => {
132  const pieces = [];
133  if (model.ctxWarning) pieces.push([[`ctx ${model.ctxWarning.percent}% → ${model.ctxWarning.threshold}%`, { fill: C.amber, bold: true }]]);
134  if (model.lanes.count > 0) pieces.push([[`● ${model.lanes.count} ${model.lanes.count === 1 ? "lane" : "lanes"}`, { fill: C.green }]]);
135  if (model.autoloop) pieces.push([[`↻ ${model.autoloop.n}/${model.autoloop.max}`, { fill: C.violet }]]);
136  if (!withProgress) return pieces;
137  if (model.progress) {
138    pieces.push([[`${model.progress.unit === "tasks" ? "tasks" : "goal"} `, { fill: C.dim }], [`${model.progress.done}`, { fill: C.white, bold: true }], [`/${model.progress.total}`, { fill: C.dim }]]);
139  } else if (model.goal) {
140    const met = model.goal.status === "complete";
141    pieces.push([["goal ", { fill: C.dim }], [`${met ? "✓ " : ""}${model.goal.passed}/${model.goal.total}`, { fill: met ? C.green : C.white, bold: met }]]);
142  }
143  return pieces;
144};
145
146// ● PLAN ── [WORK] ── ○ REVIEW on the left, the lanes, loop and progress figures on the right.
147const phaseRow = (model, accent, animate) => {
148  const { width, pad } = SVG_CARD;
149  const y = ROW_Y[1];
150  const out = [];
151  let x = pad;
152  PHASES.forEach((id, index) => {
153    const state = model.phases[id];
154    if (index > 0) {
155      const done = model.phases[PHASES[index - 1]] === "done";
156      out.push(`<line x1="${(x + 6).toFixed(1)}" y1="${y - 4}" x2="${(x + 34).toFixed(1)}" y2="${y - 4}" stroke="${done ? accent : C.rail}" stroke-width="2" stroke-linecap="round"${done ? ' stroke-opacity="0.6"' : ""}/>`);
157      x += 40;
158    }
159    if (state === "now") {
160      const fill = model.idle ? C.muted : model.ctxWarning && id === "work" ? C.amber : accent;
161      const drawn = pill(x, y, LABELS[id], { fill, ink: model.idle ? C.mutedText : C.ink, sweep: animate.sweep });
162      out.push(drawn.markup);
163      x = drawn.end;
164    } else {
165      const color = state === "done" ? accent : C.dim;
166      out.push(`<circle cx="${(x + 5).toFixed(1)}" cy="${y - 4}" r="4.5" ${state === "done" ? `fill="${color}" fill-opacity="0.7"` : `fill="none" stroke="${color}" stroke-width="1.5"`}/>`);
167      out.push(text(x + 15, y, LABELS[id], { fill: color }));
168      x += 15 + widthOf(LABELS[id]);
169    }
170  });
171  // Joined right-aligned; the leftmost group goes first when the row is short of room.
172  let groups = rightOfPhases(model);
173  const joined = (list) => list.flatMap((pieces, index) => (index > 0 ? [["  ·  ", { fill: C.faint }], ...pieces] : pieces));
174  const spanOf = (pieces) => pieces.reduce((sum, [body]) => sum + widthOf(body), 0);
175  while (groups.length && x + 24 + spanOf(joined(groups)) > width - pad) groups = groups.slice(1);
176  if (groups.length) out.push(run(width - pad - spanOf(joined(groups)), y, joined(groups)).markup);
177  return out.join("");
178};
179
180const mapColor = (accent, position) => gradientAt([C.orange, C.pink, accent], position);
181const shortLabel = (label) => label.replace(/^[A-Za-z]+/u, (word) => word.slice(0, 3));
182
183// Right-aligned groups after `x`, the leftmost dropped first when the row is short of room.
184const rightGroups = (groups, x, y) => {
185  const { width, pad } = SVG_CARD;
186  const joined = (list) => list.flatMap((pieces, index) => (index > 0 ? [["  ·  ", { fill: C.faint }], ...pieces] : pieces));
187  const spanOf = (pieces) => pieces.reduce((sum, [body]) => sum + widthOf(body), 0);
188  let kept = groups;
189  while (kept.length && x + 24 + spanOf(joined(kept)) > width - pad) kept = kept.slice(1);
190  return kept.length ? run(width - pad - spanOf(joined(kept)), y, joined(kept)).markup : "";
191};
192
193// interview ✓ ━ plan ✓ ━ [work 3/7] ━ review · ─ done ·, with the lanes and budgets on the right.
194const mapRowSvg = (model, accent, animate) => {
195  const { width, pad } = SVG_CARD;
196  const y = ROW_Y[1];
197  const { stages, current, count } = model.workflow;
198  const last = Math.max(1, stages.length - 1);
199  const labelOf = (stage, short) => {
200    const label = short ? shortLabel(stage.label) : stage.label;
201    return stage.state === "now" ? label : `${label} ${stage.state === "done" ? "✓" : "·"}`;
202  };
203  const extent = ({ rail, short }) => stages.reduce((sum, stage) => sum + widthOf(labelOf(stage, short)) + (stage.state === "now" ? 16 : 0), 0) + (stages.length - 1) * (rail + 12);
204  const layouts = [{ rail: 30, short: false }, { rail: 16, short: false }, { rail: 16, short: true }, { rail: 6, short: true }];
205  const layout = layouts.find((candidate) => pad + extent(candidate) <= width - pad - 160) ?? layouts.at(-1);
206  const out = [];
207  let x = pad;
208  stages.forEach((stage, index) => {
209    if (index > 0) {
210      const x1 = x + 6;
211      const x2 = x + 6 + layout.rail;
212      const mid = (index - 0.5) / last;
213      if (stage.state !== "todo") {
214        out.push(`<line x1="${x1.toFixed(1)}" y1="${y - 4}" x2="${x2.toFixed(1)}" y2="${y - 4}" stroke="${mapColor(accent, mid)}" stroke-width="2.5" stroke-linecap="round"/>`);
215      } else if (stages[index - 1].state === "now") {
216        out.push(`<line x1="${x1.toFixed(1)}" y1="${y - 4}" x2="${x2.toFixed(1)}" y2="${y - 4}" stroke="${C.rail}" stroke-width="2.5" stroke-linecap="round"/>`);
217        const filled = count && count.total > 0 ? Math.min(1, count.done / count.total) : 0;
218        if (filled > 0) out.push(`<line x1="${x1.toFixed(1)}" y1="${y - 4}" x2="${(x1 + filled * layout.rail).toFixed(1)}" y2="${y - 4}" stroke="${mapColor(accent, mid)}" stroke-width="2.5" stroke-linecap="round"/>`);
219        if (animate.sweep && !model.workflow.paused) out.push(`<circle cx="${x1.toFixed(1)}" cy="${y - 4}" r="2.5" fill="#ffffff"><animate attributeName="cx" values="${x1.toFixed(1)};${x2.toFixed(1)}" ${loop(TIMINGS.flow, 0.5)}/></circle>`);
220      } else {
221        out.push(`<line x1="${x1.toFixed(1)}" y1="${y - 4}" x2="${x2.toFixed(1)}" y2="${y - 4}" stroke="${C.rail}" stroke-width="1.5" stroke-linecap="round" stroke-opacity="0.7"/>`);
222      }
223      x = x2 + 6;
224    }
225    const label = labelOf(stage, layout.short);
226    if (stage.state === "now" && model.workflow.paused) {
227      // A paused stage holds still, as on the terminal card.
228      const drawn = pill(x, y, label, { fill: C.muted, ink: C.mutedText });
229      out.push(drawn.markup);
230      x = drawn.end;
231    } else if (stage.state === "now") {
232      const fill = model.idle ? C.muted : model.ctxWarning ? C.amber : accent;
233      const drawn = pill(x, y, label, { fill, ink: model.idle ? C.mutedText : C.ink, sweep: animate.sweep, breath: animate.breath ? "#ffffff" : null, breathPeak: 0.2 });
234      out.push(drawn.markup);
235      x = drawn.end;
236    } else {
237      out.push(text(x, y, label, { fill: stage.state === "done" ? mapColor(accent, index / last) : C.dim }));
238      x += widthOf(label);
239    }
240  });
241  out.push(rightGroups(rightOfPhases(model, false), x, y));
242  return out.join("");
243};
244
245const VERDICT_FILL = Object.freeze({ pass: ["✓", C.green], fail: ["✗", C.red], pending: ["·", C.dim], running: ["●", C.amber] });
246
247// └ task 3/7 ▮▮▮▯▯▯▯▯ · next ▸ <task> on the left, the spinner and the tool on the right.
248const detailRowSvg = (model, accent, animate) => {
249  const { width, pad, cell } = SVG_CARD;
250  const y = ROW_Y[2];
251  const flow = model.workflow;
252  const out = [];
253  let right = pad;
254  if (model.step) {
255    const nameEnd = width - pad;
256    out.push(text(nameEnd, y, model.step.name, { fill: C.white, anchor: "end" }));
257    const cx = nameEnd - widthOf(model.step.name) - 10;
258    out.push(`<circle cx="${cx.toFixed(1)}" cy="${y - 4}" r="4.5" fill="none" stroke="${accent}" stroke-width="1.8" stroke-dasharray="18 10.3">`
259      + (animate.breath ? `<animateTransform attributeName="transform" type="rotate" values="0 ${cx.toFixed(1)} ${y - 4};360 ${cx.toFixed(1)} ${y - 4}" ${loop(TIMINGS.spin * 8, 0.5)}/>` : "")
260      + "</circle>");
261    right = width - cx + 10;
262  }
263  if (!flow) return out.join("");
264  const room = width - pad - right - 16;
265  out.push(text(pad, y, "└", { fill: C.dim }));
266  let x = pad + widthOf("└ ");
267  const sep = () => {
268    out.push(text(x + 6, y, "·", { fill: C.faint }));
269    x += 22;
270  };
271  if (flow.count) {
272    const { done, total, unit } = flow.count;
273    const drawn = run(x, y, [[`${unit} `, { fill: C.dim }], [String(done), { fill: C.white, bold: true }], [`/${total} `, { fill: C.dim }]]);
274    out.push(drawn.markup);
275    x = drawn.end;
276    const cells = barCells(total > 0 ? done / total : 0, 8);
277    cells.forEach(({ glyph, color }, index) => out.push(`<rect x="${(x + index * 9).toFixed(1)}" y="${y - 10}" width="7" height="10" rx="1.5" fill="${glyph === "█" ? color : C.empty}"/>`));
278    const head = cells.findLastIndex(({ glyph }) => glyph === "█");
279    if (head >= 0 && animate.sweep) out.push(`<rect x="${(x + head * 9).toFixed(1)}" y="${y - 10}" width="7" height="10" rx="1.5" fill="#ffffff" opacity="0"><animate attributeName="opacity" values="0;0.6;0" ${loop(TIMINGS.breath, BREATH_PHASE)}/></rect>`);
280    x += 8 * 9 + 4;
281  }
282  const pieces = [];
283  if (flow.verdicts) pieces.push(flow.verdicts.map(({ name, state }) => [`${name} ${VERDICT_FILL[state][0]} `, { fill: VERDICT_FILL[state][1] }]));
284  for (const item of flow.items) pieces.push([[item, { fill: C.white }]]);
285  for (const group of pieces) {
286    const span = group.reduce((sum, [body]) => sum + widthOf(body), 0);
287    if (x + 22 + span > pad + room) break;
288    if (x > pad + widthOf("└ ")) sep();
289    const drawn = run(x, y, group);
290    out.push(drawn.markup);
291    x = drawn.end;
292  }
293  if (flow.next) {
294    const avail = Math.floor((pad + room - x - 22) / cell) - textWidth("next ▸ ");
295    if (avail >= 6) {
296      if (x > pad + widthOf("└ ")) sep();
297      out.push(run(x, y, [["next ", { fill: C.dim }], ["▸ ", { fill: accent }], [truncateToWidth(flow.next, avail), { fill: C.white }]]).markup);
298    }
299  }
300  return out.join("");
301};
302
303const TONES = Object.freeze({ pink: [C.pink, "#ff6fae", "🔥"], amber: [C.amber, C.amber, "⚠"], red: [C.red, C.red, "✗"] });
304
305// Skill chips, or the notice or the finish while one is up, or the hint while nothing has fired.
306const middleRow = (model, accent, animate, shift = 0, rewind = false) => {
307  const { width, pad } = SVG_CARD;
308  const y = ROW_Y[3] + shift;
309  const room = width - pad;
310  if (model.completion) {
311    return run(pad, y, [["✦ ", { fill: C.pink }], ["COMPLETE", { fill: C.white, bold: true }], [` · ${model.completion.detail ?? ""}`, { fill: C.green }]]).markup;
312  }
313  if (model.notice) {
314    const [bar, title, icon] = TONES[model.notice.tone] ?? TONES.pink;
315    return `<rect x="${pad}" y="${y - 14}" width="3" height="18" rx="1.5" fill="${bar}"/>`
316      + run(pad + 10, y, [[`${icon} ${model.notice.title}`, { fill: title, bold: true }], ...(model.notice.detail ? [[`  · ${model.notice.detail}`, { fill: C.dim }]] : [])]).markup;
317  }
318  if (model.skills.length === 0) {
319    if (!model.idle && model.next && !model.workflow?.next) return run(pad, y, [["▸ ", { fill: accent }], [truncateToWidth(model.next, Math.floor((room - pad) / SVG_CARD.cell) - 2), { fill: C.white }]]).markup;
320    if (!model.idle) return text(pad, y, "no LitFamily skill yet in this request", { fill: C.dim });
321    return run(pad, y, [["no LitFamily skill yet · type ", { fill: C.dim }], ["lit <task>", { fill: accent }], [" to start one", { fill: C.dim }]]).markup;
322  }
323  const out = [text(pad, y, "skills", { fill: C.dim })];
324  let x = pad + widthOf("skills") + 10;
325  model.skills.forEach((chip, index) => {
326    if (x === null) return;
327    const current = chip.state === "current";
328    const label = `${current ? "⚡" : "↯"} ${chip.id}`;
329    const rest = model.skills.length - index - 1;
330    const need = widthOf(label) + 16 + (rest > 0 ? widthOf(`+${rest}`) + 26 : 0);
331    if (x + need > room) {
332      out.push(pill(x, y, `+${model.skills.length - index}`, { fill: C.muted, ink: C.dim, bold: false }).markup);
333      x = null;
334      return;
335    }
336    const tinted = rewind && lookOf(chip.id) === "rewind";
337    const drawn = tinted
338      ? pill(x, y, label, { fill: "url(#lit-rainbow)", ink: C.ink, veil: true, sweep: animate.sweep })
339      : pill(x, y, label, { fill: current ? accent : C.muted, ink: current ? C.ink : C.mutedText, bold: current, sweep: current && animate.sweep });
340    out.push(drawn.markup);
341    x = drawn.end + 8;
342  });
343  if (rewind && animate.sweep && x !== null) out.push(sparksSvg(x, width - pad, y));
344  if (model.away && x !== null && x + widthOf("back? type lit adhd to catch up") <= room) out.push(run(x, y, [["back? type ", { fill: C.dim }], ["lit adhd", { fill: accent }], [" to catch up", { fill: C.dim }]]).markup);
345  return out.join("");
346};
347
348// The rewind look's sparks: ASCII stars drifting right to left through the room the chips leave,
349// each fading in and out on its own slow loop (no faster than TIMINGS.sparks / 4 per twinkle).
350const SPARK_SEEDS = Object.freeze([[0.08, 1.0, "*"], [0.22, 1.3, "+"], [0.37, 0.8, "."], [0.51, 1.1, "*"], [0.66, 0.9, "'"], [0.79, 1.4, "+"], [0.93, 0.7, "*"]]);
351const sparksSvg = (from, to, y) => {
352  const span = to - from;
353  if (span < 60) return "";
354  return SPARK_SEEDS.map(([start, speed, glyph], i) => {
355    const x = from + start * span;
356    const dur = Math.round(TIMINGS.sparks / speed);
357    return `<text class="lit-spark" x="${x.toFixed(1)}" y="${y}" fill="${rainbowAt(start)}" opacity="0.8">${esc(glyph)}`
358      + `<animateTransform attributeName="transform" type="translate" values="0 0;${(-start * span).toFixed(1)} 0" ${loop(dur, 0)}/>`
359      + `<animate attributeName="opacity" values="0.3;0.9;0.3" ${loop(Math.round(TIMINGS.sparks / 2), i / SPARK_SEEDS.length)}/></text>`;
360  }).join("");
361};
362
363// ctx 42% ▰▰▱▱▱ · 5h 31% · 1w 12% · git main, after a warning when one is up.
364const bottomRow = (model, accent, shift = 0) => {
365  const { width, pad } = SVG_CARD;
366  const y = ROW_Y[4] + shift;
367  const groups = [];
368  const warning = model.warnings[0];
369  if (warning) {
370    const color = warning.tone === "red" ? C.red : C.amber;
371    groups.push({ pieces: [[`${warning.tone === "red" ? "✗" : "⚠"} ${warning.subject}`, { fill: color, bold: true }], [` ${warning.text}`, { fill: color }]] });
372  }
373  const { context, rates, git } = model.figures;
374  if (context !== null) groups.push({ pieces: [["ctx ", { fill: C.dim }], [`${context}%`, { fill: accent }]], bar: context });
375  for (const rate of rates) groups.push({ pieces: [[`${rate.label} `, { fill: C.dim }], [`${rate.percent}%`, { fill: levelColor(rate.percent) }]] });
376  if (git) groups.push({ pieces: [["git ", { fill: C.dim }], [git, { fill: accent }]] });
377  const out = [];
378  let x = pad;
379  groups.forEach((group, index) => {
380    const span = group.pieces.reduce((sum, [body]) => sum + widthOf(body), 0) + (group.bar !== undefined ? 60 : 0);
381    if (x + span + (index > 0 ? 24 : 0) > width - pad) return;
382    if (index > 0) {
383      out.push(text(x + 8, y, "·", { fill: C.faint }));
384      x += 24;
385    }
386    const drawn = run(x, y, group.pieces);
387    out.push(drawn.markup);
388    x = drawn.end;
389    if (group.bar !== undefined) {
390      const filled = Math.max(0, Math.min(5, Math.round(group.bar / 20)));
391      for (let i = 0; i < 5; i += 1) out.push(`<rect x="${(x + 8 + i * 10).toFixed(1)}" y="${y - 9}" width="7" height="8" rx="1.5" fill="${i < filled ? accent : C.empty}"/>`);
392      x += 60;
393    }
394  });
395  return out.join("");
396};
397
398const THREAD_FILL = Object.freeze({ running: ["●", C.green], paused: ["|", C.dim], blocked: ["!", C.amber], idle: ["-", C.dim], done: ["✓", C.green] });
399const TASK_MARK = Object.freeze({ shell: "$", monitor: "~" });
400const plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
401
402// What a thread with no total says instead of a gauge. Each new drawing restarts the card's
403// animations, so the drawing leaves out the last step (it changes on every tool call) and counts
404// steps by tens past ten.
405const threadFacts = ({ steps, elapsedMs, state }) => knownFacts({ steps: Number.isInteger(steps) && steps >= 10 ? Math.floor(steps / 10) * 10 : steps, elapsedMs, state })
406  .replace(/^(\d+0) steps/u, (all, n) => (steps >= 10 ? `${n}+ steps` : all));
407const PULSE = "━━━━━";
408// The indeterminate pulse over a thread with no total: a light that slides along its track, or a
409// still track with motion off.
410const pulseSvg = (x, y, animate, fill) => {
411  const w = widthOf(PULSE);
412  if (!animate) return "";
413  return `<rect x="${x.toFixed(1)}" y="${y - 8}" width="${(w / 3).toFixed(1)}" height="4" rx="2" fill="${fill}" opacity="0.9"><animate attributeName="x" values="${x.toFixed(1)};${(x + (w * 2) / 3).toFixed(1)};${x.toFixed(1)}" ${loop(TIMINGS.flow * 2, 0.25)}/></rect>`;
414};
415
416/**
417 * The thread rows (view.mjs threadsOf), as the terminal card lays them out (frame.mjs threadRows):
418 * the stack parent first with each child under it, lanes and background tasks under a fork line
419 * when two or more run, other sessions last, `+N more` on the last row. Returns the markup.
420 */
421const threadRowsSvg = (model, { animate = false, rewind = false } = {}) => {
422  const { width, pad } = SVG_CARD;
423  const { chain, lanes, others, laneCount = 0, taskCount = 0, more } = model.threads;
424  const lines = [];
425  chain.forEach((thread, index) => lines.push({ thread, lead: index === 0 ? "" : `${"   ".repeat(index - 1)}└─ ` }));
426  const under = "   ".repeat(Math.max(0, chain.length - 1));
427  if (laneCount + taskCount >= 2) {
428    const label = [laneCount ? plural(laneCount, "lane") : null, taskCount ? plural(taskCount, "task") : null].filter(Boolean).join(" · ");
429    lines.push({ fork: `${under}┬ ${label} in parallel` });
430    lanes.forEach((thread, index) => lines.push({ thread, lead: `${under}${index === lanes.length - 1 ? "└─" : "├─"} ` }));
431  } else {
432    lanes.forEach((thread) => lines.push({ thread, lead: chain.length ? `${under}└─ ` : "" }));
433  }
434  others.forEach((thread) => lines.push({ thread, lead: "" }));
435  const threads = lines.filter(({ thread }) => thread).map(({ thread }) => thread);
436  const nameCells = Math.min(28, Math.max(...threads.map(({ name }) => textWidth(name))));
437  const stageCells = Math.min(10, Math.max(0, ...threads.map(({ stage }) => (stage ? textWidth(stage) : 0))));
438  const out = [];
439  lines.forEach((line, index) => {
440    const y = ROW_Y[2] + THREAD_ROW_H * (index + 1);
441    const tail = index === lines.length - 1 && more > 0 ? `+${more} more` : null;
442    if (tail) out.push(text(width - pad, y, tail, { fill: C.dim, anchor: "end" }));
443    const room = Math.floor((width - pad * 2 - (tail ? widthOf(tail) + 16 : 0)) / SVG_CARD.cell);
444    if (line.fork) {
445      out.push(text(pad, y, truncateToWidth(line.fork, room), { fill: C.dim }));
446      return;
447    }
448    const { thread } = line;
449    const [stateMark, color] = THREAD_FILL[thread.state] ?? THREAD_FILL.running;
450    const mark = thread.task ? TASK_MARK[thread.task] ?? "$" : stateMark;
451    const name = truncateToWidth(thread.name, nameCells);
452    const drawn = gauge(thread.done, thread.total);
453    const facts = drawn ? null : threadFacts(thread);
454    const progress = drawn
455      ? [[` ${drawn}`, { fill: C.white }], [` ${thread.state}`, { fill: color }]]
456      : [[" ", {}], [PULSE, { fill: rewind ? "url(#lit-rainbow)" : C.rail, pulse: true }], [" ", {}],
457        ...(facts.startsWith(thread.state) ? [[facts, { fill: color }]] : [[thread.state, { fill: color }], [` · ${facts}`, { fill: C.dim }]])];
458    const pieces = [
459      [line.lead, { fill: C.dim }],
460      [`${mark} `, { fill: color, bold: true }],
461      [name + " ".repeat(Math.max(0, nameCells - textWidth(name))), { fill: C.white, bold: thread.active }],
462      ...(stageCells ? [[` ${truncateToWidth(thread.stage ?? "", stageCells).padEnd(stageCells)}`, { fill: C.dim }]] : []),
463      ...progress,
464      ...(thread.session ? [[` · other session ${thread.session}`, { fill: C.dim }]] : []),
465    ];
466    // Pieces past the room are cut from the right, as the terminal card clips its rows.
467    let used = 0;
468    const kept = [];
469    for (const [body, style] of pieces) {
470      const cells = textWidth(body);
471      if (used + cells > room) {
472        if (room - used > 1) kept.push([truncateToWidth(body, room - used), style]);
473        break;
474      }
475      kept.push([body, style]);
476      used += cells;
477    }
478    out.push(run(pad, y, kept).markup);
479    const before = kept.findIndex(([, style]) => style.pulse);
480    if (before >= 0) out.push(pulseSvg(pad + kept.slice(0, before).reduce((sum, [body]) => sum + widthOf(body), 0), y, animate, rewind ? "#ffffff" : C.white));
481  });
482  return out.join("");
483};
484
485const borderColor = (model, accent) => {
486  if (model.warnings.some(({ tone }) => tone === "red")) return C.red;
487  if (model.warnings.length) return C.amber;
488  return model.idle ? C.faint : accent;
489};
490
491// The rewind look's border (plans/lit-adhd.md D7): a rainbow gradient whose stops each walk the hue
492// wheel over TIMINGS.hue while the card moves, so the colours cycle; with motion off it stands still.
493const RAINBOW_STOPS = 6;
494const rainbowDefs = (animate) => {
495  const stops = Array.from({ length: RAINBOW_STOPS + 1 }, (_, i) => {
496    const color = rainbowAt(i / RAINBOW_STOPS);
497    if (!animate) return `<stop offset="${(i / RAINBOW_STOPS).toFixed(3)}" stop-color="${color}"/>`;
498    const values = Array.from({ length: RAINBOW_STOPS + 1 }, (_, j) => rainbowAt((i + j) / RAINBOW_STOPS)).join(";");
499    return `<stop offset="${(i / RAINBOW_STOPS).toFixed(3)}" stop-color="${color}"><animate attributeName="stop-color" values="${values}" ${loop(TIMINGS.hue, 0.5)}/></stop>`;
500  });
501  return `<defs><linearGradient id="lit-rainbow" x1="0" x2="1" y1="0" y2="1">${stops.join("")}</linearGradient></defs>`;
502};
503
504/** What the card says, for a reader that cannot see the drawing. */
505export const svgCardAlt = (model) => {
506  const parts = [`LIT ${model.rewind ? "rewind" : model.idle ? "idle" : "working"}`];
507  if (model.title) parts.push(model.title);
508  const flow = model.workflow;
509  const current = PHASES.find((id) => model.phases[id] === "now");
510  if (flow) {
511    const stage = flow.stages[flow.current];
512    parts.push(stage ? `stage ${stage.label} of ${flow.stages.map(({ id }) => id).join(", ")}` : "all stages done");
513    if (flow.verdicts) parts.push(flow.verdicts.map(({ name, state }) => `${name} ${state}`).join(", "));
514  } else if (current && !model.idle) {
515    parts.push(`phase ${LABELS[current]}`);
516  }
517  if (model.step) parts.push(`running ${model.step.name}`);
518  const threads = model.threads ? [...model.threads.chain, ...model.threads.lanes, ...model.threads.others] : [];
519  if (threads.length) parts.push(`threads ${threads.map((thread) => `${thread.name} ${gauge(thread.done, thread.total) ? `${gauge(thread.done, thread.total)} ${thread.state}` : threadFacts(thread)}`).join(", ")}${model.threads.more ? `, ${model.threads.more} more` : ""}`);
520  if (model.skills.length) parts.push(`skills ${model.skills.map(({ id }) => id).join(", ")}`);
521  const { context, rates } = model.figures;
522  if (context !== null) parts.push(`context ${context}%`);
523  for (const rate of rates) parts.push(`${rate.label} ${rate.percent}%`);
524  return parts.join(" · ");
525};
526
527/**
528 * The card as `{ source, alt, animated }`: an SVG document and its description. `motion` is the
529 * mod's setting; the drawing moves only while the model is not idle and motion is not off.
530 */
531export const composeSvgCard = (model, { motion = "full", accent = "#5fd7ff" } = {}) => {
532  const { width } = SVG_CARD;
533  // The thread rows sit between the detail line and the chips; the card grows by their reserve.
534  const shift = model.threads ? THREAD_ROW_H * model.threads.reserve : 0;
535  const height = SVG_CARD.height + shift;
536  const rewind = (model.rewind === true || (!model.idle && lookOf(model.discipline) === "rewind")) && model.warnings.length === 0;
537  const working = (!model.idle || rewind) && motion !== "off";
538  const animate = { breath: working, sweep: working && motion === "full", comet: working && motion === "full" };
539  const edge = rewind ? "url(#lit-rainbow)" : borderColor(model, accent);
540  const perimeter = 1000;
541  const defs = (rewind ? rainbowDefs(animate.breath) : "") + (animate.sweep
542    ? `<defs><linearGradient id="lit-sweep" x1="0" x2="1" y1="0" y2="0"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="0.5" stop-color="#fff" stop-opacity="0.55"/><stop offset="1" stop-color="#fff" stop-opacity="0"/><animateTransform attributeName="gradientTransform" type="translate" values="-1.2 0;1.2 0" ${loop(TIMINGS.sweep, 0.5)}/></linearGradient></defs>`
543    : "");
544  const frame = `<rect x="1" y="1" width="${width - 2}" height="${height - 2}" rx="14" fill="${C.bg}" stroke="${edge}" stroke-width="${model.idle && !rewind ? "1.5" : "2.5"}"${model.idle && !rewind ? ' stroke-opacity="0.8"' : ""}>`
545    + (animate.breath && !model.warnings.length ? `<animate attributeName="stroke-opacity" values="0.85;1;0.85" ${loop(TIMINGS.breath, BREATH_PHASE)}/>` : "")
546    + "</rect>";
547  const comet = animate.comet
548    ? `<rect x="1" y="1" width="${width - 2}" height="${height - 2}" rx="14" fill="none" stroke="url(#lit-comet)" stroke-width="2.5" stroke-linecap="round" pathLength="${perimeter}" stroke-dasharray="70 ${perimeter - 70}"><animate attributeName="stroke-dashoffset" values="0;-${perimeter}" ${loop(TIMINGS.comet * 2, 0.5)}/></rect>`
549      + `<defs><linearGradient id="lit-comet" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="${C.orange}"/><stop offset="0.6" stop-color="${C.pink}"/><stop offset="1" stop-color="#ffffff"/></linearGradient></defs>`
550    : "";
551  const body = [
552    topRow(model, accent, animate, rewind),
553    model.workflow ? mapRowSvg(model, accent, animate) : phaseRow(model, accent, animate),
554    detailRowSvg(model, accent, animate),
555    model.threads ? threadRowsSvg(model, { animate: animate.sweep, rewind }) : "",
556    middleRow(model, accent, animate, shift, rewind),
557    bottomRow(model, accent, shift),
558  ].join("");
559  const source = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" font-family="${FONT}" font-size="${SVG_CARD.font}">${defs}${frame}${comet}${body}</svg>`;
560  return { source, alt: svgCardAlt(model), animated: working };
561};
562
lib/mod/skills.mjs 86 lines
1// Which LitFamily skills fired in this session, for the chips row of the LIT card. The mod notes a
2// firing from a Skill tool call, a skill prompt expansion (a typed `/litclaude:<name>` or the Skill
3// tool) and a new ignition record from the prompt hook (bare `lit` routing); only LitClaude's own
4// skills and commands become chips. Pure: the mod keeps the list, the card draws it.
5
6// canonical-skill-catalog.mjs canonicalSkillIds, kept equal by a test; the catalog pulls in the
7// resource manifest, which the mod has no reason to load.
8export const LIT_SKILL_IDS = Object.freeze([
9  "autoconference",
10  "autoresearch",
11  "browser-drive",
12  "comment-checker",
13  "debugging",
14  "deep-interview",
15  "frontend-ui-ux",
16  "lit-adhd",
17  "lit-brainstorm",
18  "lit-burnoff",
19  "lit-burnoff-file",
20  "lit-code",
21  "lit-commit",
22  "lit-comprehend",
23  "lit-crucible",
24  "lit-diagram-drawer",
25  "lit-docx",
26  "lit-handoff",
27  "lit-hijack",
28  "lit-humanizer",
29  "lit-init",
30  "lit-loop",
31  "lit-plan",
32  "lit-pptx",
33  "lit-recap",
34  "lit-schematic",
35  "lit-scientific-visualization",
36  "lit-team",
37  "lit-typographic-motion",
38  "litgoal",
39  "litresearch",
40  "litwork",
41  "lsp",
42  "lsp-setup",
43  "readme-studio",
44  "refactor",
45  "review-work",
46  "rules",
47  "start-work",
48  "structural-search",
49  "visual-qa",
50  "wikify",
51]);
52
53const KNOWN = new Set(LIT_SKILL_IDS);
54const PLUGIN_PREFIX = "litclaude:";
55const KEEP = 12;
56// A Skill call and the prompt expansion it causes arrive moments apart; they are one firing.
57const SAME_FIRING_MS = 1500;
58
59/** The chip id for a skill or command name, or null when it is not LitClaude's. */
60export const litSkillId = (name) => {
61  const raw = typeof name === "string" ? name.trim() : "";
62  if (raw.startsWith(PLUGIN_PREFIX)) {
63    const id = raw.slice(PLUGIN_PREFIX.length);
64    return /^[a-z0-9][a-z0-9-]{0,63}$/u.test(id) ? id : null;
65  }
66  return KNOWN.has(raw) ? raw : null;
67};
68
69/** Notes one firing at `at`: newest first, one entry per skill, a short history. */
70export const noteSkill = (fired, id, at) => {
71  const previous = fired.find((entry) => entry.id === id);
72  if (previous && at >= previous.at && at - previous.at < SAME_FIRING_MS) return fired;
73  return [{ id, at }, ...fired.filter((entry) => entry.id !== id)].slice(0, KEEP);
74};
75
76/**
77 * Each chip's state: `current` when it fired after `settledAt` (the end of the last finished
78 * request), `recent` otherwise. Current chips come first; each group keeps newest first.
79 */
80export const chipStates = (fired, settledAt) => {
81  const ordered = [...fired].sort((a, b) => b.at - a.at);
82  const current = ordered.filter(({ at }) => at > settledAt).map((entry) => ({ ...entry, state: "current" }));
83  const recent = ordered.filter(({ at }) => at <= settledAt).map((entry) => ({ ...entry, state: "recent" }));
84  return [...current, ...recent];
85};
86
lib/mod/toasts.mjs 93 lines
1// The toasts the mod raises when the LIT state really changes: a discipline starts, switches or resumes, the
2// workflow stage moves, a goal is set or met, a lane starts or finishes. Pure: the mod keeps the last
3// status it saw and a gate, and hands each new status in; what comes back is at most one short line
4// to show, never more than one per TOAST_GAP_MS. Gate notices (state.mjs gates, the auto-handoff)
5// pass through the same gate as `{ kind: "notice", text }`.
6
7import { DISCIPLINE_ENTRY } from "./workflow.mjs";
8
9export const TOAST_GAP_MS = 3000;
10// A burst longer than this many lines ends in `+N more`.
11const SHOWN = 3;
12
13const cut = (text, cells) => (text.length > cells ? `${text.slice(0, cells - 1)}…` : text);
14
15/**
16 * What a toast compares: the discipline (sticky: a prompt or skill that names none keeps the last),
17 * the current stage, the goal and the running lanes by id. `discipline` is the one the session last
18 * lit, `model` the band model, `snapshot` the state snapshot.
19 */
20export const statusOf = ({ discipline, model, snapshot }, previous = null) => {
21  const flow = model?.workflow;
22  const stage = flow && flow.current >= 0 ? flow.stages[flow.current]?.id ?? null : null;
23  const goal = snapshot?.goal;
24  return {
25    discipline: DISCIPLINE_ENTRY[discipline] ? discipline : previous?.discipline ?? null,
26    stage: stage && `${flow.family}:${stage}`,
27    goal: goal ? { key: goal.createdAt ?? goal.objective, objective: String(goal.objective ?? ""), status: goal.status, passed: goal.criteria.filter(({ status }) => status === "pass").length, total: goal.criteria.length } : null,
28    paused: flow?.paused?.parent ?? null,
29    place: stage && `${flow.stages[flow.current].id}${flow.paused?.count ?? flow.count ? ` ${(flow.paused?.count ?? flow.count).done}/${(flow.paused?.count ?? flow.count).total}` : ""}`,
30    lanes: Object.fromEntries((model?.lanes?.list ?? []).filter(({ id }) => id).map(({ id, name }) => [id, name])),
31  };
32};
33
34const laneEntries = (ids, names, verb) => {
35  if (ids.length === 0) return [];
36  return [{ kind: `lane-${verb}`, text: ids.length === 1 ? `lane ${verb === "start" ? "started" : "finished"} · ${names[ids[0]]}` : `${ids.length} lanes ${verb === "start" ? "started" : "finished"}` }];
37};
38
39/** The changes from one status to the next, as toast entries; none for the first status seen. */
40export const statusChanges = (previous, next) => {
41  if (!previous) return [];
42  const entries = [];
43  if (previous.paused && !next.paused && next.discipline === previous.paused) entries.push({ kind: "resumed", text: `resumed ${next.discipline}${next.place ? ` · ${next.place}` : ""}` });
44  else if (next.discipline && next.discipline !== previous.discipline) entries.push({ kind: "discipline", from: previous.discipline, to: next.discipline });
45  else if (next.stage && next.stage !== previous.stage) entries.push({ kind: "stage", text: `stage · ${next.stage.split(":")[1]}` });
46  const goal = next.goal;
47  const sameGoal = goal && previous.goal?.key === goal.key;
48  if (goal && !sameGoal && goal.status !== "complete") entries.push({ kind: "goal", text: `goal set · ${cut(goal.objective, 40)}` });
49  if (goal?.status === "complete" && !(sameGoal && previous.goal.status === "complete")) entries.push({ kind: "goal", text: `goal met · ${goal.passed}/${goal.total}` });
50  const started = Object.keys(next.lanes).filter((id) => !(id in previous.lanes));
51  const finished = Object.keys(previous.lanes).filter((id) => !(id in next.lanes));
52  entries.push(...laneEntries(started, next.lanes, "start"), ...laneEntries(finished, previous.lanes, "finish"));
53  return entries;
54};
55
56/**
57 * One line for the entries that waited: a chain of switches shows its two ends (and nothing when it
58 * came back where it started), a stage only its newest, repeats once, past SHOWN lines `+N more`.
59 * Null when nothing is left to say.
60 */
61export const mergeToasts = (entries) => {
62  let switched = null;
63  let stage = null;
64  const lines = [];
65  for (const entry of entries) {
66    if (entry.kind === "discipline") {
67      switched = switched ? { ...switched, to: entry.to } : { from: entry.from, to: entry.to, at: lines.length };
68    } else if (entry.kind === "stage") {
69      stage = { text: entry.text, at: lines.length };
70    } else if (!lines.includes(entry.text)) {
71      lines.push(entry.text);
72    }
73  }
74  if (stage) lines.splice(stage.at, 0, stage.text);
75  if (switched && switched.from !== switched.to) lines.splice(switched.at, 0, switched.from ? `${switched.from} → ${switched.to}` : `${switched.to} started`);
76  if (lines.length === 0) return null;
77  const shown = lines.length > SHOWN ? [...lines.slice(0, SHOWN), `+${lines.length - SHOWN} more`] : lines;
78  return `🔥 ${shown.join(" · ")}`;
79};
80
81/**
82 * The rate gate: `{ lastAt, pending }`. Entries join what is pending; once TOAST_GAP_MS has passed
83 * since the last toast, all of it shows as one line. Otherwise `waitMs` says how long until it may.
84 */
85export const gateToast = (gate, entries, now) => {
86  const pending = [...gate.pending, ...entries];
87  if (pending.length === 0) return { gate, show: null, waitMs: null };
88  const waitMs = gate.lastAt + TOAST_GAP_MS - now;
89  if (waitMs > 0) return { gate: { ...gate, pending }, show: null, waitMs };
90  const show = mergeToasts(pending);
91  return { gate: { lastAt: show ? now : gate.lastAt, pending: [] }, show, waitMs: null };
92};
93
lib/mod/state.mjs 593 lines
1// Read-only view of LitClaude's own state (and the Git HEAD it works on) for the LIT band. Nothing here writes, spawns, fetches or
2// follows a link: every read goes through an injected `io` ({ stat, read }) that the mod backs with
3// `$.fs.stat(path, { resolve: true })` and `$.fs.read`, and the tests back with a map. A file is read
4// only when it is a regular file, under its size cap, not itself a link, and lands exactly where its
5// spelled path says once every link is resolved, so a symlinked `.litclaude` or state file is skipped.
6//
7// The workflow map's sources are read the same way: the newest deep-interview state, the newest
8// lit-plan draft, the newest litresearch session folder and the stage record. A folder is listed
9// through `io.list` when the host offers it; without it the map falls back to the other sources.
10//
11// The two roots mirror the hook: `.litclaude` sits at the outermost Git root (project-state-root.mjs),
12// while boulder.json is found the way `lifecycleRootFor` in bin/litclaude-hook.js finds it, walking up
13// from the working directory and across a linked Git worktree to its main checkout.
14
15import { parsePlanTaskRows } from "../plan-task-rows.mjs";
16import { parseStageRecord } from "./workflow.mjs";
17
18export const STATE_CAPS = Object.freeze({
19  boulder: 2 * 1024 * 1024,
20  goals: 512 * 1024,
21  ledger: 512 * 1024,
22  small: 64 * 1024,
23  gitMarker: 4096,
24});
25
26// Mirrors PROJECT_MARKERS in rules/constants.mjs, the fallback when no Git root exists.
27const PROJECT_MARKERS = Object.freeze([".git", "pnpm-workspace.yaml", "package.json", "pyproject.toml", "Cargo.toml", "go.mod", ".venv"]);
28export const AUTOLOOP_LIMITS = Object.freeze({ blocks: 8, ms: 30 * 60 * 1000 });
29export const GATE_LIMITS = Object.freeze({ litPlan: 2, probe: 2, motion: 2 });
30
31const splitPath = (path) => String(path).replaceAll("\\", "/").split("/").filter((part, index) => part !== "" || index === 0);
32
33export const normalizePath = (path) => {
34  const parts = [];
35  const raw = String(path).replaceAll("\\", "/");
36  const absolute = raw.startsWith("/");
37  const drive = /^[A-Za-z]:/u.exec(raw)?.[0] ?? "";
38  for (const part of raw.slice(drive.length).split("/")) {
39    if (part === "" || part === ".") continue;
40    if (part === "..") parts.pop();
41    else parts.push(part);
42  }
43  return `${drive}${absolute || drive ? "/" : ""}${parts.join("/")}`;
44};
45
46export const joinPath = (...parts) => normalizePath(parts.filter((part) => part !== "").join("/"));
47
48export const parentOf = (path) => {
49  const normalized = normalizePath(path);
50  const cut = normalized.lastIndexOf("/");
51  if (cut <= 0) return normalized.slice(0, cut + 1) || normalized;
52  const parent = normalized.slice(0, cut);
53  return /^[A-Za-z]:$/u.test(parent) ? `${parent}/` : parent;
54};
55
56const isAbsolute = (path) => /^(?:[A-Za-z]:)?[\\/]/u.test(String(path));
57export const resolvePath = (base, path) => (isAbsolute(path) ? normalizePath(path) : joinPath(base, path));
58
59const ancestors = (start) => {
60  const list = [];
61  let current = normalizePath(start);
62  for (;;) {
63    list.push(current);
64    const parent = parentOf(current);
65    if (parent === current) return list;
66    current = parent;
67  }
68};
69
70const statOf = async (io, path) => {
71  try {
72    return (await io.stat(path)) ?? null;
73  } catch {
74    return null;
75  }
76};
77
78export const resolveStateRoot = async (io, cwd) => {
79  const chain = ancestors(cwd);
80  let outermostGit = null;
81  for (const directory of chain) if (await statOf(io, joinPath(directory, ".git"))) outermostGit = directory;
82  if (outermostGit) return outermostGit;
83  for (const directory of chain) {
84    for (const marker of PROJECT_MARKERS) if (await statOf(io, joinPath(directory, marker))) return directory;
85  }
86  return normalizePath(cwd);
87};
88
89// The main checkout's run reaches a linked worktree only when the run's worktree covers cwd or the
90// session is one of the run's; a sibling worktree reads its own state (the hook's rule, mirrored).
91const governsLinkedCwd = async (io, mainRoot, cwd, sessionId) => {
92  const state = await readGuardedJson(io, mainRoot, joinPath(".litclaude", "boulder.json"), STATE_CAPS.boulder);
93  const work = state?.active_work_id ? state.works?.[state.active_work_id] : null;
94  if (!work || !["active", "paused"].includes(work.status)) return true;
95  if (typeof sessionId === "string" && Array.isArray(work.session_ids) && work.session_ids.includes(sessionId)) return true;
96  const scope = splitPath(normalizePath(String(work.effective_worktree_root ?? work.worktree_path ?? mainRoot)).normalize("NFC"));
97  const here = splitPath(normalizePath((await statOf(io, cwd))?.realPath ?? cwd).normalize("NFC"));
98  return scope.every((part, index) => here[index] === part);
99};
100
101export const resolveLifecycleRoot = async (io, cwd, stateRoot, sessionId = null) => {
102  for (const directory of ancestors(cwd)) {
103    if (await statOf(io, joinPath(directory, ".litclaude", "boulder.json"))) return directory;
104    const marker = joinPath(directory, ".git");
105    const stat = await statOf(io, marker);
106    if (stat?.kind === "file" && !stat.isLink && stat.size <= STATE_CAPS.gitMarker) {
107      let text = "";
108      try {
109        text = await io.read(marker);
110      } catch {
111        text = "";
112      }
113      const match = /^gitdir:\s*(.+?)\s*$/mu.exec(text);
114      if (match) {
115        const worktreesDir = parentOf(resolvePath(directory, match[1]));
116        if (splitPath(worktreesDir).at(-1) === "worktrees") {
117          const mainRoot = parentOf(parentOf(worktreesDir));
118          if (await statOf(io, joinPath(mainRoot, ".litclaude", "boulder.json")) && await governsLinkedCwd(io, mainRoot, cwd, sessionId)) return mainRoot;
119        }
120      }
121    }
122  }
123  return stateRoot;
124};
125
126// One guarded read. `root` is the directory the file must stay under; the comparison is made on
127// resolved paths so a link anywhere between the root and the file shows up as a mismatch.
128export const readGuarded = async (io, root, relative, cap) => {
129  const path = joinPath(root, relative);
130  const stat = await statOf(io, path);
131  if (!stat || stat.isLink || stat.kind !== "file" || !(stat.size <= cap)) return null;
132  if (stat.realPath !== undefined) {
133    const rootStat = await statOf(io, root);
134    const realRoot = rootStat?.realPath;
135    if (realRoot === undefined || normalizePath(stat.realPath) !== joinPath(realRoot, relative)) return null;
136  }
137  try {
138    const text = await io.read(path);
139    return typeof text === "string" && text.length <= cap ? text : null;
140  } catch {
141    return null;
142  }
143};
144
145export const readGuardedJson = async (io, root, relative, cap) => {
146  const text = await readGuarded(io, root, relative, cap);
147  if (text === null) return null;
148  try {
149    const value = JSON.parse(text);
150    return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
151  } catch {
152    return null;
153  }
154};
155
156const str = (value, max = 400) => (typeof value === "string" && value.length > 0 ? value.slice(0, max) : null);
157const int = (value) => (Number.isInteger(value) && value >= 0 ? value : 0);
158const num = (value) => (Number.isFinite(value) ? value : null);
159const time = (value) => {
160  const parsed = typeof value === "string" ? Date.parse(value) : Number.NaN;
161  return Number.isFinite(parsed) ? parsed : null;
162};
163
164// Per-session file names, spelled as hud-ignition.mjs (ignition) and auto-handoff.mjs (session) do.
165export const ignitionFileName = (sessionId) => {
166  const raw = typeof sessionId === "string" && sessionId.trim() ? sessionId.trim() : "unknown-session";
167  return `${raw.replace(/[^A-Za-z0-9._-]/gu, "_").replace(/^\.+/u, "_")}.json`;
168};
169const handoffSessionId = (sessionId) => (typeof sessionId === "string" ? sessionId.replace(/[^A-Za-z0-9_-]/gu, "").slice(0, 64) : "");
170
171export const ignitionRoot = (env) => {
172  const override = env?.LITCLAUDE_HUD_STATE_ROOT?.trim();
173  if (override) return normalizePath(override);
174  return joinPath(env?.TMPDIR?.trim() || "/tmp", "litclaude-hud");
175};
176
177export const parseIgnition = (record, sessionId) => {
178  if (!record || (record.sessionId !== null && record.sessionId !== sessionId)) return null;
179  const discipline = str(record.discipline, 80);
180  return discipline ? { discipline, at: time(record.at) } : null;
181};
182
183const workBelongs = (work, sessionId) =>
184  work.root_session_id === sessionId
185  || work.active_session_id === sessionId
186  || (Array.isArray(work.session_ids) && work.session_ids.includes(sessionId));
187
188const laneList = (lanes) => Object.values(lanes && typeof lanes === "object" ? lanes : {})
189  .filter((lane) => lane && typeof lane === "object")
190  .map((lane) => ({
191    id: str(lane.agent_id, 120) ?? "lane",
192    type: str(lane.agent_type, 80) ?? "agent",
193    status: str(lane.status, 40) ?? "unknown",
194    startedAt: time(lane.started_at),
195  }))
196  .slice(0, 32);
197
198export const parseBoulder = (state, sessionId) => {
199  if (!state || !state.works || typeof state.works !== "object") return { work: null, completed: [] };
200  const completed = Object.values(state.works)
201    .filter((work) => work && work.status === "completed" && workBelongs(work, sessionId))
202    .map((work) => str(work.work_id, 120))
203    .filter(Boolean);
204  const active = state.active_work_id ? state.works[state.active_work_id] : null;
205  if (!active || !["active", "paused"].includes(active.status) || !workBelongs(active, sessionId)) return { work: null, completed };
206  const progress = active.progress && typeof active.progress === "object" ? active.progress : {};
207  const total = int(progress.total);
208  return {
209    work: {
210      id: str(active.work_id, 120) ?? String(state.active_work_id),
211      status: active.status,
212      planName: str(active.plan_name, 200),
213      total,
214      checked: Math.min(int(progress.checked), total),
215      nextTask: str(progress.next_task, 400),
216      lanes: laneList(active.lanes),
217      lastEvent: str(Array.isArray(active.events) ? active.events.at(-1)?.event : null, 60),
218    },
219    completed,
220  };
221};
222
223const CRITERION_STATUSES = new Set(["pass", "fail", "blocked", "pending"]);
224
225export const parseGoals = (state, sessionId) => {
226  if (!state || typeof state.status !== "string") return null;
227  if (typeof state.sessionId === "string" && state.sessionId !== sessionId) return null;
228  const criteria = (Array.isArray(state.criteria) ? state.criteria : []).slice(0, 40).map((criterion, index) => {
229    const evidence = Array.isArray(criterion?.evidence) ? criterion.evidence : [];
230    const status = CRITERION_STATUSES.has(criterion?.status) ? criterion.status : "pending";
231    return {
232      id: str(criterion?.id, 80) ?? `criterion-${index + 1}`,
233      description: str(criterion?.description, 300) ?? "",
234      status,
235      attempts: evidence.length,
236      lastAt: time(evidence.at(-1)?.recordedAt),
237    };
238  });
239  return {
240    objective: str(state.objective, 400) ?? "",
241    status: state.status,
242    autoloop: state.autoloop === true,
243    criteria,
244    blockers: (Array.isArray(state.blockers) ? state.blockers : []).slice(0, 10)
245      .map((blocker) => str(typeof blocker === "string" ? blocker : blocker?.note ?? blocker?.description, 300))
246      .filter(Boolean),
247    lastCheckpoint: str(Array.isArray(state.checkpoints) ? state.checkpoints.at(-1)?.status : null, 40),
248    createdAt: time(state.createdAt),
249    // When the goal was finished: its last `complete` checkpoint, else its last update.
250    completedAt: state.status === "complete"
251      ? time((Array.isArray(state.checkpoints) ? state.checkpoints : []).findLast((entry) => entry?.status === "complete")?.createdAt) ?? time(state.updatedAt)
252      : null,
253  };
254};
255
256export const parseAutoloop = (state) => {
257  if (!state || !Number.isInteger(state.blockCount) || state.blockCount < 0 || !Number.isFinite(state.firstBlockAt)) return null;
258  return { blockCount: state.blockCount, firstBlockAt: state.firstBlockAt };
259};
260
261const forSession = (state, sessionId) => (state && state.sessionId === sessionId ? state : null);
262
263export const parseLitPlanTurn = (state, sessionId) => {
264  const turn = forSession(state, sessionId);
265  return turn ? { blockCount: int(turn.blockCount), warned: turn.warned === true, promptAt: num(turn.promptAt) } : null;
266};
267
268export const parseProbeTurn = (state, sessionId) => {
269  const turn = forSession(state, sessionId);
270  if (!turn) return null;
271  return {
272    editAt: num(turn.editAt),
273    probeAt: num(turn.probeAt),
274    issue: str(turn.issue, 160),
275    issueAt: num(turn.issueAt),
276    paths: (Array.isArray(turn.paths) ? turn.paths : []).map((path) => str(path, 200)).filter(Boolean).slice(0, 5),
277    blockCount: int(turn.blockCount),
278  };
279};
280
281export const parseMotionTurn = (state, sessionId) => {
282  const turn = forSession(state, sessionId);
283  return turn ? { blockCount: int(turn.blockCount), runs: Array.isArray(turn.runs) ? turn.runs.length : 0, notified: turn.notified === true } : null;
284};
285
286const percentOf = (raw) => {
287  const text = typeof raw === "number" ? String(raw) : raw;
288  if (typeof text !== "string" || !/^(?:[1-9]\d?)$/u.test(text.trim())) return null;
289  return Number(text.trim());
290};
291
292// The environment wins over the saved choice, as resolveAutoHandoff in auto-handoff.mjs decides.
293export const parseAutoHandoff = (settings, session, env) => {
294  const flag = env?.LITCLAUDE_AUTO_HANDOFF;
295  const enabled = flag !== undefined ? flag === "1" : settings?.enabled === true;
296  const percent = percentOf(env?.LITCLAUDE_AUTO_HANDOFF_PERCENT !== undefined ? env.LITCLAUDE_AUTO_HANDOFF_PERCENT : settings?.percent ?? null);
297  if (!enabled || percent === null) return null;
298  return {
299    percent,
300    firedAt: num(session?.firedAt),
301    firedAtPercent: num(session?.firedAtPercent),
302    pending: session?.pending === true,
303  };
304};
305
306const listOf = async (io, path) => {
307  if (typeof io.list !== "function") return [];
308  try {
309    const entries = await io.list(path);
310    return Array.isArray(entries) ? entries.slice(0, 400) : [];
311  } catch {
312    return [];
313  }
314};
315
316const newestFile = (entries, pattern) => entries
317  .filter((entry) => entry?.kind === "file" && !entry.isLink && pattern.test(entry.name))
318  .sort((a, b) => b.mtimeMs - a.mtimeMs)[0] ?? null;
319
320export const parseInterviewState = (state, at) => {
321  const inner = state?.state && typeof state.state === "object" ? state.state : null;
322  if (!inner) return null;
323  return {
324    active: state.active === true,
325    round: Array.isArray(inner.rounds) ? inner.rounds.length : null,
326    max: Number.isInteger(inner.max_rounds) && inner.max_rounds > 0 ? inner.max_rounds : null,
327    ambiguity: num(inner.current_ambiguity),
328    threshold: num(inner.threshold),
329    at,
330  };
331};
332
333// The draft's resume gate: the last `status:` line wins, as the approval gate sits below the header.
334export const parseDraftStatus = (text, slug, at) => {
335  const statuses = [...String(text).matchAll(/^status:\s*([a-z-]+)\s*$/gmu)].map((match) => match[1]);
336  return statuses.length ? { slug, status: statuses.at(-1), at } : null;
337};
338
339export const summarizeResearchFiles = (entries, slug) => {
340  const files = entries.filter((entry) => entry?.kind === "file" && !entry.isLink);
341  const waves = files.map(({ name }) => /^wave-(\d{1,3})-.*\.md$/u.exec(name)).filter(Boolean).map((match) => Number(match[1]));
342  return {
343    slug,
344    wave: waves.length ? Math.max(...waves) : 0,
345    digests: waves.length,
346    verified: files.filter(({ name }) => /^verify-.*\.md$/u.test(name)).length,
347    synthesis: files.some(({ name }) => name === "SYNTHESIS.md"),
348    at: files.reduce((latest, { mtimeMs }) => Math.max(latest, Number.isFinite(mtimeMs) ? mtimeMs : 0), 0) || null,
349  };
350};
351
352const FRAME_SKILL = /^[a-z][a-z0-9-]{0,40}$/u;
353// The plan a frame works from, as lib/workflow-stack.mjs keeps it: a project-relative `plans/<name>.md`.
354const FRAME_PLAN = /^plans\/[A-Za-z0-9_-][A-Za-z0-9._-]*\.md$/u;
355
356/**
357 * The session's workflow stack (lib/workflow-stack.mjs) as the card trusts it: at most three frames,
358 * each with its skill, role, status and stage record, and the frame whose map the card draws, which
359 * is the nearest paused spine while a side frame is on top and the top frame otherwise. The owner's
360 * record carries the top frame's time, so a side frame keeps the paused map fresh.
361 */
362export const parseWorkflowStack = (stack, sessionId) => {
363  if (!stack || typeof stack !== "object" || !Array.isArray(stack.frames) || !stack.frames.length || stack.frames.length > 3) return null;
364  const frames = stack.frames.map((frame) => ({
365    skill: typeof frame?.skill === "string" && FRAME_SKILL.test(frame.skill) ? frame.skill : null,
366    role: frame?.role === "side" ? "side" : "spine",
367    status: frame?.status === "paused" ? "paused" : "active",
368    record: parseStageRecord({ ...frame, sessionId: undefined }, sessionId),
369    at: typeof frame?.at === "string" ? Date.parse(frame.at) : Number.NaN,
370    planRef: typeof frame?.ref?.plan === "string" && FRAME_PLAN.test(frame.ref.plan) && !frame.ref.plan.includes("..") ? frame.ref.plan : null,
371    plan: null,
372  }));
373  const top = frames.at(-1);
374  const owner = top.role === "side" ? frames.findLast(({ role }) => role === "spine") ?? top : top;
375  const record = owner.record && Number.isFinite(top.at) ? { ...owner.record, at: Math.max(owner.record.at, top.at), stacked: true } : null;
376  return { frames, owner: frames.indexOf(owner), record };
377};
378
379/** A plan's `{ done, total }` by the start-work checkbox grammar, or null when it has no task rows. */
380export const countPlanTasks = (text) => {
381  const rows = parsePlanTaskRows(String(text ?? ""));
382  return rows.length ? { done: rows.filter(({ checked }) => checked).length, total: rows.length } : null;
383};
384
385// Each frame that names a plan gets that plan's checkbox counts, read once per plan under the cap.
386const withPlanCounts = async (io, stateRoot, frames) => {
387  const refs = [...new Set(frames.map(({ planRef }) => planRef).filter(Boolean))];
388  const counts = new Map(await Promise.all(refs.map(async (ref) => [ref, countPlanTasks(await readGuarded(io, stateRoot, ref, STATE_CAPS.goals))])));
389  for (const frame of frames) frame.plan = frame.planRef ? counts.get(frame.planRef) ?? null : null;
390  return frames;
391};
392
393const OTHER_STACKS = 6;
394
395// The stacks other sessions of this project keep beside this one's, newest first and at most
396// OTHER_STACKS: `{ id8, at, frames }` each, `at` being the file's mtime. Links, this session's own
397// file and the unknown-session file are skipped; whether one is still live is the view's call.
398const readOtherStacks = async (io, stateRoot, sessionId) => {
399  const own = new Set([ignitionFileName(sessionId), ignitionFileName(null)]);
400  const files = (await listOf(io, joinPath(stateRoot, ".litclaude", "workflow-stack")))
401    .filter((entry) => entry?.kind === "file" && !entry.isLink && /^[A-Za-z0-9._-]+\.json$/u.test(entry.name) && !own.has(entry.name))
402    .sort((a, b) => b.mtimeMs - a.mtimeMs)
403    .slice(0, OTHER_STACKS);
404  const read = await Promise.all(files.map(async (entry) => {
405    const raw = await readGuardedJson(io, stateRoot, joinPath(".litclaude", "workflow-stack", entry.name), STATE_CAPS.small);
406    const id = typeof raw?.sessionId === "string" ? raw.sessionId : null;
407    const parsed = id ? parseWorkflowStack(raw, id) : null;
408    if (parsed) await withPlanCounts(io, stateRoot, parsed.frames.slice(-1));
409    return parsed ? { id8: id.replace(/[^A-Za-z0-9]/gu, "").slice(0, 8), at: entry.mtimeMs, frames: parsed.frames } : null;
410  }));
411  return read.filter(Boolean);
412};
413
414const readWorkflowSources = async (io, stateRoot, sessionId) => {
415  const dir = (name) => joinPath(stateRoot, ".litclaude", name);
416  const [interviews, drafts, researches, ownStack, unknownStack] = await Promise.all([
417    listOf(io, dir("deep-interview")),
418    listOf(io, dir("drafts")),
419    listOf(io, dir("litresearch")),
420    readGuardedJson(io, stateRoot, joinPath(".litclaude", "workflow-stack", ignitionFileName(sessionId)), STATE_CAPS.small),
421    readGuardedJson(io, stateRoot, joinPath(".litclaude", "workflow-stack", ignitionFileName(null)), STATE_CAPS.small),
422  ]);
423  const stack = parseWorkflowStack(ownStack, sessionId) ?? parseWorkflowStack(unknownStack, sessionId);
424  if (stack) {
425    await withPlanCounts(io, stateRoot, stack.frames);
426    if (stack.record) stack.record.plan = stack.frames[stack.owner].plan;
427  }
428  const others = await readOtherStacks(io, stateRoot, sessionId);
429  // A record written before the stack existed, read only while this session has no stack.
430  const legacy = stack ? null : await readGuardedJson(io, stateRoot, ".litclaude/workflow-stage.json", STATE_CAPS.small);
431  const interviewFile = newestFile(interviews, /^[A-Za-z0-9._-]+-state\.json$/u);
432  const draftFile = newestFile(drafts, /^[A-Za-z0-9._-]+\.md$/u);
433  let research = null;
434  const sessions = researches.filter((entry) => entry?.kind === "dir" && !entry.isLink && /^[A-Za-z0-9._-]+$/u.test(entry.name)).slice(0, 30);
435  if (sessions.length) {
436    const stamped = await Promise.all(sessions.map(async ({ name }) => ({ name, at: (await statOf(io, joinPath(dir("litresearch"), name)))?.mtimeMs ?? 0 })));
437    const newest = stamped.sort((a, b) => b.at - a.at)[0];
438    research = summarizeResearchFiles(await listOf(io, joinPath(dir("litresearch"), newest.name)), newest.name);
439  }
440  const [interview, draft] = await Promise.all([
441    interviewFile ? readGuardedJson(io, stateRoot, `.litclaude/deep-interview/${interviewFile.name}`, STATE_CAPS.small) : null,
442    draftFile ? readGuarded(io, stateRoot, `.litclaude/drafts/${draftFile.name}`, STATE_CAPS.goals) : null,
443  ]);
444  return {
445    record: stack?.record ?? parseStageRecord(legacy, sessionId),
446    stack,
447    others,
448    interview: interview ? parseInterviewState(interview, interviewFile.mtimeMs) : null,
449    draft: draft === null ? null : parseDraftStatus(draft, draftFile.name.replace(/\.md$/u, ""), draftFile.mtimeMs),
450    research,
451  };
452};
453
454export const readLitSnapshot = async (io, { cwd, sessionId, env = {} }) => {
455  const stateRoot = await resolveStateRoot(io, cwd);
456  const lifecycleRoot = await resolveLifecycleRoot(io, cwd, stateRoot, sessionId);
457  const state = (relative, cap = STATE_CAPS.small) => readGuardedJson(io, stateRoot, joinPath(".litclaude", relative), cap);
458  const handoffId = handoffSessionId(sessionId);
459  const [ignition, boulder, goals, autoloop, litPlan, probe, motion, handoffSettings, handoffSession] = await Promise.all([
460    readGuardedJson(io, ignitionRoot(env), ignitionFileName(sessionId), STATE_CAPS.small),
461    readGuardedJson(io, lifecycleRoot, joinPath(".litclaude", "boulder.json"), STATE_CAPS.boulder),
462    state("litgoal/goals.json", STATE_CAPS.goals),
463    state("litgoal/autoloop.json"),
464    state("lit-plan/turn.json"),
465    state("interface-probe/turn.json"),
466    state("motion/turn.json"),
467    state("auto-handoff/settings.json"),
468    handoffId ? state(`auto-handoff/session-${handoffId}.json`) : null,
469  ]);
470  const { work, completed } = parseBoulder(boulder, sessionId);
471  const workflow = await readWorkflowSources(io, stateRoot, sessionId);
472  return {
473    stateRoot,
474    lifecycleRoot,
475    ignition: parseIgnition(ignition, sessionId),
476    work,
477    completedWorkIds: completed,
478    goal: parseGoals(goals, sessionId),
479    autoloop: parseAutoloop(autoloop),
480    litPlan: parseLitPlanTurn(litPlan, sessionId),
481    probe: parseProbeTurn(probe, sessionId),
482    motion: parseMotionTurn(motion, sessionId),
483    handoff: parseAutoHandoff(handoffSettings, handoffSession, env),
484    workflow,
485  };
486};
487
488const readSmall = async (io, path, cap) => {
489  const stat = await statOf(io, path);
490  if (!stat || stat.kind !== "file" || !(stat.size <= cap)) return null;
491  try {
492    const text = await io.read(path);
493    return typeof text === "string" && text.length <= cap ? text : null;
494  } catch {
495    return null;
496  }
497};
498
499/**
500 * The branch the working directory is on, for the card's bottom edge: the nearest `.git` above
501 * `cwd`, followed through a linked worktree's `gitdir:` file, and its HEAD read as text. A
502 * detached HEAD gives the short commit; anything unreadable gives null.
503 */
504export const readGitBranch = async (io, cwd) => {
505  for (const directory of ancestors(cwd)) {
506    const marker = joinPath(directory, ".git");
507    const stat = await statOf(io, marker);
508    if (!stat) continue;
509    let gitDir = marker;
510    if (stat.kind === "file") {
511      const pointer = await readSmall(io, marker, STATE_CAPS.gitMarker);
512      const match = pointer === null ? null : /^gitdir:\s*(.+?)\s*$/mu.exec(pointer);
513      if (!match) return null;
514      gitDir = resolvePath(directory, match[1]);
515    }
516    const head = (await readSmall(io, joinPath(gitDir, "HEAD"), STATE_CAPS.gitMarker))?.trim();
517    if (!head) return null;
518    const ref = /^ref:\s*refs\/heads\/(.+)$/u.exec(head);
519    if (ref) return ref[1].slice(0, 120);
520    return /^[0-9a-f]{7,64}$/u.test(head) ? head.slice(0, 7) : null;
521  }
522  return null;
523};
524
525// The icon font the installer's opt-in step writes (bin/icon-font.mjs), or the proportional
526// symbols font a person installed some other way, in each per-user font folder.
527const ICON_FONT_NAMES = Object.freeze(["SymbolsNerdFontMono-Regular.ttf", "SymbolsNerdFont-Regular.ttf"]);
528
529export const iconFontCandidates = (env) => {
530  const home = env?.HOME?.trim();
531  const folders = [];
532  if (home) folders.push(joinPath(home, "Library", "Fonts"));
533  const data = env?.XDG_DATA_HOME?.trim() || (home ? joinPath(home, ".local", "share") : "");
534  if (data) folders.push(joinPath(data, "fonts"));
535  const local = env?.LOCALAPPDATA?.trim();
536  if (local) folders.push(joinPath(local, "Microsoft", "Windows", "Fonts"));
537  return folders.flatMap((folder) => ICON_FONT_NAMES.map((name) => joinPath(folder, name)));
538};
539
540/**
541 * Whether the icon font is installed, by stat alone (the font is never read). `note` says what
542 * the band decided and why, for the evidence pane; a folder that cannot be checked counts as no
543 * font, so the band falls back to basic glyphs rather than drawing boxes.
544 */
545export const detectIconFont = async (io, env) => {
546  const candidates = iconFontCandidates(env);
547  if (candidates.length === 0) return { present: false, path: null, note: "basic: no home folder to look in for the icon font" };
548  let unreadable = false;
549  for (const path of candidates) {
550    try {
551      const stat = await io.stat(path);
552      if (stat?.kind === "file") return { present: true, path, note: `nerd: Symbols Nerd Font found at ${path}` };
553    } catch (error) {
554      if (!/ENOENT|no such file|not found/iu.test(String(error?.message ?? error))) unreadable = true;
555    }
556  }
557  return unreadable
558    ? { present: false, path: null, note: "basic: the font folders could not be checked from the band" }
559    : { present: false, path: null, note: "basic: Symbols Nerd Font not found (litclaude-ai install --icon-font)" };
560};
561
562// The litgoal ledger's newest entries for the evidence pane, newest first. Lines that are not JSON
563// objects are skipped; the file is read only under its cap.
564// A workflow stack event's place: `lit-loop RED 1/4`.
565const stackDetail = (entry) => {
566  if (!String(entry.event ?? "").startsWith("workflow.") || typeof entry.skill !== "string") return null;
567  const count = Number.isInteger(entry.n) && Number.isInteger(entry.total) ? ` ${entry.n}/${entry.total}` : "";
568  return `${entry.skill}${typeof entry.stage === "string" ? ` ${entry.stage}${count}` : ""}`;
569};
570
571export const readLedgerTail = async (io, stateRoot, limit = 8) => {
572  const text = await readGuarded(io, stateRoot, ".litclaude/litgoal/ledger.jsonl", STATE_CAPS.ledger);
573  if (text === null) return [];
574  const entries = [];
575  for (const line of text.split("\n").reverse()) {
576    if (entries.length >= limit) break;
577    if (!line.trim()) continue;
578    try {
579      const entry = JSON.parse(line);
580      if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
581      entries.push({
582        event: str(entry.event, 60) ?? "event",
583        at: time(entry.timestamp),
584        detail: str(entry.criterionId ?? entry.note ?? entry.objective ?? stackDetail(entry) ?? entry.status, 120) ?? "",
585        status: str(entry.status, 40),
586      });
587    } catch {
588      // A torn final line from a concurrent append; the next refresh reads it whole.
589    }
590  }
591  return entries;
592};
593
lib/mod/timeline.mjs 231 lines
1// The band's timeline: which animations are running and since when. The surface module keeps one
2// timeline in its local state, folds every new model into it (advanceTimeline), samples it once per
3// frame (sampleTimeline) and stops its frame timer as soon as nothing moves (timelineBusy). Times are
4// the surface module's own frame clock in milliseconds; nothing here reads a wall clock.
5
6import { EASE, TIMINGS, isSettled, phase, retarget, sample, still } from "./motion.mjs";
7
8const PHASES = ["plan", "work", "review"];
9// The count the bar follows: the workflow stage's n/m, else the plan or goal progress.
10const countOf = (model) => model.workflow?.count ?? model.progress ?? null;
11const fractionOf = (model) => {
12  const count = countOf(model);
13  return count && count.total > 0 ? Math.min(1, count.done / count.total) : 0;
14};
15
16// The live step's clock: the elapsed time the model carried and the frame it arrived on, so the
17// surface counts on between models.
18const stepOf = (model, now) => (model.step ? { name: model.step.name, base: model.step.elapsedMs, at: now } : null);
19
20// The lanes' clock, kept the same way: the longest-running lane's time as the model carried it.
21const lanesOf = (model, now) => {
22  const times = (model.lanes?.list ?? []).map(({ elapsedMs }) => elapsedMs).filter(Number.isFinite);
23  return times.length ? { base: Math.max(...times), at: now } : null;
24};
25
26const entryEnd = (profile) => 3 * profile.duration(TIMINGS.stagger) + profile.duration(TIMINGS.entry);
27
28// One entry per chip: `at` is the firing the model reported, `arrivedAt` when this timeline saw
29// that firing arrive (null for chips already there when the band appeared, so they do not shimmer),
30// and the state crossfade from `from` to `state` starting at `changedAt`.
31const chipsOf = (model, now, previous = null) => Object.fromEntries((model.skills ?? []).map((chip) => {
32  const known = previous?.[chip.id];
33  if (!known || known.at !== chip.at) {
34    return [chip.id, { at: chip.at, arrivedAt: previous === null ? null : now, state: chip.state, from: chip.state, changedAt: now }];
35  }
36  if (known.state !== chip.state) return [chip.id, { ...known, from: known.state, state: chip.state, changedAt: now }];
37  return [chip.id, known];
38}));
39
40// The ambient loop runs while the card shows LitClaude at work, so motion itself says "active",
41// and while the rewind look lingers after a brief; `at` is when the current run began, so the loop
42// does not restart on every new model.
43const ambientOf = (model, now, profile, previous = null) => {
44  const on = Boolean(profile.ambient) && model.visible && (!model.idle || model.rewind === true);
45  return { on, at: on && previous?.on ? previous.at : now };
46};
47
48export const createTimeline = (model, now, profile) => {
49  const restAt = now + entryEnd(profile);
50  return {
51    ambient: ambientOf(model, now, profile),
52    enterAt: now,
53    exitAt: null,
54    fill: { from: 0, to: fractionOf(model), at: restAt, dur: profile.duration(TIMINGS.fill) },
55    done: countOf(model)?.done ?? null,
56    flashAt: null,
57    step: stepOf(model, now),
58    lanes: lanesOf(model, now),
59    warn: still(model.ctxWarning ? 1 : 0),
60    phases: Object.fromEntries(PHASES.map((id) => [id, { from: model.phases[id], to: model.phases[id], at: now }])),
61    notice: model.notice ? { data: model.notice, at: restAt } : null,
62    leaving: null,
63    completion: model.completion ? { data: model.completion, at: now } : null,
64    chips: chipsOf(model, now),
65  };
66};
67
68export const advanceTimeline = (timeline, model, now, profile) => {
69  const next = { ...timeline };
70  if (!model.visible && timeline.exitAt === null) next.exitAt = now;
71  if (model.visible && timeline.exitAt !== null) return createTimeline(model, now, profile);
72  next.fill = retarget(timeline.fill, fractionOf(model), now, profile.duration(TIMINGS.fill));
73  const done = countOf(model)?.done ?? null;
74  if (done !== timeline.done) {
75    next.done = done;
76    if (timeline.done !== null && done !== null) next.flashAt = now;
77  }
78  next.warn = retarget(timeline.warn, model.ctxWarning ? 1 : 0, now, profile.duration(TIMINGS.crossfade));
79  next.phases = Object.fromEntries(PHASES.map((id) => {
80    const current = timeline.phases[id];
81    return [id, current.to === model.phases[id] ? current : { from: current.to, to: model.phases[id], at: now }];
82  }));
83  const incoming = model.notice?.id ?? null;
84  if (incoming !== (timeline.notice?.data.id ?? null)) {
85    next.leaving = timeline.notice ? { data: timeline.notice.data, at: now } : timeline.leaving;
86    next.notice = model.notice ? { data: model.notice, at: now } : null;
87  }
88  if ((model.completion?.id ?? null) !== (timeline.completion?.data.id ?? null)) {
89    next.completion = model.completion ? { data: model.completion, at: now } : null;
90  }
91  next.chips = chipsOf(model, now, timeline.chips);
92  next.ambient = ambientOf(model, now, profile, timeline.ambient);
93  next.step = stepOf(model, now);
94  next.lanes = lanesOf(model, now);
95  return next;
96};
97
98/** True while the card shows a running clock (the live step or the lanes'); it ticks each second. */
99export const clockShown = (timeline) => timeline.exitAt === null && Boolean(timeline.step || timeline.lanes);
100
101/** True while the ambient loop runs; the surface module then keeps a slower frame timer going. */
102export const ambientActive = (timeline, profile) => Boolean(profile.ambient) && timeline.exitAt === null && timeline.ambient.on;
103
104// The rewind look's sparks: fixed seeds (a small LCG, so every surface draws the same sky), each a
105// starting place, a drift speed and a twinkle offset.
106const SPARK_SEEDS = (() => {
107  let state = 13;
108  const next = () => {
109    state = (state * 1103515245 + 12345) % 2147483648;
110    return state / 2147483648;
111  };
112  return Object.freeze(Array.from({ length: 7 }, () => Object.freeze({ start: next(), speed: 0.6 + 0.8 * next(), twinkle: next() })));
113})();
114
115// Where each spark stands at `elapsed` (`x`, 0 to 1, drifting right to left) and which of the four
116// ASCII sparks it shows (`glyph`, 0 to 3).
117const sparksAt = (elapsed) => SPARK_SEEDS.map(({ start, speed, twinkle }) => {
118  const x = start - (elapsed * speed) / TIMINGS.sparks;
119  const t = elapsed / (TIMINGS.sparks / 4) + twinkle;
120  return { x: ((x % 1) + 1) % 1, glyph: Math.floor((((t % 1) + 1) % 1) * 4) };
121});
122
123// One frame of the ambient loop: `breath` (0 to 1 and back over TIMINGS.breath), where the light
124// stands on the active phase (`sweep`, null between passes), how far the comet has gone around
125// the card (`comet`, 0 to 1: the top rule left to right, then the bottom rule right to left),
126// where the bright head stands on the rail leaving the current stage (`flow`, 0 to 1), how far the
127// rewind look's hue has turned (`hue`, 0 to 1 over TIMINGS.hue) and its sparks (`sparks`, full only).
128const ambientSample = (timeline, now, profile) => {
129  if (!ambientActive(timeline, profile)) return null;
130  const elapsed = Math.max(0, now - timeline.ambient.at);
131  const breath = 0.5 - 0.5 * Math.cos((2 * Math.PI * elapsed) / TIMINGS.breath);
132  const pass = ((elapsed % TIMINGS.sweep) / TIMINGS.sweep) * 1.6 - 0.3;
133  return {
134    breath,
135    sweep: profile.ambient.sweep && pass >= -0.2 && pass <= 1.2 ? pass : null,
136    comet: profile.ambient.comet ? (elapsed % TIMINGS.comet) / TIMINGS.comet : null,
137    flow: profile.ambient.sweep ? (elapsed % TIMINGS.flow) / TIMINGS.flow : null,
138    hue: (elapsed % TIMINGS.hue) / TIMINGS.hue,
139    sparks: profile.ambient.sweep ? sparksAt(elapsed) : null,
140  };
141};
142
143export const timelineBusy = (timeline, now, profile) => {
144  const d = profile.duration;
145  if (timeline.exitAt !== null) return now < timeline.exitAt + d(TIMINGS.exit);
146  if (now < timeline.enterAt + entryEnd(profile)) return true;
147  if (!isSettled(timeline.fill, now) || !isSettled(timeline.warn, now)) return true;
148  if (timeline.flashAt !== null && now < timeline.flashAt + d(TIMINGS.flash)) return true;
149  if (PHASES.some((id) => timeline.phases[id].from !== timeline.phases[id].to && now < timeline.phases[id].at + d(TIMINGS.crossfade))) return true;
150  if (timeline.notice && now < timeline.notice.at + d(TIMINGS.noticeIn)) return true;
151  if (timeline.leaving && now < timeline.leaving.at + d(TIMINGS.noticeOut)) return true;
152  if (timeline.completion && now < timeline.completion.at + d(TIMINGS.completion)) return true;
153  return Object.values(timeline.chips).some((chip) => (chip.arrivedAt !== null && now < chip.arrivedAt + d(TIMINGS.shimmer))
154    || (chip.from !== chip.state && now < chip.changedAt + d(TIMINGS.crossfade)));
155};
156
157/**
158 * Everything one frame needs, as numbers: how far each pill has popped in (ease-out-back, so it may
159 * pass 1 briefly), the bar's fill, the count flash, the amber mix, each phase's colour crossfade,
160 * the notice row's offset in cells, the completion shimmer and embers, and per chip how far its
161 * arrival has run (`arrive`, 0 to 1; 1 once settled), where full motion's light stands on its
162 * name (`shimmer`, null when none) and its state crossfade.
163 */
164export const sampleTimeline = (timeline, now, profile) => {
165  const d = profile.duration;
166  const exit = timeline.exitAt === null ? 0 : EASE.inCubic(phase(now, timeline.exitAt, d(TIMINGS.exit)));
167  const pills = [0, 1, 2, 3].map((index) => {
168    const popped = profile.enterEase(phase(now, timeline.enterAt + index * d(TIMINGS.stagger), d(TIMINGS.entry)));
169    return Math.max(0, popped * (1 - exit));
170  });
171  const rest = exit > 0 ? 0 : phase(now, timeline.enterAt + entryEnd(profile), 1);
172  const flash = timeline.flashAt === null ? 0 : Math.sin(Math.PI * phase(now, timeline.flashAt, d(TIMINGS.flash)));
173  const phases = Object.fromEntries(PHASES.map((id) => {
174    const { from, to, at } = timeline.phases[id];
175    return [id, { from, to, mix: from === to ? 1 : EASE.inOutCubic(phase(now, at, d(TIMINGS.crossfade))) }];
176  }));
177  let notice = null;
178  if (timeline.notice) {
179    const p = phase(now, timeline.notice.at, d(TIMINGS.noticeIn));
180    notice = { data: timeline.notice.data, offset: Math.max(0, Math.round(14 * (1 - profile.noticeEase(p)))) };
181  } else if (timeline.leaving && now < timeline.leaving.at + d(TIMINGS.noticeOut)) {
182    notice = { data: timeline.leaving.data, offset: Math.round(40 * EASE.inCubic(phase(now, timeline.leaving.at, d(TIMINGS.noticeOut)))) };
183  }
184  let completion = null;
185  if (timeline.completion) {
186    const at = timeline.completion.at;
187    completion = {
188      data: timeline.completion.data,
189      shimmer: profile.shimmer && now >= at + d(50) && now < at + d(50) + d(TIMINGS.shimmer)
190        ? EASE.outCubic(phase(now, at + d(50), d(TIMINGS.shimmer))) * 1.3 - 0.15
191        : null,
192      embers: profile.embers ? phase(now, at, d(TIMINGS.embers)) : null,
193      title: EASE.outCubic(phase(now, at + d(30), d(200))),
194      detail: EASE.outCubic(phase(now, at + d(100), d(200))),
195    };
196  }
197  const chips = Object.fromEntries(Object.entries(timeline.chips).map(([id, chip]) => {
198    const arrive = chip.arrivedAt === null ? 1 : phase(now, chip.arrivedAt, d(TIMINGS.shimmer));
199    return [id, {
200      arrive,
201      shimmer: profile.shimmer && arrive < 1 ? EASE.outCubic(arrive) * 1.3 - 0.15 : null,
202      from: chip.from,
203      to: chip.state,
204      mix: chip.from === chip.state ? 1 : EASE.inOutCubic(phase(now, chip.changedAt, d(TIMINGS.crossfade))),
205    }];
206  }));
207  return {
208    pills,
209    rest,
210    exit,
211    fill: sample(timeline.fill, now),
212    fillGlow: isSettled(timeline.fill, now) ? 0 : Math.sin(Math.PI * phase(now, timeline.fill.at, timeline.fill.dur)),
213    flash,
214    warn: sample(timeline.warn, now),
215    phases,
216    notice,
217    completion,
218    chips,
219    ambient: ambientSample(timeline, now, profile),
220    step: timeline.step ? { name: timeline.step.name, elapsedMs: timeline.step.base + Math.max(0, now - timeline.step.at) } : null,
221    lanes: timeline.lanes ? { elapsedMs: timeline.lanes.base + Math.max(0, now - timeline.lanes.at) } : null,
222    spin: Math.floor(Math.max(0, now) / TIMINGS.spin) % 4,
223  };
224};
225
226/** The final frame of a model, for motion off, surfaces without frame clocks and tests. */
227export const settledSample = (model) => {
228  const profile = { duration: () => 0, enterEase: EASE.linear, noticeEase: EASE.linear, shimmer: false, embers: false };
229  return sampleTimeline(createTimeline(model, 0, profile), 1, profile);
230};
231
lib/mod/view.mjs 539 lines
1// What the LIT band says, decided from a state snapshot (state.mjs), the skills that fired
2// (skills.mjs) and what Claude Code reports live (subagents, context and rate-limit usage). Pure: the mod calls these on each refresh and hands the result to the
3// band's surface module, which only draws and animates it.
4
5import { lookOf } from "./motion.mjs";
6import { AUTOLOOP_LIMITS, GATE_LIMITS } from "./state.mjs";
7import { buildWorkflow } from "./workflow.mjs";
8
9export const STALL_MS = 10 * 60 * 1000;
10// An agent the host does not list, quiet this long with no call in flight, no longer counts as
11// running: a crashed loop or a lost report must not hold the card at work for the rest of the
12// session. One the host lists as live stays, however quiet; it is marked stalled instead.
13export const AGENT_STALE_MS = 30 * 60 * 1000;
14// The context warning starts this many points before the auto-handoff percent.
15export const CONTEXT_LEAD = 5;
16
17// How long the rewind look stays after `lit adhd` was sent. The ignition keeps naming lit-adhd
18// until the next prompt, so the look lasts while the user reads the brief, but no longer than this.
19export const REWIND_LINGER_MS = 15 * 60 * 1000;
20
21// How long the card must have sat idle since its newest skill before it offers `lit adhd`.
22export const BREAK_MS = 30 * 60 * 1000;
23
24const LIVE_AGENT = new Set(["running", "pending", "waiting", "idle"]);
25const ENDED_AGENT = new Set(["completed", "failed", "killed"]);
26const NONTERMINAL_GOAL = new Set(["active", "blocked", "review_blocked", "needs_user_decision"]);
27const WORK_DISCIPLINES = new Set(["lit-loop", "litwork", "litgoal", "start-work", "native-workflow"]);
28
29export const formatDuration = (ms) => {
30  const seconds = Math.max(0, Math.round(ms / 1000));
31  if (seconds < 60) return `${seconds}s`;
32  const minutes = Math.floor(seconds / 60);
33  if (minutes < 60) return `${minutes}m`;
34  const hours = Math.floor(minutes / 60);
35  return minutes % 60 ? `${hours}h ${minutes % 60}m` : `${hours}h`;
36};
37
38export const formatAgo = (at, now) => (Number.isFinite(at) ? (now - at < 5000 ? "just now" : `${formatDuration(now - at)} ago`) : "—");
39
40// `activity.last` maps an agent id to the last time it was seen doing something (a tool call from its
41// loop, a status change, its first appearance) and `activity.first` to when it first appeared. A live
42// agent quiet for longer than `thresholdMs` is stalled.
43export const EMPTY_ACTIVITY = Object.freeze({ last: {}, first: {} });
44
45// Folds one `$.agent.list()` reading into the activity map: a new agent starts both clocks, a changed
46// status counts as activity, and agents that left the list are forgotten.
47export const trackAgents = (activity, agents, previousStatus, now) => {
48  const last = {};
49  const first = {};
50  for (const agent of agents) {
51    first[agent.id] = activity.first[agent.id] ?? now;
52    const changed = previousStatus[agent.id] !== undefined && previousStatus[agent.id] !== agent.status;
53    last[agent.id] = activity.last[agent.id] === undefined || changed ? now : activity.last[agent.id];
54  }
55  return { last, first };
56};
57
58export const noteAgentActivity = (activity, agentId, now) =>
59  activity.first[agentId] === undefined ? activity : { ...activity, last: { ...activity.last, [agentId]: now } };
60
61// `spawned` maps an agent id to what `agent.spawn` said of it, when it was last seen working and
62// how many of its tool calls are in flight. It covers the agents `$.agent.list()` never shows (a
63// workflow script's) and the moment before the list catches up; the agent's own `turn.complete`
64// ends it.
65export const noteSpawn = (spawned, { id, description, type, at }) => ({ ...spawned, [id]: { id, description, type, last: at, calls: 0 } });
66
67// `calls` is +1 as one of the agent's tool calls starts, -1 as it returns, 0 for other activity.
68export const noteSpawnActivity = (spawned, id, at, calls = 0) => (spawned[id]
69  ? { ...spawned, [id]: { ...spawned[id], last: at, calls: Math.max(0, (spawned[id].calls ?? 0) + calls) } }
70  : spawned);
71
72export const noteSpawnEnded = (spawned, id) => Object.fromEntries(Object.entries(spawned).filter(([key]) => key !== id));
73
74// Folds the spawn records into one `$.agent.list()` reading. The list speaks for the agents it shows:
75// a record whose agent it lists as ended is dropped. A record it does not show runs until its
76// `turn.complete`, or until it has been quiet for AGENT_STALE_MS with no call in flight.
77export const mergeSpawned = (listed, spawned, now) => {
78  const byId = new Map(listed.map((agent) => [agent.id, agent]));
79  const kept = {};
80  const unlisted = [];
81  for (const record of Object.values(spawned)) {
82    const row = byId.get(record.id);
83    if (row ? ENDED_AGENT.has(row.status) : now - record.last > AGENT_STALE_MS && !(record.calls > 0)) continue;
84    kept[record.id] = record;
85    if (!row) unlisted.push({ id: record.id, description: record.description, type: record.type, status: "running" });
86  }
87  return { agents: [...listed, ...unlisted], spawned: kept };
88};
89
90export const stalledLanes = (agents, activity, now, thresholdMs = STALL_MS) =>
91  agents
92    .filter((agent) => LIVE_AGENT.has(agent.status) && Number.isFinite(activity.last[agent.id]) && now - activity.last[agent.id] > thresholdMs)
93    .map((agent) => ({ id: agent.id, name: laneName(agent), quietMs: now - activity.last[agent.id] }));
94
95export const laneName = (agent) => String(agent.name || agent.description || agent.type || "agent").slice(0, 40);
96
97// The card shows at most this many threads; the rest are counted as `+N more`. A deep chain still
98// leaves PARALLEL_ROWS for the lanes and tasks under it.
99export const THREAD_ROWS = 4;
100const PARALLEL_ROWS = 3;
101// Another session of this project counts as live while its stack was written this recently.
102export const OTHER_SESSION_MS = 15 * 60 * 1000;
103
104const countOrNull = (value) => (Number.isInteger(value) && value >= 0 ? value : null);
105
106// `lists` maps a loop (`main`, or a subagent's id) to what its tool calls say of its progress:
107// `steps`, the tool calls it finished; `todos`, the counts of its last TodoWrite; `tasks`, the
108// status of each task it created with TaskCreate (TaskUpdate moves them, and may give one to a lane
109// by `owner`); `latest`, which of the two lists it touched last; `last`, its last finished step as a
110// tool name and at most a file's base name. Only statuses are kept, never a todo's or a task's text.
111export const EMPTY_LISTS = Object.freeze({});
112const LIST_CAP = 200;
113const LOOP_CAP = 40;
114const TASK_STATUS = new Set(["pending", "in_progress", "completed"]);
115
116const loopEntry = (lists, loop) => lists[loop] ?? { steps: 0, todos: null, tasks: {}, latest: null, last: null };
117const OWNER = /^[\w .:-]{1,40}$/u;
118// `Edit README.md`: the tool and the base name of the file it touched, nothing of its arguments.
119const lastStepOf = (call) => {
120  const tool = stepName(call?.tool);
121  if (!tool) return null;
122  const path = typeof call.file_path === "string" ? call.file_path : typeof call.notebook_path === "string" ? call.notebook_path : null;
123  const base = path ? path.split(/[\\/]/u).pop().replace(/[^\w .@+-]/gu, "").slice(0, 28) : "";
124  return base ? `${tool} ${base}` : tool;
125};
126
127/**
128 * Folds one finished tool call of `loop` into `lists`: every call counts a step, a TodoWrite
129 * replaces the loop's todo counts, a TaskCreate adds its task and a TaskUpdate moves the task in
130 * whichever loop created it (or in this loop, marked unseen, when its creation was not seen).
131 */
132export const noteLoopCall = (lists, loop, call, result) => {
133  if (typeof loop !== "string" || !loop) return lists;
134  const next = { ...lists };
135  const entry = { ...loopEntry(lists, loop) };
136  entry.steps += 1;
137  entry.last = lastStepOf(call) ?? entry.last ?? null;
138  if (call?.tool === "TodoWrite" && Array.isArray(call.todos)) {
139    const todos = call.todos.slice(0, LIST_CAP);
140    entry.todos = { done: todos.filter((todo) => todo?.status === "completed").length, total: todos.length };
141    entry.latest = "todos";
142  } else if (call?.tool === "TaskCreate" && typeof result?.task?.id === "string") {
143    entry.tasks = { ...entry.tasks, [result.task.id.slice(0, 64)]: { status: "pending", seen: true } };
144    entry.latest = "tasks";
145  } else if (call?.tool === "TaskUpdate" && typeof call.taskId === "string" && (TASK_STATUS.has(call.status) || call.status === "deleted" || typeof call.owner === "string")) {
146    const id = call.taskId.slice(0, 64);
147    const owner = Object.keys(next).find((key) => key !== loop && next[key].tasks?.[id]);
148    const target = owner ? { ...next[owner] } : entry;
149    const tasks = { ...target.tasks };
150    if (call.status === "deleted") delete tasks[id];
151    else if (TASK_STATUS.has(call.status) || tasks[id]) {
152      const assignee = typeof call.owner === "string" && OWNER.test(call.owner.trim()) ? call.owner.trim() : tasks[id]?.owner;
153      tasks[id] = { status: TASK_STATUS.has(call.status) ? call.status : tasks[id].status, seen: tasks[id]?.seen ?? false, ...(assignee ? { owner: assignee } : {}) };
154    }
155    target.tasks = Object.fromEntries(Object.entries(tasks).slice(-LIST_CAP));
156    target.latest = "tasks";
157    if (owner) next[owner] = target;
158  }
159  next[loop] = entry;
160  const loops = Object.keys(next);
161  return loops.length > LOOP_CAP ? Object.fromEntries(loops.slice(-LOOP_CAP).map((key) => [key, next[key]])) : next;
162};
163
164/**
165 * A loop's `{ done, total }` from its list, or null when it keeps none. A task list whose
166 * creation was not all seen knows its done count but not its total.
167 */
168export const listProgress = (entry) => {
169  if (!entry) return null;
170  const tasks = Object.values(entry.tasks ?? {});
171  const fromTasks = tasks.length ? { done: tasks.filter(({ status }) => status === "completed").length, total: tasks.every(({ seen }) => seen) ? tasks.length : null } : null;
172  if (entry.latest === "tasks" && fromTasks) return fromTasks;
173  return entry.todos ?? fromTasks;
174};
175
176// The tasks any loop's list gave to `owner` by name, as `{ done, total }`, or null when none did.
177const ownedProgress = (lists, owner) => {
178  if (!owner) return null;
179  const owned = Object.values(lists).flatMap((entry) => Object.values(entry?.tasks ?? {})).filter((task) => task.owner === owner);
180  return owned.length ? { done: owned.filter(({ status }) => status === "completed").length, total: owned.length } : null;
181};
182
183// `tasks` maps a background task's id to `{ id, kind, name, at, until }`: a shell a Bash call left
184// running (its own `backgroundTaskId`) or a monitor, named by the call's description, never its
185// command. `until` is a monitor's deadline. A task ends with its notification, a TaskStop, its
186// deadline, or after TASK_STALE_MS when no ending was ever seen.
187export const EMPTY_TASKS = Object.freeze({});
188export const TASK_STALE_MS = 12 * 60 * 60 * 1000;
189const TASK_CAP = 12;
190const TASK_ID = /^[A-Za-z0-9_-]{1,64}$/u;
191
192export const noteBackgroundTask = (tasks, { id, kind, name, at, until = null }) => {
193  if (typeof id !== "string" || !TASK_ID.test(id)) return tasks;
194  const label = String(name || kind).replace(/[\u0000-\u001f\u007f]/gu, " ").trim().slice(0, 40) || kind;
195  const next = { ...tasks, [id]: { id, kind, name: label, at, until: Number.isFinite(until) ? until : null } };
196  const ids = Object.keys(next);
197  return ids.length > TASK_CAP ? Object.fromEntries(ids.slice(-TASK_CAP).map((key) => [key, next[key]])) : next;
198};
199
200export const endBackgroundTask = (tasks, id) => (typeof id === "string" && tasks[id] ? Object.fromEntries(Object.entries(tasks).filter(([key]) => key !== id)) : tasks);
201
202// The ids a task notification names as ended. Its text is read for `<task-id>` and `<status>`
203// alone and kept nowhere; a notification that still reports a running task ends nothing.
204export const endedTaskIds = (text) => {
205  const body = String(text ?? "").slice(0, 65_536);
206  const ids = [];
207  for (const block of body.split(/<\/task-notification>/u)) {
208    const id = /<task-id>\s*([A-Za-z0-9_-]{1,64})\s*<\/task-id>/u.exec(block)?.[1];
209    const status = /<status>\s*([a-z_]{1,24})\s*<\/status>/u.exec(block)?.[1] ?? "completed";
210    if (id && !["running", "pending", "in_progress"].includes(status)) ids.push(id);
211  }
212  return ids;
213};
214
215export const pruneBackgroundTasks = (tasks, now) => {
216  const kept = Object.entries(tasks).filter(([, task]) => !(task.until !== null && now > task.until) && !(now - task.at > TASK_STALE_MS));
217  return kept.length === Object.keys(tasks).length ? tasks : Object.fromEntries(kept);
218};
219
220const goalCount = (goal) => (goalIsOpen(goal) && goal.criteria.length > 0 ? { done: goal.criteria.filter(({ status }) => status === "pass").length, total: goal.criteria.length } : null);
221const LOOP_SKILLS = new Set(["lit-loop", "litgoal", "litwork"]);
222
223// A frame's k/n, the first source that knows it: the count the frame recorded, the start-work run
224// it drives, the checkboxes of the plan it works from, the open goal's criteria (a loop), and for
225// the frame on top the main loop's own todo or task list.
226const frameCount = (frame, snapshot, mainList) => {
227  const record = frame.record;
228  if (countOrNull(record?.total) !== null) return { done: countOrNull(record.n) ?? 0, total: record.total };
229  const work = snapshot.work;
230  if (work?.total > 0 && (frame.skill === "start-work" || (record?.family === "main" && record.stage === "work"))) return { done: work.checked, total: work.total };
231  if (frame.plan?.total > 0) return frame.plan;
232  if (LOOP_SKILLS.has(frame.skill) || record?.family === "loop") {
233    const goal = goalCount(snapshot.goal);
234    if (goal) return goal;
235  }
236  return mainList ?? { done: countOrNull(record?.n), total: null };
237};
238
239// A thread with no total carries what is known instead: its finished steps, how long it has run
240// and its last step (knownFacts in rewind/gauge.mjs words them).
241const frameThread = (frame, index, snapshot, active, mainList = null, now = null, mainEntry = null) => {
242  const { done, total } = frameCount(frame, snapshot, active ? mainList : null);
243  const started = Number.isFinite(frame.at) ? frame.at : frame.record?.at;
244  return {
245    key: `frame-${index}`,
246    name: frame.skill ?? frame.record?.family ?? "work",
247    stage: frame.record?.step ?? frame.record?.stage ?? null,
248    done,
249    total,
250    state: frame.record?.done ? "done" : frame.status === "paused" ? "paused" : frame.skill === "start-work" && snapshot.work?.status === "paused" ? "blocked" : "running",
251    active,
252    steps: active && mainEntry ? mainEntry.steps : null,
253    last: active && mainEntry ? mainEntry.last ?? null : null,
254    elapsedMs: Number.isFinite(now) && Number.isFinite(started) ? Math.max(0, now - started) : null,
255  };
256};
257
258// A lane's k/n: its own todo or task list, then the tasks a loop gave it by name; else no total,
259// with the steps it has finished and its last one.
260const laneCount = (entry, lists, name) => listProgress(entry) ?? ownedProgress(lists, name) ?? { done: null, total: null, steps: entry?.steps ?? 0, last: entry?.last ?? null };
261
262/**
263 * The live threads, or null while there is at most one: `{ chain, lanes, others, laneCount,
264 * taskCount, more, reserve }`. `chain` is this session's stack, parent first; `lanes` the subagents
265 * or work lanes running under its top frame, then its background tasks (`task` names the kind);
266 * `others` the top frame of each other session of this project whose stack moved within
267 * OTHER_SESSION_MS. At most THREAD_ROWS are kept (more under a deep chain, see PARALLEL_ROWS): the
268 * active frame and its parents first, then lanes and tasks, then other sessions. `reserve` is the card's thread-row count, never below the
269 * `threadReserve` the caller carried over, so the card does not shrink while threads are live.
270 */
271const threadsOf = ({ snapshot, lanes, tasks, lists, now, threadReserve }) => {
272  const frames = snapshot.workflow?.stack?.frames ?? [];
273  const mainList = listProgress(lists.main);
274  const chain = frames.map((frame, index) => frameThread(frame, index, snapshot, index === frames.length - 1, mainList, now, lists.main));
275  const laneThreads = lanes.map((lane, index) => ({
276    key: `lane-${lane.id ?? index}`,
277    name: lane.name,
278    stage: null,
279    ...laneCount(lane.id ? lists[lane.id] : null, lists, lane.name),
280    state: lane.status === "stalled" ? "blocked" : lane.status === "idle" ? "idle" : "running",
281    active: false,
282    elapsedMs: Number.isFinite(lane.elapsedMs) ? lane.elapsedMs : null,
283  }));
284  const taskThreads = tasks.map((task) => ({ key: `task-${task.id}`, name: task.name, stage: task.kind, done: null, total: null, state: "running", active: false, task: task.kind, elapsedMs: Number.isFinite(task.at) ? Math.max(0, now - task.at) : null }));
285  const parallel = [...laneThreads, ...taskThreads];
286  const others = (snapshot.workflow?.others ?? [])
287    .filter((other) => Number.isFinite(other.at) && now - other.at <= OTHER_SESSION_MS && other.frames?.length)
288    .map((other) => ({ ...frameThread(other.frames.at(-1), 0, {}, false, null, now), key: `session-${other.id8}`, session: other.id8 }));
289  const total = chain.length + parallel.length + others.length;
290  if (total < 2) return null;
291  const room = Math.max(PARALLEL_ROWS, THREAD_ROWS - chain.length);
292  const keptLanes = parallel.slice(0, room);
293  const keptOthers = others.slice(0, Math.max(0, room - keptLanes.length));
294  const fork = parallel.length >= 2 ? 1 : 0;
295  const needed = chain.length + keptLanes.length + keptOthers.length + fork;
296  return {
297    chain,
298    lanes: keptLanes,
299    others: keptOthers,
300    laneCount: laneThreads.length,
301    taskCount: taskThreads.length,
302    more: total - chain.length - keptLanes.length - keptOthers.length,
303    reserve: Math.max(needed, Number.isInteger(threadReserve) ? threadReserve : 0),
304  };
305};
306
307const goalIsOpen = (goal) => Boolean(goal && NONTERMINAL_GOAL.has(goal.status));
308
309const progressOf = (snapshot) => {
310  if (snapshot.work && snapshot.work.total > 0) return { done: snapshot.work.checked, total: snapshot.work.total, unit: "tasks" };
311  const goal = snapshot.goal;
312  if (goalIsOpen(goal) && goal.criteria.length > 0) {
313    return { done: goal.criteria.filter(({ status }) => status === "pass").length, total: goal.criteria.length, unit: "criteria" };
314  }
315  return null;
316};
317
318const phasesOf = ({ snapshot, discipline, progress, completing }) => {
319  const met = snapshot.goal?.status === "complete";
320  const planned = Boolean(snapshot.work) || goalIsOpen(snapshot.goal) || met;
321  const allDone = (progress !== null && progress.done >= progress.total) || met;
322  const plan = planned || completing ? "done" : discipline === "lit-plan" ? "now" : "todo";
323  let work = "todo";
324  if (completing || allDone) work = "done";
325  else if (progress !== null || WORK_DISCIPLINES.has(discipline)) work = "now";
326  let review = "todo";
327  if (completing || met) review = "done";
328  else if (discipline === "review-work" || allDone) review = "now";
329  return { plan, work, review };
330};
331
332const gatesOf = (snapshot) => {
333  const gates = [];
334  if (snapshot.work) gates.push({ id: "plan", state: "ok", label: "plan saved" });
335  else if (snapshot.litPlan?.blockCount > 0) gates.push({ id: "plan", state: "fail", label: "plan not saved" });
336  else if (snapshot.litPlan) gates.push({ id: "plan", state: "pending", label: "plan pending" });
337  const probe = snapshot.probe;
338  if (probe?.editAt !== null && probe?.editAt !== undefined) {
339    if (probe.probeAt !== null && probe.probeAt >= probe.editAt) gates.push({ id: "probe", state: "ok", label: "ui probe passed" });
340    else if (probe.issue && probe.issueAt >= probe.editAt) gates.push({ id: "probe", state: "fail", label: "ui probe failed" });
341    else gates.push({ id: "probe", state: "pending", label: "ui probe pending" });
342  }
343  if (snapshot.motion?.blockCount > 0) gates.push({ id: "motion", state: "pending", label: "motion gate pending" });
344  if (snapshot.handoff) {
345    gates.push(snapshot.handoff.firedAt !== null
346      ? { id: "handoff", state: "ok", label: `handoff written at ${snapshot.handoff.firedAtPercent ?? snapshot.handoff.percent}%` }
347      : { id: "handoff", state: "pending", label: `handoff at ${snapshot.handoff.percent}%` });
348  }
349  return gates;
350};
351
352const contextWarningOf = (snapshot, usage) => {
353  const percent = usage?.context?.percent;
354  const handoff = snapshot.handoff;
355  if (!handoff || handoff.firedAt !== null || !Number.isFinite(percent)) return null;
356  return percent >= handoff.percent - CONTEXT_LEAD ? { percent: Math.round(percent), threshold: handoff.percent } : null;
357};
358
359const goalSummaryOf = (goal) => {
360  if (!goal || goal.criteria.length === 0) return null;
361  return { status: goal.status, passed: goal.criteria.filter(({ status }) => status === "pass").length, total: goal.criteria.length };
362};
363
364// The bottom edge's live figures, as the status line shows them: context fill, the five-hour and
365// weekly windows and the Git branch.
366const figuresOf = (usage, git) => {
367  const context = Number.isFinite(usage?.context?.percent) ? Math.round(usage.context.percent) : null;
368  const windows = Array.isArray(usage?.rateLimits) ? usage.rateLimits : [];
369  const rates = [["five_hour", "5h"], ["seven_day", "1w"]].flatMap(([kind, label]) => {
370    const window = windows.find((entry) => entry.kind === kind);
371    return window && Number.isFinite(window.percentUsed) ? [{ label, percent: Math.round(window.percentUsed) }] : [];
372  });
373  return { context, rates, git: typeof git === "string" && git ? git : null };
374};
375
376// The tool the main loop is running, by name only: an MCP tool by its own last segment. Never its
377// arguments, its command or the files it touches.
378export const stepName = (tool) => {
379  const name = String(tool ?? "").trim();
380  const mcp = /^mcp__.+__([^_].*)$/u.exec(name);
381  return (mcp ? mcp[1] : name).replace(/[^\w .:-]/gu, "").slice(0, 32) || null;
382};
383
384/**
385 * The band's model. `agents` is `$.agent.list()`, `activity` the per-agent last-seen map, `usage`
386 * `$.session.usage()`, `notice` and `completion` whatever the mod is currently playing, `skills`
387 * the chips (skills.mjs chipStates), `lastDiscipline` the discipline or skill the session last
388 * lit (the idle title), `git` the branch and `step` the live step (`{ name, at }`, the tool the
389 * main loop runs and since when). `threadReserve` is the previous model's `threads.reserve`,
390 * `lists` the loops' todo and task counts (noteLoopCall) and `tasks` the background tasks
391 * (noteBackgroundTask).
392 */
393export const buildBandModel = ({ snapshot, agents = [], activity = EMPTY_ACTIVITY, usage = null, now, band = "always", isWorking = false, notice = null, completion = null, skills = [], lastDiscipline = null, git = null, step = null, threadReserve = 0, lists = EMPTY_LISTS, tasks = EMPTY_TASKS }) => {
394  const discipline = snapshot.ignition?.discipline ?? null;
395  const progress = progressOf(snapshot);
396  const completing = completion !== null;
397  const phases = phasesOf({ snapshot, discipline, progress, completing });
398  const liveAgents = agents.filter((agent) => LIVE_AGENT.has(agent.status));
399  const boulderLanes = (snapshot.work?.lanes ?? []).filter(({ status }) => status === "active");
400  const stalls = stalledLanes(agents, activity, now);
401  const stalledIds = new Set(stalls.map(({ id }) => id));
402  const lanes = liveAgents.length > 0
403    ? liveAgents.slice(0, 6).map((agent) => ({
404      id: agent.id,
405      name: laneName(agent),
406      status: stalledIds.has(agent.id) ? "stalled" : agent.status,
407      elapsedMs: Number.isFinite(activity.first[agent.id]) ? now - activity.first[agent.id] : null,
408    }))
409    : boulderLanes.slice(0, 6).map((lane) => ({ name: lane.type, status: "running", elapsedMs: Number.isFinite(lane.startedAt) ? now - lane.startedAt : null }));
410  const goal = snapshot.goal;
411  const autoloop = goal?.autoloop && goal.status === "active" && snapshot.autoloop
412    ? { n: snapshot.autoloop.blockCount, max: AUTOLOOP_LIMITS.blocks, leftMs: Math.max(0, snapshot.autoloop.firstBlockAt + AUTOLOOP_LIMITS.ms - now) }
413    : null;
414  const ctxWarning = contextWarningOf(snapshot, usage);
415  const warnings = stalls.map(({ name, quietMs }) => ({ tone: "amber", subject: name, text: `quiet for ${formatDuration(quietMs)}`, hint: "wake it? (ctrl+x tab)" }));
416  const probe = snapshot.probe;
417  if (probe?.issue && probe.issueAt >= (probe.editAt ?? 0)) warnings.push({ tone: "red", subject: "ui probe", text: probe.issue, hint: "interface check failed" });
418  const active = Boolean(snapshot.work) || goalIsOpen(goal) || (discipline !== null && isWorking) || liveAgents.length > 0;
419  const lit = skills.some(({ state }) => state === "current");
420  const workflow = buildWorkflow({ snapshot, skills, completion, now });
421  const title = discipline ?? skills.find(({ state }) => state === "current")?.id ?? lastDiscipline;
422  const rewind = lookOf(discipline) === "rewind" && Number.isFinite(snapshot.ignition?.at) && now - snapshot.ignition.at < REWIND_LINGER_MS;
423  const visible = band === "off" ? false : band === "always" || active || lit || rewind || notice !== null || completion !== null || warnings.length > 0;
424  return {
425    visible,
426    idle: !active && !lit,
427    rewind,
428    away: !active && !lit && !rewind && skills.length > 0 && now - Math.max(...skills.map(({ at }) => (Number.isFinite(at) ? at : now))) >= BREAK_MS,
429    discipline,
430    title: workflow?.paused ? crumbOf(workflow.paused) : title,
431    phases,
432    progress,
433    next: snapshot.work?.nextTask ?? (goalIsOpen(goal) ? goal.criteria.find(({ status }) => status !== "pass")?.description || goal.objective || null : null),
434    lanes: { count: lanes.length, list: lanes },
435    threads: threadsOf({ snapshot, lanes, tasks: Object.values(pruneBackgroundTasks(tasks, now)), lists, now, threadReserve }),
436    autoloop,
437    ctxWarning,
438    warnings,
439    gates: gatesOf(snapshot),
440    goal: goalSummaryOf(goal),
441    skills: skills.map(({ id, at, state }) => ({ id, at, state })),
442    figures: figuresOf(usage, git),
443    workflow,
444    step: step && isWorking && stepName(step.name) ? { name: stepName(step.name), elapsedMs: Math.max(0, now - step.at) } : null,
445    notice,
446    completion,
447  };
448};
449
450// `lit-loop || > lit-brainstorm`, or `lit-loop || > … > lit-brainstorm` with a side between them.
451const crumbOf = ({ parent, child, chain }) => `${parent} || > ${chain.length > 1 ? "… > " : ""}${child}`;
452
453/**
454 * The spinner's suffix: the ellipsis Claude Code drew, then the discipline and the progress count,
455 * or the paused parent and its count while a side runs over it.
456 */
457export const spinnerSuffix = (model, base = "…") => {
458  if (!model?.visible || !model.discipline) return null;
459  const paused = model.workflow?.paused;
460  if (paused) return `${base} · ${paused.child} (|| ${paused.parent}${paused.count ? ` ${paused.count.done}/${paused.count.total}` : ""})`;
461  const count = model.progress ? ` · ${model.progress.done}/${model.progress.total}` : "";
462  return `${base} · ${model.discipline}${count}`;
463};
464
465/**
466 * Gate events worth a notice, found by comparing the previous snapshot with this one. The first
467 * snapshot of a session raises nothing: only changes seen while the mod watches are announced.
468 */
469export const diffNotices = (previous, next, previousModel = null, nextModel = null) => {
470  if (!previous) return [];
471  const notices = [];
472  const plan = next.litPlan;
473  if (plan && plan.blockCount > (previous.litPlan?.blockCount ?? 0)) {
474    notices.push({ id: `plan:${plan.promptAt}:${plan.blockCount}`, tone: "pink", title: "lit-plan is not saved yet", detail: `save plans/<slug>.md before working · stop ${plan.blockCount}/${GATE_LIMITS.litPlan}` });
475  }
476  const probe = next.probe;
477  if (probe?.issue && probe.issueAt !== null && probe.issueAt !== previous.probe?.issueAt) {
478    notices.push({ id: `probe-fail:${probe.issueAt}`, tone: "red", title: "interface probe failed", detail: probe.issue });
479  } else if (probe && probe.blockCount > (previous.probe?.blockCount ?? 0)) {
480    notices.push({ id: `probe:${probe.editAt}:${probe.blockCount}`, tone: "amber", title: "interface probe pending", detail: `run the interface probe before finishing · stop ${probe.blockCount}/${GATE_LIMITS.probe}` });
481  }
482  const motion = next.motion;
483  if (motion && motion.blockCount > (previous.motion?.blockCount ?? 0)) {
484    notices.push({ id: `motion:${motion.blockCount}:${motion.runs}`, tone: "amber", title: "motion gate: the film is not done", detail: `stop ${motion.blockCount}/${GATE_LIMITS.motion}` });
485  }
486  const handoff = next.handoff;
487  if (handoff?.firedAt !== null && handoff?.firedAt !== undefined && handoff.firedAt !== previous.handoff?.firedAt) {
488    notices.push({ id: `handoff:${handoff.firedAt}`, tone: "pink", title: `auto-handoff fired at ${handoff.firedAtPercent ?? handoff.percent}%`, detail: "write the handoff, then run /compact" });
489  }
490  if (nextModel?.ctxWarning && !previousModel?.ctxWarning) {
491    const { percent, threshold } = nextModel.ctxWarning;
492    notices.push({ id: `ctx:${threshold}:${percent}`, tone: "amber", title: `context ${percent}%`, detail: `auto-handoff is armed at ${threshold}%` });
493  }
494  return notices;
495};
496
497/** A finish worth the completion moment: all tasks checked, a work completed, or a goal met. */
498export const detectCompletion = (previous, next) => {
499  if (!previous) return null;
500  const before = previous.work;
501  if (before && before.total > 0) {
502    const after = next.work?.id === before.id ? next.work : null;
503    const finished = next.completedWorkIds.includes(before.id) || (after !== null && before.checked < before.total && after.checked >= after.total);
504    if (finished) return { id: `work:${before.id}`, detail: `${before.total}/${before.total} tasks` };
505  }
506  const goal = next.goal;
507  if (goal?.status === "complete" && previous.goal && previous.goal.status !== "complete") {
508    const passed = goal.criteria.filter(({ status }) => status === "pass").length;
509    return { id: `goal:${goal.createdAt ?? goal.objective}`, detail: `goal met · ${passed}/${goal.criteria.length} criteria` };
510  }
511  return null;
512};
513
514/** The evidence pane: goal criteria, blockers, the litgoal timeline, lanes and gates. */
515export const buildEvidenceModel = ({ snapshot, ledger = [], agents = [], activity = EMPTY_ACTIVITY, now, icons = null }) => {
516  const goal = snapshot.goal;
517  return {
518    goal: goal
519      ? {
520        objective: goal.objective,
521        status: goal.status,
522        criteria: goal.criteria.map((criterion) => ({ ...criterion, ago: formatAgo(criterion.lastAt, now) })),
523        blockers: goal.blockers,
524        reviewBlocked: goal.lastCheckpoint === "review_blocked",
525      }
526      : null,
527    timeline: ledger.map((entry) => ({ ...entry, ago: formatAgo(entry.at, now) })),
528    lanes: agents.map((agent) => ({
529      name: laneName(agent),
530      type: agent.type,
531      status: agent.status,
532      quiet: Number.isFinite(activity.last[agent.id]) ? formatDuration(now - activity.last[agent.id]) : null,
533    })),
534    gates: gatesOf(snapshot),
535    work: snapshot.work ? { plan: snapshot.work.planName, done: snapshot.work.checked, total: snapshot.work.total, next: snapshot.work.nextTask } : null,
536    icons,
537  };
538};
539
hooks/lit-band-client.mjs 55 lines
1// The LIT band's surface module: the `Client` element lit-mod.mjs draws above the prompt. It runs on
2// the drawing thread with no mods API, keeps the band's timeline in its local state, and animates on
3// the surface's own frame clock: fast while a transition plays, slower while the ambient loop says
4// LitClaude is at work, once a second while only a clock (the live step's or the lanes') moves, and
5// not at all once the card is idle and settled, when it stays still until the hooks module hands it
6// a new model. The clocks count on here, so they move even when the hooks module holds the model
7// still for a desktop drawing attached to the same session.
8
9import { composeBand } from "../lib/mod/frame.mjs";
10import { TIMINGS, motionProfile } from "../lib/mod/motion.mjs";
11import { renderBand } from "../lib/mod/render.mjs";
12import { advanceTimeline, ambientActive, clockShown, createTimeline, sampleTimeline, timelineBusy } from "../lib/mod/timeline.mjs";
13
14// The timeline lives in one holder object that the frame timer and every draw share. The timer
15// never reads `surface.state` back, so a draw that folded in a new model is never overwritten by a
16// tick that started from an older copy.
17export default function LitBand(props, surface) {
18  const profile = motionProfile(props?.motion);
19  const model = props?.model;
20  const empty = () => surface.elements.Box({ children: [] });
21  if (!model) return empty();
22
23  const holder = surface.state?.live ?? { now: 0, model: null, timeline: null, stop: null, interval: null };
24  if (holder.timeline === null) {
25    holder.timeline = createTimeline(model, 0, profile);
26    holder.model = model;
27  } else if (holder.model !== model) {
28    holder.timeline = advanceTimeline(holder.timeline, model, holder.now, profile);
29    holder.model = model;
30  }
31
32  const interval = () => (timelineBusy(holder.timeline, holder.now, profile) ? TIMINGS.frame
33    : ambientActive(holder.timeline, profile) ? TIMINGS.ambientFrame
34      : clockShown(holder.timeline) ? TIMINGS.clock : null);
35  const schedule = () => {
36    const wanted = interval();
37    if (wanted === holder.interval) return;
38    holder.stop?.();
39    holder.stop = null;
40    holder.interval = wanted;
41    if (wanted === null) return;
42    holder.stop = surface.every(wanted, () => {
43      holder.now += wanted;
44      schedule();
45      surface.setState({ live: holder, now: holder.now });
46    });
47  };
48  schedule();
49  if (surface.state?.live !== holder) surface.setState({ live: holder, now: holder.now });
50
51  if (!model.visible && interval() === null) return empty();
52  const frame = composeBand(model, sampleTimeline(holder.timeline, holder.now, profile), { columns: props.columns, glyphs: props.glyphs, accent: props.accent });
53  return renderBand(surface.elements, frame);
54}
55