@xingjisoft/neutrino 0.1.3 → 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 +27 -1
- package/dist/app.js +20 -1
- package/dist/compact.js +68 -0
- package/dist/config.js +15 -0
- package/dist/llm/client.js +309 -84
- package/dist/sanitize.js +61 -0
- package/dist/screens/chat.js +243 -26
- package/dist/screens/settings.js +83 -44
- package/dist/screens/start.js +14 -0
- package/dist/selection.js +25 -11
- package/dist/terminal/buffer.js +24 -3
- package/dist/terminal/renderer.js +41 -3
- package/dist/terminal/screen.js +15 -0
- package/dist/ui.js +3 -0
- package/package.json +2 -2
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
|
+
}
|
package/dist/screens/chat.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { Screen } from "../terminal/screen.js";
|
|
2
2
|
import { C } from "../colors.js";
|
|
3
|
-
import { loadPrompt, saveSession, } from "../config.js";
|
|
3
|
+
import { loadConfig, loadPrompt, saveSession, } from "../config.js";
|
|
4
4
|
import { drawHeader } from "../header.js";
|
|
5
5
|
import { computeInputWindow, cursorFromColumn, drawInputBox, drawScrollbar, wrapText } from "../ui.js";
|
|
6
6
|
import { drawSpans, renderMarkdownText } from "../markdown.js";
|
|
7
7
|
import { ellipsize, displayWidth } from "../width.js";
|
|
8
|
-
import {
|
|
8
|
+
import { sanitizeText } from "../sanitize.js";
|
|
9
|
+
import { LLMClient, describeError } from "../llm/client.js";
|
|
9
10
|
import { executeTool, getToolDefinitions } from "../llm/tools.js";
|
|
11
|
+
import { COMPACT_KEEP, COMPACT_MAX_CHARS_PER_MESSAGE, planCompaction, transcriptFor, } from "../compact.js";
|
|
10
12
|
const TOOLS = getToolDefinitions();
|
|
11
13
|
function parseArgs(s) {
|
|
12
14
|
if (!s || !s.trim())
|
|
@@ -45,6 +47,20 @@ export class ChatScreen extends Screen {
|
|
|
45
47
|
scrollbarGeom = null;
|
|
46
48
|
lastEscTime = 0;
|
|
47
49
|
initialized = false;
|
|
50
|
+
// ---- auto context compression ----
|
|
51
|
+
/** Prompt tokens of the most recent request — the best proxy for how large
|
|
52
|
+
* the current context has grown. Reset once the history is compressed. */
|
|
53
|
+
lastPromptTokens = 0;
|
|
54
|
+
compressing = false;
|
|
55
|
+
// ---- collapsible tool output ----
|
|
56
|
+
/** Message indices whose tool output is expanded (everything starts
|
|
57
|
+
* collapsed). */
|
|
58
|
+
expandedTools = new Set();
|
|
59
|
+
/** For each line produced by buildLines, the tool message index it belongs
|
|
60
|
+
* to, or -1 when it is not part of a tool block. */
|
|
61
|
+
toolLineIndex = [];
|
|
62
|
+
/** Terminal row -> tool message index, rebuilt each render for hit-testing. */
|
|
63
|
+
toolRows = new Map();
|
|
48
64
|
constructor(initialMessage = "") {
|
|
49
65
|
super();
|
|
50
66
|
this.initialMessage = initialMessage;
|
|
@@ -79,11 +95,30 @@ export class ChatScreen extends Screen {
|
|
|
79
95
|
this.app.render();
|
|
80
96
|
}
|
|
81
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* The input row is persistent chrome: it is drawn with absolute positioning
|
|
100
|
+
* and must survive any terminal-side drift (ConPTY reflow, a stray scroll).
|
|
101
|
+
* Repainting it every frame makes the input area self-healing — the log can
|
|
102
|
+
* never end up "covering" it.
|
|
103
|
+
*/
|
|
104
|
+
alwaysRepaintRows() {
|
|
105
|
+
const h = this.app.height;
|
|
106
|
+
const rows = [];
|
|
107
|
+
for (let y = h - 5; y <= h - 1; y++)
|
|
108
|
+
if (y >= 0)
|
|
109
|
+
rows.push(y);
|
|
110
|
+
return rows;
|
|
111
|
+
}
|
|
82
112
|
showsCursor() {
|
|
83
113
|
// The chat input is always focused; a cursor is drawn when it holds text.
|
|
84
114
|
return this.inputValue.length > 0;
|
|
85
115
|
}
|
|
86
116
|
onPaste(text) {
|
|
117
|
+
if (!text)
|
|
118
|
+
return;
|
|
119
|
+
// A pasted tab/ESC would otherwise sit in the input box and be re-emitted
|
|
120
|
+
// by the renderer on every frame, corrupting the rows around it.
|
|
121
|
+
text = sanitizeText(text, { singleLine: true });
|
|
87
122
|
if (!text)
|
|
88
123
|
return;
|
|
89
124
|
if (this.showInfo)
|
|
@@ -186,6 +221,7 @@ export class ChatScreen extends Screen {
|
|
|
186
221
|
if (key.ctrl && key.name === "l") {
|
|
187
222
|
const sysText = this.messages[0] && this.messages[0].role === "system" ? this.messages[0].content : "";
|
|
188
223
|
this.messages = [{ role: "system", content: sysText }];
|
|
224
|
+
this.expandedTools.clear();
|
|
189
225
|
this.streamingContent = null;
|
|
190
226
|
this.scrollTop = 0;
|
|
191
227
|
this.followBottom = true;
|
|
@@ -235,6 +271,12 @@ export class ChatScreen extends Screen {
|
|
|
235
271
|
this.app.render();
|
|
236
272
|
return;
|
|
237
273
|
}
|
|
274
|
+
// A click inside the conversation log toggles a tool block open/closed.
|
|
275
|
+
const tool = this.toolRows.get(ev.y);
|
|
276
|
+
if (tool !== undefined) {
|
|
277
|
+
this.toggleTool(tool);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
238
280
|
return;
|
|
239
281
|
}
|
|
240
282
|
if (ev.type === "wheel") {
|
|
@@ -307,10 +349,17 @@ export class ChatScreen extends Screen {
|
|
|
307
349
|
this.paused = false;
|
|
308
350
|
this.app.state.isThinking = true;
|
|
309
351
|
const token = ++this.turnToken;
|
|
310
|
-
const apiMessages = this.buildApiMessages();
|
|
311
352
|
this.streamingContent = "";
|
|
312
353
|
this.app.render();
|
|
313
|
-
void
|
|
354
|
+
void (async () => {
|
|
355
|
+
// Shrink the history before building the request when it has outgrown the
|
|
356
|
+
// configured budget.
|
|
357
|
+
await this.autoCompress(token);
|
|
358
|
+
if (token !== this.turnToken)
|
|
359
|
+
return;
|
|
360
|
+
const apiMessages = this.buildApiMessages();
|
|
361
|
+
await this.agentLoop(apiMessages, token);
|
|
362
|
+
})();
|
|
314
363
|
}
|
|
315
364
|
buildApiMessages() {
|
|
316
365
|
const api = [{ role: "system", content: loadPrompt() }];
|
|
@@ -318,6 +367,10 @@ export class ChatScreen extends Screen {
|
|
|
318
367
|
if (m.role === "user" || m.role === "assistant") {
|
|
319
368
|
api.push({ role: m.role, content: m.content });
|
|
320
369
|
}
|
|
370
|
+
else if (m.role === "summary") {
|
|
371
|
+
// A compressed summary is fed back to the model as its own prior output.
|
|
372
|
+
api.push({ role: "assistant", content: m.content });
|
|
373
|
+
}
|
|
321
374
|
}
|
|
322
375
|
return api;
|
|
323
376
|
}
|
|
@@ -326,6 +379,9 @@ export class ChatScreen extends Screen {
|
|
|
326
379
|
while (true) {
|
|
327
380
|
if (token !== this.turnToken)
|
|
328
381
|
return;
|
|
382
|
+
// Keep the payload inside budget during a long single turn: once the
|
|
383
|
+
// context is too large, drop the body of older tool results.
|
|
384
|
+
apiMessages = this.trimApiMessages(apiMessages);
|
|
329
385
|
const client = new LLMClient(this.model, this.modelProvider, this.intensity);
|
|
330
386
|
let content = "";
|
|
331
387
|
let hasContent = false;
|
|
@@ -343,6 +399,7 @@ export class ChatScreen extends Screen {
|
|
|
343
399
|
if (client.usage) {
|
|
344
400
|
this.tokens.prompt += client.usage.prompt;
|
|
345
401
|
this.tokens.completion += client.usage.completion;
|
|
402
|
+
this.lastPromptTokens = client.usage.prompt;
|
|
346
403
|
}
|
|
347
404
|
else {
|
|
348
405
|
this.tokens.completion += Math.max(1, Math.ceil(content.length / 4));
|
|
@@ -399,7 +456,7 @@ export class ChatScreen extends Screen {
|
|
|
399
456
|
this.onTurnFinished();
|
|
400
457
|
}
|
|
401
458
|
catch (e) {
|
|
402
|
-
const err =
|
|
459
|
+
const err = describeError(e);
|
|
403
460
|
this.messages.push({ role: "assistant", content: (this.streamingContent ?? "") + `\n[Error: ${err}]` });
|
|
404
461
|
this.streamingContent = null;
|
|
405
462
|
this.onTurnFinished();
|
|
@@ -413,8 +470,121 @@ export class ChatScreen extends Screen {
|
|
|
413
470
|
this.app.render();
|
|
414
471
|
this.maybeFlushQueue();
|
|
415
472
|
}
|
|
473
|
+
/** Expand/collapse the tool block belonging to message `index`. */
|
|
474
|
+
toggleTool(index) {
|
|
475
|
+
if (this.expandedTools.has(index))
|
|
476
|
+
this.expandedTools.delete(index);
|
|
477
|
+
else
|
|
478
|
+
this.expandedTools.add(index);
|
|
479
|
+
this.app.render();
|
|
480
|
+
}
|
|
481
|
+
/** Auto-compress threshold in (estimated) prompt tokens; 0 means "off". */
|
|
482
|
+
compressThreshold() {
|
|
483
|
+
const raw = loadConfig().context_compress_threshold;
|
|
484
|
+
const n = typeof raw === "number" ? raw : Number(raw);
|
|
485
|
+
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Once the context has grown past the configured threshold, replace the older
|
|
489
|
+
* part of the conversation with a model-written summary. Runs at the start of
|
|
490
|
+
* a turn, before the request is built. If summarising fails the old messages
|
|
491
|
+
* are simply dropped, so the context always shrinks.
|
|
492
|
+
*/
|
|
493
|
+
async autoCompress(token) {
|
|
494
|
+
const threshold = this.compressThreshold();
|
|
495
|
+
if (threshold <= 0 || this.lastPromptTokens < threshold)
|
|
496
|
+
return;
|
|
497
|
+
const system = this.messages.find((m) => m.role === "system");
|
|
498
|
+
const rest = this.messages.filter((m) => m.role !== "system");
|
|
499
|
+
const items = rest.map((m) => ({
|
|
500
|
+
role: m.role,
|
|
501
|
+
content: m.role === "tool" ? m.result : m.content ?? "",
|
|
502
|
+
name: m.role === "tool" ? m.name : undefined,
|
|
503
|
+
ref: m,
|
|
504
|
+
}));
|
|
505
|
+
const plan = planCompaction(items, COMPACT_KEEP);
|
|
506
|
+
if (!plan)
|
|
507
|
+
return;
|
|
508
|
+
const transcript = transcriptFor(plan.head, COMPACT_MAX_CHARS_PER_MESSAGE);
|
|
509
|
+
this.compressing = true;
|
|
510
|
+
this.app.render();
|
|
511
|
+
const snapshotLen = this.messages.length;
|
|
512
|
+
let summary = "";
|
|
513
|
+
try {
|
|
514
|
+
summary = await this.summarizeTranscript(transcript);
|
|
515
|
+
}
|
|
516
|
+
catch {
|
|
517
|
+
summary = "";
|
|
518
|
+
}
|
|
519
|
+
this.compressing = false;
|
|
520
|
+
if (token !== this.turnToken)
|
|
521
|
+
return;
|
|
522
|
+
// A new message (queued by the user while we were summarising) would be
|
|
523
|
+
// dropped by the swap below, so bail out and retry on the next turn.
|
|
524
|
+
if (this.messages.length !== snapshotLen) {
|
|
525
|
+
this.app.render();
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
const count = plan.head.length;
|
|
529
|
+
const content = summary
|
|
530
|
+
? `[Context compressed]\n${summary}`
|
|
531
|
+
: `[Context compressed]\n(${count} earlier messages were omitted to free up context.)`;
|
|
532
|
+
const summaryMsg = { role: "summary", content, count };
|
|
533
|
+
const kept = plan.tail.map((i) => i.ref);
|
|
534
|
+
this.messages = system ? [system, summaryMsg, ...kept] : [summaryMsg, ...kept];
|
|
535
|
+
// Message indices shifted, so any expanded tool block no longer lines up.
|
|
536
|
+
this.expandedTools.clear();
|
|
537
|
+
// The context is small again; its real size is re-learned from the next reply.
|
|
538
|
+
this.lastPromptTokens = 0;
|
|
539
|
+
this.save();
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Summarise a transcript produced by `transcriptFor`. Split out so tests can
|
|
543
|
+
* stub it without touching the network.
|
|
544
|
+
*/
|
|
545
|
+
async summarizeTranscript(transcript) {
|
|
546
|
+
const client = new LLMClient(this.model, this.modelProvider, this.intensity);
|
|
547
|
+
const text = await client.summarize(transcript);
|
|
548
|
+
if (client.usage) {
|
|
549
|
+
this.tokens.prompt += client.usage.prompt;
|
|
550
|
+
this.tokens.completion += client.usage.completion;
|
|
551
|
+
}
|
|
552
|
+
return text;
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Keep the outbound payload within budget during a long single turn: once the
|
|
556
|
+
* context is over the threshold, blank out all but the most recent tool
|
|
557
|
+
* results. Their full text stays in the visible log, which is collapsible.
|
|
558
|
+
*/
|
|
559
|
+
trimApiMessages(apiMessages) {
|
|
560
|
+
const threshold = this.compressThreshold();
|
|
561
|
+
if (threshold <= 0 || this.lastPromptTokens < threshold)
|
|
562
|
+
return apiMessages;
|
|
563
|
+
const toolIdx = [];
|
|
564
|
+
for (let i = 0; i < apiMessages.length; i++) {
|
|
565
|
+
if (apiMessages[i].role === "tool")
|
|
566
|
+
toolIdx.push(i);
|
|
567
|
+
}
|
|
568
|
+
const dropCount = toolIdx.length - COMPACT_KEEP;
|
|
569
|
+
if (dropCount <= 0)
|
|
570
|
+
return apiMessages;
|
|
571
|
+
const copy = apiMessages.slice();
|
|
572
|
+
for (let k = 0; k < dropCount; k++) {
|
|
573
|
+
copy[toolIdx[k]] = { ...copy[toolIdx[k]], content: "[tool output omitted to save context]" };
|
|
574
|
+
}
|
|
575
|
+
return copy;
|
|
576
|
+
}
|
|
416
577
|
save() {
|
|
417
|
-
const chatMessages =
|
|
578
|
+
const chatMessages = [];
|
|
579
|
+
for (const m of this.messages) {
|
|
580
|
+
if (m.role === "user" || m.role === "assistant" || m.role === "tool") {
|
|
581
|
+
chatMessages.push(m);
|
|
582
|
+
}
|
|
583
|
+
else if (m.role === "summary") {
|
|
584
|
+
// Persist a compressed summary as a plain assistant message.
|
|
585
|
+
chatMessages.push({ role: "assistant", content: m.content });
|
|
586
|
+
}
|
|
587
|
+
}
|
|
418
588
|
if (chatMessages.length === 0)
|
|
419
589
|
return;
|
|
420
590
|
try {
|
|
@@ -426,38 +596,75 @@ export class ChatScreen extends Screen {
|
|
|
426
596
|
}
|
|
427
597
|
buildLines(width) {
|
|
428
598
|
const lines = [];
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
599
|
+
// Parallel map from rendered line -> owning tool message index (-1 = none),
|
|
600
|
+
// used to turn a click on any line of a tool block into an expand/collapse.
|
|
601
|
+
const toolLineIndex = [];
|
|
602
|
+
const add = (line, tool = -1) => {
|
|
603
|
+
lines.push(line);
|
|
604
|
+
toolLineIndex.push(tool);
|
|
433
605
|
};
|
|
434
|
-
|
|
606
|
+
const pushText = (text, style, tool = -1) => {
|
|
607
|
+
for (const ln of wrapText(text, width))
|
|
608
|
+
add({ spans: [{ text: ln, style }] }, tool);
|
|
609
|
+
};
|
|
610
|
+
const pushMarkdown = (text, style) => {
|
|
611
|
+
for (const ln of renderMarkdownText(text, width, style))
|
|
612
|
+
add(ln);
|
|
613
|
+
};
|
|
614
|
+
for (let mi = 0; mi < this.messages.length; mi++) {
|
|
615
|
+
const m = this.messages[mi];
|
|
435
616
|
if (m.role === "system") {
|
|
436
|
-
pushText(m.content, { fg: C.dim });
|
|
617
|
+
pushText(sanitizeText(m.content), { fg: C.dim });
|
|
437
618
|
}
|
|
438
619
|
else if (m.role === "user") {
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
620
|
+
add({ spans: [{ text: "You:", style: { fg: C.accent, bold: true } }] });
|
|
621
|
+
pushMarkdown(sanitizeText(m.content), { fg: C.user });
|
|
622
|
+
add({ spans: [] });
|
|
442
623
|
}
|
|
443
624
|
else if (m.role === "assistant") {
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
625
|
+
add({ spans: [{ text: "Neutrino:", style: { fg: C.accent, bold: true } }] });
|
|
626
|
+
pushMarkdown(sanitizeText(m.content), { fg: C.assistant });
|
|
627
|
+
add({ spans: [] });
|
|
628
|
+
}
|
|
629
|
+
else if (m.role === "summary") {
|
|
630
|
+
add({
|
|
631
|
+
spans: [
|
|
632
|
+
{
|
|
633
|
+
text: `✂ Context compressed (${m.count} earlier messages)`,
|
|
634
|
+
style: { fg: C.amber, bold: true },
|
|
635
|
+
},
|
|
636
|
+
],
|
|
637
|
+
});
|
|
638
|
+
pushText(sanitizeText(m.content), { fg: C.dim });
|
|
639
|
+
add({ spans: [] });
|
|
447
640
|
}
|
|
448
641
|
else if (m.role === "tool") {
|
|
449
|
-
|
|
450
|
-
|
|
642
|
+
// Header + the command/path always show; the (often huge) output body is
|
|
643
|
+
// collapsed by default and toggled by clicking the block.
|
|
644
|
+
add({ spans: [{ text: `⚙ ${m.name}`, style: { fg: C.toolHeader, bold: true } }] }, mi);
|
|
645
|
+
const argText = sanitizeText(m.arguments.command ?? m.arguments.path ?? "", { singleLine: true });
|
|
451
646
|
if (argText)
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
647
|
+
add({ spans: [{ text: " " + argText, style: { fg: C.dim } }] }, mi);
|
|
648
|
+
// Command output routinely carries ANSI colours and tabs; clean it once
|
|
649
|
+
// so the collapsed line count and the expanded body agree.
|
|
650
|
+
const body = sanitizeText(m.result || "");
|
|
651
|
+
if (this.expandedTools.has(mi)) {
|
|
652
|
+
pushText(body || "(no output)", { fg: C.dim }, mi);
|
|
653
|
+
add({ spans: [{ text: " ▾ click to collapse", style: { fg: C.toolHeader } }] }, mi);
|
|
654
|
+
}
|
|
655
|
+
else {
|
|
656
|
+
const n = body ? body.split("\n").length : 0;
|
|
657
|
+
const summary = n > 0 ? `${n} line${n === 1 ? "" : "s"} hidden` : "no output";
|
|
658
|
+
add({ spans: [{ text: ` ▸ ${summary} — click to expand`, style: { fg: C.dim } }] }, mi);
|
|
659
|
+
}
|
|
660
|
+
add({ spans: [] });
|
|
455
661
|
}
|
|
456
662
|
}
|
|
457
663
|
if (this.streamingContent !== null) {
|
|
458
|
-
|
|
459
|
-
|
|
664
|
+
add({ spans: [{ text: "Neutrino:", style: { fg: C.accent, bold: true } }] });
|
|
665
|
+
pushMarkdown(sanitizeText(this.streamingContent), { fg: C.assistant });
|
|
460
666
|
}
|
|
667
|
+
this.toolLineIndex = toolLineIndex;
|
|
461
668
|
return lines;
|
|
462
669
|
}
|
|
463
670
|
render(buf) {
|
|
@@ -466,7 +673,11 @@ export class ChatScreen extends Screen {
|
|
|
466
673
|
const h = buf.height;
|
|
467
674
|
let status = "";
|
|
468
675
|
let statusStyle = { fg: C.dim };
|
|
469
|
-
if (this.
|
|
676
|
+
if (this.compressing) {
|
|
677
|
+
status = "Compressing context…";
|
|
678
|
+
statusStyle = { fg: C.amber, bold: true };
|
|
679
|
+
}
|
|
680
|
+
else if (this.queued) {
|
|
470
681
|
status = "Will send when paused or idle...";
|
|
471
682
|
statusStyle = { fg: C.amber };
|
|
472
683
|
}
|
|
@@ -500,10 +711,16 @@ export class ChatScreen extends Screen {
|
|
|
500
711
|
lines.length > viewH
|
|
501
712
|
? { x: w - 1, top: logTop, bottom: logBottom, total: lines.length, viewport: viewH }
|
|
502
713
|
: null;
|
|
714
|
+
// Remember which tool block (if any) each visible row belongs to, so a
|
|
715
|
+
// click can expand/collapse it (see the onMouse handler).
|
|
716
|
+
this.toolRows.clear();
|
|
503
717
|
for (let y = logTop; y <= logBottom; y++) {
|
|
504
718
|
const li = this.scrollTop + (y - logTop);
|
|
505
719
|
if (li >= lines.length)
|
|
506
720
|
break;
|
|
721
|
+
const tool = this.toolLineIndex[li];
|
|
722
|
+
if (tool !== undefined && tool >= 0)
|
|
723
|
+
this.toolRows.set(y, tool);
|
|
507
724
|
drawSpans(buf, 2, y, lines[li].spans, Math.max(4, w - 4));
|
|
508
725
|
}
|
|
509
726
|
// A scrollbar on the right edge of the log, shown only when the
|
package/dist/screens/settings.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { Screen } from "../terminal/screen.js";
|
|
2
2
|
import { C } from "../colors.js";
|
|
3
|
-
import {
|
|
3
|
+
import { COMPRESS_THRESHOLD_OPTIONS, CONFIG_FILE, INTENSITY_LEVELS, loadConfig, saveConfig, } from "../config.js";
|
|
4
4
|
import { drawHeader } from "../header.js";
|
|
5
5
|
import { centerVertically, drawHint } from "../ui.js";
|
|
6
|
+
/** The settings that can be changed here, top to bottom. */
|
|
7
|
+
const ROWS = ["intensity", "compress"];
|
|
6
8
|
export class SettingsScreen extends Screen {
|
|
7
9
|
intensityIdx = 2;
|
|
10
|
+
thresholdIdx = 3;
|
|
11
|
+
rowIdx = 0;
|
|
8
12
|
lastHoverX = -1;
|
|
9
13
|
lastHoverY = -1;
|
|
10
14
|
onActivate() {
|
|
@@ -12,18 +16,42 @@ export class SettingsScreen extends Screen {
|
|
|
12
16
|
const current = String(config.default_intensity ?? "High");
|
|
13
17
|
const i = INTENSITY_LEVELS.indexOf(current);
|
|
14
18
|
this.intensityIdx = i >= 0 ? i : 2;
|
|
19
|
+
const threshold = Number(config.context_compress_threshold ?? 32000);
|
|
20
|
+
const t = COMPRESS_THRESHOLD_OPTIONS.findIndex((o) => o.value === threshold);
|
|
21
|
+
this.thresholdIdx = t >= 0 ? t : 3;
|
|
22
|
+
}
|
|
23
|
+
/** Change the value of a row by `delta` steps and persist it immediately. */
|
|
24
|
+
cycle(row, delta) {
|
|
25
|
+
if (row === "intensity") {
|
|
26
|
+
const n = INTENSITY_LEVELS.length;
|
|
27
|
+
this.intensityIdx = (this.intensityIdx + delta + n) % n;
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
const n = COMPRESS_THRESHOLD_OPTIONS.length;
|
|
31
|
+
this.thresholdIdx = (this.thresholdIdx + delta + n) % n;
|
|
32
|
+
}
|
|
33
|
+
this.save();
|
|
15
34
|
}
|
|
16
35
|
onKey(key) {
|
|
17
36
|
if (key.name === "up") {
|
|
18
|
-
this.
|
|
37
|
+
this.rowIdx = (this.rowIdx + ROWS.length - 1) % ROWS.length;
|
|
19
38
|
this.app.render();
|
|
20
39
|
}
|
|
21
40
|
else if (key.name === "down") {
|
|
22
|
-
this.
|
|
41
|
+
this.rowIdx = (this.rowIdx + 1) % ROWS.length;
|
|
42
|
+
this.app.render();
|
|
43
|
+
}
|
|
44
|
+
else if (key.name === "left") {
|
|
45
|
+
this.cycle(ROWS[this.rowIdx], -1);
|
|
46
|
+
this.app.render();
|
|
47
|
+
}
|
|
48
|
+
else if (key.name === "right") {
|
|
49
|
+
this.cycle(ROWS[this.rowIdx], 1);
|
|
23
50
|
this.app.render();
|
|
24
51
|
}
|
|
25
52
|
else if (key.name === "enter" || key.name === "return") {
|
|
26
53
|
this.save();
|
|
54
|
+
this.app.render();
|
|
27
55
|
}
|
|
28
56
|
else if (key.name === "escape") {
|
|
29
57
|
this.save();
|
|
@@ -39,9 +67,9 @@ export class SettingsScreen extends Screen {
|
|
|
39
67
|
return;
|
|
40
68
|
this.lastHoverX = ev.x;
|
|
41
69
|
this.lastHoverY = ev.y;
|
|
42
|
-
for (let i = 0; i < layout.
|
|
43
|
-
if (ev.y === layout.
|
|
44
|
-
this.
|
|
70
|
+
for (let i = 0; i < layout.rowYs.length; i++) {
|
|
71
|
+
if (ev.y === layout.rowYs[i] && i !== this.rowIdx) {
|
|
72
|
+
this.rowIdx = i;
|
|
45
73
|
this.app.render();
|
|
46
74
|
return;
|
|
47
75
|
}
|
|
@@ -49,8 +77,7 @@ export class SettingsScreen extends Screen {
|
|
|
49
77
|
return;
|
|
50
78
|
}
|
|
51
79
|
if (ev.type === "wheel") {
|
|
52
|
-
this.
|
|
53
|
-
(this.intensityIdx + (ev.dir === -1 ? -1 : 1) + INTENSITY_LEVELS.length) % INTENSITY_LEVELS.length;
|
|
80
|
+
this.cycle(ROWS[this.rowIdx], ev.dir === -1 ? -1 : 1);
|
|
54
81
|
this.app.render();
|
|
55
82
|
return;
|
|
56
83
|
}
|
|
@@ -60,10 +87,15 @@ export class SettingsScreen extends Screen {
|
|
|
60
87
|
this.app.popScreen();
|
|
61
88
|
return;
|
|
62
89
|
}
|
|
63
|
-
for (let i = 0; i < layout.
|
|
64
|
-
if (ev.y === layout.
|
|
65
|
-
this.
|
|
66
|
-
|
|
90
|
+
for (let i = 0; i < layout.rowYs.length; i++) {
|
|
91
|
+
if (ev.y === layout.rowYs[i]) {
|
|
92
|
+
if (this.rowIdx === i) {
|
|
93
|
+
// Clicking the already-selected row advances its value.
|
|
94
|
+
this.cycle(ROWS[i], 1);
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
this.rowIdx = i;
|
|
98
|
+
}
|
|
67
99
|
this.app.render();
|
|
68
100
|
return;
|
|
69
101
|
}
|
|
@@ -74,59 +106,66 @@ export class SettingsScreen extends Screen {
|
|
|
74
106
|
}
|
|
75
107
|
}
|
|
76
108
|
}
|
|
109
|
+
rowTexts() {
|
|
110
|
+
return [
|
|
111
|
+
{ label: "Default intensity", value: INTENSITY_LEVELS[this.intensityIdx] },
|
|
112
|
+
{ label: "Auto-compress context", value: COMPRESS_THRESHOLD_OPTIONS[this.thresholdIdx].label },
|
|
113
|
+
];
|
|
114
|
+
}
|
|
77
115
|
layout() {
|
|
78
116
|
const h = this.app.height;
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
`Default intensity:`,
|
|
84
|
-
];
|
|
85
|
-
const total = lines.length + INTENSITY_LEVELS.length + 2;
|
|
117
|
+
const rows = this.rowTexts();
|
|
118
|
+
const labelW = Math.max(...rows.map((r) => r.label.length));
|
|
119
|
+
// title, blank, model, blank, option rows, blank, hint
|
|
120
|
+
const total = 6 + rows.length;
|
|
86
121
|
const top = centerVertically(h, total);
|
|
87
|
-
const
|
|
88
|
-
const hintY = top +
|
|
122
|
+
const rowYs = rows.map((_, i) => top + 4 + i);
|
|
123
|
+
const hintY = top + 5 + rows.length;
|
|
89
124
|
if (hintY > h - 1)
|
|
90
125
|
return null;
|
|
91
|
-
return {
|
|
126
|
+
return { rowYs, hintY, labelW };
|
|
92
127
|
}
|
|
93
128
|
save() {
|
|
94
129
|
const config = loadConfig();
|
|
95
130
|
config.default_intensity = INTENSITY_LEVELS[this.intensityIdx];
|
|
131
|
+
config.context_compress_threshold = COMPRESS_THRESHOLD_OPTIONS[this.thresholdIdx].value;
|
|
96
132
|
saveConfig(config);
|
|
97
133
|
}
|
|
98
134
|
render(buf) {
|
|
99
135
|
drawHeader(buf, this.app.state);
|
|
100
136
|
const h = buf.height;
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
`Model: ${this.app.state.selectedProvider}/${this.app.state.selectedModelName}`,
|
|
105
|
-
`Default intensity:`,
|
|
106
|
-
];
|
|
107
|
-
const content = INTENSITY_LEVELS.map((l, i) => ({
|
|
108
|
-
text: l,
|
|
109
|
-
selected: i === this.intensityIdx,
|
|
110
|
-
}));
|
|
111
|
-
const total = lines.length + content.length + 2;
|
|
137
|
+
const rows = this.rowTexts();
|
|
138
|
+
const labelW = Math.max(...rows.map((r) => r.label.length));
|
|
139
|
+
const total = 6 + rows.length;
|
|
112
140
|
const top = centerVertically(h, total);
|
|
113
141
|
let y = top;
|
|
114
|
-
buf.centerText(y,
|
|
142
|
+
buf.centerText(y, "Settings", { fg: C.accent, bold: true });
|
|
115
143
|
y += 2;
|
|
116
|
-
buf.centerText(y,
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
y +=
|
|
120
|
-
for (
|
|
121
|
-
const
|
|
122
|
-
|
|
144
|
+
buf.centerText(y, `Model: ${this.app.state.selectedProvider}/${this.app.state.selectedModelName}`, {
|
|
145
|
+
fg: C.assistant,
|
|
146
|
+
});
|
|
147
|
+
y += 2;
|
|
148
|
+
for (let i = 0; i < rows.length; i++) {
|
|
149
|
+
const selected = i === this.rowIdx;
|
|
150
|
+
const marker = selected ? "▸ " : " ";
|
|
151
|
+
const label = rows[i].label.padEnd(labelW);
|
|
152
|
+
const value = selected ? `◂ ${rows[i].value} ▸` : rows[i].value;
|
|
153
|
+
const text = marker + label + " " + value;
|
|
154
|
+
if (selected) {
|
|
155
|
+
buf.centerText(y, " " + text + " ", { fg: C.accent, bg: C.highlightBg, bold: true });
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
buf.centerText(y, text, { fg: C.assistant });
|
|
159
|
+
}
|
|
123
160
|
y += 1;
|
|
124
161
|
}
|
|
125
162
|
y += 1;
|
|
126
|
-
buf.centerText(y, `model.json: ${MODEL_FILE}`, { fg: C.dim });
|
|
127
|
-
y += 1;
|
|
128
163
|
if (y < h) {
|
|
129
|
-
|
|
164
|
+
buf.centerText(y, `config: ${CONFIG_FILE}`, { fg: C.dim });
|
|
165
|
+
y += 1;
|
|
166
|
+
}
|
|
167
|
+
if (y < h) {
|
|
168
|
+
drawHint(buf, y, "↑/↓ select · ←/→ change · Enter apply · right-click back · Esc back");
|
|
130
169
|
}
|
|
131
170
|
}
|
|
132
171
|
}
|