@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 +3 -22
- package/dist/app-main.js +67 -11
- package/dist/commands.js +54 -215
- package/dist/diff.js +22 -9
- package/dist/i18n.js +10 -0
- package/dist/render.d.ts +1 -1
- package/dist/render.js +11 -2
- package/dist/util.js +13 -3
- package/dist/viewer.client.js +346 -37
- package/dist/viewer.css +87 -25
- package/package.json +1 -1
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
|
|
66
|
+
This reviews the monacori repo itself. To review **another repo** with your local build, pass `--cwd`:
|
|
86
67
|
|
|
87
68
|
```bash
|
|
88
|
-
|
|
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
|
-
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
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…"
|
|
51
|
-
|
|
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(
|
|
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
|
-
|
|
324
|
-
|
|
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 {
|
|
7
|
-
import {
|
|
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 (
|
|
15
|
-
|
|
16
|
+
if (rawArgs.includes("--help") || rawArgs.includes("-h")) {
|
|
17
|
+
printHelp();
|
|
16
18
|
return;
|
|
17
19
|
}
|
|
18
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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(
|
|
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
|
-
//
|
|
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(
|
|
124
|
+
initFlow();
|
|
235
125
|
return;
|
|
236
126
|
}
|
|
237
127
|
ensureMonacoriGitignore(process.cwd());
|
|
238
128
|
}
|
|
239
|
-
function
|
|
240
|
-
|
|
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
|
|
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
|
|
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
|
|
336
|
-
|
|
337
|
-
Shift Shift
|
|
338
|
-
Cmd/Ctrl+E
|
|
339
|
-
Cmd/Ctrl+Down
|
|
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
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
content
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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(
|
|
9
|
+
export declare function renderReviewStatus(_input: {
|
|
10
10
|
files: number;
|
|
11
11
|
hunks: number;
|
|
12
12
|
embeddedFiles: number;
|