jeo-code 0.1.0 → 0.4.4
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 +160 -0
- package/README.ko.md +160 -0
- package/README.md +115 -297
- package/README.zh.md +160 -0
- package/package.json +11 -6
- package/scripts/install.sh +28 -28
- package/scripts/uninstall.sh +17 -15
- package/src/AGENTS.md +50 -0
- package/src/agent/AGENTS.md +49 -0
- package/src/agent/bash-fixups.ts +103 -0
- package/src/agent/compaction.ts +410 -19
- package/src/agent/config-schema.ts +119 -5
- package/src/agent/context-files.ts +314 -17
- package/src/agent/dev/AGENTS.md +36 -0
- package/src/agent/dev/advanced-analyzer.ts +12 -0
- package/src/agent/dev/evolution-bridge.ts +82 -0
- package/src/agent/dev/evolution-logger.ts +41 -0
- package/src/agent/dev/self-analysis.ts +64 -0
- package/src/agent/dev/self-improve.ts +24 -0
- package/src/agent/dev/spec-automation.ts +49 -0
- package/src/agent/engine.ts +804 -54
- package/src/agent/hooks.ts +273 -0
- package/src/agent/loop.ts +21 -1
- package/src/agent/memory.ts +201 -0
- package/src/agent/model-recency.ts +32 -0
- package/src/agent/output-minimizer.ts +108 -0
- package/src/agent/output-util.ts +64 -0
- package/src/agent/plan.ts +187 -0
- package/src/agent/seed.ts +52 -0
- package/src/agent/session.ts +235 -21
- package/src/agent/state.ts +286 -39
- package/src/agent/step-budget.ts +232 -0
- package/src/agent/subagents.ts +223 -26
- package/src/agent/task-tool.ts +272 -0
- package/src/agent/todo-tool.ts +87 -0
- package/src/agent/tokenizer.ts +117 -0
- package/src/agent/tool-registry.ts +54 -0
- package/src/agent/tools.ts +562 -103
- package/src/agent/web-search.ts +538 -0
- package/src/ai/AGENTS.md +44 -0
- package/src/ai/index.ts +1 -0
- package/src/ai/model-catalog-compat.ts +3 -1
- package/src/ai/model-catalog.ts +74 -9
- package/src/ai/model-discovery.ts +215 -17
- package/src/ai/model-manager.ts +346 -32
- package/src/ai/model-picker.ts +1 -1
- package/src/ai/model-registry.ts +4 -2
- package/src/ai/pricing.ts +84 -0
- package/src/ai/provider-registry.ts +23 -0
- package/src/ai/provider-status.ts +60 -16
- package/src/ai/providers/AGENTS.md +42 -0
- package/src/ai/providers/anthropic.ts +250 -31
- package/src/ai/providers/antigravity.ts +219 -0
- package/src/ai/providers/errors.ts +15 -1
- package/src/ai/providers/gemini.ts +196 -13
- package/src/ai/providers/ollama.ts +37 -7
- package/src/ai/providers/openai-responses.ts +173 -0
- package/src/ai/providers/openai.ts +64 -12
- package/src/ai/sse.ts +4 -1
- package/src/ai/types.ts +18 -1
- package/src/auth/AGENTS.md +41 -0
- package/src/auth/callback-server.ts +6 -1
- package/src/auth/flows/AGENTS.md +32 -0
- package/src/auth/flows/antigravity.ts +151 -0
- package/src/auth/flows/google-project.ts +190 -0
- package/src/auth/flows/google.ts +39 -18
- package/src/auth/flows/index.ts +15 -5
- package/src/auth/flows/openai.ts +2 -2
- package/src/auth/oauth.ts +8 -0
- package/src/auth/refresh.ts +44 -27
- package/src/auth/storage.ts +149 -26
- package/src/auth/types.ts +1 -1
- package/src/autopilot.ts +362 -0
- package/src/bun-imports.d.ts +4 -0
- package/src/cli/AGENTS.md +39 -0
- package/src/cli/runner.ts +148 -14
- package/src/cli.ts +13 -4
- package/src/commands/AGENTS.md +40 -0
- package/src/commands/approve.ts +62 -3
- package/src/commands/auth.ts +167 -25
- package/src/commands/chat.ts +37 -8
- package/src/commands/deep-interview.ts +633 -175
- package/src/commands/doctor.ts +84 -37
- package/src/commands/evolve-core.ts +18 -0
- package/src/commands/evolve.ts +2 -1
- package/src/commands/export.ts +176 -0
- package/src/commands/gjc.ts +52 -0
- package/src/commands/launch.ts +3549 -240
- package/src/commands/mcp.ts +3 -3
- package/src/commands/ooo-seed.ts +19 -0
- package/src/commands/ralplan.ts +253 -35
- package/src/commands/resume.ts +1 -1
- package/src/commands/session.ts +183 -0
- package/src/commands/setup-helpers.ts +10 -3
- package/src/commands/setup.ts +57 -16
- package/src/commands/skills.ts +78 -18
- package/src/commands/state.ts +198 -0
- package/src/commands/status.ts +84 -0
- package/src/commands/team.ts +340 -212
- package/src/commands/ultragoal.ts +122 -61
- package/src/commands/update.ts +244 -0
- package/src/ledger.ts +270 -0
- package/src/mcp/AGENTS.md +38 -0
- package/src/mcp/server.ts +115 -14
- package/src/mcp/tools.ts +42 -22
- package/src/md-modules.d.ts +4 -0
- package/src/prompts/AGENTS.md +41 -0
- package/src/prompts/agents/AGENTS.md +35 -0
- package/src/prompts/agents/architect.md +35 -0
- package/src/prompts/agents/critic.md +37 -0
- package/src/prompts/agents/executor.md +36 -0
- package/src/prompts/agents/planner.md +37 -0
- package/src/prompts/skills/AGENTS.md +36 -0
- package/src/prompts/skills/deep-dive/AGENTS.md +31 -0
- package/src/prompts/skills/deep-dive/SKILL.md +13 -0
- package/src/prompts/skills/deep-interview/AGENTS.md +31 -0
- package/src/prompts/skills/deep-interview/SKILL.md +12 -0
- package/src/prompts/skills/gjc/AGENTS.md +31 -0
- package/src/prompts/skills/gjc/SKILL.md +15 -0
- package/src/prompts/skills/ralplan/AGENTS.md +31 -0
- package/src/prompts/skills/ralplan/SKILL.md +11 -0
- package/src/prompts/skills/team/AGENTS.md +31 -0
- package/src/prompts/skills/team/SKILL.md +11 -0
- package/src/prompts/skills/ultragoal/AGENTS.md +31 -0
- package/src/prompts/skills/ultragoal/SKILL.md +11 -0
- package/src/skills/AGENTS.md +38 -0
- package/src/skills/catalog.ts +565 -31
- package/src/tui/AGENTS.md +43 -0
- package/src/tui/app.ts +1181 -92
- package/src/tui/components/AGENTS.md +42 -0
- package/src/tui/components/ascii-art.ts +257 -15
- package/src/tui/components/autocomplete.ts +98 -16
- package/src/tui/components/autopilot-status.ts +65 -0
- package/src/tui/components/category-index.ts +49 -0
- package/src/tui/components/code-view.ts +54 -11
- package/src/tui/components/color.ts +171 -2
- package/src/tui/components/config-panel.ts +82 -15
- package/src/tui/components/duration.ts +38 -0
- package/src/tui/components/evolution.ts +3 -3
- package/src/tui/components/footer.ts +91 -42
- package/src/tui/components/forge.ts +426 -31
- package/src/tui/components/hints.ts +54 -0
- package/src/tui/components/hud.ts +73 -0
- package/src/tui/components/index.ts +4 -0
- package/src/tui/components/input-box.ts +150 -0
- package/src/tui/components/layout.ts +11 -3
- package/src/tui/components/live-model-picker.ts +108 -0
- package/src/tui/components/markdown-table.ts +140 -0
- package/src/tui/components/markdown-text.ts +97 -0
- package/src/tui/components/meter.ts +4 -1
- package/src/tui/components/model-picker.ts +3 -2
- package/src/tui/components/provider-picker.ts +3 -2
- package/src/tui/components/section.ts +70 -0
- package/src/tui/components/select-list.ts +40 -10
- package/src/tui/components/skill-picker.ts +25 -0
- package/src/tui/components/slash.ts +244 -21
- package/src/tui/components/status.ts +272 -11
- package/src/tui/components/step-timeline.ts +218 -0
- package/src/tui/components/stream.ts +26 -9
- package/src/tui/components/themes.ts +212 -6
- package/src/tui/components/todo-card.ts +47 -0
- package/src/tui/components/tool-list.ts +58 -12
- package/src/tui/components/transcript.ts +120 -0
- package/src/tui/components/update-box.ts +31 -0
- package/src/tui/components/welcome.ts +162 -0
- package/src/tui/components/width.ts +163 -0
- package/src/tui/monitoring/AGENTS.md +31 -0
- package/src/tui/monitoring/hud-view.ts +55 -0
- package/src/tui/renderer.ts +112 -3
- package/src/tui/terminal.ts +40 -33
- package/src/util/AGENTS.md +39 -0
- package/src/util/clipboard-image.ts +118 -0
- package/src/util/env.ts +12 -0
- package/src/util/provider-error.ts +78 -0
- package/src/util/retry.ts +91 -6
- package/src/util/update-check.ts +64 -0
- package/src/commands/models.ts +0 -104
package/src/tui/app.ts
CHANGED
|
@@ -10,19 +10,35 @@
|
|
|
10
10
|
* `runAgentLoop` and calls `tui.start()` / `tui.finish(reply)` around the turn.
|
|
11
11
|
*/
|
|
12
12
|
import { Renderer } from "./renderer";
|
|
13
|
-
import {
|
|
14
|
-
import { size, isTTY, hideCursor, showCursor } from "./terminal";
|
|
13
|
+
import { readWorkflowStateStrict } from "../agent/state";
|
|
14
|
+
import { size, isTTY, hideCursor, showCursor, enterAltScreen, leaveAltScreen } from "./terminal";
|
|
15
15
|
import { Spinner } from "./components/spinner";
|
|
16
16
|
import { ToolList } from "./components/tool-list";
|
|
17
17
|
import { StreamRegion } from "./components/stream";
|
|
18
18
|
import { renderFooter, type FooterData } from "./components/footer";
|
|
19
|
-
import {
|
|
20
|
-
import { evolutionTrack, createStageProgress, type StageProgress,
|
|
19
|
+
import { renderDnaClaw, dnaClawHeight, dnaClawFrameCount, dnaClawBeat, DNA_FLOW_PALETTE } from "./components/ascii-art";
|
|
20
|
+
import { evolutionTrack, createStageProgress, type StageProgress, transitionMessage } from "./components/evolution";
|
|
21
|
+
import type { TaskSubEvent } from "../agent/task-tool";
|
|
21
22
|
import { supportsUnicode } from "./components/capability";
|
|
22
|
-
import { centerBlock, padLineTo,
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
23
|
+
import { centerBlock, padLineTo, boxBlock, BOX_ASCII, BOX_UNICODE } from "./components/layout";
|
|
24
|
+
import { SECTION_GAP, stackSections } from "./components/section";
|
|
25
|
+
import { resolveTheme, themeGradient, accentPaint, accentShadowPaint, diffPaint } from "./components/themes";
|
|
26
|
+
import { detectColorLevel, animatedGradientText, ColorLevel } from "./components/color";
|
|
27
|
+
import { formatForgeBox, summarizeForgeInvocation, summarizeForgeResult, fitForgeBoxes, webSearchCardLines, type ForgeSummary } from "./components/forge";
|
|
28
|
+
import { renderJeoStatus, renderStatusBar, renderStatusBox } from "./components/status";
|
|
29
|
+
import { costForUsage, formatCost } from "../ai/pricing";
|
|
30
|
+
import { renderMarkdownTables } from "./components/markdown-table";
|
|
31
|
+
|
|
32
|
+
import { stripMarkdown, renderMarkdownAnsi } from "./components/markdown-text";
|
|
33
|
+
import { visibleWidth, wrapTextWithAnsi } from "./components/width";
|
|
34
|
+
import { categoryBadge } from "./components/category-index";
|
|
35
|
+
import { formatStepTimeline, stepsFromTools, formatStepHeader, formatStepTimelineCompact, type StepState } from "./components/step-timeline";
|
|
36
|
+
import { formatHintBar } from "./components/hints";
|
|
37
|
+
import { formatDuration, formatUsage } from "./components/duration";
|
|
38
|
+
import { renderHud, derivePhase, type JeoPhase } from "./components/hud";
|
|
39
|
+
import { formatTodoWriteCard } from "./components/todo-card";
|
|
40
|
+
import { renderInputBox } from "./components/input-box";
|
|
41
|
+
import { jeoEnv } from "../util/env";
|
|
26
42
|
import chalk from "chalk";
|
|
27
43
|
|
|
28
44
|
export interface LaunchTuiOptions {
|
|
@@ -33,16 +49,89 @@ export interface LaunchTuiOptions {
|
|
|
33
49
|
write?: (s: string) => void;
|
|
34
50
|
/** Step budget for this turn; drives the footer's `step N/M` denominator. */
|
|
35
51
|
maxSteps?: number;
|
|
52
|
+
/** Whether to treat the output as a TTY (drives alt-screen use). Defaults to isTTY(). */
|
|
53
|
+
tty?: boolean;
|
|
54
|
+
cwd?: string;
|
|
55
|
+
branch?: string;
|
|
56
|
+
/** Uncommitted-change count for the `⑂ branch ?N` dirty flag; omit/0 = clean. */
|
|
57
|
+
dirtyCount?: number;
|
|
58
|
+
/** Thinking-level label ("high", …) for the gjc-style model status bar. */
|
|
59
|
+
thinking?: string;
|
|
36
60
|
}
|
|
37
61
|
|
|
38
62
|
export interface AgentEventsLike {
|
|
39
63
|
onStep?(step: number): void;
|
|
40
64
|
onAssistant?(raw: string, invocation: { tool: string; arguments?: unknown } | null): void;
|
|
41
65
|
onToolResult?(tool: string, success: boolean, output: string): void;
|
|
42
|
-
|
|
66
|
+
onNotice?(message: string): void;
|
|
67
|
+
onUsage?(usage: { inputTokens: number; outputTokens: number }): void;
|
|
68
|
+
onModelStream?(textSoFar: string): void;
|
|
69
|
+
onBudget?(limit: number, reason: string): void;
|
|
70
|
+
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Pull the (possibly partial) `reasoning` string value out of a streaming JSON tool
|
|
74
|
+
* call so the live view can show the model's plan as it forms. Returns "" until a
|
|
75
|
+
* `"reasoning": "…` field starts. Tolerates the unterminated tail mid-stream. */
|
|
76
|
+
function extractStreamingReasoning(buf: string): string {
|
|
77
|
+
// `reasoning` is a documented LEADING field, so only scan the head of the (growing)
|
|
78
|
+
// buffer — avoids an O(n²) full rescan per delta when reasoning is absent on a large
|
|
79
|
+
// streamed write/edit payload.
|
|
80
|
+
const head = buf.length > 512 ? buf.slice(0, 512) : buf;
|
|
81
|
+
const m = head.match(/"reasoning"\s*:\s*"((?:[^"\\]|\\.)*)/);
|
|
82
|
+
if (!m) return "";
|
|
83
|
+
try { return JSON.parse('"' + m[1] + '"'); }
|
|
84
|
+
catch { return m[1].replace(/\\\\/g, "\\").replace(/\\n/g, " ").replace(/\\"/g, '"'); }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Uniform live-activity fallback for models that stream no `reasoning` field: derive
|
|
88
|
+
* what the model is doing from the forming JSON (`"tool":"x"` → "calling x…") or, for
|
|
89
|
+
* prose-replying models, show the reply head — so the live status field behaves identically
|
|
90
|
+
* across providers/models instead of staying silent for some of them. */
|
|
91
|
+
function extractStreamingActivity(buf: string): string {
|
|
92
|
+
const head = buf.length > 512 ? buf.slice(0, 512) : buf;
|
|
93
|
+
const tool = head.match(/"tool"\s*:\s*"([^"]+)"/)?.[1];
|
|
94
|
+
if (tool) return tool === "done" ? "writing the reply…" : `calling ${tool}…`;
|
|
95
|
+
const t = head.trim();
|
|
96
|
+
if (!t) return "";
|
|
97
|
+
if (t.startsWith("{") || t.startsWith("```")) return "forming the next tool call…";
|
|
98
|
+
return t.replace(/\s+/g, " ").slice(0, 140);
|
|
43
99
|
}
|
|
44
100
|
|
|
45
|
-
const DEFAULT_MAX_STEPS =
|
|
101
|
+
const DEFAULT_MAX_STEPS = 100;
|
|
102
|
+
// Tools light enough that they never get a forge card (gjc parity): completion is a
|
|
103
|
+
// single ✓/✗ ledger line; only failures surface a result card with the error body.
|
|
104
|
+
const LIGHT_TOOLS = new Set(["read", "find", "search", "ls", "todo"]);
|
|
105
|
+
function todoListChanged(
|
|
106
|
+
oldItems: { title: string; status: string }[],
|
|
107
|
+
newItems: { title: string; status: string }[]
|
|
108
|
+
): boolean {
|
|
109
|
+
if (oldItems.length !== newItems.length) return true;
|
|
110
|
+
for (let i = 0; i < oldItems.length; i++) {
|
|
111
|
+
if (oldItems[i].title !== newItems[i].title || oldItems[i].status !== newItems[i].status) {
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
// Armed once per process: if we exit mid-turn (e.g. an uncaught crash), restore the
|
|
120
|
+
// terminal — leave the alt screen when the CURRENT turn mode is alt-screen, close any
|
|
121
|
+
// open synchronized update, and always bring the cursor back — so the TTY is never
|
|
122
|
+
// left hidden-cursor, sync-frozen, or stuck on a blank alt screen. The mode flag is
|
|
123
|
+
// mutable and refreshed on every start() so a later turn in a different mode (e.g. a
|
|
124
|
+
// test flipping JEO_TUI_ALT_SCREEN) is restored correctly.
|
|
125
|
+
let exitSafetyArmed = false;
|
|
126
|
+
let exitSafetyAltScreen = false;
|
|
127
|
+
function armExitSafety(altScreen: boolean): void {
|
|
128
|
+
exitSafetyAltScreen = altScreen;
|
|
129
|
+
if (exitSafetyArmed) return;
|
|
130
|
+
exitSafetyArmed = true;
|
|
131
|
+
process.once("exit", () => {
|
|
132
|
+
try { process.stdout.write((exitSafetyAltScreen ? leaveAltScreen() : "\x1b[?2026l") + showCursor()); } catch { /* terminal gone */ }
|
|
133
|
+
});
|
|
134
|
+
}
|
|
46
135
|
|
|
47
136
|
export class LaunchTui {
|
|
48
137
|
private readonly renderer: Renderer;
|
|
@@ -53,35 +142,113 @@ export class LaunchTui {
|
|
|
53
142
|
private readonly forgeSummaries: ForgeSummary[] = [];
|
|
54
143
|
private readonly footer: FooterData;
|
|
55
144
|
private startedAt = 0;
|
|
145
|
+
private currentStepStartedAt = 0;
|
|
56
146
|
private tickCount = 0;
|
|
57
147
|
private mutationGuarded = false;
|
|
148
|
+
private finished = false;
|
|
58
149
|
private timer: ReturnType<typeof setInterval> | undefined;
|
|
59
150
|
private pendingIndex: number | null = null;
|
|
151
|
+
private pendingTitle: string | null = null;
|
|
152
|
+
private pendingForge: ForgeSummary | null = null;
|
|
153
|
+
// True between a step start and the model's reply — i.e. we're waiting on the model.
|
|
154
|
+
// Surfaced in the status line ("calling model…") so the wait isn't an opaque pause.
|
|
155
|
+
private thinking = false;
|
|
156
|
+
private hudPhase: JeoPhase = "thinking";
|
|
157
|
+
private runningTool = false;
|
|
158
|
+
// Latest transient provider notice (rate-limit auto-retry countdown); pinned into the
|
|
159
|
+
// [STEP] status row while waiting so backoff is visible at a glance. Cleared on the
|
|
160
|
+
// next step / model reply.
|
|
161
|
+
private retryNotice: string | null = null;
|
|
162
|
+
private workflowStatus: { skill: string; phase: string; detail?: string } | null = null;
|
|
163
|
+
// Cumulative token usage for the live turn (engine onUsage event).
|
|
164
|
+
private turnUsage: { inputTokens: number; outputTokens: number } | null = null;
|
|
165
|
+
// True while a delegated subagent turn is in flight — drives the `(sub)` status marker.
|
|
166
|
+
private subagentActive = false;
|
|
167
|
+
// Latest nested subagent activity (role + glyph + detail) — surfaced LIVE in the
|
|
168
|
+
// status row while a `task` runs, so a delegated turn never reads as an opaque
|
|
169
|
+
// "calling model" stall (the perceived-hang usability gap). Cleared on done.
|
|
170
|
+
private subagentLive: string | null = null;
|
|
171
|
+
// Bounded activity-history ring: one plain-text entry per ledger append, with a
|
|
172
|
+
// turn-relative timestamp. Powers Ctrl+O's "recent activity" tail so the detail
|
|
173
|
+
// view ALWAYS answers "what has been happening", even before the first reply.
|
|
174
|
+
private readonly activityLog: { at: number; line: string }[] = [];
|
|
175
|
+
private static readonly ACTIVITY_LOG_CAP = 200;
|
|
176
|
+
// Live next-prompt draft. Printable keystrokes typed while a turn owns stdin
|
|
177
|
+
// edit this same query surface; it is rendered as the normal input box during
|
|
178
|
+
// the turn and becomes the editable prompt prefill when the turn finishes.
|
|
179
|
+
private livePromptInput = "";
|
|
180
|
+
// Auto-derived turn title (no LLM call): seeded from the first user message, refined
|
|
181
|
+
// once to the first tool's verb+target. Shown in the HUD and synced to the tmux pane
|
|
182
|
+
// title under --tmux so multiple sessions are distinguishable at a glance (gjc parity).
|
|
183
|
+
private turnTitle: string | null = null;
|
|
184
|
+
private turnTitleRefined = false;
|
|
185
|
+
// Live "reasoning" text streamed from the model's response this step (the optional
|
|
186
|
+
// `"reasoning"` field of the forming tool-call JSON). Shown dim under the HUD while
|
|
187
|
+
// the model responds, then flushed once into scrollback as a `jeo · …` ledger line.
|
|
188
|
+
private streamingReasoning = "";
|
|
189
|
+
/** Uniform live-activity text for the live status field (reasoning OR derived fallback). */
|
|
190
|
+
private streamingActivity = "";
|
|
191
|
+
/** Last stream-driven draw (ms epoch) — throttles per-delta repaints to ≤10/s. */
|
|
192
|
+
private lastStreamDraw = 0;
|
|
193
|
+
private flushedReasoning = "";
|
|
194
|
+
// Ctrl+O history/detail panel. When set, the live inline frame shows this
|
|
195
|
+
// block above the heartbeat; pressing Ctrl+O again clears it and restores the
|
|
196
|
+
// normal activity view. Kept as data, not scrollback text, so it can actually close.
|
|
197
|
+
private historyLines: string[] | null = null;
|
|
198
|
+
// Kind of the last ledger entry — drives the gjc-reference vertical rhythm: a
|
|
199
|
+
// blank line separates DIFFERENT ledger groups (card ↔ ✓-tool lines ↔ reasoning
|
|
200
|
+
// ↔ notices), while same-kind lines (consecutive ✓ reads) stay adjacent.
|
|
201
|
+
private lastLedgerKind: string | null = null;
|
|
202
|
+
// True while the live turn renders in the alternate screen buffer (TTY only);
|
|
203
|
+
// drives leaving it on finish so terminal scroll never fights the repaint.
|
|
204
|
+
private usedAltScreen = false;
|
|
205
|
+
// Agent-declared task plan (the `todo` tool), rendered as a live checklist.
|
|
206
|
+
private todos: { title: string; status: "pending" | "in_progress" | "done" }[] = [];
|
|
60
207
|
// Cache the rendered art + track per stage so the 120ms spinner tick reuses
|
|
61
208
|
// them instead of re-rendering/re-coloring the block every frame.
|
|
62
209
|
private cachedStageIndex = -1;
|
|
63
210
|
private cachedCols = -1;
|
|
211
|
+
// Effective animation frame the cached art was rendered at. Keying the cache on
|
|
212
|
+
// this (not the raw `isThinking` flag) lets frameless stages render ONCE instead
|
|
213
|
+
// of re-rendering the gradient block every 120ms tick.
|
|
214
|
+
private cachedFrame = -1;
|
|
64
215
|
private cachedArt: string[] = [];
|
|
65
216
|
private cachedTrack = "";
|
|
66
217
|
// Monotonic stage progress so evolution only ever moves forward this turn.
|
|
67
218
|
private readonly progress: StageProgress = createStageProgress();
|
|
68
219
|
// Terminal unicode capability, detected once (drives spinner/track glyph set).
|
|
69
220
|
private readonly unicode: boolean = supportsUnicode();
|
|
70
|
-
// Active color theme (
|
|
71
|
-
private readonly theme = resolveTheme();
|
|
221
|
+
// Active color theme (JEO_TUI_THEME), default cosmic; `mono` disables color.
|
|
222
|
+
private readonly theme = resolveTheme(process.env);
|
|
223
|
+
// Whether the live turn may use the alternate screen buffer (real TTY only).
|
|
224
|
+
private readonly tty: boolean;
|
|
225
|
+
// gjc-style inline rendering (default on a TTY): the live frame repaints in place in
|
|
226
|
+
// the MAIN buffer and every completed ledger line is flushed into normal scrollback
|
|
227
|
+
// first, so tmux / terminal mouse-wheel can scroll back through earlier progress
|
|
228
|
+
// mid-turn. JEO_TUI_ALT_SCREEN=1 opts back into the legacy alternate-screen turn
|
|
229
|
+
// (scroll-isolated, but no mid-turn scrollback).
|
|
230
|
+
private readonly inline: boolean;
|
|
231
|
+
// Thinking-level label for the gjc-style model status bar.
|
|
232
|
+
private readonly thinkingLevel?: string;
|
|
72
233
|
|
|
73
234
|
constructor(opts: LaunchTuiOptions) {
|
|
74
235
|
this.write = opts.write ?? ((s: string) => process.stdout.write(s));
|
|
75
|
-
this.
|
|
236
|
+
this.tty = opts.tty ?? isTTY();
|
|
237
|
+
this.inline = this.tty && jeoEnv("TUI_ALT_SCREEN") !== "1";
|
|
238
|
+
// Row reservation is only needed (and only safe) for the inline main-buffer frame;
|
|
239
|
+
// the alt screen starts at the top with a full-height frame.
|
|
240
|
+
this.renderer = new Renderer(this.write, undefined, { reserve: this.inline });
|
|
76
241
|
this.spinner = new Spinner(undefined, { unicode: this.unicode });
|
|
242
|
+
this.thinkingLevel = opts.thinking;
|
|
77
243
|
this.footer = {
|
|
78
244
|
model: opts.model,
|
|
79
245
|
provider: opts.provider,
|
|
80
246
|
sessionId: opts.sessionId,
|
|
81
247
|
maxSteps: opts.maxSteps ?? DEFAULT_MAX_STEPS,
|
|
82
248
|
unicode: this.unicode,
|
|
83
|
-
|
|
84
|
-
|
|
249
|
+
cwd: opts.cwd,
|
|
250
|
+
branch: opts.branch,
|
|
251
|
+
dirtyCount: opts.dirtyCount,
|
|
85
252
|
};
|
|
86
253
|
}
|
|
87
254
|
|
|
@@ -90,78 +257,664 @@ export class LaunchTui {
|
|
|
90
257
|
return isTTY() && !noTui;
|
|
91
258
|
}
|
|
92
259
|
|
|
260
|
+
/** Update the agent-declared task plan (driven by the `todo` tool). */
|
|
261
|
+
setTodos(items: { title: string; status: "pending" | "in_progress" | "done" }[]): void {
|
|
262
|
+
const hasInProgress = items.some(item => item.status === "in_progress");
|
|
263
|
+
const changed = todoListChanged(this.todos, items);
|
|
264
|
+
if (hasInProgress && changed && !this.runningTool) {
|
|
265
|
+
this.hudPhase = "planning";
|
|
266
|
+
}
|
|
267
|
+
// jeo-ref transcript: every plan CHANGE flushes a "Todo Write" tree card into
|
|
268
|
+
// scrollback (☑ + strikethrough as items complete), so the checklist's history
|
|
269
|
+
// is reviewable. The live pinned plan stays in the frame tail as before.
|
|
270
|
+
if (changed && items.length > 0 && !this.finished) {
|
|
271
|
+
const card = formatTodoWriteCard(items, { unicode: this.unicode, color: this.theme.color });
|
|
272
|
+
this.appendLedger(card.join("\n") + "\n", "card");
|
|
273
|
+
}
|
|
274
|
+
this.todos = items;
|
|
275
|
+
this.draw();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Update estimated context usage shown in the footer. */
|
|
279
|
+
setContextUsage(usedTokens: number, maxTokens?: number): void {
|
|
280
|
+
this.footer.contextUsedTokens = usedTokens;
|
|
281
|
+
this.footer.contextMaxTokens = maxTokens;
|
|
282
|
+
this.draw();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Seed the turn title (no LLM call) and sync it to the terminal/tmux pane title.
|
|
286
|
+
* Called once per turn from the REPL with the user's input; refined to the first
|
|
287
|
+
* tool's verb+target on the first tool call. */
|
|
288
|
+
setTurnTitle(raw: string): void {
|
|
289
|
+
const first = (raw ?? "").split("\n").map(s => s.trim()).find(s => s.length > 0) ?? "";
|
|
290
|
+
const title = first.length > 50 ? first.slice(0, 49) + "…" : first;
|
|
291
|
+
this.turnTitle = title || null;
|
|
292
|
+
this.turnTitleRefined = false;
|
|
293
|
+
this.emitPaneTitle();
|
|
294
|
+
this.draw();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Write an OSC window/pane title (`ESC]2;jeo: <title>BEL`). tmux maps this to the
|
|
298
|
+
* pane title, so multiple --tmux sessions are distinguishable at a glance. TTY only. */
|
|
299
|
+
private emitPaneTitle(): void {
|
|
300
|
+
if (!this.tty || !this.turnTitle) return;
|
|
301
|
+
try { this.write(`\x1b]2;jeo: ${this.turnTitle}\x07`); } catch { /* terminal gone */ }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Render the task plan as a status-colored checklist; empty when no plan. */
|
|
305
|
+
private renderPlan(color: boolean): string[] {
|
|
306
|
+
if (this.todos.length === 0) return [];
|
|
307
|
+
const steps = this.todos.map(t => ({
|
|
308
|
+
label: t.title,
|
|
309
|
+
state: (t.status === "done" ? "done" : t.status === "in_progress" ? "active" : "pending") as StepState,
|
|
310
|
+
}));
|
|
311
|
+
const header = formatStepHeader(steps, { unicode: this.unicode, color, label: "Todos" });
|
|
312
|
+
return [header, ...formatStepTimeline(steps, { unicode: this.unicode, color, highlightActive: true, maxRows: 8, badges: false })];
|
|
313
|
+
}
|
|
93
314
|
/** The events object to hand to runAgentLoop. */
|
|
94
315
|
events(): AgentEventsLike {
|
|
95
316
|
return {
|
|
96
317
|
onStep: step => {
|
|
97
318
|
this.footer.step = step;
|
|
319
|
+
this.thinking = true; // waiting on the model for this step
|
|
320
|
+
this.hudPhase = "thinking";
|
|
321
|
+
this.retryNotice = null; // a new step starts a fresh model call
|
|
322
|
+
this.streamingReasoning = ""; // fresh model response this step
|
|
323
|
+
this.streamingActivity = "";
|
|
324
|
+
this.flushedReasoning = "";
|
|
325
|
+
this.currentStepStartedAt = Date.now();
|
|
98
326
|
this.spinner.updateStep(step, this.footer.maxSteps);
|
|
99
327
|
this.spinner.next();
|
|
100
328
|
this.draw();
|
|
101
329
|
},
|
|
330
|
+
onModelStream: textSoFar => {
|
|
331
|
+
// Surface the model's LIVE activity uniformly for every model/provider:
|
|
332
|
+
// the streamed `reasoning` field when the model emits one, else a derived
|
|
333
|
+
// fallback (tool being formed / reply prose head) — so no model leaves the
|
|
334
|
+
// live status field silent while it streams.
|
|
335
|
+
// Draws are THROTTLED to one per 100ms: the old per-delta draw() rendered
|
|
336
|
+
// the full frame hundreds of times per response (a real chunk of jeo's
|
|
337
|
+
// per-step latency); the 120ms timer tick covers the gaps anyway.
|
|
338
|
+
const r = extractStreamingReasoning(textSoFar);
|
|
339
|
+
let changed = false;
|
|
340
|
+
if (r) {
|
|
341
|
+
changed = r !== this.streamingReasoning;
|
|
342
|
+
this.streamingReasoning = r;
|
|
343
|
+
this.streamingActivity = r;
|
|
344
|
+
} else {
|
|
345
|
+
const fallback = extractStreamingActivity(textSoFar);
|
|
346
|
+
if (fallback && fallback !== this.streamingActivity) {
|
|
347
|
+
this.streamingActivity = fallback;
|
|
348
|
+
changed = true;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (changed && Date.now() - this.lastStreamDraw >= 100) {
|
|
352
|
+
this.lastStreamDraw = Date.now();
|
|
353
|
+
this.draw();
|
|
354
|
+
}
|
|
355
|
+
},
|
|
102
356
|
onAssistant: (_raw, invocation) => {
|
|
357
|
+
this.thinking = false; // model replied; now dispatching the tool
|
|
358
|
+
this.retryNotice = null; // the call got through — clear any backoff notice
|
|
359
|
+
// Flush the streamed reasoning once into scrollback as a jeo-ref reasoning
|
|
360
|
+
// block — the agent NAME on its own accent line, the prose below it (the
|
|
361
|
+
// durable record) — then stop showing the transient live reasoning row.
|
|
362
|
+
if (this.streamingReasoning && this.streamingReasoning !== this.flushedReasoning) {
|
|
363
|
+
this.flushedReasoning = this.streamingReasoning;
|
|
364
|
+
const name = this.theme.color ? chalk.bold(accentPaint(this.theme)("jeo")) : "jeo";
|
|
365
|
+
this.appendLedger(`${name}\n${this.streamingReasoning}\n`, "reasoning");
|
|
366
|
+
}
|
|
367
|
+
this.streamingReasoning = "";
|
|
368
|
+
this.streamingActivity = "";
|
|
103
369
|
if (invocation && invocation.tool !== "done") {
|
|
104
|
-
this.
|
|
105
|
-
this.
|
|
370
|
+
this.runningTool = true;
|
|
371
|
+
this.hudPhase = "executing";
|
|
372
|
+
const toolName = invocation.tool || "(no tool)";
|
|
373
|
+
this.pendingIndex = this.tools.start(toolName);
|
|
374
|
+
const summary = summarizeForgeInvocation(toolName, invocation.arguments);
|
|
375
|
+
this.pendingTitle = summary.title;
|
|
376
|
+
// Refine the turn title once to the first tool's verb+target (e.g. "read
|
|
377
|
+
// package.json"), gjc-style, then lock — sharper than the raw user message.
|
|
378
|
+
if (!this.turnTitleRefined) {
|
|
379
|
+
this.turnTitle = summary.title.length > 50 ? summary.title.slice(0, 49) + "…" : summary.title;
|
|
380
|
+
this.turnTitleRefined = true;
|
|
381
|
+
this.emitPaneTitle();
|
|
382
|
+
}
|
|
383
|
+
// Light tools (read/find/search/…) never get a live card — their completion
|
|
384
|
+
// is a single ✓ ledger line (gjc parity). Heavier tools show the invocation
|
|
385
|
+
// card live, and the completed card is flushed into scrollback on result.
|
|
386
|
+
if (LIGHT_TOOLS.has(toolName.toLowerCase())) {
|
|
387
|
+
this.pendingForge = null;
|
|
388
|
+
} else {
|
|
389
|
+
this.pendingForge = summary;
|
|
390
|
+
this.rememberForge(summary);
|
|
391
|
+
}
|
|
106
392
|
this.draw();
|
|
393
|
+
} else {
|
|
394
|
+
this.hudPhase = "reporting";
|
|
107
395
|
}
|
|
108
396
|
},
|
|
109
397
|
onToolResult: (tool, success, output) => {
|
|
398
|
+
this.runningTool = false;
|
|
110
399
|
if (this.pendingIndex !== null) {
|
|
111
400
|
this.tools.finish(this.pendingIndex, success);
|
|
112
401
|
this.pendingIndex = null;
|
|
113
402
|
}
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
this.
|
|
403
|
+
const t = (tool || "").toLowerCase();
|
|
404
|
+
const target = this.pendingTitle || tool;
|
|
405
|
+
this.pendingTitle = null;
|
|
406
|
+
// gjc-parity glyph-first ledger line: a colored ✓/✗ leads the flushed
|
|
407
|
+
// scrollback line so a wheel-scroll back through history scans like gjc's
|
|
408
|
+
// tool checklist — no category/status badge clutter.
|
|
409
|
+
const mark = this.unicode ? (success ? "✓" : "✗") : success ? "v" : "x";
|
|
410
|
+
const paintedMark = this.theme.color ? (success ? chalk.green(mark) : chalk.red(mark)) : mark;
|
|
411
|
+
const result = summarizeForgeResult(tool, success, output);
|
|
412
|
+
const card = this.pendingForge;
|
|
413
|
+
this.pendingForge = null;
|
|
414
|
+
if (card && t === "bash") {
|
|
415
|
+
// gjc-style single Bash card: command echo + `Output` divider + body + exit
|
|
416
|
+
// note, under one ✓/✗-marked header — mutated in place so the live frame and
|
|
417
|
+
// the non-TTY summary both show the merged card.
|
|
418
|
+
card.title = `${paintedMark} Bash`;
|
|
419
|
+
card.lines.push(...result.lines);
|
|
420
|
+
this.flushForgeCard(card);
|
|
421
|
+
} else if (card && t === "web_search" && success && webSearchCardLines(output, { unicode: this.unicode })) {
|
|
422
|
+
// gjc-style Web Search card: `✓ Web Search: <provider> · N sources` header
|
|
423
|
+
// over Query / Answer / Sources / Metadata divider sections rebuilt from
|
|
424
|
+
// the structured tool output (provider chain — Anthropic native or the
|
|
425
|
+
// keyless DuckDuckGo fallback).
|
|
426
|
+
const ws = webSearchCardLines(output, { unicode: this.unicode })!;
|
|
427
|
+
card.title = `${paintedMark} Web Search: ${ws.titleMeta}`;
|
|
428
|
+
card.lines = ws.lines;
|
|
429
|
+
this.flushForgeCard(card);
|
|
430
|
+
} else if (card) {
|
|
431
|
+
card.title = `${paintedMark} ${card.title}`;
|
|
432
|
+
if (!success) this.rememberForge(result);
|
|
433
|
+
this.flushForgeCard(card);
|
|
434
|
+
if (!success) this.flushForgeCard(result);
|
|
435
|
+
} else {
|
|
436
|
+
// Light tool: one ✓/✗ line, plus a dim result tree for list-shaped output
|
|
437
|
+
// (find/search/ls) and an error card when the tool failed.
|
|
438
|
+
const { suffix, children } = this.ledgerTree(tool, success, output);
|
|
439
|
+
this.appendLedger(`${paintedMark} ${target}${suffix}\n${children.map(c => `${c}\n`).join("")}`, "tool");
|
|
440
|
+
if (!success) {
|
|
441
|
+
this.rememberForge(result);
|
|
442
|
+
this.flushForgeCard(result);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
117
445
|
this.draw();
|
|
118
446
|
},
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
447
|
+
onNotice: msg => {
|
|
448
|
+
// Transient progress notice (e.g. rate-limit auto-retry countdown) is live
|
|
449
|
+
// state, not ledger content. Pin it in the status row only; do NOT append
|
|
450
|
+
// repeated retry notices into the stream/log area.
|
|
451
|
+
this.retryNotice = msg;
|
|
452
|
+
this.draw();
|
|
453
|
+
},
|
|
454
|
+
onBudget: (limit: number, reason: string) => {
|
|
455
|
+
// gjc-style retry flow: the step budget extended itself — update the live
|
|
456
|
+
// `step N/M` denominators and leave one durable ledger line.
|
|
457
|
+
this.footer.maxSteps = limit;
|
|
458
|
+
this.spinner.updateStep(this.footer.step ?? 0, limit);
|
|
459
|
+
const mark = this.unicode ? "↻" : "~";
|
|
460
|
+
const dim = this.theme.color ? chalk.dim : (s: string) => s;
|
|
461
|
+
this.appendLedger(dim(`${mark} ${reason}`) + "\n", "notice");
|
|
462
|
+
this.draw();
|
|
463
|
+
},
|
|
464
|
+
onUsage: (u: { inputTokens: number; outputTokens: number }) => {
|
|
465
|
+
// Live cumulative token usage for the turn — shown in the final summary
|
|
466
|
+
// (and available to the footer meter).
|
|
467
|
+
this.turnUsage = u;
|
|
122
468
|
this.draw();
|
|
123
469
|
},
|
|
124
470
|
};
|
|
125
471
|
}
|
|
126
472
|
|
|
473
|
+
/** Ctrl+O history/detail toggle, mid-turn: first press opens a live panel with
|
|
474
|
+
* the full last reply / tool output, second press closes it and returns to the
|
|
475
|
+
* normal activity frame. Unlike the old scrollback dump, this is reversible. */
|
|
476
|
+
showDetail(lines: string[]): void {
|
|
477
|
+
if (this.finished) return;
|
|
478
|
+
if (this.historyLines) {
|
|
479
|
+
this.historyLines = null;
|
|
480
|
+
this.draw();
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
if (lines.length === 0) return;
|
|
484
|
+
this.historyLines = lines;
|
|
485
|
+
this.draw();
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Mirror the REPL's live next-prompt draft into the running frame. The input
|
|
489
|
+
* box remains visible during a turn, so typing never appears in a separate
|
|
490
|
+
* queued row and Enter does not create hidden auto-execute work. */
|
|
491
|
+
setLivePromptInput(text: string): void {
|
|
492
|
+
if (this.finished) return;
|
|
493
|
+
const next = text ?? "";
|
|
494
|
+
if (next === this.livePromptInput) return;
|
|
495
|
+
this.livePromptInput = next;
|
|
496
|
+
this.draw();
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
private renderLiveInputBox(cols: number): string[] {
|
|
500
|
+
const caret = this.unicode ? "▌" : "_";
|
|
501
|
+
const display = this.livePromptInput ? `${this.livePromptInput}${caret}` : "";
|
|
502
|
+
return renderInputBox(display, {
|
|
503
|
+
cols: Math.max(24, Math.min(120, cols)),
|
|
504
|
+
color: this.theme.color,
|
|
505
|
+
unicode: this.unicode,
|
|
506
|
+
accent: this.theme.color ? accentPaint(this.theme) : undefined,
|
|
507
|
+
accentShadow: this.theme.color ? accentShadowPaint(this.theme) : undefined,
|
|
508
|
+
placeholder: "Type your next message...",
|
|
509
|
+
maxBodyRows: 2,
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
private renderLiveUserQueryCard(cols: number): string[] {
|
|
514
|
+
const text = this.livePromptInput.trim();
|
|
515
|
+
if (!text) return [];
|
|
516
|
+
const boxWidth = Math.max(24, Math.min(120, cols));
|
|
517
|
+
const inner = Math.max(10, boxWidth - 2);
|
|
518
|
+
const g = this.unicode ? BOX_UNICODE : BOX_ASCII;
|
|
519
|
+
const accent = this.theme.color ? chalk.hex("#ff6b4a").bold : (s: string) => s;
|
|
520
|
+
const border = this.theme.color ? chalk.hex("#7f1d1d") : (s: string) => s;
|
|
521
|
+
const shadow = this.theme.color ? chalk.hex("#451a1a").dim : border;
|
|
522
|
+
const fill = this.theme.color ? (s: string) => chalk.bgHex("#210b10")(s) : (s: string) => s;
|
|
523
|
+
const body = text
|
|
524
|
+
.split("\n")
|
|
525
|
+
.flatMap(line => wrapTextWithAnsi(line, Math.max(8, inner - 2)))
|
|
526
|
+
.slice(0, 6);
|
|
527
|
+
const clipped = body.length === 6 && text.split("\n").length > 6
|
|
528
|
+
? [...body.slice(0, 5), this.unicode ? "…" : "..."]
|
|
529
|
+
: body;
|
|
530
|
+
const rows = clipped.length ? clipped : [""];
|
|
531
|
+
const top = border(g.tl + g.h.repeat(inner) + g.tr);
|
|
532
|
+
const bottom = shadow(g.bl + g.h.repeat(inner) + g.br);
|
|
533
|
+
const mid = rows.map(line => {
|
|
534
|
+
const content = fill(padLineTo(` ${line}`, inner, "left"));
|
|
535
|
+
return border(g.v) + content + shadow(g.v);
|
|
536
|
+
});
|
|
537
|
+
return [` ${accent("user")}`, top, ...mid, bottom];
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Append a completed progress-ledger line. In inline mode the line is flushed
|
|
541
|
+
* straight into normal scrollback ABOVE the live frame, so tmux / terminal
|
|
542
|
+
* mouse-wheel can review the full progress history mid-turn (gjc-style); the
|
|
543
|
+
* StreamRegion copy still feeds the in-frame tail and the non-TTY / alt-screen
|
|
544
|
+
* final summary.
|
|
545
|
+
* `kind` drives the readability rhythm (jeo-ref layout): a blank spacer row is
|
|
546
|
+
* inserted when the ledger switches between groups (tool lines ↔ reasoning ↔
|
|
547
|
+
* cards ↔ notices) and around every card — same-kind lines stay adjacent so a
|
|
548
|
+
* burst of ✓ reads still scans as one block.
|
|
549
|
+
* CRITICAL: every flushed line is width-wrapped to the terminal columns first.
|
|
550
|
+
* A line longer than the terminal hard-wraps into 2+ PHYSICAL rows, which breaks
|
|
551
|
+
* the renderer's 1-line=1-row reservation math — the live frame then repaints at
|
|
552
|
+
* the wrong rows (the "screen tearing + garbled scrollback" corruption). */
|
|
553
|
+
private appendLedger(text: string, kind = "line"): void {
|
|
554
|
+
this.recordActivity(text);
|
|
555
|
+
const needsGap = this.lastLedgerKind !== null && (kind !== this.lastLedgerKind || kind === "card");
|
|
556
|
+
this.lastLedgerKind = kind;
|
|
557
|
+
const body0 = needsGap ? `\n${text}` : text;
|
|
558
|
+
this.stream.append(body0);
|
|
559
|
+
if (this.inline && !this.finished) {
|
|
560
|
+
const cols = Math.max(20, size().cols);
|
|
561
|
+
const body = body0.endsWith("\n") ? body0.slice(0, -1) : body0;
|
|
562
|
+
const wrapped = body
|
|
563
|
+
.split("\n")
|
|
564
|
+
.flatMap(line => (visibleWidth(line) <= cols ? [line] : wrapTextWithAnsi(line, cols)))
|
|
565
|
+
.join("\n");
|
|
566
|
+
this.renderer.insertAbove(`${wrapped}\n`);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** Record one plain-text activity entry (ANSI-stripped first line, bounded ring). */
|
|
571
|
+
private recordActivity(text: string): void {
|
|
572
|
+
const first = text
|
|
573
|
+
.replace(/\x1b\[[0-9;]*m/g, "")
|
|
574
|
+
.split("\n")
|
|
575
|
+
.map(l => l.trim())
|
|
576
|
+
.find(l => l.length > 0);
|
|
577
|
+
if (!first) return;
|
|
578
|
+
this.activityLog.push({ at: Date.now(), line: first.slice(0, 160) });
|
|
579
|
+
if (this.activityLog.length > LaunchTui.ACTIVITY_LOG_CAP) this.activityLog.shift();
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** Recent activity entries (newest last) with turn-relative `+N.Ns` timestamps —
|
|
583
|
+
* the Ctrl+O detail view's "what just happened" tail. */
|
|
584
|
+
recentActivity(n = 30): string[] {
|
|
585
|
+
const base = this.startedAt || Date.now();
|
|
586
|
+
return this.activityLog.slice(-Math.max(1, n)).map(e => {
|
|
587
|
+
const rel = Math.max(0, (e.at - base) / 1000);
|
|
588
|
+
return `+${rel.toFixed(1)}s ${e.line}`;
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** gjc-style ledger tree for list-shaped tool results (find / search / ls): a dim
|
|
593
|
+
* ` · N files` / ` · N matches` count suffix for the summary line plus up to six
|
|
594
|
+
* dim `├─` child rows sampling the results, closed by `└─ … N more`. Children are
|
|
595
|
+
* flushed into scrollback with the summary, so wheel-scroll history reads like
|
|
596
|
+
* gjc's `✓ Find: pattern 39 files` block. Other tools return no decoration. */
|
|
597
|
+
private ledgerTree(tool: string, success: boolean, output: string): { suffix: string; children: string[] } {
|
|
598
|
+
const t = (tool || "").toLowerCase();
|
|
599
|
+
if (!success || (t !== "find" && t !== "search" && t !== "ls")) return { suffix: "", children: [] };
|
|
600
|
+
const rows = (output || "")
|
|
601
|
+
.split("\n")
|
|
602
|
+
.map(l => l.trim())
|
|
603
|
+
.filter(l => l.length > 0 && !/^no match/i.test(l) && !l.startsWith("…("));
|
|
604
|
+
if (rows.length === 0) return { suffix: "", children: [] };
|
|
605
|
+
const dim = this.theme.color ? chalk.dim : (s: string) => s;
|
|
606
|
+
const noun = t === "search" ? (rows.length === 1 ? "match" : "matches") : rows.length === 1 ? "file" : "files";
|
|
607
|
+
const suffix = dim(` · ${rows.length} ${noun}`);
|
|
608
|
+
const MAX_CHILDREN = 6;
|
|
609
|
+
const shown = rows.slice(0, MAX_CHILDREN);
|
|
610
|
+
const more = rows.length - shown.length;
|
|
611
|
+
const tee = this.unicode ? "├─" : "|-";
|
|
612
|
+
const end = this.unicode ? "└─" : "`-";
|
|
613
|
+
const ell = this.unicode ? "…" : "...";
|
|
614
|
+
const children = shown.map((l, i) =>
|
|
615
|
+
dim(` ${i === shown.length - 1 && more <= 0 ? end : tee} ${l.length > 96 ? `${l.slice(0, 95)}${ell}` : l}`),
|
|
616
|
+
);
|
|
617
|
+
if (more > 0) children.push(dim(` ${end} ${ell} ${more} more ${noun}`));
|
|
618
|
+
return { suffix, children };
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** Surface native workflow-engine progress (`/skill deep-interview`, etc.). */
|
|
622
|
+
setWorkflowStatus(status: { skill: string; phase: string; detail?: string } | null): void {
|
|
623
|
+
this.workflowStatus = status;
|
|
624
|
+
if (status) {
|
|
625
|
+
const detail = status.detail ? ` — ${status.detail}` : "";
|
|
626
|
+
const diamond = this.unicode ? "◆" : "*";
|
|
627
|
+
this.appendLedger(`${diamond} workflow ${status.skill}: ${status.phase}${detail}\n`, "workflow");
|
|
628
|
+
}
|
|
629
|
+
this.draw();
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Real, stable "what jeo is doing right now" for the [STEP] line — the in-flight tool's
|
|
634
|
+
* actual target (file / command), else the active plan step, else overall plan progress.
|
|
635
|
+
* Replaces the per-tick cycling status text so the line shows genuine content (thinking
|
|
636
|
+
* about a real file/step) instead of churning decorative messages every 120ms.
|
|
637
|
+
*/
|
|
638
|
+
private currentActivity(): string {
|
|
639
|
+
const running = this.tools.currentTool();
|
|
640
|
+
// An in-flight tool's real target beats the workflow phase banner (gjc-style:
|
|
641
|
+
// the status row shows what is happening RIGHT NOW; the ◆ hud line carries
|
|
642
|
+
// the workflow identity).
|
|
643
|
+
if (this.workflowStatus && !running) {
|
|
644
|
+
const detail = this.workflowStatus.detail ? ` — ${this.workflowStatus.detail}` : "";
|
|
645
|
+
return `workflow ${this.workflowStatus.skill}: ${this.workflowStatus.phase}${detail}`;
|
|
646
|
+
}
|
|
647
|
+
// A delegated subagent's LATEST nested event beats the parent's static
|
|
648
|
+
// "Task: <role> …" card title — a long task otherwise reads as a stall even
|
|
649
|
+
// though the subagent is actively reading/editing/running underneath.
|
|
650
|
+
if (this.subagentActive && this.subagentLive) return this.subagentLive;
|
|
651
|
+
// Waiting on the model and no tool is mid-flight → make the pause legible.
|
|
652
|
+
if (this.thinking && !running) {
|
|
653
|
+
const elapsed = this.currentStepStartedAt ? ((Date.now() - this.currentStepStartedAt) / 1000).toFixed(1) : "0.0";
|
|
654
|
+
// A provider backoff wait is the REAL current activity — show the retry notice
|
|
655
|
+
// (e.g. "rate limited (HTTP 429) — auto-retry #2 in 4s") instead of an opaque
|
|
656
|
+
// ever-growing "calling model (18.4s)…".
|
|
657
|
+
if (this.retryNotice) return `${this.retryNotice} (${elapsed}s)`;
|
|
658
|
+
return `calling model (${this.footer.model}) (${elapsed}s)…`;
|
|
659
|
+
}
|
|
660
|
+
if (running) {
|
|
661
|
+
const last = this.forgeSummaries[this.forgeSummaries.length - 1];
|
|
662
|
+
if (last?.title?.toLowerCase().startsWith("bash")) {
|
|
663
|
+
const cmd = last.lines.map(l => l.trim()).find(l => l.length > 0 && !l.startsWith("#"));
|
|
664
|
+
return cmd ? `bash: ${cmd}` : "bash command";
|
|
665
|
+
}
|
|
666
|
+
// Light tools have no live card; pendingTitle still carries the real target.
|
|
667
|
+
return this.pendingTitle ?? last?.title ?? `running ${running}`;
|
|
668
|
+
}
|
|
669
|
+
const active = this.todos.find(t => t.status === "in_progress");
|
|
670
|
+
if (active) return `step: ${active.title}`;
|
|
671
|
+
if (this.todos.length > 0) {
|
|
672
|
+
const done = this.todos.filter(t => t.status === "done").length;
|
|
673
|
+
return `plan ${done}/${this.todos.length} complete`;
|
|
674
|
+
}
|
|
675
|
+
return "thinking through the next tool call";
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Surface a delegated subagent's live progress (the `task` tool) in the stream region,
|
|
680
|
+
* mirroring gjc's subagent monitoring: the assignment, each nested tool call, and the
|
|
681
|
+
* final outcome — so a TUI turn shows what the subagent actually did instead of going
|
|
682
|
+
* silent until the task result box. The full findings still arrive as the task tool's
|
|
683
|
+
* own result forge box; this is the live play-by-play.
|
|
684
|
+
*/
|
|
685
|
+
onSubagentEvent(e: TaskSubEvent): void {
|
|
686
|
+
if (this.finished) return;
|
|
687
|
+
const color = this.theme.color;
|
|
688
|
+
const role = e.role || "subagent";
|
|
689
|
+
const roleLabel = role.toUpperCase();
|
|
690
|
+
const badge = categoryBadge("subagent", { color });
|
|
691
|
+
const ok = this.unicode ? "✓" : "v";
|
|
692
|
+
const bad = this.unicode ? "✗" : "x";
|
|
693
|
+
const branch = this.unicode ? "├─" : "|-";
|
|
694
|
+
const last = this.unicode ? "└─" : "`-";
|
|
695
|
+
const detail = (e.detail ?? "").split("\n").find(l => l.trim().length > 0)?.trim().slice(0, 140) ?? "";
|
|
696
|
+
const summary = e.summary ? ` — ${e.summary}` : "";
|
|
697
|
+
// No `step N/M` marker on nested lines — step counters carry no meaning
|
|
698
|
+
// under the dynamic budget (user feedback). A tree branch keeps subagent
|
|
699
|
+
// activity readable in scrollback and visually separate from parent tools.
|
|
700
|
+
switch (e.kind) {
|
|
701
|
+
case "start":
|
|
702
|
+
this.subagentActive = true;
|
|
703
|
+
this.subagentLive = `${roleLabel} ${this.unicode ? "▸" : ">"} ${detail || "starting"}`;
|
|
704
|
+
this.appendLedger(`${badge} ${this.unicode ? "▸" : ">"} ${roleLabel} · ${detail}\n`, "subagent");
|
|
705
|
+
break;
|
|
706
|
+
case "step":
|
|
707
|
+
this.subagentLive = `${roleLabel} ${this.unicode ? "·" : "-"} ${detail || "working"}`;
|
|
708
|
+
this.appendLedger(` ${badge} ${branch} ${roleLabel} · ${detail || "working"}\n`, "subagent");
|
|
709
|
+
break;
|
|
710
|
+
case "tool":
|
|
711
|
+
this.subagentLive = `${roleLabel} ${e.success === false ? bad : ok} ${detail || "tool"}`;
|
|
712
|
+
this.appendLedger(` ${badge} ${branch} ${roleLabel} ${e.success === false ? bad : ok} ${detail || "tool"}${summary}\n`, "subagent");
|
|
713
|
+
break;
|
|
714
|
+
case "error":
|
|
715
|
+
this.subagentLive = `${roleLabel} ${bad} ${detail || "error"}`;
|
|
716
|
+
this.appendLedger(` ${badge} ${branch} ${roleLabel} ${bad} ${detail || "error"}\n`, "subagent");
|
|
717
|
+
break;
|
|
718
|
+
case "done":
|
|
719
|
+
this.subagentActive = false;
|
|
720
|
+
this.subagentLive = null;
|
|
721
|
+
this.appendLedger(`${badge} ${last} ${roleLabel} done${e.success === false ? " (incomplete)" : ""}: ${detail}\n`, "subagent");
|
|
722
|
+
break;
|
|
723
|
+
}
|
|
724
|
+
this.draw();
|
|
725
|
+
}
|
|
726
|
+
|
|
127
727
|
start(): void {
|
|
128
728
|
this.startedAt = Date.now();
|
|
729
|
+
this.turnUsage = null;
|
|
730
|
+
this.lastLedgerKind = null; // fresh turn: no leading spacer before the first ledger line
|
|
731
|
+
this.livePromptInput = ""; // fresh turn: no next-prompt draft yet
|
|
732
|
+
this.subagentLive = null; // fresh turn: no nested subagent in flight
|
|
733
|
+
this.activityLog.length = 0; // per-turn ring: timestamps are turn-relative
|
|
129
734
|
this.spinner.updateStep(0, this.footer.maxSteps);
|
|
735
|
+
// On a real TTY the live turn renders gjc-style in the MAIN buffer by default:
|
|
736
|
+
// completed ledger lines are flushed into normal scrollback as they happen, so a
|
|
737
|
+
// tmux / terminal mouse-wheel scroll can review earlier progress mid-turn. The
|
|
738
|
+
// differential renderer reserves frame rows with real newlines, keeping the
|
|
739
|
+
// in-place repaint anchored even at the bottom of the viewport.
|
|
740
|
+
// JEO_TUI_ALT_SCREEN=1 restores the legacy alternate-screen turn (scroll-isolated,
|
|
741
|
+
// but with no scrollback until the turn ends).
|
|
742
|
+
if (this.tty) {
|
|
743
|
+
if (this.inline) {
|
|
744
|
+
// Reset the renderer baseline at the anchor (with prev=[] this clears
|
|
745
|
+
// nothing — the first frame's per-line EL paint + row reservation
|
|
746
|
+
// overwrite/scroll every viewport row, so stale pre-turn rows can't bleed).
|
|
747
|
+
this.renderer.clear();
|
|
748
|
+
} else {
|
|
749
|
+
this.usedAltScreen = true;
|
|
750
|
+
this.write(enterAltScreen());
|
|
751
|
+
this.renderer.reset();
|
|
752
|
+
}
|
|
753
|
+
armExitSafety(this.usedAltScreen);
|
|
754
|
+
}
|
|
130
755
|
this.write(hideCursor());
|
|
131
756
|
this.draw();
|
|
132
757
|
|
|
133
|
-
|
|
758
|
+
readWorkflowStateStrict("deep-interview")
|
|
134
759
|
.then(state => {
|
|
760
|
+
if (this.finished) return;
|
|
135
761
|
this.mutationGuarded = !!(state && state.active && state.current_phase !== "complete");
|
|
136
762
|
this.draw();
|
|
137
763
|
})
|
|
138
|
-
.catch(() => {
|
|
764
|
+
.catch(() => {
|
|
765
|
+
if (this.finished) return;
|
|
766
|
+
// Engine MutationGuard fails closed on corrupt state; mirror that in the UI
|
|
767
|
+
// instead of showing an unlocked footer while edits are actually blocked.
|
|
768
|
+
this.mutationGuarded = true;
|
|
769
|
+
this.draw();
|
|
770
|
+
});
|
|
771
|
+
// Watch terminal resizes: rows/cols changes invalidate the previous frame, so
|
|
772
|
+
// force a full repaint instead of diffing against stale line positions.
|
|
773
|
+
if (this.tty) {
|
|
774
|
+
process.stdout.on("resize", this.onResize);
|
|
775
|
+
}
|
|
139
776
|
// Animate the spinner + elapsed clock while the model is thinking.
|
|
140
777
|
this.timer = setInterval(() => {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
778
|
+
try {
|
|
779
|
+
this.tickCount++;
|
|
780
|
+
// Self-healing resync: every ~3s drop the differential baseline so the next
|
|
781
|
+
// draw rewrites EVERY line. Any screen corruption (stray child output, wheel
|
|
782
|
+
// noise, terminal glitches) is repaired automatically without user action.
|
|
783
|
+
if (this.tickCount % 25 === 0) this.renderer.reset();
|
|
784
|
+
this.spinner.next();
|
|
785
|
+
this.draw();
|
|
786
|
+
} catch {
|
|
787
|
+
// Ignore transient render races (resize/component state) so the agent turn keeps running.
|
|
788
|
+
}
|
|
144
789
|
}, 120);
|
|
145
790
|
}
|
|
146
791
|
|
|
792
|
+
/** Force a full repaint of the live frame (auto-invoked on resize/input noise). */
|
|
793
|
+
repaint(): void {
|
|
794
|
+
if (this.finished) return;
|
|
795
|
+
this.renderer.reset();
|
|
796
|
+
this.draw();
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
private readonly onResize = (): void => {
|
|
800
|
+
try {
|
|
801
|
+
this.repaint();
|
|
802
|
+
} catch { /* resize race — next tick repaints */ }
|
|
803
|
+
};
|
|
804
|
+
|
|
147
805
|
/** Collapse the live region to static final output. */
|
|
148
806
|
finish(reply: string): void {
|
|
807
|
+
this.finished = true;
|
|
808
|
+
this.hudPhase = "done";
|
|
149
809
|
if (this.timer) {
|
|
150
810
|
clearInterval(this.timer);
|
|
151
811
|
this.timer = undefined;
|
|
152
812
|
}
|
|
153
|
-
this.
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
813
|
+
if (this.tty) {
|
|
814
|
+
process.stdout.removeListener("resize", this.onResize);
|
|
815
|
+
}
|
|
816
|
+
if (this.usedAltScreen) {
|
|
817
|
+
// Leave the alt screen (restores the main buffer + scrollback), then print the
|
|
818
|
+
// static summary below the prior output so the turn leaves exactly one record.
|
|
819
|
+
this.renderer.reset();
|
|
820
|
+
this.write(leaveAltScreen());
|
|
821
|
+
this.write(showCursor());
|
|
822
|
+
} else {
|
|
823
|
+
this.renderer.clear();
|
|
824
|
+
this.write(showCursor());
|
|
825
|
+
}
|
|
826
|
+
const timelineSteps = stepsFromTools(this.tools.snapshot());
|
|
827
|
+
const totalElapsedMs = this.startedAt ? Date.now() - this.startedAt : 0;
|
|
828
|
+
const finalLines: string[] = [];
|
|
829
|
+
// jeo-ref final-report order: the ANSWER leads; the Todos checklist follows it
|
|
830
|
+
// (done = checked + struck through), so the plan reads as a completion receipt.
|
|
831
|
+
const planLines = this.renderPlan(this.theme.color);
|
|
832
|
+
if (!this.inline) {
|
|
833
|
+
// Inline scrollback already reads as a ✓/✗ checklist; the step timeline +
|
|
834
|
+
// compact strip + flow line would just repeat it (gjc-style slim summary).
|
|
835
|
+
if (timelineSteps.length) {
|
|
836
|
+
finalLines.push(formatStepHeader(timelineSteps, { elapsedMs: totalElapsedMs, unicode: this.unicode, color: this.theme.color }));
|
|
837
|
+
}
|
|
838
|
+
for (const line of formatStepTimeline(timelineSteps, { unicode: this.unicode, color: this.theme.color, highlightActive: true, maxRows: 12 })) {
|
|
839
|
+
finalLines.push(line);
|
|
840
|
+
}
|
|
841
|
+
if (timelineSteps.length > 1) {
|
|
842
|
+
finalLines.push(` ${formatStepTimelineCompact(timelineSteps, { unicode: this.unicode, color: this.theme.color })}`);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
if (!this.inline) {
|
|
846
|
+
// Inline turns already flushed every ledger line into scrollback live; re-printing
|
|
847
|
+
// the stream here would duplicate the whole history right below itself.
|
|
848
|
+
for (const line of this.stream.render(size().cols)) finalLines.push(line);
|
|
849
|
+
}
|
|
850
|
+
if (!this.inline) {
|
|
851
|
+
// Inline turns flushed every completed card into scrollback live; re-printing
|
|
852
|
+
// the cards here would duplicate them right below themselves. A spacer row
|
|
853
|
+
// keeps the card block from gluing to the stream above (jeo-ref rhythm).
|
|
854
|
+
const forge = this.renderForge(size().cols, 3);
|
|
855
|
+
if (forge.length && finalLines.length) finalLines.push("");
|
|
856
|
+
finalLines.push(...forge);
|
|
857
|
+
}
|
|
858
|
+
if (!this.inline && timelineSteps.length > 0) {
|
|
859
|
+
const arrow = this.unicode ? " → " : " -> ";
|
|
860
|
+
const flow = ["thinking", "planning", "executing", "done"].join(arrow);
|
|
861
|
+
const stepsCount = this.footer.step || 0;
|
|
862
|
+
const durationStr = formatDuration(Date.now() - this.startedAt);
|
|
863
|
+
const usageStr = this.turnUsage ? ` · ${formatUsage(this.turnUsage)}` : "";
|
|
864
|
+
const doneBadge = categoryBadge("done", { color: this.theme.color });
|
|
865
|
+
finalLines.push(`${doneBadge} ${flow} · ${stepsCount} steps · ${durationStr}${usageStr}`);
|
|
866
|
+
}
|
|
867
|
+
// jeo-ref final-report rendering: GFM tables become box-drawn tables, then
|
|
868
|
+
// headings/bold/inline-code are styled (theme accent) instead of stripped.
|
|
869
|
+
// color:false keeps the plain stripMarkdown text for pipes/tests.
|
|
870
|
+
const tabled = renderMarkdownTables(reply, { unicode: this.unicode });
|
|
871
|
+
const renderedReply = this.theme.color
|
|
872
|
+
? renderMarkdownAnsi(tabled, { accent: s => chalk.bold(accentPaint(this.theme)(s)) })
|
|
873
|
+
: stripMarkdown(tabled);
|
|
160
874
|
const steps = this.footer.step || 0;
|
|
161
875
|
const peak = this.progress.current();
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
876
|
+
const usageSuffix = this.turnUsage ? ` · ${formatUsage(this.turnUsage)}` : "";
|
|
877
|
+
if (this.inline) {
|
|
878
|
+
// jeo-ref clean ending: the ANSWER leads, then the Todos completion receipt,
|
|
879
|
+
// then exactly ONE compact dim status line (steps · time · usage · evolution
|
|
880
|
+
// track). The live ledger above already recorded every step. A blank spacer
|
|
881
|
+
// row separates the ledger from the answer (jeo-ref vertical rhythm).
|
|
882
|
+
finalLines.push("");
|
|
883
|
+
finalLines.push(`jeo> ${renderedReply}`);
|
|
884
|
+
if (planLines.length) {
|
|
885
|
+
finalLines.push("");
|
|
886
|
+
finalLines.push(...planLines);
|
|
887
|
+
}
|
|
888
|
+
const statusLine = `${categoryBadge("done", { color: this.theme.color })} ${steps} steps · ${formatDuration(Date.now() - this.startedAt)}${usageSuffix} · ${evolutionTrack(peak, { unicode: this.unicode, color: this.theme.color })}`;
|
|
889
|
+
finalLines.push(this.theme.color ? chalk.dim(statusLine) : statusLine);
|
|
890
|
+
} else {
|
|
891
|
+
finalLines.push(`Evolved to: ${evolutionTrack(peak, { unicode: this.unicode, color: this.theme.color })} (took ${steps} steps in ${formatDuration(Date.now() - this.startedAt)}${usageSuffix})`);
|
|
892
|
+
finalLines.push(`jeo> ${renderedReply}`);
|
|
893
|
+
if (planLines.length) {
|
|
894
|
+
finalLines.push(...planLines);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
if (this.tty) {
|
|
898
|
+
// Width-wrap every summary line to the terminal FIRST. A line wider than the
|
|
899
|
+
// terminal hard-wraps into 2+ physical rows, corrupting the result screen and
|
|
900
|
+
// throwing off the trailing `\x1b[0J` cursor math (the reported "화면깨짐").
|
|
901
|
+
// Split embedded newlines (multi-line replies / the budget consolidation
|
|
902
|
+
// wrap-up), then wrap each physical line to the column count.
|
|
903
|
+
const wrapCols = Math.max(20, size().cols);
|
|
904
|
+
const physicalLines = finalLines.flatMap(l =>
|
|
905
|
+
l.split("\n").flatMap(sub => (visibleWidth(sub) <= wrapCols ? [sub] : wrapTextWithAnsi(sub, wrapCols))),
|
|
906
|
+
);
|
|
907
|
+
// Main-buffer hygiene after leaving the alt screen: the cursor lands on rows
|
|
908
|
+
// that still hold pre-turn content (old footer box, project-context lines).
|
|
909
|
+
// Without clear-to-EOL every summary line visually MERGES with that stale
|
|
910
|
+
// text, and the leftover footer reservation below renders as a torn, dead-
|
|
911
|
+
// looking input box. Clear each line's tail, then everything below.
|
|
912
|
+
this.write("\r\x1b[K");
|
|
913
|
+
this.write(physicalLines.map(l => `${l}\x1b[K`).join("\n") + "\n");
|
|
914
|
+
this.write("\x1b[0J");
|
|
915
|
+
} else {
|
|
916
|
+
console.log(finalLines.join("\n"));
|
|
917
|
+
}
|
|
165
918
|
}
|
|
166
919
|
|
|
167
920
|
private rememberForge(summary: ForgeSummary): void {
|
|
@@ -169,129 +922,465 @@ export class LaunchTui {
|
|
|
169
922
|
if (this.forgeSummaries.length > 8) this.forgeSummaries.shift();
|
|
170
923
|
}
|
|
171
924
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
925
|
+
/** Flush a completed forge card into scrollback (inline mode) and retire it from the
|
|
926
|
+
* live array so the in-frame card region and the final summary never repeat it.
|
|
927
|
+
* Non-inline modes keep the card in `forgeSummaries` for the final static summary. */
|
|
928
|
+
private flushForgeCard(summary: ForgeSummary): void {
|
|
929
|
+
if (!this.inline || this.finished) return;
|
|
930
|
+
const width = Math.max(24, Math.min(120, size().cols));
|
|
931
|
+
const lines = formatForgeBox(summary, {
|
|
932
|
+
width,
|
|
933
|
+
maxLines: 12,
|
|
934
|
+
unicode: this.unicode,
|
|
935
|
+
paint: accentPaint(this.theme),
|
|
936
|
+
paintShadow: accentShadowPaint(this.theme),
|
|
937
|
+
diffPaint: diffPaint(this.theme),
|
|
938
|
+
color: this.theme.color,
|
|
939
|
+
});
|
|
940
|
+
this.appendLedger(lines.join("\n") + "\n", "card");
|
|
941
|
+
const i = this.forgeSummaries.indexOf(summary);
|
|
942
|
+
if (i >= 0) this.forgeSummaries.splice(i, 1);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
private renderForge(
|
|
946
|
+
width: number,
|
|
947
|
+
maxEntries: number,
|
|
948
|
+
anim?: { phase: number; colorLevel: ColorLevel; beat: string },
|
|
949
|
+
): string[] {
|
|
950
|
+
const floor = Math.min(24, width);
|
|
951
|
+
// Fill the available width (cap at formatForgeBox's own 120 ceiling) so an
|
|
952
|
+
// in-frame box does not leave a dead right-margin column inside the outer panel.
|
|
953
|
+
const boxWidth = Math.max(floor, Math.min(120, width));
|
|
954
|
+
const paint = this.theme.color ? accentPaint(this.theme) : (s: string) => s;
|
|
175
955
|
const lines: string[] = [];
|
|
176
|
-
for (const summary of this.forgeSummaries.slice(-maxEntries)) {
|
|
956
|
+
for (const [i, summary] of this.forgeSummaries.slice(-maxEntries).entries()) {
|
|
177
957
|
if (lines.length > 0) lines.push("");
|
|
178
|
-
lines.push(...formatForgeBox(summary, {
|
|
958
|
+
lines.push(...formatForgeBox(summary, {
|
|
959
|
+
width: boxWidth,
|
|
960
|
+
maxLines: 8,
|
|
961
|
+
unicode: this.unicode,
|
|
962
|
+
paint,
|
|
963
|
+
paintShadow: accentShadowPaint(this.theme),
|
|
964
|
+
diffPaint: diffPaint(this.theme),
|
|
965
|
+
index: i + 1,
|
|
966
|
+
color: this.theme.color,
|
|
967
|
+
// DNA-flow identity on LIVE cards only: the flowing helix gradient rides
|
|
968
|
+
// the card border and the claw beat marks the title. Flushed/final cards
|
|
969
|
+
// stay static — scrollback never carries animation frames.
|
|
970
|
+
...(anim
|
|
971
|
+
? { flow: { palette: DNA_FLOW_PALETTE, phase: anim.phase, colorLevel: anim.colorLevel }, titleMark: anim.beat }
|
|
972
|
+
: {}),
|
|
973
|
+
}));
|
|
179
974
|
}
|
|
180
975
|
return lines;
|
|
181
976
|
}
|
|
182
977
|
|
|
978
|
+
|
|
979
|
+
/** Render the Ctrl+O panel inside the live frame. `maxRows` includes borders. */
|
|
980
|
+
private renderHistoryPanel(width: number, maxRows: number): string[] {
|
|
981
|
+
if (!this.historyLines || maxRows < 4) return [];
|
|
982
|
+
const boxWidth = Math.max(24, Math.min(120, width));
|
|
983
|
+
const inner = Math.max(10, boxWidth - 2);
|
|
984
|
+
const accent = this.theme.color ? accentPaint(this.theme) : (s: string) => s;
|
|
985
|
+
const dim = this.theme.color ? chalk.dim : (s: string) => s;
|
|
986
|
+
const title = `${accent("history")} ${dim("· Ctrl+O closes")}`;
|
|
987
|
+
const wrapped = this.historyLines.flatMap(line => {
|
|
988
|
+
const physical = line.split("\n");
|
|
989
|
+
return physical.flatMap(part => (visibleWidth(part) <= inner ? [part] : wrapTextWithAnsi(part, inner)));
|
|
990
|
+
});
|
|
991
|
+
const header = [title, "DIVIDER"];
|
|
992
|
+
const bodyLimit = Math.max(0, maxRows - 2 - header.length);
|
|
993
|
+
let body = wrapped;
|
|
994
|
+
if (wrapped.length > bodyLimit) {
|
|
995
|
+
const keep = Math.max(0, bodyLimit - 1);
|
|
996
|
+
body = wrapped.slice(0, keep);
|
|
997
|
+
body.push(dim(`… ${wrapped.length - keep} more line(s)`));
|
|
998
|
+
} else {
|
|
999
|
+
body = wrapped.slice(0, bodyLimit);
|
|
1000
|
+
}
|
|
1001
|
+
return boxBlock([...header, ...body], boxWidth, {
|
|
1002
|
+
glyphs: this.unicode ? BOX_UNICODE : BOX_ASCII,
|
|
1003
|
+
paint: this.theme.color ? accentPaint(this.theme) : (s: string) => s,
|
|
1004
|
+
paintShadow: this.theme.color ? accentShadowPaint(this.theme) : (s: string) => s,
|
|
1005
|
+
align: "left",
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
/**
|
|
1009
|
+
* The gjc-style inline live frame: a flat stack with no outer border —
|
|
1010
|
+
* <live forge card(s)> · <spinner status line> · <todos> · <hud line> · <model bar>
|
|
1011
|
+
* Completed cards and ✓ ledger lines were already flushed into scrollback above.
|
|
1012
|
+
*/
|
|
1013
|
+
private composeInlineFrame(args: {
|
|
1014
|
+
cols: number;
|
|
1015
|
+
rows: number;
|
|
1016
|
+
stepNow: number;
|
|
1017
|
+
elapsedMs: number;
|
|
1018
|
+
idx: number;
|
|
1019
|
+
isThinking: boolean;
|
|
1020
|
+
planLines: string[];
|
|
1021
|
+
}): string[] {
|
|
1022
|
+
const { cols, rows, stepNow, elapsedMs, idx, isThinking } = args;
|
|
1023
|
+
const dim = this.theme.color ? chalk.dim : (s: string) => s;
|
|
1024
|
+
const colorLevel = detectColorLevel(process.env, isTTY());
|
|
1025
|
+
// One quantized animation clock for the whole frame: gradient phase cycles 20
|
|
1026
|
+
// steps, the claw beat advances every 3 ticks. Quantization keeps repaints
|
|
1027
|
+
// coherent (status field + forge border move together) and bounds per-tick work.
|
|
1028
|
+
const phase = (this.tickCount * 0.05) % 1;
|
|
1029
|
+
const beat = dnaClawBeat(Math.trunc(this.tickCount / 3), this.unicode);
|
|
1030
|
+
|
|
1031
|
+
// Assemble the bottom-pinned tail FIRST (status line → todos → hud → model bar):
|
|
1032
|
+
// it is the live heartbeat and must always be visible; the in-flight card gets
|
|
1033
|
+
// whatever rows remain above it.
|
|
1034
|
+
const tail: string[] = [];
|
|
1035
|
+
|
|
1036
|
+
// Live status field: unboxed thinking line + compact metrics row. The model's
|
|
1037
|
+
// streamed activity is uniform across providers via streamingActivity and keeps
|
|
1038
|
+
// the ⟦esc⟧ cancel hint visible without trapping the message inside a border.
|
|
1039
|
+
if (isThinking) {
|
|
1040
|
+
const grad = themeGradient(this.theme, idx);
|
|
1041
|
+
const costUsd = costForUsage(this.footer.model, this.turnUsage) ?? undefined;
|
|
1042
|
+
const stats = this.tools.stats();
|
|
1043
|
+
tail.push(...renderStatusBox({
|
|
1044
|
+
cols: Math.max(24, Math.min(120, cols)),
|
|
1045
|
+
phaseLabel: this.workflowStatus ? `${this.workflowStatus.skill}:${this.workflowStatus.phase}` : this.hudPhase,
|
|
1046
|
+
spinner: this.spinner.current(),
|
|
1047
|
+
activity: this.retryNotice ?? (this.streamingActivity || this.currentActivity()),
|
|
1048
|
+
escHint: true,
|
|
1049
|
+
elapsedMs,
|
|
1050
|
+
stepElapsedMs: this.currentStepStartedAt ? Date.now() - this.currentStepStartedAt : undefined,
|
|
1051
|
+
avgStepMs: stepNow > 0 ? elapsedMs / stepNow : undefined,
|
|
1052
|
+
okCount: stats.ok,
|
|
1053
|
+
failCount: stats.fail,
|
|
1054
|
+
runningCount: stats.running,
|
|
1055
|
+
totalCount: stats.total,
|
|
1056
|
+
mutationGuarded: this.mutationGuarded,
|
|
1057
|
+
unicode: this.unicode,
|
|
1058
|
+
color: this.theme.color,
|
|
1059
|
+
colorLevel,
|
|
1060
|
+
phase,
|
|
1061
|
+
palette: [grad.from, grad.to],
|
|
1062
|
+
isThinking: true,
|
|
1063
|
+
usage: this.turnUsage,
|
|
1064
|
+
costUsd,
|
|
1065
|
+
subagentActive: this.subagentActive,
|
|
1066
|
+
}));
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// User text typed while the model is still thinking is surfaced as a live
|
|
1070
|
+
// pending-user card (same query input underneath, no hidden auto-executing queue).
|
|
1071
|
+
if (this.livePromptInput.trim()) {
|
|
1072
|
+
tail.push("");
|
|
1073
|
+
tail.push(...this.renderLiveUserQueryCard(cols));
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// Agent task plan (the `todo` tool) as a Todos checklist.
|
|
1077
|
+
if (args.planLines.length) {
|
|
1078
|
+
tail.push("");
|
|
1079
|
+
tail.push(...args.planLines);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// hud line + model status bar pinned at the bottom of the live frame (gjc layout).
|
|
1083
|
+
const diamond = this.unicode ? "◆" : "*";
|
|
1084
|
+
const hudTail = this.workflowStatus
|
|
1085
|
+
? `${this.workflowStatus.skill}:${this.workflowStatus.phase}`
|
|
1086
|
+
: renderHud(this.hudPhase, { unicode: this.unicode, color: this.theme.color });
|
|
1087
|
+
tail.push("");
|
|
1088
|
+
tail.push(`${diamond} ${dim("hud")} ${hudTail}`);
|
|
1089
|
+
tail.push(...this.renderLiveInputBox(cols));
|
|
1090
|
+
tail.push(this.renderModelBar(cols, elapsedMs));
|
|
1091
|
+
|
|
1092
|
+
// Bottom-anchor: on a too-short terminal drop tail rows from the TOP so the
|
|
1093
|
+
// status/todos/hud/model-bar end stays visible; spend leftover rows on the
|
|
1094
|
+
// in-flight tool card (whole boxes only — never half a card).
|
|
1095
|
+
const tailKeep = tail.length > rows ? tail.slice(tail.length - rows) : tail;
|
|
1096
|
+
const budget = Math.max(0, rows - tailKeep.length - 1);
|
|
1097
|
+
const frame: string[] = [];
|
|
1098
|
+
const historyK = this.historyLines ? this.renderHistoryPanel(cols, budget) : [];
|
|
1099
|
+
if (historyK.length) {
|
|
1100
|
+
frame.push(...historyK);
|
|
1101
|
+
if (frame.length + tailKeep.length < rows) frame.push("");
|
|
1102
|
+
} else {
|
|
1103
|
+
const forgeAnim = isThinking && this.theme.color && colorLevel >= ColorLevel.TrueColor
|
|
1104
|
+
? { phase, colorLevel, beat }
|
|
1105
|
+
: undefined;
|
|
1106
|
+
const forgeK = budget > 0 ? fitForgeBoxes(this.renderForge(cols, 2, forgeAnim), budget) : [];
|
|
1107
|
+
if (forgeK.length) {
|
|
1108
|
+
frame.push(...forgeK);
|
|
1109
|
+
frame.push("");
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
frame.push(...tailKeep);
|
|
1113
|
+
return frame;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/** The gjc-style one-row model status bar: ⬢ model (provider) · ◔ thinking / ⑂ branch / ▸ cwd. */
|
|
1117
|
+
private renderModelBar(cols: number, elapsedMs: number): string {
|
|
1118
|
+
const usage = this.turnUsage;
|
|
1119
|
+
const rate = usage && elapsedMs >= 1000 && usage.outputTokens > 0 ? usage.outputTokens / (elapsedMs / 1000) : undefined;
|
|
1120
|
+
const ctxPct =
|
|
1121
|
+
this.footer.contextUsedTokens !== undefined && this.footer.contextMaxTokens && this.footer.contextMaxTokens > 0
|
|
1122
|
+
? Math.round((this.footer.contextUsedTokens / this.footer.contextMaxTokens) * 100)
|
|
1123
|
+
: undefined;
|
|
1124
|
+
return renderStatusBar({
|
|
1125
|
+
model: `${this.footer.model}${this.footer.provider ? ` (${this.footer.provider})` : ""}`,
|
|
1126
|
+
thinking: this.thinkingLevel,
|
|
1127
|
+
branch: this.footer.branch,
|
|
1128
|
+
dirtyCount: this.footer.dirtyCount,
|
|
1129
|
+
cwd: this.footer.cwd,
|
|
1130
|
+
rate,
|
|
1131
|
+
ctxPct,
|
|
1132
|
+
ctxMaxTokens: this.footer.contextMaxTokens,
|
|
1133
|
+
cols,
|
|
1134
|
+
unicode: this.unicode,
|
|
1135
|
+
color: this.theme.color,
|
|
1136
|
+
colorLevel: detectColorLevel(process.env, isTTY()),
|
|
1137
|
+
gradient: themeGradient(this.theme, 2),
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
|
|
183
1141
|
private draw(): void {
|
|
1142
|
+
if (this.finished) return; // never repaint a live frame after the final static output
|
|
184
1143
|
const { cols, rows } = size();
|
|
185
|
-
const fit =
|
|
1144
|
+
const fit = this.tty; // boxed full-screen layout only on a TTY (defaults to isTTY())
|
|
186
1145
|
const elapsedMs = this.startedAt ? Date.now() - this.startedAt : 0;
|
|
187
|
-
const innerWidth = fit ? cols - 4 : cols;
|
|
1146
|
+
const innerWidth = fit && !this.inline ? cols - 4 : cols;
|
|
188
1147
|
|
|
189
|
-
// Resolve the current (monotonic) stage; announce a transition
|
|
190
|
-
// first advances. The art
|
|
191
|
-
//
|
|
1148
|
+
// Resolve the current (monotonic) stage for the track; announce a transition
|
|
1149
|
+
// once when it first advances. The header art is the DNA Claw brand symbol —
|
|
1150
|
+
// a twist-frame helix rotation combined with the flowing gradient phase.
|
|
1151
|
+
// Both are quantized (3 twist frames × 20 gradient phases), so the cache
|
|
1152
|
+
// recomputes at most once per changed tick and stays a single slot (O(1)).
|
|
192
1153
|
const stepNow = this.footer.step || 0;
|
|
193
1154
|
const idx = this.progress.observe(stepNow, this.footer.maxSteps ?? DEFAULT_MAX_STEPS);
|
|
194
1155
|
const isThinking = this.timer !== undefined;
|
|
195
|
-
if (fit && this.progress.advanced() && idx > 0) {
|
|
1156
|
+
if (fit && !this.inline && this.progress.advanced() && idx > 0) {
|
|
196
1157
|
const arrow = this.unicode ? "\u27f6" : "->";
|
|
197
|
-
this.
|
|
1158
|
+
this.appendLedger(`${arrow} ${transitionMessage(idx)}\n`, "notice");
|
|
198
1159
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
1160
|
+
const showArt = fit && !this.inline && rows >= 18 && cols >= 40;
|
|
1161
|
+
// One int key folds both animation axes: twist frame advances every 3 ticks,
|
|
1162
|
+
// gradient phase cycles 20 quantized steps (tickCount*0.05 % 1).
|
|
1163
|
+
const twist = isThinking ? Math.trunc(this.tickCount / 3) % dnaClawFrameCount() : 0;
|
|
1164
|
+
const qPhase = isThinking ? this.tickCount % 20 : 0;
|
|
1165
|
+
const effFrame = twist * 100 + qPhase;
|
|
1166
|
+
if (showArt && (idx !== this.cachedStageIndex || cols !== this.cachedCols || effFrame !== this.cachedFrame)) {
|
|
1167
|
+
// Commit the cache keys only AFTER the render succeeds: if renderDnaClaw ever
|
|
1168
|
+
// throws (resize race, bad gradient level), pre-committed keys would mark the
|
|
1169
|
+
// STALE art as current and freeze the header at an old frame forever.
|
|
1170
|
+
const art = renderDnaClaw({
|
|
205
1171
|
cols: innerWidth,
|
|
206
|
-
|
|
207
|
-
frame:
|
|
1172
|
+
phase: qPhase * 0.05,
|
|
1173
|
+
frame: twist,
|
|
1174
|
+
unicode: this.unicode,
|
|
1175
|
+
color: this.theme.color,
|
|
1176
|
+
colorLevel: detectColorLevel(process.env, isTTY()),
|
|
208
1177
|
});
|
|
209
1178
|
this.cachedArt = fit ? centerBlock(art, innerWidth) : art;
|
|
210
1179
|
const track = evolutionTrack(idx, { unicode: this.unicode, color: this.theme.color });
|
|
211
1180
|
this.cachedTrack = fit ? padLineTo(track, innerWidth, "center") : track;
|
|
1181
|
+
this.cachedStageIndex = idx;
|
|
1182
|
+
this.cachedCols = cols;
|
|
1183
|
+
this.cachedFrame = effFrame;
|
|
212
1184
|
}
|
|
213
|
-
|
|
214
|
-
const showArt = fit && rows >= 18 && cols >= 40;
|
|
215
|
-
const artLinesCount = showArt ? stageHeight() : 0;
|
|
1185
|
+
const artLinesCount = showArt ? dnaClawHeight() : 0;
|
|
216
1186
|
const trackCount = showArt ? 1 : 0;
|
|
217
1187
|
const headerHeight = artLinesCount + trackCount + (showArt ? 1 : 0);
|
|
218
1188
|
|
|
219
|
-
const toolLines = this.tools.render(fit ? Math.max(3, rows - 15) : undefined);
|
|
1189
|
+
const toolLines = this.tools.render(fit ? Math.max(3, rows - 15) : undefined, { color: this.theme.color, indexed: fit });
|
|
220
1190
|
const toolListHeight = toolLines.length;
|
|
221
1191
|
|
|
1192
|
+
const planLines = this.renderPlan(this.theme.color);
|
|
1193
|
+
const planHeight = planLines.length;
|
|
1194
|
+
|
|
1195
|
+
// gjc-style inline frame: a flat stack (live card → status line → todos → hud →
|
|
1196
|
+
// model bar), no outer border, no mascot art — completed work lives in scrollback.
|
|
1197
|
+
if (fit && this.inline) {
|
|
1198
|
+
const inlineFrame = this.composeInlineFrame({ cols, rows, stepNow, elapsedMs, idx, isThinking, planLines });
|
|
1199
|
+
this.renderer.render(inlineFrame.slice(0, rows));
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
222
1203
|
// Bottom-pinned status + footer.
|
|
223
1204
|
const bottom: string[] = [];
|
|
224
|
-
const statusMsg =
|
|
1205
|
+
const statusMsg = this.currentActivity();
|
|
1206
|
+
// Live USD cost from turn usage × the model's price (undefined when the model has no
|
|
1207
|
+
// known price — then no $ is shown). Computed once per draw, fed to status + footer.
|
|
1208
|
+
const costUsd = costForUsage(this.footer.model, this.turnUsage) ?? undefined;
|
|
225
1209
|
if (isThinking) {
|
|
1210
|
+
const colorLevel = detectColorLevel(process.env, isTTY());
|
|
1211
|
+
const phase = (this.tickCount * 0.05) % 1;
|
|
1212
|
+
const grad = themeGradient(this.theme, idx);
|
|
1213
|
+
const palette = [grad.from, grad.to];
|
|
1214
|
+
|
|
226
1215
|
if (fit) {
|
|
1216
|
+
bottom.push("");
|
|
1217
|
+
if (this.turnTitle) {
|
|
1218
|
+
const arrow = this.unicode ? "▸" : ">";
|
|
1219
|
+
const titleLine = ` ${arrow} ${this.turnTitle}`;
|
|
1220
|
+
bottom.push(this.theme.color ? chalk.dim(titleLine) : titleLine);
|
|
1221
|
+
}
|
|
1222
|
+
// Live status field: unboxed thinking line + compact metrics row. The
|
|
1223
|
+
// streamed activity is uniform across providers, with the ⟦esc⟧ cancel hint
|
|
1224
|
+
// right-aligned and no misleading step counter.
|
|
227
1225
|
const stats = this.tools.stats();
|
|
228
|
-
for (const line of
|
|
229
|
-
|
|
230
|
-
|
|
1226
|
+
for (const line of renderStatusBox({
|
|
1227
|
+
cols: innerWidth,
|
|
1228
|
+
phaseLabel: this.workflowStatus ? `${this.workflowStatus.skill}:${this.workflowStatus.phase}` : this.hudPhase,
|
|
1229
|
+
spinner: this.spinner.current(),
|
|
1230
|
+
activity: this.retryNotice ?? (this.streamingActivity || statusMsg),
|
|
1231
|
+
escHint: true,
|
|
231
1232
|
elapsedMs,
|
|
232
|
-
|
|
233
|
-
|
|
1233
|
+
stepElapsedMs: this.currentStepStartedAt ? Date.now() - this.currentStepStartedAt : undefined,
|
|
1234
|
+
avgStepMs: stepNow > 0 ? elapsedMs / stepNow : undefined,
|
|
234
1235
|
okCount: stats.ok,
|
|
235
1236
|
failCount: stats.fail,
|
|
236
1237
|
runningCount: stats.running,
|
|
237
1238
|
totalCount: stats.total,
|
|
238
1239
|
mutationGuarded: this.mutationGuarded,
|
|
239
1240
|
unicode: this.unicode,
|
|
1241
|
+
color: this.theme.color,
|
|
1242
|
+
colorLevel,
|
|
1243
|
+
phase,
|
|
1244
|
+
palette,
|
|
1245
|
+
isThinking: true,
|
|
1246
|
+
usage: this.turnUsage,
|
|
1247
|
+
costUsd,
|
|
1248
|
+
subagentActive: this.subagentActive,
|
|
240
1249
|
})) bottom.push(line);
|
|
241
1250
|
} else {
|
|
242
|
-
// Compact
|
|
243
|
-
|
|
244
|
-
|
|
1251
|
+
// Compact fallback still keeps progress and insight separate: no decorative
|
|
1252
|
+
// mixed "thinking/status" line, and retry notices never become stream logs.
|
|
1253
|
+
let msg = statusMsg;
|
|
1254
|
+
if (this.theme.color && colorLevel === ColorLevel.TrueColor) {
|
|
1255
|
+
msg = animatedGradientText(msg, palette, phase, { colorLevel });
|
|
1256
|
+
}
|
|
1257
|
+
const redBold = this.theme.color ? chalk.red.bold : (s: string) => s;
|
|
1258
|
+
const guardBadge = this.mutationGuarded ? ` ${redBold("[MUTATION LOCKED]")}` : "";
|
|
1259
|
+
bottom.push(` ${categoryBadge("progress", { color: this.theme.color })} elapsed ${formatDuration(elapsedMs)}`);
|
|
1260
|
+
bottom.push(` ${categoryBadge("status", { color: this.theme.color })} ${msg}${guardBadge}`);
|
|
245
1261
|
}
|
|
246
1262
|
}
|
|
247
|
-
|
|
1263
|
+
if (fit && this.livePromptInput.trim()) {
|
|
1264
|
+
bottom.push("");
|
|
1265
|
+
bottom.push(...this.renderLiveUserQueryCard(innerWidth));
|
|
1266
|
+
}
|
|
1267
|
+
// TTY only: keep the same query input box visible above the footer while the
|
|
1268
|
+
// turn is running; typed text edits the next-prompt draft, not a side queue.
|
|
1269
|
+
if (fit) {
|
|
1270
|
+
bottom.push(formatHintBar(undefined, { unicode: this.unicode, color: this.theme.color, cols: innerWidth }));
|
|
1271
|
+
bottom.push(...this.renderLiveInputBox(innerWidth));
|
|
1272
|
+
}
|
|
1273
|
+
// Live animated step strip appended to the footer when the turn has steps.
|
|
1274
|
+
const liveSteps = stepsFromTools(this.tools.snapshot());
|
|
1275
|
+
const strip = liveSteps.length
|
|
1276
|
+
? ` ${formatStepTimelineCompact(liveSteps, { unicode: this.unicode, color: this.theme.color, frame: this.tickCount, cap: 16 })}`
|
|
1277
|
+
: "";
|
|
1278
|
+
bottom.push(`${this.spinner.current()} ${renderFooter({ ...this.footer, elapsedMs, costUsd, color: this.theme.color })}${strip}`);
|
|
248
1279
|
const bottomHeight = bottom.length;
|
|
249
1280
|
|
|
250
1281
|
const forgeLines = fit ? this.renderForge(innerWidth, 2) : [];
|
|
251
1282
|
const forgeHeight = forgeLines.length;
|
|
252
1283
|
|
|
253
1284
|
const overhead = fit ? 4 : 0; // 2 borders + 2 dividers
|
|
254
|
-
const fixedHeight = headerHeight + toolListHeight + forgeHeight + bottomHeight + overhead;
|
|
255
|
-
const maxStreamLines = fit ? Math.max(
|
|
256
|
-
|
|
1285
|
+
const fixedHeight = headerHeight + planHeight + toolListHeight + forgeHeight + bottomHeight + overhead;
|
|
1286
|
+
const maxStreamLines = fit ? Math.max(0, rows - fixedHeight) : undefined;
|
|
1287
|
+
// Inline mode: ledger lines were already flushed into scrollback (appendLedger →
|
|
1288
|
+
// insertAbove); rendering the StreamRegion tail inside the frame too would show
|
|
1289
|
+
// every recent line TWICE the moment the user wheel-scrolls back. Tool list +
|
|
1290
|
+
// forge boxes keep live activity visible in-frame; the stream feeds only the
|
|
1291
|
+
// non-TTY / alt-screen frames and their final summaries.
|
|
1292
|
+
const streamLines = this.inline ? [] : this.stream.render(innerWidth, maxStreamLines);
|
|
257
1293
|
|
|
258
1294
|
let frame: string[] = [];
|
|
259
1295
|
|
|
260
1296
|
if (fit) {
|
|
261
1297
|
// Boxed TUI matching terminal width & height exactly
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
1298
|
+
// Reserve the bottom status/hint/footer FIRST — it is the live heartbeat (spinner,
|
|
1299
|
+
// step, ETA, mutation guard, key hints) and must NEVER be trimmed. Then fit the inner
|
|
1300
|
+
// sections into the remaining rows by priority, shedding the lowest-value content first
|
|
1301
|
+
// when the terminal is short: plan + tool list (work state) > stream > forge detail >
|
|
1302
|
+
// ASCII art (decorative). Previously the assembly could overflow `rows` and the final
|
|
1303
|
+
// slice(0, rows) silently cut the footer off the bottom.
|
|
1304
|
+
const avail = Math.max(0, rows - 2); // content rows inside the top + bottom borders
|
|
1305
|
+
const bottomKeep = bottom.slice(0, avail);
|
|
1306
|
+
let budget = avail - bottomKeep.length;
|
|
1307
|
+
const take = (lines: string[]): string[] => {
|
|
1308
|
+
const kept = budget > 0 ? lines.slice(0, budget) : [];
|
|
1309
|
+
budget -= kept.length;
|
|
1310
|
+
return kept;
|
|
1311
|
+
};
|
|
1312
|
+
// Keep-priority order (highest first); display order is reassembled below.
|
|
1313
|
+
const planK = take(planLines);
|
|
1314
|
+
const toolsK = take(toolLines);
|
|
1315
|
+
const streamK = take(streamLines);
|
|
1316
|
+
// Forge boxes are bordered — include as many WHOLE (most-recent) boxes as fit; never a half-box.
|
|
1317
|
+
const forgeK = fitForgeBoxes(forgeLines, budget);
|
|
1318
|
+
budget -= forgeK.length;
|
|
1319
|
+
let headerK: string[] = [];
|
|
1320
|
+
if (showArt && budget >= headerHeight) {
|
|
1321
|
+
headerK = [...this.cachedArt, this.cachedTrack, "DIVIDER"];
|
|
1322
|
+
budget -= headerHeight;
|
|
267
1323
|
}
|
|
268
1324
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
1325
|
+
// Stage-grouped activity cards (shadcn-style rhythm + muted card headers): the
|
|
1326
|
+
// plan and forge boxes already self-label, so only the tool block ("Activity")
|
|
1327
|
+
// and the forge group ("Output") get a muted divider. Adjacent non-empty
|
|
1328
|
+
// sections are separated by SECTION_GAP blank lines so the live stages read as
|
|
1329
|
+
// distinct cards instead of one cramped wall.
|
|
1330
|
+
const activity = stackSections(
|
|
1331
|
+
[
|
|
1332
|
+
{ lines: planK },
|
|
1333
|
+
{ title: "Activity", lines: toolsK },
|
|
1334
|
+
{ lines: streamK },
|
|
1335
|
+
{ title: "Output", lines: forgeK },
|
|
1336
|
+
],
|
|
1337
|
+
{ width: innerWidth, gap: SECTION_GAP, color: this.theme.color, unicode: this.unicode },
|
|
1338
|
+
);
|
|
1339
|
+
// The labels + gaps add structural rows; spend them from the leftover budget
|
|
1340
|
+
// (what would otherwise be dead filler) and trim the group if the terminal is
|
|
1341
|
+
// too short so the assembled height never exceeds `avail`.
|
|
1342
|
+
const rawLen = planK.length + toolsK.length + streamK.length + forgeK.length;
|
|
1343
|
+
const overhead = Math.max(0, activity.length - rawLen);
|
|
1344
|
+
let activityGroup = activity;
|
|
1345
|
+
if (overhead > budget) {
|
|
1346
|
+
activityGroup = activity.slice(0, rawLen + budget);
|
|
1347
|
+
budget = 0;
|
|
1348
|
+
} else {
|
|
1349
|
+
budget -= overhead;
|
|
1350
|
+
}
|
|
1351
|
+
let trailingDivider: string[] = [];
|
|
1352
|
+
if (activityGroup.length && budget > 0) {
|
|
1353
|
+
trailingDivider = ["DIVIDER"];
|
|
1354
|
+
budget--;
|
|
1355
|
+
}
|
|
1356
|
+
const fillerCount = Math.max(0, budget);
|
|
277
1357
|
|
|
1358
|
+
// Display order (shadcn header / spacer / content+footer): the decorative art
|
|
1359
|
+
// banner sits at the top, breathing room follows, then the live activity cards
|
|
1360
|
+
// hug the bottom status panel — no dead gap between the work area and the HUD.
|
|
278
1361
|
const boxedContent: string[] = [];
|
|
279
|
-
for (const line of
|
|
1362
|
+
for (const line of headerK) boxedContent.push(line);
|
|
280
1363
|
for (let i = 0; i < fillerCount; i++) boxedContent.push("");
|
|
281
|
-
for (const line of
|
|
1364
|
+
for (const line of activityGroup) boxedContent.push(line);
|
|
1365
|
+
for (const line of trailingDivider) boxedContent.push(line);
|
|
1366
|
+
for (const line of bottomKeep) boxedContent.push(line);
|
|
282
1367
|
|
|
283
|
-
const paint = this.theme.color ?
|
|
1368
|
+
const paint = this.theme.color ? accentPaint(this.theme) : (s: string) => s;
|
|
284
1369
|
frame = boxBlock(boxedContent, cols, {
|
|
285
1370
|
glyphs: this.unicode ? BOX_UNICODE : BOX_ASCII,
|
|
286
1371
|
paint,
|
|
287
1372
|
});
|
|
1373
|
+
|
|
288
1374
|
} else {
|
|
289
1375
|
// Unboxed Mode (fallback for tests/non-TTY)
|
|
290
1376
|
const header = showArt ? [...this.cachedArt, this.cachedTrack, ""] : [];
|
|
291
|
-
const body = [...toolLines, ...streamLines, ...forgeLines];
|
|
1377
|
+
const body = [...planLines, ...toolLines, ...streamLines, ...forgeLines];
|
|
292
1378
|
frame = [...header, ...body, ...bottom];
|
|
293
1379
|
}
|
|
294
1380
|
|
|
1381
|
+
if (fit) {
|
|
1382
|
+
frame = frame.slice(0, rows);
|
|
1383
|
+
}
|
|
295
1384
|
this.renderer.render(frame);
|
|
296
1385
|
}
|
|
297
1386
|
}
|