agent-sh 0.15.0 → 0.15.2

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 (124) hide show
  1. package/dist/agent/agent-loop.js +11 -8
  2. package/dist/agent/events.d.ts +4 -0
  3. package/docs/README.md +14 -0
  4. package/docs/agent.md +398 -0
  5. package/docs/architecture.md +196 -0
  6. package/docs/context-management.md +200 -0
  7. package/docs/extensions.md +951 -0
  8. package/docs/library.md +84 -0
  9. package/docs/troubleshooting.md +65 -0
  10. package/docs/tui-composition.md +294 -0
  11. package/docs/usage.md +306 -0
  12. package/examples/extensions/ash-scheme/package.json +1 -1
  13. package/examples/extensions/ashi/EXTENDING.md +2 -2
  14. package/examples/extensions/ashi/README.md +2 -2
  15. package/examples/extensions/ashi/docs/ui-surface-protocol.md +1 -1
  16. package/examples/extensions/ashi/package.json +5 -3
  17. package/examples/extensions/ashi/src/chat/tool-group.ts +3 -2
  18. package/examples/extensions/ashi/src/cli.ts +9 -8
  19. package/examples/extensions/ashi/src/dialogs.ts +16 -1
  20. package/examples/extensions/ashi/src/events.ts +1 -0
  21. package/examples/extensions/ashi/src/frontend.ts +26 -6
  22. package/examples/extensions/ashi/src/renderer.ts +24 -4
  23. package/examples/extensions/ashi/src/renderers/pi-tui/schema-mount.ts +4 -3
  24. package/examples/extensions/ashi/src/renderers/pi-tui/tool-group.ts +5 -8
  25. package/examples/extensions/ashi/src/ui.ts +11 -0
  26. package/examples/extensions/ashi-ink/package.json +2 -2
  27. package/examples/extensions/claude-code-bridge/package.json +1 -1
  28. package/examples/extensions/opencode-bridge/package.json +1 -1
  29. package/package.json +3 -1
  30. package/src/agent/agent-loop.ts +1566 -0
  31. package/src/agent/entry-format.ts +19 -0
  32. package/src/agent/events.ts +153 -0
  33. package/src/agent/extensions/rolling-history/constants.ts +1 -0
  34. package/src/agent/extensions/rolling-history/index.ts +202 -0
  35. package/src/agent/extensions/rolling-history/recall.ts +131 -0
  36. package/src/agent/extensions/rolling-history/strategy.ts +404 -0
  37. package/src/agent/host-types.ts +192 -0
  38. package/src/agent/index.ts +591 -0
  39. package/src/agent/live-view.ts +279 -0
  40. package/src/agent/llm-client.ts +111 -0
  41. package/src/agent/llm-facade.ts +43 -0
  42. package/src/agent/normalize-args.ts +61 -0
  43. package/src/agent/nuclear-form.ts +382 -0
  44. package/src/agent/providers/deepseek.ts +39 -0
  45. package/src/agent/providers/ollama.ts +92 -0
  46. package/src/agent/providers/openai-compatible.ts +36 -0
  47. package/src/agent/providers/openai.ts +52 -0
  48. package/src/agent/providers/opencode.ts +142 -0
  49. package/src/agent/providers/openrouter.ts +105 -0
  50. package/src/agent/providers/zai-coding-plan.ts +33 -0
  51. package/src/agent/session-store.ts +336 -0
  52. package/src/agent/skills.ts +228 -0
  53. package/src/agent/store.ts +310 -0
  54. package/src/agent/subagent.ts +305 -0
  55. package/src/agent/system-prompt.ts +151 -0
  56. package/src/agent/token-budget.ts +12 -0
  57. package/src/agent/tool-protocol.ts +722 -0
  58. package/src/agent/tool-registry.ts +66 -0
  59. package/src/agent/tools/bash.ts +95 -0
  60. package/src/agent/tools/edit-file.ts +154 -0
  61. package/src/agent/tools/expand-home.ts +7 -0
  62. package/src/agent/tools/glob.ts +108 -0
  63. package/src/agent/tools/grep.ts +228 -0
  64. package/src/agent/tools/list-skills.ts +37 -0
  65. package/src/agent/tools/ls.ts +81 -0
  66. package/src/agent/tools/pwsh.ts +140 -0
  67. package/src/agent/tools/read-file.ts +164 -0
  68. package/src/agent/tools/write-file.ts +72 -0
  69. package/src/agent/types.ts +149 -0
  70. package/src/cli/args.ts +91 -0
  71. package/src/cli/auth/cli.ts +244 -0
  72. package/src/cli/auth/discover.ts +52 -0
  73. package/src/cli/auth/keys.ts +143 -0
  74. package/src/cli/index.ts +295 -0
  75. package/src/cli/init.ts +74 -0
  76. package/src/cli/install.ts +439 -0
  77. package/src/cli/shell-env.ts +68 -0
  78. package/src/cli/subcommands.ts +24 -0
  79. package/src/core/event-bus.ts +252 -0
  80. package/src/core/extension-loader.ts +347 -0
  81. package/src/core/index.ts +152 -0
  82. package/src/core/settings.ts +398 -0
  83. package/src/core/types.ts +61 -0
  84. package/src/extensions/file-autocomplete.ts +71 -0
  85. package/src/extensions/index.ts +38 -0
  86. package/src/extensions/slash-commands/events.ts +14 -0
  87. package/src/extensions/slash-commands/index.ts +269 -0
  88. package/src/shell/events.ts +73 -0
  89. package/src/shell/host-types.ts +150 -0
  90. package/src/shell/index.ts +159 -0
  91. package/src/shell/input-handler.ts +505 -0
  92. package/src/shell/output-parser.ts +156 -0
  93. package/src/shell/shell-context.ts +193 -0
  94. package/src/shell/shell.ts +414 -0
  95. package/src/shell/strategies/bash.ts +83 -0
  96. package/src/shell/strategies/fish.ts +77 -0
  97. package/src/shell/strategies/index.ts +24 -0
  98. package/src/shell/strategies/types.ts +64 -0
  99. package/src/shell/strategies/zsh.ts +92 -0
  100. package/src/shell/terminal.ts +124 -0
  101. package/src/shell/tui-input-view.ts +222 -0
  102. package/src/shell/tui-renderer.ts +1126 -0
  103. package/src/utils/ansi.ts +140 -0
  104. package/src/utils/box-frame.ts +138 -0
  105. package/src/utils/compositor.ts +157 -0
  106. package/src/utils/diff-renderer.ts +829 -0
  107. package/src/utils/diff.ts +244 -0
  108. package/src/utils/executor.ts +305 -0
  109. package/src/utils/file-watcher.ts +110 -0
  110. package/src/utils/floating-panel.ts +1160 -0
  111. package/src/utils/handler-registry.ts +110 -0
  112. package/src/utils/line-editor.ts +636 -0
  113. package/src/utils/markdown.ts +437 -0
  114. package/src/utils/message-utils.ts +113 -0
  115. package/src/utils/package-version.ts +12 -0
  116. package/src/utils/palette.ts +64 -0
  117. package/src/utils/ref-counter.ts +9 -0
  118. package/src/utils/ripgrep-path.ts +17 -0
  119. package/src/utils/shell-output-spill.ts +76 -0
  120. package/src/utils/stream-transform.ts +292 -0
  121. package/src/utils/terminal-buffer.ts +213 -0
  122. package/src/utils/tool-display.ts +315 -0
  123. package/src/utils/tool-interactive.ts +71 -0
  124. package/src/utils/tty.ts +14 -0
@@ -0,0 +1,437 @@
1
+ import { visibleLen, truncateAnsiToWidth, padEndToWidth, charWidth } from "./ansi.js";
2
+ import { palette as p } from "./palette.js";
3
+
4
+ export const MAX_CONTENT_WIDTH = 90;
5
+
6
+ // CJK line-breaking rules: closing punctuation must not start a line,
7
+ // opening punctuation must not end a line. Both CJK fullwidth and ASCII
8
+ // equivalents are included so mixed text wraps correctly.
9
+ const CJK_NO_LINE_START = new Set([
10
+ "。", ",", "、", ".", ";", ":", "!", "?",
11
+ ")", "」", "』", "】", "》", "〉", "〕", "]", "}",
12
+ "・", "々", "〜", "~", "ー",
13
+ ".", ",", ";", ":", "!", "?", ")", "]", "}",
14
+ ]);
15
+
16
+ const CJK_NO_LINE_END = new Set([
17
+ "(", "「", "『", "【", "《", "〈", "〔", "[", "{",
18
+ "(", "[", "{",
19
+ ]);
20
+
21
+ /**
22
+ * Tokenize a visible-text run into units suitable for wrapping.
23
+ * Each width-2 character (CJK, fullwidth, emoji) becomes its own token so the
24
+ * wrapper can break between them; ASCII runs stay together as word tokens.
25
+ */
26
+ function tokenizeVisible(text: string): string[] {
27
+ const tokens: string[] = [];
28
+ let ascii = "";
29
+ const flush = () => { if (ascii) { tokens.push(ascii); ascii = ""; } };
30
+ let i = 0;
31
+ while (i < text.length) {
32
+ const cp = text.codePointAt(i) ?? 0;
33
+ const chLen = cp > 0xffff ? 2 : 1;
34
+ const ch = text.slice(i, i + chLen);
35
+ if (ch === " ") {
36
+ flush();
37
+ let spaces = "";
38
+ while (i < text.length && text[i] === " ") { spaces += " "; i += 1; }
39
+ tokens.push(spaces);
40
+ continue;
41
+ }
42
+ if (charWidth(cp) === 2) {
43
+ flush();
44
+ tokens.push(ch);
45
+ i += chLen;
46
+ continue;
47
+ }
48
+ ascii += ch;
49
+ i += chLen;
50
+ }
51
+ flush();
52
+ return tokens;
53
+ }
54
+
55
+ /**
56
+ * Word-wrap a string (which may contain ANSI codes) to a maximum visible width.
57
+ * Returns an array of lines, each fitting within `maxWidth` visible characters.
58
+ *
59
+ * Handles CJK text by breaking between wide characters and applying basic
60
+ * CJK rules (closing punctuation sticks to the previous line; opening
61
+ * punctuation sticks to the next).
62
+ */
63
+ export function wrapLine(text: string, maxWidth: number): string[] {
64
+ if (!(maxWidth > 0)) return [text]; // catches NaN, <=0, undefined
65
+ if (visibleLen(text) <= maxWidth) return [text];
66
+
67
+ const result: string[] = [];
68
+ const segments = text.match(/(\x1b\[[^m]*m|[^\x1b]+)/g) || [text];
69
+
70
+ let lineTokens: string[] = [];
71
+ let lineWidth = 0;
72
+ let activeStyles = "";
73
+ let lastVisibleIdx = -1;
74
+
75
+ const commit = () => {
76
+ result.push(lineTokens.join("") + p.reset);
77
+ lineTokens = activeStyles ? [activeStyles] : [];
78
+ lineWidth = 0;
79
+ lastVisibleIdx = -1;
80
+ };
81
+
82
+ for (const seg of segments) {
83
+ if (seg.startsWith("\x1b[")) {
84
+ lineTokens.push(seg);
85
+ if (seg === p.reset) activeStyles = "";
86
+ else activeStyles += seg;
87
+ continue;
88
+ }
89
+
90
+ for (const token of tokenizeVisible(seg)) {
91
+ const tokenWidth = visibleLen(token);
92
+ const isSpace = token[0] === " ";
93
+
94
+ if (lineWidth + tokenWidth <= maxWidth) {
95
+ lineTokens.push(token);
96
+ lineWidth += tokenWidth;
97
+ if (!isSpace) lastVisibleIdx = lineTokens.length - 1;
98
+ continue;
99
+ }
100
+
101
+ // Token doesn't fit on the current line.
102
+ if (isSpace) continue; // spaces at wrap points are dropped
103
+
104
+ if (lineWidth === 0) {
105
+ // Token longer than the entire line — hard-break by char width.
106
+ let remaining = token;
107
+ while (remaining.length > 0) {
108
+ let fitLen = 0, fitWidth = 0;
109
+ for (const ch of remaining) {
110
+ const cw = charWidth(ch.codePointAt(0) ?? 0);
111
+ if (fitWidth + cw > maxWidth) break;
112
+ fitWidth += cw;
113
+ fitLen += ch.length;
114
+ }
115
+ if (fitLen === 0) fitLen = remaining[0]?.length ?? 1;
116
+ const chunk = remaining.slice(0, fitLen);
117
+ remaining = remaining.slice(fitLen);
118
+ lineTokens.push(chunk);
119
+ lineWidth += visibleLen(chunk);
120
+ lastVisibleIdx = lineTokens.length - 1;
121
+ if (remaining.length > 0) commit();
122
+ }
123
+ continue;
124
+ }
125
+
126
+ // Rule (a): closing punctuation must not start a line. Allow up to 2
127
+ // columns of overflow so the punctuation stays with its phrase.
128
+ if (CJK_NO_LINE_START.has(token)) {
129
+ lineTokens.push(token);
130
+ lineWidth += tokenWidth;
131
+ commit();
132
+ continue;
133
+ }
134
+
135
+ // Rule (b): opening punctuation must not end a line. Pull the trailing
136
+ // opener down to the next line with us.
137
+ let carried: string[] = [];
138
+ if (lastVisibleIdx >= 0 && CJK_NO_LINE_END.has(lineTokens[lastVisibleIdx]!)) {
139
+ carried = lineTokens.splice(lastVisibleIdx);
140
+ while (lineTokens.length > 0 && /^ +$/.test(lineTokens[lineTokens.length - 1]!)) {
141
+ lineTokens.pop();
142
+ }
143
+ }
144
+
145
+ commit();
146
+ for (const t of carried) {
147
+ lineTokens.push(t);
148
+ lineWidth += visibleLen(t);
149
+ }
150
+ lineTokens.push(token);
151
+ lineWidth += tokenWidth;
152
+ lastVisibleIdx = lineTokens.length - 1;
153
+ }
154
+ }
155
+
156
+ if (lineWidth > 0) {
157
+ result.push(lineTokens.join(""));
158
+ }
159
+
160
+ return result;
161
+ }
162
+
163
+ /**
164
+ * Streaming markdown renderer that processes chunks of text,
165
+ * renders complete lines with ANSI formatting, and wraps output
166
+ * in a bordered box.
167
+ *
168
+ * The renderer accumulates lines internally. Call `drainLines()` to
169
+ * extract them — this is the only way output leaves the renderer.
170
+ */
171
+ export class MarkdownRenderer {
172
+ private buffer = "";
173
+ private contentWidth: number;
174
+ private firstLine = true;
175
+ private lastLineBlank = false;
176
+ private pendingLines: string[] = [];
177
+ private width: number;
178
+ private tableRows: string[][] = [];
179
+
180
+ constructor(width: number) {
181
+ this.width = Math.max(10, width);
182
+ this.contentWidth = Math.min(MAX_CONTENT_WIDTH, this.width - 2);
183
+ }
184
+
185
+ /**
186
+ * Push a streaming chunk. Complete lines are rendered immediately;
187
+ * incomplete trailing text stays in the buffer.
188
+ */
189
+ push(chunk: string): void {
190
+ this.buffer += chunk;
191
+ this.processBuffer();
192
+ }
193
+
194
+ /**
195
+ * Flush any remaining text in the buffer (called when the response ends).
196
+ */
197
+ flush(): void {
198
+ if (this.buffer.length > 0) {
199
+ this.processLine(this.buffer);
200
+ this.buffer = "";
201
+ }
202
+ this.flushTable();
203
+ }
204
+
205
+ printTopBorder(): void {
206
+ this.pendingLines.push(`${p.dim}${p.accent}${"─".repeat(this.width)}${p.reset}`);
207
+ this.firstLine = true;
208
+ }
209
+
210
+ printBottomBorder(): void {
211
+ this.pendingLines.push(`${p.dim}${p.accent}${"─".repeat(this.width)}${p.reset}`);
212
+ }
213
+
214
+ /**
215
+ * Extract and clear all accumulated lines.
216
+ * This is the only way output leaves the renderer.
217
+ */
218
+ drainLines(): string[] {
219
+ const lines = this.pendingLines;
220
+ this.pendingLines = [];
221
+ return lines;
222
+ }
223
+
224
+ private processBuffer(): void {
225
+ const lines = this.buffer.split("\n");
226
+ this.buffer = lines.pop()!;
227
+
228
+ for (const line of lines) {
229
+ this.processLine(line);
230
+ }
231
+ }
232
+
233
+ private processLine(line: string): void {
234
+ // Table row detection: lines with | separators
235
+ if (/^\s*\|/.test(line)) {
236
+ const cells = parseTableRow(line);
237
+ if (cells) {
238
+ this.tableRows.push(cells);
239
+ return;
240
+ }
241
+ }
242
+
243
+ // Non-table line — flush any buffered table first
244
+ this.flushTable();
245
+
246
+ const rendered = this.renderLine(line);
247
+ const wrapped = wrapLine(rendered, this.contentWidth);
248
+ for (const wl of wrapped) {
249
+ this.writeLine(wl);
250
+ }
251
+ }
252
+
253
+ private flushTable(): void {
254
+ if (this.tableRows.length === 0) return;
255
+
256
+ const rows = this.tableRows;
257
+ this.tableRows = [];
258
+
259
+ // Filter out separator rows (|---|---|)
260
+ const sepIdx: number[] = [];
261
+ const dataRows: string[][] = [];
262
+ for (let i = 0; i < rows.length; i++) {
263
+ if (rows[i]!.every((c) => /^[-:]+$/.test(c.trim()) || c.trim() === "")) {
264
+ sepIdx.push(i);
265
+ } else {
266
+ dataRows.push(rows[i]!);
267
+ }
268
+ }
269
+
270
+ if (dataRows.length === 0) return;
271
+
272
+ // Normalize column count
273
+ const numCols = Math.max(...dataRows.map((r) => r.length));
274
+ for (const row of dataRows) {
275
+ while (row.length < numCols) row.push("");
276
+ }
277
+
278
+ // Width from rendered cell — raw `**bold**` over-counts by 4 per pair.
279
+ const colWidths: number[] = new Array(numCols).fill(0);
280
+ for (const row of dataRows) {
281
+ for (let c = 0; c < numCols; c++) {
282
+ colWidths[c] = Math.max(colWidths[c]!, visibleLen(this.renderInline(row[c]!)));
283
+ }
284
+ }
285
+
286
+ // Tables bypass the prose width cap — borders guide the eye, so wider is fine.
287
+ const separatorWidth = (numCols - 1) * 3;
288
+ const tableWidth = Math.max(10, this.width - 2);
289
+ const availableWidth = tableWidth - separatorWidth;
290
+ // Shrink the widest column one step at a time until the table fits.
291
+ // Preserves natural width on narrow columns — proportional scaling
292
+ // over-truncates when only one column is oversized.
293
+ let total = colWidths.reduce((a, b) => a + b, 0);
294
+ while (total > availableWidth && availableWidth > numCols) {
295
+ let maxIdx = 0;
296
+ for (let c = 1; c < numCols; c++) {
297
+ if (colWidths[c]! > colWidths[maxIdx]!) maxIdx = c;
298
+ }
299
+ if (colWidths[maxIdx]! <= 1) break;
300
+ colWidths[maxIdx]!--;
301
+ total--;
302
+ }
303
+
304
+ // Render rows
305
+ const hasHeader = sepIdx.includes(1) && dataRows.length > 1;
306
+
307
+ // Top border
308
+ const topBorder = colWidths.map((w) => "─".repeat(w)).join(`─┬─`);
309
+ this.writeLine(`${p.dim}┌─${topBorder}─┐${p.reset}`);
310
+
311
+ for (let i = 0; i < dataRows.length; i++) {
312
+ const row = dataRows[i]!;
313
+ const isHeader = hasHeader && i === 0;
314
+ const cells = row.map((cell, c) => {
315
+ const w = colWidths[c]!;
316
+ const rendered = this.renderInline(cell);
317
+ // Truncation can yield width < w when a CJK double-width char
318
+ // won't fit the remaining budget — always re-pad to keep cells
319
+ // aligned with the border grid.
320
+ const clipped = visibleLen(rendered) > w
321
+ ? truncateAnsiToWidth(rendered, w)
322
+ : rendered;
323
+ const text = padEndToWidth(clipped, w);
324
+ return isHeader ? `${p.bold}${text}${p.reset}` : text;
325
+ });
326
+ this.writeLine(`${p.dim}│${p.reset} ${cells.join(` ${p.dim}│${p.reset} `)} ${p.dim}│${p.reset}`);
327
+
328
+ // Separator after header
329
+ if (isHeader) {
330
+ const sep = colWidths.map((w) => "─".repeat(w)).join(`─┼─`);
331
+ this.writeLine(`${p.dim}├─${sep}─┤${p.reset}`);
332
+ }
333
+ }
334
+
335
+ // Bottom border
336
+ const bottomBorder = colWidths.map((w) => "─".repeat(w)).join(`─┴─`);
337
+ this.writeLine(`${p.dim}└─${bottomBorder}─┘${p.reset}`);
338
+ }
339
+
340
+ private renderLine(line: string): string {
341
+ if (line.trim() === "") return "";
342
+
343
+ // Headings
344
+ const h1 = line.match(/^# (.+)/);
345
+ if (h1) return `${p.bold}${p.warning}${h1[1]}${p.reset}`;
346
+
347
+ const h2 = line.match(/^## (.+)/);
348
+ if (h2) return `${p.bold}${p.accent}${h2[1]}${p.reset}`;
349
+
350
+ const h3 = line.match(/^### (.+)/);
351
+ if (h3) return `${p.bold}${h3[1]}${p.reset}`;
352
+
353
+ const h4 = line.match(/^#{4,} (.+)/);
354
+ if (h4) return `${p.bold}${h4[1]}${p.reset}`;
355
+
356
+ // Horizontal rule — subtle short separator, not full-width
357
+ if (/^(-{3,}|_{3,}|\*{3,})\s*$/.test(line)) {
358
+ return "";
359
+ }
360
+
361
+ // Blockquote
362
+ const bq = line.match(/^>\s?(.*)/);
363
+ if (bq) return `${p.muted}│${p.reset} ${p.dim}${p.italic}${this.renderInline(bq[1] || "")}${p.reset}`;
364
+
365
+ // Task list (checkbox items) — must come before generic unordered list
366
+ const task = line.match(/^(\s*)[*\-+]\s+\[([ xX])\]\s+(.*)/);
367
+ if (task) {
368
+ const indent = task[1] || "";
369
+ const checked = task[2] !== " ";
370
+ const box = checked
371
+ ? `${p.success}☑${p.reset}`
372
+ : `${p.dim}☐${p.reset}`;
373
+ return `${indent} ${box} ${this.renderInline(task[3] || "")}`;
374
+ }
375
+
376
+ // Unordered list
377
+ const ul = line.match(/^(\s*)[*\-+]\s+(.*)/);
378
+ if (ul) {
379
+ const indent = ul[1] || "";
380
+ return `${indent} ${p.accent}*${p.reset} ${this.renderInline(ul[2] || "")}`;
381
+ }
382
+
383
+ // Ordered list
384
+ const ol = line.match(/^(\s*)(\d+)[.)]\s+(.*)/);
385
+ if (ol) {
386
+ const indent = ol[1] || "";
387
+ return `${indent} ${p.accent}${ol[2]}.${p.reset} ${this.renderInline(ol[3] || "")}`;
388
+ }
389
+
390
+ return this.renderInline(line);
391
+ }
392
+
393
+ private renderInline(text: string): string {
394
+ // Links first — later subs inject `\x1b[…m` whose `[` would be eaten here.
395
+ text = text.replace(
396
+ /\[([^\]]+)\]\(([^)]+)\)/g,
397
+ `$1 ${p.muted}${p.underline}($2)${p.reset}`
398
+ );
399
+ // Inline code
400
+ text = text.replace(/`([^`]+)`/g, `${p.accent}$1${p.reset}`);
401
+ // Bold + italic
402
+ text = text.replace(/\*\*\*(.+?)\*\*\*/g, `${p.bold}${p.italic}$1${p.reset}`);
403
+ // Bold
404
+ text = text.replace(/\*\*(.+?)\*\*/g, `${p.bold}$1${p.reset}`);
405
+ text = text.replace(/(?<!\w)__(.+?)__(?!\w)/g, `${p.bold}$1${p.reset}`);
406
+ // Italic
407
+ text = text.replace(/\*(.+?)\*/g, `${p.italic}$1${p.reset}`);
408
+ text = text.replace(/(?<!\w)_(.+?)_(?!\w)/g, `${p.italic}$1${p.reset}`);
409
+ // Strikethrough
410
+ text = text.replace(/~~(.+?)~~/g, `${p.dim}$1${p.reset}`);
411
+ return text;
412
+ }
413
+
414
+ /**
415
+ * Add a single line with a subtle left indent.
416
+ * The line is accumulated internally — call drainLines() to extract.
417
+ */
418
+ writeLine(text: string): void {
419
+ const isBlank = visibleLen(text) === 0;
420
+ if (this.firstLine && isBlank) return;
421
+ // Collapse consecutive blank lines to a single one
422
+ if (isBlank && this.lastLineBlank) return;
423
+ this.firstLine = false;
424
+ this.lastLineBlank = isBlank;
425
+ this.pendingLines.push(` ${text}`);
426
+ }
427
+ }
428
+
429
+ /** Parse a markdown table row into trimmed cell strings, or null if not a table row. */
430
+ function parseTableRow(line: string): string[] | null {
431
+ const trimmed = line.trim();
432
+ if (!trimmed.startsWith("|") || !trimmed.endsWith("|")) return null;
433
+ // Split on |, drop first and last empty entries
434
+ const parts = trimmed.split("|");
435
+ if (parts.length < 3) return null; // need at least |cell|
436
+ return parts.slice(1, -1).map((c) => c.trim());
437
+ }
@@ -0,0 +1,113 @@
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
+ /* eslint-disable @typescript-eslint/no-explicit-any */
9
+
10
+ /**
11
+ * Find tool call IDs matching a tool name and optional argument filter.
12
+ *
13
+ * Scans assistant messages for tool_calls where `function.name` matches
14
+ * and parsed arguments satisfy the filter (shallow key/value match).
15
+ *
16
+ * Returns call IDs in message order (earliest first).
17
+ */
18
+ export function findToolCallIds(
19
+ messages: any[],
20
+ toolName: string,
21
+ argFilter?: Record<string, unknown>,
22
+ ): string[] {
23
+ const ids: string[] = [];
24
+ for (const msg of messages) {
25
+ if (msg.role !== "assistant" || !msg.tool_calls) continue;
26
+ for (const tc of msg.tool_calls) {
27
+ const fn = tc.function ?? tc.fn;
28
+ if (!fn || fn.name !== toolName) continue;
29
+ if (argFilter) {
30
+ let args: Record<string, unknown>;
31
+ try { args = JSON.parse(fn.arguments); } catch { continue; }
32
+ const match = Object.entries(argFilter).every(([k, v]) => args[k] === v);
33
+ if (!match) continue;
34
+ }
35
+ ids.push(tc.id);
36
+ }
37
+ }
38
+ return ids;
39
+ }
40
+
41
+ /**
42
+ * Replace tool result content for specific call IDs.
43
+ *
44
+ * Returns a new array (shallow copy) with matching tool messages
45
+ * replaced. Non-matching messages are passed through by reference.
46
+ */
47
+ export function stubToolResults(
48
+ messages: any[],
49
+ callIds: Set<string>,
50
+ stub: string,
51
+ ): any[] {
52
+ return messages.map((msg) => {
53
+ if (msg.role === "tool" && callIds.has(msg.tool_call_id)) {
54
+ return { ...msg, content: stub };
55
+ }
56
+ return msg;
57
+ });
58
+ }
59
+
60
+ /**
61
+ * Prepend a `<dynamic_context>` block onto the trailing message.
62
+ *
63
+ * Wrapping the *trailing* message (rather than inserting at the head) keeps
64
+ * the [system] + [prior history] prefix byte-stable across turns, so the
65
+ * provider's prefix cache holds. Caller passes a copy; conversation state
66
+ * is never mutated.
67
+ *
68
+ * No-op when both `dynamicContext` and `toolPrompt` are empty — i.e. no
69
+ * extension registered any per-turn signal — so a vanilla session sends
70
+ * exactly `[system, ...history]` with no synthetic envelope.
71
+ */
72
+ export function wrapTrailingWithDynamicContext(
73
+ history: any[],
74
+ dynamicContext: string,
75
+ toolPrompt?: string,
76
+ ): any[] {
77
+ const ctx = dynamicContext.trim();
78
+ const tp = (toolPrompt ?? "").trim();
79
+ if (!ctx && !tp) return history;
80
+ if (history.length === 0) return history;
81
+ const last = history[history.length - 1];
82
+ if (typeof last.content !== "string") return history;
83
+
84
+ const blockBody = ctx && tp ? `${ctx}\n${tp}` : ctx || tp;
85
+ const wrappedContent = `<dynamic_context>\n${blockBody}\n</dynamic_context>\n\n${last.content}`;
86
+ return [...history.slice(0, -1), { ...last, content: wrappedContent }];
87
+ }
88
+
89
+ /**
90
+ * Deduplicate tool results: keep only the latest result for a given
91
+ * tool name + argument filter, replace all older results with a stub.
92
+ *
93
+ * Common use case: a file that's read repeatedly (e.g. a live transcript)
94
+ * — only the most recent read matters.
95
+ *
96
+ * Example:
97
+ * dedupeToolResults(messages, "read_file",
98
+ * { path: "/path/to/transcript.txt" },
99
+ * "[stale — superseded by later read]")
100
+ */
101
+ export function dedupeToolResults(
102
+ messages: any[],
103
+ toolName: string,
104
+ argFilter?: Record<string, unknown>,
105
+ stub = "[superseded by later call]",
106
+ ): any[] {
107
+ const callIds = findToolCallIds(messages, toolName, argFilter);
108
+ if (callIds.length <= 1) return messages;
109
+
110
+ // Keep the last one, stub the rest
111
+ const staleIds = new Set(callIds.slice(0, -1));
112
+ return stubToolResults(messages, staleIds, stub);
113
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The agent-sh package version, read from package.json at load time.
3
+ * Emitted on `agent:info` so consumers (TUI, remote peers, logs) see a
4
+ * version that tracks releases instead of a hand-edited constant.
5
+ */
6
+ import { createRequire } from "module";
7
+
8
+ const require = createRequire(import.meta.url);
9
+ // dist/utils/package-version.js → ../../package.json (project root)
10
+ const pkg = require("../../package.json") as { version?: string };
11
+
12
+ export const PACKAGE_VERSION: string = pkg.version ?? "0.0.0";
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Semantic color palette with a small set of base roles.
3
+ *
4
+ * Components use these roles instead of raw ANSI escapes.
5
+ * Extensions can override via setPalette() for theming.
6
+ *
7
+ * Design: ~10 base slots that cover all UI needs. Components
8
+ * derive specific uses from these (e.g. "diff added" = success,
9
+ * "tool title" = warning, "user query border" = accent).
10
+ */
11
+
12
+ export interface ColorPalette {
13
+ // ── Semantic foreground roles ─────────────────────────────
14
+ accent: string; // primary highlight — user queries, spinner, links
15
+ success: string; // positive — diff added, checkmarks
16
+ warning: string; // attention — tool titles, agent prompt
17
+ error: string; // negative — diff removed, errors
18
+ muted: string; // de-emphasized — info, context lines, borders
19
+
20
+ // ── True-color backgrounds (diff highlighting) ────────────
21
+ successBg: string; // subtle green tint for added lines
22
+ errorBg: string; // subtle red tint for removed lines
23
+ successBgEmph: string; // stronger green for changed tokens
24
+ errorBgEmph: string; // stronger red for changed tokens
25
+
26
+ // ── Style modifiers ───────────────────────────────────────
27
+ bold: string;
28
+ dim: string;
29
+ italic: string;
30
+ underline: string;
31
+ reset: string;
32
+ }
33
+
34
+ const defaultPalette: ColorPalette = {
35
+ accent: "\x1b[36m", // cyan
36
+ success: "\x1b[32m", // green
37
+ warning: "\x1b[33m", // yellow
38
+ error: "\x1b[31m", // red
39
+ muted: "\x1b[90m", // gray
40
+
41
+ successBg: "\x1b[48;2;34;92;43m",
42
+ errorBg: "\x1b[48;2;122;41;54m",
43
+ successBgEmph: "\x1b[48;2;56;166;96m",
44
+ errorBgEmph: "\x1b[48;2;179;89;107m",
45
+
46
+ bold: "\x1b[1m",
47
+ dim: "\x1b[2m",
48
+ italic: "\x1b[3m",
49
+ underline: "\x1b[4m",
50
+ reset: "\x1b[0m",
51
+ };
52
+
53
+ /** Active palette — import and use directly in components. */
54
+ export const palette: ColorPalette = { ...defaultPalette };
55
+
56
+ /** Override palette slots. Merges with current values. */
57
+ export function setPalette(overrides: Partial<ColorPalette>): void {
58
+ Object.assign(palette, overrides);
59
+ }
60
+
61
+ /** Reset palette to defaults. */
62
+ export function resetPalette(): void {
63
+ Object.assign(palette, defaultPalette);
64
+ }
@@ -0,0 +1,9 @@
1
+ /** Simple ref-counted counter. Increment/decrement never goes below zero. */
2
+ export class RefCounter {
3
+ private count = 0;
4
+ increment(): void { this.count++; }
5
+ decrement(): void { this.count = Math.max(0, this.count - 1); }
6
+ reset(): void { this.count = 0; }
7
+ get active(): boolean { return this.count > 0; }
8
+ get value(): number { return this.count; }
9
+ }
@@ -0,0 +1,17 @@
1
+ import * as fs from "node:fs";
2
+ import { rgPath as bundledRgPath } from "@vscode/ripgrep";
3
+
4
+ /**
5
+ * Resolve the ripgrep binary path. Prefers the version bundled via
6
+ * @vscode/ripgrep (downloaded by its postinstall hook). Falls back to plain
7
+ * "rg" so users with rg on PATH still work even if the postinstall failed
8
+ * (offline install, blocked egress, etc.).
9
+ */
10
+ export function resolveRgPath(): string {
11
+ try {
12
+ if (bundledRgPath && fs.existsSync(bundledRgPath)) return bundledRgPath;
13
+ } catch {
14
+ // fall through
15
+ }
16
+ return "rg";
17
+ }