miii-agent 0.1.24 → 0.1.26

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 (3) hide show
  1. package/README.md +6 -0
  2. package/dist/cli.js +288 -84
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  <p align="center">
10
10
  <a href="https://www.npmjs.com/package/miii-agent"><img src="https://img.shields.io/npm/v/miii-agent" alt="npm version"></a>
11
+ <a href="https://www.npmjs.com/package/miii-agent"><img src="https://img.shields.io/npm/dt/miii-agent" alt="npm total downloads"></a>
11
12
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="license"></a>
12
13
  <a href="https://nodejs.org"><img src="https://img.shields.io/badge/node-%3E%3D18-brightgreen" alt="node version"></a>
13
14
  <a href="https://ollama.com"><img src="https://img.shields.io/badge/powered%20by-Ollama-black" alt="powered by Ollama"></a>
@@ -68,6 +69,10 @@ It doesn't just chat, either — it decomposes the problem, calls tools, and che
68
69
  miii doctor # grade every installed model
69
70
  miii doctor qwen2.5-coder:7b # grade one
70
71
  ```
72
+ - **🖼️ Paste images** — copy a screenshot and hit `Ctrl+V` to attach it to your message, or paste an image file path. Great for "why does this UI look broken?" or reading an error screenshot. **Needs a vision-capable model** (`llava`, `llama3.2-vision`, `qwen2-vl`, …) — text-only models silently ignore the image.
73
+ ```bash
74
+ ollama pull llava # or llama3.2-vision
75
+ ```
71
76
  - **💧 Lossless output spill** — that 50K-line test log won't get truncated and leave the model guessing. miii spills the full output to disk and lets the model page through it. Nothing is ever lost.
72
77
  - **🔒 Permission-gated tools** — you approve what the agent can touch; "always" approvals persist. File tools are confined to your working directory.
73
78
  - **📄 `MIII.md`** — drop one in your repo to teach miii your conventions, build/test commands, and do's & don'ts. Same idea as `CLAUDE.md`, read every turn.
@@ -96,6 +101,7 @@ File tools (`read_file`, `write_file`, `edit_file`) reject `../` traversal and a
96
101
  |-----|--------|
97
102
  | `Enter` | Send prompt |
98
103
  | `@filename` | Attach file to context |
104
+ | `Ctrl+V` | Paste clipboard image (needs a vision model) |
99
105
  | `/models` | Switch active model |
100
106
  | `/clear` | Reset conversation |
101
107
  | `Esc` | Stop generation or tool run |
package/dist/cli.js CHANGED
@@ -129,6 +129,15 @@ function isConnectionError(err) {
129
129
  const msg = err instanceof Error ? err.message : String(err);
130
130
  return msg.includes("ECONNREFUSED") || msg.includes("fetch failed") || msg.includes("connect");
131
131
  }
132
+ function isNoVisionError(err) {
133
+ const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
134
+ return msg.includes("does not support image") || msg.includes("image input");
135
+ }
136
+ function noVisionError(model) {
137
+ return new Error(
138
+ `"${model}" can't read images \u2014 it has no vision support. Switch to a vision-capable model (e.g. llava, llama3.2-vision, qwen2-vl) with /models, then resend. Pull one with: ollama pull llava`
139
+ );
140
+ }
132
141
  async function listModels(entry) {
133
142
  try {
134
143
  const { models } = await makeClient(entry).list();
@@ -208,6 +217,7 @@ async function* chat(entry, model, messages, tools, opts) {
208
217
  }
209
218
  } catch (err) {
210
219
  if (signal?.aborted) return;
220
+ if (isNoVisionError(err)) throw noVisionError(model);
211
221
  if (isConnectionError(err)) {
212
222
  throw new Error(NOT_RUNNING);
213
223
  }
@@ -233,6 +243,7 @@ async function* chat(entry, model, messages, tools, opts) {
233
243
  }
234
244
  } catch (err) {
235
245
  if (opts?.signal?.aborted) return;
246
+ if (isNoVisionError(err)) throw noVisionError(model);
236
247
  if (isConnectionError(err)) {
237
248
  throw new Error(NOT_RUNNING);
238
249
  }
@@ -401,6 +412,10 @@ async function* chat2(entry, model, messages, tools, opts) {
401
412
  const delta = choices[0].delta ?? {};
402
413
  const finishReason = choices[0].finish_reason;
403
414
  if (finishReason) lastFinishReason = finishReason;
415
+ const reasoning = delta.reasoning_content ?? (typeof delta.reasoning === "string" ? delta.reasoning : void 0) ?? delta.reasoning?.content;
416
+ if (reasoning) {
417
+ yield { content: "", thinking: reasoning, done: false };
418
+ }
404
419
  if (delta.content) {
405
420
  yield { content: delta.content, done: false };
406
421
  }
@@ -1425,7 +1440,9 @@ function toOllamaMessages(history, system) {
1425
1440
  const out = [{ role: "system", content: system }];
1426
1441
  for (const msg of history) {
1427
1442
  if (typeof msg.content === "string") {
1428
- out.push({ role: msg.role === "system" ? "system" : msg.role, content: msg.content });
1443
+ const om = { role: msg.role === "system" ? "system" : msg.role, content: msg.content };
1444
+ if (msg.role === "user" && msg.images && msg.images.length > 0) om.images = msg.images;
1445
+ out.push(om);
1429
1446
  continue;
1430
1447
  }
1431
1448
  if (msg.role === "assistant") {
@@ -1611,7 +1628,11 @@ async function* runAgent(opts) {
1611
1628
  const effort = EFFORT_OPTIONS[loadConfig().effort ?? "medium"];
1612
1629
  const history = [
1613
1630
  ...opts.history,
1614
- { role: "user", content: opts.userText }
1631
+ {
1632
+ role: "user",
1633
+ content: opts.userText,
1634
+ ...opts.images && opts.images.length > 0 ? { images: opts.images } : {}
1635
+ }
1615
1636
  ];
1616
1637
  let promptTokens = 0;
1617
1638
  let evalTokens = 0;
@@ -1829,14 +1850,14 @@ var init_loop = __esm({
1829
1850
  });
1830
1851
 
1831
1852
  // eval/runner.ts
1832
- import { mkdtempSync, writeFileSync as writeFileSync7, mkdirSync as mkdirSync6, rmSync as rmSync3 } from "fs";
1833
- import { dirname as dirname3, join as join8 } from "path";
1834
- import { tmpdir } from "os";
1853
+ import { mkdtempSync, writeFileSync as writeFileSync7, mkdirSync as mkdirSync6, rmSync as rmSync4 } from "fs";
1854
+ import { dirname as dirname3, join as join10 } from "path";
1855
+ import { tmpdir as tmpdir2 } from "os";
1835
1856
  async function runScenario(model, s) {
1836
- const dir = mkdtempSync(join8(tmpdir(), "miii-eval-"));
1857
+ const dir = mkdtempSync(join10(tmpdir2(), "miii-eval-"));
1837
1858
  const prevCwd = process.cwd();
1838
1859
  for (const [rel, content] of Object.entries(s.files ?? {})) {
1839
- const abs = join8(dir, rel);
1860
+ const abs = join10(dir, rel);
1840
1861
  mkdirSync6(dirname3(abs), { recursive: true });
1841
1862
  writeFileSync7(abs, content, "utf-8");
1842
1863
  }
@@ -1876,7 +1897,7 @@ async function runScenario(model, s) {
1876
1897
  r.durationMs = Date.now() - start;
1877
1898
  if (r.error) {
1878
1899
  r.reason = `loop error: ${r.error}`;
1879
- rmSync3(dir, { recursive: true, force: true });
1900
+ rmSync4(dir, { recursive: true, force: true });
1880
1901
  return r;
1881
1902
  }
1882
1903
  try {
@@ -1886,7 +1907,7 @@ async function runScenario(model, s) {
1886
1907
  } catch (err) {
1887
1908
  r.reason = `check threw: ${err instanceof Error ? err.message : String(err)}`;
1888
1909
  }
1889
- rmSync3(dir, { recursive: true, force: true });
1910
+ rmSync4(dir, { recursive: true, force: true });
1890
1911
  return r;
1891
1912
  }
1892
1913
  var autoYes;
@@ -1899,13 +1920,13 @@ var init_runner = __esm({
1899
1920
  });
1900
1921
 
1901
1922
  // eval/scenarios.ts
1902
- import { readFileSync as readFileSync7, existsSync as existsSync6 } from "fs";
1903
- import { join as join9 } from "path";
1923
+ import { readFileSync as readFileSync9, existsSync as existsSync8 } from "fs";
1924
+ import { join as join11 } from "path";
1904
1925
  var read, scenarios;
1905
1926
  var init_scenarios = __esm({
1906
1927
  "eval/scenarios.ts"() {
1907
1928
  "use strict";
1908
- read = (dir, f) => existsSync6(join9(dir, f)) ? readFileSync7(join9(dir, f), "utf-8") : null;
1929
+ read = (dir, f) => existsSync8(join11(dir, f)) ? readFileSync9(join11(dir, f), "utf-8") : null;
1909
1930
  scenarios = [
1910
1931
  {
1911
1932
  name: "edit-exact-string",
@@ -2055,7 +2076,7 @@ init_client();
2055
2076
  init_config();
2056
2077
  import { useState as useState5, useEffect as useEffect4, useRef as useRef2 } from "react";
2057
2078
  import { Box as Box13, Text as Text13, useApp } from "ink";
2058
- import { homedir as homedir6 } from "os";
2079
+ import { homedir as homedir7 } from "os";
2059
2080
  import { sep as sep2 } from "path";
2060
2081
 
2061
2082
  // src/ui/WelcomeBlock.tsx
@@ -2342,7 +2363,8 @@ function persistSession(id, messages, title) {
2342
2363
  };
2343
2364
  const lines = [JSON.stringify({ type: "meta", ...meta })];
2344
2365
  for (const message of messages) {
2345
- lines.push(JSON.stringify({ type: "message", message }));
2366
+ const { images: _img, ...rest } = message;
2367
+ lines.push(JSON.stringify({ type: "message", message: rest }));
2346
2368
  }
2347
2369
  writeFileSync2(sessionPath(id), lines.join("\n") + "\n", "utf-8");
2348
2370
  }
@@ -3102,6 +3124,83 @@ function renderMarkdownStreaming(content) {
3102
3124
  // src/ui/ThinkingBlock.tsx
3103
3125
  import { useState as useState2, useEffect as useEffect2 } from "react";
3104
3126
  import { Box as Box8, Text as Text8 } from "ink";
3127
+
3128
+ // src/ui/layout.ts
3129
+ function formatTokens(n) {
3130
+ if (n >= 1e3) return (n / 1e3).toFixed(n >= 1e4 ? 0 : 1) + "k";
3131
+ return String(n);
3132
+ }
3133
+ function formatDuration(ms) {
3134
+ const totalSec = ms / 1e3;
3135
+ if (totalSec < 60) return `${totalSec.toFixed(1)}s`;
3136
+ const m = Math.floor(totalSec / 60);
3137
+ const s = Math.round(totalSec - m * 60);
3138
+ return `${m}m ${s}s`;
3139
+ }
3140
+ function countLines(s) {
3141
+ if (!s) return 0;
3142
+ return s.split("\n").length;
3143
+ }
3144
+ function truncate2(s, max) {
3145
+ if (s.length <= max) return s;
3146
+ return s.slice(0, max - 1) + "\u2026";
3147
+ }
3148
+ function clipTail(rendered, max) {
3149
+ const lines = rendered.split("\n");
3150
+ if (lines.length <= max) return { text: rendered, clipped: 0 };
3151
+ return { text: lines.slice(-max).join("\n"), clipped: lines.length - max };
3152
+ }
3153
+ function clipTailVisual(content, maxRows, width) {
3154
+ const w = Math.max(1, width);
3155
+ const lines = content.split("\n");
3156
+ const visualRows = (line) => Math.max(1, Math.ceil(line.length / w));
3157
+ let rows = 0;
3158
+ let start = lines.length;
3159
+ for (let i = lines.length - 1; i >= 0; i--) {
3160
+ const h = visualRows(lines[i]);
3161
+ if (rows + h > maxRows && start < lines.length) break;
3162
+ rows += h;
3163
+ start = i;
3164
+ }
3165
+ if (start === 0) return { text: content, clipped: 0 };
3166
+ return { text: lines.slice(start).join("\n"), clipped: start };
3167
+ }
3168
+ function liveFrameRows() {
3169
+ const rows = process.stdout.rows ?? 24;
3170
+ return Math.max(6, rows - 8);
3171
+ }
3172
+ var COLLAPSED_LINES = 3;
3173
+ function estimateToolRows(use, result) {
3174
+ const input = use.input ?? {};
3175
+ const noErr = !result?.is_error;
3176
+ if (use.name === "write_file" && noErr) {
3177
+ const total = countLines(String(input.content ?? ""));
3178
+ const shown = Math.min(total, COLLAPSED_LINES);
3179
+ return 2 + shown + (total > shown ? 1 : 0);
3180
+ }
3181
+ if (use.name === "edit_file" && noErr) {
3182
+ const total = countLines(String(input.old_str ?? "")) + countLines(String(input.new_str ?? ""));
3183
+ const shown = Math.min(total, COLLAPSED_LINES);
3184
+ return 2 + shown + (total > shown ? 1 : 0);
3185
+ }
3186
+ let rows = 1;
3187
+ if (result) {
3188
+ const lines = (result.content ?? "").split("\n");
3189
+ const multi = (use.name === "run_bash" || use.name === "grep" || use.name === "glob" || result.is_error) && lines.length > 1;
3190
+ if (multi) {
3191
+ const shown = Math.min(lines.length, COLLAPSED_LINES);
3192
+ rows += 1 + shown + (lines.length > shown ? 1 : 0);
3193
+ } else {
3194
+ rows += 1;
3195
+ }
3196
+ }
3197
+ return rows;
3198
+ }
3199
+ function contentWidth() {
3200
+ return Math.max(20, (process.stdout.columns ?? 80) - 4);
3201
+ }
3202
+
3203
+ // src/ui/ThinkingBlock.tsx
3105
3204
  import { jsx as jsx8, jsxs as jsxs8 } from "react/jsx-runtime";
3106
3205
  var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
3107
3206
  var globalThinkingVisible = false;
@@ -3142,10 +3241,13 @@ function ThinkingBlock({ content }) {
3142
3241
  ] })
3143
3242
  ] }),
3144
3243
  visible && content ? (() => {
3145
- const max = Math.max(4, (process.stdout.rows ?? 24) - 10);
3146
- const lines = content.split("\n");
3147
- const shown = lines.length > max ? lines.slice(-max) : lines;
3148
- return /* @__PURE__ */ jsx8(Box8, { marginLeft: 2, children: /* @__PURE__ */ jsx8(Text8, { dimColor: true, italic: true, children: shown.join("\n") }) });
3244
+ const width = Math.max(20, contentWidth() - 2);
3245
+ const budget = Math.max(4, liveFrameRows() - 1);
3246
+ const { text, clipped } = clipTailVisual(content, budget, width);
3247
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", marginLeft: 2, children: [
3248
+ clipped > 0 && /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: `\u2191 ${clipped} earlier line${clipped === 1 ? "" : "s"} above` }),
3249
+ /* @__PURE__ */ jsx8(Box8, { width, children: /* @__PURE__ */ jsx8(Text8, { dimColor: true, italic: true, wrap: "wrap", children: text }) })
3250
+ ] });
3149
3251
  })() : null
3150
3252
  ] });
3151
3253
  }
@@ -3156,6 +3258,7 @@ var EMPTY_STATE_HINTS = [
3156
3258
  "\u2022 /models \u2014 switch model or effort",
3157
3259
  "\u2022 /new \u2014 start a new chat",
3158
3260
  "\u2022 /sessions \u2014 view saved chats",
3261
+ "\u2022 ctrl+v \u2014 paste an image (needs a vision model)",
3159
3262
  "\u2022 ctrl+t \u2014 toggle thinking"
3160
3263
  ];
3161
3264
  var EMPTY_STATE_TITLE = "Ask anything, or try:";
@@ -3188,66 +3291,6 @@ function useToolExpanded() {
3188
3291
  return expanded;
3189
3292
  }
3190
3293
 
3191
- // src/ui/layout.ts
3192
- function formatTokens(n) {
3193
- if (n >= 1e3) return (n / 1e3).toFixed(n >= 1e4 ? 0 : 1) + "k";
3194
- return String(n);
3195
- }
3196
- function formatDuration(ms) {
3197
- const totalSec = ms / 1e3;
3198
- if (totalSec < 60) return `${totalSec.toFixed(1)}s`;
3199
- const m = Math.floor(totalSec / 60);
3200
- const s = Math.round(totalSec - m * 60);
3201
- return `${m}m ${s}s`;
3202
- }
3203
- function countLines(s) {
3204
- if (!s) return 0;
3205
- return s.split("\n").length;
3206
- }
3207
- function truncate2(s, max) {
3208
- if (s.length <= max) return s;
3209
- return s.slice(0, max - 1) + "\u2026";
3210
- }
3211
- function clipTail(rendered, max) {
3212
- const lines = rendered.split("\n");
3213
- if (lines.length <= max) return { text: rendered, clipped: 0 };
3214
- return { text: lines.slice(-max).join("\n"), clipped: lines.length - max };
3215
- }
3216
- function liveFrameRows() {
3217
- const rows = process.stdout.rows ?? 24;
3218
- return Math.max(6, rows - 8);
3219
- }
3220
- var COLLAPSED_LINES = 3;
3221
- function estimateToolRows(use, result) {
3222
- const input = use.input ?? {};
3223
- const noErr = !result?.is_error;
3224
- if (use.name === "write_file" && noErr) {
3225
- const total = countLines(String(input.content ?? ""));
3226
- const shown = Math.min(total, COLLAPSED_LINES);
3227
- return 2 + shown + (total > shown ? 1 : 0);
3228
- }
3229
- if (use.name === "edit_file" && noErr) {
3230
- const total = countLines(String(input.old_str ?? "")) + countLines(String(input.new_str ?? ""));
3231
- const shown = Math.min(total, COLLAPSED_LINES);
3232
- return 2 + shown + (total > shown ? 1 : 0);
3233
- }
3234
- let rows = 1;
3235
- if (result) {
3236
- const lines = (result.content ?? "").split("\n");
3237
- const multi = (use.name === "run_bash" || use.name === "grep" || use.name === "glob" || result.is_error) && lines.length > 1;
3238
- if (multi) {
3239
- const shown = Math.min(lines.length, COLLAPSED_LINES);
3240
- rows += 1 + shown + (lines.length > shown ? 1 : 0);
3241
- } else {
3242
- rows += 1;
3243
- }
3244
- }
3245
- return rows;
3246
- }
3247
- function contentWidth() {
3248
- return Math.max(20, (process.stdout.columns ?? 80) - 4);
3249
- }
3250
-
3251
3294
  // src/ui/ToolBlock.tsx
3252
3295
  import { jsx as jsx9, jsxs as jsxs9 } from "react/jsx-runtime";
3253
3296
  var COLLAPSED_LINES2 = 3;
@@ -3661,7 +3704,7 @@ function useAgentRunner(model, activeCtx) {
3661
3704
  setPendingPermission(null);
3662
3705
  req.resolve(answers[cursor]);
3663
3706
  }
3664
- async function sendMessage(text) {
3707
+ async function sendMessage(text, images) {
3665
3708
  if (busyRef.current || !model) return;
3666
3709
  busyRef.current = true;
3667
3710
  setBusy(true);
@@ -3719,6 +3762,7 @@ function useAgentRunner(model, activeCtx) {
3719
3762
  cwd: process.cwd(),
3720
3763
  history: agentHistory,
3721
3764
  userText: text,
3765
+ images,
3722
3766
  permissions: { ask: askPermission },
3723
3767
  signal: controller.signal,
3724
3768
  num_ctx: activeCtx ?? void 0
@@ -3847,17 +3891,142 @@ function useAgentRunner(model, activeCtx) {
3847
3891
  };
3848
3892
  }
3849
3893
 
3894
+ // src/ui/hooks/useKeyboard.ts
3895
+ import { existsSync as existsSync7, readFileSync as readFileSync8 } from "fs";
3896
+ import { basename, join as join9 } from "path";
3897
+ import { homedir as homedir6 } from "os";
3898
+ import { useInput, useStdout } from "ink";
3899
+
3900
+ // src/ui/clipboard.ts
3901
+ import { execFileSync as execFileSync2 } from "child_process";
3902
+ import { existsSync as existsSync6, readFileSync as readFileSync7, rmSync as rmSync3 } from "fs";
3903
+ import { join as join8 } from "path";
3904
+ import { tmpdir } from "os";
3905
+ function safeRm(p) {
3906
+ try {
3907
+ rmSync3(p, { force: true });
3908
+ } catch {
3909
+ }
3910
+ }
3911
+ function consume(p) {
3912
+ try {
3913
+ const b64 = readFileSync7(p).toString("base64");
3914
+ return b64.length > 0 ? b64 : null;
3915
+ } catch {
3916
+ return null;
3917
+ } finally {
3918
+ safeRm(p);
3919
+ }
3920
+ }
3921
+ function readMac(out) {
3922
+ try {
3923
+ execFileSync2("pngpaste", [out], { stdio: "ignore" });
3924
+ if (existsSync6(out)) return consume(out);
3925
+ } catch {
3926
+ }
3927
+ const png = "\xABclass PNGf\xBB";
3928
+ const script = [
3929
+ "try",
3930
+ `set f to open for access (POSIX file "${out}") with write permission`,
3931
+ `set theData to (the clipboard as ${png})`,
3932
+ "write theData to f",
3933
+ "close access f",
3934
+ "on error",
3935
+ "try",
3936
+ "close access f",
3937
+ "end try",
3938
+ 'return "NOIMG"',
3939
+ "end try"
3940
+ ];
3941
+ try {
3942
+ const res = execFileSync2("osascript", script.flatMap((s) => ["-e", s]), { encoding: "utf8" });
3943
+ if (res.includes("NOIMG")) {
3944
+ safeRm(out);
3945
+ return null;
3946
+ }
3947
+ if (existsSync6(out)) return consume(out);
3948
+ } catch {
3949
+ }
3950
+ safeRm(out);
3951
+ return null;
3952
+ }
3953
+ function readLinux(out) {
3954
+ for (const [cmd2, args2] of [
3955
+ ["wl-paste", ["--type", "image/png"]],
3956
+ ["xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]]
3957
+ ]) {
3958
+ try {
3959
+ const buf = execFileSync2(cmd2, args2, { maxBuffer: 64 * 1024 * 1024 });
3960
+ if (buf.length > 0) return buf.toString("base64");
3961
+ } catch {
3962
+ }
3963
+ }
3964
+ return null;
3965
+ }
3966
+ function readWindows(out) {
3967
+ const ps = [
3968
+ "Add-Type -AssemblyName System.Windows.Forms,System.Drawing;",
3969
+ "$img = [System.Windows.Forms.Clipboard]::GetImage();",
3970
+ // Single-quoted PS string → backslashes are literal, no escaping needed.
3971
+ `if ($img -ne $null) { $img.Save('${out}', [System.Drawing.Imaging.ImageFormat]::Png); 'OK' } else { 'NOIMG' }`
3972
+ ].join(" ");
3973
+ try {
3974
+ const res = execFileSync2(
3975
+ "powershell",
3976
+ ["-NoProfile", "-NonInteractive", "-STA", "-Command", ps],
3977
+ { encoding: "utf8" }
3978
+ );
3979
+ if (res.includes("NOIMG")) {
3980
+ safeRm(out);
3981
+ return null;
3982
+ }
3983
+ if (existsSync6(out)) return consume(out);
3984
+ } catch {
3985
+ }
3986
+ safeRm(out);
3987
+ return null;
3988
+ }
3989
+ function readClipboardImage() {
3990
+ const out = join8(tmpdir(), `miii-clip-${Date.now()}-${Math.random().toString(36).slice(2)}.png`);
3991
+ if (process.platform === "darwin") return readMac(out);
3992
+ if (process.platform === "linux") return readLinux(out);
3993
+ if (process.platform === "win32") return readWindows(out);
3994
+ return null;
3995
+ }
3996
+
3850
3997
  // src/ui/hooks/useKeyboard.ts
3851
3998
  init_config();
3852
- import { useInput } from "ink";
3853
3999
  var EFFORTS = ["low", "medium", "high"];
3854
4000
  var PASTE_CHIP_LINES = 4;
3855
4001
  var PASTE_CHIP_CHARS = 200;
3856
4002
  var pasteStore = /* @__PURE__ */ new Map();
3857
4003
  var pasteCounter = 0;
4004
+ var imageStore = /* @__PURE__ */ new Map();
4005
+ var imageCounter = 0;
4006
+ var IMAGE_EXT_RE = /\.(png|jpe?g|webp|gif|bmp)$/i;
3858
4007
  function clearPasteStore() {
3859
4008
  pasteStore.clear();
3860
4009
  pasteCounter = 0;
4010
+ imageStore.clear();
4011
+ imageCounter = 0;
4012
+ }
4013
+ function tryImagePaste(cleaned) {
4014
+ let p = cleaned.trim();
4015
+ if (p.startsWith('"') && p.endsWith('"') || p.startsWith("'") && p.endsWith("'")) {
4016
+ p = p.slice(1, -1);
4017
+ }
4018
+ p = p.replace(/\\ /g, " ");
4019
+ if (p.includes("\n") || !IMAGE_EXT_RE.test(p)) return null;
4020
+ if (p.startsWith("~/")) p = join9(homedir6(), p.slice(2));
4021
+ if (!existsSync7(p)) return null;
4022
+ try {
4023
+ const b64 = readFileSync8(p).toString("base64");
4024
+ const chip = `[Image #${++imageCounter} \xB7 ${basename(p)}]`;
4025
+ imageStore.set(chip, b64);
4026
+ return chip;
4027
+ } catch {
4028
+ return null;
4029
+ }
3861
4030
  }
3862
4031
  var inputHistory = [];
3863
4032
  var historyIndex = -1;
@@ -3878,6 +4047,8 @@ function stripControls(chunk) {
3878
4047
  function sanitizePaste(chunk) {
3879
4048
  if (chunk.length <= 1) return chunk;
3880
4049
  const cleaned = stripControls(chunk).replace(/\r/g, "");
4050
+ const imageChip = tryImagePaste(cleaned);
4051
+ if (imageChip) return imageChip;
3881
4052
  const lines = cleaned.split("\n").length;
3882
4053
  if (lines > PASTE_CHIP_LINES || cleaned.length > PASTE_CHIP_CHARS) {
3883
4054
  const chip = `[Pasted #${++pasteCounter} \xB7 ${lines} line${lines === 1 ? "" : "s"}]`;
@@ -3935,6 +4106,10 @@ function useKeyboard(opts) {
3935
4106
  setActiveToolResults,
3936
4107
  setError
3937
4108
  } = agent;
4109
+ const { write } = useStdout();
4110
+ function hardClear() {
4111
+ write("\x1B[2J\x1B[3J\x1B[H");
4112
+ }
3938
4113
  function clearSession() {
3939
4114
  setMessages(() => []);
3940
4115
  setAgentHistory([]);
@@ -4108,6 +4283,19 @@ function useKeyboard(opts) {
4108
4283
  }
4109
4284
  if (state === "ready") {
4110
4285
  if (busyRef.current) return;
4286
+ if (key.ctrl && char === "v") {
4287
+ const b64 = readClipboardImage();
4288
+ if (!b64) {
4289
+ setNotice("no image in clipboard");
4290
+ return;
4291
+ }
4292
+ const chip = `[Image #${++imageCounter} \xB7 clipboard]`;
4293
+ imageStore.set(chip, b64);
4294
+ historyIndex = -1;
4295
+ setInput((s) => s.slice(0, caret) + chip + s.slice(caret));
4296
+ setCaret((i) => i + chip.length);
4297
+ return;
4298
+ }
4111
4299
  const paletteOpen = input.startsWith("/");
4112
4300
  const matches2 = paletteOpen ? filteredCommands(input) : [];
4113
4301
  const mention = !paletteOpen ? parseMention(input) : null;
@@ -4196,10 +4384,12 @@ function useKeyboard(opts) {
4196
4384
  setCursor(() => Math.max(0, providers.findIndex((p) => p.name === cfg.provider)));
4197
4385
  setState("providers");
4198
4386
  } else if (trimmed === "/clear") {
4387
+ hardClear();
4199
4388
  clearSession();
4200
4389
  } else if (trimmed === "/new") {
4201
4390
  if (agentHistory.length) setNotice("session saved");
4202
4391
  setSessionId(newSessionId());
4392
+ hardClear();
4203
4393
  clearSession();
4204
4394
  } else if (trimmed === "/sessions") {
4205
4395
  setSessions(listSessions());
@@ -4218,8 +4408,16 @@ function useKeyboard(opts) {
4218
4408
  }
4219
4409
  } else if (trimmed) {
4220
4410
  setNotice(null);
4221
- const message = expandPastes(trimmed);
4222
- sendMessage(message);
4411
+ const images = [];
4412
+ let textPart = trimmed;
4413
+ for (const [chip, b64] of imageStore) {
4414
+ if (textPart.includes(chip)) {
4415
+ images.push(b64);
4416
+ textPart = textPart.split(chip).join("").trim();
4417
+ }
4418
+ }
4419
+ const message = expandPastes(textPart) || "Describe the attached image.";
4420
+ sendMessage(message, images.length ? images : void 0);
4223
4421
  }
4224
4422
  clearPasteStore();
4225
4423
  setInput(() => "");
@@ -4253,8 +4451,14 @@ function useKeyboard(opts) {
4253
4451
  for (const chip of pasteStore.keys()) {
4254
4452
  if (before.endsWith(chip) && chip.length > match.length) match = chip;
4255
4453
  }
4454
+ for (const chip of imageStore.keys()) {
4455
+ if (before.endsWith(chip) && chip.length > match.length) match = chip;
4456
+ }
4256
4457
  const cut = match ? match.length : 1;
4257
- if (match) pasteStore.delete(match);
4458
+ if (match) {
4459
+ pasteStore.delete(match);
4460
+ imageStore.delete(match);
4461
+ }
4258
4462
  setInput((s) => s.slice(0, caret - cut) + s.slice(caret));
4259
4463
  setCaret((i) => Math.max(0, i - cut));
4260
4464
  } else if (char && !key.ctrl && !key.meta && !key.tab) {
@@ -4310,7 +4514,7 @@ async function checkForUpdate() {
4310
4514
  import { Fragment as Fragment3, jsx as jsx13, jsxs as jsxs13 } from "react/jsx-runtime";
4311
4515
  function App() {
4312
4516
  const { exit } = useApp();
4313
- const cwd = process.cwd().replace(homedir6(), "~").split(sep2).join("/");
4517
+ const cwd = process.cwd().replace(homedir7(), "~").split(sep2).join("/");
4314
4518
  const [cfg, setCfg] = useState5(loadConfig());
4315
4519
  const [models, setModels] = useState5([]);
4316
4520
  const [contexts, setContexts] = useState5(() => cfg.modelContexts ?? {});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "miii-agent",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
4
4
  "description": "Cursor / Claude Code, but local. An offline AI pair-programmer in your terminal, powered by Ollama. Private by default, free forever.",
5
5
  "type": "module",
6
6
  "bin": {