jeo-code 0.6.27 → 0.6.28

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.
@@ -250,12 +250,25 @@ export function providerPickEntries(live: ProviderModelsResult[], want: Provider
250
250
  if (catalog.length) {
251
251
  return catalog.map((m, i) => ({ index: i + 1, provider: want, model: qualifyModelId(m.providerModel, want) }));
252
252
  }
253
+ // Offline fallback for catalog-less (OpenAI-compatible) providers: the def's
254
+ // defaultModel first, then its knownModels list, so the per-role provider picker
255
+ // shows several pickable ids instead of one. De-duped + provider-qualified.
256
+ const def = openaiCompatDef(want);
257
+ if (def) {
258
+ const ids = [def.defaultModel, ...(def.knownModels ?? [])].map(m => qualifyModelId(m, want));
259
+ const seen = new Set<string>();
260
+ const entries: PickEntry[] = [];
261
+ for (const model of ids) {
262
+ if (seen.has(model)) continue;
263
+ seen.add(model);
264
+ entries.push({ index: entries.length + 1, provider: want, model });
265
+ }
266
+ if (entries.length) return entries;
267
+ }
253
268
  const fallback = providerDefaultModel(want);
254
269
  return fallback ? [{ index: 1, provider: want, model: qualifyModelId(fallback, want) }] : [];
255
270
  }
256
271
 
257
-
258
-
259
272
  export function formatResumeHint(sessionId: string): string {
260
273
  return `Resume with: jeo launch --resume ${sessionId}`;
261
274
  }
@@ -510,6 +523,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
510
523
  // answer). Captured from the reasoning stream and persisted on the assistant message so
511
524
  // it survives /resume + export (gjc "think → answer" record). Reset at each turn start.
512
525
  let lastTurnReasoning = "";
526
+ // Native reasoning artifacts for the FINAL (done) step — the engine attaches intermediate
527
+ // steps' artifacts to their own pushed messages, but the done turn is built here. Reset on
528
+ // each step boundary so only the last step's artifacts ride the final reply (no duplication).
529
+ let lastTurnArtifacts: import("../ai/types").ReasoningArtifact[] = [];
513
530
  /** Wrap turn events so EVERY sink (TUI or plain stream) records the last full
514
531
  * tool output for the Ctrl+O detail view. */
515
532
  const withToolDetailCapture = (base: ReturnType<LaunchTui["events"]>): ReturnType<LaunchTui["events"]> => ({
@@ -518,12 +535,22 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
518
535
  lastToolDetail = { tool, output };
519
536
  base.onToolResult?.(tool, success, output);
520
537
  },
538
+ onStep: (step: number) => {
539
+ // New step: drop the prior step's final-reply artifacts so only the LAST step's ride
540
+ // the done reply (intermediate steps are persisted by the engine on their own turns).
541
+ lastTurnArtifacts = [];
542
+ base.onStep?.(step);
543
+ },
521
544
  onReasoningStream: (textSoFar: string) => {
522
545
  // textSoFar is the cumulative thought for the current step; keep the latest
523
546
  // non-empty value (the thought immediately preceding the turn's answer).
524
547
  if (textSoFar.trim()) lastTurnReasoning = textSoFar;
525
548
  base.onReasoningStream?.(textSoFar);
526
549
  },
550
+ onReasoningArtifactStream: (artifact) => {
551
+ lastTurnArtifacts.push(artifact);
552
+ base.onReasoningArtifactStream?.(artifact);
553
+ },
527
554
  });
528
555
  /** Compose a session-persistence flush into onStep so each completed step is
529
556
  * written as it lands (durability across mid-turn interruption) without
@@ -626,6 +653,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
626
653
  // persistence block below.
627
654
  let beforeLen = history.length;
628
655
  lastTurnReasoning = ""; // fresh turn: capture this turn's thinking from scratch
656
+ lastTurnArtifacts = [];
629
657
  // Incremental session persistence (durability across mid-turn interruption):
630
658
  // persistTurnTail() flushes history messages added since the last flush — called
631
659
  // right after the user prompt, on every onStep boundary, and once post-turn — so
@@ -929,9 +957,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
929
957
  // this only covers the tail — net content is the full turn either way.
930
958
  try {
931
959
  await persistTurnTail();
932
- const assistantMsg: Message = lastTurnReasoning.trim()
933
- ? { role: "assistant", content: reply, reasoning: lastTurnReasoning }
934
- : { role: "assistant", content: reply };
960
+ const assistantMsg: Message = { role: "assistant", content: reply };
961
+ if (lastTurnReasoning.trim()) assistantMsg.reasoning = lastTurnReasoning;
962
+ if (lastTurnArtifacts.length) assistantMsg.reasoningArtifacts = lastTurnArtifacts;
935
963
  history.push(assistantMsg);
936
964
  if (sessionId) await appendMessage(sessionId, assistantMsg, cwd);
937
965
  if (tui) tui.finish(reply);
@@ -1616,7 +1644,15 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1616
1644
  jeoEnv("NO_SLASH_PREVIEW") !== "1";
1617
1645
  // Footer height reserved by the CURRENTLY armed region; disarm/draw must use the
1618
1646
  // same value the arm computed, even if the terminal was resized in between.
1647
+ // `footerRows` is the MAX reservation height (the budget previewLines/historyPreview
1648
+ // may fill). The PHYSICAL reservation (`footerRendered`) is now dynamic: compact at
1649
+ // idle (no dropdown) so a finished/idle prompt leaves NO reserved blank rows, and
1650
+ // grown on demand when a slash/arg preview needs more. `footerWantRows` is the height
1651
+ // the latest previewLines/historyPreview wants; drawFooter re-pins to it in place.
1619
1652
  let footerRows = MAX_PREVIEW_ROWS;
1653
+ // Compact idle reservation: status bar (1) + spacer (1) + input box (3 rows).
1654
+ const COMPACT_FOOTER_ROWS = 5;
1655
+ let footerWantRows = COMPACT_FOOTER_ROWS;
1620
1656
  const out = process.stdout;
1621
1657
  // Arrow-key selection over the slash preview list.
1622
1658
  let navMatches: string[] = []; // command names matching the typed keyword (display order)
@@ -1666,24 +1702,42 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1666
1702
  // line painted at an older, wider geometry reflows onto extra rows after a width shrink.
1667
1703
  let lastDrawnLines: string[] = [];
1668
1704
  const padToFooter = (lines: string[]): string[] => {
1669
- if (lines.length >= footerRows) return lines.slice(0, footerRows);
1670
- return [...lines, ...new Array(footerRows - lines.length).fill("")];
1705
+ if (lines.length >= footerRendered) return lines.slice(0, footerRendered);
1706
+ return [...lines, ...new Array(footerRendered - lines.length).fill("")];
1671
1707
  };
1672
1708
  const armPreview = () => {
1673
1709
  if (!previewEnabled || previewArmed) return;
1674
1710
  footerRows = previewRowsFor(process.stdout.rows ?? 24);
1675
- // Reserve `footerRows` bottom rows: write blank newlines (the terminal scrolls
1676
- // ONCE here, not on every keystroke), then park the cursor at the top of the
1677
- // reservation. Every subsequent drawFooter call stays inside this region.
1678
- if (footerRows > 1) {
1679
- out.write("\n".repeat(footerRows - 1) + cursorUp(footerRows - 1));
1711
+ // Reserve a COMPACT region (idle prompt height) right after the current output —
1712
+ // not the full `footerRows` budget so a finished/idle prompt leaves no blank rows
1713
+ // below it. drawFooter grows the reservation in place when a dropdown needs more.
1714
+ const initial = Math.max(1, Math.min(footerRows, COMPACT_FOOTER_ROWS));
1715
+ if (initial > 1) {
1716
+ out.write("\n".repeat(initial - 1) + cursorUp(initial - 1));
1680
1717
  }
1681
1718
  out.write(toColumn(1));
1682
- footerRendered = footerRows;
1719
+ footerRendered = initial;
1720
+ footerWantRows = initial;
1683
1721
  footerParkedRow = 0;
1684
1722
  previewArmed = true;
1685
1723
  lastFooterKey = "";
1686
1724
  };
1725
+ // Re-pin the reservation to `n` rows IN PLACE (right after the existing output, never
1726
+ // bottom-pinned): clear the old region from its top, then reserve `n` rows there. Used
1727
+ // by drawFooter to grow for a dropdown and shrink back to the compact idle height, so
1728
+ // the prompt never carries a trailing/floating blank block.
1729
+ const setFooterRows = (n: number) => {
1730
+ n = Math.max(1, Math.min(n, footerRows));
1731
+ if (!previewArmed || n === footerRendered) return;
1732
+ let s = footerParkedRow > 0 ? cursorUp(footerParkedRow) : "";
1733
+ s += toColumn(1) + clearToEnd(); // wipe old region; cursor now at its top (after output)
1734
+ if (n > 1) s += "\n".repeat(n - 1) + cursorUp(n - 1);
1735
+ s += toColumn(1);
1736
+ out.write(s);
1737
+ footerRendered = n;
1738
+ footerParkedRow = 0;
1739
+ lastFooterKey = ""; // force a full repaint into the resized region
1740
+ };
1687
1741
  // Clear the reserved region and park the cursor at its top row so subsequent
1688
1742
  // command output starts where the box was (and inherits the existing scrollback).
1689
1743
  const disarmPreview = () => {
@@ -1800,7 +1854,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1800
1854
  const slash = budget > 0 ? formatSlashPreview(line, budget, selected, skillSlashDetails, resolvedSkills) : [];
1801
1855
  const args = !slash.length && budget > 0 ? formatCompletionPreview(line, completionContext(), budget) : [];
1802
1856
  const preview = (slash.length ? slash : args).map(l => chalk.gray(truncateAnsi(l, cols)));
1803
- return [statusBarLine(cols), "", ...input, ...preview].slice(0, footerRows);
1857
+ const result = [statusBarLine(cols), "", ...input, ...preview].slice(0, footerRows);
1858
+ // Want only the input box + status bar at idle (no dropdown) → compact reservation;
1859
+ // grow to fit the dropdown when a preview is present.
1860
+ footerWantRows = preview.length > 0 ? result.length : Math.min(footerRows, 2 + input.length);
1861
+ return result;
1804
1862
  };
1805
1863
  // Render the reversible Ctrl+O detail panel into the footer reservation: a status
1806
1864
  // bar, a title (with scroll hint when needed), then a windowed slice of the detail
@@ -1838,10 +1896,15 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1838
1896
  if (below > 0) body.push(chalk.dim(`↓ ${below} more below`));
1839
1897
  }
1840
1898
  footerCursor = { row: Math.min(1, footerRows - 1), col: 1 };
1841
- return [statusBarLine(cols), title, ...body].slice(0, footerRows);
1899
+ const result = [statusBarLine(cols), title, ...body].slice(0, footerRows);
1900
+ footerWantRows = result.length; // the Ctrl+O panel sizes the reservation to its content
1901
+ return result;
1842
1902
  };
1843
1903
  const drawFooter = (lines: string[]) => {
1844
1904
  if (!previewArmed || footerRendered === 0) return;
1905
+ // Re-pin the reservation to the height the latest preview/panel wants (compact at
1906
+ // idle, grown for a dropdown) BEFORE painting, so no reserved blank trails the prompt.
1907
+ setFooterRows(footerWantRows);
1845
1908
  // ALWAYS paint exactly footerRendered rows so the reservation is fully covered
1846
1909
  // and no row can spill past it — the bug fix that kept `@folder<more text>`
1847
1910
  // typing from scrolling the input box (and prior output) off the top.
@@ -2558,9 +2621,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2558
2621
  out.write(clearScreen());
2559
2622
  out.write(renderWelcome({ ...welcomeData, cols }).join("\n") + "\n");
2560
2623
  footerRows = previewRowsFor(rows);
2561
- if (footerRows > 1) out.write("\n".repeat(footerRows - 1) + cursorUp(footerRows - 1));
2624
+ const initial = Math.max(1, Math.min(footerRows, COMPACT_FOOTER_ROWS));
2625
+ if (initial > 1) out.write("\n".repeat(initial - 1) + cursorUp(initial - 1));
2562
2626
  out.write(toColumn(1));
2563
- footerRendered = footerRows;
2627
+ footerRendered = initial;
2628
+ footerWantRows = initial;
2564
2629
  drawFooter(promptHistoryLines ? historyPreviewLines(promptHistoryLines) : previewLines(typedLine, navIdx));
2565
2630
  return;
2566
2631
  }
@@ -2591,14 +2656,17 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2591
2656
  const caretSubRow = Math.floor(Math.max(0, footerCursor.col - 1) / c);
2592
2657
  const hopUp = abovePhysical + caretSubRow;
2593
2658
  let s = (hopUp > 0 ? cursorUp(hopUp) : "") + toColumn(1) + clearToEnd();
2594
- // Re-pin a clean reservation to the screen bottom and repaint. ED already blanked
2595
- // from the frame top down to the bottom, so just position at the bottom region.
2659
+ // Re-pin a COMPACT reservation right where the frame top was (just below the
2660
+ // static content) NOT bottom-pinned. ED already blanked from the frame top
2661
+ // down, so reserve the idle height here; drawFooter grows it for a dropdown.
2662
+ // Bottom-pinning left a tall blank gap above the bar when the content was short.
2596
2663
  footerRows = previewRowsFor(rows);
2597
- s += `\x1b[${Math.max(1, rows)};1H`;
2598
- if (footerRows > 1) s += cursorUp(footerRows - 1);
2664
+ const initial = Math.max(1, Math.min(footerRows, COMPACT_FOOTER_ROWS));
2665
+ if (initial > 1) s += "\n".repeat(initial - 1) + cursorUp(initial - 1);
2599
2666
  s += toColumn(1);
2600
2667
  out.write(s);
2601
- footerRendered = footerRows;
2668
+ footerRendered = initial;
2669
+ footerWantRows = initial;
2602
2670
  footerParkedRow = 0;
2603
2671
  lastFooterKey = "";
2604
2672
  drawFooter(promptHistoryLines ? historyPreviewLines(promptHistoryLines) : previewLines(typedLine, navIdx));
package/src/tui/app.ts CHANGED
@@ -68,6 +68,9 @@ export interface AgentEventsLike {
68
68
  onUsage?(usage: { inputTokens: number; outputTokens: number }): void;
69
69
  onModelStream?(textSoFar: string): void;
70
70
  onReasoningStream?(textSoFar: string): void;
71
+ /** Per-artifact native reasoning replay records (signature / thoughtSignature / reasoning
72
+ * item). The TUI ignores these; launch.ts uses them to persist the final reply's artifacts. */
73
+ onReasoningArtifactStream?(artifact: import("../ai/types").ReasoningArtifact): void;
71
74
  onBudget?(limit: number, reason: string): void;
72
75
 
73
76
  }
@@ -112,6 +115,27 @@ export function tailForWrap(text: string, maxChars = FRAME_WRAP_TAIL_CHARS): str
112
115
  return text.length > maxChars ? text.slice(text.length - maxChars) : text;
113
116
  }
114
117
 
118
+ /** Max lines of a committed reasoning block kept in scrollback (gjc-style collapse): a
119
+ * long chain-of-thought is clipped with a "+N more" hint so it never floods the ledger. */
120
+ export const THINKING_COMMIT_MAX_LINES = 12;
121
+
122
+ /** Collapse a committed reasoning block to a line cap, appending a "… (+N more lines)"
123
+ * hint when clipped (gjc collapsed-by-default parity). Returns the input verbatim when
124
+ * it already fits. */
125
+ export function clipReasoningLines(text: string, cap = THINKING_COMMIT_MAX_LINES): string {
126
+ const rows = text.replace(/\r/g, "").split("\n");
127
+ if (rows.length <= cap) return rows.join("\n");
128
+ return [...rows.slice(0, cap), `… (+${rows.length - cap} more lines)`].join("\n");
129
+ }
130
+
131
+ /** gjc-style "thought for Ns" header for a committed/streaming Thinking block. Omits the
132
+ * duration when no step start is known (e.g. resumed/exported records). */
133
+ export function thinkingHeader(elapsedMs: number | undefined, unicode: boolean): string {
134
+ const diamond = unicode ? "◇" : "*";
135
+ const secs = elapsedMs !== undefined && elapsedMs >= 0 ? `${(elapsedMs / 1000).toFixed(1)}s` : null;
136
+ return `${diamond} thinking${secs ? ` · ${secs}` : ""}`;
137
+ }
138
+
115
139
  /** Status animation palette while a tool/process runs (background verification): an
116
140
  * amber→yellow gradient, distinct from the cool thinking gradient, so "the agent is
117
141
  * running a process / verifying" reads at a glance (gjc parity: `theme.fg("warning")`
@@ -444,13 +468,17 @@ export class LaunchTui {
444
468
  : (s: string) => s;
445
469
  const style = (prose: string) => prose.split("\n").map(styleThought).join("\n");
446
470
  const parts: string[] = [this.agentLabel()];
471
+ // gjc "thought for Ns" header: step-start → commit ≈ the model's think+gen time.
472
+ const elapsedMs = this.currentStepStartedAt ? Date.now() - this.currentStepStartedAt : undefined;
473
+ const header = thinkingHeader(elapsedMs, this.unicode);
474
+ parts.push(this.theme.color ? chalk.dim(header) : header);
447
475
  if (willFlushThought) {
448
476
  this.flushedThought = this.streamingThought;
449
- parts.push(style(this.streamingThought));
477
+ parts.push(style(clipReasoningLines(this.streamingThought)));
450
478
  }
451
479
  if (willFlushReasoning) {
452
480
  this.flushedReasoning = this.streamingReasoning;
453
- parts.push(style(this.streamingReasoning));
481
+ parts.push(style(clipReasoningLines(this.streamingReasoning)));
454
482
  }
455
483
  this.appendLedger(`${parts.join("\n")}\n`, "reasoning");
456
484
  }
@@ -1206,7 +1234,7 @@ export class LaunchTui {
1206
1234
  * block shows only the most-recent lines, capped at ~30% of the screen height (a
1207
1235
  * ceiling guards a tall terminal), so it grows with the stream and shrinks with the
1208
1236
  * viewport. Returns [] when there is nothing to show. */
1209
- private renderLiveBlock(label: string, text: string, cols: number, rows: number, ceiling: number): string[] {
1237
+ private renderLiveBlock(label: string, text: string, cols: number, rows: number, ceiling: number, cacheKey = label): string[] {
1210
1238
  const dim = this.theme.color ? chalk.dim : (s: string) => s;
1211
1239
  if (!text.trim()) return [];
1212
1240
  const wrapW = Math.max(8, cols - 2);
@@ -1214,8 +1242,8 @@ export class LaunchTui {
1214
1242
  // this (up to 16KB) tail every frame just re-segments graphemes for no visible change.
1215
1243
  // Per-label slot (Thinking / Output) keyed by wrap width + text — a real delta misses
1216
1244
  // once and recomputes; an idle tick hits the cache. `rows` only gates the post-slice.
1217
- let cache = this.liveBlockWrapCaches.get(label);
1218
- if (!cache) { cache = lastValueCache<string[]>(); this.liveBlockWrapCaches.set(label, cache); }
1245
+ let cache = this.liveBlockWrapCaches.get(cacheKey);
1246
+ if (!cache) { cache = lastValueCache<string[]>(); this.liveBlockWrapCaches.set(cacheKey, cache); }
1219
1247
  const wrapped = cache(`${wrapW}\u0000${text}`, () =>
1220
1248
  tailForWrap(text)
1221
1249
  .split("\n")
@@ -1349,7 +1377,11 @@ export class LaunchTui {
1349
1377
  // rectangle, so a short trace leaves no padded "hole" and a short terminal is spared.
1350
1378
  const liveThink = this.streamingThought.trim() || this.streamingReasoning.trim();
1351
1379
  if (isThinking && liveThink) {
1352
- tail.push(...this.renderLiveBlock("Thinking", liveThink, cols, rows, 6));
1380
+ // gjc-parity: the Thinking block label carries a running timer ("Thinking · Ns").
1381
+ // Cache key stays the constant "Thinking" so the per-frame wrap memo is unaffected.
1382
+ const liveMs = this.currentStepStartedAt ? Date.now() - this.currentStepStartedAt : undefined;
1383
+ const liveLabel = liveMs !== undefined ? `Thinking · ${(liveMs / 1000).toFixed(1)}s` : "Thinking";
1384
+ tail.push(...this.renderLiveBlock(liveLabel, liveThink, cols, rows, 6, "Thinking"));
1353
1385
  }
1354
1386
 
1355
1387
  // Live tool output (gjc-style streaming bash stdout): while a tool runs, its
@@ -412,26 +412,24 @@ export async function animateFrames(stage: AsciiStage, opts: AnimateFramesOption
412
412
  }
413
413
  return total;
414
414
  }
415
- /** The compact jeo forge mark: a horizontal lobster/crayfish (가재) emblem composed
416
- * of SIMPLE, DISCONNECTED shapes (no connecting strokes) that together read as the
417
- * mascot lying on its side, left→right: the raised pincer CLAWS (집게) as the splayed
418
- * corner wedges (◤◣ left, ◥◢ right), the body carrying the JEO wordmark (J E O), and
419
- * a DNA double-helix woven above and below as a row of crossing nodes (╳ = base-pair
420
- * crossings). gjc-forge aesthetic: clean negative space, geometric symmetry, the
421
- * blue→violet→pink flow gradient applied by renderForgeMark doing the neon glow. The
422
- * lobster identity is carried by the disconnected silhouette; the JEO typography is
423
- * the deliberate lettermark at the core. Width-1 glyphs only (box drawing + geometrics)
415
+ /** The compact jeo forge mark: a symmetrical crayfish (가재) brand emblem composed
416
+ * of SIMPLE, DISCONNECTED shapes (no connecting strokes) that highlight the signature
417
+ * pincer CLAWS (집게) flanking the sides (◤◣ and ❮ on the left, ◥◢ and on the right),
418
+ * the body carrying the JEO wordmark (J E O), and a DNA double-helix woven above and
419
+ * below as a row of crossing nodes (╳ = base-pair crossings). gjc-forge aesthetic:
420
+ * clean negative space, geometric symmetry, the blue→violet→pink flow gradient applied
421
+ * by renderForgeMark doing the neon glow. Width-1 glyphs only (box drawing + geometrics)
424
422
  * so padding/centering math stays exact. Frame 0 is the static symbol. */
425
423
  export const FORGE_MARK_ART: string[] = [
426
- "◤ ╳ ╳ ╳ ╳ ◥",
427
- "❮ J E O",
428
- "◣ ╳ ╳ ╳ ╳ ◢"
424
+ "◤ ╳ ╳ ╳ ╳ ◥",
425
+ "❮ J E O",
426
+ "◣ ╳ ╳ ╳ ╳ ◢"
429
427
  ];
430
428
 
431
429
  export const FORGE_MARK_ART_ASCII: string[] = [
432
- "/ x x x x \\",
433
- "< J E O |",
434
- "\\ x x x x /"
430
+ "/ x x x x \\",
431
+ "< J E O >",
432
+ "\\ x x x x /"
435
433
  ];
436
434
 
437
435
  /** Claw-snap blink frames for the compact lobster forge mark: the helix nodes, the
@@ -442,18 +440,18 @@ export const FORGE_MARK_ART_ASCII: string[] = [
442
440
  export const FORGE_MARK_FRAMES: string[][] = [
443
441
  FORGE_MARK_ART,
444
442
  [
445
- "◢ ╳ ╳ ╳ ╳ ◣",
446
- "❮ J E O",
447
- "◥ ╳ ╳ ╳ ╳ ◤"
443
+ "◢ ╳ ╳ ╳ ╳ ◣",
444
+ "❮ J E O",
445
+ "◥ ╳ ╳ ╳ ╳ ◤"
448
446
  ]
449
447
  ];
450
448
 
451
449
  export const FORGE_MARK_FRAMES_ASCII: string[][] = [
452
450
  FORGE_MARK_ART_ASCII,
453
451
  [
454
- "\\ x x x x /",
455
- "< J E O |",
456
- "/ x x x x \\"
452
+ "\\ x x x x /",
453
+ "< J E O >",
454
+ "/ x x x x \\"
457
455
  ]
458
456
  ];
459
457
 
@@ -463,27 +461,25 @@ export function forgeMarkFrameCount(): number {
463
461
  }
464
462
 
465
463
  /** Grand hero variant for the welcome forge box (gjc-style spacious banner): the same
466
- * horizontal lobster emblem rendered large — the splayed pincer claws as corner wedges
467
- * (◤◣ left, ◥◢ right), the JEO wordmark spaced across the body (J E O), and the DNA
468
- * double-helix woven above and below as a wider row of crossing nodes (╳). gjc-forge
469
- * aesthetic: generous negative space + geometric symmetry, with renderForgeMark's
470
- * blue→violet→pink flow gradient supplying the neon glow. The JEO typography is the
471
- * deliberate lettermark; the lobster reads from the disconnected silhouette. Width 29
472
- * (matches the welcome compact↔grand threshold) and width-1 glyphs only so
464
+ * symmetrical crayfish emblem rendered large — the splayed pincer claws as corner wedges
465
+ * (◤◣ left, ◥◢ right) and heavy brackets (❮ left, right), the JEO wordmark spaced
466
+ * across the body (J E O), and the DNA double-helix woven above and below as a wider
467
+ * row of crossing nodes (╳). gjc-forge aesthetic: generous negative space + geometric
468
+ * symmetry, with renderForgeMark's blue→violet→pink flow gradient supplying the neon glow.
469
+ * Width 29 (matches the welcome compact↔grand threshold) and width-1 glyphs only so
473
470
  * padding/centering math stays exact. */
474
471
  export const FORGE_MARK_ART_GRAND: string[] = [
475
472
  "◤ ╳ ╳ ╳ ╳ ╳ ╳ ◥",
476
- "❮ J E O ",
473
+ "❮ J E O ",
477
474
  "◣ ╳ ╳ ╳ ╳ ╳ ╳ ◢"
478
475
  ];
479
476
 
480
477
  export const FORGE_MARK_ART_GRAND_ASCII: string[] = [
481
478
  "/ x x x x x x \\",
482
- "< J E O |",
479
+ "< J E O >",
483
480
  "\\ x x x x x x /"
484
481
  ];
485
482
 
486
-
487
483
  // Bounded memo of fully-rendered forge-mark frames keyed by every input that affects
488
484
  // output (grand/unicode/cols/color/colorLevel/phase/frame). The live HUD cycles a
489
485
  // FIXED frame set (blink × gradient phases) at ~120ms; without this each recurrence