@litfamily/litopencode 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.
@@ -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({
@@ -1 +1 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.21"><title>release: 1.0.21</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.21</text></g></svg>
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.23"><title>release: 1.0.23</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.23</text></g></svg>
@@ -257,25 +257,46 @@ OpenCode 1.18은 `tui.json`이나 `tui.jsonc`의 `plugin` 배열에 적힌 TUI
257
257
 
258
258
  화면에 그리는 것:
259
259
 
260
- - 입력창 아래 agent/model 줄 오른쪽의 단계 칩: `🔥 LIT` 알약 뒤에 `PLAN` › `WORK n/m` › `REVIEW`
261
- 알약과 다섯 칸 `▰▱` 막대가 붙습니다. 자리가 좁으면 이름부터 덜어 내서, `🔥`와 지금·다음 단계만,
262
- 더 좁으면 `🔥`와 개수만 남깁니다. 자리는 OpenCode에 저장된 사이드바 설정과 세션 종류(하위 세션에는
263
- 사이드바가 없음)로 계산합니다. 설정을 아직 읽을 수 없으면 사이드바가 열려 있다고 보고 좁게 잡습니다.
260
+ - 작업 흐름 지도: 어떤 스킬이 돌았는지만이 아니라 지금 LIT 작업 흐름의 어디쯤인지 보여 줍니다.
261
+ 세션마다 단계표 하나를 따릅니다.
262
+ - 기본 흐름 `interview › plan › work n/m › review › done`
263
+ - lit-loop의 기준 반복: 단계는 같고, lit-loop나 litgoal 세션이 할 일 대신 목표 기준을 셀 때
264
+ work 단계가 `criterion n/m`으로 바뀝니다.
265
+ - litresearch 실행: 세션이 litresearch를 돌리고 있거나 세션이 쓴 실행 폴더가 아직 열려 있으면
266
+ `decompose › wave n › expand › verify › synthesis`를 따릅니다.
267
+
268
+ 하지 않고 지나간 단계에는 `–`가 붙습니다. 범위가 정해진 `lit-task`는 계획을 만들지 않으므로 plan
269
+ 단계를 건너뛴 것으로 보여 주고, 했다고 표시하지 않습니다. `review-work` 없이 할 일만 모두 끝내도
270
+ review는 `–`로 남습니다. 리뷰 명령, 리뷰 막힘, ledger CLI로 끝낸 목표(완료 관문이 리뷰를 포함함)가 있어야
271
+ 완료로 바뀝니다. 에이전트가 직접 쓴 `goals.json`에서 끝났다고 표시한 목표는 관문을 거치지 않았으므로
272
+ review를 `–`로 둡니다. 지나온 단계는 테마의 primary에서
273
+ secondary를 거쳐 accent로 가는 색을 차례로 받고, 앞으로 할 단계는 흐리게 그립니다.
274
+ - 입력창 아래 agent/model 줄 오른쪽의 지도 칩: `🔥 LIT` 알약 뒤에 지금 단계 앞뒤를 이어 쓴
275
+ `plan ✓ › WORK ▰▰▰▱▱▱▱ 3/7 › review`가 붙습니다. 지금 단계는 채운 알약과 일곱 칸 막대로 보입니다.
276
+ 자리가 넉넉하면 지금 하는 일(`⠹ bash · 0:48`)이 뒤따릅니다. 자리가 좁으면 지금 하는 일을 먼저 빼고,
277
+ 다음에는 브랜드와 지난 단계를 빼서 `🔥`, 지금·다음 단계, 다섯 칸 막대만 남기고, 더 좁으면 `🔥`와
278
+ 개수만 남깁니다. 자리는 OpenCode에 저장된 사이드바 설정과 세션 종류(하위 세션에는 사이드바가
279
+ 없음)로 계산합니다. 설정을 아직 읽을 수 없으면 사이드바가 열려 있다고 보고 좁게 잡습니다.
264
280
  - 세션 사이드바 맨 위의 미션 패널: 36칸짜리 둥근 상자이고, 위 테두리에 `🔥 LIT ▸ <단계>`, 아래
265
281
  테두리에 `n/m criteria`가 붙습니다. 테두리 색이 상태를 보여 줍니다. 일하는 중에는 테마 accent,
266
282
  경고가 있으면 warning, 끝나면 success 색입니다. 안에는 두 줄짜리 `LIT`과 그 옆의 작업 이름·상태,
267
- 굵은 목표 제목, 단계 줄, 1/8칸 블록으로 칠하고 빈 곳은 가는 선으로 둔 그라데이션 막대, 다음 할 일이나
268
- 아직 통과하지 못한 기준, 기준 목록, 경고, 최근 증거 네 개가 들어갑니다.
283
+ 굵은 목표 제목, 세로 단계 지도가 들어갑니다. 지도에서 지난 단계는 `◆`, 지금 단계는 굵은 `◉`,
284
+ 경고가 걸린 단계는 `⚑`, 남은 단계는 `◇`입니다. 지금 단계까지는 단계 색의 `┃`로, 그 뒤로는 점선
285
+ `┆`로 잇고, plan 옆에는 저장된 계획의 할 일 수, work 옆에는 `iter k`를 적습니다. 그 아래로 1/8칸
286
+ 블록으로 칠하고 빈 곳은 가는 선으로 둔 그라데이션 막대, 자세히 한 줄 `└ task 3 · next ▸ <항목>`
287
+ (조사 실행에서는 주장 수와 검증된 수), 지금 하는 일, 기준 목록, 경고, 최근 증거 네 개가 이어집니다.
288
+ - 지금 하는 일: 세션이 돌리고 있는 도구의 이름과 걸린 시간입니다. 실행 중인 도구 part의 이름과 시작
289
+ 시각만 읽고, 입력(명령, 경로, 인자)은 읽지도 보여 주지도 않습니다. 턴이 도는 동안에만 보입니다.
269
290
  - 띠(band): 화면 맨 아래에 붙는 상태 카드입니다(OpenCode의 `app_bottom` 자리). 세션과 사이드바
270
291
  아래를 모두 가로지릅니다. lit을 쓰든 안 쓰든 세션 내내 보이고, 쉴 때나 일할 때나 똑같이 네 줄이라
271
292
  입력창이 움직이지 않습니다.
272
293
  - 위 테두리 왼쪽에 `🔥 LIT · <작업 이름>`, 오른쪽에 단계가 붙습니다(`● WORK`, `⚑ review blocked`,
273
294
  `✓ COMPLETE`, lit 턴 전과 세션이 턴을 돌리지 않는 동안에는 `○ IDLE`. 이때는 OpenCode의 `session.status`를
274
295
  읽고, 밝게 켜진 단계가 없습니다).
275
- - 첫째 줄은 칩과 같은 알약으로 그린 단계 줄 `PLAN ━ WORK ━ REVIEW`, 1/8칸 막대, 목표의
276
- `n/m criteria`, 다음 할 일입니다. 경고가 열려 있는 동안에는 `▸ 다음 일` 자리에 `⚑ <내용>`이
277
- 들어갑니다. 쉬는 세션에서는 흐린 단계 줄 옆에 프로젝트의 마지막 목표를, 목표가 없으면
278
- `add lit to a prompt to start`를 보여 줍니다.
296
+ - 첫째 줄은 단계 전체를 이어 쓴 지도 `interview – ━ plan ✓ ━ WORK 3/7 ─ review ─ done`, 1/8칸
297
+ 막대, 목표의 `n/m criteria`(단계가 이미 기준을 세는 기준 반복에서는 빠집니다), 지금 하는 일, 다음 할 일입니다. 경고가 열려 있는 동안에는 지금 하는
298
+ 일과 `▸ 다음 일` 자리에 `⚑ <내용>`이 들어갑니다. 쉬는 세션에서는 흐린 지도 옆에 프로젝트의
299
+ 마지막 목표를, 목표가 없으면 `add lit to a prompt to start`를 보여 줍니다.
279
300
  - 둘째 줄은 스킬 칩입니다. 세션에서 실행된 LitOpenCode 스킬이 여러 개 함께 보입니다. 지금 요청에서
280
301
  실행된 스킬은 요청이 도는 동안 accent 색 알약으로 밝게 보이고, 새로 들어온 칩에는 빛이 짧게 한 번
281
302
  지나갑니다. 턴이 끝나면 흐려져 최근 목록으로 남고(최근 것이 앞), 다시 실행되면 다시 밝아집니다.
@@ -284,8 +305,8 @@ OpenCode 1.18은 `tui.json`이나 `tui.jsonc`의 `plugin` 배열에 적힌 TUI
284
305
  붙습니다.
285
306
 
286
307
  하위(subagent) 세션에서는 부모 세션의 카드를 `· in lane`과 함께 보여 주고, 홈 화면에는 띠가
287
- 없습니다. 폭이 좁으면 줄마다 덜 중요한 것부터 덜어 냅니다. 단계 줄은 다음 할 일 글자, 막대 길이,
288
- 막대, 기준 개수, 단계 이름 순서로, 아래 테두리는 브랜치, 시간, context 사용량 순서로 뺍니다. 줄을
308
+ 없습니다. 폭이 좁으면 줄마다 덜 중요한 것부터 덜어 냅니다. 단계 줄은 지금 하는 일과 다음 할 일
309
+ 글자, 막대 길이, 막대, 기준 개수를 차례로 빼고, 마지막에는 지금 단계만 남깁니다. 아래 테두리는 브랜치, 시간, context 사용량 순서로 뺍니다. 줄을
289
310
  넘기지는 않습니다. 완료 순간도 여기서 함께 보입니다.
290
311
  - 홈 화면 입력창 아래, OpenCode 팁 다음 줄: `🔥 lit ready · last: <목표> ✓ n/m`. 프로젝트 ledger에
291
312
  목표가 있을 때만 보입니다.
@@ -293,9 +314,14 @@ OpenCode 1.18은 `tui.json`이나 `tui.jsonc`의 `plugin` 배열에 적힌 TUI
293
314
  때문입니다. 알파가 0인 테마 바탕(system, lucent-orng)은 검게 칠하지 않고 비워 둡니다.
294
315
  - 관문 사건마다 토스트 하나: 계획 저장 또는 저장 실패, 목표 설정, 기준 통과, 리뷰 막힘과 해결, 완료.
295
316
  한 번에 여러 사건이 생기면 가장 급한 것 하나로 합칩니다.
296
- - 목표가 끝나거나 할 일이 모두 끝나면 1.2초 안에 끝나는 완료 순간을 보여 줍니다. 테두리가 초록으로
297
- 바뀌고 막대가 끝까지 차며, `WORK`와 `REVIEW`가 차례로 완료로 바뀝니다. 브랜드에 빛이 한 번 지나가고,
298
- 패널 안으로 불씨가 올라가며, `COMPLETE`가 살짝 넘쳤다가 제자리로 옵니다.
317
+ - 목표가 끝나거나, 할 일이 모두 끝나거나, 조사 실행이 보고서를 쓰면 1.2초 안에 끝나는 완료 순간을
318
+ 보여 줍니다. 테두리가 초록으로 바뀌고 막대가 끝까지 차며, 돌던 단계와 그 뒤 단계가 60ms 간격으로
319
+ 차례로 완료로 바뀝니다. 브랜드에 빛이 한 번 지나가고, 패널 안으로 불씨가 올라가며, `COMPLETE`가
320
+ 살짝 넘쳤다가 제자리로 옵니다.
321
+ - 같은 순간에 켤 때만 울리는 끝 알림: OpenCode의 `attention.notify`로 데스크톱 알림과 호스트의
322
+ `done` 소리를 보냅니다. 기본은 꺼져 있고, 켜는 법은 아래 옵션에 있습니다. 실제로 울릴지는 OpenCode
323
+ 자체의 `attention` 설정이 정합니다. 이 설정은 tui.json에서 `"attention": { "enabled": true }`로 켜기
324
+ 전까지 꺼져 있고, 꺼져 있으면 OpenCode가 `attention_disabled`로 답합니다.
299
325
 
300
326
  데이터는 이미 있는 것만 읽고, TUI를 위해 새로 쓰는 파일은 없습니다.
301
327
 
@@ -306,16 +332,36 @@ OpenCode 1.18은 `tui.json`이나 `tui.jsonc`의 `plugin` 배열에 적힌 TUI
306
332
  동안 마지막 사용자 메시지 이후에 실행된 것을 뜻합니다.
307
333
  - `lit`이 모드를 켜거나 명령이 실행될 때(`save-plan` 포함) 이 플러그인이 사용자 메시지 part에 붙이는
308
334
  `metadata.litopencode` 표시. 표시가 없는 세션은 쉬는 상태로 보고, 기본 `lit-loop` agent여도 칩과
309
- 패널을 숨깁니다.
335
+ 패널을 숨깁니다. `/litresearch` 같은 슬래시 명령은 OpenCode의 명령 hook으로 지도에 표시되므로
336
+ `litopencode install`이 쓰는 명령 파일이 있어야 합니다. `plugin` 항목만 있으면 OpenCode가 이를
337
+ 평범한 글로 보내고, 지도에는 아무것도 표시되지 않습니다.
310
338
  - 세션의 OpenCode 할 일 목록(진행도와 다음 항목), 상태(재시도), 대기 중인 권한 요청과 질문, 마지막
311
339
  assistant 메시지 기준 context 사용량(75%부터 주황).
312
340
  - 프로젝트 루트의 `.litopencode/litgoal/lit-loop/goals.json`. 심볼릭 링크와 1 MiB가 넘는 파일은 읽지
313
- 않습니다. 세션의 첫 lit 턴보다 먼저 마지막으로 바뀐 목표는 이전 작업으로 보고 빼 둡니다.
341
+ 않습니다. CLI가 쓰는 ledger 형태(`goals`와 `activeGoalId`) 말고도 에이전트가 직접 쓴 형태, 곧 맨 위의
342
+ 목표 하나, `goal` 아래의 목표, `goals` 목록의 마지막 항목도 읽고, `completed`나 `done`은 통과한 기준이나
343
+ 끝난 목표로 셉니다. 세션의 첫 lit 턴보다 먼저 마지막으로 바뀐 목표는 이전 작업으로 보고 빼 둡니다. 목표의
344
+ checkpoint 수가 기준 반복의 iter 수가 됩니다.
345
+ - `/start-work`가 남기는 범위 작업 기록 `.litopencode/litgoal/lit-loop/work-schema-3.json`. 이 세션의
346
+ 것이고 취소되지 않았을 때만 읽습니다. `work.progressed` 기록 수가 work 단계의 iter 수가 되고, 기록이
347
+ 가리키는 계획 파일에서 맨 앞에 붙은 `- [ ]` / `- [x]` 줄을 세어 할 일 수와 다음 항목을 얻습니다.
348
+ 계획 파일은 프로젝트 루트 안의 일반 파일일 때만 읽습니다.
349
+ - `.litopencode/litgoal/lit-loop/research/` 아래에서 가장 최근에 쓴 실행 폴더. `report.md`가 있으면
350
+ synthesis, `claim-graph.jsonl`에 관문 결과(`verified`, `abstained`, `unresolved`, `refuted`)가 붙은
351
+ 주장이 있으면 verify, `journal.md`의 마지막 제목이 `EXPAND`로 시작하면 expand, 주장이 있으면 wave(가장
352
+ 큰 `wave` 값이나 `## Wave` 제목 수로 번호를 붙임), 아무것도 없으면 decompose입니다. 세션의 첫 lit
353
+ 턴보다 먼저 쓴 폴더는 이전 작업으로 봅니다.
354
+ - 지금 하는 일을 위해 세션의 마지막 assistant 메시지에서 실행 중인 도구 part. 도구 이름과 `time.start`만
355
+ 씁니다.
314
356
 
315
357
  움직임은 전환이 일어나는 동안에만 짧은 프레임 타이머로 돌고 끝나면 멈춥니다. 새 줄과 알약은
316
358
  ease-out-back으로 160ms씩, 60ms 간격으로 들어오고, 막대는 300ms에 차오릅니다. 경고 줄은 220ms에
317
- 걸쳐 들어오고 색은 140ms 동안 넘어가며, 빛이 지나가는 데는 400ms가 걸립니다. lit 세션이
318
- 열려 있는 동안만 2초마다 목표 ledger를 다시 읽고, 쉬는 세션은 읽지 않습니다. 색은 지금 OpenCode
359
+ 걸쳐 들어오고 색은 140ms 동안 넘어가며, 빛이 지나가는 데는 400ms가 걸립니다. 지도가 다음 단계로
360
+ 넘어가면 옛 단계와 새 단계 사이의 세로 선이 위에서 아래로 60ms 간격으로 차오르고, 지금 단계 알약이
361
+ 한 번 빛납니다. 계속 도는 움직임은 없습니다. 지금 하는 일은 미끄러져 들어오지 않고 제자리에서
362
+ 바뀌며, 도구가 도는 동안에만 걸린 시간이 1초에 한 번 바뀝니다. 도는 도구가 없으면 타이머도
363
+ 없습니다. lit 세션이 열려 있는 동안만 2초마다 목표 ledger, 범위 작업 기록, 조사 폴더를 다시 읽고,
364
+ 쉬는 세션은 읽지 않습니다. 색은 지금 OpenCode
319
365
  테마에서 가져옵니다. 움직임을 끄려면 `tui.json`에 `["@litfamily/litopencode@<version>", { "motion": false }]`
320
366
  처럼 적고, 플러그인 자체를 끄려면 OpenCode 플러그인 관리자나 `plugin_enabled`에서 id `litopencode`를 끕니다.
321
367
 
@@ -326,6 +372,11 @@ ease-out-back으로 160ms씩, 60ms 간격으로 들어오고, 막대는 300ms에
326
372
  목표가 있는 동안 카드는 네 줄로 고정되어, 경고가 생기거나 사라져도 입력창이 움직이지 않습니다.
327
373
  카드는 기본으로 꺼져 있습니다.
328
374
 
375
+ 끝 알림도 같은 객체에 적으며, 적지 않으면 꺼져 있습니다.
376
+
377
+ - `{ "notify": true }`는 미션이 끝날 때 데스크톱 알림을 보내고 `done` 소리를 냅니다.
378
+ - `{ "notify": { "sound": false } }`는 알림만, `{ "notify": { "desktop": false } }`는 소리만 남깁니다.
379
+
329
380
  다른 터미널에서 쓴 ledger(예: `litopencode record-evidence`)는 OpenCode 이벤트를 만들지 않습니다.
330
381
  그래서 2초마다 도는 공용 ledger 확인이 이를 잡고, 칩·패널·띠가 같은 프레임 시계로 함께 바뀝니다.
331
382