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 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) |
@@ -13,6 +13,7 @@ export const DEFAULT_SETTINGS = {
13
13
  autoApproveCommands: false,
14
14
  gitContext: true,
15
15
  autoSave: "delay",
16
+ mouse: true,
16
17
  plugins: {},
17
18
  trustedPlugins: {},
18
19
  };
@@ -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);
@@ -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 ? _jsxs(Text, { color: C.dim, children: [" \u2191", back, " more"] }) : null] });
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" })] }));
@@ -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));
@@ -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) {
@@ -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"] })] })
@@ -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")
@@ -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.0",
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": "^25.5.0",
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",