agent-sh 0.8.0 → 0.9.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 (74) hide show
  1. package/README.md +25 -34
  2. package/dist/agent/agent-loop.d.ts +29 -6
  3. package/dist/agent/agent-loop.js +177 -59
  4. package/dist/agent/conversation-state.d.ts +3 -1
  5. package/dist/agent/conversation-state.js +6 -2
  6. package/dist/agent/nuclear-form.js +5 -4
  7. package/dist/agent/system-prompt.d.ts +4 -5
  8. package/dist/agent/system-prompt.js +12 -28
  9. package/dist/{token-budget.js → agent/token-budget.js} +1 -1
  10. package/dist/agent/tool-protocol.d.ts +83 -0
  11. package/dist/agent/tool-protocol.js +386 -0
  12. package/dist/agent/types.d.ts +21 -1
  13. package/dist/core.d.ts +7 -7
  14. package/dist/core.js +76 -194
  15. package/dist/event-bus.d.ts +26 -0
  16. package/dist/event-bus.js +20 -1
  17. package/dist/extension-loader.d.ts +5 -0
  18. package/dist/extension-loader.js +104 -17
  19. package/dist/extensions/agent-backend.d.ts +13 -0
  20. package/dist/extensions/agent-backend.js +167 -0
  21. package/dist/extensions/command-suggest.d.ts +3 -3
  22. package/dist/extensions/command-suggest.js +4 -3
  23. package/dist/extensions/index.d.ts +19 -0
  24. package/dist/extensions/index.js +25 -0
  25. package/dist/extensions/slash-commands.d.ts +1 -1
  26. package/dist/extensions/slash-commands.js +16 -1
  27. package/dist/extensions/terminal-buffer.d.ts +1 -1
  28. package/dist/extensions/terminal-buffer.js +13 -4
  29. package/dist/extensions/tui-renderer.js +63 -43
  30. package/dist/index.js +14 -20
  31. package/dist/settings.d.ts +6 -0
  32. package/dist/settings.js +4 -1
  33. package/dist/{input-handler.d.ts → shell/input-handler.d.ts} +1 -1
  34. package/dist/{input-handler.js → shell/input-handler.js} +60 -43
  35. package/dist/{output-parser.d.ts → shell/output-parser.d.ts} +1 -1
  36. package/dist/{output-parser.js → shell/output-parser.js} +1 -1
  37. package/dist/{shell.d.ts → shell/shell.d.ts} +8 -2
  38. package/dist/{shell.js → shell/shell.js} +20 -6
  39. package/dist/types.d.ts +49 -10
  40. package/dist/utils/compositor.d.ts +62 -0
  41. package/dist/utils/compositor.js +88 -0
  42. package/dist/utils/diff-renderer.js +92 -4
  43. package/dist/utils/floating-panel.d.ts +2 -0
  44. package/dist/utils/floating-panel.js +30 -14
  45. package/dist/utils/handler-registry.d.ts +26 -10
  46. package/dist/utils/handler-registry.js +52 -16
  47. package/dist/utils/line-editor.d.ts +23 -3
  48. package/dist/utils/line-editor.js +180 -42
  49. package/dist/utils/markdown.d.ts +1 -0
  50. package/dist/utils/markdown.js +1 -1
  51. package/dist/utils/message-utils.d.ts +35 -0
  52. package/dist/utils/message-utils.js +75 -0
  53. package/dist/utils/terminal-buffer.d.ts +5 -1
  54. package/dist/utils/terminal-buffer.js +18 -2
  55. package/dist/utils/tool-interactive.d.ts +12 -0
  56. package/dist/utils/tool-interactive.js +53 -0
  57. package/examples/extensions/ash-acp-bridge/README.md +39 -0
  58. package/examples/extensions/ash-acp-bridge/package.json +23 -0
  59. package/examples/extensions/ash-acp-bridge/src/index.ts +571 -0
  60. package/examples/extensions/ash-acp-bridge/tsconfig.json +14 -0
  61. package/examples/extensions/ash-mcp-bridge/README.md +72 -0
  62. package/examples/extensions/ash-mcp-bridge/index.ts +154 -0
  63. package/examples/extensions/ash-mcp-bridge/package.json +9 -0
  64. package/examples/extensions/interactive-prompts.ts +82 -110
  65. package/examples/extensions/overlay-agent.ts +84 -38
  66. package/examples/extensions/peer-mesh.ts +450 -0
  67. package/examples/extensions/questionnaire.ts +249 -0
  68. package/examples/extensions/tmux-pane.ts +307 -0
  69. package/examples/extensions/web-access.ts +327 -0
  70. package/package.json +9 -1
  71. package/dist/extensions/overlay-agent.d.ts +0 -14
  72. package/dist/extensions/overlay-agent.js +0 -147
  73. package/examples/extensions/terminal-buffer.ts +0 -184
  74. /package/dist/{token-budget.d.ts → agent/token-budget.d.ts} +0 -0
@@ -2,22 +2,93 @@
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
  */
8
14
  // ── Kitty protocol keycode → readable name ──────────────────────
9
15
  const KITTY_KEY_NAMES = {
10
16
  9: "tab", 13: "enter", 27: "escape", 127: "backspace",
11
17
  };
18
+ // ── Paste placeholder ───────────────────────────────────────────
19
+ /** First Unicode Private Use Area codepoint, used as paste placeholder. */
20
+ const PUA_BASE = 0xE000;
21
+ function isPUA(ch) {
22
+ const code = ch.charCodeAt(0);
23
+ return code >= PUA_BASE && code <= 0xF8FF;
24
+ }
12
25
  // ── Line editor ─────────────────────────────────────────────────
13
26
  export class LineEditor {
14
- buffer = "";
27
+ _buf = "";
15
28
  cursor = 0;
16
29
  pendingSeq = ""; // buffered incomplete escape sequence
30
+ // ── Bracket paste state ─────────────────────────────────────
31
+ inPaste = false;
32
+ pasteAccum = ""; // accumulates during bracket paste
33
+ pastes = new Map(); // id → pasted content
34
+ pasteCounter = 0;
17
35
  // ── History ──────────────────────────────────────────────────
18
36
  history = [];
19
37
  historyIndex = -1; // -1 = current input, 0..N = history entries (newest first)
20
38
  savedBuffer = ""; // saves current input when browsing history
39
+ // ── Public accessors ────────────────────────────────────────
40
+ /** Resolved text — paste placeholders expanded. For submit, history, logic. */
41
+ get text() {
42
+ let result = "";
43
+ for (const ch of this._buf) {
44
+ const paste = this.pastes.get(ch.charCodeAt(0) - PUA_BASE);
45
+ result += paste ?? ch;
46
+ }
47
+ return result;
48
+ }
49
+ /** Display text — paste placeholders replaced with labels. For rendering. */
50
+ get displayText() {
51
+ let result = "";
52
+ for (const ch of this._buf) {
53
+ const paste = this.pastes.get(ch.charCodeAt(0) - PUA_BASE);
54
+ if (paste) {
55
+ const n = paste.split("\n").length;
56
+ result += `[paste +${n} lines]`;
57
+ }
58
+ else {
59
+ result += ch;
60
+ }
61
+ }
62
+ return result;
63
+ }
64
+ /** Cursor position mapped to display-text coordinates. */
65
+ get displayCursor() {
66
+ let pos = 0;
67
+ for (let i = 0; i < this._buf.length && i < this.cursor; i++) {
68
+ const ch = this._buf[i];
69
+ const paste = this.pastes.get(ch.charCodeAt(0) - PUA_BASE);
70
+ if (paste) {
71
+ const n = paste.split("\n").length;
72
+ pos += `[paste +${n} lines]`.length;
73
+ }
74
+ else {
75
+ pos++;
76
+ }
77
+ }
78
+ return pos;
79
+ }
80
+ /** Number of logical positions in the buffer. */
81
+ get length() {
82
+ return this._buf.length;
83
+ }
84
+ /** Replace buffer content. Clears paste attachments. */
85
+ setText(value) {
86
+ this._buf = value;
87
+ this.pastes.clear();
88
+ this.pasteCounter = 0;
89
+ this.cursor = value.length;
90
+ }
91
+ // ── Input processing ────────────────────────────────────────
21
92
  /** Process raw terminal input, return actions for the consumer. */
22
93
  feed(data) {
23
94
  // If we had a pending incomplete escape sequence, prepend it
@@ -68,7 +139,7 @@ export class LineEditor {
68
139
  actions.push({ action: "arrow-down" });
69
140
  break;
70
141
  case "C":
71
- if (this.cursor < this.buffer.length) {
142
+ if (this.cursor < this._buf.length) {
72
143
  this.cursor++;
73
144
  actions.push({ action: "changed" });
74
145
  }
@@ -86,8 +157,8 @@ export class LineEditor {
86
157
  }
87
158
  break;
88
159
  case "F": // End
89
- if (this.cursor < this.buffer.length) {
90
- this.cursor = this.buffer.length;
160
+ if (this.cursor < this._buf.length) {
161
+ this.cursor = this._buf.length;
91
162
  actions.push({ action: "changed" });
92
163
  }
93
164
  break;
@@ -119,6 +190,16 @@ export class LineEditor {
119
190
  // Other Alt+key — ignore
120
191
  continue;
121
192
  }
193
+ // ── Bracket paste: accumulate into side buffer ─────
194
+ if (this.inPaste) {
195
+ if (ch === "\r") {
196
+ i++;
197
+ continue;
198
+ } // skip CR (CR+LF → just LF)
199
+ this.pasteAccum += ch;
200
+ i++;
201
+ continue;
202
+ }
122
203
  // ── Control characters ──────────────────────────────
123
204
  if (ch.charCodeAt(0) < 0x20 || ch === "\x7f") {
124
205
  const action = this.handleControl(ch);
@@ -128,7 +209,7 @@ export class LineEditor {
128
209
  continue;
129
210
  }
130
211
  // ── Printable character ─────────────────────────────
131
- this.buffer = this.buffer.slice(0, this.cursor) + ch + this.buffer.slice(this.cursor);
212
+ this._buf = this._buf.slice(0, this.cursor) + ch + this._buf.slice(this.cursor);
132
213
  this.cursor++;
133
214
  actions.push({ action: "changed" });
134
215
  i++;
@@ -148,9 +229,13 @@ export class LineEditor {
148
229
  return wasBarEscape ? [{ action: "cancel" }] : [];
149
230
  }
150
231
  clear() {
151
- this.buffer = "";
232
+ this._buf = "";
152
233
  this.cursor = 0;
153
234
  this.pendingSeq = "";
235
+ this.inPaste = false;
236
+ this.pasteAccum = "";
237
+ this.pastes.clear();
238
+ this.pasteCounter = 0;
154
239
  this.historyIndex = -1;
155
240
  this.savedBuffer = "";
156
241
  }
@@ -171,11 +256,10 @@ export class LineEditor {
171
256
  if (this.historyIndex + 1 >= this.history.length)
172
257
  return null;
173
258
  if (this.historyIndex === -1) {
174
- this.savedBuffer = this.buffer; // save current input
259
+ this.savedBuffer = this.text; // save resolved current input
175
260
  }
176
261
  this.historyIndex++;
177
- this.buffer = this.history[this.historyIndex];
178
- this.cursor = this.buffer.length;
262
+ this.setText(this.history[this.historyIndex]);
179
263
  return { action: "changed" };
180
264
  }
181
265
  /** Navigate to a more recent history entry. Returns changed action or null. */
@@ -184,12 +268,11 @@ export class LineEditor {
184
268
  return null;
185
269
  this.historyIndex--;
186
270
  if (this.historyIndex === -1) {
187
- this.buffer = this.savedBuffer;
271
+ this.setText(this.savedBuffer);
188
272
  }
189
273
  else {
190
- this.buffer = this.history[this.historyIndex];
274
+ this.setText(this.history[this.historyIndex]);
191
275
  }
192
- this.cursor = this.buffer.length;
193
276
  return { action: "changed" };
194
277
  }
195
278
  // ── Key bindings ────────────────────────────────────────────
@@ -198,17 +281,17 @@ export class LineEditor {
198
281
  // characters and kitty protocol sequences resolve to a key name
199
282
  // and look it up here. To add a binding, add one entry.
200
283
  bindings = {
201
- "enter": () => ({ action: "submit", buffer: this.buffer }),
284
+ "enter": () => ({ action: "submit", buffer: this.text }),
202
285
  "ctrl+c": () => ({ action: "cancel" }),
203
286
  "tab": () => ({ action: "tab" }),
204
287
  "backspace": () => this.deleteBackward(),
205
- "ctrl+d": () => this.buffer.length === 0 ? { action: "delete-empty" } : this.deleteForward(),
288
+ "ctrl+d": () => this._buf.length === 0 ? { action: "delete-empty" } : this.deleteForward(),
206
289
  "ctrl+a": () => this.moveTo(0),
207
- "ctrl+e": () => this.moveTo(this.buffer.length),
290
+ "ctrl+e": () => this.moveTo(this._buf.length),
208
291
  "ctrl+b": () => this.moveTo(this.cursor - 1),
209
292
  "ctrl+f": () => this.moveTo(this.cursor + 1),
210
293
  "ctrl+u": () => this.deleteRange(0, this.cursor),
211
- "ctrl+k": () => this.deleteRange(this.cursor, this.buffer.length),
294
+ "ctrl+k": () => this.deleteRange(this.cursor, this._buf.length),
212
295
  "ctrl+w": () => this.deleteWordBackward() ? { action: "changed" } : null,
213
296
  "alt+f": () => this.wordForward() ? { action: "changed" } : null,
214
297
  "alt+b": () => this.wordBackward() ? { action: "changed" } : null,
@@ -259,36 +342,51 @@ export class LineEditor {
259
342
  }
260
343
  // ── Editing primitives ─────────────────────────────────────
261
344
  insertAt(ch) {
262
- this.buffer = this.buffer.slice(0, this.cursor) + ch + this.buffer.slice(this.cursor);
345
+ this._buf = this._buf.slice(0, this.cursor) + ch + this._buf.slice(this.cursor);
263
346
  this.cursor++;
264
347
  return { action: "changed" };
265
348
  }
266
349
  moveTo(pos) {
267
- const clamped = Math.max(0, Math.min(pos, this.buffer.length));
350
+ const clamped = Math.max(0, Math.min(pos, this._buf.length));
268
351
  if (clamped === this.cursor)
269
352
  return null;
270
353
  this.cursor = clamped;
271
354
  return { action: "changed" };
272
355
  }
273
356
  deleteBackward() {
274
- if (this.buffer.length === 0)
357
+ if (this._buf.length === 0)
275
358
  return { action: "delete-empty" };
276
359
  if (this.cursor <= 0)
277
360
  return null;
278
- this.buffer = this.buffer.slice(0, this.cursor - 1) + this.buffer.slice(this.cursor);
361
+ // If deleting a paste placeholder, also remove the paste entry
362
+ const deleted = this._buf[this.cursor - 1];
363
+ if (isPUA(deleted)) {
364
+ this.pastes.delete(deleted.charCodeAt(0) - PUA_BASE);
365
+ }
366
+ this._buf = this._buf.slice(0, this.cursor - 1) + this._buf.slice(this.cursor);
279
367
  this.cursor--;
280
368
  return { action: "changed" };
281
369
  }
282
370
  deleteForward() {
283
- if (this.cursor >= this.buffer.length)
371
+ if (this.cursor >= this._buf.length)
284
372
  return null;
285
- this.buffer = this.buffer.slice(0, this.cursor) + this.buffer.slice(this.cursor + 1);
373
+ const deleted = this._buf[this.cursor];
374
+ if (isPUA(deleted)) {
375
+ this.pastes.delete(deleted.charCodeAt(0) - PUA_BASE);
376
+ }
377
+ this._buf = this._buf.slice(0, this.cursor) + this._buf.slice(this.cursor + 1);
286
378
  return { action: "changed" };
287
379
  }
288
380
  deleteRange(start, end) {
289
381
  if (start >= end)
290
382
  return null;
291
- this.buffer = this.buffer.slice(0, start) + this.buffer.slice(end);
383
+ // Clean up any paste entries in the deleted range
384
+ for (let k = start; k < end; k++) {
385
+ const ch = this._buf[k];
386
+ if (isPUA(ch))
387
+ this.pastes.delete(ch.charCodeAt(0) - PUA_BASE);
388
+ }
389
+ this._buf = this._buf.slice(0, start) + this._buf.slice(end);
292
390
  this.cursor = start;
293
391
  return { action: "changed" };
294
392
  }
@@ -326,7 +424,7 @@ export class LineEditor {
326
424
  actions.push({ action: "changed" });
327
425
  }
328
426
  else {
329
- if (this.cursor < this.buffer.length) {
427
+ if (this.cursor < this._buf.length) {
330
428
  this.cursor++;
331
429
  actions.push({ action: "changed" });
332
430
  }
@@ -351,8 +449,8 @@ export class LineEditor {
351
449
  }
352
450
  break;
353
451
  case "F": // End
354
- if (this.cursor < this.buffer.length) {
355
- this.cursor = this.buffer.length;
452
+ if (this.cursor < this._buf.length) {
453
+ this.cursor = this._buf.length;
356
454
  actions.push({ action: "changed" });
357
455
  }
358
456
  break;
@@ -365,11 +463,39 @@ export class LineEditor {
365
463
  actions.push(action);
366
464
  break;
367
465
  }
368
- case "~": // Extended keys: Delete (3~), etc.
466
+ case "~": // Extended keys: Delete (3~), bracket paste (200~/201~), etc.
369
467
  if (params === "3") {
370
468
  // 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);
469
+ if (this.cursor < this._buf.length) {
470
+ const deleted = this._buf[this.cursor];
471
+ if (isPUA(deleted))
472
+ this.pastes.delete(deleted.charCodeAt(0) - PUA_BASE);
473
+ this._buf = this._buf.slice(0, this.cursor) + this._buf.slice(this.cursor + 1);
474
+ actions.push({ action: "changed" });
475
+ }
476
+ }
477
+ else if (params === "200") {
478
+ this.inPaste = true;
479
+ this.pasteAccum = "";
480
+ }
481
+ else if (params === "201") {
482
+ this.inPaste = false;
483
+ if (this.pasteAccum) {
484
+ const lines = this.pasteAccum.split("\n");
485
+ if (lines.length <= 1) {
486
+ // Single-line paste — inline directly
487
+ this._buf = this._buf.slice(0, this.cursor) + this.pasteAccum + this._buf.slice(this.cursor);
488
+ this.cursor += this.pasteAccum.length;
489
+ }
490
+ else {
491
+ // Multi-line paste — store and insert placeholder
492
+ const id = this.pasteCounter++;
493
+ this.pastes.set(id, this.pasteAccum);
494
+ const placeholder = String.fromCharCode(PUA_BASE + id);
495
+ this._buf = this._buf.slice(0, this.cursor) + placeholder + this._buf.slice(this.cursor);
496
+ this.cursor++;
497
+ }
498
+ this.pasteAccum = "";
373
499
  actions.push({ action: "changed" });
374
500
  }
375
501
  }
@@ -383,11 +509,11 @@ export class LineEditor {
383
509
  if (this.cursor === 0)
384
510
  return false;
385
511
  let pos = this.cursor;
386
- // Skip spaces
387
- while (pos > 0 && this.buffer[pos - 1] === " ")
512
+ // Skip PUA placeholders and spaces
513
+ while (pos > 0 && (this._buf[pos - 1] === " " || isPUA(this._buf[pos - 1])))
388
514
  pos--;
389
515
  // Skip word chars
390
- while (pos > 0 && this.buffer[pos - 1] !== " ")
516
+ while (pos > 0 && this._buf[pos - 1] !== " " && !isPUA(this._buf[pos - 1]))
391
517
  pos--;
392
518
  if (pos === this.cursor)
393
519
  return false;
@@ -395,14 +521,14 @@ export class LineEditor {
395
521
  return true;
396
522
  }
397
523
  wordForward() {
398
- if (this.cursor >= this.buffer.length)
524
+ if (this.cursor >= this._buf.length)
399
525
  return false;
400
526
  let pos = this.cursor;
401
- // Skip word chars
402
- while (pos < this.buffer.length && this.buffer[pos] !== " ")
527
+ // Skip word chars and PUA placeholders
528
+ while (pos < this._buf.length && this._buf[pos] !== " " && !isPUA(this._buf[pos]))
403
529
  pos++;
404
- // Skip spaces
405
- while (pos < this.buffer.length && this.buffer[pos] === " ")
530
+ // Skip spaces and PUA
531
+ while (pos < this._buf.length && (this._buf[pos] === " " || isPUA(this._buf[pos])))
406
532
  pos++;
407
533
  if (pos === this.cursor)
408
534
  return false;
@@ -414,15 +540,27 @@ export class LineEditor {
414
540
  return false;
415
541
  const start = this.cursor;
416
542
  this.wordBackward();
417
- this.buffer = this.buffer.slice(0, this.cursor) + this.buffer.slice(start);
543
+ // Clean up paste entries
544
+ for (let k = this.cursor; k < start; k++) {
545
+ const ch = this._buf[k];
546
+ if (isPUA(ch))
547
+ this.pastes.delete(ch.charCodeAt(0) - PUA_BASE);
548
+ }
549
+ this._buf = this._buf.slice(0, this.cursor) + this._buf.slice(start);
418
550
  return true;
419
551
  }
420
552
  deleteWordForward() {
421
- if (this.cursor >= this.buffer.length)
553
+ if (this.cursor >= this._buf.length)
422
554
  return false;
423
555
  const start = this.cursor;
424
556
  this.wordForward();
425
- this.buffer = this.buffer.slice(0, start) + this.buffer.slice(this.cursor);
557
+ // Clean up paste entries
558
+ for (let k = start; k < this.cursor; k++) {
559
+ const ch = this._buf[k];
560
+ if (isPUA(ch))
561
+ this.pastes.delete(ch.charCodeAt(0) - PUA_BASE);
562
+ }
563
+ this._buf = this._buf.slice(0, start) + this._buf.slice(this.cursor);
426
564
  this.cursor = start;
427
565
  return true;
428
566
  }
@@ -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 {
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Interactive UI primitive for tools.
3
+ *
4
+ * Gives a tool imperative control over rendering and input on the active
5
+ * surface. The tool provides render() + handleInput(), the primitive
6
+ * handles surface writing, input interception, shell pause/unpause,
7
+ * and cleanup.
8
+ */
9
+ import type { EventBus } from "../event-bus.js";
10
+ import type { RenderSurface } from "./compositor.js";
11
+ import type { ToolUI } from "../agent/types.js";
12
+ export declare function createToolUI(bus: EventBus, surface: RenderSurface): ToolUI;
@@ -0,0 +1,53 @@
1
+ /** Clear N lines above the cursor. */
2
+ function clearLines(surface, count) {
3
+ for (let i = 0; i < count; i++) {
4
+ surface.write("\x1b[A\x1b[2K");
5
+ }
6
+ }
7
+ export function createToolUI(bus, surface) {
8
+ return {
9
+ custom(session) {
10
+ return new Promise((resolve) => {
11
+ let prevLineCount = 0;
12
+ let finished = false;
13
+ const done = (result) => {
14
+ if (finished)
15
+ return;
16
+ finished = true;
17
+ clearLines(surface, prevLineCount);
18
+ bus.offPipe("input:intercept", interceptor);
19
+ bus.emit("shell:stdout-hide", {});
20
+ bus.emit("tool:interactive-end", {});
21
+ session.onUnmount?.();
22
+ resolve(result);
23
+ };
24
+ const render = () => {
25
+ if (finished)
26
+ return;
27
+ clearLines(surface, prevLineCount);
28
+ const lines = session.render(surface.columns);
29
+ for (const line of lines) {
30
+ surface.writeLine(line);
31
+ }
32
+ prevLineCount = lines.length;
33
+ };
34
+ const interceptor = (payload) => {
35
+ if (finished)
36
+ return payload;
37
+ // Let Ctrl+C through for agent cancellation
38
+ if (payload.data === "\x03")
39
+ return payload;
40
+ session.handleInput(payload.data, done);
41
+ render();
42
+ return { ...payload, consumed: true };
43
+ };
44
+ // Setup
45
+ bus.emit("tool:interactive-start", {});
46
+ bus.emit("shell:stdout-show", {});
47
+ bus.onPipe("input:intercept", interceptor);
48
+ session.onMount?.(() => render());
49
+ render();
50
+ });
51
+ },
52
+ };
53
+ }