@jals1212/pi-spaces-cli 0.6.2 → 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 +17 -0
- package/README.md +3 -2
- package/dist/cli.js +61 -27
- package/dist/picker.js +268 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,23 @@ 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
|
+
|
|
9
26
|
## 0.6.2
|
|
10
27
|
|
|
11
28
|
### Fixed
|
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";
|
|
@@ -429,8 +431,8 @@ function agentHome(env , home ) {
|
|
|
429
431
|
|
|
430
432
|
|
|
431
433
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
+
|
|
435
|
+
|
|
434
436
|
|
|
435
437
|
|
|
436
438
|
|
|
@@ -576,6 +578,7 @@ export async function main(argv , io ) {
|
|
|
576
578
|
case "complete":
|
|
577
579
|
return complete(command.words, io);
|
|
578
580
|
}
|
|
581
|
+
const callerIo = io;
|
|
579
582
|
let settings ;
|
|
580
583
|
try {
|
|
581
584
|
settings = resolveSettings(command, io);
|
|
@@ -607,7 +610,7 @@ export async function main(argv , io ) {
|
|
|
607
610
|
case "launch":
|
|
608
611
|
return launch(command.name, command.args, dir, io, commandSource);
|
|
609
612
|
case "picker":
|
|
610
|
-
return pick(
|
|
613
|
+
return pick(command, settings, callerIo);
|
|
611
614
|
}
|
|
612
615
|
}
|
|
613
616
|
|
|
@@ -1037,7 +1040,48 @@ async function runSetup(target , dir , io , setupSour
|
|
|
1037
1040
|
io.exit(0);
|
|
1038
1041
|
}
|
|
1039
1042
|
|
|
1040
|
-
|
|
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;
|
|
1041
1085
|
if (!io.stdin.isTTY) {
|
|
1042
1086
|
io.stderr.write(USAGE);
|
|
1043
1087
|
return io.exit(2);
|
|
@@ -1049,28 +1093,18 @@ async function pick(dir , io , commandSource )
|
|
|
1049
1093
|
io.stderr.write(`pi-space: ${commandSource}: ${(error ).message}.\n`);
|
|
1050
1094
|
return io.exit(2);
|
|
1051
1095
|
}
|
|
1052
|
-
const
|
|
1053
|
-
|
|
1054
|
-
const
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
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);
|
|
1061
1107
|
}
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
/** One line from the stream, or undefined at end of input. */
|
|
1066
|
-
function readLine(input ) {
|
|
1067
|
-
return new Promise((resolve) => {
|
|
1068
|
-
const rl = createInterface({ input });
|
|
1069
|
-
rl.once("line", (line) => {
|
|
1070
|
-
// Resolve first: close() emits "close" synchronously.
|
|
1071
|
-
resolve(line);
|
|
1072
|
-
rl.close();
|
|
1073
|
-
});
|
|
1074
|
-
rl.once("close", () => resolve(undefined));
|
|
1075
|
-
});
|
|
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);
|
|
1076
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