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.
- package/CHANGELOG.md +287 -0
- package/README.ja.md +3 -3
- package/README.ko.md +3 -3
- package/README.md +3 -3
- package/README.zh.md +3 -3
- package/package.json +2 -1
- package/src/agent/engine.ts +27 -3
- package/src/agent/json.ts +105 -25
- package/src/agent/loop.ts +2 -0
- package/src/agent/tool-schemas.ts +132 -0
- package/src/agent/tools.ts +8 -2
- package/src/ai/model-manager.ts +1 -0
- package/src/ai/providers/anthropic.ts +60 -3
- package/src/ai/providers/antigravity.ts +31 -1
- package/src/ai/providers/openai-responses.ts +55 -0
- package/src/ai/providers/openai.ts +46 -3
- package/src/ai/types.ts +19 -0
- package/src/commands/launch.ts +53 -6
- package/src/commands/whats-new.ts +62 -0
- package/src/skills/catalog.ts +8 -0
- package/src/tui/app.ts +28 -9
- package/src/util/whats-new.ts +272 -0
package/src/commands/launch.ts
CHANGED
|
@@ -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
|
-
|
|
1184
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/skills/catalog.ts
CHANGED
|
@@ -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:
|
|
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
|
+
}
|