@litfamily/litopencode 1.0.19 → 1.0.21

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 (37) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README-Ko-KR.md +6 -6
  3. package/README.md +6 -6
  4. package/dist/agents/registry.js +2 -2
  5. package/dist/cli/doctor.d.ts +5 -0
  6. package/dist/cli/doctor.js +27 -6
  7. package/dist/cli/install-config.d.ts +6 -0
  8. package/dist/cli/install-config.js +32 -2
  9. package/dist/cli/install.js +4 -1
  10. package/dist/cli/model-catalog.d.ts +6 -1
  11. package/dist/cli/model-catalog.js +32 -15
  12. package/dist/cli/model-routing.js +14 -9
  13. package/dist/cli/types.d.ts +1 -1
  14. package/dist/config.d.ts +2 -0
  15. package/dist/config.js +10 -2
  16. package/dist/model-route-policy.d.ts +9 -1
  17. package/dist/model-route-policy.js +46 -47
  18. package/dist/tui/mission-band.d.ts +28 -8
  19. package/dist/tui/mission-band.js +223 -39
  20. package/dist/tui/mission-skills.d.ts +38 -0
  21. package/dist/tui/mission-skills.js +81 -0
  22. package/dist/tui/mission-source.d.ts +17 -0
  23. package/dist/tui/mission-source.js +30 -0
  24. package/dist/tui/mission-text.d.ts +4 -1
  25. package/dist/tui/mission-text.js +2 -2
  26. package/dist/tui.js +47 -21
  27. package/docs/assets/readme/badge-version.svg +1 -1
  28. package/docs/migration.md +1 -1
  29. package/docs/reference-Ko-KR.md +36 -13
  30. package/docs/reference.md +37 -14
  31. package/package.json +1 -1
  32. package/skills/lit-pptx/package-lock.json +167 -119
  33. package/skills/lit-pptx/package.json +1 -1
  34. package/skills/managed-skill-manifest.json +4 -4
  35. package/skills/readme-studio/templates/hyperframes/package-lock.json +169 -121
  36. package/skills/readme-studio/templates/remotion/package-lock.json +3 -3
  37. package/tools/check-pack-payload.mjs +2 -2
@@ -1,8 +1,14 @@
1
1
  import { effectiveAgentConfig } from "./config.js";
2
- import { defaultOpenAiRoutes, newGenerationOpenAiModels, openAiModelEfforts, previousGenerationOpenAiModels, previousGpt6OpenAiModels, stripFastSuffix } from "./cli/model-catalog.js";
3
- const safeLunaEfforts = new Set(openAiModelEfforts(previousGenerationOpenAiModels.luna));
4
- const safeTerraEfforts = new Set(openAiModelEfforts(previousGenerationOpenAiModels.terra));
2
+ import { defaultOpenAiRoutes, newGenerationOpenAiModels, openAiModelEfforts, previousGenerationOpenAiModels, previousGpt6OpenAiModels, replacedRetiredEffort, stripFastSuffix } from "./cli/model-catalog.js";
5
3
  const safeAstraEfforts = new Set(openAiModelEfforts(defaultOpenAiRoutes.lead.model));
4
+ // The managed rows whose effort is checked; other GPT-5.6 ids stay user-owned as before.
5
+ const guardedModels = new Set([
6
+ newGenerationOpenAiModels.sol,
7
+ previousGpt6OpenAiModels.sol,
8
+ newGenerationOpenAiModels.luna,
9
+ previousGenerationOpenAiModels.luna,
10
+ previousGenerationOpenAiModels.terra
11
+ ]);
6
12
  function dispatchedModel(route) {
7
13
  if (route.model === undefined)
8
14
  return undefined;
@@ -53,41 +59,17 @@ export function diagnoseModelRoutes(routes) {
53
59
  }
54
60
  continue;
55
61
  }
56
- if (modelId === newGenerationOpenAiModels.sol || modelId === previousGpt6OpenAiModels.sol || modelId === newGenerationOpenAiModels.luna) {
57
- const supported = new Set(openAiModelEfforts(modelId));
58
- const variantUnsafe = route.variant !== undefined && !supported.has(route.variant);
59
- const reasoningUnsafe = route.reasoningEffort !== undefined && !supported.has(route.reasoningEffort);
60
- if (variantUnsafe || reasoningUnsafe || (route.variant === undefined && route.reasoningEffort === undefined)) {
61
- diagnostics.push({ agentId, code: "model_effort_unsupported", model, effort });
62
- }
63
- if (route.variant !== undefined && route.reasoningEffort !== undefined && route.variant !== route.reasoningEffort) {
64
- diagnostics.push({ agentId, code: "model_effort_conflict", model, effort });
65
- }
66
- continue;
67
- }
68
- if (modelId === previousGenerationOpenAiModels.luna) {
69
- const hasXhigh = route.variant === "xhigh" || route.reasoningEffort === "xhigh";
70
- if ((route.variant !== undefined && route.variant !== "xhigh" && !safeLunaEfforts.has(route.variant)) ||
71
- (route.reasoningEffort !== undefined && route.reasoningEffort !== "xhigh" && !safeLunaEfforts.has(route.reasoningEffort)) ||
72
- (route.variant === undefined && route.reasoningEffort === undefined)) {
73
- diagnostics.push({ agentId, code: "luna_effort_below_high", model, effort });
74
- }
75
- if (hasXhigh)
76
- diagnostics.push({ agentId, code: "luna_xhigh_forbidden", model, effort });
77
- if (route.variant !== undefined && route.reasoningEffort !== undefined && route.variant !== route.reasoningEffort) {
78
- diagnostics.push({ agentId, code: "luna_effort_conflict", model, effort });
79
- }
62
+ // Each guarded row accepts exactly the efforts OpenCode offers for it.
63
+ if (!guardedModels.has(modelId))
80
64
  continue;
81
- }
82
- if (modelId !== previousGenerationOpenAiModels.terra)
83
- continue;
84
- if ((route.variant !== undefined && !safeTerraEfforts.has(route.variant)) ||
85
- (route.reasoningEffort !== undefined && !safeTerraEfforts.has(route.reasoningEffort)) ||
86
- (route.variant === undefined && route.reasoningEffort === undefined)) {
87
- diagnostics.push({ agentId, code: "terra_effort_below_high", model, effort });
65
+ const supported = new Set(openAiModelEfforts(modelId));
66
+ const variantUnsafe = route.variant !== undefined && !supported.has(route.variant);
67
+ const reasoningUnsafe = route.reasoningEffort !== undefined && !supported.has(route.reasoningEffort);
68
+ if (variantUnsafe || reasoningUnsafe || (route.variant === undefined && route.reasoningEffort === undefined)) {
69
+ diagnostics.push({ agentId, code: "model_effort_unsupported", model, effort });
88
70
  }
89
71
  if (route.variant !== undefined && route.reasoningEffort !== undefined && route.variant !== route.reasoningEffort) {
90
- diagnostics.push({ agentId, code: "terra_effort_conflict", model, effort });
72
+ diagnostics.push({ agentId, code: "model_effort_conflict", model, effort });
91
73
  }
92
74
  }
93
75
  return diagnostics;
@@ -114,22 +96,12 @@ export class UnsafeModelRouteError extends Error {
114
96
  const summary = diagnostics
115
97
  .map((diagnostic) => {
116
98
  switch (diagnostic.code) {
117
- case "luna_effort_below_high":
118
- return `${diagnostic.agentId}: Luna requires high or max (${diagnostic.model}, ${diagnostic.effort})`;
119
- case "luna_xhigh_forbidden":
120
- return `${diagnostic.agentId}: Luna plus xhigh is forbidden (${diagnostic.model}, ${diagnostic.effort})`;
121
- case "luna_effort_conflict":
122
- return `${diagnostic.agentId}: Luna effort fields conflict (${diagnostic.model}, ${diagnostic.effort})`;
123
- case "terra_effort_below_high":
124
- return `${diagnostic.agentId}: TERRA requires high or xhigh (${diagnostic.model}, ${diagnostic.effort})`;
125
- case "terra_effort_conflict":
126
- return `${diagnostic.agentId}: TERRA effort fields conflict (${diagnostic.model}, ${diagnostic.effort})`;
127
99
  case "astra_effort_unsupported":
128
- return `${diagnostic.agentId}: Astra requires low, medium, high, xhigh, or max (${diagnostic.model}, ${diagnostic.effort})`;
100
+ return `${diagnostic.agentId}: Astra requires ${[...safeAstraEfforts].join(", ")} (${diagnostic.model}, ${diagnostic.effort})`;
129
101
  case "astra_effort_conflict":
130
102
  return `${diagnostic.agentId}: Astra effort fields conflict (${diagnostic.model}, ${diagnostic.effort})`;
131
103
  case "model_effort_unsupported":
132
- return `${diagnostic.agentId}: ${diagnostic.model} accepts a supported catalog effort (${diagnostic.model}, ${diagnostic.effort})`;
104
+ return `${diagnostic.agentId}: ${diagnostic.model} accepts ${openAiModelEfforts(unqualifiedModel(diagnostic.model)).join(", ")} (${diagnostic.model}, ${diagnostic.effort})`;
133
105
  case "model_effort_conflict":
134
106
  return `${diagnostic.agentId}: ${diagnostic.model} effort fields conflict (${diagnostic.model}, ${diagnostic.effort})`;
135
107
  }
@@ -144,3 +116,30 @@ export function assertSafeEffectiveAgentRoutes(config, agentIds) {
144
116
  if (diagnostics.length > 0)
145
117
  throw new UnsafeModelRouteError(diagnostics);
146
118
  }
119
+ function isRouteRecord(value) {
120
+ return typeof value === "object" && value !== null && !Array.isArray(value);
121
+ }
122
+ /** The saved routes in a litopencode.json object that still name an effort OpenCode no longer offers. */
123
+ export function retiredEffortRoutes(config) {
124
+ const found = [];
125
+ if (!isRouteRecord(config))
126
+ return found;
127
+ for (const section of ["categories", "agents"]) {
128
+ const entries = config[section];
129
+ if (!isRouteRecord(entries))
130
+ continue;
131
+ for (const [id, route] of Object.entries(entries)) {
132
+ if (!isRouteRecord(route) || typeof route.model !== "string")
133
+ continue;
134
+ const provider = typeof route.provider === "string" ? route.provider : undefined;
135
+ const effort = (value) => (typeof value === "string" ? value : undefined);
136
+ if (replacedRetiredEffort(provider, route.model, effort(route.variant)) === undefined && replacedRetiredEffort(provider, route.model, effort(route.reasoningEffort)) === undefined)
137
+ continue;
138
+ found.push({ section, id, model: route.model.slice(route.model.indexOf("/") + 1) });
139
+ }
140
+ }
141
+ return found;
142
+ }
143
+ export function retiredEffortWarning(route) {
144
+ return `ultra is no longer offered by OpenCode for ${route.model} (${route.section}.${route.id}); the next install moves it to max.`;
145
+ }
@@ -1,29 +1,49 @@
1
1
  import { type MotionFrame } from "./mission-motion.ts";
2
- import type { MissionView } from "./mission-model.ts";
3
- import { type Line, type MissionPalette } from "./mission-text.ts";
2
+ import { type MissionView } from "./mission-model.ts";
3
+ import type { ChipShimmer, SkillChip } from "./mission-skills.ts";
4
+ import { type LastMission, type Line, type MissionPalette } from "./mission-text.ts";
4
5
  export type BandOptions = {
5
6
  readonly enabled: boolean;
6
7
  readonly card: boolean;
7
8
  };
8
9
  /**
9
10
  * Reads the band switches from the plugin's tui.json options: `band: false` turns the band off,
10
- * `band: { card: true }` adds the card under the ribbon. Anything else keeps the defaults.
11
+ * `band: { card: true }` adds the detail card under the status card. Anything else keeps the defaults.
11
12
  */
12
13
  export declare function readBandOptions(options: unknown): BandOptions;
13
14
  /**
14
- * One ribbon row exactly `width` cells wide: pills, bar, the next task or a warning, and the elapsed
15
- * time, shedding parts in a fixed order so it never wraps. Idle sessions draw nothing.
15
+ * The skill chips row, exactly `width` cells: bright accent pills for the current request first, then
16
+ * dim recent names, and `+N` for the ones that do not fit. The label goes before any chip does.
16
17
  */
17
- export declare function bandRibbon(view: MissionView, frame: MotionFrame, palette: MissionPalette, width: number, place: {
18
+ export declare function skillRow(chips: readonly SkillChip[], shimmer: ChipShimmer | undefined, palette: MissionPalette, width: number): Line;
19
+ export type CardFigures = {
20
+ readonly contextPercent: number | null;
21
+ readonly branch?: string;
18
22
  readonly inLane: boolean;
19
- }): Line;
23
+ };
24
+ export type CardInputs = {
25
+ readonly view: MissionView;
26
+ readonly frame: MotionFrame;
27
+ readonly chips: readonly SkillChip[];
28
+ /** Whether the session's turn is running; false lets a lit card rest at idle. Unknown keeps it working. */
29
+ readonly busy?: boolean;
30
+ readonly shimmer?: ChipShimmer;
31
+ readonly figures: CardFigures;
32
+ /** The project's last goal, shown on the track while the session has no lit mission. */
33
+ readonly last?: LastMission;
34
+ };
35
+ /**
36
+ * The status card: four rows of exactly `width` cells whether the session is idle or working, so
37
+ * the prompt never moves. Each row sheds its own low-priority parts as the terminal narrows.
38
+ */
39
+ export declare function statusCard(input: CardInputs, palette: MissionPalette, width: number): Line[];
20
40
  export type BandCard = {
21
41
  readonly title: string;
22
42
  readonly border: string;
23
43
  readonly rows: readonly Line[];
24
44
  };
25
45
  /**
26
- * The opt-in card under the ribbon. It keeps a fixed four rows whenever a goal exists, warnings
46
+ * The opt-in detail card under the status card. It keeps a fixed four rows whenever a goal exists, warnings
27
47
  * first, so turning a warning on or off never moves the prompt.
28
48
  */
29
49
  export declare function bandCard(view: MissionView, frame: MotionFrame, palette: MissionPalette, width: number): BandCard | undefined;
@@ -1,11 +1,14 @@
1
- // The band: one full-width ribbon row in OpenCode's app_bottom slot that carries the lit mission on
2
- // the main screen, plus an opt-in card. It reuses the chip's pills, the panel's bar and the shared
3
- // motion frame, so the chip, the panel and the band always move together.
4
- import { mixHex, motionTiming } from "./mission-motion.js";
5
- import { cellWidth, chipGroups, chipSparkCells, chipSparkWidth, groupsWidth, lineWidth, missionPanel, progressBar, shimmerAt, statusSegments, truncateCells } from "./mission-text.js";
1
+ // The band: a status card in OpenCode's full-width app_bottom slot that stays on screen for the
2
+ // whole session, plus an opt-in detail card. Its border carries the LIT mark, the discipline and the
3
+ // phase; row one is the phase track, row two the skill chips, and the bottom border the live figures.
4
+ // It reuses the chip's pills, the panel's bar and the shared motion frame, so the chip, the panel and
5
+ // the band always move together.
6
+ import { ease, mixHex, motionTiming, seg } from "./mission-motion.js";
7
+ import { contextWarnPercent, missionStatus } from "./mission-model.js";
8
+ import { cellWidth, chipSparkCells, chipSparkWidth, fade, lineWidth, missionPanel, progressBar, shimmerAt, shine, statusSegments, stepPill, truncateCells } from "./mission-text.js";
6
9
  /**
7
10
  * Reads the band switches from the plugin's tui.json options: `band: false` turns the band off,
8
- * `band: { card: true }` adds the card under the ribbon. Anything else keeps the defaults.
11
+ * `band: { card: true }` adds the detail card under the status card. Anything else keeps the defaults.
9
12
  */
10
13
  export function readBandOptions(options) {
11
14
  const band = typeof options === "object" && options !== null ? options.band : undefined;
@@ -14,18 +17,6 @@ export function readBandOptions(options) {
14
17
  const card = typeof band === "object" && band !== null && band.card === true;
15
18
  return { enabled: true, card };
16
19
  }
17
- // Shedding order as the row narrows: step labels first, then the next-task text, then bar width,
18
- // then the time. Each plan is tried in turn and the first that fits wins.
19
- const plans = [
20
- { tier: "full", bar: 18, text: true, time: true },
21
- { tier: "medium", bar: 18, text: true, time: true },
22
- { tier: "tight", bar: 18, text: true, time: true },
23
- { tier: "tight", bar: 18, text: false, time: true },
24
- { tier: "tight", bar: 10, text: false, time: true },
25
- { tier: "tight", bar: 5, text: false, time: true },
26
- { tier: "tight", bar: 0, text: false, time: true },
27
- { tier: "tight", bar: 0, text: false, time: false }
28
- ];
29
20
  const minimumText = 12;
30
21
  function elapsedText(minutes) {
31
22
  const total = Math.max(0, minutes ?? 0);
@@ -50,41 +41,234 @@ 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
  }
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
+ }
53
85
  /**
54
- * One ribbon row exactly `width` cells wide: pills, bar, the next task or a warning, and the elapsed
55
- * time, shedding parts in a fixed order so it never wraps. Idle sessions draw nothing.
86
+ * A lit session whose turn has ended rests: nothing is running, so the card stops showing a lit step.
87
+ * A finished goal and an open warning still show, since they are news after the turn.
56
88
  */
57
- export function bandRibbon(view, frame, palette, width, place) {
58
- if (!view.active || width <= 0)
59
- return [];
60
- const time = { text: `⟳ ${elapsedText(view.elapsedMinutes)}${place.inLane ? " · in lane" : ""}`, fg: palette.muted };
89
+ function resting(view, busy) {
90
+ return view.active && busy === false && !view.complete && view.warnings.length === 0;
91
+ }
92
+ function borderColor(view, frame, palette, rest) {
93
+ if (!view.active || rest)
94
+ return palette.borderActive;
95
+ const enter = frame.chipEnter ? ease.outCubic(seg(frame.t, 0, motionTiming.entry)) : 1;
96
+ const done = view.complete ? (frame.celebrate === undefined ? 1 : ease.inOutCubic(seg(frame.celebrate, 0, motionTiming.crossfade))) : 0;
97
+ return mixHex(palette.borderActive, mixHex(mixHex(palette.accent, palette.warning, frame.warn), palette.success, done), enter);
98
+ }
99
+ /** The phase on the right of the title: always a glyph plus words. */
100
+ function phaseLabel(view, frame, palette, rest) {
101
+ if (!view.active || rest)
102
+ return { text: "○ IDLE", fg: palette.muted };
103
+ const status = missionStatus(view);
104
+ if (status.tone === "success")
105
+ return { text: "✓ COMPLETE", fg: palette.success, bold: true };
106
+ if (status.tone === "warning")
107
+ return { text: `⚑ ${status.text}`, fg: mixHex(palette.info, palette.warning, Math.max(frame.warn, 0.5)), bold: true };
108
+ return { text: `● ${view.phase.toUpperCase()}`, fg: palette.info, bold: true };
109
+ }
110
+ function topLine(view, frame, palette, width, border, rest) {
111
+ const brand = [{ text: "🔥 ", fg: palette.text }, { text: "LIT", fg: shine(palette.primary, palette, 0.5, shimmerAt(frame), 0.6), bold: true }];
112
+ const name = view.active ? view.discipline : "ready";
113
+ const right = [phaseLabel(view, frame, palette, rest)];
114
+ const withName = (room) => [...brand, { text: " · ", fg: palette.muted }, { text: truncateCells(name, room), fg: view.active ? palette.text : palette.muted }];
115
+ const nameRoom = width - cellWidth("╭─") - lineWidth(withName(0)) - 2 - (lineWidth(right) + 2) - cellWidth("─╮") - 1;
116
+ const attempts = [];
117
+ if (nameRoom >= Math.min(4, cellWidth(name)))
118
+ attempts.push([withName(nameRoom), right]);
119
+ attempts.push([brand, right], [brand, []]);
120
+ for (const [left, label] of attempts) {
121
+ const line = ruled("╭─", left, label, "─╮", width, border);
122
+ if (line !== undefined)
123
+ return line;
124
+ }
125
+ return fitLine([{ text: "╭" + "─".repeat(Math.max(0, width)), fg: border }], width, palette);
126
+ }
127
+ /** The figures in the bottom border, most important first; the last ones go first when room runs out. */
128
+ function figureParts(view, figures, palette) {
129
+ const parts = [];
130
+ if (figures.contextPercent !== null) {
131
+ const percent = Math.round(figures.contextPercent);
132
+ parts.push({ text: `ctx ${percent}%`, fg: percent >= contextWarnPercent ? palette.warning : palette.muted });
133
+ }
134
+ if (view.active)
135
+ parts.push({ text: `⟳ ${elapsedText(view.elapsedMinutes)}${figures.inLane ? " · in lane" : ""}`, fg: palette.muted });
136
+ if (figures.branch !== undefined)
137
+ parts.push({ text: `⎇ ${truncateCells(figures.branch, 24)}`, fg: palette.muted });
138
+ return parts;
139
+ }
140
+ function bottomLine(view, figures, palette, width, border) {
141
+ const parts = figureParts(view, figures, palette);
142
+ for (let count = parts.length; count >= 0; count -= 1) {
143
+ const right = [];
144
+ parts.slice(0, count).forEach((part, index) => right.push(...(index === 0 ? [] : [{ text: " · ", fg: palette.muted }]), part));
145
+ const line = ruled("╰", [], right, "─╯", width, border);
146
+ if (line !== undefined)
147
+ return line;
148
+ }
149
+ return fitLine([{ text: "╰" + "─".repeat(Math.max(0, width)), fg: border }], width, palette);
150
+ }
151
+ function trackPills(view, frame, palette, tight) {
152
+ const alpha = (index) => (frame.chipEnter ? ease.outCubic(seg(frame.t, index * motionTiming.stagger, motionTiming.entry)) : 1);
153
+ if (tight) {
154
+ const current = view.steps.find((step) => step.state === "now" || step.state === "warn") ?? view.steps[view.complete ? 2 : view.active ? 1 : 0];
155
+ return [fade(stepPill(current, view, frame, palette), alpha(0), palette)];
156
+ }
157
+ const out = [];
158
+ view.steps.forEach((step, index) => {
159
+ if (index > 0)
160
+ out.push(fade({ text: " ━ ", fg: view.steps[index - 1].state === "done" ? palette.success : palette.borderSubtle }, alpha(index), palette));
161
+ out.push(fade(stepPill(step, view, frame, palette), alpha(index), palette));
162
+ });
163
+ return out;
164
+ }
165
+ /** The text after the track in an idle session: the project's last goal, or how to start. */
166
+ function idleText(last, palette, room) {
167
+ if (last === undefined || last.title === "")
168
+ return [{ text: " ", fg: palette.muted }, { text: truncateCells("add lit to a prompt to start", room - 2), fg: palette.muted }];
169
+ const mark = last.complete ? { text: ` ✓ ${last.passed}/${last.total}`, fg: palette.success } : { text: ` ● ${last.passed}/${last.total}`, fg: palette.info };
170
+ const head = { text: " last: ", fg: palette.muted };
171
+ return [head, { text: truncateCells(last.title, room - cellWidth(head.text) - cellWidth(mark.text)), fg: palette.text }, mark];
172
+ }
173
+ // Shedding order as the row narrows: the next-task text, bar width, the bar, the criteria count and
174
+ // then the step labels. Each plan is tried in turn and the first that fits wins.
175
+ const trackPlans = [
176
+ { tight: false, bar: 16, criteria: true, text: true },
177
+ { tight: false, bar: 16, criteria: true, text: false },
178
+ { tight: false, bar: 8, criteria: true, text: false },
179
+ { tight: false, bar: 0, criteria: true, text: false },
180
+ { tight: false, bar: 0, criteria: false, text: false },
181
+ { tight: true, bar: 0, criteria: false, text: false }
182
+ ];
183
+ function trackRow(view, frame, palette, width, last) {
61
184
  const sweep = shimmerAt(frame);
62
- for (const plan of plans) {
63
- const pills = chipGroups(view, frame, palette, plan.tier, false).flat();
64
- const bar = plan.bar > 0 && view.progress !== undefined ? [{ text: " ", fg: palette.muted }, ...progressBar(frame.barRatio, plan.bar, palette, sweep)] : [];
65
- const timeWidth = plan.time ? cellWidth(time.text) + 1 : 0;
66
- const fixed = lineWidth(pills) + lineWidth(bar) + timeWidth;
185
+ const gap = { text: " ", fg: palette.muted };
186
+ for (const plan of trackPlans) {
187
+ const pills = trackPills(view, frame, palette, plan.tight);
188
+ const bar = plan.bar > 0 && view.progress !== undefined ? [gap, ...progressBar(frame.barRatio, plan.bar, palette, sweep)] : [];
189
+ const goal = view.goal;
190
+ 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 }] : [];
191
+ const fixed = lineWidth(pills) + lineWidth(bar) + lineWidth(criteria);
67
192
  if (fixed > width)
68
193
  continue;
69
194
  const room = width - fixed;
70
- const after = trailing(view, frame, palette, room);
195
+ const after = plan.text ? (view.active ? trailing(view, frame, palette, room) : idleText(last, palette, room)) : [];
71
196
  const keepText = plan.text && after.length > 0 && (view.complete ? lineWidth(after) <= room : room >= minimumText);
72
197
  if (plan.text && after.length > 0 && !keepText)
73
198
  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);
199
+ const body = [...pills, ...bar, ...criteria, ...(keepText ? after : [])];
80
200
  // Sparks sit after the text, in the free space, so the pills never shift while they play.
81
- return [...body, ...(sparks ?? []), { text: " ".repeat(Math.max(plan.time ? 1 : 0, gap)), fg: palette.muted }, ...(plan.time ? [time] : [])];
201
+ const sparks = frame.celebrate !== undefined && lineWidth(body) + chipSparkWidth <= width ? chipSparkCells(frame.celebrate, palette) ?? [] : [];
202
+ return fitLine([...body, ...sparks], width, palette);
203
+ }
204
+ return fitLine(trackPills(view, frame, palette, true), width, palette);
205
+ }
206
+ function chipSegments(chip, shimmer, palette) {
207
+ const text = ` ${chip.name} `;
208
+ if (!chip.current)
209
+ return [{ text, fg: palette.muted, ...(palette.element === undefined ? {} : { bg: palette.element }) }];
210
+ const sweep = shimmer?.names.includes(chip.name) === true ? shimmer.sweep : -1;
211
+ if (sweep < 0)
212
+ return [{ text, fg: palette.onFill, bg: palette.accent, bold: true }];
213
+ const chars = [...text];
214
+ return chars.map((char, index) => ({ text: char, fg: palette.onFill, bg: shine(palette.accent, palette, index / (chars.length - 1), sweep, 0.3), bold: true }));
215
+ }
216
+ /**
217
+ * The skill chips row, exactly `width` cells: bright accent pills for the current request first, then
218
+ * dim recent names, and `+N` for the ones that do not fit. The label goes before any chip does.
219
+ */
220
+ export function skillRow(chips, shimmer, palette, width) {
221
+ const label = { text: "skills ", fg: palette.muted };
222
+ if (chips.length === 0)
223
+ return fitLine([label, { text: " none yet", fg: palette.muted }], width, palette);
224
+ const pills = chips.map((chip) => chipSegments(chip, shimmer, palette));
225
+ const space = { text: " ", fg: palette.muted };
226
+ for (const lead of [[label], []]) {
227
+ const all = lineWidth(lead) + pills.reduce((total, pill, index) => total + lineWidth(pill) + (index === 0 ? 0 : 1), 0);
228
+ let shown = pills.length;
229
+ if (all > width) {
230
+ let used = lineWidth(lead);
231
+ shown = 0;
232
+ for (const pill of pills) {
233
+ const next = used + (shown === 0 ? 0 : 1) + lineWidth(pill);
234
+ if (next + cellWidth(` +${pills.length - shown - 1}`) > width)
235
+ break;
236
+ used = next;
237
+ shown += 1;
238
+ }
239
+ }
240
+ if (shown === 0 && lead.length > 0)
241
+ continue;
242
+ const out = [...lead];
243
+ pills.slice(0, shown).forEach((pill, index) => out.push(...(index === 0 ? [] : [space]), ...pill));
244
+ if (shown < pills.length)
245
+ out.push({ text: `${shown === 0 ? "" : " "}+${pills.length - shown}`, fg: palette.muted });
246
+ return fitLine(out, width, palette);
82
247
  }
83
- return [];
248
+ return fitLine([{ text: `+${pills.length}`, fg: palette.muted }], width, palette);
249
+ }
250
+ /**
251
+ * The status card: four rows of exactly `width` cells whether the session is idle or working, so
252
+ * the prompt never moves. Each row sheds its own low-priority parts as the terminal narrows.
253
+ */
254
+ export function statusCard(input, palette, width) {
255
+ const { frame } = input;
256
+ const rest = resting(input.view, input.busy);
257
+ // At rest the track keeps what is done and unlights the step that was running.
258
+ const view = rest ? { ...input.view, steps: input.view.steps.map((step) => (step.state === "now" ? { ...step, state: "todo" } : step)) } : input.view;
259
+ const border = borderColor(view, frame, palette, rest);
260
+ const inner = width - 4;
261
+ const row = (content) => (inner < 0 ? fitLine([], width, palette) : [{ text: "│ ", fg: border }, ...fitLine(content, inner, palette), { text: " │", fg: border }]);
262
+ return [
263
+ topLine(view, frame, palette, width, border, rest),
264
+ row(trackRow(view, frame, palette, Math.max(0, inner), input.last)),
265
+ row(skillRow(input.chips, input.shimmer, palette, Math.max(0, inner))),
266
+ bottomLine(view, input.figures, palette, width, border)
267
+ ];
84
268
  }
85
269
  const cardRows = 4;
86
270
  /**
87
- * The opt-in card under the ribbon. It keeps a fixed four rows whenever a goal exists, warnings
271
+ * The opt-in detail card under the status card. It keeps a fixed four rows whenever a goal exists, warnings
88
272
  * first, so turning a warning on or off never moves the prompt.
89
273
  */
90
274
  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,16 @@ 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 busy: boolean;
64
+ readonly contextPercent: number | null;
65
+ readonly branch?: string;
66
+ };
67
+ /**
68
+ * What the band card shows beside the mission: the session's skill chips, whether a turn is running,
69
+ * context use and git branch. OpenCode keeps each session's last `session.status` ({ type: "busy" },
70
+ * "retry" or "idle"); a session it has not seen run has none, which counts as not running.
71
+ */
72
+ export declare function readCardInput(state: MissionStateSource, sessionID: string): CardInput;
56
73
  export {};