@jals1212/pi-spaces-cli 0.7.0 → 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,27 @@ 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
+
9
30
  ## 0.7.0
10
31
 
11
32
  ### Changed
package/dist/cli.js CHANGED
@@ -1077,7 +1077,7 @@ function pickerChoices(call , settings , callerIo )
1077
1077
 
1078
1078
  /**
1079
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.
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
1081
  */
1082
1082
  async function pick(call , settings , callerIo ) {
1083
1083
  const { dir, commandSource } = settings;
@@ -1099,7 +1099,7 @@ async function pick(call , settings , callerIo )
1099
1099
  if (choice.profile === undefined) return launch(choice.name, [], dir, io, commandSource);
1100
1100
  let chosen ;
1101
1101
  try {
1102
- chosen = resolveSettings({ ...(call.config !== undefined ? { config: call.config } : {}), profile: choice.profile }, callerIo);
1102
+ chosen = resolveSettings({ ...(call.config !== undefined ? { config: call.config } : {}), ...(call.setup !== undefined ? { setup: call.setup } : {}), profile: choice.profile }, callerIo);
1103
1103
  } catch (error) {
1104
1104
  if (!(error instanceof ConfigError)) throw error;
1105
1105
  io.stderr.write(`pi-space: ${error.message}\n`);
package/dist/picker.js CHANGED
@@ -71,6 +71,19 @@ export function parseKeys(chunk ) {
71
71
  return keys;
72
72
  }
73
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
+
74
87
  /** The items whose name or group holds the query, case-insensitive, in order. */
75
88
  export function visibleItems(state ) {
76
89
  const query = state.query.toLowerCase();
@@ -112,11 +125,44 @@ export function step(state , key ) {
112
125
 
113
126
  const HINT = "Pick a space: ↑/↓ move, Enter starts Pi, Esc cancels, type to filter";
114
127
 
115
- /** The line cut to `columns` code points, ending with "…" when it was longer, so it never wraps. */
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. */
116
154
  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("")}…`;
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}…`;
120
166
  }
121
167
 
122
168
  /**
@@ -158,6 +204,18 @@ function frame(state , rows ) {
158
204
 
159
205
 
160
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
+
161
219
  /** The chosen item, none when cancelled (Esc, Ctrl-D, the end of input), and whether it was Ctrl-C. */
162
220
 
163
221
 
@@ -170,10 +228,12 @@ const ESCAPE_WAIT_MS = 50;
170
228
  /**
171
229
  * Runs the picker on a terminal: raw mode while it runs, each frame drawn over the last, and everything put
172
230
  * 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.
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.
175
235
  */
176
- export function runPicker(items , terminal , escapeWaitMs = ESCAPE_WAIT_MS) {
236
+ export function runPicker(items , terminal , escapeWaitMs = ESCAPE_WAIT_MS, host = process) {
177
237
  const { stdin, stdout } = terminal;
178
238
  let state = initialState(items);
179
239
  let drawn = 0;
@@ -186,17 +246,26 @@ export function runPicker(items , terminal , escapeW
186
246
  };
187
247
  return new Promise ((resolve, reject) => {
188
248
  let finished = false;
189
- // A bare ESC waiting for the rest of its sequence, and the timer that turns it into the Esc key.
249
+ // An incomplete escape sequence waiting for the rest of it, and the timer that ends the wait.
190
250
  let pending = "";
191
251
  let held ;
192
252
  const finish = (outcome ) => {
193
253
  if (finished) return;
194
254
  finished = true;
195
255
  clearTimeout(held);
196
- process.off("exit", onExit);
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]);
197
264
  stdin.off("data", onData);
198
265
  stdin.off("end", onEnd);
199
- // Restoring comes first and must not stop halfway: a write that fails here must not keep the terminal raw.
266
+ };
267
+ // Restoring must not stop halfway: a write that fails here must not keep the terminal raw.
268
+ const restore = () => {
200
269
  try {
201
270
  stdin.setRawMode?.(false);
202
271
  stdin.pause();
@@ -207,9 +276,21 @@ export function runPicker(items , terminal , escapeW
207
276
  try {
208
277
  stdout.write("\x1b[?25h");
209
278
  } catch {}
210
- if ("error" in outcome) reject(outcome.error);
211
- else resolve(outcome.result);
212
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
+ ) ;
213
294
  // The process is going away without finish (exit, a throw elsewhere): leave the terminal usable.
214
295
  const onExit = () => {
215
296
  try {
@@ -242,20 +323,23 @@ export function runPicker(items , terminal , escapeW
242
323
  clearTimeout(held);
243
324
  const text = pending + data.toString();
244
325
  pending = "";
245
- if (!text.endsWith("\x1b")) {
326
+ const [complete, tail] = splitIncomplete(text);
327
+ if (tail === "") {
246
328
  handle(text);
247
329
  return;
248
330
  }
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";
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;
252
334
  held = setTimeout(() => guarded(() => {
253
335
  pending = "";
254
- handle("\x1b");
336
+ // A lone ESC is the Esc key; any other half sequence is dropped.
337
+ if (tail === "\x1b") handle("\x1b");
255
338
  }), escapeWaitMs);
256
339
  });
257
340
  const onEnd = () => finish({ result: { item: undefined, interrupted: false } });
258
- process.on("exit", onExit);
341
+ host.on("exit", onExit);
342
+ for (const signal of SIGNALS) host.once(signal, onSignal[signal]);
259
343
  guarded(() => {
260
344
  stdin.setRawMode?.(true);
261
345
  stdout.write("\x1b[?25l");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jals1212/pi-spaces-cli",
3
- "version": "0.7.0",
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",