@happy-nut/monacori 0.1.0 → 0.1.3

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
@@ -1,177 +1,89 @@
1
1
  # monacori
2
2
 
3
- Validation control plane for AI-generated code changes.
3
+ **Validation control plane for AI-generated code changes.** After an AI edits your repository, `monacori` produces verification evidence a human can actually trust.
4
4
 
5
- `monacori` is not an agent orchestrator. It does not manage panes, sessions, worktrees, or model adapters. Its job is narrower: after an AI edits a repository, produce verification evidence that a human can trust.
6
-
7
- It gives you:
8
-
9
- - detected verification commands in `.monacori/config.json`
10
- - repeatable verification logs under `.monacori/logs/`
11
- - a local desktop review app for live diff inspection
12
- - diff2html review pages under `.monacori/diffs/`
13
- - searchable source previews, including unchanged indexed files
14
- - compact validation reports under `.monacori/reports/`
5
+ It is *not* an agent orchestrator. It does not manage panes, sessions, worktrees, or model adapters. Its job is narrow on purpose: validate what the AI just did.
15
6
 
16
7
  ## Why
17
8
 
18
- AI coding output is hard to trust when review depends on chat memory or vague "done" claims. `monacori` keeps the review artifacts in the repository so the state can be inspected, rerun, and discussed.
9
+ AI coding output is hard to trust when review depends on chat memory or a vague "done" claim. `monacori` keeps the review artifacts inside the repository, so the state of a change can be inspected, rerun, and discussed instead of taken on faith.
19
10
 
20
- The intended loop is simple:
11
+ The loop is simple:
21
12
 
22
13
  ```text
23
14
  AI edits code.
24
15
  monacori runs verification.
25
16
  monacori creates a reviewable diff artifact.
26
- You inspect evidence before accepting the change.
17
+ You inspect the evidence before accepting the change.
27
18
  ```
28
19
 
20
+ ![Diff review](assets/screenshots/diff-review.png)
21
+ *Local desktop review app: side-by-side diff with changed lines highlighted and IntelliJ-style git status colors in the sidebar.*
22
+
29
23
  ## Install
30
24
 
31
25
  ```bash
32
26
  npm install -g @happy-nut/monacori
33
27
  ```
34
28
 
35
- After installation, the short command is:
36
-
37
- ```bash
38
- dg
39
- ```
40
-
41
- Homebrew tap distribution is prepared through `Formula/monacori.rb`. Once the scoped npm package is published and the formula is copied to the `happy-nut/homebrew-monacori` tap, the intended install path is:
29
+ After install, the short command is `mo`. A Homebrew tap (`happy-nut/monacori/monacori`) is also available.
42
30
 
43
- ```bash
44
- brew install happy-nut/monacori/monacori
45
- ```
31
+ ## What you get
46
32
 
47
- For local development:
33
+ - **Local desktop review app** — diff review with changed-line highlighting and an IntelliJ-style sidebar that colors files by git status. Reads the repo directly, refreshes on change, no HTTP server.
34
+ - **Integrated terminal** — run AI CLIs like `claude` or `codex` inside the app, and send a "merged prompt" (your question or fix request bundled with the relevant code context) straight to the session.
35
+ - **Inline comments** — annotate the diff while you review.
36
+ - **Verification logs & reports** — repeatable verification under `.monacori/logs/` and compact validation reports under `.monacori/reports/`.
48
37
 
49
- ```bash
50
- git clone https://github.com/happy-nut/monacori.git
51
- cd monacori
52
- npm install
53
- npm run build
54
- npm link
55
- ```
38
+ ![Integrated terminal](assets/screenshots/terminal.png)
39
+ *Run your AI CLI inside the review app, next to the diff it just produced.*
56
40
 
57
- ## Quick Start
41
+ ## Quick start
58
42
 
59
43
  Inside the repository you want to validate:
60
44
 
61
45
  ```bash
62
- dg
63
- monacori check --include-untracked
64
- ```
65
-
66
- `dg` opens the local desktop review app for the current directory. On first run it creates `.monacori/`, updates Git ignore rules for `.monacori/`, and includes untracked files so new AI-created files show up immediately.
67
-
68
- `check` runs configured verification commands, writes a log, creates a browser diff review, and records a compact report.
69
-
70
- For a one-off command:
71
-
72
- ```bash
73
- monacori check -- npm test
74
- ```
75
-
76
- For live diff review while an AI is still editing:
77
-
78
- ```bash
79
- dg
46
+ mo # open the desktop review app for the current dir
47
+ monacori check --include-untracked # run verification, write a log, diff, and report
80
48
  ```
81
49
 
82
- ## Diff Review
83
-
84
- The desktop review app is powered by Electron and diff2html. It reads Git diff and source files directly from the local repository, writes a local `.monacori/app-review.html` file, and refreshes when the working tree changes. It does not start an HTTP server.
85
-
86
- - `F7`: show the next changed hunk, starting from the current source file when possible
87
- - `Shift+F7`: previous changed hunk
88
- - `]` / `[`: fallback keys if the browser captures function keys
89
- - `Shift Shift`: search indexed files, including unchanged files
90
- - `Cmd/Ctrl+E`: open recent files
91
- - `Cmd/Ctrl+Down`: jump from the source cursor to a declaration-like match for the symbol under it
92
-
93
- The app opens as a read-only source viewer by default. The `Files` tab opens indexed files even when they are unchanged, and `F7` switches into the diff view when you want to inspect changes. Drag-copying source text adds `path:line-range` plus a fenced code block to the clipboard so the snippet can be pasted into an AI prompt with context. Viewed marks are stored with file signatures, so a file becomes unviewed again when it changes.
94
-
95
- The browser artifact path is still available through `monacori diff`. Add `--watch` there only when you specifically want a browser-served live review.
50
+ On first run, `mo` creates `.monacori/`, adds it to `.gitignore`, and includes untracked files so new AI-created files show up immediately.
96
51
 
97
52
  ## Commands
98
53
 
99
- ```bash
100
- monacori open [--base HEAD] [--staged] [--tracked-only] [--context 12] [--no-watch]
101
- ```
102
-
103
- Opens the local desktop review app for the current directory. `dg` and bare `monacori` are aliases for this default flow. It auto-initializes local state when needed and includes untracked files by default; pass `--tracked-only` to inspect tracked changes only.
54
+ | Command | What it does |
55
+ | --- | --- |
56
+ | `mo` | Open the desktop review app (alias for `monacori open`). |
57
+ | `monacori check` | Run verification, then create a diff and a validation report. `-- <cmd>` overrides commands for one run. |
58
+ | `monacori verify` | Run configured verification commands and store the log. Exits non-zero on failure. |
59
+ | `monacori diff` | Generate a browser-based side-by-side diff page. `--watch` serves a live-reloading review. |
60
+ | `monacori app` | Launch the desktop review app (same as `mo`). |
61
+ | `monacori report` | Store a manual report under `.monacori/reports/`. |
62
+ | `monacori status` | Print branch, git status, diff stat, verification commands, and recent reports/logs. |
104
63
 
105
- ```bash
106
- monacori check [--include-untracked] [--staged] [--base HEAD] [--context 12] [--open] [--no-verify] [--no-diff] [-- <command>]
107
- ```
64
+ ## Repository state
108
65
 
109
- Runs verification and creates a validation report. By default it uses commands from `.monacori/config.json`; pass `-- <command>` to override for one run.
110
-
111
- ```bash
112
- monacori init [--force]
113
- ```
114
-
115
- Creates `.monacori/` with config, state, decisions, reports, logs, and diff directories. Because these are local validation artifacts, monacori also adds `.monacori/` to `.gitignore` inside Git worktrees.
116
-
117
- ```bash
118
- monacori install [--force] [--apply-agent-docs]
119
- ```
120
-
121
- Writes `.monacori/agent-snippet.md`, a short instruction block telling AI agents to run validation before claiming completion. With `--apply-agent-docs`, it updates `AGENTS.md` and `CLAUDE.md` marker blocks where available.
122
-
123
- ```bash
124
- monacori verify [-- <command>]
125
- ```
126
-
127
- Runs configured verification commands and stores the log in `.monacori/logs/`. Exits non-zero on failure.
128
-
129
- ```bash
130
- monacori app [--base HEAD] [--staged] [--include-untracked] [--context 12] [--no-watch]
131
- ```
132
-
133
- Launches the local desktop review app. `dg`, `monacori open`, and `monacori review` are aliases. Prefer `dg` for normal use.
134
-
135
- ```bash
136
- monacori diff [--base HEAD] [--staged] [--include-untracked] [--context 12] [--output review.html] [--open] [--watch] [--port 0]
137
- ```
138
-
139
- Generates a browser-based side-by-side diff review. Add `--watch` to serve a live review that reloads when the working tree changes.
140
-
141
- ```bash
142
- monacori status
143
- ```
144
-
145
- Prints current branch, git status, diff stat, configured verification commands, recent reports, and recent logs.
146
-
147
- ```bash
148
- monacori report [--label manual] [--file report.md]
149
- ```
150
-
151
- Stores a manual report under `.monacori/reports/` and appends a compact summary to `.monacori/state.md`.
152
-
153
- ## Repository State
154
-
155
- `monacori init` creates:
66
+ `monacori init` (run automatically by `mo`) creates:
156
67
 
157
68
  ```text
158
69
  .monacori/
159
- config.json verification commands and diff defaults
160
- state.md compact validation history
161
- decisions.md durable validation decisions
162
- diffs/ generated browser diff reviews
163
- reports/ validation reports
164
- logs/ verification logs
70
+ config.json verification commands and diff defaults
71
+ state.md compact validation history
72
+ diffs/ generated browser diff reviews
73
+ reports/ validation reports
74
+ logs/ verification logs
165
75
  ```
166
76
 
167
- `monacori install` also writes `.monacori/agent-snippet.md` for projects that want to paste or apply agent-facing validation instructions.
168
-
169
77
  Keep `.monacori/` ignored unless your team explicitly wants to commit validation state.
170
78
 
171
- ## Design Principles
79
+ ## Design principles
172
80
 
173
81
  - Verification evidence beats chat memory.
174
- - Generated artifacts should be plain Markdown, JSON, logs, or static HTML.
175
- - The tool should not require a specific AI agent, terminal multiplexer, editor, or worktree strategy.
82
+ - Generated artifacts are plain Markdown, JSON, logs, or static HTML.
83
+ - No required AI agent, terminal multiplexer, editor, or worktree strategy.
176
84
  - The default command should be useful after any AI-generated edit.
177
- - A change is not accepted until verification evidence is clear or the gap is explicitly documented.
85
+ - A change is not accepted until the evidence is clear or the gap is documented.
86
+
87
+ ## License
88
+
89
+ MIT
package/assets/icon.png CHANGED
Binary file
Binary file
package/dist/app-main.js CHANGED
@@ -1,13 +1,131 @@
1
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { spawnSync } from "node:child_process";
2
3
  import { dirname, join, resolve } from "node:path";
3
4
  import { fileURLToPath } from "node:url";
4
- import { app, BrowserWindow, Menu, nativeImage } from "electron";
5
- import { buildDiffReview } from "./cli.js";
5
+ import { app, BrowserWindow, ipcMain, Menu, nativeImage } from "electron";
6
+ import { buildDiffReview, performHttpRequest } from "./cli.js";
7
+ import { spawn as spawnPty } from "node-pty";
6
8
  const FLOW_DIR = ".monacori";
7
9
  const REVIEW_FILE = "app-review.html";
8
10
  const WATCH_INTERVAL_MS = 1000;
11
+ // Painted immediately while the first review build + HTML render run, so startup shows a spinner instead
12
+ // of a blank window. Inlined as a data: URL so it needs no file on disk and appears before any review work.
13
+ const LOADING_HTML = `<!doctype html><html><head><meta charset="utf-8"><style>
14
+ html,body{margin:0;height:100vh;background:#2b2b2b;color:#9aa4af;display:flex;flex-direction:column;
15
+ align-items:center;justify-content:center;gap:18px;
16
+ font:13px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
17
+ .s{width:34px;height:34px;border:3px solid #3a3a3a;border-top-color:#4a9eff;border-radius:50%;
18
+ animation:spin .8s linear infinite}
19
+ @keyframes spin{to{transform:rotate(360deg)}}
20
+ </style></head><body><div class="s"></div><div>monacori</div></body></html>`;
9
21
  app.setName("monacori");
22
+ ipcMain.handle("monacori:http-send", (_event, request) => performHttpRequest(request));
23
+ // Phase 2 lazy-LOAD: serve a single file's diff body to the renderer on demand. Retained from the
24
+ // most recent writeReviewFile() build so navigation/scroll can materialize bodies without embedding.
25
+ let currentBodies = [];
26
+ let currentSourceData = "[]";
27
+ ipcMain.handle("monacori:get-file", (_event, request) => {
28
+ const i = Number(request?.index);
29
+ return Number.isInteger(i) && i >= 0 && i < currentBodies.length ? currentBodies[i] : "";
30
+ });
31
+ // Phase 2b lazy-LOAD: serve the full source files JSON (with content) on demand.
32
+ ipcMain.handle("monacori:get-source-data", () => currentSourceData);
33
+ // Self-update: install the latest published package globally, then relaunch so the updated code loads.
34
+ // Runs in the main process because the sandboxed renderer can't spawn npm. Returns {ok:true} (and
35
+ // relaunches shortly after) or {ok:false,error} so the renderer can fall back to the manual command.
36
+ ipcMain.handle("monacori:self-update", () => {
37
+ const result = spawnSync("npm", ["install", "-g", "@happy-nut/monacori@latest"], {
38
+ encoding: "utf8",
39
+ shell: true,
40
+ env: process.env,
41
+ timeout: 5 * 60 * 1000,
42
+ });
43
+ if ((result.status ?? 1) === 0) {
44
+ // Let the renderer paint "Restarting…" before we relaunch with the new code.
45
+ setTimeout(() => { app.relaunch(); app.exit(0); }, 500);
46
+ return { ok: true };
47
+ }
48
+ const detail = (result.stderr || result.stdout || (result.error && result.error.message) || "npm install failed").trim();
49
+ return { ok: false, error: detail.slice(-600) };
50
+ });
51
+ // Integrated terminal: own node-pty sessions in the main process (the sandboxed renderer can't spawn
52
+ // them) and relay bytes to the renderer's xterm panes. Each split pane gets its own pty, keyed by id, so
53
+ // the renderer can route data/resize/kill per pane.
54
+ const terms = new Map();
55
+ let nextPtyId = 0;
56
+ ipcMain.handle("monacori:pty-spawn", (_event, size) => {
57
+ const id = ++nextPtyId;
58
+ const shell = process.env.SHELL || (process.platform === "win32" ? "powershell.exe" : "/bin/zsh");
59
+ const t = spawnPty(shell, [], {
60
+ name: "xterm-color",
61
+ cols: size?.cols ?? 80,
62
+ rows: size?.rows ?? 24,
63
+ cwd: options.root,
64
+ env: process.env,
65
+ });
66
+ terms.set(id, t);
67
+ // mainWindow?. only guards null, NOT a *destroyed* window — sending to a closed window's webContents
68
+ // throws "Object has been destroyed". The pty can outlive the window (close races pty teardown), so
69
+ // guard every relay with isDestroyed().
70
+ const deliver = (channel, payload) => {
71
+ if (mainWindow && !mainWindow.isDestroyed())
72
+ mainWindow.webContents.send(channel, payload);
73
+ };
74
+ t.onData((data) => deliver("monacori:pty-data", { id, data }));
75
+ t.onExit(() => { terms.delete(id); deliver("monacori:pty-exit", { id }); });
76
+ return { ok: true, id };
77
+ });
78
+ ipcMain.on("monacori:pty-write", (_event, msg) => { terms.get(msg?.id)?.write(msg.data); });
79
+ ipcMain.on("monacori:pty-resize", (_event, msg) => {
80
+ try {
81
+ terms.get(msg?.id)?.resize(msg.cols, msg.rows);
82
+ }
83
+ catch { /* resize can race the pty teardown — ignore */ }
84
+ });
85
+ ipcMain.on("monacori:pty-kill", (_event, msg) => {
86
+ const t = terms.get(msg?.id);
87
+ if (t) {
88
+ try {
89
+ t.kill();
90
+ }
91
+ catch { /* already exited */ }
92
+ terms.delete(msg.id);
93
+ }
94
+ });
95
+ // Persisted global settings (locale, …) live in a JSON file under userData and reach the renderer
96
+ // via preload + the two handlers below. The renderer's file:// localStorage is NOT reliably persisted
97
+ // across app restarts, so settings that must survive a reopen round-trip through the main process.
98
+ function settingsFile() {
99
+ return join(app.getPath("userData"), "monacori-settings.json");
100
+ }
101
+ function readSettings() {
102
+ try {
103
+ return JSON.parse(readFileSync(settingsFile(), "utf8"));
104
+ }
105
+ catch {
106
+ return {};
107
+ }
108
+ }
109
+ function writeSettings(settings) {
110
+ try {
111
+ writeFileSync(settingsFile(), JSON.stringify(settings, null, 2));
112
+ }
113
+ catch {
114
+ /* best-effort: a failed write just means the setting isn't persisted */
115
+ }
116
+ }
117
+ ipcMain.on("monacori:get-settings", (event) => {
118
+ event.returnValue = readSettings();
119
+ });
120
+ ipcMain.on("monacori:set-setting", (_event, msg) => {
121
+ if (!msg || typeof msg.key !== "string")
122
+ return;
123
+ const settings = readSettings();
124
+ settings[msg.key] = msg.value;
125
+ writeSettings(settings);
126
+ });
10
127
  const iconPath = join(dirname(fileURLToPath(import.meta.url)), "..", "assets", "icon.png");
128
+ const preloadPath = join(dirname(fileURLToPath(import.meta.url)), "preload.cjs");
11
129
  const options = parseArgs(process.argv.slice(2));
12
130
  let mainWindow;
13
131
  let currentSignature = "";
@@ -19,13 +137,67 @@ if (!existsSync(options.root)) {
19
137
  app.whenReady().then(async () => {
20
138
  process.chdir(options.root);
21
139
  mkdirSync(FLOW_DIR, { recursive: true });
22
- Menu.setApplicationMenu(null);
140
+ // Keep the standard Edit/Window roles so Cmd+C/V/X/A (copy comments into prompts) and Cmd+Q work.
141
+ // The in-window menu bar stays hidden on Windows/Linux via autoHideMenuBar; macOS shows it in the top bar.
142
+ const sendMerged = (kind) => mainWindow?.webContents.send("monacori:merged-view", kind);
143
+ const menuTemplate = [];
144
+ if (process.platform === "darwin")
145
+ menuTemplate.push({ role: "appMenu" });
146
+ menuTemplate.push({ role: "editMenu" });
147
+ // Ctrl+Cmd+Shift+/ ("?") and Ctrl+Cmd+Shift+. (">") open the merged question / change-request views.
148
+ // ? and > are Shift+/ and Shift+. so Shift is part of the combo; Ctrl+Cmd avoids macOS's Cmd+? Help grab.
149
+ menuTemplate.push({
150
+ label: "Review",
151
+ submenu: [
152
+ { label: "All questions", accelerator: "Control+Command+Shift+/", click: () => sendMerged("q") },
153
+ { label: "All change requests", accelerator: "Control+Command+Shift+.", click: () => sendMerged("c") },
154
+ { type: "separator" },
155
+ // Whitespace-ignore re-runs git diff with --ignore-all-space and reloads (main-process action,
156
+ // so a menu checkbox is simpler than a renderer IPC round-trip).
157
+ {
158
+ label: "Ignore whitespace",
159
+ type: "checkbox",
160
+ checked: options.ignoreWhitespace,
161
+ accelerator: "CommandOrControl+Shift+W",
162
+ click: (item) => {
163
+ options.ignoreWhitespace = item.checked;
164
+ currentSignature = writeReviewFile(options).signature;
165
+ mainWindow?.webContents.reloadIgnoringCache();
166
+ },
167
+ },
168
+ ],
169
+ });
170
+ // Cmd/Ctrl+W closes the active Files-mode tab (routed to the renderer) instead of the window, matching
171
+ // editor/browser tab behavior. Closing the window stays available via the menu item and Cmd/Ctrl+Q.
172
+ menuTemplate.push({
173
+ label: "Window",
174
+ submenu: [
175
+ { role: "minimize" },
176
+ { role: "zoom" },
177
+ { type: "separator" },
178
+ { label: "Close Tab", accelerator: "CommandOrControl+W", click: () => mainWindow?.webContents.send("monacori:close-tab") },
179
+ { label: "Close Window", click: () => mainWindow?.close() },
180
+ ],
181
+ });
182
+ // Terminal toggle/split as menu accelerators: Chromium swallows Cmd+D before it reaches the renderer
183
+ // (Cmd+A and friends arrive fine), so route the split — and the toggles — through the menu instead.
184
+ menuTemplate.push({
185
+ label: "Terminal",
186
+ submenu: [
187
+ { label: "Toggle Terminal", accelerator: "Control+`", click: () => mainWindow?.webContents.send("monacori:terminal-toggle") },
188
+ { label: "Toggle Terminal (F12)", accelerator: "Alt+F12", click: () => mainWindow?.webContents.send("monacori:terminal-toggle") },
189
+ { label: "Split Terminal", accelerator: "CommandOrControl+D", click: () => mainWindow?.webContents.send("monacori:terminal-split") },
190
+ { type: "separator" },
191
+ { label: "Focus Previous Pane", accelerator: "CommandOrControl+Alt+[", click: () => mainWindow?.webContents.send("monacori:terminal-pane-focus", -1) },
192
+ { label: "Focus Next Pane", accelerator: "CommandOrControl+Alt+]", click: () => mainWindow?.webContents.send("monacori:terminal-pane-focus", 1) },
193
+ { label: "Rename Pane", accelerator: "F2", click: () => mainWindow?.webContents.send("monacori:terminal-pane-rename") },
194
+ ],
195
+ });
196
+ Menu.setApplicationMenu(Menu.buildFromTemplate(menuTemplate));
23
197
  const appIcon = nativeImage.createFromPath(iconPath);
24
198
  if (process.platform === "darwin" && app.dock && !appIcon.isEmpty()) {
25
199
  app.dock.setIcon(appIcon);
26
200
  }
27
- const firstBuild = writeReviewFile(options);
28
- currentSignature = firstBuild.signature;
29
201
  mainWindow = new BrowserWindow({
30
202
  width: 1440,
31
203
  height: 960,
@@ -37,6 +209,7 @@ app.whenReady().then(async () => {
37
209
  backgroundColor: "#2b2b2b",
38
210
  autoHideMenuBar: true,
39
211
  webPreferences: {
212
+ preload: preloadPath,
40
213
  contextIsolation: true,
41
214
  nodeIntegration: false,
42
215
  sandbox: true,
@@ -45,10 +218,24 @@ app.whenReady().then(async () => {
45
218
  });
46
219
  mainWindow.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
47
220
  mainWindow.once("ready-to-show", () => mainWindow?.show());
48
- await mainWindow.loadFile(reviewPath());
49
- if (options.watch) {
50
- refreshTimer = setInterval(refreshIfChanged, WATCH_INTERVAL_MS);
51
- }
221
+ // Paint the window with a spinner immediately, then build the (potentially heavy) review off the first
222
+ // paint and swap it in. The first build used to run synchronously *before* the window existed, so the
223
+ // screen stayed blank for the first few seconds of startup; now the user sees a loading screen instead.
224
+ await mainWindow.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(LOADING_HTML));
225
+ setImmediate(() => {
226
+ try {
227
+ const firstBuild = writeReviewFile(options);
228
+ currentSignature = firstBuild.signature;
229
+ if (mainWindow && !mainWindow.isDestroyed())
230
+ void mainWindow.loadFile(reviewPath());
231
+ if (options.watch)
232
+ refreshTimer = setInterval(refreshIfChanged, WATCH_INTERVAL_MS);
233
+ }
234
+ catch (error) {
235
+ console.error(error instanceof Error ? error.message : String(error));
236
+ app.quit();
237
+ }
238
+ });
52
239
  }).catch((error) => {
53
240
  console.error(error instanceof Error ? error.message : String(error));
54
241
  app.quit();
@@ -56,6 +243,13 @@ app.whenReady().then(async () => {
56
243
  app.on("window-all-closed", () => {
57
244
  if (refreshTimer)
58
245
  clearInterval(refreshTimer);
246
+ for (const t of terms.values()) {
247
+ try {
248
+ t.kill();
249
+ }
250
+ catch { /* already exited */ }
251
+ }
252
+ terms.clear();
59
253
  app.quit();
60
254
  });
61
255
  async function refreshIfChanged() {
@@ -83,8 +277,13 @@ function writeReviewFile(input) {
83
277
  includeUntracked: input.includeUntracked,
84
278
  context: input.context,
85
279
  title: "monacori",
280
+ ignoreWhitespace: input.ignoreWhitespace,
281
+ lazyLoad: true, // Electron streams per-file bodies/source over IPC (monacori:get-file / get-source)
282
+ app: true, // gate the integrated terminal (xterm) into the HTML — Electron only
86
283
  });
87
284
  writeFileSync(reviewPath(), build.html);
285
+ currentBodies = build.lazyBodies ?? [];
286
+ currentSourceData = build.lazySourceData ?? "[]";
88
287
  return { signature: build.signature };
89
288
  }
90
289
  function reviewPath() {
@@ -100,6 +299,7 @@ function parseArgs(args) {
100
299
  includeUntracked: args.includes("--include-untracked"),
101
300
  context: contextValue ? parsePositiveInteger(contextValue, "--context") : 12,
102
301
  watch: !args.includes("--no-watch"),
302
+ ignoreWhitespace: args.includes("--ignore-whitespace"),
103
303
  };
104
304
  }
105
305
  function readOption(args, name) {
@@ -0,0 +1,6 @@
1
+ export declare function readViewerAsset(name: string): string;
2
+ export declare function diff2HtmlCss(): string;
3
+ export declare function diffCss(): string;
4
+ export declare function diffScript(): string;
5
+ export declare function xtermCss(): string;
6
+ export declare function xtermScript(): string;
package/dist/assets.js ADDED
@@ -0,0 +1,51 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { createRequire } from "node:module";
5
+ const nodeRequire = createRequire(import.meta.url);
6
+ const viewerAssetCache = new Map();
7
+ // Client viewer script/stylesheet live in sibling files (copied to dist/ at build) so this
8
+ // module stays small and the client code can use template literals freely (no String.raw).
9
+ export function readViewerAsset(name) {
10
+ let cached = viewerAssetCache.get(name);
11
+ if (cached === undefined) {
12
+ cached = readFileSync(join(dirname(fileURLToPath(import.meta.url)), name), "utf8");
13
+ viewerAssetCache.set(name, cached);
14
+ }
15
+ return cached;
16
+ }
17
+ export function diff2HtmlCss() {
18
+ try {
19
+ return readFileSync(nodeRequire.resolve("diff2html/bundles/css/diff2html.min.css"), "utf8");
20
+ }
21
+ catch {
22
+ return "";
23
+ }
24
+ }
25
+ export function diffCss() {
26
+ return readViewerAsset("viewer.css");
27
+ }
28
+ export function diffScript() {
29
+ return readViewerAsset("viewer.client.js");
30
+ }
31
+ // xterm.js (terminal renderer) for the integrated terminal panel. UMD bundles that expose
32
+ // window.Terminal + window.FitAddon when inlined. Resolved from node_modules like diff2HtmlCss();
33
+ // pure JS, no native binding — the pty itself lives in the main process via node-pty.
34
+ export function xtermCss() {
35
+ try {
36
+ return readFileSync(nodeRequire.resolve("@xterm/xterm/css/xterm.css"), "utf8");
37
+ }
38
+ catch {
39
+ return "";
40
+ }
41
+ }
42
+ export function xtermScript() {
43
+ try {
44
+ const core = readFileSync(nodeRequire.resolve("@xterm/xterm/lib/xterm.js"), "utf8");
45
+ const fit = readFileSync(nodeRequire.resolve("@xterm/addon-fit/lib/addon-fit.js"), "utf8");
46
+ return core + "\n" + fit;
47
+ }
48
+ catch {
49
+ return "";
50
+ }
51
+ }
@@ -0,0 +1,13 @@
1
+ import type { DiffReviewBuild } from "./types.js";
2
+ export declare function buildDiffReview(input: {
3
+ base?: string;
4
+ staged: boolean;
5
+ includeUntracked: boolean;
6
+ context: number;
7
+ title: string;
8
+ watch?: boolean;
9
+ ignoreWhitespace?: boolean;
10
+ lazy?: boolean;
11
+ lazyLoad?: boolean;
12
+ app?: boolean;
13
+ }): DiffReviewBuild;
package/dist/build.js ADDED
@@ -0,0 +1,77 @@
1
+ import { createHash } from "node:crypto";
2
+ import { basename } from "node:path";
3
+ import { isGitRepository } from "./git.js";
4
+ import { collectHttpEnvironments, collectReviewFileStates, collectSourceFiles, parseUnifiedDiff, readUnifiedDiff } from "./diff.js";
5
+ import { renderDiff2Html } from "./highlight.js";
6
+ import { diffSubtitle, renderDiffHtml, renderNotGitRepoHtml, shouldLazyRender, splitDiffForLazy } from "./render.js";
7
+ export function buildDiffReview(input) {
8
+ if (!isGitRepository(process.cwd())) {
9
+ return {
10
+ html: renderNotGitRepoHtml(process.cwd()),
11
+ files: 0,
12
+ hunks: 0,
13
+ signature: "not-a-git-repo",
14
+ generatedAt: new Date().toISOString(),
15
+ };
16
+ }
17
+ const diffText = readUnifiedDiff({
18
+ base: input.base,
19
+ staged: input.staged,
20
+ context: input.context,
21
+ includeUntracked: input.includeUntracked,
22
+ ignoreWhitespace: input.ignoreWhitespace,
23
+ });
24
+ const files = parseUnifiedDiff(diffText);
25
+ const sourceFiles = collectSourceFiles(files);
26
+ const fileStates = collectReviewFileStates(files, sourceFiles);
27
+ const httpEnvironments = collectHttpEnvironments(process.cwd());
28
+ const hunks = files.reduce((sum, file) => sum + file.hunks.length, 0);
29
+ const generatedAt = new Date().toISOString();
30
+ const diffHtml = renderDiff2Html(diffText);
31
+ const totalLines = files.reduce((sum, file) => sum + file.hunks.reduce((t, h) => t + h.lines.length, 0), 0);
32
+ // lazy-LOAD (Phase 2) serves each file body + source on demand instead of embedding them; it implies
33
+ // lazy (shells). The transport opts in (serve/Electron pass lazyLoad:true) and we honor it regardless
34
+ // of size: it used to be gated by `&& big`, but a mid-size repo (dozens of files, just under the
35
+ // threshold) then embedded a 600KB+ source blob + every diff body inline, forcing the renderer to
36
+ // parse + lay out a huge document before the first click — the startup "freeze". Standalone (no
37
+ // transport) has no server, so it auto-decides by size and lazy-materializes from embedded islands.
38
+ const big = shouldLazyRender(files.length, totalLines);
39
+ const lazyLoad = input.lazyLoad ?? false;
40
+ const lazy = lazyLoad || (input.lazy ?? big);
41
+ const diffSplit = lazy ? splitDiffForLazy(diffHtml, files) : { container: diffHtml, islands: "", bodies: [] };
42
+ const signature = createHash("sha1")
43
+ .update(diffText)
44
+ .update("\n")
45
+ .update(sourceFiles.map((file) => `${file.path}\0${file.size}\0${file.embedded ? file.content : file.skippedReason ?? ""}`).join("\n"))
46
+ .update("\n")
47
+ .update(JSON.stringify(httpEnvironments))
48
+ .digest("hex");
49
+ const html = renderDiffHtml({
50
+ files,
51
+ diffHtml: diffSplit.container,
52
+ diffIslands: lazyLoad ? "" : diffSplit.islands,
53
+ lazy,
54
+ lazyLoad,
55
+ sourceFiles,
56
+ fileStates,
57
+ httpEnvironments,
58
+ title: input.title,
59
+ subtitle: diffSubtitle(input),
60
+ projectName: basename(process.cwd()),
61
+ projectPath: process.cwd(),
62
+ watch: Boolean(input.watch),
63
+ ignoreWhitespace: Boolean(input.ignoreWhitespace),
64
+ app: Boolean(input.app),
65
+ signature,
66
+ generatedAt,
67
+ });
68
+ return {
69
+ html,
70
+ files: files.length,
71
+ hunks,
72
+ signature,
73
+ generatedAt,
74
+ lazyBodies: diffSplit.bodies,
75
+ lazySourceData: lazyLoad ? JSON.stringify(sourceFiles) : undefined,
76
+ };
77
+ }