@jals1212/pi-spaces-cli 0.6.2 → 0.7.1

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,44 @@ commands and options and a patch version fixes.
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.7.1
10
+
11
+ ### Fixed
12
+
13
+ - A bare `pi-space` with `--setup`: a space of another profile now starts with
14
+ that `--setup` (as `PI_SPACES_SETUP`), as the spaces of the current profile
15
+ already did.
16
+ - A failure starting a space of another profile (a command with an unclosed
17
+ quote, a program that cannot start) is reported with the profile's command
18
+ and exits 2 or 1, with a test for each.
19
+ - The picker puts the terminal back (raw mode off, cursor shown, picker
20
+ erased) on SIGTERM, SIGHUP and SIGINT, then ends the process with that
21
+ signal, instead of leaving the terminal raw.
22
+ - An escape sequence a terminal splits after its start (`ESC [`, `ESC O`, or
23
+ `ESC [` with parameters) is joined with its rest instead of being read as Esc
24
+ and text. If the rest never comes, a lone Esc still cancels and any other
25
+ half sequence is ignored.
26
+ - Lines with wide characters (Chinese, Japanese, Korean, fullwidth, emoji)
27
+ are cut by the columns they take, so they no longer wrap and break the
28
+ redraw; combining marks take none.
29
+
30
+ ## 0.7.0
31
+
32
+ ### Changed
33
+
34
+ - A bare `pi-space` opens a picker instead of a numbered list: ↑/↓ (or
35
+ Ctrl-P/Ctrl-N, Tab) move, Enter starts Pi, Esc or Ctrl-D cancel (exit 0),
36
+ Ctrl-C exits 130, and typing filters by space or profile name (Backspace,
37
+ Ctrl-U). `base` comes first; the cursor starts on the space this Pi runs in
38
+ (`PI_SPACE_HOME`), marked `(active)`. A long list scrolls to fit the
39
+ terminal. No new dependency. Without a terminal it prints the usage and exits
40
+ 2, as before.
41
+ - With a config file, and no `--profile`, `--dir` or `--command` in the call,
42
+ the picker lists the spaces of every profile under its name, the current one
43
+ first; a profile whose spaces folder is already listed is skipped. A space of
44
+ another profile starts with that profile's command and spaces folder (and
45
+ the `--config` of the call).
46
+
9
47
  ## 0.6.2
10
48
 
11
49
  ### 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` | 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";
@@ -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(dir, io, commandSource);
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
- 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 and --setup 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 names = [BASE, ...listSpaces(dir)];
1053
- io.stdout.write(`${[`Spaces in ${dir}:`, ...names.map((name, index) => `${index + 1}) ${name}`)].join("\n")}\nSpace number: `);
1054
- const line = await readLine(io.stdin);
1055
- const answer = line?.trim() ?? "";
1056
- if (answer === "") return io.exit(0);
1057
- const number = /^\d+$/.test(answer) ? Number(answer) : 0;
1058
- if (number < 1 || number > names.length) {
1059
- io.stderr.write(`pi-space: Choose a number from 1 to ${names.length}.\n`);
1060
- 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 } : {}), ...(call.setup !== undefined ? { setup: call.setup } : {}), 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
- return launch(names[number - 1], [], dir, io, commandSource);
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,352 @@
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
+ /**
75
+ * A chunk split into what is complete and the incomplete escape sequence at its end (empty when none): a lone
76
+ * ESC, ESC O, or ESC [ with parameter and intermediate bytes but no final byte yet. A terminal may send the
77
+ * rest of such a sequence in the next chunk.
78
+ */
79
+ export function splitIncomplete(chunk ) {
80
+ const start = chunk.lastIndexOf("\x1b");
81
+ if (start === -1) return [chunk, ""];
82
+ const tail = chunk.slice(start);
83
+ const incomplete = tail === "\x1b" || tail === "\x1bO" || (tail.startsWith("\x1b[") && /^[\x20-\x3f]*$/.test(tail.slice(2)));
84
+ return incomplete ? [chunk.slice(0, start), tail] : [chunk, ""];
85
+ }
86
+
87
+ /** The items whose name or group holds the query, case-insensitive, in order. */
88
+ export function visibleItems(state ) {
89
+ const query = state.query.toLowerCase();
90
+ if (query === "") return state.items;
91
+ return state.items.filter((item) => item.name.toLowerCase().includes(query) || (item.group?.toLowerCase().includes(query) ?? false));
92
+ }
93
+
94
+ /** No filter, the cursor on the active item, else on the first. */
95
+ export function initialState(items ) {
96
+ return { items, query: "", cursor: Math.max(0, items.findIndex((item) => item.active)) };
97
+ }
98
+
99
+ /** A new query puts the cursor on the first match. */
100
+ function withQuery(state , query ) {
101
+ return { ...state, query, cursor: 0 };
102
+ }
103
+
104
+ export function step(state , key ) {
105
+ const count = visibleItems(state).length;
106
+ switch (key.kind) {
107
+ case "escape":
108
+ return { done: undefined };
109
+ case "interrupt":
110
+ return { done: undefined, interrupted: true };
111
+ case "enter":
112
+ return count === 0 ? { state } : { done: visibleItems(state)[state.cursor] };
113
+ case "up":
114
+ case "down":
115
+ if (count === 0) return { state };
116
+ return { state: { ...state, cursor: (state.cursor + (key.kind === "up" ? count - 1 : 1)) % count } };
117
+ case "backspace":
118
+ return { state: withQuery(state, [...state.query].slice(0, -1).join("")) };
119
+ case "clear":
120
+ return { state: withQuery(state, "") };
121
+ case "text":
122
+ return { state: withQuery(state, state.query + key.text) };
123
+ }
124
+ }
125
+
126
+ const HINT = "Pick a space: ↑/↓ move, Enter starts Pi, Esc cancels, type to filter";
127
+
128
+ /** The terminal columns of one character: 0 for combining marks and joiners, 2 for East Asian wide ones and emoji, else 1. */
129
+ function charWidth(char ) {
130
+ const code = char.codePointAt(0) ;
131
+ if ((code >= 0x200b && code <= 0x200f) || (code >= 0xfe00 && code <= 0xfe0f) || /^[\p{Mn}\p{Me}]$/u.test(char)) return 0;
132
+ const wide =
133
+ (code >= 0x1100 && code <= 0x115f) ||
134
+ (code >= 0x2e80 && code <= 0xa4cf && code !== 0x303f) ||
135
+ (code >= 0xac00 && code <= 0xd7a3) ||
136
+ (code >= 0xf900 && code <= 0xfaff) ||
137
+ (code >= 0xfe30 && code <= 0xfe4f) ||
138
+ (code >= 0xff00 && code <= 0xff60) ||
139
+ (code >= 0xffe0 && code <= 0xffe6) ||
140
+ (code >= 0x1f300 && code <= 0x1f64f) ||
141
+ (code >= 0x1f900 && code <= 0x1f9ff) ||
142
+ (code >= 0x20000 && code <= 0x3fffd);
143
+ return wide ? 2 : 1;
144
+ }
145
+
146
+ /** The columns a text takes on a terminal. */
147
+ export function displayWidth(text ) {
148
+ let width = 0;
149
+ for (const char of text) width += charWidth(char);
150
+ return width;
151
+ }
152
+
153
+ /** The line cut to `columns` terminal columns, ending with "…" when it was longer, so it never wraps. */
154
+ function fit(line , columns ) {
155
+ if (displayWidth(line) <= columns) return line;
156
+ // Keep one column for the "…" (none when there is room for just one); a wide character is never cut in half.
157
+ const room = columns <= 1 ? Math.max(0, columns) : columns - 1;
158
+ let kept = "";
159
+ let width = 0;
160
+ for (const char of line) {
161
+ if (width + charWidth(char) > room) break;
162
+ kept += char;
163
+ width += charWidth(char);
164
+ }
165
+ return columns <= 1 ? kept : `${kept}…`;
166
+ }
167
+
168
+ /**
169
+ * The lines of one frame: the hint, the filter, then the items under a header per group (when they have
170
+ * groups), with the cursor and the active mark. Only a window around the cursor is drawn when the list is
171
+ * longer than the terminal (`rows` lines), with a line that says how many more are above and below. Every
172
+ * line is cut to `columns` so that one line is one terminal row, which the redraw counts on.
173
+ */
174
+ export function renderPicker(state , rows , columns = 80) {
175
+ return frame(state, rows).map((line) => fit(line, columns));
176
+ }
177
+
178
+ function frame(state , rows ) {
179
+ const items = visibleItems(state);
180
+ const head = [HINT, `> ${state.query}`];
181
+ if (items.length === 0) return [...head, " No space matches."];
182
+ // The body: each item's line, with its group's header before the first of the group; `at` maps the cursor.
183
+ const body = [];
184
+ let at = 0;
185
+ items.forEach((item, index) => {
186
+ if (item.group !== undefined && item.group !== items[index - 1]?.group) body.push(item.group);
187
+ if (index === state.cursor) at = body.length;
188
+ body.push(`${index === state.cursor ? " > " : " "}${item.name}${item.active ? " (active)" : ""}`);
189
+ });
190
+ // Lines the body may use: what the terminal has left after the head, keeping one for the shell prompt (at least 3).
191
+ const room = Math.max(3, rows - head.length - 1);
192
+ if (body.length <= room) return [...head, ...body];
193
+ // Body lines in the window: the room minus the two lines that may say there is more above and below.
194
+ const size = room - 2;
195
+ const start = Math.min(Math.max(0, at - Math.floor(size / 2)), body.length - size);
196
+ const end = start + size;
197
+ const above = start > 0 ? [` … ${start} more above`] : [];
198
+ const below = end < body.length ? [` … ${body.length - end} more below`] : [];
199
+ return [...head, ...above, ...body.slice(start, end), ...below];
200
+ }
201
+
202
+
203
+
204
+
205
+
206
+
207
+ /** What runPicker needs of the process: its listeners and the way to raise a signal; the real process by default. */
208
+
209
+
210
+
211
+
212
+
213
+
214
+
215
+
216
+ /** The signals that end the picker's process: it restores the terminal, then dies of the same signal. */
217
+ const SIGNALS = ["SIGTERM", "SIGHUP", "SIGINT"] ;
218
+
219
+ /** The chosen item, none when cancelled (Esc, Ctrl-D, the end of input), and whether it was Ctrl-C. */
220
+
221
+
222
+
223
+
224
+
225
+ /** How long a lone ESC waits for the rest of an escape sequence a terminal may have split across chunks. */
226
+ const ESCAPE_WAIT_MS = 50;
227
+
228
+ /**
229
+ * Runs the picker on a terminal: raw mode while it runs, each frame drawn over the last, and everything put
230
+ * back before it returns (raw mode off, the picker erased, the cursor shown, stdin paused) so Pi can start clean.
231
+ * That also holds when drawing throws (the promise rejects) or the process exits while it runs; on SIGTERM,
232
+ * SIGHUP or SIGINT the terminal is restored and the signal raised again, so the process ends as it would have.
233
+ * A chunk ending in an incomplete escape sequence is held for `escapeWaitMs` to join the next chunk; if none
234
+ * comes, a held lone ESC is the Esc key and any other held sequence is dropped.
235
+ */
236
+ export function runPicker(items , terminal , escapeWaitMs = ESCAPE_WAIT_MS, host = process) {
237
+ const { stdin, stdout } = terminal;
238
+ let state = initialState(items);
239
+ let drawn = 0;
240
+ const render = () => renderPicker(state, stdout.rows ?? 24, stdout.columns ?? 80);
241
+ const draw = (lines ) => {
242
+ // Back to the first line of the last frame, then erase down from there.
243
+ const up = drawn > 1 ? `\x1b[${drawn - 1}A` : "";
244
+ stdout.write(`${up}\r\x1b[J${lines.join("\n")}`);
245
+ drawn = lines.length;
246
+ };
247
+ return new Promise ((resolve, reject) => {
248
+ let finished = false;
249
+ // An incomplete escape sequence waiting for the rest of it, and the timer that ends the wait.
250
+ let pending = "";
251
+ let held ;
252
+ const finish = (outcome ) => {
253
+ if (finished) return;
254
+ finished = true;
255
+ clearTimeout(held);
256
+ stopListening();
257
+ restore();
258
+ if ("error" in outcome) reject(outcome.error);
259
+ else resolve(outcome.result);
260
+ };
261
+ const stopListening = () => {
262
+ host.off("exit", onExit);
263
+ for (const signal of SIGNALS) host.off(signal, onSignal[signal]);
264
+ stdin.off("data", onData);
265
+ stdin.off("end", onEnd);
266
+ };
267
+ // Restoring must not stop halfway: a write that fails here must not keep the terminal raw.
268
+ const restore = () => {
269
+ try {
270
+ stdin.setRawMode?.(false);
271
+ stdin.pause();
272
+ } catch {}
273
+ try {
274
+ draw([]);
275
+ } catch {}
276
+ try {
277
+ stdout.write("\x1b[?25h");
278
+ } catch {}
279
+ };
280
+ // A signal: restore, stop listening so its default action applies, then raise it again. The promise never settles.
281
+ const onSignal = Object.fromEntries(
282
+ SIGNALS.map((signal) => [
283
+ signal,
284
+ () => {
285
+ if (finished) return;
286
+ finished = true;
287
+ clearTimeout(held);
288
+ stopListening();
289
+ restore();
290
+ host.kill(host.pid, signal);
291
+ },
292
+ ]),
293
+ ) ;
294
+ // The process is going away without finish (exit, a throw elsewhere): leave the terminal usable.
295
+ const onExit = () => {
296
+ try {
297
+ stdin.setRawMode?.(false);
298
+ stdout.write("\x1b[?25h");
299
+ } catch {}
300
+ };
301
+ /** Applies the keys of some input; true when the picker ended. */
302
+ const handle = (text ) => {
303
+ for (const key of parseKeys(text)) {
304
+ const next = step(state, key);
305
+ if ("done" in next) {
306
+ finish({ result: { item: next.done, interrupted: next.interrupted === true } });
307
+ return true;
308
+ }
309
+ state = next.state;
310
+ }
311
+ draw(render());
312
+ return false;
313
+ };
314
+ const guarded = (run ) => {
315
+ try {
316
+ run();
317
+ } catch (error) {
318
+ finish({ error });
319
+ }
320
+ };
321
+ const onData = (data ) =>
322
+ guarded(() => {
323
+ clearTimeout(held);
324
+ const text = pending + data.toString();
325
+ pending = "";
326
+ const [complete, tail] = splitIncomplete(text);
327
+ if (tail === "") {
328
+ handle(text);
329
+ return;
330
+ }
331
+ // An incomplete sequence at the end may be one the terminal split: hold it for the next chunk.
332
+ if (complete !== "" && handle(complete)) return;
333
+ pending = tail;
334
+ held = setTimeout(() => guarded(() => {
335
+ pending = "";
336
+ // A lone ESC is the Esc key; any other half sequence is dropped.
337
+ if (tail === "\x1b") handle("\x1b");
338
+ }), escapeWaitMs);
339
+ });
340
+ const onEnd = () => finish({ result: { item: undefined, interrupted: false } });
341
+ host.on("exit", onExit);
342
+ for (const signal of SIGNALS) host.once(signal, onSignal[signal]);
343
+ guarded(() => {
344
+ stdin.setRawMode?.(true);
345
+ stdout.write("\x1b[?25l");
346
+ draw(render());
347
+ stdin.on("data", onData);
348
+ stdin.once("end", onEnd);
349
+ stdin.resume();
350
+ });
351
+ });
352
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jals1212/pi-spaces-cli",
3
- "version": "0.6.2",
3
+ "version": "0.7.1",
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",