@litfamily/litclaude 1.0.21 → 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 +18 -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 +131 -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 -22
- 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 +314 -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/hud-ignition.mjs +7 -37
- 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 +454 -0
- package/plugins/litclaude/lib/mod/layout.mjs +53 -0
- package/plugins/litclaude/lib/mod/motion.mjs +101 -0
- package/plugins/litclaude/lib/mod/render.mjs +20 -0
- package/plugins/litclaude/lib/mod/settings.mjs +105 -0
- package/plugins/litclaude/lib/mod/skills.mjs +81 -0
- package/plugins/litclaude/lib/mod/state.mjs +411 -0
- package/plugins/litclaude/lib/mod/timeline.mjs +152 -0
- package/plugins/litclaude/lib/mod/view.mjs +255 -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
|
@@ -56,11 +56,11 @@ export const ansi16CodeForAccent = (value) => ({
|
|
|
56
56
|
}[normalizeHudAccent(value)] ?? 36);
|
|
57
57
|
|
|
58
58
|
// --- LitClaude neon brand banner (D21) -------------------------------------
|
|
59
|
-
// `[🔥
|
|
59
|
+
// `[🔥LIT vX.Y.Z]` rendered as a neon glow. Truecolor terminals get a
|
|
60
60
|
// per-letter hot-pink -> electric-cyan gradient; 256-color terminals fall back
|
|
61
61
|
// to bright-magenta bold; NO_COLOR is plain text. The 🔥 is mandatory and the
|
|
62
62
|
// brand is accent-independent (LITCLAUDE_HUD_ACCENT still themes the HUD body).
|
|
63
|
-
const NEON_BRAND = "
|
|
63
|
+
const NEON_BRAND = "LIT";
|
|
64
64
|
export const NEON_START = Object.freeze([255, 45, 149]); // #ff2d95 hot pink
|
|
65
65
|
export const NEON_END = Object.freeze([0, 229, 255]); // #00e5ff electric cyan
|
|
66
66
|
|
|
@@ -1,18 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
|
|
5
|
-
// Per-session ignition record: the prompt hook writes it on every UserPromptSubmit and the
|
|
6
|
-
//
|
|
7
|
-
// the current turn instead of anything the model chooses to
|
|
8
|
-
// durable, so the record lives in the per-user temp directory:
|
|
9
|
-
// root (activation must stay side-effect-free there) and not in the
|
|
10
|
-
|
|
11
|
-
const IGNITION_PALETTE = Object.freeze({
|
|
12
|
-
truecolor: "\x1b[38;2;255;99;55m",
|
|
13
|
-
"256": "\x1b[38;5;203m",
|
|
14
|
-
"16": "\x1b[91m",
|
|
15
|
-
});
|
|
5
|
+
// Per-session ignition record: the prompt hook writes it on every UserPromptSubmit and the LIT
|
|
6
|
+
// card (hooks/lit-mod.mjs, through lib/mod/state.mjs) reads it back, so the card reflects the
|
|
7
|
+
// discipline actually selected for the current turn instead of anything the model chooses to
|
|
8
|
+
// draw. Mode is per turn and never durable, so the record lives in the per-user temp directory:
|
|
9
|
+
// not in the repository state root (activation must stay side-effect-free there) and not in the
|
|
10
|
+
// home directory.
|
|
16
11
|
|
|
17
12
|
export const ignitionStateRoot = (env = process.env) => {
|
|
18
13
|
const override = env.LITCLAUDE_HUD_STATE_ROOT?.trim();
|
|
@@ -47,28 +42,3 @@ export function writeIgnitionState({ sessionId, discipline }, env = process.env,
|
|
|
47
42
|
renameSync(staging, target);
|
|
48
43
|
return record;
|
|
49
44
|
}
|
|
50
|
-
|
|
51
|
-
export function readIgnitionState(sessionId, env = process.env) {
|
|
52
|
-
const target = ignitionStatePath(sessionId, env);
|
|
53
|
-
if (!existsSync(target)) return null;
|
|
54
|
-
try {
|
|
55
|
-
const parsed = JSON.parse(readFileSync(target, "utf8"));
|
|
56
|
-
if (!parsed || typeof parsed !== "object") return null;
|
|
57
|
-
return {
|
|
58
|
-
sessionId: typeof parsed.sessionId === "string" ? parsed.sessionId : null,
|
|
59
|
-
discipline: typeof parsed.discipline === "string" && parsed.discipline ? parsed.discipline : null,
|
|
60
|
-
at: typeof parsed.at === "string" ? parsed.at : null,
|
|
61
|
-
};
|
|
62
|
-
} catch {
|
|
63
|
-
return null;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export function renderIgnitionSegment(state, { depth = "plain" } = {}) {
|
|
68
|
-
const discipline = state?.discipline ?? null;
|
|
69
|
-
if (!discipline) return "";
|
|
70
|
-
const label = ignitionMark(discipline);
|
|
71
|
-
if (depth === "plain") return label;
|
|
72
|
-
const paint = IGNITION_PALETTE[depth] ?? IGNITION_PALETTE["256"];
|
|
73
|
-
return `\x1b[1m${paint}${label}\x1b[0m`;
|
|
74
|
-
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
12
12
|
import { extname, isAbsolute, join } from "node:path";
|
|
13
13
|
|
|
14
|
-
import { resolveProjectStateRoot } from "./project-state-root.mjs";
|
|
14
|
+
import { ensureStateDirIgnored, resolveProjectStateRoot } from "./project-state-root.mjs";
|
|
15
15
|
|
|
16
16
|
export const INTERFACE_PROBE_MAX_BLOCKS = 2;
|
|
17
17
|
// start-work is left out: its prompt turn must stay side-effect-free, and the plan rows it runs
|
|
@@ -41,6 +41,7 @@ const readTurn = (cwd) => {
|
|
|
41
41
|
const writeTurn = (cwd, state) => {
|
|
42
42
|
const target = interfaceProbeTurnPath(cwd);
|
|
43
43
|
mkdirSync(join(target, ".."), { recursive: true });
|
|
44
|
+
ensureStateDirIgnored(target);
|
|
44
45
|
const tmp = `${target}.tmp`;
|
|
45
46
|
writeFileSync(tmp, JSON.stringify(state));
|
|
46
47
|
renameSync(tmp, target);
|
|
@@ -13,6 +13,7 @@ import { closeSync, constants, lstatSync, mkdirSync, openSync, readFileSync, ren
|
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
|
|
15
15
|
import { canonicalSkillIds } from "./canonical-skill-catalog.mjs";
|
|
16
|
+
import { ensureStateDirIgnored } from "./project-state-root.mjs";
|
|
16
17
|
import { SECRET_SHAPE_PATTERNS } from "./secret-shapes.mjs";
|
|
17
18
|
|
|
18
19
|
export const JEV_FLAG = "LITCLAUDE_JEV";
|
|
@@ -253,6 +254,7 @@ const jevStateDirectory = (stateRoot, { create = false } = {}) => {
|
|
|
253
254
|
}
|
|
254
255
|
if (!stat.isDirectory()) throw new Error("state folder is not a real folder");
|
|
255
256
|
}
|
|
257
|
+
if (create) ensureStateDirIgnored(directory);
|
|
256
258
|
return directory;
|
|
257
259
|
};
|
|
258
260
|
|
|
@@ -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,454 @@
|
|
|
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.
|
|
16
|
+
//
|
|
17
|
+
// In a real Claude Code 2.1.292 terminal, a row whose only change is a non-ASCII glyph (a block,
|
|
18
|
+
// symbol, Hangul or Nerd Font cell) keeps showing the old glyph; a change to ASCII text, or to the
|
|
19
|
+
// colour of an ASCII cell, repaints. So every change is carried by ASCII: a bar cell is a space on
|
|
20
|
+
// its colour and the leading cell fades in by colour, embers are ASCII sparks, labels pop in letter
|
|
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.
|
|
23
|
+
|
|
24
|
+
import { textWidth, truncateToWidth } from "./layout.mjs";
|
|
25
|
+
import { barCells, lerpColor } from "./motion.mjs";
|
|
26
|
+
import { glyphSet } from "./settings.mjs";
|
|
27
|
+
import { formatDuration } from "./view.mjs";
|
|
28
|
+
|
|
29
|
+
export const PALETTE = Object.freeze({
|
|
30
|
+
pink: "#ff2d95",
|
|
31
|
+
pinkText: "#ff6fae",
|
|
32
|
+
orange: "#ff6337",
|
|
33
|
+
cyan: "#00e5ff",
|
|
34
|
+
accent: "#5fd7ff",
|
|
35
|
+
green: "#6bdb8f",
|
|
36
|
+
amber: "#ffaf3c",
|
|
37
|
+
red: "#ff5f5f",
|
|
38
|
+
violet: "#b48cff",
|
|
39
|
+
white: "#e6edf3",
|
|
40
|
+
dim: "#6e7681",
|
|
41
|
+
faint: "#30363d",
|
|
42
|
+
});
|
|
43
|
+
|
|
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" });
|
|
51
|
+
const TONE = Object.freeze({
|
|
52
|
+
pink: { bar: PALETTE.pink, title: PALETTE.pinkText, icon: "🔥" },
|
|
53
|
+
amber: { bar: PALETTE.amber, title: PALETTE.amber, icon: "⚠" },
|
|
54
|
+
red: { bar: PALETTE.red, title: PALETTE.red, icon: "✗" },
|
|
55
|
+
});
|
|
56
|
+
const LABELS = Object.freeze({ plan: "PLAN", work: "WORK", review: "REVIEW" });
|
|
57
|
+
const PHASES = Object.freeze(["plan", "work", "review"]);
|
|
58
|
+
|
|
59
|
+
const span = (text, style = {}) => ({ text, ...style });
|
|
60
|
+
export const rowWidth = (row) => row.reduce((sum, part) => sum + textWidth(part.text), 0);
|
|
61
|
+
export const rowText = (row) => row.map(({ text }) => text).join("");
|
|
62
|
+
|
|
63
|
+
// Cuts a row to `cells`, splitting the span that crosses the edge.
|
|
64
|
+
export const clipRow = (row, cells) => {
|
|
65
|
+
const out = [];
|
|
66
|
+
let room = cells;
|
|
67
|
+
for (const part of row) {
|
|
68
|
+
if (room <= 0) break;
|
|
69
|
+
const width = textWidth(part.text);
|
|
70
|
+
if (width <= room) {
|
|
71
|
+
out.push(part);
|
|
72
|
+
room -= width;
|
|
73
|
+
} else {
|
|
74
|
+
out.push({ ...part, text: truncateToWidth(part.text, room, "") });
|
|
75
|
+
room = 0;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
};
|
|
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
|
+
|
|
88
|
+
// A label popping in: the first `reveal` share of its characters, and while ease-out-back carries
|
|
89
|
+
// the reveal past 1, a cell or two of extra padding so the label visibly overshoots and settles.
|
|
90
|
+
export const revealText = (label, reveal) => {
|
|
91
|
+
if (!(reveal > 0)) return null;
|
|
92
|
+
const chars = [...label];
|
|
93
|
+
const count = Math.max(1, Math.round(reveal * chars.length));
|
|
94
|
+
return chars.slice(0, Math.min(count, chars.length)).join("") + " ".repeat(Math.max(0, count - chars.length));
|
|
95
|
+
};
|
|
96
|
+
|
|
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)];
|
|
101
|
+
const chars = [...text];
|
|
102
|
+
const visible = chars.filter((ch) => ch.trim()).length;
|
|
103
|
+
let k = 0;
|
|
104
|
+
return chars.map((ch) => {
|
|
105
|
+
if (!ch.trim()) return span(ch, { ...style });
|
|
106
|
+
const position = visible <= 1 ? 0 : k / (visible - 1);
|
|
107
|
+
k += 1;
|
|
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 });
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
|
|
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 })]);
|
|
118
|
+
|
|
119
|
+
const inkOf = (accent) => ({ done: lerpColor(accent, PALETTE.dim, 0.45), now: accent, todo: PALETTE.dim, warn: PALETTE.amber });
|
|
120
|
+
|
|
121
|
+
// Colour of one phase in this frame: the crossfade from its previous state, then the amber mix for
|
|
122
|
+
// the work phase while the context warning is up.
|
|
123
|
+
const phaseColor = (id, sample, ink) => {
|
|
124
|
+
const { from, to, mix } = sample.phases[id];
|
|
125
|
+
const color = lerpColor(ink[from], ink[to], mix);
|
|
126
|
+
return id === "work" && sample.warn > 0 ? lerpColor(color, ink.warn, sample.warn) : color;
|
|
127
|
+
};
|
|
128
|
+
|
|
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;
|
|
131
|
+
|
|
132
|
+
const segment = (id, priority, spans, joiner = " ") => (spans && spans.length ? { id, priority, spans, joiner, width: rowWidth(spans) } : null);
|
|
133
|
+
|
|
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;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const joinSegments = (kept, joinerColor) => kept.flatMap((part, index) => (index > 0 ? [span(part.joiner, { fg: joinerColor }), ...part.spans] : part.spans));
|
|
147
|
+
|
|
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
|
+
};
|
|
155
|
+
|
|
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 })];
|
|
168
|
+
};
|
|
169
|
+
|
|
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);
|
|
184
|
+
};
|
|
185
|
+
|
|
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
|
+
};
|
|
211
|
+
|
|
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
|
+
}
|
|
230
|
+
});
|
|
231
|
+
return out;
|
|
232
|
+
};
|
|
233
|
+
|
|
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);
|
|
257
|
+
};
|
|
258
|
+
|
|
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;
|
|
306
|
+
}
|
|
307
|
+
if (shown < chips.length) row.push(...(row.length ? [span(" ")] : []), ...mutedPill(`+${chips.length - shown}`, glyphs));
|
|
308
|
+
return fitRow(row, inner);
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
const noticeRow = (sample, inner) => {
|
|
312
|
+
const { data, offset } = sample.notice;
|
|
313
|
+
const tone = TONE[data.tone] ?? TONE.pink;
|
|
314
|
+
return fitRow([
|
|
315
|
+
span(" ".repeat(offset)),
|
|
316
|
+
span("▎", { fg: tone.bar }),
|
|
317
|
+
span(` ${tone.icon} ${data.title}`, { fg: tone.title, bold: true }),
|
|
318
|
+
...(data.detail ? [span(` · ${data.detail}`, { fg: PALETTE.dim })] : []),
|
|
319
|
+
], inner);
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const SPARKS = [".", "+", "*"];
|
|
323
|
+
|
|
324
|
+
const completionRow = (sample, inner) => {
|
|
325
|
+
const completion = sample.completion;
|
|
326
|
+
const glow = completion.embers === null ? 0 : 0.6 * Math.sin(Math.PI * completion.embers);
|
|
327
|
+
const words = [
|
|
328
|
+
span("COMPLETE", { fg: lerpColor(lerpColor(PALETTE.dim, PALETTE.white, completion.title), PALETTE.pinkText, glow), bold: true }),
|
|
329
|
+
span(` · ${completion.data.detail}`, { fg: lerpColor(PALETTE.dim, PALETTE.green, completion.detail) }),
|
|
330
|
+
];
|
|
331
|
+
const row = [];
|
|
332
|
+
if (completion.embers !== null) {
|
|
333
|
+
const count = Math.max(0, Math.min(18, Math.floor((inner - rowWidth(words) - 2) / 2)));
|
|
334
|
+
for (let i = 0; i < count; i += 1) {
|
|
335
|
+
const life = Math.min(1, Math.max(0, completion.embers * 1.6 - i * 0.04));
|
|
336
|
+
const alpha = life <= 0 || life >= 1 ? 0 : Math.sin(life * Math.PI);
|
|
337
|
+
const glyph = alpha < 0.05 ? " " : SPARKS[Math.min(2, Math.floor(alpha * 3))];
|
|
338
|
+
row.push(span(glyph, { fg: lerpColor(PALETTE.faint, i % 3 ? PALETTE.pink : PALETTE.amber, alpha) }), span(" "));
|
|
339
|
+
}
|
|
340
|
+
row.push(span(" "));
|
|
341
|
+
} else {
|
|
342
|
+
row.push(span("✦ ", { fg: PALETTE.pink }));
|
|
343
|
+
}
|
|
344
|
+
return fitRow([...row, ...words], inner);
|
|
345
|
+
};
|
|
346
|
+
|
|
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);
|
|
371
|
+
};
|
|
372
|
+
|
|
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
|
+
};
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
const GATE_STYLE = Object.freeze({
|
|
401
|
+
ok: (glyphs) => ({ glyph: glyphs.done, fg: PALETTE.green }),
|
|
402
|
+
pending: () => ({ glyph: "…", fg: PALETTE.amber }),
|
|
403
|
+
fail: (glyphs) => ({ glyph: glyphs.fail, fg: PALETTE.red }),
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
const gateEntries = (gates, glyphs) => gates.map((gate) => {
|
|
407
|
+
const style = GATE_STYLE[gate.state](glyphs);
|
|
408
|
+
return [span(style.glyph, { fg: style.fg }), span(` ${gate.label}`, { fg: PALETTE.dim })];
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
/** Rows for the evidence pane's tab (`goal`, `lanes` or `gates`), fitted to `columns`. */
|
|
412
|
+
export const composeEvidence = (evidence, tab, { columns, glyphs: glyphMode = "basic" }) => {
|
|
413
|
+
const glyphs = glyphSet(glyphMode);
|
|
414
|
+
const rows = [];
|
|
415
|
+
const line = (...parts) => rows.push(clipRow(parts, columns));
|
|
416
|
+
if (tab === "lanes") {
|
|
417
|
+
if (evidence.lanes.length === 0) line(span("No subagents in this session.", { fg: PALETTE.dim }));
|
|
418
|
+
for (const lane of evidence.lanes) {
|
|
419
|
+
const color = lane.status === "failed" ? PALETTE.red : lane.status === "running" ? PALETTE.green : PALETTE.dim;
|
|
420
|
+
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 }));
|
|
421
|
+
}
|
|
422
|
+
return rows;
|
|
423
|
+
}
|
|
424
|
+
if (tab === "gates") {
|
|
425
|
+
if (evidence.work) line(span("plan ", { fg: PALETTE.dim }), span(`${evidence.work.plan ?? "plan"} · ${evidence.work.done}/${evidence.work.total}`, { fg: PALETTE.white }));
|
|
426
|
+
if (evidence.gates.length === 0) line(span("No gate is armed in this session.", { fg: PALETTE.dim }));
|
|
427
|
+
for (const entry of gateEntries(evidence.gates, glyphs)) line(...entry);
|
|
428
|
+
if (evidence.icons) line(span("icons ", { fg: PALETTE.dim }), span(evidence.icons, { fg: PALETTE.dim }));
|
|
429
|
+
return rows;
|
|
430
|
+
}
|
|
431
|
+
const goal = evidence.goal;
|
|
432
|
+
if (!goal) {
|
|
433
|
+
line(span("No litgoal goal in this project for this session.", { fg: PALETTE.dim }));
|
|
434
|
+
} else {
|
|
435
|
+
line(span("goal ", { fg: PALETTE.dim }), span(goal.objective, { fg: PALETTE.white, bold: true }), span(` ${goal.status}`, { fg: PALETTE.dim }));
|
|
436
|
+
line(span(""));
|
|
437
|
+
for (const criterion of goal.criteria) {
|
|
438
|
+
const mark = criterion.status === "pass" ? [glyphs.done, PALETTE.green] : criterion.status === "fail" || criterion.status === "blocked" ? [glyphs.fail, PALETTE.red] : ["·", PALETTE.faint];
|
|
439
|
+
const tail = ` ${criterion.ago}`;
|
|
440
|
+
const description = truncateToWidth(criterion.description, Math.max(8, columns - textWidth(criterion.id) - textWidth(tail) - 5));
|
|
441
|
+
line(span(`${mark[0]} `, { fg: mark[1] }), span(criterion.id, { fg: PALETTE.white }), span(` ${description}`, { fg: PALETTE.dim }), span(tail, { fg: PALETTE.dim }));
|
|
442
|
+
}
|
|
443
|
+
for (const blocker of goal.blockers) line(span(`${glyphs.flag} `, { fg: PALETTE.amber }), span(blocker, { fg: PALETTE.amber }));
|
|
444
|
+
if (goal.reviewBlocked) line(span(`${glyphs.flag} `, { fg: PALETTE.amber }), span("review: blocked", { fg: PALETTE.amber }));
|
|
445
|
+
}
|
|
446
|
+
if (evidence.timeline.length) {
|
|
447
|
+
line(span(""));
|
|
448
|
+
for (const entry of evidence.timeline) {
|
|
449
|
+
const color = entry.event.startsWith("evidence") ? PALETTE.accent : entry.event.startsWith("checkpoint") ? PALETTE.violet : PALETTE.green;
|
|
450
|
+
line(span(entry.ago.padEnd(10), { fg: PALETTE.dim }), span(entry.event.padEnd(20), { fg: color }), span(entry.detail, { fg: PALETTE.dim }));
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return rows;
|
|
454
|
+
};
|