@xingjisoft/neutrino 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  import { Screen } from "../terminal/screen.js";
2
2
  import { C } from "../colors.js";
3
- import { deleteSession, listSessions, loadSession } from "../config.js";
3
+ import { deleteSession, listSessionsForCwd, loadSession } from "../config.js";
4
+ import { ellipsizeStart } from "../width.js";
4
5
  import { drawHeader } from "../header.js";
5
6
  import { drawHint } from "../ui.js";
6
7
  const LIST_TOP = 5;
@@ -12,8 +13,11 @@ export class HistoryScreen extends Screen {
12
13
  lastHoverY = -1;
13
14
  rows = [];
14
15
  itemBounds = [];
16
+ cwd = process.cwd();
15
17
  onActivate() {
16
- this.sessions = listSessions();
18
+ // Only sessions started in the current working directory are listed.
19
+ this.cwd = process.cwd();
20
+ this.sessions = listSessionsForCwd(this.cwd);
17
21
  this.selectedIdx = this.sessions.length > 0 ? 1 : 0;
18
22
  this.scrollTop = 0;
19
23
  this.rows = [];
@@ -104,7 +108,7 @@ export class HistoryScreen extends Screen {
104
108
  if (!session)
105
109
  return;
106
110
  deleteSession(session.id);
107
- this.sessions = listSessions();
111
+ this.sessions = listSessionsForCwd(this.cwd);
108
112
  this.selectedIdx = Math.min(this.selectedIdx, this.sessions.length);
109
113
  this.app.render();
110
114
  }
@@ -120,8 +124,6 @@ export class HistoryScreen extends Screen {
120
124
  rows.push(`${created} | ${s.provider}/${s.model} (${s.intensity}) | ${s.message_count} msgs`);
121
125
  if (s.title)
122
126
  rows.push(` ${s.title}`);
123
- if (s.cwd)
124
- rows.push(` ${s.cwd}`);
125
127
  itemBounds.push({ start, end: rows.length });
126
128
  }
127
129
  this.rows = rows;
@@ -132,7 +134,7 @@ export class HistoryScreen extends Screen {
132
134
  const w = buf.width;
133
135
  const h = buf.height;
134
136
  buf.centerText(2, "History", { fg: C.accent, bold: true });
135
- buf.centerText(3, "Select a session to load", { fg: C.dim });
137
+ buf.centerText(3, `Sessions in ${ellipsizeStart(this.cwd, Math.max(8, w - 14))}`, { fg: C.dim });
136
138
  const listBottom = h - 2;
137
139
  const viewH = Math.max(1, listBottom - LIST_TOP);
138
140
  this.buildRows();
@@ -160,7 +162,7 @@ export class HistoryScreen extends Screen {
160
162
  buf.writeText(0, y, (prefix + row).slice(0, w), style);
161
163
  }
162
164
  if (this.sessions.length === 0) {
163
- buf.centerText(LIST_TOP + 1, "No sessions yet", { fg: C.dim });
165
+ buf.centerText(LIST_TOP + 1, "No sessions in this directory", { fg: C.dim });
164
166
  }
165
167
  drawHint(buf, listBottom, "↑/↓ select · Enter load · click to load · Delete delete · right-click back · Esc back");
166
168
  }
@@ -1,29 +1,130 @@
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 { CONTEXT_WINDOW_OPTIONS, DEFAULT_CONTEXT_WINDOW, contextWindow, CONFIG_FILE, INTENSITY_LEVELS, getModelsByProvider, loadConfig, loadModels, saveConfig, saveModels, } from "../config.js";
4
4
  import { drawHeader } from "../header.js";
5
5
  import { centerVertically, drawHint } from "../ui.js";
6
+ import { hashWebPassword } from "../web/auth.js";
7
+ /** The settings that can be changed here, top to bottom. */
8
+ const ROWS = ["intensity", "compress", "approval_model", "extreme_approval", "sound", "web_password"];
6
9
  export class SettingsScreen extends Screen {
7
10
  intensityIdx = 2;
11
+ contextTokens = DEFAULT_CONTEXT_WINDOW;
12
+ contextChanged = false;
13
+ /** 0 = same model as the chat, otherwise index + 1 into `models`. */
14
+ approvalModelIdx = 0;
15
+ /** 0 = off (human only), 1 = on (two independent assessments). */
16
+ extremeIdx = 0;
17
+ /** 0 = silent, 1 = audible cue on turn end and on approval requests. */
18
+ soundIdx = 1;
19
+ models = [];
20
+ rowIdx = 0;
8
21
  lastHoverX = -1;
9
22
  lastHoverY = -1;
23
+ editingPassword = false;
24
+ passwordDraft = "";
25
+ passwordMessage = "Press Enter to change";
10
26
  onActivate() {
11
27
  const config = loadConfig();
12
28
  const current = String(config.default_intensity ?? "High");
13
29
  const i = INTENSITY_LEVELS.indexOf(current);
14
30
  this.intensityIdx = i >= 0 ? i : 2;
31
+ const state = this.app.state;
32
+ const model = getModelsByProvider()[state.selectedProvider]?.find((m) => m.name === state.selectedModelName);
33
+ this.contextTokens = contextWindow(model?.context_window);
34
+ this.contextChanged = false;
35
+ this.models = Object.values(getModelsByProvider()).flatMap((list) => list.map((m) => m.name));
36
+ const currentApproval = String(config.approval_model ?? "");
37
+ const approvalAt = currentApproval ? this.models.indexOf(currentApproval) : -1;
38
+ this.approvalModelIdx = approvalAt >= 0 ? approvalAt + 1 : 0;
39
+ this.extremeIdx = config.auto_approve_extreme_risk === true ? 1 : 0;
40
+ this.soundIdx = config.sound_alerts === false ? 0 : 1;
41
+ }
42
+ /** Change the value of a row by `delta` steps and persist it immediately. */
43
+ cycle(row, delta) {
44
+ if (row === "web_password") {
45
+ this.editingPassword = true;
46
+ this.passwordDraft = "";
47
+ this.passwordMessage = "Type new password";
48
+ return;
49
+ }
50
+ if (row === "intensity") {
51
+ const n = INTENSITY_LEVELS.length;
52
+ this.intensityIdx = (this.intensityIdx + delta + n) % n;
53
+ }
54
+ else if (row === "approval_model") {
55
+ const n = this.models.length + 1;
56
+ this.approvalModelIdx = (this.approvalModelIdx + delta + n) % n;
57
+ }
58
+ else if (row === "extreme_approval") {
59
+ // A switch, not a list: any nudge flips it.
60
+ this.extremeIdx = this.extremeIdx === 1 ? 0 : 1;
61
+ }
62
+ else if (row === "sound") {
63
+ this.soundIdx = this.soundIdx === 1 ? 0 : 1;
64
+ }
65
+ else {
66
+ const options = [...new Set([...CONTEXT_WINDOW_OPTIONS, this.contextTokens])].sort((a, b) => a - b);
67
+ const i = options.indexOf(this.contextTokens);
68
+ this.contextTokens = options[(i + delta + options.length) % options.length];
69
+ this.contextChanged = true;
70
+ }
71
+ this.save();
15
72
  }
16
73
  onKey(key) {
74
+ if (this.editingPassword) {
75
+ if (key.name === "escape") {
76
+ this.editingPassword = false;
77
+ this.passwordDraft = "";
78
+ this.passwordMessage = "Press Enter to change";
79
+ }
80
+ else if (key.name === "enter" || key.name === "return") {
81
+ try {
82
+ const hash = hashWebPassword(this.passwordDraft);
83
+ const config = loadConfig();
84
+ config.web_password_hash = hash;
85
+ saveConfig(config);
86
+ this.editingPassword = false;
87
+ this.passwordDraft = "";
88
+ this.passwordMessage = "Saved permanently";
89
+ }
90
+ catch (error) {
91
+ this.passwordMessage = error instanceof Error ? error.message : "Could not save password";
92
+ }
93
+ }
94
+ else if (key.name === "backspace") {
95
+ this.passwordDraft = Array.from(this.passwordDraft).slice(0, -1).join("");
96
+ }
97
+ else if (key.ctrl && key.name === "u") {
98
+ this.passwordDraft = "";
99
+ }
100
+ else if (key.char && !key.ctrl && !key.meta) {
101
+ this.passwordDraft = (this.passwordDraft + key.char).slice(0, 256);
102
+ }
103
+ this.app.render();
104
+ return;
105
+ }
17
106
  if (key.name === "up") {
18
- this.intensityIdx = (this.intensityIdx + INTENSITY_LEVELS.length - 1) % INTENSITY_LEVELS.length;
107
+ this.rowIdx = (this.rowIdx + ROWS.length - 1) % ROWS.length;
19
108
  this.app.render();
20
109
  }
21
110
  else if (key.name === "down") {
22
- this.intensityIdx = (this.intensityIdx + 1) % INTENSITY_LEVELS.length;
111
+ this.rowIdx = (this.rowIdx + 1) % ROWS.length;
112
+ this.app.render();
113
+ }
114
+ else if (key.name === "left") {
115
+ this.cycle(ROWS[this.rowIdx], -1);
116
+ this.app.render();
117
+ }
118
+ else if (key.name === "right") {
119
+ this.cycle(ROWS[this.rowIdx], 1);
23
120
  this.app.render();
24
121
  }
25
122
  else if (key.name === "enter" || key.name === "return") {
26
- this.save();
123
+ if (ROWS[this.rowIdx] === "web_password")
124
+ this.cycle("web_password", 1);
125
+ else
126
+ this.save();
127
+ this.app.render();
27
128
  }
28
129
  else if (key.name === "escape") {
29
130
  this.save();
@@ -31,6 +132,8 @@ export class SettingsScreen extends Screen {
31
132
  }
32
133
  }
33
134
  onMouse(ev) {
135
+ if (this.editingPassword)
136
+ return;
34
137
  const layout = this.layout();
35
138
  if (!layout)
36
139
  return;
@@ -39,9 +142,9 @@ export class SettingsScreen extends Screen {
39
142
  return;
40
143
  this.lastHoverX = ev.x;
41
144
  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;
145
+ for (let i = 0; i < layout.rowYs.length; i++) {
146
+ if (ev.y === layout.rowYs[i] && i !== this.rowIdx) {
147
+ this.rowIdx = i;
45
148
  this.app.render();
46
149
  return;
47
150
  }
@@ -49,8 +152,7 @@ export class SettingsScreen extends Screen {
49
152
  return;
50
153
  }
51
154
  if (ev.type === "wheel") {
52
- this.intensityIdx =
53
- (this.intensityIdx + (ev.dir === -1 ? -1 : 1) + INTENSITY_LEVELS.length) % INTENSITY_LEVELS.length;
155
+ this.cycle(ROWS[this.rowIdx], ev.dir === -1 ? -1 : 1);
54
156
  this.app.render();
55
157
  return;
56
158
  }
@@ -60,10 +162,15 @@ export class SettingsScreen extends Screen {
60
162
  this.app.popScreen();
61
163
  return;
62
164
  }
63
- for (let i = 0; i < layout.optionYs.length; i++) {
64
- if (ev.y === layout.optionYs[i]) {
65
- this.intensityIdx = i;
66
- this.save();
165
+ for (let i = 0; i < layout.rowYs.length; i++) {
166
+ if (ev.y === layout.rowYs[i]) {
167
+ if (this.rowIdx === i) {
168
+ // Clicking the already-selected row advances its value.
169
+ this.cycle(ROWS[i], 1);
170
+ }
171
+ else {
172
+ this.rowIdx = i;
173
+ }
67
174
  this.app.render();
68
175
  return;
69
176
  }
@@ -74,59 +181,108 @@ export class SettingsScreen extends Screen {
74
181
  }
75
182
  }
76
183
  }
184
+ onPaste(text) {
185
+ if (!this.editingPassword)
186
+ return;
187
+ this.passwordDraft = (this.passwordDraft + text).slice(0, 256);
188
+ this.app.render();
189
+ }
190
+ onDeactivate() {
191
+ this.passwordDraft = "";
192
+ this.editingPassword = false;
193
+ }
194
+ rowTexts() {
195
+ return [
196
+ { label: "Default intensity", value: INTENSITY_LEVELS[this.intensityIdx] },
197
+ { label: "Model context (80%)", value: `${this.contextTokens.toLocaleString("en-US")} tokens` },
198
+ {
199
+ label: "Approval reviewer model",
200
+ value: this.approvalModelIdx === 0 ? "Same as chat" : this.models[this.approvalModelIdx - 1] ?? "Same as chat",
201
+ },
202
+ {
203
+ label: "Extreme-risk auto-approval",
204
+ // Names the mechanism: three separate review rounds, not three reviewers
205
+ // sitting in one panel.
206
+ value: this.extremeIdx === 1 ? "On (3 separate rounds)" : "Off (human only)",
207
+ },
208
+ {
209
+ label: "Sound alerts",
210
+ value: this.soundIdx === 1 ? "On (turn end, approvals)" : "Off",
211
+ },
212
+ {
213
+ label: "Web password",
214
+ value: this.editingPassword ? "*".repeat(Math.min(this.passwordDraft.length, 24)) || "Type new password" : this.passwordMessage,
215
+ },
216
+ ];
217
+ }
77
218
  layout() {
78
219
  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;
220
+ const rows = this.rowTexts();
221
+ const labelW = Math.max(...rows.map((r) => r.label.length));
222
+ // title, blank, model, blank, option rows, blank, hint
223
+ const total = 6 + rows.length;
86
224
  const top = centerVertically(h, total);
87
- const optionYs = INTENSITY_LEVELS.map((_, i) => top + 4 + i);
88
- const hintY = top + 4 + INTENSITY_LEVELS.length + 2;
225
+ const rowYs = rows.map((_, i) => top + 4 + i);
226
+ const hintY = top + 5 + rows.length;
89
227
  if (hintY > h - 1)
90
228
  return null;
91
- return { optionYs, hintY };
229
+ return { rowYs, hintY, labelW };
92
230
  }
93
231
  save() {
94
232
  const config = loadConfig();
95
233
  config.default_intensity = INTENSITY_LEVELS[this.intensityIdx];
234
+ if (this.contextChanged) {
235
+ const data = loadModels();
236
+ const state = this.app.state;
237
+ const model = data.providers.find((p) => p.name === state.selectedProvider)?.models.find((m) => m.name === state.selectedModelName);
238
+ if (model) {
239
+ model.context_window = this.contextTokens;
240
+ saveModels(data);
241
+ }
242
+ this.contextChanged = false;
243
+ }
244
+ config.approval_model = this.approvalModelIdx === 0 ? "" : this.models[this.approvalModelIdx - 1] ?? "";
245
+ config.auto_approve_extreme_risk = this.extremeIdx === 1;
246
+ config.sound_alerts = this.soundIdx === 1;
96
247
  saveConfig(config);
97
248
  }
98
249
  render(buf) {
99
250
  drawHeader(buf, this.app.state);
100
251
  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;
252
+ const rows = this.rowTexts();
253
+ const labelW = Math.max(...rows.map((r) => r.label.length));
254
+ const total = 6 + rows.length;
112
255
  const top = centerVertically(h, total);
113
256
  let y = top;
114
- buf.centerText(y, lines[0], { fg: C.accent, bold: true });
257
+ buf.centerText(y, "Settings", { fg: C.accent, bold: true });
115
258
  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 });
259
+ buf.centerText(y, `Model: ${this.app.state.selectedProvider}/${this.app.state.selectedModelName}`, {
260
+ fg: C.assistant,
261
+ });
262
+ y += 2;
263
+ for (let i = 0; i < rows.length; i++) {
264
+ const selected = i === this.rowIdx;
265
+ const marker = selected ? "▸ " : " ";
266
+ const label = rows[i].label.padEnd(labelW);
267
+ const value = selected && ROWS[i] !== "web_password" ? `◂ ${rows[i].value} ▸` : rows[i].value;
268
+ const text = marker + label + " " + value;
269
+ if (selected) {
270
+ buf.centerText(y, " " + text + " ", { fg: C.accent, bg: C.highlightBg, bold: true });
271
+ }
272
+ else {
273
+ buf.centerText(y, text, { fg: C.assistant });
274
+ }
123
275
  y += 1;
124
276
  }
125
277
  y += 1;
126
- buf.centerText(y, `model.json: ${MODEL_FILE}`, { fg: C.dim });
127
- y += 1;
128
278
  if (y < h) {
129
- drawHint(buf, y, "↑/↓ change · Enter apply · click to choose · right-click back · Esc back");
279
+ buf.centerText(y, this.editingPassword ? this.passwordMessage : `config: ${CONFIG_FILE}`, { fg: C.dim });
280
+ y += 1;
281
+ }
282
+ if (y < h) {
283
+ drawHint(buf, y, this.editingPassword
284
+ ? "Type or paste password · Enter save · Esc cancel"
285
+ : "↑/↓ select · ←/→ change · Enter apply · right-click back · Esc back");
130
286
  }
131
287
  }
132
288
  }
@@ -1,21 +1,35 @@
1
1
  import { Screen } from "../terminal/screen.js";
2
2
  import { C } from "../colors.js";
3
- import { loadLogo, loadVersion } from "../config.js";
3
+ import { loadLogo, loadVersion, loadConfig, saveConfig, normalizeApprovalMode } from "../config.js";
4
4
  import { drawHeader } from "../header.js";
5
- import { centerVertically, computeInputWindow, cursorFromColumn, drawHint, drawInputBox, drawTabButton, inRect, } from "../ui.js";
5
+ import { centerVertically, computeInputWindow, cursorFromColumn, drawHint, drawInputBox, drawTabButton, inRect, approvalToggle, drawApprovalToggle, } from "../ui.js";
6
6
  const TABS = ["Model", "History", "Settings"];
7
7
  const LOGO = loadLogo();
8
- const FOCUS_COUNT = TABS.length + 1; // input box + 3 tabs
8
+ const FOCUS_COUNT = TABS.length + 2; // input box + 3 tabs + approval mode
9
9
  const INPUT_H = 3;
10
10
  const TAB_H = 3;
11
11
  function isLowSurrogate(code) {
12
12
  return code >= 0xdc00 && code <= 0xdfff;
13
13
  }
14
14
  export class StartScreen extends Screen {
15
+ get approvalMode() {
16
+ return normalizeApprovalMode(loadConfig().approval_mode);
17
+ }
18
+ toggleApproval() {
19
+ const config = loadConfig();
20
+ config.approval_mode = this.approvalMode === "auto" ? "request" : "auto";
21
+ saveConfig(config);
22
+ this.app.render();
23
+ }
24
+ logoLines() {
25
+ const lines = LOGO.replace(/\n+$/, "").split("\n");
26
+ const count = Math.max(0, Math.min(lines.length, this.app.height - 13));
27
+ return count ? lines.slice(-count) : [];
28
+ }
15
29
  inputValue = "";
16
30
  cursor = 0;
17
- focusIdx = 0; // 0 = input box, 1..3 = tabs
18
- hoverIdx = -1; // -1 = nothing, 0 = input box, 1..3 = tabs
31
+ focusIdx = 0; // 0 = input box, 1..3 = tabs, 4 = approval mode
32
+ hoverIdx = -1; // -1 = nothing, otherwise the control index
19
33
  lastHoverX = -1;
20
34
  lastHoverY = -1;
21
35
  showsCursor() {
@@ -34,6 +48,10 @@ export class StartScreen extends Screen {
34
48
  this.app.render();
35
49
  }
36
50
  onKey(key) {
51
+ if (key.name === "tab") {
52
+ this.move(key.shift ? -1 : 1);
53
+ return;
54
+ }
37
55
  // When the input box is focused and holds text, left/right move the text
38
56
  // cursor instead of switching between the input box and the tabs. When the
39
57
  // input box is empty (or another control is focused), left/right switch.
@@ -61,6 +79,9 @@ export class StartScreen extends Screen {
61
79
  if (text)
62
80
  this.app.startNewChat(text);
63
81
  }
82
+ else if (this.focusIdx === FOCUS_COUNT - 1) {
83
+ this.toggleApproval();
84
+ }
64
85
  else {
65
86
  this.enterTab();
66
87
  }
@@ -117,7 +138,10 @@ export class StartScreen extends Screen {
117
138
  this.lastHoverY = ev.y;
118
139
  const layout = this.layout();
119
140
  let hover = -1;
120
- if (layout && inRect(ev.x, ev.y, { x: layout.inputX, y: layout.inputY, w: layout.inputW, h: INPUT_H })) {
141
+ if (inRect(ev.x, ev.y, approvalToggle(this.app.width, this.app.height - 4, this.approvalMode))) {
142
+ hover = FOCUS_COUNT - 1;
143
+ }
144
+ else if (layout && inRect(ev.x, ev.y, { x: layout.inputX, y: layout.inputY, w: layout.inputW, h: INPUT_H })) {
121
145
  hover = 0;
122
146
  }
123
147
  else if (layout && ev.y >= layout.tabY && ev.y < layout.tabY + TAB_H) {
@@ -140,6 +164,11 @@ export class StartScreen extends Screen {
140
164
  return;
141
165
  if (ev.button === 2)
142
166
  return; // right click: no-op on start screen
167
+ if (ev.y >= 1 && inRect(ev.x, ev.y, approvalToggle(this.app.width, this.app.height - 4, this.approvalMode))) {
168
+ this.focusIdx = FOCUS_COUNT - 1;
169
+ this.toggleApproval();
170
+ return;
171
+ }
143
172
  if (ev.y === 0) {
144
173
  this.app.openModelConfig();
145
174
  return;
@@ -168,15 +197,32 @@ export class StartScreen extends Screen {
168
197
  return;
169
198
  }
170
199
  }
200
+ /**
201
+ * The message box, the tabs below it and the hint row are persistent chrome;
202
+ * repainting them every frame keeps them self-healing against terminal-side
203
+ * drift (see Screen.alwaysRepaintRows).
204
+ */
205
+ alwaysRepaintRows() {
206
+ const { inputY, tabY } = this.layout();
207
+ const h = this.app.height;
208
+ const rows = [];
209
+ for (let y = inputY; y <= tabY + TAB_H; y++)
210
+ if (y >= 0 && y < h)
211
+ rows.push(y);
212
+ if (h - 4 >= 1)
213
+ rows.push(h - 4);
214
+ return rows;
215
+ }
171
216
  layout() {
172
217
  const w = this.app.width;
173
218
  const h = this.app.height;
174
219
  const inputW = Math.min(78, w - 4);
175
220
  const inputX = Math.floor((w - inputW) / 2);
176
- const logoLines = LOGO.replace(/\n+$/, "").split("\n");
221
+ const logoLines = this.logoLines();
177
222
  const gap = 1;
178
- const contentH = logoLines.length + 1 + INPUT_H + gap + TAB_H;
179
- const inputY = centerVertically(h, contentH) + logoLines.length + 1;
223
+ const logoGap = logoLines.length ? 1 : 0;
224
+ const contentH = logoLines.length + logoGap + INPUT_H + gap + TAB_H;
225
+ const inputY = centerVertically(h - 4, contentH) + logoLines.length + logoGap;
180
226
  const tabY = inputY + INPUT_H + gap;
181
227
  const tabGap = 2;
182
228
  const tabInner = inputW - tabGap * (TABS.length - 1);
@@ -228,18 +274,19 @@ export class StartScreen extends Screen {
228
274
  const h = buf.height;
229
275
  const inputW = Math.min(78, w - 4);
230
276
  const inputX = Math.floor((w - inputW) / 2);
231
- const logoLines = LOGO.replace(/\n+$/, "").split("\n");
277
+ const logoLines = this.logoLines();
232
278
  const logoMax = logoLines.reduce((m, l) => Math.max(m, l.length), 0);
233
279
  const logoX = Math.max(0, Math.floor((w - logoMax) / 2));
234
280
  const gap = 1;
235
- const contentH = logoLines.length + 1 + INPUT_H + gap + TAB_H;
236
- const top = centerVertically(h, contentH);
281
+ const logoGap = logoLines.length ? 1 : 0;
282
+ const contentH = logoLines.length + logoGap + INPUT_H + gap + TAB_H;
283
+ const top = centerVertically(h - 4, contentH);
237
284
  let y = top;
238
285
  for (const line of logoLines) {
239
286
  buf.writeText(logoX, y, line, { fg: C.accent, bold: true });
240
287
  y++;
241
288
  }
242
- y += 1;
289
+ y += logoGap;
243
290
  drawInputBox(buf, inputX, y, inputW, this.inputValue, this.cursor, {
244
291
  active: this.focusIdx === 0,
245
292
  cursorOn: this.app.state.cursorOn,
@@ -263,10 +310,10 @@ export class StartScreen extends Screen {
263
310
  const version = loadVersion();
264
311
  const versionY = h - 2;
265
312
  const showVersion = version.length > 0 && versionY >= 1;
266
- // The hint sits right below the tabs, but is dropped when it would land on
267
- // or below the reserved version row so the two never collide.
313
+ // Reserve the button row and the blank row between it and the version.
268
314
  const hintY = y + TAB_H + 1;
269
- if (hintY < h && (!showVersion || hintY < versionY)) {
315
+ const approvalY = h - 4;
316
+ if (hintY < approvalY) {
270
317
  const nav = this.focusIdx === 0 && this.inputValue.length > 0
271
318
  ? "←/→ move cursor · ↑/↓ switch"
272
319
  : "←/→ / ↑/↓ switch";
@@ -275,5 +322,6 @@ export class StartScreen extends Screen {
275
322
  if (showVersion) {
276
323
  buf.centerText(versionY, `v${version}`, { fg: C.dim });
277
324
  }
325
+ drawApprovalToggle(buf, approvalY, this.approvalMode, this.focusIdx === FOCUS_COUNT - 1 || this.hoverIdx === FOCUS_COUNT - 1);
278
326
  }
279
327
  }
package/dist/selection.js CHANGED
@@ -6,11 +6,16 @@ export function normalizeSelection(sel) {
6
6
  return { start: a, end: b };
7
7
  return { start: b, end: a };
8
8
  }
9
- /** Index of the last non-blank cell on a row, or -1 when the row is empty. */
9
+ /**
10
+ * Index of the last cell on a row that holds real content, or -1 when the row
11
+ * has none. UI chrome (a scrollbar) and blank cells are ignored, so a highlight
12
+ * never spills over the scrollbar column.
13
+ */
10
14
  function lastContentCol(buf, y) {
11
15
  const row = buf.cells[y];
12
16
  for (let x = row.length - 1; x >= 0; x--) {
13
- if (row[x].ch !== " ")
17
+ const cell = row[x];
18
+ if (!cell.chrome && cell.ch !== " ")
14
19
  return x;
15
20
  }
16
21
  return -1;
@@ -34,8 +39,8 @@ export function applySelection(buf, sel) {
34
39
  continue;
35
40
  for (let x = x0; x <= x1; x++) {
36
41
  const cell = buf.cells[y][x];
37
- if (!cell)
38
- continue;
42
+ if (!cell || cell.chrome)
43
+ continue; // never highlight the scrollbar
39
44
  cell.style = {
40
45
  fg: C.white,
41
46
  bg: C.selection,
@@ -46,9 +51,18 @@ export function applySelection(buf, sel) {
46
51
  }
47
52
  }
48
53
  /**
49
- * Extract the plain text covered by a selection. Trailing whitespace is trimmed
50
- * per line (shell-style) and wide-character continuation cells are skipped, so
51
- * the result can be pasted straight back into a terminal.
54
+ * Extract the plain text covered by a selection.
55
+ *
56
+ * Three kinds of cell are skipped so the result is clean, pasteable text:
57
+ * - UI chrome (the scrollbar), which is never content;
58
+ * - wide-character continuation cells (`""`), which would duplicate a CJK
59
+ * glyph;
60
+ * - trailing blanks, trimmed per line (shell-style).
61
+ *
62
+ * Intermediate lines are copied out to the buffer's full width and only then
63
+ * trimmed, which keeps indentation intact while dropping the padding on the
64
+ * right. Skipping the scrollbar *before* trimming is what lets that trim
65
+ * actually reach the end of the text instead of stopping at the bar.
52
66
  */
53
67
  export function selectionText(buf, sel) {
54
68
  const { start, end } = normalizeSelection(sel);
@@ -57,12 +71,12 @@ export function selectionText(buf, sel) {
57
71
  const lines = [];
58
72
  for (let y = y0; y <= y1; y++) {
59
73
  const x0 = y === start.y ? Math.max(0, start.x) : 0;
60
- let x1 = y === end.y ? end.x : buf.width - 1;
74
+ const x1 = y === end.y ? Math.min(buf.width - 1, end.x) : buf.width - 1;
61
75
  let line = "";
62
- for (let x = x0; x <= Math.min(buf.width - 1, x1); x++) {
76
+ for (let x = x0; x <= x1; x++) {
63
77
  const cell = buf.cells[y][x];
64
- if (!cell || cell.ch === "")
65
- continue; // "" is the tail of a wide glyph
78
+ if (!cell || cell.chrome || cell.ch === "")
79
+ continue;
66
80
  line += cell.ch;
67
81
  }
68
82
  lines.push(line.replace(/[ \t]+$/, ""));
package/dist/state.js CHANGED
@@ -9,6 +9,7 @@ export function defaultState() {
9
9
  isThinking: false,
10
10
  blinkOn: false,
11
11
  cursorOn: true,
12
+ approval: null,
12
13
  selection: null,
13
14
  };
14
15
  }