jeo-code 0.1.0 → 0.4.5

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 (177) hide show
  1. package/README.ja.md +160 -0
  2. package/README.ko.md +160 -0
  3. package/README.md +115 -297
  4. package/README.zh.md +160 -0
  5. package/package.json +11 -6
  6. package/scripts/install.sh +28 -28
  7. package/scripts/uninstall.sh +17 -15
  8. package/src/AGENTS.md +50 -0
  9. package/src/agent/AGENTS.md +49 -0
  10. package/src/agent/bash-fixups.ts +103 -0
  11. package/src/agent/compaction.ts +410 -19
  12. package/src/agent/config-schema.ts +119 -5
  13. package/src/agent/context-files.ts +314 -17
  14. package/src/agent/dev/AGENTS.md +36 -0
  15. package/src/agent/dev/advanced-analyzer.ts +12 -0
  16. package/src/agent/dev/evolution-bridge.ts +82 -0
  17. package/src/agent/dev/evolution-logger.ts +41 -0
  18. package/src/agent/dev/self-analysis.ts +64 -0
  19. package/src/agent/dev/self-improve.ts +24 -0
  20. package/src/agent/dev/spec-automation.ts +49 -0
  21. package/src/agent/engine.ts +808 -54
  22. package/src/agent/hooks.ts +273 -0
  23. package/src/agent/loop.ts +21 -1
  24. package/src/agent/memory.ts +201 -0
  25. package/src/agent/model-recency.ts +32 -0
  26. package/src/agent/output-minimizer.ts +108 -0
  27. package/src/agent/output-util.ts +64 -0
  28. package/src/agent/plan.ts +187 -0
  29. package/src/agent/seed.ts +52 -0
  30. package/src/agent/session.ts +235 -21
  31. package/src/agent/state.ts +286 -39
  32. package/src/agent/step-budget.ts +232 -0
  33. package/src/agent/subagents.ts +223 -26
  34. package/src/agent/task-tool.ts +272 -0
  35. package/src/agent/todo-tool.ts +87 -0
  36. package/src/agent/tokenizer.ts +117 -0
  37. package/src/agent/tool-registry.ts +54 -0
  38. package/src/agent/tools.ts +624 -103
  39. package/src/agent/web-search.ts +538 -0
  40. package/src/ai/AGENTS.md +44 -0
  41. package/src/ai/index.ts +1 -0
  42. package/src/ai/model-catalog-compat.ts +3 -1
  43. package/src/ai/model-catalog.ts +74 -9
  44. package/src/ai/model-discovery.ts +215 -17
  45. package/src/ai/model-manager.ts +346 -32
  46. package/src/ai/model-picker.ts +1 -1
  47. package/src/ai/model-registry.ts +4 -2
  48. package/src/ai/pricing.ts +84 -0
  49. package/src/ai/provider-registry.ts +23 -0
  50. package/src/ai/provider-status.ts +60 -16
  51. package/src/ai/providers/AGENTS.md +42 -0
  52. package/src/ai/providers/anthropic.ts +250 -31
  53. package/src/ai/providers/antigravity.ts +219 -0
  54. package/src/ai/providers/errors.ts +15 -1
  55. package/src/ai/providers/gemini.ts +196 -13
  56. package/src/ai/providers/ollama.ts +37 -7
  57. package/src/ai/providers/openai-responses.ts +173 -0
  58. package/src/ai/providers/openai.ts +64 -12
  59. package/src/ai/sse.ts +4 -1
  60. package/src/ai/types.ts +18 -1
  61. package/src/auth/AGENTS.md +41 -0
  62. package/src/auth/callback-server.ts +6 -1
  63. package/src/auth/flows/AGENTS.md +32 -0
  64. package/src/auth/flows/antigravity.ts +151 -0
  65. package/src/auth/flows/google-project.ts +190 -0
  66. package/src/auth/flows/google.ts +39 -18
  67. package/src/auth/flows/index.ts +15 -5
  68. package/src/auth/flows/openai.ts +2 -2
  69. package/src/auth/oauth.ts +8 -0
  70. package/src/auth/refresh.ts +44 -27
  71. package/src/auth/storage.ts +149 -26
  72. package/src/auth/types.ts +1 -1
  73. package/src/autopilot.ts +362 -0
  74. package/src/bun-imports.d.ts +4 -0
  75. package/src/cli/AGENTS.md +39 -0
  76. package/src/cli/runner.ts +148 -14
  77. package/src/cli.ts +13 -4
  78. package/src/commands/AGENTS.md +40 -0
  79. package/src/commands/approve.ts +62 -3
  80. package/src/commands/auth.ts +167 -25
  81. package/src/commands/chat.ts +37 -8
  82. package/src/commands/deep-interview.ts +633 -175
  83. package/src/commands/doctor.ts +84 -37
  84. package/src/commands/evolve-core.ts +18 -0
  85. package/src/commands/evolve.ts +2 -1
  86. package/src/commands/export.ts +176 -0
  87. package/src/commands/gjc.ts +52 -0
  88. package/src/commands/launch.ts +3549 -240
  89. package/src/commands/mcp.ts +3 -3
  90. package/src/commands/ooo-seed.ts +19 -0
  91. package/src/commands/ralplan.ts +253 -35
  92. package/src/commands/resume.ts +1 -1
  93. package/src/commands/session.ts +183 -0
  94. package/src/commands/setup-helpers.ts +10 -3
  95. package/src/commands/setup.ts +57 -16
  96. package/src/commands/skills.ts +78 -18
  97. package/src/commands/state.ts +198 -0
  98. package/src/commands/status.ts +84 -0
  99. package/src/commands/team.ts +340 -212
  100. package/src/commands/ultragoal.ts +122 -61
  101. package/src/commands/update.ts +244 -0
  102. package/src/ledger.ts +270 -0
  103. package/src/mcp/AGENTS.md +38 -0
  104. package/src/mcp/server.ts +115 -14
  105. package/src/mcp/tools.ts +42 -22
  106. package/src/md-modules.d.ts +4 -0
  107. package/src/prompts/AGENTS.md +41 -0
  108. package/src/prompts/agents/AGENTS.md +35 -0
  109. package/src/prompts/agents/architect.md +35 -0
  110. package/src/prompts/agents/critic.md +37 -0
  111. package/src/prompts/agents/executor.md +36 -0
  112. package/src/prompts/agents/planner.md +37 -0
  113. package/src/prompts/skills/AGENTS.md +36 -0
  114. package/src/prompts/skills/deep-dive/AGENTS.md +31 -0
  115. package/src/prompts/skills/deep-dive/SKILL.md +13 -0
  116. package/src/prompts/skills/deep-interview/AGENTS.md +31 -0
  117. package/src/prompts/skills/deep-interview/SKILL.md +12 -0
  118. package/src/prompts/skills/gjc/AGENTS.md +31 -0
  119. package/src/prompts/skills/gjc/SKILL.md +15 -0
  120. package/src/prompts/skills/ralplan/AGENTS.md +31 -0
  121. package/src/prompts/skills/ralplan/SKILL.md +11 -0
  122. package/src/prompts/skills/team/AGENTS.md +31 -0
  123. package/src/prompts/skills/team/SKILL.md +11 -0
  124. package/src/prompts/skills/ultragoal/AGENTS.md +31 -0
  125. package/src/prompts/skills/ultragoal/SKILL.md +11 -0
  126. package/src/skills/AGENTS.md +38 -0
  127. package/src/skills/catalog.ts +565 -31
  128. package/src/tui/AGENTS.md +43 -0
  129. package/src/tui/app.ts +1181 -92
  130. package/src/tui/components/AGENTS.md +42 -0
  131. package/src/tui/components/ascii-art.ts +257 -15
  132. package/src/tui/components/autocomplete.ts +98 -16
  133. package/src/tui/components/autopilot-status.ts +65 -0
  134. package/src/tui/components/category-index.ts +49 -0
  135. package/src/tui/components/code-view.ts +54 -11
  136. package/src/tui/components/color.ts +171 -2
  137. package/src/tui/components/config-panel.ts +82 -15
  138. package/src/tui/components/duration.ts +38 -0
  139. package/src/tui/components/evolution.ts +3 -3
  140. package/src/tui/components/footer.ts +91 -42
  141. package/src/tui/components/forge.ts +426 -31
  142. package/src/tui/components/hints.ts +54 -0
  143. package/src/tui/components/hud.ts +73 -0
  144. package/src/tui/components/index.ts +4 -0
  145. package/src/tui/components/input-box.ts +150 -0
  146. package/src/tui/components/layout.ts +11 -3
  147. package/src/tui/components/live-model-picker.ts +108 -0
  148. package/src/tui/components/markdown-table.ts +140 -0
  149. package/src/tui/components/markdown-text.ts +97 -0
  150. package/src/tui/components/meter.ts +4 -1
  151. package/src/tui/components/model-picker.ts +3 -2
  152. package/src/tui/components/provider-picker.ts +3 -2
  153. package/src/tui/components/section.ts +70 -0
  154. package/src/tui/components/select-list.ts +40 -10
  155. package/src/tui/components/skill-picker.ts +25 -0
  156. package/src/tui/components/slash.ts +244 -21
  157. package/src/tui/components/status.ts +272 -11
  158. package/src/tui/components/step-timeline.ts +218 -0
  159. package/src/tui/components/stream.ts +26 -9
  160. package/src/tui/components/themes.ts +212 -6
  161. package/src/tui/components/todo-card.ts +47 -0
  162. package/src/tui/components/tool-list.ts +58 -12
  163. package/src/tui/components/transcript.ts +120 -0
  164. package/src/tui/components/update-box.ts +31 -0
  165. package/src/tui/components/welcome.ts +162 -0
  166. package/src/tui/components/width.ts +163 -0
  167. package/src/tui/monitoring/AGENTS.md +31 -0
  168. package/src/tui/monitoring/hud-view.ts +55 -0
  169. package/src/tui/renderer.ts +112 -3
  170. package/src/tui/terminal.ts +40 -33
  171. package/src/util/AGENTS.md +39 -0
  172. package/src/util/clipboard-image.ts +118 -0
  173. package/src/util/env.ts +12 -0
  174. package/src/util/provider-error.ts +78 -0
  175. package/src/util/retry.ts +91 -6
  176. package/src/util/update-check.ts +64 -0
  177. 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 { readWorkflowState } from "../agent/state";
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 { getStageByIndex, renderAsciiArt, stageHeight, stageWidth } from "./components/ascii-art";
20
- import { evolutionTrack, createStageProgress, type StageProgress, getEvolutionStatusMessage, transitionMessage } from "./components/evolution";
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, 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";
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
- onError?(message: string): void;
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 = 25;
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 (JOC_TUI_THEME), default cosmic; `mono` disables color.
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.renderer = new Renderer(this.write);
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
- showEta: true,
84
- showProgress: true,
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.pendingIndex = this.tools.start(invocation.tool);
105
- this.rememberForge(summarizeForgeInvocation(invocation.tool, invocation.arguments));
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
- 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`);
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
- onError: msg => {
120
- const marker = this.unicode ? "✗" : "x";
121
- this.stream.append(`${marker} error: ${msg}\n`);
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
- readWorkflowState("deep-interview")
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
- this.tickCount++;
142
- this.spinner.next();
143
- this.draw();
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.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);
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
- 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"));
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
- 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;
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, { width: boxWidth, maxLines: 8, unicode: this.unicode, paint }));
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 = isTTY(); // fill terminal width+height only on a real TTY
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 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.
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.stream.append(`${arrow} ${transitionMessage(idx)}\n`);
1158
+ this.appendLedger(`${arrow} ${transitionMessage(idx)}\n`, "notice");
198
1159
  }
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(),
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
- firing: isThinking,
207
- frame: isThinking ? this.tickCount : 0,
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 = getEvolutionStatusMessage(stepNow, this.footer.maxSteps ?? DEFAULT_MAX_STEPS, this.tickCount);
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 renderJocStatus({
229
- step: stepNow,
230
- maxSteps: this.footer.maxSteps,
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
- message: statusMsg,
233
- currentTool: this.tools.currentTool(),
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 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}`);
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
- bottom.push(`${this.spinner.current()} ${renderFooter({ ...this.footer, elapsedMs })}`);
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(2, rows - fixedHeight) : undefined;
256
- const streamLines = this.stream.render(innerWidth, maxStreamLines);
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
- 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");
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
- 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);
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 innerLines) boxedContent.push(line);
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 bottom) boxedContent.push(line);
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 ? chalk.blue : (s: string) => s;
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
  }