@xingjisoft/neutrino 0.1.2 → 0.1.4
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 +36 -2
- package/dist/app.js +65 -1
- package/dist/colors.js +2 -0
- package/dist/compact.js +68 -0
- package/dist/config.js +15 -0
- package/dist/llm/client.js +309 -84
- package/dist/markdown.js +1 -1
- package/dist/sanitize.js +61 -0
- package/dist/screens/agentConversation.js +29 -2
- package/dist/screens/chat.js +284 -27
- package/dist/screens/settings.js +83 -44
- package/dist/screens/start.js +25 -0
- package/dist/selection.js +25 -11
- package/dist/terminal/buffer.js +24 -3
- package/dist/terminal/input.js +75 -1
- package/dist/terminal/renderer.js +43 -3
- package/dist/terminal/screen.js +29 -0
- package/dist/ui.js +105 -38
- package/package.json +2 -2
package/dist/llm/client.js
CHANGED
|
@@ -1,4 +1,138 @@
|
|
|
1
1
|
import { INTENSITY_PARAMS } from "../config.js";
|
|
2
|
+
/** How many extra times a request is retried when the connection drops. */
|
|
3
|
+
const NETWORK_RETRIES = 2;
|
|
4
|
+
/** Give up if the server has not started to respond within this window. */
|
|
5
|
+
const CONNECT_TIMEOUT_MS = 120_000;
|
|
6
|
+
/**
|
|
7
|
+
* System prompt used when compressing a long conversation. The instruction
|
|
8
|
+
* spells out exactly what must survive compression (goals, decisions, files,
|
|
9
|
+
* commands) so the summary stays useful for continuing the work.
|
|
10
|
+
*/
|
|
11
|
+
const SUMMARY_SYSTEM_PROMPT = "You compress the conversation history of an AI coding assistant so it fits in a smaller context window. " +
|
|
12
|
+
"Write a dense, factual summary in the same language as the conversation. Preserve: the user's goals and " +
|
|
13
|
+
"constraints, decisions that were made, files created or modified (with their paths), commands that were run " +
|
|
14
|
+
"and their meaningful results, and any unresolved problems or next steps. Drop pleasantries, restated " +
|
|
15
|
+
"instructions and redundant tool output. Output only the summary, with no preface.";
|
|
16
|
+
/**
|
|
17
|
+
* Terse transport messages that all mean "the connection dropped mid-flight".
|
|
18
|
+
* The \b keeps "terminated" from matching unrelated text such as
|
|
19
|
+
* "Unterminated string constant".
|
|
20
|
+
*/
|
|
21
|
+
const DROP_HINT_RE = /\bterminated\b|other side closed|socket hang up|premature close/i;
|
|
22
|
+
/**
|
|
23
|
+
* Flatten an error and its `cause` chain into one readable line.
|
|
24
|
+
*
|
|
25
|
+
* `fetch` reports almost every transport problem as a bare
|
|
26
|
+
* `TypeError: fetch failed`; the actual reason (`ECONNRESET`, `EMFILE`,
|
|
27
|
+
* `ENOTFOUND`, `UND_ERR_…`) is tucked away in `error.cause`. Without walking
|
|
28
|
+
* that chain a failure is impossible to diagnose, so this always reports it.
|
|
29
|
+
*/
|
|
30
|
+
export function describeError(e) {
|
|
31
|
+
if (!(e instanceof Error))
|
|
32
|
+
return String(e);
|
|
33
|
+
const parts = [];
|
|
34
|
+
const push = (msg, code) => {
|
|
35
|
+
const text = code && !msg.includes(code) ? `${msg} (${code})` : msg;
|
|
36
|
+
if (text && !parts.includes(text))
|
|
37
|
+
parts.push(text);
|
|
38
|
+
};
|
|
39
|
+
let cur = e;
|
|
40
|
+
const seen = new Set();
|
|
41
|
+
while (cur != null && !seen.has(cur)) {
|
|
42
|
+
if (typeof cur === "string") {
|
|
43
|
+
// fetch/undici sometimes stash the real reason in a plain string
|
|
44
|
+
// (`cause: "other side closed"`), which an object-only walk would miss.
|
|
45
|
+
push(cur);
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
if (typeof cur !== "object")
|
|
49
|
+
break;
|
|
50
|
+
seen.add(cur);
|
|
51
|
+
const err = cur;
|
|
52
|
+
const message = typeof err.message === "string" && err.message ? err.message : String(err.name ?? "Error");
|
|
53
|
+
const code = typeof err.code === "string" ? err.code : undefined;
|
|
54
|
+
push(message, code);
|
|
55
|
+
cur = err.cause;
|
|
56
|
+
}
|
|
57
|
+
const text = parts.join(": ") || e.message;
|
|
58
|
+
// A dropped mid-stream connection surfaces as a bare `TypeError: terminated`,
|
|
59
|
+
// which on its own tells the user nothing worth reading.
|
|
60
|
+
return DROP_HINT_RE.test(text) ? `${text} — the connection was closed before the response finished` : text;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Whether a failed request is worth retrying: a transient connection problem
|
|
64
|
+
* (reset / refused / DNS / timeout) rather than a real response from the API.
|
|
65
|
+
*/
|
|
66
|
+
function isRetriableNetworkError(e) {
|
|
67
|
+
const transient = new Set([
|
|
68
|
+
"ECONNRESET",
|
|
69
|
+
"ECONNREFUSED",
|
|
70
|
+
"ECONNABORTED",
|
|
71
|
+
"EPIPE",
|
|
72
|
+
"ETIMEDOUT",
|
|
73
|
+
"ENETUNREACH",
|
|
74
|
+
"ENETDOWN",
|
|
75
|
+
"EAI_AGAIN",
|
|
76
|
+
"ERR_STREAM_PREMATURE_CLOSE",
|
|
77
|
+
"UND_ERR_SOCKET",
|
|
78
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
79
|
+
]);
|
|
80
|
+
let cur = e;
|
|
81
|
+
const seen = new Set();
|
|
82
|
+
while (cur != null && !seen.has(cur)) {
|
|
83
|
+
if (typeof cur === "string") {
|
|
84
|
+
if (DROP_HINT_RE.test(cur))
|
|
85
|
+
return true;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
if (typeof cur !== "object")
|
|
89
|
+
break;
|
|
90
|
+
seen.add(cur);
|
|
91
|
+
const err = cur;
|
|
92
|
+
if (typeof err.code === "string" && transient.has(err.code))
|
|
93
|
+
return true;
|
|
94
|
+
// Our own connect timeout aborts with a plain AbortError; undici wraps a
|
|
95
|
+
// dropped socket in a SocketError.
|
|
96
|
+
if (err.name === "AbortError" || err.name === "SocketError")
|
|
97
|
+
return true;
|
|
98
|
+
// A stream that dies mid-flight: "terminated", "socket hang up", …
|
|
99
|
+
if (typeof err.message === "string" && DROP_HINT_RE.test(err.message))
|
|
100
|
+
return true;
|
|
101
|
+
cur = err.cause;
|
|
102
|
+
}
|
|
103
|
+
// Undici's generic wrapper: assume the socket dropped and try again.
|
|
104
|
+
return e instanceof Error && /fetch failed/i.test(e.message);
|
|
105
|
+
}
|
|
106
|
+
function sleep(ms) {
|
|
107
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Issue a streaming request, retrying a few times on transient network errors
|
|
111
|
+
* and giving up with a clear error if the server never starts responding.
|
|
112
|
+
*
|
|
113
|
+
* Retries are safe here because they only ever happen *before* a response is
|
|
114
|
+
* returned; once bytes start flowing the caller is responsible for the stream.
|
|
115
|
+
*/
|
|
116
|
+
async function requestStream(url, init) {
|
|
117
|
+
let lastErr;
|
|
118
|
+
for (let attempt = 0; attempt <= NETWORK_RETRIES; attempt++) {
|
|
119
|
+
const controller = new AbortController();
|
|
120
|
+
const timer = setTimeout(() => controller.abort(new Error(`request timed out after ${CONNECT_TIMEOUT_MS / 1000}s`)), CONNECT_TIMEOUT_MS);
|
|
121
|
+
try {
|
|
122
|
+
const resp = await fetch(url, { ...init, signal: controller.signal });
|
|
123
|
+
clearTimeout(timer);
|
|
124
|
+
return resp;
|
|
125
|
+
}
|
|
126
|
+
catch (e) {
|
|
127
|
+
clearTimeout(timer);
|
|
128
|
+
lastErr = e;
|
|
129
|
+
if (attempt === NETWORK_RETRIES || !isRetriableNetworkError(e))
|
|
130
|
+
throw e;
|
|
131
|
+
await sleep(300 * (attempt + 1));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
throw lastErr;
|
|
135
|
+
}
|
|
2
136
|
export class LLMClient {
|
|
3
137
|
model;
|
|
4
138
|
provider;
|
|
@@ -15,6 +149,25 @@ export class LLMClient {
|
|
|
15
149
|
this.temperature = params.temperature;
|
|
16
150
|
this.top_p = params.top_p;
|
|
17
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* Ask the model to compress a transcript into a short summary. Returns an
|
|
154
|
+
* empty string on any failure so the caller can fall back to simply dropping
|
|
155
|
+
* the old messages. Uses a throw-away request (no tools) via the normal
|
|
156
|
+
* streaming path, so provider differences are handled for free.
|
|
157
|
+
*/
|
|
158
|
+
async summarize(transcript) {
|
|
159
|
+
const messages = [
|
|
160
|
+
{ role: "system", content: SUMMARY_SYSTEM_PROMPT },
|
|
161
|
+
{ role: "user", content: transcript },
|
|
162
|
+
];
|
|
163
|
+
let out = "";
|
|
164
|
+
for await (const chunk of this.streamTurn(messages))
|
|
165
|
+
out += chunk;
|
|
166
|
+
const text = out.trim();
|
|
167
|
+
if (!text || text.startsWith("[Error"))
|
|
168
|
+
return "";
|
|
169
|
+
return text;
|
|
170
|
+
}
|
|
18
171
|
async *streamTurn(messages, tools) {
|
|
19
172
|
this.toolCalls = [];
|
|
20
173
|
this.usage = null;
|
|
@@ -26,10 +179,38 @@ export class LLMClient {
|
|
|
26
179
|
yield* this.streamOpenAI(messages, tools);
|
|
27
180
|
}
|
|
28
181
|
catch (e) {
|
|
29
|
-
yield `\n[Error: ${
|
|
182
|
+
yield `\n[Error: ${describeError(e)}]`;
|
|
30
183
|
}
|
|
31
184
|
}
|
|
185
|
+
/**
|
|
186
|
+
* Drive one request, retrying if the stream dies before it produced anything.
|
|
187
|
+
*
|
|
188
|
+
* A response can be cut off on the very first read — "terminated" when a
|
|
189
|
+
* proxy or the server closes the connection mid-flight. Nothing has reached
|
|
190
|
+
* the user yet, so re-issuing the request is invisible (and cannot duplicate
|
|
191
|
+
* text). Once a token has been emitted a retry is unsafe, so the error is
|
|
192
|
+
* surfaced instead.
|
|
193
|
+
*/
|
|
32
194
|
async *streamOpenAI(messages, tools) {
|
|
195
|
+
for (let attempt = 0;; attempt++) {
|
|
196
|
+
this.toolCalls = [];
|
|
197
|
+
let produced = false;
|
|
198
|
+
try {
|
|
199
|
+
for await (const token of this.openAIAttempt(messages, tools)) {
|
|
200
|
+
produced = true;
|
|
201
|
+
yield token;
|
|
202
|
+
}
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
catch (e) {
|
|
206
|
+
if (produced || attempt >= NETWORK_RETRIES || !isRetriableNetworkError(e))
|
|
207
|
+
throw e;
|
|
208
|
+
await sleep(400 * (attempt + 1));
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/** A single streaming attempt; see `streamOpenAI` for the retry policy. */
|
|
213
|
+
async *openAIAttempt(messages, tools) {
|
|
33
214
|
const url = this.model.url.replace(/\/+$/, "") + "/chat/completions";
|
|
34
215
|
const headers = {
|
|
35
216
|
"Content-Type": "application/json",
|
|
@@ -45,7 +226,7 @@ export class LLMClient {
|
|
|
45
226
|
};
|
|
46
227
|
if (tools && tools.length)
|
|
47
228
|
payload.tools = tools;
|
|
48
|
-
const resp = await
|
|
229
|
+
const resp = await requestStream(url, {
|
|
49
230
|
method: "POST",
|
|
50
231
|
headers,
|
|
51
232
|
body: JSON.stringify(payload),
|
|
@@ -64,66 +245,100 @@ export class LLMClient {
|
|
|
64
245
|
const decoder = new TextDecoder();
|
|
65
246
|
let buf = "";
|
|
66
247
|
let stopped = false;
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
buf += decoder.decode(value, { stream: true });
|
|
72
|
-
let idx;
|
|
73
|
-
while ((idx = buf.indexOf("\n")) >= 0) {
|
|
74
|
-
const raw = buf.slice(0, idx).replace(/\r$/, "");
|
|
75
|
-
buf = buf.slice(idx + 1);
|
|
76
|
-
const line = raw.trim();
|
|
77
|
-
if (!line)
|
|
78
|
-
continue;
|
|
79
|
-
if (!line.startsWith("data:"))
|
|
80
|
-
continue;
|
|
81
|
-
const data = line.slice(5).trim();
|
|
82
|
-
if (data === "[DONE]") {
|
|
83
|
-
stopped = true;
|
|
248
|
+
try {
|
|
249
|
+
while (!stopped) {
|
|
250
|
+
const { done, value } = await reader.read();
|
|
251
|
+
if (done)
|
|
84
252
|
break;
|
|
85
|
-
}
|
|
86
|
-
let
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
253
|
+
buf += decoder.decode(value, { stream: true });
|
|
254
|
+
let idx;
|
|
255
|
+
while ((idx = buf.indexOf("\n")) >= 0) {
|
|
256
|
+
const raw = buf.slice(0, idx).replace(/\r$/, "");
|
|
257
|
+
buf = buf.slice(idx + 1);
|
|
258
|
+
const line = raw.trim();
|
|
259
|
+
if (!line)
|
|
260
|
+
continue;
|
|
261
|
+
if (!line.startsWith("data:"))
|
|
262
|
+
continue;
|
|
263
|
+
const data = line.slice(5).trim();
|
|
264
|
+
if (data === "[DONE]") {
|
|
265
|
+
stopped = true;
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
let chunk;
|
|
269
|
+
try {
|
|
270
|
+
chunk = JSON.parse(data);
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
const usage = chunk.usage;
|
|
276
|
+
if (usage) {
|
|
277
|
+
this.usage = {
|
|
278
|
+
prompt: usage.prompt_tokens ?? 0,
|
|
279
|
+
completion: usage.completion_tokens ?? 0,
|
|
280
|
+
total: usage.total_tokens ?? (usage.prompt_tokens ?? 0) + (usage.completion_tokens ?? 0),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
const choice = chunk.choices?.[0];
|
|
284
|
+
const delta = choice?.delta ?? {};
|
|
285
|
+
const content = delta.content;
|
|
286
|
+
if (typeof content === "string" && content)
|
|
287
|
+
yield content;
|
|
288
|
+
const toolCalls = delta.tool_calls;
|
|
289
|
+
if (toolCalls) {
|
|
290
|
+
for (const tc of toolCalls) {
|
|
291
|
+
const i = tc.index ?? 0;
|
|
292
|
+
const entry = (toolCallsByIndex[i] ??= { id: "", name: "", arguments: "" });
|
|
293
|
+
if (tc.id)
|
|
294
|
+
entry.id = tc.id;
|
|
295
|
+
if (tc.function?.name)
|
|
296
|
+
entry.name = tc.function.name;
|
|
297
|
+
if (tc.function?.arguments)
|
|
298
|
+
entry.arguments += tc.function.arguments;
|
|
299
|
+
}
|
|
117
300
|
}
|
|
118
301
|
}
|
|
119
302
|
}
|
|
120
303
|
}
|
|
304
|
+
finally {
|
|
305
|
+
// The consumer may walk away mid-stream (pause, a newer turn taking over,
|
|
306
|
+
// or `[DONE]` arriving before the body ends). Always release the reader so
|
|
307
|
+
// the socket is torn down instead of lingering — leaked streaming bodies
|
|
308
|
+
// are what eventually make every request fail with "fetch failed".
|
|
309
|
+
try {
|
|
310
|
+
await reader.cancel();
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
// Already closed / errored; nothing left to release.
|
|
314
|
+
}
|
|
315
|
+
}
|
|
121
316
|
this.toolCalls = Object.keys(toolCallsByIndex)
|
|
122
317
|
.map((k) => Number(k))
|
|
123
318
|
.sort((a, b) => a - b)
|
|
124
319
|
.map((k) => toolCallsByIndex[k]);
|
|
125
320
|
}
|
|
321
|
+
/** Anthropic counterpart of `streamOpenAI`, with the same retry policy. */
|
|
126
322
|
async *streamAnthropic(messages) {
|
|
323
|
+
for (let attempt = 0;; attempt++) {
|
|
324
|
+
this.toolCalls = [];
|
|
325
|
+
let produced = false;
|
|
326
|
+
try {
|
|
327
|
+
for await (const token of this.anthropicAttempt(messages)) {
|
|
328
|
+
produced = true;
|
|
329
|
+
yield token;
|
|
330
|
+
}
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
catch (e) {
|
|
334
|
+
if (produced || attempt >= NETWORK_RETRIES || !isRetriableNetworkError(e))
|
|
335
|
+
throw e;
|
|
336
|
+
await sleep(400 * (attempt + 1));
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
/** A single streaming attempt; see `streamAnthropic` for the retry policy. */
|
|
341
|
+
async *anthropicAttempt(messages) {
|
|
127
342
|
const url = this.model.url.replace(/\/+$/, "") + "/messages";
|
|
128
343
|
const headers = {
|
|
129
344
|
"Content-Type": "application/json",
|
|
@@ -147,7 +362,7 @@ export class LLMClient {
|
|
|
147
362
|
};
|
|
148
363
|
if (systemMsg)
|
|
149
364
|
payload.system = systemMsg;
|
|
150
|
-
const resp = await
|
|
365
|
+
const resp = await requestStream(url, {
|
|
151
366
|
method: "POST",
|
|
152
367
|
headers,
|
|
153
368
|
body: JSON.stringify(payload),
|
|
@@ -165,41 +380,51 @@ export class LLMClient {
|
|
|
165
380
|
const decoder = new TextDecoder();
|
|
166
381
|
let buf = "";
|
|
167
382
|
let stopped = false;
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
buf += decoder.decode(value, { stream: true });
|
|
173
|
-
let idx;
|
|
174
|
-
while ((idx = buf.indexOf("\n")) >= 0) {
|
|
175
|
-
const raw = buf.slice(0, idx).replace(/\r$/, "");
|
|
176
|
-
buf = buf.slice(idx + 1);
|
|
177
|
-
const line = raw.trim();
|
|
178
|
-
if (!line || !line.startsWith("data:"))
|
|
179
|
-
continue;
|
|
180
|
-
const data = line.slice(5).trim();
|
|
181
|
-
if (data === "[DONE]") {
|
|
182
|
-
stopped = true;
|
|
183
|
-
break;
|
|
184
|
-
}
|
|
185
|
-
let chunk;
|
|
186
|
-
try {
|
|
187
|
-
chunk = JSON.parse(data);
|
|
188
|
-
}
|
|
189
|
-
catch {
|
|
190
|
-
continue;
|
|
191
|
-
}
|
|
192
|
-
const type = chunk.type;
|
|
193
|
-
if (type === "content_block_delta") {
|
|
194
|
-
const text = chunk.delta?.text;
|
|
195
|
-
if (text)
|
|
196
|
-
yield text;
|
|
197
|
-
}
|
|
198
|
-
else if (type === "message_stop") {
|
|
199
|
-
stopped = true;
|
|
383
|
+
try {
|
|
384
|
+
while (!stopped) {
|
|
385
|
+
const { done, value } = await reader.read();
|
|
386
|
+
if (done)
|
|
200
387
|
break;
|
|
388
|
+
buf += decoder.decode(value, { stream: true });
|
|
389
|
+
let idx;
|
|
390
|
+
while ((idx = buf.indexOf("\n")) >= 0) {
|
|
391
|
+
const raw = buf.slice(0, idx).replace(/\r$/, "");
|
|
392
|
+
buf = buf.slice(idx + 1);
|
|
393
|
+
const line = raw.trim();
|
|
394
|
+
if (!line || !line.startsWith("data:"))
|
|
395
|
+
continue;
|
|
396
|
+
const data = line.slice(5).trim();
|
|
397
|
+
if (data === "[DONE]") {
|
|
398
|
+
stopped = true;
|
|
399
|
+
break;
|
|
400
|
+
}
|
|
401
|
+
let chunk;
|
|
402
|
+
try {
|
|
403
|
+
chunk = JSON.parse(data);
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
const type = chunk.type;
|
|
409
|
+
if (type === "content_block_delta") {
|
|
410
|
+
const text = chunk.delta?.text;
|
|
411
|
+
if (text)
|
|
412
|
+
yield text;
|
|
413
|
+
}
|
|
414
|
+
else if (type === "message_stop") {
|
|
415
|
+
stopped = true;
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
201
418
|
}
|
|
202
419
|
}
|
|
203
420
|
}
|
|
421
|
+
finally {
|
|
422
|
+
try {
|
|
423
|
+
await reader.cancel();
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
// Already closed / errored; nothing left to release.
|
|
427
|
+
}
|
|
428
|
+
}
|
|
204
429
|
}
|
|
205
430
|
}
|
package/dist/markdown.js
CHANGED
|
@@ -566,7 +566,7 @@ function renderTable(t, width) {
|
|
|
566
566
|
for (let c = 0; c < nCols; c++) {
|
|
567
567
|
if (c === 0)
|
|
568
568
|
spans.push({ text: "│ ", style: borderStyle });
|
|
569
|
-
const cellLine = wrapped[c][li]?.spans ?? [];
|
|
569
|
+
const cellLine = wrapped[c]?.[li]?.spans ?? [];
|
|
570
570
|
for (const s of cellLine) {
|
|
571
571
|
spans.push(isHeader ? { ...s, style: { ...s.style, bold: true } } : s);
|
|
572
572
|
}
|
package/dist/sanitize.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text hygiene for anything that will be drawn into the terminal Buffer.
|
|
3
|
+
*
|
|
4
|
+
* The renderer copies a cell's glyph straight into the output stream, so a cell
|
|
5
|
+
* that somehow holds a *control* character turns into a command the terminal
|
|
6
|
+
* executes: an ESC sequence can move the cursor, clear the screen or leave the
|
|
7
|
+
* alternate buffer, and a TAB shifts the rest of the row. Tool output (ANSI
|
|
8
|
+
* colours from `ls --color`, `git`, `npm`, progress bars, …), file contents and
|
|
9
|
+
* pasted text routinely carry such bytes, so they must be neutralised before
|
|
10
|
+
* they can reach the screen.
|
|
11
|
+
*
|
|
12
|
+
* `sanitizeText` is the *display-side* cleanup: it removes whole escape
|
|
13
|
+
* sequences (not just the ESC byte, which would leave `[31m` behind as literal
|
|
14
|
+
* text), expands tabs so every character's measured width matches what the
|
|
15
|
+
* terminal will draw, and drops any remaining control code. Newlines are kept
|
|
16
|
+
* because the wrapping/markdown layer needs them to lay text out.
|
|
17
|
+
*/
|
|
18
|
+
// Control Sequence Introducer: ESC [ params intermediates final
|
|
19
|
+
const CSI = /\x1b\[[0-?]*[ -/]*[@-~]/g;
|
|
20
|
+
// Operating System Command: ESC ] … (BEL | ST)
|
|
21
|
+
const OSC = /\x1b\][\s\S]*?(?:\x07|\x1b\\)/g;
|
|
22
|
+
// Device Control / Privacy Message / Application Program Command … ST
|
|
23
|
+
const DCS = /\x1b[P^_X][\s\S]*?\x1b\\/g;
|
|
24
|
+
// Two-character Fe/Fs/Fp escapes (ESC 7, ESC =, ESC (B, …)
|
|
25
|
+
const ESC_SIMPLE = /\x1b[ -/]*[@-~]/g;
|
|
26
|
+
// A lone ESC that nothing else matched.
|
|
27
|
+
const ESC_LONE = /\x1b/g;
|
|
28
|
+
/** Remove ANSI/VT escape sequences from `text`. */
|
|
29
|
+
export function stripAnsi(text) {
|
|
30
|
+
return text
|
|
31
|
+
.replace(OSC, "")
|
|
32
|
+
.replace(DCS, "")
|
|
33
|
+
.replace(CSI, "")
|
|
34
|
+
.replace(ESC_SIMPLE, "")
|
|
35
|
+
.replace(ESC_LONE, "");
|
|
36
|
+
}
|
|
37
|
+
// C0 controls (except LF, which the layout engine needs) plus DEL and C1.
|
|
38
|
+
const OTHER_CONTROLS = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g;
|
|
39
|
+
/**
|
|
40
|
+
* Make `text` safe to render: normalise newlines, drop escape sequences, expand
|
|
41
|
+
* tabs and remove every other control code. The result contains no character
|
|
42
|
+
* the terminal would interpret as a command, so it can never corrupt the grid.
|
|
43
|
+
*/
|
|
44
|
+
export function sanitizeText(text, opts = {}) {
|
|
45
|
+
const tabWidth = opts.tabWidth ?? 4;
|
|
46
|
+
let s = text.replace(/\r\n?/g, "\n");
|
|
47
|
+
s = stripAnsi(s);
|
|
48
|
+
if (tabWidth > 0)
|
|
49
|
+
s = s.replace(/\t/g, " ".repeat(tabWidth));
|
|
50
|
+
s = s.replace(OTHER_CONTROLS, "");
|
|
51
|
+
if (opts.singleLine)
|
|
52
|
+
s = s.replace(/\s*\n\s*/g, " ");
|
|
53
|
+
return s;
|
|
54
|
+
}
|
|
55
|
+
/** True if `ch` is a character the terminal would treat as a control code. */
|
|
56
|
+
export function isControlChar(ch) {
|
|
57
|
+
if (!ch)
|
|
58
|
+
return false;
|
|
59
|
+
const cp = ch.codePointAt(0) ?? 0;
|
|
60
|
+
return cp < 32 || (cp >= 0x7f && cp <= 0x9f);
|
|
61
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Screen } from "../terminal/screen.js";
|
|
2
2
|
import { C } from "../colors.js";
|
|
3
3
|
import { drawHeader } from "../header.js";
|
|
4
|
-
import { drawHint } from "../ui.js";
|
|
4
|
+
import { drawHint, drawScrollbar } from "../ui.js";
|
|
5
5
|
import { drawSpans, renderMarkdownText } from "../markdown.js";
|
|
6
6
|
import { ellipsize } from "../width.js";
|
|
7
7
|
export class AgentConversationScreen extends Screen {
|
|
@@ -9,6 +9,7 @@ export class AgentConversationScreen extends Screen {
|
|
|
9
9
|
messages;
|
|
10
10
|
scrollTop = 0;
|
|
11
11
|
followBottom = true;
|
|
12
|
+
scrollbarGeom = null;
|
|
12
13
|
constructor(title, messages) {
|
|
13
14
|
super();
|
|
14
15
|
this.title = title;
|
|
@@ -52,6 +53,25 @@ export class AgentConversationScreen extends Screen {
|
|
|
52
53
|
this.app.popScreen();
|
|
53
54
|
}
|
|
54
55
|
}
|
|
56
|
+
scrollbar() {
|
|
57
|
+
const g = this.scrollbarGeom;
|
|
58
|
+
if (!g || g.total <= g.viewport)
|
|
59
|
+
return null;
|
|
60
|
+
return {
|
|
61
|
+
x: g.x,
|
|
62
|
+
top: g.top,
|
|
63
|
+
bottom: g.bottom,
|
|
64
|
+
total: g.total,
|
|
65
|
+
viewport: g.viewport,
|
|
66
|
+
offset: this.scrollTop,
|
|
67
|
+
scrollTo: (offset) => {
|
|
68
|
+
const maxScroll = Math.max(0, g.total - g.viewport);
|
|
69
|
+
this.scrollTop = Math.max(0, Math.min(offset, maxScroll));
|
|
70
|
+
this.followBottom = this.scrollTop >= maxScroll;
|
|
71
|
+
this.app.render();
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
55
75
|
buildLines(width) {
|
|
56
76
|
const lines = [];
|
|
57
77
|
for (const m of this.messages) {
|
|
@@ -85,12 +105,19 @@ export class AgentConversationScreen extends Screen {
|
|
|
85
105
|
}
|
|
86
106
|
if (this.scrollTop < 0)
|
|
87
107
|
this.scrollTop = 0;
|
|
108
|
+
// Expose the bar so the app can drag it (see the scrollbar() method).
|
|
109
|
+
this.scrollbarGeom =
|
|
110
|
+
lines.length > viewH
|
|
111
|
+
? { x: w - 1, top: logTop, bottom: logBottom, total: lines.length, viewport: viewH }
|
|
112
|
+
: null;
|
|
88
113
|
for (let y = logTop; y <= logBottom; y++) {
|
|
89
114
|
const li = this.scrollTop + (y - logTop);
|
|
90
115
|
if (li >= lines.length)
|
|
91
116
|
break;
|
|
92
117
|
drawSpans(buf, 2, y, lines[li].spans, Math.max(4, w - 4));
|
|
93
118
|
}
|
|
94
|
-
|
|
119
|
+
// Scrollbar on the right edge, visible only when the log overflows.
|
|
120
|
+
drawScrollbar(buf, w - 1, logTop, logBottom, lines.length, viewH, this.scrollTop);
|
|
121
|
+
drawHint(buf, h - 1, "↑/↓ scroll · wheel or drag the bar · right-click back · Esc back");
|
|
95
122
|
}
|
|
96
123
|
}
|