@xingjisoft/neutrino 0.1.0 → 0.1.1

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";
@@ -190,14 +191,34 @@ export class ChatScreen extends Screen {
190
191
  }
191
192
  onMouse(ev) {
192
193
  if (ev.type === "click") {
193
- if (ev.y === 0 && ev.x >= this.app.width - 3) {
194
- this.showInfo = !this.showInfo;
194
+ if (ev.button === 2)
195
+ return; // right click: no-op in chat
196
+ // header row: right corner toggles token stats, the rest opens model config
197
+ if (ev.y === 0) {
198
+ if (ev.x >= this.app.width - 3) {
199
+ this.showInfo = !this.showInfo;
200
+ }
201
+ else {
202
+ this.app.openModelConfig();
203
+ }
195
204
  this.app.render();
196
205
  return;
197
206
  }
198
207
  if (this.showInfo) {
199
208
  this.showInfo = false;
200
209
  this.app.render();
210
+ return;
211
+ }
212
+ // click on the input box -> focus and position the cursor
213
+ const inputY = this.app.height - 4;
214
+ const inW = Math.max(12, this.app.width - 2);
215
+ if (ev.y === inputY + 1 && ev.x >= 3 && ev.x < 3 + (inW - 4)) {
216
+ const innerW = inW - 4;
217
+ const col = Math.min(innerW - 1, ev.x - 3);
218
+ const win = computeInputWindow(this.inputValue, this.cursor, innerW);
219
+ this.cursor = cursorFromColumn(this.inputValue, col, win);
220
+ this.app.render();
221
+ return;
201
222
  }
202
223
  return;
203
224
  }
@@ -370,36 +391,37 @@ export class ChatScreen extends Screen {
370
391
  }
371
392
  buildLines(width) {
372
393
  const lines = [];
373
- const push = (text, style) => {
374
- for (const ln of wrapText(text, width))
375
- lines.push({ text: ln, style });
394
+ const pushText = (text, style) => {
395
+ for (const ln of wrapText(text, width)) {
396
+ lines.push({ spans: [{ text: ln, style }] });
397
+ }
376
398
  };
377
399
  for (const m of this.messages) {
378
400
  if (m.role === "system") {
379
- push(m.content, { fg: C.dim });
401
+ pushText(m.content, { fg: C.dim });
380
402
  }
381
403
  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: {} });
404
+ lines.push({ spans: [{ text: "You:", style: { fg: C.accent, bold: true } }] });
405
+ lines.push(...renderMarkdownText(m.content, width, { fg: C.user }));
406
+ lines.push({ spans: [] });
385
407
  }
386
408
  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: {} });
409
+ lines.push({ spans: [{ text: "Neutrino:", style: { fg: C.accent, bold: true } }] });
410
+ lines.push(...renderMarkdownText(m.content, width, { fg: C.assistant }));
411
+ lines.push({ spans: [] });
390
412
  }
391
413
  else if (m.role === "tool") {
392
- lines.push({ text: `⚙ ${m.name}`, style: { fg: C.toolHeader, bold: true } });
414
+ lines.push({ spans: [{ text: `⚙ ${m.name}`, style: { fg: C.toolHeader, bold: true } }] });
393
415
  const argText = m.arguments.command ?? m.arguments.path ?? "";
394
416
  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: {} });
417
+ lines.push({ spans: [{ text: " " + argText, style: { fg: C.dim } }] });
418
+ pushText(m.result || "(no output)", { fg: C.dim });
419
+ lines.push({ spans: [] });
398
420
  }
399
421
  }
400
422
  if (this.streamingContent !== null) {
401
- lines.push({ text: "Neutrino:", style: { fg: C.accent, bold: true } });
402
- push(this.streamingContent, { fg: C.assistant });
423
+ lines.push({ spans: [{ text: "Neutrino:", style: { fg: C.accent, bold: true } }] });
424
+ lines.push(...renderMarkdownText(this.streamingContent, width, { fg: C.assistant }));
403
425
  }
404
426
  return lines;
405
427
  }
@@ -441,8 +463,7 @@ export class ChatScreen extends Screen {
441
463
  const li = this.scrollTop + (y - logTop);
442
464
  if (li >= lines.length)
443
465
  break;
444
- const line = lines[li];
445
- buf.writeText(2, y, line.text.slice(0, w - 2), line.style);
466
+ drawSpans(buf, 2, y, lines[li].spans, Math.max(4, w - 4));
446
467
  }
447
468
  const inW = Math.max(12, w - 2);
448
469
  drawInputBox(buf, 1, inputY, inW, this.inputValue, this.cursor, {
@@ -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]] ?? [];