@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,9 +2,11 @@ import { Screen } from "../terminal/screen.js";
2
2
  import { C } from "../colors.js";
3
3
  import { INTENSITY_LEVELS, MODEL_FILE, loadConfig, saveConfig } from "../config.js";
4
4
  import { drawHeader } from "../header.js";
5
- import { centerVertically } from "../ui.js";
5
+ import { centerVertically, drawHint } from "../ui.js";
6
6
  export class SettingsScreen extends Screen {
7
7
  intensityIdx = 2;
8
+ lastHoverX = -1;
9
+ lastHoverY = -1;
8
10
  onActivate() {
9
11
  const config = loadConfig();
10
12
  const current = String(config.default_intensity ?? "High");
@@ -28,6 +30,66 @@ export class SettingsScreen extends Screen {
28
30
  this.app.popScreen();
29
31
  }
30
32
  }
33
+ onMouse(ev) {
34
+ const layout = this.layout();
35
+ if (!layout)
36
+ return;
37
+ if (ev.type === "move") {
38
+ if (ev.x === this.lastHoverX && ev.y === this.lastHoverY)
39
+ return;
40
+ this.lastHoverX = ev.x;
41
+ 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;
45
+ this.app.render();
46
+ return;
47
+ }
48
+ }
49
+ return;
50
+ }
51
+ if (ev.type === "wheel") {
52
+ this.intensityIdx =
53
+ (this.intensityIdx + (ev.dir === -1 ? -1 : 1) + INTENSITY_LEVELS.length) % INTENSITY_LEVELS.length;
54
+ this.app.render();
55
+ return;
56
+ }
57
+ if (ev.type === "click") {
58
+ if (ev.button === 2) {
59
+ this.save();
60
+ this.app.popScreen();
61
+ return;
62
+ }
63
+ for (let i = 0; i < layout.optionYs.length; i++) {
64
+ if (ev.y === layout.optionYs[i]) {
65
+ this.intensityIdx = i;
66
+ this.save();
67
+ this.app.render();
68
+ return;
69
+ }
70
+ }
71
+ if (ev.y === layout.hintY) {
72
+ this.save();
73
+ this.app.popScreen();
74
+ }
75
+ }
76
+ }
77
+ layout() {
78
+ 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;
86
+ const top = centerVertically(h, total);
87
+ const optionYs = INTENSITY_LEVELS.map((_, i) => top + 4 + i);
88
+ const hintY = top + 4 + INTENSITY_LEVELS.length + 2;
89
+ if (hintY > h - 1)
90
+ return null;
91
+ return { optionYs, hintY };
92
+ }
31
93
  save() {
32
94
  const config = loadConfig();
33
95
  config.default_intensity = INTENSITY_LEVELS[this.intensityIdx];
@@ -64,7 +126,7 @@ export class SettingsScreen extends Screen {
64
126
  buf.centerText(y, `model.json: ${MODEL_FILE}`, { fg: C.dim });
65
127
  y += 1;
66
128
  if (y < h) {
67
- buf.centerText(y, "↑/↓ change · Enter apply · Esc back", { fg: C.dim });
129
+ drawHint(buf, y, "↑/↓ change · Enter apply · click to choose · right-click back · Esc back");
68
130
  }
69
131
  }
70
132
  }
@@ -2,14 +2,19 @@ import { Screen } from "../terminal/screen.js";
2
2
  import { C } from "../colors.js";
3
3
  import { loadLogo } from "../config.js";
4
4
  import { drawHeader } from "../header.js";
5
- import { centerVertically, drawInputBox, drawTabButton } from "../ui.js";
5
+ import { centerVertically, computeInputWindow, cursorFromColumn, drawHint, drawInputBox, drawTabButton, inRect, } from "../ui.js";
6
6
  const TABS = ["Model", "History", "Settings"];
7
7
  const LOGO = loadLogo();
8
8
  const FOCUS_COUNT = TABS.length + 1; // input box + 3 tabs
9
+ const INPUT_H = 3;
10
+ const TAB_H = 3;
9
11
  export class StartScreen extends Screen {
10
12
  inputValue = "";
11
13
  cursor = 0;
12
14
  focusIdx = 0; // 0 = input box, 1..3 = tabs
15
+ hoverIdx = -1; // -1 = nothing, 0 = input box, 1..3 = tabs
16
+ lastHoverX = -1;
17
+ lastHoverY = -1;
13
18
  onKey(key) {
14
19
  if (key.name === "left" || key.name === "up") {
15
20
  this.move(-1);
@@ -72,8 +77,92 @@ export class StartScreen extends Screen {
72
77
  this.app.render();
73
78
  }
74
79
  }
80
+ onMouse(ev) {
81
+ if (ev.type === "move") {
82
+ // avoid redundant work when the pointer hasn't moved to a new cell
83
+ if (ev.x === this.lastHoverX && ev.y === this.lastHoverY)
84
+ return;
85
+ this.lastHoverX = ev.x;
86
+ this.lastHoverY = ev.y;
87
+ const layout = this.layout();
88
+ let hover = -1;
89
+ if (layout && inRect(ev.x, ev.y, { x: layout.inputX, y: layout.inputY, w: layout.inputW, h: INPUT_H })) {
90
+ hover = 0;
91
+ }
92
+ else if (layout && ev.y >= layout.tabY && ev.y < layout.tabY + TAB_H) {
93
+ for (let i = 0; i < layout.tabs.length; i++) {
94
+ if (inRect(ev.x, ev.y, { x: layout.tabs[i].x, y: layout.tabY, w: layout.tabs[i].w, h: TAB_H })) {
95
+ hover = i + 1;
96
+ break;
97
+ }
98
+ }
99
+ }
100
+ if (hover !== this.hoverIdx) {
101
+ this.hoverIdx = hover;
102
+ this.app.render();
103
+ }
104
+ return;
105
+ }
106
+ if (ev.type === "click") {
107
+ const layout = this.layout();
108
+ if (!layout)
109
+ return;
110
+ if (ev.button === 2)
111
+ return; // right click: no-op on start screen
112
+ if (ev.y === 0) {
113
+ this.app.openModelConfig();
114
+ return;
115
+ }
116
+ if (inRect(ev.x, ev.y, { x: layout.inputX, y: layout.inputY, w: layout.inputW, h: INPUT_H })) {
117
+ this.focusIdx = 0;
118
+ if (ev.y === layout.inputY + 1 && ev.x >= layout.inputX + 2) {
119
+ const innerW = layout.inputW - 4;
120
+ const col = Math.min(innerW - 1, ev.x - (layout.inputX + 2));
121
+ const win = computeInputWindow(this.inputValue, this.cursor, innerW);
122
+ this.cursor = cursorFromColumn(this.inputValue, col, win);
123
+ }
124
+ this.app.render();
125
+ return;
126
+ }
127
+ if (ev.y >= layout.tabY && ev.y < layout.tabY + TAB_H) {
128
+ for (let i = 0; i < layout.tabs.length; i++) {
129
+ if (inRect(ev.x, ev.y, { x: layout.tabs[i].x, y: layout.tabY, w: layout.tabs[i].w, h: TAB_H })) {
130
+ this.focusIdx = i + 1;
131
+ this.hoverIdx = i + 1;
132
+ this.enterTab();
133
+ return;
134
+ }
135
+ }
136
+ }
137
+ return;
138
+ }
139
+ }
140
+ layout() {
141
+ const w = this.app.width;
142
+ const h = this.app.height;
143
+ const inputW = Math.min(78, w - 4);
144
+ const inputX = Math.floor((w - inputW) / 2);
145
+ const logoLines = LOGO.replace(/\n+$/, "").split("\n");
146
+ const gap = 1;
147
+ const contentH = logoLines.length + 1 + INPUT_H + gap + TAB_H;
148
+ const inputY = centerVertically(h, contentH) + logoLines.length + 1;
149
+ const tabY = inputY + INPUT_H + gap;
150
+ const tabGap = 2;
151
+ const tabInner = inputW - tabGap * (TABS.length - 1);
152
+ const baseW = Math.floor(tabInner / TABS.length);
153
+ const extra = tabInner - baseW * TABS.length;
154
+ const tabs = [];
155
+ let tx = inputX;
156
+ for (let i = 0; i < TABS.length; i++) {
157
+ const tw = baseW + (i < extra ? 1 : 0);
158
+ tabs.push({ x: tx, w: tw });
159
+ tx += tw + tabGap;
160
+ }
161
+ return { inputX, inputY, inputW, tabY, tabs };
162
+ }
75
163
  move(delta) {
76
164
  this.focusIdx = (this.focusIdx + delta + FOCUS_COUNT) % FOCUS_COUNT;
165
+ this.hoverIdx = -1;
77
166
  this.app.render();
78
167
  }
79
168
  enterTab() {
@@ -94,9 +183,7 @@ export class StartScreen extends Screen {
94
183
  const logoMax = logoLines.reduce((m, l) => Math.max(m, l.length), 0);
95
184
  const logoX = Math.max(0, Math.floor((w - logoMax) / 2));
96
185
  const gap = 1;
97
- const inputBoxH = 3;
98
- const tabH = 3;
99
- const contentH = logoLines.length + 1 + inputBoxH + gap + tabH;
186
+ const contentH = logoLines.length + 1 + INPUT_H + gap + TAB_H;
100
187
  const top = centerVertically(h, contentH);
101
188
  let y = top;
102
189
  for (const line of logoLines) {
@@ -108,7 +195,7 @@ export class StartScreen extends Screen {
108
195
  active: this.focusIdx === 0,
109
196
  placeholder: "Type a message to start a new chat...",
110
197
  });
111
- y += inputBoxH + gap;
198
+ y += INPUT_H + gap;
112
199
  const tabGap = 2;
113
200
  const tabInner = inputW - tabGap * (TABS.length - 1);
114
201
  const baseW = Math.floor(tabInner / TABS.length);
@@ -116,14 +203,13 @@ export class StartScreen extends Screen {
116
203
  let tx = inputX;
117
204
  for (let i = 0; i < TABS.length; i++) {
118
205
  const tw = baseW + (i < extra ? 1 : 0);
119
- drawTabButton(buf, tx, y, tw, TABS[i], i === this.focusIdx - 1);
206
+ const selected = i === this.focusIdx - 1 || i === this.hoverIdx - 1;
207
+ drawTabButton(buf, tx, y, tw, TABS[i], selected);
120
208
  tx += tw + tabGap;
121
209
  }
122
- const hintY = y + tabH + 1;
210
+ const hintY = y + TAB_H + 1;
123
211
  if (hintY < h) {
124
- buf.centerText(hintY, "←/→ / ↑/↓ switch · Enter confirm · Ctrl+E model · Ctrl+Q quit", {
125
- fg: C.dim,
126
- });
212
+ drawHint(buf, hintY, "←/→ / ↑/↓ switch · Enter confirm · click to choose · Ctrl+E model · Ctrl+Q quit");
127
213
  }
128
214
  }
129
215
  }
package/dist/state.js CHANGED
@@ -7,5 +7,6 @@ export function defaultState() {
7
7
  selectedModelStreaming: true,
8
8
  selectedIntensity: "High",
9
9
  isThinking: false,
10
+ blinkOn: false,
10
11
  };
11
12
  }
@@ -155,6 +155,16 @@ export class InputReader {
155
155
  this.emitKey("end", false, false, false, "\x1b[4~");
156
156
  }
157
157
  }
158
+ /**
159
+ * Parse SGR (extended) mouse sequences: ESC [ < b ; x ; y M/m
160
+ *
161
+ * Button codes (SGR mode, any-event tracking ?1003):
162
+ * 0/1/2 left/middle/right press
163
+ * 3 release (ignored)
164
+ * 32 motion with no buttons held (hover)
165
+ * 35 motion with left button held (drag)
166
+ * 64/65 wheel up / wheel down
167
+ */
158
168
  handleMouse(s, final) {
159
169
  const parts = s.slice(1).split(";");
160
170
  if (parts.length < 3)
@@ -170,6 +180,10 @@ export class InputReader {
170
180
  else if (b === 65) {
171
181
  this.emitMouse({ type: "wheel", dir: 1, x, y });
172
182
  }
183
+ else if (final === "M" && (b === 32 || b === 35)) {
184
+ // hover / drag motion
185
+ this.emitMouse({ type: "move", x, y });
186
+ }
173
187
  else if (final === "M" && (b === 0 || b === 1 || b === 2)) {
174
188
  this.emitMouse({ type: "click", x, y, button: b });
175
189
  }
@@ -11,6 +11,8 @@ function styleKey(st) {
11
11
  k += "d";
12
12
  if (st.inverse)
13
13
  k += "i";
14
+ if (st.italic)
15
+ k += "I";
14
16
  return k;
15
17
  }
16
18
  function sgr(st) {
@@ -19,6 +21,8 @@ function sgr(st) {
19
21
  codes.push("1");
20
22
  if (st.dim)
21
23
  codes.push("2");
24
+ if (st.italic)
25
+ codes.push("3");
22
26
  if (st.inverse)
23
27
  codes.push("7");
24
28
  if (st.fg)
@@ -45,10 +49,10 @@ export class Renderer {
45
49
  init() {
46
50
  this.write("\x1b[?1049h"); // alternate screen
47
51
  this.write("\x1b[?25l"); // hide cursor
48
- this.write("\x1b[?1000h\x1b[?1002h\x1b[?1006h\x1b[?1015h"); // mouse tracking
52
+ this.write("\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h\x1b[?1015h"); // mouse tracking (incl. any-event for hover)
49
53
  }
50
54
  dispose() {
51
- this.write("\x1b[?1000l\x1b[?1002l\x1b[?1006l\x1b[?1015l");
55
+ this.write("\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1015l");
52
56
  this.write("\x1b[?25h");
53
57
  this.write("\x1b[?1049l");
54
58
  this.write("\x1b[0m");
package/dist/ui.js CHANGED
@@ -70,25 +70,12 @@ export function wrapText(text, width) {
70
70
  }
71
71
  return lines;
72
72
  }
73
- export function drawInputBox(buf, x, y, w, value, cursorPos, opts = {}) {
74
- const active = opts.active ?? true;
75
- const dimmed = opts.dimmed ?? false;
76
- const placeholder = opts.placeholder ?? "";
77
- const h = 3;
78
- const innerX = x + 2;
79
- const innerW = w - 4;
80
- const innerY = y + 1;
81
- if (active)
82
- buf.fillRect(x, y, w, h, { bg: C.highlightBg });
83
- const textStyle = dimmed
84
- ? { fg: C.dim, bg: C.highlightBg }
85
- : active
86
- ? { fg: C.white, bg: C.highlightBg }
87
- : { fg: C.dim };
88
- if (value.length === 0) {
89
- buf.writeText(innerX, innerY, truncateByWidth(placeholder, innerW), textStylePlaceholder(active));
90
- return;
91
- }
73
+ /**
74
+ * Compute which part of the input value is visible inside the box, given the
75
+ * cursor position. Shared by `drawInputBox` and mouse click handling so the
76
+ * rendered text and click-to-position-cursor always agree.
77
+ */
78
+ export function computeInputWindow(value, cursorPos, innerW) {
92
79
  const chars = Array.from(value);
93
80
  const widths = chars.map((ch) => wcwidth(ch.codePointAt(0) ?? 0));
94
81
  const cursorCp = Math.min(Array.from(value.slice(0, cursorPos)).length, chars.length);
@@ -113,22 +100,80 @@ export function drawInputBox(buf, x, y, w, value, cursorPos, opts = {}) {
113
100
  let prefixW = 0;
114
101
  for (let i = 0; i < startCp; i++)
115
102
  prefixW += widths[i];
116
- let endCp = chars.length;
103
+ let visibleCount = 0;
117
104
  let acc = 0;
118
105
  for (let i = startCp; i < chars.length; i++) {
119
- if (acc + widths[i] > innerW) {
120
- endCp = i;
106
+ if (acc + widths[i] > innerW)
121
107
  break;
122
- }
123
108
  acc += widths[i];
109
+ visibleCount++;
124
110
  }
125
- const visible = chars.slice(startCp, endCp).join("");
111
+ return { startCp, prefixW, visibleCount };
112
+ }
113
+ /**
114
+ * Map a column inside the input box (0-based, relative to the inner area) to a
115
+ * cursor index in the full value.
116
+ */
117
+ export function cursorFromColumn(value, col, win) {
118
+ const chars = Array.from(value);
119
+ if (col <= 0)
120
+ return win.startCp;
121
+ let acc = 0;
122
+ let i = win.startCp;
123
+ while (i < chars.length) {
124
+ const w = wcwidth(chars[i].codePointAt(0) ?? 0);
125
+ if (col < acc + w)
126
+ return i;
127
+ acc += w;
128
+ i++;
129
+ }
130
+ return i;
131
+ }
132
+ export function inRect(px, py, r) {
133
+ return px >= r.x && px < r.x + r.w && py >= r.y && py < r.y + r.h;
134
+ }
135
+ export function drawHint(buf, y, text) {
136
+ const max = buf.width;
137
+ const t = displayWidth(text) <= max ? text : truncateByWidth(text, max - 1) + "…";
138
+ buf.centerText(y, t, { fg: C.dim });
139
+ }
140
+ export function drawInputBox(buf, x, y, w, value, cursorPos, opts = {}) {
141
+ const active = opts.active ?? true;
142
+ const dimmed = opts.dimmed ?? false;
143
+ const placeholder = opts.placeholder ?? "";
144
+ const h = 3;
145
+ const innerX = x + 2;
146
+ const innerW = w - 4;
147
+ const innerY = y + 1;
148
+ if (active)
149
+ buf.fillRect(x, y, w, h, { bg: C.highlightBg });
150
+ const textStyle = dimmed
151
+ ? { fg: C.dim, bg: C.highlightBg }
152
+ : active
153
+ ? { fg: C.white, bg: C.highlightBg }
154
+ : { fg: C.dim };
155
+ if (value.length === 0) {
156
+ buf.writeText(innerX, innerY, truncateByWidth(placeholder, innerW), textStylePlaceholder(active));
157
+ return;
158
+ }
159
+ const win = computeInputWindow(value, cursorPos, innerW);
160
+ const chars = Array.from(value);
161
+ const visible = chars.slice(win.startCp, win.startCp + win.visibleCount).join("");
126
162
  buf.writeText(innerX, innerY, visible, textStyle);
127
- const cursorScreenCol = cursorCol - prefixW;
163
+ const cursorScreenCol = cursorColAt(value, cursorPos, win);
128
164
  if (active && cursorScreenCol >= 0 && cursorScreenCol < innerW) {
129
165
  buf.set(innerX + cursorScreenCol, innerY, " ", { fg: C.black, bg: C.accent });
130
166
  }
131
167
  }
168
+ function cursorColAt(value, cursorPos, win) {
169
+ const chars = Array.from(value);
170
+ const cursorCp = Math.min(Array.from(value.slice(0, cursorPos)).length, chars.length);
171
+ let col = win.prefixW;
172
+ for (let i = win.startCp; i < cursorCp; i++) {
173
+ col += wcwidth(chars[i].codePointAt(0) ?? 0);
174
+ }
175
+ return col - win.prefixW;
176
+ }
132
177
  function textStylePlaceholder(active) {
133
178
  return active ? { fg: C.dim, bg: C.highlightBg } : { fg: C.dim };
134
179
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xingjisoft/neutrino",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "XINGJI Neutrino - a CLI coding agent in the terminal.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",