@groeponline/pi-wishcraft 1.0.5 → 1.2.0

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 (72) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +25 -9
  3. package/ROADMAP.md +41 -260
  4. package/docs/commands.md +6 -2
  5. package/docs/configuration.md +21 -0
  6. package/docs/design/accessibility.md +71 -0
  7. package/docs/design/deck-layout.md +69 -0
  8. package/docs/design/motion-gallery.md +80 -0
  9. package/docs/design/motion-system.md +119 -0
  10. package/docs/design/presets.md +196 -0
  11. package/docs/design/regression-testing.md +54 -0
  12. package/docs/design/responsive.md +53 -0
  13. package/docs/design/signal.md +68 -0
  14. package/docs/design/theme-contract.md +132 -0
  15. package/docs/design/vnext-overview.md +55 -0
  16. package/docs/design/vnext-release-plan.md +212 -0
  17. package/docs/index.md +19 -1
  18. package/docs/segments.md +1 -1
  19. package/package.json +16 -9
  20. package/src/config/appearance.ts +230 -0
  21. package/src/config/parse.ts +41 -0
  22. package/src/config/presets.ts +184 -0
  23. package/src/config/structural-presets.ts +555 -0
  24. package/src/config/tokens.ts +154 -0
  25. package/src/config/types.ts +210 -2
  26. package/src/extension/commands/commands.ts +36 -12
  27. package/src/extension/commands/powerline-completions.ts +4 -0
  28. package/src/extension/commands/queue-commands.ts +6 -0
  29. package/src/extension/core/segment-context.ts +7 -1
  30. package/src/extension/core/state.ts +14 -0
  31. package/src/extension/core/types.ts +7 -0
  32. package/src/extension/session/session-lifecycle.ts +20 -0
  33. package/src/extension/settings/appearance-write.ts +135 -0
  34. package/src/extension/settings/wishcraft-config-items.ts +119 -0
  35. package/src/extension/settings/wishcraft-config.ts +28 -114
  36. package/src/extension/skills/skill-manager.ts +57 -3
  37. package/src/extension/ui/custom-editor.ts +7 -4
  38. package/src/extension/ui/deck/component.ts +358 -0
  39. package/src/extension/ui/deck/index.ts +34 -0
  40. package/src/extension/ui/deck/render.ts +260 -0
  41. package/src/extension/ui/deck/route-bodies.ts +209 -0
  42. package/src/extension/ui/deck/routes.ts +37 -0
  43. package/src/extension/ui/deck/session-snapshot.ts +104 -0
  44. package/src/extension/ui/deck/types.ts +92 -0
  45. package/src/extension/ui/layout.ts +3 -2
  46. package/src/extension/ui/menu-views.ts +6 -1
  47. package/src/extension/ui/powerline-menu-view.ts +19 -15
  48. package/src/extension/ui/signal-layout.ts +77 -0
  49. package/src/extension/ui/status-line-renderers.ts +24 -5
  50. package/src/motion/accessibility.ts +59 -0
  51. package/src/motion/catalog-extra.ts +429 -0
  52. package/src/motion/catalog.ts +336 -0
  53. package/src/motion/composer.ts +147 -0
  54. package/src/motion/frames.ts +84 -0
  55. package/src/motion/gallery.ts +77 -0
  56. package/src/motion/index.ts +78 -0
  57. package/src/motion/policy.ts +128 -0
  58. package/src/motion/scheduler.ts +159 -0
  59. package/src/motion/types.ts +132 -0
  60. package/src/render/timer.ts +1 -0
  61. package/src/segments/registry.ts +18 -6
  62. package/src/segments/system.ts +1 -1
  63. package/src/signal/controller.ts +123 -0
  64. package/src/signal/integration.ts +42 -0
  65. package/src/signal/render.ts +178 -0
  66. package/src/theme/colors.ts +14 -0
  67. package/src/theme/detect.ts +48 -0
  68. package/src/theme/tokens/index.ts +9 -0
  69. package/src/theme/tokens/mapping.ts +62 -0
  70. package/src/theme/tokens/types.ts +39 -0
  71. package/src/welcome/renderer.ts +2 -2
  72. package/src/welcome/whats-new.ts +46 -10
@@ -0,0 +1,42 @@
1
+ import type { MotionEvent } from "../motion/index.ts";
2
+ import { effectiveAppearanceMix, resolveAppearanceMix } from "../config/appearance.ts";
3
+ import type { AppearanceMixConfig } from "../config/types.ts";
4
+ import { config } from "../extension/core/state.ts";
5
+ import type { RuntimeState } from "../extension/core/types.ts";
6
+ import { setSignalEvent, stopSignal } from "./controller.ts";
7
+
8
+ export function dispatchSignalEvent(
9
+ rt: RuntimeState,
10
+ appearance: AppearanceMixConfig,
11
+ event: MotionEvent,
12
+ activity?: string,
13
+ ): void {
14
+ const resolved = resolveAppearanceMix(
15
+ effectiveAppearanceMix(appearance, config.preset),
16
+ );
17
+ setSignalEvent(rt.signal, rt.motionScheduler, rt.motionPolicy, event, {
18
+ activity,
19
+ motionId: resolved.motion[event] ?? resolved.signal.animation,
20
+ maxTicks: isFiniteEvent(event) ? 6 : undefined,
21
+ });
22
+ rt.lastLayoutResult = null;
23
+ rt.tuiRef?.requestRender();
24
+ }
25
+
26
+ export function settleSignal(rt: RuntimeState): void {
27
+ stopSignal(rt.signal, rt.motionScheduler, rt.motionPolicy);
28
+ rt.lastLayoutResult = null;
29
+ rt.tuiRef?.requestRender();
30
+ }
31
+
32
+ function isFiniteEvent(event: MotionEvent): boolean {
33
+ return (
34
+ event === "tool.end" ||
35
+ event === "idea.capture" ||
36
+ event === "skill.insert" ||
37
+ event === "policy.deny" ||
38
+ event === "success" ||
39
+ event === "warning" ||
40
+ event === "error"
41
+ );
42
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Three-lane Signal renderer.
3
+ *
4
+ * Left: model/repository state. Center: live semantic activity and motion rail.
5
+ * Right: context/queue/usage. Modules retain the existing per-segment fault
6
+ * isolation because every item still renders through `renderSegment`.
7
+ */
8
+
9
+ import { visibleWidth } from "@earendil-works/pi-tui";
10
+ import type {
11
+ PresetDef,
12
+ SegmentContext,
13
+ SignalSpec,
14
+ StatusLineSegmentId,
15
+ StatusLineSeparatorStyle,
16
+ } from "../config/types.ts";
17
+ import { mergeSegmentsWithCustomItems } from "../config/powerline-config.ts";
18
+ import { renderSegment } from "../segments/index.ts";
19
+ import { ansi, colorEnabled, getFgAnsiCode } from "../theme/colors.ts";
20
+ import { getSeparator } from "../theme/separators.ts";
21
+ import { frameAt, getMotion } from "../motion/index.ts";
22
+ import type { SignalRuntime } from "./controller.ts";
23
+
24
+ export interface SignalRenderOptions {
25
+ separatorStyle: StatusLineSeparatorStyle;
26
+ signal: SignalSpec;
27
+ ascii?: boolean;
28
+ layout?: import("../config/types.ts").StatusLineLayout | null;
29
+ disabledSegments?: StatusLineSegmentId[];
30
+ }
31
+
32
+ interface Module {
33
+ content: string;
34
+ width: number;
35
+ }
36
+
37
+ export function renderSignal(
38
+ ctx: SegmentContext,
39
+ preset: PresetDef,
40
+ runtime: SignalRuntime,
41
+ availableWidth: number,
42
+ options: SignalRenderOptions,
43
+ ): { topContent: string; secondaryContent: string } {
44
+ const merged = mergeSegmentsWithCustomItems(
45
+ preset,
46
+ ctx.effectiveCustomItems,
47
+ {
48
+ layout: options.layout ?? null,
49
+ disabledSegments: options.disabledSegments ?? [],
50
+ },
51
+ );
52
+ const left = renderModules(merged.leftSegments, ctx);
53
+ const right = renderModules(merged.rightSegments, ctx);
54
+ const secondary = renderModules(merged.secondarySegments, ctx);
55
+ const separator = getSeparator(options.separatorStyle).left;
56
+
57
+ const center = renderActivity(runtime, options.signal, options.ascii);
58
+ const top = fitLanes(left, center, right, separator, availableWidth);
59
+ return {
60
+ topContent: top,
61
+ secondaryContent: joinModules(secondary, separator, availableWidth),
62
+ };
63
+ }
64
+
65
+ function renderModules(ids: readonly StatusLineSegmentId[], ctx: SegmentContext): Module[] {
66
+ const modules: Module[] = [];
67
+ for (const id of ids) {
68
+ // renderSegment is the established per-module error boundary.
69
+ const rendered = renderSegment(id, ctx);
70
+ if (!rendered.visible || !rendered.content) continue;
71
+ modules.push({
72
+ content: rendered.content,
73
+ width: visibleWidth(rendered.content),
74
+ });
75
+ }
76
+ return modules;
77
+ }
78
+
79
+ export function renderActivity(
80
+ runtime: SignalRuntime,
81
+ spec: SignalSpec,
82
+ ascii = false,
83
+ ): string {
84
+ const def = getMotion(runtime.motionId) ?? getMotion(spec.animation);
85
+ const glyph = def
86
+ ? frameAt(def, runtime.tick, ascii)
87
+ : ascii
88
+ ? "*"
89
+ : "◆";
90
+ const label = runtime.activity || "ready";
91
+ const open = spec.caps.leftOpen ?? "";
92
+ const close = spec.caps.leftClose ?? "";
93
+ const dim = getFgAnsiCode("sep");
94
+ const reset = colorEnabled() ? ansi.reset : "";
95
+ const rail = runtime.active
96
+ ? `${spec.separators.left}${glyph}${spec.separators.right}`
97
+ : `${spec.separators.left}${spec.separators.right}`;
98
+ return `${dim}${open}${rail}${close}${reset} ${label}`;
99
+ }
100
+
101
+ function fitLanes(
102
+ leftInput: Module[],
103
+ center: string,
104
+ rightInput: Module[],
105
+ separator: string,
106
+ availableWidth: number,
107
+ ): string {
108
+ if (availableWidth <= 0) return "";
109
+ const left = [...leftInput];
110
+ const right = [...rightInput];
111
+ const sep = styledSeparator(separator);
112
+ const centerWidth = visibleWidth(center);
113
+
114
+ const lane = (modules: Module[]) => modules.map((module) => module.content).join(sep);
115
+ const totalWidth = () => {
116
+ const leftText = lane(left);
117
+ const rightText = lane(right);
118
+ return (
119
+ visibleWidth(leftText) +
120
+ visibleWidth(rightText) +
121
+ centerWidth +
122
+ (leftText ? 2 : 0) +
123
+ (rightText ? 2 : 0)
124
+ );
125
+ };
126
+
127
+ // Preserve the model/git side first, then context/queue. Remove the least
128
+ // important outer modules until all three lanes fit.
129
+ while (totalWidth() + 2 > availableWidth && right.length > 1) right.shift();
130
+ while (totalWidth() + 2 > availableWidth && left.length > 1) left.pop();
131
+ while (totalWidth() + 2 > availableWidth && right.length > 0) right.shift();
132
+ while (totalWidth() + 2 > availableWidth && left.length > 0) left.pop();
133
+
134
+ const leftText = lane(left);
135
+ const rightText = lane(right);
136
+ if (centerWidth + 2 > availableWidth) {
137
+ return ` ${truncatePlain(center, Math.max(0, availableWidth - 2))} `;
138
+ }
139
+
140
+ const contentWidth =
141
+ visibleWidth(leftText) +
142
+ visibleWidth(rightText) +
143
+ centerWidth +
144
+ (leftText ? 2 : 0) +
145
+ (rightText ? 2 : 0);
146
+ const flexible = Math.max(0, availableWidth - 2 - contentWidth);
147
+ const before = leftText ? " " : "";
148
+ const after = rightText ? " " : "";
149
+ // Extra space lives between center and right, keeping state metrics pinned.
150
+ return ` ${leftText}${before}${center}${after}${" ".repeat(flexible)}${rightText} `;
151
+ }
152
+
153
+ function joinModules(modules: Module[], separator: string, width: number): string {
154
+ if (modules.length === 0) return "";
155
+ const sep = styledSeparator(separator);
156
+ const fitted: Module[] = [];
157
+ let used = 2;
158
+ for (const module of modules) {
159
+ const next = module.width + (fitted.length ? visibleWidth(separator) + 2 : 0);
160
+ if (used + next > width) break;
161
+ fitted.push(module);
162
+ used += next;
163
+ }
164
+ return fitted.length ? ` ${fitted.map((module) => module.content).join(sep)} ` : "";
165
+ }
166
+
167
+ function styledSeparator(separator: string): string {
168
+ const color = getFgAnsiCode("sep");
169
+ const reset = colorEnabled() ? ansi.reset : "";
170
+ return ` ${color}${separator}${reset} `;
171
+ }
172
+
173
+ function truncatePlain(text: string, width: number): string {
174
+ if (width <= 0) return "";
175
+ // Activity labels are ASCII; strip ANSI before this emergency narrow path.
176
+ const plain = text.replace(/\x1b\[[0-9;]*m/g, "");
177
+ return plain.length <= width ? plain : plain.slice(0, Math.max(0, width - 1)) + "…";
178
+ }
@@ -12,6 +12,18 @@ export const ansi: AnsiColors = {
12
12
  reset: "\x1b[0m",
13
13
  };
14
14
 
15
+ // ponytail: NO_COLOR (de-facto standard — present and non-empty) disables all
16
+ // wishcraft color so the status bar stays plain text in no-color terminals
17
+ // and color-blind pipelines. Computed lazily so test env changes take effect.
18
+ let _colorEnabled: boolean | undefined;
19
+ export function colorEnabled(): boolean {
20
+ if (_colorEnabled === undefined) {
21
+ const v = process.env.NO_COLOR;
22
+ _colorEnabled = !(v != null && v !== "");
23
+ }
24
+ return _colorEnabled;
25
+ }
26
+
15
27
  function hexToRgb(hex: string): [number, number, number] {
16
28
  const cleanHex = hex.startsWith("#") ? hex.slice(1) : hex;
17
29
  if (!/^[0-9A-Fa-f]{6}$/.test(cleanHex)) {
@@ -51,10 +63,12 @@ for (const key of Object.keys(PALETTE)) {
51
63
  }
52
64
 
53
65
  export function fgOnly(color: ColorName, text: string): string {
66
+ if (!colorEnabled()) return text;
54
67
  const code = ansiCodeCache.get(color as string) ?? "";
55
68
  return code ? `${code}${text}` : text;
56
69
  }
57
70
 
58
71
  export function getFgAnsiCode(color: ColorName): string {
72
+ if (!colorEnabled()) return "";
59
73
  return ansiCodeCache.get(color as string) ?? "";
60
74
  }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Terminal and host capability detection. Pure: reads an env map, no process
3
+ * globals except the default argument. Motion policy is assembled elsewhere
4
+ * so this file stays free of the motion engine.
5
+ */
6
+
7
+ export interface TerminalEnvironment {
8
+ noColor: boolean;
9
+ lowColor: boolean;
10
+ screenReader: boolean;
11
+ reducedMotion: boolean;
12
+ dumb: boolean;
13
+ }
14
+
15
+ const MOTION_LEVELS = ["full", "reduced", "functional", "off"] as const;
16
+
17
+ export function detectEnvironment(
18
+ env: NodeJS.ProcessEnv = process.env,
19
+ ): TerminalEnvironment {
20
+ const term = (env.TERM ?? "").toLowerCase();
21
+ const colorterm = (env.COLORTERM ?? "").toLowerCase();
22
+ const noColor = env.NO_COLOR !== undefined && env.NO_COLOR !== "";
23
+ const dumb = term === "dumb" || term === "";
24
+ const lowColor =
25
+ !colorterm.includes("truecolor") &&
26
+ !colorterm.includes("24bit") &&
27
+ (term.includes("vt100") || term === "linux" || /\bansi\b/.test(term));
28
+ const screenReader =
29
+ env.WISHCRAFT_SCREEN_READER === "1" ||
30
+ env.ACCESSIBILITY_SCREEN_READER === "1" ||
31
+ env.NVDA !== undefined ||
32
+ env.VOICEOVER === "1";
33
+ const reducedMotion =
34
+ env.WISHCRAFT_REDUCED_MOTION === "1" ||
35
+ env.PREFER_REDUCED_MOTION === "1" ||
36
+ env.MOZ_PREFER_REDUCED_MOTION === "1";
37
+
38
+ return { noColor, lowColor: lowColor && !noColor, screenReader, reducedMotion, dumb };
39
+ }
40
+
41
+ export function motionLevelFromEnv(
42
+ env: NodeJS.ProcessEnv = process.env,
43
+ ): (typeof MOTION_LEVELS)[number] | null {
44
+ const raw = (env.WISHCRAFT_MOTION ?? "").toLowerCase();
45
+ return (MOTION_LEVELS as readonly string[]).includes(raw)
46
+ ? (raw as (typeof MOTION_LEVELS)[number])
47
+ : null;
48
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * src/theme/tokens/index.ts
3
+ * ---------------------------------------------------------------------------
4
+ * Public exports for Wishcraft Semantic Design Tokens.
5
+ * ---------------------------------------------------------------------------
6
+ */
7
+
8
+ export * from "./types.ts";
9
+ export * from "./mapping.ts";
@@ -0,0 +1,62 @@
1
+ /**
2
+ * src/theme/tokens/mapping.ts
3
+ * ---------------------------------------------------------------------------
4
+ * Default token mapping and legacy SemanticColor bridge for Wishcraft.
5
+ * ---------------------------------------------------------------------------
6
+ */
7
+
8
+ import type { ColorScheme, SemanticColor } from "../../config/types.ts";
9
+ import type { WishcraftTokens } from "./types.ts";
10
+
11
+ export const DEFAULT_TOKENS: WishcraftTokens = {
12
+ surface: "#0f172a",
13
+ surfaceRaised: "#1e293b",
14
+ text: "#f8fafc",
15
+ textMuted: "dim",
16
+ primary: "#f59e0b",
17
+ secondary: "#00afaf",
18
+ accent: "#ea580c",
19
+ success: "success",
20
+ warning: "warning",
21
+ error: "error",
22
+ focus: "#38bdf8",
23
+ selection: "#334155",
24
+ motionDim: "dim",
25
+ motionHot: "#fbbf24",
26
+ motionTrail: "#78350f",
27
+ };
28
+
29
+ /**
30
+ * Derives legacy SemanticColor configuration from modern WishcraftTokens.
31
+ */
32
+ export function deriveColorSchemeFromTokens(tokens: WishcraftTokens): Required<ColorScheme> {
33
+ return {
34
+ model: tokens.primary,
35
+ shellMode: tokens.accent,
36
+ path: tokens.secondary,
37
+ gitClean: tokens.success,
38
+ gitDirty: tokens.warning,
39
+ thinking: "thinkingOff",
40
+ thinkingMinimal: "thinkingMinimal",
41
+ thinkingLow: "thinkingLow",
42
+ thinkingMedium: "thinkingMedium",
43
+ context: tokens.textMuted,
44
+ contextWarn: tokens.warning,
45
+ contextError: tokens.error,
46
+ cost: tokens.text,
47
+ tokens: tokens.textMuted,
48
+ queue: tokens.accent,
49
+ separator: tokens.textMuted,
50
+ border: tokens.surfaceRaised,
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Creates WishcraftTokens by merging overrides onto defaults.
56
+ */
57
+ export function createTokens(overrides?: Partial<WishcraftTokens>): WishcraftTokens {
58
+ return {
59
+ ...DEFAULT_TOKENS,
60
+ ...overrides,
61
+ };
62
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * src/theme/tokens/types.ts
3
+ * ---------------------------------------------------------------------------
4
+ * Semantic Design Tokens for Wishcraft (Crush pattern).
5
+ * ---------------------------------------------------------------------------
6
+ */
7
+
8
+ import type { ThemeColor } from "@earendil-works/pi-coding-agent";
9
+
10
+ export type ColorValue = ThemeColor | `#${string}`;
11
+
12
+ export interface WishcraftTokens {
13
+ // Surfaces & Backgrounds
14
+ surface: ColorValue;
15
+ surfaceRaised: ColorValue;
16
+
17
+ // Typography
18
+ text: ColorValue;
19
+ textMuted: ColorValue;
20
+
21
+ // Brand & Accents
22
+ primary: ColorValue;
23
+ secondary: ColorValue;
24
+ accent: ColorValue;
25
+
26
+ // Feedback & State
27
+ success: ColorValue;
28
+ warning: ColorValue;
29
+ error: ColorValue;
30
+
31
+ // Interaction
32
+ focus: ColorValue;
33
+ selection: ColorValue;
34
+
35
+ // Motion & Animation Channels
36
+ motionDim: ColorValue;
37
+ motionHot: ColorValue;
38
+ motionTrail: ColorValue;
39
+ }
@@ -1,5 +1,5 @@
1
1
  import { visibleWidth } from "@earendil-works/pi-tui";
2
- import { ansi, fgOnly, getFgAnsiCode } from "../theme/colors.ts";
2
+ import { ansi, colorEnabled, fgOnly, getFgAnsiCode } from "../theme/colors.ts";
3
3
  import { centerText, fitToWidth, getBoxLayout } from "./layout.ts";
4
4
  import type { WelcomeData, WelcomeWidget, WidgetRenderContext } from "./types.ts";
5
5
 
@@ -32,7 +32,7 @@ function bold(text: string): string {
32
32
  }
33
33
 
34
34
  export function dim(text: string): string {
35
- return getFgAnsiCode("sep") + text + ansi.reset;
35
+ return getFgAnsiCode("sep") + text + (colorEnabled() ? ansi.reset : "");
36
36
  }
37
37
 
38
38
  function gradientLine(line: string): string {
@@ -16,6 +16,9 @@ import { getAgentPath } from "../paths/agent-dirs.ts";
16
16
 
17
17
  const MAX_WHATSNEW_LINES = 8;
18
18
  const WHATS_NEW_STATE_FILE = "whats-new.json";
19
+ // First GroepOnline release. On a first run (no stored version) the welcome
20
+ // panel shows history from here forward, never the pre-1.0 `0.x` prehistory.
21
+ const FIRST_OWN_RELEASE = "1.0.0";
19
22
 
20
23
  const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
21
24
 
@@ -37,38 +40,71 @@ function isVersionNewer(version: string, lastSeen: string | null): boolean {
37
40
  return false;
38
41
  }
39
42
 
43
+ function isVersionAtOrNewer(version: string, floor: string): boolean {
44
+ const next = parseVersion(version);
45
+ const min = parseVersion(floor);
46
+ if (!next || !min) return false;
47
+ for (let i = 0; i < 3; i++) {
48
+ if (next[i] > min[i]) return true;
49
+ if (next[i] < min[i]) return false;
50
+ }
51
+ return true;
52
+ }
53
+
40
54
  /**
41
55
  * Extract the bullet lines from released changelog sections whose version is
42
- * strictly newer than `lastSeenVersion` (null = first run, so the newest
43
- * sections win). Markdown bold markers are stripped; backticks are kept so
44
- * commands like `/powerline doctor` stay readable in the terminal.
56
+ * strictly newer than `lastSeenVersion`. On a first run (`lastSeenVersion` is
57
+ * null) `floorVersion` becomes the lower bound, so the panel shows history from
58
+ * the first own release forward and skips the pre-1.0 prehistory. Sections are
59
+ * returned oldest-first (chronological "since your last version") and capped at
60
+ * `maxLines`. Markdown bold markers are stripped; backticks are kept so commands
61
+ * like `/powerline doctor` stay readable in the terminal.
45
62
  */
46
63
  export function parseChangelogDelta(
47
64
  changelog: string,
48
65
  lastSeenVersion: string | null,
49
66
  maxLines: number = MAX_WHATSNEW_LINES,
67
+ floorVersion: string | null = FIRST_OWN_RELEASE,
50
68
  ): string[] {
51
- const entries: string[] = [];
52
- let includeSection = false;
69
+ // The changelog is newest-first; collect per section, then reverse to read
70
+ // oldest-first before capping.
71
+ const sections: string[][] = [];
72
+ let current: string[] | null = null;
73
+ // A stored last-seen version is a strict lower bound (only strictly newer
74
+ // sections). On a first run the floor is inclusive, so the first own release
75
+ // itself is shown while the pre-1.0 prehistory is skipped.
76
+ const matchesBound =
77
+ lastSeenVersion !== null
78
+ ? (version: string) => isVersionNewer(version, lastSeenVersion)
79
+ : floorVersion !== null
80
+ ? (version: string) => isVersionAtOrNewer(version, floorVersion)
81
+ : () => true;
53
82
 
54
83
  for (const rawLine of changelog.split("\n")) {
55
84
  const line = rawLine.trimEnd();
56
85
  const versionMatch = line.match(/^##\s+\[([^\]]+)\]/);
57
86
  if (versionMatch) {
58
87
  const version = versionMatch[1].trim();
59
- includeSection =
60
- version !== "Unreleased" && isVersionNewer(version, lastSeenVersion);
88
+ const include = version !== "Unreleased" && matchesBound(version);
89
+ current = include ? [] : null;
90
+ if (current) sections.push(current);
61
91
  continue;
62
92
  }
63
- if (!includeSection) continue;
93
+ if (!current) continue;
64
94
  if (!line.startsWith("- ")) continue;
65
95
 
66
96
  const cleaned = line.slice(2).trim().replace(/\*\*/g, "").trim();
67
97
  if (!cleaned) continue;
68
- entries.push(cleaned);
69
- if (entries.length >= maxLines) break;
98
+ current.push(cleaned);
70
99
  }
71
100
 
101
+ const entries: string[] = [];
102
+ for (const section of sections.reverse()) {
103
+ for (const entry of section) {
104
+ entries.push(entry);
105
+ if (entries.length >= maxLines) return entries;
106
+ }
107
+ }
72
108
  return entries;
73
109
  }
74
110