@litfamily/litopencode 1.0.20 → 1.0.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README-Ko-KR.md +6 -6
  3. package/README.md +6 -6
  4. package/dist/agents/registry.js +2 -2
  5. package/dist/cli/doctor.d.ts +5 -0
  6. package/dist/cli/doctor.js +27 -6
  7. package/dist/cli/install-config.d.ts +6 -0
  8. package/dist/cli/install-config.js +32 -2
  9. package/dist/cli/install.js +4 -1
  10. package/dist/cli/model-catalog.d.ts +6 -1
  11. package/dist/cli/model-catalog.js +32 -15
  12. package/dist/cli/model-routing.js +14 -9
  13. package/dist/cli/types.d.ts +1 -1
  14. package/dist/config.d.ts +2 -0
  15. package/dist/config.js +10 -2
  16. package/dist/model-route-policy.d.ts +9 -1
  17. package/dist/model-route-policy.js +46 -47
  18. package/dist/tui/mission-band.d.ts +13 -0
  19. package/dist/tui/mission-band.js +63 -25
  20. package/dist/tui/mission-model.d.ts +69 -3
  21. package/dist/tui/mission-model.js +155 -29
  22. package/dist/tui/mission-motion.d.ts +10 -1
  23. package/dist/tui/mission-motion.js +17 -7
  24. package/dist/tui/mission-source.d.ts +36 -6
  25. package/dist/tui/mission-source.js +167 -13
  26. package/dist/tui/mission-text.d.ts +34 -9
  27. package/dist/tui/mission-text.js +169 -74
  28. package/dist/tui.js +38 -5
  29. package/docs/assets/readme/badge-version.svg +1 -1
  30. package/docs/migration.md +1 -1
  31. package/docs/reference-Ko-KR.md +81 -26
  32. package/docs/reference.md +92 -28
  33. package/package.json +1 -1
  34. package/skills/lit-pptx/package-lock.json +167 -119
  35. package/skills/lit-pptx/package.json +1 -1
  36. package/skills/managed-skill-manifest.json +4 -4
  37. package/skills/readme-studio/templates/hyperframes/package-lock.json +169 -121
  38. package/skills/readme-studio/templates/remotion/package-lock.json +3 -3
  39. package/tools/check-pack-payload.mjs +2 -2
@@ -2,7 +2,7 @@
2
2
  // (Hangul and emoji take two cells), truncation, the gradient bar, and the coloured segments the TUI
3
3
  // renders as spans. Every frame of motion is drawn from a MotionFrame, so all of it is testable here.
4
4
  import { ease, mixHex, rowEntryStart, seg, slideIn, motionTiming } from "./mission-motion.js";
5
- import { missionRowKeys, missionStatus } from "./mission-model.js";
5
+ import { currentStepIndex, missionRowKeys, missionStatus, stepName } from "./mission-model.js";
6
6
  /** Panel box width in cells, and the content width inside its border and one cell of padding per side. */
7
7
  export const panelWidth = 36;
8
8
  export const panelInnerWidth = panelWidth - 4;
@@ -49,6 +49,7 @@ export function paletteFromTheme(theme, mode = "dark") {
49
49
  const neutral = mode === "light" ? "#f2f2f2" : "#1c1c1c";
50
50
  return {
51
51
  primary: pick("primary", "#ff6337"),
52
+ secondary: pick("secondary", "#ff2d95"),
52
53
  accent: pick("accent", "#b48cff"),
53
54
  info: pick("info", "#0aa6c2"),
54
55
  warning: pick("warning", "#ffaf3c"),
@@ -182,27 +183,34 @@ export function progressBar(ratio, cells, palette, sweep = -1) {
182
183
  segments.push({ text: "─".repeat(cells - used), fg: palette.borderSubtle });
183
184
  return segments;
184
185
  }
185
- function microBar(ratio, palette) {
186
- const filled = Math.max(0, Math.min(5, Math.round(Math.max(0, Math.min(1, ratio)) * 5)));
186
+ /** The stage colour ramp: the theme's primary through secondary to accent, so every theme keeps its own feel. */
187
+ export function stageColor(index, count, palette) {
188
+ return gradientAt([palette.primary, palette.secondary, palette.accent], count <= 1 ? 0 : index / (count - 1));
189
+ }
190
+ function microBar(ratio, palette, cells = 5) {
191
+ const filled = Math.max(0, Math.min(cells, Math.round(Math.max(0, Math.min(1, ratio)) * cells)));
187
192
  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 });
193
+ for (let index = 0; index < cells; index += 1) {
194
+ segments.push(index < filled ? { text: "▰", fg: gradientAt([palette.primary, palette.secondary, palette.accent], cells <= 1 ? 0 : index / (cells - 1)) } : { text: "▱", fg: palette.borderSubtle });
190
195
  }
191
196
  return segments;
192
197
  }
193
198
  function countText(view) {
194
199
  return view.progress === undefined ? "" : `${view.progress.done}/${view.progress.total}`;
195
200
  }
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) {
201
+ /**
202
+ * The step state to draw. During the completion moment the stage that was running and every stage
203
+ * after it flip to done one after the other, 60 ms apart.
204
+ */
205
+ function shownState(step, index, frame) {
198
206
  const t = frame.celebrate;
199
- if (t === undefined || step.state !== "done" || step.id === "plan")
207
+ const from = frame.celebrateFrom;
208
+ if (t === undefined || from === undefined || step.state !== "done" || index < from)
200
209
  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));
210
+ const at = motionTiming.workDoneAt + (index - from) * (motionTiming.reviewDoneAt - motionTiming.workDoneAt);
203
211
  if (t < at)
204
- return { state: step.id === "work" ? "now" : "todo", doneMix: 0 };
205
- return { state: "done", doneMix: mix };
212
+ return { state: index === from ? "now" : "todo", doneMix: 0 };
213
+ return { state: "done", doneMix: ease.inOutCubic(seg(t, at, motionTiming.crossfade)) };
206
214
  }
207
215
  function pillStyle(state, palette, warn) {
208
216
  if (state === "done")
@@ -212,64 +220,120 @@ function pillStyle(state, palette, warn) {
212
220
  const level = state === "warn" ? Math.max(warn, 0.5) : warn;
213
221
  return { fg: palette.onFill, bg: mixHex(palette.info, palette.warning, level), bold: true };
214
222
  }
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(" ");
223
+ /** A light pass over the running stage: once when the map moves forward, and during the completion moment. */
224
+ export function stageFlash(frame) {
225
+ if (frame.stageFrom !== undefined && frame.t <= motionTiming.shimmer)
226
+ return -0.2 + 1.4 * seg(frame.t, 0, motionTiming.shimmer);
227
+ return shimmerAt(frame);
228
+ }
229
+ /**
230
+ * The colour of the connector into stage `index`. It carries the stage ramp once the stage before it is
231
+ * behind the map, and fills in turn, 60 ms apart, when the map has just moved forward.
232
+ */
233
+ export function linkColor(view, index, frame, palette, idle) {
234
+ const before = view.steps[index - 1];
235
+ if (before === undefined)
236
+ return idle;
237
+ const state = shownState(before, index - 1, frame).state;
238
+ if (state !== "done" && state !== "skip")
239
+ return idle;
240
+ const color = stageColor(index - 0.5, view.steps.length, palette);
241
+ if (frame.stageFrom === undefined || index <= frame.stageFrom)
242
+ return color;
243
+ return mixHex(idle, color, ease.outCubic(seg(frame.t, (index - frame.stageFrom - 1) * motionTiming.stagger, motionTiming.entry)));
244
+ }
245
+ /**
246
+ * One stage of the breadcrumb. Behind the map a stage is its name in its ramp colour with `✓` (or `–`
247
+ * when skipped); ahead it is a muted name; the running stage is a filled capital pill followed by an
248
+ * optional `bar`-cell bar and the count.
249
+ */
250
+ export function crumb(view, index, frame, palette, bar, flash = -1) {
251
+ const step = view.steps[index];
252
+ const { state, doneMix } = shownState(step, index, frame);
253
+ const name = stepName(step.id, view);
254
+ const counted = step.id === "work" && view.progress !== undefined;
255
+ if (state === "done") {
256
+ const color = stageColor(index, view.steps.length, palette);
257
+ return [{ text: [name, counted ? countText(view) : "", "✓"].filter((part) => part !== "").join(" "), fg: doneMix >= 1 ? color : mixHex(index === frame.celebrateFrom ? palette.info : palette.muted, color, doneMix) }];
258
+ }
229
259
  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(" ");
260
+ return [{ text: `${name} –`, fg: palette.muted }];
261
+ if (state === "todo")
262
+ return [{ text: name, fg: palette.muted }];
263
+ const style = pillStyle(state, palette, frame.warn);
264
+ const label = ` ${name.toUpperCase()}${state === "warn" ? " ⚑" : ""} `;
265
+ const chars = [...label];
266
+ const pill = flash < 0
267
+ ? [{ text: label, fg: style.fg, ...(style.bg === undefined ? {} : { bg: style.bg }), bold: style.bold }]
268
+ : chars.map((char, position) => ({ text: char, fg: style.fg, bg: shine(style.bg ?? palette.info, palette, position / (chars.length - 1), flash, 0.3), bold: true }));
269
+ if (!counted)
270
+ return pill;
271
+ return [...pill, ...(bar > 0 ? [{ text: " ", fg: palette.muted }, ...microBar(frame.barRatio, palette, bar)] : []), { text: ` ${countText(view)}`, fg: palette.text, bold: true }];
272
+ }
273
+ const spinnerFrames = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
274
+ function clockText(seconds) {
275
+ const minutes = Math.floor(seconds / 60);
276
+ const rest = String(seconds % 60).padStart(2, "0");
277
+ return minutes < 60 ? `${minutes}:${rest}` : `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, "0")}:${rest}`;
234
278
  }
235
- export 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 };
279
+ /**
280
+ * The live step: the running tool's name and how long it has run, `⠹ bash · 0:48`. The spinner moves
281
+ * with the elapsed second, so it changes only when the time does. Nothing of the tool's input is shown.
282
+ */
283
+ export function liveSegments(view, frame, palette, room) {
284
+ if (view.live === undefined || view.complete || room < 4)
285
+ return [];
286
+ const seconds = frame.now === undefined ? undefined : Math.max(0, Math.floor((frame.now - view.live.start) / 1000));
287
+ const spin = [...spinnerFrames][(seconds ?? 0) % 10];
288
+ const time = seconds === undefined ? "" : ` · ${clockText(seconds)}`;
289
+ return [
290
+ { text: `${spin} `, fg: palette.warning },
291
+ { text: truncateCells(view.live.tool, Math.max(1, room - 2 - cellWidth(time))), fg: palette.text },
292
+ { text: time, fg: palette.muted }
293
+ ];
240
294
  }
241
295
  function brandPill(palette, sweep) {
242
296
  const chars = [" ", "🔥", " ", "L", "I", "T", " "];
243
297
  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
298
  }
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);
299
+ /**
300
+ * The chip's groups for a tier. The full tier is the brand and the breadcrumb around the running stage
301
+ * (the one before, the running one with a seven-cell bar, the one after); medium keeps the running
302
+ * stage and the next with a five-cell bar; tight keeps only the count.
303
+ */
304
+ export function chipGroups(view, frame, palette, tier) {
248
305
  const space = { text: " ", fg: palette.muted };
249
- const separator = { text: " › ", fg: palette.borderActive };
250
306
  const flame = { text: "🔥", fg: palette.text };
307
+ const count = view.steps.length;
308
+ const reached = currentStepIndex(view);
309
+ const current = Math.min(count - 1, Math.max(0, reached));
310
+ const flash = stageFlash(frame);
251
311
  const groups = [];
312
+ const trail = (from, to, bar) => {
313
+ for (let index = Math.max(0, from); index <= Math.min(count - 1, to); index += 1) {
314
+ const lead = index === Math.max(0, from) ? space : { text: " › ", fg: linkColor(view, index, frame, palette, palette.borderActive) };
315
+ groups.push([lead, ...crumb(view, index, frame, palette, bar, index === current ? flash : -1)]);
316
+ }
317
+ };
318
+ // A finished map keeps its last stages in view, so the completion moment can flip them in turn.
319
+ const anchor = reached >= count ? count - 2 : current;
252
320
  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)]));
321
+ groups.push(brandPill(palette, shimmerAt(frame)));
322
+ trail(anchor - 1, anchor + 1, 7);
255
323
  }
256
324
  else if (tier === "medium") {
257
325
  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)]));
326
+ trail(anchor, anchor + 1, 5);
261
327
  }
262
328
  else {
263
329
  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);
330
+ const step = view.steps[current];
331
+ const shown = shownState(step, current, frame).state;
332
+ const done = view.complete && shown === "done";
333
+ const value = done ? `✓ ${countText(view)}`.trim() : step.state === "warn" ? `⚑ ${countText(view)}`.trim() : countText(view) || stepName(step.id, view).toUpperCase();
334
+ const style = pillStyle(done ? "done" : step.state === "warn" ? "warn" : "now", palette, frame.warn);
269
335
  groups.push([space, { text: ` ${value} `, fg: style.fg, ...(style.bg === undefined ? {} : { bg: style.bg }), bold: style.bold }]);
270
336
  }
271
- if (withMicroBar && view.progress !== undefined)
272
- groups.push([space, ...microBar(frame.barRatio, palette)]);
273
337
  return groups;
274
338
  }
275
339
  export function groupsWidth(groups) {
@@ -314,14 +378,20 @@ export function chipTier(view, frame, palette, budget) {
314
378
  return "tight";
315
379
  }
316
380
  /**
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.
381
+ * The map chip on the right of the prompt's agent/model row: `🔥 LIT plan ✓ › WORK ▰▰▰▱▱▱▱ 3/7 › review`,
382
+ * then the live step when the full chip leaves room for it, shedding labels before values as `budget`
383
+ * shrinks. Idle sessions render nothing.
319
384
  */
320
385
  export function chipLine(view, frame, palette, budget) {
321
386
  if (!view.active)
322
387
  return [];
323
388
  const tier = chipTier(view, frame, palette, budget);
324
389
  const groups = chipGroups(view, frame, palette, tier);
390
+ if (tier === "full") {
391
+ const live = liveSegments(view, frame, palette, 24);
392
+ if (live.length > 0 && groupsWidth(groups) + 2 + lineWidth(live) <= budget)
393
+ groups.push([{ text: " ", fg: palette.muted }, ...live]);
394
+ }
325
395
  const out = [];
326
396
  if (frame.celebrate !== undefined && groupsWidth(groups) + chipSparkWidth <= budget)
327
397
  out.push(...(chipSparkCells(frame.celebrate, palette) ?? []));
@@ -367,20 +437,35 @@ export function statusSegments(view, frame, palette) {
367
437
  };
368
438
  return { segments: [fade(pill, progress <= 0 ? 0 : ease.outCubic(progress), palette)], offset: Math.max(-1, slideIn(progress)) };
369
439
  }
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;
440
+ /**
441
+ * One stage of the sidebar's vertical map: `◆` behind, `◉` running in bold, `⚑` flagged, `◇` ahead,
442
+ * with the plan's task count or the loop's iteration on the right.
443
+ */
444
+ function stepperRow(view, index, frame, palette, width) {
445
+ const step = view.steps[index];
446
+ const { state, doneMix } = shownState(step, index, frame);
447
+ const counted = step.id === "work" && view.progress !== undefined ? ` ${countText(view)}` : "";
448
+ const name = `${stepName(step.id, view)}${counted}`;
449
+ const note = step.id === "plan" && view.planTasks !== undefined ? `${view.planTasks} tasks` : step.id === "work" && view.iteration !== undefined ? `iter ${view.iteration}` : "";
450
+ const live = mixHex(palette.info, palette.warning, state === "warn" ? Math.max(frame.warn, 0.5) : frame.warn);
451
+ let head;
452
+ if (state === "done") {
453
+ const color = stageColor(index, view.steps.length, palette);
454
+ head = [{ text: "◆ ", fg: doneMix >= 1 ? color : mixHex(palette.info, color, doneMix) }, { text: name, fg: palette.text }];
455
+ }
456
+ else if (state === "now" || state === "warn") {
457
+ head = [{ text: `${state === "warn" ? "⚑" : "◉"} ${name}`, fg: live, bold: true }];
458
+ }
459
+ else {
460
+ head = [{ text: `◇ ${name}${state === "skip" ? " –" : ""}`, fg: palette.muted }];
461
+ }
462
+ if (note === "")
463
+ return head;
464
+ const room = width - cellWidth(note) - 1;
465
+ const used = lineWidth(head);
466
+ if (used > room)
467
+ return head;
468
+ return [...head, { text: " ".repeat(width - used - cellWidth(note)), fg: palette.muted }, { text: note, fg: palette.muted }];
384
469
  }
385
470
  function criterionSegment(id, status, palette) {
386
471
  if (status === "pass")
@@ -510,9 +595,9 @@ function withEmbers(rows, t, palette) {
510
595
  }
511
596
  /**
512
597
  * 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.
598
+ * warning on a warning, success when complete), a tiny LIT header, the goal title, the vertical stage
599
+ * map with its connectors, the bar, the detail line, the live step, criteria, warnings and the
600
+ * evidence timeline. Rows are at most `panelInnerWidth` cells.
516
601
  */
517
602
  export function missionPanel(view, frame, palette) {
518
603
  if (!view.active)
@@ -528,12 +613,22 @@ export function missionPanel(view, frame, palette) {
528
613
  rows.set("b1", []);
529
614
  if (view.goal !== undefined)
530
615
  rows.set("title", [{ text: truncateCells(view.goal.title, width), fg: palette.text, bold: true }]);
531
- rows.set("steps", stepsRow(view, frame, palette));
616
+ view.steps.forEach((step, index) => {
617
+ if (index > 0) {
618
+ const color = linkColor(view, index, frame, palette, palette.borderSubtle);
619
+ rows.set(`link:${index}`, [{ text: color === palette.borderSubtle ? "┆" : "┃", fg: color }]);
620
+ }
621
+ rows.set(`step:${step.id}`, stepperRow(view, index, frame, palette, width));
622
+ });
532
623
  if (view.progress !== undefined)
533
624
  rows.set("bar", progressBar(frame.barRatio, width, palette, sweep));
534
625
  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 }]);
626
+ // The detail line: `└ task 3 · next ▸ …`, or a research run's claim counts.
627
+ const lead = view.detail === undefined ? "" : truncateCells(view.detail, width - 2);
628
+ const gap = lead !== "" && view.next !== undefined ? " · " : "";
629
+ const next = view.next === undefined ? "" : truncateCells(`next ▸ ${view.next}`, Math.max(0, width - 2 - cellWidth(lead) - cellWidth(gap)));
630
+ rows.set("detail", [{ text: "└ ", fg: palette.muted }, { text: lead + (next === "" ? "" : gap), fg: palette.muted }, ...(next === "" ? [] : [{ text: next, fg: palette.text }])]);
631
+ rows.set("live", liveSegments(view, frame, palette, width));
537
632
  if (view.goal !== undefined) {
538
633
  const criteria = [{ text: "crit", fg: palette.muted }];
539
634
  for (const criterion of view.goal.criteria) {
@@ -573,7 +668,7 @@ export function missionPanel(view, frame, palette) {
573
668
  const enter = frame.chipEnter ? ease.outCubic(seg(frame.t, 0, motionTiming.entry)) : 1;
574
669
  const done = view.complete ? (frame.celebrate === undefined ? 1 : ease.inOutCubic(seg(frame.celebrate, 0, motionTiming.crossfade))) : 0;
575
670
  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();
671
+ const phase = view.complete ? "COMPLETE" : stepName(view.phase, view).toUpperCase();
577
672
  const flagged = view.warnings.length > 0 && !view.complete ? " ⚑" : "";
578
673
  const bottom = view.goal !== undefined
579
674
  ? ` ${view.goal.passed}/${view.goal.total} criteria${view.complete ? " ✓" : ""} `
package/dist/tui.js CHANGED
@@ -4,14 +4,15 @@ import { jsx } from "@opentui/solid/jsx-runtime";
4
4
  import { buildMissionView, lastMission, leadingNotice, missionNotices } from "./tui/mission-model.js";
5
5
  import { idleMotion, motionTiming, planMotion, sampleMotion } from "./tui/mission-motion.js";
6
6
  import { chipBudget, chipLine, homeLine, missionPanel, paletteFromTheme, panelWidth } from "./tui/mission-text.js";
7
- import { createLedgerReader, missionProjectRoot, readCardInput, readMissionInput } from "./tui/mission-source.js";
7
+ import { createLedgerReader, createWorkReader, missionProjectRoot, readCardInput, readLatestResearchRun, readMissionInput } from "./tui/mission-source.js";
8
8
  import { chipShimmer, idleChipMotion, planChipMotion } from "./tui/mission-skills.js";
9
- import { bandCard, readBandOptions, statusCard } from "./tui/mission-band.js";
9
+ import { bandCard, readBandOptions, readNotifyOptions, statusCard } from "./tui/mission-band.js";
10
10
  export const litOpenCodeTuiPluginId = "litopencode";
11
11
  const homeLineWidth = 69;
12
12
  const refreshDelayMs = 80;
13
13
  const ledgerPollMs = 2000;
14
14
  const toastMs = 4000;
15
+ const liveTickMs = 1000;
15
16
  const trackedSessionLimit = 4;
16
17
  const refreshEvents = [
17
18
  "message.updated",
@@ -38,7 +39,9 @@ function lineElement(line) {
38
39
  const tui = async (api, options) => {
39
40
  const motionEnabled = options?.motion !== false;
40
41
  const band = readBandOptions(options);
42
+ const notify = readNotifyOptions(options);
41
43
  const readLedger = createLedgerReader();
44
+ const readWork = createWorkReader();
42
45
  const sessions = new Map();
43
46
  const tracked = [];
44
47
  const [revision, setRevision] = createSignal(0);
@@ -46,6 +49,7 @@ const tui = async (api, options) => {
46
49
  let frameTimer;
47
50
  let refreshTimer;
48
51
  let pollTimer;
52
+ let liveTimer;
49
53
  const state = () => api.state;
50
54
  const palette = () => paletteFromTheme(api.theme.current, api.theme.mode());
51
55
  // OpenCode keeps the sidebar choice in its KV store under "sidebar" ("auto" or "hide"); a child
@@ -79,20 +83,45 @@ const tui = async (api, options) => {
79
83
  }
80
84
  }, motionTiming.frameMs);
81
85
  }
86
+ // The live step's elapsed time is the only steady change: one tick a second while a tool runs, and
87
+ // no timer at all otherwise.
88
+ function syncLiveTick() {
89
+ const running = [...sessions.values()].some((entry) => entry.view.live !== undefined);
90
+ if (running && liveTimer === undefined)
91
+ liveTimer = setInterval(() => setClock(Date.now()), liveTickMs);
92
+ if (!running && liveTimer !== undefined) {
93
+ clearInterval(liveTimer);
94
+ liveTimer = undefined;
95
+ }
96
+ }
97
+ // The finish alert is off unless tui.json opts in; OpenCode's own attention settings still decide
98
+ // whether a desktop notification or a sound actually plays.
99
+ function alertFinish(notice) {
100
+ if (!notify.enabled)
101
+ return;
102
+ void api.attention
103
+ .notify({ title: "🔥 LIT complete", message: notice.message, notification: notify.desktop, sound: notify.sound ? { name: "done" } : false })
104
+ .catch(() => undefined);
105
+ }
82
106
  function refresh(sessionID) {
83
107
  const now = Date.now();
84
108
  const source = state();
85
- const ledger = readLedger(missionProjectRoot(source.path));
86
- const next = buildMissionView(readMissionInput(source, sessionID, ledger, now));
109
+ const root = missionProjectRoot(source.path);
110
+ const ledger = readLedger(root);
111
+ const next = buildMissionView(readMissionInput(source, sessionID, ledger, now, { work: readWork(root, sessionID), research: readLatestResearchRun(root) }));
87
112
  const card = readCardInput(source, sessionID);
88
113
  const entry = sessions.get(sessionID);
89
114
  const viewChanged = entry === undefined || JSON.stringify(entry.view) !== JSON.stringify(next);
90
115
  if (!viewChanged && JSON.stringify(entry.card) === JSON.stringify(card))
91
116
  return;
92
117
  if (entry !== undefined && viewChanged) {
93
- const notice = leadingNotice(missionNotices(entry.view, next));
118
+ const notices = missionNotices(entry.view, next);
119
+ const notice = leadingNotice(notices);
94
120
  if (notice !== undefined)
95
121
  api.ui.toast({ variant: notice.variant, title: notice.title, message: notice.message, duration: toastMs });
122
+ const finish = notices.find((candidate) => candidate.celebrate === true);
123
+ if (finish !== undefined)
124
+ alertFinish(finish);
96
125
  }
97
126
  sessions.set(sessionID, {
98
127
  view: next,
@@ -103,6 +132,7 @@ const tui = async (api, options) => {
103
132
  setClock(now);
104
133
  setRevision(revision() + 1);
105
134
  ensureFrames();
135
+ syncLiveTick();
106
136
  }
107
137
  function refreshTracked() {
108
138
  refreshTimer = undefined;
@@ -177,6 +207,8 @@ const tui = async (api, options) => {
177
207
  clearTimeout(refreshTimer);
178
208
  if (pollTimer !== undefined)
179
209
  clearInterval(pollTimer);
210
+ if (liveTimer !== undefined)
211
+ clearInterval(liveTimer);
180
212
  sessions.clear();
181
213
  });
182
214
  api.slots.register({
@@ -289,6 +321,7 @@ const tui = async (api, options) => {
289
321
  view: snapshot.view,
290
322
  frame: snapshot.frame,
291
323
  chips: snapshot.card.chips,
324
+ busy: snapshot.card.busy,
292
325
  ...(shimmer === undefined ? {} : { shimmer }),
293
326
  figures: { contextPercent: snapshot.card.contextPercent, ...(snapshot.card.branch === undefined ? {} : { branch: snapshot.card.branch }), inLane: snapshot.inLane },
294
327
  ...(last === undefined ? {} : { last })
@@ -1 +1 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.20"><title>release: 1.0.20</title><g shape-rendering="crispEdges"><rect width="49" height="20" fill="#080d14"/><rect x="49" width="97" height="20" fill="#ff6337"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text x="255" y="140" textLength="390" transform="scale(.1)">release</text><text x="965" y="140" textLength="870" transform="scale(.1)" fill="#080D14">1.0.20</text></g></svg>
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.22"><title>release: 1.0.22</title><g shape-rendering="crispEdges"><rect width="49" height="20" fill="#080d14"/><rect x="49" width="97" height="20" fill="#ff6337"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text x="255" y="140" textLength="390" transform="scale(.1)">release</text><text x="965" y="140" textLength="870" transform="scale(.1)" fill="#080D14">1.0.22</text></g></svg>
package/docs/migration.md CHANGED
@@ -23,7 +23,7 @@ This guide describes the supported migration shape for moving an OpenCode workfl
23
23
  - Use `litopencode doctor --root <workspace>` to inspect `litopencode.json` route validity and effective `lit-plan` / `lit-loop` settings without writing files.
24
24
  - Use `litopencode install --dry-run --root <workspace>` to preview the `opencode.json` plugin mutation without writing files.
25
25
  - Route fields include top-level `permissionMode` (`safe`, `balanced`, or `yolo`) plus per-agent/per-category `provider`, `model`, `variant`, `category`, `reasoningEffort`, `textVerbosity`, `thinking`, `temperature`, `topP`, `maxTokens`, `promptAppend`, `tools`, `permission`, and `providerOptions`; generated route files use `variant` as the primary effort knob while `reasoningEffort` stays available for explicit provider-level overrides. LitOpenCode keeps `topP` as its config spelling and projects it to OpenCode's native `top_p` field; this preserves nonreasoning model sampling while Astra's native Responses capability omits unsupported sampling fields.
26
- - Global route config loads first, project-local route config merges afterward, and effective per-agent fields override category fields. Fresh/reset lead routes use Astra with native `reasoningEffort`. GPT-6 Astra and Sol accept `low`, `medium`, `high`, `xhigh`, `max`, and `ultra`. GPT-6 Luna accepts `low`, `medium`, `high`, `xhigh`, and `max`, but not `ultra`. Previous-generation GPT-5.6 Luna accepts only `high` and `max`; `xhigh` remains unsupported for that model. Astra has no invented `-fast` alias. No-selection installs preserve user route bytes but fail before success output when a managed route is unsafe. Other model IDs remain user-owned custom routes. Existing host entries named `momus` or `litwork-reviewer` preserve explicit routes and receive Astra/xhigh only when route fields are absent. LitOpenCode does not expose Hermes-specific per-subagent overrides.
26
+ - Global route config loads first, project-local route config merges afterward, and effective per-agent fields override category fields. Fresh/reset lead routes use Astra with native `reasoningEffort`. Each model accepts exactly the efforts OpenCode lists for it: GPT-6 Astra and GPT-6.1 Sol accept `low`, `medium`, `high`, `xhigh`, and `max`; GPT-6 Luna, GPT-6 Sol, and the GPT-5.6 rows accept `none`, `low`, `medium`, `high`, `xhigh`, and `max`. No model accepts `ultra`. A saved route set to `ultra` keeps running as `max`, `litopencode doctor` warns about it, and the next install rewrites it to `max` with a one-line notice after copying the file to `litopencode.json.litopencode-backup`. Astra has no invented `-fast` alias. No-selection installs preserve user route bytes but fail before success output when a managed route is unsafe. Other model IDs remain user-owned custom routes. Existing host entries named `momus` or `litwork-reviewer` preserve explicit routes and receive Astra/xhigh only when route fields are absent. LitOpenCode does not expose Hermes-specific per-subagent overrides.
27
27
  - Use `litopencode install --permission-prompt`, `--permission-mode balanced`, or `--yolo` only when you explicitly want LitOpenCode to reduce OpenCode approval prompts. Default installs do not relax permissions. Balanced mode allows routine automation while keeping dangerous bash patterns on ask; YOLO mode writes `permissionMode: "yolo"` and the plugin config hook allows OpenCode `bash`, `edit`, `webfetch`, and `external_directory` globally. In both balanced and YOLO modes, `task` delegation is granted only to execution-capable primary agents; subagents and `lit-plan` stay task-denied so delegation never recurses and the planner cannot select an implementation child. Existing global string/object and per-primary task policies are preserved unless they would weaken those hard deny guards; absent global task policy is not invented, and the runtime lineage guard blocks child task calls even under an explicit global allow. `lit-plan` remains agent-level edit/bash/task `deny`.
28
28
  - Restart OpenCode after changing `permissionMode`; OpenCode loads config at startup and running sessions keep the already-loaded permission policy.
29
29
  - Planning routes now produce proportionate objective-achievable checklists rather than relying on a later review pass to discover missing execution detail. Each retained item carries action/output/verification, while risky or multi-stage work receives SDD-like gates only when justified. `/review-work` accepts both draft plans (read-only `PASS`/`ITERATE`/`NEEDS-CONTEXT`) and completed work (the existing five-lane review); it never implements a draft plan.