@litfamily/litclaude 1.0.21 → 1.0.22
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 +12 -0
- package/README.md +34 -92
- package/README_ko-KR.md +42 -95
- package/bin/icon-font.mjs +264 -0
- package/bin/litclaude-ai.js +155 -18
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/hooks.md +100 -5
- package/docs/privacy.md +2 -2
- package/package.json +13 -3
- package/plugins/litclaude/.claude-plugin/plugin.json +22 -2
- package/plugins/litclaude/bin/litclaude-hook.js +13 -7
- package/plugins/litclaude/bin/litclaude-hud.js +36 -19
- package/plugins/litclaude/bin/litclaude-mcp.js +1 -1
- package/plugins/litclaude/hooks/hooks.json +1 -0
- package/plugins/litclaude/hooks/lit-band-client.mjs +44 -0
- package/plugins/litclaude/hooks/lit-mod.mjs +269 -0
- package/plugins/litclaude/lib/auto-handoff.mjs +2 -0
- package/plugins/litclaude/lib/automatic-update-refresh.mjs +4 -0
- package/plugins/litclaude/lib/automatic-update.mjs +148 -1
- package/plugins/litclaude/lib/cache-measurement.mjs +62 -1
- package/plugins/litclaude/lib/canonical-skill-resources.mjs +9 -9
- package/plugins/litclaude/lib/hud-accent-palette.mjs +2 -2
- package/plugins/litclaude/lib/interface-probe-gate.mjs +2 -1
- package/plugins/litclaude/lib/jev-skill-hint.mjs +2 -0
- package/plugins/litclaude/lib/lit-plan-persistence.mjs +2 -1
- package/plugins/litclaude/lib/litgoal/autoloop.mjs +2 -0
- package/plugins/litclaude/lib/litgoal/cli.mjs +2 -0
- package/plugins/litclaude/lib/litgoal/ledger.mjs +3 -0
- package/plugins/litclaude/lib/litgoal/state.mjs +4 -0
- package/plugins/litclaude/lib/mod/frame.mjs +378 -0
- package/plugins/litclaude/lib/mod/layout.mjs +76 -0
- package/plugins/litclaude/lib/mod/motion.mjs +101 -0
- package/plugins/litclaude/lib/mod/render.mjs +24 -0
- package/plugins/litclaude/lib/mod/settings.mjs +57 -0
- package/plugins/litclaude/lib/mod/state.mjs +374 -0
- package/plugins/litclaude/lib/mod/timeline.mjs +124 -0
- package/plugins/litclaude/lib/mod/view.mjs +232 -0
- package/plugins/litclaude/lib/motion-render-gate.mjs +2 -1
- package/plugins/litclaude/lib/office-runtime.mjs +6 -0
- package/plugins/litclaude/lib/project-state-root.mjs +34 -2
- package/plugins/litclaude/lib/rules/engine.mjs +2 -1
- package/plugins/litclaude/lib/rules/format.mjs +24 -3
- package/plugins/litclaude/lib/rules/session-state.mjs +2 -1
- package/plugins/litclaude/lib/secure-path-read.mjs +46 -0
- package/plugins/litclaude/lib/start-work-lifecycle.mjs +4 -0
- package/plugins/litclaude/lib/wikify-knowledge.mjs +2 -1
- package/plugins/litclaude/scripts/scaffold-plan.mjs +2 -1
- package/plugins/litclaude/skills/lit-team/scripts/team.mjs +2 -1
- package/plugins/litclaude/skills/lit-typographic-motion/scripts/motion-runtime.mjs +7 -0
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { join, resolve } from "node:path";
|
|
8
8
|
|
|
9
|
-
import { resolveProjectStateRoot } from "./project-state-root.mjs";
|
|
9
|
+
import { ensureStateDirIgnored, resolveProjectStateRoot } from "./project-state-root.mjs";
|
|
10
10
|
import { findProjectRoot } from "./rules/discovery.mjs";
|
|
11
11
|
|
|
12
12
|
export const LIT_PLAN_MAX_BLOCKS = 2;
|
|
@@ -38,6 +38,7 @@ const readTurn = (cwd) => {
|
|
|
38
38
|
const writeTurn = (cwd, state) => {
|
|
39
39
|
const target = litPlanTurnPath(cwd);
|
|
40
40
|
mkdirSync(join(target, ".."), { recursive: true });
|
|
41
|
+
ensureStateDirIgnored(target);
|
|
41
42
|
const tmp = `${target}.tmp`;
|
|
42
43
|
writeFileSync(tmp, JSON.stringify(state));
|
|
43
44
|
renameSync(tmp, target);
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// than risk a runaway loop. State lives at `.litclaude/litgoal/autoloop.json`.
|
|
6
6
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { join } from "node:path";
|
|
8
|
+
import { ensureStateDirIgnored } from "../project-state-root.mjs";
|
|
8
9
|
import { appendLitgoalLedger } from "./ledger.mjs";
|
|
9
10
|
import { litgoalGoalsPath, litgoalLedgerPath, litgoalLockDir, litgoalStateDir } from "./paths.mjs";
|
|
10
11
|
import { readLitgoalState, withLitgoalLock, writeLitgoalState } from "./state.mjs";
|
|
@@ -40,6 +41,7 @@ export const readAutoloopState = (cwd = process.cwd()) => {
|
|
|
40
41
|
export const writeAutoloopState = (cwd, state) => {
|
|
41
42
|
const dir = litgoalStateDir(cwd);
|
|
42
43
|
mkdirSync(dir, { recursive: true });
|
|
44
|
+
ensureStateDirIgnored(dir);
|
|
43
45
|
const target = litgoalAutoloopPath(cwd);
|
|
44
46
|
const tmp = `${target}.tmp`;
|
|
45
47
|
writeFileSync(tmp, JSON.stringify(state));
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
|
+
import { ensureStateDirIgnored } from "../project-state-root.mjs";
|
|
3
4
|
import { appendLitgoalLedger } from "./ledger.mjs";
|
|
4
5
|
import {
|
|
5
6
|
litgoalBriefPath,
|
|
@@ -193,6 +194,7 @@ const createGoals = (cwd, args) => {
|
|
|
193
194
|
};
|
|
194
195
|
|
|
195
196
|
mkdirSync(dirname(litgoalBriefPath(cwd)), { recursive: true });
|
|
197
|
+
ensureStateDirIgnored(litgoalBriefPath(cwd));
|
|
196
198
|
writeFileSync(litgoalBriefPath(cwd), `${brief}\n`);
|
|
197
199
|
writeLitgoalState(litgoalGoalsPath(cwd), state);
|
|
198
200
|
// Fresh autoloop run -> reset the durable iteration counter so prior caps don't carry over.
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { appendFileSync, closeSync, fsyncSync, mkdirSync, openSync } from "node:fs";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
|
|
4
|
+
import { ensureStateDirIgnored } from "../project-state-root.mjs";
|
|
5
|
+
|
|
4
6
|
export const appendLitgoalLedger = (path, entry) => {
|
|
5
7
|
mkdirSync(dirname(path), { recursive: true });
|
|
8
|
+
ensureStateDirIgnored(path);
|
|
6
9
|
const record = {
|
|
7
10
|
timestamp: new Date().toISOString(),
|
|
8
11
|
...entry,
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
|
|
4
|
+
import { ensureStateDirIgnored } from "../project-state-root.mjs";
|
|
5
|
+
|
|
4
6
|
export class LitgoalStateError extends Error {
|
|
5
7
|
constructor(message, status = 65) {
|
|
6
8
|
super(message);
|
|
@@ -21,6 +23,7 @@ export const readLitgoalState = (path, fallback = {}) => {
|
|
|
21
23
|
export const writeLitgoalState = (path, state) => {
|
|
22
24
|
const dir = dirname(path);
|
|
23
25
|
mkdirSync(dir, { recursive: true });
|
|
26
|
+
ensureStateDirIgnored(dir);
|
|
24
27
|
const tmpPath = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
25
28
|
writeFileSync(tmpPath, `${JSON.stringify(state, null, 2)}\n`);
|
|
26
29
|
// Flush tmp file data to disk before rename so the rename is durable.
|
|
@@ -46,6 +49,7 @@ const sleepSync = (milliseconds) => {
|
|
|
46
49
|
|
|
47
50
|
export const withLitgoalLock = (lockDir, fn) => {
|
|
48
51
|
mkdirSync(dirname(lockDir), { recursive: true });
|
|
52
|
+
ensureStateDirIgnored(lockDir);
|
|
49
53
|
const startedAt = Date.now();
|
|
50
54
|
while (true) {
|
|
51
55
|
try {
|
|
@@ -0,0 +1,378 @@
|
|
|
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.
|
|
4
|
+
//
|
|
5
|
+
// In a real Claude Code 2.1.292 terminal, a row whose only change is a non-ASCII glyph (a block,
|
|
6
|
+
// symbol, Hangul or Nerd Font cell) keeps showing the old glyph; a change to ASCII text, or to the
|
|
7
|
+
// colour of an ASCII cell, repaints. So every change is carried by ASCII: a bar cell is a space on
|
|
8
|
+
// 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.
|
|
11
|
+
|
|
12
|
+
import { fitSegments, textWidth, truncateToWidth } from "./layout.mjs";
|
|
13
|
+
import { barCells, gradientAt, lerpColor } from "./motion.mjs";
|
|
14
|
+
import { glyphSet } from "./settings.mjs";
|
|
15
|
+
import { formatDuration } from "./view.mjs";
|
|
16
|
+
|
|
17
|
+
export const PALETTE = Object.freeze({
|
|
18
|
+
pink: "#ff2d95",
|
|
19
|
+
pinkText: "#ff6fae",
|
|
20
|
+
orange: "#ff6337",
|
|
21
|
+
cyan: "#00e5ff",
|
|
22
|
+
accent: "#5fd7ff",
|
|
23
|
+
green: "#6bdb8f",
|
|
24
|
+
amber: "#ffaf3c",
|
|
25
|
+
red: "#ff5f5f",
|
|
26
|
+
violet: "#b48cff",
|
|
27
|
+
white: "#e6edf3",
|
|
28
|
+
dim: "#6e7681",
|
|
29
|
+
faint: "#30363d",
|
|
30
|
+
cardBorder: "#3a2a4a",
|
|
31
|
+
});
|
|
32
|
+
|
|
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 });
|
|
42
|
+
const TONE = Object.freeze({
|
|
43
|
+
pink: { bar: PALETTE.pink, title: PALETTE.pinkText, icon: "🔥" },
|
|
44
|
+
amber: { bar: PALETTE.amber, title: PALETTE.amber, icon: "⚠" },
|
|
45
|
+
red: { bar: PALETTE.red, title: PALETTE.red, icon: "✗" },
|
|
46
|
+
});
|
|
47
|
+
const LABELS = Object.freeze({ plan: "PLAN", work: "WORK", review: "REVIEW" });
|
|
48
|
+
|
|
49
|
+
const span = (text, style = {}) => ({ text, ...style });
|
|
50
|
+
export const rowWidth = (row) => row.reduce((sum, part) => sum + textWidth(part.text), 0);
|
|
51
|
+
export const rowText = (row) => row.map(({ text }) => text).join("");
|
|
52
|
+
|
|
53
|
+
// Cuts a row to `cells`, splitting the span that crosses the edge.
|
|
54
|
+
export const clipRow = (row, cells) => {
|
|
55
|
+
const out = [];
|
|
56
|
+
let room = cells;
|
|
57
|
+
for (const part of row) {
|
|
58
|
+
if (room <= 0) break;
|
|
59
|
+
const width = textWidth(part.text);
|
|
60
|
+
if (width <= room) {
|
|
61
|
+
out.push(part);
|
|
62
|
+
room -= width;
|
|
63
|
+
} else {
|
|
64
|
+
out.push({ ...part, text: truncateToWidth(part.text, room, "") });
|
|
65
|
+
room = 0;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// 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.
|
|
73
|
+
export const revealText = (label, reveal) => {
|
|
74
|
+
if (!(reveal > 0)) return null;
|
|
75
|
+
const chars = [...label];
|
|
76
|
+
const count = Math.max(1, Math.round(reveal * chars.length));
|
|
77
|
+
return chars.slice(0, Math.min(count, chars.length)).join("") + " ".repeat(Math.max(0, count - chars.length));
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const gradientSpans = (text, stops, { shimmer = null, bold = true } = {}) => {
|
|
81
|
+
const chars = [...text];
|
|
82
|
+
const visible = chars.filter((ch) => ch.trim()).length;
|
|
83
|
+
let k = 0;
|
|
84
|
+
return chars.map((ch) => {
|
|
85
|
+
if (!ch.trim()) return span(ch);
|
|
86
|
+
const position = visible <= 1 ? 0 : k / (visible - 1);
|
|
87
|
+
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 });
|
|
91
|
+
});
|
|
92
|
+
};
|
|
93
|
+
|
|
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
|
+
];
|
|
99
|
+
|
|
100
|
+
const mixColors = (a, b, t) => ({ bg: lerpColor(a.bg, b.bg, t), fg: lerpColor(a.fg, b.fg, t) });
|
|
101
|
+
|
|
102
|
+
// Colours of one phase in this frame: the crossfade from its previous state, then the amber mix for
|
|
103
|
+
// the work phase while the context warning is up.
|
|
104
|
+
const phaseColors = (id, sample, palette) => {
|
|
105
|
+
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;
|
|
109
|
+
};
|
|
110
|
+
|
|
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
|
+
};
|
|
116
|
+
|
|
117
|
+
const countText = (model) => (model.progress ? ` ${model.progress.done}/${model.progress.total}` : "");
|
|
118
|
+
|
|
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 })];
|
|
127
|
+
};
|
|
128
|
+
|
|
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
|
+
};
|
|
139
|
+
|
|
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);
|
|
143
|
+
|
|
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 })];
|
|
148
|
+
};
|
|
149
|
+
|
|
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 })];
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const segment = (id, priority, spans, extra = {}) => (spans && spans.length ? { id, priority, spans, width: rowWidth(spans), ...extra } : null);
|
|
157
|
+
|
|
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));
|
|
165
|
+
});
|
|
166
|
+
return row;
|
|
167
|
+
};
|
|
168
|
+
|
|
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)) });
|
|
173
|
+
};
|
|
174
|
+
|
|
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)));
|
|
197
|
+
}
|
|
198
|
+
const kept = fitSegments(segments.filter(Boolean), columns, 2);
|
|
199
|
+
return assemble(kept, 2, { next: (width) => [span(truncateToWidth(`▸ ${model.next}`, width), { fg: PALETTE.white })] });
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const noticeRow = (sample, columns) => {
|
|
203
|
+
if (!sample.notice) return null;
|
|
204
|
+
const { data, offset } = sample.notice;
|
|
205
|
+
const tone = TONE[data.tone] ?? TONE.pink;
|
|
206
|
+
const row = [
|
|
207
|
+
span(" ".repeat(offset)),
|
|
208
|
+
span("▎", { fg: tone.bar }),
|
|
209
|
+
span(` ${tone.icon} ${data.title}`, { fg: tone.title, bold: true }),
|
|
210
|
+
...(data.detail ? [span(` · ${data.detail}`, { fg: PALETTE.dim })] : []),
|
|
211
|
+
];
|
|
212
|
+
return clipRow(row, columns);
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const SPARKS = [".", "+", "*"];
|
|
216
|
+
|
|
217
|
+
const completionRow = (sample, columns) => {
|
|
218
|
+
const completion = sample.completion;
|
|
219
|
+
if (!completion) return null;
|
|
220
|
+
const glow = completion.embers === null ? 0 : 0.6 * Math.sin(Math.PI * completion.embers);
|
|
221
|
+
const words = [
|
|
222
|
+
span("COMPLETE", { fg: lerpColor(lerpColor(PALETTE.dim, PALETTE.white, completion.title), PALETTE.pinkText, glow), bold: true }),
|
|
223
|
+
span(` · ${completion.data.detail}`, { fg: lerpColor(PALETTE.dim, PALETTE.green, completion.detail) }),
|
|
224
|
+
];
|
|
225
|
+
const row = [];
|
|
226
|
+
if (completion.embers !== null) {
|
|
227
|
+
const count = Math.max(0, Math.min(18, Math.floor((columns - rowWidth(words) - 2) / 2)));
|
|
228
|
+
for (let i = 0; i < count; i += 1) {
|
|
229
|
+
const life = Math.min(1, Math.max(0, completion.embers * 1.6 - i * 0.04));
|
|
230
|
+
const alpha = life <= 0 || life >= 1 ? 0 : Math.sin(life * Math.PI);
|
|
231
|
+
const glyph = alpha < 0.05 ? " " : SPARKS[Math.min(2, Math.floor(alpha * 3))];
|
|
232
|
+
row.push(span(glyph, { fg: lerpColor(PALETTE.faint, i % 3 ? PALETTE.pink : PALETTE.amber, alpha) }), span(" "));
|
|
233
|
+
}
|
|
234
|
+
row.push(span(" "));
|
|
235
|
+
} else {
|
|
236
|
+
row.push(span("✦ ", { fg: PALETTE.pink }));
|
|
237
|
+
}
|
|
238
|
+
return clipRow([...row, ...words], columns);
|
|
239
|
+
};
|
|
240
|
+
|
|
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 })];
|
|
249
|
+
};
|
|
250
|
+
|
|
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);
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const GATE_STYLE = Object.freeze({
|
|
267
|
+
ok: (glyphs) => ({ glyph: glyphs.done, fg: PALETTE.green }),
|
|
268
|
+
pending: () => ({ glyph: "…", fg: PALETTE.amber }),
|
|
269
|
+
fail: (glyphs) => ({ glyph: glyphs.fail, fg: PALETTE.red }),
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
const gateEntries = (gates, glyphs) => gates.map((gate) => {
|
|
273
|
+
const style = GATE_STYLE[gate.state](glyphs);
|
|
274
|
+
return [span(style.glyph, { fg: style.fg }), span(` ${gate.label}`, { fg: PALETTE.dim })];
|
|
275
|
+
});
|
|
276
|
+
|
|
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
|
+
/** Rows for the evidence pane's tab (`goal`, `lanes` or `gates`), fitted to `columns`. */
|
|
336
|
+
export const composeEvidence = (evidence, tab, { columns, glyphs: glyphMode = "basic" }) => {
|
|
337
|
+
const glyphs = glyphSet(glyphMode);
|
|
338
|
+
const rows = [];
|
|
339
|
+
const line = (...parts) => rows.push(clipRow(parts, columns));
|
|
340
|
+
if (tab === "lanes") {
|
|
341
|
+
if (evidence.lanes.length === 0) line(span("No subagents in this session.", { fg: PALETTE.dim }));
|
|
342
|
+
for (const lane of evidence.lanes) {
|
|
343
|
+
const color = lane.status === "failed" ? PALETTE.red : lane.status === "running" ? PALETTE.green : PALETTE.dim;
|
|
344
|
+
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 }));
|
|
345
|
+
}
|
|
346
|
+
return rows;
|
|
347
|
+
}
|
|
348
|
+
if (tab === "gates") {
|
|
349
|
+
if (evidence.work) line(span("plan ", { fg: PALETTE.dim }), span(`${evidence.work.plan ?? "plan"} · ${evidence.work.done}/${evidence.work.total}`, { fg: PALETTE.white }));
|
|
350
|
+
if (evidence.gates.length === 0) line(span("No gate is armed in this session.", { fg: PALETTE.dim }));
|
|
351
|
+
for (const entry of gateEntries(evidence.gates, glyphs)) line(...entry);
|
|
352
|
+
if (evidence.icons) line(span("icons ", { fg: PALETTE.dim }), span(evidence.icons, { fg: PALETTE.dim }));
|
|
353
|
+
return rows;
|
|
354
|
+
}
|
|
355
|
+
const goal = evidence.goal;
|
|
356
|
+
if (!goal) {
|
|
357
|
+
line(span("No litgoal goal in this project for this session.", { fg: PALETTE.dim }));
|
|
358
|
+
} else {
|
|
359
|
+
line(span("goal ", { fg: PALETTE.dim }), span(goal.objective, { fg: PALETTE.white, bold: true }), span(` ${goal.status}`, { fg: PALETTE.dim }));
|
|
360
|
+
line(span(""));
|
|
361
|
+
for (const criterion of goal.criteria) {
|
|
362
|
+
const mark = criterion.status === "pass" ? [glyphs.done, PALETTE.green] : criterion.status === "fail" || criterion.status === "blocked" ? [glyphs.fail, PALETTE.red] : ["·", PALETTE.faint];
|
|
363
|
+
const tail = ` ${criterion.ago}`;
|
|
364
|
+
const description = truncateToWidth(criterion.description, Math.max(8, columns - textWidth(criterion.id) - textWidth(tail) - 5));
|
|
365
|
+
line(span(`${mark[0]} `, { fg: mark[1] }), span(criterion.id, { fg: PALETTE.white }), span(` ${description}`, { fg: PALETTE.dim }), span(tail, { fg: PALETTE.dim }));
|
|
366
|
+
}
|
|
367
|
+
for (const blocker of goal.blockers) line(span(`${glyphs.flag} `, { fg: PALETTE.amber }), span(blocker, { fg: PALETTE.amber }));
|
|
368
|
+
if (goal.reviewBlocked) line(span(`${glyphs.flag} `, { fg: PALETTE.amber }), span("review: blocked", { fg: PALETTE.amber }));
|
|
369
|
+
}
|
|
370
|
+
if (evidence.timeline.length) {
|
|
371
|
+
line(span(""));
|
|
372
|
+
for (const entry of evidence.timeline) {
|
|
373
|
+
const color = entry.event.startsWith("evidence") ? PALETTE.accent : entry.event.startsWith("checkpoint") ? PALETTE.violet : PALETTE.green;
|
|
374
|
+
line(span(entry.ago.padEnd(10), { fg: PALETTE.dim }), span(entry.event.padEnd(20), { fg: color }), span(entry.detail, { fg: PALETTE.dim }));
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return rows;
|
|
378
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Terminal cell arithmetic for the LIT band. The band is laid out by LitClaude itself rather than
|
|
2
|
+
// left to wrap, so every width here is in terminal cells: Hangul, CJK and emoji take two, combining
|
|
3
|
+
// marks and variation selectors none. Pure functions only; the mod runs them inside Claude Code and
|
|
4
|
+
// the tests run them under Node.
|
|
5
|
+
|
|
6
|
+
const ZERO_WIDTH = /[\p{Mn}\p{Me}-︀-️]/u;
|
|
7
|
+
const EMOJI = /\p{Emoji_Presentation}/u;
|
|
8
|
+
|
|
9
|
+
const isWide = (code) =>
|
|
10
|
+
(code >= 0x1100 && code <= 0x115f)
|
|
11
|
+
|| (code >= 0x2e80 && code <= 0x303e)
|
|
12
|
+
|| (code >= 0x3041 && code <= 0x33ff)
|
|
13
|
+
|| (code >= 0x3400 && code <= 0x4dbf)
|
|
14
|
+
|| (code >= 0x4e00 && code <= 0x9fff)
|
|
15
|
+
|| (code >= 0xa000 && code <= 0xa4cf)
|
|
16
|
+
|| (code >= 0xa960 && code <= 0xa97f)
|
|
17
|
+
|| (code >= 0xac00 && code <= 0xd7a3)
|
|
18
|
+
|| (code >= 0xf900 && code <= 0xfaff)
|
|
19
|
+
|| (code >= 0xfe30 && code <= 0xfe4f)
|
|
20
|
+
|| (code >= 0xff00 && code <= 0xff60)
|
|
21
|
+
|| (code >= 0xffe0 && code <= 0xffe6)
|
|
22
|
+
|| (code >= 0x20000 && code <= 0x3fffd);
|
|
23
|
+
|
|
24
|
+
export const charWidth = (ch) => {
|
|
25
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
26
|
+
if (code < 0x20 || (code >= 0x7f && code < 0xa0)) return 0;
|
|
27
|
+
if (ZERO_WIDTH.test(ch)) return 0;
|
|
28
|
+
if (EMOJI.test(ch) || isWide(code)) return 2;
|
|
29
|
+
return 1;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const textWidth = (text) => {
|
|
33
|
+
let width = 0;
|
|
34
|
+
for (const ch of String(text)) width += charWidth(ch);
|
|
35
|
+
return width;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const truncateToWidth = (text, cells, ellipsis = "…") => {
|
|
39
|
+
const value = String(text);
|
|
40
|
+
if (cells <= 0) return "";
|
|
41
|
+
if (textWidth(value) <= cells) return value;
|
|
42
|
+
const room = cells - textWidth(ellipsis);
|
|
43
|
+
if (room <= 0) return ellipsis.slice(0, cells);
|
|
44
|
+
let out = "";
|
|
45
|
+
let width = 0;
|
|
46
|
+
for (const ch of value) {
|
|
47
|
+
const w = charWidth(ch);
|
|
48
|
+
if (width + w > room) break;
|
|
49
|
+
out += ch;
|
|
50
|
+
width += w;
|
|
51
|
+
}
|
|
52
|
+
return `${out.trimEnd()}${ellipsis}`;
|
|
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
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Motion vocabulary for the LIT band: easing curves, the approved timings, animated channels and
|
|
2
|
+
// colour mixing. Entrances pop with ease-out-back, fills and colour changes run ease-in-out, exits
|
|
3
|
+
// accelerate with ease-in. A channel is plain data ({ from, to, at, dur }) sampled against the
|
|
4
|
+
// surface's frame clock, so the band module keeps no timers beyond the frames it is animating.
|
|
5
|
+
|
|
6
|
+
export 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.
|
|
16
|
+
export 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
|
+
});
|
|
31
|
+
|
|
32
|
+
// full: the approved motion. subtle: no overshoot, no shimmer or embers, three quarters the time.
|
|
33
|
+
// off: every duration is zero, so the band draws its final frame and never starts a frame timer.
|
|
34
|
+
export const motionProfile = (mode) => {
|
|
35
|
+
if (mode === "off") {
|
|
36
|
+
return { mode, duration: () => 0, enterEase: EASE.linear, noticeEase: EASE.linear, shimmer: false, embers: false };
|
|
37
|
+
}
|
|
38
|
+
if (mode === "subtle") {
|
|
39
|
+
return { mode, duration: (ms) => Math.round(ms * 0.75), enterEase: EASE.outCubic, noticeEase: EASE.outCubic, shimmer: false, embers: false };
|
|
40
|
+
}
|
|
41
|
+
return { mode: "full", duration: (ms) => ms, enterEase: EASE.outBack, noticeEase: EASE.outBack, shimmer: true, embers: true };
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const clamp01 = (x) => (x <= 0 ? 0 : x >= 1 ? 1 : x);
|
|
45
|
+
|
|
46
|
+
// Progress through a window that opens at `at` and lasts `dur`; a zero-length window is complete.
|
|
47
|
+
export const phase = (now, at, dur) => (dur <= 0 ? (now >= at ? 1 : 0) : clamp01((now - at) / dur));
|
|
48
|
+
|
|
49
|
+
export const still = (value) => ({ from: value, to: value, at: 0, dur: 0 });
|
|
50
|
+
|
|
51
|
+
export const sample = (channel, now, ease = EASE.inOutCubic) =>
|
|
52
|
+
channel.from + (channel.to - channel.from) * ease(phase(now, channel.at, channel.dur));
|
|
53
|
+
|
|
54
|
+
export const retarget = (channel, to, now, dur) => {
|
|
55
|
+
if (channel.to === to) return channel;
|
|
56
|
+
const from = sample(channel, now);
|
|
57
|
+
return from === to ? still(to) : { from, to, at: now, dur };
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const isSettled = (channel, now) => channel.from === channel.to || now >= channel.at + channel.dur;
|
|
61
|
+
|
|
62
|
+
const hexToRgb = (hex) => {
|
|
63
|
+
const value = String(hex).replace(/^#/u, "");
|
|
64
|
+
return [0, 2, 4].map((i) => Number.parseInt(value.slice(i, i + 2), 16) || 0);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const lerpColor = (a, b, t) => {
|
|
68
|
+
const from = hexToRgb(a);
|
|
69
|
+
const to = hexToRgb(b);
|
|
70
|
+
const k = clamp01(t);
|
|
71
|
+
return `#${from.map((v, i) => Math.round(v + (to[i] - v) * k).toString(16).padStart(2, "0")).join("")}`;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const gradientAt = (stops, position) => {
|
|
75
|
+
if (stops.length === 1) return lerpColor(stops[0], stops[0], 0);
|
|
76
|
+
const scaled = clamp01(position) * (stops.length - 1);
|
|
77
|
+
const index = Math.min(stops.length - 2, Math.floor(scaled));
|
|
78
|
+
return lerpColor(stops[index], stops[index + 1], scaled - index);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const BAR_STOPS = Object.freeze(["#ff2d95", "#00e5ff"]);
|
|
82
|
+
export const BAR_TRACK = "#30363d";
|
|
83
|
+
|
|
84
|
+
// A bar of `cells` cells filled to `fraction`. Whole cells take the pink-to-cyan gradient; the
|
|
85
|
+
// cell the fill is inside fades from the track colour to its gradient colour by how far it is
|
|
86
|
+
// filled, so the bar moves continuously in colour rather than in eighth-cell glyphs (a glyph-only
|
|
87
|
+
// change is not repainted inside a Claude Code Client region). While the bar moves, `glow` (0 to 1)
|
|
88
|
+
// brightens that leading cell. Every filled or fading cell is a solid `█`; the rest is the track.
|
|
89
|
+
export const barCells = (fraction, cells, glow = 0) => {
|
|
90
|
+
const filled = clamp01(fraction) * cells;
|
|
91
|
+
const full = Math.floor(filled + 1e-9);
|
|
92
|
+
const part = full < cells ? filled - full : 0;
|
|
93
|
+
const head = part > 1e-6 ? full : full - 1;
|
|
94
|
+
const gradient = (i) => gradientAt(BAR_STOPS, cells <= 1 ? 0 : i / (cells - 1));
|
|
95
|
+
const shine = (i, color) => (i === head && glow > 0 ? lerpColor(color, "#ffffff", glow * 0.7) : color);
|
|
96
|
+
return Array.from({ length: cells }, (_, i) => {
|
|
97
|
+
if (i < full) return { glyph: "█", color: shine(i, gradient(i)) };
|
|
98
|
+
if (i === full && part > 1e-6) return { glyph: "█", color: shine(i, lerpColor(BAR_TRACK, gradient(i), part)) };
|
|
99
|
+
return { glyph: "░", color: BAR_TRACK };
|
|
100
|
+
});
|
|
101
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Draws composed rows (frame.mjs) with a surface's element table: one row Box of Text spans per
|
|
2
|
+
// line, inside a round-bordered card when the frame asks for one. It calls the element constructors
|
|
3
|
+
// directly, so the band's surface module, the mod's static fallback and the tests share it.
|
|
4
|
+
|
|
5
|
+
const textProps = (part) => {
|
|
6
|
+
const props = { children: part.text, wrap: "truncate-end" };
|
|
7
|
+
if (part.fg) props.color = part.fg;
|
|
8
|
+
if (part.bg) props.backgroundColor = part.bg;
|
|
9
|
+
if (part.bold) props.bold = true;
|
|
10
|
+
return props;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const renderRows = ({ Box, Text }, rows) =>
|
|
14
|
+
rows.map((row) => {
|
|
15
|
+
const parts = row.filter(({ text }) => text.length > 0);
|
|
16
|
+
return Box({ flexDirection: "row", children: parts.length ? parts.map((part) => Text(textProps(part))) : [Text({ children: " " })] });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const renderBand = (elements, frame) => {
|
|
20
|
+
const children = renderRows(elements, frame.rows);
|
|
21
|
+
return frame.mode === "card"
|
|
22
|
+
? elements.Box({ flexDirection: "column", borderStyle: "round", borderColor: frame.border, paddingX: 1, children })
|
|
23
|
+
: elements.Box({ flexDirection: "column", children });
|
|
24
|
+
};
|