nou-cli 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +1 -0
- package/README.md +26 -8
- package/dist/index.js +4 -5
- package/dist/src/agent/index.js +3 -2
- package/dist/src/config/index.js +2 -1
- package/dist/src/env.js +3 -0
- package/dist/src/providers/index.js +16 -0
- package/dist/src/providers/openai.js +7 -4
- package/dist/src/syntax/index.js +11 -2
- package/dist/src/ui/App.js +300 -70
- package/dist/src/ui/Chat.js +105 -49
- package/dist/src/ui/ChatInput.js +11 -2
- package/dist/src/ui/CodeView.js +42 -23
- package/dist/src/ui/Editor.js +471 -99
- package/dist/src/ui/FileTree.js +74 -42
- package/dist/src/ui/Header.js +3 -2
- package/dist/src/ui/ProblemsView.js +2 -2
- package/dist/src/ui/Settings.js +2 -4
- package/dist/src/ui/clipboard.js +59 -0
- package/dist/src/ui/fast-output.js +250 -0
- package/dist/src/ui/mouse.js +8 -3
- package/dist/src/ui/render-options.js +11 -0
- package/dist/src/ui/scroll.js +30 -0
- package/dist/src/ui/text.js +155 -0
- package/package.json +3 -2
package/dist/src/ui/text.js
CHANGED
|
@@ -110,3 +110,158 @@ export function lineCounts(before, after) {
|
|
|
110
110
|
}
|
|
111
111
|
return { added, removed };
|
|
112
112
|
}
|
|
113
|
+
const INLINE = /(`+)([^`]+?)\1|\*\*(.+?)\*\*|__(.+?)__|(?<![\w*])\*(?!\s)(.+?)(?<!\s)\*(?![\w*])|(?<!\w)_(?!\s)(.+?)(?<!\s)_(?!\w)|\[([^\]]+)\]\(([^)\s]+)\)/g;
|
|
114
|
+
/** Splits a line of markdown into styled spans: `code`, **bold**, *italic*, [links](url). */
|
|
115
|
+
export function inlineSpans(text, base = { text: "" }) {
|
|
116
|
+
const out = [];
|
|
117
|
+
let pos = 0;
|
|
118
|
+
for (const m of text.matchAll(INLINE)) {
|
|
119
|
+
if (m.index > pos)
|
|
120
|
+
out.push({ ...base, text: text.slice(pos, m.index) });
|
|
121
|
+
if (m[2] !== undefined)
|
|
122
|
+
out.push({ ...base, text: m[2], code: true });
|
|
123
|
+
else if (m[3] !== undefined || m[4] !== undefined)
|
|
124
|
+
out.push(...inlineSpans(m[3] ?? m[4], { ...base, bold: true }));
|
|
125
|
+
else if (m[5] !== undefined || m[6] !== undefined)
|
|
126
|
+
out.push(...inlineSpans(m[5] ?? m[6], { ...base, italic: true }));
|
|
127
|
+
else {
|
|
128
|
+
out.push(...inlineSpans(m[7], base));
|
|
129
|
+
if (m[8] !== m[7])
|
|
130
|
+
out.push({ ...base, text: ` (${m[8]})`, dim: true });
|
|
131
|
+
}
|
|
132
|
+
pos = m.index + m[0].length;
|
|
133
|
+
}
|
|
134
|
+
if (pos < text.length)
|
|
135
|
+
out.push({ ...base, text: text.slice(pos) });
|
|
136
|
+
return out.filter((s) => s.text);
|
|
137
|
+
}
|
|
138
|
+
const sameStyle = (a, b) => !!a.bold === !!b.bold && !!a.italic === !!b.italic && !!a.code === !!b.code && !!a.dim === !!b.dim;
|
|
139
|
+
/** Word-wraps styled spans; continuation lines start with `indent` spaces. */
|
|
140
|
+
function wrapSpans(spans, width, indent = 0) {
|
|
141
|
+
const max = Math.max(1, width);
|
|
142
|
+
const lines = [[]];
|
|
143
|
+
let len = 0;
|
|
144
|
+
const push = (span) => {
|
|
145
|
+
const line = lines[lines.length - 1];
|
|
146
|
+
const last = line[line.length - 1];
|
|
147
|
+
if (last && sameStyle(last, span))
|
|
148
|
+
last.text += span.text;
|
|
149
|
+
else
|
|
150
|
+
line.push({ ...span });
|
|
151
|
+
len += span.text.length;
|
|
152
|
+
};
|
|
153
|
+
const newLine = () => {
|
|
154
|
+
const line = lines[lines.length - 1];
|
|
155
|
+
const last = line[line.length - 1];
|
|
156
|
+
if (last)
|
|
157
|
+
last.text = last.text.trimEnd();
|
|
158
|
+
lines.push(indent ? [{ text: " ".repeat(indent) }] : []);
|
|
159
|
+
len = indent;
|
|
160
|
+
};
|
|
161
|
+
for (const span of spans) {
|
|
162
|
+
for (const word of span.text.split(/(\s+)/)) {
|
|
163
|
+
if (!word)
|
|
164
|
+
continue;
|
|
165
|
+
if (len + word.length <= max) {
|
|
166
|
+
push({ ...span, text: word });
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (/^\s+$/.test(word)) {
|
|
170
|
+
newLine();
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (len > indent)
|
|
174
|
+
newLine();
|
|
175
|
+
let rest = word;
|
|
176
|
+
while (len + rest.length > max) {
|
|
177
|
+
const room = Math.max(1, max - len);
|
|
178
|
+
push({ ...span, text: rest.slice(0, room) });
|
|
179
|
+
rest = rest.slice(room);
|
|
180
|
+
newLine();
|
|
181
|
+
}
|
|
182
|
+
push({ ...span, text: rest });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return lines;
|
|
186
|
+
}
|
|
187
|
+
const cells = (row) => row.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim());
|
|
188
|
+
/** Renders markdown for the terminal: headings, lists, quotes, code blocks, tables and inline styles, wrapped to `width`. */
|
|
189
|
+
export function renderMarkdown(text, width) {
|
|
190
|
+
const out = [];
|
|
191
|
+
const src = splitLines(text);
|
|
192
|
+
let fence = null;
|
|
193
|
+
for (let i = 0; i < src.length; i++) {
|
|
194
|
+
const raw = src[i];
|
|
195
|
+
const opener = raw.match(/^\s*(`{3,}|~{3,})\s*(\S*)/);
|
|
196
|
+
if (fence !== null) {
|
|
197
|
+
if (opener && raw.trim().startsWith(fence)) {
|
|
198
|
+
fence = null;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
for (const line of wrap(raw, width))
|
|
202
|
+
out.push({ kind: "code", spans: [{ text: line, code: true }] });
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (opener) {
|
|
206
|
+
fence = opener[1];
|
|
207
|
+
if (opener[2])
|
|
208
|
+
out.push({ kind: "code", spans: [{ text: opener[2], dim: true }] });
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
// Tables: a header row, a |---| row, then body rows.
|
|
212
|
+
if (/^\s*\|/.test(raw) && /^\s*\|?\s*:?-{2,}/.test(src[i + 1] ?? "")) {
|
|
213
|
+
const rows = [cells(raw)];
|
|
214
|
+
let j = i + 2;
|
|
215
|
+
for (; j < src.length && /^\s*\|/.test(src[j]); j++)
|
|
216
|
+
rows.push(cells(src[j]));
|
|
217
|
+
i = j - 1;
|
|
218
|
+
const plain = rows.map((r) => r.map((c) => inlineSpans(c).map((s) => s.text).join("")));
|
|
219
|
+
const columns = Math.max(...plain.map((r) => r.length));
|
|
220
|
+
const widths = Array.from({ length: columns }, (_, c) => Math.max(...plain.map((r) => (r[c] ?? "").length)));
|
|
221
|
+
const fits = widths.reduce((a, b) => a + b, 0) + 3 * (columns - 1) <= width;
|
|
222
|
+
rows.forEach((row, r) => {
|
|
223
|
+
const spans = [];
|
|
224
|
+
row.forEach((cell, c) => {
|
|
225
|
+
if (c > 0)
|
|
226
|
+
spans.push({ text: " │ ", dim: true });
|
|
227
|
+
spans.push(...inlineSpans(cell, r === 0 ? { text: "", bold: true } : { text: "" }));
|
|
228
|
+
if (fits && c < columns - 1)
|
|
229
|
+
spans.push({ text: " ".repeat(widths[c] - plain[r][c].length) });
|
|
230
|
+
});
|
|
231
|
+
for (const line of wrapSpans(spans, width, 2))
|
|
232
|
+
out.push({ kind: "text", spans: line });
|
|
233
|
+
if (r === 0)
|
|
234
|
+
out.push({ kind: "rule", spans: [{ text: fits ? widths.map((w) => "─".repeat(w)).join("─┼─") : "─".repeat(Math.min(width, 40)), dim: true }] });
|
|
235
|
+
});
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
const heading = raw.match(/^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/);
|
|
239
|
+
if (heading) {
|
|
240
|
+
for (const line of wrapSpans(inlineSpans(heading[2], { text: "", bold: true }), width))
|
|
241
|
+
out.push({ kind: "heading", spans: line });
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (/^\s{0,3}([-*_])(\s*\1){2,}\s*$/.test(raw)) {
|
|
245
|
+
out.push({ kind: "rule", spans: [{ text: "─".repeat(Math.min(width, 40)), dim: true }] });
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const quote = raw.match(/^\s*>\s?(.*)/);
|
|
249
|
+
if (quote) {
|
|
250
|
+
for (const line of wrapSpans(inlineSpans(quote[1], { text: "", italic: true }), width - 2))
|
|
251
|
+
out.push({ kind: "quote", spans: [{ text: "│ ", dim: true }, ...line] });
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
const item = raw.match(/^(\s*)([-*+]|\d+[.)])\s+(\[[ xX]\]\s+)?(.*)/);
|
|
255
|
+
if (item) {
|
|
256
|
+
const depth = Math.floor(item[1].length / 2) * 2;
|
|
257
|
+
const task = item[3] ? (/x/i.test(item[3]) ? "☑ " : "☐ ") : "";
|
|
258
|
+
const bullet = `${" ".repeat(depth)}${/\d/.test(item[2]) ? item[2] : "•"} ${task}`;
|
|
259
|
+
for (const line of wrapSpans([{ text: bullet }, ...inlineSpans(item[4])], width, bullet.length))
|
|
260
|
+
out.push({ kind: "text", spans: line });
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
for (const line of wrapSpans(inlineSpans(raw), width))
|
|
264
|
+
out.push({ kind: "text", spans: line });
|
|
265
|
+
}
|
|
266
|
+
return out;
|
|
267
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nou-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Nou: an IDE-style AI coding assistant for your terminal. Local (Ollama, LM Studio) and cloud (Claude, OpenAI, Gemini...) models, live diffs, git-aware, built-in editor.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
"eval": "tsx evals/run.ts",
|
|
25
25
|
"prepublishOnly": "npm run build",
|
|
26
26
|
"screenshot": "node -e \"process.env.FORCE_COLOR='3';require('child_process').execSync('npx tsx scripts/screenshot.tsx',{stdio:'inherit'})\"",
|
|
27
|
-
"test": "tsx tests/unit.ts && tsx tests/e2e.tsx"
|
|
27
|
+
"test": "tsx tests/unit.ts && tsx tests/e2e.tsx",
|
|
28
|
+
"bench": "tsx scripts/bench.tsx"
|
|
28
29
|
},
|
|
29
30
|
"repository": {
|
|
30
31
|
"type": "git",
|