agent-sh 0.8.0 → 0.10.0

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.
Files changed (106) hide show
  1. package/README.md +27 -43
  2. package/dist/agent/agent-loop.d.ts +69 -6
  3. package/dist/agent/agent-loop.js +954 -153
  4. package/dist/agent/conversation-state.d.ts +74 -21
  5. package/dist/agent/conversation-state.js +361 -150
  6. package/dist/agent/history-file.d.ts +13 -4
  7. package/dist/agent/history-file.js +110 -36
  8. package/dist/agent/nuclear-form.d.ts +28 -3
  9. package/dist/agent/nuclear-form.js +88 -6
  10. package/dist/agent/skills.d.ts +2 -4
  11. package/dist/agent/skills.js +10 -4
  12. package/dist/agent/subagent.d.ts +23 -0
  13. package/dist/agent/subagent.js +53 -11
  14. package/dist/agent/system-prompt.d.ts +37 -5
  15. package/dist/agent/system-prompt.js +100 -67
  16. package/dist/{token-budget.d.ts → agent/token-budget.d.ts} +5 -4
  17. package/dist/{token-budget.js → agent/token-budget.js} +15 -20
  18. package/dist/agent/tool-protocol.d.ts +105 -0
  19. package/dist/agent/tool-protocol.js +551 -0
  20. package/dist/agent/tools/bash.js +3 -3
  21. package/dist/agent/tools/edit-file.js +9 -6
  22. package/dist/agent/tools/glob.js +4 -2
  23. package/dist/agent/tools/grep.js +27 -3
  24. package/dist/agent/tools/ls.js +5 -6
  25. package/dist/agent/types.d.ts +22 -2
  26. package/dist/context-manager.d.ts +17 -0
  27. package/dist/context-manager.js +37 -4
  28. package/dist/core.d.ts +7 -7
  29. package/dist/core.js +99 -196
  30. package/dist/event-bus.d.ts +85 -2
  31. package/dist/event-bus.js +20 -1
  32. package/dist/executor.d.ts +4 -3
  33. package/dist/executor.js +18 -15
  34. package/dist/extension-loader.d.ts +5 -0
  35. package/dist/extension-loader.js +143 -19
  36. package/dist/extensions/agent-backend.d.ts +14 -0
  37. package/dist/extensions/agent-backend.js +188 -0
  38. package/dist/extensions/command-suggest.d.ts +3 -3
  39. package/dist/extensions/command-suggest.js +4 -3
  40. package/dist/extensions/index.d.ts +19 -0
  41. package/dist/extensions/index.js +24 -0
  42. package/dist/extensions/slash-commands.d.ts +1 -1
  43. package/dist/extensions/slash-commands.js +30 -10
  44. package/dist/extensions/tui-renderer.js +117 -113
  45. package/dist/index.js +39 -26
  46. package/dist/settings.d.ts +40 -3
  47. package/dist/settings.js +57 -10
  48. package/dist/{input-handler.d.ts → shell/input-handler.d.ts} +3 -2
  49. package/dist/{input-handler.js → shell/input-handler.js} +111 -85
  50. package/dist/{output-parser.d.ts → shell/output-parser.d.ts} +1 -1
  51. package/dist/{output-parser.js → shell/output-parser.js} +1 -1
  52. package/dist/{shell.d.ts → shell/shell.d.ts} +8 -2
  53. package/dist/{shell.js → shell/shell.js} +39 -8
  54. package/dist/types.d.ts +61 -10
  55. package/dist/utils/ansi.d.ts +5 -0
  56. package/dist/utils/ansi.js +1 -1
  57. package/dist/utils/compositor.d.ts +67 -0
  58. package/dist/utils/compositor.js +116 -0
  59. package/dist/utils/diff-renderer.d.ts +9 -0
  60. package/dist/utils/diff-renderer.js +312 -146
  61. package/dist/utils/diff.d.ts +21 -2
  62. package/dist/utils/diff.js +165 -89
  63. package/dist/utils/floating-panel.d.ts +2 -0
  64. package/dist/utils/floating-panel.js +30 -14
  65. package/dist/utils/handler-registry.d.ts +31 -10
  66. package/dist/utils/handler-registry.js +58 -16
  67. package/dist/utils/line-editor.d.ts +33 -3
  68. package/dist/utils/line-editor.js +221 -44
  69. package/dist/utils/markdown.d.ts +1 -0
  70. package/dist/utils/markdown.js +1 -1
  71. package/dist/utils/message-utils.d.ts +35 -0
  72. package/dist/utils/message-utils.js +75 -0
  73. package/dist/utils/terminal-buffer.d.ts +5 -1
  74. package/dist/utils/terminal-buffer.js +18 -2
  75. package/dist/utils/tool-display.d.ts +1 -1
  76. package/dist/utils/tool-display.js +4 -4
  77. package/dist/utils/tool-interactive.d.ts +12 -0
  78. package/dist/utils/tool-interactive.js +53 -0
  79. package/examples/extensions/ash-acp-bridge/README.md +39 -0
  80. package/examples/extensions/ash-acp-bridge/package.json +23 -0
  81. package/examples/extensions/ash-acp-bridge/src/index.ts +574 -0
  82. package/examples/extensions/ash-acp-bridge/tsconfig.json +14 -0
  83. package/examples/extensions/ash-mcp-bridge/README.md +72 -0
  84. package/examples/extensions/ash-mcp-bridge/index.ts +164 -0
  85. package/examples/extensions/ash-mcp-bridge/package.json +9 -0
  86. package/examples/extensions/claude-code-bridge/index.ts +198 -51
  87. package/examples/extensions/claude-code-bridge/package.json +1 -0
  88. package/examples/extensions/interactive-prompts.ts +98 -112
  89. package/examples/extensions/overlay-agent.ts +84 -38
  90. package/examples/extensions/peer-mesh.ts +565 -0
  91. package/examples/extensions/pi-bridge/index.ts +2 -2
  92. package/examples/extensions/questionnaire.ts +260 -0
  93. package/examples/extensions/subagents.ts +19 -4
  94. package/examples/extensions/terminal-buffer.ts +32 -53
  95. package/examples/extensions/tmux-pane.ts +307 -0
  96. package/examples/extensions/user-shell.ts +136 -0
  97. package/examples/extensions/web-access.ts +335 -0
  98. package/package.json +44 -2
  99. package/dist/agent/tools/display.d.ts +0 -13
  100. package/dist/agent/tools/display.js +0 -70
  101. package/dist/agent/tools/user-shell.d.ts +0 -13
  102. package/dist/agent/tools/user-shell.js +0 -87
  103. package/dist/extensions/overlay-agent.d.ts +0 -14
  104. package/dist/extensions/overlay-agent.js +0 -147
  105. package/dist/extensions/terminal-buffer.d.ts +0 -14
  106. package/dist/extensions/terminal-buffer.js +0 -125
@@ -2,22 +2,110 @@
2
2
  * Minimal line editor with readline-style keybindings.
3
3
  *
4
4
  * Pure logic — no I/O, no rendering, no event bus. Consumers feed raw
5
- * terminal input bytes and receive high-level actions back. Buffer and
6
- * cursor state are public for rendering.
5
+ * terminal input bytes and receive high-level actions back.
6
+ *
7
+ * The internal buffer may contain PUA placeholder characters for pasted
8
+ * multi-line content. Consumers should use the typed accessors:
9
+ * - `text` — resolved content (pastes expanded), for submit/history/logic
10
+ * - `displayText` — display content (pastes collapsed to labels), for rendering
11
+ * - `displayCursor` — cursor column in display coordinates
12
+ * - `setText()` — replace buffer content (clears paste attachments)
7
13
  */
14
+ import { charWidth } from "./ansi.js";
8
15
  // ── Kitty protocol keycode → readable name ──────────────────────
9
16
  const KITTY_KEY_NAMES = {
10
17
  9: "tab", 13: "enter", 27: "escape", 127: "backspace",
11
18
  };
19
+ // ── Paste placeholder ───────────────────────────────────────────
20
+ /** First Unicode Private Use Area codepoint, used as paste placeholder. */
21
+ const PUA_BASE = 0xE000;
22
+ function isPUA(ch) {
23
+ const code = ch.charCodeAt(0);
24
+ return code >= PUA_BASE && code <= 0xF8FF;
25
+ }
12
26
  // ── Line editor ─────────────────────────────────────────────────
13
27
  export class LineEditor {
14
- buffer = "";
28
+ _buf = "";
15
29
  cursor = 0;
16
30
  pendingSeq = ""; // buffered incomplete escape sequence
31
+ // ── Bracket paste state ─────────────────────────────────────
32
+ inPaste = false;
33
+ pasteAccum = ""; // accumulates during bracket paste
34
+ pastes = new Map(); // id → pasted content
35
+ pasteCounter = 0;
17
36
  // ── History ──────────────────────────────────────────────────
18
37
  history = [];
19
38
  historyIndex = -1; // -1 = current input, 0..N = history entries (newest first)
20
39
  savedBuffer = ""; // saves current input when browsing history
40
+ // ── Public accessors ────────────────────────────────────────
41
+ /** Resolved text — paste placeholders expanded. For submit, history, logic. */
42
+ get text() {
43
+ let result = "";
44
+ for (const ch of this._buf) {
45
+ const paste = this.pastes.get(ch.charCodeAt(0) - PUA_BASE);
46
+ result += paste ?? ch;
47
+ }
48
+ return result;
49
+ }
50
+ /** Display text — paste placeholders replaced with labels. For rendering. */
51
+ get displayText() {
52
+ let result = "";
53
+ for (const ch of this._buf) {
54
+ const paste = this.pastes.get(ch.charCodeAt(0) - PUA_BASE);
55
+ if (paste) {
56
+ const n = paste.split("\n").length;
57
+ result += `[paste +${n} lines]`;
58
+ }
59
+ else {
60
+ result += ch;
61
+ }
62
+ }
63
+ return result;
64
+ }
65
+ /** Cursor position mapped to display-text character offset. */
66
+ get displayCursor() {
67
+ let pos = 0;
68
+ for (let i = 0; i < this._buf.length && i < this.cursor; i++) {
69
+ const ch = this._buf[i];
70
+ const paste = this.pastes.get(ch.charCodeAt(0) - PUA_BASE);
71
+ if (paste) {
72
+ const n = paste.split("\n").length;
73
+ pos += `[paste +${n} lines]`.length;
74
+ }
75
+ else {
76
+ pos++;
77
+ }
78
+ }
79
+ return pos;
80
+ }
81
+ /** Cursor position as visible terminal-column width (accounts for CJK etc.). */
82
+ get displayCursorWidth() {
83
+ let width = 0;
84
+ for (let i = 0; i < this._buf.length && i < this.cursor; i++) {
85
+ const ch = this._buf[i];
86
+ const paste = this.pastes.get(ch.charCodeAt(0) - PUA_BASE);
87
+ if (paste) {
88
+ const n = paste.split("\n").length;
89
+ width += `[paste +${n} lines]`.length; // ASCII-only, 1 col each
90
+ }
91
+ else {
92
+ width += charWidth(ch.codePointAt(0) ?? 0);
93
+ }
94
+ }
95
+ return width;
96
+ }
97
+ /** Number of logical positions in the buffer. */
98
+ get length() {
99
+ return this._buf.length;
100
+ }
101
+ /** Replace buffer content. Clears paste attachments. */
102
+ setText(value) {
103
+ this._buf = value;
104
+ this.pastes.clear();
105
+ this.pasteCounter = 0;
106
+ this.cursor = value.length;
107
+ }
108
+ // ── Input processing ────────────────────────────────────────
21
109
  /** Process raw terminal input, return actions for the consumer. */
22
110
  feed(data) {
23
111
  // If we had a pending incomplete escape sequence, prepend it
@@ -68,7 +156,7 @@ export class LineEditor {
68
156
  actions.push({ action: "arrow-down" });
69
157
  break;
70
158
  case "C":
71
- if (this.cursor < this.buffer.length) {
159
+ if (this.cursor < this._buf.length) {
72
160
  this.cursor++;
73
161
  actions.push({ action: "changed" });
74
162
  }
@@ -86,8 +174,8 @@ export class LineEditor {
86
174
  }
87
175
  break;
88
176
  case "F": // End
89
- if (this.cursor < this.buffer.length) {
90
- this.cursor = this.buffer.length;
177
+ if (this.cursor < this._buf.length) {
178
+ this.cursor = this._buf.length;
91
179
  actions.push({ action: "changed" });
92
180
  }
93
181
  break;
@@ -119,6 +207,16 @@ export class LineEditor {
119
207
  // Other Alt+key — ignore
120
208
  continue;
121
209
  }
210
+ // ── Bracket paste: accumulate into side buffer ─────
211
+ if (this.inPaste) {
212
+ if (ch === "\r") {
213
+ i++;
214
+ continue;
215
+ } // skip CR (CR+LF → just LF)
216
+ this.pasteAccum += ch;
217
+ i++;
218
+ continue;
219
+ }
122
220
  // ── Control characters ──────────────────────────────
123
221
  if (ch.charCodeAt(0) < 0x20 || ch === "\x7f") {
124
222
  const action = this.handleControl(ch);
@@ -128,7 +226,7 @@ export class LineEditor {
128
226
  continue;
129
227
  }
130
228
  // ── Printable character ─────────────────────────────
131
- this.buffer = this.buffer.slice(0, this.cursor) + ch + this.buffer.slice(this.cursor);
229
+ this._buf = this._buf.slice(0, this.cursor) + ch + this._buf.slice(this.cursor);
132
230
  this.cursor++;
133
231
  actions.push({ action: "changed" });
134
232
  i++;
@@ -148,9 +246,13 @@ export class LineEditor {
148
246
  return wasBarEscape ? [{ action: "cancel" }] : [];
149
247
  }
150
248
  clear() {
151
- this.buffer = "";
249
+ this._buf = "";
152
250
  this.cursor = 0;
153
251
  this.pendingSeq = "";
252
+ this.inPaste = false;
253
+ this.pasteAccum = "";
254
+ this.pastes.clear();
255
+ this.pasteCounter = 0;
154
256
  this.historyIndex = -1;
155
257
  this.savedBuffer = "";
156
258
  }
@@ -171,11 +273,10 @@ export class LineEditor {
171
273
  if (this.historyIndex + 1 >= this.history.length)
172
274
  return null;
173
275
  if (this.historyIndex === -1) {
174
- this.savedBuffer = this.buffer; // save current input
276
+ this.savedBuffer = this.text; // save resolved current input
175
277
  }
176
278
  this.historyIndex++;
177
- this.buffer = this.history[this.historyIndex];
178
- this.cursor = this.buffer.length;
279
+ this.setText(this.history[this.historyIndex]);
179
280
  return { action: "changed" };
180
281
  }
181
282
  /** Navigate to a more recent history entry. Returns changed action or null. */
@@ -184,12 +285,11 @@ export class LineEditor {
184
285
  return null;
185
286
  this.historyIndex--;
186
287
  if (this.historyIndex === -1) {
187
- this.buffer = this.savedBuffer;
288
+ this.setText(this.savedBuffer);
188
289
  }
189
290
  else {
190
- this.buffer = this.history[this.historyIndex];
291
+ this.setText(this.history[this.historyIndex]);
191
292
  }
192
- this.cursor = this.buffer.length;
193
293
  return { action: "changed" };
194
294
  }
195
295
  // ── Key bindings ────────────────────────────────────────────
@@ -198,17 +298,17 @@ export class LineEditor {
198
298
  // characters and kitty protocol sequences resolve to a key name
199
299
  // and look it up here. To add a binding, add one entry.
200
300
  bindings = {
201
- "enter": () => ({ action: "submit", buffer: this.buffer }),
301
+ "enter": () => ({ action: "submit", buffer: this.text }),
202
302
  "ctrl+c": () => ({ action: "cancel" }),
203
303
  "tab": () => ({ action: "tab" }),
204
304
  "backspace": () => this.deleteBackward(),
205
- "ctrl+d": () => this.buffer.length === 0 ? { action: "delete-empty" } : this.deleteForward(),
206
- "ctrl+a": () => this.moveTo(0),
207
- "ctrl+e": () => this.moveTo(this.buffer.length),
305
+ "ctrl+d": () => this._buf.length === 0 ? { action: "delete-empty" } : this.deleteForward(),
306
+ "ctrl+a": () => this.moveToLineStart(),
307
+ "ctrl+e": () => this.moveToLineEnd(),
208
308
  "ctrl+b": () => this.moveTo(this.cursor - 1),
209
309
  "ctrl+f": () => this.moveTo(this.cursor + 1),
210
- "ctrl+u": () => this.deleteRange(0, this.cursor),
211
- "ctrl+k": () => this.deleteRange(this.cursor, this.buffer.length),
310
+ "ctrl+u": () => this.deleteLineStart(),
311
+ "ctrl+k": () => this.deleteLineEnd(),
212
312
  "ctrl+w": () => this.deleteWordBackward() ? { action: "changed" } : null,
213
313
  "alt+f": () => this.wordForward() ? { action: "changed" } : null,
214
314
  "alt+b": () => this.wordBackward() ? { action: "changed" } : null,
@@ -259,36 +359,73 @@ export class LineEditor {
259
359
  }
260
360
  // ── Editing primitives ─────────────────────────────────────
261
361
  insertAt(ch) {
262
- this.buffer = this.buffer.slice(0, this.cursor) + ch + this.buffer.slice(this.cursor);
362
+ this._buf = this._buf.slice(0, this.cursor) + ch + this._buf.slice(this.cursor);
263
363
  this.cursor++;
264
364
  return { action: "changed" };
265
365
  }
266
366
  moveTo(pos) {
267
- const clamped = Math.max(0, Math.min(pos, this.buffer.length));
367
+ const clamped = Math.max(0, Math.min(pos, this._buf.length));
268
368
  if (clamped === this.cursor)
269
369
  return null;
270
370
  this.cursor = clamped;
271
371
  return { action: "changed" };
272
372
  }
373
+ /** Move cursor to start of the current logical line. */
374
+ moveToLineStart() {
375
+ const lineStart = this._buf.lastIndexOf("\n", this.cursor - 1) + 1;
376
+ return this.moveTo(lineStart);
377
+ }
378
+ /** Move cursor to end of the current logical line. */
379
+ moveToLineEnd() {
380
+ const nextNewline = this._buf.indexOf("\n", this.cursor);
381
+ const lineEnd = nextNewline === -1 ? this._buf.length : nextNewline;
382
+ return this.moveTo(lineEnd);
383
+ }
384
+ /** Delete from start of current logical line to cursor (Ctrl+U). */
385
+ deleteLineStart() {
386
+ const lineStart = this._buf.lastIndexOf("\n", this.cursor - 1) + 1;
387
+ return this.deleteRange(lineStart, this.cursor);
388
+ }
389
+ /** Delete from cursor to end of current logical line (Ctrl+K). */
390
+ deleteLineEnd() {
391
+ const nextNewline = this._buf.indexOf("\n", this.cursor);
392
+ const lineEnd = nextNewline === -1 ? this._buf.length : nextNewline;
393
+ return this.deleteRange(this.cursor, lineEnd);
394
+ }
273
395
  deleteBackward() {
274
- if (this.buffer.length === 0)
396
+ if (this._buf.length === 0)
275
397
  return { action: "delete-empty" };
276
398
  if (this.cursor <= 0)
277
399
  return null;
278
- this.buffer = this.buffer.slice(0, this.cursor - 1) + this.buffer.slice(this.cursor);
400
+ // If deleting a paste placeholder, also remove the paste entry
401
+ const deleted = this._buf[this.cursor - 1];
402
+ if (isPUA(deleted)) {
403
+ this.pastes.delete(deleted.charCodeAt(0) - PUA_BASE);
404
+ }
405
+ this._buf = this._buf.slice(0, this.cursor - 1) + this._buf.slice(this.cursor);
279
406
  this.cursor--;
280
407
  return { action: "changed" };
281
408
  }
282
409
  deleteForward() {
283
- if (this.cursor >= this.buffer.length)
410
+ if (this.cursor >= this._buf.length)
284
411
  return null;
285
- this.buffer = this.buffer.slice(0, this.cursor) + this.buffer.slice(this.cursor + 1);
412
+ const deleted = this._buf[this.cursor];
413
+ if (isPUA(deleted)) {
414
+ this.pastes.delete(deleted.charCodeAt(0) - PUA_BASE);
415
+ }
416
+ this._buf = this._buf.slice(0, this.cursor) + this._buf.slice(this.cursor + 1);
286
417
  return { action: "changed" };
287
418
  }
288
419
  deleteRange(start, end) {
289
420
  if (start >= end)
290
421
  return null;
291
- this.buffer = this.buffer.slice(0, start) + this.buffer.slice(end);
422
+ // Clean up any paste entries in the deleted range
423
+ for (let k = start; k < end; k++) {
424
+ const ch = this._buf[k];
425
+ if (isPUA(ch))
426
+ this.pastes.delete(ch.charCodeAt(0) - PUA_BASE);
427
+ }
428
+ this._buf = this._buf.slice(0, start) + this._buf.slice(end);
292
429
  this.cursor = start;
293
430
  return { action: "changed" };
294
431
  }
@@ -326,7 +463,7 @@ export class LineEditor {
326
463
  actions.push({ action: "changed" });
327
464
  }
328
465
  else {
329
- if (this.cursor < this.buffer.length) {
466
+ if (this.cursor < this._buf.length) {
330
467
  this.cursor++;
331
468
  actions.push({ action: "changed" });
332
469
  }
@@ -351,8 +488,8 @@ export class LineEditor {
351
488
  }
352
489
  break;
353
490
  case "F": // End
354
- if (this.cursor < this.buffer.length) {
355
- this.cursor = this.buffer.length;
491
+ if (this.cursor < this._buf.length) {
492
+ this.cursor = this._buf.length;
356
493
  actions.push({ action: "changed" });
357
494
  }
358
495
  break;
@@ -365,11 +502,39 @@ export class LineEditor {
365
502
  actions.push(action);
366
503
  break;
367
504
  }
368
- case "~": // Extended keys: Delete (3~), etc.
505
+ case "~": // Extended keys: Delete (3~), bracket paste (200~/201~), etc.
369
506
  if (params === "3") {
370
507
  // Delete key: delete char under cursor
371
- if (this.cursor < this.buffer.length) {
372
- this.buffer = this.buffer.slice(0, this.cursor) + this.buffer.slice(this.cursor + 1);
508
+ if (this.cursor < this._buf.length) {
509
+ const deleted = this._buf[this.cursor];
510
+ if (isPUA(deleted))
511
+ this.pastes.delete(deleted.charCodeAt(0) - PUA_BASE);
512
+ this._buf = this._buf.slice(0, this.cursor) + this._buf.slice(this.cursor + 1);
513
+ actions.push({ action: "changed" });
514
+ }
515
+ }
516
+ else if (params === "200") {
517
+ this.inPaste = true;
518
+ this.pasteAccum = "";
519
+ }
520
+ else if (params === "201") {
521
+ this.inPaste = false;
522
+ if (this.pasteAccum) {
523
+ const lines = this.pasteAccum.split("\n");
524
+ if (lines.length <= 1) {
525
+ // Single-line paste — inline directly
526
+ this._buf = this._buf.slice(0, this.cursor) + this.pasteAccum + this._buf.slice(this.cursor);
527
+ this.cursor += this.pasteAccum.length;
528
+ }
529
+ else {
530
+ // Multi-line paste — store and insert placeholder
531
+ const id = this.pasteCounter++;
532
+ this.pastes.set(id, this.pasteAccum);
533
+ const placeholder = String.fromCharCode(PUA_BASE + id);
534
+ this._buf = this._buf.slice(0, this.cursor) + placeholder + this._buf.slice(this.cursor);
535
+ this.cursor++;
536
+ }
537
+ this.pasteAccum = "";
373
538
  actions.push({ action: "changed" });
374
539
  }
375
540
  }
@@ -383,11 +548,11 @@ export class LineEditor {
383
548
  if (this.cursor === 0)
384
549
  return false;
385
550
  let pos = this.cursor;
386
- // Skip spaces
387
- while (pos > 0 && this.buffer[pos - 1] === " ")
551
+ // Skip PUA placeholders and spaces
552
+ while (pos > 0 && (this._buf[pos - 1] === " " || isPUA(this._buf[pos - 1])))
388
553
  pos--;
389
554
  // Skip word chars
390
- while (pos > 0 && this.buffer[pos - 1] !== " ")
555
+ while (pos > 0 && this._buf[pos - 1] !== " " && !isPUA(this._buf[pos - 1]))
391
556
  pos--;
392
557
  if (pos === this.cursor)
393
558
  return false;
@@ -395,14 +560,14 @@ export class LineEditor {
395
560
  return true;
396
561
  }
397
562
  wordForward() {
398
- if (this.cursor >= this.buffer.length)
563
+ if (this.cursor >= this._buf.length)
399
564
  return false;
400
565
  let pos = this.cursor;
401
- // Skip word chars
402
- while (pos < this.buffer.length && this.buffer[pos] !== " ")
566
+ // Skip word chars and PUA placeholders
567
+ while (pos < this._buf.length && this._buf[pos] !== " " && !isPUA(this._buf[pos]))
403
568
  pos++;
404
- // Skip spaces
405
- while (pos < this.buffer.length && this.buffer[pos] === " ")
569
+ // Skip spaces and PUA
570
+ while (pos < this._buf.length && (this._buf[pos] === " " || isPUA(this._buf[pos])))
406
571
  pos++;
407
572
  if (pos === this.cursor)
408
573
  return false;
@@ -414,15 +579,27 @@ export class LineEditor {
414
579
  return false;
415
580
  const start = this.cursor;
416
581
  this.wordBackward();
417
- this.buffer = this.buffer.slice(0, this.cursor) + this.buffer.slice(start);
582
+ // Clean up paste entries
583
+ for (let k = this.cursor; k < start; k++) {
584
+ const ch = this._buf[k];
585
+ if (isPUA(ch))
586
+ this.pastes.delete(ch.charCodeAt(0) - PUA_BASE);
587
+ }
588
+ this._buf = this._buf.slice(0, this.cursor) + this._buf.slice(start);
418
589
  return true;
419
590
  }
420
591
  deleteWordForward() {
421
- if (this.cursor >= this.buffer.length)
592
+ if (this.cursor >= this._buf.length)
422
593
  return false;
423
594
  const start = this.cursor;
424
595
  this.wordForward();
425
- this.buffer = this.buffer.slice(0, start) + this.buffer.slice(this.cursor);
596
+ // Clean up paste entries
597
+ for (let k = start; k < this.cursor; k++) {
598
+ const ch = this._buf[k];
599
+ if (isPUA(ch))
600
+ this.pastes.delete(ch.charCodeAt(0) - PUA_BASE);
601
+ }
602
+ this._buf = this._buf.slice(0, start) + this._buf.slice(this.cursor);
426
603
  this.cursor = start;
427
604
  return true;
428
605
  }
@@ -1,3 +1,4 @@
1
+ export declare const MAX_CONTENT_WIDTH = 90;
1
2
  /**
2
3
  * Word-wrap a string (which may contain ANSI codes) to a maximum visible width.
3
4
  * Returns an array of lines, each fitting within `maxWidth` visible characters.
@@ -1,6 +1,6 @@
1
1
  import { visibleLen, truncateToWidth, padEndToWidth } from "./ansi.js";
2
2
  import { palette as p } from "./palette.js";
3
- const MAX_CONTENT_WIDTH = 90;
3
+ export const MAX_CONTENT_WIDTH = 90;
4
4
  /**
5
5
  * Word-wrap a string (which may contain ANSI codes) to a maximum visible width.
6
6
  * Returns an array of lines, each fitting within `maxWidth` visible characters.
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Utilities for manipulating OpenAI-format message arrays.
3
+ *
4
+ * Used by extensions advising `conversation:prepare` to transform
5
+ * the message array before it's sent to the LLM.
6
+ */
7
+ /**
8
+ * Find tool call IDs matching a tool name and optional argument filter.
9
+ *
10
+ * Scans assistant messages for tool_calls where `function.name` matches
11
+ * and parsed arguments satisfy the filter (shallow key/value match).
12
+ *
13
+ * Returns call IDs in message order (earliest first).
14
+ */
15
+ export declare function findToolCallIds(messages: any[], toolName: string, argFilter?: Record<string, unknown>): string[];
16
+ /**
17
+ * Replace tool result content for specific call IDs.
18
+ *
19
+ * Returns a new array (shallow copy) with matching tool messages
20
+ * replaced. Non-matching messages are passed through by reference.
21
+ */
22
+ export declare function stubToolResults(messages: any[], callIds: Set<string>, stub: string): any[];
23
+ /**
24
+ * Deduplicate tool results: keep only the latest result for a given
25
+ * tool name + argument filter, replace all older results with a stub.
26
+ *
27
+ * Common use case: a file that's read repeatedly (e.g. a live transcript)
28
+ * — only the most recent read matters.
29
+ *
30
+ * Example:
31
+ * dedupeToolResults(messages, "read_file",
32
+ * { path: "/path/to/transcript.txt" },
33
+ * "[stale — superseded by later read]")
34
+ */
35
+ export declare function dedupeToolResults(messages: any[], toolName: string, argFilter?: Record<string, unknown>, stub?: string): any[];
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Utilities for manipulating OpenAI-format message arrays.
3
+ *
4
+ * Used by extensions advising `conversation:prepare` to transform
5
+ * the message array before it's sent to the LLM.
6
+ */
7
+ /* eslint-disable @typescript-eslint/no-explicit-any */
8
+ /**
9
+ * Find tool call IDs matching a tool name and optional argument filter.
10
+ *
11
+ * Scans assistant messages for tool_calls where `function.name` matches
12
+ * and parsed arguments satisfy the filter (shallow key/value match).
13
+ *
14
+ * Returns call IDs in message order (earliest first).
15
+ */
16
+ export function findToolCallIds(messages, toolName, argFilter) {
17
+ const ids = [];
18
+ for (const msg of messages) {
19
+ if (msg.role !== "assistant" || !msg.tool_calls)
20
+ continue;
21
+ for (const tc of msg.tool_calls) {
22
+ const fn = tc.function ?? tc.fn;
23
+ if (!fn || fn.name !== toolName)
24
+ continue;
25
+ if (argFilter) {
26
+ let args;
27
+ try {
28
+ args = JSON.parse(fn.arguments);
29
+ }
30
+ catch {
31
+ continue;
32
+ }
33
+ const match = Object.entries(argFilter).every(([k, v]) => args[k] === v);
34
+ if (!match)
35
+ continue;
36
+ }
37
+ ids.push(tc.id);
38
+ }
39
+ }
40
+ return ids;
41
+ }
42
+ /**
43
+ * Replace tool result content for specific call IDs.
44
+ *
45
+ * Returns a new array (shallow copy) with matching tool messages
46
+ * replaced. Non-matching messages are passed through by reference.
47
+ */
48
+ export function stubToolResults(messages, callIds, stub) {
49
+ return messages.map((msg) => {
50
+ if (msg.role === "tool" && callIds.has(msg.tool_call_id)) {
51
+ return { ...msg, content: stub };
52
+ }
53
+ return msg;
54
+ });
55
+ }
56
+ /**
57
+ * Deduplicate tool results: keep only the latest result for a given
58
+ * tool name + argument filter, replace all older results with a stub.
59
+ *
60
+ * Common use case: a file that's read repeatedly (e.g. a live transcript)
61
+ * — only the most recent read matters.
62
+ *
63
+ * Example:
64
+ * dedupeToolResults(messages, "read_file",
65
+ * { path: "/path/to/transcript.txt" },
66
+ * "[stale — superseded by later read]")
67
+ */
68
+ export function dedupeToolResults(messages, toolName, argFilter, stub = "[superseded by later call]") {
69
+ const callIds = findToolCallIds(messages, toolName, argFilter);
70
+ if (callIds.length <= 1)
71
+ return messages;
72
+ // Keep the last one, stub the rest
73
+ const staleIds = new Set(callIds.slice(0, -1));
74
+ return stubToolResults(messages, staleIds, stub);
75
+ }
@@ -47,7 +47,9 @@ export declare class TerminalBuffer {
47
47
  /** Get the raw serialized terminal output (includes ANSI sequences). */
48
48
  serialize(): string;
49
49
  /** Read clean screen text with metadata. */
50
- readScreen(): ScreenSnapshot;
50
+ readScreen(opts?: {
51
+ includeScrollback?: boolean;
52
+ }): ScreenSnapshot;
51
53
  /**
52
54
  * Get terminal screen as lines, padded/trimmed to exactly `rows` lines.
53
55
  * Clean text only (ANSI stripped). Reads from the active buffer's
@@ -57,6 +59,8 @@ export declare class TerminalBuffer {
57
59
  getScreenLines(rows?: number): string[];
58
60
  /** Read visible viewport lines from a buffer. */
59
61
  private readViewportLines;
62
+ /** Read all lines including scrollback from a buffer. */
63
+ private readAllLines;
60
64
  /** Get cursor position. */
61
65
  getCursor(): {
62
66
  x: number;
@@ -130,9 +130,11 @@ export class TerminalBuffer {
130
130
  return this.serializeAddon.serialize();
131
131
  }
132
132
  /** Read clean screen text with metadata. */
133
- readScreen() {
133
+ readScreen(opts) {
134
134
  const buf = this.term.buffer.active;
135
- const lines = this.readViewportLines(buf);
135
+ const lines = opts?.includeScrollback
136
+ ? this.readAllLines(buf)
137
+ : this.readViewportLines(buf);
136
138
  return {
137
139
  text: lines.join("\n"),
138
140
  altScreen: buf.type === "alternate",
@@ -161,6 +163,20 @@ export class TerminalBuffer {
161
163
  }
162
164
  return lines;
163
165
  }
166
+ /** Read all lines including scrollback from a buffer. */
167
+ readAllLines(buf) {
168
+ const total = (buf.baseY ?? 0) + buf.length;
169
+ const lines = [];
170
+ for (let y = 0; y < total; y++) {
171
+ const line = buf.getLine(y);
172
+ lines.push(line ? line.translateToString(true) : "");
173
+ }
174
+ // Trim trailing empty lines
175
+ while (lines.length > 0 && lines[lines.length - 1] === "") {
176
+ lines.pop();
177
+ }
178
+ return lines;
179
+ }
164
180
  /** Get cursor position. */
165
181
  getCursor() {
166
182
  return {
@@ -27,7 +27,7 @@ export interface ToolResultRender {
27
27
  }
28
28
  export declare function isQuietCommand(command: string): boolean;
29
29
  export declare function selectToolDisplayMode(width: number): ToolDisplayMode;
30
- export declare function renderToolCall(tool: ToolCallRender, width: number): string[];
30
+ export declare function renderToolCall(tool: ToolCallRender, width: number, cwd?: string): string[];
31
31
  export declare function renderToolResult(result: ToolResultRender, width: number): string[];
32
32
  export declare function formatElapsed(ms: number): string;
33
33
  export declare const SPINNER_FRAMES: string[];
@@ -39,7 +39,6 @@ const KIND_ICONS = {
39
39
  move: "↗",
40
40
  search: "⌕",
41
41
  execute: "▶",
42
- display: "◇",
43
42
  think: "◇",
44
43
  fetch: "↓",
45
44
  switch_mode: "⇄",
@@ -48,7 +47,7 @@ function kindIcon(kind) {
48
47
  return kind ? (KIND_ICONS[kind] ?? "▶") : "▶";
49
48
  }
50
49
  // ── Tool call rendering ──────────────────────────────────────────
51
- export function renderToolCall(tool, width) {
50
+ export function renderToolCall(tool, width, cwd = process.cwd()) {
52
51
  const mode = selectToolDisplayMode(width);
53
52
  const icon = tool.icon ?? kindIcon(tool.kind);
54
53
  // If the tool registered a custom icon, it's self-describing — omit the name.
@@ -61,7 +60,6 @@ export function renderToolCall(tool, width) {
61
60
  const lines = [];
62
61
  // Build a compact detail string to append after the title
63
62
  let detail = "";
64
- const cwd = process.cwd();
65
63
  if (mode === "full" && tool.displayDetail) {
66
64
  detail = tool.displayDetail;
67
65
  }
@@ -70,7 +68,7 @@ export function renderToolCall(tool, width) {
70
68
  detail = `$ ${tool.command}`;
71
69
  }
72
70
  else if (tool.locations && tool.locations.length > 0) {
73
- const loc = tool.locations[0];
71
+ const loc = tool.locations.find((l) => l?.path) ?? tool.locations[0];
74
72
  const lineInfo = loc.line ? `:${loc.line}` : "";
75
73
  detail = `${shortenPath(loc.path, cwd)}${lineInfo}`;
76
74
  }
@@ -227,6 +225,8 @@ export function renderSpinnerLine(state, label, opts) {
227
225
  * Shorten an absolute path to a relative or tilde-prefixed form.
228
226
  */
229
227
  function shortenPath(p, cwd) {
228
+ if (!p || typeof p !== "string")
229
+ return "";
230
230
  if (p.startsWith(cwd + "/"))
231
231
  return p.slice(cwd.length + 1);
232
232
  if (p.startsWith(cwd))