nou-cli 0.5.3 → 0.5.6
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 +13 -5
- package/dist/src/cli.js +33 -0
- package/dist/src/config/index.js +3 -0
- package/dist/src/diagnostics/providers.js +23 -2
- package/dist/src/engines/codex.js +28 -1
- package/dist/src/games/index.js +220 -30
- package/dist/src/guide/index.js +100 -0
- package/dist/src/syntax/index.js +17 -17
- package/dist/src/ui/App.js +398 -54
- package/dist/src/ui/Chat.js +103 -19
- package/dist/src/ui/CodeView.js +70 -19
- package/dist/src/ui/Editor.js +44 -7
- package/dist/src/ui/FileTree.js +4 -2
- package/dist/src/ui/GamePanel.js +42 -14
- package/dist/src/ui/Header.js +5 -2
- package/dist/src/ui/ProblemsView.js +4 -2
- package/dist/src/ui/Settings.js +10 -3
- package/dist/src/ui/Terminal.js +5 -2
- package/dist/src/ui/clipboard.js +19 -0
- package/dist/src/ui/commands.js +21 -1
- package/dist/src/ui/focus.js +59 -0
- package/dist/src/ui/redraw.js +16 -0
- package/dist/src/ui/render-options.js +3 -0
- package/dist/src/ui/text.js +76 -3
- package/dist/src/ui/theme.js +2 -0
- package/package.json +1 -1
package/dist/src/ui/Header.js
CHANGED
|
@@ -2,11 +2,14 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
import { memo } from "react";
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { Box, Text, useAnimation } from "ink";
|
|
5
|
-
import { C, SPINNER, gradient } from "./theme.js";
|
|
5
|
+
import { C, SPINNER, gradient, ANIMATION_MS } from "./theme.js";
|
|
6
|
+
import { useTerminalFocused } from "./focus.js";
|
|
6
7
|
import { currentVersion } from "../update/index.js";
|
|
7
8
|
const VERSION = `v${currentVersion()}`;
|
|
8
9
|
export const Header = memo(function Header({ width, branch, changes, model, local, busy, update }) {
|
|
9
|
-
|
|
10
|
+
// The spinner stops while Nou's window is in the background: nobody sees it, and each frame is a redraw.
|
|
11
|
+
const windowFocused = useTerminalFocused();
|
|
12
|
+
const { frame } = useAnimation({ interval: ANIMATION_MS, isActive: !!busy && windowFocused });
|
|
10
13
|
const project = path.basename(process.cwd());
|
|
11
14
|
return (_jsxs(Box, { width: width, justifyContent: "space-between", children: [_jsx(Box, { flexShrink: 1, flexGrow: 1, overflow: "hidden", children: _jsxs(Text, { wrap: "truncate-end", children: [" ", gradient("◆ NOU", busy ? frame : 0).map((g, i) => _jsx(Text, { bold: true, color: g.color, children: g.ch }, i)), _jsxs(Text, { color: C.dim, children: [" ", VERSION] }), _jsx(Text, { color: C.dim, children: " \u2502 " }), _jsx(Text, { color: C.text, children: project }), branch && (_jsxs(_Fragment, { children: [_jsx(Text, { color: C.dim, children: " \u2502 " }), _jsxs(Text, { color: C.accent2, children: ["\u2387 ", branch] }), changes > 0 && _jsxs(Text, { color: C.yellow, children: [" \u00B1", changes] })] })), busy && _jsxs(Text, { color: C.accent2, children: [" ", SPINNER[frame % SPINNER.length], " ", busy] })] }) }), _jsx(Box, { flexShrink: 0, children: _jsxs(Text, { wrap: "truncate-end", children: [update && width >= 110 && _jsxs(Text, { color: C.green, bold: true, children: ["\u2B06 v", update, " available \u00B7 run `nou update` "] }), _jsx(Text, { backgroundColor: local ? C.green : C.accent2, color: "black", bold: true, children: ` ${local ? "⌂ LOCAL" : "☁ CLOUD"} ` }), _jsx(Text, { backgroundColor: C.panel, color: C.text, children: ` ${model} ` }), " "] }) })] }));
|
|
12
15
|
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { jsxs as _jsxs, jsx as _jsx, 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 { C, SPINNER, fileIcon } from "./theme.js";
|
|
4
|
+
import { C, SPINNER, fileIcon, ANIMATION_MS } from "./theme.js";
|
|
5
|
+
import { useTerminalFocused } from "./focus.js";
|
|
5
6
|
import { Pane } from "./Pane.js";
|
|
6
7
|
export const SEVERITY_STYLE = {
|
|
7
8
|
error: { icon: "✖", color: C.red },
|
|
@@ -11,7 +12,8 @@ export const SEVERITY_STYLE = {
|
|
|
11
12
|
export function ProblemsView({ width, height, focused, problems, checking, wheel, onJump, onClose }) {
|
|
12
13
|
const [cursor, setCursor] = useState(0);
|
|
13
14
|
const [onlyErrors, setOnlyErrors] = useState(false);
|
|
14
|
-
const
|
|
15
|
+
const windowFocused = useTerminalFocused();
|
|
16
|
+
const { frame } = useAnimation({ interval: ANIMATION_MS, isActive: checking.length > 0 && windowFocused });
|
|
15
17
|
const rows = useMemo(() => {
|
|
16
18
|
const list = onlyErrors ? problems.filter((d) => d.severity === "error") : problems;
|
|
17
19
|
const out = [];
|
package/dist/src/ui/Settings.js
CHANGED
|
@@ -11,7 +11,7 @@ import { PLUGIN_DIRS, isEnabled, trustState } from "../plugins/index.js";
|
|
|
11
11
|
import { currentVersion } from "../update/index.js";
|
|
12
12
|
import { describe, providers, rejectedKey } from "../providers/index.js";
|
|
13
13
|
import { ENGINES, isInstalled } from "../engines/index.js";
|
|
14
|
-
import { C, SPINNER } from "./theme.js";
|
|
14
|
+
import { C, SPINNER, ANIMATION_MS } from "./theme.js";
|
|
15
15
|
/** A few lines of code to show what a code theme looks like. */
|
|
16
16
|
const THEME_PREVIEW = `// Greets someone, politely
|
|
17
17
|
export function greet(name: string, times = 2): string {
|
|
@@ -40,9 +40,11 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
|
|
|
40
40
|
const [models, setModels] = useState({});
|
|
41
41
|
const [connecting, setConnecting] = useState(connectTo?.connect ? { step: 0, values: {} } : null);
|
|
42
42
|
const [field, setField] = useState(() => (connectTo?.connect ? initialValue(connectTo.connect[0]) : ""));
|
|
43
|
-
const { frame } = useAnimation({ interval: 80 });
|
|
44
43
|
const provider = providers[row] ?? providers[0];
|
|
45
44
|
const listed = models[provider.id];
|
|
45
|
+
// Only "loading models…" animates; a timer running the whole time settings is open would redraw it 10
|
|
46
|
+
// times a second for nothing.
|
|
47
|
+
const { frame } = useAnimation({ interval: ANIMATION_MS, isActive: listed === "loading" });
|
|
46
48
|
// Load the model list for the highlighted provider.
|
|
47
49
|
useEffect(() => {
|
|
48
50
|
if (tab !== "Model" || connecting || provider.unavailable() || models[provider.id])
|
|
@@ -190,6 +192,11 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
|
|
|
190
192
|
used += cost(--first);
|
|
191
193
|
return { first, last };
|
|
192
194
|
})();
|
|
195
|
+
const gameWindow = (() => {
|
|
196
|
+
const fit = Math.max(1, Math.floor((boxHeight - 4) / 3));
|
|
197
|
+
const first = Math.max(0, Math.min((tab === "Games" ? row : 0) - fit + 1, GAMES.length - fit));
|
|
198
|
+
return { first, last: first + fit };
|
|
199
|
+
})();
|
|
193
200
|
const toggle = (on, warn) => (_jsx(Text, { color: on ? (warn ? C.orange : C.green) : C.dim, bold: true, children: on ? "● ON " : "○ OFF" }));
|
|
194
201
|
const body = () => {
|
|
195
202
|
if (tab === "Model") {
|
|
@@ -252,7 +259,7 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
|
|
|
252
259
|
}), prefWindow.last < PREFS.length ? _jsxs(Text, { color: C.dim, children: [" \u2193 ", PREFS.length - prefWindow.last, " more"] }) : null] }));
|
|
253
260
|
}
|
|
254
261
|
if (tab === "Games") {
|
|
255
|
-
return (_jsxs(Box, { flexDirection: "column", height: boxHeight, borderStyle: "round", borderColor: C.accent, paddingX: 1, children: [_jsx(Text, { color: C.dim, children: "Little games for a break while Nou works. Added ones show in the Games panel: /games
|
|
262
|
+
return (_jsxs(Box, { flexDirection: "column", height: boxHeight, borderStyle: "round", borderColor: C.accent, paddingX: 1, children: [_jsx(Text, { color: C.dim, wrap: "truncate-end", children: "Little games for a break while Nou works. Added ones show in the Games panel: /games." }), _jsx(Text, { children: " " }), GAMES.map((g, i) => ({ g, i })).slice(gameWindow.first, gameWindow.last).map(({ g, i }) => {
|
|
256
263
|
const selected = i === row;
|
|
257
264
|
const added = settings.games.includes(g.id);
|
|
258
265
|
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 ? "▸ " : " " }), added ? _jsx(Text, { color: C.green, bold: true, children: "\u2713 ADDED" }) : _jsx(Text, { color: C.accent2, bold: true, children: "\u2B07 GET " }), _jsxs(Text, { color: C.text, bold: true, children: [" ", g.title] }), _jsxs(Text, { color: C.dim, children: [" ", g.controls] })] }), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: [" ", g.blurb] })] }, g.id));
|
package/dist/src/ui/Terminal.js
CHANGED
|
@@ -6,8 +6,9 @@ const COLORS = { out: C.text, command: C.accent, info: C.dim, error: C.red };
|
|
|
6
6
|
/**
|
|
7
7
|
* The terminal panel, under the code like VS Code's: type a command and Enter runs it. While one runs,
|
|
8
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.
|
|
9
10
|
*/
|
|
10
|
-
export const Terminal = memo(function Terminal({ shell, version, width, height, focused, wheel, onLeave }) {
|
|
11
|
+
export const Terminal = memo(function Terminal({ shell, version, width, height, focused, wheel, onLeave, onResize }) {
|
|
11
12
|
const [input, setInput] = useState("");
|
|
12
13
|
const [history, setHistory] = useState([]);
|
|
13
14
|
const [back, setBack] = useState(-1);
|
|
@@ -57,6 +58,8 @@ export const Terminal = memo(function Terminal({ shell, version, width, height,
|
|
|
57
58
|
setBack(-1);
|
|
58
59
|
return;
|
|
59
60
|
}
|
|
61
|
+
if (key.meta && (key.upArrow || key.downArrow))
|
|
62
|
+
return onResize(key.upArrow ? 2 : -2);
|
|
60
63
|
if (key.upArrow || key.downArrow) {
|
|
61
64
|
if (!history.length)
|
|
62
65
|
return;
|
|
@@ -79,6 +82,6 @@ export const Terminal = memo(function Terminal({ shell, version, width, height,
|
|
|
79
82
|
setInput((v) => v + typed.replace(/[\r\n]+/g, " "));
|
|
80
83
|
}, { isActive: focused });
|
|
81
84
|
const color = focused ? C.accent : C.dim;
|
|
82
|
-
const title = ` ▸ Terminal${shell.running ? " ● running · Ctrl+C stops" : ""}${at ? ` ↑ ${at} lines` : ""}`;
|
|
85
|
+
const title = ` ▸ Terminal${shell.running ? " ● running · Ctrl+C stops" : ""}${at ? ` ↑ ${at} lines` : ""}${focused && !shell.running && !at ? " ⇕ drag this edge or Alt+↑↓ to resize" : ""}`;
|
|
83
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] })] }));
|
|
84
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(["fold", "unfold", "redraw", "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" },
|
|
@@ -16,12 +28,20 @@ export const SLASH_COMMANDS = [
|
|
|
16
28
|
{ name: "copy", args: "[code|file]", description: "copy the last reply, its last code block, or the open file" },
|
|
17
29
|
{ name: "compact", description: "summarize the conversation to free up context" },
|
|
18
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: "fold", description: "fold every function and block in the open file to its first line (z toggles)" },
|
|
37
|
+
{ name: "unfold", description: "open every fold in the open file" },
|
|
38
|
+
{ name: "redraw", description: "draw the whole screen again (Ctrl+L), if it ever looks shifted or clicks land a row off" },
|
|
19
39
|
{ name: "terminal", description: "a terminal under the code, like VS Code's (or press `)" },
|
|
20
40
|
{ name: "resume", args: "[number|last]", description: "pick up an earlier conversation in this folder (any engine)" },
|
|
21
41
|
{ name: "pull", args: "<model>", description: "download an Ollama model" },
|
|
22
42
|
{ name: "plugins", args: "[add|new <name>]", description: "your plugins and the starter ones" },
|
|
23
43
|
{ name: "mcp", args: "[add|remove|import]", description: "MCP servers: tools from other programs (/mcp add browser for a browser)" },
|
|
24
|
-
{ name: "games", description: "open or close the Games panel (Pong, Snake, Breakout)" },
|
|
44
|
+
{ name: "games", description: "open or close the Games panel (Pong, Snake, Breakout, Dodge, Hop, Pairs)" },
|
|
25
45
|
{ name: "version", description: "which version of Nou this is" },
|
|
26
46
|
{ name: "help", description: "all commands and keys" },
|
|
27
47
|
];
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { useEffect, useSyncExternalStore } from "react";
|
|
2
|
+
import { useStdin } from "ink";
|
|
3
|
+
/**
|
|
4
|
+
* Whether Nou's terminal window is the one in front. Terminals that report focus (xterm's mode 1004:
|
|
5
|
+
* iTerm2, Terminal.app, kitty, Ghostty, WezTerm, Windows Terminal, VS Code's, tmux with focus-events on)
|
|
6
|
+
* send ESC [ I and ESC [ O as you switch windows; Nou then stops its spinners and git polling while you're
|
|
7
|
+
* elsewhere, so it doesn't keep the CPU (and the terminal's redraws) busy for something nobody is watching.
|
|
8
|
+
* Terminals that don't report it never send either, and Nou stays "focused" as before.
|
|
9
|
+
*/
|
|
10
|
+
let focused = true;
|
|
11
|
+
const listeners = new Set();
|
|
12
|
+
export function setTerminalFocused(next) {
|
|
13
|
+
if (next === focused)
|
|
14
|
+
return;
|
|
15
|
+
focused = next;
|
|
16
|
+
for (const listener of listeners)
|
|
17
|
+
listener();
|
|
18
|
+
}
|
|
19
|
+
export const terminalFocused = () => focused;
|
|
20
|
+
export function onTerminalFocus(listener) {
|
|
21
|
+
listeners.add(listener);
|
|
22
|
+
return () => listeners.delete(listener);
|
|
23
|
+
}
|
|
24
|
+
export function useTerminalFocused() {
|
|
25
|
+
return useSyncExternalStore(onTerminalFocus, terminalFocused, terminalFocused);
|
|
26
|
+
}
|
|
27
|
+
/** The last focus report in a chunk of terminal input, if there is one. Ink ignores these, so they're never typed. */
|
|
28
|
+
export function parseFocus(chunk) {
|
|
29
|
+
const at = Math.max(chunk.lastIndexOf("\x1b[I"), chunk.lastIndexOf("\x1b[O"));
|
|
30
|
+
return at < 0 ? null : chunk[at + 2] === "I";
|
|
31
|
+
}
|
|
32
|
+
const ENABLE = "\x1b[?1004h";
|
|
33
|
+
const DISABLE = "\x1b[?1004l";
|
|
34
|
+
let exitHookInstalled = false;
|
|
35
|
+
/** Asks the terminal to report focus while mounted, and keeps `useTerminalFocused` up to date. */
|
|
36
|
+
export function useFocusReporting(enabled) {
|
|
37
|
+
const { stdin } = useStdin();
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!enabled || !stdin)
|
|
40
|
+
return;
|
|
41
|
+
const terminal = process.stdout.isTTY ? process.stdout : null;
|
|
42
|
+
terminal?.write(ENABLE);
|
|
43
|
+
if (terminal && !exitHookInstalled) {
|
|
44
|
+
process.on("exit", () => process.stdout.write(DISABLE));
|
|
45
|
+
exitHookInstalled = true;
|
|
46
|
+
}
|
|
47
|
+
const onData = (data) => {
|
|
48
|
+
const next = parseFocus(String(data));
|
|
49
|
+
if (next !== null)
|
|
50
|
+
setTerminalFocused(next);
|
|
51
|
+
};
|
|
52
|
+
stdin.on("data", onData);
|
|
53
|
+
return () => {
|
|
54
|
+
stdin.off("data", onData);
|
|
55
|
+
terminal?.write(DISABLE);
|
|
56
|
+
setTerminalFocused(true);
|
|
57
|
+
};
|
|
58
|
+
}, [enabled, stdin]);
|
|
59
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A full repaint: the app normally rewrites only the lines that changed, so if anything outside it ever
|
|
3
|
+
* wrote to the terminal, the screen would stay shifted (clicks landing a row off) until something forces
|
|
4
|
+
* every line out again. The CLI hands over Ink's clear here; /redraw and Ctrl+L call it.
|
|
5
|
+
*/
|
|
6
|
+
let clearScreen = null;
|
|
7
|
+
export function setScreenClear(fn) {
|
|
8
|
+
clearScreen = fn;
|
|
9
|
+
}
|
|
10
|
+
/** Forgets what's on screen, so the next frame is drawn in full; false where there's no terminal (tests). */
|
|
11
|
+
export function forgetScreen() {
|
|
12
|
+
if (!clearScreen)
|
|
13
|
+
return false;
|
|
14
|
+
clearScreen();
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
@@ -8,6 +8,9 @@ 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
|
+
// Library output goes to ~/.nou/nou.log instead (see cli.ts): Ink's console patch would print it into
|
|
12
|
+
// the full-screen app, scrolling it so clicks land a row off.
|
|
13
|
+
patchConsole: false,
|
|
11
14
|
// Terminals that speak the kitty keyboard protocol (kitty, Ghostty, WezTerm, iTerm2, foot...) then tell
|
|
12
15
|
// Enter from Ctrl+Enter, Cmd+Enter and Shift+Enter, so those can add a line in the chat. Others just
|
|
13
16
|
// don't answer the query and keys work as before.
|
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;
|
|
@@ -385,3 +413,48 @@ export function closeOpenMarkdown(text) {
|
|
|
385
413
|
out += "**";
|
|
386
414
|
return out;
|
|
387
415
|
}
|
|
416
|
+
/**
|
|
417
|
+
* Regions that can fold, from indentation (like VS Code without a language server): a line followed by
|
|
418
|
+
* more-indented lines folds them, and the closing bracket that ends the block. 1-based line -> last line.
|
|
419
|
+
*/
|
|
420
|
+
export function foldRanges(text) {
|
|
421
|
+
const lines = text.replace(/\r\n/g, "\n").split("\n");
|
|
422
|
+
const indent = (s) => s.match(/^[ \t]*/)[0].replace(/\t/g, " ").length;
|
|
423
|
+
const blank = (s) => !s.trim();
|
|
424
|
+
const out = new Map();
|
|
425
|
+
for (let i = 0; i < lines.length; i++) {
|
|
426
|
+
if (blank(lines[i]))
|
|
427
|
+
continue;
|
|
428
|
+
const base = indent(lines[i]);
|
|
429
|
+
let j = i + 1;
|
|
430
|
+
while (j < lines.length && blank(lines[j]))
|
|
431
|
+
j++;
|
|
432
|
+
if (j >= lines.length || indent(lines[j]) <= base)
|
|
433
|
+
continue;
|
|
434
|
+
let end = j;
|
|
435
|
+
for (let k = j; k < lines.length; k++) {
|
|
436
|
+
if (blank(lines[k]))
|
|
437
|
+
continue;
|
|
438
|
+
if (indent(lines[k]) <= base)
|
|
439
|
+
break;
|
|
440
|
+
end = k;
|
|
441
|
+
}
|
|
442
|
+
// The line that closes the block (`}`, `)`, `]`, `end`...) folds with it.
|
|
443
|
+
const next = end + 1;
|
|
444
|
+
if (next < lines.length && indent(lines[next]) === base && /^\s*([}\])]|end\b|<\/)/.test(lines[next]))
|
|
445
|
+
end = next;
|
|
446
|
+
if (end > i)
|
|
447
|
+
out.set(i + 1, end + 1);
|
|
448
|
+
}
|
|
449
|
+
return out;
|
|
450
|
+
}
|
|
451
|
+
/** The file lines (1-based) left showing with `folded` regions (start lines) closed. */
|
|
452
|
+
export function unfoldedLines(count, ranges, folded) {
|
|
453
|
+
const out = [];
|
|
454
|
+
for (let n = 1; n <= count; n++) {
|
|
455
|
+
out.push(n);
|
|
456
|
+
if (folded.has(n) && ranges.has(n))
|
|
457
|
+
n = ranges.get(n);
|
|
458
|
+
}
|
|
459
|
+
return out;
|
|
460
|
+
}
|
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.6",
|
|
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": {
|