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.
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.zh.md +1 -1
- 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 +21 -71
- package/src/agent/loop.ts +2 -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 +119 -27
- package/src/agent/tool-output.ts +115 -0
- package/src/agent/tools.ts +42 -8
- package/src/ai/model-manager.ts +2 -11
- 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 +47 -9
- package/src/commands/team.ts +13 -6
- package/src/skills/catalog.ts +0 -2
- package/src/tui/app.ts +97 -11
- package/src/tui/components/forge.ts +18 -1
- package/src/tui/components/markdown-text.ts +10 -1
- package/src/tui/components/themes.ts +46 -0
- package/src/tui/components/todo-card.ts +44 -13
- 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/commands/launch.ts
CHANGED
|
@@ -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
|
-
//
|
|
1412
|
-
//
|
|
1413
|
-
|
|
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
|
-
|
|
1699
|
-
|
|
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") {
|
package/src/commands/team.ts
CHANGED
|
@@ -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
|
|
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
|
|
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 &&
|
|
458
|
-
// Round-8: a mutating role finished without
|
|
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
|
|
461
|
-
|
|
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;
|
package/src/skills/catalog.ts
CHANGED
|
@@ -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, {
|
|
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
|
|
548
|
-
*
|
|
549
|
-
*
|
|
550
|
-
|
|
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
|
|
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
|
-
|
|
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. */
|