agent-sh 0.1.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.
- package/README.md +659 -0
- package/dist/acp-client.d.ts +76 -0
- package/dist/acp-client.js +507 -0
- package/dist/context-manager.d.ts +45 -0
- package/dist/context-manager.js +405 -0
- package/dist/core.d.ts +41 -0
- package/dist/core.js +76 -0
- package/dist/event-bus.d.ts +140 -0
- package/dist/event-bus.js +79 -0
- package/dist/executor.d.ts +31 -0
- package/dist/executor.js +116 -0
- package/dist/extension-loader.d.ts +16 -0
- package/dist/extension-loader.js +164 -0
- package/dist/extensions/file-autocomplete.d.ts +2 -0
- package/dist/extensions/file-autocomplete.js +63 -0
- package/dist/extensions/shell-recall.d.ts +9 -0
- package/dist/extensions/shell-recall.js +8 -0
- package/dist/extensions/slash-commands.d.ts +2 -0
- package/dist/extensions/slash-commands.js +105 -0
- package/dist/extensions/tui-renderer.d.ts +2 -0
- package/dist/extensions/tui-renderer.js +354 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +159 -0
- package/dist/input-handler.d.ts +48 -0
- package/dist/input-handler.js +302 -0
- package/dist/output-parser.d.ts +55 -0
- package/dist/output-parser.js +166 -0
- package/dist/shell.d.ts +54 -0
- package/dist/shell.js +219 -0
- package/dist/types.d.ts +71 -0
- package/dist/types.js +1 -0
- package/dist/utils/ansi.d.ts +12 -0
- package/dist/utils/ansi.js +23 -0
- package/dist/utils/box-frame.d.ts +21 -0
- package/dist/utils/box-frame.js +60 -0
- package/dist/utils/diff-renderer.d.ts +20 -0
- package/dist/utils/diff-renderer.js +506 -0
- package/dist/utils/diff.d.ts +24 -0
- package/dist/utils/diff.js +122 -0
- package/dist/utils/file-watcher.d.ts +31 -0
- package/dist/utils/file-watcher.js +101 -0
- package/dist/utils/markdown.d.ts +39 -0
- package/dist/utils/markdown.js +248 -0
- package/dist/utils/palette.d.ts +32 -0
- package/dist/utils/palette.js +36 -0
- package/dist/utils/tool-display.d.ts +33 -0
- package/dist/utils/tool-display.js +141 -0
- package/examples/extensions/interactive-prompts.ts +161 -0
- package/examples/extensions/solarized-theme.ts +27 -0
- package/package.json +72 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
const SKIP_DIRS = new Set([
|
|
4
|
+
".git", "node_modules", "dist", "build", ".next",
|
|
5
|
+
"__pycache__", ".venv", "vendor", ".cache", ".turbo",
|
|
6
|
+
]);
|
|
7
|
+
const MAX_FILES = 200;
|
|
8
|
+
const MAX_FILE_SIZE = 100_000; // 100 KB
|
|
9
|
+
/**
|
|
10
|
+
* Snapshots the working directory before an agent prompt so that
|
|
11
|
+
* file modifications made by **any** method (ACP writeTextFile,
|
|
12
|
+
* the agent's own edit tools, shell commands, etc.) can be detected
|
|
13
|
+
* and shown as an interactive diff preview.
|
|
14
|
+
*/
|
|
15
|
+
export class FileWatcher {
|
|
16
|
+
cwd;
|
|
17
|
+
baseline = new Map();
|
|
18
|
+
constructor(cwd) {
|
|
19
|
+
this.cwd = cwd;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Recursively snapshot all text files in the working directory.
|
|
23
|
+
* Skips common non-source directories, binary files, and files
|
|
24
|
+
* exceeding MAX_FILE_SIZE. Capped at MAX_FILES entries.
|
|
25
|
+
*/
|
|
26
|
+
async snapshot() {
|
|
27
|
+
this.baseline.clear();
|
|
28
|
+
let count = 0;
|
|
29
|
+
const walk = async (dir) => {
|
|
30
|
+
if (count >= MAX_FILES)
|
|
31
|
+
return;
|
|
32
|
+
let entries;
|
|
33
|
+
try {
|
|
34
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
for (const entry of entries) {
|
|
40
|
+
if (count >= MAX_FILES)
|
|
41
|
+
return;
|
|
42
|
+
const full = path.join(dir, entry.name);
|
|
43
|
+
if (entry.isDirectory()) {
|
|
44
|
+
if (!SKIP_DIRS.has(entry.name))
|
|
45
|
+
await walk(full);
|
|
46
|
+
}
|
|
47
|
+
else if (entry.isFile()) {
|
|
48
|
+
try {
|
|
49
|
+
const stat = await fs.stat(full);
|
|
50
|
+
if (stat.size > MAX_FILE_SIZE || stat.size === 0)
|
|
51
|
+
continue;
|
|
52
|
+
const content = await fs.readFile(full, "utf-8");
|
|
53
|
+
this.baseline.set(full, content);
|
|
54
|
+
count++;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// Skip binary / unreadable files
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
await walk(this.cwd);
|
|
63
|
+
}
|
|
64
|
+
/** Update baseline after a write is approved (avoids double-reporting). */
|
|
65
|
+
approve(absPath, content) {
|
|
66
|
+
this.baseline.set(absPath, content);
|
|
67
|
+
}
|
|
68
|
+
/** Detect all tracked files whose on-disk content differs from baseline. */
|
|
69
|
+
async detectChanges() {
|
|
70
|
+
const changes = [];
|
|
71
|
+
for (const [absPath, baseline] of this.baseline) {
|
|
72
|
+
let after;
|
|
73
|
+
try {
|
|
74
|
+
after = await fs.readFile(absPath, "utf-8");
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (baseline !== after) {
|
|
80
|
+
changes.push({
|
|
81
|
+
path: absPath,
|
|
82
|
+
relPath: path.relative(this.cwd, absPath),
|
|
83
|
+
before: baseline,
|
|
84
|
+
after,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return changes;
|
|
89
|
+
}
|
|
90
|
+
/** Revert a file to its baseline content. */
|
|
91
|
+
async revert(absPath) {
|
|
92
|
+
const baseline = this.baseline.get(absPath);
|
|
93
|
+
if (baseline !== undefined) {
|
|
94
|
+
await fs.writeFile(absPath, baseline, "utf-8");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** Clear all tracking state. */
|
|
98
|
+
reset() {
|
|
99
|
+
this.baseline.clear();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Word-wrap a string (which may contain ANSI codes) to a maximum visible width.
|
|
3
|
+
* Returns an array of lines, each fitting within `maxWidth` visible characters.
|
|
4
|
+
*/
|
|
5
|
+
export declare function wrapLine(text: string, maxWidth: number): string[];
|
|
6
|
+
/**
|
|
7
|
+
* Streaming markdown renderer that processes chunks of text,
|
|
8
|
+
* renders complete lines with ANSI formatting, and wraps output
|
|
9
|
+
* in a bordered box.
|
|
10
|
+
*/
|
|
11
|
+
export declare class MarkdownRenderer {
|
|
12
|
+
private buffer;
|
|
13
|
+
private inCodeBlock;
|
|
14
|
+
private codeLanguage;
|
|
15
|
+
private codeLines;
|
|
16
|
+
private contentWidth;
|
|
17
|
+
private firstLine;
|
|
18
|
+
constructor(terminalWidth?: number);
|
|
19
|
+
/**
|
|
20
|
+
* Push a streaming chunk. Complete lines are rendered immediately;
|
|
21
|
+
* incomplete trailing text stays in the buffer.
|
|
22
|
+
*/
|
|
23
|
+
push(chunk: string): void;
|
|
24
|
+
/**
|
|
25
|
+
* Flush any remaining text in the buffer (called when the response ends).
|
|
26
|
+
*/
|
|
27
|
+
flush(): void;
|
|
28
|
+
printTopBorder(): void;
|
|
29
|
+
printBottomBorder(): void;
|
|
30
|
+
private processBuffer;
|
|
31
|
+
private processLine;
|
|
32
|
+
private renderLine;
|
|
33
|
+
private renderInline;
|
|
34
|
+
private renderCodeBlock;
|
|
35
|
+
/**
|
|
36
|
+
* Write a single line with a subtle left indent.
|
|
37
|
+
*/
|
|
38
|
+
writeLine(text: string): void;
|
|
39
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { highlight } from "cli-highlight";
|
|
2
|
+
import { visibleLen } from "./ansi.js";
|
|
3
|
+
import { palette as p } from "./palette.js";
|
|
4
|
+
const MAX_CONTENT_WIDTH = 90;
|
|
5
|
+
/**
|
|
6
|
+
* Word-wrap a string (which may contain ANSI codes) to a maximum visible width.
|
|
7
|
+
* Returns an array of lines, each fitting within `maxWidth` visible characters.
|
|
8
|
+
*/
|
|
9
|
+
export function wrapLine(text, maxWidth) {
|
|
10
|
+
if (visibleLen(text) <= maxWidth)
|
|
11
|
+
return [text];
|
|
12
|
+
const result = [];
|
|
13
|
+
// Split into segments: ANSI codes and visible text
|
|
14
|
+
const segments = text.match(/(\x1b\[[^m]*m|[^\x1b]+)/g) || [text];
|
|
15
|
+
let currentLine = "";
|
|
16
|
+
let currentWidth = 0;
|
|
17
|
+
let activeStyles = ""; // track ANSI styles to reapply after wraps
|
|
18
|
+
for (const seg of segments) {
|
|
19
|
+
if (seg.startsWith("\x1b[")) {
|
|
20
|
+
// ANSI code — track it, add to current line
|
|
21
|
+
currentLine += seg;
|
|
22
|
+
if (seg === p.reset) {
|
|
23
|
+
activeStyles = "";
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
activeStyles += seg;
|
|
27
|
+
}
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
// Visible text — split into words
|
|
31
|
+
const words = seg.split(/( +)/);
|
|
32
|
+
for (const word of words) {
|
|
33
|
+
if (word.length === 0)
|
|
34
|
+
continue;
|
|
35
|
+
if (currentWidth + word.length <= maxWidth) {
|
|
36
|
+
currentLine += word;
|
|
37
|
+
currentWidth += word.length;
|
|
38
|
+
}
|
|
39
|
+
else if (currentWidth === 0) {
|
|
40
|
+
// Single word longer than maxWidth — hard break
|
|
41
|
+
let remaining = word;
|
|
42
|
+
while (remaining.length > 0) {
|
|
43
|
+
const chunk = remaining.slice(0, maxWidth - currentWidth || maxWidth);
|
|
44
|
+
remaining = remaining.slice(chunk.length);
|
|
45
|
+
currentLine += chunk;
|
|
46
|
+
if (remaining.length > 0) {
|
|
47
|
+
result.push(currentLine + p.reset);
|
|
48
|
+
currentLine = activeStyles;
|
|
49
|
+
currentWidth = 0;
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
currentWidth += chunk.length;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
// Wrap to next line
|
|
58
|
+
result.push(currentLine + p.reset);
|
|
59
|
+
currentLine = activeStyles;
|
|
60
|
+
currentWidth = 0;
|
|
61
|
+
// Skip leading spaces on new line
|
|
62
|
+
const trimmed = word.replace(/^ +/, "");
|
|
63
|
+
currentLine += trimmed;
|
|
64
|
+
currentWidth = trimmed.length;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (currentLine.length > 0) {
|
|
69
|
+
result.push(currentLine);
|
|
70
|
+
}
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Streaming markdown renderer that processes chunks of text,
|
|
75
|
+
* renders complete lines with ANSI formatting, and wraps output
|
|
76
|
+
* in a bordered box.
|
|
77
|
+
*/
|
|
78
|
+
export class MarkdownRenderer {
|
|
79
|
+
buffer = "";
|
|
80
|
+
inCodeBlock = false;
|
|
81
|
+
codeLanguage = "";
|
|
82
|
+
codeLines = [];
|
|
83
|
+
contentWidth;
|
|
84
|
+
firstLine = true;
|
|
85
|
+
constructor(terminalWidth) {
|
|
86
|
+
const termW = terminalWidth ?? (process.stdout.columns || 100);
|
|
87
|
+
// 2-char left indent for content
|
|
88
|
+
this.contentWidth = Math.min(MAX_CONTENT_WIDTH, termW - 2);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Push a streaming chunk. Complete lines are rendered immediately;
|
|
92
|
+
* incomplete trailing text stays in the buffer.
|
|
93
|
+
*/
|
|
94
|
+
push(chunk) {
|
|
95
|
+
this.buffer += chunk;
|
|
96
|
+
this.processBuffer();
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Flush any remaining text in the buffer (called when the response ends).
|
|
100
|
+
*/
|
|
101
|
+
flush() {
|
|
102
|
+
if (this.inCodeBlock) {
|
|
103
|
+
this.renderCodeBlock();
|
|
104
|
+
}
|
|
105
|
+
if (this.buffer.length > 0) {
|
|
106
|
+
this.processLine(this.buffer);
|
|
107
|
+
this.buffer = "";
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
printTopBorder() {
|
|
111
|
+
const w = Math.min(this.contentWidth, 40);
|
|
112
|
+
process.stdout.write(`${p.dim}${p.accent}${"─".repeat(w)}${p.reset}\n`);
|
|
113
|
+
this.firstLine = true;
|
|
114
|
+
}
|
|
115
|
+
printBottomBorder() {
|
|
116
|
+
const w = Math.min(this.contentWidth, 40);
|
|
117
|
+
process.stdout.write(`${p.dim}${p.accent}${"─".repeat(w)}${p.reset}\n`);
|
|
118
|
+
}
|
|
119
|
+
processBuffer() {
|
|
120
|
+
const lines = this.buffer.split("\n");
|
|
121
|
+
this.buffer = lines.pop();
|
|
122
|
+
for (const line of lines) {
|
|
123
|
+
this.processLine(line);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
processLine(line) {
|
|
127
|
+
// Code fence detection
|
|
128
|
+
const fenceMatch = line.match(/^(\s*)```(\w*)/);
|
|
129
|
+
if (fenceMatch) {
|
|
130
|
+
if (!this.inCodeBlock) {
|
|
131
|
+
this.inCodeBlock = true;
|
|
132
|
+
this.codeLanguage = fenceMatch[2] || "";
|
|
133
|
+
this.codeLines = [];
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
this.inCodeBlock = false;
|
|
138
|
+
this.renderCodeBlock();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (this.inCodeBlock) {
|
|
143
|
+
this.codeLines.push(line);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const rendered = this.renderLine(line);
|
|
147
|
+
// Word-wrap and output each wrapped line
|
|
148
|
+
const wrapped = wrapLine(rendered, this.contentWidth);
|
|
149
|
+
for (const wl of wrapped) {
|
|
150
|
+
this.writeLine(wl);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
renderLine(line) {
|
|
154
|
+
if (line.trim() === "")
|
|
155
|
+
return "";
|
|
156
|
+
// Headings
|
|
157
|
+
const h1 = line.match(/^# (.+)/);
|
|
158
|
+
if (h1)
|
|
159
|
+
return `${p.bold}${p.warning}${h1[1]}${p.reset}`;
|
|
160
|
+
const h2 = line.match(/^## (.+)/);
|
|
161
|
+
if (h2)
|
|
162
|
+
return `${p.bold}${p.accent}${h2[1]}${p.reset}`;
|
|
163
|
+
const h3 = line.match(/^### (.+)/);
|
|
164
|
+
if (h3)
|
|
165
|
+
return `${p.bold}${h3[1]}${p.reset}`;
|
|
166
|
+
const h4 = line.match(/^#{4,} (.+)/);
|
|
167
|
+
if (h4)
|
|
168
|
+
return `${p.bold}${h4[1]}${p.reset}`;
|
|
169
|
+
// Horizontal rule
|
|
170
|
+
if (/^(-{3,}|_{3,}|\*{3,})\s*$/.test(line)) {
|
|
171
|
+
return `${p.muted}${"─".repeat(this.contentWidth)}${p.reset}`;
|
|
172
|
+
}
|
|
173
|
+
// Blockquote
|
|
174
|
+
const bq = line.match(/^>\s?(.*)/);
|
|
175
|
+
if (bq)
|
|
176
|
+
return `${p.muted}│${p.reset} ${p.dim}${p.italic}${this.renderInline(bq[1] || "")}${p.reset}`;
|
|
177
|
+
// Unordered list
|
|
178
|
+
const ul = line.match(/^(\s*)[*\-+]\s+(.*)/);
|
|
179
|
+
if (ul) {
|
|
180
|
+
const indent = ul[1] || "";
|
|
181
|
+
return `${indent} ${p.accent}*${p.reset} ${this.renderInline(ul[2] || "")}`;
|
|
182
|
+
}
|
|
183
|
+
// Ordered list
|
|
184
|
+
const ol = line.match(/^(\s*)(\d+)[.)]\s+(.*)/);
|
|
185
|
+
if (ol) {
|
|
186
|
+
const indent = ol[1] || "";
|
|
187
|
+
return `${indent} ${p.accent}${ol[2]}.${p.reset} ${this.renderInline(ol[3] || "")}`;
|
|
188
|
+
}
|
|
189
|
+
return this.renderInline(line);
|
|
190
|
+
}
|
|
191
|
+
renderInline(text) {
|
|
192
|
+
// Inline code
|
|
193
|
+
text = text.replace(/`([^`]+)`/g, `${p.accent}$1${p.reset}`);
|
|
194
|
+
// Bold + italic
|
|
195
|
+
text = text.replace(/\*\*\*(.+?)\*\*\*/g, `${p.bold}${p.italic}$1${p.reset}`);
|
|
196
|
+
// Bold
|
|
197
|
+
text = text.replace(/\*\*(.+?)\*\*/g, `${p.bold}$1${p.reset}`);
|
|
198
|
+
text = text.replace(/__(.+?)__/g, `${p.bold}$1${p.reset}`);
|
|
199
|
+
// Italic
|
|
200
|
+
text = text.replace(/\*(.+?)\*/g, `${p.italic}$1${p.reset}`);
|
|
201
|
+
text = text.replace(/_(.+?)_/g, `${p.italic}$1${p.reset}`);
|
|
202
|
+
// Strikethrough
|
|
203
|
+
text = text.replace(/~~(.+?)~~/g, `${p.dim}$1${p.reset}`);
|
|
204
|
+
// Links
|
|
205
|
+
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, `$1 ${p.muted}${p.underline}($2)${p.reset}`);
|
|
206
|
+
return text;
|
|
207
|
+
}
|
|
208
|
+
renderCodeBlock() {
|
|
209
|
+
const code = this.codeLines.join("\n");
|
|
210
|
+
const lang = this.codeLanguage;
|
|
211
|
+
if (lang) {
|
|
212
|
+
this.writeLine(`${p.dim}${lang}${p.reset}`);
|
|
213
|
+
}
|
|
214
|
+
let highlighted;
|
|
215
|
+
try {
|
|
216
|
+
highlighted = highlight(code, { language: lang || undefined });
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
highlighted = `${p.success}${code}${p.reset}`;
|
|
220
|
+
}
|
|
221
|
+
// Code blocks get indented, and each line is individually wrapped
|
|
222
|
+
for (const line of highlighted.split("\n")) {
|
|
223
|
+
const indented = ` ${line}`;
|
|
224
|
+
const wrapped = wrapLine(indented, this.contentWidth);
|
|
225
|
+
for (const wl of wrapped) {
|
|
226
|
+
this.writeLine(wl);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
this.codeLanguage = "";
|
|
230
|
+
this.codeLines = [];
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Write a single line with a subtle left indent.
|
|
234
|
+
*/
|
|
235
|
+
writeLine(text) {
|
|
236
|
+
if (this.firstLine && visibleLen(text) === 0)
|
|
237
|
+
return;
|
|
238
|
+
this.firstLine = false;
|
|
239
|
+
process.stdout.write(` ${text}\n`);
|
|
240
|
+
if (process.stdout.writable) {
|
|
241
|
+
try {
|
|
242
|
+
process.stdout.write('');
|
|
243
|
+
}
|
|
244
|
+
catch (e) {
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
export interface ColorPalette {
|
|
12
|
+
accent: string;
|
|
13
|
+
success: string;
|
|
14
|
+
warning: string;
|
|
15
|
+
error: string;
|
|
16
|
+
muted: string;
|
|
17
|
+
successBg: string;
|
|
18
|
+
errorBg: string;
|
|
19
|
+
successBgEmph: string;
|
|
20
|
+
errorBgEmph: string;
|
|
21
|
+
bold: string;
|
|
22
|
+
dim: string;
|
|
23
|
+
italic: string;
|
|
24
|
+
underline: string;
|
|
25
|
+
reset: string;
|
|
26
|
+
}
|
|
27
|
+
/** Active palette — import and use directly in components. */
|
|
28
|
+
export declare const palette: ColorPalette;
|
|
29
|
+
/** Override palette slots. Merges with current values. */
|
|
30
|
+
export declare function setPalette(overrides: Partial<ColorPalette>): void;
|
|
31
|
+
/** Reset palette to defaults. */
|
|
32
|
+
export declare function resetPalette(): void;
|
|
@@ -0,0 +1,36 @@
|
|
|
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
|
+
const defaultPalette = {
|
|
12
|
+
accent: "\x1b[36m", // cyan
|
|
13
|
+
success: "\x1b[32m", // green
|
|
14
|
+
warning: "\x1b[33m", // yellow
|
|
15
|
+
error: "\x1b[31m", // red
|
|
16
|
+
muted: "\x1b[90m", // gray
|
|
17
|
+
successBg: "\x1b[48;2;0;60;0m",
|
|
18
|
+
errorBg: "\x1b[48;2;50;0;0m",
|
|
19
|
+
successBgEmph: "\x1b[48;2;0;112;0m",
|
|
20
|
+
errorBgEmph: "\x1b[48;2;90;0;0m",
|
|
21
|
+
bold: "\x1b[1m",
|
|
22
|
+
dim: "\x1b[2m",
|
|
23
|
+
italic: "\x1b[3m",
|
|
24
|
+
underline: "\x1b[4m",
|
|
25
|
+
reset: "\x1b[0m",
|
|
26
|
+
};
|
|
27
|
+
/** Active palette — import and use directly in components. */
|
|
28
|
+
export const palette = { ...defaultPalette };
|
|
29
|
+
/** Override palette slots. Merges with current values. */
|
|
30
|
+
export function setPalette(overrides) {
|
|
31
|
+
Object.assign(palette, overrides);
|
|
32
|
+
}
|
|
33
|
+
/** Reset palette to defaults. */
|
|
34
|
+
export function resetPalette() {
|
|
35
|
+
Object.assign(palette, defaultPalette);
|
|
36
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export type ToolDisplayMode = "full" | "compact" | "summary";
|
|
2
|
+
export interface ToolCallRender {
|
|
3
|
+
/** The tool title (e.g. "Read file", "Bash command"). */
|
|
4
|
+
title: string;
|
|
5
|
+
/** Optional command string for bash-like tools. */
|
|
6
|
+
command?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface ToolResultRender {
|
|
9
|
+
exitCode: number | null;
|
|
10
|
+
/** Output lines from the tool. */
|
|
11
|
+
outputLines?: string[];
|
|
12
|
+
/** Maximum output lines to show. Default 10. */
|
|
13
|
+
maxOutputLines?: number;
|
|
14
|
+
}
|
|
15
|
+
export declare function isQuietCommand(command: string): boolean;
|
|
16
|
+
export declare function selectToolDisplayMode(width: number): ToolDisplayMode;
|
|
17
|
+
export declare function renderToolCall(tool: ToolCallRender, width: number): string[];
|
|
18
|
+
export declare function renderToolResult(result: ToolResultRender, width: number): string[];
|
|
19
|
+
export declare function formatElapsed(ms: number): string;
|
|
20
|
+
export interface SpinnerState {
|
|
21
|
+
frame: number;
|
|
22
|
+
startTime: number;
|
|
23
|
+
interval: ReturnType<typeof setInterval> | null;
|
|
24
|
+
}
|
|
25
|
+
export declare function createSpinner(): SpinnerState;
|
|
26
|
+
/**
|
|
27
|
+
* Start a spinner that writes to stdout on the current line.
|
|
28
|
+
* Returns the SpinnerState for later stopping.
|
|
29
|
+
*/
|
|
30
|
+
export declare function startSpinner(label: string, opts?: {
|
|
31
|
+
color?: string;
|
|
32
|
+
}): SpinnerState;
|
|
33
|
+
export declare function stopSpinner(state: SpinnerState): void;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool display renderer with elapsed timer and width-adaptive output.
|
|
3
|
+
*
|
|
4
|
+
* Follows the render(width) -> string[] protocol for completed tools.
|
|
5
|
+
* Also provides a spinner/timer component for in-progress tools.
|
|
6
|
+
*/
|
|
7
|
+
import { visibleLen } from "./ansi.js";
|
|
8
|
+
import { palette as p } from "./palette.js";
|
|
9
|
+
// ── Quiet command detection ──────────────────────────────────────
|
|
10
|
+
const QUIET_PATTERNS = [
|
|
11
|
+
/^cd\b/,
|
|
12
|
+
/^mkdir\b/,
|
|
13
|
+
/^touch\b/,
|
|
14
|
+
/^rm\b/,
|
|
15
|
+
/^cp\b/,
|
|
16
|
+
/^mv\b/,
|
|
17
|
+
/^ln\b/,
|
|
18
|
+
/^chmod\b/,
|
|
19
|
+
/^chown\b/,
|
|
20
|
+
/^git\s+(add|checkout|branch|switch|stash|tag|config)\b/,
|
|
21
|
+
/^npm\s+(install|ci|uninstall)\b/,
|
|
22
|
+
/^yarn\s+(add|remove|install)\b/,
|
|
23
|
+
/^pnpm\s+(add|remove|install)\b/,
|
|
24
|
+
/^export\b/,
|
|
25
|
+
/^source\b/,
|
|
26
|
+
/^\.\s/,
|
|
27
|
+
];
|
|
28
|
+
export function isQuietCommand(command) {
|
|
29
|
+
const trimmed = command.trim();
|
|
30
|
+
return QUIET_PATTERNS.some((p) => p.test(trimmed));
|
|
31
|
+
}
|
|
32
|
+
// ── Mode selection ───────────────────────────────────────────────
|
|
33
|
+
export function selectToolDisplayMode(width) {
|
|
34
|
+
if (width >= 80)
|
|
35
|
+
return "full";
|
|
36
|
+
if (width >= 40)
|
|
37
|
+
return "compact";
|
|
38
|
+
return "summary";
|
|
39
|
+
}
|
|
40
|
+
// ── Tool call rendering ──────────────────────────────────────────
|
|
41
|
+
export function renderToolCall(tool, width) {
|
|
42
|
+
const mode = selectToolDisplayMode(width);
|
|
43
|
+
if (mode === "summary") {
|
|
44
|
+
const text = truncateVisible(`▶ ${tool.title}`, width);
|
|
45
|
+
return [`${p.warning}${text}${p.reset}`];
|
|
46
|
+
}
|
|
47
|
+
const lines = [];
|
|
48
|
+
lines.push(`${p.warning}${p.bold}▶ ${tool.title}${p.reset}`);
|
|
49
|
+
if (tool.command && mode === "full") {
|
|
50
|
+
const maxCmdW = Math.max(1, width - 4);
|
|
51
|
+
const cmd = tool.command.length > maxCmdW
|
|
52
|
+
? tool.command.slice(0, maxCmdW - 1) + "…"
|
|
53
|
+
: tool.command;
|
|
54
|
+
lines.push(` ${p.dim}$ ${cmd}${p.reset}`);
|
|
55
|
+
}
|
|
56
|
+
return lines;
|
|
57
|
+
}
|
|
58
|
+
// ── Tool result rendering ────────────────────────────────────────
|
|
59
|
+
export function renderToolResult(result, width) {
|
|
60
|
+
const mode = selectToolDisplayMode(width);
|
|
61
|
+
const lines = [];
|
|
62
|
+
// Status indicator
|
|
63
|
+
if (result.exitCode === null) {
|
|
64
|
+
lines.push(` ${p.muted}(timed out)${p.reset}`);
|
|
65
|
+
}
|
|
66
|
+
else if (result.exitCode === 0) {
|
|
67
|
+
lines.push(` ${p.success}✓${p.reset}`);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
lines.push(` ${p.error}✗ exit ${result.exitCode}${p.reset}`);
|
|
71
|
+
}
|
|
72
|
+
// Output preview (full mode only)
|
|
73
|
+
if (mode === "full" && result.outputLines && result.outputLines.length > 0) {
|
|
74
|
+
const maxLines = result.maxOutputLines ?? 10;
|
|
75
|
+
const total = result.outputLines.length;
|
|
76
|
+
const shown = result.outputLines.slice(0, maxLines);
|
|
77
|
+
const maxTextW = Math.max(1, width - 6);
|
|
78
|
+
for (const line of shown) {
|
|
79
|
+
const text = line.length > maxTextW
|
|
80
|
+
? line.slice(0, maxTextW - 1) + "…"
|
|
81
|
+
: line;
|
|
82
|
+
lines.push(` ${p.dim} ${text}${p.reset}`);
|
|
83
|
+
}
|
|
84
|
+
if (total > maxLines) {
|
|
85
|
+
lines.push(` ${p.dim} … ${total - maxLines} more lines${p.reset}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return lines;
|
|
89
|
+
}
|
|
90
|
+
// ── Elapsed timer ────────────────────────────────────────────────
|
|
91
|
+
export function formatElapsed(ms) {
|
|
92
|
+
if (ms < 1000)
|
|
93
|
+
return "";
|
|
94
|
+
const s = Math.floor(ms / 1000);
|
|
95
|
+
if (s < 60)
|
|
96
|
+
return `${s}s`;
|
|
97
|
+
const m = Math.floor(s / 60);
|
|
98
|
+
const rs = s % 60;
|
|
99
|
+
if (m < 60)
|
|
100
|
+
return rs > 0 ? `${m}m ${rs}s` : `${m}m`;
|
|
101
|
+
const h = Math.floor(m / 60);
|
|
102
|
+
const rm = m % 60;
|
|
103
|
+
return rm > 0 ? `${h}h ${rm}m` : `${h}h`;
|
|
104
|
+
}
|
|
105
|
+
// ── Spinner with elapsed timer ───────────────────────────────────
|
|
106
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
107
|
+
export function createSpinner() {
|
|
108
|
+
return { frame: 0, startTime: Date.now(), interval: null };
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Start a spinner that writes to stdout on the current line.
|
|
112
|
+
* Returns the SpinnerState for later stopping.
|
|
113
|
+
*/
|
|
114
|
+
export function startSpinner(label, opts) {
|
|
115
|
+
const state = createSpinner();
|
|
116
|
+
const color = opts?.color ?? p.accent;
|
|
117
|
+
state.interval = setInterval(() => {
|
|
118
|
+
const frame = SPINNER_FRAMES[state.frame % SPINNER_FRAMES.length];
|
|
119
|
+
const elapsed = formatElapsed(Date.now() - state.startTime);
|
|
120
|
+
const timer = elapsed ? ` ${p.dim}${elapsed}${p.reset}` : "";
|
|
121
|
+
process.stdout.write(`\r ${color}${frame} ${label}...${p.reset}${timer}\x1b[K`);
|
|
122
|
+
state.frame++;
|
|
123
|
+
}, 80);
|
|
124
|
+
return state;
|
|
125
|
+
}
|
|
126
|
+
export function stopSpinner(state) {
|
|
127
|
+
if (state.interval) {
|
|
128
|
+
clearInterval(state.interval);
|
|
129
|
+
state.interval = null;
|
|
130
|
+
process.stdout.write("\r\x1b[2K");
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// ── Helpers ──────────────────────────────────────────────────────
|
|
134
|
+
function truncateVisible(text, maxWidth) {
|
|
135
|
+
if (visibleLen(text) <= maxWidth)
|
|
136
|
+
return text;
|
|
137
|
+
// Simple truncation for plain text (no ANSI)
|
|
138
|
+
if (maxWidth <= 1)
|
|
139
|
+
return "…";
|
|
140
|
+
return text.slice(0, maxWidth - 1) + "…";
|
|
141
|
+
}
|