jeo-code 0.4.4 → 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 +2 -2
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/README.zh.md +2 -2
- package/package.json +1 -1
- package/src/agent/engine.ts +62 -3
- package/src/agent/step-budget.ts +10 -0
- package/src/agent/subagents.ts +1 -1
- package/src/agent/task-tool.ts +13 -1
- package/src/agent/tools.ts +62 -0
- package/src/ai/model-manager.ts +7 -3
- package/src/ai/model-registry.ts +8 -3
- package/src/commands/launch.ts +111 -44
- package/src/tui/app.ts +38 -13
- package/src/tui/components/forge.ts +7 -6
- package/src/tui/components/input-box.ts +8 -3
- package/src/tui/components/themes.ts +11 -1
- package/src/tui/monitoring/hud-view.ts +53 -30
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.
|
|
154
|
+
- **[0.4.5]** (2026-06-14) — First-class filesystem make/remove tools.
|
|
153
155
|
- **[0.4.4]** (2026-06-13) — Live subagent status mirroring, always-useful Ctrl+O activity tail, read lineRange crash guard.
|
|
154
156
|
- **[0.4.3]** (2026-06-13) — Readability pass for autopilot, subagent activity, and worked-history review.
|
|
155
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.
|
|
156
|
-
- **[0.4.1]** (2026-06-12) — TUI card parity polish + done-time todo reconciliation.
|
|
157
|
-
- **[0.4.0]** (2026-06-12) — Verified TUI, resilient engine, batch input, multilingual docs.
|
|
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.
|
|
154
|
+
- **[0.4.5]** (2026-06-14) — First-class filesystem make/remove tools.
|
|
153
155
|
- **[0.4.4]** (2026-06-13) — Live subagent status mirroring, always-useful Ctrl+O activity tail, read lineRange crash guard.
|
|
154
156
|
- **[0.4.3]** (2026-06-13) — Readability pass for autopilot, subagent activity, and worked-history review.
|
|
155
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.
|
|
156
|
-
- **[0.4.1]** (2026-06-12) — TUI card parity polish + done-time todo reconciliation.
|
|
157
|
-
- **[0.4.0]** (2026-06-12) — Verified TUI, resilient engine, batch input, multilingual docs.
|
|
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.
|
|
154
|
+
- **[0.4.5]** (2026-06-14) — First-class filesystem make/remove tools.
|
|
153
155
|
- **[0.4.4]** (2026-06-13) — Live subagent status mirroring, always-useful Ctrl+O activity tail, read lineRange crash guard.
|
|
154
156
|
- **[0.4.3]** (2026-06-13) — Readability pass for autopilot, subagent activity, and worked-history review.
|
|
155
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.
|
|
156
|
-
- **[0.4.1]** (2026-06-12) — TUI card parity polish + done-time todo reconciliation.
|
|
157
|
-
- **[0.4.0]** (2026-06-12) — Verified TUI, resilient engine, batch input, multilingual docs.
|
|
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.
|
|
154
|
+
- **[0.4.5]** (2026-06-14) — First-class filesystem make/remove tools.
|
|
153
155
|
- **[0.4.4]** (2026-06-13) — Live subagent status mirroring, always-useful Ctrl+O activity tail, read lineRange crash guard.
|
|
154
156
|
- **[0.4.3]** (2026-06-13) — Readability pass for autopilot, subagent activity, and worked-history review.
|
|
155
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.
|
|
156
|
-
- **[0.4.1]** (2026-06-12) — TUI card parity polish + done-time todo reconciliation.
|
|
157
|
-
- **[0.4.0]** (2026-06-12) — Verified TUI, resilient engine, batch input, multilingual docs.
|
|
158
158
|
|
|
159
159
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
160
160
|
<!-- CHANGELOG:END -->
|
package/package.json
CHANGED
package/src/agent/engine.ts
CHANGED
|
@@ -11,7 +11,7 @@ import * as fs from "node:fs/promises";
|
|
|
11
11
|
import * as path from "node:path";
|
|
12
12
|
import type { Message } from "./loop";
|
|
13
13
|
import { extractJsonObject } from "./json";
|
|
14
|
-
import { readTool, writeTool, editTool, bashTool, findTool, searchTool, lsTool, type ToolResult } from "./tools";
|
|
14
|
+
import { readTool, writeTool, editTool, bashTool, findTool, searchTool, lsTool, mkdirTool, deleteTool, type ToolResult } from "./tools";
|
|
15
15
|
import { webSearchTool, setWebSearchActiveModel } from "./web-search";
|
|
16
16
|
import { friendlyProviderError, isContextOverflowError, isRefusalError } from "../util/provider-error";
|
|
17
17
|
import { isRateLimitError } from "../util/retry";
|
|
@@ -50,6 +50,8 @@ export const DEFAULT_TOOLS: Record<string, ToolHandler> = {
|
|
|
50
50
|
find: (a, cwd) => findTool(a.globPattern ?? a.pattern, cwd),
|
|
51
51
|
search: (a, cwd) => searchTool(a.pattern, a.globPattern ?? "*", cwd, !!(a.ignoreCase ?? a.i), { before: a.before, after: a.after, context: a.context, maxMatches: a.maxMatches }),
|
|
52
52
|
ls: (a, cwd) => lsTool(a.dirPath ?? a.path ?? a.dir ?? ".", cwd),
|
|
53
|
+
mkdir: (a, cwd) => mkdirTool(a.dirPath ?? a.path ?? a.dir, cwd),
|
|
54
|
+
delete: (a, cwd) => deleteTool(a.path ?? a.filePath ?? a.targetPath ?? a.dirPath, cwd, !!(a.recursive ?? a.r)),
|
|
53
55
|
web_search: (a, cwd) => webSearchTool(a, cwd),
|
|
54
56
|
};
|
|
55
57
|
|
|
@@ -63,8 +65,10 @@ export const TOOL_PROTOCOL = [
|
|
|
63
65
|
"5. find {globPattern} — find files by name",
|
|
64
66
|
"6. search {pattern, globPattern?, ignoreCase?, context?, maxMatches?} — grep (context: N lines around each match)",
|
|
65
67
|
"7. ls {dirPath} — list a directory's entries (dirs first)",
|
|
66
|
-
"8.
|
|
67
|
-
"9.
|
|
68
|
+
"8. mkdir {dirPath} — create a directory (parents included; idempotent)",
|
|
69
|
+
"9. delete {path, recursive?} — remove a file (or directory with recursive:true)",
|
|
70
|
+
"10. web_search {query, recency?, limit?} — search the web (Anthropic-native: synthesized answer + sources + citations)",
|
|
71
|
+
"11. done {reason?} — call when the task is fully implemented AND verified",
|
|
68
72
|
"",
|
|
69
73
|
"Reply with STRICT JSON only — no code fences. You MAY include an optional leading",
|
|
70
74
|
'"reasoning" string (one short sentence on your plan) before "tool":',
|
|
@@ -145,6 +149,10 @@ export interface AgentLoopEvents {
|
|
|
145
149
|
* first"); return null to let the turn finish. The engine guarantees at most
|
|
146
150
|
* one bounce per turn, so a stubborn model can never loop here. */
|
|
147
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;
|
|
148
156
|
}
|
|
149
157
|
|
|
150
158
|
export interface AgentLoopOptions {
|
|
@@ -169,6 +177,11 @@ export interface AgentLoopOptions {
|
|
|
169
177
|
/** Step-budget overrides (gjc-style retry flow). `{ maxExtensions: 0 }` restores the
|
|
170
178
|
* legacy fixed counter — used by bounded subagent delegation. */
|
|
171
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[];
|
|
172
185
|
}
|
|
173
186
|
|
|
174
187
|
export interface AgentLoopResult {
|
|
@@ -396,6 +409,29 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
396
409
|
}
|
|
397
410
|
await ev.onStep?.(step);
|
|
398
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
|
+
|
|
399
435
|
// MID-TURN context guard: a single long turn (60+ steps) otherwise grows the
|
|
400
436
|
// history without bound — turn-boundary compaction never runs inside a turn,
|
|
401
437
|
// and field evidence shows multi-million-token prompts degrading the model
|
|
@@ -619,6 +655,29 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
619
655
|
continue;
|
|
620
656
|
}
|
|
621
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
|
+
}
|
|
622
681
|
return finish({ done: true, steps: step, doneReason: (toolCalls[0].arguments?.reason as string) ?? "" });
|
|
623
682
|
}
|
|
624
683
|
|
package/src/agent/step-budget.ts
CHANGED
|
@@ -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;
|
package/src/agent/subagents.ts
CHANGED
|
@@ -233,7 +233,7 @@ export function bashCommandAllowed(command: string, prefixes: string[]): boolean
|
|
|
233
233
|
*/
|
|
234
234
|
export function subagentToolset(role: SubagentRole): Record<string, ToolHandler> {
|
|
235
235
|
if (role.readOnly) {
|
|
236
|
-
const MUTATING = new Set(["write", "edit", "bash"]);
|
|
236
|
+
const MUTATING = new Set(["write", "edit", "bash", "mkdir", "delete"]);
|
|
237
237
|
const ro: Record<string, ToolHandler> = {};
|
|
238
238
|
for (const [name, handler] of Object.entries(DEFAULT_TOOLS)) {
|
|
239
239
|
if (MUTATING.has(name)) continue;
|
package/src/agent/task-tool.ts
CHANGED
|
@@ -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
|
}
|
package/src/agent/tools.ts
CHANGED
|
@@ -812,3 +812,65 @@ export async function lsTool(
|
|
|
812
812
|
return { success: false, output: "", error: err.message };
|
|
813
813
|
}
|
|
814
814
|
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Create a directory (and any missing parents). Idempotent: an already-existing
|
|
818
|
+
* directory is a success, not an error — the model should not need to branch on
|
|
819
|
+
* existence. Respects the deep-interview mutation lock like write/edit.
|
|
820
|
+
*/
|
|
821
|
+
export async function mkdirTool(
|
|
822
|
+
dirPath: string,
|
|
823
|
+
cwd: string = process.cwd()
|
|
824
|
+
): Promise<ToolResult> {
|
|
825
|
+
try {
|
|
826
|
+
if (typeof dirPath !== "string" || dirPath.trim() === "") {
|
|
827
|
+
return { success: false, output: "", error: 'mkdir requires a non-empty "dirPath".' };
|
|
828
|
+
}
|
|
829
|
+
await assertMutationAllowed(dirPath, cwd);
|
|
830
|
+
const abs = path.resolve(cwd, dirPath);
|
|
831
|
+
const existing = await fs.stat(abs).catch(() => null);
|
|
832
|
+
if (existing && !existing.isDirectory()) {
|
|
833
|
+
return { success: false, output: "", error: `Path exists and is not a directory: ${dirPath}` };
|
|
834
|
+
}
|
|
835
|
+
await fs.mkdir(abs, { recursive: true });
|
|
836
|
+
return { success: true, output: `Directory ready: ${dirPath}` };
|
|
837
|
+
} catch (err: any) {
|
|
838
|
+
return { success: false, output: "", error: err.message };
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Delete a file or directory. A directory requires `recursive: true` so a stray
|
|
844
|
+
* call cannot wipe a populated tree by accident. Missing paths are a soft error
|
|
845
|
+
* (nothing to delete) rather than a crash. Respects the mutation lock like
|
|
846
|
+
* write/edit; the file-freshness snapshot is cleared so a later write to the
|
|
847
|
+
* same path is not rejected as stale.
|
|
848
|
+
*/
|
|
849
|
+
export async function deleteTool(
|
|
850
|
+
targetPath: string,
|
|
851
|
+
cwd: string = process.cwd(),
|
|
852
|
+
recursive: boolean = false
|
|
853
|
+
): Promise<ToolResult> {
|
|
854
|
+
try {
|
|
855
|
+
if (typeof targetPath !== "string" || targetPath.trim() === "") {
|
|
856
|
+
return { success: false, output: "", error: 'delete requires a non-empty "path".' };
|
|
857
|
+
}
|
|
858
|
+
await assertMutationAllowed(targetPath, cwd);
|
|
859
|
+
const abs = path.resolve(cwd, targetPath);
|
|
860
|
+
if (abs === path.resolve(cwd)) {
|
|
861
|
+
return { success: false, output: "", error: "Refusing to delete the working directory itself." };
|
|
862
|
+
}
|
|
863
|
+
const st = await fs.stat(abs).catch(() => null);
|
|
864
|
+
if (!st) {
|
|
865
|
+
return { success: false, output: "", error: `Nothing to delete: ${targetPath} (does not exist).` };
|
|
866
|
+
}
|
|
867
|
+
if (st.isDirectory() && !recursive) {
|
|
868
|
+
return { success: false, output: "", error: `${targetPath} is a directory — pass recursive:true to remove it and its contents.` };
|
|
869
|
+
}
|
|
870
|
+
await fs.rm(abs, { recursive, force: false });
|
|
871
|
+
lastReadSnapshots.delete(abs); // a future write to this path must not be flagged stale
|
|
872
|
+
return { success: true, output: `Deleted ${st.isDirectory() ? "directory" : "file"}: ${targetPath}` };
|
|
873
|
+
} catch (err: any) {
|
|
874
|
+
return { success: false, output: "", error: err.message };
|
|
875
|
+
}
|
|
876
|
+
}
|
package/src/ai/model-manager.ts
CHANGED
|
@@ -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
|
-
|
|
101
|
-
|
|
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
|
|
package/src/ai/model-registry.ts
CHANGED
|
@@ -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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
}
|
package/src/commands/launch.ts
CHANGED
|
@@ -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
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
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;
|
|
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 = `${
|
|
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
|
-
|
|
2841
|
-
//
|
|
2842
|
-
// readline's own
|
|
2843
|
-
//
|
|
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
|
-
|
|
2858
|
-
|
|
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
|
-
|
|
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
|
|
520
|
-
const
|
|
521
|
-
const
|
|
522
|
-
const
|
|
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
|
|
936
|
-
paintShadow
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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(
|
|
132
|
+
content.push(labelAccent(opts.attachmentLabel));
|
|
128
133
|
}
|
|
129
134
|
if (opts.cwdLabel) {
|
|
130
|
-
content.push(
|
|
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
|
-
|
|
5
|
-
|
|
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
|
|
33
|
+
const evo = evolutionTrack(stage, { color: true, unicode, ratio });
|
|
25
34
|
const statusMsg = getEvolutionStatusMessage(state.step, state.maxSteps, state.tickCount);
|
|
26
|
-
|
|
27
|
-
// Progress
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
return output;
|
|
76
|
+
out.push(bottom);
|
|
77
|
+
return out.join("\n") + "\n";
|
|
55
78
|
}
|