nou-cli 0.5.0 → 0.5.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
@@ -76,13 +76,13 @@ Every change is reviewed like a code review:
76
76
 
77
77
  | Key | Does |
78
78
  | --- | --- |
79
- | 🖱️ Mouse | wheel scrolls the pane under the pointer (<kbd>Shift</kbd>+wheel or a tilt wheel: sideways) · click or drag a scrollbar to jump · click a file to open it · **click in the code to edit right there**, drag to select · drag a border between panes to resize them (press <kbd>m</kbd> to turn the mouse off and select text in your terminal) |
79
+ | 🖱️ Mouse | wheel scrolls the pane under the pointer (<kbd>Shift</kbd>+wheel or a tilt wheel: sideways) · click or drag a scrollbar to jump · click a file to open it · **double-click in the code to edit right there** (in the editor: select a word), drag to select · drag a border between panes to resize them (press <kbd>m</kbd> to turn the mouse off and select text in your terminal) |
80
80
  | <kbd>PgUp</kbd>/<kbd>PgDn</kbd>, <kbd>Shift</kbd>+<kbd>↑</kbd>/<kbd>↓</kbd> | scroll the chat; scrolled up, new replies wait below instead of yanking the view (<kbd>Ctrl</kbd>+<kbd>End</kbd> jumps back) |
81
81
  | <kbd>/</kbd> | start a command from any pane |
82
82
  | <kbd>Tab</kbd> | switch between files, code and chat |
83
83
  | <kbd>Shift</kbd>+<kbd>Tab</kbd> | ask → auto-edit (edits apply without asking) → auto (commands too); everything stays undoable |
84
84
  | <kbd>Ctrl</kbd>+<kbd>P</kbd> | fuzzy-find a file |
85
- | <kbd>e</kbd> or a click | edit the open file (see the editor keys below) |
85
+ | <kbd>e</kbd> or a double-click | edit the open file (see the editor keys below) |
86
86
  | <kbd>p</kbd> | Problems panel: every error and warning, <kbd>Enter</kbd> jumps to it |
87
87
  | <kbd>u</kbd> | undo Nou's last edit |
88
88
  | <kbd>v</kbd> | diff the open file vs git `HEAD`, Nou's last edit, or this session · <kbd>n</kbd>/<kbd>N</kbd> jump between changes · <kbd>[</kbd>/<kbd>]</kbd> previous/next file in a session diff |
@@ -34,6 +34,7 @@ const MIN_TREE = 14;
34
34
  const MIN_CODE = 20;
35
35
  const MIN_CHAT = 24;
36
36
  const NO_LINES = new Set();
37
+ const DOUBLE_CLICK_MS = 500;
37
38
  const HELP = `Commands:
38
39
  /settings models, preferences and plugins
39
40
  /model show providers and the current model
@@ -60,10 +61,10 @@ Keys:
60
61
  Shift+Tab ask / auto-edit / auto < > resize pane = reset
61
62
  m mouse on/off (off: select text to copy; Shift+drag also works in most terminals)
62
63
  / start a command from any pane
63
- e edit open file (or click in the code) v diff (git / Nou / session) f follow mode
64
+ e edit open file (or double-click in the code) v diff (git / Nou / session) f follow mode
64
65
  p problems u undo Nou's edit s settings
65
66
  c (in files) show only changed files Ctrl+C twice quit
66
- Mouse: wheel scrolls (Shift+wheel sideways) · click/drag a scrollbar · click in the code to edit there, drag to select
67
+ Mouse: wheel scrolls (Shift+wheel sideways) · click/drag a scrollbar · double-click in the code to edit there (in the editor: select a word), drag to select
67
68
  In the editor:
68
69
  Shift+arrows / mouse drag select · Ctrl+A all · Ctrl+C/X/V copy/cut/paste · Esc clear selection or leave
69
70
  Ctrl+←/→ word · Ctrl+Backspace or Ctrl+W delete word · Ctrl+Home/End file start/end
@@ -146,7 +147,7 @@ export function App({ initialModel, permContext }) {
146
147
  const [update, setUpdate] = useState(null);
147
148
  const [wheel, setWheel] = useState(null);
148
149
  const [scrollTo, setScrollTo] = useState(null);
149
- const [editorMouse, setEditorMouse] = useState(null);
150
+ const [editorMouse, setEditorMouse] = useState([]);
150
151
  /** Wheel events waiting for the next frame, added up per pane. */
151
152
  const pendingWheel = useRef({ pane: "chat", lines: 0, columns: 0, timer: null });
152
153
  const [chatPrefill, setChatPrefill] = useState(null);
@@ -158,6 +159,7 @@ export function App({ initialModel, permContext }) {
158
159
  const paneWidthsRef = useRef(paneWidths);
159
160
  paneWidthsRef.current = paneWidths;
160
161
  /** What a mouse drag is moving: a pane border, a pane's scrollbar, or the editor's selection. */
162
+ const lastClick = useRef(null);
161
163
  const dragging = useRef(null);
162
164
  const pullAbort = useRef(null);
163
165
  const diagnosticsRef = useRef(null);
@@ -524,8 +526,9 @@ export function App({ initialModel, permContext }) {
524
526
  return;
525
527
  const { treeWidth, codeWidth, paneHeight } = layout.current;
526
528
  const codeLeft = treeWidth;
527
- const editorAt = (x, y, kind) => setEditorMouse((m) => ({ x: x - codeLeft - 1, y: y - 3, kind, nonce: (m?.nonce ?? 0) + 1 }));
529
+ const editorAt = (x, y, kind) => setEditorMouse((m) => [...m.slice(-15), { x: x - codeLeft - 1, y: y - 3, kind, seq: (m[m.length - 1]?.seq ?? 0) + 1 }]);
528
530
  if (event.kind === "drag") {
531
+ lastClick.current = null; // a press that became a drag doesn't start a double-click
529
532
  const drag = dragging.current;
530
533
  if (drag === "tree" || drag === "chat")
531
534
  moveBorder(drag, event.x);
@@ -536,8 +539,12 @@ export function App({ initialModel, permContext }) {
536
539
  return;
537
540
  }
538
541
  if (event.kind === "release") {
542
+ // The release says where the button let go, so a drag lands there even in a terminal that
543
+ // doesn't report the movement in between.
539
544
  if (dragging.current === "tree" || dragging.current === "chat")
540
- updateSettings({ paneWidths: paneWidthsRef.current });
545
+ updateSettings({ paneWidths: moveBorder(dragging.current, event.x) });
546
+ else if (dragging.current && typeof dragging.current === "object")
547
+ scrollPane(dragging.current.scroll, event.y);
541
548
  if (dragging.current === "editor")
542
549
  editorAt(event.x, event.y, "release");
543
550
  dragging.current = null;
@@ -566,13 +573,17 @@ export function App({ initialModel, permContext }) {
566
573
  // Rows start under the pane's top border (y=1) and title (y=2).
567
574
  if (pane === "tree" && event.y >= 3)
568
575
  setTreeClick((c) => ({ row: event.y - 3, nonce: (c?.nonce ?? 0) + 1 }));
576
+ // Two clicks in the same spot within half a second are a double-click.
577
+ const last = lastClick.current;
578
+ const double = !!last && Date.now() - last.time < DOUBLE_CLICK_MS && Math.abs(last.x - event.x) <= 1 && last.y === event.y;
579
+ lastClick.current = double ? null : { x: event.x, y: event.y, time: Date.now() };
569
580
  if (pane === "code" && event.y >= 3 && codePanel === "code") {
570
581
  if (showEditor) {
571
582
  dragging.current = "editor";
572
- editorAt(event.x, event.y, "press");
583
+ editorAt(event.x, event.y, double ? "double" : "press");
573
584
  }
574
- else if (openFile && !diff && settleDraft(openFile)) {
575
- // Click in the code: edit right there.
585
+ else if (double && openFile && !diff && settleDraft(openFile)) {
586
+ // Double-click in the code: edit right there.
576
587
  const gutter = String(content.split("\n").length).length + 1;
577
588
  setEditorStart((s) => ({ line: (viewport?.top ?? 1) + event.y - 3, col: Math.max(1, event.x - codeLeft - gutter), nonce: s.nonce + 1 }));
578
589
  setDiffMode("off");
@@ -1160,7 +1171,7 @@ ${snippet}
1160
1171
  ? [["Enter", "open"], ["e", "edit"], ["/", "command"], ["c", "changes"], ["Ctrl+P", "find"], ["u", "undo"], ["p", "problems"], ["< >", "resize"], ["m", "select text"], ["s", "settings"]]
1161
1172
  : diff
1162
1173
  ? [["Esc", "close diff"], ["n/N", "next/prev change"], ...(diffMode === "session" ? [["[ ]", "prev/next file"]] : []), ["v", "cycle diff"]]
1163
- : [["click/e", "edit"], ["v", "diff"], ["u", "undo"], ["/", "command"], ["p", "problems"], ["< >", "resize"], ["m", "select text"], ["s", "settings"]];
1174
+ : [["double-click/e", "edit"], ["v", "diff"], ["u", "undo"], ["/", "command"], ["p", "problems"], ["< >", "resize"], ["m", "select text"], ["s", "settings"]];
1164
1175
  const statusUsed = mode.label.length + 2 + autoMode.length + 2 + 10 + ` ✖ ${totals.errors} ▲ ${totals.warnings} `.length + (checking.length ? 4 + checking.join(", ").length : 0) + 2;
1165
1176
  const shownHints = [];
1166
1177
  for (let used = statusUsed, i = 0; i < hints.length; i++) {
@@ -111,7 +111,7 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
111
111
  const { icon, color } = fileIcon(filePath);
112
112
  const errorCount = problems.filter((d) => d.severity === "error").length;
113
113
  const warningCount = problems.filter((d) => d.severity === "warning").length;
114
- const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", filePath, errorCount > 0 && _jsxs(Text, { color: C.red, children: [" \u2716 ", errorCount] }), warningCount > 0 && _jsxs(Text, { color: C.yellow, children: [" \u25B2 ", warningCount] }), _jsxs(Text, { color: C.dim, children: [scroll, total > rows ? ` (${percent}%)` : "", focused ? " click or e to edit · v diff" : ""] })] }));
114
+ const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", filePath, errorCount > 0 && _jsxs(Text, { color: C.red, children: [" \u2716 ", errorCount] }), warningCount > 0 && _jsxs(Text, { color: C.yellow, children: [" \u25B2 ", warningCount] }), _jsxs(Text, { color: C.dim, children: [scroll, total > rows ? ` (${percent}%)` : "", focused ? " double-click or e to edit · v diff" : ""] })] }));
115
115
  return (_jsx(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: lines.slice(start, start + rows).map((line, i) => {
116
116
  const n = start + i + 1;
117
117
  const isMarked = marked.has(n);
@@ -309,10 +309,17 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
309
309
  view.current.top = topFor(scrollTo.fraction, buf.lines.length, rows);
310
310
  rerender((n) => n + 1);
311
311
  }, [scrollTo?.nonce]);
312
- // Click to put the cursor there; drag to select.
312
+ // Click to put the cursor there; double-click to select a word; drag to select.
313
+ const lastMouse = useRef(mouse?.[mouse.length - 1]?.seq ?? 0); // events from before the editor opened are done
313
314
  useEffect(() => {
314
- if (!mouse)
315
- return;
315
+ for (const event of mouse ?? []) {
316
+ if (event.seq <= lastMouse.current)
317
+ continue;
318
+ lastMouse.current = event.seq;
319
+ handleMouse(event);
320
+ }
321
+ }, [mouse]);
322
+ const handleMouse = (mouse) => {
316
323
  if (mouse.kind === "release") {
317
324
  dragFrom.current = null;
318
325
  return;
@@ -327,12 +334,21 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
327
334
  dragFrom.current = { row, col };
328
335
  return { ...b, row, col, anchor: null };
329
336
  }
337
+ if (mouse.kind === "double") {
338
+ // Double-click selects the word under the pointer.
339
+ const line = b.lines[row];
340
+ const at = isWord(line[col]) ? col : isWord(line[col - 1]) ? col - 1 : -1;
341
+ dragFrom.current = null;
342
+ if (at < 0)
343
+ return { ...b, row, col, anchor: null };
344
+ return { ...b, row, col: wordRight(line, at), anchor: { row, col: wordLeft(line, at + 1) } };
345
+ }
330
346
  return { ...b, row, col, anchor: dragFrom.current ?? b.anchor ?? { row: b.row, col: b.col } };
331
347
  });
332
348
  // Dragging past the top or bottom edge scrolls.
333
349
  if (mouse.kind === "drag" && (mouse.y < 0 || mouse.y >= rows))
334
350
  reveal.current = true;
335
- }, [mouse?.nonce]);
351
+ };
336
352
  usePaste((text) => {
337
353
  if (prompt)
338
354
  return setPrompt({ ...prompt, text: prompt.text + text.replace(/\s+/g, " ") });
@@ -1,8 +1,10 @@
1
1
  import { useEffect, useRef } from "react";
2
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";
3
+ // Report clicks and wheel (1000), and movement while a button is held (1002, without it terminals never
4
+ // send drags: no resizing panes, dragging scrollbars or selecting), using SGR coordinates (1006), which
5
+ // work past column 223.
6
+ export const ENABLE = "\x1b[?1000h\x1b[?1002h\x1b[?1006h";
7
+ const DISABLE = "\x1b[?1006l\x1b[?1002l\x1b[?1000l";
6
8
  /** Mouse events in a chunk of terminal input (SGR format: ESC [ < button ; x ; y M|m). */
7
9
  export function parseMouse(chunk) {
8
10
  const events = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nou-cli",
3
- "version": "0.5.0",
3
+ "version": "0.5.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": {