roforge-cli 0.3.0

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/src/tui/tui.js ADDED
@@ -0,0 +1,463 @@
1
+ // RoForge TUI — Claude-Code-style interactive terminal session.
2
+ // Append-style rendering (terminal scrollback preserved), single status line
3
+ // with a spinner, approval prompts, slash commands, input history.
4
+ import { bold, dim, red, green, yellow, cyan, magenta, gray, wrap, SPINNER_FRAMES, CLEAR_LINE } from "./ansi.js";
5
+ import { parseModelRef, PROVIDERS } from "../config.js";
6
+ import { MarkdownStream } from "./markdown.js";
7
+
8
+ const VERSION = "0.2.0";
9
+
10
+ export class TUI {
11
+ constructor(session, { out = process.stdout, err = process.stderr } = {}) {
12
+ this.session = session;
13
+ this.out = out;
14
+ this.err = err;
15
+ this.busy = false;
16
+ this.history = [];
17
+ this.historyIndex = -1;
18
+ this.buffer = "";
19
+ this.inputLine = null; // active readline for line input
20
+ this.charMode = false; // single-char mode (approval)
21
+ this.charResolve = null;
22
+ this.spinnerTimer = null;
23
+ this.spinnerFrame = 0;
24
+ this.spinnerVisible = false;
25
+ this.ctrlCTime = 0;
26
+ this.running = false;
27
+ this._md = null; // active MarkdownStream for the current assistant segment
28
+ this._toolOutputs = []; // recent tool outputs, expandable via /out
29
+ this._toolOutSeq = 0;
30
+ }
31
+
32
+ // ---------------- low-level input ----------------
33
+
34
+ _setRaw(on) {
35
+ const stdin = process.stdin;
36
+ try {
37
+ if (on) {
38
+ stdin.setRawMode(true);
39
+ stdin.resume();
40
+ } else if (stdin.isTTY) {
41
+ stdin.setRawMode(false);
42
+ stdin.pause();
43
+ }
44
+ } catch {
45
+ /* non-tty */
46
+ }
47
+ }
48
+
49
+ _onData(chunk) {
50
+ const s = String(chunk);
51
+ if (this.charMode) {
52
+ for (const ch of s) {
53
+ if (ch === "\r" || ch === "\n") {
54
+ this._endCharMode();
55
+ this.charResolve && this.charResolve("(empty)");
56
+ break;
57
+ }
58
+ if (ch === "\x7f" || ch === "\b") {
59
+ this.out.write("\b \b");
60
+ continue;
61
+ }
62
+ this.out.write(ch);
63
+ this._endCharMode();
64
+ this.charResolve && this.charResolve(ch.toLowerCase());
65
+ break;
66
+ }
67
+ return;
68
+ }
69
+ for (const ch of s) {
70
+ if (ch === "\x03") {
71
+ // Ctrl+C
72
+ if (this.busy) {
73
+ this.session.abort();
74
+ this.out.write("\r\n" + yellow("aborted — type a new message or /exit\n"));
75
+ continue;
76
+ }
77
+ const now = Date.now();
78
+ if (now - this.ctrlCTime < 1500) {
79
+ this.stop();
80
+ process.exit(0);
81
+ }
82
+ this.ctrlCTime = now;
83
+ this.out.write("\r\n" + dim("press Ctrl+C again to exit") + "\r> ");
84
+ this.buffer = "";
85
+ } else if (ch === "\x04") {
86
+ // Ctrl+D
87
+ this.stop();
88
+ process.exit(0);
89
+ } else if (ch === "\x7f" || ch === "\b") {
90
+ if (this.buffer.length) {
91
+ this.buffer = this.buffer.slice(0, -1);
92
+ this.out.write("\b \b");
93
+ }
94
+ } else if (ch === "\u001b") {
95
+ // escape sequence start (arrows) — consume via next chunks; we ignore here
96
+ this._pendingEsc = true;
97
+ } else if (this._pendingEsc) {
98
+ this._pendingEsc = false;
99
+ if (ch === "[") {
100
+ this._pendingArrow = true;
101
+ continue;
102
+ }
103
+ } else if (this._pendingArrow) {
104
+ this._pendingArrow = false;
105
+ if (ch === "A") this._historyNav(-1);
106
+ else if (ch === "B") this._historyNav(1);
107
+ } else if (ch === "\r" || ch === "\n") {
108
+ const line = this.buffer;
109
+ this.buffer = "";
110
+ this.out.write("\r\n");
111
+ this._submit(line);
112
+ } else if (ch >= " " || ch === "\t") {
113
+ this.buffer += ch;
114
+ this.out.write(ch);
115
+ }
116
+ }
117
+ }
118
+
119
+ _historyNav(dir) {
120
+ if (!this.history.length) return;
121
+ const width = this.buffer.length;
122
+ this.historyIndex = this.historyIndex === -1 ? this.history.length - 1 : Math.min(this.history.length - 1, Math.max(0, this.historyIndex + dir));
123
+ const entry = this.history[this.historyIndex] || "";
124
+ this.out.write("\r" + CLEAR_LINE + "> " + entry + " ".repeat(Math.max(0, width - entry.length)));
125
+ this.buffer = entry;
126
+ }
127
+
128
+ _submit(line) {
129
+ line = line.trim();
130
+ if (!line) return;
131
+ if (line.startsWith("/")) {
132
+ this._slash(line);
133
+ return;
134
+ }
135
+ this.history.push(line);
136
+ this.historyIndex = -1;
137
+ this._runTurn(line);
138
+ }
139
+
140
+ // ---------------- commands ----------------
141
+
142
+ _slash(line) {
143
+ const [cmd, ...rest] = line.split(/\s+/);
144
+ const arg = rest.join(" ");
145
+ if (cmd === "/save") {
146
+ this._save(arg);
147
+ return;
148
+ }
149
+ switch (cmd) {
150
+ case "/exit":
151
+ case "/quit":
152
+ this.stop();
153
+ process.exit(0);
154
+ break;
155
+ case "/help":
156
+ this.out.write(
157
+ [
158
+ bold("/help") + " this help",
159
+ bold("/tools") + " list available tools",
160
+ bold("/studio") + " studio connection status (MCP / bridge)",
161
+ bold("/clear") + " clear the conversation",
162
+ bold("/model <name>") + " switch model this session (supports provider:model, e.g. gemini:gemini-2.5-flash)",
163
+ bold("/yolo") + " approve all tool runs this session (careful!)",
164
+ bold("/ask") + " require approval again",
165
+ bold("/save [path]") + " save the conversation as a Markdown transcript",
166
+ bold("/out [n]") + " show the full output of tool call #n (no arg: list recent)",
167
+ bold("/exit") + " quit",
168
+ ].join("\n")
169
+ );
170
+ break;
171
+ case "/tools":
172
+ for (const t of this.session.tools) {
173
+ const tier = t.tier ? gray(` [${t.tier}]`) : "";
174
+ const appr = t.requiresApproval ? gray(" (approve)") : "";
175
+ this.out.write(` ${cyan(t.name)}${tier}${appr} — ${gray(t.description.split(".")[0])}\n`);
176
+ }
177
+ break;
178
+ case "/studio":
179
+ this.out.write(this._studioStatusText() + "\n");
180
+ break;
181
+ case "/clear":
182
+ this.session.clear();
183
+ this.out.write(dim("conversation cleared") + "\n");
184
+ break;
185
+ case "/model":
186
+ if (arg) {
187
+ const ref = parseModelRef(arg);
188
+ if (ref) {
189
+ this.session.cfg.provider = ref.provider;
190
+ this.session.cfg._activeModel = ref.model;
191
+ } else {
192
+ this.session.cfg._activeModel = arg;
193
+ }
194
+ const free = PROVIDERS[this.session.providerName]?.hasFreeTier ? dim(" · free tier") : "";
195
+ this.out.write(`model → ${this.session.cfg._activeModel} (${this.session.providerName}${free})\n`);
196
+ } else {
197
+ const free = PROVIDERS[this.session.providerName]?.hasFreeTier ? dim(" · free tier") : "";
198
+ this.out.write(`current: ${this.session.model} (${this.session.providerName}${free})\n`);
199
+ }
200
+ break;
201
+ case "/yolo":
202
+ this.session.cfg.approve = "yolo";
203
+ this.out.write(yellow("all tool runs auto-approved for this session") + "\n");
204
+ break;
205
+ case "/ask":
206
+ this.session.cfg.approve = "ask";
207
+ this.out.write("approval prompts re-enabled\n");
208
+ break;
209
+ case "/out": {
210
+ const n = parseInt(arg, 10);
211
+ if (arg && Number.isNaN(n)) {
212
+ this.out.write(dim("usage: /out [n] — e.g. /out 3\n"));
213
+ } else if (arg) {
214
+ const entry = this._toolOutputs.find((e) => e.id === n);
215
+ if (!entry) {
216
+ this.out.write(dim(`no tool call #${n} in the buffer (last ${this._toolOutputs.length} kept)\n`));
217
+ } else {
218
+ this.out.write(cyan(`— #${n} ${entry.tool}(${entry.args})\n`));
219
+ this.out.write((entry.full || "(no output)") + "\n");
220
+ }
221
+ } else if (!this._toolOutputs.length) {
222
+ this.out.write(dim("no tool calls yet\n"));
223
+ } else {
224
+ for (const e of this._toolOutputs.slice(-10).reverse()) {
225
+ const lines = (e.full || "").split("\n").length;
226
+ this.out.write(dim(` [${e.id}] ${e.tool} — ${lines} line(s)\n`));
227
+ }
228
+ }
229
+ break;
230
+ }
231
+ default:
232
+ this.out.write(`unknown command ${cmd} — /help\n`);
233
+ }
234
+ }
235
+
236
+ async _save(arg) {
237
+ try {
238
+ const fs = await import("node:fs/promises");
239
+ const path = await import("node:path");
240
+ const target =
241
+ arg && arg.trim()
242
+ ? path.resolve(this.session.cwd, arg.trim())
243
+ : path.resolve(this.session.cwd, `roforge-transcript-${new Date().toISOString().replace(/[:.]/g, "-")}.md`);
244
+ await fs.writeFile(target, this.session.transcript(), "utf8");
245
+ this.out.write(green(`transcript saved → `) + target + "\n");
246
+ } catch (e) {
247
+ this.out.write(red(`could not save transcript: ${e.message || e}`) + "\n");
248
+ }
249
+ }
250
+
251
+ _studioStatusText() {
252
+ const s = this.session.studioInfo;
253
+ const lines = [];
254
+ if (s.mcp) lines.push(`${green("●")} studio MCP (built-in): ${s.mcpToolCount} tools @ ${this.session.cfg.mcpUrl}`);
255
+ else lines.push(`${red("○")} studio MCP (built-in): not reachable @ ${this.session.cfg.mcpUrl}` + dim(" (File → Studio Settings → Beta Features → MCP Server)"));
256
+ if (this.session.bridgeServer) {
257
+ const b = this.session.bridgeServer;
258
+ lines.push(
259
+ b.connected
260
+ ? `${green("●")} bridge plugin: connected (http://${b.host}:${b.port})`
261
+ : `${yellow("○")} bridge plugin: waiting for Studio (http://${b.host}:${b.port})`
262
+ );
263
+ }
264
+ return lines.join("\n");
265
+ }
266
+
267
+ // ---------------- turn rendering ----------------
268
+
269
+ async _runTurn(text) {
270
+ this.out.write(dim("you> ") + text + "\n");
271
+ this.busy = true;
272
+ this._md = null;
273
+ try {
274
+ await this.session.send(text);
275
+ } catch (e) {
276
+ this.out.write(red(`error: ${e.message || e}`) + "\n");
277
+ }
278
+ this.busy = false;
279
+ this._printPrompt();
280
+ }
281
+
282
+ _printPrompt() {
283
+ this._stopSpinner();
284
+ this.out.write("> ");
285
+ }
286
+
287
+ _startSpinner(label = "thinking…") {
288
+ if (!process.stdout.isTTY) return;
289
+ this._stopSpinner();
290
+ this.spinnerVisible = true;
291
+ this.out.write(label);
292
+ this.spinnerTimer = setInterval(() => {
293
+ this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length;
294
+ const len = label.length + 3;
295
+ this.out.write("\r" + CLEAR_LINE + this.spinnerFrame + " " + label.slice(0, Math.max(0, len - 2)));
296
+ }, 90);
297
+ this.spinnerTimer.unref && this.spinnerTimer.unref();
298
+ }
299
+
300
+ _stopSpinner() {
301
+ if (this.spinnerTimer) {
302
+ clearInterval(this.spinnerTimer);
303
+ this.spinnerTimer = null;
304
+ }
305
+ if (this.spinnerVisible) {
306
+ this.out.write("\r" + CLEAR_LINE);
307
+ this.spinnerVisible = false;
308
+ }
309
+ }
310
+
311
+ // ---------------- ui event sink (Session) ----------------
312
+
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
+ onText(delta) {
322
+ this._stopSpinner();
323
+ if (!this._assistantHeaderShown) {
324
+ this.out.write(magenta("RoForge> ") );
325
+ this._assistantHeaderShown = true;
326
+ }
327
+ if (!this._md) this._md = new MarkdownStream();
328
+ const rendered = this._md.push(delta);
329
+ if (rendered) this.out.write(rendered);
330
+ }
331
+
332
+ onAssistantDone() {
333
+ this._flushMd();
334
+ }
335
+
336
+ onToolStart(tool, args) {
337
+ this._stopSpinner();
338
+ this._flushMd();
339
+ let argsStr;
340
+ try {
341
+ argsStr = JSON.stringify(args || {});
342
+ } catch {
343
+ argsStr = "{}";
344
+ }
345
+ if (argsStr.length > 80) argsStr = argsStr.slice(0, 77) + "…";
346
+ this._toolOutSeq += 1;
347
+ this._toolOutputs.push({ id: this._toolOutSeq, tool: tool.name, args: argsStr, full: "" });
348
+ if (this._toolOutputs.length > 30) this._toolOutputs.shift();
349
+ this.out.write(dim(` ⚙ [${this._toolOutSeq}] ${tool.name}(${argsStr})`) + "\n");
350
+ this._startSpinner(dim("running " + tool.name + "…"));
351
+ }
352
+
353
+ onToolEnd(tool, args, result) {
354
+ this._stopSpinner();
355
+ const r = String(result || "");
356
+ const last = this._toolOutputs[this._toolOutputs.length - 1];
357
+ if (last && last.tool === tool.name) last.full = r;
358
+ const first = r.split("\n")[0].slice(0, 120);
359
+ const more = (r.length > 120 || r.includes("\n")) && last ? dim(` (more: /out ${last.id})`) : "";
360
+ if (r.startsWith("ERROR")) {
361
+ this.out.write(dim(" ↳ ") + red(first) + "\n");
362
+ } else {
363
+ this.out.write(dim(` ↳ ${first}`) + more + "\n");
364
+ }
365
+ this._startSpinner("thinking…");
366
+ }
367
+
368
+ onInfo(msg) {
369
+ this._stopSpinner();
370
+ this.out.write(gray(msg) + "\n");
371
+ }
372
+
373
+ onWarn(msg) {
374
+ this._stopSpinner();
375
+ this.out.write(red(msg) + "\n");
376
+ }
377
+
378
+ onStatus(msg) {
379
+ // Only the per-turn cost footer is printed here; the spinner covers
380
+ // "thinking…" and tool progress is shown on its own lines.
381
+ if (String(msg).includes("tok")) this.out.write("\n" + gray(msg) + "\n");
382
+ }
383
+
384
+ async promptApproval(name, args) {
385
+ this._stopSpinner();
386
+ let target = "";
387
+ try {
388
+ target = JSON.stringify(args || {});
389
+ } catch {
390
+ target = "{}";
391
+ }
392
+ if (target.length > 100) target = target.slice(0, 97) + "…";
393
+ this.out.write(yellow(` ✋ approve ${name}(${target})? `) + dim("[y]es / [n]o / [a]lways "));
394
+ return await this._readChar();
395
+ }
396
+
397
+ _readChar() {
398
+ return new Promise((resolve) => {
399
+ this.charMode = true;
400
+ this.charResolve = (ch) => {
401
+ if (ch === "y") resolve(true);
402
+ else if (ch === "a") {
403
+ // always for this tool (session handles via return true + marker)
404
+ resolve("always");
405
+ } else resolve(false);
406
+ };
407
+ });
408
+ }
409
+
410
+ _endCharMode() {
411
+ this.charMode = false;
412
+ }
413
+
414
+ // ---------------- lifecycle ----------------
415
+
416
+ async start() {
417
+ const s = this.session;
418
+ this.running = true;
419
+ this.out.write(
420
+ bold(`RoForge ${VERSION}`) +
421
+ dim(" — local Roblox agent ") +
422
+ cyan(s.model) +
423
+ dim(` (${s.providerName}) · ${s.cwd}`) +
424
+ "\n"
425
+ );
426
+ this.out.write(this._studioStatusText() + "\n");
427
+ if (!s.cfg._apiKeyPresent) {
428
+ this.out.write(red(" no API key found — run `roforge login` or set GEMINI_API_KEY / GROQ_API_KEY / OPENROUTER_API_KEY / ANTHROPIC_API_KEY / OPENAI_API_KEY (free keys work: aistudio.google.com, console.groq.com, openrouter.ai)") + "\n");
429
+ }
430
+ this.out.write(dim(" /help for commands · Ctrl+C aborts a turn · Ctrl+D exits") + "\n\n");
431
+
432
+ if (process.platform === "win32" && process.stdin.isTTY) {
433
+ // Verify raw mode is actually available (legacy conhost can claim a TTY
434
+ // but not support raw mode — the TUI would be unusable there).
435
+ try {
436
+ process.stdin.setRawMode(true);
437
+ process.stdin.setRawMode(false);
438
+ } catch {
439
+ this.out.write(
440
+ red("Windows: raw terminal mode is unavailable (legacy console).\n") +
441
+ dim(" Run RoForge in Windows Terminal (or PowerShell 7+), or use `roforge chat -m \"...\"` for one-shot mode.\n")
442
+ );
443
+ process.exit(1);
444
+ }
445
+ }
446
+ if (process.stdin.isTTY) {
447
+ this._setRaw(true);
448
+ process.stdin.on("data", (c) => this._onData(c));
449
+ this._printPrompt();
450
+ return true;
451
+ }
452
+ this.out.write(dim("(stdin is not a TTY — use `roforge chat -m \"prompt\"` for one-shot mode)") + "\n");
453
+ this.stop();
454
+ return false;
455
+ }
456
+
457
+ stop() {
458
+ this.running = false;
459
+ this._stopSpinner();
460
+ this._setRaw(false);
461
+ process.stdin.removeAllListeners("data");
462
+ }
463
+ }
package/src/util.js ADDED
@@ -0,0 +1,117 @@
1
+ // Shared helpers: SSE parsing, JSON over HTTP, misc. Zero dependencies.
2
+ import { randomBytes } from "node:crypto";
3
+
4
+ // Incremental Server-Sent-Events parser.
5
+ // push(chunk) feeds raw text; each complete event invokes onEvent({event, data}).
6
+ // `data` is JSON-decoded when possible, else the raw string. [DONE] → data="[DONE]".
7
+ export function createSSE(onEvent) {
8
+ let buf = "";
9
+ return {
10
+ push(chunk) {
11
+ buf += chunk;
12
+ let idx;
13
+ while ((idx = buf.indexOf("\n\n")) !== -1) {
14
+ const raw = buf.slice(0, idx);
15
+ buf = buf.slice(idx + 2);
16
+ const ev = parseSSEBlock(raw);
17
+ if (ev) onEvent(ev);
18
+ }
19
+ // guard against unbounded buffer on malformed streams
20
+ if (buf.length > 4 * 1024 * 1024) buf = "";
21
+ },
22
+ end() {
23
+ if (buf.trim()) {
24
+ const ev = parseSSEBlock(buf);
25
+ buf = "";
26
+ if (ev) onEvent(ev);
27
+ }
28
+ },
29
+ };
30
+ }
31
+
32
+ function parseSSEBlock(raw) {
33
+ let event = "message";
34
+ const dataLines = [];
35
+ for (const line of raw.split("\n")) {
36
+ if (line.startsWith(":")) continue; // comment
37
+ if (line.startsWith("event:")) event = line.slice(6).trim();
38
+ else if (line.startsWith("data:")) dataLines.push(line.slice(5).replace(/^ /, ""));
39
+ }
40
+ if (!dataLines.length) return null;
41
+ const dataStr = dataLines.join("\n");
42
+ if (dataStr === "[DONE]") return { event, data: "[DONE]", done: true };
43
+ try {
44
+ return { event, data: JSON.parse(dataStr) };
45
+ } catch {
46
+ return { event, data: dataStr };
47
+ }
48
+ }
49
+
50
+ // Parse an entire SSE text blob (used for MCP responses that arrive as a whole body).
51
+ export function parseSSEStream(text) {
52
+ const events = [];
53
+ const parser = createSSE((ev) => events.push(ev));
54
+ parser.push(text);
55
+ parser.end();
56
+ return events;
57
+ }
58
+
59
+ export class HttpError extends Error {
60
+ constructor(message, status, code) {
61
+ super(message);
62
+ this.status = status || 500;
63
+ this.code = code || "HTTP_ERROR";
64
+ }
65
+ }
66
+
67
+ export async function fetchJson(url, opts = {}) {
68
+ const res = await fetch(url, {
69
+ ...opts,
70
+ headers: { "content-type": "application/json", ...opts.headers },
71
+ signal: opts.signal ?? AbortSignal.timeout(opts.timeoutMs ?? 30000),
72
+ });
73
+ const text = await res.text();
74
+ let json = null;
75
+ try {
76
+ json = text ? JSON.parse(text) : null;
77
+ } catch {
78
+ /* not json */
79
+ }
80
+ if (!res.ok) {
81
+ const msg = (json && (json.error && (typeof json.error === "string" ? json.error : json.error.message))) || text.slice(0, 300);
82
+ throw new HttpError(`HTTP ${res.status}: ${msg}`, res.status, (json && json.code) || "HTTP_ERROR");
83
+ }
84
+ return json ?? text;
85
+ }
86
+
87
+ export function truncate(s, n) {
88
+ s = String(s ?? "");
89
+ return s.length > n ? s.slice(0, n) + `\n... [truncated ${s.length - n} chars]` : s;
90
+ }
91
+
92
+ // Crude but effective HTML → plain text.
93
+ export function htmlToText(html) {
94
+ let t = String(html);
95
+ t = t.replace(/<script[\s\S]*?<\/script>/gi, " ");
96
+ t = t.replace(/<style[\s\S]*?<\/style>/gi, " ");
97
+ t = t.replace(/<nav[\s\S]*?<\/nav>/gi, " ");
98
+ t = t.replace(/<footer[\s\S]*?<\/footer>/gi, " ");
99
+ t = t.replace(/<!--[\s\S]*?-->/g, " ");
100
+ t = t.replace(/<br\s*\/?>/gi, "\n");
101
+ t = t.replace(/<\/(p|div|li|h[1-6]|tr|section|article|pre|blockquote)>/gi, "\n");
102
+ t = t.replace(/<[^>]+>/g, " ");
103
+ t = t
104
+ .replace(/&nbsp;/g, " ")
105
+ .replace(/&amp;/g, "&")
106
+ .replace(/&lt;/g, "<")
107
+ .replace(/&gt;/g, ">")
108
+ .replace(/&quot;/g, '"')
109
+ .replace(/&#39;|&apos;/g, "'");
110
+ t = t.replace(/[ \t]+/g, " ");
111
+ t = t.replace(/\s*\n\s*/g, "\n");
112
+ return t.trim();
113
+ }
114
+
115
+ export function randomToken(bytes = 24) {
116
+ return randomBytes(bytes).toString("hex");
117
+ }