@litfamily/litopencode 1.0.18 → 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.
Files changed (56) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README-Ko-KR.md +56 -86
  3. package/README.md +57 -86
  4. package/dist/activation-routing.js +3 -1
  5. package/dist/cli/args.js +5 -1
  6. package/dist/cli/auto-update.js +1 -1
  7. package/dist/cli/doctor.js +7 -5
  8. package/dist/cli/host-limits.js +2 -5
  9. package/dist/cli/install-config.d.ts +4 -0
  10. package/dist/cli/install-config.js +27 -1
  11. package/dist/cli/install-report.js +4 -1
  12. package/dist/cli/install.js +89 -28
  13. package/dist/cli/json.d.ts +1 -0
  14. package/dist/cli/json.js +10 -4
  15. package/dist/cli/jsonc.d.ts +17 -0
  16. package/dist/cli/jsonc.js +174 -0
  17. package/dist/cli/loop.d.ts +1 -1
  18. package/dist/cli/loop.js +81 -1
  19. package/dist/cli/motion-runtime.d.ts +13 -1
  20. package/dist/cli/motion-runtime.js +89 -2
  21. package/dist/cli/plugin-mutation.js +11 -6
  22. package/dist/cli/tui-config.d.ts +52 -0
  23. package/dist/cli/tui-config.js +198 -0
  24. package/dist/cli/types.d.ts +4 -0
  25. package/dist/cli.js +8 -3
  26. package/dist/deliverable-hedge-guard.js +2 -2
  27. package/dist/tui/mission-band.d.ts +47 -0
  28. package/dist/tui/mission-band.js +290 -0
  29. package/dist/tui/mission-model.d.ts +139 -0
  30. package/dist/tui/mission-model.js +318 -0
  31. package/dist/tui/mission-motion.d.ts +66 -0
  32. package/dist/tui/mission-motion.js +147 -0
  33. package/dist/tui/mission-skills.d.ts +38 -0
  34. package/dist/tui/mission-skills.js +81 -0
  35. package/dist/tui/mission-source.d.ts +68 -0
  36. package/dist/tui/mission-source.js +112 -0
  37. package/dist/tui/mission-text.d.ts +106 -0
  38. package/dist/tui/mission-text.js +613 -0
  39. package/dist/tui.d.ts +6 -0
  40. package/dist/tui.js +342 -0
  41. package/docs/assets/readme/badge-version.svg +1 -1
  42. package/docs/reference-Ko-KR.md +91 -1
  43. package/docs/reference.md +100 -1
  44. package/package.json +21 -2
  45. package/skills/lit-pptx/scripts/lib/template-registry.js +1 -1
  46. package/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +0 -6
  47. package/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +0 -6
  48. package/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +0 -6
  49. package/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +0 -6
  50. package/skills/lit-pptx/templates/tonalities/night/pack.yaml +0 -6
  51. package/skills/lit-pptx/templates/tonalities/paper/pack.yaml +0 -6
  52. package/skills/lit-pptx/templates/tonalities/signal/pack.yaml +0 -6
  53. package/skills/lit-pptx/templates/tonalities/studio/pack.yaml +0 -6
  54. package/skills/managed-skill-manifest.json +9 -9
  55. package/tools/check-pack-payload.mjs +20 -5
  56. package/tools/version-manifests.json +18 -6
package/dist/tui.js ADDED
@@ -0,0 +1,342 @@
1
+ import { createEffect, createMemo, createSignal } from "solid-js";
2
+ import { useTerminalDimensions } from "@opentui/solid";
3
+ import { jsx } from "@opentui/solid/jsx-runtime";
4
+ import { buildMissionView, lastMission, leadingNotice, missionNotices } from "./tui/mission-model.js";
5
+ import { idleMotion, motionTiming, planMotion, sampleMotion } from "./tui/mission-motion.js";
6
+ import { chipBudget, chipLine, homeLine, missionPanel, paletteFromTheme, panelWidth } from "./tui/mission-text.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";
10
+ export const litOpenCodeTuiPluginId = "litopencode";
11
+ const homeLineWidth = 69;
12
+ const refreshDelayMs = 80;
13
+ const ledgerPollMs = 2000;
14
+ const toastMs = 4000;
15
+ const trackedSessionLimit = 4;
16
+ const refreshEvents = [
17
+ "message.updated",
18
+ "message.part.updated",
19
+ "todo.updated",
20
+ "session.status",
21
+ "session.idle",
22
+ "session.error",
23
+ "permission.asked",
24
+ "permission.replied",
25
+ "question.asked",
26
+ "question.replied",
27
+ "question.rejected"
28
+ ];
29
+ function spanFor(segment) {
30
+ return jsx("span", {
31
+ style: { fg: segment.fg, ...(segment.bg === undefined ? {} : { bg: segment.bg }), ...(segment.bold === true ? { bold: true } : {}) },
32
+ children: segment.text
33
+ });
34
+ }
35
+ function lineElement(line) {
36
+ return jsx("text", { wrapMode: "none", children: line.length === 0 ? " " : line.map(spanFor) });
37
+ }
38
+ const tui = async (api, options) => {
39
+ const motionEnabled = options?.motion !== false;
40
+ const band = readBandOptions(options);
41
+ const readLedger = createLedgerReader();
42
+ const sessions = new Map();
43
+ const tracked = [];
44
+ const [revision, setRevision] = createSignal(0);
45
+ const [clock, setClock] = createSignal(Date.now());
46
+ let frameTimer;
47
+ let refreshTimer;
48
+ let pollTimer;
49
+ const state = () => api.state;
50
+ const palette = () => paletteFromTheme(api.theme.current, api.theme.mode());
51
+ // OpenCode keeps the sidebar choice in its KV store under "sidebar" ("auto" or "hide"); a child
52
+ // session never shows the sidebar. Before the store is ready the setting is unknown.
53
+ function sidebarSetting() {
54
+ if (!api.kv.ready)
55
+ return undefined;
56
+ const value = api.kv.get("sidebar", "auto");
57
+ return typeof value === "string" ? value : undefined;
58
+ }
59
+ function isChildSession(sessionID) {
60
+ const session = api.state.session.get(sessionID);
61
+ return typeof session?.parentID === "string" && session.parentID !== "";
62
+ }
63
+ function anyMoving(now) {
64
+ for (const entry of sessions.values())
65
+ if (sampleMotion(entry.motion, now).moving || chipShimmer(entry.chipMotion, now) !== undefined)
66
+ return true;
67
+ return false;
68
+ }
69
+ // The frame timer runs only while a track is moving; an idle session draws nothing new.
70
+ function ensureFrames() {
71
+ if (frameTimer !== undefined || !anyMoving(Date.now()))
72
+ return;
73
+ frameTimer = setInterval(() => {
74
+ const now = Date.now();
75
+ setClock(now);
76
+ if (!anyMoving(now)) {
77
+ clearInterval(frameTimer);
78
+ frameTimer = undefined;
79
+ }
80
+ }, motionTiming.frameMs);
81
+ }
82
+ function refresh(sessionID) {
83
+ const now = Date.now();
84
+ const source = state();
85
+ const ledger = readLedger(missionProjectRoot(source.path));
86
+ const next = buildMissionView(readMissionInput(source, sessionID, ledger, now));
87
+ const card = readCardInput(source, sessionID);
88
+ const entry = sessions.get(sessionID);
89
+ const viewChanged = entry === undefined || JSON.stringify(entry.view) !== JSON.stringify(next);
90
+ if (!viewChanged && JSON.stringify(entry.card) === JSON.stringify(card))
91
+ return;
92
+ if (entry !== undefined && viewChanged) {
93
+ const notice = leadingNotice(missionNotices(entry.view, next));
94
+ if (notice !== undefined)
95
+ api.ui.toast({ variant: notice.variant, title: notice.title, message: notice.message, duration: toastMs });
96
+ }
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
+ });
103
+ setClock(now);
104
+ setRevision(revision() + 1);
105
+ ensureFrames();
106
+ }
107
+ function refreshTracked() {
108
+ refreshTimer = undefined;
109
+ for (const sessionID of tracked)
110
+ refresh(sessionID);
111
+ syncPoll();
112
+ }
113
+ // Ledger writes from a terminal or another session fire no TUI event, so an active mission
114
+ // re-reads goals.json (one lstat while unchanged) every two seconds. Idle sessions do not poll.
115
+ function syncPoll() {
116
+ const active = [...sessions.values()].some((entry) => entry.view.active);
117
+ if (active && pollTimer === undefined)
118
+ pollTimer = setInterval(refreshTracked, ledgerPollMs);
119
+ if (!active && pollTimer !== undefined) {
120
+ clearInterval(pollTimer);
121
+ pollTimer = undefined;
122
+ }
123
+ }
124
+ function scheduleRefresh() {
125
+ if (refreshTimer === undefined)
126
+ refreshTimer = setTimeout(refreshTracked, refreshDelayMs);
127
+ }
128
+ function track(sessionID) {
129
+ if (tracked.includes(sessionID))
130
+ return;
131
+ tracked.push(sessionID);
132
+ if (tracked.length > trackedSessionLimit)
133
+ sessions.delete(tracked.shift() ?? "");
134
+ queueMicrotask(() => {
135
+ refresh(sessionID);
136
+ syncPoll();
137
+ });
138
+ }
139
+ // Opening a session loads its messages and parts over HTTP, which fires no bus event. Reading the
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.
142
+ function watchSession(sessionID) {
143
+ createEffect(() => {
144
+ const source = state();
145
+ for (const message of source.session.messages(sessionID))
146
+ void source.part(message.id).length;
147
+ void source.session.todo(sessionID).length;
148
+ void source.session.status(sessionID)?.type;
149
+ scheduleRefresh();
150
+ });
151
+ }
152
+ function sessionView(sessionID) {
153
+ return () => {
154
+ revision();
155
+ const entry = sessions.get(sessionID);
156
+ if (entry === undefined || !entry.view.active)
157
+ return undefined;
158
+ return { view: entry.view, frame: sampleMotion(entry.motion, clock()) };
159
+ };
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
+ }
170
+ for (const type of refreshEvents) {
171
+ api.event.on(type, scheduleRefresh);
172
+ }
173
+ api.lifecycle.onDispose(() => {
174
+ if (frameTimer !== undefined)
175
+ clearInterval(frameTimer);
176
+ if (refreshTimer !== undefined)
177
+ clearTimeout(refreshTimer);
178
+ if (pollTimer !== undefined)
179
+ clearInterval(pollTimer);
180
+ sessions.clear();
181
+ });
182
+ api.slots.register({
183
+ order: 50,
184
+ slots: {
185
+ session_prompt_right(_ctx, props) {
186
+ track(props.session_id);
187
+ watchSession(props.session_id);
188
+ const current = sessionView(props.session_id);
189
+ const dimensions = useTerminalDimensions();
190
+ const visible = createMemo(() => current() !== undefined);
191
+ return () => {
192
+ if (!visible())
193
+ return null;
194
+ return jsx("box", {
195
+ flexDirection: "row",
196
+ flexShrink: 0,
197
+ children: () => {
198
+ const snapshot = current();
199
+ if (snapshot === undefined)
200
+ return null;
201
+ const budget = chipBudget({ width: dimensions().width, sidebar: sidebarSetting(), child: isChildSession(props.session_id) });
202
+ return lineElement(chipLine(snapshot.view, snapshot.frame, palette(), budget));
203
+ }
204
+ });
205
+ };
206
+ },
207
+ sidebar_content(_ctx, props) {
208
+ track(props.session_id);
209
+ watchSession(props.session_id);
210
+ const current = sessionView(props.session_id);
211
+ const panel = createMemo(() => {
212
+ const snapshot = current();
213
+ return snapshot === undefined ? undefined : missionPanel(snapshot.view, snapshot.frame, palette());
214
+ });
215
+ const visible = createMemo(() => panel() !== undefined);
216
+ return () => {
217
+ if (!visible())
218
+ return null;
219
+ // Props are getters so the border, titles and fill follow each frame without rebuilding the box.
220
+ return jsx("box", {
221
+ border: true,
222
+ borderStyle: "rounded",
223
+ width: panelWidth,
224
+ flexShrink: 0,
225
+ flexDirection: "column",
226
+ paddingLeft: 1,
227
+ paddingRight: 1,
228
+ titleAlignment: "left",
229
+ bottomTitleAlignment: "right",
230
+ get borderColor() { return panel()?.border; },
231
+ get title() { return panel()?.title; },
232
+ get titleColor() { return panel()?.titleColor; },
233
+ get bottomTitle() { return panel()?.bottomTitle ?? ""; },
234
+ get backgroundColor() { return panel()?.background ?? "transparent"; },
235
+ children: () => (panel()?.rows ?? []).map(lineElement)
236
+ });
237
+ };
238
+ }
239
+ }
240
+ });
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.
244
+ if (band.enabled) {
245
+ api.slots.register({
246
+ order: 50,
247
+ slots: {
248
+ app_bottom() {
249
+ const dimensions = useTerminalDimensions();
250
+ const target = createMemo(() => {
251
+ const route = api.route.current;
252
+ const id = route.name === "session" && typeof route.params?.sessionID === "string" ? route.params.sessionID : undefined;
253
+ if (id === undefined)
254
+ return undefined;
255
+ const parent = api.state.session.get(id)?.parentID;
256
+ return typeof parent === "string" && parent !== "" ? { sessionID: parent, inLane: true } : { sessionID: id, inLane: false };
257
+ });
258
+ createEffect(() => {
259
+ const place = target();
260
+ if (place === undefined)
261
+ return;
262
+ track(place.sessionID);
263
+ watchSession(place.sessionID);
264
+ });
265
+ const current = createMemo(() => {
266
+ const place = target();
267
+ const snapshot = place === undefined ? undefined : cardView(place.sessionID)();
268
+ return snapshot === undefined || place === undefined ? undefined : { ...snapshot, inLane: place.inLane };
269
+ });
270
+ const visible = createMemo(() => current() !== undefined);
271
+ return () => {
272
+ if (!visible())
273
+ return null;
274
+ return jsx("box", {
275
+ flexDirection: "column",
276
+ flexShrink: 0,
277
+ paddingLeft: 1,
278
+ paddingRight: 1,
279
+ get backgroundColor() { return palette().panel ?? "transparent"; },
280
+ children: () => {
281
+ const snapshot = current();
282
+ if (snapshot === undefined)
283
+ return null;
284
+ const colors = palette();
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;
298
+ if (card !== undefined) {
299
+ rows.push(jsx("box", {
300
+ border: true,
301
+ borderStyle: "rounded",
302
+ borderColor: card.border,
303
+ title: card.title,
304
+ titleColor: colors.text,
305
+ paddingLeft: 1,
306
+ paddingRight: 1,
307
+ flexShrink: 0,
308
+ flexDirection: "column",
309
+ children: card.rows.map(lineElement)
310
+ }));
311
+ }
312
+ return rows;
313
+ }
314
+ });
315
+ };
316
+ }
317
+ }
318
+ });
319
+ }
320
+ // After the built-in home tips (order 100): one line about the project's last mission, if any.
321
+ api.slots.register({
322
+ order: 150,
323
+ slots: {
324
+ home_bottom() {
325
+ const dimensions = useTerminalDimensions();
326
+ const line = createMemo(() => {
327
+ revision();
328
+ return homeLine(lastMission(readLedger(missionProjectRoot(state().path))), palette(), Math.min(homeLineWidth, dimensions().width - 10));
329
+ });
330
+ return () => {
331
+ const segments = line();
332
+ return segments.length === 0 ? null : jsx("box", { flexDirection: "row", justifyContent: "center", flexShrink: 0, children: lineElement(segments) });
333
+ };
334
+ }
335
+ }
336
+ });
337
+ };
338
+ const plugin = {
339
+ id: litOpenCodeTuiPluginId,
340
+ tui
341
+ };
342
+ export default plugin;
@@ -1 +1 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.18"><title>release: 1.0.18</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.18</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>
@@ -242,6 +242,96 @@ npm uninstall -g @litfamily/litopencode
242
242
  프로젝트의 `.litopencode/`는 별도 ledger와 receipt입니다. 나중에 재개할 계획이 없다면
243
243
  그 상태를 버려도 되는지 확인한 뒤 직접 정리합니다.
244
244
 
245
+ TUI 패널 항목은 `tui.json`이나 `tui.jsonc`에 있습니다. `litopencode tui remove [--root <dir>]`는
246
+ 두 파일에서 LitOpenCode 항목만 지우고, 다른 플러그인과 주석은 그대로 둡니다. 바뀌는 파일은 먼저
247
+ `<file>.litopencode-backup`으로 복사합니다. `--dry-run`은 같은 결과를 보여 주기만 하고 아무것도 쓰지 않습니다.
248
+
249
+ ## TUI 미션 패널
250
+
251
+ OpenCode 1.18은 `tui.json`이나 `tui.jsonc`의 `plugin` 배열에 적힌 TUI 플러그인을 불러옵니다.
252
+ 이 패키지는 TUI 모듈을 서버 플러그인과 따로 `@litfamily/litopencode/tui`(`dist/tui.js`, 플러그인 id
253
+ `litopencode`)로 내보냅니다. OpenCode가 두 대상을 한 모듈에 섞는 것을 허용하지 않기 때문입니다.
254
+
255
+ 화면에 그리는 것:
256
+
257
+ - 입력창 아래 agent/model 줄 오른쪽의 단계 칩: `🔥 LIT` 알약 뒤에 `PLAN` › `WORK n/m` › `REVIEW`
258
+ 알약과 다섯 칸 `▰▱` 막대가 붙습니다. 자리가 좁으면 이름부터 덜어 내서, `🔥`와 지금·다음 단계만,
259
+ 더 좁으면 `🔥`와 개수만 남깁니다. 자리는 OpenCode에 저장된 사이드바 설정과 세션 종류(하위 세션에는
260
+ 사이드바가 없음)로 계산합니다. 설정을 아직 읽을 수 없으면 사이드바가 열려 있다고 보고 좁게 잡습니다.
261
+ - 세션 사이드바 맨 위의 미션 패널: 36칸짜리 둥근 상자이고, 위 테두리에 `🔥 LIT ▸ <단계>`, 아래
262
+ 테두리에 `n/m criteria`가 붙습니다. 테두리 색이 상태를 보여 줍니다. 일하는 중에는 테마 accent,
263
+ 경고가 있으면 warning, 끝나면 success 색입니다. 안에는 두 줄짜리 `LIT`과 그 옆의 작업 이름·상태,
264
+ 굵은 목표 제목, 단계 줄, 1/8칸 블록으로 칠하고 빈 곳은 가는 선으로 둔 그라데이션 막대, 다음 할 일이나
265
+ 아직 통과하지 못한 기준, 기준 목록, 경고, 최근 증거 네 개가 들어갑니다.
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
+ 넘기지는 않습니다. 완료 순간도 여기서 함께 보입니다.
286
+ - 홈 화면 입력창 아래, OpenCode 팁 다음 줄: `🔥 lit ready · last: <목표> ✓ n/m`. 프로젝트 ledger에
287
+ 목표가 있을 때만 보입니다.
288
+ - 경고는 색만 바꾸지 않고 늘 `⚑`와 글자를 함께 붙입니다. accent와 warning이 같은 색인 테마가 있기
289
+ 때문입니다. 알파가 0인 테마 바탕(system, lucent-orng)은 검게 칠하지 않고 비워 둡니다.
290
+ - 관문 사건마다 토스트 하나: 계획 저장 또는 저장 실패, 목표 설정, 기준 통과, 리뷰 막힘과 해결, 완료.
291
+ 한 번에 여러 사건이 생기면 가장 급한 것 하나로 합칩니다.
292
+ - 목표가 끝나거나 할 일이 모두 끝나면 1.2초 안에 끝나는 완료 순간을 보여 줍니다. 테두리가 초록으로
293
+ 바뀌고 막대가 끝까지 차며, `WORK`와 `REVIEW`가 차례로 완료로 바뀝니다. 브랜드에 빛이 한 번 지나가고,
294
+ 패널 안으로 불씨가 올라가며, `COMPLETE`가 살짝 넘쳤다가 제자리로 옵니다.
295
+
296
+ 데이터는 이미 있는 것만 읽고, TUI를 위해 새로 쓰는 파일은 없습니다.
297
+
298
+ - 스킬 칩은 OpenCode가 이미 저장하는 세 곳에서 읽습니다. 이름이 LitOpenCode가 싣고 다니는 스킬인
299
+ 내장 `skill` 도구 호출(다른 스킬은 빼고, 불러오기에 실패한 호출과 상태 조회는 실행으로 치지 않음),
300
+ 이 플러그인의 `lit`, `litwork`, `start-work`, `review-work`, `wikify` 도구 호출, 그리고 맨 `lit`
301
+ 라우팅과 슬래시 명령에 붙는 아래의 `metadata.litopencode` 표시입니다. "지금 요청"은 세션이 일하는
302
+ 동안 마지막 사용자 메시지 이후에 실행된 것을 뜻합니다.
303
+ - `lit`이 모드를 켜거나 명령이 실행될 때(`save-plan` 포함) 이 플러그인이 사용자 메시지 part에 붙이는
304
+ `metadata.litopencode` 표시. 표시가 없는 세션은 쉬는 상태로 보고, 기본 `lit-loop` agent여도 칩과
305
+ 패널을 숨깁니다.
306
+ - 세션의 OpenCode 할 일 목록(진행도와 다음 항목), 상태(재시도), 대기 중인 권한 요청과 질문, 마지막
307
+ assistant 메시지 기준 context 사용량(75%부터 주황).
308
+ - 프로젝트 루트의 `.litopencode/litgoal/lit-loop/goals.json`. 심볼릭 링크와 1 MiB가 넘는 파일은 읽지
309
+ 않습니다. 세션의 첫 lit 턴보다 먼저 마지막으로 바뀐 목표는 이전 작업으로 보고 빼 둡니다.
310
+
311
+ 움직임은 전환이 일어나는 동안에만 짧은 프레임 타이머로 돌고 끝나면 멈춥니다. 새 줄과 알약은
312
+ ease-out-back으로 160ms씩, 60ms 간격으로 들어오고, 막대는 300ms에 차오릅니다. 경고 줄은 220ms에
313
+ 걸쳐 들어오고 색은 140ms 동안 넘어가며, 빛이 지나가는 데는 400ms가 걸립니다. lit 세션이
314
+ 열려 있는 동안만 2초마다 목표 ledger를 다시 읽고, 쉬는 세션은 읽지 않습니다. 색은 지금 OpenCode
315
+ 테마에서 가져옵니다. 움직임을 끄려면 `tui.json`에 `["@litfamily/litopencode@<version>", { "motion": false }]`
316
+ 처럼 적고, 플러그인 자체를 끄려면 OpenCode 플러그인 관리자나 `plugin_enabled`에서 id `litopencode`를 끕니다.
317
+
318
+ 띠 설정도 같은 옵션 객체에 적습니다.
319
+
320
+ - `{ "band": false }`는 띠만 없애고 칩, 패널, 홈 줄은 그대로 둡니다.
321
+ - `{ "band": { "card": true } }`는 상태 카드 아래에 경고, 다음 할 일, 목표를 담은 둥근 상세 카드를 붙입니다.
322
+ 목표가 있는 동안 카드는 네 줄로 고정되어, 경고가 생기거나 사라져도 입력창이 움직이지 않습니다.
323
+ 카드는 기본으로 꺼져 있습니다.
324
+
325
+ 다른 터미널에서 쓴 ledger(예: `litopencode record-evidence`)는 OpenCode 이벤트를 만들지 않습니다.
326
+ 그래서 2초마다 도는 공용 ledger 확인이 이를 잡고, 칩·패널·띠가 같은 프레임 시계로 함께 바뀝니다.
327
+
328
+ 항목은 install이 씁니다. 기본 root에서는 `opencode plugin`이 `./tui` export를 보고 직접 항목을 넣고,
329
+ LitOpenCode가 중복과 옛 버전을 정리합니다. custom root에서는 LitOpenCode가 직접 씁니다. 이미
330
+ LitOpenCode 항목이 있는 파일을 고치고, 없으면 `tui.json`과 `tui.jsonc` 중 먼저 있는 파일을, 둘 다 없으면
331
+ 새 `tui.json`을 씁니다. OpenCode가 고르는 방식과 같습니다. `./herdr-tui-session.js` 같은 다른 항목과
332
+ 모든 주석은 그대로 남습니다. `plugin` 목록 안의 주석을 지워야 하는 경우에는 아무것도 쓰기 전에 install을
333
+ 멈춥니다. `doctor`는 이 항목을 `install.tui`에 보고하고, `install.ok` 값은 바꾸지 않습니다.
334
+
245
335
  ## 자동 인수인계
246
336
 
247
337
  자동 인수인계는 직접 켜야 하며 기본은 꺼져 있습니다. 기본 퍼센트는 없습니다. 플러그인의 `event` 훅과 `experimental.chat.system.transform` 훅에서 동작하고, 대화 내용은 읽지 않으며 assistant 메시지의 토큰 수만 봅니다.
@@ -338,7 +428,7 @@ OpenCode는 세션이 바쁜 동안 들어온 `summarize` 호출을 줄 세웠
338
428
 
339
429
  - 패키지와 plugin id: `litopencode`
340
430
  - CLI binary: `litopencode`
341
- - 공개 export: `litopencode`, `litopencode/server`
431
+ - 공개 export: `litopencode`, `litopencode/server`, `litopencode/tui`
342
432
  - 전역 route 파일: `~/.config/opencode/litopencode.json`
343
433
  - 프로젝트 override: `.litopencode/config.json`
344
434
  - Durable 상태: `.litopencode/litgoal/`
package/docs/reference.md CHANGED
@@ -342,6 +342,105 @@ OpenCode's managed `opencode.jsonc` (or the custom root's `opencode.json`). Remo
342
342
  Project `.litopencode/` state is separate; preserve it for later resume or remove it only
343
343
  when discarding that project's ledger and receipts is intentional.
344
344
 
345
+ The TUI panel entry lives in `tui.json` or `tui.jsonc`. `litopencode tui remove [--root <dir>]`
346
+ deletes LitOpenCode's own entry from both files, keeps every other plugin and every comment, and
347
+ copies each changed file to `<file>.litopencode-backup` first. `--dry-run` prints the same receipt
348
+ and writes nothing.
349
+
350
+ ## TUI mission panel
351
+
352
+ OpenCode 1.18 loads TUI plugins listed in the `plugin` array of `tui.json` or `tui.jsonc`. The
353
+ package exports its TUI module as `@litfamily/litopencode/tui` (`dist/tui.js`, plugin id
354
+ `litopencode`), separate from the server plugin, as OpenCode requires.
355
+
356
+ What it draws:
357
+
358
+ - A phase chip on the right of the prompt's agent/model row: a `🔥 LIT` pill, then `PLAN` ›
359
+ `WORK n/m` › `REVIEW` pills and a five-segment `▰▱` bar. When the row is narrower it drops the
360
+ labels first: `🔥` with the current and next phase, then `🔥` with only the count. The room comes
361
+ from OpenCode's stored sidebar setting and the session kind (a child session has no sidebar); when
362
+ the setting cannot be read yet, the chip assumes the sidebar is open and stays narrower.
363
+ - A mission panel at the top of the session sidebar, in a 36-cell rounded box titled
364
+ `🔥 LIT ▸ <PHASE>` with `n/m criteria` in the bottom border. The border carries the state: the
365
+ theme accent while working, the warning colour on a warning, the success colour when complete.
366
+ Inside: a two-row `LIT` with the discipline and a status beside it, the goal title in bold, the
367
+ phase line, a gradient bar drawn in eighth-cell blocks over a thin rule, the next todo or open
368
+ criterion, the criteria, warnings, and the last four evidence events.
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.
389
+ - A line under the home prompt, after OpenCode's tips: `🔥 lit ready · last: <goal> ✓ n/m`. It
390
+ appears only when the project ledger holds a goal.
391
+ - Warnings always carry `⚑` and words, not colour alone, because some themes use one colour for
392
+ both accent and warning. Theme surfaces with alpha 0 (the system and lucent-orng themes) are left
393
+ unfilled rather than painted black.
394
+ - One toast per refresh for a gate event: plan saved or not saved, goal set, criterion passed,
395
+ review blocker opened or resolved, and completion. Several events at once collapse into the most
396
+ urgent one.
397
+ - A completion moment of at most 1.2 seconds when the goal completes or every todo is done: the
398
+ border turns green, the bar fills, `WORK` then `REVIEW` turn done, a light sweep crosses the brand,
399
+ embers rise through the panel and `COMPLETE` pops in with a small overshoot before it settles.
400
+
401
+ Where the data comes from, with nothing new written for the TUI:
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.
408
+ - The `metadata.litopencode` marks this plugin already attaches to user-message parts when `lit`
409
+ activates a mode or a command runs (`save-plan` included). A session with no mark is idle and the
410
+ chip and panel stay hidden, even on the default `lit-loop` agent.
411
+ - OpenCode's todo list for the session (progress and next item), its status (retry), pending
412
+ permissions and questions, and context use from the last assistant message (amber at 75%).
413
+ - `.litopencode/litgoal/lit-loop/goals.json` under the project root. Symbolic links and files over
414
+ 1 MiB are ignored. A goal last updated before the session's first lit turn is treated as earlier
415
+ work and left out.
416
+
417
+ Motion runs on a short frame timer only while a transition plays and stops afterwards: new rows
418
+ and pills slide in with ease-out-back, 160 ms each and 60 ms apart; the bar fills in 300 ms; a
419
+ warning row slides in over 220 ms and colours cross-fade in 140 ms; the light sweep takes 400 ms. While a
420
+ lit session is open the panel re-reads the goal ledger every two seconds; an idle session does not.
421
+ Colours come from the active OpenCode theme. Set `["@litfamily/litopencode@<version>", { "motion": false }]`
422
+ in `tui.json` to keep it static, or turn the plugin off by id `litopencode` in OpenCode's plugin
423
+ manager or with `plugin_enabled`.
424
+
425
+ Band options go in the same options object:
426
+
427
+ - `{ "band": false }` removes the band and leaves the chip, the panel and the home line.
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
430
+ clearing never moves the prompt. The card is off by default.
431
+
432
+ A ledger written from another terminal (for example `litopencode record-evidence`) raises no
433
+ OpenCode event, so the shared two-second ledger check picks it up; the chip, the panel and the band
434
+ then change on the same frame clock.
435
+
436
+ Install writes the entry. On the default root `opencode plugin` adds it from the `./tui` export and
437
+ LitOpenCode then removes duplicates and stale versions; on a custom root LitOpenCode writes it. The
438
+ file that already holds a LitOpenCode entry is edited, otherwise the first of `tui.json` and
439
+ `tui.jsonc` that exists, otherwise a new `tui.json`, matching OpenCode's own choice. Other entries
440
+ such as `./herdr-tui-session.js` and all comments stay; an edit that would delete a comment inside
441
+ the `plugin` list stops the install before anything is written. `doctor` reports the entry under
442
+ `install.tui` without changing `install.ok`.
443
+
345
444
  ## Automatic handoff
346
445
 
347
446
  Automatic handoff is opt-in and OFF by default. There is no default percent. It works in the plugin's `event` hook and its `experimental.chat.system.transform` hook, and it reads nothing from the conversation except each assistant message's token counts.
@@ -453,7 +552,7 @@ only. It is sent only in the `Authorization` header and is never written, logged
453
552
 
454
553
  - Package and plugin id: `litopencode`
455
554
  - CLI binary: `litopencode`
456
- - Public exports: `litopencode` and `litopencode/server`
555
+ - Public exports: `litopencode`, `litopencode/server` and `litopencode/tui`
457
556
  - Global route file: `~/.config/opencode/litopencode.json`
458
557
  - Project overrides: `.litopencode/config.json`
459
558
  - Durable state: `.litopencode/litgoal/`
package/package.json CHANGED
@@ -1,7 +1,22 @@
1
1
  {
2
2
  "name": "@litfamily/litopencode",
3
- "version": "1.0.18",
4
- "description": "LitOpenCode OpenCode plugin bootstrap package.",
3
+ "version": "1.0.20",
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
+ "keywords": [
6
+ "opencode",
7
+ "opencode-plugin",
8
+ "ai-agents",
9
+ "coding-agent",
10
+ "agent-workflow",
11
+ "planning",
12
+ "code-review",
13
+ "research",
14
+ "context-handoff",
15
+ "developer-tools",
16
+ "korean",
17
+ "docx",
18
+ "pptx"
19
+ ],
5
20
  "license": "MIT",
6
21
  "repository": {
7
22
  "type": "git",
@@ -22,6 +37,10 @@
22
37
  "./server": {
23
38
  "types": "./dist/server.d.ts",
24
39
  "import": "./dist/server.js"
40
+ },
41
+ "./tui": {
42
+ "types": "./dist/tui.d.ts",
43
+ "import": "./dist/tui.js"
25
44
  }
26
45
  },
27
46
  "scripts": {
@@ -470,7 +470,7 @@ function validatePack(pack, file) {
470
470
  need(typeof pack.intent === "string" && pack.intent.length > 0, "intent is missing");
471
471
  need(isList(pack.canvas) && pack.canvas.length > 0 && pack.canvas.every((c) => grid.CANVAS[c]), "canvas must list 16:9 and/or 4:3");
472
472
  // `faces` is the deck's default and is Pretendard only; `faces-a2z` applies when the source asks.
473
- for (const key of ["faces", "faces-pretendard-only", "faces-a2z"]) {
473
+ for (const key of ["faces", "faces-a2z"]) {
474
474
  if (key === "faces-a2z" && pack[key] == null) continue;
475
475
  const faces = pack[key] || {};
476
476
  for (const role of FACE_KEYS) {
@@ -14,12 +14,6 @@ faces:
14
14
  body: Pretendard Regular
15
15
  label: Pretendard Regular
16
16
  numeral: Pretendard Regular
17
- faces-pretendard-only:
18
- display: Pretendard Regular
19
- title: Pretendard Bold
20
- body: Pretendard Regular
21
- label: Pretendard Regular
22
- numeral: Pretendard Regular
23
17
  faces-a2z:
24
18
  display: A2Z Light
25
19
  title: Pretendard Bold
@@ -14,12 +14,6 @@ faces:
14
14
  body: Pretendard Regular
15
15
  label: Pretendard Regular
16
16
  numeral: Pretendard Bold
17
- faces-pretendard-only:
18
- display: Pretendard Bold
19
- title: Pretendard Bold
20
- body: Pretendard Regular
21
- label: Pretendard Regular
22
- numeral: Pretendard Bold
23
17
  faces-a2z:
24
18
  display: A2Z Bold
25
19
  title: A2Z Bold
@@ -15,12 +15,6 @@ faces:
15
15
  body: Pretendard Regular
16
16
  label: Pretendard Regular
17
17
  numeral: Pretendard Bold
18
- faces-pretendard-only:
19
- display: Pretendard Bold
20
- title: Pretendard Bold
21
- body: Pretendard Regular
22
- label: Pretendard Regular
23
- numeral: Pretendard Bold
24
18
  faces-a2z:
25
19
  display: A2Z Bold
26
20
  title: Pretendard Bold