@nghyane/arcane 0.1.17 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/package.json +6 -6
  2. package/src/cli/setup-cli.ts +2 -62
  3. package/src/commands/setup.ts +1 -1
  4. package/src/config/keybindings.ts +1 -4
  5. package/src/config/settings-schema.ts +4 -52
  6. package/src/extensibility/custom-tools/types.ts +2 -2
  7. package/src/extensibility/custom-tools/wrapper.ts +1 -1
  8. package/src/extensibility/extensions/wrapper.ts +1 -1
  9. package/src/extensibility/hooks/tool-wrapper.ts +1 -1
  10. package/src/modes/components/custom-editor.ts +6 -2
  11. package/src/modes/controllers/command-controller.ts +0 -2
  12. package/src/modes/controllers/input-controller.ts +123 -6
  13. package/src/modes/interactive-mode.ts +1 -84
  14. package/src/modes/types.ts +0 -1
  15. package/src/patch/edit-tool.ts +2 -11
  16. package/src/prompts/agents/explore.md +4 -2
  17. package/src/prompts/agents/librarian.md +4 -6
  18. package/src/prompts/agents/reviewer.md +1 -1
  19. package/src/prompts/agents/task.md +5 -1
  20. package/src/prompts/system/system-prompt.md +15 -8
  21. package/src/sdk.ts +11 -18
  22. package/src/session/agent-session.ts +1 -7
  23. package/src/session/session-manager.ts +0 -30
  24. package/src/session/streaming-edit.ts +1 -36
  25. package/src/tools/bash.ts +2 -1
  26. package/src/tools/create-tools.ts +2 -33
  27. package/src/tools/fetch.ts +1 -1
  28. package/src/tools/grep.ts +2 -1
  29. package/src/tools/python.ts +53 -1
  30. package/src/tools/read.ts +2 -1
  31. package/src/tools/write.ts +1 -1
  32. package/src/web/search/index.ts +2 -1
  33. package/src/patch/normative.ts +0 -72
  34. package/src/stt/downloader.ts +0 -68
  35. package/src/stt/index.ts +0 -3
  36. package/src/stt/recorder.ts +0 -351
  37. package/src/stt/setup.ts +0 -50
  38. package/src/stt/stt-controller.ts +0 -160
  39. package/src/stt/transcribe.py +0 -70
  40. package/src/stt/transcriber.ts +0 -91
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@nghyane/arcane",
4
- "version": "0.1.17",
4
+ "version": "0.1.18",
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,11 +44,11 @@
44
44
  },
45
45
  "dependencies": {
46
46
  "@mozilla/readability": "0.6.0",
47
- "@nghyane/arcane-stats": "^0.1.10",
48
- "@nghyane/arcane-agent": "^0.1.13",
49
- "@nghyane/arcane-ai": "^0.1.10",
50
- "@nghyane/arcane-natives": "^0.1.8",
51
- "@nghyane/arcane-tui": "^0.1.12",
47
+ "@nghyane/arcane-stats": "^0.1.11",
48
+ "@nghyane/arcane-agent": "^0.1.14",
49
+ "@nghyane/arcane-ai": "^0.1.11",
50
+ "@nghyane/arcane-natives": "^0.1.9",
51
+ "@nghyane/arcane-tui": "^0.1.13",
52
52
  "@nghyane/arcane-utils": "^0.1.7",
53
53
  "@sinclair/typebox": "^0.34.48",
54
54
  "@xterm/headless": "^6.0.0",
@@ -9,7 +9,7 @@ import { $ } from "bun";
9
9
  import chalk from "chalk";
10
10
  import { theme } from "../theme/theme";
11
11
 
12
- export type SetupComponent = "python" | "stt";
12
+ export type SetupComponent = "python";
13
13
 
14
14
  export interface SetupCommandArgs {
15
15
  component: SetupComponent;
@@ -19,7 +19,7 @@ export interface SetupCommandArgs {
19
19
  };
20
20
  }
21
21
 
22
- const VALID_COMPONENTS: SetupComponent[] = ["python", "stt"];
22
+ const VALID_COMPONENTS: SetupComponent[] = ["python"];
23
23
 
24
24
  const PYTHON_PACKAGES = ["jupyter_kernel_gateway", "ipykernel"];
25
25
  const MANAGED_PYTHON_ENV = getPythonEnvDir();
@@ -206,9 +206,6 @@ export async function runSetupCommand(cmd: SetupCommandArgs): Promise<void> {
206
206
  case "python":
207
207
  await handlePythonSetup(cmd.flags);
208
208
  break;
209
- case "stt":
210
- await handleSttSetup(cmd.flags);
211
- break;
212
209
  }
213
210
  }
214
211
 
@@ -295,60 +292,6 @@ async function handlePythonSetup(flags: { json?: boolean; check?: boolean }): Pr
295
292
  }
296
293
  }
297
294
 
298
- async function handleSttSetup(flags: { json?: boolean; check?: boolean }): Promise<void> {
299
- const { checkDependencies, formatDependencyStatus } = await import("../stt/setup");
300
- const status = await checkDependencies();
301
-
302
- if (flags.json) {
303
- console.log(JSON.stringify(status, null, 2));
304
- if (!status.recorder.available || !status.python.available || !status.whisper.available) process.exit(1);
305
- return;
306
- }
307
-
308
- console.log(formatDependencyStatus(status));
309
-
310
- if (status.recorder.available && status.python.available && status.whisper.available) {
311
- console.log(chalk.green(`\n${theme.status.success} Speech-to-text is ready`));
312
- return;
313
- }
314
-
315
- if (flags.check) {
316
- process.exit(1);
317
- }
318
-
319
- if (!status.python.available) {
320
- console.error(chalk.red(`\n${theme.status.error} Python not found`));
321
- console.error(chalk.dim("Install Python 3.8+ and ensure it's in your PATH"));
322
- process.exit(1);
323
- }
324
-
325
- if (!status.recorder.available) {
326
- console.error(chalk.yellow(`\n${theme.status.warning} No recording tool found`));
327
- console.error(chalk.dim(status.recorder.installHint));
328
- }
329
-
330
- if (!status.whisper.available) {
331
- console.log(chalk.dim(`\nInstalling openai-whisper...`));
332
- const { resolvePython } = await import("../stt/transcriber");
333
- const pythonCmd = resolvePython()!;
334
- const result = await $`${pythonCmd} -m pip install -q openai-whisper`.nothrow();
335
- if (result.exitCode !== 0) {
336
- console.error(chalk.red(`\n${theme.status.error} Failed to install openai-whisper`));
337
- console.error(chalk.dim("Try manually: pip install openai-whisper"));
338
- process.exit(1);
339
- }
340
- }
341
-
342
- const recheck = await checkDependencies();
343
- if (recheck.recorder.available && recheck.python.available && recheck.whisper.available) {
344
- console.log(chalk.green(`\n${theme.status.success} Speech-to-text is ready`));
345
- } else {
346
- console.error(chalk.red(`\n${theme.status.error} Setup incomplete`));
347
- console.log(formatDependencyStatus(recheck));
348
- process.exit(1);
349
- }
350
- }
351
-
352
295
  /**
353
296
  * Print setup command help.
354
297
  */
@@ -360,7 +303,6 @@ ${chalk.bold("Usage:")}
360
303
 
361
304
  ${chalk.bold("Components:")}
362
305
  python Install Jupyter kernel dependencies for Python code execution
363
- stt Install speech-to-text dependencies (openai-whisper, recording tools)
364
306
  Packages: ${PYTHON_PACKAGES.join(", ")}
365
307
 
366
308
  ${chalk.bold("Options:")}
@@ -369,8 +311,6 @@ ${chalk.bold("Options:")}
369
311
 
370
312
  ${chalk.bold("Examples:")}
371
313
  ${APP_NAME} setup python Install Python execution dependencies
372
- ${APP_NAME} setup stt Install speech-to-text dependencies
373
- ${APP_NAME} setup stt --check Check if STT dependencies are available
374
314
  ${APP_NAME} setup python --check Check if Python execution is available
375
315
  `);
376
316
  }
@@ -5,7 +5,7 @@ import { Args, Command, Flags, renderCommandHelp } from "@nghyane/arcane-utils/c
5
5
  import { runSetupCommand, type SetupCommandArgs, type SetupComponent } from "../cli/setup-cli";
6
6
  import { initTheme } from "../theme/theme";
7
7
 
8
- const COMPONENTS: SetupComponent[] = ["python", "stt"];
8
+ const COMPONENTS: SetupComponent[] = ["python"];
9
9
 
10
10
  export default class Setup extends Command {
11
11
  static description = "Install dependencies for optional features";
@@ -34,8 +34,7 @@ export type AppAction =
34
34
  | "newSession"
35
35
  | "tree"
36
36
  | "fork"
37
- | "resume"
38
- | "toggleSTT";
37
+ | "resume";
39
38
 
40
39
  /**
41
40
  * All configurable actions.
@@ -73,7 +72,6 @@ export const DEFAULT_APP_KEYBINDINGS: Record<AppAction, KeyId | KeyId[]> = {
73
72
  tree: [],
74
73
  fork: [],
75
74
  resume: [],
76
- toggleSTT: "alt+h",
77
75
  };
78
76
 
79
77
  /**
@@ -106,7 +104,6 @@ const APP_ACTIONS: AppAction[] = [
106
104
  "tree",
107
105
  "fork",
108
106
  "resume",
109
- "toggleSTT",
110
107
  ];
111
108
 
112
109
  function isAppAction(action: string): action is AppAction {
@@ -204,7 +204,7 @@ export const SETTINGS_SCHEMA = {
204
204
  steeringMode: {
205
205
  type: "enum",
206
206
  values: ["all", "one-at-a-time"] as const,
207
- default: "one-at-a-time",
207
+ default: "all",
208
208
  ui: {
209
209
  tab: "agent",
210
210
  label: "Steering mode",
@@ -214,7 +214,7 @@ export const SETTINGS_SCHEMA = {
214
214
  followUpMode: {
215
215
  type: "enum",
216
216
  values: ["all", "one-at-a-time"] as const,
217
- default: "one-at-a-time",
217
+ default: "all",
218
218
  ui: {
219
219
  tab: "agent",
220
220
  label: "Follow-up mode",
@@ -253,15 +253,6 @@ export const SETTINGS_SCHEMA = {
253
253
  submenu: true,
254
254
  },
255
255
  },
256
- normativeRewrite: {
257
- type: "boolean",
258
- default: false,
259
- ui: {
260
- tab: "agent",
261
- label: "Normative rewrite",
262
- description: "Rewrite tool call arguments to normalized format in session history",
263
- },
264
- },
265
256
  readLineNumbers: {
266
257
  type: "boolean",
267
258
  default: false,
@@ -586,7 +577,7 @@ export const SETTINGS_SCHEMA = {
586
577
  type: "boolean",
587
578
  default: true,
588
579
  ui: {
589
- tab: "display",
580
+ tab: "input",
590
581
  label: "Auto-resize images",
591
582
  description: "Resize large images to 2000x2000 max for better model compatibility",
592
583
  },
@@ -594,7 +585,7 @@ export const SETTINGS_SCHEMA = {
594
585
  "images.blockImages": {
595
586
  type: "boolean",
596
587
  default: false,
597
- ui: { tab: "display", label: "Block images", description: "Prevent images from being sent to LLM providers" },
588
+ ui: { tab: "input", label: "Block images", description: "Prevent images from being sent to LLM providers" },
598
589
  },
599
590
 
600
591
  // ─────────────────────────────────────────────────────────────────────────
@@ -796,36 +787,6 @@ export const SETTINGS_SCHEMA = {
796
787
  },
797
788
  },
798
789
 
799
- // ─────────────────────────────────────────────────────────────────────────
800
- // STT settings
801
- // ─────────────────────────────────────────────────────────────────────────
802
- "stt.enabled": {
803
- type: "boolean",
804
- default: false,
805
- ui: { tab: "input", label: "Speech-to-text", description: "Enable speech-to-text input via microphone" },
806
- },
807
- "stt.language": {
808
- type: "string",
809
- default: "en",
810
- ui: {
811
- tab: "input",
812
- label: "STT language",
813
- description: "Language code for transcription (e.g., en, es, fr)",
814
- submenu: true,
815
- },
816
- },
817
- "stt.modelName": {
818
- type: "enum",
819
- values: ["tiny", "tiny.en", "base", "base.en", "small", "small.en", "medium", "medium.en", "large"] as const,
820
- default: "base.en",
821
- ui: {
822
- tab: "input",
823
- label: "STT model",
824
- description: "Whisper model size (larger = more accurate but slower)",
825
- submenu: true,
826
- },
827
- },
828
-
829
790
  // ─────────────────────────────────────────────────────────────────────────
830
791
  // Edit settings
831
792
  // ─────────────────────────────────────────────────────────────────────────
@@ -1109,14 +1070,6 @@ export interface ThinkingBudgetsSettings {
1109
1070
  high: number;
1110
1071
  }
1111
1072
 
1112
- export interface SttSettings {
1113
- enabled: boolean;
1114
- language: string | undefined;
1115
- modelName: string;
1116
- whisperPath: string | undefined;
1117
- modelPath: string | undefined;
1118
- }
1119
-
1120
1073
  export interface BashInterceptorRule {
1121
1074
  pattern: string;
1122
1075
  flags?: string;
@@ -1136,7 +1089,6 @@ export interface GroupTypeMap {
1136
1089
  exa: ExaSettings;
1137
1090
  statusLine: StatusLineSettings;
1138
1091
  thinkingBudgets: ThinkingBudgetsSettings;
1139
- stt: SttSettings;
1140
1092
  modelRoles: Record<string, string>;
1141
1093
  }
1142
1094
 
@@ -178,10 +178,10 @@ export interface CustomTool<TParams extends TSchema = TSchema, TDetails = any> {
178
178
  execute(
179
179
  toolCallId: string,
180
180
  params: Static<TParams>,
181
- onUpdate: AgentToolUpdateCallback<TDetails, TParams> | undefined,
181
+ onUpdate: AgentToolUpdateCallback<TDetails> | undefined,
182
182
  ctx: CustomToolContext,
183
183
  signal?: AbortSignal,
184
- ): Promise<AgentToolResult<TDetails, TParams>>;
184
+ ): Promise<AgentToolResult<TDetails>>;
185
185
 
186
186
  /** Called on session lifecycle events - use to reconstruct state or cleanup resources */
187
187
  onSession?: (event: CustomToolSessionEvent, ctx: CustomToolContext) => void | Promise<void>;
@@ -26,7 +26,7 @@ export class CustomToolAdapter<TParams extends TSchema = TSchema, TDetails = any
26
26
  toolCallId: string,
27
27
  params: Static<TParams>,
28
28
  signal?: AbortSignal,
29
- onUpdate?: AgentToolUpdateCallback<TDetails, TParams>,
29
+ onUpdate?: AgentToolUpdateCallback<TDetails>,
30
30
  context?: CustomToolContext,
31
31
  ) {
32
32
  return this.tool.execute(toolCallId, params, onUpdate, context ?? this.getContext(), signal);
@@ -96,7 +96,7 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
96
96
  toolCallId: string,
97
97
  params: Static<TParameters>,
98
98
  signal?: AbortSignal,
99
- onUpdate?: AgentToolUpdateCallback<TDetails, TParameters>,
99
+ onUpdate?: AgentToolUpdateCallback<TDetails>,
100
100
  context?: AgentToolContext,
101
101
  ) {
102
102
  // Emit tool_call event - extensions can block execution
@@ -34,7 +34,7 @@ export class HookToolWrapper<TParameters extends TSchema = TSchema, TDetails = u
34
34
  toolCallId: string,
35
35
  params: Static<TParameters>,
36
36
  signal?: AbortSignal,
37
- onUpdate?: AgentToolUpdateCallback<TDetails, TParameters>,
37
+ onUpdate?: AgentToolUpdateCallback<TDetails>,
38
38
  context?: AgentToolContext,
39
39
  ) {
40
40
  // Emit tool_call event - hooks can block execution
@@ -59,9 +59,13 @@ export class CustomEditor extends Editor {
59
59
  return;
60
60
  }
61
61
 
62
- // Intercept Ctrl+V for image paste (async - fires and handles result)
62
+ // Intercept Ctrl+V for image paste fall through to text paste if no image handled
63
63
  if (matchesKey(data, "ctrl+v") && this.onCtrlV) {
64
- void this.onCtrlV();
64
+ void this.onCtrlV().then(handled => {
65
+ if (!handled) {
66
+ super.handleInput(data);
67
+ }
68
+ });
65
69
  return;
66
70
  }
67
71
 
@@ -364,7 +364,6 @@ export class CommandController {
364
364
  handleHotkeysCommand(): void {
365
365
  const k = (id: string) => formatKeyHint(id as KeyId);
366
366
  const expandToolsKey = this.ctx.keybindings.getDisplayString("expandTools") || k("ctrl+o");
367
- const sttKey = this.ctx.keybindings.getDisplayString("toggleSTT") || k("alt+h");
368
367
  const hotkeys = `
369
368
  **Navigation**
370
369
  | Key | Action |
@@ -400,7 +399,6 @@ export class CommandController {
400
399
  | \`${expandToolsKey}\` | Toggle tool output expansion |
401
400
  | \`${k("ctrl+t")}\` | Toggle todo list expansion |
402
401
  | \`${k("ctrl+g")}\` | Edit message in external editor |
403
- | \`${sttKey}\` | Toggle speech-to-text recording |
404
402
  | \`/\` | Slash commands |
405
403
  | \`!\` | Run bash command |
406
404
  | \`!!\` | Run bash command (excluded from context) |
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import type { AgentMessage } from "@nghyane/arcane-agent";
3
- import { copyToClipboard, readImageFromClipboard, sanitizeText } from "@nghyane/arcane-natives";
3
+ import { copyToClipboard, readImageFromClipboard, readTextFromClipboard, sanitizeText } from "@nghyane/arcane-natives";
4
4
  import { $env } from "@nghyane/arcane-utils";
5
5
  import { settings } from "../../config/settings";
6
6
  import type { InteractiveModeContext } from "../../modes/types";
@@ -10,8 +10,10 @@ import { executeBuiltinSlashCommand } from "../../slash-commands/builtin-registr
10
10
  import { theme } from "../../theme/theme";
11
11
  import { getEditorCommand, openInEditor } from "../../utils/external-editor";
12
12
  import { resizeImage } from "../../utils/image-resize";
13
+ import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime";
13
14
  import { generateSessionTitle, setTerminalTitle } from "../../utils/title-generator";
14
15
 
16
+ const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 5 MB
15
17
  interface Expandable {
16
18
  setExpanded(expanded: boolean): void;
17
19
  }
@@ -123,9 +125,6 @@ export class InputController {
123
125
  for (const key of this.ctx.keybindings.getKeys("followUp")) {
124
126
  this.ctx.editor.setCustomKeyHandler(key, () => void this.handleFollowUp());
125
127
  }
126
- for (const key of this.ctx.keybindings.getKeys("toggleSTT")) {
127
- this.ctx.editor.setCustomKeyHandler(key, () => void this.ctx.handleSTTToggle());
128
- }
129
128
 
130
129
  this.ctx.editor.onChange = (text: string) => {
131
130
  const wasBashMode = this.ctx.isBashMode;
@@ -137,6 +136,9 @@ export class InputController {
137
136
  this.ctx.updateEditorBorderColor();
138
137
  }
139
138
  };
139
+
140
+ this.ctx.editor.onPaste = (text: string) => this.handlePastedImagePaths(text);
141
+ this.ctx.editor.onHighlightLine = (text: string) => this.#highlightEditorLine(text);
140
142
  }
141
143
 
142
144
  setupEditorSubmitHandler(): void {
@@ -478,6 +480,20 @@ export class InputController {
478
480
  }
479
481
  }
480
482
 
483
+ const sizeBytes = (imageData.data.length * 3) / 4;
484
+ if (sizeBytes > MAX_IMAGE_BYTES) {
485
+ try {
486
+ const compressed = await resizeImage(
487
+ { type: "image", data: imageData.data, mimeType: imageData.mimeType },
488
+ { maxBytes: MAX_IMAGE_BYTES },
489
+ );
490
+ imageData = { data: compressed.data, mimeType: compressed.mimeType };
491
+ } catch {
492
+ this.ctx.showStatus(`Image too large (${(sizeBytes / 1024 / 1024).toFixed(1)} MB, max 5 MB)`);
493
+ return true;
494
+ }
495
+ }
496
+
481
497
  this.ctx.pendingImages.push({
482
498
  type: "image",
483
499
  data: imageData.data,
@@ -490,8 +506,14 @@ export class InputController {
490
506
  this.ctx.ui.requestRender();
491
507
  return true;
492
508
  }
493
- // No image in clipboard - show hint
494
- this.ctx.showStatus("No image in clipboard (use terminal paste for text)");
509
+
510
+ // No image data try text clipboard for file paths
511
+ const clipText = await readTextFromClipboard();
512
+ if (clipText && this.handlePastedImagePaths(clipText)) {
513
+ return true;
514
+ }
515
+
516
+ // Not an image — let caller fall through to normal text paste
495
517
  return false;
496
518
  } catch {
497
519
  this.ctx.showStatus("Failed to read clipboard");
@@ -499,6 +521,101 @@ export class InputController {
499
521
  }
500
522
  }
501
523
 
524
+ /**
525
+ * Detect image file paths in pasted text (e.g., drag-drop from file manager).
526
+ * Returns true if all pasted content was image paths (suppresses normal paste).
527
+ */
528
+ handlePastedImagePaths(text: string): boolean {
529
+ const trimmed = text.trim();
530
+ if (!trimmed) return false;
531
+
532
+ // Split by newlines — drag-drop can paste multiple file paths
533
+ const lines = trimmed
534
+ .split(/\r?\n/)
535
+ .map(l => l.trim())
536
+ .filter(Boolean);
537
+ if (lines.length === 0 || lines.length > 20) return false;
538
+
539
+ // Check if ALL lines look like file paths to images
540
+ const IMAGE_EXT = /\.(png|jpe?g|gif|webp)$/i;
541
+ const paths: string[] = [];
542
+ for (const line of lines) {
543
+ // Terminals may quote or add file:// prefix
544
+ let p = line.replace(/^['"]|['"]$/g, "").replace(/^file:\/\//, "");
545
+ // Must look like an absolute or home-relative path with image extension
546
+ if (!/^[/~]/.test(p) || !IMAGE_EXT.test(p)) return false;
547
+ // Expand ~ to home dir
548
+ if (p.startsWith("~/")) {
549
+ p = `${process.env.HOME ?? ""}${p.slice(1)}`;
550
+ }
551
+ paths.push(p);
552
+ }
553
+
554
+ // Process asynchronously, return true to suppress normal paste
555
+ void this.#loadImageFiles(paths);
556
+ return true;
557
+ }
558
+
559
+ async #loadImageFiles(paths: string[]): Promise<void> {
560
+ let loaded = 0;
561
+ for (const filePath of paths) {
562
+ try {
563
+ const mimeType = await detectSupportedImageMimeTypeFromFile(filePath);
564
+ if (!mimeType) {
565
+ this.ctx.showStatus(`Not a supported image: ${filePath}`);
566
+ continue;
567
+ }
568
+ const data = await Bun.file(filePath).bytes();
569
+ let base64Data = data.toBase64();
570
+ let finalMime = mimeType;
571
+
572
+ if (settings.get("images.autoResize")) {
573
+ try {
574
+ const resized = await resizeImage({ type: "image", data: base64Data, mimeType });
575
+ base64Data = resized.data;
576
+ finalMime = resized.mimeType;
577
+ } catch {
578
+ // Use original on resize failure
579
+ }
580
+ }
581
+
582
+ const sizeBytes = (base64Data.length * 3) / 4;
583
+ if (sizeBytes > MAX_IMAGE_BYTES) {
584
+ try {
585
+ const compressed = await resizeImage(
586
+ { type: "image", data: base64Data, mimeType: finalMime },
587
+ { maxBytes: MAX_IMAGE_BYTES },
588
+ );
589
+ base64Data = compressed.data;
590
+ finalMime = compressed.mimeType;
591
+ } catch {
592
+ this.ctx.showStatus(
593
+ `Image too large: ${filePath} (${(sizeBytes / 1024 / 1024).toFixed(1)} MB, max 5 MB)`,
594
+ );
595
+ continue;
596
+ }
597
+ }
598
+
599
+ this.ctx.pendingImages.push({ type: "image", data: base64Data, mimeType: finalMime });
600
+ const imageNum = this.ctx.pendingImages.length;
601
+ this.ctx.editor.insertText(`[Image #${imageNum}] `);
602
+ loaded++;
603
+ } catch {
604
+ this.ctx.showStatus(`Failed to read image: ${filePath}`);
605
+ }
606
+ }
607
+ if (loaded > 0) {
608
+ this.ctx.ui.requestRender();
609
+ }
610
+ }
611
+
612
+ #highlightEditorLine(text: string): string {
613
+ // Highlight [Image #N] and [paste #N ...] markers with accent color
614
+ return text.replace(/\[(Image #\d+|paste #\d+[^\]]*)\]/g, match => {
615
+ return theme.fg("accent", match);
616
+ });
617
+ }
618
+
502
619
  /** Copy current prompt text to system clipboard. */
503
620
  handleCopyPrompt(): void {
504
621
  const text = this.ctx.editor.getText();
@@ -15,7 +15,7 @@ import {
15
15
  Text,
16
16
  TUI,
17
17
  } from "@nghyane/arcane-tui";
18
- import { hsvToRgb, isEnoent, logger, postmortem } from "@nghyane/arcane-utils";
18
+ import { isEnoent, logger, postmortem } from "@nghyane/arcane-utils";
19
19
  import { APP_NAME, getProjectDir } from "@nghyane/arcane-utils/dirs";
20
20
  import chalk from "chalk";
21
21
  import { KeybindingsManager } from "../config/keybindings";
@@ -27,7 +27,6 @@ import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
27
27
  import { HistoryStorage } from "../session/history-storage";
28
28
  import type { SessionContext, SessionManager } from "../session/session-manager";
29
29
  import { getRecentSessions } from "../session/session-manager";
30
- import { STTController, type SttState } from "../stt";
31
30
  import { setMermaidRenderCallback } from "../theme/mermaid-cache";
32
31
  import type { Theme } from "../theme/theme";
33
32
  import { getEditorTheme, getMarkdownTheme, onThemeChange, theme } from "../theme/theme";
@@ -145,11 +144,6 @@ export class InteractiveMode implements InteractiveModeContext {
145
144
  readonly #inputController: InputController;
146
145
  readonly #selectorController: SelectorController;
147
146
  readonly #uiHelpers: UiHelpers;
148
- #sttController: STTController | undefined;
149
- #voiceAnimationInterval: NodeJS.Timeout | undefined;
150
- #voiceHue = 0;
151
- #voicePreviousShowHardwareCursor: boolean | null = null;
152
- #voicePreviousUseTerminalCursor: boolean | null = null;
153
147
  #resizeHandler?: () => void;
154
148
 
155
149
  constructor(
@@ -501,11 +495,6 @@ export class InteractiveMode implements InteractiveModeContext {
501
495
  this.loadingAnimation.stop();
502
496
  this.loadingAnimation = undefined;
503
497
  }
504
- this.#cleanupMicAnimation();
505
- if (this.#sttController) {
506
- this.#sttController.dispose();
507
- this.#sttController = undefined;
508
- }
509
498
  this.#extensionUiController.clearExtensionTerminalInputListeners();
510
499
  this.statusLine.dispose();
511
500
  if (this.#resizeHandler) {
@@ -726,78 +715,6 @@ export class InteractiveMode implements InteractiveModeContext {
726
715
  return this.#commandController.handleMemoryCommand(text);
727
716
  }
728
717
 
729
- async handleSTTToggle(): Promise<void> {
730
- if (!settings.get("stt.enabled")) {
731
- this.showWarning("Speech-to-text is disabled. Enable it in settings: stt.enabled");
732
- return;
733
- }
734
- if (!this.#sttController) {
735
- this.#sttController = new STTController();
736
- }
737
- await this.#sttController.toggle(this.editor, {
738
- showWarning: (msg: string) => this.showWarning(msg),
739
- showStatus: (msg: string) => this.showStatus(msg),
740
- onStateChange: (state: SttState) => {
741
- if (state === "recording") {
742
- this.#voicePreviousShowHardwareCursor = this.ui.getShowHardwareCursor();
743
- this.#voicePreviousUseTerminalCursor = this.editor.getUseTerminalCursor();
744
- this.ui.setShowHardwareCursor(false);
745
- this.editor.setUseTerminalCursor(false);
746
- this.#startMicAnimation();
747
- } else if (state === "transcribing") {
748
- this.#stopMicAnimation();
749
- this.editor.cursorOverride = `\x1b[38;2;200;200;200m${theme.icon.mic}\x1b[0m`;
750
- this.editor.cursorOverrideWidth = 1;
751
- } else {
752
- this.#cleanupMicAnimation();
753
- }
754
- this.updateEditorTopBorder();
755
- this.ui.requestRender();
756
- },
757
- });
758
- }
759
-
760
- #updateMicIcon(): void {
761
- const { r, g, b } = hsvToRgb({ h: this.#voiceHue, s: 0.9, v: 1.0 });
762
- this.editor.cursorOverride = `\x1b[38;2;${r};${g};${b}m${theme.icon.mic}\x1b[0m`;
763
- this.editor.cursorOverrideWidth = 1;
764
- }
765
-
766
- #startMicAnimation(): void {
767
- if (this.#voiceAnimationInterval) return;
768
- this.#voiceHue = 0;
769
- this.#updateMicIcon();
770
- this.#voiceAnimationInterval = setInterval(() => {
771
- this.#voiceHue = (this.#voiceHue + 8) % 360;
772
- this.#updateMicIcon();
773
- this.ui.requestRender();
774
- }, 60);
775
- }
776
-
777
- #stopMicAnimation(): void {
778
- if (this.#voiceAnimationInterval) {
779
- clearInterval(this.#voiceAnimationInterval);
780
- this.#voiceAnimationInterval = undefined;
781
- }
782
- }
783
-
784
- #cleanupMicAnimation(): void {
785
- if (this.#voiceAnimationInterval) {
786
- clearInterval(this.#voiceAnimationInterval);
787
- this.#voiceAnimationInterval = undefined;
788
- }
789
- this.editor.cursorOverride = undefined;
790
- this.editor.cursorOverrideWidth = undefined;
791
- if (this.#voicePreviousShowHardwareCursor !== null) {
792
- this.ui.setShowHardwareCursor(this.#voicePreviousShowHardwareCursor);
793
- this.#voicePreviousShowHardwareCursor = null;
794
- }
795
- if (this.#voicePreviousUseTerminalCursor !== null) {
796
- this.editor.setUseTerminalCursor(this.#voicePreviousUseTerminalCursor);
797
- this.#voicePreviousUseTerminalCursor = null;
798
- }
799
- }
800
-
801
718
  showDebugSelector(): void {
802
719
  this.#selectorController.showDebugSelector();
803
720
  }
@@ -149,7 +149,6 @@ export interface InteractiveModeContext {
149
149
  handleHandoffCommand(customInstructions?: string): Promise<void>;
150
150
  handleMoveCommand(targetPath: string): Promise<void>;
151
151
  handleMemoryCommand(text: string): Promise<void>;
152
- handleSTTToggle(): Promise<void>;
153
152
  executeCompaction(customInstructionsOrOptions?: string | CompactOptions, isAuto?: boolean): Promise<void>;
154
153
  openInBrowser(urlOrPath: string): void;
155
154
  refreshSlashCommandState(cwd?: string): Promise<void>;