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.
- package/dist/agent/agent-loop.js +11 -8
- package/dist/agent/events.d.ts +4 -0
- package/docs/README.md +14 -0
- package/docs/agent.md +398 -0
- package/docs/architecture.md +196 -0
- package/docs/context-management.md +200 -0
- package/docs/extensions.md +951 -0
- package/docs/library.md +84 -0
- package/docs/troubleshooting.md +65 -0
- package/docs/tui-composition.md +294 -0
- package/docs/usage.md +306 -0
- package/examples/extensions/ash-scheme/package.json +1 -1
- package/examples/extensions/ashi/EXTENDING.md +2 -2
- package/examples/extensions/ashi/README.md +2 -2
- package/examples/extensions/ashi/docs/ui-surface-protocol.md +1 -1
- package/examples/extensions/ashi/package.json +5 -3
- package/examples/extensions/ashi/src/chat/tool-group.ts +3 -2
- package/examples/extensions/ashi/src/cli.ts +9 -8
- package/examples/extensions/ashi/src/dialogs.ts +16 -1
- package/examples/extensions/ashi/src/events.ts +1 -0
- package/examples/extensions/ashi/src/frontend.ts +26 -6
- package/examples/extensions/ashi/src/renderer.ts +24 -4
- package/examples/extensions/ashi/src/renderers/pi-tui/schema-mount.ts +4 -3
- package/examples/extensions/ashi/src/renderers/pi-tui/tool-group.ts +5 -8
- package/examples/extensions/ashi/src/ui.ts +11 -0
- package/examples/extensions/ashi-ink/package.json +2 -2
- package/examples/extensions/claude-code-bridge/package.json +1 -1
- package/examples/extensions/opencode-bridge/package.json +1 -1
- package/package.json +3 -1
- package/src/agent/agent-loop.ts +1566 -0
- package/src/agent/entry-format.ts +19 -0
- package/src/agent/events.ts +153 -0
- package/src/agent/extensions/rolling-history/constants.ts +1 -0
- package/src/agent/extensions/rolling-history/index.ts +202 -0
- package/src/agent/extensions/rolling-history/recall.ts +131 -0
- package/src/agent/extensions/rolling-history/strategy.ts +404 -0
- package/src/agent/host-types.ts +192 -0
- package/src/agent/index.ts +591 -0
- package/src/agent/live-view.ts +279 -0
- package/src/agent/llm-client.ts +111 -0
- package/src/agent/llm-facade.ts +43 -0
- package/src/agent/normalize-args.ts +61 -0
- package/src/agent/nuclear-form.ts +382 -0
- package/src/agent/providers/deepseek.ts +39 -0
- package/src/agent/providers/ollama.ts +92 -0
- package/src/agent/providers/openai-compatible.ts +36 -0
- package/src/agent/providers/openai.ts +52 -0
- package/src/agent/providers/opencode.ts +142 -0
- package/src/agent/providers/openrouter.ts +105 -0
- package/src/agent/providers/zai-coding-plan.ts +33 -0
- package/src/agent/session-store.ts +336 -0
- package/src/agent/skills.ts +228 -0
- package/src/agent/store.ts +310 -0
- package/src/agent/subagent.ts +305 -0
- package/src/agent/system-prompt.ts +151 -0
- package/src/agent/token-budget.ts +12 -0
- package/src/agent/tool-protocol.ts +722 -0
- package/src/agent/tool-registry.ts +66 -0
- package/src/agent/tools/bash.ts +95 -0
- package/src/agent/tools/edit-file.ts +154 -0
- package/src/agent/tools/expand-home.ts +7 -0
- package/src/agent/tools/glob.ts +108 -0
- package/src/agent/tools/grep.ts +228 -0
- package/src/agent/tools/list-skills.ts +37 -0
- package/src/agent/tools/ls.ts +81 -0
- package/src/agent/tools/pwsh.ts +140 -0
- package/src/agent/tools/read-file.ts +164 -0
- package/src/agent/tools/write-file.ts +72 -0
- package/src/agent/types.ts +149 -0
- package/src/cli/args.ts +91 -0
- package/src/cli/auth/cli.ts +244 -0
- package/src/cli/auth/discover.ts +52 -0
- package/src/cli/auth/keys.ts +143 -0
- package/src/cli/index.ts +295 -0
- package/src/cli/init.ts +74 -0
- package/src/cli/install.ts +439 -0
- package/src/cli/shell-env.ts +68 -0
- package/src/cli/subcommands.ts +24 -0
- package/src/core/event-bus.ts +252 -0
- package/src/core/extension-loader.ts +347 -0
- package/src/core/index.ts +152 -0
- package/src/core/settings.ts +398 -0
- package/src/core/types.ts +61 -0
- package/src/extensions/file-autocomplete.ts +71 -0
- package/src/extensions/index.ts +38 -0
- package/src/extensions/slash-commands/events.ts +14 -0
- package/src/extensions/slash-commands/index.ts +269 -0
- package/src/shell/events.ts +73 -0
- package/src/shell/host-types.ts +150 -0
- package/src/shell/index.ts +159 -0
- package/src/shell/input-handler.ts +505 -0
- package/src/shell/output-parser.ts +156 -0
- package/src/shell/shell-context.ts +193 -0
- package/src/shell/shell.ts +414 -0
- package/src/shell/strategies/bash.ts +83 -0
- package/src/shell/strategies/fish.ts +77 -0
- package/src/shell/strategies/index.ts +24 -0
- package/src/shell/strategies/types.ts +64 -0
- package/src/shell/strategies/zsh.ts +92 -0
- package/src/shell/terminal.ts +124 -0
- package/src/shell/tui-input-view.ts +222 -0
- package/src/shell/tui-renderer.ts +1126 -0
- package/src/utils/ansi.ts +140 -0
- package/src/utils/box-frame.ts +138 -0
- package/src/utils/compositor.ts +157 -0
- package/src/utils/diff-renderer.ts +829 -0
- package/src/utils/diff.ts +244 -0
- package/src/utils/executor.ts +305 -0
- package/src/utils/file-watcher.ts +110 -0
- package/src/utils/floating-panel.ts +1160 -0
- package/src/utils/handler-registry.ts +110 -0
- package/src/utils/line-editor.ts +636 -0
- package/src/utils/markdown.ts +437 -0
- package/src/utils/message-utils.ts +113 -0
- package/src/utils/package-version.ts +12 -0
- package/src/utils/palette.ts +64 -0
- package/src/utils/ref-counter.ts +9 -0
- package/src/utils/ripgrep-path.ts +17 -0
- package/src/utils/shell-output-spill.ts +76 -0
- package/src/utils/stream-transform.ts +292 -0
- package/src/utils/terminal-buffer.ts +213 -0
- package/src/utils/tool-display.ts +315 -0
- package/src/utils/tool-interactive.ts +71 -0
- 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
|
+
}
|