@happy-nut/monacori 0.1.11 → 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,85 +25,31 @@ 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
- // In dev only (`npm run dev` sets MONACORI_DEV=1) announce which build is launching, so a local
128
- // checkout is distinguishable from the installed package. Normal `mo` runs stay silent — the shell
129
- // should be clean, not littered with our internal path.
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.
130
53
  if (process.env.MONACORI_DEV === "1") {
131
54
  console.error(`monacori: launching ${appMainPath()}`);
132
55
  }
@@ -134,24 +57,10 @@ function launchReviewApp(args) {
134
57
  const result = spawnSync(electronBinary, appArgs, { stdio: "inherit" });
135
58
  process.exit(result.status ?? 0);
136
59
  }
137
- const child = spawn(electronBinary, appArgs, {
138
- detached: true,
139
- stdio: "ignore",
140
- });
60
+ const child = spawn(electronBinary, appArgs, { detached: true, stdio: "ignore" });
141
61
  child.unref();
142
62
  console.log("Opened monacori review app.");
143
63
  }
144
- function openCurrentRepository(args) {
145
- if (args.includes("--help") || args.includes("-h")) {
146
- printOpenHelp();
147
- return;
148
- }
149
- const appArgs = args.filter((arg) => arg !== "--tracked-only");
150
- if (!args.includes("--tracked-only") && !args.includes("--staged") && !args.includes("--include-untracked")) {
151
- appArgs.push("--include-untracked");
152
- }
153
- launchReviewApp(appArgs);
154
- }
155
64
  function resolveElectronBinary() {
156
65
  const electronModule = nodeRequire("electron");
157
66
  if (typeof electronModule === "string") {
@@ -168,12 +77,30 @@ function resolveElectronBinary() {
168
77
  function appMainPath() {
169
78
  return join(dirname(fileURLToPath(import.meta.url)), "app-main.js");
170
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
+ }
171
99
  function initialState(config) {
172
100
  return [
173
101
  "# Monacori Validation State",
174
102
  "",
175
103
  `Project: ${config.projectName}`,
176
- `Initialized: ${new Date().toISOString()}`,
177
104
  "",
178
105
  "## Goal",
179
106
  "- Keep AI-generated changes reviewable, test-backed, and easy to inspect.",
@@ -192,74 +119,22 @@ function initialDecisions() {
192
119
  "",
193
120
  ].join("\n");
194
121
  }
195
- function agentSnippet() {
196
- return [
197
- "<!-- MONACORI:START -->",
198
- "## monacori Diff Review",
199
- "",
200
- "This repository uses monacori to help humans review AI-generated code changes side-by-side.",
201
- "",
202
- "After making code changes:",
203
- "",
204
- "- The user can run `mo` to open the diff review app and inspect your changes.",
205
- "- Inspect changed hunks with F7 / Shift+F7.",
206
- "- Use Shift Shift in the diff review to search indexed files, including unchanged files.",
207
- "- In source previews, use Cmd/Ctrl+Down to jump to the declaration-like match under the cursor.",
208
- "- Inline comments left in the review are bundled into a prompt and sent back to the session.",
209
- "<!-- MONACORI:END -->",
210
- "",
211
- ].join("\n");
212
- }
213
- function applyAgentDocSnippet(fileName) {
214
- const path = join(process.cwd(), fileName);
215
- const snippet = agentSnippet();
216
- if (!existsSync(path)) {
217
- writeFileSync(path, `# ${fileName}\n\n${snippet}`);
218
- return;
219
- }
220
- const current = readFileSync(path, "utf8");
221
- const markerPattern = /<!-- MONACORI:START -->[\s\S]*?<!-- MONACORI:END -->\n?/;
222
- const next = markerPattern.test(current)
223
- ? current.replace(markerPattern, snippet)
224
- : `${current.trimEnd()}\n\n${snippet}`;
225
- writeFileSync(path, next);
226
- }
227
- function ensureInitialized() {
228
- if (!existsSync(join(process.cwd(), FLOW_DIR, CONFIG_FILE))) {
229
- throw new Error(`Missing ${FLOW_DIR}/. Run \`monacori init\` first.`);
230
- }
231
- }
232
122
  function ensureWritableFlowState() {
233
123
  if (!existsSync(join(process.cwd(), FLOW_DIR, CONFIG_FILE))) {
234
- initFlow(["--quiet"]);
124
+ initFlow();
235
125
  return;
236
126
  }
237
127
  ensureMonacoriGitignore(process.cwd());
238
128
  }
239
- function loadConfig() {
240
- ensureInitialized();
241
- const raw = JSON.parse(readFileSync(join(process.cwd(), FLOW_DIR, CONFIG_FILE), "utf8"));
242
- return {
243
- version: 1,
244
- projectName: raw.projectName ?? basename(process.cwd()),
245
- verification: {
246
- commands: Array.isArray(raw.verification?.commands) ? raw.verification.commands : [],
247
- },
248
- diff: {
249
- context: typeof raw.diff?.context === "number" ? raw.diff.context : 12,
250
- includeUntracked: typeof raw.diff?.includeUntracked === "boolean" ? raw.diff.includeUntracked : false,
251
- },
252
- };
253
- }
254
- function writeIfMissing(path, content, force) {
255
- if (!force && existsSync(path)) {
129
+ function writeIfMissing(path, content) {
130
+ if (existsSync(path)) {
256
131
  return;
257
132
  }
258
133
  writeFileSync(path, content);
259
134
  }
260
135
  function ensureMonacoriGitignore(root) {
261
136
  if (git(root, ["rev-parse", "--is-inside-work-tree"]) !== "true") {
262
- return false;
137
+ return;
263
138
  }
264
139
  const path = join(root, GITIGNORE_FILE);
265
140
  const content = existsSync(path) ? readFileSync(path, "utf8") : "";
@@ -268,11 +143,10 @@ function ensureMonacoriGitignore(root) {
268
143
  .map((line) => line.trim())
269
144
  .some((line) => line === FLOW_DIR || line === `${FLOW_DIR}/`);
270
145
  if (hasEntry) {
271
- return false;
146
+ return;
272
147
  }
273
148
  const prefix = content.length === 0 ? "" : content.endsWith("\n") ? "\n" : "\n\n";
274
149
  writeFileSync(path, `${content}${prefix}# monacori local validation artifacts\n${FLOW_DIR}/\n`);
275
- return true;
276
150
  }
277
151
  function detectVerificationCommands(root) {
278
152
  const commands = new Set();
@@ -320,51 +194,16 @@ function packageScriptCommand(manager, script) {
320
194
  return `pnpm ${script}`;
321
195
  }
322
196
  function printHelp() {
323
- console.log(`monacori
324
-
325
- Desktop review app for AI-generated code changes.
197
+ console.log(`monacori — desktop review app for AI-generated code changes.
326
198
 
327
199
  Usage:
328
- mo
329
- monacori open [--base HEAD] [--staged] [--tracked-only]
330
- monacori app [--base HEAD] [--staged] [--include-untracked]
331
- monacori init [--force]
332
- monacori install [--force] [--apply-agent-docs]
200
+ mo open the review app for the current repository
333
201
 
334
202
  Diff review keys:
335
- F7 next changed hunk
336
- Shift+F7 previous changed hunk
337
- Shift Shift file search across indexed files
338
- Cmd/Ctrl+E recent files
339
- Cmd/Ctrl+Down jump to symbol under cursor
340
- `);
341
- }
342
- function printOpenHelp() {
343
- console.log(`monacori open
344
-
345
- Open the local desktop review app for the current directory. This is the default command behind \`mo\` and \`monacori\` with no arguments.
346
-
347
- 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.
348
-
349
- Usage:
350
- mo
351
- monacori open [--base HEAD] [--staged] [--tracked-only] [--context 12] [--no-watch] [--foreground]
352
-
353
- Options:
354
- --tracked-only inspect tracked changes only
355
- `);
356
- }
357
- function printAppHelp() {
358
- console.log(`monacori app
359
-
360
- 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.
361
-
362
- Usage:
363
- monacori app [--base HEAD] [--staged] [--include-untracked] [--context 12] [--no-watch] [--foreground]
364
-
365
- Aliases:
366
- mo
367
- monacori open
368
- 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
369
208
  `);
370
209
  }
package/dist/diff.js CHANGED
@@ -4,6 +4,11 @@ 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
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) {
8
13
  const root = repoRoot();
9
14
  const args = ["diff", "--no-ext-diff", "--find-renames", `--unified=${options.context}`];
@@ -284,7 +289,11 @@ export function collectSourceFiles(diffFiles) {
284
289
  }
285
290
  continue;
286
291
  }
287
- 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)) {
288
297
  const skippedReason = "binary file";
289
298
  sourceFiles.push({ ...base, size: stats.size, signature: hashText(`${path}\0binary\0${stats.size}`), skippedReason });
290
299
  continue;
@@ -299,14 +308,18 @@ export function collectSourceFiles(diffFiles) {
299
308
  sourceFiles.push({ ...base, size: stats.size, signature: hashText(`${path}\0budget\0${stats.size}`), skippedReason });
300
309
  continue;
301
310
  }
302
- const content = readFileSync(absolute, "utf8");
303
- sourceFiles.push({
304
- ...base,
305
- content,
306
- size: stats.size,
307
- embedded: true,
308
- signature: hashText(`${path}\0${content}`),
309
- });
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 });
310
323
  embeddedFiles += 1;
311
324
  embeddedBytes += stats.size;
312
325
  }
package/dist/i18n.js CHANGED
@@ -62,6 +62,9 @@ export const MESSAGES = {
62
62
  "settings.cat.prompts": "Merge prompts",
63
63
  // Settings — General
64
64
  "settings.language": "Language",
65
+ "settings.theme": "Theme",
66
+ "theme.dark": "Dark",
67
+ "theme.light": "Light",
65
68
  "settings.checkingUpdates": "Checking for updates…",
66
69
  "settings.updateRestart": "Update & Restart",
67
70
  "settings.upToDate": "Up to date",
@@ -126,6 +129,8 @@ export const MESSAGES = {
126
129
  "merged.copied": "Copied",
127
130
  "merged.copyFailed": "Copy failed",
128
131
  "merged.close": "Close",
132
+ "dropdown.navigate": "Go to comment",
133
+ "dropdown.remove": "Remove",
129
134
  "merged.qHeading": "# Questions",
130
135
  "merged.cHeading": "# Change requests",
131
136
  // Prompt memo (Cmd/Ctrl+Shift+N) — a single freeform Markdown scratchpad with a live split preview.
@@ -188,6 +193,9 @@ export const MESSAGES = {
188
193
  "settings.cat.prompts": "병합 프롬프트",
189
194
  // Settings — General
190
195
  "settings.language": "언어",
196
+ "settings.theme": "테마",
197
+ "theme.dark": "다크",
198
+ "theme.light": "라이트",
191
199
  "settings.checkingUpdates": "업데이트 확인 중…",
192
200
  "settings.updateRestart": "업데이트 후 재시작",
193
201
  "settings.upToDate": "최신 버전입니다",
@@ -252,6 +260,8 @@ export const MESSAGES = {
252
260
  "merged.copied": "복사됨",
253
261
  "merged.copyFailed": "복사 실패",
254
262
  "merged.close": "닫기",
263
+ "dropdown.navigate": "코멘트로 이동",
264
+ "dropdown.remove": "지우기",
255
265
  // Structural markers stay English in both locales (the preamble prose below follows the locale).
256
266
  "merged.qHeading": "# Questions",
257
267
  "merged.cHeading": "# Change requests",
package/dist/render.d.ts CHANGED
@@ -6,7 +6,7 @@ export declare function splitDiffForLazy(diffHtml: string, files: DiffFile[]): {
6
6
  islands: string;
7
7
  bodies: string[];
8
8
  };
9
- export declare function renderReviewStatus(input: {
9
+ export declare function renderReviewStatus(_input: {
10
10
  files: number;
11
11
  hunks: number;
12
12
  embeddedFiles: number;