agent-sh 0.15.4 → 0.15.6
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/types.d.ts +4 -1
- package/dist/cli/shell-env.js +41 -2
- package/dist/shell/strategies/bash.js +6 -0
- package/dist/shell/strategies/fish.js +6 -0
- package/dist/shell/strategies/types.d.ts +1 -0
- package/dist/shell/strategies/zsh.js +6 -0
- package/dist/shell/tui-renderer.js +1 -0
- package/dist/utils/diff-renderer.js +65 -30
- package/dist/utils/markdown.js +37 -23
- package/examples/extensions/command-suggest.ts +86 -0
- package/package.json +5 -5
- package/src/agent/types.ts +4 -1
- package/src/cli/shell-env.ts +44 -4
- package/src/shell/strategies/bash.ts +6 -0
- package/src/shell/strategies/fish.ts +6 -0
- package/src/shell/strategies/types.ts +2 -0
- package/src/shell/strategies/zsh.ts +6 -0
- package/src/shell/tui-renderer.ts +1 -0
- package/src/utils/diff-renderer.ts +62 -29
- package/src/utils/markdown.ts +32 -20
package/dist/agent/types.d.ts
CHANGED
|
@@ -49,7 +49,10 @@ export type ToolResultBody = {
|
|
|
49
49
|
maxLines?: number;
|
|
50
50
|
};
|
|
51
51
|
export interface ToolDisplayInfo {
|
|
52
|
-
|
|
52
|
+
/** Verb shown next to the detail (e.g. "execute foo.py"). Omit when a custom
|
|
53
|
+
* `icon` already makes the action self-evident — the renderer then shows
|
|
54
|
+
* icon + detail with no verb. */
|
|
55
|
+
kind?: "read" | "write" | "execute" | "search";
|
|
53
56
|
locations?: {
|
|
54
57
|
path: string;
|
|
55
58
|
line?: number | null;
|
package/dist/cli/shell-env.js
CHANGED
|
@@ -1,8 +1,48 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
2
4
|
import { pickStrategy, FALLBACK_STRATEGY } from "../shell/strategies/index.js";
|
|
5
|
+
import { CONFIG_DIR } from "../core/settings.js";
|
|
6
|
+
const CACHE_FILE = path.join(CONFIG_DIR, "cache", "shell-env.json");
|
|
7
|
+
function captureSignature(shell, strategy, captureCmd) {
|
|
8
|
+
const files = strategy.envCaptureFiles?.(process.env) ?? [];
|
|
9
|
+
const stamps = files.sort().map((f) => {
|
|
10
|
+
try {
|
|
11
|
+
return [f, fs.statSync(f).mtimeMs];
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return [f, 0];
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
return JSON.stringify({ shell, captureCmd, stamps });
|
|
18
|
+
}
|
|
19
|
+
function readCachedEnv(sig) {
|
|
20
|
+
try {
|
|
21
|
+
const raw = JSON.parse(fs.readFileSync(CACHE_FILE, "utf-8"));
|
|
22
|
+
if (raw?.sig === sig && raw.env && typeof raw.env === "object")
|
|
23
|
+
return raw.env;
|
|
24
|
+
}
|
|
25
|
+
catch { }
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
function writeCachedEnv(sig, env) {
|
|
29
|
+
try {
|
|
30
|
+
fs.mkdirSync(path.dirname(CACHE_FILE), { recursive: true });
|
|
31
|
+
fs.writeFileSync(CACHE_FILE, JSON.stringify({ sig, env }));
|
|
32
|
+
}
|
|
33
|
+
catch { }
|
|
34
|
+
}
|
|
3
35
|
export async function captureShellEnvAsync(shell) {
|
|
4
36
|
if (process.env.AGENT_SH_SKIP_SHELL_ENV)
|
|
5
37
|
return {};
|
|
38
|
+
const strategy = pickStrategy(shell) ?? FALLBACK_STRATEGY;
|
|
39
|
+
const captureCmd = strategy.envCaptureCommand();
|
|
40
|
+
const sig = captureSignature(shell, strategy, captureCmd);
|
|
41
|
+
if (!process.env.AGENT_SH_SHELL_ENV_NOCACHE) {
|
|
42
|
+
const cached = readCachedEnv(sig);
|
|
43
|
+
if (cached)
|
|
44
|
+
return cached;
|
|
45
|
+
}
|
|
6
46
|
return new Promise((resolve) => {
|
|
7
47
|
let settled = false;
|
|
8
48
|
const done = (result) => {
|
|
@@ -12,8 +52,6 @@ export async function captureShellEnvAsync(shell) {
|
|
|
12
52
|
resolve(result);
|
|
13
53
|
};
|
|
14
54
|
try {
|
|
15
|
-
const strategy = pickStrategy(shell) ?? FALLBACK_STRATEGY;
|
|
16
|
-
const captureCmd = strategy.envCaptureCommand();
|
|
17
55
|
const child = spawn(shell, ["-l", "-c", captureCmd], {
|
|
18
56
|
stdio: ["ignore", "pipe", "ignore"],
|
|
19
57
|
timeout: 5000,
|
|
@@ -34,6 +72,7 @@ export async function captureShellEnvAsync(shell) {
|
|
|
34
72
|
if (eq > 0)
|
|
35
73
|
env[entry.slice(0, eq)] = entry.slice(eq + 1);
|
|
36
74
|
}
|
|
75
|
+
writeCachedEnv(sig, env);
|
|
37
76
|
done(env);
|
|
38
77
|
});
|
|
39
78
|
child.on("error", () => {
|
|
@@ -66,6 +66,12 @@ export const bashStrategy = {
|
|
|
66
66
|
envCaptureCommand() {
|
|
67
67
|
return "[ -f ~/.bashrc ] && source ~/.bashrc 2>/dev/null; env -0";
|
|
68
68
|
},
|
|
69
|
+
envCaptureFiles(env) {
|
|
70
|
+
const home = env.HOME;
|
|
71
|
+
if (!home)
|
|
72
|
+
return [];
|
|
73
|
+
return [".bashrc", ".bash_profile", ".bash_login", ".profile"].map((f) => path.join(home, f));
|
|
74
|
+
},
|
|
69
75
|
redrawEscape() {
|
|
70
76
|
return "\x1b[9999~";
|
|
71
77
|
},
|
|
@@ -59,6 +59,12 @@ export const fishStrategy = {
|
|
|
59
59
|
// `fish -l` already sources config.fish + conf.d, so no explicit source.
|
|
60
60
|
return "env -0";
|
|
61
61
|
},
|
|
62
|
+
envCaptureFiles(env) {
|
|
63
|
+
const config = env.XDG_CONFIG_HOME || (env.HOME ? path.join(env.HOME, ".config") : undefined);
|
|
64
|
+
if (!config)
|
|
65
|
+
return [];
|
|
66
|
+
return [path.join(config, "fish", "config.fish"), path.join(config, "fish", "conf.d")];
|
|
67
|
+
},
|
|
62
68
|
redrawEscape() {
|
|
63
69
|
return "\x1b[57400u";
|
|
64
70
|
},
|
|
@@ -41,6 +41,7 @@ export interface ShellStrategy {
|
|
|
41
41
|
* config and dump env. Used at startup to inherit shell-only env vars.
|
|
42
42
|
*/
|
|
43
43
|
envCaptureCommand(): string;
|
|
44
|
+
envCaptureFiles?(env: Record<string, string | undefined>): string[];
|
|
44
45
|
/**
|
|
45
46
|
* Escape sequence to write to the PTY to ask the shell to repaint its
|
|
46
47
|
* prompt in place. The corresponding binding is set up in prepareSpawn.
|
|
@@ -66,6 +66,12 @@ export const zshStrategy = {
|
|
|
66
66
|
envCaptureCommand() {
|
|
67
67
|
return "source ~/.zshrc 2>/dev/null; env -0";
|
|
68
68
|
},
|
|
69
|
+
envCaptureFiles(env) {
|
|
70
|
+
const zdot = env.ZDOTDIR || env.HOME;
|
|
71
|
+
if (!zdot)
|
|
72
|
+
return [];
|
|
73
|
+
return [".zshenv", ".zprofile", ".zshrc", ".zlogin"].map((f) => path.join(zdot, f));
|
|
74
|
+
},
|
|
69
75
|
redrawEscape() {
|
|
70
76
|
return "\x1b[9999~";
|
|
71
77
|
},
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { highlight } from "cli-highlight";
|
|
9
9
|
import { visibleLen, charWidth } from "./ansi.js";
|
|
10
10
|
import { palette as p } from "./palette.js";
|
|
11
|
+
import { wrapLine } from "./markdown.js";
|
|
11
12
|
// ── Constants ────────────────────────────────────────────────────
|
|
12
13
|
const SPLIT_MIN_WIDTH = 120;
|
|
13
14
|
const UNIFIED_MIN_WIDTH = 40;
|
|
@@ -102,6 +103,42 @@ function tokenLcs(a, b) {
|
|
|
102
103
|
}
|
|
103
104
|
return { oldMatch, newMatch };
|
|
104
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* Anchors the shared prefix/suffix as unchanged and runs the O(m·n) LCS only on
|
|
108
|
+
* the differing middle. Null when that middle exceeds maxProduct.
|
|
109
|
+
*/
|
|
110
|
+
function inlineMatches(a, b, maxProduct) {
|
|
111
|
+
const m = a.length;
|
|
112
|
+
const n = b.length;
|
|
113
|
+
let pre = 0;
|
|
114
|
+
while (pre < m && pre < n && a[pre].text === b[pre].text)
|
|
115
|
+
pre++;
|
|
116
|
+
let suf = 0;
|
|
117
|
+
while (suf < m - pre && suf < n - pre && a[m - 1 - suf].text === b[n - 1 - suf].text)
|
|
118
|
+
suf++;
|
|
119
|
+
const midM = m - pre - suf;
|
|
120
|
+
const midN = n - pre - suf;
|
|
121
|
+
if (midM * midN > maxProduct)
|
|
122
|
+
return null;
|
|
123
|
+
const oldMatch = new Array(m).fill(false);
|
|
124
|
+
const newMatch = new Array(n).fill(false);
|
|
125
|
+
for (let i = 0; i < pre; i++) {
|
|
126
|
+
oldMatch[i] = true;
|
|
127
|
+
newMatch[i] = true;
|
|
128
|
+
}
|
|
129
|
+
for (let i = 0; i < suf; i++) {
|
|
130
|
+
oldMatch[m - 1 - i] = true;
|
|
131
|
+
newMatch[n - 1 - i] = true;
|
|
132
|
+
}
|
|
133
|
+
if (midM > 0 && midN > 0) {
|
|
134
|
+
const mid = tokenLcs(a.slice(pre, m - suf), b.slice(pre, n - suf));
|
|
135
|
+
for (let i = 0; i < midM; i++)
|
|
136
|
+
oldMatch[pre + i] = mid.oldMatch[i];
|
|
137
|
+
for (let j = 0; j < midN; j++)
|
|
138
|
+
newMatch[pre + j] = mid.newMatch[j];
|
|
139
|
+
}
|
|
140
|
+
return { oldMatch, newMatch };
|
|
141
|
+
}
|
|
105
142
|
/**
|
|
106
143
|
* Rewrite full ANSI resets (\x1b[0m) to foreground-only resets,
|
|
107
144
|
* preserving the given background color across the line.
|
|
@@ -139,14 +176,14 @@ function highlightInlineChanges(oldLine, newLine, oldPalette, newPalette, useTru
|
|
|
139
176
|
new: language ? highlightLine(newLine, language) : newLine,
|
|
140
177
|
};
|
|
141
178
|
}
|
|
142
|
-
|
|
143
|
-
if (
|
|
179
|
+
const matches = inlineMatches(oldTokens, newTokens, 1_000_000);
|
|
180
|
+
if (!matches) {
|
|
144
181
|
return {
|
|
145
182
|
old: language ? highlightLine(oldLine, language) : oldLine,
|
|
146
183
|
new: language ? highlightLine(newLine, language) : newLine,
|
|
147
184
|
};
|
|
148
185
|
}
|
|
149
|
-
const { oldMatch, newMatch } =
|
|
186
|
+
const { oldMatch, newMatch } = matches;
|
|
150
187
|
const buildHighlighted = (tokens, matched, palette) => {
|
|
151
188
|
let result = "";
|
|
152
189
|
for (let i = 0; i < tokens.length; i++) {
|
|
@@ -248,13 +285,19 @@ function renderUnifiedHunk(hunk, layout) {
|
|
|
248
285
|
const pairs = findChangePairs(hunk);
|
|
249
286
|
const bgWidth = Math.max(1, textWidth - noW - 3);
|
|
250
287
|
const gutter = (n) => `${p.dim}${n} │${p.reset} `;
|
|
288
|
+
const continuationNo = " ".repeat(noW);
|
|
289
|
+
// Wrapped rows after the first blank the line number and sigil.
|
|
251
290
|
const change = (no, sigil, bg, fg, text) => {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
291
|
+
return wrapLine(text, lineTextW).map((seg, r) => {
|
|
292
|
+
const n = r === 0 ? no : continuationNo;
|
|
293
|
+
const sg = r === 0 ? sigil : " ";
|
|
294
|
+
if (!gutterLine) {
|
|
295
|
+
return `${bg}${padToWidth(`${fg}${n} ${sg}${p.diffText} ${preserveBg(seg, bg)}`, textWidth)}${p.reset}`;
|
|
296
|
+
}
|
|
297
|
+
if (useTrueColor)
|
|
298
|
+
return gutter(n) + padToWidth(`${bg}${fg}${sg}${p.diffText} ${preserveBg(seg, bg)}`, bgWidth) + p.reset;
|
|
299
|
+
return `${gutter(n)}${fg}${sg} ${seg}${p.reset}`;
|
|
300
|
+
});
|
|
258
301
|
};
|
|
259
302
|
const hlCache = new Map();
|
|
260
303
|
const highlightedPair = (pair) => {
|
|
@@ -269,33 +312,25 @@ function renderUnifiedHunk(hunk, layout) {
|
|
|
269
312
|
const line = hunk.lines[i];
|
|
270
313
|
const no = String(line.type === "removed" ? (line.oldNo ?? "") : (line.newNo ?? line.oldNo ?? "")).padStart(noW);
|
|
271
314
|
if (line.type === "context") {
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
315
|
+
const text = lang ? highlightLine(line.text, lang) : line.text;
|
|
316
|
+
wrapLine(text, lineTextW).forEach((seg, r) => {
|
|
317
|
+
const n = r === 0 ? no : continuationNo;
|
|
318
|
+
out.push(!gutterLine ? `${p.dim}${n}${p.reset} ${seg}` : `${gutter(n)} ${p.dim}${seg}${p.reset}`);
|
|
319
|
+
});
|
|
275
320
|
continue;
|
|
276
321
|
}
|
|
277
322
|
const pair = pairs.get(i);
|
|
278
323
|
if (line.type === "removed") {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
else {
|
|
284
|
-
const raw = truncateText(line.text, lineTextW);
|
|
285
|
-
removedText = lang ? highlightLine(raw, lang) : raw;
|
|
286
|
-
}
|
|
287
|
-
out.push(change(no, "-", p.errorBg, p.error, removedText));
|
|
324
|
+
const removedText = pair
|
|
325
|
+
? highlightedPair(pair).old
|
|
326
|
+
: (lang ? highlightLine(line.text, lang) : line.text);
|
|
327
|
+
out.push(...change(no, "-", p.errorBg, p.error, removedText));
|
|
288
328
|
}
|
|
289
329
|
else {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
else {
|
|
295
|
-
const raw = truncateText(line.text, lineTextW);
|
|
296
|
-
addedText = lang ? highlightLine(raw, lang) : raw;
|
|
297
|
-
}
|
|
298
|
-
out.push(change(no, "+", p.successBg, p.success, addedText));
|
|
330
|
+
const addedText = pair
|
|
331
|
+
? highlightedPair(pair).new
|
|
332
|
+
: (lang ? highlightLine(line.text, lang) : line.text);
|
|
333
|
+
out.push(...change(no, "+", p.successBg, p.success, addedText));
|
|
299
334
|
}
|
|
300
335
|
}
|
|
301
336
|
return out;
|
package/dist/utils/markdown.js
CHANGED
|
@@ -78,6 +78,34 @@ export function wrapLine(text, maxWidth) {
|
|
|
78
78
|
lineWidth = 0;
|
|
79
79
|
lastVisibleIdx = -1;
|
|
80
80
|
};
|
|
81
|
+
const hardBreak = (token) => {
|
|
82
|
+
let remaining = token;
|
|
83
|
+
while (remaining.length > 0) {
|
|
84
|
+
let fitLen = 0, fitWidth = 0;
|
|
85
|
+
for (const ch of remaining) {
|
|
86
|
+
const cw = charWidth(ch.codePointAt(0) ?? 0);
|
|
87
|
+
if (fitWidth + cw > maxWidth - lineWidth)
|
|
88
|
+
break;
|
|
89
|
+
fitWidth += cw;
|
|
90
|
+
fitLen += ch.length;
|
|
91
|
+
}
|
|
92
|
+
if (fitLen === 0) {
|
|
93
|
+
// Force one char on an empty line so an over-wide char can't loop forever.
|
|
94
|
+
if (lineWidth > 0) {
|
|
95
|
+
commit();
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
fitLen = remaining[0]?.length ?? 1;
|
|
99
|
+
}
|
|
100
|
+
const chunk = remaining.slice(0, fitLen);
|
|
101
|
+
remaining = remaining.slice(fitLen);
|
|
102
|
+
lineTokens.push(chunk);
|
|
103
|
+
lineWidth += visibleLen(chunk);
|
|
104
|
+
lastVisibleIdx = lineTokens.length - 1;
|
|
105
|
+
if (remaining.length > 0)
|
|
106
|
+
commit();
|
|
107
|
+
}
|
|
108
|
+
};
|
|
81
109
|
for (const seg of segments) {
|
|
82
110
|
if (seg.startsWith("\x1b[")) {
|
|
83
111
|
lineTokens.push(seg);
|
|
@@ -102,26 +130,7 @@ export function wrapLine(text, maxWidth) {
|
|
|
102
130
|
continue; // spaces at wrap points are dropped
|
|
103
131
|
if (lineWidth === 0) {
|
|
104
132
|
// Token longer than the entire line — hard-break by char width.
|
|
105
|
-
|
|
106
|
-
while (remaining.length > 0) {
|
|
107
|
-
let fitLen = 0, fitWidth = 0;
|
|
108
|
-
for (const ch of remaining) {
|
|
109
|
-
const cw = charWidth(ch.codePointAt(0) ?? 0);
|
|
110
|
-
if (fitWidth + cw > maxWidth)
|
|
111
|
-
break;
|
|
112
|
-
fitWidth += cw;
|
|
113
|
-
fitLen += ch.length;
|
|
114
|
-
}
|
|
115
|
-
if (fitLen === 0)
|
|
116
|
-
fitLen = remaining[0]?.length ?? 1;
|
|
117
|
-
const chunk = remaining.slice(0, fitLen);
|
|
118
|
-
remaining = remaining.slice(fitLen);
|
|
119
|
-
lineTokens.push(chunk);
|
|
120
|
-
lineWidth += visibleLen(chunk);
|
|
121
|
-
lastVisibleIdx = lineTokens.length - 1;
|
|
122
|
-
if (remaining.length > 0)
|
|
123
|
-
commit();
|
|
124
|
-
}
|
|
133
|
+
hardBreak(token);
|
|
125
134
|
continue;
|
|
126
135
|
}
|
|
127
136
|
// Rule (a): closing punctuation must not start a line. Allow up to 2
|
|
@@ -146,9 +155,14 @@ export function wrapLine(text, maxWidth) {
|
|
|
146
155
|
lineTokens.push(t);
|
|
147
156
|
lineWidth += visibleLen(t);
|
|
148
157
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
158
|
+
if (lineWidth + tokenWidth <= maxWidth) {
|
|
159
|
+
lineTokens.push(token);
|
|
160
|
+
lineWidth += tokenWidth;
|
|
161
|
+
lastVisibleIdx = lineTokens.length - 1;
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
hardBreak(token);
|
|
165
|
+
}
|
|
152
166
|
}
|
|
153
167
|
}
|
|
154
168
|
if (lineWidth > 0) {
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* command-suggest extension
|
|
3
|
+
*
|
|
4
|
+
* Registers the suggest_command tool. When the agent calls it, the response
|
|
5
|
+
* finishes and the user drops to the shell prompt with the command pre-typed
|
|
6
|
+
* — no copy-paste, no mode toggle, just review and press Enter.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* agent-sh -e ./examples/extensions/command-suggest.ts
|
|
10
|
+
*
|
|
11
|
+
* # Or install permanently:
|
|
12
|
+
* cp examples/extensions/command-suggest.ts ~/.agent-sh/extensions/
|
|
13
|
+
*/
|
|
14
|
+
import type { ExtensionContext } from "agent-sh/types";
|
|
15
|
+
|
|
16
|
+
export default function activate(ctx: ExtensionContext): void {
|
|
17
|
+
const { bus } = ctx;
|
|
18
|
+
let pendingCommand: string | null = null;
|
|
19
|
+
|
|
20
|
+
// ── Tool ────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
ctx.agent?.registerTool({
|
|
23
|
+
name: "suggest_command",
|
|
24
|
+
description:
|
|
25
|
+
"Stage a shell command at the user's prompt. After this response " +
|
|
26
|
+
"completes, the command appears in their shell prompt (not inside " +
|
|
27
|
+
"agent-input mode), ready to edit or run with Enter. " +
|
|
28
|
+
"Only call this when the user is asking for a command to run, or otherwise " +
|
|
29
|
+
"signals they want one staged — e.g. \"give me the command to …\", " +
|
|
30
|
+
"\"what do I run to …\". Do NOT call it unprompted after a general question, " +
|
|
31
|
+
"an explanation, or any turn where no command was requested. " +
|
|
32
|
+
"Prefer it over telling the user to copy-paste a command. " +
|
|
33
|
+
"Only the most recent call matters. Call with an empty string to clear.",
|
|
34
|
+
input_schema: {
|
|
35
|
+
type: "object",
|
|
36
|
+
properties: {
|
|
37
|
+
command: {
|
|
38
|
+
type: "string",
|
|
39
|
+
description:
|
|
40
|
+
"The shell command to place in the user's prompt. " +
|
|
41
|
+
"Multi-line commands are collapsed to a single line.",
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
required: ["command"],
|
|
45
|
+
},
|
|
46
|
+
showOutput: true,
|
|
47
|
+
|
|
48
|
+
getDisplayInfo: () => ({ icon: "⏎" }),
|
|
49
|
+
|
|
50
|
+
formatCall: (args) => {
|
|
51
|
+
const cmd = (args.command as string).trim();
|
|
52
|
+
if (!cmd) return "(clear suggestion)";
|
|
53
|
+
return cmd.length > 60 ? cmd.slice(0, 57) + "..." : cmd;
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
async execute(args) {
|
|
57
|
+
const cmd = (args.command as string).trim();
|
|
58
|
+
if (!cmd) {
|
|
59
|
+
pendingCommand = null;
|
|
60
|
+
return { content: "Cleared pending command suggestion.", exitCode: 0, isError: false };
|
|
61
|
+
}
|
|
62
|
+
// Collapse newlines to spaces so the command stays on one readline buffer.
|
|
63
|
+
pendingCommand = cmd.replace(/\n/g, " ");
|
|
64
|
+
return {
|
|
65
|
+
content: `Will suggest at shell prompt: ${pendingCommand}`,
|
|
66
|
+
exitCode: 0,
|
|
67
|
+
isError: false,
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// ── Injection hook ──────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
// Replace the default handler — which re-enters agent-input mode when sticky —
|
|
75
|
+
// so a pending command lands at a fresh shell prompt instead. The "\n" leads
|
|
76
|
+
// the same PTY write so the new prompt appears before the command text fills it.
|
|
77
|
+
ctx.advise("shell:on-processing-redraw", (next) => {
|
|
78
|
+
if (pendingCommand) {
|
|
79
|
+
const cmd = pendingCommand;
|
|
80
|
+
pendingCommand = null;
|
|
81
|
+
bus.emit("shell:pty-write", { data: "\n" + cmd });
|
|
82
|
+
} else {
|
|
83
|
+
next();
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sh",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.6",
|
|
4
4
|
"description": "A composable agent runtime — pair any frontend with any agent backend over one shared extension layer",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"workspaces": [
|
|
@@ -73,6 +73,10 @@
|
|
|
73
73
|
"types": "./dist/agent/types.d.ts",
|
|
74
74
|
"default": "./dist/agent/types.js"
|
|
75
75
|
},
|
|
76
|
+
"./skills": {
|
|
77
|
+
"types": "./dist/agent/skills.d.ts",
|
|
78
|
+
"default": "./dist/agent/skills.js"
|
|
79
|
+
},
|
|
76
80
|
"./store": {
|
|
77
81
|
"types": "./dist/agent/store.d.ts",
|
|
78
82
|
"default": "./dist/agent/store.js"
|
|
@@ -109,10 +113,6 @@
|
|
|
109
113
|
"types": "./dist/agent/token-budget.d.ts",
|
|
110
114
|
"default": "./dist/agent/token-budget.js"
|
|
111
115
|
},
|
|
112
|
-
"./agent/history-file": {
|
|
113
|
-
"types": "./dist/agent/history-file.d.ts",
|
|
114
|
-
"default": "./dist/agent/history-file.js"
|
|
115
|
-
},
|
|
116
116
|
"./agent/nuclear-form": {
|
|
117
117
|
"types": "./dist/agent/nuclear-form.d.ts",
|
|
118
118
|
"default": "./dist/agent/nuclear-form.js"
|
package/src/agent/types.ts
CHANGED
|
@@ -54,7 +54,10 @@ export type ToolResultBody =
|
|
|
54
54
|
| { kind: "lines"; lines: string[]; maxLines?: number }
|
|
55
55
|
|
|
56
56
|
export interface ToolDisplayInfo {
|
|
57
|
-
|
|
57
|
+
/** Verb shown next to the detail (e.g. "execute foo.py"). Omit when a custom
|
|
58
|
+
* `icon` already makes the action self-evident — the renderer then shows
|
|
59
|
+
* icon + detail with no verb. */
|
|
60
|
+
kind?: "read" | "write" | "execute" | "search";
|
|
58
61
|
locations?: { path: string; line?: number | null }[];
|
|
59
62
|
/** Custom icon character for TUI display (e.g., "◆", "⌕"). When set, the TUI shows
|
|
60
63
|
* icon + detail only. When absent, the tool name is shown alongside the detail. */
|
package/src/cli/shell-env.ts
CHANGED
|
@@ -1,8 +1,50 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { pickStrategy, FALLBACK_STRATEGY, type ShellStrategy } from "../shell/strategies/index.js";
|
|
5
|
+
import { CONFIG_DIR } from "../core/settings.js";
|
|
6
|
+
|
|
7
|
+
const CACHE_FILE = path.join(CONFIG_DIR, "cache", "shell-env.json");
|
|
8
|
+
|
|
9
|
+
function captureSignature(shell: string, strategy: ShellStrategy, captureCmd: string): string {
|
|
10
|
+
const files = strategy.envCaptureFiles?.(process.env) ?? [];
|
|
11
|
+
const stamps = files.sort().map((f) => {
|
|
12
|
+
try {
|
|
13
|
+
return [f, fs.statSync(f).mtimeMs];
|
|
14
|
+
} catch {
|
|
15
|
+
return [f, 0];
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
return JSON.stringify({ shell, captureCmd, stamps });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function readCachedEnv(sig: string): Record<string, string> | null {
|
|
22
|
+
try {
|
|
23
|
+
const raw = JSON.parse(fs.readFileSync(CACHE_FILE, "utf-8"));
|
|
24
|
+
if (raw?.sig === sig && raw.env && typeof raw.env === "object") return raw.env;
|
|
25
|
+
} catch {}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function writeCachedEnv(sig: string, env: Record<string, string>): void {
|
|
30
|
+
try {
|
|
31
|
+
fs.mkdirSync(path.dirname(CACHE_FILE), { recursive: true });
|
|
32
|
+
fs.writeFileSync(CACHE_FILE, JSON.stringify({ sig, env }));
|
|
33
|
+
} catch {}
|
|
34
|
+
}
|
|
3
35
|
|
|
4
36
|
export async function captureShellEnvAsync(shell: string): Promise<Record<string, string>> {
|
|
5
37
|
if (process.env.AGENT_SH_SKIP_SHELL_ENV) return {};
|
|
38
|
+
|
|
39
|
+
const strategy = pickStrategy(shell) ?? FALLBACK_STRATEGY;
|
|
40
|
+
const captureCmd = strategy.envCaptureCommand();
|
|
41
|
+
const sig = captureSignature(shell, strategy, captureCmd);
|
|
42
|
+
|
|
43
|
+
if (!process.env.AGENT_SH_SHELL_ENV_NOCACHE) {
|
|
44
|
+
const cached = readCachedEnv(sig);
|
|
45
|
+
if (cached) return cached;
|
|
46
|
+
}
|
|
47
|
+
|
|
6
48
|
return new Promise((resolve) => {
|
|
7
49
|
let settled = false;
|
|
8
50
|
const done = (result: Record<string, string>): void => {
|
|
@@ -12,9 +54,6 @@ export async function captureShellEnvAsync(shell: string): Promise<Record<string
|
|
|
12
54
|
};
|
|
13
55
|
|
|
14
56
|
try {
|
|
15
|
-
const strategy = pickStrategy(shell) ?? FALLBACK_STRATEGY;
|
|
16
|
-
const captureCmd = strategy.envCaptureCommand();
|
|
17
|
-
|
|
18
57
|
const child = spawn(shell, ["-l", "-c", captureCmd], {
|
|
19
58
|
stdio: ["ignore", "pipe", "ignore"],
|
|
20
59
|
timeout: 5000,
|
|
@@ -36,6 +75,7 @@ export async function captureShellEnvAsync(shell: string): Promise<Record<string
|
|
|
36
75
|
const eq = entry.indexOf("=");
|
|
37
76
|
if (eq > 0) env[entry.slice(0, eq)] = entry.slice(eq + 1);
|
|
38
77
|
}
|
|
78
|
+
writeCachedEnv(sig, env);
|
|
39
79
|
done(env);
|
|
40
80
|
});
|
|
41
81
|
|
|
@@ -77,6 +77,12 @@ export const bashStrategy: ShellStrategy = {
|
|
|
77
77
|
return "[ -f ~/.bashrc ] && source ~/.bashrc 2>/dev/null; env -0";
|
|
78
78
|
},
|
|
79
79
|
|
|
80
|
+
envCaptureFiles(env): string[] {
|
|
81
|
+
const home = env.HOME;
|
|
82
|
+
if (!home) return [];
|
|
83
|
+
return [".bashrc", ".bash_profile", ".bash_login", ".profile"].map((f) => path.join(home, f));
|
|
84
|
+
},
|
|
85
|
+
|
|
80
86
|
redrawEscape(): string {
|
|
81
87
|
return "\x1b[9999~";
|
|
82
88
|
},
|
|
@@ -71,6 +71,12 @@ export const fishStrategy: ShellStrategy = {
|
|
|
71
71
|
return "env -0";
|
|
72
72
|
},
|
|
73
73
|
|
|
74
|
+
envCaptureFiles(env): string[] {
|
|
75
|
+
const config = env.XDG_CONFIG_HOME || (env.HOME ? path.join(env.HOME, ".config") : undefined);
|
|
76
|
+
if (!config) return [];
|
|
77
|
+
return [path.join(config, "fish", "config.fish"), path.join(config, "fish", "conf.d")];
|
|
78
|
+
},
|
|
79
|
+
|
|
74
80
|
redrawEscape(): string {
|
|
75
81
|
return "\x1b[57400u";
|
|
76
82
|
},
|
|
@@ -48,6 +48,8 @@ export interface ShellStrategy {
|
|
|
48
48
|
*/
|
|
49
49
|
envCaptureCommand(): string;
|
|
50
50
|
|
|
51
|
+
envCaptureFiles?(env: Record<string, string | undefined>): string[];
|
|
52
|
+
|
|
51
53
|
/**
|
|
52
54
|
* Escape sequence to write to the PTY to ask the shell to repaint its
|
|
53
55
|
* prompt in place. The corresponding binding is set up in prepareSpawn.
|
|
@@ -77,6 +77,12 @@ export const zshStrategy: ShellStrategy = {
|
|
|
77
77
|
return "source ~/.zshrc 2>/dev/null; env -0";
|
|
78
78
|
},
|
|
79
79
|
|
|
80
|
+
envCaptureFiles(env): string[] {
|
|
81
|
+
const zdot = env.ZDOTDIR || env.HOME;
|
|
82
|
+
if (!zdot) return [];
|
|
83
|
+
return [".zshenv", ".zprofile", ".zshrc", ".zlogin"].map((f) => path.join(zdot, f));
|
|
84
|
+
},
|
|
85
|
+
|
|
80
86
|
redrawEscape(): string {
|
|
81
87
|
return "\x1b[9999~";
|
|
82
88
|
},
|
|
@@ -140,6 +140,39 @@ function tokenLcs(
|
|
|
140
140
|
return { oldMatch, newMatch };
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Anchors the shared prefix/suffix as unchanged and runs the O(m·n) LCS only on
|
|
145
|
+
* the differing middle. Null when that middle exceeds maxProduct.
|
|
146
|
+
*/
|
|
147
|
+
function inlineMatches(
|
|
148
|
+
a: Token[],
|
|
149
|
+
b: Token[],
|
|
150
|
+
maxProduct: number,
|
|
151
|
+
): { oldMatch: boolean[]; newMatch: boolean[] } | null {
|
|
152
|
+
const m = a.length;
|
|
153
|
+
const n = b.length;
|
|
154
|
+
let pre = 0;
|
|
155
|
+
while (pre < m && pre < n && a[pre].text === b[pre].text) pre++;
|
|
156
|
+
let suf = 0;
|
|
157
|
+
while (suf < m - pre && suf < n - pre && a[m - 1 - suf].text === b[n - 1 - suf].text) suf++;
|
|
158
|
+
|
|
159
|
+
const midM = m - pre - suf;
|
|
160
|
+
const midN = n - pre - suf;
|
|
161
|
+
if (midM * midN > maxProduct) return null;
|
|
162
|
+
|
|
163
|
+
const oldMatch = new Array<boolean>(m).fill(false);
|
|
164
|
+
const newMatch = new Array<boolean>(n).fill(false);
|
|
165
|
+
for (let i = 0; i < pre; i++) { oldMatch[i] = true; newMatch[i] = true; }
|
|
166
|
+
for (let i = 0; i < suf; i++) { oldMatch[m - 1 - i] = true; newMatch[n - 1 - i] = true; }
|
|
167
|
+
|
|
168
|
+
if (midM > 0 && midN > 0) {
|
|
169
|
+
const mid = tokenLcs(a.slice(pre, m - suf), b.slice(pre, n - suf));
|
|
170
|
+
for (let i = 0; i < midM; i++) oldMatch[pre + i] = mid.oldMatch[i];
|
|
171
|
+
for (let j = 0; j < midN; j++) newMatch[pre + j] = mid.newMatch[j];
|
|
172
|
+
}
|
|
173
|
+
return { oldMatch, newMatch };
|
|
174
|
+
}
|
|
175
|
+
|
|
143
176
|
/**
|
|
144
177
|
* Rewrite full ANSI resets (\x1b[0m) to foreground-only resets,
|
|
145
178
|
* preserving the given background color across the line.
|
|
@@ -193,15 +226,14 @@ function highlightInlineChanges(
|
|
|
193
226
|
};
|
|
194
227
|
}
|
|
195
228
|
|
|
196
|
-
|
|
197
|
-
if (
|
|
229
|
+
const matches = inlineMatches(oldTokens, newTokens, 1_000_000);
|
|
230
|
+
if (!matches) {
|
|
198
231
|
return {
|
|
199
232
|
old: language ? highlightLine(oldLine, language) : oldLine,
|
|
200
233
|
new: language ? highlightLine(newLine, language) : newLine,
|
|
201
234
|
};
|
|
202
235
|
}
|
|
203
|
-
|
|
204
|
-
const { oldMatch, newMatch } = tokenLcs(oldTokens, newTokens);
|
|
236
|
+
const { oldMatch, newMatch } = matches;
|
|
205
237
|
|
|
206
238
|
const buildHighlighted = (
|
|
207
239
|
tokens: Token[],
|
|
@@ -340,12 +372,19 @@ function renderUnifiedHunk(hunk: DiffHunk, layout: UnifiedLayout): string[] {
|
|
|
340
372
|
const bgWidth = Math.max(1, textWidth - noW - 3);
|
|
341
373
|
const gutter = (n: string): string => `${p.dim}${n} │${p.reset} `;
|
|
342
374
|
|
|
343
|
-
const
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
375
|
+
const continuationNo = " ".repeat(noW);
|
|
376
|
+
|
|
377
|
+
// Wrapped rows after the first blank the line number and sigil.
|
|
378
|
+
const change = (no: string, sigil: string, bg: string, fg: string, text: string): string[] => {
|
|
379
|
+
return wrapLine(text, lineTextW).map((seg, r) => {
|
|
380
|
+
const n = r === 0 ? no : continuationNo;
|
|
381
|
+
const sg = r === 0 ? sigil : " ";
|
|
382
|
+
if (!gutterLine) {
|
|
383
|
+
return `${bg}${padToWidth(`${fg}${n} ${sg}${p.diffText} ${preserveBg(seg, bg)}`, textWidth)}${p.reset}`;
|
|
384
|
+
}
|
|
385
|
+
if (useTrueColor) return gutter(n) + padToWidth(`${bg}${fg}${sg}${p.diffText} ${preserveBg(seg, bg)}`, bgWidth) + p.reset;
|
|
386
|
+
return `${gutter(n)}${fg}${sg} ${seg}${p.reset}`;
|
|
387
|
+
});
|
|
349
388
|
};
|
|
350
389
|
|
|
351
390
|
const hlCache = new Map<ChangePair, { old: string; new: string }>();
|
|
@@ -367,31 +406,25 @@ function renderUnifiedHunk(hunk: DiffHunk, layout: UnifiedLayout): string[] {
|
|
|
367
406
|
).padStart(noW);
|
|
368
407
|
|
|
369
408
|
if (line.type === "context") {
|
|
370
|
-
const
|
|
371
|
-
|
|
372
|
-
|
|
409
|
+
const text = lang ? highlightLine(line.text, lang) : line.text;
|
|
410
|
+
wrapLine(text, lineTextW).forEach((seg, r) => {
|
|
411
|
+
const n = r === 0 ? no : continuationNo;
|
|
412
|
+
out.push(!gutterLine ? `${p.dim}${n}${p.reset} ${seg}` : `${gutter(n)} ${p.dim}${seg}${p.reset}`);
|
|
413
|
+
});
|
|
373
414
|
continue;
|
|
374
415
|
}
|
|
375
416
|
|
|
376
417
|
const pair = pairs.get(i);
|
|
377
418
|
if (line.type === "removed") {
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
const raw = truncateText(line.text, lineTextW);
|
|
383
|
-
removedText = lang ? highlightLine(raw, lang) : raw;
|
|
384
|
-
}
|
|
385
|
-
out.push(change(no, "-", p.errorBg, p.error, removedText));
|
|
419
|
+
const removedText = pair
|
|
420
|
+
? highlightedPair(pair).old
|
|
421
|
+
: (lang ? highlightLine(line.text, lang) : line.text);
|
|
422
|
+
out.push(...change(no, "-", p.errorBg, p.error, removedText));
|
|
386
423
|
} else {
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
const raw = truncateText(line.text, lineTextW);
|
|
392
|
-
addedText = lang ? highlightLine(raw, lang) : raw;
|
|
393
|
-
}
|
|
394
|
-
out.push(change(no, "+", p.successBg, p.success, addedText));
|
|
424
|
+
const addedText = pair
|
|
425
|
+
? highlightedPair(pair).new
|
|
426
|
+
: (lang ? highlightLine(line.text, lang) : line.text);
|
|
427
|
+
out.push(...change(no, "+", p.successBg, p.success, addedText));
|
|
395
428
|
}
|
|
396
429
|
}
|
|
397
430
|
return out;
|
package/src/utils/markdown.ts
CHANGED
|
@@ -79,6 +79,30 @@ export function wrapLine(text: string, maxWidth: number): string[] {
|
|
|
79
79
|
lastVisibleIdx = -1;
|
|
80
80
|
};
|
|
81
81
|
|
|
82
|
+
const hardBreak = (token: string): void => {
|
|
83
|
+
let remaining = token;
|
|
84
|
+
while (remaining.length > 0) {
|
|
85
|
+
let fitLen = 0, fitWidth = 0;
|
|
86
|
+
for (const ch of remaining) {
|
|
87
|
+
const cw = charWidth(ch.codePointAt(0) ?? 0);
|
|
88
|
+
if (fitWidth + cw > maxWidth - lineWidth) break;
|
|
89
|
+
fitWidth += cw;
|
|
90
|
+
fitLen += ch.length;
|
|
91
|
+
}
|
|
92
|
+
if (fitLen === 0) {
|
|
93
|
+
// Force one char on an empty line so an over-wide char can't loop forever.
|
|
94
|
+
if (lineWidth > 0) { commit(); continue; }
|
|
95
|
+
fitLen = remaining[0]?.length ?? 1;
|
|
96
|
+
}
|
|
97
|
+
const chunk = remaining.slice(0, fitLen);
|
|
98
|
+
remaining = remaining.slice(fitLen);
|
|
99
|
+
lineTokens.push(chunk);
|
|
100
|
+
lineWidth += visibleLen(chunk);
|
|
101
|
+
lastVisibleIdx = lineTokens.length - 1;
|
|
102
|
+
if (remaining.length > 0) commit();
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
82
106
|
for (const seg of segments) {
|
|
83
107
|
if (seg.startsWith("\x1b[")) {
|
|
84
108
|
lineTokens.push(seg);
|
|
@@ -103,23 +127,7 @@ export function wrapLine(text: string, maxWidth: number): string[] {
|
|
|
103
127
|
|
|
104
128
|
if (lineWidth === 0) {
|
|
105
129
|
// Token longer than the entire line — hard-break by char width.
|
|
106
|
-
|
|
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
|
-
}
|
|
130
|
+
hardBreak(token);
|
|
123
131
|
continue;
|
|
124
132
|
}
|
|
125
133
|
|
|
@@ -147,9 +155,13 @@ export function wrapLine(text: string, maxWidth: number): string[] {
|
|
|
147
155
|
lineTokens.push(t);
|
|
148
156
|
lineWidth += visibleLen(t);
|
|
149
157
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
158
|
+
if (lineWidth + tokenWidth <= maxWidth) {
|
|
159
|
+
lineTokens.push(token);
|
|
160
|
+
lineWidth += tokenWidth;
|
|
161
|
+
lastVisibleIdx = lineTokens.length - 1;
|
|
162
|
+
} else {
|
|
163
|
+
hardBreak(token);
|
|
164
|
+
}
|
|
153
165
|
}
|
|
154
166
|
}
|
|
155
167
|
|