@litfamily/litclaude 1.0.25 → 1.0.26
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 +10 -0
- package/README.md +8 -8
- package/README_ko-KR.md +8 -8
- package/bin/litclaude-ai.js +10 -3
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/hooks.md +76 -24
- package/package.json +1 -1
- package/plugins/litclaude/.claude-plugin/plugin.json +7 -1
- package/plugins/litclaude/bin/litclaude-hook.js +31 -0
- package/plugins/litclaude/bin/litclaude-mcp.js +1 -1
- package/plugins/litclaude/hooks/hooks.json +1 -1
- package/plugins/litclaude/hooks/lit-mod.mjs +87 -25
- package/plugins/litclaude/lib/mod/frame.mjs +204 -14
- package/plugins/litclaude/lib/mod/motion.mjs +2 -0
- package/plugins/litclaude/lib/mod/settings.mjs +6 -2
- package/plugins/litclaude/lib/mod/state.mjs +84 -0
- package/plugins/litclaude/lib/mod/svg-card.mjs +413 -0
- package/plugins/litclaude/lib/mod/timeline.mjs +20 -5
- package/plugins/litclaude/lib/mod/view.mjs +14 -2
- package/plugins/litclaude/lib/mod/workflow.mjs +239 -0
- package/plugins/litclaude/lib/start-work-lifecycle.mjs +126 -1
- package/plugins/litclaude/lib/workflow-stage.mjs +349 -0
- package/plugins/litclaude/skills/lit-crucible/SKILL.md +4 -0
- package/plugins/litclaude/skills/lit-loop/SKILL.md +8 -0
- package/plugins/litclaude/skills/review-work/SKILL.md +10 -0
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
// LitClaude's Claude Code mod (hooks/hooks.json `modules`). It draws the LIT card above the prompt
|
|
2
|
-
// for the whole session (the `band` setting can limit it to active work or turn it off)
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
2
|
+
// for the whole session (the `band` setting can limit it to active work or turn it off): where the
|
|
3
|
+
// session stands in the LIT workflow, the tool the main loop is running and for how long, a chip
|
|
4
|
+
// for each LitFamily skill that fires. It adds the discipline and progress to the spinner, raises
|
|
5
|
+
// gate notices, serves /lit-evidence and, when the `notify` option asks, raises a notification
|
|
6
|
+
// when LIT work completes or a long LIT turn ends. It is read-only: it reads LitClaude's own state
|
|
7
|
+
// files and the Git HEAD through lib/mod/state.mjs, the settings for the status line's accent, and
|
|
8
|
+
// the host's live figures, and never writes, spawns, fetches or calls a model. Of a tool call it
|
|
9
|
+
// keeps the tool's name and start time, never its arguments.
|
|
7
10
|
// Claude Code releases before mods ignore the `modules` key and keep running the settings hooks.
|
|
8
11
|
//
|
|
9
12
|
// The mods loader only follows `$` into functions declared at the top of this file, so each helper
|
|
@@ -13,6 +16,7 @@ import { composeBand, composeEvidence } from "../lib/mod/frame.mjs";
|
|
|
13
16
|
import { TIMINGS } from "../lib/mod/motion.mjs";
|
|
14
17
|
import { renderBand, renderRows } from "../lib/mod/render.mjs";
|
|
15
18
|
import { accentFromSettings, normalizeModSettings, resolveGlyphs } from "../lib/mod/settings.mjs";
|
|
19
|
+
import { composeSvgCard } from "../lib/mod/svg-card.mjs";
|
|
16
20
|
import { chipStates, litSkillId, noteSkill } from "../lib/mod/skills.mjs";
|
|
17
21
|
import { detectIconFont, readGitBranch, readLedgerTail, readLitSnapshot } from "../lib/mod/state.mjs";
|
|
18
22
|
import { settledSample } from "../lib/mod/timeline.mjs";
|
|
@@ -22,6 +26,7 @@ import {
|
|
|
22
26
|
buildEvidenceModel,
|
|
23
27
|
detectCompletion,
|
|
24
28
|
diffNotices,
|
|
29
|
+
formatDuration,
|
|
25
30
|
mergeSpawned,
|
|
26
31
|
noteAgentActivity,
|
|
27
32
|
noteSpawn,
|
|
@@ -39,7 +44,9 @@ const TABS = Object.freeze([
|
|
|
39
44
|
{ id: "lanes", label: "lanes", hotkey: "2" },
|
|
40
45
|
{ id: "gates", label: "gates", hotkey: "3" },
|
|
41
46
|
]);
|
|
42
|
-
const NO_SNAPSHOT = Object.freeze({ ignition: null, work: null, completedWorkIds: [], goal: null, autoloop: null, litPlan: null, probe: null, motion: null, handoff: null });
|
|
47
|
+
const NO_SNAPSHOT = Object.freeze({ ignition: null, work: null, completedWorkIds: [], goal: null, autoloop: null, litPlan: null, probe: null, motion: null, handoff: null, workflow: null });
|
|
48
|
+
// A LIT turn at least this long notifies when it ends, under `notify`.
|
|
49
|
+
const LONG_TURN_MS = 3 * 60 * 1000;
|
|
43
50
|
|
|
44
51
|
const createLive = (options) => ({
|
|
45
52
|
band: normalizeModSettings(options).band,
|
|
@@ -69,11 +76,16 @@ const createLive = (options) => ({
|
|
|
69
76
|
settledAt: 0,
|
|
70
77
|
ignitionAt: undefined,
|
|
71
78
|
lastDiscipline: null,
|
|
79
|
+
step: null,
|
|
80
|
+
mainToolCalls: 0,
|
|
81
|
+
turnStartedAt: null,
|
|
82
|
+
turnNotified: false,
|
|
72
83
|
});
|
|
73
84
|
|
|
74
85
|
const stateIo = ($) => ({
|
|
75
86
|
stat: (path) => $.fs.stat(path, { resolve: true }),
|
|
76
87
|
read: (path) => $.fs.read(path),
|
|
88
|
+
list: (path) => $.fs.list(path),
|
|
77
89
|
});
|
|
78
90
|
|
|
79
91
|
const toastText = (raised) => `🔥 ${raised.title}${raised.detail ? ` · ${raised.detail}` : ""}`;
|
|
@@ -94,6 +106,7 @@ async function rebuild($, live) {
|
|
|
94
106
|
skills: chipStates(live.fired, live.settledAt),
|
|
95
107
|
lastDiscipline: live.lastDiscipline,
|
|
96
108
|
git: live.git,
|
|
109
|
+
step: live.step,
|
|
97
110
|
});
|
|
98
111
|
live.model = next;
|
|
99
112
|
if (previous?.visible && !next.visible && !live.exiting) {
|
|
@@ -118,10 +131,18 @@ function showNotice($, live, raised) {
|
|
|
118
131
|
});
|
|
119
132
|
}
|
|
120
133
|
|
|
121
|
-
|
|
134
|
+
// The opt-in finish notification. No sound file ships yet, so `finish+sound` notifies like `finish`.
|
|
135
|
+
function notifyFinish($, options, text) {
|
|
136
|
+
if (normalizeModSettings(options).notify === "off") return;
|
|
137
|
+
$.ui.notify(text, { title: "LitClaude" }).catch(() => undefined);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function celebrate($, live, finish, options) {
|
|
122
141
|
if (live.celebrated.has(finish.id)) return;
|
|
123
142
|
live.celebrated.add(finish.id);
|
|
124
143
|
live.completion = finish;
|
|
144
|
+
live.turnNotified = true;
|
|
145
|
+
notifyFinish($, options, `LIT complete · ${finish.detail}`);
|
|
125
146
|
$.clock.after(TIMINGS.completion + 200, () => {
|
|
126
147
|
if (live.completion?.id !== finish.id) return;
|
|
127
148
|
live.completion = null;
|
|
@@ -148,7 +169,7 @@ function noteIgnition(live, ignition, now) {
|
|
|
148
169
|
if (id) live.fired = noteSkill(live.fired, id, ignition.at ?? now);
|
|
149
170
|
}
|
|
150
171
|
|
|
151
|
-
async function refresh($, live) {
|
|
172
|
+
async function refresh($, live, options) {
|
|
152
173
|
if (live.refreshing) return;
|
|
153
174
|
live.refreshing = true;
|
|
154
175
|
try {
|
|
@@ -166,7 +187,7 @@ async function refresh($, live) {
|
|
|
166
187
|
live.snapshot = fresh;
|
|
167
188
|
noteIgnition(live, fresh.ignition, now);
|
|
168
189
|
const finish = detectCompletion(before, fresh);
|
|
169
|
-
if (finish) celebrate($, live, finish);
|
|
190
|
+
if (finish) celebrate($, live, finish, options);
|
|
170
191
|
const next = await rebuild($, live);
|
|
171
192
|
const raised = diffNotices(before, fresh, previousModel, next);
|
|
172
193
|
if (raised.length > 0) {
|
|
@@ -182,12 +203,12 @@ async function refresh($, live) {
|
|
|
182
203
|
}
|
|
183
204
|
}
|
|
184
205
|
|
|
185
|
-
function refreshSoon($, live) {
|
|
206
|
+
function refreshSoon($, live, options) {
|
|
186
207
|
if (live.quickPending) return;
|
|
187
208
|
live.quickPending = true;
|
|
188
209
|
$.clock.after(QUICK_REFRESH_MS, () => {
|
|
189
210
|
live.quickPending = false;
|
|
190
|
-
void refresh($, live);
|
|
211
|
+
void refresh($, live, options);
|
|
191
212
|
});
|
|
192
213
|
}
|
|
193
214
|
|
|
@@ -209,16 +230,19 @@ export const register = (on, options) => {
|
|
|
209
230
|
live.settledAt = await $.clock.now();
|
|
210
231
|
live.accent = accentFromSettings(await $.settings.read().catch(() => null), await $.env.get("LITCLAUDE_HUD_ACCENT"));
|
|
211
232
|
await $.command.register({ name: PANE, description: "Show LitClaude goal criteria, evidence, lanes and gates in a pane" });
|
|
212
|
-
await refresh($, live);
|
|
233
|
+
await refresh($, live, options);
|
|
213
234
|
$.clock.every(POLL_MS, () => {
|
|
214
|
-
void refresh($, live);
|
|
235
|
+
void refresh($, live, options);
|
|
215
236
|
});
|
|
216
237
|
return next(e);
|
|
217
238
|
});
|
|
218
239
|
|
|
219
240
|
on("turn.start", async ($, e, next) => {
|
|
220
241
|
live.isWorking = true;
|
|
221
|
-
|
|
242
|
+
live.mainToolCalls = 0;
|
|
243
|
+
live.turnStartedAt = await $.clock.now();
|
|
244
|
+
live.turnNotified = false;
|
|
245
|
+
refreshSoon($, live, options);
|
|
222
246
|
return next(e);
|
|
223
247
|
});
|
|
224
248
|
|
|
@@ -226,50 +250,81 @@ export const register = (on, options) => {
|
|
|
226
250
|
// A subagent's or teammate's turn ends its run, not the main turn the card follows.
|
|
227
251
|
if (typeof e.agentId === "string") {
|
|
228
252
|
live.spawned = noteSpawnEnded(live.spawned, e.agentId);
|
|
229
|
-
refreshSoon($, live);
|
|
253
|
+
refreshSoon($, live, options);
|
|
230
254
|
return next(e);
|
|
231
255
|
}
|
|
232
256
|
live.isWorking = false;
|
|
257
|
+
live.step = null;
|
|
258
|
+
live.mainToolCalls = 0;
|
|
259
|
+
const now = await $.clock.now();
|
|
260
|
+
const took = Number.isFinite(e.durationMs) ? e.durationMs : now - (live.turnStartedAt ?? now);
|
|
261
|
+
const lit = live.model && (live.model.discipline || live.model.workflow || live.model.skills.some(({ state }) => state === "current"));
|
|
262
|
+
if (lit && !live.turnNotified && took >= LONG_TURN_MS) {
|
|
263
|
+
live.turnNotified = true;
|
|
264
|
+
notifyFinish($, options, `LIT turn finished · ${live.model.title ?? "LitClaude"} · ${formatDuration(took)}`);
|
|
265
|
+
}
|
|
233
266
|
// The request is over: its chips dim to the recent list until a skill fires again.
|
|
234
|
-
live.settledAt =
|
|
235
|
-
refreshSoon($, live);
|
|
267
|
+
live.settledAt = now;
|
|
268
|
+
refreshSoon($, live, options);
|
|
236
269
|
return next(e);
|
|
237
270
|
});
|
|
238
271
|
|
|
239
272
|
on("session.measure", async ($, e, next) => {
|
|
240
273
|
live.usage = { context: e.context, rateLimits: e.rateLimits };
|
|
241
|
-
if (e.changed.includes("context")) refreshSoon($, live);
|
|
274
|
+
if (e.changed.includes("context")) refreshSoon($, live, options);
|
|
242
275
|
return next(e);
|
|
243
276
|
});
|
|
244
277
|
|
|
245
278
|
on("tool.call", async ($, e, next) => {
|
|
246
|
-
|
|
279
|
+
// The card follows the main loop: a skill a subagent loads (a reviewer reading `rules`) neither
|
|
280
|
+
// lights a chip nor retitles the card.
|
|
281
|
+
if (e.tool === "Skill" && typeof e.agentId !== "string") await noteFiring($, live, e.skill);
|
|
247
282
|
if (typeof e.agentId === "string") live.spawned = noteSpawnActivity(live.spawned, e.agentId, await $.clock.now());
|
|
248
|
-
|
|
283
|
+
// The live step: the main loop's tool by name and since when, nothing else of the call.
|
|
284
|
+
const step = typeof e.agentId === "string" ? null : { name: e.tool, at: await $.clock.now() };
|
|
285
|
+
if (step) live.mainToolCalls += 1;
|
|
286
|
+
if (step) {
|
|
287
|
+
live.step = step;
|
|
288
|
+
await rebuild($, live);
|
|
289
|
+
}
|
|
290
|
+
let result;
|
|
291
|
+
try {
|
|
292
|
+
result = await next(e);
|
|
293
|
+
} finally {
|
|
294
|
+
if (step && live.step === step) live.step = live.isWorking ? { name: "thinking", at: await $.clock.now() } : null;
|
|
295
|
+
}
|
|
249
296
|
if (typeof e.agentId === "string") {
|
|
250
297
|
const now = await $.clock.now();
|
|
251
298
|
live.activity = noteAgentActivity(live.activity, e.agentId, now);
|
|
252
299
|
live.spawned = noteSpawnActivity(live.spawned, e.agentId, now);
|
|
253
300
|
}
|
|
254
|
-
refreshSoon($, live);
|
|
301
|
+
refreshSoon($, live, options);
|
|
255
302
|
return result;
|
|
256
303
|
}).catch(($, e, next) => next(e));
|
|
257
304
|
|
|
305
|
+
// A model request on the main loop: the step reads "thinking" until a tool call names itself.
|
|
306
|
+
on("turn.step", async function* ($, e, next) {
|
|
307
|
+
if (typeof e.agentId !== "string" && live.isWorking && live.step?.name !== "thinking") live.step = { name: "thinking", at: await $.clock.now() };
|
|
308
|
+
return yield* next(e);
|
|
309
|
+
});
|
|
310
|
+
|
|
258
311
|
// Every subagent, teammate and workflow agent starts here. A workflow's never shows in
|
|
259
312
|
// `$.agent.list()`, so the card keeps its own record until the agent's turn.complete.
|
|
260
313
|
on("agent.spawn", async ($, e, next) => {
|
|
261
314
|
const result = await next(e);
|
|
262
315
|
if (typeof result?.agentId === "string") {
|
|
263
316
|
live.spawned = noteSpawn(live.spawned, { id: result.agentId, description: e.description, type: e.subagentType, at: await $.clock.now() });
|
|
264
|
-
refreshSoon($, live);
|
|
317
|
+
refreshSoon($, live, options);
|
|
265
318
|
}
|
|
266
319
|
return result;
|
|
267
320
|
}).catch(($, e, next) => next(e));
|
|
268
321
|
|
|
269
322
|
// A typed `/litclaude:<name>` and the Skill tool both expand the skill's prompt here; the text
|
|
270
|
-
// passes through untouched.
|
|
323
|
+
// passes through untouched. So does a subagent's preloaded skill (an agent's `skills:` list),
|
|
324
|
+
// and the event does not say whose it is: a firing counts only before the main loop's first tool
|
|
325
|
+
// call of the turn (a typed command) or inside its Skill call.
|
|
271
326
|
on("skill.prompt", async ($, e, next) => {
|
|
272
|
-
await noteFiring($, live, e.skill);
|
|
327
|
+
if (live.mainToolCalls === 0 || live.step?.name === "Skill") await noteFiring($, live, e.skill);
|
|
273
328
|
return next(e);
|
|
274
329
|
}).catch(($, e, next) => next(e));
|
|
275
330
|
|
|
@@ -286,7 +341,14 @@ export const register = (on, options) => {
|
|
|
286
341
|
const elements = $.ui.resolve(e);
|
|
287
342
|
const columns = Math.max(20, e.props.bodyColumns ?? e.viewport?.columns ?? 80);
|
|
288
343
|
let drawn = null;
|
|
289
|
-
if (
|
|
344
|
+
if (e.surface !== "terminal" && elements.Svg) {
|
|
345
|
+
// A desktop or editor draws Text in a proportional font, so the cell-built card comes apart
|
|
346
|
+
// there; those surfaces get the card as one drawing instead.
|
|
347
|
+
if (model.visible) {
|
|
348
|
+
const { source, alt, animated } = composeSvgCard(model, { motion: settings.motion, accent: live.accent });
|
|
349
|
+
drawn = elements.Svg({ source, alt, ...(animated ? { isInteractive: true } : {}) });
|
|
350
|
+
}
|
|
351
|
+
} else if (elements.Client && !live.clientFailed) {
|
|
290
352
|
drawn = elements.Client({ key: "lit-band", module: "./lit-band-client.mjs", props: { model, columns, glyphs, motion: settings.motion, accent: live.accent } });
|
|
291
353
|
} else if (model.visible) {
|
|
292
354
|
drawn = renderBand(elements, composeBand(model, settledSample(model), { columns, glyphs, accent: live.accent }));
|
|
@@ -1,16 +1,25 @@
|
|
|
1
|
-
// Turns a band model and one sampled frame into the LIT card:
|
|
1
|
+
// Turns a band model and one sampled frame into the LIT card: five rows of coloured spans. A span is
|
|
2
2
|
// { text, fg?, bg?, bold? }; the surface module draws each row as a line of Text elements. The card
|
|
3
3
|
// draws its own rounded border so the top edge can carry the title and the bottom edge the live
|
|
4
4
|
// figures, and every row is fitted to exactly the card's width, counting Hangul and emoji as two
|
|
5
5
|
// cells, so nothing wraps and the card keeps its height whether LitClaude is working or idle.
|
|
6
6
|
//
|
|
7
|
-
// ╭─ [🔥 LIT] ·
|
|
8
|
-
// │
|
|
9
|
-
// │
|
|
10
|
-
//
|
|
7
|
+
// ╭─ [🔥 LIT] · start-work ───────────────────────────────── WORK ● ─╮
|
|
8
|
+
// │ interview ✓ ━━ plan ✓ ━━ [work 3/7] ━━ review · ── done · 1 lane │
|
|
9
|
+
// │ └ task 3/7 ███░░░░░ · next ▸ 4. README demo - Bash · 0:12 │
|
|
10
|
+
// │ skills [⚡ start-work] [↯ lit-plan] │
|
|
11
|
+
// ╰─ ctx 42% ▰▰▱▱▱ · 5h 31% · 1w 12% · git main ─────────────────────╯
|
|
11
12
|
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
13
|
+
// The second row is the workflow map (workflow.mjs): done stages carry a check in a colour graded
|
|
14
|
+
// along the rail from flame orange through pink to the accent, the current stage is a pill, the
|
|
15
|
+
// stages ahead are dim. The rail leaving the current stage fills with its count and, while
|
|
16
|
+
// LitClaude works, carries a bright head; the rails ahead are dim `─`. A narrow card drops the
|
|
17
|
+
// rails, then shortens the names, then shows only the current stage and its neighbours. With no
|
|
18
|
+
// family to place the session in, the row is the PLAN / WORK / REVIEW track instead. The third row
|
|
19
|
+
// is the stage's detail and, on the right, the tool the main loop is running and for how long.
|
|
20
|
+
//
|
|
21
|
+
// Each bracket pair stands for the two end cells of a filled pill: the brand on pink, the current
|
|
22
|
+
// stage and this request's skills on the accent with dark text, skills from earlier requests on
|
|
14
23
|
// dark grey. Those end cells are rounded Nerd Font caps in the pill's colour, or one cell of
|
|
15
24
|
// padding in that colour with basic glyphs, so a pill is as wide in either glyph set.
|
|
16
25
|
//
|
|
@@ -19,10 +28,12 @@
|
|
|
19
28
|
// colour of an ASCII cell, repaints. So every change is carried by ASCII: a bar cell is a space on
|
|
20
29
|
// its colour and the leading cell fades in by colour, embers are ASCII sparks, labels pop in letter
|
|
21
30
|
// 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.
|
|
31
|
+
// glyph swaps only together with the colour of its words. The map's rail cells keep their glyph
|
|
32
|
+
// and move by colour alone (a colour change on `─` or `━` repaints), and the live step's spinner
|
|
33
|
+
// and clock are ASCII.
|
|
23
34
|
|
|
24
35
|
import { textWidth, truncateToWidth } from "./layout.mjs";
|
|
25
|
-
import { barCells, lerpColor } from "./motion.mjs";
|
|
36
|
+
import { barCells, gradientAt, lerpColor } from "./motion.mjs";
|
|
26
37
|
import { glyphSet } from "./settings.mjs";
|
|
27
38
|
import { formatDuration } from "./view.mjs";
|
|
28
39
|
|
|
@@ -195,6 +206,12 @@ const brandSpans = (sample, glyphs) => {
|
|
|
195
206
|
const indicatorSpans = (model, sample, glyphs, accent) => {
|
|
196
207
|
if (sample.completion) return [span(`DONE ${glyphs.done}`, { fg: PALETTE.green, bold: true })];
|
|
197
208
|
if (model.idle) return [span("idle", { fg: PALETTE.dim })];
|
|
209
|
+
const flow = model.workflow;
|
|
210
|
+
if (flow && flow.current >= 0) {
|
|
211
|
+
const warn = model.ctxWarning && sample.warn > 0.02;
|
|
212
|
+
return [span(`${flow.stages[flow.current].id.toUpperCase()} ${warn ? glyphs.warn : glyphs.now}`, { fg: warn ? lerpColor(accent, PALETTE.amber, sample.warn) : accent, bold: true })];
|
|
213
|
+
}
|
|
214
|
+
if (flow && model.lanes.count === 0) return [span(`DONE ${glyphs.done}`, { fg: PALETTE.green, bold: true })];
|
|
198
215
|
const current = PHASES.find((id) => model.phases[id] === "now");
|
|
199
216
|
if (current) return [span(`${LABELS[current]} ${warned(current, sample) ? glyphs.warn : glyphs.now}`, { fg: phaseColor(current, sample, inkOf(accent)), bold: true })];
|
|
200
217
|
if (PHASES.every((id) => model.phases[id] === "done") && model.lanes.count === 0) return [span(`DONE ${glyphs.done}`, { fg: PALETTE.green, bold: true })];
|
|
@@ -292,6 +309,172 @@ const phaseRow = (model, sample, { inner, glyphs, accent }) => {
|
|
|
292
309
|
return fitRow([...track, span(" ".repeat(Math.max(2, inner - rowWidth(track) - rowWidth(tail)))), ...tail], inner);
|
|
293
310
|
};
|
|
294
311
|
|
|
312
|
+
// The colour of a point `position` (0 to 1) of the way along the map: flame orange, pink, accent.
|
|
313
|
+
const mapColor = (accent, position) => gradientAt([PALETTE.orange, PALETTE.pink, accent], position);
|
|
314
|
+
|
|
315
|
+
// A stage name cut to its first three letters, its count kept: `work 3/7` becomes `wor 3/7`.
|
|
316
|
+
const shortLabel = (label) => label.replace(/^[A-Za-z]+/u, (word) => word.slice(0, 3));
|
|
317
|
+
|
|
318
|
+
// The rail from stage `index - 1` to stage `index`, `cells` wide. Behind the current stage it is
|
|
319
|
+
// `━` graded along the map; leaving it, `━` filled by the stage's count with a bright head while
|
|
320
|
+
// LitClaude works; ahead, dim `─`. With no room for rails, one space.
|
|
321
|
+
const railSpans = (model, sample, index, cells, accent) => {
|
|
322
|
+
if (cells === 0) return [span(" ")];
|
|
323
|
+
const { stages } = model.workflow;
|
|
324
|
+
const last = Math.max(1, stages.length - 1);
|
|
325
|
+
const at = (cell) => (index - 1 + (cell + 0.5) / cells) / last;
|
|
326
|
+
const into = stages[index].state;
|
|
327
|
+
const from = stages[index - 1].state;
|
|
328
|
+
let body;
|
|
329
|
+
if (into === "done" || into === "now") {
|
|
330
|
+
body = Array.from({ length: cells }, (_, cell) => span("━", { fg: mapColor(accent, at(cell)) }));
|
|
331
|
+
} else if (from === "now") {
|
|
332
|
+
const filled = model.workflow.count ? sample.fill * cells : 0;
|
|
333
|
+
const flow = model.idle ? null : sample.ambient?.flow ?? null;
|
|
334
|
+
const head = flow === null ? -1 : Math.floor(flow * cells);
|
|
335
|
+
body = Array.from({ length: cells }, (_, cell) => {
|
|
336
|
+
const base = cell < filled ? mapColor(accent, at(cell)) : PILL.rail;
|
|
337
|
+
return span("━", { fg: cell === head ? lerpColor(base, "#ffffff", 0.75) : base });
|
|
338
|
+
});
|
|
339
|
+
} else {
|
|
340
|
+
body = [span("─".repeat(cells), { fg: PILL.rail })];
|
|
341
|
+
}
|
|
342
|
+
return [span(" "), ...body, span(" ")];
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
// interview ✓ ━━ plan ✓ ━━ [work 3/7] ━━ review · ── done ·
|
|
346
|
+
const mapTrack = (model, sample, { glyphs, accent, rail, short = false, window = false }) => {
|
|
347
|
+
const { stages, current } = model.workflow;
|
|
348
|
+
const last = Math.max(1, stages.length - 1);
|
|
349
|
+
const focus = current < 0 ? stages.length - 1 : current;
|
|
350
|
+
const shown = stages.map((_, index) => index).filter((index) => !window || Math.abs(index - focus) <= 1);
|
|
351
|
+
const out = [];
|
|
352
|
+
if (window && shown[0] > 0) out.push(span("… ", { fg: PALETTE.dim }));
|
|
353
|
+
shown.forEach((index) => {
|
|
354
|
+
const stage = stages[index];
|
|
355
|
+
const label = short ? shortLabel(stage.label) : stage.label;
|
|
356
|
+
const text = stage.state === "now" ? label : `${label} ${stage.state === "done" ? glyphs.done : "·"}`;
|
|
357
|
+
const revealed = revealText(text, sample.pills[Math.min(3, 1 + Math.floor(index / 2))]);
|
|
358
|
+
if (revealed === null) return;
|
|
359
|
+
if (out.length && !(window && out.length === 1 && shown[0] > 0)) out.push(...railSpans(model, sample, index, rail, accent));
|
|
360
|
+
if (stage.state === "now") {
|
|
361
|
+
let fill = model.idle ? lerpColor(accent, PILL.muted, 0.5) : accent;
|
|
362
|
+
if (model.ctxWarning) fill = lerpColor(fill, PALETTE.amber, sample.warn);
|
|
363
|
+
if (sample.ambient && !model.idle) fill = lerpColor(fill, "#ffffff", 0.2 * sample.ambient.breath);
|
|
364
|
+
const sweep = model.idle ? null : sample.ambient?.sweep ?? null;
|
|
365
|
+
out.push(...pill(litText(revealed.slice(0, label.length).padEnd(label.length), { fg: PILL.ink, bg: fill, bold: true }, sweep), fill, glyphs));
|
|
366
|
+
} else {
|
|
367
|
+
out.push(span(revealed, { fg: stage.state === "done" ? mapColor(accent, index / last) : PALETTE.dim }));
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
if (window && shown.at(-1) < stages.length - 1) out.push(span(" …", { fg: PALETTE.dim }));
|
|
371
|
+
return out;
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
const MAP_VARIANTS = Object.freeze([
|
|
375
|
+
{ rail: 2 },
|
|
376
|
+
{ rail: 1 },
|
|
377
|
+
{ rail: 0 },
|
|
378
|
+
{ rail: 0, short: true },
|
|
379
|
+
{ rail: 0, short: true, window: true },
|
|
380
|
+
]);
|
|
381
|
+
|
|
382
|
+
// The map, then the context warning, the lanes and the autoloop budget while there is room.
|
|
383
|
+
const mapRow = (model, sample, { inner, glyphs, accent }) => {
|
|
384
|
+
const tracks = MAP_VARIANTS.map((variant) => mapTrack(model, sample, { glyphs, accent, ...variant }));
|
|
385
|
+
const track = tracks.find((candidate) => rowWidth(candidate) <= inner) ?? tracks.at(-1);
|
|
386
|
+
const right = [];
|
|
387
|
+
if (sample.rest > 0) {
|
|
388
|
+
if (model.ctxWarning && sample.warn > 0.02) {
|
|
389
|
+
right.push(segment("ctx", 1, [span(`ctx ${model.ctxWarning.percent}% → ${model.ctxWarning.threshold}%`, { fg: lerpColor(PALETTE.dim, PALETTE.amber, sample.warn), bold: sample.warn > 0.5 })]));
|
|
390
|
+
}
|
|
391
|
+
if (model.lanes.count > 0) {
|
|
392
|
+
const dots = model.lanes.list.slice(0, 4).map((lane) => span(glyphs.lane, { fg: lane.status === "stalled" ? PALETTE.amber : PALETTE.green }));
|
|
393
|
+
right.push(segment("lanes", 3, [...dots, span(` ${model.lanes.count} ${model.lanes.count === 1 ? "lane" : "lanes"}`, { fg: PALETTE.dim })]));
|
|
394
|
+
}
|
|
395
|
+
if (model.autoloop) {
|
|
396
|
+
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 })]));
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
const kept = fitJoined(right, inner - rowWidth(track) - 2);
|
|
400
|
+
const tail = joinSegments(kept, PALETTE.faint);
|
|
401
|
+
if (!tail.length) return fitRow(track, inner);
|
|
402
|
+
return fitRow([...track, span(" ".repeat(Math.max(2, inner - rowWidth(track) - rowWidth(tail)))), ...tail], inner);
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
const SPINNER = Object.freeze(["-", "\\", "|", "/"]);
|
|
406
|
+
|
|
407
|
+
// m:ss, or h:mm:ss past the hour.
|
|
408
|
+
export const formatClock = (ms) => {
|
|
409
|
+
const seconds = Math.max(0, Math.floor(ms / 1000));
|
|
410
|
+
const minutes = Math.floor(seconds / 60);
|
|
411
|
+
const ss = String(seconds % 60).padStart(2, "0");
|
|
412
|
+
return minutes >= 60 ? `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, "0")}:${ss}` : `${minutes}:${ss}`;
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
// The live step: an ASCII spinner, the tool's name and how long it has run. Nothing else of the call.
|
|
416
|
+
const stepSpans = (sample, accent) => {
|
|
417
|
+
if (!sample.step) return [];
|
|
418
|
+
return [
|
|
419
|
+
span(`${SPINNER[sample.spin ?? 0]} `, { fg: accent, bold: true }),
|
|
420
|
+
span(sample.step.name, { fg: PALETTE.white }),
|
|
421
|
+
span(` · ${formatClock(sample.step.elapsedMs)}`, { fg: PALETTE.dim }),
|
|
422
|
+
];
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
const VERDICT_STYLE = Object.freeze({
|
|
426
|
+
pass: (glyphs) => [glyphs.done, PALETTE.green],
|
|
427
|
+
fail: (glyphs) => [glyphs.fail, PALETTE.red],
|
|
428
|
+
pending: () => ["·", PALETTE.dim],
|
|
429
|
+
running: (glyphs) => [glyphs.lane, PALETTE.amber],
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// The review lanes or a crucible verdict: each name in its verdict's colour (a lane under review
|
|
433
|
+
// amber), so a change repaints.
|
|
434
|
+
// `cut` shortens the names to that many letters; 0 drops them and leaves the marks.
|
|
435
|
+
const verdictSpans = (verdicts, glyphs, cut = Infinity) => verdicts.flatMap(({ name, state }, index) => {
|
|
436
|
+
const [mark, color] = VERDICT_STYLE[state](glyphs);
|
|
437
|
+
const text = cut === 0 ? mark : `${name.slice(0, cut)} ${mark}`;
|
|
438
|
+
return [...(index > 0 ? [span(" ")] : []), span(text, { fg: color })];
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
// └ task 3/7 ███░░░░░ · paused · next ▸ <task> - Bash · 0:12
|
|
442
|
+
const detailRow = (model, sample, { inner, glyphs, accent }) => {
|
|
443
|
+
const flow = model.workflow;
|
|
444
|
+
const right = stepSpans(sample, accent);
|
|
445
|
+
const segments = [];
|
|
446
|
+
if (flow?.count) {
|
|
447
|
+
const { done, total, unit } = flow.count;
|
|
448
|
+
const glow = sample.ambient && !model.idle ? 0.5 * sample.ambient.breath : sample.fillGlow ?? 0;
|
|
449
|
+
const bar = barCells(sample.fill, inner >= 70 ? 8 : 5, glow).map(({ glyph, color }) => (glyph === "█" ? span(" ", { bg: color }) : span(glyph, { fg: color })));
|
|
450
|
+
segments.push(segment("count", 1, [
|
|
451
|
+
span(`${unit} `, { fg: PALETTE.dim }),
|
|
452
|
+
span(String(done), { fg: lerpColor(PALETTE.white, PALETTE.cyan, Math.max(sample.flash, sample.fillGlow ?? 0)), bold: true }),
|
|
453
|
+
span(`/${total} `, { fg: PALETTE.dim }),
|
|
454
|
+
...bar,
|
|
455
|
+
], " · "));
|
|
456
|
+
}
|
|
457
|
+
const lead = [span("└ ", { fg: PALETTE.dim })];
|
|
458
|
+
const room = inner - rowWidth(lead) - (right.length ? rowWidth(right) + 2 : 0);
|
|
459
|
+
if (flow?.verdicts) {
|
|
460
|
+
const before = segments.reduce((sum, part) => sum + part.width + textWidth(part.joiner), 0);
|
|
461
|
+
const forms = [Infinity, 3, 0].map((cut) => segment("verdicts", 2, verdictSpans(flow.verdicts, glyphs, cut), " · "));
|
|
462
|
+
segments.push(forms.find((form) => before + form.width <= room) ?? forms.at(-1));
|
|
463
|
+
}
|
|
464
|
+
(flow?.items ?? []).forEach((item, index) => segments.push(segment(`item-${index}`, 3 + index, [span(item, { fg: PALETTE.white })], " · ")));
|
|
465
|
+
let left = joinSegments(fitJoined(segments, room), PALETTE.faint);
|
|
466
|
+
if (flow?.next) {
|
|
467
|
+
const gap = left.length ? [span(" · ", { fg: PALETTE.faint })] : [];
|
|
468
|
+
const label = [span("next ", { fg: PALETTE.dim }), span(`${glyphs.next} `, { fg: accent })];
|
|
469
|
+
const avail = room - rowWidth(left) - rowWidth(gap) - rowWidth(label);
|
|
470
|
+
if (avail >= 6) left = [...left, ...gap, ...label, span(truncateToWidth(flow.next, avail), { fg: PALETTE.white })];
|
|
471
|
+
}
|
|
472
|
+
if (!left.length && !right.length) return fitRow([], inner);
|
|
473
|
+
const body = [...lead, ...left];
|
|
474
|
+
if (!right.length) return fitRow(body, inner);
|
|
475
|
+
return fitRow([...body, span(" ".repeat(Math.max(1, inner - rowWidth(body) - rowWidth(right)))), ...right], inner);
|
|
476
|
+
};
|
|
477
|
+
|
|
295
478
|
const CHIP_TONE = Object.freeze({
|
|
296
479
|
current: (accent) => ({ fill: accent, text: PILL.ink }),
|
|
297
480
|
recent: () => ({ fill: PILL.muted, text: PILL.mutedText }),
|
|
@@ -320,7 +503,7 @@ const mutedPill = (text, glyphs) => pill([span(text, { fg: PALETTE.dim, bg: PILL
|
|
|
320
503
|
const chipsRow = (model, sample, { inner, glyphs, accent }) => {
|
|
321
504
|
const chips = model.skills;
|
|
322
505
|
if (chips.length === 0) {
|
|
323
|
-
if (!model.idle && model.next) return fitRow([span("▸ ", { fg: accent }), span(truncateToWidth(model.next, inner - 2), { fg: PALETTE.white })], inner);
|
|
506
|
+
if (!model.idle && model.next && !model.workflow?.next) return fitRow([span("▸ ", { fg: accent }), span(truncateToWidth(model.next, inner - 2), { fg: PALETTE.white })], inner);
|
|
324
507
|
if (!model.idle) return fitRow([span("no LitFamily skill yet in this request", { fg: PALETTE.dim })], inner);
|
|
325
508
|
const hints = [
|
|
326
509
|
[span("no LitFamily skill yet · type ", { fg: PALETTE.dim }), span("lit <task>", { fg: accent }), span(" to start one", { fg: PALETTE.dim })],
|
|
@@ -416,9 +599,10 @@ const edgeColor = (model, sample, accent) => {
|
|
|
416
599
|
};
|
|
417
600
|
|
|
418
601
|
/**
|
|
419
|
-
* The card for one frame: `{ mode: "card", border, width, rows }`, always
|
|
420
|
-
* phase
|
|
421
|
-
* CARD_MAX. `accent` is the status line's accent colour
|
|
602
|
+
* The card for one frame: `{ mode: "card", border, width, rows }`, always five rows (top edge,
|
|
603
|
+
* workflow map or phase track, detail line, chips row, bottom edge), each exactly `width` cells:
|
|
604
|
+
* the terminal's columns up to CARD_MAX. `accent` is the status line's accent colour
|
|
605
|
+
* (settings.mjs accentFromSettings).
|
|
422
606
|
*/
|
|
423
607
|
export const composeBand = (model, sample, { columns, glyphs: glyphMode = "basic", accent = PALETTE.accent }) => {
|
|
424
608
|
const glyphs = glyphSet(glyphMode);
|
|
@@ -432,7 +616,13 @@ export const composeBand = (model, sample, { columns, glyphs: glyphMode = "basic
|
|
|
432
616
|
mode: "card",
|
|
433
617
|
border: edge,
|
|
434
618
|
width,
|
|
435
|
-
rows: [
|
|
619
|
+
rows: [
|
|
620
|
+
topEdge(model, sample, options),
|
|
621
|
+
side(model.workflow ? mapRow(model, sample, options) : phaseRow(model, sample, options)),
|
|
622
|
+
side(detailRow(model, sample, options)),
|
|
623
|
+
side(middle),
|
|
624
|
+
bottomEdge(model, sample, options),
|
|
625
|
+
],
|
|
436
626
|
};
|
|
437
627
|
};
|
|
438
628
|
|
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
// The LIT band's
|
|
1
|
+
// The LIT band's four userConfig options. The manifest declares them as plain strings without an
|
|
2
2
|
// `options` list, because Claude Code 2.1.270 and older refuse a whole plugin whose userConfig has
|
|
3
3
|
// one; the values are checked here instead, and anything unknown falls back to the default (the
|
|
4
|
-
// first choice).
|
|
4
|
+
// first choice). `notify` raises a notification through the person's own channel when LIT work
|
|
5
|
+
// completes or a LIT turn longer than three minutes ends; `finish+sound` adds a sound only where
|
|
6
|
+
// the plugin ships one, and none ships yet, so it notifies like `finish`.
|
|
5
7
|
|
|
6
8
|
export const MOD_CHOICES = Object.freeze({
|
|
7
9
|
motion: ["full", "subtle", "off"],
|
|
8
10
|
glyphs: ["auto", "nerd", "basic"],
|
|
9
11
|
band: ["always", "auto", "off"],
|
|
12
|
+
notify: ["off", "finish", "finish+sound"],
|
|
10
13
|
});
|
|
11
14
|
|
|
12
15
|
const pick = (value, choices) => {
|
|
@@ -18,6 +21,7 @@ export const normalizeModSettings = (options) => ({
|
|
|
18
21
|
motion: pick(options?.motion, MOD_CHOICES.motion),
|
|
19
22
|
glyphs: pick(options?.glyphs, MOD_CHOICES.glyphs),
|
|
20
23
|
band: pick(options?.band, MOD_CHOICES.band),
|
|
24
|
+
notify: pick(options?.notify, MOD_CHOICES.notify),
|
|
21
25
|
});
|
|
22
26
|
|
|
23
27
|
// The glyph set one surface draws. Private-use Nerd Font glyphs only reach the terminal, which
|