@litfamily/litopencode 1.0.36 β†’ 1.0.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/v2/tui.js CHANGED
@@ -1,26 +1,52 @@
1
- // LitOpenCode's OpenCode 2 TUI plugin: the mission panel in the session sidebar, the ignition line under
2
- // the prompt (`πŸ”₯ LIT IGNITED Β· <discipline>`, the OpenCode 2 home of the OpenCode 1 completion mark),
3
- // and toasts. OpenCode 2 loads it on its own for an active server plugin. The server cannot raise toasts
4
- // on OpenCode 2, so the ignition toast is inferred here from the lit mark the server writes into the user
5
- // message metadata. It only reads state. OpenTUI and Solid come from the host at run time; tests pass a
6
- // stand-in runtime.
1
+ // LitOpenCode's OpenCode 2 TUI plugin: the same HUD OpenCode 1 draws, placed in the slots OpenCode 2
2
+ // publishes (@opencode/plugin SlotMap). The status card sits after the prompt footer, the stage chip and
3
+ // the ignition line (`πŸ”₯ LIT IGNITED Β· <discipline>`, the OpenCode 2 home of the OpenCode 1 completion
4
+ // mark) share prompt.footer.status, the home line is in home.footer and the mission panel in
5
+ // sidebar.content. Toasts cover the gate events, the stage moving, discipline switches, lanes, the
6
+ // ignition and the restart notice, with an opt-in finish alert. OpenCode 2 loads the plugin on its own
7
+ // for an active server plugin. The server cannot raise toasts there, so the ignition toast is inferred
8
+ // from the lit mark the server writes into the user message metadata. It only reads state. OpenTUI and
9
+ // Solid come from the host at run time; tests pass a stand-in runtime.
7
10
  import { activationDiscipline } from "../activation-probe.js";
11
+ import { readPackageMetadata } from "../cli/json.js";
8
12
  import { micro, supportsMarkGlyphs } from "../lit-mark.js";
9
- import { buildMissionView, leadingNotice, missionNotices } from "../tui/mission-model.js";
13
+ import { readInstalledVersions, restartVersion } from "../restart-notice.js";
14
+ import { buildMissionView, lastMission, leadingNotice, missionNotices } from "../tui/mission-model.js";
10
15
  import { idleMotion, motionTiming, planMotion, sampleMotion } from "../tui/mission-motion.js";
11
16
  import { latestLitMarkV2, missionStateFromV2 } from "../tui/mission-source-v2.js";
12
- import { createLedgerReader, createWorkReader, missionProjectRoot, readLatestResearchRun, readMissionInput } from "../tui/mission-source.js";
13
- import { missionPanel, paletteFromTheme, panelWidth } from "../tui/mission-text.js";
17
+ import { adhdSuggestDue, createLedgerReader, createWorkReader, earlierWorkWindowMs, hasEarlierWork, messageTimes, missionProjectRoot, readCardInput, readLatestResearchRun, readMissionInput, readThreadSources, wayBackDue } from "../tui/mission-source.js";
18
+ import { chipShimmer, idleChipMotion, planChipMotion } from "../tui/mission-skills.js";
19
+ import { bandCard, readBandOptions, readNotifyOptions, statusCard } from "../tui/mission-band.js";
20
+ import { activeSkillLook, turnGlow, waitingOnYou } from "../tui/mission-looks.js";
21
+ import { rainbowActive, rainbowAllowed, rainbowAt, rainbowShows, rainbowTickMs } from "../tui/mission-rainbow.js";
22
+ import { threadLineCount, threadsOf } from "../tui/mission-threads.js";
23
+ import { chipLine, cellWidth, homeLine, lineWidth, missionPanel, paletteFromTheme, panelWidth } from "../tui/mission-text.js";
24
+ import { v2StageNotices } from "./tui-notices.js";
14
25
  const refreshDelayMs = 80;
15
26
  const ledgerPollMs = 2000;
16
27
  const toastMs = 4000;
17
28
  const ignitionToastMs = 6000;
29
+ const liveTickMs = 1000;
30
+ const restartCheckMs = 10 * 60_000;
18
31
  const trackedSessionLimit = 4;
32
+ const homeLineWidth = 69;
19
33
  // A lit mark older than the moment a session is first drawn is history being loaded, not a new turn.
20
34
  const historySlackMs = 2000;
35
+ const defaultWidth = 120;
36
+ // What the footer row holds besides our line: the host's spinner and hint on the left, its token, cost,
37
+ // goal and `ctrl+p commands` figures on the right (measured with a turn running, when they are widest).
38
+ const hostFooterCells = 68;
39
+ const sidebarCells = 42;
40
+ const sidebarMinWidth = 120;
21
41
  async function hostRuntime() {
22
- const [jsxRuntime, solid] = await Promise.all([import("@opentui/solid/jsx-runtime"), import("solid-js")]);
23
- return { jsx: jsxRuntime.jsx, createSignal: solid.createSignal, createEffect: solid.createEffect, createRoot: solid.createRoot };
42
+ const [jsxRuntime, solid, opentui] = await Promise.all([import("@opentui/solid/jsx-runtime"), import("solid-js"), import("@opentui/solid")]);
43
+ return {
44
+ jsx: jsxRuntime.jsx,
45
+ createSignal: solid.createSignal,
46
+ createEffect: solid.createEffect,
47
+ createRoot: solid.createRoot,
48
+ useTerminalDimensions: opentui.useTerminalDimensions
49
+ };
24
50
  }
25
51
  /** OpenCode 2 theme tokens under the names the mission palette reads. */
26
52
  export function paletteFromV2Theme(theme, mode) {
@@ -42,23 +68,33 @@ export async function setupLitOpenCodeTui(context, runtime) {
42
68
  const ui = runtime ?? await hostRuntime();
43
69
  const [settings] = context.storage.store("settings", { initial: { motion: true } });
44
70
  const motionEnabled = settings.motion !== false;
71
+ const band = readBandOptions(context.options);
72
+ const notify = readNotifyOptions(context.options);
45
73
  const directory = context.location?.directory ?? context.data.location.default().directory;
46
74
  const root = missionProjectRoot({ directory });
47
75
  const readLedger = createLedgerReader();
48
76
  const readWork = createWorkReader();
77
+ const colorAllowed = rainbowAllowed(motionEnabled, process.env.NO_COLOR);
49
78
  const sessions = new Map();
50
79
  const ignitions = new Map();
51
80
  const trackedAt = new Map();
52
81
  const tracked = [];
82
+ const suggested = new Set();
83
+ const suggestedDays = new Map();
53
84
  // Each session's store watcher lives in its own root, so it outlasts the slot render that started it
54
85
  // and ends only on eviction or cleanup. After cleanup nothing may arm a timer again.
55
86
  const watchers = new Map();
56
87
  let disposed = false;
57
88
  const [revision, setRevision] = ui.createSignal(0);
58
89
  const [clock, setClock] = ui.createSignal(Date.now());
90
+ // The version this start loaded; an install newer than it shows `restart to <v>` and one toast.
91
+ const [restartTo, setRestartTo] = ui.createSignal(undefined);
59
92
  let frameTimer;
60
93
  let refreshTimer;
61
94
  let pollTimer;
95
+ let liveTimer;
96
+ let bandTimer;
97
+ let restartTimer;
62
98
  const palette = () => paletteFromV2Theme(context.theme, context.themeMode);
63
99
  function span(segment) {
64
100
  return ui.jsx("span", {
@@ -69,17 +105,43 @@ export async function setupLitOpenCodeTui(context, runtime) {
69
105
  function line(segments) {
70
106
  return ui.jsx("text", { wrapMode: "none", children: segments.length === 0 ? " " : segments.map(span) });
71
107
  }
72
- function viewFor(sessionID, now) {
73
- const source = missionStateFromV2(context.data, directory);
74
- const records = { work: readWork(root, sessionID), research: readLatestResearchRun(root) };
75
- return buildMissionView(readMissionInput(source, sessionID, readLedger(root), now, {
76
- ...(records.work === undefined ? {} : { work: records.work }),
77
- ...(records.research === undefined ? {} : { research: records.research })
78
- }));
108
+ function state() {
109
+ return missionStateFromV2(context.data, directory);
110
+ }
111
+ function parentOf(sessionID) {
112
+ const parent = context.data.session.get?.(sessionID)?.parentID;
113
+ return typeof parent === "string" && parent !== "" ? parent : undefined;
114
+ }
115
+ function width() {
116
+ return ui.useTerminalDimensions?.()().width ?? defaultWidth;
117
+ }
118
+ function rainbowOn(entry, now) {
119
+ return colorAllowed && rainbowShows(rainbowActive(entry.view, entry.card.chips, entry.card.busy), entry.rainbowSeen, entry.lastPromptAt, now);
120
+ }
121
+ function bandStepping(entry) {
122
+ if (!colorAllowed)
123
+ return false;
124
+ if (entry.waiting || rainbowOn(entry, Date.now()))
125
+ return true;
126
+ return entry.card.threads?.some((row) => row.status === "working" && row.done === undefined) === true;
127
+ }
128
+ function syncBandTimer() {
129
+ if (disposed)
130
+ return;
131
+ const needed = [...sessions.values()].some(bandStepping);
132
+ if (needed && bandTimer === undefined)
133
+ bandTimer = setInterval(() => {
134
+ setClock(Date.now());
135
+ syncBandTimer();
136
+ }, rainbowTickMs);
137
+ if (!needed && bandTimer !== undefined) {
138
+ clearInterval(bandTimer);
139
+ bandTimer = undefined;
140
+ }
79
141
  }
80
142
  function anyMoving(now) {
81
143
  for (const entry of sessions.values())
82
- if (sampleMotion(entry.motion, now).moving)
144
+ if (sampleMotion(entry.motion, now).moving || chipShimmer(entry.chipMotion, now) !== undefined)
83
145
  return true;
84
146
  return false;
85
147
  }
@@ -95,11 +157,103 @@ export async function setupLitOpenCodeTui(context, runtime) {
95
157
  }
96
158
  }, motionTiming.frameMs);
97
159
  }
160
+ // The live step's elapsed time is the one steady change: a tick a second while a tool runs.
161
+ function syncLiveTick() {
162
+ if (disposed)
163
+ return;
164
+ const running = [...sessions.values()].some((entry) => entry.view.live !== undefined);
165
+ if (running && liveTimer === undefined)
166
+ liveTimer = setInterval(() => setClock(Date.now()), liveTickMs);
167
+ if (!running && liveTimer !== undefined) {
168
+ clearInterval(liveTimer);
169
+ liveTimer = undefined;
170
+ }
171
+ }
172
+ // The card keeps the tallest lane count it reached while lanes are live or a turn runs, so the prompt does not jump.
173
+ function holdRows(held, rows, busy) {
174
+ if (rows > 0)
175
+ return Math.max(held, rows);
176
+ return busy ? held : 0;
177
+ }
98
178
  function ignitionToast(sessionID, name) {
99
179
  const label = `${ignitionLine(name)} πŸ”₯`;
100
180
  const message = supportsMarkGlyphs() ? [...micro.map((row) => row.replace(/ +$/u, "")), label].join("\n") : label;
101
181
  context.ui.toast.show({ title: "πŸ”₯ LIT IGNITED", message, variant: "warning", duration: ignitionToastMs, sessionID });
102
182
  }
183
+ // The finish alert is off unless the plugin options opt in; OpenCode's own attention settings still
184
+ // decide whether a desktop notification or a sound actually plays.
185
+ function alertFinish(notice) {
186
+ if (!notify.enabled || context.attention === undefined)
187
+ return;
188
+ void context.attention
189
+ .notify({ title: "πŸ”₯ LIT complete", message: notice.message, notification: notify.desktop, sound: notify.sound ? { name: "done" } : false })
190
+ .catch(() => undefined);
191
+ }
192
+ async function checkRestart() {
193
+ const loaded = await readPackageMetadata().catch(() => undefined);
194
+ if (loaded === undefined || disposed)
195
+ return;
196
+ const target = restartVersion(loaded.version, await readInstalledVersions(loaded.packageRoot).catch(() => []));
197
+ if (disposed || target === undefined || target === restartTo())
198
+ return;
199
+ if (restartTo() === undefined) {
200
+ context.ui.toast.show({ variant: "info", title: "πŸ”₯ LIT", message: `LitOpenCode ${target} is installed. Restart OpenCode to load it.`, duration: toastMs });
201
+ }
202
+ setRestartTo(target);
203
+ }
204
+ // One toast per session, for a fresh root session in a workspace that has earlier work to pick up.
205
+ function suggestAdhd(sessionID, source) {
206
+ if (suggested.has(sessionID) || parentOf(sessionID) !== undefined)
207
+ return;
208
+ suggested.add(sessionID);
209
+ const created = context.data.session.get?.(sessionID)?.time?.created;
210
+ const now = Date.now();
211
+ if (source.session.messages(sessionID).length > 0 || typeof created !== "number" || now - created > 2 * 60_000)
212
+ return;
213
+ const projectRoot = missionProjectRoot(source.path);
214
+ if (projectRoot === undefined || !hasEarlierWork(projectRoot, sessionID, now, earlierWorkWindowMs) || !adhdSuggestDue(suggestedDays, projectRoot, now))
215
+ return;
216
+ context.ui.toast.show({ variant: "info", title: "πŸ”₯ LIT", message: "Earlier work here. Say lit adhd to pick up where you left off.", duration: toastMs });
217
+ }
218
+ // One session's view, card and the notices from its previous entry. Pure reads; nothing is armed here.
219
+ function build(sessionID, entry, now) {
220
+ const source = state();
221
+ const work = readWork(root, sessionID);
222
+ const research = readLatestResearchRun(root);
223
+ const view = buildMissionView(readMissionInput(source, sessionID, readLedger(root), now, {
224
+ ...(work === undefined ? {} : { work }),
225
+ ...(research === undefined ? {} : { research })
226
+ }));
227
+ const threads = threadsOf(readThreadSources(source, sessionID, root, now), now);
228
+ const times = messageTimes(source, sessionID);
229
+ const card = {
230
+ ...readCardInput(source, sessionID),
231
+ ...(threads === undefined ? {} : { threads }),
232
+ ...(wayBackDue(times.lastAt, now, hasEarlierWork(root, sessionID, now, earlierWorkWindowMs)) ? { earlier: true } : {})
233
+ };
234
+ const viewChanged = entry === undefined || JSON.stringify(entry.view) !== JSON.stringify(view);
235
+ const rainbowSeen = rainbowActive(view, card.chips, card.busy) ? now : entry?.rainbowSeen;
236
+ const waiting = waitingOnYou(view, card.busy, { asked: card.asked === true, userSpokeLast: card.userSpokeLast === true, blockedReply: card.blockedReply === true, boundary: work?.boundary === true });
237
+ const changed = entry === undefined || viewChanged || entry.lastPromptAt !== times.lastPromptAt || JSON.stringify(entry.card) !== JSON.stringify(card) || entry.rainbowSeen !== rainbowSeen || entry.waiting !== waiting;
238
+ const lanesChanged = entry !== undefined && JSON.stringify(entry.card.threads) !== JSON.stringify(card.threads);
239
+ const notices = entry !== undefined && (viewChanged || lanesChanged)
240
+ ? [...missionNotices(entry.view, view), ...v2StageNotices(entry.view, view, entry.card.threads ?? [], card.threads ?? [])]
241
+ : [];
242
+ return {
243
+ changed,
244
+ notices,
245
+ next: {
246
+ view,
247
+ card,
248
+ motion: entry !== undefined && viewChanged ? planMotion(entry.view, view, now, entry.motion, motionEnabled) : (entry?.motion ?? idleMotion),
249
+ chipMotion: planChipMotion(entry?.card.chips, card.chips, now, entry?.chipMotion ?? idleChipMotion, motionEnabled),
250
+ reserve: holdRows(entry?.reserve ?? 0, threadLineCount(threads), card.busy),
251
+ ...(rainbowSeen === undefined ? {} : { rainbowSeen }),
252
+ ...(times.lastPromptAt === undefined ? {} : { lastPromptAt: times.lastPromptAt }),
253
+ waiting
254
+ }
255
+ };
256
+ }
103
257
  function refresh(sessionID) {
104
258
  if (disposed)
105
259
  return;
@@ -110,19 +264,23 @@ export async function setupLitOpenCodeTui(context, runtime) {
110
264
  if (mark !== undefined && mark.at >= (trackedAt.get(sessionID) ?? 0) - historySlackMs)
111
265
  ignitionToast(sessionID, mark.name);
112
266
  }
113
- const next = viewFor(sessionID, now);
114
267
  const entry = sessions.get(sessionID);
115
- if (entry !== undefined && JSON.stringify(entry.view) === JSON.stringify(next))
268
+ const { next, changed, notices } = build(sessionID, entry, now);
269
+ if (!changed)
116
270
  return;
117
- if (entry !== undefined) {
118
- const notice = leadingNotice(missionNotices(entry.view, next));
119
- if (notice !== undefined)
120
- context.ui.toast.show({ variant: notice.variant, title: notice.title, message: notice.message, duration: toastMs, sessionID });
121
- }
122
- sessions.set(sessionID, { view: next, motion: planMotion(entry?.view, next, now, entry?.motion ?? idleMotion, motionEnabled) });
271
+ const notice = leadingNotice(notices);
272
+ if (notice !== undefined)
273
+ context.ui.toast.show({ variant: notice.variant, title: notice.title, message: notice.message, duration: toastMs, sessionID });
274
+ const finish = notices.find((candidate) => candidate.celebrate === true);
275
+ if (finish !== undefined)
276
+ alertFinish(finish);
277
+ sessions.set(sessionID, next);
123
278
  setClock(now);
124
279
  setRevision(revision() + 1);
125
280
  ensureFrames();
281
+ syncBandTimer();
282
+ syncLiveTick();
283
+ syncPoll();
126
284
  }
127
285
  function syncPoll() {
128
286
  if (disposed)
@@ -164,8 +322,13 @@ export async function setupLitOpenCodeTui(context, runtime) {
164
322
  const now = Date.now();
165
323
  trackedAt.set(sessionID, now);
166
324
  ignitions.set(sessionID, latestLitMarkV2(context.data, sessionID)?.messageID);
167
- sessions.set(sessionID, { view: viewFor(sessionID, now), motion: idleMotion });
168
- queueMicrotask(syncPoll);
325
+ suggestAdhd(sessionID, state());
326
+ sessions.set(sessionID, build(sessionID, undefined, now).next);
327
+ queueMicrotask(() => {
328
+ syncPoll();
329
+ syncLiveTick();
330
+ syncBandTimer();
331
+ });
169
332
  // Opening a session loads its messages over HTTP, which raises no event. Reading the host's reactive
170
333
  // store here re-runs this effect as they arrive, so a resumed session lights up too.
171
334
  const dispose = ui.createRoot((release) => {
@@ -184,6 +347,60 @@ export async function setupLitOpenCodeTui(context, runtime) {
184
347
  if (type.startsWith("session.") || type.startsWith("permission.") || type.startsWith("form."))
185
348
  scheduleRefresh();
186
349
  });
350
+ // Reads that only a rendered slot needs. `view` follows the reactive revision and the clock.
351
+ function snapshot(sessionID) {
352
+ revision();
353
+ const entry = sessions.get(sessionID);
354
+ const now = clock();
355
+ return entry === undefined ? undefined : { ...entry, frame: sampleMotion(entry.motion, now), now };
356
+ }
357
+ function statusCardRows(sessionID, inLane, columns) {
358
+ const current = snapshot(sessionID);
359
+ if (current === undefined)
360
+ return undefined;
361
+ const colors = palette();
362
+ const cells = columns - 2;
363
+ const last = current.view.active ? undefined : lastMission(readLedger(root));
364
+ const shimmer = chipShimmer(current.chipMotion, current.now);
365
+ const lines = statusCard({
366
+ view: current.view,
367
+ frame: current.frame,
368
+ chips: current.card.chips,
369
+ busy: current.card.busy,
370
+ motion: motionEnabled,
371
+ reserve: current.reserve,
372
+ ...(current.card.threads === undefined ? {} : { threads: current.card.threads }),
373
+ ...(current.card.earlier === true ? { earlier: true } : {}),
374
+ ...(current.waiting ? { turn: { glow: turnGlow(current.now, colorAllowed) } } : {}),
375
+ ...(rainbowOn(current, current.now) ? { rainbow: rainbowAt(current.now) } : {}),
376
+ ...(current.card.lanes !== undefined && activeSkillLook(current.view, current.card.chips, current.card.busy) === "review" ? { lanes: current.card.lanes } : {}),
377
+ ...(shimmer === undefined ? {} : { shimmer }),
378
+ figures: {
379
+ contextPercent: current.card.contextPercent,
380
+ ...(current.card.branch === undefined ? {} : { branch: current.card.branch }),
381
+ inLane,
382
+ ...(restartTo() === undefined ? {} : { restartTo: restartTo() })
383
+ },
384
+ ...(last === undefined ? {} : { last })
385
+ }, colors, cells);
386
+ const rows = lines.map(line);
387
+ const detail = band.card ? bandCard(current.view, current.frame, colors, cells) : undefined;
388
+ if (detail !== undefined) {
389
+ rows.push(ui.jsx("box", {
390
+ border: true,
391
+ borderStyle: "rounded",
392
+ borderColor: detail.border,
393
+ title: detail.title,
394
+ titleColor: colors.text,
395
+ paddingLeft: 1,
396
+ paddingRight: 1,
397
+ flexShrink: 0,
398
+ flexDirection: "column",
399
+ children: detail.rows.map(line)
400
+ }));
401
+ }
402
+ return rows;
403
+ }
187
404
  const releases = [
188
405
  context.ui.slot({
189
406
  append: "prompt.footer.status",
@@ -192,14 +409,25 @@ export async function setupLitOpenCodeTui(context, runtime) {
192
409
  if (sessionID === undefined)
193
410
  return null;
194
411
  track(sessionID);
412
+ const columns = width;
195
413
  return () => {
196
414
  revision();
197
415
  const mark = latestLitMarkV2(context.data, sessionID);
198
416
  if (mark === undefined)
199
417
  return null;
418
+ const ignition = ignitionLine(mark.name);
419
+ const current = snapshot(sessionID);
420
+ // The sidebar setting is not readable here, so a wide terminal is taken to have it open.
421
+ const sidebar = parentOf(sessionID) === undefined && columns() > sidebarMinWidth ? sidebarCells : 0;
422
+ const budget = columns() - sidebar - hostFooterCells - cellWidth(ignition);
423
+ const drawn = current === undefined || !current.view.active ? [] : chipLine(current.view, current.frame, palette(), budget);
424
+ const chip = lineWidth(drawn) > budget ? [] : drawn;
200
425
  return ui.jsx("box", {
201
- flexShrink: 0,
202
- children: line([{ text: ignitionLine(mark.name), fg: palette().primary, bold: true }])
426
+ flexShrink: 1,
427
+ minWidth: 0,
428
+ overflow: "hidden",
429
+ flexDirection: "row",
430
+ children: [line([{ text: ignition, fg: palette().primary, bold: true }]), ...(chip.length === 0 ? [] : [line([{ text: " ", fg: palette().muted }]), line(chip)])]
203
431
  });
204
432
  };
205
433
  }
@@ -235,8 +463,52 @@ export async function setupLitOpenCodeTui(context, runtime) {
235
463
  });
236
464
  };
237
465
  }
466
+ }),
467
+ context.ui.slot({
468
+ append: "home.footer",
469
+ render() {
470
+ const columns = width;
471
+ return () => {
472
+ revision();
473
+ const segments = homeLine(lastMission(readLedger(root)), palette(), Math.min(homeLineWidth, columns() - 10));
474
+ return segments.length === 0 ? null : ui.jsx("box", { flexDirection: "row", justifyContent: "center", flexShrink: 0, children: line(segments) });
475
+ };
476
+ }
238
477
  })
239
478
  ];
479
+ // The card is drawn once for the whole app, under the session and its sidebar, on every session, lit or
480
+ // not, and keeps its rows when a turn starts. A subagent session shows its parent's card as a lane. The
481
+ // home route has no session, so it shows no card.
482
+ if (band.enabled) {
483
+ releases.push(context.ui.slot({
484
+ append: "app",
485
+ render() {
486
+ const columns = width;
487
+ return () => {
488
+ const route = context.ui.router?.current();
489
+ const sessionID = route?.type === "session" ? route.sessionID : undefined;
490
+ if (sessionID === undefined)
491
+ return null;
492
+ const parent = parentOf(sessionID);
493
+ const target = parent ?? sessionID;
494
+ track(target);
495
+ const rows = statusCardRows(target, parent !== undefined, columns());
496
+ if (rows === undefined)
497
+ return null;
498
+ return ui.jsx("box", {
499
+ flexDirection: "column",
500
+ flexShrink: 0,
501
+ paddingLeft: 1,
502
+ paddingRight: 1,
503
+ backgroundColor: palette().panel ?? "transparent",
504
+ children: rows
505
+ });
506
+ };
507
+ }
508
+ }));
509
+ }
510
+ void checkRestart();
511
+ restartTimer = setInterval(() => void checkRestart(), restartCheckMs);
240
512
  return async () => {
241
513
  disposed = true;
242
514
  for (const dispose of watchers.values())
@@ -246,12 +518,18 @@ export async function setupLitOpenCodeTui(context, runtime) {
246
518
  unlisten();
247
519
  for (const release of releases)
248
520
  release();
521
+ if (restartTimer !== undefined)
522
+ clearInterval(restartTimer);
249
523
  if (frameTimer !== undefined)
250
524
  clearInterval(frameTimer);
251
525
  if (refreshTimer !== undefined)
252
526
  clearTimeout(refreshTimer);
253
527
  if (pollTimer !== undefined)
254
528
  clearInterval(pollTimer);
529
+ if (liveTimer !== undefined)
530
+ clearInterval(liveTimer);
531
+ if (bandTimer !== undefined)
532
+ clearInterval(bandTimer);
255
533
  sessions.clear();
256
534
  ignitions.clear();
257
535
  trackedAt.clear();
@@ -1 +1 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.36"><title>release: 1.0.36</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.36</text></g></svg>
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.37"><title>release: 1.0.37</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.37</text></g></svg>
@@ -380,10 +380,18 @@ Code Mode λ°–, λͺ¨λΈμ˜ 도ꡬ λͺ©λ‘μ— λ°”λ‘œ μžˆμœΌλ―€λ‘œ `lit-plan`도 계
380
380
  lit ν”„λ‘¬ν”„νŠΈμ΄λ©΄ ν”„λ‘¬ν”„νŠΈ μ•„λž˜ 쀄(`prompt.footer.status`)에 `πŸ”₯ LIT IGNITED Β· <discipline>`이 λ‚˜μ˜΅λ‹ˆλ‹€.
381
381
  OpenCode 1μ—μ„œλŠ” ν‘œμ‹œκ°€ μ˜ˆμ „ μžλ¦¬μ— κ·ΈλŒ€λ‘œ μžˆμŠ΅λ‹ˆλ‹€.
382
382
  - *ν† μŠ€νŠΈ.* OpenCode 2μ—μ„œλŠ” μ„œλ²„κ°€ ν† μŠ€νŠΈλ₯Ό λ„μšΈ 수 μ—†μŠ΅λ‹ˆλ‹€. μ„œλ²„κ°€ μ‚¬μš©μž λ©”μ‹œμ§€ 메타데이터에 남긴 lit ν‘œμ‹œλ₯Ό
383
- TUIκ°€ 보고 점화 ν† μŠ€νŠΈλ₯Ό 직접 λ„μ›λ‹ˆλ‹€. μ„œλ²„λ§Œ μ•Œλ˜ μžλ™ μΈμˆ˜μΈκ³„ μ•Œλ¦Όκ³Ό μŠ€ν‚¬ νžŒνŠΈλŠ” 그곳에 λ‚˜μ˜€μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.
384
- - *TUI.* λ―Έμ…˜ νŒ¨λ„μ€ μ „μ²˜λŸΌ μ„Έμ…˜ μ‚¬μ΄λ“œλ°”μ— μžˆμŠ΅λ‹ˆλ‹€. OpenCode 2λŠ” μ„œλ²„ ν”ŒλŸ¬κ·ΈμΈκ³Ό ν•¨κ»˜ μ˜€λŠ” TUI에 μ˜΅μ…˜μ„
385
- λ„˜κΈ°μ§€ μ•Šκ³  ν•  일 λͺ©λ‘λ„ 두지 μ•ŠμœΌλ―€λ‘œ, λ―Έμ…˜ 진행은 ledger와 μž‘μ—… 기둝과 κ³„νšμ—μ„œ μ½μŠ΅λ‹ˆλ‹€. μ„Έμ…˜ μ•„λž˜ μƒνƒœ 띠와
386
- ν™ˆ ν™”λ©΄ ν•œ 쀄은 OpenCode 1μ—μ„œλ§Œ κ·Έλ¦½λ‹ˆλ‹€.
383
+ TUIκ°€ 보고 점화 ν† μŠ€νŠΈλ₯Ό 직접 λ„μš°κ³ , OpenCode 1κ³Ό 같은 κ΄€λ¬Έ ν† μŠ€νŠΈ(κ³„νš μ €μž₯, λͺ©ν‘œ μ„€μ •, κΈ°μ€€ 톡과, λ§‰νž˜, μ™„λ£Œ)에
384
+ 더해 OpenCode 2μ—λ§Œ μžˆλŠ” μ„Έ κ°€μ§€λ₯Ό λ„μ›λ‹ˆλ‹€. μž‘μ—… 단계가 λ„˜μ–΄κ°ˆ λ•Œ(`πŸ”₯ Stage`, `work 3/7`), 규율이 λ°”λ€” λ•Œ
385
+ (`πŸ”₯ lit-loop β†’ start-work`), 레인이 μ‹œμž‘λ˜κ±°λ‚˜ 끝날 λ•Œμž…λ‹ˆλ‹€. ν•œκΊΌλ²ˆμ— μΌμ–΄λ‚œ 일은 `+N more`κ°€ 뢙은 ν† μŠ€νŠΈ
386
+ ν•˜λ‚˜λ‘œ ν•©μΉ©λ‹ˆλ‹€. μž¬μ‹œμž‘ μ•ˆλ‚΄, `lit adhd` μ œμ•ˆ, 켜 두면 λ‚˜μ˜€λŠ” μ™„λ£Œ μ•Œλ¦Όμ€ OpenCode 1κ³Ό κ°™μŠ΅λ‹ˆλ‹€. μ„œλ²„λ§Œ μ•Œλ˜
387
+ μžλ™ μΈμˆ˜μΈκ³„ μ•Œλ¦Όκ³Ό μŠ€ν‚¬ νžŒνŠΈλŠ” 그곳에 λ‚˜μ˜€μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.
388
+ - *TUI.* HUDλŠ” OpenCode 2κ°€ κ³΅κ°œν•œ μŠ¬λ‘―μ— λ†“μž…λ‹ˆλ‹€. μƒνƒœ μΉ΄λ“œλŠ” `app` μŠ¬λ‘―μ—μ„œ μ„Έμ…˜κ³Ό μ‚¬μ΄λ“œλ°” μ•„λž˜μ— κ·Έλ €μ§€κ³ ,
389
+ 쉬든 μΌν•˜λ“  λ„€ 쀄을 μœ μ§€ν•©λ‹ˆλ‹€. 단계 칩은 μ—μ΄μ „νŠΈΒ·λͺ¨λΈ 쀄 였λ₯Έμͺ½μ— 슬둯이 μ—†μ–΄μ„œ 점화 쀄과 ν•¨κ»˜
390
+ `prompt.footer.status`에 있고, ν™ˆ 쀄은 `home.footer`, λ―Έμ…˜ νŒ¨λ„μ€ `sidebar.content`에 μžˆμœΌλ―€λ‘œ μ‚¬μ΄λ“œλ°”λ₯Ό μ—΄μ–΄
391
+ 두면 λ³΄μž…λ‹ˆλ‹€. μΉ΄λ“œ, μΉ©, νŒ¨λ„, ν™ˆ 쀄은 OpenCode 1κ³Ό 같은 κΈ€μž λΉŒλ”λ₯Ό μ”λ‹ˆλ‹€. ν”ŒλŸ¬κ·ΈμΈ ν•­λͺ©μ˜ `band`, `notify`
392
+ μ˜΅μ…˜μ€ OpenCode 2κ°€ λ„˜κ²¨ μ£ΌλŠ” μ˜΅μ…˜μ—μ„œ μ½μŠ΅λ‹ˆλ‹€. OpenCode 2λŠ” ν•  일 λͺ©λ‘μ„ 두지 μ•ŠμœΌλ―€λ‘œ λ―Έμ…˜ 진행은 ledger와
393
+ μž‘μ—… 기둝과 κ³„νšμ—μ„œ μ½μŠ΅λ‹ˆλ‹€. 도ꡬ 이름도 λ‹€λ¦…λ‹ˆλ‹€(`shell`, `subagent`, `id`λ₯Ό λ°›λŠ” `skill` 도ꡬ). μΉ΄λ“œλŠ” κ·Έ
394
+ 이름을 μ½μŠ΅λ‹ˆλ‹€. OpenCode 2λŠ” ν”ŒλŸ¬κ·ΈμΈμ— μš”κΈˆμ œ ν•œλ„ 수치λ₯Ό μ£Όμ§€ μ•ŠμœΌλ―€λ‘œ 그리지 μ•ŠμŠ΅λ‹ˆλ‹€.
387
395
  - *μ—…λ°μ΄νŠΈ.* OpenCode 2μ—μ„œλŠ” ν”ŒλŸ¬κ·ΈμΈ μ•ˆμ˜ μžλ™ μ—…λ°μ΄νŠΈκ°€ μ—†μŠ΅λ‹ˆλ‹€. `opencode plugin update`λ₯Ό μ‹€ν–‰ν•˜κ±°λ‚˜
388
396
  OpenCode 2의 μ—…λ°μ΄νŠΈ 방식에 λ§‘κΈ°λ©΄ λ©λ‹ˆλ‹€.
389
397
 
@@ -458,7 +466,7 @@ OpenCode 1.18은 `tui.json`μ΄λ‚˜ `tui.jsonc`의 `plugin` 배열에 적힌 TUI
458
466
  - `lit-adhd`(별칭 `lit-rewind`)κ°€ λ„λŠ” λ™μ•ˆ ν…Œλ‘λ¦¬, LIT κΈ€μž, κ²Œμ΄μ§€κ°€ 같은 밝기의 μƒ‰μƒν™˜μ„ 따라 λ•λ‹ˆλ‹€.
459
467
  1μ΄ˆμ— λ„€ 번 색이 μ‘°κΈˆμ”© λ°”λ€Œκ³  κΉœλ°•μ΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€. `motion: false`와 `NO_COLOR`μ—μ„œλŠ” κΊΌμ§‘λ‹ˆλ‹€. 이전
460
468
  μž‘μ—…μ΄ μžˆλŠ” μž‘μ—… κ³΅κ°„μ˜ μ‰¬λŠ” μ„Έμ…˜μ€ `lit adhd to pick up where you left off`λ₯Ό 보여 μ£Όκ³ , κ·Έκ³³μ—μ„œ 처음 μ—°
461
- μƒˆ μ„Έμ…˜μ€ 같은 문ꡬ둜 ν† μŠ€νŠΈλ₯Ό ν•œ 번 λ„μ›λ‹ˆλ‹€(v1 TUI만. v2 TUIμ—λŠ” 띠도 ν† μŠ€νŠΈλ„ μ—†μŠ΅λ‹ˆλ‹€).
469
+ μƒˆ μ„Έμ…˜μ€ 같은 문ꡬ둜 ν† μŠ€νŠΈλ₯Ό ν•œ 번 λ„μ›λ‹ˆλ‹€(OpenCode 1κ³Ό 2 λͺ¨λ‘).
462
470
  - μΉ΄λ“œμ˜ λͺ¨μ–‘은 ν•œ λ²ˆμ— ν•˜λ‚˜μ΄κ³ , `src/tui/mission-looks.ts`의 μž‘μ€ λͺ©λ‘μ—μ„œ κ³ λ¦…λ‹ˆλ‹€. lit-adhdλ₯Ό μ‹€ν–‰ν•˜λŠ”
463
471
  길은 λͺ¨λ‘ 되감기 λͺ¨μ–‘이 λ©λ‹ˆλ‹€. μ±„νŒ…μ˜ `lit adhd`/`lit rewind`, `/lit-adhd`/`/lit-rewind`(v2에 μ„€μΉ˜λœ 별칭
464
472
  νŒŒμΌμ—μ„œ 온 것도 포함), λ„€μ΄ν‹°λΈŒ `skill` λ‘œλ“œκ°€ κ·Έλ ‡μŠ΅λ‹ˆλ‹€. μ„Έμ…˜μ΄ μ‚¬μš©μžλ₯Ό κΈ°λ‹€λ¦¬λŠ” λ™μ•ˆ(κΆŒν•œ μš”μ²­μ΄λ‚˜ 질문,
package/docs/reference.md CHANGED
@@ -498,12 +498,22 @@ settings but does not send them yet.
498
498
  TUI: the line under the prompt (`prompt.footer.status`) reads `πŸ”₯ LIT IGNITED Β· <discipline>` while the
499
499
  latest prompt of the session is a lit prompt. On OpenCode 1 the mark stays where it was.
500
500
  - *Toasts.* The server cannot show toasts on OpenCode 2. The TUI raises the ignition toast itself when a
501
- user message arrives with the lit mark the server writes into its metadata. The automatic-handoff
502
- notices and the skill hint, which only the server knew about, are not shown there.
503
- - *TUI.* The mission panel sits in the session sidebar as before. OpenCode 2 passes no options to a TUI
504
- that comes with a server plugin, and it keeps no todo list, so mission progress comes from the ledger,
505
- the work record and the plan. The status band under the session and the home-screen line are drawn on
506
- OpenCode 1 only.
501
+ user message arrives with the lit mark the server writes into its metadata, and it raises the same gate
502
+ toasts as OpenCode 1 (plan saved, goal set, criterion passed, blockers, complete) plus three more that
503
+ only OpenCode 2 has: the workflow stage moving (`πŸ”₯ Stage`, `work 3/7`), a switch of discipline
504
+ (`πŸ”₯ lit-loop β†’ start-work`) and a lane starting or finishing. A burst folds into one toast with a
505
+ `+N more` tail. The restart notice, the `lit adhd` suggestion and the opt-in finish alert work as on
506
+ OpenCode 1. The automatic-handoff notices and the skill hint, which only the server knew about, are
507
+ not shown there.
508
+ - *TUI.* The HUD is placed in the slots OpenCode 2 publishes: the status card is claimed in `app`, under
509
+ the session and the sidebar, and keeps its four rows idle or working; the stage chip shares
510
+ `prompt.footer.status` with the ignition line, because there is no slot right of the agent and model
511
+ row; the home line is in `home.footer`; the mission panel sits in `sidebar.content`, so it shows when
512
+ the sidebar is open. The card, chip, panel and home line use the same text builders as OpenCode 1. The
513
+ `band` and `notify` options of the plugin entry are read from the options OpenCode 2 hands over. OpenCode 2
514
+ keeps no todo list, so mission progress comes from the ledger, the work record and the plan. It names
515
+ its tools differently (`shell`, `subagent`, a `skill` tool that takes `id`), and the card reads those
516
+ names. OpenCode 2 gives plugins no rate-limit figures, so none are drawn.
507
517
  - *Updates.* There is no automatic self-update inside the plugin on OpenCode 2. Run
508
518
  `opencode plugin update`, or let OpenCode 2's own update policy handle it.
509
519
 
@@ -586,7 +596,7 @@ What it draws:
586
596
  the colour wheel at one fixed lightness, four small colour steps a second with no flashing. It
587
597
  is off under `motion: false` and `NO_COLOR`. An idle session in a workspace with earlier work
588
598
  says `lit adhd to pick up where you left off`, and the first fresh session there raises one
589
- toast with the same words (v1 TUI only; the v2 TUI has no band and no toast).
599
+ toast with the same words (OpenCode 1 and 2).
590
600
  - one look at a time, picked from a small registry in `src/tui/mission-looks.ts`. Every route that runs
591
601
  lit-adhd wears the rewind look: `lit adhd`/`lit rewind` in chat, `/lit-adhd`/`/lit-rewind` (also from an
592
602
  installed v2 alias file) and a native `skill` load. While the session waits on you (a permission or a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@litfamily/litopencode",
3
- "version": "1.0.36",
3
+ "version": "1.0.37",
4
4
  "description": "Workflow plugin for OpenCode: add lit to a prompt and the work is planned, built, checked and written down so the next session can pick it up.",
5
5
  "keywords": [
6
6
  "opencode",
@@ -108,7 +108,7 @@ function prepare(out) {
108
108
  const cover = loadCoverTreatment(out);
109
109
  const coverDir = path.join(out, "cover");
110
110
  if (!existsSync(path.join(coverDir, "index.html"))) throw stop(EXIT.STAGE_CONTRACT_ERROR, "STAGE_CONTRACT_ERROR", `no cover page at ${path.join(coverDir, "index.html")}; write it first (references/cover.md)`);
111
- const scan = scanStage(coverDir);
111
+ const scan = scanStage(coverDir, { clips: false });
112
112
  const assets = checkAssets({ out, treatment: cover, rasters: scan.rasters });
113
113
  const state = inspectWarmState();
114
114
  if (state.blocked) throw stop(state.blocked.exit, state.blocked.name, `${state.blocked.detail}; fix: ${state.fix}`);
@@ -6,7 +6,7 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from "node:
6
6
  import path from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { chromeFlagRungs } from "../constants.mjs";
9
- import { StageError, contentTypes, insideDir } from "./scan.mjs";
9
+ import { StageError, clipsScript, contentTypes, insideDir } from "./scan.mjs";
10
10
 
11
11
  export const stageHost = "lit.stage";
12
12
  export const stageOrigin = `http://${stageHost}`;
@@ -108,7 +108,8 @@ export async function openStage(chromium, { chromePath, profile, stageDir, width
108
108
  const line = String(error?.message ?? error).split("\n").find((part) => part.trim()) ?? String(error);
109
109
  throw Object.assign(new Error(`BLOCKED_NO_CHROME (10): Chrome failed to launch headless: ${line}; fix: check that Chrome can start headless in this sandbox`), { exitCode: 10 });
110
110
  }
111
- const { blocked, refused, missing } = await serveStage(context, { stageDir, fonts, scripts: { "/lit/stage-kit.js": kitSource(), ...styleFiles } });
111
+ const clips = clipsScript(stageDir);
112
+ const { blocked, refused, missing } = await serveStage(context, { stageDir, fonts, scripts: { "/lit/stage-kit.js": kitSource(), ...(clips ? { "/lit/clips.js": clips } : {}), ...styleFiles } });
112
113
  await context.addInitScript({ content: `window.__litStageConfig = ${JSON.stringify({ seed: seed >>> 0, fps })};\n${clockSource()}` });
113
114
  if (extraInit) await context.addInitScript({ content: extraInit });
114
115
  const page = context.pages()[0] ?? await context.newPage();
@@ -146,6 +146,7 @@ export function formatStageReport(result, context) {
146
146
  `outputs: ${out("film.mp4")} Β· ${out(manifest.previewEncoder === "gif" ? "preview.gif" : "preview.webp")} (encoder: ${manifest.previewEncoder}) Β· ${out("poster.png")} Β· ${out("reduced-motion.png")}`,
147
147
  `format / fps / length: ${manifest.format} ${manifest.resolution.join("x")} @ ${manifest.fps} fps, ${manifest.durationSec.toFixed(2)} s (treatment ${manifest.targetDurationSec} s)`,
148
148
  `renderer: stage ${manifest.rung === "gpu" ? `GPU rung (${manifest.renderer ?? "native ANGLE"})` : "software rung"} (flags: ${(manifest.chromeFlags ?? []).join(" ")})`,
149
+ ...(manifest.clips?.length ? [`clips: ${manifest.clips.map((clip) => `${clip.id} ${clip.frames} frames at ${clip.fps} fps`).join(", ")} (keyed overlays, chosen from film time)`] : []),
149
150
  ...(manifest.warnings ?? []).map((note) => `notice: ${note}`),
150
151
  `timing: master ${manifest.timing?.masterSec ?? "?"} s, per frame p50 ${manifest.timing?.frameP50 ?? "?"} ms / p95 ${manifest.timing?.frameP95 ?? "?"} ms, total ${manifest.timing?.totalSec ?? "?"} s`,
151
152
  "",
@@ -450,6 +450,7 @@ export async function renderStage(out, { round = 1, stillsOnly = false } = {}) {
450
450
  preview: preview ? { width: preview.width, height: preview.height, fps: preview.fps, bytes: preview.bytes } : null,
451
451
  posterFrame, posterSize: [poster.width, poster.height], stillFrame, determinismFrames: samples,
452
452
  timing: { ...master.timing, totalSec: Number(((performance.now() - began) / 1000).toFixed(1)) },
453
+ ...(context.scan.clips.length ? { clips: context.scan.clips.map((clip) => ({ id: clip.id, frames: clip.count, fps: clip.fps, width: clip.width, height: clip.height, bytes: clip.bytes })) } : {}),
453
454
  ...(context.plate ? { plate: { blender: context.plate.blender, engine: context.plate.engine, device: context.plate.device, samples: context.plate.samples, fps: context.plate.fps, frames: context.plate.frames.length } } : {}),
454
455
  sound, qa: { file: ".run/qa.json", samples: qaResult.samples.length, trackFrames: qaResult.trackFrames, allowedFaces: qaResult.allowedFaces },
455
456
  treatmentSha256: sha256(readFileSync(path.join(out, "treatment.json"))), stageSha256: stageSha(context.stageDir),