nou-cli 0.5.2 → 0.5.3
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 +12 -7
- package/dist/src/agent/index.js +33 -3
- package/dist/src/cli.js +3 -1
- package/dist/src/commands/index.js +1 -1
- package/dist/src/config/index.js +1 -1
- package/dist/src/conversations/external.js +165 -0
- package/dist/src/conversations/index.js +71 -0
- package/dist/src/engines/claude.js +10 -0
- package/dist/src/engines/codex.js +60 -5
- package/dist/src/providers/anthropic.js +3 -14
- package/dist/src/terminal/index.js +127 -0
- package/dist/src/ui/App.js +258 -39
- package/dist/src/ui/Chat.js +63 -5
- package/dist/src/ui/ChatInput.js +5 -0
- package/dist/src/ui/CodeView.js +1 -1
- package/dist/src/ui/FileTree.js +5 -4
- package/dist/src/ui/GamePanel.js +18 -11
- package/dist/src/ui/Tabs.js +6 -5
- package/dist/src/ui/Terminal.js +84 -0
- package/dist/src/ui/commands.js +3 -0
- package/dist/src/ui/render-options.js +4 -0
- package/dist/src/ui/text.js +16 -0
- package/package.json +1 -1
package/dist/src/ui/Chat.js
CHANGED
|
@@ -3,8 +3,9 @@ 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 { linkAt, linkFileRefs, renderMarkdown, wrap } from "./text.js";
|
|
6
|
+
import { closeOpenMarkdown, linkAt, linkFileRefs, renderMarkdown, wrap } from "./text.js";
|
|
7
7
|
import { ChatInput, inputHeight } from "./ChatInput.js";
|
|
8
|
+
import { copyToClipboard } from "./clipboard.js";
|
|
8
9
|
import { C, SPINNER } from "./theme.js";
|
|
9
10
|
import { Pane } from "./Pane.js";
|
|
10
11
|
import { matchCommands } from "./commands.js";
|
|
@@ -70,7 +71,8 @@ function cachedLines(entry, inner) {
|
|
|
70
71
|
laidOut.set(entry, { inner, lines });
|
|
71
72
|
return lines;
|
|
72
73
|
}
|
|
73
|
-
|
|
74
|
+
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 = [] }) {
|
|
74
76
|
const [input, setInput] = useState("");
|
|
75
77
|
useEffect(() => {
|
|
76
78
|
if (prefill)
|
|
@@ -80,7 +82,7 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
|
|
|
80
82
|
const { frame } = useAnimation({ interval: 80, isActive: !!busy });
|
|
81
83
|
const inner = width - 3; // minus the borders and the scrollbar
|
|
82
84
|
const history = useMemo(() => entries.flatMap((e) => cachedLines(e, inner)), [entries, inner]);
|
|
83
|
-
const lines = useMemo(() => (streaming ? [...history, ...entryLines({ kind: "assistant", text: streaming }, inner, false)] : history), [history, streaming, inner]);
|
|
85
|
+
const lines = useMemo(() => (streaming ? [...history, ...entryLines({ kind: "assistant", text: closeOpenMarkdown(streaming) }, inner, false)] : history), [history, streaming, inner]);
|
|
84
86
|
// At the bottom, new output scrolls into view. Scrolled up to read, the view stays where it is
|
|
85
87
|
// (counted from the bottom, so it moves up by however many lines arrived) until you scroll back down.
|
|
86
88
|
const lastCount = useRef(lines.length);
|
|
@@ -166,6 +168,56 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
|
|
|
166
168
|
if (ref)
|
|
167
169
|
onOpenRef?.(ref);
|
|
168
170
|
}, [click?.nonce]);
|
|
171
|
+
// Drag over the messages to select them (in lines of the conversation, so it holds while it scrolls);
|
|
172
|
+
// letting go copies them. Dragging past the top or bottom scrolls.
|
|
173
|
+
const [selection, setSelection] = useState(null);
|
|
174
|
+
const [flash, setFlash] = useState(null);
|
|
175
|
+
const anchor = useRef(null);
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
if (!select)
|
|
178
|
+
return;
|
|
179
|
+
const at = { line: Math.max(0, Math.min(lines.length - 1, firstShown + select.row)), col: Math.max(0, select.col) };
|
|
180
|
+
if (select.kind === "press") {
|
|
181
|
+
anchor.current = at;
|
|
182
|
+
setSelection(null);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (!anchor.current)
|
|
186
|
+
return;
|
|
187
|
+
if (select.kind === "drag") {
|
|
188
|
+
if (select.row < 0)
|
|
189
|
+
setScrollBack((b) => Math.min(maxBack, b + 1));
|
|
190
|
+
else if (select.row >= rows)
|
|
191
|
+
setScrollBack((b) => Math.max(0, b - 1));
|
|
192
|
+
}
|
|
193
|
+
const moved = at.line !== anchor.current.line || at.col !== anchor.current.col;
|
|
194
|
+
const range = moved ? (at.line < anchor.current.line || (at.line === anchor.current.line && at.col < anchor.current.col) ? { from: at, to: anchor.current } : { from: anchor.current, to: at }) : null;
|
|
195
|
+
setSelection(range);
|
|
196
|
+
if (select.kind !== "release")
|
|
197
|
+
return;
|
|
198
|
+
anchor.current = null;
|
|
199
|
+
if (!range)
|
|
200
|
+
return;
|
|
201
|
+
const text = lines.slice(range.from.line, range.to.line + 1).map((line, i, all) => {
|
|
202
|
+
const t = lineText(line);
|
|
203
|
+
return t.slice(i === 0 ? range.from.col : 0, i === all.length - 1 ? range.to.col + 1 : undefined).trimEnd();
|
|
204
|
+
}).join("\n");
|
|
205
|
+
void copyToClipboard(text).then((ok) => {
|
|
206
|
+
setFlash(ok ? `Copied ${text.split("\n").length > 1 ? `${text.split("\n").length} lines` : "it"} ✓` : "Can't copy: press m, then select");
|
|
207
|
+
setTimeout(() => setFlash(null), 2000);
|
|
208
|
+
});
|
|
209
|
+
}, [select?.nonce]);
|
|
210
|
+
// Typing or new messages leave the selection be; a new conversation clears it.
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
if (!entries.length)
|
|
213
|
+
setSelection(null);
|
|
214
|
+
}, [entries.length]);
|
|
215
|
+
/** The selected columns of a line, if any. */
|
|
216
|
+
const selectedIn = (index) => {
|
|
217
|
+
if (!selection || index < selection.from.line || index > selection.to.line)
|
|
218
|
+
return null;
|
|
219
|
+
return [index === selection.from.line ? selection.from.col : 0, index === selection.to.line ? selection.to.col + 1 : Infinity];
|
|
220
|
+
};
|
|
169
221
|
// While something waits for approval, y/n into an empty input answer it; any other key starts a message.
|
|
170
222
|
useInput((typed) => {
|
|
171
223
|
if (/^[yYnN]$/.test(typed))
|
|
@@ -210,12 +262,18 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
|
|
|
210
262
|
? 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)))
|
|
211
263
|
: _jsx(Text, { bold: !!line.bold, dimColor: !!line.dim, ...(line.color ? { color: line.color } : {}), children: line.text || " " })] }, key));
|
|
212
264
|
const empty = entries.length === 0 && !streaming;
|
|
213
|
-
const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u25C6" }), " Chat", back
|
|
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
|
|
214
266
|
? unread
|
|
215
267
|
? _jsxs(Text, { color: C.green, bold: true, children: [" \u2193 ", unread, " new lines below", _jsx(Text, { color: C.dim, children: " \u00B7 PgDn / wheel / Ctrl+End" })] })
|
|
216
268
|
: _jsxs(Text, { color: C.yellow, children: [" \u2191 scrolled ", back, " lines", _jsx(Text, { color: C.dim, children: " \u00B7 PgDn / wheel / Ctrl+End" })] })
|
|
217
269
|
: maxBack > 0 ? _jsx(Text, { color: C.dim, children: " PgUp / wheel to scroll" }) : null] }));
|
|
218
|
-
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: 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) =>
|
|
270
|
+
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: 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) => {
|
|
271
|
+
const range = selectedIn(firstShown + i);
|
|
272
|
+
if (!range)
|
|
273
|
+
return render(line, firstShown + i, false);
|
|
274
|
+
const t = lineText(line) || " ";
|
|
275
|
+
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));
|
|
276
|
+
}) })] })) }), _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) => {
|
|
219
277
|
const selected = menuStart + i === menuRow;
|
|
220
278
|
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));
|
|
221
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" })] }));
|
package/dist/src/ui/ChatInput.js
CHANGED
|
@@ -78,6 +78,11 @@ export function ChatInput({ value, onChange, onSubmit, focus, width, placeholder
|
|
|
78
78
|
if (arrowsTaken && (key.upArrow || key.downArrow))
|
|
79
79
|
return;
|
|
80
80
|
if (key.return) {
|
|
81
|
+
// Ctrl/Cmd/Shift+Enter add a line (in terminals that report them); so does \ then Enter, everywhere.
|
|
82
|
+
if (key.ctrl || key.super || key.shift)
|
|
83
|
+
return insert("\n");
|
|
84
|
+
if (value[pos - 1] === "\\")
|
|
85
|
+
return set(`${value.slice(0, pos - 1)}\n${value.slice(pos)}`, pos);
|
|
81
86
|
setRecall(null);
|
|
82
87
|
onSubmit(value, key.meta);
|
|
83
88
|
return;
|
package/dist/src/ui/CodeView.js
CHANGED
|
@@ -181,7 +181,7 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
|
|
|
181
181
|
["drag", "a pane's border to resize it"],
|
|
182
182
|
["s", "settings: models, keys, plugins"],
|
|
183
183
|
];
|
|
184
|
-
return (_jsx(Pane, { title: _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u2726" }), " Welcome"] }), width: width, height: height, focused: focused, children: _jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [inner > 62 && artWork.split("\n").filter((l) => l.trim()).map((line, i) => (_jsx(Text, { children: gradient(line, i).map((g, j) => _jsx(Text, { color: g.color, children: g.ch }, j)) }, i))), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, color: C.text, children: "Hello, I am Nou! \uD83C\uDF1E Your terminal coding companion." }), _jsx(Text, { color: C.dim, children: "Ask in the chat on the right. Files I read or edit open here as I work." }), _jsx(Text, { children: " " }), tips.map(([k, d]) => (_jsxs(Text, { children: [" ", _jsx(Text, { color: C.accent, bold: true, children: k.padEnd(10) }), _jsx(Text, { color: C.dim, children: d })] }, k)))] }) }));
|
|
184
|
+
return (_jsx(Pane, { title: _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u2726" }), " Welcome"] }), width: width, height: height, focused: focused, children: _jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, flexShrink: 0, children: [inner > 62 && artWork.split("\n").filter((l) => l.trim()).map((line, i) => (_jsx(Text, { children: gradient(line, i).map((g, j) => _jsx(Text, { color: g.color, children: g.ch }, j)) }, i))), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, color: C.text, children: "Hello, I am Nou! \uD83C\uDF1E Your terminal coding companion." }), _jsx(Text, { color: C.dim, children: "Ask in the chat on the right. Files I read or edit open here as I work." }), _jsx(Text, { children: " " }), tips.map(([k, d]) => (_jsxs(Text, { children: [" ", _jsx(Text, { color: C.accent, bold: true, children: k.padEnd(10) }), _jsx(Text, { color: C.dim, children: d })] }, k)))] }) }));
|
|
185
185
|
}
|
|
186
186
|
const gutter = String(lines.length).length;
|
|
187
187
|
const { icon, color } = fileIcon(filePath);
|
package/dist/src/ui/FileTree.js
CHANGED
|
@@ -2,9 +2,9 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
|
3
3
|
import { readdirSync } from "fs";
|
|
4
4
|
import path from "path";
|
|
5
|
-
import { Box, Text, useInput } from "ink";
|
|
5
|
+
import { Box, Text, useAnimation, useInput } from "ink";
|
|
6
6
|
import { IGNORED } from "../commands/index.js";
|
|
7
|
-
import { C, GIT_COLORS, fileIcon } from "./theme.js";
|
|
7
|
+
import { C, GIT_COLORS, fileIcon, gradient } from "./theme.js";
|
|
8
8
|
import { Pane } from "./Pane.js";
|
|
9
9
|
import { Scrollbar, topFor } from "./scroll.js";
|
|
10
10
|
function listDir(dir) {
|
|
@@ -31,7 +31,7 @@ function flatten(expanded) {
|
|
|
31
31
|
walk(".", 0);
|
|
32
32
|
return nodes;
|
|
33
33
|
}
|
|
34
|
-
export const FileTree = memo(function FileTree({ width, height, focused, openFile, reveal, version, changed, git, problemCounts, wheel, scrollTo, click, onOpen, onHover }) {
|
|
34
|
+
export const FileTree = memo(function FileTree({ width, height, focused, openFile, reveal, version, changed, working, git, problemCounts, wheel, scrollTo, click, onOpen, onHover }) {
|
|
35
35
|
const [expanded, setExpanded] = useState(() => new Set(["src"]));
|
|
36
36
|
const [cursor, setCursor] = useState(0);
|
|
37
37
|
// The first row shown. The wheel moves it on its own; the keyboard moves the cursor and the view follows.
|
|
@@ -167,6 +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: 120, isActive: !!working?.size });
|
|
170
171
|
const inner = width - 3; // minus the borders and the scrollbar
|
|
171
172
|
const title = changesOnly
|
|
172
173
|
? _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.yellow, children: "\u00B1" }), " Changes ", _jsxs(Text, { color: C.dim, children: ["(", nodes.length, ") c: all files"] })] })
|
|
@@ -187,6 +188,6 @@ export const FileTree = memo(function FileTree({ width, height, focused, openFil
|
|
|
187
188
|
const room = Math.max(1, inner - marker.length - 1);
|
|
188
189
|
const shown = label.length > room ? `${label.slice(0, room - 1)}…` : label.padEnd(room);
|
|
189
190
|
const iconAt = shown.indexOf(icon);
|
|
190
|
-
return (_jsxs(Text, { wrap: "truncate-end", ...(selected && focused ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { ...(nameColor ? { color: nameColor } : {}), bold: isOpen, children: shown.slice(0, iconAt) }), _jsx(Text, { color: color, children: shown.slice(iconAt, iconAt + icon.length) }), _jsx(Text, { ...(nameColor ? { color: nameColor } : {}), bold: isOpen, inverse: selected && focused, children: shown.slice(iconAt + icon.length) }), _jsxs(Text, { color: markerColor, children: [" ", marker] })] }, node.path));
|
|
191
|
+
return (_jsxs(Text, { wrap: "truncate-end", ...(selected && focused ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { ...(nameColor ? { color: nameColor } : {}), bold: isOpen, children: shown.slice(0, iconAt) }), _jsx(Text, { color: color, children: shown.slice(iconAt, iconAt + icon.length) }), working?.has(node.path) ? (_jsx(Text, { bold: true, inverse: selected && focused, children: gradient(shown.slice(iconAt + icon.length), frame).map((g, j) => _jsx(Text, { color: g.color, children: g.ch }, j)) })) : (_jsx(Text, { ...(nameColor ? { color: nameColor } : {}), bold: isOpen, inverse: selected && focused, children: shown.slice(iconAt + icon.length) })), _jsxs(Text, { color: markerColor, children: [" ", marker] })] }, node.path));
|
|
191
192
|
})] }), _jsx(Scrollbar, { total: nodes.length, top: start, rows: rows, focused: focused })] }) }));
|
|
192
193
|
});
|
package/dist/src/ui/GamePanel.js
CHANGED
|
@@ -3,9 +3,8 @@ import { memo, useEffect, useRef, useState } from "react";
|
|
|
3
3
|
import { Box, Text, useInput } from "ink";
|
|
4
4
|
import { GAMES, gameById } from "../games/index.js";
|
|
5
5
|
import { C } from "./theme.js";
|
|
6
|
-
/** The panel's
|
|
7
|
-
export const GAME_PANEL = {
|
|
8
|
-
const FIELD = { width: GAME_PANEL.width - 4, height: GAME_PANEL.height - 6 };
|
|
6
|
+
/** The panel's tallest height (borders included) when docked under a pane, and the least it needs. */
|
|
7
|
+
export const GAME_PANEL = { height: 19, minWidth: 26, minHeight: 10 };
|
|
9
8
|
const KEYS = { w: "up", s: "down", a: "left", d: "right", k: "up", j: "down", h: "left", l: "right" };
|
|
10
9
|
/** A row of the playfield, colored per character in runs so it stays a handful of Text nodes. */
|
|
11
10
|
function Row({ line, colors }) {
|
|
@@ -21,10 +20,11 @@ function Row({ line, colors }) {
|
|
|
21
20
|
return _jsx(Text, { children: runs.map((r, i) => _jsx(Text, { ...(r.color ? { color: r.color } : {}), children: r.text }, i)) });
|
|
22
21
|
}
|
|
23
22
|
/**
|
|
24
|
-
* A
|
|
23
|
+
* A panel for little games, docked under a pane (drag its title to another pane; × closes it). The app gives it
|
|
25
24
|
* the keyboard while it's focused; a game only runs while it has the keyboard, so it pauses as you leave.
|
|
26
25
|
*/
|
|
27
|
-
export const GamePanel = memo(function GamePanel({
|
|
26
|
+
export const GamePanel = memo(function GamePanel({ width, height, focused, moving, installed, onLeave, onOpenSettings }) {
|
|
27
|
+
const FIELD = { width: Math.max(10, width - 4), height: Math.max(3, height - 6) };
|
|
28
28
|
const games = installed.map(gameById).filter((g) => !!g);
|
|
29
29
|
const [row, setRow] = useState(0);
|
|
30
30
|
const [playing, setPlaying] = useState(null);
|
|
@@ -42,6 +42,11 @@ export const GamePanel = memo(function GamePanel({ x, y, focused, installed, onL
|
|
|
42
42
|
}, playing.game.tickMs);
|
|
43
43
|
return () => clearInterval(timer);
|
|
44
44
|
}, [running, playing?.game]);
|
|
45
|
+
// A different size (moved to another pane, or the window changed) starts the game over at that size.
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (playing)
|
|
48
|
+
setPlaying({ game: playing.game, state: playing.game.create(FIELD.width, FIELD.height) });
|
|
49
|
+
}, [FIELD.width, FIELD.height]);
|
|
45
50
|
const start = (game) => {
|
|
46
51
|
inputs.current = [];
|
|
47
52
|
setPaused(false);
|
|
@@ -74,12 +79,14 @@ export const GamePanel = memo(function GamePanel({ x, y, focused, installed, onL
|
|
|
74
79
|
if (pressed)
|
|
75
80
|
inputs.current.push(pressed);
|
|
76
81
|
}, { isActive: focused });
|
|
77
|
-
const color = focused ? C.accent2 : C.dim;
|
|
78
|
-
const inner =
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
82
|
+
const color = focused || moving ? C.accent2 : C.dim;
|
|
83
|
+
const inner = width - 2;
|
|
84
|
+
const title = moving ? " ✦ Drop it on a pane: files, code or chat" : playing ? ` ✦ ${playing.game.title} ⠿ drag to move` : " ✦ Games ⠿ drag to move";
|
|
85
|
+
// The title row: the name and the drag handle, and × (close) at the right.
|
|
86
|
+
const head = title.length > inner - 3 ? `${title.slice(0, inner - 4)}…` : title.padEnd(inner - 3);
|
|
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";
|
|
88
|
+
const small = width < GAME_PANEL.minWidth || height < GAME_PANEL.minHeight;
|
|
89
|
+
return (_jsxs(Box, { width: width, height: height, flexDirection: "column", borderStyle: "round", borderColor: color, overflow: "hidden", children: [_jsxs(Text, { backgroundColor: C.panel, wrap: "truncate-end", children: [_jsx(Text, { color: color, bold: true, children: head }), _jsx(Text, { color: C.text, bold: true, children: " \u00D7 " })] }), small ? (_jsx(Box, { flexDirection: "column", paddingX: 1, flexGrow: 1, children: _jsx(Text, { color: C.dim, wrap: "wrap", children: "Too small here: drag me to a bigger pane, or make this one wider." }) })) : null, small ? null : playing && view ? (_jsxs(Box, { flexDirection: "column", paddingX: 1, height: FIELD.height + 2, children: [view.lines.map((line, i) => {
|
|
83
90
|
// The end-of-game message goes over the middle of the field.
|
|
84
91
|
if (view.over && i === Math.floor(FIELD.height / 2)) {
|
|
85
92
|
const msg = ` ${view.over} `;
|
package/dist/src/ui/Tabs.js
CHANGED
|
@@ -16,8 +16,8 @@ function labels(tabs) {
|
|
|
16
16
|
* are left out at the ends, marked with ‹ ›.
|
|
17
17
|
*/
|
|
18
18
|
export function layoutTabs(tabs, active, width) {
|
|
19
|
-
const all = labels(tabs).map((label, i) => ({ file: tabs[i], label }));
|
|
20
|
-
const size = (t) => t.label.length +
|
|
19
|
+
const all = labels(tabs).map((label, i) => ({ file: tabs[i], label: label.length > 24 ? `${label.slice(0, 23)}…` : label }));
|
|
20
|
+
const size = (t) => t.label.length + 6 + 1; // " ◆ name × " and "│"
|
|
21
21
|
const at = Math.max(0, all.findIndex((t) => t.file === active));
|
|
22
22
|
let from = at;
|
|
23
23
|
let to = at + 1;
|
|
@@ -39,10 +39,11 @@ export function layoutTabs(tabs, active, width) {
|
|
|
39
39
|
let x = from > 0 ? 1 : 0;
|
|
40
40
|
for (const t of shown) {
|
|
41
41
|
const w = size(t) - 1;
|
|
42
|
-
zones.push({ file: t.file, from: x, to: x + w, close:
|
|
42
|
+
zones.push({ file: t.file, from: x, to: x + w, close: x + w - 2 });
|
|
43
43
|
x += w + 1;
|
|
44
44
|
}
|
|
45
|
-
|
|
45
|
+
// ‹ and › (tabs that don't fit) are clickable too: they go to the previous / next tab.
|
|
46
|
+
return { shown, before: from > 0, after: to < all.length, zones, prev: from > 0 ? 0 : null, next: to < all.length ? x : null };
|
|
46
47
|
}
|
|
47
48
|
/** The open files as tabs, for a pane's title row: the active one highlighted, with × to close it. */
|
|
48
49
|
export function Tabs({ tabs, active, width, dirty }) {
|
|
@@ -50,6 +51,6 @@ export function Tabs({ tabs, active, width, dirty }) {
|
|
|
50
51
|
return (_jsxs(_Fragment, { children: [before ? _jsx(Text, { color: C.dim, children: "\u2039" }) : null, shown.map((t) => {
|
|
51
52
|
const on = t.file === active;
|
|
52
53
|
const { icon, color } = fileIcon(t.file);
|
|
53
|
-
return (_jsxs(Fragment, { children: [_jsxs(Text, { ...(on ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { children: " " }), _jsx(Text, { color: color, children: icon }), _jsxs(Text, { color: on ? C.text : C.dim, bold: on, children: [" ", t.label] }),
|
|
54
|
+
return (_jsxs(Fragment, { children: [_jsxs(Text, { ...(on ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { children: " " }), _jsx(Text, { color: color, children: icon }), _jsxs(Text, { color: on ? C.text : C.dim, bold: on, children: [" ", t.label] }), _jsxs(Text, { color: on && dirty ? C.yellow : on ? C.text : C.dim, children: [" ", on && dirty ? "●" : "×"] }), _jsx(Text, { children: " " })] }), _jsx(Text, { color: C.dim, children: "\u2502" })] }, t.file));
|
|
54
55
|
}), after ? _jsx(Text, { color: C.dim, children: "\u203A" }) : null] }));
|
|
55
56
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
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
|
+
*/
|
|
10
|
+
export const Terminal = memo(function Terminal({ shell, version, width, height, focused, wheel, onLeave }) {
|
|
11
|
+
const [input, setInput] = useState("");
|
|
12
|
+
const [history, setHistory] = useState([]);
|
|
13
|
+
const [back, setBack] = useState(-1);
|
|
14
|
+
const [scroll, setScroll] = useState(0);
|
|
15
|
+
const inner = width - 2;
|
|
16
|
+
const rows = Math.max(1, height - 3); // borders and the title
|
|
17
|
+
const lines = shell.view();
|
|
18
|
+
// Fit long lines by wrapping them, newest at the bottom.
|
|
19
|
+
const wrapped = [];
|
|
20
|
+
for (const line of lines.slice(-rows * 4)) {
|
|
21
|
+
const text = line.text || " ";
|
|
22
|
+
for (let i = 0; i < text.length; i += inner)
|
|
23
|
+
wrapped.push({ text: text.slice(i, i + inner), color: COLORS[line.kind] });
|
|
24
|
+
}
|
|
25
|
+
const promptText = shell.running ? "› " : shell.prompt();
|
|
26
|
+
const shownPrompt = `${promptText}${input}`;
|
|
27
|
+
const promptRows = Math.max(1, Math.ceil((shownPrompt.length + 1) / inner));
|
|
28
|
+
const outRows = Math.max(0, rows - promptRows);
|
|
29
|
+
const maxScroll = Math.max(0, wrapped.length - outRows);
|
|
30
|
+
const at = Math.min(scroll, maxScroll);
|
|
31
|
+
const shown = wrapped.slice(Math.max(0, wrapped.length - outRows - at), wrapped.length - at);
|
|
32
|
+
useEffect(() => setScroll(0), [version]); // new output: back to the bottom
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (wheel?.lines)
|
|
35
|
+
setScroll((s) => Math.max(0, Math.min(maxScroll, s - wheel.lines)));
|
|
36
|
+
}, [wheel?.nonce]);
|
|
37
|
+
useInput((typed, key) => {
|
|
38
|
+
if (key.escape)
|
|
39
|
+
return onLeave();
|
|
40
|
+
if (key.ctrl && typed === "c") {
|
|
41
|
+
if (shell.running)
|
|
42
|
+
shell.interrupt();
|
|
43
|
+
else
|
|
44
|
+
setInput("");
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (key.ctrl && typed === "l")
|
|
48
|
+
return shell.clear();
|
|
49
|
+
if (key.return) {
|
|
50
|
+
if (!shell.running && input.trim())
|
|
51
|
+
setHistory((h) => [...h.filter((c) => c !== input.trim()), input.trim()].slice(-100));
|
|
52
|
+
if (shell.running)
|
|
53
|
+
shell.input(input);
|
|
54
|
+
else
|
|
55
|
+
shell.run(input);
|
|
56
|
+
setInput("");
|
|
57
|
+
setBack(-1);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (key.upArrow || key.downArrow) {
|
|
61
|
+
if (!history.length)
|
|
62
|
+
return;
|
|
63
|
+
const next = key.upArrow ? Math.min(history.length - 1, back + 1) : back - 1;
|
|
64
|
+
setBack(next);
|
|
65
|
+
setInput(next < 0 ? "" : history[history.length - 1 - next]);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (key.pageUp)
|
|
69
|
+
return setScroll((s) => Math.min(maxScroll, s + outRows - 1));
|
|
70
|
+
if (key.pageDown)
|
|
71
|
+
return setScroll((s) => Math.max(0, s - outRows + 1));
|
|
72
|
+
if (key.backspace || key.delete)
|
|
73
|
+
return setInput((v) => v.slice(0, -1));
|
|
74
|
+
if (key.ctrl && typed === "u")
|
|
75
|
+
return setInput("");
|
|
76
|
+
if (key.ctrl || key.meta || key.tab)
|
|
77
|
+
return;
|
|
78
|
+
if (typed)
|
|
79
|
+
setInput((v) => v + typed.replace(/[\r\n]+/g, " "));
|
|
80
|
+
}, { isActive: focused });
|
|
81
|
+
const color = focused ? C.accent : C.dim;
|
|
82
|
+
const title = ` ▸ Terminal${shell.running ? " ● running · Ctrl+C stops" : ""}${at ? ` ↑ ${at} lines` : ""}`;
|
|
83
|
+
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
|
+
});
|
package/dist/src/ui/commands.js
CHANGED
|
@@ -6,6 +6,7 @@ export const SLASH_COMMANDS = [
|
|
|
6
6
|
{ name: "settings", description: "models, engine, preferences and plugins" },
|
|
7
7
|
{ name: "auto", args: "[ask|auto-edit|auto]", description: "how much Nou does without asking" },
|
|
8
8
|
{ name: "engine", args: "[name]", description: "which agent answers: nou, claude-code, codex or pi" },
|
|
9
|
+
{ name: "cancel", description: "take back your last message (stops Nou; edit it and resend)" },
|
|
9
10
|
{ name: "undo", description: "undo Nou's last change" },
|
|
10
11
|
{ name: "rewind", description: "undo everything from Nou's last turn" },
|
|
11
12
|
{ name: "diff", args: "[file|n]", description: "everything changed this session (or session n)" },
|
|
@@ -15,6 +16,8 @@ export const SLASH_COMMANDS = [
|
|
|
15
16
|
{ name: "copy", args: "[code|file]", description: "copy the last reply, its last code block, or the open file" },
|
|
16
17
|
{ name: "compact", description: "summarize the conversation to free up context" },
|
|
17
18
|
{ name: "clear", description: "start a fresh conversation" },
|
|
19
|
+
{ name: "terminal", description: "a terminal under the code, like VS Code's (or press `)" },
|
|
20
|
+
{ name: "resume", args: "[number|last]", description: "pick up an earlier conversation in this folder (any engine)" },
|
|
18
21
|
{ name: "pull", args: "<model>", description: "download an Ollama model" },
|
|
19
22
|
{ name: "plugins", args: "[add|new <name>]", description: "your plugins and the starter ones" },
|
|
20
23
|
{ name: "mcp", args: "[add|remove|import]", description: "MCP servers: tools from other programs (/mcp add browser for a browser)" },
|
|
@@ -8,4 +8,8 @@ export const RENDER_OPTIONS = {
|
|
|
8
8
|
incrementalRendering: true,
|
|
9
9
|
// Up to 60 frames a second, so scrolling and typing keep up with the keyboard and mouse.
|
|
10
10
|
maxFps: 60,
|
|
11
|
+
// Terminals that speak the kitty keyboard protocol (kitty, Ghostty, WezTerm, iTerm2, foot...) then tell
|
|
12
|
+
// Enter from Ctrl+Enter, Cmd+Enter and Shift+Enter, so those can add a line in the chat. Others just
|
|
13
|
+
// don't answer the query and keys work as before.
|
|
14
|
+
kittyKeyboard: { mode: "auto", flags: ["disambiguateEscapeCodes"] },
|
|
11
15
|
};
|
package/dist/src/ui/text.js
CHANGED
|
@@ -369,3 +369,19 @@ export function markAnsi(line, from, to, open = "\x1b[7m", close = "\x1b[27m") {
|
|
|
369
369
|
}
|
|
370
370
|
return out;
|
|
371
371
|
}
|
|
372
|
+
/**
|
|
373
|
+
* A reply still streaming, with its open markdown closed for now (a code block, **bold**, `code`), so it
|
|
374
|
+
* renders the way it will once finished instead of reflowing when the closing marker arrives.
|
|
375
|
+
*/
|
|
376
|
+
export function closeOpenMarkdown(text) {
|
|
377
|
+
const fences = text.match(/^\s*(`{3,}|~{3,})/gm)?.length ?? 0;
|
|
378
|
+
if (fences % 2)
|
|
379
|
+
return `${text}\n\`\`\``;
|
|
380
|
+
const last = text.slice(text.lastIndexOf("\n") + 1);
|
|
381
|
+
let out = text;
|
|
382
|
+
if ((last.match(/`/g)?.length ?? 0) % 2)
|
|
383
|
+
out += "`";
|
|
384
|
+
else if ((last.match(/\*\*/g)?.length ?? 0) % 2)
|
|
385
|
+
out += "**";
|
|
386
|
+
return out;
|
|
387
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nou-cli",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.3",
|
|
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": {
|