@xingjisoft/neutrino 0.1.2 → 0.1.4

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,4 +1,138 @@
1
1
  import { INTENSITY_PARAMS } from "../config.js";
2
+ /** How many extra times a request is retried when the connection drops. */
3
+ const NETWORK_RETRIES = 2;
4
+ /** Give up if the server has not started to respond within this window. */
5
+ const CONNECT_TIMEOUT_MS = 120_000;
6
+ /**
7
+ * System prompt used when compressing a long conversation. The instruction
8
+ * spells out exactly what must survive compression (goals, decisions, files,
9
+ * commands) so the summary stays useful for continuing the work.
10
+ */
11
+ const SUMMARY_SYSTEM_PROMPT = "You compress the conversation history of an AI coding assistant so it fits in a smaller context window. " +
12
+ "Write a dense, factual summary in the same language as the conversation. Preserve: the user's goals and " +
13
+ "constraints, decisions that were made, files created or modified (with their paths), commands that were run " +
14
+ "and their meaningful results, and any unresolved problems or next steps. Drop pleasantries, restated " +
15
+ "instructions and redundant tool output. Output only the summary, with no preface.";
16
+ /**
17
+ * Terse transport messages that all mean "the connection dropped mid-flight".
18
+ * The \b keeps "terminated" from matching unrelated text such as
19
+ * "Unterminated string constant".
20
+ */
21
+ const DROP_HINT_RE = /\bterminated\b|other side closed|socket hang up|premature close/i;
22
+ /**
23
+ * Flatten an error and its `cause` chain into one readable line.
24
+ *
25
+ * `fetch` reports almost every transport problem as a bare
26
+ * `TypeError: fetch failed`; the actual reason (`ECONNRESET`, `EMFILE`,
27
+ * `ENOTFOUND`, `UND_ERR_…`) is tucked away in `error.cause`. Without walking
28
+ * that chain a failure is impossible to diagnose, so this always reports it.
29
+ */
30
+ export function describeError(e) {
31
+ if (!(e instanceof Error))
32
+ return String(e);
33
+ const parts = [];
34
+ const push = (msg, code) => {
35
+ const text = code && !msg.includes(code) ? `${msg} (${code})` : msg;
36
+ if (text && !parts.includes(text))
37
+ parts.push(text);
38
+ };
39
+ let cur = e;
40
+ const seen = new Set();
41
+ while (cur != null && !seen.has(cur)) {
42
+ if (typeof cur === "string") {
43
+ // fetch/undici sometimes stash the real reason in a plain string
44
+ // (`cause: "other side closed"`), which an object-only walk would miss.
45
+ push(cur);
46
+ break;
47
+ }
48
+ if (typeof cur !== "object")
49
+ break;
50
+ seen.add(cur);
51
+ const err = cur;
52
+ const message = typeof err.message === "string" && err.message ? err.message : String(err.name ?? "Error");
53
+ const code = typeof err.code === "string" ? err.code : undefined;
54
+ push(message, code);
55
+ cur = err.cause;
56
+ }
57
+ const text = parts.join(": ") || e.message;
58
+ // A dropped mid-stream connection surfaces as a bare `TypeError: terminated`,
59
+ // which on its own tells the user nothing worth reading.
60
+ return DROP_HINT_RE.test(text) ? `${text} — the connection was closed before the response finished` : text;
61
+ }
62
+ /**
63
+ * Whether a failed request is worth retrying: a transient connection problem
64
+ * (reset / refused / DNS / timeout) rather than a real response from the API.
65
+ */
66
+ function isRetriableNetworkError(e) {
67
+ const transient = new Set([
68
+ "ECONNRESET",
69
+ "ECONNREFUSED",
70
+ "ECONNABORTED",
71
+ "EPIPE",
72
+ "ETIMEDOUT",
73
+ "ENETUNREACH",
74
+ "ENETDOWN",
75
+ "EAI_AGAIN",
76
+ "ERR_STREAM_PREMATURE_CLOSE",
77
+ "UND_ERR_SOCKET",
78
+ "UND_ERR_CONNECT_TIMEOUT",
79
+ ]);
80
+ let cur = e;
81
+ const seen = new Set();
82
+ while (cur != null && !seen.has(cur)) {
83
+ if (typeof cur === "string") {
84
+ if (DROP_HINT_RE.test(cur))
85
+ return true;
86
+ break;
87
+ }
88
+ if (typeof cur !== "object")
89
+ break;
90
+ seen.add(cur);
91
+ const err = cur;
92
+ if (typeof err.code === "string" && transient.has(err.code))
93
+ return true;
94
+ // Our own connect timeout aborts with a plain AbortError; undici wraps a
95
+ // dropped socket in a SocketError.
96
+ if (err.name === "AbortError" || err.name === "SocketError")
97
+ return true;
98
+ // A stream that dies mid-flight: "terminated", "socket hang up", …
99
+ if (typeof err.message === "string" && DROP_HINT_RE.test(err.message))
100
+ return true;
101
+ cur = err.cause;
102
+ }
103
+ // Undici's generic wrapper: assume the socket dropped and try again.
104
+ return e instanceof Error && /fetch failed/i.test(e.message);
105
+ }
106
+ function sleep(ms) {
107
+ return new Promise((resolve) => setTimeout(resolve, ms));
108
+ }
109
+ /**
110
+ * Issue a streaming request, retrying a few times on transient network errors
111
+ * and giving up with a clear error if the server never starts responding.
112
+ *
113
+ * Retries are safe here because they only ever happen *before* a response is
114
+ * returned; once bytes start flowing the caller is responsible for the stream.
115
+ */
116
+ async function requestStream(url, init) {
117
+ let lastErr;
118
+ for (let attempt = 0; attempt <= NETWORK_RETRIES; attempt++) {
119
+ const controller = new AbortController();
120
+ const timer = setTimeout(() => controller.abort(new Error(`request timed out after ${CONNECT_TIMEOUT_MS / 1000}s`)), CONNECT_TIMEOUT_MS);
121
+ try {
122
+ const resp = await fetch(url, { ...init, signal: controller.signal });
123
+ clearTimeout(timer);
124
+ return resp;
125
+ }
126
+ catch (e) {
127
+ clearTimeout(timer);
128
+ lastErr = e;
129
+ if (attempt === NETWORK_RETRIES || !isRetriableNetworkError(e))
130
+ throw e;
131
+ await sleep(300 * (attempt + 1));
132
+ }
133
+ }
134
+ throw lastErr;
135
+ }
2
136
  export class LLMClient {
3
137
  model;
4
138
  provider;
@@ -15,6 +149,25 @@ export class LLMClient {
15
149
  this.temperature = params.temperature;
16
150
  this.top_p = params.top_p;
17
151
  }
152
+ /**
153
+ * Ask the model to compress a transcript into a short summary. Returns an
154
+ * empty string on any failure so the caller can fall back to simply dropping
155
+ * the old messages. Uses a throw-away request (no tools) via the normal
156
+ * streaming path, so provider differences are handled for free.
157
+ */
158
+ async summarize(transcript) {
159
+ const messages = [
160
+ { role: "system", content: SUMMARY_SYSTEM_PROMPT },
161
+ { role: "user", content: transcript },
162
+ ];
163
+ let out = "";
164
+ for await (const chunk of this.streamTurn(messages))
165
+ out += chunk;
166
+ const text = out.trim();
167
+ if (!text || text.startsWith("[Error"))
168
+ return "";
169
+ return text;
170
+ }
18
171
  async *streamTurn(messages, tools) {
19
172
  this.toolCalls = [];
20
173
  this.usage = null;
@@ -26,10 +179,38 @@ export class LLMClient {
26
179
  yield* this.streamOpenAI(messages, tools);
27
180
  }
28
181
  catch (e) {
29
- yield `\n[Error: ${e instanceof Error ? e.message : String(e)}]`;
182
+ yield `\n[Error: ${describeError(e)}]`;
30
183
  }
31
184
  }
185
+ /**
186
+ * Drive one request, retrying if the stream dies before it produced anything.
187
+ *
188
+ * A response can be cut off on the very first read — "terminated" when a
189
+ * proxy or the server closes the connection mid-flight. Nothing has reached
190
+ * the user yet, so re-issuing the request is invisible (and cannot duplicate
191
+ * text). Once a token has been emitted a retry is unsafe, so the error is
192
+ * surfaced instead.
193
+ */
32
194
  async *streamOpenAI(messages, tools) {
195
+ for (let attempt = 0;; attempt++) {
196
+ this.toolCalls = [];
197
+ let produced = false;
198
+ try {
199
+ for await (const token of this.openAIAttempt(messages, tools)) {
200
+ produced = true;
201
+ yield token;
202
+ }
203
+ return;
204
+ }
205
+ catch (e) {
206
+ if (produced || attempt >= NETWORK_RETRIES || !isRetriableNetworkError(e))
207
+ throw e;
208
+ await sleep(400 * (attempt + 1));
209
+ }
210
+ }
211
+ }
212
+ /** A single streaming attempt; see `streamOpenAI` for the retry policy. */
213
+ async *openAIAttempt(messages, tools) {
33
214
  const url = this.model.url.replace(/\/+$/, "") + "/chat/completions";
34
215
  const headers = {
35
216
  "Content-Type": "application/json",
@@ -45,7 +226,7 @@ export class LLMClient {
45
226
  };
46
227
  if (tools && tools.length)
47
228
  payload.tools = tools;
48
- const resp = await fetch(url, {
229
+ const resp = await requestStream(url, {
49
230
  method: "POST",
50
231
  headers,
51
232
  body: JSON.stringify(payload),
@@ -64,66 +245,100 @@ export class LLMClient {
64
245
  const decoder = new TextDecoder();
65
246
  let buf = "";
66
247
  let stopped = false;
67
- while (!stopped) {
68
- const { done, value } = await reader.read();
69
- if (done)
70
- break;
71
- buf += decoder.decode(value, { stream: true });
72
- let idx;
73
- while ((idx = buf.indexOf("\n")) >= 0) {
74
- const raw = buf.slice(0, idx).replace(/\r$/, "");
75
- buf = buf.slice(idx + 1);
76
- const line = raw.trim();
77
- if (!line)
78
- continue;
79
- if (!line.startsWith("data:"))
80
- continue;
81
- const data = line.slice(5).trim();
82
- if (data === "[DONE]") {
83
- stopped = true;
248
+ try {
249
+ while (!stopped) {
250
+ const { done, value } = await reader.read();
251
+ if (done)
84
252
  break;
85
- }
86
- let chunk;
87
- try {
88
- chunk = JSON.parse(data);
89
- }
90
- catch {
91
- continue;
92
- }
93
- const usage = chunk.usage;
94
- if (usage) {
95
- this.usage = {
96
- prompt: usage.prompt_tokens ?? 0,
97
- completion: usage.completion_tokens ?? 0,
98
- total: usage.total_tokens ?? (usage.prompt_tokens ?? 0) + (usage.completion_tokens ?? 0),
99
- };
100
- }
101
- const choice = chunk.choices?.[0];
102
- const delta = choice?.delta ?? {};
103
- const content = delta.content;
104
- if (typeof content === "string" && content)
105
- yield content;
106
- const toolCalls = delta.tool_calls;
107
- if (toolCalls) {
108
- for (const tc of toolCalls) {
109
- const i = tc.index ?? 0;
110
- const entry = (toolCallsByIndex[i] ??= { id: "", name: "", arguments: "" });
111
- if (tc.id)
112
- entry.id = tc.id;
113
- if (tc.function?.name)
114
- entry.name = tc.function.name;
115
- if (tc.function?.arguments)
116
- entry.arguments += tc.function.arguments;
253
+ buf += decoder.decode(value, { stream: true });
254
+ let idx;
255
+ while ((idx = buf.indexOf("\n")) >= 0) {
256
+ const raw = buf.slice(0, idx).replace(/\r$/, "");
257
+ buf = buf.slice(idx + 1);
258
+ const line = raw.trim();
259
+ if (!line)
260
+ continue;
261
+ if (!line.startsWith("data:"))
262
+ continue;
263
+ const data = line.slice(5).trim();
264
+ if (data === "[DONE]") {
265
+ stopped = true;
266
+ break;
267
+ }
268
+ let chunk;
269
+ try {
270
+ chunk = JSON.parse(data);
271
+ }
272
+ catch {
273
+ continue;
274
+ }
275
+ const usage = chunk.usage;
276
+ if (usage) {
277
+ this.usage = {
278
+ prompt: usage.prompt_tokens ?? 0,
279
+ completion: usage.completion_tokens ?? 0,
280
+ total: usage.total_tokens ?? (usage.prompt_tokens ?? 0) + (usage.completion_tokens ?? 0),
281
+ };
282
+ }
283
+ const choice = chunk.choices?.[0];
284
+ const delta = choice?.delta ?? {};
285
+ const content = delta.content;
286
+ if (typeof content === "string" && content)
287
+ yield content;
288
+ const toolCalls = delta.tool_calls;
289
+ if (toolCalls) {
290
+ for (const tc of toolCalls) {
291
+ const i = tc.index ?? 0;
292
+ const entry = (toolCallsByIndex[i] ??= { id: "", name: "", arguments: "" });
293
+ if (tc.id)
294
+ entry.id = tc.id;
295
+ if (tc.function?.name)
296
+ entry.name = tc.function.name;
297
+ if (tc.function?.arguments)
298
+ entry.arguments += tc.function.arguments;
299
+ }
117
300
  }
118
301
  }
119
302
  }
120
303
  }
304
+ finally {
305
+ // The consumer may walk away mid-stream (pause, a newer turn taking over,
306
+ // or `[DONE]` arriving before the body ends). Always release the reader so
307
+ // the socket is torn down instead of lingering — leaked streaming bodies
308
+ // are what eventually make every request fail with "fetch failed".
309
+ try {
310
+ await reader.cancel();
311
+ }
312
+ catch {
313
+ // Already closed / errored; nothing left to release.
314
+ }
315
+ }
121
316
  this.toolCalls = Object.keys(toolCallsByIndex)
122
317
  .map((k) => Number(k))
123
318
  .sort((a, b) => a - b)
124
319
  .map((k) => toolCallsByIndex[k]);
125
320
  }
321
+ /** Anthropic counterpart of `streamOpenAI`, with the same retry policy. */
126
322
  async *streamAnthropic(messages) {
323
+ for (let attempt = 0;; attempt++) {
324
+ this.toolCalls = [];
325
+ let produced = false;
326
+ try {
327
+ for await (const token of this.anthropicAttempt(messages)) {
328
+ produced = true;
329
+ yield token;
330
+ }
331
+ return;
332
+ }
333
+ catch (e) {
334
+ if (produced || attempt >= NETWORK_RETRIES || !isRetriableNetworkError(e))
335
+ throw e;
336
+ await sleep(400 * (attempt + 1));
337
+ }
338
+ }
339
+ }
340
+ /** A single streaming attempt; see `streamAnthropic` for the retry policy. */
341
+ async *anthropicAttempt(messages) {
127
342
  const url = this.model.url.replace(/\/+$/, "") + "/messages";
128
343
  const headers = {
129
344
  "Content-Type": "application/json",
@@ -147,7 +362,7 @@ export class LLMClient {
147
362
  };
148
363
  if (systemMsg)
149
364
  payload.system = systemMsg;
150
- const resp = await fetch(url, {
365
+ const resp = await requestStream(url, {
151
366
  method: "POST",
152
367
  headers,
153
368
  body: JSON.stringify(payload),
@@ -165,41 +380,51 @@ export class LLMClient {
165
380
  const decoder = new TextDecoder();
166
381
  let buf = "";
167
382
  let stopped = false;
168
- while (!stopped) {
169
- const { done, value } = await reader.read();
170
- if (done)
171
- break;
172
- buf += decoder.decode(value, { stream: true });
173
- let idx;
174
- while ((idx = buf.indexOf("\n")) >= 0) {
175
- const raw = buf.slice(0, idx).replace(/\r$/, "");
176
- buf = buf.slice(idx + 1);
177
- const line = raw.trim();
178
- if (!line || !line.startsWith("data:"))
179
- continue;
180
- const data = line.slice(5).trim();
181
- if (data === "[DONE]") {
182
- stopped = true;
183
- break;
184
- }
185
- let chunk;
186
- try {
187
- chunk = JSON.parse(data);
188
- }
189
- catch {
190
- continue;
191
- }
192
- const type = chunk.type;
193
- if (type === "content_block_delta") {
194
- const text = chunk.delta?.text;
195
- if (text)
196
- yield text;
197
- }
198
- else if (type === "message_stop") {
199
- stopped = true;
383
+ try {
384
+ while (!stopped) {
385
+ const { done, value } = await reader.read();
386
+ if (done)
200
387
  break;
388
+ buf += decoder.decode(value, { stream: true });
389
+ let idx;
390
+ while ((idx = buf.indexOf("\n")) >= 0) {
391
+ const raw = buf.slice(0, idx).replace(/\r$/, "");
392
+ buf = buf.slice(idx + 1);
393
+ const line = raw.trim();
394
+ if (!line || !line.startsWith("data:"))
395
+ continue;
396
+ const data = line.slice(5).trim();
397
+ if (data === "[DONE]") {
398
+ stopped = true;
399
+ break;
400
+ }
401
+ let chunk;
402
+ try {
403
+ chunk = JSON.parse(data);
404
+ }
405
+ catch {
406
+ continue;
407
+ }
408
+ const type = chunk.type;
409
+ if (type === "content_block_delta") {
410
+ const text = chunk.delta?.text;
411
+ if (text)
412
+ yield text;
413
+ }
414
+ else if (type === "message_stop") {
415
+ stopped = true;
416
+ break;
417
+ }
201
418
  }
202
419
  }
203
420
  }
421
+ finally {
422
+ try {
423
+ await reader.cancel();
424
+ }
425
+ catch {
426
+ // Already closed / errored; nothing left to release.
427
+ }
428
+ }
204
429
  }
205
430
  }
package/dist/markdown.js CHANGED
@@ -566,7 +566,7 @@ function renderTable(t, width) {
566
566
  for (let c = 0; c < nCols; c++) {
567
567
  if (c === 0)
568
568
  spans.push({ text: "│ ", style: borderStyle });
569
- const cellLine = wrapped[c][li]?.spans ?? [];
569
+ const cellLine = wrapped[c]?.[li]?.spans ?? [];
570
570
  for (const s of cellLine) {
571
571
  spans.push(isHeader ? { ...s, style: { ...s.style, bold: true } } : s);
572
572
  }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Text hygiene for anything that will be drawn into the terminal Buffer.
3
+ *
4
+ * The renderer copies a cell's glyph straight into the output stream, so a cell
5
+ * that somehow holds a *control* character turns into a command the terminal
6
+ * executes: an ESC sequence can move the cursor, clear the screen or leave the
7
+ * alternate buffer, and a TAB shifts the rest of the row. Tool output (ANSI
8
+ * colours from `ls --color`, `git`, `npm`, progress bars, …), file contents and
9
+ * pasted text routinely carry such bytes, so they must be neutralised before
10
+ * they can reach the screen.
11
+ *
12
+ * `sanitizeText` is the *display-side* cleanup: it removes whole escape
13
+ * sequences (not just the ESC byte, which would leave `[31m` behind as literal
14
+ * text), expands tabs so every character's measured width matches what the
15
+ * terminal will draw, and drops any remaining control code. Newlines are kept
16
+ * because the wrapping/markdown layer needs them to lay text out.
17
+ */
18
+ // Control Sequence Introducer: ESC [ params intermediates final
19
+ const CSI = /\x1b\[[0-?]*[ -/]*[@-~]/g;
20
+ // Operating System Command: ESC ] … (BEL | ST)
21
+ const OSC = /\x1b\][\s\S]*?(?:\x07|\x1b\\)/g;
22
+ // Device Control / Privacy Message / Application Program Command … ST
23
+ const DCS = /\x1b[P^_X][\s\S]*?\x1b\\/g;
24
+ // Two-character Fe/Fs/Fp escapes (ESC 7, ESC =, ESC (B, …)
25
+ const ESC_SIMPLE = /\x1b[ -/]*[@-~]/g;
26
+ // A lone ESC that nothing else matched.
27
+ const ESC_LONE = /\x1b/g;
28
+ /** Remove ANSI/VT escape sequences from `text`. */
29
+ export function stripAnsi(text) {
30
+ return text
31
+ .replace(OSC, "")
32
+ .replace(DCS, "")
33
+ .replace(CSI, "")
34
+ .replace(ESC_SIMPLE, "")
35
+ .replace(ESC_LONE, "");
36
+ }
37
+ // C0 controls (except LF, which the layout engine needs) plus DEL and C1.
38
+ const OTHER_CONTROLS = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g;
39
+ /**
40
+ * Make `text` safe to render: normalise newlines, drop escape sequences, expand
41
+ * tabs and remove every other control code. The result contains no character
42
+ * the terminal would interpret as a command, so it can never corrupt the grid.
43
+ */
44
+ export function sanitizeText(text, opts = {}) {
45
+ const tabWidth = opts.tabWidth ?? 4;
46
+ let s = text.replace(/\r\n?/g, "\n");
47
+ s = stripAnsi(s);
48
+ if (tabWidth > 0)
49
+ s = s.replace(/\t/g, " ".repeat(tabWidth));
50
+ s = s.replace(OTHER_CONTROLS, "");
51
+ if (opts.singleLine)
52
+ s = s.replace(/\s*\n\s*/g, " ");
53
+ return s;
54
+ }
55
+ /** True if `ch` is a character the terminal would treat as a control code. */
56
+ export function isControlChar(ch) {
57
+ if (!ch)
58
+ return false;
59
+ const cp = ch.codePointAt(0) ?? 0;
60
+ return cp < 32 || (cp >= 0x7f && cp <= 0x9f);
61
+ }
@@ -1,7 +1,7 @@
1
1
  import { Screen } from "../terminal/screen.js";
2
2
  import { C } from "../colors.js";
3
3
  import { drawHeader } from "../header.js";
4
- import { drawHint } from "../ui.js";
4
+ import { drawHint, drawScrollbar } from "../ui.js";
5
5
  import { drawSpans, renderMarkdownText } from "../markdown.js";
6
6
  import { ellipsize } from "../width.js";
7
7
  export class AgentConversationScreen extends Screen {
@@ -9,6 +9,7 @@ export class AgentConversationScreen extends Screen {
9
9
  messages;
10
10
  scrollTop = 0;
11
11
  followBottom = true;
12
+ scrollbarGeom = null;
12
13
  constructor(title, messages) {
13
14
  super();
14
15
  this.title = title;
@@ -52,6 +53,25 @@ export class AgentConversationScreen extends Screen {
52
53
  this.app.popScreen();
53
54
  }
54
55
  }
56
+ scrollbar() {
57
+ const g = this.scrollbarGeom;
58
+ if (!g || g.total <= g.viewport)
59
+ return null;
60
+ return {
61
+ x: g.x,
62
+ top: g.top,
63
+ bottom: g.bottom,
64
+ total: g.total,
65
+ viewport: g.viewport,
66
+ offset: this.scrollTop,
67
+ scrollTo: (offset) => {
68
+ const maxScroll = Math.max(0, g.total - g.viewport);
69
+ this.scrollTop = Math.max(0, Math.min(offset, maxScroll));
70
+ this.followBottom = this.scrollTop >= maxScroll;
71
+ this.app.render();
72
+ },
73
+ };
74
+ }
55
75
  buildLines(width) {
56
76
  const lines = [];
57
77
  for (const m of this.messages) {
@@ -85,12 +105,19 @@ export class AgentConversationScreen extends Screen {
85
105
  }
86
106
  if (this.scrollTop < 0)
87
107
  this.scrollTop = 0;
108
+ // Expose the bar so the app can drag it (see the scrollbar() method).
109
+ this.scrollbarGeom =
110
+ lines.length > viewH
111
+ ? { x: w - 1, top: logTop, bottom: logBottom, total: lines.length, viewport: viewH }
112
+ : null;
88
113
  for (let y = logTop; y <= logBottom; y++) {
89
114
  const li = this.scrollTop + (y - logTop);
90
115
  if (li >= lines.length)
91
116
  break;
92
117
  drawSpans(buf, 2, y, lines[li].spans, Math.max(4, w - 4));
93
118
  }
94
- drawHint(buf, h - 1, "↑/↓ scroll · wheel scroll · right-click back · Esc back");
119
+ // Scrollbar on the right edge, visible only when the log overflows.
120
+ drawScrollbar(buf, w - 1, logTop, logBottom, lines.length, viewH, this.scrollTop);
121
+ drawHint(buf, h - 1, "↑/↓ scroll · wheel or drag the bar · right-click back · Esc back");
95
122
  }
96
123
  }