bmweb-cli 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +10 -6
  2. package/dist/bmweb.js +158 -76
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -309,15 +309,19 @@ status line and the script's progress window are the two bottom lines.
309
309
  With no arguments it starts on the app's own home, an INPA script of the
310
310
  project's own (`home/bmweb-home.ips`): F1 picks a chassis then a module,
311
311
  F2 the chassis's whole-vehicle script, and `scriptchange` hands the screen
312
- to that script. The picks are numbered lists on the terminal; typing text
313
- filters, a number opens, Enter cancels. The home starts with or without a
314
- cable.
312
+ to that script. A pick is a list the keyboard walks: Up/Down move the bar,
313
+ typing narrows the list to the rows containing the text, Enter picks, Esc
314
+ cancels. The home starts with or without a cable.
315
+
316
+ The TUI runs on the terminal's alternate screen (the buffer vim and htop
317
+ use), so the shell's scrollback is never touched and quitting restores it;
318
+ a screen redraws in place, only the lines that changed.
315
319
 
316
320
  Every dialog INPA opens is a prompt: a message waits for Enter, an input
317
321
  asks for the number (or hex, or text) within the declared range, the
318
- two-word box takes y/n, the component picker (togglelist) lists the
319
- screen's lines by number, Select lists the named lines, save-as asks for a
320
- file name. **Every write is asked first**, exactly as the app asks: a key
322
+ two-word box takes y/n, the component picker (togglelist) and Select are
323
+ the same list picker (Space marks several where several may be picked),
324
+ save-as asks for a file name. **Every write is asked first**, exactly as the app asks: a key
321
325
  whose body can send a write names the jobs and waits for y; a screen that
322
326
  sends one on every refresh asks once for as long as it is open; n or
323
327
  Enter abandons the key. On quit the leaving menu's Back job (the script's
package/dist/bmweb.js CHANGED
@@ -1892,6 +1892,7 @@ function nodeTerminal() {
1892
1892
  };
1893
1893
  raw(true);
1894
1894
  input.resume();
1895
+ output.write("\x1B[?1049h\x1B[H");
1895
1896
  const subs = /* @__PURE__ */ new Set();
1896
1897
  let paused = false;
1897
1898
  input.on(
@@ -1945,6 +1946,7 @@ function nodeTerminal() {
1945
1946
  }
1946
1947
  },
1947
1948
  close() {
1949
+ output.write("\x1B[?1049l");
1948
1950
  raw(false);
1949
1951
  input.pause();
1950
1952
  }
@@ -1960,6 +1962,8 @@ var TuiUi = class {
1960
1962
  program = null;
1961
1963
  /** the lines of the frame on screen, when the cursor sits right below it */
1962
1964
  frame = [];
1965
+ /** a picker owns the keyboard: the program's key handler must stand back */
1966
+ modal = false;
1963
1967
  /** the terminal size the frame was drawn for; a change draws fresh */
1964
1968
  frameSize = "";
1965
1969
  leftResolve = null;
@@ -1988,10 +1992,7 @@ var TuiUi = class {
1988
1992
  * and drawn again for the new size.
1989
1993
  */
1990
1994
  resized() {
1991
- if (this.frame.length) {
1992
- this.term.write(`\x1B[${this.frame.length}A\r\x1B[J`);
1993
- this.frame = [];
1994
- }
1995
+ this.frame = [];
1995
1996
  if (this.program) this.paint(this.program);
1996
1997
  }
1997
1998
  /**
@@ -2014,26 +2015,116 @@ var TuiUi = class {
2014
2015
  return null;
2015
2016
  }
2016
2017
  const title = step.what === "module" ? `Modules of ${step.arg}` : "Vehicles";
2017
- let shown = options;
2018
- for (; ; ) {
2019
- const rows = shown.map(
2020
- (o, i) => ` ${String(i + 1).padStart(3)}. ${o.label}${o.meta ? ` (${o.meta})` : ""}`
2021
- ).join("\n");
2022
- const a = await this.ask(
2023
- `
2024
- ${title}${shown.length !== options.length ? ` (${shown.length} of ${options.length})` : ""}
2025
- ${rows}
2026
- Number to open, text to filter, Enter to cancel: `
2027
- );
2028
- if (a == null || !a.trim()) return null;
2029
- const n = Number(a.trim());
2030
- if (Number.isInteger(n) && n >= 1 && n <= shown.length)
2031
- return shown[n - 1].value;
2032
- const q = a.trim().toLowerCase();
2033
- const next = options.filter(
2018
+ const picked = await this.pickList(title, options);
2019
+ return picked ? picked[0] : null;
2020
+ }
2021
+ /**
2022
+ * The picker: a list the keyboard walks. Up/Down (and PageUp/PageDown)
2023
+ * move the bar, typing narrows the list to the rows containing the text,
2024
+ * Backspace widens it again, Enter picks the row under the bar (Space
2025
+ * marks a row when several may be picked, Enter then takes the marked
2026
+ * ones, or the bar's row when none is marked), Esc cancels. Drawn as the
2027
+ * frame, so it repaints in place like a screen.
2028
+ * @param title - what is being picked
2029
+ * @param options - the rows
2030
+ * @param multiple - whether several rows may be picked
2031
+ * @returns the picked values (one, unless multiple), or null for cancel
2032
+ */
2033
+ async pickList(title, options, multiple = false) {
2034
+ let filter = "";
2035
+ let cursor = 0;
2036
+ let top = 0;
2037
+ const marked = /* @__PURE__ */ new Set();
2038
+ const shown = () => {
2039
+ const q = filter.toLowerCase();
2040
+ return q ? options.filter(
2034
2041
  (o) => `${o.label} ${o.meta || ""} ${o.value}`.toLowerCase().includes(q)
2042
+ ) : options;
2043
+ };
2044
+ const draw = () => {
2045
+ const rows = shown();
2046
+ const w = this.term.columns;
2047
+ const window = Math.max(3, this.term.rows - 7);
2048
+ if (cursor >= rows.length) cursor = Math.max(0, rows.length - 1);
2049
+ if (cursor < top) top = cursor;
2050
+ if (cursor >= top + window) top = cursor - window + 1;
2051
+ const count = rows.length === options.length ? `${options.length}` : `${rows.length} of ${options.length}`;
2052
+ const lines = [
2053
+ `${title} \x1B[2m(${count})\x1B[0m`,
2054
+ `\x1B[2mFilter:\x1B[0m ${filter}\x1B[7m \x1B[0m`,
2055
+ ""
2056
+ ];
2057
+ for (const o of rows.slice(top, top + window)) {
2058
+ const i = rows.indexOf(o);
2059
+ const mark = multiple ? marked.has(o.value) ? "[x] " : "[ ] " : "";
2060
+ const meta = o.meta ? ` ${o.meta}` : "";
2061
+ const text = `${mark}${o.label}${meta}`.slice(0, w - 4);
2062
+ lines.push(
2063
+ i === cursor ? `\x1B[7m > ${text.padEnd(w - 4)} \x1B[0m` : ` ${o.meta ? `${mark}${o.label}\x1B[2m${meta}\x1B[0m` : text}`
2064
+ );
2065
+ }
2066
+ if (!rows.length) lines.push(" \x1B[2m(nothing matches)\x1B[0m");
2067
+ if (rows.length > top + window)
2068
+ lines.push(` \x1B[2m... ${rows.length - top - window} more\x1B[0m`);
2069
+ lines.push("");
2070
+ lines.push(
2071
+ `\x1B[2m\u2191\u2193 move type to filter Enter picks${multiple ? " Space marks" : ""} Esc cancels\x1B[0m`
2035
2072
  );
2036
- shown = next.length ? next : options;
2073
+ this.flush(lines);
2074
+ };
2075
+ this.modal = true;
2076
+ try {
2077
+ return await new Promise((resolve2) => {
2078
+ const off = this.term.onKey((k) => {
2079
+ const rows = shown();
2080
+ const finish = (v) => {
2081
+ off();
2082
+ resolve2(v);
2083
+ };
2084
+ if (k.name === "escape" || k.ctrl && k.name === "c") {
2085
+ finish(null);
2086
+ return;
2087
+ }
2088
+ if (k.name === "return" || k.name === "enter") {
2089
+ if (multiple && marked.size) {
2090
+ finish(
2091
+ options.filter((o2) => marked.has(o2.value)).map((o2) => o2.value)
2092
+ );
2093
+ return;
2094
+ }
2095
+ const o = rows[cursor];
2096
+ finish(o ? [o.value] : null);
2097
+ return;
2098
+ }
2099
+ if (k.name === "up" || k.ctrl && k.name === "p")
2100
+ cursor = Math.max(0, cursor - 1);
2101
+ else if (k.name === "down" || k.ctrl && k.name === "n")
2102
+ cursor = Math.min(rows.length - 1, cursor + 1);
2103
+ else if (k.name === "pageup")
2104
+ cursor = Math.max(0, cursor - (this.term.rows - 7));
2105
+ else if (k.name === "pagedown")
2106
+ cursor = Math.min(rows.length - 1, cursor + (this.term.rows - 7));
2107
+ else if (k.name === "home") cursor = 0;
2108
+ else if (k.name === "end") cursor = Math.max(0, rows.length - 1);
2109
+ else if (k.name === "backspace") filter = filter.slice(0, -1);
2110
+ else if (multiple && k.name === "space") {
2111
+ const o = rows[cursor];
2112
+ if (o) {
2113
+ if (marked.has(o.value)) marked.delete(o.value);
2114
+ else marked.add(o.value);
2115
+ }
2116
+ } else if (k.ch && !k.ctrl && k.ch >= " " && k.ch !== "\x7F") {
2117
+ filter += k.ch;
2118
+ cursor = 0;
2119
+ } else return;
2120
+ draw();
2121
+ });
2122
+ draw();
2123
+ });
2124
+ } finally {
2125
+ this.modal = false;
2126
+ this.frame = [];
2127
+ if (this.program) this.paint(this.program);
2037
2128
  }
2038
2129
  }
2039
2130
  /** Note the program once it exists, for the key handler. */
@@ -2041,10 +2132,9 @@ Number to open, text to filter, Enter to cancel: `
2041
2132
  this.program = p;
2042
2133
  }
2043
2134
  /**
2044
- * A prompt on the terminal. It scrolls the frame away from under the
2045
- * cursor, so the frame is forgotten and the next paint draws fresh below
2046
- * the answer (the prompt and its answer stay in the transcript, the way a
2047
- * tool call does).
2135
+ * A typed prompt under the frame (readline, raw mode off). It moves the
2136
+ * cursor, so the frame is forgotten and the next paint clears the screen
2137
+ * and draws fresh.
2048
2138
  * @param prompt - the prompt text
2049
2139
  * @returns the answer, or null when cancelled
2050
2140
  */
@@ -2164,8 +2254,8 @@ Send ${job}${arg ? ` ${arg}` : ""} to the module (${ctx.label})?${every} [y/N] `
2164
2254
  * flag, every picked key ';'-joined).
2165
2255
  */
2166
2256
  async pickComponent(p, step) {
2167
- const rows = this.R.ipoScreenComponents(p.exec, p.screen).map((l, i2) => ({
2168
- key: step.argnum ? String(i2 + 1) : String(l.keys).split(";")[0] || "",
2257
+ const rows = this.R.ipoScreenComponents(p.exec, p.screen).map((l, i) => ({
2258
+ key: step.argnum ? String(i + 1) : String(l.keys).split(";")[0] || "",
2169
2259
  caption: l.label || String(l.keys).split(";")[0] || ""
2170
2260
  }));
2171
2261
  if (!rows.length) {
@@ -2175,30 +2265,27 @@ Send ${job}${arg ? ` ${arg}` : ""} to the module (${ctx.label})?${every} [y/N] `
2175
2265
  );
2176
2266
  return null;
2177
2267
  }
2178
- const list = rows.map((r, i2) => ` ${i2 + 1}. ${r.caption} (${r.key})`).join("\n");
2268
+ const options = rows.map((r) => ({
2269
+ value: r.key,
2270
+ label: r.caption,
2271
+ meta: r.key !== r.caption ? r.key : ""
2272
+ }));
2179
2273
  if (step.multiple) {
2180
- const a2 = await this.ask(
2181
- `
2182
- ${list}
2183
- Components, comma-separated (Enter cancels): `
2184
- );
2185
- const picked = pickNumbers(a2, rows.length).map(
2186
- (i2) => rows[i2]
2187
- );
2188
- if (!picked.length) return null;
2189
- return { ort: picked.map((r) => r.key).join(";"), ein: 0 };
2190
- }
2191
- const a = await this.ask(`
2192
- ${list}
2193
- Component number (Enter cancels): `);
2194
- const [i] = pickNumbers(a, rows.length);
2195
- if (i == null) return null;
2196
- const onOff = await this.ask(`On or off? [on/off] `);
2197
- if (onOff == null || !onOff.trim()) return null;
2198
- return {
2199
- ort: rows[i].key,
2200
- ein: /^on/i.test(onOff.trim()) ? 0 : 1
2201
- };
2274
+ const picked2 = await this.pickList("Components", options, true);
2275
+ if (!picked2 || !picked2.length) return null;
2276
+ return { ort: picked2.join(";"), ein: 0 };
2277
+ }
2278
+ const picked = await this.pickList("Component", options);
2279
+ if (!picked) return null;
2280
+ const onOff = await this.pickList(
2281
+ `${options.find((o) => o.value === picked[0])?.label || picked[0]}`,
2282
+ [
2283
+ { value: "on", label: "On" },
2284
+ { value: "off", label: "Off" }
2285
+ ]
2286
+ );
2287
+ if (!onOff) return null;
2288
+ return { ort: picked[0], ein: onOff[0] === "on" ? 0 : 1 };
2202
2289
  }
2203
2290
  /** INPA's Select: which named logical lines to keep on screen. */
2204
2291
  async pickLines(_p, names, multiple, _current, hints) {
@@ -2210,16 +2297,14 @@ Component number (Enter cancels): `);
2210
2297
  );
2211
2298
  return null;
2212
2299
  }
2213
- const list = names.map((n, i) => ` ${i + 1}. ${n}`).join("\n");
2214
- const a = await this.ask(
2215
- `
2216
- ${list}
2217
- Lines to show${multiple ? ", comma-separated" : ""} (a = all, Enter cancels): `
2218
- );
2219
- if (a == null) return null;
2220
- if (/^a(ll)?$/i.test(a.trim())) return [];
2221
- const picked = pickNumbers(a, names.length).map((i) => names[i]);
2222
- return picked.length ? picked : null;
2300
+ const options = [
2301
+ { value: "\0all", label: "(all lines)" },
2302
+ ...names.map((n) => ({ value: n, label: n }))
2303
+ ];
2304
+ const picked = await this.pickList("Lines to show", options, multiple);
2305
+ if (!picked || !picked.length) return null;
2306
+ if (picked.includes("\0all")) return [];
2307
+ return picked;
2223
2308
  }
2224
2309
  /** INPA's save-as dialog: a file name, written when the body ends. */
2225
2310
  async saveFile() {
@@ -2233,10 +2318,9 @@ Save as [fault-memory.txt]: `);
2233
2318
  this.status(_p, `wrote ${picked.name}`);
2234
2319
  }
2235
2320
  printScreen(p) {
2236
- this.term.write(`
2237
- ${this.gridLines(p).join("\n")}
2238
- `);
2239
- this.frame = [];
2321
+ const name = `bmweb-screen-${Date.now()}.txt`;
2322
+ writeFileSync4(name, this.gridLines(p).join("\n") + "\n", "utf8");
2323
+ this.status(p, `printed the screen to ${name}`);
2240
2324
  }
2241
2325
  /**
2242
2326
  * The module a scriptchange names. From the home script it is the car's
@@ -2322,12 +2406,13 @@ ${this.gridLines(p).join("\n")}
2322
2406
  );
2323
2407
  }
2324
2408
  /**
2325
- * Put a frame on the terminal over the last one. With a frame on screen
2326
- * the cursor is on the line under it: go up to its top, rewrite the lines
2409
+ * Put a frame on the screen over the last one. The frame always starts
2410
+ * at the top-left of the alternate screen: go there, rewrite the lines
2327
2411
  * that differ (each erased to the end of the row), step over the ones
2328
2412
  * that match, and erase whatever the old frame had below the new one.
2329
- * Without one (first paint, after a prompt, after a resize) the frame is
2330
- * written where the cursor is.
2413
+ * With no frame to build on (first paint, after a prompt, after a
2414
+ * resize) the screen is cleared first -- on the alternate screen that
2415
+ * costs no scrollback.
2331
2416
  * @param lines - the new frame
2332
2417
  */
2333
2418
  flush(lines) {
@@ -2337,14 +2422,14 @@ ${this.gridLines(p).join("\n")}
2337
2422
  const prev = this.frame;
2338
2423
  if (prev.length === lines.length && prev.every((l, i) => l === lines[i]))
2339
2424
  return;
2340
- let s = prev.length ? `\x1B[${prev.length}A` : "";
2425
+ let s = prev.length ? "\x1B[H" : "\x1B[2J\x1B[H";
2341
2426
  lines.forEach((line, i) => {
2342
2427
  if (i < prev.length && prev[i] === line) s += "\x1B[B";
2343
2428
  else s += `\r${line}\x1B[K\r
2344
2429
  `;
2345
2430
  });
2346
2431
  if (lines.length < prev.length) s += "\r\x1B[J";
2347
- if (s) this.term.write(s);
2432
+ this.term.write(s);
2348
2433
  this.frame = lines;
2349
2434
  }
2350
2435
  /**
@@ -2404,10 +2489,6 @@ ${this.progressText}\r
2404
2489
  if (this.leftResolve) this.leftResolve();
2405
2490
  }
2406
2491
  };
2407
- function pickNumbers(a, n) {
2408
- if (a == null) return [];
2409
- return a.split(/[,\s]+/).map((s) => Number(s)).filter((i) => Number.isInteger(i) && i >= 1 && i <= n).map((i) => i - 1);
2410
- }
2411
2492
  function cellText(c) {
2412
2493
  const text = String(c.text || "");
2413
2494
  if (c.kind === "lamp") {
@@ -2496,6 +2577,7 @@ async function tuiCommand(chassis, sgbd, opts = {}) {
2496
2577
  }
2497
2578
  const program = p;
2498
2579
  const unsubscribe = term.onKey((k) => {
2580
+ if (ui.modal) return;
2499
2581
  const what = keyToPress(k);
2500
2582
  if (what === null) return;
2501
2583
  if (what === "quit") {
@@ -2525,7 +2607,7 @@ async function tuiCommand(chassis, sgbd, opts = {}) {
2525
2607
  }
2526
2608
 
2527
2609
  // src/bmweb.ts
2528
- var VERSION = true ? "0.1.1" : "0.0.0-dev";
2610
+ var VERSION = true ? "0.1.2" : "0.0.0-dev";
2529
2611
  var INCLUDE = {
2530
2612
  include: {
2531
2613
  kind: "list",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bmweb-cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "BMWeb's tools as a command line: read INPA .IPO scripts, compile .IPS sources, search the corpus job index, decode and diff shared Garage reports, and, over a K+DCAN cable, run jobs, whole-car scans and INPA screens in the terminal.",
5
5
  "license": "GPL-3.0-only",
6
6
  "type": "module",