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.
@@ -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
+ });
@@ -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
+ }
@@ -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
  };
@@ -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
- out.push(...inlineSpans(m[7], base));
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({ ...base, text: ` (${m[8]})`, dim: true });
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
- const spansOf = (line, base) => (isFile ? linkFileRefs(inlineSpans(line, base), isFile) : inlineSpans(line, base));
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
+ }
@@ -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.2",
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": {