@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 +4 -0
- package/README-Ko-KR.md +6 -6
- package/README.md +6 -6
- package/dist/tui/mission-band.d.ts +26 -8
- package/dist/tui/mission-band.js +215 -41
- package/dist/tui/mission-skills.d.ts +38 -0
- package/dist/tui/mission-skills.js +81 -0
- package/dist/tui/mission-source.d.ts +12 -0
- package/dist/tui/mission-source.js +24 -0
- package/dist/tui/mission-text.d.ts +4 -1
- package/dist/tui/mission-text.js +2 -2
- package/dist/tui.js +46 -21
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/reference-Ko-KR.md +26 -7
- package/docs/reference.md +27 -9
- package/package.json +1 -1
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.
|
|
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.
|
|
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.
|
|
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.
|
|
61
|
-
- [마이그레이션 안내](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.
|
|
62
|
-
- [LitFamily 허브](https://github.com/wjgoarxiv/litfamily) · [MIT 라이선스](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
61
|
-
- [Migration notes](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.
|
|
62
|
-
- [LitFamily hub](https://github.com/wjgoarxiv/litfamily) · [MIT license](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.
|
|
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
|
|
3
|
-
import {
|
|
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
|
|
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
|
-
*
|
|
15
|
-
*
|
|
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
|
|
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
|
-
}
|
|
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
|
|
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;
|
package/dist/tui/mission-band.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
// The band:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
const
|
|
66
|
-
const
|
|
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
|
-
|
|
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
|
|
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. */
|
package/dist/tui/mission-text.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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, {
|
|
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
|
-
|
|
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:
|
|
226
|
-
// screen; a child (subagent) session shows its parent's mission as a lane.
|
|
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 :
|
|
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
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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.
|
|
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>
|
package/docs/reference-Ko-KR.md
CHANGED
|
@@ -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): 화면 맨
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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:
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
|
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.
|
|
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",
|