@happy-nut/monacori 0.1.10 → 0.1.12

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
@@ -52,25 +52,6 @@ On first run, `mo` creates `.monacori/`, adds it to `.gitignore`, and includes u
52
52
  - **Source navigation**: jump between changed files, search indexed files, preview source, and move through hunks from the keyboard.
53
53
  - **Plain local artifacts**: generated review files and state are Markdown, JSON, and static HTML under `.monacori/`.
54
54
 
55
- ## Commands
56
-
57
- | Command | What it does |
58
- | --- | --- |
59
- | `mo` | Open the desktop diff-review app for the current repository. Alias for `monacori open`. |
60
- | `monacori open` | Launch the review app, auto-initialize `.monacori/`, and include untracked files by default. |
61
- | `monacori app` | Launch the same desktop app explicitly. |
62
- | `monacori init` | Initialize `.monacori/` in the current directory. |
63
- | `monacori install` | Initialize and write agent instruction snippets. Use `--apply-agent-docs` to patch `AGENTS.md` or `CLAUDE.md`. |
64
-
65
- Useful review options:
66
-
67
- ```bash
68
- mo --staged # review only staged changes
69
- mo --tracked-only # exclude untracked files
70
- mo --base main # compare against a specific base
71
- mo --context 20 # show more context around each hunk
72
- ```
73
-
74
55
  ## Development
75
56
 
76
57
  Working on monacori itself? The globally-installed `mo` runs the **published** package, not your
@@ -82,10 +63,10 @@ Run your checkout directly (builds, then launches in the foreground with DevTool
82
63
  npm run dev
83
64
  ```
84
65
 
85
- This reviews the monacori repo itself. To point a local build at another project:
66
+ This reviews the monacori repo itself. To review **another repo** with your local build, pass `--cwd`:
86
67
 
87
68
  ```bash
88
- MONACORI_DEV=1 node /path/to/monacori/bin/monacori.js --foreground
69
+ npm run dev -- --cwd /path/to/other-repo
89
70
  ```
90
71
 
91
72
  **Which build is running?** A dev build titles its window `monacori (dev)` and opens DevTools, and
@@ -120,7 +101,7 @@ suite gates every release.
120
101
 
121
102
  ## Local State
122
103
 
123
- `monacori init` creates a git-ignored `.monacori/` directory for generated diff reviews, local config, comments, logs, and validation notes. Keep it ignored unless your team intentionally wants to version review artifacts.
104
+ Running `mo` creates a git-ignored `.monacori/` directory for generated diff reviews, local config, comments, logs, and validation notes. Keep it ignored unless your team intentionally wants to version review artifacts.
124
105
 
125
106
  ## Design Principles
126
107
 
package/dist/app-main.js CHANGED
@@ -1,10 +1,12 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import { spawnSync } from "node:child_process";
2
+ import { spawnSync, spawn } from "node:child_process";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { app, BrowserWindow, ipcMain, Menu, nativeImage } from "electron";
6
6
  import { buildDiffReview, performHttpRequest } from "./cli.js";
7
7
  import { sanitizeTerminalEnv } from "./util.js";
8
+ import { readUnifiedDiff } from "./diff.js";
9
+ import { createHash } from "node:crypto";
8
10
  import { spawn as spawnPty } from "node-pty";
9
11
  // `npm run dev` sets MONACORI_DEV=1 so a locally-built app announces itself — a window-title suffix
10
12
  // plus a boot log with its on-disk path — making it obvious whether `mo` launched THIS checkout or
@@ -15,15 +17,32 @@ const FLOW_DIR = ".monacori";
15
17
  const REVIEW_FILE = "app-review.html";
16
18
  const WATCH_INTERVAL_MS = 1000;
17
19
  // Painted immediately while the first review build + HTML render run, so startup shows a spinner instead
18
- // of a blank window. Inlined as a data: URL so it needs no file on disk and appears before any review work.
19
- const LOADING_HTML = `<!doctype html><html><head><meta charset="utf-8"><style>
20
- html,body{margin:0;height:100vh;background:#2b2b2b;color:#9aa4af;display:flex;flex-direction:column;
20
+ // of a blank window. Inlined as a data: URL so it needs no file on disk and appears before any review
21
+ // work. Theme-aware so a light-theme user doesn't get a dark flash before the renderer applies the theme.
22
+ function loadingHtml(light) {
23
+ const bg = light ? "#ffffff" : "#2b2b2b";
24
+ const fg = light ? "#6e7781" : "#9aa4af";
25
+ const ring = light ? "#d0d7de" : "#3a3a3a";
26
+ const accent = light ? "#0969da" : "#4a9eff";
27
+ return `<!doctype html><html><head><meta charset="utf-8"><style>
28
+ html,body{margin:0;height:100vh;background:${bg};color:${fg};display:flex;flex-direction:column;
21
29
  align-items:center;justify-content:center;gap:18px;
22
30
  font:13px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
23
- .s{width:34px;height:34px;border:3px solid #3a3a3a;border-top-color:#4a9eff;border-radius:50%;
31
+ .s{width:34px;height:34px;border:3px solid ${ring};border-top-color:${accent};border-radius:50%;
24
32
  animation:spin .8s linear infinite}
25
33
  @keyframes spin{to{transform:rotate(360deg)}}
26
34
  </style></head><body><div class="s"></div><div>monacori</div></body></html>`;
35
+ }
36
+ // The persisted theme (set by the renderer via monacoriSettings). Read at startup so the native window
37
+ // chrome + loading screen match before the renderer boots. Defaults to dark.
38
+ function isLightTheme() {
39
+ try {
40
+ return readSettings()["monacori-theme"] === "light";
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ }
27
46
  app.setName("monacori");
28
47
  ipcMain.handle("monacori:http-send", (_event, request) => performHttpRequest(request));
29
48
  // Phase 2 lazy-LOAD: serve a single file's diff body to the renderer on demand. Retained from the
@@ -47,8 +66,27 @@ ipcMain.handle("monacori:self-update", () => {
47
66
  timeout: 5 * 60 * 1000,
48
67
  });
49
68
  if ((result.status ?? 1) === 0) {
50
- // Let the renderer paint "Restarting…" before we relaunch with the new code.
51
- setTimeout(() => { app.relaunch(); app.exit(0); }, 500);
69
+ // Let the renderer paint "Restarting…", then start the freshly-installed CLI as a NEW detached
70
+ // process and exit. app.relaunch() re-runs THIS process, but the global install just replaced our
71
+ // on-disk dist (and possibly the bundled Electron), so the current process is stale — relaunching
72
+ // it can boot the old code or fail outright. Spawning `mo` loads the new code; a sanitized env
73
+ // keeps this update run's npm_* vars out of the fresh process. Falls back to relaunch if spawn fails.
74
+ setTimeout(() => {
75
+ try {
76
+ const child = spawn("mo", [], {
77
+ cwd: options.root,
78
+ detached: true,
79
+ stdio: "ignore",
80
+ env: sanitizeTerminalEnv(process.env),
81
+ shell: true,
82
+ });
83
+ child.unref();
84
+ }
85
+ catch {
86
+ app.relaunch();
87
+ }
88
+ app.exit(0);
89
+ }, 400);
52
90
  return { ok: true };
53
91
  }
54
92
  const detail = (result.stderr || result.stdout || (result.error && result.error.message) || "npm install failed").trim();
@@ -211,6 +249,7 @@ app.whenReady().then(async () => {
211
249
  if (process.platform === "darwin" && app.dock && !appIcon.isEmpty()) {
212
250
  app.dock.setIcon(appIcon);
213
251
  }
252
+ const themeLight = isLightTheme();
214
253
  mainWindow = new BrowserWindow({
215
254
  width: 1440,
216
255
  height: 960,
@@ -219,7 +258,7 @@ app.whenReady().then(async () => {
219
258
  show: false,
220
259
  title: APP_TITLE,
221
260
  icon: iconPath,
222
- backgroundColor: "#2b2b2b",
261
+ backgroundColor: themeLight ? "#ffffff" : "#2b2b2b",
223
262
  autoHideMenuBar: true,
224
263
  webPreferences: {
225
264
  preload: preloadPath,
@@ -238,7 +277,7 @@ app.whenReady().then(async () => {
238
277
  // Paint the window with a spinner immediately, then build the (potentially heavy) review off the first
239
278
  // paint and swap it in. The first build used to run synchronously *before* the window existed, so the
240
279
  // screen stayed blank for the first few seconds of startup; now the user sees a loading screen instead.
241
- await mainWindow.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(LOADING_HTML));
280
+ await mainWindow.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(loadingHtml(themeLight)));
242
281
  // Give the loading spinner a few frames to actually paint before the (synchronous) first build blocks
243
282
  // the main process — otherwise the spinner looks frozen until the build finishes. The boot overlay in
244
283
  // the review HTML then takes over, so there's no blank gap when loadFile swaps the page in.
@@ -272,11 +311,27 @@ app.on("window-all-closed", () => {
272
311
  terms.clear();
273
312
  app.quit();
274
313
  });
314
+ let lastDiffSig = "";
275
315
  async function refreshIfChanged() {
276
316
  if (refreshing || !mainWindow || mainWindow.isDestroyed())
277
317
  return;
278
318
  refreshing = true;
279
319
  try {
320
+ // Fast path: hash only the git diff (~120ms) before the full build (~1s). The vast majority of
321
+ // watch ticks see no change, so skip the heavy buildDiffReview entirely then — keeping the main
322
+ // process free for IPC/pty so the UI never stalls on an unchanged tree.
323
+ const diffSig = createHash("sha1")
324
+ .update(readUnifiedDiff({
325
+ base: options.base,
326
+ staged: options.staged,
327
+ context: options.context,
328
+ includeUntracked: options.includeUntracked,
329
+ ignoreWhitespace: options.ignoreWhitespace,
330
+ }))
331
+ .digest("hex");
332
+ if (diffSig === lastDiffSig)
333
+ return;
334
+ lastDiffSig = diffSig;
280
335
  const next = writeReviewFile(options);
281
336
  if (next.signature !== currentSignature) {
282
337
  currentSignature = next.signature;
@@ -320,8 +375,9 @@ function parseArgs(args) {
320
375
  const contextValue = readOption(args, "--context");
321
376
  return {
322
377
  root: resolve(root),
323
- base: readOption(args, "--base"),
324
- staged: args.includes("--staged"),
378
+ // staged review and custom --base were removed from the CLI; always diff the working tree against
379
+ // HEAD (base omitted → defaults to HEAD downstream).
380
+ staged: false,
325
381
  includeUntracked: args.includes("--include-untracked"),
326
382
  context: contextValue ? parsePositiveInteger(contextValue, "--context") : 12,
327
383
  watch: !args.includes("--no-watch"),
package/dist/commands.js CHANGED
@@ -1,46 +1,23 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import { basename, dirname, join } from "node:path";
2
+ import { basename, dirname, join, resolve } from "node:path";
3
3
  import { spawn, spawnSync } from "node:child_process";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { createRequire } from "node:module";
6
- import { AGENT_SNIPPET_FILE, CONFIG_FILE, DECISIONS_FILE, FLOW_DIR, GITIGNORE_FILE, STATE_FILE } from "./constants.js";
7
- import { parsePositiveInteger, readOption } from "./util.js";
6
+ import { CONFIG_FILE, DECISIONS_FILE, FLOW_DIR, GITIGNORE_FILE, STATE_FILE } from "./constants.js";
7
+ import { readOption } from "./util.js";
8
8
  import { git } from "./git.js";
9
9
  const nodeRequire = createRequire(import.meta.url);
10
+ // monacori is a single command: open the desktop review app for the current repository. `mo` and
11
+ // `monacori` (with or without flags) all do the same thing. `--cwd <path>` reviews another repo (used by
12
+ // `npm run dev -- --cwd <path>`); `--no-watch` / `--foreground` are dev/internal knobs. `--help` prints help.
10
13
  export function main() {
11
14
  const rawArgs = process.argv.slice(2);
12
- const [command, ...args] = rawArgs;
13
15
  try {
14
- if (!command) {
15
- openCurrentRepository([]);
16
+ if (rawArgs.includes("--help") || rawArgs.includes("-h")) {
17
+ printHelp();
16
18
  return;
17
19
  }
18
- if (command !== "--help" && command !== "-h" && command.startsWith("-")) {
19
- openCurrentRepository(rawArgs);
20
- return;
21
- }
22
- switch (command) {
23
- case "init":
24
- initFlow(args);
25
- break;
26
- case "install":
27
- installFlow(args);
28
- break;
29
- case "app":
30
- case "review":
31
- launchReviewApp(args);
32
- break;
33
- case "open":
34
- openCurrentRepository(args);
35
- break;
36
- case "--help":
37
- case "-h":
38
- case "help":
39
- printHelp();
40
- break;
41
- default:
42
- throw new Error(`Unknown command: ${command}`);
43
- }
20
+ launchReviewApp(rawArgs);
44
21
  }
45
22
  catch (error) {
46
23
  const message = error instanceof Error ? error.message : String(error);
@@ -48,108 +25,42 @@ export function main() {
48
25
  process.exit(1);
49
26
  }
50
27
  }
51
- function initFlow(args) {
52
- const force = args.includes("--force");
53
- const quiet = args.includes("--quiet");
54
- const root = process.cwd();
55
- const flowPath = join(root, FLOW_DIR);
56
- mkdirSync(flowPath, { recursive: true });
57
- mkdirSync(join(flowPath, "reports"), { recursive: true });
58
- mkdirSync(join(flowPath, "logs"), { recursive: true });
59
- mkdirSync(join(flowPath, "diffs"), { recursive: true });
60
- const config = {
61
- version: 1,
62
- projectName: basename(root),
63
- verification: {
64
- commands: detectVerificationCommands(root),
65
- },
66
- diff: {
67
- context: 12,
68
- includeUntracked: false,
69
- },
70
- };
71
- writeIfMissing(join(flowPath, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`, force);
72
- writeIfMissing(join(flowPath, STATE_FILE), initialState(config), force);
73
- writeIfMissing(join(flowPath, DECISIONS_FILE), initialDecisions(), force);
74
- const ignored = ensureMonacoriGitignore(root);
75
- if (!quiet) {
76
- console.log(`Initialized ${FLOW_DIR}/ in ${root}`);
77
- if (ignored) {
78
- console.log(`Updated ${GITIGNORE_FILE} to ignore ${FLOW_DIR}/ validation artifacts.`);
79
- }
80
- console.log("Next: run `mo` to open the diff review app.");
81
- }
82
- }
83
- function installFlow(args) {
84
- const force = args.includes("--force");
85
- const applyAgentDocs = args.includes("--apply-agent-docs");
86
- initFlow(["--quiet"]);
87
- writeIfMissing(join(process.cwd(), FLOW_DIR, AGENT_SNIPPET_FILE), agentSnippet(), force);
88
- if (applyAgentDocs) {
89
- applyAgentDocSnippet("AGENTS.md");
90
- applyAgentDocSnippet("CLAUDE.md");
91
- }
92
- console.log("Installed monacori validation instructions.");
93
- console.log(`- ${FLOW_DIR}/${AGENT_SNIPPET_FILE}`);
94
- if (applyAgentDocs) {
95
- console.log("- Updated AGENTS.md / CLAUDE.md validation snippets where available.");
96
- }
97
- else {
98
- console.log(`Next: add ${FLOW_DIR}/${AGENT_SNIPPET_FILE} to your agent instructions if desired.`);
99
- }
100
- }
28
+ // "Show everything" diff context: large enough that `git diff -U<n>` emits entire files as context, so the
29
+ // diff never folds away unchanged regions. Reviewers skip with F7/Shift+F7 instead of relying on gaps.
30
+ const FULL_DIFF_CONTEXT = 100000;
101
31
  function launchReviewApp(args) {
102
- if (args.includes("--help") || args.includes("-h")) {
103
- printAppHelp();
104
- return;
32
+ // Review the directory given by --cwd (defaults to the current one), so `npm run dev -- --cwd <path>` can
33
+ // open ANY repo from anywhere. chdir up front so the flow state and the launched app resolve to one repo.
34
+ const targetCwd = resolve(readOption(args, "--cwd") ?? process.cwd());
35
+ if (!existsSync(targetCwd)) {
36
+ throw new Error(`Directory does not exist: ${targetCwd}`);
105
37
  }
38
+ process.chdir(targetCwd);
106
39
  ensureWritableFlowState();
107
- const config = loadConfig();
108
- const contextValue = readOption(args, "--context");
109
- const context = contextValue ? parsePositiveInteger(contextValue, "--context") : config.diff.context;
110
40
  const appArgs = [
111
41
  appMainPath(),
112
42
  "--cwd",
113
43
  process.cwd(),
114
44
  "--context",
115
- String(context),
45
+ String(FULL_DIFF_CONTEXT),
46
+ "--include-untracked", // new AI-created files are visible by default
116
47
  ];
117
- const base = readOption(args, "--base");
118
- if (base)
119
- appArgs.push("--base", base);
120
- if (args.includes("--staged"))
121
- appArgs.push("--staged");
122
- if (args.includes("--include-untracked") || config.diff.includeUntracked)
123
- appArgs.push("--include-untracked");
124
48
  if (args.includes("--no-watch"))
125
49
  appArgs.push("--no-watch");
126
50
  const electronBinary = resolveElectronBinary();
127
- // Tell the user which build is about to run. The app-main path disambiguates a local checkout from
128
- // the installed package (their versions can match); printed on the shell that ran `mo`, even when
129
- // the app itself is spawned detached.
130
- console.error(`monacori: launching ${appMainPath()}`);
51
+ // In dev only (`npm run dev` sets MONACORI_DEV=1) announce which build is launching, so a local checkout
52
+ // is distinguishable from the installed package. Normal `mo` runs stay silent.
53
+ if (process.env.MONACORI_DEV === "1") {
54
+ console.error(`monacori: launching ${appMainPath()}`);
55
+ }
131
56
  if (args.includes("--foreground")) {
132
57
  const result = spawnSync(electronBinary, appArgs, { stdio: "inherit" });
133
58
  process.exit(result.status ?? 0);
134
59
  }
135
- const child = spawn(electronBinary, appArgs, {
136
- detached: true,
137
- stdio: "ignore",
138
- });
60
+ const child = spawn(electronBinary, appArgs, { detached: true, stdio: "ignore" });
139
61
  child.unref();
140
62
  console.log("Opened monacori review app.");
141
63
  }
142
- function openCurrentRepository(args) {
143
- if (args.includes("--help") || args.includes("-h")) {
144
- printOpenHelp();
145
- return;
146
- }
147
- const appArgs = args.filter((arg) => arg !== "--tracked-only");
148
- if (!args.includes("--tracked-only") && !args.includes("--staged") && !args.includes("--include-untracked")) {
149
- appArgs.push("--include-untracked");
150
- }
151
- launchReviewApp(appArgs);
152
- }
153
64
  function resolveElectronBinary() {
154
65
  const electronModule = nodeRequire("electron");
155
66
  if (typeof electronModule === "string") {
@@ -166,12 +77,30 @@ function resolveElectronBinary() {
166
77
  function appMainPath() {
167
78
  return join(dirname(fileURLToPath(import.meta.url)), "app-main.js");
168
79
  }
80
+ // Create .monacori/ on first run (the app auto-initializes; there is no separate `init` command).
81
+ function initFlow() {
82
+ const root = process.cwd();
83
+ const flowPath = join(root, FLOW_DIR);
84
+ mkdirSync(flowPath, { recursive: true });
85
+ mkdirSync(join(flowPath, "reports"), { recursive: true });
86
+ mkdirSync(join(flowPath, "logs"), { recursive: true });
87
+ mkdirSync(join(flowPath, "diffs"), { recursive: true });
88
+ const config = {
89
+ version: 1,
90
+ projectName: basename(root),
91
+ verification: { commands: detectVerificationCommands(root) },
92
+ diff: { context: 12, includeUntracked: false },
93
+ };
94
+ writeIfMissing(join(flowPath, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`);
95
+ writeIfMissing(join(flowPath, STATE_FILE), initialState(config));
96
+ writeIfMissing(join(flowPath, DECISIONS_FILE), initialDecisions());
97
+ ensureMonacoriGitignore(root);
98
+ }
169
99
  function initialState(config) {
170
100
  return [
171
101
  "# Monacori Validation State",
172
102
  "",
173
103
  `Project: ${config.projectName}`,
174
- `Initialized: ${new Date().toISOString()}`,
175
104
  "",
176
105
  "## Goal",
177
106
  "- Keep AI-generated changes reviewable, test-backed, and easy to inspect.",
@@ -190,74 +119,22 @@ function initialDecisions() {
190
119
  "",
191
120
  ].join("\n");
192
121
  }
193
- function agentSnippet() {
194
- return [
195
- "<!-- MONACORI:START -->",
196
- "## monacori Diff Review",
197
- "",
198
- "This repository uses monacori to help humans review AI-generated code changes side-by-side.",
199
- "",
200
- "After making code changes:",
201
- "",
202
- "- The user can run `mo` to open the diff review app and inspect your changes.",
203
- "- Inspect changed hunks with F7 / Shift+F7.",
204
- "- Use Shift Shift in the diff review to search indexed files, including unchanged files.",
205
- "- In source previews, use Cmd/Ctrl+Down to jump to the declaration-like match under the cursor.",
206
- "- Inline comments left in the review are bundled into a prompt and sent back to the session.",
207
- "<!-- MONACORI:END -->",
208
- "",
209
- ].join("\n");
210
- }
211
- function applyAgentDocSnippet(fileName) {
212
- const path = join(process.cwd(), fileName);
213
- const snippet = agentSnippet();
214
- if (!existsSync(path)) {
215
- writeFileSync(path, `# ${fileName}\n\n${snippet}`);
216
- return;
217
- }
218
- const current = readFileSync(path, "utf8");
219
- const markerPattern = /<!-- MONACORI:START -->[\s\S]*?<!-- MONACORI:END -->\n?/;
220
- const next = markerPattern.test(current)
221
- ? current.replace(markerPattern, snippet)
222
- : `${current.trimEnd()}\n\n${snippet}`;
223
- writeFileSync(path, next);
224
- }
225
- function ensureInitialized() {
226
- if (!existsSync(join(process.cwd(), FLOW_DIR, CONFIG_FILE))) {
227
- throw new Error(`Missing ${FLOW_DIR}/. Run \`monacori init\` first.`);
228
- }
229
- }
230
122
  function ensureWritableFlowState() {
231
123
  if (!existsSync(join(process.cwd(), FLOW_DIR, CONFIG_FILE))) {
232
- initFlow(["--quiet"]);
124
+ initFlow();
233
125
  return;
234
126
  }
235
127
  ensureMonacoriGitignore(process.cwd());
236
128
  }
237
- function loadConfig() {
238
- ensureInitialized();
239
- const raw = JSON.parse(readFileSync(join(process.cwd(), FLOW_DIR, CONFIG_FILE), "utf8"));
240
- return {
241
- version: 1,
242
- projectName: raw.projectName ?? basename(process.cwd()),
243
- verification: {
244
- commands: Array.isArray(raw.verification?.commands) ? raw.verification.commands : [],
245
- },
246
- diff: {
247
- context: typeof raw.diff?.context === "number" ? raw.diff.context : 12,
248
- includeUntracked: typeof raw.diff?.includeUntracked === "boolean" ? raw.diff.includeUntracked : false,
249
- },
250
- };
251
- }
252
- function writeIfMissing(path, content, force) {
253
- if (!force && existsSync(path)) {
129
+ function writeIfMissing(path, content) {
130
+ if (existsSync(path)) {
254
131
  return;
255
132
  }
256
133
  writeFileSync(path, content);
257
134
  }
258
135
  function ensureMonacoriGitignore(root) {
259
136
  if (git(root, ["rev-parse", "--is-inside-work-tree"]) !== "true") {
260
- return false;
137
+ return;
261
138
  }
262
139
  const path = join(root, GITIGNORE_FILE);
263
140
  const content = existsSync(path) ? readFileSync(path, "utf8") : "";
@@ -266,11 +143,10 @@ function ensureMonacoriGitignore(root) {
266
143
  .map((line) => line.trim())
267
144
  .some((line) => line === FLOW_DIR || line === `${FLOW_DIR}/`);
268
145
  if (hasEntry) {
269
- return false;
146
+ return;
270
147
  }
271
148
  const prefix = content.length === 0 ? "" : content.endsWith("\n") ? "\n" : "\n\n";
272
149
  writeFileSync(path, `${content}${prefix}# monacori local validation artifacts\n${FLOW_DIR}/\n`);
273
- return true;
274
150
  }
275
151
  function detectVerificationCommands(root) {
276
152
  const commands = new Set();
@@ -318,51 +194,16 @@ function packageScriptCommand(manager, script) {
318
194
  return `pnpm ${script}`;
319
195
  }
320
196
  function printHelp() {
321
- console.log(`monacori
322
-
323
- Desktop review app for AI-generated code changes.
197
+ console.log(`monacori — desktop review app for AI-generated code changes.
324
198
 
325
199
  Usage:
326
- mo
327
- monacori open [--base HEAD] [--staged] [--tracked-only]
328
- monacori app [--base HEAD] [--staged] [--include-untracked]
329
- monacori init [--force]
330
- monacori install [--force] [--apply-agent-docs]
200
+ mo open the review app for the current repository
331
201
 
332
202
  Diff review keys:
333
- F7 next changed hunk
334
- Shift+F7 previous changed hunk
335
- Shift Shift file search across indexed files
336
- Cmd/Ctrl+E recent files
337
- Cmd/Ctrl+Down jump to symbol under cursor
338
- `);
339
- }
340
- function printOpenHelp() {
341
- console.log(`monacori open
342
-
343
- Open the local desktop review app for the current directory. This is the default command behind \`mo\` and \`monacori\` with no arguments.
344
-
345
- It auto-initializes .monacori/ when needed, makes sure .monacori/ is ignored in Git worktrees, and includes untracked files by default so new AI-created files are visible.
346
-
347
- Usage:
348
- mo
349
- monacori open [--base HEAD] [--staged] [--tracked-only] [--context 12] [--no-watch] [--foreground]
350
-
351
- Options:
352
- --tracked-only inspect tracked changes only
353
- `);
354
- }
355
- function printAppHelp() {
356
- console.log(`monacori app
357
-
358
- Launch the local desktop review app. The app reads Git diff and source files directly from this repository, writes a local review file under .monacori/, and refreshes when the working tree changes. It does not start an HTTP server.
359
-
360
- Usage:
361
- monacori app [--base HEAD] [--staged] [--include-untracked] [--context 12] [--no-watch] [--foreground]
362
-
363
- Aliases:
364
- mo
365
- monacori open
366
- monacori review
203
+ F7 / Shift+F7 next / previous changed hunk
204
+ Cmd/Ctrl+0 / +1 focus the Changes / Files panel (arrows + Enter to open a file)
205
+ Shift Shift file search across indexed files
206
+ Cmd/Ctrl+E recent files
207
+ Cmd/Ctrl+Down jump to symbol under cursor
367
208
  `);
368
209
  }
package/dist/diff.js CHANGED
@@ -3,8 +3,14 @@ import { existsSync, readFileSync, statSync } from "node:fs";
3
3
  import { basename, join } from "node:path";
4
4
  import { FLOW_DIR, IMAGE_MAX_BYTES, SOURCE_MAX_FILE_BYTES, SOURCE_MAX_FILES, SOURCE_MAX_TOTAL_BYTES } from "./constants.js";
5
5
  import { formatBytes, hashText, isLikelyBinary, languageForPath, stripDiffPath } from "./util.js";
6
- import { git } from "./git.js";
6
+ import { git, repoRoot } from "./git.js";
7
+ // File content + signature cache, keyed by path and validated on (mtime, size). Under `watch` the app
8
+ // rebuilds every second; without this, collectSourceFiles re-reads + re-hashes EVERY tracked source
9
+ // file each tick (~1.3s for ~6k files on a large repo), pinning the Electron main process and starving
10
+ // IPC/pty. With it an unchanged file costs a single statSync — the per-tick cost collapses to stat-only.
11
+ const sourceContentCache = new Map();
7
12
  export function readUnifiedDiff(options) {
13
+ const root = repoRoot();
8
14
  const args = ["diff", "--no-ext-diff", "--find-renames", `--unified=${options.context}`];
9
15
  if (options.ignoreWhitespace)
10
16
  args.push("--ignore-all-space");
@@ -16,7 +22,7 @@ export function readUnifiedDiff(options) {
16
22
  }
17
23
  args.push("--");
18
24
  const result = spawnSync("git", args, {
19
- cwd: process.cwd(),
25
+ cwd: root,
20
26
  encoding: "utf8",
21
27
  maxBuffer: 1024 * 1024 * 100,
22
28
  });
@@ -25,18 +31,18 @@ export function readUnifiedDiff(options) {
25
31
  }
26
32
  const chunks = [result.stdout ?? ""];
27
33
  if (options.includeUntracked && !options.staged) {
28
- chunks.push(readUntrackedDiff(options.context));
34
+ chunks.push(readUntrackedDiff(options.context, root));
29
35
  }
30
36
  return chunks.filter(Boolean).join("\n");
31
37
  }
32
- function readUntrackedDiff(context) {
33
- const files = git(process.cwd(), ["ls-files", "--others", "--exclude-standard"])
38
+ function readUntrackedDiff(context, root) {
39
+ const files = git(root, ["ls-files", "--others", "--exclude-standard"])
34
40
  .split(/\r?\n/)
35
41
  .map((line) => line.trim())
36
42
  .filter((line) => line && !line.startsWith(`${FLOW_DIR}/`));
37
43
  const chunks = [];
38
44
  for (const file of files) {
39
- const absolute = join(process.cwd(), file);
45
+ const absolute = join(root, file);
40
46
  if (!existsSync(absolute) || !statSync(absolute).isFile()) {
41
47
  continue;
42
48
  }
@@ -225,14 +231,15 @@ export function collectSourceFiles(diffFiles) {
225
231
  }
226
232
  changedLinesByPath.set(file.displayPath, nums);
227
233
  }
228
- const vcsByPath = gitStatusMap(process.cwd());
234
+ const root = repoRoot();
235
+ const vcsByPath = gitStatusMap(root);
229
236
  for (const file of diffFiles) {
230
237
  const kind = vcsByPath.get(file.displayPath);
231
238
  if (kind)
232
239
  file.vcs = kind; // color the Changes list from the same status map
233
240
  }
234
241
  const paths = new Set();
235
- const gitFiles = git(process.cwd(), ["ls-files", "--cached", "--others", "--exclude-standard"]);
242
+ const gitFiles = git(root, ["ls-files", "--cached", "--others", "--exclude-standard"]);
236
243
  for (const file of gitFiles.split(/\r?\n/)) {
237
244
  const path = file.trim();
238
245
  if (path && isSourceCandidate(path)) {
@@ -248,7 +255,7 @@ export function collectSourceFiles(diffFiles) {
248
255
  let embeddedFiles = 0;
249
256
  let embeddedBytes = 0;
250
257
  for (const path of Array.from(paths).sort((a, b) => a.localeCompare(b))) {
251
- const absolute = join(process.cwd(), path);
258
+ const absolute = join(root, path);
252
259
  const base = {
253
260
  path,
254
261
  name: basename(path),
@@ -282,7 +289,11 @@ export function collectSourceFiles(diffFiles) {
282
289
  }
283
290
  continue;
284
291
  }
285
- if (isLikelyBinary(absolute)) {
292
+ // A file we already cached as text (same mtime+size) can't have turned binary — skip the binary
293
+ // sniff (an open+read per file, ~635ms across this repo) on the hot watch path.
294
+ const cached = sourceContentCache.get(path);
295
+ const fresh = Boolean(cached && cached.mtimeMs === stats.mtimeMs && cached.size === stats.size);
296
+ if (!fresh && isLikelyBinary(absolute)) {
286
297
  const skippedReason = "binary file";
287
298
  sourceFiles.push({ ...base, size: stats.size, signature: hashText(`${path}\0binary\0${stats.size}`), skippedReason });
288
299
  continue;
@@ -297,14 +308,18 @@ export function collectSourceFiles(diffFiles) {
297
308
  sourceFiles.push({ ...base, size: stats.size, signature: hashText(`${path}\0budget\0${stats.size}`), skippedReason });
298
309
  continue;
299
310
  }
300
- const content = readFileSync(absolute, "utf8");
301
- sourceFiles.push({
302
- ...base,
303
- content,
304
- size: stats.size,
305
- embedded: true,
306
- signature: hashText(`${path}\0${content}`),
307
- });
311
+ let content;
312
+ let signature;
313
+ if (fresh) {
314
+ content = cached.content; // unchanged since last build — skip the read + hash
315
+ signature = cached.signature;
316
+ }
317
+ else {
318
+ content = readFileSync(absolute, "utf8");
319
+ signature = hashText(`${path}\0${content}`);
320
+ sourceContentCache.set(path, { mtimeMs: stats.mtimeMs, size: stats.size, content, signature });
321
+ }
322
+ sourceFiles.push({ ...base, content, size: stats.size, embedded: true, signature });
308
323
  embeddedFiles += 1;
309
324
  embeddedBytes += stats.size;
310
325
  }