@xingjisoft/neutrino 0.1.4 → 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,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/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 });
@@ -1,4 +1,5 @@
1
1
  import process from "node:process";
2
+ import { restoreTerminalSync, markTuiActive } from "./restore.js";
2
3
  import { isControlChar } from "../sanitize.js";
3
4
  function styleKey(st) {
4
5
  let k = "";
@@ -44,6 +45,7 @@ function rowSignature(row) {
44
45
  }
45
46
  export class Renderer {
46
47
  prevSig = [];
48
+ disposed = false;
47
49
  prevW = 0;
48
50
  prevH = 0;
49
51
  out = process.stdout;
@@ -56,17 +58,21 @@ export class Renderer {
56
58
  lastBuf = null;
57
59
  lastForce = [];
58
60
  init() {
61
+ // Tell the crash guard the terminal is ours *before* touching it, so a
62
+ // crash that skips dispose() can still undo every switch below.
63
+ markTuiActive();
59
64
  this.write("\x1b[?1049h"); // alternate screen
60
65
  this.write("\x1b[?25l"); // hide cursor
61
66
  this.write("\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h\x1b[?1015h"); // mouse tracking (incl. any-event for hover)
62
67
  this.write("\x1b[?2004h"); // bracketed paste (so pastes can be told apart from typing)
63
68
  }
64
69
  dispose() {
65
- this.write("\x1b[?2004l");
66
- this.write("\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1015l");
67
- this.write("\x1b[?25h");
68
- this.write("\x1b[?1049l");
69
- this.write("\x1b[0m");
70
+ if (this.disposed)
71
+ return;
72
+ this.disposed = true;
73
+ // stdout.write may still be queued when App.quit calls process.exit.
74
+ // Use the same synchronous teardown as the crash/exit guard.
75
+ restoreTerminalSync();
70
76
  }
71
77
  /**
72
78
  * @param forceRows rows to repaint unconditionally, even when their contents
@@ -92,6 +98,17 @@ export class Renderer {
92
98
  all.push(y);
93
99
  this.draw(this.lastBuf, all);
94
100
  }
101
+ /**
102
+ * Ring the terminal bell.
103
+ *
104
+ * Written straight to the stream rather than into a frame: BEL has no visual
105
+ * effect, so it cannot desync the diffed rows, and it must never end up in
106
+ * `lastBuf` where repaint() would replay it.
107
+ */
108
+ bell(times = 1) {
109
+ const count = Math.max(1, Math.trunc(times) || 1);
110
+ this.write("\x07".repeat(count));
111
+ }
95
112
  draw(buf, forceRows) {
96
113
  const w = buf.width;
97
114
  const h = buf.height;
@@ -140,6 +157,14 @@ export class Renderer {
140
157
  return s;
141
158
  }
142
159
  write(chunk) {
143
- this.out.write(chunk);
160
+ if (this.disposed || this.out.destroyed || this.out.writableEnded)
161
+ return;
162
+ try {
163
+ this.out.write(chunk);
164
+ }
165
+ catch {
166
+ // A terminal that vanished (closed window, dying conhost) must not take
167
+ // the whole session down with it.
168
+ }
144
169
  }
145
170
  }