nou-cli 0.5.2 → 0.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -7
- package/dist/src/agent/index.js +33 -3
- package/dist/src/cli.js +3 -1
- package/dist/src/commands/index.js +1 -1
- package/dist/src/config/index.js +3 -1
- package/dist/src/conversations/external.js +165 -0
- package/dist/src/conversations/index.js +71 -0
- package/dist/src/engines/claude.js +10 -0
- package/dist/src/engines/codex.js +60 -5
- package/dist/src/guide/index.js +100 -0
- package/dist/src/providers/anthropic.js +3 -14
- package/dist/src/syntax/index.js +17 -17
- package/dist/src/terminal/index.js +127 -0
- package/dist/src/ui/App.js +582 -69
- package/dist/src/ui/Chat.js +153 -14
- package/dist/src/ui/ChatInput.js +5 -0
- package/dist/src/ui/CodeView.js +1 -1
- package/dist/src/ui/Editor.js +44 -7
- package/dist/src/ui/FileTree.js +5 -4
- package/dist/src/ui/GamePanel.js +18 -11
- package/dist/src/ui/Header.js +2 -2
- package/dist/src/ui/ProblemsView.js +2 -2
- package/dist/src/ui/Settings.js +2 -2
- package/dist/src/ui/Tabs.js +6 -5
- package/dist/src/ui/Terminal.js +87 -0
- package/dist/src/ui/clipboard.js +19 -0
- package/dist/src/ui/commands.js +20 -0
- package/dist/src/ui/render-options.js +4 -0
- package/dist/src/ui/text.js +47 -3
- package/dist/src/ui/theme.js +2 -0
- package/package.json +1 -1
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { memo, useEffect, useState } from "react";
|
|
3
|
+
import { Box, Text, useInput } from "ink";
|
|
4
|
+
import { C } from "./theme.js";
|
|
5
|
+
const COLORS = { out: C.text, command: C.accent, info: C.dim, error: C.red };
|
|
6
|
+
/**
|
|
7
|
+
* The terminal panel, under the code like VS Code's: type a command and Enter runs it. While one runs,
|
|
8
|
+
* what you type goes to it; Ctrl+C stops it. ↑↓ go through what you ran, Ctrl+L clears, Esc leaves.
|
|
9
|
+
* Drag its top border (or Alt+↑/↓) to make it taller or shorter.
|
|
10
|
+
*/
|
|
11
|
+
export const Terminal = memo(function Terminal({ shell, version, width, height, focused, wheel, onLeave, onResize }) {
|
|
12
|
+
const [input, setInput] = useState("");
|
|
13
|
+
const [history, setHistory] = useState([]);
|
|
14
|
+
const [back, setBack] = useState(-1);
|
|
15
|
+
const [scroll, setScroll] = useState(0);
|
|
16
|
+
const inner = width - 2;
|
|
17
|
+
const rows = Math.max(1, height - 3); // borders and the title
|
|
18
|
+
const lines = shell.view();
|
|
19
|
+
// Fit long lines by wrapping them, newest at the bottom.
|
|
20
|
+
const wrapped = [];
|
|
21
|
+
for (const line of lines.slice(-rows * 4)) {
|
|
22
|
+
const text = line.text || " ";
|
|
23
|
+
for (let i = 0; i < text.length; i += inner)
|
|
24
|
+
wrapped.push({ text: text.slice(i, i + inner), color: COLORS[line.kind] });
|
|
25
|
+
}
|
|
26
|
+
const promptText = shell.running ? "› " : shell.prompt();
|
|
27
|
+
const shownPrompt = `${promptText}${input}`;
|
|
28
|
+
const promptRows = Math.max(1, Math.ceil((shownPrompt.length + 1) / inner));
|
|
29
|
+
const outRows = Math.max(0, rows - promptRows);
|
|
30
|
+
const maxScroll = Math.max(0, wrapped.length - outRows);
|
|
31
|
+
const at = Math.min(scroll, maxScroll);
|
|
32
|
+
const shown = wrapped.slice(Math.max(0, wrapped.length - outRows - at), wrapped.length - at);
|
|
33
|
+
useEffect(() => setScroll(0), [version]); // new output: back to the bottom
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (wheel?.lines)
|
|
36
|
+
setScroll((s) => Math.max(0, Math.min(maxScroll, s - wheel.lines)));
|
|
37
|
+
}, [wheel?.nonce]);
|
|
38
|
+
useInput((typed, key) => {
|
|
39
|
+
if (key.escape)
|
|
40
|
+
return onLeave();
|
|
41
|
+
if (key.ctrl && typed === "c") {
|
|
42
|
+
if (shell.running)
|
|
43
|
+
shell.interrupt();
|
|
44
|
+
else
|
|
45
|
+
setInput("");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (key.ctrl && typed === "l")
|
|
49
|
+
return shell.clear();
|
|
50
|
+
if (key.return) {
|
|
51
|
+
if (!shell.running && input.trim())
|
|
52
|
+
setHistory((h) => [...h.filter((c) => c !== input.trim()), input.trim()].slice(-100));
|
|
53
|
+
if (shell.running)
|
|
54
|
+
shell.input(input);
|
|
55
|
+
else
|
|
56
|
+
shell.run(input);
|
|
57
|
+
setInput("");
|
|
58
|
+
setBack(-1);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (key.meta && (key.upArrow || key.downArrow))
|
|
62
|
+
return onResize(key.upArrow ? 2 : -2);
|
|
63
|
+
if (key.upArrow || key.downArrow) {
|
|
64
|
+
if (!history.length)
|
|
65
|
+
return;
|
|
66
|
+
const next = key.upArrow ? Math.min(history.length - 1, back + 1) : back - 1;
|
|
67
|
+
setBack(next);
|
|
68
|
+
setInput(next < 0 ? "" : history[history.length - 1 - next]);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (key.pageUp)
|
|
72
|
+
return setScroll((s) => Math.min(maxScroll, s + outRows - 1));
|
|
73
|
+
if (key.pageDown)
|
|
74
|
+
return setScroll((s) => Math.max(0, s - outRows + 1));
|
|
75
|
+
if (key.backspace || key.delete)
|
|
76
|
+
return setInput((v) => v.slice(0, -1));
|
|
77
|
+
if (key.ctrl && typed === "u")
|
|
78
|
+
return setInput("");
|
|
79
|
+
if (key.ctrl || key.meta || key.tab)
|
|
80
|
+
return;
|
|
81
|
+
if (typed)
|
|
82
|
+
setInput((v) => v + typed.replace(/[\r\n]+/g, " "));
|
|
83
|
+
}, { isActive: focused });
|
|
84
|
+
const color = focused ? C.accent : C.dim;
|
|
85
|
+
const title = ` ▸ Terminal${shell.running ? " ● running · Ctrl+C stops" : ""}${at ? ` ↑ ${at} lines` : ""}${focused && !shell.running && !at ? " ⇕ drag this edge or Alt+↑↓ to resize" : ""}`;
|
|
86
|
+
return (_jsxs(Box, { width: width, height: height, flexDirection: "column", borderStyle: focused ? "round" : "single", borderColor: color, overflow: "hidden", children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: color, bold: true, children: title.slice(0, inner - 3).padEnd(inner - 3) }), _jsx(Text, { color: C.text, bold: true, children: " \u00D7 " })] }), _jsx(Box, { flexDirection: "column", height: outRows, flexShrink: 0, children: shown.map((l, i) => _jsx(Text, { color: l.color, wrap: "truncate-end", children: l.text }, i)) }), _jsxs(Text, { wrap: "wrap", children: [_jsx(Text, { color: shell.running ? C.yellow : C.green, children: promptText }), _jsx(Text, { color: C.text, children: input }), focused ? _jsx(Text, { inverse: true, children: " " }) : null] })] }));
|
|
87
|
+
});
|
package/dist/src/ui/clipboard.js
CHANGED
|
@@ -59,3 +59,22 @@ export async function readClipboard() {
|
|
|
59
59
|
}
|
|
60
60
|
return null;
|
|
61
61
|
}
|
|
62
|
+
/** Opens a web address in the default browser (the terminal's own link handling can't: Nou has the mouse). */
|
|
63
|
+
export function openInBrowser(url) {
|
|
64
|
+
if (!/^https?:\/\//.test(url))
|
|
65
|
+
return false;
|
|
66
|
+
if (process.env.NOU_TEST_OPEN)
|
|
67
|
+
return (globalThis.__opened = url), true; // tests: note it, open nothing
|
|
68
|
+
const [cmd, args] = process.platform === "darwin" ? ["open", [url]]
|
|
69
|
+
: process.platform === "win32" ? ["cmd", ["/c", "start", "", url.replace(/&/g, "^&")]]
|
|
70
|
+
: ["xdg-open", [url]];
|
|
71
|
+
try {
|
|
72
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
73
|
+
child.on("error", () => { });
|
|
74
|
+
child.unref();
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
package/dist/src/ui/commands.js
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Commands that only change what's on screen (or handle a busy Nou themselves): they run right away
|
|
3
|
+
* while Nou works instead of waiting in the queue behind it.
|
|
4
|
+
*/
|
|
5
|
+
const INSTANT = new Set(["new", "tab", "close", "terminal", "games", "open", "settings", "auto", "diff", "sessions", "history", "problems", "copy", "help", "version", "cancel"]);
|
|
6
|
+
/** Whether a typed command runs right away while Nou works (see INSTANT); `/showme` counts only to look or step. */
|
|
7
|
+
export function runsWhileBusy(text) {
|
|
8
|
+
const [name, ...rest] = text.slice(1).trim().split(/\s+/);
|
|
9
|
+
if (name === "showme" || name === "teach")
|
|
10
|
+
return !rest.length || /^(next|skip|stop|end)$/.test(rest.join(" "));
|
|
11
|
+
return text.startsWith("/") && INSTANT.has(name ?? "");
|
|
12
|
+
}
|
|
1
13
|
export const SLASH_COMMANDS = [
|
|
2
14
|
{ name: "model", args: "[provider:model]", description: "show or switch the model" },
|
|
3
15
|
{ name: "models", args: "[provider]", description: "list the models a provider offers" },
|
|
@@ -6,6 +18,7 @@ export const SLASH_COMMANDS = [
|
|
|
6
18
|
{ name: "settings", description: "models, engine, preferences and plugins" },
|
|
7
19
|
{ name: "auto", args: "[ask|auto-edit|auto]", description: "how much Nou does without asking" },
|
|
8
20
|
{ name: "engine", args: "[name]", description: "which agent answers: nou, claude-code, codex or pi" },
|
|
21
|
+
{ name: "cancel", description: "take back your last message (stops Nou; edit it and resend)" },
|
|
9
22
|
{ name: "undo", description: "undo Nou's last change" },
|
|
10
23
|
{ name: "rewind", description: "undo everything from Nou's last turn" },
|
|
11
24
|
{ name: "diff", args: "[file|n]", description: "everything changed this session (or session n)" },
|
|
@@ -15,6 +28,13 @@ export const SLASH_COMMANDS = [
|
|
|
15
28
|
{ name: "copy", args: "[code|file]", description: "copy the last reply, its last code block, or the open file" },
|
|
16
29
|
{ name: "compact", description: "summarize the conversation to free up context" },
|
|
17
30
|
{ name: "clear", description: "start a fresh conversation" },
|
|
31
|
+
{ name: "showme", args: "<what you want>", description: "Nou shows you how, step by step, and you type it (with the code shown ahead of your cursor)" },
|
|
32
|
+
{ name: "teach", args: "<what you want>", description: "same as /showme: Nou teaches you to make the change yourself, step by step" },
|
|
33
|
+
{ name: "new", description: "a new conversation tab: another agent, working alongside this one" },
|
|
34
|
+
{ name: "tab", args: "[n]", description: "list the conversation tabs, or switch to tab n" },
|
|
35
|
+
{ name: "close", description: "close this conversation tab (it stays saved for /resume)" },
|
|
36
|
+
{ name: "terminal", description: "a terminal under the code, like VS Code's (or press `)" },
|
|
37
|
+
{ name: "resume", args: "[number|last]", description: "pick up an earlier conversation in this folder (any engine)" },
|
|
18
38
|
{ name: "pull", args: "<model>", description: "download an Ollama model" },
|
|
19
39
|
{ name: "plugins", args: "[add|new <name>]", description: "your plugins and the starter ones" },
|
|
20
40
|
{ name: "mcp", args: "[add|remove|import]", description: "MCP servers: tools from other programs (/mcp add browser for a browser)" },
|
|
@@ -8,4 +8,8 @@ export const RENDER_OPTIONS = {
|
|
|
8
8
|
incrementalRendering: true,
|
|
9
9
|
// Up to 60 frames a second, so scrolling and typing keep up with the keyboard and mouse.
|
|
10
10
|
maxFps: 60,
|
|
11
|
+
// Terminals that speak the kitty keyboard protocol (kitty, Ghostty, WezTerm, iTerm2, foot...) then tell
|
|
12
|
+
// Enter from Ctrl+Enter, Cmd+Enter and Shift+Enter, so those can add a line in the chat. Others just
|
|
13
|
+
// don't answer the query and keys work as before.
|
|
14
|
+
kittyKeyboard: { mode: "auto", flags: ["disambiguateEscapeCodes"] },
|
|
11
15
|
};
|
package/dist/src/ui/text.js
CHANGED
|
@@ -140,9 +140,11 @@ export function inlineSpans(text, base = { text: "" }) {
|
|
|
140
140
|
else if (m[5] !== undefined || m[6] !== undefined)
|
|
141
141
|
out.push(...inlineSpans(m[5] ?? m[6], { ...base, italic: true }));
|
|
142
142
|
else {
|
|
143
|
-
|
|
143
|
+
// [text](https://...) links to the page; [text](file) is left for the file links.
|
|
144
|
+
const web = /^https?:\/\//.test(m[8]) ? { ...base, link: { path: "", url: m[8] } } : base;
|
|
145
|
+
out.push(...inlineSpans(m[7], web));
|
|
144
146
|
if (m[8] !== m[7])
|
|
145
|
-
out.push({ ...
|
|
147
|
+
out.push({ ...web, text: ` (${m[8]})`, dim: true });
|
|
146
148
|
}
|
|
147
149
|
pos = m.index + m[0].length;
|
|
148
150
|
}
|
|
@@ -203,7 +205,8 @@ const cells = (row) => row.trim().replace(/^\|/, "").replace(/\|$/, "").split("|
|
|
|
203
205
|
/** Renders markdown for the terminal: headings, lists, quotes, code blocks, tables and inline styles, wrapped to `width`. */
|
|
204
206
|
export function renderMarkdown(text, width, isFile) {
|
|
205
207
|
// File references become links before wrapping, so a long path that wraps is still one link.
|
|
206
|
-
|
|
208
|
+
// Web addresses first, so a file name inside a URL stays part of the URL.
|
|
209
|
+
const spansOf = (line, base) => (isFile ? linkFileRefs(linkUrls(inlineSpans(line, base)), isFile) : linkUrls(inlineSpans(line, base)));
|
|
207
210
|
const out = [];
|
|
208
211
|
const src = splitLines(text);
|
|
209
212
|
let fence = null;
|
|
@@ -291,6 +294,10 @@ const FILE_REF = /(?<![\w/\\.@~-])((?:[A-Za-z]:[\\/]|\.{0,2}[\\/])?(?:[\w@.~+-]+
|
|
|
291
294
|
export function linkFileRefs(spans, isFile) {
|
|
292
295
|
const out = [];
|
|
293
296
|
for (const span of spans) {
|
|
297
|
+
if (span.link) {
|
|
298
|
+
out.push(span);
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
294
301
|
let pos = 0;
|
|
295
302
|
for (const m of span.text.matchAll(FILE_REF)) {
|
|
296
303
|
const file = m[1].replace(/^\.[\\/]/, "");
|
|
@@ -311,6 +318,27 @@ export function linkFileRefs(spans, isFile) {
|
|
|
311
318
|
}
|
|
312
319
|
return out;
|
|
313
320
|
}
|
|
321
|
+
const URL_RE = /\bhttps?:\/\/[^\s<>()"'`]+[^\s<>()"'`.,;:!?]/g;
|
|
322
|
+
/** Splits out web addresses (http:// and https://) as link spans, keeping each span's style. */
|
|
323
|
+
export function linkUrls(spans) {
|
|
324
|
+
const out = [];
|
|
325
|
+
for (const span of spans) {
|
|
326
|
+
if (span.link || span.code || !span.text.includes("http")) {
|
|
327
|
+
out.push(span);
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
let pos = 0;
|
|
331
|
+
for (const m of span.text.matchAll(URL_RE)) {
|
|
332
|
+
if (m.index > pos)
|
|
333
|
+
out.push({ ...span, text: span.text.slice(pos, m.index) });
|
|
334
|
+
out.push({ ...span, text: m[0], link: { path: "", url: m[0] } });
|
|
335
|
+
pos = m.index + m[0].length;
|
|
336
|
+
}
|
|
337
|
+
if (pos < span.text.length)
|
|
338
|
+
out.push(pos ? { ...span, text: span.text.slice(pos) } : span);
|
|
339
|
+
}
|
|
340
|
+
return out;
|
|
341
|
+
}
|
|
314
342
|
/** The link at column `col` of a line of spans, if any. */
|
|
315
343
|
export function linkAt(spans, col) {
|
|
316
344
|
let x = 0;
|
|
@@ -369,3 +397,19 @@ export function markAnsi(line, from, to, open = "\x1b[7m", close = "\x1b[27m") {
|
|
|
369
397
|
}
|
|
370
398
|
return out;
|
|
371
399
|
}
|
|
400
|
+
/**
|
|
401
|
+
* A reply still streaming, with its open markdown closed for now (a code block, **bold**, `code`), so it
|
|
402
|
+
* renders the way it will once finished instead of reflowing when the closing marker arrives.
|
|
403
|
+
*/
|
|
404
|
+
export function closeOpenMarkdown(text) {
|
|
405
|
+
const fences = text.match(/^\s*(`{3,}|~{3,})/gm)?.length ?? 0;
|
|
406
|
+
if (fences % 2)
|
|
407
|
+
return `${text}\n\`\`\``;
|
|
408
|
+
const last = text.slice(text.lastIndexOf("\n") + 1);
|
|
409
|
+
let out = text;
|
|
410
|
+
if ((last.match(/`/g)?.length ?? 0) % 2)
|
|
411
|
+
out += "`";
|
|
412
|
+
else if ((last.match(/\*\*/g)?.length ?? 0) % 2)
|
|
413
|
+
out += "**";
|
|
414
|
+
return out;
|
|
415
|
+
}
|
package/dist/src/ui/theme.js
CHANGED
|
@@ -12,6 +12,8 @@ export const C = {
|
|
|
12
12
|
dim: "#565f89",
|
|
13
13
|
panel: "#1f2335",
|
|
14
14
|
};
|
|
15
|
+
/** Every spinner and shimmer ticks on this one interval, so they redraw together in a single frame. */
|
|
16
|
+
export const ANIMATION_MS = 100;
|
|
15
17
|
export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
16
18
|
const GRADIENT = [C.accent, "#8c9cf7", C.accent2, "#c39af2", "#e08ad6", C.red, C.orange, C.yellow, C.green, C.cyan];
|
|
17
19
|
/** Colors for each character of a label, shifted by `frame` so it shimmers when animated. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nou-cli",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.4",
|
|
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": {
|