@litfamily/litopencode 1.0.18 → 1.0.19
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 +10 -0
- package/README-Ko-KR.md +56 -86
- package/README.md +57 -86
- package/dist/activation-routing.js +3 -1
- package/dist/cli/args.js +5 -1
- package/dist/cli/auto-update.js +1 -1
- package/dist/cli/doctor.js +7 -5
- package/dist/cli/host-limits.js +2 -5
- package/dist/cli/install-config.d.ts +4 -0
- package/dist/cli/install-config.js +27 -1
- package/dist/cli/install-report.js +4 -1
- package/dist/cli/install.js +89 -28
- package/dist/cli/json.d.ts +1 -0
- package/dist/cli/json.js +10 -4
- package/dist/cli/jsonc.d.ts +17 -0
- package/dist/cli/jsonc.js +174 -0
- package/dist/cli/loop.d.ts +1 -1
- package/dist/cli/loop.js +81 -1
- package/dist/cli/motion-runtime.d.ts +13 -1
- package/dist/cli/motion-runtime.js +89 -2
- package/dist/cli/plugin-mutation.js +11 -6
- package/dist/cli/tui-config.d.ts +52 -0
- package/dist/cli/tui-config.js +198 -0
- package/dist/cli/types.d.ts +4 -0
- package/dist/cli.js +8 -3
- package/dist/deliverable-hedge-guard.js +2 -2
- package/dist/tui/mission-band.d.ts +29 -0
- package/dist/tui/mission-band.js +116 -0
- package/dist/tui/mission-model.d.ts +139 -0
- package/dist/tui/mission-model.js +318 -0
- package/dist/tui/mission-motion.d.ts +66 -0
- package/dist/tui/mission-motion.js +147 -0
- package/dist/tui/mission-source.d.ts +56 -0
- package/dist/tui/mission-source.js +88 -0
- package/dist/tui/mission-text.d.ts +103 -0
- package/dist/tui/mission-text.js +613 -0
- package/dist/tui.d.ts +6 -0
- package/dist/tui.js +317 -0
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/reference-Ko-KR.md +72 -1
- package/docs/reference.md +82 -1
- package/package.json +21 -2
- package/skills/lit-pptx/scripts/lib/template-registry.js +1 -1
- package/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +0 -6
- package/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +0 -6
- package/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +0 -6
- package/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +0 -6
- package/skills/lit-pptx/templates/tonalities/night/pack.yaml +0 -6
- package/skills/lit-pptx/templates/tonalities/paper/pack.yaml +0 -6
- package/skills/lit-pptx/templates/tonalities/signal/pack.yaml +0 -6
- package/skills/lit-pptx/templates/tonalities/studio/pack.yaml +0 -6
- package/skills/managed-skill-manifest.json +9 -9
- package/tools/check-pack-payload.mjs +20 -5
- package/tools/version-manifests.json +18 -6
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
// The mission view model for the OpenCode TUI. It reads only what LitOpenCode already leaves behind:
|
|
2
|
+
// the `metadata.litopencode` marks the plugin attaches to user-message parts on activation and on
|
|
3
|
+
// commands, the host todo list, and the evidence ledger in .litopencode/litgoal/lit-loop/goals.json.
|
|
4
|
+
// No new state is written for the TUI.
|
|
5
|
+
export const contextWarnPercent = 75;
|
|
6
|
+
const timelineLimit = 4;
|
|
7
|
+
const staleGoalSlackMs = 60_000;
|
|
8
|
+
export const idleMission = Object.freeze({
|
|
9
|
+
active: false,
|
|
10
|
+
discipline: "",
|
|
11
|
+
phase: "plan",
|
|
12
|
+
steps: Object.freeze([
|
|
13
|
+
{ id: "plan", state: "todo" },
|
|
14
|
+
{ id: "work", state: "todo" },
|
|
15
|
+
{ id: "review", state: "todo" }
|
|
16
|
+
]),
|
|
17
|
+
blockers: Object.freeze([]),
|
|
18
|
+
warnings: Object.freeze([]),
|
|
19
|
+
timeline: Object.freeze([]),
|
|
20
|
+
planSaved: false,
|
|
21
|
+
complete: false
|
|
22
|
+
});
|
|
23
|
+
function isRecord(value) {
|
|
24
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
25
|
+
}
|
|
26
|
+
function text(value) {
|
|
27
|
+
return typeof value === "string" ? value : "";
|
|
28
|
+
}
|
|
29
|
+
/** Reads one part's `metadata.litopencode` mark. Anything without that shape is not a mark. */
|
|
30
|
+
export function readLitMark(metadata, at) {
|
|
31
|
+
if (!isRecord(metadata) || !isRecord(metadata.litopencode))
|
|
32
|
+
return undefined;
|
|
33
|
+
const mark = metadata.litopencode;
|
|
34
|
+
const mode = text(mark.mode);
|
|
35
|
+
const command = text(mark.command);
|
|
36
|
+
if (mode === "" && command === "")
|
|
37
|
+
return undefined;
|
|
38
|
+
return {
|
|
39
|
+
at,
|
|
40
|
+
...(mode === "" ? {} : { mode }),
|
|
41
|
+
...(command === "" ? {} : { command }),
|
|
42
|
+
...(text(mark.operation) === "" ? {} : { operation: text(mark.operation) }),
|
|
43
|
+
...(mark.blocked === true ? { blocked: true } : {})
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function stepOf(name) {
|
|
47
|
+
if (name === "")
|
|
48
|
+
return undefined;
|
|
49
|
+
if (name === "review-work" || name.includes("review"))
|
|
50
|
+
return "review";
|
|
51
|
+
if (name === "lit-plan" || name.endsWith("-plan") || name === "lit-crucible" || name === "deep-interview")
|
|
52
|
+
return "plan";
|
|
53
|
+
return "work";
|
|
54
|
+
}
|
|
55
|
+
/** Lenient read of goals.json: the active goal, or undefined when the file is absent or not the ledger shape. */
|
|
56
|
+
export function readActiveGoal(raw) {
|
|
57
|
+
if (!isRecord(raw) || !Array.isArray(raw.goals))
|
|
58
|
+
return undefined;
|
|
59
|
+
const goal = raw.goals.find((candidate) => isRecord(candidate) && candidate.id === raw.activeGoalId);
|
|
60
|
+
if (!isRecord(goal))
|
|
61
|
+
return undefined;
|
|
62
|
+
const list = (value) => (Array.isArray(value) ? value.filter(isRecord) : []);
|
|
63
|
+
return {
|
|
64
|
+
title: text(goal.title) || text(goal.objective),
|
|
65
|
+
status: text(goal.status),
|
|
66
|
+
createdAt: text(goal.createdAt),
|
|
67
|
+
updatedAt: text(goal.updatedAt),
|
|
68
|
+
criteria: list(goal.criteria).map((criterion) => ({
|
|
69
|
+
id: text(criterion.id),
|
|
70
|
+
scenario: text(criterion.scenario),
|
|
71
|
+
status: text(criterion.status),
|
|
72
|
+
evidence: list(criterion.evidence).map((entry) => ({ kind: text(entry.kind), ref: text(entry.ref), at: text(entry.at) }))
|
|
73
|
+
})),
|
|
74
|
+
checkpoints: list(goal.checkpoints).map((entry) => ({ summary: text(entry.summary), at: text(entry.at) })),
|
|
75
|
+
reviewBlockers: list(goal.reviewBlockers).map((entry) => ({
|
|
76
|
+
id: text(entry.id),
|
|
77
|
+
detail: text(entry.detail),
|
|
78
|
+
resolved: entry.resolved === true,
|
|
79
|
+
needsUserDecision: entry.needsUserDecision === true
|
|
80
|
+
}))
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
export function formatAgo(at, now) {
|
|
84
|
+
const time = Date.parse(at);
|
|
85
|
+
if (!Number.isFinite(time))
|
|
86
|
+
return "";
|
|
87
|
+
const seconds = Math.max(0, Math.round((now - time) / 1000));
|
|
88
|
+
if (seconds < 60)
|
|
89
|
+
return "now";
|
|
90
|
+
const minutes = Math.round(seconds / 60);
|
|
91
|
+
if (minutes < 60)
|
|
92
|
+
return `${minutes}m`;
|
|
93
|
+
const hours = Math.round(minutes / 60);
|
|
94
|
+
if (hours < 48)
|
|
95
|
+
return `${hours}h`;
|
|
96
|
+
return `${Math.round(hours / 24)}d`;
|
|
97
|
+
}
|
|
98
|
+
function timeline(goal, now) {
|
|
99
|
+
const events = [];
|
|
100
|
+
for (const criterion of goal.criteria) {
|
|
101
|
+
for (const entry of criterion.evidence) {
|
|
102
|
+
events.push({ at: Date.parse(entry.at), raw: entry.at, kind: entry.kind, label: `${criterion.id} ${entry.ref}`.trim() });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
for (const checkpoint of goal.checkpoints) {
|
|
106
|
+
events.push({ at: Date.parse(checkpoint.at), raw: checkpoint.at, kind: "checkpoint", label: checkpoint.summary });
|
|
107
|
+
}
|
|
108
|
+
if (goal.createdAt !== "")
|
|
109
|
+
events.push({ at: Date.parse(goal.createdAt), raw: goal.createdAt, kind: "goal", label: "created" });
|
|
110
|
+
return events
|
|
111
|
+
.filter((event) => Number.isFinite(event.at))
|
|
112
|
+
.sort((left, right) => right.at - left.at)
|
|
113
|
+
.slice(0, timelineLimit)
|
|
114
|
+
.map((event) => ({ ago: formatAgo(event.raw, now), kind: event.kind, label: event.label }));
|
|
115
|
+
}
|
|
116
|
+
export function buildMissionView(input) {
|
|
117
|
+
const marks = [...input.marks].sort((left, right) => left.at - right.at);
|
|
118
|
+
const agentStep = input.agent?.startsWith("lit-") === true ? stepOf(input.agent) : undefined;
|
|
119
|
+
const latest = marks.at(-1);
|
|
120
|
+
// The ledger is per project. A goal last touched before this session's first lit turn belongs to
|
|
121
|
+
// earlier work, so a fresh session does not open on someone else's finished goal.
|
|
122
|
+
const ledgerGoal = readActiveGoal(input.ledger);
|
|
123
|
+
const touched = ledgerGoal === undefined ? Number.NaN : Date.parse(ledgerGoal.updatedAt || ledgerGoal.createdAt);
|
|
124
|
+
const goal = ledgerGoal !== undefined && marks.length > 0 && !(touched < marks[0].at - staleGoalSlackMs) ? ledgerGoal : undefined;
|
|
125
|
+
// lit-loop is LitOpenCode's default agent, so the agent alone never lights the mission; only a lit
|
|
126
|
+
// activation or a lit command recorded in this session does.
|
|
127
|
+
const active = marks.length > 0;
|
|
128
|
+
if (!active)
|
|
129
|
+
return idleMission;
|
|
130
|
+
const planMark = [...marks].reverse().find((mark) => mark.operation === "save-plan");
|
|
131
|
+
const planSaved = planMark !== undefined && planMark.blocked !== true;
|
|
132
|
+
const planBlocked = planMark?.blocked === true;
|
|
133
|
+
const discipline = latest?.mode && latest.mode !== "bounded-authority-lifecycle" ? latest.mode : latest?.command ?? input.agent ?? "lit";
|
|
134
|
+
const counted = input.todos.filter((todo) => todo.status !== "cancelled");
|
|
135
|
+
const progress = counted.length > 0
|
|
136
|
+
? { done: counted.filter((todo) => todo.status === "completed").length, total: counted.length }
|
|
137
|
+
: goal !== undefined && goal.criteria.length > 0
|
|
138
|
+
? { done: goal.criteria.filter((criterion) => criterion.status === "pass").length, total: goal.criteria.length }
|
|
139
|
+
: undefined;
|
|
140
|
+
const nextTodo = counted.find((todo) => todo.status === "in_progress") ?? counted.find((todo) => todo.status === "pending");
|
|
141
|
+
const nextCriterion = goal?.criteria.find((criterion) => criterion.status !== "pass");
|
|
142
|
+
const blockers = (goal?.reviewBlockers ?? []).filter((blocker) => !blocker.resolved);
|
|
143
|
+
const complete = goal?.status === "complete" || (progress !== undefined && progress.total > 0 && progress.done === progress.total && goal === undefined && counted.length > 0);
|
|
144
|
+
let phase = latest === undefined ? agentStep ?? "work" : stepOf(latest.command ?? latest.mode ?? "") ?? "work";
|
|
145
|
+
if (latest?.operation === "save-plan" && !planBlocked)
|
|
146
|
+
phase = "work";
|
|
147
|
+
// A blocker sends finished work back to review; with criteria still open the work phase carries the warning.
|
|
148
|
+
const workComplete = progress === undefined || progress.done === progress.total;
|
|
149
|
+
if (blockers.length > 0 || goal?.status === "review_blocked")
|
|
150
|
+
phase = workComplete ? "review" : phase === "review" ? "review" : "work";
|
|
151
|
+
if (complete)
|
|
152
|
+
phase = "done";
|
|
153
|
+
const warnings = [];
|
|
154
|
+
for (const blocker of blockers) {
|
|
155
|
+
warnings.push({ kind: blocker.needsUserDecision ? "decision" : "blocker", text: `${blocker.id} ${blocker.detail}`.trim() });
|
|
156
|
+
}
|
|
157
|
+
if (goal?.status === "needs_user_decision" && !blockers.some((blocker) => blocker.needsUserDecision)) {
|
|
158
|
+
warnings.push({ kind: "decision", text: "goal needs your decision" });
|
|
159
|
+
}
|
|
160
|
+
if (planBlocked && phase === "plan")
|
|
161
|
+
warnings.push({ kind: "plan", text: "plan not saved yet" });
|
|
162
|
+
if (input.sessionStatus === "retry")
|
|
163
|
+
warnings.push({ kind: "retry", text: input.retryMessage ? `retrying · ${input.retryMessage}` : "retrying" });
|
|
164
|
+
if ((input.pendingPermissions ?? 0) > 0)
|
|
165
|
+
warnings.push({ kind: "permission", text: "waiting for permission" });
|
|
166
|
+
if ((input.pendingQuestions ?? 0) > 0)
|
|
167
|
+
warnings.push({ kind: "question", text: "waiting for your answer" });
|
|
168
|
+
if (typeof input.contextPercent === "number" && input.contextPercent >= contextWarnPercent) {
|
|
169
|
+
warnings.push({ kind: "context", text: `ctx ${Math.round(input.contextPercent)}%` });
|
|
170
|
+
}
|
|
171
|
+
const order = ["plan", "work", "review"];
|
|
172
|
+
const position = phase === "done" ? order.length : order.indexOf(phase);
|
|
173
|
+
const planned = planMark !== undefined || agentStep === "plan" || marks.some((mark) => stepOf(mark.command ?? mark.mode ?? "") === "plan");
|
|
174
|
+
const steps = order.map((id, index) => {
|
|
175
|
+
if (index < position)
|
|
176
|
+
return { id, state: id === "plan" && !planned ? "skip" : "done" };
|
|
177
|
+
if (index > position)
|
|
178
|
+
return { id, state: "todo" };
|
|
179
|
+
return { id, state: warnings.length > 0 ? "warn" : "now" };
|
|
180
|
+
});
|
|
181
|
+
return {
|
|
182
|
+
active: true,
|
|
183
|
+
discipline,
|
|
184
|
+
phase,
|
|
185
|
+
steps,
|
|
186
|
+
...(progress === undefined ? {} : { progress }),
|
|
187
|
+
...(nextTodo !== undefined ? { next: nextTodo.content } : nextCriterion !== undefined ? { next: `${nextCriterion.id} ${nextCriterion.scenario}`.trim() } : {}),
|
|
188
|
+
...(goal === undefined
|
|
189
|
+
? {}
|
|
190
|
+
: {
|
|
191
|
+
goal: {
|
|
192
|
+
title: goal.title,
|
|
193
|
+
status: goal.status,
|
|
194
|
+
passed: goal.criteria.filter((criterion) => criterion.status === "pass").length,
|
|
195
|
+
total: goal.criteria.length,
|
|
196
|
+
criteria: goal.criteria.map((criterion) => ({ id: criterion.id, status: criterion.status }))
|
|
197
|
+
}
|
|
198
|
+
}),
|
|
199
|
+
blockers: blockers.map((blocker) => ({ id: blocker.id, detail: blocker.detail, needsUserDecision: blocker.needsUserDecision })),
|
|
200
|
+
warnings,
|
|
201
|
+
timeline: goal === undefined ? [] : timeline(goal, input.now),
|
|
202
|
+
planSaved,
|
|
203
|
+
complete,
|
|
204
|
+
elapsedMinutes: Math.max(0, Math.floor((input.now - marks[0].at) / 60_000))
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
const noticeRank = { error: 3, warning: 2, success: 1, info: 0 };
|
|
208
|
+
/**
|
|
209
|
+
* OpenCode shows one toast at a time, so several gate events from one refresh collapse into the most
|
|
210
|
+
* urgent one (completion first), with the rest counted in its message. The panel still lists them all.
|
|
211
|
+
*/
|
|
212
|
+
export function leadingNotice(notices) {
|
|
213
|
+
if (notices.length <= 1)
|
|
214
|
+
return notices[0];
|
|
215
|
+
const lead = [...notices].sort((left, right) => Number(right.celebrate === true) - Number(left.celebrate === true) || noticeRank[right.variant] - noticeRank[left.variant])[0];
|
|
216
|
+
return { ...lead, message: `${lead.message} · +${notices.length - 1} more` };
|
|
217
|
+
}
|
|
218
|
+
/** The gate events between two views. A first view never toasts its whole history. */
|
|
219
|
+
export function missionNotices(prev, next) {
|
|
220
|
+
if (prev === undefined || !next.active)
|
|
221
|
+
return [];
|
|
222
|
+
const notices = [];
|
|
223
|
+
if (prev.active && !prev.planSaved && next.planSaved) {
|
|
224
|
+
notices.push({ variant: "success", title: "🔥 Plan saved", message: "Work can start from the saved plan." });
|
|
225
|
+
}
|
|
226
|
+
if (!prev.warnings.some((warning) => warning.kind === "plan") && next.warnings.some((warning) => warning.kind === "plan")) {
|
|
227
|
+
notices.push({ variant: "warning", title: "🔥 Plan not saved yet", message: "Save the plan under .litopencode/plans before work starts." });
|
|
228
|
+
}
|
|
229
|
+
if (prev.goal === undefined && next.goal !== undefined) {
|
|
230
|
+
notices.push({ variant: "info", title: "🔥 Goal set", message: `${next.goal.title} · ${next.goal.total} criteria` });
|
|
231
|
+
}
|
|
232
|
+
if (prev.goal !== undefined && next.goal !== undefined) {
|
|
233
|
+
for (const criterion of next.goal.criteria) {
|
|
234
|
+
const before = prev.goal.criteria.find((candidate) => candidate.id === criterion.id);
|
|
235
|
+
if (criterion.status === "pass" && before?.status !== "pass") {
|
|
236
|
+
notices.push({ variant: "success", title: `✓ ${criterion.id} passed`, message: `${next.goal.passed}/${next.goal.total} criteria` });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
for (const blocker of next.blockers) {
|
|
241
|
+
if (!prev.blockers.some((candidate) => candidate.id === blocker.id)) {
|
|
242
|
+
notices.push({
|
|
243
|
+
variant: "warning",
|
|
244
|
+
title: blocker.needsUserDecision ? "⚑ Needs your decision" : "⚑ Review blocker",
|
|
245
|
+
message: `${blocker.id} ${blocker.detail}`.trim()
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
for (const blocker of prev.blockers) {
|
|
250
|
+
if (!next.blockers.some((candidate) => candidate.id === blocker.id)) {
|
|
251
|
+
notices.push({ variant: "info", title: `✓ ${blocker.id} resolved`, message: blocker.detail });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (!prev.complete && next.complete) {
|
|
255
|
+
const message = next.goal !== undefined
|
|
256
|
+
? `${next.goal.title} · ${next.goal.passed}/${next.goal.total} criteria`
|
|
257
|
+
: `${next.progress?.done ?? 0}/${next.progress?.total ?? 0} tasks`;
|
|
258
|
+
notices.push({ variant: "success", title: "🔥 Complete", message, celebrate: true });
|
|
259
|
+
}
|
|
260
|
+
return notices;
|
|
261
|
+
}
|
|
262
|
+
const warningStatus = {
|
|
263
|
+
blocker: "review blocked",
|
|
264
|
+
decision: "needs decision",
|
|
265
|
+
retry: "retrying",
|
|
266
|
+
permission: "permission asked",
|
|
267
|
+
question: "question asked",
|
|
268
|
+
context: "context high",
|
|
269
|
+
plan: "plan not saved"
|
|
270
|
+
};
|
|
271
|
+
/** The short status beside the header: always a glyph plus words, never colour alone. */
|
|
272
|
+
export function missionStatus(view) {
|
|
273
|
+
if (view.complete)
|
|
274
|
+
return { glyph: "✓", text: "COMPLETE", tone: "success" };
|
|
275
|
+
const warning = view.warnings[0];
|
|
276
|
+
if (warning !== undefined)
|
|
277
|
+
return { glyph: "⚑", text: warning.kind === "context" ? warning.text : warningStatus[warning.kind], tone: "warning" };
|
|
278
|
+
return { glyph: "●", text: view.goal === undefined ? "working" : "active", tone: "info" };
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Stable keys for the panel rows a view shows. Motion compares two views' keys to decide which rows
|
|
282
|
+
* slide in, so a new goal animates its own rows without replaying the header.
|
|
283
|
+
*/
|
|
284
|
+
export function missionRowKeys(view) {
|
|
285
|
+
if (!view.active)
|
|
286
|
+
return [];
|
|
287
|
+
const keys = ["h1", "h2", "b1"];
|
|
288
|
+
if (view.goal !== undefined)
|
|
289
|
+
keys.push("title");
|
|
290
|
+
keys.push("steps");
|
|
291
|
+
if (view.progress !== undefined)
|
|
292
|
+
keys.push("bar");
|
|
293
|
+
const detail = (view.next !== undefined && !view.complete) || view.goal !== undefined || view.warnings.length > 0;
|
|
294
|
+
if (detail)
|
|
295
|
+
keys.push("b2");
|
|
296
|
+
if (view.next !== undefined && !view.complete)
|
|
297
|
+
keys.push("next");
|
|
298
|
+
if (view.goal !== undefined)
|
|
299
|
+
keys.push("crit");
|
|
300
|
+
view.warnings.forEach((warning) => keys.push(`warn:${warning.kind}:${warning.text}`));
|
|
301
|
+
if (view.timeline.length > 0) {
|
|
302
|
+
keys.push("b3", "evh");
|
|
303
|
+
view.timeline.forEach((_event, index) => keys.push(`ev${index}`));
|
|
304
|
+
}
|
|
305
|
+
return keys;
|
|
306
|
+
}
|
|
307
|
+
/** The project's last goal for the home line, or undefined when the ledger holds none. */
|
|
308
|
+
export function lastMission(raw) {
|
|
309
|
+
const goal = readActiveGoal(raw);
|
|
310
|
+
if (goal === undefined || goal.title === "")
|
|
311
|
+
return undefined;
|
|
312
|
+
return {
|
|
313
|
+
title: goal.title,
|
|
314
|
+
passed: goal.criteria.filter((criterion) => criterion.status === "pass").length,
|
|
315
|
+
total: goal.criteria.length,
|
|
316
|
+
complete: goal.status === "complete"
|
|
317
|
+
};
|
|
318
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { type MissionView } from "./mission-model.ts";
|
|
2
|
+
export declare const motionTiming: Readonly<{
|
|
3
|
+
stagger: 60;
|
|
4
|
+
entry: 160;
|
|
5
|
+
entryLead: 40;
|
|
6
|
+
fill: 300;
|
|
7
|
+
fillDelayOnGoal: 240;
|
|
8
|
+
noticeIn: 220;
|
|
9
|
+
crossfade: 140;
|
|
10
|
+
workDoneAt: 200;
|
|
11
|
+
reviewDoneAt: 260;
|
|
12
|
+
popAt: 300;
|
|
13
|
+
pop: 160;
|
|
14
|
+
shimmerAt: 300;
|
|
15
|
+
shimmer: 400;
|
|
16
|
+
settleAt: 1000;
|
|
17
|
+
complete: 1200;
|
|
18
|
+
frameMs: 33;
|
|
19
|
+
}>;
|
|
20
|
+
export declare function clamp01(value: number): number;
|
|
21
|
+
export declare const ease: Readonly<{
|
|
22
|
+
outCubic: (x: number) => number;
|
|
23
|
+
inCubic: (x: number) => number;
|
|
24
|
+
inOutCubic: (x: number) => number;
|
|
25
|
+
outBack: (x: number) => number;
|
|
26
|
+
}>;
|
|
27
|
+
/** 0..1 progress of a track that starts `start` ms after the change and lasts `duration` ms. */
|
|
28
|
+
export declare function seg(t: number, start: number, duration: number): number;
|
|
29
|
+
/** Cells a row is pushed right while it slides in; the ease-out-back overshoot lands it a cell left first. */
|
|
30
|
+
export declare function slideIn(progress: number, amplitude?: number): number;
|
|
31
|
+
export declare function mixHex(from: string, to: string, t: number): string;
|
|
32
|
+
export type MotionState = {
|
|
33
|
+
/** When the last state change happened; undefined while nothing has animated yet. */
|
|
34
|
+
readonly changeAt?: number;
|
|
35
|
+
/** Panel row keys that are new in this change and slide in, in order. */
|
|
36
|
+
readonly entering: readonly string[];
|
|
37
|
+
readonly chipEnter: boolean;
|
|
38
|
+
readonly barFrom: number;
|
|
39
|
+
readonly barTo: number;
|
|
40
|
+
readonly barDelay: number;
|
|
41
|
+
readonly warnFrom: number;
|
|
42
|
+
readonly warnTo: number;
|
|
43
|
+
readonly celebrate: boolean;
|
|
44
|
+
};
|
|
45
|
+
export type MotionFrame = {
|
|
46
|
+
/** Milliseconds since the last change; Infinity once settled or when motion is off. */
|
|
47
|
+
readonly t: number;
|
|
48
|
+
readonly entering: readonly string[];
|
|
49
|
+
readonly chipEnter: boolean;
|
|
50
|
+
readonly barRatio: number;
|
|
51
|
+
/** 0 = normal colours, 1 = warning colours. */
|
|
52
|
+
readonly warn: number;
|
|
53
|
+
/** Milliseconds into the completion moment while it plays, otherwise undefined. */
|
|
54
|
+
readonly celebrate?: number;
|
|
55
|
+
readonly moving: boolean;
|
|
56
|
+
};
|
|
57
|
+
export declare const idleMotion: MotionState;
|
|
58
|
+
/** Row entry with the approved stagger: rows of a fresh mission wait a short lead; later rows follow 60 ms apart. */
|
|
59
|
+
export declare function rowEntryStart(state: Pick<MotionState, "entering" | "chipEnter">, key: string): number | undefined;
|
|
60
|
+
/**
|
|
61
|
+
* Starts the tracks a state change calls for. A bar or warning already moving continues from where
|
|
62
|
+
* it is, so a burst of updates never makes either jump backwards.
|
|
63
|
+
*/
|
|
64
|
+
export declare function planMotion(prev: MissionView | undefined, next: MissionView, now: number, state: MotionState, enabled?: boolean): MotionState;
|
|
65
|
+
export declare function sampleMotion(state: MotionState, now: number): MotionFrame;
|
|
66
|
+
export declare function restingFrameFor(view: MissionView): MotionFrame;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Motion for the mission chip and panel. Everything here is a pure function of a clock value, so the
|
|
2
|
+
// TUI only runs a frame timer while one of these tracks is still moving and stays static otherwise.
|
|
3
|
+
import { missionRowKeys } from "./mission-model.js";
|
|
4
|
+
export const motionTiming = Object.freeze({
|
|
5
|
+
stagger: 60,
|
|
6
|
+
entry: 160,
|
|
7
|
+
entryLead: 40,
|
|
8
|
+
fill: 300,
|
|
9
|
+
fillDelayOnGoal: 240,
|
|
10
|
+
noticeIn: 220,
|
|
11
|
+
crossfade: 140,
|
|
12
|
+
workDoneAt: 200,
|
|
13
|
+
reviewDoneAt: 260,
|
|
14
|
+
popAt: 300,
|
|
15
|
+
pop: 160,
|
|
16
|
+
shimmerAt: 300,
|
|
17
|
+
shimmer: 400,
|
|
18
|
+
settleAt: 1000,
|
|
19
|
+
complete: 1200,
|
|
20
|
+
frameMs: 33
|
|
21
|
+
});
|
|
22
|
+
export function clamp01(value) {
|
|
23
|
+
return value <= 0 ? 0 : value >= 1 ? 1 : value;
|
|
24
|
+
}
|
|
25
|
+
export const ease = Object.freeze({
|
|
26
|
+
outCubic: (x) => 1 - Math.pow(1 - x, 3),
|
|
27
|
+
inCubic: (x) => x * x * x,
|
|
28
|
+
inOutCubic: (x) => (x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2),
|
|
29
|
+
// Overshoots a little past 1 and settles back: the snap of a pill or row popping in.
|
|
30
|
+
outBack: (x) => {
|
|
31
|
+
const c1 = 1.70158;
|
|
32
|
+
const c3 = c1 + 1;
|
|
33
|
+
return 1 + c3 * Math.pow(x - 1, 3) + c1 * Math.pow(x - 1, 2);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
/** 0..1 progress of a track that starts `start` ms after the change and lasts `duration` ms. */
|
|
37
|
+
export function seg(t, start, duration) {
|
|
38
|
+
if (duration <= 0)
|
|
39
|
+
return t >= start ? 1 : 0;
|
|
40
|
+
return clamp01((t - start) / duration);
|
|
41
|
+
}
|
|
42
|
+
/** Cells a row is pushed right while it slides in; the ease-out-back overshoot lands it a cell left first. */
|
|
43
|
+
export function slideIn(progress, amplitude = 6) {
|
|
44
|
+
return Math.round(amplitude * (1 - ease.outBack(progress)));
|
|
45
|
+
}
|
|
46
|
+
function hexChannels(hex) {
|
|
47
|
+
const clean = hex.replace(/^#/u, "");
|
|
48
|
+
const full = clean.length === 3 ? clean.split("").map((c) => c + c).join("") : clean.slice(0, 6);
|
|
49
|
+
const value = Number.parseInt(full, 16);
|
|
50
|
+
if (!Number.isFinite(value) || full.length !== 6)
|
|
51
|
+
return [0, 0, 0];
|
|
52
|
+
return [(value >> 16) & 255, (value >> 8) & 255, value & 255];
|
|
53
|
+
}
|
|
54
|
+
export function mixHex(from, to, t) {
|
|
55
|
+
const a = hexChannels(from);
|
|
56
|
+
const b = hexChannels(to);
|
|
57
|
+
const k = clamp01(t);
|
|
58
|
+
return "#" + a.map((channel, index) => Math.round(channel + (b[index] - channel) * k).toString(16).padStart(2, "0")).join("");
|
|
59
|
+
}
|
|
60
|
+
export const idleMotion = Object.freeze({
|
|
61
|
+
entering: Object.freeze([]),
|
|
62
|
+
chipEnter: false,
|
|
63
|
+
barFrom: 0,
|
|
64
|
+
barTo: 0,
|
|
65
|
+
barDelay: 0,
|
|
66
|
+
warnFrom: 0,
|
|
67
|
+
warnTo: 0,
|
|
68
|
+
celebrate: false
|
|
69
|
+
});
|
|
70
|
+
function ratio(view) {
|
|
71
|
+
const progress = view?.progress;
|
|
72
|
+
if (progress === undefined || progress.total === 0)
|
|
73
|
+
return 0;
|
|
74
|
+
return clamp01(progress.done / progress.total);
|
|
75
|
+
}
|
|
76
|
+
function warnTarget(view) {
|
|
77
|
+
return view !== undefined && view.warnings.length > 0 ? 1 : 0;
|
|
78
|
+
}
|
|
79
|
+
/** Row entry with the approved stagger: rows of a fresh mission wait a short lead; later rows follow 60 ms apart. */
|
|
80
|
+
export function rowEntryStart(state, key) {
|
|
81
|
+
const index = state.entering.indexOf(key);
|
|
82
|
+
if (index < 0)
|
|
83
|
+
return undefined;
|
|
84
|
+
return (state.chipEnter ? motionTiming.entryLead : 0) + index * motionTiming.stagger;
|
|
85
|
+
}
|
|
86
|
+
function rowDuration(key) {
|
|
87
|
+
return key.startsWith("warn:") ? motionTiming.noticeIn : motionTiming.entry;
|
|
88
|
+
}
|
|
89
|
+
function settleTime(state) {
|
|
90
|
+
let end = motionTiming.crossfade;
|
|
91
|
+
for (const key of state.entering)
|
|
92
|
+
end = Math.max(end, (rowEntryStart(state, key) ?? 0) + rowDuration(key));
|
|
93
|
+
if (state.chipEnter)
|
|
94
|
+
end = Math.max(end, 3 * motionTiming.stagger + motionTiming.entry);
|
|
95
|
+
if (state.barFrom !== state.barTo)
|
|
96
|
+
end = Math.max(end, state.barDelay + motionTiming.fill);
|
|
97
|
+
if (state.celebrate)
|
|
98
|
+
end = Math.max(end, motionTiming.complete);
|
|
99
|
+
return end;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Starts the tracks a state change calls for. A bar or warning already moving continues from where
|
|
103
|
+
* it is, so a burst of updates never makes either jump backwards.
|
|
104
|
+
*/
|
|
105
|
+
export function planMotion(prev, next, now, state, enabled = true) {
|
|
106
|
+
const nextRatio = ratio(next);
|
|
107
|
+
const nextWarn = warnTarget(next);
|
|
108
|
+
if (!enabled || !next.active)
|
|
109
|
+
return { ...idleMotion, barFrom: nextRatio, barTo: nextRatio, warnFrom: nextWarn, warnTo: nextWarn };
|
|
110
|
+
const current = sampleMotion(state, now);
|
|
111
|
+
const appearing = prev === undefined || !prev.active;
|
|
112
|
+
const before = new Set(appearing ? [] : missionRowKeys(prev));
|
|
113
|
+
const entering = missionRowKeys(next).filter((key) => !before.has(key));
|
|
114
|
+
const goalArrived = !appearing && prev?.goal === undefined && next.goal !== undefined;
|
|
115
|
+
return {
|
|
116
|
+
changeAt: now,
|
|
117
|
+
entering,
|
|
118
|
+
chipEnter: appearing,
|
|
119
|
+
barFrom: appearing ? 0 : current.barRatio,
|
|
120
|
+
barTo: nextRatio,
|
|
121
|
+
barDelay: goalArrived || appearing ? motionTiming.fillDelayOnGoal : 0,
|
|
122
|
+
warnFrom: appearing ? nextWarn : current.warn,
|
|
123
|
+
warnTo: nextWarn,
|
|
124
|
+
celebrate: !appearing && prev?.complete !== true && next.complete
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
export function sampleMotion(state, now) {
|
|
128
|
+
if (state.changeAt === undefined) {
|
|
129
|
+
return { t: Number.POSITIVE_INFINITY, entering: [], chipEnter: false, barRatio: state.barTo, warn: state.warnTo, moving: false };
|
|
130
|
+
}
|
|
131
|
+
const t = now - state.changeAt;
|
|
132
|
+
const moving = t < settleTime(state);
|
|
133
|
+
const fill = ease.inOutCubic(seg(t, state.barDelay, motionTiming.fill));
|
|
134
|
+
const fade = ease.inOutCubic(seg(t, 0, motionTiming.crossfade));
|
|
135
|
+
return {
|
|
136
|
+
t: moving ? t : Number.POSITIVE_INFINITY,
|
|
137
|
+
entering: moving ? state.entering : [],
|
|
138
|
+
chipEnter: moving && state.chipEnter,
|
|
139
|
+
barRatio: clamp01(state.barFrom + (state.barTo - state.barFrom) * fill),
|
|
140
|
+
warn: clamp01(state.warnFrom + (state.warnTo - state.warnFrom) * fade),
|
|
141
|
+
...(moving && state.celebrate ? { celebrate: t } : {}),
|
|
142
|
+
moving
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
export function restingFrameFor(view) {
|
|
146
|
+
return { t: Number.POSITIVE_INFINITY, entering: [], chipEnter: false, barRatio: ratio(view), warn: warnTarget(view), moving: false };
|
|
147
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type MissionInput, type TodoInput } from "./mission-model.ts";
|
|
2
|
+
type MessageLike = {
|
|
3
|
+
readonly id: string;
|
|
4
|
+
readonly role: string;
|
|
5
|
+
readonly agent?: unknown;
|
|
6
|
+
readonly time?: {
|
|
7
|
+
readonly created?: unknown;
|
|
8
|
+
};
|
|
9
|
+
readonly providerID?: unknown;
|
|
10
|
+
readonly modelID?: unknown;
|
|
11
|
+
readonly tokens?: {
|
|
12
|
+
readonly input?: unknown;
|
|
13
|
+
readonly output?: unknown;
|
|
14
|
+
readonly reasoning?: unknown;
|
|
15
|
+
readonly cache?: {
|
|
16
|
+
readonly read?: unknown;
|
|
17
|
+
readonly write?: unknown;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
export type MissionStateSource = {
|
|
22
|
+
readonly path: {
|
|
23
|
+
readonly worktree?: string;
|
|
24
|
+
readonly directory?: string;
|
|
25
|
+
};
|
|
26
|
+
readonly provider: ReadonlyArray<{
|
|
27
|
+
readonly id: string;
|
|
28
|
+
readonly models: Readonly<Record<string, {
|
|
29
|
+
readonly limit?: {
|
|
30
|
+
readonly context?: number;
|
|
31
|
+
};
|
|
32
|
+
}>>;
|
|
33
|
+
}>;
|
|
34
|
+
readonly session: {
|
|
35
|
+
messages(sessionID: string): ReadonlyArray<MessageLike>;
|
|
36
|
+
todo(sessionID: string): ReadonlyArray<TodoInput>;
|
|
37
|
+
status(sessionID: string): {
|
|
38
|
+
readonly type: string;
|
|
39
|
+
readonly message?: string;
|
|
40
|
+
} | undefined;
|
|
41
|
+
permission(sessionID: string): ReadonlyArray<unknown>;
|
|
42
|
+
question(sessionID: string): ReadonlyArray<unknown>;
|
|
43
|
+
};
|
|
44
|
+
part(messageID: string): ReadonlyArray<{
|
|
45
|
+
readonly metadata?: unknown;
|
|
46
|
+
}>;
|
|
47
|
+
};
|
|
48
|
+
/** The same root the server plugin uses: the worktree unless it is the filesystem root, else the directory. */
|
|
49
|
+
export declare function missionProjectRoot(paths: MissionStateSource["path"]): string | undefined;
|
|
50
|
+
/**
|
|
51
|
+
* Reads goals.json under `root`, refusing symbolic links and oversized files. The result is cached
|
|
52
|
+
* on mtime and size, so calling it on every refresh costs one lstat.
|
|
53
|
+
*/
|
|
54
|
+
export declare function createLedgerReader(): (root: string | undefined) => unknown;
|
|
55
|
+
export declare function readMissionInput(state: MissionStateSource, sessionID: string, ledger: unknown, now: number): MissionInput;
|
|
56
|
+
export {};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Gathers the mission input from the OpenCode TUI state and the project's evidence ledger. The state
|
|
2
|
+
// shape is the slice of `api.state` the plugin reads, so tests can pass a plain object.
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { readLitMark } from "./mission-model.js";
|
|
6
|
+
const ledgerByteLimit = 1024 * 1024;
|
|
7
|
+
/** The same root the server plugin uses: the worktree unless it is the filesystem root, else the directory. */
|
|
8
|
+
export function missionProjectRoot(paths) {
|
|
9
|
+
const worktree = paths.worktree?.trim();
|
|
10
|
+
if (worktree !== undefined && worktree !== "" && worktree !== "/")
|
|
11
|
+
return worktree;
|
|
12
|
+
const directory = paths.directory?.trim();
|
|
13
|
+
return directory === undefined || directory === "" ? undefined : directory;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Reads goals.json under `root`, refusing symbolic links and oversized files. The result is cached
|
|
17
|
+
* on mtime and size, so calling it on every refresh costs one lstat.
|
|
18
|
+
*/
|
|
19
|
+
export function createLedgerReader() {
|
|
20
|
+
let cache;
|
|
21
|
+
return (root) => {
|
|
22
|
+
if (root === undefined)
|
|
23
|
+
return undefined;
|
|
24
|
+
const file = path.join(root, ".litopencode", "litgoal", "lit-loop", "goals.json");
|
|
25
|
+
let stat;
|
|
26
|
+
try {
|
|
27
|
+
stat = fs.lstatSync(file);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
if (!stat.isFile() || stat.size > ledgerByteLimit)
|
|
33
|
+
return undefined;
|
|
34
|
+
const stamp = `${stat.mtimeMs}:${stat.size}`;
|
|
35
|
+
if (cache?.file === file && cache.stamp === stamp)
|
|
36
|
+
return cache.value;
|
|
37
|
+
let value;
|
|
38
|
+
try {
|
|
39
|
+
value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
value = undefined;
|
|
43
|
+
}
|
|
44
|
+
cache = { file, stamp, value };
|
|
45
|
+
return value;
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function num(value) {
|
|
49
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
50
|
+
}
|
|
51
|
+
function contextPercent(state, messages) {
|
|
52
|
+
const last = [...messages].reverse().find((message) => message.role === "assistant" && num(message.tokens?.output) > 0);
|
|
53
|
+
if (last === undefined)
|
|
54
|
+
return null;
|
|
55
|
+
const tokens = num(last.tokens?.input) + num(last.tokens?.output) + num(last.tokens?.reasoning) + num(last.tokens?.cache?.read) + num(last.tokens?.cache?.write);
|
|
56
|
+
const limit = state.provider.find((provider) => provider.id === last.providerID)?.models[String(last.modelID)]?.limit?.context;
|
|
57
|
+
return typeof limit === "number" && limit > 0 ? (tokens / limit) * 100 : null;
|
|
58
|
+
}
|
|
59
|
+
export function readMissionInput(state, sessionID, ledger, now) {
|
|
60
|
+
const messages = state.session.messages(sessionID);
|
|
61
|
+
const marks = [];
|
|
62
|
+
let agent;
|
|
63
|
+
for (const message of messages) {
|
|
64
|
+
if (message.role !== "user")
|
|
65
|
+
continue;
|
|
66
|
+
if (typeof message.agent === "string")
|
|
67
|
+
agent = message.agent;
|
|
68
|
+
const at = num(message.time?.created);
|
|
69
|
+
for (const part of state.part(message.id)) {
|
|
70
|
+
const mark = readLitMark(part.metadata, at);
|
|
71
|
+
if (mark !== undefined)
|
|
72
|
+
marks.push(mark);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const status = state.session.status(sessionID);
|
|
76
|
+
return {
|
|
77
|
+
marks,
|
|
78
|
+
...(agent === undefined ? {} : { agent }),
|
|
79
|
+
todos: state.session.todo(sessionID),
|
|
80
|
+
ledger,
|
|
81
|
+
...(status === undefined ? {} : { sessionStatus: status.type }),
|
|
82
|
+
...(status?.type === "retry" && typeof status.message === "string" ? { retryMessage: status.message } : {}),
|
|
83
|
+
pendingPermissions: state.session.permission(sessionID).length,
|
|
84
|
+
pendingQuestions: state.session.question(sessionID).length,
|
|
85
|
+
contextPercent: contextPercent(state, messages),
|
|
86
|
+
now
|
|
87
|
+
};
|
|
88
|
+
}
|