miii-cli 0.3.0 β†’ 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
- # πŸš€ Miii CLI β€” High-Performance Local AI Coding Agent
1
+ # Miii β€” The High-Performance Local AI Coding Agent
2
2
 
3
- **The definitive local AI coding agent for your terminal. Automate complex engineering workflows with total control, zero cloud, and zero Python overhead.**
3
+ > **You're paying $200/month for an AI that reads your private code and sends it to a cloud server you don't control. There's a better way.**
4
4
 
5
5
  ![MIII Demo](mii-cli.gif)
6
6
 
@@ -9,87 +9,166 @@
9
9
  [![license](https://img.shields.io/npm/l/miii-cli)](LICENSE)
10
10
  [![node](https://img.shields.io/node/v/miii-cli)](https://nodejs.org)
11
11
 
12
- ## πŸ“Š How Miii Stacks Up
12
+ ---
13
13
 
14
- | Feature | **Miii** | Claude Code | Codex CLI | Aider |
15
- |---|---|---|---|---|
16
- | **Runs locally** | βœ… Ollama / any API | ❌ Cloud only | ❌ Cloud only | βœ… Local + cloud |
17
- | **Code stays private** | βœ… Never leaves machine | ❌ Sent to Anthropic | ❌ Sent to OpenAI | ⚠️ Depends on model |
18
- | **Cost** | πŸ†“ Free (your compute) | πŸ’³ Pay per token | πŸ’³ Pay per token | πŸ†“ Free (local) |
19
- | **Runtime** | ⚑ TypeScript β€” instant start | 🐍 Node (fast) | 🐍 Node | 🐒 Python |
20
- | **Deep Think mode** | βœ… Gather + synthesize | ❌ | ❌ | ❌ |
21
- | **Auto-test loop** | βœ… Jest / Vitest / Mocha | ⚠️ Manual | ❌ | ⚠️ Manual |
22
- | **Web search built-in** | βœ… Tavily | ❌ | ❌ | ❌ |
23
- | **Surgical patch edits** | βœ… `patch_file` | βœ… | ⚠️ | βœ… |
24
- | **Session memory** | βœ… Named, persistent | βœ… | ❌ | ⚠️ Basic |
25
- | **Skill / plugin system** | βœ… npm + `.md` skills | ⚠️ MCP only | ❌ | ❌ |
26
- | **Open source** | βœ… MIT | ❌ | ❌ | βœ… Apache 2.0 |
14
+ **Miii is a fully autonomous coding agent that runs entirely on your machine.** It plans, edits files, runs your tests, searches the web, indexes your codebase semantically, and iterates until the job is done β€” all without a single byte of your code leaving your network.
27
15
 
28
- > βœ… = supported  |  ⚠️ = partial  |  ❌ = not supported
29
-
30
- ## ⚑️ Quick Start
31
-
32
- Get up and running in 30 seconds:
16
+ Zero subscription. Zero cloud dependency. Zero Python overhead. **176 KB total.** Just raw engineering horsepower in your terminal.
33
17
 
34
18
  ```bash
35
- ollama pull qwen2.5-coder:7b
36
- npm install -g miii-cli
37
- miii
19
+ npm install -g miii-cli && miii
38
20
  ```
39
21
 
40
- ## 🧠 Why Miii?
22
+ ---
23
+
24
+ ## Why Engineers Are Switching
25
+
26
+ Claude Code is impressive. It's also a 50 MB binary that costs $200/month, requires an internet connection, and sends every line of your codebase to a server you don't own.
41
27
 
42
- Most AI coding tools are either heavy Python wrappers or expensive monthly subscriptions that send your code to the cloud. **miii is different.**
28
+ **Miii does everything Claude Code does. It's 176 KB. It's free. It runs on your laptop.**
43
29
 
44
- - **Local-First & Private**: Runs on Ollama or any OpenAI-compatible API. Your code never leaves your machine, ensuring 100% privacy and security.
45
- - **Blazing Fast**: Built with TypeScript for near-instant startup. No heavy Python runtime overhead. Tiny footprint, massive power.
46
- - **Fully Autonomous**: Miii doesn't just suggest code; it acts as a junior engineerβ€”editing files, running your test suite, and iterating until the bugs are gone.
47
- - **Deep Context Awareness**: Automatically analyzes git diffs and project architecture, eliminating the need for manual copy-pasting.
30
+ GitHub Copilot streams your proprietary code to Microsoft. Aider is a Python monolith that takes longer to boot than to write a function. All of them charge you monthly for the privilege of being the product.
48
31
 
49
- ## πŸ”₯ Killer Features
32
+ Miii flips the model. Your compute. Your data. Your rules.
50
33
 
51
- - **πŸ›  Precision Editing**: Using `patch_file`, miii makes surgical changes without rewriting entire files.
52
- - **πŸ”„ Auto-Test Loop**: Miii runs your Jest/Vitest/Mocha tests after every edit. If it breaks, it fixes itself.
53
- - **🌐 Web Intelligence**: Integrated `web_search` and `web_extract` via Tavily for real-time documentation.
54
- - **🧠 Deep Think**: Two-phase research mode β€” gathers from files, git, and web first, then synthesizes a complete answer. Available as `/think <query>` or as a tool the LLM calls autonomously.
55
- - **πŸ“ Planning Mode**: Use `/plan` to architect a solution before a single line of code is written.
56
- - **πŸ“‚ Session Memory**: Every conversation is auto-named and persisted. Resume your work instantly with `miii --session feature-auth`.
57
- - **πŸ“¦ Skill System**: Extend miii with npm skill plugins or custom `.md` files.
34
+ ---
58
35
 
59
- ## 🧠 Deep Think
36
+ ## What Miii Actually Does
60
37
 
61
- Deep think is a two-phase research engine built into miii:
38
+ This isn't a fancy autocomplete. Miii is a **full autonomous agent loop:**
62
39
 
63
- 1. **Gather phase** β€” runs a constrained inner loop with read-only tools: `read_file`, `list_files`, `git_status`, `git_log`, `git_diff`, `web_search`, `web_extract`. Guardrails enforce a hard cap of 6 tool calls and 4 web calls. No file writes, no shell mutations.
64
- 2. **Synthesize phase** β€” gathered findings feed into the main run loop for a complete, grounded answer.
40
+ 1. You describe a goal
41
+ 2. Miii reads your codebase, plans the changes, edits the files
42
+ 3. It runs your test suite automatically after every change
43
+ 4. If tests fail, it reads the error, fixes the code, re-runs
44
+ 5. It repeats until the work is done
65
45
 
66
- **Two ways to trigger:**
46
+ No babysitting. No copy-pasting error messages. No broken half-edits.
47
+
48
+ ---
49
+
50
+ ## What a Session Looks Like
67
51
 
68
52
  ```
69
- /think how does the auth middleware handle token expiry?
70
- /think what does this codebase do and how is it structured?
71
- /think latest breaking changes in react 19
53
+ > refactor the auth module to use JWT instead of sessions
54
+
55
+ ● Researching: refactor auth module to use JWT
56
+ ● Reading src/auth/session.ts
57
+ ● Reading src/middleware/auth.ts
58
+ ● Reading src/routes/login.ts
59
+
60
+ Planning: 3 file(s) to change
61
+
62
+ ● Editing src/auth/session.ts
63
+ ● Editing src/middleware/auth.ts
64
+ ● Editing src/routes/login.ts
65
+ ● Running tests
66
+
67
+ ─ refactor done β€” 3 file(s) processed
72
68
  ```
73
69
 
74
- The LLM can also call `deep_think` autonomously mid-conversation when it decides a question needs multi-source research before answering.
70
+ No prompts asking which files to change. No copy-pasting error messages. Just: describe the goal, watch it work.
71
+
72
+ ---
75
73
 
76
- > Requires a Tavily key (`/tavily-key tvly-...`) for web calls. File/git research works without it.
74
+ ## Killer Features
77
75
 
78
- ## ⌨️ Command Cheat Sheet
76
+ **πŸ” Semantic Codebase Indexing** *(new in v0.3.2)*
77
+ Build a vector index of your entire codebase using local embeddings. Ask "where is the auth logic?" and Miii finds it by meaning, not keyword. No data leaves your machine.
78
+
79
+ **🧠 Deep Think Engine**
80
+ Before answering complex questions, Miii runs a constrained research phase β€” reading files, checking git history, searching the web β€” then synthesizes a grounded answer. Not a hallucination. A conclusion.
81
+
82
+ **🌐 Real-Time Web Access**
83
+ Tavily-powered web search and page extraction, built in. Ask about breaking changes in a library you just upgraded. Get an answer that's actually current.
84
+
85
+ **πŸ›  Surgical File Editing**
86
+ `patch_file` replaces exact strings in your files. No full rewrites. No formatting destruction. No token waste. Exactly the change, nothing more.
87
+
88
+ **πŸ”„ Self-Healing Test Loop**
89
+ Miii runs `npm test` after every file change. If something breaks, it reads the failure trace and fixes it autonomously β€” up to 3 retries before surfacing the issue to you.
90
+
91
+ **πŸ“‚ Persistent Sessions**
92
+ Pick up exactly where you left off. Named sessions mean your context, your history, and your goal survive terminal restarts.
93
+
94
+ **πŸ“¦ Skill System**
95
+ Extend Miii with plain Markdown files or npm packages. Ship reusable agent behaviors as versioned packages your whole team can pull.
96
+
97
+ ---
98
+
99
+ ## The Numbers That Matter
100
+
101
+ | | **Miii** | Claude Code | Aider |
102
+ |---|:---:|:---:|:---:|
103
+ | Monthly cost | **$0** | $20–200 | $0 |
104
+ | Bundle size | **176 KB** | ~50 MB | ~200 MB |
105
+ | Your code stays local | **βœ…** | ❌ | ⚠️ |
106
+ | Startup time | **<100ms** | ~2s | ~4s |
107
+ | Semantic codebase index | **βœ…** | ❌ | ❌ |
108
+ | Deep research mode | **βœ…** | ❌ | ❌ |
109
+ | Auto test loop | **βœ…** | ⚠️ | ⚠️ |
110
+ | Works air-gapped | **βœ…** | ❌ | ❌ |
111
+ | License | **MIT** | Proprietary | Apache 2.0 |
112
+
113
+ ---
114
+
115
+ ## Get Running in 60 Seconds
116
+
117
+ ```bash
118
+ # 1. Start Ollama and pull a model
119
+ ollama pull qwen2.5-coder:7b
120
+
121
+ # 2. Install Miii
122
+ npm install -g miii-cli
123
+
124
+ # 3. Go to your project and start
125
+ cd your-project
126
+ miii
127
+ ```
128
+
129
+ That's it. No API keys. No account. No sign-up form.
130
+
131
+ ---
132
+
133
+ ## Power Commands
79
134
 
80
135
  | Command | What it does |
81
136
  |---|---|
82
- | `/think <query>` | Deep research: gather from files + web, then synthesize answer |
83
- | `/refactor <goal>` | The powerhouse: plans, edits, and tests across your whole codebase |
84
- | `/git <sub>` | Instant git status, diffs, and automated commit messages |
85
- | `/plan` | Stop coding, start thinking (Structured Planning Mode) |
86
- | `/model <name>` | Swap LLMs on the fly |
87
- | `/tavily-key <key>` | Enable real-time web browsing |
88
- | `/sessions` | Travel back in time to previous coding sessions |
137
+ | `/think <question>` | Deep research: reads files + web, then answers |
138
+ | `/refactor <goal>` | Autonomous multi-file refactor with test validation |
139
+ | `/index build` | Build semantic vector index of your codebase |
140
+ | `/index search <query>` | Find code by meaning, not string match |
141
+ | `/git review` | AI reviews your current diff for bugs and issues |
142
+ | `/git commit <msg>` | Stage everything and commit in one shot |
143
+ | `/plan <topic>` | Structured planning mode before you write a line |
144
+ | `/model <name>` | Hot-swap your LLM mid-conversation |
145
+ | `/session <name>` | Switch between named project sessions |
146
+ | `@filename` | Inject any file directly into context |
147
+
148
+ ---
149
+
150
+ ## Semantic Codebase Indexing
151
+
152
+ For large codebases, Miii can build and query a local vector index β€” no third-party APIs, no embeddings sent anywhere.
153
+
154
+ ```bash
155
+ # Pull an embedding model (one time)
156
+ ollama pull nomic-embed-text
157
+
158
+ # Index your project
159
+ /index build
160
+
161
+ # The agent now calls search_codebase automatically
162
+ # when it needs to find code by concept
163
+ ```
89
164
 
90
- ## βš™οΈ Configuration
165
+ The agent calls `search_codebase` on its own when needed. You don't have to think about it.
91
166
 
92
- Customise your experience in `.miii.json` or `~/.config/miii/config.json`:
167
+ ---
168
+
169
+ ## Configuration
170
+
171
+ Drop a `.miii.json` in your project root or `~/.config/miii/config.json` globally:
93
172
 
94
173
  ```json
95
174
  {
@@ -97,26 +176,36 @@ Customise your experience in `.miii.json` or `~/.config/miii/config.json`:
97
176
  "provider": "ollama",
98
177
  "baseUrl": "http://localhost:11434",
99
178
  "gitContext": true,
100
- "tavilyApiKey": "tvly-..."
179
+ "tavilyApiKey": "tvly-...",
180
+ "embedModel": "nomic-embed-text"
101
181
  }
102
182
  ```
103
183
 
104
- ## πŸ›  Build from Source
184
+ ---
185
+
186
+ ## Build from Source
105
187
 
106
188
  ```bash
107
189
  git clone https://github.com/maruakshay/miii-cli
108
190
  cd miii-cli && npm install && npm run build && npm link
109
191
  ```
110
192
 
111
- ## 🌟 Community & Philosophy
193
+ ---
194
+
195
+ ## The Bottom Line
196
+
197
+ The AI coding tools you're paying for right now will raise their prices, change their terms, and keep reading your code. **Miii won't.** It's MIT licensed, runs locally, and gets better every time Ollama ships a new model.
198
+
199
+ One engineer built a 176 KB tool that replaces a $200/month cloud product. That shouldn't be a surprise β€” it should be the baseline.
200
+
201
+ If this saves you time or money, **star the repo**. It's the only metric that tells other engineers this is worth their attention.
202
+
203
+ **[⭐ Star on GitHub](https://github.com/maruakshay/miii-cli)**
204
+
205
+ > Built by [@maruakshay](https://github.com/maruakshay) β€” open to PRs, issues, and model recommendations.
112
206
 
113
- **Own your AI stack. Stop renting your intelligence. The future of coding is local.**
207
+ ---
114
208
 
115
- miii is built for the community. If this tool saves you hours of coding, help us grow:
116
- - 🌟 **Star the repo** on GitHub
117
- - 🐦 **Share on X**
118
- - πŸ€– **Post on Reddit**
119
- - πŸ’¬ **Tell a fellow developer**
209
+ ## License
120
210
 
121
- ## πŸ“œ License
122
- MIT
211
+ MIT β€” do whatever you want with it.
package/dist/init.js CHANGED
@@ -9,7 +9,7 @@ import { execSync } from 'child_process';
9
9
  import { loadConfig } from './config.js';
10
10
  import { SkillLoader } from './skills/loader.js';
11
11
  import { InputBar } from './tui/InputBar.js';
12
- import { welcome, setInkInstance } from './tui/printer.js';
12
+ import { welcome } from './tui/printer.js';
13
13
  import { ensureOllama } from './llm/ollama.js';
14
14
  const require = createRequire(import.meta.url);
15
15
  const UPDATE_CACHE = join(homedir(), '.config', 'miii', 'update-check.json');
@@ -90,7 +90,6 @@ export async function lazyInit() {
90
90
  // Print welcome banner to scrollback BEFORE Ink starts
91
91
  welcome(config.provider, config.model, process.cwd(), currentVersion, updateAvailable, linked);
92
92
  const sessionName = argv.session || `s-${Date.now()}`;
93
- const { waitUntilExit, clear } = render(React.createElement(InputBar, { config, skills, cwd: process.cwd(), session: sessionName, version: currentVersion }), { exitOnCtrlC: false });
94
- setInkInstance(clear);
93
+ const { waitUntilExit } = render(React.createElement(InputBar, { config, skills, cwd: process.cwd(), session: sessionName, version: currentVersion }), { exitOnCtrlC: false });
95
94
  await waitUntilExit();
96
95
  }
package/dist/sessions.js CHANGED
@@ -44,10 +44,29 @@ export function loadSession(name) {
44
44
  }
45
45
  export function saveSession(name, messages) {
46
46
  ensureDir();
47
- writeFileSync(join(SESSIONS_DIR, `${sanitizeName(name)}.json`), JSON.stringify(messages));
47
+ try {
48
+ writeFileSync(join(SESSIONS_DIR, `${sanitizeName(name)}.json`), JSON.stringify(messages));
49
+ }
50
+ catch { }
48
51
  }
49
52
  export function deleteSession(name) {
50
53
  const p = join(SESSIONS_DIR, `${sanitizeName(name)}.json`);
51
54
  if (existsSync(p))
52
55
  unlinkSync(p);
53
56
  }
57
+ export function deleteAllSessions(exceptName) {
58
+ ensureDir();
59
+ const files = readdirSync(SESSIONS_DIR).filter(f => f.endsWith('.json'));
60
+ let count = 0;
61
+ for (const f of files) {
62
+ const name = f.replace('.json', '');
63
+ if (exceptName && name === exceptName)
64
+ continue;
65
+ try {
66
+ unlinkSync(join(SESSIONS_DIR, f));
67
+ count++;
68
+ }
69
+ catch { }
70
+ }
71
+ return count;
72
+ }
@@ -12,6 +12,7 @@ export class MicroQueue {
12
12
  if (!this.heap.length)
13
13
  return undefined;
14
14
  const top = this.heap[0];
15
+ this.order.delete(top.id);
15
16
  const last = this.heap.pop();
16
17
  if (this.heap.length) {
17
18
  this.heap[0] = last;
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState, useCallback, useRef, useMemo } from 'react';
2
+ import { useState, useCallback, useRef, useMemo, useEffect } from 'react';
3
3
  import { Box, Text, useStdout } from 'ink';
4
4
  import { InputArea } from './components/InputArea.js';
5
5
  import { ModelPicker } from './components/ModelPicker.js';
@@ -8,7 +8,8 @@ import { tools } from '../tools/index.js';
8
8
  import { readFile } from '../files/ops.js';
9
9
  import { generateId } from '../types.js';
10
10
  import * as printer from './printer.js';
11
- import { loadSession, saveSession, listSessions, deleteSession } from '../sessions.js';
11
+ import { toolArgSummary } from './printer.js';
12
+ import { loadSession, saveSession, listSessions, deleteSession, deleteAllSessions } from '../sessions.js';
12
13
  import { MacroQueue, MicroQueue } from '../tasks/queue.js';
13
14
  import { TaskExecutor } from '../tasks/executor.js';
14
15
  import { fileEditContext } from '../tasks/compactor.js';
@@ -24,6 +25,7 @@ import { useSession } from './hooks/useSession.js';
24
25
  import { useModelPicker } from './hooks/useModelPicker.js';
25
26
  import { useRunLoop } from './hooks/useRunLoop.js';
26
27
  import { runDeepThink } from './deepThink.js';
28
+ import { setInkInstance } from './printer.js';
27
29
  const gitRun = promisify(exec);
28
30
  function buildAtContext(text) {
29
31
  const refs = [...text.matchAll(/@([\w./\-]+)/g)];
@@ -40,9 +42,20 @@ function buildAtContext(text) {
40
42
  }
41
43
  return parts.length ? parts.join('\n\n') + '\n\n' : '';
42
44
  }
45
+ function formatElapsed(ms) {
46
+ const s = Math.floor(ms / 1000);
47
+ if (s < 60)
48
+ return `${s}s`;
49
+ const m = Math.floor(s / 60);
50
+ const rem = s % 60;
51
+ return rem === 0 ? `${m}m` : `${m}m ${rem}s`;
52
+ }
43
53
  export function InputBar({ config, skills, cwd, session, version }) {
44
- const { stdout } = useStdout();
54
+ const { stdout, write: stdoutWrite } = useStdout();
45
55
  const cols = stdout.columns ?? 80;
56
+ useEffect(() => {
57
+ setInkInstance(stdoutWrite);
58
+ }, []);
46
59
  const phraseSeq = useMemo(() => Array.from({ length: 100 }, () => Math.floor(Math.random() * THINKING_PHRASES.length)), []);
47
60
  const [planningMode, setPlanningMode] = useState(false);
48
61
  const macroQueueRef = useRef(new MacroQueue());
@@ -61,7 +74,7 @@ export function InputBar({ config, skills, cwd, session, version }) {
61
74
  },
62
75
  }), [config]);
63
76
  const allTools = useMemo(() => [...tools, deepThinkTool], [deepThinkTool]);
64
- const { status, setStatus, tick, currentTool, setCurrentTool, taskLabel, setTaskLabel, thinkingStartRef, runLoop, handleAbort, } = useRunLoop(config, currentModelRef, pushHistory, allTools, abortRef);
77
+ const { status, setStatus, tick, currentTool, setCurrentTool, taskLabel, setTaskLabel, thinkingStartRef, runLoop, handleAbort, permissionRequest, resolvePermission, } = useRunLoop(config, currentModelRef, pushHistory, allTools, abortRef);
65
78
  // ─── refactor ─────────────────────────────────────────────────────────────
66
79
  const runRefactor = useCallback(async (goal) => {
67
80
  printer.systemMsg(`refactor: ${goal}`);
@@ -444,7 +457,12 @@ export function InputBar({ config, skills, cwd, session, version }) {
444
457
  if (arg.startsWith('delete ')) {
445
458
  const target = arg.slice(7).trim();
446
459
  if (!target) {
447
- printer.systemMsg('usage: /session delete <name>');
460
+ printer.systemMsg('usage: /session delete <name|all>');
461
+ return;
462
+ }
463
+ if (target === 'all') {
464
+ const count = deleteAllSessions(sessionNameRef.current);
465
+ printer.systemMsg(`deleted ${count} session(s) β€” kept active: ${sessionNameRef.current}`);
448
466
  return;
449
467
  }
450
468
  if (target === sessionNameRef.current) {
@@ -514,7 +532,7 @@ export function InputBar({ config, skills, cwd, session, version }) {
514
532
  }, [skills, runLoop, openPicker]);
515
533
  const skillList = skills.list();
516
534
  // ─── render ────────────────────────────────────────────────────────────────
517
- return (_jsxs(Box, { flexDirection: "column", children: [pickerOpen ? (_jsxs(_Fragment, { children: [_jsx(ModelPicker, { models: pickerModels, current: currentModel, loading: pickerLoading, error: pickerError, pull: pullState, onSelect: handleModelSelect, onPull: handleModelPull, onClose: () => { setPickerOpen(false); } }), _jsx(Divider, { cols: cols })] })) : (status === 'thinking' || status === 'tool') ? (_jsxs(_Fragment, { children: [_jsx(Box, { flexDirection: "column", paddingX: 1, children: _jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { children: status === 'thinking'
518
- ? _jsxs(_Fragment, { children: [_jsxs(Text, { color: "yellow", children: [SPARKLE[tick % SPARKLE.length], " "] }), _jsx(Text, { color: "gray", dimColor: true, italic: true, children: THINKING_PHRASES[phraseSeq[Math.floor(tick / 62) % phraseSeq.length]] })] })
519
- : _jsxs(Text, { color: "yellow", dimColor: true, children: ["\u2699 running ", currentTool ?? 'tool', "\u2026"] }) }), _jsxs(Box, { gap: 2, children: [_jsxs(Text, { color: "gray", dimColor: true, children: [Math.floor((Date.now() - thinkingStartRef.current) / 1000), "s"] }), taskLabel && _jsx(Text, { color: "cyan", dimColor: true, children: taskLabel })] })] }) }), _jsx(Divider, { cols: cols })] })) : null, _jsx(InputArea, { status: status, skills: skillList, cwd: cwd, planningMode: planningMode, onSubmit: handleSubmit, onAbort: handleAbort })] }));
535
+ return (_jsxs(Box, { flexDirection: "column", children: [pickerOpen ? (_jsxs(_Fragment, { children: [_jsx(ModelPicker, { models: pickerModels, current: currentModel, loading: pickerLoading, error: pickerError, pull: pullState, onSelect: handleModelSelect, onPull: handleModelPull, onClose: () => { setPickerOpen(false); } }), _jsx(Divider, { cols: cols })] })) : permissionRequest ? (_jsxs(Box, { paddingX: 1, gap: 1, children: [_jsx(Text, { color: "yellow", children: "\u26A0" }), _jsx(Text, { color: "white", bold: true, children: permissionRequest.toolName }), _jsx(Text, { color: "gray", children: toolArgSummary(permissionRequest.args) })] })) : (status === 'thinking' || status === 'tool') ? (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Box, { children: status === 'thinking'
536
+ ? _jsxs(_Fragment, { children: [_jsxs(Text, { color: "yellow", children: [SPARKLE[tick % SPARKLE.length], " "] }), _jsx(Text, { color: "gray", dimColor: true, italic: true, children: THINKING_PHRASES[phraseSeq[Math.floor(tick / 62) % phraseSeq.length]] })] })
537
+ : _jsxs(Text, { color: "yellow", dimColor: true, children: ["\u2699 running ", currentTool ?? 'tool', "\u2026"] }) }), _jsxs(Box, { gap: 2, children: [_jsx(Text, { color: "gray", dimColor: true, children: formatElapsed(Date.now() - thinkingStartRef.current) }), taskLabel && _jsx(Text, { color: "cyan", dimColor: true, children: taskLabel })] })] })) : null, _jsx(InputArea, { status: status, skills: skillList, cwd: cwd, planningMode: planningMode, permissionRequest: permissionRequest, onPermissionResponse: resolvePermission, onSubmit: handleSubmit, onAbort: handleAbort, history: historyRef.current.filter(m => m.role === 'user').map(m => m.content) })] }));
520
538
  }
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState, useMemo, useRef } from 'react';
3
- import { Box, Text, useInput } from 'ink';
3
+ import { Box, Text, useInput, useStdout } from 'ink';
4
4
  import { listFiles } from '../../files/ops.js';
5
5
  import { CommandPalette } from './CommandPalette.js';
6
6
  import { AtPicker } from './AtPicker.js';
@@ -32,18 +32,34 @@ const PLANNING_COMMANDS = [
32
32
  { ns: 'plan', name: 'review', description: 'review and critique the plan so far' },
33
33
  { ns: 'plan', name: 'done', description: 'exit planning mode' },
34
34
  ];
35
- const PASTE_MIN_LINES = 3;
36
- const PASTE_MIN_CHARS = 200;
37
- export function InputArea({ status, skills, cwd, planningMode, onSubmit, onAbort }) {
35
+ const PASTE_MIN_CHARS = 120;
36
+ function wordStartBefore(line, col) {
37
+ let i = col;
38
+ while (i > 0 && line[i - 1] === ' ')
39
+ i--;
40
+ while (i > 0 && line[i - 1] !== ' ')
41
+ i--;
42
+ return i;
43
+ }
44
+ function wordEndAfter(line, col) {
45
+ let i = col;
46
+ while (i < line.length && line[i] === ' ')
47
+ i++;
48
+ while (i < line.length && line[i] !== ' ')
49
+ i++;
50
+ return i;
51
+ }
52
+ export function InputArea({ status, skills, cwd, planningMode, permissionRequest, onPermissionResponse, onSubmit, onAbort, history = [] }) {
38
53
  const [lines, setLines] = useState(['']);
39
54
  const [cursor, setCursor] = useState({ row: 0, col: 0 });
40
55
  const [overlay, setOverlay] = useState('none');
41
56
  const [overlayIdx, setOverlayIdx] = useState(0);
42
57
  const [pasteLines, setPasteLines] = useState(0);
43
58
  const pasteRef = useRef(null);
59
+ const [historyIdx, setHistoryIdx] = useState(-1);
60
+ const savedInputRef = useRef('');
44
61
  const [files, setFiles] = useState([]);
45
62
  const filesLoadedRef = useRef(false);
46
- // built-ins first, then loaded skills (deduplicated by name)
47
63
  const allCommands = useMemo(() => {
48
64
  const builtinNames = new Set(BUILTIN_COMMANDS.map(b => b.name));
49
65
  const userSkills = skills.filter(s => !builtinNames.has(s.name));
@@ -61,7 +77,7 @@ export function InputArea({ status, skills, cwd, planningMode, onSubmit, onAbort
61
77
  return '';
62
78
  const after = before.slice(atIdx + 1);
63
79
  if (after.includes(' '))
64
- return ''; // space breaks @ ref
80
+ return '';
65
81
  return after;
66
82
  }, [lines, cursor]);
67
83
  const filteredCommands = useMemo(() => {
@@ -80,7 +96,9 @@ export function InputArea({ status, skills, cwd, planningMode, onSubmit, onAbort
80
96
  setTimeout(() => { try {
81
97
  setFiles(listFiles(cwd, true));
82
98
  }
83
- catch { } }, 0);
99
+ catch {
100
+ filesLoadedRef.current = false;
101
+ } }, 0);
84
102
  return [];
85
103
  }
86
104
  return files.filter(f => f.rel.toLowerCase().includes(atQuery.toLowerCase())).slice(0, 8);
@@ -93,6 +111,8 @@ export function InputArea({ status, skills, cwd, planningMode, onSubmit, onAbort
93
111
  setOverlayIdx(0);
94
112
  pasteRef.current = null;
95
113
  setPasteLines(0);
114
+ setHistoryIdx(-1);
115
+ savedInputRef.current = '';
96
116
  }
97
117
  function appendChar(ch) {
98
118
  setLines(prev => {
@@ -103,23 +123,45 @@ export function InputArea({ status, skills, cwd, planningMode, onSubmit, onAbort
103
123
  });
104
124
  setCursor(c => ({ ...c, col: c.col + ch.length }));
105
125
  }
106
- function deleteChar() {
126
+ function insertNewline() {
107
127
  const { row, col } = cursor;
128
+ const before = lines[row].slice(0, col);
129
+ const after = lines[row].slice(col);
108
130
  setLines(prev => {
109
131
  const next = [...prev];
110
- if (col > 0) {
111
- next[row] = next[row].slice(0, col - 1) + next[row].slice(col);
112
- }
113
- else if (row > 0) {
114
- const prevLen = next[row - 1].length;
115
- next.splice(row - 1, 2, next[row - 1] + next[row]);
116
- setCursor({ row: row - 1, col: prevLen });
117
- return next;
118
- }
132
+ next.splice(row, 1, before, after);
119
133
  return next;
120
134
  });
121
- if (col > 0)
135
+ setCursor({ row: row + 1, col: 0 });
136
+ }
137
+ function deleteChar() {
138
+ const { row, col } = cursor;
139
+ if (col > 0) {
140
+ setLines(prev => {
141
+ const next = [...prev];
142
+ next[row] = next[row].slice(0, col - 1) + next[row].slice(col);
143
+ return next;
144
+ });
122
145
  setCursor(c => ({ ...c, col: c.col - 1 }));
146
+ }
147
+ else if (row > 0) {
148
+ const prevLen = lines[row - 1].length;
149
+ setLines(prev => {
150
+ const next = [...prev];
151
+ next.splice(row - 1, 2, next[row - 1] + next[row]);
152
+ return next;
153
+ });
154
+ setCursor({ row: row - 1, col: prevLen });
155
+ }
156
+ }
157
+ function recallHistory(idx) {
158
+ const entry = history[history.length - 1 - idx];
159
+ if (!entry)
160
+ return;
161
+ const recalled = entry.split('\n');
162
+ setLines(recalled);
163
+ setCursor({ row: 0, col: recalled[0].length });
164
+ setHistoryIdx(idx);
123
165
  }
124
166
  function selectCommand(skill) {
125
167
  const name = (skill.ns === 'default' || skill.ns === 'builtin')
@@ -148,7 +190,17 @@ export function InputArea({ status, skills, cwd, planningMode, onSubmit, onAbort
148
190
  setOverlayIdx(0);
149
191
  }
150
192
  useInput((input, key) => {
151
- // ESC: close overlay, abort stream, or clear input
193
+ if (permissionRequest && onPermissionResponse) {
194
+ if (input === 'y' || input === 'Y') {
195
+ onPermissionResponse(true);
196
+ return;
197
+ }
198
+ if (input === 'n' || input === 'N' || key.escape) {
199
+ onPermissionResponse(false);
200
+ return;
201
+ }
202
+ return;
203
+ }
152
204
  if (key.escape) {
153
205
  if (overlay !== 'none') {
154
206
  setOverlay('none');
@@ -162,7 +214,6 @@ export function InputArea({ status, skills, cwd, planningMode, onSubmit, onAbort
162
214
  clearInput();
163
215
  return;
164
216
  }
165
- // Ctrl+C
166
217
  if (key.ctrl && input === 'c') {
167
218
  if (status !== 'idle') {
168
219
  onAbort();
@@ -187,7 +238,6 @@ export function InputArea({ status, skills, cwd, planningMode, onSubmit, onAbort
187
238
  if (key.return) {
188
239
  if (overlay === 'command') {
189
240
  if (commandQuery.includes(' ')) {
190
- // has args β€” submit full text, don't pick from palette
191
241
  const text = fullInput.trim();
192
242
  if (text) {
193
243
  clearInput();
@@ -203,7 +253,6 @@ export function InputArea({ status, skills, cwd, planningMode, onSubmit, onAbort
203
253
  }
204
254
  return;
205
255
  }
206
- // backspace/typing falls through to normal handling below
207
256
  }
208
257
  if (key.return) {
209
258
  const typed = fullInput.trim();
@@ -217,6 +266,11 @@ export function InputArea({ status, skills, cwd, planningMode, onSubmit, onAbort
217
266
  }
218
267
  return;
219
268
  }
269
+ // Ctrl+J β€” insert newline without submitting
270
+ if (key.ctrl && input === 'j') {
271
+ insertNewline();
272
+ return;
273
+ }
220
274
  if (key.backspace || key.delete) {
221
275
  if (pasteRef.current) {
222
276
  pasteRef.current = null;
@@ -224,7 +278,6 @@ export function InputArea({ status, skills, cwd, planningMode, onSubmit, onAbort
224
278
  return;
225
279
  }
226
280
  deleteChar();
227
- // Recompute overlay trigger for updated input
228
281
  const r = cursor.row;
229
282
  const col = cursor.col;
230
283
  const prospectiveLine = col > 0
@@ -237,18 +290,96 @@ export function InputArea({ status, skills, cwd, planningMode, onSubmit, onAbort
237
290
  setOverlay('none');
238
291
  if (overlay === 'at') {
239
292
  const before = prospectiveLine.slice(0, Math.max(0, col - 1));
240
- const atIdx = before.lastIndexOf('@');
241
- if (atIdx === -1)
293
+ if (before.lastIndexOf('@') === -1)
242
294
  setOverlay('none');
243
295
  }
244
296
  return;
245
297
  }
298
+ // Ctrl chords
299
+ if (key.ctrl) {
300
+ const { row, col } = cursor;
301
+ const line = lines[row] ?? '';
302
+ if (input === 'a') {
303
+ setCursor(c => ({ ...c, col: 0 }));
304
+ return;
305
+ }
306
+ if (input === 'e') {
307
+ setCursor(c => ({ ...c, col: line.length }));
308
+ return;
309
+ }
310
+ if (input === 'w') {
311
+ if (col === 0)
312
+ return;
313
+ const newCol = wordStartBefore(line, col);
314
+ setLines(prev => {
315
+ const next = [...prev];
316
+ next[row] = line.slice(0, newCol) + line.slice(col);
317
+ return next;
318
+ });
319
+ setCursor(c => ({ ...c, col: newCol }));
320
+ return;
321
+ }
322
+ if (input === 'k') {
323
+ setLines(prev => {
324
+ const next = [...prev];
325
+ next[row] = line.slice(0, col);
326
+ return next;
327
+ });
328
+ return;
329
+ }
330
+ if (input === 'u') {
331
+ setLines(prev => {
332
+ const next = [...prev];
333
+ next[row] = '';
334
+ return next;
335
+ });
336
+ setCursor(c => ({ ...c, col: 0 }));
337
+ return;
338
+ }
339
+ if (key.leftArrow) {
340
+ setCursor(c => ({ ...c, col: wordStartBefore(line, col) }));
341
+ return;
342
+ }
343
+ if (key.rightArrow) {
344
+ setCursor(c => ({ ...c, col: wordEndAfter(line, col) }));
345
+ return;
346
+ }
347
+ return;
348
+ }
349
+ // Arrow keys
246
350
  if (key.upArrow && overlay === 'none') {
247
- setCursor(c => ({ row: Math.max(0, c.row - 1), col: 0 }));
351
+ if (cursor.row > 0) {
352
+ setCursor(c => ({ row: c.row - 1, col: Math.min(c.col, lines[c.row - 1]?.length ?? 0) }));
353
+ return;
354
+ }
355
+ // history recall at top row
356
+ if (history.length > 0) {
357
+ const nextIdx = historyIdx + 1;
358
+ if (nextIdx < history.length) {
359
+ if (historyIdx === -1)
360
+ savedInputRef.current = fullInput;
361
+ recallHistory(nextIdx);
362
+ }
363
+ }
248
364
  return;
249
365
  }
250
366
  if (key.downArrow && overlay === 'none') {
251
- setCursor(c => ({ row: Math.min(lines.length - 1, c.row + 1), col: 0 }));
367
+ if (cursor.row < lines.length - 1) {
368
+ setCursor(c => ({ row: c.row + 1, col: Math.min(c.col, lines[c.row + 1]?.length ?? 0) }));
369
+ return;
370
+ }
371
+ // history forward at bottom row
372
+ if (historyIdx > 0) {
373
+ recallHistory(historyIdx - 1);
374
+ }
375
+ else if (historyIdx === 0) {
376
+ const saved = savedInputRef.current;
377
+ const restored = saved ? saved.split('\n') : [''];
378
+ setLines(restored);
379
+ setCursor({ row: 0, col: restored[0].length });
380
+ setHistoryIdx(-1);
381
+ savedInputRef.current = '';
382
+ }
252
383
  return;
253
384
  }
254
385
  if (key.leftArrow) {
@@ -259,15 +390,18 @@ export function InputArea({ status, skills, cwd, planningMode, onSubmit, onAbort
259
390
  setCursor(c => ({ ...c, col: Math.min(lines[c.row]?.length ?? 0, c.col + 1) }));
260
391
  return;
261
392
  }
262
- if (input && !key.ctrl && !key.meta) {
263
- // Detect paste: Ink delivers entire pasted chunk as one input string
264
- const lineCount = input.split('\n').length;
265
- if (input.length > 1 && (lineCount >= PASTE_MIN_LINES || input.length >= PASTE_MIN_CHARS)) {
393
+ if (input && !key.meta) {
394
+ // Detect paste
395
+ const hasNewline = input.includes('\n');
396
+ const lineCount = hasNewline ? input.split('\n').length : 1;
397
+ if (input.length > 1 && (hasNewline || input.length >= PASTE_MIN_CHARS)) {
266
398
  pasteRef.current = input;
267
399
  setPasteLines(lineCount);
268
400
  return;
269
401
  }
270
- // Compute prospective new input to decide overlay
402
+ // Exit history mode on any edit
403
+ if (historyIdx !== -1)
404
+ setHistoryIdx(-1);
271
405
  const r = cursor.row;
272
406
  const col = cursor.col;
273
407
  const prospectiveLine = lines[r].slice(0, col) + input + lines[r].slice(col);
@@ -275,11 +409,9 @@ export function InputArea({ status, skills, cwd, planningMode, onSubmit, onAbort
275
409
  prospectiveLines[r] = prospectiveLine;
276
410
  const prospective = prospectiveLines.join('\n');
277
411
  appendChar(input);
278
- // Open/update overlays
279
412
  if (prospective.startsWith('/')) {
280
- const q = prospective.slice(1);
281
- if (q.includes(' ')) {
282
- setOverlay('none'); // typing args β€” close palette, let user type freely
413
+ if (prospective.slice(1).includes(' ')) {
414
+ setOverlay('none');
283
415
  }
284
416
  else {
285
417
  setOverlay('command');
@@ -295,22 +427,32 @@ export function InputArea({ status, skills, cwd, planningMode, onSubmit, onAbort
295
427
  }
296
428
  }
297
429
  });
430
+ const { stdout } = useStdout();
431
+ const cols = stdout.columns ?? 80;
298
432
  const isProcessing = status !== 'idle';
299
- const borderColor = isProcessing ? 'yellow' : 'cyan';
300
- const hint = isProcessing
301
- ? 'esc to abort'
302
- : pasteLines > 0
303
- ? 'backspace removes paste enter to send'
304
- : overlay === 'command' && !commandQuery.includes(' ')
305
- ? '↑↓ navigate enter select esc close'
306
- : overlay === 'at'
307
- ? '↑↓ navigate enter select esc close'
308
- : planningMode
309
- ? 'πŸ“‹ planning mode / suggestions enter send /plan:done to exit'
310
- : '@ file / command enter send ctrl+c exit';
311
- return (_jsxs(Box, { flexDirection: "column", children: [overlay === 'command' && (_jsx(CommandPalette, { skills: allCommands, query: commandQuery, idx: overlayIdx })), overlay === 'at' && (_jsx(AtPicker, { files: filteredFiles, query: atQuery, idx: overlayIdx })), _jsxs(Box, { borderStyle: "round", borderColor: borderColor, paddingX: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: borderColor, bold: true, children: '❯ ' }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: pasteLines > 0 ? (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "cyan", children: "\u2398" }), _jsxs(Text, { color: "cyan", children: ["pasted ", pasteLines, " lines"] }), (lines.length > 1 || lines[0]) && (_jsx(Text, { color: "gray", dimColor: true, children: "+ typed text" }))] })) : lines.length === 1 && !lines[0] ? (_jsx(Text, { color: isActive ? 'white' : 'gray', dimColor: isProcessing, children: isActive ? 'β–ˆ' : 'processing...' })) : (lines.map((line, i) => (_jsx(Text, { wrap: "wrap", children: i === cursor.row
312
- ? renderLineWithCursor(line, cursor.col, isActive)
313
- : line }, i)))) })] }), _jsx(Text, { color: "gray", dimColor: true, children: hint })] })] }));
433
+ const promptColor = permissionRequest ? 'yellow' : isProcessing ? 'yellow' : 'green';
434
+ const inHistory = historyIdx !== -1;
435
+ const hint = permissionRequest
436
+ ? 'y approve Β· n deny'
437
+ : isProcessing
438
+ ? 'esc to interrupt'
439
+ : pasteLines > 0
440
+ ? 'backspace removes paste Β· enter to send'
441
+ : overlay === 'command' && !commandQuery.includes(' ')
442
+ ? '↑↓ navigate Β· enter select Β· esc close'
443
+ : overlay === 'at'
444
+ ? '↑↓ navigate Β· enter select Β· esc close'
445
+ : inHistory
446
+ ? `history [${historyIdx + 1}/${history.length}] Β· ↑↓ navigate Β· enter to send Β· esc clear`
447
+ : planningMode
448
+ ? 'planning mode Β· / suggestions Β· enter send Β· /plan:done exit'
449
+ : 'enter send Β· @ file Β· / cmd Β· ctrl+j newline Β· ↑ history';
450
+ const pastePreview = pasteRef.current
451
+ ? pasteRef.current.split('\n')[0].slice(0, cols - 6)
452
+ : '';
453
+ return (_jsxs(Box, { flexDirection: "column", children: [overlay === 'command' && (_jsx(CommandPalette, { skills: allCommands, query: commandQuery, idx: overlayIdx })), overlay === 'at' && (_jsx(AtPicker, { files: filteredFiles, query: atQuery, idx: overlayIdx })), _jsx(Text, { color: "gray", dimColor: true, children: '─'.repeat(Math.max(cols, 10)) }), _jsxs(Box, { paddingX: 1, children: [_jsx(Text, { color: promptColor, bold: true, children: '> ' }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: permissionRequest ? (_jsxs(Box, { gap: 2, children: [_jsx(Text, { color: "green", bold: true, children: "y yes" }), _jsx(Text, { color: "red", bold: true, children: "n no" })] })) : pasteLines > 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "cyan", children: "\u2398" }), _jsxs(Text, { color: "cyan", children: ["pasted ", pasteLines, " line", pasteLines !== 1 ? 's' : ''] }), (lines.length > 1 || lines[0]) && (_jsx(Text, { color: "gray", dimColor: true, children: "+ typed text" }))] }), pastePreview && (_jsxs(Text, { color: "gray", dimColor: true, children: [" ", pastePreview, pasteRef.current.split('\n')[0].length > cols - 6 ? '…' : ''] }))] })) : lines.length === 1 && !lines[0] ? (isActive ? (_jsxs(Text, { children: [_jsx(Text, { color: "gray", dimColor: true, children: "How can I help you? " }), _jsx(Text, { children: "\u2588" })] })) : (_jsx(Text, { color: "gray", dimColor: true, children: " " }))) : (lines.map((line, i) => (_jsx(Text, { wrap: "wrap", children: i === cursor.row
454
+ ? renderLineWithCursor(line, cursor.col, isActive)
455
+ : line }, i)))) })] }), _jsx(Text, { color: "gray", dimColor: true, children: '─ ' + hint + ' ' + '─'.repeat(Math.max(0, cols - hint.length - 3)) })] }));
314
456
  }
315
457
  function renderLineWithCursor(line, col, showCursor) {
316
458
  return line.slice(0, col) + (showCursor ? 'β–ˆ' : '') + line.slice(col);
@@ -34,6 +34,7 @@ Guardrails:
34
34
  return;
35
35
  depth++;
36
36
  let fullText = '';
37
+ let chatError = null;
37
38
  await chat({
38
39
  provider: config.provider,
39
40
  model,
@@ -41,10 +42,13 @@ Guardrails:
41
42
  apiKey: config.apiKey,
42
43
  messages: msgs,
43
44
  signal,
45
+ onChunk() { },
44
46
  async onDone(text) { fullText = text; },
45
47
  onError(err) { if (err.name !== 'AbortError')
46
- throw err; },
48
+ chatError = err; },
47
49
  });
50
+ if (chatError)
51
+ throw chatError;
48
52
  if (!fullText)
49
53
  return;
50
54
  const pending = [];
@@ -24,7 +24,8 @@ export async function buildGitContext(cwd, lastStatusRef) {
24
24
  if (code.includes('D'))
25
25
  continue;
26
26
  const raw = line.slice(3).trim().replace(/^"|"$/g, '');
27
- const rel = raw.includes(' -> ') ? raw.split(' -> ')[1] : raw;
27
+ const arrowIdx = raw.lastIndexOf(' -> ');
28
+ const rel = arrowIdx !== -1 ? raw.slice(arrowIdx + 4) : raw;
28
29
  if (!rel)
29
30
  continue;
30
31
  try {
@@ -7,16 +7,24 @@ import * as printer from '../printer.js';
7
7
  const MAX_TOOL_DEPTH = 6;
8
8
  const FILE_EDIT_TOOLS = new Set(['edit_file', 'create_file', 'patch_file', 'delete_file']);
9
9
  const SHOW_RESULT_TOOLS = new Set(['run_tests', 'git_commit']);
10
+ const PERMISSION_TOOLS = new Set(['edit_file', 'patch_file', 'delete_file', 'create_file', 'move_file', 'run_command', 'git_commit']);
10
11
  export function useRunLoop(config, currentModelRef, pushHistory, extraTools = [], abortRef) {
11
12
  const [status, setStatus] = useState('idle');
12
13
  const [tick, setTick] = useState(0);
13
14
  const [currentTool, setCurrentTool] = useState();
14
15
  const [taskLabel, setTaskLabel] = useState();
16
+ const [permissionRequest, setPermissionRequest] = useState(null);
17
+ const permissionResolveRef = useRef(null);
15
18
  const thinkingStartRef = useRef(0);
16
19
  const extraToolsRef = useRef(extraTools);
17
20
  extraToolsRef.current = extraTools;
18
21
  const pushHistoryRef = useRef(pushHistory);
19
22
  useEffect(() => { pushHistoryRef.current = pushHistory; }, [pushHistory]);
23
+ const resolvePermission = useCallback((approved) => {
24
+ permissionResolveRef.current?.(approved);
25
+ permissionResolveRef.current = null;
26
+ setPermissionRequest(null);
27
+ }, []);
20
28
  useEffect(() => {
21
29
  if (status === 'idle')
22
30
  return;
@@ -25,6 +33,7 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
25
33
  }, [status]);
26
34
  const runLoop = useCallback(async (contextMsgs, depth = 0, goal) => {
27
35
  if (depth >= MAX_TOOL_DEPTH) {
36
+ abortRef.current = null;
28
37
  setStatus('idle');
29
38
  return;
30
39
  }
@@ -78,6 +87,17 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
78
87
  const allTools = [...staticTools, ...extraToolsRef.current];
79
88
  const tool = allTools.find(t => t.name === tc.name);
80
89
  setCurrentTool(tc.name);
90
+ if (PERMISSION_TOOLS.has(tc.name)) {
91
+ const approved = await new Promise(resolve => {
92
+ permissionResolveRef.current = resolve;
93
+ setPermissionRequest({ toolName: tc.name, args: tc.args });
94
+ });
95
+ if (!approved) {
96
+ printer.systemMsg(`denied: ${tc.name}`);
97
+ next.push({ role: 'user', content: `Tool ${tc.name} was denied by the user` });
98
+ break;
99
+ }
100
+ }
81
101
  if (tool) {
82
102
  try {
83
103
  printer.toolCallStart(tc.name, tc.args);
@@ -136,6 +156,11 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
136
156
  }, [config]);
137
157
  const handleAbort = useCallback(() => {
138
158
  abortRef.current?.abort();
159
+ if (permissionResolveRef.current) {
160
+ permissionResolveRef.current(false);
161
+ permissionResolveRef.current = null;
162
+ setPermissionRequest(null);
163
+ }
139
164
  setStatus('idle');
140
165
  }, []);
141
166
  return {
@@ -144,5 +169,6 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
144
169
  taskLabel, setTaskLabel,
145
170
  thinkingStartRef,
146
171
  runLoop, handleAbort,
172
+ permissionRequest, resolvePermission,
147
173
  };
148
174
  }
@@ -1,11 +1,15 @@
1
1
  // ANSI-formatted stdout output β€” goes into terminal scrollback
2
- let _inkClear = null;
3
- export function setInkInstance(clear) {
4
- _inkClear = clear;
2
+ let _inkWrite = null;
3
+ export function setInkInstance(inkWrite) {
4
+ _inkWrite = inkWrite;
5
5
  }
6
6
  function write(s) {
7
- _inkClear?.();
8
- process.stdout.write(s);
7
+ if (_inkWrite) {
8
+ _inkWrite(s);
9
+ }
10
+ else {
11
+ process.stdout.write(s);
12
+ }
9
13
  }
10
14
  const R = '\x1b[0m';
11
15
  const BOLD = '\x1b[1m';
@@ -63,7 +67,7 @@ function formatContent(text) {
63
67
  function truncate(s, n) {
64
68
  return s.length > n ? s.slice(0, n) + '…' : s;
65
69
  }
66
- function toolArgSummary(args) {
70
+ export function toolArgSummary(args) {
67
71
  if (args.message)
68
72
  return `"${truncate(String(args.message), 60)}"`;
69
73
  if (args.path)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "miii-cli",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "type": "module",
5
5
  "description": "The high-performance local AI coding agent for your terminal. Automate complex workflows with local LLMs.",
6
6
  "license": "MIT",