@xingjisoft/neutrino 0.1.4 → 0.1.6

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,38 +1,108 @@
1
1
  import { Screen } from "../terminal/screen.js";
2
2
  import { C } from "../colors.js";
3
- import { COMPRESS_THRESHOLD_OPTIONS, CONFIG_FILE, INTENSITY_LEVELS, 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";
6
7
  /** The settings that can be changed here, top to bottom. */
7
- const ROWS = ["intensity", "compress"];
8
+ const ROWS = ["intensity", "compress", "approval_model", "extreme_approval", "sound", "web_password"];
8
9
  export class SettingsScreen extends Screen {
9
10
  intensityIdx = 2;
10
- thresholdIdx = 3;
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 = [];
11
20
  rowIdx = 0;
12
21
  lastHoverX = -1;
13
22
  lastHoverY = -1;
23
+ editingPassword = false;
24
+ passwordDraft = "";
25
+ passwordMessage = "Press Enter to change";
14
26
  onActivate() {
15
27
  const config = loadConfig();
16
28
  const current = String(config.default_intensity ?? "High");
17
29
  const i = INTENSITY_LEVELS.indexOf(current);
18
30
  this.intensityIdx = i >= 0 ? i : 2;
19
- const threshold = Number(config.context_compress_threshold ?? 32000);
20
- const t = COMPRESS_THRESHOLD_OPTIONS.findIndex((o) => o.value === threshold);
21
- this.thresholdIdx = t >= 0 ? t : 3;
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;
22
41
  }
23
42
  /** Change the value of a row by `delta` steps and persist it immediately. */
24
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
+ }
25
50
  if (row === "intensity") {
26
51
  const n = INTENSITY_LEVELS.length;
27
52
  this.intensityIdx = (this.intensityIdx + delta + n) % n;
28
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
+ }
29
65
  else {
30
- const n = COMPRESS_THRESHOLD_OPTIONS.length;
31
- this.thresholdIdx = (this.thresholdIdx + delta + n) % n;
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;
32
70
  }
33
71
  this.save();
34
72
  }
35
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
+ }
36
106
  if (key.name === "up") {
37
107
  this.rowIdx = (this.rowIdx + ROWS.length - 1) % ROWS.length;
38
108
  this.app.render();
@@ -50,7 +120,10 @@ export class SettingsScreen extends Screen {
50
120
  this.app.render();
51
121
  }
52
122
  else if (key.name === "enter" || key.name === "return") {
53
- this.save();
123
+ if (ROWS[this.rowIdx] === "web_password")
124
+ this.cycle("web_password", 1);
125
+ else
126
+ this.save();
54
127
  this.app.render();
55
128
  }
56
129
  else if (key.name === "escape") {
@@ -59,6 +132,8 @@ export class SettingsScreen extends Screen {
59
132
  }
60
133
  }
61
134
  onMouse(ev) {
135
+ if (this.editingPassword)
136
+ return;
62
137
  const layout = this.layout();
63
138
  if (!layout)
64
139
  return;
@@ -106,10 +181,38 @@ export class SettingsScreen extends Screen {
106
181
  }
107
182
  }
108
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
+ }
109
194
  rowTexts() {
110
195
  return [
111
196
  { label: "Default intensity", value: INTENSITY_LEVELS[this.intensityIdx] },
112
- { label: "Auto-compress context", value: COMPRESS_THRESHOLD_OPTIONS[this.thresholdIdx].label },
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
+ },
113
216
  ];
114
217
  }
115
218
  layout() {
@@ -128,7 +231,19 @@ export class SettingsScreen extends Screen {
128
231
  save() {
129
232
  const config = loadConfig();
130
233
  config.default_intensity = INTENSITY_LEVELS[this.intensityIdx];
131
- config.context_compress_threshold = COMPRESS_THRESHOLD_OPTIONS[this.thresholdIdx].value;
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;
132
247
  saveConfig(config);
133
248
  }
134
249
  render(buf) {
@@ -149,7 +264,7 @@ export class SettingsScreen extends Screen {
149
264
  const selected = i === this.rowIdx;
150
265
  const marker = selected ? "▸ " : " ";
151
266
  const label = rows[i].label.padEnd(labelW);
152
- const value = selected ? `◂ ${rows[i].value} ▸` : rows[i].value;
267
+ const value = selected && ROWS[i] !== "web_password" ? `◂ ${rows[i].value} ▸` : rows[i].value;
153
268
  const text = marker + label + " " + value;
154
269
  if (selected) {
155
270
  buf.centerText(y, " " + text + " ", { fg: C.accent, bg: C.highlightBg, bold: true });
@@ -161,11 +276,13 @@ export class SettingsScreen extends Screen {
161
276
  }
162
277
  y += 1;
163
278
  if (y < h) {
164
- buf.centerText(y, `config: ${CONFIG_FILE}`, { fg: C.dim });
279
+ buf.centerText(y, this.editingPassword ? this.passwordMessage : `config: ${CONFIG_FILE}`, { fg: C.dim });
165
280
  y += 1;
166
281
  }
167
282
  if (y < h) {
168
- drawHint(buf, y, "↑/↓ select · ←/→ change · Enter apply · right-click back · Esc back");
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");
169
286
  }
170
287
  }
171
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;
@@ -180,6 +209,8 @@ export class StartScreen extends Screen {
180
209
  for (let y = inputY; y <= tabY + TAB_H; y++)
181
210
  if (y >= 0 && y < h)
182
211
  rows.push(y);
212
+ if (h - 4 >= 1)
213
+ rows.push(h - 4);
183
214
  return rows;
184
215
  }
185
216
  layout() {
@@ -187,10 +218,11 @@ export class StartScreen extends Screen {
187
218
  const h = this.app.height;
188
219
  const inputW = Math.min(78, w - 4);
189
220
  const inputX = Math.floor((w - inputW) / 2);
190
- const logoLines = LOGO.replace(/\n+$/, "").split("\n");
221
+ const logoLines = this.logoLines();
191
222
  const gap = 1;
192
- const contentH = logoLines.length + 1 + INPUT_H + gap + TAB_H;
193
- 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;
194
226
  const tabY = inputY + INPUT_H + gap;
195
227
  const tabGap = 2;
196
228
  const tabInner = inputW - tabGap * (TABS.length - 1);
@@ -242,18 +274,19 @@ export class StartScreen extends Screen {
242
274
  const h = buf.height;
243
275
  const inputW = Math.min(78, w - 4);
244
276
  const inputX = Math.floor((w - inputW) / 2);
245
- const logoLines = LOGO.replace(/\n+$/, "").split("\n");
277
+ const logoLines = this.logoLines();
246
278
  const logoMax = logoLines.reduce((m, l) => Math.max(m, l.length), 0);
247
279
  const logoX = Math.max(0, Math.floor((w - logoMax) / 2));
248
280
  const gap = 1;
249
- const contentH = logoLines.length + 1 + INPUT_H + gap + TAB_H;
250
- 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);
251
284
  let y = top;
252
285
  for (const line of logoLines) {
253
286
  buf.writeText(logoX, y, line, { fg: C.accent, bold: true });
254
287
  y++;
255
288
  }
256
- y += 1;
289
+ y += logoGap;
257
290
  drawInputBox(buf, inputX, y, inputW, this.inputValue, this.cursor, {
258
291
  active: this.focusIdx === 0,
259
292
  cursorOn: this.app.state.cursorOn,
@@ -277,10 +310,10 @@ export class StartScreen extends Screen {
277
310
  const version = loadVersion();
278
311
  const versionY = h - 2;
279
312
  const showVersion = version.length > 0 && versionY >= 1;
280
- // The hint sits right below the tabs, but is dropped when it would land on
281
- // 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.
282
314
  const hintY = y + TAB_H + 1;
283
- if (hintY < h && (!showVersion || hintY < versionY)) {
315
+ const approvalY = h - 4;
316
+ if (hintY < approvalY) {
284
317
  const nav = this.focusIdx === 0 && this.inputValue.length > 0
285
318
  ? "←/→ move cursor · ↑/↓ switch"
286
319
  : "←/→ / ↑/↓ switch";
@@ -289,5 +322,6 @@ export class StartScreen extends Screen {
289
322
  if (showVersion) {
290
323
  buf.centerText(versionY, `v${version}`, { fg: C.dim });
291
324
  }
325
+ drawApprovalToggle(buf, approvalY, this.approvalMode, this.focusIdx === FOCUS_COUNT - 1 || this.hoverIdx === FOCUS_COUNT - 1);
292
326
  }
293
327
  }
package/dist/selection.js CHANGED
@@ -6,6 +6,12 @@ export function normalizeSelection(sel) {
6
6
  return { start: a, end: b };
7
7
  return { start: b, end: a };
8
8
  }
9
+ /** Order the two ends of a line-space selection into reading order. */
10
+ export function orderLinePoints(a, b) {
11
+ if (a.line < b.line || (a.line === b.line && a.x <= b.x))
12
+ return { start: a, end: b };
13
+ return { start: b, end: a };
14
+ }
9
15
  /**
10
16
  * Index of the last cell on a row that holds real content, or -1 when the row
11
17
  * has none. UI chrome (a scrollbar) and blank cells are ignored, so a highlight
@@ -87,3 +93,57 @@ export function selectionText(buf, sel) {
87
93
  lines.pop();
88
94
  return lines.join("\n");
89
95
  }
96
+ /**
97
+ * One buffer row as column-indexed text: `cells[x]` is what column `x` holds,
98
+ * or `""` for a cell that never copies (UI chrome, and the tail of a
99
+ * double-width glyph).
100
+ *
101
+ * Keeping the column index is the point: it lets a row be sliced by column
102
+ * later, after the frame it came from is long gone. A drag that scrolls the log
103
+ * needs exactly that, because the rows it started on leave the screen while the
104
+ * selection keeps growing.
105
+ */
106
+ export function rowCells(buf, y) {
107
+ const row = buf.cells[y];
108
+ if (!row)
109
+ return [];
110
+ const cells = new Array(row.length);
111
+ for (let x = 0; x < row.length; x++) {
112
+ const cell = row[x];
113
+ cells[x] = !cell || cell.chrome || cell.ch === "" ? "" : cell.ch;
114
+ }
115
+ return cells;
116
+ }
117
+ /**
118
+ * Extract the text of a selection expressed in content lines (`rowSelectionText`
119
+ * is to `selectionText` what `LinePoint` is to `Point`).
120
+ *
121
+ * `getRow` supplies the cells of a line — from the current frame while the line
122
+ * is on screen, from the snapshot taken during the drag once it has scrolled
123
+ * away, or `null` for a line that was never captured. Trailing blanks and edge
124
+ * blank lines are trimmed exactly like `selectionText`, so both paths produce
125
+ * the same text for a selection that fits on one screen.
126
+ */
127
+ export function rowSelectionText(getRow, start, end) {
128
+ const lines = [];
129
+ for (let line = start.line; line <= end.line; line++) {
130
+ const row = getRow(line);
131
+ if (!row) {
132
+ // Never captured (a line that scrolled past without being drawn). Keep
133
+ // the line count honest instead of glueing its neighbours together.
134
+ lines.push("");
135
+ continue;
136
+ }
137
+ const x0 = line === start.line ? Math.max(0, start.x) : 0;
138
+ const x1 = line === end.line ? Math.min(row.length - 1, end.x) : row.length - 1;
139
+ let text = "";
140
+ for (let x = x0; x <= x1; x++)
141
+ text += row[x] ?? "";
142
+ lines.push(text.replace(/[ \t]+$/, ""));
143
+ }
144
+ while (lines.length > 0 && lines[0] === "")
145
+ lines.shift();
146
+ while (lines.length > 0 && lines[lines.length - 1] === "")
147
+ lines.pop();
148
+ return lines.join("\n");
149
+ }
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
  }
@@ -54,9 +54,20 @@ export class InputReader {
54
54
  stdin.removeListener("data", this.dataHandler);
55
55
  if (this.escTimer)
56
56
  clearTimeout(this.escTimer);
57
- if (stdin.isTTY)
58
- stdin.setRawMode(false);
59
- stdin.pause();
57
+ try {
58
+ if (stdin.isTTY)
59
+ stdin.setRawMode(false);
60
+ }
61
+ catch {
62
+ // The console already went away; leaving raw mode is best-effort here and
63
+ // failing must not abort the rest of the shutdown.
64
+ }
65
+ try {
66
+ stdin.pause();
67
+ }
68
+ catch {
69
+ // Same as above.
70
+ }
60
71
  }
61
72
  onData(chunk) {
62
73
  this.pending += this.decoder.decode(chunk, { stream: true });
@@ -183,6 +194,24 @@ export class InputReader {
183
194
  finishCsi() {
184
195
  const s = this.csiBuf;
185
196
  const final = s[s.length - 1];
197
+ // CSI-u (kitty) and xterm modifyOtherKeys preserve Ctrl+Shift separately.
198
+ const extended = final === "u"
199
+ ? /^(\d+)(?::[\d:]*)?(?:;(\d+)(?::(\d+))?)?(?:;[\d:]*)?u$/.exec(s)
200
+ : /^27;(\d+);(\d+)~$/.exec(s);
201
+ if (extended) {
202
+ const code = Number(extended[final === "u" ? 1 : 2]);
203
+ const modifiers = Number(extended[final === "u" ? 2 : 1] ?? 1) - 1;
204
+ // Ignore key-up reports: releasing C must not count as another press.
205
+ if (final === "u" && extended[3] === "3")
206
+ return;
207
+ if (code < 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff))
208
+ return;
209
+ const char = String.fromCodePoint(code);
210
+ const special = { 13: "return", 9: "tab", 27: "escape", 127: "backspace" };
211
+ const ctrl = (modifiers & 4) !== 0;
212
+ this.emitKey(special[code] ?? (ctrl ? char.toLowerCase() : char), ctrl, (modifiers & 2) !== 0, (modifiers & 1) !== 0, char, "\x1b[" + s);
213
+ return;
214
+ }
186
215
  if (final === "A")
187
216
  this.emitKey("up", false, false, false, "\x1b[A");
188
217
  else if (final === "B")
@@ -261,7 +290,7 @@ export class InputReader {
261
290
  this.emitMouse({ type: "click", x, y, button });
262
291
  }
263
292
  }
264
- emitKey(name, ctrl, meta, shift, char) {
293
+ emitKey(name, ctrl, meta, shift, char, sequence = char) {
265
294
  if (!this.handler)
266
295
  return;
267
296
  this.handler({
@@ -272,7 +301,7 @@ export class InputReader {
272
301
  meta,
273
302
  shift,
274
303
  char,
275
- sequence: char,
304
+ sequence,
276
305
  },
277
306
  });
278
307
  }