agent-sh 0.15.8 → 0.15.9

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.
@@ -57,7 +57,8 @@ export class InputHandler {
57
57
  loadHistory() {
58
58
  try {
59
59
  const data = fs.readFileSync(HISTORY_FILE, "utf-8");
60
- this.history = data.split("\n").filter(Boolean);
60
+ this.history = data.split("\n").filter(Boolean)
61
+ .map((l) => l.replace(/\\([\\n])/g, (_, c) => c === "n" ? "\n" : "\\"));
61
62
  }
62
63
  catch {
63
64
  }
@@ -66,7 +67,8 @@ export class InputHandler {
66
67
  try {
67
68
  const { historySize } = getSettings();
68
69
  fs.mkdirSync(path.dirname(HISTORY_FILE), { recursive: true });
69
- const lines = this.history.slice(-historySize);
70
+ const lines = this.history.slice(-historySize)
71
+ .map((l) => l.replace(/\\/g, "\\\\").replace(/\n/g, "\\n"));
70
72
  fs.writeFileSync(HISTORY_FILE, lines.join("\n") + "\n");
71
73
  }
72
74
  catch {
@@ -373,7 +375,7 @@ export class InputHandler {
373
375
  this.editor.clear();
374
376
  this.view.resetCursor();
375
377
  this.dismissAutocomplete();
376
- if (query && query.startsWith("/")) {
378
+ if (query && query.startsWith("/") && !query.includes("\n")) {
377
379
  const spaceIdx = query.indexOf(" ");
378
380
  const name = spaceIdx === -1 ? query : query.slice(0, spaceIdx);
379
381
  const args = spaceIdx === -1 ? "" : query.slice(spaceIdx + 1).trim();
@@ -1,12 +1,3 @@
1
- /**
2
- * Terminal — the user-facing I/O endpoint that a Shell talks to.
3
- *
4
- * Shell wraps a *pseudo*-terminal (the PTY the child shell sees). This
5
- * interface is the *real* terminal (or its substitute) on the other end:
6
- * bytes in, bytes out, dimensions, resize notifications. The default
7
- * factory wires it to process.stdin/stdout for the CLI; headless hosts
8
- * (multi-session web hubs, tests) supply their own.
9
- */
10
1
  import type { RenderSurface } from "../utils/compositor.js";
11
2
  export interface Terminal {
12
3
  write(data: string): void;
@@ -23,8 +14,8 @@ export interface Terminal {
23
14
  resume(): void;
24
15
  };
25
16
  }
26
- /** Default Terminal: wraps process.stdin/stdout. */
27
- export declare function processTerminal(): Terminal;
17
+ /** Default Terminal: wraps process.stdin/stdout (injectable for tests). */
18
+ export declare function processTerminal(stdin?: NodeJS.ReadStream, stdout?: NodeJS.WriteStream): Terminal;
28
19
  /**
29
20
  * No-op terminal for non-rendering hosts (tests, agent-only embeds).
30
21
  * Writes are discarded; input/resize never fire.
@@ -1,42 +1,60 @@
1
- /** Default Terminal: wraps process.stdin/stdout. */
2
- export function processTerminal() {
1
+ /**
2
+ * Terminal — the user-facing I/O endpoint that a Shell talks to.
3
+ *
4
+ * Shell wraps a *pseudo*-terminal (the PTY the child shell sees). This
5
+ * interface is the *real* terminal (or its substitute) on the other end:
6
+ * bytes in, bytes out, dimensions, resize notifications. The default
7
+ * factory wires it to process.stdin/stdout for the CLI; headless hosts
8
+ * (multi-session web hubs, tests) supply their own.
9
+ */
10
+ import { StringDecoder } from "node:string_decoder";
11
+ /** Default Terminal: wraps process.stdin/stdout (injectable for tests). */
12
+ export function processTerminal(stdin = process.stdin, stdout = process.stdout) {
3
13
  return {
4
14
  write(data) {
5
- if (process.stdout.writable) {
15
+ if (stdout.writable) {
6
16
  try {
7
- process.stdout.write(data);
17
+ stdout.write(data);
8
18
  }
9
19
  catch { /* ignore */ }
10
20
  }
11
21
  },
12
22
  onInput(cb) {
13
- const handler = (b) => cb(b.toString("utf-8"));
14
- process.stdin.on("data", handler);
15
- return () => { process.stdin.off("data", handler); };
23
+ // Stateful decode: tty chunk boundaries can land mid-way through a
24
+ // multibyte UTF-8 sequence (large pastes), so per-chunk toString()
25
+ // would emit U+FFFD for the torn halves.
26
+ const decoder = new StringDecoder("utf-8");
27
+ const handler = (b) => {
28
+ const text = decoder.write(b);
29
+ if (text)
30
+ cb(text);
31
+ };
32
+ stdin.on("data", handler);
33
+ return () => { stdin.off("data", handler); };
16
34
  },
17
35
  onResize(cb) {
18
- const handler = () => cb(process.stdout.columns || 80, process.stdout.rows || 24);
19
- process.stdout.on("resize", handler);
20
- return () => { process.stdout.off("resize", handler); };
36
+ const handler = () => cb(stdout.columns || 80, stdout.rows || 24);
37
+ stdout.on("resize", handler);
38
+ return () => { stdout.off("resize", handler); };
21
39
  },
22
- cols() { return process.stdout.columns || 80; },
23
- rows() { return process.stdout.rows || 24; },
40
+ cols() { return stdout.columns || 80; },
41
+ rows() { return stdout.rows || 24; },
24
42
  suspendInput() {
25
- const wasRaw = process.stdin.isTTY && process.stdin.isRaw;
26
- if (process.stdin.isTTY) {
43
+ const wasRaw = stdin.isTTY && stdin.isRaw;
44
+ if (stdin.isTTY) {
27
45
  try {
28
- process.stdin.setRawMode(false);
29
- process.stdin.pause();
46
+ stdin.setRawMode(false);
47
+ stdin.pause();
30
48
  }
31
49
  catch { /* ignore */ }
32
50
  }
33
51
  return {
34
52
  resume() {
35
- if (process.stdin.isTTY) {
53
+ if (stdin.isTTY) {
36
54
  try {
37
- process.stdin.resume();
55
+ stdin.resume();
38
56
  if (wasRaw)
39
- process.stdin.setRawMode(true);
57
+ stdin.setRawMode(true);
40
58
  }
41
59
  catch { /* ignore */ }
42
60
  }
@@ -845,7 +845,7 @@ export default function activate(ctx) {
845
845
  ? getSettings().readOutputMaxLines
846
846
  : getSettings().maxCommandOutputLines;
847
847
  s.commandOutputBuffer += chunk;
848
- const lines = s.commandOutputBuffer.split("\n");
848
+ const lines = s.commandOutputBuffer.split(/\r?\n/);
849
849
  s.commandOutputBuffer = lines.pop();
850
850
  for (const line of lines) {
851
851
  if (s.commandOutputLineCount < maxLines) {
@@ -44,3 +44,10 @@ export declare function padEndToWidth(str: string, targetWidth: number): string;
44
44
  * CSI, private-mode, 8-bit CSI, and newer variants). `\r` is not an escape
45
45
  * but callers rely on it being stripped alongside. */
46
46
  export declare function stripAnsi(str: string): string;
47
+ /**
48
+ * Sanitize text for painting at a fixed screen position: SGR (color/style)
49
+ * passes through; anything else that would move the cursor or mutate
50
+ * terminal state mid-row is dropped. Tabs become a single space so painted
51
+ * width matches `stripAnsi`-based measurement.
52
+ */
53
+ export declare function stripCursorControls(str: string): string;
@@ -134,3 +134,23 @@ export function padEndToWidth(str, targetWidth) {
134
134
  export function stripAnsi(str) {
135
135
  return stripAnsiPkg(str).replace(/\r/g, "");
136
136
  }
137
+ /**
138
+ * Sanitize text for painting at a fixed screen position: SGR (color/style)
139
+ * passes through; anything else that would move the cursor or mutate
140
+ * terminal state mid-row is dropped. Tabs become a single space so painted
141
+ * width matches `stripAnsi`-based measurement.
142
+ */
143
+ export function stripCursorControls(str) {
144
+ // Park SGR behind NUL placeholders so the strips below can't eat their ESC bytes.
145
+ const sgr = [];
146
+ const cleaned = str
147
+ .replace(/\x00/g, "")
148
+ .replace(/\x1b\[[0-9;:]*m/g, (m) => { sgr.push(m); return "\x00"; })
149
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g, "")
150
+ .replace(/\x1b\[[0-9;:?]*[ -/]*[@-~]/g, "")
151
+ .replace(/\x1b./g, "")
152
+ .replace(/\t/g, " ")
153
+ .replace(/[\x01-\x08\x0a-\x1f\x7f]/g, "");
154
+ let i = 0;
155
+ return cleaned.replace(/\x00/g, () => sgr[i++] ?? "");
156
+ }
@@ -30,7 +30,7 @@
30
30
  * Usage from extensions:
31
31
  * import { FloatingPanel } from "agent-sh/utils/floating-panel.js";
32
32
  */
33
- import { stripAnsi } from "./ansi.js";
33
+ import { stripAnsi, stripCursorControls } from "./ansi.js";
34
34
  import { wrapLine } from "./markdown.js";
35
35
  import { LineEditor } from "./line-editor.js";
36
36
  import { TerminalBuffer } from "./terminal-buffer.js";
@@ -253,10 +253,11 @@ export class FloatingPanel {
253
253
  this.handlers.define(`${p}:input`, (_data) => false);
254
254
  // Default row builder: truncate and pad
255
255
  this.handlers.define(`${p}:build-row`, (content, width) => {
256
- const plain = stripAnsi(content);
256
+ const clean = stripCursorControls(content);
257
+ const plain = stripAnsi(clean);
257
258
  const display = plain.length > width
258
- ? content.slice(0, width - 1) + "\u2026"
259
- : content;
259
+ ? clean.slice(0, width - 1) + "\u2026"
260
+ : clean;
260
261
  const pad = Math.max(0, width - stripAnsi(display).length);
261
262
  return display + " ".repeat(pad);
262
263
  });
@@ -121,7 +121,7 @@ export class LineEditor {
121
121
  // paste, since typed input arrives one keystroke per chunk in raw mode.
122
122
  if (!this.inPaste && data.length > 1 && /[\r\n]/.test(data)
123
123
  && data.indexOf("\x1b[200~") === -1) {
124
- this.pasteAccum = data.replace(/\r\n?/g, "\n");
124
+ this.pasteAccum = data;
125
125
  actions.push(...this.commitPaste());
126
126
  return actions;
127
127
  }
@@ -247,7 +247,7 @@ export class LineEditor {
247
247
  consumePasteChunk(data) {
248
248
  const endIdx = data.indexOf(PASTE_END);
249
249
  if (endIdx !== -1) {
250
- this.pasteAccum += data.slice(0, endIdx).replace(/\r/g, "");
250
+ this.pasteAccum += data.slice(0, endIdx);
251
251
  return endIdx;
252
252
  }
253
253
  let suffixLen = 0;
@@ -258,14 +258,17 @@ export class LineEditor {
258
258
  }
259
259
  }
260
260
  const safeEnd = data.length - suffixLen;
261
- this.pasteAccum += data.slice(0, safeEnd).replace(/\r/g, "");
261
+ this.pasteAccum += data.slice(0, safeEnd);
262
262
  if (suffixLen > 0)
263
263
  this.pendingSeq = data.slice(safeEnd);
264
264
  return -1;
265
265
  }
266
266
  commitPaste() {
267
267
  this.inPaste = false;
268
- const accum = this.pasteAccum;
268
+ // Pasted line separators arrive as \n, \r (xterm convention), or \r\n
269
+ // depending on terminal; normalize on the full accumulation so a \r\n
270
+ // pair split across chunks still collapses to one newline.
271
+ const accum = this.pasteAccum.replace(/\r\n?/g, "\n");
269
272
  this.pasteAccum = "";
270
273
  if (!accum)
271
274
  return [];
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guanyilun/ashi",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "Ash in an interactive TUI — agent-sh's built-in agent without the shell underneath",
5
5
  "type": "module",
6
6
  "main": "dist/cli.js",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sh",
3
- "version": "0.15.8",
3
+ "version": "0.15.9",
4
4
  "description": "A composable agent runtime — pair any frontend with any agent backend over one shared extension layer",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -88,7 +88,8 @@ export class InputHandler {
88
88
  private loadHistory(): void {
89
89
  try {
90
90
  const data = fs.readFileSync(HISTORY_FILE, "utf-8");
91
- this.history = data.split("\n").filter(Boolean);
91
+ this.history = data.split("\n").filter(Boolean)
92
+ .map((l) => l.replace(/\\([\\n])/g, (_, c: string) => c === "n" ? "\n" : "\\"));
92
93
  } catch {
93
94
  }
94
95
  }
@@ -97,7 +98,8 @@ export class InputHandler {
97
98
  try {
98
99
  const { historySize } = getSettings();
99
100
  fs.mkdirSync(path.dirname(HISTORY_FILE), { recursive: true });
100
- const lines = this.history.slice(-historySize);
101
+ const lines = this.history.slice(-historySize)
102
+ .map((l) => l.replace(/\\/g, "\\\\").replace(/\n/g, "\\n"));
101
103
  fs.writeFileSync(HISTORY_FILE, lines.join("\n") + "\n");
102
104
  } catch {
103
105
  }
@@ -392,7 +394,7 @@ export class InputHandler {
392
394
  this.editor.clear();
393
395
  this.view.resetCursor();
394
396
  this.dismissAutocomplete();
395
- if (query && query.startsWith("/")) {
397
+ if (query && query.startsWith("/") && !query.includes("\n")) {
396
398
  const spaceIdx = query.indexOf(" ");
397
399
  const name = spaceIdx === -1 ? query : query.slice(0, spaceIdx);
398
400
  const args = spaceIdx === -1 ? "" : query.slice(spaceIdx + 1).trim();
@@ -7,6 +7,7 @@
7
7
  * factory wires it to process.stdin/stdout for the CLI; headless hosts
8
8
  * (multi-session web hubs, tests) supply their own.
9
9
  */
10
+ import { StringDecoder } from "node:string_decoder";
10
11
  import type { RenderSurface } from "../utils/compositor.js";
11
12
 
12
13
  export interface Terminal {
@@ -23,40 +24,50 @@ export interface Terminal {
23
24
  suspendInput?(): { resume(): void };
24
25
  }
25
26
 
26
- /** Default Terminal: wraps process.stdin/stdout. */
27
- export function processTerminal(): Terminal {
27
+ /** Default Terminal: wraps process.stdin/stdout (injectable for tests). */
28
+ export function processTerminal(
29
+ stdin: NodeJS.ReadStream = process.stdin,
30
+ stdout: NodeJS.WriteStream = process.stdout,
31
+ ): Terminal {
28
32
  return {
29
33
  write(data) {
30
- if (process.stdout.writable) {
31
- try { process.stdout.write(data); } catch { /* ignore */ }
34
+ if (stdout.writable) {
35
+ try { stdout.write(data); } catch { /* ignore */ }
32
36
  }
33
37
  },
34
38
  onInput(cb) {
35
- const handler = (b: Buffer) => cb(b.toString("utf-8"));
36
- process.stdin.on("data", handler);
37
- return () => { process.stdin.off("data", handler); };
39
+ // Stateful decode: tty chunk boundaries can land mid-way through a
40
+ // multibyte UTF-8 sequence (large pastes), so per-chunk toString()
41
+ // would emit U+FFFD for the torn halves.
42
+ const decoder = new StringDecoder("utf-8");
43
+ const handler = (b: Buffer) => {
44
+ const text = decoder.write(b);
45
+ if (text) cb(text);
46
+ };
47
+ stdin.on("data", handler);
48
+ return () => { stdin.off("data", handler); };
38
49
  },
39
50
  onResize(cb) {
40
- const handler = () => cb(process.stdout.columns || 80, process.stdout.rows || 24);
41
- process.stdout.on("resize", handler);
42
- return () => { process.stdout.off("resize", handler); };
51
+ const handler = () => cb(stdout.columns || 80, stdout.rows || 24);
52
+ stdout.on("resize", handler);
53
+ return () => { stdout.off("resize", handler); };
43
54
  },
44
- cols() { return process.stdout.columns || 80; },
45
- rows() { return process.stdout.rows || 24; },
55
+ cols() { return stdout.columns || 80; },
56
+ rows() { return stdout.rows || 24; },
46
57
  suspendInput() {
47
- const wasRaw = process.stdin.isTTY && (process.stdin as { isRaw?: boolean }).isRaw;
48
- if (process.stdin.isTTY) {
58
+ const wasRaw = stdin.isTTY && (stdin as { isRaw?: boolean }).isRaw;
59
+ if (stdin.isTTY) {
49
60
  try {
50
- process.stdin.setRawMode(false);
51
- process.stdin.pause();
61
+ stdin.setRawMode(false);
62
+ stdin.pause();
52
63
  } catch { /* ignore */ }
53
64
  }
54
65
  return {
55
66
  resume() {
56
- if (process.stdin.isTTY) {
67
+ if (stdin.isTTY) {
57
68
  try {
58
- process.stdin.resume();
59
- if (wasRaw) process.stdin.setRawMode(true);
69
+ stdin.resume();
70
+ if (wasRaw) stdin.setRawMode(true);
60
71
  } catch { /* ignore */ }
61
72
  }
62
73
  },
@@ -939,7 +939,7 @@ export default function activate(ctx: ExtensionContext): void {
939
939
  ? getSettings().readOutputMaxLines
940
940
  : getSettings().maxCommandOutputLines;
941
941
  s.commandOutputBuffer += chunk;
942
- const lines = s.commandOutputBuffer.split("\n");
942
+ const lines = s.commandOutputBuffer.split(/\r?\n/);
943
943
  s.commandOutputBuffer = lines.pop()!;
944
944
  for (const line of lines) {
945
945
  if (s.commandOutputLineCount < maxLines) {
package/src/utils/ansi.ts CHANGED
@@ -138,3 +138,24 @@ export function padEndToWidth(str: string, targetWidth: number): string {
138
138
  export function stripAnsi(str: string): string {
139
139
  return stripAnsiPkg(str).replace(/\r/g, "");
140
140
  }
141
+
142
+ /**
143
+ * Sanitize text for painting at a fixed screen position: SGR (color/style)
144
+ * passes through; anything else that would move the cursor or mutate
145
+ * terminal state mid-row is dropped. Tabs become a single space so painted
146
+ * width matches `stripAnsi`-based measurement.
147
+ */
148
+ export function stripCursorControls(str: string): string {
149
+ // Park SGR behind NUL placeholders so the strips below can't eat their ESC bytes.
150
+ const sgr: string[] = [];
151
+ const cleaned = str
152
+ .replace(/\x00/g, "")
153
+ .replace(/\x1b\[[0-9;:]*m/g, (m) => { sgr.push(m); return "\x00"; })
154
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g, "")
155
+ .replace(/\x1b\[[0-9;:?]*[ -/]*[@-~]/g, "")
156
+ .replace(/\x1b./g, "")
157
+ .replace(/\t/g, " ")
158
+ .replace(/[\x01-\x08\x0a-\x1f\x7f]/g, "");
159
+ let i = 0;
160
+ return cleaned.replace(/\x00/g, () => sgr[i++] ?? "");
161
+ }
@@ -30,7 +30,7 @@
30
30
  * Usage from extensions:
31
31
  * import { FloatingPanel } from "agent-sh/utils/floating-panel.js";
32
32
  */
33
- import { stripAnsi } from "./ansi.js";
33
+ import { stripAnsi, stripCursorControls } from "./ansi.js";
34
34
  import { wrapLine } from "./markdown.js";
35
35
  import { LineEditor } from "./line-editor.js";
36
36
  import { TerminalBuffer } from "./terminal-buffer.js";
@@ -408,10 +408,11 @@ export class FloatingPanel {
408
408
 
409
409
  // Default row builder: truncate and pad
410
410
  this.handlers.define(`${p}:build-row`, (content: string, width: number): string => {
411
- const plain = stripAnsi(content);
411
+ const clean = stripCursorControls(content);
412
+ const plain = stripAnsi(clean);
412
413
  const display = plain.length > width
413
- ? content.slice(0, width - 1) + "\u2026"
414
- : content;
414
+ ? clean.slice(0, width - 1) + "\u2026"
415
+ : clean;
415
416
  const pad = Math.max(0, width - stripAnsi(display).length);
416
417
  return display + " ".repeat(pad);
417
418
  });
@@ -152,7 +152,7 @@ export class LineEditor {
152
152
  // paste, since typed input arrives one keystroke per chunk in raw mode.
153
153
  if (!this.inPaste && data.length > 1 && /[\r\n]/.test(data)
154
154
  && data.indexOf("\x1b[200~") === -1) {
155
- this.pasteAccum = data.replace(/\r\n?/g, "\n");
155
+ this.pasteAccum = data;
156
156
  actions.push(...this.commitPaste());
157
157
  return actions;
158
158
  }
@@ -263,7 +263,7 @@ export class LineEditor {
263
263
  private consumePasteChunk(data: string): number {
264
264
  const endIdx = data.indexOf(PASTE_END);
265
265
  if (endIdx !== -1) {
266
- this.pasteAccum += data.slice(0, endIdx).replace(/\r/g, "");
266
+ this.pasteAccum += data.slice(0, endIdx);
267
267
  return endIdx;
268
268
  }
269
269
  let suffixLen = 0;
@@ -271,14 +271,17 @@ export class LineEditor {
271
271
  if (data.endsWith(PASTE_END.slice(0, p))) { suffixLen = p; break; }
272
272
  }
273
273
  const safeEnd = data.length - suffixLen;
274
- this.pasteAccum += data.slice(0, safeEnd).replace(/\r/g, "");
274
+ this.pasteAccum += data.slice(0, safeEnd);
275
275
  if (suffixLen > 0) this.pendingSeq = data.slice(safeEnd);
276
276
  return -1;
277
277
  }
278
278
 
279
279
  private commitPaste(): LineEditAction[] {
280
280
  this.inPaste = false;
281
- const accum = this.pasteAccum;
281
+ // Pasted line separators arrive as \n, \r (xterm convention), or \r\n
282
+ // depending on terminal; normalize on the full accumulation so a \r\n
283
+ // pair split across chunks still collapses to one newline.
284
+ const accum = this.pasteAccum.replace(/\r\n?/g, "\n");
282
285
  this.pasteAccum = "";
283
286
  if (!accum) return [];
284
287
  if (accum.indexOf("\n") === -1) {