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/README.ja.md +2 -2
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/README.zh.md +2 -2
- package/package.json +1 -1
- package/src/agent/dev/evolution-bridge.ts +36 -3
- package/src/agent/dev/self-analysis.ts +6 -1
- package/src/agent/engine.ts +76 -71
- package/src/agent/loop.ts +2 -0
- package/src/agent/step-budget.ts +10 -0
- package/src/agent/subagent-registry.ts +131 -0
- package/src/agent/subagent-tool.ts +89 -0
- package/src/agent/subagents.ts +22 -3
- package/src/agent/task-tool.ts +123 -19
- package/src/agent/tool-output.ts +115 -0
- package/src/agent/tools.ts +42 -8
- package/src/ai/model-manager.ts +9 -14
- package/src/ai/model-registry.ts +8 -3
- package/src/ai/providers/antigravity.ts +11 -2
- package/src/ai/providers/gemini.ts +12 -2
- package/src/ai/register-providers.ts +21 -0
- package/src/ai/types.ts +4 -0
- package/src/cli/runner.ts +0 -9
- package/src/commands/launch.ts +157 -52
- package/src/commands/team.ts +13 -6
- package/src/skills/catalog.ts +0 -2
- package/src/tui/app.ts +131 -20
- package/src/tui/components/forge.ts +25 -7
- package/src/tui/components/input-box.ts +8 -3
- package/src/tui/components/markdown-text.ts +10 -1
- package/src/tui/components/themes.ts +57 -1
- package/src/tui/components/todo-card.ts +44 -13
- package/src/tui/monitoring/hud-view.ts +53 -30
- package/src/util/update-check.ts +53 -0
- package/src/commands/gjc.ts +0 -52
- package/src/prompts/skills/gjc/AGENTS.md +0 -31
- package/src/prompts/skills/gjc/SKILL.md +0 -15
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, {
|
|
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
|
-
|
|
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
|
|
520
|
-
const
|
|
521
|
-
const
|
|
522
|
-
const
|
|
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
|
|
936
|
-
paintShadow
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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(
|
|
132
|
+
content.push(labelAccent(opts.attachmentLabel));
|
|
128
133
|
}
|
|
129
134
|
if (opts.cwdLabel) {
|
|
130
|
-
content.push(
|
|
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
|
|
17
|
-
* the
|
|
18
|
-
*
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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.
|
|
39
|
-
|
|
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
|
-
|
|
63
|
+
rows.push(` ${conn} ${boxOpen} ${active(item.title)}`);
|
|
42
64
|
} else {
|
|
43
|
-
|
|
65
|
+
rows.push(` ${conn} ${boxOpen} ${color ? muted(item.title) : item.title}`);
|
|
44
66
|
}
|
|
45
67
|
});
|
|
46
|
-
|
|
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
|
}
|