@litfamily/litclaude 1.0.22 → 1.0.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,16 +1,28 @@
1
- // Turns a band model and one sampled frame into rows of coloured spans. A span is
2
- // { text, fg?, bg?, bold? }; the surface module draws each row as a line of Text elements. Every
3
- // row is fitted to the width it is given, counting Hangul and emoji as two cells, so nothing wraps.
1
+ // Turns a band model and one sampled frame into the LIT card: four 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] · lit-loop ─────────────────────────── WORK ● ─╮
8
+ // │ ● PLAN ━━ [WORK] ━━ ○ REVIEW goal ██░░░ 2/5 │
9
+ // │ skills [⚡ lit-plan] [⚡ litresearch] [↯ lit-docx] │
10
+ // ╰─ ctx 42% ▰▰▱▱▱ · 5h 31% · git main ──────────────────────╯
11
+ //
12
+ // Each bracket pair stands for the two end cells of a filled pill: the brand on pink, the active
13
+ // phase and this request's skills on the accent with dark text, skills from earlier requests on
14
+ // dark grey. Those end cells are rounded Nerd Font caps in the pill's colour, or one cell of
15
+ // padding in that colour with basic glyphs, so a pill is as wide in either glyph set.
4
16
  //
5
17
  // In a real Claude Code 2.1.292 terminal, a row whose only change is a non-ASCII glyph (a block,
6
18
  // symbol, Hangul or Nerd Font cell) keeps showing the old glyph; a change to ASCII text, or to the
7
19
  // colour of an ASCII cell, repaints. So every change is carried by ASCII: a bar cell is a space on
8
20
  // its colour and the leading cell fades in by colour, embers are ASCII sparks, labels pop in letter
9
- // by letter, a notice slides by its leading spaces, a glyph swaps only together with its words,
10
- // and each gate state has its own label.
21
+ // by letter, a notice slides by its leading spaces, a chip's light moves over its ASCII name, and a
22
+ // glyph swaps only together with the colour of its words.
11
23
 
12
- import { fitSegments, textWidth, truncateToWidth } from "./layout.mjs";
13
- import { barCells, gradientAt, lerpColor } from "./motion.mjs";
24
+ import { textWidth, truncateToWidth } from "./layout.mjs";
25
+ import { barCells, lerpColor } from "./motion.mjs";
14
26
  import { glyphSet } from "./settings.mjs";
15
27
  import { formatDuration } from "./view.mjs";
16
28
 
@@ -27,24 +39,22 @@ export const PALETTE = Object.freeze({
27
39
  white: "#e6edf3",
28
40
  dim: "#6e7681",
29
41
  faint: "#30363d",
30
- cardBorder: "#3a2a4a",
31
42
  });
32
43
 
33
- const BRAND_STOPS = Object.freeze([PALETTE.orange, PALETTE.pink, PALETTE.cyan]);
34
- const PILL = Object.freeze({
35
- brand: { bg: PALETTE.pink, fg: "#12060c" },
36
- done: { bg: "#1d4d36", fg: "#b8f5cf" },
37
- now: { bg: "#0aa6c2", fg: "#001318" },
38
- todo: { bg: "#222a33", fg: "#8b949e" },
39
- warn: { bg: "#c27a0a", fg: "#1a0f00" },
40
- });
41
- const INK = Object.freeze({ done: PALETTE.green, now: PALETTE.accent, todo: PALETTE.dim, warn: PALETTE.amber });
44
+ // The status line's level colours (xterm 71, 178, 167) and its empty bar cells (238).
45
+ const HUD = Object.freeze({ green: "#5faf5f", yellow: "#d7af00", red: "#d75f5f", empty: "#444444" });
46
+ // Past this many columns the card stops growing, so a wide terminal does not stretch it thin.
47
+ export const CARD_MAX = 80;
48
+ // Pill colours: dark text on the bright fills, the muted pill for earlier skills and +N, the brand
49
+ // pink of the status line's prefix, and the connectors between phases.
50
+ const PILL = Object.freeze({ ink: "#0b0f14", muted: "#2d333b", mutedText: "#adb5bf", brand: PALETTE.pink, rail: "#46505c" });
42
51
  const TONE = Object.freeze({
43
52
  pink: { bar: PALETTE.pink, title: PALETTE.pinkText, icon: "🔥" },
44
53
  amber: { bar: PALETTE.amber, title: PALETTE.amber, icon: "⚠" },
45
54
  red: { bar: PALETTE.red, title: PALETTE.red, icon: "✗" },
46
55
  });
47
56
  const LABELS = Object.freeze({ plan: "PLAN", work: "WORK", review: "REVIEW" });
57
+ const PHASES = Object.freeze(["plan", "work", "review"]);
48
58
 
49
59
  const span = (text, style = {}) => ({ text, ...style });
50
60
  export const rowWidth = (row) => row.reduce((sum, part) => sum + textWidth(part.text), 0);
@@ -68,8 +78,15 @@ export const clipRow = (row, cells) => {
68
78
  return out;
69
79
  };
70
80
 
81
+ // Clips a row to `cells` and pads it with spaces to exactly that width.
82
+ const fitRow = (row, cells) => {
83
+ const clipped = clipRow(row, cells);
84
+ const missing = cells - rowWidth(clipped);
85
+ return missing > 0 ? [...clipped, span(" ".repeat(missing))] : clipped;
86
+ };
87
+
71
88
  // A label popping in: the first `reveal` share of its characters, and while ease-out-back carries
72
- // the reveal past 1, a cell or two of extra padding so the pill visibly overshoots and settles.
89
+ // the reveal past 1, a cell or two of extra padding so the label visibly overshoots and settles.
73
90
  export const revealText = (label, reveal) => {
74
91
  if (!(reveal > 0)) return null;
75
92
  const chars = [...label];
@@ -77,146 +94,235 @@ export const revealText = (label, reveal) => {
77
94
  return chars.slice(0, Math.min(count, chars.length)).join("") + " ".repeat(Math.max(0, count - chars.length));
78
95
  };
79
96
 
80
- const gradientSpans = (text, stops, { shimmer = null, bold = true } = {}) => {
97
+ // Text inside a pill, one span per character, when a light is passing: the fill brightens around
98
+ // `shimmer` (0 to 1 across the visible characters). Without a light the text stays one span.
99
+ const litText = (text, style, shimmer) => {
100
+ if (shimmer === null || shimmer === undefined) return [span(text, style)];
81
101
  const chars = [...text];
82
102
  const visible = chars.filter((ch) => ch.trim()).length;
83
103
  let k = 0;
84
104
  return chars.map((ch) => {
85
- if (!ch.trim()) return span(ch);
105
+ if (!ch.trim()) return span(ch, { ...style });
86
106
  const position = visible <= 1 ? 0 : k / (visible - 1);
87
107
  k += 1;
88
- let fg = gradientAt(stops, position);
89
- if (shimmer !== null && Math.abs(position - shimmer) < 0.18) fg = lerpColor(fg, "#ffffff", (1 - Math.abs(position - shimmer) / 0.18) * 0.75);
90
- return span(ch, { fg, bold });
108
+ const near = Math.abs(position - shimmer);
109
+ return span(ch, near < 0.18 ? { ...style, bg: lerpColor(style.bg, "#ffffff", (1 - near / 0.18) * 0.6) } : { ...style });
91
110
  });
92
111
  };
93
112
 
94
- const pillSpans = (text, colors, glyphs, bold = true) => [
95
- ...(glyphs.capLeft ? [span(glyphs.capLeft, { fg: colors.bg })] : []),
96
- span(text, { fg: colors.fg, bg: colors.bg, bold }),
97
- ...(glyphs.capRight ? [span(glyphs.capRight, { fg: colors.bg })] : []),
98
- ];
113
+ // A filled pill around `body` (spans that already carry the fill as `bg`): rounded caps in the
114
+ // fill colour where the glyph set has them, one cell of padding in the fill otherwise.
115
+ const pill = (body, fill, glyphs) => (glyphs.capLeft
116
+ ? [span(glyphs.capLeft, { fg: fill }), ...body, span(glyphs.capRight, { fg: fill })]
117
+ : [span(" ", { bg: fill }), ...body, span(" ", { bg: fill })]);
99
118
 
100
- const mixColors = (a, b, t) => ({ bg: lerpColor(a.bg, b.bg, t), fg: lerpColor(a.fg, b.fg, t) });
119
+ const inkOf = (accent) => ({ done: lerpColor(accent, PALETTE.dim, 0.45), now: accent, todo: PALETTE.dim, warn: PALETTE.amber });
101
120
 
102
- // Colours of one phase in this frame: the crossfade from its previous state, then the amber mix for
121
+ // Colour of one phase in this frame: the crossfade from its previous state, then the amber mix for
103
122
  // the work phase while the context warning is up.
104
- const phaseColors = (id, sample, palette) => {
123
+ const phaseColor = (id, sample, ink) => {
105
124
  const { from, to, mix } = sample.phases[id];
106
- let colors = palette === PILL ? mixColors(PILL[from], PILL[to], mix) : { fg: lerpColor(INK[from], INK[to], mix) };
107
- if (id === "work" && sample.warn > 0) colors = palette === PILL ? mixColors(colors, PILL.warn, sample.warn) : { fg: lerpColor(colors.fg, INK.warn, sample.warn) };
108
- return colors;
125
+ const color = lerpColor(ink[from], ink[to], mix);
126
+ return id === "work" && sample.warn > 0 ? lerpColor(color, ink.warn, sample.warn) : color;
109
127
  };
110
128
 
111
- const phaseGlyph = (id, model, sample, glyphs) => {
112
- // The warning glyph swaps on the same frame the ASCII `ctx` text appears, so the row repaints.
113
- if (id === "work" && sample.warn > 0.02) return glyphs.warn;
114
- return glyphs[sample.phases[id].to] ?? glyphs.todo;
115
- };
129
+ // The warning glyph swaps on the same frame the ASCII `ctx` text appears, so the row repaints.
130
+ const warned = (id, sample) => id === "work" && sample.warn > 0.02;
116
131
 
117
- const countText = (model) => (model.progress ? ` ${model.progress.done}/${model.progress.total}` : "");
132
+ const segment = (id, priority, spans, joiner = " ") => (spans && spans.length ? { id, priority, spans, joiner, width: rowWidth(spans) } : null);
118
133
 
119
- const brandSpans = (sample, glyphs, nerd) => {
120
- const shimmer = sample.completion?.shimmer ?? null;
121
- const reveal = revealText(nerd ? "🔥 LIT" : "(🔥 LIT)", sample.pills[0]);
122
- if (reveal === null) return [];
123
- if (!nerd) return gradientSpans(reveal, BRAND_STOPS, { shimmer });
124
- if (shimmer === null) return pillSpans(reveal, PILL.brand, glyphs);
125
- const lit = gradientSpans(reveal, [PILL.brand.fg, PILL.brand.fg], { shimmer }).map((part) => ({ ...part, bg: PILL.brand.bg }));
126
- return [span(glyphs.capLeft, { fg: PILL.brand.bg }), ...lit, span(glyphs.capRight, { fg: PILL.brand.bg })];
134
+ // Keeps segments in display order, dropping the highest priority numbers first until the joined
135
+ // row fits `room`; each kept segment after the first is preceded by its own joiner.
136
+ const fitJoined = (segments, room) => {
137
+ let kept = segments.filter(Boolean);
138
+ const total = () => kept.reduce((sum, part, index) => sum + part.width + (index > 0 ? textWidth(part.joiner) : 0), 0);
139
+ while (kept.length > 0 && total() > room) {
140
+ const worst = kept.reduce((a, b) => (b.priority >= a.priority ? b : a));
141
+ kept = kept.filter((part) => part !== worst);
142
+ }
143
+ return kept;
127
144
  };
128
145
 
129
- const barSegment = (model, sample, cells) => {
130
- if (!model.progress) return null;
131
- const bar = barCells(sample.fill, cells, sample.fillGlow ?? 0).map(({ glyph, color }) => (glyph === "█" ? span(" ", { bg: color }) : span(glyph, { fg: color })));
132
- return [
133
- ...bar,
134
- span(" "),
135
- span(String(model.progress.done), { fg: lerpColor(PALETTE.white, PALETTE.cyan, Math.max(sample.flash, sample.fillGlow ?? 0)), bold: true }),
136
- span(`/${model.progress.total}`, { fg: PALETTE.dim }),
137
- ];
138
- };
146
+ const joinSegments = (kept, joinerColor) => kept.flatMap((part, index) => (index > 0 ? [span(part.joiner, { fg: joinerColor }), ...part.spans] : part.spans));
139
147
 
140
- const loopSegment = (model, glyphs, suffix = "") => (model.autoloop
141
- ? [span(`${glyphs.loop} ${model.autoloop.n}/${model.autoloop.max}`, { fg: PALETTE.violet }), span(` · ${formatDuration(model.autoloop.leftMs)}${suffix}`, { fg: PALETTE.dim })]
142
- : null);
148
+ // An edge of the card: `open` and `close` are its corners, `left` its title or figures and
149
+ // `right` the phase indicator, with the rule filling whatever room is left.
150
+ const edgeRow = (open, left, right, close, width, color) => {
151
+ const tail = right.length ? [span(" "), ...right, span(` ${close}`, { fg: color })] : [span(close, { fg: color })];
152
+ const fill = Math.max(1, width - textWidth(open) - rowWidth(left) - 1 - rowWidth(tail));
153
+ return clipRow([span(open, { fg: color }), ...left, span(" "), span("─".repeat(fill), { fg: color }), ...tail], width);
154
+ };
143
155
 
144
- const ctxSegment = (model, sample) => {
145
- const warning = model.ctxWarning;
146
- if (!warning || sample.warn <= 0.02) return null;
147
- return [span(`ctx ${warning.percent}% → ${warning.threshold}%`, { fg: lerpColor(PALETTE.dim, PALETTE.amber, sample.warn), bold: sample.warn > 0.5 })];
156
+ const brandSpans = (sample, glyphs) => {
157
+ const reveal = revealText("🔥 LIT", sample.pills[0]);
158
+ if (reveal === null) return [];
159
+ return pill(litText(reveal, { fg: PILL.ink, bg: PILL.brand, bold: true }, sample.completion?.shimmer ?? null), PILL.brand, glyphs);
160
+ };
161
+ const indicatorSpans = (model, sample, glyphs, accent) => {
162
+ if (sample.completion) return [span(`DONE ${glyphs.done}`, { fg: PALETTE.green, bold: true })];
163
+ if (model.idle) return [span("idle", { fg: PALETTE.dim })];
164
+ const current = PHASES.find((id) => model.phases[id] === "now");
165
+ if (current) return [span(`${LABELS[current]} ${warned(current, sample) ? glyphs.warn : glyphs.now}`, { fg: phaseColor(current, sample, inkOf(accent)), bold: true })];
166
+ if (PHASES.every((id) => model.phases[id] === "done") && model.lanes.count === 0) return [span(`DONE ${glyphs.done}`, { fg: PALETTE.green, bold: true })];
167
+ return [span(`LIVE ${glyphs.now}`, { fg: accent, bold: true })];
148
168
  };
149
169
 
150
- const lanesSegment = (model, glyphs) => {
151
- if (model.lanes.count === 0) return null;
152
- const dots = model.lanes.list.slice(0, 4).map((lane) => span(glyphs.lane, { fg: lane.status === "stalled" ? PALETTE.amber : PALETTE.green }));
153
- return [...dots, span(` ${model.lanes.count} ${model.lanes.count === 1 ? "lane" : "lanes"}`, { fg: PALETTE.dim })];
170
+ // ╭─ [🔥 LIT] · <discipline> ──── <PHASE ●> ─╮. On a narrow card the phase indicator goes first,
171
+ // then the discipline shortens, then it goes too.
172
+ const topEdge = (model, sample, { width, glyphs, accent, edge }) => {
173
+ const brand = brandSpans(sample, glyphs);
174
+ const right = indicatorSpans(model, sample, glyphs, accent);
175
+ const name = model.title ?? "ready";
176
+ const nameStyle = model.idle ? { fg: PALETTE.dim } : { fg: accent, bold: true };
177
+ const title = (text) => (text ? [span(" · ", { fg: PALETTE.dim }), span(text, nameStyle)] : []);
178
+ const fixed = textWidth("╭─ ") + rowWidth(brand) + 1 + 1;
179
+ const withRight = fixed + rowWidth(title(name)) + 1 + rowWidth(right) + textWidth(" ─╮");
180
+ if (withRight <= width) return edgeRow("╭─ ", [...brand, ...title(name)], right, "─╮", width, edge);
181
+ const room = width - fixed - textWidth("─╮") - textWidth(" · ");
182
+ const shown = room >= 4 ? truncateToWidth(name, room) : "";
183
+ return edgeRow("╭─ ", [...brand, ...title(shown)], [], "─╮", width, edge);
154
184
  };
155
185
 
156
- const segment = (id, priority, spans, extra = {}) => (spans && spans.length ? { id, priority, spans, width: rowWidth(spans), ...extra } : null);
186
+ const barSpans = (fraction, sample, cells) => barCells(fraction, cells, sample.fillGlow ?? 0)
187
+ .map(({ glyph, color }) => (glyph === "█" ? span(" ", { bg: color }) : span(glyph, { fg: color })));
188
+
189
+ // The right half of the phase row: a context warning, the lanes, the autoloop budget and the
190
+ // progress of the plan or goal, dropped in that priority order when the row is narrow.
191
+ const progressSegment = (model, sample, glyphs, cells) => {
192
+ if (model.progress) {
193
+ const label = model.progress.unit === "tasks" ? "tasks" : "goal";
194
+ return segment("progress", 1, [
195
+ span(`${label} `, { fg: PALETTE.dim }),
196
+ ...barSpans(sample.fill, sample, cells),
197
+ span(" "),
198
+ span(String(model.progress.done), { fg: lerpColor(PALETTE.white, PALETTE.cyan, Math.max(sample.flash, sample.fillGlow ?? 0)), bold: true }),
199
+ span(`/${model.progress.total}`, { fg: PALETTE.dim }),
200
+ ]);
201
+ }
202
+ const goal = model.goal;
203
+ if (!goal) return null;
204
+ const met = goal.status === "complete";
205
+ return segment("progress", 1, [
206
+ span("goal ", { fg: PALETTE.dim }),
207
+ span(met ? `${glyphs.done} ` : "", { fg: PALETTE.green }),
208
+ span(`${goal.passed}/${goal.total}`, { fg: met ? PALETTE.green : PALETTE.white, bold: met }),
209
+ ]);
210
+ };
157
211
 
158
- const assemble = (kept, gap, fitted = {}) => {
159
- const row = [];
160
- kept.forEach((part, index) => {
161
- if (index > 0) row.push(span(" ".repeat(kept[index - 1].trailingSeparator && part.kind === "phase" ? 1 : gap)));
162
- let spans = part.spans;
163
- if (part.trailingSeparator && kept[index + 1]?.kind !== "phase") spans = spans.slice(0, -1);
164
- row.push(...(fitted[part.id] ? fitted[part.id](part.width) : spans));
212
+ // ● PLAN ━━ [WORK] ━━ ○ REVIEW: the active phase is a pill on the accent (amber while the context
213
+ // warning is up), done phases a dim-accent dot and the rest a dim ring. Each form of a label is
214
+ // as wide as the others, so a phase changing state never moves the row.
215
+ const phaseTrack = (model, sample, { glyphs, accent, rail, short }) => {
216
+ const ink = inkOf(accent);
217
+ const out = [];
218
+ PHASES.forEach((id, index) => {
219
+ const label = short ? LABELS[id][0] : LABELS[id];
220
+ const state = sample.phases[id].to;
221
+ const shown = revealText(state === "now" ? label : `${state === "done" ? "●" : "○"} ${label}`, sample.pills[index + 1]);
222
+ if (shown === null) return;
223
+ if (out.length) out.push(span(` ${glyphs.rail.repeat(rail)} `, { fg: sample.phases[PHASES[index - 1]].to === "done" ? ink.done : PILL.rail }));
224
+ if (state === "now") {
225
+ const fill = model.idle ? lerpColor(phaseColor(id, sample, ink), PILL.muted, 0.5) : phaseColor(id, sample, ink);
226
+ out.push(...pill([span(shown.slice(0, label.length).padEnd(label.length), { fg: PILL.ink, bg: fill, bold: true })], fill, glyphs));
227
+ } else {
228
+ out.push(span(warned(id, sample) ? shown.replace(/^[●○]/u, glyphs.warn) : shown, { fg: phaseColor(id, sample, ink) }));
229
+ }
165
230
  });
166
- return row;
231
+ return out;
167
232
  };
168
233
 
169
- const nextSegment = (model) => {
170
- if (!model.next) return null;
171
- const text = `▸ ${model.next}`;
172
- return segment("next", 4, [span(text, { fg: PALETTE.white })], { minWidth: Math.min(12, textWidth(text)) });
234
+ const phaseRow = (model, sample, { inner, glyphs, accent }) => {
235
+ const variants = [{ rail: 2, short: false }, { rail: 1, short: false }, { rail: 1, short: true }];
236
+ const tracks = variants.map((variant) => phaseTrack(model, sample, { glyphs, accent, ...variant }));
237
+ const right = [];
238
+ if (sample.rest > 0) {
239
+ const warning = model.ctxWarning && sample.warn > 0.02
240
+ ? [span(`ctx ${model.ctxWarning.percent}% → ${model.ctxWarning.threshold}%`, { fg: lerpColor(PALETTE.dim, PALETTE.amber, sample.warn), bold: sample.warn > 0.5 })]
241
+ : null;
242
+ right.push(segment("ctx", 1, warning));
243
+ if (model.lanes.count > 0) {
244
+ const dots = model.lanes.list.slice(0, 4).map((lane) => span(glyphs.lane, { fg: lane.status === "stalled" ? PALETTE.amber : PALETTE.green }));
245
+ right.push(segment("lanes", 3, [...dots, span(` ${model.lanes.count} ${model.lanes.count === 1 ? "lane" : "lanes"}`, { fg: PALETTE.dim })]));
246
+ }
247
+ if (model.autoloop) {
248
+ 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 })]));
249
+ }
250
+ right.push(progressSegment(model, sample, glyphs, inner >= 70 ? 8 : 5));
251
+ }
252
+ const track = tracks.find((candidate) => rowWidth(candidate) + 2 + 10 <= inner) ?? tracks.at(-1);
253
+ const kept = fitJoined(right, inner - rowWidth(track) - 2);
254
+ const tail = joinSegments(kept, PALETTE.faint);
255
+ if (!tail.length) return fitRow(track, inner);
256
+ return fitRow([...track, span(" ".repeat(Math.max(2, inner - rowWidth(track) - rowWidth(tail)))), ...tail], inner);
173
257
  };
174
258
 
175
- const ribbonRow = (model, sample, { columns, glyphs, nerd }) => {
176
- const segments = [segment("brand", 0, brandSpans(sample, glyphs, nerd))];
177
- ["plan", "work", "review"].forEach((id, index) => {
178
- const label = nerd
179
- ? `${phaseGlyph(id, model, sample, glyphs)} ${LABELS[id]}${id === "work" ? countText(model) : ""}`
180
- : `${LABELS[id]} ${phaseGlyph(id, model, sample, glyphs)}${id === "work" ? countText(model) : ""}`;
181
- const text = revealText(label, sample.pills[index + 1]);
182
- if (text === null) return;
183
- const colors = phaseColors(id, sample, nerd ? PILL : INK);
184
- const spans = nerd
185
- ? pillSpans(text, colors, glyphs, sample.phases[id].to !== "todo" || id === "work")
186
- : [span(text, { fg: colors.fg, bold: sample.phases[id].to === "now" || (id === "work" && sample.warn > 0.5) })];
187
- const separator = !nerd && id !== "review";
188
- segments.push(segment(id, id === "work" ? 0 : 3, separator ? [...spans, span(` ${glyphs.link}`, { fg: PALETTE.faint })] : spans, { kind: "phase", trailingSeparator: separator }));
189
- });
190
- if (sample.rest > 0) {
191
- const cells = columns >= 110 ? 10 : columns >= 80 ? 7 : 5;
192
- segments.push(segment("bar", 2, barSegment(model, sample, cells)));
193
- const ctx = ctxSegment(model, sample);
194
- segments.push(ctx ? segment("ctx", 1, ctx) : nextSegment(model));
195
- segments.push(segment("lanes", 5, lanesSegment(model, glyphs)));
196
- segments.push(segment("loop", 6, loopSegment(model, glyphs)));
259
+ const CHIP_TONE = Object.freeze({
260
+ current: (accent) => ({ fill: accent, text: PILL.ink }),
261
+ recent: () => ({ fill: PILL.muted, text: PILL.mutedText }),
262
+ });
263
+
264
+ // One chip pill: the bolt and the skill name, on the accent for this request or muted for an
265
+ // earlier one. A chip that just arrived carries full motion's light along its name, or fills up
266
+ // from the muted pill under subtle; a chip changing state crossfades its colours.
267
+ const chipSpans = (chip, timed, glyphs, accent) => {
268
+ const to = timed?.to ?? chip.state;
269
+ const from = timed?.from ?? to;
270
+ const mix = timed?.mix ?? 1;
271
+ const arrive = timed?.arrive ?? 1;
272
+ let fill = lerpColor(CHIP_TONE[from](accent).fill, CHIP_TONE[to](accent).fill, mix);
273
+ const text = lerpColor(CHIP_TONE[from](accent).text, CHIP_TONE[to](accent).text, mix);
274
+ if (arrive < 1 && (timed?.shimmer === null || timed?.shimmer === undefined)) fill = lerpColor(PILL.muted, fill, arrive);
275
+ const bold = to === "current";
276
+ return pill([
277
+ span(`${to === "current" ? glyphs.bolt : glyphs.recentBolt} `, { fg: text, bg: fill }),
278
+ ...litText(chip.id, { fg: text, bg: fill, bold }, timed?.shimmer),
279
+ ], fill, glyphs);
280
+ };
281
+
282
+ const mutedPill = (text, glyphs) => pill([span(text, { fg: PALETTE.dim, bg: PILL.muted })], PILL.muted, glyphs);
283
+
284
+ const chipsRow = (model, sample, { inner, glyphs, accent }) => {
285
+ const chips = model.skills;
286
+ if (chips.length === 0) {
287
+ if (!model.idle && model.next) return fitRow([span("▸ ", { fg: accent }), span(truncateToWidth(model.next, inner - 2), { fg: PALETTE.white })], inner);
288
+ if (!model.idle) return fitRow([span("no LitFamily skill yet in this request", { fg: PALETTE.dim })], inner);
289
+ const hints = [
290
+ [span("no LitFamily skill yet · type ", { fg: PALETTE.dim }), span("lit <task>", { fg: accent }), span(" to start one", { fg: PALETTE.dim })],
291
+ [span("no skill yet · type ", { fg: PALETTE.dim }), span("lit <task>", { fg: accent })],
292
+ [span("type ", { fg: PALETTE.dim }), span("lit <task>", { fg: accent })],
293
+ ];
294
+ return fitRow(hints.find((hint) => rowWidth(hint) <= inner) ?? hints.at(-1), inner);
295
+ }
296
+ const row = inner >= 30 ? [span("skills", { fg: PALETTE.dim })] : [];
297
+ let shown = 0;
298
+ for (const chip of chips) {
299
+ const entry = chipSpans(chip, sample.chips?.[chip.id], glyphs, accent);
300
+ const gap = row.length ? [span(" ")] : [];
301
+ const rest = chips.length - shown - 1;
302
+ const reserve = rest > 0 ? 1 + rowWidth(mutedPill(`+${rest}`, glyphs)) : 0;
303
+ if (rowWidth(row) + rowWidth(gap) + rowWidth(entry) + reserve > inner) break;
304
+ row.push(...gap, ...entry);
305
+ shown += 1;
197
306
  }
198
- const kept = fitSegments(segments.filter(Boolean), columns, 2);
199
- return assemble(kept, 2, { next: (width) => [span(truncateToWidth(`▸ ${model.next}`, width), { fg: PALETTE.white })] });
307
+ if (shown < chips.length) row.push(...(row.length ? [span(" ")] : []), ...mutedPill(`+${chips.length - shown}`, glyphs));
308
+ return fitRow(row, inner);
200
309
  };
201
310
 
202
- const noticeRow = (sample, columns) => {
203
- if (!sample.notice) return null;
311
+ const noticeRow = (sample, inner) => {
204
312
  const { data, offset } = sample.notice;
205
313
  const tone = TONE[data.tone] ?? TONE.pink;
206
- const row = [
314
+ return fitRow([
207
315
  span(" ".repeat(offset)),
208
316
  span("▎", { fg: tone.bar }),
209
317
  span(` ${tone.icon} ${data.title}`, { fg: tone.title, bold: true }),
210
318
  ...(data.detail ? [span(` · ${data.detail}`, { fg: PALETTE.dim })] : []),
211
- ];
212
- return clipRow(row, columns);
319
+ ], inner);
213
320
  };
214
321
 
215
322
  const SPARKS = [".", "+", "*"];
216
323
 
217
- const completionRow = (sample, columns) => {
324
+ const completionRow = (sample, inner) => {
218
325
  const completion = sample.completion;
219
- if (!completion) return null;
220
326
  const glow = completion.embers === null ? 0 : 0.6 * Math.sin(Math.PI * completion.embers);
221
327
  const words = [
222
328
  span("COMPLETE", { fg: lerpColor(lerpColor(PALETTE.dim, PALETTE.white, completion.title), PALETTE.pinkText, glow), bold: true }),
@@ -224,7 +330,7 @@ const completionRow = (sample, columns) => {
224
330
  ];
225
331
  const row = [];
226
332
  if (completion.embers !== null) {
227
- const count = Math.max(0, Math.min(18, Math.floor((columns - rowWidth(words) - 2) / 2)));
333
+ const count = Math.max(0, Math.min(18, Math.floor((inner - rowWidth(words) - 2) / 2)));
228
334
  for (let i = 0; i < count; i += 1) {
229
335
  const life = Math.min(1, Math.max(0, completion.embers * 1.6 - i * 0.04));
230
336
  const alpha = life <= 0 || life >= 1 ? 0 : Math.sin(life * Math.PI);
@@ -235,32 +341,60 @@ const completionRow = (sample, columns) => {
235
341
  } else {
236
342
  row.push(span("✦ ", { fg: PALETTE.pink }));
237
343
  }
238
- return clipRow([...row, ...words], columns);
344
+ return fitRow([...row, ...words], inner);
239
345
  };
240
346
 
241
- const laneEntry = (lane, glyphs) => {
242
- const state = lane.status === "stalled"
243
- ? { glyph: glyphs.lane, fg: PALETTE.amber, word: "quiet" }
244
- : lane.status === "waiting" || lane.status === "idle"
245
- ? { glyph: glyphs.idleLane, fg: PALETTE.faint, word: "waiting" }
246
- : { glyph: glyphs.lane, fg: PALETTE.green, word: "working" };
247
- const elapsed = lane.elapsedMs === null ? "" : ` ${formatDuration(lane.elapsedMs)}`;
248
- return [span(state.glyph, { fg: state.fg }), span(` ${lane.name}`, { fg: PALETTE.white }), span(` ${state.word}${elapsed}`, { fg: PALETTE.dim })];
347
+ const levelColor = (percent) => (percent >= 90 ? HUD.red : percent >= 70 ? HUD.yellow : HUD.green);
348
+
349
+ // ╰─ ctx 42% ▰▰▱▱▱ · 5h 31% · git main ───╯, or a warning first when one is up. The branch goes
350
+ // first on a narrow card, then the rate window, then the context bar.
351
+ const bottomEdge = (model, { width, glyphs, accent, edge }) => {
352
+ const { context, rate, git } = model.figures;
353
+ const warning = model.warnings[0];
354
+ const segments = [];
355
+ if (warning) {
356
+ const color = warning.tone === "red" ? PALETTE.red : PALETTE.amber;
357
+ segments.push(segment("warning", 0, [span(`${warning.tone === "red" ? glyphs.fail : "⚠"} ${warning.subject}`, { fg: color, bold: true }), span(` ${warning.text}`, { fg: color })], " · "));
358
+ }
359
+ if (context !== null) {
360
+ segments.push(segment("ctx", 1, [span("ctx ", { fg: PALETTE.dim }), span(`${context}%`, { fg: accent })], " · "));
361
+ const filled = Math.max(0, Math.min(5, Math.round(context / 20)));
362
+ segments.push(segment("ctxbar", 2, [span("▰".repeat(filled), { fg: accent }), span("▱".repeat(5 - filled), { fg: HUD.empty })], " "));
363
+ }
364
+ if (rate) segments.push(segment("rate", 3, [span(`${rate.label} `, { fg: PALETTE.dim }), span(`${rate.percent}%`, { fg: levelColor(rate.percent) })], " · "));
365
+ if (git) segments.push(segment("git", 4, [span(`${glyphs.branch} `, { fg: PALETTE.dim }), span(git, { fg: accent })], " · "));
366
+ const room = width - textWidth("╰─ ") - 1 - 1 - textWidth("─╯");
367
+ let kept = fitJoined(segments, room);
368
+ if (kept[0]?.id === "ctxbar") kept = kept.slice(1);
369
+ if (kept.length === 0 && warning) kept = [{ ...segments[0], spans: clipRow(segments[0].spans, room) }];
370
+ return edgeRow("╰─ ", joinSegments(kept, edge), [], "─╯", width, edge);
249
371
  };
250
372
 
251
- const listRow = (label, entries, columns) => {
252
- const row = [span(`${label} `, { fg: PALETTE.dim })];
253
- let shown = 0;
254
- for (const entry of entries) {
255
- const gap = shown > 0 ? [span(" ")] : [];
256
- const rest = entries.length - shown - 1;
257
- const reserve = rest > 0 ? textWidth(` +${rest}`) : 0;
258
- if (rowWidth(row) + rowWidth(gap) + rowWidth(entry) + reserve > columns) break;
259
- row.push(...gap, ...entry);
260
- shown += 1;
261
- }
262
- if (shown < entries.length) row.push(span(` +${entries.length - shown}`, { fg: PALETTE.dim }));
263
- return clipRow(row, columns);
373
+ const edgeColor = (model, accent) => {
374
+ if (model.warnings.some(({ tone }) => tone === "red")) return PALETTE.red;
375
+ if (model.warnings.length) return PALETTE.amber;
376
+ return lerpColor(PALETTE.faint, accent, model.idle ? 0.35 : 0.6);
377
+ };
378
+
379
+ /**
380
+ * The card for one frame: `{ mode: "card", border, width, rows }`, always four rows (top edge,
381
+ * phase row, chips row, bottom edge), each exactly `width` cells: the terminal's columns up to
382
+ * CARD_MAX. `accent` is the status line's accent colour (settings.mjs accentFromSettings).
383
+ */
384
+ export const composeBand = (model, sample, { columns, glyphs: glyphMode = "basic", accent = PALETTE.accent }) => {
385
+ const glyphs = glyphSet(glyphMode);
386
+ const width = Math.max(12, Math.min(CARD_MAX, columns));
387
+ const inner = width - 4;
388
+ const edge = edgeColor(model, accent);
389
+ const side = (row) => [span("│ ", { fg: edge }), ...row, span(" │", { fg: edge })];
390
+ const options = { width, inner, glyphs, accent, edge };
391
+ const middle = sample.completion ? completionRow(sample, inner) : sample.notice ? noticeRow(sample, inner) : chipsRow(model, sample, options);
392
+ return {
393
+ mode: "card",
394
+ border: edge,
395
+ width,
396
+ rows: [topEdge(model, sample, options), side(phaseRow(model, sample, options)), side(middle), bottomEdge(model, options)],
397
+ };
264
398
  };
265
399
 
266
400
  const GATE_STYLE = Object.freeze({
@@ -274,64 +408,6 @@ const gateEntries = (gates, glyphs) => gates.map((gate) => {
274
408
  return [span(style.glyph, { fg: style.fg }), span(` ${gate.label}`, { fg: PALETTE.dim })];
275
409
  });
276
410
 
277
- const warningRow = (warning, glyphs, columns) => {
278
- const color = warning.tone === "red" ? PALETTE.red : PALETTE.amber;
279
- return clipRow([
280
- span(warning.tone === "red" ? glyphs.fail : "⚠", { fg: color }),
281
- span(` ${warning.subject}`, { fg: PALETTE.white }),
282
- span(` ${warning.text}`, { fg: color }),
283
- span(` — ${warning.hint}`, { fg: PALETTE.dim }),
284
- ], columns);
285
- };
286
-
287
- const phaseLine = (model, sample, { columns, glyphs }) => {
288
- const railLength = columns >= 100 ? 6 : columns >= 80 ? 3 : 1;
289
- const segments = [segment("brand", 0, sample.pills[0] > 0 ? gradientSpans(revealText("🔥 L I T", sample.pills[0]), BRAND_STOPS, { shimmer: sample.completion?.shimmer ?? null }) : [])];
290
- const phaseSpans = [];
291
- ["plan", "work", "review"].forEach((id, index) => {
292
- const text = revealText(`${phaseGlyph(id, model, sample, glyphs)} ${LABELS[id]}`, sample.pills[index + 1]);
293
- if (text === null) return;
294
- const colors = phaseColors(id, sample, INK);
295
- if (phaseSpans.length) {
296
- const leftDone = sample.phases[["plan", "work", "review"][index - 1]].to === "done";
297
- phaseSpans.push(span(` ${glyphs.rail.repeat(railLength)} `, { fg: leftDone ? PALETTE.green : PALETTE.faint }));
298
- }
299
- phaseSpans.push(span(text, { fg: colors.fg, bold: sample.phases[id].to === "now" }));
300
- });
301
- segments.push(segment("phases", 1, phaseSpans));
302
- if (sample.rest > 0) {
303
- segments.push(segment("bar", 2, barSegment(model, sample, columns >= 90 ? 14 : 8)));
304
- segments.push(segment("ctx", 1, ctxSegment(model, sample)));
305
- segments.push(segment("loop", 4, loopSegment(model, glyphs, " left")));
306
- }
307
- return assemble(fitSegments(segments.filter(Boolean), columns, 3), 3);
308
- };
309
-
310
- /**
311
- * The whole band for one frame: `{ mode, border, rows }`. `mode` is `card` (a round-bordered box,
312
- * `columns` minus its border and padding inside) when two or more lanes run or a warning needs a
313
- * row of its own, else `ribbon`.
314
- */
315
- export const composeBand = (model, sample, { columns, glyphs: glyphMode = "basic" }) => {
316
- const glyphs = glyphSet(glyphMode);
317
- const nerd = glyphMode === "nerd";
318
- const extras = [noticeRow(sample, columns), completionRow(sample, columns)].filter(Boolean);
319
- if (!model.card) {
320
- return { mode: "ribbon", border: null, rows: [ribbonRow(model, sample, { columns, glyphs, nerd }), ...extras] };
321
- }
322
- const inner = Math.max(10, columns - 4);
323
- const rows = [phaseLine(model, sample, { columns: inner, glyphs })];
324
- if (sample.rest > 0) {
325
- if (model.next) rows.push(clipRow([span("next ", { fg: PALETTE.dim }), span(truncateToWidth(model.next, inner - 6), { fg: PALETTE.white })], inner));
326
- if (model.lanes.count > 0) rows.push(listRow("lanes", model.lanes.list.map((lane) => laneEntry(lane, glyphs)), inner));
327
- if (model.gates.length > 0) rows.push(listRow("gates", gateEntries(model.gates, glyphs), inner));
328
- for (const warning of model.warnings) rows.push(warningRow(warning, glyphs, inner));
329
- }
330
- rows.push(...[noticeRow(sample, inner), completionRow(sample, inner)].filter(Boolean));
331
- const border = model.warnings.some(({ tone }) => tone === "red") ? PALETTE.red : model.warnings.length ? PALETTE.amber : PALETTE.cardBorder;
332
- return { mode: "card", border, rows };
333
- };
334
-
335
411
  /** Rows for the evidence pane's tab (`goal`, `lanes` or `gates`), fitted to `columns`. */
336
412
  export const composeEvidence = (evidence, tab, { columns, glyphs: glyphMode = "basic" }) => {
337
413
  const glyphs = glyphSet(glyphMode);
@@ -51,26 +51,3 @@ export const truncateToWidth = (text, cells, ellipsis = "…") => {
51
51
  }
52
52
  return `${out.trimEnd()}${ellipsis}`;
53
53
  };
54
-
55
- // Segments arrive in display order with a priority (lower numbers outlast higher ones) and an
56
- // optional `minWidth`, which marks the one flexible segment (the next task) that may shrink. Fitting
57
- // drops the highest priority numbers until the row fits with that segment at its minimum, then lets
58
- // it grow back into whatever room is left. Returns the kept segments, in display order, with widths.
59
- export const fitSegments = (segments, columns, gap = 1) => {
60
- let kept = segments.map((segment) => ({ ...segment, natural: segment.width }));
61
- const isFlexible = (segment) => Number.isFinite(segment.minWidth);
62
- const total = () => kept.reduce((sum, segment) => sum + (isFlexible(segment) ? Math.min(segment.minWidth, segment.natural) : segment.width), 0)
63
- + Math.max(0, kept.length - 1) * gap;
64
- while (kept.length > 0 && total() > columns) {
65
- const worst = kept.reduce((a, b) => (b.priority >= a.priority ? b : a));
66
- kept = kept.filter((segment) => segment !== worst);
67
- }
68
- let spare = columns - total();
69
- return kept.map(({ natural, ...segment }) => {
70
- if (!isFlexible(segment)) return segment;
71
- const base = Math.min(segment.minWidth, natural);
72
- const width = Math.min(natural, base + spare);
73
- spare -= width - base;
74
- return { ...segment, width };
75
- });
76
- };