@litfamily/litopencode 1.0.19 → 1.0.20

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 CHANGED
@@ -5,6 +5,10 @@ are tracked separately from this product history.
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## 1.0.20 - 2026-10-12
9
+
10
+ - The band along the bottom of OpenCode's main screen is now a small card that stays on screen for the whole session. The top edge shows LIT, the current workflow and its phase. The first row shows PLAN ━ WORK ━ REVIEW with the goal and the next task. The second row shows a chip for each LitOpenCode skill that ran: skills used by the current request are bright and get a short shine as they appear, then dim into a recent list when the answer is done, and extra chips fold into `+N`. The bottom edge shows context use, elapsed time and the git branch. Before you use lit in a session, the card shows the project's last goal. `band: false` still hides it and `band: { card: true }` still adds the detail card.
11
+
8
12
  ## 1.0.19 - 2026-10-11
9
13
 
10
14
  - The README is shorter for first-time readers. It says what LitOpenCode does, how to install it and what to type first, and the detail now lives in docs pages (guide, skills, screens and brand) in English and Korean. The GitHub page also plays a short film of a real session.
package/README-Ko-KR.md CHANGED
@@ -1,13 +1,13 @@
1
- <p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.19/docs/assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.19/docs/assets/cover-motion.webp" width="100%" alt="LitFamily 모션 커버: 다섯 로봇 패널이 차례로 켜지고, LitOpenCode 로봇의 눈과 테두리가 빛난 뒤 LITFAMILY와 KEEP THE WORK LIT. 문구가 밝아지는 영상" /></picture></p>
1
+ <p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.20/docs/assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.20/docs/assets/cover-motion.webp" width="100%" alt="LitFamily 모션 커버: 다섯 로봇 패널이 차례로 켜지고, LitOpenCode 로봇의 눈과 테두리가 빛난 뒤 LITFAMILY와 KEEP THE WORK LIT. 문구가 밝아지는 영상" /></picture></p>
2
2
 
3
3
  # LitOpenCode
4
4
 
5
5
  **LitOpenCode는 OpenCode용 워크플로 플러그인입니다. 프롬프트에 `lit`만 붙이면 계획하고, 만들고, 확인하고, 기록까지 남겨서 다음 세션이 어디서 멈췄는지 바로 압니다.**
6
6
 
7
7
  <p align="center">
8
- <a href="https://www.npmjs.com/package/@litfamily/litopencode"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.19/docs/assets/readme/badge-version.svg" alt="1.0.19" /></a>
8
+ <a href="https://www.npmjs.com/package/@litfamily/litopencode"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.20/docs/assets/readme/badge-version.svg" alt="1.0.20" /></a>
9
9
  <a href="https://www.npmjs.com/package/@litfamily/litopencode"><img src="https://img.shields.io/npm/dm/@litfamily/litopencode?label=downloads&color=D7F75B" alt="월간 npm 다운로드 수" /></a>
10
- <a href="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.19/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.19/docs/assets/readme/badge-license.svg" alt="MIT 라이선스" /></a>
10
+ <a href="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.20/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.20/docs/assets/readme/badge-license.svg" alt="MIT 라이선스" /></a>
11
11
  <a href="https://github.com/wjgoarxiv/litopencode"><img src="https://img.shields.io/github/stars/wjgoarxiv/litopencode?style=social" alt="GitHub 스타" /></a>
12
12
  </p>
13
13
 
@@ -57,9 +57,9 @@ npm uninstall -g @litfamily/litopencode
57
57
  ## 더 보기
58
58
 
59
59
  - [GitHub의 사용 안내](https://github.com/wjgoarxiv/litopencode/blob/main/docs/guide-Ko-KR.md): 설치 옵션, 자동 인수인계, 문제 해결, 제거
60
- - [워크플로 상세 안내](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.19/docs/reference-Ko-KR.md) · [English reference](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.19/docs/reference.md)
61
- - [마이그레이션 안내](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.19/docs/migration.md) · [Changelog](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.19/CHANGELOG.md) · [개인정보와 네트워크 동작](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.19/docs/privacy.md)
62
- - [LitFamily 허브](https://github.com/wjgoarxiv/litfamily) · [MIT 라이선스](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.19/LICENSE) · [ASCII 글꼴 라이선스](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.19/docs/assets/readme/JetBrainsMono-OFL.txt)
60
+ - [워크플로 상세 안내](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.20/docs/reference-Ko-KR.md) · [English reference](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.20/docs/reference.md)
61
+ - [마이그레이션 안내](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.20/docs/migration.md) · [Changelog](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.20/CHANGELOG.md) · [개인정보와 네트워크 동작](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.20/docs/privacy.md)
62
+ - [LitFamily 허브](https://github.com/wjgoarxiv/litfamily) · [MIT 라이선스](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.20/LICENSE) · [ASCII 글꼴 라이선스](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.20/docs/assets/readme/JetBrainsMono-OFL.txt)
63
63
 
64
64
  <details>
65
65
  <summary>ASCII 로고 복사</summary>
package/README.md CHANGED
@@ -1,13 +1,13 @@
1
- <p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.19/docs/assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.19/docs/assets/cover-motion.webp" width="100%" alt="LitFamily motion cover: five armored robots power on one by one, the LitOpenCode robot wakes with glowing eyes and a lit frame, then LITFAMILY and KEEP THE WORK LIT. light up." /></picture></p>
1
+ <p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.20/docs/assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.20/docs/assets/cover-motion.webp" width="100%" alt="LitFamily motion cover: five armored robots power on one by one, the LitOpenCode robot wakes with glowing eyes and a lit frame, then LITFAMILY and KEEP THE WORK LIT. light up." /></picture></p>
2
2
 
3
3
  # LitOpenCode
4
4
 
5
5
  **LitOpenCode is a workflow plugin for OpenCode. Add `lit` to a prompt and the work gets planned, built, checked and written down, so the next session knows where you stopped.**
6
6
 
7
7
  <p align="center">
8
- <a href="https://www.npmjs.com/package/@litfamily/litopencode"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.19/docs/assets/readme/badge-version.svg" alt="1.0.19" /></a>
8
+ <a href="https://www.npmjs.com/package/@litfamily/litopencode"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.20/docs/assets/readme/badge-version.svg" alt="1.0.20" /></a>
9
9
  <a href="https://www.npmjs.com/package/@litfamily/litopencode"><img src="https://img.shields.io/npm/dm/@litfamily/litopencode?label=downloads&color=D7F75B" alt="npm downloads per month" /></a>
10
- <a href="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.19/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.19/docs/assets/readme/badge-license.svg" alt="MIT license" /></a>
10
+ <a href="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.20/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.20/docs/assets/readme/badge-license.svg" alt="MIT license" /></a>
11
11
  <a href="https://github.com/wjgoarxiv/litopencode"><img src="https://img.shields.io/github/stars/wjgoarxiv/litopencode?style=social" alt="GitHub stars" /></a>
12
12
  </p>
13
13
 
@@ -57,9 +57,9 @@ npm uninstall -g @litfamily/litopencode
57
57
  ## More
58
58
 
59
59
  - [Guide on GitHub](https://github.com/wjgoarxiv/litopencode/blob/main/docs/guide.md): install options, automatic handoff, troubleshooting and removal.
60
- - [Workflow reference](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.19/docs/reference.md)
61
- - [Migration notes](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.19/docs/migration.md) · [Changelog](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.19/CHANGELOG.md) · [Privacy and network behavior](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.19/docs/privacy.md)
62
- - [LitFamily hub](https://github.com/wjgoarxiv/litfamily) · [MIT license](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.19/LICENSE) · [ASCII font license](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.19/docs/assets/readme/JetBrainsMono-OFL.txt)
60
+ - [Workflow reference](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.20/docs/reference.md)
61
+ - [Migration notes](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.20/docs/migration.md) · [Changelog](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.20/CHANGELOG.md) · [Privacy and network behavior](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.20/docs/privacy.md)
62
+ - [LitFamily hub](https://github.com/wjgoarxiv/litfamily) · [MIT license](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.20/LICENSE) · [ASCII font license](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.20/docs/assets/readme/JetBrainsMono-OFL.txt)
63
63
 
64
64
  <details>
65
65
  <summary>Copy ASCII logo</summary>
@@ -1,29 +1,47 @@
1
1
  import { type MotionFrame } from "./mission-motion.ts";
2
- import type { MissionView } from "./mission-model.ts";
3
- import { type Line, type MissionPalette } from "./mission-text.ts";
2
+ import { type MissionView } from "./mission-model.ts";
3
+ import type { ChipShimmer, SkillChip } from "./mission-skills.ts";
4
+ import { type LastMission, type Line, type MissionPalette } from "./mission-text.ts";
4
5
  export type BandOptions = {
5
6
  readonly enabled: boolean;
6
7
  readonly card: boolean;
7
8
  };
8
9
  /**
9
10
  * Reads the band switches from the plugin's tui.json options: `band: false` turns the band off,
10
- * `band: { card: true }` adds the card under the ribbon. Anything else keeps the defaults.
11
+ * `band: { card: true }` adds the detail card under the status card. Anything else keeps the defaults.
11
12
  */
12
13
  export declare function readBandOptions(options: unknown): BandOptions;
13
14
  /**
14
- * One ribbon row exactly `width` cells wide: pills, bar, the next task or a warning, and the elapsed
15
- * time, shedding parts in a fixed order so it never wraps. Idle sessions draw nothing.
15
+ * The skill chips row, exactly `width` cells: bright accent pills for the current request first, then
16
+ * dim recent names, and `+N` for the ones that do not fit. The label goes before any chip does.
16
17
  */
17
- export declare function bandRibbon(view: MissionView, frame: MotionFrame, palette: MissionPalette, width: number, place: {
18
+ export declare function skillRow(chips: readonly SkillChip[], shimmer: ChipShimmer | undefined, palette: MissionPalette, width: number): Line;
19
+ export type CardFigures = {
20
+ readonly contextPercent: number | null;
21
+ readonly branch?: string;
18
22
  readonly inLane: boolean;
19
- }): Line;
23
+ };
24
+ export type CardInputs = {
25
+ readonly view: MissionView;
26
+ readonly frame: MotionFrame;
27
+ readonly chips: readonly SkillChip[];
28
+ readonly shimmer?: ChipShimmer;
29
+ readonly figures: CardFigures;
30
+ /** The project's last goal, shown on the track while the session has no lit mission. */
31
+ readonly last?: LastMission;
32
+ };
33
+ /**
34
+ * The status card: four rows of exactly `width` cells whether the session is idle or working, so
35
+ * the prompt never moves. Each row sheds its own low-priority parts as the terminal narrows.
36
+ */
37
+ export declare function statusCard(input: CardInputs, palette: MissionPalette, width: number): Line[];
20
38
  export type BandCard = {
21
39
  readonly title: string;
22
40
  readonly border: string;
23
41
  readonly rows: readonly Line[];
24
42
  };
25
43
  /**
26
- * The opt-in card under the ribbon. It keeps a fixed four rows whenever a goal exists, warnings
44
+ * The opt-in detail card under the status card. It keeps a fixed four rows whenever a goal exists, warnings
27
45
  * first, so turning a warning on or off never moves the prompt.
28
46
  */
29
47
  export declare function bandCard(view: MissionView, frame: MotionFrame, palette: MissionPalette, width: number): BandCard | undefined;
@@ -1,11 +1,14 @@
1
- // The band: one full-width ribbon row in OpenCode's app_bottom slot that carries the lit mission on
2
- // the main screen, plus an opt-in card. It reuses the chip's pills, the panel's bar and the shared
3
- // motion frame, so the chip, the panel and the band always move together.
4
- import { mixHex, motionTiming } from "./mission-motion.js";
5
- import { cellWidth, chipGroups, chipSparkCells, chipSparkWidth, groupsWidth, lineWidth, missionPanel, progressBar, shimmerAt, statusSegments, truncateCells } from "./mission-text.js";
1
+ // The band: a status card in OpenCode's full-width app_bottom slot that stays on screen for the
2
+ // whole session, plus an opt-in detail card. Its border carries the LIT mark, the discipline and the
3
+ // phase; row one is the phase track, row two the skill chips, and the bottom border the live figures.
4
+ // It reuses the chip's pills, the panel's bar and the shared motion frame, so the chip, the panel and
5
+ // the band always move together.
6
+ import { ease, mixHex, motionTiming, seg } from "./mission-motion.js";
7
+ import { contextWarnPercent, missionStatus } from "./mission-model.js";
8
+ import { cellWidth, chipSparkCells, chipSparkWidth, fade, lineWidth, missionPanel, progressBar, shimmerAt, shine, statusSegments, stepPill, truncateCells } from "./mission-text.js";
6
9
  /**
7
10
  * Reads the band switches from the plugin's tui.json options: `band: false` turns the band off,
8
- * `band: { card: true }` adds the card under the ribbon. Anything else keeps the defaults.
11
+ * `band: { card: true }` adds the detail card under the status card. Anything else keeps the defaults.
9
12
  */
10
13
  export function readBandOptions(options) {
11
14
  const band = typeof options === "object" && options !== null ? options.band : undefined;
@@ -14,18 +17,6 @@ export function readBandOptions(options) {
14
17
  const card = typeof band === "object" && band !== null && band.card === true;
15
18
  return { enabled: true, card };
16
19
  }
17
- // Shedding order as the row narrows: step labels first, then the next-task text, then bar width,
18
- // then the time. Each plan is tried in turn and the first that fits wins.
19
- const plans = [
20
- { tier: "full", bar: 18, text: true, time: true },
21
- { tier: "medium", bar: 18, text: true, time: true },
22
- { tier: "tight", bar: 18, text: true, time: true },
23
- { tier: "tight", bar: 18, text: false, time: true },
24
- { tier: "tight", bar: 10, text: false, time: true },
25
- { tier: "tight", bar: 5, text: false, time: true },
26
- { tier: "tight", bar: 0, text: false, time: true },
27
- { tier: "tight", bar: 0, text: false, time: false }
28
- ];
29
20
  const minimumText = 12;
30
21
  function elapsedText(minutes) {
31
22
  const total = Math.max(0, minutes ?? 0);
@@ -50,41 +41,224 @@ function trailing(view, frame, palette, room) {
50
41
  return [];
51
42
  return [{ text: " ▸ ", fg: palette.muted }, { text: truncateCells(view.next, room - 4), fg: palette.text }];
52
43
  }
53
- /**
54
- * One ribbon row exactly `width` cells wide: pills, bar, the next task or a warning, and the elapsed
55
- * time, shedding parts in a fixed order so it never wraps. Idle sessions draw nothing.
56
- */
57
- export function bandRibbon(view, frame, palette, width, place) {
58
- if (!view.active || width <= 0)
59
- return [];
60
- const time = { text: `⟳ ${elapsedText(view.elapsedMinutes)}${place.inLane ? " · in lane" : ""}`, fg: palette.muted };
44
+ /** Cuts or pads a line to exactly `width` cells, keeping wide glyphs whole. */
45
+ function fitLine(line, width, palette) {
46
+ const out = [];
47
+ let used = 0;
48
+ for (const segment of line) {
49
+ const room = width - used;
50
+ if (room <= 0)
51
+ break;
52
+ if (cellWidth(segment.text) <= room) {
53
+ out.push(segment);
54
+ used += cellWidth(segment.text);
55
+ continue;
56
+ }
57
+ let text = "";
58
+ let taken = 0;
59
+ for (const char of segment.text) {
60
+ const w = cellWidth(char);
61
+ if (taken + w > room)
62
+ break;
63
+ text += char;
64
+ taken += w;
65
+ }
66
+ if (text !== "")
67
+ out.push({ ...segment, text });
68
+ used += taken;
69
+ break;
70
+ }
71
+ if (used < width)
72
+ out.push({ text: " ".repeat(width - used), fg: palette.muted });
73
+ return out;
74
+ }
75
+ /** A border row: corner, optional left label, rule, optional right label, corner; undefined when it does not fit. */
76
+ function ruled(lead, left, right, tail, width, border) {
77
+ const space = { text: " ", fg: border };
78
+ const l = left.length > 0 ? [space, ...left, space] : [];
79
+ const r = right.length > 0 ? [space, ...right, space] : [];
80
+ const fill = width - cellWidth(lead) - lineWidth(l) - lineWidth(r) - cellWidth(tail);
81
+ if (fill < 1)
82
+ return undefined;
83
+ return [{ text: lead, fg: border }, ...l, { text: "─".repeat(fill), fg: border }, ...r, { text: tail, fg: border }];
84
+ }
85
+ function borderColor(view, frame, palette) {
86
+ if (!view.active)
87
+ return palette.borderActive;
88
+ const enter = frame.chipEnter ? ease.outCubic(seg(frame.t, 0, motionTiming.entry)) : 1;
89
+ const done = view.complete ? (frame.celebrate === undefined ? 1 : ease.inOutCubic(seg(frame.celebrate, 0, motionTiming.crossfade))) : 0;
90
+ return mixHex(palette.borderActive, mixHex(mixHex(palette.accent, palette.warning, frame.warn), palette.success, done), enter);
91
+ }
92
+ /** The phase on the right of the title: always a glyph plus words. */
93
+ function phaseLabel(view, frame, palette) {
94
+ if (!view.active)
95
+ return { text: "○ IDLE", fg: palette.muted };
96
+ const status = missionStatus(view);
97
+ if (status.tone === "success")
98
+ return { text: "✓ COMPLETE", fg: palette.success, bold: true };
99
+ if (status.tone === "warning")
100
+ return { text: `⚑ ${status.text}`, fg: mixHex(palette.info, palette.warning, Math.max(frame.warn, 0.5)), bold: true };
101
+ return { text: `● ${view.phase.toUpperCase()}`, fg: palette.info, bold: true };
102
+ }
103
+ function topLine(view, frame, palette, width, border) {
104
+ const brand = [{ text: "🔥 ", fg: palette.text }, { text: "LIT", fg: shine(palette.primary, palette, 0.5, shimmerAt(frame), 0.6), bold: true }];
105
+ const name = view.active ? view.discipline : "ready";
106
+ const right = [phaseLabel(view, frame, palette)];
107
+ const withName = (room) => [...brand, { text: " · ", fg: palette.muted }, { text: truncateCells(name, room), fg: view.active ? palette.text : palette.muted }];
108
+ const nameRoom = width - cellWidth("╭─") - lineWidth(withName(0)) - 2 - (lineWidth(right) + 2) - cellWidth("─╮") - 1;
109
+ const attempts = [];
110
+ if (nameRoom >= Math.min(4, cellWidth(name)))
111
+ attempts.push([withName(nameRoom), right]);
112
+ attempts.push([brand, right], [brand, []]);
113
+ for (const [left, label] of attempts) {
114
+ const line = ruled("╭─", left, label, "─╮", width, border);
115
+ if (line !== undefined)
116
+ return line;
117
+ }
118
+ return fitLine([{ text: "╭" + "─".repeat(Math.max(0, width)), fg: border }], width, palette);
119
+ }
120
+ /** The figures in the bottom border, most important first; the last ones go first when room runs out. */
121
+ function figureParts(view, figures, palette) {
122
+ const parts = [];
123
+ if (figures.contextPercent !== null) {
124
+ const percent = Math.round(figures.contextPercent);
125
+ parts.push({ text: `ctx ${percent}%`, fg: percent >= contextWarnPercent ? palette.warning : palette.muted });
126
+ }
127
+ if (view.active)
128
+ parts.push({ text: `⟳ ${elapsedText(view.elapsedMinutes)}${figures.inLane ? " · in lane" : ""}`, fg: palette.muted });
129
+ if (figures.branch !== undefined)
130
+ parts.push({ text: `⎇ ${truncateCells(figures.branch, 24)}`, fg: palette.muted });
131
+ return parts;
132
+ }
133
+ function bottomLine(view, figures, palette, width, border) {
134
+ const parts = figureParts(view, figures, palette);
135
+ for (let count = parts.length; count >= 0; count -= 1) {
136
+ const right = [];
137
+ parts.slice(0, count).forEach((part, index) => right.push(...(index === 0 ? [] : [{ text: " · ", fg: palette.muted }]), part));
138
+ const line = ruled("╰", [], right, "─╯", width, border);
139
+ if (line !== undefined)
140
+ return line;
141
+ }
142
+ return fitLine([{ text: "╰" + "─".repeat(Math.max(0, width)), fg: border }], width, palette);
143
+ }
144
+ function trackPills(view, frame, palette, tight) {
145
+ const alpha = (index) => (frame.chipEnter ? ease.outCubic(seg(frame.t, index * motionTiming.stagger, motionTiming.entry)) : 1);
146
+ if (tight) {
147
+ const current = view.steps.find((step) => step.state === "now" || step.state === "warn") ?? view.steps[view.complete ? 2 : view.active ? 1 : 0];
148
+ return [fade(stepPill(current, view, frame, palette), alpha(0), palette)];
149
+ }
150
+ const out = [];
151
+ view.steps.forEach((step, index) => {
152
+ if (index > 0)
153
+ out.push(fade({ text: " ━ ", fg: view.steps[index - 1].state === "done" ? palette.success : palette.borderSubtle }, alpha(index), palette));
154
+ out.push(fade(stepPill(step, view, frame, palette), alpha(index), palette));
155
+ });
156
+ return out;
157
+ }
158
+ /** The text after the track in an idle session: the project's last goal, or how to start. */
159
+ function idleText(last, palette, room) {
160
+ if (last === undefined || last.title === "")
161
+ return [{ text: " ", fg: palette.muted }, { text: truncateCells("add lit to a prompt to start", room - 2), fg: palette.muted }];
162
+ const mark = last.complete ? { text: ` ✓ ${last.passed}/${last.total}`, fg: palette.success } : { text: ` ● ${last.passed}/${last.total}`, fg: palette.info };
163
+ const head = { text: " last: ", fg: palette.muted };
164
+ return [head, { text: truncateCells(last.title, room - cellWidth(head.text) - cellWidth(mark.text)), fg: palette.text }, mark];
165
+ }
166
+ // Shedding order as the row narrows: the next-task text, bar width, the bar, the criteria count and
167
+ // then the step labels. Each plan is tried in turn and the first that fits wins.
168
+ const trackPlans = [
169
+ { tight: false, bar: 16, criteria: true, text: true },
170
+ { tight: false, bar: 16, criteria: true, text: false },
171
+ { tight: false, bar: 8, criteria: true, text: false },
172
+ { tight: false, bar: 0, criteria: true, text: false },
173
+ { tight: false, bar: 0, criteria: false, text: false },
174
+ { tight: true, bar: 0, criteria: false, text: false }
175
+ ];
176
+ function trackRow(view, frame, palette, width, last) {
61
177
  const sweep = shimmerAt(frame);
62
- for (const plan of plans) {
63
- const pills = chipGroups(view, frame, palette, plan.tier, false).flat();
64
- const bar = plan.bar > 0 && view.progress !== undefined ? [{ text: " ", fg: palette.muted }, ...progressBar(frame.barRatio, plan.bar, palette, sweep)] : [];
65
- const timeWidth = plan.time ? cellWidth(time.text) + 1 : 0;
66
- const fixed = lineWidth(pills) + lineWidth(bar) + timeWidth;
178
+ const gap = { text: " ", fg: palette.muted };
179
+ for (const plan of trackPlans) {
180
+ const pills = trackPills(view, frame, palette, plan.tight);
181
+ const bar = plan.bar > 0 && view.progress !== undefined ? [gap, ...progressBar(frame.barRatio, plan.bar, palette, sweep)] : [];
182
+ const goal = view.goal;
183
+ const criteria = plan.criteria && goal !== undefined ? [gap, { text: `${goal.passed}/${goal.total} criteria`, fg: goal.total > 0 && goal.passed === goal.total ? palette.success : palette.muted }] : [];
184
+ const fixed = lineWidth(pills) + lineWidth(bar) + lineWidth(criteria);
67
185
  if (fixed > width)
68
186
  continue;
69
187
  const room = width - fixed;
70
- const after = trailing(view, frame, palette, room);
188
+ const after = plan.text ? (view.active ? trailing(view, frame, palette, room) : idleText(last, palette, room)) : [];
71
189
  const keepText = plan.text && after.length > 0 && (view.complete ? lineWidth(after) <= room : room >= minimumText);
72
190
  if (plan.text && after.length > 0 && !keepText)
73
191
  continue;
74
- const body = [...pills, ...bar, ...(keepText ? after : [])];
75
- let used = lineWidth(body) + timeWidth;
76
- const sparks = frame.celebrate !== undefined && used + chipSparkWidth <= width ? chipSparkCells(frame.celebrate, palette) : undefined;
77
- if (sparks !== undefined)
78
- used += chipSparkWidth;
79
- const gap = width - used + (plan.time ? 1 : 0);
192
+ const body = [...pills, ...bar, ...criteria, ...(keepText ? after : [])];
80
193
  // Sparks sit after the text, in the free space, so the pills never shift while they play.
81
- return [...body, ...(sparks ?? []), { text: " ".repeat(Math.max(plan.time ? 1 : 0, gap)), fg: palette.muted }, ...(plan.time ? [time] : [])];
194
+ const sparks = frame.celebrate !== undefined && lineWidth(body) + chipSparkWidth <= width ? chipSparkCells(frame.celebrate, palette) ?? [] : [];
195
+ return fitLine([...body, ...sparks], width, palette);
82
196
  }
83
- return [];
197
+ return fitLine(trackPills(view, frame, palette, true), width, palette);
198
+ }
199
+ function chipSegments(chip, shimmer, palette) {
200
+ const text = ` ${chip.name} `;
201
+ if (!chip.current)
202
+ return [{ text, fg: palette.muted, ...(palette.element === undefined ? {} : { bg: palette.element }) }];
203
+ const sweep = shimmer?.names.includes(chip.name) === true ? shimmer.sweep : -1;
204
+ if (sweep < 0)
205
+ return [{ text, fg: palette.onFill, bg: palette.accent, bold: true }];
206
+ const chars = [...text];
207
+ return chars.map((char, index) => ({ text: char, fg: palette.onFill, bg: shine(palette.accent, palette, index / (chars.length - 1), sweep, 0.3), bold: true }));
208
+ }
209
+ /**
210
+ * The skill chips row, exactly `width` cells: bright accent pills for the current request first, then
211
+ * dim recent names, and `+N` for the ones that do not fit. The label goes before any chip does.
212
+ */
213
+ export function skillRow(chips, shimmer, palette, width) {
214
+ const label = { text: "skills ", fg: palette.muted };
215
+ if (chips.length === 0)
216
+ return fitLine([label, { text: " none yet", fg: palette.muted }], width, palette);
217
+ const pills = chips.map((chip) => chipSegments(chip, shimmer, palette));
218
+ const space = { text: " ", fg: palette.muted };
219
+ for (const lead of [[label], []]) {
220
+ const all = lineWidth(lead) + pills.reduce((total, pill, index) => total + lineWidth(pill) + (index === 0 ? 0 : 1), 0);
221
+ let shown = pills.length;
222
+ if (all > width) {
223
+ let used = lineWidth(lead);
224
+ shown = 0;
225
+ for (const pill of pills) {
226
+ const next = used + (shown === 0 ? 0 : 1) + lineWidth(pill);
227
+ if (next + cellWidth(` +${pills.length - shown - 1}`) > width)
228
+ break;
229
+ used = next;
230
+ shown += 1;
231
+ }
232
+ }
233
+ if (shown === 0 && lead.length > 0)
234
+ continue;
235
+ const out = [...lead];
236
+ pills.slice(0, shown).forEach((pill, index) => out.push(...(index === 0 ? [] : [space]), ...pill));
237
+ if (shown < pills.length)
238
+ out.push({ text: `${shown === 0 ? "" : " "}+${pills.length - shown}`, fg: palette.muted });
239
+ return fitLine(out, width, palette);
240
+ }
241
+ return fitLine([{ text: `+${pills.length}`, fg: palette.muted }], width, palette);
242
+ }
243
+ /**
244
+ * The status card: four rows of exactly `width` cells whether the session is idle or working, so
245
+ * the prompt never moves. Each row sheds its own low-priority parts as the terminal narrows.
246
+ */
247
+ export function statusCard(input, palette, width) {
248
+ const { view, frame } = input;
249
+ const border = borderColor(view, frame, palette);
250
+ const inner = width - 4;
251
+ const row = (content) => (inner < 0 ? fitLine([], width, palette) : [{ text: "│ ", fg: border }, ...fitLine(content, inner, palette), { text: " │", fg: border }]);
252
+ return [
253
+ topLine(view, frame, palette, width, border),
254
+ row(trackRow(view, frame, palette, Math.max(0, inner), input.last)),
255
+ row(skillRow(input.chips, input.shimmer, palette, Math.max(0, inner))),
256
+ bottomLine(view, input.figures, palette, width, border)
257
+ ];
84
258
  }
85
259
  const cardRows = 4;
86
260
  /**
87
- * The opt-in card under the ribbon. It keeps a fixed four rows whenever a goal exists, warnings
261
+ * The opt-in detail card under the status card. It keeps a fixed four rows whenever a goal exists, warnings
88
262
  * first, so turning a warning on or off never moves the prompt.
89
263
  */
90
264
  export function bandCard(view, frame, palette, width) {
@@ -0,0 +1,38 @@
1
+ export type SkillUse = {
2
+ readonly name: string;
3
+ readonly at: number;
4
+ };
5
+ export type SkillChip = {
6
+ readonly name: string;
7
+ readonly current: boolean;
8
+ };
9
+ /**
10
+ * One part as a skill use, or undefined. `at` is the owning message's time, used when the part
11
+ * carries no start time of its own. A failed call or a status read is not a firing.
12
+ */
13
+ export declare function readSkillUse(part: unknown, at: number): SkillUse | undefined;
14
+ /**
15
+ * The chips to draw: skills fired since `since` (the latest user message) are current while the
16
+ * session is busy, in the order they arrived; every other name is recent, newest first.
17
+ */
18
+ export declare function skillChips(uses: readonly SkillUse[], turn: {
19
+ readonly since: number | undefined;
20
+ readonly busy: boolean;
21
+ }): SkillChip[];
22
+ export type ChipMotion = {
23
+ readonly at?: number;
24
+ readonly arrivals: readonly string[];
25
+ };
26
+ export declare const idleChipMotion: ChipMotion;
27
+ /**
28
+ * Starts a shimmer over chips that just became current. On the first look at a session only current
29
+ * chips shimmer, since they belong to the running request; recent ones, the same chips again, or
30
+ * motion turned off leave the state as it is.
31
+ */
32
+ export declare function planChipMotion(prev: readonly SkillChip[] | undefined, next: readonly SkillChip[], now: number, state: ChipMotion, enabled?: boolean): ChipMotion;
33
+ export type ChipShimmer = {
34
+ readonly names: readonly string[];
35
+ readonly sweep: number;
36
+ };
37
+ /** The light sweep over arriving chips at `now`: its centre runs from just before to just past each chip. */
38
+ export declare function chipShimmer(state: ChipMotion, now: number): ChipShimmer | undefined;
@@ -0,0 +1,81 @@
1
+ // Skill chips for the band: which LitOpenCode skills fired in a session. Everything is read from what
2
+ // OpenCode already stores: the built-in `skill` tool's calls, this plugin's own tool calls, and the
3
+ // `metadata.litopencode` marks on user parts for bare `lit` routing and slash commands.
4
+ import { litOpenCodeRuntimeSkills, litOpenCodeStaticOnlySkills } from "../skills.js";
5
+ import { seg, motionTiming } from "./mission-motion.js";
6
+ import { readLitMark } from "./mission-model.js";
7
+ const ownSkills = new Set([...litOpenCodeRuntimeSkills, ...litOpenCodeStaticOnlySkills].map((skill) => skill.id));
8
+ // LitOpenCode's own tools, by the chip each one shows. The `lit` tool activates lit-loop.
9
+ const ownTools = { lit: "lit-loop", litwork: "litwork", "start-work": "start-work", "review-work": "review-work", wikify: "wikify" };
10
+ const recentLimit = 12;
11
+ function isRecord(value) {
12
+ return typeof value === "object" && value !== null && !Array.isArray(value);
13
+ }
14
+ /**
15
+ * One part as a skill use, or undefined. `at` is the owning message's time, used when the part
16
+ * carries no start time of its own. A failed call or a status read is not a firing.
17
+ */
18
+ export function readSkillUse(part, at) {
19
+ if (!isRecord(part))
20
+ return undefined;
21
+ if (part.type === "tool" && typeof part.tool === "string" && isRecord(part.state)) {
22
+ const state = part.state;
23
+ if (state.status === "error")
24
+ return undefined;
25
+ const input = isRecord(state.input) ? state.input : {};
26
+ const start = isRecord(state.time) && typeof state.time.start === "number" ? state.time.start : at;
27
+ if (part.tool === "skill") {
28
+ return typeof input.name === "string" && ownSkills.has(input.name) ? { name: input.name, at: start } : undefined;
29
+ }
30
+ const name = ownTools[part.tool];
31
+ if (name === undefined || input.action === "status")
32
+ return undefined;
33
+ return { name, at: start };
34
+ }
35
+ const mark = readLitMark(part.metadata, at);
36
+ if (mark === undefined)
37
+ return undefined;
38
+ const name = mark.mode !== undefined && mark.mode !== "bounded-authority-lifecycle" ? mark.command ?? mark.mode : mark.command;
39
+ return name === undefined ? undefined : { name, at };
40
+ }
41
+ /**
42
+ * The chips to draw: skills fired since `since` (the latest user message) are current while the
43
+ * session is busy, in the order they arrived; every other name is recent, newest first.
44
+ */
45
+ export function skillChips(uses, turn) {
46
+ const ordered = [...uses].sort((left, right) => left.at - right.at);
47
+ const last = new Map();
48
+ const current = [];
49
+ for (const use of ordered) {
50
+ last.set(use.name, use.at);
51
+ if (turn.busy && turn.since !== undefined && use.at >= turn.since && !current.includes(use.name))
52
+ current.push(use.name);
53
+ }
54
+ const recent = [...last.entries()]
55
+ .filter(([name]) => !current.includes(name))
56
+ .sort((left, right) => right[1] - left[1])
57
+ .slice(0, recentLimit)
58
+ .map(([name]) => ({ name, current: false }));
59
+ return [...current.map((name) => ({ name, current: true })), ...recent];
60
+ }
61
+ export const idleChipMotion = Object.freeze({ arrivals: Object.freeze([]) });
62
+ /**
63
+ * Starts a shimmer over chips that just became current. On the first look at a session only current
64
+ * chips shimmer, since they belong to the running request; recent ones, the same chips again, or
65
+ * motion turned off leave the state as it is.
66
+ */
67
+ export function planChipMotion(prev, next, now, state, enabled = true) {
68
+ if (!enabled)
69
+ return state;
70
+ const arrivals = next.filter((chip) => chip.current && !(prev ?? []).some((before) => before.name === chip.name && before.current)).map((chip) => chip.name);
71
+ return arrivals.length === 0 ? state : { at: now, arrivals };
72
+ }
73
+ /** The light sweep over arriving chips at `now`: its centre runs from just before to just past each chip. */
74
+ export function chipShimmer(state, now) {
75
+ if (state.at === undefined || state.arrivals.length === 0)
76
+ return undefined;
77
+ const t = now - state.at;
78
+ if (t < 0 || t > motionTiming.shimmer)
79
+ return undefined;
80
+ return { names: state.arrivals, sweep: -0.2 + 1.4 * seg(t, 0, motionTiming.shimmer) };
81
+ }
@@ -1,4 +1,5 @@
1
1
  import { type MissionInput, type TodoInput } from "./mission-model.ts";
2
+ import { type SkillChip } from "./mission-skills.ts";
2
3
  type MessageLike = {
3
4
  readonly id: string;
4
5
  readonly role: string;
@@ -23,6 +24,9 @@ export type MissionStateSource = {
23
24
  readonly worktree?: string;
24
25
  readonly directory?: string;
25
26
  };
27
+ readonly vcs?: {
28
+ readonly branch?: string;
29
+ };
26
30
  readonly provider: ReadonlyArray<{
27
31
  readonly id: string;
28
32
  readonly models: Readonly<Record<string, {
@@ -42,6 +46,7 @@ export type MissionStateSource = {
42
46
  question(sessionID: string): ReadonlyArray<unknown>;
43
47
  };
44
48
  part(messageID: string): ReadonlyArray<{
49
+ readonly type?: string;
45
50
  readonly metadata?: unknown;
46
51
  }>;
47
52
  };
@@ -53,4 +58,11 @@ export declare function missionProjectRoot(paths: MissionStateSource["path"]): s
53
58
  */
54
59
  export declare function createLedgerReader(): (root: string | undefined) => unknown;
55
60
  export declare function readMissionInput(state: MissionStateSource, sessionID: string, ledger: unknown, now: number): MissionInput;
61
+ export type CardInput = {
62
+ readonly chips: readonly SkillChip[];
63
+ readonly contextPercent: number | null;
64
+ readonly branch?: string;
65
+ };
66
+ /** What the band card shows beside the mission: the session's skill chips, context use and git branch. */
67
+ export declare function readCardInput(state: MissionStateSource, sessionID: string): CardInput;
56
68
  export {};
@@ -3,6 +3,7 @@
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { readLitMark } from "./mission-model.js";
6
+ import { readSkillUse, skillChips } from "./mission-skills.js";
6
7
  const ledgerByteLimit = 1024 * 1024;
7
8
  /** The same root the server plugin uses: the worktree unless it is the filesystem root, else the directory. */
8
9
  export function missionProjectRoot(paths) {
@@ -86,3 +87,26 @@ export function readMissionInput(state, sessionID, ledger, now) {
86
87
  now
87
88
  };
88
89
  }
90
+ /** What the band card shows beside the mission: the session's skill chips, context use and git branch. */
91
+ export function readCardInput(state, sessionID) {
92
+ const messages = state.session.messages(sessionID);
93
+ const uses = [];
94
+ let since;
95
+ for (const message of messages) {
96
+ const at = num(message.time?.created);
97
+ if (message.role === "user")
98
+ since = at;
99
+ for (const part of state.part(message.id)) {
100
+ const use = readSkillUse(part, at);
101
+ if (use !== undefined)
102
+ uses.push(use);
103
+ }
104
+ }
105
+ const status = state.session.status(sessionID)?.type;
106
+ const branch = state.vcs?.branch?.trim();
107
+ return {
108
+ chips: skillChips(uses, { since, busy: status === "busy" || status === "retry" }),
109
+ contextPercent: contextPercent(state, messages),
110
+ ...(branch === undefined || branch === "" ? {} : { branch })
111
+ };
112
+ }
@@ -1,5 +1,5 @@
1
1
  import { type MotionFrame } from "./mission-motion.ts";
2
- import { type MissionView } from "./mission-model.ts";
2
+ import { type MissionStep, type MissionView } from "./mission-model.ts";
3
3
  export type Segment = {
4
4
  readonly text: string;
5
5
  readonly fg: string;
@@ -53,12 +53,15 @@ export declare function truncateCells(value: string, max: number): string;
53
53
  export declare function padCells(value: string, width: number): string;
54
54
  export declare function lineWidth(line: Line): number;
55
55
  export declare function gradientAt(stops: readonly string[], position: number): string;
56
+ /** A light pass along a row: `sweep` is its centre in 0..1 of the row, or negative when off. */
57
+ export declare function shine(color: string, palette: MissionPalette, position: number, sweep: number, reach: number): string;
56
58
  export declare function shimmerAt(frame: MotionFrame): number;
57
59
  /**
58
60
  * A bar of `cells` cells filled to `ratio` with eighth-cell blocks, each cell its own step of the
59
61
  * primary-to-accent gradient. The empty track is a thin muted rule, never a dither.
60
62
  */
61
63
  export declare function progressBar(ratio: number, cells: number, palette: MissionPalette, sweep?: number): Segment[];
64
+ export declare function stepPill(step: MissionStep, view: MissionView, frame: MotionFrame, palette: MissionPalette): Segment;
62
65
  export type ChipTier = "full" | "medium" | "tight";
63
66
  export type ChipGroup = readonly Segment[];
64
67
  /** The chip's pill groups for a tier; the five-segment bar is appended unless `withMicroBar` is false. */
@@ -139,7 +139,7 @@ export function gradientAt(stops, position) {
139
139
  return mixHex(stops[index], stops[index + 1], scaled - index);
140
140
  }
141
141
  /** A light pass along a row: `sweep` is its centre in 0..1 of the row, or negative when off. */
142
- function shine(color, palette, position, sweep, reach) {
142
+ export function shine(color, palette, position, sweep, reach) {
143
143
  if (sweep < 0)
144
144
  return color;
145
145
  const distance = Math.abs(position - sweep);
@@ -232,7 +232,7 @@ function stepLabel(step, state, view) {
232
232
  return [name, "⚑", count].filter((part) => part !== "").join(" ");
233
233
  return [name, state === "now" ? count : ""].filter((part) => part !== "").join(" ");
234
234
  }
235
- function stepPill(step, view, frame, palette) {
235
+ export function stepPill(step, view, frame, palette) {
236
236
  const { state, doneMix } = shownState(step, view, frame);
237
237
  const before = pillStyle(step.id === "work" ? "now" : "todo", palette, frame.warn);
238
238
  const style = state === "done" && doneMix < 1 ? blendStyle(before, pillStyle("done", palette, frame.warn), doneMix, palette) : pillStyle(state, palette, frame.warn);
package/dist/tui.js CHANGED
@@ -4,8 +4,9 @@ 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, readMissionInput } from "./tui/mission-source.js";
8
- import { bandCard, bandRibbon, readBandOptions } from "./tui/mission-band.js";
7
+ import { createLedgerReader, missionProjectRoot, readCardInput, readMissionInput } from "./tui/mission-source.js";
8
+ import { chipShimmer, idleChipMotion, planChipMotion } from "./tui/mission-skills.js";
9
+ import { bandCard, readBandOptions, statusCard } from "./tui/mission-band.js";
9
10
  export const litOpenCodeTuiPluginId = "litopencode";
10
11
  const homeLineWidth = 69;
11
12
  const refreshDelayMs = 80;
@@ -61,7 +62,7 @@ const tui = async (api, options) => {
61
62
  }
62
63
  function anyMoving(now) {
63
64
  for (const entry of sessions.values())
64
- if (sampleMotion(entry.motion, now).moving)
65
+ if (sampleMotion(entry.motion, now).moving || chipShimmer(entry.chipMotion, now) !== undefined)
65
66
  return true;
66
67
  return false;
67
68
  }
@@ -83,15 +84,22 @@ const tui = async (api, options) => {
83
84
  const source = state();
84
85
  const ledger = readLedger(missionProjectRoot(source.path));
85
86
  const next = buildMissionView(readMissionInput(source, sessionID, ledger, now));
87
+ const card = readCardInput(source, sessionID);
86
88
  const entry = sessions.get(sessionID);
87
- if (entry !== undefined && JSON.stringify(entry.view) === JSON.stringify(next))
89
+ const viewChanged = entry === undefined || JSON.stringify(entry.view) !== JSON.stringify(next);
90
+ if (!viewChanged && JSON.stringify(entry.card) === JSON.stringify(card))
88
91
  return;
89
- if (entry !== undefined) {
92
+ if (entry !== undefined && viewChanged) {
90
93
  const notice = leadingNotice(missionNotices(entry.view, next));
91
94
  if (notice !== undefined)
92
95
  api.ui.toast({ variant: notice.variant, title: notice.title, message: notice.message, duration: toastMs });
93
96
  }
94
- sessions.set(sessionID, { view: next, motion: planMotion(entry?.view, next, now, entry?.motion ?? idleMotion, motionEnabled) });
97
+ sessions.set(sessionID, {
98
+ view: next,
99
+ card,
100
+ motion: viewChanged ? planMotion(entry?.view, next, now, entry?.motion ?? idleMotion, motionEnabled) : entry.motion,
101
+ chipMotion: planChipMotion(entry?.card.chips, card.chips, now, entry?.chipMotion ?? idleChipMotion, motionEnabled)
102
+ });
95
103
  setClock(now);
96
104
  setRevision(revision() + 1);
97
105
  ensureFrames();
@@ -129,14 +137,13 @@ const tui = async (api, options) => {
129
137
  });
130
138
  }
131
139
  // Opening a session loads its messages and parts over HTTP, which fires no bus event. Reading the
132
- // reactive store here re-runs this effect as they arrive, so a resumed session lights up too.
140
+ // reactive store here re-runs this effect as they arrive, so a resumed session lights up too and its
141
+ // skill calls come back as chips.
133
142
  function watchSession(sessionID) {
134
143
  createEffect(() => {
135
144
  const source = state();
136
- for (const message of source.session.messages(sessionID)) {
137
- if (message.role === "user")
138
- void source.part(message.id).length;
139
- }
145
+ for (const message of source.session.messages(sessionID))
146
+ void source.part(message.id).length;
140
147
  void source.session.todo(sessionID).length;
141
148
  void source.session.status(sessionID)?.type;
142
149
  scheduleRefresh();
@@ -151,6 +158,15 @@ const tui = async (api, options) => {
151
158
  return { view: entry.view, frame: sampleMotion(entry.motion, clock()) };
152
159
  };
153
160
  }
161
+ // The band shows every session, lit or not, so it reads the entry whether or not the mission is active.
162
+ function cardView(sessionID) {
163
+ return () => {
164
+ revision();
165
+ const entry = sessions.get(sessionID);
166
+ const now = clock();
167
+ return entry === undefined ? undefined : { ...entry, frame: sampleMotion(entry.motion, now), now };
168
+ };
169
+ }
154
170
  for (const type of refreshEvents) {
155
171
  api.event.on(type, scheduleRefresh);
156
172
  }
@@ -222,8 +238,9 @@ const tui = async (api, options) => {
222
238
  }
223
239
  }
224
240
  });
225
- // The band: one full-width ribbon row under every route in app_bottom. It follows the session on
226
- // screen; a child (subagent) session shows its parent's mission as a lane. Home shows no band.
241
+ // The band: a four-row status card under every session route in app_bottom, idle or working. It
242
+ // follows the session on screen; a child (subagent) session shows its parent's mission as a lane.
243
+ // Home shows no band.
227
244
  if (band.enabled) {
228
245
  api.slots.register({
229
246
  order: 50,
@@ -247,7 +264,7 @@ const tui = async (api, options) => {
247
264
  });
248
265
  const current = createMemo(() => {
249
266
  const place = target();
250
- const snapshot = place === undefined ? undefined : sessionView(place.sessionID)();
267
+ const snapshot = place === undefined ? undefined : cardView(place.sessionID)();
251
268
  return snapshot === undefined || place === undefined ? undefined : { ...snapshot, inLane: place.inLane };
252
269
  });
253
270
  const visible = createMemo(() => current() !== undefined);
@@ -257,17 +274,27 @@ const tui = async (api, options) => {
257
274
  return jsx("box", {
258
275
  flexDirection: "column",
259
276
  flexShrink: 0,
277
+ paddingLeft: 1,
278
+ paddingRight: 1,
260
279
  get backgroundColor() { return palette().panel ?? "transparent"; },
261
280
  children: () => {
262
281
  const snapshot = current();
263
282
  if (snapshot === undefined)
264
283
  return null;
265
284
  const colors = palette();
266
- const width = dimensions().width;
267
- const rows = [
268
- jsx("box", { paddingLeft: 2, paddingRight: 2, flexShrink: 0, children: lineElement(bandRibbon(snapshot.view, snapshot.frame, colors, width - 4, { inLane: snapshot.inLane })) })
269
- ];
270
- const card = band.card ? bandCard(snapshot.view, snapshot.frame, colors, width - 2) : undefined;
285
+ const width = dimensions().width - 2;
286
+ const last = snapshot.view.active ? undefined : lastMission(readLedger(missionProjectRoot(state().path)));
287
+ const shimmer = chipShimmer(snapshot.chipMotion, snapshot.now);
288
+ const lines = statusCard({
289
+ view: snapshot.view,
290
+ frame: snapshot.frame,
291
+ chips: snapshot.card.chips,
292
+ ...(shimmer === undefined ? {} : { shimmer }),
293
+ figures: { contextPercent: snapshot.card.contextPercent, ...(snapshot.card.branch === undefined ? {} : { branch: snapshot.card.branch }), inLane: snapshot.inLane },
294
+ ...(last === undefined ? {} : { last })
295
+ }, colors, width);
296
+ const rows = lines.map(lineElement);
297
+ const card = band.card ? bandCard(snapshot.view, snapshot.frame, colors, width) : undefined;
271
298
  if (card !== undefined) {
272
299
  rows.push(jsx("box", {
273
300
  border: true,
@@ -275,8 +302,6 @@ const tui = async (api, options) => {
275
302
  borderColor: card.border,
276
303
  title: card.title,
277
304
  titleColor: colors.text,
278
- marginLeft: 1,
279
- marginRight: 1,
280
305
  paddingLeft: 1,
281
306
  paddingRight: 1,
282
307
  flexShrink: 0,
@@ -1 +1 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.19"><title>release: 1.0.19</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.19</text></g></svg>
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.20"><title>release: 1.0.20</title><g shape-rendering="crispEdges"><rect width="49" height="20" fill="#080d14"/><rect x="49" width="97" height="20" fill="#ff6337"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text x="255" y="140" textLength="390" transform="scale(.1)">release</text><text x="965" y="140" textLength="870" transform="scale(.1)" fill="#080D14">1.0.20</text></g></svg>
@@ -263,12 +263,26 @@ OpenCode 1.18은 `tui.json`이나 `tui.jsonc`의 `plugin` 배열에 적힌 TUI
263
263
  경고가 있으면 warning, 끝나면 success 색입니다. 안에는 두 줄짜리 `LIT`과 그 옆의 작업 이름·상태,
264
264
  굵은 목표 제목, 단계 줄, 1/8칸 블록으로 칠하고 빈 곳은 가는 선으로 둔 그라데이션 막대, 다음 할 일이나
265
265
  아직 통과하지 못한 기준, 기준 목록, 경고, 최근 증거 네 개가 들어갑니다.
266
- - 띠(band): 화면 맨 아래 전체 폭에 붙는 리본 한 줄입니다(OpenCode의 `app_bottom` 자리). 세션과
267
- 사이드바 아래를 모두 가로지릅니다. 알약, 1/8칸 막대, 다음 할 일, 첫 lit 턴부터 지난 시간(`⟳ 7m`)을
268
- 보여 줍니다. 경고가 열려 있는 동안에는 `▸ 다음 일` 자리에 `⚑ <내용>`이 들어갑니다. 세션의 첫 lit
269
- 턴에 나타나 lit 세션 내내 남으므로, 입력창은 한 번만 움직입니다. 하위(subagent) 세션에서는 부모
270
- 세션의 미션을 `· in lane`과 함께 보여 주고, 홈 화면에는 띠가 없습니다. 폭이 좁으면 단계 이름,
271
- 다음 할 일 글자, 막대 길이, 시간 순서로 덜어 내며 줄을 넘기지 않습니다. 완료 순간도 여기서 함께 보입니다.
266
+ - 띠(band): 화면 맨 아래에 붙는 상태 카드입니다(OpenCode의 `app_bottom` 자리). 세션과 사이드바
267
+ 아래를 모두 가로지릅니다. lit을 쓰든 안 쓰든 세션 내내 보이고, 쉴 때나 일할 때나 똑같이 네 줄이라
268
+ 입력창이 움직이지 않습니다.
269
+ - 위 테두리 왼쪽에 `🔥 LIT · <작업 이름>`, 오른쪽에 단계가 붙습니다(`● WORK`, `⚑ review blocked`,
270
+ `✓ COMPLETE`, lit 턴 전에는 `○ IDLE`).
271
+ - 첫째 줄은 칩과 같은 알약으로 그린 단계 줄 `PLAN ━ WORK ━ REVIEW`, 1/8칸 막대, 목표의
272
+ `n/m criteria`, 다음 할 일입니다. 경고가 열려 있는 동안에는 `▸ 다음 일` 자리에 `⚑ <내용>`이
273
+ 들어갑니다. 쉬는 세션에서는 흐린 단계 줄 옆에 프로젝트의 마지막 목표를, 목표가 없으면
274
+ `add lit to a prompt to start`를 보여 줍니다.
275
+ - 둘째 줄은 스킬 칩입니다. 세션에서 실행된 LitOpenCode 스킬이 여러 개 함께 보입니다. 지금 요청에서
276
+ 실행된 스킬은 요청이 도는 동안 accent 색 알약으로 밝게 보이고, 새로 들어온 칩에는 빛이 짧게 한 번
277
+ 지나갑니다. 턴이 끝나면 흐려져 최근 목록으로 남고(최근 것이 앞), 다시 실행되면 다시 밝아집니다.
278
+ 자리가 모자라면 남은 칩을 `+N`으로 접습니다.
279
+ - 아래 테두리에는 context 사용량(75%부터 주황), 첫 lit 턴부터 지난 시간(`⟳ 7m`), git 브랜치가
280
+ 붙습니다.
281
+
282
+ 하위(subagent) 세션에서는 부모 세션의 카드를 `· in lane`과 함께 보여 주고, 홈 화면에는 띠가
283
+ 없습니다. 폭이 좁으면 줄마다 덜 중요한 것부터 덜어 냅니다. 단계 줄은 다음 할 일 글자, 막대 길이,
284
+ 막대, 기준 개수, 단계 이름 순서로, 아래 테두리는 브랜치, 시간, context 사용량 순서로 뺍니다. 줄을
285
+ 넘기지는 않습니다. 완료 순간도 여기서 함께 보입니다.
272
286
  - 홈 화면 입력창 아래, OpenCode 팁 다음 줄: `🔥 lit ready · last: <목표> ✓ n/m`. 프로젝트 ledger에
273
287
  목표가 있을 때만 보입니다.
274
288
  - 경고는 색만 바꾸지 않고 늘 `⚑`와 글자를 함께 붙입니다. accent와 warning이 같은 색인 테마가 있기
@@ -281,6 +295,11 @@ OpenCode 1.18은 `tui.json`이나 `tui.jsonc`의 `plugin` 배열에 적힌 TUI
281
295
 
282
296
  데이터는 이미 있는 것만 읽고, TUI를 위해 새로 쓰는 파일은 없습니다.
283
297
 
298
+ - 스킬 칩은 OpenCode가 이미 저장하는 세 곳에서 읽습니다. 이름이 LitOpenCode가 싣고 다니는 스킬인
299
+ 내장 `skill` 도구 호출(다른 스킬은 빼고, 불러오기에 실패한 호출과 상태 조회는 실행으로 치지 않음),
300
+ 이 플러그인의 `lit`, `litwork`, `start-work`, `review-work`, `wikify` 도구 호출, 그리고 맨 `lit`
301
+ 라우팅과 슬래시 명령에 붙는 아래의 `metadata.litopencode` 표시입니다. "지금 요청"은 세션이 일하는
302
+ 동안 마지막 사용자 메시지 이후에 실행된 것을 뜻합니다.
284
303
  - `lit`이 모드를 켜거나 명령이 실행될 때(`save-plan` 포함) 이 플러그인이 사용자 메시지 part에 붙이는
285
304
  `metadata.litopencode` 표시. 표시가 없는 세션은 쉬는 상태로 보고, 기본 `lit-loop` agent여도 칩과
286
305
  패널을 숨깁니다.
@@ -299,7 +318,7 @@ ease-out-back으로 160ms씩, 60ms 간격으로 들어오고, 막대는 300ms에
299
318
  띠 설정도 같은 옵션 객체에 적습니다.
300
319
 
301
320
  - `{ "band": false }`는 띠만 없애고 칩, 패널, 홈 줄은 그대로 둡니다.
302
- - `{ "band": { "card": true } }`는 리본 아래에 경고, 다음 할 일, 목표를 담은 둥근 카드를 붙입니다.
321
+ - `{ "band": { "card": true } }`는 상태 카드 아래에 경고, 다음 할 일, 목표를 담은 둥근 상세 카드를 붙입니다.
303
322
  목표가 있는 동안 카드는 네 줄로 고정되어, 경고가 생기거나 사라져도 입력창이 움직이지 않습니다.
304
323
  카드는 기본으로 꺼져 있습니다.
305
324
 
package/docs/reference.md CHANGED
@@ -366,13 +366,26 @@ What it draws:
366
366
  Inside: a two-row `LIT` with the discipline and a status beside it, the goal title in bold, the
367
367
  phase line, a gradient bar drawn in eighth-cell blocks over a thin rule, the next todo or open
368
368
  criterion, the criteria, warnings, and the last four evidence events.
369
- - A band: one full-width ribbon row at the bottom of the screen (OpenCode's `app_bottom` slot), under
370
- the session and the sidebar alike. It shows the pills, an eighth-block bar, the next task and the
371
- time since the first lit turn (`⟳ 7m`). While a warning is open, `⚑ <text>` takes the place of
372
- `▸ next`. The row appears at the session's first lit turn and stays for the whole lit session, so
373
- the prompt moves only once. A subagent session shows its parent's mission with `· in lane`; the
374
- home screen has no band. When the row is narrow it drops, in order, the step labels, the next-task
375
- text, bar width and then the time; it never wraps. The completion moment plays here too.
369
+ - A band: a status card along the bottom of the screen (OpenCode's `app_bottom` slot), under the
370
+ session and the sidebar alike. It is on screen for the whole session, lit or not, and keeps the
371
+ same four rows idle or working, so the prompt never moves:
372
+ - the top border carries `🔥 LIT · <discipline>` on the left and the phase on the right
373
+ (`● WORK`, `⚑ review blocked`, `✓ COMPLETE`, or `○ IDLE` before any lit turn);
374
+ - row one is the phase track `PLAN ━ WORK ━ REVIEW` in the chip's pills, an eighth-block bar,
375
+ `n/m criteria` for the goal and the next task. While a warning is open, `⚑ <text>` takes the
376
+ place of `▸ next`. An idle session shows the dim track with the project's last goal, or
377
+ `add lit to a prompt to start`;
378
+ - row two holds the skill chips: every LitOpenCode skill that fired in the session, several at
379
+ once. Skills fired by the current request are bright accent pills while it runs, and a new one
380
+ gets a short light sweep as it arrives; when the turn ends they dim and stay as the recent list,
381
+ newest first, and firing one again brightens it. Chips that do not fit collapse into `+N`;
382
+ - the bottom border holds the context use (amber from 75%), the time since the first lit turn
383
+ (`⟳ 7m`) and the git branch.
384
+
385
+ A subagent session shows its parent's card with `· in lane`; the home screen has no band. When the
386
+ card is narrow each row drops its own low-priority parts: the track drops the next-task text, bar
387
+ width, the bar, the criteria count and then the step labels; the bottom border drops the branch,
388
+ then the time, then the context use. It never wraps. The completion moment plays here too.
376
389
  - A line under the home prompt, after OpenCode's tips: `🔥 lit ready · last: <goal> ✓ n/m`. It
377
390
  appears only when the project ledger holds a goal.
378
391
  - Warnings always carry `⚑` and words, not colour alone, because some themes use one colour for
@@ -387,6 +400,11 @@ What it draws:
387
400
 
388
401
  Where the data comes from, with nothing new written for the TUI:
389
402
 
403
+ - The skill chips come from three places OpenCode already stores: the built-in `skill` tool's calls
404
+ whose name is a skill LitOpenCode ships (other skills are left out, and a failed load or a status
405
+ read is not a firing), this plugin's own `lit`, `litwork`, `start-work`, `review-work` and `wikify`
406
+ tool calls, and the `metadata.litopencode` marks below for bare `lit` routing and slash commands.
407
+ "Current" means fired since the latest user message while the session is busy.
390
408
  - The `metadata.litopencode` marks this plugin already attaches to user-message parts when `lit`
391
409
  activates a mode or a command runs (`save-plan` included). A session with no mark is idle and the
392
410
  chip and panel stay hidden, even on the default `lit-loop` agent.
@@ -407,8 +425,8 @@ manager or with `plugin_enabled`.
407
425
  Band options go in the same options object:
408
426
 
409
427
  - `{ "band": false }` removes the band and leaves the chip, the panel and the home line.
410
- - `{ "band": { "card": true } }` adds a rounded card under the ribbon with the warnings, the next
411
- task and the goal. While a goal exists the card keeps a fixed four rows, so a warning arriving or
428
+ - `{ "band": { "card": true } }` adds a rounded detail card under the status card with the warnings,
429
+ the next task and the goal. While a goal exists the card keeps a fixed four rows, so a warning arriving or
412
430
  clearing never moves the prompt. The card is off by default.
413
431
 
414
432
  A ledger written from another terminal (for example `litopencode record-evidence`) raises no
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@litfamily/litopencode",
3
- "version": "1.0.19",
3
+ "version": "1.0.20",
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",