finch-markdown-editor 0.2.1 → 0.2.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/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // src/index.ts
2
2
  import { createHash as createHash3 } from "node:crypto";
3
- import { mkdir as mkdir2, readFile as readFile2, realpath, stat, writeFile as writeFile2 } from "node:fs/promises";
3
+ import { appendFile, mkdir as mkdir2, readFile as readFile2, realpath, rename, stat, writeFile as writeFile2 } from "node:fs/promises";
4
4
  import { watch } from "node:fs";
5
5
  import { spawn as spawn2 } from "node:child_process";
6
6
  import path3 from "node:path";
@@ -161,6 +161,39 @@ async function runBmmd(args, input) {
161
161
  }
162
162
  var FINCH_FILE_IMAGE_RE = /finch-file:\/\/local\?path=[^\s)"']+/g;
163
163
  var FINCH_IMAGE_PLACEHOLDER_ORIGIN = "https://finch-local.invalid/markdown-image/";
164
+ var MARKDOWN_IMAGE_ALT_RE = /!\[([^\]\n]*)\](?=\()/g;
165
+ function prepareObsidianImageWidths(markdown) {
166
+ const markers = [];
167
+ const prepared = markdown.replace(MARKDOWN_IMAGE_ALT_RE, (whole, rawAlt) => {
168
+ const sized = /^(.*)\|(\d+)(?:x\d+)?$/.exec(rawAlt);
169
+ const width = sized ? Number(sized[2]) : NaN;
170
+ if (!sized || !Number.isFinite(width) || width <= 0) return whole;
171
+ const normalizedWidth = Math.round(width);
172
+ const token = `FINCHIMGSIZE${markers.length}X${normalizedWidth}X`;
173
+ markers.push({ token, width: normalizedWidth, emptyCaption: !sized[1] });
174
+ return `![${sized[1]}${token}]`;
175
+ });
176
+ return { markdown: prepared, markers };
177
+ }
178
+ function applyObsidianImageWidths(html, markers) {
179
+ let rendered = html;
180
+ for (const marker of markers) {
181
+ const widthStyle = `width: ${marker.width}px; max-width: 100%; height: auto;`;
182
+ rendered = rendered.replace(/<img\b[^>]*>/gi, (tag) => {
183
+ if (!tag.includes(marker.token)) return tag;
184
+ const cleanTag = tag.split(marker.token).join("");
185
+ if (/\sstyle="[^"]*"/i.test(cleanTag)) {
186
+ return cleanTag.replace(/\sstyle="([^"]*)"/i, (_styleAttr, style) => ` style="${style} ${widthStyle}"`);
187
+ }
188
+ return cleanTag.replace(/>$/, ` style="${widthStyle}">`);
189
+ });
190
+ if (marker.emptyCaption) {
191
+ rendered = rendered.replace(/<figcaption\b[^>]*>[\s\S]*?<\/figcaption>/gi, (caption) => caption.includes(marker.token) ? "" : caption);
192
+ }
193
+ rendered = rendered.split(marker.token).join("");
194
+ }
195
+ return rendered;
196
+ }
164
197
  function substituteFinchFileImagesForBm(markdown) {
165
198
  const urls = /* @__PURE__ */ new Map();
166
199
  let sequence = 0;
@@ -174,8 +207,10 @@ function substituteFinchFileImagesForBm(markdown) {
174
207
  async function renderWithBm(markdown, markdownStyle, customCss) {
175
208
  const args = ["render", "--platform", "wechat", "--markdown-style", markdownStyle || "kami"];
176
209
  if (customCss && customCss.trim()) args.push("--custom-css", customCss);
177
- const prepared = substituteFinchFileImagesForBm(markdown);
210
+ const sized = prepareObsidianImageWidths(markdown);
211
+ const prepared = substituteFinchFileImagesForBm(sized.markdown);
178
212
  let html = await runBmmd(args, prepared.markdown);
213
+ html = applyObsidianImageWidths(html, sized.markers);
179
214
  for (const [placeholder, originalUrl] of prepared.urls) html = html.split(placeholder).join(originalUrl);
180
215
  return html;
181
216
  }
@@ -236,6 +271,8 @@ async function openMarkdownImagePreview(ctx, rawUrl) {
236
271
  throw new Error(`Unsupported image URL: ${url.protocol}`);
237
272
  }
238
273
  var STYLE_SLOT_COUNT = 3;
274
+ var WRITING_STYLE_IDS = /* @__PURE__ */ new Set(["kami", "bauhaus", "blueprint", "botanical", "newsprint", "retro", "sketch", "terminal", "custom"]);
275
+ var MAX_CUSTOM_STYLE_CSS_LENGTH = 2e5;
239
276
  function result(message, isError = false) {
240
277
  return { content: [{ type: "text", text: message }], isError };
241
278
  }
@@ -245,6 +282,39 @@ function stateFile(ctx) {
245
282
  function styleSlotsFile(ctx) {
246
283
  return path3.join(ctx.storagePath, "style-slots.json");
247
284
  }
285
+ function writingPreferencesFile(ctx) {
286
+ return path3.join(ctx.storagePath, "writing-preferences.json");
287
+ }
288
+ function normalizeWritingPreferences(raw) {
289
+ const value = raw && typeof raw === "object" ? raw : {};
290
+ const fontSize = value.fontSize === 16 || value.fontSize === 18 ? value.fontSize : 14;
291
+ const fontFamily = value.fontFamily === "songti" ? "songti" : "rounded";
292
+ const customCss = typeof value.customCss === "string" ? value.customCss.slice(0, MAX_CUSTOM_STYLE_CSS_LENGTH) : "";
293
+ const requestedStyle = typeof value.style === "string" ? value.style : "kami";
294
+ const style = WRITING_STYLE_IDS.has(requestedStyle) && (requestedStyle !== "custom" || customCss) ? requestedStyle : "kami";
295
+ return {
296
+ fontSize,
297
+ fontFamily,
298
+ comfortWriting: value.comfortWriting === true,
299
+ style,
300
+ customCss: style === "custom" ? customCss : "",
301
+ customStyleLabel: style === "custom" && typeof value.customStyleLabel === "string" ? value.customStyleLabel.slice(0, 120) : ""
302
+ };
303
+ }
304
+ async function readWritingPreferences(ctx) {
305
+ try {
306
+ const raw = await readFile2(writingPreferencesFile(ctx), "utf8");
307
+ return normalizeWritingPreferences(JSON.parse(raw));
308
+ } catch {
309
+ return void 0;
310
+ }
311
+ }
312
+ async function writeWritingPreferences(ctx, raw) {
313
+ const preferences = normalizeWritingPreferences(raw);
314
+ await mkdir2(ctx.storagePath, { recursive: true });
315
+ await writeFile2(writingPreferencesFile(ctx), JSON.stringify(preferences), "utf8");
316
+ return preferences;
317
+ }
248
318
  function normalizeStyleSlots(raw) {
249
319
  const arr = Array.isArray(raw) ? raw : [];
250
320
  const slots = [];
@@ -284,6 +354,34 @@ async function readLastPathState(ctx) {
284
354
  return {};
285
355
  }
286
356
  }
357
+ async function loadStateForWrite(ctx) {
358
+ let raw;
359
+ try {
360
+ raw = await readFile2(stateFile(ctx), "utf8");
361
+ } catch (error) {
362
+ if (error?.code === "ENOENT") return {};
363
+ throw error;
364
+ }
365
+ if (!raw.trim()) throw new Error("state file is present but empty");
366
+ const parsed = JSON.parse(raw);
367
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("state file is not an object");
368
+ return parsed;
369
+ }
370
+ var stateWriteChain = Promise.resolve();
371
+ async function mutateState(ctx, mutate) {
372
+ const run = stateWriteChain.then(async () => {
373
+ const state = await loadStateForWrite(ctx);
374
+ if (await mutate(state) === false) return;
375
+ await mkdir2(ctx.storagePath, { recursive: true });
376
+ const target = stateFile(ctx);
377
+ const temp = `${target}.${process.pid}.tmp`;
378
+ await writeFile2(temp, JSON.stringify(state), "utf8");
379
+ await rename(temp, target);
380
+ });
381
+ stateWriteChain = run.catch(() => {
382
+ });
383
+ return run;
384
+ }
287
385
  function resolveRecentScope(state, cwd, sessionId, spaceId) {
288
386
  if (path3.isAbsolute(cwd)) {
289
387
  if (sessionId && !spaceId) state.homePath = cwd;
@@ -313,26 +411,39 @@ async function resolveDocumentScope(ctx, sourcePath, fallbackCwd) {
313
411
  const spaceName = space.name || space.alias || path3.basename(space.directoryPath);
314
412
  return { scope: space.directoryPath, spaceId: space.id, spaceName, scopeLabel: spaceName, scopeKind: "space" };
315
413
  }
316
- const workspaceRoot = ctx.workspace.projectPath;
317
- if (workspaceRoot && pathBelongsTo(workspaceRoot, sourcePath)) {
318
- return { scope: workspaceRoot, scopeKind: "workspace" };
319
- }
320
414
  if (fallbackCwd && path3.isAbsolute(fallbackCwd) && pathBelongsTo(fallbackCwd, sourcePath)) {
321
415
  return { scope: fallbackCwd, scopeKind: "workspace" };
322
416
  }
323
417
  return { scope: path3.dirname(sourcePath), scopeKind: "external" };
324
418
  }
419
+ async function describePanelScope(ctx, scopeDir, sessionSpaceId) {
420
+ const spaces = await ctx.spaces.list().catch(() => []);
421
+ const named = (space) => space.name || space.alias || (space.directoryPath ? path3.basename(space.directoryPath) : space.id);
422
+ if (sessionSpaceId) {
423
+ const space = spaces.find((entry) => entry.id === sessionSpaceId);
424
+ if (space) {
425
+ const label = named(space);
426
+ return { spaceId: space.id, spaceName: label, scopeLabel: label, scopeKind: "space", scopePath: space.directoryPath || scopeDir };
427
+ }
428
+ }
429
+ const match = spaces.filter((space) => path3.isAbsolute(space.directoryPath ?? "") && pathBelongsTo(space.directoryPath, scopeDir)).sort((a, b) => (b.directoryPath?.length ?? 0) - (a.directoryPath?.length ?? 0))[0];
430
+ if (match?.directoryPath) {
431
+ const label = named(match);
432
+ return { spaceId: match.id, spaceName: label, scopeLabel: label, scopeKind: "space", scopePath: match.directoryPath };
433
+ }
434
+ return { scopeKind: "workspace", scopePath: scopeDir };
435
+ }
325
436
  async function rememberPanelRecentScope(ctx, panel, cwd, sessionId, spaceId) {
326
437
  try {
327
- await mkdir2(ctx.storagePath, { recursive: true });
328
- const state = await readLastPathState(ctx);
329
- const resolved = resolveRecentScope(state, cwd, sessionId, spaceId);
330
- if (resolved.scope) {
331
- state.panelRecentScopes = { ...state.panelRecentScopes, [panel.id]: resolved.scope };
332
- const panelPath = state.panels?.[panel.id];
333
- if (panelPath) addRecentPath(state, resolved.scope, panelPath);
334
- }
335
- await writeFile2(stateFile(ctx), JSON.stringify(state), "utf8");
438
+ let resolved = {};
439
+ await mutateState(ctx, (state) => {
440
+ resolved = resolveRecentScope(state, cwd, sessionId, spaceId);
441
+ if (resolved.scope) {
442
+ state.panelRecentScopes = { ...state.panelRecentScopes, [panel.id]: resolved.scope };
443
+ const panelPath = state.panels?.[panel.id];
444
+ if (panelPath) addRecentPath(state, resolved.scope, panelPath);
445
+ }
446
+ });
336
447
  return resolved;
337
448
  } catch (error) {
338
449
  ctx.logger.warn(`Could not persist panel recent scope: ${String(error)}`);
@@ -341,32 +452,42 @@ async function rememberPanelRecentScope(ctx, panel, cwd, sessionId, spaceId) {
341
452
  }
342
453
  async function rememberLastPath(ctx, panel, sourcePath) {
343
454
  try {
344
- await mkdir2(ctx.storagePath, { recursive: true });
345
- const state = await readLastPathState(ctx);
346
- state.panels = { ...state.panels, [panel.id]: sourcePath };
347
- state.sessions = { ...state.sessions, [sessionBucketKey(panel)]: sourcePath };
348
- let scope = state.panelRecentScopes?.[panel.id];
349
- if (!scope && panel.view === "appView") {
350
- scope = (await resolveDocumentScope(ctx, sourcePath)).scope;
351
- state.panelRecentScopes = { ...state.panelRecentScopes, [panel.id]: scope };
352
- }
353
- if (scope) addRecentPath(state, scope, sourcePath);
354
- await writeFile2(stateFile(ctx), JSON.stringify(state), "utf8");
455
+ await mutateState(ctx, async (state) => {
456
+ state.panels = { ...state.panels, [panel.id]: sourcePath };
457
+ state.sessions = { ...state.sessions, [sessionBucketKey(panel)]: sourcePath };
458
+ let scope = state.panelRecentScopes?.[panel.id];
459
+ if (!scope && panel.view === "appView") {
460
+ scope = (await resolveDocumentScope(ctx, sourcePath)).scope;
461
+ state.panelRecentScopes = { ...state.panelRecentScopes, [panel.id]: scope };
462
+ }
463
+ if (scope) addRecentPath(state, scope, sourcePath);
464
+ });
355
465
  } catch (error) {
356
466
  ctx.logger.warn(`Could not persist last-opened path: ${String(error)}`);
357
467
  }
358
468
  }
359
469
  async function rememberRecentPath(ctx, sourcePath, panel) {
360
470
  try {
361
- await mkdir2(ctx.storagePath, { recursive: true });
362
- const state = await readLastPathState(ctx);
363
- const scope = panel ? state.panelRecentScopes?.[panel.id] : resolveRecentScope(state, ctx.session.cwd ?? "", ctx.session.id ?? "", ctx.session.spaceId ?? "").scope;
364
- if (scope) addRecentPath(state, scope, sourcePath);
365
- await writeFile2(stateFile(ctx), JSON.stringify(state), "utf8");
471
+ await mutateState(ctx, (state) => {
472
+ const scope = panel ? state.panelRecentScopes?.[panel.id] : resolveRecentScope(state, ctx.session.cwd ?? "", ctx.session.id ?? "", ctx.session.spaceId ?? "").scope;
473
+ if (scope) addRecentPath(state, scope, sourcePath);
474
+ });
366
475
  } catch (error) {
367
476
  ctx.logger.warn(`Could not persist recent path: ${String(error)}`);
368
477
  }
369
478
  }
479
+ async function forgetLastPath(ctx, panel) {
480
+ try {
481
+ await mutateState(ctx, (state) => {
482
+ const key = sessionBucketKey(panel);
483
+ state.panels = { ...state.panels, [panel.id]: "" };
484
+ state.sessions = { ...state.sessions, [key]: "" };
485
+ if (key === "__global__" && typeof state.lastPath === "string") state.lastPath = "";
486
+ });
487
+ } catch (error) {
488
+ ctx.logger.warn(`Could not clear last-opened path: ${String(error)}`);
489
+ }
490
+ }
370
491
  async function readLastPath(ctx, panel) {
371
492
  const state = await readLastPathState(ctx);
372
493
  const perPanel = state.panels?.[panel.id];
@@ -402,36 +523,24 @@ async function collectRecentDocuments(ctx, requestedCwd, sessionId, spaceId) {
402
523
  const resolved = resolveRecentScope(state, cwd, sessionId, spaceId);
403
524
  if (!resolved.scope) return { documents: [] };
404
525
  const scope = resolved.scope;
405
- const candidates = (state.recentPathsByScope?.[scope] ?? []).filter(
406
- (value, index, values) => typeof value === "string" && path3.isAbsolute(value) && isMarkdownPath(value) && values.indexOf(value) === index
407
- );
408
- const documents = await Promise.all(
409
- candidates.map(async (filePath) => {
410
- try {
411
- const info = await stat(filePath);
412
- if (!info.isFile()) return void 0;
413
- const markdown = await readFile2(filePath, "utf8");
414
- const fileName = path3.basename(filePath);
415
- return {
416
- path: filePath,
417
- relativePath: path3.relative(scope, filePath),
418
- fileName,
419
- title: deriveTitle(markdown, fileName.replace(/\.[^.]+$/, "")),
420
- preview: derivePreview(markdown),
421
- modifiedAt: info.mtimeMs
422
- };
423
- } catch {
424
- return void 0;
425
- }
426
- })
427
- );
526
+ const descriptor = await describePanelScope(ctx, scope, spaceId);
527
+ const scopeRoot = descriptor.scopePath && path3.isAbsolute(descriptor.scopePath) ? descriptor.scopePath : scope;
528
+ const classified = await classifyTrackedDocuments(ctx, state);
529
+ const documents = classified.filter((doc) => descriptor.spaceId ? doc.spaceId === descriptor.spaceId : !doc.spaceId && pathBelongsTo(scopeRoot, doc.path)).map((doc) => ({
530
+ ...doc,
531
+ relativePath: path3.relative(scopeRoot, doc.path),
532
+ spaceId: descriptor.spaceId,
533
+ spaceName: descriptor.spaceName,
534
+ scopeLabel: descriptor.scopeLabel,
535
+ scopeKind: descriptor.scopeKind,
536
+ scopePath: descriptor.scopePath
537
+ }));
428
538
  return {
429
- documents: documents.filter((entry) => Boolean(entry)).sort((a, b) => b.modifiedAt - a.modifiedAt).slice(0, RECENT_LIMIT),
539
+ documents: documents.slice(0, RECENT_LIMIT),
430
540
  fallbackCwd: resolved.fallbackCwd
431
541
  };
432
542
  }
433
- async function collectLibraryDocuments(ctx) {
434
- const state = await readLastPathState(ctx);
543
+ async function classifyTrackedDocuments(ctx, state) {
435
544
  const candidates = Object.values(state.recentPathsByScope ?? {}).flat().concat(Object.values(state.panels ?? {})).filter((value, index, values) => path3.isAbsolute(value) && isMarkdownPath(value) && values.indexOf(value) === index);
436
545
  const documents = await Promise.all(candidates.map(async (filePath) => {
437
546
  try {
@@ -448,24 +557,55 @@ async function collectLibraryDocuments(ctx) {
448
557
  spaceId: scope.spaceId,
449
558
  spaceName: scope.spaceName,
450
559
  scopeLabel: scope.scopeLabel,
451
- scopeKind: scope.scopeKind
560
+ scopeKind: scope.scopeKind,
561
+ scopePath: scope.scope
452
562
  };
453
563
  } catch {
454
564
  return void 0;
455
565
  }
456
566
  }));
457
- return documents.filter((entry) => Boolean(entry)).sort((a, b) => b.modifiedAt - a.modifiedAt).slice(0, RECENT_LIMIT);
567
+ return documents.filter((entry) => Boolean(entry)).sort((a, b) => b.modifiedAt - a.modifiedAt);
568
+ }
569
+ async function collectLibraryDocuments(ctx) {
570
+ const state = await readLastPathState(ctx);
571
+ const classified = await classifyTrackedDocuments(ctx, state);
572
+ const perCategory = /* @__PURE__ */ new Map();
573
+ return classified.filter((doc) => {
574
+ const key = doc.spaceId ? `space:${doc.spaceId}` : `${doc.scopeKind}:${doc.scopePath ?? ""}`;
575
+ const used = perCategory.get(key) ?? 0;
576
+ if (used >= RECENT_LIMIT) return false;
577
+ perCategory.set(key, used + 1);
578
+ return true;
579
+ });
458
580
  }
459
581
  var livePanelDocuments = /* @__PURE__ */ new Map();
582
+ var openPanels = /* @__PURE__ */ new Map();
583
+ var fileRevisions = /* @__PURE__ */ new Map();
584
+ function nextFileRevision(sourcePath) {
585
+ const next = (fileRevisions.get(sourcePath) ?? 0) + 1;
586
+ fileRevisions.set(sourcePath, next);
587
+ return next;
588
+ }
460
589
  async function sendDocument(panel, state) {
461
- livePanelDocuments.set(panel.id, state);
462
- await panel.postMessage({ type: "document", ...state });
590
+ const revision = state.path && path3.isAbsolute(state.path) ? state.revision ?? nextFileRevision(state.path) : state.revision;
591
+ const delivered = revision === void 0 ? state : { ...state, revision };
592
+ livePanelDocuments.set(panel.id, delivered);
593
+ await panel.postMessage({ type: "document", ...delivered });
594
+ }
595
+ async function publishFileUpdate(ctx, sourcePath) {
596
+ const markdown = await readFile2(sourcePath, "utf8");
597
+ const revision = nextFileRevision(sourcePath);
598
+ const state = { path: sourcePath, markdown, title: documentTitle(markdown, sourcePath), revision };
599
+ const targets = Array.from(openPanels.values()).filter((panel) => livePanelDocuments.get(panel.id)?.path === sourcePath);
600
+ await Promise.all(targets.map((panel) => sendDocument(panel, state).catch((error) => {
601
+ ctx.logger.warn(`Could not deliver written file to panel ${panel.id}: ${String(error)}`);
602
+ })));
463
603
  }
464
604
  async function sendLiveDocument(ctx, panel, liveDocument) {
465
605
  if (liveDocument.path && path3.isAbsolute(liveDocument.path)) {
466
606
  try {
467
607
  const { markdown, diskMarkdown, draftRestored, draftConflict } = await readFileWithDraft(ctx, liveDocument.path);
468
- await sendDocument(panel, { ...liveDocument, markdown, title: documentTitle(markdown, liveDocument.path), draftRestored, draftConflict, diskMarkdown });
608
+ await sendDocument(panel, { ...liveDocument, revision: void 0, markdown, title: documentTitle(markdown, liveDocument.path), draftRestored, draftConflict, diskMarkdown });
469
609
  return;
470
610
  } catch (error) {
471
611
  ctx.logger.warn(`Could not re-read ${liveDocument.path} for a reconnecting panel, resending cached copy: ${String(error)}`);
@@ -511,8 +651,10 @@ function watchSource(ctx, panel, sourcePath) {
511
651
  entry.watcher = watch(sourcePath, () => {
512
652
  if (entry.timer) clearTimeout(entry.timer);
513
653
  entry.timer = setTimeout(async () => {
654
+ const revisionBeforeRead = fileRevisions.get(sourcePath) ?? 0;
514
655
  try {
515
656
  const markdown = await readFile2(sourcePath, "utf8");
657
+ if (panelWatchers.get(panel.id) !== entry || (fileRevisions.get(sourcePath) ?? 0) !== revisionBeforeRead) return;
516
658
  await sendDocument(panel, { path: sourcePath, markdown, title: documentTitle(markdown, sourcePath) });
517
659
  } catch (error) {
518
660
  ctx.logger.warn(`Source refresh failed: ${String(error)}`);
@@ -542,14 +684,18 @@ async function getAssistantName(ctx) {
542
684
  }
543
685
  async function sendReady(ctx, panel) {
544
686
  const pickFileSupported = ctx.api.supports("ui.pickFile");
545
- const styleSlots = await readStyleSlots(ctx);
546
- const assistantName = await getAssistantName(ctx);
687
+ const [styleSlots, writingPreferences, assistantName] = await Promise.all([
688
+ readStyleSlots(ctx),
689
+ readWritingPreferences(ctx),
690
+ getAssistantName(ctx)
691
+ ]);
547
692
  ctx.logger.info(`sending ready to panel; pickFileSupported = ${pickFileSupported}`);
548
693
  await panel.postMessage({
549
694
  type: "ready",
550
695
  locale: ctx.i18n.locale,
551
696
  pickFileSupported,
552
697
  styleSlots,
698
+ writingPreferences,
553
699
  assistantName,
554
700
  // So the page can render `cwd` the OS-friendly way (`~/…`) without a
555
701
  // round trip — it never needs the raw value for anything but display.
@@ -580,13 +726,60 @@ async function readRewriteSession(ctx, sourcePath) {
580
726
  }
581
727
  async function rememberRewriteSession(ctx, sourcePath, sessionId) {
582
728
  try {
583
- const state = await readLastPathState(ctx);
584
- state.rewriteSessions = { ...state.rewriteSessions, [sourcePath]: sessionId };
585
- await writeFile2(stateFile(ctx), JSON.stringify(state), "utf8");
729
+ await mutateState(ctx, (state) => {
730
+ state.rewriteSessions = { ...state.rewriteSessions, [sourcePath]: sessionId };
731
+ });
586
732
  } catch (error) {
587
733
  ctx.logger.warn(`Could not persist rewrite session: ${String(error)}`);
588
734
  }
589
735
  }
736
+ async function rememberStyleOperation(ctx, sourcePath, operation) {
737
+ await mutateState(ctx, (state) => {
738
+ state.styleOperations = { ...state.styleOperations, [sourcePath]: operation };
739
+ });
740
+ }
741
+ async function clearStyleOperation(ctx, sourcePath, turnId) {
742
+ await mutateState(ctx, (state) => {
743
+ if (state.styleOperations?.[sourcePath]?.turnId !== turnId) return false;
744
+ const operations = { ...state.styleOperations };
745
+ delete operations[sourcePath];
746
+ state.styleOperations = operations;
747
+ });
748
+ }
749
+ async function rememberPendingStyle(ctx, sourcePath, pending) {
750
+ await mutateState(ctx, (state) => {
751
+ state.pendingStyles = { ...state.pendingStyles, [sourcePath]: pending };
752
+ });
753
+ }
754
+ async function clearPendingStyle(ctx, sourcePath) {
755
+ await mutateState(ctx, (state) => {
756
+ if (!state.pendingStyles?.[sourcePath]) return false;
757
+ const pending = { ...state.pendingStyles };
758
+ delete pending[sourcePath];
759
+ state.pendingStyles = pending;
760
+ });
761
+ }
762
+ function findPanelsForPath(sourcePath) {
763
+ return Array.from(openPanels.values()).filter((panel) => livePanelDocuments.get(panel.id)?.path === sourcePath);
764
+ }
765
+ async function rememberRewriteOperation(ctx, sourcePath, operation) {
766
+ await mutateState(ctx, (state) => {
767
+ state.rewriteOperations = { ...state.rewriteOperations, [sourcePath]: operation };
768
+ });
769
+ }
770
+ async function clearRewriteOperation(ctx, sourcePath, turnId) {
771
+ await mutateState(ctx, (state) => {
772
+ if (state.rewriteOperations?.[sourcePath]?.turnId !== turnId) return false;
773
+ const operations = { ...state.rewriteOperations };
774
+ delete operations[sourcePath];
775
+ state.rewriteOperations = operations;
776
+ });
777
+ }
778
+ async function notifyRewritePanels(sourcePath, message) {
779
+ const targets = Array.from(openPanels.values()).filter((panel) => livePanelDocuments.get(panel.id)?.path === sourcePath);
780
+ await Promise.all(targets.map((panel) => panel.postMessage(message).catch(() => {
781
+ })));
782
+ }
590
783
  async function startRewriteSession(ctx, panel, message) {
591
784
  const sourcePath = String(message.path ?? "").trim();
592
785
  const selectedText = String(message.selectedText ?? "").trim();
@@ -634,30 +827,104 @@ ${selectedText}
634
827
  await panel.postMessage({ type: "rewriteSessionFailed", message: "\u6539\u5199\u4F1A\u8BDD\u961F\u5217\u7E41\u5FD9\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002" });
635
828
  return;
636
829
  }
637
- await panel.postMessage({
830
+ const operation = {
831
+ sessionId,
832
+ turnId: receipt.turnId,
833
+ startLine: message.startLine,
834
+ endLine: message.endLine ?? message.startLine,
835
+ rewriteMode,
836
+ startedAt: Date.now()
837
+ };
838
+ await rememberRewriteOperation(ctx, sourcePath, operation).catch((error) => ctx.logger.warn(`Could not persist rewrite operation: ${String(error)}`));
839
+ await notifyRewritePanels(sourcePath, {
638
840
  type: "rewriteSessionStarted",
639
841
  sessionId,
640
842
  spaceName: scope.spaceName,
641
843
  title: `${rewriteMode === "continue" ? "\u7EED\u5199" : "\u6539\u5199"}\uFF1A${path3.basename(sourcePath)}`,
642
- startLine: message.startLine,
643
- endLine: message.endLine ?? message.startLine,
844
+ startLine: operation.startLine,
845
+ endLine: operation.endLine,
644
846
  rewriteMode
645
847
  });
646
848
  void ctx.sessions.waitForTurn(sessionId, receipt.turnId, { timeoutMs: 6e5 }).then(async (result2) => {
647
849
  const verb = rewriteMode === "continue" ? "\u7EED\u5199" : "\u6539\u5199";
648
- await panel.postMessage({
850
+ await clearRewriteOperation(ctx, sourcePath, receipt.turnId).catch((error) => ctx.logger.warn(`Could not clear rewrite operation: ${String(error)}`));
851
+ await notifyRewritePanels(sourcePath, {
649
852
  type: result2.state === "completed" ? "rewriteSessionFinished" : "rewriteSessionFailed",
650
853
  sessionId,
651
854
  message: result2.state === "completed" ? `${verb}\u5DF2\u5B8C\u6210\u3002` : result2.state === "timeout" ? `${verb}\u4ECD\u5728\u4F1A\u8BDD\u4E2D\u7EE7\u7EED\u3002` : `${verb}\u4F1A\u8BDD\u672A\u5B8C\u6210\u3002`
652
- }).catch(() => {
653
855
  });
654
856
  });
655
857
  }
858
+ async function readStyleSession(ctx, sourcePath) {
859
+ const state = await readLastPathState(ctx);
860
+ const id = state.styleSessions?.[sourcePath];
861
+ if (!id) return void 0;
862
+ const session = await ctx.sessions.get(id).catch(() => void 0);
863
+ return session ? id : void 0;
864
+ }
865
+ async function rememberStyleSession(ctx, sourcePath, sessionId) {
866
+ try {
867
+ await mutateState(ctx, (state) => {
868
+ state.styleSessions = { ...state.styleSessions, [sourcePath]: sessionId };
869
+ });
870
+ } catch (error) {
871
+ ctx.logger.warn(`Could not persist style session: ${String(error)}`);
872
+ }
873
+ }
874
+ async function startStyleSession(ctx, panel, message) {
875
+ const sourcePath = String(message.path ?? "").trim();
876
+ if (panel.view !== "appView" || !path3.isAbsolute(sourcePath)) {
877
+ await panel.postMessage({ type: "styleSessionFailed", message: "\u8BA9 AI \u8BBE\u8BA1\u6392\u7248\u9700\u8981 App View \u4E2D\u5DF2\u4FDD\u5B58\u7684\u672C\u5730\u6587\u6863\u3002" });
878
+ return;
879
+ }
880
+ const requirement = String(message.requirement ?? "").trim() || "\u8BA9\u6392\u7248\u66F4\u6E05\u6670\u7F8E\u89C2\uFF0C\u8D34\u5408\u6587\u7AE0\u5185\u5BB9\u548C\u8BED\u6C14";
881
+ const baseStyle = String(message.baseStyle ?? "").trim();
882
+ const baseNote = baseStyle === "custom" ? "\u5F53\u524D\u57FA\u7840\u98CE\u683C\u662F kami\uFF08\u81EA\u5B9A\u4E49 CSS \u53E0\u52A0\u5176\u4E0A\uFF09" : baseStyle ? `\u5F53\u524D\u57FA\u7840\u98CE\u683C\u662F ${baseStyle}` : "";
883
+ const scope = await resolveDocumentScope(ctx, sourcePath);
884
+ let sessionId = await readStyleSession(ctx, sourcePath);
885
+ if (!sessionId) {
886
+ const session = await ctx.sessions.create({
887
+ ...scope.spaceId ? { space: { spaceId: scope.spaceId } } : {},
888
+ title: `\u8BBE\u8BA1\u6392\u7248\uFF1A${path3.basename(sourcePath)}`,
889
+ activity: "interactive",
890
+ permissionMode: "acceptCalls"
891
+ });
892
+ sessionId = session.sessionId;
893
+ await rememberStyleSession(ctx, sourcePath, sessionId);
894
+ }
895
+ const prompt = `\u8BF7\u4E3A\u8FD9\u7BC7\u516C\u4F17\u53F7\u6587\u7AE0\u8BBE\u8BA1\u4E00\u5957\u81EA\u5B9A\u4E49\u6392\u7248 CSS\u3002${baseNote ? baseNote + "\uFF0C" : ""}\u4F60\u7684 CSS \u4F1A\u53E0\u52A0\u5728\u57FA\u7840\u98CE\u683C\u4E4B\u4E0A\u3002\u8981\u6C42\uFF1A\u53EA\u5199\u666E\u901A CSS \u89C4\u5219\uFF0C\u9009\u62E9\u5668\u9650\u5B9A\u5728 #bm-md \u4E0B\u7684\u6807\u7B7E/\u7ED3\u6784\uFF08\u5982 #bm-md h1\u3001#bm-md p\u3001#bm-md blockquote\u3001#bm-md pre code\u3001#bm-md a\u3001#bm-md strong\u3001#bm-md table \u7B49\uFF09\uFF0C\u4E0D\u8981\u4F7F\u7528 class\uFF0C\u5FC5\u8981\u65F6\u7528 !important \u8986\u76D6\u57FA\u7840\u98CE\u683C\u3002\u53EF\u53C2\u8003 bm.md \u5185\u7F6E\u98CE\u683C\u7684\u8BBE\u8BA1\u8BED\u8A00\uFF1Akami\uFF08\u6696\u8272\u7EB8\u611F\uFF09\u3001bauhaus\uFF08\u51E0\u4F55\u649E\u8272\uFF09\u3001blueprint\uFF08\u6280\u672F\u84DD\u56FE\u7F51\u683C\uFF09\u3001botanical\uFF08\u6E05\u65B0\u7EFF\u610F\uFF09\u3001newsprint\uFF08\u62A5\u520A\u886C\u7EBF\uFF09\u3001retro\uFF08\u590D\u53E4\u6000\u65E7\uFF09\u3001sketch\uFF08\u624B\u7ED8\u98CE\uFF09\u3001terminal\uFF08\u7B49\u5BBD\u6697\u8272\u7EC8\u7AEF\u98CE\uFF09\u3002\u6587\u7AE0\u8DEF\u5F84\uFF1A${sourcePath}\u3002\u8981\u6C42\uFF1A${requirement}\u3002\u8BBE\u8BA1\u597D\u540E\u76F4\u63A5\u8C03\u7528 markdown_editor_document \u7684 set_style\uFF08\u4F20 path="${sourcePath}"\uFF0Ccss \u548C\u7B80\u77ED label\uFF0C\u4E0D\u8981\u4F20 slot\u2014\u2014\u4F20 path \u662F\u4E3A\u4E86\u8BA9\u5B83\u80FD\u627E\u5230\u8FD9\u7BC7\u6587\u6863\u5BF9\u5E94\u7684\u9884\u89C8\u7A97\u53E3\uFF0C\u5373\u4F7F\u7528\u6237\u5DF2\u7ECF\u5207\u6362\u5230\u522B\u7684\u754C\u9762\uFF09\uFF0C\u8BA9\u5B83\u5E94\u7528\u5230\u9884\u89C8\uFF1B\u4E0D\u8981\u5728\u8FD9\u91CC\u8BE2\u95EE\u8981\u8986\u76D6\u54EA\u4E2A\u69FD\u4F4D\u2014\u2014\u9762\u677F\u4F1A\u81EA\u5DF1\u7ED9\u7528\u6237\u4E00\u4E2A\u8F7B\u91CF\u7684\u201C\u4FDD\u5B58\u4E3A\u81EA\u5B9A\u4E49\u98CE\u683C\u201D\u6309\u94AE\uFF0C\u7528\u6237\u56DE\u5230\u8FD9\u7BC7\u6587\u6863\u65F6\u4E5F\u8FD8\u80FD\u770B\u5230\u3002\u5B8C\u6210\u540E\u7528\u4E00\u4E24\u53E5\u8BDD\u7B80\u77ED\u8BF4\u660E\u8BBE\u8BA1\u601D\u8DEF\u5373\u53EF\u3002`;
896
+ const receipt = await ctx.sessions.send(sessionId, {
897
+ text: prompt,
898
+ idempotencyKey: `style-${createHash3("sha256").update(`${sourcePath}:${requirement}:${Date.now()}`).digest("hex")}`
899
+ });
900
+ if (receipt.state === "rejected") {
901
+ await panel.postMessage({ type: "styleSessionFailed", message: "\u6392\u7248\u8BBE\u8BA1\u4F1A\u8BDD\u961F\u5217\u7E41\u5FD9\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002" });
902
+ return;
903
+ }
904
+ await rememberStyleOperation(ctx, sourcePath, { sessionId, turnId: receipt.turnId, startedAt: Date.now() });
905
+ await panel.postMessage({ type: "styleSessionStarted", sessionId, spaceName: scope.spaceName });
906
+ void ctx.sessions.waitForTurn(sessionId, receipt.turnId, { timeoutMs: 6e5 }).then(async (result2) => {
907
+ await clearStyleOperation(ctx, sourcePath, receipt.turnId);
908
+ const targets = findPanelsForPath(sourcePath);
909
+ await Promise.all((targets.length ? targets : [panel]).map((target) => target.postMessage({
910
+ type: result2.state === "completed" ? "styleSessionFinished" : "styleSessionFailed",
911
+ sessionId,
912
+ message: result2.state === "completed" ? "\u6392\u7248\u8BBE\u8BA1\u5DF2\u5B8C\u6210\u3002" : result2.state === "timeout" ? "\u6392\u7248\u8BBE\u8BA1\u4ECD\u5728\u4F1A\u8BDD\u4E2D\u7EE7\u7EED\u3002" : "\u6392\u7248\u8BBE\u8BA1\u4F1A\u8BDD\u672A\u5B8C\u6210\u3002"
913
+ }).catch(() => {
914
+ })));
915
+ });
916
+ }
656
917
  async function handleMessage(ctx, panel, raw) {
657
918
  const message = raw;
658
919
  switch (message.type) {
659
920
  case "clientLog": {
660
- ctx.logger.info(`[panel] ${String(message.message ?? "")}`);
921
+ const text = `[panel] ${String(message.message ?? "")}`;
922
+ ctx.logger.info(text);
923
+ try {
924
+ await appendFile(path3.join(ctx.storagePath, "panel-errors.log"), `${(/* @__PURE__ */ new Date()).toISOString()} ${text}
925
+ `);
926
+ } catch (_) {
927
+ }
661
928
  return;
662
929
  }
663
930
  case "panelReady": {
@@ -668,6 +935,22 @@ async function handleMessage(ctx, panel, raw) {
668
935
  } else if (!await restoreDocument(ctx, panel)) {
669
936
  await panel.postMessage({ type: "lastFileUnavailable" });
670
937
  }
938
+ const currentPath = livePanelDocuments.get(panel.id)?.path;
939
+ if (currentPath) {
940
+ const state = await readLastPathState(ctx);
941
+ const operation = state.rewriteOperations?.[currentPath];
942
+ if (operation) await panel.postMessage({
943
+ type: "rewriteSessionStarted",
944
+ sessionId: operation.sessionId,
945
+ startLine: operation.startLine,
946
+ endLine: operation.endLine,
947
+ rewriteMode: operation.rewriteMode
948
+ });
949
+ const styleOperation = state.styleOperations?.[currentPath];
950
+ if (styleOperation) await panel.postMessage({ type: "styleSessionStarted", sessionId: styleOperation.sessionId });
951
+ const pendingStyle = state.pendingStyles?.[currentPath];
952
+ if (pendingStyle) await panel.postMessage({ type: "customStyleSet", css: pendingStyle.css, label: pendingStyle.label });
953
+ }
671
954
  return;
672
955
  }
673
956
  case "openImage": {
@@ -768,6 +1051,7 @@ async function handleMessage(ctx, panel, raw) {
768
1051
  case "goHome": {
769
1052
  stopWatching(panel.id);
770
1053
  livePanelDocuments.delete(panel.id);
1054
+ await forgetLastPath(ctx, panel);
771
1055
  return;
772
1056
  }
773
1057
  case "requestRecentDocuments": {
@@ -793,6 +1077,10 @@ async function handleMessage(ctx, panel, raw) {
793
1077
  await startRewriteSession(ctx, panel, message);
794
1078
  return;
795
1079
  }
1080
+ case "requestStyleSession": {
1081
+ await startStyleSession(ctx, panel, message);
1082
+ return;
1083
+ }
796
1084
  case "saveMarkdown": {
797
1085
  const sourcePath = String(message.path ?? "").trim();
798
1086
  if (!path3.isAbsolute(sourcePath)) return;
@@ -806,6 +1094,14 @@ async function handleMessage(ctx, panel, raw) {
806
1094
  }
807
1095
  return;
808
1096
  }
1097
+ case "saveWritingPreferences": {
1098
+ try {
1099
+ await writeWritingPreferences(ctx, message.preferences);
1100
+ } catch (error) {
1101
+ ctx.logger.warn(`Could not save writing preferences: ${String(error)}`);
1102
+ }
1103
+ return;
1104
+ }
809
1105
  case "saveDraft": {
810
1106
  const sourcePath = String(message.path ?? "").trim();
811
1107
  if (!path3.isAbsolute(sourcePath)) return;
@@ -859,6 +1155,7 @@ async function handleMessage(ctx, panel, raw) {
859
1155
  const current = await readFile2(sourcePath, "utf8");
860
1156
  const appliedMarkdown = preserveTextEnvelope(current, markdown);
861
1157
  await writeFile2(sourcePath, appliedMarkdown, "utf8");
1158
+ await publishFileUpdate(ctx, sourcePath);
862
1159
  await rememberRecentPath(ctx, sourcePath, panel);
863
1160
  await panel.postMessage({ type: "applied", path: sourcePath, title: documentTitle(appliedMarkdown, sourcePath) });
864
1161
  } catch (error) {
@@ -907,6 +1204,8 @@ async function handleMessage(ctx, panel, raw) {
907
1204
  }
908
1205
  try {
909
1206
  const slots = await writeStyleSlot(ctx, slot, { css, label: String(message.label ?? "").trim() || "\u81EA\u5B9A\u4E49\u98CE\u683C" });
1207
+ const sourcePath = String(message.path ?? "").trim();
1208
+ if (sourcePath) await clearPendingStyle(ctx, sourcePath);
910
1209
  await panel.postMessage({ type: "styleSlots", styleSlots: slots, savedSlot: slot });
911
1210
  } catch (error) {
912
1211
  await panel.postMessage({ type: "error", message: `Could not save style slot: ${error instanceof Error ? error.message : String(error)}` });
@@ -973,6 +1272,7 @@ function activate(ctx) {
973
1272
  }
974
1273
  }));
975
1274
  ctx.subscriptions.push(ctx.ui.onDidOpenPanel((panel) => {
1275
+ openPanels.set(panel.id, panel);
976
1276
  if (panel.visible) lastPanel = panel;
977
1277
  ctx.subscriptions.push(panel.onDidReceiveMessage((message) => {
978
1278
  handleMessage(ctx, panel, message).catch((error) => {
@@ -985,6 +1285,7 @@ function activate(ctx) {
985
1285
  }));
986
1286
  ctx.subscriptions.push(panel.onDidDispose(() => {
987
1287
  stopWatching(panel.id);
1288
+ openPanels.delete(panel.id);
988
1289
  livePanelDocuments.delete(panel.id);
989
1290
  flushPendingDraftWrite(ctx, panel.id);
990
1291
  if (lastPanel === panel) lastPanel = void 0;
@@ -999,12 +1300,12 @@ action:
999
1300
  open \u2014 read an absolute local Markdown path and open it as an editable WeChat article preview
1000
1301
  create \u2014 write brand-new Markdown content to an absolute path that does not exist yet, then open it in Markdown Editor. Use this whenever the user asks to write an article, start writing, write a post, create, or draft a new document \u2014 even if they do not mention Markdown. If title/topic or destination is missing, guide the user to provide it; once known, create and open the document rather than returning prose only. If they only want to begin, create a minimal titled starter document. Markdown Editor's own UI has no "new file" button on purpose \u2014 this tool action is the intended way to start a new document
1001
1302
  apply \u2014 revise a source document (requires path). For a small, targeted change, pass edits instead of markdown: an array of {old_string, new_string} replacements matched against the file's current on-disk content, the same find-and-replace contract as a code editor's Edit tool \u2014 this avoids resending the whole document and keeps the on-screen highlight scoped to what actually changed. Reserve markdown (the full updated document) for a genuine full rewrite. Once this conversation has started editing a .md document through Markdown Editor, always use this apply/edits path for subsequent changes to that same file before considering the built-in Edit tool: it refreshes the panel and highlights the exact change. Fall back to the built-in Edit tool only after this apply actually fails. The open panel refreshes in place, no Diff window. Whenever you propose a rewrite and wait for approval before applying it, calling Session action=suggest with 1-3 one-tap confirmations is MANDATORY, not optional, and part of that same turn \u2014 sending the proposal text alone does not complete the confirmation step, so do not end the turn without also calling it
1002
- set_style \u2014 apply an AI-designed custom CSS layout to the currently open Markdown Editor preview (requires css). Write plain CSS scoped under #bm-md using tag/id selectors (no classes), use !important where needed to override the base style, and take inspiration from bm.md's built-in styles: kami (warm paper), bauhaus (geometric primary colors), blueprint (technical grid), botanical (soft green), newsprint (editorial serif), retro (nostalgic), sketch (hand-drawn), terminal (monospace dark).`,
1303
+ set_style \u2014 apply an AI-designed custom CSS layout to the currently open Markdown Editor preview (requires css). Apply it right away, without asking the user which reusable slot to use first \u2014 omit \`slot\` and it only updates the live preview; the panel itself then shows a lightweight one-tap prompt so the user decides whether to save it into a reusable custom-style slot, no chat back-and-forth needed. Only pass \`slot\` when the user already told you which of the 3 slots (1/2/3) to save into. Write plain CSS scoped under #bm-md using tag/id selectors (no classes), use !important where needed to override the base style, and take inspiration from bm.md's built-in styles: kami (warm paper), bauhaus (geometric primary colors), blueprint (technical grid), botanical (soft green), newsprint (editorial serif), retro (nostalgic), sketch (hand-drawn), terminal (monospace dark).`,
1003
1304
  inputSchema: {
1004
1305
  type: "object",
1005
1306
  properties: {
1006
1307
  action: { type: "string", enum: ["open", "create", "apply", "set_style"], description: "Operation to perform." },
1007
- path: { type: "string", description: "Absolute path to the Markdown file. Required for open, create, and apply. For create, the file must not already exist." },
1308
+ path: { type: "string", description: "Absolute path to the Markdown file. Required for open, create, and apply. For create, the file must not already exist. Recommended (though optional) for set_style: passing it lets Markdown Editor find the right panel for this document even if it is not the most recently focused one \u2014 e.g. the user switched away while an App View design Session was still working. Without it, set_style falls back to whichever panel was last focused." },
1008
1309
  markdown: { type: "string", description: "Full Markdown content. Required for create. For apply, use this only for a genuine full rewrite \u2014 prefer `edits` for a small, targeted change." },
1009
1310
  edits: {
1010
1311
  type: "array",
@@ -1021,7 +1322,7 @@ action:
1021
1322
  },
1022
1323
  css: { type: "string", description: "Custom CSS to layer on top of the current base style, required for set_style." },
1023
1324
  label: { type: "string", description: "Short label describing the custom style, optional for set_style." },
1024
- slot: { type: "number", enum: [1, 2, 3], description: "Required for AI-designed styles: user-selected reusable custom style slot to overwrite." }
1325
+ slot: { type: "number", enum: [1, 2, 3], description: "Optional for set_style. Omit it to just apply the design to the live preview \u2014 the panel will offer the user a one-tap way to save it afterward. Only pass this when the user already picked which of the 3 reusable custom style slots (1, 2, or 3) to overwrite." }
1025
1326
  },
1026
1327
  required: ["action"]
1027
1328
  },
@@ -1076,6 +1377,7 @@ action:
1076
1377
  const applied = applyEditSpecs(current, edits);
1077
1378
  if (!applied.ok) return result(applied.error, true);
1078
1379
  await writeFile2(sourcePath, applied.content, "utf8");
1380
+ await publishFileUpdate(ctx, sourcePath);
1079
1381
  await rememberRecentPath(ctx, sourcePath);
1080
1382
  return result(`Applied ${edits.length} targeted edit${edits.length > 1 ? "s" : ""} to ${path3.basename(sourcePath)}.`);
1081
1383
  } catch (error) {
@@ -1087,6 +1389,7 @@ action:
1087
1389
  try {
1088
1390
  const current = await readFile2(sourcePath, "utf8");
1089
1391
  await writeFile2(sourcePath, preserveTextEnvelope(current, markdown), "utf8");
1392
+ await publishFileUpdate(ctx, sourcePath);
1090
1393
  await rememberRecentPath(ctx, sourcePath);
1091
1394
  return result(`Applied reviewed Markdown to ${path3.basename(sourcePath)}.`);
1092
1395
  } catch (error) {
@@ -1096,16 +1399,32 @@ action:
1096
1399
  if (action === "set_style") {
1097
1400
  const css = String(input.css ?? "").trim();
1098
1401
  if (!css) return result("`set_style` requires non-empty `css`.", true);
1099
- if (!lastPanel) return result("No Markdown Editor panel is open. Ask the user to open a document first.", true);
1402
+ const pathHint = String(input.path ?? "").trim();
1403
+ const targets = pathHint ? findPanelsForPath(pathHint) : [];
1404
+ const panel = targets[0] ?? lastPanel;
1405
+ if (!panel && !pathHint) return result("No Markdown Editor panel is open. Ask the user to open a document first.", true);
1100
1406
  try {
1101
1407
  const label = String(input.label ?? "") || "AI style";
1408
+ const hasSlot = input.slot !== void 0 && input.slot !== null && String(input.slot).trim() !== "";
1409
+ if (!hasSlot) {
1410
+ if (pathHint) await rememberPendingStyle(ctx, pathHint, { css, label });
1411
+ if (panel) {
1412
+ await panel.postMessage({ type: "customStyleSet", css, label });
1413
+ return result("Custom style applied to the open Markdown Editor preview. It is not saved to a reusable slot yet \u2014 the panel now shows a one-tap prompt for the user to save it themselves; do not ask which slot in chat unless the user asks you to save it directly.");
1414
+ }
1415
+ return result("No Markdown Editor panel is currently open for this document, so the style was saved for later \u2014 it will apply automatically (with the same one-tap save prompt) the next time the user opens this file in Markdown Editor.");
1416
+ }
1102
1417
  const slot = Number(input.slot);
1103
1418
  if (!Number.isInteger(slot) || slot < 1 || slot > STYLE_SLOT_COUNT) {
1104
- return result("Ask the user which reusable custom style slot (1, 2, or 3) to overwrite, then call `set_style` again with that `slot`.", true);
1419
+ return result("`slot` must be 1, 2, or 3 when provided.", true);
1105
1420
  }
1106
1421
  const slots = await writeStyleSlot(ctx, slot - 1, { css, label });
1107
- await lastPanel.postMessage({ type: "customStyleSet", css, label, styleSlots: slots, savedSlot: slot - 1 });
1108
- return result(`Custom style saved to slot ${slot} and applied to the open Markdown Editor panel.`);
1422
+ if (pathHint) await clearPendingStyle(ctx, pathHint);
1423
+ if (panel) {
1424
+ await panel.postMessage({ type: "customStyleSet", css, label, styleSlots: slots, savedSlot: slot - 1 });
1425
+ return result(`Custom style saved to slot ${slot} and applied to the open Markdown Editor panel.`);
1426
+ }
1427
+ return result(`Custom style saved to slot ${slot}. No Markdown Editor panel is currently open for this document \u2014 it will apply automatically the next time the user opens this file.`);
1109
1428
  } catch (error) {
1110
1429
  return result(`Could not apply custom style: ${error instanceof Error ? error.message : String(error)}`, true);
1111
1430
  }