nou-cli 0.5.14 → 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 |
@@ -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))
@@ -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(", ");
@@ -2549,7 +2566,7 @@ ${snippet}
2549
2566
  { title: "Ask before every edit and command", keys: "Shift+Tab", run: () => cycleAutoMode("ask") },
2550
2567
  { title: "Auto-edit: apply edits without asking", keys: "Shift+Tab", run: () => cycleAutoMode("auto-edit") },
2551
2568
  { title: "Auto: edits and commands without asking", keys: "Shift+Tab", run: () => cycleAutoMode("auto") },
2552
- { 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() },
2553
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] }) },
2554
2571
  { title: `Mouse ${settings.mouse ? "off (select text in the terminal)" : "on"}`, keys: "m", run: toggleMouse },
2555
2572
  { title: "Copy the last reply", keys: "/copy", run: () => void runCommand("/copy") },
@@ -2625,7 +2642,7 @@ ${snippet}
2625
2642
  x += width;
2626
2643
  };
2627
2644
  add(` ${autoMode} `.length, () => cycleAutoMode());
2628
- add(followChip.length, () => setFollow((f) => !f));
2645
+ add(followChip.length, () => toggleFollow());
2629
2646
  add(` ✗ ${totals.errors} ▲ ${totals.warnings} `.length, toggleProblems);
2630
2647
  if (checking.length)
2631
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)" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nou-cli",
3
- "version": "0.5.14",
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": {