@happy-nut/monacori 0.1.9 → 0.1.11

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
@@ -71,6 +71,53 @@ mo --base main # compare against a specific base
71
71
  mo --context 20 # show more context around each hunk
72
72
  ```
73
73
 
74
+ ## Development
75
+
76
+ Working on monacori itself? The globally-installed `mo` runs the **published** package, not your
77
+ checkout — local edits won't appear until you build and run locally.
78
+
79
+ Run your checkout directly (builds, then launches in the foreground with DevTools open):
80
+
81
+ ```bash
82
+ npm run dev
83
+ ```
84
+
85
+ This reviews the monacori repo itself. To point a local build at another project:
86
+
87
+ ```bash
88
+ MONACORI_DEV=1 node /path/to/monacori/bin/monacori.js --foreground
89
+ ```
90
+
91
+ **Which build is running?** A dev build titles its window `monacori (dev)` and opens DevTools, and
92
+ every launch prints its app path — so a local checkout is distinguishable from the installed package
93
+ even when their version numbers match:
94
+
95
+ ```text
96
+ monacori: launching /…/repos/monacori/dist/app-main.js # local checkout
97
+ monacori: launching /…/lib/node_modules/@happy-nut/monacori/dist/app-main.js # installed package
98
+ ```
99
+
100
+ Prefer the `mo` command pointed at your checkout? `npm link` once, then rebuild after each change:
101
+
102
+ ```bash
103
+ npm link # global `mo` now runs this checkout
104
+ npm run build # rebuild dist/ after editing src/
105
+ npm unlink -g @happy-nut/monacori # restore the published `mo`
106
+ ```
107
+
108
+ `src/viewer.client.js` and `src/viewer.css` are copied (not compiled) into `dist/` by the build, so
109
+ re-run `npm run build` (or `npm run dev`) after editing them.
110
+
111
+ ### Tests
112
+
113
+ ```bash
114
+ npm test
115
+ ```
116
+
117
+ `npm test` builds, then runs the jsdom regression suite (`test/*.test.mjs`) against the built `dist/`.
118
+ It guards the core user flows end to end — see [test/USER_FLOWS.md](test/USER_FLOWS.md) — and the same
119
+ suite gates every release.
120
+
74
121
  ## Local State
75
122
 
76
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.
package/dist/app-main.js CHANGED
@@ -4,7 +4,13 @@ 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
+ import { sanitizeTerminalEnv } from "./util.js";
7
8
  import { spawn as spawnPty } from "node-pty";
9
+ // `npm run dev` sets MONACORI_DEV=1 so a locally-built app announces itself — a window-title suffix
10
+ // plus a boot log with its on-disk path — making it obvious whether `mo` launched THIS checkout or
11
+ // the globally-installed package (their version numbers can be identical; the path is the tell).
12
+ const DEV_BUILD = process.env.MONACORI_DEV === "1";
13
+ const APP_TITLE = DEV_BUILD ? "monacori (dev)" : "monacori";
8
14
  const FLOW_DIR = ".monacori";
9
15
  const REVIEW_FILE = "app-review.html";
10
16
  const WATCH_INTERVAL_MS = 1000;
@@ -61,7 +67,7 @@ ipcMain.handle("monacori:pty-spawn", (_event, size) => {
61
67
  cols: size?.cols ?? 80,
62
68
  rows: size?.rows ?? 24,
63
69
  cwd: options.root,
64
- env: process.env,
70
+ env: sanitizeTerminalEnv(process.env),
65
71
  });
66
72
  terms.set(id, t);
67
73
  // mainWindow?. only guards null, NOT a *destroyed* window — sending to a closed window's webContents
@@ -137,6 +143,9 @@ if (!existsSync(options.root)) {
137
143
  throw new Error(`Repository path does not exist: ${options.root}`);
138
144
  }
139
145
  app.whenReady().then(async () => {
146
+ // Foreground (`npm run dev` / `mo --foreground`) surfaces this in the terminal; detached `mo` drops
147
+ // it. Either way the path disambiguates a local checkout from the installed package.
148
+ console.error(`[monacori] ${DEV_BUILD ? "DEV build" : "build"} — ${app.getAppPath()} (electron ${process.versions.electron})`);
140
149
  process.chdir(options.root);
141
150
  mkdirSync(FLOW_DIR, { recursive: true });
142
151
  // Keep the standard Edit/Window roles so Cmd+C/V/X/A (copy comments into prompts) and Cmd+Q work.
@@ -208,7 +217,7 @@ app.whenReady().then(async () => {
208
217
  minWidth: 960,
209
218
  minHeight: 640,
210
219
  show: false,
211
- title: "monacori",
220
+ title: APP_TITLE,
212
221
  icon: iconPath,
213
222
  backgroundColor: "#2b2b2b",
214
223
  autoHideMenuBar: true,
@@ -221,7 +230,11 @@ app.whenReady().then(async () => {
221
230
  },
222
231
  });
223
232
  mainWindow.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
224
- mainWindow.once("ready-to-show", () => mainWindow?.show());
233
+ mainWindow.once("ready-to-show", () => {
234
+ mainWindow?.show();
235
+ if (DEV_BUILD)
236
+ mainWindow?.webContents.openDevTools({ mode: "detach" });
237
+ });
225
238
  // Paint the window with a spinner immediately, then build the (potentially heavy) review off the first
226
239
  // paint and swap it in. The first build used to run synchronously *before* the window existed, so the
227
240
  // screen stayed blank for the first few seconds of startup; now the user sees a loading screen instead.
@@ -289,7 +302,7 @@ function writeReviewFile(input) {
289
302
  staged: input.staged,
290
303
  includeUntracked: input.includeUntracked,
291
304
  context: input.context,
292
- title: "monacori",
305
+ title: APP_TITLE,
293
306
  ignoreWhitespace: input.ignoreWhitespace,
294
307
  lazyLoad: true, // Electron streams per-file bodies/source over IPC (monacori:get-file / get-source)
295
308
  app: true, // gate the integrated terminal (xterm) into the HTML — Electron only
package/dist/commands.js CHANGED
@@ -124,6 +124,12 @@ function launchReviewApp(args) {
124
124
  if (args.includes("--no-watch"))
125
125
  appArgs.push("--no-watch");
126
126
  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.
130
+ if (process.env.MONACORI_DEV === "1") {
131
+ console.error(`monacori: launching ${appMainPath()}`);
132
+ }
127
133
  if (args.includes("--foreground")) {
128
134
  const result = spawnSync(electronBinary, appArgs, { stdio: "inherit" });
129
135
  process.exit(result.status ?? 0);
package/dist/diff.js CHANGED
@@ -3,8 +3,9 @@ import { existsSync, readFileSync, statSync } from "node:fs";
3
3
  import { basename, join } from "node:path";
4
4
  import { FLOW_DIR, IMAGE_MAX_BYTES, SOURCE_MAX_FILE_BYTES, SOURCE_MAX_FILES, SOURCE_MAX_TOTAL_BYTES } from "./constants.js";
5
5
  import { formatBytes, hashText, isLikelyBinary, languageForPath, stripDiffPath } from "./util.js";
6
- import { git } from "./git.js";
6
+ import { git, repoRoot } from "./git.js";
7
7
  export function readUnifiedDiff(options) {
8
+ const root = repoRoot();
8
9
  const args = ["diff", "--no-ext-diff", "--find-renames", `--unified=${options.context}`];
9
10
  if (options.ignoreWhitespace)
10
11
  args.push("--ignore-all-space");
@@ -16,7 +17,7 @@ export function readUnifiedDiff(options) {
16
17
  }
17
18
  args.push("--");
18
19
  const result = spawnSync("git", args, {
19
- cwd: process.cwd(),
20
+ cwd: root,
20
21
  encoding: "utf8",
21
22
  maxBuffer: 1024 * 1024 * 100,
22
23
  });
@@ -25,18 +26,18 @@ export function readUnifiedDiff(options) {
25
26
  }
26
27
  const chunks = [result.stdout ?? ""];
27
28
  if (options.includeUntracked && !options.staged) {
28
- chunks.push(readUntrackedDiff(options.context));
29
+ chunks.push(readUntrackedDiff(options.context, root));
29
30
  }
30
31
  return chunks.filter(Boolean).join("\n");
31
32
  }
32
- function readUntrackedDiff(context) {
33
- const files = git(process.cwd(), ["ls-files", "--others", "--exclude-standard"])
33
+ function readUntrackedDiff(context, root) {
34
+ const files = git(root, ["ls-files", "--others", "--exclude-standard"])
34
35
  .split(/\r?\n/)
35
36
  .map((line) => line.trim())
36
37
  .filter((line) => line && !line.startsWith(`${FLOW_DIR}/`));
37
38
  const chunks = [];
38
39
  for (const file of files) {
39
- const absolute = join(process.cwd(), file);
40
+ const absolute = join(root, file);
40
41
  if (!existsSync(absolute) || !statSync(absolute).isFile()) {
41
42
  continue;
42
43
  }
@@ -225,14 +226,15 @@ export function collectSourceFiles(diffFiles) {
225
226
  }
226
227
  changedLinesByPath.set(file.displayPath, nums);
227
228
  }
228
- const vcsByPath = gitStatusMap(process.cwd());
229
+ const root = repoRoot();
230
+ const vcsByPath = gitStatusMap(root);
229
231
  for (const file of diffFiles) {
230
232
  const kind = vcsByPath.get(file.displayPath);
231
233
  if (kind)
232
234
  file.vcs = kind; // color the Changes list from the same status map
233
235
  }
234
236
  const paths = new Set();
235
- const gitFiles = git(process.cwd(), ["ls-files", "--cached", "--others", "--exclude-standard"]);
237
+ const gitFiles = git(root, ["ls-files", "--cached", "--others", "--exclude-standard"]);
236
238
  for (const file of gitFiles.split(/\r?\n/)) {
237
239
  const path = file.trim();
238
240
  if (path && isSourceCandidate(path)) {
@@ -248,7 +250,7 @@ export function collectSourceFiles(diffFiles) {
248
250
  let embeddedFiles = 0;
249
251
  let embeddedBytes = 0;
250
252
  for (const path of Array.from(paths).sort((a, b) => a.localeCompare(b))) {
251
- const absolute = join(process.cwd(), path);
253
+ const absolute = join(root, path);
252
254
  const base = {
253
255
  path,
254
256
  name: basename(path),
package/dist/git.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { GitSnapshot } from "./types.js";
2
2
  export declare function isGitRepository(root: string): boolean;
3
3
  export declare function git(root: string, args: string[]): string;
4
+ export declare function repoRoot(cwd?: string): string;
4
5
  export declare function readGitSnapshot(root: string): GitSnapshot;
package/dist/git.js CHANGED
@@ -13,6 +13,15 @@ export function git(root, args) {
13
13
  }
14
14
  return (result.stdout ?? "").trim();
15
15
  }
16
+ // Resolve the repository root. `git diff` and `git ls-files` print paths relative to it, and the
17
+ // desktop tree shows them as-is — so every filesystem read of those paths must resolve against the
18
+ // SAME root, not process.cwd(). When `mo` runs from a monorepo subdirectory (cwd != root), joining a
19
+ // repo-root-relative path onto cwd points at a file that doesn't exist, which surfaced as a diff with
20
+ // no source preview ("file is not present in the working tree"). Falls back to cwd outside a repo.
21
+ export function repoRoot(cwd = process.cwd()) {
22
+ const top = git(cwd, ["rev-parse", "--show-toplevel"]);
23
+ return top || cwd;
24
+ }
16
25
  export function readGitSnapshot(root) {
17
26
  return {
18
27
  branch: git(root, ["branch", "--show-current"]),
package/dist/util.d.ts CHANGED
@@ -16,3 +16,6 @@ export declare function jsonForScript(value: unknown): string;
16
16
  export declare function escapeAttr(value: string): string;
17
17
  export declare function formatBytes(bytes: number): string;
18
18
  export declare function listRecentFiles(dir: string, limit: number): string[];
19
+ export declare function sanitizeTerminalEnv(env: NodeJS.ProcessEnv): {
20
+ [key: string]: string;
21
+ };
package/dist/util.js CHANGED
@@ -142,3 +142,21 @@ export function listRecentFiles(dir, limit) {
142
142
  .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)
143
143
  .slice(0, limit);
144
144
  }
145
+ // The integrated terminal should behave like the user's own login shell — not like a child of however
146
+ // monacori was launched. When started through npm (`npm run dev`, or a global install run via an npm
147
+ // shim), npm injects npm_config_* / npm_lifecycle_* / npm_package_* vars into our process. Inheriting
148
+ // them into the pty leaks our run's npm config into the user's shell and, with nvm, triggers:
149
+ // "nvm is not compatible with the npm_config_prefix environment variable …"
150
+ // Strip every npm_*-injected var (npm_config_prefix is the one nvm rejects) and drop undefined holes,
151
+ // so the shell starts clean. Returns a fresh object; the input is not mutated.
152
+ export function sanitizeTerminalEnv(env) {
153
+ const out = {};
154
+ for (const [key, value] of Object.entries(env)) {
155
+ if (value === undefined)
156
+ continue;
157
+ if (key.startsWith("npm_"))
158
+ continue;
159
+ out[key] = value;
160
+ }
161
+ return out;
162
+ }
@@ -124,7 +124,16 @@ var I18N = JSON.parse(document.getElementById('i18n-data')?.textContent || '{}')
124
124
  // app restart; file:// localStorage doesn't); browser/serve falls back to localStorage. persistRead
125
125
  // returns the bridge value (native) if present, else undefined so callers parse localStorage themselves.
126
126
  function persistRead(key) {
127
- try { if (window.monacoriSettings && window.monacoriSettings.all && key in window.monacoriSettings.all) return window.monacoriSettings.all[key]; } catch (e) {}
127
+ // window.monacoriSettings.all crosses Electron's contextBridge, which DEEP-FREEZES every value it
128
+ // exposes. Returning that frozen object/array directly breaks callers that mutate the result —
129
+ // reviewComments.push(...) and mergePrompts[kind]=... both throw "object is not extensible". Hand
130
+ // back a mutable deep copy so the persisted snapshot is a starting point, not a locked one.
131
+ try {
132
+ if (window.monacoriSettings && window.monacoriSettings.all && key in window.monacoriSettings.all) {
133
+ var v = window.monacoriSettings.all[key];
134
+ return v && typeof v === 'object' ? JSON.parse(JSON.stringify(v)) : v;
135
+ }
136
+ } catch (e) {}
128
137
  return undefined;
129
138
  }
130
139
  function persistSave(key, value) {
@@ -1847,7 +1856,7 @@ function refreshComments() {
1847
1856
  if (composerState) {
1848
1857
  var composerFocusTries = 0;
1849
1858
  var tryFocusComposer = function () {
1850
- var ta = document.querySelector('.mc-composer .mc-input');
1859
+ var ta = activeComposerInput();
1851
1860
  if (!ta) return true; // composer gone — stop retrying
1852
1861
  if (document.activeElement === ta) return true; // already focused — done
1853
1862
  try { ta.focus({ preventScroll: true }); } catch (e) { try { ta.focus(); } catch (e2) {} }
@@ -1879,9 +1888,24 @@ function closeComposer() {
1879
1888
  composerState = null;
1880
1889
  refreshComments();
1881
1890
  }
1891
+ // The composer is injected into BOTH the diff and source views (refreshComments renders comments in
1892
+ // each), but only one view is on screen at a time — the other lives inside a `.hidden` container with
1893
+ // its own, empty textarea. Pick the textarea in the *visible* view so save/auto-focus never grab the
1894
+ // off-screen duplicate. This was the "Comment doesn't save" bug: clicking Save ran
1895
+ // document.querySelector('.mc-composer .mc-input'), which returns the hidden diff-view textarea first
1896
+ // (it precedes #source-viewer in the DOM), so addComment got its empty value and bailed.
1897
+ function activeComposerInput() {
1898
+ var inputs = document.querySelectorAll('.mc-composer .mc-input');
1899
+ for (var i = 0; i < inputs.length; i++) {
1900
+ if (inputs[i].closest('#diff-view') && !isDiffViewVisible()) continue;
1901
+ if (inputs[i].closest('#source-viewer') && !isSourceViewerVisible()) continue;
1902
+ return inputs[i];
1903
+ }
1904
+ return inputs[0] || null;
1905
+ }
1882
1906
  function saveComposer(ta) {
1883
1907
  if (!composerState) return;
1884
- var box = ta || document.querySelector('.mc-composer .mc-input');
1908
+ var box = ta || activeComposerInput();
1885
1909
  if (!box) return;
1886
1910
  addComment(composerState.kind, composerState.path, composerState.line, composerState.code, box.value);
1887
1911
  composerState = null;
package/dist/viewer.css CHANGED
@@ -532,6 +532,9 @@ summary.tree-focus { background: var(--bg); }
532
532
  file's diff body extends all the way down. */
533
533
  #diff-view:not(.hidden) { flex: 1 1 auto; display: flex; flex-direction: column; min-height: 0; }
534
534
  #diff-view .diff2html-container { flex: 1 1 auto; display: flex; flex-direction: column; min-height: 0; }
535
+ /* Source view mirrors the diff view: fill the content column so the source-body (its last child, after
536
+ the tabs + toolbar) reaches the bottom even for short files, instead of floating at the top. */
537
+ #source-viewer:not(.hidden) { flex: 1 1 auto; display: flex; flex-direction: column; min-height: 0; }
535
538
  .diff2html-container .d2h-wrapper { flex: 1 1 auto; display: flex; flex-direction: column; }
536
539
  .diff2html-container .d2h-file-wrapper:last-child { flex: 1 1 auto; }
537
540
  .diff2html-container .d2h-file-wrapper:last-child .d2h-files-diff { height: 100%; }
@@ -639,10 +642,13 @@ h1 { margin: 0; font-size: 18px; }
639
642
  .source-tab-close:hover { background: var(--line); color: var(--text); }
640
643
  .source-body {
641
644
  border: 1px solid var(--border);
642
- border-radius: 8px;
643
645
  overflow: auto;
644
646
  background: var(--panel);
645
647
  user-select: text;
648
+ /* Square edges flush to the chrome (no rounded corners), and fill the content column so a short
649
+ file still reaches the bottom instead of floating at the top — IntelliJ-style. */
650
+ flex: 1 1 auto;
651
+ min-height: 0;
646
652
  }
647
653
  /* Extend the line-number gutter (and its divider) to the bottom of the panel so it never stops at the
648
654
  last line with an empty strip + cut-off border below it — one continuous gutter. The .num cell is 58px
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@happy-nut/monacori",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Validation control plane for AI-generated code changes.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -29,10 +29,13 @@
29
29
  },
30
30
  "scripts": {
31
31
  "build": "tsc -p tsconfig.json && node scripts/copy-viewer-assets.mjs",
32
+ "dev": "npm run build && MONACORI_DEV=1 node bin/monacori.js --foreground",
32
33
  "icon": "node scripts/gen-icon.mjs",
33
34
  "postinstall": "node scripts/patch-electron-name.mjs && node scripts/fix-pty-spawn-helper.mjs",
34
35
  "prepare": "npm run build",
35
- "smoke": "npm run build && node dist/cli.js --help"
36
+ "smoke": "npm run build && node dist/cli.js --help",
37
+ "pretest": "npm run build",
38
+ "test": "node --test test/*.test.mjs"
36
39
  },
37
40
  "keywords": [
38
41
  "ai",
@@ -45,6 +48,7 @@
45
48
  "license": "MIT",
46
49
  "devDependencies": {
47
50
  "@types/node": "^22.15.21",
51
+ "jsdom": "^29.1.1",
48
52
  "typescript": "^5.8.3"
49
53
  },
50
54
  "engines": {