@litfamily/litclaude 1.0.22 → 1.0.23
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.
- package/CHANGELOG.md +6 -0
- package/README.md +8 -8
- package/README_ko-KR.md +8 -8
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/hooks.md +56 -25
- package/package.json +1 -1
- package/plugins/litclaude/.claude-plugin/plugin.json +3 -3
- package/plugins/litclaude/bin/litclaude-hud.js +2 -5
- package/plugins/litclaude/bin/litclaude-mcp.js +1 -1
- package/plugins/litclaude/hooks/lit-band-client.mjs +1 -1
- package/plugins/litclaude/hooks/lit-mod.mjs +53 -8
- package/plugins/litclaude/lib/hud-ignition.mjs +7 -37
- package/plugins/litclaude/lib/mod/frame.mjs +270 -194
- package/plugins/litclaude/lib/mod/layout.mjs +0 -23
- package/plugins/litclaude/lib/mod/render.mjs +4 -8
- package/plugins/litclaude/lib/mod/settings.mjs +52 -4
- package/plugins/litclaude/lib/mod/skills.mjs +81 -0
- package/plugins/litclaude/lib/mod/state.mjs +38 -1
- package/plugins/litclaude/lib/mod/timeline.mjs +30 -2
- package/plugins/litclaude/lib/mod/view.mjs +32 -9
|
@@ -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.
|
|
3
|
-
//
|
|
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
|
|
10
|
-
//
|
|
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 {
|
|
13
|
-
import { barCells,
|
|
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
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
119
|
+
const inkOf = (accent) => ({ done: lerpColor(accent, PALETTE.dim, 0.45), now: accent, todo: PALETTE.dim, warn: PALETTE.amber });
|
|
101
120
|
|
|
102
|
-
//
|
|
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
|
|
123
|
+
const phaseColor = (id, sample, ink) => {
|
|
105
124
|
const { from, to, mix } = sample.phases[id];
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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
|
|
132
|
+
const segment = (id, priority, spans, joiner = " ") => (spans && spans.length ? { id, priority, spans, joiner, width: rowWidth(spans) } : null);
|
|
118
133
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
145
|
-
const
|
|
146
|
-
if (
|
|
147
|
-
return
|
|
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")) 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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
231
|
+
return out;
|
|
167
232
|
};
|
|
168
233
|
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
const
|
|
172
|
-
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
if (
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
199
|
-
return
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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((
|
|
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
|
|
344
|
+
return fitRow([...row, ...words], inner);
|
|
239
345
|
};
|
|
240
346
|
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const
|
|
248
|
-
|
|
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
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
};
|