roforge-cli 0.3.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roforge-cli",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
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": {
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
- freeModel: "qwen/qwen3-coder:free", // :free models: req/day limit, no billing
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
- "qwen/qwen3-coder:free": { input: 0, output: 0 },
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
 
@@ -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 fetch(`${cfg.openaiBaseUrl}/v1/chat/completions`, {
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(`network error calling OpenAI: ${e.cause?.code || e.message}`);
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
 
@@ -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
+ }
@@ -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
- import { bold, dim, cyan, gray } from "./ansi.js";
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
- // Consume a chunk; returns the renderable output for completed lines.
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
- let out = "";
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 += this._renderLine(line) + "\n";
57
+ out.push(this._renderLineSegs(line));
23
58
  }
24
59
  return out;
25
60
  }
26
61
 
27
- // Flush the incomplete trailing line (at end of an assistant segment).
28
- finish() {
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._renderLine(line) + "\n";
73
+ return this._renderLineSegs(line);
33
74
  }
34
75
 
35
- _renderLine(line) {
76
+ // --- shared core ------------------------------------------------------------
77
+
78
+ _renderLineSegs(line) {
36
79
  if (/^\s*(```|~~~)/.test(line)) {
37
80
  this.inFence = !this.inFence;
38
- return dim(" " + line.trim());
81
+ return [{ text: " " + line.trim(), attr: A.DIM }];
39
82
  }
40
83
  if (this.inFence) {
41
- return dim(" " + line);
84
+ return [{ text: " " + line, attr: A.DIM }];
42
85
  }
43
- return this._renderPlain(line);
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
- _renderPlain(line) {
96
+ _renderPlainSegs(line) {
47
97
  const h = line.match(/^(#{1,4})\s+(.*)$/);
48
- if (h) return bold(h[1] + " " + this._inline(h[2]));
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
- return cyan("• ") + this._inline(line.replace(/^\s*[-*+]\s+/, ""));
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) return gray(num[1] + ".") + " " + this._inline(num[2]);
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
- return dim("│ ") + dim(this._inline(line.replace(/^\s*>\s?/, "")));
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)) return dim("────────────────────────────────");
58
- if (!line.trim()) return "";
59
- return this._inline(line);
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
- _inline(s) {
63
- s = s.replace(/`([^`]+)`/g, (_, c) => cyan(c));
64
- s = s.replace(/\*\*([^*]+)\*\*/g, (_, b) => bold(b));
65
- return s;
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), single status line
3
- // with a spinner, approval prompts, slash commands, input history.
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:gemini-2.5-flash)\n`)
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._md = null;
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.spinnerVisible = true;
321
- this.out.write(label);
322
- this.spinnerTimer = setInterval(() => {
323
- this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length;
324
- const len = label.length + 3;
325
- this.out.write("\r" + CLEAR_LINE + this.spinnerFrame + " " + label.slice(0, Math.max(0, len - 2)));
326
- }, 90);
327
- this.spinnerTimer.unref && this.spinnerTimer.unref();
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 rendered = this._md.push(delta);
359
- if (rendered) this.out.write(rendered);
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._flushMd();
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._flushMd();
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._startSpinner(dim("running " + tool.name + "…"));
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
- // Only the per-turn cost footer is printed here; the spinner covers
410
- // "thinking…" and tool progress is shown on its own lines.
411
- if (String(msg).includes("tok")) this.out.write("\n" + gray(msg) + "\n");
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
- return await this._readChar();
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
  }