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.
Files changed (40) hide show
  1. package/README.md +12 -9
  2. package/dist/agent/agent-loop.d.ts +0 -3
  3. package/dist/agent/agent-loop.js +18 -35
  4. package/dist/agent/conversation-state.js +8 -2
  5. package/dist/agent/nuclear-form.d.ts +2 -0
  6. package/dist/agent/nuclear-form.js +11 -1
  7. package/dist/agent/system-prompt.js +1 -1
  8. package/dist/agent/token-budget.d.ts +8 -12
  9. package/dist/agent/token-budget.js +5 -40
  10. package/dist/agent/tool-registry.js +6 -0
  11. package/dist/agent/types.d.ts +3 -1
  12. package/dist/context-manager.d.ts +1 -21
  13. package/dist/context-manager.js +26 -163
  14. package/dist/event-bus.d.ts +0 -1
  15. package/dist/extension-loader.js +25 -4
  16. package/dist/extensions/agent-backend.js +3 -2
  17. package/dist/extensions/index.js +0 -1
  18. package/dist/extensions/tui-renderer.js +47 -29
  19. package/dist/settings.d.ts +3 -11
  20. package/dist/settings.js +0 -4
  21. package/dist/shell/input-handler.js +14 -9
  22. package/dist/types.d.ts +3 -0
  23. package/dist/utils/ansi.d.ts +6 -1
  24. package/dist/utils/ansi.js +114 -7
  25. package/dist/utils/box-frame.js +8 -2
  26. package/dist/utils/llm-client.d.ts +4 -0
  27. package/dist/utils/llm-client.js +8 -0
  28. package/dist/utils/markdown.d.ts +4 -0
  29. package/dist/utils/markdown.js +136 -48
  30. package/dist/utils/package-version.d.ts +1 -0
  31. package/dist/utils/package-version.js +10 -0
  32. package/dist/utils/shell-output-spill.d.ts +2 -0
  33. package/dist/utils/shell-output-spill.js +81 -0
  34. package/examples/extensions/claude-code-bridge/README.md +14 -0
  35. package/examples/extensions/claude-code-bridge/index.ts +13 -101
  36. package/examples/extensions/pi-bridge/README.md +16 -0
  37. package/examples/extensions/pi-bridge/index.ts +8 -154
  38. package/package.json +9 -1
  39. package/dist/extensions/shell-recall.d.ts +0 -9
  40. package/dist/extensions/shell-recall.js +0 -8
@@ -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
- handlers = null;
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: e.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
- // Apply per-type truncation so giant outputs don't blow up the turn.
160
- const truncated = fresh.map((ex) => {
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 truncateOutput(text, threshold, headLines, tailLines, id) {
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, use shell_recall tool with expand and id ${id} to see full output ...]`,
225
+ `[... ${omitted} lines truncated full output at ${spillPath}; use read_file to expand ...]`,
363
226
  ...lines.slice(-tailLines),
364
227
  ].join("\n");
365
228
  }
@@ -238,7 +238,6 @@ export interface ShellEvents {
238
238
  active: string | null;
239
239
  };
240
240
  "config:changed": Record<string, never>;
241
- "config:cycle": Record<string, never>;
242
241
  "config:switch-model": {
243
242
  model: string;
244
243
  };
@@ -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
- async function ensureTsSupport() {
9
- if (tsRegistered)
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: "0.4",
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: "0.4", model: switchModel, provider: name, contextWindow: p.contextWindow });
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
  });
@@ -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
- toolLineOpen: false,
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
- function cappedW() { return Math.min(MAX_CONTENT_WIDTH + 2, out().columns); }
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
- showToolComplete(e.exitCode, e.resultDisplay);
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
- s.toolLineOpen = true;
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.toolLineOpen && s.commandOutputLineCount === 0) {
763
+ if (!labelTitle && s.openTool && s.commandOutputLineCount === 0) {
752
764
  out().write(` ${mark}\n`);
753
- s.toolLineOpen = false;
765
+ s.openTool = null;
754
766
  }
755
767
  else {
756
768
  closeToolLine();
757
769
  flushCommandOutput();
758
- s.renderer.writeLine(` ${mark}`);
770
+ s.renderer.writeLine(labelTitle
771
+ ? ` ${p.muted}⎿${p.reset} ${p.dim}${labelTitle}${p.reset} ${mark}`
772
+ : ` ${mark}`);
759
773
  drain();
760
774
  }
761
- // Render structured body if present
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.toolLineOpen) {
825
+ if (s.openTool) {
814
826
  out().write("\n");
815
- s.toolLineOpen = false;
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
- /** Finalize a group of collapsed tool calls, rendering the summary. */
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
- if (s.toolGroupCount <= 1) {
821
- // 0–1 tools: standalone, nothing to finalize
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 = [];
@@ -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
- /** Recent exchanges included in agent context window. */
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 truncated shell output. */
37
+ /** Lines kept from start of spilled shell output. */
42
38
  shellHeadLines?: number;
43
- /** Lines kept from end of truncated shell output. */
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
- // Clearing on next redraw needs total rows, so measure the full
111
- // content width not just up to the cursor.
112
- const totalVisLen = promptVisLen + visibleLen(display);
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
- // Total rows (not cursor row) so next redraw clears the whole area.
161
- this.cursorRowsBelow = rowsSoFar - 1 > 0 ? rowsSoFar - 1 : 0;
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;
@@ -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.