@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.
@@ -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 { LLMClient } from "../llm/client.js";
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 this.agentLoop(apiMessages, token);
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 = e instanceof Error ? e.message : String(e);
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 = this.messages.filter((m) => m.role === "user" || m.role === "assistant" || m.role === "tool");
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
- const pushText = (text, style) => {
399
- for (const ln of wrapText(text, width)) {
400
- lines.push({ spans: [{ text: ln, style }] });
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
- for (const m of this.messages) {
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
- lines.push({ spans: [{ text: "You:", style: { fg: C.accent, bold: true } }] });
409
- lines.push(...renderMarkdownText(m.content, width, { fg: C.user }));
410
- lines.push({ spans: [] });
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
- lines.push({ spans: [{ text: "Neutrino:", style: { fg: C.accent, bold: true } }] });
414
- lines.push(...renderMarkdownText(m.content, width, { fg: C.assistant }));
415
- lines.push({ spans: [] });
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
- lines.push({ spans: [{ text: `⚙ ${m.name}`, style: { fg: C.toolHeader, bold: true } }] });
419
- const argText = m.arguments.command ?? m.arguments.path ?? "";
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
- lines.push({ spans: [{ text: " " + argText, style: { fg: C.dim } }] });
422
- pushText(m.result || "(no output)", { fg: C.dim });
423
- lines.push({ spans: [] });
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
- lines.push({ spans: [{ text: "Neutrino:", style: { fg: C.accent, bold: true } }] });
428
- lines.push(...renderMarkdownText(this.streamingContent, width, { fg: C.assistant }));
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.queued) {
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,
@@ -1,10 +1,14 @@
1
1
  import { Screen } from "../terminal/screen.js";
2
2
  import { C } from "../colors.js";
3
- import { INTENSITY_LEVELS, MODEL_FILE, loadConfig, saveConfig } from "../config.js";
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.intensityIdx = (this.intensityIdx + INTENSITY_LEVELS.length - 1) % INTENSITY_LEVELS.length;
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.intensityIdx = (this.intensityIdx + 1) % INTENSITY_LEVELS.length;
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.optionYs.length; i++) {
43
- if (ev.y === layout.optionYs[i] && i !== this.intensityIdx) {
44
- this.intensityIdx = i;
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.intensityIdx =
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.optionYs.length; i++) {
64
- if (ev.y === layout.optionYs[i]) {
65
- this.intensityIdx = i;
66
- this.save();
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 lines = [
80
- "Settings",
81
- "",
82
- `Model: ${this.app.state.selectedProvider}/${this.app.state.selectedModelName}`,
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 optionYs = INTENSITY_LEVELS.map((_, i) => top + 4 + i);
88
- const hintY = top + 4 + INTENSITY_LEVELS.length + 2;
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 { optionYs, hintY };
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 lines = [
102
- "Settings",
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, lines[0], { fg: C.accent, bold: true });
142
+ buf.centerText(y, "Settings", { fg: C.accent, bold: true });
115
143
  y += 2;
116
- buf.centerText(y, lines[2], { fg: C.assistant });
117
- y += 1;
118
- buf.centerText(y, lines[3], { fg: C.dim });
119
- y += 1;
120
- for (const c of content) {
121
- const text = (c.selected ? "▸ " : " ") + c.text;
122
- buf.centerText(y, text, c.selected ? { fg: C.accent, bg: C.highlightBg } : { fg: C.assistant });
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
- drawHint(buf, y, "↑/↓ change · Enter apply · click to choose · right-click back · Esc back");
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
  }