@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.
@@ -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,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 { 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())
@@ -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 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
+ })();
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 = e instanceof Error ? e.message : String(e);
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 = 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
+ }
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
- const pushText = (text, style) => {
430
- for (const ln of wrapText(text, width)) {
431
- lines.push({ spans: [{ text: ln, style }] });
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
- 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];
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
- lines.push({ spans: [{ text: "You:", style: { fg: C.accent, bold: true } }] });
440
- lines.push(...renderMarkdownText(m.content, width, { fg: C.user }));
441
- 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: [] });
442
623
  }
443
624
  else if (m.role === "assistant") {
444
- lines.push({ spans: [{ text: "Neutrino:", style: { fg: C.accent, bold: true } }] });
445
- lines.push(...renderMarkdownText(m.content, width, { fg: C.assistant }));
446
- 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: [] });
447
640
  }
448
641
  else if (m.role === "tool") {
449
- lines.push({ spans: [{ text: `⚙ ${m.name}`, style: { fg: C.toolHeader, bold: true } }] });
450
- 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 });
451
646
  if (argText)
452
- lines.push({ spans: [{ text: " " + argText, style: { fg: C.dim } }] });
453
- pushText(m.result || "(no output)", { fg: C.dim });
454
- 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: [] });
455
661
  }
456
662
  }
457
663
  if (this.streamingContent !== null) {
458
- lines.push({ spans: [{ text: "Neutrino:", style: { fg: C.accent, bold: true } }] });
459
- 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 });
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.queued) {
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
@@ -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
  }