jeo-code 0.4.5 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ja.md CHANGED
@@ -150,11 +150,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
150
150
  ## 変更履歴 (Changelog)
151
151
 
152
152
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
153
+ - **[0.4.6]** (2026-06-14) — Width-correct forge cards for CJK/emoji, red borders on failed tool cards, aligned `ooo ralph` monitor HUD, and a per-theme user-card palette.
153
154
  - **[0.4.5]** (2026-06-14) — First-class filesystem make/remove tools.
154
155
  - **[0.4.4]** (2026-06-13) — Live subagent status mirroring, always-useful Ctrl+O activity tail, read lineRange crash guard.
155
156
  - **[0.4.3]** (2026-06-13) — Readability pass for autopilot, subagent activity, and worked-history review.
156
157
  - **[0.4.2]** (2026-06-13) — Thinking-loop termination guarantees (cycle guard + turn wall-clock budget), unboxed live status without step counters, self-contained `.jeo` namespace, live next-prompt input card, role-targeted model/thinking picker.
157
- - **[0.4.1]** (2026-06-12) — TUI card parity polish + done-time todo reconciliation.
158
158
 
159
159
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
160
160
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -150,11 +150,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
150
150
  ## 변경 이력 (Changelog)
151
151
 
152
152
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
153
+ - **[0.4.6]** (2026-06-14) — Width-correct forge cards for CJK/emoji, red borders on failed tool cards, aligned `ooo ralph` monitor HUD, and a per-theme user-card palette.
153
154
  - **[0.4.5]** (2026-06-14) — First-class filesystem make/remove tools.
154
155
  - **[0.4.4]** (2026-06-13) — Live subagent status mirroring, always-useful Ctrl+O activity tail, read lineRange crash guard.
155
156
  - **[0.4.3]** (2026-06-13) — Readability pass for autopilot, subagent activity, and worked-history review.
156
157
  - **[0.4.2]** (2026-06-13) — Thinking-loop termination guarantees (cycle guard + turn wall-clock budget), unboxed live status without step counters, self-contained `.jeo` namespace, live next-prompt input card, role-targeted model/thinking picker.
157
- - **[0.4.1]** (2026-06-12) — TUI card parity polish + done-time todo reconciliation.
158
158
 
159
159
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
160
160
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -150,11 +150,11 @@ Required npm token permissions (repository secret `NPM_TOKEN`):
150
150
  ## Changelog
151
151
 
152
152
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
153
+ - **[0.4.6]** (2026-06-14) — Width-correct forge cards for CJK/emoji, red borders on failed tool cards, aligned `ooo ralph` monitor HUD, and a per-theme user-card palette.
153
154
  - **[0.4.5]** (2026-06-14) — First-class filesystem make/remove tools.
154
155
  - **[0.4.4]** (2026-06-13) — Live subagent status mirroring, always-useful Ctrl+O activity tail, read lineRange crash guard.
155
156
  - **[0.4.3]** (2026-06-13) — Readability pass for autopilot, subagent activity, and worked-history review.
156
157
  - **[0.4.2]** (2026-06-13) — Thinking-loop termination guarantees (cycle guard + turn wall-clock budget), unboxed live status without step counters, self-contained `.jeo` namespace, live next-prompt input card, role-targeted model/thinking picker.
157
- - **[0.4.1]** (2026-06-12) — TUI card parity polish + done-time todo reconciliation.
158
158
 
159
159
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
160
160
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -150,11 +150,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
150
150
  ## 更新日志 (Changelog)
151
151
 
152
152
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
153
+ - **[0.4.6]** (2026-06-14) — Width-correct forge cards for CJK/emoji, red borders on failed tool cards, aligned `ooo ralph` monitor HUD, and a per-theme user-card palette.
153
154
  - **[0.4.5]** (2026-06-14) — First-class filesystem make/remove tools.
154
155
  - **[0.4.4]** (2026-06-13) — Live subagent status mirroring, always-useful Ctrl+O activity tail, read lineRange crash guard.
155
156
  - **[0.4.3]** (2026-06-13) — Readability pass for autopilot, subagent activity, and worked-history review.
156
157
  - **[0.4.2]** (2026-06-13) — Thinking-loop termination guarantees (cycle guard + turn wall-clock budget), unboxed live status without step counters, self-contained `.jeo` namespace, live next-prompt input card, role-targeted model/thinking picker.
157
- - **[0.4.1]** (2026-06-12) — TUI card parity polish + done-time todo reconciliation.
158
158
 
159
159
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
160
160
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -149,6 +149,10 @@ export interface AgentLoopEvents {
149
149
  * first"); return null to let the turn finish. The engine guarantees at most
150
150
  * one bounce per turn, so a stubborn model can never loop here. */
151
151
  onBeforeDone?(reason: string): string | null;
152
+ /** Fired when a mid-turn steering message (an additional user query typed while
153
+ * the turn is running) is injected into the live history. `text` is the raw
154
+ * user line — drives a TUI notice so the user sees their input was picked up. */
155
+ onSteer?(text: string): void;
152
156
  }
153
157
 
154
158
  export interface AgentLoopOptions {
@@ -173,6 +177,11 @@ export interface AgentLoopOptions {
173
177
  /** Step-budget overrides (gjc-style retry flow). `{ maxExtensions: 0 }` restores the
174
178
  * legacy fixed counter — used by bounded subagent delegation. */
175
179
  budget?: Partial<StepBudgetConfig>;
180
+ /** Mid-turn steering drain (gjc parity): called at each step boundary. Any strings
181
+ * returned are appended to `history` as user messages BEFORE the next model call,
182
+ * so an additional query typed while the turn runs steers the live turn instead of
183
+ * waiting for the next prompt. Return [] when nothing is pending. */
184
+ steer?: () => string[];
176
185
  }
177
186
 
178
187
  export interface AgentLoopResult {
@@ -400,6 +409,29 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
400
409
  }
401
410
  await ev.onStep?.(step);
402
411
 
412
+ // MID-TURN steering (gjc parity): drain any additional user queries typed while
413
+ // the turn is running and inject them as user messages BEFORE this step's model
414
+ // call, so the live turn adapts immediately instead of deferring to the next
415
+ // prompt. A genuine new instruction resets the stall/failure guards (it is fresh
416
+ // progress, not a repeat) and earns a budget extension so the loop has room to act.
417
+ if (opts.steer) {
418
+ const pending = opts.steer();
419
+ for (const raw of pending) {
420
+ const text = (raw ?? "").trim();
421
+ if (!text) continue;
422
+ history.push({
423
+ role: "user",
424
+ content: `[mid-turn steering — additional instruction from the user; incorporate it now]\n${text}`,
425
+ });
426
+ ev.onSteer?.(text);
427
+ repeatCount = 0;
428
+ lastSig = "";
429
+ consecutiveFailures = 0;
430
+ recentStepSigs.length = 0;
431
+ budget.noteSteer?.();
432
+ }
433
+ }
434
+
403
435
  // MID-TURN context guard: a single long turn (60+ steps) otherwise grows the
404
436
  // history without bound — turn-boundary compaction never runs inside a turn,
405
437
  // and field evidence shows multi-million-token prompts degrading the model
@@ -623,6 +655,29 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
623
655
  continue;
624
656
  }
625
657
  }
658
+ // Steering that arrived DURING this final step (after the top-of-loop drain,
659
+ // while the model was generating its `done`): reopen the turn and handle it now
660
+ // instead of letting it bounce to the next prompt. Bounded by the step/time budget.
661
+ if (opts.steer) {
662
+ const pending = opts.steer().map(s => (s ?? "").trim()).filter(Boolean);
663
+ if (pending.length) {
664
+ history.push({ role: "assistant", content: responseText });
665
+ for (const text of pending) {
666
+ history.push({
667
+ role: "user",
668
+ content: `[mid-turn steering — additional instruction from the user; incorporate it now before finishing]\n${text}`,
669
+ });
670
+ ev.onSteer?.(text);
671
+ }
672
+ repeatCount = 0;
673
+ lastSig = "";
674
+ consecutiveFailures = 0;
675
+ recentStepSigs.length = 0;
676
+ budget.noteSteer();
677
+ step++;
678
+ continue;
679
+ }
680
+ }
626
681
  return finish({ done: true, steps: step, doneReason: (toolCalls[0].arguments?.reason as string) ?? "" });
627
682
  }
628
683
 
@@ -180,6 +180,16 @@ export class StepBudget {
180
180
  if (this.window.length > this.cfg.windowSize) this.window.shift();
181
181
  }
182
182
 
183
+ /** A mid-turn steering message arrived — fresh, user-driven work. Grant headroom
184
+ * (capped at the hard cap, without consuming the extension budget) and clear the
185
+ * scoring window so the new instruction is never declined by the previous
186
+ * sub-task's stall/failure signals. */
187
+ noteSteer(): void {
188
+ this.window.length = 0;
189
+ this.novelSinceExtension = 0;
190
+ this.currentLimit = Math.min(this.currentLimit + this.cfg.extensionSteps, this.cfg.hardCap);
191
+ }
192
+
183
193
  /** Progress over the recent window: ok count, total, distinct signatures. */
184
194
  progress(): { ok: number; total: number; distinct: number } {
185
195
  const ok = this.window.filter(r => r.success).length;
@@ -51,6 +51,13 @@ export interface TaskToolOptions {
51
51
  signal?: AbortSignal;
52
52
  /** Optional live sink (e.g. plain-stream rendering of nested progress). */
53
53
  onEvent?: (ev: TaskSubEvent) => void;
54
+ /** Mid-turn steering drain (gjc parity). Forwarded to a SINGLE running subagent so
55
+ * an additional user query typed while the subagent works reaches it live. While
56
+ * the subagent runs the parent loop is blocked inside this tool call, so the
57
+ * subagent is the only active drainer — the message is not double-consumed.
58
+ * Fan-out batches do NOT forward it (parallel drains would deliver to one arbitrary
59
+ * subagent); pending steering stays for the parent after the batch returns. */
60
+ steer?: () => string[];
54
61
  }
55
62
 
56
63
  /** Max concurrent read-only subagents in a fan-out batch. */
@@ -134,6 +141,7 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
134
141
  taskText: string,
135
142
  context: string,
136
143
  cwd: string,
144
+ steer?: () => string[],
137
145
  ): Promise<ToolResult> => {
138
146
  const model = resolveSubagentModel(role.id, opts.config);
139
147
  const maxSteps = resolveSubagentMaxSteps(role.id, opts.config);
@@ -162,6 +170,7 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
162
170
  // owns any retry/extension decision, so the gjc retry flow is disabled here.
163
171
  budget: { maxExtensions: 0 },
164
172
  signal: opts.signal,
173
+ steer,
165
174
  tools: subagentToolset(role),
166
175
  events: {
167
176
  onStep: n => { currentStep = n; },
@@ -184,6 +193,9 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
184
193
  // Retry notices (rate-limit backoff etc.) surface as live "step" beats so the
185
194
  // parent's monitor shows WHY a subagent is pausing instead of going silent.
186
195
  onNotice: msg => opts.onEvent?.({ role: role.id, kind: "step", detail: msg, step: currentStep, maxSteps, model }),
196
+ // Mid-turn steering reached this subagent: surface it as a live beat so the
197
+ // parent's monitor shows the redirect instead of an unexplained behavior change.
198
+ onSteer: text => opts.onEvent?.({ role: role.id, kind: "step", detail: `↳ steer: ${text}`, step: currentStep, maxSteps, model }),
187
199
  },
188
200
  });
189
201
  const reason = result.doneReason?.trim() || `(subagent reached the ${result.steps}-step limit without signaling done)`;
@@ -267,6 +279,6 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
267
279
  if (!taskText) {
268
280
  return { success: false, output: "", error: `task tool requires a non-empty 'task' (or a 'tasks' array). Valid roles: ${subagentRoleIds(opts.config).join(", ")}.` };
269
281
  }
270
- return runOne(role, taskText, ctx(args.context), cwd);
282
+ return runOne(role, taskText, ctx(args.context), cwd, opts.steer);
271
283
  };
272
284
  }
@@ -96,9 +96,13 @@ export function thinkingToReasoningEffort(
96
96
  return "medium";
97
97
  }
98
98
 
99
- /** Describe a model id: alias expansion + the provider it routes to. For `/model` + diagnostics. */
100
- export async function describeModel(input: string): Promise<{ input: string; resolved: string; provider: ProviderName }> {
101
- const resolved = await resolveModelId(input);
99
+ /** Describe a model id: alias expansion + the provider it routes to. For `/model` + diagnostics.
100
+ * Pass an already-read `config` to skip a redundant readGlobalConfig() on the turn hot path. */
101
+ export async function describeModel(
102
+ input: string,
103
+ config?: { modelAliases?: Record<string, string> },
104
+ ): Promise<{ input: string; resolved: string; provider: ProviderName }> {
105
+ const resolved = await resolveModelId(input, config);
102
106
  return { input, resolved, provider: resolveProvider(resolved) };
103
107
  }
104
108
 
@@ -25,9 +25,14 @@ export function expandAlias(input: string, aliases: ModelAliases = BUILTIN_ALIAS
25
25
  }
26
26
 
27
27
  // Async: merge BUILTIN_ALIASES with config.modelAliases (config wins) and expand.
28
- export async function resolveModelId(input: string): Promise<string> {
29
- const config = await readGlobalConfig();
30
- const modelAliases = (config as any).modelAliases ?? {};
28
+ // Pass an already-read `config` to skip the readGlobalConfig() round-trip (turn
29
+ // hot path: avoids re-reading the config file mid-turn for model resolution).
30
+ export async function resolveModelId(
31
+ input: string,
32
+ config?: { modelAliases?: ModelAliases },
33
+ ): Promise<string> {
34
+ const cfg = config ?? (await readGlobalConfig());
35
+ const modelAliases = (cfg as any).modelAliases ?? {};
31
36
  const merged: ModelAliases = { ...BUILTIN_ALIASES, ...modelAliases };
32
37
  return expandAlias(input, merged);
33
38
  }
@@ -1297,6 +1297,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1297
1297
  // box during a running turn so typed text stays in the same query surface
1298
1298
  // instead of a separate queued row.
1299
1299
  let queueBusySnapshot: (() => { text: string }) | undefined;
1300
+ // Clears the live next-prompt draft — used after a mid-turn Enter is lifted into
1301
+ // the steering inbox so the consumed line does not also become the next prompt.
1302
+ let queueBusyClear: (() => void) | undefined;
1300
1303
  let interactiveTurnActive = false;
1301
1304
 
1302
1305
 
@@ -1311,42 +1314,56 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1311
1314
  const activeModel = sessionModel || turnConfig.defaultModel;
1312
1315
  const contextTokens = catalogMetadata(activeModel)?.contextTokens;
1313
1316
 
1314
- const compRes = await maybeCompact(history, {
1315
- model: sessionModel,
1316
- contextTokens,
1317
- });
1318
-
1319
- if (compRes.error) {
1320
- throw new Error(compRes.error);
1321
- }
1322
-
1323
- if (compRes.compacted && sessionId && compRes.replacesThrough !== undefined) {
1324
- const touchedNote = compRes.touchedFiles?.length ? ` Files touched: ${compRes.touchedFiles.join(", ")}.` : "";
1325
- const summaryText = compRes.summary ?? `[Earlier conversation omitted: ${compRes.removed} messages — summary unavailable.${touchedNote}]`;
1326
- await appendCompaction(sessionId, ++compactionSeq, summaryText, compRes.replacesThrough, cwd);
1327
- }
1328
-
1329
- const beforeLen = history.length;
1330
- if (images?.length && catalogMetadata(activeModel)?.images === false) {
1331
- console.log(`! ${activeModel} does not advertise image input — sending the attachment anyway.`);
1332
- }
1333
- history.push(images?.length ? { role: "user", content: userInput, images } : { role: "user", content: userInput });
1334
-
1335
- // `turnConfig` was read before compaction so both the compactor and delegated
1336
- // task tool see mid-session config changes (e.g. `/agents <role> <model>`).
1337
- const { provider: activeProvider } = await describeModel(activeModel);
1338
- // Dirty count is recomputed at each turn start (gjc parity P1.B5: per-turn, not
1339
- // per-render) so `?N` grows as the agent edits files; one spawn/turn, not per frame.
1317
+ // Resolve provider + dirty count up front — both are cheap and feed the live
1318
+ // frame's footer. `turnConfig` is reused so describeModel does NOT re-read the
1319
+ // config file. (gjc parity P1.B5: dirty count per-turn, not per-render.)
1320
+ const { provider: activeProvider } = await describeModel(activeModel, turnConfig);
1340
1321
  const turnDirtyCount = branch ? gitDirtyCount(cwd) : undefined;
1341
1322
  const tui = useTui ? new LaunchTui({ model: activeModel, provider: activeProvider, sessionId, maxSteps: initialStepLimit, cwd, branch, dirtyCount: turnDirtyCount, thinking: sessionThinking }) : null;
1342
- tui?.setContextUsage(historyTokens(history), contextTokens);
1343
1323
  tui?.setTurnTitle(userInput); // gjc-parity turn title → HUD + tmux pane title (no LLM call)
1324
+ // `beforeLen` marks where this turn's appended messages start; it is re-read
1325
+ // AFTER compaction (which mutates history) and consumed by the post-turn
1326
+ // persistence block below.
1327
+ let beforeLen = history.length;
1344
1328
  let result;
1345
1329
  try {
1330
+ // Paint the live frame + spinner the INSTANT the turn is accepted, BEFORE the
1331
+ // potentially slow / LLM-driven compaction below. Otherwise the gap between the
1332
+ // submitted prompt and the first feedback reads as a dead "no response" window
1333
+ // (the reported symptom). All remaining preflight runs UNDER the spinner now.
1346
1334
  if (tui) {
1347
1335
  interactiveTurnActive = true;
1348
1336
  tui.start();
1349
1337
  }
1338
+ const compRes = await maybeCompact(history, {
1339
+ model: sessionModel,
1340
+ contextTokens,
1341
+ });
1342
+ if (compRes.error) {
1343
+ throw new Error(compRes.error);
1344
+ }
1345
+ if (compRes.compacted && sessionId && compRes.replacesThrough !== undefined) {
1346
+ const touchedNote = compRes.touchedFiles?.length ? ` Files touched: ${compRes.touchedFiles.join(", ")}.` : "";
1347
+ const summaryText = compRes.summary ?? `[Earlier conversation omitted: ${compRes.removed} messages — summary unavailable.${touchedNote}]`;
1348
+ await appendCompaction(sessionId, ++compactionSeq, summaryText, compRes.replacesThrough, cwd);
1349
+ tui?.events().onNotice?.(`(compacted ${compRes.removed} older message${compRes.removed === 1 ? "" : "s"})`);
1350
+ }
1351
+ beforeLen = history.length;
1352
+ if (images?.length && catalogMetadata(activeModel)?.images === false) {
1353
+ const warn = `! ${activeModel} does not advertise image input — sending the attachment anyway.`;
1354
+ if (tui) tui.events().onNotice?.(warn);
1355
+ else console.log(warn);
1356
+ }
1357
+ history.push(images?.length ? { role: "user", content: userInput, images } : { role: "user", content: userInput });
1358
+ tui?.setContextUsage(historyTokens(history), contextTokens);
1359
+
1360
+ // Per-turn steering inbox (gjc parity): additional queries typed mid-turn land
1361
+ // here and the engine drains them at each step boundary; createTaskTool forwards
1362
+ // the same drain so a single running subagent receives them live. Unconsumed
1363
+ // messages are folded into the next prompt in the finally block (race safety).
1364
+ const steerInbox: string[] = [];
1365
+ const steeringEnabled = !!tui && jeoEnv("NO_STEER") !== "1";
1366
+ const drainSteer = () => steerInbox.splice(0, steerInbox.length);
1350
1367
  const harness = createInFlightAbortHarness({
1351
1368
  captureEsc: !!tui,
1352
1369
  onNoise: () => tui?.repaint(),
@@ -1368,10 +1385,31 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1368
1385
  },
1369
1386
  onBufferedInput: chunk => {
1370
1387
  if (!tui) return;
1388
+ // gjc-style mid-turn steering: a typed Enter (outside a bracketed paste)
1389
+ // lifts the current draft into the steering inbox so the RUNNING turn picks
1390
+ // it up at the next step, instead of only becoming the next prompt.
1391
+ // JEO_NO_STEER=1 restores the legacy draft-only behavior.
1392
+ const typedEnter =
1393
+ steeringEnabled &&
1394
+ !(queueBusyPasteActive?.() ?? false) &&
1395
+ /[\r\n]/.test(chunk) &&
1396
+ !chunk.includes(PASTE_START) &&
1397
+ !chunk.includes(PASTE_END);
1371
1398
  const captured = queueBusyInput?.(chunk) ?? false;
1399
+ if (typedEnter) {
1400
+ const line = (queueBusySnapshot?.().text ?? "").trim();
1401
+ if (line) {
1402
+ steerInbox.push(line);
1403
+ queueBusyClear?.();
1404
+ tui.setLivePromptInput("");
1405
+ // Surface the steered query as a `user` card in scrollback so it reads
1406
+ // as an accepted input that started work — not just a transient notice.
1407
+ tui.flushSteerCard(line);
1408
+ return;
1409
+ }
1410
+ }
1372
1411
  // Keep the SAME query input box visible during a live turn. Printable
1373
- // keystrokes edit the next prompt draft; Enter does not create a hidden
1374
- // queue entry, so there is no separate "queued input" surface.
1412
+ // keystrokes edit the next prompt draft; there is no separate queue surface.
1375
1413
  if (captured) tui.setLivePromptInput(queueBusySnapshot?.().text ?? "");
1376
1414
  },
1377
1415
  onAbortNotice: msg => {
@@ -1403,6 +1441,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1403
1441
  task: createTaskTool({
1404
1442
  config: { ...turnConfig, defaultModel: activeModel },
1405
1443
  signal: ac.signal,
1444
+ steer: drainSteer,
1406
1445
  onEvent: useTui
1407
1446
  ? (e => tui?.onSubagentEvent(e))
1408
1447
  : (e => logTaskSubEvent(e)),
@@ -1417,6 +1456,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1417
1456
  model: sessionModel,
1418
1457
  maxTokens: sessionThinking ? thinkingMaxTokens(sessionThinking) : undefined,
1419
1458
  signal: ac.signal,
1459
+ steer: drainSteer,
1420
1460
  events: { ...withToolDetailCapture(tui ? tui.events() : streamEvents), onBeforeDone },
1421
1461
  });
1422
1462
  if (result.done && looksLikeSkillEcho(result.doneReason ?? "", resolvedSkills)) {
@@ -1434,6 +1474,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1434
1474
  model: sessionModel,
1435
1475
  maxTokens: sessionThinking ? thinkingMaxTokens(sessionThinking) : undefined,
1436
1476
  signal: ac.signal,
1477
+ steer: drainSteer,
1437
1478
  events: withToolDetailCapture(tui ? tui.events() : streamEvents),
1438
1479
  });
1439
1480
  const usage =
@@ -1447,6 +1488,13 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1447
1488
  }
1448
1489
  } finally {
1449
1490
  harness.dispose();
1491
+ // Steering typed but never drained (e.g. entered just after the final step)
1492
+ // must not be lost — fold it into the next prompt draft so it runs next.
1493
+ const leftover = steerInbox.splice(0, steerInbox.length).map(s => s.trim()).filter(Boolean);
1494
+ if (leftover.length) {
1495
+ const merged = [queuedPromptInput.partial, ...leftover].filter(Boolean).join(" ");
1496
+ queuedPromptInput.partial = merged;
1497
+ }
1450
1498
  }
1451
1499
  } catch (err) {
1452
1500
  if (tui) {
@@ -1800,6 +1848,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1800
1848
  };
1801
1849
  };
1802
1850
  let previewArmed = false;
1851
+ // True while a prompt line is being awaited. Folded into the readline output
1852
+ // gate so native echo is suppressed for the WHOLE await window — including the
1853
+ // brief gap between turn-end and armPreview() — so no keystroke can leak into
1854
+ // scrollback before the boxed footer takes over.
1855
+ let promptActive = false;
1803
1856
  let pickerActive = false;
1804
1857
  const rl = createInterface({
1805
1858
  input: process.stdin,
@@ -1810,7 +1863,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1810
1863
  // previously OPENED the gate and let readline echo typed filter characters
1811
1864
  // (CJK wide chars especially) straight onto the picker frame — the
1812
1865
  // "stacked input-box borders" corruption.
1813
- output: gatedStdout(process.stdout, () => previewArmed || pickerActive || interactiveTurnActive),
1866
+ output: gatedStdout(process.stdout, () => previewArmed || promptActive || pickerActive || interactiveTurnActive),
1814
1867
  completer: (line: string) => readlineCompleter(line, completionContext()),
1815
1868
  });
1816
1869
  const promptStdin = process.stdin as typeof process.stdin & { isRaw?: boolean; setRawMode?(raw: boolean): void };
@@ -1839,6 +1892,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1839
1892
  queueBusySnapshot = () => ({
1840
1893
  text: queuedPromptInput.partial,
1841
1894
  });
1895
+ queueBusyClear = () => { queuedPromptInput.partial = ""; };
1842
1896
  // Bracketed-paste line routing at the PROMPT: readline strips the 2004 markers
1843
1897
  // and replays pasted lines as synthetic keypresses, emitting paste-start /
1844
1898
  // paste-end around them. Lines submitted INSIDE that window are intentional
@@ -1897,6 +1951,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1897
1951
  if (hardExitOnLoopEnd || process.stdin.isTTY || process.stdout.isTTY) forceExitFromCtrlC();
1898
1952
  return "/exit";
1899
1953
  }
1954
+ promptActive = true;
1900
1955
  try {
1901
1956
  return await Promise.race([
1902
1957
  rl.question(prompt),
@@ -1908,6 +1963,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1908
1963
  }),
1909
1964
  ]);
1910
1965
  } finally {
1966
+ promptActive = false;
1911
1967
  notifyStdinClosed = undefined;
1912
1968
  }
1913
1969
  };
@@ -1955,10 +2011,16 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1955
2011
  const MAX_PREVIEW_ROWS = 12;
1956
2012
  const MIN_PREVIEW_ROWS = 7; // status bar (1) + spacer (1) + input box (3 rows) + 2 preview rows
1957
2013
  const previewRowsFor = (rows: number): number => Math.max(MIN_PREVIEW_ROWS, Math.min(MAX_PREVIEW_ROWS, rows - 6));
2014
+ // Enable the boxed input footer for ANY interactive TTY. It paints inside a
2015
+ // reserved bottom region (never scrollback) and only commits the line on
2016
+ // Enter, so typed characters never leak into the conversation history while
2017
+ // typing. previewRowsFor() clamps the reservation height for short terminals,
2018
+ // so even small panes get the box instead of the raw `jeo>` echo fallback
2019
+ // (which echoes every keystroke straight into scrollback). The raw fallback is
2020
+ // now reserved for non-TTY/piped input, where live history echo is moot.
1958
2021
  const previewEnabled =
1959
2022
  process.stdin.isTTY &&
1960
- jeoEnv("NO_SLASH_PREVIEW") !== "1" &&
1961
- (process.stdout.rows ?? 24) >= MIN_PREVIEW_ROWS + 6; // box + ≥6 scrollable content rows
2023
+ jeoEnv("NO_SLASH_PREVIEW") !== "1";
1962
2024
  // Footer height reserved by the CURRENTLY armed region; disarm/draw must use the
1963
2025
  // same value the arm computed, even if the terminal was resized in between.
1964
2026
  let footerRows = MAX_PREVIEW_ROWS;
@@ -2112,7 +2174,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2112
2174
  };
2113
2175
  const historyPreviewLines = (detail: string[]): string[] => {
2114
2176
  const cols = Math.max(24, (process.stdout.columns ?? 80) - 1);
2115
- const title = `${chalk.cyan.bold("history")} ${chalk.gray("· Ctrl+O closes")}`;
2177
+ const title = `${uiAccent("history")} ${chalk.dim("· Ctrl+O closes")}`;
2116
2178
  const budget = Math.max(0, footerRows - 2);
2117
2179
  const physical = detail.flatMap(line => line.split("\n")).map(line => truncateAnsi(line, cols));
2118
2180
  let body = physical;
@@ -2833,29 +2895,34 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2833
2895
  console.log(chalk.dim(`(restored ${folded} queued input line${folded > 1 ? "s" : ""} into the prompt — Enter to run, Esc to discard)`));
2834
2896
  }
2835
2897
  }
2836
- // Refresh the status bar's dirty flag once per prompt (one git spawn, not per frame).
2837
- idleDirtyCount = branch ? gitDirtyCount(cwd) : undefined;
2838
2898
  const prefilledLine = queuedPromptInput.partial;
2839
2899
  queuedPromptInput.partial = "";
2840
- armPreview();
2841
- // Render the boxed input immediately so the prompt is visible even though
2842
- // readline's own echo is suppressed. If the user typed while the previous
2843
- // live turn/subagent was still running, seed that text into readline and the
2844
- // box instead of dropping it as "noise". A pasted batch's TRAILING partial
2845
- // (no final newline) survives in readline's own buffer — adopt it as the
2846
- // visible typed line so the box never hides editable input.
2900
+ // Resolve the visible input text FIRST (cheap, no I/O): the queued prefill, else
2901
+ // any partial the user typed while the previous live turn/subagent was running
2902
+ // (that text survives in readline's own buffer adopt it so the box never hides
2903
+ // editable input). A pasted batch's TRAILING partial is recovered the same way.
2847
2904
  const rli = rl as unknown as { line?: string; cursor?: number; _refreshLine?: () => void };
2848
2905
  const residualPartial = !prefilledLine && typeof rli.line === "string" && rli.line.length > 0 && !/\x1b/.test(rli.line)
2849
2906
  ? rli.line
2850
2907
  : "";
2851
2908
  typedLine = prefilledLine || residualPartial;
2909
+ navMatches = [];
2910
+ navIdx = -1;
2911
+ // Reserve + paint the boxed input IMMEDIATELY — WITH its real text — so the
2912
+ // prompt is visible the instant the turn ends, BEFORE the git spawn below.
2913
+ // Otherwise the dirty-flag spawn (slow on a large / just-edited repo) runs in a
2914
+ // window where the box is gone and keystrokes echo nowhere (the "no response
2915
+ // after the result" gap). readline's own echo stays gated while armed.
2916
+ armPreview();
2852
2917
  if (prefilledLine) {
2853
2918
  rli.line = prefilledLine;
2854
2919
  rli.cursor = prefilledLine.length;
2855
2920
  rli._refreshLine?.();
2856
2921
  }
2857
- navMatches = [];
2858
- navIdx = -1;
2922
+ drawFooter(previewLines(typedLine));
2923
+ // Refresh the status bar's dirty flag once per prompt (one git spawn, not per
2924
+ // frame); the second drawFooter repaints only if the count actually changed.
2925
+ idleDirtyCount = branch ? gitDirtyCount(cwd) : undefined;
2859
2926
  drawFooter(previewLines(typedLine));
2860
2927
  // Box mode: NO raw `jeo>` prompt at all — the boxed footer IS the input UI
2861
2928
  // (gating already suppresses readline echo, the empty prompt guarantees no
package/src/tui/app.ts CHANGED
@@ -417,7 +417,7 @@ export class LaunchTui {
417
417
  // the non-TTY summary both show the merged card.
418
418
  card.title = `${paintedMark} Bash`;
419
419
  card.lines.push(...result.lines);
420
- this.flushForgeCard(card);
420
+ this.flushForgeCard(card, success);
421
421
  } else if (card && t === "web_search" && success && webSearchCardLines(output, { unicode: this.unicode })) {
422
422
  // gjc-style Web Search card: `✓ Web Search: <provider> · N sources` header
423
423
  // over Query / Answer / Sources / Metadata divider sections rebuilt from
@@ -426,12 +426,12 @@ export class LaunchTui {
426
426
  const ws = webSearchCardLines(output, { unicode: this.unicode })!;
427
427
  card.title = `${paintedMark} Web Search: ${ws.titleMeta}`;
428
428
  card.lines = ws.lines;
429
- this.flushForgeCard(card);
429
+ this.flushForgeCard(card, success);
430
430
  } else if (card) {
431
431
  card.title = `${paintedMark} ${card.title}`;
432
432
  if (!success) this.rememberForge(result);
433
- this.flushForgeCard(card);
434
- if (!success) this.flushForgeCard(result);
433
+ this.flushForgeCard(card, success);
434
+ if (!success) this.flushForgeCard(result, false);
435
435
  } else {
436
436
  // Light tool: one ✓/✗ line, plus a dim result tree for list-shaped output
437
437
  // (find/search/ls) and an error card when the tool failed.
@@ -439,7 +439,7 @@ export class LaunchTui {
439
439
  this.appendLedger(`${paintedMark} ${target}${suffix}\n${children.map(c => `${c}\n`).join("")}`, "tool");
440
440
  if (!success) {
441
441
  this.rememberForge(result);
442
- this.flushForgeCard(result);
442
+ this.flushForgeCard(result, false);
443
443
  }
444
444
  }
445
445
  this.draw();
@@ -511,15 +511,22 @@ export class LaunchTui {
511
511
  }
512
512
 
513
513
  private renderLiveUserQueryCard(cols: number): string[] {
514
- const text = this.livePromptInput.trim();
514
+ return this.renderUserCard(this.livePromptInput, cols);
515
+ }
516
+
517
+ /** Render a `user`-labeled query card (orange "user" header over a filled box).
518
+ * Shared by the live next-prompt draft and the mid-turn steering flush. */
519
+ private renderUserCard(rawText: string, cols: number): string[] {
520
+ const text = (rawText ?? "").trim();
515
521
  if (!text) return [];
516
522
  const boxWidth = Math.max(24, Math.min(120, cols));
517
523
  const inner = Math.max(10, boxWidth - 2);
518
524
  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;
525
+ const uc = this.theme.userCard;
526
+ const accent = this.theme.color && uc ? chalk.hex(uc.accent).bold : (s: string) => s;
527
+ const border = this.theme.color && uc ? chalk.hex(uc.border) : (s: string) => s;
528
+ const shadow = this.theme.color && uc ? chalk.hex(uc.shadow) : border;
529
+ const fill = this.theme.color && uc ? (s: string) => chalk.bgHex(uc.fill)(s) : (s: string) => s;
523
530
  const body = text
524
531
  .split("\n")
525
532
  .flatMap(line => wrapTextWithAnsi(line, Math.max(8, inner - 2)))
@@ -537,6 +544,17 @@ export class LaunchTui {
537
544
  return [` ${accent("user")}`, top, ...mid, bottom];
538
545
  }
539
546
 
547
+ /** Flush a `user` card into scrollback for a mid-turn steering query: signals that
548
+ * the additional input was accepted and is now driving the running turn (gjc parity),
549
+ * instead of only a transient status notice. */
550
+ flushSteerCard(text: string): void {
551
+ const t = (text ?? "").trim();
552
+ if (!t || this.finished) return;
553
+ const cols = Math.max(20, size().cols);
554
+ const lines = this.renderUserCard(t, cols);
555
+ if (lines.length) this.appendLedger(lines.join("\n"), "card");
556
+ }
557
+
540
558
  /** Append a completed progress-ledger line. In inline mode the line is flushed
541
559
  * straight into normal scrollback ABOVE the live frame, so tmux / terminal
542
560
  * mouse-wheel can review the full progress history mid-turn (gjc-style); the
@@ -925,15 +943,22 @@ export class LaunchTui {
925
943
  /** Flush a completed forge card into scrollback (inline mode) and retire it from the
926
944
  * live array so the in-frame card region and the final summary never repeat it.
927
945
  * Non-inline modes keep the card in `forgeSummaries` for the final static summary. */
928
- private flushForgeCard(summary: ForgeSummary): void {
946
+ private flushForgeCard(summary: ForgeSummary, success?: boolean): void {
929
947
  if (!this.inline || this.finished) return;
930
948
  const width = Math.max(24, Math.min(120, size().cols));
949
+ // gjc D2 (state-encoded border): a FAILED card gets a red border so it pops
950
+ // out of scrollback at a glance; OK/neutral cards keep the theme accent
951
+ // identity. The ✓/✗ title mark already encodes state, but the border tone
952
+ // is what the eye catches first when scanning back through history.
953
+ const errored = success === false && this.theme.color;
954
+ const paint = errored ? (s: string) => chalk.red(s) : accentPaint(this.theme);
955
+ const paintShadow = errored ? (s: string) => chalk.dim(chalk.red(s)) : accentShadowPaint(this.theme);
931
956
  const lines = formatForgeBox(summary, {
932
957
  width,
933
958
  maxLines: 12,
934
959
  unicode: this.unicode,
935
- paint: accentPaint(this.theme),
936
- paintShadow: accentShadowPaint(this.theme),
960
+ paint,
961
+ paintShadow,
937
962
  diffPaint: diffPaint(this.theme),
938
963
  color: this.theme.color,
939
964
  });
@@ -1,7 +1,7 @@
1
1
  import chalk from "chalk";
2
2
  import { BOX_ASCII, BOX_UNICODE, padLineTo, type BoxGlyphs } from "./layout";
3
- import { stripAnsi, visibleWidth, animatedGradientText } from "./color";
4
- import { truncateToWidth } from "./width";
3
+ import { visibleWidth, animatedGradientText } from "./color";
4
+ import { truncateToWidth, wrapTextWithAnsi } from "./width";
5
5
  import { lightHighlightLine } from "./code-view";
6
6
  import { type UiCategory } from "./category-index";
7
7
 
@@ -421,12 +421,13 @@ export function summarizeForgeResult(tool: string, success: boolean, output: str
421
421
  }
422
422
 
423
423
  function wrapPlainLine(line: string, width: number): string[] {
424
- const plain = stripAnsi(line);
425
424
  if (width <= 0) return [""];
425
+ // Wrap by DISPLAY width (SGR-aware, wide-glyph-aware) so CJK/emoji content
426
+ // breaks on column boundaries and never overflows the card border. The old
427
+ // `slice(i, i+width)` counted code points, so a Hangul/CJK line (2 cols each)
428
+ // rendered ~2× the intended width and tore the right edge.
426
429
  if (visibleWidth(line) <= width) return [line];
427
- const out: string[] = [];
428
- for (let i = 0; i < plain.length; i += width) out.push(plain.slice(i, i + width));
429
- return out;
430
+ return wrapTextWithAnsi(line, width);
430
431
  }
431
432
 
432
433
  function borderGlyphs(unicode: boolean | undefined): BoxGlyphs {
@@ -115,7 +115,7 @@ export function renderInputFrame(line: string, opts: InputBoxOptions = {}): Inpu
115
115
  if (crow - hidden < 0) { visRow = 0; ccol = 0; }
116
116
 
117
117
  const promptMark = "> ";
118
- const paintPrompt = useColor ? (opts.accent ?? chalk.red) : (s: string) => s;
118
+ const paintPrompt = useColor ? (opts.accent ?? chalk.blueBright) : (s: string) => s;
119
119
  const paintGhost = useColor ? chalk.dim : (s: string) => s;
120
120
  const body = rows.map((r, i) => {
121
121
  const content = placeholderRow ? paintGhost(r) : r;
@@ -123,11 +123,16 @@ export function renderInputFrame(line: string, opts: InputBoxOptions = {}): Inpu
123
123
  });
124
124
 
125
125
  const content = [...body];
126
+ // Label rows follow the active theme: the attachment hint uses the accent and
127
+ // the cwd label a dimmed accent (shadow), so the whole box reads in one tone
128
+ // instead of off-theme cyan/gray.
129
+ const labelAccent = useColor ? (opts.accent ?? chalk.cyan) : (s: string) => s;
130
+ const labelMuted = useColor ? (opts.accentShadow ?? chalk.gray) : (s: string) => s;
126
131
  if (opts.attachmentLabel) {
127
- content.push(useColor ? chalk.cyan(opts.attachmentLabel) : opts.attachmentLabel);
132
+ content.push(labelAccent(opts.attachmentLabel));
128
133
  }
129
134
  if (opts.cwdLabel) {
130
- content.push(useColor ? chalk.gray(opts.cwdLabel) : opts.cwdLabel);
135
+ content.push(labelMuted(opts.cwdLabel));
131
136
  }
132
137
  const glyphs = opts.unicode === false ? BOX_ASCII : BOX_UNICODE;
133
138
  // Depth cue: lit top/left edge (bright accent) vs shaded bottom/right edge (dim).
@@ -32,6 +32,8 @@ export interface EvolutionTheme {
32
32
  * `addBg`/`delBg` are full-row background tints that give added/removed
33
33
  * lines block-level separation, not just a colored sign. */
34
34
  diff?: { add: string; del: string; addBg: string; delBg: string; hunk: string };
35
+ /** User query card palette: themed colors for the mid-turn steering user card. */
36
+ userCard?: { accent: string; border: string; shadow: string; fill: string };
35
37
  }
36
38
 
37
39
  /** Default diff palette (used when a theme defines none): high-contrast
@@ -44,13 +46,14 @@ export const DEFAULT_DIFF_PALETTE = {
44
46
  hunk: "#7dcfff",
45
47
  } as const;
46
48
 
47
- const COSMIC: EvolutionTheme = {
49
+ const COSMIC: EvolutionTheme = {
48
50
  name: "cosmic",
49
51
  description: "Default — deep-space arc from cyan tide to white-hot singularity.",
50
52
  gradients: EVOLUTION_STAGE_GRADIENTS,
51
53
  color: true,
52
54
  accent: "#48dbfb",
53
55
  accentShadow: "#1b6f8c",
56
+ userCard: { accent: "#48dbfb", border: "#1b6f8c", shadow: "#0e3c4c", fill: "#081b24" },
54
57
  };
55
58
 
56
59
  const MATRIX: EvolutionTheme = {
@@ -67,6 +70,7 @@ const MATRIX: EvolutionTheme = {
67
70
  accent: "#39ff14",
68
71
  accentShadow: "#0b6623",
69
72
  diff: { add: "#7fff00", del: "#ff5f5f", addBg: "#0c2410", delBg: "#2a1212", hunk: "#00e5a0" },
73
+ userCard: { accent: "#39ff14", border: "#0b6623", shadow: "#053311", fill: "#031a08" },
70
74
  };
71
75
 
72
76
  const SOLAR: EvolutionTheme = {
@@ -82,6 +86,7 @@ const SOLAR: EvolutionTheme = {
82
86
  color: true,
83
87
  accent: "#ff8c00",
84
88
  accentShadow: "#8a4500",
89
+ userCard: { accent: "#ff8c00", border: "#8a4500", shadow: "#452200", fill: "#241100" },
85
90
  };
86
91
 
87
92
  const RED_CLAW: EvolutionTheme = {
@@ -97,6 +102,7 @@ const RED_CLAW: EvolutionTheme = {
97
102
  color: true,
98
103
  accent: "#e25656",
99
104
  accentShadow: "#5c0f0f",
105
+ userCard: { accent: "#e25656", border: "#5c0f0f", shadow: "#2e0707", fill: "#170303" },
100
106
  };
101
107
 
102
108
  const BLUE_CRAB: EvolutionTheme = {
@@ -113,6 +119,7 @@ const BLUE_CRAB: EvolutionTheme = {
113
119
  accent: "#0096c7",
114
120
  accentShadow: "#023e8a",
115
121
  diff: { add: "#06d6a0", del: "#ef476f", addBg: "#0a2922", delBg: "#2b1320", hunk: "#48cae4" },
122
+ userCard: { accent: "#0096c7", border: "#023e8a", shadow: "#011f45", fill: "#000f24" },
116
123
  };
117
124
 
118
125
  const AURORA: EvolutionTheme = {
@@ -129,6 +136,7 @@ const AURORA: EvolutionTheme = {
129
136
  accent: "#3ddad7",
130
137
  accentShadow: "#1d5c8f",
131
138
  diff: { add: "#16c79a", del: "#fd7c9b", addBg: "#0c2620", delBg: "#2a1626", hunk: "#7c83fd" },
139
+ userCard: { accent: "#3ddad7", border: "#1d5c8f", shadow: "#0e2e47", fill: "#071724" },
132
140
  };
133
141
 
134
142
  const SYNTHWAVE: EvolutionTheme = {
@@ -145,6 +153,7 @@ const SYNTHWAVE: EvolutionTheme = {
145
153
  accent: "#ec38bc",
146
154
  accentShadow: "#5b1a8a",
147
155
  diff: { add: "#03e9f4", del: "#ff5e99", addBg: "#0a2330", delBg: "#33122a", hunk: "#b388eb" },
156
+ userCard: { accent: "#ec38bc", border: "#5b1a8a", shadow: "#2d0d45", fill: "#160624" },
148
157
  };
149
158
 
150
159
  const SAKURA: EvolutionTheme = {
@@ -161,6 +170,7 @@ const SAKURA: EvolutionTheme = {
161
170
  accent: "#d6336c",
162
171
  accentShadow: "#862e59",
163
172
  diff: { add: "#37b24d", del: "#e03131", addBg: "#13260f", delBg: "#2b1212", hunk: "#cc5de8" },
173
+ userCard: { accent: "#d6336c", border: "#862e59", shadow: "#43172c", fill: "#210b16" },
164
174
  };
165
175
 
166
176
  const MONO: EvolutionTheme = {
@@ -1,12 +1,12 @@
1
1
  import chalk from "chalk";
2
2
  import { renderHud, type JeoPhase } from "../components/hud";
3
- import {
4
- evolutionTrack,
5
- stageIndexForStep,
3
+ import { padLineTo } from "../components/layout";
4
+ import { visibleWidth, truncateToWidth } from "../components/width";
5
+ import {
6
+ evolutionTrack,
7
+ stageIndexForStep,
6
8
  getEvolutionStatusMessage,
7
- stageProgressRatio,
8
9
  meterGlyphsFor,
9
- EVOLUTION_STAGE_COLORS
10
10
  } from "../components/evolution";
11
11
 
12
12
  export interface MonitorState {
@@ -17,39 +17,62 @@ export interface MonitorState {
17
17
  analysisReport?: string;
18
18
  }
19
19
 
20
+ /**
21
+ * The `ooo ralph` sovereign monitoring HUD. Every row is padded by DISPLAY width
22
+ * (ANSI escapes count 0, wide glyphs count 2) and the box auto-sizes to its widest
23
+ * row, so the heavy border stays flush regardless of color/unicode content. The
24
+ * old version padded ANSI-colored strings with `String.padEnd`, which counted the
25
+ * SGR escape bytes and tore the right edge whenever color was on.
26
+ */
20
27
  export function renderMonitorView(state: MonitorState): string {
21
28
  const unicode = true;
22
29
  const stage = stageIndexForStep(state.step, state.maxSteps);
30
+ const ratio = Math.max(0, Math.min(1, state.maxSteps > 0 ? state.step / state.maxSteps : 0));
31
+
23
32
  const hud = renderHud(state.phase, { unicode, color: true });
24
- const evo = evolutionTrack(stage, { color: true, unicode, ratio: state.step / state.maxSteps });
33
+ const evo = evolutionTrack(stage, { color: true, unicode, ratio });
25
34
  const statusMsg = getEvolutionStatusMessage(state.step, state.maxSteps, state.tickCount);
26
-
27
- // Progress Bar / Meter
28
- const ratio = Math.max(0, Math.min(1, state.step / state.maxSteps));
35
+
36
+ // Progress meter.
29
37
  const barWidth = 30;
30
38
  const filledWidth = Math.round(ratio * barWidth);
31
39
  const glyphs = meterGlyphsFor(stage, unicode);
32
40
  const bar = glyphs.color(glyphs.fill.repeat(filledWidth)) + chalk.dim(glyphs.empty.repeat(barWidth - filledWidth));
33
- const percentage = (ratio * 100).toFixed(1) + "%";
34
-
35
- let output = "";
36
- output += chalk.bold.cyan("┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓") + "\n";
37
- output += chalk.bold.cyan("┃") + " " + chalk.bold.yellow("ooo ralph") + chalk.bold(" Sovereign Monitoring HUD") + " ".repeat(25) + chalk.bold.cyan("┃") + "\n";
38
- output += chalk.bold.cyan("┠──────────────────────────────────────────────────────────────┨") + "\n";
39
- output += chalk.bold.cyan("┃") + " " + chalk.bold("PHASE:") + " " + hud.padEnd(50) + " ".repeat(4) + chalk.bold.cyan("┃") + "\n";
40
- output += chalk.bold.cyan("┃") + " " + chalk.bold("EVO :") + " " + evo.padEnd(50) + " ".repeat(4) + chalk.bold.cyan("┃") + "\n";
41
- output += chalk.bold.cyan("┃") + " " + chalk.bold("PROG :") + " " + bar.padEnd(50) + " " + chalk.bold(percentage).padStart(6) + chalk.bold.cyan("┃") + "\n";
42
- output += chalk.bold.cyan("┠──────────────────────────────────────────────────────────────┨") + "\n";
43
- output += chalk.bold.cyan("┃") + " " + chalk.italic.dim("> " + statusMsg).padEnd(60) + " " + chalk.bold.cyan("┃") + "\n";
44
-
45
- if (state.analysisReport) {
46
- output += chalk.bold.cyan("┠──────────────────────────────────────────────────────────────┨") + "\n";
47
- const lines = state.analysisReport.split("\n").slice(0, 5);
48
- for (const line of lines) {
49
- output += chalk.bold.cyan("┃") + " " + chalk.yellow(line.substring(0, 58).padEnd(58)) + " " + chalk.bold.cyan("┃") + "\n";
50
- }
41
+ const percentage = chalk.bold((ratio * 100).toFixed(1) + "%");
42
+
43
+ const label = (s: string) => chalk.bold(s);
44
+ const title = `${chalk.bold.yellow("ooo ralph")}${chalk.bold(" Sovereign Monitoring HUD")}`;
45
+ const phaseRow = `${label("PHASE:")} ${hud}`;
46
+ const evoRow = `${label("EVO :")} ${evo}`;
47
+ const progLeft = `${label("PROG :")} ${bar}`;
48
+ const statusRow = chalk.italic.dim(`> ${statusMsg}`);
49
+ const analysisRows = state.analysisReport
50
+ ? state.analysisReport.split("\n").slice(0, 5).map(l => chalk.yellow(l))
51
+ : [];
52
+
53
+ // Size the inner content area to the widest row (clamped), then right-align the
54
+ // progress percentage within that width.
55
+ const MIN_INNER = 40;
56
+ const MAX_INNER = 88;
57
+ const measured = [title, phaseRow, evoRow, progLeft, statusRow, ...analysisRows].map(visibleWidth);
58
+ const progMin = visibleWidth(progLeft) + 1 + visibleWidth(percentage);
59
+ const inner = Math.min(MAX_INNER, Math.max(MIN_INNER, progMin, ...measured));
60
+
61
+ const progGap = Math.max(1, inner - visibleWidth(progLeft) - visibleWidth(percentage));
62
+ const progRow = `${progLeft}${" ".repeat(progGap)}${percentage}`;
63
+
64
+ const paint = chalk.bold.cyan;
65
+ const top = paint("┏" + "━".repeat(inner + 2) + "┓");
66
+ const sep = paint("┠" + "─".repeat(inner + 2) + "┨");
67
+ const bottom = paint("┗" + "━".repeat(inner + 2) + "┛");
68
+ const v = paint("┃");
69
+ const row = (content: string) => `${v} ${padLineTo(truncateToWidth(content, inner), inner, "left")} ${v}`;
70
+
71
+ const out: string[] = [top, row(title), sep, row(phaseRow), row(evoRow), row(progRow), sep, row(statusRow)];
72
+ if (analysisRows.length > 0) {
73
+ out.push(sep);
74
+ for (const line of analysisRows) out.push(row(line));
51
75
  }
52
- output += chalk.bold.cyan("┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛") + "\n";
53
-
54
- return output;
76
+ out.push(bottom);
77
+ return out.join("\n") + "\n";
55
78
  }