mellos-mapping 0.14.0 → 0.16.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
@@ -128,9 +128,11 @@ the script after updating the plugin.
128
128
  To watch the live pane beside a Codex session on Windows, run
129
129
  `node <plugin root>/scripts/open-pane.mjs <project dir>` — it splits the
130
130
  terminal window hosting the session (or falls back to a dedicated
131
- "mellos-mapping" window; `--window` picks that on purpose). Elsewhere run
132
- `node <plugin root>/dist/watch.mjs` from the project directory in a second
133
- terminal (or any terminal split).
131
+ "mellos-mapping" window; `--window` picks that on purpose). Add
132
+ `--page <slug>` to open on a particular page — and with a pane already open,
133
+ rerunning with `--page` retargets it instead of opening another. Elsewhere
134
+ run `node <plugin root>/dist/watch.mjs` (same `--page` flag) from the
135
+ project directory in a second terminal (or any terminal split).
134
136
 
135
137
  ## Any MCP client
136
138
 
package/README.zh-CN.md CHANGED
@@ -118,8 +118,10 @@ node ~/.codex/plugins/cache/mellos-mapping/mellos-mapping/<版本>/scripts/codex
118
118
  要在 Codex 会话旁边看实况面板,Windows 上运行
119
119
  `node <插件根>/scripts/open-pane.mjs <项目目录>`——它会在承载本会话的
120
120
  终端窗口里分屏(识别不到就确定性地开到专属的 "mellos-mapping" 窗口;
121
- `--window` 则是主动选择专属窗口)。其他环境在项目目录下的第二个终端
122
- (或任意分屏)运行 `node <插件根>/dist/watch.mjs`。
121
+ `--window` 则是主动选择专属窗口)。加 `--page <slug>` 指定打开哪一页;
122
+ 面板已经开着时,带 `--page` 重跑一次不会再开新面板,而是让现有面板
123
+ 切到那一页。其他环境在项目目录下的第二个终端(或任意分屏)运行
124
+ `node <插件根>/dist/watch.mjs`(同样支持 `--page`)。
123
125
 
124
126
  ## 任意 MCP 客户端
125
127
 
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
  }
@@ -22131,7 +22133,7 @@ function drawBox(canvas, box, opts, neutral, focused = false) {
22131
22133
  }
22132
22134
 
22133
22135
  // src/store/store.ts
22134
- import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
22136
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
22135
22137
  import { basename, dirname, join } from "node:path";
22136
22138
  var STATE_FILE_VERSION = 1;
22137
22139
  var STATE_FILE_RELATIVE_PATH = join(".claude", "mellos-mapping.json");
@@ -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.14.0";
22518
+ var SERVER_VERSION = "0.16.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
  }
@@ -963,11 +967,14 @@ function drawBox(canvas, box, opts, neutral, focused = false) {
963
967
  }
964
968
 
965
969
  // src/store/store.ts
966
- import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
970
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
967
971
  import { basename, dirname, join } from "node:path";
968
972
  var STATE_FILE_VERSION = 1;
969
973
  var STATE_FILE_RELATIVE_PATH = join(".claude", "mellos-mapping.json");
970
974
  var PAGES_DIR_NAME = "mellos-mapping.pages";
975
+ function makePageId(raw) {
976
+ return ID_RULE.test(raw) ? ok(raw) : err({ kind: "invalid-id", raw, rule: ID_RULE_TEXT });
977
+ }
971
978
  function pageFilePath(defaultFile, page) {
972
979
  return page === void 0 ? defaultFile : join(dirname(defaultFile), PAGES_DIR_NAME, `${page}.json`);
973
980
  }
@@ -989,6 +996,35 @@ function listPageFiles(defaultFile) {
989
996
  }
990
997
  return out;
991
998
  }
999
+ var FOCUS_FILE_NAME = "mellos-mapping.focus";
1000
+ function focusFilePath(defaultFile) {
1001
+ return join(dirname(defaultFile), FOCUS_FILE_NAME);
1002
+ }
1003
+ function takeFocusRequest(defaultFile) {
1004
+ const path = focusFilePath(defaultFile);
1005
+ let raw;
1006
+ try {
1007
+ raw = readFileSync(path, "utf8");
1008
+ } catch {
1009
+ return void 0;
1010
+ }
1011
+ try {
1012
+ rmSync(path, { force: true });
1013
+ } catch {
1014
+ }
1015
+ let parsed;
1016
+ try {
1017
+ parsed = JSON.parse(raw);
1018
+ } catch {
1019
+ return void 0;
1020
+ }
1021
+ if (typeof parsed !== "object" || parsed === null) return void 0;
1022
+ const page = parsed.page;
1023
+ if (page === void 0 || page === null) return { page: void 0 };
1024
+ if (typeof page !== "string") return void 0;
1025
+ const id = makePageId(page);
1026
+ return id.ok ? { page: id.value } : void 0;
1027
+ }
992
1028
  function describeStoreError(e) {
993
1029
  switch (e.kind) {
994
1030
  case "not-found":
@@ -1175,7 +1211,7 @@ var KEY_PAN = {
1175
1211
  function mouseEvent(code, x, y, final) {
1176
1212
  if (code & WHEEL) {
1177
1213
  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 };
1214
+ return code & SHIFT ? { kind: "pan", dx: 0, dy: (down ? 1 : -1) * WHEEL_V_STEP } : { kind: "zoom", delta: down ? -1 : 1, at: { x, y } };
1179
1215
  }
1180
1216
  const buttons = code & BUTTON_BITS;
1181
1217
  if (final === "m") return buttons === 0 ? { kind: "mouse-up", x, y } : void 0;
@@ -1237,11 +1273,17 @@ function parseArgs(argv, cwd) {
1237
1273
  let unicode = true;
1238
1274
  let color = true;
1239
1275
  let mouse = true;
1276
+ let page;
1240
1277
  for (let i = 0; i < argv.length; i++) {
1241
1278
  switch (argv[i]) {
1242
1279
  case "--file":
1243
1280
  file = argv[++i] ?? file;
1244
1281
  break;
1282
+ case "--page": {
1283
+ const parsed = makePageId(argv[++i] ?? "");
1284
+ if (parsed.ok) page = parsed.value;
1285
+ break;
1286
+ }
1245
1287
  case "--interval":
1246
1288
  intervalMs = Math.max(50, Number(argv[++i]) || intervalMs);
1247
1289
  break;
@@ -1258,7 +1300,19 @@ function parseArgs(argv, cwd) {
1258
1300
  break;
1259
1301
  }
1260
1302
  }
1261
- return { file, intervalMs, unicode, color, mouse };
1303
+ return { file, intervalMs, unicode, color, mouse, page };
1304
+ }
1305
+ function mostRecentPageFile(files, mtimeOf) {
1306
+ let best;
1307
+ let bestMtime = -Infinity;
1308
+ for (const file of files) {
1309
+ const mtime = mtimeOf(file);
1310
+ if (mtime !== void 0 && mtime > bestMtime) {
1311
+ best = file;
1312
+ bestMtime = mtime;
1313
+ }
1314
+ }
1315
+ return best ?? files[0];
1262
1316
  }
1263
1317
  var HIDE_CURSOR = "\x1B[?25l";
1264
1318
  var SHOW_CURSOR = "\x1B[?25h";
@@ -1271,6 +1325,9 @@ var RESET = "\x1B[0m";
1271
1325
  var PANEL_CONTENT_ROWS = 6;
1272
1326
  var PANEL_ROWS_MIN = 2;
1273
1327
  var MAP_ROWS_MIN = 4;
1328
+ function usableColumns(cols) {
1329
+ return Math.max(1, cols - 1);
1330
+ }
1274
1331
  function clampPanelRows(wanted, totalRows, tabRows) {
1275
1332
  const largest = totalRows - tabRows - MAP_ROWS_MIN - 2;
1276
1333
  return Math.max(PANEL_ROWS_MIN, Math.min(wanted, largest));
@@ -1302,27 +1359,46 @@ function anchorOffsets(anchor, offset, before, after) {
1302
1359
  y: before.h > 0 ? Math.round(offset.y * after.h / before.h) : 0
1303
1360
  };
1304
1361
  }
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;
1362
+ var TAB_INDICATOR_W = 3;
1363
+ function pageTabRow(tabs, width, unicode, scroll = 0) {
1364
+ const texts = tabs.map((tab) => {
1311
1365
  const marker = tab.active ? unicode ? "\u25CF" : "*" : unicode ? "\u25CB" : "o";
1312
1366
  const glyph = STATUS_GLYPH[tab.status][unicode ? 0 : 1];
1313
- const text = fitWidth(tab.neutral === true ? ` ${marker} ${tab.title} ` : ` ${marker} ${glyph} ${tab.title} `, room);
1367
+ return tab.neutral === true ? ` ${marker} ${tab.title} ` : ` ${marker} ${glyph} ${tab.title} `;
1368
+ });
1369
+ 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";
1370
+ const widths = texts.map(displayWidth);
1371
+ const count = tabs.length;
1372
+ let lo = 0;
1373
+ let hi = count - 1;
1374
+ if (widths.reduce((a, b) => a + b, 0) > width) {
1375
+ lo = Math.max(0, Math.min(scroll, count - 1));
1376
+ hi = lo;
1377
+ 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);
1378
+ while (hi + 1 < count && cost(lo, hi + 1) <= width) hi++;
1379
+ }
1380
+ const segments = [];
1381
+ let col = 1;
1382
+ const push = (text, sgr, action) => {
1314
1383
  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
- });
1384
+ segments.push({ text, sgr, lo: col, hi: col + w - 1, action });
1322
1385
  col += w;
1386
+ };
1387
+ if (lo > 0) push(unicode ? " \u2039 " : " < ", "90", { kind: "scroll", delta: -1 });
1388
+ const tail = hi < count - 1 ? TAB_INDICATOR_W : 0;
1389
+ for (let i = lo; i <= hi; i++) {
1390
+ push(fitWidth(texts[i], Math.max(1, width - (col - 1) - tail)), sgrOf(tabs[i]), { kind: "switch", index: i });
1323
1391
  }
1392
+ if (hi < count - 1) push(unicode ? " \u203A " : " > ", "90", { kind: "scroll", delta: 1 });
1324
1393
  return segments;
1325
1394
  }
1395
+ function tabScrollFor(tabs, width, unicode, scroll, index) {
1396
+ if (index <= scroll) return Math.max(0, index);
1397
+ const visibleAt = (s2) => pageTabRow(tabs, width, unicode, s2).some((seg) => seg.action.kind === "switch" && seg.action.index === index);
1398
+ let s = Math.max(0, Math.min(scroll, tabs.length - 1));
1399
+ while (s < index && !visibleAt(s)) s++;
1400
+ return s;
1401
+ }
1326
1402
  function topLevelFiles(defaultFile, files, mapOf) {
1327
1403
  const refs = /* @__PURE__ */ new Set();
1328
1404
  for (const m of mapOf.values()) {
@@ -1601,7 +1677,9 @@ function main() {
1601
1677
  const pageViews = /* @__PURE__ */ new Map();
1602
1678
  let activeFile;
1603
1679
  let firstScan = true;
1680
+ let pendingFocusFile = cfg.page === void 0 ? void 0 : pageFilePath(cfg.file, cfg.page);
1604
1681
  let lastTabSegments = [];
1682
+ let tabScroll = 0;
1605
1683
  let offsetX = 0;
1606
1684
  let offsetY = 0;
1607
1685
  let zoom = ZOOM_DEFAULT;
@@ -1629,13 +1707,24 @@ function main() {
1629
1707
  parent = diveOrigin(cfg.file, activeFile, pageFiles, maps())?.parent;
1630
1708
  }
1631
1709
  if (parent !== void 0 && parent !== activeFile) {
1632
- switchPage(parent);
1710
+ handSwitch(parent);
1633
1711
  return true;
1634
1712
  }
1635
1713
  return false;
1636
1714
  };
1715
+ const viewWidth = () => usableColumns(process.stdout.columns ?? 100);
1637
1716
  const viewHeight = () => Math.max(1, (process.stdout.rows ?? 30) - (1 + panelContentRows) - 1 - tabRows());
1638
1717
  const dividerY = () => tabRows() + viewHeight() + 1;
1718
+ const pageTabsOf = (files) => files.map((f) => {
1719
+ const m = pageData.get(f)?.map;
1720
+ return {
1721
+ title: m?.title ?? (pageIdOfFile(cfg.file, f) ?? "main"),
1722
+ status: m !== void 0 ? mapStatus(m) : "planned",
1723
+ active: f === activeFile,
1724
+ fresh: pageData.get(f)?.fresh ?? false,
1725
+ neutral: m !== void 0 && isNeutralKind(m)
1726
+ };
1727
+ });
1639
1728
  const switchPage = (file) => {
1640
1729
  if (activeFile !== void 0) pageViews.set(activeFile, { offsetX, offsetY, zoom, selectedId });
1641
1730
  activeFile = file;
@@ -1649,10 +1738,18 @@ function main() {
1649
1738
  if (entry !== void 0 && entry.fresh) pageData.set(file, { ...entry, fresh: false });
1650
1739
  map = entry?.map;
1651
1740
  notice = map === void 0 ? `waiting for ${file} ...` : "";
1741
+ const top = topFiles();
1742
+ const tabIndex = top.indexOf(file);
1743
+ if (tabIndex >= 0) tabScroll = tabScrollFor(pageTabsOf(top), viewWidth(), cfg.unicode, tabScroll, tabIndex);
1744
+ };
1745
+ const handSwitch = (file) => {
1746
+ pendingFocusFile = void 0;
1747
+ switchPage(file);
1652
1748
  };
1653
1749
  const hitTest = (termX, termY) => {
1654
1750
  const sx = termX - 1;
1655
1751
  const sy = termY - 1 - tabRows();
1752
+ if (sx < 0 || sx >= viewWidth()) return void 0;
1656
1753
  if (sy < 0 || sy >= viewHeight()) return void 0;
1657
1754
  const cx = sx + offsetX;
1658
1755
  const cy = sy + offsetY;
@@ -1667,6 +1764,7 @@ function main() {
1667
1764
  process.on("SIGTERM", restore);
1668
1765
  const paint = () => {
1669
1766
  const cols = process.stdout.columns ?? 100;
1767
+ const viewW = viewWidth();
1670
1768
  panelContentRows = clampPanelRows(panelContentRows, process.stdout.rows ?? 30, tabRows());
1671
1769
  const viewH = viewHeight();
1672
1770
  const focus = hoverId ?? selectedId;
@@ -1677,9 +1775,9 @@ function main() {
1677
1775
  const windowed = renderMapWindow(
1678
1776
  map,
1679
1777
  { color: cfg.color, unicode: cfg.unicode, spinnerFrame, focus, zoom },
1680
- { x: offsetX, y: offsetY, width: cols, height: viewH }
1778
+ { x: offsetX, y: offsetY, width: viewW, height: viewH }
1681
1779
  );
1682
- const maxX = Math.max(0, windowed.contentWidth - cols);
1780
+ const maxX = Math.max(0, windowed.contentWidth - viewW);
1683
1781
  const maxY = Math.max(0, windowed.contentHeight - viewH);
1684
1782
  if (offsetX > maxX || offsetY > maxY || offsetX < 0 || offsetY < 0) {
1685
1783
  offsetX = Math.min(Math.max(0, offsetX), maxX);
@@ -1693,10 +1791,10 @@ function main() {
1693
1791
  lastContent = { w: windowed.contentWidth, h: windowed.contentHeight };
1694
1792
  if (offsetX !== 0 || offsetY !== 0) panned = ` (+${offsetX},+${offsetY})`;
1695
1793
  } else {
1696
- body = (interactive ? splashFrame(notice, splashTick, cols, viewH, cfg.unicode, cfg.color) : void 0) ?? [fitWidth(notice, Math.max(1, cols - 1))];
1794
+ body = (interactive ? splashFrame(notice, splashTick, viewW, viewH, cfg.unicode, cfg.color) : void 0) ?? [fitWidth(notice, viewW)];
1697
1795
  }
1698
1796
  if (notice !== "" && map !== void 0) {
1699
- body[body.length - 1] = fitWidth(` ${notice}`, Math.max(1, cols - 1));
1797
+ body[body.length - 1] = fitWidth(` ${notice}`, viewW);
1700
1798
  }
1701
1799
  const panelWidth = Math.max(10, cols - 2);
1702
1800
  let panel;
@@ -1708,9 +1806,9 @@ function main() {
1708
1806
  panel = mapPanel(map, cfg.unicode, panelWidth, panelContentRows);
1709
1807
  }
1710
1808
  const grip = cfg.unicode ? " \u22EF " : " ~ ";
1711
- const bar = (cfg.unicode ? "\u2500" : "-").repeat(cols);
1712
- const gripAt = Math.max(0, Math.floor((cols - grip.length) / 2));
1713
- const separator = cols > grip.length + 2 ? bar.slice(0, gripAt) + grip + bar.slice(gripAt + grip.length) : bar;
1809
+ const bar = (cfg.unicode ? "\u2500" : "-").repeat(viewW);
1810
+ const gripAt = Math.max(0, Math.floor((viewW - grip.length) / 2));
1811
+ const separator = viewW > grip.length + 2 ? bar.slice(0, gripAt) + grip + bar.slice(gripAt + grip.length) : bar;
1714
1812
  const panelRows = [
1715
1813
  cfg.color ? `\x1B[90m${separator}${RESET}` : separator,
1716
1814
  ...panel.map(
@@ -1726,29 +1824,19 @@ function main() {
1726
1824
  const parentTitle = parentFile !== void 0 ? pageData.get(parentFile)?.map?.title ?? (pageIdOfFile(cfg.file, parentFile) ?? "main") : "main";
1727
1825
  const nodeLabel = scanned?.label ?? map?.title ?? "";
1728
1826
  const crumbHead = ` ${cfg.unicode ? "\u232B" : "<"} ${parentTitle} ${cfg.unicode ? "\u25B8" : ">"} `;
1729
- const head = { text: crumbHead, sgr: "90", lo: 1, hi: displayWidth(crumbHead), index: -1 };
1730
- const tailText = fitWidth(`${nodeLabel} `, Math.max(1, cols - displayWidth(crumbHead)));
1827
+ const head = { text: crumbHead, sgr: "90", lo: 1, hi: displayWidth(crumbHead), action: { kind: "back" } };
1828
+ const tailText = fitWidth(`${nodeLabel} `, Math.max(1, viewW - displayWidth(crumbHead)));
1731
1829
  const tail = {
1732
1830
  text: tailText,
1733
1831
  sgr: "1",
1734
1832
  lo: head.hi + 1,
1735
1833
  hi: head.hi + displayWidth(tailText),
1736
- index: -1
1834
+ action: { kind: "back" }
1737
1835
  };
1738
1836
  lastTabSegments = [head, tail];
1739
1837
  tabLine = lastTabSegments.map((s) => cfg.color && s.sgr !== "" ? `\x1B[${s.sgr}m${s.text}${RESET}` : s.text).join("");
1740
1838
  } else if (tabRows() > 0) {
1741
- const tabs = lastTabFiles.map((f) => {
1742
- const m = pageData.get(f)?.map;
1743
- return {
1744
- title: m?.title ?? (pageIdOfFile(cfg.file, f) ?? "main"),
1745
- status: m !== void 0 ? mapStatus(m) : "planned",
1746
- active: f === activeFile,
1747
- fresh: pageData.get(f)?.fresh ?? false,
1748
- neutral: m !== void 0 && isNeutralKind(m)
1749
- };
1750
- });
1751
- const segments = pageTabRow(tabs, cols, cfg.unicode);
1839
+ const segments = pageTabRow(pageTabsOf(lastTabFiles), viewW, cfg.unicode, tabScroll);
1752
1840
  lastTabSegments = segments;
1753
1841
  tabLine = segments.map((s) => cfg.color && s.sgr !== "" ? `\x1B[${s.sgr}m${s.text}${RESET}` : s.text).join("");
1754
1842
  } else {
@@ -1756,7 +1844,7 @@ function main() {
1756
1844
  }
1757
1845
  const zoomTag = `${cfg.unicode ? "\u2295" : "zoom"} ${zoomLabel(zoom)}`;
1758
1846
  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";
1759
- const footerText = fitWidth(` ${hint}${panned}`, Math.max(1, cols - 1));
1847
+ const footerText = fitWidth(` ${hint}${panned}`, viewW);
1760
1848
  const footer = cfg.color ? `\x1B[90m${footerText}${RESET}` : footerText;
1761
1849
  let frame = HOME;
1762
1850
  if (tabLine !== void 0) frame += tabLine + ERASE_LINE_END + "\n";
@@ -1813,8 +1901,18 @@ function main() {
1813
1901
  if (file === activeFile) notice = describeStoreError(loaded.error);
1814
1902
  }
1815
1903
  }
1904
+ const request = takeFocusRequest(cfg.file);
1905
+ if (request !== void 0 && !(firstScan && pendingFocusFile !== void 0)) {
1906
+ pendingFocusFile = pageFilePath(cfg.file, request.page);
1907
+ }
1908
+ if (pendingFocusFile !== void 0 && pageFiles.includes(pendingFocusFile)) {
1909
+ if (pendingFocusFile !== activeFile) switchPage(pendingFocusFile);
1910
+ pendingFocusFile = void 0;
1911
+ }
1816
1912
  firstScan = false;
1817
- if (activeFile === void 0 || !pageFiles.includes(activeFile)) switchPage(pageFiles[0]);
1913
+ if (activeFile === void 0 || !pageFiles.includes(activeFile)) {
1914
+ switchPage(mostRecentPageFile(pageFiles, (f) => pageData.get(f)?.mtimeMs));
1915
+ }
1818
1916
  if ([...pageData.values()].some((p) => p.map?.nodes.some((n) => n.status === "in-progress"))) spinnerFrame++;
1819
1917
  if (flash !== void 0 && Date.now() > flash.until) flash = void 0;
1820
1918
  paint();
@@ -1849,10 +1947,14 @@ function main() {
1849
1947
  dirty = true;
1850
1948
  break;
1851
1949
  case "zoom": {
1950
+ if (event.at !== void 0 && event.at.y === 1 && tabRows() > 0 && !inSubmap()) {
1951
+ tabScroll = Math.max(0, Math.min(tabScroll + (event.delta === 1 ? -1 : 1), topFiles().length - 1));
1952
+ dirty = true;
1953
+ break;
1954
+ }
1852
1955
  const next = clampZoom(zoom + event.delta);
1853
1956
  if (next === zoom || map === void 0) break;
1854
- const cols = process.stdout.columns ?? 100;
1855
- const anchorId = hoverId ?? selectedId ?? nearestHit(lastHits, offsetX + cols / 2, offsetY + viewHeight() / 2)?.id;
1957
+ const anchorId = hoverId ?? selectedId ?? nearestHit(lastHits, offsetX + viewWidth() / 2, offsetY + viewHeight() / 2)?.id;
1856
1958
  const before = lastHits.find((h) => h.id === anchorId);
1857
1959
  zoom = next;
1858
1960
  const sized = renderMapWindow(
@@ -1916,11 +2018,13 @@ function main() {
1916
2018
  if (press && !press.moved) {
1917
2019
  const tabHit = tabRows() > 0 && event.y === 1 ? lastTabSegments.find((s) => event.x >= s.lo && event.x <= s.hi) : void 0;
1918
2020
  if (tabHit !== void 0) {
1919
- if (tabHit.index === -1) {
2021
+ if (tabHit.action.kind === "back") {
1920
2022
  climbBack();
2023
+ } else if (tabHit.action.kind === "scroll") {
2024
+ tabScroll = Math.max(0, Math.min(tabScroll + tabHit.action.delta, lastTabFiles.length - 1));
1921
2025
  } else {
1922
- const target = lastTabFiles[tabHit.index];
1923
- if (target !== void 0 && target !== activeFile) switchPage(target);
2026
+ const target = lastTabFiles[tabHit.action.index];
2027
+ if (target !== void 0 && target !== activeFile) handSwitch(target);
1924
2028
  }
1925
2029
  } else {
1926
2030
  const id = hitTest(event.x, event.y);
@@ -1931,7 +2035,7 @@ function main() {
1931
2035
  const target = pageFilePath(cfg.file, submap);
1932
2036
  if (pageFiles.includes(target) && target !== activeFile) {
1933
2037
  diveStack.push(activeFile);
1934
- switchPage(target);
2038
+ handSwitch(target);
1935
2039
  } else if (!pageFiles.includes(target)) {
1936
2040
  flash = { text: `submap "${submap}" has no page yet`, until: now + 2500 };
1937
2041
  }
@@ -1955,7 +2059,7 @@ function main() {
1955
2059
  const step = event.kind === "next-page" ? 1 : -1;
1956
2060
  const target = top[(current + step + top.length) % top.length];
1957
2061
  if (target !== activeFile) {
1958
- switchPage(target);
2062
+ handSwitch(target);
1959
2063
  dirty = true;
1960
2064
  }
1961
2065
  }
@@ -1964,7 +2068,7 @@ function main() {
1964
2068
  case "page": {
1965
2069
  const target = topFiles()[event.index];
1966
2070
  if (target !== void 0 && target !== activeFile) {
1967
- switchPage(target);
2071
+ handSwitch(target);
1968
2072
  dirty = true;
1969
2073
  }
1970
2074
  break;
@@ -2010,6 +2114,7 @@ export {
2010
2114
  launchedAsEntry,
2011
2115
  liveRipples,
2012
2116
  mapPanel,
2117
+ mostRecentPageFile,
2013
2118
  nearestHit,
2014
2119
  nodePanel,
2015
2120
  pageTabRow,
@@ -2017,7 +2122,9 @@ export {
2017
2122
  parseArgs,
2018
2123
  splashArt,
2019
2124
  splashFrame,
2125
+ tabScrollFor,
2020
2126
  topLevelFiles,
2127
+ usableColumns,
2021
2128
  waveAt,
2022
2129
  waveHash,
2023
2130
  waveLevel,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mellos-mapping",
3
- "version": "0.14.0",
3
+ "version": "0.16.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",
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * Open the live map pane in the RIGHT Windows Terminal window.
4
4
  *
5
- * node scripts/open-pane.mjs <project-dir> [--window] [--ascii] [--force]
5
+ * node scripts/open-pane.mjs <project-dir> [--page <slug>] [--window] [--ascii] [--force]
6
6
  *
7
7
  * Why this exists: the agent's shell runs on a hidden console (no WT_SESSION),
8
8
  * so a bare `wt -w 0 sp` targets the MOST RECENTLY USED terminal window — with
@@ -33,18 +33,38 @@
33
33
  * Known race, accepted: if the user focuses a DIFFERENT terminal window in
34
34
  * the ~1s between our focus-verify and wt reading its MRU state, the split
35
35
  * can still land there. The window is at least one the user is actively in.
36
+ *
37
+ * --page <slug> opens the map ON that page (the effort under discussion, not
38
+ * whatever page the store lists first). With a watcher already running it
39
+ * writes the one-shot focus file instead — the existing pane retargets within
40
+ * a poll tick — so re-running with --page is also how you steer an open pane.
36
41
  */
37
42
  import { spawnSync } from 'node:child_process';
38
- import { existsSync } from 'node:fs';
43
+ import { existsSync, mkdirSync, renameSync, writeFileSync } from 'node:fs';
39
44
  import { dirname, join, resolve } from 'node:path';
40
45
  import { fileURLToPath } from 'node:url';
41
46
 
47
+ const USAGE = 'usage: node scripts/open-pane.mjs <project-dir> [--page <slug>] [--window] [--ascii] [--force]';
48
+
42
49
  const argv = process.argv.slice(2);
43
- const flags = new Set(argv.filter((a) => a.startsWith('--')));
44
- const positional = argv.filter((a) => !a.startsWith('--'));
50
+ const flags = new Set();
51
+ const positional = [];
52
+ let pageSlug;
53
+ for (let i = 0; i < argv.length; i++) {
54
+ const a = argv[i];
55
+ if (a === '--page') pageSlug = argv[++i];
56
+ else if (a.startsWith('--')) flags.add(a);
57
+ else positional.push(a);
58
+ }
45
59
 
46
60
  if (positional.length !== 1) {
47
- console.error('usage: node scripts/open-pane.mjs <project-dir> [--window] [--ascii] [--force]');
61
+ console.error(USAGE);
62
+ process.exit(1);
63
+ }
64
+ // Mirrors ID_RULE in src/domain/types.ts — this script runs standalone and
65
+ // cannot import the TypeScript sources.
66
+ if (pageSlug !== undefined && !/^[a-z0-9][a-z0-9-]{0,63}$/.test(pageSlug)) {
67
+ console.error(`--page needs a kebab-case slug (got "${pageSlug}")\n${USAGE}`);
48
68
  process.exit(1);
49
69
  }
50
70
  if (process.platform !== 'win32') {
@@ -203,6 +223,7 @@ Write-Output "FOCUS=$(if ($focused) { 1 } else { 0 })"
203
223
  function paneCommand() {
204
224
  const cmd = ['--title', 'mellos map', '-d', projectDir, 'node', watchPath, '--file', mapFile];
205
225
  if (flags.has('--ascii')) cmd.push('--ascii');
226
+ if (pageSlug !== undefined) cmd.push('--page', pageSlug);
206
227
  return cmd;
207
228
  }
208
229
 
@@ -221,8 +242,21 @@ function openDedicatedWindow(reason) {
221
242
  }
222
243
 
223
244
  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).`);
245
+ if (pageSlug !== undefined) {
246
+ // One-shot focus request (see takeFocusRequest in src/store/store.ts):
247
+ // the running watcher consumes and deletes it within a poll tick. Temp +
248
+ // rename because the watcher polls: a torn read would be swept as junk,
249
+ // silently losing the request.
250
+ mkdirSync(dirname(mapFile), { recursive: true });
251
+ const focusFile = join(dirname(mapFile), 'mellos-mapping.focus');
252
+ writeFileSync(`${focusFile}.tmp`, JSON.stringify({ page: pageSlug }));
253
+ renameSync(`${focusFile}.tmp`, focusFile);
254
+ console.log(`MMAP_PANE already-open refocused=${pageSlug}`);
255
+ console.log(`A watcher for ${mapFile} is already running — asked it to show page "${pageSlug}".`);
256
+ } else {
257
+ console.log('MMAP_PANE already-open');
258
+ console.log(`A watcher for ${mapFile} is already running — not opening another pane (use --force to override).`);
259
+ }
226
260
  process.exit(0);
227
261
  }
228
262