@litfamily/litopencode 1.0.18 → 1.0.19

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.
Files changed (54) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README-Ko-KR.md +56 -86
  3. package/README.md +57 -86
  4. package/dist/activation-routing.js +3 -1
  5. package/dist/cli/args.js +5 -1
  6. package/dist/cli/auto-update.js +1 -1
  7. package/dist/cli/doctor.js +7 -5
  8. package/dist/cli/host-limits.js +2 -5
  9. package/dist/cli/install-config.d.ts +4 -0
  10. package/dist/cli/install-config.js +27 -1
  11. package/dist/cli/install-report.js +4 -1
  12. package/dist/cli/install.js +89 -28
  13. package/dist/cli/json.d.ts +1 -0
  14. package/dist/cli/json.js +10 -4
  15. package/dist/cli/jsonc.d.ts +17 -0
  16. package/dist/cli/jsonc.js +174 -0
  17. package/dist/cli/loop.d.ts +1 -1
  18. package/dist/cli/loop.js +81 -1
  19. package/dist/cli/motion-runtime.d.ts +13 -1
  20. package/dist/cli/motion-runtime.js +89 -2
  21. package/dist/cli/plugin-mutation.js +11 -6
  22. package/dist/cli/tui-config.d.ts +52 -0
  23. package/dist/cli/tui-config.js +198 -0
  24. package/dist/cli/types.d.ts +4 -0
  25. package/dist/cli.js +8 -3
  26. package/dist/deliverable-hedge-guard.js +2 -2
  27. package/dist/tui/mission-band.d.ts +29 -0
  28. package/dist/tui/mission-band.js +116 -0
  29. package/dist/tui/mission-model.d.ts +139 -0
  30. package/dist/tui/mission-model.js +318 -0
  31. package/dist/tui/mission-motion.d.ts +66 -0
  32. package/dist/tui/mission-motion.js +147 -0
  33. package/dist/tui/mission-source.d.ts +56 -0
  34. package/dist/tui/mission-source.js +88 -0
  35. package/dist/tui/mission-text.d.ts +103 -0
  36. package/dist/tui/mission-text.js +613 -0
  37. package/dist/tui.d.ts +6 -0
  38. package/dist/tui.js +317 -0
  39. package/docs/assets/readme/badge-version.svg +1 -1
  40. package/docs/reference-Ko-KR.md +72 -1
  41. package/docs/reference.md +82 -1
  42. package/package.json +21 -2
  43. package/skills/lit-pptx/scripts/lib/template-registry.js +1 -1
  44. package/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +0 -6
  45. package/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +0 -6
  46. package/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +0 -6
  47. package/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +0 -6
  48. package/skills/lit-pptx/templates/tonalities/night/pack.yaml +0 -6
  49. package/skills/lit-pptx/templates/tonalities/paper/pack.yaml +0 -6
  50. package/skills/lit-pptx/templates/tonalities/signal/pack.yaml +0 -6
  51. package/skills/lit-pptx/templates/tonalities/studio/pack.yaml +0 -6
  52. package/skills/managed-skill-manifest.json +9 -9
  53. package/tools/check-pack-payload.mjs +20 -5
  54. package/tools/version-manifests.json +18 -6
@@ -0,0 +1,613 @@
1
+ // Terminal layout for the mission chip, the boxed sidebar panel and the home line: cell widths
2
+ // (Hangul and emoji take two cells), truncation, the gradient bar, and the coloured segments the TUI
3
+ // renders as spans. Every frame of motion is drawn from a MotionFrame, so all of it is testable here.
4
+ import { ease, mixHex, rowEntryStart, seg, slideIn, motionTiming } from "./mission-motion.js";
5
+ import { missionRowKeys, missionStatus } from "./mission-model.js";
6
+ /** Panel box width in cells, and the content width inside its border and one cell of padding per side. */
7
+ export const panelWidth = 36;
8
+ export const panelInnerWidth = panelWidth - 4;
9
+ function readColor(value) {
10
+ if (typeof value === "string" && /^#[0-9a-f]{6}$/iu.test(value))
11
+ return { hex: value.toLowerCase(), alpha: 1 };
12
+ if (typeof value !== "object" || value === null)
13
+ return undefined;
14
+ const color = value;
15
+ let channels;
16
+ let alpha = 1;
17
+ if (typeof color.toInts === "function") {
18
+ const ints = color.toInts();
19
+ channels = ints.slice(0, 3);
20
+ if (typeof ints[3] === "number")
21
+ alpha = ints[3] / 255;
22
+ }
23
+ else if (typeof color.r === "number" && typeof color.g === "number" && typeof color.b === "number") {
24
+ channels = [color.r, color.g, color.b].map((channel) => Math.round(channel * 255));
25
+ if (typeof color.a === "number")
26
+ alpha = color.a;
27
+ }
28
+ if (channels === undefined || channels.length < 3 || channels.some((channel) => !Number.isFinite(channel)))
29
+ return undefined;
30
+ return { hex: "#" + channels.map((channel) => Math.max(0, Math.min(255, Math.round(channel))).toString(16).padStart(2, "0")).join(""), alpha };
31
+ }
32
+ /** Theme tokens arrive as RGBA objects; plain hex strings pass through. Alpha is ignored here. */
33
+ export function colorToHex(value, fallback) {
34
+ return readColor(value)?.hex ?? fallback;
35
+ }
36
+ /**
37
+ * Builds the palette from `api.theme.current`. A surface token with alpha 0 (the system and
38
+ * lucent-orng themes) means "draw no background", never black.
39
+ */
40
+ export function paletteFromTheme(theme, mode = "dark") {
41
+ const pick = (key, fallback) => colorToHex(theme?.[key], fallback);
42
+ const surface = (key) => {
43
+ const color = readColor(theme?.[key]);
44
+ return color === undefined || color.alpha === 0 ? undefined : color.hex;
45
+ };
46
+ const background = surface("background");
47
+ const panel = surface("backgroundPanel");
48
+ const element = surface("backgroundElement");
49
+ const neutral = mode === "light" ? "#f2f2f2" : "#1c1c1c";
50
+ return {
51
+ primary: pick("primary", "#ff6337"),
52
+ accent: pick("accent", "#b48cff"),
53
+ info: pick("info", "#0aa6c2"),
54
+ warning: pick("warning", "#ffaf3c"),
55
+ success: pick("success", "#6bdb8f"),
56
+ error: pick("error", "#ff5f5f"),
57
+ text: pick("text", "#e6edf3"),
58
+ muted: pick("textMuted", "#7d8590"),
59
+ borderActive: pick("borderActive", "#484f58"),
60
+ borderSubtle: pick("borderSubtle", "#2a313b"),
61
+ ...(background === undefined ? {} : { background }),
62
+ ...(panel === undefined ? {} : { panel }),
63
+ ...(element === undefined ? {} : { element }),
64
+ mixBase: panel ?? background ?? neutral,
65
+ onFill: background ?? panel ?? (mode === "light" ? "#ffffff" : "#0b0b0b")
66
+ };
67
+ }
68
+ /**
69
+ * Columns the prompt row leaves for the chip. OpenCode shows the sidebar when the session is not a
70
+ * child, the stored `sidebar` setting is "auto" and the terminal is wider than 120 columns. An unknown
71
+ * setting is treated as open, the narrower and safe guess.
72
+ */
73
+ export function chipBudget(input) {
74
+ const sidebarOpen = !input.child && input.sidebar !== "hide" && input.width > 120;
75
+ return input.width - (sidebarOpen ? 42 : 0) - 4 - 42;
76
+ }
77
+ function isWide(code) {
78
+ return ((code >= 0x1100 && code <= 0x115f) ||
79
+ (code >= 0x2e80 && code <= 0x303e) ||
80
+ (code >= 0x3041 && code <= 0x33ff) ||
81
+ (code >= 0x3400 && code <= 0x4dbf) ||
82
+ (code >= 0x4e00 && code <= 0x9fff) ||
83
+ (code >= 0xa000 && code <= 0xa4cf) ||
84
+ (code >= 0xac00 && code <= 0xd7a3) ||
85
+ (code >= 0xf900 && code <= 0xfaff) ||
86
+ (code >= 0xfe30 && code <= 0xfe4f) ||
87
+ (code >= 0xff00 && code <= 0xff60) ||
88
+ (code >= 0xffe0 && code <= 0xffe6) ||
89
+ (code >= 0x1f300 && code <= 0x1f64f) ||
90
+ (code >= 0x1f900 && code <= 0x1f9ff) ||
91
+ (code >= 0x20000 && code <= 0x3fffd));
92
+ }
93
+ function isZeroWidth(code) {
94
+ return (code >= 0x0300 && code <= 0x036f) || code === 0x200d || (code >= 0xfe00 && code <= 0xfe0f) || (code >= 0x1160 && code <= 0x11ff);
95
+ }
96
+ function charWidth(char) {
97
+ const code = char.codePointAt(0) ?? 0;
98
+ if (code < 0x20 || isZeroWidth(code))
99
+ return 0;
100
+ return isWide(code) ? 2 : 1;
101
+ }
102
+ export function cellWidth(value) {
103
+ let width = 0;
104
+ for (const char of value)
105
+ width += charWidth(char);
106
+ return width;
107
+ }
108
+ /** Cuts `value` to at most `max` cells, ending in an ellipsis when anything was dropped. */
109
+ export function truncateCells(value, max) {
110
+ const clean = value.replace(/\s+/gu, " ").trim();
111
+ if (cellWidth(clean) <= max)
112
+ return clean;
113
+ if (max <= 0)
114
+ return "";
115
+ let out = "";
116
+ let width = 0;
117
+ for (const char of clean) {
118
+ const next = charWidth(char);
119
+ if (width + next > max - 1)
120
+ break;
121
+ out += char;
122
+ width += next;
123
+ }
124
+ return out + "…";
125
+ }
126
+ export function padCells(value, width) {
127
+ return value + " ".repeat(Math.max(0, width - cellWidth(value)));
128
+ }
129
+ export function lineWidth(line) {
130
+ return line.reduce((total, segment) => total + cellWidth(segment.text), 0);
131
+ }
132
+ export function gradientAt(stops, position) {
133
+ if (stops.length === 0)
134
+ return "#ffffff";
135
+ if (stops.length === 1)
136
+ return stops[0];
137
+ const scaled = Math.max(0, Math.min(1, position)) * (stops.length - 1);
138
+ const index = Math.min(stops.length - 2, Math.floor(scaled));
139
+ return mixHex(stops[index], stops[index + 1], scaled - index);
140
+ }
141
+ /** A light pass along a row: `sweep` is its centre in 0..1 of the row, or negative when off. */
142
+ function shine(color, palette, position, sweep, reach) {
143
+ if (sweep < 0)
144
+ return color;
145
+ const distance = Math.abs(position - sweep);
146
+ return distance < reach ? mixHex(color, palette.text, (1 - distance / reach) * 0.75) : color;
147
+ }
148
+ export function shimmerAt(frame) {
149
+ const t = frame.celebrate;
150
+ if (t === undefined || t < motionTiming.shimmerAt || t > motionTiming.shimmerAt + motionTiming.shimmer)
151
+ return -1;
152
+ return -0.2 + 1.4 * seg(t, motionTiming.shimmerAt, motionTiming.shimmer);
153
+ }
154
+ const eighths = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
155
+ /**
156
+ * A bar of `cells` cells filled to `ratio` with eighth-cell blocks, each cell its own step of the
157
+ * primary-to-accent gradient. The empty track is a thin muted rule, never a dither.
158
+ */
159
+ export function progressBar(ratio, cells, palette, sweep = -1) {
160
+ const clamped = Math.max(0, Math.min(1, ratio));
161
+ const exact = clamped * cells;
162
+ let full = Math.floor(exact);
163
+ let partial = Math.round((exact - full) * 8);
164
+ if (partial === 8) {
165
+ full += 1;
166
+ partial = 0;
167
+ }
168
+ const stops = [palette.primary, palette.accent];
169
+ const color = (index) => {
170
+ const position = cells <= 1 ? 0 : index / (cells - 1);
171
+ return shine(gradientAt(stops, position), palette, position, sweep, 0.18);
172
+ };
173
+ const segments = [];
174
+ let used = Math.min(full, cells);
175
+ for (let index = 0; index < used; index += 1)
176
+ segments.push({ text: "█", fg: color(index) });
177
+ if (partial > 0 && used < cells) {
178
+ segments.push({ text: eighths[partial], fg: color(used) });
179
+ used += 1;
180
+ }
181
+ if (used < cells)
182
+ segments.push({ text: "─".repeat(cells - used), fg: palette.borderSubtle });
183
+ return segments;
184
+ }
185
+ function microBar(ratio, palette) {
186
+ const filled = Math.max(0, Math.min(5, Math.round(Math.max(0, Math.min(1, ratio)) * 5)));
187
+ const segments = [];
188
+ for (let index = 0; index < 5; index += 1) {
189
+ segments.push(index < filled ? { text: "▰", fg: gradientAt([palette.primary, palette.accent], index / 4) } : { text: "▱", fg: palette.borderSubtle });
190
+ }
191
+ return segments;
192
+ }
193
+ function countText(view) {
194
+ return view.progress === undefined ? "" : `${view.progress.done}/${view.progress.total}`;
195
+ }
196
+ /** The step state to draw: during the completion moment WORK and REVIEW flip to done one after the other. */
197
+ function shownState(step, view, frame) {
198
+ const t = frame.celebrate;
199
+ if (t === undefined || step.state !== "done" || step.id === "plan")
200
+ return { state: step.state, doneMix: step.state === "done" ? 1 : 0 };
201
+ const at = step.id === "work" ? motionTiming.workDoneAt : motionTiming.reviewDoneAt;
202
+ const mix = ease.inOutCubic(seg(t, at, motionTiming.crossfade));
203
+ if (t < at)
204
+ return { state: step.id === "work" ? "now" : "todo", doneMix: 0 };
205
+ return { state: "done", doneMix: mix };
206
+ }
207
+ function pillStyle(state, palette, warn) {
208
+ if (state === "done")
209
+ return { fg: palette.success, bg: mixHex(palette.success, palette.background ?? palette.mixBase, 0.72), bold: false };
210
+ if (state === "todo" || state === "skip")
211
+ return { fg: palette.muted, ...(palette.panel === undefined ? {} : { bg: palette.panel }), bold: false };
212
+ const level = state === "warn" ? Math.max(warn, 0.5) : warn;
213
+ return { fg: palette.onFill, bg: mixHex(palette.info, palette.warning, level), bold: true };
214
+ }
215
+ function blendStyle(from, to, amount, palette) {
216
+ if (amount <= 0)
217
+ return from;
218
+ if (amount >= 1)
219
+ return to;
220
+ const bgFrom = from.bg ?? palette.mixBase;
221
+ const bgTo = to.bg ?? palette.mixBase;
222
+ return { fg: mixHex(from.fg, to.fg, amount), bg: mixHex(bgFrom, bgTo, amount), bold: amount < 0.5 ? from.bold : to.bold };
223
+ }
224
+ function stepLabel(step, state, view) {
225
+ const name = step.id.toUpperCase();
226
+ const count = step.id === "work" && view.progress !== undefined ? countText(view) : "";
227
+ if (state === "done")
228
+ return [name, count, "✓"].filter((part) => part !== "").join(" ");
229
+ if (state === "skip")
230
+ return `${name} –`;
231
+ if (state === "warn")
232
+ return [name, "⚑", count].filter((part) => part !== "").join(" ");
233
+ return [name, state === "now" ? count : ""].filter((part) => part !== "").join(" ");
234
+ }
235
+ function stepPill(step, view, frame, palette) {
236
+ const { state, doneMix } = shownState(step, view, frame);
237
+ const before = pillStyle(step.id === "work" ? "now" : "todo", palette, frame.warn);
238
+ const style = state === "done" && doneMix < 1 ? blendStyle(before, pillStyle("done", palette, frame.warn), doneMix, palette) : pillStyle(state, palette, frame.warn);
239
+ return { text: ` ${stepLabel(step, state, view)} `, fg: style.fg, ...(style.bg === undefined ? {} : { bg: style.bg }), bold: style.bold };
240
+ }
241
+ function brandPill(palette, sweep) {
242
+ const chars = [" ", "🔥", " ", "L", "I", "T", " "];
243
+ return chars.map((char, index) => ({ text: char, fg: palette.onFill, bg: shine(palette.primary, palette, index / (chars.length - 1), sweep, 0.3), bold: true }));
244
+ }
245
+ /** The chip's pill groups for a tier; the five-segment bar is appended unless `withMicroBar` is false. */
246
+ export function chipGroups(view, frame, palette, tier, withMicroBar = true) {
247
+ const sweep = shimmerAt(frame);
248
+ const space = { text: " ", fg: palette.muted };
249
+ const separator = { text: " › ", fg: palette.borderActive };
250
+ const flame = { text: "🔥", fg: palette.text };
251
+ const groups = [];
252
+ if (tier === "full") {
253
+ groups.push(brandPill(palette, sweep));
254
+ view.steps.forEach((step, index) => groups.push([index === 0 ? space : separator, stepPill(step, view, frame, palette)]));
255
+ }
256
+ else if (tier === "medium") {
257
+ groups.push([flame]);
258
+ const current = view.steps.findIndex((step) => step.state === "now" || step.state === "warn");
259
+ const from = current >= 0 ? current : view.complete ? 1 : 0;
260
+ view.steps.slice(from, from + 2).forEach((step, index) => groups.push([index === 0 ? space : separator, stepPill(step, view, frame, palette)]));
261
+ }
262
+ else {
263
+ groups.push([flame]);
264
+ const current = view.steps.find((step) => step.state === "now" || step.state === "warn") ?? view.steps[1];
265
+ const count = countText(view);
266
+ const done = view.complete && (frame.celebrate === undefined || frame.celebrate >= motionTiming.workDoneAt);
267
+ const value = done ? `✓ ${count}`.trim() : current.state === "warn" ? `⚑ ${count}`.trim() : count || current.id.toUpperCase();
268
+ const style = pillStyle(done ? "done" : current.state === "warn" ? "warn" : "now", palette, frame.warn);
269
+ groups.push([space, { text: ` ${value} `, fg: style.fg, ...(style.bg === undefined ? {} : { bg: style.bg }), bold: style.bold }]);
270
+ }
271
+ if (withMicroBar && view.progress !== undefined)
272
+ groups.push([space, ...microBar(frame.barRatio, palette)]);
273
+ return groups;
274
+ }
275
+ export function groupsWidth(groups) {
276
+ return groups.reduce((total, group) => total + lineWidth(group), 0);
277
+ }
278
+ export function fade(segment, alpha, palette) {
279
+ if (alpha >= 1)
280
+ return segment;
281
+ return {
282
+ ...segment,
283
+ fg: mixHex(palette.mixBase, segment.fg, alpha),
284
+ ...(segment.bg === undefined ? {} : { bg: mixHex(palette.mixBase, segment.bg, alpha) })
285
+ };
286
+ }
287
+ const sparkGlyphs = ["·", "✦", "✧", "˚"];
288
+ const chipSparks = [
289
+ { at: 4, start: 220, color: "warning" },
290
+ { at: 1, start: 300, color: "primary" },
291
+ { at: 5, start: 380, color: "accent" },
292
+ { at: 2, start: 470, color: "primary" }
293
+ ];
294
+ const chipSparkLife = 420;
295
+ export const chipSparkWidth = 7;
296
+ export function chipSparkCells(t, palette) {
297
+ const last = chipSparks[chipSparks.length - 1].start + chipSparkLife;
298
+ if (t > last)
299
+ return undefined;
300
+ const cells = Array.from({ length: chipSparkWidth }, () => ({ text: " ", fg: palette.muted }));
301
+ for (const spark of chipSparks) {
302
+ if (t < spark.start || t > spark.start + chipSparkLife)
303
+ continue;
304
+ const life = (t - spark.start) / chipSparkLife;
305
+ cells[spark.at] = { text: sparkGlyphs[Math.min(3, Math.floor(life * 4))], fg: mixHex(palette.mixBase, palette[spark.color], Math.sin(Math.PI * life)) };
306
+ }
307
+ return cells;
308
+ }
309
+ export function chipTier(view, frame, palette, budget) {
310
+ for (const tier of ["full", "medium"]) {
311
+ if (groupsWidth(chipGroups(view, frame, palette, tier)) <= budget)
312
+ return tier;
313
+ }
314
+ return "tight";
315
+ }
316
+ /**
317
+ * The phase chip on the right of the prompt's agent/model row: `🔥 LIT` `PLAN` › `WORK n/m` › `REVIEW`
318
+ * and a five-segment bar, shedding labels before values as `budget` shrinks. Idle sessions render nothing.
319
+ */
320
+ export function chipLine(view, frame, palette, budget) {
321
+ if (!view.active)
322
+ return [];
323
+ const tier = chipTier(view, frame, palette, budget);
324
+ const groups = chipGroups(view, frame, palette, tier);
325
+ const out = [];
326
+ if (frame.celebrate !== undefined && groupsWidth(groups) + chipSparkWidth <= budget)
327
+ out.push(...(chipSparkCells(frame.celebrate, palette) ?? []));
328
+ groups.forEach((group, index) => {
329
+ const start = index * motionTiming.stagger;
330
+ const alpha = frame.chipEnter ? ease.outCubic(seg(frame.t, start, motionTiming.entry)) : 1;
331
+ for (const segment of group)
332
+ out.push(fade(segment, alpha, palette));
333
+ });
334
+ return out;
335
+ }
336
+ // The tiny LIT, drawn as text so each column takes its own colour from the warning-primary-accent ramp.
337
+ const logoRows = ["█ █ ▀█▀", "█▄▄ █ █ "];
338
+ const logoWidth = 9;
339
+ function logoLine(row, palette, sweep) {
340
+ const stops = [palette.warning, palette.primary, palette.accent];
341
+ return [...logoRows[row]].map((char, index) => {
342
+ const position = index / (logoWidth - 1);
343
+ return { text: char, fg: shine(gradientAt(stops, position), palette, position, sweep, 0.3), bold: true };
344
+ });
345
+ }
346
+ /** The header status: a glyph and words, or the COMPLETE badge that pops in and settles to green text. */
347
+ export function statusSegments(view, frame, palette) {
348
+ const status = missionStatus(view);
349
+ if (status.tone === "warning") {
350
+ return { segments: [{ text: `${status.glyph} ${status.text}`, fg: mixHex(palette.info, palette.warning, Math.max(frame.warn, 0.5)), bold: true }], offset: 0 };
351
+ }
352
+ if (status.tone === "info")
353
+ return { segments: [{ text: `${status.glyph} ${status.text}`, fg: palette.info }], offset: 0 };
354
+ const t = frame.celebrate ?? Number.POSITIVE_INFINITY;
355
+ if (t < motionTiming.popAt) {
356
+ const alpha = 1 - ease.inOutCubic(seg(t, 0, motionTiming.crossfade));
357
+ return { segments: [fade({ text: "● active", fg: palette.info }, alpha, palette)], offset: 0 };
358
+ }
359
+ const progress = seg(t, motionTiming.popAt, motionTiming.pop);
360
+ // A filled pill pops in with an overshoot, holds, then settles to plain green text.
361
+ const filled = 1 - ease.inOutCubic(seg(t, motionTiming.settleAt, motionTiming.crossfade));
362
+ const pill = {
363
+ text: " ✓ COMPLETE ",
364
+ fg: mixHex(palette.success, palette.onFill, filled),
365
+ ...(filled > 0 ? { bg: mixHex(palette.panel ?? palette.mixBase, palette.success, filled) } : {}),
366
+ bold: true
367
+ };
368
+ return { segments: [fade(pill, progress <= 0 ? 0 : ease.outCubic(progress), palette)], offset: Math.max(-1, slideIn(progress)) };
369
+ }
370
+ function stepsRow(view, frame, palette) {
371
+ const out = [];
372
+ let previousDone = false;
373
+ view.steps.forEach((step, index) => {
374
+ const { state, doneMix } = shownState(step, view, frame);
375
+ const live = mixHex(palette.info, palette.warning, state === "warn" ? Math.max(frame.warn, 0.5) : frame.warn);
376
+ const color = state === "done" ? mixHex(step.id === "work" ? palette.info : palette.muted, palette.success, doneMix) : state === "now" || state === "warn" ? live : palette.muted;
377
+ const glyph = state === "done" ? "✓" : state === "skip" ? "–" : state === "warn" ? "⚑" : state === "now" ? "●" : "○";
378
+ if (index > 0)
379
+ out.push({ text: " ── ", fg: previousDone ? palette.success : palette.borderSubtle });
380
+ out.push({ text: `${glyph} ${step.id.toUpperCase()}`, fg: color, bold: state === "now" || state === "warn" });
381
+ previousDone = state === "done";
382
+ });
383
+ return out;
384
+ }
385
+ function criterionSegment(id, status, palette) {
386
+ if (status === "pass")
387
+ return { text: `✓${id}`, fg: palette.success };
388
+ if (status === "fail")
389
+ return { text: `✗${id}`, fg: palette.error };
390
+ if (status === "blocked")
391
+ return { text: `⚑${id}`, fg: palette.warning };
392
+ return { text: `○${id}`, fg: palette.muted };
393
+ }
394
+ function evidenceColor(kind, palette) {
395
+ if (kind === "green" || kind === "scenario")
396
+ return palette.success;
397
+ if (kind === "red")
398
+ return palette.error;
399
+ if (kind === "checkpoint")
400
+ return palette.accent;
401
+ return palette.info;
402
+ }
403
+ function toCells(line, width, palette) {
404
+ const cells = [];
405
+ for (const segment of line) {
406
+ for (const char of segment.text) {
407
+ const w = charWidth(char);
408
+ if (w === 0 && cells.length > 0) {
409
+ cells[cells.length - 1].text += char;
410
+ continue;
411
+ }
412
+ cells.push({ text: char, fg: segment.fg, ...(segment.bg === undefined ? {} : { bg: segment.bg }), ...(segment.bold ? { bold: true } : {}), width: w });
413
+ }
414
+ }
415
+ let used = cells.reduce((total, cell) => total + cell.width, 0);
416
+ while (used < width) {
417
+ cells.push({ text: " ", fg: palette.muted, width: 1 });
418
+ used += 1;
419
+ }
420
+ return cells;
421
+ }
422
+ function fromCells(cells) {
423
+ const out = [];
424
+ for (const cell of cells) {
425
+ const last = out[out.length - 1];
426
+ if (last !== undefined && last.fg === cell.fg && last.bg === cell.bg && (last.bold ?? false) === (cell.bold ?? false)) {
427
+ out[out.length - 1] = { ...last, text: last.text + cell.text };
428
+ }
429
+ else {
430
+ out.push({ text: cell.text, fg: cell.fg, ...(cell.bg === undefined ? {} : { bg: cell.bg }), ...(cell.bold ? { bold: true } : {}) });
431
+ }
432
+ }
433
+ return out;
434
+ }
435
+ /** Shifts a row right by `offset` cells and cuts it back to `width`, keeping wide glyphs whole. */
436
+ function shiftRow(line, offset, width) {
437
+ if (offset <= 0)
438
+ return line;
439
+ const shifted = [{ text: " ".repeat(offset), fg: line[0]?.fg ?? "#000000" }, ...line];
440
+ const out = [];
441
+ let used = 0;
442
+ for (const segment of shifted) {
443
+ const room = width - used;
444
+ if (room <= 0)
445
+ break;
446
+ if (cellWidth(segment.text) <= room) {
447
+ out.push(segment);
448
+ used += cellWidth(segment.text);
449
+ continue;
450
+ }
451
+ let text = "";
452
+ let taken = 0;
453
+ for (const char of segment.text) {
454
+ const w = charWidth(char);
455
+ if (taken + w > room)
456
+ break;
457
+ text += char;
458
+ taken += w;
459
+ }
460
+ if (text !== "")
461
+ out.push({ ...segment, text });
462
+ break;
463
+ }
464
+ return out;
465
+ }
466
+ const embers = Array.from({ length: 18 }, (_unused, index) => {
467
+ const rnd = (n) => {
468
+ const x = Math.sin(index * 127.1 + n * 311.7) * 43758.5453;
469
+ return x - Math.floor(x);
470
+ };
471
+ return {
472
+ col: Math.floor(rnd(1) * panelInnerWidth),
473
+ row: 2 + Math.floor(rnd(2) * 7),
474
+ start: 200 + Math.floor(rnd(3) * 340),
475
+ life: 400 + Math.floor(rnd(4) * 240),
476
+ rise: 120 + Math.floor(rnd(5) * 80),
477
+ drift: rnd(6) < 0.5 ? 0 : rnd(7) < 0.5 ? -1 : 1,
478
+ color: ["warning", "primary", "accent"][index % 3]
479
+ };
480
+ });
481
+ /** Embers rise through the empty cells of the panel during the completion moment and are gone by 1.2 s. */
482
+ function withEmbers(rows, t, palette) {
483
+ const grid = rows.map((row) => toCells(row, panelInnerWidth, palette));
484
+ let touched = false;
485
+ for (const ember of embers) {
486
+ if (t < ember.start || t > ember.start + ember.life)
487
+ continue;
488
+ const life = (t - ember.start) / ember.life;
489
+ const up = Math.floor((t - ember.start) / ember.rise);
490
+ const row = ember.row - up;
491
+ const col = ember.col + (up > 1 ? ember.drift : 0);
492
+ if (row < 0 || row >= grid.length)
493
+ continue;
494
+ let x = 0;
495
+ for (const cell of grid[row]) {
496
+ if (x === col) {
497
+ if (cell.text === " " && cell.width === 1) {
498
+ cell.text = sparkGlyphs[Math.min(3, Math.floor(life * 4))];
499
+ cell.fg = mixHex(palette.mixBase, palette[ember.color], Math.sin(Math.PI * life));
500
+ touched = true;
501
+ }
502
+ break;
503
+ }
504
+ x += cell.width;
505
+ if (x > col)
506
+ break;
507
+ }
508
+ }
509
+ return touched ? grid.map(fromCells) : rows;
510
+ }
511
+ /**
512
+ * The sidebar mission panel: a rounded box whose border carries the state (accent while working,
513
+ * warning on a warning, success when complete), a tiny LIT header, the goal title, the phase line,
514
+ * the bar, the next item, criteria, warnings and the evidence timeline. Rows are at most
515
+ * `panelInnerWidth` cells.
516
+ */
517
+ export function missionPanel(view, frame, palette) {
518
+ if (!view.active)
519
+ return undefined;
520
+ const width = panelInnerWidth;
521
+ const sweep = shimmerAt(frame);
522
+ const keys = missionRowKeys(view);
523
+ const rows = new Map();
524
+ rows.set("h1", [...logoLine(0, palette, sweep), { text: " ", fg: palette.muted }, { text: truncateCells(view.discipline, width - logoWidth - 2), fg: palette.muted }]);
525
+ const status = statusSegments(view, frame, palette);
526
+ const statusStart = logoWidth + 2 + status.offset;
527
+ rows.set("h2", [...logoLine(1, palette, sweep), { text: " ".repeat(Math.max(0, statusStart - logoWidth)), fg: palette.muted }, ...status.segments]);
528
+ rows.set("b1", []);
529
+ if (view.goal !== undefined)
530
+ rows.set("title", [{ text: truncateCells(view.goal.title, width), fg: palette.text, bold: true }]);
531
+ rows.set("steps", stepsRow(view, frame, palette));
532
+ if (view.progress !== undefined)
533
+ rows.set("bar", progressBar(frame.barRatio, width, palette, sweep));
534
+ rows.set("b2", []);
535
+ if (view.next !== undefined)
536
+ rows.set("next", [{ text: "next ", fg: palette.muted }, { text: truncateCells(`▸ ${view.next}`, width - 5), fg: palette.text }]);
537
+ if (view.goal !== undefined) {
538
+ const criteria = [{ text: "crit", fg: palette.muted }];
539
+ for (const criterion of view.goal.criteria) {
540
+ criteria.push({ text: " ", fg: palette.muted }, criterionSegment(criterion.id, criterion.status, palette));
541
+ }
542
+ rows.set("crit", lineWidth(criteria) <= width ? criteria : [{ text: "crit ", fg: palette.muted }, { text: `${view.goal.passed}/${view.goal.total} pass`, fg: palette.muted }]);
543
+ }
544
+ for (const warning of view.warnings) {
545
+ const color = warning.kind === "retry" ? mixHex(palette.warning, palette.error, 0.5) : palette.warning;
546
+ rows.set(`warn:${warning.kind}:${warning.text}`, [{ text: "⚑ ", fg: color, bold: true }, { text: truncateCells(warning.text, width - 2), fg: color }]);
547
+ }
548
+ rows.set("b3", []);
549
+ rows.set("evh", [{ text: "evidence ", fg: palette.muted }, { text: "─".repeat(width - 9), fg: palette.borderSubtle }]);
550
+ view.timeline.forEach((event, index) => {
551
+ const head = padCells(event.ago, 4) + " ";
552
+ const kind = padCells(truncateCells(event.kind, 10), 10) + " ";
553
+ rows.set(`ev${index}`, [
554
+ { text: head, fg: palette.muted },
555
+ { text: kind, fg: evidenceColor(event.kind, palette) },
556
+ { text: truncateCells(event.label, Math.max(0, width - cellWidth(head) - cellWidth(kind))), fg: palette.muted }
557
+ ]);
558
+ });
559
+ let lines = keys.map((key) => {
560
+ const row = rows.get(key) ?? [];
561
+ const start = rowEntryStart(frame, key);
562
+ if (start === undefined)
563
+ return row;
564
+ const duration = key.startsWith("warn:") ? motionTiming.noticeIn : motionTiming.entry;
565
+ const progress = seg(frame.t, start, duration);
566
+ if (progress <= 0)
567
+ return [];
568
+ const alpha = ease.outCubic(progress);
569
+ return shiftRow(row.map((segment) => fade(segment, alpha, palette)), Math.max(0, slideIn(progress)), width);
570
+ });
571
+ if (frame.celebrate !== undefined)
572
+ lines = withEmbers(lines, frame.celebrate, palette);
573
+ const enter = frame.chipEnter ? ease.outCubic(seg(frame.t, 0, motionTiming.entry)) : 1;
574
+ const done = view.complete ? (frame.celebrate === undefined ? 1 : ease.inOutCubic(seg(frame.celebrate, 0, motionTiming.crossfade))) : 0;
575
+ const border = mixHex(palette.mixBase, mixHex(mixHex(palette.accent, palette.warning, frame.warn), palette.success, done), enter);
576
+ const phase = view.complete ? "COMPLETE" : view.phase.toUpperCase();
577
+ const flagged = view.warnings.length > 0 && !view.complete ? " ⚑" : "";
578
+ const bottom = view.goal !== undefined
579
+ ? ` ${view.goal.passed}/${view.goal.total} criteria${view.complete ? " ✓" : ""} `
580
+ : view.progress !== undefined
581
+ ? ` ${view.progress.done}/${view.progress.total} tasks${view.complete ? " ✓" : ""} `
582
+ : undefined;
583
+ return {
584
+ border,
585
+ title: ` 🔥 LIT ▸ ${phase}${flagged} `,
586
+ titleColor: mixHex(palette.mixBase, palette.text, enter),
587
+ ...(bottom === undefined ? {} : { bottomTitle: bottom }),
588
+ ...(palette.panel === undefined ? {} : { background: palette.panel }),
589
+ rows: lines
590
+ };
591
+ }
592
+ /** The home-screen line under the prompt: `🔥 lit ready · last: <goal> ✓ n/m`, labels shed first when narrow. */
593
+ export function homeLine(last, palette, budget) {
594
+ if (last === undefined || last.title === "")
595
+ return [];
596
+ const mark = last.complete
597
+ ? { text: ` ✓ ${last.passed}/${last.total}`, fg: palette.success }
598
+ : { text: ` ● ${last.passed}/${last.total}`, fg: palette.info };
599
+ const full = [
600
+ { text: "🔥 ", fg: palette.text },
601
+ { text: "lit", fg: palette.primary, bold: true },
602
+ { text: " ready", fg: palette.text },
603
+ { text: " · ", fg: palette.muted },
604
+ { text: "last: ", fg: palette.muted },
605
+ { text: last.title, fg: palette.text },
606
+ mark
607
+ ];
608
+ if (lineWidth(full) <= budget)
609
+ return full;
610
+ const head = [{ text: "🔥 ", fg: palette.text }, { text: "lit", fg: palette.primary, bold: true }, { text: " · ", fg: palette.muted }];
611
+ const room = Math.max(1, budget - lineWidth(head) - cellWidth(mark.text));
612
+ return [...head, { text: truncateCells(last.title, room), fg: palette.text }, mark];
613
+ }
package/dist/tui.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ import type { TuiPluginModule } from "@opencode-ai/plugin/tui";
2
+ export declare const litOpenCodeTuiPluginId = "litopencode";
3
+ declare const plugin: TuiPluginModule & {
4
+ readonly id: string;
5
+ };
6
+ export default plugin;