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 +1 -1
- package/src/config.js +6 -2
- package/src/providers/openai.js +40 -2
- package/src/tui/frame.js +264 -0
- package/src/tui/markdown.js +112 -24
- package/src/tui/tui.js +182 -36
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -46,7 +46,10 @@ export const PROVIDERS = {
|
|
|
46
46
|
env: "OPENROUTER_API_KEY",
|
|
47
47
|
baseField: "openrouterBaseUrl",
|
|
48
48
|
defaultModel: "qwen/qwen3-coder",
|
|
49
|
-
|
|
49
|
+
// Free tier rotates — this is the live free model verified 2026-09-13
|
|
50
|
+
// via the public OpenRouter models API + a real request. When it dies,
|
|
51
|
+
// check https://openrouter.ai/models?max_price=0 and update here.
|
|
52
|
+
freeModel: "nvidia/nemotron-3-super-120b-a12b:free",
|
|
50
53
|
hasFreeTier: true,
|
|
51
54
|
},
|
|
52
55
|
anthropic: {
|
|
@@ -114,7 +117,8 @@ const DEFAULTS = {
|
|
|
114
117
|
"gpt-4o": { input: 2.5, output: 10 },
|
|
115
118
|
"gemini-2.5-flash": { input: 0, output: 0 },
|
|
116
119
|
"llama-3.3-70b-versatile": { input: 0, output: 0 },
|
|
117
|
-
"
|
|
120
|
+
"nvidia/nemotron-3-super-120b-a12b:free": { input: 0, output: 0 },
|
|
121
|
+
"nvidia/nemotron-3-ultra-550b-a55b:free": { input: 0, output: 0 },
|
|
118
122
|
},
|
|
119
123
|
};
|
|
120
124
|
|
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/tui/frame.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// LiveRegion — a flicker-free live region at the bottom of the append-scroll
|
|
2
|
+
// output, sized to preserve the terminal scrollback above it (the core trick
|
|
3
|
+
// behind Claude Code's fluid TUI, adapted to an append-scroll layout).
|
|
4
|
+
//
|
|
5
|
+
// Model & cursor invariant:
|
|
6
|
+
// • Committed history scrolls normally above the region (plain append).
|
|
7
|
+
// • The region owns the currently-streaming content: every completed
|
|
8
|
+
// content line is appended (terminal scrolls, nothing is ever lost), and
|
|
9
|
+
// one status line always sits on the last line, rewritten in place.
|
|
10
|
+
// • The cursor is ALWAYS at the end of the status line.
|
|
11
|
+
// • Adding a line = overwrite the status line with the new content line,
|
|
12
|
+
// then write the status on the fresh line below (single write, no
|
|
13
|
+
// intermediate clear → no flicker).
|
|
14
|
+
// • The last (partially streamed) content line is rewritten in place as it
|
|
15
|
+
// grows: up one line, rewrite, back down, rewrite the status.
|
|
16
|
+
// • Committing = release(): the region's lines simply become history; the
|
|
17
|
+
// cursor drops to a fresh line below for the next turn.
|
|
18
|
+
//
|
|
19
|
+
// Long lines are pre-wrapped to the terminal width (segment-aware) so the
|
|
20
|
+
// terminal never auto-wraps and breaks the cursor arithmetic.
|
|
21
|
+
// A terminal resize erases the region; the next update re-renders it.
|
|
22
|
+
|
|
23
|
+
import { COLUMNS } from "./ansi.js";
|
|
24
|
+
|
|
25
|
+
const SGR_RESET = "\x1b[0m";
|
|
26
|
+
function sgrFor(attr) {
|
|
27
|
+
if (!attr) return SGR_RESET;
|
|
28
|
+
const p = [];
|
|
29
|
+
if (attr & 1) p.push("1"); // bold
|
|
30
|
+
if (attr & 2) p.push("2"); // dim
|
|
31
|
+
if (attr & 4) p.push("31"); // red
|
|
32
|
+
if (attr & 8) p.push("32"); // green
|
|
33
|
+
if (attr & 16) p.push("33"); // yellow
|
|
34
|
+
if (attr & 32) p.push("36"); // cyan
|
|
35
|
+
if (attr & 64) p.push("35"); // magenta
|
|
36
|
+
if (attr & 128) p.push("34"); // blue
|
|
37
|
+
return "\x1b[" + p.join(";") + "m";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const ATTR = {
|
|
41
|
+
PLAIN: 0,
|
|
42
|
+
BOLD: 1,
|
|
43
|
+
DIM: 2,
|
|
44
|
+
RED: 4,
|
|
45
|
+
GREEN: 8,
|
|
46
|
+
YELLOW: 16,
|
|
47
|
+
CYAN: 32,
|
|
48
|
+
MAGENTA: 64,
|
|
49
|
+
BLUE: 128,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Word-aware wrap for styled lines.
|
|
54
|
+
* @param {Array<{text: string, attr?: number}>} segments
|
|
55
|
+
* @param {number} width
|
|
56
|
+
* @returns {Array<Array<{text: string, attr?: number}>>} wrapped lines
|
|
57
|
+
*/
|
|
58
|
+
export function wrapSegments(segments, width) {
|
|
59
|
+
width = Math.max(2, width);
|
|
60
|
+
const lines = [[]];
|
|
61
|
+
let col = 0;
|
|
62
|
+
let pendingSpace = 0; // width of whitespace awaiting a word (dropped on wrap)
|
|
63
|
+
const lastLine = () => lines[lines.length - 1];
|
|
64
|
+
for (const seg of segments || []) {
|
|
65
|
+
const text = String(seg.text ?? "");
|
|
66
|
+
const attr = seg.attr ?? 0;
|
|
67
|
+
// pieces alternate: word, whitespace, word, ...
|
|
68
|
+
for (const piece of text.split(/(\s+)/)) {
|
|
69
|
+
if (!piece) continue;
|
|
70
|
+
if (/^\s+$/.test(piece)) {
|
|
71
|
+
// remember; only emitted when a following word lands on this line
|
|
72
|
+
pendingSpace = piece.length;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
let w = piece;
|
|
76
|
+
while (w.length) {
|
|
77
|
+
// a pending space only counts when the word isn't starting a fresh line
|
|
78
|
+
const spaceNeeded = col > 0 && pendingSpace ? pendingSpace : 0;
|
|
79
|
+
const room = width - col - spaceNeeded;
|
|
80
|
+
if (w.length <= room) {
|
|
81
|
+
// whole word fits
|
|
82
|
+
if (spaceNeeded) {
|
|
83
|
+
lastLine().push({ text: " ".repeat(spaceNeeded), attr });
|
|
84
|
+
col += spaceNeeded;
|
|
85
|
+
}
|
|
86
|
+
pendingSpace = 0;
|
|
87
|
+
lastLine().push({ text: w, attr });
|
|
88
|
+
col += w.length;
|
|
89
|
+
w = "";
|
|
90
|
+
} else if (w.length > width) {
|
|
91
|
+
// unbreakable: longer than a full line — hard-break it
|
|
92
|
+
if (col > 0 || spaceNeeded) {
|
|
93
|
+
lines.push([]);
|
|
94
|
+
col = 0;
|
|
95
|
+
}
|
|
96
|
+
pendingSpace = 0;
|
|
97
|
+
lastLine().push({ text: w.slice(0, width - col), attr });
|
|
98
|
+
w = w.slice(width - col);
|
|
99
|
+
lines.push([]);
|
|
100
|
+
col = 0;
|
|
101
|
+
} else {
|
|
102
|
+
// breakable word that doesn't fit the remainder — wrap it whole
|
|
103
|
+
lines.push([]);
|
|
104
|
+
col = 0;
|
|
105
|
+
pendingSpace = 0;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// drop a trailing line that is empty or whitespace-only
|
|
111
|
+
if (lines.length > 1) {
|
|
112
|
+
const tail = lines[lines.length - 1];
|
|
113
|
+
if (!tail.length || tail.every((s) => /^\s*$/.test(s.text))) lines.pop();
|
|
114
|
+
}
|
|
115
|
+
return lines.length ? lines : [[]];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function renderLine(segments) {
|
|
119
|
+
let out = "";
|
|
120
|
+
let state = 0;
|
|
121
|
+
for (const seg of segments || []) {
|
|
122
|
+
const a = seg.attr ?? 0;
|
|
123
|
+
if (a !== state) {
|
|
124
|
+
out += sgrFor(a);
|
|
125
|
+
state = a;
|
|
126
|
+
}
|
|
127
|
+
out += String(seg.text ?? "");
|
|
128
|
+
}
|
|
129
|
+
if (state) out += SGR_RESET;
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export class LiveRegion {
|
|
134
|
+
/**
|
|
135
|
+
* @param {(s: string) => void} emit
|
|
136
|
+
* @param {{ maxRows?: number, enabled?: boolean }} opts
|
|
137
|
+
* maxRows is accepted for API compatibility; the append model keeps every
|
|
138
|
+
* completed line live (terminal scrollback is the cap), so it is a no-op.
|
|
139
|
+
*/
|
|
140
|
+
constructor(emit, { maxRows = 6, enabled = true } = {}) {
|
|
141
|
+
this.emit = emit;
|
|
142
|
+
this.maxRows = maxRows;
|
|
143
|
+
this.enabled = enabled && Boolean(process.stdout.isTTY);
|
|
144
|
+
this.active = false;
|
|
145
|
+
this._rendered = false;
|
|
146
|
+
this._shown = 0; // completed content display lines already written
|
|
147
|
+
this._lastContent = null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
begin(_opts) {
|
|
151
|
+
this.active = true;
|
|
152
|
+
this._rendered = false;
|
|
153
|
+
this._shown = 0;
|
|
154
|
+
this._lastContent = null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
get isActive() {
|
|
158
|
+
return this.active;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Update the region.
|
|
163
|
+
* @param {Array<Array<{text, attr?}>>} contentLines styled logical lines —
|
|
164
|
+
* the full content so far, where the LAST line is the partially streamed
|
|
165
|
+
* line (grows across calls). Earlier lines are complete.
|
|
166
|
+
* @param {Array<{text, attr?}>|null} status styled status line (last row)
|
|
167
|
+
*/
|
|
168
|
+
update(contentLines, status) {
|
|
169
|
+
if (!this.active) return;
|
|
170
|
+
this._lastContent = contentLines;
|
|
171
|
+
const cols = COLUMNS();
|
|
172
|
+
// wrap every logical line into display lines
|
|
173
|
+
const display = [];
|
|
174
|
+
for (const line of contentLines || []) display.push(...wrapSegments(line, cols));
|
|
175
|
+
const statusLine = status ? wrapSegments(status, cols - 1).slice(0, 1)[0] || [] : [];
|
|
176
|
+
const C = display.length;
|
|
177
|
+
|
|
178
|
+
if (!this._rendered) {
|
|
179
|
+
// first render: append everything from the current cursor position.
|
|
180
|
+
// The first line is written at the cursor (col 0 of a fresh line, or
|
|
181
|
+
// right after a sameLine header) — no leading newline.
|
|
182
|
+
const rows = [...display, ...(statusLine.length ? [statusLine] : [])];
|
|
183
|
+
let out = "";
|
|
184
|
+
rows.forEach((r, i) => {
|
|
185
|
+
if (i > 0) out += "\n";
|
|
186
|
+
out += renderLine(r) + "\x1b[K";
|
|
187
|
+
});
|
|
188
|
+
if (out) this.emit(out);
|
|
189
|
+
this._rendered = true;
|
|
190
|
+
this._shown = C;
|
|
191
|
+
this._hadStatus = statusLine.length > 0;
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const newLines = display.slice(this._shown);
|
|
196
|
+
const lastLine = C > 0 ? display[C - 1] : null;
|
|
197
|
+
let out = "";
|
|
198
|
+
if (newLines.length === 0 && C === 0 && !statusLine.length) {
|
|
199
|
+
// nothing to show and nothing stale to clear — but a previously
|
|
200
|
+
// rendered status line must be blanked
|
|
201
|
+
if (this._hadStatus) {
|
|
202
|
+
out = "\x1b[1G\x1b[K";
|
|
203
|
+
this._hadStatus = false;
|
|
204
|
+
}
|
|
205
|
+
if (out) this.emit(out);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (newLines.length > 0) {
|
|
209
|
+
// New completed line(s): the cursor sits at the end of the status line.
|
|
210
|
+
// Overwrite it with the first new line, push the rest below, then write
|
|
211
|
+
// the status on the fresh bottom line. One write, no flicker.
|
|
212
|
+
out += "\x1b[1G";
|
|
213
|
+
for (const nl of newLines) out += renderLine(nl) + "\x1b[K\n";
|
|
214
|
+
if (statusLine.length) out += renderLine(statusLine) + "\x1b[K";
|
|
215
|
+
this._shown = C;
|
|
216
|
+
this._hadStatus = statusLine.length > 0;
|
|
217
|
+
} else if (C > 0 || statusLine.length) {
|
|
218
|
+
// No new lines: the last content line may have grown (or only the
|
|
219
|
+
// status changed). Rewrite the last content line in place, then the
|
|
220
|
+
// status.
|
|
221
|
+
if (C > 0) {
|
|
222
|
+
out += "\x1b[1A\x1b[1G"; // up to the last content line
|
|
223
|
+
out += renderLine(lastLine) + "\x1b[K"; // rewrite it (clear stale tail)
|
|
224
|
+
out += "\n"; // back down to the status line
|
|
225
|
+
} else {
|
|
226
|
+
out += "\x1b[1G";
|
|
227
|
+
}
|
|
228
|
+
out += renderLine(statusLine) + "\x1b[K"; // clears stale status text too
|
|
229
|
+
this._hadStatus = statusLine.length > 0;
|
|
230
|
+
}
|
|
231
|
+
if (out) this.emit(out);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Final update + release; region lines become committed history. */
|
|
235
|
+
end(status) {
|
|
236
|
+
if (!this.active) return;
|
|
237
|
+
if (status) this.update(this._lastContent || [], status);
|
|
238
|
+
this.release();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Release: drop the cursor to a fresh line below; lines become history. */
|
|
242
|
+
release() {
|
|
243
|
+
if (this.active && this._rendered) this.emit("\n");
|
|
244
|
+
this.active = false;
|
|
245
|
+
this._rendered = false;
|
|
246
|
+
this._shown = 0;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Erase the region on terminal resize (stale width would be wrong). */
|
|
250
|
+
eraseOnResize() {
|
|
251
|
+
if (!this.active || !this._rendered) return;
|
|
252
|
+
const h = this._shown + 1; // content lines + status
|
|
253
|
+
const out = ["\x1b[" + Math.max(0, h - 1) + "A", "\x1b[1G"];
|
|
254
|
+
for (let i = 0; i < h; i++) {
|
|
255
|
+
out.push("\x1b[2K");
|
|
256
|
+
if (i < h - 1) out.push("\n");
|
|
257
|
+
}
|
|
258
|
+
// return to the region top so the next update re-renders in place
|
|
259
|
+
out.push("\x1b[" + Math.max(0, h - 1) + "A");
|
|
260
|
+
this.emit(out.join(""));
|
|
261
|
+
this._rendered = false;
|
|
262
|
+
this._shown = 0;
|
|
263
|
+
}
|
|
264
|
+
}
|
package/src/tui/markdown.js
CHANGED
|
@@ -3,7 +3,23 @@
|
|
|
3
3
|
// code-fence state across deltas, and resets cleanly per assistant segment.
|
|
4
4
|
// Deliberately small: headings, bullets, numbered lists, bold, inline code,
|
|
5
5
|
// fences, blockquotes, rules. Everything else passes through untouched.
|
|
6
|
-
|
|
6
|
+
//
|
|
7
|
+
// Two output modes:
|
|
8
|
+
// push()/finish() — ANSI strings (piped / non-TTY path, legacy rendering)
|
|
9
|
+
// pushLines() & co — styled segment lines (Array<{text, attr}>) for the
|
|
10
|
+
// LiveRegion, which wraps them to terminal width
|
|
11
|
+
// itself while preserving per-segment style.
|
|
12
|
+
import { bold, dim, cyan } from "./ansi.js";
|
|
13
|
+
import { ATTR } from "./frame.js";
|
|
14
|
+
|
|
15
|
+
const A = ATTR;
|
|
16
|
+
|
|
17
|
+
// attr → string-mode ANSI helper (respects NO_COLOR / --no-color at call time)
|
|
18
|
+
const STRING_STYLE = {
|
|
19
|
+
[A.BOLD]: bold,
|
|
20
|
+
[A.DIM]: dim,
|
|
21
|
+
[A.CYAN]: cyan,
|
|
22
|
+
};
|
|
7
23
|
|
|
8
24
|
export class MarkdownStream {
|
|
9
25
|
constructor() {
|
|
@@ -11,57 +27,129 @@ export class MarkdownStream {
|
|
|
11
27
|
this.inFence = false;
|
|
12
28
|
}
|
|
13
29
|
|
|
14
|
-
//
|
|
30
|
+
// --- string mode (piped output) -------------------------------------------
|
|
31
|
+
|
|
32
|
+
// Consume a chunk; returns the rendered output for completed lines.
|
|
15
33
|
push(delta) {
|
|
34
|
+
const lines = this.pushLines(delta);
|
|
35
|
+
if (!lines.length) return "";
|
|
36
|
+
return lines.map((segs) => segsToAnsi(segs)).join("\n") + "\n";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Flush the incomplete trailing line (at end of an assistant segment).
|
|
40
|
+
finish() {
|
|
41
|
+
const segs = this.finishLines();
|
|
42
|
+
return segs ? segsToAnsi(segs) + "\n" : "";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// --- segment mode (LiveRegion) --------------------------------------------
|
|
46
|
+
|
|
47
|
+
// Consume a chunk; returns styled lines for every line that COMPLETED in
|
|
48
|
+
// this delta (each line = Array<{text, attr}>). The partial trailing line
|
|
49
|
+
// stays buffered; use partialLines() to render it.
|
|
50
|
+
pushLines(delta) {
|
|
16
51
|
this.buf += String(delta ?? "");
|
|
17
|
-
|
|
52
|
+
const out = [];
|
|
18
53
|
let idx;
|
|
19
54
|
while ((idx = this.buf.indexOf("\n")) !== -1) {
|
|
20
55
|
const line = this.buf.slice(0, idx);
|
|
21
56
|
this.buf = this.buf.slice(idx + 1);
|
|
22
|
-
out
|
|
57
|
+
out.push(this._renderLineSegs(line));
|
|
23
58
|
}
|
|
24
59
|
return out;
|
|
25
60
|
}
|
|
26
61
|
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
if (!this.buf.length) return
|
|
62
|
+
// Styled segments for the currently buffered (incomplete) line, or null.
|
|
63
|
+
partialLines() {
|
|
64
|
+
if (!this.buf.length) return null;
|
|
65
|
+
return this._renderLineSegs(this.buf);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Flush the incomplete trailing line as segments (or null).
|
|
69
|
+
finishLines() {
|
|
70
|
+
if (!this.buf.length) return null;
|
|
30
71
|
const line = this.buf;
|
|
31
72
|
this.buf = "";
|
|
32
|
-
return this.
|
|
73
|
+
return this._renderLineSegs(line);
|
|
33
74
|
}
|
|
34
75
|
|
|
35
|
-
|
|
76
|
+
// --- shared core ------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
_renderLineSegs(line) {
|
|
36
79
|
if (/^\s*(```|~~~)/.test(line)) {
|
|
37
80
|
this.inFence = !this.inFence;
|
|
38
|
-
return
|
|
81
|
+
return [{ text: " " + line.trim(), attr: A.DIM }];
|
|
39
82
|
}
|
|
40
83
|
if (this.inFence) {
|
|
41
|
-
return
|
|
84
|
+
return [{ text: " " + line, attr: A.DIM }];
|
|
42
85
|
}
|
|
43
|
-
return this.
|
|
86
|
+
return this._renderPlainSegs(line);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
_pushSeg(line, text, attr) {
|
|
90
|
+
if (!text) return;
|
|
91
|
+
const last = line[line.length - 1];
|
|
92
|
+
if (last && last.attr === attr) last.text += text;
|
|
93
|
+
else line.push({ text, attr: attr || 0 });
|
|
44
94
|
}
|
|
45
95
|
|
|
46
|
-
|
|
96
|
+
_renderPlainSegs(line) {
|
|
47
97
|
const h = line.match(/^(#{1,4})\s+(.*)$/);
|
|
48
|
-
if (h)
|
|
98
|
+
if (h) {
|
|
99
|
+
const out = [{ text: h[1] + " ", attr: A.BOLD }];
|
|
100
|
+
for (const seg of this._inlineSegs(h[2])) {
|
|
101
|
+
// heading text is bold by default; code inside keeps its own style
|
|
102
|
+
this._pushSeg(out, seg.text, seg.attr === A.CYAN ? seg.attr : A.BOLD);
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
49
106
|
if (/^\s*([-*+])\s+/.test(line)) {
|
|
50
|
-
|
|
107
|
+
const out = [{ text: "• ", attr: A.CYAN }];
|
|
108
|
+
for (const seg of this._inlineSegs(line.replace(/^\s*[-*+]\s+/, ""))) this._pushSeg(out, seg.text, seg.attr);
|
|
109
|
+
return out;
|
|
51
110
|
}
|
|
52
111
|
const num = line.match(/^\s*(\d+)[.)]\s+(.*)$/);
|
|
53
|
-
if (num)
|
|
112
|
+
if (num) {
|
|
113
|
+
const out = [{ text: num[1] + ". ", attr: A.DIM }];
|
|
114
|
+
for (const seg of this._inlineSegs(num[2])) this._pushSeg(out, seg.text, seg.attr);
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
54
117
|
if (/^\s*>\s?/.test(line)) {
|
|
55
|
-
|
|
118
|
+
const out = [{ text: "│ ", attr: A.DIM }];
|
|
119
|
+
for (const seg of this._inlineSegs(line.replace(/^\s*>\s?/, ""))) this._pushSeg(out, seg.text, A.DIM);
|
|
120
|
+
return out;
|
|
56
121
|
}
|
|
57
|
-
if (/^\s*([-*_])\1{2,}\s*$/.test(line))
|
|
58
|
-
|
|
59
|
-
|
|
122
|
+
if (/^\s*([-*_])\1{2,}\s*$/.test(line)) {
|
|
123
|
+
return [{ text: "────────────────────────────────", attr: A.DIM }];
|
|
124
|
+
}
|
|
125
|
+
if (!line.trim()) return [];
|
|
126
|
+
return this._inlineSegs(line);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Split inline text into plain / code / bold segments.
|
|
130
|
+
_inlineSegs(s) {
|
|
131
|
+
const out = [];
|
|
132
|
+
// tokenize: `code`, **bold**, and plain runs
|
|
133
|
+
const re = /(`[^`]+`|\*\*[^*]+\*\*)/g;
|
|
134
|
+
let last = 0;
|
|
135
|
+
let m;
|
|
136
|
+
while ((m = re.exec(s)) !== null) {
|
|
137
|
+
if (m.index > last) this._pushSeg(out, s.slice(last, m.index), A.PLAIN);
|
|
138
|
+
const tok = m[0];
|
|
139
|
+
if (tok.startsWith("`")) this._pushSeg(out, tok.slice(1, -1), A.CYAN);
|
|
140
|
+
else this._pushSeg(out, tok.slice(2, -2), A.BOLD);
|
|
141
|
+
last = m.index + tok.length;
|
|
142
|
+
}
|
|
143
|
+
if (last < s.length) this._pushSeg(out, s.slice(last), A.PLAIN);
|
|
144
|
+
return out;
|
|
60
145
|
}
|
|
146
|
+
}
|
|
61
147
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
148
|
+
export function segsToAnsi(segs) {
|
|
149
|
+
let out = "";
|
|
150
|
+
for (const seg of segs || []) {
|
|
151
|
+
const style = STRING_STYLE[seg.attr] || ((t) => t);
|
|
152
|
+
out += style(seg.text);
|
|
66
153
|
}
|
|
154
|
+
return out;
|
|
67
155
|
}
|
package/src/tui/tui.js
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
// RoForge TUI — Claude-Code-style interactive terminal session.
|
|
2
|
-
// Append-style rendering (terminal scrollback preserved)
|
|
3
|
-
//
|
|
2
|
+
// Append-style rendering (terminal scrollback preserved).
|
|
3
|
+
//
|
|
4
|
+
// Two render paths, picked once at startup:
|
|
5
|
+
// • LIVE (stdout is a TTY): the streaming block (markdown + status line) is
|
|
6
|
+
// a LiveRegion — rewritten in place every frame, zero flicker, history
|
|
7
|
+
// committed above it. See frame.js.
|
|
8
|
+
// • LEGACY (piped output / tests): plain append + \r-spinner, exactly the
|
|
9
|
+
// pre-LiveRegion behavior.
|
|
4
10
|
import { createRequire } from "node:module";
|
|
5
11
|
import { bold, dim, red, green, yellow, cyan, magenta, gray, wrap, SPINNER_FRAMES, CLEAR_LINE } from "./ansi.js";
|
|
6
12
|
import { parseModelRef, PROVIDERS } from "../config.js";
|
|
7
|
-
import { MarkdownStream } from "./markdown.js";
|
|
13
|
+
import { MarkdownStream, segsToAnsi } from "./markdown.js";
|
|
14
|
+
import { LiveRegion, ATTR } from "./frame.js";
|
|
8
15
|
|
|
9
16
|
const VERSION = (() => {
|
|
10
17
|
try {
|
|
@@ -22,7 +29,7 @@ for (const p of Object.values(PROVIDERS)) {
|
|
|
22
29
|
}
|
|
23
30
|
|
|
24
31
|
export class TUI {
|
|
25
|
-
constructor(session, { out = process.stdout, err = process.stderr } = {}) {
|
|
32
|
+
constructor(session, { out = process.stdout, err = process.stderr, live } = {}) {
|
|
26
33
|
this.session = session;
|
|
27
34
|
this.out = out;
|
|
28
35
|
this.err = err;
|
|
@@ -38,7 +45,16 @@ export class TUI {
|
|
|
38
45
|
this.spinnerVisible = false;
|
|
39
46
|
this.ctrlCTime = 0;
|
|
40
47
|
this.running = false;
|
|
48
|
+
|
|
49
|
+
// LiveRegion (live path). `live` overrides detection (tests).
|
|
50
|
+
this.live = new LiveRegion((s) => this.out.write(s), { maxRows: 6 });
|
|
51
|
+
this._liveOK =
|
|
52
|
+
live === undefined ? Boolean(process.stdout.isTTY) && this.out === process.stdout : live;
|
|
41
53
|
this._md = null; // active MarkdownStream for the current assistant segment
|
|
54
|
+
this._segLines = []; // completed styled lines for the current segment
|
|
55
|
+
this._statusLabel = "thinking…";
|
|
56
|
+
this._costStatus = null; // final cost line for this turn (plain text)
|
|
57
|
+
this._lastLiveSig = null;
|
|
42
58
|
this._toolOutputs = []; // recent tool outputs, expandable via /out
|
|
43
59
|
this._toolOutSeq = 0;
|
|
44
60
|
}
|
|
@@ -85,6 +101,8 @@ export class TUI {
|
|
|
85
101
|
// Ctrl+C
|
|
86
102
|
if (this.busy) {
|
|
87
103
|
this.session.abort();
|
|
104
|
+
this._stopSpinner();
|
|
105
|
+
this._liveCommit();
|
|
88
106
|
this.out.write("\r\n" + yellow("aborted — type a new message or /exit\n"));
|
|
89
107
|
continue;
|
|
90
108
|
}
|
|
@@ -220,7 +238,7 @@ export class TUI {
|
|
|
220
238
|
this.out.write(`model → ${this.session.cfg._activeModel} (${this.session.providerName}${free})\n`);
|
|
221
239
|
if (!ref && !KNOWN_MODELS.has(arg)) {
|
|
222
240
|
this.out.write(
|
|
223
|
-
dim(` (unrecognized model name — double-check the spelling, or pin explicitly: /model provider:model, e.g. /model gemini:
|
|
241
|
+
dim(` (unrecognized model name — double-check the spelling, or pin explicitly: /model provider:model, e.g. /model gemini:2.5-flash)\n`)
|
|
224
242
|
);
|
|
225
243
|
}
|
|
226
244
|
} else {
|
|
@@ -299,12 +317,18 @@ export class TUI {
|
|
|
299
317
|
async _runTurn(text) {
|
|
300
318
|
this.out.write(dim("you> ") + text + "\n");
|
|
301
319
|
this.busy = true;
|
|
302
|
-
this.
|
|
320
|
+
this._resetSegment();
|
|
321
|
+
this._costStatus = null;
|
|
322
|
+
this._lastLiveSig = null;
|
|
303
323
|
try {
|
|
304
324
|
await this.session.send(text);
|
|
305
325
|
} catch (e) {
|
|
326
|
+
this._stopSpinner();
|
|
327
|
+
this._liveCommit();
|
|
306
328
|
this.out.write(red(`error: ${e.message || e}`) + "\n");
|
|
307
329
|
}
|
|
330
|
+
// commit the live block with the final cost line as its status
|
|
331
|
+
this._liveCommit(this._costStatus ? [{ text: this._costStatus, attr: ATTR.DIM }] : null);
|
|
308
332
|
this.busy = false;
|
|
309
333
|
this._printPrompt();
|
|
310
334
|
}
|
|
@@ -314,17 +338,92 @@ export class TUI {
|
|
|
314
338
|
this.out.write("> ");
|
|
315
339
|
}
|
|
316
340
|
|
|
341
|
+
// ---------------- live-region plumbing ----------------
|
|
342
|
+
|
|
343
|
+
// Region content for the current assistant segment: completed lines + the
|
|
344
|
+
// partial line, with the magenta "RoForge> " header on the first
|
|
345
|
+
// non-empty line (leading blank lines from the model stay blank).
|
|
346
|
+
_regionLines() {
|
|
347
|
+
const lines = [...this._segLines];
|
|
348
|
+
const partial = this._md ? this._md.partialLines() : null;
|
|
349
|
+
if (partial) lines.push(partial);
|
|
350
|
+
const first = lines.findIndex((l) => l && l.some((s) => s.text));
|
|
351
|
+
if (first === -1) return [];
|
|
352
|
+
lines[first] = [{ text: "RoForge> ", attr: ATTR.MAGENTA }, ...lines[first]];
|
|
353
|
+
return lines;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
_statusSegs() {
|
|
357
|
+
if (this._costStatus) return [{ text: this._costStatus, attr: ATTR.DIM }];
|
|
358
|
+
const frame = SPINNER_FRAMES[this.spinnerFrame];
|
|
359
|
+
return [{ text: frame + " " + this._statusLabel, attr: ATTR.DIM }];
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Redraw the live region (no-op when nothing changed since the last frame).
|
|
363
|
+
_liveRefresh() {
|
|
364
|
+
if (!this._liveOK || !this.live.isActive) return;
|
|
365
|
+
const lines = this._regionLines();
|
|
366
|
+
const status = this._statusSegs();
|
|
367
|
+
const last = lines.length ? lines[lines.length - 1] : null;
|
|
368
|
+
const sig =
|
|
369
|
+
lines.length +
|
|
370
|
+
":" +
|
|
371
|
+
(last ? last.map((s) => s.text).join("") : "") +
|
|
372
|
+
"|" +
|
|
373
|
+
status.map((s) => s.text).join("");
|
|
374
|
+
if (sig === this._lastLiveSig) return;
|
|
375
|
+
this._lastLiveSig = sig;
|
|
376
|
+
this.live.update(lines, status);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Commit the live block to history. statusSegs = final status line, or null
|
|
380
|
+
// to commit with a blank one (acts as a separator).
|
|
381
|
+
_liveCommit(statusSegs = null) {
|
|
382
|
+
if (!this._liveOK || !this.live.isActive) return;
|
|
383
|
+
this._finalizeMd();
|
|
384
|
+
this.live.update(this._regionLines(), statusSegs || []);
|
|
385
|
+
this.live.release();
|
|
386
|
+
this._lastLiveSig = null;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
_finalizeMd() {
|
|
390
|
+
if (this._md) {
|
|
391
|
+
const tail = this._md.finishLines();
|
|
392
|
+
if (tail) this._segLines.push(tail);
|
|
393
|
+
this._md = null;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Start a fresh assistant segment (clears streamed content + markdown).
|
|
398
|
+
// The "running tool" region and gaps between segments show no content, so
|
|
399
|
+
// the stale block never re-renders in a new region.
|
|
400
|
+
_resetSegment() {
|
|
401
|
+
this._md = null;
|
|
402
|
+
this._segLines = [];
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ---------------- spinner ----------------
|
|
406
|
+
|
|
317
407
|
_startSpinner(label = "thinking…") {
|
|
318
|
-
if (!process.stdout.isTTY) return;
|
|
319
408
|
this._stopSpinner();
|
|
320
|
-
this.
|
|
321
|
-
this.
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
409
|
+
this._statusLabel = label;
|
|
410
|
+
if (this._liveOK) {
|
|
411
|
+
this.spinnerTimer = setInterval(() => {
|
|
412
|
+
this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length;
|
|
413
|
+
if (this.live.isActive) this._liveRefresh();
|
|
414
|
+
}, 90);
|
|
415
|
+
this.spinnerTimer.unref && this.spinnerTimer.unref();
|
|
416
|
+
this._liveRefresh();
|
|
417
|
+
} else if (process.stdout.isTTY) {
|
|
418
|
+
this.spinnerVisible = true;
|
|
419
|
+
this.out.write(label);
|
|
420
|
+
this.spinnerTimer = setInterval(() => {
|
|
421
|
+
this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length;
|
|
422
|
+
const len = label.length + 3;
|
|
423
|
+
this.out.write("\r" + CLEAR_LINE + this.spinnerFrame + " " + label.slice(0, Math.max(0, len - 2)));
|
|
424
|
+
}, 90);
|
|
425
|
+
this.spinnerTimer.unref && this.spinnerTimer.unref();
|
|
426
|
+
}
|
|
328
427
|
}
|
|
329
428
|
|
|
330
429
|
_stopSpinner() {
|
|
@@ -340,32 +439,42 @@ export class TUI {
|
|
|
340
439
|
|
|
341
440
|
// ---------------- ui event sink (Session) ----------------
|
|
342
441
|
|
|
343
|
-
_flushMd() {
|
|
344
|
-
if (this._md) {
|
|
345
|
-
const tail = this._md.finish();
|
|
346
|
-
if (tail) this.out.write(tail);
|
|
347
|
-
this._md = null;
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
|
|
351
442
|
onText(delta) {
|
|
352
443
|
this._stopSpinner();
|
|
353
|
-
if (!this._assistantHeaderShown) {
|
|
354
|
-
this.out.write(magenta("RoForge> ") );
|
|
355
|
-
this._assistantHeaderShown = true;
|
|
356
|
-
}
|
|
357
444
|
if (!this._md) this._md = new MarkdownStream();
|
|
358
|
-
const
|
|
359
|
-
|
|
445
|
+
const newLines = this._md.pushLines(delta);
|
|
446
|
+
for (const l of newLines) this._segLines.push(l);
|
|
447
|
+
if (this._liveOK) {
|
|
448
|
+
if (!this.live.isActive) this.live.begin();
|
|
449
|
+
this._liveRefresh();
|
|
450
|
+
} else {
|
|
451
|
+
if (!this._assistantHeaderShown) {
|
|
452
|
+
this.out.write(magenta("RoForge> "));
|
|
453
|
+
this._assistantHeaderShown = true;
|
|
454
|
+
}
|
|
455
|
+
if (newLines.length) this.out.write(newLines.map(segsToAnsi).join("\n") + "\n");
|
|
456
|
+
}
|
|
360
457
|
}
|
|
361
458
|
|
|
362
459
|
onAssistantDone() {
|
|
363
|
-
this.
|
|
460
|
+
if (this._md) {
|
|
461
|
+
const tail = this._md.finishLines();
|
|
462
|
+
this._md = null;
|
|
463
|
+
if (tail) {
|
|
464
|
+
if (this._liveOK) {
|
|
465
|
+
this._segLines.push(tail);
|
|
466
|
+
this._liveRefresh();
|
|
467
|
+
} else {
|
|
468
|
+
this.out.write(segsToAnsi(tail) + "\n");
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
364
472
|
}
|
|
365
473
|
|
|
366
474
|
onToolStart(tool, args) {
|
|
367
475
|
this._stopSpinner();
|
|
368
|
-
this.
|
|
476
|
+
this._liveCommit(); // assistant block → history (blank separator)
|
|
477
|
+
this._resetSegment(); // running region shows status only, no content
|
|
369
478
|
let argsStr;
|
|
370
479
|
try {
|
|
371
480
|
argsStr = JSON.stringify(args || {});
|
|
@@ -377,7 +486,8 @@ export class TUI {
|
|
|
377
486
|
this._toolOutputs.push({ id: this._toolOutSeq, tool: tool.name, args: argsStr, full: "" });
|
|
378
487
|
if (this._toolOutputs.length > 30) this._toolOutputs.shift();
|
|
379
488
|
this.out.write(dim(` ⚙ [${this._toolOutSeq}] ${tool.name}(${argsStr})`) + "\n");
|
|
380
|
-
this.
|
|
489
|
+
if (this._liveOK) this.live.begin();
|
|
490
|
+
this._startSpinner("running " + tool.name + "…");
|
|
381
491
|
}
|
|
382
492
|
|
|
383
493
|
onToolEnd(tool, args, result) {
|
|
@@ -385,6 +495,8 @@ export class TUI {
|
|
|
385
495
|
const r = String(result || "");
|
|
386
496
|
const last = this._toolOutputs[this._toolOutputs.length - 1];
|
|
387
497
|
if (last && last.tool === tool.name) last.full = r;
|
|
498
|
+
this._liveCommit(); // "running…" block → history
|
|
499
|
+
this._resetSegment(); // next assistant text starts a fresh segment
|
|
388
500
|
const first = r.split("\n")[0].slice(0, 120);
|
|
389
501
|
const more = (r.length > 120 || r.includes("\n")) && last ? dim(` (more: /out ${last.id})`) : "";
|
|
390
502
|
if (r.startsWith("ERROR")) {
|
|
@@ -392,27 +504,45 @@ export class TUI {
|
|
|
392
504
|
} else {
|
|
393
505
|
this.out.write(dim(` ↳ ${first}`) + more + "\n");
|
|
394
506
|
}
|
|
507
|
+
if (this._liveOK) this.live.begin();
|
|
395
508
|
this._startSpinner("thinking…");
|
|
396
509
|
}
|
|
397
510
|
|
|
398
511
|
onInfo(msg) {
|
|
399
512
|
this._stopSpinner();
|
|
513
|
+
this._liveCommit();
|
|
400
514
|
this.out.write(gray(msg) + "\n");
|
|
401
515
|
}
|
|
402
516
|
|
|
403
517
|
onWarn(msg) {
|
|
404
518
|
this._stopSpinner();
|
|
519
|
+
this._liveCommit();
|
|
405
520
|
this.out.write(red(msg) + "\n");
|
|
406
521
|
}
|
|
407
522
|
|
|
408
523
|
onStatus(msg) {
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
524
|
+
const m = String(msg);
|
|
525
|
+
if (m.includes("tok")) {
|
|
526
|
+
// per-turn cost footer — becomes the live block's final status line
|
|
527
|
+
this._costStatus = m;
|
|
528
|
+
if (this._liveOK && this.live.isActive) {
|
|
529
|
+
this._stopSpinner();
|
|
530
|
+
this._liveRefresh();
|
|
531
|
+
} else if (!this._liveOK) {
|
|
532
|
+
this.out.write("\n" + gray(m) + "\n");
|
|
533
|
+
}
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
// "thinking… (step n/total)" / "done" → live status label
|
|
537
|
+
if (this._liveOK && this.live.isActive) {
|
|
538
|
+
this._statusLabel = m === "done" ? "finishing…" : m;
|
|
539
|
+
this._liveRefresh();
|
|
540
|
+
}
|
|
412
541
|
}
|
|
413
542
|
|
|
414
543
|
async promptApproval(name, args) {
|
|
415
544
|
this._stopSpinner();
|
|
545
|
+
this._liveCommit();
|
|
416
546
|
let target = "";
|
|
417
547
|
try {
|
|
418
548
|
target = JSON.stringify(args || {});
|
|
@@ -421,7 +551,9 @@ export class TUI {
|
|
|
421
551
|
}
|
|
422
552
|
if (target.length > 100) target = target.slice(0, 97) + "…";
|
|
423
553
|
this.out.write(yellow(` ✋ approve ${name}(${target})? `) + dim("[y]es / [n]o / [a]lways "));
|
|
424
|
-
|
|
554
|
+
const ans = await this._readChar();
|
|
555
|
+
this.out.write("\n"); // next output starts on a fresh line
|
|
556
|
+
return ans;
|
|
425
557
|
}
|
|
426
558
|
|
|
427
559
|
_readChar() {
|
|
@@ -476,6 +608,15 @@ export class TUI {
|
|
|
476
608
|
if (process.stdin.isTTY) {
|
|
477
609
|
this._setRaw(true);
|
|
478
610
|
process.stdin.on("data", (c) => this._onData(c));
|
|
611
|
+
if (this._liveOK) {
|
|
612
|
+
this._onResize = () => {
|
|
613
|
+
if (this.live.isActive) {
|
|
614
|
+
this.live.eraseOnResize();
|
|
615
|
+
this._lastLiveSig = null;
|
|
616
|
+
}
|
|
617
|
+
};
|
|
618
|
+
process.stdout.on("resize", this._onResize);
|
|
619
|
+
}
|
|
479
620
|
this._printPrompt();
|
|
480
621
|
return true;
|
|
481
622
|
}
|
|
@@ -487,6 +628,11 @@ export class TUI {
|
|
|
487
628
|
stop() {
|
|
488
629
|
this.running = false;
|
|
489
630
|
this._stopSpinner();
|
|
631
|
+
this._liveCommit();
|
|
632
|
+
if (this._onResize) {
|
|
633
|
+
process.stdout.removeListener("resize", this._onResize);
|
|
634
|
+
this._onResize = null;
|
|
635
|
+
}
|
|
490
636
|
this._setRaw(false);
|
|
491
637
|
process.stdin.removeAllListeners("data");
|
|
492
638
|
}
|