roforge-cli 0.3.0 → 0.3.2
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.md +4 -6
- package/bin/roforge.js +26 -4
- package/package.json +2 -2
- package/src/config.js +47 -7
- package/src/providers/openai.js +40 -2
- package/src/session.js +12 -0
- package/src/tui/frame.js +264 -0
- package/src/tui/markdown.js +112 -24
- package/src/tui/tui.js +212 -36
package/README.md
CHANGED
|
@@ -14,15 +14,13 @@ roforge analyze <file...> official Luau analyzer
|
|
|
14
14
|
## Install
|
|
15
15
|
|
|
16
16
|
```bash
|
|
17
|
-
|
|
18
|
-
npm config set @hacvilke:registry https://npm.pkg.github.com
|
|
19
|
-
echo "//npm.pkg.github.com/:_authToken=YOUR_GITHUB_TOKEN" >> ~/.npmrc
|
|
20
|
-
npm i -g @hacvilke/roforge-cli
|
|
21
|
-
|
|
22
|
-
# …or clone the repo and run `node cli/bin/roforge.js`
|
|
17
|
+
npm i -g roforge-cli
|
|
23
18
|
roforge
|
|
24
19
|
```
|
|
25
20
|
|
|
21
|
+
Also on **GitHub Packages** as `@hacvilke/roforge-cli` (any GitHub token with
|
|
22
|
+
`read:packages` works), or clone the repo and run `node cli/bin/roforge.js`.
|
|
23
|
+
|
|
26
24
|
Requires Node ≥ 18.17. No other dependencies.
|
|
27
25
|
|
|
28
26
|
## Free tiers (no card needed)
|
package/bin/roforge.js
CHANGED
|
@@ -84,12 +84,26 @@ async function main() {
|
|
|
84
84
|
const promptKey = async (label) => {
|
|
85
85
|
process.stderr.write(`Paste ${label} (input hidden): `);
|
|
86
86
|
let val = "";
|
|
87
|
-
|
|
88
|
-
|
|
87
|
+
// raw mode hides input; legacy Windows consoles may not support it —
|
|
88
|
+
// fall back to a visible paste instead of crashing
|
|
89
|
+
let raw = false;
|
|
90
|
+
if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
|
|
91
|
+
try {
|
|
92
|
+
process.stdin.setRawMode(true);
|
|
93
|
+
raw = true;
|
|
94
|
+
} catch {
|
|
95
|
+
raw = false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (raw) {
|
|
89
99
|
for await (const chunk of process.stdin) {
|
|
90
100
|
for (const ch of String(chunk)) {
|
|
91
101
|
if (ch === "\r" || ch === "\n" || ch === "\x04") {
|
|
92
|
-
|
|
102
|
+
try {
|
|
103
|
+
process.stdin.setRawMode(false);
|
|
104
|
+
} catch {
|
|
105
|
+
/* ignore */
|
|
106
|
+
}
|
|
93
107
|
console.error("");
|
|
94
108
|
return val;
|
|
95
109
|
}
|
|
@@ -98,6 +112,7 @@ async function main() {
|
|
|
98
112
|
}
|
|
99
113
|
}
|
|
100
114
|
} else {
|
|
115
|
+
process.stderr.write(dim("(raw input unsupported — paste the key and press Enter)\n"));
|
|
101
116
|
for await (const chunk of process.stdin) val += String(chunk);
|
|
102
117
|
return val.trim();
|
|
103
118
|
}
|
|
@@ -336,7 +351,14 @@ async function oneShot(cfg, prompt) {
|
|
|
336
351
|
},
|
|
337
352
|
});
|
|
338
353
|
await session.init();
|
|
339
|
-
|
|
354
|
+
let out;
|
|
355
|
+
try {
|
|
356
|
+
out = await session.send(prompt);
|
|
357
|
+
} catch (e) {
|
|
358
|
+
console.error(red(`error: ${e.message || e}`) + "\n");
|
|
359
|
+
bridge.stop();
|
|
360
|
+
process.exit(1);
|
|
361
|
+
}
|
|
340
362
|
process.stdout.write("\n");
|
|
341
363
|
bridge.stop();
|
|
342
364
|
process.exit(out.ok ? 0 : 1);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "roforge-cli",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"description": "RoForge
|
|
3
|
+
"version": "0.3.2",
|
|
4
|
+
"description": "RoForge \u2014 Claude-Code-style local AI agent for Roblox Studio. BYOK, zero backend, zero dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"roforge": "bin/roforge.js"
|
package/src/config.js
CHANGED
|
@@ -8,8 +8,16 @@ import os from "node:os";
|
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import { randomToken } from "./util.js";
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
export
|
|
11
|
+
// Resolved lazily so ROFORGE_CONFIG_DIR changes (tests, late env) are honored.
|
|
12
|
+
export function configDir() {
|
|
13
|
+
return process.env.ROFORGE_CONFIG_DIR || path.join(os.homedir(), ".roforge");
|
|
14
|
+
}
|
|
15
|
+
export function configFile() {
|
|
16
|
+
return path.join(configDir(), "config.json");
|
|
17
|
+
}
|
|
18
|
+
// (static snapshots — display only; read/write go through configFile())
|
|
19
|
+
export const CONFIG_DIR = configDir();
|
|
20
|
+
export const CONFIG_FILE = configFile();
|
|
13
21
|
|
|
14
22
|
// Provider registry. "auto" (default) picks the first configured provider,
|
|
15
23
|
// free-tier providers first (gemini → groq → openrouter → anthropic → openai).
|
|
@@ -38,7 +46,10 @@ export const PROVIDERS = {
|
|
|
38
46
|
env: "OPENROUTER_API_KEY",
|
|
39
47
|
baseField: "openrouterBaseUrl",
|
|
40
48
|
defaultModel: "qwen/qwen3-coder",
|
|
41
|
-
|
|
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",
|
|
42
53
|
hasFreeTier: true,
|
|
43
54
|
},
|
|
44
55
|
anthropic: {
|
|
@@ -106,13 +117,14 @@ const DEFAULTS = {
|
|
|
106
117
|
"gpt-4o": { input: 2.5, output: 10 },
|
|
107
118
|
"gemini-2.5-flash": { input: 0, output: 0 },
|
|
108
119
|
"llama-3.3-70b-versatile": { input: 0, output: 0 },
|
|
109
|
-
"
|
|
120
|
+
"nvidia/nemotron-3-super-120b-a12b:free": { input: 0, output: 0 },
|
|
121
|
+
"nvidia/nemotron-3-ultra-550b-a55b:free": { input: 0, output: 0 },
|
|
110
122
|
},
|
|
111
123
|
};
|
|
112
124
|
|
|
113
125
|
export function loadFileConfig() {
|
|
114
126
|
try {
|
|
115
|
-
return JSON.parse(fs.readFileSync(
|
|
127
|
+
return JSON.parse(fs.readFileSync(configFile(), "utf8"));
|
|
116
128
|
} catch {
|
|
117
129
|
return {};
|
|
118
130
|
}
|
|
@@ -121,8 +133,8 @@ export function loadFileConfig() {
|
|
|
121
133
|
export function saveFileConfig(patch) {
|
|
122
134
|
const current = loadFileConfig();
|
|
123
135
|
const next = deepMerge(current, patch);
|
|
124
|
-
fs.mkdirSync(
|
|
125
|
-
fs.writeFileSync(
|
|
136
|
+
fs.mkdirSync(configDir(), { recursive: true });
|
|
137
|
+
fs.writeFileSync(configFile(), JSON.stringify(next, null, 2));
|
|
126
138
|
return next;
|
|
127
139
|
}
|
|
128
140
|
|
|
@@ -134,11 +146,39 @@ function deepMerge(a, b) {
|
|
|
134
146
|
return out;
|
|
135
147
|
}
|
|
136
148
|
|
|
149
|
+
// Lenient import: env-var-style keys (GEMINI_API_KEY, OPENROUTER_API_KEY, …)
|
|
150
|
+
// accepted from ANYWHERE in the config file (top-level or nested, e.g. under
|
|
151
|
+
// "bridge"), mapped onto the canonical key fields.
|
|
152
|
+
const ENV_STYLE_KEY_FIELDS = {
|
|
153
|
+
GEMINI_API_KEY: "geminiKey",
|
|
154
|
+
GROQ_API_KEY: "groqKey",
|
|
155
|
+
OPENROUTER_API_KEY: "openrouterKey",
|
|
156
|
+
ANTHROPIC_API_KEY: "anthropicKey",
|
|
157
|
+
OPENAI_API_KEY: "openaiKey",
|
|
158
|
+
};
|
|
159
|
+
function collectEnvStyleKeys(node, out) {
|
|
160
|
+
if (!node || typeof node !== "object") return out;
|
|
161
|
+
for (const [k, v] of Object.entries(node)) {
|
|
162
|
+
if (typeof v === "string" && v && ENV_STYLE_KEY_FIELDS[k] && !out[ENV_STYLE_KEY_FIELDS[k]]) {
|
|
163
|
+
out[ENV_STYLE_KEY_FIELDS[k]] = v;
|
|
164
|
+
} else if (v && typeof v === "object") {
|
|
165
|
+
collectEnvStyleKeys(v, out);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
|
|
137
171
|
// Resolved, runtime config (env overrides applied, defaults filled).
|
|
138
172
|
export function resolveConfig() {
|
|
139
173
|
const file = loadFileConfig();
|
|
140
174
|
const cfg = deepMerge(DEFAULTS, file);
|
|
141
175
|
|
|
176
|
+
// env-var-style keys anywhere in the file (e.g. {"bridge": {"OPENROUTER_API_KEY": "…"}})
|
|
177
|
+
const envStyle = collectEnvStyleKeys(file, {});
|
|
178
|
+
for (const [field, val] of Object.entries(envStyle)) {
|
|
179
|
+
if (!cfg[field]) cfg[field] = val;
|
|
180
|
+
}
|
|
181
|
+
|
|
142
182
|
cfg.geminiKey = process.env.GEMINI_API_KEY || cfg.geminiKey || "";
|
|
143
183
|
cfg.groqKey = process.env.GROQ_API_KEY || cfg.groqKey || "";
|
|
144
184
|
cfg.openrouterKey = process.env.OPENROUTER_API_KEY || cfg.openrouterKey || "";
|
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/session.js
CHANGED
|
@@ -32,6 +32,9 @@ export class Session {
|
|
|
32
32
|
return effectiveProvider(this.cfg) || this.cfg.provider || "auto";
|
|
33
33
|
}
|
|
34
34
|
get provider() {
|
|
35
|
+
// "auto" with no configured keys → null (caller shows a friendly
|
|
36
|
+
// "no API key" error instead of silently hitting Anthropic).
|
|
37
|
+
if (this.providerName === "auto" && !effectiveProvider(this.cfg)) return null;
|
|
35
38
|
return PROVIDER_MODULES[this.providerName] || Anthropic;
|
|
36
39
|
}
|
|
37
40
|
get model() {
|
|
@@ -80,6 +83,15 @@ Current state:
|
|
|
80
83
|
}
|
|
81
84
|
|
|
82
85
|
async send(userText) {
|
|
86
|
+
if (!this.provider) {
|
|
87
|
+
const prov = this.cfg.provider && this.cfg.provider !== "auto" ? this.cfg.provider : "auto";
|
|
88
|
+
throw new Error(
|
|
89
|
+
`No API key found for ${prov === "auto" ? "any provider" : prov}. ` +
|
|
90
|
+
"Run `roforge login --provider <gemini|groq|openrouter|anthropic|openai>` " +
|
|
91
|
+
"(free keys: aistudio.google.com, console.groq.com, openrouter.ai) or set the " +
|
|
92
|
+
"matching *_API_KEY env var / key in your config."
|
|
93
|
+
);
|
|
94
|
+
}
|
|
83
95
|
this.turns++;
|
|
84
96
|
this.aborted = false;
|
|
85
97
|
this._controller = new AbortController();
|
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,14 +1,35 @@
|
|
|
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.
|
|
10
|
+
import { createRequire } from "node:module";
|
|
4
11
|
import { bold, dim, red, green, yellow, cyan, magenta, gray, wrap, SPINNER_FRAMES, CLEAR_LINE } from "./ansi.js";
|
|
5
12
|
import { parseModelRef, PROVIDERS } from "../config.js";
|
|
6
|
-
import { MarkdownStream } from "./markdown.js";
|
|
13
|
+
import { MarkdownStream, segsToAnsi } from "./markdown.js";
|
|
14
|
+
import { LiveRegion, ATTR } from "./frame.js";
|
|
15
|
+
|
|
16
|
+
const VERSION = (() => {
|
|
17
|
+
try {
|
|
18
|
+
return createRequire(import.meta.url)("../../package.json").version;
|
|
19
|
+
} catch {
|
|
20
|
+
return "dev";
|
|
21
|
+
}
|
|
22
|
+
})();
|
|
7
23
|
|
|
8
|
-
|
|
24
|
+
// Known model names (for a soft /model warning; anything else is allowed).
|
|
25
|
+
const KNOWN_MODELS = new Set();
|
|
26
|
+
for (const p of Object.values(PROVIDERS)) {
|
|
27
|
+
if (p.defaultModel) KNOWN_MODELS.add(p.defaultModel);
|
|
28
|
+
if (p.freeModel) KNOWN_MODELS.add(p.freeModel);
|
|
29
|
+
}
|
|
9
30
|
|
|
10
31
|
export class TUI {
|
|
11
|
-
constructor(session, { out = process.stdout, err = process.stderr } = {}) {
|
|
32
|
+
constructor(session, { out = process.stdout, err = process.stderr, live } = {}) {
|
|
12
33
|
this.session = session;
|
|
13
34
|
this.out = out;
|
|
14
35
|
this.err = err;
|
|
@@ -24,7 +45,16 @@ export class TUI {
|
|
|
24
45
|
this.spinnerVisible = false;
|
|
25
46
|
this.ctrlCTime = 0;
|
|
26
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;
|
|
27
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;
|
|
28
58
|
this._toolOutputs = []; // recent tool outputs, expandable via /out
|
|
29
59
|
this._toolOutSeq = 0;
|
|
30
60
|
}
|
|
@@ -71,6 +101,8 @@ export class TUI {
|
|
|
71
101
|
// Ctrl+C
|
|
72
102
|
if (this.busy) {
|
|
73
103
|
this.session.abort();
|
|
104
|
+
this._stopSpinner();
|
|
105
|
+
this._liveCommit();
|
|
74
106
|
this.out.write("\r\n" + yellow("aborted — type a new message or /exit\n"));
|
|
75
107
|
continue;
|
|
76
108
|
}
|
|
@@ -132,6 +164,17 @@ export class TUI {
|
|
|
132
164
|
this._slash(line);
|
|
133
165
|
return;
|
|
134
166
|
}
|
|
167
|
+
// Shell commands typed into the TUI go to the model and fail
|
|
168
|
+
// confusingly — catch the common ones and point at the terminal.
|
|
169
|
+
if (/^(roforge|npm|node|npx|git)\b(\s|$)/.test(line)) {
|
|
170
|
+
this.out.write(
|
|
171
|
+
yellow(
|
|
172
|
+
"that's a shell command, not a chat message — /exit first, then run it in your terminal " +
|
|
173
|
+
"(e.g. `roforge login`). In the TUI use /help for commands.\n"
|
|
174
|
+
)
|
|
175
|
+
);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
135
178
|
this.history.push(line);
|
|
136
179
|
this.historyIndex = -1;
|
|
137
180
|
this._runTurn(line);
|
|
@@ -193,6 +236,11 @@ export class TUI {
|
|
|
193
236
|
}
|
|
194
237
|
const free = PROVIDERS[this.session.providerName]?.hasFreeTier ? dim(" · free tier") : "";
|
|
195
238
|
this.out.write(`model → ${this.session.cfg._activeModel} (${this.session.providerName}${free})\n`);
|
|
239
|
+
if (!ref && !KNOWN_MODELS.has(arg)) {
|
|
240
|
+
this.out.write(
|
|
241
|
+
dim(` (unrecognized model name — double-check the spelling, or pin explicitly: /model provider:model, e.g. /model gemini:2.5-flash)\n`)
|
|
242
|
+
);
|
|
243
|
+
}
|
|
196
244
|
} else {
|
|
197
245
|
const free = PROVIDERS[this.session.providerName]?.hasFreeTier ? dim(" · free tier") : "";
|
|
198
246
|
this.out.write(`current: ${this.session.model} (${this.session.providerName}${free})\n`);
|
|
@@ -269,12 +317,18 @@ export class TUI {
|
|
|
269
317
|
async _runTurn(text) {
|
|
270
318
|
this.out.write(dim("you> ") + text + "\n");
|
|
271
319
|
this.busy = true;
|
|
272
|
-
this.
|
|
320
|
+
this._resetSegment();
|
|
321
|
+
this._costStatus = null;
|
|
322
|
+
this._lastLiveSig = null;
|
|
273
323
|
try {
|
|
274
324
|
await this.session.send(text);
|
|
275
325
|
} catch (e) {
|
|
326
|
+
this._stopSpinner();
|
|
327
|
+
this._liveCommit();
|
|
276
328
|
this.out.write(red(`error: ${e.message || e}`) + "\n");
|
|
277
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);
|
|
278
332
|
this.busy = false;
|
|
279
333
|
this._printPrompt();
|
|
280
334
|
}
|
|
@@ -284,17 +338,92 @@ export class TUI {
|
|
|
284
338
|
this.out.write("> ");
|
|
285
339
|
}
|
|
286
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
|
+
|
|
287
407
|
_startSpinner(label = "thinking…") {
|
|
288
|
-
if (!process.stdout.isTTY) return;
|
|
289
408
|
this._stopSpinner();
|
|
290
|
-
this.
|
|
291
|
-
this.
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
+
}
|
|
298
427
|
}
|
|
299
428
|
|
|
300
429
|
_stopSpinner() {
|
|
@@ -310,32 +439,42 @@ export class TUI {
|
|
|
310
439
|
|
|
311
440
|
// ---------------- ui event sink (Session) ----------------
|
|
312
441
|
|
|
313
|
-
_flushMd() {
|
|
314
|
-
if (this._md) {
|
|
315
|
-
const tail = this._md.finish();
|
|
316
|
-
if (tail) this.out.write(tail);
|
|
317
|
-
this._md = null;
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
|
|
321
442
|
onText(delta) {
|
|
322
443
|
this._stopSpinner();
|
|
323
|
-
if (!this._assistantHeaderShown) {
|
|
324
|
-
this.out.write(magenta("RoForge> ") );
|
|
325
|
-
this._assistantHeaderShown = true;
|
|
326
|
-
}
|
|
327
444
|
if (!this._md) this._md = new MarkdownStream();
|
|
328
|
-
const
|
|
329
|
-
|
|
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
|
+
}
|
|
330
457
|
}
|
|
331
458
|
|
|
332
459
|
onAssistantDone() {
|
|
333
|
-
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
|
+
}
|
|
334
472
|
}
|
|
335
473
|
|
|
336
474
|
onToolStart(tool, args) {
|
|
337
475
|
this._stopSpinner();
|
|
338
|
-
this.
|
|
476
|
+
this._liveCommit(); // assistant block → history (blank separator)
|
|
477
|
+
this._resetSegment(); // running region shows status only, no content
|
|
339
478
|
let argsStr;
|
|
340
479
|
try {
|
|
341
480
|
argsStr = JSON.stringify(args || {});
|
|
@@ -347,7 +486,8 @@ export class TUI {
|
|
|
347
486
|
this._toolOutputs.push({ id: this._toolOutSeq, tool: tool.name, args: argsStr, full: "" });
|
|
348
487
|
if (this._toolOutputs.length > 30) this._toolOutputs.shift();
|
|
349
488
|
this.out.write(dim(` ⚙ [${this._toolOutSeq}] ${tool.name}(${argsStr})`) + "\n");
|
|
350
|
-
this.
|
|
489
|
+
if (this._liveOK) this.live.begin();
|
|
490
|
+
this._startSpinner("running " + tool.name + "…");
|
|
351
491
|
}
|
|
352
492
|
|
|
353
493
|
onToolEnd(tool, args, result) {
|
|
@@ -355,6 +495,8 @@ export class TUI {
|
|
|
355
495
|
const r = String(result || "");
|
|
356
496
|
const last = this._toolOutputs[this._toolOutputs.length - 1];
|
|
357
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
|
|
358
500
|
const first = r.split("\n")[0].slice(0, 120);
|
|
359
501
|
const more = (r.length > 120 || r.includes("\n")) && last ? dim(` (more: /out ${last.id})`) : "";
|
|
360
502
|
if (r.startsWith("ERROR")) {
|
|
@@ -362,27 +504,45 @@ export class TUI {
|
|
|
362
504
|
} else {
|
|
363
505
|
this.out.write(dim(` ↳ ${first}`) + more + "\n");
|
|
364
506
|
}
|
|
507
|
+
if (this._liveOK) this.live.begin();
|
|
365
508
|
this._startSpinner("thinking…");
|
|
366
509
|
}
|
|
367
510
|
|
|
368
511
|
onInfo(msg) {
|
|
369
512
|
this._stopSpinner();
|
|
513
|
+
this._liveCommit();
|
|
370
514
|
this.out.write(gray(msg) + "\n");
|
|
371
515
|
}
|
|
372
516
|
|
|
373
517
|
onWarn(msg) {
|
|
374
518
|
this._stopSpinner();
|
|
519
|
+
this._liveCommit();
|
|
375
520
|
this.out.write(red(msg) + "\n");
|
|
376
521
|
}
|
|
377
522
|
|
|
378
523
|
onStatus(msg) {
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
+
}
|
|
382
541
|
}
|
|
383
542
|
|
|
384
543
|
async promptApproval(name, args) {
|
|
385
544
|
this._stopSpinner();
|
|
545
|
+
this._liveCommit();
|
|
386
546
|
let target = "";
|
|
387
547
|
try {
|
|
388
548
|
target = JSON.stringify(args || {});
|
|
@@ -391,7 +551,9 @@ export class TUI {
|
|
|
391
551
|
}
|
|
392
552
|
if (target.length > 100) target = target.slice(0, 97) + "…";
|
|
393
553
|
this.out.write(yellow(` ✋ approve ${name}(${target})? `) + dim("[y]es / [n]o / [a]lways "));
|
|
394
|
-
|
|
554
|
+
const ans = await this._readChar();
|
|
555
|
+
this.out.write("\n"); // next output starts on a fresh line
|
|
556
|
+
return ans;
|
|
395
557
|
}
|
|
396
558
|
|
|
397
559
|
_readChar() {
|
|
@@ -446,6 +608,15 @@ export class TUI {
|
|
|
446
608
|
if (process.stdin.isTTY) {
|
|
447
609
|
this._setRaw(true);
|
|
448
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
|
+
}
|
|
449
620
|
this._printPrompt();
|
|
450
621
|
return true;
|
|
451
622
|
}
|
|
@@ -457,6 +628,11 @@ export class TUI {
|
|
|
457
628
|
stop() {
|
|
458
629
|
this.running = false;
|
|
459
630
|
this._stopSpinner();
|
|
631
|
+
this._liveCommit();
|
|
632
|
+
if (this._onResize) {
|
|
633
|
+
process.stdout.removeListener("resize", this._onResize);
|
|
634
|
+
this._onResize = null;
|
|
635
|
+
}
|
|
460
636
|
this._setRaw(false);
|
|
461
637
|
process.stdin.removeAllListeners("data");
|
|
462
638
|
}
|