@nghyane/arcane 0.1.19 → 0.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/package.json +7 -7
  3. package/src/lsp/clients/biome-client.ts +1 -1
  4. package/src/lsp/edits.ts +1 -1
  5. package/src/lsp/index.ts +1 -1
  6. package/src/lsp/render.ts +3 -2
  7. package/src/lsp/utils.ts +1 -1
  8. package/src/main.ts +2 -2
  9. package/src/modes/components/assistant-message.ts +55 -25
  10. package/src/modes/components/bash-execution.ts +31 -0
  11. package/src/modes/components/context-group.ts +30 -3
  12. package/src/modes/components/model-selector.ts +35 -9
  13. package/src/modes/components/python-execution.ts +37 -0
  14. package/src/modes/components/tool-execution.ts +3 -4
  15. package/src/modes/controllers/event-controller.ts +43 -11
  16. package/src/modes/utils/ui-helpers.ts +1 -1
  17. package/src/patch/edit-tool.ts +13 -24
  18. package/src/patch/hashline.ts +105 -3
  19. package/src/patch/schemas.ts +2 -2
  20. package/src/prompts/agents/explore.md +1 -1
  21. package/src/prompts/agents/librarian.md +1 -1
  22. package/src/prompts/system/system-prompt.md +0 -1
  23. package/src/session/agent-session.ts +28 -27
  24. package/src/task/index.ts +1 -9
  25. package/src/task/render.ts +3 -3
  26. package/src/tools/ask.ts +0 -2
  27. package/src/tools/bash.ts +6 -3
  28. package/src/tools/browser.ts +1 -1
  29. package/src/tools/default-renderer.ts +7 -5
  30. package/src/tools/fetch.ts +5 -2
  31. package/src/tools/find-thread.ts +5 -2
  32. package/src/tools/find.ts +3 -3
  33. package/src/tools/gemini-image.ts +18 -10
  34. package/src/tools/github.ts +2 -2
  35. package/src/tools/grep.ts +3 -3
  36. package/src/tools/notebook.ts +8 -2
  37. package/src/tools/python.ts +3 -2
  38. package/src/tools/read-thread.ts +5 -2
  39. package/src/tools/read.ts +6 -3
  40. package/src/tools/render-mermaid.ts +3 -7
  41. package/src/tools/save-memory.ts +6 -3
  42. package/src/tools/ssh.ts +6 -3
  43. package/src/tools/todo-write.ts +6 -3
  44. package/src/tools/undo-edit.ts +5 -2
  45. package/src/ui/render-utils.ts +1 -1
  46. package/src/utils/file-mentions.ts +1 -1
  47. package/src/web/github-client.ts +2 -1
  48. package/src/web/scrapers/youtube.ts +1 -1
  49. package/src/web/search/render.ts +11 -2
  50. package/src/prompts/tools/render-mermaid.md +0 -9
@@ -11,14 +11,37 @@ import type { AgentSessionEvent } from "../../session/agent-session";
11
11
  import { getSymbolTheme, theme } from "../../theme/theme";
12
12
  import { getToolTier, isContextTool } from "../../ui/render-utils";
13
13
 
14
+ const STREAM_RENDER_INTERVAL_MS = 32;
15
+
14
16
  export class EventController {
15
17
  #lastThinkingCount = 0;
16
18
  #renderedCustomMessages = new Set<string>();
17
19
  #currentContextGroup?: ContextGroupComponent;
18
20
  #toolGroups = new Map<string, ContextGroupComponent>();
21
+ #streamRenderTimer?: Timer;
22
+ #pendingStreamMessage?: AgentSessionEvent;
19
23
 
20
24
  constructor(private ctx: InteractiveModeContext) {}
21
25
 
26
+ #flushStreamRender(): void {
27
+ const pending = this.#pendingStreamMessage;
28
+ if (pending && "message" in pending && pending.message.role === "assistant" && this.ctx.streamingComponent) {
29
+ this.#pendingStreamMessage = undefined;
30
+ this.ctx.streamingComponent.updateContent(pending.message);
31
+ this.ctx.ui.requestRender();
32
+ }
33
+ const timer = setTimeout(() => {
34
+ // Guard against orphan callbacks surviving clearTimeout after message_end
35
+ if (this.#streamRenderTimer !== timer) return;
36
+ if (this.#pendingStreamMessage) {
37
+ this.#flushStreamRender();
38
+ } else {
39
+ this.#streamRenderTimer = undefined;
40
+ }
41
+ }, STREAM_RENDER_INTERVAL_MS);
42
+ this.#streamRenderTimer = timer;
43
+ }
44
+
22
45
  subscribeToAgent(): void {
23
46
  this.ctx.unsubscribe = this.ctx.session.subscribe(async (event: AgentSessionEvent) => {
24
47
  await this.handleEvent(event);
@@ -96,18 +119,10 @@ export class EventController {
96
119
  case "message_update":
97
120
  if (this.ctx.streamingComponent && event.message.role === "assistant") {
98
121
  this.ctx.streamingMessage = event.message;
99
- this.ctx.streamingComponent.updateContent(this.ctx.streamingMessage);
100
-
101
- const thinkingCount = this.ctx.streamingMessage.content.filter(
102
- content => content.type === "thinking" && content.thinking.trim(),
103
- ).length;
104
- if (thinkingCount > this.#lastThinkingCount) {
105
- this.#lastThinkingCount = thinkingCount;
106
- }
107
122
 
123
+ // Tool calls need immediate processing (new tool components)
108
124
  for (const content of this.ctx.streamingMessage.content) {
109
125
  if (content.type !== "toolCall") continue;
110
-
111
126
  if (!this.ctx.pendingTools.has(content.id)) {
112
127
  const tool = this.ctx.session.getToolByName(content.name);
113
128
  this.#appendTool(content.id, content.name, content.arguments, tool);
@@ -119,13 +134,30 @@ export class EventController {
119
134
  }
120
135
  }
121
136
 
122
- this.ctx.ui.requestRender();
137
+ const thinkingCount = this.ctx.streamingMessage.content.filter(
138
+ content => content.type === "thinking" && content.thinking.trim(),
139
+ ).length;
140
+ if (thinkingCount > this.#lastThinkingCount) {
141
+ this.#lastThinkingCount = thinkingCount;
142
+ }
143
+
144
+ // Throttle text/thinking render updates to avoid re-parsing markdown every token
145
+ this.#pendingStreamMessage = event;
146
+ if (!this.#streamRenderTimer) {
147
+ this.#flushStreamRender();
148
+ }
123
149
  }
124
150
  break;
125
151
 
126
152
  case "message_end":
127
153
  if (event.message.role === "user") break;
128
154
  if (this.ctx.streamingComponent && event.message.role === "assistant") {
155
+ // Flush any throttled stream render and stop the timer
156
+ if (this.#streamRenderTimer) {
157
+ clearTimeout(this.#streamRenderTimer);
158
+ this.#streamRenderTimer = undefined;
159
+ this.#pendingStreamMessage = undefined;
160
+ }
129
161
  this.ctx.streamingMessage = event.message;
130
162
  let errorMessage: string | undefined;
131
163
  if (this.ctx.streamingMessage.stopReason === "aborted" && !this.ctx.session.isTtsrAbortPending) {
@@ -349,7 +381,7 @@ export class EventController {
349
381
 
350
382
  if (isContextTool(toolName)) {
351
383
  if (!this.#currentContextGroup) {
352
- this.#currentContextGroup = new ContextGroupComponent();
384
+ this.#currentContextGroup = new ContextGroupComponent(this.ctx.ui);
353
385
  this.#currentContextGroup.setExpanded(this.ctx.toolOutputExpanded);
354
386
  this.ctx.chatContainer.addChild(this.#currentContextGroup);
355
387
  }
@@ -231,7 +231,7 @@ export class UiHelpers {
231
231
 
232
232
  if (isContextTool(content.name)) {
233
233
  if (!currentGroup) {
234
- currentGroup = new ContextGroupComponent();
234
+ currentGroup = new ContextGroupComponent(this.ctx.ui);
235
235
  currentGroup.setExpanded(this.ctx.toolOutputExpanded);
236
236
  this.ctx.chatContainer.addChild(currentGroup);
237
237
  }
@@ -30,6 +30,7 @@ import {
30
30
  import { findMatch } from "./fuzzy";
31
31
  import {
32
32
  applyHashlineEdits,
33
+ buildCompactDiffPreview,
33
34
  computeLineHash,
34
35
  type HashlineEdit,
35
36
  type LineTag,
@@ -212,7 +213,7 @@ export class EditTool implements AgentTool<TInput, any, Theme> {
212
213
  }
213
214
 
214
215
  description =
215
- "Apply edits to existing files (create, update, delete, rename). The diff is shown to the user, so do not repeat or summarize the changes.";
216
+ "Apply edits to existing files (create, update, delete, rename). Diff shown to user do not repeat changes. Re-read before editing same file again (tags shift). On mismatch, retry with fresh tags from error.";
216
217
 
217
218
  /**
218
219
  * Dynamic parameters schema based on current edit mode (which depends on current model).
@@ -343,28 +344,12 @@ export class EditTool implements AgentTool<TInput, any, Theme> {
343
344
  }
344
345
  case "insert": {
345
346
  const { before, after, content } = edit;
346
- if (before && !after) {
347
- anchorEdits.push({
348
- op: "prepend",
349
- before: parseTag(before),
350
- content: hashlineParseContent(content),
351
- });
352
- } else if (after && !before) {
353
- anchorEdits.push({
354
- op: "append",
355
- after: parseTag(after),
356
- content: hashlineParseContent(content),
357
- });
358
- } else if (before && after) {
359
- anchorEdits.push({
360
- op: "insert",
361
- before: parseTag(before),
362
- after: parseTag(after),
363
- content: hashlineParseContent(content),
364
- });
365
- } else {
366
- throw new Error(`Insert must have both before and after tags.`);
367
- }
347
+ anchorEdits.push({
348
+ op: "insert",
349
+ before: parseTag(before),
350
+ after: parseTag(after),
351
+ content: hashlineParseContent(content),
352
+ });
368
353
  break;
369
354
  }
370
355
  case "replaceText": {
@@ -493,11 +478,15 @@ export class EditTool implements AgentTool<TInput, any, Theme> {
493
478
  .get();
494
479
 
495
480
  const resultText = rename ? `Updated and moved ${path} to ${rename}` : `Updated ${path}`;
481
+ const preview = buildCompactDiffPreview(diffResult.diff);
482
+ const summaryLine = `Changes: +${preview.addedLines} -${preview.removedLines}`;
483
+ const previewBlock = preview.preview ? `\n\nDiff preview:\n${preview.preview}` : "";
484
+ const warningsBlock = result.warnings?.length ? `\n\nWarnings:\n${result.warnings.join("\n")}` : "";
496
485
  return {
497
486
  content: [
498
487
  {
499
488
  type: "text",
500
- text: `${resultText}${result.warnings?.length ? `\n\nWarnings:\n${result.warnings.join("\n")}` : ""}`,
489
+ text: `${resultText}\n${summaryLine}${previewBlock}${warningsBlock}`,
501
490
  },
502
491
  ],
503
492
  details: {
@@ -818,7 +818,7 @@ export function applyHashlineEdits(
818
818
  let nextLines = merged.newLines;
819
819
  nextLines = restoreIndentForPairedReplacement([origLines[0] ?? ""], nextLines);
820
820
 
821
- if (origLines.every((line, i) => line === nextLines[i])) {
821
+ if (origLines.length === nextLines.length && origLines.every((line, i) => line === nextLines[i])) {
822
822
  noopEdits.push({
823
823
  editIndex: idx,
824
824
  loc: `${edit.tag.line}#${edit.tag.hash}`,
@@ -838,7 +838,7 @@ export function applyHashlineEdits(
838
838
  : edit.content;
839
839
  stripped = autocorrect ? restoreOldWrappedLines(origLines, stripped) : stripped;
840
840
  const newLines = autocorrect ? restoreIndentForPairedReplacement(origLines, stripped) : stripped;
841
- if (origLines.every((line, i) => line === newLines[i])) {
841
+ if (origLines.length === newLines.length && origLines.every((line, i) => line === newLines[i])) {
842
842
  noopEdits.push({
843
843
  editIndex: idx,
844
844
  loc: `${edit.tag.line}#${edit.tag.hash}`,
@@ -858,7 +858,7 @@ export function applyHashlineEdits(
858
858
  : edit.content;
859
859
  stripped = autocorrect ? restoreOldWrappedLines(origLines, stripped) : stripped;
860
860
  const newLines = autocorrect ? restoreIndentForPairedReplacement(origLines, stripped) : stripped;
861
- if (autocorrect && origLines.every((line, i) => line === newLines[i])) {
861
+ if (origLines.length === newLines.length && origLines.every((line, i) => line === newLines[i])) {
862
862
  noopEdits.push({
863
863
  editIndex: idx,
864
864
  loc: `${edit.first.line}#${edit.first.hash}`,
@@ -1012,3 +1012,105 @@ export function applyHashlineEdits(
1012
1012
  return null;
1013
1013
  }
1014
1014
  }
1015
+
1016
+ // ═══════════════════════════════════════════════════════════════════════════
1017
+ // Compact diff preview for model-visible tool responses
1018
+ // ═══════════════════════════════════════════════════════════════════════════
1019
+
1020
+ export interface CompactDiffPreview {
1021
+ preview: string;
1022
+ addedLines: number;
1023
+ removedLines: number;
1024
+ }
1025
+
1026
+ export interface CompactDiffOptions {
1027
+ maxUnchangedRun?: number;
1028
+ maxAdditionRun?: number;
1029
+ maxDeletionRun?: number;
1030
+ maxOutputLines?: number;
1031
+ }
1032
+
1033
+ const NUMBERED_DIFF_LINE_RE = /^([ +-])\d+\|/;
1034
+
1035
+ type RunPosition = "first" | "last" | "middle";
1036
+
1037
+ function collapseRun(lines: string[], max: number, label: string, position: RunPosition): string[] {
1038
+ const len = lines.length;
1039
+ if (position === "first") {
1040
+ if (len <= max) return lines;
1041
+ return [` ... ${len - max} more ${label} lines`, ...lines.slice(-max)];
1042
+ }
1043
+ if (position === "last") {
1044
+ if (len <= max) return lines;
1045
+ return [...lines.slice(0, max), ` ... ${len - max} more ${label} lines`];
1046
+ }
1047
+ if (len <= max * 2) return lines;
1048
+ return [...lines.slice(0, max), ` ... ${len - max * 2} more ${label} lines`, ...lines.slice(-max)];
1049
+ }
1050
+
1051
+ /**
1052
+ * Build a compact diff preview for model-visible tool responses.
1053
+ * Collapses long unchanged/added/removed runs so the model sees the shape
1054
+ * of edits without replaying full file content.
1055
+ */
1056
+ export function buildCompactDiffPreview(diff: string, options: CompactDiffOptions = {}): CompactDiffPreview {
1057
+ const maxCtx = options.maxUnchangedRun ?? 2;
1058
+ const maxAdd = options.maxAdditionRun ?? 2;
1059
+ const maxDel = options.maxDeletionRun ?? 2;
1060
+ const maxOut = options.maxOutputLines ?? 16;
1061
+
1062
+ if (diff.length === 0) return { preview: "", addedLines: 0, removedLines: 0 };
1063
+
1064
+ const inputLines = diff.split("\n");
1065
+
1066
+ // Single-pass: group consecutive lines by kind into run spans
1067
+ type Kind = " " | "+" | "-" | "meta";
1068
+ const runs: { kind: Kind; start: number; end: number }[] = [];
1069
+ for (let i = 0; i < inputLines.length; i++) {
1070
+ const m = NUMBERED_DIFF_LINE_RE.exec(inputLines[i]);
1071
+ const kind: Kind = (m?.[1] as " " | "+" | "-" | undefined) ?? "meta";
1072
+ const prev = runs[runs.length - 1];
1073
+ if (prev && prev.kind === kind) {
1074
+ prev.end = i;
1075
+ } else {
1076
+ runs.push({ kind, start: i, end: i });
1077
+ }
1078
+ }
1079
+
1080
+ const out: string[] = [];
1081
+ let addedLines = 0;
1082
+ let removedLines = 0;
1083
+
1084
+ for (let ri = 0; ri < runs.length; ri++) {
1085
+ const { kind, start, end } = runs[ri];
1086
+ const slice = inputLines.slice(start, end + 1);
1087
+ switch (kind) {
1088
+ case "meta":
1089
+ out.push(...slice);
1090
+ break;
1091
+ case "+":
1092
+ addedLines += slice.length;
1093
+ out.push(...collapseRun(slice, maxAdd, "added", "last"));
1094
+ break;
1095
+ case "-":
1096
+ removedLines += slice.length;
1097
+ out.push(...collapseRun(slice, maxDel, "removed", "last"));
1098
+ break;
1099
+ case " ": {
1100
+ const pos: RunPosition = ri === 0 ? "first" : ri === runs.length - 1 ? "last" : "middle";
1101
+ out.push(...collapseRun(slice, maxCtx, "unchanged", pos));
1102
+ break;
1103
+ }
1104
+ }
1105
+ }
1106
+
1107
+ if (out.length > maxOut) {
1108
+ return {
1109
+ preview: [...out.slice(0, maxOut), ` ... ${out.length - maxOut} more preview lines`].join("\n"),
1110
+ addedLines,
1111
+ removedLines,
1112
+ };
1113
+ }
1114
+
1115
+ return { preview: out.join("\n"), addedLines, removedLines };
1116
+ }
@@ -144,8 +144,8 @@ const hashlineRangeEditSchema = Type.Object(
144
144
  const hashlineInsertEditSchema = Type.Object(
145
145
  {
146
146
  op: Type.Literal("insert"),
147
- before: Type.Optional(hashlineTagFormat("line before which to insert")),
148
- after: Type.Optional(hashlineTagFormat("line after which to insert")),
147
+ before: hashlineTagFormat("line before which to insert"),
148
+ after: hashlineTagFormat("line after which to insert"),
149
149
  content: hashlineInsertContentFormat("Inserted"),
150
150
  },
151
151
  { additionalProperties: false },
@@ -32,4 +32,4 @@ Before searching, decompose the query into:
32
32
 
33
33
  <critical>
34
34
  Your final message must contain ONLY the search results — no preamble like "I'll search for...".
35
- </critical>
35
+ </critical>
@@ -57,4 +57,4 @@ Be comprehensive and direct. No filler.
57
57
  Only your final message is returned to the caller. It must be self-contained with all findings, paths, and explanations. Do not reference tool names or intermediate steps — present conclusions directly. Your final message must contain ONLY the information found — no preamble.
58
58
 
59
59
  Use "fluent" linking — embed file/PR/commit references in natural noun phrases, not raw URLs. Example: The [`handleAuth` function](file:///path/to/auth.ts#L42) validates tokens.
60
- </critical>
60
+ </critical>
@@ -216,7 +216,6 @@ These are inviolable. Violation is system failure.
216
216
  Keep going until fully resolved. This matters.
217
217
  </critical>
218
218
 
219
-
220
219
  <project>
221
220
  {{#if contextFiles.length}}
222
221
  ## Context
@@ -231,6 +231,7 @@ export class AgentSession {
231
231
  // Verification loop state
232
232
  #verificationReminderCount = 0;
233
233
  #turnHasFileModifications = false;
234
+ #editErrorSteerCount = 0;
234
235
 
235
236
  // Bash execution state
236
237
  #bashAbortController: AbortController | undefined = undefined;
@@ -555,6 +556,25 @@ export class AgentSession {
555
556
  { deliverAs: "nextTurn" },
556
557
  );
557
558
  }
559
+ if (toolName === "edit" && isError && this.#editErrorSteerCount < 2) {
560
+ this.#editErrorSteerCount++;
561
+ const errorText = content?.find(part => part.type === "text")?.text;
562
+ const reminderText = [
563
+ "<system_reminder>",
564
+ "The edit tool failed. Re-read the file to get fresh line tags, then retry the edit.",
565
+ errorText ? `Failure: ${errorText}` : "Failure: edit returned an error.",
566
+ "</system_reminder>",
567
+ ].join("\n");
568
+ await this.sendCustomMessage(
569
+ {
570
+ customType: "edit-error-reminder",
571
+ content: reminderText,
572
+ display: false,
573
+ details: { toolName, errorText },
574
+ },
575
+ { deliverAs: "steer" },
576
+ );
577
+ }
558
578
  }
559
579
  }
560
580
 
@@ -1103,6 +1123,7 @@ export class AgentSession {
1103
1123
  this.#todoReminderCount = 0;
1104
1124
  this.#verificationReminderCount = 0;
1105
1125
  this.#turnHasFileModifications = false;
1126
+ this.#editErrorSteerCount = 0;
1106
1127
 
1107
1128
  // Validate model
1108
1129
  if (!this.model) {
@@ -2335,33 +2356,13 @@ Be thorough - include exact file paths, function names, error messages, and tech
2335
2356
  const availableModels = this.#model.registry.getAvailable();
2336
2357
  if (availableModels.length === 0) return undefined;
2337
2358
 
2338
- const candidates: Model[] = [];
2339
- const seen = new Set<string>();
2340
- const addCandidate = (candidate: Model | undefined): void => {
2341
- if (!candidate) return;
2342
- const key = this.#model.getModelKey(candidate);
2343
- if (seen.has(key)) return;
2344
- seen.add(key);
2345
- candidates.push(candidate);
2346
- };
2347
-
2348
- addCandidate(this.#model.resolveContextPromotionTarget(currentModel, availableModels));
2349
-
2350
- const sameProviderLarger = [...availableModels]
2351
- .filter(
2352
- m => m.provider === currentModel.provider && m.api === currentModel.api && m.contextWindow > contextWindow,
2353
- )
2354
- .sort((a, b) => a.contextWindow - b.contextWindow);
2355
- addCandidate(sameProviderLarger[0]);
2356
- for (const candidate of candidates) {
2357
- if (modelsAreEqual(candidate, currentModel)) continue;
2358
- if (candidate.contextWindow <= contextWindow) continue;
2359
- const apiKey = await this.#model.registry.getApiKey(candidate, this.sessionId);
2360
- if (!apiKey) continue;
2361
- return candidate;
2362
- }
2363
-
2364
- return undefined;
2359
+ const candidate = this.#model.resolveContextPromotionTarget(currentModel, availableModels);
2360
+ if (!candidate) return undefined;
2361
+ if (modelsAreEqual(candidate, currentModel)) return undefined;
2362
+ if (candidate.contextWindow <= contextWindow) return undefined;
2363
+ const apiKey = await this.#model.registry.getApiKey(candidate, this.sessionId);
2364
+ if (!apiKey) return undefined;
2365
+ return candidate;
2365
2366
  }
2366
2367
  /**
2367
2368
  * Internal: Run auto-compaction with events.
package/src/task/index.ts CHANGED
@@ -227,16 +227,8 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
227
227
  const totalDuration = Date.now() - startTime;
228
228
  const output = agentOutput.trim() || result.stderr.trim() || "(no output)";
229
229
 
230
- // Return structured result as JSON for code tool composability
231
- const structured = {
232
- exitCode: result.exitCode,
233
- output,
234
- tokens: result.tokens,
235
- durationMs: result.durationMs,
236
- };
237
-
238
230
  return {
239
- content: [{ type: "text", text: JSON.stringify(structured) }],
231
+ content: [{ type: "text", text: output }],
240
232
  details: {
241
233
  results: [result],
242
234
  totalDurationMs: totalDuration,
@@ -24,7 +24,7 @@ import type { AgentProgress, SingleResult, TaskParams, TaskToolDetails } from ".
24
24
  function getStatusIcon(status: AgentProgress["status"], theme: Theme, spinnerFrame?: number): string {
25
25
  switch (status) {
26
26
  case "pending":
27
- return formatStatusIcon("pending", theme);
27
+ return formatStatusIcon("running", theme, spinnerFrame);
28
28
  case "running":
29
29
  return formatStatusIcon("running", theme, spinnerFrame);
30
30
  case "completed":
@@ -274,7 +274,7 @@ export function renderResult(
274
274
  lines.push(...renderConclusionMarkdown(fallbackText, width, expanded, theme));
275
275
  }
276
276
  } else {
277
- const icon = formatStatusIcon("pending", theme);
277
+ const icon = formatStatusIcon("running", theme, spinnerFrame);
278
278
  lines.push(...renderSubagentHeader(taskRenderConfig, args, { icon }, theme));
279
279
  }
280
280
 
@@ -417,7 +417,7 @@ export function createUnifiedSubagentRenderer(config: SubagentRenderConfig): {
417
417
  lines.push(...renderConclusionMarkdown(fallbackText, width, expanded, theme));
418
418
  }
419
419
  } else {
420
- const icon = formatStatusIcon("pending", theme);
420
+ const icon = formatStatusIcon("running", theme, spinnerFrame);
421
421
  lines.push(...renderSubagentHeader(config, params, { icon }, theme));
422
422
  }
423
423
 
package/src/tools/ask.ts CHANGED
@@ -73,8 +73,6 @@ export interface AskToolDetails {
73
73
 
74
74
  const OTHER_OPTION = "Other (type your own)";
75
75
  const RECOMMENDED_SUFFIX = " (Recommended)";
76
- /** Default timeout in milliseconds (used when settings unavailable) */
77
- const _DEFAULT_ASK_TIMEOUT_MS = 30000;
78
76
 
79
77
  function getDoneOptionLabel(): string {
80
78
  return `${theme.status.success} Done selecting`;
package/src/tools/bash.ts CHANGED
@@ -26,7 +26,7 @@ export const BASH_DEFAULT_PREVIEW_LINES = 10;
26
26
 
27
27
  const bashSchema = Type.Object({
28
28
  command: Type.String({ description: "Shell command to execute" }),
29
- timeout: Type.Optional(Type.Number({ description: "Timeout in milliseconds" })),
29
+ timeout: Type.Optional(Type.Number({ description: "Timeout in seconds" })),
30
30
  cwd: Type.Optional(Type.String({ description: "Working directory" })),
31
31
  head: Type.Optional(Type.Number({ description: "Return only the first N lines of output" })),
32
32
  tail: Type.Optional(Type.Number({ description: "Return only the last N lines of output" })),
@@ -196,9 +196,12 @@ export class BashTool implements AgentTool<typeof bashSchema, BashToolDetails, T
196
196
  return context;
197
197
  }
198
198
 
199
- renderCall(args: BashRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
199
+ renderCall(args: BashRenderArgs, options: RenderResultOptions, uiTheme: Theme): Component {
200
200
  const cmdText = formatBashCommand(args, uiTheme);
201
- const text = renderStatusLine({ icon: "pending", title: "Bash", description: cmdText }, uiTheme);
201
+ const text = renderStatusLine(
202
+ { icon: "running", spinnerFrame: options.spinnerFrame, title: "Bash", description: cmdText },
203
+ uiTheme,
204
+ );
202
205
  return new Text(text, 0, 0);
203
206
  }
204
207
 
@@ -349,7 +349,7 @@ const browserSchema = Type.Object({
349
349
  value: Type.Optional(Type.String({ description: "Value to fill into input" })),
350
350
  attribute: Type.Optional(Type.String({ description: "Attribute name to retrieve" })),
351
351
  key: Type.Optional(Type.String({ description: "Key to press (e.g. Enter, Escape)" })),
352
- timeout: Type.Optional(Type.Number({ description: "Timeout in milliseconds" })),
352
+ timeout: Type.Optional(Type.Number({ description: "Timeout in seconds" })),
353
353
  wait_until: Type.Optional(
354
354
  StringEnum(["load", "domcontentloaded", "networkidle0", "networkidle2"], {
355
355
  description: "Navigation wait condition",
@@ -3,7 +3,7 @@ import { Text } from "@nghyane/arcane-tui";
3
3
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
4
4
  import type { Theme } from "../theme/theme";
5
5
  import { renderStatusLine } from "../tui";
6
- import { formatMoreItems, PREVIEW_LIMITS, TRUNCATE_LENGTHS, truncateToWidth } from "../ui/render-utils";
6
+ import { formatMoreItems, PREVIEW_LIMITS, replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../ui/render-utils";
7
7
  import {
8
8
  formatArgsInline,
9
9
  JSON_TREE_MAX_DEPTH_COLLAPSED,
@@ -36,7 +36,7 @@ export const defaultRenderer: DefaultRenderer = {
36
36
  renderCall(args: unknown, options: RenderResultOptions, theme: Theme): Component {
37
37
  const label = options.label ?? "Tool";
38
38
  const lines: string[] = [];
39
- lines.push(renderStatusLine({ icon: "pending", title: label }, theme));
39
+ lines.push(renderStatusLine({ icon: "running", spinnerFrame: options.spinnerFrame, title: label }, theme));
40
40
 
41
41
  const argsObject = asRecord(args);
42
42
  if (argsObject && Object.keys(argsObject).length > 0) {
@@ -57,8 +57,10 @@ export const defaultRenderer: DefaultRenderer = {
57
57
  const { expanded = false, isPartial = false } = options;
58
58
  const label = options.label ?? "Tool";
59
59
  const lines: string[] = [];
60
- const icon = isPartial ? "pending" : result.isError ? "error" : "success";
61
- lines.push(renderStatusLine({ icon, title: label }, theme));
60
+ const icon = isPartial ? "running" : result.isError ? "error" : "success";
61
+ lines.push(
62
+ renderStatusLine({ icon, spinnerFrame: isPartial ? options.spinnerFrame : undefined, title: label }, theme),
63
+ );
62
64
 
63
65
  // Output
64
66
  const textContent = (result.content?.find(c => c.type === "text")?.text ?? "").trimEnd();
@@ -92,7 +94,7 @@ export const defaultRenderer: DefaultRenderer = {
92
94
  const maxOutputLines = expanded ? PREVIEW_LIMITS.OUTPUT_EXPANDED : PREVIEW_LIMITS.OUTPUT_COLLAPSED;
93
95
  const displayLines = outputLines.slice(0, maxOutputLines);
94
96
  for (const line of displayLines) {
95
- lines.push(theme.fg("toolOutput", truncateToWidth(line, TRUNCATE_LENGTHS.CONTENT)));
97
+ lines.push(theme.fg("toolOutput", truncateToWidth(replaceTabs(line), TRUNCATE_LENGTHS.CONTENT)));
96
98
  }
97
99
  if (outputLines.length > maxOutputLines) {
98
100
  const remaining = outputLines.length - maxOutputLines;
@@ -959,7 +959,7 @@ function countNonEmptyLines(text: string): number {
959
959
  /** Render fetch call (URL preview) */
960
960
  function renderFetchCall(
961
961
  args: { url?: string; timeout?: number; raw?: boolean },
962
- _options: RenderResultOptions,
962
+ options: RenderResultOptions,
963
963
  uiTheme: Theme = theme,
964
964
  ): Component {
965
965
  const url = args.url ?? "";
@@ -969,7 +969,10 @@ function renderFetchCall(
969
969
  const meta: string[] = [];
970
970
  if (args.raw) meta.push("raw");
971
971
  if (args.timeout !== undefined) meta.push(`timeout:${args.timeout}s`);
972
- const text = renderStatusLine({ icon: "pending", title: "Fetch", description, meta }, uiTheme);
972
+ const text = renderStatusLine(
973
+ { icon: "running", spinnerFrame: options.spinnerFrame, title: "Fetch", description, meta },
974
+ uiTheme,
975
+ );
973
976
  return new Text(text, 0, 0);
974
977
  }
975
978
 
@@ -79,9 +79,12 @@ export class FindThreadTool implements AgentTool<typeof findThreadSchema, FindTh
79
79
  };
80
80
  }
81
81
 
82
- renderCall(args: FindThreadRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
82
+ renderCall(args: FindThreadRenderArgs, options: RenderResultOptions, uiTheme: Theme): Component {
83
83
  const meta = args.query ? [`"${args.query}"`] : [];
84
- const text = renderStatusLine({ icon: "pending", title: "Find Thread", meta }, uiTheme);
84
+ const text = renderStatusLine(
85
+ { icon: "running", spinnerFrame: options.spinnerFrame, title: "Find Thread", meta },
86
+ uiTheme,
87
+ );
85
88
  return new Text(text, 0, 0);
86
89
  }
87
90
 
package/src/tools/find.ts CHANGED
@@ -19,7 +19,7 @@ import { resolveToCwd } from "./path-utils";
19
19
  import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
20
20
 
21
21
  const findSchema = Type.Object({
22
- pattern: Type.String({ description: "Glob pattern to match file paths" }),
22
+ pattern: Type.String({ description: "Glob pattern to match file paths", minLength: 1 }),
23
23
  hidden: Type.Optional(Type.Boolean({ description: "Include hidden files and directories" })),
24
24
  limit: Type.Optional(Type.Number({ description: "Max number of results to return" })),
25
25
  });
@@ -381,12 +381,12 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails, T
381
381
  });
382
382
  }
383
383
 
384
- renderCall(args: FindRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
384
+ renderCall(args: FindRenderArgs, options: RenderResultOptions, uiTheme: Theme): Component {
385
385
  const meta: string[] = [];
386
386
  if (args.limit !== undefined) meta.push(`limit:${args.limit}`);
387
387
 
388
388
  const text = renderStatusLine(
389
- { icon: "pending", title: "Find", description: args.pattern || "*", meta },
389
+ { icon: "running", spinnerFrame: options.spinnerFrame, title: "Find", description: args.pattern || "*", meta },
390
390
  uiTheme,
391
391
  );
392
392
  return new Text(text, 0, 0);
@@ -33,14 +33,24 @@ const imageSizeSchema = StringEnum(["1024x1024", "1536x1024", "1024x1536"], {
33
33
  description: "Image size, mainly for gemini-3-pro-image-preview.",
34
34
  });
35
35
 
36
- const inputImageSchema = Type.Object(
37
- {
38
- path: Type.Optional(Type.String({ description: "Path to an input image file." })),
39
- data: Type.Optional(Type.String({ description: "Base64 image data or a data: URL." })),
40
- mime_type: Type.Optional(Type.String({ description: "Required for raw base64 data." })),
41
- },
42
- { additionalProperties: false },
43
- );
36
+ const inputImageSchema = Type.Union([
37
+ Type.Object(
38
+ {
39
+ path: Type.String({ description: "Path to an input image file." }),
40
+ data: Type.Optional(Type.String({ description: "Base64 image data or a data: URL." })),
41
+ mime_type: Type.Optional(Type.String({ description: "Required for raw base64 data." })),
42
+ },
43
+ { additionalProperties: false },
44
+ ),
45
+ Type.Object(
46
+ {
47
+ path: Type.Optional(Type.String({ description: "Path to an input image file." })),
48
+ data: Type.String({ description: "Base64 image data or a data: URL." }),
49
+ mime_type: Type.Optional(Type.String({ description: "Required for raw base64 data." })),
50
+ },
51
+ { additionalProperties: false },
52
+ ),
53
+ ]);
44
54
 
45
55
  const baseImageSchema = Type.Object(
46
56
  {
@@ -574,8 +584,6 @@ interface AntigravitySseResult {
574
584
  usage?: GeminiUsageMetadata;
575
585
  }
576
586
 
577
- const _prefix = Buffer.from("data: ", "utf-8");
578
-
579
587
  async function parseAntigravitySseForImage(response: Response, signal?: AbortSignal): Promise<AntigravitySseResult> {
580
588
  if (!response.body) {
581
589
  throw new Error("No response body");