miii-agent 0.1.30 โ†’ 0.1.32

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 +58 -11
  2. package/dist/cli.js +240 -56
  3. package/package.json +10 -3
package/README.md CHANGED
@@ -1,9 +1,9 @@
1
- <h1 align="center">miii</h1>
1
+ <h1 align="center">miii โ€” Local AI Coding Agent for Your Terminal</h1>
2
2
 
3
3
  <p align="center">
4
- <strong>Cursor / Claude Code, but local.</strong><br>
5
- An offline AI pair-programmer in your terminal, powered by Ollama.<br>
6
- Private by default. Free forever.
4
+ <strong>An open-source, offline alternative to Claude Code, Cursor, and GitHub Copilot.</strong><br>
5
+ A private AI pair-programmer in your terminal, powered by Ollama and any local LLM.<br>
6
+ Private by default. Free forever. Works offline.
7
7
  </p>
8
8
 
9
9
  <p align="center">
@@ -15,26 +15,41 @@
15
15
  </p>
16
16
 
17
17
  <p align="center">
18
- <img src="demo3.gif" alt="miii demo">
18
+ <img src="demo3.gif" alt="miii local AI coding agent terminal demo powered by Ollama">
19
+ </p>
20
+
21
+ <p align="center">
22
+ ๐Ÿ”’ <strong>100% local</strong> โ€” your code never leaves your machine &nbsp;ยท&nbsp;
23
+ ๐Ÿ’ธ <strong>Free</strong> โ€” no API keys, no per-token billing &nbsp;ยท&nbsp;
24
+ โšก <strong>Offline</strong> โ€” runs on your own GPU
19
25
  </p>
20
26
 
21
27
  ---
22
28
 
23
- ## What is this?
29
+ ## What is miii? โ€” a local AI coding agent
24
30
 
25
31
  miii lives in your terminal and codes alongside you โ€” reading files, writing features, running tests, fixing bugs. The twist: it runs on **your** hardware, powered by [Ollama](https://ollama.com) (or any local OpenAI-compatible server like [llama.cpp](https://github.com/ggml-org/llama.cpp) / [LM Studio](https://lmstudio.ai)).
26
32
 
27
- Your code never leaves your disk. There's nothing to log in to. Pull a model, type `miii`, go.
33
+ Your code never leaves your disk. There's nothing to log in to. Pull a model, type `miii`, go. It's the open-source, offline answer to cloud coding assistants like Claude Code, Cursor, and GitHub Copilot.
28
34
 
29
- ## Try it in 30 seconds
35
+ ## Install (macOS, Linux, Windows)
30
36
 
31
37
  **macOS / Linux:**
32
38
 
33
39
  ```bash
34
40
  ollama pull qwen2.5-coder:14b # any coding model works
41
+ ```
42
+
43
+ **Which model should I use?**
44
+ - **Low VRAM (8GB):** `qwen2.5-coder:7b` (Fast, capable)
45
+ - **Mid VRAM (16-24GB):** `qwen2.5-coder:14b` (Sweet spot)
46
+ - **High VRAM (48GB+):** `qwen2.5-coder:32b` (Powerhouse)
47
+
48
+ ```bash
35
49
  curl -fsSL https://raw.githubusercontent.com/maruakshay/miii-cli/main/install.sh | sh
36
50
  miii
37
51
  ```
52
+ *(The installer downloads the pre-compiled binary and adds it to your local path)*
38
53
 
39
54
  **Windows (PowerShell):**
40
55
 
@@ -77,7 +92,7 @@ miii --version # what you're running
77
92
  Opt out of background updates by adding `"autoUpdate": false` to `~/.miii/config.json`,
78
93
  or re-run the install script (`curl โ€ฆ | sh`) any time to update by hand.
79
94
 
80
- ## Why local-first?
95
+ ## Why local-first? Private, free, offline
81
96
 
82
97
  Most "AI coding tools" are just wrappers around a cloud API โ€” slow, metered, and they ship your private codebase to someone else's server.
83
98
 
@@ -89,13 +104,16 @@ Most "AI coding tools" are just wrappers around a cloud API โ€” slow, metered, a
89
104
  | Offline | No | Yes |
90
105
  | Latency | Network + queue | Your GPU only |
91
106
 
92
- It doesn't just chat, either โ€” it decomposes the problem, calls tools, and checks its own work before claiming victory.
107
+ It doesn't just chat, either โ€” it follows a **Plan $\rightarrow$ Act $\rightarrow$ Observe** loop:
108
+ 1. **Plan**: Decomposes the problem into a sequence of concrete steps.
109
+ 2. **Act**: Calls the necessary tools to gather context or modify code.
110
+ 3. **Observe**: Verifies the result and adjusts the plan until the goal is met.
93
111
 
94
112
  ## Five letters, five ideas
95
113
 
96
114
  **s**mall ยท **s**imple ยท **s**mart ยท **s**trategic ยท **s**emantic โ€” a tiny codebase you can read in an afternoon, no config ceremony, plans before it acts, and operates on the *meaning* of your code, not blind text matching.
97
115
 
98
- ## A few things that make it fun
116
+ ## Features
99
117
 
100
118
  - **๐Ÿงช `miii doctor`** โ€” not every local model can drive an agent. Doctor runs your models through real engineering tasks and tells you which ones actually deliver.
101
119
  ```bash
@@ -189,6 +207,15 @@ The model pages through the middle with ranged `read_file` reads. Spill files ar
189
207
  <details>
190
208
  <summary><strong>Development</strong></summary>
191
209
 
210
+ **Project Architecture:**
211
+ ```text
212
+ src/
213
+ โ”œโ”€โ”€ agent/ # The core reasoning loop
214
+ โ”œโ”€โ”€ tools/ # Implementation of read/write/bash
215
+ โ”œโ”€โ”€ terminal/ # UI and input handling
216
+ โ””โ”€โ”€ config/ # Settings and provider logic
217
+ ```
218
+
192
219
  ```bash
193
220
  git clone https://github.com/maruakshay/miii-cli.git
194
221
  cd miii-cli
@@ -211,6 +238,26 @@ npm run build && npm link # restore later with: npm install -g miii-agent
211
238
 
212
239
  ---
213
240
 
241
+ ## FAQ
242
+
243
+ **Does miii work without internet?**
244
+ Yes. Once you've pulled a model with Ollama, miii runs fully offline. No network calls, no account, no cloud.
245
+
246
+ **Is my code sent anywhere?**
247
+ No. Every file read, edit, and model inference happens on your machine. Your codebase never leaves your disk.
248
+
249
+ **Which model is best for coding?**
250
+ Depends on VRAM: `qwen2.5-coder:7b` (8GB), `qwen2.5-coder:14b` (16โ€“24GB, the sweet spot), `qwen2.5-coder:32b` (48GB+). Run `miii doctor` to grade your installed models on real engineering tasks.
251
+
252
+ **How is miii different from Claude Code, Cursor, or GitHub Copilot?**
253
+ Claude Code, Cursor, and Copilot are cloud services โ€” metered, account-gated, and they ship your code to a third-party server. miii is open-source, free, and runs entirely on your hardware. Same terminal-agent workflow as Claude Code, but on your own local model.
254
+
255
+ **How is it different from Continue.dev?**
256
+ Continue.dev is an IDE extension. miii is a standalone terminal agent โ€” no editor required โ€” with a Plan โ†’ Act โ†’ Observe loop, permission-gated tools, and lossless output spill built in.
257
+
258
+ **Do I need a GPU?**
259
+ No, but it helps. Smaller models run on CPU; a GPU makes larger models fast enough for real work.
260
+
214
261
  ## Status
215
262
 
216
263
  **MVP.** Core agent loop is stable; actively refining tool execution, streaming, and the permission model. PRs welcome โ€” fork it, break it, improve it.
package/dist/cli.js CHANGED
@@ -790,6 +790,43 @@ function nearMiss(src, old_str) {
790
790
  Closest text in file (lines ${from + 1}-${to}):
791
791
  ${ctx}`;
792
792
  }
793
+ function locate(src, old_str) {
794
+ const first = src.indexOf(old_str);
795
+ if (first !== -1) {
796
+ if (src.indexOf(old_str, first + 1) !== -1) {
797
+ return { error: `old_str not unique \u2014 add surrounding context to disambiguate.` };
798
+ }
799
+ return [first, first + old_str.length];
800
+ }
801
+ const fuzzy = fuzzyRange(src, old_str);
802
+ if (fuzzy) return fuzzy;
803
+ return { error: `old_str not found.${nearMiss(src, old_str)}` };
804
+ }
805
+ function applyBatch(src, edits) {
806
+ const ranges = [];
807
+ for (let i = 0; i < edits.length; i++) {
808
+ const { old_str, new_str } = edits[i];
809
+ if (typeof old_str !== "string" || typeof new_str !== "string") {
810
+ return { error: `edits[${i}] must have string old_str and new_str.` };
811
+ }
812
+ if (old_str === "") return { error: `edits[${i}].old_str is empty.` };
813
+ if (old_str === new_str) return { error: `edits[${i}] old_str and new_str are identical \u2014 nothing to change.` };
814
+ const r = locate(src, old_str);
815
+ if (!Array.isArray(r)) return { error: `edits[${i}]: ${r.error}` };
816
+ ranges.push({ start: r[0], end: r[1], new_str });
817
+ }
818
+ const sorted = [...ranges].sort((a, b) => a.start - b.start);
819
+ for (let i = 1; i < sorted.length; i++) {
820
+ if (sorted[i].start < sorted[i - 1].end) {
821
+ return { error: `edits overlap in the file \u2014 split them into separate calls or widen the context.` };
822
+ }
823
+ }
824
+ let out = src;
825
+ for (const r of [...ranges].sort((a, b) => b.start - a.start)) {
826
+ out = out.slice(0, r.start) + r.new_str + out.slice(r.end);
827
+ }
828
+ return { out, count: ranges.length };
829
+ }
793
830
  var edit_file;
794
831
  var init_edit_file = __esm({
795
832
  "src/tools/edit_file.ts"() {
@@ -798,19 +835,42 @@ var init_edit_file = __esm({
798
835
  init_verifyHint();
799
836
  edit_file = {
800
837
  name: "edit_file",
801
- description: "Replace an exact string in a file. old_str must be unique unless replace_all is set. On no match, returns the closest text in the file.",
838
+ description: "Replace an exact string in a file. old_str must be unique unless replace_all is set. On no match, returns the closest text in the file. To make several edits to one file at once, pass an `edits` array of {old_str,new_str} \u2014 they apply atomically (all or nothing).",
802
839
  input_schema: {
803
840
  type: "object",
804
841
  properties: {
805
842
  path: { type: "string", description: "File path" },
806
- old_str: { type: "string", description: "Exact text to replace (whitespace-sensitive)" },
807
- new_str: { type: "string", description: "Replacement text" },
808
- replace_all: { type: "boolean", description: "Replace every occurrence instead of requiring uniqueness" }
843
+ old_str: { type: "string", description: "Exact text to replace (whitespace-sensitive). Omit when using edits[]." },
844
+ new_str: { type: "string", description: "Replacement text. Omit when using edits[]." },
845
+ replace_all: { type: "boolean", description: "Replace every occurrence instead of requiring uniqueness" },
846
+ edits: {
847
+ type: "array",
848
+ description: "Batch mode: several edits applied atomically. Each old_str must be unique in the file. Alternative to old_str/new_str.",
849
+ items: {
850
+ type: "object",
851
+ properties: {
852
+ old_str: { type: "string", description: "Exact text to replace (whitespace-sensitive)" },
853
+ new_str: { type: "string", description: "Replacement text" }
854
+ },
855
+ required: ["old_str", "new_str"]
856
+ }
857
+ }
809
858
  },
810
- required: ["path", "old_str", "new_str"]
859
+ required: ["path"]
811
860
  },
812
- handler: ({ path, old_str, new_str, replace_all }) => {
861
+ handler: ({ path, old_str, new_str, replace_all, edits }) => {
813
862
  try {
863
+ if (Array.isArray(edits) && edits.length > 0) {
864
+ const abs2 = confinePath(path);
865
+ const src2 = readFileSync4(abs2, "utf-8");
866
+ const res = applyBatch(src2, edits);
867
+ if ("error" in res) return { content: `${res.error} (in ${path})`, is_error: true };
868
+ writeFileSync4(abs2, res.out, "utf-8");
869
+ return { content: `Edited ${path} (${res.count} edits).${verifyHint(path)}` };
870
+ }
871
+ if (typeof old_str !== "string" || typeof new_str !== "string") {
872
+ return { content: `edit_file needs old_str and new_str (or an edits[] array) for ${path}.`, is_error: true };
873
+ }
814
874
  if (old_str === new_str) {
815
875
  return {
816
876
  content: `old_str and new_str are identical \u2014 nothing to change in ${path}. If the file is already correct, do NOT edit again: finish with the respond action and tell the user it is done.`,
@@ -857,11 +917,22 @@ function numbered(lines, start) {
857
917
  const width = String(start + lines.length - 1).length;
858
918
  return lines.map((l, i) => `${String(start + i).padStart(width, " ")} ${l}`).join("\n");
859
919
  }
860
- var read_file;
920
+ function looksImage(buf) {
921
+ if (buf.length < 4) return false;
922
+ if (buf[0] === 137 && buf[1] === 80) return true;
923
+ if (buf[0] === 255 && buf[1] === 216) return true;
924
+ if (buf[0] === 71 && buf[1] === 73) return true;
925
+ if (buf[0] === 66 && buf[1] === 77) return true;
926
+ if (buf.length >= 12 && buf.toString("ascii", 0, 4) === "RIFF" && buf.toString("ascii", 8, 12) === "WEBP") return true;
927
+ return false;
928
+ }
929
+ var IMAGE_EXT, MAX_IMAGE_BYTES, read_file;
861
930
  var init_read_file = __esm({
862
931
  "src/tools/read_file.ts"() {
863
932
  "use strict";
864
933
  init_paths();
934
+ IMAGE_EXT = /* @__PURE__ */ new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp"]);
935
+ MAX_IMAGE_BYTES = 8 * 1024 * 1024;
865
936
  read_file = {
866
937
  name: "read_file",
867
938
  description: "Read file contents as UTF-8 text with line numbers. Use offset/limit to read a range of a large file instead of the whole thing.",
@@ -878,6 +949,19 @@ var init_read_file = __esm({
878
949
  try {
879
950
  const MAX_CHARS = 2e5;
880
951
  const buf = readFileSync5(confinePath(path));
952
+ const ext = path.slice(path.lastIndexOf(".") + 1).toLowerCase();
953
+ if (IMAGE_EXT.has(ext) || looksImage(buf)) {
954
+ if (buf.length > MAX_IMAGE_BYTES) {
955
+ return {
956
+ content: `${path} is an image but too large to attach (${buf.length} bytes > ${MAX_IMAGE_BYTES}). Resize it first.`,
957
+ is_error: true
958
+ };
959
+ }
960
+ return {
961
+ content: `[image ${path} \u2014 ${buf.length} bytes, attached for viewing]`,
962
+ images: [buf.toString("base64")]
963
+ };
964
+ }
881
965
  if (buf.subarray(0, 8e3).includes(0)) {
882
966
  return { content: `${path} looks binary (${buf.length} bytes); not reading as text.`, is_error: true };
883
967
  }
@@ -992,6 +1076,17 @@ var init_spill = __esm({
992
1076
 
993
1077
  // src/tools/run_bash.ts
994
1078
  import { execa } from "execa";
1079
+ function killTree(pid, isWin) {
1080
+ if (!pid) return;
1081
+ try {
1082
+ if (isWin) {
1083
+ execa("taskkill", ["/pid", String(pid), "/T", "/F"], { reject: false });
1084
+ } else {
1085
+ process.kill(-pid, "SIGKILL");
1086
+ }
1087
+ } catch {
1088
+ }
1089
+ }
995
1090
  var run_bash;
996
1091
  var init_run_bash = __esm({
997
1092
  "src/tools/run_bash.ts"() {
@@ -1008,27 +1103,44 @@ var init_run_bash = __esm({
1008
1103
  },
1009
1104
  required: ["command"]
1010
1105
  },
1011
- handler: async ({ command, timeout_ms }) => {
1106
+ handler: async ({ command, timeout_ms }, ctx) => {
1107
+ const isWin = process.platform === "win32";
1108
+ const shell = isWin ? "cmd" : "bash";
1109
+ const shellArgs = isWin ? ["/c", command] : ["-c", command];
1110
+ const timeout = timeout_ms ?? 12e4;
1111
+ const child = execa(shell, shellArgs, {
1112
+ reject: false,
1113
+ all: true,
1114
+ detached: !isWin
1115
+ // POSIX: new process group so killTree(-pid) hits the whole tree
1116
+ });
1117
+ let timedOut = false;
1118
+ let aborted = false;
1119
+ const timer = setTimeout(() => {
1120
+ timedOut = true;
1121
+ killTree(child.pid, isWin);
1122
+ }, timeout);
1123
+ const onAbort = () => {
1124
+ aborted = true;
1125
+ killTree(child.pid, isWin);
1126
+ };
1127
+ ctx?.signal?.addEventListener("abort", onAbort, { once: true });
1012
1128
  try {
1013
- const isWin = process.platform === "win32";
1014
- const shell = isWin ? "cmd" : "bash";
1015
- const shellArgs = isWin ? ["/c", command] : ["-c", command];
1016
- const { stdout, stderr, exitCode } = await execa(shell, shellArgs, {
1017
- timeout: timeout_ms ?? 12e4,
1018
- reject: false,
1019
- all: false
1020
- });
1021
- const out = [stdout, stderr].filter(Boolean).join("\n");
1022
- const is_error = exitCode !== 0;
1129
+ const { all, exitCode } = await child;
1130
+ const out = all ?? "";
1131
+ const is_error = aborted || timedOut || exitCode !== 0;
1132
+ const note = timedOut ? `
1133
+ [timed out after ${timeout}ms \u2014 process tree killed]` : aborted ? `
1134
+ [aborted \u2014 process tree killed]` : "";
1023
1135
  const body = out || (is_error ? `(no output)` : "");
1024
1136
  const content = `${spillIfLarge(body, "command output")}
1025
- [exit ${exitCode}]`;
1026
- return {
1027
- content,
1028
- is_error
1029
- };
1137
+ [exit ${exitCode ?? "killed"}]${note}`;
1138
+ return { content, is_error };
1030
1139
  } catch (err) {
1031
1140
  return { content: err instanceof Error ? err.message : String(err), is_error: true };
1141
+ } finally {
1142
+ clearTimeout(timer);
1143
+ ctx?.signal?.removeEventListener("abort", onAbort);
1032
1144
  }
1033
1145
  }
1034
1146
  };
@@ -1445,6 +1557,7 @@ Ask in a numbered list. One round of questions per turn. Then wait.
1445
1557
  - After a tool result, move directly to the next tool call or the final answer. Do not restate what the previous tool did.
1446
1558
  - Every tool call MUST carry a complete, valid arguments object: all required fields present, correct types, valid JSON. Never emit a call with empty, partial, or placeholder arguments.
1447
1559
  - WRONG (leaks as text, nothing runs): writing \`call:some_tool{"foo":"bar"}\` or a fenced JSON block in your reply. RIGHT: emit it as a native function call with a full arguments object.
1560
+ - Batch independent tool calls in a SINGLE turn \u2014 parallel, not serial. If two reads, greps, or searches do not depend on each other's output, emit them together. Only serialize when a later call needs an earlier result.
1448
1561
 
1449
1562
  # Tools
1450
1563
  You have access to the following tools. Call them via the function-calling interface.
@@ -1454,14 +1567,33 @@ ${toolLines}
1454
1567
  - When you need to act on the filesystem or run a command, emit a tool call.
1455
1568
  - After each tool result, decide: more tool calls, or a final plain-text answer.
1456
1569
  - Stop emitting tool calls when GOAL is met. Reply with a concise plain-text final message confirming CRITERION is satisfied.
1570
+ - After the work is done, always close by asking the user what they want to do next \u2014 a brief, specific prompt (offer the most likely follow-ups when obvious). One line, no filler.
1457
1571
 
1458
1572
  # Rules
1459
1573
  - Always read a file before updating it. Never edit, overwrite, or create-over a file you have not read first this turn.
1460
1574
  - Prefer editing existing files over creating new ones.
1575
+ - To change an existing file, use edit_file with a small, targeted old_str/new_str diff \u2014 never rewrite the whole file with write_file. Reserve write_file for brand-new files or small ones. A full-file write_file on a large file risks getting cut off at the output token limit mid-write; a targeted edit_file stays small and avoids that.
1576
+ - When a new file's content is large, create it with write_file for the first portion, then append the rest with successive edit_file calls. Keep every write small.
1461
1577
  - For edit_file, make old_str unique by including surrounding context, or set replace_all to change every occurrence.
1462
1578
  - Never invent file paths. Read, glob, or grep before editing.
1463
1579
  - No empty filler or robotic boilerplate. A brief, genuine warm touch (see Tone and voice) is welcome; hollow pleasantries and reflexive apologies are not.
1464
1580
 
1581
+ # Scope discipline
1582
+ - Do ONLY what the user asked. No unrequested refactors, renames, reformatting, or "while I'm here" edits.
1583
+ - If you spot an unrelated issue worth fixing, mention it in your final message \u2014 do not fix it unprompted.
1584
+ - Touch the fewest files needed. A one-line request gets a one-line change, not a redesign.
1585
+
1586
+ # Secrets and safety
1587
+ - Never print, log, or echo secrets, API keys, tokens, passwords, or \`.env\` values. Redact them if you must reference one.
1588
+ - Never write credentials into source, commits, or output. If a secret is needed, read it from the environment or config.
1589
+ - Do not exfiltrate file contents to external services without the user asking.
1590
+
1591
+ # Git and commits
1592
+ - Do NOT commit, push, or create branches/PRs unless the user explicitly asks.
1593
+ - When asked to commit: never commit on the main branch \u2014 branch first. Stage only files relevant to the change; never blanket \`git add -A\` without checking what it sweeps in.
1594
+ - Write a concise commit message stating what changed and why. Do not add credentials or generated noise.
1595
+ - Never force-push, rebase shared history, or run destructive git commands without explicit confirmation.
1596
+
1465
1597
  # Context discipline
1466
1598
  - read_file returns line numbers and accepts offset/limit. For large files, grep or glob to the relevant region first, then read only that range with offset/limit. Do not read a whole large file when you need a few functions \u2014 it wastes the context window.
1467
1599
  - Reference code by the line numbers read_file returns.
@@ -1515,6 +1647,9 @@ function toOllamaMessages(history, system) {
1515
1647
  const texts = msg.content.filter((b) => b.type === "text");
1516
1648
  for (const tr of tool_results) {
1517
1649
  out.push({ role: "tool", content: tr.content, tool_call_id: tr.tool_use_id });
1650
+ if (tr.images && tr.images.length > 0) {
1651
+ out.push({ role: "user", content: "Image content from the previous tool result:", images: tr.images });
1652
+ }
1518
1653
  }
1519
1654
  if (texts.length > 0) {
1520
1655
  out.push({ role: "user", content: texts.map((t) => t.text).join("") });
@@ -1957,12 +2092,13 @@ async function* runAgent(opts) {
1957
2092
  }
1958
2093
  let r;
1959
2094
  try {
1960
- const out = await tool.handler(use.input);
2095
+ const out = await tool.handler(use.input, { signal });
1961
2096
  r = {
1962
2097
  type: "tool_result",
1963
2098
  tool_use_id: use.id,
1964
2099
  content: out.content,
1965
- is_error: out.is_error
2100
+ is_error: out.is_error,
2101
+ ...out.images && out.images.length > 0 ? { images: out.images } : {}
1966
2102
  };
1967
2103
  } catch (err) {
1968
2104
  r = {
@@ -2298,7 +2434,7 @@ var InputBar = memo(function InputBar2({ input, caret, disabled, processingLabel
2298
2434
  const [frame, setFrame] = useState(0);
2299
2435
  useEffect(() => {
2300
2436
  if (!disabled) return;
2301
- const t = setInterval(() => setFrame((f) => (f + 1) % SPIN.length), 150);
2437
+ const t = setInterval(() => setFrame((f) => (f + 1) % SPIN.length), 200);
2302
2438
  return () => clearInterval(t);
2303
2439
  }, [disabled]);
2304
2440
  return /* @__PURE__ */ jsx2(
@@ -3347,14 +3483,26 @@ function clipTail(rendered, max) {
3347
3483
  if (lines.length <= max) return { text: rendered, clipped: 0 };
3348
3484
  return { text: lines.slice(-max).join("\n"), clipped: lines.length - max };
3349
3485
  }
3486
+ var ANSI_RE = /\x1b\[[0-9;]*m/g;
3487
+ function stripAnsi(s) {
3488
+ return s.replace(ANSI_RE, "");
3489
+ }
3490
+ function visualHeight(text, width) {
3491
+ const w = Math.max(1, width);
3492
+ let rows = 0;
3493
+ for (const line of text.split("\n")) {
3494
+ rows += Math.max(1, Math.ceil(stripAnsi(line).length / w));
3495
+ }
3496
+ return rows;
3497
+ }
3350
3498
  function clipTailVisual(content, maxRows, width) {
3351
3499
  const w = Math.max(1, width);
3352
3500
  const lines = content.split("\n");
3353
- const visualRows = (line) => Math.max(1, Math.ceil(line.length / w));
3501
+ const visualRows2 = (line) => Math.max(1, Math.ceil(stripAnsi(line).length / w));
3354
3502
  let rows = 0;
3355
3503
  let start = lines.length;
3356
3504
  for (let i = lines.length - 1; i >= 0; i--) {
3357
- const h = visualRows(lines[i]);
3505
+ const h = visualRows2(lines[i]);
3358
3506
  if (rows + h > maxRows && start < lines.length) break;
3359
3507
  rows += h;
3360
3508
  start = i;
@@ -3367,9 +3515,20 @@ function liveFrameRows() {
3367
3515
  return Math.max(6, rows - 8);
3368
3516
  }
3369
3517
  var COLLAPSED_LINES = 3;
3518
+ function visualRows(text, width, cap) {
3519
+ const w = Math.max(1, width);
3520
+ let rows = 0;
3521
+ const lines = text.split("\n");
3522
+ for (const line of lines) {
3523
+ rows += Math.max(1, Math.ceil(line.length / w));
3524
+ if (rows >= cap) return cap;
3525
+ }
3526
+ return rows;
3527
+ }
3370
3528
  function estimateToolRows(use, result) {
3371
3529
  const input = use.input ?? {};
3372
3530
  const noErr = !result?.is_error;
3531
+ const w = contentWidth();
3373
3532
  if (use.name === "write_file" && noErr) {
3374
3533
  const total = countLines(String(input.content ?? ""));
3375
3534
  const shown = Math.min(total, COLLAPSED_LINES);
@@ -3385,10 +3544,10 @@ function estimateToolRows(use, result) {
3385
3544
  const lines = (result.content ?? "").split("\n");
3386
3545
  const multi = (use.name === "run_bash" || use.name === "grep" || use.name === "glob" || result.is_error) && lines.length > 1;
3387
3546
  if (multi) {
3388
- const shown = Math.min(lines.length, COLLAPSED_LINES);
3389
- rows += 1 + shown + (lines.length > shown ? 1 : 0);
3547
+ const shownLines = lines.slice(0, COLLAPSED_LINES).join("\n");
3548
+ rows += 1 + visualRows(shownLines, w, COLLAPSED_LINES * 4) + (lines.length > COLLAPSED_LINES ? 1 : 0);
3390
3549
  } else {
3391
- rows += 1;
3550
+ rows += visualRows(lines[0] ?? "", w, 4);
3392
3551
  }
3393
3552
  }
3394
3553
  return rows;
@@ -3422,7 +3581,7 @@ function ThinkingBlock({ content }) {
3422
3581
  const [frame, setFrame] = useState2(0);
3423
3582
  const visible = useThinkingVisible();
3424
3583
  useEffect2(() => {
3425
- const t = setInterval(() => setFrame((f) => (f + 1) % FRAMES.length), 80);
3584
+ const t = setInterval(() => setFrame((f) => (f + 1) % FRAMES.length), 100);
3426
3585
  return () => clearInterval(t);
3427
3586
  }, []);
3428
3587
  const label = "thinking";
@@ -3689,14 +3848,16 @@ function ToolUseLine({ use, result }) {
3689
3848
  }
3690
3849
  if (use.name === "edit_file" && !result?.is_error) {
3691
3850
  const input = use.input;
3692
- const oldS = input.old_str ?? "";
3693
- const newS = input.new_str ?? "";
3694
- const added = countLines(newS);
3695
- const removed = countLines(oldS);
3696
- const preview = [
3697
- ...oldS.split("\n").map((t) => ({ sign: "-", text: t })),
3698
- ...newS.split("\n").map((t) => ({ sign: "+", text: t }))
3699
- ];
3851
+ const pairs = Array.isArray(input.edits) && input.edits.length > 0 ? input.edits.map((e) => ({ oldS: e.old_str ?? "", newS: e.new_str ?? "" })) : [{ oldS: input.old_str ?? "", newS: input.new_str ?? "" }];
3852
+ let added = 0;
3853
+ let removed = 0;
3854
+ const preview = [];
3855
+ for (const { oldS, newS } of pairs) {
3856
+ added += countLines(newS);
3857
+ removed += countLines(oldS);
3858
+ preview.push(...oldS.split("\n").map((t) => ({ sign: "-", text: t })));
3859
+ preview.push(...newS.split("\n").map((t) => ({ sign: "+", text: t })));
3860
+ }
3700
3861
  return /* @__PURE__ */ jsx9(FileEditBlock, { label: "Update", path: input.path ?? "", added, removed, previewLines: preview });
3701
3862
  }
3702
3863
  const { label, arg } = toolHeader(use);
@@ -3801,7 +3962,8 @@ function ChatView({
3801
3962
  permissionCursor = 0,
3802
3963
  activeToolUses,
3803
3964
  activeToolResults,
3804
- header
3965
+ header,
3966
+ logEpoch = 0
3805
3967
  }) {
3806
3968
  const empty = messages.length === 0 && !streaming && !thinking && !pendingPermission && !error;
3807
3969
  const log = [];
@@ -3816,8 +3978,12 @@ function ChatView({
3816
3978
  let streamNode = null;
3817
3979
  let streamRows = 0;
3818
3980
  if (streaming && streamingContent) {
3819
- const { text, clipped } = clipTail(renderMarkdownStreaming(streamingContent), liveBudget);
3820
- streamRows = text.split("\n").length + (clipped > 0 ? 1 : 0);
3981
+ const raw = clipTail(streamingContent, liveBudget);
3982
+ const width = contentWidth();
3983
+ const rendered = clipTailVisual(renderMarkdownStreaming(raw.text), liveBudget, width);
3984
+ const text = rendered.text;
3985
+ const clipped = raw.clipped + rendered.clipped;
3986
+ streamRows = visualHeight(text, width) + (clipped > 0 ? 1 : 0);
3821
3987
  streamNode = /* @__PURE__ */ jsxs12(Box12, { flexDirection: "column", marginBottom: 1, children: [
3822
3988
  clipped > 0 && /* @__PURE__ */ jsx12(Text12, { dimColor: true, children: `\u2191 ${clipped} more line${clipped === 1 ? "" : "s"} above \u2014 streaming\u2026` }),
3823
3989
  /* @__PURE__ */ jsxs12(Box12, { flexDirection: "row", children: [
@@ -3847,7 +4013,7 @@ function ChatView({
3847
4013
  ] });
3848
4014
  }
3849
4015
  return /* @__PURE__ */ jsxs12(Fragment2, { children: [
3850
- /* @__PURE__ */ jsx12(Static, { items: log, children: (item) => item.key === "header" ? /* @__PURE__ */ jsx12(Box12, { children: item.node }, item.key) : /* @__PURE__ */ jsx12(Box12, { marginLeft: 1, children: item.node }, item.key) }),
4016
+ /* @__PURE__ */ jsx12(Static, { items: log, children: (item) => item.key === "header" ? /* @__PURE__ */ jsx12(Box12, { children: item.node }, item.key) : /* @__PURE__ */ jsx12(Box12, { marginLeft: 1, children: item.node }, item.key) }, logEpoch),
3851
4017
  /* @__PURE__ */ jsxs12(Box12, { flexDirection: "column", marginLeft: 1, marginBottom: 1, children: [
3852
4018
  empty && /* @__PURE__ */ jsxs12(Box12, { flexDirection: "column", marginBottom: 1, children: [
3853
4019
  /* @__PURE__ */ jsx12(Text12, { dimColor: true, children: EMPTY_STATE_TITLE }),
@@ -4015,8 +4181,8 @@ function useAgentRunner(model, activeCtx) {
4015
4181
  case "turn-end": {
4016
4182
  flushStream(true);
4017
4183
  flushThink(true);
4018
- setStreaming(false);
4019
4184
  if (ev.stop_reason === "tool_use") {
4185
+ setStreaming(false);
4020
4186
  flushTurn(null);
4021
4187
  setThinking(true);
4022
4188
  thinkingAcc = "";
@@ -4288,6 +4454,7 @@ function useKeyboard(opts) {
4288
4454
  sessions,
4289
4455
  setSessions,
4290
4456
  setNotice,
4457
+ setLogEpoch,
4291
4458
  switchProvider
4292
4459
  } = opts;
4293
4460
  const {
@@ -4321,6 +4488,7 @@ function useKeyboard(opts) {
4321
4488
  setError(null);
4322
4489
  setNotice(null);
4323
4490
  clearPasteStore();
4491
+ setLogEpoch((n) => n + 1);
4324
4492
  }
4325
4493
  const effort = cfg.effort ?? "medium";
4326
4494
  useInput((char, key) => {
@@ -4779,6 +4947,7 @@ function App() {
4779
4947
  sessionIdRef.current = sessionId;
4780
4948
  const [sessions, setSessions] = useState5([]);
4781
4949
  const [notice, setNotice] = useState5(null);
4950
+ const [logEpoch, setLogEpoch] = useState5(0);
4782
4951
  const [input, setInput] = useState5("");
4783
4952
  const [caret, setCaret] = useState5(0);
4784
4953
  const [paletteCursor, setPaletteCursor] = useState5(0);
@@ -4899,6 +5068,7 @@ function App() {
4899
5068
  sessions,
4900
5069
  setSessions,
4901
5070
  setNotice,
5071
+ setLogEpoch,
4902
5072
  switchProvider
4903
5073
  });
4904
5074
  const effort = cfg.effort ?? "medium";
@@ -4910,7 +5080,7 @@ function App() {
4910
5080
  return Math.round(used / activeCtx * 100);
4911
5081
  })();
4912
5082
  return /* @__PURE__ */ jsxs13(Box13, { flexDirection: "column", paddingX: 1, children: [
4913
- state !== "ready" && /* @__PURE__ */ jsx13(WelcomeBlock, { model: cfg.model, activeCtx, effort, cwd, error: agent.error, updateAvailable, updateStatus }),
5083
+ state !== "ready" && state !== "sessions" && state !== "models" && /* @__PURE__ */ jsx13(WelcomeBlock, { model: cfg.model, activeCtx, effort, cwd, error: agent.error, updateAvailable, updateStatus }),
4914
5084
  state === "loading" && !agent.error && /* @__PURE__ */ jsx13(Box13, { marginLeft: 2, marginBottom: 1, children: /* @__PURE__ */ jsx13(Text13, { dimColor: true, children: `connecting to ${provName}\u2026` }) }),
4915
5085
  agent.error && state !== "ready" && /* @__PURE__ */ jsx13(
4916
5086
  ChatView,
@@ -4922,7 +5092,7 @@ function App() {
4922
5092
  error: agent.error
4923
5093
  }
4924
5094
  ),
4925
- (state === "select-model" || state === "models") && /* @__PURE__ */ jsx13(
5095
+ state === "select-model" && /* @__PURE__ */ jsx13(
4926
5096
  ModelsView,
4927
5097
  {
4928
5098
  models: filteredModels,
@@ -4933,7 +5103,7 @@ function App() {
4933
5103
  providerType: provEntry.type,
4934
5104
  effort,
4935
5105
  query: pickerQuery,
4936
- requireSelection: state === "select-model"
5106
+ requireSelection: true
4937
5107
  }
4938
5108
  ),
4939
5109
  state === "providers" && /* @__PURE__ */ jsx13(
@@ -4945,8 +5115,7 @@ function App() {
4945
5115
  query: pickerQuery
4946
5116
  }
4947
5117
  ),
4948
- state === "sessions" && /* @__PURE__ */ jsx13(SessionsView, { sessions, cursor }),
4949
- state === "ready" && /* @__PURE__ */ jsxs13(Fragment3, { children: [
5118
+ (state === "ready" || state === "sessions" || state === "models") && /* @__PURE__ */ jsxs13(Fragment3, { children: [
4950
5119
  notice && /* @__PURE__ */ jsx13(Box13, { marginLeft: 2, marginBottom: 1, children: /* @__PURE__ */ jsx13(Text13, { color: "green", children: `\u2713 ${notice}` }) }),
4951
5120
  /* @__PURE__ */ jsx13(
4952
5121
  ChatView,
@@ -4961,18 +5130,33 @@ function App() {
4961
5130
  permissionCursor: agent.permissionCursor,
4962
5131
  activeToolUses: agent.activeToolUses,
4963
5132
  activeToolResults: agent.activeToolResults,
4964
- header: /* @__PURE__ */ jsx13(WelcomeBlock, { model: cfg.model, activeCtx, effort, cwd })
5133
+ header: /* @__PURE__ */ jsx13(WelcomeBlock, { model: cfg.model, activeCtx, effort, cwd }),
5134
+ logEpoch
4965
5135
  }
4966
5136
  ),
4967
- input.startsWith("/") && /* @__PURE__ */ jsx13(CommandPalette, { filter: input, cursor: paletteCursor }),
4968
- contextWarning !== null && /* @__PURE__ */ jsx13(Box13, { marginLeft: 2, marginBottom: 1, children: /* @__PURE__ */ jsx13(Text13, { color: "yellow", children: `\u26A0 context ${contextWarning}% full \u2014 run /clear and start fresh` }) }),
4969
- !input.startsWith("/") && (() => {
5137
+ state === "ready" && input.startsWith("/") && /* @__PURE__ */ jsx13(CommandPalette, { filter: input, cursor: paletteCursor }),
5138
+ state === "ready" && contextWarning !== null && /* @__PURE__ */ jsx13(Box13, { marginLeft: 2, marginBottom: 1, children: /* @__PURE__ */ jsx13(Text13, { color: "yellow", children: `\u26A0 context ${contextWarning}% full \u2014 run /clear and start fresh` }) }),
5139
+ state === "ready" && !input.startsWith("/") && (() => {
4970
5140
  const m = parseMention(input);
4971
5141
  if (!m) return null;
4972
5142
  return /* @__PURE__ */ jsx13(FilePicker, { matches: searchFiles(process.cwd(), m.query), cursor: filePickerCursor });
4973
5143
  })(),
4974
- /* @__PURE__ */ jsx13(InputBar, { input, caret, disabled: agent.busy, processingLabel: agent.processingLabel }),
4975
- !agent.busy && /* @__PURE__ */ jsx13(Box13, { marginLeft: 2, marginBottom: 1, children: /* @__PURE__ */ jsx13(Text13, { dimColor: true, children: providerDown ? "provider unavailable \u2014 /provider to switch \xB7 /models to pick a model" : "type / to see commands" }) }),
5144
+ state === "ready" && /* @__PURE__ */ jsx13(InputBar, { input, caret, disabled: agent.busy, processingLabel: agent.processingLabel }),
5145
+ state === "sessions" && /* @__PURE__ */ jsx13(SessionsView, { sessions, cursor }),
5146
+ state === "models" && /* @__PURE__ */ jsx13(
5147
+ ModelsView,
5148
+ {
5149
+ models: filteredModels,
5150
+ cursor,
5151
+ model: cfg.model,
5152
+ host: provEntry.baseUrl,
5153
+ provider: provName,
5154
+ providerType: provEntry.type,
5155
+ effort,
5156
+ query: pickerQuery
5157
+ }
5158
+ ),
5159
+ state === "ready" && !agent.busy && /* @__PURE__ */ jsx13(Box13, { marginLeft: 2, marginBottom: 1, children: /* @__PURE__ */ jsx13(Text13, { dimColor: true, children: providerDown ? "provider unavailable \u2014 /provider to switch \xB7 /models to pick a model" : "type / to see commands" }) }),
4976
5160
  updateAvailable && /* @__PURE__ */ jsx13(Box13, { marginLeft: 2, marginBottom: 1, children: /* @__PURE__ */ jsx13(Text13, { color: updateStatus === "failed" ? "red" : updateStatus === "installed" ? "green" : "yellow", children: updateBannerText(updateAvailable, updateStatus) }) })
4977
5161
  ] })
4978
5162
  ] });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "miii-agent",
3
- "version": "0.1.30",
4
- "description": "Cursor / Claude Code, but local. An offline AI pair-programmer in your terminal, powered by Ollama. Private by default, free forever.",
3
+ "version": "0.1.32",
4
+ "description": "Local AI coding agent for your terminal โ€” an open-source, offline alternative to Claude Code, Cursor, and GitHub Copilot, powered by Ollama. Private by default, free forever.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "miii": "dist/cli.js"
@@ -52,7 +52,14 @@
52
52
  "pair-programming",
53
53
  "code-generation",
54
54
  "llama-cpp",
55
- "lm-studio"
55
+ "lm-studio",
56
+ "copilot",
57
+ "copilot-alternative",
58
+ "cursor-alternative",
59
+ "claude-code",
60
+ "claude-code-alternative",
61
+ "developer-tools",
62
+ "qwen"
56
63
  ],
57
64
  "license": "MIT",
58
65
  "dependencies": {