@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.
- package/CHANGELOG.md +12 -0
- package/README-Ko-KR.md +6 -6
- package/README.md +6 -6
- package/dist/tui/mission-band.d.ts +11 -0
- package/dist/tui/mission-band.js +44 -16
- package/dist/tui/mission-model.d.ts +69 -3
- package/dist/tui/mission-model.js +155 -29
- package/dist/tui/mission-motion.d.ts +10 -1
- package/dist/tui/mission-motion.js +17 -7
- package/dist/tui/mission-source.d.ts +30 -5
- package/dist/tui/mission-source.js +159 -11
- package/dist/tui/mission-text.d.ts +34 -9
- package/dist/tui/mission-text.js +169 -74
- package/dist/tui.js +37 -5
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/reference-Ko-KR.md +70 -19
- package/docs/reference.md +81 -22
- package/package.json +1 -1
package/dist/tui/mission-text.js
CHANGED
|
@@ -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
|
-
|
|
186
|
-
|
|
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 <
|
|
189
|
-
segments.push(index < filled ? { text: "▰", fg: gradientAt([palette.primary, palette.accent], index /
|
|
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
|
-
/**
|
|
197
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
205
|
-
return { state: "done", doneMix:
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const
|
|
227
|
-
if (
|
|
228
|
-
return
|
|
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 === "
|
|
232
|
-
return [name,
|
|
233
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
/**
|
|
246
|
-
|
|
247
|
-
|
|
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,
|
|
254
|
-
|
|
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
|
-
|
|
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
|
|
265
|
-
const
|
|
266
|
-
const done = view.complete &&
|
|
267
|
-
const value = done ? `✓ ${
|
|
268
|
-
const style = pillStyle(done ? "done" :
|
|
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
|
|
318
|
-
*
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
|
514
|
-
* the bar, the
|
|
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
|
-
|
|
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
|
-
|
|
536
|
-
|
|
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
|
|
86
|
-
const
|
|
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
|
|
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.
|
|
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>
|
package/docs/reference-Ko-KR.md
CHANGED
|
@@ -257,25 +257,46 @@ OpenCode 1.18은 `tui.json`이나 `tui.jsonc`의 `plugin` 배열에 적힌 TUI
|
|
|
257
257
|
|
|
258
258
|
화면에 그리는 것:
|
|
259
259
|
|
|
260
|
-
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
굵은 목표 제목, 단계
|
|
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
|
-
- 첫째 줄은
|
|
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
|
-
막대, 기준
|
|
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
|
-
- 목표가
|
|
297
|
-
바뀌고 막대가 끝까지 차며,
|
|
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
|
-
않습니다.
|
|
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가 걸립니다.
|
|
318
|
-
|
|
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
|
|