jeo-code 0.5.10 → 0.5.13

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.
@@ -1,7 +1,7 @@
1
1
  import { createInterface } from "node:readline/promises";
2
2
  import { emitKeypressEvents } from "node:readline";
3
3
  import { PassThrough } from "node:stream";
4
- import { runAgentLoop, executorSystemPrompt, DEFAULT_TOOLS, TOOL_PROTOCOL, WORKING_DISCIPLINE, type AgentLoopEvents } from "../agent/engine";
4
+ import { runAgentLoop, executorSystemPrompt, DEFAULT_TOOLS, TOOL_PROTOCOL, WORKING_DISCIPLINE, OUTPUT_DISCIPLINE, type AgentLoopEvents } from "../agent/engine";
5
5
  import { createOpikTracer, wrapEvents } from "../agent/opik-tracer";
6
6
  import { initialDynamicStepLimit } from "../agent/step-budget";
7
7
  import { memoryPromptSection, spawnDetachedDistill } from "../agent/memory";
@@ -27,6 +27,7 @@ import { renderWelcome, playWelcomeSweep } from "../tui/components/welcome";
27
27
  import { checkForUpdate, readUpdateCache, writeUpdateCache } from "../util/update-check";
28
28
  import { jeoEnv } from "../util/env";
29
29
  import { renderUpdateBox } from "../tui/components/update-box";
30
+ import { consumeLaunchWhatsNew } from "../util/whats-new";
30
31
  import { supportsUnicode } from "../tui/components/capability";
31
32
  import pkg from "../../package.json";
32
33
  import chalk from "chalk";
@@ -498,6 +499,16 @@ interface AbortHarnessOptions {
498
499
  export const PASTE_START = "\u001b[200~";
499
500
  export const PASTE_END = "\u001b[201~";
500
501
 
502
+ /** True when a stdin chunk is ONLY backspace bytes (DEL 0x7f or BS 0x08) — i.e. a
503
+ * standalone Backspace keystroke with nothing else. A backspace on an EMPTY input
504
+ * line is a no-op edit, but some Bun readline builds turn it into a spurious `close`
505
+ * event, which the REPL would treat as a hard exit ("Backspace quits jeo"). The
506
+ * input filter swallows these when the line buffer is already empty so the byte never
507
+ * reaches readline and the close can't fire. */
508
+ export function isStandaloneBackspace(chunk: string): boolean {
509
+ return chunk.length > 0 && /^[\x7f\b]+$/.test(chunk);
510
+ }
511
+
501
512
  export interface PromptInputQueue {
502
513
  pendingLines: string[];
503
514
  partial: string;
@@ -963,6 +974,7 @@ export function buildToolProtocol(allowedTools: Set<string>): string {
963
974
  lines.push("Reply with STRICT JSON only — no code fences. You MAY include an optional leading");
964
975
  lines.push('"reasoning" string (one short sentence on your plan, shown live to the user) before "tool":');
965
976
  lines.push('{ "reasoning": "<one short sentence>", "tool": "<name>", "arguments": { ... } }');
977
+ lines.push("Tool calibration: scale calls to difficulty — one for a known fact, a few for a normal task, more only when evidence is genuinely missing. Locate before you open: search/find first, then read the hit, instead of guessing paths.");
966
978
  return lines.join("\n");
967
979
  }
968
980
 
@@ -1180,14 +1192,28 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1180
1192
 
1181
1193
  const workflowSkills = workflowSkillsForPrompt(resolvedSkills);
1182
1194
  const resolvedSkillNames = resolvedSkills.map(s => s.name);
1183
- const skillSlashDetails: SlashCommandInfo[] = resolvedSkills.flatMap(skill =>
1184
- skillSlashAliases(skill).map(alias => ({
1195
+ // Bundled workflows are first-class `/name` commands (deep-interview/ralplan/team/
1196
+ // ultragoal), surfaced in the `/` menu even when their SKILL.md self-references no
1197
+ // slash token — `parseSkillInvocation` dispatches `/name` by skill name. Aliases the
1198
+ // SKILL.md does declare are listed too (deduped, case-insensitive).
1199
+ const WORKFLOW_SLASH_NAMES = ["deep-interview", "ralplan", "team", "ultragoal"];
1200
+ const skillSlashDetails: SlashCommandInfo[] = resolvedSkills.flatMap(skill => {
1201
+ const aliases = skillSlashAliases(skill);
1202
+ const nameSlash = WORKFLOW_SLASH_NAMES.includes(skill.name) ? [`/${skill.name}`] : [];
1203
+ const seen = new Set<string>();
1204
+ const commands = [...nameSlash, ...aliases].filter(a => {
1205
+ const k = a.toLowerCase();
1206
+ if (seen.has(k)) return false;
1207
+ seen.add(k);
1208
+ return true;
1209
+ });
1210
+ return commands.map(alias => ({
1185
1211
  command: alias,
1186
1212
  usage: `${alias} [intent]`,
1187
1213
  description: `Run ${skill.name} skill${skill.summary ? ` — ${skill.summary}` : ""}`,
1188
1214
  group: "skills" as const,
1189
- })),
1190
- );
1215
+ }));
1216
+ });
1191
1217
 
1192
1218
  const protocol = buildToolProtocol(allowedTools);
1193
1219
  const preamble = flags.systemPrompt ?? "You are the jeo, an interactive coding agent.\nAccomplish the user's request by calling tools and verifying your work.";
@@ -1197,7 +1223,8 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1197
1223
  const baseSystemPrompt =
1198
1224
  preamble + "\n\n" + protocol + "\n\n" +
1199
1225
  WORKING_DISCIPLINE + "\n\n" +
1200
- "Always verify (run tests / execute the program) before calling done." +
1226
+ OUTPUT_DISCIPLINE + "\n\n" +
1227
+ "Before calling done, self-check: did I run the test or command that exercises this change, are directly-affected callsites/tests/docs updated, and does my claim match real output? If any answer is no, keep working — do not call done." +
1201
1228
  "\nWhen you have finished the user's request, or need to reply to or ask the user something, call done with {\"reason\": <your natural-language reply to the user>}. The reason text is shown to the user as your message." +
1202
1229
  (allowedTools.has("task") ? "\n\nDelegation: " + taskToolProtocolLine(cfg) +
1203
1230
  " Call task with {\"role\": <one of the advertised roles>, \"task\": <assignment>, \"context\": <optional>} to hand a focused slice to a subagent." : "") +
@@ -1770,6 +1797,17 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1770
1797
  };
1771
1798
  showUpdateBanner(await readUpdateCache(pkg.version));
1772
1799
  showUpdateBanner(await Promise.race([updatePromise, new Promise<null>(r => setTimeout(() => r(null), 1200))]));
1800
+ // First launch after a version bump: surface the bundled release notes ONCE
1801
+ // (offline, from the new package's CHANGELOG.md) and record the seen version
1802
+ // so it never repeats. Screen-safe: prints BEFORE the prompt is armed.
1803
+ try {
1804
+ const whatsNew = await consumeLaunchWhatsNew({
1805
+ cols: Math.min(100, Math.max(40, (process.stdout.columns ?? 80) - 2)),
1806
+ unicode: supportsUnicode(),
1807
+ color: welcomeTheme.color,
1808
+ });
1809
+ if (whatsNew && whatsNew.length) console.log(whatsNew.join("\n"));
1810
+ } catch { /* release notes are a courtesy; never block launch */ }
1773
1811
  if (!LaunchTui.usable(flags.noTui)) console.log("(plain output)");
1774
1812
 
1775
1813
  const useTui = LaunchTui.usable(flags.noTui);
@@ -1954,6 +1992,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1954
1992
  for (const off of promptListenerCleanups.splice(0)) { try { off(); } catch { /* best effort */ } }
1955
1993
  };
1956
1994
  let keyFilter: PassThrough | undefined;
1995
+ // Holder for the active readline so the input filter can see the current line
1996
+ // buffer (used by the empty-line backspace guard below). Set after rl is created.
1997
+ let activeRl: { line?: string } | undefined;
1957
1998
  if (multilineInput) {
1958
1999
  const kf = new PassThrough();
1959
2000
  (kf as unknown as { isTTY: boolean }).isTTY = true;
@@ -1975,6 +2016,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1975
2016
  let kfInPaste = false;
1976
2017
  const kfDataHandler = (chunk: Buffer) => {
1977
2018
  const data = chunk.toString("utf8");
2019
+ // Empty-line Backspace guard: a standalone Backspace with nothing to delete is a
2020
+ // no-op, but some Bun readline builds emit a spurious `close` for it — which the
2021
+ // REPL treats as a hard exit. Drop it before it reaches readline. (Inside a paste,
2022
+ // or with text in the buffer, backspace is forwarded normally so editing works.)
2023
+ if (!kfInPaste && isStandaloneBackspace(data) && !(activeRl?.line ?? "")) return;
1978
2024
  let out = "";
1979
2025
  let i = 0;
1980
2026
  while (i < data.length) {
@@ -2015,6 +2061,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2015
2061
  output: gatedStdout(process.stdout, () => previewArmed || promptActive || pickerActive || interactiveTurnActive),
2016
2062
  completer: (line: string) => readlineCompleter(line, completionContext()),
2017
2063
  });
2064
+ activeRl = rl; // wire the input filter's empty-line backspace guard to the live buffer
2018
2065
  const promptStdin = process.stdin as typeof process.stdin & { isRaw?: boolean; setRawMode?(raw: boolean): void };
2019
2066
  const promptWasRaw = !!promptStdin.isRaw;
2020
2067
  let promptRawChanged = false;
@@ -0,0 +1,62 @@
1
+ import pkg from "../../package.json";
2
+ import {
3
+ loadBundledChangelog,
4
+ parseChangelogSections,
5
+ releaseSections,
6
+ renderWhatsNew,
7
+ } from "../util/whats-new";
8
+ import { supportsUnicode } from "../tui/components/capability";
9
+
10
+ /** `jeo whats-new` — show the release notes bundled with the running version. */
11
+ export async function runWhatsNewCommand(args: string[] = []): Promise<void> {
12
+ const isHelp = args.includes("--help") || args.includes("-h");
13
+ const hasAll = args.includes("--all");
14
+ const hasJson = args.includes("--json");
15
+
16
+ const KNOWN = new Set(["--all", "--json", "-h", "--help"]);
17
+ for (const arg of args) {
18
+ if (!KNOWN.has(arg)) {
19
+ console.log(`Unknown flag: ${arg}`);
20
+ printUsage();
21
+ process.exitCode = 1;
22
+ return;
23
+ }
24
+ }
25
+
26
+ if (isHelp) {
27
+ printUsage();
28
+ return;
29
+ }
30
+
31
+ const md = await loadBundledChangelog();
32
+ const all = md ? releaseSections(parseChangelogSections(md)) : [];
33
+ const sections = hasAll ? all : all.slice(0, 1);
34
+
35
+ if (hasJson) {
36
+ console.log(JSON.stringify({ version: pkg.version, entries: sections }, null, 2));
37
+ return;
38
+ }
39
+
40
+ if (sections.length === 0) {
41
+ console.log(`No release notes found for jeo-code ${pkg.version}.`);
42
+ return;
43
+ }
44
+
45
+ const cols = process.stdout.columns ?? 80;
46
+ console.log(renderWhatsNew(sections, {
47
+ cols: Math.min(100, Math.max(40, cols - 2)),
48
+ unicode: supportsUnicode(),
49
+ color: process.stdout.isTTY === true,
50
+ }).join("\n"));
51
+ }
52
+
53
+ function printUsage(): void {
54
+ console.log("Usage: jeo whats-new [options]");
55
+ console.log("");
56
+ console.log("Show the release notes bundled with the installed jeo-code version.");
57
+ console.log("");
58
+ console.log("Options:");
59
+ console.log(" --all Show notes for every released version, not just the latest");
60
+ console.log(" --json Output the notes as JSON");
61
+ console.log(" -h, --help Show this help message");
62
+ }
@@ -546,6 +546,14 @@ export function parseSkillInvocation(input: string, skills: SkillDoc[]): SkillIn
546
546
  }
547
547
  }
548
548
  let skill = getSkillBySlash(skills, command);
549
+ // `/team`, `/deep-interview`, `/ultragoal`, … — a bare slash + skill NAME (or unique
550
+ // prefix) is the SAME entrypoint as `$name` and `/skill:name`. Only when getSkillBySlash
551
+ // found no alias and the token is a plain `/word` (no nested `/path` and no `.` so
552
+ // `/speckit.plan` aliases and `./file` paths keep their own resolution). This is what
553
+ // makes the bundled workflows actually run from the `/` menu, not just `/ralplan`.
554
+ if (!skill && command.length > 1 && command.startsWith("/") && !command.includes(".") && command.indexOf("/", 1) === -1) {
555
+ skill = getSkillFrom(skills, command.slice(1)) ?? uniquePrefixSkill(skills, command.slice(1));
556
+ }
549
557
  if (!skill) {
550
558
  if (command.startsWith("/") || command.startsWith(".") || command.includes("/")) {
551
559
  const resolved = tryResolveSkillFromFilePath(command);
package/src/tui/app.ts CHANGED
@@ -32,7 +32,7 @@ import { renderMarkdownTables } from "./components/markdown-table";
32
32
  import { stripMarkdown, renderMarkdownAnsi } from "./components/markdown-text";
33
33
  import { visibleWidth, wrapTextWithAnsi, truncateToWidth, sanitizeForFrame } from "./components/width";
34
34
  import { categoryBadge } from "./components/category-index";
35
- import { formatStepTimeline, stepsFromTools, formatStepHeader, formatStepTimelineCompact, type StepState } from "./components/step-timeline";
35
+ import { formatStepTimeline, stepsFromTools, formatStepHeader, formatStepTimelineCompact, formatDuration as formatToolMs, type StepState } from "./components/step-timeline";
36
36
  import { formatHintBar } from "./components/hints";
37
37
  import { formatDuration, formatUsage } from "./components/duration";
38
38
  import { renderHud, derivePhase, type JeoPhase } from "./components/hud";
@@ -112,6 +112,12 @@ export const FRAME_WRAP_TAIL_CHARS = 16 * 1024;
112
112
  export function tailForWrap(text: string, maxChars = FRAME_WRAP_TAIL_CHARS): string {
113
113
  return text.length > maxChars ? text.slice(text.length - maxChars) : text;
114
114
  }
115
+
116
+ /** Status animation palette while a tool/process runs (background verification): an
117
+ * amber→yellow gradient, distinct from the cool thinking gradient, so "the agent is
118
+ * running a process / verifying" reads at a glance (gjc parity: `theme.fg("warning")`
119
+ * on the in-flight tool line). */
120
+ export const STATUS_VERIFY_PALETTE = ["#ffd24a", "#ffb300"] as const;
115
121
  const DEFAULT_MAX_STEPS = 100;
116
122
  // Tools light enough that they never get a forge card (gjc parity): completion is a
117
123
  // single ✓/✗ ledger line; only failures surface a result card with the error body.
@@ -169,6 +175,8 @@ export class LaunchTui {
169
175
  private thinking = false;
170
176
  private hudPhase: JeoPhase = "thinking";
171
177
  private runningTool = false;
178
+ // When the current tool started (Date.now()); drives the result card's elapsed `(Nms)`.
179
+ private toolStartedAt = 0;
172
180
  // Latest transient provider notice (rate-limit auto-retry countdown); pinned into the
173
181
  // [STEP] status row while waiting so backoff is visible at a glance. Cleared on the
174
182
  // next step / model reply.
@@ -412,6 +420,7 @@ export class LaunchTui {
412
420
  this.streamingActivity = "";
413
421
  if (invocation && invocation.tool !== "done") {
414
422
  this.runningTool = true;
423
+ this.toolStartedAt = Date.now();
415
424
  this.hudPhase = "executing";
416
425
  const toolName = invocation.tool || "(no tool)";
417
426
  this.pendingIndex = this.tools.start(toolName);
@@ -464,6 +473,12 @@ export class LaunchTui {
464
473
  // tool checklist — no category/status badge clutter.
465
474
  const mark = this.unicode ? (success ? "✓" : "✗") : success ? "v" : "x";
466
475
  const paintedMark = this.theme.color ? (success ? chalk.green(mark) : chalk.red(mark)) : mark;
476
+ // gjc-parity timing detail: the completed card shows how long the tool ran,
477
+ // dim after the ✓/✗ glyph (e.g. `✓ Bash · (438ms)`).
478
+ const toolMs = this.toolStartedAt ? Date.now() - this.toolStartedAt : 0;
479
+ this.toolStartedAt = 0;
480
+ const durDim = this.theme.color ? chalk.dim : (s: string) => s;
481
+ const durSuffix = toolMs > 0 ? durDim(` ${this.unicode ? "·" : "-"} (${formatToolMs(toolMs)})`) : "";
467
482
  const result = summarizeForgeResult(tool, success, output);
468
483
  const card = this.pendingForge;
469
484
  this.pendingForge = null;
@@ -471,7 +486,7 @@ export class LaunchTui {
471
486
  // gjc-style single Bash card: command echo + `Output` divider + body + exit
472
487
  // note, under one ✓/✗-marked header — mutated in place so the live frame and
473
488
  // the non-TTY summary both show the merged card.
474
- card.title = `${paintedMark} Bash`;
489
+ card.title = `${paintedMark} Bash${durSuffix}`;
475
490
  card.lines.push(...result.lines);
476
491
  this.flushForgeCard(card, success);
477
492
  } else if (card && t === "web_search" && success && webSearchCardLines(output, { unicode: this.unicode })) {
@@ -480,11 +495,11 @@ export class LaunchTui {
480
495
  // the structured tool output (provider chain — Anthropic native or the
481
496
  // keyless DuckDuckGo fallback).
482
497
  const ws = webSearchCardLines(output, { unicode: this.unicode })!;
483
- card.title = `${paintedMark} Web Search: ${ws.titleMeta}`;
498
+ card.title = `${paintedMark} Web Search: ${ws.titleMeta}${durSuffix}`;
484
499
  card.lines = ws.lines;
485
500
  this.flushForgeCard(card, success);
486
501
  } else if (card) {
487
- card.title = `${paintedMark} ${card.title}`;
502
+ card.title = `${paintedMark} ${card.title}${durSuffix}`;
488
503
  if (!success) this.rememberForge(result);
489
504
  this.flushForgeCard(card, success);
490
505
  if (!success) this.flushForgeCard(result, false);
@@ -492,7 +507,7 @@ export class LaunchTui {
492
507
  // Light tool: one ✓/✗ line, plus a dim result tree for list-shaped output
493
508
  // (find/search/ls) and an error card when the tool failed.
494
509
  const { suffix, children } = this.ledgerTree(tool, success, output);
495
- this.appendLedger(`${paintedMark} ${target}${suffix}\n${children.map(c => `${c}\n`).join("")}`, "tool");
510
+ this.appendLedger(`${paintedMark} ${target}${suffix}${durSuffix}\n${children.map(c => `${c}\n`).join("")}`, "tool");
496
511
  if (!success) {
497
512
  this.rememberForge(result);
498
513
  this.flushForgeCard(result, false);
@@ -1164,12 +1179,16 @@ export class LaunchTui {
1164
1179
  // the ⟦esc⟧ cancel hint visible without trapping the message inside a border.
1165
1180
  if (isThinking) {
1166
1181
  const grad = themeGradient(this.theme, idx);
1182
+ // While a tool/process runs (background verification), the status animation turns
1183
+ // amber/yellow — distinct from the cool thinking gradient (gjc warning-color parity).
1184
+ const verifying = this.runningTool;
1185
+ const verifySpin = verifying && this.theme.color ? chalk.yellow(this.spinner.current()) : this.spinner.current();
1167
1186
  const costUsd = costForUsage(this.footer.model, this.turnUsage) ?? undefined;
1168
1187
  const stats = this.tools.stats();
1169
1188
  tail.push(...renderStatusBox({
1170
1189
  cols: Math.max(24, Math.min(120, cols)),
1171
1190
  phaseLabel: this.workflowStatus ? `${this.workflowStatus.skill}:${this.workflowStatus.phase}` : this.hudPhase,
1172
- spinner: this.spinner.current(),
1191
+ spinner: verifySpin,
1173
1192
  activity: this.retryNotice ?? (this.streamingActivity || this.currentActivity()),
1174
1193
  escHint: true,
1175
1194
  elapsedMs,
@@ -1184,7 +1203,7 @@ export class LaunchTui {
1184
1203
  color: this.theme.color,
1185
1204
  colorLevel,
1186
1205
  phase,
1187
- palette: [grad.from, grad.to],
1206
+ palette: verifying ? [...STATUS_VERIFY_PALETTE] : [grad.from, grad.to],
1188
1207
  isThinking: true,
1189
1208
  usage: this.turnUsage,
1190
1209
  costUsd,
@@ -1350,7 +1369,7 @@ export class LaunchTui {
1350
1369
  for (const line of renderStatusBox({
1351
1370
  cols: innerWidth,
1352
1371
  phaseLabel: this.workflowStatus ? `${this.workflowStatus.skill}:${this.workflowStatus.phase}` : this.hudPhase,
1353
- spinner: this.spinner.current(),
1372
+ spinner: this.runningTool && this.theme.color ? chalk.yellow(this.spinner.current()) : this.spinner.current(),
1354
1373
  activity: this.retryNotice ?? (this.streamingActivity || statusMsg),
1355
1374
  escHint: true,
1356
1375
  elapsedMs,
@@ -1365,7 +1384,7 @@ export class LaunchTui {
1365
1384
  color: this.theme.color,
1366
1385
  colorLevel,
1367
1386
  phase,
1368
- palette,
1387
+ palette: this.runningTool ? [...STATUS_VERIFY_PALETTE] : palette,
1369
1388
  isThinking: true,
1370
1389
  usage: this.turnUsage,
1371
1390
  costUsd,
@@ -0,0 +1,272 @@
1
+ import pkg from "../../package.json";
2
+ import { compareVersions } from "../commands/update";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
6
+ import { jeoEnv } from "./env";
7
+ import chalk from "chalk";
8
+ import { boxBlock, BOX_UNICODE, BOX_ASCII } from "../tui/components/layout";
9
+
10
+ // ---- "What's New" release notes ---------------------------------------------
11
+ // Mirrors gjc's post-upgrade release-notes surface. The bundled CHANGELOG.md
12
+ // (shipped via package.json `files`) is always the RUNNING version's changelog,
13
+ // so after a `bun install -g jeo-code` upgrade the next launch reads the NEW
14
+ // notes offline. `jeo whats-new` shows them on demand; `jeo update --install`
15
+ // shows them right after a successful self-update.
16
+
17
+ export interface ChangelogGroup {
18
+ /** "Added" | "Changed" | "Fixed" | "" (ungrouped). */
19
+ label: string;
20
+ items: string[];
21
+ }
22
+
23
+ export interface ChangelogSection {
24
+ version: string; // "0.5.9" | "Unreleased"
25
+ date?: string;
26
+ summary: string; // the `_italic_` one-liner under the header
27
+ groups: ChangelogGroup[];
28
+ }
29
+
30
+ const HEADER = /^##\s+\[([^\]]+)\](?:\s*-\s*(\S+))?\s*$/;
31
+ const SUBHEADER = /^###\s+(.+?)\s*$/;
32
+ const BULLET = /^[-*]\s+(.+)$/;
33
+ const ITALIC = /^_(.+)_$/;
34
+
35
+ /** Parse `## [version] - date` sections with their summary line and grouped bullets. */
36
+ export function parseChangelogSections(markdown: string): ChangelogSection[] {
37
+ const lines = markdown.split(/\r?\n/);
38
+ const sections: ChangelogSection[] = [];
39
+ let current: ChangelogSection | null = null;
40
+ let group: ChangelogGroup | null = null;
41
+
42
+ for (const raw of lines) {
43
+ const line = raw.trimEnd();
44
+ const head = line.match(HEADER);
45
+ if (head) {
46
+ current = { version: head[1]!, date: head[2], summary: "", groups: [] };
47
+ group = null;
48
+ sections.push(current);
49
+ continue;
50
+ }
51
+ if (!current) continue;
52
+
53
+ const trimmed = line.trim();
54
+ if (trimmed === "") continue;
55
+
56
+ const sub = trimmed.match(SUBHEADER);
57
+ if (sub) {
58
+ group = { label: sub[1]!, items: [] };
59
+ current.groups.push(group);
60
+ continue;
61
+ }
62
+
63
+ const bullet = trimmed.match(BULLET);
64
+ if (bullet) {
65
+ if (!group) {
66
+ group = { label: "", items: [] };
67
+ current.groups.push(group);
68
+ }
69
+ group.items.push(bullet[1]!.trim());
70
+ continue;
71
+ }
72
+
73
+ const italic = trimmed.match(ITALIC);
74
+ if (italic && !current.summary && current.groups.length === 0) {
75
+ current.summary = italic[1]!.trim();
76
+ }
77
+ }
78
+
79
+ return sections;
80
+ }
81
+
82
+ /** Real releases only (drops an `Unreleased` heading), newest-first as written. */
83
+ export function releaseSections(sections: ChangelogSection[]): ChangelogSection[] {
84
+ return sections.filter(s => s.version.toLowerCase() !== "unreleased");
85
+ }
86
+
87
+ /**
88
+ * Sections strictly newer than `fromVersion` and at most `toVersion`.
89
+ * `fromVersion` null → every release up to and including `toVersion`.
90
+ */
91
+ export function selectNewSections(
92
+ sections: ChangelogSection[],
93
+ fromVersion: string | null,
94
+ toVersion: string,
95
+ ): ChangelogSection[] {
96
+ return releaseSections(sections).filter(s => {
97
+ const newerThanFrom = fromVersion ? compareVersions(s.version, fromVersion) > 0 : true;
98
+ const atMostTo = compareVersions(s.version, toVersion) <= 0;
99
+ return newerThanFrom && atMostTo;
100
+ });
101
+ }
102
+
103
+ export interface WhatsNewRenderOpts {
104
+ cols?: number;
105
+ unicode?: boolean;
106
+ color?: boolean;
107
+ /** Cap rendered body lines so a multi-release jump stays bounded. Default 22. */
108
+ maxBodyLines?: number;
109
+ }
110
+
111
+ /** Word-wrap plain text to `width` columns, hard-breaking any single over-long token. */
112
+ function wrapWords(text: string, width: number): string[] {
113
+ const w = Math.max(1, width);
114
+ const out: string[] = [];
115
+ let cur = "";
116
+ const flush = () => { if (cur) { out.push(cur); cur = ""; } };
117
+ for (let word of text.split(/\s+/).filter(Boolean)) {
118
+ while (word.length > w) {
119
+ flush();
120
+ out.push(word.slice(0, w));
121
+ word = word.slice(w);
122
+ }
123
+ if (!cur) cur = word;
124
+ else if (cur.length + 1 + word.length <= w) cur += " " + word;
125
+ else { out.push(cur); cur = word; }
126
+ }
127
+ flush();
128
+ return out.length ? out : [""];
129
+ }
130
+
131
+ /** Render a boxed "What's New" panel for the given sections (newest-first). */
132
+ export function renderWhatsNew(sections: ChangelogSection[], opts?: WhatsNewRenderOpts): string[] {
133
+ if (sections.length === 0) return [];
134
+
135
+ const cols = opts?.cols ?? 80;
136
+ const useColor = opts?.color !== false;
137
+ const useUnicode = opts?.unicode !== false;
138
+ const maxBody = opts?.maxBodyLines ?? 22;
139
+ const width = Math.max(32, Math.min(120, cols));
140
+ const inner = Math.max(8, width - 2);
141
+
142
+ const accent = useColor ? chalk.hex("#f2b84b") : (s: string) => s;
143
+ const bold = useColor ? (s: string) => chalk.bold(accent(s)) : (s: string) => s;
144
+ const boldPlain = useColor ? chalk.bold : (s: string) => s;
145
+ const dim = useColor ? chalk.dim : (s: string) => s;
146
+ const bulletChar = useUnicode ? "•" : "-";
147
+
148
+ const body: string[] = [];
149
+ let clipped = false;
150
+
151
+ // Wrap `text` to the inner width, paint each line, and push — bullets indent
152
+ // their continuation lines under the marker. Returns false once the cap is hit.
153
+ const emit = (text: string, paint: (s: string) => string, kind: "plain" | "bullet" = "plain"): boolean => {
154
+ const avail = kind === "bullet" ? inner - 2 : inner;
155
+ const wrapped = wrapWords(text, avail);
156
+ for (let i = 0; i < wrapped.length; i++) {
157
+ if (body.length >= maxBody) { clipped = true; return false; }
158
+ const prefix = kind === "bullet" ? (i === 0 ? `${bulletChar} ` : " ") : "";
159
+ body.push(paint(prefix + wrapped[i]!));
160
+ }
161
+ return true;
162
+ };
163
+
164
+ const headerLabel = sections.length === 1
165
+ ? `What's New in jeo ${sections[0]!.version}`
166
+ : `What's New (${sections.length} releases)`;
167
+ emit(headerLabel, bold);
168
+
169
+ outer:
170
+ for (const s of sections) {
171
+ if (sections.length > 1) {
172
+ if (body.length >= maxBody) { clipped = true; break; }
173
+ body.push("DIVIDER");
174
+ const when = s.date ? ` — ${s.date}` : "";
175
+ if (!emit(`${s.version}${when}`, accent)) break;
176
+ }
177
+ if (s.summary && !emit(s.summary, dim)) break;
178
+ for (const g of s.groups) {
179
+ if (g.label && !emit(g.label, boldPlain)) break outer;
180
+ for (const item of g.items) {
181
+ if (!emit(item, (l: string) => l, "bullet")) break outer;
182
+ }
183
+ }
184
+ }
185
+ if (clipped) body.push(dim("… see CHANGELOG.md for the full notes"));
186
+
187
+ return boxBlock(body, width, {
188
+ glyphs: useUnicode ? BOX_UNICODE : BOX_ASCII,
189
+ paint: accent,
190
+ align: "left",
191
+ });
192
+ }
193
+
194
+ // ---- Bundled changelog + last-seen-version state ----------------------------
195
+
196
+ /** Read the CHANGELOG.md that ships next to this package (running version's notes). */
197
+ export async function loadBundledChangelog(): Promise<string | null> {
198
+ // This module lives at src/util/whats-new.ts; CHANGELOG.md sits at the package
199
+ // root, i.e. two levels up. Resolve against the module dir so it works from a
200
+ // global install path just as from the repo.
201
+ const candidate = path.join(import.meta.dir, "..", "..", "CHANGELOG.md");
202
+ try {
203
+ return await readFile(candidate, "utf-8");
204
+ } catch {
205
+ return null;
206
+ }
207
+ }
208
+
209
+ interface WhatsNewState {
210
+ lastSeenVersion: string;
211
+ updatedAt: number;
212
+ }
213
+
214
+ function stateDir(): string {
215
+ return jeoEnv("CONFIG_DIR") || path.join(os.homedir(), ".jeo");
216
+ }
217
+
218
+ function statePath(): string {
219
+ return path.join(stateDir(), "whats-new.json");
220
+ }
221
+
222
+ export async function readLastSeenVersion(): Promise<string | null> {
223
+ try {
224
+ const raw = await readFile(statePath(), "utf-8");
225
+ const data = JSON.parse(raw) as Partial<WhatsNewState>;
226
+ if (!data || typeof data.lastSeenVersion !== "string" || !data.lastSeenVersion) return null;
227
+ return data.lastSeenVersion;
228
+ } catch {
229
+ return null;
230
+ }
231
+ }
232
+
233
+ /** Persist the last version the user has seen notes for (best-effort; never throws). */
234
+ export async function writeLastSeenVersion(version: string): Promise<void> {
235
+ if (typeof version !== "string" || !version) return;
236
+ try {
237
+ await mkdir(stateDir(), { recursive: true, mode: 0o700 });
238
+ const payload: WhatsNewState = { lastSeenVersion: version, updatedAt: Date.now() };
239
+ await writeFile(statePath(), JSON.stringify(payload, null, 2), { encoding: "utf-8", mode: 0o600 });
240
+ } catch {
241
+ // State is an optimization; a write failure must never break launch/update.
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Launch-time hook: returns rendered notes the FIRST time jeo runs after an
247
+ * upgrade, then records the current version so it never repeats. A fresh install
248
+ * (no prior state) records silently and shows nothing — only genuine upgrades
249
+ * surface notes, matching gjc / npm update-notice behaviour.
250
+ */
251
+ export async function consumeLaunchWhatsNew(opts?: WhatsNewRenderOpts): Promise<string[] | null> {
252
+ const current = pkg.version;
253
+ const lastSeen = await readLastSeenVersion();
254
+
255
+ if (!lastSeen) {
256
+ await writeLastSeenVersion(current);
257
+ return null;
258
+ }
259
+ if (compareVersions(current, lastSeen) <= 0) {
260
+ // Not an upgrade (equal, or a local downgrade). Keep state monotonic-ish.
261
+ if (compareVersions(current, lastSeen) < 0) await writeLastSeenVersion(current);
262
+ return null;
263
+ }
264
+
265
+ // Genuine upgrade: mark seen up front so a render/parse failure never repeats.
266
+ const md = await loadBundledChangelog();
267
+ await writeLastSeenVersion(current);
268
+ if (!md) return null;
269
+ const sections = selectNewSections(parseChangelogSections(md), lastSeen, current);
270
+ if (sections.length === 0) return null;
271
+ return renderWhatsNew(sections, opts);
272
+ }