@nghyane/arcane 0.1.11 → 0.1.13

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/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.1.12] - 2026-02-24
6
+
7
+ ### Fixed
8
+
9
+ - Preserve single blank line content in edit tool — `hashlineParseContent` no longer strips the only line when it is empty
10
+
11
+ ### Changed
12
+
13
+ - Stream codemode intent immediately during LLM generation instead of waiting for execution start
14
+ - Hide loader spinner when codemode group is active to avoid duplicate status indicators
15
+
5
16
  ## [0.1.8] - 2026-02-22
6
17
 
7
18
  ### Changed
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@nghyane/arcane",
4
- "version": "0.1.11",
4
+ "version": "0.1.13",
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",
@@ -45,11 +45,11 @@
45
45
  "dependencies": {
46
46
  "@mozilla/readability": "0.6.0",
47
47
  "@nghyane/arcane-stats": "^0.1.8",
48
- "@nghyane/arcane-agent": "^0.1.9",
49
- "@nghyane/arcane-codemode": "^0.1.10",
48
+ "@nghyane/arcane-agent": "^0.1.11",
49
+ "@nghyane/arcane-codemode": "^0.1.12",
50
50
  "@nghyane/arcane-ai": "^0.1.8",
51
51
  "@nghyane/arcane-natives": "^0.1.7",
52
- "@nghyane/arcane-tui": "^0.1.9",
52
+ "@nghyane/arcane-tui": "^0.1.10",
53
53
  "@nghyane/arcane-utils": "^0.1.6",
54
54
  "@sinclair/typebox": "^0.34.48",
55
55
  "@xterm/headless": "^6.0.0",
@@ -47,7 +47,7 @@ export const MODEL_ROLES: Record<ModelRole, ModelRoleInfo> = {
47
47
  fast: { tag: "FAST", name: "Fast", color: "warning" },
48
48
  reviewer: { tag: "REVIEW", name: "Reviewer", color: "accent" },
49
49
  oracle: { tag: "ORACLE", name: "Oracle", color: "accent" },
50
- commit: { name: "Commit" },
50
+ commit: { tag: "COMMIT", name: "Commit", color: "dim" },
51
51
  };
52
52
 
53
53
  export const MODEL_ROLE_IDS: ModelRole[] = ["default", "fast", "reviewer", "oracle", "commit"];
@@ -26,13 +26,8 @@ Parameters:
26
26
  async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
27
27
  try {
28
28
  const apiKey = await findApiKey();
29
- if (!apiKey) {
30
- return {
31
- content: [{ type: "text" as const, text: "Error: EXA_API_KEY not found" }],
32
- details: { error: "EXA_API_KEY not found", toolName: "exa_company" },
33
- };
34
- }
35
- const response = await callExaTool("company_research", params, apiKey);
29
+ // Exa MCP endpoint is publicly accessible; API key is optional
30
+ const response = await callExaTool("company_research_exa", params, apiKey);
36
31
 
37
32
  if (isSearchResponse(response)) {
38
33
  const formatted = formatSearchResults(response);
@@ -26,13 +26,8 @@ Parameters:
26
26
  async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
27
27
  try {
28
28
  const apiKey = await findApiKey();
29
- if (!apiKey) {
30
- return {
31
- content: [{ type: "text" as const, text: "Error: EXA_API_KEY not found" }],
32
- details: { error: "EXA_API_KEY not found", toolName: "exa_linkedin" },
33
- };
34
- }
35
- const response = await callExaTool("linkedin_search", params, apiKey);
29
+ // Exa MCP endpoint is publicly accessible; API key is optional
30
+ const response = await callExaTool("linkedin_search_exa", params, apiKey);
36
31
 
37
32
  if (isSearchResponse(response)) {
38
33
  const formatted = formatSearchResults(response);
@@ -18,8 +18,11 @@ export function findApiKey(): string | null {
18
18
  }
19
19
 
20
20
  /** Fetch available tools from Exa MCP */
21
- export async function fetchExaTools(apiKey: string, toolNames: string[]): Promise<MCPTool[]> {
22
- const url = `https://mcp.exa.ai/mcp?exaApiKey=${encodeURIComponent(apiKey)}&toolNames=${encodeURIComponent(toolNames.join(","))}`;
21
+ export async function fetchExaTools(apiKey: string | null, toolNames: string[]): Promise<MCPTool[]> {
22
+ const params = new URLSearchParams();
23
+ if (apiKey) params.set("exaApiKey", apiKey);
24
+ params.set("toolNames", toolNames.join(","));
25
+ const url = `https://mcp.exa.ai/mcp?${params.toString()}`;
23
26
  const response = (await callMCP(url, "tools/list")) as MCPToolsResponse;
24
27
 
25
28
  if (response.error) {
@@ -44,8 +47,15 @@ export async function fetchWebsetsTools(apiKey: string): Promise<MCPTool[]> {
44
47
  }
45
48
 
46
49
  /** Call a tool on Exa MCP (simplified: toolName as first arg for easier use) */
47
- export async function callExaTool(toolName: string, args: Record<string, unknown>, apiKey: string): Promise<unknown> {
48
- const url = `https://mcp.exa.ai/mcp?exaApiKey=${encodeURIComponent(apiKey)}&tools=${encodeURIComponent(toolName)}`;
50
+ export async function callExaTool(
51
+ toolName: string,
52
+ args: Record<string, unknown>,
53
+ apiKey: string | null,
54
+ ): Promise<unknown> {
55
+ const params = new URLSearchParams();
56
+ if (apiKey) params.set("exaApiKey", apiKey);
57
+ params.set("tools", toolName);
58
+ const url = `https://mcp.exa.ai/mcp?${params.toString()}`;
49
59
  const response = (await callMCP(url, "tools/call", {
50
60
  name: toolName,
51
61
  arguments: args,
@@ -188,7 +198,7 @@ const mcpSchemaCache = new Map<string, MCPTool>();
188
198
 
189
199
  /** Fetch and cache MCP tool schema */
190
200
  export async function fetchMCPToolSchema(
191
- apiKey: string,
201
+ apiKey: string | null,
192
202
  mcpToolName: string,
193
203
  isWebsetsTool = false,
194
204
  ): Promise<MCPTool | null> {
@@ -198,7 +208,7 @@ export async function fetchMCPToolSchema(
198
208
  }
199
209
 
200
210
  try {
201
- const tools = isWebsetsTool ? await fetchWebsetsTools(apiKey) : await fetchExaTools(apiKey, [mcpToolName]);
211
+ const tools = isWebsetsTool ? await fetchWebsetsTools(apiKey!) : await fetchExaTools(apiKey, [mcpToolName]);
202
212
  const tool = tools.find(t => t.name === mcpToolName);
203
213
  if (tool) {
204
214
  mcpSchemaCache.set(cacheKey, tool);
@@ -238,15 +248,15 @@ export class MCPWrappedTool implements CustomTool<TSchema, ExaRenderDetails> {
238
248
  ): Promise<CustomToolResult<ExaRenderDetails>> {
239
249
  try {
240
250
  const apiKey = await findApiKey();
241
- if (!apiKey) {
251
+ if (!apiKey && this.config.isWebsetsTool) {
242
252
  return {
243
- content: [{ type: "text" as const, text: "Error: EXA_API_KEY not found" }],
244
- details: { error: "EXA_API_KEY not found", toolName: this.config.name },
253
+ content: [{ type: "text" as const, text: "Error: EXA_API_KEY required for Websets tools" }],
254
+ details: { error: "EXA_API_KEY required for Websets tools", toolName: this.config.name },
245
255
  };
246
256
  }
247
257
 
248
258
  const response = this.config.isWebsetsTool
249
- ? await callWebsetsTool(apiKey, this.config.mcpToolName, params as Record<string, unknown>)
259
+ ? await callWebsetsTool(apiKey!, this.config.mcpToolName, params as Record<string, unknown>)
250
260
  : await callExaTool(this.config.mcpToolName, params as Record<string, unknown>, apiKey);
251
261
 
252
262
  if (isSearchResponse(response)) {
@@ -277,7 +287,7 @@ export class MCPWrappedTool implements CustomTool<TSchema, ExaRenderDetails> {
277
287
  * Falls back to provided fallback schema if MCP fetch fails.
278
288
  */
279
289
  export async function createMCPToolFromServer(
280
- apiKey: string,
290
+ apiKey: string | null,
281
291
  config: MCPToolWrapperConfig,
282
292
  fallbackSchema: TSchema,
283
293
  fallbackDescription: string,
@@ -33,12 +33,7 @@ const researcherStartTool: CustomTool<any, ExaRenderDetails> = {
33
33
  async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
34
34
  try {
35
35
  const apiKey = await findApiKey();
36
- if (!apiKey) {
37
- return {
38
- content: [{ type: "text" as const, text: "Error: EXA_API_KEY not found" }],
39
- details: { error: "EXA_API_KEY not found", toolName: "exa_researcher_start" },
40
- };
41
- }
36
+ // Exa MCP endpoint is publicly accessible; API key is optional
42
37
  const result = await callExaTool("deep_researcher_start", params as Record<string, unknown>, apiKey);
43
38
  return {
44
39
  content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
@@ -65,12 +60,7 @@ const researcherPollTool: CustomTool<any, ExaRenderDetails> = {
65
60
  async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
66
61
  try {
67
62
  const apiKey = await findApiKey();
68
- if (!apiKey) {
69
- return {
70
- content: [{ type: "text" as const, text: "Error: EXA_API_KEY not found" }],
71
- details: { error: "EXA_API_KEY not found", toolName: "exa_researcher_poll" },
72
- };
73
- }
63
+ // Exa MCP endpoint is publicly accessible; API key is optional
74
64
  const result = await callExaTool("deep_researcher_check", params as Record<string, unknown>, apiKey);
75
65
  return {
76
66
  content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
package/src/exa/search.ts CHANGED
@@ -83,12 +83,7 @@ Parameters:
83
83
  async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
84
84
  try {
85
85
  const apiKey = await findApiKey();
86
- if (!apiKey) {
87
- return {
88
- content: [{ type: "text" as const, text: "Error: EXA_API_KEY not found" }],
89
- details: { error: "EXA_API_KEY not found", toolName: "exa_search" },
90
- };
91
- }
86
+ // Exa MCP endpoint is publicly accessible; API key is optional
92
87
  const response = await callExaTool("web_search_exa", params, apiKey);
93
88
 
94
89
  if (isSearchResponse(response)) {
@@ -177,12 +172,7 @@ Similar parameters to exa_search, optimized for research depth.`,
177
172
  async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
178
173
  try {
179
174
  const apiKey = await findApiKey();
180
- if (!apiKey) {
181
- return {
182
- content: [{ type: "text" as const, text: "Error: EXA_API_KEY not found" }],
183
- details: { error: "EXA_API_KEY not found", toolName: "exa_search_deep" },
184
- };
185
- }
175
+ // Exa MCP endpoint is publicly accessible; API key is optional
186
176
  const args = { ...params, type: "deep" };
187
177
  const response = await callExaTool("web_search_exa", args, apiKey);
188
178
 
@@ -232,12 +222,7 @@ Parameters:
232
222
  async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
233
223
  try {
234
224
  const apiKey = await findApiKey();
235
- if (!apiKey) {
236
- return {
237
- content: [{ type: "text" as const, text: "Error: EXA_API_KEY not found" }],
238
- details: { error: "EXA_API_KEY not found", toolName: "exa_search_code" },
239
- };
240
- }
225
+ // Exa MCP endpoint is publicly accessible; API key is optional
241
226
  const response = await callExaTool("get_code_context_exa", params, apiKey);
242
227
 
243
228
  if (isSearchResponse(response)) {
@@ -292,13 +277,8 @@ Parameters:
292
277
  async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
293
278
  try {
294
279
  const apiKey = await findApiKey();
295
- if (!apiKey) {
296
- return {
297
- content: [{ type: "text" as const, text: "Error: EXA_API_KEY not found" }],
298
- details: { error: "EXA_API_KEY not found", toolName: "exa_crawl" },
299
- };
300
- }
301
- const response = await callExaTool("crawling", params, apiKey);
280
+ // Exa MCP endpoint is publicly accessible; API key is optional
281
+ const response = await callExaTool("crawling_exa", params, apiKey);
302
282
 
303
283
  if (isSearchResponse(response)) {
304
284
  const formatted = formatSearchResults(response);
package/src/exa/types.ts CHANGED
@@ -122,11 +122,11 @@ export const EXA_TOOL_MAPPINGS = {
122
122
  // Search tools
123
123
  web_search_exa: "exa_search",
124
124
  get_code_context_exa: "exa_search_code",
125
- crawling: "exa_crawl",
125
+ crawling_exa: "exa_crawl",
126
126
  // LinkedIn
127
- linkedin_search: "exa_linkedin",
127
+ linkedin_search_exa: "exa_linkedin",
128
128
  // Company
129
- company_research: "exa_company",
129
+ company_research_exa: "exa_company",
130
130
  // Researcher
131
131
  deep_researcher_start: "exa_researcher_start",
132
132
  deep_researcher_check: "exa_researcher_poll",
@@ -7,6 +7,7 @@ import { Shell } from "@nghyane/arcane-natives";
7
7
  import { Settings } from "../config/settings";
8
8
  import { OutputSink } from "../session/streaming-output";
9
9
  import { getOrCreateSnapshot } from "../utils/shell-snapshot";
10
+ import { NON_INTERACTIVE_ENV } from "./non-interactive-env";
10
11
 
11
12
  export interface BashExecutorOptions {
12
13
  cwd?: string;
@@ -97,7 +98,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
97
98
  {
98
99
  command: finalCommand,
99
100
  cwd: options?.cwd,
100
- env: options?.env,
101
+ env: options?.env ? { ...NON_INTERACTIVE_ENV, ...options.env } : NON_INTERACTIVE_ENV,
101
102
  timeoutMs: options?.timeout,
102
103
  signal,
103
104
  },
@@ -0,0 +1,43 @@
1
+ export const NON_INTERACTIVE_ENV: Readonly<Record<string, string>> = {
2
+ // Disable pagers so commands don't block on interactive views.
3
+ PAGER: "cat",
4
+ GIT_PAGER: "cat",
5
+ MANPAGER: "cat",
6
+ SYSTEMD_PAGER: "cat",
7
+ BAT_PAGER: "cat",
8
+ DELTA_PAGER: "cat",
9
+ GH_PAGER: "cat",
10
+ GLAB_PAGER: "cat",
11
+ PSQL_PAGER: "cat",
12
+ MYSQL_PAGER: "cat",
13
+ AWS_PAGER: "",
14
+ HOMEBREW_PAGER: "cat",
15
+ LESS: "FRX",
16
+ // Disable editor and terminal credential prompts.
17
+ GIT_EDITOR: "true",
18
+ VISUAL: "true",
19
+ EDITOR: "true",
20
+ GIT_TERMINAL_PROMPT: "0",
21
+ SSH_ASKPASS: "/usr/bin/false",
22
+ CI: "1",
23
+ // Package manager defaults for unattended execution.
24
+ npm_config_yes: "true",
25
+ npm_config_update_notifier: "false",
26
+ npm_config_fund: "false",
27
+ npm_config_audit: "false",
28
+ npm_config_progress: "false",
29
+ PNPM_DISABLE_SELF_UPDATE_CHECK: "true",
30
+ PNPM_UPDATE_NOTIFIER: "false",
31
+ YARN_ENABLE_TELEMETRY: "0",
32
+ YARN_ENABLE_PROGRESS_BARS: "0",
33
+ // Cross-language/tooling non-interactive defaults.
34
+ CARGO_TERM_PROGRESS_WHEN: "never",
35
+ DEBIAN_FRONTEND: "noninteractive",
36
+ PIP_NO_INPUT: "1",
37
+ PIP_DISABLE_PIP_VERSION_CHECK: "1",
38
+ TF_INPUT: "0",
39
+ TF_IN_AUTOMATION: "1",
40
+ GH_PROMPT_DISABLED: "1",
41
+ COMPOSER_NO_INTERACTION: "1",
42
+ CLOUDSDK_CORE_DISABLE_PROMPTS: "1",
43
+ };
@@ -2,6 +2,7 @@ import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
3
  import { isEnoent } from "@nghyane/arcane-utils";
4
4
  import { getAgentDir, getProjectDir } from "@nghyane/arcane-utils/dirs";
5
+ import { $ } from "bun";
5
6
  import type { InstalledPlugin } from "./types";
6
7
 
7
8
  const PLUGINS_DIR = path.join(getAgentDir(), "plugins");
@@ -45,17 +46,9 @@ export async function installPlugin(packageName: string): Promise<InstalledPlugi
45
46
  }
46
47
 
47
48
  // Run npm install in plugins directory
48
- const proc = Bun.spawn(["bun", "install", packageName], {
49
- cwd: PLUGINS_DIR,
50
- stdin: "ignore",
51
- stdout: "pipe",
52
- stderr: "pipe",
53
- windowsHide: true,
54
- });
55
-
56
- const exitCode = await proc.exited;
57
- if (exitCode !== 0) {
58
- const stderr = await new Response(proc.stderr).text();
49
+ const result = await $`bun install ${packageName}`.cwd(PLUGINS_DIR).quiet().nothrow();
50
+ if (result.exitCode !== 0) {
51
+ const stderr = result.stderr.toString().trim();
59
52
  throw new Error(`Failed to install ${packageName}: ${stderr}`);
60
53
  }
61
54
 
@@ -87,16 +80,8 @@ export async function uninstallPlugin(name: string): Promise<void> {
87
80
 
88
81
  await ensurePluginsDir();
89
82
 
90
- const proc = Bun.spawn(["bun", "uninstall", name], {
91
- cwd: PLUGINS_DIR,
92
- stdin: "ignore",
93
- stdout: "pipe",
94
- stderr: "pipe",
95
- windowsHide: true,
96
- });
97
-
98
- const exitCode = await proc.exited;
99
- if (exitCode !== 0) {
83
+ const result = await $`bun uninstall ${name}`.cwd(PLUGINS_DIR).quiet().nothrow();
84
+ if (result.exitCode !== 0) {
100
85
  throw new Error(`Failed to uninstall ${name}`);
101
86
  }
102
87
  }
@@ -9,6 +9,7 @@ import {
9
9
  getProjectDir,
10
10
  getProjectPluginOverridesPath,
11
11
  } from "@nghyane/arcane-utils/dirs";
12
+ import { $ } from "bun";
12
13
  import { extractPackageName, parsePluginSpec } from "./parser";
13
14
  import type {
14
15
  DoctorCheck,
@@ -155,17 +156,9 @@ export class PluginManager {
155
156
  }
156
157
 
157
158
  // Run npm install
158
- const proc = Bun.spawn(["bun", "install", spec.packageName], {
159
- cwd: getPluginsDir(),
160
- stdin: "ignore",
161
- stdout: "pipe",
162
- stderr: "pipe",
163
- windowsHide: true,
164
- });
165
-
166
- const exitCode = await proc.exited;
167
- if (exitCode !== 0) {
168
- const stderr = await new Response(proc.stderr).text();
159
+ const result = await $`bun install ${spec.packageName}`.cwd(getPluginsDir()).quiet().nothrow();
160
+ if (result.exitCode !== 0) {
161
+ const stderr = result.stderr.toString().trim();
169
162
  throw new Error(`npm install failed: ${stderr}`);
170
163
  }
171
164
 
@@ -236,16 +229,8 @@ export class PluginManager {
236
229
  validatePackageName(name);
237
230
  await this.#ensurePackageJson();
238
231
 
239
- const proc = Bun.spawn(["bun", "uninstall", name], {
240
- cwd: getPluginsDir(),
241
- stdin: "ignore",
242
- stdout: "pipe",
243
- stderr: "pipe",
244
- windowsHide: true,
245
- });
246
-
247
- const exitCode = await proc.exited;
248
- if (exitCode !== 0) {
232
+ const result = await $`bun uninstall ${name}`.cwd(getPluginsDir()).quiet().nothrow();
233
+ if (result.exitCode !== 0) {
249
234
  throw new Error(`npm uninstall failed for ${name}`);
250
235
  }
251
236
 
@@ -619,14 +604,8 @@ export class PluginManager {
619
604
 
620
605
  async #fixMissingPlugin(): Promise<boolean> {
621
606
  try {
622
- const proc = Bun.spawn(["bun", "install"], {
623
- cwd: getPluginsDir(),
624
- stdin: "ignore",
625
- stdout: "pipe",
626
- stderr: "pipe",
627
- windowsHide: true,
628
- });
629
- return (await proc.exited) === 0;
607
+ const result = await $`bun install`.cwd(getPluginsDir()).quiet().nothrow();
608
+ return result.exitCode === 0;
630
609
  } catch {
631
610
  return false;
632
611
  }
@@ -298,7 +298,7 @@ class TwoColumnBody implements Component {
298
298
 
299
299
  render(width: number): string[] {
300
300
  const leftWidth = Math.floor(width * 0.5);
301
- const rightWidth = width - leftWidth - 3;
301
+ const rightWidth = Math.max(0, width - leftWidth - 3);
302
302
 
303
303
  const leftLines = this.leftPane.render(leftWidth);
304
304
  const rightLines = this.rightPane.render(rightWidth);
@@ -34,12 +34,17 @@ export class InspectorPanel implements Component {
34
34
  lines.push("");
35
35
 
36
36
  // Description (wrapped)
37
- if (ext.description) {
38
- const wrapped = wrapTextWithAnsi(ext.description, width - 2);
37
+ const desc = ext.description;
38
+ const isValidDescription = typeof desc === "string" && desc.length > 0;
39
+ if (isValidDescription && width > 2) {
40
+ const wrapped = wrapTextWithAnsi(desc, width - 2);
39
41
  for (const line of wrapped) {
40
42
  lines.push(truncateToWidth(line, width));
41
43
  }
42
44
  lines.push("");
45
+ } else if (isValidDescription) {
46
+ lines.push(truncateToWidth(desc, width));
47
+ lines.push("");
43
48
  }
44
49
 
45
50
  // Origin
@@ -67,9 +67,9 @@ export class WelcomeComponent implements Component {
67
67
  const leftCol = showRightColumn ? dualLeftCol : boxWidth - 2;
68
68
  const rightCol = showRightColumn ? dualRightCol : 0;
69
69
 
70
- // Block-based OMP logo (gradient: magenta → cyan)
70
+ // Block-based ARC logo (gradient: blue → cyan → green / Nord Frost)
71
71
  // biome-ignore format: preserve ASCII art layout
72
- const piLogo = ["▀████████████▀", " ╘███ ███ ", " ███ ███ ", " ███ ███ ", " ▄███▄ ▄███▄ "];
72
+ const piLogo = ["╭━━━╮╭━━━╮╭━━━╮", "┃╭━╮┃┃╭━╮┃┃╭━━╯", "┃╰━╯┃┃╰━╯┃┃┃ ", "┃┃ ┃┃┃╭╮╭╯┃╰━━╮", "╰╯ ╰╯╰╯╰╯ ╰━━━╯"];
73
73
 
74
74
  // Apply gradient to logo
75
75
  const logoColored = piLogo.map(line => this.#gradientLine(line));
@@ -190,15 +190,14 @@ export class WelcomeComponent implements Component {
190
190
  return padding(leftPad) + text + padding(rightPad);
191
191
  }
192
192
 
193
- /** Apply magenta→cyan gradient to a string */
193
+ /** Apply Nord Frost gradient (blue cyan green) to a string */
194
194
  #gradientLine(line: string): string {
195
195
  const colors = [
196
- "\x1b[38;5;199m", // bright magenta
197
- "\x1b[38;5;171m", // magenta-purple
198
- "\x1b[38;5;135m", // purple
199
- "\x1b[38;5;99m", // purple-blue
200
- "\x1b[38;5;75m", // cyan-blue
201
- "\x1b[38;5;51m", // bright cyan
196
+ "\x1b[38;2;136;192;208m", // #88c0d0 blue
197
+ "\x1b[38;2;141;200;200m", // blend
198
+ "\x1b[38;2;143;188;187m", // #8fbcbb cyan
199
+ "\x1b[38;2;153;189;170m", // blend
200
+ "\x1b[38;2;163;190;140m", // #a3be8c green
202
201
  ];
203
202
  const reset = "\x1b[0m";
204
203
 
@@ -42,6 +42,39 @@ export class EventController {
42
42
  this.ctx.setWorkingMessage(`${trimmed} (esc to interrupt)`);
43
43
  }
44
44
 
45
+ #ensureCodemodeGroup(id: string): CodeModeGroupComponent {
46
+ let group = this.#codemodeGroups.get(id);
47
+ if (!group) {
48
+ this.#resetReadGroup();
49
+ group = new CodeModeGroupComponent(this.ctx.ui);
50
+ group.setExpanded(this.ctx.toolOutputExpanded);
51
+ this.ctx.chatContainer.addChild(group);
52
+ this.#codemodeGroups.set(id, group);
53
+ this.ctx.pendingTools.set(id, group);
54
+ this.#hideLoader();
55
+ }
56
+ return group;
57
+ }
58
+
59
+ #hideLoader(): void {
60
+ if (!this.ctx.loadingAnimation) return;
61
+ this.ctx.loadingAnimation.stop();
62
+ this.ctx.statusContainer.clear();
63
+ this.ctx.loadingAnimation = undefined;
64
+ }
65
+
66
+ #restoreLoader(): void {
67
+ if (this.ctx.loadingAnimation || this.#codemodeGroups.size > 0) return;
68
+ this.ctx.loadingAnimation = new Loader(
69
+ this.ctx.ui,
70
+ spinner => theme.fg("accent", spinner),
71
+ text => theme.fg("muted", text),
72
+ `Working\u2026 (esc to interrupt)`,
73
+ getSymbolTheme().spinnerFrames,
74
+ );
75
+ this.ctx.statusContainer.addChild(this.ctx.loadingAnimation);
76
+ }
77
+
45
78
  subscribeToAgent(): void {
46
79
  this.ctx.unsubscribe = this.ctx.session.subscribe(async (event: AgentSessionEvent) => {
47
80
  await this.handleEvent(event);
@@ -132,8 +165,16 @@ export class EventController {
132
165
 
133
166
  for (const content of this.ctx.streamingMessage.content) {
134
167
  if (content.type !== "toolCall") continue;
135
- // Code Mode: suppress streaming render for "code" tool
136
- if (content.name === "code") continue;
168
+ // Code Mode: create group component early during streaming for intent display
169
+ if (content.name === "code") {
170
+ const group = this.#ensureCodemodeGroup(content.id);
171
+ const args = content.arguments;
172
+ if (args && typeof args === "object" && INTENT_FIELD in args) {
173
+ const intent = (args[INTENT_FIELD] as string | undefined)?.trim();
174
+ if (intent) group.setIntent(intent);
175
+ }
176
+ continue;
177
+ }
137
178
 
138
179
  if (!this.ctx.pendingTools.has(content.id)) {
139
180
  if (content.name === "read") {
@@ -169,9 +210,10 @@ export class EventController {
169
210
  }
170
211
  }
171
212
 
172
- // Update working message with intent from streamed tool arguments
213
+ // Update working message with intent skip for code tools that already have a visible group
173
214
  for (const content of this.ctx.streamingMessage.content) {
174
215
  if (content.type !== "toolCall") continue;
216
+ if (this.#codemodeGroups.has(content.id)) continue;
175
217
  const args = content.arguments;
176
218
  if (!args || typeof args !== "object" || !(INTENT_FIELD in args)) continue;
177
219
  this.#updateWorkingMessageFromIntent(args[INTENT_FIELD] as string | undefined);
@@ -218,19 +260,15 @@ export class EventController {
218
260
  break;
219
261
 
220
262
  case "tool_execution_start": {
221
- this.#updateWorkingMessageFromIntent(event.intent);
222
- // Code Mode: create a group component for the "code" tool
263
+ if (!this.#codemodeGroups.has(event.toolCallId)) this.#updateWorkingMessageFromIntent(event.intent);
223
264
  if (event.toolName === "code") {
224
- this.#resetReadGroup();
225
- const group = new CodeModeGroupComponent(this.ctx.ui);
226
- const intent = event.intent ?? (event.args as Record<string, unknown>)?.agent__intent;
265
+ const group = this.#ensureCodemodeGroup(event.toolCallId);
266
+ const intent = (event.intent ?? (event.args as Record<string, unknown>)?.agent__intent) as
267
+ | string
268
+ | undefined;
227
269
  if (typeof intent === "string" && intent.trim()) {
228
270
  group.setIntent(intent.trim());
229
271
  }
230
- group.setExpanded(this.ctx.toolOutputExpanded);
231
- this.ctx.chatContainer.addChild(group);
232
- this.#codemodeGroups.set(event.toolCallId, group);
233
- this.ctx.pendingTools.set(event.toolCallId, group);
234
272
  this.ctx.ui.requestRender();
235
273
  break;
236
274
  }
@@ -316,6 +354,7 @@ export class EventController {
316
354
  group.setDone();
317
355
  this.#codemodeGroups.delete(event.toolCallId);
318
356
  }
357
+ this.#restoreLoader();
319
358
  }
320
359
  // Update todo display when todo_write tool completes
321
360
  if (event.toolName === "todo_write" && !event.isError) {
@@ -199,7 +199,7 @@ function hashlineParseContent(edit: string | string[] | null): string[] {
199
199
  if (Array.isArray(edit)) return edit;
200
200
  const lines = stripNewLinePrefixes(edit.split("\n"));
201
201
  if (lines.length === 0) return [];
202
- if (lines[lines.length - 1].trim() === "") return lines.slice(0, -1);
202
+ if (lines.length > 1 && lines[lines.length - 1].trim() === "") return lines.slice(0, -1);
203
203
  return lines;
204
204
  }
205
205
 
@@ -663,6 +663,11 @@ export class AuthStorage {
663
663
  let credentials: OAuthCredentials;
664
664
  const saveApiKeyCredential = async (apiKey: string): Promise<void> => {
665
665
  const newCredential: ApiKeyCredential = { type: "api_key", key: apiKey };
666
+ const shouldReplaceExisting = provider === "minimax-code" || provider === "minimax-code-cn";
667
+ if (shouldReplaceExisting) {
668
+ await this.set(provider, newCredential);
669
+ return;
670
+ }
666
671
  const existing = this.#getCredentialsForProvider(provider);
667
672
  if (existing.length === 0) {
668
673
  await this.set(provider, newCredential);
@@ -77,6 +77,7 @@ function buildSshTarget(host: SSHConnectionTarget): string {
77
77
 
78
78
  function buildCommonArgs(host: SSHConnectionTarget): string[] {
79
79
  const args = [
80
+ "-n",
80
81
  "-o",
81
82
  "ControlMaster=auto",
82
83
  "-o",
@@ -46,10 +46,7 @@ async function ensurePythonWhisper(options?: EnsureOptions): Promise<void> {
46
46
  }
47
47
 
48
48
  // Check if whisper module is already importable
49
- const check = Bun.spawnSync([pythonCmd, "-c", "import whisper"], {
50
- stdout: "pipe",
51
- stderr: "pipe",
52
- });
49
+ const check = await $`${pythonCmd} -c ${"import whisper"}`.quiet().nothrow();
53
50
  if (check.exitCode === 0) return;
54
51
 
55
52
  options?.onProgress?.({ stage: "Installing openai-whisper (this may take a few minutes)..." });
package/src/stt/setup.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { $ } from "bun";
1
2
  import { detectRecordingTools } from "./recorder";
2
3
  import { resolvePython } from "./transcriber";
3
4
 
@@ -20,10 +21,7 @@ export async function checkDependencies(): Promise<STTDependencyStatus> {
20
21
 
21
22
  let whisperAvailable = false;
22
23
  if (pythonCmd) {
23
- const check = Bun.spawnSync([pythonCmd, "-c", "import whisper"], {
24
- stdout: "pipe",
25
- stderr: "pipe",
26
- });
24
+ const check = await $`${pythonCmd} -c ${"import whisper"}`.quiet().nothrow();
27
25
  whisperAvailable = check.exitCode === 0;
28
26
  }
29
27
  const whisperHint = "Run 'arc setup stt' to auto-install, or: pip install openai-whisper";
@@ -11,6 +11,7 @@ import {
11
11
  } from "@nghyane/arcane-tui";
12
12
  import type { Terminal as XtermTerminalType } from "@xterm/headless";
13
13
  import xterm from "@xterm/headless";
14
+ import { NON_INTERACTIVE_ENV } from "../exec/non-interactive-env";
14
15
  import type { Theme } from "../modes/theme/theme";
15
16
  import { OutputSink, type OutputSummary } from "../session/streaming-output";
16
17
  import { getStateIcon } from "../tui";
@@ -276,50 +277,6 @@ class BashInteractiveOverlayComponent implements Component {
276
277
  }
277
278
  }
278
279
 
279
- const NO_PAGER_ENV = {
280
- // Disable pagers so commands don't block on interactive views.
281
- PAGER: "cat",
282
- GIT_PAGER: "cat",
283
- MANPAGER: "cat",
284
- SYSTEMD_PAGER: "cat",
285
- BAT_PAGER: "cat",
286
- DELTA_PAGER: "cat",
287
- GH_PAGER: "cat",
288
- GLAB_PAGER: "cat",
289
- PSQL_PAGER: "cat",
290
- MYSQL_PAGER: "cat",
291
- AWS_PAGER: "",
292
- HOMEBREW_PAGER: "cat",
293
- LESS: "FRX",
294
- // Disable editor and terminal credential prompts.
295
- GIT_EDITOR: "true",
296
- VISUAL: "true",
297
- EDITOR: "true",
298
- GIT_TERMINAL_PROMPT: "0",
299
- SSH_ASKPASS: "/usr/bin/false",
300
- CI: "1",
301
- // Package manager defaults for unattended execution.
302
- npm_config_yes: "true",
303
- npm_config_update_notifier: "false",
304
- npm_config_fund: "false",
305
- npm_config_audit: "false",
306
- npm_config_progress: "false",
307
- PNPM_DISABLE_SELF_UPDATE_CHECK: "true",
308
- PNPM_UPDATE_NOTIFIER: "false",
309
- YARN_ENABLE_TELEMETRY: "0",
310
- YARN_ENABLE_PROGRESS_BARS: "0",
311
- // Cross-language/tooling non-interactive defaults.
312
- CARGO_TERM_PROGRESS_WHEN: "never",
313
- DEBIAN_FRONTEND: "noninteractive",
314
- PIP_NO_INPUT: "1",
315
- PIP_DISABLE_PIP_VERSION_CHECK: "1",
316
- TF_INPUT: "0",
317
- TF_IN_AUTOMATION: "1",
318
- GH_PROMPT_DISABLED: "1",
319
- COMPOSER_NO_INTERACTION: "1",
320
- CLOUDSDK_CORE_DISABLE_PROMPTS: "1",
321
- };
322
-
323
280
  export async function runInteractiveBashPty(
324
281
  ui: NonNullable<AgentToolContext["ui"]>,
325
282
  options: {
@@ -389,8 +346,8 @@ export async function runInteractiveBashPty(
389
346
  cwd: options.cwd,
390
347
  timeoutMs: options.timeoutMs,
391
348
  env: {
349
+ ...NON_INTERACTIVE_ENV,
392
350
  ...options.env,
393
- ...NO_PAGER_ENV,
394
351
  },
395
352
  signal: options.signal,
396
353
  cols,
package/src/utils/open.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { $ } from "bun";
1
2
  /** Open a URL or file path in the default browser/application. Best-effort, never throws. */
2
3
  export function openPath(urlOrPath: string): void {
3
4
  let cmd: string[];
@@ -13,7 +14,7 @@ export function openPath(urlOrPath: string): void {
13
14
  break;
14
15
  }
15
16
  try {
16
- Bun.spawn(cmd, { stdout: "ignore", stderr: "ignore", windowsHide: true });
17
+ $`${cmd}`.quiet().nothrow();
17
18
  } catch {
18
19
  // Best-effort: browser opening is non-critical
19
20
  }
@@ -461,7 +461,7 @@ Parameters:
461
461
  parameters: webSearchCrawlSchema,
462
462
 
463
463
  async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
464
- return executeExaTool("crawling", params as Record<string, unknown>, "web_search_crawl");
464
+ return executeExaTool("crawling_exa", params as Record<string, unknown>, "web_search_crawl");
465
465
  },
466
466
 
467
467
  renderCall(args, _options, theme) {
@@ -492,7 +492,7 @@ Parameters:
492
492
  parameters: webSearchLinkedinSchema,
493
493
 
494
494
  async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
495
- return executeExaTool("linkedin_search", params as Record<string, unknown>, "web_search_linkedin");
495
+ return executeExaTool("linkedin_search_exa", params as Record<string, unknown>, "web_search_linkedin");
496
496
  },
497
497
 
498
498
  renderCall(args, _options, theme) {
@@ -522,7 +522,7 @@ Parameters:
522
522
  parameters: webSearchCompanySchema,
523
523
 
524
524
  async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
525
- return executeExaTool("company_research", params as Record<string, unknown>, "web_search_company");
525
+ return executeExaTool("company_research_exa", params as Record<string, unknown>, "web_search_company");
526
526
  },
527
527
 
528
528
  renderCall(args, _options, theme) {