mellos-mapping 0.20.3 → 0.23.0

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.
Files changed (75) hide show
  1. package/README.md +130 -69
  2. package/README.zh-CN.md +126 -65
  3. package/dist/hook-session-start.mjs +28 -20
  4. package/dist/mmap.mjs +303 -171
  5. package/dist/preview.mjs +1457 -0
  6. package/dist/server.mjs +1984 -741
  7. package/dist/store-paths.mjs +69 -22
  8. package/dist/terminal-worker.mjs +3331 -0
  9. package/dist/watch.mjs +957 -571
  10. package/dist/web/TERMINAL-LICENSES.txt +70 -0
  11. package/dist/web/app.css +967 -0
  12. package/dist/web/app.js +1356 -0
  13. package/dist/web/index.html +9 -0
  14. package/dist/web/terminal.css +9 -0
  15. package/dist/web/terminal.html +7 -0
  16. package/dist/web/terminal.js +9293 -0
  17. package/dist/web/xterm.css +285 -0
  18. package/dist/web.mjs +5646 -0
  19. package/docs/codex.md +189 -0
  20. package/docs/map-api.md +148 -0
  21. package/lib/domain/context.d.ts +11 -0
  22. package/lib/domain/context.js +27 -0
  23. package/lib/domain/text.d.ts +9 -0
  24. package/lib/domain/text.js +54 -0
  25. package/lib/domain/types.d.ts +3 -0
  26. package/lib/preview/index.d.ts +3 -0
  27. package/lib/preview/index.js +3 -0
  28. package/lib/preview/markdown.d.ts +8 -0
  29. package/lib/preview/markdown.js +54 -0
  30. package/lib/preview/presentation.d.ts +6 -0
  31. package/lib/preview/presentation.js +14 -0
  32. package/lib/preview/publisher.d.ts +23 -0
  33. package/lib/preview/publisher.js +143 -0
  34. package/lib/preview/svg.d.ts +3 -0
  35. package/lib/preview/svg.js +74 -0
  36. package/lib/preview/text.d.ts +4 -0
  37. package/lib/preview/text.js +13 -0
  38. package/lib/render/canvas.d.ts +1 -1
  39. package/lib/render/canvas.js +4 -2
  40. package/lib/render/draw.js +9 -6
  41. package/lib/render/render.d.ts +6 -0
  42. package/lib/render/render.js +50 -18
  43. package/lib/render/width.js +3 -1
  44. package/lib/store/atomic.d.ts +18 -0
  45. package/lib/store/atomic.js +86 -0
  46. package/lib/store/channels.d.ts +40 -0
  47. package/lib/store/channels.js +135 -0
  48. package/lib/store/format.js +21 -4
  49. package/lib/store/json-text.d.ts +9 -0
  50. package/lib/store/json-text.js +16 -0
  51. package/lib/store/maps.d.ts +12 -0
  52. package/lib/store/maps.js +42 -0
  53. package/lib/store/migration.d.ts +12 -0
  54. package/lib/store/migration.js +46 -0
  55. package/lib/store/pages.d.ts +46 -0
  56. package/lib/store/pages.js +89 -0
  57. package/lib/store/policy.d.ts +74 -0
  58. package/lib/store/policy.js +144 -0
  59. package/lib/store/project.d.ts +2 -0
  60. package/lib/store/project.js +29 -0
  61. package/lib/store/store.d.ts +15 -257
  62. package/lib/store/store.js +16 -695
  63. package/lib/store/transaction.d.ts +12 -0
  64. package/lib/store/transaction.js +91 -0
  65. package/lib/store/viewers.d.ts +81 -0
  66. package/lib/store/viewers.js +186 -0
  67. package/package.json +27 -5
  68. package/scripts/codex-cli.mjs +42 -0
  69. package/scripts/codex-register.mjs +27 -94
  70. package/scripts/mmap.mjs +27 -17
  71. package/scripts/open-pane.mjs +37 -18
  72. package/scripts/pane-core.mjs +57 -220
  73. package/scripts/terminal-session.mjs +137 -0
  74. package/scripts/tmux-session.mjs +90 -0
  75. package/scripts/watcher-command.mjs +16 -0
@@ -0,0 +1,143 @@
1
+ /** Filesystem adapter for derived previews. Never writes back into map JSON. */
2
+ import { createHash } from 'node:crypto';
3
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmdirSync } from 'node:fs';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { EMPTY_MAP, ID_RULE, err, ok } from '../domain/types.js';
6
+ import { describeStoreError, listPageFiles, loadMapFile, pageIdOfFile, writeFileAtomic } from '../store/store.js';
7
+ import { renderMapMarkdown, renderPreviewIndex } from './markdown.js';
8
+ import { documentName } from './presentation.js';
9
+ import { renderMapSvg } from './svg.js';
10
+ export const PREVIEW_DIR_NAME = 'previews';
11
+ const ENABLED = '.enabled';
12
+ const PUBLISH_LOCK = '.publish-lock';
13
+ export function previewDirectory(defaultFile) {
14
+ return join(dirname(defaultFile), PREVIEW_DIR_NAME);
15
+ }
16
+ export function previewFile(defaultFile, page) {
17
+ if (page !== undefined && !ID_RULE.test(page))
18
+ throw new Error('Invalid preview page id.');
19
+ return join(previewDirectory(defaultFile), documentName(page));
20
+ }
21
+ function save(path, contents) {
22
+ try {
23
+ if (readFileSync(path, 'utf8') === contents)
24
+ return;
25
+ }
26
+ catch (error) {
27
+ if (error.code !== 'ENOENT')
28
+ throw error;
29
+ }
30
+ const result = writeFileAtomic(path, contents);
31
+ if (!result.ok)
32
+ throw new Error(describeStoreError(result.error));
33
+ }
34
+ /** Output directories cannot redirect generated writes through a junction. */
35
+ function ownedDirectory(path) {
36
+ mkdirSync(path, { recursive: true });
37
+ const expected = join(realpathSync(dirname(path)), path.slice(dirname(path).length + 1));
38
+ const actual = realpathSync(path);
39
+ if ((process.platform === 'win32' ? actual.toLowerCase() !== expected.toLowerCase() : actual !== expected)) {
40
+ throw new Error(`Preview directory redirects outside its parent: ${path}`);
41
+ }
42
+ }
43
+ /**
44
+ * Serialize project-wide projections across processes. Source saves stay
45
+ * independent; the next waiting publisher reads them AFTER taking this lock,
46
+ * so an older project snapshot cannot overwrite a later page's preview.
47
+ * A killed exporter leaves a visible failure, never a silently stale success.
48
+ */
49
+ function acquireLock(directory) {
50
+ const path = join(directory, PUBLISH_LOCK);
51
+ const deadline = Date.now() + 2_000;
52
+ while (true) {
53
+ try {
54
+ mkdirSync(path);
55
+ return () => rmdirSync(path);
56
+ }
57
+ catch (error) {
58
+ if (error.code !== 'EEXIST')
59
+ throw error;
60
+ if (Date.now() >= deadline)
61
+ throw new Error(`Preview export is busy or was interrupted. Retry; if no exporter is running, remove the stale lock directory: ${path}`);
62
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
63
+ }
64
+ }
65
+ }
66
+ /**
67
+ * Activation is project-local and survives MCP restarts. Each publisher reads
68
+ * it again, so concurrent clients cooperate without a shared server process.
69
+ * SVG names are content-addressed: a document always points at a complete
70
+ * image and file-preview caches cannot reuse the previous state's picture.
71
+ * Old images stay available to open documents; the preview directory is a
72
+ * disposable cache, never source history.
73
+ */
74
+ export function createPreviewPublisher(defaultFile) {
75
+ const directory = previewDirectory(defaultFile);
76
+ const enabledFile = join(directory, ENABLED);
77
+ const enabled = () => existsSync(enabledFile);
78
+ const refresh = (page) => {
79
+ try {
80
+ const path = previewFile(defaultFile, page);
81
+ ownedDirectory(directory);
82
+ const release = acquireLock(directory);
83
+ try {
84
+ const pages = [];
85
+ for (const source of listPageFiles(defaultFile)) {
86
+ const slug = pageIdOfFile(defaultFile, source);
87
+ if (slug !== undefined && !ID_RULE.test(slug))
88
+ throw new Error(`Invalid map page filename: ${source}`);
89
+ const loaded = loadMapFile(source);
90
+ if (!loaded.ok)
91
+ throw new Error(describeStoreError(loaded.error));
92
+ pages.push({ page: slug, map: loaded.value });
93
+ }
94
+ // An unopened project can show a standby default map. A misspelled named
95
+ // page must not manufacture a plausible empty diagram.
96
+ if (page !== undefined && !pages.some(p => p.page === page))
97
+ return err(`No map page named "${page}".`);
98
+ if (page === undefined && !pages.some(p => p.page === undefined))
99
+ pages.unshift({ page: undefined, map: EMPTY_MAP });
100
+ const images = join(directory, 'images');
101
+ ownedDirectory(images);
102
+ const present = new Set();
103
+ for (const item of pages) {
104
+ const svg = renderMapSvg(item.map);
105
+ const digest = createHash('sha256').update(svg).digest('hex');
106
+ const image = `images/${digest}.svg`;
107
+ save(join(images, `${digest}.svg`), svg);
108
+ const filename = documentName(item.page);
109
+ save(join(directory, filename), renderMapMarkdown(item.map, image, pages));
110
+ present.add(filename);
111
+ }
112
+ // An already-open deleted page must stop presenting its old state.
113
+ for (const filename of readdirSync(directory)) {
114
+ if (/^(map|page-[a-z0-9][a-z0-9-]{0,63})\.md$/.test(filename) && !present.has(filename)) {
115
+ save(join(directory, filename), '# 地图已删除\n\n此页面已不在项目地图中。\n\n[返回地图目录](index.md)\n');
116
+ }
117
+ }
118
+ const index = join(directory, 'index.md');
119
+ save(index, renderPreviewIndex(pages));
120
+ return ok({ path: resolve(path), index: resolve(index), pages: pages.length });
121
+ }
122
+ finally {
123
+ release();
124
+ }
125
+ }
126
+ catch (error) {
127
+ return err(error instanceof Error ? error.message : String(error));
128
+ }
129
+ };
130
+ const activate = (page) => {
131
+ const published = refresh(page);
132
+ if (!published.ok)
133
+ return published;
134
+ try {
135
+ save(enabledFile, 'Markdown preview updates are enabled for this project.\n');
136
+ }
137
+ catch (error) {
138
+ return err(error instanceof Error ? error.message : String(error));
139
+ }
140
+ return published;
141
+ };
142
+ return { enabled, refresh, activate };
143
+ }
@@ -0,0 +1,3 @@
1
+ /** Pure vector view. Reuses terminal layout/routing, without ANSI or browser I/O. */
2
+ import type { MellosMap } from '../domain/types.js';
3
+ export declare function renderMapSvg(map: MellosMap): string;
@@ -0,0 +1,74 @@
1
+ import { flipForSequence, isNeutralKind } from '../semantics/semantics.js';
2
+ import { layoutColumns, layoutRows } from '../render/layout.js';
3
+ import { edgePolyline, routeEdges } from '../render/routing.js';
4
+ import { displayWidth, fitWidth } from '../render/width.js';
5
+ import { zoomGeometry } from '../render/zoom-geometry.js';
6
+ import { isVerified, statusText } from './presentation.js';
7
+ import { xml } from './text.js';
8
+ const X = 8;
9
+ const Y = 26;
10
+ const TOP = 20;
11
+ const PALETTES = {
12
+ planned: ['#f5f7fa', '#98a3b2', '#566477'],
13
+ 'in-progress': ['#fff4d9', '#c48c24', '#805910'],
14
+ done: ['#e9f5ee', '#67a883', '#286247'],
15
+ regressed: ['#fdecec', '#cc7575', '#923d3d'],
16
+ unverified: ['#fff6e8', '#b49a77', '#785e3e'],
17
+ neutral: ['#f2f5f9', '#a1adbc', '#364558'],
18
+ };
19
+ export function renderMapSvg(map) {
20
+ const neutral = isNeutralKind(map);
21
+ const originals = new Map(map.nodes.map(n => [n.id, n]));
22
+ const oriented = flipForSequence(map);
23
+ // Reserve enough room for the second status line, cap long labels; full text
24
+ // remains in the document and each SVG node's accessible title.
25
+ const shaped = { ...oriented, nodes: oriented.nodes.map(n => {
26
+ const label = fitWidth(n.label, 32);
27
+ return { ...n, label: label + ' '.repeat(Math.max(0, 20 - displayWidth(label))) };
28
+ }) };
29
+ const geo = { ...zoomGeometry(0), boxGap: 6 };
30
+ const columns = layoutColumns(shaped, geo, true, neutral);
31
+ const routing = routeEdges(shaped, columns);
32
+ const rows = layoutRows(columns, geo, routing.gapRowCount, false, map.lanes.length > 0);
33
+ const width = Math.max(440, (columns.contentWidth + 4 + routing.fallbackCount * 2) * X);
34
+ const height = Math.max(100, TOP + rows.legendY * Y);
35
+ const parts = [
36
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-labelledby="map-title map-desc">`,
37
+ `<title id="map-title">${xml(map.title ?? '梅勒斯地图')}</title>`,
38
+ `<desc id="map-desc">${map.nodes.length} 个节点,${map.edges.length} 条依赖。完整说明与验证记录在地图文档中。</desc>`,
39
+ '<defs><marker id="arrow" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M0 0 L8 4 L0 8 Z" fill="#8995a5"/></marker></defs>',
40
+ `<rect width="${width}" height="${height}" fill="#ffffff"/>`,
41
+ '<g font-family="Segoe UI, Microsoft YaHei, Noto Sans CJK SC, sans-serif">',
42
+ ];
43
+ if (map.nodes.length === 0)
44
+ parts.push('<text x="20" y="52" font-size="14" fill="#657286">尚未声明模块,等待地图更新。</text>');
45
+ columns.bands.forEach((band, i) => {
46
+ const top = TOP + rows.barY[i] * Y;
47
+ const boxes = rows.bandBoxes[i];
48
+ const bottom = boxes.reduce((max, b) => Math.max(max, TOP + (b.y + b.h - 1) * Y), top + 60);
49
+ parts.push(`<rect x="8" y="${top - 10}" width="${width - 16}" height="${bottom - top + 26}" rx="5" fill="#fafbfc"/>`);
50
+ parts.push(`<text x="16" y="${top + 5}" font-size="12" fill="#6a7687">${xml(fitWidth(band.name, Math.floor((width - 40) / X)))}</text>`);
51
+ });
52
+ if (rows.laneHeaderY !== undefined)
53
+ map.lanes.forEach((lane, i) => {
54
+ const region = columns.lanes[i];
55
+ parts.push(`<text x="${(region.x + region.w / 2) * X}" y="${TOP + rows.laneHeaderY * Y + 5}" text-anchor="middle" font-size="12" fill="#566477">${xml(fitWidth(lane.label, region.w))}</text>`);
56
+ });
57
+ for (const edge of routing.edges) {
58
+ const points = edgePolyline(edge, rows).map(([x, y]) => `${x * X},${TOP + y * Y}`).join(' ');
59
+ parts.push(`<polyline points="${points}" fill="none" stroke="#8995a5" stroke-width="1.4" marker-end="url(#arrow)"/>`);
60
+ }
61
+ for (const box of rows.boxOf.values()) {
62
+ const node = originals.get(box.node.id);
63
+ const palette = neutral ? PALETTES.neutral : node.status === 'done' && !isVerified(node) ? PALETTES.unverified : PALETTES[node.status];
64
+ const x = box.x * X, y = TOP + box.y * Y, w = (box.w - 1) * X, h = (box.h - 1) * Y;
65
+ const dash = !neutral && node.status === 'planned' ? ' stroke-dasharray="5 4"' : '';
66
+ parts.push(`<g data-node="${xml(node.id)}"><title>${xml(node.label)}${neutral ? '' : ` · ${xml(statusText(node))}`}</title>`);
67
+ parts.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="5" fill="${palette[0]}" stroke="${palette[1]}" stroke-width="1.5"${dash}/>`);
68
+ parts.push(`<text x="${x + w / 2}" y="${y + 21}" text-anchor="middle" font-size="14" fill="${palette[2]}">${xml(box.label.trim())}</text>`);
69
+ const secondary = neutral ? node.kind ?? node.id : statusText(node);
70
+ parts.push(`<text x="${x + w / 2}" y="${y + 40}" text-anchor="middle" font-size="11" fill="${palette[2]}">${xml(fitWidth(secondary, box.w - 4))}</text></g>`);
71
+ }
72
+ parts.push('</g></svg>');
73
+ return parts.join('\n');
74
+ }
@@ -0,0 +1,4 @@
1
+ /** Literal user content stays data in both XML and Markdown output. */
2
+ export declare function xml(text: string): string;
3
+ export declare function markdown(text: string): string;
4
+ export declare function cell(text: string): string;
@@ -0,0 +1,13 @@
1
+ /** Literal user content stays data in both XML and Markdown output. */
2
+ export function xml(text) {
3
+ return text.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\ufffe\uffff]/g, '')
4
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
5
+ .replace(/"/g, '&quot;').replace(/'/g, '&apos;');
6
+ }
7
+ export function markdown(text) {
8
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
9
+ .replace(/([\\`*_[\]{}()#+.!|~-])/g, '\\$1').replace(/\r?\n/g, ' \n');
10
+ }
11
+ export function cell(text) {
12
+ return markdown(text).replace(/ \n/g, '<br>');
13
+ }
@@ -13,7 +13,7 @@
13
13
  * produced.
14
14
  */
15
15
  import type { RenderOptions, Viewport } from './options.js';
16
- export type Style = 'none' | 'dim' | 'amber' | 'green' | 'greenDim' | 'red' | 'faint';
16
+ export type Style = 'none' | 'dim' | 'amber' | 'green' | 'greenDim' | 'red' | 'faint' | 'focus';
17
17
  /** SGR parameter per style; combined with bold ("1") at emit time. */
18
18
  export declare const SGR: Readonly<Record<Style, string>>;
19
19
  export declare const ANSI_RESET = "\u001B[0m";
@@ -12,6 +12,7 @@
12
12
  * (the ink palette below) and coordinates, and it is the only place ANSI is
13
13
  * produced.
14
14
  */
15
+ import { terminalText } from '../domain/text.js';
15
16
  import { charWidth } from './width.js';
16
17
  /** SGR parameter per style; combined with bold ("1") at emit time. */
17
18
  export const SGR = {
@@ -22,6 +23,7 @@ export const SGR = {
22
23
  greenDim: '32;2', // done, but nothing behind the claim: green, not fully lit
23
24
  red: '31',
24
25
  faint: '90',
26
+ focus: '97', // bright foreground without changing font weight
25
27
  };
26
28
  export const ANSI_RESET = '\x1b[0m';
27
29
  // ---------------------------------------------------------------------------
@@ -91,7 +93,7 @@ export class Canvas {
91
93
  /** Write literal text starting at (x, y). Returns the column just past it. */
92
94
  text(x, y, s, style, bold = false) {
93
95
  let cx = x;
94
- for (const ch of s) {
96
+ for (const ch of terminalText(s)) {
95
97
  const w = charWidth(ch.codePointAt(0));
96
98
  if (w === 0) {
97
99
  // A combining mark, a variation selector or a skin tone takes no
@@ -162,7 +164,7 @@ export class Canvas {
162
164
  ? ''
163
165
  : isWire
164
166
  ? c.bright
165
- ? '1' // spotlighted wire: bold default color against the faint board
167
+ ? SGR.focus // change color only; bold fallback glyphs can visually shift
166
168
  : SGR.faint
167
169
  : [SGR[c.style], c.bold ? '1' : ''].filter(Boolean).join(';');
168
170
  if (opts.color && params !== open) {
@@ -46,6 +46,9 @@ export function drawBands(canvas, columns, rows, wiredWidth, totalWidth) {
46
46
  export function drawBox(canvas, box, opts, neutral, face, focused = false) {
47
47
  const { node, x, y, w } = box;
48
48
  const skin = neutral ? neutralSkin(opts.unicode) : skinFor(face, opts.unicode);
49
+ // Hover must not switch font faces: some terminal fonts draw their bold
50
+ // box/line glyphs at a different offset, making a stationary box jump.
51
+ const borderStyle = focused ? 'focus' : skin.style;
49
52
  // Neutral pages give the glyph slot to the node kind (a bullet when kindless).
50
53
  const slotGlyph = neutral ? neutralGlyph(node, opts.unicode) : glyphFor(face, opts);
51
54
  if (box.borderless) {
@@ -56,18 +59,18 @@ export function drawBox(canvas, box, opts, neutral, face, focused = false) {
56
59
  }
57
60
  const inner = w - 2;
58
61
  const pad = box.pad === 1 ? ' ' : '';
59
- canvas.text(x, y, skin.corners[0] + skin.h.repeat(inner) + skin.corners[1], skin.style, focused);
60
- canvas.text(x, y + 1, skin.v, skin.style, focused);
62
+ canvas.text(x, y, skin.corners[0] + skin.h.repeat(inner) + skin.corners[1], borderStyle);
63
+ canvas.text(x, y + 1, skin.v, borderStyle);
61
64
  canvas.text(x + 1, y + 1, `${pad}${slotGlyph} ${box.label}${pad}`, skin.style, true);
62
- canvas.text(x + w - 1, y + 1, skin.v, skin.style, focused);
65
+ canvas.text(x + w - 1, y + 1, skin.v, borderStyle);
63
66
  for (let i = 0; i < box.extra.length; i++) {
64
67
  const row = box.extra[i];
65
68
  const yy = y + 2 + i;
66
- canvas.text(x, yy, skin.v, skin.style, focused);
69
+ canvas.text(x, yy, skin.v, borderStyle);
67
70
  canvas.text(x + 1, yy, row.text, row.style);
68
- canvas.text(x + w - 1, yy, skin.v, skin.style, focused);
71
+ canvas.text(x + w - 1, yy, skin.v, borderStyle);
69
72
  }
70
- canvas.text(x, y + box.h - 1, skin.corners[2] + skin.h.repeat(inner) + skin.corners[3], skin.style, focused);
73
+ canvas.text(x, y + box.h - 1, skin.corners[2] + skin.h.repeat(inner) + skin.corners[3], borderStyle);
71
74
  }
72
75
  /** Every wire. Those touching the focused node render bright. */
73
76
  export function drawEdges(canvas, edges, rows, opts) {
@@ -86,3 +86,9 @@ export interface WindowedRender {
86
86
  }
87
87
  /** Render only the given viewport of the map, plus the full content extent. */
88
88
  export declare function renderMapWindow(map: MellosMap, opts: RenderOptions, viewport: Viewport): WindowedRender;
89
+ /**
90
+ * A pane-owned renderer for immutable map snapshots. Retains only the last
91
+ * scene: map/zoom/glyph changes rebuild geometry; animation, focus and pan
92
+ * only repaint it. There is no global cache or filesystem dependency.
93
+ */
94
+ export declare function createWindowRenderer(): typeof renderMapWindow;
@@ -68,12 +68,38 @@ export { statusSgr } from './skins.js';
68
68
  export { ZOOM_DEFAULT, ZOOM_MAX, ZOOM_MIN, clampZoom, isNeutralKind, kindGlyph, spinnerGlyph, statusGlyph, unverifiedDoneGlyph, zoomLabel, } from '../semantics/semantics.js';
69
69
  /** Render the whole map as terminal lines. */
70
70
  export function renderMap(map, opts) {
71
- const built = buildCanvas(map, opts);
71
+ const built = paint(prepareScene(map, opts), opts);
72
72
  return built.canvas.emit(opts);
73
73
  }
74
74
  /** Render only the given viewport of the map, plus the full content extent. */
75
75
  export function renderMapWindow(map, opts, viewport) {
76
- const built = buildCanvas(map, opts);
76
+ return renderSceneWindow(prepareScene(map, opts), opts, viewport);
77
+ }
78
+ /**
79
+ * A pane-owned renderer for immutable map snapshots. Retains only the last
80
+ * scene: map/zoom/glyph changes rebuild geometry; animation, focus and pan
81
+ * only repaint it. There is no global cache or filesystem dependency.
82
+ */
83
+ export function createWindowRenderer() {
84
+ let previous;
85
+ let frame;
86
+ return (map, opts, viewport) => {
87
+ const zoom = opts.zoom ?? ZOOM_DEFAULT;
88
+ if (previous?.map !== map || previous.unicode !== opts.unicode || previous.zoom !== zoom) {
89
+ // Commit the cache only after preparation succeeds, so failures retry.
90
+ previous = { map, unicode: opts.unicode, zoom, scene: prepareScene(map, opts) };
91
+ }
92
+ const scene = previous.scene;
93
+ if (frame?.scene !== scene || frame.spinner !== opts.spinnerFrame || frame.focus !== opts.focus || frame.color !== opts.color) {
94
+ frame = { scene, spinner: opts.spinnerFrame, focus: opts.focus, color: opts.color, built: paint(scene, opts) };
95
+ }
96
+ return emitWindow(frame.built, opts, viewport);
97
+ };
98
+ }
99
+ function renderSceneWindow(scene, opts, viewport) {
100
+ return emitWindow(paint(scene, opts), opts, viewport);
101
+ }
102
+ function emitWindow(built, opts, viewport) {
77
103
  return {
78
104
  lines: built.canvas.emit(opts, viewport),
79
105
  contentWidth: built.canvas.width,
@@ -81,22 +107,20 @@ export function renderMapWindow(map, opts, viewport) {
81
107
  hits: built.hits,
82
108
  };
83
109
  }
84
- function buildCanvas(map, opts) {
110
+ /** Static decisions, independent of spinner frame, focus, color and viewport. */
111
+ function prepareScene(map, opts) {
85
112
  const oriented = flipForSequence(map);
86
113
  const plainGeo = zoomGeometry(opts.zoom ?? ZOOM_DEFAULT);
87
114
  // The far zoom does not shrink the map, it AGGREGATES it: groups become one
88
115
  // box each. Which map is drawn is decided here, once.
89
116
  const aggregated = plainGeo.mode === 'constellation' ? aggregateMap(oriented) : undefined;
90
117
  const drawn = aggregated ?? oriented;
91
- return paint(drawn, opts, aggregated !== undefined ? AGGREGATE_GEO : plainGeo, unverifiedDoneIds(oriented, drawn));
118
+ const unverified = unverifiedDoneIds(oriented, drawn);
119
+ if (drawn.layers.length === 0)
120
+ return { map: drawn, unverified };
121
+ return { map: drawn, unverified, geometry: prepareGeometry(drawn, opts, aggregated !== undefined ? AGGREGATE_GEO : plainGeo) };
92
122
  }
93
- function paint(map, opts, geo, unverified) {
94
- const canvas = new Canvas();
95
- if (map.layers.length === 0) {
96
- canvas.text(0, 0, map.title ?? 'mellos mapping', 'none', true);
97
- canvas.text(0, 2, '(empty map — declare layers and nodes to begin)', 'dim');
98
- return { canvas, hits: [] };
99
- }
123
+ function prepareGeometry(map, opts, geo) {
100
124
  const neutral = isNeutralKind(map);
101
125
  const columns = layoutColumns(map, geo, opts.unicode, neutral);
102
126
  const routing = routeEdges(map, columns);
@@ -105,6 +129,21 @@ function paint(map, opts, geo, unverified) {
105
129
  const wiredWidth = routing.fallbackCount > 0 ? columns.contentWidth + 2 + routing.fallbackCount * 2 : columns.contentWidth;
106
130
  /** Plus the band labels' own right margin, which nothing else may enter. */
107
131
  const totalWidth = wiredWidth + Math.max(...columns.bandLabel.map(displayWidth));
132
+ const hits = [...rows.boxOf.values()].map((b) => ({
133
+ id: b.node.id, x: b.x, y: b.y, w: b.w, h: b.h,
134
+ }));
135
+ return { neutral, columns, routing, rows, wiredWidth, totalWidth, hits };
136
+ }
137
+ /** Dynamic drawing always gets a fresh canvas; frames cannot contaminate each other. */
138
+ function paint(scene, opts) {
139
+ const { map, unverified, geometry } = scene;
140
+ const canvas = new Canvas();
141
+ if (geometry === undefined) {
142
+ canvas.text(0, 0, map.title ?? 'mellos mapping', 'none', true);
143
+ canvas.text(0, 2, '(empty map — declare layers and nodes to begin)', 'dim');
144
+ return { canvas, hits: [] };
145
+ }
146
+ const { neutral, columns, routing, rows, wiredWidth, totalWidth, hits } = geometry;
108
147
  if (map.title !== undefined)
109
148
  drawTitle(canvas, map.title);
110
149
  drawLaneHeaders(canvas, map, columns, rows);
@@ -117,12 +156,5 @@ function paint(map, opts, geo, unverified) {
117
156
  }
118
157
  drawEdges(canvas, routing.edges, rows, opts);
119
158
  drawLegend(canvas, map, opts, rows.legendY, neutral, unverified.size > 0);
120
- const hits = [...rows.boxOf.values()].map((b) => ({
121
- id: b.node.id,
122
- x: b.x,
123
- y: b.y,
124
- w: b.w,
125
- h: b.h,
126
- }));
127
159
  return { canvas, hits };
128
160
  }
@@ -14,6 +14,7 @@
14
14
  *
15
15
  * Pure functions of a string; no canvas, no options, no I/O.
16
16
  */
17
+ import { terminalText } from '../domain/text.js';
17
18
  const WIDE_RANGES = [
18
19
  [0x1100, 0x115f], // Hangul Jamo
19
20
  // Wide symbols scattered through the BMP — mostly emoji that predate the
@@ -99,6 +100,7 @@ export function displayWidth(text) {
99
100
  }
100
101
  /** Truncate to a display width, ANSI-free input, appending … when cut. */
101
102
  export function fitWidth(s, width) {
103
+ s = terminalText(s);
102
104
  if (displayWidth(s) <= width)
103
105
  return s;
104
106
  let out = '';
@@ -117,7 +119,7 @@ export function wrapWidth(s, width) {
117
119
  const lines = [];
118
120
  let line = '';
119
121
  let w = 0;
120
- for (const ch of s.replace(/\r/g, '')) {
122
+ for (const ch of terminalText(s.replace(/\r/g, '').replace(/\t/g, ' '), true)) {
121
123
  if (ch === '\n') {
122
124
  lines.push(line);
123
125
  line = '';
@@ -0,0 +1,18 @@
1
+ import { type Result } from '../domain/types.js';
2
+ import { type StoreError } from './format.js';
3
+ export declare function errnoOf(e: unknown): string;
4
+ /**
5
+ * Write `contents` to `path` atomically (P2).
6
+ *
7
+ * Preconditions: none — the parent directory is created if missing.
8
+ * Postcondition on ok: `path` holds exactly `contents` and no temp file
9
+ * remains. Postcondition on error: `path` is untouched (it keeps its
10
+ * previous content, or stays absent) and no temp file remains.
11
+ *
12
+ * The temp name is private to this call — `<path>.<pid>.<random>.tmp` — so
13
+ * two writers racing on one page cannot install each other's partial content
14
+ * or make each other's rename miss its file.
15
+ */
16
+ export declare function writeFileAtomic(path: string, contents: string): Result<void, StoreError>;
17
+ /** A heartbeat may yield to the next tick; an authoritative save must retry. */
18
+ export declare function writeAtomic(path: string, contents: string, maxAttempts: number): Result<void, StoreError>;
@@ -0,0 +1,86 @@
1
+ import { mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { err, ok } from '../domain/types.js';
4
+ // ---------------------------------------------------------------------------
5
+ // atomic writes — the one primitive every save in this module is built on
6
+ // ---------------------------------------------------------------------------
7
+ /**
8
+ * How many times a rename is attempted before the save is reported failed.
9
+ * A reader's open handle blocks a rename on Windows for as long as it holds
10
+ * the file; the watcher reads a page in well under a tick, so a handful of
11
+ * attempts spans far more than any legitimate reader needs.
12
+ */
13
+ const RENAME_MAX_ATTEMPTS = 10;
14
+ /** Backoff granularity: attempt N waits N * this, so ten attempts span ~450ms. */
15
+ const RENAME_BACKOFF_STEP_MS = 10;
16
+ /**
17
+ * errno codes a rename can raise while the target is momentarily unavailable
18
+ * — a reader holding it open (EPERM/EBUSY/EACCES on Windows) or an
19
+ * antivirus/indexer briefly owning it (ENOENT between its own operations).
20
+ * Anything else (ENOSPC, EROFS, ENOTDIR) is a real fault: retrying it only
21
+ * delays the report.
22
+ */
23
+ const TRANSIENT_RENAME_CODES = new Set(['EPERM', 'EBUSY', 'EACCES', 'ENOENT']);
24
+ /**
25
+ * Block this thread for `ms`. The save path is synchronous by contract (the
26
+ * MCP tool answers after the file is on disk), so the backoff must be too.
27
+ */
28
+ function sleepSync(ms) {
29
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
30
+ }
31
+ /** The failed write's leftover temp, removed on a best-effort basis. */
32
+ function discardTemp(tmp) {
33
+ try {
34
+ rmSync(tmp, { force: true });
35
+ }
36
+ catch {
37
+ // The temp is unreachable for the same reason the write failed; leaving
38
+ // a stray *.tmp is strictly better than masking the original refusal.
39
+ }
40
+ }
41
+ export function errnoOf(e) {
42
+ return e.code ?? e.message;
43
+ }
44
+ /**
45
+ * Write `contents` to `path` atomically (P2).
46
+ *
47
+ * Preconditions: none — the parent directory is created if missing.
48
+ * Postcondition on ok: `path` holds exactly `contents` and no temp file
49
+ * remains. Postcondition on error: `path` is untouched (it keeps its
50
+ * previous content, or stays absent) and no temp file remains.
51
+ *
52
+ * The temp name is private to this call — `<path>.<pid>.<random>.tmp` — so
53
+ * two writers racing on one page cannot install each other's partial content
54
+ * or make each other's rename miss its file.
55
+ */
56
+ export function writeFileAtomic(path, contents) {
57
+ return writeAtomic(path, contents, RENAME_MAX_ATTEMPTS);
58
+ }
59
+ /** A heartbeat may yield to the next tick; an authoritative save must retry. */
60
+ export function writeAtomic(path, contents, maxAttempts) {
61
+ const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
62
+ try {
63
+ mkdirSync(dirname(path), { recursive: true });
64
+ writeFileSync(tmp, contents, 'utf8');
65
+ }
66
+ catch (e) {
67
+ discardTemp(tmp);
68
+ return err({ kind: 'save-failed', path, detail: `writing the temp file failed: ${errnoOf(e)}` });
69
+ }
70
+ let attempt = 1;
71
+ for (;;) {
72
+ try {
73
+ renameSync(tmp, path);
74
+ return ok(undefined);
75
+ }
76
+ catch (e) {
77
+ const code = errnoOf(e);
78
+ if (!TRANSIENT_RENAME_CODES.has(code) || attempt >= maxAttempts) {
79
+ discardTemp(tmp);
80
+ return err({ kind: 'save-failed', path, detail: `${code} after ${attempt} attempt(s)` });
81
+ }
82
+ sleepSync(attempt * RENAME_BACKOFF_STEP_MS);
83
+ attempt += 1;
84
+ }
85
+ }
86
+ }
@@ -0,0 +1,40 @@
1
+ import { type PageId } from './format.js';
2
+ /** Sibling of the default file carrying a one-shot "show this page" request. */
3
+ export declare const FOCUS_FILE_NAME = "focus";
4
+ export declare function focusFilePath(defaultFile: string, pid?: number): string;
5
+ /** A consumed focus request: the page to show (undefined = the default page). */
6
+ export interface FocusRequest {
7
+ readonly page: PageId | undefined;
8
+ }
9
+ /**
10
+ * Consume a pending focus request: read it, delete the file, return it.
11
+ * Absent file — the overwhelmingly common case — or junk content means no
12
+ * request; the channel is best-effort and junk is swept by the same delete.
13
+ */
14
+ export declare function takeFocusRequest(defaultFile: string, pid?: number): FocusRequest | undefined;
15
+ /** Sibling of the default file carrying a one-shot "close the pane" request. */
16
+ export declare const QUIT_FILE_NAME = "quit";
17
+ export declare function quitFilePath(defaultFile: string, pid?: number): string;
18
+ /**
19
+ * Consume a pending quit request: read it, delete the file, say whether there
20
+ * was one. Absent file — the overwhelmingly common case — or content that is
21
+ * not a JSON object means NO request; the channel is best-effort and junk is
22
+ * swept by the same delete.
23
+ *
24
+ * The empty JSON object is the whole grammar. It exists so that a stray file
25
+ * of this name — an editor backup, a half-written write from a foreign tool —
26
+ * cannot take a live pane down by accident; a pane closing is the one thing
27
+ * in this channel a user cannot undo by waiting.
28
+ */
29
+ export declare function takeQuitRequest(defaultFile: string, pid?: number): boolean;
30
+ /**
31
+ * Delete a quit request WITHOUT acting on it — the same file, read as a
32
+ * leftover rather than as a message.
33
+ *
34
+ * A toggle that wrote the request and then lost its watcher (a crash, a
35
+ * closed window, a `taskkill`) leaves the file behind, and the next pane to
36
+ * open would consume it on its first tick and close instantly. The watcher
37
+ * sweeps at STARTUP for exactly that: a request that predates the pane cannot
38
+ * have been addressed to it. Best-effort, like every delete in this channel.
39
+ */
40
+ export declare function sweepQuitRequest(defaultFile: string, pid?: number): void;