jeo-code 0.6.1 → 0.6.3

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 CHANGED
@@ -6,6 +6,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6
6
 
7
7
  The README mirrors the latest 5 entries — regenerate with `bun run changelog:sync`.
8
8
 
9
+ ## [0.6.3] - 2026-06-16
10
+ _OAuth loopback reliability fix._
11
+
12
+ ### Fixed
13
+ - **OAuth loopback redirect uses `127.0.0.1` instead of `localhost`** (RFC 8252 §7.3). `localhost` can resolve to IPv6 `::1` or be hosts-file-overridden, intermittently breaking the auth callback; the IP literal is reliable. Only the dynamic-loopback path changes — providers with a fixed redirect URI are unaffected (#30).
14
+
15
+ ## [0.6.2] - 2026-06-16
16
+ _Interactive `/provider` picker, clearer animated status + labeled block/prose boundaries, and a transient empty-response retry._
17
+
18
+ ### Added
19
+ - **Interactive `/provider` picker** (gjc-style) with a clean screen after login (#26).
20
+ - **Clearer visual structure** — unified labeled boundaries for thinking / reasoning / output blocks and a dimensional animated status line (#29, building on the labeled-boundary work).
21
+ - `docs/minimo/` — a plan to apply MiMo Code's memory & goal-management ideas to jeo (#28).
22
+
23
+ ### Fixed
24
+ - **Retry transient empty-200 responses** (a 200 with an empty body) for stability — gjc parity (#27).
25
+
9
26
  ## [0.6.1] - 2026-06-16
10
27
  _Live reasoning progress (no more frozen "calling model"), thinking-level fixes for Anthropic/Antigravity, and input-box/Ctrl+O TUI fixes._
11
28
 
package/README.ja.md CHANGED
@@ -158,11 +158,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
158
158
  ## 変更履歴 (Changelog)
159
159
 
160
160
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
161
+ - **[0.6.3]** (2026-06-16) — OAuth loopback reliability fix.
162
+ - **[0.6.2]** (2026-06-16) — Interactive `/provider` picker, clearer animated status + labeled block/prose boundaries, and a transient empty-response retry.
161
163
  - **[0.6.1]** (2026-06-16) — Live reasoning progress (no more frozen "calling model"), thinking-level fixes for Anthropic/Antigravity, and input-box/Ctrl+O TUI fixes.
162
164
  - **[0.6.0]** (2026-06-16) — TUI quality of life: durable input history (↑ recalls past queries across launches), clean `/resume` rendering, and a scrollable mid-turn Ctrl+O panel.
163
165
  - **[0.5.16]** (2026-06-16) — `/resume` and Ctrl+O no longer corrupt the TUI — clean screen restore + scrollback expand.
164
- - **[0.5.15]** (2026-06-16) — `jeo update` now actually upgrades — bare command installs the latest release instead of just printing a manual command.
165
- - **[0.5.14]** (2026-06-16) — `jeo --tmux` live-verification harness — repeatable stability + behavior checks.
166
166
 
167
167
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
168
168
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -158,11 +158,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
158
158
  ## 변경 이력 (Changelog)
159
159
 
160
160
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
161
+ - **[0.6.3]** (2026-06-16) — OAuth loopback reliability fix.
162
+ - **[0.6.2]** (2026-06-16) — Interactive `/provider` picker, clearer animated status + labeled block/prose boundaries, and a transient empty-response retry.
161
163
  - **[0.6.1]** (2026-06-16) — Live reasoning progress (no more frozen "calling model"), thinking-level fixes for Anthropic/Antigravity, and input-box/Ctrl+O TUI fixes.
162
164
  - **[0.6.0]** (2026-06-16) — TUI quality of life: durable input history (↑ recalls past queries across launches), clean `/resume` rendering, and a scrollable mid-turn Ctrl+O panel.
163
165
  - **[0.5.16]** (2026-06-16) — `/resume` and Ctrl+O no longer corrupt the TUI — clean screen restore + scrollback expand.
164
- - **[0.5.15]** (2026-06-16) — `jeo update` now actually upgrades — bare command installs the latest release instead of just printing a manual command.
165
- - **[0.5.14]** (2026-06-16) — `jeo --tmux` live-verification harness — repeatable stability + behavior checks.
166
166
 
167
167
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
168
168
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -158,11 +158,11 @@ Required npm token permissions (repository secret `NPM_TOKEN`):
158
158
  ## Changelog
159
159
 
160
160
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
161
+ - **[0.6.3]** (2026-06-16) — OAuth loopback reliability fix.
162
+ - **[0.6.2]** (2026-06-16) — Interactive `/provider` picker, clearer animated status + labeled block/prose boundaries, and a transient empty-response retry.
161
163
  - **[0.6.1]** (2026-06-16) — Live reasoning progress (no more frozen "calling model"), thinking-level fixes for Anthropic/Antigravity, and input-box/Ctrl+O TUI fixes.
162
164
  - **[0.6.0]** (2026-06-16) — TUI quality of life: durable input history (↑ recalls past queries across launches), clean `/resume` rendering, and a scrollable mid-turn Ctrl+O panel.
163
165
  - **[0.5.16]** (2026-06-16) — `/resume` and Ctrl+O no longer corrupt the TUI — clean screen restore + scrollback expand.
164
- - **[0.5.15]** (2026-06-16) — `jeo update` now actually upgrades — bare command installs the latest release instead of just printing a manual command.
165
- - **[0.5.14]** (2026-06-16) — `jeo --tmux` live-verification harness — repeatable stability + behavior checks.
166
166
 
167
167
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
168
168
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -158,11 +158,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
158
158
  ## 更新日志 (Changelog)
159
159
 
160
160
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
161
+ - **[0.6.3]** (2026-06-16) — OAuth loopback reliability fix.
162
+ - **[0.6.2]** (2026-06-16) — Interactive `/provider` picker, clearer animated status + labeled block/prose boundaries, and a transient empty-response retry.
161
163
  - **[0.6.1]** (2026-06-16) — Live reasoning progress (no more frozen "calling model"), thinking-level fixes for Anthropic/Antigravity, and input-box/Ctrl+O TUI fixes.
162
164
  - **[0.6.0]** (2026-06-16) — TUI quality of life: durable input history (↑ recalls past queries across launches), clean `/resume` rendering, and a scrollable mid-turn Ctrl+O panel.
163
165
  - **[0.5.16]** (2026-06-16) — `/resume` and Ctrl+O no longer corrupt the TUI — clean screen restore + scrollback expand.
164
- - **[0.5.15]** (2026-06-16) — `jeo update` now actually upgrades — bare command installs the latest release instead of just printing a manual command.
165
- - **[0.5.14]** (2026-06-16) — `jeo --tmux` live-verification harness — repeatable stability + behavior checks.
166
166
 
167
167
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
168
168
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -11,7 +11,7 @@ import type { OAuthController, OAuthCredentials } from "./types";
11
11
  import { generateState } from "./pkce";
12
12
 
13
13
  const DEFAULT_TIMEOUT_MS = 300_000;
14
- const DEFAULT_HOSTNAME = "localhost";
14
+ const DEFAULT_HOSTNAME = "127.0.0.1";
15
15
  const DEFAULT_CALLBACK_PATH = "/callback";
16
16
 
17
17
  export interface OAuthCallbackFlowOptions {
@@ -45,6 +45,7 @@ import { SelectList, renderSelectList, type SelectItem } from "../tui/components
45
45
  import {
46
46
  formatModelLine,
47
47
  formatProviderPanel,
48
+ emitLoginCleanup,
48
49
  formatAgentsPanel,
49
50
  formatAgentDetail,
50
51
  formatConfigPanel,
@@ -55,7 +56,7 @@ import {
55
56
  } from "../tui/components/config-panel";
56
57
  import { liveModelPicker, renderLiveModelPicker, type ModelAssignmentBadge } from "../tui/components/live-model-picker";
57
58
 
58
- import { providerPicker, renderProviderPicker } from "../tui/components/provider-picker";
59
+ import { providerPicker, renderProviderPicker, buildProviderChoices } from "../tui/components/provider-picker";
59
60
  import { detectLanguage, languageLabel, parseLineRange, sliceLines, formatCodeBlock, formatDiff, sanitizeForTerminal } from "../tui/components/code-view";
60
61
  import { categoryBadge } from "../tui/components/category-index";
61
62
  import { renderInputFrame } from "../tui/components/input-box";
@@ -3631,7 +3632,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3631
3632
  }
3632
3633
  if (input.startsWith("/provider") && (input === "/provider" || input[9] === " ")) {
3633
3634
  const tokens = input.substring(9).trim().split(/\s+/).filter(Boolean);
3634
- const name = (tokens[0] ?? "").toLowerCase();
3635
+ let name = (tokens[0] ?? "").toLowerCase();
3635
3636
  const explicitModel = tokens[1];
3636
3637
  // `/provider login|auth [name]` → run OAuth login from the REPL.
3637
3638
  if (name === "login" || name === "auth") {
@@ -3660,20 +3661,34 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3660
3661
  console.log(`Starting OAuth login for ${target}…`);
3661
3662
  try {
3662
3663
  const { email } = await interactiveOAuthLogin(target as AuthProvider, rl);
3663
- console.log(`[SUCCESS] OAuth login complete for ${target}${email ? ` (${email})` : ""}. Tokens saved to ~/.jeo/config.json.`);
3664
+ // Tidy back to the initial query-input screen: the OAuth flow printed
3665
+ // browser prompts / "waiting…" lines that clutter scrollback. Clear the
3666
+ // screen + scrollback and re-render the welcome (same path as /clear),
3667
+ // then a single concise confirmation. lastPickIndex is still seeded so
3668
+ // `/model #N` works; the verbose live-model dump is dropped (it cluttered).
3664
3669
  const live = await refreshLiveModelsCache();
3665
3670
  const after = (await describeAllProviders()).find(s => s.name === target);
3666
- if (after) console.log(` status → ${after.name}: ${after.ready ? `✓ ${after.label}` : after.label}`);
3667
3671
  const forProvider = live.filter(r => r.provider === target);
3668
- if (forProvider.some(r => r.ok && r.models.length > 0)) {
3669
- lastPickIndex = flattenModels(forProvider);
3670
- const viaCatalog = forProvider.some(r => r.fallback);
3671
- console.log(` ${viaCatalog ? "catalog" : "live"} ${target} models /model #N or /provider ${target} #N${viaCatalog ? " (live list endpoint rejected this token; showing known models)" : ""}`);
3672
- logLines(formatPickListWithCapabilities(lastPickIndex, { cap: 12 }));
3673
- } else {
3674
- const failed = forProvider.find(r => !r.ok);
3675
- if (failed?.error) console.log(` live ${target} models unavailable: ${failed.error}`);
3676
- }
3672
+ if (forProvider.some(r => r.ok && r.models.length > 0)) lastPickIndex = flattenModels(forProvider);
3673
+ // Tidy back to the initial query-input screen: the OAuth flow printed
3674
+ // browser prompts / "waiting…" lines that clutter scrollback. emitLoginCleanup
3675
+ // clears + re-renders the welcome (same path as /clear) then prints one
3676
+ // confirmation; the verbose live-model dump is dropped (lastPickIndex is
3677
+ // still seeded so /model #N works). Orchestration is unit-tested.
3678
+ emitLoginCleanup(
3679
+ {
3680
+ clear: () => { disarmPreview(); process.stdout.write("\x1b[2J\x1b[3J\x1b[H"); },
3681
+ write: line => console.log(line),
3682
+ },
3683
+ {
3684
+ isTty: process.stdout.isTTY === true,
3685
+ provider: target,
3686
+ email,
3687
+ ready: after?.ready ?? false,
3688
+ label: after?.label,
3689
+ welcomeLines: renderWelcome(welcomeData),
3690
+ },
3691
+ );
3677
3692
  } catch (err) {
3678
3693
  console.log(`[FAILED] ${(err as Error).message} — or set ${target.toUpperCase()}_API_KEY.`);
3679
3694
  }
@@ -3682,10 +3697,25 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3682
3697
  const cfgNow = await readGlobalConfig();
3683
3698
  const statuses = await describeAllProviders(cfgNow);
3684
3699
  if (!name) {
3685
- console.log("Providers (credential · base URL):");
3686
- logLines(formatProviderPanel(statuses));
3687
- console.log("Switch with: /provider <name> [model] · arrows+Enter picker: /provider <name> · choose models: /model");
3688
- continue;
3700
+ if (process.stdin.isTTY && process.stdout.isTTY) {
3701
+ // gjc-style interactive picker (grouped ready / needs-setup, current marked)
3702
+ // instead of a static text dump — arrows + Enter switch, Esc cancels.
3703
+ const current = (await describeModel(sessionModel || cfgNow.defaultModel, cfgNow)).provider;
3704
+ const items = buildProviderChoices(statuses, true, current).map(c => ({
3705
+ value: c.value as string,
3706
+ label: c.label,
3707
+ hint: c.hint,
3708
+ group: c.group,
3709
+ }));
3710
+ const picked = await pickFromOptions("Select a provider ↑↓ move · Enter switch · Esc cancel", items);
3711
+ if (!picked) { console.log("(switch cancelled)"); continue; }
3712
+ name = picked.toLowerCase(); // fall through to the switch logic below
3713
+ } else {
3714
+ console.log("Providers (credential · base URL):");
3715
+ logLines(formatProviderPanel(statuses));
3716
+ console.log("Switch with: /provider <name> [model] · choose models: /model");
3717
+ continue;
3718
+ }
3689
3719
  }
3690
3720
  if (!isProviderName(name)) {
3691
3721
  console.log(`Unknown provider '${name}'. Known: ${statuses.map(s => s.name).join(", ")}.`);
package/src/tui/app.ts CHANGED
@@ -21,7 +21,7 @@ import { evolutionTrack, createStageProgress, type StageProgress, transitionMess
21
21
  import type { TaskSubEvent } from "../agent/task-tool";
22
22
  import { supportsUnicode } from "./components/capability";
23
23
  import { centerBlock, padLineTo, boxBlock, BOX_ASCII, BOX_UNICODE } from "./components/layout";
24
- import { SECTION_GAP, stackSections } from "./components/section";
24
+ import { SECTION_GAP, sectionLabel, 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";
@@ -414,12 +414,21 @@ export class LaunchTui {
414
414
  this.thinking = false; // model replied; now dispatching the tool
415
415
  this.retryNotice = null; // the call got through — clear any backoff notice
416
416
  // Flush the streamed reasoning once into scrollback as a jeo-ref reasoning
417
- // block — the agent NAME on its own accent line, the prose below it (the
418
- // durable record) — then stop showing the transient live reasoning row.
417
+ // block — a muted "Reasoning" divider header, the prose below it (the durable
418
+ // record) — then stop showing the transient live reasoning row.
419
419
  if (this.streamingReasoning && this.streamingReasoning !== this.flushedReasoning) {
420
420
  this.flushedReasoning = this.streamingReasoning;
421
- const name = this.theme.color ? chalk.bold(accentPaint(this.theme)("jeo")) : "jeo";
422
- this.appendLedger(`${name}\n${this.streamingReasoning}\n`, "reasoning");
421
+ // A muted "Reasoning" card-header divider announces the reasoning block
422
+ // boundary (consistent with the section design tokens — Thinking/Reasoning/
423
+ // Output share one visual language); the prose below it is the durable record.
424
+ // A full-width divider ROW respects appendLedger's 1-line=1-row pre-wrap
425
+ // invariant (no per-line prefix), unlike a left-border enclosure which would
426
+ // push wrapped lines past `cols` and tear the frame.
427
+ const header = sectionLabel("Reasoning", Math.max(20, size().cols), {
428
+ color: this.theme.color,
429
+ unicode: this.unicode,
430
+ });
431
+ this.appendLedger(`${header}\n${this.streamingReasoning}\n`, "reasoning");
423
432
  }
424
433
  this.streamingReasoning = "";
425
434
  this.streamingThought = "";
@@ -1236,7 +1245,7 @@ export class LaunchTui {
1236
1245
  // (duplicate model bar) is gone; height now toggles only at lifecycle boundaries.
1237
1246
  const ROWS = 6;
1238
1247
  const shown = wrapped.slice(-ROWS);
1239
- tail.push(dim(`${this.unicode ? "│" : "|"} thinking`));
1248
+ tail.push(sectionLabel("Thinking", Math.max(8, Math.min(120, cols)), { color: this.theme.color, unicode: this.unicode }));
1240
1249
  for (let k = 0; k < ROWS - shown.length; k++) tail.push("");
1241
1250
  for (const l of shown) tail.push(dim(` ${l}`));
1242
1251
  tail.push("");
@@ -1255,7 +1264,7 @@ export class LaunchTui {
1255
1264
  // so cumulative stdout growth does not thrash the frame height.
1256
1265
  const ROWS = 8;
1257
1266
  const shown = wrapped.slice(-ROWS);
1258
- tail.push(dim(`${this.unicode ? "│" : "|"} output`));
1267
+ tail.push(sectionLabel("Output", Math.max(8, Math.min(120, cols)), { color: this.theme.color, unicode: this.unicode }));
1259
1268
  for (let k = 0; k < ROWS - shown.length; k++) tail.push("");
1260
1269
  for (const l of shown) tail.push(dim(` ${l}`));
1261
1270
  tail.push("");
@@ -46,6 +46,35 @@ export function formatProviderPanel(statuses: ProviderStatus[]): string[] {
46
46
  });
47
47
  }
48
48
 
49
+ /** Side-effect sinks for {@link emitLoginCleanup} — injected so the orchestration
50
+ * (ordering + conditionals + the confirmation string) is unit-testable without a
51
+ * real terminal. The caller wires `clear` to the actual screen+scrollback escape. */
52
+ export interface LoginCleanupIO {
53
+ clear: () => void;
54
+ write: (line: string) => void;
55
+ }
56
+ export interface LoginCleanupOpts {
57
+ isTty: boolean;
58
+ provider: string;
59
+ email?: string;
60
+ ready: boolean;
61
+ label?: string;
62
+ welcomeLines: string[];
63
+ }
64
+ /** Tidy the screen after a successful `/provider login`: on a TTY clear the screen
65
+ * and re-render the welcome (drops the OAuth flow's browser/"waiting…" noise — the
66
+ * same path `/clear` uses), then print ONE confirmation line; off a TTY just the
67
+ * confirmation. Pure orchestration over the injected IO. */
68
+ export function emitLoginCleanup(io: LoginCleanupIO, opts: LoginCleanupOpts): void {
69
+ if (opts.isTty) {
70
+ io.clear();
71
+ io.write(opts.welcomeLines.join("\n"));
72
+ }
73
+ const who = opts.email ? ` (${opts.email})` : "";
74
+ const status = !opts.ready && opts.label ? ` — ${opts.label}` : "";
75
+ io.write(`✓ Logged in to ${opts.provider}${who}${status}. Pick a model with /model.`);
76
+ }
77
+
49
78
  /** Subagent roster: ` id title — model · thinking ≤N steps (read-only)`. */
50
79
  export function formatAgentsPanel(
51
80
  roles: readonly SubagentRole[],
@@ -1,3 +1,4 @@
1
+ import { ColorLevel, hexToRgb, lerpColor, fgEscape, bgEscape, resetEscape, stripAnsi } from "./color";
1
2
  import chalk from "chalk";
2
3
 
3
4
  /**
@@ -33,10 +34,10 @@ export const EVOLUTION_STAGE_COLORS: readonly ((s: string) => string)[] = [
33
34
  /** Spinner frame sets, one per evolution stage. */
34
35
  export const EVOLUTION_SPINNER_FRAMES: readonly string[][] = [
35
36
  [". ", ".. ", "... ", "....", "... ", ".. "],
36
- ["\u2801", "\u2802", "\u2804", "\u2808", "\u2810", "\u2820"],
37
- ["|", "/", "-", "\\"],
37
+ ["\u2801", "\u2803", "\u2807", "\u2827", "\u2837", "\u283f", "\u283e", "\u283d", "\u283b", "\u2819", "\u2818", "\u2810"],
38
+ ["", "", "", ""],
38
39
  ["\u280b", "\u2819", "\u2839", "\u2838", "\u283c", "\u2834", "\u2826", "\u2827", "\u2807", "\u280f"],
39
- ["\u25dc", "\u25dd", "\u25de", "\u25df"],
40
+ ["\u25f0", "\u25f3", "\u25f2", "\u25f1"],
40
41
  ];
41
42
 
42
43
  /**
@@ -303,3 +304,81 @@ export const EVOLUTION_TRANSITION_MESSAGES: readonly string[] = [
303
304
  export function transitionMessage(index: number): string {
304
305
  return EVOLUTION_TRANSITION_MESSAGES[clampStageIndex(index)]!;
305
306
  }
307
+ /**
308
+ * Applies a dimensional, animated gradient (foreground or background) to a string of text.
309
+ * Driven by a phaseMs timestamp, it creates a slow, tasteful shimmer wave
310
+ * and adds depth cues (bright core, dimmer edges).
311
+ */
312
+ export function applyDimensionalGradient(
313
+ text: string,
314
+ timeMs: number,
315
+ fromHex: string,
316
+ toHex: string,
317
+ level: ColorLevel,
318
+ isBg: boolean,
319
+ fgHex: string = "#ebebeb"
320
+ ): string {
321
+ const plain = stripAnsi(text);
322
+ if (level === ColorLevel.None || plain.length === 0) return plain;
323
+
324
+ const from = hexToRgb(fromHex);
325
+ const to = hexToRgb(toHex);
326
+ const fg = hexToRgb(fgHex);
327
+
328
+ const L = plain.length;
329
+ let out = "";
330
+ if (isBg) {
331
+ out += fgEscape(fg, level);
332
+ }
333
+
334
+ // Slow period: 4.5 seconds for a complete wave cycle
335
+ const p = (timeMs / 4500) % 1;
336
+
337
+ for (let i = 0; i < L; i++) {
338
+ const ch = plain[i]!;
339
+ if (!isBg && ch === " ") {
340
+ out += ch;
341
+ continue;
342
+ }
343
+
344
+ const x = L > 1 ? i / (L - 1) : 0;
345
+ const base = lerpColor(from, to, x);
346
+
347
+ // 3D depth cue: bright center (1.0), dimmer edges (0.75)
348
+ // Using a parabolic/sin curve
349
+ const depth = 0.75 + 0.25 * Math.sin(x * Math.PI);
350
+
351
+ // Shimmer highlight wave
352
+ // Shortest circular distance between position x and wave phase p
353
+ let dist = Math.abs(x - p);
354
+ if (dist > 0.5) dist = 1 - dist;
355
+
356
+ const waveWidth = 0.22;
357
+ const highlight = dist < waveWidth ? Math.cos((dist / waveWidth) * (Math.PI / 2)) : 0;
358
+
359
+ // Apply depth
360
+ let r = base.r * depth;
361
+ let g = base.g * depth;
362
+ let b = base.b * depth;
363
+
364
+ // Apply shimmer highlight (blending towards white)
365
+ const shimmerIntensity = 0.45;
366
+ r += (255 - r) * highlight * shimmerIntensity;
367
+ g += (255 - g) * highlight * shimmerIntensity;
368
+ b += (255 - b) * highlight * shimmerIntensity;
369
+
370
+ const rgb = {
371
+ r: Math.max(0, Math.min(255, Math.round(r))),
372
+ g: Math.max(0, Math.min(255, Math.round(g))),
373
+ b: Math.max(0, Math.min(255, Math.round(b))),
374
+ };
375
+
376
+ if (isBg) {
377
+ out += bgEscape(rgb, level) + ch;
378
+ } else {
379
+ out += fgEscape(rgb, level) + ch;
380
+ }
381
+ }
382
+
383
+ return out + resetEscape(level);
384
+ }
@@ -419,8 +419,8 @@ export function summarizeForgeResult(tool: string, success: boolean, output: str
419
419
  }
420
420
  }
421
421
  const lines = previewLines(body, success ? 5 : 10, success ? 600 : 1200);
422
+ lines.unshift(forgeDivider("Output"));
422
423
  if (normalized === "bash") {
423
- lines.unshift(forgeDivider("Output"));
424
424
  if (exitNote) lines.push("", exitNote);
425
425
  }
426
426
  return {
@@ -74,33 +74,85 @@ export function renderMarkdownAnsi(text: string, opts: MarkdownAnsiOptions = {})
74
74
 
75
75
  const out: string[] = [];
76
76
  let inFence = false;
77
+
78
+ // Track the type of the last pushed content line to manage rhythm
79
+ // Types: "empty" | "heading" | "blockquote" | "list" | "code" | "prose"
80
+ let lastType: "empty" | "heading" | "blockquote" | "list" | "code" | "prose" = "empty";
81
+
82
+ const ensureBlankLine = () => {
83
+ if (out.length > 0 && out[out.length - 1] !== "") {
84
+ out.push("");
85
+ }
86
+ };
87
+
77
88
  for (const line of text.split("\n")) {
78
- if (/^```/.test(line.trim())) {
89
+ const trimmed = line.trim();
90
+
91
+ // Check if we are toggling a code fence
92
+ if (/^```/.test(trimmed)) {
93
+ ensureBlankLine();
79
94
  inFence = !inFence;
80
- continue; // drop the fence row itself (stripMarkdown parity)
95
+ lastType = inFence ? "code" : "empty";
96
+ continue;
81
97
  }
98
+
82
99
  if (inFence) {
83
- out.push(line); // code bodies verbatim — never styled or reflowed
100
+ out.push(line); // code bodies verbatim
101
+ lastType = "code";
84
102
  continue;
85
103
  }
104
+
105
+ if (trimmed === "") {
106
+ ensureBlankLine();
107
+ lastType = "empty";
108
+ continue;
109
+ }
110
+
86
111
  const heading = line.match(/^(#{1,6})\s+(.+)$/);
87
112
  if (heading) {
88
- // Vertical rhythm: a heading that follows content gets one blank line of
89
- // breathing room above it (final-report readability), never a leading blank.
90
- if (out.length > 0 && out[out.length - 1]!.trim() !== "") out.push("");
113
+ ensureBlankLine();
91
114
  out.push(accent(styleInline(heading[2]!)));
115
+ lastType = "heading";
92
116
  continue;
93
117
  }
118
+
94
119
  const quote = line.match(/^>\s+(.+)$/);
95
120
  if (quote) {
121
+ if (lastType !== "blockquote" && lastType !== "empty") {
122
+ ensureBlankLine();
123
+ }
96
124
  out.push(chalk.dim(`▎ ${styleInline(quote[1]!)}`));
125
+ lastType = "blockquote";
97
126
  continue;
98
127
  }
128
+
99
129
  if (/^[-\*_]{3,}\s*$/.test(line)) {
130
+ ensureBlankLine();
100
131
  out.push(chalk.dim("─".repeat(24)));
132
+ ensureBlankLine();
133
+ lastType = "empty";
134
+ continue;
135
+ }
136
+
137
+ // Check if list item
138
+ const isList = /^\s*([*\-+]\s|\d+\.\s)/.test(line);
139
+ if (isList) {
140
+ if (lastType !== "list" && lastType !== "empty") {
141
+ ensureBlankLine();
142
+ }
143
+ out.push(styleInline(line));
144
+ lastType = "list";
101
145
  continue;
102
146
  }
147
+
148
+ // Default prose line
149
+ if (lastType !== "prose" && lastType !== "empty" && lastType !== "list" && lastType !== "heading") {
150
+ // Transitioning from list/quote/code to prose -> ensure blank line
151
+ ensureBlankLine();
152
+ }
103
153
  out.push(styleInline(line));
154
+ lastType = "prose";
104
155
  }
156
+
105
157
  return out.join("\n").trim();
106
158
  }
@@ -16,14 +16,16 @@ export function providerHint(s: ProviderStatus, unicode = true): string {
16
16
  return parts.join(" \u00b7 ");
17
17
  }
18
18
 
19
- /** Build provider choices, ready providers first (stable within each group). */
20
- export function buildProviderChoices(statuses: ProviderStatus[], unicode = true): SelectItem<ProviderName>[] {
19
+ /** Build provider choices, ready providers first (stable within each group). When
20
+ * `current` is given, that provider's row is marked `· ● current` so the active
21
+ * provider is obvious in the picker (gjc-style). */
22
+ export function buildProviderChoices(statuses: ProviderStatus[], unicode = true, current?: ProviderName): SelectItem<ProviderName>[] {
21
23
  const sorted = [...statuses].sort((a, b) => (a.ready === b.ready ? 0 : a.ready ? -1 : 1));
22
24
  return sorted.map(s => ({
23
25
  value: s.name,
24
26
  label: `${s.name} (${companyLabel(s.name)})`,
25
27
  group: s.ready ? "ready" : "needs setup",
26
- hint: providerHint(s, unicode),
28
+ hint: s.name === current ? `${providerHint(s, unicode)} · ${unicode ? "●" : "*"} current` : providerHint(s, unicode),
27
29
  }));
28
30
  }
29
31
 
@@ -29,7 +29,10 @@ export interface SectionLabelOpts {
29
29
  export function sectionLabel(title: string, width: number, opts: SectionLabelOpts = {}): string {
30
30
  const unicode = opts.unicode !== false;
31
31
  const dash = unicode ? "─" : "-";
32
- const head = `${dash.repeat(2)} ${title.trim()} `;
32
+ // A 3-dash lead-in makes the card header read as a distinct boundary (stronger than a
33
+ // single rule) without becoming loud.
34
+ const startDash = unicode ? "───" : "---";
35
+ const head = `${startDash} ${title.trim()} `;
33
36
  const headW = visibleWidth(head);
34
37
  const fill = Math.max(0, Math.trunc(width) - headW);
35
38
  const line = head + dash.repeat(fill);
@@ -59,12 +62,23 @@ export function stackSections(sections: Section[], opts: StackOptions): string[]
59
62
  const gap = Math.max(0, opts.gap ?? SECTION_GAP);
60
63
  const out: string[] = [];
61
64
  for (const section of sections) {
62
- if (!section.lines.length) continue;
63
- if (out.length) for (let i = 0; i < gap; i++) out.push("");
65
+ const lines = [...section.lines];
66
+ while (lines.length && lines[0] === "") {
67
+ lines.shift();
68
+ }
69
+ while (lines.length && lines[lines.length - 1] === "") {
70
+ lines.pop();
71
+ }
72
+ if (!lines.length) continue;
73
+ if (out.length) {
74
+ for (let i = 0; i < gap; i++) out.push("");
75
+ }
64
76
  if (section.title) {
65
77
  out.push(sectionLabel(section.title, opts.width, { color: opts.color, unicode: opts.unicode }));
66
78
  }
67
- for (const line of section.lines) out.push(line);
79
+ for (const line of lines) {
80
+ out.push(line);
81
+ }
68
82
  }
69
83
  return out;
70
84
  }
@@ -4,6 +4,7 @@ import { spinnerFramesFor, stageIndexForStep, clampStageIndex } from "./evolutio
4
4
  * Stage-aware spinner. Frames evolve with the agent's step against its budget,
5
5
  * sourced from the canonical evolution model so the spinner stays in lockstep
6
6
  * with the ASCII art, meter, and footer track.
7
+ * Enriched with 3D/dimensional quadrant and twisting braille helix elements.
7
8
  */
8
9
  export class Spinner {
9
10
  private defaultFrames: string[];
@@ -5,6 +5,7 @@ import { animatedGradientText, applyBgGradient, hexToRgb, visibleWidth, ColorLev
5
5
  import * as os from "node:os";
6
6
  import { formatUsage } from "./duration";
7
7
  import { formatCost } from "../../ai/pricing";
8
+ import { applyDimensionalGradient, stageGradient, stageIndexForStep } from "./evolution";
8
9
 
9
10
  /**
10
11
  * One-row status bar pinned directly above the boxed input (gjc-layout parity):
@@ -100,7 +101,7 @@ export function renderStatusBar(d: StatusBarData): string {
100
101
  const level = d.colorLevel ?? (useColor ? ColorLevel.TrueColor : ColorLevel.None);
101
102
  const grad = d.gradient ?? { from: "#0a3d62", to: "#48dbfb" };
102
103
  const paintedLeft = useColor
103
- ? applyBgGradient(left, hexToRgb(grad.from), hexToRgb(grad.to), level)
104
+ ? applyDimensionalGradient(left, Date.now(), grad.from, grad.to, level, true)
104
105
  : left;
105
106
  return `${paintedLeft}${" ".repeat(gap)}${right}`;
106
107
  }
@@ -151,9 +152,19 @@ export function renderJeoStatus(data: JeoStatusData): string[] {
151
152
  const elapsed = `${seconds(data.elapsedMs)}s`;
152
153
  let msg = data.message ?? "thinking through the next tool call";
153
154
  const level = data.colorLevel ?? (useColor ? ColorLevel.TrueColor : ColorLevel.None);
154
- if (useColor && data.isThinking && level === ColorLevel.TrueColor && data.palette && data.palette.length > 0) {
155
- const phase = data.phase ?? 0;
156
- msg = animatedGradientText(msg, data.palette, phase, { colorLevel: level });
155
+ if (useColor && level !== ColorLevel.None && (data.isThinking !== false)) {
156
+ let fromHex = "#0a3d62";
157
+ let toHex = "#48dbfb";
158
+ if (data.palette && data.palette.length > 0) {
159
+ fromHex = data.palette[0]!;
160
+ toHex = data.palette[data.palette.length - 1]!;
161
+ } else {
162
+ const stageIdx = stageIndexForStep(data.step ?? 0, data.maxSteps ?? 25);
163
+ const grad = stageGradient(stageIdx);
164
+ fromHex = grad.from;
165
+ toHex = grad.to;
166
+ }
167
+ msg = applyDimensionalGradient(msg, Date.now(), fromHex, toHex, level, false);
157
168
  }
158
169
  const current = data.currentTool ? `forging ${data.currentTool}` : "forge idle";
159
170
  const stage = data.stage ? `${data.stage} · ` : "";
@@ -260,8 +271,19 @@ export function renderStatusBox(data: StatusBoxData): string[] {
260
271
  activity = cut + (unicode ? "…" : "...");
261
272
  }
262
273
  const plainActivityWidth = visibleWidth(activity);
263
- if (useColor && data.isThinking && level === ColorLevel.TrueColor && data.palette && data.palette.length > 0) {
264
- activity = animatedGradientText(activity, data.palette, data.phase ?? 0, { colorLevel: level });
274
+ if (useColor && level !== ColorLevel.None && (data.isThinking !== false)) {
275
+ let fromHex = "#0a3d62";
276
+ let toHex = "#48dbfb";
277
+ if (data.palette && data.palette.length > 0) {
278
+ fromHex = data.palette[0]!;
279
+ toHex = data.palette[data.palette.length - 1]!;
280
+ } else {
281
+ const stageIdx = stageIndexForStep(data.step ?? 0, data.maxSteps ?? 25);
282
+ const grad = stageGradient(stageIdx);
283
+ fromHex = grad.from;
284
+ toHex = grad.to;
285
+ }
286
+ activity = applyDimensionalGradient(activity, Date.now(), fromHex, toHex, level, false);
265
287
  }
266
288
  const escPad = esc
267
289
  ? " ".repeat(Math.max(1, cols - 1 - visibleWidth(headPlain) - plainActivityWidth - visibleWidth(esc))) + dim(esc)
@@ -131,6 +131,9 @@ export function formatTranscript(messages: readonly Message[], opts: TranscriptO
131
131
  : [];
132
132
  const toolCalls = calls.filter(c => c.tool !== "done");
133
133
  if (toolCalls.length > 0) {
134
+ if (lines.length > 0 && lines[lines.length - 1] !== "") {
135
+ lines.push("");
136
+ }
134
137
  // The matching `Tool [x] result (ok|fail)` user message follows; for a batch it
135
138
  // is ONE message with several blocks. Parse verdicts in call order.
136
139
  const next = messages[i + 1];
@@ -153,6 +156,9 @@ export function formatTranscript(messages: readonly Message[], opts: TranscriptO
153
156
  ? ""
154
157
  : m.content;
155
158
  if (!reason.trim()) continue;
159
+ if (lines.length > 0 && lines[lines.length - 1] !== "") {
160
+ lines.push("");
161
+ }
156
162
  lines.push(`${magentaBold(`jeo ${jeoMark}`)}`);
157
163
  lines.push(...clipBody(reason.trim(), bodyCap));
158
164
  }
package/src/util/retry.ts CHANGED
@@ -61,6 +61,15 @@ export function defaultRetryable(err: unknown): boolean {
61
61
  return true;
62
62
  }
63
63
 
64
+ // Transient empty 200s — a provider returned a successful response with no content.
65
+ // This is a known intermittent failure (load/edge races on Anthropic/Gemini/OpenAI), so
66
+ // retry it like an overload instead of letting one empty reply drop the turn. EXCEPTION:
67
+ // deterministic budget exhaustion (max_tokens / length / "output budget exhausted") re-empties
68
+ // on every retry — fail fast so the caller sees the raise-maxTokens/lower-thinking hint.
69
+ if (lowerMessage.includes("returned no content")) {
70
+ return !/max_tokens|max_output_tokens|finish_reason=length|done_reason=length|output budget exhausted/.test(lowerMessage);
71
+ }
72
+
64
73
  // Numeric `.status` field (structured provider errors, fetch responses).
65
74
  if (typeof err === "object" && err !== null) {
66
75
  const status = (err as any).status;