miii-agent 0.1.14 → 0.1.16

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 +45 -3
  2. package/dist/cli.js +278 -61
  3. package/package.json +6 -2
package/README.md CHANGED
@@ -16,7 +16,7 @@
16
16
  <a href="https://ollama.com"><img src="https://img.shields.io/badge/powered%20by-Ollama-black" alt="powered by Ollama"></a>
17
17
  </p>
18
18
 
19
- miii is a local-first AI coding agent that lives in your terminal. Powered by [Ollama](https://ollama.com), it reads your code, writes features, runs tests, and fixes bugs entirely on your hardware, at native speed.
19
+ miii is a local-first AI coding agent that lives in your terminal. Powered by [Ollama](https://ollama.com) — or any OpenAI-compatible local server like [llama.cpp](https://github.com/ggml-org/llama.cpp) and [LM Studio](https://lmstudio.ai) — it reads your code, writes features, runs tests, and fixes bugs entirely on your hardware, at native speed.
20
20
 
21
21
  ---
22
22
 
@@ -48,7 +48,7 @@ Most AI coding tools are wrappers around cloud APIs. They are slow, expensive, a
48
48
 
49
49
  miii flips the script:
50
50
 
51
- - **Absolute Privacy** — Powered by Ollama. Your code stays on your disk, period.
51
+ - **Absolute Privacy** — Powered by Ollama, llama.cpp, or any local OpenAI-compatible server. Your code stays on your disk, period.
52
52
  - **Zero Friction** — No API keys, no billing, no accounts. Just `miii`.
53
53
  - **True Agency** — miii doesn't just chat; it decomposes problems, invokes tools, and verifies results like a senior engineer.
54
54
  - **Native Performance** — No network round-trips. Latency is limited by your GPU, not a CDN.
@@ -120,6 +120,19 @@ Inside the TUI, interact naturally:
120
120
  | `Ctrl+O` | Toggle full tool output view |
121
121
  | `Ctrl+C` | Quit |
122
122
 
123
+ ### Project Instructions (`MIII.md`)
124
+
125
+ Drop a `MIII.md` file in your project and miii reads it first, every turn — the same idea as `CLAUDE.md`. Use it to teach the agent your conventions, build/test commands, architecture, and do's and don'ts.
126
+
127
+ ```markdown
128
+ # MIII.md
129
+ - Use tabs, not spaces.
130
+ - Run `npm test` before declaring any task done.
131
+ - The HTTP layer lives in src/server/ — never import it from src/core/.
132
+ ```
133
+
134
+ miii searches upward from the working directory to the repo root (the directory containing `.git`); the nearest `MIII.md` wins. It is treated as authoritative — higher priority than the agent's defaults — except it can never override the permission system or safety boundaries. Files over 32KB are truncated.
135
+
123
136
  ---
124
137
 
125
138
  ## Technical Deep Dive
@@ -137,7 +150,7 @@ miii ships with a built-in tool suite that the agent invokes autonomously:
137
150
  | `grep` | Regex search across files |
138
151
  | `run_bash` | Execute shell commands |
139
152
 
140
- **Security & Safety:** Every sensitive operation is gated by a permission system. You approve what the agent can touch, and "always" approvals persist to `~/.miii/permissions.json`. File tools are strictly confined to your working directory; `../` traversal and absolute paths outside the workspace are rejected.
153
+ **Security & Safety:** Every sensitive operation is gated by a permission system. You approve what the agent can touch, and "always" approvals persist to `~/.miii/permissions.json`. The file tools (`read_file`, `write_file`, `edit_file`) are strictly confined to your working directory; `../` traversal and absolute paths outside the workspace are rejected. `run_bash` runs arbitrary shell commands and is **not** path-confined — its only boundary is the permission prompt, so review commands before approving (especially "always").
141
154
 
142
155
  ### Lossless Output Spill
143
156
 
@@ -197,6 +210,35 @@ Settings live in `~/.miii/config.json` and are created on first run.
197
210
  | `ollamaHost` | Ollama API endpoint | URL string |
198
211
  | `effort` | Controls temperature & limits | `low` \| `medium` \| `high` |
199
212
 
213
+ ### Other Local Backends
214
+
215
+ Ollama is the default, but miii talks to any **OpenAI-compatible** local server — so you can run [llama.cpp](https://github.com/ggml-org/llama.cpp) or [LM Studio](https://lmstudio.ai) instead. Your code still never leaves your machine.
216
+
217
+ Start `llama-server` (ships with llama.cpp), then point a named provider at it:
218
+
219
+ ```bash
220
+ llama-server -m ./qwen2.5-coder-14b.gguf --port 8080
221
+ ```
222
+
223
+ ```json
224
+ {
225
+ "model": "qwen2.5-coder-14b",
226
+ "provider": "llamacpp",
227
+ "providers": {
228
+ "llamacpp": { "type": "openai", "baseUrl": "http://localhost:8080" }
229
+ }
230
+ }
231
+ ```
232
+
233
+ | Field | Description | Values |
234
+ |-------|-------------|--------|
235
+ | `provider` | Active provider name (keys into `providers`) | e.g. `ollama`, `llamacpp` |
236
+ | `providers.<name>.type` | Wire protocol | `ollama` \| `openai` |
237
+ | `providers.<name>.baseUrl` | Server endpoint | URL string |
238
+ | `providers.<name>.apiKey` | Optional bearer token | string |
239
+
240
+ Switch the active provider at launch with `miii --provider llamacpp`. Any `openai`-type provider on a `localhost` URL counts as local — no key, no cloud.
241
+
200
242
  ---
201
243
 
202
244
  ## System Architecture
package/dist/cli.js CHANGED
@@ -79,10 +79,15 @@ function setEffort(effort) {
79
79
  function setProvider(provider) {
80
80
  saveConfig({ ...readRawConfig(), provider });
81
81
  }
82
- var CONFIG_DIR, CONFIG_PATH;
82
+ var EFFORT_OPTIONS, CONFIG_DIR, CONFIG_PATH;
83
83
  var init_config = __esm({
84
84
  "src/config.ts"() {
85
85
  "use strict";
86
+ EFFORT_OPTIONS = {
87
+ low: { temperature: 0.2, num_predict: 1024 },
88
+ medium: { temperature: 0.7, num_predict: 2048 },
89
+ high: { temperature: 1, num_predict: -1 }
90
+ };
86
91
  CONFIG_DIR = join(homedir(), ".miii");
87
92
  CONFIG_PATH = join(CONFIG_DIR, "config.json");
88
93
  }
@@ -850,6 +855,7 @@ var grep;
850
855
  var init_grep = __esm({
851
856
  "src/tools/grep.ts"() {
852
857
  "use strict";
858
+ init_paths();
853
859
  grep = {
854
860
  name: "grep",
855
861
  description: "Search file contents for a regex pattern. Uses ripgrep if available, falls back to grep -R.",
@@ -865,7 +871,12 @@ var init_grep = __esm({
865
871
  required: ["pattern"]
866
872
  },
867
873
  handler: async ({ pattern, path, glob: glob2, case_insensitive, max_results }) => {
868
- const root = path ?? process.cwd();
874
+ let root;
875
+ try {
876
+ root = confinePath(path ?? ".");
877
+ } catch (err) {
878
+ return { content: err instanceof Error ? err.message : String(err), is_error: true };
879
+ }
869
880
  const limit = max_results ?? 200;
870
881
  const ci = case_insensitive === true || String(case_insensitive) === "true";
871
882
  const tryRg = async () => {
@@ -931,6 +942,7 @@ var glob;
931
942
  var init_glob = __esm({
932
943
  "src/tools/glob.ts"() {
933
944
  "use strict";
945
+ init_paths();
934
946
  glob = {
935
947
  name: "glob",
936
948
  description: 'List files matching a glob pattern (e.g. "**/*.ts"). Uses ripgrep --files if available.',
@@ -944,7 +956,12 @@ var init_glob = __esm({
944
956
  required: ["pattern"]
945
957
  },
946
958
  handler: async ({ pattern, path, max_results }) => {
947
- const root = path ?? process.cwd();
959
+ let root;
960
+ try {
961
+ root = confinePath(path ?? ".");
962
+ } catch (err) {
963
+ return { content: err instanceof Error ? err.message : String(err), is_error: true };
964
+ }
948
965
  const limit = max_results ?? 500;
949
966
  const tryRg = () => execa3("rg", ["--files", "--hidden", "--glob", pattern, root], {
950
967
  reject: false,
@@ -1056,12 +1073,61 @@ var init_validate = __esm({
1056
1073
  }
1057
1074
  });
1058
1075
 
1076
+ // src/prompt/context.ts
1077
+ import { existsSync as existsSync3, readFileSync as readFileSync5, statSync as statSync3 } from "fs";
1078
+ import { dirname as dirname2, join as join6 } from "path";
1079
+ function findContextFile(cwd) {
1080
+ let dir = cwd;
1081
+ for (; ; ) {
1082
+ const candidate = join6(dir, CONTEXT_FILENAME);
1083
+ if (existsSync3(candidate)) return candidate;
1084
+ if (existsSync3(join6(dir, ".git"))) return null;
1085
+ const parent = dirname2(dir);
1086
+ if (parent === dir) return null;
1087
+ dir = parent;
1088
+ }
1089
+ }
1090
+ function loadProjectContext(cwd) {
1091
+ const source = findContextFile(cwd);
1092
+ if (!source) return EMPTY;
1093
+ try {
1094
+ if (statSync3(source).size === 0) return { ...EMPTY, source };
1095
+ const raw = readFileSync5(source, "utf8");
1096
+ if (Buffer.byteLength(raw, "utf8") > MAX_CONTEXT_BYTES) {
1097
+ const clipped = Buffer.from(raw, "utf8").subarray(0, MAX_CONTEXT_BYTES).toString("utf8");
1098
+ return { content: clipped, source, truncated: true };
1099
+ }
1100
+ return { content: raw, source, truncated: false };
1101
+ } catch {
1102
+ return { ...EMPTY, source };
1103
+ }
1104
+ }
1105
+ var CONTEXT_FILENAME, MAX_CONTEXT_BYTES, EMPTY;
1106
+ var init_context = __esm({
1107
+ "src/prompt/context.ts"() {
1108
+ "use strict";
1109
+ CONTEXT_FILENAME = "MIII.md";
1110
+ MAX_CONTEXT_BYTES = 32 * 1024;
1111
+ EMPTY = { content: "", source: null, truncated: false };
1112
+ }
1113
+ });
1114
+
1059
1115
  // src/prompt/system.ts
1060
- function buildSystemPrompt(tools, cwd) {
1116
+ function buildSystemPrompt(tools, cwd, project) {
1061
1117
  const toolLines = tools.map((t) => `- ${t.name}: ${t.description}`).join("\n");
1118
+ const projectSection = project && project.content.trim() ? `
1119
+ # ${CONTEXT_FILENAME} \u2014 project instructions (authoritative, read first)
1120
+ The user maintains ${CONTEXT_FILENAME} at ${project.source} to steer how you work in this project: conventions, commands, architecture, do's and don'ts. Treat it as direct instruction from the user, higher priority than your defaults. When it conflicts with a default rule below, ${CONTEXT_FILENAME} wins (except permissions and safety, which you never override).${project.truncated ? `
1121
+ (Note: file exceeded ${"32KB"} and was truncated.)` : ""}
1122
+
1123
+ --- BEGIN ${CONTEXT_FILENAME} ---
1124
+ ${project.content.trim()}
1125
+ --- END ${CONTEXT_FILENAME} ---
1126
+ ` : "";
1062
1127
  return `You are miii, a senior software engineer running in a terminal.
1063
1128
 
1064
1129
  Working directory: ${cwd}
1130
+ ${projectSection}
1065
1131
 
1066
1132
  # Goal Understanding (read this first, every turn)
1067
1133
  Before acting on any request, extract and hold three things:
@@ -1142,17 +1208,18 @@ ${toolLines}
1142
1208
  var init_system = __esm({
1143
1209
  "src/prompt/system.ts"() {
1144
1210
  "use strict";
1211
+ init_context();
1145
1212
  }
1146
1213
  });
1147
1214
 
1148
1215
  // src/permissions/policy.ts
1149
- import { readFileSync as readFileSync5, writeFileSync as writeFileSync6, mkdirSync as mkdirSync5, existsSync as existsSync3, renameSync } from "fs";
1150
- import { join as join6 } from "path";
1216
+ import { readFileSync as readFileSync6, writeFileSync as writeFileSync6, mkdirSync as mkdirSync5, existsSync as existsSync4, renameSync } from "fs";
1217
+ import { join as join7 } from "path";
1151
1218
  import { homedir as homedir5 } from "os";
1152
1219
  function loadRules() {
1153
- if (!existsSync3(RULES_PATH)) return [];
1220
+ if (!existsSync4(RULES_PATH)) return [];
1154
1221
  try {
1155
- const data = JSON.parse(readFileSync5(RULES_PATH, "utf-8"));
1222
+ const data = JSON.parse(readFileSync6(RULES_PATH, "utf-8"));
1156
1223
  return Array.isArray(data.rules) ? data.rules : [];
1157
1224
  } catch {
1158
1225
  return [];
@@ -1203,8 +1270,8 @@ var RULES_DIR, RULES_PATH, ALWAYS_ALLOW;
1203
1270
  var init_policy = __esm({
1204
1271
  "src/permissions/policy.ts"() {
1205
1272
  "use strict";
1206
- RULES_DIR = join6(homedir5(), ".miii");
1207
- RULES_PATH = join6(RULES_DIR, "permissions.json");
1273
+ RULES_DIR = join7(homedir5(), ".miii");
1274
+ RULES_PATH = join7(RULES_DIR, "permissions.json");
1208
1275
  ALWAYS_ALLOW = /* @__PURE__ */ new Set(["read_file", "grep", "glob"]);
1209
1276
  }
1210
1277
  });
@@ -1346,11 +1413,37 @@ var init_adapter = __esm({
1346
1413
  });
1347
1414
 
1348
1415
  // src/agent/loop.ts
1416
+ import { existsSync as existsSync5 } from "fs";
1417
+ function readGuard(name, input, seen) {
1418
+ if (name !== "edit_file" && name !== "write_file") return null;
1419
+ const p = input.path;
1420
+ if (typeof p !== "string" || !p) return null;
1421
+ let abs;
1422
+ try {
1423
+ abs = confinePath(p);
1424
+ } catch {
1425
+ return null;
1426
+ }
1427
+ if (seen.has(abs)) return null;
1428
+ if (name === "write_file" && !existsSync5(abs)) return null;
1429
+ const verb = name === "edit_file" ? "edit" : "overwrite";
1430
+ return `Refusing to ${verb} ${p}: you have not read it this turn. Call read_file on ${p} first, then retry the ${name}.`;
1431
+ }
1432
+ function markSeen(name, input, seen) {
1433
+ if (name !== "read_file" && name !== "edit_file" && name !== "write_file") return;
1434
+ const p = input.path;
1435
+ if (typeof p !== "string" || !p) return;
1436
+ try {
1437
+ seen.add(confinePath(p));
1438
+ } catch {
1439
+ }
1440
+ }
1349
1441
  async function* runAgent(opts) {
1350
1442
  const { model, cwd, permissions, hooks, signal, num_ctx } = opts;
1351
1443
  const startTime = Date.now();
1352
- const system = buildSystemPrompt(TOOLS, cwd);
1444
+ const system = buildSystemPrompt(TOOLS, cwd, loadProjectContext(cwd));
1353
1445
  const ollamaTools = toOllamaTools(TOOLS);
1446
+ const effort = EFFORT_OPTIONS[loadConfig().effort ?? "medium"];
1354
1447
  const history = [
1355
1448
  ...opts.history,
1356
1449
  { role: "user", content: opts.userText }
@@ -1359,6 +1452,7 @@ async function* runAgent(opts) {
1359
1452
  let evalTokens = 0;
1360
1453
  let lastAssistantSig = "";
1361
1454
  let repeatCount = 0;
1455
+ const seenPaths = /* @__PURE__ */ new Set();
1362
1456
  for (let turn = 0; turn < MAX_TURNS; turn++) {
1363
1457
  let text = "";
1364
1458
  let tool_calls;
@@ -1369,7 +1463,7 @@ async function* runAgent(opts) {
1369
1463
  const composedSignal = signal ? AbortSignal.any ? AbortSignal.any([signal, ac.signal]) : ac.signal : ac.signal;
1370
1464
  if (signal) signal.addEventListener("abort", () => ac.abort(), { once: true });
1371
1465
  try {
1372
- for await (const chunk of chat3(model, toOllamaMessages(history, system), ollamaTools, { signal: composedSignal, num_ctx, num_predict: NUM_PREDICT })) {
1466
+ for await (const chunk of chat3(model, toOllamaMessages(history, system), ollamaTools, { signal: composedSignal, num_ctx, num_predict: effort.num_predict, temperature: effort.temperature })) {
1373
1467
  if (signal?.aborted) break;
1374
1468
  if (chunk.content) {
1375
1469
  text += chunk.content;
@@ -1483,6 +1577,18 @@ async function* runAgent(opts) {
1483
1577
  yield { type: "tool-result", block: r2 };
1484
1578
  continue;
1485
1579
  }
1580
+ const guard = readGuard(use.name, use.input, seenPaths);
1581
+ if (guard) {
1582
+ const r2 = {
1583
+ type: "tool_result",
1584
+ tool_use_id: use.id,
1585
+ content: guard,
1586
+ is_error: true
1587
+ };
1588
+ results.push(r2);
1589
+ yield { type: "tool-result", block: r2 };
1590
+ continue;
1591
+ }
1486
1592
  try {
1487
1593
  await hooks?.firePre(use);
1488
1594
  } catch {
@@ -1504,6 +1610,7 @@ async function* runAgent(opts) {
1504
1610
  is_error: true
1505
1611
  };
1506
1612
  }
1613
+ if (!r.is_error) markSeen(use.name, use.input, seenPaths);
1507
1614
  try {
1508
1615
  await hooks?.firePost(use, r);
1509
1616
  } catch {
@@ -1517,18 +1624,20 @@ async function* runAgent(opts) {
1517
1624
  yield { type: "done", prompt_tokens: promptTokens, eval_tokens: evalTokens };
1518
1625
  return history;
1519
1626
  }
1520
- var MAX_TURNS, NUM_PREDICT, REPEAT_TAIL, REPEAT_KILL;
1627
+ var MAX_TURNS, REPEAT_TAIL, REPEAT_KILL;
1521
1628
  var init_loop = __esm({
1522
1629
  "src/agent/loop.ts"() {
1523
1630
  "use strict";
1524
1631
  init_client();
1632
+ init_paths();
1525
1633
  init_registry();
1526
1634
  init_validate();
1527
1635
  init_system();
1636
+ init_context();
1528
1637
  init_policy();
1638
+ init_config();
1529
1639
  init_adapter();
1530
1640
  MAX_TURNS = 25;
1531
- NUM_PREDICT = 8192;
1532
1641
  REPEAT_TAIL = 120;
1533
1642
  REPEAT_KILL = 4;
1534
1643
  }
@@ -1536,14 +1645,14 @@ var init_loop = __esm({
1536
1645
 
1537
1646
  // eval/runner.ts
1538
1647
  import { mkdtempSync, writeFileSync as writeFileSync7, mkdirSync as mkdirSync6, rmSync as rmSync3 } from "fs";
1539
- import { dirname as dirname2, join as join7 } from "path";
1648
+ import { dirname as dirname3, join as join8 } from "path";
1540
1649
  import { tmpdir } from "os";
1541
1650
  async function runScenario(model, s) {
1542
- const dir = mkdtempSync(join7(tmpdir(), "miii-eval-"));
1651
+ const dir = mkdtempSync(join8(tmpdir(), "miii-eval-"));
1543
1652
  const prevCwd = process.cwd();
1544
1653
  for (const [rel, content] of Object.entries(s.files ?? {})) {
1545
- const abs = join7(dir, rel);
1546
- mkdirSync6(dirname2(abs), { recursive: true });
1654
+ const abs = join8(dir, rel);
1655
+ mkdirSync6(dirname3(abs), { recursive: true });
1547
1656
  writeFileSync7(abs, content, "utf-8");
1548
1657
  }
1549
1658
  const r = {
@@ -1605,13 +1714,13 @@ var init_runner = __esm({
1605
1714
  });
1606
1715
 
1607
1716
  // eval/scenarios.ts
1608
- import { readFileSync as readFileSync6, existsSync as existsSync4 } from "fs";
1609
- import { join as join8 } from "path";
1717
+ import { readFileSync as readFileSync7, existsSync as existsSync6 } from "fs";
1718
+ import { join as join9 } from "path";
1610
1719
  var read, scenarios;
1611
1720
  var init_scenarios = __esm({
1612
1721
  "eval/scenarios.ts"() {
1613
1722
  "use strict";
1614
- read = (dir, f) => existsSync4(join8(dir, f)) ? readFileSync6(join8(dir, f), "utf-8") : null;
1723
+ read = (dir, f) => existsSync6(join9(dir, f)) ? readFileSync7(join9(dir, f), "utf-8") : null;
1615
1724
  scenarios = [
1616
1725
  {
1617
1726
  name: "edit-exact-string",
@@ -1767,7 +1876,7 @@ import { sep as sep2 } from "path";
1767
1876
  // src/ui/WelcomeBlock.tsx
1768
1877
  import { Box, Text } from "ink";
1769
1878
  import { jsx, jsxs } from "react/jsx-runtime";
1770
- function WelcomeBlock({ model, activeCtx, effort, cwd }) {
1879
+ function WelcomeBlock({ model, activeCtx, effort, cwd, updateAvailable }) {
1771
1880
  const ctxLabel = activeCtx != null ? `${Math.round(activeCtx / 1024)}k ctx` : "\u2014 ctx";
1772
1881
  return /* @__PURE__ */ jsxs(
1773
1882
  Box,
@@ -1790,18 +1899,19 @@ function WelcomeBlock({ model, activeCtx, effort, cwd }) {
1790
1899
  " effort"
1791
1900
  ] })
1792
1901
  ] }),
1793
- /* @__PURE__ */ jsx(Text, { dimColor: true, children: cwd })
1902
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: cwd }),
1903
+ updateAvailable && /* @__PURE__ */ jsx(Text, { color: "yellow", children: `\u2191 update available: v${updateAvailable} \u2014 run: miii --update` })
1794
1904
  ]
1795
1905
  }
1796
1906
  );
1797
1907
  }
1798
1908
 
1799
1909
  // src/ui/InputBar.tsx
1800
- import { useEffect, useState } from "react";
1910
+ import { memo, useEffect, useState } from "react";
1801
1911
  import { Box as Box2, Text as Text2 } from "ink";
1802
1912
  import { Fragment, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
1803
1913
  var SPIN = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
1804
- function InputBar({ input, disabled, processingLabel }) {
1914
+ var InputBar = memo(function InputBar2({ input, disabled, processingLabel }) {
1805
1915
  const [frame, setFrame] = useState(0);
1806
1916
  useEffect(() => {
1807
1917
  if (!disabled) return;
@@ -1829,7 +1939,7 @@ function InputBar({ input, disabled, processingLabel }) {
1829
1939
  ] })
1830
1940
  }
1831
1941
  );
1832
- }
1942
+ });
1833
1943
 
1834
1944
  // src/ui/ModelsView.tsx
1835
1945
  import { Box as Box3, Text as Text3 } from "ink";
@@ -1837,7 +1947,7 @@ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
1837
1947
  function ModelsView({ models, cursor, model, host, provider, effort, query, requireSelection }) {
1838
1948
  return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", marginLeft: 2, children: [
1839
1949
  /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", marginBottom: 1, children: [
1840
- /* @__PURE__ */ jsxs3(Text3, { children: [
1950
+ /* @__PURE__ */ jsxs3(Text3, { wrap: "truncate", children: [
1841
1951
  /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "provider " }),
1842
1952
  /* @__PURE__ */ jsx3(Text3, { color: "cyan", children: provider }),
1843
1953
  /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
@@ -1855,7 +1965,7 @@ function ModelsView({ models, cursor, model, host, provider, effort, query, requ
1855
1965
  /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "select model" }),
1856
1966
  /* @__PURE__ */ jsx3(Box3, { marginTop: 1, flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: models.length === 0 ? /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: query ? `no models match "${query}"` : provider === "lmstudio" ? "no models. load a model in LM Studio and start the server." : "no models found." }) : models.map((m, i) => {
1857
1967
  const sel = i === cursor;
1858
- return /* @__PURE__ */ jsxs3(Text3, { color: sel ? "blue" : void 0, dimColor: !sel, children: [
1968
+ return /* @__PURE__ */ jsxs3(Text3, { wrap: "truncate", color: sel ? "blue" : void 0, dimColor: !sel, children: [
1859
1969
  sel ? "\u276F " : " ",
1860
1970
  m,
1861
1971
  m === model ? /* @__PURE__ */ jsx3(Text3, { color: "green", children: " \u25CF" }) : null
@@ -1901,6 +2011,11 @@ function ProviderPicker({ entries, cursor, activeName, query }) {
1901
2011
  // src/ui/SessionsView.tsx
1902
2012
  import { Box as Box5, Text as Text5 } from "ink";
1903
2013
  import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
2014
+ function truncate(s, max) {
2015
+ if (max <= 0) return "";
2016
+ if (s.length <= max) return s;
2017
+ return s.slice(0, Math.max(0, max - 1)) + "\u2026";
2018
+ }
1904
2019
  function relativeTime(iso) {
1905
2020
  const diff = Date.now() - new Date(iso).getTime();
1906
2021
  const min = Math.floor(diff / 6e4);
@@ -1916,13 +2031,16 @@ function SessionsView({ sessions, cursor }) {
1916
2031
  /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "resume session" }),
1917
2032
  /* @__PURE__ */ jsx5(Box5, { marginTop: 1, flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: sessions.length === 0 ? /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "no saved sessions yet" }) : sessions.map((s, i) => {
1918
2033
  const active2 = i === cursor;
1919
- const label = s.title;
2034
+ const meta = `\xB7 ${s.messageCount} msgs \xB7 ${relativeTime(s.updatedAt)}`;
2035
+ const cols = process.stdout.columns ?? 80;
2036
+ const titleMax = cols - 9 - meta.length;
2037
+ const label = truncate(s.title, titleMax);
1920
2038
  return /* @__PURE__ */ jsxs5(Box5, { gap: 1, children: [
1921
- /* @__PURE__ */ jsxs5(Text5, { color: active2 ? "blue" : void 0, dimColor: !active2, children: [
2039
+ /* @__PURE__ */ jsxs5(Text5, { wrap: "truncate", color: active2 ? "blue" : void 0, dimColor: !active2, children: [
1922
2040
  active2 ? "\u276F " : " ",
1923
2041
  label
1924
2042
  ] }),
1925
- /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: `\xB7 ${s.messageCount} msgs \xB7 ${relativeTime(s.updatedAt)}` })
2043
+ /* @__PURE__ */ jsx5(Text5, { wrap: "truncate", dimColor: true, children: meta })
1926
2044
  ] }, s.id);
1927
2045
  }) }),
1928
2046
  /* @__PURE__ */ jsx5(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "\u2191\u2193 navigate enter resume d delete esc cancel" }) })
@@ -2198,8 +2316,9 @@ function FilePicker({ matches: matches2, cursor }) {
2198
2316
  }
2199
2317
 
2200
2318
  // src/ui/ChatView.tsx
2201
- import { useState as useState3, useEffect as useEffect3 } from "react";
2319
+ import { memo as memo2, useState as useState3, useEffect as useEffect3 } from "react";
2202
2320
  import { Box as Box9, Text as Text9 } from "ink";
2321
+ import { highlight } from "cli-highlight";
2203
2322
 
2204
2323
  // src/ui/ThinkingBlock.tsx
2205
2324
  import { useState as useState2, useEffect as useEffect2 } from "react";
@@ -2292,6 +2411,55 @@ function countLines(s) {
2292
2411
  if (!s) return 0;
2293
2412
  return s.split("\n").length;
2294
2413
  }
2414
+ var EXT_LANG = {
2415
+ ts: "typescript",
2416
+ tsx: "typescript",
2417
+ mts: "typescript",
2418
+ cts: "typescript",
2419
+ js: "javascript",
2420
+ jsx: "javascript",
2421
+ mjs: "javascript",
2422
+ cjs: "javascript",
2423
+ json: "json",
2424
+ py: "python",
2425
+ rb: "ruby",
2426
+ go: "go",
2427
+ rs: "rust",
2428
+ java: "java",
2429
+ c: "c",
2430
+ h: "c",
2431
+ cpp: "cpp",
2432
+ cc: "cpp",
2433
+ hpp: "cpp",
2434
+ cs: "csharp",
2435
+ php: "php",
2436
+ swift: "swift",
2437
+ kt: "kotlin",
2438
+ scala: "scala",
2439
+ sh: "bash",
2440
+ bash: "bash",
2441
+ zsh: "bash",
2442
+ yml: "yaml",
2443
+ yaml: "yaml",
2444
+ html: "xml",
2445
+ xml: "xml",
2446
+ css: "css",
2447
+ scss: "scss",
2448
+ sql: "sql",
2449
+ md: "markdown"
2450
+ };
2451
+ function langFromPath(path) {
2452
+ const ext = path.split(".").pop()?.toLowerCase();
2453
+ return ext ? EXT_LANG[ext] : void 0;
2454
+ }
2455
+ function highlightLine(text, lang) {
2456
+ if (!lang) return text;
2457
+ try {
2458
+ return highlight(text, { language: lang, ignoreIllegals: true });
2459
+ } catch {
2460
+ return text;
2461
+ }
2462
+ }
2295
2463
  function FileEditBlock({
2296
2464
  label,
2297
2465
  path,
@@ -2302,10 +2470,11 @@ function FileEditBlock({
2302
2470
  const expanded = useToolExpanded();
2303
2471
  const shown = expanded ? previewLines : previewLines.slice(0, COLLAPSED_LINES);
2304
2472
  const extra = previewLines.length - shown.length;
2473
+ const lang = langFromPath(path);
2305
2474
  return /* @__PURE__ */ jsxs9(Box9, { flexDirection: "column", marginLeft: 2, children: [
2306
2475
  /* @__PURE__ */ jsxs9(Box9, { children: [
2307
- /* @__PURE__ */ jsx9(Text9, { color: "yellow", children: "\u25CF " }),
2308
- /* @__PURE__ */ jsxs9(Text9, { color: "yellow", children: [
2476
+ /* @__PURE__ */ jsx9(Text9, { color: "green", children: "\u25CF " }),
2477
+ /* @__PURE__ */ jsxs9(Text9, { color: "white", children: [
2309
2478
  label,
2310
2479
  " "
2311
2480
  ] }),
@@ -2319,15 +2488,19 @@ function FileEditBlock({
2319
2488
  ] }) }),
2320
2489
  shown.map((ln, i) => {
2321
2490
  const width = (process.stdout.columns ?? 80) - 6 - 20;
2322
- const raw = `${ln.sign} ${ln.text}`;
2323
- const content = raw.length > width ? raw.slice(0, width) : raw.padEnd(width);
2324
- return /* @__PURE__ */ jsx9(Box9, { marginLeft: 4, children: /* @__PURE__ */ jsx9(
2491
+ const textWidth = Math.max(0, width - 2);
2492
+ const plain = ln.text.length > textWidth ? ln.text.slice(0, textWidth) : ln.text.padEnd(textWidth);
2493
+ const code = ln.sign === " " ? plain : highlightLine(plain, lang);
2494
+ return /* @__PURE__ */ jsx9(Box9, { marginLeft: 4, children: /* @__PURE__ */ jsxs9(
2325
2495
  Text9,
2326
2496
  {
2327
2497
  wrap: "truncate",
2328
2498
  backgroundColor: ln.sign === "+" ? "#13351f" : ln.sign === "-" ? "#3b1414" : void 0,
2329
2499
  dimColor: ln.sign === " ",
2330
- children: content
2500
+ children: [
2501
+ `${ln.sign} `,
2502
+ code
2503
+ ]
2331
2504
  }
2332
2505
  ) }, i);
2333
2506
  }),
@@ -2346,7 +2519,7 @@ var TOOL_LABEL = {
2346
2519
  glob: "Glob",
2347
2520
  grep: "Grep"
2348
2521
  };
2349
- function truncate(s, max) {
2522
+ function truncate2(s, max) {
2350
2523
  if (s.length <= max) return s;
2351
2524
  return s.slice(0, max - 1) + "\u2026";
2352
2525
  }
@@ -2362,15 +2535,15 @@ function toolHeader(use) {
2362
2535
  break;
2363
2536
  case "run_bash": {
2364
2537
  const cmd2 = String(input.command ?? "").replace(/\s+/g, " ");
2365
- arg = truncate(cmd2, 120);
2538
+ arg = truncate2(cmd2, 120);
2366
2539
  break;
2367
2540
  }
2368
2541
  case "glob":
2369
2542
  case "grep":
2370
- arg = truncate(String(input.pattern ?? ""), 120);
2543
+ arg = truncate2(String(input.pattern ?? ""), 120);
2371
2544
  break;
2372
2545
  default: {
2373
- arg = truncate(JSON.stringify(input), 80);
2546
+ arg = truncate2(JSON.stringify(input), 80);
2374
2547
  }
2375
2548
  }
2376
2549
  return { label, arg };
@@ -2412,7 +2585,7 @@ function ToolResultBlock({ result, toolName }) {
2412
2585
  }
2413
2586
  const MAX_LINE_WIDTH = 200;
2414
2587
  const visible = expanded ? lines : lines.slice(0, COLLAPSED_LINES);
2415
- const shown = visible.map((l) => truncate(l, MAX_LINE_WIDTH));
2588
+ const shown = visible.map((l) => truncate2(l, MAX_LINE_WIDTH));
2416
2589
  const extra = lines.length - shown.length;
2417
2590
  return /* @__PURE__ */ jsxs9(Box9, { flexDirection: "column", marginLeft: 2, children: [
2418
2591
  /* @__PURE__ */ jsxs9(Text9, { color: result.is_error ? "red" : void 0, dimColor: !result.is_error, children: [
@@ -2450,8 +2623,8 @@ function ToolUseLine({ use, result }) {
2450
2623
  const { label, arg } = toolHeader(use);
2451
2624
  return /* @__PURE__ */ jsxs9(Box9, { flexDirection: "column", marginLeft: 2, children: [
2452
2625
  /* @__PURE__ */ jsxs9(Box9, { children: [
2453
- /* @__PURE__ */ jsx9(Text9, { color: "yellow", children: "\u25CF " }),
2454
- /* @__PURE__ */ jsxs9(Text9, { color: "yellow", children: [
2626
+ /* @__PURE__ */ jsx9(Text9, { color: "green", children: "\u25CF " }),
2627
+ /* @__PURE__ */ jsxs9(Text9, { color: "white", children: [
2455
2628
  label,
2456
2629
  " "
2457
2630
  ] }),
@@ -2462,10 +2635,16 @@ function ToolUseLine({ use, result }) {
2462
2635
  result && /* @__PURE__ */ jsx9(ToolResultBlock, { result, toolName: use.name })
2463
2636
  ] });
2464
2637
  }
2465
- function AssistantMessage({ msg }) {
2638
+ var UserMessage = memo2(function UserMessage2({ msg }) {
2639
+ return /* @__PURE__ */ jsxs9(Box9, { flexDirection: "row", marginBottom: 1, children: [
2640
+ /* @__PURE__ */ jsx9(Text9, { color: "gray", children: "\u276F " }),
2641
+ /* @__PURE__ */ jsx9(Box9, { flexGrow: 1, children: /* @__PURE__ */ jsx9(Text9, { children: msg.content }) })
2642
+ ] });
2643
+ });
2644
+ var AssistantMessage = memo2(function AssistantMessage2({ msg }) {
2466
2645
  return /* @__PURE__ */ jsxs9(Box9, { flexDirection: "column", marginBottom: 1, children: [
2467
2646
  msg.content && /* @__PURE__ */ jsxs9(Box9, { flexDirection: "row", children: [
2468
- /* @__PURE__ */ jsx9(Text9, { color: "white", children: "\u25CF " }),
2647
+ /* @__PURE__ */ jsx9(Text9, { color: "blue", children: "\u25CF " }),
2469
2648
  /* @__PURE__ */ jsx9(Box9, { flexGrow: 1, children: /* @__PURE__ */ jsx9(Text9, { children: msg.content }) })
2470
2649
  ] }),
2471
2650
  msg.tool_uses?.map((u) => {
@@ -2477,14 +2656,16 @@ function AssistantMessage({ msg }) {
2477
2656
  msg.duration != null ? ` \xB7 ${formatDuration(msg.duration)}` : ""
2478
2657
  ] }) })
2479
2658
  ] });
2480
- }
2659
+ });
2481
2660
  function summarizeInput(input) {
2482
2661
  if (!input || typeof input !== "object") return "";
2483
2662
  const obj = input;
2484
2663
  const priority = ["path", "file_path", "command", "pattern", "query"];
2485
2664
  for (const k of priority) {
2486
2665
  const v = obj[k];
2487
- if (typeof v === "string" && v.length > 0) return `${k}: ${v}`;
2666
+ if (typeof v === "string" && v.length > 0) {
2667
+ return `${k}: ${v.length > 120 ? v.slice(0, 120) + "\u2026" : v}`;
2668
+ }
2488
2669
  }
2489
2670
  const first = Object.entries(obj).find(([, v]) => typeof v === "string");
2490
2671
  if (first) {
@@ -2509,7 +2690,7 @@ function PermissionPrompt({ req, cursor }) {
2509
2690
  /* @__PURE__ */ jsx9(Text9, { bold: true, children: label }),
2510
2691
  "?"
2511
2692
  ] }) }),
2512
- summary && /* @__PURE__ */ jsx9(Box9, { marginLeft: 2, children: /* @__PURE__ */ jsx9(Text9, { dimColor: true, children: summary }) }),
2693
+ summary && /* @__PURE__ */ jsx9(Box9, { marginLeft: 2, children: /* @__PURE__ */ jsx9(Text9, { wrap: "truncate", dimColor: true, children: summary }) }),
2513
2694
  /* @__PURE__ */ jsx9(Box9, { flexDirection: "column", marginTop: 1, children: options.map((opt, i) => /* @__PURE__ */ jsxs9(Text9, { color: i === cursor ? "blue" : void 0, children: [
2514
2695
  i === cursor ? "\u276F " : " ",
2515
2696
  i + 1,
@@ -2540,10 +2721,7 @@ function ChatView({
2540
2721
  ] }, i))
2541
2722
  ] }),
2542
2723
  messages.map(
2543
- (msg, i) => msg.role === "user" ? /* @__PURE__ */ jsxs9(Box9, { flexDirection: "row", marginBottom: 1, children: [
2544
- /* @__PURE__ */ jsx9(Text9, { color: "blue", children: "\u25CF " }),
2545
- /* @__PURE__ */ jsx9(Box9, { flexGrow: 1, children: /* @__PURE__ */ jsx9(Text9, { children: msg.content }) })
2546
- ] }, i) : /* @__PURE__ */ jsx9(AssistantMessage, { msg }, i)
2724
+ (msg, i) => msg.role === "user" ? /* @__PURE__ */ jsx9(UserMessage, { msg }, i) : /* @__PURE__ */ jsx9(AssistantMessage, { msg }, i)
2547
2725
  ),
2548
2726
  thinking && /* @__PURE__ */ jsx9(ThinkingBlock, { content: thinkingContent }),
2549
2727
  streaming && streamingContent && /* @__PURE__ */ jsxs9(Box9, { flexDirection: "row", marginBottom: 1, children: [
@@ -2789,6 +2967,33 @@ function useAgentRunner(model, activeCtx) {
2789
2967
  init_config();
2790
2968
  import { useInput } from "ink";
2791
2969
  var EFFORTS = ["low", "medium", "high"];
2970
+ var PASTE_CHIP_LINES = 4;
2971
+ var PASTE_CHIP_CHARS = 200;
2972
+ var pasteStore = /* @__PURE__ */ new Map();
2973
+ var pasteCounter = 0;
2974
+ function clearPasteStore() {
2975
+ pasteStore.clear();
2976
+ pasteCounter = 0;
2977
+ }
2978
+ function expandPastes(text) {
2979
+ let out = text;
2980
+ for (const [chip, full] of pasteStore) out = out.split(chip).join(full);
2981
+ return out;
2982
+ }
2983
+ function stripControls(chunk) {
2984
+ return chunk.replace(/\x1b\[20[01]~/g, "").replace(/\t/g, " ").replace(/[\x00-\x09\x0b-\x1f\x7f]/g, "");
2985
+ }
2986
+ function sanitizePaste(chunk) {
2987
+ if (chunk.length <= 1) return chunk;
2988
+ const cleaned = stripControls(chunk).replace(/\r/g, "");
2989
+ const lines = cleaned.split("\n").length;
2990
+ if (lines > PASTE_CHIP_LINES || cleaned.length > PASTE_CHIP_CHARS) {
2991
+ const chip = `[Pasted #${++pasteCounter} \xB7 ${lines} line${lines === 1 ? "" : "s"}]`;
2992
+ pasteStore.set(chip, cleaned);
2993
+ return chip;
2994
+ }
2995
+ return cleaned.replace(/\n/g, " ");
2996
+ }
2792
2997
  function useKeyboard(opts) {
2793
2998
  const {
2794
2999
  exit,
@@ -2844,6 +3049,7 @@ function useKeyboard(opts) {
2844
3049
  setActiveToolResults([]);
2845
3050
  setError(null);
2846
3051
  setNotice(null);
3052
+ clearPasteStore();
2847
3053
  }
2848
3054
  const effort = cfg.effort ?? "medium";
2849
3055
  useInput((char, key) => {
@@ -3025,6 +3231,7 @@ function useKeyboard(opts) {
3025
3231
  return;
3026
3232
  }
3027
3233
  if (paletteOpen && key.escape) {
3234
+ clearPasteStore();
3028
3235
  setInput(() => "");
3029
3236
  setPaletteCursor(() => 0);
3030
3237
  return;
@@ -3080,19 +3287,21 @@ function useKeyboard(opts) {
3080
3287
  }
3081
3288
  } else if (trimmed) {
3082
3289
  setNotice(null);
3290
+ const message = expandPastes(trimmed);
3083
3291
  if (!agentHistory.length && cfg.model) {
3084
3292
  const id = sessionId;
3085
3293
  const model = cfg.model;
3086
3294
  void (async () => {
3087
3295
  try {
3088
- const title = await summarizeMessage(model, trimmed);
3089
- persistSession(id, [{ role: "user", content: trimmed }], title);
3296
+ const title = await summarizeMessage(model, message);
3297
+ persistSession(id, [{ role: "user", content: message }], title);
3090
3298
  } catch {
3091
3299
  }
3092
3300
  })();
3093
3301
  }
3094
- sendMessage(trimmed);
3302
+ sendMessage(message);
3095
3303
  }
3304
+ clearPasteStore();
3096
3305
  setInput(() => "");
3097
3306
  setPaletteCursor(() => 0);
3098
3307
  return;
@@ -3101,13 +3310,22 @@ function useKeyboard(opts) {
3101
3310
  setInput((s) => {
3102
3311
  setPaletteCursor(() => 0);
3103
3312
  setFilePickerCursor(() => 0);
3313
+ let match = "";
3314
+ for (const chip of pasteStore.keys()) {
3315
+ if (s.endsWith(chip) && chip.length > match.length) match = chip;
3316
+ }
3317
+ if (match) {
3318
+ pasteStore.delete(match);
3319
+ return s.slice(0, -match.length);
3320
+ }
3104
3321
  return s.slice(0, -1);
3105
3322
  });
3106
3323
  } else if (char && !key.ctrl && !key.meta && !key.tab) {
3107
- setInput((s) => {
3324
+ const text = sanitizePaste(char);
3325
+ if (text) setInput((s) => {
3108
3326
  setPaletteCursor(() => 0);
3109
3327
  setFilePickerCursor(() => 0);
3110
- return s + char;
3328
+ return s + text;
3111
3329
  });
3112
3330
  }
3113
3331
  }
@@ -3262,8 +3480,7 @@ function App() {
3262
3480
  return Math.round(used / activeCtx * 100);
3263
3481
  })();
3264
3482
  return /* @__PURE__ */ jsxs10(Box10, { flexDirection: "column", paddingX: 1, children: [
3265
- /* @__PURE__ */ jsx10(WelcomeBlock, { model: cfg.model, activeCtx, effort, cwd, error: agent.error }),
3266
- updateAvailable && /* @__PURE__ */ jsx10(Box10, { marginLeft: 2, marginBottom: 1, children: /* @__PURE__ */ jsx10(Text10, { color: "yellow", children: `\u2191 update available: v${updateAvailable} \u2014 run: miii --update` }) }),
3483
+ /* @__PURE__ */ jsx10(WelcomeBlock, { model: cfg.model, activeCtx, effort, cwd, error: agent.error, updateAvailable }),
3267
3484
  state === "loading" && !agent.error && /* @__PURE__ */ jsx10(Box10, { marginLeft: 2, marginBottom: 1, children: /* @__PURE__ */ jsx10(Text10, { dimColor: true, children: `connecting to ${provName}\u2026` }) }),
3268
3485
  agent.error && state !== "ready" && /* @__PURE__ */ jsx10(
3269
3486
  ChatView,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "miii-agent",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "Terminal AI coding agent powered by Ollama",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,6 +17,8 @@
17
17
  "start": "tsx src/cli.tsx",
18
18
  "eval": "tsx src/cli.tsx eval",
19
19
  "typecheck": "tsc --noEmit",
20
+ "test": "vitest run",
21
+ "test:watch": "vitest",
20
22
  "dev": "tsx watch src/cli.tsx",
21
23
  "build": "tsup",
22
24
  "postbuild": "node -e \"if(process.platform!=='win32')require('fs').chmodSync('dist/cli.js',0o755)\"",
@@ -36,6 +38,7 @@
36
38
  ],
37
39
  "license": "MIT",
38
40
  "dependencies": {
41
+ "cli-highlight": "^2.1.11",
39
42
  "execa": "^9.0.0",
40
43
  "ink": "^5.0.0",
41
44
  "ollama": "^0.5.0",
@@ -48,6 +51,7 @@
48
51
  "@types/react": "^18.3.0",
49
52
  "tsup": "^8.5.1",
50
53
  "tsx": "^4.19.0",
51
- "typescript": "^5.7.0"
54
+ "typescript": "^5.7.0",
55
+ "vitest": "^4.1.9"
52
56
  }
53
57
  }