lazy-skill 0.3.0 → 0.5.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.
@@ -3,10 +3,13 @@ import { api, ApiError } from "../lib/api.js";
3
3
  import { readConfig, updateConfig } from "../lib/config.js";
4
4
  import { detectAgents } from "../adapters/index.js";
5
5
  import { THEMES, style } from "../ui/theme.js";
6
- import { renderFramedQr } from "../ui/qr.js";
7
- import { blank, brand, fail, heading, line, muted, ok, status } from "../ui/layout.js";
6
+ import { boxWidth, visibleWidth } from "../ui/layout.js";
7
+ import { renderQr } from "../ui/qr.js";
8
+ import { blank, box, centre, fail, hint, line, muted, ok, status } from "../ui/layout.js";
9
+ import { banner, mascot } from "../ui/banner.js";
8
10
  import { messages } from "../ui/messages.js";
9
11
  import { Spinner } from "../ui/spinner.js";
12
+ const VERSION = "0.5.0";
10
13
  function friendlyPlatform() {
11
14
  const p = platform();
12
15
  return p === "darwin" || p === "win32" || p === "linux" ? p : "unknown";
@@ -23,8 +26,10 @@ export async function connectCommand(options) {
23
26
  const config = readConfig();
24
27
  const theme = THEMES[config.theme];
25
28
  blank();
26
- brand(theme);
27
- muted("See it. Search it. Install it.");
29
+ for (const row of banner(theme, VERSION))
30
+ console.log(row);
31
+ blank();
32
+ hint("See it. Search it. Install it.");
28
33
  blank();
29
34
  // Detection runs before anything is drawn: a spinner writes to the current
30
35
  // line, and anything transient printed mid-layout tears it.
@@ -59,11 +64,21 @@ export async function connectCommand(options) {
59
64
  blank();
60
65
  return 1;
61
66
  }
62
- heading(theme, "Scan to connect");
63
- muted("Open Lazy Skill on your phone and point the camera here.");
67
+ hint("Open Lazy Skill on your phone and scan this.");
64
68
  blank();
65
- for (const row of await renderFramedQr(session.pairUrl))
66
- console.log(row);
69
+ // The QR is the one thing to act on, so it gets the box. Its own quiet zone
70
+ // sits inside the border, untouched — nothing is drawn over the modules.
71
+ const plate = await renderQr(session.pairUrl);
72
+ const width = plate.length ? visibleWidth(plate[0]) + 4 : boxWidth();
73
+ box(theme, plate.map((row) => centre(row, width)), width);
74
+ // Bit sits under the code rather than over it: anything overlapping the
75
+ // module area would cost scan reliability for decoration.
76
+ blank();
77
+ const bit = mascot(theme, false);
78
+ for (let i = 0; i < bit.length; i++) {
79
+ const caption = i === 2 ? ` ${style.gray("waiting for your phone")}` : "";
80
+ console.log(` ${centre(bit[i], width - 18)}${caption}`);
81
+ }
67
82
  blank();
68
83
  muted(session.pairUrl);
69
84
  blank();
@@ -2,7 +2,7 @@ import { createInterface } from "node:readline/promises";
2
2
  import { readConfig } from "../lib/config.js";
3
3
  import { adapterFor, createInstallPlan, describePlan, detectAgents, InvalidPlanError, } from "../adapters/index.js";
4
4
  import { THEMES, rgb, style } from "../ui/theme.js";
5
- import { blank, brand, fail, line, muted, ok, status } from "../ui/layout.js";
5
+ import { blank, fail, line, muted, ok, status, welcome } from "../ui/layout.js";
6
6
  import { messages } from "../ui/messages.js";
7
7
  const STAGE_LABEL = {
8
8
  starting: "Preparing",
@@ -87,7 +87,7 @@ export async function installCommand(ref, options = { yes: false }) {
87
87
  }
88
88
  // Show exactly what will run, before it runs.
89
89
  blank();
90
- brand(theme);
90
+ welcome(theme, "Lazy Skill");
91
91
  blank();
92
92
  line(`${style.bold(ref)}`);
93
93
  muted(`for ${targets.map((t) => t.label).join(", ")} · ${options.project ? "this project" : "global"}`);
@@ -4,7 +4,7 @@ import { homePath } from "../adapters/detect.js";
4
4
  import { detectAgents } from "../adapters/index.js";
5
5
  import { readConfig } from "../lib/config.js";
6
6
  import { THEMES, style } from "../ui/theme.js";
7
- import { blank, brand, line, muted, status } from "../ui/layout.js";
7
+ import { blank, line, muted, status, welcome } from "../ui/layout.js";
8
8
  /**
9
9
  * Where each agent keeps installed skills. Read-only lookups — the CLI lists
10
10
  * what is on disk and never infers a skill that is not there.
@@ -40,7 +40,7 @@ export async function skillsCommand() {
40
40
  const theme = THEMES[readConfig().theme];
41
41
  const agents = await detectAgents();
42
42
  blank();
43
- brand(theme);
43
+ welcome(theme, "Lazy Skill");
44
44
  blank();
45
45
  let total = 0;
46
46
  for (const agent of agents) {
@@ -2,13 +2,13 @@ import { api, ApiError } from "../lib/api.js";
2
2
  import { readConfig } from "../lib/config.js";
3
3
  import { detectAgents } from "../adapters/index.js";
4
4
  import { THEMES, style } from "../ui/theme.js";
5
- import { blank, brand, line, muted, status } from "../ui/layout.js";
5
+ import { blank, line, muted, status, welcome } from "../ui/layout.js";
6
6
  export async function statusCommand() {
7
7
  const config = readConfig();
8
8
  const theme = THEMES[config.theme];
9
9
  const agents = await detectAgents();
10
10
  blank();
11
- brand(theme);
11
+ welcome(theme, "Lazy Skill");
12
12
  blank();
13
13
  if (!config.deviceToken) {
14
14
  status("computer", "off", "not connected");
@@ -1,50 +1,59 @@
1
- import { createInterface } from "node:readline/promises";
2
1
  import { readConfig, updateConfig } from "../lib/config.js";
3
2
  import { THEMES, THEME_IDS, rgb, style } from "../ui/theme.js";
3
+ import { banner, mascot } from "../ui/banner.js";
4
+ import { pointer, select } from "../ui/select.js";
5
+ import { blank, muted, ok } from "../ui/layout.js";
4
6
  /**
5
7
  * Theme picker. The choice is stored locally and travels with the next
6
- * pairing so the phone adopts the same look.
8
+ * pairing, so the phone ends up on the same colour.
7
9
  */
8
- export async function themeCommand(requested) {
10
+ export async function themeCommand(requested, version) {
9
11
  const current = readConfig().theme;
10
12
  if (requested) {
11
13
  const match = THEME_IDS.find((id) => id === requested);
12
14
  if (!match) {
13
- console.error(`\n ${style.red("✗")} Unknown theme "${requested}".`);
14
- console.error(` ${style.gray("Options:")} ${THEME_IDS.join(", ")}\n`);
15
+ blank();
16
+ muted(`Unknown theme "${requested}".`);
17
+ muted(THEME_IDS.join(", "));
18
+ blank();
15
19
  return 1;
16
20
  }
17
21
  updateConfig({ theme: match });
18
- console.log(`\n ${style.green("✓")} Theme set to ${rgb(THEMES[match].accent, THEMES[match].label)}.\n`);
22
+ blank();
23
+ ok(`Theme set to ${rgb(THEMES[match].accent, THEMES[match].label)}.`);
24
+ muted("Connect again to carry it to your phone.");
25
+ blank();
19
26
  return 0;
20
27
  }
21
- console.log(`\n ${style.bold("Pick a theme")}\n`);
22
- THEME_IDS.forEach((id, i) => {
23
- const t = THEMES[id];
24
- const marker = id === current ? style.green("●") : style.gray("○");
25
- console.log(` ${marker} ${i + 1}. ${rgb(t.accent, "████")} ${t.label}`);
28
+ const chosen = await select({
29
+ choices: THEME_IDS.map((id) => ({ value: id, label: THEMES[id].label })),
30
+ initial: THEME_IDS.indexOf(current),
31
+ render: (index) => {
32
+ const theme = THEMES[THEME_IDS[index]];
33
+ const bit = mascot(theme, true);
34
+ const out = [""];
35
+ out.push(...banner(theme, version));
36
+ out.push("");
37
+ THEME_IDS.forEach((themeId, i) => {
38
+ const t = THEMES[themeId];
39
+ const active = i === index;
40
+ const mark = themeId === current ? style.green("●") : style.gray("○");
41
+ // Pad before colouring: padEnd counts escape codes as characters, so
42
+ // padding a coloured string leaves the column ragged.
43
+ const padded = t.label.padEnd(14);
44
+ const label = active ? style.bold(padded) : style.gray(padded);
45
+ const beside = i >= 1 && i <= 5 ? " " + bit[i - 1] : "";
46
+ out.push(` ${pointer(active)} ${mark} ${rgb(t.accent, "████")} ${label}${beside}`);
47
+ });
48
+ out.push("");
49
+ out.push(` ${style.gray("↑ ↓ to move · enter to choose")}`);
50
+ out.push("");
51
+ return out;
52
+ },
26
53
  });
27
- console.log();
28
- if (!process.stdin.isTTY) {
29
- console.log(` ${style.gray("Not a terminal pass a theme id instead, e.g. lazy-skill theme matrix-green")}\n`);
30
- return 0;
31
- }
32
- const rl = createInterface({ input: process.stdin, output: process.stdout });
33
- try {
34
- const answer = (await rl.question(" Number (enter to keep current): ")).trim();
35
- if (!answer)
36
- return 0;
37
- const index = Number(answer) - 1;
38
- if (!Number.isInteger(index) || index < 0 || index >= THEME_IDS.length) {
39
- console.log(`\n ${style.yellow("!")} Not one of the options. Theme unchanged.\n`);
40
- return 1;
41
- }
42
- const chosen = THEME_IDS[index];
43
- updateConfig({ theme: chosen });
44
- console.log(`\n ${style.green("✓")} Theme set to ${rgb(THEMES[chosen].accent, THEMES[chosen].label)}.\n`);
45
- return 0;
46
- }
47
- finally {
48
- rl.close();
49
- }
54
+ updateConfig({ theme: chosen });
55
+ ok(`Theme set to ${rgb(THEMES[chosen].accent, THEMES[chosen].label)}.`);
56
+ muted("Connect again to carry it to your phone.");
57
+ blank();
58
+ return 0;
50
59
  }
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { readConfig } from "./lib/config.js";
2
+ import { isFirstRun, readConfig, updateConfig } from "./lib/config.js";
3
+ import { chooseTheme } from "./ui/onboarding.js";
3
4
  import { THEMES, rgb, style } from "./ui/theme.js";
4
5
  import { connectCommand } from "./commands/connect.js";
5
6
  import { disconnectCommand } from "./commands/disconnect.js";
@@ -9,7 +10,7 @@ import { installCommand } from "./commands/install.js";
9
10
  import { themeCommand } from "./commands/theme.js";
10
11
  import { listenCommand } from "./commands/listen.js";
11
12
  import { warnIfOutdated } from "./lib/version-check.js";
12
- const VERSION = "0.3.0";
13
+ const VERSION = "0.5.0";
13
14
  function help() {
14
15
  const theme = THEMES[readConfig().theme];
15
16
  const cmd = (name) => rgb(theme.accent, name.padEnd(26));
@@ -60,6 +61,13 @@ async function main() {
60
61
  // Only for the long-running commands: a stale copy matters most when the
61
62
  // user is about to sit and wait for something that will never work.
62
63
  const longRunning = !command || command === "connect" || command === "listen";
64
+ // Ask for a colour before anything is drawn, so the first thing the user
65
+ // sees is already in their theme — and so the phone has something to adopt
66
+ // when it pairs a moment later. Only on a genuinely first run, and never
67
+ // for `theme`, which asks on its own.
68
+ if (isFirstRun() && command !== "theme") {
69
+ updateConfig({ theme: await chooseTheme(VERSION) });
70
+ }
63
71
  if (longRunning)
64
72
  await warnIfOutdated(VERSION, THEMES[readConfig().theme]);
65
73
  if (!command) {
@@ -83,7 +91,7 @@ async function main() {
83
91
  case "skills":
84
92
  return skillsCommand();
85
93
  case "theme":
86
- return themeCommand(positional[1]);
94
+ return themeCommand(positional[1], VERSION);
87
95
  case "install": {
88
96
  const agentFlag = argv.find((a) => a.startsWith("--agent="))?.split("=")[1];
89
97
  const yes = flags.has("-y") || flags.has("--yes");
@@ -8,6 +8,10 @@ const DEFAULTS = { theme: DEFAULT_THEME };
8
8
  export function configPath() {
9
9
  return FILE;
10
10
  }
11
+ /** True before the user has ever been asked anything. */
12
+ export function isFirstRun() {
13
+ return !existsSync(FILE);
14
+ }
11
15
  export function readConfig() {
12
16
  try {
13
17
  if (!existsSync(FILE))
@@ -0,0 +1,61 @@
1
+ import { rgb, style } from "./theme.js";
2
+ /**
3
+ * The wordmark, drawn as blocks.
4
+ *
5
+ * Only the seven letters in LAZY SKILL are defined — a full alphabet would be
6
+ * dead weight for a name that never changes. Five rows tall, which reads as a
7
+ * logo without eating half the terminal.
8
+ */
9
+ const GLYPHS = {
10
+ L: ["█ ", "█ ", "█ ", "█ ", "████"],
11
+ A: [" ██ ", "█ █", "████", "█ █", "█ █"],
12
+ Z: ["████", " █", " ██ ", "█ ", "████"],
13
+ Y: ["█ █", "█ █", " ██ ", " █ ", " █ "],
14
+ S: [" ███", "█ ", " ██ ", " █", "███ "],
15
+ K: ["█ █", "█ █ ", "██ ", "█ █ ", "█ █"],
16
+ I: ["███", " █ ", " █ ", " █ ", "███"],
17
+ " ": [" ", " ", " ", " ", " "],
18
+ };
19
+ const ROWS = 5;
20
+ function render(word) {
21
+ const lines = Array.from({ length: ROWS }, () => "");
22
+ for (const char of word.toUpperCase()) {
23
+ const glyph = GLYPHS[char];
24
+ if (!glyph)
25
+ continue;
26
+ for (let r = 0; r < ROWS; r++)
27
+ lines[r] += glyph[r] + " ";
28
+ }
29
+ return lines;
30
+ }
31
+ /**
32
+ * `LAZY` in the terminal's own ink and `SKILL` in the theme accent, matching
33
+ * the two-tone split the web app uses.
34
+ */
35
+ export function banner(theme, version) {
36
+ const left = render("LAZY");
37
+ const right = render("SKILL");
38
+ const lines = left.map((row, i) => ` ${style.bold(row)} ${rgb(theme.accent, style.bold(right[i]))}`);
39
+ // The version sits on the last row, trailing the mark like a signature.
40
+ lines[ROWS - 1] += ` ${style.gray("v" + version)}`;
41
+ return lines;
42
+ }
43
+ /**
44
+ * Bit, in blocks, for sitting beside things.
45
+ *
46
+ * A deliberately small silhouette — the hood, the closed eyes, the muzzle.
47
+ * Anything more detailed turns to mush at terminal resolution.
48
+ */
49
+ export function mascot(theme, awake = false) {
50
+ const hood = (t) => rgb(theme.accent, t);
51
+ // Every row is exactly ten cells wide. Ragged rows shear the silhouette,
52
+ // and the colour codes make it impossible to eyeball.
53
+ const eyes = awake ? `${style.bold("o")} ${style.bold("o")}` : "- -";
54
+ return [
55
+ hood(" ▄████▄ "),
56
+ hood(" ██") + style.gray("‾‾‾‾") + hood("██ "),
57
+ hood(" █ ") + eyes + hood(" █ "),
58
+ hood(" █ ") + style.gray("‿‿") + hood(" █ "),
59
+ hood(" ▀████▀ "),
60
+ ];
61
+ }
package/dist/ui/layout.js CHANGED
@@ -2,30 +2,33 @@ import { rgb, style, visibleWidth } from "./theme.js";
2
2
  /**
3
3
  * The CLI's visual language.
4
4
  *
5
- * No frames. The old design wrapped everything in a box, which meant every
6
- * line carried two border glyphs and a fixed width, and the content had to
7
- * fight for the space between them. Indentation and blank lines do the same
8
- * grouping work without the noise, and they reflow on any terminal size.
5
+ * One box, around the thing the user is meant to act on — the QR, a prompt, a
6
+ * result. Everything else is indented text. Framing every line was the
7
+ * problem before: two border glyphs per row, a fixed width, and content
8
+ * squeezed between them.
9
9
  */
10
- const PAD = " ";
10
+ export const PAD = " ";
11
+ /** Box width, clamped so it stays readable in a narrow terminal. */
12
+ export function boxWidth() {
13
+ const columns = process.stdout.columns ?? 80;
14
+ return Math.max(44, Math.min(72, columns - 4));
15
+ }
11
16
  export function blank() {
12
17
  console.log();
13
18
  }
14
- /** The wordmark. Two-tone, matching the web app. */
15
- export function brand(theme) {
16
- console.log(`${PAD}${style.bold("LAZY")} ${rgb(theme.accent, style.bold("SKILL"))}${rgb(theme.support, " Zz")}`);
19
+ export function line(text = "") {
20
+ console.log(text ? `${PAD}${text}` : "");
17
21
  }
18
- /** A quiet line of supporting text. */
19
22
  export function muted(text) {
20
23
  console.log(`${PAD}${style.gray(text)}`);
21
24
  }
22
- /** A normal line. */
23
- export function line(text = "") {
24
- console.log(text ? `${PAD}${text}` : "");
25
+ /** The welcome line. The mark, then the product, then a greeting. */
26
+ export function welcome(theme, text) {
27
+ console.log(`${PAD}${rgb(theme.accent, "")} ${style.bold(text)}`);
25
28
  }
26
- /** A heading, used sparingly one per screen at most. */
27
- export function heading(theme, text) {
28
- console.log(`${PAD}${rgb(theme.accent, style.bold(text))}`);
29
+ /** An indented hint under the welcome. */
30
+ export function hint(text) {
31
+ console.log(`${PAD} ${style.gray(text)}`);
29
32
  }
30
33
  const DOT = {
31
34
  ok: style.green("●"),
@@ -33,7 +36,6 @@ const DOT = {
33
36
  off: style.gray("○"),
34
37
  fail: style.red("●"),
35
38
  };
36
- /** `● Claude ready` — the dot carries the state, the word confirms it. */
37
39
  export function status(label, tone, detail = "") {
38
40
  const width = 10;
39
41
  const name = label.length >= width ? label : label + " ".repeat(width - label.length);
@@ -44,18 +46,6 @@ export function status(label, tone, detail = "") {
44
46
  : style.gray(detail || "not found");
45
47
  console.log(`${PAD}${DOT[tone]} ${name} ${text}`);
46
48
  }
47
- /** A step in a sequence, numbered so the order is obvious. */
48
- export function step(theme, n, text) {
49
- console.log(`${PAD}${rgb(theme.accent, String(n))} ${text}`);
50
- }
51
- /** Something the user is meant to type or scan. */
52
- export function emphasis(theme, text) {
53
- console.log(`${PAD}${rgb(theme.accent, text)}`);
54
- }
55
- /** A short rule, only where a real break is needed. */
56
- export function divider(width = 34) {
57
- console.log(`${PAD}${style.gray("─".repeat(width))}`);
58
- }
59
49
  export function ok(text) {
60
50
  console.log(`${PAD}${style.green("✓")} ${text}`);
61
51
  }
@@ -65,4 +55,26 @@ export function warn(text) {
65
55
  export function fail(text) {
66
56
  console.log(`${PAD}${style.red("✗")} ${text}`);
67
57
  }
68
- export { PAD, visibleWidth };
58
+ /**
59
+ * The one box.
60
+ *
61
+ * Rows are padded to a common width using their *visible* length, so colour
62
+ * codes inside the content cannot push the right border out of alignment.
63
+ */
64
+ export function box(theme, rows, width = boxWidth()) {
65
+ const edge = (left, right) => console.log(`${PAD}${rgb(theme.accent, left + "─".repeat(width - 2) + right)}`);
66
+ const bar = rgb(theme.accent, "│");
67
+ edge("╭", "╮");
68
+ for (const row of rows) {
69
+ const pad = Math.max(0, width - 4 - visibleWidth(row));
70
+ console.log(`${PAD}${bar} ${row}${" ".repeat(pad)} ${bar}`);
71
+ }
72
+ edge("╰", "╯");
73
+ }
74
+ /** Centres a row inside the box. */
75
+ export function centre(text, width = boxWidth()) {
76
+ const inner = width - 4;
77
+ const left = Math.max(0, Math.floor((inner - visibleWidth(text)) / 2));
78
+ return " ".repeat(left) + text;
79
+ }
80
+ export { visibleWidth };
@@ -0,0 +1,44 @@
1
+ import { THEMES, THEME_IDS, rgb, style } from "./theme.js";
2
+ import { banner, mascot } from "./banner.js";
3
+ import { pointer, select } from "./select.js";
4
+ /**
5
+ * First-run theme choice, with live preview.
6
+ *
7
+ * The whole screen — wordmark, mascot, pointer — repaints in whichever theme
8
+ * is under the cursor, so the choice is made by looking rather than by
9
+ * imagining. It is asked once because it applies to both halves of the
10
+ * product: the terminal colours itself, and the phone adopts the same theme
11
+ * the moment it pairs.
12
+ */
13
+ export async function chooseTheme(version) {
14
+ const rows = (index) => {
15
+ const id = THEME_IDS[index];
16
+ const theme = THEMES[id];
17
+ const bit = mascot(theme, true);
18
+ const out = [""];
19
+ out.push(...banner(theme, version));
20
+ out.push("");
21
+ out.push(` ${style.gray("Pick a colour. Your phone will match it when you connect.")}`);
22
+ out.push("");
23
+ THEME_IDS.forEach((themeId, i) => {
24
+ const t = THEMES[themeId];
25
+ const active = i === index;
26
+ const swatch = rgb(t.accent, "████");
27
+ // Pad before colouring: padEnd counts escape codes as characters, so
28
+ // padding a coloured string leaves the column ragged.
29
+ const padded = t.label.padEnd(14);
30
+ const label = active ? style.bold(padded) : style.gray(padded);
31
+ // The mascot sits beside the list, aligned to its middle rows.
32
+ const beside = i >= 1 && i <= 5 ? " " + bit[i - 1] : "";
33
+ out.push(` ${pointer(active)} ${swatch} ${label}${beside}`);
34
+ });
35
+ out.push("");
36
+ out.push(` ${style.gray("↑ ↓ to move · enter to choose")}`);
37
+ out.push("");
38
+ return out;
39
+ };
40
+ return select({
41
+ choices: THEME_IDS.map((id) => ({ value: id, label: THEMES[id].label })),
42
+ render: rows,
43
+ });
44
+ }
@@ -0,0 +1,86 @@
1
+ import { emitKeypressEvents } from "node:readline";
2
+ import { style, hasColor } from "./theme.js";
3
+ /**
4
+ * Arrow-key selection with live redraw.
5
+ *
6
+ * Raw mode rather than a prompt library: the whole dependency would exist to
7
+ * read four escape sequences. Cursor state is restored in a `finally` and on
8
+ * SIGINT — leaving a terminal with a hidden cursor and no echo is a genuinely
9
+ * hostile thing to do to someone who pressed Ctrl-C.
10
+ */
11
+ export async function select({ choices, initial = 0, render, }) {
12
+ const stdin = process.stdin;
13
+ // No TTY means nobody to ask; a pipe or CI takes the default.
14
+ if (!stdin.isTTY || !hasColor) {
15
+ for (const row of render(initial))
16
+ console.log(row);
17
+ return choices[initial].value;
18
+ }
19
+ let index = Math.max(0, Math.min(initial, choices.length - 1));
20
+ let painted = 0;
21
+ const paint = () => {
22
+ // Move back over what was drawn last time and overwrite it, so the list
23
+ // updates in place instead of scrolling.
24
+ if (painted > 0)
25
+ process.stdout.write(`\x1b[${painted}A`);
26
+ const rows = render(index);
27
+ for (const row of rows)
28
+ process.stdout.write(`\r\x1b[K${row}\n`);
29
+ painted = rows.length;
30
+ };
31
+ emitKeypressEvents(stdin);
32
+ const wasRaw = stdin.isRaw;
33
+ stdin.setRawMode(true);
34
+ stdin.resume();
35
+ process.stdout.write("\x1b[?25l"); // hide cursor
36
+ const restore = () => {
37
+ process.stdout.write("\x1b[?25h");
38
+ stdin.setRawMode(Boolean(wasRaw));
39
+ stdin.pause();
40
+ };
41
+ return new Promise((resolve) => {
42
+ const onKey = (_chunk, key) => {
43
+ if (!key)
44
+ return;
45
+ if (key.name === "up" || key.name === "k") {
46
+ index = (index - 1 + choices.length) % choices.length;
47
+ paint();
48
+ return;
49
+ }
50
+ if (key.name === "down" || key.name === "j") {
51
+ index = (index + 1) % choices.length;
52
+ paint();
53
+ return;
54
+ }
55
+ if (key.name === "return" || key.name === "space") {
56
+ cleanup();
57
+ resolve(choices[index].value);
58
+ return;
59
+ }
60
+ // Ctrl-C and Escape both accept the highlighted value rather than
61
+ // throwing: this choice is cosmetic, and refusing to continue over it
62
+ // would be worse than picking the one under the cursor.
63
+ if ((key.ctrl && key.name === "c") || key.name === "escape") {
64
+ cleanup();
65
+ resolve(choices[index].value);
66
+ return;
67
+ }
68
+ // Number keys jump straight to a row.
69
+ const digit = Number(key.name);
70
+ if (Number.isInteger(digit) && digit >= 1 && digit <= choices.length) {
71
+ index = digit - 1;
72
+ paint();
73
+ }
74
+ };
75
+ const cleanup = () => {
76
+ stdin.off("keypress", onKey);
77
+ restore();
78
+ };
79
+ stdin.on("keypress", onKey);
80
+ paint();
81
+ });
82
+ }
83
+ /** The pointer column for a row. */
84
+ export function pointer(active) {
85
+ return active ? style.bold("❯") : " ";
86
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lazy-skill",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Found an AI skill while scrolling? Install it to your computer from your phone.",
5
5
  "type": "module",
6
6
  "bin": {