jeo-code 0.4.6 → 0.4.8
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 +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 +120 -14
- 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/tui/components/width.ts +51 -0
- package/src/tui/renderer.ts +38 -12
- 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";
|
|
@@ -30,7 +30,7 @@ import { costForUsage, formatCost } from "../ai/pricing";
|
|
|
30
30
|
import { renderMarkdownTables } from "./components/markdown-table";
|
|
31
31
|
|
|
32
32
|
import { stripMarkdown, renderMarkdownAnsi } from "./components/markdown-text";
|
|
33
|
-
import { visibleWidth, wrapTextWithAnsi } from "./components/width";
|
|
33
|
+
import { visibleWidth, wrapTextWithAnsi, truncateToWidth, sanitizeForFrame } from "./components/width";
|
|
34
34
|
import { categoryBadge } from "./components/category-index";
|
|
35
35
|
import { formatStepTimeline, stepsFromTools, formatStepHeader, formatStepTimelineCompact, type StepState } from "./components/step-timeline";
|
|
36
36
|
import { formatHintBar } from "./components/hints";
|
|
@@ -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,20 @@ export class LaunchTui {
|
|
|
394
426
|
this.hudPhase = "reporting";
|
|
395
427
|
}
|
|
396
428
|
},
|
|
429
|
+
onToolProgress: (_tool, partial) => {
|
|
430
|
+
if (this.finished) return;
|
|
431
|
+
// Sanitize raw child stdout (CR / EL / cursor-move escapes) before it enters the
|
|
432
|
+
// frame — unsanitized control bytes tore the renderer's next \x1b[2K (literal "2K")
|
|
433
|
+
// and hijacked the cursor, corrupting the live frame.
|
|
434
|
+
this.liveToolOutput = sanitizeForFrame(partial);
|
|
435
|
+
if (Date.now() - this.lastStreamDraw >= 100) {
|
|
436
|
+
this.lastStreamDraw = Date.now();
|
|
437
|
+
this.draw();
|
|
438
|
+
}
|
|
439
|
+
},
|
|
397
440
|
onToolResult: (tool, success, output) => {
|
|
398
441
|
this.runningTool = false;
|
|
442
|
+
this.liveToolOutput = ""; // formatted result card now replaces the live dim output
|
|
399
443
|
if (this.pendingIndex !== null) {
|
|
400
444
|
this.tools.finish(this.pendingIndex, success);
|
|
401
445
|
this.pendingIndex = null;
|
|
@@ -544,10 +588,10 @@ export class LaunchTui {
|
|
|
544
588
|
return [` ${accent("user")}`, top, ...mid, bottom];
|
|
545
589
|
}
|
|
546
590
|
|
|
547
|
-
/** Flush a `user` card into scrollback
|
|
548
|
-
*
|
|
549
|
-
*
|
|
550
|
-
|
|
591
|
+
/** Flush a `user` card into scrollback so a submitted query stays visible there
|
|
592
|
+
* (gjc parity), instead of only as the transient HUD turn-title / a status notice.
|
|
593
|
+
* Shared by the prompt that STARTS a turn and the mid-turn steering flush. */
|
|
594
|
+
flushUserCard(text: string): void {
|
|
551
595
|
const t = (text ?? "").trim();
|
|
552
596
|
if (!t || this.finished) return;
|
|
553
597
|
const cols = Math.max(20, size().cols);
|
|
@@ -555,6 +599,12 @@ export class LaunchTui {
|
|
|
555
599
|
if (lines.length) this.appendLedger(lines.join("\n"), "card");
|
|
556
600
|
}
|
|
557
601
|
|
|
602
|
+
/** Mid-turn steering query → a `user` card in scrollback (accepted input that is
|
|
603
|
+
* now driving the running turn). Alias of {@link flushUserCard}. */
|
|
604
|
+
flushSteerCard(text: string): void {
|
|
605
|
+
this.flushUserCard(text);
|
|
606
|
+
}
|
|
607
|
+
|
|
558
608
|
/** Append a completed progress-ledger line. In inline mode the line is flushed
|
|
559
609
|
* straight into normal scrollback ABOVE the live frame, so tmux / terminal
|
|
560
610
|
* mouse-wheel can review the full progress history mid-turn (gjc-style); the
|
|
@@ -704,7 +754,7 @@ export class LaunchTui {
|
|
|
704
754
|
if (this.finished) return;
|
|
705
755
|
const color = this.theme.color;
|
|
706
756
|
const role = e.role || "subagent";
|
|
707
|
-
const roleLabel = role.toUpperCase();
|
|
757
|
+
const roleLabel = e.index && e.total ? `${role.toUpperCase()}[${e.index}/${e.total}]` : role.toUpperCase();
|
|
708
758
|
const badge = categoryBadge("subagent", { color });
|
|
709
759
|
const ok = this.unicode ? "✓" : "v";
|
|
710
760
|
const bad = this.unicode ? "✗" : "x";
|
|
@@ -736,7 +786,7 @@ export class LaunchTui {
|
|
|
736
786
|
case "done":
|
|
737
787
|
this.subagentActive = false;
|
|
738
788
|
this.subagentLive = null;
|
|
739
|
-
this.appendLedger(`${badge} ${last} ${roleLabel} done${e.success === false ? " (incomplete)" : ""}: ${detail}\n`, "subagent");
|
|
789
|
+
this.appendLedger(`${badge} ${last} ${roleLabel} done${e.tokens ? ` (${e.tokens.input + e.tokens.output} tok)` : ""}${e.success === false ? " (incomplete)" : ""}: ${detail}\n`, "subagent");
|
|
740
790
|
break;
|
|
741
791
|
}
|
|
742
792
|
this.draw();
|
|
@@ -960,6 +1010,7 @@ export class LaunchTui {
|
|
|
960
1010
|
paint,
|
|
961
1011
|
paintShadow,
|
|
962
1012
|
diffPaint: diffPaint(this.theme),
|
|
1013
|
+
fill: cardFillPaint(this.theme),
|
|
963
1014
|
color: this.theme.color,
|
|
964
1015
|
});
|
|
965
1016
|
this.appendLedger(lines.join("\n") + "\n", "card");
|
|
@@ -971,6 +1022,7 @@ export class LaunchTui {
|
|
|
971
1022
|
width: number,
|
|
972
1023
|
maxEntries: number,
|
|
973
1024
|
anim?: { phase: number; colorLevel: ColorLevel; beat: string },
|
|
1025
|
+
dim = false,
|
|
974
1026
|
): string[] {
|
|
975
1027
|
const floor = Math.min(24, width);
|
|
976
1028
|
// Fill the available width (cap at formatForgeBox's own 120 ceiling) so an
|
|
@@ -987,12 +1039,14 @@ export class LaunchTui {
|
|
|
987
1039
|
paint,
|
|
988
1040
|
paintShadow: accentShadowPaint(this.theme),
|
|
989
1041
|
diffPaint: diffPaint(this.theme),
|
|
1042
|
+
fill: cardFillPaint(this.theme),
|
|
990
1043
|
index: i + 1,
|
|
991
1044
|
color: this.theme.color,
|
|
1045
|
+
dim,
|
|
992
1046
|
// DNA-flow identity on LIVE cards only: the flowing helix gradient rides
|
|
993
1047
|
// the card border and the claw beat marks the title. Flushed/final cards
|
|
994
|
-
// stay static
|
|
995
|
-
...(anim
|
|
1048
|
+
// stay static. Suppressed while `dim` (in-flight shading takes precedence).
|
|
1049
|
+
...(anim && !dim
|
|
996
1050
|
? { flow: { palette: DNA_FLOW_PALETTE, phase: anim.phase, colorLevel: anim.colorLevel }, titleMark: anim.beat }
|
|
997
1051
|
: {}),
|
|
998
1052
|
}));
|
|
@@ -1057,6 +1111,47 @@ export class LaunchTui {
|
|
|
1057
1111
|
// it is the live heartbeat and must always be visible; the in-flight card gets
|
|
1058
1112
|
// whatever rows remain above it.
|
|
1059
1113
|
const tail: string[] = [];
|
|
1114
|
+
// Live reasoning (gjc-style muted thinking): stream the model's forming thought
|
|
1115
|
+
// as a DIMMED, bounded block above the status line. It is transient — flushed
|
|
1116
|
+
// UN-dimmed into scrollback once the model commits to a tool/reply (onAssistant),
|
|
1117
|
+
// so the in-progress trace stays shaded while the final record reads in normal text.
|
|
1118
|
+
const liveThink = this.streamingThought.trim() || this.streamingReasoning.trim();
|
|
1119
|
+
if (isThinking && liveThink) {
|
|
1120
|
+
const wrapW = Math.max(8, Math.min(120, cols) - 2);
|
|
1121
|
+
const wrapped = liveThink
|
|
1122
|
+
.split("\n")
|
|
1123
|
+
.flatMap(l => wrapTextWithAnsi(l, wrapW))
|
|
1124
|
+
.filter(l => l.length > 0);
|
|
1125
|
+
// FIXED reserved height (bottom-anchored, blank-padded at top): once present the
|
|
1126
|
+
// block's row count is CONSTANT, so streaming content never changes the frame
|
|
1127
|
+
// height. The per-100ms height thrash that desynced the differential renderer
|
|
1128
|
+
// (duplicate model bar) is gone; height now toggles only at lifecycle boundaries.
|
|
1129
|
+
const ROWS = 6;
|
|
1130
|
+
const shown = wrapped.slice(-ROWS);
|
|
1131
|
+
tail.push(dim(`${this.unicode ? "│" : "|"} thinking`));
|
|
1132
|
+
for (let k = 0; k < ROWS - shown.length; k++) tail.push("");
|
|
1133
|
+
for (const l of shown) tail.push(dim(` ${l}`));
|
|
1134
|
+
tail.push("");
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// Live tool output (gjc-style streaming bash stdout): while a tool runs, its
|
|
1138
|
+
// output arrives via onToolProgress and is shown as a DIMMED, bounded tail block.
|
|
1139
|
+
// It is transient — cleared on result, when the formatted forge card takes over.
|
|
1140
|
+
if (this.runningTool && this.liveToolOutput.trim()) {
|
|
1141
|
+
const wrapW = Math.max(8, Math.min(120, cols) - 2);
|
|
1142
|
+
const wrapped = this.liveToolOutput
|
|
1143
|
+
.split("\n")
|
|
1144
|
+
.flatMap(l => wrapTextWithAnsi(l, wrapW))
|
|
1145
|
+
.filter(l => l.length > 0);
|
|
1146
|
+
// FIXED reserved height (see thinking block): constant rows while a tool streams,
|
|
1147
|
+
// so cumulative stdout growth does not thrash the frame height.
|
|
1148
|
+
const ROWS = 8;
|
|
1149
|
+
const shown = wrapped.slice(-ROWS);
|
|
1150
|
+
tail.push(dim(`${this.unicode ? "│" : "|"} output`));
|
|
1151
|
+
for (let k = 0; k < ROWS - shown.length; k++) tail.push("");
|
|
1152
|
+
for (const l of shown) tail.push(dim(` ${l}`));
|
|
1153
|
+
tail.push("");
|
|
1154
|
+
}
|
|
1060
1155
|
|
|
1061
1156
|
// Live status field: unboxed thinking line + compact metrics row. The model's
|
|
1062
1157
|
// streamed activity is uniform across providers via streamingActivity and keeps
|
|
@@ -1128,7 +1223,7 @@ export class LaunchTui {
|
|
|
1128
1223
|
const forgeAnim = isThinking && this.theme.color && colorLevel >= ColorLevel.TrueColor
|
|
1129
1224
|
? { phase, colorLevel, beat }
|
|
1130
1225
|
: undefined;
|
|
1131
|
-
const forgeK = budget > 0 ? fitForgeBoxes(this.renderForge(cols, 2, forgeAnim), budget) : [];
|
|
1226
|
+
const forgeK = budget > 0 ? fitForgeBoxes(this.renderForge(cols, 2, forgeAnim, true), budget) : [];
|
|
1132
1227
|
if (forgeK.length) {
|
|
1133
1228
|
frame.push(...forgeK);
|
|
1134
1229
|
frame.push("");
|
|
@@ -1221,7 +1316,18 @@ export class LaunchTui {
|
|
|
1221
1316
|
// model bar), no outer border, no mascot art — completed work lives in scrollback.
|
|
1222
1317
|
if (fit && this.inline) {
|
|
1223
1318
|
const inlineFrame = this.composeInlineFrame({ cols, rows, stepNow, elapsedMs, idx, isThinking, planLines });
|
|
1224
|
-
|
|
1319
|
+
// Option C (constant live-frame height): pad the composed frame to EXACTLY `rows`
|
|
1320
|
+
// — blank rows at the TOP, the tail (status/hud/input/model bar) pinned to the
|
|
1321
|
+
// bottom. With a constant height the differential renderer reserves rows ONCE and
|
|
1322
|
+
// thereafter only does in-place, within-frame cursor moves; the bottom-margin
|
|
1323
|
+
// reserve-GROW that drifted the anchor by one row (the duplicate model bar during
|
|
1324
|
+
// rapid tool churn) never runs again. Every line is still width-clamped so a long
|
|
1325
|
+
// line cannot soft-wrap into a second physical row and desync the row accounting.
|
|
1326
|
+
const capped = inlineFrame.length > rows ? inlineFrame.slice(inlineFrame.length - rows) : inlineFrame;
|
|
1327
|
+
const fixedHeight = capped.length < rows
|
|
1328
|
+
? [...new Array(rows - capped.length).fill(""), ...capped]
|
|
1329
|
+
: capped;
|
|
1330
|
+
this.renderer.render(fixedHeight.map(l => truncateToWidth(l, cols)));
|
|
1225
1331
|
return;
|
|
1226
1332
|
}
|
|
1227
1333
|
|
|
@@ -1406,6 +1512,6 @@ export class LaunchTui {
|
|
|
1406
1512
|
if (fit) {
|
|
1407
1513
|
frame = frame.slice(0, rows);
|
|
1408
1514
|
}
|
|
1409
|
-
this.renderer.render(frame);
|
|
1515
|
+
this.renderer.render(frame.map(l => truncateToWidth(l, cols)));
|
|
1410
1516
|
}
|
|
1411
1517
|
}
|
|
@@ -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. */
|