nou-cli 0.3.0 → 0.4.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 +1 -0
- package/README.md +9 -6
- package/dist/src/agent/index.js +3 -2
- package/dist/src/config/index.js +2 -1
- package/dist/src/providers/index.js +16 -0
- package/dist/src/providers/openai.js +7 -4
- package/dist/src/syntax/index.js +11 -2
- package/dist/src/ui/App.js +143 -33
- package/dist/src/ui/Chat.js +19 -10
- package/dist/src/ui/CodeView.js +1 -1
- package/dist/src/ui/Editor.js +1 -1
- package/dist/src/ui/FileTree.js +12 -7
- package/dist/src/ui/Settings.js +2 -4
- package/dist/src/ui/clipboard.js +29 -0
- package/dist/src/ui/mouse.js +4 -2
- package/dist/src/ui/text.js +155 -0
- package/package.json +1 -1
package/.env.example
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
# A LiteLLM proxy, or any hosted OpenAI-compatible gateway (full cloud context, unlike NOU_BASE_URL):
|
|
30
30
|
# LITELLM_BASE_URL=http://localhost:4000/v1
|
|
31
31
|
# LITELLM_API_KEY=
|
|
32
|
+
# (LITELLM_PROXY_API_BASE and LITELLM_PROXY_API_KEY, the names LiteLLM's SDK uses, work too.)
|
|
32
33
|
# NOU_MODEL=litellm:<a model name from your proxy>
|
|
33
34
|
# Or skip this file: /connect <provider> inside Nou saves keys to ~/.nou/credentials.json.
|
|
34
35
|
|
package/README.md
CHANGED
|
@@ -76,22 +76,25 @@ Every change is reviewed like a code review:
|
|
|
76
76
|
|
|
77
77
|
| Key | Does |
|
|
78
78
|
| --- | --- |
|
|
79
|
-
| 🖱️ Mouse | wheel scrolls the pane under the pointer · click a pane to focus it · click a file to open it (hold <kbd>Shift</kbd> to select text
|
|
79
|
+
| 🖱️ Mouse | wheel scrolls the pane under the pointer · click a pane to focus it · click a file to open it · drag a border between panes to resize them (hold <kbd>Shift</kbd> to select text, or press <kbd>m</kbd> to turn the mouse off) |
|
|
80
80
|
| <kbd>Shift</kbd>+<kbd>↑</kbd>/<kbd>↓</kbd>, <kbd>PgUp</kbd>/<kbd>PgDn</kbd> | scroll the chat history |
|
|
81
81
|
| <kbd>Tab</kbd> | switch between files, code and chat |
|
|
82
|
+
| <kbd>Shift</kbd>+<kbd>Tab</kbd> | ask → auto-edit (edits apply without asking) → auto (commands too); everything stays undoable |
|
|
82
83
|
| <kbd>Ctrl</kbd>+<kbd>P</kbd> | fuzzy-find a file |
|
|
83
84
|
| <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) |
|
|
84
85
|
| <kbd>p</kbd> | Problems panel: every error and warning, <kbd>Enter</kbd> jumps to it |
|
|
85
86
|
| <kbd>u</kbd> | undo Nou's last edit |
|
|
86
87
|
| <kbd>v</kbd> | diff the open file vs git `HEAD`, Nou's last edit, or this session · <kbd>n</kbd>/<kbd>N</kbd> jump between changes · <kbd>[</kbd>/<kbd>]</kbd> previous/next file in a session diff |
|
|
87
88
|
| <kbd>c</kbd> | in the file tree: show only changed files |
|
|
88
|
-
| <kbd>f</kbd> | follow mode: jump to files as Nou reads and edits them |
|
|
89
|
+
| <kbd>f</kbd> | follow mode (off by default): jump to files as Nou reads and edits them |
|
|
90
|
+
| <kbd><</kbd> / <kbd>></kbd> / <kbd>=</kbd> | make the focused pane narrower / wider / reset the layout |
|
|
91
|
+
| <kbd>m</kbd> | mouse on/off, so you can select and copy text in the terminal |
|
|
89
92
|
| <kbd>s</kbd> | settings: model, preferences, plugins |
|
|
90
|
-
| <kbd>y</kbd> / <kbd>n</kbd> | accept / reject a proposed edit or command |
|
|
93
|
+
| <kbd>y</kbd> / <kbd>n</kbd> | accept / reject a proposed edit or command, from any pane; the rest of Nou keeps working while it waits (<kbd>o</kbd> shows the edit) |
|
|
91
94
|
| <kbd>Esc</kbd> | stop Nou · close a diff · back to chat |
|
|
92
95
|
| <kbd>Ctrl</kbd>+<kbd>C</kbd> | stop Nou while it works · press twice to quit |
|
|
93
96
|
|
|
94
|
-
Chat commands: `/settings` · `/model [provider:model]` · `/models [provider]` · `/pull <model>` · `/engine [name]` · `/undo` · `/rewind` · `/history` · `/diff [file or n]` · `/sessions` · `/problems` · `/compact` · `/clear` · `/help`
|
|
97
|
+
Chat commands: `/settings` · `/model [provider:model]` · `/models [provider]` · `/pull <model>` · `/engine [name]` · `/auto [ask|auto-edit|auto]` · `/copy [code|file]` · `/undo` · `/rewind` · `/history` · `/diff [file or n]` · `/sessions` · `/problems` · `/compact` · `/clear` · `/help`
|
|
95
98
|
|
|
96
99
|
## 🧠 Models
|
|
97
100
|
|
|
@@ -108,9 +111,9 @@ If you haven't set a key, Nou uses the API key from Claude Code (`~/.claude/sett
|
|
|
108
111
|
| 🖥️ LM Studio | 🟢 local | start its local server |
|
|
109
112
|
| ⚙️ llama.cpp server | 🟢 local | start `llama-server` |
|
|
110
113
|
| 🔧 Any OpenAI-compatible server | 🟢 local | `NOU_BASE_URL`, optional `NOU_API_KEY` |
|
|
111
|
-
| 🚅 LiteLLM proxy or any hosted gateway | ☁️ cloud | `/connect litellm`, or `LITELLM_BASE_URL` + `LITELLM_API_KEY` |
|
|
114
|
+
| 🚅 LiteLLM proxy or any hosted gateway | ☁️ cloud | `/connect litellm`, or `LITELLM_BASE_URL` + `LITELLM_API_KEY` (LiteLLM's `LITELLM_PROXY_API_BASE` + `LITELLM_PROXY_API_KEY` work too) |
|
|
112
115
|
| 🟣 Anthropic (Claude) | ☁️ cloud | `ANTHROPIC_API_KEY` |
|
|
113
|
-
| ⚪ OpenAI | ☁️ cloud | `OPENAI_API_KEY` |
|
|
116
|
+
| ⚪ OpenAI | ☁️ cloud | `OPENAI_API_KEY`, optional `OPENAI_BASE_URL` |
|
|
114
117
|
| 🔷 Google Gemini | ☁️ cloud | `GEMINI_API_KEY` |
|
|
115
118
|
| 🔀 OpenRouter | ☁️ cloud | `OPENROUTER_API_KEY` |
|
|
116
119
|
| ⚡ Groq | ☁️ cloud | `GROQ_API_KEY` |
|
package/dist/src/agent/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "crypto";
|
|
2
2
|
import { toolDefs } from "../tools/index.js";
|
|
3
3
|
import { applyChange, capOutput, fileTree, isInProject, proposeEdit, proposeWrite, readForModel, runCommand, toRelative, } from "../commands/index.js";
|
|
4
|
-
import { describe } from "../providers/index.js";
|
|
4
|
+
import { describe, keyHint } from "../providers/index.js";
|
|
5
5
|
import { newCallId } from "../providers/ids.js";
|
|
6
6
|
import { globFiles, grep } from "../search/index.js";
|
|
7
7
|
/** Model requests per user message before stopping; real tasks (explore, edit, test, fix) take a few dozen. */
|
|
@@ -272,7 +272,8 @@ export class Agent {
|
|
|
272
272
|
this.hooks.onEntry({ kind: "info", text: "Cancelled." });
|
|
273
273
|
}
|
|
274
274
|
else {
|
|
275
|
-
|
|
275
|
+
const hint = keyHint(this.choice.provider, String(error.message));
|
|
276
|
+
this.hooks.onEntry({ kind: "error", text: `${describe(this.choice)}: ${error.message}${hint ? `\n${hint}` : ""}` });
|
|
276
277
|
}
|
|
277
278
|
this.dropUnansweredCalls();
|
|
278
279
|
}
|
package/dist/src/config/index.js
CHANGED
|
@@ -9,13 +9,14 @@ export const DEFAULT_SETTINGS = {
|
|
|
9
9
|
model: "",
|
|
10
10
|
engine: "nou",
|
|
11
11
|
effort: "high",
|
|
12
|
-
followMode:
|
|
12
|
+
followMode: false,
|
|
13
13
|
autoApproveEdits: false,
|
|
14
14
|
autoApproveCommands: false,
|
|
15
15
|
gitContext: true,
|
|
16
16
|
autoSave: "delay",
|
|
17
17
|
mouse: true,
|
|
18
18
|
reuseToolConfig: true,
|
|
19
|
+
paneWidths: null,
|
|
19
20
|
plugins: {},
|
|
20
21
|
trustedPlugins: {},
|
|
21
22
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { loadSettings, saveSettings } from "../config/index.js";
|
|
2
|
+
import { originOf } from "../config/origins.js";
|
|
2
3
|
import { anthropicProvider } from "./anthropic.js";
|
|
3
4
|
import { ollamaProvider } from "./ollama.js";
|
|
4
5
|
import { OPENAI_COMPATIBLE, openAICompatibleProvider } from "./openai.js";
|
|
@@ -24,6 +25,21 @@ export function resolveModel(spec) {
|
|
|
24
25
|
return { provider: providers[0], model: trimmed };
|
|
25
26
|
}
|
|
26
27
|
export const describe = (choice) => `${choice.provider.id}:${choice.model}`;
|
|
28
|
+
/** Whether a provider error means the API key is wrong (rather than, say, the server being down). */
|
|
29
|
+
export const rejectedKey = (error) => /\b(401|403)\b|api key|unauthori[sz]ed|authentication/i.test(error);
|
|
30
|
+
/**
|
|
31
|
+
* For an error that says the key was rejected: which key, where it was set, and how to fix it.
|
|
32
|
+
* The usual cause is a project's .env holding a key for the app rather than for Nou.
|
|
33
|
+
*/
|
|
34
|
+
export function keyHint(provider, error) {
|
|
35
|
+
const key = provider.connect?.find((f) => f.secret)?.env;
|
|
36
|
+
if (!key || !rejectedKey(error))
|
|
37
|
+
return null;
|
|
38
|
+
const origin = originOf(key);
|
|
39
|
+
const others = providers.filter((p) => p !== provider && !p.local && !p.unavailable()).map((p) => p.id);
|
|
40
|
+
return `The ${key}${origin ? ` from ${origin}` : ""} was rejected. Enter another with /connect ${provider.id}`
|
|
41
|
+
+ (others.length ? `, or switch with /model ${others.join(" · /model ")}.` : ", or connect another provider (e.g. /connect litellm).");
|
|
42
|
+
}
|
|
27
43
|
/** NOU_MODEL wins, then the model saved in settings, then local Ollama. */
|
|
28
44
|
export function initialModel() {
|
|
29
45
|
if (process.env.NOU_MODEL)
|
|
@@ -2,14 +2,16 @@ import OpenAI from "openai";
|
|
|
2
2
|
import { newCallId } from "./ids.js";
|
|
3
3
|
/** Everything that speaks the OpenAI chat completions API. */
|
|
4
4
|
export const OPENAI_COMPATIBLE = [
|
|
5
|
-
{ id: "openai", label: "OpenAI", baseURL: "https://api.openai.com/v1", keyEnv: "OPENAI_API_KEY", defaultModel: "gpt-5", local: false },
|
|
5
|
+
{ id: "openai", label: "OpenAI", baseURL: "https://api.openai.com/v1", urlEnv: "OPENAI_BASE_URL", keyEnv: "OPENAI_API_KEY", defaultModel: "gpt-5", local: false },
|
|
6
6
|
{ id: "gemini", label: "Google Gemini", baseURL: "https://generativelanguage.googleapis.com/v1beta/openai/", keyEnv: "GEMINI_API_KEY", defaultModel: "gemini-2.5-pro", local: false },
|
|
7
7
|
{ id: "openrouter", label: "OpenRouter", baseURL: "https://openrouter.ai/api/v1", keyEnv: "OPENROUTER_API_KEY", defaultModel: "anthropic/claude-opus-5-5", local: false },
|
|
8
8
|
{ id: "groq", label: "Groq", baseURL: "https://api.groq.com/openai/v1", keyEnv: "GROQ_API_KEY", defaultModel: "llama-3.3-70b-versatile", local: false },
|
|
9
9
|
{ id: "deepseek", label: "DeepSeek", baseURL: "https://api.deepseek.com/v1", keyEnv: "DEEPSEEK_API_KEY", defaultModel: "deepseek-chat", local: false },
|
|
10
10
|
{ id: "fireworks", label: "Fireworks AI", baseURL: "https://api.fireworks.ai/inference/v1", keyEnv: "FIREWORKS_API_KEY", defaultModel: "accounts/fireworks/models/kimi-k2-instruct", local: false, keyHint: "fireworks.ai/account/api-keys" },
|
|
11
11
|
// A LiteLLM proxy, or any hosted OpenAI-compatible gateway: cloud-sized context, unlike "custom".
|
|
12
|
-
{ id: "litellm", label: "LiteLLM / gateway", baseURL: "", urlEnv: "LITELLM_BASE_URL", keyEnv: "LITELLM_API_KEY", defaultModel: "gpt-5", local: false, keyHint: "your proxy's virtual key or master key"
|
|
12
|
+
{ id: "litellm", label: "LiteLLM / gateway", baseURL: "", urlEnv: "LITELLM_BASE_URL", keyEnv: "LITELLM_API_KEY", defaultModel: "gpt-5", local: false, keyHint: "your proxy's virtual key or master key",
|
|
13
|
+
// The names LiteLLM's own SDK uses for a proxy (its litellm_proxy provider).
|
|
14
|
+
aliases: { url: ["LITELLM_PROXY_API_BASE"], key: ["LITELLM_PROXY_API_KEY"] } },
|
|
13
15
|
{ id: "lmstudio", label: "LM Studio", baseURL: "http://localhost:1234/v1", urlEnv: "LMSTUDIO_BASE_URL", defaultModel: "local-model", local: true },
|
|
14
16
|
{ id: "llamacpp", label: "llama.cpp server", baseURL: "http://localhost:8080/v1", urlEnv: "LLAMACPP_BASE_URL", defaultModel: "local-model", local: true },
|
|
15
17
|
{ id: "custom", label: "Custom OpenAI-compatible", baseURL: "", urlEnv: "NOU_BASE_URL", keyEnv: "NOU_API_KEY", defaultModel: "default", local: true },
|
|
@@ -41,8 +43,9 @@ function toOpenAI(system, messages) {
|
|
|
41
43
|
}
|
|
42
44
|
export function openAICompatibleProvider(preset) {
|
|
43
45
|
// Read when used, not when loaded: keys found in other tools' config (see setup/) are added at startup.
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
+
const first = (names) => names.map((n) => n && process.env[n]).find(Boolean);
|
|
47
|
+
const baseURL = () => first([preset.urlEnv, ...(preset.aliases?.url ?? [])]) || preset.baseURL;
|
|
48
|
+
const apiKey = () => first([preset.keyEnv, ...(preset.aliases?.key ?? [])]) || (preset.local ? "not-needed" : "");
|
|
46
49
|
// Rebuilt when the URL or key changes (they can be entered while Nou runs).
|
|
47
50
|
let client = null;
|
|
48
51
|
const getClient = () => {
|
package/dist/src/syntax/index.js
CHANGED
|
@@ -66,8 +66,17 @@ function parse(file, text) {
|
|
|
66
66
|
load(name);
|
|
67
67
|
if (!lang || typeof lang === "string" || !parser)
|
|
68
68
|
return null;
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
try {
|
|
70
|
+
parser.setLanguage(lang);
|
|
71
|
+
return parser.parse(text);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// Some shipped grammars call into the runtime with functions it doesn't have (e.g. YAML's scanner).
|
|
75
|
+
// Don't use that grammar again; callers fall back to highlight.js.
|
|
76
|
+
languages.set(name, "failed");
|
|
77
|
+
parser = new Parser();
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
71
80
|
}
|
|
72
81
|
export function hasGrammar(file) {
|
|
73
82
|
return grammarFor(file) !== undefined;
|
package/dist/src/ui/App.js
CHANGED
|
@@ -24,10 +24,14 @@ import { FileTree } from "./FileTree.js";
|
|
|
24
24
|
import { CodeView } from "./CodeView.js";
|
|
25
25
|
import { Editor } from "./Editor.js";
|
|
26
26
|
import { Chat } from "./Chat.js";
|
|
27
|
+
import { copyToClipboard } from "./clipboard.js";
|
|
27
28
|
import { Header } from "./Header.js";
|
|
28
29
|
import { QuickOpen, listAllFiles } from "./QuickOpen.js";
|
|
29
30
|
import { Settings } from "./Settings.js";
|
|
30
31
|
const FOCUS_ORDER = ["tree", "code", "chat"];
|
|
32
|
+
const MIN_TREE = 14;
|
|
33
|
+
const MIN_CODE = 20;
|
|
34
|
+
const MIN_CHAT = 24;
|
|
31
35
|
const NO_LINES = new Set();
|
|
32
36
|
const HELP = `Commands:
|
|
33
37
|
/settings models, preferences and plugins
|
|
@@ -37,6 +41,8 @@ const HELP = `Commands:
|
|
|
37
41
|
/connect <provider> enter an API key (and URL), e.g. /connect fireworks /connect litellm
|
|
38
42
|
/pull <model> download an Ollama model
|
|
39
43
|
/engine [name] which agent answers: nou, claude-code, codex or pi
|
|
44
|
+
/auto [ask|auto-edit|auto] how much Nou does without asking (also: Shift+Tab)
|
|
45
|
+
/copy [code|file] copy the last reply, its last code block, or the open file
|
|
40
46
|
/plugins your plugins and the starter ones you can add
|
|
41
47
|
/plugins add <name> add a starter plugin (copied to ~/.nou/plugins to edit)
|
|
42
48
|
/plugins new <name> start your own plugin in .nou/plugins/
|
|
@@ -50,6 +56,8 @@ const HELP = `Commands:
|
|
|
50
56
|
/version which version of Nou this is
|
|
51
57
|
Keys:
|
|
52
58
|
Ctrl+P find a file Tab switch pane Esc stop / back
|
|
59
|
+
Shift+Tab ask / auto-edit / auto < > resize pane = reset
|
|
60
|
+
m mouse on/off (off: select text to copy; Shift+drag also works in most terminals)
|
|
53
61
|
e edit open file v diff (git / Nou / session) f follow mode
|
|
54
62
|
p problems u undo Nou's edit s settings
|
|
55
63
|
c (in files) show only changed files Ctrl+C twice quit
|
|
@@ -124,6 +132,11 @@ export function App({ initialModel, permContext }) {
|
|
|
124
132
|
const [treeClick, setTreeClick] = useState(null);
|
|
125
133
|
// Pane geometry from the last render, so mouse events can be mapped to the pane under the pointer.
|
|
126
134
|
const layout = useRef({ treeWidth: 0, codeWidth: 0, paneHeight: 0 });
|
|
135
|
+
/** Pane widths while a border is dragged (saved on release); the saved ones otherwise. */
|
|
136
|
+
const [paneWidths, setPaneWidths] = useState(settings.paneWidths);
|
|
137
|
+
const paneWidthsRef = useRef(paneWidths);
|
|
138
|
+
paneWidthsRef.current = paneWidths;
|
|
139
|
+
const dragging = useRef(null);
|
|
127
140
|
const pullAbort = useRef(null);
|
|
128
141
|
const diagnosticsRef = useRef(null);
|
|
129
142
|
diagnosticsRef.current ??= new DiagnosticsManager();
|
|
@@ -132,6 +145,8 @@ export function App({ initialModel, permContext }) {
|
|
|
132
145
|
timelineRef.current ??= new Timeline(() => setHistoryVersion((v) => v + 1));
|
|
133
146
|
const timeline = timelineRef.current;
|
|
134
147
|
const followRef = useRef(follow);
|
|
148
|
+
/** Whether the editor is open, for the agent hooks (which outlive renders). */
|
|
149
|
+
const draftActiveRef = useRef(false);
|
|
135
150
|
followRef.current = follow;
|
|
136
151
|
const settingsRef = useRef(settings);
|
|
137
152
|
settingsRef.current = settings;
|
|
@@ -169,8 +184,8 @@ export function App({ initialModel, permContext }) {
|
|
|
169
184
|
}
|
|
170
185
|
const lines = status === "applied" ? changedLines(change.before, change.after) : NO_LINES;
|
|
171
186
|
setChanges((prev) => new Map(prev).set(change.path, { change, status, lines }));
|
|
172
|
-
// Diffs awaiting approval are
|
|
173
|
-
if (status === "pending" || followRef.current) {
|
|
187
|
+
// Diffs awaiting approval are shown unless you're in the middle of editing a file (the chat says what's waiting).
|
|
188
|
+
if ((status === "pending" && !draftActiveRef.current) || followRef.current) {
|
|
174
189
|
setOpenFile(change.path);
|
|
175
190
|
setContent(status === "applied" ? change.after : readText(change.path));
|
|
176
191
|
setDiffMode("off");
|
|
@@ -280,18 +295,19 @@ export function App({ initialModel, permContext }) {
|
|
|
280
295
|
if (!isInstalled(target)) {
|
|
281
296
|
const controller = new AbortController();
|
|
282
297
|
pullAbort.current = controller;
|
|
283
|
-
info(
|
|
284
|
-
setBusy(`Installing ${target.label}...`);
|
|
298
|
+
info(`${target.label} isn't installed yet, so Nou is installing it now: npm install ${target.package} into ~/.nou/engines. This happens once and can take a minute. Esc to stop.`);
|
|
299
|
+
setBusy(`Installing the ${target.label} engine (npm)...`);
|
|
285
300
|
try {
|
|
286
|
-
await installEngine(target, (line) => setBusy(`Installing ${target.label}: ${line.slice(0,
|
|
301
|
+
await installEngine(target, (line) => setBusy(`Installing the ${target.label} engine (npm): ${line.slice(0, 70)}`), controller.signal);
|
|
287
302
|
}
|
|
288
303
|
catch (error) {
|
|
289
|
-
return info(controller.signal.aborted ? `Stopped installing ${target.label}.` : `Couldn't install ${target.label}: ${error.message}`, "error");
|
|
304
|
+
return info(controller.signal.aborted ? `Stopped installing ${target.label}. Nothing changed; you're still on ${engineInfo(agentRef.current.id).label}.` : `Couldn't install ${target.label}: ${error.message}`, "error");
|
|
290
305
|
}
|
|
291
306
|
finally {
|
|
292
307
|
pullAbort.current = null;
|
|
293
308
|
setBusy(null);
|
|
294
309
|
}
|
|
310
|
+
info(`Installed ${target.label} (${target.package}).`);
|
|
295
311
|
}
|
|
296
312
|
agentRef.current.dispose?.();
|
|
297
313
|
const engine = createEngine(id, choice, engineConfig.current.system ?? buildSystemPrompt(permContext), hooksRef.current);
|
|
@@ -365,16 +381,40 @@ export function App({ initialModel, permContext }) {
|
|
|
365
381
|
approval?.resolve(ok);
|
|
366
382
|
setApproval(null);
|
|
367
383
|
};
|
|
384
|
+
/** Shows the edit waiting for approval in the code view. */
|
|
385
|
+
const openPending = () => {
|
|
386
|
+
const file = approval?.request.kind === "change" ? approval.request.change.path : null;
|
|
387
|
+
if (!file || !settleDraft(file))
|
|
388
|
+
return;
|
|
389
|
+
setEditing(false);
|
|
390
|
+
open(file);
|
|
391
|
+
setReveal(file);
|
|
392
|
+
setFocus("code");
|
|
393
|
+
};
|
|
394
|
+
/** Ask → Auto-edit (edits apply without asking) → Auto (commands too) → Ask. Undo and diffs work the same in each. */
|
|
395
|
+
const autoMode = settings.autoApproveCommands ? "auto" : settings.autoApproveEdits ? "auto-edit" : "ask";
|
|
396
|
+
const cycleAutoMode = (to) => {
|
|
397
|
+
const next = to ?? (autoMode === "ask" ? "auto-edit" : autoMode === "auto-edit" ? "auto" : "ask");
|
|
398
|
+
updateSettings({ autoApproveEdits: next !== "ask", autoApproveCommands: next === "auto" });
|
|
399
|
+
// Whatever is waiting now falls under the new mode too.
|
|
400
|
+
const kind = approval?.request.kind;
|
|
401
|
+
if (kind && (next === "auto" || (next === "auto-edit" && kind === "change")))
|
|
402
|
+
answer(true);
|
|
403
|
+
info(next === "ask" ? "Ask mode: Nou asks before every edit and command."
|
|
404
|
+
: next === "auto-edit" ? "Auto-edit mode: edits apply without asking; Nou still asks before commands. Review with the diff view, u to undo."
|
|
405
|
+
: "Auto mode: edits and commands run without asking. Review with the diff view, u to undo. Shift+Tab to change.");
|
|
406
|
+
};
|
|
368
407
|
const record = openFile ? changes.get(openFile) : undefined;
|
|
369
408
|
const pendingChange = approval?.request.kind === "change" ? approval.request.change : null;
|
|
370
409
|
const gitChange = useMemo(() => (diffMode === "git" && openFile ? diffAgainstHead(openFile, content) : null), [diffMode, openFile, content]);
|
|
371
410
|
const sessionId = sessionView?.id ?? agentRef.current.session;
|
|
372
411
|
const sessionFiles = useMemo(() => timeline.sessionChanges(sessionId), [sessionId, historyVersion]);
|
|
373
412
|
const sessionChange = openFile ? sessionFiles.find((c) => c.path === openFile) ?? null : null;
|
|
374
|
-
const diff = pendingChange ?? (diffMode === "git" ? gitChange
|
|
413
|
+
const diff = (pendingChange && pendingChange.path === openFile ? pendingChange : null) ?? (diffMode === "git" ? gitChange
|
|
375
414
|
: diffMode === "nou" && record ? record.change
|
|
376
415
|
: diffMode === "session" ? sessionChange : null);
|
|
377
|
-
const showEditor = editing && !!openFile && !pendingChange;
|
|
416
|
+
const showEditor = editing && !!openFile && !(pendingChange && pendingChange.path === openFile);
|
|
417
|
+
draftActiveRef.current = showEditor;
|
|
378
418
|
// Check Nou's proposed edit before you approve it: does it add problems?
|
|
379
419
|
useEffect(() => {
|
|
380
420
|
setPendingNote(null);
|
|
@@ -415,14 +455,37 @@ export function App({ initialModel, permContext }) {
|
|
|
415
455
|
setExitArmed(true);
|
|
416
456
|
setTimeout(() => setExitArmed(false), 2000);
|
|
417
457
|
};
|
|
458
|
+
/** Moves a pane border to column `x` and keeps every pane usable. */
|
|
459
|
+
const moveBorder = (border, x) => {
|
|
460
|
+
const { treeWidth, codeWidth } = layout.current;
|
|
461
|
+
const chatWidth = columns - treeWidth - codeWidth;
|
|
462
|
+
const tree = border === "tree" ? Math.min(Math.max(x + 1, MIN_TREE), columns - chatWidth - MIN_CODE) : treeWidth;
|
|
463
|
+
const chat = border === "chat" ? Math.min(Math.max(columns - x, MIN_CHAT), columns - treeWidth - MIN_CODE) : chatWidth;
|
|
464
|
+
const next = { tree: tree / columns, chat: chat / columns };
|
|
465
|
+
setPaneWidths(next);
|
|
466
|
+
return next;
|
|
467
|
+
};
|
|
418
468
|
// Mouse: the wheel scrolls whatever pane is under the pointer; a click focuses that pane
|
|
419
|
-
// (and in the file tree, opens the clicked file).
|
|
469
|
+
// (and in the file tree, opens the clicked file). Dragging a border between panes resizes them.
|
|
420
470
|
useMouse((event) => {
|
|
421
471
|
if (view !== "main" || quickOpen)
|
|
422
472
|
return;
|
|
423
473
|
const { treeWidth, codeWidth, paneHeight } = layout.current;
|
|
474
|
+
if (event.kind === "drag")
|
|
475
|
+
return void (dragging.current && moveBorder(dragging.current, event.x));
|
|
476
|
+
if (event.kind === "release") {
|
|
477
|
+
if (dragging.current)
|
|
478
|
+
updateSettings({ paneWidths: paneWidthsRef.current });
|
|
479
|
+
dragging.current = null;
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
424
482
|
if (event.y < 1 || event.y > paneHeight)
|
|
425
483
|
return; // header or status bar
|
|
484
|
+
// Each border is two columns: the left pane's edge and the right pane's.
|
|
485
|
+
if (event.kind === "click" && Math.abs(event.x - (treeWidth - 0.5)) <= 1)
|
|
486
|
+
return void (dragging.current = "tree");
|
|
487
|
+
if (event.kind === "click" && Math.abs(event.x - (treeWidth + codeWidth - 0.5)) <= 1)
|
|
488
|
+
return void (dragging.current = "chat");
|
|
426
489
|
const pane = event.x < treeWidth ? "tree" : event.x < treeWidth + codeWidth ? "code" : "chat";
|
|
427
490
|
if (event.kind === "wheel") {
|
|
428
491
|
setWheel((w) => ({ pane, dir: event.dir, nonce: (w?.nonce ?? 0) + 1 }));
|
|
@@ -611,16 +674,17 @@ export function App({ initialModel, permContext }) {
|
|
|
611
674
|
setDiffMode((m) => order[(order.indexOf(m) + 1) % order.length]);
|
|
612
675
|
};
|
|
613
676
|
useInput((input, key) => {
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
answer(false);
|
|
619
|
-
else if (key.escape) {
|
|
677
|
+
// A waiting approval doesn't freeze anything: y/n answer it from the tree or code view (the chat
|
|
678
|
+
// handles them itself when its input is empty), Esc stops the turn, and every other key works as usual.
|
|
679
|
+
if (approval && !(showEditor && focus === "code")) {
|
|
680
|
+
if (key.escape) {
|
|
620
681
|
agentRef.current?.cancel();
|
|
621
|
-
answer(false);
|
|
682
|
+
return answer(false);
|
|
622
683
|
}
|
|
623
|
-
|
|
684
|
+
if (focus !== "chat" && /^[yYnN]$/.test(input))
|
|
685
|
+
return answer(/[yY]/.test(input));
|
|
686
|
+
if (focus !== "chat" && input === "o" && pendingChange)
|
|
687
|
+
return openPending();
|
|
624
688
|
}
|
|
625
689
|
if (key.ctrl && input === "p") {
|
|
626
690
|
setQuickOpen(true);
|
|
@@ -628,9 +692,10 @@ export function App({ initialModel, permContext }) {
|
|
|
628
692
|
}
|
|
629
693
|
if (showEditor && focus === "code")
|
|
630
694
|
return; // the editor owns the keyboard
|
|
695
|
+
if (key.tab && key.shift)
|
|
696
|
+
return cycleAutoMode();
|
|
631
697
|
if (key.tab) {
|
|
632
|
-
|
|
633
|
-
setFocus((f) => FOCUS_ORDER[(FOCUS_ORDER.indexOf(f) + step + FOCUS_ORDER.length) % FOCUS_ORDER.length]);
|
|
698
|
+
setFocus((f) => FOCUS_ORDER[(FOCUS_ORDER.indexOf(f) + 1) % FOCUS_ORDER.length]);
|
|
634
699
|
return;
|
|
635
700
|
}
|
|
636
701
|
if (key.escape) {
|
|
@@ -650,6 +715,10 @@ export function App({ initialModel, permContext }) {
|
|
|
650
715
|
return; // letters belong to the text box
|
|
651
716
|
if (input === "f")
|
|
652
717
|
setFollow((f) => !f);
|
|
718
|
+
else if (input === "m") {
|
|
719
|
+
updateSettings({ mouse: !settings.mouse });
|
|
720
|
+
info(settings.mouse ? "Mouse off: drag to select text and copy it as usual in your terminal. m turns it back on." : "Mouse on: wheel scrolls, click focuses, drag borders to resize.");
|
|
721
|
+
}
|
|
653
722
|
else if (input === "p") {
|
|
654
723
|
setCodePanel((p) => (p === "problems" ? "code" : "problems"));
|
|
655
724
|
setEditing(false);
|
|
@@ -657,6 +726,16 @@ export function App({ initialModel, permContext }) {
|
|
|
657
726
|
}
|
|
658
727
|
else if (input === "u")
|
|
659
728
|
undoLast();
|
|
729
|
+
else if (input === "<" || input === ">") {
|
|
730
|
+
// Moves the focused pane's inner border: the tree's right edge, or the code view's edge with the chat.
|
|
731
|
+
const { treeWidth, codeWidth } = layout.current;
|
|
732
|
+
const step = input === ">" ? 2 : -2;
|
|
733
|
+
updateSettings({ paneWidths: moveBorder(focus === "tree" ? "tree" : "chat", (focus === "tree" ? treeWidth - 1 : treeWidth + codeWidth) + step) });
|
|
734
|
+
}
|
|
735
|
+
else if (input === "=") {
|
|
736
|
+
setPaneWidths(null);
|
|
737
|
+
updateSettings({ paneWidths: null });
|
|
738
|
+
}
|
|
660
739
|
else if (input === "v")
|
|
661
740
|
cycleDiff();
|
|
662
741
|
else if ((input === "]" || input === "[") && diffMode === "session")
|
|
@@ -774,13 +853,31 @@ ${snippet}
|
|
|
774
853
|
}
|
|
775
854
|
return chooseModel(resolveModel(arg));
|
|
776
855
|
}
|
|
856
|
+
case "copy": {
|
|
857
|
+
const reply = [...entries].reverse().find((e) => e.kind === "assistant")?.text;
|
|
858
|
+
const blocks = reply ? [...reply.matchAll(/```[^\n]*\n([\s\S]*?)```/g)].map((m) => m[1]) : [];
|
|
859
|
+
const what = arg === "file" ? (openFile ? readText(openFile) : null) : arg === "code" ? blocks[blocks.length - 1] ?? null : reply ?? null;
|
|
860
|
+
if (arg && arg !== "file" && arg !== "code")
|
|
861
|
+
return info("Usage: /copy (the last reply) · /copy code (its last code block) · /copy file (the open file)", "error");
|
|
862
|
+
if (!what)
|
|
863
|
+
return info(arg === "file" ? "No file is open." : arg === "code" ? "The last reply has no code block." : "Nothing to copy yet.", "error");
|
|
864
|
+
const ok = await copyToClipboard(what);
|
|
865
|
+
return info(ok ? `Copied ${arg === "file" ? openFile : arg === "code" ? "the code block" : "the last reply"} (${what.split("\n").length} lines).` : "Couldn't reach the clipboard. Press m to turn off the mouse and select the text yourself.", ok ? "info" : "error");
|
|
866
|
+
}
|
|
867
|
+
case "auto": {
|
|
868
|
+
if (!arg)
|
|
869
|
+
return cycleAutoMode();
|
|
870
|
+
if (arg === "ask" || arg === "auto-edit" || arg === "auto")
|
|
871
|
+
return cycleAutoMode(arg);
|
|
872
|
+
return info("Usage: /auto [ask|auto-edit|auto]", "error");
|
|
873
|
+
}
|
|
777
874
|
case "engine": {
|
|
778
875
|
if (arg && isEngineId(arg))
|
|
779
876
|
return switchEngine(arg);
|
|
780
877
|
if (arg)
|
|
781
878
|
return info(`Unknown engine ${arg}. Choose one of: ${ENGINES.map((e) => e.id).join(", ")}`, "error");
|
|
782
879
|
const current = agentRef.current.id;
|
|
783
|
-
const lines = ENGINES.map((e) => ` ${e.id === current ? "●" : "○"} ${e.id.padEnd(12)} ${e.description}${isInstalled(e) ? "" :
|
|
880
|
+
const lines = ENGINES.map((e) => ` ${e.id === current ? "●" : "○"} ${e.id.padEnd(12)} ${e.description}${isInstalled(e) ? "" : ` (not installed: switching runs npm install ${e.package})`}`);
|
|
784
881
|
return info(`Engines (/engine <name> to switch, or /settings → Engine):\n${lines.join("\n")}`);
|
|
785
882
|
}
|
|
786
883
|
case "pull": {
|
|
@@ -892,9 +989,9 @@ ${snippet}
|
|
|
892
989
|
} }));
|
|
893
990
|
}
|
|
894
991
|
const height = Math.max(10, rows - 2);
|
|
895
|
-
const treeWidth = Math.max(24, Math.floor(columns * 0.2));
|
|
896
|
-
const chatWidth = Math.max(34, Math.floor(columns * 0.3));
|
|
897
|
-
const codeWidth = Math.max(
|
|
992
|
+
const treeWidth = paneWidths ? Math.max(MIN_TREE, Math.round(columns * paneWidths.tree)) : Math.max(24, Math.floor(columns * 0.2));
|
|
993
|
+
const chatWidth = paneWidths ? Math.max(MIN_CHAT, Math.min(Math.round(columns * paneWidths.chat), columns - treeWidth - MIN_CODE)) : Math.max(34, Math.floor(columns * 0.3));
|
|
994
|
+
const codeWidth = Math.max(MIN_CODE, columns - treeWidth - chatWidth);
|
|
898
995
|
layout.current = { treeWidth, codeWidth, paneHeight: height };
|
|
899
996
|
const wheelFor = (pane) => (wheel?.pane === pane ? wheel : null);
|
|
900
997
|
const edited = new Set([...changes].filter(([, c]) => c.status === "applied").map(([p]) => p));
|
|
@@ -908,19 +1005,32 @@ ${snippet}
|
|
|
908
1005
|
: diff ? { label: "DIFF", color: C.accent2 }
|
|
909
1006
|
: busy ? { label: "WORKING", color: C.accent2 }
|
|
910
1007
|
: { label: focus.toUpperCase(), color: C.accent };
|
|
911
|
-
|
|
912
|
-
|
|
1008
|
+
// Shortcuts for the status bar, most useful first; the ones that don't fit are left out (all of them are in /help).
|
|
1009
|
+
const hints = approval && !(showEditor && focus === "code")
|
|
1010
|
+
? [["y", "accept"], ["n", "reject"], ...(pendingChange && diff !== pendingChange ? [["o", "show the edit"]] : []), ["Esc", "stop"], ["Shift+Tab", "auto mode"]]
|
|
913
1011
|
: showEditor && focus === "code"
|
|
914
|
-
?
|
|
1012
|
+
? [["Esc", "done"], ["Ctrl+S", settings.autoSave === "off" ? "save" : "save (auto-save on)"], ["Ctrl+Z", "undo"], ["Ctrl+Y", "redo"], ["Ctrl+K", "info"], ["Ctrl+G", "definition"]]
|
|
915
1013
|
: focus === "chat"
|
|
916
|
-
? busy
|
|
1014
|
+
? busy
|
|
1015
|
+
? [["Esc", "stop"], ["Shift+↑↓", "scroll"], ["Tab", "next pane"]]
|
|
1016
|
+
: [["Enter", "send"], ["/", "commands"], ["Tab", "next pane"], ["Ctrl+P", "find file"], ["Shift+↑↓", "scroll"]]
|
|
917
1017
|
: focus === "tree"
|
|
918
|
-
? "
|
|
919
|
-
: diff
|
|
920
|
-
|
|
1018
|
+
? [["Enter", "open"], ["e", "edit"], ["c", "changes"], ["Ctrl+P", "find"], ["u", "undo"], ["p", "problems"], ["< >", "resize"], ["m", "select text"], ["s", "settings"]]
|
|
1019
|
+
: diff
|
|
1020
|
+
? [["Esc", "close diff"], ["n/N", "next/prev change"], ...(diffMode === "session" ? [["[ ]", "prev/next file"]] : []), ["v", "cycle diff"]]
|
|
1021
|
+
: [["e", "edit"], ["v", "diff"], ["u", "undo"], ["p", "problems"], ["< >", "resize"], ["m", "select text"], ["s", "settings"]];
|
|
1022
|
+
const statusUsed = mode.label.length + 2 + autoMode.length + 2 + 10 + ` ✖ ${totals.errors} ▲ ${totals.warnings} `.length + (checking.length ? 4 + checking.join(", ").length : 0) + 2;
|
|
1023
|
+
const shownHints = [];
|
|
1024
|
+
for (let used = statusUsed, i = 0; i < hints.length; i++) {
|
|
1025
|
+
used += hints[i][0].length + hints[i][1].length + 4;
|
|
1026
|
+
if (used > columns)
|
|
1027
|
+
break;
|
|
1028
|
+
shownHints.push(hints[i]);
|
|
1029
|
+
}
|
|
1030
|
+
return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(Header, { width: columns, branch: branch, changes: git.size, model: agentRef.current.id === "nou" ? describe(choice) : `${engineInfo(agentRef.current.id).label} · ${engineInfo(agentRef.current.id).model(choice)}`, local: choice.provider.local, busy: busy, update: update }), _jsxs(Box, { flexDirection: "row", height: height, children: [_jsx(FileTree, { width: treeWidth, height: height, focused: focus === "tree", openFile: openFile, reveal: reveal, version: treeVersion, changed: edited, git: git, problemCounts: diagnostics.fileCounts(), wheel: wheelFor("tree"), click: treeClick, onOpen: (filePath) => {
|
|
921
1031
|
open(filePath);
|
|
922
1032
|
setFocus("code");
|
|
923
|
-
}, onHover: () => { } }), codePanel === "problems" && !pendingChange ? (_jsx(ProblemsView, { width: codeWidth, height: height, focused: focus === "code"
|
|
1033
|
+
}, onHover: () => { } }), codePanel === "problems" && !pendingChange ? (_jsx(ProblemsView, { width: codeWidth, height: height, focused: focus === "code", problems: diagnostics.all(), checking: checking, wheel: wheelFor("code"), onJump: (file, line) => openAt(file, line), onClose: () => setCodePanel("code") })) : showEditor ? (_jsx(Editor, { width: codeWidth, height: height, focused: focus === "code", filePath: openFile, initial: draft.current?.path === openFile ? draft.current.text : content, baseline: draft.current?.path === openFile ? draft.current.baseline : content, startLine: editorStart.line, problems: fileProblems, autoSave: settings.autoSave, syntaxVersion: syntaxVersion, wheel: wheelFor("code"), onHover: (text, line, col) => (lspEnabled ? lsp.hover(openFile, text, line, col) : Promise.resolve(null)), onDefinition: (text, line, col) => {
|
|
924
1034
|
if (!lspEnabled)
|
|
925
1035
|
return info("Go to definition needs a language server (see /settings → Plugins).", "error");
|
|
926
1036
|
void lsp.definition(openFile, text, line, col).then((target) => {
|
|
@@ -939,9 +1049,9 @@ ${snippet}
|
|
|
939
1049
|
draft.current = state.dirty
|
|
940
1050
|
? { path: openFile, text: state.lines.join("\n"), baseline: draft.current?.path === openFile ? draft.current.baseline : content }
|
|
941
1051
|
: null;
|
|
942
|
-
} }, `${openFile}:${editorStart.nonce}`)) : (_jsx(CodeView, { width: codeWidth, height: height, focused: focus === "code"
|
|
1052
|
+
} }, `${openFile}:${editorStart.nonce}`)) : (_jsx(CodeView, { width: codeWidth, height: height, focused: focus === "code", filePath: openFile, content: content, diff: diff, diffLabel: diffMode === "git" ? "vs HEAD" : diffMode === "session" ? sessionView?.label ?? "this session" : "Nou's edit", pending: !!pendingChange && diff === pendingChange, marked: record?.lines ?? NO_LINES, problems: fileProblems, focusLine: focusLine, syntaxVersion: syntaxVersion, diffNote: pendingChange && diff === pendingChange ? pendingNote?.short ?? (checking.length ? "checking…" : null) : null, wheel: wheelFor("code"), onViewport: (top, bottom) => setViewport((v) => (v?.top === top && v.bottom === bottom ? v : { top, bottom })) })), _jsx(Chat, { width: chatWidth, height: height, focused: focus === "chat", entries: entries, streaming: streaming, busy: busy, approval: approval?.request ?? null, approvalNote: pendingChange ? pendingNote?.detail ?? null : null, contextChip: contextChip, wheel: wheelFor("chat"), onSubmit: send, onAnswer: answer })] }), _jsx(Box, { children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { backgroundColor: mode.color, color: "black", bold: true, children: ` ${mode.label} ` }), _jsx(Text, { backgroundColor: C.panel, color: autoMode === "ask" ? C.dim : C.orange, bold: autoMode !== "ask", children: ` ${autoMode === "ask" ? "ask" : autoMode} ` }), _jsx(Text, { backgroundColor: C.panel, color: follow ? C.green : C.dim, children: follow ? " ◉ follow " : " ○ follow " }), _jsx(Text, { backgroundColor: C.panel, color: totals.errors ? C.red : C.dim, children: ` ✖ ${totals.errors}` }), _jsx(Text, { backgroundColor: C.panel, color: totals.warnings ? C.yellow : C.dim, children: ` ▲ ${totals.warnings} ` }), checking.length > 0 && _jsxs(Text, { color: C.accent2, children: [" \u27F3 ", checking.join(", ")] }), exitArmed
|
|
943
1053
|
? _jsxs(Text, { color: C.yellow, bold: true, children: [" ", "Press again to quit", draft.current && settings.autoSave === "off" ? ` (unsaved changes in ${draft.current.path} will be lost)` : ""] })
|
|
944
|
-
: _jsxs(Text, { color: C.dim, children: ["
|
|
1054
|
+
: shownHints.map(([key, action], i) => (_jsxs(Text, { children: [i === 0 ? " " : _jsx(Text, { color: C.dim, children: " \u00B7 " }), _jsx(Text, { color: C.text, children: key }), _jsxs(Text, { color: C.dim, children: [" ", action] })] }, key)))] }) }), quickOpen && (_jsx(QuickOpen, { width: columns, top: 3, files: allFiles, onPick: (file) => {
|
|
945
1055
|
setQuickOpen(false);
|
|
946
1056
|
open(file);
|
|
947
1057
|
setReveal(file);
|
package/dist/src/ui/Chat.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useEffect, useMemo, useState } from "react";
|
|
3
3
|
import { Box, Text, useAnimation, useInput } from "ink";
|
|
4
|
-
import { wrap } from "./text.js";
|
|
4
|
+
import { renderMarkdown, wrap } from "./text.js";
|
|
5
5
|
import { ChatInput, inputHeight } from "./ChatInput.js";
|
|
6
6
|
import { C, SPINNER } from "./theme.js";
|
|
7
7
|
import { Pane } from "./Pane.js";
|
|
8
|
-
export const COMMANDS = ["/model", "/models", "/settings", "/undo", "/rewind", "/history", "/problems", "/version", "/clear", "/help"];
|
|
9
|
-
export function Chat({ width, height, focused, entries, streaming, busy, approval, approvalNote, contextChip, wheel, onSubmit }) {
|
|
8
|
+
export const COMMANDS = ["/model", "/models", "/settings", "/auto", "/engine", "/copy", "/undo", "/rewind", "/history", "/problems", "/version", "/clear", "/help"];
|
|
9
|
+
export function Chat({ width, height, focused, entries, streaming, busy, approval, approvalNote, contextChip, wheel, onSubmit, onAnswer }) {
|
|
10
10
|
const [input, setInput] = useState("");
|
|
11
11
|
const [scrollBack, setScrollBack] = useState(0);
|
|
12
12
|
const { frame } = useAnimation({ interval: 80, isActive: !!busy });
|
|
@@ -23,9 +23,9 @@ export function Chat({ width, height, focused, entries, streaming, busy, approva
|
|
|
23
23
|
}
|
|
24
24
|
else if (entry.kind === "assistant") {
|
|
25
25
|
out.push({ text: "◆ nou", color: C.accent2, bold: true, bar: C.accent2 });
|
|
26
|
-
for (const line of
|
|
27
|
-
const
|
|
28
|
-
out.push({ text: line, ...(
|
|
26
|
+
for (const line of renderMarkdown(entry.text, inner - 3)) {
|
|
27
|
+
const color = line.kind === "heading" ? C.accent2 : line.kind === "quote" ? C.dim : undefined;
|
|
28
|
+
out.push({ text: "", spans: line.spans, ...(color ? { color } : {}), bar: C.accent2 });
|
|
29
29
|
}
|
|
30
30
|
out.push({ text: "" });
|
|
31
31
|
}
|
|
@@ -54,13 +54,13 @@ export function Chat({ width, height, focused, entries, streaming, busy, approva
|
|
|
54
54
|
footer.push({ text: " [y] allow [n] deny [Esc] stop", color: C.yellow });
|
|
55
55
|
}
|
|
56
56
|
else if (approval?.kind === "change") {
|
|
57
|
-
footer.push({ text: `✎
|
|
57
|
+
footer.push({ text: `✎ Nou wants to ${approval.change.isNew ? "create" : "edit"} ${approval.change.path}`, color: C.yellow, bold: true });
|
|
58
58
|
if (approvalNote) {
|
|
59
59
|
footer.push({ text: "⚠ This edit adds problems:", color: C.red, bold: true });
|
|
60
60
|
for (const line of wrap(approvalNote, inner - 2).slice(0, 6))
|
|
61
61
|
footer.push({ text: ` ${line}`, color: C.red });
|
|
62
62
|
}
|
|
63
|
-
footer.push({ text: " [y] accept [n] reject [Esc] stop", color: C.yellow });
|
|
63
|
+
footer.push({ text: " [y] accept [n] reject [o] show it [Esc] stop", color: C.yellow });
|
|
64
64
|
}
|
|
65
65
|
else if (busy) {
|
|
66
66
|
footer.push({ text: `${SPINNER[frame % SPINNER.length]} ${busy}`, color: C.accent2 });
|
|
@@ -78,6 +78,13 @@ export function Chat({ width, height, focused, entries, streaming, busy, approva
|
|
|
78
78
|
if (wheel)
|
|
79
79
|
setScrollBack((b) => Math.max(0, Math.min(maxBack, b - wheel.dir * 3)));
|
|
80
80
|
}, [wheel?.nonce]);
|
|
81
|
+
// While something waits for approval, y/n into an empty input answer it; any other key starts a message.
|
|
82
|
+
useInput((typed) => {
|
|
83
|
+
if (/^[yYnN]$/.test(typed))
|
|
84
|
+
onAnswer(/[yY]/.test(typed));
|
|
85
|
+
else if (typed && typed >= " ")
|
|
86
|
+
setInput(typed); // printable, not a control key
|
|
87
|
+
}, { isActive: focused && !!approval && !input });
|
|
81
88
|
useInput((_input, key) => {
|
|
82
89
|
if (key.pageUp || (key.upArrow && key.shift))
|
|
83
90
|
setScrollBack((b) => Math.min(maxBack, b + Math.max(1, rows - 2)));
|
|
@@ -92,12 +99,14 @@ export function Chat({ width, height, focused, entries, streaming, busy, approva
|
|
|
92
99
|
setInput("");
|
|
93
100
|
onSubmit(command);
|
|
94
101
|
};
|
|
95
|
-
const render = (line, key) => (_jsxs(Text, { wrap: "truncate-end", children: [line.bar ? _jsx(Text, { color: line.bar, children: "\u258C " }) : null,
|
|
102
|
+
const render = (line, key) => (_jsxs(Text, { wrap: "truncate-end", children: [line.bar ? _jsx(Text, { color: line.bar, children: "\u258C " }) : null, line.spans?.length
|
|
103
|
+
? line.spans.map((span, i) => (_jsx(Text, { bold: !!span.bold || !!line.bold, italic: !!span.italic, dimColor: !!span.dim, ...(span.code ? { color: C.yellow } : line.color ? { color: line.color } : { color: C.text }), children: span.text }, i)))
|
|
104
|
+
: _jsx(Text, { bold: !!line.bold, dimColor: !!line.dim, ...(line.color ? { color: line.color } : {}), children: line.text || " " })] }, key));
|
|
96
105
|
const empty = entries.length === 0 && !streaming;
|
|
97
106
|
const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u25C6" }), " Chat", back
|
|
98
107
|
? _jsxs(Text, { color: C.yellow, children: [" \u2191 scrolled ", back, " lines \u00B7 Shift+\u2193 / PgDn / wheel to go back"] })
|
|
99
108
|
: maxBack > 0 ? _jsx(Text, { color: C.dim, children: " Shift+\u2191 / PgUp / wheel to scroll" }) : null] }));
|
|
100
109
|
return (_jsxs(Pane, { title: title, width: width, height: height, focused: focused, children: [_jsx(Box, { flexDirection: "column", height: rows, children: empty ? (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { color: C.dim, children: "Try:" }), _jsx(Text, { color: C.text, children: " \u201Cexplain this file\u201D" }), _jsx(Text, { color: C.text, children: " \u201Cwhat changed since my last commit?\u201D" }), _jsx(Text, { color: C.text, children: " \u201Cadd error handling here\u201D" }), _jsx(Text, { children: " " }), _jsx(Text, { color: C.dim, children: "I can see the file you have open." })] })) : visible.map((line, i) => render(line, i)) }), _jsx(Box, { flexDirection: "column", children: footer.map((line, i) => render(line, `f${i}`)) }), _jsx(Text, { wrap: "truncate-end", color: C.dim, children: suggestions.length
|
|
101
110
|
? _jsxs(Text, { color: C.accent, children: [suggestions.join(" "), " ", _jsx(Text, { color: C.dim, children: suggestions.length === 1 ? "Enter ↵" : "" })] })
|
|
102
|
-
: contextChip ? _jsxs(_Fragment, { children: [_jsx(Text, { color: C.cyan, children: "@" }), " ", contextChip] }) : " " }), _jsx(Text, { color: focused ? C.accent : C.dim, children: "─".repeat(Math.max(0, inner)) }), _jsx(ChatInput, { value: input, onChange: setInput, onSubmit: submit, focus: focused && !approval, width: inner, history: history, placeholder: busy ? "working… Esc to stop" : "Ask anything · / for commands · ↑ history" })] }));
|
|
111
|
+
: contextChip ? _jsxs(_Fragment, { children: [_jsx(Text, { color: C.cyan, children: "@" }), " ", contextChip] }) : " " }), _jsx(Text, { color: focused ? C.accent : C.dim, children: "─".repeat(Math.max(0, inner)) }), _jsx(ChatInput, { value: input, onChange: setInput, onSubmit: submit, focus: focused && !(approval && !input), width: inner, history: history, placeholder: busy ? "working… Esc to stop" : "Ask anything · / for commands · ↑ history" })] }));
|
|
103
112
|
}
|
package/dist/src/ui/CodeView.js
CHANGED
|
@@ -53,7 +53,7 @@ export function CodeView({ width, height, focused, filePath, content, diff, diff
|
|
|
53
53
|
setTop(0);
|
|
54
54
|
else if (input === "G")
|
|
55
55
|
setTop(maxTop);
|
|
56
|
-
else if (diff && (input === "n" || input === "N")) {
|
|
56
|
+
else if (diff && !pending && (input === "n" || input === "N")) {
|
|
57
57
|
// Jump between change hunks.
|
|
58
58
|
const starts = diffRows.flatMap((r, i) => (r.changed && !diffRows[i - 1]?.changed ? [i] : []));
|
|
59
59
|
const target = input === "n" ? starts.find((s) => s - 3 > start) : [...starts].reverse().find((s) => s - 3 < start);
|
package/dist/src/ui/Editor.js
CHANGED
|
@@ -234,5 +234,5 @@ export function Editor({ width, height, focused, filePath, initial, baseline, st
|
|
|
234
234
|
const shown = expand(line).slice(v.left, v.left + textWidth);
|
|
235
235
|
const at = cursorX - v.left;
|
|
236
236
|
return (_jsxs(Text, { wrap: "truncate-end", children: [num, _jsx(Text, { color: C.text, children: shown.slice(0, at) }), _jsx(Text, { inverse: true, color: C.green, children: shown[at] ?? " " }), _jsx(Text, { color: C.text, children: shown.slice(at + 1) })] }, n));
|
|
237
|
-
}) }), hoverLines.length > 0 && (_jsx(Box, { position: "absolute", top: Math.max(1, rows - hoverLines.length - 1), left: gutter + 1, flexDirection: "column", borderStyle: "round", borderColor: C.accent2, width: Math.min(width - gutter - 3, 2 + Math.max(...hoverLines.map((l) => l.length))), children: hoverLines.map((l, i) => _jsx(Text, { color: C.text, wrap: "truncate-end", children: l.padEnd(width) }, i)) })),
|
|
237
|
+
}) }), hoverLines.length > 0 && (_jsx(Box, { position: "absolute", top: Math.max(1, rows - hoverLines.length - 1), left: gutter + 1, flexDirection: "column", borderStyle: "round", borderColor: C.accent2, width: Math.min(width - gutter - 3, 2 + Math.max(...hoverLines.map((l) => l.length))), children: hoverLines.map((l, i) => _jsx(Text, { color: C.text, wrap: "truncate-end", children: l.padEnd(width) }, i)) })), _jsx(Text, { wrap: "truncate-end", color: statusColor, children: ` ${status}` })] }));
|
|
238
238
|
}
|
package/dist/src/ui/FileTree.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
3
3
|
import { readdirSync } from "fs";
|
|
4
4
|
import path from "path";
|
|
5
5
|
import { Box, Text, useInput } from "ink";
|
|
@@ -50,10 +50,13 @@ export function FileTree({ width, height, focused, openFile, reveal, version, ch
|
|
|
50
50
|
}
|
|
51
51
|
return dirs;
|
|
52
52
|
}, [git]);
|
|
53
|
-
// Expand the folders leading to whatever the agent is touching and move to it
|
|
53
|
+
// Expand the folders leading to whatever the agent is touching and move to it, once per reveal:
|
|
54
|
+
// later changes to the list (expanding folders, git refreshes) must not pull the cursor back.
|
|
55
|
+
const pendingReveal = useRef(null);
|
|
54
56
|
useEffect(() => {
|
|
55
57
|
if (!reveal)
|
|
56
58
|
return;
|
|
59
|
+
pendingReveal.current = reveal;
|
|
57
60
|
const parts = reveal.split("/");
|
|
58
61
|
setExpanded((prev) => {
|
|
59
62
|
const next = new Set(prev);
|
|
@@ -63,12 +66,14 @@ export function FileTree({ width, height, focused, openFile, reveal, version, ch
|
|
|
63
66
|
});
|
|
64
67
|
}, [reveal, version]);
|
|
65
68
|
useEffect(() => {
|
|
66
|
-
if (!
|
|
69
|
+
if (!pendingReveal.current)
|
|
70
|
+
return;
|
|
71
|
+
const index = nodes.findIndex((n) => n.path === pendingReveal.current);
|
|
72
|
+
if (index < 0)
|
|
67
73
|
return;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}, [nodes, reveal]);
|
|
74
|
+
setCursor(index);
|
|
75
|
+
pendingReveal.current = null;
|
|
76
|
+
}, [nodes]);
|
|
72
77
|
useEffect(() => {
|
|
73
78
|
setCursor((c) => Math.min(c, Math.max(0, nodes.length - 1)));
|
|
74
79
|
}, [nodes.length]);
|
package/dist/src/ui/Settings.js
CHANGED
|
@@ -7,7 +7,7 @@ import { saveCredentials } from "../config/credentials.js";
|
|
|
7
7
|
import { originOf, setOutsideNou } from "../config/origins.js";
|
|
8
8
|
import { PLUGIN_DIRS, isEnabled, trustState } from "../plugins/index.js";
|
|
9
9
|
import { currentVersion } from "../update/index.js";
|
|
10
|
-
import { describe, providers } from "../providers/index.js";
|
|
10
|
+
import { describe, providers, rejectedKey } from "../providers/index.js";
|
|
11
11
|
import { ENGINES, isInstalled } from "../engines/index.js";
|
|
12
12
|
import { C, SPINNER } from "./theme.js";
|
|
13
13
|
const TABS = ["Model", "Engine", "Preferences", "Plugins"];
|
|
@@ -201,7 +201,7 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
|
|
|
201
201
|
return (_jsxs(Box, { flexDirection: "column", height: boxHeight, borderStyle: "round", borderColor: C.accent, paddingX: 1, children: [ENGINES.map((engine, i) => {
|
|
202
202
|
const selected = i === row;
|
|
203
203
|
const active = engine.id === settings.engine;
|
|
204
|
-
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▸ " : " " }), _jsx(Text, { color: active ? C.green : C.dim, bold: true, children: active ? "● " : "○ " }), _jsx(Text, { color: C.text, bold: selected, children: engine.label.padEnd(14) }), _jsxs(Text, { color: C.dim, children: [active ? "in use · " : "", isInstalled(engine)
|
|
204
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▸ " : " " }), _jsx(Text, { color: active ? C.green : C.dim, bold: true, children: active ? "● " : "○ " }), _jsx(Text, { color: C.text, bold: selected, children: engine.label.padEnd(14) }), _jsxs(Text, { color: C.dim, children: [active ? "in use · " : "", "model: ", engine.model(choice)] })] }), !isInstalled(engine) && _jsxs(Text, { color: C.orange, wrap: "truncate-end", children: [" Not installed. Enter installs it with npm (", engine.package, ") into ~/.nou/engines."] }), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: [" ", engine.description] }), engine.approvals === "after" && _jsx(Text, { color: C.orange, wrap: "truncate-end", children: " Nou can't ask before its edits; it shows them afterwards so you can undo them." })] }, engine.id));
|
|
205
205
|
}), _jsx(Text, { color: C.dim, wrap: "truncate-end", children: "The engine is the agent that answers in the chat. Diffs, approvals, undo and problems work with each one." })] }));
|
|
206
206
|
}
|
|
207
207
|
if (tab === "Preferences") {
|
|
@@ -240,5 +240,3 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
|
|
|
240
240
|
function initialValue(f) {
|
|
241
241
|
return f.secret ? "" : process.env[f.env] ?? "";
|
|
242
242
|
}
|
|
243
|
-
/** Whether a provider error means the API key is wrong (rather than, say, the server being down). */
|
|
244
|
-
const rejectedKey = (error) => /\b(401|403)\b|api key|unauthori[sz]ed|authentication/i.test(error);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
/** Native clipboard commands to try, in order, for this platform. */
|
|
3
|
+
function commands() {
|
|
4
|
+
if (process.platform === "win32")
|
|
5
|
+
return [["powershell", ["-NoProfile", "-Command", "Set-Clipboard -Value ([Console]::In.ReadToEnd())"]]];
|
|
6
|
+
if (process.platform === "darwin")
|
|
7
|
+
return [["pbcopy", []]];
|
|
8
|
+
return [["wl-copy", []], ["xclip", ["-selection", "clipboard"]], ["xsel", ["--clipboard", "--input"]]];
|
|
9
|
+
}
|
|
10
|
+
function run(cmd, args, text) {
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
const child = spawn(cmd, args, { stdio: ["pipe", "ignore", "ignore"] });
|
|
13
|
+
child.on("error", () => resolve(false));
|
|
14
|
+
child.on("close", (code) => resolve(code === 0));
|
|
15
|
+
child.stdin.end(text);
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Copies text to the system clipboard. Also sends it as an OSC 52 escape, which terminals that
|
|
20
|
+
* support it (Windows Terminal, iTerm2, kitty, WezTerm...) put on the clipboard even over SSH.
|
|
21
|
+
*/
|
|
22
|
+
export async function copyToClipboard(text) {
|
|
23
|
+
if (process.stdout.isTTY)
|
|
24
|
+
process.stdout.write(`\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`);
|
|
25
|
+
for (const [cmd, args] of commands())
|
|
26
|
+
if (await run(cmd, args, text))
|
|
27
|
+
return true;
|
|
28
|
+
return process.stdout.isTTY === true;
|
|
29
|
+
}
|
package/dist/src/ui/mouse.js
CHANGED
|
@@ -14,8 +14,10 @@ export function parseMouse(chunk) {
|
|
|
14
14
|
const button = code & 0b11000011; // drop the shift/alt/ctrl bits
|
|
15
15
|
if (button === 64 || button === 65)
|
|
16
16
|
events.push({ kind: "wheel", dir: button === 64 ? -1 : 1, x, y });
|
|
17
|
-
else if (button === 0 &&
|
|
18
|
-
events.push({ kind: "
|
|
17
|
+
else if (button === 0 && code & 32)
|
|
18
|
+
events.push({ kind: "drag", x, y }); // 32: moved with the left button held
|
|
19
|
+
else if (button === 0)
|
|
20
|
+
events.push({ kind: m[4] === "M" ? "click" : "release", x, y });
|
|
19
21
|
}
|
|
20
22
|
return events;
|
|
21
23
|
}
|
package/dist/src/ui/text.js
CHANGED
|
@@ -110,3 +110,158 @@ export function lineCounts(before, after) {
|
|
|
110
110
|
}
|
|
111
111
|
return { added, removed };
|
|
112
112
|
}
|
|
113
|
+
const INLINE = /(`+)([^`]+?)\1|\*\*(.+?)\*\*|__(.+?)__|(?<![\w*])\*(?!\s)(.+?)(?<!\s)\*(?![\w*])|(?<!\w)_(?!\s)(.+?)(?<!\s)_(?!\w)|\[([^\]]+)\]\(([^)\s]+)\)/g;
|
|
114
|
+
/** Splits a line of markdown into styled spans: `code`, **bold**, *italic*, [links](url). */
|
|
115
|
+
export function inlineSpans(text, base = { text: "" }) {
|
|
116
|
+
const out = [];
|
|
117
|
+
let pos = 0;
|
|
118
|
+
for (const m of text.matchAll(INLINE)) {
|
|
119
|
+
if (m.index > pos)
|
|
120
|
+
out.push({ ...base, text: text.slice(pos, m.index) });
|
|
121
|
+
if (m[2] !== undefined)
|
|
122
|
+
out.push({ ...base, text: m[2], code: true });
|
|
123
|
+
else if (m[3] !== undefined || m[4] !== undefined)
|
|
124
|
+
out.push(...inlineSpans(m[3] ?? m[4], { ...base, bold: true }));
|
|
125
|
+
else if (m[5] !== undefined || m[6] !== undefined)
|
|
126
|
+
out.push(...inlineSpans(m[5] ?? m[6], { ...base, italic: true }));
|
|
127
|
+
else {
|
|
128
|
+
out.push(...inlineSpans(m[7], base));
|
|
129
|
+
if (m[8] !== m[7])
|
|
130
|
+
out.push({ ...base, text: ` (${m[8]})`, dim: true });
|
|
131
|
+
}
|
|
132
|
+
pos = m.index + m[0].length;
|
|
133
|
+
}
|
|
134
|
+
if (pos < text.length)
|
|
135
|
+
out.push({ ...base, text: text.slice(pos) });
|
|
136
|
+
return out.filter((s) => s.text);
|
|
137
|
+
}
|
|
138
|
+
const sameStyle = (a, b) => !!a.bold === !!b.bold && !!a.italic === !!b.italic && !!a.code === !!b.code && !!a.dim === !!b.dim;
|
|
139
|
+
/** Word-wraps styled spans; continuation lines start with `indent` spaces. */
|
|
140
|
+
function wrapSpans(spans, width, indent = 0) {
|
|
141
|
+
const max = Math.max(1, width);
|
|
142
|
+
const lines = [[]];
|
|
143
|
+
let len = 0;
|
|
144
|
+
const push = (span) => {
|
|
145
|
+
const line = lines[lines.length - 1];
|
|
146
|
+
const last = line[line.length - 1];
|
|
147
|
+
if (last && sameStyle(last, span))
|
|
148
|
+
last.text += span.text;
|
|
149
|
+
else
|
|
150
|
+
line.push({ ...span });
|
|
151
|
+
len += span.text.length;
|
|
152
|
+
};
|
|
153
|
+
const newLine = () => {
|
|
154
|
+
const line = lines[lines.length - 1];
|
|
155
|
+
const last = line[line.length - 1];
|
|
156
|
+
if (last)
|
|
157
|
+
last.text = last.text.trimEnd();
|
|
158
|
+
lines.push(indent ? [{ text: " ".repeat(indent) }] : []);
|
|
159
|
+
len = indent;
|
|
160
|
+
};
|
|
161
|
+
for (const span of spans) {
|
|
162
|
+
for (const word of span.text.split(/(\s+)/)) {
|
|
163
|
+
if (!word)
|
|
164
|
+
continue;
|
|
165
|
+
if (len + word.length <= max) {
|
|
166
|
+
push({ ...span, text: word });
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (/^\s+$/.test(word)) {
|
|
170
|
+
newLine();
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (len > indent)
|
|
174
|
+
newLine();
|
|
175
|
+
let rest = word;
|
|
176
|
+
while (len + rest.length > max) {
|
|
177
|
+
const room = Math.max(1, max - len);
|
|
178
|
+
push({ ...span, text: rest.slice(0, room) });
|
|
179
|
+
rest = rest.slice(room);
|
|
180
|
+
newLine();
|
|
181
|
+
}
|
|
182
|
+
push({ ...span, text: rest });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return lines;
|
|
186
|
+
}
|
|
187
|
+
const cells = (row) => row.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim());
|
|
188
|
+
/** Renders markdown for the terminal: headings, lists, quotes, code blocks, tables and inline styles, wrapped to `width`. */
|
|
189
|
+
export function renderMarkdown(text, width) {
|
|
190
|
+
const out = [];
|
|
191
|
+
const src = splitLines(text);
|
|
192
|
+
let fence = null;
|
|
193
|
+
for (let i = 0; i < src.length; i++) {
|
|
194
|
+
const raw = src[i];
|
|
195
|
+
const opener = raw.match(/^\s*(`{3,}|~{3,})\s*(\S*)/);
|
|
196
|
+
if (fence !== null) {
|
|
197
|
+
if (opener && raw.trim().startsWith(fence)) {
|
|
198
|
+
fence = null;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
for (const line of wrap(raw, width))
|
|
202
|
+
out.push({ kind: "code", spans: [{ text: line, code: true }] });
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (opener) {
|
|
206
|
+
fence = opener[1];
|
|
207
|
+
if (opener[2])
|
|
208
|
+
out.push({ kind: "code", spans: [{ text: opener[2], dim: true }] });
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
// Tables: a header row, a |---| row, then body rows.
|
|
212
|
+
if (/^\s*\|/.test(raw) && /^\s*\|?\s*:?-{2,}/.test(src[i + 1] ?? "")) {
|
|
213
|
+
const rows = [cells(raw)];
|
|
214
|
+
let j = i + 2;
|
|
215
|
+
for (; j < src.length && /^\s*\|/.test(src[j]); j++)
|
|
216
|
+
rows.push(cells(src[j]));
|
|
217
|
+
i = j - 1;
|
|
218
|
+
const plain = rows.map((r) => r.map((c) => inlineSpans(c).map((s) => s.text).join("")));
|
|
219
|
+
const columns = Math.max(...plain.map((r) => r.length));
|
|
220
|
+
const widths = Array.from({ length: columns }, (_, c) => Math.max(...plain.map((r) => (r[c] ?? "").length)));
|
|
221
|
+
const fits = widths.reduce((a, b) => a + b, 0) + 3 * (columns - 1) <= width;
|
|
222
|
+
rows.forEach((row, r) => {
|
|
223
|
+
const spans = [];
|
|
224
|
+
row.forEach((cell, c) => {
|
|
225
|
+
if (c > 0)
|
|
226
|
+
spans.push({ text: " │ ", dim: true });
|
|
227
|
+
spans.push(...inlineSpans(cell, r === 0 ? { text: "", bold: true } : { text: "" }));
|
|
228
|
+
if (fits && c < columns - 1)
|
|
229
|
+
spans.push({ text: " ".repeat(widths[c] - plain[r][c].length) });
|
|
230
|
+
});
|
|
231
|
+
for (const line of wrapSpans(spans, width, 2))
|
|
232
|
+
out.push({ kind: "text", spans: line });
|
|
233
|
+
if (r === 0)
|
|
234
|
+
out.push({ kind: "rule", spans: [{ text: fits ? widths.map((w) => "─".repeat(w)).join("─┼─") : "─".repeat(Math.min(width, 40)), dim: true }] });
|
|
235
|
+
});
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
const heading = raw.match(/^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/);
|
|
239
|
+
if (heading) {
|
|
240
|
+
for (const line of wrapSpans(inlineSpans(heading[2], { text: "", bold: true }), width))
|
|
241
|
+
out.push({ kind: "heading", spans: line });
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (/^\s{0,3}([-*_])(\s*\1){2,}\s*$/.test(raw)) {
|
|
245
|
+
out.push({ kind: "rule", spans: [{ text: "─".repeat(Math.min(width, 40)), dim: true }] });
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const quote = raw.match(/^\s*>\s?(.*)/);
|
|
249
|
+
if (quote) {
|
|
250
|
+
for (const line of wrapSpans(inlineSpans(quote[1], { text: "", italic: true }), width - 2))
|
|
251
|
+
out.push({ kind: "quote", spans: [{ text: "│ ", dim: true }, ...line] });
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
const item = raw.match(/^(\s*)([-*+]|\d+[.)])\s+(\[[ xX]\]\s+)?(.*)/);
|
|
255
|
+
if (item) {
|
|
256
|
+
const depth = Math.floor(item[1].length / 2) * 2;
|
|
257
|
+
const task = item[3] ? (/x/i.test(item[3]) ? "☑ " : "☐ ") : "";
|
|
258
|
+
const bullet = `${" ".repeat(depth)}${/\d/.test(item[2]) ? item[2] : "•"} ${task}`;
|
|
259
|
+
for (const line of wrapSpans([{ text: bullet }, ...inlineSpans(item[4])], width, bullet.length))
|
|
260
|
+
out.push({ kind: "text", spans: line });
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
for (const line of wrapSpans(inlineSpans(raw), width))
|
|
264
|
+
out.push({ kind: "text", spans: line });
|
|
265
|
+
}
|
|
266
|
+
return out;
|
|
267
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nou-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Nou: an IDE-style AI coding assistant for your terminal. Local (Ollama, LM Studio) and cloud (Claude, OpenAI, Gemini...) models, live diffs, git-aware, built-in editor.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|