jeo-code 0.5.13 → 0.5.15

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.
@@ -35,7 +35,9 @@ export function compareVersions(a: string, b: string): -1 | 0 | 1 {
35
35
  export interface UpdateDeps {
36
36
  fetchJson: (url: string, options?: { signal?: AbortSignal }) => Promise<any>;
37
37
  localVersion: () => string;
38
- install: () => Promise<{ success: boolean; stdout?: string; stderr?: string }>;
38
+ install: (version?: string) => Promise<{ success: boolean; stdout?: string; stderr?: string }>;
39
+ /** Display release notes after a successful self-update (best-effort, no-op in tests). */
40
+ showWhatsNew?: () => void;
39
41
  }
40
42
 
41
43
  export const defaultDeps: UpdateDeps = {
@@ -56,13 +58,26 @@ export const defaultDeps: UpdateDeps = {
56
58
  localVersion: () => {
57
59
  return pkg.version;
58
60
  },
59
- install: async () => {
60
- const proc = Bun.spawnSync(["bun", "install", "-g", "jeo-code"], {
61
+ install: async (version?: string) => {
62
+ // Self-update the global install. jeo runs on Bun (see the `#!/usr/bin/env bun`
63
+ // shebang), so Bun is always present; `@<version>` (default `latest`) forces the
64
+ // newest publish even if a stale global is cached.
65
+ const target = `jeo-code@${version ?? "latest"}`;
66
+ const proc = Bun.spawnSync(["bun", "install", "-g", target], {
61
67
  stdout: "inherit",
62
68
  stderr: "inherit",
63
69
  });
64
70
  return { success: proc.success };
65
71
  }
72
+ ,
73
+ showWhatsNew: () => {
74
+ try {
75
+ // Spawn the freshly-installed binary so it reads the NEW bundled CHANGELOG.
76
+ Bun.spawnSync(["jeo", "whats-new"], { stdout: "inherit", stderr: "inherit" });
77
+ } catch {
78
+ // Notes are a courtesy; a spawn failure must never fail the update.
79
+ }
80
+ }
66
81
  };
67
82
 
68
83
  export async function runUpdateCommand(args: string[] = []): Promise<void> {
@@ -72,6 +87,7 @@ export async function runUpdateCommand(args: string[] = []): Promise<void> {
72
87
  export async function runUpdateCommandWith(args: string[], deps: UpdateDeps): Promise<void> {
73
88
  const isHelp = args.includes("--help") || args.includes("-h");
74
89
  const hasInstall = args.includes("--install");
90
+ const hasCheck = args.includes("--check");
75
91
  const hasJson = args.includes("--json");
76
92
  const hasStrict = args.includes("--strict");
77
93
 
@@ -143,8 +159,13 @@ export async function runUpdateCommandWith(args: string[], deps: UpdateDeps): Pr
143
159
  }
144
160
  }
145
161
 
162
+ // Default action is INSTALL (bare `jeo update` upgrades). `--check` forces a
163
+ // check-only run; `--json` stays check-only too (programmatic status polling must
164
+ // not trigger an install) unless `--install` is given explicitly.
165
+ const shouldInstall = hasInstall || (!hasCheck && !hasJson);
166
+
146
167
  // We got the version successfully
147
- if (hasInstall) {
168
+ if (shouldInstall) {
148
169
  if (upToDate) {
149
170
  if (hasJson) {
150
171
  console.log(JSON.stringify({
@@ -154,14 +175,14 @@ export async function runUpdateCommandWith(args: string[], deps: UpdateDeps): Pr
154
175
  installed: false
155
176
  }));
156
177
  } else {
157
- console.log(`jeo-code is up-to-date (${current}). Skipping installation.`);
178
+ console.log(`jeo-code is already up-to-date (${current}).`);
158
179
  }
159
180
  } else {
160
181
  if (!hasJson) {
161
182
  console.log(`Installing update: ${current} -> ${latest}...`);
162
183
  }
163
184
  try {
164
- const result = await deps.install();
185
+ const result = await deps.install(latest ?? undefined);
165
186
  if (result.success) {
166
187
  if (hasJson) {
167
188
  console.log(JSON.stringify({
@@ -172,6 +193,7 @@ export async function runUpdateCommandWith(args: string[], deps: UpdateDeps): Pr
172
193
  }));
173
194
  } else {
174
195
  console.log(`Successfully installed jeo-code@${latest}`);
196
+ deps.showWhatsNew?.();
175
197
  }
176
198
  } else {
177
199
  if (hasJson) {
@@ -224,7 +246,7 @@ export async function runUpdateCommandWith(args: string[], deps: UpdateDeps): Pr
224
246
  }));
225
247
  } else {
226
248
  console.log(`Newer version available: ${latest} (current: ${current}).`);
227
- console.log("Run 'bun install -g jeo-code' to upgrade.");
249
+ console.log("Run 'jeo update' to install it.");
228
250
  }
229
251
  }
230
252
  }
@@ -236,8 +258,9 @@ function printUsage() {
236
258
  console.log("Check for and install updates for jeo-code.");
237
259
  console.log("");
238
260
  console.log("Options:");
239
- console.log(" --check Check for updates (default)");
240
- console.log(" --install Check and install if newer");
261
+ console.log(" (default) Check and install if a newer version is available");
262
+ console.log(" --check Only check; do not install");
263
+ console.log(" --install Force install if newer (also used with --json)");
241
264
  console.log(" --json Output result in JSON format");
242
265
  console.log(" --strict Exit with code 1 on network/registry errors");
243
266
  console.log(" -h, --help Show this help message");
@@ -4,6 +4,7 @@ import {
4
4
  parseChangelogSections,
5
5
  releaseSections,
6
6
  renderWhatsNew,
7
+ RECENT_RELEASE_COUNT,
7
8
  } from "../util/whats-new";
8
9
  import { supportsUnicode } from "../tui/components/capability";
9
10
 
@@ -30,7 +31,7 @@ export async function runWhatsNewCommand(args: string[] = []): Promise<void> {
30
31
 
31
32
  const md = await loadBundledChangelog();
32
33
  const all = md ? releaseSections(parseChangelogSections(md)) : [];
33
- const sections = hasAll ? all : all.slice(0, 1);
34
+ const sections = hasAll ? all : all.slice(0, RECENT_RELEASE_COUNT);
34
35
 
35
36
  if (hasJson) {
36
37
  console.log(JSON.stringify({ version: pkg.version, entries: sections }, null, 2));
@@ -56,7 +57,7 @@ function printUsage(): void {
56
57
  console.log("Show the release notes bundled with the installed jeo-code version.");
57
58
  console.log("");
58
59
  console.log("Options:");
59
- console.log(" --all Show notes for every released version, not just the latest");
60
+ console.log(` --all Show notes for every released version, not just the recent ${RECENT_RELEASE_COUNT}`);
60
61
  console.log(" --json Output the notes as JSON");
61
62
  console.log(" -h, --help Show this help message");
62
63
  }
@@ -183,50 +183,20 @@ export function skillsPromptSection(skills: SkillDoc[] = SKILLS): string {
183
183
  import * as fs from "node:fs/promises";
184
184
  import * as path from "node:path";
185
185
  import * as os from "node:os";
186
- import { existsSync, statSync, readFileSync } from "node:fs";
187
- import { jeoEnv } from "../util/env";
188
186
 
189
- export function tryResolveSkillFromFilePath(filePath: string): SkillDoc | null {
190
- try {
191
- let targetPath = path.resolve(filePath);
192
- if (!existsSync(targetPath)) {
193
- return null;
194
- }
195
- const stat = statSync(targetPath);
196
- if (stat.isDirectory()) {
197
- const skillMd = path.join(targetPath, "SKILL.md");
198
- if (existsSync(skillMd) && statSync(skillMd).isFile()) {
199
- targetPath = skillMd;
200
- } else {
201
- return null;
202
- }
203
- } else if (!stat.isFile() || !targetPath.endsWith(".md")) {
204
- return null;
205
- }
187
+ import { jeoEnv } from "../util/env";
206
188
 
207
- const content = readFileSync(targetPath, "utf-8");
208
- // Determine a name for this skill
209
- let skillName = path.basename(targetPath, ".md");
210
- if (skillName.toLowerCase() === "skill" || skillName.toLowerCase() === "readme") {
211
- // Use the directory name if the filename is generic
212
- skillName = path.basename(path.dirname(targetPath));
213
- }
214
- const parsed = parseSkillMarkdown(skillName, content, { preferMetaName: true });
215
- return isSupportedExternalSkill(parsed) ? parsed : null;
216
- } catch {
217
- return null;
218
- }
219
- }
220
189
  const BUILTIN_SLASH_ALIASES = new Set([
221
190
  "/help", "/clear", "/compact", "/model", "/fast", "/provider", "/logout",
222
191
  "/agents", "/config", "/roles", "/thinking",
223
- "/view", "/diff", "/find", "/search", "/sessions", "/skill", "/evolve",
192
+ "/view", "/diff", "/find", "/search",
224
193
  "/exit", "/quit",
225
194
  ]);
226
195
 
227
- const RESERVED_SKILL_NAMES = new Set(
228
- [...BUILTIN_SLASH_ALIASES].map(alias => alias.slice(1).toLowerCase())
229
- );
196
+ const RESERVED_SKILL_NAMES = new Set([
197
+ ...[...BUILTIN_SLASH_ALIASES].map(alias => alias.slice(1).toLowerCase()),
198
+ "skill",
199
+ ]);
230
200
 
231
201
  function normalizeSlashAlias(raw: string): string | undefined {
232
202
  const m = raw.trim().match(/^\/[A-Za-z][A-Za-z0-9_-]*(?:\.[A-Za-z][A-Za-z0-9_-]*)*$/);
@@ -410,12 +380,33 @@ export function parseSkillMarkdown(name: string, content: string, opts?: { prefe
410
380
  };
411
381
  }
412
382
 
383
+ const ALLOWED_SKILL_NAMES = new Set([
384
+ "deep-interview",
385
+ "deep-dive",
386
+ "ralplan",
387
+ "team",
388
+ "ultragoal",
389
+ "research",
390
+ "ultrawork"
391
+ ]);
392
+
413
393
  function isSupportedExternalSkill(doc: SkillDoc): boolean {
414
- return !RESERVED_SKILL_NAMES.has(doc.name.toLowerCase());
394
+ const nameLower = doc.name.toLowerCase();
395
+ return !RESERVED_SKILL_NAMES.has(nameLower);
415
396
  }
416
397
 
417
398
  /** Bundled skills merged with user skill docs from {@link skillDirs} (user overrides by name). */
418
399
  export async function loadSkills(cwd: string = process.cwd()): Promise<SkillDoc[]> {
400
+ try {
401
+ const lockPath = path.join(cwd, "skills-lock.json");
402
+ const lockContent = await fs.readFile(lockPath, "utf-8");
403
+ const lockData = JSON.parse(lockContent);
404
+ if (lockData && lockData.skills) {
405
+ for (const name of Object.keys(lockData.skills)) {
406
+ ALLOWED_SKILL_NAMES.add(name.toLowerCase());
407
+ }
408
+ }
409
+ } catch {}
419
410
  const byName = new Map<string, SkillDoc>(SKILLS.map(s => [s.name.toLowerCase(), s]));
420
411
  for (const dir of skillDirs(cwd)) {
421
412
  let entries: import("node:fs").Dirent[] = [];
@@ -512,59 +503,24 @@ export interface SkillInvocation {
512
503
  intent: string;
513
504
  invokedAs?: string;
514
505
  }
515
-
516
- /** Parse only explicit skill invocations. Ambient mentions of skill names or slash
517
- * aliases inside a broader prompt are deliberately ignored so pasted SKILL.md files
518
- * cannot hijack an ordinary coding request. */
506
+ /** Parse only an explicit `$skill` invocation. Skills are invokable ONLY via the `$`
507
+ * entrypoint `/` commands and slash aliases never load a skill file, and pasted SKILL.md
508
+ * paths cannot hijack an ordinary coding request. Only the FIRST token counts, and only
509
+ * when a skill with that exact name (or unique name prefix) is loaded; `$HOME is what?` or
510
+ * any unknown `$word` falls through to the model as an ordinary prompt. */
519
511
  export function parseSkillInvocation(input: string, skills: SkillDoc[]): SkillInvocation | null {
520
512
  const trimmed = input.trim();
521
513
  if (!trimmed) return null;
522
514
 
523
- const explicitEntrypoint = trimmed.startsWith("/skill:")
524
- ? "/skill:"
525
- : (trimmed === "/skill" || trimmed.startsWith("/skill ")) ? "/skill" : "";
526
- if (explicitEntrypoint) {
527
- const rest = trimmed.substring(explicitEntrypoint.length).trim();
528
- if (!rest) return null;
529
- const [name, ...intentParts] = rest.split(/\s+/);
530
- let skill = getSkillFrom(skills, name ?? "");
531
- if (!skill && name) {
532
- skill = tryResolveSkillFromFilePath(name) ?? undefined;
533
- }
534
- return skill ? { skill, intent: intentParts.join(" ").trim() } : null;
535
- }
536
-
537
515
  const command = trimmed.split(/\s+/, 1)[0] ?? "";
538
- // Codex/gjc-style exact-name entrypoint: `$team [intent]` invokes the skill
539
- // named "team" directly (case-insensitive). Only the FIRST token counts, and
540
- // only when a skill with that exact name is loaded — `$HOME is what?` or any
541
- // unknown `$word` falls through to the model as an ordinary prompt.
542
516
  if (command.length > 1 && command.startsWith("$")) {
543
517
  const dollarSkill = getSkillFrom(skills, command.slice(1)) ?? uniquePrefixSkill(skills, command.slice(1));
544
518
  if (dollarSkill) {
545
519
  return { skill: dollarSkill, intent: trimmed.slice(command.length).trim(), invokedAs: command };
546
520
  }
547
521
  }
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
- }
557
- if (!skill) {
558
- if (command.startsWith("/") || command.startsWith(".") || command.includes("/")) {
559
- const resolved = tryResolveSkillFromFilePath(command);
560
- if (resolved) {
561
- return { skill: resolved, intent: trimmed.slice(command.length).trim(), invokedAs: command };
562
- }
563
- }
564
- }
565
- return skill ? { skill, intent: trimmed.slice(command.length).trim(), invokedAs: command } : null;
522
+ return null;
566
523
  }
567
-
568
524
  /** Parse a LEADING run of `$skill` tokens into an ordered chain that shares the trailing
569
525
  * text as one intent: `$ralplan $team build auth` → [ralplan, team] each with intent
570
526
  * "build auth". This is what lets `$` invoke several skills in one line — they all run,
package/src/tui/app.ts CHANGED
@@ -25,8 +25,8 @@ import { SECTION_GAP, stackSections } from "./components/section";
25
25
  import { resolveTheme, themeGradient, accentPaint, accentShadowPaint, diffPaint, mutedPaint, cardFillPaint } from "./components/themes";
26
26
  import { detectColorLevel, animatedGradientText, ColorLevel } from "./components/color";
27
27
  import { formatForgeBox, summarizeForgeInvocation, summarizeForgeResult, fitForgeBoxes, webSearchCardLines, type ForgeSummary } from "./components/forge";
28
- import { renderJeoStatus, renderStatusBar, renderStatusBox } from "./components/status";
29
- import { costForUsage, formatCost } from "../ai/pricing";
28
+ import { renderStatusBar, renderStatusBox, type StatusBoxData } from "./components/status";
29
+ import { costForUsage } from "../ai/pricing";
30
30
  import { renderMarkdownTables } from "./components/markdown-table";
31
31
 
32
32
  import { stripMarkdown, renderMarkdownAnsi } from "./components/markdown-text";
@@ -35,7 +35,7 @@ import { categoryBadge } from "./components/category-index";
35
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
- import { renderHud, derivePhase, type JeoPhase } from "./components/hud";
38
+ import { renderHud, type JeoPhase } from "./components/hud";
39
39
  import { formatTodoWriteCard } from "./components/todo-card";
40
40
  import { renderInputBox } from "./components/input-box";
41
41
  import { jeoEnv } from "../util/env";
@@ -505,9 +505,11 @@ export class LaunchTui {
505
505
  if (!success) this.flushForgeCard(result, false);
506
506
  } else {
507
507
  // Light tool: one ✓/✗ line, plus a dim result tree for list-shaped output
508
- // (find/search/ls) and an error card when the tool failed.
508
+ // (find/search/ls) and an error card when the tool failed. The ledger line
509
+ // stays a clean single line (no ms suffix) — light tools are sub-ms and the
510
+ // duration detail lives on the heavier forge cards instead.
509
511
  const { suffix, children } = this.ledgerTree(tool, success, output);
510
- this.appendLedger(`${paintedMark} ${target}${suffix}${durSuffix}\n${children.map(c => `${c}\n`).join("")}`, "tool");
512
+ this.appendLedger(`${paintedMark} ${target}${suffix}\n${children.map(c => `${c}\n`).join("")}`, "tool");
511
513
  if (!success) {
512
514
  this.rememberForge(result);
513
515
  this.flushForgeCard(result, false);
@@ -1105,6 +1107,40 @@ export class LaunchTui {
1105
1107
  align: "left",
1106
1108
  });
1107
1109
  }
1110
+
1111
+ /** Build the live status-box data — the ~20-field payload shared by the inline and
1112
+ * the bottom-pinned (non-inline) frames so they can't drift (color, verify-yellow,
1113
+ * metrics, usage all defined once). Only `cols` differs between callers. */
1114
+ private statusBoxData(args: { cols: number; elapsedMs: number; stepNow: number; phase: number; colorLevel: number; idx: number }): StatusBoxData {
1115
+ const { cols, elapsedMs, stepNow, phase, colorLevel, idx } = args;
1116
+ const grad = themeGradient(this.theme, idx);
1117
+ const verifying = this.runningTool;
1118
+ const stats = this.tools.stats();
1119
+ return {
1120
+ cols,
1121
+ phaseLabel: this.workflowStatus ? `${this.workflowStatus.skill}:${this.workflowStatus.phase}` : this.hudPhase,
1122
+ spinner: verifying && this.theme.color ? chalk.yellow(this.spinner.current()) : this.spinner.current(),
1123
+ activity: this.retryNotice ?? (this.streamingActivity || this.currentActivity()),
1124
+ escHint: true,
1125
+ elapsedMs,
1126
+ stepElapsedMs: this.currentStepStartedAt ? Date.now() - this.currentStepStartedAt : undefined,
1127
+ avgStepMs: stepNow > 0 ? elapsedMs / stepNow : undefined,
1128
+ okCount: stats.ok,
1129
+ failCount: stats.fail,
1130
+ runningCount: stats.running,
1131
+ totalCount: stats.total,
1132
+ mutationGuarded: this.mutationGuarded,
1133
+ unicode: this.unicode,
1134
+ color: this.theme.color,
1135
+ colorLevel,
1136
+ phase,
1137
+ palette: verifying ? [...STATUS_VERIFY_PALETTE] : [grad.from, grad.to],
1138
+ isThinking: true,
1139
+ usage: this.turnUsage,
1140
+ costUsd: costForUsage(this.footer.model, this.turnUsage) ?? undefined,
1141
+ subagentActive: this.subagentActive,
1142
+ };
1143
+ }
1108
1144
  /**
1109
1145
  * The gjc-style inline live frame: a flat stack with no outer border —
1110
1146
  * <live forge card(s)> · <spinner status line> · <todos> · <hud line> · <model bar>
@@ -1178,37 +1214,7 @@ export class LaunchTui {
1178
1214
  // streamed activity is uniform across providers via streamingActivity and keeps
1179
1215
  // the ⟦esc⟧ cancel hint visible without trapping the message inside a border.
1180
1216
  if (isThinking) {
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();
1186
- const costUsd = costForUsage(this.footer.model, this.turnUsage) ?? undefined;
1187
- const stats = this.tools.stats();
1188
- tail.push(...renderStatusBox({
1189
- cols: Math.max(24, Math.min(120, cols)),
1190
- phaseLabel: this.workflowStatus ? `${this.workflowStatus.skill}:${this.workflowStatus.phase}` : this.hudPhase,
1191
- spinner: verifySpin,
1192
- activity: this.retryNotice ?? (this.streamingActivity || this.currentActivity()),
1193
- escHint: true,
1194
- elapsedMs,
1195
- stepElapsedMs: this.currentStepStartedAt ? Date.now() - this.currentStepStartedAt : undefined,
1196
- avgStepMs: stepNow > 0 ? elapsedMs / stepNow : undefined,
1197
- okCount: stats.ok,
1198
- failCount: stats.fail,
1199
- runningCount: stats.running,
1200
- totalCount: stats.total,
1201
- mutationGuarded: this.mutationGuarded,
1202
- unicode: this.unicode,
1203
- color: this.theme.color,
1204
- colorLevel,
1205
- phase,
1206
- palette: verifying ? [...STATUS_VERIFY_PALETTE] : [grad.from, grad.to],
1207
- isThinking: true,
1208
- usage: this.turnUsage,
1209
- costUsd,
1210
- subagentActive: this.subagentActive,
1211
- }));
1217
+ tail.push(...renderStatusBox(this.statusBoxData({ cols: Math.max(24, Math.min(120, cols)), elapsedMs, stepNow, phase, colorLevel, idx })));
1212
1218
  }
1213
1219
 
1214
1220
 
@@ -1365,31 +1371,7 @@ export class LaunchTui {
1365
1371
  // Live status field: unboxed thinking line + compact metrics row. The
1366
1372
  // streamed activity is uniform across providers, with the ⟦esc⟧ cancel hint
1367
1373
  // right-aligned and no misleading step counter.
1368
- const stats = this.tools.stats();
1369
- for (const line of renderStatusBox({
1370
- cols: innerWidth,
1371
- phaseLabel: this.workflowStatus ? `${this.workflowStatus.skill}:${this.workflowStatus.phase}` : this.hudPhase,
1372
- spinner: this.runningTool && this.theme.color ? chalk.yellow(this.spinner.current()) : this.spinner.current(),
1373
- activity: this.retryNotice ?? (this.streamingActivity || statusMsg),
1374
- escHint: true,
1375
- elapsedMs,
1376
- stepElapsedMs: this.currentStepStartedAt ? Date.now() - this.currentStepStartedAt : undefined,
1377
- avgStepMs: stepNow > 0 ? elapsedMs / stepNow : undefined,
1378
- okCount: stats.ok,
1379
- failCount: stats.fail,
1380
- runningCount: stats.running,
1381
- totalCount: stats.total,
1382
- mutationGuarded: this.mutationGuarded,
1383
- unicode: this.unicode,
1384
- color: this.theme.color,
1385
- colorLevel,
1386
- phase,
1387
- palette: this.runningTool ? [...STATUS_VERIFY_PALETTE] : palette,
1388
- isThinking: true,
1389
- usage: this.turnUsage,
1390
- costUsd,
1391
- subagentActive: this.subagentActive,
1392
- })) bottom.push(line);
1374
+ for (const line of renderStatusBox(this.statusBoxData({ cols: innerWidth, elapsedMs, stepNow, phase, colorLevel, idx }))) bottom.push(line);
1393
1375
  } else {
1394
1376
  // Compact fallback still keeps progress and insight separate: no decorative
1395
1377
  // mixed "thinking/status" line, and retry notices never become stream logs.
@@ -133,11 +133,6 @@ export function complete(line: string, ctx: CompletionContext): CompletionResult
133
133
  // Completing the command name itself (single token, still typing it).
134
134
  if (tokens.length <= 1 && !trailingSpace) {
135
135
  const token = tokens[0] ?? "/";
136
- if (token.toLowerCase().startsWith("/skill:")) {
137
- const prefix = token.slice("/skill:".length);
138
- const names = ctx.skillNames ?? skillNames();
139
- return { completions: dedupeCap(prefixHits(names.map(n => `/skill:${n}`), `/skill:${prefix}`)), token, kind: "command" };
140
- }
141
136
  return { completions: dedupeCap(prefixHits(ctx.slashCommands, token)), token, kind: "command" };
142
137
  }
143
138
 
@@ -183,8 +178,7 @@ export function complete(line: string, ctx: CompletionContext): CompletionResult
183
178
  if (argIndex === 2 && (tokens[2]?.toLowerCase() === "maxsteps" || tokens[2]?.toLowerCase() === "steps")) return { completions: [], token, kind: "none" };
184
179
  return { completions: [], token, kind: "none" };
185
180
  }
186
- case "/skill":
187
- return argIndex === 0 ? finish(ctx.skillNames ?? skillNames(), "subcommand") : { completions: [], token, kind: "none" };
181
+
188
182
  case "/roles": {
189
183
  const tiers = ["smol", "slow", "plan"];
190
184
  if (argIndex === 0) return finish(tiers, "role");
@@ -194,7 +188,7 @@ export function complete(line: string, ctx: CompletionContext): CompletionResult
194
188
  case "/thinking":
195
189
  return argIndex === 0 ? finish(ctx.thinkingLevels, "thinking") : { completions: [], token, kind: "none" };
196
190
  case "/session":
197
- return argIndex === 0 ? finish(["info", "delete"], "subcommand") : { completions: [], token, kind: "none" };
191
+ return argIndex === 0 ? finish(["list", "info", "new", "drop", "delete", "rename", "resume"], "subcommand") : { completions: [], token, kind: "none" };
198
192
  case "/theme":
199
193
  return argIndex === 0 ? finish(listThemes().map(t => t.name), "subcommand") : { completions: [], token, kind: "none" };
200
194
  case "/login":
@@ -46,8 +46,7 @@ export const SLASH_COMMAND_DETAILS: readonly SlashCommandInfo[] = [
46
46
  { command: "/diff", usage: "/diff [file]", description: "Render `git diff` with +/- coloring", group: "code" },
47
47
  { command: "/find", usage: "/find <glob>", description: "List files matching a glob", group: "code" },
48
48
  { command: "/search", usage: "/search <pat> [glob]", description: "Search the repo for a pattern", group: "code" },
49
- { command: "/skill", usage: "/skill [name [intent]]", description: "List, show, or run a workflow skill", group: "skills" },
50
- { command: "/skill:", usage: "/skill:<name> [intent]", description: "Run a workflow skill by GJC-style entrypoint", group: "skills" },
49
+
51
50
  { command: "/sessions", usage: "/sessions", description: "List saved sessions", group: "session" },
52
51
  { command: "/usage", usage: "/usage", description: "Show cumulative token usage for this session", group: "system" },
53
52
  { command: "/context", usage: "/context", description: "Show context token usage breakdown", group: "system" },
@@ -27,6 +27,9 @@ export interface ChangelogSection {
27
27
  groups: ChangelogGroup[];
28
28
  }
29
29
 
30
+ /** Default number of recent releases surfaced as "update news" (whats-new default, post-upgrade notice). Use --all for the full history. */
31
+ export const RECENT_RELEASE_COUNT = 5;
32
+
30
33
  const HEADER = /^##\s+\[([^\]]+)\](?:\s*-\s*(\S+))?\s*$/;
31
34
  const SUBHEADER = /^###\s+(.+?)\s*$/;
32
35
  const BULLET = /^[-*]\s+(.+)$/;
@@ -266,7 +269,7 @@ export async function consumeLaunchWhatsNew(opts?: WhatsNewRenderOpts): Promise<
266
269
  const md = await loadBundledChangelog();
267
270
  await writeLastSeenVersion(current);
268
271
  if (!md) return null;
269
- const sections = selectNewSections(parseChangelogSections(md), lastSeen, current);
272
+ const sections = selectNewSections(parseChangelogSections(md), lastSeen, current).slice(0, RECENT_RELEASE_COUNT);
270
273
  if (sections.length === 0) return null;
271
274
  return renderWhatsNew(sections, opts);
272
275
  }