@xingjisoft/neutrino 0.1.0 → 0.1.2

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.
@@ -2,14 +2,20 @@ import { Screen } from "../terminal/screen.js";
2
2
  import { C } from "../colors.js";
3
3
  import { drawHeader } from "../header.js";
4
4
  import { truncateByWidth } from "../width.js";
5
+ import { drawHint } from "../ui.js";
5
6
  import { listAgentSessions, loadAgentMessages, } from "../agents.js";
6
7
  import { AgentConversationScreen } from "./agentConversation.js";
8
+ const LIST_TOP = 5;
7
9
  export class AgentHistoryScreen extends Screen {
8
10
  agent;
9
11
  label;
10
12
  sessions = [];
11
13
  selectedIdx = 0;
12
14
  scrollTop = 0;
15
+ lastHoverX = -1;
16
+ lastHoverY = -1;
17
+ rows = [];
18
+ itemBounds = [];
13
19
  constructor(agent, label) {
14
20
  super();
15
21
  this.agent = agent;
@@ -19,6 +25,8 @@ export class AgentHistoryScreen extends Screen {
19
25
  this.sessions = listAgentSessions(this.agent);
20
26
  this.selectedIdx = 0;
21
27
  this.scrollTop = 0;
28
+ this.rows = [];
29
+ this.itemBounds = [];
22
30
  }
23
31
  move(delta) {
24
32
  if (this.sessions.length === 0)
@@ -37,10 +45,50 @@ export class AgentHistoryScreen extends Screen {
37
45
  this.app.popScreen();
38
46
  }
39
47
  onMouse(ev) {
48
+ if (ev.type === "move") {
49
+ if (ev.x === this.lastHoverX && ev.y === this.lastHoverY)
50
+ return;
51
+ this.lastHoverX = ev.x;
52
+ this.lastHoverY = ev.y;
53
+ const idx = this.itemIndexAtRow(ev.y);
54
+ if (idx >= 0 && idx !== this.selectedIdx) {
55
+ this.selectedIdx = idx;
56
+ this.app.render();
57
+ }
58
+ return;
59
+ }
40
60
  if (ev.type === "wheel") {
41
61
  this.scrollTop = Math.max(0, this.scrollTop + (ev.dir === -1 ? -3 : 3));
42
62
  this.app.render();
63
+ return;
64
+ }
65
+ if (ev.type === "click") {
66
+ if (ev.button === 2) {
67
+ this.app.popScreen();
68
+ return;
69
+ }
70
+ const idx = this.itemIndexAtRow(ev.y);
71
+ if (idx < 0)
72
+ return;
73
+ this.selectedIdx = idx;
74
+ this.loadSelected();
75
+ }
76
+ }
77
+ itemIndexAtRow(y) {
78
+ if (y < LIST_TOP)
79
+ return -1;
80
+ const h = this.app.height;
81
+ const listBottom = h - 2;
82
+ if (y >= listBottom)
83
+ return -1;
84
+ const ri = this.scrollTop + (y - LIST_TOP);
85
+ if (ri < 0 || ri >= this.rows.length)
86
+ return -1;
87
+ for (let i = 0; i < this.itemBounds.length; i++) {
88
+ if (ri >= this.itemBounds[i].start && ri < this.itemBounds[i].end)
89
+ return i;
43
90
  }
91
+ return -1;
44
92
  }
45
93
  loadSelected() {
46
94
  if (this.selectedIdx < 0 || this.selectedIdx >= this.sessions.length)
@@ -49,20 +97,7 @@ export class AgentHistoryScreen extends Screen {
49
97
  const messages = loadAgentMessages(this.agent, s.id);
50
98
  this.app.pushScreen(new AgentConversationScreen(`${this.label} · ${s.title || s.id}`, messages));
51
99
  }
52
- render(buf) {
53
- drawHeader(buf, this.app.state);
54
- const w = buf.width;
55
- const h = buf.height;
56
- buf.centerText(2, this.label, { fg: C.accent, bold: true });
57
- buf.centerText(3, "Select a session to view", { fg: C.dim });
58
- const listTop = 5;
59
- const listBottom = h - 2;
60
- const viewH = Math.max(1, listBottom - listTop);
61
- if (this.sessions.length === 0) {
62
- buf.centerText(listTop + 1, "No sessions yet", { fg: C.dim });
63
- buf.centerText(listBottom, "Esc back", { fg: C.dim });
64
- return;
65
- }
100
+ buildRows() {
66
101
  const rows = [];
67
102
  const itemBounds = [];
68
103
  for (const s of this.sessions) {
@@ -73,27 +108,44 @@ export class AgentHistoryScreen extends Screen {
73
108
  rows.push(` ${s.model}`);
74
109
  itemBounds.push({ start, end: rows.length });
75
110
  }
111
+ this.rows = rows;
112
+ this.itemBounds = itemBounds;
113
+ }
114
+ render(buf) {
115
+ drawHeader(buf, this.app.state);
116
+ const w = buf.width;
117
+ const h = buf.height;
118
+ buf.centerText(2, this.label, { fg: C.accent, bold: true });
119
+ buf.centerText(3, "Select a session to view", { fg: C.dim });
120
+ const listBottom = h - 2;
121
+ const viewH = Math.max(1, listBottom - LIST_TOP);
122
+ if (this.sessions.length === 0) {
123
+ buf.centerText(LIST_TOP + 1, "No sessions yet", { fg: C.dim });
124
+ drawHint(buf, listBottom, "Esc back");
125
+ return;
126
+ }
127
+ this.buildRows();
76
128
  if (this.selectedIdx < 0)
77
129
  this.selectedIdx = 0;
78
- if (this.selectedIdx >= itemBounds.length)
79
- this.selectedIdx = itemBounds.length - 1;
80
- const selected = itemBounds[this.selectedIdx];
130
+ if (this.selectedIdx >= this.itemBounds.length)
131
+ this.selectedIdx = Math.max(0, this.itemBounds.length - 1);
132
+ const selected = this.itemBounds[this.selectedIdx];
81
133
  const firstRowOfSelected = selected.start;
82
134
  if (firstRowOfSelected < this.scrollTop)
83
135
  this.scrollTop = firstRowOfSelected;
84
136
  if (firstRowOfSelected >= this.scrollTop + viewH)
85
137
  this.scrollTop = firstRowOfSelected - viewH + 1;
86
- if (this.scrollTop > rows.length - viewH)
87
- this.scrollTop = Math.max(0, rows.length - viewH);
88
- for (let y = listTop; y < listBottom; y++) {
89
- const ri = this.scrollTop + (y - listTop);
90
- if (ri < 0 || ri >= rows.length)
138
+ if (this.scrollTop > this.rows.length - viewH)
139
+ this.scrollTop = Math.max(0, this.rows.length - viewH);
140
+ for (let y = LIST_TOP; y < listBottom; y++) {
141
+ const ri = this.scrollTop + (y - LIST_TOP);
142
+ if (ri < 0 || ri >= this.rows.length)
91
143
  break;
92
144
  const isSelected = ri >= selected.start && ri < selected.end;
93
145
  const isSelectedFirst = ri === selected.start;
94
- const text = (isSelectedFirst ? "▸ " : " ") + rows[ri];
146
+ const text = (isSelectedFirst ? "▸ " : " ") + this.rows[ri];
95
147
  buf.writeText(0, y, truncateByWidth(text, w), isSelected ? { fg: C.accent, bg: C.highlightBg } : { fg: C.assistant });
96
148
  }
97
- buf.centerText(listBottom, "↑/↓ select · Enter view · Esc back", { fg: C.dim });
149
+ drawHint(buf, listBottom, "↑/↓ select · Enter view · click to view · right-click back · Esc back");
98
150
  }
99
151
  }
@@ -1,16 +1,21 @@
1
1
  import { Screen } from "../terminal/screen.js";
2
2
  import { C } from "../colors.js";
3
3
  import { drawHeader } from "../header.js";
4
- import { drawTabButton } from "../ui.js";
4
+ import { drawHint, drawTabButton, inRect } from "../ui.js";
5
5
  import { AgentHistoryScreen } from "./agentHistory.js";
6
6
  const AGENTS = [
7
7
  { id: "claude", label: "Claude Code" },
8
8
  { id: "codex", label: "Codex" },
9
9
  { id: "opencode", label: "OpenCode" },
10
10
  ];
11
+ const LIST_TOP = 5;
12
+ const BTN_H = 3;
13
+ const BTN_X = 1;
11
14
  export class AgentSelectScreen extends Screen {
12
15
  selectedIdx = 0;
13
16
  scrollBtn = 0;
17
+ lastHoverX = -1;
18
+ lastHoverY = -1;
14
19
  onActivate() {
15
20
  this.selectedIdx = 0;
16
21
  this.scrollBtn = 0;
@@ -36,6 +41,49 @@ export class AgentSelectScreen extends Screen {
36
41
  else if (key.name === "escape")
37
42
  this.app.popScreen();
38
43
  }
44
+ onMouse(ev) {
45
+ if (ev.type === "move") {
46
+ if (ev.x === this.lastHoverX && ev.y === this.lastHoverY)
47
+ return;
48
+ this.lastHoverX = ev.x;
49
+ this.lastHoverY = ev.y;
50
+ const i = this.indexAtRow(ev.y);
51
+ if (i >= 0 && i !== this.selectedIdx) {
52
+ this.selectedIdx = i;
53
+ this.app.render();
54
+ }
55
+ return;
56
+ }
57
+ if (ev.type === "wheel") {
58
+ this.move(ev.dir === -1 ? -1 : 1);
59
+ return;
60
+ }
61
+ if (ev.type === "click") {
62
+ if (ev.button === 2) {
63
+ this.app.popScreen();
64
+ return;
65
+ }
66
+ const i = this.indexAtRow(ev.y);
67
+ if (i < 0)
68
+ return;
69
+ if (!inRect(ev.x, ev.y, { x: BTN_X, y: LIST_TOP + (i - this.scrollBtn) * BTN_H, w: this.app.width - 2, h: BTN_H })) {
70
+ return;
71
+ }
72
+ this.selectedIdx = i;
73
+ this.confirm();
74
+ }
75
+ }
76
+ indexAtRow(y) {
77
+ const h = this.app.height;
78
+ const hintRow = h - 1;
79
+ const maxVisible = Math.max(1, Math.floor((hintRow - LIST_TOP) / BTN_H));
80
+ const i = Math.floor((y - LIST_TOP) / BTN_H) + this.scrollBtn;
81
+ if (i < 0 || i >= AGENTS.length)
82
+ return -1;
83
+ if (LIST_TOP + (i - this.scrollBtn) * BTN_H + BTN_H > hintRow)
84
+ return -1;
85
+ return i;
86
+ }
39
87
  confirm() {
40
88
  const agent = AGENTS[this.selectedIdx];
41
89
  if (!agent)
@@ -48,24 +96,23 @@ export class AgentSelectScreen extends Screen {
48
96
  const h = buf.height;
49
97
  buf.centerText(2, "Other Agents", { fg: C.accent, bold: true });
50
98
  buf.centerText(3, "Select an agent to open its history", { fg: C.white });
51
- const listTop = 5;
52
- const btnH = 3;
99
+ const btnH = BTN_H;
53
100
  const stride = btnH;
54
101
  const hintRow = h - 1;
55
- const maxVisible = Math.max(1, Math.floor((hintRow - listTop) / stride));
102
+ const maxVisible = Math.max(1, Math.floor((hintRow - LIST_TOP) / stride));
56
103
  if (this.selectedIdx < this.scrollBtn)
57
104
  this.scrollBtn = this.selectedIdx;
58
105
  if (this.selectedIdx >= this.scrollBtn + maxVisible)
59
106
  this.scrollBtn = this.selectedIdx - maxVisible + 1;
60
107
  if (this.scrollBtn < 0)
61
108
  this.scrollBtn = 0;
62
- let y = listTop;
109
+ let y = LIST_TOP;
63
110
  for (let i = this.scrollBtn; i < AGENTS.length; i++) {
64
111
  if (y + btnH > hintRow)
65
112
  break;
66
- drawTabButton(buf, 1, y, w - 2, AGENTS[i].label, i === this.selectedIdx, "left");
113
+ drawTabButton(buf, BTN_X, y, w - 2, AGENTS[i].label, i === this.selectedIdx, "left");
67
114
  y += stride;
68
115
  }
69
- buf.centerText(hintRow, "↑/↓ select · Enter open · Esc back", { fg: C.dim });
116
+ drawHint(buf, hintRow, "↑/↓ select · Enter open · click to open · right-click back · Esc back");
70
117
  }
71
118
  }
@@ -2,7 +2,8 @@ import { Screen } from "../terminal/screen.js";
2
2
  import { C } from "../colors.js";
3
3
  import { loadPrompt, saveSession, } from "../config.js";
4
4
  import { drawHeader } from "../header.js";
5
- import { drawInputBox, wrapText } from "../ui.js";
5
+ import { computeInputWindow, cursorFromColumn, drawInputBox, wrapText } from "../ui.js";
6
+ import { drawSpans, renderMarkdownText } from "../markdown.js";
6
7
  import { ellipsize, displayWidth } from "../width.js";
7
8
  import { LLMClient } from "../llm/client.js";
8
9
  import { executeTool, getToolDefinitions } from "../llm/tools.js";
@@ -77,6 +78,10 @@ export class ChatScreen extends Screen {
77
78
  this.app.render();
78
79
  }
79
80
  }
81
+ showsCursor() {
82
+ // The chat input is always focused; a cursor is drawn when it holds text.
83
+ return this.inputValue.length > 0;
84
+ }
80
85
  onKey(key) {
81
86
  if (this.showInfo) {
82
87
  this.showInfo = false;
@@ -190,14 +195,34 @@ export class ChatScreen extends Screen {
190
195
  }
191
196
  onMouse(ev) {
192
197
  if (ev.type === "click") {
193
- if (ev.y === 0 && ev.x >= this.app.width - 3) {
194
- this.showInfo = !this.showInfo;
198
+ if (ev.button === 2)
199
+ return; // right click: no-op in chat
200
+ // header row: right corner toggles token stats, the rest opens model config
201
+ if (ev.y === 0) {
202
+ if (ev.x >= this.app.width - 3) {
203
+ this.showInfo = !this.showInfo;
204
+ }
205
+ else {
206
+ this.app.openModelConfig();
207
+ }
195
208
  this.app.render();
196
209
  return;
197
210
  }
198
211
  if (this.showInfo) {
199
212
  this.showInfo = false;
200
213
  this.app.render();
214
+ return;
215
+ }
216
+ // click on the input box -> focus and position the cursor
217
+ const inputY = this.app.height - 4;
218
+ const inW = Math.max(12, this.app.width - 2);
219
+ if (ev.y === inputY + 1 && ev.x >= 3 && ev.x < 3 + (inW - 4)) {
220
+ const innerW = inW - 4;
221
+ const col = Math.min(innerW - 1, ev.x - 3);
222
+ const win = computeInputWindow(this.inputValue, this.cursor, innerW);
223
+ this.cursor = cursorFromColumn(this.inputValue, col, win);
224
+ this.app.render();
225
+ return;
201
226
  }
202
227
  return;
203
228
  }
@@ -370,36 +395,37 @@ export class ChatScreen extends Screen {
370
395
  }
371
396
  buildLines(width) {
372
397
  const lines = [];
373
- const push = (text, style) => {
374
- for (const ln of wrapText(text, width))
375
- lines.push({ text: ln, style });
398
+ const pushText = (text, style) => {
399
+ for (const ln of wrapText(text, width)) {
400
+ lines.push({ spans: [{ text: ln, style }] });
401
+ }
376
402
  };
377
403
  for (const m of this.messages) {
378
404
  if (m.role === "system") {
379
- push(m.content, { fg: C.dim });
405
+ pushText(m.content, { fg: C.dim });
380
406
  }
381
407
  else if (m.role === "user") {
382
- lines.push({ text: "You:", style: { fg: C.accent, bold: true } });
383
- push(m.content, { fg: C.user });
384
- lines.push({ text: "", style: {} });
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: [] });
385
411
  }
386
412
  else if (m.role === "assistant") {
387
- lines.push({ text: "Neutrino:", style: { fg: C.accent, bold: true } });
388
- push(m.content, { fg: C.assistant });
389
- lines.push({ text: "", style: {} });
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: [] });
390
416
  }
391
417
  else if (m.role === "tool") {
392
- lines.push({ text: `⚙ ${m.name}`, style: { fg: C.toolHeader, bold: true } });
418
+ lines.push({ spans: [{ text: `⚙ ${m.name}`, style: { fg: C.toolHeader, bold: true } }] });
393
419
  const argText = m.arguments.command ?? m.arguments.path ?? "";
394
420
  if (argText)
395
- lines.push({ text: " " + argText, style: { fg: C.dim } });
396
- push(m.result || "(no output)", { fg: C.dim });
397
- lines.push({ text: "", style: {} });
421
+ lines.push({ spans: [{ text: " " + argText, style: { fg: C.dim } }] });
422
+ pushText(m.result || "(no output)", { fg: C.dim });
423
+ lines.push({ spans: [] });
398
424
  }
399
425
  }
400
426
  if (this.streamingContent !== null) {
401
- lines.push({ text: "Neutrino:", style: { fg: C.accent, bold: true } });
402
- push(this.streamingContent, { fg: C.assistant });
427
+ lines.push({ spans: [{ text: "Neutrino:", style: { fg: C.accent, bold: true } }] });
428
+ lines.push(...renderMarkdownText(this.streamingContent, width, { fg: C.assistant }));
403
429
  }
404
430
  return lines;
405
431
  }
@@ -441,17 +467,17 @@ export class ChatScreen extends Screen {
441
467
  const li = this.scrollTop + (y - logTop);
442
468
  if (li >= lines.length)
443
469
  break;
444
- const line = lines[li];
445
- buf.writeText(2, y, line.text.slice(0, w - 2), line.style);
470
+ drawSpans(buf, 2, y, lines[li].spans, Math.max(4, w - 4));
446
471
  }
447
472
  const inW = Math.max(12, w - 2);
448
473
  drawInputBox(buf, 1, inputY, inW, this.inputValue, this.cursor, {
449
474
  active: true,
450
475
  dimmed: this.busy,
476
+ cursorOn: this.app.state.cursorOn,
451
477
  placeholder: "Type a message...",
452
478
  });
453
479
  const hintY = h - 1;
454
- const hints = "Enter send · ESC ESC pause · Ctrl+B menu · Ctrl+L clear";
480
+ const hints = "Enter send · ESC ESC pause · Ctrl+B menu · Ctrl+L clear · drag to copy";
455
481
  const hintW = displayWidth(hints);
456
482
  const hintX = Math.max(0, w - 1 - hintW);
457
483
  const pathAvail = Math.max(0, hintX - 2);
@@ -2,14 +2,22 @@ import { Screen } from "../terminal/screen.js";
2
2
  import { C } from "../colors.js";
3
3
  import { deleteSession, listSessions, loadSession } from "../config.js";
4
4
  import { drawHeader } from "../header.js";
5
+ import { drawHint } from "../ui.js";
6
+ const LIST_TOP = 5;
5
7
  export class HistoryScreen extends Screen {
6
8
  sessions = [];
7
9
  selectedIdx = 0;
8
10
  scrollTop = 0;
11
+ lastHoverX = -1;
12
+ lastHoverY = -1;
13
+ rows = [];
14
+ itemBounds = [];
9
15
  onActivate() {
10
16
  this.sessions = listSessions();
11
17
  this.selectedIdx = this.sessions.length > 0 ? 1 : 0;
12
18
  this.scrollTop = 0;
19
+ this.rows = [];
20
+ this.itemBounds = [];
13
21
  }
14
22
  move(delta) {
15
23
  const count = this.sessions.length + 1;
@@ -29,10 +37,51 @@ export class HistoryScreen extends Screen {
29
37
  this.app.popScreen();
30
38
  }
31
39
  onMouse(ev) {
40
+ if (ev.type === "move") {
41
+ if (ev.x === this.lastHoverX && ev.y === this.lastHoverY)
42
+ return;
43
+ this.lastHoverX = ev.x;
44
+ this.lastHoverY = ev.y;
45
+ const idx = this.itemIndexAtRow(ev.y);
46
+ if (idx >= 0 && idx !== this.selectedIdx) {
47
+ this.selectedIdx = idx;
48
+ this.app.render();
49
+ }
50
+ return;
51
+ }
32
52
  if (ev.type === "wheel") {
33
53
  this.scrollTop = Math.max(0, this.scrollTop + (ev.dir === -1 ? -3 : 3));
34
54
  this.app.render();
55
+ return;
56
+ }
57
+ if (ev.type === "click") {
58
+ if (ev.button === 2) {
59
+ this.app.popScreen();
60
+ return;
61
+ }
62
+ const idx = this.itemIndexAtRow(ev.y);
63
+ if (idx < 0)
64
+ return;
65
+ this.selectedIdx = idx;
66
+ this.loadSelected();
67
+ }
68
+ }
69
+ /** Map a terminal row to the history item index (0 = "Open other Agent's history"). */
70
+ itemIndexAtRow(y) {
71
+ if (y < LIST_TOP)
72
+ return -1;
73
+ const h = this.app.height;
74
+ const listBottom = h - 2;
75
+ if (y >= listBottom)
76
+ return -1;
77
+ const ri = this.scrollTop + (y - LIST_TOP);
78
+ if (ri < 0 || ri >= this.rows.length)
79
+ return -1;
80
+ for (let i = 0; i < this.itemBounds.length; i++) {
81
+ if (ri >= this.itemBounds[i].start && ri < this.itemBounds[i].end)
82
+ return i;
35
83
  }
84
+ return -1;
36
85
  }
37
86
  loadSelected() {
38
87
  if (this.selectedIdx === 0) {
@@ -59,15 +108,7 @@ export class HistoryScreen extends Screen {
59
108
  this.selectedIdx = Math.min(this.selectedIdx, this.sessions.length);
60
109
  this.app.render();
61
110
  }
62
- render(buf) {
63
- drawHeader(buf, this.app.state);
64
- const w = buf.width;
65
- const h = buf.height;
66
- buf.centerText(2, "History", { fg: C.accent, bold: true });
67
- buf.centerText(3, "Select a session to load", { fg: C.dim });
68
- const listTop = 5;
69
- const listBottom = h - 2;
70
- const viewH = Math.max(1, listBottom - listTop);
111
+ buildRows() {
71
112
  const rows = [];
72
113
  const itemBounds = [];
73
114
  rows.push("Open other Agent's history");
@@ -83,32 +124,44 @@ export class HistoryScreen extends Screen {
83
124
  rows.push(` ${s.cwd}`);
84
125
  itemBounds.push({ start, end: rows.length });
85
126
  }
127
+ this.rows = rows;
128
+ this.itemBounds = itemBounds;
129
+ }
130
+ render(buf) {
131
+ drawHeader(buf, this.app.state);
132
+ const w = buf.width;
133
+ const h = buf.height;
134
+ buf.centerText(2, "History", { fg: C.accent, bold: true });
135
+ buf.centerText(3, "Select a session to load", { fg: C.dim });
136
+ const listBottom = h - 2;
137
+ const viewH = Math.max(1, listBottom - LIST_TOP);
138
+ this.buildRows();
86
139
  if (this.selectedIdx < 0)
87
140
  this.selectedIdx = 0;
88
- if (this.selectedIdx >= itemBounds.length)
89
- this.selectedIdx = itemBounds.length - 1;
90
- const selected = itemBounds[this.selectedIdx];
141
+ if (this.selectedIdx >= this.itemBounds.length)
142
+ this.selectedIdx = Math.max(0, this.itemBounds.length - 1);
143
+ const selected = this.itemBounds[this.selectedIdx];
91
144
  const firstRowOfSelected = selected.start;
92
145
  if (firstRowOfSelected < this.scrollTop)
93
146
  this.scrollTop = firstRowOfSelected;
94
147
  if (firstRowOfSelected >= this.scrollTop + viewH)
95
148
  this.scrollTop = firstRowOfSelected - viewH + 1;
96
- if (this.scrollTop > rows.length - viewH)
97
- this.scrollTop = Math.max(0, rows.length - viewH);
98
- for (let y = listTop; y < listBottom; y++) {
99
- const ri = this.scrollTop + (y - listTop);
100
- if (ri < 0 || ri >= rows.length)
149
+ if (this.scrollTop > this.rows.length - viewH)
150
+ this.scrollTop = Math.max(0, this.rows.length - viewH);
151
+ for (let y = LIST_TOP; y < listBottom; y++) {
152
+ const ri = this.scrollTop + (y - LIST_TOP);
153
+ if (ri < 0 || ri >= this.rows.length)
101
154
  break;
102
155
  const isSelected = ri >= selected.start && ri < selected.end;
103
156
  const isSelectedFirst = ri === selected.start;
104
- const row = rows[ri];
157
+ const row = this.rows[ri];
105
158
  const prefix = isSelectedFirst ? "▸ " : " ";
106
159
  const style = isSelected ? { fg: C.accent, bg: C.highlightBg } : { fg: C.assistant };
107
160
  buf.writeText(0, y, (prefix + row).slice(0, w), style);
108
161
  }
109
162
  if (this.sessions.length === 0) {
110
- buf.centerText(listTop + 1, "No sessions yet", { fg: C.dim });
163
+ buf.centerText(LIST_TOP + 1, "No sessions yet", { fg: C.dim });
111
164
  }
112
- buf.centerText(listBottom, "↑/↓ select · Enter load · Delete delete · Esc back", { fg: C.dim });
165
+ drawHint(buf, listBottom, "↑/↓ select · Enter load · click to load · Delete delete · right-click back · Esc back");
113
166
  }
114
167
  }
@@ -2,7 +2,10 @@ import { Screen } from "../terminal/screen.js";
2
2
  import { C } from "../colors.js";
3
3
  import { INTENSITY_LEVELS, getModelsByProvider } from "../config.js";
4
4
  import { drawHeader } from "../header.js";
5
- import { drawTabButton } from "../ui.js";
5
+ import { drawHint, drawTabButton, inRect } from "../ui.js";
6
+ const LIST_TOP = 5;
7
+ const BTN_H = 3;
8
+ const BTN_X = 1;
6
9
  export class ModelSelectScreen extends Screen {
7
10
  level = 0;
8
11
  providers = [];
@@ -11,6 +14,8 @@ export class ModelSelectScreen extends Screen {
11
14
  selectedModelIdx = 0;
12
15
  selectedIntensityIdx = 2;
13
16
  scrollBtn = 0;
17
+ lastHoverX = -1;
18
+ lastHoverY = -1;
14
19
  onActivate() {
15
20
  const map = getModelsByProvider();
16
21
  this.providers = Object.keys(map);
@@ -62,6 +67,7 @@ export class ModelSelectScreen extends Screen {
62
67
  return;
63
68
  this.level = 1;
64
69
  this.selectedModelIdx = 0;
70
+ this.scrollBtn = 0;
65
71
  this.app.render();
66
72
  return;
67
73
  }
@@ -70,6 +76,7 @@ export class ModelSelectScreen extends Screen {
70
76
  if (models.length === 0)
71
77
  return;
72
78
  this.level = 2;
79
+ this.scrollBtn = 0;
73
80
  this.app.render();
74
81
  return;
75
82
  }
@@ -85,12 +92,65 @@ export class ModelSelectScreen extends Screen {
85
92
  goBack() {
86
93
  if (this.level > 0) {
87
94
  this.level--;
95
+ this.scrollBtn = 0;
88
96
  this.app.render();
89
97
  }
90
98
  else {
91
99
  this.app.popScreen();
92
100
  }
93
101
  }
102
+ /** Map a terminal row to the list index rendered there, if any. */
103
+ indexAtRow(y) {
104
+ const list = this.currentList();
105
+ const h = this.app.height;
106
+ const hintRow = h - 1;
107
+ const maxVisible = Math.max(1, Math.floor((hintRow - LIST_TOP) / BTN_H));
108
+ const i = Math.floor((y - LIST_TOP) / BTN_H) + this.scrollBtn;
109
+ if (i < 0 || i >= list.length)
110
+ return -1;
111
+ const visibleRow = LIST_TOP + (i - this.scrollBtn) * BTN_H;
112
+ if (visibleRow + BTN_H > hintRow)
113
+ return -1;
114
+ return i;
115
+ }
116
+ onMouse(ev) {
117
+ if (ev.type === "move") {
118
+ if (ev.x === this.lastHoverX && ev.y === this.lastHoverY)
119
+ return;
120
+ this.lastHoverX = ev.x;
121
+ this.lastHoverY = ev.y;
122
+ const list = this.currentList();
123
+ if (list.length === 0)
124
+ return;
125
+ const i = this.indexAtRow(ev.y);
126
+ if (i >= 0 && i !== this.currentIdx()) {
127
+ this.setIdx(i);
128
+ this.app.render();
129
+ }
130
+ return;
131
+ }
132
+ if (ev.type === "wheel") {
133
+ this.move(ev.dir === -1 ? -1 : 1);
134
+ return;
135
+ }
136
+ if (ev.type === "click") {
137
+ if (ev.button === 2) {
138
+ this.goBack();
139
+ return;
140
+ }
141
+ if (ev.y < LIST_TOP || ev.y >= this.app.height - 1)
142
+ return;
143
+ const i = this.indexAtRow(ev.y);
144
+ if (i < 0)
145
+ return;
146
+ if (!inRect(ev.x, ev.y, { x: BTN_X, y: LIST_TOP + (i - this.scrollBtn) * BTN_H, w: this.app.width - 2, h: BTN_H })) {
147
+ return;
148
+ }
149
+ this.setIdx(i);
150
+ this.confirm();
151
+ return;
152
+ }
153
+ }
94
154
  onKey(key) {
95
155
  if (key.name === "up")
96
156
  this.move(-1);
@@ -118,26 +178,25 @@ export class ModelSelectScreen extends Screen {
118
178
  else
119
179
  subtitle = `Model: ${this.currentModelName()} - Select Intensity`;
120
180
  buf.centerText(3, subtitle, { fg: C.white });
121
- const listTop = 5;
122
- const btnH = 3;
181
+ const btnH = BTN_H;
123
182
  const btnGap = 0;
124
183
  const stride = btnH + btnGap;
125
184
  const hintRow = h - 1;
126
- const maxVisible = Math.max(1, Math.floor((hintRow - listTop) / stride));
185
+ const maxVisible = Math.max(1, Math.floor((hintRow - LIST_TOP) / stride));
127
186
  if (idx < this.scrollBtn)
128
187
  this.scrollBtn = idx;
129
188
  if (idx >= this.scrollBtn + maxVisible)
130
189
  this.scrollBtn = idx - maxVisible + 1;
131
190
  if (this.scrollBtn < 0)
132
191
  this.scrollBtn = 0;
133
- let y = listTop;
192
+ let y = LIST_TOP;
134
193
  for (let i = this.scrollBtn; i < list.length; i++) {
135
194
  if (y + btnH > hintRow)
136
195
  break;
137
- drawTabButton(buf, 1, y, w - 2, list[i], i === idx, "left");
196
+ drawTabButton(buf, BTN_X, y, w - 2, list[i], i === idx, "left");
138
197
  y += stride;
139
198
  }
140
- buf.centerText(hintRow, "↑/↓ select · Enter confirm · Esc back", { fg: C.dim });
199
+ drawHint(buf, hintRow, "↑/↓ select · Enter confirm · click to choose · right-click back · Esc back");
141
200
  }
142
201
  currentModelName() {
143
202
  const models = this.modelMap[this.providers[this.selectedProviderIdx]] ?? [];