@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/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
|
-
import { computeInputWindow, cursorFromColumn, drawInputBox, wrapText } from "../ui.js";
|
|
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())
|
|
@@ -42,8 +44,23 @@ export class ChatScreen extends Screen {
|
|
|
42
44
|
cursor = 0;
|
|
43
45
|
scrollTop = 0;
|
|
44
46
|
followBottom = true;
|
|
47
|
+
scrollbarGeom = null;
|
|
45
48
|
lastEscTime = 0;
|
|
46
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();
|
|
47
64
|
constructor(initialMessage = "") {
|
|
48
65
|
super();
|
|
49
66
|
this.initialMessage = initialMessage;
|
|
@@ -78,10 +95,39 @@ export class ChatScreen extends Screen {
|
|
|
78
95
|
this.app.render();
|
|
79
96
|
}
|
|
80
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
|
+
}
|
|
81
112
|
showsCursor() {
|
|
82
113
|
// The chat input is always focused; a cursor is drawn when it holds text.
|
|
83
114
|
return this.inputValue.length > 0;
|
|
84
115
|
}
|
|
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 });
|
|
122
|
+
if (!text)
|
|
123
|
+
return;
|
|
124
|
+
if (this.showInfo)
|
|
125
|
+
this.showInfo = false;
|
|
126
|
+
this.inputValue =
|
|
127
|
+
this.inputValue.slice(0, this.cursor) + text + this.inputValue.slice(this.cursor);
|
|
128
|
+
this.cursor += text.length;
|
|
129
|
+
this.app.render();
|
|
130
|
+
}
|
|
85
131
|
onKey(key) {
|
|
86
132
|
if (this.showInfo) {
|
|
87
133
|
this.showInfo = false;
|
|
@@ -175,6 +221,7 @@ export class ChatScreen extends Screen {
|
|
|
175
221
|
if (key.ctrl && key.name === "l") {
|
|
176
222
|
const sysText = this.messages[0] && this.messages[0].role === "system" ? this.messages[0].content : "";
|
|
177
223
|
this.messages = [{ role: "system", content: sysText }];
|
|
224
|
+
this.expandedTools.clear();
|
|
178
225
|
this.streamingContent = null;
|
|
179
226
|
this.scrollTop = 0;
|
|
180
227
|
this.followBottom = true;
|
|
@@ -224,6 +271,12 @@ export class ChatScreen extends Screen {
|
|
|
224
271
|
this.app.render();
|
|
225
272
|
return;
|
|
226
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
|
+
}
|
|
227
280
|
return;
|
|
228
281
|
}
|
|
229
282
|
if (ev.type === "wheel") {
|
|
@@ -237,6 +290,26 @@ export class ChatScreen extends Screen {
|
|
|
237
290
|
this.app.render();
|
|
238
291
|
}
|
|
239
292
|
}
|
|
293
|
+
scrollbar() {
|
|
294
|
+
const g = this.scrollbarGeom;
|
|
295
|
+
if (!g || g.total <= g.viewport)
|
|
296
|
+
return null;
|
|
297
|
+
return {
|
|
298
|
+
x: g.x,
|
|
299
|
+
top: g.top,
|
|
300
|
+
bottom: g.bottom,
|
|
301
|
+
total: g.total,
|
|
302
|
+
viewport: g.viewport,
|
|
303
|
+
offset: this.scrollTop,
|
|
304
|
+
scrollTo: (offset) => {
|
|
305
|
+
const maxScroll = Math.max(0, g.total - g.viewport);
|
|
306
|
+
this.scrollTop = Math.max(0, Math.min(offset, maxScroll));
|
|
307
|
+
// Dragging to the very bottom resumes auto-following new output.
|
|
308
|
+
this.followBottom = this.scrollTop >= maxScroll;
|
|
309
|
+
this.app.render();
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
}
|
|
240
313
|
togglePause() {
|
|
241
314
|
if (!this.busy && !this.queued)
|
|
242
315
|
return;
|
|
@@ -276,10 +349,17 @@ export class ChatScreen extends Screen {
|
|
|
276
349
|
this.paused = false;
|
|
277
350
|
this.app.state.isThinking = true;
|
|
278
351
|
const token = ++this.turnToken;
|
|
279
|
-
const apiMessages = this.buildApiMessages();
|
|
280
352
|
this.streamingContent = "";
|
|
281
353
|
this.app.render();
|
|
282
|
-
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
|
+
})();
|
|
283
363
|
}
|
|
284
364
|
buildApiMessages() {
|
|
285
365
|
const api = [{ role: "system", content: loadPrompt() }];
|
|
@@ -287,6 +367,10 @@ export class ChatScreen extends Screen {
|
|
|
287
367
|
if (m.role === "user" || m.role === "assistant") {
|
|
288
368
|
api.push({ role: m.role, content: m.content });
|
|
289
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
|
+
}
|
|
290
374
|
}
|
|
291
375
|
return api;
|
|
292
376
|
}
|
|
@@ -295,6 +379,9 @@ export class ChatScreen extends Screen {
|
|
|
295
379
|
while (true) {
|
|
296
380
|
if (token !== this.turnToken)
|
|
297
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);
|
|
298
385
|
const client = new LLMClient(this.model, this.modelProvider, this.intensity);
|
|
299
386
|
let content = "";
|
|
300
387
|
let hasContent = false;
|
|
@@ -312,6 +399,7 @@ export class ChatScreen extends Screen {
|
|
|
312
399
|
if (client.usage) {
|
|
313
400
|
this.tokens.prompt += client.usage.prompt;
|
|
314
401
|
this.tokens.completion += client.usage.completion;
|
|
402
|
+
this.lastPromptTokens = client.usage.prompt;
|
|
315
403
|
}
|
|
316
404
|
else {
|
|
317
405
|
this.tokens.completion += Math.max(1, Math.ceil(content.length / 4));
|
|
@@ -368,7 +456,7 @@ export class ChatScreen extends Screen {
|
|
|
368
456
|
this.onTurnFinished();
|
|
369
457
|
}
|
|
370
458
|
catch (e) {
|
|
371
|
-
const err =
|
|
459
|
+
const err = describeError(e);
|
|
372
460
|
this.messages.push({ role: "assistant", content: (this.streamingContent ?? "") + `\n[Error: ${err}]` });
|
|
373
461
|
this.streamingContent = null;
|
|
374
462
|
this.onTurnFinished();
|
|
@@ -382,8 +470,121 @@ export class ChatScreen extends Screen {
|
|
|
382
470
|
this.app.render();
|
|
383
471
|
this.maybeFlushQueue();
|
|
384
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
|
+
}
|
|
385
577
|
save() {
|
|
386
|
-
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
|
+
}
|
|
387
588
|
if (chatMessages.length === 0)
|
|
388
589
|
return;
|
|
389
590
|
try {
|
|
@@ -395,38 +596,75 @@ export class ChatScreen extends Screen {
|
|
|
395
596
|
}
|
|
396
597
|
buildLines(width) {
|
|
397
598
|
const lines = [];
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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);
|
|
402
605
|
};
|
|
403
|
-
|
|
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];
|
|
404
616
|
if (m.role === "system") {
|
|
405
|
-
pushText(m.content, { fg: C.dim });
|
|
617
|
+
pushText(sanitizeText(m.content), { fg: C.dim });
|
|
406
618
|
}
|
|
407
619
|
else if (m.role === "user") {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
620
|
+
add({ spans: [{ text: "You:", style: { fg: C.accent, bold: true } }] });
|
|
621
|
+
pushMarkdown(sanitizeText(m.content), { fg: C.user });
|
|
622
|
+
add({ spans: [] });
|
|
411
623
|
}
|
|
412
624
|
else if (m.role === "assistant") {
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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: [] });
|
|
416
640
|
}
|
|
417
641
|
else if (m.role === "tool") {
|
|
418
|
-
|
|
419
|
-
|
|
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 });
|
|
420
646
|
if (argText)
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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: [] });
|
|
424
661
|
}
|
|
425
662
|
}
|
|
426
663
|
if (this.streamingContent !== null) {
|
|
427
|
-
|
|
428
|
-
|
|
664
|
+
add({ spans: [{ text: "Neutrino:", style: { fg: C.accent, bold: true } }] });
|
|
665
|
+
pushMarkdown(sanitizeText(this.streamingContent), { fg: C.assistant });
|
|
429
666
|
}
|
|
667
|
+
this.toolLineIndex = toolLineIndex;
|
|
430
668
|
return lines;
|
|
431
669
|
}
|
|
432
670
|
render(buf) {
|
|
@@ -435,7 +673,11 @@ export class ChatScreen extends Screen {
|
|
|
435
673
|
const h = buf.height;
|
|
436
674
|
let status = "";
|
|
437
675
|
let statusStyle = { fg: C.dim };
|
|
438
|
-
if (this.
|
|
676
|
+
if (this.compressing) {
|
|
677
|
+
status = "Compressing context…";
|
|
678
|
+
statusStyle = { fg: C.amber, bold: true };
|
|
679
|
+
}
|
|
680
|
+
else if (this.queued) {
|
|
439
681
|
status = "Will send when paused or idle...";
|
|
440
682
|
statusStyle = { fg: C.amber };
|
|
441
683
|
}
|
|
@@ -463,12 +705,27 @@ export class ChatScreen extends Screen {
|
|
|
463
705
|
}
|
|
464
706
|
if (this.scrollTop < 0)
|
|
465
707
|
this.scrollTop = 0;
|
|
708
|
+
// Remember the bar's geometry so a drag on the last column can scroll the
|
|
709
|
+
// log (see the scrollbar() method).
|
|
710
|
+
this.scrollbarGeom =
|
|
711
|
+
lines.length > viewH
|
|
712
|
+
? { x: w - 1, top: logTop, bottom: logBottom, total: lines.length, viewport: viewH }
|
|
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();
|
|
466
717
|
for (let y = logTop; y <= logBottom; y++) {
|
|
467
718
|
const li = this.scrollTop + (y - logTop);
|
|
468
719
|
if (li >= lines.length)
|
|
469
720
|
break;
|
|
721
|
+
const tool = this.toolLineIndex[li];
|
|
722
|
+
if (tool !== undefined && tool >= 0)
|
|
723
|
+
this.toolRows.set(y, tool);
|
|
470
724
|
drawSpans(buf, 2, y, lines[li].spans, Math.max(4, w - 4));
|
|
471
725
|
}
|
|
726
|
+
// A scrollbar on the right edge of the log, shown only when the
|
|
727
|
+
// conversation overflows the visible area.
|
|
728
|
+
drawScrollbar(buf, w - 1, logTop, logBottom, lines.length, viewH, this.scrollTop);
|
|
472
729
|
const inW = Math.max(12, w - 2);
|
|
473
730
|
drawInputBox(buf, 1, inputY, inW, this.inputValue, this.cursor, {
|
|
474
731
|
active: true,
|
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
|
}
|