@happy-nut/monacori 0.1.26 → 0.1.27

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
@@ -2,9 +2,9 @@
2
2
 
3
3
  **A local desktop review workspace for AI-generated code changes.**
4
4
 
5
- Run `mo` after an AI edits your repository. monacori opens a side-by-side diff, lets you attach line-level questions or change requests, and bundles that feedback back into the AI CLI (command-line interface) session running in the built-in terminal.
5
+ Run `mo` after an AI edits your repository. monacori opens the real local diff, lets you attach line-level questions or change requests, and turns that feedback into a grounded follow-up prompt for the AI CLI session running in the built-in terminal.
6
6
 
7
- ![monacori reviewing a diff, adding a change request, and targeting the built-in terminal](assets/monacori-demo.gif)
7
+ ![monacori reviewing an AI-generated diff, adding a line-level change request, merging the prompt, and sending it to the integrated terminal](assets/monacori-core-flow.gif)
8
8
 
9
9
  ## Why monacori
10
10
 
@@ -16,6 +16,15 @@ AI coding tools are fast, but their "done" message is not a review. monacori giv
16
16
  - Send all reviewer comments, with file paths and code context, into `claude`, `codex`, or another terminal session without copy-paste.
17
17
  - Keep all generated review state local, plain, and inspectable under `.monacori/`.
18
18
 
19
+ ## Core Flow
20
+
21
+ monacori's core value is a grounded correction loop:
22
+
23
+ 1. Review the exact Git diff produced by an AI coding tool.
24
+ 2. Attach a question or change request on the relevant line.
25
+ 3. Merge those comments into a prompt that includes file paths, line numbers, and code context.
26
+ 4. Send the prompt into the integrated terminal so the next AI turn starts from reviewed evidence, not a chat summary.
27
+
19
28
  ## Workflow
20
29
 
21
30
  1. Let an AI coding tool make changes in your repository.
@@ -89,6 +98,12 @@ npm unlink -g @happy-nut/monacori # restore the published `mo`
89
98
  `src/viewer.client.js` and `src/viewer.css` are copied (not compiled) into `dist/` by the build, so
90
99
  re-run `npm run build` (or `npm run dev`) after editing them.
91
100
 
101
+ Regenerate the README demo GIF from a temporary sample repository:
102
+
103
+ ```bash
104
+ npm run demo:gif
105
+ ```
106
+
92
107
  ### Tests
93
108
 
94
109
  ```bash
Binary file
@@ -1,8 +1,24 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
1
3
  export type RelaunchTarget = {
2
4
  relaunch(options?: {
3
5
  args?: string[];
4
6
  }): void;
5
7
  exit(exitCode?: number): void;
6
8
  };
9
+ type SpawnFn = typeof spawn;
10
+ type SpawnSyncFn = typeof spawnSync;
11
+ type ExistsFn = typeof existsSync;
12
+ type RelaunchDeps = {
13
+ spawn?: SpawnFn;
14
+ spawnSync?: SpawnSyncFn;
15
+ existsSync?: ExistsFn;
16
+ platform?: NodeJS.Platform;
17
+ env?: NodeJS.ProcessEnv;
18
+ };
7
19
  export declare function relaunchArgsForCwd(argv: string[], cwd: string): string[];
8
- export declare function relaunchUpdatedApp(app: RelaunchTarget, argv: string[], cwd: string): void;
20
+ export declare function cliArgsForCwd(argv: string[], cwd: string): string[];
21
+ export declare function globalMoBinCandidates(prefix: string, platform: NodeJS.Platform): string[];
22
+ export declare function resolveGlobalMoBin(deps?: RelaunchDeps): string | null;
23
+ export declare function relaunchUpdatedApp(app: RelaunchTarget, argv: string[], cwd: string, deps?: RelaunchDeps): void;
24
+ export {};
@@ -1,3 +1,6 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
1
4
  export function relaunchArgsForCwd(argv, cwd) {
2
5
  const args = argv.slice(1);
3
6
  const cwdIndex = args.indexOf("--cwd");
@@ -12,7 +15,63 @@ export function relaunchArgsForCwd(argv, cwd) {
12
15
  }
13
16
  return args;
14
17
  }
15
- export function relaunchUpdatedApp(app, argv, cwd) {
18
+ export function cliArgsForCwd(argv, cwd) {
19
+ const args = ["--cwd", cwd];
20
+ if (argv.includes("--no-watch"))
21
+ args.push("--no-watch");
22
+ return args;
23
+ }
24
+ export function globalMoBinCandidates(prefix, platform) {
25
+ if (platform === "win32") {
26
+ return [join(prefix, "mo.cmd"), join(prefix, "mo")];
27
+ }
28
+ return [join(prefix, "bin", "mo"), join(prefix, "mo")];
29
+ }
30
+ export function resolveGlobalMoBin(deps = {}) {
31
+ const spawnSyncImpl = deps.spawnSync ?? spawnSync;
32
+ const existsImpl = deps.existsSync ?? existsSync;
33
+ const platform = deps.platform ?? process.platform;
34
+ const result = spawnSyncImpl("npm", ["prefix", "-g"], {
35
+ encoding: "utf8",
36
+ shell: true,
37
+ env: deps.env ?? process.env,
38
+ });
39
+ if (result.status !== 0 || typeof result.stdout !== "string")
40
+ return null;
41
+ const prefix = result.stdout.trim().split(/\r?\n/).pop()?.trim();
42
+ if (!prefix)
43
+ return null;
44
+ return globalMoBinCandidates(prefix, platform).find((candidate) => existsImpl(candidate)) ?? null;
45
+ }
46
+ function spawnDetached(spawnImpl, command, args, cwd, env, shell) {
47
+ const child = spawnImpl(command, args, { cwd, detached: true, stdio: "ignore", env, shell });
48
+ child.unref();
49
+ return child;
50
+ }
51
+ export function relaunchUpdatedApp(app, argv, cwd, deps = {}) {
52
+ const spawnImpl = deps.spawn ?? spawn;
53
+ const env = deps.env ?? process.env;
54
+ const cliArgs = cliArgsForCwd(argv, cwd);
55
+ const moBin = resolveGlobalMoBin(deps);
56
+ if (moBin) {
57
+ try {
58
+ spawnDetached(spawnImpl, moBin, cliArgs, cwd, env, false);
59
+ app.exit(0);
60
+ return;
61
+ }
62
+ catch {
63
+ // Fall through to npm exec; the bin path can exist but still fail to spawn if a manager rewrites it.
64
+ }
65
+ }
66
+ try {
67
+ spawnDetached(spawnImpl, "npm", ["exec", "-g", "--", "mo", ...cliArgs], cwd, env, true);
68
+ app.exit(0);
69
+ return;
70
+ }
71
+ catch {
72
+ // Last resort only. app.relaunch() uses the current executable path; after a rebrand update that path
73
+ // may be the stale Electron.app/Contents/MacOS/Electron, so prefer the freshly installed CLI above.
74
+ }
16
75
  app.relaunch({ args: relaunchArgsForCwd(argv, cwd) });
17
76
  app.exit(0);
18
77
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@happy-nut/monacori",
3
- "version": "0.1.26",
3
+ "version": "0.1.27",
4
4
  "description": "Validation control plane for AI-generated code changes.",
5
5
  "type": "module",
6
6
  "main": "dist/app-main.js",
@@ -22,6 +22,7 @@
22
22
  "assets",
23
23
  "scripts/patch-electron-name.mjs",
24
24
  "scripts/fix-pty-spawn-helper.mjs",
25
+ "scripts/record-demo.mjs",
25
26
  "README.md",
26
27
  "LICENSE"
27
28
  ],
@@ -34,6 +35,7 @@
34
35
  "icon": "node scripts/gen-icon.mjs",
35
36
  "postinstall": "node scripts/patch-electron-name.mjs && node scripts/fix-pty-spawn-helper.mjs",
36
37
  "prepare": "npm run build",
38
+ "demo:gif": "npm run build && electron scripts/record-demo.mjs",
37
39
  "smoke": "npm run build && node dist/cli.js --help",
38
40
  "pretest": "npm run build",
39
41
  "test": "node --test test/*.test.mjs",
@@ -0,0 +1,373 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { app, BrowserWindow } from "electron";
4
+ import { execFileSync, spawnSync } from "node:child_process";
5
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { dirname, join, resolve } from "node:path";
8
+ import { pathToFileURL } from "node:url";
9
+ import { fileURLToPath } from "node:url";
10
+ import { buildDiffReview } from "../dist/cli.js";
11
+
12
+ const repoRoot = dirname(dirname(fileURLToPath(import.meta.url)));
13
+ const outputGif = resolve(repoRoot, "assets", "monacori-core-flow.gif");
14
+ const width = 1280;
15
+ const height = 760;
16
+ const frameRate = 10;
17
+
18
+ function run(command, args, options = {}) {
19
+ const result = spawnSync(command, args, { encoding: "utf8", ...options });
20
+ if (result.status !== 0) {
21
+ throw new Error((result.stderr || result.stdout || `${command} failed`).trim());
22
+ }
23
+ return result.stdout;
24
+ }
25
+
26
+ function git(cwd, args) {
27
+ execFileSync("git", args, { cwd, stdio: "ignore" });
28
+ }
29
+
30
+ function writeDemoFile(root, path, contents) {
31
+ const fullPath = join(root, path);
32
+ mkdirSync(dirname(fullPath), { recursive: true });
33
+ writeFileSync(fullPath, contents);
34
+ }
35
+
36
+ function createDemoRepo(workRoot) {
37
+ const demoRepo = join(workRoot, "checkout-flow");
38
+ mkdirSync(demoRepo, { recursive: true });
39
+
40
+ writeDemoFile(demoRepo, "README.md", "# Checkout Flow\n\nReview automation for order changes.\n");
41
+ writeDemoFile(
42
+ demoRepo,
43
+ "src/reviewQueue.ts",
44
+ [
45
+ "export type ReviewItem = {",
46
+ " id: string;",
47
+ " title: string;",
48
+ " status: \"open\" | \"archived\";",
49
+ " createdAt: number;",
50
+ " priority?: \"low\" | \"normal\" | \"high\";",
51
+ "};",
52
+ "",
53
+ "export function buildReviewQueue(items: ReviewItem[]): ReviewItem[] {",
54
+ " return items",
55
+ " .filter((item) => item.status === \"open\")",
56
+ " .sort((a, b) => a.createdAt - b.createdAt);",
57
+ "}",
58
+ "",
59
+ ].join("\n"),
60
+ );
61
+ writeDemoFile(
62
+ demoRepo,
63
+ "src/prompt.ts",
64
+ [
65
+ "export function nextPrompt(file: string, line: number): string {",
66
+ " return `Please inspect ${file}:${line}.`;",
67
+ "}",
68
+ "",
69
+ ].join("\n"),
70
+ );
71
+
72
+ git(demoRepo, ["init", "-b", "main"]);
73
+ git(demoRepo, ["config", "user.email", "demo@monacori.local"]);
74
+ git(demoRepo, ["config", "user.name", "Monacori Demo"]);
75
+ git(demoRepo, ["add", "."]);
76
+ git(demoRepo, ["commit", "-m", "baseline review flow"]);
77
+
78
+ writeDemoFile(
79
+ demoRepo,
80
+ "src/reviewQueue.ts",
81
+ [
82
+ "export type ReviewItem = {",
83
+ " id: string;",
84
+ " title: string;",
85
+ " status: \"open\" | \"archived\";",
86
+ " createdAt: number;",
87
+ " priority?: \"low\" | \"normal\" | \"high\";",
88
+ " assignee?: string;",
89
+ "};",
90
+ "",
91
+ "export function buildReviewQueue(items: ReviewItem[], includeArchived = false): ReviewItem[] {",
92
+ " return items",
93
+ " .filter((item) => includeArchived || item.status === \"open\")",
94
+ " .map((item) => ({",
95
+ " ...item,",
96
+ " title: item.title.trim(),",
97
+ " assignee: item.assignee || \"ai-agent\",",
98
+ " }))",
99
+ " .sort((a, b) => String(b.priority).localeCompare(String(a.priority)));",
100
+ "}",
101
+ "",
102
+ ].join("\n"),
103
+ );
104
+ writeDemoFile(
105
+ demoRepo,
106
+ "src/prompt.ts",
107
+ [
108
+ "export function nextPrompt(file: string, line: number, comment: string): string {",
109
+ " return [`Please inspect ${file}:${line}.`, comment].join(\"\\n\\n\");",
110
+ "}",
111
+ "",
112
+ ].join("\n"),
113
+ );
114
+ writeDemoFile(
115
+ demoRepo,
116
+ "docs/review-notes.md",
117
+ [
118
+ "# AI Review Notes",
119
+ "",
120
+ "- Check whether archived items should be opt-in.",
121
+ "- Preserve stable ordering for equal priorities.",
122
+ "",
123
+ ].join("\n"),
124
+ );
125
+
126
+ return demoRepo;
127
+ }
128
+
129
+ function fakePtyPrelude() {
130
+ return String.raw`<script>
131
+ window.monacoriClipboard = { write: function (text) { window.__demoClipboard = String(text || ''); } };
132
+ window.monacoriSettings = { all: { 'monacori-theme': 'dark', 'monacori-locale': 'en' }, set: function (key, value) { this.all[key] = value; } };
133
+ window.monacoriMenu = {};
134
+ window.monacoriPty = (function () {
135
+ var nextId = 0;
136
+ var dataHandlers = [];
137
+ var exitHandlers = [];
138
+ function emit(id, data) {
139
+ dataHandlers.forEach(function (fn) { fn({ id: id, data: data }); });
140
+ }
141
+ return {
142
+ spawn: function () {
143
+ var id = ++nextId;
144
+ setTimeout(function () {
145
+ emit(id, '\x1b[32mcodex\x1b[0m demo-session ready\r\n$ ');
146
+ }, 120);
147
+ return Promise.resolve({ id: id });
148
+ },
149
+ onData: function (fn) { dataHandlers.push(fn); },
150
+ onExit: function (fn) { exitHandlers.push(fn); },
151
+ resize: function () {},
152
+ kill: function (msg) { exitHandlers.forEach(function (fn) { fn({ id: msg && msg.id }); }); },
153
+ bell: function () {},
154
+ write: function (msg) {
155
+ var id = msg && msg.id;
156
+ var text = String((msg && msg.data) || '').replace(/\n/g, '\r\n');
157
+ setTimeout(function () {
158
+ emit(id, '\r\n' + text + '\r\n\x1b[36m# grounded follow-up prompt received\x1b[0m\r\n$ ');
159
+ }, 80);
160
+ },
161
+ };
162
+ })();
163
+ window.__demoCaption = function (text) {
164
+ var el = document.getElementById('demo-caption');
165
+ if (!el) {
166
+ el = document.createElement('div');
167
+ el.id = 'demo-caption';
168
+ document.body.appendChild(el);
169
+ }
170
+ el.textContent = text;
171
+ };
172
+ </script>`;
173
+ }
174
+
175
+ function demoStyles() {
176
+ return String.raw`<style>
177
+ #demo-caption {
178
+ position: fixed;
179
+ left: 78px;
180
+ top: 18px;
181
+ z-index: 9999;
182
+ padding: 8px 12px;
183
+ border: 1px solid rgba(255, 255, 255, .15);
184
+ border-radius: 7px;
185
+ background: rgba(25, 28, 32, .88);
186
+ color: #e6edf3;
187
+ font: 600 13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
188
+ box-shadow: 0 8px 24px rgba(0, 0, 0, .25);
189
+ }
190
+ .demo-pulse {
191
+ outline: 2px solid #ffc66d !important;
192
+ outline-offset: 2px;
193
+ }
194
+ </style>`;
195
+ }
196
+
197
+ function renderDemoHtml(demoRepo, workRoot) {
198
+ const review = buildDiffReview({
199
+ root: demoRepo,
200
+ includeUntracked: true,
201
+ staged: false,
202
+ context: 4,
203
+ title: "monacori",
204
+ app: true,
205
+ lazy: false,
206
+ lazyLoad: false,
207
+ });
208
+ const html = review.html
209
+ .replace("</head>", `${demoStyles()}\n</head>`)
210
+ .replace("<body>", `<body>\n${fakePtyPrelude()}`);
211
+ const htmlPath = join(workRoot, "monacori-demo.html");
212
+ writeFileSync(htmlPath, html);
213
+ return htmlPath;
214
+ }
215
+
216
+ async function waitFor(win, predicate, timeoutMs = 6000) {
217
+ const start = Date.now();
218
+ while (Date.now() - start < timeoutMs) {
219
+ const ok = await win.webContents.executeJavaScript(`Boolean(${predicate})`);
220
+ if (ok) return;
221
+ await new Promise((resolve) => setTimeout(resolve, 80));
222
+ }
223
+ throw new Error(`Timed out waiting for ${predicate}`);
224
+ }
225
+
226
+ async function pause(ms) {
227
+ await new Promise((resolve) => setTimeout(resolve, ms));
228
+ }
229
+
230
+ async function capture(win, frameDir, state, repeats = 6) {
231
+ await pause(90);
232
+ const image = await win.capturePage();
233
+ const png = image.toPNG();
234
+ for (let i = 0; i < repeats; i += 1) {
235
+ state.index += 1;
236
+ writeFileSync(join(frameDir, `frame-${String(state.index).padStart(4, "0")}.png`), png);
237
+ }
238
+ }
239
+
240
+ async function captureTyping(win, frameDir, state, selector, text) {
241
+ await win.webContents.executeJavaScript(`document.querySelector(${JSON.stringify(selector)}).value = ""`);
242
+ for (let i = 0; i < text.length; i += 4) {
243
+ const chunk = text.slice(0, i + 4);
244
+ await win.webContents.executeJavaScript(`
245
+ var el = document.querySelector(${JSON.stringify(selector)});
246
+ el.value = ${JSON.stringify(chunk)};
247
+ el.dispatchEvent(new Event('input', { bubbles: true }));
248
+ `);
249
+ await capture(win, frameDir, state, 1);
250
+ }
251
+ }
252
+
253
+ async function recordFrames(htmlPath, frameDir) {
254
+ const win = new BrowserWindow({
255
+ width,
256
+ height,
257
+ show: false,
258
+ paintWhenInitiallyHidden: true,
259
+ backgroundColor: "#2b2b2b",
260
+ webPreferences: {
261
+ offscreen: true,
262
+ contextIsolation: false,
263
+ nodeIntegration: false,
264
+ sandbox: false,
265
+ },
266
+ });
267
+ const state = { index: 0 };
268
+ await win.loadURL(`${pathToFileURL(htmlPath).href}#hunk-0`);
269
+ await waitFor(win, "!document.getElementById('boot-overlay')");
270
+ await waitFor(win, "document.querySelector('#diff-view:not(.hidden) .diff-active-row')");
271
+
272
+ await win.webContents.executeJavaScript(`
273
+ window.__demoCaption('1. Review the exact AI-generated diff');
274
+ document.querySelector('.diff-active-row')?.classList.add('demo-pulse');
275
+ `);
276
+ await capture(win, frameDir, state, 10);
277
+
278
+ await win.webContents.executeJavaScript(`
279
+ document.querySelector('.diff-active-row')?.classList.remove('demo-pulse');
280
+ window.__demoCaption('2. Add a line-level change request');
281
+ openComposer('c');
282
+ `);
283
+ await waitFor(win, "document.querySelector('.mc-composer .mc-input')");
284
+ await capture(win, frameDir, state, 4);
285
+ await captureTyping(
286
+ win,
287
+ frameDir,
288
+ state,
289
+ ".mc-composer .mc-input",
290
+ "Keep archived reviews out of the default queue and preserve a stable priority order.",
291
+ );
292
+ await win.webContents.executeJavaScript("saveComposer()");
293
+ await waitFor(win, "document.querySelector('.mc-card.mc-c')");
294
+ await capture(win, frameDir, state, 8);
295
+
296
+ await win.webContents.executeJavaScript(`
297
+ window.__demoCaption('3. Merge comments into a grounded follow-up prompt');
298
+ openMergedView('c');
299
+ `);
300
+ await waitFor(win, "document.querySelector('#mc-merged-panel textarea')");
301
+ await capture(win, frameDir, state, 10);
302
+
303
+ await win.webContents.executeJavaScript(`
304
+ window.__demoCaption('4. Send the prompt into the integrated AI terminal');
305
+ var promptText = buildMergedText('c');
306
+ window.__monacoriTerminal.open();
307
+ window.__demoPromptText = promptText;
308
+ `);
309
+ await waitFor(win, "window.__monacoriTerminal && window.__monacoriTerminal.paneCount() > 0");
310
+ await capture(win, frameDir, state, 6);
311
+ await win.webContents.executeJavaScript("window.__monacoriTerminal.enterSendMode(window.__demoPromptText)");
312
+ await capture(win, frameDir, state, 6);
313
+ await win.webContents.executeJavaScript("window.__monacoriTerminal.send(window.__demoPromptText)");
314
+ await pause(300);
315
+ await capture(win, frameDir, state, 12);
316
+
317
+ win.destroy();
318
+ return state.index;
319
+ }
320
+
321
+ function makeGif(frameDir) {
322
+ if (!existsSync(outputGif)) mkdirSync(dirname(outputGif), { recursive: true });
323
+ const palette = join(frameDir, "palette.png");
324
+ run("ffmpeg", [
325
+ "-y",
326
+ "-framerate",
327
+ String(frameRate),
328
+ "-i",
329
+ join(frameDir, "frame-%04d.png"),
330
+ "-vf",
331
+ "fps=10,scale=960:-1:flags=lanczos,palettegen",
332
+ palette,
333
+ ]);
334
+ run("ffmpeg", [
335
+ "-y",
336
+ "-framerate",
337
+ String(frameRate),
338
+ "-i",
339
+ join(frameDir, "frame-%04d.png"),
340
+ "-i",
341
+ palette,
342
+ "-lavfi",
343
+ "fps=10,scale=960:-1:flags=lanczos[x];[x][1:v]paletteuse=dither=bayer:bayer_scale=3",
344
+ outputGif,
345
+ ]);
346
+ }
347
+
348
+ async function main() {
349
+ const workRoot = mkdtempSync(join(tmpdir(), "monacori-demo-"));
350
+ try {
351
+ const demoRepo = createDemoRepo(workRoot);
352
+ const htmlPath = renderDemoHtml(demoRepo, workRoot);
353
+ const frameDir = join(workRoot, "frames");
354
+ mkdirSync(frameDir, { recursive: true });
355
+ const frames = await recordFrames(htmlPath, frameDir);
356
+ makeGif(frameDir);
357
+ console.log(`wrote ${outputGif} from ${frames} frames`);
358
+ } finally {
359
+ if (process.env.MONACORI_KEEP_DEMO_FRAMES !== "1") {
360
+ rmSync(workRoot, { recursive: true, force: true });
361
+ } else {
362
+ console.log(`kept demo workdir: ${workRoot}`);
363
+ }
364
+ }
365
+ }
366
+
367
+ app.whenReady()
368
+ .then(main)
369
+ .then(() => app.quit())
370
+ .catch((error) => {
371
+ console.error(error instanceof Error ? error.stack || error.message : String(error));
372
+ app.exit(1);
373
+ });