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.
Files changed (50) hide show
  1. package/README.md +659 -0
  2. package/dist/acp-client.d.ts +76 -0
  3. package/dist/acp-client.js +507 -0
  4. package/dist/context-manager.d.ts +45 -0
  5. package/dist/context-manager.js +405 -0
  6. package/dist/core.d.ts +41 -0
  7. package/dist/core.js +76 -0
  8. package/dist/event-bus.d.ts +140 -0
  9. package/dist/event-bus.js +79 -0
  10. package/dist/executor.d.ts +31 -0
  11. package/dist/executor.js +116 -0
  12. package/dist/extension-loader.d.ts +16 -0
  13. package/dist/extension-loader.js +164 -0
  14. package/dist/extensions/file-autocomplete.d.ts +2 -0
  15. package/dist/extensions/file-autocomplete.js +63 -0
  16. package/dist/extensions/shell-recall.d.ts +9 -0
  17. package/dist/extensions/shell-recall.js +8 -0
  18. package/dist/extensions/slash-commands.d.ts +2 -0
  19. package/dist/extensions/slash-commands.js +105 -0
  20. package/dist/extensions/tui-renderer.d.ts +2 -0
  21. package/dist/extensions/tui-renderer.js +354 -0
  22. package/dist/index.d.ts +2 -0
  23. package/dist/index.js +159 -0
  24. package/dist/input-handler.d.ts +48 -0
  25. package/dist/input-handler.js +302 -0
  26. package/dist/output-parser.d.ts +55 -0
  27. package/dist/output-parser.js +166 -0
  28. package/dist/shell.d.ts +54 -0
  29. package/dist/shell.js +219 -0
  30. package/dist/types.d.ts +71 -0
  31. package/dist/types.js +1 -0
  32. package/dist/utils/ansi.d.ts +12 -0
  33. package/dist/utils/ansi.js +23 -0
  34. package/dist/utils/box-frame.d.ts +21 -0
  35. package/dist/utils/box-frame.js +60 -0
  36. package/dist/utils/diff-renderer.d.ts +20 -0
  37. package/dist/utils/diff-renderer.js +506 -0
  38. package/dist/utils/diff.d.ts +24 -0
  39. package/dist/utils/diff.js +122 -0
  40. package/dist/utils/file-watcher.d.ts +31 -0
  41. package/dist/utils/file-watcher.js +101 -0
  42. package/dist/utils/markdown.d.ts +39 -0
  43. package/dist/utils/markdown.js +248 -0
  44. package/dist/utils/palette.d.ts +32 -0
  45. package/dist/utils/palette.js +36 -0
  46. package/dist/utils/tool-display.d.ts +33 -0
  47. package/dist/utils/tool-display.js +141 -0
  48. package/examples/extensions/interactive-prompts.ts +161 -0
  49. package/examples/extensions/solarized-theme.ts +27 -0
  50. 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
+ }