nou-cli 0.5.13 → 0.5.15

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
@@ -104,7 +104,7 @@ Every change is reviewed like a code review:
104
104
  | <kbd>u</kbd> | undo Nou's last edit |
105
105
  | <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 |
106
106
  | <kbd>c</kbd> | in the file tree: show only changed files |
107
- | <kbd>f</kbd> | follow mode (off by default): watch along as Nou works. Each file it reads (the exact lines, highlighted) is shown a step at a time, a little behind it, and its edits as they happen. The status bar shows how many steps behind it is; set the speed in settings → Preferences (slow · normal · fast · instant) |
107
+ | <kbd>f</kbd> or `/follow [on\|off]` | follow mode (off by default, and your choice is remembered): watch along as Nou works, with any engine (Codex's `sed`/`cat` reads count too). Each file it reads (the exact lines, highlighted) is shown a step at a time, a little behind it, and its edits as they happen. The status bar shows how many steps behind it is; set the speed in settings → Preferences (slow · normal · fast · instant) |
108
108
  | <kbd>&lt;</kbd> / <kbd>&gt;</kbd> / <kbd>=</kbd> | make the focused pane narrower / wider / reset the layout |
109
109
  | <kbd>`</kbd> | a terminal under the code, like VS Code's (also `/terminal`): Enter runs a command, Ctrl+C stops it, ↑↓ history, Esc back to the code, × or `exit` closes it; drag its top border (or <kbd>Alt</kbd>+↑/↓) to make it taller or shorter. Commands run through your shell one at a time, so full-screen programs like vim don't draw there |
110
110
  | <kbd>z</kbd> | in the code view: fold every function and block to its first line, or open them all again (also `/fold`, `/unfold`); click a ▾/▸ beside a line number to fold or open just that one |
@@ -113,7 +113,7 @@ Every change is reviewed like a code review:
113
113
  | <kbd>m</kbd> | mouse on/off, so you can select and copy text in the terminal (in the chat, just drag over the text: it's copied when you let go; double-click copies a word, path or link, triple-click a paragraph) |
114
114
  | <kbd>s</kbd> | settings: model, preferences, plugins |
115
115
  | <kbd>y</kbd> / <kbd>n</kbd> | accept / reject a proposed edit or command, from any pane; the rest of Nou keeps working while it waits (<kbd>o</kbd> shows the edit) |
116
- | <kbd>Enter</kbd> while Nou works | queue the message: it goes when Nou finishes, together with anything else you queued, as one message; commands that only change the screen, like `/terminal`, `/games` or `/open`, run right away. To send now instead of waiting: <kbd>Alt</kbd>+<kbd>Enter</kbd> (stops Nou and sends it), click the queued message, or `/now`; or turn on **Send while Nou works** in settings so <kbd>Enter</kbd> sends right away (<kbd>Alt</kbd>+<kbd>Enter</kbd> then queues) |
116
+ | <kbd>Enter</kbd> while Nou works | queue the message: it goes when Nou finishes, together with anything else you queued, as one message; commands that only change the screen, like `/terminal`, `/games` or `/open`, run right away. To send now instead of waiting: <kbd>Alt</kbd>+<kbd>Enter</kbd> (stops Nou and sends it), click the queued message, or `/now`. Nou answers it first, then finishes what it was working on, unless your message says to drop it; or turn on **Send while Nou works** in settings so <kbd>Enter</kbd> sends right away (<kbd>Alt</kbd>+<kbd>Enter</kbd> then queues) |
117
117
  | <kbd>Esc</kbd> | stop Nou (anything queued goes back in the input) · close a diff · back to chat |
118
118
  | <kbd>Ctrl</kbd>+<kbd>C</kbd> | stop Nou while it works · press twice to quit |
119
119
 
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from "fs";
2
2
  import path from "path";
3
3
  import { toRelative } from "../commands/index.js";
4
- import { ExternalEngine, replaceIn } from "./external.js";
4
+ import { ExternalEngine, readsInCommand, replaceIn } from "./external.js";
5
5
  import { loadPackage } from "./index.js";
6
6
  const EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]);
7
7
  /** Claude Code tools that change files; Nou previews and approves them. */
@@ -95,8 +95,16 @@ export class ClaudeCodeEngine extends ExternalEngine {
95
95
  ? { hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: `This edit introduced problems; fix them:\n${problems}` } }
96
96
  : {};
97
97
  }
98
- if (tool === "Read" && args.file_path)
99
- this.hooks.onRead(toRelative(args.file_path));
98
+ if (tool === "Read" && args.file_path) {
99
+ // A part of the file (offset and limit, in lines): follow mode shows those lines.
100
+ const from = typeof args.offset === "number" ? Math.max(1, args.offset) : undefined;
101
+ const to = from && typeof args.limit === "number" ? from + args.limit - 1 : undefined;
102
+ this.hooks.onRead(toRelative(args.file_path), from, to);
103
+ }
104
+ if ((tool === "Bash" || tool === "PowerShell") && typeof args.command === "string") {
105
+ for (const read of readsInCommand(args.command))
106
+ this.hooks.onRead(read.path, read.line, read.to);
107
+ }
100
108
  this.hooks.onEntry({ kind: "tool", text: tool === "Bash" || tool === "PowerShell" ? `Ran: ${args.command}` : describeTool(tool, args) });
101
109
  return {};
102
110
  };
@@ -3,7 +3,7 @@ import { realpathSync, statSync } from "fs";
3
3
  import path from "path";
4
4
  import { transientMessage } from "../agent/index.js";
5
5
  import { listAllFiles } from "../ui/QuickOpen.js";
6
- import { ExternalEngine } from "./external.js";
6
+ import { ExternalEngine, readsInCommand } from "./external.js";
7
7
  import { loadPackage } from "./index.js";
8
8
  const EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]);
9
9
  const MAX_FILES = 5000;
@@ -166,6 +166,9 @@ export class CodexEngine extends ExternalEngine {
166
166
  }
167
167
  else if (event.type === "item.started" && event.item.type === "command_execution") {
168
168
  this.hooks.onBusy(`Codex: ${event.item.command}`);
169
+ // Codex reads files with shell commands: follow mode shows them as it would a read.
170
+ for (const read of readsInCommand(event.item.command))
171
+ this.hooks.onRead(read.path, read.line, read.to);
169
172
  }
170
173
  else if (event.type === "item.completed") {
171
174
  const item = event.item;
@@ -1,4 +1,4 @@
1
- import { readFileSync } from "fs";
1
+ import { readFileSync, statSync } from "fs";
2
2
  import path from "path";
3
3
  import { newSessionId } from "../agent/index.js";
4
4
  import { isInProject, toRelative } from "../commands/index.js";
@@ -10,6 +10,53 @@ function readOrNull(filePath) {
10
10
  return null;
11
11
  }
12
12
  }
13
+ /**
14
+ * The files a shell command reads, so follow mode can show them: engines like Codex read with `sed -n
15
+ * '10,80p' f`, `cat f`, `nl -ba f`, `head -n 40 f` instead of a read tool. Only files that exist in the
16
+ * project count; anything it can't make sense of is no read.
17
+ */
18
+ export function readsInCommand(command) {
19
+ // Codex runs `bash -lc '<script>'`: look inside the quotes.
20
+ const wrapped = command.match(/^\S*(?:bash|sh|zsh)\s+-l?c\s+(['"])([\s\S]*)\1\s*$/);
21
+ const script = wrapped ? wrapped[2] : command;
22
+ const reads = [];
23
+ for (const part of script.split(/\s*(?:&&|\|\||;|\||\n)\s*/)) {
24
+ const words = (part.match(/'[^']*'|"[^"]*"|\S+/g) ?? []).map((w) => w.replace(/^(['"])(.*)\1$/, "$2"));
25
+ const [tool, ...args] = words;
26
+ if (!tool)
27
+ continue;
28
+ const files = args.filter((a) => !a.startsWith("-") && /[./]|\w\.\w/.test(a));
29
+ let range = {};
30
+ if (tool === "sed" && args.includes("-n")) {
31
+ const m = args.join(" ").match(/(\d+)\s*,\s*(\d+)\s*p/) ?? args.join(" ").match(/(\d+)p/);
32
+ if (!m)
33
+ continue;
34
+ range = { line: Number(m[1]), to: Number(m[2] ?? m[1]) };
35
+ files.splice(0, files.length, ...files.filter((f) => !/^\d+(,\d+)?p$/.test(f)));
36
+ // `nl -ba f | sed -n '5,9p'`: the lines are of the file read just before it in the pipe.
37
+ const last = reads.at(-1);
38
+ if (!files.length && last && last.line === undefined)
39
+ Object.assign(last, range);
40
+ }
41
+ else if (tool === "head") {
42
+ const n = Number(args.join(" ").match(/-n\s*(\d+)|-(\d+)/)?.slice(1).find(Boolean) ?? 10);
43
+ range = { line: 1, to: n };
44
+ }
45
+ else if (!["cat", "nl", "bat", "less", "more", "tail"].includes(tool))
46
+ continue;
47
+ for (const file of files) {
48
+ try {
49
+ if (!statSync(path.resolve(process.cwd(), file)).isFile() || !isInProject(file))
50
+ continue;
51
+ }
52
+ catch {
53
+ continue;
54
+ }
55
+ reads.push({ path: toRelative(file), ...range });
56
+ }
57
+ }
58
+ return reads;
59
+ }
13
60
  /** Replaces `oldText` with `newText`, as edit tools do; null if it isn't there (the tool will fail on its own). */
14
61
  export function replaceIn(text, oldText, newText, all = false) {
15
62
  if (!oldText || !text.includes(oldText))
@@ -35,7 +35,7 @@ import { FIND_KEY, isFind } from "./keys.js";
35
35
  import { FileTree } from "./FileTree.js";
36
36
  import { CodeView } from "./CodeView.js";
37
37
  import { Editor } from "./Editor.js";
38
- import { SLASH_COMMANDS, closestCommand, onlyChangesScreen, runsWhileBusy } from "./commands.js";
38
+ import { SLASH_COMMANDS, closestCommand, finishAfter, onlyChangesScreen, runsWhileBusy } from "./commands.js";
39
39
  import { WHEEL_STEP, sendWheel } from "./scroll.js";
40
40
  import { Chat, chatTabZones } from "./Chat.js";
41
41
  import { copyToClipboard, openInBrowser } from "./clipboard.js";
@@ -1457,6 +1457,17 @@ export function App({ initialModel, permContext, resume }) {
1457
1457
  if (!followTimer.current)
1458
1458
  playFollow();
1459
1459
  };
1460
+ /** Turns follow mode on or off (`to`), or flips it; remembered for next time, and the chat says what it does. */
1461
+ const toggleFollow = (to) => {
1462
+ const on = to ?? !followRef.current;
1463
+ followRef.current = on;
1464
+ setFollow(on);
1465
+ if (settingsRef.current.followMode !== on)
1466
+ updateSettings({ followMode: on });
1467
+ info(on
1468
+ ? "Follow on: the code view now shows each file Nou reads or edits as it works (f, the follow chip or /follow off to stop)."
1469
+ : "Follow off: the code view stays on what you have open. Edits waiting for your OK still show.");
1470
+ };
1460
1471
  const followStepRef = useRef(followStep);
1461
1472
  followStepRef.current = followStep;
1462
1473
  useEffect(() => {
@@ -1570,7 +1581,7 @@ export function App({ initialModel, permContext, resume }) {
1570
1581
  setChatPrefill((p) => ({ text: "/", nonce: (p?.nonce ?? 0) + 1 }));
1571
1582
  }
1572
1583
  else if (input === "f")
1573
- setFollow((f) => !f);
1584
+ toggleFollow();
1574
1585
  else if (input === "m")
1575
1586
  toggleMouse();
1576
1587
  else if (input === "`")
@@ -1752,6 +1763,12 @@ ${snippet}
1752
1763
  return info(`No file matches "${wanted}". To find where something is in the code, ask in the chat.`, "error");
1753
1764
  return openRef({ path: file, ...(m[2] ? { line: Number(m[2]) } : {}), ...(m[3] ? { to: Number(m[3]) } : {}) });
1754
1765
  }
1766
+ case "follow": {
1767
+ const want = arg.trim().toLowerCase();
1768
+ if (want && !/^(on|off)$/.test(want))
1769
+ return info("Use /follow on or /follow off (or just /follow to switch).", "error");
1770
+ return toggleFollow(want ? want === "on" : undefined);
1771
+ }
1755
1772
  case "effort": {
1756
1773
  // Speed vs smarts, by name: /effort smarter (or the effort itself: /effort xhigh).
1757
1774
  const options = EFFORTS.map((e) => EFFORT_LABELS[e].name.toLowerCase()).join(", ");
@@ -2017,6 +2034,17 @@ ${snippet}
2017
2034
  agentRef.current?.cancel();
2018
2035
  stopped.current = true;
2019
2036
  };
2037
+ /** What Nou was working on when a message cut in (see interruptToSend), so it finishes that too. */
2038
+ const unfinished = useRef(null);
2039
+ const lastAsked = useRef(null);
2040
+ /** Stops Nou so what's queued goes now; the request it was on isn't dropped, it's handed back with it. */
2041
+ const interruptToSend = () => {
2042
+ // What the agent was last asked, unfinished work handed back included: cutting in twice keeps both.
2043
+ if (lastAsked.current)
2044
+ unfinished.current = lastAsked.current;
2045
+ stop();
2046
+ stopped.current = false; // what's queued is meant to go
2047
+ };
2020
2048
  useEffect(() => {
2021
2049
  if (busy || approval || !queued.length)
2022
2050
  return;
@@ -2031,7 +2059,10 @@ ${snippet}
2031
2059
  const isCommand = (text) => text.startsWith("/");
2032
2060
  const take = isCommand(queued[0]) ? 1 : Math.max(1, queued.findIndex(isCommand) === -1 ? queued.length : queued.findIndex(isCommand));
2033
2061
  setQueued(queued.slice(take));
2034
- send(queued.slice(0, take).join("\n\n"));
2062
+ const text = queued.slice(0, take).join("\n\n");
2063
+ const previous = unfinished.current;
2064
+ unfinished.current = null;
2065
+ send(text, previous && !isCommand(text) ? finishAfter(text, previous) : undefined);
2035
2066
  }, [busy, approval, queued]);
2036
2067
  // When a reply points at places in the code (Nou's engine with show_places, the others with a ```nou-show
2037
2068
  // block, which is taken out of the reply), the first one opens highlighted and the rest are listed.
@@ -2186,8 +2217,7 @@ ${snippet}
2186
2217
  const sendNow = () => {
2187
2218
  if (!queued.length)
2188
2219
  return;
2189
- stop();
2190
- stopped.current = false; // what's queued is meant to go
2220
+ interruptToSend();
2191
2221
  };
2192
2222
  /**
2193
2223
  * From the chat: while Nou works, a message waits its turn, or (`now`: Alt+Enter) stops Nou and goes right
@@ -2198,14 +2228,14 @@ ${snippet}
2198
2228
  return send(text);
2199
2229
  const now = settings.sendNowWhileBusy ? !alt : alt;
2200
2230
  if (now) {
2201
- stop();
2202
- stopped.current = false; // this one is meant to go
2231
+ interruptToSend();
2203
2232
  setQueued((q) => [text, ...q]);
2204
2233
  }
2205
2234
  else
2206
2235
  setQueued((q) => [...q, text]);
2207
2236
  };
2208
- const send = (text) => {
2237
+ /** `forAgent`: what the agent gets instead of `text` (the chat still shows `text`). */
2238
+ const send = (text, forAgent) => {
2209
2239
  // Right after /resume lists them, a number picks a conversation instead of being sent to the agent.
2210
2240
  const picking = pickingResume.current;
2211
2241
  pickingResume.current = false;
@@ -2222,7 +2252,8 @@ ${snippet}
2222
2252
  return;
2223
2253
  }
2224
2254
  setEntries((prev) => [...prev, { kind: "user", text }]);
2225
- void agentRef.current.send(text, editorContext() ?? undefined);
2255
+ lastAsked.current = forAgent ?? text;
2256
+ void agentRef.current.send(forAgent ?? text, editorContext() ?? undefined);
2226
2257
  };
2227
2258
  function saveFromEditor(text, file = openFile) {
2228
2259
  if (!file)
@@ -2535,7 +2566,7 @@ ${snippet}
2535
2566
  { title: "Ask before every edit and command", keys: "Shift+Tab", run: () => cycleAutoMode("ask") },
2536
2567
  { title: "Auto-edit: apply edits without asking", keys: "Shift+Tab", run: () => cycleAutoMode("auto-edit") },
2537
2568
  { title: "Auto: edits and commands without asking", keys: "Shift+Tab", run: () => cycleAutoMode("auto") },
2538
- { title: `Follow mode ${follow ? "off" : "on"}: jump to files Nou works on`, keys: "f", run: () => setFollow((f) => !f) },
2569
+ { title: `Follow mode ${follow ? "off" : "on"}: jump to files Nou works on`, keys: "f", run: () => toggleFollow() },
2539
2570
  { title: `Replay speed for follow mode: ${settings.followSpeed} (next: ${FOLLOW_SPEEDS[(FOLLOW_SPEEDS.indexOf(settings.followSpeed) + 1) % FOLLOW_SPEEDS.length]})`, keys: "settings", run: () => updateSettings({ followSpeed: FOLLOW_SPEEDS[(FOLLOW_SPEEDS.indexOf(settings.followSpeed) + 1) % FOLLOW_SPEEDS.length] }) },
2540
2571
  { title: `Mouse ${settings.mouse ? "off (select text in the terminal)" : "on"}`, keys: "m", run: toggleMouse },
2541
2572
  { title: "Copy the last reply", keys: "/copy", run: () => void runCommand("/copy") },
@@ -2611,7 +2642,7 @@ ${snippet}
2611
2642
  x += width;
2612
2643
  };
2613
2644
  add(` ${autoMode} `.length, () => cycleAutoMode());
2614
- add(followChip.length, () => setFollow((f) => !f));
2645
+ add(followChip.length, () => toggleFollow());
2615
2646
  add(` ✗ ${totals.errors} ▲ ${totals.warnings} `.length, toggleProblems);
2616
2647
  if (checking.length)
2617
2648
  add(3 + checking.join(", ").length);
@@ -2,7 +2,7 @@
2
2
  * Commands that only change what's on screen (or handle a busy Nou themselves): they run right away
3
3
  * while Nou works instead of waiting in the queue behind it.
4
4
  */
5
- const INSTANT = new Set(["files", "actions", "palette", "zoom", "now", "fold", "unfold", "redraw", "new", "tab", "close", "terminal", "games", "open", "settings", "auto", "diff", "sessions", "history", "problems", "copy", "help", "version", "cancel"]);
5
+ const INSTANT = new Set(["follow", "files", "actions", "palette", "zoom", "now", "fold", "unfold", "redraw", "new", "tab", "close", "terminal", "games", "open", "settings", "auto", "diff", "sessions", "history", "problems", "copy", "help", "version", "cancel"]);
6
6
  /** Whether a typed command runs right away while Nou works (see INSTANT); `/showme` counts only to look or step. */
7
7
  /**
8
8
  * The command a mistyped name means: the same letters in another order ("/termnial"), or one letter off
@@ -51,6 +51,7 @@ export const SLASH_COMMANDS = [
51
51
  { name: "models", args: "[provider]", description: "list the models a provider offers" },
52
52
  { name: "connect", args: "<provider>", description: "enter an API key (and URL) for a provider" },
53
53
  { name: "open", args: "<file>[:line]", description: "open a file in the code view (part of its name is enough)" },
54
+ { name: "follow", args: "[on|off]", description: "the code view shows each file the agent reads or edits as it works" },
54
55
  { name: "effort", args: "[fastest|fast|balanced|smarter|smartest]", description: "speed vs smarts: how hard the model thinks (cheaper + faster, or smarter)" },
55
56
  { name: "workspace", args: "[new <folder> | n]", description: "several projects in this window, a tab each in the header: open one, or switch" },
56
57
  { name: "files", description: "find a file by part of its name (also Ctrl+P)" },
@@ -96,3 +97,11 @@ export function matchCommands(typed, extra = []) {
96
97
  const contains = query ? all.filter((c) => !c.name.startsWith(query) && (c.name.includes(query) || c.description.includes(query))) : [];
97
98
  return [...starts.sort((a, b) => Number(b.name === query) - Number(a.name === query)), ...contains];
98
99
  }
100
+ /**
101
+ * A message sent while Nou was working stops it so it's answered now, but what Nou was on isn't dropped:
102
+ * the agent is told what that was and to finish it after, unless the new message says otherwise.
103
+ */
104
+ export function finishAfter(text, previous) {
105
+ const was = previous.length > 6000 ? `${previous.slice(0, 6000)}…` : previous;
106
+ return `${text}\n\n[Sent while you were still working on my previous message, so you were interrupted. Handle this first, then carry on with and finish the previous request, picking up where you left off (don't redo finished steps), unless this message cancels or replaces it. The previous request was:\n${was}]`;
107
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nou-cli",
3
- "version": "0.5.13",
3
+ "version": "0.5.15",
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": {