@jals1212/pi-spaces-cli 0.6.1 → 0.7.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/CHANGELOG.md CHANGED
@@ -6,6 +6,34 @@ commands and options and a patch version fixes.
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.7.0
10
+
11
+ ### Changed
12
+
13
+ - A bare `pi-space` opens a picker instead of a numbered list: ↑/↓ (or
14
+ Ctrl-P/Ctrl-N, Tab) move, Enter starts Pi, Esc or Ctrl-D cancel (exit 0),
15
+ Ctrl-C exits 130, and typing filters by space or profile name (Backspace,
16
+ Ctrl-U). `base` comes first; the cursor starts on the space this Pi runs in
17
+ (`PI_SPACE_HOME`), marked `(active)`. A long list scrolls to fit the
18
+ terminal. No new dependency. Without a terminal it prints the usage and exits
19
+ 2, as before.
20
+ - With a config file, and no `--profile`, `--dir` or `--command` in the call,
21
+ the picker lists the spaces of every profile under its name, the current one
22
+ first; a profile whose spaces folder is already listed is skipped. A space of
23
+ another profile starts with that profile's command and spaces folder (and
24
+ the `--config` of the call).
25
+
26
+ ## 0.6.2
27
+
28
+ ### Fixed
29
+
30
+ - `pi-space --help <TAB>` (and `-h`) offers the commands with their summary,
31
+ `completion` included, so the help of a command is one Tab away.
32
+ - `--every` is completed in its own `target` group instead of among the space
33
+ names, since it is not a space.
34
+ - The completion text of the first `--help` says it also takes a command:
35
+ `print the help, or a command's: --help <command>`.
36
+
9
37
  ## 0.6.1
10
38
 
11
39
  ### Added
package/README.md CHANGED
@@ -16,7 +16,7 @@ The command words are reserved: a space can never be named `list`, `new`, `remov
16
16
 
17
17
  | Command | What it does |
18
18
  | --- | --- |
19
- | `pi-space` | Shows a numbered list of spaces and starts Pi in the one you pick. |
19
+ | `pi-space` | Opens a picker of the spaces and starts Pi in the one you choose: ↑/↓ (or Ctrl-P/Ctrl-N, Tab) move, Enter starts Pi, Esc cancels, typing filters by space or profile name. `base` comes first, and the cursor starts on the space this Pi runs in, marked `(active)`. With a config file and no `--profile`, `--dir` or `--command`, the spaces of every profile are listed under its name, the current one first; a space of another profile starts with that profile. |
20
20
  | `pi-space <name> [pi args...]` | Starts Pi in that space (`base` for the home Pi starts with). |
21
21
  | `pi-space list` | Lists the spaces. |
22
22
  | `pi-space list <name\|--every>` | Lists the packages of a space (Pi's `list`). |
@@ -206,7 +206,8 @@ Only those three variables change. A variable that points into the base home sta
206
206
  | Status | Meaning |
207
207
  | --- | --- |
208
208
  | 0 … | Pi's own exit status, for a space and for a package command in one space; the setup's exit status for `setup <name>` and for a `new` whose setup failed. |
209
- | 1 | An unknown or invalid space, a name `new` or `rename` cannot use (taken, reserved or invalid), a space `remove` or `rename` cannot touch, `remove` without `--yes` and no terminal, an out-of-range pick, the program could not start, or `--every` and at least one run failed (the failed spaces are on the last line of stderr), or `setup base`, since setup runs only in spaces. |
209
+ | 1 | An unknown or invalid space, a name `new` or `rename` cannot use (taken, reserved or invalid), a space `remove` or `rename` cannot touch, `remove` without `--yes` and no terminal, the program could not start, or `--every` and at least one run failed (the failed spaces are on the last line of stderr), or `setup base`, since setup runs only in spaces. |
210
+ | 130 | Ctrl-C in the picker. |
210
211
  | 2 | A config file that is wrong or an unknown `--profile` (the problem is printed), `completion` with another shell or none, no arguments and no terminal to pick in, a command with the wrong number of names (`new`, `remove`, `rename`), `install` or `uninstall` without a source, a package command without a space or `--every`, or `--command`, `--dir`, `--setup`, `--profile` or `--config` without a value (or a blank one) or given twice: the usage is printed. `setup` without a setup command: the problem is printed. The old `--new` and `--remove`: the replacing command is printed. A `--command`, `PI_SPACES_COMMAND`, `--setup`, `PI_SPACES_SETUP` or a profile's `command` or `setup` with an unclosed quote: the problem is printed, and nothing is created or started. |
211
212
 
212
213
  ## More
package/dist/cli.js CHANGED
@@ -8,6 +8,7 @@ import { dirname, join, resolve } from "node:path";
8
8
  import { createInterface } from "node:readline";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import crossSpawn from "cross-spawn";
11
+ import { runPicker } from "./picker.js";
11
12
  import {
12
13
  BACKUPS,
13
14
  BASE,
@@ -22,6 +23,7 @@ import {
22
23
  removeSpace,
23
24
  renameSpace,
24
25
  SpaceError,
26
+ samePath,
25
27
  spacesDir,
26
28
  validateName,
27
29
  } from "@jals1212/pi-spaces/spaces";
@@ -239,8 +241,15 @@ const COMMANDS = {
239
241
  },
240
242
  };
241
243
 
244
+ /** --help after a command; GENERAL_OPTIONS has the --help that comes first. */
242
245
  const HELP_OPTION = { flag: "--help", alias: "-h", text: "the help of this command" };
243
246
 
247
+ /** The options that stand alone in first place, as completion describes them. */
248
+ const GENERAL_OPTIONS = [
249
+ ["--help", "print the help, or a command's: --help <command>"],
250
+ ["--version", "print the version"],
251
+ ];
252
+
244
253
  /** The help of one command: usage, what it does, arguments, options, the call options that apply, and examples. */
245
254
  function commandHelp(name ) {
246
255
  const help = COMMANDS[name];
@@ -284,7 +293,7 @@ export function parseCli(args ) {
284
293
  function parseCommand(args ) {
285
294
  const [first, ...rest] = args;
286
295
  if (first === undefined) return { kind: "picker" };
287
- if (isHelpFlag(first)) return rest.length === 0 ? { kind: "help" } : rest.length === 1 ? { kind: "command-help", command: rest[0] } : { kind: "usage" };
296
+ if (isHelpFlag(first)) return parseHelp(rest);
288
297
  if (first === "--version" || first === "-v") return { kind: "version" };
289
298
  if (Object.hasOwn(REPLACED, first)) return { kind: "replaced", option: first, instead: REPLACED[first ] };
290
299
  if (first.startsWith("-")) return { kind: "unknown-option", option: first };
@@ -308,6 +317,13 @@ function parseCommand(args ) {
308
317
  return { kind: "launch", name: first, args: rest };
309
318
  }
310
319
 
320
+ /** `--help` alone is the general help; `--help <command>` is that command's. */
321
+ function parseHelp(rest ) {
322
+ if (rest.length === 0) return { kind: "help" };
323
+ if (rest.length === 1) return { kind: "command-help", command: rest[0] };
324
+ return { kind: "usage" };
325
+ }
326
+
311
327
  /** `setup <name>` or `setup --every`: exactly one argument. */
312
328
  function parseSetup(rest ) {
313
329
  if (rest.length !== 1) return { kind: "usage" };
@@ -415,8 +431,8 @@ function agentHome(env , home ) {
415
431
 
416
432
 
417
433
 
418
-
419
-
434
+
435
+
420
436
 
421
437
 
422
438
 
@@ -562,6 +578,7 @@ export async function main(argv , io ) {
562
578
  case "complete":
563
579
  return complete(command.words, io);
564
580
  }
581
+ const callerIo = io;
565
582
  let settings ;
566
583
  try {
567
584
  settings = resolveSettings(command, io);
@@ -593,7 +610,7 @@ export async function main(argv , io ) {
593
610
  case "launch":
594
611
  return launch(command.name, command.args, dir, io, commandSource);
595
612
  case "picker":
596
- return pick(dir, io, commandSource);
613
+ return pick(command, settings, callerIo);
597
614
  }
598
615
  }
599
616
 
@@ -627,7 +644,7 @@ const ZSH_COMPLETION = [
627
644
  "#compdef pi-space",
628
645
  "# zsh completion for pi-space. Load it from .zshrc with: source <(pi-space completion zsh)",
629
646
  "_pi_space() {",
630
- " local -a lines cmds spaces profiles shells opts",
647
+ " local -a lines cmds spaces targets profiles shells opts",
631
648
  " local line group value text ret=1",
632
649
  ' lines=("${(@f)$(command "${words[1]}" __complete "${(@)words[2,CURRENT-1]}" 2>/dev/null)}")',
633
650
  ` if [[ "\${lines[1]}" == ${FILES} ]]; then`,
@@ -645,6 +662,7 @@ const ZSH_COMPLETION = [
645
662
  ' case "$group" in',
646
663
  ' command) cmds+=("$value:$text") ;;',
647
664
  ' space) spaces+=("$value:$text") ;;',
665
+ ' target) targets+=("$value:$text") ;;',
648
666
  ' profile) profiles+=("$value:$text") ;;',
649
667
  ' shell) shells+=("$value:$text") ;;',
650
668
  ' option) [[ "$PREFIX" == -* ]] && opts+=("$value:$text") ;;',
@@ -652,6 +670,7 @@ const ZSH_COMPLETION = [
652
670
  " done",
653
671
  " (( $#cmds )) && _describe -t commands 'command' cmds && ret=0",
654
672
  " (( $#spaces )) && _describe -t spaces 'space' spaces && ret=0",
673
+ " (( $#targets )) && _describe -t targets 'target' targets && ret=0",
655
674
  " (( $#profiles )) && _describe -t profiles 'profile' profiles && ret=0",
656
675
  " (( $#shells )) && _describe -t shells 'shell' shells && ret=0",
657
676
  " (( $#opts )) && _describe -t options 'option' opts && ret=0",
@@ -661,8 +680,8 @@ const ZSH_COMPLETION = [
661
680
  "",
662
681
  ].join("\n");
663
682
 
664
- /** The groups of a `__complete` line. */
665
-
683
+ /** The groups of a `__complete` line; `target` is --every, which stands in for a space name. */
684
+
666
685
 
667
686
  /** One `__complete` line; the description never holds a tab or a newline. */
668
687
  function candidate(group , value , text ) {
@@ -684,10 +703,10 @@ function complete(words , io ) {
684
703
  return io.exit(0);
685
704
  }
686
705
 
687
- /** The first-place commands: `completion` is left out (it still works typed in full). */
688
- function commandCandidates() {
706
+ /** The commands with their summary; in first place `completion` is left out (it still works typed in full), after --help it is not. */
707
+ function commandCandidates(withCompletion ) {
689
708
  return Object.entries(COMMANDS)
690
- .filter(([name]) => name !== "completion")
709
+ .filter(([name]) => withCompletion || name !== "completion")
691
710
  .map(([name, help]) => candidate("command", name, help.summary));
692
711
  }
693
712
 
@@ -696,7 +715,7 @@ function firstOptions(call , alone ) {
696
715
  const calls = Object.entries(CALL_OPTIONS)
697
716
  .filter(([, key]) => call[key] === undefined)
698
717
  .map(([flag, key]) => candidate("option", flag, CALL_HELP[key][1]));
699
- const general = alone ? [candidate("option", "--help", "print the help"), candidate("option", "--version", "print the version")] : [];
718
+ const general = alone ? GENERAL_OPTIONS.map(([flag, text]) => candidate("option", flag, text)) : [];
700
719
  return [...calls, ...general];
701
720
  }
702
721
 
@@ -707,7 +726,7 @@ function spaceCandidates(spaces , withBase ) {
707
726
 
708
727
  /** What follows a command word: its target, its own options and --help. */
709
728
  function commandArguments(command , spaces ) {
710
- const every = (text ) => candidate("space", "--every", text);
729
+ const every = (text ) => candidate("target", "--every", text);
711
730
  const options = [...COMMANDS[command].options, HELP_OPTION].map((option) => candidate("option", option.flag, option.text));
712
731
  switch (command) {
713
732
  case "remove":
@@ -740,8 +759,10 @@ function candidates(words , io ) {
740
759
  const { dir } = resolveSettings(call, io);
741
760
  const rest = words.slice(index);
742
761
  const spaces = listSpaces(dir);
743
- if (rest.length === 0) return [...commandCandidates(), ...spaceCandidates(spaces, true), ...firstOptions(call, index === 0)];
744
- if (rest.length > 1 || !Object.hasOwn(COMMANDS, rest[0])) return [];
762
+ if (rest.length === 0) return [...commandCandidates(false), ...spaceCandidates(spaces, true), ...firstOptions(call, index === 0)];
763
+ if (rest.length > 1) return [];
764
+ if (isHelpFlag(rest[0])) return commandCandidates(true);
765
+ if (!Object.hasOwn(COMMANDS, rest[0])) return [];
745
766
  return commandArguments(rest[0], spaces);
746
767
  }
747
768
 
@@ -1019,7 +1040,48 @@ async function runSetup(target , dir , io , setupSour
1019
1040
  io.exit(0);
1020
1041
  }
1021
1042
 
1022
- async function pick(dir , io , commandSource ) {
1043
+ /** A picker item and where it starts: a profile of the config file, or the settings of this call. */
1044
+
1045
+
1046
+
1047
+
1048
+ /**
1049
+ * The choices of the picker: base and the spaces of this call, then, when the call names no profile, folder or
1050
+ * command and there is a config file, those of every other profile, each under its name; a profile whose spaces
1051
+ * folder is already listed is skipped. The space this Pi runs in (PI_SPACE_HOME) is the active one.
1052
+ */
1053
+ function pickerChoices(call , settings , callerIo ) {
1054
+ const { dir, config, profile, io } = settings;
1055
+ const activeHome = io.env.PI_SPACE_HOME;
1056
+ const group = (groupName , profileName , spaces , baseHome ) =>
1057
+ [BASE, ...listSpaces(spaces)].map((name) => {
1058
+ const home = name === BASE ? baseHome : join(spaces, name);
1059
+ return {
1060
+ name,
1061
+ ...(groupName !== undefined ? { group: groupName } : {}),
1062
+ ...(profileName !== undefined ? { profile: profileName } : {}),
1063
+ ...(activeHome && samePath(home, activeHome) ? { active: true } : {}),
1064
+ };
1065
+ });
1066
+ const baseHome = profile?.home ?? (io.env.PI_SPACES_BASE_HOME || agentHome(io.env, io.home));
1067
+ const others = config !== undefined && call.profile === undefined && call.dir === undefined && call.command === undefined
1068
+ ? profileNames(config)
1069
+ .map((name) => profileByName(config, name, callerIo.env, callerIo.home))
1070
+ .filter((other, index, all) => !samePath(other.dir, dir) && all.findIndex((first) => samePath(first.dir, other.dir)) === index)
1071
+ : [];
1072
+ if (others.length === 0) return group(undefined, undefined, dir, baseHome);
1073
+ // The current one keeps the settings of this call; its header is its profile's name.
1074
+ const current = group(profile?.name ?? "current", undefined, dir, baseHome);
1075
+ return [...current, ...others.flatMap((other) => group(other.name, other.name, other.dir, other.home))];
1076
+ }
1077
+
1078
+ /**
1079
+ * A bare `pi-space` on a terminal: the picker, then Pi in the chosen space. A space of another profile starts
1080
+ * with that profile's settings (and the --config of this call). Esc or the end of input exit 0; Ctrl-C exits 130.
1081
+ */
1082
+ async function pick(call , settings , callerIo ) {
1083
+ const { dir, commandSource } = settings;
1084
+ const io = settings.io;
1023
1085
  if (!io.stdin.isTTY) {
1024
1086
  io.stderr.write(USAGE);
1025
1087
  return io.exit(2);
@@ -1031,28 +1093,18 @@ async function pick(dir , io , commandSource )
1031
1093
  io.stderr.write(`pi-space: ${commandSource}: ${(error ).message}.\n`);
1032
1094
  return io.exit(2);
1033
1095
  }
1034
- const names = [BASE, ...listSpaces(dir)];
1035
- io.stdout.write(`${[`Spaces in ${dir}:`, ...names.map((name, index) => `${index + 1}) ${name}`)].join("\n")}\nSpace number: `);
1036
- const line = await readLine(io.stdin);
1037
- const answer = line?.trim() ?? "";
1038
- if (answer === "") return io.exit(0);
1039
- const number = /^\d+$/.test(answer) ? Number(answer) : 0;
1040
- if (number < 1 || number > names.length) {
1041
- io.stderr.write(`pi-space: Choose a number from 1 to ${names.length}.\n`);
1042
- return io.exit(1);
1096
+ const { item, interrupted } = await runPicker(pickerChoices(call, settings, callerIo), io);
1097
+ if (item === undefined) return io.exit(interrupted ? 130 : 0);
1098
+ const choice = item ;
1099
+ if (choice.profile === undefined) return launch(choice.name, [], dir, io, commandSource);
1100
+ let chosen ;
1101
+ try {
1102
+ chosen = resolveSettings({ ...(call.config !== undefined ? { config: call.config } : {}), profile: choice.profile }, callerIo);
1103
+ } catch (error) {
1104
+ if (!(error instanceof ConfigError)) throw error;
1105
+ io.stderr.write(`pi-space: ${error.message}\n`);
1106
+ return io.exit(2);
1043
1107
  }
1044
- return launch(names[number - 1], [], dir, io, commandSource);
1045
- }
1046
-
1047
- /** One line from the stream, or undefined at end of input. */
1048
- function readLine(input ) {
1049
- return new Promise((resolve) => {
1050
- const rl = createInterface({ input });
1051
- rl.once("line", (line) => {
1052
- // Resolve first: close() emits "close" synchronously.
1053
- resolve(line);
1054
- rl.close();
1055
- });
1056
- rl.once("close", () => resolve(undefined));
1057
- });
1108
+ // Its command was not checked before the picker: launch reports an unclosed quote.
1109
+ return launch(choice.name, [], chosen.dir, chosen.io, chosen.commandSource);
1058
1110
  }
package/dist/picker.js ADDED
@@ -0,0 +1,268 @@
1
+ // The interactive picker of a bare `pi-space`: arrows move, Enter picks, Esc cancels, typing filters.
2
+ // No dependency: the terminal goes into raw mode and the keys are read from the bytes it sends.
3
+
4
+ /** One choice: a space name (or base) in an optional group, a profile's name. */
5
+
6
+
7
+
8
+
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+
23
+ /** What one key leads to: the next state, or the end with the chosen item (none when cancelled). */
24
+
25
+
26
+ const CONTROL_KEYS = {
27
+ "\r": { kind: "enter" },
28
+ "\n": { kind: "enter" },
29
+ "\x03": { kind: "interrupt" },
30
+ // Ctrl-D, like the end of input, cancels.
31
+ "\x04": { kind: "escape" },
32
+ "\x7f": { kind: "backspace" },
33
+ "\b": { kind: "backspace" },
34
+ "\x15": { kind: "clear" },
35
+ "\x10": { kind: "up" },
36
+ "\x0e": { kind: "down" },
37
+ "\t": { kind: "down" },
38
+ };
39
+
40
+ const ARROWS = { A: { kind: "up" }, B: { kind: "down" } };
41
+
42
+ /**
43
+ * The keys in one chunk of terminal input. ESC [ or ESC O start a sequence: up and down are kept, any other
44
+ * (Home, Delete, F1) is skipped whole. An ESC followed by nothing or by another ESC is the Esc key.
45
+ */
46
+ export function parseKeys(chunk ) {
47
+ const keys = [];
48
+ const chars = [...chunk];
49
+ let index = 0;
50
+ while (index < chars.length) {
51
+ const char = chars[index];
52
+ if (char === "\x1b") {
53
+ const next = chars[index + 1];
54
+ if (next !== "[" && next !== "O") {
55
+ keys.push({ kind: "escape" });
56
+ index += 1;
57
+ continue;
58
+ }
59
+ // Parameters, then one final byte from "@" to "~"; ESC O takes just the one byte after it.
60
+ let end = index + 2;
61
+ if (next === "[") while (end < chars.length && !/[@-~]/.test(chars[end])) end += 1;
62
+ const final = chars[end];
63
+ if (final !== undefined && end === index + 2 && Object.hasOwn(ARROWS, final)) keys.push(ARROWS[final]);
64
+ index = end + 1;
65
+ continue;
66
+ }
67
+ if (Object.hasOwn(CONTROL_KEYS, char)) keys.push(CONTROL_KEYS[char]);
68
+ else if (char >= " ") keys.push({ kind: "text", text: char });
69
+ index += 1;
70
+ }
71
+ return keys;
72
+ }
73
+
74
+ /** The items whose name or group holds the query, case-insensitive, in order. */
75
+ export function visibleItems(state ) {
76
+ const query = state.query.toLowerCase();
77
+ if (query === "") return state.items;
78
+ return state.items.filter((item) => item.name.toLowerCase().includes(query) || (item.group?.toLowerCase().includes(query) ?? false));
79
+ }
80
+
81
+ /** No filter, the cursor on the active item, else on the first. */
82
+ export function initialState(items ) {
83
+ return { items, query: "", cursor: Math.max(0, items.findIndex((item) => item.active)) };
84
+ }
85
+
86
+ /** A new query puts the cursor on the first match. */
87
+ function withQuery(state , query ) {
88
+ return { ...state, query, cursor: 0 };
89
+ }
90
+
91
+ export function step(state , key ) {
92
+ const count = visibleItems(state).length;
93
+ switch (key.kind) {
94
+ case "escape":
95
+ return { done: undefined };
96
+ case "interrupt":
97
+ return { done: undefined, interrupted: true };
98
+ case "enter":
99
+ return count === 0 ? { state } : { done: visibleItems(state)[state.cursor] };
100
+ case "up":
101
+ case "down":
102
+ if (count === 0) return { state };
103
+ return { state: { ...state, cursor: (state.cursor + (key.kind === "up" ? count - 1 : 1)) % count } };
104
+ case "backspace":
105
+ return { state: withQuery(state, [...state.query].slice(0, -1).join("")) };
106
+ case "clear":
107
+ return { state: withQuery(state, "") };
108
+ case "text":
109
+ return { state: withQuery(state, state.query + key.text) };
110
+ }
111
+ }
112
+
113
+ const HINT = "Pick a space: ↑/↓ move, Enter starts Pi, Esc cancels, type to filter";
114
+
115
+ /** The line cut to `columns` code points, ending with "…" when it was longer, so it never wraps. */
116
+ function fit(line , columns ) {
117
+ const chars = [...line];
118
+ if (chars.length <= columns) return line;
119
+ return columns <= 1 ? chars.slice(0, Math.max(0, columns)).join("") : `${chars.slice(0, columns - 1).join("")}…`;
120
+ }
121
+
122
+ /**
123
+ * The lines of one frame: the hint, the filter, then the items under a header per group (when they have
124
+ * groups), with the cursor and the active mark. Only a window around the cursor is drawn when the list is
125
+ * longer than the terminal (`rows` lines), with a line that says how many more are above and below. Every
126
+ * line is cut to `columns` so that one line is one terminal row, which the redraw counts on.
127
+ */
128
+ export function renderPicker(state , rows , columns = 80) {
129
+ return frame(state, rows).map((line) => fit(line, columns));
130
+ }
131
+
132
+ function frame(state , rows ) {
133
+ const items = visibleItems(state);
134
+ const head = [HINT, `> ${state.query}`];
135
+ if (items.length === 0) return [...head, " No space matches."];
136
+ // The body: each item's line, with its group's header before the first of the group; `at` maps the cursor.
137
+ const body = [];
138
+ let at = 0;
139
+ items.forEach((item, index) => {
140
+ if (item.group !== undefined && item.group !== items[index - 1]?.group) body.push(item.group);
141
+ if (index === state.cursor) at = body.length;
142
+ body.push(`${index === state.cursor ? " > " : " "}${item.name}${item.active ? " (active)" : ""}`);
143
+ });
144
+ // Lines the body may use: what the terminal has left after the head, keeping one for the shell prompt (at least 3).
145
+ const room = Math.max(3, rows - head.length - 1);
146
+ if (body.length <= room) return [...head, ...body];
147
+ // Body lines in the window: the room minus the two lines that may say there is more above and below.
148
+ const size = room - 2;
149
+ const start = Math.min(Math.max(0, at - Math.floor(size / 2)), body.length - size);
150
+ const end = start + size;
151
+ const above = start > 0 ? [` … ${start} more above`] : [];
152
+ const below = end < body.length ? [` … ${body.length - end} more below`] : [];
153
+ return [...head, ...above, ...body.slice(start, end), ...below];
154
+ }
155
+
156
+
157
+
158
+
159
+
160
+
161
+ /** The chosen item, none when cancelled (Esc, Ctrl-D, the end of input), and whether it was Ctrl-C. */
162
+
163
+
164
+
165
+
166
+
167
+ /** How long a lone ESC waits for the rest of an escape sequence a terminal may have split across chunks. */
168
+ const ESCAPE_WAIT_MS = 50;
169
+
170
+ /**
171
+ * Runs the picker on a terminal: raw mode while it runs, each frame drawn over the last, and everything put
172
+ * back before it returns (raw mode off, the picker erased, the cursor shown, stdin paused) so Pi can start clean.
173
+ * That also holds when drawing throws (the promise rejects) or the process exits while it runs.
174
+ * A chunk ending in a bare ESC is held for `escapeWaitMs` to join the next chunk; if none comes, it is the Esc key.
175
+ */
176
+ export function runPicker(items , terminal , escapeWaitMs = ESCAPE_WAIT_MS) {
177
+ const { stdin, stdout } = terminal;
178
+ let state = initialState(items);
179
+ let drawn = 0;
180
+ const render = () => renderPicker(state, stdout.rows ?? 24, stdout.columns ?? 80);
181
+ const draw = (lines ) => {
182
+ // Back to the first line of the last frame, then erase down from there.
183
+ const up = drawn > 1 ? `\x1b[${drawn - 1}A` : "";
184
+ stdout.write(`${up}\r\x1b[J${lines.join("\n")}`);
185
+ drawn = lines.length;
186
+ };
187
+ return new Promise ((resolve, reject) => {
188
+ let finished = false;
189
+ // A bare ESC waiting for the rest of its sequence, and the timer that turns it into the Esc key.
190
+ let pending = "";
191
+ let held ;
192
+ const finish = (outcome ) => {
193
+ if (finished) return;
194
+ finished = true;
195
+ clearTimeout(held);
196
+ process.off("exit", onExit);
197
+ stdin.off("data", onData);
198
+ stdin.off("end", onEnd);
199
+ // Restoring comes first and must not stop halfway: a write that fails here must not keep the terminal raw.
200
+ try {
201
+ stdin.setRawMode?.(false);
202
+ stdin.pause();
203
+ } catch {}
204
+ try {
205
+ draw([]);
206
+ } catch {}
207
+ try {
208
+ stdout.write("\x1b[?25h");
209
+ } catch {}
210
+ if ("error" in outcome) reject(outcome.error);
211
+ else resolve(outcome.result);
212
+ };
213
+ // The process is going away without finish (exit, a throw elsewhere): leave the terminal usable.
214
+ const onExit = () => {
215
+ try {
216
+ stdin.setRawMode?.(false);
217
+ stdout.write("\x1b[?25h");
218
+ } catch {}
219
+ };
220
+ /** Applies the keys of some input; true when the picker ended. */
221
+ const handle = (text ) => {
222
+ for (const key of parseKeys(text)) {
223
+ const next = step(state, key);
224
+ if ("done" in next) {
225
+ finish({ result: { item: next.done, interrupted: next.interrupted === true } });
226
+ return true;
227
+ }
228
+ state = next.state;
229
+ }
230
+ draw(render());
231
+ return false;
232
+ };
233
+ const guarded = (run ) => {
234
+ try {
235
+ run();
236
+ } catch (error) {
237
+ finish({ error });
238
+ }
239
+ };
240
+ const onData = (data ) =>
241
+ guarded(() => {
242
+ clearTimeout(held);
243
+ const text = pending + data.toString();
244
+ pending = "";
245
+ if (!text.endsWith("\x1b")) {
246
+ handle(text);
247
+ return;
248
+ }
249
+ // A bare ESC at the end may be the start of a sequence the terminal split: hold it for the next chunk.
250
+ if (text.length > 1 && handle(text.slice(0, -1))) return;
251
+ pending = "\x1b";
252
+ held = setTimeout(() => guarded(() => {
253
+ pending = "";
254
+ handle("\x1b");
255
+ }), escapeWaitMs);
256
+ });
257
+ const onEnd = () => finish({ result: { item: undefined, interrupted: false } });
258
+ process.on("exit", onExit);
259
+ guarded(() => {
260
+ stdin.setRawMode?.(true);
261
+ stdout.write("\x1b[?25l");
262
+ draw(render());
263
+ stdin.on("data", onData);
264
+ stdin.once("end", onEnd);
265
+ stdin.resume();
266
+ });
267
+ });
268
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jals1212/pi-spaces-cli",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "The pi-space command: pick a space and start Pi in it. Companion to the Pi extension @jals1212/pi-spaces.",
5
5
  "license": "MIT",
6
6
  "author": "jals1212",