@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
package/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.1.20] - 2026-03-05
6
+
7
+ ### Added
8
+
9
+ - Spinner animation on tool calls while running
10
+
11
+ ### Changed
12
+
13
+ - Stricter tool schemas and simplified edit tool insert operation
14
+ - Improved edit error steering and diff preview
15
+ - Normalize node:path/fs imports to namespace style
16
+
17
+ ### Fixed
18
+
19
+ - Task tool returns plain text instead of JSON-serialized result
20
+ - GitHub client non-JSON response handling
21
+ - Tab sanitization in hashline
22
+
23
+ ### Performance
24
+
25
+ - Reuse markdown components and throttle stream renders
26
+
5
27
  ## [0.1.17] - 2026-03-02
6
28
 
7
29
  ### Added
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@nghyane/arcane",
4
- "version": "0.1.19",
4
+ "version": "0.1.20",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/nghyane/arcane",
7
7
  "author": "Can Bölük",
@@ -44,12 +44,12 @@
44
44
  },
45
45
  "dependencies": {
46
46
  "@mozilla/readability": "0.6.0",
47
- "@nghyane/arcane-stats": "^0.1.11",
48
- "@nghyane/arcane-agent": "^0.1.15",
49
- "@nghyane/arcane-ai": "^0.1.11",
50
- "@nghyane/arcane-natives": "^0.1.10",
51
- "@nghyane/arcane-tui": "^0.1.14",
52
- "@nghyane/arcane-utils": "^0.1.7",
47
+ "@nghyane/arcane-stats": "^0.1.12",
48
+ "@nghyane/arcane-agent": "^0.1.16",
49
+ "@nghyane/arcane-ai": "^0.1.12",
50
+ "@nghyane/arcane-natives": "^0.1.11",
51
+ "@nghyane/arcane-tui": "^0.1.15",
52
+ "@nghyane/arcane-utils": "^0.1.8",
53
53
  "@sinclair/typebox": "^0.34.48",
54
54
  "@xterm/headless": "^6.0.0",
55
55
  "ajv": "^8.18.0",
@@ -2,7 +2,7 @@
2
2
  * Biome CLI-based linter client.
3
3
  * Uses Biome's CLI with JSON output instead of LSP (which has stale diagnostics issues).
4
4
  */
5
- import path from "node:path";
5
+ import * as path from "node:path";
6
6
  import type { Diagnostic, DiagnosticSeverity, LinterClient, ServerConfig } from "../../lsp/types";
7
7
 
8
8
  // =============================================================================
package/src/lsp/edits.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as fs from "node:fs/promises";
2
- import path from "node:path";
2
+ import * as path from "node:path";
3
3
  import type { CreateFile, DeleteFile, RenameFile, TextDocumentEdit, TextEdit, WorkspaceEdit } from "./types";
4
4
  import { uriToFile } from "./utils";
5
5
 
package/src/lsp/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as fs from "node:fs";
2
- import path from "node:path";
2
+ import * as path from "node:path";
3
3
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@nghyane/arcane-agent";
4
4
  import { logger, once, untilAborted } from "@nghyane/arcane-utils";
5
5
  import type { BunFile } from "bun";
package/src/lsp/render.ts CHANGED
@@ -16,7 +16,7 @@ import type { LspParams, LspToolDetails } from "./types";
16
16
  * Render the LSP tool call in the TUI.
17
17
  * Shows: "lsp <operation> <file/filecount>"
18
18
  */
19
- export function renderCall(args: LspParams, _options: RenderResultOptions, theme: Theme): Text {
19
+ export function renderCall(args: LspParams, options: RenderResultOptions, theme: Theme): Text {
20
20
  const actionLabel = (args.action ?? "request").replace(/_/g, " ");
21
21
  const queryPreview = args.query ? truncateToWidth(args.query, TRUNCATE_LENGTHS.SHORT) : undefined;
22
22
 
@@ -66,7 +66,8 @@ export function renderCall(args: LspParams, _options: RenderResultOptions, theme
66
66
 
67
67
  const text = renderStatusLine(
68
68
  {
69
- icon: "pending",
69
+ icon: "running",
70
+ spinnerFrame: options.spinnerFrame,
70
71
  title: "LSP",
71
72
  description: descriptionParts.join(" "),
72
73
  meta,
package/src/lsp/utils.ts CHANGED
@@ -1,4 +1,4 @@
1
- import path from "node:path";
1
+ import * as path from "node:path";
2
2
  import { type Theme, theme } from "../theme/theme";
3
3
  import type {
4
4
  Diagnostic,
package/src/main.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * createAgentSession() options. The SDK does the heavy lifting.
6
6
  */
7
7
 
8
- import { realpathSync } from "node:fs";
8
+ import * as nodeFs from "node:fs";
9
9
  import * as fs from "node:fs/promises";
10
10
  import * as os from "node:os";
11
11
  import * as path from "node:path";
@@ -284,7 +284,7 @@ async function maybeAutoChdir(parsed: Args): Promise<void> {
284
284
  }
285
285
 
286
286
  const normalizePath = (value: string) => {
287
- const resolved = realpathSync(path.resolve(value));
287
+ const resolved = nodeFs.realpathSync(path.resolve(value));
288
288
  return process.platform === "win32" ? resolved.toLowerCase() : resolved;
289
289
  };
290
290
 
@@ -4,13 +4,21 @@ import { logger } from "@nghyane/arcane-utils";
4
4
  import { hasPendingMermaid, prerenderMermaid } from "../../theme/mermaid-cache";
5
5
  import { getMarkdownTheme, theme } from "../../theme/theme";
6
6
 
7
+ interface CachedBlock {
8
+ type: "text" | "thinking" | "thinking-hidden";
9
+ component: Markdown | Text;
10
+ text: string;
11
+ }
12
+
7
13
  /**
8
- * Component that renders a complete assistant message
14
+ * Component that renders a complete assistant message.
15
+ * Reuses Markdown/Text instances across updates so unchanged blocks skip re-parsing.
9
16
  */
10
17
  export class AssistantMessageComponent extends Container {
11
18
  #contentContainer: Container;
12
19
  #lastMessage?: AssistantMessage;
13
20
  #prerenderInFlight = false;
21
+ #cachedBlocks: CachedBlock[] = [];
14
22
 
15
23
  constructor(
16
24
  message?: AssistantMessage,
@@ -69,11 +77,7 @@ export class AssistantMessageComponent extends Container {
69
77
 
70
78
  updateContent(message: AssistantMessage): void {
71
79
  this.#lastMessage = message;
72
-
73
- // Clear content container
74
80
  this.#contentContainer.clear();
75
-
76
- // Trigger background mermaid pre-rendering if needed
77
81
  this.#triggerMermaidPrerender(message);
78
82
 
79
83
  const hasVisibleContent = message.content.some(
@@ -84,43 +88,73 @@ export class AssistantMessageComponent extends Container {
84
88
  this.#contentContainer.addChild(new Spacer(1));
85
89
  }
86
90
 
87
- // Render content in order
91
+ let blockIndex = 0;
88
92
  for (let i = 0; i < message.content.length; i++) {
89
93
  const content = message.content[i];
90
94
  if (content.type === "text" && content.text.trim()) {
91
- // Assistant text messages with no background - trim the text
92
- // Set paddingY=0 to avoid extra spacing before tool executions
93
- this.#contentContainer.addChild(new Markdown(content.text.trim(), 2, 0, getMarkdownTheme()));
95
+ const text = content.text.trim();
96
+ const cached = this.#cachedBlocks[blockIndex];
97
+ let md: Markdown;
98
+ if (cached?.type === "text") {
99
+ md = cached.component as Markdown;
100
+ if (cached.text !== text) {
101
+ md.setText(text);
102
+ cached.text = text;
103
+ }
104
+ } else {
105
+ md = new Markdown(text, 2, 0, getMarkdownTheme());
106
+ this.#cachedBlocks[blockIndex] = { type: "text", component: md, text };
107
+ }
108
+ this.#contentContainer.addChild(md);
109
+ blockIndex++;
94
110
  } else if (content.type === "thinking" && content.thinking.trim()) {
95
- // Add spacing only when another visible assistant content block follows.
96
- // This avoids a superfluous blank line before separately-rendered tool execution blocks.
97
111
  const hasVisibleContentAfter = message.content
98
112
  .slice(i + 1)
99
113
  .some(c => (c.type === "text" && c.text.trim()) || (c.type === "thinking" && c.thinking.trim()));
100
114
 
101
115
  if (this.hideThinkingBlock) {
102
- // Show static "Thinking..." label when hidden
103
- this.#contentContainer.addChild(new Text(theme.italic(theme.fg("thinkingText", "Thinking...")), 2, 0));
116
+ const cached = this.#cachedBlocks[blockIndex];
117
+ let label: Text;
118
+ if (cached?.type === "thinking-hidden") {
119
+ label = cached.component as Text;
120
+ } else {
121
+ label = new Text(theme.italic(theme.fg("thinkingText", "Thinking...")), 2, 0);
122
+ this.#cachedBlocks[blockIndex] = { type: "thinking-hidden", component: label, text: "" };
123
+ }
124
+ this.#contentContainer.addChild(label);
104
125
  if (hasVisibleContentAfter) {
105
126
  this.#contentContainer.addChild(new Spacer(1));
106
127
  }
107
128
  } else {
108
- // Thinking traces in thinkingText color, italic
109
- this.#contentContainer.addChild(
110
- new Markdown(content.thinking.trim(), 2, 0, getMarkdownTheme(), {
129
+ const text = content.thinking.trim();
130
+ const cached = this.#cachedBlocks[blockIndex];
131
+ let md: Markdown;
132
+ if (cached?.type === "thinking") {
133
+ md = cached.component as Markdown;
134
+ if (cached.text !== text) {
135
+ md.setText(text);
136
+ cached.text = text;
137
+ }
138
+ } else {
139
+ md = new Markdown(text, 2, 0, getMarkdownTheme(), {
111
140
  color: (text: string) => theme.fg("thinkingText", text),
112
141
  italic: true,
113
- }),
114
- );
142
+ });
143
+ this.#cachedBlocks[blockIndex] = { type: "thinking", component: md, text };
144
+ }
145
+ this.#contentContainer.addChild(md);
115
146
  if (hasVisibleContentAfter) {
116
147
  this.#contentContainer.addChild(new Spacer(1));
117
148
  }
118
149
  }
150
+ blockIndex++;
119
151
  }
120
152
  }
121
153
 
122
- // Check if aborted - show after partial content
123
- // But only if there are no tool calls (tool execution components will show the error)
154
+ if (this.#cachedBlocks.length > blockIndex) {
155
+ this.#cachedBlocks.length = blockIndex;
156
+ }
157
+
124
158
  const hasToolCalls = message.content.some(c => c.type === "toolCall");
125
159
  if (!hasToolCalls) {
126
160
  if (message.stopReason === "aborted") {
@@ -128,11 +162,7 @@ export class AssistantMessageComponent extends Container {
128
162
  message.errorMessage && message.errorMessage !== "Request was aborted"
129
163
  ? message.errorMessage
130
164
  : "Operation aborted";
131
- if (hasVisibleContent) {
132
- this.#contentContainer.addChild(new Spacer(1));
133
- } else {
134
- this.#contentContainer.addChild(new Spacer(1));
135
- }
165
+ this.#contentContainer.addChild(new Spacer(1));
136
166
  this.#contentContainer.addChild(new Text(theme.fg("error", abortMessage), 2, 0));
137
167
  } else if (message.stopReason === "error") {
138
168
  const errorMsg = message.errorMessage || "Unknown error";
@@ -22,6 +22,9 @@ export class BashExecutionComponent extends Container {
22
22
  #expanded = false;
23
23
  #headerText: Text;
24
24
  #bodyText: Text;
25
+ #spinnerFrame = 0;
26
+ #spinnerInterval?: NodeJS.Timeout;
27
+ #ui: TUI;
25
28
 
26
29
  constructor(
27
30
  private readonly command: string,
@@ -29,6 +32,7 @@ export class BashExecutionComponent extends Container {
29
32
  _excludeFromContext = false,
30
33
  ) {
31
34
  super();
35
+ this.#ui = ui;
32
36
  this.addChild(new Spacer(1));
33
37
 
34
38
  this.#headerText = new Text(
@@ -49,6 +53,7 @@ export class BashExecutionComponent extends Container {
49
53
  getSymbolTheme().spinnerFrames,
50
54
  );
51
55
  this.addChild(this.#loader);
56
+ this.#startSpinner();
52
57
  }
53
58
 
54
59
  setExpanded(expanded: boolean): void {
@@ -91,9 +96,28 @@ export class BashExecutionComponent extends Container {
91
96
  this.#setOutput(options.output);
92
97
  }
93
98
  this.#loader.stop();
99
+ this.#stopSpinner();
94
100
  this.#updateDisplay();
95
101
  }
96
102
 
103
+ #startSpinner(): void {
104
+ if (this.#spinnerInterval) return;
105
+ this.#spinnerInterval = setInterval(() => {
106
+ const frameCount = theme.spinnerFrames.length;
107
+ if (frameCount === 0) return;
108
+ this.#spinnerFrame = (this.#spinnerFrame + 1) % frameCount;
109
+ this.#updateDisplay();
110
+ this.#ui.requestRender();
111
+ }, 80);
112
+ }
113
+
114
+ #stopSpinner(): void {
115
+ if (this.#spinnerInterval) {
116
+ clearInterval(this.#spinnerInterval);
117
+ this.#spinnerInterval = undefined;
118
+ }
119
+ }
120
+
97
121
  #updateDisplay(): void {
98
122
  const isError = this.#status === "error";
99
123
  const isDone = this.#status !== "running";
@@ -109,6 +133,13 @@ export class BashExecutionComponent extends Container {
109
133
  this.#headerText.setText(
110
134
  renderStatusLine({ icon, title: "Bash", description: `$ ${this.command}`, meta }, theme),
111
135
  );
136
+ } else {
137
+ this.#headerText.setText(
138
+ renderStatusLine(
139
+ { icon: "running", spinnerFrame: this.#spinnerFrame, title: "Bash", description: `$ ${this.command}` },
140
+ theme,
141
+ ),
142
+ );
112
143
  }
113
144
 
114
145
  // Build tree-style body
@@ -1,4 +1,4 @@
1
- import type { Component } from "@nghyane/arcane-tui";
1
+ import type { Component, TUI } from "@nghyane/arcane-tui";
2
2
  import { Spacer } from "@nghyane/arcane-tui";
3
3
  import { theme } from "../../theme/theme";
4
4
  import { formatCount, formatStatusIcon } from "../../ui/render-utils";
@@ -26,19 +26,27 @@ export class ContextGroupComponent implements Component {
26
26
  #expanded = false;
27
27
  #pendingCount = 0;
28
28
  #spacer: Spacer;
29
+ #spinnerFrame = 0;
30
+ #spinnerInterval?: NodeJS.Timeout;
31
+ #ui: TUI;
29
32
 
30
- constructor() {
33
+ constructor(ui: TUI) {
31
34
  this.#spacer = new Spacer(1);
35
+ this.#ui = ui;
32
36
  }
33
37
 
34
38
  addTool(name: string, component: ToolExecutionComponent): void {
35
39
  this.#entries.push({ name, component });
36
40
  this.#pendingCount++;
37
41
  component.setExpanded(this.#expanded);
42
+ this.#startSpinner();
38
43
  }
39
44
 
40
45
  markDone(): void {
41
46
  this.#pendingCount = Math.max(0, this.#pendingCount - 1);
47
+ if (this.#pendingCount <= 0) {
48
+ this.#stopSpinner();
49
+ }
42
50
  }
43
51
 
44
52
  setExpanded(expanded: boolean): void {
@@ -63,6 +71,23 @@ export class ContextGroupComponent implements Component {
63
71
  }
64
72
  }
65
73
 
74
+ #startSpinner(): void {
75
+ if (this.#spinnerInterval) return;
76
+ this.#spinnerInterval = setInterval(() => {
77
+ const frameCount = theme.spinnerFrames.length;
78
+ if (frameCount === 0) return;
79
+ this.#spinnerFrame = (this.#spinnerFrame + 1) % frameCount;
80
+ this.#ui.requestRender();
81
+ }, 80);
82
+ }
83
+
84
+ #stopSpinner(): void {
85
+ if (this.#spinnerInterval) {
86
+ clearInterval(this.#spinnerInterval);
87
+ this.#spinnerInterval = undefined;
88
+ }
89
+ }
90
+
66
91
  render(width: number): string[] {
67
92
  const lines: string[] = [];
68
93
 
@@ -71,7 +96,9 @@ export class ContextGroupComponent implements Component {
71
96
 
72
97
  // Summary line
73
98
  const allDone = this.#pendingCount <= 0;
74
- const icon = allDone ? formatStatusIcon("success", theme) : formatStatusIcon("running", theme);
99
+ const icon = allDone
100
+ ? formatStatusIcon("success", theme)
101
+ : formatStatusIcon("running", theme, this.#spinnerFrame);
75
102
  const label = allDone ? "Gathered context" : "Gathering context…";
76
103
 
77
104
  // Count by tool type
@@ -12,7 +12,7 @@ import {
12
12
  visibleWidth,
13
13
  } from "@nghyane/arcane-tui";
14
14
  import { MODEL_ROLE_IDS, MODEL_ROLES, type ModelRegistry, type ModelRole } from "../../config/model-registry";
15
- import { parseModelString } from "../../config/model-resolver";
15
+ import { parseModelPattern, parseModelString } from "../../config/model-resolver";
16
16
  import type { Settings } from "../../config/settings";
17
17
  import { type ThemeColor, theme } from "../../theme/theme";
18
18
  import { fuzzyFilter } from "../../utils/fuzzy";
@@ -24,6 +24,19 @@ function makeInvertedBadge(label: string, color: ThemeColor): string {
24
24
  return `${bgAnsi}\x1b[30m ${label} \x1b[39m${theme.getAppBgAnsi()}`;
25
25
  }
26
26
 
27
+ function formatRoleThinkingLabel(thinkingLevel: string | undefined): string {
28
+ if (!thinkingLevel || thinkingLevel === "default") return "inherit";
29
+ const labels: Record<string, string> = {
30
+ minimal: "min",
31
+ low: "low",
32
+ medium: "med",
33
+ high: "high",
34
+ xhigh: "xhi",
35
+ off: "off",
36
+ };
37
+ return labels[thinkingLevel] ?? thinkingLevel;
38
+ }
39
+
27
40
  interface ModelItem {
28
41
  provider: string;
29
42
  id: string;
@@ -40,7 +53,11 @@ interface MenuAction {
40
53
  role: ModelRole;
41
54
  }
42
55
 
43
- const MENU_ACTIONS: MenuAction[] = MODEL_ROLE_IDS.map(role => ({ label: `Set as ${MODEL_ROLES[role].name}`, role }));
56
+ const MENU_ACTIONS: MenuAction[] = MODEL_ROLE_IDS.map(role => {
57
+ const roleInfo = MODEL_ROLES[role];
58
+ const roleLabel = roleInfo.tag ? `${roleInfo.tag} (${roleInfo.name})` : roleInfo.name;
59
+ return { label: `Set as ${roleLabel}`, role };
60
+ });
44
61
 
45
62
  const ALL_TAB = "ALL";
46
63
 
@@ -69,7 +86,7 @@ export class ModelSelectorComponent extends Container {
69
86
  #allModels: ModelItem[] = [];
70
87
  #filteredModels: ModelItem[] = [];
71
88
  #selectedIndex: number = 0;
72
- #roles: { [key in ModelRole]?: Model } = {};
89
+ #roles: { [key in ModelRole]?: { model: Model; thinkingLevel?: string } } = {};
73
90
  #settings: Settings;
74
91
  #modelRegistry: ModelRegistry;
75
92
  #onSelectCallback: (model: Model, role: ModelRole | null) => void;
@@ -183,9 +200,15 @@ export class ModelSelectorComponent extends Container {
183
200
  if (parsed) {
184
201
  const model = allModels.find(m => m.provider === parsed.provider && m.id === parsed.id);
185
202
  if (model) {
186
- this.#roles[role] = model;
203
+ this.#roles[role] = { model };
204
+ continue;
187
205
  }
188
206
  }
207
+ // Fallback: parse as pattern to extract thinking level from suffix
208
+ const result = parseModelPattern(modelId, allModels);
209
+ if (result.model) {
210
+ this.#roles[role] = { model: result.model, thinkingLevel: result.thinkingLevel };
211
+ }
189
212
  }
190
213
  }
191
214
 
@@ -198,7 +221,7 @@ export class ModelSelectorComponent extends Container {
198
221
  let i = 0;
199
222
  while (i < MODEL_ROLE_IDS.length) {
200
223
  const role = MODEL_ROLE_IDS[i];
201
- if (this.#roles[role] && modelsAreEqual(this.#roles[role], model.model)) {
224
+ if (this.#roles[role] && modelsAreEqual(this.#roles[role]!.model, model.model)) {
202
225
  break;
203
226
  }
204
227
  i++;
@@ -395,9 +418,12 @@ export class ModelSelectorComponent extends Container {
395
418
  const badges: string[] = [];
396
419
  for (const role of MODEL_ROLE_IDS) {
397
420
  const { tag, color } = MODEL_ROLES[role];
398
- if (tag && modelsAreEqual(this.#roles[role], item.model)) {
399
- badges.push(makeInvertedBadge(tag, color ?? "success"));
400
- }
421
+ const assigned = this.#roles[role];
422
+ if (!tag || !assigned || !modelsAreEqual(assigned.model, item.model)) continue;
423
+
424
+ const badge = makeInvertedBadge(tag, color ?? "success");
425
+ const thinkingLabel = formatRoleThinkingLabel(assigned.thinkingLevel);
426
+ badges.push(`${badge} ${theme.fg("dim", `(${thinkingLabel})`)}`);
401
427
  }
402
428
  const badgeText = badges.length > 0 ? ` ${badges.join(" ")}` : "";
403
429
 
@@ -596,7 +622,7 @@ export class ModelSelectorComponent extends Container {
596
622
  this.#settings.setModelRole(role, `${model.provider}/${model.id}`);
597
623
 
598
624
  // Update local state for UI
599
- this.#roles[role] = model;
625
+ this.#roles[role] = { model };
600
626
 
601
627
  // Notify caller (for updating agent state if needed)
602
628
  this.#onSelectCallback(model, role);
@@ -22,6 +22,9 @@ export class PythonExecutionComponent extends Container {
22
22
  #expanded = false;
23
23
  #headerText: Text;
24
24
  #bodyText: Text;
25
+ #spinnerFrame = 0;
26
+ #spinnerInterval?: NodeJS.Timeout;
27
+ #ui: TUI;
25
28
 
26
29
  constructor(
27
30
  private readonly code: string,
@@ -29,6 +32,7 @@ export class PythonExecutionComponent extends Container {
29
32
  _excludeFromContext = false,
30
33
  ) {
31
34
  super();
35
+ this.#ui = ui;
32
36
  this.addChild(new Spacer(1));
33
37
 
34
38
  const codePreview = code.split("\n")[0].slice(0, 60);
@@ -50,6 +54,7 @@ export class PythonExecutionComponent extends Container {
50
54
  getSymbolTheme().spinnerFrames,
51
55
  );
52
56
  this.addChild(this.#loader);
57
+ this.#startSpinner();
53
58
  }
54
59
 
55
60
  setExpanded(expanded: boolean): void {
@@ -92,9 +97,28 @@ export class PythonExecutionComponent extends Container {
92
97
  this.#setOutput(options.output);
93
98
  }
94
99
  this.#loader.stop();
100
+ this.#stopSpinner();
95
101
  this.#updateDisplay();
96
102
  }
97
103
 
104
+ #startSpinner(): void {
105
+ if (this.#spinnerInterval) return;
106
+ this.#spinnerInterval = setInterval(() => {
107
+ const frameCount = theme.spinnerFrames.length;
108
+ if (frameCount === 0) return;
109
+ this.#spinnerFrame = (this.#spinnerFrame + 1) % frameCount;
110
+ this.#updateDisplay();
111
+ this.#ui.requestRender();
112
+ }, 80);
113
+ }
114
+
115
+ #stopSpinner(): void {
116
+ if (this.#spinnerInterval) {
117
+ clearInterval(this.#spinnerInterval);
118
+ this.#spinnerInterval = undefined;
119
+ }
120
+ }
121
+
98
122
  #updateDisplay(): void {
99
123
  const isError = this.#status === "error";
100
124
  const isDone = this.#status !== "running";
@@ -110,6 +134,19 @@ export class PythonExecutionComponent extends Container {
110
134
  this.#headerText.setText(
111
135
  renderStatusLine({ icon, title: "Python", description: `>>> ${codePreview}`, meta }, theme),
112
136
  );
137
+ } else {
138
+ const codePreview = this.code.split("\n")[0].slice(0, 60);
139
+ this.#headerText.setText(
140
+ renderStatusLine(
141
+ {
142
+ icon: "running",
143
+ spinnerFrame: this.#spinnerFrame,
144
+ title: "Python",
145
+ description: `>>> ${codePreview}`,
146
+ },
147
+ theme,
148
+ ),
149
+ );
113
150
  }
114
151
 
115
152
  const bodyLines: string[] = [];
@@ -130,6 +130,7 @@ export class ToolExecutionComponent extends Container {
130
130
  this.addChild(this.#contentBox);
131
131
  }
132
132
 
133
+ this.#updateSpinnerAnimation();
133
134
  this.#updateDisplay();
134
135
  }
135
136
 
@@ -212,10 +213,8 @@ export class ToolExecutionComponent extends Container {
212
213
  * Start or stop spinner animation based on whether this is a partial task result.
213
214
  */
214
215
  #updateSpinnerAnimation(): void {
215
- // Spinner for: task tool with partial result, or edit/write while args streaming
216
- const isStreamingArgs = !this.#argsComplete && (this.#toolName === "edit" || this.#toolName === "write");
217
- const isPartialTask = this.#isPartial && this.#tool?.mergeCallAndResult === true;
218
- const needsSpinner = isStreamingArgs || isPartialTask;
216
+ // Spinner runs whenever the tool hasn't produced a final result
217
+ const needsSpinner = this.#isPartial || !this.#result || !this.#argsComplete;
219
218
  if (needsSpinner && !this.#spinnerInterval) {
220
219
  this.#spinnerInterval = setInterval(() => {
221
220
  const frameCount = theme.spinnerFrames.length;