@happy-nut/monacori 0.1.3 → 0.1.5

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/dist/i18n.js CHANGED
@@ -94,6 +94,7 @@ export const MESSAGES = {
94
94
  "kbd.allQuestionsChanges": "All questions / changes",
95
95
  "kbd.ignoreWhitespace": "Ignore whitespace",
96
96
  "kbd.saveComment": "Save comment",
97
+ "kbd.promptMemo": "Prompt memo",
97
98
  "kbd.toggleTerminal": "Toggle terminal",
98
99
  "kbd.splitPane": "Split pane",
99
100
  "kbd.focusPane": "Focus prev / next pane",
@@ -127,6 +128,10 @@ export const MESSAGES = {
127
128
  "merged.close": "Close",
128
129
  "merged.qHeading": "# Questions",
129
130
  "merged.cHeading": "# Change requests",
131
+ // Prompt memo (Cmd/Ctrl+Shift+N) — a single freeform Markdown scratchpad with a live split preview.
132
+ "memo.title": "Prompt memo",
133
+ "memo.placeholder": "Jot down what you're planning, in Markdown…",
134
+ "memo.previewEmpty": "Markdown preview shows up here as you type.",
130
135
  // Merge-prompt default agent contracts (these follow the locale — a Korean user gets Korean defaults)
131
136
  "mergePrompt.default.q": "The following are questions about code you just wrote. Answer each one — explain the intent, rationale, or context. Do not change any code; this clarifies understanding before any revisions.",
132
137
  "mergePrompt.default.c": "The following are change requests for code you just wrote. For each, edit the code at the quoted location to satisfy the request. Keep changes minimal and focused; do not make unrelated edits.",
@@ -215,6 +220,7 @@ export const MESSAGES = {
215
220
  "kbd.allQuestionsChanges": "전체 질문 / 변경요청",
216
221
  "kbd.ignoreWhitespace": "공백 무시",
217
222
  "kbd.saveComment": "코멘트 저장",
223
+ "kbd.promptMemo": "프롬프트 메모",
218
224
  "kbd.toggleTerminal": "터미널 토글",
219
225
  "kbd.splitPane": "패널 분할",
220
226
  "kbd.focusPane": "이전 / 다음 패널로 이동",
@@ -249,6 +255,10 @@ export const MESSAGES = {
249
255
  // Structural markers stay English in both locales (the preamble prose below follows the locale).
250
256
  "merged.qHeading": "# Questions",
251
257
  "merged.cHeading": "# Change requests",
258
+ // 프롬프트 메모 (Cmd/Ctrl+Shift+N) — 라이브 분할 미리보기가 있는 자유 형식 마크다운 메모 한 장.
259
+ "memo.title": "프롬프트 메모",
260
+ "memo.placeholder": "구상 중인 것을 마크다운으로 적어 보세요…",
261
+ "memo.previewEmpty": "입력하면 여기에 마크다운 미리보기가 나타납니다.",
252
262
  // Merge-prompt default agent contracts (Korean default for Korean users)
253
263
  "mergePrompt.default.q": "다음은 방금 작성한 코드에 대한 질문입니다. 각 질문에 답하면서 의도, 근거, 맥락을 설명하세요. 코드는 변경하지 마세요. 이 단계는 수정에 앞서 이해를 명확히 하기 위한 것입니다.",
254
264
  "mergePrompt.default.c": "다음은 방금 작성한 코드에 대한 변경 요청입니다. 각 요청에 대해 인용된 위치의 코드를 수정하여 요구사항을 충족하세요. 변경은 최소한으로 집중해서 하고, 관련 없는 수정은 하지 마세요.",
package/dist/preload.cjs CHANGED
@@ -13,6 +13,15 @@ electron_1.contextBridge.exposeInMainWorld("monacoriMenu", {
13
13
  onMergedView: (cb) => {
14
14
  electron_1.ipcRenderer.on("monacori:merged-view", (_event, kind) => cb(kind));
15
15
  },
16
+ // Review menu's Cmd/Ctrl+Shift+N -> open/close the prompt memo in the renderer.
17
+ onOpenMemo: (cb) => {
18
+ electron_1.ipcRenderer.on("monacori:open-memo", () => cb());
19
+ },
20
+ // Electron watch: main pushes the rebuilt review HTML so the renderer refreshes the diff in place
21
+ // (no window reload), keeping the integrated terminal's pty sessions alive.
22
+ onDiffUpdate: (cb) => {
23
+ electron_1.ipcRenderer.on("monacori:diff-update", (_event, html) => cb(html));
24
+ },
16
25
  // Cmd/Ctrl+W from the Window menu -> close the active Files-mode tab in the renderer.
17
26
  onCloseTab: (cb) => {
18
27
  electron_1.ipcRenderer.on("monacori:close-tab", () => cb());
package/dist/render.d.ts CHANGED
@@ -6,6 +6,15 @@ export declare function splitDiffForLazy(diffHtml: string, files: DiffFile[]): {
6
6
  islands: string;
7
7
  bodies: string[];
8
8
  };
9
+ export declare function renderReviewStatus(input: {
10
+ files: number;
11
+ hunks: number;
12
+ embeddedFiles: number;
13
+ sourceFileCount: number;
14
+ ignoreWhitespace?: boolean;
15
+ watch?: boolean;
16
+ generatedAt?: string;
17
+ }): string;
9
18
  export declare function renderDiffHtml(input: {
10
19
  files: DiffFile[];
11
20
  diffHtml: string;
@@ -25,6 +34,8 @@ export declare function renderDiffHtml(input: {
25
34
  signature?: string;
26
35
  generatedAt?: string;
27
36
  }): string;
37
+ export declare function renderDiffTree(files: DiffFile[]): string;
38
+ export declare function renderSourceTree(files: SourceFile[]): string;
28
39
  export declare function diffSubtitle(options: {
29
40
  base?: string;
30
41
  staged: boolean;
package/dist/render.js CHANGED
@@ -82,6 +82,11 @@ export function splitDiffForLazy(diffHtml, files) {
82
82
  });
83
83
  return { container: shells.join("\n"), islands: islands.join("\n"), bodies };
84
84
  }
85
+ // The toolbar's review-status row (file/hunk counts, index + live status). Extracted so the in-place
86
+ // update path can re-render just this strip; renderDiffHtml wraps it in <div class="review-status">.
87
+ export function renderReviewStatus(input) {
88
+ return `<span>${input.files} <span data-i18n="status.files">files</span></span><span>${input.hunks} <span data-i18n="status.hunks">hunks</span></span>${input.ignoreWhitespace ? '<span class="ws-ignored" data-i18n="status.wsIgnored" data-i18n-title="status.wsIgnored.title" title="Whitespace ignored — Cmd/Ctrl+Shift+W">ws ignored</span>' : ""}<span class="index-status" id="index-status" data-i18n-title="status.index.title" title="Go-to-definition index">${input.embeddedFiles}/${input.sourceFileCount} indexed</span><span class="index-progress hidden" id="index-progress" aria-hidden="true"><span class="index-progress-bar"></span></span><span class="live-status ${input.watch ? "watching" : ""}" id="live-status"${input.watch ? ' data-i18n="status.watching"' : ""}>${input.watch ? "watching" : escapeHtml(input.generatedAt ?? new Date().toISOString())}</span>`;
89
+ }
85
90
  export function renderDiffHtml(input) {
86
91
  const totalHunks = input.files.reduce((sum, file) => sum + file.hunks.length, 0);
87
92
  const fileNav = renderDiffTree(input.files);
@@ -102,6 +107,8 @@ export function renderDiffHtml(input) {
102
107
  "</style>",
103
108
  "</head>",
104
109
  "<body>",
110
+ // Boot overlay (removed by the renderer once bootstrap has painted) covers the blank gap after loadFile.
111
+ '<div id="boot-overlay"><div class="boot-spinner"></div><div>monacori</div></div>',
105
112
  '<aside class="sidebar" aria-label="Review navigation">',
106
113
  '<div class="sidebar-scroll">',
107
114
  `<div class="sidebar-brand" title="${escapeAttr(input.projectPath)}"><span class="brand-mark">monacori</span><span class="brand-project">${escapeHtml(input.projectName)}</span></div>`,
@@ -122,7 +129,7 @@ export function renderDiffHtml(input) {
122
129
  '<section id="diff-view" class="hidden">',
123
130
  '<div class="toolbar">',
124
131
  '<div class="breadcrumb" id="diff-breadcrumb"></div>',
125
- `<div class="review-status"><span>${input.files.length} <span data-i18n="status.files">files</span></span><span>${totalHunks} <span data-i18n="status.hunks">hunks</span></span>${input.ignoreWhitespace ? '<span class="ws-ignored" data-i18n="status.wsIgnored" data-i18n-title="status.wsIgnored.title" title="Whitespace ignored — Cmd/Ctrl+Shift+W">ws ignored</span>' : ""}<span class="index-status" id="index-status" data-i18n-title="status.index.title" title="Go-to-definition index">${embeddedFiles}/${input.sourceFiles.length} indexed</span><span class="index-progress hidden" id="index-progress" aria-hidden="true"><span class="index-progress-bar"></span></span><span class="live-status ${input.watch ? "watching" : ""}" id="live-status"${input.watch ? ' data-i18n="status.watching"' : ""}>${input.watch ? "watching" : escapeHtml(input.generatedAt ?? new Date().toISOString())}</span></div>`,
132
+ `<div class="review-status">${renderReviewStatus({ files: input.files.length, hunks: totalHunks, embeddedFiles, sourceFileCount: input.sourceFiles.length, ignoreWhitespace: input.ignoreWhitespace, watch: input.watch, generatedAt: input.generatedAt })}</div>`,
126
133
  '<button type="button" id="diff-viewed-toggle" class="diff-viewed-toggle" aria-pressed="false" data-i18n="btn.viewed" data-i18n-title="btn.viewed.title" title="Toggle viewed (<)" hidden>Viewed</button>',
127
134
  "</div>",
128
135
  `<div id="diff2html-container" class="diff2html-container">${input.diffHtml || '<div class="empty" data-i18n="diff.noDiff">No diff to review.</div>'}</div>`,
@@ -194,13 +201,14 @@ export function renderDiffHtml(input) {
194
201
  '<kbd>Cmd/Ctrl+Shift+/ .</kbd><span data-i18n="kbd.allQuestionsChanges">All questions / changes</span>' +
195
202
  '<kbd>Cmd/Ctrl+Shift+W</kbd><span data-i18n="kbd.ignoreWhitespace">Ignore whitespace</span>' +
196
203
  '<kbd>Cmd/Ctrl+Enter</kbd><span data-i18n="kbd.saveComment">Save comment</span>' +
204
+ '<kbd>Cmd/Ctrl+Shift+N</kbd><span data-i18n="kbd.promptMemo">Prompt memo</span>' +
197
205
  '</div>' +
198
206
  '<div class="keys-cat" data-i18n="settings.kbd.cat.terminal">Terminal</div>' +
199
207
  '<div class="keys-grid">' +
200
208
  '<kbd>Ctrl+`</kbd><span data-i18n="kbd.toggleTerminal">Toggle terminal</span>' +
201
209
  '<kbd>Cmd/Ctrl+D</kbd><span data-i18n="kbd.splitPane">Split pane</span>' +
202
210
  '<kbd>Cmd/Ctrl+Alt+[ / ]</kbd><span data-i18n="kbd.focusPane">Focus prev / next pane</span>' +
203
- '<kbd>F2</kbd><span data-i18n="kbd.renamePane">Rename pane</span>' +
211
+ '<kbd>Cmd/Ctrl+Alt+R</kbd><span data-i18n="kbd.renamePane">Rename pane</span>' +
204
212
  '<kbd>Cmd/Ctrl+W</kbd><span data-i18n="kbd.closeTerminal">Close terminal (when focused)</span>' +
205
213
  '</div>' +
206
214
  '</div>',
@@ -234,7 +242,7 @@ export function renderDiffHtml(input) {
234
242
  "</html>",
235
243
  ].join("\n");
236
244
  }
237
- function renderDiffTree(files) {
245
+ export function renderDiffTree(files) {
238
246
  if (files.length === 0) {
239
247
  return '<div class="empty-nav">No changed files</div>';
240
248
  }
@@ -266,7 +274,7 @@ function renderDiffTree(files) {
266
274
  });
267
275
  return `<nav class="tree changes-flat">${rows.join("")}</nav>`;
268
276
  }
269
- function renderSourceTree(files) {
277
+ export function renderSourceTree(files) {
270
278
  if (files.length === 0) {
271
279
  return '<div class="empty-nav">No source files indexed</div>';
272
280
  }
@@ -394,7 +402,7 @@ function renderSourceNode(node, depth) {
394
402
  }
395
403
  return [
396
404
  `<details class="tree-dir source-dir" data-dir="${escapeAttr(labelNode.path)}" style="--depth:${depth}">`,
397
- `<summary><span class="folder-icon">v</span><span class="path">${escapeHtml(names.join("/"))}</span></summary>`,
405
+ `<summary><span class="folder-icon"><svg class="folder-ic fi-closed" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg><svg class="folder-ic fi-open" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 14 1.45-2.9A2 2 0 0 1 9.24 10H21a2 2 0 0 1 1.94 2.5l-1.55 6a2 2 0 0 1-1.94 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"/></svg></span><span class="path">${escapeHtml(names.join("/"))}</span></summary>`,
398
406
  renderSourceChildren(labelNode, depth + 1),
399
407
  "</details>",
400
408
  ].join("\n");
package/dist/server.js CHANGED
@@ -108,6 +108,12 @@ export function serveDiffWatch(input) {
108
108
  });
109
109
  return;
110
110
  }
111
+ // Compact in-place refresh payload — the poller fetches this only when the signature changed.
112
+ if (requestUrl.pathname === "/__ai_flow_update") {
113
+ const latest = lastBuild ?? build();
114
+ writeHttpJson(response, latest.update ?? {});
115
+ return;
116
+ }
111
117
  if (requestUrl.pathname === "/__http_send" && request.method === "POST") {
112
118
  void handleHttpProxy(request, response);
113
119
  return;
package/dist/types.d.ts CHANGED
@@ -78,12 +78,24 @@ export type DiffReviewResult = {
78
78
  files: number;
79
79
  hunks: number;
80
80
  };
81
+ export type DiffReviewUpdate = {
82
+ signature: string;
83
+ generatedAt: string;
84
+ diffContainer: string;
85
+ changesPanel: string;
86
+ filesTree: string;
87
+ reviewStatus: string;
88
+ fileStates: ReviewFileState[];
89
+ sourceFilesMeta: SourceFile[];
90
+ httpEnvironments: Record<string, Record<string, string>>;
91
+ };
81
92
  export type DiffReviewBuild = {
82
93
  html: string;
83
94
  files: number;
84
95
  hunks: number;
85
96
  signature: string;
86
97
  generatedAt: string;
98
+ update?: DiffReviewUpdate;
87
99
  lazyBodies?: string[];
88
100
  lazySourceData?: string;
89
101
  };
@@ -113,9 +113,9 @@ function setupLazyDiff() {
113
113
  if (wrappers[0]) ensureFileReady(wrappers[0]); // first file ready so the initial caret has a row to land on
114
114
  }
115
115
  if (REVIEW_LAZY) { setupLazyDiff(); setTimeout(function () { diffBootDone = true; }, 0); }
116
- const links = Array.from(document.querySelectorAll('#changes-panel .file-link'));
116
+ let links = Array.from(document.querySelectorAll('#changes-panel .file-link')); // re-captured on in-place diff update
117
117
  let sourceLinks = Array.from(document.querySelectorAll('.source-link')); // re-captured when a deferred tree materializes
118
- const sourceFiles = JSON.parse(document.getElementById('source-files-data')?.textContent || '[]');
118
+ let sourceFiles = JSON.parse(document.getElementById('source-files-data')?.textContent || '[]');
119
119
  // i18n: the message catalog (en + ko) is emitted server-side; the locale lives in localStorage and the
120
120
  // whole UI switches live (no reload). t() feeds dynamically-built text; applyI18n() rewrites the static
121
121
  // chrome (data-i18n / -ph / -title / -aria). English is the first-paint default.
@@ -147,13 +147,13 @@ function applyI18n() {
147
147
  var sel = document.getElementById('settings-language');
148
148
  if (sel) sel.value = locale;
149
149
  }
150
- const fileStates = JSON.parse(document.getElementById('file-state-data')?.textContent || '[]');
151
- const httpEnvironments = JSON.parse(document.getElementById('http-env-data')?.textContent || '{}');
152
- const httpEnvNames = Object.keys(httpEnvironments);
150
+ let fileStates = JSON.parse(document.getElementById('file-state-data')?.textContent || '[]');
151
+ let httpEnvironments = JSON.parse(document.getElementById('http-env-data')?.textContent || '{}');
152
+ let httpEnvNames = Object.keys(httpEnvironments);
153
153
  const httpEnvKey = 'monacori-http-env:' + location.pathname;
154
154
  const httpRequestsByPath = new Map();
155
155
  const httpVarsByPath = new Map();
156
- const sourceByPath = new Map(sourceFiles.map((file) => [file.path, file]));
156
+ let sourceByPath = new Map(sourceFiles.map((file) => [file.path, file]));
157
157
  // Phase 2b lazy-LOAD: source content is fetched once after first paint (serve /source-data or the
158
158
  // Electron bridge) and merged into the metadata-only source records; until then sourceLoaded is false
159
159
  // and the source view shows a brief loading state. Non-lazy-load modes embed source -> already loaded.
@@ -184,16 +184,16 @@ function loadSourceData() {
184
184
  }
185
185
  sourceLoaded = true;
186
186
  sourceLoading = false;
187
- try { startSymbolIndex(); } catch (e) {}
187
+ scheduleSymbolIndex();
188
188
  if (pendingSourceOpen) { var po = pendingSourceOpen; pendingSourceOpen = null; openSourceFile(po.path, po.shouldSwitch); }
189
189
  else if (isSourceViewerVisible() && document.getElementById('source-viewer').dataset.openPath) { openSourceFile(document.getElementById('source-viewer').dataset.openPath, false); }
190
190
  if (pendingSymbol) { var s = pendingSymbol; pendingSymbol = null; goToDefOrUsages(s); }
191
191
  }, function () { sourceLoaded = true; sourceLoading = false; });
192
192
  }
193
- const fileSignatureByPath = new Map(fileStates.map((file) => [file.path, file.signature]));
193
+ let fileSignatureByPath = new Map(fileStates.map((file) => [file.path, file.signature]));
194
194
  const reviewMeta = document.getElementById('review-meta');
195
195
  const watchEnabled = reviewMeta?.dataset.watch === 'true';
196
- const currentSignature = reviewMeta?.dataset.signature || '';
196
+ let currentSignature = reviewMeta?.dataset.signature || '';
197
197
  const uiStateKey = 'monacori-diff-ui:' + location.pathname;
198
198
  const recentKey = 'monacori-diff-recent:' + location.pathname;
199
199
  const viewedKey = 'monacori-diff-viewed:' + location.pathname;
@@ -455,14 +455,26 @@ function scheduleDiffScroll(row) {
455
455
  });
456
456
  }
457
457
 
458
+ var setActiveRaf = 0, setActiveScrollPending = true;
458
459
  function setActive(index, shouldScroll = true) {
459
460
  if (hunkTotal() === 0) return;
460
461
  current = ((index % hunkTotal()) + hunkTotal()) % hunkTotal();
462
+ // Coalesce rapid presses (holding/spamming F7 or Shift+F7) into one DOM apply per animation frame. The
463
+ // key handler returns immediately and `current` updates synchronously (so next()/nav math stays correct),
464
+ // while the heavy DOM work (full link/wrapper sweeps, body materialize) runs at most once per frame
465
+ // instead of once per keystroke — the input queue never blocks and can't pile up on big repos.
466
+ setActiveScrollPending = shouldScroll;
467
+ if (setActiveRaf) return;
468
+ setActiveRaf = requestAnimationFrame(function () {
469
+ setActiveRaf = 0;
470
+ applySetActive(current, setActiveScrollPending);
471
+ });
472
+ }
473
+ function applySetActive(idx, shouldScroll) {
461
474
  document.getElementById('source-viewer')?.classList.add('hidden');
462
475
  document.getElementById('diff-view')?.classList.remove('hidden');
463
476
  setTab('changes');
464
- const file = hunkPathAt(current);
465
- const idx = current;
477
+ const file = hunkPathAt(idx);
466
478
  links.forEach((link) => link.classList.toggle('active', link.dataset.file === file));
467
479
  renderBreadcrumb(document.getElementById('diff-breadcrumb'), file);
468
480
  var dvt = document.getElementById('diff-viewed-toggle');
@@ -973,6 +985,13 @@ document.addEventListener('keydown', (event) => {
973
985
  openMergedView((event.code === 'Slash' || event.key === '?') ? 'q' : 'c');
974
986
  return;
975
987
  }
988
+ // Cmd/Ctrl+Shift+N opens/closes the prompt memo. Electron also routes this via the Review menu; in the
989
+ // browser/serve build (no menu) this keydown is the only path. Match the physical key so layout/IME never swallows it.
990
+ if ((event.metaKey || event.ctrlKey) && event.shiftKey && (event.code === 'KeyN' || event.key === 'n' || event.key === 'N')) {
991
+ event.preventDefault();
992
+ openMemoView();
993
+ return;
994
+ }
976
995
  // "?" = question, ">" = change-request composer on the current line/selection (no modifier).
977
996
  if (!event.altKey && !event.metaKey && !event.ctrlKey && (event.key === '?' || event.key === '>')) {
978
997
  const ce = document.activeElement;
@@ -1215,7 +1234,7 @@ document.addEventListener('copy', handleSourceCopy);
1215
1234
 
1216
1235
  applyI18n(); // first paint already shows English (inline); this swaps to the saved locale before the rest of init renders dynamic text
1217
1236
  populateHttpEnvSelect();
1218
- if (!REVIEW_LAZY_LOAD) setTimeout(startSymbolIndex, 0); // non-lazy indexes now; lazy-LOAD defers the (large) source blob + index to the first source-view open / go-to-def
1237
+ if (!REVIEW_LAZY_LOAD) scheduleSymbolIndex(); // non-lazy indexes when idle; lazy-LOAD defers the (large) source blob + index to the first source-view open / go-to-def
1219
1238
  const restored = restoreUiState();
1220
1239
  if (!restored) {
1221
1240
  const initial = location.hash.match(/^#hunk-(\d+)$/);
@@ -1227,6 +1246,19 @@ initSourceTreeFolds();
1227
1246
  if (watchEnabled) setInterval(checkForLiveUpdate, 1500);
1228
1247
  window.addEventListener('beforeunload', saveUiState);
1229
1248
 
1249
+ // First render has painted — drop the boot overlay (it bridged the blank gap right after loadFile). Two
1250
+ // rAFs so the spinner stays until the diff/tree are actually on screen, then a short fade-out.
1251
+ (function () {
1252
+ var ov = document.getElementById('boot-overlay');
1253
+ if (!ov) return;
1254
+ requestAnimationFrame(function () {
1255
+ requestAnimationFrame(function () {
1256
+ ov.classList.add('hide');
1257
+ setTimeout(function () { ov.remove(); }, 240);
1258
+ });
1259
+ });
1260
+ })();
1261
+
1230
1262
  (function setupSidebarResize() {
1231
1263
  const resizer = document.querySelector('.sidebar-resizer');
1232
1264
  if (!resizer) return;
@@ -1943,6 +1975,96 @@ function openMergedView(kind) {
1943
1975
  }
1944
1976
  }
1945
1977
 
1978
+ // Prompt memo (Cmd/Ctrl+Shift+N): one freeform Markdown scratchpad with a live split preview, persisted
1979
+ // across reopens via the same store as comments/locale. "Send to terminal" hands the current draft to the
1980
+ // same pane-pick mode the merged views use, so a half-formed prompt can target any live claude/codex session.
1981
+ var memoKey = 'monacori-memo';
1982
+ function loadMemo() {
1983
+ var v = persistRead(memoKey);
1984
+ if (typeof v === 'string') return v;
1985
+ try { var s = localStorage.getItem(memoKey); return typeof s === 'string' ? s : ''; } catch (e) { return ''; }
1986
+ }
1987
+ function saveMemo(text) { persistSave(memoKey, text || ''); }
1988
+ function renderMemoMd(text) {
1989
+ if (!text || !text.trim()) return '<div class="mc-memo-empty" data-i18n="memo.previewEmpty">' + escapeHtml(t('memo.previewEmpty')) + '</div>';
1990
+ return renderMarkdownBlocks(text).map(function (b) { return b.html; }).join('');
1991
+ }
1992
+ function openMemoView() {
1993
+ var existing = document.getElementById('mc-memo');
1994
+ if (existing) { existing.remove(); return; } // the shortcut toggles: a second press closes the memo
1995
+ var modal = document.createElement('div');
1996
+ modal.id = 'mc-memo';
1997
+ modal.className = 'mc-modal';
1998
+ var panel = document.createElement('div');
1999
+ panel.className = 'mc-modal-panel mc-memo-panel';
2000
+ var head = document.createElement('div');
2001
+ head.className = 'mc-modal-head';
2002
+ var title = document.createElement('span');
2003
+ title.setAttribute('data-i18n', 'memo.title');
2004
+ title.textContent = t('memo.title');
2005
+ var closeBtn = document.createElement('button');
2006
+ closeBtn.type = 'button';
2007
+ closeBtn.className = 'mc-btn mc-ghost';
2008
+ closeBtn.setAttribute('data-i18n', 'merged.close');
2009
+ closeBtn.textContent = t('merged.close');
2010
+ closeBtn.addEventListener('click', function () { modal.remove(); });
2011
+
2012
+ var body = document.createElement('div');
2013
+ body.className = 'mc-memo-body';
2014
+ var area = document.createElement('textarea');
2015
+ area.className = 'mc-modal-text mc-memo-edit';
2016
+ area.spellcheck = false;
2017
+ area.setAttribute('data-i18n-ph', 'memo.placeholder');
2018
+ area.placeholder = t('memo.placeholder');
2019
+ area.value = loadMemo();
2020
+ var preview = document.createElement('div');
2021
+ preview.className = 'md-cell mc-memo-preview';
2022
+ preview.innerHTML = renderMemoMd(area.value);
2023
+ area.addEventListener('input', function () {
2024
+ saveMemo(area.value);
2025
+ preview.innerHTML = renderMemoMd(area.value);
2026
+ });
2027
+
2028
+ // Terminal send: hand the current draft to pane-pick mode (arrows choose the session, Enter sends). Shown
2029
+ // only once a terminal pane exists; enterSendMode reopens the panel if it was closed.
2030
+ var sendBtn = null;
2031
+ if (window.__monacoriTerminal && typeof window.__monacoriTerminal.paneCount === 'function' && window.__monacoriTerminal.paneCount() > 0) {
2032
+ sendBtn = document.createElement('button');
2033
+ sendBtn.type = 'button';
2034
+ sendBtn.className = 'mc-btn mc-send-term';
2035
+ sendBtn.setAttribute('data-i18n', 'merged.sendToTerminal');
2036
+ sendBtn.textContent = t('merged.sendToTerminal');
2037
+ sendBtn.addEventListener('click', function () {
2038
+ var text = area.value;
2039
+ modal.remove();
2040
+ window.__monacoriTerminal.enterSendMode(text);
2041
+ });
2042
+ }
2043
+
2044
+ head.appendChild(title);
2045
+ if (sendBtn) head.appendChild(sendBtn);
2046
+ head.appendChild(closeBtn);
2047
+ body.appendChild(area);
2048
+ body.appendChild(preview);
2049
+ panel.appendChild(head);
2050
+ panel.appendChild(body);
2051
+ modal.appendChild(panel);
2052
+ modal.addEventListener('mousedown', function (e) { if (e.target === modal) modal.remove(); });
2053
+ modal.addEventListener('keydown', function (e) { if (e.key === 'Escape') { e.preventDefault(); modal.remove(); } });
2054
+ document.body.appendChild(modal);
2055
+ // Focus the editor; Electron async-restores focus to <body>, so retry briefly (same as the composer/merged view).
2056
+ var memoFocusTries = 0;
2057
+ var tryFocusMemo = function () {
2058
+ if (!document.getElementById('mc-memo')) return true;
2059
+ if (document.activeElement === area) return true;
2060
+ try { area.focus(); } catch (e) {}
2061
+ return document.activeElement === area;
2062
+ };
2063
+ if (!tryFocusMemo()) {
2064
+ var memoFocusIv = setInterval(function () { if (tryFocusMemo() || ++memoFocusTries > 12) clearInterval(memoFocusIv); }, 25);
2065
+ }
2066
+ }
2067
+
1946
2068
  document.addEventListener('click', function (event) {
1947
2069
  var t = event.target;
1948
2070
  if (!t || !t.closest) return;
@@ -2011,7 +2133,11 @@ refreshComments();
2011
2133
 
2012
2134
  function setActive(p) {
2013
2135
  active = p;
2014
- panes.forEach(function (q) { q.el.classList.toggle('is-active', q === p); });
2136
+ panes.forEach(function (q) {
2137
+ q.el.classList.toggle('is-active', q === p);
2138
+ // 2+ panes: dim every pane but the active one (no border, just a clean focus cue). A lone pane stays full.
2139
+ q.el.classList.toggle('is-inactive', panes.length > 1 && q !== p);
2140
+ });
2015
2141
  if (p) requestAnimationFrame(function () { try { p.term.focus(); } catch (e) {} });
2016
2142
  }
2017
2143
 
@@ -2044,7 +2170,13 @@ refreshComments();
2044
2170
  term.attachCustomKeyEventHandler(function (e) {
2045
2171
  if (e.type === 'keydown' && e.metaKey) {
2046
2172
  var k = (e.key || '').toLowerCase();
2047
- if (k === 'c' || k === 'v' || k === 'x' || k === 'a') return true;
2173
+ // The bare modifier press (Cmd goes down BEFORE the letter on macOS) must not blur blurring
2174
+ // here drops the textarea focus the upcoming Cmd+V paste / Cmd+C copy needs, which broke them.
2175
+ if (k === 'meta' || k === 'control' || k === 'alt' || k === 'shift') return true;
2176
+ // Match the PHYSICAL key (e.code), not e.key: under a non-Latin layout/IME (e.g. Korean 한글)
2177
+ // Cmd+V reports e.key as 'ㅍ', so a key-based check misses it — blurring the terminal and
2178
+ // breaking paste/copy/cut/select-all whenever the Korean input source is active.
2179
+ if (e.code === 'KeyC' || e.code === 'KeyV' || e.code === 'KeyX' || e.code === 'KeyA') return true;
2048
2180
  try { term.blur(); } catch (x) {}
2049
2181
  return false;
2050
2182
  }
@@ -2067,8 +2199,18 @@ refreshComments();
2067
2199
  if (el.getAttribute('contenteditable') === 'true') return;
2068
2200
  setActive(pane);
2069
2201
  el.contentEditable = 'true';
2070
- el.focus();
2071
- try { var range = document.createRange(); range.selectNodeContents(el); var sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); } catch (e) {}
2202
+ // Electron asynchronously restores focus to <body> after the keydown, so a one-shot focus loses the
2203
+ // race and the label turns editable but never gets the caret retry until it sticks, then select all
2204
+ // (same pattern as the composer/memo). This is why rename "did nothing" before.
2205
+ var renameTries = 0;
2206
+ var focusLabel = function () {
2207
+ if (el.getAttribute('contenteditable') !== 'true') return true; // finished/cancelled meanwhile
2208
+ try { el.focus(); } catch (e) {}
2209
+ if (document.activeElement !== el) return false;
2210
+ try { var range = document.createRange(); range.selectNodeContents(el); var sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); } catch (e) {}
2211
+ return true;
2212
+ };
2213
+ if (!focusLabel()) { var renameIv = setInterval(function () { if (focusLabel() || ++renameTries > 12) clearInterval(renameIv); }, 25); }
2072
2214
  function finish(commit) {
2073
2215
  el.removeEventListener('keydown', onKey);
2074
2216
  el.removeEventListener('blur', onBlur);
@@ -2239,6 +2381,15 @@ if (window.monacoriMenu && typeof window.monacoriMenu.onMergedView === 'function
2239
2381
  // split), so the user can pick which claude/codex session receives the prompt.
2240
2382
  window.monacoriMenu.onMergedView(function (kind) { openMergedView(kind); });
2241
2383
  }
2384
+ if (window.monacoriMenu && typeof window.monacoriMenu.onOpenMemo === 'function') {
2385
+ // Cmd/Ctrl+Shift+N from the Review menu -> open/close the prompt memo.
2386
+ window.monacoriMenu.onOpenMemo(function () { openMemoView(); });
2387
+ }
2388
+ if (window.monacoriMenu && typeof window.monacoriMenu.onDiffUpdate === 'function') {
2389
+ // Electron watch: main rebuilds on working-tree changes and pushes the new HTML so we refresh the diff
2390
+ // in place — NO window reload — keeping the integrated terminal's pty sessions (claude/codex) alive.
2391
+ window.monacoriMenu.onDiffUpdate(function (html) { try { applyDiffUpdate(html); } catch (e) {} });
2392
+ }
2242
2393
  if (window.monacoriMenu && typeof window.monacoriMenu.onCloseTab === 'function') {
2243
2394
  // Cmd/Ctrl+W: close the active Files-mode tab (no-op outside the source viewer).
2244
2395
  window.monacoriMenu.onCloseTab(function () {
@@ -2471,6 +2622,71 @@ function restoreUiState() {
2471
2622
  return false;
2472
2623
  }
2473
2624
 
2625
+ // In-place diff refresh (instead of a full window reload): apply a compact payload of just the changed
2626
+ // regions (diff container, sidebar trees, status, data) and re-run the bootstrap steps. The window never
2627
+ // reloads, so the integrated terminal's pty sessions (claude/codex) survive a watch refresh. Electron's
2628
+ // main pushes the payload over IPC (monacori:diff-update); serve mode's poller fetches /__ai_flow_update.
2629
+ function applyDiffUpdate(u) {
2630
+ if (!u || !u.signature || u.signature === currentSignature) return false; // unchanged — nothing to do
2631
+
2632
+ // Remember what to restore after the swap (comments/viewed persist on their own; these don't).
2633
+ var sv = document.getElementById('source-viewer');
2634
+ var openPath = (sv && sv.dataset.openPath) || '';
2635
+ var wasSource = isSourceViewerVisible();
2636
+ var container = document.getElementById('diff2html-container');
2637
+ var diffScrollTop = container ? container.scrollTop : 0;
2638
+
2639
+ // 1) Replace the visible regions straight from the payload (no full-HTML parse).
2640
+ if (container) container.innerHTML = u.diffContainer || '';
2641
+ var changesPanel = document.getElementById('changes-panel');
2642
+ if (changesPanel) changesPanel.innerHTML = u.changesPanel || '';
2643
+ // Files tree: keep the inert island (lazy, not yet opened) in sync, and refresh the live panel when it's
2644
+ // already materialized — or always, in eager mode where the panel holds the tree directly.
2645
+ var filesIsland = document.getElementById('files-tree-html');
2646
+ if (filesIsland) filesIsland.textContent = u.filesTree || '';
2647
+ var filesPanel = document.getElementById('files-panel');
2648
+ if (filesPanel && (!REVIEW_LAZY || filesPanel.innerHTML.trim())) filesPanel.innerHTML = u.filesTree || '';
2649
+ var statusEl = document.querySelector('.review-status');
2650
+ if (statusEl) statusEl.innerHTML = u.reviewStatus || '';
2651
+ if (reviewMeta) { reviewMeta.setAttribute('data-signature', u.signature); if (u.generatedAt) reviewMeta.setAttribute('data-generated-at', u.generatedAt); }
2652
+
2653
+ // 2) Re-derive module-level state directly from the payload objects.
2654
+ fileStates = u.fileStates || [];
2655
+ fileSignatureByPath = new Map(fileStates.map(function (f) { return [f.path, f.signature]; }));
2656
+ sourceFiles = u.sourceFilesMeta || [];
2657
+ sourceByPath = new Map(sourceFiles.map(function (f) { return [f.path, f]; }));
2658
+ httpEnvironments = u.httpEnvironments || {};
2659
+ httpEnvNames = Object.keys(httpEnvironments);
2660
+ currentSignature = u.signature;
2661
+ links = Array.from(document.querySelectorAll('#changes-panel .file-link'));
2662
+ sourceLinks = Array.from(document.querySelectorAll('.source-link'));
2663
+
2664
+ // 3) Reset lazy-materialize + index state so the new diff bodies / source / symbols rebuild on demand.
2665
+ bodyPromise = {};
2666
+ diffBootDone = false;
2667
+ sourceLoaded = !REVIEW_LAZY_LOAD; // lazyLoad: re-fetch source content on next use
2668
+ sourceLoading = false;
2669
+ symbolIndex = null;
2670
+ if (REVIEW_LAZY) { setupLazyDiff(); setTimeout(function () { diffBootDone = true; }, 0); }
2671
+ else { prepareDiff2HtmlHunks(); diffBootDone = true; }
2672
+ if (!REVIEW_LAZY_LOAD) setTimeout(startSymbolIndex, 0);
2673
+
2674
+ // 4) Re-run the DOM-dependent bootstrap steps.
2675
+ applyI18n();
2676
+ populateHttpEnvSelect();
2677
+ initSourceTreeFolds();
2678
+ refreshComments();
2679
+
2680
+ // 5) Best-effort restore of what the user was looking at.
2681
+ if (wasSource && openPath && sourceByPath.has(openPath)) {
2682
+ openSourceFile(openPath, false);
2683
+ } else if (container) {
2684
+ showDiffView(false);
2685
+ container.scrollTop = diffScrollTop;
2686
+ }
2687
+ return true;
2688
+ }
2689
+
2474
2690
  async function checkForLiveUpdate() {
2475
2691
  if (checkingForUpdates) return;
2476
2692
  checkingForUpdates = true;
@@ -2483,8 +2699,12 @@ async function checkForLiveUpdate() {
2483
2699
  liveStatus.textContent = t('status.live.updated') + ' ' + new Date(state.generatedAt).toLocaleTimeString();
2484
2700
  }
2485
2701
  if (state.signature && state.signature !== currentSignature) {
2486
- saveUiState();
2487
- location.reload();
2702
+ // serve mode: fetch just the compact update payload and refresh in place (same path Electron uses
2703
+ // over IPC) rather than reloading — so an open integrated terminal keeps its sessions.
2704
+ try {
2705
+ var fresh = await fetch('__ai_flow_update', { cache: 'no-store' });
2706
+ if (fresh.ok) applyDiffUpdate(await fresh.json());
2707
+ } catch (e) {}
2488
2708
  }
2489
2709
  } catch {
2490
2710
  if (liveStatus) liveStatus.textContent = t('status.live.waiting');
@@ -3104,6 +3324,14 @@ function symbolIndexWorker() {
3104
3324
  self.postMessage({ index: index, total: total });
3105
3325
  };
3106
3326
  }
3327
+ // Run symbol indexing off the critical path: requestIdleCallback so the heavy postMessage of the whole
3328
+ // source blob to the worker (structured-clone serialization is synchronous on the main thread) never
3329
+ // competes with key handling — especially on big repos right after the diff/tree first paints.
3330
+ function scheduleSymbolIndex() {
3331
+ var run = function () { try { startSymbolIndex(); } catch (e) {} };
3332
+ if (typeof window !== 'undefined' && typeof window.requestIdleCallback === 'function') window.requestIdleCallback(run, { timeout: 3000 });
3333
+ else setTimeout(run, 0);
3334
+ }
3107
3335
  function startSymbolIndex() {
3108
3336
  try {
3109
3337
  if (typeof Worker === 'undefined' || typeof Blob === 'undefined' || typeof URL === 'undefined' || !URL.createObjectURL) return;
@@ -3810,16 +4038,21 @@ function populateHttpEnvSelect() {
3810
4038
  opts += '<option value="' + escapeHtml(name) + '"' + (name === currentHttpEnvName ? ' selected' : '') + '>' + escapeHtml(name) + '</option>';
3811
4039
  });
3812
4040
  select.innerHTML = opts;
3813
- select.addEventListener('change', function () {
3814
- currentHttpEnvName = select.value;
3815
- try { localStorage.setItem(httpEnvKey, currentHttpEnvName); } catch (error) {}
3816
- const path = document.getElementById('source-viewer')?.dataset.openPath || '';
3817
- if (path && isHttpFile(path)) {
3818
- const file = sourceByPath.get(path);
3819
- const body = document.getElementById('source-body');
3820
- if (file && body) body.innerHTML = renderHttpTable(file);
3821
- }
3822
- });
4041
+ // The <select> lives in the toolbar (not swapped on in-place diff updates), so wire the change handler
4042
+ // exactly once — populateHttpEnvSelect is re-called by applyDiffUpdate to refresh the options.
4043
+ if (!select.dataset.wired) {
4044
+ select.dataset.wired = '1';
4045
+ select.addEventListener('change', function () {
4046
+ currentHttpEnvName = select.value;
4047
+ try { localStorage.setItem(httpEnvKey, currentHttpEnvName); } catch (error) {}
4048
+ const path = document.getElementById('source-viewer')?.dataset.openPath || '';
4049
+ if (path && isHttpFile(path)) {
4050
+ const file = sourceByPath.get(path);
4051
+ const body = document.getElementById('source-body');
4052
+ if (file && body) body.innerHTML = renderHttpTable(file);
4053
+ }
4054
+ });
4055
+ }
3823
4056
  }
3824
4057
 
3825
4058
  function renderSourceTable(file, query) {