@tikoci/rosetta 0.8.3 → 0.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/rosetta",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
4
4
  "description": "RouterOS documentation as SQLite FTS5 — RAG search + command glossary via MCP",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/browse.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  browseCommands,
32
32
  browseCommandsAtVersion,
33
33
  checkCommandVersions,
34
+ compareVersions,
34
35
  diffCommandVersions,
35
36
  fetchCurrentVersions,
36
37
  getDudePage,
@@ -199,7 +200,6 @@ function formatTime(seconds: number): string {
199
200
 
200
201
  // ── Pager ──
201
202
 
202
- /** Output lines with paging. Returns true if user quit early. */
203
203
  /**
204
204
  * Interactive pager.
205
205
  *
@@ -209,17 +209,26 @@ function formatTime(seconds: number): string {
209
209
  * b / < previous page
210
210
  * g jump to top
211
211
  * G jump to bottom
212
- * q quit pager (returns true)
212
+ * q quit pager
213
+ * 1-9 If `selectCount` is set and digit ≤ selectCount, **select**
214
+ * that result (returns { selected: N }). Otherwise, jump to
215
+ * page N within the pager.
213
216
  *
214
- * Shows a status line with page X/Y and line N/M. Returns true if user quit
215
- * before EOF, false otherwise.
217
+ * Shows a status line with page X/Y and line N/M. Returns:
218
+ * - { quit: true } user pressed q before EOF
219
+ * - { quit: false } reached EOF naturally
220
+ * - { quit: true, selected: N } user picked result N from the displayed list
216
221
  */
217
- async function paged(output: string): Promise<boolean> {
222
+ type PagerResult = { quit: boolean; selected?: number };
223
+
224
+ async function paged(output: string, opts?: { selectCount?: number }): Promise<PagerResult> {
225
+ const selectCount = opts?.selectCount ?? 0;
218
226
  const lines = output.split("\n");
219
227
  const pageSize = Math.max(5, termHeight() - 2);
220
228
  if (lines.length <= pageSize || !process.stdout.isTTY) {
221
229
  process.stdout.write(`${output}\n`);
222
- return false;
230
+ // Non-TTY (or single screen) — no interactive selection possible.
231
+ return { quit: false };
223
232
  }
224
233
  const totalPages = Math.ceil(lines.length / pageSize);
225
234
  let offset = 0;
@@ -227,15 +236,25 @@ async function paged(output: string): Promise<boolean> {
227
236
  const chunk = lines.slice(offset, offset + pageSize);
228
237
  process.stdout.write(`${chunk.join("\n")}\n`);
229
238
  const newOffset = offset + pageSize;
230
- if (newOffset >= lines.length) return false;
239
+ if (newOffset >= lines.length) return { quit: false };
231
240
  const curPage = Math.floor(newOffset / pageSize);
241
+ // Cap selectable digits at 9 (single keystroke).
242
+ const maxSel = Math.min(9, selectCount);
243
+ const selectHint = maxSel > 0
244
+ ? ` | 1-${maxSel} open #N`
245
+ : "";
246
+ const jumpHint = totalPages > 1 && maxSel < 9
247
+ ? ` | ${maxSel + 1}-${Math.min(9, totalPages)} jump page`
248
+ : totalPages > 1 && maxSel === 0
249
+ ? ` | 1-${Math.min(9, totalPages)} jump page`
250
+ : "";
232
251
  const prompt = dim(
233
- `── page ${curPage}/${totalPages} line ${newOffset}/${lines.length} [SPACE next | ENTER line | b back | 1-${Math.min(9, totalPages)} jump | g/G top/end | q quit]`,
252
+ `── page ${curPage}/${totalPages} line ${newOffset}/${lines.length} [SPACE next | ENTER line | b back${selectHint}${jumpHint} | g/G top/end | q quit]`,
234
253
  );
235
254
  process.stdout.write(prompt);
236
255
  const key = await waitForKey();
237
256
  process.stdout.write(`\r${" ".repeat(Math.min(termWidth(), stripAnsi(prompt).length))}\r`);
238
- if (key === "q" || key === "Q") return true;
257
+ if (key === "q" || key === "Q") return { quit: true };
239
258
  if (key === "g") { offset = 0; continue; }
240
259
  if (key === "G") { offset = Math.max(0, lines.length - pageSize); continue; }
241
260
  if (key === "b" || key === "<" || key === "\x1b[D") {
@@ -246,18 +265,26 @@ async function paged(output: string): Promise<boolean> {
246
265
  offset += 1;
247
266
  continue;
248
267
  }
249
- // Digit 1-9 jumps to that page (1-indexed). If the page is past EOF,
250
- // clamp to the last page.
251
268
  if (/^[1-9]$/.test(key)) {
252
- const target = Number.parseInt(key, 10);
253
- offset = Math.min(lines.length - pageSize, (target - 1) * pageSize);
269
+ const n = Number.parseInt(key, 10);
270
+ // Digits 1..selectCount SELECT the displayed result and end paging.
271
+ if (selectCount > 0 && n <= selectCount) {
272
+ return { quit: true, selected: n };
273
+ }
274
+ // Digits beyond selectCount → page jump (1-indexed). Clamp to last page.
275
+ offset = Math.min(lines.length - pageSize, (n - 1) * pageSize);
254
276
  if (offset < 0) offset = 0;
255
277
  continue;
256
278
  }
257
279
  // SPACE / f / anything else → next page
258
280
  offset = newOffset;
259
281
  }
260
- return false;
282
+ return { quit: false };
283
+ }
284
+
285
+ async function selectFromPager(output: string, selectCount: number): Promise<void> {
286
+ const result = await paged(output, { selectCount });
287
+ if (result.selected !== undefined) await handleNumberSelect(result.selected - 1);
261
288
  }
262
289
 
263
290
  function waitForKey(): Promise<string> {
@@ -1052,7 +1079,8 @@ function renderHelp(): string {
1052
1079
 
1053
1080
  cmd("<query>", "", "Search pages (default action)", "routeros_search");
1054
1081
  cmd("search <query>", "s", "Explicit page search", "routeros_search");
1055
- cmd("page <id|title>", "", "View full page", "routeros_get_page");
1082
+ cmd("page <id|title>", "", "View full page (no args = re-render current)", "routeros_get_page");
1083
+ cmd("view", "v", "Re-render current view (without popping the stack like `b`)");
1056
1084
  cmd("prop <name>", "p", "Look up property (scoped to current page)", "routeros_lookup_property");
1057
1085
  cmd("props <query>", "sp", "Search properties by FTS");
1058
1086
  cmd("glossary [term]", "g", "Look up RouterOS jargon / list glossary");
@@ -1080,8 +1108,8 @@ function renderHelp(): string {
1080
1108
  out.push(` ${cyan(pad(".resources", 38))} ${dim("Registered MCP resources (rosetta:// URIs)")}`);
1081
1109
  out.push(` ${cyan(pad(".routeros_search <q> [limit=N]", 38))} ${dim("Raw JSON output, same query path as MCP")}`);
1082
1110
  out.push("");
1083
- out.push(` ${dim("Navigation: type a number to select from results; in pager, 1–9 jumps to page N.")}`);
1084
- out.push(` ${dim("After viewing a page, [p] = properties, [cal] = page callouts, [b] = re-render previous view.")}`);
1111
+ out.push(` ${dim("Navigation: type a number to select from results; in pager, 1–N opens result #N (1-indexed).")}`);
1112
+ out.push(` ${dim("After viewing a page, [v] = re-render, [N] = go to section N, [p] = properties, [cal] = callouts, [b] = back.")}`);
1085
1113
  out.push(` ${dim("URLs are clickable in supported terminals (iTerm2, etc.).")}`);
1086
1114
  out.push(` ${dim("cmd supports @version suffix: cmd /ip/address @7.15")}`);
1087
1115
  out.push("");
@@ -1357,6 +1385,27 @@ async function dispatchDotCommand(input: string): Promise<void> {
1357
1385
  }
1358
1386
  try {
1359
1387
  const args = parseDotArgs(m[2] ?? "", tool.primary, tool.aliases);
1388
+ // If the tool has a primary arg and the user supplied no value for it
1389
+ // (and no key=value args at all), show a usage hint instead of running
1390
+ // the tool against an empty/null query — calling with no args usually
1391
+ // returns null or an empty object, which is confusing for users probing
1392
+ // the MCP surface for the first time.
1393
+ if (
1394
+ tool.primary &&
1395
+ (args[tool.primary] === undefined || args[tool.primary] === "") &&
1396
+ Object.keys(args).length === 0
1397
+ ) {
1398
+ const aliasStr = tool.aliases
1399
+ ? Object.keys(tool.aliases).map((a) => `${a}=`).join(" | ")
1400
+ : "";
1401
+ const primaryHint = aliasStr
1402
+ ? `${tool.primary}= (or ${aliasStr})`
1403
+ : `${tool.primary}=`;
1404
+ console.log(` ${dim("Usage:")} ${cyan(`.${name}`)} ${dim(`<${tool.primary}> | ${primaryHint}<value> [key=value ...]`)}`);
1405
+ console.log(` ${dim(tool.desc)}`);
1406
+ if (tool.tui) console.log(` ${dim("TUI equivalent:")} ${cyan(tool.tui)}`);
1407
+ return;
1408
+ }
1360
1409
  const result = await tool.run(args);
1361
1410
  const json = JSON.stringify(result, null, 2);
1362
1411
  const banner = dim(`── .${name} args=${JSON.stringify(args)}`);
@@ -1425,16 +1474,38 @@ async function dispatch(input: string): Promise<void> {
1425
1474
  return;
1426
1475
 
1427
1476
  case "page": {
1428
- if (!rest) { console.log(dim(" Usage: page <id|title>")); return; }
1477
+ if (!rest) {
1478
+ // No args: re-render current page if we're already viewing one.
1479
+ if (ctx.type === "page" || ctx.type === "sections") {
1480
+ await renderCurrentContext();
1481
+ return;
1482
+ }
1483
+ console.log(dim(" Usage: page <id|title>"));
1484
+ return;
1485
+ }
1429
1486
  await doPage(rest);
1430
1487
  return;
1431
1488
  }
1432
1489
 
1490
+ case "v":
1491
+ case "view": {
1492
+ // Re-render the current view — useful after exiting the pager (q) and
1493
+ // wanting to see the results/page again without losing breadcrumb state
1494
+ // (which `b` would do by popping one level).
1495
+ if (ctx.type === "home") {
1496
+ console.log(dim(" Nothing to view yet. Try: help"));
1497
+ return;
1498
+ }
1499
+ await renderCurrentContext();
1500
+ return;
1501
+ }
1502
+
1433
1503
  case "p":
1434
1504
  case "prop": {
1435
1505
  if (!rest) {
1436
1506
  // Context-scoped: show properties for current page
1437
- if (ctx.type === "page") {
1507
+ // "sections" context is also a page view (pages with headings push sections, not page)
1508
+ if (ctx.type === "page" || ctx.type === "sections") {
1438
1509
  const page = getPage(ctx.pageId, 0); // just get metadata
1439
1510
  if (page) {
1440
1511
  await doPropsForPage(ctx.pageId, ctx.title);
@@ -1482,13 +1553,14 @@ async function dispatch(input: string): Promise<void> {
1482
1553
 
1483
1554
  case "cal":
1484
1555
  case "callouts": {
1485
- if (!rest && ctx.type === "page") {
1556
+ // "sections" context is also a page view (pages with headings push sections, not page)
1557
+ if (!rest && (ctx.type === "page" || ctx.type === "sections")) {
1486
1558
  // Page-scoped: query callouts directly by page_id (the
1487
1559
  // FTS-with-empty-query path always returned [] before).
1488
- const pageCallouts = getPageCallouts((ctx as { pageId: number }).pageId);
1560
+ const pageCallouts = getPageCallouts(ctx.pageId);
1489
1561
  if (pageCallouts.length > 0) {
1490
- await paged(renderCallouts(pageCallouts));
1491
1562
  pushCtx({ type: "callouts", query: "", results: pageCallouts });
1563
+ await selectFromPager(renderCallouts(pageCallouts), pageCallouts.length);
1492
1564
  } else {
1493
1565
  console.log(dim(" This page has no callouts."));
1494
1566
  }
@@ -1665,36 +1737,36 @@ async function renderCurrentContext(): Promise<void> {
1665
1737
  console.log(dim(" ← back to home. Type 'help' for commands."));
1666
1738
  return;
1667
1739
  case "search":
1668
- await paged(renderSearchResults(ctx.response));
1740
+ await selectFromPager(renderSearchResults(ctx.response), ctx.results.length);
1669
1741
  return;
1670
1742
  case "page":
1671
1743
  case "sections": {
1672
1744
  const page = getPage(ctx.pageId);
1673
- if (page) await paged(renderPage(page));
1745
+ if (page) await selectFromPager(renderPage(page), page.sections?.length ?? 0);
1674
1746
  return;
1675
1747
  }
1676
1748
  case "commands": {
1677
1749
  const children = browseCommands(ctx.path);
1678
- await paged(renderCommandTree(ctx.path, children));
1750
+ await selectFromPager(renderCommandTree(ctx.path, children), children.length);
1679
1751
  return;
1680
1752
  }
1681
1753
  case "devices":
1682
- await paged(renderDeviceResults(ctx.results, "search", ctx.results.length));
1754
+ await selectFromPager(renderDeviceResults(ctx.results, "search", ctx.results.length), ctx.results.length);
1683
1755
  return;
1684
1756
  case "device":
1685
1757
  await paged(renderDeviceCard(ctx.device));
1686
1758
  return;
1687
1759
  case "callouts":
1688
- await paged(renderCallouts(ctx.results));
1760
+ await selectFromPager(renderCallouts(ctx.results), ctx.results.length);
1689
1761
  return;
1690
1762
  case "changelogs":
1691
- await paged(renderChangelogs(ctx.results));
1763
+ await selectFromPager(renderChangelogs(ctx.results), ctx.results.length);
1692
1764
  return;
1693
1765
  case "videos":
1694
- await paged(renderVideos(ctx.results));
1766
+ await selectFromPager(renderVideos(ctx.results), ctx.results.length);
1695
1767
  return;
1696
1768
  case "dude":
1697
- await paged(renderDudeResults(ctx.results));
1769
+ await selectFromPager(renderDudeResults(ctx.results), ctx.results.length);
1698
1770
  return;
1699
1771
  case "skills":
1700
1772
  await doListSkills();
@@ -1705,7 +1777,7 @@ async function renderCurrentContext(): Promise<void> {
1705
1777
  const p = ctx.results[i];
1706
1778
  lines.push(` ${cyan(String(i + 1).padStart(2))}. ${bold(p.name)} ${dim(`@ ${p.page_title}`)}`);
1707
1779
  }
1708
- await paged(lines.join("\n"));
1780
+ await selectFromPager(lines.join("\n"), ctx.results.length);
1709
1781
  return;
1710
1782
  }
1711
1783
  case "tests":
@@ -1718,8 +1790,10 @@ async function renderCurrentContext(): Promise<void> {
1718
1790
 
1719
1791
  async function doSearch(query: string): Promise<void> {
1720
1792
  const resp = searchAll(query);
1721
- await paged(renderSearchResults(resp));
1793
+ // Push context BEFORE paging so digit-selection in the pager (and the
1794
+ // post-pager handleNumberSelect call) both see the right ctx.
1722
1795
  pushCtx({ type: "search", response: resp, results: resp.pages });
1796
+ await selectFromPager(renderSearchResults(resp), resp.pages.length);
1723
1797
  }
1724
1798
 
1725
1799
  async function doPage(idOrTitle: string, sectionName?: string): Promise<void> {
@@ -1732,7 +1806,6 @@ async function doPage(idOrTitle: string, sectionName?: string): Promise<void> {
1732
1806
  console.log(dim(` Page not found: ${idOrTitle}`));
1733
1807
  return;
1734
1808
  }
1735
- await paged(renderPage(page));
1736
1809
 
1737
1810
  // Determine linked command path (if any)
1738
1811
  let commandPath: string | undefined;
@@ -1748,6 +1821,7 @@ async function doPage(idOrTitle: string, sectionName?: string): Promise<void> {
1748
1821
  } else {
1749
1822
  pushCtx({ type: "page", pageId: page.id, title: page.title, commandPath });
1750
1823
  }
1824
+ await selectFromPager(renderPage(page), page.sections?.length ?? 0);
1751
1825
  }
1752
1826
 
1753
1827
  async function doPropsForPage(pageId: number, title: string): Promise<void> {
@@ -1758,8 +1832,8 @@ async function doPropsForPage(pageId: number, title: string): Promise<void> {
1758
1832
  console.log(dim(` No properties found for "${title}".`));
1759
1833
  return;
1760
1834
  }
1761
- await paged(` ${bold("Properties for")} ${bold(title)}\n\n${renderProperties(pageProps)}`);
1762
1835
  pushCtx({ type: "properties", query: title, pageId, results: pageProps.map(p => ({ name: p.name, page_id: p.page_id ?? pageId, page_title: p.page_title })) });
1836
+ await selectFromPager(` ${bold("Properties for")} ${bold(title)}\n\n${renderProperties(pageProps)}`, pageProps.length);
1763
1837
  }
1764
1838
 
1765
1839
  async function doLookupProperty(name: string): Promise<void> {
@@ -1770,8 +1844,8 @@ async function doLookupProperty(name: string): Promise<void> {
1770
1844
  console.log(` Try: ${cyan("props")} ${name}`);
1771
1845
  return;
1772
1846
  }
1773
- await paged(renderProperties(results));
1774
1847
  pushCtx({ type: "properties", query: name, results: results.map(p => ({ name: p.name, page_id: p.page_id, page_title: p.page_title })) });
1848
+ await selectFromPager(renderProperties(results), results.length);
1775
1849
  }
1776
1850
 
1777
1851
  async function doSearchProperties(query: string): Promise<void> {
@@ -1780,8 +1854,8 @@ async function doSearchProperties(query: string): Promise<void> {
1780
1854
  console.log(dim(` No properties found for "${query}".`));
1781
1855
  return;
1782
1856
  }
1783
- await paged(` ${bold(String(results.length))} properties matching ${cyan(`"${query}"`)}\n\n${renderProperties(results)}`);
1784
1857
  pushCtx({ type: "properties", query, results: results.map(p => ({ name: p.name, page_id: p.page_id, page_title: p.page_title })) });
1858
+ await selectFromPager(` ${bold(String(results.length))} properties matching ${cyan(`"${query}"`)}\n\n${renderProperties(results)}`, results.length);
1785
1859
  }
1786
1860
 
1787
1861
  async function doGlossary(rest: string): Promise<void> {
@@ -1874,8 +1948,8 @@ async function doCommandTree(path: string): Promise<void> {
1874
1948
  return;
1875
1949
  }
1876
1950
  const label = version ? `${cmdPath} @${version}` : cmdPath;
1877
- await paged(renderCommandTree(label, children));
1878
1951
  pushCtx({ type: "commands", path: cmdPath });
1952
+ await selectFromPager(renderCommandTree(label, children), children.length);
1879
1953
  }
1880
1954
 
1881
1955
  async function doDeviceLookup(query: string): Promise<void> {
@@ -1884,12 +1958,15 @@ async function doDeviceLookup(query: string): Promise<void> {
1884
1958
  console.log(dim(` No devices found for "${query}".`));
1885
1959
  return;
1886
1960
  }
1887
- await paged(renderDeviceResults(result.results, result.mode, result.total));
1888
1961
  if (result.results.length === 1) {
1889
1962
  pushCtx({ type: "device", device: result.results[0] });
1890
1963
  } else {
1891
1964
  pushCtx({ type: "devices", query, results: result.results });
1892
1965
  }
1966
+ await selectFromPager(
1967
+ renderDeviceResults(result.results, result.mode, result.total),
1968
+ result.results.length > 1 ? result.results.length : 0,
1969
+ );
1893
1970
  }
1894
1971
 
1895
1972
  async function doTests(argsStr: string): Promise<void> {
@@ -1947,8 +2024,11 @@ async function doSearchCallouts(query: string): Promise<void> {
1947
2024
  console.log(dim(` No callouts found.`));
1948
2025
  return;
1949
2026
  }
1950
- await paged(` ${bold(String(results.length))} callouts${type ? ` (${type})` : ""}\n\n${renderCallouts(results)}`);
1951
2027
  pushCtx({ type: "callouts", query, results });
2028
+ await selectFromPager(
2029
+ ` ${bold(String(results.length))} callouts${type ? ` (${type})` : ""}\n\n${renderCallouts(results)}`,
2030
+ results.length,
2031
+ );
1952
2032
  }
1953
2033
 
1954
2034
  async function doSearchChangelogs(query: string): Promise<void> {
@@ -1966,7 +2046,13 @@ async function doSearchChangelogs(query: string): Promise<void> {
1966
2046
  if (part.toLowerCase() === "breaking") { breakingOnly = true; continue; }
1967
2047
  // Version range: "7.20..7.22"
1968
2048
  const rangeMatch = part.match(/^(\d+\.\d+[.\w]*)\.\.(\d+\.\d+[.\w]*)$/);
1969
- if (rangeMatch) { fromVersion = rangeMatch[1]; toVersion = rangeMatch[2]; continue; }
2049
+ if (rangeMatch) {
2050
+ const [a, b] = [rangeMatch[1], rangeMatch[2]];
2051
+ // Normalise: always put the lower version first
2052
+ if (compareVersions(a, b) <= 0) { fromVersion = a; toVersion = b; }
2053
+ else { fromVersion = b; toVersion = a; }
2054
+ continue;
2055
+ }
1970
2056
  // Single version: "7.22"
1971
2057
  if (/^\d+\.\d+/.test(part)) { version = part; continue; }
1972
2058
  searchTerms.push(part);
@@ -1979,6 +2065,8 @@ async function doSearchChangelogs(query: string): Promise<void> {
1979
2065
  version,
1980
2066
  fromVersion,
1981
2067
  toVersion,
2068
+ inclusiveFrom: fromVersion !== undefined, // X..Y range is [X, Y] inclusive on both ends
2069
+ sortAscending: fromVersion !== undefined, // range queries read chronologically (oldest first)
1982
2070
  category,
1983
2071
  breakingOnly,
1984
2072
  limit: 50,
@@ -1988,8 +2076,8 @@ async function doSearchChangelogs(query: string): Promise<void> {
1988
2076
  console.log(dim(" No changelog entries found."));
1989
2077
  return;
1990
2078
  }
1991
- await paged(` ${bold("Changelogs")}${version ? ` for ${bold(version)}` : ""}${breakingOnly ? ` ${red("(breaking only)")}` : ""}\n\n${renderChangelogs(results)}`);
1992
2079
  pushCtx({ type: "changelogs", results });
2080
+ await selectFromPager(` ${bold("Changelogs")}${version ? ` for ${bold(version)}` : ""}${breakingOnly ? ` ${red("(breaking only)")}` : ""}\n\n${renderChangelogs(results)}`, results.length);
1993
2081
  }
1994
2082
 
1995
2083
  async function doSearchVideos(query: string): Promise<void> {
@@ -1998,8 +2086,11 @@ async function doSearchVideos(query: string): Promise<void> {
1998
2086
  console.log(dim(` No video results for "${query}".`));
1999
2087
  return;
2000
2088
  }
2001
- await paged(` ${bold(String(results.length))} video results for ${cyan(`"${query}"`)}\n\n${renderVideos(results)}`);
2002
2089
  pushCtx({ type: "videos", query, results });
2090
+ await selectFromPager(
2091
+ ` ${bold(String(results.length))} video results for ${cyan(`"${query}"`)}\n\n${renderVideos(results)}`,
2092
+ results.length,
2093
+ );
2003
2094
  }
2004
2095
 
2005
2096
  async function doSearchDude(query: string): Promise<void> {
@@ -2008,8 +2099,11 @@ async function doSearchDude(query: string): Promise<void> {
2008
2099
  console.log(dim(` No Dude wiki results for "${query}".`));
2009
2100
  return;
2010
2101
  }
2011
- await paged(` ${bold(String(results.length))} Dude wiki results for ${cyan(`"${query}"`)}\n\n${renderDudeResults(results)}`);
2012
2102
  pushCtx({ type: "dude", query, results });
2103
+ await selectFromPager(
2104
+ ` ${bold(String(results.length))} Dude wiki results for ${cyan(`"${query}"`)}\n\n${renderDudeResults(results)}`,
2105
+ results.length,
2106
+ );
2013
2107
  }
2014
2108
 
2015
2109
  async function doListSkills(): Promise<void> {
@@ -2028,8 +2122,8 @@ async function doListSkills(): Promise<void> {
2028
2122
  });
2029
2123
  out.push("");
2030
2124
  out.push(` ${dim("Type a number to view, or: skill <name>")}`);
2031
- await paged(out.join("\n"));
2032
2125
  pushCtx({ type: "skills" });
2126
+ await selectFromPager(out.join("\n"), skills.length);
2033
2127
  }
2034
2128
 
2035
2129
  async function doViewSkill(name: string): Promise<void> {
@@ -2147,6 +2241,14 @@ async function main() {
2147
2241
  } catch (err) {
2148
2242
  console.error(red(` Error: ${err instanceof Error ? err.message : String(err)}`));
2149
2243
  }
2244
+ // Discard any keystrokes that leaked into readline's internal line buffer
2245
+ // while the pager was running in raw mode. readline's data handler stays
2246
+ // active during paging, so each pager keystroke (digits, q, space, b…)
2247
+ // accumulates in rl.line and reappears echoed after the next prompt via
2248
+ // _refreshLine(). Clear before re-prompting to keep the prompt clean.
2249
+ const rlBuf = rl as unknown as { line: string; cursor: number };
2250
+ rlBuf.line = "";
2251
+ rlBuf.cursor = 0;
2150
2252
  rl.setPrompt(buildPrompt());
2151
2253
  rl.prompt();
2152
2254
  });
package/src/mcp.ts CHANGED
@@ -1288,6 +1288,11 @@ what would otherwise require 125+ individual device lookups.
1288
1288
  **Configuration matching:** Uses LIKE (substring) — "25 ip filter" matches "25 ip filter rules".
1289
1289
  Note: some devices use slightly different names (e.g., "25 bridge filter" vs "25 bridge filter rules").
1290
1290
 
1291
+ **Default ordering:** Results are sorted by throughput DESC. When \`packet_size\` is NOT specified,
1292
+ 512-byte rows are surfaced first (within the LIMIT) before other sizes — 512B is the conventional
1293
+ mid-size benchmark RouterOS admins compare on, so this keeps comparable values from being crowded
1294
+ out by 1518B "best case" rows. Pin \`packet_size\` to override.
1295
+
1291
1296
  **Tip:** Call with no filters first to see available test_types, modes, configurations, and packet_sizes via the metadata field.
1292
1297
 
1293
1298
  Results include product_name, product_code, architecture — use routeros_device_lookup for full specs (CPU, RAM, ports, etc.).
package/src/query.test.ts CHANGED
@@ -1375,6 +1375,30 @@ describe("searchDeviceTests", () => {
1375
1375
  expect(res.results).toHaveLength(1);
1376
1376
  expect(res.total).toBe(4);
1377
1377
  });
1378
+
1379
+ test("prioritizes 512-byte rows when packet_size is unspecified", () => {
1380
+ // Fixtures: two 512B ethernet rows + one 1518B ethernet row (higher mbps) + one 1400B ipsec row.
1381
+ // Without 512B priority, the 1518B/19577 mbps row would lead. With priority, 512B rows lead.
1382
+ const res = searchDeviceTests({ test_type: "ethernet" });
1383
+ expect(res.results.length).toBeGreaterThanOrEqual(3);
1384
+ // All 512B rows must precede any non-512B row
1385
+ let sawNon512 = false;
1386
+ for (const row of res.results) {
1387
+ if (row.packet_size !== 512) sawNon512 = true;
1388
+ else if (sawNon512) {
1389
+ throw new Error(`512B row appeared after a non-512B row at packet_size=${row.packet_size}`);
1390
+ }
1391
+ }
1392
+ // Within the 512B bucket, mbps descending
1393
+ expect(res.results[0].packet_size).toBe(512);
1394
+ expect(res.results[0].throughput_mbps).toBe(9551.9);
1395
+ });
1396
+
1397
+ test("explicit packet_size filter overrides 512B priority", () => {
1398
+ const res = searchDeviceTests({ packet_size: 1518 });
1399
+ expect(res.results).toHaveLength(1);
1400
+ expect(res.results[0].packet_size).toBe(1518);
1401
+ });
1378
1402
  });
1379
1403
 
1380
1404
  describe("dataset CSV exports", () => {
package/src/query.ts CHANGED
@@ -1701,13 +1701,23 @@ export function searchDeviceTests(
1701
1701
  JOIN devices d ON d.id = t.device_id ${whereClause}`;
1702
1702
  const total = Number((db.prepare(totalSql).get(...params) as { c: number }).c);
1703
1703
 
1704
+ // When the caller did NOT pin a packet size, prioritise 512-byte results.
1705
+ // 512B is the conventional "mid-size" benchmark RouterOS admins compare on
1706
+ // — it best represents real-world traffic mixes. Without this, the default
1707
+ // throughput-DESC sort surfaces 1518B "best case" rows first, crowding out
1708
+ // the comparison values within the LIMIT. Within each priority bucket we
1709
+ // still sort by throughput DESC so the fastest devices float to the top.
1710
+ const orderBy = filters.packet_size === undefined
1711
+ ? `(CASE WHEN t.packet_size = 512 THEN 0 ELSE 1 END), ${orderCol} DESC NULLS LAST`
1712
+ : `${orderCol} DESC NULLS LAST`;
1713
+
1704
1714
  const sql = `SELECT d.product_name, d.product_code, d.architecture,
1705
1715
  t.test_type, t.mode, t.configuration, t.packet_size,
1706
1716
  t.throughput_kpps, t.throughput_mbps
1707
1717
  FROM device_test_results t
1708
1718
  JOIN devices d ON d.id = t.device_id
1709
1719
  ${whereClause}
1710
- ORDER BY ${orderCol} DESC NULLS LAST
1720
+ ORDER BY ${orderBy}
1711
1721
  LIMIT ?`;
1712
1722
 
1713
1723
  const results = db.prepare(sql).all(...params, limit) as DeviceTestRow[];
@@ -1852,6 +1862,7 @@ export type ChangelogResult = {
1852
1862
  is_breaking: number;
1853
1863
  description: string;
1854
1864
  excerpt: string;
1865
+ sort_order: number;
1855
1866
  };
1856
1867
 
1857
1868
  /** Get all versions that have changelog data, sorted numerically. */
@@ -1862,14 +1873,22 @@ function getChangelogVersions(): string[] {
1862
1873
  return rows.map((r) => r.version).sort(compareVersions);
1863
1874
  }
1864
1875
 
1865
- /** Filter versions to those within (fromVersion, toVersion] range (fromVersion exclusive, toVersion inclusive). */
1876
+ /**
1877
+ * Filter versions to those within a range.
1878
+ * Default: (fromVersion, toVersion] — fromVersion exclusive, toVersion inclusive.
1879
+ * Pass inclusiveFrom=true for [fromVersion, toVersion] (both ends inclusive).
1880
+ */
1866
1881
  function filterVersionRange(
1867
1882
  versions: string[],
1868
1883
  fromVersion?: string,
1869
1884
  toVersion?: string,
1885
+ inclusiveFrom = false,
1870
1886
  ): string[] {
1871
1887
  return versions.filter((v) => {
1872
- if (fromVersion && compareVersions(v, fromVersion) <= 0) return false;
1888
+ if (fromVersion) {
1889
+ const cmp = compareVersions(v, fromVersion);
1890
+ if (inclusiveFrom ? cmp < 0 : cmp <= 0) return false;
1891
+ }
1873
1892
  if (toVersion && compareVersions(v, toVersion) > 0) return false;
1874
1893
  return true;
1875
1894
  });
@@ -1882,6 +1901,8 @@ export function searchChangelogs(
1882
1901
  version?: string;
1883
1902
  fromVersion?: string;
1884
1903
  toVersion?: string;
1904
+ inclusiveFrom?: boolean;
1905
+ sortAscending?: boolean;
1885
1906
  category?: string;
1886
1907
  breakingOnly?: boolean;
1887
1908
  limit?: number;
@@ -1896,23 +1917,30 @@ export function searchChangelogs(
1896
1917
  versionList = [options.version];
1897
1918
  } else if (options.fromVersion || options.toVersion) {
1898
1919
  const all = getChangelogVersions();
1899
- versionList = filterVersionRange(all, options.fromVersion, options.toVersion);
1920
+ versionList = filterVersionRange(all, options.fromVersion, options.toVersion, options.inclusiveFrom);
1900
1921
  if (versionList.length === 0) return [];
1901
1922
  }
1902
1923
 
1924
+ let results: ChangelogResult[];
1925
+
1903
1926
  // No FTS query — browse by filters only
1904
1927
  if (terms.length === 0) {
1905
- return browseChangelogs(versionList, options.category, options.breakingOnly, limit);
1906
- }
1928
+ results = browseChangelogs(versionList, options.category, options.breakingOnly, limit);
1929
+ } else {
1930
+ // FTS search with AND, then fallback to OR
1931
+ let ftsQuery = buildFtsQuery(terms, "AND");
1932
+ if (!ftsQuery) return [];
1933
+ results = runChangelogFtsQuery(ftsQuery, versionList, options.category, options.breakingOnly, limit);
1907
1934
 
1908
- // FTS search with AND, then fallback to OR
1909
- let ftsQuery = buildFtsQuery(terms, "AND");
1910
- if (!ftsQuery) return [];
1911
- let results = runChangelogFtsQuery(ftsQuery, versionList, options.category, options.breakingOnly, limit);
1935
+ if (results.length === 0 && terms.length > 1) {
1936
+ ftsQuery = buildFtsQuery(terms, "OR");
1937
+ results = runChangelogFtsQuery(ftsQuery, versionList, options.category, options.breakingOnly, limit);
1938
+ }
1939
+ }
1912
1940
 
1913
- if (results.length === 0 && terms.length > 1) {
1914
- ftsQuery = buildFtsQuery(terms, "OR");
1915
- results = runChangelogFtsQuery(ftsQuery, versionList, options.category, options.breakingOnly, limit);
1941
+ // Re-sort by version when caller wants chronological order (oldest first)
1942
+ if (options.sortAscending) {
1943
+ results.sort((a, b) => compareVersions(a.version, b.version) || a.sort_order - b.sort_order);
1916
1944
  }
1917
1945
 
1918
1946
  return results;
@@ -1946,7 +1974,7 @@ function browseChangelogs(
1946
1974
  }
1947
1975
 
1948
1976
  const sql = `SELECT c.version, c.released, c.category, c.is_breaking,
1949
- c.description, substr(c.description, 1, 200) as excerpt
1977
+ c.description, substr(c.description, 1, 200) as excerpt, c.sort_order
1950
1978
  FROM changelogs c
1951
1979
  WHERE ${where.join(" AND ")}
1952
1980
  ORDER BY c.sort_order
@@ -1982,7 +2010,7 @@ function runChangelogFtsQuery(
1982
2010
  }
1983
2011
 
1984
2012
  const sql = `SELECT c.version, c.released, c.category, c.is_breaking,
1985
- c.description,
2013
+ c.description, c.sort_order,
1986
2014
  snippet(changelogs_fts, 1, '**', '**', '...', 25) as excerpt
1987
2015
  FROM changelogs_fts fts
1988
2016
  JOIN changelogs c ON c.id = fts.rowid
@@ -454,6 +454,51 @@ describe("CLI flags", () => {
454
454
  });
455
455
  });
456
456
 
457
+ // ---------------------------------------------------------------------------
458
+ // Browse TUI structural checks — catch pager/navigation regressions at build time
459
+ // ---------------------------------------------------------------------------
460
+
461
+ describe("browse TUI structure", () => {
462
+ const src = readText("src/browse.ts");
463
+ const changelogPrefix = 'await selectFromPager(` ' + "$" + '{bold("Changelogs")}';
464
+
465
+ test("defines helper to route pager digit selections back into current context", () => {
466
+ expect(src).toContain("async function selectFromPager");
467
+ expect(src).toContain("handleNumberSelect(result.selected - 1)");
468
+ });
469
+
470
+ test("re-rendered result views preserve pager selection", () => {
471
+ expect(src).toContain('await selectFromPager(renderSearchResults(ctx.response), ctx.results.length)');
472
+ expect(src).toContain('await selectFromPager(renderCommandTree(ctx.path, children), children.length)');
473
+ expect(src).toContain('await selectFromPager(renderCallouts(ctx.results), ctx.results.length)');
474
+ expect(src).toContain('await selectFromPager(renderChangelogs(ctx.results), ctx.results.length)');
475
+ });
476
+
477
+ test("initial changelog and page-callout views push context before pager selection", () => {
478
+ expect(src).toContain('pushCtx({ type: "changelogs", results });');
479
+ expect(src).toContain(changelogPrefix);
480
+ expect(src).toContain('pushCtx({ type: "callouts", query: "", results: pageCallouts });');
481
+ expect(src).toContain('await selectFromPager(renderCallouts(pageCallouts), pageCallouts.length);');
482
+ });
483
+
484
+ test("page-scoped [p] and [cal] work on both page and sections contexts", () => {
485
+ // Pages with headings push type:"sections" not type:"page", so both handlers
486
+ // must check for sections context to make the hints visible on the page work.
487
+ expect(src).toContain('ctx.type === "page" || ctx.type === "sections"');
488
+ // Both handlers must appear at least twice (one per fix)
489
+ const matches = src.split('ctx.type === "page" || ctx.type === "sections"').length - 1;
490
+ expect(matches).toBeGreaterThanOrEqual(2);
491
+ });
492
+
493
+ test("pager keystrokes cleared from readline buffer before re-prompt", () => {
494
+ // readline's data handler stays active while pager runs in raw mode, so
495
+ // pager keystrokes accumulate in rl.line and appear after the next prompt.
496
+ // The REPL line handler must clear rl.line/cursor before calling rl.prompt().
497
+ expect(src).toContain('rlBuf.line = ""');
498
+ expect(src).toContain("rlBuf.cursor = 0");
499
+ });
500
+ });
501
+
457
502
  // ---------------------------------------------------------------------------
458
503
  // HTTP transport structural checks — catch per-session breakage at build time
459
504
  // ---------------------------------------------------------------------------