nou-cli 0.2.0 → 0.2.1
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 +2 -0
- package/dist/src/config/index.js +1 -0
- package/dist/src/ui/App.js +35 -5
- package/dist/src/ui/Chat.js +9 -3
- package/dist/src/ui/CodeView.js +5 -1
- package/dist/src/ui/Editor.js +5 -1
- package/dist/src/ui/FileTree.js +18 -1
- package/dist/src/ui/Header.js +3 -1
- package/dist/src/ui/ProblemsView.js +5 -1
- package/dist/src/ui/Settings.js +1 -0
- package/dist/src/ui/mouse.js +52 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -73,6 +73,8 @@ Every change is reviewed like a code review:
|
|
|
73
73
|
|
|
74
74
|
| Key | Does |
|
|
75
75
|
| --- | --- |
|
|
76
|
+
| 🖱️ Mouse | wheel scrolls the pane under the pointer · click a pane to focus it · click a file to open it (hold <kbd>Shift</kbd> to select text; turn off in settings) |
|
|
77
|
+
| <kbd>Shift</kbd>+<kbd>↑</kbd>/<kbd>↓</kbd>, <kbd>PgUp</kbd>/<kbd>PgDn</kbd> | scroll the chat history |
|
|
76
78
|
| <kbd>Tab</kbd> | switch between files, code and chat |
|
|
77
79
|
| <kbd>Ctrl</kbd>+<kbd>P</kbd> | fuzzy-find a file |
|
|
78
80
|
| <kbd>e</kbd> | edit the open file (<kbd>Ctrl</kbd>+<kbd>S</kbd> save · <kbd>Ctrl</kbd>+<kbd>Z</kbd>/<kbd>Y</kbd> undo/redo · <kbd>Ctrl</kbd>+<kbd>K</kbd> info · <kbd>Ctrl</kbd>+<kbd>G</kbd> definition · <kbd>Esc</kbd> close) |
|
package/dist/src/config/index.js
CHANGED
package/dist/src/ui/App.js
CHANGED
|
@@ -13,9 +13,10 @@ import { DiagnosticsManager, formatDiagnostic, newProblems } from "../diagnostic
|
|
|
13
13
|
import { Timeline } from "../history/index.js";
|
|
14
14
|
import { lsp } from "../lsp/index.js";
|
|
15
15
|
import { onGrammarLoaded, preload } from "../syntax/index.js";
|
|
16
|
-
import { checkForUpdate } from "../update/index.js";
|
|
16
|
+
import { checkForUpdate, currentVersion } from "../update/index.js";
|
|
17
17
|
import { changedLines } from "./text.js";
|
|
18
18
|
import { ProblemsView } from "./ProblemsView.js";
|
|
19
|
+
import { useMouse } from "./mouse.js";
|
|
19
20
|
import { C } from "./theme.js";
|
|
20
21
|
import { FileTree } from "./FileTree.js";
|
|
21
22
|
import { CodeView } from "./CodeView.js";
|
|
@@ -35,6 +36,7 @@ const HELP = `Commands:
|
|
|
35
36
|
/undo undo Nou's last change (also: u)
|
|
36
37
|
/rewind undo everything from Nou's last turn
|
|
37
38
|
/history recent changes
|
|
39
|
+
/version which version of Nou this is
|
|
38
40
|
Keys:
|
|
39
41
|
Ctrl+P find a file Tab switch pane Esc stop / back
|
|
40
42
|
e edit open file v diff (git / Nou) f follow mode
|
|
@@ -84,6 +86,10 @@ export function App({ initialModel, permContext }) {
|
|
|
84
86
|
const [pendingNote, setPendingNote] = useState(null);
|
|
85
87
|
const [exitArmed, setExitArmed] = useState(false);
|
|
86
88
|
const [update, setUpdate] = useState(null);
|
|
89
|
+
const [wheel, setWheel] = useState(null);
|
|
90
|
+
const [treeClick, setTreeClick] = useState(null);
|
|
91
|
+
// Pane geometry from the last render, so mouse events can be mapped to the pane under the pointer.
|
|
92
|
+
const layout = useRef({ treeWidth: 0, codeWidth: 0, paneHeight: 0 });
|
|
87
93
|
const diagnosticsRef = useRef(null);
|
|
88
94
|
diagnosticsRef.current ??= new DiagnosticsManager();
|
|
89
95
|
const diagnostics = diagnosticsRef.current;
|
|
@@ -311,6 +317,26 @@ export function App({ initialModel, permContext }) {
|
|
|
311
317
|
setExitArmed(true);
|
|
312
318
|
setTimeout(() => setExitArmed(false), 2000);
|
|
313
319
|
};
|
|
320
|
+
// Mouse: the wheel scrolls whatever pane is under the pointer; a click focuses that pane
|
|
321
|
+
// (and in the file tree, opens the clicked file).
|
|
322
|
+
useMouse((event) => {
|
|
323
|
+
if (view !== "main" || quickOpen)
|
|
324
|
+
return;
|
|
325
|
+
const { treeWidth, codeWidth, paneHeight } = layout.current;
|
|
326
|
+
if (event.y < 1 || event.y > paneHeight)
|
|
327
|
+
return; // header or status bar
|
|
328
|
+
const pane = event.x < treeWidth ? "tree" : event.x < treeWidth + codeWidth ? "code" : "chat";
|
|
329
|
+
if (event.kind === "wheel") {
|
|
330
|
+
setWheel((w) => ({ pane, dir: event.dir, nonce: (w?.nonce ?? 0) + 1 }));
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (approval)
|
|
334
|
+
return;
|
|
335
|
+
setFocus(pane);
|
|
336
|
+
// Rows start under the pane's top border (y=1) and title (y=2).
|
|
337
|
+
if (pane === "tree" && event.y >= 3)
|
|
338
|
+
setTreeClick((c) => ({ row: event.y - 3, nonce: (c?.nonce ?? 0) + 1 }));
|
|
339
|
+
}, settings.mouse);
|
|
314
340
|
// Ctrl+C: stop Nou if it's working; otherwise press twice to quit.
|
|
315
341
|
useInput((input, key) => {
|
|
316
342
|
if (!(key.ctrl && input === "c"))
|
|
@@ -558,6 +584,8 @@ ${snippet}
|
|
|
558
584
|
agentRef.current.clear();
|
|
559
585
|
setEntries([]);
|
|
560
586
|
return;
|
|
587
|
+
case "version":
|
|
588
|
+
return info(`nou v${currentVersion()}${update ? ` · v${update} is available, run \`nou update\`` : " · up to date"}`);
|
|
561
589
|
case "help":
|
|
562
590
|
return info(HELP);
|
|
563
591
|
default: {
|
|
@@ -618,6 +646,8 @@ ${snippet}
|
|
|
618
646
|
const treeWidth = Math.max(24, Math.floor(columns * 0.2));
|
|
619
647
|
const chatWidth = Math.max(34, Math.floor(columns * 0.3));
|
|
620
648
|
const codeWidth = Math.max(20, columns - treeWidth - chatWidth);
|
|
649
|
+
layout.current = { treeWidth, codeWidth, paneHeight: height };
|
|
650
|
+
const wheelFor = (pane) => (wheel?.pane === pane ? wheel : null);
|
|
621
651
|
const edited = new Set([...changes].filter(([, c]) => c.status === "applied").map(([p]) => p));
|
|
622
652
|
const fileProblems = openFile ? diagnostics.get(openFile) : [];
|
|
623
653
|
const totals = diagnostics.counts();
|
|
@@ -634,14 +664,14 @@ ${snippet}
|
|
|
634
664
|
: showEditor && focus === "code"
|
|
635
665
|
? `Ctrl+S save${settings.autoSave === "off" ? "" : " (auto-save on)"} · Ctrl+Z/Y undo/redo · Ctrl+K info · Ctrl+G definition · Esc close`
|
|
636
666
|
: focus === "chat"
|
|
637
|
-
? busy ? "Esc stop · Tab switch pane" : "Enter send · / commands · Ctrl+P find file · Tab switch pane"
|
|
667
|
+
? busy ? "Esc stop · Shift+↑↓ scroll chat · Tab switch pane" : "Enter send · Shift+↑↓ / PgUp scroll · / commands · Ctrl+P find file · Tab switch pane"
|
|
638
668
|
: focus === "tree"
|
|
639
669
|
? "↑↓ move · Enter open · c changes · e edit · p problems · u undo · Ctrl+P find · s settings"
|
|
640
670
|
: diff ? "n/N next/prev change · v cycle diff · Esc close diff" : "↑↓ scroll · e edit · v diff · p problems · u undo · s settings";
|
|
641
|
-
return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(Header, { width: columns, branch: branch, changes: git.size, model: describe(choice), local: choice.provider.local, busy: busy, update: update }), _jsxs(Box, { flexDirection: "row", height: height, children: [_jsx(FileTree, { width: treeWidth, height: height, focused: focus === "tree" && !approval, openFile: openFile, reveal: reveal, version: treeVersion, changed: edited, git: git, problemCounts: diagnostics.fileCounts(), onOpen: (filePath) => {
|
|
671
|
+
return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(Header, { width: columns, branch: branch, changes: git.size, model: describe(choice), local: choice.provider.local, busy: busy, update: update }), _jsxs(Box, { flexDirection: "row", height: height, children: [_jsx(FileTree, { width: treeWidth, height: height, focused: focus === "tree" && !approval, openFile: openFile, reveal: reveal, version: treeVersion, changed: edited, git: git, problemCounts: diagnostics.fileCounts(), wheel: wheelFor("tree"), click: treeClick, onOpen: (filePath) => {
|
|
642
672
|
open(filePath);
|
|
643
673
|
setFocus("code");
|
|
644
|
-
}, onHover: () => { } }), codePanel === "problems" && !pendingChange ? (_jsx(ProblemsView, { width: codeWidth, height: height, focused: focus === "code" && !approval, problems: diagnostics.all(), checking: checking, onJump: (file, line) => openAt(file, line), onClose: () => setCodePanel("code") })) : showEditor ? (_jsx(Editor, { width: codeWidth, height: height, focused: focus === "code", filePath: openFile, initial: draft.current?.path === openFile ? draft.current.text : content, baseline: draft.current?.path === openFile ? draft.current.baseline : content, startLine: editorStart.line, problems: fileProblems, autoSave: settings.autoSave, syntaxVersion: syntaxVersion, onHover: (text, line, col) => (lspEnabled ? lsp.hover(openFile, text, line, col) : Promise.resolve(null)), onDefinition: (text, line, col) => {
|
|
674
|
+
}, onHover: () => { } }), codePanel === "problems" && !pendingChange ? (_jsx(ProblemsView, { width: codeWidth, height: height, focused: focus === "code" && !approval, problems: diagnostics.all(), checking: checking, wheel: wheelFor("code"), onJump: (file, line) => openAt(file, line), onClose: () => setCodePanel("code") })) : showEditor ? (_jsx(Editor, { width: codeWidth, height: height, focused: focus === "code", filePath: openFile, initial: draft.current?.path === openFile ? draft.current.text : content, baseline: draft.current?.path === openFile ? draft.current.baseline : content, startLine: editorStart.line, problems: fileProblems, autoSave: settings.autoSave, syntaxVersion: syntaxVersion, wheel: wheelFor("code"), onHover: (text, line, col) => (lspEnabled ? lsp.hover(openFile, text, line, col) : Promise.resolve(null)), onDefinition: (text, line, col) => {
|
|
645
675
|
if (!lspEnabled)
|
|
646
676
|
return info("Go to definition needs a language server (see /settings → Plugins).", "error");
|
|
647
677
|
void lsp.definition(openFile, text, line, col).then((target) => {
|
|
@@ -660,7 +690,7 @@ ${snippet}
|
|
|
660
690
|
draft.current = state.dirty
|
|
661
691
|
? { path: openFile, text: state.lines.join("\n"), baseline: draft.current?.path === openFile ? draft.current.baseline : content }
|
|
662
692
|
: null;
|
|
663
|
-
} }, `${openFile}:${editorStart.nonce}`)) : (_jsx(CodeView, { width: codeWidth, height: height, focused: focus === "code" && !approval, filePath: openFile, content: content, diff: diff, diffLabel: diffMode === "git" ? "vs HEAD" : "Nou's edit", pending: !!pendingChange, marked: record?.lines ?? NO_LINES, problems: fileProblems, focusLine: focusLine, syntaxVersion: syntaxVersion, diffNote: pendingChange ? pendingNote?.short ?? (checking.length ? "checking…" : null) : null, onViewport: (top, bottom) => setViewport((v) => (v?.top === top && v.bottom === bottom ? v : { top, bottom })) })), _jsx(Chat, { width: chatWidth, height: height, focused: focus === "chat", entries: entries, streaming: streaming, busy: busy, approval: approval?.request ?? null, approvalNote: pendingChange ? pendingNote?.detail ?? null : null, contextChip: contextChip, onSubmit: send })] }), _jsx(Box, { children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { backgroundColor: mode.color, color: "black", bold: true, children: ` ${mode.label} ` }), _jsx(Text, { backgroundColor: C.panel, color: follow ? C.green : C.dim, children: follow ? " ◉ follow " : " ○ follow " }), _jsx(Text, { backgroundColor: C.panel, color: totals.errors ? C.red : C.dim, children: ` ✖ ${totals.errors}` }), _jsx(Text, { backgroundColor: C.panel, color: totals.warnings ? C.yellow : C.dim, children: ` ▲ ${totals.warnings} ` }), checking.length > 0 && _jsxs(Text, { color: C.accent2, children: [" \u27F3 ", checking.join(", ")] }), exitArmed
|
|
693
|
+
} }, `${openFile}:${editorStart.nonce}`)) : (_jsx(CodeView, { width: codeWidth, height: height, focused: focus === "code" && !approval, filePath: openFile, content: content, diff: diff, diffLabel: diffMode === "git" ? "vs HEAD" : "Nou's edit", pending: !!pendingChange, marked: record?.lines ?? NO_LINES, problems: fileProblems, focusLine: focusLine, syntaxVersion: syntaxVersion, diffNote: pendingChange ? pendingNote?.short ?? (checking.length ? "checking…" : null) : null, wheel: wheelFor("code"), onViewport: (top, bottom) => setViewport((v) => (v?.top === top && v.bottom === bottom ? v : { top, bottom })) })), _jsx(Chat, { width: chatWidth, height: height, focused: focus === "chat", entries: entries, streaming: streaming, busy: busy, approval: approval?.request ?? null, approvalNote: pendingChange ? pendingNote?.detail ?? null : null, contextChip: contextChip, wheel: wheelFor("chat"), onSubmit: send })] }), _jsx(Box, { children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { backgroundColor: mode.color, color: "black", bold: true, children: ` ${mode.label} ` }), _jsx(Text, { backgroundColor: C.panel, color: follow ? C.green : C.dim, children: follow ? " ◉ follow " : " ○ follow " }), _jsx(Text, { backgroundColor: C.panel, color: totals.errors ? C.red : C.dim, children: ` ✖ ${totals.errors}` }), _jsx(Text, { backgroundColor: C.panel, color: totals.warnings ? C.yellow : C.dim, children: ` ▲ ${totals.warnings} ` }), checking.length > 0 && _jsxs(Text, { color: C.accent2, children: [" \u27F3 ", checking.join(", ")] }), exitArmed
|
|
664
694
|
? _jsxs(Text, { color: C.yellow, bold: true, children: [" ", "Press again to quit", draft.current && settings.autoSave === "off" ? ` (unsaved changes in ${draft.current.path} will be lost)` : ""] })
|
|
665
695
|
: _jsxs(Text, { color: C.dim, children: [" ", hints] })] }) }), quickOpen && (_jsx(QuickOpen, { width: columns, top: 3, files: allFiles, onPick: (file) => {
|
|
666
696
|
setQuickOpen(false);
|
package/dist/src/ui/Chat.js
CHANGED
|
@@ -5,8 +5,8 @@ import { wrap } from "./text.js";
|
|
|
5
5
|
import { ChatInput, inputHeight } from "./ChatInput.js";
|
|
6
6
|
import { C, SPINNER } from "./theme.js";
|
|
7
7
|
import { Pane } from "./Pane.js";
|
|
8
|
-
export const COMMANDS = ["/model", "/models", "/settings", "/clear", "/help"];
|
|
9
|
-
export function Chat({ width, height, focused, entries, streaming, busy, approval, approvalNote, contextChip, onSubmit }) {
|
|
8
|
+
export const COMMANDS = ["/model", "/models", "/settings", "/undo", "/rewind", "/history", "/problems", "/version", "/clear", "/help"];
|
|
9
|
+
export function Chat({ width, height, focused, entries, streaming, busy, approval, approvalNote, contextChip, wheel, onSubmit }) {
|
|
10
10
|
const [input, setInput] = useState("");
|
|
11
11
|
const [scrollBack, setScrollBack] = useState(0);
|
|
12
12
|
const { frame } = useAnimation({ interval: 80, isActive: !!busy });
|
|
@@ -74,6 +74,10 @@ export function Chat({ width, height, focused, entries, streaming, busy, approva
|
|
|
74
74
|
const maxBack = Math.max(0, lines.length - rows);
|
|
75
75
|
const back = Math.min(scrollBack, maxBack);
|
|
76
76
|
const visible = lines.slice(Math.max(0, lines.length - rows - back), lines.length - back);
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (wheel)
|
|
79
|
+
setScrollBack((b) => Math.max(0, Math.min(maxBack, b - wheel.dir * 3)));
|
|
80
|
+
}, [wheel?.nonce]);
|
|
77
81
|
useInput((_input, key) => {
|
|
78
82
|
if (key.pageUp || (key.upArrow && key.shift))
|
|
79
83
|
setScrollBack((b) => Math.min(maxBack, b + Math.max(1, rows - 2)));
|
|
@@ -90,7 +94,9 @@ export function Chat({ width, height, focused, entries, streaming, busy, approva
|
|
|
90
94
|
};
|
|
91
95
|
const render = (line, key) => (_jsxs(Text, { wrap: "truncate-end", children: [line.bar ? _jsx(Text, { color: line.bar, children: "\u258C " }) : null, _jsx(Text, { bold: !!line.bold, dimColor: !!line.dim, ...(line.color ? { color: line.color } : {}), children: line.text || " " })] }, key));
|
|
92
96
|
const empty = entries.length === 0 && !streaming;
|
|
93
|
-
const title = _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u25C6" }), " Chat", back
|
|
97
|
+
const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u25C6" }), " Chat", back
|
|
98
|
+
? _jsxs(Text, { color: C.yellow, children: [" \u2191 scrolled ", back, " lines \u00B7 Shift+\u2193 / PgDn / wheel to go back"] })
|
|
99
|
+
: maxBack > 0 ? _jsx(Text, { color: C.dim, children: " Shift+\u2191 / PgUp / wheel to scroll" }) : null] }));
|
|
94
100
|
return (_jsxs(Pane, { title: title, width: width, height: height, focused: focused, children: [_jsx(Box, { flexDirection: "column", height: rows, 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." })] })) : visible.map((line, i) => render(line, i)) }), _jsx(Box, { flexDirection: "column", children: footer.map((line, i) => render(line, `f${i}`)) }), _jsx(Text, { wrap: "truncate-end", color: C.dim, children: suggestions.length
|
|
95
101
|
? _jsxs(Text, { color: C.accent, children: [suggestions.join(" "), " ", _jsx(Text, { color: C.dim, children: suggestions.length === 1 ? "Enter ↵" : "" })] })
|
|
96
102
|
: 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)) }), _jsx(ChatInput, { value: input, onChange: setInput, onSubmit: submit, focus: focused && !approval, width: inner, history: history, placeholder: busy ? "working… Esc to stop" : "Ask anything · / for commands · ↑ history" })] }));
|
package/dist/src/ui/CodeView.js
CHANGED
|
@@ -6,7 +6,7 @@ import { artWork } from "../util/index.js";
|
|
|
6
6
|
import { highlightLines, sideBySide } from "./text.js";
|
|
7
7
|
import { C, fileIcon, gradient } from "./theme.js";
|
|
8
8
|
import { Pane } from "./Pane.js";
|
|
9
|
-
export function CodeView({ width, height, focused, filePath, content, diff, diffLabel, pending, marked, problems, focusLine, syntaxVersion, diffNote, onViewport }) {
|
|
9
|
+
export function CodeView({ width, height, focused, filePath, content, diff, diffLabel, pending, marked, problems, focusLine, syntaxVersion, diffNote, wheel, onViewport }) {
|
|
10
10
|
const [top, setTop] = useState(0);
|
|
11
11
|
const rows = Math.max(1, height - 3);
|
|
12
12
|
const inner = width - 2;
|
|
@@ -32,6 +32,10 @@ export function CodeView({ width, height, focused, filePath, content, diff, diff
|
|
|
32
32
|
}, [focusLine]);
|
|
33
33
|
const maxTop = Math.max(0, total - rows);
|
|
34
34
|
const start = Math.min(top, maxTop);
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (wheel)
|
|
37
|
+
setTop((t) => Math.max(0, Math.min(maxTop, Math.min(t, maxTop) + wheel.dir * 3)));
|
|
38
|
+
}, [wheel?.nonce]);
|
|
35
39
|
useEffect(() => {
|
|
36
40
|
if (filePath && !diff)
|
|
37
41
|
onViewport(start + 1, Math.min(total, start + rows));
|
package/dist/src/ui/Editor.js
CHANGED
|
@@ -7,7 +7,7 @@ import { SEVERITY_STYLE } from "./ProblemsView.js";
|
|
|
7
7
|
import { Pane } from "./Pane.js";
|
|
8
8
|
const expand = (s) => s.replace(/\t/g, " ");
|
|
9
9
|
/** A small modal text editor: arrows to move, type to insert, Ctrl+S save, Ctrl+Z undo, Esc leave. */
|
|
10
|
-
export function Editor({ width, height, focused, filePath, initial, baseline, startLine, problems, autoSave, syntaxVersion, onSave, onExit, onState, onHover, onDefinition }) {
|
|
10
|
+
export function Editor({ width, height, focused, filePath, initial, baseline, startLine, problems, autoSave, syntaxVersion, wheel, onSave, onExit, onState, onHover, onDefinition }) {
|
|
11
11
|
const eol = useMemo(() => (baseline.includes("\r\n") ? "\r\n" : "\n"), [baseline]);
|
|
12
12
|
const indentUnit = useMemo(() => (/^\t/m.test(initial) ? "\t" : " "), [initial]);
|
|
13
13
|
const [buf, setBuf] = useState(() => {
|
|
@@ -91,6 +91,10 @@ export function Editor({ width, height, focused, filePath, initial, baseline, st
|
|
|
91
91
|
}, [focused]);
|
|
92
92
|
const position = () => ({ text: buf.lines.join("\n"), line: buf.row + 1, col: buf.col + 1 });
|
|
93
93
|
const rows = Math.max(1, height - 4);
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (wheel)
|
|
96
|
+
move((b) => ({ row: b.row + wheel.dir * 3 }));
|
|
97
|
+
}, [wheel?.nonce]);
|
|
94
98
|
usePaste((text) => insert(text), { isActive: focused });
|
|
95
99
|
useInput((input, key) => {
|
|
96
100
|
if (confirmExit) {
|
package/dist/src/ui/FileTree.js
CHANGED
|
@@ -30,7 +30,7 @@ function flatten(expanded) {
|
|
|
30
30
|
walk(".", 0);
|
|
31
31
|
return nodes;
|
|
32
32
|
}
|
|
33
|
-
export function FileTree({ width, height, focused, openFile, reveal, version, changed, git, problemCounts, onOpen, onHover }) {
|
|
33
|
+
export function FileTree({ width, height, focused, openFile, reveal, version, changed, git, problemCounts, wheel, click, onOpen, onHover }) {
|
|
34
34
|
const [expanded, setExpanded] = useState(() => new Set(["src"]));
|
|
35
35
|
const [cursor, setCursor] = useState(0);
|
|
36
36
|
const [changesOnly, setChangesOnly] = useState(false);
|
|
@@ -118,6 +118,23 @@ export function FileTree({ width, height, focused, openFile, reveal, version, ch
|
|
|
118
118
|
}, { isActive: focused });
|
|
119
119
|
const start = Math.max(0, Math.min(cursor - Math.floor(rows / 2), nodes.length - rows));
|
|
120
120
|
const visible = nodes.slice(start, start + rows);
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
if (wheel)
|
|
123
|
+
setCursor((c) => Math.max(0, Math.min(nodes.length - 1, c + wheel.dir * 3)));
|
|
124
|
+
}, [wheel?.nonce]);
|
|
125
|
+
// Clicking a row selects it; a file opens, a folder expands or collapses.
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
if (!click)
|
|
128
|
+
return;
|
|
129
|
+
const node = visible[click.row];
|
|
130
|
+
if (!node)
|
|
131
|
+
return;
|
|
132
|
+
setCursor(start + click.row);
|
|
133
|
+
if (node.isDir)
|
|
134
|
+
toggle(node.path);
|
|
135
|
+
else
|
|
136
|
+
onOpen(node.path);
|
|
137
|
+
}, [click?.nonce]);
|
|
121
138
|
const inner = width - 2;
|
|
122
139
|
const title = changesOnly
|
|
123
140
|
? _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/Header.js
CHANGED
|
@@ -2,8 +2,10 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { Box, Text, useAnimation } from "ink";
|
|
4
4
|
import { C, SPINNER, gradient } from "./theme.js";
|
|
5
|
+
import { currentVersion } from "../update/index.js";
|
|
6
|
+
const VERSION = `v${currentVersion()}`;
|
|
5
7
|
export function Header({ width, branch, changes, model, local, busy, update }) {
|
|
6
8
|
const { frame } = useAnimation({ interval: 120, isActive: !!busy });
|
|
7
9
|
const project = path.basename(process.cwd());
|
|
8
|
-
return (_jsxs(Box, { width: width, justifyContent: "space-between", 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)), _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] })] }), _jsxs(Text, { children: [update && _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} ` }), " "] })] }));
|
|
10
|
+
return (_jsxs(Box, { width: width, justifyContent: "space-between", 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] })] }), _jsxs(Text, { children: [update && _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} ` }), " "] })] }));
|
|
9
11
|
}
|
|
@@ -8,7 +8,7 @@ export const SEVERITY_STYLE = {
|
|
|
8
8
|
warning: { icon: "▲", color: C.yellow },
|
|
9
9
|
info: { icon: "ℹ", color: C.accent },
|
|
10
10
|
};
|
|
11
|
-
export function ProblemsView({ width, height, focused, problems, checking, onJump, onClose }) {
|
|
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
14
|
const { frame } = useAnimation({ interval: 80, isActive: checking.length > 0 });
|
|
@@ -32,6 +32,10 @@ export function ProblemsView({ width, height, focused, problems, checking, onJum
|
|
|
32
32
|
return out;
|
|
33
33
|
}, [problems, onlyErrors]);
|
|
34
34
|
useEffect(() => setCursor((c) => Math.min(c, Math.max(0, rows.length - 1))), [rows.length]);
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (wheel)
|
|
37
|
+
setCursor((c) => Math.max(0, Math.min(rows.length - 1, c + wheel.dir * 3)));
|
|
38
|
+
}, [wheel?.nonce]);
|
|
35
39
|
const visibleRows = Math.max(1, height - 4);
|
|
36
40
|
useInput((input, key) => {
|
|
37
41
|
if (key.upArrow || input === "k")
|
package/dist/src/ui/Settings.js
CHANGED
|
@@ -11,6 +11,7 @@ const TABS = ["Model", "Preferences", "Plugins"];
|
|
|
11
11
|
const PREFS = [
|
|
12
12
|
{ key: "effort", label: "Reasoning effort", help: "How hard Claude thinks (low → max). Higher is smarter but slower and pricier." },
|
|
13
13
|
{ key: "autoSave", label: "Auto-save", help: "delay: a second after you stop typing · focus: when you leave the editor · off: only Ctrl+S." },
|
|
14
|
+
{ key: "mouse", label: "Mouse", help: "Wheel scrolls the pane under the pointer; click to focus or open files. Hold Shift to select text." },
|
|
14
15
|
{ key: "followMode", label: "Follow mode on start", help: "The code view jumps to files Nou reads and edits." },
|
|
15
16
|
{ key: "gitContext", label: "Git context", help: "Give the model your branch, changes and commits, plus git tools." },
|
|
16
17
|
{ key: "autoApproveEdits", label: "Auto-approve edits", help: "Apply file edits without asking (diffs are still shown).", warn: true },
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { useStdin } from "ink";
|
|
3
|
+
// Report clicks and wheel (1000) using SGR coordinates (1006), which work past column 223.
|
|
4
|
+
const ENABLE = "\x1b[?1000h\x1b[?1006h";
|
|
5
|
+
const DISABLE = "\x1b[?1000l\x1b[?1006l";
|
|
6
|
+
/** Mouse events in a chunk of terminal input (SGR format: ESC [ < button ; x ; y M|m). */
|
|
7
|
+
export function parseMouse(chunk) {
|
|
8
|
+
const events = [];
|
|
9
|
+
// eslint-disable-next-line no-control-regex -- mouse reports start with ESC
|
|
10
|
+
for (const m of chunk.matchAll(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/g)) {
|
|
11
|
+
const code = Number(m[1]);
|
|
12
|
+
const x = Number(m[2]) - 1;
|
|
13
|
+
const y = Number(m[3]) - 1;
|
|
14
|
+
const button = code & 0b11000011; // drop the shift/alt/ctrl bits
|
|
15
|
+
if (button === 64 || button === 65)
|
|
16
|
+
events.push({ kind: "wheel", dir: button === 64 ? -1 : 1, x, y });
|
|
17
|
+
else if (button === 0 && m[4] === "M")
|
|
18
|
+
events.push({ kind: "click", x, y });
|
|
19
|
+
}
|
|
20
|
+
return events;
|
|
21
|
+
}
|
|
22
|
+
let exitHookInstalled = false;
|
|
23
|
+
/**
|
|
24
|
+
* Turns on mouse reporting while mounted and calls `handler` for wheel and click events.
|
|
25
|
+
* Ink keeps reading the keyboard as usual: a 'data' listener sees the same chunks Ink reads, and Ink
|
|
26
|
+
* already ignores mouse sequences, so they never show up as typed text.
|
|
27
|
+
*/
|
|
28
|
+
export function useMouse(handler, enabled) {
|
|
29
|
+
const { stdin } = useStdin();
|
|
30
|
+
const latest = useRef(handler);
|
|
31
|
+
latest.current = handler;
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!enabled || !stdin)
|
|
34
|
+
return;
|
|
35
|
+
const terminal = process.stdout.isTTY ? process.stdout : null;
|
|
36
|
+
terminal?.write(ENABLE);
|
|
37
|
+
if (terminal && !exitHookInstalled) {
|
|
38
|
+
// Leave the terminal clean even if Nou crashes; otherwise the shell gets mouse garbage.
|
|
39
|
+
process.on("exit", () => process.stdout.write(DISABLE));
|
|
40
|
+
exitHookInstalled = true;
|
|
41
|
+
}
|
|
42
|
+
const onData = (data) => {
|
|
43
|
+
for (const event of parseMouse(String(data)))
|
|
44
|
+
latest.current(event);
|
|
45
|
+
};
|
|
46
|
+
stdin.on("data", onData);
|
|
47
|
+
return () => {
|
|
48
|
+
stdin.off("data", onData);
|
|
49
|
+
terminal?.write(DISABLE);
|
|
50
|
+
};
|
|
51
|
+
}, [enabled, stdin]);
|
|
52
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nou-cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
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": {
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@eslint/js": "^10.0.1",
|
|
60
|
-
"@types/node": "^
|
|
60
|
+
"@types/node": "^22.20.5",
|
|
61
61
|
"@types/react": "^19.3.0",
|
|
62
62
|
"eslint": "^10.2.0",
|
|
63
63
|
"globals": "^17.4.0",
|