nou-cli 0.5.4 → 0.5.10

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
@@ -50,9 +50,11 @@ Needs **Node 22.12+** and an interactive terminal (Windows Terminal, PowerShell,
50
50
  | 🔀 **Live diffs** | Every edit Nou proposes shows up side by side, and the files it is editing shimmer in the file tree while it works. <kbd>y</kbd> accepts, <kbd>n</kbd> rejects. Nothing touches disk without you. |
51
51
  | ✍️ **Built-in editor** | Press <kbd>e</kbd> to edit the open file yourself, with undo/redo and **auto-save**. |
52
52
  | 📍 **Shows you where things are** | Ask "where is the LiveKit integration?" and Nou finds it and opens it in the middle panel, jumped to the exact lines and highlighted. Any file a reply mentions (`src/a.ts:12`) is a link: click it. `/open name[:line]` opens a file yourself. |
53
+ | 🪟 **Workspaces** | Several projects in one window, a tab each in the header: click **+** (or `/workspace new ../api`) and the other folder opens beside this one with its own files, chats and agents. Click a tab (or `/workspace 2`) to switch; the ones in the background keep working, and a tab shows ● while its agent is busy. Quitting one (Ctrl+C twice) closes its tab. The first time, Nou installs a small pseudo-terminal package ([@lydell/node-pty](https://www.npmjs.com/package/@lydell/node-pty), prebuilt for macOS, Linux and Windows) and reopens as a workspaces window, with your conversation in the first tab. Or start that way: `nou --workspaces . ../api`. |
53
54
  | 🗂️ **Several agents at once** | `/new` (or **+** in the chat's title) opens another conversation tab while the first keeps working. Each tab has its own agent and history; a working tab shows ●, one waiting for your OK shows !, and a click (or `/tab 2`) switches. `/close` closes one; it stays in `/resume`. |
54
- | 🧑‍🏫 **Show me how** | `/showme add a --verbose flag` and Nou finds where the change goes, then walks you through typing it yourself: each step opens the file at the right line, says why, and shows the code to type dim ahead of your cursor, like autocomplete (<kbd>Tab</kbd> fills in a line). It moves on when what you typed matches, and saves as it goes. `/showme next` skips a step, `/showme stop` ends it. Works with every engine (Nou's, Claude Code, Codex and pi): any edit one makes anyway is put back. `/teach` does the same. |
55
- | 🎮 **Games for a break** | Add Pong, Snake and Breakout in settings → Games, then `/games` opens a panel docked under the chat: it splits that column instead of covering anything. Drag its title onto the files, code or chat to dock it there, and × at its top right closes it. Drag its top edge to make it taller or shorter. A game runs only while the panel has the keyboard: Esc goes back to your code and it waits. |
55
+ | 👀 **Show me** | Ask how something works or where it happens (`/showme where do we check the login?`, or just ask) and Nou explains it with the code open: each place it talks about opens in the code view, highlighted, and they're listed in the chat to click. `/showme next` and `/showme back` step through them. Works with every engine: Nou's points at places with a tool, and Claude Code, Codex and pi are asked to list them at the end of their reply, which Nou reads and hides. |
56
+ | 🧑‍🏫 **Teach me** | `/teach add a --verbose flag` and Nou finds where the change goes, then walks you through typing it yourself: each step opens the file at the right line, says why, and shows the code to type dim ahead of your cursor, like autocomplete (<kbd>Tab</kbd> fills in a line). It moves on when what you typed matches, and saves as it goes. `/teach next` skips a step, `/teach stop` ends it. Works with every engine (Nou's, Claude Code, Codex and pi): any edit one makes anyway is put back. |
57
+ | 🎮 **Games for a break** | Add Pong, Snake, Breakout (it gets faster each wall you clear), Dodge, Hop and Pairs in settings → Games, then `/games` opens a panel docked under the chat: it splits that column instead of covering anything. Drag its title onto the files, code or chat to dock it there, and × at its top right closes it. Drag its top edge to make it taller or shorter. A game runs only while the panel has the keyboard: Esc goes back to your code and it waits. Your best score in each is kept, and a panel too small for a game says how much room it needs. |
56
58
  | 🧠 **A capable engine** | Reads and searches in parallel, numbered reads for exact line references, edits that survive small whitespace mismatches, several edits to a file as one change, a plan for longer tasks, full command output (stderr too) with exit codes and timeouts, retries when a provider is overloaded or a connection drops (Codex too: it picks the turn back up instead of stopping at "Reconnecting..."), and conversation caching with Claude. It follows your repo's `AGENTS.md`, `CLAUDE.md` and `NOU.md` (and `~/.nou/NOU.md`). |
57
59
  | 🔎 **Finds its way around** | The agent searches your code (`grep`, `glob`) without asking, reads only what it needs, runs your tests to check its work, and summarizes long conversations to stay within the model's context (`/compact` does it on demand). |
58
60
  | ↶ **Undo anything** | Every change, Nou's or yours, is recorded. <kbd>u</kbd> undoes Nou's last edit, `/rewind` undoes its whole last turn, and your own later edits are kept. |
@@ -64,9 +66,12 @@ Needs **Node 22.12+** and an interactive terminal (Windows Terminal, PowerShell,
64
66
  | 👀 **Knows what you're looking at** | The open file, visible lines, your cursor and unsaved edits ride along with each message. "Fix this function" just works. |
65
67
  | 🌿 **Git aware** | Sees your branch, uncommitted changes and recent commits, and can diff on its own. Press <kbd>v</kbd> to diff any file against `HEAD`. |
66
68
  | 🔌 **Any model** | Local (Ollama, LM Studio, llama.cpp) or cloud (Claude, OpenAI, Gemini, OpenRouter, Groq, DeepSeek). Switch mid-conversation. |
69
+ | 🎚️ **Speed vs smarts** | Pick how hard the model thinks in plain words: **Fastest** and **Fast** (cheaper + faster), **Balanced** (the default), **Smarter** and **Smartest** (thinks longer, costs more). In <kbd>s</kbd> settings, or `/effort smarter`. |
67
70
  | 🛞 **Pick your agent** | Nou's own, or Claude Code, Codex or pi, switchable like models, with the same diffs, approvals and undo. |
68
71
  | 🧩 **Plugins** | Add your own tools and slash commands with a single file. |
69
72
  | 🔎 **Quick open** | <kbd>Ctrl</kbd>+<kbd>P</kbd> fuzzy-finds any file. |
73
+ | 🔋 **Easy on your battery** | Idle, Nou uses next to no CPU. When its window is in the background (in terminals that report focus: iTerm2, Terminal.app, kitty, Ghostty, WezTerm, Windows Terminal, VS Code), spinners and git polling pause until you come back, and TypeScript checks are incremental. |
74
+ | 👓 **Easy to read** | Hints and secondary text keep at least 4.5:1 contrast. Nou asks your terminal whether its background is light or dark and picks matching colors (code switches to GitHub Light on a light background); set it yourself in <kbd>s</kbd> settings → **Nou's colors**. |
70
75
 
71
76
  Every change is reviewed like a code review:
72
77
 
@@ -100,12 +105,14 @@ Every change is reviewed like a code review:
100
105
  | <kbd>c</kbd> | in the file tree: show only changed files |
101
106
  | <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) |
102
107
  | <kbd>&lt;</kbd> / <kbd>&gt;</kbd> / <kbd>=</kbd> | make the focused pane narrower / wider / reset the layout |
103
- | <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, × 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 |
108
+ | <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 |
109
+ | <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 |
110
+ | <kbd>Ctrl</kbd>+<kbd>L</kbd> | draw the whole screen again (also `/redraw`), if it ever looks shifted or clicks land a row off. Output from libraries goes to `~/.nou/nou.log` instead of the screen |
104
111
  | <kbd>Ctrl</kbd>+<kbd>O</kbd> | in the chat: open or fold every "▸ N steps" line (what the agent read and ran between replies folds into one; click one to open just it) |
105
- | <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) |
112
+ | <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) |
106
113
  | <kbd>s</kbd> | settings: model, preferences, plugins |
107
114
  | <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) |
108
- | <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 (<kbd>Alt</kbd>+<kbd>Enter</kbd>: stop Nou and send it now) |
115
+ | <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) |
109
116
  | <kbd>Esc</kbd> | stop Nou (anything queued goes back in the input) · close a diff · back to chat |
110
117
  | <kbd>Ctrl</kbd>+<kbd>C</kbd> | stop Nou while it works · press twice to quit |
111
118
 
@@ -123,11 +130,13 @@ In the editor:
123
130
  | <kbd>Ctrl</kbd>+<kbd>K</kbd> · <kbd>Ctrl</kbd>+<kbd>G</kbd> | hover info · go to definition (with a language server) |
124
131
  | <kbd>Esc</kbd> | clear the selection, or leave the editor |
125
132
 
126
- Chat commands: `/settings` · `/model [provider:model]` · `/models [provider]` · `/pull <model>` · `/engine [name]` · `/auto [ask|auto-edit|auto]` · `/copy [code|file]` · `/undo` · `/rewind` · `/history` · `/diff [file or n]` · `/sessions` · `/problems` · `/compact` · `/clear` · `/resume [n|last]` · `/terminal` · `/showme <what you want>` · `/help`
133
+ Chat commands: `/settings` · `/model [provider:model]` · `/models [provider]` · `/pull <model>` · `/engine [name]` · `/effort [fastest|fast|balanced|smarter|smartest]` · `/auto [ask|auto-edit|auto]` · `/copy [code|file]` · `/undo` · `/rewind` · `/history` · `/diff [file or n]` · `/sessions` · `/problems` · `/compact` · `/clear` · `/resume [n|last]` · `/terminal` · `/workspace [new <folder> | n]` · `/now` · `/showme <question>` · `/teach <change>` · `/help`
134
+
135
+ A small typo in a command still works when it only changes the screen (`/termnial` opens the terminal); for others Nou asks "Did you mean...?" instead of guessing.
127
136
 
128
137
  New lines in a message: <kbd>Shift</kbd>/<kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Enter</kbd> in terminals that report them (kitty, Ghostty, WezTerm, iTerm2 and others with the kitty keyboard protocol), or type `\` and press <kbd>Enter</kbd> in any terminal.
129
138
 
130
- Conversations are saved per folder after each reply. `/resume` lists them and `/resume 2` (or `nou --continue` for the last one, `nou --resume` for the list) picks one back up with the engine it was on: Nou's own engine restores its messages, Claude Code its session and Codex its thread; pi is handed the conversation so far. Sessions you started in Claude Code's or Codex's own CLI in this folder are listed too (marked "CLI"), and resuming one carries it on in that engine inside Nou.
139
+ Conversations are saved per folder after each reply. `/resume` opens a picker (↑↓ and Enter, type to filter, or type its number) and `/resume 2` (or `nou --continue` for the last one, `nou --resume` for the list) picks one back up with the engine it was on: Nou's own engine restores its messages, Claude Code its session and Codex its thread; pi is handed the conversation so far. Sessions you started in Claude Code's or Codex's own CLI in this folder are listed too (marked "CLI"), and resuming one carries it on in that engine inside Nou.
131
140
 
132
141
  ## 🧠 Models
133
142
 
@@ -170,6 +179,8 @@ The engine is the agent that answers in the chat. Nou's own works with every mod
170
179
  | `pi` | your Nou model, local or cloud | ✅ |
171
180
  | `codex` (Codex SDK) | OpenAI; your API key or ChatGPT login | ❌ edits in its sandbox; review and undo after |
172
181
 
182
+ Codex's sandbox can write to the project and the repo's `.git` (so it can commit, stash and pull) and reach the network.
183
+
173
184
  Other engines install on first use into `~/.nou/engines/`, so Nou itself stays small. Switching engines starts a new conversation. Compare them on the same tasks with `npm run eval -- --engine nou --engine claude-code`.
174
185
 
175
186
  ## 🔄 Updating
@@ -49,6 +49,7 @@ How to work:
49
49
  - Don't commit, push, install packages or delete files unless the user asked. Don't add comments the code didn't need.
50
50
  - Before saying something works, check it (run the tests or the code). If you couldn't, say so.
51
51
  - When the user asks where something is, or to show, open or find something, search for it (grep, glob), read enough to be sure, then call open_file on the exact place (with line and end_line) so it opens in their code view, and answer in a sentence or two with path:line.
52
+ - When you explain how something works and it happens in more than one place, call show_places with each part (file, line range, a short note) in the order to read them: Nou opens them highlighted, one after another. Then explain in that order.
52
53
  - If the user rejects an edit or command, don't retry it: ask what they would like instead.
53
54
  - Keep replies short. Refer to code as path:line.
54
55
 
package/dist/src/cli.js CHANGED
@@ -14,11 +14,45 @@ import { lsp } from "./lsp/index.js";
14
14
  import { App } from "./ui/App.js";
15
15
  import { installFastOutput } from "./ui/fast-output.js";
16
16
  import { RENDER_OPTIONS } from "./ui/render-options.js";
17
+ import { setScreenClear } from "./ui/redraw.js";
18
+ import { detectBackground } from "./ui/background.js";
19
+ import { setPalette } from "./ui/theme.js";
20
+ import { runHost, workspaceFolder } from "./workspaces/host.js";
21
+ import { pendingWorkspaces } from "./workspaces/client.js";
22
+ import { spawn } from "child_process";
23
+ import { appendFileSync, mkdirSync } from "fs";
24
+ import path from "path";
25
+ import { format } from "util";
26
+ import { NOU_HOME } from "./config/index.js";
27
+ /**
28
+ * Sends console output and stderr to `file` instead of the terminal, until the returned function puts them
29
+ * back. Nou draws the whole screen; a stray line from a library would scroll it out from under the mouse.
30
+ */
31
+ function quietConsole(file) {
32
+ const saved = { log: console.log, info: console.info, warn: console.warn, error: console.error, debug: console.debug, stderr: process.stderr.write };
33
+ const toFile = (text) => {
34
+ try {
35
+ mkdirSync(path.dirname(file), { recursive: true });
36
+ appendFileSync(file, text);
37
+ }
38
+ catch {
39
+ // nowhere to keep it: dropping it still beats scrambling the screen
40
+ }
41
+ };
42
+ for (const name of ["log", "info", "warn", "error", "debug"])
43
+ console[name] = (...args) => toFile(`${new Date().toISOString()} ${name}: ${format(...args)}\n`);
44
+ process.stderr.write = ((chunk) => (toFile(String(chunk)), true));
45
+ return () => {
46
+ Object.assign(console, { log: saved.log, info: saved.info, warn: saved.warn, error: saved.error, debug: saved.debug });
47
+ process.stderr.write = saved.stderr;
48
+ };
49
+ }
17
50
  const HELP = `nou ${currentVersion()}: an AI coding assistant for your terminal
18
51
 
19
52
  Usage:
20
53
  nou open Nou in the current folder
21
54
  nou --continue pick up the last conversation in this folder (-c)
55
+ nou --workspaces . ../api several projects in one window, a tab each
22
56
  nou --resume list earlier conversations here to pick one up (-r)
23
57
  nou update update to the latest version
24
58
  nou --version print the version
@@ -48,6 +82,24 @@ async function main() {
48
82
  console.log("If that was a permissions error, run: sudo npm install -g nou-cli@latest");
49
83
  process.exit(1);
50
84
  }
85
+ // nou --workspaces <folder>... : several Nous in this window, a tab each (see src/workspaces).
86
+ const wsAt = args.indexOf("--workspaces");
87
+ if (wsAt >= 0) {
88
+ if (!process.stdin.isTTY) {
89
+ console.error("Nou needs an interactive terminal.");
90
+ process.exit(1);
91
+ }
92
+ const asked = args.slice(wsAt + 1).filter((a) => !a.startsWith("-"));
93
+ const folders = asked.map((f) => workspaceFolder(f, process.cwd())).filter((f) => !!f);
94
+ const missing = asked.filter((f) => !workspaceFolder(f, process.cwd()));
95
+ if (missing.length)
96
+ console.error(`Not a folder, so not opened: ${missing.join(", ")}`);
97
+ const uiTheme = loadSettings().uiTheme;
98
+ // Every workspace uses the colors found for this terminal (they can't ask it themselves from the background).
99
+ process.env.NOU_PALETTE = uiTheme === "dark" || uiTheme === "light" ? uiTheme : (await detectBackground()) ?? "dark";
100
+ const firstArgs = args.includes("--continue") || args.includes("-c") ? ["--continue"] : [];
101
+ process.exit(await runHost(folders.length ? folders : [process.cwd()], firstArgs));
102
+ }
51
103
  if (!process.stdin.isTTY) {
52
104
  console.error("Nou needs an interactive terminal (try Windows Terminal, PowerShell, or Terminal/iTerm on macOS).");
53
105
  process.exit(1);
@@ -108,9 +160,26 @@ async function main() {
108
160
  if ((settings.engine ?? "nou") === "nou")
109
161
  await ensureProjectContext(choice);
110
162
  const permContext = await loadPermanentContext();
163
+ // Colors that are readable on this terminal: a dark or a light set, from the setting or the background.
164
+ const uiTheme = loadSettings().uiTheme;
165
+ const fromHost = process.env.NOU_PALETTE;
166
+ setPalette(uiTheme === "dark" || uiTheme === "light" ? uiTheme : fromHost === "light" || fromHost === "dark" ? fromHost : (await detectBackground()) ?? "dark");
111
167
  await installFastOutput();
168
+ // While Nou is on screen, anything a library prints (console.log, warnings, stderr) goes to a log file:
169
+ // written into the terminal, it would scroll the screen out from under the mouse.
170
+ const restoreOutput = quietConsole(path.join(NOU_HOME, "nou.log"));
112
171
  const app = render(createElement(App, { initialModel: choice, permContext, ...(args.includes("--continue") || args.includes("-c") ? { resume: "last" } : args.includes("--resume") || args.includes("-r") ? { resume: "list" } : {}) }), RENDER_OPTIONS);
172
+ setScreenClear(() => app.clear());
113
173
  await app.waitUntilExit();
174
+ restoreOutput();
175
+ // /workspace new: Nou closes and opens again as a workspaces window, this conversation in the first tab.
176
+ const folders = pendingWorkspaces();
177
+ if (folders) {
178
+ lsp.stopAll();
179
+ const child = spawn(process.execPath, [process.argv[1], "--workspaces", ...folders, "--continue"], { stdio: "inherit" });
180
+ child.on("close", (code) => process.exit(code ?? 0));
181
+ return;
182
+ }
114
183
  lsp.stopAll();
115
184
  p.outro(chalk.cyan("Bye bye!"));
116
185
  }
@@ -4,6 +4,23 @@ import path from "path";
4
4
  export const NOU_HOME = path.join(os.homedir(), ".nou");
5
5
  const CONFIG_PATH = path.join(NOU_HOME, "config.json");
6
6
  export const EFFORTS = ["low", "medium", "high", "xhigh", "max"];
7
+ /**
8
+ * What each effort means for you, since "xhigh" says little: a short name for the picker and what it trades.
9
+ * The saved value stays the effort itself (low...max), which is what the engines are given.
10
+ */
11
+ export const EFFORT_LABELS = {
12
+ low: { name: "Fastest", tradeoff: "Cheapest + fastest: quick answers, little thinking. Good for small edits and questions." },
13
+ medium: { name: "Fast", tradeoff: "Cheaper + faster: some thinking, for everyday changes." },
14
+ high: { name: "Balanced", tradeoff: "Balanced: thinks things through without taking long. The default." },
15
+ xhigh: { name: "Smarter", tradeoff: "Smarter: thinks longer, for trickier bugs and bigger changes. Slower, costs more." },
16
+ max: { name: "Smartest", tradeoff: "Smartest: thinks as long as it needs, for the hardest problems. Slowest, costs the most." },
17
+ };
18
+ /** An effort from its name or its label ("smarter", "xhigh"), any case. */
19
+ export function effortFrom(text) {
20
+ const t = text.trim().toLowerCase();
21
+ return EFFORTS.find((e) => e === t || EFFORT_LABELS[e].name.toLowerCase() === t) ?? null;
22
+ }
23
+ export const UI_THEMES = ["auto", "dark", "light"];
7
24
  export const AUTO_SAVE = ["delay", "focus", "off"];
8
25
  /** How fast follow mode replays what the agent does: a step every so often, or as it happens. */
9
26
  export const FOLLOW_SPEEDS = ["slow", "normal", "fast", "instant"];
@@ -18,8 +35,10 @@ export const DEFAULT_SETTINGS = {
18
35
  autoApproveEdits: false,
19
36
  autoApproveCommands: false,
20
37
  gitContext: true,
38
+ sendNowWhileBusy: false,
21
39
  autoSave: "delay",
22
40
  codeTheme: "one-dark",
41
+ uiTheme: "auto",
23
42
  mouse: true,
24
43
  reuseToolConfig: true,
25
44
  paneWidths: null,
@@ -28,6 +47,7 @@ export const DEFAULT_SETTINGS = {
28
47
  gameDock: "chat",
29
48
  terminalRows: null,
30
49
  gameRows: null,
50
+ gameBest: {},
31
51
  plugins: {},
32
52
  trustedPlugins: {},
33
53
  };
@@ -29,11 +29,27 @@ function jsonLines(file, limit = Infinity) {
29
29
  return out;
30
30
  }
31
31
  /** The start of a file, for the line that says which folder a Codex session ran in. */
32
- function head(file, bytes = 8192) {
32
+ /**
33
+ * The file's first line, however long (up to `max`): Codex puts its whole system prompt in a session's first
34
+ * line, tens of KB, and reading a fixed 8 KB cut it short, so no Codex session was ever found.
35
+ */
36
+ function firstLine(file, max = 4 * 1024 * 1024) {
33
37
  const fd = openSync(file, "r");
34
38
  try {
35
- const buf = Buffer.alloc(bytes);
36
- return buf.subarray(0, readSync(fd, buf, 0, bytes, 0)).toString("utf-8");
39
+ const chunks = [];
40
+ let read = 0;
41
+ const buf = Buffer.alloc(64 * 1024);
42
+ while (read < max) {
43
+ const n = readSync(fd, buf, 0, buf.length, read);
44
+ if (n <= 0)
45
+ break;
46
+ const end = buf.subarray(0, n).indexOf(10); // \n
47
+ chunks.push(Buffer.from(buf.subarray(0, end >= 0 ? end : n)));
48
+ read += n;
49
+ if (end >= 0)
50
+ break;
51
+ }
52
+ return Buffer.concat(chunks).toString("utf-8");
37
53
  }
38
54
  finally {
39
55
  closeSync(fd);
@@ -90,28 +106,34 @@ function codexSessions(home, cwd) {
90
106
  if (!existsSync(root))
91
107
  return [];
92
108
  const files = [];
109
+ // By date: sessions/YYYY/MM/DD/rollout-*.jsonl. Anything else in there (a .DS_Store, a folder that can't
110
+ // be read) is skipped, rather than hiding every session.
93
111
  const walk = (dir, depth) => {
94
- for (const name of readdirSync(dir).sort().reverse()) {
112
+ let names;
113
+ try {
114
+ names = readdirSync(dir).sort().reverse();
115
+ }
116
+ catch {
117
+ return;
118
+ }
119
+ for (const name of names) {
95
120
  if (files.length >= SCAN)
96
121
  return;
97
122
  const full = path.join(dir, name);
98
- if (depth < 3)
99
- walk(full, depth + 1);
123
+ if (depth < 3) {
124
+ if (!name.startsWith("."))
125
+ walk(full, depth + 1);
126
+ }
100
127
  else if (name.startsWith("rollout-") && name.endsWith(".jsonl"))
101
128
  files.push(full);
102
129
  }
103
130
  };
104
- try {
105
- walk(root, 0);
106
- }
107
- catch {
108
- return [];
109
- }
131
+ walk(root, 0);
110
132
  const out = [];
111
133
  for (const file of files) {
112
134
  let meta;
113
135
  try {
114
- meta = JSON.parse(head(file).split("\n")[0]);
136
+ meta = JSON.parse(firstLine(file));
115
137
  }
116
138
  catch {
117
139
  continue;
@@ -1,5 +1,7 @@
1
- import { existsSync } from "fs";
1
+ import { createHash } from "crypto";
2
+ import { existsSync, mkdirSync } from "fs";
2
3
  import path from "path";
4
+ import { NOU_HOME } from "../config/index.js";
3
5
  import { onPath, projectBin, run } from "./process.js";
4
6
  const rel = (file) => path.relative(process.cwd(), path.resolve(process.cwd(), file)).replace(/\\/g, "/");
5
7
  /** ESLint, using the project's own install and config, so results match `npm run lint`. */
@@ -38,6 +40,18 @@ export const eslintProvider = {
38
40
  },
39
41
  };
40
42
  /** The TypeScript compiler over the whole project (type errors need the full picture). */
43
+ /**
44
+ * tsc runs after every edit, and a full check of a mid-sized project is many CPU-seconds (on a laptop,
45
+ * battery). Incremental checks reuse what didn't change. The build info lives in ~/.nou/cache, one file per
46
+ * project, so the project's own files (and any tsBuildInfoFile it sets) are left alone.
47
+ */
48
+ let incremental = true;
49
+ function buildInfoFile() {
50
+ const id = createHash("sha1").update(process.cwd()).digest("hex").slice(0, 16);
51
+ const dir = path.join(NOU_HOME, "cache", "tsc");
52
+ mkdirSync(dir, { recursive: true });
53
+ return path.join(dir, `${id}.tsbuildinfo`);
54
+ }
41
55
  export const typescriptProvider = {
42
56
  id: "typescript",
43
57
  name: "TypeScript",
@@ -49,7 +63,14 @@ export const typescriptProvider = {
49
63
  const bin = projectBin("typescript", "tsc");
50
64
  if (!bin)
51
65
  return [];
52
- const result = await run(process.execPath, [bin, "--noEmit", "-p", ".", "--pretty", "false"], { timeout: 180_000 });
66
+ const args = [bin, "--noEmit", "-p", ".", "--pretty", "false"];
67
+ let result = await run(process.execPath, incremental ? [...args, "--incremental", "--tsBuildInfoFile", buildInfoFile()] : args, { timeout: 180_000 });
68
+ // An option this project's tsconfig rules out (a TS5xxx/TS6xxx error that names no file): check it the
69
+ // plain way from now on.
70
+ if (incremental && /^error TS[56]\d{3}:/m.test(result.stdout)) {
71
+ incremental = false;
72
+ result = await run(process.execPath, args, { timeout: 180_000 });
73
+ }
53
74
  const diagnostics = [];
54
75
  for (const line of result.stdout.split(/\r?\n/)) {
55
76
  const m = line.match(/^(.+?)\((\d+),(\d+)\): (error|warning) (TS\d+): (.*)$/);
@@ -1,5 +1,6 @@
1
1
  import { execFileSync } from "child_process";
2
- import { statSync } from "fs";
2
+ import { realpathSync, statSync } from "fs";
3
+ import path from "path";
3
4
  import { transientMessage } from "../agent/index.js";
4
5
  import { listAllFiles } from "../ui/QuickOpen.js";
5
6
  import { ExternalEngine } from "./external.js";
@@ -9,6 +10,31 @@ const MAX_FILES = 5000;
9
10
  const MAX_BYTES = 512 * 1024;
10
11
  /** Times a turn that failed for a passing reason (a dropped stream, a failing server) is picked up again. */
11
12
  const RESUMES = 5;
13
+ /**
14
+ * The repo's git folders. Codex's "workspace-write" sandbox keeps `.git` read-only inside the folder it may
15
+ * write to, so `git commit`, `git stash` and `git pull` fail ("cannot write to .git"). Handing it the git folder
16
+ * as a folder of its own makes it writable (the read-only rule is for a `.git` inside a writable folder).
17
+ * Worktrees keep their state in the main repo's git folder, so that one goes too.
18
+ */
19
+ export function gitDirs(cwd = process.cwd()) {
20
+ try {
21
+ const out = execFileSync("git", ["rev-parse", "--absolute-git-dir", "--git-common-dir"], { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
22
+ // Real paths, so one folder reached two ways (macOS's /var is /private/var, Windows' short RUNNER~1 names)
23
+ // is listed once.
24
+ const real = (d) => { try {
25
+ return realpathSync.native(d);
26
+ }
27
+ catch {
28
+ return d;
29
+ } };
30
+ const dirs = out.split("\n").map((d) => d.trim()).filter(Boolean).map((d) => real(path.resolve(cwd, d)));
31
+ const key = (d) => (process.platform === "win32" ? d.toLowerCase() : d);
32
+ return dirs.filter((d, i) => dirs.findIndex((e) => key(e) === key(d)) === i);
33
+ }
34
+ catch {
35
+ return []; // not a git repo, or git isn't installed
36
+ }
37
+ }
12
38
  /** Project files small enough to keep a copy of (git's list when there is one, so ignored files are skipped). */
13
39
  function projectFiles() {
14
40
  let files;
@@ -79,6 +105,7 @@ export class CodexEngine extends ExternalEngine {
79
105
  // resolve registry.npmjs.org"); Nou's other engines can reach it, so Codex can too.
80
106
  networkAccessEnabled: true,
81
107
  approvalPolicy: "never",
108
+ additionalDirectories: gitDirs(),
82
109
  ...(this.choice.provider.id === "openai" ? { model: this.choice.model } : {}),
83
110
  ...(this.effort && EFFORTS.has(this.effort) ? { modelReasoningEffort: this.effort } : {}),
84
111
  };