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

<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&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 "lit add a --shout flag that prints the greeting in capitals, with a test" 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>
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.
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&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&ID on one A3 sheet<br /><code>lit draw a detailed P&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>
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>
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>
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>
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>
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>
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>
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.
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.
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
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.
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.
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:
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
hooks/lit-mod.mjs 509 lines1// 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};
509lib/mod/frame.mjs 887 lines1// 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};
887lib/mod/motion.mjs 132 lines1// 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};
132lib/mod/render.mjs 21 lines1// 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) });
21lib/mod/settings.mjs 114 lines1// 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};
114lib/mod/svg-card.mjs 562 lines1// 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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
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};
562lib/mod/skills.mjs 86 lines1// 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};
86lib/mod/toasts.mjs 93 lines1// 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};
93lib/mod/state.mjs 593 lines1// 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};
593lib/mod/timeline.mjs 231 lines1// 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};
231lib/mod/view.mjs 539 lines1// 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};
539hooks/lit-band-client.mjs 55 lines1// 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