mellos-mapping 0.12.1 → 0.15.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.
package/README.md CHANGED
@@ -125,7 +125,10 @@ session's working directory instead: the state file lands in your project,
125
125
  same as under Claude Code. The registered path is version-specific — re-run
126
126
  the script after updating the plugin.
127
127
 
128
- To watch the live pane beside a Codex session, run
128
+ To watch the live pane beside a Codex session on Windows, run
129
+ `node <plugin root>/scripts/open-pane.mjs <project dir>` — it splits the
130
+ terminal window hosting the session (or falls back to a dedicated
131
+ "mellos-mapping" window; `--window` picks that on purpose). Elsewhere run
129
132
  `node <plugin root>/dist/watch.mjs` from the project directory in a second
130
133
  terminal (or any terminal split).
131
134
 
@@ -154,7 +157,10 @@ get the four `mmap_*` tools and the pane, and bring their own prompting.
154
157
  declare the ghost design and keep the map current as it works.
155
158
  2. Run `/mellos-mapping:mmap` to open the live pane (Windows Terminal split
156
159
  on Windows, tmux split inside tmux, or a printed command to run in any
157
- second terminal). Prefer `--ascii` if your font lacks box-drawing glyphs.
160
+ second terminal). On Windows the pane opens in the terminal window
161
+ hosting YOUR session, even with several windows open; pass `--window` to
162
+ put the map in its own dedicated window instead. Prefer `--ascii` if
163
+ your font lacks box-drawing glyphs.
158
164
  3. Watch nodes light up from the bottom. Interrupt when the picture worries
159
165
  you — that is what it is for.
160
166
 
package/README.zh-CN.md CHANGED
@@ -115,8 +115,11 @@ node ~/.codex/plugins/cache/mellos-mapping/mellos-mapping/<版本>/scripts/codex
115
115
  项目里,与 Claude Code 下行为一致。注册的是版本相关的绝对路径——插件
116
116
  更新后重跑一次脚本即可。
117
117
 
118
- 要在 Codex 会话旁边看实况面板,在项目目录下的第二个终端(或任意分屏)
119
- 运行 `node <插件根>/dist/watch.mjs`。
118
+ 要在 Codex 会话旁边看实况面板,Windows 上运行
119
+ `node <插件根>/scripts/open-pane.mjs <项目目录>`——它会在承载本会话的
120
+ 终端窗口里分屏(识别不到就确定性地开到专属的 "mellos-mapping" 窗口;
121
+ `--window` 则是主动选择专属窗口)。其他环境在项目目录下的第二个终端
122
+ (或任意分屏)运行 `node <插件根>/dist/watch.mjs`。
120
123
 
121
124
  ## 任意 MCP 客户端
122
125
 
@@ -143,7 +146,9 @@ npx -y -p mellos-mapping mellos-mapping-watch
143
146
  设计,并在工作过程中保持地图与现实一致。
144
147
  2. 运行 `/mellos-mapping:mmap` 打开实况分屏(Windows 上是 Windows
145
148
  Terminal 分屏,tmux 里是 tmux 分屏,其他环境会打印一条命令让你在
146
- 第二个终端里运行)。字体缺少制表符字形时用 `--ascii`。
149
+ 第二个终端里运行)。Windows 上即使开着多个终端窗口,分屏也会落在
150
+ **你的会话所在的窗口**;想让地图独占一个窗口就加 `--window`。字体
151
+ 缺少制表符字形时用 `--ascii`。
147
152
  3. 看着节点从底部一路亮起。图让你不安的时候就打断它——这正是它存在的
148
153
  意义。
149
154
 
package/dist/server.mjs CHANGED
@@ -21390,7 +21390,7 @@ function removeLayer(map, id) {
21390
21390
 
21391
21391
  // src/render/render.ts
21392
21392
  var ZOOM_MIN = -4;
21393
- var ZOOM_MAX = 1;
21393
+ var ZOOM_MAX = 2;
21394
21394
  var ZOOM_DEFAULT = 0;
21395
21395
  function clampZoom(n) {
21396
21396
  return Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, Math.round(n)));
@@ -21668,10 +21668,14 @@ function neutralSkin(unicode) {
21668
21668
  var BOX_H = 3;
21669
21669
  var BOX_GAP = 2;
21670
21670
  var LEFT_MARGIN = 2;
21671
+ var DETAIL_BUDGET = { innerMin: 22, innerMax: 32, noteRows: 3 };
21672
+ var DETAIL_PLUS_BUDGET = { innerMin: 30, innerMax: 48, noteRows: 12 };
21671
21673
  function zoomGeometry(zoom) {
21672
21674
  switch (zoom) {
21675
+ case 2:
21676
+ return { mode: "detail", scale: 1, pad: 1, boxGap: BOX_GAP, breathe: 1, titleGap: 1, barGap: 1, bandCounts: false, detail: DETAIL_PLUS_BUDGET };
21673
21677
  case 1:
21674
- return { mode: "detail", scale: 1, pad: 1, boxGap: BOX_GAP, breathe: 1, titleGap: 1, barGap: 1, bandCounts: false };
21678
+ return { mode: "detail", scale: 1, pad: 1, boxGap: BOX_GAP, breathe: 1, titleGap: 1, barGap: 1, bandCounts: false, detail: DETAIL_BUDGET };
21675
21679
  case 0:
21676
21680
  return { mode: "boxes", scale: 1, pad: 1, boxGap: BOX_GAP, breathe: 1, titleGap: 1, barGap: 1, bandCounts: false };
21677
21681
  case -1:
@@ -21684,9 +21688,6 @@ function zoomGeometry(zoom) {
21684
21688
  return { mode: "constellation", scale: 0, pad: 0, boxGap: BOX_GAP, breathe: 0, titleGap: 0, barGap: 1, bandCounts: true };
21685
21689
  }
21686
21690
  }
21687
- var DETAIL_INNER_MIN = 22;
21688
- var DETAIL_INNER_MAX = 32;
21689
- var DETAIL_NOTE_ROWS = 3;
21690
21691
  var LABEL_BUDGET_MIN = 4;
21691
21692
  function boxSpec(node, geo, unicode, neutral) {
21692
21693
  const glyph = node.kind !== void 0 ? kindGlyph(node.kind, unicode) : void 0;
@@ -21696,14 +21697,15 @@ function boxSpec(node, geo, unicode, neutral) {
21696
21697
  if (geo.mode === "constellation") {
21697
21698
  return { w: 3, h: 1, label: "", pad: 0, borderless: true, extra: [] };
21698
21699
  }
21699
- if (geo.mode === "detail") {
21700
- const innerW = Math.min(Math.max(displayWidth(text2) + badgeW + 4, DETAIL_INNER_MIN), DETAIL_INNER_MAX);
21700
+ if (geo.mode === "detail" && geo.detail !== void 0) {
21701
+ const budget2 = geo.detail;
21702
+ const innerW = Math.min(Math.max(displayWidth(text2) + badgeW + 4, budget2.innerMin), budget2.innerMax);
21701
21703
  const extra = [];
21702
21704
  if (node.evidence !== void 0) extra.push({ text: fitWidth(` ${node.evidence}`, innerW), style: "faint" });
21703
21705
  if (node.detail !== void 0) {
21704
21706
  const wrapped = wrapWidth(node.detail, innerW - 2);
21705
- for (let i = 0; i < Math.min(wrapped.length, DETAIL_NOTE_ROWS); i++) {
21706
- const cut = i === DETAIL_NOTE_ROWS - 1 && wrapped.length > DETAIL_NOTE_ROWS;
21707
+ for (let i = 0; i < Math.min(wrapped.length, budget2.noteRows); i++) {
21708
+ const cut = i === budget2.noteRows - 1 && wrapped.length > budget2.noteRows;
21707
21709
  extra.push({ text: ` ${cut ? fitWidth(wrapped[i] + "\u2026", innerW - 2) : wrapped[i]}`, style: "none" });
21708
21710
  }
21709
21711
  }
@@ -22513,7 +22515,7 @@ function summarize(map) {
22513
22515
 
22514
22516
  // src/server/server.ts
22515
22517
  var SERVER_NAME = "mellos-mapping";
22516
- var SERVER_VERSION = "0.12.1";
22518
+ var SERVER_VERSION = "0.15.0";
22517
22519
  var ID = external_exports.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/, "lowercase letters, digits and dashes, 1-64 chars").describe("stable kebab-case identifier");
22518
22520
  var PAGE = ID.optional().describe(
22519
22521
  "page (parallel map) this call targets; omit for the default page. One effort = one page: start a NEW effort on its own page named after the effort, so concurrent sessions never write over each other and the pane can switch between pages."
package/dist/watch.mjs CHANGED
@@ -201,13 +201,15 @@ function updateNode(map, input) {
201
201
 
202
202
  // src/render/render.ts
203
203
  var ZOOM_MIN = -4;
204
- var ZOOM_MAX = 1;
204
+ var ZOOM_MAX = 2;
205
205
  var ZOOM_DEFAULT = 0;
206
206
  function clampZoom(n) {
207
207
  return Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, Math.round(n)));
208
208
  }
209
209
  function zoomLabel(zoom) {
210
210
  switch (zoom) {
211
+ case 2:
212
+ return "detail+";
211
213
  case 1:
212
214
  return "detail";
213
215
  case 0:
@@ -495,10 +497,14 @@ function neutralSkin(unicode) {
495
497
  var BOX_H = 3;
496
498
  var BOX_GAP = 2;
497
499
  var LEFT_MARGIN = 2;
500
+ var DETAIL_BUDGET = { innerMin: 22, innerMax: 32, noteRows: 3 };
501
+ var DETAIL_PLUS_BUDGET = { innerMin: 30, innerMax: 48, noteRows: 12 };
498
502
  function zoomGeometry(zoom) {
499
503
  switch (zoom) {
504
+ case 2:
505
+ return { mode: "detail", scale: 1, pad: 1, boxGap: BOX_GAP, breathe: 1, titleGap: 1, barGap: 1, bandCounts: false, detail: DETAIL_PLUS_BUDGET };
500
506
  case 1:
501
- return { mode: "detail", scale: 1, pad: 1, boxGap: BOX_GAP, breathe: 1, titleGap: 1, barGap: 1, bandCounts: false };
507
+ return { mode: "detail", scale: 1, pad: 1, boxGap: BOX_GAP, breathe: 1, titleGap: 1, barGap: 1, bandCounts: false, detail: DETAIL_BUDGET };
502
508
  case 0:
503
509
  return { mode: "boxes", scale: 1, pad: 1, boxGap: BOX_GAP, breathe: 1, titleGap: 1, barGap: 1, bandCounts: false };
504
510
  case -1:
@@ -511,9 +517,6 @@ function zoomGeometry(zoom) {
511
517
  return { mode: "constellation", scale: 0, pad: 0, boxGap: BOX_GAP, breathe: 0, titleGap: 0, barGap: 1, bandCounts: true };
512
518
  }
513
519
  }
514
- var DETAIL_INNER_MIN = 22;
515
- var DETAIL_INNER_MAX = 32;
516
- var DETAIL_NOTE_ROWS = 3;
517
520
  var LABEL_BUDGET_MIN = 4;
518
521
  function boxSpec(node, geo, unicode, neutral) {
519
522
  const glyph = node.kind !== void 0 ? kindGlyph(node.kind, unicode) : void 0;
@@ -523,14 +526,15 @@ function boxSpec(node, geo, unicode, neutral) {
523
526
  if (geo.mode === "constellation") {
524
527
  return { w: 3, h: 1, label: "", pad: 0, borderless: true, extra: [] };
525
528
  }
526
- if (geo.mode === "detail") {
527
- const innerW = Math.min(Math.max(displayWidth(text) + badgeW + 4, DETAIL_INNER_MIN), DETAIL_INNER_MAX);
529
+ if (geo.mode === "detail" && geo.detail !== void 0) {
530
+ const budget2 = geo.detail;
531
+ const innerW = Math.min(Math.max(displayWidth(text) + badgeW + 4, budget2.innerMin), budget2.innerMax);
528
532
  const extra = [];
529
533
  if (node.evidence !== void 0) extra.push({ text: fitWidth(` ${node.evidence}`, innerW), style: "faint" });
530
534
  if (node.detail !== void 0) {
531
535
  const wrapped = wrapWidth(node.detail, innerW - 2);
532
- for (let i = 0; i < Math.min(wrapped.length, DETAIL_NOTE_ROWS); i++) {
533
- const cut = i === DETAIL_NOTE_ROWS - 1 && wrapped.length > DETAIL_NOTE_ROWS;
536
+ for (let i = 0; i < Math.min(wrapped.length, budget2.noteRows); i++) {
537
+ const cut = i === budget2.noteRows - 1 && wrapped.length > budget2.noteRows;
534
538
  extra.push({ text: ` ${cut ? fitWidth(wrapped[i] + "\u2026", innerW - 2) : wrapped[i]}`, style: "none" });
535
539
  }
536
540
  }
@@ -1175,7 +1179,7 @@ var KEY_PAN = {
1175
1179
  function mouseEvent(code, x, y, final) {
1176
1180
  if (code & WHEEL) {
1177
1181
  const down = (code & 1) !== 0;
1178
- return code & SHIFT ? { kind: "pan", dx: 0, dy: (down ? 1 : -1) * WHEEL_V_STEP } : { kind: "zoom", delta: down ? -1 : 1 };
1182
+ return code & SHIFT ? { kind: "pan", dx: 0, dy: (down ? 1 : -1) * WHEEL_V_STEP } : { kind: "zoom", delta: down ? -1 : 1, at: { x, y } };
1179
1183
  }
1180
1184
  const buttons = code & BUTTON_BITS;
1181
1185
  if (final === "m") return buttons === 0 ? { kind: "mouse-up", x, y } : void 0;
@@ -1271,6 +1275,9 @@ var RESET = "\x1B[0m";
1271
1275
  var PANEL_CONTENT_ROWS = 6;
1272
1276
  var PANEL_ROWS_MIN = 2;
1273
1277
  var MAP_ROWS_MIN = 4;
1278
+ function usableColumns(cols) {
1279
+ return Math.max(1, cols - 1);
1280
+ }
1274
1281
  function clampPanelRows(wanted, totalRows, tabRows) {
1275
1282
  const largest = totalRows - tabRows - MAP_ROWS_MIN - 2;
1276
1283
  return Math.max(PANEL_ROWS_MIN, Math.min(wanted, largest));
@@ -1302,27 +1309,46 @@ function anchorOffsets(anchor, offset, before, after) {
1302
1309
  y: before.h > 0 ? Math.round(offset.y * after.h / before.h) : 0
1303
1310
  };
1304
1311
  }
1305
- function pageTabRow(tabs, width, unicode) {
1306
- const segments = [];
1307
- let col = 1;
1308
- for (const [index, tab] of tabs.entries()) {
1309
- const room = width - (col - 1);
1310
- if (room <= 3) break;
1312
+ var TAB_INDICATOR_W = 3;
1313
+ function pageTabRow(tabs, width, unicode, scroll = 0) {
1314
+ const texts = tabs.map((tab) => {
1311
1315
  const marker = tab.active ? unicode ? "\u25CF" : "*" : unicode ? "\u25CB" : "o";
1312
1316
  const glyph = STATUS_GLYPH[tab.status][unicode ? 0 : 1];
1313
- const text = fitWidth(tab.neutral === true ? ` ${marker} ${tab.title} ` : ` ${marker} ${glyph} ${tab.title} `, room);
1317
+ return tab.neutral === true ? ` ${marker} ${tab.title} ` : ` ${marker} ${glyph} ${tab.title} `;
1318
+ });
1319
+ const sgrOf = (tab) => tab.neutral === true ? tab.active ? "1" : tab.fresh ? "36" : "90" : tab.active ? `${STATUS_SGR[tab.status]};1` : tab.fresh ? STATUS_SGR[tab.status] : "90";
1320
+ const widths = texts.map(displayWidth);
1321
+ const count = tabs.length;
1322
+ let lo = 0;
1323
+ let hi = count - 1;
1324
+ if (widths.reduce((a, b) => a + b, 0) > width) {
1325
+ lo = Math.max(0, Math.min(scroll, count - 1));
1326
+ hi = lo;
1327
+ const cost = (l, h) => widths.slice(l, h + 1).reduce((a, b) => a + b, 0) + (l > 0 ? TAB_INDICATOR_W : 0) + (h < count - 1 ? TAB_INDICATOR_W : 0);
1328
+ while (hi + 1 < count && cost(lo, hi + 1) <= width) hi++;
1329
+ }
1330
+ const segments = [];
1331
+ let col = 1;
1332
+ const push = (text, sgr, action) => {
1314
1333
  const w = displayWidth(text);
1315
- segments.push({
1316
- text,
1317
- sgr: tab.neutral === true ? tab.active ? "1" : tab.fresh ? "36" : "90" : tab.active ? `${STATUS_SGR[tab.status]};1` : tab.fresh ? STATUS_SGR[tab.status] : "90",
1318
- lo: col,
1319
- hi: col + w - 1,
1320
- index
1321
- });
1334
+ segments.push({ text, sgr, lo: col, hi: col + w - 1, action });
1322
1335
  col += w;
1336
+ };
1337
+ if (lo > 0) push(unicode ? " \u2039 " : " < ", "90", { kind: "scroll", delta: -1 });
1338
+ const tail = hi < count - 1 ? TAB_INDICATOR_W : 0;
1339
+ for (let i = lo; i <= hi; i++) {
1340
+ push(fitWidth(texts[i], Math.max(1, width - (col - 1) - tail)), sgrOf(tabs[i]), { kind: "switch", index: i });
1323
1341
  }
1342
+ if (hi < count - 1) push(unicode ? " \u203A " : " > ", "90", { kind: "scroll", delta: 1 });
1324
1343
  return segments;
1325
1344
  }
1345
+ function tabScrollFor(tabs, width, unicode, scroll, index) {
1346
+ if (index <= scroll) return Math.max(0, index);
1347
+ const visibleAt = (s2) => pageTabRow(tabs, width, unicode, s2).some((seg) => seg.action.kind === "switch" && seg.action.index === index);
1348
+ let s = Math.max(0, Math.min(scroll, tabs.length - 1));
1349
+ while (s < index && !visibleAt(s)) s++;
1350
+ return s;
1351
+ }
1326
1352
  function topLevelFiles(defaultFile, files, mapOf) {
1327
1353
  const refs = /* @__PURE__ */ new Set();
1328
1354
  for (const m of mapOf.values()) {
@@ -1442,6 +1468,131 @@ function nodePanel(map, focusId, unicode, width, pinned, rows = PANEL_CONTENT_RO
1442
1468
  }
1443
1469
  return lines.slice(0, rows);
1444
1470
  }
1471
+ var SPLASH_FONT = {
1472
+ M: ["# #", "## ##", "# # #", "# #", "# #"],
1473
+ E: ["####", "#", "###", "#", "####"],
1474
+ L: ["#", "#", "#", "#", "####"],
1475
+ O: [" ###", "# #", "# #", "# #", " ###"],
1476
+ S: [" ####", "#", " ###", " #", "####"],
1477
+ A: [" ###", "# #", "#####", "# #", "# #"],
1478
+ P: ["####", "# #", "####", "#", "#"],
1479
+ I: ["###", " #", " #", " #", "###"],
1480
+ N: ["# #", "## #", "# # #", "# ##", "# #"],
1481
+ G: [" ####", "#", "# ##", "# #", " ###"]
1482
+ };
1483
+ var SPLASH_ROWS = 5;
1484
+ var SPLASH_SHADES = {
1485
+ unicode: ["\u2591", "\u2591", "\u2592", "\u2592", "\u2593", "\u2593", "\u2588", "\u2588"],
1486
+ ascii: [".", ".", ":", ":", "=", "=", "#", "#"]
1487
+ };
1488
+ var WAVE_RAMP = [17, 18, 19, 61, 24, 25, 31, 37, 44, 45, 51, 87, 123, 159, 195];
1489
+ var SPINNER_FRAMES = {
1490
+ unicode: ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"],
1491
+ ascii: ["|", "/", "-", "\\"]
1492
+ };
1493
+ function wordArt(word) {
1494
+ const glyphs = [...word.toUpperCase()].map((ch) => SPLASH_FONT[ch]).filter((g) => g !== void 0);
1495
+ const widths = glyphs.map((g) => Math.max(...g.map((r) => r.length)));
1496
+ const rows = [];
1497
+ for (let r = 0; r < SPLASH_ROWS; r++) {
1498
+ rows.push(glyphs.map((g, i) => (g[r] ?? "").padEnd(widths[i], " ")).join(" "));
1499
+ }
1500
+ return rows;
1501
+ }
1502
+ function splashArt() {
1503
+ const words = [wordArt("MELLOS"), wordArt("MAPPING")];
1504
+ const width = Math.max(...words.flat().map((r) => r.length));
1505
+ const centered = words.map((rows) => {
1506
+ const own = Math.max(...rows.map((r) => r.length));
1507
+ return rows.map((r) => " ".repeat(Math.floor((width - own) / 2)) + r);
1508
+ });
1509
+ return [...centered[0], "", ...centered[1]];
1510
+ }
1511
+ var WAVE_INTERVAL = 18;
1512
+ var WAVE_LIFETIME = 64;
1513
+ var WAVE_SPEED = 0.9;
1514
+ var WAVE_ENVELOPE = 10;
1515
+ var WAVE_NUMBER = 0.42;
1516
+ var WAVE_LEVELS = 7;
1517
+ var WAVE_GAIN = 4.5;
1518
+ function waveHash(n) {
1519
+ let h = Math.imul(n + 1, 2654435761) >>> 0;
1520
+ h ^= h >>> 15;
1521
+ h = Math.imul(h, 2246822519) >>> 0;
1522
+ h ^= h >>> 13;
1523
+ return h >>> 0;
1524
+ }
1525
+ var WAVE_CORNERS = [
1526
+ [0, 0],
1527
+ [1, 0],
1528
+ [0, 1],
1529
+ [1, 1]
1530
+ ];
1531
+ function liveRipples(frame, width, height) {
1532
+ const out = [];
1533
+ const first = Math.floor((frame - WAVE_LIFETIME - WAVE_INTERVAL) / WAVE_INTERVAL);
1534
+ const last = Math.floor(frame / WAVE_INTERVAL);
1535
+ for (let n = Math.max(0, first); n <= last; n++) {
1536
+ const h = waveHash(n);
1537
+ const age = frame - (n * WAVE_INTERVAL + h % WAVE_INTERVAL);
1538
+ if (age < 0 || age > WAVE_LIFETIME) continue;
1539
+ const [fx, fy] = WAVE_CORNERS[h % WAVE_CORNERS.length];
1540
+ out.push({
1541
+ ox: fx * (width - 1),
1542
+ oy: fy * (height - 1),
1543
+ r: age * WAVE_SPEED,
1544
+ fade: 1 - age / WAVE_LIFETIME
1545
+ });
1546
+ }
1547
+ return out;
1548
+ }
1549
+ function waveAt(ripples, x, y) {
1550
+ let value = 0;
1551
+ for (const w of ripples) {
1552
+ const front = Math.hypot(x - w.ox, (y - w.oy) * 2) - w.r;
1553
+ value += Math.cos(front * WAVE_NUMBER) * Math.exp(-(front * front) / (2 * WAVE_ENVELOPE ** 2)) * w.fade;
1554
+ }
1555
+ return value;
1556
+ }
1557
+ function waveLevel(value) {
1558
+ return Math.max(-WAVE_LEVELS, Math.min(WAVE_LEVELS, Math.round(value * WAVE_GAIN)));
1559
+ }
1560
+ function splashFrame(notice, frame, width, height, unicode, color) {
1561
+ const art = splashArt();
1562
+ const artWidth = Math.max(...art.map((r) => r.length));
1563
+ if (width < artWidth + 2 || height < art.length + 2) return void 0;
1564
+ const mode = unicode ? "unicode" : "ascii";
1565
+ const shades = SPLASH_SHADES[mode];
1566
+ const solid = shades[shades.length - 1];
1567
+ const indent = " ".repeat(Math.floor((width - artWidth) / 2));
1568
+ const ripples = liveRipples(frame, artWidth, art.length);
1569
+ const inkOf = (x, y) => {
1570
+ const level = waveLevel(waveAt(ripples, x, y));
1571
+ return color ? `38;5;${WAVE_RAMP[WAVE_LEVELS + level]}` : shades[Math.abs(level)];
1572
+ };
1573
+ const paintRow = (row, y) => {
1574
+ const cells = [...row].map((ch, x) => ch === "#" ? inkOf(x, y) : void 0);
1575
+ let out = "";
1576
+ for (let i = 0; i < cells.length; ) {
1577
+ const cell = cells[i];
1578
+ let j = i;
1579
+ while (j < cells.length && cells[j] === cell) j++;
1580
+ if (cell === void 0) out += " ".repeat(j - i);
1581
+ else out += color ? `\x1B[${cell}m${solid.repeat(j - i)}${RESET}` : cell.repeat(j - i);
1582
+ i = j;
1583
+ }
1584
+ return out;
1585
+ };
1586
+ const spinner = SPINNER_FRAMES[mode];
1587
+ const status = fitWidth(`${spinner[frame % spinner.length]} ${notice}`, Math.max(1, width - 2));
1588
+ const statusIndent = " ".repeat(Math.max(0, Math.floor((width - displayWidth(status)) / 2)));
1589
+ const block = [
1590
+ ...art.map((row, y) => row.trim() === "" ? "" : indent + paintRow(row, y)),
1591
+ "",
1592
+ statusIndent + (color ? `\x1B[90m${status}${RESET}` : status)
1593
+ ];
1594
+ return [...Array.from({ length: Math.max(0, Math.floor((height - block.length) / 2)) }, () => ""), ...block];
1595
+ }
1445
1596
  function mapPanel(map, unicode, width, rows = PANEL_CONTENT_ROWS) {
1446
1597
  const g = (s) => STATUS_GLYPH[s][unicode ? 0 : 1];
1447
1598
  const count = (s) => map.nodes.filter((n) => n.status === s).length;
@@ -1466,6 +1617,7 @@ function main() {
1466
1617
  const mouseActive = interactive && cfg.mouse;
1467
1618
  let lastFrame = "";
1468
1619
  let spinnerFrame = 0;
1620
+ let splashTick = 0;
1469
1621
  let map;
1470
1622
  let notice = `waiting for ${cfg.file} ...`;
1471
1623
  let lastCols = process.stdout.columns ?? 0;
@@ -1476,6 +1628,7 @@ function main() {
1476
1628
  let activeFile;
1477
1629
  let firstScan = true;
1478
1630
  let lastTabSegments = [];
1631
+ let tabScroll = 0;
1479
1632
  let offsetX = 0;
1480
1633
  let offsetY = 0;
1481
1634
  let zoom = ZOOM_DEFAULT;
@@ -1508,8 +1661,19 @@ function main() {
1508
1661
  }
1509
1662
  return false;
1510
1663
  };
1664
+ const viewWidth = () => usableColumns(process.stdout.columns ?? 100);
1511
1665
  const viewHeight = () => Math.max(1, (process.stdout.rows ?? 30) - (1 + panelContentRows) - 1 - tabRows());
1512
1666
  const dividerY = () => tabRows() + viewHeight() + 1;
1667
+ const pageTabsOf = (files) => files.map((f) => {
1668
+ const m = pageData.get(f)?.map;
1669
+ return {
1670
+ title: m?.title ?? (pageIdOfFile(cfg.file, f) ?? "main"),
1671
+ status: m !== void 0 ? mapStatus(m) : "planned",
1672
+ active: f === activeFile,
1673
+ fresh: pageData.get(f)?.fresh ?? false,
1674
+ neutral: m !== void 0 && isNeutralKind(m)
1675
+ };
1676
+ });
1513
1677
  const switchPage = (file) => {
1514
1678
  if (activeFile !== void 0) pageViews.set(activeFile, { offsetX, offsetY, zoom, selectedId });
1515
1679
  activeFile = file;
@@ -1523,10 +1687,14 @@ function main() {
1523
1687
  if (entry !== void 0 && entry.fresh) pageData.set(file, { ...entry, fresh: false });
1524
1688
  map = entry?.map;
1525
1689
  notice = map === void 0 ? `waiting for ${file} ...` : "";
1690
+ const top = topFiles();
1691
+ const tabIndex = top.indexOf(file);
1692
+ if (tabIndex >= 0) tabScroll = tabScrollFor(pageTabsOf(top), viewWidth(), cfg.unicode, tabScroll, tabIndex);
1526
1693
  };
1527
1694
  const hitTest = (termX, termY) => {
1528
1695
  const sx = termX - 1;
1529
1696
  const sy = termY - 1 - tabRows();
1697
+ if (sx < 0 || sx >= viewWidth()) return void 0;
1530
1698
  if (sy < 0 || sy >= viewHeight()) return void 0;
1531
1699
  const cx = sx + offsetX;
1532
1700
  const cy = sy + offsetY;
@@ -1541,6 +1709,7 @@ function main() {
1541
1709
  process.on("SIGTERM", restore);
1542
1710
  const paint = () => {
1543
1711
  const cols = process.stdout.columns ?? 100;
1712
+ const viewW = viewWidth();
1544
1713
  panelContentRows = clampPanelRows(panelContentRows, process.stdout.rows ?? 30, tabRows());
1545
1714
  const viewH = viewHeight();
1546
1715
  const focus = hoverId ?? selectedId;
@@ -1551,9 +1720,9 @@ function main() {
1551
1720
  const windowed = renderMapWindow(
1552
1721
  map,
1553
1722
  { color: cfg.color, unicode: cfg.unicode, spinnerFrame, focus, zoom },
1554
- { x: offsetX, y: offsetY, width: cols, height: viewH }
1723
+ { x: offsetX, y: offsetY, width: viewW, height: viewH }
1555
1724
  );
1556
- const maxX = Math.max(0, windowed.contentWidth - cols);
1725
+ const maxX = Math.max(0, windowed.contentWidth - viewW);
1557
1726
  const maxY = Math.max(0, windowed.contentHeight - viewH);
1558
1727
  if (offsetX > maxX || offsetY > maxY || offsetX < 0 || offsetY < 0) {
1559
1728
  offsetX = Math.min(Math.max(0, offsetX), maxX);
@@ -1567,10 +1736,10 @@ function main() {
1567
1736
  lastContent = { w: windowed.contentWidth, h: windowed.contentHeight };
1568
1737
  if (offsetX !== 0 || offsetY !== 0) panned = ` (+${offsetX},+${offsetY})`;
1569
1738
  } else {
1570
- body = [fitWidth(notice, Math.max(1, cols - 1))];
1739
+ body = (interactive ? splashFrame(notice, splashTick, viewW, viewH, cfg.unicode, cfg.color) : void 0) ?? [fitWidth(notice, viewW)];
1571
1740
  }
1572
1741
  if (notice !== "" && map !== void 0) {
1573
- body[body.length - 1] = fitWidth(` ${notice}`, Math.max(1, cols - 1));
1742
+ body[body.length - 1] = fitWidth(` ${notice}`, viewW);
1574
1743
  }
1575
1744
  const panelWidth = Math.max(10, cols - 2);
1576
1745
  let panel;
@@ -1582,9 +1751,9 @@ function main() {
1582
1751
  panel = mapPanel(map, cfg.unicode, panelWidth, panelContentRows);
1583
1752
  }
1584
1753
  const grip = cfg.unicode ? " \u22EF " : " ~ ";
1585
- const bar = (cfg.unicode ? "\u2500" : "-").repeat(cols);
1586
- const gripAt = Math.max(0, Math.floor((cols - grip.length) / 2));
1587
- const separator = cols > grip.length + 2 ? bar.slice(0, gripAt) + grip + bar.slice(gripAt + grip.length) : bar;
1754
+ const bar = (cfg.unicode ? "\u2500" : "-").repeat(viewW);
1755
+ const gripAt = Math.max(0, Math.floor((viewW - grip.length) / 2));
1756
+ const separator = viewW > grip.length + 2 ? bar.slice(0, gripAt) + grip + bar.slice(gripAt + grip.length) : bar;
1588
1757
  const panelRows = [
1589
1758
  cfg.color ? `\x1B[90m${separator}${RESET}` : separator,
1590
1759
  ...panel.map(
@@ -1600,29 +1769,19 @@ function main() {
1600
1769
  const parentTitle = parentFile !== void 0 ? pageData.get(parentFile)?.map?.title ?? (pageIdOfFile(cfg.file, parentFile) ?? "main") : "main";
1601
1770
  const nodeLabel = scanned?.label ?? map?.title ?? "";
1602
1771
  const crumbHead = ` ${cfg.unicode ? "\u232B" : "<"} ${parentTitle} ${cfg.unicode ? "\u25B8" : ">"} `;
1603
- const head = { text: crumbHead, sgr: "90", lo: 1, hi: displayWidth(crumbHead), index: -1 };
1604
- const tailText = fitWidth(`${nodeLabel} `, Math.max(1, cols - displayWidth(crumbHead)));
1772
+ const head = { text: crumbHead, sgr: "90", lo: 1, hi: displayWidth(crumbHead), action: { kind: "back" } };
1773
+ const tailText = fitWidth(`${nodeLabel} `, Math.max(1, viewW - displayWidth(crumbHead)));
1605
1774
  const tail = {
1606
1775
  text: tailText,
1607
1776
  sgr: "1",
1608
1777
  lo: head.hi + 1,
1609
1778
  hi: head.hi + displayWidth(tailText),
1610
- index: -1
1779
+ action: { kind: "back" }
1611
1780
  };
1612
1781
  lastTabSegments = [head, tail];
1613
1782
  tabLine = lastTabSegments.map((s) => cfg.color && s.sgr !== "" ? `\x1B[${s.sgr}m${s.text}${RESET}` : s.text).join("");
1614
1783
  } else if (tabRows() > 0) {
1615
- const tabs = lastTabFiles.map((f) => {
1616
- const m = pageData.get(f)?.map;
1617
- return {
1618
- title: m?.title ?? (pageIdOfFile(cfg.file, f) ?? "main"),
1619
- status: m !== void 0 ? mapStatus(m) : "planned",
1620
- active: f === activeFile,
1621
- fresh: pageData.get(f)?.fresh ?? false,
1622
- neutral: m !== void 0 && isNeutralKind(m)
1623
- };
1624
- });
1625
- const segments = pageTabRow(tabs, cols, cfg.unicode);
1784
+ const segments = pageTabRow(pageTabsOf(lastTabFiles), viewW, cfg.unicode, tabScroll);
1626
1785
  lastTabSegments = segments;
1627
1786
  tabLine = segments.map((s) => cfg.color && s.sgr !== "" ? `\x1B[${s.sgr}m${s.text}${RESET}` : s.text).join("");
1628
1787
  } else {
@@ -1630,7 +1789,7 @@ function main() {
1630
1789
  }
1631
1790
  const zoomTag = `${cfg.unicode ? "\u2295" : "zoom"} ${zoomLabel(zoom)}`;
1632
1791
  const hint = !interactive ? cfg.file : (flash !== void 0 ? `${flash.text} \xB7 ` : "") + `${zoomTag} \xB7 wheel zoom \xB7 ` + (pannable ? "drag pan \xB7 " : "") + "hover/click \xB7 0 reset \xB7 q quit";
1633
- const footerText = fitWidth(` ${hint}${panned}`, Math.max(1, cols - 1));
1792
+ const footerText = fitWidth(` ${hint}${panned}`, viewW);
1634
1793
  const footer = cfg.color ? `\x1B[90m${footerText}${RESET}` : footerText;
1635
1794
  let frame = HOME;
1636
1795
  if (tabLine !== void 0) frame += tabLine + ERASE_LINE_END + "\n";
@@ -1723,10 +1882,14 @@ function main() {
1723
1882
  dirty = true;
1724
1883
  break;
1725
1884
  case "zoom": {
1885
+ if (event.at !== void 0 && event.at.y === 1 && tabRows() > 0 && !inSubmap()) {
1886
+ tabScroll = Math.max(0, Math.min(tabScroll + (event.delta === 1 ? -1 : 1), topFiles().length - 1));
1887
+ dirty = true;
1888
+ break;
1889
+ }
1726
1890
  const next = clampZoom(zoom + event.delta);
1727
1891
  if (next === zoom || map === void 0) break;
1728
- const cols = process.stdout.columns ?? 100;
1729
- const anchorId = hoverId ?? selectedId ?? nearestHit(lastHits, offsetX + cols / 2, offsetY + viewHeight() / 2)?.id;
1892
+ const anchorId = hoverId ?? selectedId ?? nearestHit(lastHits, offsetX + viewWidth() / 2, offsetY + viewHeight() / 2)?.id;
1730
1893
  const before = lastHits.find((h) => h.id === anchorId);
1731
1894
  zoom = next;
1732
1895
  const sized = renderMapWindow(
@@ -1790,10 +1953,12 @@ function main() {
1790
1953
  if (press && !press.moved) {
1791
1954
  const tabHit = tabRows() > 0 && event.y === 1 ? lastTabSegments.find((s) => event.x >= s.lo && event.x <= s.hi) : void 0;
1792
1955
  if (tabHit !== void 0) {
1793
- if (tabHit.index === -1) {
1956
+ if (tabHit.action.kind === "back") {
1794
1957
  climbBack();
1958
+ } else if (tabHit.action.kind === "scroll") {
1959
+ tabScroll = Math.max(0, Math.min(tabScroll + tabHit.action.delta, lastTabFiles.length - 1));
1795
1960
  } else {
1796
- const target = lastTabFiles[tabHit.index];
1961
+ const target = lastTabFiles[tabHit.action.index];
1797
1962
  if (target !== void 0 && target !== activeFile) switchPage(target);
1798
1963
  }
1799
1964
  } else {
@@ -1854,6 +2019,13 @@ function main() {
1854
2019
  }
1855
2020
  tick();
1856
2021
  setInterval(tick, cfg.intervalMs);
2022
+ if (interactive) {
2023
+ setInterval(() => {
2024
+ if (map !== void 0) return;
2025
+ splashTick++;
2026
+ paint();
2027
+ }, 80);
2028
+ }
1857
2029
  }
1858
2030
  function launchedAsEntry(argv1, moduleUrl) {
1859
2031
  if (argv1 === void 0) return false;
@@ -1868,17 +2040,28 @@ if (launchedAsEntry(process.argv[1], import.meta.url)) {
1868
2040
  }
1869
2041
  export {
1870
2042
  PANEL_ROWS_MIN,
2043
+ WAVE_LEVELS,
2044
+ WAVE_NUMBER,
1871
2045
  anchorOffsets,
1872
2046
  clampPanelRows,
1873
2047
  diveOrigin,
1874
2048
  fitWidth,
1875
2049
  launchedAsEntry,
2050
+ liveRipples,
1876
2051
  mapPanel,
1877
2052
  nearestHit,
1878
2053
  nodePanel,
1879
2054
  pageTabRow,
1880
2055
  panelRowsFromDividerY,
1881
2056
  parseArgs,
2057
+ splashArt,
2058
+ splashFrame,
2059
+ tabScrollFor,
1882
2060
  topLevelFiles,
2061
+ usableColumns,
2062
+ waveAt,
2063
+ waveHash,
2064
+ waveLevel,
2065
+ wordArt,
1883
2066
  wrapWidth
1884
2067
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mellos-mapping",
3
- "version": "0.12.1",
3
+ "version": "0.15.0",
4
4
  "mcpName": "io.github.GuangminJu/mellos-mapping",
5
5
  "description": "A live layered dependency map for bottom-up development — MCP server + terminal pane. Ghost the design first, then light nodes up from the bottom as they are built and verified.",
6
6
  "type": "module",
@@ -33,7 +33,8 @@
33
33
  "files": [
34
34
  "dist",
35
35
  "README.zh-CN.md",
36
- "scripts/codex-register.mjs"
36
+ "scripts/codex-register.mjs",
37
+ "scripts/open-pane.mjs"
37
38
  ],
38
39
  "engines": {
39
40
  "node": ">=18"
@@ -0,0 +1,249 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Open the live map pane in the RIGHT Windows Terminal window.
4
+ *
5
+ * node scripts/open-pane.mjs <project-dir> [--window] [--ascii] [--force]
6
+ *
7
+ * Why this exists: the agent's shell runs on a hidden console (no WT_SESSION),
8
+ * so a bare `wt -w 0 sp` targets the MOST RECENTLY USED terminal window — with
9
+ * several windows open the map lands wherever the user last clicked, not
10
+ * beside the conversation. Verified on Windows 11 / WT single-process mode:
11
+ * all windows share one WindowsTerminal.exe pid, and the console UI is
12
+ * attached by default-terminal delegation, so neither pid-matching nor the
13
+ * process tree can name the hosting window.
14
+ *
15
+ * What does work — and what this script does, verifying every step:
16
+ *
17
+ * 1. Walk this process's ancestors and AttachConsole to each in turn; write
18
+ * a nonce into that console's title. The WT window whose title lights up
19
+ * is the one hosting this session. The nonce only shows when the session
20
+ * tab is the window's ACTIVE tab — exactly the precondition under which
21
+ * `sp` would split beside the conversation, so identification doubles as
22
+ * the go/no-go check.
23
+ * 2. Bring that window to the foreground (plain SetForegroundWindow, then
24
+ * the Alt-key unlock, then AttachThreadInput — Windows' foreground lock
25
+ * denies the plain call from background processes), verifying with
26
+ * GetForegroundWindow after each attempt.
27
+ * 3. Only then `wt -w 0 sp` — "most recently used" is now provably ours.
28
+ *
29
+ * Any step failing falls back to a dedicated window named "mellos-mapping":
30
+ * deterministic, never a random window. `--window` picks that mode outright
31
+ * (explicit choice for users who want the map separate from the chat).
32
+ *
33
+ * Known race, accepted: if the user focuses a DIFFERENT terminal window in
34
+ * the ~1s between our focus-verify and wt reading its MRU state, the split
35
+ * can still land there. The window is at least one the user is actively in.
36
+ */
37
+ import { spawnSync } from 'node:child_process';
38
+ import { existsSync } from 'node:fs';
39
+ import { dirname, join, resolve } from 'node:path';
40
+ import { fileURLToPath } from 'node:url';
41
+
42
+ const argv = process.argv.slice(2);
43
+ const flags = new Set(argv.filter((a) => a.startsWith('--')));
44
+ const positional = argv.filter((a) => !a.startsWith('--'));
45
+
46
+ if (positional.length !== 1) {
47
+ console.error('usage: node scripts/open-pane.mjs <project-dir> [--window] [--ascii] [--force]');
48
+ process.exit(1);
49
+ }
50
+ if (process.platform !== 'win32') {
51
+ console.error('open-pane.mjs is Windows Terminal-only — use the tmux/manual route from the command doc.');
52
+ process.exit(1);
53
+ }
54
+
55
+ const projectDir = resolve(positional[0]);
56
+ if (!existsSync(projectDir)) {
57
+ console.error(`project directory does not exist: ${projectDir}`);
58
+ process.exit(1);
59
+ }
60
+
61
+ const pluginRoot = dirname(dirname(fileURLToPath(import.meta.url)));
62
+ const watchPath = join(pluginRoot, 'dist', 'watch.mjs');
63
+ if (!existsSync(watchPath)) {
64
+ console.error(`watcher not found (is the plugin built?): ${watchPath}`);
65
+ process.exit(1);
66
+ }
67
+ const mapFile = join(projectDir, '.claude', 'mellos-mapping.json');
68
+
69
+ function runPowerShell(script) {
70
+ const encoded = Buffer.from(script, 'utf16le').toString('base64');
71
+ const r = spawnSync(
72
+ 'powershell.exe',
73
+ ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded],
74
+ { encoding: 'utf8', timeout: 30_000, windowsHide: true },
75
+ );
76
+ return r.stdout ?? '';
77
+ }
78
+
79
+ // One watcher per map file is enough — watch.mjs redraws on change for every
80
+ // viewer of the same file, and piling up panes on repeated /mmap is noise.
81
+ // Matches only watchers this script started (they carry --file <mapFile> on
82
+ // their command line); --force bypasses.
83
+ function watcherAlreadyRunning() {
84
+ const token = mapFile.replace(/'/g, "''");
85
+ const out = runPowerShell(
86
+ `$ErrorActionPreference = 'SilentlyContinue'\n` +
87
+ `$w = @(Get-CimInstance Win32_Process -Filter "Name='node.exe'" | Where-Object { $_.CommandLine -like '*watch.mjs*' -and $_.CommandLine -like '*${token}*' })\n` +
88
+ `Write-Output "WATCHERS=$($w.Count)"`,
89
+ );
90
+ const m = out.match(/WATCHERS=(\d+)/);
91
+ return m !== null && Number(m[1]) > 0;
92
+ }
93
+
94
+ // Prints IDENT=<hwnd|0> and, when identified, FOCUS=<1|0>.
95
+ const IDENTIFY_AND_FOCUS = String.raw`
96
+ $ErrorActionPreference = 'SilentlyContinue'
97
+ Add-Type @"
98
+ using System;
99
+ using System.Text;
100
+ using System.Runtime.InteropServices;
101
+ public class MmapWin {
102
+ [DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc cb, IntPtr lp);
103
+ public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lp);
104
+ [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd);
105
+ [DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetClassName(IntPtr hWnd, StringBuilder sb, int max);
106
+ [DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder sb, int max);
107
+ [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
108
+ [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
109
+ [DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr hWnd);
110
+ [DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a, uint b, bool f);
111
+ [DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();
112
+ [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid);
113
+ [DllImport("user32.dll")] public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, IntPtr dwExtraInfo);
114
+ [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
115
+ [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd);
116
+ [DllImport("kernel32.dll")] public static extern bool FreeConsole();
117
+ [DllImport("kernel32.dll")] public static extern bool AttachConsole(uint pid);
118
+ [DllImport("kernel32.dll", CharSet=CharSet.Unicode)] public static extern bool SetConsoleTitle(string title);
119
+ [DllImport("kernel32.dll", CharSet=CharSet.Unicode)] public static extern uint GetConsoleTitle(StringBuilder sb, uint size);
120
+ }
121
+ "@
122
+ function Get-WtWindows {
123
+ $wins = New-Object System.Collections.ArrayList
124
+ $cb = {
125
+ param($h, $lp)
126
+ if ([MmapWin]::IsWindowVisible($h)) {
127
+ $cls = New-Object System.Text.StringBuilder 256
128
+ [void][MmapWin]::GetClassName($h, $cls, 256)
129
+ if ($cls.ToString() -eq 'CASCADIA_HOSTING_WINDOW_CLASS') {
130
+ $t = New-Object System.Text.StringBuilder 512
131
+ [void][MmapWin]::GetWindowText($h, $t, 512)
132
+ [void]$wins.Add(@{ hwnd = $h.ToInt64(); title = $t.ToString() })
133
+ }
134
+ }
135
+ return $true
136
+ }
137
+ [void][MmapWin]::EnumWindows($cb, [IntPtr]::Zero)
138
+ return ,$wins
139
+ }
140
+
141
+ $ancestors = @()
142
+ $p = $PID
143
+ for ($i = 0; $i -lt 12 -and $p; $i++) {
144
+ $proc = Get-CimInstance Win32_Process -Filter "ProcessId=$p"
145
+ if (-not $proc) { break }
146
+ if ($i -gt 0) { $ancestors += [uint32]$proc.ProcessId }
147
+ $p = $proc.ParentProcessId
148
+ }
149
+
150
+ # The agent CLI (claude/codex/...) is some ancestor holding the console that a
151
+ # WT window renders; hidden-console ancestors just never light a window up.
152
+ $nonce = "__NONCE__"
153
+ $hwnd = [IntPtr]::Zero
154
+ foreach ($apid in $ancestors) {
155
+ [void][MmapWin]::FreeConsole()
156
+ if (-not [MmapWin]::AttachConsole($apid)) { continue }
157
+ $sb = New-Object System.Text.StringBuilder 1024
158
+ [void][MmapWin]::GetConsoleTitle($sb, 1024)
159
+ $orig = $sb.ToString()
160
+ for ($i = 0; $i -lt 6 -and $hwnd -eq [IntPtr]::Zero; $i++) {
161
+ [void][MmapWin]::SetConsoleTitle($nonce)
162
+ Start-Sleep -Milliseconds 60
163
+ foreach ($w in (Get-WtWindows)) {
164
+ if ($w.title -like "*$nonce*") { $hwnd = [IntPtr]$w.hwnd; break }
165
+ }
166
+ }
167
+ Start-Sleep -Milliseconds 100
168
+ [void][MmapWin]::SetConsoleTitle($orig)
169
+ Start-Sleep -Milliseconds 200
170
+ [void][MmapWin]::FreeConsole()
171
+ if ($hwnd -ne [IntPtr]::Zero) { break }
172
+ }
173
+
174
+ if ($hwnd -eq [IntPtr]::Zero) { Write-Output 'IDENT=0'; exit 0 }
175
+ Write-Output "IDENT=$($hwnd.ToInt64())"
176
+
177
+ if ([MmapWin]::IsIconic($hwnd)) { [void][MmapWin]::ShowWindow($hwnd, 9) }
178
+ $focused = $false
179
+ [void][MmapWin]::SetForegroundWindow($hwnd)
180
+ Start-Sleep -Milliseconds 150
181
+ if ([MmapWin]::GetForegroundWindow() -eq $hwnd) { $focused = $true }
182
+ if (-not $focused) {
183
+ [MmapWin]::keybd_event(0x12, 0, 0, [IntPtr]::Zero)
184
+ [void][MmapWin]::SetForegroundWindow($hwnd)
185
+ [MmapWin]::keybd_event(0x12, 0, 2, [IntPtr]::Zero)
186
+ Start-Sleep -Milliseconds 150
187
+ if ([MmapWin]::GetForegroundWindow() -eq $hwnd) { $focused = $true }
188
+ }
189
+ if (-not $focused) {
190
+ $fgpid = 0
191
+ $fgThread = [MmapWin]::GetWindowThreadProcessId([MmapWin]::GetForegroundWindow(), [ref]$fgpid)
192
+ $myThread = [MmapWin]::GetCurrentThreadId()
193
+ [void][MmapWin]::AttachThreadInput($myThread, $fgThread, $true)
194
+ [void][MmapWin]::BringWindowToTop($hwnd)
195
+ [void][MmapWin]::SetForegroundWindow($hwnd)
196
+ [void][MmapWin]::AttachThreadInput($myThread, $fgThread, $false)
197
+ Start-Sleep -Milliseconds 150
198
+ if ([MmapWin]::GetForegroundWindow() -eq $hwnd) { $focused = $true }
199
+ }
200
+ Write-Output "FOCUS=$(if ($focused) { 1 } else { 0 })"
201
+ `;
202
+
203
+ function paneCommand() {
204
+ const cmd = ['--title', 'mellos map', '-d', projectDir, 'node', watchPath, '--file', mapFile];
205
+ if (flags.has('--ascii')) cmd.push('--ascii');
206
+ return cmd;
207
+ }
208
+
209
+ function openWt(args, what) {
210
+ const r = spawnSync('wt', args, { stdio: 'ignore', timeout: 15_000, windowsHide: true });
211
+ if (r.status !== 0) {
212
+ console.error(`wt failed to ${what} (exit ${r.status ?? 'timeout'}) — is Windows Terminal installed?`);
213
+ process.exit(1);
214
+ }
215
+ }
216
+
217
+ function openDedicatedWindow(reason) {
218
+ openWt(['-w', 'mellos-mapping', 'nt', ...paneCommand()], 'open the dedicated window');
219
+ console.log(`MMAP_PANE mode=window name=mellos-mapping reason=${reason}`);
220
+ console.log('Map opened in the dedicated "mellos-mapping" window.');
221
+ }
222
+
223
+ if (!flags.has('--force') && watcherAlreadyRunning()) {
224
+ console.log('MMAP_PANE already-open');
225
+ console.log(`A watcher for ${mapFile} is already running — not opening another pane (use --force to override).`);
226
+ process.exit(0);
227
+ }
228
+
229
+ if (flags.has('--window')) {
230
+ openDedicatedWindow('requested');
231
+ process.exit(0);
232
+ }
233
+
234
+ const nonce = `MMAP-NONCE-${process.pid}`;
235
+ const out = runPowerShell(IDENTIFY_AND_FOCUS.replaceAll('__NONCE__', nonce));
236
+ const ident = out.match(/IDENT=(\d+)/)?.[1] ?? '0';
237
+ const focused = /FOCUS=1/.test(out);
238
+
239
+ if (ident === '0') {
240
+ openDedicatedWindow('session-window-not-identified');
241
+ } else if (!focused) {
242
+ openDedicatedWindow('session-window-focus-denied');
243
+ } else {
244
+ // The identified window is foreground right now, so "most recently used"
245
+ // is deterministically it (see the race note in the header).
246
+ openWt(['-w', '0', 'sp', '-V', '--size', '0.42', ...paneCommand()], 'split the session window');
247
+ console.log(`MMAP_PANE mode=split hwnd=${ident}`);
248
+ console.log('Map opened beside this conversation (vertical split).');
249
+ }