@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 +28 -0
- package/README.md +3 -2
- package/dist/cli.js +90 -38
- package/dist/picker.js +268 -0
- package/package.json +1 -1
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` |
|
|
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,
|
|
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
|
|
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(
|
|
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
|
|
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 ? [
|
|
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("
|
|
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
|
|
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
|
-
|
|
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
|
|
1035
|
-
|
|
1036
|
-
const
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
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
|
-
|
|
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