agent-sh 0.10.0 → 0.10.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/README.md +12 -9
- package/dist/agent/agent-loop.d.ts +0 -3
- package/dist/agent/agent-loop.js +18 -35
- package/dist/agent/conversation-state.js +8 -2
- package/dist/agent/nuclear-form.d.ts +2 -0
- package/dist/agent/nuclear-form.js +11 -1
- package/dist/agent/system-prompt.js +1 -1
- package/dist/agent/token-budget.d.ts +8 -12
- package/dist/agent/token-budget.js +5 -40
- package/dist/agent/tool-registry.js +6 -0
- package/dist/agent/types.d.ts +3 -1
- package/dist/context-manager.d.ts +1 -21
- package/dist/context-manager.js +26 -163
- package/dist/event-bus.d.ts +0 -1
- package/dist/extension-loader.js +25 -4
- package/dist/extensions/agent-backend.js +3 -2
- package/dist/extensions/index.js +0 -1
- package/dist/extensions/tui-renderer.js +47 -29
- package/dist/settings.d.ts +3 -11
- package/dist/settings.js +0 -4
- package/dist/shell/input-handler.js +14 -9
- package/dist/types.d.ts +3 -0
- package/dist/utils/ansi.d.ts +6 -1
- package/dist/utils/ansi.js +114 -7
- package/dist/utils/box-frame.js +8 -2
- package/dist/utils/llm-client.d.ts +4 -0
- package/dist/utils/llm-client.js +8 -0
- package/dist/utils/markdown.d.ts +4 -0
- package/dist/utils/markdown.js +136 -48
- package/dist/utils/package-version.d.ts +1 -0
- package/dist/utils/package-version.js +10 -0
- package/dist/utils/shell-output-spill.d.ts +2 -0
- package/dist/utils/shell-output-spill.js +81 -0
- package/examples/extensions/claude-code-bridge/README.md +14 -0
- package/examples/extensions/claude-code-bridge/index.ts +13 -101
- package/examples/extensions/pi-bridge/README.md +16 -0
- package/examples/extensions/pi-bridge/index.ts +8 -154
- package/package.json +9 -1
- package/dist/extensions/shell-recall.d.ts +0 -9
- package/dist/extensions/shell-recall.js +0 -8
package/dist/context-manager.js
CHANGED
|
@@ -1,32 +1,43 @@
|
|
|
1
1
|
import { getSettings } from "./settings.js";
|
|
2
|
+
import { spillOutput } from "./utils/shell-output-spill.js";
|
|
2
3
|
export class ContextManager {
|
|
3
4
|
exchanges = [];
|
|
4
5
|
nextId = 1;
|
|
5
6
|
currentCwd;
|
|
6
|
-
sessionStart;
|
|
7
|
-
firstPrompt = true;
|
|
8
7
|
agentShellActive = false; // true while user_shell command is executing
|
|
9
|
-
|
|
10
|
-
constructor(bus, handlers) {
|
|
11
|
-
if (handlers) {
|
|
12
|
-
this.handlers = handlers;
|
|
13
|
-
// Extensions can advise this to inject extra context (e.g. terminal buffer)
|
|
14
|
-
handlers.define("context:build-extra", () => "");
|
|
15
|
-
}
|
|
8
|
+
constructor(bus, _handlers) {
|
|
16
9
|
this.currentCwd = process.cwd();
|
|
17
|
-
this.sessionStart = Date.now();
|
|
18
10
|
// ── Subscribe to shell events ──
|
|
19
11
|
bus.on("shell:command-done", (e) => {
|
|
20
12
|
const lines = e.output.split("\n");
|
|
13
|
+
const s = getSettings();
|
|
14
|
+
// Spill long outputs to a tempfile so the agent can `read_file` them
|
|
15
|
+
// on demand instead of carrying the full text in LLM context.
|
|
16
|
+
let output = e.output;
|
|
17
|
+
let spillPath;
|
|
18
|
+
if (lines.length > s.shellTruncateThreshold) {
|
|
19
|
+
// Reserve the id we're about to assign so the tempfile name matches.
|
|
20
|
+
const id = this.nextId;
|
|
21
|
+
try {
|
|
22
|
+
spillPath = spillOutput(id, e.output);
|
|
23
|
+
output = buildSpillStub(lines, s.shellHeadLines, s.shellTailLines, spillPath);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// If spill fails (e.g. disk full), fall back to keeping output in memory.
|
|
27
|
+
output = e.output;
|
|
28
|
+
spillPath = undefined;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
21
31
|
this.addExchange({
|
|
22
32
|
type: "shell_command",
|
|
23
33
|
command: e.command,
|
|
24
|
-
output
|
|
34
|
+
output,
|
|
25
35
|
cwd: e.cwd,
|
|
26
36
|
exitCode: e.exitCode,
|
|
27
37
|
outputLines: lines.length,
|
|
28
38
|
outputBytes: e.output.length,
|
|
29
39
|
source: this.agentShellActive ? "agent" : "user",
|
|
40
|
+
spillPath,
|
|
30
41
|
});
|
|
31
42
|
});
|
|
32
43
|
bus.on("shell:cwd-change", (e) => {
|
|
@@ -46,16 +57,6 @@ export class ContextManager {
|
|
|
46
57
|
getCwd() {
|
|
47
58
|
return this.currentCwd;
|
|
48
59
|
}
|
|
49
|
-
/**
|
|
50
|
-
* Build the <shell_context> block for the agent prompt.
|
|
51
|
-
* Pipeline: window → truncate → format
|
|
52
|
-
*/
|
|
53
|
-
getContext(budget) {
|
|
54
|
-
budget ??= getSettings().contextBudget;
|
|
55
|
-
let exchanges = this.applyWindow(this.exchanges);
|
|
56
|
-
exchanges = this.applyTruncation(exchanges, budget);
|
|
57
|
-
return this.formatContext(exchanges);
|
|
58
|
-
}
|
|
59
60
|
/**
|
|
60
61
|
* Regex/keyword search across all exchanges. Returns formatted results.
|
|
61
62
|
*/
|
|
@@ -106,40 +107,6 @@ export class ContextManager {
|
|
|
106
107
|
}
|
|
107
108
|
return parts.join("\n");
|
|
108
109
|
}
|
|
109
|
-
/**
|
|
110
|
-
* Return content for specific exchange IDs.
|
|
111
|
-
* Optional start/end restrict to a line range (1-indexed).
|
|
112
|
-
*/
|
|
113
|
-
expand(ids, start, end) {
|
|
114
|
-
const results = [];
|
|
115
|
-
for (const id of ids) {
|
|
116
|
-
const ex = this.exchanges.find((e) => e.id === id);
|
|
117
|
-
if (!ex) {
|
|
118
|
-
results.push(`#${id}: not found`);
|
|
119
|
-
continue;
|
|
120
|
-
}
|
|
121
|
-
const text = this.formatExchangeFull(ex);
|
|
122
|
-
const lines = text.split("\n");
|
|
123
|
-
const total = lines.length;
|
|
124
|
-
if (start != null || end != null) {
|
|
125
|
-
// Line range requested
|
|
126
|
-
const s = Math.max(0, (start ?? 1) - 1);
|
|
127
|
-
const e = end ?? total;
|
|
128
|
-
results.push(lines.slice(s, e).join("\n") +
|
|
129
|
-
`\n[showing lines ${s + 1}-${Math.min(e, total)} of ${total}]`);
|
|
130
|
-
}
|
|
131
|
-
else if (total > getSettings().recallExpandMaxLines) {
|
|
132
|
-
// Too large — tell the agent to narrow down
|
|
133
|
-
results.push(`#${ex.id}: output is ${total} lines, too large to expand fully. ` +
|
|
134
|
-
`Use start/end params to select a line range (e.g. start=1, end=50), ` +
|
|
135
|
-
`or use search with a regex to find specific content.`);
|
|
136
|
-
}
|
|
137
|
-
else {
|
|
138
|
-
results.push(text);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
return results.join("\n\n");
|
|
142
|
-
}
|
|
143
110
|
/**
|
|
144
111
|
* Return shell events with id > afterId, formatted as an incremental
|
|
145
112
|
* delta suitable for injection into conversation history. Skips
|
|
@@ -156,18 +123,8 @@ export class ContextManager {
|
|
|
156
123
|
if (fresh.length === 0)
|
|
157
124
|
return null;
|
|
158
125
|
const lastSeq = this.exchanges[this.exchanges.length - 1].id;
|
|
159
|
-
//
|
|
160
|
-
const
|
|
161
|
-
if (ex.type === "shell_command") {
|
|
162
|
-
const s = getSettings();
|
|
163
|
-
return {
|
|
164
|
-
...ex,
|
|
165
|
-
output: truncateOutput(ex.output, s.shellTruncateThreshold, s.shellHeadLines, s.shellTailLines, ex.id),
|
|
166
|
-
};
|
|
167
|
-
}
|
|
168
|
-
return { ...ex };
|
|
169
|
-
});
|
|
170
|
-
const body = truncated.map((ex) => this.formatExchangeTruncated(ex)).join("\n");
|
|
126
|
+
// Outputs already carry head+tail+spillPath stubs from capture time.
|
|
127
|
+
const body = fresh.map((ex) => this.formatExchangeTruncated(ex)).join("\n");
|
|
171
128
|
return {
|
|
172
129
|
text: `<shell-events>\n${body}</shell-events>`,
|
|
173
130
|
lastSeq,
|
|
@@ -186,104 +143,13 @@ export class ContextManager {
|
|
|
186
143
|
return "No exchanges yet.";
|
|
187
144
|
return recent.map((ex) => this.exchangeOneLiner(ex)).join("\n");
|
|
188
145
|
}
|
|
189
|
-
/**
|
|
190
|
-
* Parse and handle shell_recall commands.
|
|
191
|
-
*/
|
|
192
|
-
handleRecallCommand(command) {
|
|
193
|
-
const args = command.replace(/^_*shell_recall\s*/, "").trim();
|
|
194
|
-
if (!args || args === "--help") {
|
|
195
|
-
return [
|
|
196
|
-
"Usage:",
|
|
197
|
-
" shell_recall Browse recent exchanges",
|
|
198
|
-
" shell_recall --search <query> Search all exchanges",
|
|
199
|
-
" shell_recall --expand <id,...> Show full content of exchanges",
|
|
200
|
-
"",
|
|
201
|
-
"Examples:",
|
|
202
|
-
' shell_recall --search "test fail"',
|
|
203
|
-
" shell_recall --expand 41",
|
|
204
|
-
" shell_recall --expand 41,42,43",
|
|
205
|
-
].join("\n");
|
|
206
|
-
}
|
|
207
|
-
const searchMatch = args.match(/^--search\s+(?:"([^"]+)"|(\S+))/);
|
|
208
|
-
if (searchMatch) {
|
|
209
|
-
return this.search(searchMatch[1] ?? searchMatch[2] ?? "");
|
|
210
|
-
}
|
|
211
|
-
const expandMatch = args.match(/^--expand\s+([\d,\s]+)/);
|
|
212
|
-
if (expandMatch) {
|
|
213
|
-
const ids = expandMatch[1]
|
|
214
|
-
.split(/[,\s]+/)
|
|
215
|
-
.map(Number)
|
|
216
|
-
.filter((n) => !isNaN(n));
|
|
217
|
-
if (ids.length === 0)
|
|
218
|
-
return "No valid IDs provided.";
|
|
219
|
-
return this.expand(ids);
|
|
220
|
-
}
|
|
221
|
-
// Default: browse
|
|
222
|
-
return this.getRecentSummary();
|
|
223
|
-
}
|
|
224
146
|
/**
|
|
225
147
|
* Clear exchange history (used by /clear command).
|
|
226
148
|
*/
|
|
227
149
|
clear() {
|
|
228
150
|
this.exchanges = [];
|
|
229
|
-
this.firstPrompt = true;
|
|
230
151
|
// Don't reset nextId — IDs should be globally unique within a session
|
|
231
152
|
}
|
|
232
|
-
// ── Pipeline stages ───────────────────────────────────────────
|
|
233
|
-
applyWindow(exchanges, windowSize) {
|
|
234
|
-
windowSize ??= getSettings().contextWindowSize;
|
|
235
|
-
return exchanges.slice(-windowSize);
|
|
236
|
-
}
|
|
237
|
-
applyTruncation(exchanges, budget) {
|
|
238
|
-
// Deep clone so we don't mutate the source
|
|
239
|
-
const result = exchanges.map((e) => ({ ...e }));
|
|
240
|
-
// Pass 1: per-type truncation
|
|
241
|
-
for (const ex of result) {
|
|
242
|
-
if (ex.type === "shell_command") {
|
|
243
|
-
const s = getSettings();
|
|
244
|
-
ex.output = truncateOutput(ex.output, s.shellTruncateThreshold, s.shellHeadLines, s.shellTailLines, ex.id);
|
|
245
|
-
}
|
|
246
|
-
// agent_query has no output to truncate
|
|
247
|
-
}
|
|
248
|
-
// Pass 2: budget enforcement — strip output from oldest if over budget
|
|
249
|
-
let totalSize = result.reduce((sum, ex) => sum + this.exchangeSize(ex), 0);
|
|
250
|
-
for (let i = 0; i < result.length - 1 && totalSize > budget; i++) {
|
|
251
|
-
const ex = result[i];
|
|
252
|
-
const before = this.exchangeSize(ex);
|
|
253
|
-
if (ex.type === "shell_command") {
|
|
254
|
-
ex.output = `[output omitted, use shell_recall tool to expand id ${ex.id}]`;
|
|
255
|
-
}
|
|
256
|
-
totalSize -= before - this.exchangeSize(ex);
|
|
257
|
-
}
|
|
258
|
-
return result;
|
|
259
|
-
}
|
|
260
|
-
formatContext(exchanges) {
|
|
261
|
-
const elapsed = Math.round((Date.now() - this.sessionStart) / 60000);
|
|
262
|
-
const totalCount = this.exchanges.length;
|
|
263
|
-
let out = "<shell_context>\n";
|
|
264
|
-
if (this.firstPrompt) {
|
|
265
|
-
out += `You are an AI assistant living inside agent-sh, a shell-first terminal.\n`;
|
|
266
|
-
out += `The user interacts with a real shell (PTY) and sends you queries inline. You are there to help them with their tasks.\n`;
|
|
267
|
-
out += `\n`;
|
|
268
|
-
out += `IMPORTANT tool usage rules:\n`;
|
|
269
|
-
out += `- Your internal tools (bash, read, write, ls, etc.) run in an isolated subprocess. The user CANNOT see their output.\n`;
|
|
270
|
-
out += `- Only use internal tools when YOU need to reason about content silently (e.g. reading a file to answer a question about it).\n`;
|
|
271
|
-
out += `- You can browse or search shell history with shell_recall.\n`;
|
|
272
|
-
out += `\n`;
|
|
273
|
-
this.firstPrompt = false;
|
|
274
|
-
}
|
|
275
|
-
out += `cwd: ${this.currentCwd}\n`;
|
|
276
|
-
out += `session: ${totalCount} exchanges, ${elapsed}m elapsed\n`;
|
|
277
|
-
for (const ex of exchanges) {
|
|
278
|
-
out += "\n" + this.formatExchangeTruncated(ex);
|
|
279
|
-
}
|
|
280
|
-
// Allow extensions to inject extra context (e.g. terminal buffer snapshot)
|
|
281
|
-
const extra = this.handlers?.call("context:build-extra");
|
|
282
|
-
if (extra)
|
|
283
|
-
out += "\n" + extra + "\n";
|
|
284
|
-
out += "\n</shell_context>\n";
|
|
285
|
-
return out;
|
|
286
|
-
}
|
|
287
153
|
// ── Internal helpers ──────────────────────────────────────────
|
|
288
154
|
addExchange(partial) {
|
|
289
155
|
const exchange = {
|
|
@@ -352,14 +218,11 @@ export class ContextManager {
|
|
|
352
218
|
}
|
|
353
219
|
}
|
|
354
220
|
// ── Utility functions ─────────────────────────────────────────
|
|
355
|
-
function
|
|
356
|
-
const lines = text.split("\n");
|
|
357
|
-
if (lines.length <= threshold)
|
|
358
|
-
return text;
|
|
221
|
+
function buildSpillStub(lines, headLines, tailLines, spillPath) {
|
|
359
222
|
const omitted = lines.length - headLines - tailLines;
|
|
360
223
|
return [
|
|
361
224
|
...lines.slice(0, headLines),
|
|
362
|
-
`[... ${omitted} lines truncated
|
|
225
|
+
`[... ${omitted} lines truncated — full output at ${spillPath}; use read_file to expand ...]`,
|
|
363
226
|
...lines.slice(-tailLines),
|
|
364
227
|
].join("\n");
|
|
365
228
|
}
|
package/dist/event-bus.d.ts
CHANGED
package/dist/extension-loader.js
CHANGED
|
@@ -5,12 +5,33 @@ const EXT_DIR = path.join(CONFIG_DIR, "extensions");
|
|
|
5
5
|
const TS_EXTS = [".ts", ".tsx", ".mts"];
|
|
6
6
|
const SCRIPT_EXTS = [".js", ".mjs", ".ts", ".tsx", ".mts"];
|
|
7
7
|
let tsRegistered = false;
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
let tsxUnregister = null;
|
|
9
|
+
/**
|
|
10
|
+
* Register tsx's ESM loader for .ts file support.
|
|
11
|
+
*
|
|
12
|
+
* Called before importing .ts extensions. The tsx loader uses Node's
|
|
13
|
+
* module.register() which creates a background thread with a MessageChannel.
|
|
14
|
+
* On reload, the old loader may become stale (the MessageChannel port can be
|
|
15
|
+
* GC'd or the loader thread can stop responding), so we unregister the old
|
|
16
|
+
* handle and re-register on each reload.
|
|
17
|
+
*
|
|
18
|
+
* Initial load: registers fresh.
|
|
19
|
+
* Reload: unregisters old handle, registers new one.
|
|
20
|
+
* Non-reload calls within the same load: no-op (tsRegistered guard).
|
|
21
|
+
*/
|
|
22
|
+
async function ensureTsSupport(force = false) {
|
|
23
|
+
if (tsRegistered && !force)
|
|
10
24
|
return;
|
|
11
25
|
try {
|
|
26
|
+
// Unregister previous loader if reloading
|
|
27
|
+
if (tsxUnregister) {
|
|
28
|
+
try {
|
|
29
|
+
await tsxUnregister();
|
|
30
|
+
}
|
|
31
|
+
catch { /* ignore stale handle */ }
|
|
32
|
+
}
|
|
12
33
|
const { register } = await import("tsx/esm/api");
|
|
13
|
-
register();
|
|
34
|
+
tsxUnregister = register();
|
|
14
35
|
tsRegistered = true;
|
|
15
36
|
}
|
|
16
37
|
catch {
|
|
@@ -166,7 +187,7 @@ async function loadSpecifiers(specifiers, ctx, bustCache, userSpecifiers) {
|
|
|
166
187
|
try {
|
|
167
188
|
let importPath = await resolveSpecifier(specifier);
|
|
168
189
|
if (TS_EXTS.some((ext) => importPath.endsWith(ext))) {
|
|
169
|
-
await ensureTsSupport();
|
|
190
|
+
await ensureTsSupport(bustCache);
|
|
170
191
|
}
|
|
171
192
|
// Append timestamp query to bust Node's module cache on reload
|
|
172
193
|
if (bustCache) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { AgentLoop } from "../agent/agent-loop.js";
|
|
2
2
|
import { LlmClient } from "../utils/llm-client.js";
|
|
3
3
|
import { resolveProvider, getProviderNames, getSettings } from "../settings.js";
|
|
4
|
+
import { PACKAGE_VERSION } from "../utils/package-version.js";
|
|
4
5
|
/** Read the user's persisted defaultModel for a provider, if any. */
|
|
5
6
|
function persistedModelFor(providerName) {
|
|
6
7
|
if (!providerName)
|
|
@@ -71,7 +72,7 @@ export default function agentBackend(ctx) {
|
|
|
71
72
|
agentLoop.wire();
|
|
72
73
|
bus.emit("agent:info", {
|
|
73
74
|
name: "ash",
|
|
74
|
-
version:
|
|
75
|
+
version: PACKAGE_VERSION,
|
|
75
76
|
model: llmClient.model,
|
|
76
77
|
provider: modes[initialModeIndex]?.provider,
|
|
77
78
|
contextWindow: modes[initialModeIndex]?.contextWindow,
|
|
@@ -181,7 +182,7 @@ export default function agentBackend(ctx) {
|
|
|
181
182
|
};
|
|
182
183
|
});
|
|
183
184
|
bus.emit("config:set-modes", { modes: newModes });
|
|
184
|
-
bus.emit("agent:info", { name: "ash", version:
|
|
185
|
+
bus.emit("agent:info", { name: "ash", version: PACKAGE_VERSION, model: switchModel, provider: name, contextWindow: p.contextWindow });
|
|
185
186
|
bus.emit("ui:info", { message: `Switched to ${name} (${switchModel})` });
|
|
186
187
|
bus.emit("config:changed", {});
|
|
187
188
|
});
|
package/dist/extensions/index.js
CHANGED
|
@@ -3,7 +3,6 @@ export const BUILTIN_EXTENSIONS = [
|
|
|
3
3
|
{ name: "tui-renderer", load: () => import("./tui-renderer.js").then(m => m.default) },
|
|
4
4
|
{ name: "slash-commands", load: () => import("./slash-commands.js").then(m => m.default) },
|
|
5
5
|
{ name: "file-autocomplete", load: () => import("./file-autocomplete.js").then(m => m.default) },
|
|
6
|
-
{ name: "shell-recall", load: () => import("./shell-recall.js").then(m => m.default) },
|
|
7
6
|
{ name: "command-suggest", load: () => import("./command-suggest.js").then(m => m.default) },
|
|
8
7
|
];
|
|
9
8
|
/**
|
|
@@ -48,7 +48,8 @@ function createRenderState() {
|
|
|
48
48
|
spinnerOpts: {},
|
|
49
49
|
spinnerInterval: null,
|
|
50
50
|
spinnerStartTime: 0,
|
|
51
|
-
|
|
51
|
+
openTool: null,
|
|
52
|
+
pendingToolCompletes: new Map(),
|
|
52
53
|
currentToolKind: undefined,
|
|
53
54
|
toolStartTime: 0,
|
|
54
55
|
toolExitCode: null,
|
|
@@ -58,6 +59,7 @@ function createRenderState() {
|
|
|
58
59
|
commandOverflowLines: [],
|
|
59
60
|
toolGroupKind: undefined,
|
|
60
61
|
toolGroupCount: 0,
|
|
62
|
+
toolGroupCompletedCount: 0,
|
|
61
63
|
toolGroupAllOk: true,
|
|
62
64
|
toolGroupRendered: 0,
|
|
63
65
|
toolGroupSummaries: [],
|
|
@@ -74,8 +76,11 @@ export default function activate(ctx) {
|
|
|
74
76
|
bus.on("shell:cwd-change", (e) => { shellCwd = e.cwd; });
|
|
75
77
|
/** Shorthand — get the current agent surface. */
|
|
76
78
|
function out() { return compositor.surface("agent"); }
|
|
77
|
-
/** Capped width for borders, tool lines, and content — keeps everything aligned.
|
|
78
|
-
|
|
79
|
+
/** Capped width for borders, tool lines, and content — keeps everything aligned.
|
|
80
|
+
* MarkdownRenderer.writeLine prepends a 2-char indent (" ") to every line,
|
|
81
|
+
* so available width for actual content is columns - 2. Subtract an additional
|
|
82
|
+
* 1 to prevent terminal auto-wrap when a line lands exactly at the right edge. */
|
|
83
|
+
function cappedW() { return Math.min(MAX_CONTENT_WIDTH + 2, out().columns) - 2 - 1; }
|
|
79
84
|
// Gate: other extensions (e.g. overlay) can advise this to suppress
|
|
80
85
|
// TUI rendering of agent output while they own the display.
|
|
81
86
|
define("tui:should-render-agent", () => true);
|
|
@@ -228,6 +233,9 @@ export default function activate(ctx) {
|
|
|
228
233
|
return;
|
|
229
234
|
s.isThinking = false;
|
|
230
235
|
if (pendingUsage && s.renderer) {
|
|
236
|
+
// Flush any buffered partial line first — otherwise responses that
|
|
237
|
+
// don't end with a newline emit the usage line before their final text.
|
|
238
|
+
s.renderer.flush();
|
|
231
239
|
const { prompt_tokens, completion_tokens } = pendingUsage;
|
|
232
240
|
const maxTokens = backendInfo?.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
|
|
233
241
|
s.renderer.writeLine("");
|
|
@@ -297,29 +305,26 @@ export default function activate(ctx) {
|
|
|
297
305
|
group.headerShown = true;
|
|
298
306
|
s.toolGroupKind = kind;
|
|
299
307
|
s.toolGroupCount = 0;
|
|
308
|
+
s.toolGroupCompletedCount = 0;
|
|
300
309
|
s.toolGroupRendered = 0;
|
|
301
310
|
s.toolGroupAllOk = true;
|
|
302
311
|
s.toolGroupSummaries = [];
|
|
303
312
|
}
|
|
304
313
|
s.toolGroupCount++;
|
|
305
314
|
if (s.toolGroupRendered < GROUP_MAX_VISIBLE) {
|
|
306
|
-
showToolCall(e.title, "", {
|
|
307
|
-
...e,
|
|
308
|
-
batchIndex: e.batchIndex,
|
|
309
|
-
batchTotal: e.batchTotal,
|
|
310
|
-
groupContinuation: true,
|
|
311
|
-
});
|
|
315
|
+
showToolCall(e.title, "", { ...e, groupContinuation: true });
|
|
312
316
|
s.toolGroupRendered++;
|
|
313
317
|
}
|
|
318
|
+
// Record identity so late completes (after a premature finalize
|
|
319
|
+
// from a cross-kind standalone start) can render as labeled ⎿ lines.
|
|
320
|
+
if (e.toolCallId) {
|
|
321
|
+
s.pendingToolCompletes.set(e.toolCallId, { title: e.title });
|
|
322
|
+
}
|
|
314
323
|
}
|
|
315
324
|
else {
|
|
316
325
|
// Standalone tool — single in its batch kind, or not groupable
|
|
317
326
|
finalizeToolGroup();
|
|
318
|
-
showToolCall(e.title, "", {
|
|
319
|
-
...e,
|
|
320
|
-
batchIndex: e.batchIndex,
|
|
321
|
-
batchTotal: e.batchTotal,
|
|
322
|
-
});
|
|
327
|
+
showToolCall(e.title, "", { ...e });
|
|
323
328
|
}
|
|
324
329
|
});
|
|
325
330
|
bus.on("agent:tool-completed", (e) => {
|
|
@@ -333,10 +338,17 @@ export default function activate(ctx) {
|
|
|
333
338
|
// Don't restart spinner between grouped tools — it's already running from group start.
|
|
334
339
|
if (e.resultDisplay?.summary)
|
|
335
340
|
s.toolGroupSummaries.push(e.resultDisplay.summary);
|
|
341
|
+
if (e.toolCallId)
|
|
342
|
+
s.pendingToolCompletes.delete(e.toolCallId);
|
|
343
|
+
s.toolGroupCompletedCount++;
|
|
336
344
|
s.currentToolKind = undefined;
|
|
337
345
|
}
|
|
338
346
|
else {
|
|
339
|
-
|
|
347
|
+
// Route by callId — tools that lost the inline slot get a labeled ⎿ line.
|
|
348
|
+
const pending = e.toolCallId ? s.pendingToolCompletes.get(e.toolCallId) : undefined;
|
|
349
|
+
if (pending)
|
|
350
|
+
s.pendingToolCompletes.delete(e.toolCallId);
|
|
351
|
+
showToolComplete(e.exitCode, e.resultDisplay, pending?.title);
|
|
340
352
|
s.currentToolKind = undefined;
|
|
341
353
|
s.spinnerStartTime = 0;
|
|
342
354
|
startThinkingSpinner();
|
|
@@ -731,37 +743,37 @@ export default function activate(ctx) {
|
|
|
731
743
|
// Grouped tools: close the line immediately — checkmarks go on the ⎿ summary
|
|
732
744
|
s.renderer.writeLine(` ${batchPrefix}${lines[lines.length - 1]}`);
|
|
733
745
|
drain();
|
|
734
|
-
s.toolLineOpen = false;
|
|
735
746
|
}
|
|
736
747
|
else {
|
|
737
748
|
out().write(` ${batchPrefix}${lines[lines.length - 1]}`);
|
|
738
|
-
|
|
749
|
+
if (extra?.toolCallId)
|
|
750
|
+
s.openTool = { callId: extra.toolCallId, title };
|
|
739
751
|
}
|
|
740
752
|
}
|
|
741
753
|
s.hadToolCalls = true;
|
|
742
754
|
s.commandOutputLineCount = 0;
|
|
743
755
|
s.commandOutputOverflow = 0;
|
|
744
756
|
}
|
|
745
|
-
function showToolComplete(exitCode, resultDisplay) {
|
|
757
|
+
function showToolComplete(exitCode, resultDisplay, labelTitle) {
|
|
746
758
|
if (!s.renderer)
|
|
747
759
|
return;
|
|
748
760
|
stopCurrentSpinner();
|
|
749
761
|
const elapsed = s.toolStartTime ? formatElapsed(Date.now() - s.toolStartTime) : "";
|
|
750
762
|
const mark = ctx.call("tui:render-tool-complete", exitCode, elapsed, resultDisplay?.summary);
|
|
751
|
-
if (s.
|
|
763
|
+
if (!labelTitle && s.openTool && s.commandOutputLineCount === 0) {
|
|
752
764
|
out().write(` ${mark}\n`);
|
|
753
|
-
s.
|
|
765
|
+
s.openTool = null;
|
|
754
766
|
}
|
|
755
767
|
else {
|
|
756
768
|
closeToolLine();
|
|
757
769
|
flushCommandOutput();
|
|
758
|
-
s.renderer.writeLine(
|
|
770
|
+
s.renderer.writeLine(labelTitle
|
|
771
|
+
? ` ${p.muted}⎿${p.reset} ${p.dim}${labelTitle}${p.reset} ${mark}`
|
|
772
|
+
: ` ${mark}`);
|
|
759
773
|
drain();
|
|
760
774
|
}
|
|
761
|
-
|
|
762
|
-
if (resultDisplay?.body) {
|
|
775
|
+
if (resultDisplay?.body)
|
|
763
776
|
renderResultBody(resultDisplay.body);
|
|
764
|
-
}
|
|
765
777
|
}
|
|
766
778
|
function renderResultBody(body) {
|
|
767
779
|
if (!s.renderer)
|
|
@@ -810,18 +822,23 @@ export default function activate(ctx) {
|
|
|
810
822
|
}
|
|
811
823
|
}
|
|
812
824
|
function closeToolLine() {
|
|
813
|
-
if (s.
|
|
825
|
+
if (s.openTool) {
|
|
814
826
|
out().write("\n");
|
|
815
|
-
|
|
827
|
+
// Stash identity so the completion renders as ⎿ labeled, not orphan ✓.
|
|
828
|
+
s.pendingToolCompletes.set(s.openTool.callId, { title: s.openTool.title });
|
|
829
|
+
s.openTool = null;
|
|
816
830
|
}
|
|
817
831
|
}
|
|
818
|
-
/**
|
|
832
|
+
/** Render the group aggregate ⎿ line, or skip if no members have
|
|
833
|
+
* completed yet (late completes will render individually as ⎿ labeled). */
|
|
819
834
|
function finalizeToolGroup() {
|
|
820
|
-
|
|
821
|
-
|
|
835
|
+
const skipAggregate = s.toolGroupCount > 1 && s.toolGroupCompletedCount === 0;
|
|
836
|
+
if (s.toolGroupCount <= 1 || skipAggregate) {
|
|
822
837
|
s.toolGroupKind = undefined;
|
|
823
838
|
s.toolGroupCount = 0;
|
|
839
|
+
s.toolGroupCompletedCount = 0;
|
|
824
840
|
s.toolGroupRendered = 0;
|
|
841
|
+
s.toolGroupAllOk = true;
|
|
825
842
|
s.toolGroupSummaries = [];
|
|
826
843
|
return;
|
|
827
844
|
}
|
|
@@ -833,6 +850,7 @@ export default function activate(ctx) {
|
|
|
833
850
|
drain();
|
|
834
851
|
s.toolGroupKind = undefined;
|
|
835
852
|
s.toolGroupCount = 0;
|
|
853
|
+
s.toolGroupCompletedCount = 0;
|
|
836
854
|
s.toolGroupAllOk = true;
|
|
837
855
|
s.toolGroupRendered = 0;
|
|
838
856
|
s.toolGroupSummaries = [];
|
package/dist/settings.d.ts
CHANGED
|
@@ -32,20 +32,12 @@ export interface Settings {
|
|
|
32
32
|
defaultProvider?: string;
|
|
33
33
|
/** Preferred agent backend (extension name, e.g. "pi", "claude-code"). */
|
|
34
34
|
defaultBackend?: string;
|
|
35
|
-
/**
|
|
36
|
-
contextWindowSize?: number;
|
|
37
|
-
/** Context budget in bytes (~4 chars per token). */
|
|
38
|
-
contextBudget?: number;
|
|
39
|
-
/** Shell output lines before truncation kicks in. */
|
|
35
|
+
/** Shell output lines before spill-to-tempfile kicks in. */
|
|
40
36
|
shellTruncateThreshold?: number;
|
|
41
|
-
/** Lines kept from start of
|
|
37
|
+
/** Lines kept from start of spilled shell output. */
|
|
42
38
|
shellHeadLines?: number;
|
|
43
|
-
/** Lines kept from end of
|
|
39
|
+
/** Lines kept from end of spilled shell output. */
|
|
44
40
|
shellTailLines?: number;
|
|
45
|
-
/** Max lines for recall expand before requiring line ranges. */
|
|
46
|
-
recallExpandMaxLines?: number;
|
|
47
|
-
/** Fraction of content budget allocated to shell context (0-1, default 0.35). */
|
|
48
|
-
shellContextRatio?: number;
|
|
49
41
|
/** Max history file size in bytes (default: 102400 = 100KB). */
|
|
50
42
|
historyMaxBytes?: number;
|
|
51
43
|
/** Number of prior history entries to load on startup (default: 50). */
|
package/dist/settings.js
CHANGED
|
@@ -16,13 +16,9 @@ const DEFAULTS = {
|
|
|
16
16
|
defaultProvider: undefined,
|
|
17
17
|
defaultBackend: "ash",
|
|
18
18
|
toolMode: "api",
|
|
19
|
-
contextWindowSize: 20,
|
|
20
|
-
contextBudget: 32768,
|
|
21
19
|
shellTruncateThreshold: 20,
|
|
22
20
|
shellHeadLines: 10,
|
|
23
21
|
shellTailLines: 10,
|
|
24
|
-
recallExpandMaxLines: 500,
|
|
25
|
-
shellContextRatio: 0.35,
|
|
26
22
|
historyMaxBytes: 104857600, // 100MB — history is only accessed via search/expand, never loaded wholesale
|
|
27
23
|
historyStartupEntries: 100,
|
|
28
24
|
autoCompactThreshold: 0.5,
|
|
@@ -107,11 +107,11 @@ export class InputHandler {
|
|
|
107
107
|
p.accent + after + p.reset +
|
|
108
108
|
"\x1b8" // DECRC — restore cursor position
|
|
109
109
|
);
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
this.cursorRowsBelow = totalVisLen > 0 ? Math.ceil(totalVisLen / termW) - 1 : 0;
|
|
110
|
+
// cursorRowsBelow is distance from cursor (restored by DECRC, sitting at
|
|
111
|
+
// the cursor col) back up to the prompt's top row. Next redraw uses it
|
|
112
|
+
// with \x1b[${n}A then \x1b[J — moving past the top scrolls the screen.
|
|
114
113
|
const cursorVisCol = promptVisLen + visibleLen(before);
|
|
114
|
+
this.cursorRowsBelow = cursorVisCol > 0 ? Math.ceil(cursorVisCol / termW) - 1 : 0;
|
|
115
115
|
this.cursorTermCol = cursorVisCol === 0 ? 1 : (cursorVisCol % termW === 0 ? termW : (cursorVisCol % termW) + 1);
|
|
116
116
|
}
|
|
117
117
|
else {
|
|
@@ -157,8 +157,10 @@ export class InputHandler {
|
|
|
157
157
|
rowsSoFar += lineTermRows;
|
|
158
158
|
}
|
|
159
159
|
process.stdout.write(output + "\x1b8"); // DECRC — restore cursor position
|
|
160
|
-
//
|
|
161
|
-
|
|
160
|
+
// Distance from cursor (where DECRC lands) back to the top row. Next
|
|
161
|
+
// redraw moves up by this and clears to end-of-screen — \x1b[J handles
|
|
162
|
+
// everything below, including rows after the cursor's logical line.
|
|
163
|
+
this.cursorRowsBelow = cursorRowFromTop;
|
|
162
164
|
}
|
|
163
165
|
}
|
|
164
166
|
handleInput(data) {
|
|
@@ -216,6 +218,12 @@ export class InputHandler {
|
|
|
216
218
|
this.lineBuffer = "";
|
|
217
219
|
this.ctx.writeToPty(ch);
|
|
218
220
|
}
|
|
221
|
+
else if (ch === "\x0b" || ch === "\x15") {
|
|
222
|
+
// Ctrl-K / Ctrl-U kill the line in the shell; mirror that so the
|
|
223
|
+
// mode-trigger check sees an empty buffer. Not cursor-accurate.
|
|
224
|
+
this.lineBuffer = "";
|
|
225
|
+
this.ctx.writeToPty(ch);
|
|
226
|
+
}
|
|
219
227
|
else if (ch === "\x1b") {
|
|
220
228
|
// Escape sequence — forward the entire sequence to the PTY but
|
|
221
229
|
// don't let it corrupt lineBuffer. Skip CSI (ESC [ ... final)
|
|
@@ -519,9 +527,6 @@ export class InputHandler {
|
|
|
519
527
|
this.applyAutocomplete();
|
|
520
528
|
}
|
|
521
529
|
break;
|
|
522
|
-
case "shift+tab":
|
|
523
|
-
this.bus.emit("config:cycle", {});
|
|
524
|
-
break;
|
|
525
530
|
case "arrow-up":
|
|
526
531
|
if (this.autocompleteActive) {
|
|
527
532
|
this.autocompleteIndex =
|
package/dist/types.d.ts
CHANGED
|
@@ -158,12 +158,15 @@ export type Exchange = {
|
|
|
158
158
|
timestamp: number;
|
|
159
159
|
cwd: string;
|
|
160
160
|
command: string;
|
|
161
|
+
/** In-context representation: full text if short, head+tail+path stub if spilled. */
|
|
161
162
|
output: string;
|
|
162
163
|
exitCode: number | null;
|
|
163
164
|
outputLines: number;
|
|
164
165
|
outputBytes: number;
|
|
165
166
|
/** Who initiated this command: "user" (typed) or "agent" (via user_shell). */
|
|
166
167
|
source: "user" | "agent";
|
|
168
|
+
/** Path to the tempfile holding the full captured output, if spilled. */
|
|
169
|
+
spillPath?: string;
|
|
167
170
|
} | {
|
|
168
171
|
type: "agent_query";
|
|
169
172
|
id: number;
|
package/dist/utils/ansi.d.ts
CHANGED
|
@@ -8,7 +8,9 @@ export declare const BOLD = "\u001B[1m";
|
|
|
8
8
|
export declare const RESET = "\u001B[0m";
|
|
9
9
|
/**
|
|
10
10
|
* Check if a Unicode code point is a wide character (CJK, fullwidth, emoji, etc.)
|
|
11
|
-
* Returns 2 for wide chars, 1 for normal chars.
|
|
11
|
+
* Returns 2 for wide chars, 1 for normal chars, 0 for combining chars.
|
|
12
|
+
*
|
|
13
|
+
* Based on East Asian Width and Unicode categories.
|
|
12
14
|
*/
|
|
13
15
|
export declare function charWidth(codePoint: number): number;
|
|
14
16
|
/**
|
|
@@ -21,6 +23,9 @@ export declare function visibleLen(str: string): number;
|
|
|
21
23
|
* Accounts for CJK double-width characters. Appends `…` if truncated.
|
|
22
24
|
*/
|
|
23
25
|
export declare function truncateToWidth(str: string, maxWidth: number): string;
|
|
26
|
+
/** Truncate to visible width while preserving SGR sequences — use when
|
|
27
|
+
* input carries color/bold codes. `truncateToWidth` strips them. */
|
|
28
|
+
export declare function truncateAnsiToWidth(str: string, maxWidth: number): string;
|
|
24
29
|
/**
|
|
25
30
|
* Pad a string with spaces to fill `targetWidth` visible columns.
|
|
26
31
|
* Accounts for CJK double-width characters.
|