jeo-code 0.1.0

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.
Files changed (93) hide show
  1. package/README.md +342 -0
  2. package/package.json +57 -0
  3. package/scripts/install.sh +322 -0
  4. package/scripts/uninstall.sh +30 -0
  5. package/src/agent/compaction.ts +75 -0
  6. package/src/agent/config-schema.ts +87 -0
  7. package/src/agent/context-files.ts +51 -0
  8. package/src/agent/engine.ts +208 -0
  9. package/src/agent/json.ts +87 -0
  10. package/src/agent/loop.ts +22 -0
  11. package/src/agent/session.ts +198 -0
  12. package/src/agent/state.ts +199 -0
  13. package/src/agent/subagents.ts +149 -0
  14. package/src/agent/tools.ts +355 -0
  15. package/src/ai/index.ts +11 -0
  16. package/src/ai/model-catalog-compat.ts +119 -0
  17. package/src/ai/model-catalog.ts +97 -0
  18. package/src/ai/model-discovery.ts +148 -0
  19. package/src/ai/model-enrich.ts +75 -0
  20. package/src/ai/model-manager.ts +178 -0
  21. package/src/ai/model-picker.ts +73 -0
  22. package/src/ai/model-registry.ts +83 -0
  23. package/src/ai/provider-status.ts +77 -0
  24. package/src/ai/providers/anthropic.ts +87 -0
  25. package/src/ai/providers/errors.ts +47 -0
  26. package/src/ai/providers/gemini.ts +77 -0
  27. package/src/ai/providers/ollama.ts +54 -0
  28. package/src/ai/providers/openai.ts +67 -0
  29. package/src/ai/sse.ts +46 -0
  30. package/src/ai/types.ts +37 -0
  31. package/src/auth/callback-server.ts +195 -0
  32. package/src/auth/flows/anthropic.ts +114 -0
  33. package/src/auth/flows/google.ts +120 -0
  34. package/src/auth/flows/index.ts +50 -0
  35. package/src/auth/flows/openai.ts +130 -0
  36. package/src/auth/index.ts +23 -0
  37. package/src/auth/oauth.ts +80 -0
  38. package/src/auth/pkce.ts +24 -0
  39. package/src/auth/refresh.ts +60 -0
  40. package/src/auth/storage.ts +113 -0
  41. package/src/auth/types.ts +26 -0
  42. package/src/cli/index.ts +1 -0
  43. package/src/cli/runner.ts +245 -0
  44. package/src/cli.ts +17 -0
  45. package/src/commands/approve.ts +63 -0
  46. package/src/commands/auth.ts +144 -0
  47. package/src/commands/chat.ts +37 -0
  48. package/src/commands/deep-interview.ts +239 -0
  49. package/src/commands/doctor.ts +250 -0
  50. package/src/commands/evolve.ts +191 -0
  51. package/src/commands/launch.ts +745 -0
  52. package/src/commands/mcp.ts +18 -0
  53. package/src/commands/models.ts +104 -0
  54. package/src/commands/ralplan.ts +86 -0
  55. package/src/commands/resume.ts +6 -0
  56. package/src/commands/setup-helpers.ts +93 -0
  57. package/src/commands/setup.ts +190 -0
  58. package/src/commands/skills.ts +38 -0
  59. package/src/commands/team.ts +337 -0
  60. package/src/commands/ultragoal.ts +102 -0
  61. package/src/index.ts +31 -0
  62. package/src/mcp/index.ts +3 -0
  63. package/src/mcp/protocol.ts +45 -0
  64. package/src/mcp/server.ts +97 -0
  65. package/src/mcp/tools.ts +156 -0
  66. package/src/skills/catalog.ts +61 -0
  67. package/src/tui/app.ts +297 -0
  68. package/src/tui/components/ascii-art.ts +340 -0
  69. package/src/tui/components/autocomplete.ts +165 -0
  70. package/src/tui/components/capability.ts +29 -0
  71. package/src/tui/components/code-view.ts +146 -0
  72. package/src/tui/components/color.ts +172 -0
  73. package/src/tui/components/config-panel.ts +193 -0
  74. package/src/tui/components/evolution.ts +305 -0
  75. package/src/tui/components/footer.ts +95 -0
  76. package/src/tui/components/forge.ts +167 -0
  77. package/src/tui/components/index.ts +7 -0
  78. package/src/tui/components/layout.ts +105 -0
  79. package/src/tui/components/meter.ts +61 -0
  80. package/src/tui/components/model-picker.ts +82 -0
  81. package/src/tui/components/provider-picker.ts +42 -0
  82. package/src/tui/components/select-list.ts +199 -0
  83. package/src/tui/components/slash.ts +34 -0
  84. package/src/tui/components/spinner.ts +49 -0
  85. package/src/tui/components/status.ts +45 -0
  86. package/src/tui/components/stream.ts +36 -0
  87. package/src/tui/components/themes.ts +86 -0
  88. package/src/tui/components/tool-list.ts +67 -0
  89. package/src/tui/index.ts +2 -0
  90. package/src/tui/renderer.ts +70 -0
  91. package/src/tui/terminal.ts +78 -0
  92. package/src/util/retry.ts +108 -0
  93. package/tsconfig.json +18 -0
@@ -0,0 +1,156 @@
1
+ import { resolveProvider } from "../ai";
2
+ import { resolveCredential, type AuthProvider } from "../auth";
3
+ import { readGlobalConfig } from "../agent/state";
4
+ import type { ToolDefinition, ToolResult } from "./protocol";
5
+
6
+ const AUTH_PROVIDERS: AuthProvider[] = ["anthropic", "openai", "gemini"];
7
+
8
+ function textResult(text: string, isError = false): ToolResult {
9
+ return { content: [{ type: "text", text }], isError };
10
+ }
11
+
12
+ export const TOOLS: ToolDefinition[] = [
13
+ {
14
+ name: "joc_resolve_provider",
15
+ description: "Given a model name (e.g. 'claude-3-5-sonnet', 'openai/mock-1', 'gemini-2.0-flash', 'ollama/llama3'), return which provider joc would route the call to.",
16
+ inputSchema: {
17
+ type: "object",
18
+ properties: {
19
+ model: { type: "string", description: "Model identifier (raw or prefixed)" },
20
+ },
21
+ required: ["model"],
22
+ },
23
+ async handler(args) {
24
+ const model = String(args.model ?? "");
25
+ if (!model) return textResult("error: 'model' is required", true);
26
+ return textResult(resolveProvider(model));
27
+ },
28
+ },
29
+ {
30
+ name: "joc_credential_status",
31
+ description: "Report which auth method is configured for a provider (oauth|api_key|none). Does not reveal the secret.",
32
+ inputSchema: {
33
+ type: "object",
34
+ properties: {
35
+ provider: { type: "string", enum: AUTH_PROVIDERS, description: "Cloud provider" },
36
+ },
37
+ required: ["provider"],
38
+ },
39
+ async handler(args) {
40
+ const provider = String(args.provider ?? "");
41
+ if (!AUTH_PROVIDERS.includes(provider as AuthProvider)) {
42
+ return textResult(`error: provider must be one of ${AUTH_PROVIDERS.join(", ")}`, true);
43
+ }
44
+ const credential = await resolveCredential(provider as AuthProvider);
45
+ return textResult(JSON.stringify({ provider, kind: credential.kind }));
46
+ },
47
+ },
48
+ {
49
+ name: "joc_config_snapshot",
50
+ description: "Return a redacted snapshot of joc's current config: default model, openai/ollama base URLs, and which providers have credentials.",
51
+ inputSchema: { type: "object", properties: {} },
52
+ async handler() {
53
+ const cfg = await readGlobalConfig();
54
+ const snapshot = {
55
+ defaultModel: cfg.defaultModel,
56
+ defaultProvider: resolveProvider(cfg.defaultModel),
57
+ openaiBaseUrl: cfg.openaiBaseUrl ?? null,
58
+ ollamaBaseUrl: cfg.ollamaBaseUrl ?? "http://localhost:11434",
59
+ credentials: {
60
+ anthropic: (await resolveCredential("anthropic")).kind,
61
+ openai: (await resolveCredential("openai")).kind,
62
+ gemini: (await resolveCredential("gemini")).kind,
63
+ },
64
+ };
65
+ return textResult(JSON.stringify(snapshot, null, 2));
66
+ },
67
+ },
68
+ {
69
+ name: "joc_doctor",
70
+ description: "Run joc's health probe and return a structured report of provider connectivity. Same probe used by 'joc doctor' CLI.",
71
+ inputSchema: { type: "object", properties: {} },
72
+ async handler() {
73
+ const { runDoctorCommand } = await import("../commands/doctor");
74
+ const lines: string[] = [];
75
+ const originalLog = console.log;
76
+ console.log = (...args: unknown[]) => {
77
+ lines.push(args.map(a => (typeof a === "string" ? a : JSON.stringify(a))).join(" "));
78
+ };
79
+ try {
80
+ await runDoctorCommand([]);
81
+ } finally {
82
+ console.log = originalLog;
83
+ }
84
+ return textResult(lines.join("\n"));
85
+ },
86
+ },
87
+ ];
88
+
89
+ async function captureCommand(run: () => Promise<void>): Promise<string> {
90
+ const lines: string[] = [];
91
+ const originalLog = console.log;
92
+ console.log = (...args: unknown[]) => {
93
+ lines.push(args.map(a => (typeof a === "string" ? a : JSON.stringify(a))).join(" "));
94
+ };
95
+ try {
96
+ await run();
97
+ } finally {
98
+ console.log = originalLog;
99
+ }
100
+ return lines.join("\n");
101
+ }
102
+
103
+ const PIPELINE_TOOLS: ToolDefinition[] = [
104
+ {
105
+ name: "joc_deep_interview",
106
+ description: "DANGER: WRITES FILES + BURNS LLM CREDITS. Runs Socratic requirements interview. Writes .joc/spec.json. Requires default model credential.",
107
+ inputSchema: {
108
+ type: "object",
109
+ properties: {
110
+ idea: { type: "string", description: "Initial product idea seed" },
111
+ },
112
+ required: ["idea"],
113
+ },
114
+ async handler(args) {
115
+ const idea = String(args.idea ?? "");
116
+ if (!idea) return textResult("error: 'idea' is required", true);
117
+ const { runDeepInterviewCommand } = await import("../commands/deep-interview");
118
+ const out = await captureCommand(() => runDeepInterviewCommand([idea]));
119
+ return textResult(out);
120
+ },
121
+ },
122
+ {
123
+ name: "joc_ralplan",
124
+ description: "DANGER: WRITES FILES + BURNS LLM CREDITS. Reads .joc/spec.json, writes .joc/plan.yaml via Planner/Architect/Critic.",
125
+ inputSchema: { type: "object", properties: {} },
126
+ async handler() {
127
+ const { runRalplanCommand } = await import("../commands/ralplan");
128
+ const out = await captureCommand(() => runRalplanCommand());
129
+ return textResult(out);
130
+ },
131
+ },
132
+ {
133
+ name: "joc_team",
134
+ description: "DANGER: WRITES FILES + EDITS CODE + BURNS LLM CREDITS. Executes .joc/plan.yaml via Executor subagent. Modifies the working tree.",
135
+ inputSchema: { type: "object", properties: {} },
136
+ async handler() {
137
+ const { runTeamCommand } = await import("../commands/team");
138
+ const out = await captureCommand(() => runTeamCommand());
139
+ return textResult(out);
140
+ },
141
+ },
142
+ {
143
+ name: "joc_ultragoal",
144
+ description: "DANGER: BURNS LLM CREDITS. Runs acceptance verification against .joc/spec.json + .joc/plan.yaml.",
145
+ inputSchema: { type: "object", properties: {} },
146
+ async handler() {
147
+ const { runUltragoalCommand } = await import("../commands/ultragoal");
148
+ const out = await captureCommand(() => runUltragoalCommand());
149
+ return textResult(out);
150
+ },
151
+ },
152
+ ];
153
+
154
+ if (process.env.JOC_MCP_PIPELINE === "1") {
155
+ TOOLS.push(...PIPELINE_TOOLS);
156
+ }
@@ -0,0 +1,61 @@
1
+ export interface SkillDoc {
2
+ name: string; // e.g. "deep-interview"
3
+ command: string; // e.g. "joc deep-interview \"<idea>\""
4
+ summary: string; // one line
5
+ whenToUse: string; // one line
6
+ details: string; // 2-5 lines of guidance
7
+ }
8
+
9
+ export const SKILLS: SkillDoc[] = [
10
+ {
11
+ name: "deep-interview",
12
+ command: 'joc deep-interview "<idea>"',
13
+ summary: "Socratic ambiguity gate, freezes a seed at ambiguity ≤ 20%; --auto for non-interactive.",
14
+ whenToUse: "When an idea is vague and needs requirement gathering and refinement before planning.",
15
+ details: "Initiates a Socratic dialogue to ask clarifying questions about a vague idea.\nScores the ambiguity of the proposal and iterates until it is under 20%.\nSaves a structured requirements seed that can be used by subsequent workflows.\nSupports an --auto flag to skip interaction."
16
+ },
17
+ {
18
+ name: "ralplan",
19
+ command: "joc ralplan",
20
+ summary: "Planner/Architect/Critic blueprint from the seed.",
21
+ whenToUse: "When requirements are clear (e.g. from deep-interview) and you need a robust execution blueprint.",
22
+ details: "Executes a multi-agent critique and planning process to generate a structured implementation plan.\nCombines views from a Planner, an Architect, and a Critic to identify risks, define tasks, and specify files.\nSaves the blueprint for execution."
23
+ },
24
+ {
25
+ name: "team",
26
+ command: "joc team",
27
+ summary: "Per-task executor loop against the plan.",
28
+ whenToUse: "When you have a blueprint/plan and need to execute the concrete implementation tasks.",
29
+ details: "Coordinates execution of individual tasks defined in the blueprint.\nSpawns per-task executor subagents or loops to implement code changes.\nEnsures task-level isolation and tracks implementation status."
30
+ },
31
+ {
32
+ name: "ultragoal",
33
+ command: "joc ultragoal",
34
+ summary: "Verify acceptance criteria, write report.",
35
+ whenToUse: "When tasks are implemented and you need a final, high-level verification and summary report.",
36
+ details: "Verifies the implementation against the acceptance criteria specified in the plan.\nRuns checks, tests, or validations to ensure correctness.\nGenerates a final completion report outlining the changes and verification evidence."
37
+ }
38
+ ];
39
+
40
+ export function getSkill(name: string): SkillDoc | undefined {
41
+ return SKILLS.find(s => s.name.toLowerCase() === name.toLowerCase());
42
+ }
43
+
44
+ export function skillNames(): string[] {
45
+ return SKILLS.map(s => s.name);
46
+ }
47
+
48
+ export function formatSkill(s: SkillDoc): string {
49
+ return [
50
+ `Skill: ${s.name}`,
51
+ `Command: ${s.command}`,
52
+ `Summary: ${s.summary}`,
53
+ `When to use: ${s.whenToUse}`,
54
+ `Details:`,
55
+ s.details.split("\n").map(line => ` ${line}`).join("\n")
56
+ ].join("\n");
57
+ }
58
+
59
+ export function skillsPromptSection(): string {
60
+ return SKILLS.map(s => `- ${s.name} — ${s.summary}`).join("\n");
61
+ }
package/src/tui/app.ts ADDED
@@ -0,0 +1,297 @@
1
+ /**
2
+ * LaunchTui — the interactive coding-agent TUI (plan/01-tui.md §M2).
3
+ *
4
+ * Owns a differential Renderer + components and maps the agent engine's
5
+ * `AgentLoopEvents` to a live, in-place frame: a tool-call list + an animated
6
+ * status footer. On turn end the live region collapses to static final output
7
+ * (tool summary + the assistant's reply), which stays in scrollback.
8
+ *
9
+ * Pure UI: it never imports the engine; the caller passes `tui.events()` into
10
+ * `runAgentLoop` and calls `tui.start()` / `tui.finish(reply)` around the turn.
11
+ */
12
+ import { Renderer } from "./renderer";
13
+ import { readWorkflowState } from "../agent/state";
14
+ import { size, isTTY, hideCursor, showCursor } from "./terminal";
15
+ import { Spinner } from "./components/spinner";
16
+ import { ToolList } from "./components/tool-list";
17
+ import { StreamRegion } from "./components/stream";
18
+ import { renderFooter, type FooterData } from "./components/footer";
19
+ import { getStageByIndex, renderAsciiArt, stageHeight, stageWidth } from "./components/ascii-art";
20
+ import { evolutionTrack, createStageProgress, type StageProgress, getEvolutionStatusMessage, transitionMessage } from "./components/evolution";
21
+ import { supportsUnicode } from "./components/capability";
22
+ import { centerBlock, padLineTo, fillScreen, boxBlock, BOX_ASCII, BOX_UNICODE } from "./components/layout";
23
+ import { resolveTheme } from "./components/themes";
24
+ import { formatForgeBox, summarizeForgeInvocation, summarizeForgeResult, type ForgeSummary } from "./components/forge";
25
+ import { renderJocStatus } from "./components/status";
26
+ import chalk from "chalk";
27
+
28
+ export interface LaunchTuiOptions {
29
+ model: string;
30
+ /** Resolved provider name for the footer (anthropic / openai / gemini / ollama). */
31
+ provider?: string;
32
+ sessionId?: string;
33
+ write?: (s: string) => void;
34
+ /** Step budget for this turn; drives the footer's `step N/M` denominator. */
35
+ maxSteps?: number;
36
+ }
37
+
38
+ export interface AgentEventsLike {
39
+ onStep?(step: number): void;
40
+ onAssistant?(raw: string, invocation: { tool: string; arguments?: unknown } | null): void;
41
+ onToolResult?(tool: string, success: boolean, output: string): void;
42
+ onError?(message: string): void;
43
+ }
44
+
45
+ const DEFAULT_MAX_STEPS = 25;
46
+
47
+ export class LaunchTui {
48
+ private readonly renderer: Renderer;
49
+ private readonly write: (s: string) => void;
50
+ private readonly spinner: Spinner;
51
+ private readonly tools = new ToolList();
52
+ private readonly stream = new StreamRegion();
53
+ private readonly forgeSummaries: ForgeSummary[] = [];
54
+ private readonly footer: FooterData;
55
+ private startedAt = 0;
56
+ private tickCount = 0;
57
+ private mutationGuarded = false;
58
+ private timer: ReturnType<typeof setInterval> | undefined;
59
+ private pendingIndex: number | null = null;
60
+ // Cache the rendered art + track per stage so the 120ms spinner tick reuses
61
+ // them instead of re-rendering/re-coloring the block every frame.
62
+ private cachedStageIndex = -1;
63
+ private cachedCols = -1;
64
+ private cachedArt: string[] = [];
65
+ private cachedTrack = "";
66
+ // Monotonic stage progress so evolution only ever moves forward this turn.
67
+ private readonly progress: StageProgress = createStageProgress();
68
+ // Terminal unicode capability, detected once (drives spinner/track glyph set).
69
+ private readonly unicode: boolean = supportsUnicode();
70
+ // Active color theme (JOC_TUI_THEME), default cosmic; `mono` disables color.
71
+ private readonly theme = resolveTheme();
72
+
73
+ constructor(opts: LaunchTuiOptions) {
74
+ this.write = opts.write ?? ((s: string) => process.stdout.write(s));
75
+ this.renderer = new Renderer(this.write);
76
+ this.spinner = new Spinner(undefined, { unicode: this.unicode });
77
+ this.footer = {
78
+ model: opts.model,
79
+ provider: opts.provider,
80
+ sessionId: opts.sessionId,
81
+ maxSteps: opts.maxSteps ?? DEFAULT_MAX_STEPS,
82
+ unicode: this.unicode,
83
+ showEta: true,
84
+ showProgress: true,
85
+ };
86
+ }
87
+
88
+ /** Whether a TUI should be used at all (TTY required). */
89
+ static usable(noTui: boolean): boolean {
90
+ return isTTY() && !noTui;
91
+ }
92
+
93
+ /** The events object to hand to runAgentLoop. */
94
+ events(): AgentEventsLike {
95
+ return {
96
+ onStep: step => {
97
+ this.footer.step = step;
98
+ this.spinner.updateStep(step, this.footer.maxSteps);
99
+ this.spinner.next();
100
+ this.draw();
101
+ },
102
+ onAssistant: (_raw, invocation) => {
103
+ if (invocation && invocation.tool !== "done") {
104
+ this.pendingIndex = this.tools.start(invocation.tool);
105
+ this.rememberForge(summarizeForgeInvocation(invocation.tool, invocation.arguments));
106
+ this.draw();
107
+ }
108
+ },
109
+ onToolResult: (tool, success, output) => {
110
+ if (this.pendingIndex !== null) {
111
+ this.tools.finish(this.pendingIndex, success);
112
+ this.pendingIndex = null;
113
+ }
114
+ this.rememberForge(summarizeForgeResult(tool, success, output));
115
+ const marker = success ? (this.unicode ? "✓" : "v") : (this.unicode ? "✗" : "x");
116
+ this.stream.append(`${marker} ${success ? "complete" : "error"}: ${tool}\n`);
117
+ this.draw();
118
+ },
119
+ onError: msg => {
120
+ const marker = this.unicode ? "✗" : "x";
121
+ this.stream.append(`${marker} error: ${msg}\n`);
122
+ this.draw();
123
+ },
124
+ };
125
+ }
126
+
127
+ start(): void {
128
+ this.startedAt = Date.now();
129
+ this.spinner.updateStep(0, this.footer.maxSteps);
130
+ this.write(hideCursor());
131
+ this.draw();
132
+
133
+ readWorkflowState("deep-interview")
134
+ .then(state => {
135
+ this.mutationGuarded = !!(state && state.active && state.current_phase !== "complete");
136
+ this.draw();
137
+ })
138
+ .catch(() => {});
139
+ // Animate the spinner + elapsed clock while the model is thinking.
140
+ this.timer = setInterval(() => {
141
+ this.tickCount++;
142
+ this.spinner.next();
143
+ this.draw();
144
+ }, 120);
145
+ }
146
+
147
+ /** Collapse the live region to static final output. */
148
+ finish(reply: string): void {
149
+ if (this.timer) {
150
+ clearInterval(this.timer);
151
+ this.timer = undefined;
152
+ }
153
+ this.renderer.clear();
154
+ this.write(showCursor());
155
+ const finalLines = [...this.tools.render()];
156
+ for (const line of this.stream.render(size().cols)) finalLines.push(line);
157
+ for (const line of this.renderForge(size().cols, 3)) finalLines.push(line);
158
+ // Show how far the agent evolved this turn (monotonic peak) with rich statistics.
159
+ const elapsedSecs = Math.round((Date.now() - this.startedAt) / 1000);
160
+ const steps = this.footer.step || 0;
161
+ const peak = this.progress.current();
162
+ finalLines.push(`Evolved to: ${evolutionTrack(peak, { unicode: this.unicode, color: this.theme.color })} (took ${steps} steps in ${elapsedSecs}s)`);
163
+ finalLines.push(`joc> ${reply}`);
164
+ console.log(finalLines.join("\n"));
165
+ }
166
+
167
+ private rememberForge(summary: ForgeSummary): void {
168
+ this.forgeSummaries.push(summary);
169
+ if (this.forgeSummaries.length > 8) this.forgeSummaries.shift();
170
+ }
171
+
172
+ private renderForge(width: number, maxEntries: number): string[] {
173
+ const boxWidth = Math.max(24, Math.min(96, width));
174
+ const paint = this.theme.color ? chalk.gray : (s: string) => s;
175
+ const lines: string[] = [];
176
+ for (const summary of this.forgeSummaries.slice(-maxEntries)) {
177
+ if (lines.length > 0) lines.push("");
178
+ lines.push(...formatForgeBox(summary, { width: boxWidth, maxLines: 8, unicode: this.unicode, paint }));
179
+ }
180
+ return lines;
181
+ }
182
+
183
+ private draw(): void {
184
+ const { cols, rows } = size();
185
+ const fit = isTTY(); // fill terminal width+height only on a real TTY
186
+ const elapsedMs = this.startedAt ? Date.now() - this.startedAt : 0;
187
+ const innerWidth = fit ? cols - 4 : cols;
188
+
189
+ // Resolve the current (monotonic) stage; announce a transition once when it
190
+ // first advances. The art + track are cached per stage index/cols so the
191
+ // 120ms spinner tick does not re-render the block every frame.
192
+ const stepNow = this.footer.step || 0;
193
+ const idx = this.progress.observe(stepNow, this.footer.maxSteps ?? DEFAULT_MAX_STEPS);
194
+ const isThinking = this.timer !== undefined;
195
+ if (fit && this.progress.advanced() && idx > 0) {
196
+ const arrow = this.unicode ? "\u27f6" : "->";
197
+ this.stream.append(`${arrow} ${transitionMessage(idx)}\n`);
198
+ }
199
+ if (idx !== this.cachedStageIndex || cols !== this.cachedCols || isThinking) {
200
+ this.cachedStageIndex = idx;
201
+ this.cachedCols = cols;
202
+ const art = renderAsciiArt(getStageByIndex(idx), {
203
+ height: stageHeight(),
204
+ width: stageWidth(),
205
+ cols: innerWidth,
206
+ firing: isThinking,
207
+ frame: isThinking ? this.tickCount : 0,
208
+ });
209
+ this.cachedArt = fit ? centerBlock(art, innerWidth) : art;
210
+ const track = evolutionTrack(idx, { unicode: this.unicode, color: this.theme.color });
211
+ this.cachedTrack = fit ? padLineTo(track, innerWidth, "center") : track;
212
+ }
213
+
214
+ const showArt = fit && rows >= 18 && cols >= 40;
215
+ const artLinesCount = showArt ? stageHeight() : 0;
216
+ const trackCount = showArt ? 1 : 0;
217
+ const headerHeight = artLinesCount + trackCount + (showArt ? 1 : 0);
218
+
219
+ const toolLines = this.tools.render(fit ? Math.max(3, rows - 15) : undefined);
220
+ const toolListHeight = toolLines.length;
221
+
222
+ // Bottom-pinned status + footer.
223
+ const bottom: string[] = [];
224
+ const statusMsg = getEvolutionStatusMessage(stepNow, this.footer.maxSteps ?? DEFAULT_MAX_STEPS, this.tickCount);
225
+ if (isThinking) {
226
+ if (fit) {
227
+ const stats = this.tools.stats();
228
+ for (const line of renderJocStatus({
229
+ step: stepNow,
230
+ maxSteps: this.footer.maxSteps,
231
+ elapsedMs,
232
+ message: statusMsg,
233
+ currentTool: this.tools.currentTool(),
234
+ okCount: stats.ok,
235
+ failCount: stats.fail,
236
+ runningCount: stats.running,
237
+ totalCount: stats.total,
238
+ mutationGuarded: this.mutationGuarded,
239
+ unicode: this.unicode,
240
+ })) bottom.push(line);
241
+ } else {
242
+ // Compact single-line status off a TTY (pipes / tests).
243
+ const guardBadge = this.mutationGuarded ? ` ${chalk.red.bold("[MUTATION LOCKED]")}` : "";
244
+ bottom.push(` ${chalk.italic.gray(statusMsg)}${guardBadge}`);
245
+ }
246
+ }
247
+ bottom.push(`${this.spinner.current()} ${renderFooter({ ...this.footer, elapsedMs })}`);
248
+ const bottomHeight = bottom.length;
249
+
250
+ const forgeLines = fit ? this.renderForge(innerWidth, 2) : [];
251
+ const forgeHeight = forgeLines.length;
252
+
253
+ const overhead = fit ? 4 : 0; // 2 borders + 2 dividers
254
+ const fixedHeight = headerHeight + toolListHeight + forgeHeight + bottomHeight + overhead;
255
+ const maxStreamLines = fit ? Math.max(2, rows - fixedHeight) : undefined;
256
+ const streamLines = this.stream.render(innerWidth, maxStreamLines);
257
+
258
+ let frame: string[] = [];
259
+
260
+ if (fit) {
261
+ // Boxed TUI matching terminal width & height exactly
262
+ const innerLines: string[] = [];
263
+ if (showArt) {
264
+ for (const line of this.cachedArt) innerLines.push(line);
265
+ innerLines.push(this.cachedTrack);
266
+ innerLines.push("DIVIDER");
267
+ }
268
+
269
+ for (const line of toolLines) innerLines.push(line);
270
+ for (const line of streamLines) innerLines.push(line);
271
+ for (const line of forgeLines) innerLines.push(line);
272
+
273
+ innerLines.push("DIVIDER");
274
+
275
+ const totalLines = innerLines.length + bottom.length;
276
+ const fillerCount = Math.max(0, rows - 2 - totalLines);
277
+
278
+ const boxedContent: string[] = [];
279
+ for (const line of innerLines) boxedContent.push(line);
280
+ for (let i = 0; i < fillerCount; i++) boxedContent.push("");
281
+ for (const line of bottom) boxedContent.push(line);
282
+
283
+ const paint = this.theme.color ? chalk.blue : (s: string) => s;
284
+ frame = boxBlock(boxedContent, cols, {
285
+ glyphs: this.unicode ? BOX_UNICODE : BOX_ASCII,
286
+ paint,
287
+ });
288
+ } else {
289
+ // Unboxed Mode (fallback for tests/non-TTY)
290
+ const header = showArt ? [...this.cachedArt, this.cachedTrack, ""] : [];
291
+ const body = [...toolLines, ...streamLines, ...forgeLines];
292
+ frame = [...header, ...body, ...bottom];
293
+ }
294
+
295
+ this.renderer.render(frame);
296
+ }
297
+ }