@nghyane/arcane 0.1.11 → 0.1.12

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.12",
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,8 +45,8 @@
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.10",
49
+ "@nghyane/arcane-codemode": "^0.1.11",
50
50
  "@nghyane/arcane-ai": "^0.1.8",
51
51
  "@nghyane/arcane-natives": "^0.1.7",
52
52
  "@nghyane/arcane-tui": "^0.1.9",
@@ -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
  }
@@ -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
 
@@ -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";
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
  }