roforge-cli 0.3.1 → 0.3.3
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/bin/roforge.js +36 -2
- package/package.json +1 -1
- package/src/bridge/server.js +76 -4
- package/src/config.js +7 -2
- package/src/pro.js +126 -0
- package/src/providers/openai.js +40 -2
- package/src/tools/studio.js +5 -2
- package/src/tui/frame.js +264 -0
- package/src/tui/markdown.js +112 -24
- package/src/tui/tui.js +182 -36
package/bin/roforge.js
CHANGED
|
@@ -9,7 +9,7 @@ import { Session } from "../src/session.js";
|
|
|
9
9
|
import { BridgeServer } from "../src/bridge/server.js";
|
|
10
10
|
import { probeMcp } from "../src/mcp.js";
|
|
11
11
|
import { TUI } from "../src/tui/tui.js";
|
|
12
|
-
import { bold, dim, red, green, cyan, yellow, gray } from "../src/tui/ansi.js";
|
|
12
|
+
import { bold, dim, red, green, cyan, yellow, gray, magenta } from "../src/tui/ansi.js";
|
|
13
13
|
|
|
14
14
|
const argv = process.argv.slice(2);
|
|
15
15
|
const command = argv[0] || "tui";
|
|
@@ -262,6 +262,39 @@ async function main() {
|
|
|
262
262
|
return;
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
+
case "pro": {
|
|
266
|
+
const { queryProStatus, renderProStatus, probeBridge, startOwnBridge } = await import("../src/pro.js");
|
|
267
|
+
const port = flags.port ? Number(flags.port) : cfg.bridge.port;
|
|
268
|
+
const host = cfg.bridge.host;
|
|
269
|
+
const token = cfg.bridge.token;
|
|
270
|
+
const base = `http://${host}:${port}`;
|
|
271
|
+
console.log(bold("RoForge Pro") + dim(" — license status\n"));
|
|
272
|
+
// Attach to an already-running bridge (e.g. `roforge studio`);
|
|
273
|
+
// otherwise start a throwaway one for the plugin to connect to.
|
|
274
|
+
const health = await probeBridge(base);
|
|
275
|
+
if (health) {
|
|
276
|
+
const res = await queryProStatus(null, { port, token, baseUrl: base });
|
|
277
|
+
console.log(renderProStatus(res, { bold, dim, red, green, magenta, yellow }) + "\n");
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const bridge = startOwnBridge({ port, host, token });
|
|
281
|
+
try {
|
|
282
|
+
await bridge.start();
|
|
283
|
+
} catch (e) {
|
|
284
|
+
console.error(red(`bridge could not start on port ${port}: ${e.message}`));
|
|
285
|
+
process.exit(1);
|
|
286
|
+
}
|
|
287
|
+
// The plugin pings every ~1s; give it a moment to show up if it's up.
|
|
288
|
+
const deadline = Date.now() + 6000;
|
|
289
|
+
while (!bridge.connected && Date.now() < deadline) {
|
|
290
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
291
|
+
}
|
|
292
|
+
const res = await queryProStatus(bridge, { port, token });
|
|
293
|
+
console.log(renderProStatus(res, { bold, dim, red, green, magenta, yellow }) + "\n");
|
|
294
|
+
bridge.stop();
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
265
298
|
case "help":
|
|
266
299
|
case "--help":
|
|
267
300
|
case "-h": {
|
|
@@ -375,6 +408,7 @@ ${bold("Usage")}
|
|
|
375
408
|
roforge tools list all tools
|
|
376
409
|
roforge login --provider <p> store a key (gemini|groq|openrouter|anthropic|openai)
|
|
377
410
|
roforge providers list providers, keys, and auto-routing order
|
|
411
|
+
roforge pro show RoForge Pro license status (needs Studio bridge)
|
|
378
412
|
roforge analyze <file...> run the official Luau analyzer on files
|
|
379
413
|
roforge config [set k v] show / set configuration
|
|
380
414
|
roforge version
|
|
@@ -389,7 +423,7 @@ ${bold("Model providers (BYOK, zero backend)")}
|
|
|
389
423
|
auto (default): first configured key wins, free tiers first:
|
|
390
424
|
gemini → groq → openrouter → anthropic → openai
|
|
391
425
|
free tiers: Gemini 2.5 Flash (~1,500 req/day), Groq Llama 3.3 70B (~1,000 req/day),
|
|
392
|
-
OpenRouter ":free" models (e.g.
|
|
426
|
+
OpenRouter ":free" models (e.g. nvidia/nemotron-3-super-120b-a12b:free)
|
|
393
427
|
pin: --provider <p> or --model <provider>:<model> · ROFORGE_FREE_FIRST=0
|
|
394
428
|
|
|
395
429
|
${bold("Config & keys")}
|
package/package.json
CHANGED
package/src/bridge/server.js
CHANGED
|
@@ -10,11 +10,12 @@ import http from "node:http";
|
|
|
10
10
|
import { json } from "./wire.js";
|
|
11
11
|
|
|
12
12
|
export class BridgeServer {
|
|
13
|
-
constructor({ port, host = "127.0.0.1", token, jobTimeoutMs = 60000 }) {
|
|
13
|
+
constructor({ port, host = "127.0.0.1", token, jobTimeoutMs = 60000, completedTtlMs = 30000 }) {
|
|
14
14
|
this.port = port;
|
|
15
15
|
this.host = host;
|
|
16
16
|
this.token = token;
|
|
17
17
|
this.jobTimeoutMs = jobTimeoutMs;
|
|
18
|
+
this.completedTtlMs = completedTtlMs;
|
|
18
19
|
this.jobs = new Map(); // id → {id, tool, args, status, resolve, timer}
|
|
19
20
|
this.lastPingAt = null;
|
|
20
21
|
this.lastSeenAt = null;
|
|
@@ -45,7 +46,7 @@ export class BridgeServer {
|
|
|
45
46
|
stop() {
|
|
46
47
|
for (const job of this.jobs.values()) {
|
|
47
48
|
clearTimeout(job.timer);
|
|
48
|
-
job.resolve({ ok: false, error: "bridge shut down" });
|
|
49
|
+
if (typeof job.resolve === "function") job.resolve({ ok: false, error: "bridge shut down" });
|
|
49
50
|
}
|
|
50
51
|
this.jobs.clear();
|
|
51
52
|
if (this.server) {
|
|
@@ -114,20 +115,91 @@ export class BridgeServer {
|
|
|
114
115
|
}
|
|
115
116
|
clearTimeout(job.timer);
|
|
116
117
|
const out = body.error ? { ok: false, error: body.error } : { ok: true, result: body.result ?? "" };
|
|
117
|
-
|
|
118
|
-
job.
|
|
118
|
+
job.status = "done";
|
|
119
|
+
job.out = out;
|
|
120
|
+
if (typeof job.resolve === "function") job.resolve(out);
|
|
121
|
+
this._retire(job);
|
|
119
122
|
emit("job_done", { id: job.id, ...out });
|
|
120
123
|
json(res, 200, { ok: true });
|
|
121
124
|
});
|
|
122
125
|
return;
|
|
123
126
|
}
|
|
124
127
|
|
|
128
|
+
// Enqueue a job from an authenticated client (used by `roforge pro`
|
|
129
|
+
// attaching to an already-running bridge). The plugin still discovers
|
|
130
|
+
// it via the normal GET /jobs claim path.
|
|
131
|
+
if (req.method === "POST" && path === "/v1/bridge/jobs/enqueue") {
|
|
132
|
+
let body = {};
|
|
133
|
+
const chunks = [];
|
|
134
|
+
let size = 0;
|
|
135
|
+
req.on("data", (c) => {
|
|
136
|
+
size += c.length;
|
|
137
|
+
if (size < 1024 * 1024) chunks.push(c);
|
|
138
|
+
});
|
|
139
|
+
req.on("end", () => {
|
|
140
|
+
try {
|
|
141
|
+
body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : {};
|
|
142
|
+
} catch {
|
|
143
|
+
body = {};
|
|
144
|
+
}
|
|
145
|
+
if (typeof body.tool !== "string" || !body.tool) {
|
|
146
|
+
return json(res, 400, { error: "missing tool", code: "BAD_REQUEST" });
|
|
147
|
+
}
|
|
148
|
+
const id = this.enqueueRemote(body.tool, body.args ?? {});
|
|
149
|
+
json(res, 200, { ok: true, id });
|
|
150
|
+
});
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Poll a (possibly completed) job's status — read by an attaching client.
|
|
155
|
+
const gs = /^\/v1\/bridge\/jobs\/(\w+)$/.exec(path);
|
|
156
|
+
if (req.method === "GET" && gs) {
|
|
157
|
+
const st = this.jobStatus(gs[1]);
|
|
158
|
+
if (!st) return json(res, 404, { error: "unknown job", code: "JOB_NOT_FOUND" });
|
|
159
|
+
return json(res, 200, { ok: true, ...st });
|
|
160
|
+
}
|
|
161
|
+
|
|
125
162
|
return json(res, 404, { error: `no route: ${req.method} ${path}`, code: "NOT_FOUND" });
|
|
126
163
|
} catch (e) {
|
|
127
164
|
json(res, 500, { error: e.message, code: "INTERNAL" });
|
|
128
165
|
}
|
|
129
166
|
}
|
|
130
167
|
|
|
168
|
+
// Keep a finished job readable for a short window (so an attaching client
|
|
169
|
+
// can poll its result), then drop it. Unref'd: housekeeping must not keep
|
|
170
|
+
// the CLI process (or test runner) alive.
|
|
171
|
+
_retire(job) {
|
|
172
|
+
const t = setTimeout(() => {
|
|
173
|
+
if (this.jobs.get(job.id) === job) this.jobs.delete(job.id);
|
|
174
|
+
}, this.completedTtlMs);
|
|
175
|
+
if (typeof t.unref === "function") t.unref();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Enqueue a job on behalf of a remote (attaching) client. Returns the id to
|
|
179
|
+
// poll. The plugin claims it through the normal GET /jobs path.
|
|
180
|
+
enqueueRemote(tool, args, { timeoutMs = this.jobTimeoutMs } = {}) {
|
|
181
|
+
const id = `job_${this.nextJobId++}`;
|
|
182
|
+
const job = { id, tool, args, status: "pending", remote: true, out: null, resolve: null, claimedAt: null, timer: null };
|
|
183
|
+
job.timer = setTimeout(() => {
|
|
184
|
+
if (this.jobs.get(id) === job && job.status !== "done") {
|
|
185
|
+
job.status = "timeout";
|
|
186
|
+
job.out = { ok: false, error: `studio job timed out after ${Math.round(timeoutMs / 1000)}s` };
|
|
187
|
+
this._retire(job);
|
|
188
|
+
}
|
|
189
|
+
}, timeoutMs);
|
|
190
|
+
this.jobs.set(id, job);
|
|
191
|
+
return id;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Current status of a job for a poller: {id, status} while in flight, or
|
|
195
|
+
// {id, status:"done"|"timeout", ...out} once finished. null if unknown.
|
|
196
|
+
jobStatus(id) {
|
|
197
|
+
const job = this.jobs.get(id);
|
|
198
|
+
if (!job) return null;
|
|
199
|
+
if (job.status === "pending" || job.status === "claimed") return { id, status: job.status };
|
|
200
|
+
return { id, status: job.status, ...(job.out || {}) };
|
|
201
|
+
}
|
|
202
|
+
|
|
131
203
|
// Submit a tool job and wait for the plugin's result. Resolves to
|
|
132
204
|
// {ok:true, result:string} or {ok:false, error:string}.
|
|
133
205
|
submit(tool, args, { timeoutMs = this.jobTimeoutMs } = {}) {
|
package/src/config.js
CHANGED
|
@@ -46,7 +46,10 @@ export const PROVIDERS = {
|
|
|
46
46
|
env: "OPENROUTER_API_KEY",
|
|
47
47
|
baseField: "openrouterBaseUrl",
|
|
48
48
|
defaultModel: "qwen/qwen3-coder",
|
|
49
|
-
|
|
49
|
+
// Free tier rotates — this is the live free model verified 2026-09-13
|
|
50
|
+
// via the public OpenRouter models API + a real request. When it dies,
|
|
51
|
+
// check https://openrouter.ai/models?max_price=0 and update here.
|
|
52
|
+
freeModel: "nvidia/nemotron-3-super-120b-a12b:free",
|
|
50
53
|
hasFreeTier: true,
|
|
51
54
|
},
|
|
52
55
|
anthropic: {
|
|
@@ -114,7 +117,8 @@ const DEFAULTS = {
|
|
|
114
117
|
"gpt-4o": { input: 2.5, output: 10 },
|
|
115
118
|
"gemini-2.5-flash": { input: 0, output: 0 },
|
|
116
119
|
"llama-3.3-70b-versatile": { input: 0, output: 0 },
|
|
117
|
-
"
|
|
120
|
+
"nvidia/nemotron-3-super-120b-a12b:free": { input: 0, output: 0 },
|
|
121
|
+
"nvidia/nemotron-3-ultra-550b-a55b:free": { input: 0, output: 0 },
|
|
118
122
|
},
|
|
119
123
|
};
|
|
120
124
|
|
|
@@ -184,6 +188,7 @@ export function resolveConfig() {
|
|
|
184
188
|
if (process.env.ROFORGE_PROVIDER) cfg.provider = process.env.ROFORGE_PROVIDER;
|
|
185
189
|
if (process.env.ROFORGE_MCP_URL) cfg.mcpUrl = process.env.ROFORGE_MCP_URL;
|
|
186
190
|
if (process.env.ROFORGE_BRIDGE_PORT) cfg.bridge.port = Number(process.env.ROFORGE_BRIDGE_PORT);
|
|
191
|
+
if (process.env.ROFORGE_BRIDGE_TOKEN) cfg.bridge.token = process.env.ROFORGE_BRIDGE_TOKEN;
|
|
187
192
|
if (process.env.ROFORGE_STUDIO_MODE) cfg.studioMode = process.env.ROFORGE_STUDIO_MODE;
|
|
188
193
|
if (process.env.ROFORGE_MAX_ITERATIONS) cfg.maxIterations = Number(process.env.ROFORGE_MAX_ITERATIONS);
|
|
189
194
|
if (process.env.ROFORGE_FREE_FIRST === "0" || process.env.ROFORGE_FREE_FIRST === "false") cfg.freeFirst = false;
|
package/src/pro.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// RoForge Pro — CLI-side status plumbing.
|
|
2
|
+
//
|
|
3
|
+
// The entitlement itself is checked inside Studio by the bridge plugin
|
|
4
|
+
// (MarketplaceService only exists there). This module reaches the connected
|
|
5
|
+
// Studio and renders its `forge_pro` report for `roforge pro`.
|
|
6
|
+
//
|
|
7
|
+
// Two ways to reach Studio, tried in order:
|
|
8
|
+
// 1. ATTACH — if a bridge is already running on the configured port (e.g.
|
|
9
|
+
// `roforge studio` is up and the plugin is bound to it), enqueue the
|
|
10
|
+
// forge_pro job on that bridge and poll for the result. This is the
|
|
11
|
+
// normal case: you check Pro while your Studio session is live.
|
|
12
|
+
// 2. OWN — otherwise start a throwaway bridge on the configured port, wait
|
|
13
|
+
// for the plugin (bound to the same port + token) to connect, submit,
|
|
14
|
+
// and stop. This lets `roforge pro` work standalone too.
|
|
15
|
+
|
|
16
|
+
import { BridgeServer } from "./bridge/server.js";
|
|
17
|
+
|
|
18
|
+
const HEALTH_PATH = "/health";
|
|
19
|
+
|
|
20
|
+
// Probe for an already-running bridge on baseUrl. Returns the /health body
|
|
21
|
+
// ({ok, service, connected, port}) or null when nothing is listening.
|
|
22
|
+
export async function probeBridge(baseUrl, { timeoutMs = 1200 } = {}) {
|
|
23
|
+
try {
|
|
24
|
+
const r = await fetch(`${baseUrl}${HEALTH_PATH}`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
25
|
+
if (!r.ok) return null;
|
|
26
|
+
const j = await r.json();
|
|
27
|
+
return j && j.service === "roforge-bridge" ? j : null;
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Enqueue `tool` on a running bridge and poll for the result. Resolves to
|
|
34
|
+
// {ok, result?/error}. `connected` lets the caller fail fast when the plugin
|
|
35
|
+
// isn't actually bound to this bridge.
|
|
36
|
+
export async function remoteSubmit(baseUrl, token, tool, args, { timeoutMs = 20000, pollMs = 150 } = {}) {
|
|
37
|
+
const auth = { authorization: `Bearer ${token}`, "content-type": "application/json" };
|
|
38
|
+
const enq = await fetch(`${baseUrl}/v1/bridge/jobs/enqueue`, {
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers: auth,
|
|
41
|
+
body: JSON.stringify({ tool, args }),
|
|
42
|
+
});
|
|
43
|
+
if (!enq.ok) {
|
|
44
|
+
let msg = `HTTP ${enq.status}`;
|
|
45
|
+
try { msg = (await enq.json()).error || msg; } catch { /* keep status */ }
|
|
46
|
+
return { ok: false, error: msg };
|
|
47
|
+
}
|
|
48
|
+
const { id } = await enq.json();
|
|
49
|
+
const deadline = Date.now() + timeoutMs;
|
|
50
|
+
while (Date.now() < deadline) {
|
|
51
|
+
const st = await (await fetch(`${baseUrl}/v1/bridge/jobs/${id}`, { headers: { authorization: `Bearer ${token}` } })).json();
|
|
52
|
+
if (st.status === "done") {
|
|
53
|
+
return st.error ? { ok: false, error: st.error } : { ok: true, result: st.result ?? "" };
|
|
54
|
+
}
|
|
55
|
+
if (st.status === "timeout") return { ok: false, error: st.error || "studio job timed out" };
|
|
56
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
57
|
+
}
|
|
58
|
+
return { ok: false, error: "timed out waiting for Studio" };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Query Pro status, attaching to a running bridge when present. Returns
|
|
62
|
+
// { text } on success, { error } otherwise. `bridge` may be null to force the
|
|
63
|
+
// "own bridge" path (used by tests).
|
|
64
|
+
export async function queryProStatus(bridge, { port, token, baseUrl, timeoutMs = 20000 } = {}) {
|
|
65
|
+
const base = baseUrl || `http://127.0.0.1:${port}`;
|
|
66
|
+
|
|
67
|
+
// Path 1: attach to a running bridge.
|
|
68
|
+
if (!bridge) {
|
|
69
|
+
const health = await probeBridge(base);
|
|
70
|
+
if (health) {
|
|
71
|
+
if (!health.connected) {
|
|
72
|
+
return {
|
|
73
|
+
error:
|
|
74
|
+
"A bridge is running but Studio is not connected. Open Roblox Studio with the " +
|
|
75
|
+
"RoForge Bridge plugin active and paste the bridge token into it.",
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const out = await remoteSubmit(base, token, "forge_pro", {}, { timeoutMs });
|
|
79
|
+
if (!out.ok) return { error: out.error };
|
|
80
|
+
return { text: String(out.result ?? "") };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Path 2: own bridge (already started by the caller).
|
|
85
|
+
if (!bridge || !bridge.connected) {
|
|
86
|
+
return {
|
|
87
|
+
error:
|
|
88
|
+
"No Studio bridge connection. Pro status is checked inside Studio (MarketplaceService), " +
|
|
89
|
+
"so this needs the RoForge Bridge plugin running and connected.",
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const out = await bridge.submit("forge_pro", {}, { timeoutMs });
|
|
93
|
+
if (!out.ok) return { error: out.error };
|
|
94
|
+
return { text: String(out.result ?? "") };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Start a throwaway bridge for the "own bridge" path. Returns the started
|
|
98
|
+
// BridgeServer or throws on EADDRINUSE.
|
|
99
|
+
export function startOwnBridge({ port, host = "127.0.0.1", token }) {
|
|
100
|
+
const bridge = new BridgeServer({ port, host, token });
|
|
101
|
+
return bridge;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Render the forge_pro report (plain text from the plugin) with minimal
|
|
105
|
+
// styling: the tier header gets color, the rest stays as-is.
|
|
106
|
+
export function renderProStatus(res, { bold, dim, red, green, magenta, yellow } = {}) {
|
|
107
|
+
const id = (fn, s) => (fn ? fn(s) : String(s));
|
|
108
|
+
if (res && res.error) {
|
|
109
|
+
return (
|
|
110
|
+
id(red, res.error) +
|
|
111
|
+
"\n" +
|
|
112
|
+
id(dim, " 1. open Roblox Studio with the RoForge Bridge plugin\n") +
|
|
113
|
+
id(dim, " 2. run `roforge studio` here and paste the token it prints into the plugin\n") +
|
|
114
|
+
id(dim, " 3. re-run `roforge pro` while both are running")
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
const lines = String(res?.text ?? "").split("\n");
|
|
118
|
+
const out = [];
|
|
119
|
+
for (const line of lines) {
|
|
120
|
+
if (line.startsWith("RoForge Pro: PRO")) out.push(id(green, line));
|
|
121
|
+
else if (line.startsWith("RoForge Pro: FREE")) out.push(id(yellow, line));
|
|
122
|
+
else if (line.startsWith("Unlock Pro:")) out.push(id(magenta, line));
|
|
123
|
+
else out.push(line);
|
|
124
|
+
}
|
|
125
|
+
return out.join("\n");
|
|
126
|
+
}
|
package/src/providers/openai.js
CHANGED
|
@@ -3,6 +3,34 @@
|
|
|
3
3
|
import { createSSE } from "../util.js";
|
|
4
4
|
import { ProviderError } from "./anthropic.js";
|
|
5
5
|
|
|
6
|
+
// fetch with a per-attempt timeout and ONE automatic retry on network-level
|
|
7
|
+
// failures (UND_ERR_CONNECT_TIMEOUT etc.) — user-initiated aborts are never
|
|
8
|
+
// retried.
|
|
9
|
+
export async function fetchWithRetry(url, opts, { timeoutMs = 60000, retries = 1 } = {}) {
|
|
10
|
+
let lastErr;
|
|
11
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
12
|
+
if (opts.signal?.aborted) throw lastErr || new ProviderError("aborted");
|
|
13
|
+
const controller = new AbortController();
|
|
14
|
+
const timer = setTimeout(() => controller.abort(new Error("timeout")), timeoutMs);
|
|
15
|
+
const onAbort = () => controller.abort(opts.signal.reason);
|
|
16
|
+
if (opts.signal) opts.signal.addEventListener("abort", onAbort, { once: true });
|
|
17
|
+
try {
|
|
18
|
+
return await fetch(url, { ...opts, signal: controller.signal });
|
|
19
|
+
} catch (e) {
|
|
20
|
+
lastErr = e;
|
|
21
|
+
if (opts.signal?.aborted) break; // user Ctrl+C — don't retry
|
|
22
|
+
const sig = `${e.cause?.code || ""} ${e.name} ${e.message}`;
|
|
23
|
+
const isNetwork = /UND_ERR|ECONN|ETIMEDOUT|EAI_AGAIN|EPIPE|EHOSTUNREACH|ENOTFOUND|timeout|aborted/i.test(sig);
|
|
24
|
+
if (!isNetwork || attempt === retries) break;
|
|
25
|
+
await new Promise((r) => setTimeout(r, 1200 * (attempt + 1)));
|
|
26
|
+
} finally {
|
|
27
|
+
clearTimeout(timer);
|
|
28
|
+
if (opts.signal) opts.signal.removeEventListener("abort", onAbort);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
throw lastErr;
|
|
32
|
+
}
|
|
33
|
+
|
|
6
34
|
export async function chatStream(cfg, params, events = {}) {
|
|
7
35
|
const key = cfg.openaiKey;
|
|
8
36
|
if (!key) throw new ProviderError("No OpenAI API key. Run `roforge login` or set OPENAI_API_KEY.");
|
|
@@ -17,7 +45,7 @@ export async function chatStream(cfg, params, events = {}) {
|
|
|
17
45
|
|
|
18
46
|
let res;
|
|
19
47
|
try {
|
|
20
|
-
res = await
|
|
48
|
+
res = await fetchWithRetry(`${cfg.openaiBaseUrl}/v1/chat/completions`, {
|
|
21
49
|
method: "POST",
|
|
22
50
|
headers: {
|
|
23
51
|
Authorization: `Bearer ${key}`,
|
|
@@ -27,10 +55,20 @@ export async function chatStream(cfg, params, events = {}) {
|
|
|
27
55
|
signal: params.signal,
|
|
28
56
|
});
|
|
29
57
|
} catch (e) {
|
|
30
|
-
throw new ProviderError(
|
|
58
|
+
throw new ProviderError(
|
|
59
|
+
`network error calling OpenAI: ${e.cause?.code || e.message} (retried once — if it persists, check your connection)`
|
|
60
|
+
);
|
|
31
61
|
}
|
|
32
62
|
if (!res.ok) {
|
|
33
63
|
const text = await res.text().catch(() => "");
|
|
64
|
+
// OpenRouter retires free slugs; the 404 body names the paid replacement
|
|
65
|
+
if (res.status === 404 && /unavailable for free/i.test(text)) {
|
|
66
|
+
const m = text.match(/use this slug instead:\s*([^\s",}]+)/);
|
|
67
|
+
throw new ProviderError(
|
|
68
|
+
`${params.model} was retired from the free tier. Paid slug: ${m ? m[1] : "(see error)"} — ` +
|
|
69
|
+
"or pick a live free model: https://openrouter.ai/models?max_price=0 (then /model openrouter:<slug>)"
|
|
70
|
+
);
|
|
71
|
+
}
|
|
34
72
|
throw new ProviderError(`OpenAI HTTP ${res.status}: ${text.slice(0, 400)}`);
|
|
35
73
|
}
|
|
36
74
|
|
package/src/tools/studio.js
CHANGED
|
@@ -28,6 +28,7 @@ const BRIDGE_TOOL_NAMES = [
|
|
|
28
28
|
"forge_diff",
|
|
29
29
|
"forge_export",
|
|
30
30
|
"forge_import",
|
|
31
|
+
"forge_pro",
|
|
31
32
|
];
|
|
32
33
|
|
|
33
34
|
const BRIDGE_DESCRIPTIONS = {
|
|
@@ -53,8 +54,9 @@ const BRIDGE_DESCRIPTIONS = {
|
|
|
53
54
|
forge_bulk_create: "Create many instances in one call (paste-style): items[] of {path: parent path, class_name, name?, properties?}. Destructive.",
|
|
54
55
|
forge_snapshot: "Capture the current instance tree under a name, so forge_diff can show what changed later. Keeps the last 10.",
|
|
55
56
|
forge_diff: "Compare a snapshot to the current instance tree: added (+) and removed (-) instances. Omit name to diff the most recent snapshot.",
|
|
56
|
-
forge_export: "Export a DataModel subtree as JSON (properties, script sources truncated, attributes). Defaults to workspace, depth 3 (max 6).",
|
|
57
|
-
forge_import: "Apply a forge_export JSON back into Studio: recreates the instance tree (properties, sources, attributes) under a parent. dry_run=true only reports. Destructive; max 500 nodes.",
|
|
57
|
+
forge_export: "Export a DataModel subtree as JSON (properties, script sources truncated, attributes). Defaults to workspace, depth 3 (max 6; 10 with RoForge Pro).",
|
|
58
|
+
forge_import: "Apply a forge_export JSON back into Studio: recreates the instance tree (properties, sources, attributes) under a parent. dry_run=true only reports. Destructive; max 500 nodes (2500 with RoForge Pro).",
|
|
59
|
+
forge_pro: "Report the current RoForge Pro entitlement: Free or Pro, which pass/product the Studio user owns, and the active limits + Pro features. Call it to know whether the user has Pro.",
|
|
58
60
|
};
|
|
59
61
|
|
|
60
62
|
const BRIDGE_SCHEMAS = {
|
|
@@ -213,6 +215,7 @@ const BRIDGE_SCHEMAS = {
|
|
|
213
215
|
},
|
|
214
216
|
additionalProperties: false,
|
|
215
217
|
},
|
|
218
|
+
forge_pro: { type: "object", properties: {}, additionalProperties: false },
|
|
216
219
|
};
|
|
217
220
|
|
|
218
221
|
// Tools that modify the DataModel — gated by the approval prompt.
|
package/src/tui/frame.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// LiveRegion — a flicker-free live region at the bottom of the append-scroll
|
|
2
|
+
// output, sized to preserve the terminal scrollback above it (the core trick
|
|
3
|
+
// behind Claude Code's fluid TUI, adapted to an append-scroll layout).
|
|
4
|
+
//
|
|
5
|
+
// Model & cursor invariant:
|
|
6
|
+
// • Committed history scrolls normally above the region (plain append).
|
|
7
|
+
// • The region owns the currently-streaming content: every completed
|
|
8
|
+
// content line is appended (terminal scrolls, nothing is ever lost), and
|
|
9
|
+
// one status line always sits on the last line, rewritten in place.
|
|
10
|
+
// • The cursor is ALWAYS at the end of the status line.
|
|
11
|
+
// • Adding a line = overwrite the status line with the new content line,
|
|
12
|
+
// then write the status on the fresh line below (single write, no
|
|
13
|
+
// intermediate clear → no flicker).
|
|
14
|
+
// • The last (partially streamed) content line is rewritten in place as it
|
|
15
|
+
// grows: up one line, rewrite, back down, rewrite the status.
|
|
16
|
+
// • Committing = release(): the region's lines simply become history; the
|
|
17
|
+
// cursor drops to a fresh line below for the next turn.
|
|
18
|
+
//
|
|
19
|
+
// Long lines are pre-wrapped to the terminal width (segment-aware) so the
|
|
20
|
+
// terminal never auto-wraps and breaks the cursor arithmetic.
|
|
21
|
+
// A terminal resize erases the region; the next update re-renders it.
|
|
22
|
+
|
|
23
|
+
import { COLUMNS } from "./ansi.js";
|
|
24
|
+
|
|
25
|
+
const SGR_RESET = "\x1b[0m";
|
|
26
|
+
function sgrFor(attr) {
|
|
27
|
+
if (!attr) return SGR_RESET;
|
|
28
|
+
const p = [];
|
|
29
|
+
if (attr & 1) p.push("1"); // bold
|
|
30
|
+
if (attr & 2) p.push("2"); // dim
|
|
31
|
+
if (attr & 4) p.push("31"); // red
|
|
32
|
+
if (attr & 8) p.push("32"); // green
|
|
33
|
+
if (attr & 16) p.push("33"); // yellow
|
|
34
|
+
if (attr & 32) p.push("36"); // cyan
|
|
35
|
+
if (attr & 64) p.push("35"); // magenta
|
|
36
|
+
if (attr & 128) p.push("34"); // blue
|
|
37
|
+
return "\x1b[" + p.join(";") + "m";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const ATTR = {
|
|
41
|
+
PLAIN: 0,
|
|
42
|
+
BOLD: 1,
|
|
43
|
+
DIM: 2,
|
|
44
|
+
RED: 4,
|
|
45
|
+
GREEN: 8,
|
|
46
|
+
YELLOW: 16,
|
|
47
|
+
CYAN: 32,
|
|
48
|
+
MAGENTA: 64,
|
|
49
|
+
BLUE: 128,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Word-aware wrap for styled lines.
|
|
54
|
+
* @param {Array<{text: string, attr?: number}>} segments
|
|
55
|
+
* @param {number} width
|
|
56
|
+
* @returns {Array<Array<{text: string, attr?: number}>>} wrapped lines
|
|
57
|
+
*/
|
|
58
|
+
export function wrapSegments(segments, width) {
|
|
59
|
+
width = Math.max(2, width);
|
|
60
|
+
const lines = [[]];
|
|
61
|
+
let col = 0;
|
|
62
|
+
let pendingSpace = 0; // width of whitespace awaiting a word (dropped on wrap)
|
|
63
|
+
const lastLine = () => lines[lines.length - 1];
|
|
64
|
+
for (const seg of segments || []) {
|
|
65
|
+
const text = String(seg.text ?? "");
|
|
66
|
+
const attr = seg.attr ?? 0;
|
|
67
|
+
// pieces alternate: word, whitespace, word, ...
|
|
68
|
+
for (const piece of text.split(/(\s+)/)) {
|
|
69
|
+
if (!piece) continue;
|
|
70
|
+
if (/^\s+$/.test(piece)) {
|
|
71
|
+
// remember; only emitted when a following word lands on this line
|
|
72
|
+
pendingSpace = piece.length;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
let w = piece;
|
|
76
|
+
while (w.length) {
|
|
77
|
+
// a pending space only counts when the word isn't starting a fresh line
|
|
78
|
+
const spaceNeeded = col > 0 && pendingSpace ? pendingSpace : 0;
|
|
79
|
+
const room = width - col - spaceNeeded;
|
|
80
|
+
if (w.length <= room) {
|
|
81
|
+
// whole word fits
|
|
82
|
+
if (spaceNeeded) {
|
|
83
|
+
lastLine().push({ text: " ".repeat(spaceNeeded), attr });
|
|
84
|
+
col += spaceNeeded;
|
|
85
|
+
}
|
|
86
|
+
pendingSpace = 0;
|
|
87
|
+
lastLine().push({ text: w, attr });
|
|
88
|
+
col += w.length;
|
|
89
|
+
w = "";
|
|
90
|
+
} else if (w.length > width) {
|
|
91
|
+
// unbreakable: longer than a full line — hard-break it
|
|
92
|
+
if (col > 0 || spaceNeeded) {
|
|
93
|
+
lines.push([]);
|
|
94
|
+
col = 0;
|
|
95
|
+
}
|
|
96
|
+
pendingSpace = 0;
|
|
97
|
+
lastLine().push({ text: w.slice(0, width - col), attr });
|
|
98
|
+
w = w.slice(width - col);
|
|
99
|
+
lines.push([]);
|
|
100
|
+
col = 0;
|
|
101
|
+
} else {
|
|
102
|
+
// breakable word that doesn't fit the remainder — wrap it whole
|
|
103
|
+
lines.push([]);
|
|
104
|
+
col = 0;
|
|
105
|
+
pendingSpace = 0;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// drop a trailing line that is empty or whitespace-only
|
|
111
|
+
if (lines.length > 1) {
|
|
112
|
+
const tail = lines[lines.length - 1];
|
|
113
|
+
if (!tail.length || tail.every((s) => /^\s*$/.test(s.text))) lines.pop();
|
|
114
|
+
}
|
|
115
|
+
return lines.length ? lines : [[]];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function renderLine(segments) {
|
|
119
|
+
let out = "";
|
|
120
|
+
let state = 0;
|
|
121
|
+
for (const seg of segments || []) {
|
|
122
|
+
const a = seg.attr ?? 0;
|
|
123
|
+
if (a !== state) {
|
|
124
|
+
out += sgrFor(a);
|
|
125
|
+
state = a;
|
|
126
|
+
}
|
|
127
|
+
out += String(seg.text ?? "");
|
|
128
|
+
}
|
|
129
|
+
if (state) out += SGR_RESET;
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export class LiveRegion {
|
|
134
|
+
/**
|
|
135
|
+
* @param {(s: string) => void} emit
|
|
136
|
+
* @param {{ maxRows?: number, enabled?: boolean }} opts
|
|
137
|
+
* maxRows is accepted for API compatibility; the append model keeps every
|
|
138
|
+
* completed line live (terminal scrollback is the cap), so it is a no-op.
|
|
139
|
+
*/
|
|
140
|
+
constructor(emit, { maxRows = 6, enabled = true } = {}) {
|
|
141
|
+
this.emit = emit;
|
|
142
|
+
this.maxRows = maxRows;
|
|
143
|
+
this.enabled = enabled && Boolean(process.stdout.isTTY);
|
|
144
|
+
this.active = false;
|
|
145
|
+
this._rendered = false;
|
|
146
|
+
this._shown = 0; // completed content display lines already written
|
|
147
|
+
this._lastContent = null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
begin(_opts) {
|
|
151
|
+
this.active = true;
|
|
152
|
+
this._rendered = false;
|
|
153
|
+
this._shown = 0;
|
|
154
|
+
this._lastContent = null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
get isActive() {
|
|
158
|
+
return this.active;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Update the region.
|
|
163
|
+
* @param {Array<Array<{text, attr?}>>} contentLines styled logical lines —
|
|
164
|
+
* the full content so far, where the LAST line is the partially streamed
|
|
165
|
+
* line (grows across calls). Earlier lines are complete.
|
|
166
|
+
* @param {Array<{text, attr?}>|null} status styled status line (last row)
|
|
167
|
+
*/
|
|
168
|
+
update(contentLines, status) {
|
|
169
|
+
if (!this.active) return;
|
|
170
|
+
this._lastContent = contentLines;
|
|
171
|
+
const cols = COLUMNS();
|
|
172
|
+
// wrap every logical line into display lines
|
|
173
|
+
const display = [];
|
|
174
|
+
for (const line of contentLines || []) display.push(...wrapSegments(line, cols));
|
|
175
|
+
const statusLine = status ? wrapSegments(status, cols - 1).slice(0, 1)[0] || [] : [];
|
|
176
|
+
const C = display.length;
|
|
177
|
+
|
|
178
|
+
if (!this._rendered) {
|
|
179
|
+
// first render: append everything from the current cursor position.
|
|
180
|
+
// The first line is written at the cursor (col 0 of a fresh line, or
|
|
181
|
+
// right after a sameLine header) — no leading newline.
|
|
182
|
+
const rows = [...display, ...(statusLine.length ? [statusLine] : [])];
|
|
183
|
+
let out = "";
|
|
184
|
+
rows.forEach((r, i) => {
|
|
185
|
+
if (i > 0) out += "\n";
|
|
186
|
+
out += renderLine(r) + "\x1b[K";
|
|
187
|
+
});
|
|
188
|
+
if (out) this.emit(out);
|
|
189
|
+
this._rendered = true;
|
|
190
|
+
this._shown = C;
|
|
191
|
+
this._hadStatus = statusLine.length > 0;
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const newLines = display.slice(this._shown);
|
|
196
|
+
const lastLine = C > 0 ? display[C - 1] : null;
|
|
197
|
+
let out = "";
|
|
198
|
+
if (newLines.length === 0 && C === 0 && !statusLine.length) {
|
|
199
|
+
// nothing to show and nothing stale to clear — but a previously
|
|
200
|
+
// rendered status line must be blanked
|
|
201
|
+
if (this._hadStatus) {
|
|
202
|
+
out = "\x1b[1G\x1b[K";
|
|
203
|
+
this._hadStatus = false;
|
|
204
|
+
}
|
|
205
|
+
if (out) this.emit(out);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (newLines.length > 0) {
|
|
209
|
+
// New completed line(s): the cursor sits at the end of the status line.
|
|
210
|
+
// Overwrite it with the first new line, push the rest below, then write
|
|
211
|
+
// the status on the fresh bottom line. One write, no flicker.
|
|
212
|
+
out += "\x1b[1G";
|
|
213
|
+
for (const nl of newLines) out += renderLine(nl) + "\x1b[K\n";
|
|
214
|
+
if (statusLine.length) out += renderLine(statusLine) + "\x1b[K";
|
|
215
|
+
this._shown = C;
|
|
216
|
+
this._hadStatus = statusLine.length > 0;
|
|
217
|
+
} else if (C > 0 || statusLine.length) {
|
|
218
|
+
// No new lines: the last content line may have grown (or only the
|
|
219
|
+
// status changed). Rewrite the last content line in place, then the
|
|
220
|
+
// status.
|
|
221
|
+
if (C > 0) {
|
|
222
|
+
out += "\x1b[1A\x1b[1G"; // up to the last content line
|
|
223
|
+
out += renderLine(lastLine) + "\x1b[K"; // rewrite it (clear stale tail)
|
|
224
|
+
out += "\n"; // back down to the status line
|
|
225
|
+
} else {
|
|
226
|
+
out += "\x1b[1G";
|
|
227
|
+
}
|
|
228
|
+
out += renderLine(statusLine) + "\x1b[K"; // clears stale status text too
|
|
229
|
+
this._hadStatus = statusLine.length > 0;
|
|
230
|
+
}
|
|
231
|
+
if (out) this.emit(out);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Final update + release; region lines become committed history. */
|
|
235
|
+
end(status) {
|
|
236
|
+
if (!this.active) return;
|
|
237
|
+
if (status) this.update(this._lastContent || [], status);
|
|
238
|
+
this.release();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Release: drop the cursor to a fresh line below; lines become history. */
|
|
242
|
+
release() {
|
|
243
|
+
if (this.active && this._rendered) this.emit("\n");
|
|
244
|
+
this.active = false;
|
|
245
|
+
this._rendered = false;
|
|
246
|
+
this._shown = 0;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Erase the region on terminal resize (stale width would be wrong). */
|
|
250
|
+
eraseOnResize() {
|
|
251
|
+
if (!this.active || !this._rendered) return;
|
|
252
|
+
const h = this._shown + 1; // content lines + status
|
|
253
|
+
const out = ["\x1b[" + Math.max(0, h - 1) + "A", "\x1b[1G"];
|
|
254
|
+
for (let i = 0; i < h; i++) {
|
|
255
|
+
out.push("\x1b[2K");
|
|
256
|
+
if (i < h - 1) out.push("\n");
|
|
257
|
+
}
|
|
258
|
+
// return to the region top so the next update re-renders in place
|
|
259
|
+
out.push("\x1b[" + Math.max(0, h - 1) + "A");
|
|
260
|
+
this.emit(out.join(""));
|
|
261
|
+
this._rendered = false;
|
|
262
|
+
this._shown = 0;
|
|
263
|
+
}
|
|
264
|
+
}
|
package/src/tui/markdown.js
CHANGED
|
@@ -3,7 +3,23 @@
|
|
|
3
3
|
// code-fence state across deltas, and resets cleanly per assistant segment.
|
|
4
4
|
// Deliberately small: headings, bullets, numbered lists, bold, inline code,
|
|
5
5
|
// fences, blockquotes, rules. Everything else passes through untouched.
|
|
6
|
-
|
|
6
|
+
//
|
|
7
|
+
// Two output modes:
|
|
8
|
+
// push()/finish() — ANSI strings (piped / non-TTY path, legacy rendering)
|
|
9
|
+
// pushLines() & co — styled segment lines (Array<{text, attr}>) for the
|
|
10
|
+
// LiveRegion, which wraps them to terminal width
|
|
11
|
+
// itself while preserving per-segment style.
|
|
12
|
+
import { bold, dim, cyan } from "./ansi.js";
|
|
13
|
+
import { ATTR } from "./frame.js";
|
|
14
|
+
|
|
15
|
+
const A = ATTR;
|
|
16
|
+
|
|
17
|
+
// attr → string-mode ANSI helper (respects NO_COLOR / --no-color at call time)
|
|
18
|
+
const STRING_STYLE = {
|
|
19
|
+
[A.BOLD]: bold,
|
|
20
|
+
[A.DIM]: dim,
|
|
21
|
+
[A.CYAN]: cyan,
|
|
22
|
+
};
|
|
7
23
|
|
|
8
24
|
export class MarkdownStream {
|
|
9
25
|
constructor() {
|
|
@@ -11,57 +27,129 @@ export class MarkdownStream {
|
|
|
11
27
|
this.inFence = false;
|
|
12
28
|
}
|
|
13
29
|
|
|
14
|
-
//
|
|
30
|
+
// --- string mode (piped output) -------------------------------------------
|
|
31
|
+
|
|
32
|
+
// Consume a chunk; returns the rendered output for completed lines.
|
|
15
33
|
push(delta) {
|
|
34
|
+
const lines = this.pushLines(delta);
|
|
35
|
+
if (!lines.length) return "";
|
|
36
|
+
return lines.map((segs) => segsToAnsi(segs)).join("\n") + "\n";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Flush the incomplete trailing line (at end of an assistant segment).
|
|
40
|
+
finish() {
|
|
41
|
+
const segs = this.finishLines();
|
|
42
|
+
return segs ? segsToAnsi(segs) + "\n" : "";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// --- segment mode (LiveRegion) --------------------------------------------
|
|
46
|
+
|
|
47
|
+
// Consume a chunk; returns styled lines for every line that COMPLETED in
|
|
48
|
+
// this delta (each line = Array<{text, attr}>). The partial trailing line
|
|
49
|
+
// stays buffered; use partialLines() to render it.
|
|
50
|
+
pushLines(delta) {
|
|
16
51
|
this.buf += String(delta ?? "");
|
|
17
|
-
|
|
52
|
+
const out = [];
|
|
18
53
|
let idx;
|
|
19
54
|
while ((idx = this.buf.indexOf("\n")) !== -1) {
|
|
20
55
|
const line = this.buf.slice(0, idx);
|
|
21
56
|
this.buf = this.buf.slice(idx + 1);
|
|
22
|
-
out
|
|
57
|
+
out.push(this._renderLineSegs(line));
|
|
23
58
|
}
|
|
24
59
|
return out;
|
|
25
60
|
}
|
|
26
61
|
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
if (!this.buf.length) return
|
|
62
|
+
// Styled segments for the currently buffered (incomplete) line, or null.
|
|
63
|
+
partialLines() {
|
|
64
|
+
if (!this.buf.length) return null;
|
|
65
|
+
return this._renderLineSegs(this.buf);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Flush the incomplete trailing line as segments (or null).
|
|
69
|
+
finishLines() {
|
|
70
|
+
if (!this.buf.length) return null;
|
|
30
71
|
const line = this.buf;
|
|
31
72
|
this.buf = "";
|
|
32
|
-
return this.
|
|
73
|
+
return this._renderLineSegs(line);
|
|
33
74
|
}
|
|
34
75
|
|
|
35
|
-
|
|
76
|
+
// --- shared core ------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
_renderLineSegs(line) {
|
|
36
79
|
if (/^\s*(```|~~~)/.test(line)) {
|
|
37
80
|
this.inFence = !this.inFence;
|
|
38
|
-
return
|
|
81
|
+
return [{ text: " " + line.trim(), attr: A.DIM }];
|
|
39
82
|
}
|
|
40
83
|
if (this.inFence) {
|
|
41
|
-
return
|
|
84
|
+
return [{ text: " " + line, attr: A.DIM }];
|
|
42
85
|
}
|
|
43
|
-
return this.
|
|
86
|
+
return this._renderPlainSegs(line);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
_pushSeg(line, text, attr) {
|
|
90
|
+
if (!text) return;
|
|
91
|
+
const last = line[line.length - 1];
|
|
92
|
+
if (last && last.attr === attr) last.text += text;
|
|
93
|
+
else line.push({ text, attr: attr || 0 });
|
|
44
94
|
}
|
|
45
95
|
|
|
46
|
-
|
|
96
|
+
_renderPlainSegs(line) {
|
|
47
97
|
const h = line.match(/^(#{1,4})\s+(.*)$/);
|
|
48
|
-
if (h)
|
|
98
|
+
if (h) {
|
|
99
|
+
const out = [{ text: h[1] + " ", attr: A.BOLD }];
|
|
100
|
+
for (const seg of this._inlineSegs(h[2])) {
|
|
101
|
+
// heading text is bold by default; code inside keeps its own style
|
|
102
|
+
this._pushSeg(out, seg.text, seg.attr === A.CYAN ? seg.attr : A.BOLD);
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
49
106
|
if (/^\s*([-*+])\s+/.test(line)) {
|
|
50
|
-
|
|
107
|
+
const out = [{ text: "• ", attr: A.CYAN }];
|
|
108
|
+
for (const seg of this._inlineSegs(line.replace(/^\s*[-*+]\s+/, ""))) this._pushSeg(out, seg.text, seg.attr);
|
|
109
|
+
return out;
|
|
51
110
|
}
|
|
52
111
|
const num = line.match(/^\s*(\d+)[.)]\s+(.*)$/);
|
|
53
|
-
if (num)
|
|
112
|
+
if (num) {
|
|
113
|
+
const out = [{ text: num[1] + ". ", attr: A.DIM }];
|
|
114
|
+
for (const seg of this._inlineSegs(num[2])) this._pushSeg(out, seg.text, seg.attr);
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
54
117
|
if (/^\s*>\s?/.test(line)) {
|
|
55
|
-
|
|
118
|
+
const out = [{ text: "│ ", attr: A.DIM }];
|
|
119
|
+
for (const seg of this._inlineSegs(line.replace(/^\s*>\s?/, ""))) this._pushSeg(out, seg.text, A.DIM);
|
|
120
|
+
return out;
|
|
56
121
|
}
|
|
57
|
-
if (/^\s*([-*_])\1{2,}\s*$/.test(line))
|
|
58
|
-
|
|
59
|
-
|
|
122
|
+
if (/^\s*([-*_])\1{2,}\s*$/.test(line)) {
|
|
123
|
+
return [{ text: "────────────────────────────────", attr: A.DIM }];
|
|
124
|
+
}
|
|
125
|
+
if (!line.trim()) return [];
|
|
126
|
+
return this._inlineSegs(line);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Split inline text into plain / code / bold segments.
|
|
130
|
+
_inlineSegs(s) {
|
|
131
|
+
const out = [];
|
|
132
|
+
// tokenize: `code`, **bold**, and plain runs
|
|
133
|
+
const re = /(`[^`]+`|\*\*[^*]+\*\*)/g;
|
|
134
|
+
let last = 0;
|
|
135
|
+
let m;
|
|
136
|
+
while ((m = re.exec(s)) !== null) {
|
|
137
|
+
if (m.index > last) this._pushSeg(out, s.slice(last, m.index), A.PLAIN);
|
|
138
|
+
const tok = m[0];
|
|
139
|
+
if (tok.startsWith("`")) this._pushSeg(out, tok.slice(1, -1), A.CYAN);
|
|
140
|
+
else this._pushSeg(out, tok.slice(2, -2), A.BOLD);
|
|
141
|
+
last = m.index + tok.length;
|
|
142
|
+
}
|
|
143
|
+
if (last < s.length) this._pushSeg(out, s.slice(last), A.PLAIN);
|
|
144
|
+
return out;
|
|
60
145
|
}
|
|
146
|
+
}
|
|
61
147
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
148
|
+
export function segsToAnsi(segs) {
|
|
149
|
+
let out = "";
|
|
150
|
+
for (const seg of segs || []) {
|
|
151
|
+
const style = STRING_STYLE[seg.attr] || ((t) => t);
|
|
152
|
+
out += style(seg.text);
|
|
66
153
|
}
|
|
154
|
+
return out;
|
|
67
155
|
}
|
package/src/tui/tui.js
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
// RoForge TUI — Claude-Code-style interactive terminal session.
|
|
2
|
-
// Append-style rendering (terminal scrollback preserved)
|
|
3
|
-
//
|
|
2
|
+
// Append-style rendering (terminal scrollback preserved).
|
|
3
|
+
//
|
|
4
|
+
// Two render paths, picked once at startup:
|
|
5
|
+
// • LIVE (stdout is a TTY): the streaming block (markdown + status line) is
|
|
6
|
+
// a LiveRegion — rewritten in place every frame, zero flicker, history
|
|
7
|
+
// committed above it. See frame.js.
|
|
8
|
+
// • LEGACY (piped output / tests): plain append + \r-spinner, exactly the
|
|
9
|
+
// pre-LiveRegion behavior.
|
|
4
10
|
import { createRequire } from "node:module";
|
|
5
11
|
import { bold, dim, red, green, yellow, cyan, magenta, gray, wrap, SPINNER_FRAMES, CLEAR_LINE } from "./ansi.js";
|
|
6
12
|
import { parseModelRef, PROVIDERS } from "../config.js";
|
|
7
|
-
import { MarkdownStream } from "./markdown.js";
|
|
13
|
+
import { MarkdownStream, segsToAnsi } from "./markdown.js";
|
|
14
|
+
import { LiveRegion, ATTR } from "./frame.js";
|
|
8
15
|
|
|
9
16
|
const VERSION = (() => {
|
|
10
17
|
try {
|
|
@@ -22,7 +29,7 @@ for (const p of Object.values(PROVIDERS)) {
|
|
|
22
29
|
}
|
|
23
30
|
|
|
24
31
|
export class TUI {
|
|
25
|
-
constructor(session, { out = process.stdout, err = process.stderr } = {}) {
|
|
32
|
+
constructor(session, { out = process.stdout, err = process.stderr, live } = {}) {
|
|
26
33
|
this.session = session;
|
|
27
34
|
this.out = out;
|
|
28
35
|
this.err = err;
|
|
@@ -38,7 +45,16 @@ export class TUI {
|
|
|
38
45
|
this.spinnerVisible = false;
|
|
39
46
|
this.ctrlCTime = 0;
|
|
40
47
|
this.running = false;
|
|
48
|
+
|
|
49
|
+
// LiveRegion (live path). `live` overrides detection (tests).
|
|
50
|
+
this.live = new LiveRegion((s) => this.out.write(s), { maxRows: 6 });
|
|
51
|
+
this._liveOK =
|
|
52
|
+
live === undefined ? Boolean(process.stdout.isTTY) && this.out === process.stdout : live;
|
|
41
53
|
this._md = null; // active MarkdownStream for the current assistant segment
|
|
54
|
+
this._segLines = []; // completed styled lines for the current segment
|
|
55
|
+
this._statusLabel = "thinking…";
|
|
56
|
+
this._costStatus = null; // final cost line for this turn (plain text)
|
|
57
|
+
this._lastLiveSig = null;
|
|
42
58
|
this._toolOutputs = []; // recent tool outputs, expandable via /out
|
|
43
59
|
this._toolOutSeq = 0;
|
|
44
60
|
}
|
|
@@ -85,6 +101,8 @@ export class TUI {
|
|
|
85
101
|
// Ctrl+C
|
|
86
102
|
if (this.busy) {
|
|
87
103
|
this.session.abort();
|
|
104
|
+
this._stopSpinner();
|
|
105
|
+
this._liveCommit();
|
|
88
106
|
this.out.write("\r\n" + yellow("aborted — type a new message or /exit\n"));
|
|
89
107
|
continue;
|
|
90
108
|
}
|
|
@@ -220,7 +238,7 @@ export class TUI {
|
|
|
220
238
|
this.out.write(`model → ${this.session.cfg._activeModel} (${this.session.providerName}${free})\n`);
|
|
221
239
|
if (!ref && !KNOWN_MODELS.has(arg)) {
|
|
222
240
|
this.out.write(
|
|
223
|
-
dim(` (unrecognized model name — double-check the spelling, or pin explicitly: /model provider:model, e.g. /model gemini:
|
|
241
|
+
dim(` (unrecognized model name — double-check the spelling, or pin explicitly: /model provider:model, e.g. /model gemini:2.5-flash)\n`)
|
|
224
242
|
);
|
|
225
243
|
}
|
|
226
244
|
} else {
|
|
@@ -299,12 +317,18 @@ export class TUI {
|
|
|
299
317
|
async _runTurn(text) {
|
|
300
318
|
this.out.write(dim("you> ") + text + "\n");
|
|
301
319
|
this.busy = true;
|
|
302
|
-
this.
|
|
320
|
+
this._resetSegment();
|
|
321
|
+
this._costStatus = null;
|
|
322
|
+
this._lastLiveSig = null;
|
|
303
323
|
try {
|
|
304
324
|
await this.session.send(text);
|
|
305
325
|
} catch (e) {
|
|
326
|
+
this._stopSpinner();
|
|
327
|
+
this._liveCommit();
|
|
306
328
|
this.out.write(red(`error: ${e.message || e}`) + "\n");
|
|
307
329
|
}
|
|
330
|
+
// commit the live block with the final cost line as its status
|
|
331
|
+
this._liveCommit(this._costStatus ? [{ text: this._costStatus, attr: ATTR.DIM }] : null);
|
|
308
332
|
this.busy = false;
|
|
309
333
|
this._printPrompt();
|
|
310
334
|
}
|
|
@@ -314,17 +338,92 @@ export class TUI {
|
|
|
314
338
|
this.out.write("> ");
|
|
315
339
|
}
|
|
316
340
|
|
|
341
|
+
// ---------------- live-region plumbing ----------------
|
|
342
|
+
|
|
343
|
+
// Region content for the current assistant segment: completed lines + the
|
|
344
|
+
// partial line, with the magenta "RoForge> " header on the first
|
|
345
|
+
// non-empty line (leading blank lines from the model stay blank).
|
|
346
|
+
_regionLines() {
|
|
347
|
+
const lines = [...this._segLines];
|
|
348
|
+
const partial = this._md ? this._md.partialLines() : null;
|
|
349
|
+
if (partial) lines.push(partial);
|
|
350
|
+
const first = lines.findIndex((l) => l && l.some((s) => s.text));
|
|
351
|
+
if (first === -1) return [];
|
|
352
|
+
lines[first] = [{ text: "RoForge> ", attr: ATTR.MAGENTA }, ...lines[first]];
|
|
353
|
+
return lines;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
_statusSegs() {
|
|
357
|
+
if (this._costStatus) return [{ text: this._costStatus, attr: ATTR.DIM }];
|
|
358
|
+
const frame = SPINNER_FRAMES[this.spinnerFrame];
|
|
359
|
+
return [{ text: frame + " " + this._statusLabel, attr: ATTR.DIM }];
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Redraw the live region (no-op when nothing changed since the last frame).
|
|
363
|
+
_liveRefresh() {
|
|
364
|
+
if (!this._liveOK || !this.live.isActive) return;
|
|
365
|
+
const lines = this._regionLines();
|
|
366
|
+
const status = this._statusSegs();
|
|
367
|
+
const last = lines.length ? lines[lines.length - 1] : null;
|
|
368
|
+
const sig =
|
|
369
|
+
lines.length +
|
|
370
|
+
":" +
|
|
371
|
+
(last ? last.map((s) => s.text).join("") : "") +
|
|
372
|
+
"|" +
|
|
373
|
+
status.map((s) => s.text).join("");
|
|
374
|
+
if (sig === this._lastLiveSig) return;
|
|
375
|
+
this._lastLiveSig = sig;
|
|
376
|
+
this.live.update(lines, status);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Commit the live block to history. statusSegs = final status line, or null
|
|
380
|
+
// to commit with a blank one (acts as a separator).
|
|
381
|
+
_liveCommit(statusSegs = null) {
|
|
382
|
+
if (!this._liveOK || !this.live.isActive) return;
|
|
383
|
+
this._finalizeMd();
|
|
384
|
+
this.live.update(this._regionLines(), statusSegs || []);
|
|
385
|
+
this.live.release();
|
|
386
|
+
this._lastLiveSig = null;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
_finalizeMd() {
|
|
390
|
+
if (this._md) {
|
|
391
|
+
const tail = this._md.finishLines();
|
|
392
|
+
if (tail) this._segLines.push(tail);
|
|
393
|
+
this._md = null;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Start a fresh assistant segment (clears streamed content + markdown).
|
|
398
|
+
// The "running tool" region and gaps between segments show no content, so
|
|
399
|
+
// the stale block never re-renders in a new region.
|
|
400
|
+
_resetSegment() {
|
|
401
|
+
this._md = null;
|
|
402
|
+
this._segLines = [];
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ---------------- spinner ----------------
|
|
406
|
+
|
|
317
407
|
_startSpinner(label = "thinking…") {
|
|
318
|
-
if (!process.stdout.isTTY) return;
|
|
319
408
|
this._stopSpinner();
|
|
320
|
-
this.
|
|
321
|
-
this.
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
409
|
+
this._statusLabel = label;
|
|
410
|
+
if (this._liveOK) {
|
|
411
|
+
this.spinnerTimer = setInterval(() => {
|
|
412
|
+
this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length;
|
|
413
|
+
if (this.live.isActive) this._liveRefresh();
|
|
414
|
+
}, 90);
|
|
415
|
+
this.spinnerTimer.unref && this.spinnerTimer.unref();
|
|
416
|
+
this._liveRefresh();
|
|
417
|
+
} else if (process.stdout.isTTY) {
|
|
418
|
+
this.spinnerVisible = true;
|
|
419
|
+
this.out.write(label);
|
|
420
|
+
this.spinnerTimer = setInterval(() => {
|
|
421
|
+
this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length;
|
|
422
|
+
const len = label.length + 3;
|
|
423
|
+
this.out.write("\r" + CLEAR_LINE + this.spinnerFrame + " " + label.slice(0, Math.max(0, len - 2)));
|
|
424
|
+
}, 90);
|
|
425
|
+
this.spinnerTimer.unref && this.spinnerTimer.unref();
|
|
426
|
+
}
|
|
328
427
|
}
|
|
329
428
|
|
|
330
429
|
_stopSpinner() {
|
|
@@ -340,32 +439,42 @@ export class TUI {
|
|
|
340
439
|
|
|
341
440
|
// ---------------- ui event sink (Session) ----------------
|
|
342
441
|
|
|
343
|
-
_flushMd() {
|
|
344
|
-
if (this._md) {
|
|
345
|
-
const tail = this._md.finish();
|
|
346
|
-
if (tail) this.out.write(tail);
|
|
347
|
-
this._md = null;
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
|
|
351
442
|
onText(delta) {
|
|
352
443
|
this._stopSpinner();
|
|
353
|
-
if (!this._assistantHeaderShown) {
|
|
354
|
-
this.out.write(magenta("RoForge> ") );
|
|
355
|
-
this._assistantHeaderShown = true;
|
|
356
|
-
}
|
|
357
444
|
if (!this._md) this._md = new MarkdownStream();
|
|
358
|
-
const
|
|
359
|
-
|
|
445
|
+
const newLines = this._md.pushLines(delta);
|
|
446
|
+
for (const l of newLines) this._segLines.push(l);
|
|
447
|
+
if (this._liveOK) {
|
|
448
|
+
if (!this.live.isActive) this.live.begin();
|
|
449
|
+
this._liveRefresh();
|
|
450
|
+
} else {
|
|
451
|
+
if (!this._assistantHeaderShown) {
|
|
452
|
+
this.out.write(magenta("RoForge> "));
|
|
453
|
+
this._assistantHeaderShown = true;
|
|
454
|
+
}
|
|
455
|
+
if (newLines.length) this.out.write(newLines.map(segsToAnsi).join("\n") + "\n");
|
|
456
|
+
}
|
|
360
457
|
}
|
|
361
458
|
|
|
362
459
|
onAssistantDone() {
|
|
363
|
-
this.
|
|
460
|
+
if (this._md) {
|
|
461
|
+
const tail = this._md.finishLines();
|
|
462
|
+
this._md = null;
|
|
463
|
+
if (tail) {
|
|
464
|
+
if (this._liveOK) {
|
|
465
|
+
this._segLines.push(tail);
|
|
466
|
+
this._liveRefresh();
|
|
467
|
+
} else {
|
|
468
|
+
this.out.write(segsToAnsi(tail) + "\n");
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
364
472
|
}
|
|
365
473
|
|
|
366
474
|
onToolStart(tool, args) {
|
|
367
475
|
this._stopSpinner();
|
|
368
|
-
this.
|
|
476
|
+
this._liveCommit(); // assistant block → history (blank separator)
|
|
477
|
+
this._resetSegment(); // running region shows status only, no content
|
|
369
478
|
let argsStr;
|
|
370
479
|
try {
|
|
371
480
|
argsStr = JSON.stringify(args || {});
|
|
@@ -377,7 +486,8 @@ export class TUI {
|
|
|
377
486
|
this._toolOutputs.push({ id: this._toolOutSeq, tool: tool.name, args: argsStr, full: "" });
|
|
378
487
|
if (this._toolOutputs.length > 30) this._toolOutputs.shift();
|
|
379
488
|
this.out.write(dim(` ⚙ [${this._toolOutSeq}] ${tool.name}(${argsStr})`) + "\n");
|
|
380
|
-
this.
|
|
489
|
+
if (this._liveOK) this.live.begin();
|
|
490
|
+
this._startSpinner("running " + tool.name + "…");
|
|
381
491
|
}
|
|
382
492
|
|
|
383
493
|
onToolEnd(tool, args, result) {
|
|
@@ -385,6 +495,8 @@ export class TUI {
|
|
|
385
495
|
const r = String(result || "");
|
|
386
496
|
const last = this._toolOutputs[this._toolOutputs.length - 1];
|
|
387
497
|
if (last && last.tool === tool.name) last.full = r;
|
|
498
|
+
this._liveCommit(); // "running…" block → history
|
|
499
|
+
this._resetSegment(); // next assistant text starts a fresh segment
|
|
388
500
|
const first = r.split("\n")[0].slice(0, 120);
|
|
389
501
|
const more = (r.length > 120 || r.includes("\n")) && last ? dim(` (more: /out ${last.id})`) : "";
|
|
390
502
|
if (r.startsWith("ERROR")) {
|
|
@@ -392,27 +504,45 @@ export class TUI {
|
|
|
392
504
|
} else {
|
|
393
505
|
this.out.write(dim(` ↳ ${first}`) + more + "\n");
|
|
394
506
|
}
|
|
507
|
+
if (this._liveOK) this.live.begin();
|
|
395
508
|
this._startSpinner("thinking…");
|
|
396
509
|
}
|
|
397
510
|
|
|
398
511
|
onInfo(msg) {
|
|
399
512
|
this._stopSpinner();
|
|
513
|
+
this._liveCommit();
|
|
400
514
|
this.out.write(gray(msg) + "\n");
|
|
401
515
|
}
|
|
402
516
|
|
|
403
517
|
onWarn(msg) {
|
|
404
518
|
this._stopSpinner();
|
|
519
|
+
this._liveCommit();
|
|
405
520
|
this.out.write(red(msg) + "\n");
|
|
406
521
|
}
|
|
407
522
|
|
|
408
523
|
onStatus(msg) {
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
524
|
+
const m = String(msg);
|
|
525
|
+
if (m.includes("tok")) {
|
|
526
|
+
// per-turn cost footer — becomes the live block's final status line
|
|
527
|
+
this._costStatus = m;
|
|
528
|
+
if (this._liveOK && this.live.isActive) {
|
|
529
|
+
this._stopSpinner();
|
|
530
|
+
this._liveRefresh();
|
|
531
|
+
} else if (!this._liveOK) {
|
|
532
|
+
this.out.write("\n" + gray(m) + "\n");
|
|
533
|
+
}
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
// "thinking… (step n/total)" / "done" → live status label
|
|
537
|
+
if (this._liveOK && this.live.isActive) {
|
|
538
|
+
this._statusLabel = m === "done" ? "finishing…" : m;
|
|
539
|
+
this._liveRefresh();
|
|
540
|
+
}
|
|
412
541
|
}
|
|
413
542
|
|
|
414
543
|
async promptApproval(name, args) {
|
|
415
544
|
this._stopSpinner();
|
|
545
|
+
this._liveCommit();
|
|
416
546
|
let target = "";
|
|
417
547
|
try {
|
|
418
548
|
target = JSON.stringify(args || {});
|
|
@@ -421,7 +551,9 @@ export class TUI {
|
|
|
421
551
|
}
|
|
422
552
|
if (target.length > 100) target = target.slice(0, 97) + "…";
|
|
423
553
|
this.out.write(yellow(` ✋ approve ${name}(${target})? `) + dim("[y]es / [n]o / [a]lways "));
|
|
424
|
-
|
|
554
|
+
const ans = await this._readChar();
|
|
555
|
+
this.out.write("\n"); // next output starts on a fresh line
|
|
556
|
+
return ans;
|
|
425
557
|
}
|
|
426
558
|
|
|
427
559
|
_readChar() {
|
|
@@ -476,6 +608,15 @@ export class TUI {
|
|
|
476
608
|
if (process.stdin.isTTY) {
|
|
477
609
|
this._setRaw(true);
|
|
478
610
|
process.stdin.on("data", (c) => this._onData(c));
|
|
611
|
+
if (this._liveOK) {
|
|
612
|
+
this._onResize = () => {
|
|
613
|
+
if (this.live.isActive) {
|
|
614
|
+
this.live.eraseOnResize();
|
|
615
|
+
this._lastLiveSig = null;
|
|
616
|
+
}
|
|
617
|
+
};
|
|
618
|
+
process.stdout.on("resize", this._onResize);
|
|
619
|
+
}
|
|
479
620
|
this._printPrompt();
|
|
480
621
|
return true;
|
|
481
622
|
}
|
|
@@ -487,6 +628,11 @@ export class TUI {
|
|
|
487
628
|
stop() {
|
|
488
629
|
this.running = false;
|
|
489
630
|
this._stopSpinner();
|
|
631
|
+
this._liveCommit();
|
|
632
|
+
if (this._onResize) {
|
|
633
|
+
process.stdout.removeListener("resize", this._onResize);
|
|
634
|
+
this._onResize = null;
|
|
635
|
+
}
|
|
490
636
|
this._setRaw(false);
|
|
491
637
|
process.stdin.removeAllListeners("data");
|
|
492
638
|
}
|