jeo-code 0.4.5 → 0.4.7

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/src/tui/app.ts CHANGED
@@ -22,7 +22,7 @@ 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
24
  import { SECTION_GAP, stackSections } from "./components/section";
25
- import { resolveTheme, themeGradient, accentPaint, accentShadowPaint, diffPaint } from "./components/themes";
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
28
  import { renderJeoStatus, renderStatusBar, renderStatusBox } from "./components/status";
@@ -63,9 +63,11 @@ export interface AgentEventsLike {
63
63
  onStep?(step: number): void;
64
64
  onAssistant?(raw: string, invocation: { tool: string; arguments?: unknown } | null): void;
65
65
  onToolResult?(tool: string, success: boolean, output: string): void;
66
+ onToolProgress?(tool: string, partial: string): void;
66
67
  onNotice?(message: string): void;
67
68
  onUsage?(usage: { inputTokens: number; outputTokens: number }): void;
68
69
  onModelStream?(textSoFar: string): void;
70
+ onReasoningStream?(textSoFar: string): void;
69
71
  onBudget?(limit: number, reason: string): void;
70
72
 
71
73
  }
@@ -186,11 +188,19 @@ export class LaunchTui {
186
188
  // `"reasoning"` field of the forming tool-call JSON). Shown dim under the HUD while
187
189
  // the model responds, then flushed once into scrollback as a `jeo · …` ledger line.
188
190
  private streamingReasoning = "";
191
+ /** Native model thinking text (separate reasoning channel), shown DIMMED while it
192
+ * streams and cleared on commit — ephemeral, never flushed (the durable record is the
193
+ * action/reply the thinking produced). */
194
+ private streamingThought = "";
189
195
  /** Uniform live-activity text for the live status field (reasoning OR derived fallback). */
190
196
  private streamingActivity = "";
191
197
  /** Last stream-driven draw (ms epoch) — throttles per-delta repaints to ≤10/s. */
192
198
  private lastStreamDraw = 0;
193
199
  private flushedReasoning = "";
200
+ // Live streaming output of the currently-running tool (bash stdout via onToolProgress).
201
+ // Shown as a DIMMED bounded block while the tool runs; cleared when the formatted
202
+ // result card lands (onToolResult) — the gjc-style "shaded until complete" effect.
203
+ private liveToolOutput = "";
194
204
  // Ctrl+O history/detail panel. When set, the live inline frame shows this
195
205
  // block above the heartbeat; pressing Ctrl+O again clears it and restores the
196
206
  // normal activity view. Kept as data, not scrollback text, so it can actually close.
@@ -268,7 +278,14 @@ export class LaunchTui {
268
278
  // scrollback (☑ + strikethrough as items complete), so the checklist's history
269
279
  // is reviewable. The live pinned plan stays in the frame tail as before.
270
280
  if (changed && items.length > 0 && !this.finished) {
271
- const card = formatTodoWriteCard(items, { unicode: this.unicode, color: this.theme.color });
281
+ const card = formatTodoWriteCard(items, {
282
+ unicode: this.unicode,
283
+ color: this.theme.color,
284
+ muted: mutedPaint(this.theme),
285
+ accent: this.theme.color ? accentPaint(this.theme) : undefined,
286
+ fill: cardFillPaint(this.theme),
287
+ width: Math.max(24, Math.min(100, size().cols)),
288
+ });
272
289
  this.appendLedger(card.join("\n") + "\n", "card");
273
290
  }
274
291
  this.todos = items;
@@ -320,8 +337,10 @@ export class LaunchTui {
320
337
  this.hudPhase = "thinking";
321
338
  this.retryNotice = null; // a new step starts a fresh model call
322
339
  this.streamingReasoning = ""; // fresh model response this step
340
+ this.streamingThought = "";
323
341
  this.streamingActivity = "";
324
342
  this.flushedReasoning = "";
343
+ this.liveToolOutput = ""; // fresh step: no tool output yet
325
344
  this.currentStepStartedAt = Date.now();
326
345
  this.spinner.updateStep(step, this.footer.maxSteps);
327
346
  this.spinner.next();
@@ -353,6 +372,18 @@ export class LaunchTui {
353
372
  this.draw();
354
373
  }
355
374
  },
375
+ onReasoningStream: textSoFar => {
376
+ if (this.finished) return;
377
+ // Native thinking deltas → the SAME transient dimmed block as the JSON-reasoning
378
+ // path (reuses the screen-safe tail renderer; no new frame structure). Ephemeral:
379
+ // cleared on commit, never flushed into scrollback.
380
+ if (textSoFar === this.streamingThought) return;
381
+ this.streamingThought = textSoFar;
382
+ if (Date.now() - this.lastStreamDraw >= 100) {
383
+ this.lastStreamDraw = Date.now();
384
+ this.draw();
385
+ }
386
+ },
356
387
  onAssistant: (_raw, invocation) => {
357
388
  this.thinking = false; // model replied; now dispatching the tool
358
389
  this.retryNotice = null; // the call got through — clear any backoff notice
@@ -365,6 +396,7 @@ export class LaunchTui {
365
396
  this.appendLedger(`${name}\n${this.streamingReasoning}\n`, "reasoning");
366
397
  }
367
398
  this.streamingReasoning = "";
399
+ this.streamingThought = "";
368
400
  this.streamingActivity = "";
369
401
  if (invocation && invocation.tool !== "done") {
370
402
  this.runningTool = true;
@@ -394,8 +426,17 @@ export class LaunchTui {
394
426
  this.hudPhase = "reporting";
395
427
  }
396
428
  },
429
+ onToolProgress: (_tool, partial) => {
430
+ if (this.finished) return;
431
+ this.liveToolOutput = partial;
432
+ if (Date.now() - this.lastStreamDraw >= 100) {
433
+ this.lastStreamDraw = Date.now();
434
+ this.draw();
435
+ }
436
+ },
397
437
  onToolResult: (tool, success, output) => {
398
438
  this.runningTool = false;
439
+ this.liveToolOutput = ""; // formatted result card now replaces the live dim output
399
440
  if (this.pendingIndex !== null) {
400
441
  this.tools.finish(this.pendingIndex, success);
401
442
  this.pendingIndex = null;
@@ -417,7 +458,7 @@ export class LaunchTui {
417
458
  // the non-TTY summary both show the merged card.
418
459
  card.title = `${paintedMark} Bash`;
419
460
  card.lines.push(...result.lines);
420
- this.flushForgeCard(card);
461
+ this.flushForgeCard(card, success);
421
462
  } else if (card && t === "web_search" && success && webSearchCardLines(output, { unicode: this.unicode })) {
422
463
  // gjc-style Web Search card: `✓ Web Search: <provider> · N sources` header
423
464
  // over Query / Answer / Sources / Metadata divider sections rebuilt from
@@ -426,12 +467,12 @@ export class LaunchTui {
426
467
  const ws = webSearchCardLines(output, { unicode: this.unicode })!;
427
468
  card.title = `${paintedMark} Web Search: ${ws.titleMeta}`;
428
469
  card.lines = ws.lines;
429
- this.flushForgeCard(card);
470
+ this.flushForgeCard(card, success);
430
471
  } else if (card) {
431
472
  card.title = `${paintedMark} ${card.title}`;
432
473
  if (!success) this.rememberForge(result);
433
- this.flushForgeCard(card);
434
- if (!success) this.flushForgeCard(result);
474
+ this.flushForgeCard(card, success);
475
+ if (!success) this.flushForgeCard(result, false);
435
476
  } else {
436
477
  // Light tool: one ✓/✗ line, plus a dim result tree for list-shaped output
437
478
  // (find/search/ls) and an error card when the tool failed.
@@ -439,7 +480,7 @@ export class LaunchTui {
439
480
  this.appendLedger(`${paintedMark} ${target}${suffix}\n${children.map(c => `${c}\n`).join("")}`, "tool");
440
481
  if (!success) {
441
482
  this.rememberForge(result);
442
- this.flushForgeCard(result);
483
+ this.flushForgeCard(result, false);
443
484
  }
444
485
  }
445
486
  this.draw();
@@ -511,15 +552,22 @@ export class LaunchTui {
511
552
  }
512
553
 
513
554
  private renderLiveUserQueryCard(cols: number): string[] {
514
- const text = this.livePromptInput.trim();
555
+ return this.renderUserCard(this.livePromptInput, cols);
556
+ }
557
+
558
+ /** Render a `user`-labeled query card (orange "user" header over a filled box).
559
+ * Shared by the live next-prompt draft and the mid-turn steering flush. */
560
+ private renderUserCard(rawText: string, cols: number): string[] {
561
+ const text = (rawText ?? "").trim();
515
562
  if (!text) return [];
516
563
  const boxWidth = Math.max(24, Math.min(120, cols));
517
564
  const inner = Math.max(10, boxWidth - 2);
518
565
  const g = this.unicode ? BOX_UNICODE : BOX_ASCII;
519
- const accent = this.theme.color ? chalk.hex("#ff6b4a").bold : (s: string) => s;
520
- const border = this.theme.color ? chalk.hex("#7f1d1d") : (s: string) => s;
521
- const shadow = this.theme.color ? chalk.hex("#451a1a").dim : border;
522
- const fill = this.theme.color ? (s: string) => chalk.bgHex("#210b10")(s) : (s: string) => s;
566
+ const uc = this.theme.userCard;
567
+ const accent = this.theme.color && uc ? chalk.hex(uc.accent).bold : (s: string) => s;
568
+ const border = this.theme.color && uc ? chalk.hex(uc.border) : (s: string) => s;
569
+ const shadow = this.theme.color && uc ? chalk.hex(uc.shadow) : border;
570
+ const fill = this.theme.color && uc ? (s: string) => chalk.bgHex(uc.fill)(s) : (s: string) => s;
523
571
  const body = text
524
572
  .split("\n")
525
573
  .flatMap(line => wrapTextWithAnsi(line, Math.max(8, inner - 2)))
@@ -537,6 +585,23 @@ export class LaunchTui {
537
585
  return [` ${accent("user")}`, top, ...mid, bottom];
538
586
  }
539
587
 
588
+ /** Flush a `user` card into scrollback so a submitted query stays visible there
589
+ * (gjc parity), instead of only as the transient HUD turn-title / a status notice.
590
+ * Shared by the prompt that STARTS a turn and the mid-turn steering flush. */
591
+ flushUserCard(text: string): void {
592
+ const t = (text ?? "").trim();
593
+ if (!t || this.finished) return;
594
+ const cols = Math.max(20, size().cols);
595
+ const lines = this.renderUserCard(t, cols);
596
+ if (lines.length) this.appendLedger(lines.join("\n"), "card");
597
+ }
598
+
599
+ /** Mid-turn steering query → a `user` card in scrollback (accepted input that is
600
+ * now driving the running turn). Alias of {@link flushUserCard}. */
601
+ flushSteerCard(text: string): void {
602
+ this.flushUserCard(text);
603
+ }
604
+
540
605
  /** Append a completed progress-ledger line. In inline mode the line is flushed
541
606
  * straight into normal scrollback ABOVE the live frame, so tmux / terminal
542
607
  * mouse-wheel can review the full progress history mid-turn (gjc-style); the
@@ -686,7 +751,7 @@ export class LaunchTui {
686
751
  if (this.finished) return;
687
752
  const color = this.theme.color;
688
753
  const role = e.role || "subagent";
689
- const roleLabel = role.toUpperCase();
754
+ const roleLabel = e.index && e.total ? `${role.toUpperCase()}[${e.index}/${e.total}]` : role.toUpperCase();
690
755
  const badge = categoryBadge("subagent", { color });
691
756
  const ok = this.unicode ? "✓" : "v";
692
757
  const bad = this.unicode ? "✗" : "x";
@@ -718,7 +783,7 @@ export class LaunchTui {
718
783
  case "done":
719
784
  this.subagentActive = false;
720
785
  this.subagentLive = null;
721
- this.appendLedger(`${badge} ${last} ${roleLabel} done${e.success === false ? " (incomplete)" : ""}: ${detail}\n`, "subagent");
786
+ this.appendLedger(`${badge} ${last} ${roleLabel} done${e.tokens ? ` (${e.tokens.input + e.tokens.output} tok)` : ""}${e.success === false ? " (incomplete)" : ""}: ${detail}\n`, "subagent");
722
787
  break;
723
788
  }
724
789
  this.draw();
@@ -925,16 +990,24 @@ export class LaunchTui {
925
990
  /** Flush a completed forge card into scrollback (inline mode) and retire it from the
926
991
  * live array so the in-frame card region and the final summary never repeat it.
927
992
  * Non-inline modes keep the card in `forgeSummaries` for the final static summary. */
928
- private flushForgeCard(summary: ForgeSummary): void {
993
+ private flushForgeCard(summary: ForgeSummary, success?: boolean): void {
929
994
  if (!this.inline || this.finished) return;
930
995
  const width = Math.max(24, Math.min(120, size().cols));
996
+ // gjc D2 (state-encoded border): a FAILED card gets a red border so it pops
997
+ // out of scrollback at a glance; OK/neutral cards keep the theme accent
998
+ // identity. The ✓/✗ title mark already encodes state, but the border tone
999
+ // is what the eye catches first when scanning back through history.
1000
+ const errored = success === false && this.theme.color;
1001
+ const paint = errored ? (s: string) => chalk.red(s) : accentPaint(this.theme);
1002
+ const paintShadow = errored ? (s: string) => chalk.dim(chalk.red(s)) : accentShadowPaint(this.theme);
931
1003
  const lines = formatForgeBox(summary, {
932
1004
  width,
933
1005
  maxLines: 12,
934
1006
  unicode: this.unicode,
935
- paint: accentPaint(this.theme),
936
- paintShadow: accentShadowPaint(this.theme),
1007
+ paint,
1008
+ paintShadow,
937
1009
  diffPaint: diffPaint(this.theme),
1010
+ fill: cardFillPaint(this.theme),
938
1011
  color: this.theme.color,
939
1012
  });
940
1013
  this.appendLedger(lines.join("\n") + "\n", "card");
@@ -946,6 +1019,7 @@ export class LaunchTui {
946
1019
  width: number,
947
1020
  maxEntries: number,
948
1021
  anim?: { phase: number; colorLevel: ColorLevel; beat: string },
1022
+ dim = false,
949
1023
  ): string[] {
950
1024
  const floor = Math.min(24, width);
951
1025
  // Fill the available width (cap at formatForgeBox's own 120 ceiling) so an
@@ -962,12 +1036,14 @@ export class LaunchTui {
962
1036
  paint,
963
1037
  paintShadow: accentShadowPaint(this.theme),
964
1038
  diffPaint: diffPaint(this.theme),
1039
+ fill: cardFillPaint(this.theme),
965
1040
  index: i + 1,
966
1041
  color: this.theme.color,
1042
+ dim,
967
1043
  // DNA-flow identity on LIVE cards only: the flowing helix gradient rides
968
1044
  // the card border and the claw beat marks the title. Flushed/final cards
969
- // stay static scrollback never carries animation frames.
970
- ...(anim
1045
+ // stay static. Suppressed while `dim` (in-flight shading takes precedence).
1046
+ ...(anim && !dim
971
1047
  ? { flow: { palette: DNA_FLOW_PALETTE, phase: anim.phase, colorLevel: anim.colorLevel }, titleMark: anim.beat }
972
1048
  : {}),
973
1049
  }));
@@ -1032,6 +1108,41 @@ export class LaunchTui {
1032
1108
  // it is the live heartbeat and must always be visible; the in-flight card gets
1033
1109
  // whatever rows remain above it.
1034
1110
  const tail: string[] = [];
1111
+ // Live reasoning (gjc-style muted thinking): stream the model's forming thought
1112
+ // as a DIMMED, bounded block above the status line. It is transient — flushed
1113
+ // UN-dimmed into scrollback once the model commits to a tool/reply (onAssistant),
1114
+ // so the in-progress trace stays shaded while the final record reads in normal text.
1115
+ const liveThink = this.streamingThought.trim() || this.streamingReasoning.trim();
1116
+ if (isThinking && liveThink) {
1117
+ const wrapW = Math.max(8, Math.min(120, cols) - 2);
1118
+ const wrapped = liveThink
1119
+ .split("\n")
1120
+ .flatMap(l => wrapTextWithAnsi(l, wrapW))
1121
+ .filter(l => l.length > 0);
1122
+ const shown = wrapped.slice(-6); // bottom-anchored tail of the live trace
1123
+ if (shown.length) {
1124
+ tail.push(dim(`${this.unicode ? "│" : "|"} thinking`));
1125
+ for (const l of shown) tail.push(dim(` ${l}`));
1126
+ tail.push("");
1127
+ }
1128
+ }
1129
+
1130
+ // Live tool output (gjc-style streaming bash stdout): while a tool runs, its
1131
+ // output arrives via onToolProgress and is shown as a DIMMED, bounded tail block.
1132
+ // It is transient — cleared on result, when the formatted forge card takes over.
1133
+ if (this.runningTool && this.liveToolOutput.trim()) {
1134
+ const wrapW = Math.max(8, Math.min(120, cols) - 2);
1135
+ const wrapped = this.liveToolOutput
1136
+ .split("\n")
1137
+ .flatMap(l => wrapTextWithAnsi(l, wrapW))
1138
+ .filter(l => l.length > 0);
1139
+ const shown = wrapped.slice(-8); // bottom-anchored tail of the live output
1140
+ if (shown.length) {
1141
+ tail.push(dim(`${this.unicode ? "│" : "|"} output`));
1142
+ for (const l of shown) tail.push(dim(` ${l}`));
1143
+ tail.push("");
1144
+ }
1145
+ }
1035
1146
 
1036
1147
  // Live status field: unboxed thinking line + compact metrics row. The model's
1037
1148
  // streamed activity is uniform across providers via streamingActivity and keeps
@@ -1103,7 +1214,7 @@ export class LaunchTui {
1103
1214
  const forgeAnim = isThinking && this.theme.color && colorLevel >= ColorLevel.TrueColor
1104
1215
  ? { phase, colorLevel, beat }
1105
1216
  : undefined;
1106
- const forgeK = budget > 0 ? fitForgeBoxes(this.renderForge(cols, 2, forgeAnim), budget) : [];
1217
+ const forgeK = budget > 0 ? fitForgeBoxes(this.renderForge(cols, 2, forgeAnim, true), budget) : [];
1107
1218
  if (forgeK.length) {
1108
1219
  frame.push(...forgeK);
1109
1220
  frame.push("");
@@ -1,7 +1,7 @@
1
1
  import chalk from "chalk";
2
2
  import { BOX_ASCII, BOX_UNICODE, padLineTo, type BoxGlyphs } from "./layout";
3
- import { stripAnsi, visibleWidth, animatedGradientText } from "./color";
4
- import { truncateToWidth } from "./width";
3
+ import { visibleWidth, animatedGradientText } from "./color";
4
+ import { truncateToWidth, wrapTextWithAnsi } from "./width";
5
5
  import { lightHighlightLine } from "./code-view";
6
6
  import { type UiCategory } from "./category-index";
7
7
 
@@ -33,6 +33,16 @@ export interface ForgeBoxOptions {
33
33
  * the FULL padded row so added/removed lines read as background-tinted
34
34
  * stripes — block-level contrast inside the card. */
35
35
  diffPaint?: { add: (s: string) => string; del: (s: string) => string };
36
+ /** Faint card background tint painter — applied to every WHOLE box row (borders
37
+ * + content) so the card reads as a panel. Interior coloring uses targeted close
38
+ * codes, so the fill spans each row. Identity/omitted = transparent card. Patch
39
+ * `diffPaint` rows set their own background and intentionally override it. */
40
+ fill?: (s: string) => string;
41
+ /** In-flight shading: render the card as a flat DIMMED block (gjc-style "not done
42
+ * yet" look). Strips inner color and wraps every row in `chalk.dim`, so a live
43
+ * card reads as shaded until the result arrives and the normal formatted card
44
+ * replaces it. Overrides `fill`/`flow` coloring. */
45
+ dim?: boolean;
36
46
  }
37
47
 
38
48
  const SECRET_VALUE_RE = /(api[_-]?key|authorization|bearer|password|secret|token)(\s*[:=]\s*)(["']?)[^"'\s,}]+/gi;
@@ -421,12 +431,13 @@ export function summarizeForgeResult(tool: string, success: boolean, output: str
421
431
  }
422
432
 
423
433
  function wrapPlainLine(line: string, width: number): string[] {
424
- const plain = stripAnsi(line);
425
434
  if (width <= 0) return [""];
435
+ // Wrap by DISPLAY width (SGR-aware, wide-glyph-aware) so CJK/emoji content
436
+ // breaks on column boundaries and never overflows the card border. The old
437
+ // `slice(i, i+width)` counted code points, so a Hangul/CJK line (2 cols each)
438
+ // rendered ~2× the intended width and tore the right edge.
426
439
  if (visibleWidth(line) <= width) return [line];
427
- const out: string[] = [];
428
- for (let i = 0; i < plain.length; i += width) out.push(plain.slice(i, i + width));
429
- return out;
440
+ return wrapTextWithAnsi(line, width);
430
441
  }
431
442
 
432
443
  function borderGlyphs(unicode: boolean | undefined): BoxGlyphs {
@@ -558,5 +569,12 @@ export function formatForgeBox(summary: ForgeSummary, opts: ForgeBoxOptions = {}
558
569
  rendered.push(contentRow(`… ${content.length - clipped.length} more lines ${hint}`));
559
570
  }
560
571
  rendered.push(bottom);
561
- return rendered;
572
+ // In-flight shading takes precedence: strip inner color and dim every row so a
573
+ // live card reads as a flat shaded block until its formatted result replaces it.
574
+ if (opts.dim) {
575
+ return rendered.map(l => chalk.dim(l.replace(/\x1b\[[0-9;]*m/g, "")));
576
+ }
577
+ // Faint panel tint: wrap each whole, width-padded row so the card background spans
578
+ // the full rectangle (borders + content). No-op when no fill painter is supplied.
579
+ return opts.fill ? rendered.map(opts.fill) : rendered;
562
580
  }
@@ -115,7 +115,7 @@ export function renderInputFrame(line: string, opts: InputBoxOptions = {}): Inpu
115
115
  if (crow - hidden < 0) { visRow = 0; ccol = 0; }
116
116
 
117
117
  const promptMark = "> ";
118
- const paintPrompt = useColor ? (opts.accent ?? chalk.red) : (s: string) => s;
118
+ const paintPrompt = useColor ? (opts.accent ?? chalk.blueBright) : (s: string) => s;
119
119
  const paintGhost = useColor ? chalk.dim : (s: string) => s;
120
120
  const body = rows.map((r, i) => {
121
121
  const content = placeholderRow ? paintGhost(r) : r;
@@ -123,11 +123,16 @@ export function renderInputFrame(line: string, opts: InputBoxOptions = {}): Inpu
123
123
  });
124
124
 
125
125
  const content = [...body];
126
+ // Label rows follow the active theme: the attachment hint uses the accent and
127
+ // the cwd label a dimmed accent (shadow), so the whole box reads in one tone
128
+ // instead of off-theme cyan/gray.
129
+ const labelAccent = useColor ? (opts.accent ?? chalk.cyan) : (s: string) => s;
130
+ const labelMuted = useColor ? (opts.accentShadow ?? chalk.gray) : (s: string) => s;
126
131
  if (opts.attachmentLabel) {
127
- content.push(useColor ? chalk.cyan(opts.attachmentLabel) : opts.attachmentLabel);
132
+ content.push(labelAccent(opts.attachmentLabel));
128
133
  }
129
134
  if (opts.cwdLabel) {
130
- content.push(useColor ? chalk.gray(opts.cwdLabel) : opts.cwdLabel);
135
+ content.push(labelMuted(opts.cwdLabel));
131
136
  }
132
137
  const glyphs = opts.unicode === false ? BOX_ASCII : BOX_UNICODE;
133
138
  // Depth cue: lit top/left edge (bright accent) vs shaded bottom/right edge (dim).
@@ -64,7 +64,13 @@ export function renderMarkdownAnsi(text: string, opts: MarkdownAnsiOptions = {})
64
64
  .replace(/`([^`]+)`/g, (_m, code: string) => chalk.cyan(code))
65
65
  .replace(/\*\*\*([^\*]+)\*\*\*/g, (_m, t: string) => chalk.bold.italic(t))
66
66
  .replace(/\*\*([^\*]+)\*\*/g, (_m, t: string) => chalk.bold(t))
67
- .replace(/__([^\_]+)__/g, (_m, t: string) => chalk.bold(t));
67
+ .replace(/__([^\_]+)__/g, (_m, t: string) => chalk.bold(t))
68
+ // Single *italic* / _italic_ run AFTER the ***/**/__ passes so the doubles
69
+ // are already consumed. The `*` form ignores list bullets ("* item" has no
70
+ // closing `*`); the `_` form is word-boundary guarded so snake_case
71
+ // identifiers (foo_bar_baz) are never mistaken for emphasis.
72
+ .replace(/\*([^\s*][^*\n]*?[^\s*]|[^\s*])\*/g, (_m, t: string) => chalk.italic(t))
73
+ .replace(/(?<![\p{L}\p{N}_])_(?=\S)([^_\n]*?)(?<=\S)_(?![\p{L}\p{N}_])/gu, (_m, t: string) => chalk.italic(t));
68
74
 
69
75
  const out: string[] = [];
70
76
  let inFence = false;
@@ -79,6 +85,9 @@ export function renderMarkdownAnsi(text: string, opts: MarkdownAnsiOptions = {})
79
85
  }
80
86
  const heading = line.match(/^(#{1,6})\s+(.+)$/);
81
87
  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("");
82
91
  out.push(accent(styleInline(heading[2]!)));
83
92
  continue;
84
93
  }
@@ -32,6 +32,16 @@ export interface EvolutionTheme {
32
32
  * `addBg`/`delBg` are full-row background tints that give added/removed
33
33
  * lines block-level separation, not just a colored sign. */
34
34
  diff?: { add: string; del: string; addBg: string; delBg: string; hunk: string };
35
+ /** User query card palette: themed colors for the mid-turn steering user card. */
36
+ userCard?: { accent: string; border: string; shadow: string; fill: string };
37
+ /** Muted foreground for secondary text (done/pending todo items, counts, tree
38
+ * connectors). A real readable mid-tone — replaces `chalk.dim`, which washes
39
+ * out to near-invisible on dark backgrounds. Falls back to a neutral gray. */
40
+ muted?: string;
41
+ /** Faint background tint for tool/todo cards — a subtle panel fill so cards read
42
+ * as distinct blocks instead of floating on the terminal background. Falls back
43
+ * to `userCard.fill`. No fill when the theme is colorless. */
44
+ card?: { fill: string };
35
45
  }
36
46
 
37
47
  /** Default diff palette (used when a theme defines none): high-contrast
@@ -44,13 +54,14 @@ export const DEFAULT_DIFF_PALETTE = {
44
54
  hunk: "#7dcfff",
45
55
  } as const;
46
56
 
47
- const COSMIC: EvolutionTheme = {
57
+ const COSMIC: EvolutionTheme = {
48
58
  name: "cosmic",
49
59
  description: "Default — deep-space arc from cyan tide to white-hot singularity.",
50
60
  gradients: EVOLUTION_STAGE_GRADIENTS,
51
61
  color: true,
52
62
  accent: "#48dbfb",
53
63
  accentShadow: "#1b6f8c",
64
+ userCard: { accent: "#48dbfb", border: "#1b6f8c", shadow: "#0e3c4c", fill: "#081b24" },
54
65
  };
55
66
 
56
67
  const MATRIX: EvolutionTheme = {
@@ -67,6 +78,7 @@ const MATRIX: EvolutionTheme = {
67
78
  accent: "#39ff14",
68
79
  accentShadow: "#0b6623",
69
80
  diff: { add: "#7fff00", del: "#ff5f5f", addBg: "#0c2410", delBg: "#2a1212", hunk: "#00e5a0" },
81
+ userCard: { accent: "#39ff14", border: "#0b6623", shadow: "#053311", fill: "#031a08" },
70
82
  };
71
83
 
72
84
  const SOLAR: EvolutionTheme = {
@@ -82,6 +94,7 @@ const SOLAR: EvolutionTheme = {
82
94
  color: true,
83
95
  accent: "#ff8c00",
84
96
  accentShadow: "#8a4500",
97
+ userCard: { accent: "#ff8c00", border: "#8a4500", shadow: "#452200", fill: "#241100" },
85
98
  };
86
99
 
87
100
  const RED_CLAW: EvolutionTheme = {
@@ -97,6 +110,7 @@ const RED_CLAW: EvolutionTheme = {
97
110
  color: true,
98
111
  accent: "#e25656",
99
112
  accentShadow: "#5c0f0f",
113
+ userCard: { accent: "#e25656", border: "#5c0f0f", shadow: "#2e0707", fill: "#170303" },
100
114
  };
101
115
 
102
116
  const BLUE_CRAB: EvolutionTheme = {
@@ -113,6 +127,7 @@ const BLUE_CRAB: EvolutionTheme = {
113
127
  accent: "#0096c7",
114
128
  accentShadow: "#023e8a",
115
129
  diff: { add: "#06d6a0", del: "#ef476f", addBg: "#0a2922", delBg: "#2b1320", hunk: "#48cae4" },
130
+ userCard: { accent: "#0096c7", border: "#023e8a", shadow: "#011f45", fill: "#000f24" },
116
131
  };
117
132
 
118
133
  const AURORA: EvolutionTheme = {
@@ -129,6 +144,7 @@ const AURORA: EvolutionTheme = {
129
144
  accent: "#3ddad7",
130
145
  accentShadow: "#1d5c8f",
131
146
  diff: { add: "#16c79a", del: "#fd7c9b", addBg: "#0c2620", delBg: "#2a1626", hunk: "#7c83fd" },
147
+ userCard: { accent: "#3ddad7", border: "#1d5c8f", shadow: "#0e2e47", fill: "#071724" },
132
148
  };
133
149
 
134
150
  const SYNTHWAVE: EvolutionTheme = {
@@ -145,6 +161,7 @@ const SYNTHWAVE: EvolutionTheme = {
145
161
  accent: "#ec38bc",
146
162
  accentShadow: "#5b1a8a",
147
163
  diff: { add: "#03e9f4", del: "#ff5e99", addBg: "#0a2330", delBg: "#33122a", hunk: "#b388eb" },
164
+ userCard: { accent: "#ec38bc", border: "#5b1a8a", shadow: "#2d0d45", fill: "#160624" },
148
165
  };
149
166
 
150
167
  const SAKURA: EvolutionTheme = {
@@ -161,6 +178,7 @@ const SAKURA: EvolutionTheme = {
161
178
  accent: "#d6336c",
162
179
  accentShadow: "#862e59",
163
180
  diff: { add: "#37b24d", del: "#e03131", addBg: "#13260f", delBg: "#2b1212", hunk: "#cc5de8" },
181
+ userCard: { accent: "#d6336c", border: "#862e59", shadow: "#43172c", fill: "#210b16" },
164
182
  };
165
183
 
166
184
  const MONO: EvolutionTheme = {
@@ -267,6 +285,44 @@ export function accentShadowPaint(theme: EvolutionTheme): (s: string) => string
267
285
  return (s: string) => chalk.dim(chalk.hex(hex)(s));
268
286
  }
269
287
 
288
+ /** Default muted foreground when a theme defines no `muted` — a readable mid-gray
289
+ * that holds up on dark terminals (unlike ANSI `dim`, which collapses toward the
290
+ * background). */
291
+ export const DEFAULT_MUTED = "#9aa0a6";
292
+
293
+ /** Painter for secondary/muted text. A real mid-tone hue, NOT `chalk.dim` — dim
294
+ * renders near-invisible on dark backgrounds (the washed-out done/pending todo
295
+ * rows). Identity when the theme is colorless. */
296
+ export function mutedPaint(theme: EvolutionTheme): (s: string) => string {
297
+ if (!theme.color) return (s: string) => s;
298
+ const hex = theme.muted ?? DEFAULT_MUTED;
299
+ return (s: string) => chalk.hex(hex)(s);
300
+ }
301
+
302
+ /** Lighten a `#rrggbb` hex by a per-channel delta (clamped 0–255). Used to lift a
303
+ * near-black `userCard.fill` (tuned for input chrome) into a card tint that reads
304
+ * as a distinct panel without becoming loud. */
305
+ export function liftHex(hex: string, delta: number): string {
306
+ const m = /^#?([0-9a-fA-F]{6})$/.exec(hex.trim());
307
+ if (!m) return hex;
308
+ const n = parseInt(m[1], 16);
309
+ const ch = (shift: number) => Math.max(0, Math.min(255, ((n >> shift) & 0xff) + delta));
310
+ return "#" + [ch(16), ch(8), ch(0)].map(v => v.toString(16).padStart(2, "0")).join("");
311
+ }
312
+
313
+ /** Painter that applies the theme's faint card background tint, so tool/todo cards
314
+ * read as panels. Uses an explicit `card.fill`, else lifts `userCard.fill` (which is
315
+ * near-black, tuned for input chrome) by a small delta so the panel separates from
316
+ * the terminal background. Identity when the theme is colorless or has no fill. The
317
+ * fill wraps whole, width-padded lines whose interior coloring uses targeted close
318
+ * codes (never a full `\x1b[0m` reset), so the background spans the row. */
319
+ export function cardFillPaint(theme: EvolutionTheme): (s: string) => string {
320
+ if (!theme.color) return (s: string) => s;
321
+ const fill = theme.card?.fill ?? (theme.userCard?.fill ? liftHex(theme.userCard.fill, 12) : undefined);
322
+ if (!fill) return (s: string) => s;
323
+ return (s: string) => chalk.bgHex(fill)(s);
324
+ }
325
+
270
326
  /** Themed diff painters: foreground + full-row background tints for added /
271
327
  * removed lines (block-level separation, not just a colored sign) and a
272
328
  * distinct hunk-header color. Identity painters when the theme is colorless. */
@@ -1,4 +1,6 @@
1
1
  import chalk from "chalk";
2
+ import { padLineTo } from "./layout";
3
+ import { visibleWidth } from "./color";
2
4
 
3
5
  export interface TodoCardItem {
4
6
  title: string;
@@ -8,40 +10,69 @@ export interface TodoCardItem {
8
10
  export interface TodoCardOptions {
9
11
  unicode?: boolean;
10
12
  color?: boolean;
13
+ /** Faint card background tint painter — gives the card a panel look so it reads
14
+ * as a distinct block. Identity when absent (or colorless). */
15
+ fill?: (s: string) => string;
16
+ /** Muted foreground painter for secondary text (done/pending labels, count,
17
+ * tree connectors). Replaces `chalk.dim`, which washes out on dark terminals. */
18
+ muted?: (s: string) => string;
19
+ /** Accent painter for the in_progress (active) item — the theme's highlight hue,
20
+ * applied bold. Defaults to cyan so the active row matches the rest of the theme
21
+ * instead of a hardcoded color that clashes on warm/green/red palettes. */
22
+ accent?: (s: string) => string;
23
+ /** Panel width: lines pad to this so the fill spans a clean rectangle. Clamped
24
+ * to [20,120]; defaults to the longest content row + 2. */
25
+ width?: number;
11
26
  }
12
27
 
13
28
  /**
14
29
  * jeo-ref "Todo Write" scrollback card: a ✓-led header with a tree-connector
15
30
  * checklist — done items get ☑ + strikethrough, the active item highlights,
16
- * pending stays dim. Pure `string[]`; the caller flushes it into the ledger so
17
- * the plan's evolution (items checking off turn by turn) reads as transcript
18
- * history, exactly like the reference TUI.
31
+ * pending stays muted. A faint background tint (when a `fill` painter is given)
32
+ * makes the whole block read as a panel; secondary text uses a real muted hue
33
+ * instead of ANSI dim so completed rows stay legible. Pure `string[]`; the caller
34
+ * flushes it into the ledger so the plan's evolution reads as transcript history.
19
35
  */
20
36
  export function formatTodoWriteCard(items: TodoCardItem[], opts: TodoCardOptions = {}): string[] {
21
37
  if (items.length === 0) return [];
22
38
  const unicode = opts.unicode !== false;
23
39
  const color = opts.color !== false;
40
+ const fill = opts.fill ?? ((s: string) => s);
41
+ const muted = color ? (opts.muted ?? chalk.dim) : (s: string) => s;
42
+ const active = color ? (opts.accent ? (s: string) => chalk.bold(opts.accent!(s)) : chalk.cyan.bold) : (s: string) => s;
24
43
  const check = unicode ? "✓" : "v";
25
44
  const boxDone = unicode ? "☑" : "[x]";
26
45
  const boxOpen = unicode ? "☐" : "[ ]";
27
46
  const tee = unicode ? "├─" : "|-";
28
47
  const ell = unicode ? "└─" : "`-";
29
48
  const count = `${items.length} task${items.length === 1 ? "" : "s"}`;
30
- const head = color
31
- ? `${chalk.green(check)} ${chalk.bold("Todo Write")} ${chalk.dim(count)}`
32
- : `${check} Todo Write ${count}`;
33
- const lines = [head];
49
+
50
+ const rows: string[] = [];
51
+ rows.push(
52
+ color
53
+ ? `${chalk.green(check)} ${chalk.bold("Todo Write")} ${muted(count)}`
54
+ : `${check} Todo Write ${count}`,
55
+ );
34
56
  items.forEach((item, i) => {
35
- const conn = i === items.length - 1 ? ell : tee;
57
+ const conn = color ? muted(i === items.length - 1 ? ell : tee) : i === items.length - 1 ? ell : tee;
36
58
  if (item.status === "done") {
37
59
  const box = color ? chalk.green(boxDone) : boxDone;
38
- const label = color ? chalk.dim.strikethrough(item.title) : item.title;
39
- lines.push(` ${conn} ${box} ${label}`);
60
+ const label = color ? chalk.strikethrough(muted(item.title)) : item.title;
61
+ rows.push(` ${conn} ${box} ${label}`);
40
62
  } else if (item.status === "in_progress") {
41
- lines.push(` ${conn} ${boxOpen} ${color ? chalk.cyan.bold(item.title) : item.title}`);
63
+ rows.push(` ${conn} ${boxOpen} ${active(item.title)}`);
42
64
  } else {
43
- lines.push(` ${conn} ${boxOpen} ${color ? chalk.dim(item.title) : item.title}`);
65
+ rows.push(` ${conn} ${boxOpen} ${color ? muted(item.title) : item.title}`);
44
66
  }
45
67
  });
46
- return lines;
68
+
69
+ // Panel: pad each row (with a 1-col left gutter) to a common width and tint the
70
+ // whole rectangle — only when the caller opts in via `fill`/`width`; otherwise the
71
+ // bare tree-checklist rows return unchanged (back-compat). visibleWidth ignores
72
+ // ANSI, and every painter above closes with a TARGETED reset (never `\x1b[0m`), so
73
+ // the background spans the full row.
74
+ if (!opts.fill && opts.width === undefined) return rows;
75
+ const contentWidth = rows.reduce((m, r) => Math.max(m, visibleWidth(r)), 0) + 2;
76
+ const panelWidth = Math.max(20, Math.min(120, opts.width ?? contentWidth));
77
+ return rows.map(r => fill(padLineTo(` ${r}`, panelWidth, "left")));
47
78
  }