nou-cli 0.5.3 → 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 +7 -4
- package/dist/src/config/index.js +2 -0
- package/dist/src/guide/index.js +100 -0
- package/dist/src/syntax/index.js +17 -17
- package/dist/src/ui/App.js +336 -42
- package/dist/src/ui/Chat.js +100 -19
- package/dist/src/ui/Editor.js +44 -7
- package/dist/src/ui/FileTree.js +2 -2
- package/dist/src/ui/GamePanel.js +1 -1
- 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/Terminal.js +5 -2
- package/dist/src/ui/clipboard.js +19 -0
- package/dist/src/ui/commands.js +17 -0
- package/dist/src/ui/text.js +31 -3
- package/dist/src/ui/theme.js +2 -0
- package/package.json +1 -1
package/dist/src/ui/Chat.js
CHANGED
|
@@ -3,13 +3,23 @@ import { memo, useEffect, useMemo, useRef, useState } from "react";
|
|
|
3
3
|
import { Box, Text, useAnimation, useInput } from "ink";
|
|
4
4
|
import { statSync } from "fs";
|
|
5
5
|
import path from "path";
|
|
6
|
-
import { closeOpenMarkdown, linkAt, linkFileRefs, renderMarkdown, wrap } from "./text.js";
|
|
6
|
+
import { closeOpenMarkdown, linkAt, linkFileRefs, linkUrls, renderMarkdown, wrap } from "./text.js";
|
|
7
7
|
import { ChatInput, inputHeight } from "./ChatInput.js";
|
|
8
8
|
import { copyToClipboard } from "./clipboard.js";
|
|
9
|
-
import { C, SPINNER } from "./theme.js";
|
|
9
|
+
import { C, SPINNER, ANIMATION_MS } from "./theme.js";
|
|
10
10
|
import { Pane } from "./Pane.js";
|
|
11
11
|
import { matchCommands } from "./commands.js";
|
|
12
12
|
import { Scrollbar, topFor } from "./scroll.js";
|
|
13
|
+
/**
|
|
14
|
+
* What the agent did between replies (files read, commands run...) folds into one line, like Claude's
|
|
15
|
+
* "▸ 5 steps": click it (or Ctrl+O for all of them) to see each step. `open` holds the runs shown open.
|
|
16
|
+
*/
|
|
17
|
+
function stepLines(steps, open, live, inner) {
|
|
18
|
+
const last = steps[steps.length - 1].text.replace(/\s+/g, " ");
|
|
19
|
+
const summary = `${open ? "▾" : "▸"} ${steps.length} steps${open ? "" : ` · ${live ? "now: " : "last: "}${last}`}`;
|
|
20
|
+
const head = { text: ` ${summary.length > inner - 4 ? `${summary.slice(0, inner - 5)}…` : summary}`, color: open ? C.accent2 : C.dim, lead: " ⚙", leadColor: C.dim, toggle: steps[0] };
|
|
21
|
+
return open ? [head, ...steps.flatMap((e) => cachedLines(e, inner).map((l) => (l.lead === " ⚙" ? { ...l, lead: " " } : l)))] : [head];
|
|
22
|
+
}
|
|
13
23
|
const BAR = "▌ ";
|
|
14
24
|
// Whether a path in a message is a file in the project, remembered so each one is checked once.
|
|
15
25
|
const knownFiles = new Map();
|
|
@@ -54,7 +64,7 @@ function entryLines(entry, inner, links = true) {
|
|
|
54
64
|
? { icon: "⚙", color: C.dim }
|
|
55
65
|
: entry.kind === "error" ? { icon: "✖", color: C.red } : { icon: "ℹ", color: C.accent };
|
|
56
66
|
wrap(entry.text, inner - 4).forEach((line, i) => {
|
|
57
|
-
const spans = links && entry.kind !== "user" ? linkFileRefs([{ text: ` ${line}` }], isProjectFile) : [];
|
|
67
|
+
const spans = links && entry.kind !== "user" ? linkFileRefs(linkUrls([{ text: ` ${line}` }]), isProjectFile) : [];
|
|
58
68
|
out.push({ text: ` ${line}`, color: style.color, lead: i === 0 ? ` ${style.icon}` : " ", leadColor: style.color, ...(spans.some((sp) => sp.link) ? { spans } : {}) });
|
|
59
69
|
});
|
|
60
70
|
}
|
|
@@ -71,17 +81,67 @@ function cachedLines(entry, inner) {
|
|
|
71
81
|
laidOut.set(entry, { inner, lines });
|
|
72
82
|
return lines;
|
|
73
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* Where each tab sits in the chat's title row (columns from the pane's inner left edge), and the "+"
|
|
86
|
+
* that opens a new one: shared by the drawing and the clicks.
|
|
87
|
+
*/
|
|
88
|
+
export function chatTabZones(tabs) {
|
|
89
|
+
// One tab: just "Chat +", to leave the title row its room.
|
|
90
|
+
if (tabs.length === 1)
|
|
91
|
+
return { zones: [{ id: tabs[0].id, from: 3, to: 7 }], plus: 8, width: 9 };
|
|
92
|
+
let x = 3; // " ◆ "
|
|
93
|
+
const zones = tabs.map((t) => {
|
|
94
|
+
const w = tabText(t).length;
|
|
95
|
+
const zone = { id: t.id, from: x, to: x + w };
|
|
96
|
+
x += w + 1;
|
|
97
|
+
return zone;
|
|
98
|
+
});
|
|
99
|
+
return { zones, plus: x + 1, width: x + 3 };
|
|
100
|
+
}
|
|
101
|
+
const tabText = (t) => ` ${t.state === "busy" ? "● " : t.state === "waiting" ? "! " : ""}${t.label} `;
|
|
74
102
|
const lineText = (line) => (line.spans ? line.spans.map((s) => s.text).join("") : line.text ?? "");
|
|
75
|
-
export const Chat = memo(function Chat({ width, height, focused, entries, streaming, busy, approval, approvalNote, contextChip, wheel, scrollTo, prefill, extraCommands, onMenu, onSubmit, onAnswer, click, onOpenRef, select, queued = [] }) {
|
|
76
|
-
const [input,
|
|
103
|
+
export const Chat = memo(function Chat({ width, height, focused, entries, streaming, busy, approval, approvalNote, contextChip, wheel, scrollTo, prefill, extraCommands, onMenu, onSubmit, onAnswer, click, onOpenRef, select, tabs, queued = [] }) {
|
|
104
|
+
const [input, setInputText] = useState("");
|
|
105
|
+
// Whether the chat has the keyboard right now: a key handler from a render before focus moved away can
|
|
106
|
+
// still be attached for a moment (on a busy machine), and its keys belong to the pane that has focus.
|
|
107
|
+
const focusedNow = useRef(focused);
|
|
108
|
+
focusedNow.current = focused;
|
|
109
|
+
const [menuRow, setMenuRow] = useState(0);
|
|
110
|
+
/** A new input also puts the command menu back on its first row, in the same render. */
|
|
111
|
+
const setInput = (text) => {
|
|
112
|
+
setInputText(text);
|
|
113
|
+
setMenuRow(0);
|
|
114
|
+
};
|
|
77
115
|
useEffect(() => {
|
|
78
116
|
if (prefill)
|
|
79
117
|
setInput(prefill.text);
|
|
80
118
|
}, [prefill?.nonce]);
|
|
81
119
|
const [scrollBack, setScrollBack] = useState(0);
|
|
82
|
-
const { frame } = useAnimation({ interval:
|
|
120
|
+
const { frame } = useAnimation({ interval: ANIMATION_MS, isActive: !!busy });
|
|
83
121
|
const inner = width - 3; // minus the borders and the scrollbar
|
|
84
|
-
const
|
|
122
|
+
const [openSteps, setOpenSteps] = useState(new Set());
|
|
123
|
+
const [allOpen, setAllOpen] = useState(false);
|
|
124
|
+
const history = useMemo(() => {
|
|
125
|
+
const out = [];
|
|
126
|
+
for (let i = 0; i < entries.length;) {
|
|
127
|
+
if (entries[i].kind !== "tool") {
|
|
128
|
+
out.push(...cachedLines(entries[i], inner));
|
|
129
|
+
i++;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
// A run of steps: one alone is shown as is, two or more fold into a line.
|
|
133
|
+
let j = i;
|
|
134
|
+
while (j < entries.length && entries[j].kind === "tool")
|
|
135
|
+
j++;
|
|
136
|
+
const steps = entries.slice(i, j);
|
|
137
|
+
if (steps.length === 1)
|
|
138
|
+
out.push(...cachedLines(steps[0], inner));
|
|
139
|
+
else
|
|
140
|
+
out.push(...stepLines(steps, allOpen || openSteps.has(steps[0]), !!busy && j === entries.length, inner));
|
|
141
|
+
i = j;
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}, [entries, inner, openSteps, allOpen, !!busy]);
|
|
85
145
|
const lines = useMemo(() => (streaming ? [...history, ...entryLines({ kind: "assistant", text: closeOpenMarkdown(streaming) }, inner, false)] : history), [history, streaming, inner]);
|
|
86
146
|
// At the bottom, new output scrolls into view. Scrolled up to read, the view stays where it is
|
|
87
147
|
// (counted from the bottom, so it moves up by however many lines arrived) until you scroll back down.
|
|
@@ -135,8 +195,6 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
|
|
|
135
195
|
// Typing "/" opens a menu of commands with what each does; ↑↓ pick one, Tab completes, Enter runs it.
|
|
136
196
|
const menuOpen = focused && !approval && input.startsWith("/") && !input.includes(" ") && !input.includes("\n");
|
|
137
197
|
const matches = useMemo(() => (menuOpen ? matchCommands(input, extraCommands) : []), [menuOpen, input, extraCommands]);
|
|
138
|
-
const [menuRow, setMenuRow] = useState(0);
|
|
139
|
-
useEffect(() => setMenuRow(0), [input]);
|
|
140
198
|
const showMenu = matches.length > 0;
|
|
141
199
|
useEffect(() => onMenu?.(showMenu), [showMenu]);
|
|
142
200
|
const menuRows = Math.min(matches.length, Math.max(1, Math.min(7, height - 12)));
|
|
@@ -164,6 +222,20 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
|
|
|
164
222
|
if (!click)
|
|
165
223
|
return;
|
|
166
224
|
const line = visible[click.row];
|
|
225
|
+
const run = line?.toggle;
|
|
226
|
+
if (run) {
|
|
227
|
+
setOpenSteps((open) => {
|
|
228
|
+
const next = new Set(open);
|
|
229
|
+
if (next.has(run) || allOpen)
|
|
230
|
+
next.delete(run);
|
|
231
|
+
else
|
|
232
|
+
next.add(run);
|
|
233
|
+
return next;
|
|
234
|
+
});
|
|
235
|
+
if (allOpen)
|
|
236
|
+
setAllOpen(false);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
167
239
|
const ref = line?.spans ? linkAt(line.spans, click.col) : null;
|
|
168
240
|
if (ref)
|
|
169
241
|
onOpenRef?.(ref);
|
|
@@ -225,13 +297,19 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
|
|
|
225
297
|
else if (typed && typed >= " ")
|
|
226
298
|
setInput(typed); // printable, not a control key
|
|
227
299
|
}, { isActive: focused && !!approval && !input });
|
|
228
|
-
useInput((
|
|
300
|
+
useInput((input, key) => {
|
|
229
301
|
if (key.pageUp || (key.upArrow && key.shift))
|
|
230
302
|
setScrollBack((b) => Math.min(maxBack, Math.min(b, maxBack) + Math.max(1, rows - 2)));
|
|
231
303
|
else if (key.pageDown || (key.downArrow && key.shift))
|
|
232
304
|
setScrollBack((b) => Math.max(0, Math.min(b, maxBack) - Math.max(1, rows - 2)));
|
|
233
305
|
else if (key.ctrl && key.home)
|
|
234
306
|
setScrollBack(maxBack);
|
|
307
|
+
else if (key.ctrl && input === "o") {
|
|
308
|
+
// Open every run of steps, or fold them all (the ones opened by a click too).
|
|
309
|
+
setAllOpen(!allOpen);
|
|
310
|
+
if (allOpen)
|
|
311
|
+
setOpenSteps(new Set());
|
|
312
|
+
}
|
|
235
313
|
else if (key.ctrl && key.end)
|
|
236
314
|
setScrollBack(0);
|
|
237
315
|
}, { isActive: focused });
|
|
@@ -262,19 +340,22 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
|
|
|
262
340
|
? line.spans.map((span, i) => (_jsx(Text, { bold: !!span.bold || !!line.bold, italic: !!span.italic, dimColor: !!span.dim && !span.link, underline: !!span.link, ...(span.link ? { color: C.cyan } : span.code ? { color: C.yellow } : line.color ? { color: line.color } : { color: C.text }), children: span.text }, i)))
|
|
263
341
|
: _jsx(Text, { bold: !!line.bold, dimColor: !!line.dim, ...(line.color ? { color: line.color } : {}), children: line.text || " " })] }, key));
|
|
264
342
|
const empty = entries.length === 0 && !streaming;
|
|
265
|
-
const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u25C6" }), " Chat", flash ? _jsxs(Text, { color: C.green, bold: true, children: [" ", flash] }) : back
|
|
343
|
+
const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u25C6" }), " ", tabs && tabs.items.length > 1 ? (_jsxs(_Fragment, { children: [tabs.items.map((t) => (_jsxs(Text, { children: [_jsx(Text, { ...(t.id === tabs.active ? { backgroundColor: C.panel, color: C.text, bold: true } : { color: t.state === "waiting" ? C.yellow : C.dim }), children: tabText(t) }), " "] }, t.id))), _jsx(Text, { color: C.accent2, bold: true, children: " + " })] })) : _jsxs(_Fragment, { children: ["Chat", tabs ? _jsx(Text, { color: C.accent2, bold: true, children: " +" }) : null] }), flash ? _jsxs(Text, { color: C.green, bold: true, children: [" ", flash] }) : back
|
|
266
344
|
? unread
|
|
267
345
|
? _jsxs(Text, { color: C.green, bold: true, children: [" \u2193 ", unread, " new lines below", _jsx(Text, { color: C.dim, children: " \u00B7 PgDn / wheel / Ctrl+End" })] })
|
|
268
346
|
: _jsxs(Text, { color: C.yellow, children: [" \u2191 scrolled ", back, " lines", _jsx(Text, { color: C.dim, children: " \u00B7 PgDn / wheel / Ctrl+End" })] })
|
|
269
347
|
: maxBack > 0 ? _jsx(Text, { color: C.dim, children: " PgUp / wheel to scroll" }) : null] }));
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
348
|
+
// The messages, rebuilt only when they or the scroll position change: typing, the context chip and the
|
|
349
|
+
// spinner re-render the chat without touching its (often hundreds of) lines.
|
|
350
|
+
const messages = useMemo(() => (_jsx(_Fragment, { 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." })] })) : (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 2, flexShrink: 0, children: _jsx(Text, { children: visible.map((line, i) => _jsx(Text, { color: line.leadColor ?? C.dim, children: `${line.lead ?? " "}${i < visible.length - 1 ? "\n" : ""}` }, i)) }) }), _jsx(Box, { flexDirection: "column", width: inner - 2, flexShrink: 0, children: visible.map((line, i) => {
|
|
351
|
+
const range = selectedIn(firstShown + i);
|
|
352
|
+
if (!range)
|
|
353
|
+
return render(line, firstShown + i, false);
|
|
354
|
+
const t = lineText(line) || " ";
|
|
355
|
+
return (_jsxs(Text, { wrap: "truncate-end", color: C.text, children: [t.slice(0, range[0]), _jsx(Text, { inverse: true, children: t.slice(range[0], range[1]) || " " }), t.slice(range[1])] }, firstShown + i));
|
|
356
|
+
}) })] })) })), [lines, firstShown, visible.length, selection, inner, empty]);
|
|
357
|
+
return (_jsxs(Pane, { title: title, width: width, height: height, focused: focused, children: [_jsxs(Box, { flexDirection: "row", height: rows, children: [_jsx(Box, { flexDirection: "column", width: inner, flexShrink: 0, children: messages }), _jsx(Scrollbar, { total: lines.length, top: firstShown, rows: rows, focused: focused })] }), _jsx(Box, { flexDirection: "column", children: footer.map((line, i) => render(line, `f${i}`)) }), showMenu ? (_jsx(Box, { flexDirection: "column", children: matches.slice(menuStart, menuStart + menuRows).map((c, i) => {
|
|
277
358
|
const selected = menuStart + i === menuRow;
|
|
278
359
|
return (_jsxs(Text, { wrap: "truncate-end", ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▸" : " " }), _jsxs(Text, { color: selected ? C.accent : C.text, bold: selected, children: ["/", c.name] }), c.args && inner >= 56 ? _jsxs(Text, { color: C.dim, children: [" ", c.args] }) : null /* narrow: the description matters more */, _jsxs(Text, { color: C.dim, children: [" ", c.description] })] }, c.name));
|
|
279
|
-
}) })) : (_jsx(Text, { wrap: "truncate-end", color: C.dim, children: 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 + 1)) }), _jsx(ChatInput, { value: input, onChange: setInput, onSubmit: submit, focus: focused && !(approval && !input), width: inner + 1, history: sent, arrowsTaken: showMenu, placeholder: busy ? (inner >= 44 ? "Enter queues · Alt+Enter sends now · Esc stops" : "Enter queues · Esc stops") : showMenu ? "↑↓ choose · Tab complete · Enter run" : inner >= 44 ? "Ask anything · / for commands · ↑ history" : "Ask anything · / commands" })] }));
|
|
360
|
+
}) })) : (_jsx(Text, { wrap: "truncate-end", color: C.dim, children: 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 + 1)) }), _jsx(ChatInput, { value: input, onChange: (text) => focusedNow.current && setInput(text), onSubmit: submit, focus: focused && !(approval && !input), width: inner + 1, history: sent, arrowsTaken: showMenu, placeholder: busy ? (inner >= 44 ? "Enter queues · Alt+Enter sends now · Esc stops" : "Enter queues · Esc stops") : showMenu ? "↑↓ choose · Tab complete · Enter run" : inner >= 44 ? "Ask anything · / for commands · ↑ history" : "Ask anything · / commands" })] }));
|
|
280
361
|
});
|
package/dist/src/ui/Editor.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { Box, Text, useInput, usePaste } from "ink";
|
|
@@ -8,6 +8,7 @@ import { SEVERITY_STYLE } from "./ProblemsView.js";
|
|
|
8
8
|
import { Pane } from "./Pane.js";
|
|
9
9
|
import { Scrollbar, topFor } from "./scroll.js";
|
|
10
10
|
import { copyToClipboard, readClipboard } from "./clipboard.js";
|
|
11
|
+
import { guideProgress } from "../guide/index.js";
|
|
11
12
|
const TAB = " ";
|
|
12
13
|
const expand = (s) => s.replace(/\t/g, TAB);
|
|
13
14
|
/** Screen column of buffer column `col` on `line` (tabs take two cells). */
|
|
@@ -87,13 +88,24 @@ const LINE_COMMENTS = {
|
|
|
87
88
|
".toml": "#", ".r": "#", ".pl": "#", ".ex": "#", ".exs": "#", ".dockerfile": "#", ".sql": "--", ".lua": "--", ".hs": "--",
|
|
88
89
|
};
|
|
89
90
|
const commentFor = (file) => LINE_COMMENTS[path.extname(file).toLowerCase()] ?? (path.basename(file).toLowerCase() === "dockerfile" ? "#" : null);
|
|
91
|
+
/** A line without the cursor or a selection: kept as is while you type on other lines. */
|
|
92
|
+
const PlainRow = memo(function PlainRow({ num, numColor, text }) {
|
|
93
|
+
return _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: numColor, children: num }), text || " "] });
|
|
94
|
+
});
|
|
90
95
|
/** A text editor: arrows and the mouse to move, Shift to select, Ctrl+C/X/V clipboard, Ctrl+S save, Ctrl+Z undo, Esc leave. */
|
|
91
|
-
export const Editor = memo(function Editor({ width, height, focused, filePath, initial, baseline, startLine, startCol, problems, autoSave, syntaxVersion, wheel, scrollTo, mouse, onSave, onExit, onState, onHover, onDefinition, tabs }) {
|
|
96
|
+
export const Editor = memo(function Editor({ width, height, focused, filePath, initial, baseline, startLine, startCol, problems, autoSave, syntaxVersion, wheel, scrollTo, mouse, onSave, onExit, onState, onHover, onDefinition, tabs, guide, onGuideDone }) {
|
|
92
97
|
const eol = useMemo(() => (baseline.includes("\r\n") ? "\r\n" : "\n"), [baseline]);
|
|
93
98
|
const indentUnit = useMemo(() => (/^\t/m.test(initial) ? "\t" : " "), [initial]);
|
|
94
99
|
const comment = useMemo(() => commentFor(filePath), [filePath]);
|
|
95
100
|
const [buf, setBuf] = useState(() => {
|
|
96
101
|
const lines = initial.replace(/\r\n/g, "\n").split("\n");
|
|
102
|
+
if (guide) {
|
|
103
|
+
// A fresh line for the guided code, indented like its first line.
|
|
104
|
+
const row = Math.max(0, Math.min(lines.length, guide.row));
|
|
105
|
+
const indent = guide.code.match(/^[ \t]*/)[0];
|
|
106
|
+
lines.splice(row, 0, indent);
|
|
107
|
+
return { lines, row, col: indent.length, anchor: null };
|
|
108
|
+
}
|
|
97
109
|
const row = Math.max(0, Math.min(lines.length - 1, (startLine ?? 1) - 1));
|
|
98
110
|
const col = startCol ? Math.min(lines[row].length, startCol - 1) : lines[row].match(/^\s*/)[0].length;
|
|
99
111
|
return { lines, row, col, anchor: null };
|
|
@@ -517,6 +529,12 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
|
|
|
517
529
|
}
|
|
518
530
|
return insert(`\n${indent}${opens ? indentUnit : ""}`);
|
|
519
531
|
}
|
|
532
|
+
if (key.tab && !key.shift && !hasSelection && guide && ghost) {
|
|
533
|
+
// Guided: Tab types the rest of the line for you (and the next line's indentation).
|
|
534
|
+
const end = ghost.indexOf("\n");
|
|
535
|
+
const chunk = end < 0 ? ghost : ghost.slice(0, end + 1) + ghost.slice(end + 1).match(/^[ \t]*/)[0];
|
|
536
|
+
return insert(chunk);
|
|
537
|
+
}
|
|
520
538
|
if (key.tab) {
|
|
521
539
|
// With a selection, or Shift+Tab: indent or outdent whole lines.
|
|
522
540
|
if (key.shift)
|
|
@@ -589,16 +607,32 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
|
|
|
589
607
|
return map;
|
|
590
608
|
}, [problems]);
|
|
591
609
|
const sel = selection(buf);
|
|
610
|
+
// Guided typing: how much of the step's code is typed (from its line's start to the cursor).
|
|
611
|
+
const guideRow = guide ? Math.max(0, Math.min(buf.lines.length - 1, guide.row)) : -1;
|
|
612
|
+
const typed = guide && (buf.row > guideRow || (buf.row === guideRow)) ? [...buf.lines.slice(guideRow, buf.row), buf.lines[buf.row].slice(0, buf.col)].join("\n") : null;
|
|
613
|
+
const progress = guide && typed !== null ? guideProgress(typed, guide.code) : -1;
|
|
614
|
+
const ghost = guide && progress >= 0 ? guide.code.slice(progress) : "";
|
|
615
|
+
const guideDone = useRef(false);
|
|
616
|
+
useEffect(() => {
|
|
617
|
+
if (guide && progress === guide.code.length && !guideDone.current) {
|
|
618
|
+
guideDone.current = true;
|
|
619
|
+
onGuideDone?.();
|
|
620
|
+
}
|
|
621
|
+
}, [progress]);
|
|
592
622
|
const cursorProblem = byLine.get(buf.row + 1)?.[0];
|
|
593
623
|
const selectedCount = sel ? selectedText(buf).length : 0;
|
|
594
|
-
const
|
|
624
|
+
const guideStatus = !guide ? null
|
|
625
|
+
: progress < 0 ? "Not quite: that doesn't match the step's code. Backspace and try again (the dim text shows what goes here)."
|
|
626
|
+
: ghost ? `Type the dim text${ghost.includes("\n") ? ` (${ghost.split("\n").length} lines to go)` : ""} · Tab fills in the rest of the line · Esc stop`
|
|
627
|
+
: "✓ Step done";
|
|
628
|
+
const status = guideStatus && !confirmExit && !prompt ? guideStatus : confirmExit
|
|
595
629
|
? "Unsaved changes! Ctrl+S save & close · Esc discard · any key keep editing"
|
|
596
630
|
: prompt
|
|
597
631
|
? prompt.kind === "find" ? `Find: ${prompt.text}▏ Enter/↓ next · Shift+Enter/↑ previous · Esc close` : `Go to line: ${prompt.text}▏ Enter go · Esc cancel`
|
|
598
632
|
: flash ?? (cursorProblem
|
|
599
633
|
? `${SEVERITY_STYLE[cursorProblem.severity].icon} ${cursorProblem.message} (${cursorProblem.source})`
|
|
600
634
|
: `Ln ${buf.row + 1}, Col ${buf.col + 1}${sel ? ` (${selectedCount} selected · Ctrl+C copy · Ctrl+X cut)` : ""}${dirty ? " ● modified" : ""} Ctrl+F find · Ctrl+/ comment · Esc done`);
|
|
601
|
-
const statusColor = confirmExit ? C.red : prompt ? C.accent : flash ? C.green : cursorProblem ? SEVERITY_STYLE[cursorProblem.severity].color : C.dim;
|
|
635
|
+
const statusColor = guideStatus && !confirmExit && !prompt ? (progress < 0 ? C.red : C.accent2) : confirmExit ? C.red : prompt ? C.accent : flash ? C.green : cursorProblem ? SEVERITY_STYLE[cursorProblem.severity].color : C.dim;
|
|
602
636
|
const hoverLines = hover ? hover.split("\n").filter((l) => l.trim()).slice(0, 6) : [];
|
|
603
637
|
const cursorVisible = buf.row >= v.top && buf.row < v.top + rows;
|
|
604
638
|
return (_jsxs(Pane, { title: _jsxs(_Fragment, { children: [tabs ?? _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", filePath, dirty ? _jsx(Text, { color: C.yellow, children: " \u25CF" }) : ""] }), " ", _jsx(Text, { backgroundColor: C.green, color: "black", children: " \u270E EDITING " }), !cursorVisible ? _jsxs(Text, { color: C.dim, children: [" cursor on line ", buf.row + 1, " \u00B7 \u2191\u2193 to go back"] }) : null] }), width: width, height: height, focused: focused, accent: C.green, children: [_jsxs(Box, { flexDirection: "row", height: rows, children: [_jsx(Box, { flexDirection: "column", width: width - 3, flexShrink: 0, children: visible.map((line, i) => {
|
|
@@ -609,7 +643,7 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
|
|
|
609
643
|
const num = (_jsxs(Text, { color: mark?.color ?? (isCursorLine ? C.green : C.dim), children: [String(n + 1).padStart(gutter - 2), mark ? ` ${mark.icon}` : isCursorLine ? " ▸" : " "] }));
|
|
610
644
|
const inSelection = sel && n >= sel.start.row && n <= sel.end.row;
|
|
611
645
|
if (!isCursorLine && !inSelection) {
|
|
612
|
-
return
|
|
646
|
+
return _jsx(PlainRow, { num: `${String(n + 1).padStart(gutter - 2)}${mark ? ` ${mark.icon}` : " "}`, numColor: mark?.color ?? C.dim, text: colored[i] ?? "" }, n);
|
|
613
647
|
}
|
|
614
648
|
// Lines with the cursor or a selection show plain text, so both can be drawn exactly.
|
|
615
649
|
const shown = expand(line).slice(v.left, v.left + textWidth);
|
|
@@ -626,8 +660,11 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
|
|
|
626
660
|
else
|
|
627
661
|
cells.push(cell);
|
|
628
662
|
}
|
|
663
|
+
// The code still to type, dim, from the cursor to the end of its line (only past the line's text).
|
|
664
|
+
const ghostLine = isCursorLine && ghost && buf.col >= line.length ? ghost.split("\n")[0] : "";
|
|
665
|
+
const ghostMore = isCursorLine && ghost && buf.col >= line.length ? ghost.split("\n").length - 1 : 0;
|
|
629
666
|
return (_jsxs(Text, { wrap: "truncate-end", children: [num, cells.map((c, j) => c.cursor
|
|
630
|
-
? _jsx(Text, { inverse: true, color: C.green, children: c.text }, j)
|
|
631
|
-
: _jsx(Text, { color: C.text, ...(c.selected ? { backgroundColor: "#364a82" } : {}), children: c.text }, j))] }, n));
|
|
667
|
+
? _jsx(Text, { inverse: true, color: C.green, children: ghostLine ? ghostLine[0] : c.text }, j)
|
|
668
|
+
: _jsx(Text, { color: C.text, ...(c.selected ? { backgroundColor: "#364a82" } : {}), children: c.text }, j)), ghostLine.length > 1 ? _jsx(Text, { color: C.dim, italic: true, children: expand(ghostLine.slice(1)) }) : null, ghostMore ? _jsxs(Text, { color: C.accent2, children: [ghostLine ? " " : "", "\u23CE", ghostMore > 1 ? ` +${ghostMore - 1} line${ghostMore > 2 ? "s" : ""}` : ""] }) : null] }, n));
|
|
632
669
|
}) }), _jsx(Scrollbar, { total: buf.lines.length, top: v.top, rows: rows, focused: focused })] }), 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}` })] }));
|
|
633
670
|
});
|
package/dist/src/ui/FileTree.js
CHANGED
|
@@ -4,7 +4,7 @@ import { readdirSync } from "fs";
|
|
|
4
4
|
import path from "path";
|
|
5
5
|
import { Box, Text, useAnimation, useInput } from "ink";
|
|
6
6
|
import { IGNORED } from "../commands/index.js";
|
|
7
|
-
import { C, GIT_COLORS, fileIcon, gradient } from "./theme.js";
|
|
7
|
+
import { C, GIT_COLORS, fileIcon, gradient, ANIMATION_MS } from "./theme.js";
|
|
8
8
|
import { Pane } from "./Pane.js";
|
|
9
9
|
import { Scrollbar, topFor } from "./scroll.js";
|
|
10
10
|
function listDir(dir) {
|
|
@@ -167,7 +167,7 @@ export const FileTree = memo(function FileTree({ width, height, focused, openFil
|
|
|
167
167
|
else
|
|
168
168
|
onOpen(node.path);
|
|
169
169
|
}, [click?.nonce]);
|
|
170
|
-
const { frame } = useAnimation({ interval:
|
|
170
|
+
const { frame } = useAnimation({ interval: ANIMATION_MS, isActive: !!working?.size });
|
|
171
171
|
const inner = width - 3; // minus the borders and the scrollbar
|
|
172
172
|
const title = changesOnly
|
|
173
173
|
? _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.yellow, children: "\u00B1" }), " Changes ", _jsxs(Text, { color: C.dim, children: ["(", nodes.length, ") c: all files"] })] })
|
package/dist/src/ui/GamePanel.js
CHANGED
|
@@ -81,7 +81,7 @@ export const GamePanel = memo(function GamePanel({ width, height, focused, movin
|
|
|
81
81
|
}, { isActive: focused });
|
|
82
82
|
const color = focused || moving ? C.accent2 : C.dim;
|
|
83
83
|
const inner = width - 2;
|
|
84
|
-
const title = moving ? " ✦ Drop it on a pane: files, code or chat" : playing ? ` ✦ ${playing.game.title}
|
|
84
|
+
const title = moving ? " ✦ Drop it on a pane: files, code or chat" : `${playing ? ` ✦ ${playing.game.title}` : " ✦ Games"} ⠿ drag to move · top edge resizes`;
|
|
85
85
|
// The title row: the name and the drag handle, and × (close) at the right.
|
|
86
86
|
const head = title.length > inner - 3 ? `${title.slice(0, inner - 4)}…` : title.padEnd(inner - 3);
|
|
87
87
|
const hint = !focused ? "click to play" : playing ? (view?.over ? "Enter again · q games · Esc leave" : `${paused ? "PAUSED · " : ""}Space pause · q games · Esc leave`) : "↑↓ choose · Enter play · Esc leave";
|
package/dist/src/ui/Header.js
CHANGED
|
@@ -2,11 +2,11 @@ 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
6
|
import { currentVersion } from "../update/index.js";
|
|
7
7
|
const VERSION = `v${currentVersion()}`;
|
|
8
8
|
export const Header = memo(function Header({ width, branch, changes, model, local, busy, update }) {
|
|
9
|
-
const { frame } = useAnimation({ interval:
|
|
9
|
+
const { frame } = useAnimation({ interval: ANIMATION_MS, isActive: !!busy });
|
|
10
10
|
const project = path.basename(process.cwd());
|
|
11
11
|
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
12
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
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
5
|
import { Pane } from "./Pane.js";
|
|
6
6
|
export const SEVERITY_STYLE = {
|
|
7
7
|
error: { icon: "✖", color: C.red },
|
|
@@ -11,7 +11,7 @@ export const SEVERITY_STYLE = {
|
|
|
11
11
|
export function ProblemsView({ width, height, focused, problems, checking, wheel, onJump, onClose }) {
|
|
12
12
|
const [cursor, setCursor] = useState(0);
|
|
13
13
|
const [onlyErrors, setOnlyErrors] = useState(false);
|
|
14
|
-
const { frame } = useAnimation({ interval:
|
|
14
|
+
const { frame } = useAnimation({ interval: ANIMATION_MS, isActive: checking.length > 0 });
|
|
15
15
|
const rows = useMemo(() => {
|
|
16
16
|
const list = onlyErrors ? problems.filter((d) => d.severity === "error") : problems;
|
|
17
17
|
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,7 +40,7 @@ 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:
|
|
43
|
+
const { frame } = useAnimation({ interval: ANIMATION_MS });
|
|
44
44
|
const provider = providers[row] ?? providers[0];
|
|
45
45
|
const listed = models[provider.id];
|
|
46
46
|
// Load the model list for the highlighted provider.
|
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(["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,6 +28,11 @@ 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)" },
|
|
19
36
|
{ name: "terminal", description: "a terminal under the code, like VS Code's (or press `)" },
|
|
20
37
|
{ name: "resume", args: "[number|last]", description: "pick up an earlier conversation in this folder (any engine)" },
|
|
21
38
|
{ name: "pull", args: "<model>", description: "download an Ollama model" },
|
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;
|
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": {
|