nou-cli 0.0.0-stage → 0.2.0
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/.env.example +24 -0
- package/README.md +163 -2
- package/dist/grammars/tree-sitter-bash.wasm +0 -0
- package/dist/grammars/tree-sitter-c.wasm +0 -0
- package/dist/grammars/tree-sitter-css.wasm +0 -0
- package/dist/grammars/tree-sitter-go.wasm +0 -0
- package/dist/grammars/tree-sitter-html.wasm +0 -0
- package/dist/grammars/tree-sitter-java.wasm +0 -0
- package/dist/grammars/tree-sitter-javascript.wasm +0 -0
- package/dist/grammars/tree-sitter-json.wasm +0 -0
- package/dist/grammars/tree-sitter-python.wasm +0 -0
- package/dist/grammars/tree-sitter-rust.wasm +0 -0
- package/dist/grammars/tree-sitter-toml.wasm +0 -0
- package/dist/grammars/tree-sitter-tsx.wasm +0 -0
- package/dist/grammars/tree-sitter-typescript.wasm +0 -0
- package/dist/grammars/tree-sitter-yaml.wasm +0 -0
- package/dist/index.js +84 -0
- package/dist/src/agent/index.js +246 -0
- package/dist/src/commands/index.js +125 -0
- package/dist/src/config/index.js +39 -0
- package/dist/src/context/index.js +53 -0
- package/dist/src/diagnostics/index.js +235 -0
- package/dist/src/diagnostics/process.js +64 -0
- package/dist/src/diagnostics/providers.js +103 -0
- package/dist/src/diagnostics/types.js +1 -0
- package/dist/src/env.js +12 -0
- package/dist/src/git/index.js +94 -0
- package/dist/src/git/tools.js +52 -0
- package/dist/src/history/index.js +113 -0
- package/dist/src/lsp/client.js +169 -0
- package/dist/src/lsp/index.js +169 -0
- package/dist/src/plugins/index.js +134 -0
- package/dist/src/providers/anthropic.js +109 -0
- package/dist/src/providers/ids.js +3 -0
- package/dist/src/providers/index.js +46 -0
- package/dist/src/providers/ollama.js +58 -0
- package/dist/src/providers/openai.js +107 -0
- package/dist/src/providers/types.js +2 -0
- package/dist/src/syntax/index.js +240 -0
- package/dist/src/tools/index.js +100 -0
- package/dist/src/ui/App.js +671 -0
- package/dist/src/ui/Chat.js +97 -0
- package/dist/src/ui/ChatInput.js +128 -0
- package/dist/src/ui/CodeView.js +100 -0
- package/dist/src/ui/Editor.js +234 -0
- package/dist/src/ui/FileTree.js +143 -0
- package/dist/src/ui/Header.js +9 -0
- package/dist/src/ui/Pane.js +8 -0
- package/dist/src/ui/ProblemsView.js +71 -0
- package/dist/src/ui/QuickOpen.js +91 -0
- package/dist/src/ui/Settings.js +165 -0
- package/dist/src/ui/text.js +100 -0
- package/dist/src/ui/theme.js +41 -0
- package/dist/src/update/index.js +102 -0
- package/dist/src/util/index.js +9 -0
- package/package.json +69 -4
package/.env.example
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Copy to .env and fill in only the providers you use. .env is gitignored.
|
|
2
|
+
|
|
3
|
+
# Pick the starting model as provider:model (or switch in the app with /model).
|
|
4
|
+
# NOU_MODEL=ollama:qwen3:8b
|
|
5
|
+
# NOU_MODEL=anthropic:claude-opus-5-5
|
|
6
|
+
|
|
7
|
+
# --- Local ---
|
|
8
|
+
# OLLAMA_HOST=http://127.0.0.1:11434
|
|
9
|
+
# LMSTUDIO_BASE_URL=http://localhost:1234/v1
|
|
10
|
+
# LLAMACPP_BASE_URL=http://localhost:8080/v1
|
|
11
|
+
# Any other OpenAI-compatible server (vLLM, Jan, LocalAI, ...):
|
|
12
|
+
# NOU_BASE_URL=http://localhost:8000/v1
|
|
13
|
+
# NOU_API_KEY=
|
|
14
|
+
|
|
15
|
+
# --- Cloud ---
|
|
16
|
+
# ANTHROPIC_API_KEY=
|
|
17
|
+
# OPENAI_API_KEY=
|
|
18
|
+
# GEMINI_API_KEY=
|
|
19
|
+
# OPENROUTER_API_KEY=
|
|
20
|
+
# GROQ_API_KEY=
|
|
21
|
+
# DEEPSEEK_API_KEY=
|
|
22
|
+
|
|
23
|
+
# Claude effort level: low | medium | high | xhigh | max (default high)
|
|
24
|
+
# NOU_EFFORT=high
|
package/README.md
CHANGED
|
@@ -1,3 +1,164 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/JorgeOchoaReyes/nou/HEAD/docs/banner.svg" alt="Nou: your AI coding companion, right in the terminal" width="100%">
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://www.npmjs.com/package/nou-cli"><img src="https://img.shields.io/npm/v/nou-cli?style=for-the-badge&color=bb9af7&labelColor=1a1b26&logo=npm&logoColor=white" alt="npm version"></a>
|
|
7
|
+
<a href="https://github.com/JorgeOchoaReyes/nou/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/JorgeOchoaReyes/nou/ci.yml?branch=master&style=for-the-badge&label=CI&labelColor=1a1b26&color=9ece6a&logo=githubactions&logoColor=white" alt="CI"></a>
|
|
8
|
+
<img src="https://img.shields.io/node/v/nou-cli?style=for-the-badge&color=7dcfff&labelColor=1a1b26&logo=nodedotjs&logoColor=white" alt="node version">
|
|
9
|
+
<img src="https://img.shields.io/npm/l/nou-cli?style=for-the-badge&color=e0af68&labelColor=1a1b26" alt="license">
|
|
10
|
+
</p>
|
|
11
|
+
|
|
12
|
+
<p align="center">
|
|
13
|
+
<img src="https://img.shields.io/badge/Ollama-local-9ece6a?style=flat-square&logo=ollama&logoColor=white&labelColor=1a1b26" alt="Ollama">
|
|
14
|
+
<img src="https://img.shields.io/badge/LM%20Studio-local-9ece6a?style=flat-square&labelColor=1a1b26" alt="LM Studio">
|
|
15
|
+
<img src="https://img.shields.io/badge/Claude-cloud-bb9af7?style=flat-square&logo=anthropic&logoColor=white&labelColor=1a1b26" alt="Claude">
|
|
16
|
+
<img src="https://img.shields.io/badge/OpenAI-cloud-bb9af7?style=flat-square&labelColor=1a1b26" alt="OpenAI">
|
|
17
|
+
<img src="https://img.shields.io/badge/Gemini-cloud-bb9af7?style=flat-square&logo=googlegemini&logoColor=white&labelColor=1a1b26" alt="Gemini">
|
|
18
|
+
<img src="https://img.shields.io/badge/OpenRouter%20·%20Groq%20·%20DeepSeek-cloud-bb9af7?style=flat-square&labelColor=1a1b26" alt="More providers">
|
|
19
|
+
</p>
|
|
20
|
+
|
|
21
|
+
<p align="center">
|
|
22
|
+
<b>An IDE that lives in your terminal.</b> Browse your code, watch the AI's edits as live side-by-side diffs,<br>
|
|
23
|
+
approve them with a keypress, edit files yourself, and switch between local and cloud models on the fly.
|
|
24
|
+
</p>
|
|
25
|
+
|
|
26
|
+
<p align="center">
|
|
27
|
+
<img src="https://raw.githubusercontent.com/JorgeOchoaReyes/nou/HEAD/docs/screenshot.svg" alt="Nou proposing an edit as a side-by-side diff, with the file tree and chat" width="100%">
|
|
28
|
+
</p>
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## ⚡ Quick start
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npm install -g nou-cli
|
|
36
|
+
cd your-project
|
|
37
|
+
nou
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
> [!TIP]
|
|
41
|
+
> Want it fully local and free? Install [Ollama](https://ollama.com), run `ollama pull qwen3:8b`, and you're set. That's the default model.
|
|
42
|
+
|
|
43
|
+
Needs **Node 22.12+** and an interactive terminal (Windows Terminal, PowerShell, macOS/Linux terminals).
|
|
44
|
+
|
|
45
|
+
## ✨ What it does
|
|
46
|
+
|
|
47
|
+
| | |
|
|
48
|
+
| --- | --- |
|
|
49
|
+
| 🗂️ **File explorer** | Browse your project with file icons and git status (`M` modified, `?` new). Press <kbd>c</kbd> to see only changed files. |
|
|
50
|
+
| 🔀 **Live diffs** | Every edit Nou proposes shows up side by side. <kbd>y</kbd> accepts, <kbd>n</kbd> rejects. Nothing touches disk without you. |
|
|
51
|
+
| ✍️ **Built-in editor** | Press <kbd>e</kbd> to edit the open file yourself, with undo/redo and **auto-save**. |
|
|
52
|
+
| ↶ **Undo anything** | Every change, Nou's or yours, is recorded. <kbd>u</kbd> undoes Nou's last edit, `/rewind` undoes its whole last turn, and your own later edits are kept. |
|
|
53
|
+
| 🩺 **Problems, live** | Syntax errors instantly, plus ESLint, TypeScript, Ruff and language servers when your project has them. Shown in the gutter, inline, and in a <kbd>p</kbd> Problems panel. |
|
|
54
|
+
| 🤖 **Self-correcting** | Nou checks its own edits: you see "⚠ +1 problem" *before* approving, and any problems it introduced are fed back so it fixes them. |
|
|
55
|
+
| 🎨 **Real syntax highlighting** | tree-sitter parsing for 14 languages (TS, JS, Python, Go, Rust, Java, C, JSON, YAML…). |
|
|
56
|
+
| 🧭 **Code intelligence** | <kbd>Ctrl</kbd>+<kbd>K</kbd> hover info and <kbd>Ctrl</kbd>+<kbd>G</kbd> go-to-definition via language servers (typescript-language-server, pyright, rust-analyzer, gopls, clangd). |
|
|
57
|
+
| 👀 **Knows what you're looking at** | The open file, visible lines, your cursor and unsaved edits ride along with each message. "Fix this function" just works. |
|
|
58
|
+
| 🌿 **Git aware** | Sees your branch, uncommitted changes and recent commits, and can diff on its own. Press <kbd>v</kbd> to diff any file against `HEAD`. |
|
|
59
|
+
| 🔌 **Any model** | Local (Ollama, LM Studio, llama.cpp) or cloud (Claude, OpenAI, Gemini, OpenRouter, Groq, DeepSeek). Switch mid-conversation. |
|
|
60
|
+
| 🧩 **Plugins** | Add your own tools and slash commands with a single file. |
|
|
61
|
+
| 🔎 **Quick open** | <kbd>Ctrl</kbd>+<kbd>P</kbd> fuzzy-finds any file. |
|
|
62
|
+
|
|
63
|
+
Every change is reviewed like a code review:
|
|
64
|
+
|
|
65
|
+
```diff
|
|
66
|
+
function resolveInProject(filePath: string): string {
|
|
67
|
+
- const fullPath = path.resolve(process.cwd(), filePath);
|
|
68
|
+
+ const root = realpathSync(process.cwd());
|
|
69
|
+
+ const fullPath = path.resolve(root, filePath);
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## ⌨️ Keys
|
|
73
|
+
|
|
74
|
+
| Key | Does |
|
|
75
|
+
| --- | --- |
|
|
76
|
+
| <kbd>Tab</kbd> | switch between files, code and chat |
|
|
77
|
+
| <kbd>Ctrl</kbd>+<kbd>P</kbd> | fuzzy-find a file |
|
|
78
|
+
| <kbd>e</kbd> | edit the open file (<kbd>Ctrl</kbd>+<kbd>S</kbd> save · <kbd>Ctrl</kbd>+<kbd>Z</kbd>/<kbd>Y</kbd> undo/redo · <kbd>Ctrl</kbd>+<kbd>K</kbd> info · <kbd>Ctrl</kbd>+<kbd>G</kbd> definition · <kbd>Esc</kbd> close) |
|
|
79
|
+
| <kbd>p</kbd> | Problems panel: every error and warning, <kbd>Enter</kbd> jumps to it |
|
|
80
|
+
| <kbd>u</kbd> | undo Nou's last edit |
|
|
81
|
+
| <kbd>v</kbd> | diff the open file vs git `HEAD` (or Nou's last edit) · <kbd>n</kbd>/<kbd>N</kbd> jump between changes |
|
|
82
|
+
| <kbd>c</kbd> | in the file tree: show only changed files |
|
|
83
|
+
| <kbd>f</kbd> | follow mode: jump to files as Nou reads and edits them |
|
|
84
|
+
| <kbd>s</kbd> | settings: model, preferences, plugins |
|
|
85
|
+
| <kbd>y</kbd> / <kbd>n</kbd> | accept / reject a proposed edit or command |
|
|
86
|
+
| <kbd>Esc</kbd> | stop Nou · close a diff · back to chat |
|
|
87
|
+
| <kbd>Ctrl</kbd>+<kbd>C</kbd> | stop Nou while it works · press twice to quit |
|
|
88
|
+
|
|
89
|
+
Chat commands: `/settings` · `/model [provider:model]` · `/models [provider]` · `/undo` · `/rewind` · `/history` · `/problems` · `/clear` · `/help`
|
|
90
|
+
|
|
91
|
+
## 🧠 Models
|
|
92
|
+
|
|
93
|
+
Pick one in <kbd>s</kbd> settings, with `/model anthropic` or `/model ollama:qwen3:8b`, or set `NOU_MODEL=provider:model`. API keys come from your environment or a `.env` file in your project; see [.env.example](.env.example).
|
|
94
|
+
|
|
95
|
+
<details>
|
|
96
|
+
<summary><b>All providers and how to set them up</b></summary>
|
|
97
|
+
|
|
98
|
+
| Provider | Type | Setup |
|
|
99
|
+
| --- | --- | --- |
|
|
100
|
+
| 🦙 Ollama *(default)* | 🟢 local | runs locally · `OLLAMA_HOST` to change |
|
|
101
|
+
| 🖥️ LM Studio | 🟢 local | start its local server |
|
|
102
|
+
| ⚙️ llama.cpp server | 🟢 local | start `llama-server` |
|
|
103
|
+
| 🔧 Any OpenAI-compatible server | 🟢 local | `NOU_BASE_URL`, optional `NOU_API_KEY` |
|
|
104
|
+
| 🟣 Anthropic (Claude) | ☁️ cloud | `ANTHROPIC_API_KEY` |
|
|
105
|
+
| ⚪ OpenAI | ☁️ cloud | `OPENAI_API_KEY` |
|
|
106
|
+
| 🔷 Google Gemini | ☁️ cloud | `GEMINI_API_KEY` |
|
|
107
|
+
| 🔀 OpenRouter | ☁️ cloud | `OPENROUTER_API_KEY` |
|
|
108
|
+
| ⚡ Groq | ☁️ cloud | `GROQ_API_KEY` |
|
|
109
|
+
| 🐋 DeepSeek | ☁️ cloud | `DEEPSEEK_API_KEY` |
|
|
110
|
+
|
|
111
|
+
</details>
|
|
112
|
+
|
|
113
|
+
## 🔄 Updating
|
|
114
|
+
|
|
115
|
+
Nou checks npm once a day and offers to update when a new version is out (one keypress, then it restarts itself). Or run it yourself:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
nou update # or: npm install -g nou-cli@latest
|
|
119
|
+
nou --version
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Set `NOU_NO_UPDATE_CHECK=1` to turn the check off.
|
|
123
|
+
|
|
124
|
+
## 🧩 Plugins *(early)*
|
|
125
|
+
|
|
126
|
+
Drop a `.js` / `.mjs` file into `.nou/plugins/` (this project) or `~/.nou/plugins/` (everywhere), then switch it on in <kbd>s</kbd> settings → **Plugins**. A plugin can give the model new tools, add slash commands, add to the system prompt, and add **checkers** (linters) whose problems show in the editor. The built-in ESLint, TypeScript, Ruff, syntax and language-server checkers are plugins too, so you can switch any of them off.
|
|
127
|
+
|
|
128
|
+
```js
|
|
129
|
+
export default {
|
|
130
|
+
name: "word-count",
|
|
131
|
+
tools: [{ readOnly: true, definition: { name: "word_count", /* ... */ }, run: (args) => "42 words" }],
|
|
132
|
+
commands: [{ name: "wc", description: "Count words", run: (args) => "42 words" }],
|
|
133
|
+
diagnostics: [{
|
|
134
|
+
id: "todo", name: "TODOs", checksText: true, matches: (f) => f.endsWith(".ts"), available: () => true,
|
|
135
|
+
check: async (file, text) => text.split("\n").flatMap((l, i) => l.includes("TODO")
|
|
136
|
+
? [{ file, line: i + 1, col: 1, severity: "info", message: "TODO left here", source: "todo" }] : []),
|
|
137
|
+
}],
|
|
138
|
+
};
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Full example: [examples/plugins/word-count.mjs](examples/plugins/word-count.mjs).
|
|
142
|
+
|
|
143
|
+
> [!WARNING]
|
|
144
|
+
> Plugins run code on your machine, so they stay **off** until you enable them.
|
|
145
|
+
|
|
146
|
+
## 🛠️ Development
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
npm install
|
|
150
|
+
npm run dev # run from source
|
|
151
|
+
npm run build # compile to dist/ (and copy the syntax grammars)
|
|
152
|
+
npm test # end-to-end tests: drives the real UI with simulated keys
|
|
153
|
+
npm run screenshot # regenerate docs/screenshot.svg
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Releasing: bump `version` in `package.json`, merge to `master`, then run **Actions → Publish to npm**. It tests, publishes with provenance, and creates the git tag and GitHub Release. (Or publish a GitHub release tagged `vX.Y.Z`.) The token lives in the `production` environment as `NPM_TOKEN`.
|
|
157
|
+
|
|
158
|
+
Upkeep runs on its own:
|
|
159
|
+
- **CI** on every PR: type-check, lint, build, unit and end-to-end tests on Linux, macOS and Windows (Node 22.12 and 24 too), plus installing the packed tarball to check the package works.
|
|
160
|
+
- **Dependabot** opens weekly PRs for dependency and GitHub Actions updates.
|
|
161
|
+
- **CodeQL** scans for security issues on every PR and weekly.
|
|
162
|
+
- **npm audit** fails CI if a dependency users install has a high-severity vulnerability.
|
|
163
|
+
|
|
164
|
+
<p align="center"><sub>Made with 🌞 by <a href="https://github.com/JorgeOchoaReyes">Jorge Ochoa-Reyes</a></sub></p>
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Must stay the first import: loads .env before any provider reads its keys and URLs.
|
|
3
|
+
import "./src/env.js";
|
|
4
|
+
import { createElement } from "react";
|
|
5
|
+
import { render } from "ink";
|
|
6
|
+
import * as p from "@clack/prompts";
|
|
7
|
+
import chalk from "chalk";
|
|
8
|
+
import { artWork } from "./src/util/index.js";
|
|
9
|
+
import { ensureProjectContext, loadPermanentContext } from "./src/context/index.js";
|
|
10
|
+
import { describe, initialModel, resolveModel } from "./src/providers/index.js";
|
|
11
|
+
import { checkForUpdate, currentVersion, installUpdate, relaunch } from "./src/update/index.js";
|
|
12
|
+
import { lsp } from "./src/lsp/index.js";
|
|
13
|
+
import { App } from "./src/ui/App.js";
|
|
14
|
+
const HELP = `nou ${currentVersion()}: an AI coding assistant for your terminal
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
nou open Nou in the current folder
|
|
18
|
+
nou update update to the latest version
|
|
19
|
+
nou --version print the version
|
|
20
|
+
nou --help show this help
|
|
21
|
+
|
|
22
|
+
Settings live in ~/.nou/config.json (or press s inside Nou). Set NOU_NO_UPDATE_CHECK=1 to skip update checks.`;
|
|
23
|
+
async function main() {
|
|
24
|
+
const args = process.argv.slice(2);
|
|
25
|
+
if (args.includes("--version") || args.includes("-v")) {
|
|
26
|
+
console.log(currentVersion());
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
30
|
+
console.log(HELP);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (args[0] === "update") {
|
|
34
|
+
const latest = await checkForUpdate({ force: true, timeout: 10_000 });
|
|
35
|
+
if (!latest) {
|
|
36
|
+
console.log(`nou ${currentVersion()} is up to date.`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
console.log(`Updating nou ${currentVersion()} → ${latest}...`);
|
|
40
|
+
if (await installUpdate(latest))
|
|
41
|
+
process.exit(0);
|
|
42
|
+
if (process.platform !== "win32")
|
|
43
|
+
console.log("If that was a permissions error, run: sudo npm install -g nou-cli@latest");
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
if (!process.stdin.isTTY) {
|
|
47
|
+
console.error("Nou needs an interactive terminal (try Windows Terminal, PowerShell, or Terminal/iTerm on macOS).");
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
p.intro(chalk.cyan(`${artWork} \n Hello I am Nou! 🌞 ${chalk.dim(`v${currentVersion()}`)}`));
|
|
51
|
+
const latest = await checkForUpdate();
|
|
52
|
+
if (latest) {
|
|
53
|
+
const update = await p.confirm({ message: `Nou ${chalk.green(latest)} is available (you have ${currentVersion()}). Update now?` });
|
|
54
|
+
if (update === true) {
|
|
55
|
+
const ok = await installUpdate(latest);
|
|
56
|
+
if (ok) {
|
|
57
|
+
p.log.success(`Updated to ${latest}. Restarting...`);
|
|
58
|
+
relaunch();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
p.log.warn(process.platform === "win32"
|
|
62
|
+
? "The update failed. You can retry later with: npm install -g nou-cli@latest"
|
|
63
|
+
: "The update failed. If it was a permissions error, run: sudo npm install -g nou-cli@latest");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
let choice = initialModel();
|
|
67
|
+
const problem = choice.provider.unavailable();
|
|
68
|
+
if (problem) {
|
|
69
|
+
p.log.warn(`${choice.provider.label} isn't set up (${problem}), falling back to local Ollama.`);
|
|
70
|
+
choice = resolveModel("ollama");
|
|
71
|
+
}
|
|
72
|
+
p.log.info(`Model: ${chalk.yellow(describe(choice))} (change it any time with /model)`);
|
|
73
|
+
await ensureProjectContext(choice);
|
|
74
|
+
const permContext = await loadPermanentContext();
|
|
75
|
+
// Ctrl+C is handled inside the app: press it twice to quit, so a stray press doesn't lose work.
|
|
76
|
+
const app = render(createElement(App, { initialModel: choice, permContext }), {
|
|
77
|
+
alternateScreen: true,
|
|
78
|
+
exitOnCtrlC: false,
|
|
79
|
+
});
|
|
80
|
+
await app.waitUntilExit();
|
|
81
|
+
lsp.stopAll();
|
|
82
|
+
p.outro(chalk.cyan("Bye bye!"));
|
|
83
|
+
}
|
|
84
|
+
main().catch(console.error);
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { toolDefs } from "../tools/index.js";
|
|
2
|
+
import { applyChange, fileTree, proposeEdit, proposeWrite, readFile, runCommand, toRelative, } from "../commands/index.js";
|
|
3
|
+
import { describe } from "../providers/index.js";
|
|
4
|
+
import { newCallId } from "../providers/ids.js";
|
|
5
|
+
const MAX_STEPS = 25;
|
|
6
|
+
export function buildSystemPrompt(permContext, extras = []) {
|
|
7
|
+
return `You are Nou, a coding assistant running in the user's terminal, working in ${process.cwd()}.
|
|
8
|
+
You can read, edit and create files and run commands with the tools you have.
|
|
9
|
+
- Read a file before editing it. Prefer edit_file for small changes and write_file for new files.
|
|
10
|
+
- Every edit and command is shown to the user, who approves or rejects it.
|
|
11
|
+
- Keep replies short and to the point.
|
|
12
|
+
|
|
13
|
+
Project files:
|
|
14
|
+
${fileTree(".", 3)}
|
|
15
|
+
${extras.map((e) => `\n${e}`).join("")}
|
|
16
|
+
${permContext}`;
|
|
17
|
+
}
|
|
18
|
+
/** Checks required arguments are present before a tool runs (models, especially small local ones, skip them). */
|
|
19
|
+
function invalidArgs(call, tools) {
|
|
20
|
+
if ("__invalid_json" in call.args)
|
|
21
|
+
return `The arguments were not valid JSON: ${JSON.stringify(call.args.__invalid_json)}`;
|
|
22
|
+
const def = tools.find((t) => t.name === call.name);
|
|
23
|
+
if (!def)
|
|
24
|
+
return null;
|
|
25
|
+
const missing = def.parameters.required.filter((key) => call.args[key] === undefined || call.args[key] === null);
|
|
26
|
+
return missing.length ? `Missing or non-string arguments: ${missing.join(", ")}` : null;
|
|
27
|
+
}
|
|
28
|
+
export class Agent {
|
|
29
|
+
choice;
|
|
30
|
+
system;
|
|
31
|
+
hooks;
|
|
32
|
+
messages = [];
|
|
33
|
+
controller = null;
|
|
34
|
+
extraTools = [];
|
|
35
|
+
effort;
|
|
36
|
+
notes = [];
|
|
37
|
+
/** Increments per user message; tags changes so a whole turn can be rewound. */
|
|
38
|
+
turn = 0;
|
|
39
|
+
/** Distinguishes this run from earlier ones, since turn numbers restart at 1 every session. */
|
|
40
|
+
session = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
|
41
|
+
get turnId() {
|
|
42
|
+
return `${this.session}:${this.turn}`;
|
|
43
|
+
}
|
|
44
|
+
constructor(choice, system, hooks) {
|
|
45
|
+
this.choice = choice;
|
|
46
|
+
this.system = system;
|
|
47
|
+
this.hooks = hooks;
|
|
48
|
+
}
|
|
49
|
+
configure(options) {
|
|
50
|
+
if (options.system !== undefined)
|
|
51
|
+
this.system = options.system;
|
|
52
|
+
if (options.extraTools)
|
|
53
|
+
this.extraTools = options.extraTools;
|
|
54
|
+
if (options.effort)
|
|
55
|
+
this.effort = options.effort;
|
|
56
|
+
}
|
|
57
|
+
get tools() {
|
|
58
|
+
return [...toolDefs, ...this.extraTools.map((t) => t.definition)];
|
|
59
|
+
}
|
|
60
|
+
/** Switches model; the conversation carries over. */
|
|
61
|
+
setModel(choice) {
|
|
62
|
+
this.choice = choice;
|
|
63
|
+
}
|
|
64
|
+
clear() {
|
|
65
|
+
this.messages = [];
|
|
66
|
+
this.notes = [];
|
|
67
|
+
}
|
|
68
|
+
/** Something the model should know before the next message (e.g. the user undid its edit). */
|
|
69
|
+
note(text) {
|
|
70
|
+
this.notes.push(text);
|
|
71
|
+
}
|
|
72
|
+
cancel() {
|
|
73
|
+
this.controller?.abort();
|
|
74
|
+
}
|
|
75
|
+
/** `context` (what the user is looking at) is sent to the model but not shown in the chat. */
|
|
76
|
+
async send(text, context) {
|
|
77
|
+
this.turn++;
|
|
78
|
+
const notes = this.notes.length ? `${this.notes.map((n) => `[Note: ${n}]`).join("\n")}\n\n` : "";
|
|
79
|
+
this.notes = [];
|
|
80
|
+
this.messages.push({ role: "user", content: `${notes}${text}${context ? `\n\n${context}` : ""}` });
|
|
81
|
+
this.controller = new AbortController();
|
|
82
|
+
const { signal } = this.controller;
|
|
83
|
+
try {
|
|
84
|
+
for (let step = 0; step < MAX_STEPS; step++) {
|
|
85
|
+
this.hooks.onBusy(`Pondering with ${describe(this.choice)}...`);
|
|
86
|
+
let streamed = "";
|
|
87
|
+
const result = await this.choice.provider.chat({
|
|
88
|
+
model: this.choice.model,
|
|
89
|
+
system: this.system,
|
|
90
|
+
messages: this.messages,
|
|
91
|
+
tools: this.tools,
|
|
92
|
+
signal,
|
|
93
|
+
...(this.effort ? { effort: this.effort } : {}),
|
|
94
|
+
onText: (delta) => {
|
|
95
|
+
streamed += delta;
|
|
96
|
+
this.hooks.onStream(streamed);
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
this.hooks.onStream("");
|
|
100
|
+
// Tool-call ids must be unique across the conversation (results are matched by id, and some APIs
|
|
101
|
+
// reject duplicates). Some providers reuse ids, so rename any repeat. Claude's own copy of the turn
|
|
102
|
+
// (raw) keeps its ids, which the API guarantees are unique.
|
|
103
|
+
const used = new Set(this.messages.flatMap((m) => (m.role === "assistant" ? m.toolCalls.map((c) => c.id) : [])));
|
|
104
|
+
for (const call of result.toolCalls) {
|
|
105
|
+
if (used.has(call.id) && !result.raw)
|
|
106
|
+
call.id = newCallId();
|
|
107
|
+
used.add(call.id);
|
|
108
|
+
}
|
|
109
|
+
this.messages.push({
|
|
110
|
+
role: "assistant",
|
|
111
|
+
content: result.text,
|
|
112
|
+
toolCalls: result.toolCalls,
|
|
113
|
+
...(result.raw ? { raw: result.raw } : {}),
|
|
114
|
+
});
|
|
115
|
+
const visible = result.text.replace(/<think>[\s\S]*?<\/think>/g, "").trim();
|
|
116
|
+
if (visible)
|
|
117
|
+
this.hooks.onEntry({ kind: "assistant", text: visible });
|
|
118
|
+
if (result.stop === "refusal") {
|
|
119
|
+
this.hooks.onEntry({ kind: "error", text: "The model declined this request." });
|
|
120
|
+
this.dropUnansweredCalls();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (result.stop === "max_tokens") {
|
|
124
|
+
this.hooks.onEntry({ kind: "error", text: "The response was cut off before its tool call finished. Try asking for a smaller change." });
|
|
125
|
+
this.dropUnansweredCalls();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (result.toolCalls.length === 0)
|
|
129
|
+
return;
|
|
130
|
+
for (const call of result.toolCalls) {
|
|
131
|
+
if (signal.aborted)
|
|
132
|
+
break;
|
|
133
|
+
this.hooks.onBusy(`Running ${call.name}...`);
|
|
134
|
+
const problem = invalidArgs(call, this.tools);
|
|
135
|
+
const output = problem ?? await this.runTool(call);
|
|
136
|
+
this.messages.push({
|
|
137
|
+
role: "tool",
|
|
138
|
+
callId: call.id,
|
|
139
|
+
name: call.name,
|
|
140
|
+
content: problem ? `Error: ${problem}` : output,
|
|
141
|
+
...(problem ? { isError: true } : {}),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
if (signal.aborted)
|
|
145
|
+
throw new Error("aborted");
|
|
146
|
+
}
|
|
147
|
+
this.hooks.onEntry({ kind: "error", text: `Stopped after ${MAX_STEPS} steps.` });
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
this.hooks.onStream("");
|
|
151
|
+
if (signal.aborted) {
|
|
152
|
+
this.hooks.onEntry({ kind: "info", text: "Cancelled." });
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
this.hooks.onEntry({ kind: "error", text: `${describe(this.choice)}: ${error.message}` });
|
|
156
|
+
}
|
|
157
|
+
this.dropUnansweredCalls();
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
this.controller = null;
|
|
161
|
+
this.hooks.onBusy(null);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/** Every tool call needs a result before the next request, or most APIs reject the history. */
|
|
165
|
+
dropUnansweredCalls() {
|
|
166
|
+
const answered = new Set(this.messages.flatMap((m) => (m.role === "tool" ? [m.callId] : [])));
|
|
167
|
+
for (const m of this.messages) {
|
|
168
|
+
if (m.role !== "assistant")
|
|
169
|
+
continue;
|
|
170
|
+
for (const call of m.toolCalls) {
|
|
171
|
+
if (answered.has(call.id))
|
|
172
|
+
continue;
|
|
173
|
+
this.messages.push({ role: "tool", callId: call.id, name: call.name, content: "Not run: the turn was interrupted.", isError: true });
|
|
174
|
+
answered.add(call.id);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
async runTool(call) {
|
|
179
|
+
const { name, args } = call;
|
|
180
|
+
try {
|
|
181
|
+
switch (name) {
|
|
182
|
+
case "read_file": {
|
|
183
|
+
this.hooks.onRead(toRelative(args.path));
|
|
184
|
+
this.hooks.onEntry({ kind: "tool", text: `Read ${args.path}` });
|
|
185
|
+
return await readFile(args.path);
|
|
186
|
+
}
|
|
187
|
+
case "list_files":
|
|
188
|
+
case "get_file_tree": {
|
|
189
|
+
this.hooks.onEntry({ kind: "tool", text: `Listed ${args.path ?? "."}` });
|
|
190
|
+
return fileTree(args.path ?? ".", 4);
|
|
191
|
+
}
|
|
192
|
+
case "edit_file":
|
|
193
|
+
case "write_file": {
|
|
194
|
+
const change = name === "edit_file"
|
|
195
|
+
? await proposeEdit(args.path, args.old_string, args.new_string)
|
|
196
|
+
: await proposeWrite(args.path, args.content ?? "");
|
|
197
|
+
return await this.reviewChange(change);
|
|
198
|
+
}
|
|
199
|
+
case "run_command": {
|
|
200
|
+
const ok = await this.hooks.approve({ kind: "command", command: args.command });
|
|
201
|
+
if (!ok) {
|
|
202
|
+
this.hooks.onEntry({ kind: "tool", text: `Denied: ${args.command}` });
|
|
203
|
+
return "User denied permission to run this command.";
|
|
204
|
+
}
|
|
205
|
+
this.hooks.onEntry({ kind: "tool", text: `Ran: ${args.command}` });
|
|
206
|
+
const output = await runCommand(args.command);
|
|
207
|
+
return output.trim() || "(no output)";
|
|
208
|
+
}
|
|
209
|
+
default: {
|
|
210
|
+
const extra = this.extraTools.find((t) => t.definition.name === name);
|
|
211
|
+
if (!extra)
|
|
212
|
+
return `Unknown tool: ${name}`;
|
|
213
|
+
if (!extra.readOnly && !(await this.hooks.approve({ kind: "tool", name, args }))) {
|
|
214
|
+
this.hooks.onEntry({ kind: "tool", text: `Denied: ${name}` });
|
|
215
|
+
return "User denied permission to run this tool.";
|
|
216
|
+
}
|
|
217
|
+
this.hooks.onEntry({ kind: "tool", text: `${extra.source}: ${name}${args.path ? ` ${args.path}` : ""}` });
|
|
218
|
+
return String(await extra.run(args));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
this.hooks.onEntry({ kind: "error", text: `${name}: ${error.message}` });
|
|
224
|
+
return `Error: ${error.message}`;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
async reviewChange(change) {
|
|
228
|
+
this.hooks.onChange(change, "pending", this.turnId);
|
|
229
|
+
const ok = await this.hooks.approve({ kind: "change", change });
|
|
230
|
+
if (!ok) {
|
|
231
|
+
this.hooks.onChange(change, "rejected", this.turnId);
|
|
232
|
+
this.hooks.onEntry({ kind: "tool", text: `Rejected edit to ${change.path}` });
|
|
233
|
+
return "User rejected this change. Ask them what they would like instead.";
|
|
234
|
+
}
|
|
235
|
+
const result = await applyChange(change);
|
|
236
|
+
this.hooks.onChange(change, "applied", this.turnId);
|
|
237
|
+
this.hooks.onEntry({ kind: "tool", text: result });
|
|
238
|
+
if (result.startsWith("Failed"))
|
|
239
|
+
return result;
|
|
240
|
+
this.hooks.onBusy(`Checking ${change.path} for problems...`);
|
|
241
|
+
const problems = await this.hooks.checkChange?.(change).catch(() => null);
|
|
242
|
+
if (problems)
|
|
243
|
+
this.hooks.onEntry({ kind: "error", text: `New problems in ${change.path}:\n${problems}` });
|
|
244
|
+
return problems ? `${result}\n\nThis edit introduced problems; fix them:\n${problems}` : result;
|
|
245
|
+
}
|
|
246
|
+
}
|