jeo-code 0.4.6 → 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.
@@ -3,6 +3,8 @@ import { runAgentLoop, executorSystemPrompt, DEFAULT_TOOLS, TOOL_PROTOCOL, WORKI
3
3
  import { initialDynamicStepLimit } from "../agent/step-budget";
4
4
  import { memoryPromptSection, spawnDetachedDistill } from "../agent/memory";
5
5
  import { createTaskTool, taskToolProtocolLine, type TaskSubEvent } from "../agent/task-tool";
6
+ import { createSubagentTool, SUBAGENT_TOOL_PROTOCOL_LINE } from "../agent/subagent-tool";
7
+ import { SubagentRegistry } from "../agent/subagent-registry";
6
8
  import { createTodoTool, TODO_TOOL_PROTOCOL_LINE } from "../agent/todo-tool";
7
9
  import { LaunchTui } from "../tui/app";
8
10
  import { runDeepInterviewEngine } from "./deep-interview";
@@ -19,7 +21,7 @@ import { staticCompletionContext, readlineCompleter, formatCompletionPreview, to
19
21
  import { EVOLUTION_STAGES, animateAsciiArt } from "../tui/components/ascii-art";
20
22
  import { getEvolutionTip } from "../tui/components/evolution";
21
23
  import { renderWelcome, playWelcomeSweep } from "../tui/components/welcome";
22
- import { checkForUpdate } from "../util/update-check";
24
+ import { checkForUpdate, readUpdateCache, writeUpdateCache } from "../util/update-check";
23
25
  import { jeoEnv } from "../util/env";
24
26
  import { renderUpdateBox } from "../tui/components/update-box";
25
27
  import { supportsUnicode } from "../tui/components/capability";
@@ -379,7 +381,8 @@ function streamResultSuffix(tool: string, ok: boolean, output: string | undefine
379
381
 
380
382
  export function formatTaskSubEvent(e: TaskSubEvent): string {
381
383
  const role = e.role || "subagent";
382
- const roleLabel = role.toUpperCase();
384
+ const roleLabel = e.index && e.total ? `${role.toUpperCase()}[${e.index}/${e.total}]` : role.toUpperCase();
385
+ const tokTag = e.tokens ? ` (${e.tokens.input + e.tokens.output} tok)` : "";
383
386
  const detail = firstOutputLine(e.detail);
384
387
  const summary = e.summary ? ` — ${e.summary}` : "";
385
388
  // No ` step N/M` marker — step counters carry no meaning under the dynamic
@@ -390,7 +393,7 @@ export function formatTaskSubEvent(e: TaskSubEvent): string {
390
393
  if (e.kind === "step") return ` ${badge} ${chalk.cyan(`├─ ${roleLabel}`)} · ${detail || "working"}`;
391
394
  if (e.kind === "tool") return ` ${badge} ${e.success === false ? chalk.red("├─") : chalk.green("├─")} ${roleLabel} ${e.success === false ? chalk.red("✗") : chalk.green("✓")} ${detail || "tool"}${summary}`;
392
395
  if (e.kind === "error") return ` ${badge} ${chalk.red("├─")} ${roleLabel} ${chalk.red("✗")} ${detail || "error"}`;
393
- return `${badge} ${e.success === false ? chalk.red("└─") : chalk.green("└─")} ${roleLabel} done${e.success === false ? " (incomplete)" : ""}${detail ? `: ${detail}` : ""}`;
396
+ return `${badge} ${e.success === false ? chalk.red("└─") : chalk.green("└─")} ${roleLabel} done${tokTag}${e.success === false ? " (incomplete)" : ""}${detail ? `: ${detail}` : ""}`;
394
397
  }
395
398
 
396
399
  function logTaskSubEvent(e: TaskSubEvent, log: (line: string) => void = (s: string) => console.log(s)): void {
@@ -1141,7 +1144,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1141
1144
  // pi-style: load project context (JEO.md / AGENTS.md / .jeo/context.md / CLAUDE.md) into the prompt.
1142
1145
  const contextFiles = await loadProjectContext(cwd);
1143
1146
 
1144
- const KNOWN_TOOLS = new Set(["read", "write", "edit", "bash", "find", "search", "ls", "task", "todo"]);
1147
+ const KNOWN_TOOLS = new Set(["read", "write", "edit", "bash", "find", "search", "ls", "task", "todo", "subagent"]);
1145
1148
  let allowedTools = new Set(KNOWN_TOOLS);
1146
1149
 
1147
1150
  if (flags.noTools) {
@@ -1196,6 +1199,8 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1196
1199
  (allowedTools.has("task") ? "\n\nDelegation: " + taskToolProtocolLine(cfg) +
1197
1200
  " Call task with {\"role\": <one of the advertised roles>, \"task\": <assignment>, \"context\": <optional>} to hand a focused slice to a subagent." : "") +
1198
1201
  (allowedTools.has("todo") ? "\n\nPlanning: " + TODO_TOOL_PROTOCOL_LINE : "") +
1202
+ (allowedTools.has("subagent") ? "\n\nDetached subagents: " + SUBAGENT_TOOL_PROTOCOL_LINE +
1203
+ " Launch background work with task {\"detached\": true, \"role\": <role>, \"task\": <assignment>}; it returns a subagent id immediately so you can keep working and collect the result later." : "") +
1199
1204
  (effectiveNoSkills ? "" :
1200
1205
  "\n\nJEO workflow routing:\n" +
1201
1206
  "- Answer the user's request DIRECTLY. Never reply with a catalog, list, or summary of skills unless the user explicitly asks what skills exist.\n" +
@@ -1355,6 +1360,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1355
1360
  else console.log(warn);
1356
1361
  }
1357
1362
  history.push(images?.length ? { role: "user", content: userInput, images } : { role: "user", content: userInput });
1363
+ // Keep the submitted query in scrollback: the prompt that STARTS a turn shows
1364
+ // only as the transient HUD turn-title otherwise, which vanishes when the live
1365
+ // frame clears at turn-end — so the conversation transcript lost every user
1366
+ // prompt. Flush a `user` card (same surface as a mid-turn steer) so it persists.
1367
+ if (tui && userInput.trim()) tui.flushUserCard(userInput);
1358
1368
  tui?.setContextUsage(historyTokens(history), contextTokens);
1359
1369
 
1360
1370
  // Per-turn steering inbox (gjc parity): additional queries typed mid-turn land
@@ -1408,9 +1418,15 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1408
1418
  return;
1409
1419
  }
1410
1420
  }
1411
- // Keep the SAME query input box visible during a live turn. Printable
1412
- // keystrokes edit the next prompt draft; there is no separate queue surface.
1413
- if (captured) tui.setLivePromptInput(queueBusySnapshot?.().text ?? "");
1421
+ // Mid-turn additional input is committed (and shown) ONLY on Enter (above):
1422
+ // the running turn does NOT echo half-typed text per keystroke. Captured
1423
+ // printable input accumulates silently in the draft buffer and surfaces as a
1424
+ // `user` card the moment Enter lifts it into the steering inbox (or folds into
1425
+ // the next prompt if the turn ends first). JEO_LIVE_DRAFT=1 restores the
1426
+ // legacy live per-keystroke echo in the input box.
1427
+ if (captured && jeoEnv("LIVE_DRAFT") === "1") {
1428
+ tui.setLivePromptInput(queueBusySnapshot?.().text ?? "");
1429
+ }
1414
1430
  },
1415
1431
  onAbortNotice: msg => {
1416
1432
  if (tui) tui.events().onNotice?.(msg);
@@ -1422,6 +1438,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1422
1438
  },
1423
1439
  });
1424
1440
  const ac = harness.controller;
1441
+ // #9: per-turn registry for DETACHED subagents (task{detached:true}); the
1442
+ // `subagent` tool controls them and cancelAll() in finally prevents orphans.
1443
+ const subagentRegistry = new SubagentRegistry();
1425
1444
  try {
1426
1445
  // Per-turn todo snapshot: drives the done-time reconciliation gate (the
1427
1446
  // Todos checklist used to end a finished turn stuck at "✓0 ◐1 ·4 / 5"
@@ -1442,11 +1461,13 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1442
1461
  config: { ...turnConfig, defaultModel: activeModel },
1443
1462
  signal: ac.signal,
1444
1463
  steer: drainSteer,
1464
+ registry: subagentRegistry,
1445
1465
  onEvent: useTui
1446
1466
  ? (e => tui?.onSubagentEvent(e))
1447
1467
  : (e => logTaskSubEvent(e)),
1448
1468
  }),
1449
1469
  todo: createTodoTool({ onChange: items => { turnTodos = items; tui?.setTodos(items); } }),
1470
+ subagent: createSubagentTool(subagentRegistry),
1450
1471
  };
1451
1472
  const tools = filterToolMap(fullTools, Array.from(allowedTools));
1452
1473
  result = await runAgentLoop(history, {
@@ -1488,6 +1509,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1488
1509
  }
1489
1510
  } finally {
1490
1511
  harness.dispose();
1512
+ subagentRegistry.cancelAll(); // #9: no detached run leaks past the turn
1491
1513
  // Steering typed but never drained (e.g. entered just after the final step)
1492
1514
  // must not be lost — fold it into the next prompt draft so it runs next.
1493
1515
  const leftover = steerInbox.splice(0, steerInbox.length).map(s => s.trim()).filter(Boolean);
@@ -1660,6 +1682,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1660
1682
 
1661
1683
  // INTERACTIVE mode
1662
1684
  const updatePromise = checkForUpdate({ timeoutMs: 2500 });
1685
+ // Refresh the on-disk update cache for the NEXT launch regardless of whether
1686
+ // this launch's bounded wait below catches the result. Screen-safe: writes
1687
+ // only, never renders (rendering after the prompt is armed would corrupt the
1688
+ // boxed input footer).
1689
+ void updatePromise.then(u => { if (u) void writeUpdateCache(u.latest); }).catch(() => {});
1663
1690
  // Terminal hygiene BEFORE anything renders: a previous program (or stale tmux
1664
1691
  // pane) can leave xterm mouse-tracking ON, so the terminal reports clicks and
1665
1692
  // motion as escape sequences from the very first prompt — the "starts out
@@ -1695,8 +1722,18 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1695
1722
  if (sweepable) await playWelcomeSweep(welcomeData, { cycles: sweepCycles });
1696
1723
  else console.log(renderWelcome(welcomeData).join("\n"));
1697
1724
 
1698
- const upd = await Promise.race([updatePromise, new Promise<null>(r => setTimeout(() => r(null), 1200))]);
1699
- if (upd?.updateAvailable) console.log(renderUpdateBox(upd.current, upd.latest).join("\n"));
1725
+ // Surface the "New version" banner reliably: render ONCE from the on-disk cache
1726
+ // instantly (no network wait, works offline — the common path after the first
1727
+ // successful check), and ALSO from a bounded live check so a first run / version
1728
+ // bump still shows it this launch. Both must run BEFORE the prompt is armed.
1729
+ let updateBannerShown = false;
1730
+ const showUpdateBanner = (u: { current: string; latest: string; updateAvailable: boolean } | null): void => {
1731
+ if (updateBannerShown || !u?.updateAvailable) return;
1732
+ updateBannerShown = true;
1733
+ console.log(renderUpdateBox(u.current, u.latest).join("\n"));
1734
+ };
1735
+ showUpdateBanner(await readUpdateCache(pkg.version));
1736
+ showUpdateBanner(await Promise.race([updatePromise, new Promise<null>(r => setTimeout(() => r(null), 1200))]));
1700
1737
  if (!LaunchTui.usable(flags.noTui)) console.log("(plain output)");
1701
1738
 
1702
1739
  const useTui = LaunchTui.usable(flags.noTui);
@@ -3238,6 +3275,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3238
3275
  for (const line of TOOL_PROTOCOL.split("\n")) console.log(` ${line}`);
3239
3276
  console.log(` ${taskToolProtocolLine(await readGlobalConfig())}`);
3240
3277
  console.log(` ${TODO_TOOL_PROTOCOL_LINE}`);
3278
+ console.log(` ${SUBAGENT_TOOL_PROTOCOL_LINE}`);
3241
3279
  continue;
3242
3280
  }
3243
3281
  if (input === "/hotkeys") {
@@ -403,7 +403,8 @@ async function executeTaskWithAgent(ctx: RalphSubagentPromptContext & { cwd: str
403
403
  // LLM summary failure does not halt team
404
404
  }
405
405
 
406
- let mutationsOk = 0; // round-8 parent audit: successful write/edit/bash count
406
+ let fileMutations = 0; // round-8 parent audit: successful write/edit/mkdir/delete
407
+ let bashRuns = 0; // bash counted apart so read-only bash isn't edit evidence
407
408
  const result = await runAgentLoop(history, {
408
409
  cwd: ctx.cwd,
409
410
  model,
@@ -429,7 +430,10 @@ async function executeTaskWithAgent(ctx: RalphSubagentPromptContext & { cwd: str
429
430
  }
430
431
  },
431
432
  onToolResult: (tool, ok) => {
432
- if (ok && (tool === "write" || tool === "edit" || tool === "bash")) mutationsOk++;
433
+ if (ok) {
434
+ if (tool === "write" || tool === "edit" || tool === "mkdir" || tool === "delete") fileMutations++;
435
+ else if (tool === "bash") bashRuns++;
436
+ }
433
437
  console.log(formatRalphStreamEvent(ok ? "complete" : "error", `tool ${tool}`, renderOpts));
434
438
  },
435
439
  onNotice: msg => console.log(formatRalphStreamEvent("step", msg, renderOpts)),
@@ -454,11 +458,14 @@ async function executeTaskWithAgent(ctx: RalphSubagentPromptContext & { cwd: str
454
458
  return false;
455
459
  }
456
460
 
457
- if (!role.readOnly && mutationsOk === 0) {
458
- // Round-8: a mutating role finished without ONE successful mutation — the
461
+ if (!role.readOnly && fileMutations === 0) {
462
+ // Round-8: a mutating role finished without a successful file mutation — the
459
463
  // task may be legitimately read-only, but its "Changed Files:" claim is
460
- // unverified; warn instead of silently trusting the report.
461
- console.log(formatRalphStreamEvent("error", `${role.title} completed WITHOUT any successful write/edit/bash — treat its changed-files claim as unverified.`, renderOpts));
464
+ // unverified. bash is tracked apart: an only-bash run MIGHT have mutated.
465
+ const msg = bashRuns === 0
466
+ ? `${role.title} completed WITHOUT any successful write/edit/bash — treat its changed-files claim as unverified.`
467
+ : `${role.title} completed with only bash (no write/edit) — verify its changed-files claim independently.`;
468
+ console.log(formatRalphStreamEvent("error", msg, renderOpts));
462
469
  }
463
470
  console.log(formatRalphStreamEvent("complete", `${role.title} finished task`, renderOpts));
464
471
  return true;
@@ -3,7 +3,6 @@ import deepDiveSkillRaw from "../prompts/skills/deep-dive/SKILL.md" with { type:
3
3
  import ralplanSkillRaw from "../prompts/skills/ralplan/SKILL.md" with { type: "text" };
4
4
  import teamSkillRaw from "../prompts/skills/team/SKILL.md" with { type: "text" };
5
5
  import ultragoalSkillRaw from "../prompts/skills/ultragoal/SKILL.md" with { type: "text" };
6
- import gjcSkillRaw from "../prompts/skills/gjc/SKILL.md" with { type: "text" };
7
6
 
8
7
  const MAX_SKILL_SUMMARY_CHARS = 180;
9
8
  const MAX_SKILL_DETAILS_CHARS = 8_000;
@@ -29,7 +28,6 @@ export const SKILLS: SkillDoc[] = [
29
28
  parseSkillMarkdown("ralplan", ralplanSkillRaw),
30
29
  parseSkillMarkdown("team", teamSkillRaw),
31
30
  parseSkillMarkdown("ultragoal", ultragoalSkillRaw),
32
- parseSkillMarkdown("gjc", gjcSkillRaw),
33
31
  ];
34
32
  export const BUILTIN_SKILL_NAMES = SKILLS.map(s => s.name.toLowerCase());
35
33
 
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;
@@ -544,10 +585,10 @@ export class LaunchTui {
544
585
  return [` ${accent("user")}`, top, ...mid, bottom];
545
586
  }
546
587
 
547
- /** Flush a `user` card into scrollback for a mid-turn steering query: signals that
548
- * the additional input was accepted and is now driving the running turn (gjc parity),
549
- * instead of only a transient status notice. */
550
- flushSteerCard(text: string): void {
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 {
551
592
  const t = (text ?? "").trim();
552
593
  if (!t || this.finished) return;
553
594
  const cols = Math.max(20, size().cols);
@@ -555,6 +596,12 @@ export class LaunchTui {
555
596
  if (lines.length) this.appendLedger(lines.join("\n"), "card");
556
597
  }
557
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
+
558
605
  /** Append a completed progress-ledger line. In inline mode the line is flushed
559
606
  * straight into normal scrollback ABOVE the live frame, so tmux / terminal
560
607
  * mouse-wheel can review the full progress history mid-turn (gjc-style); the
@@ -704,7 +751,7 @@ export class LaunchTui {
704
751
  if (this.finished) return;
705
752
  const color = this.theme.color;
706
753
  const role = e.role || "subagent";
707
- const roleLabel = role.toUpperCase();
754
+ const roleLabel = e.index && e.total ? `${role.toUpperCase()}[${e.index}/${e.total}]` : role.toUpperCase();
708
755
  const badge = categoryBadge("subagent", { color });
709
756
  const ok = this.unicode ? "✓" : "v";
710
757
  const bad = this.unicode ? "✗" : "x";
@@ -736,7 +783,7 @@ export class LaunchTui {
736
783
  case "done":
737
784
  this.subagentActive = false;
738
785
  this.subagentLive = null;
739
- 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");
740
787
  break;
741
788
  }
742
789
  this.draw();
@@ -960,6 +1007,7 @@ export class LaunchTui {
960
1007
  paint,
961
1008
  paintShadow,
962
1009
  diffPaint: diffPaint(this.theme),
1010
+ fill: cardFillPaint(this.theme),
963
1011
  color: this.theme.color,
964
1012
  });
965
1013
  this.appendLedger(lines.join("\n") + "\n", "card");
@@ -971,6 +1019,7 @@ export class LaunchTui {
971
1019
  width: number,
972
1020
  maxEntries: number,
973
1021
  anim?: { phase: number; colorLevel: ColorLevel; beat: string },
1022
+ dim = false,
974
1023
  ): string[] {
975
1024
  const floor = Math.min(24, width);
976
1025
  // Fill the available width (cap at formatForgeBox's own 120 ceiling) so an
@@ -987,12 +1036,14 @@ export class LaunchTui {
987
1036
  paint,
988
1037
  paintShadow: accentShadowPaint(this.theme),
989
1038
  diffPaint: diffPaint(this.theme),
1039
+ fill: cardFillPaint(this.theme),
990
1040
  index: i + 1,
991
1041
  color: this.theme.color,
1042
+ dim,
992
1043
  // DNA-flow identity on LIVE cards only: the flowing helix gradient rides
993
1044
  // the card border and the claw beat marks the title. Flushed/final cards
994
- // stay static scrollback never carries animation frames.
995
- ...(anim
1045
+ // stay static. Suppressed while `dim` (in-flight shading takes precedence).
1046
+ ...(anim && !dim
996
1047
  ? { flow: { palette: DNA_FLOW_PALETTE, phase: anim.phase, colorLevel: anim.colorLevel }, titleMark: anim.beat }
997
1048
  : {}),
998
1049
  }));
@@ -1057,6 +1108,41 @@ export class LaunchTui {
1057
1108
  // it is the live heartbeat and must always be visible; the in-flight card gets
1058
1109
  // whatever rows remain above it.
1059
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
+ }
1060
1146
 
1061
1147
  // Live status field: unboxed thinking line + compact metrics row. The model's
1062
1148
  // streamed activity is uniform across providers via streamingActivity and keeps
@@ -1128,7 +1214,7 @@ export class LaunchTui {
1128
1214
  const forgeAnim = isThinking && this.theme.color && colorLevel >= ColorLevel.TrueColor
1129
1215
  ? { phase, colorLevel, beat }
1130
1216
  : undefined;
1131
- const forgeK = budget > 0 ? fitForgeBoxes(this.renderForge(cols, 2, forgeAnim), budget) : [];
1217
+ const forgeK = budget > 0 ? fitForgeBoxes(this.renderForge(cols, 2, forgeAnim, true), budget) : [];
1132
1218
  if (forgeK.length) {
1133
1219
  frame.push(...forgeK);
1134
1220
  frame.push("");
@@ -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;
@@ -559,5 +569,12 @@ export function formatForgeBox(summary: ForgeSummary, opts: ForgeBoxOptions = {}
559
569
  rendered.push(contentRow(`… ${content.length - clipped.length} more lines ${hint}`));
560
570
  }
561
571
  rendered.push(bottom);
562
- 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;
563
580
  }
@@ -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
  }
@@ -34,6 +34,14 @@ export interface EvolutionTheme {
34
34
  diff?: { add: string; del: string; addBg: string; delBg: string; hunk: string };
35
35
  /** User query card palette: themed colors for the mid-turn steering user card. */
36
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 };
37
45
  }
38
46
 
39
47
  /** Default diff palette (used when a theme defines none): high-contrast
@@ -277,6 +285,44 @@ export function accentShadowPaint(theme: EvolutionTheme): (s: string) => string
277
285
  return (s: string) => chalk.dim(chalk.hex(hex)(s));
278
286
  }
279
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
+
280
326
  /** Themed diff painters: foreground + full-row background tints for added /
281
327
  * removed lines (block-level separation, not just a colored sign) and a
282
328
  * distinct hunk-header color. Identity painters when the theme is colorless. */