@codebend3r/che 1.0.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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +424 -0
  3. package/bin/che.py +869 -0
  4. package/bin/commands.py +872 -0
  5. package/bin/drives/eject-all-drives.py +203 -0
  6. package/bin/drives/mount-all-drives.py +140 -0
  7. package/bin/drives/ping-nas.py +242 -0
  8. package/bin/files/compress-folders.py +188 -0
  9. package/bin/files/delete-by-ext.py +108 -0
  10. package/bin/files/delete-empty-folders.py +136 -0
  11. package/bin/files/delete-smb-files.py +105 -0
  12. package/bin/files/files-under-size.py +132 -0
  13. package/bin/files/find-largest-files.py +102 -0
  14. package/bin/files/make-alpha-dir.py +63 -0
  15. package/bin/git/all-actions.py +545 -0
  16. package/bin/git/checkout-my-branches.py +147 -0
  17. package/bin/git/clean-stale-branches.py +161 -0
  18. package/bin/git/prune-worktrees.py +143 -0
  19. package/bin/git/sync-all-branches.py +368 -0
  20. package/bin/git/update-local-branches.py +195 -0
  21. package/bin/install.py +1515 -0
  22. package/bin/shellgen.py +782 -0
  23. package/bin/system/btop-launch.py +114 -0
  24. package/bin/system/update-brew.py +146 -0
  25. package/bin/tui.py +648 -0
  26. package/bin/utils.py +836 -0
  27. package/bin/video/delete-duplicate-videos.py +313 -0
  28. package/bin/video/detect-green-magenta-videos.py +268 -0
  29. package/bin/video/find-movie-by-year.py +84 -0
  30. package/bin/video/find-video-mkv-issues.py +242 -0
  31. package/bin/video/fix-codecs.py +209 -0
  32. package/bin/video/largest-tv-shows.py +175 -0
  33. package/bin/video/remove-metadata.py +126 -0
  34. package/bin/video/rename-video-file.py +329 -0
  35. package/bin/video/scan-videos-audio-language.py +110 -0
  36. package/bin/video/show-codecs.py +111 -0
  37. package/bin/video/validate-video-files.py +114 -0
  38. package/bin/video/video-list.py +120 -0
  39. package/package.json +54 -0
  40. package/shell/che.bash +382 -0
  41. package/shell/che.fish +282 -0
  42. package/shell/che.zsh +389 -0
  43. package/shell/completions/_che +146 -0
  44. package/shell/completions/che.bash +107 -0
  45. package/shell/completions/che.fish +371 -0
package/bin/tui.py ADDED
@@ -0,0 +1,648 @@
1
+ """Terminal primitives for the interactive parts of ``che``.
2
+
3
+ Standard library only, like everything else here: no curses windows, no
4
+ third-party TUI toolkit. What that buys is a menu that runs from a bare
5
+ ``python3`` on a freshly cloned machine, which is the whole point of this repo.
6
+
7
+ What this module provides:
8
+
9
+ * :class:`Term` - alternate screen, hidden cursor and raw input, restored on
10
+ every exit path including SIGINT, SIGTERM and an uncaught exception.
11
+ * :func:`read_key` - one keypress, decoded into a name (``"up"``, ``"enter"``,
12
+ ``"ctrl-c"``) rather than a raw escape sequence.
13
+ * :class:`Screen` - a frame buffer. Frames are composed in memory and written
14
+ with a single ``write()``, which is what keeps the menu from flickering.
15
+ * :func:`display_width` and :func:`fit` - width-aware truncation and padding,
16
+ so a row with an emoji in it still lines up.
17
+ * :func:`ask`, :func:`confirm`, :func:`choose` - line-based prompts with
18
+ readline editing, filename completion and per-prompt history.
19
+
20
+ Library module: no shebang, not executable.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import os
26
+ import re
27
+ import select
28
+ import signal
29
+ import sys
30
+ import termios
31
+ import tty
32
+ import unicodedata
33
+ from collections.abc import Iterable, Sequence
34
+ from contextlib import suppress
35
+ from dataclasses import dataclass
36
+ from pathlib import Path
37
+ from types import TracebackType
38
+
39
+ from utils import color_enabled
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Colors
43
+ # ---------------------------------------------------------------------------
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class Palette:
48
+ """Every escape the UI uses, in one place so NO_COLOR can blank them all."""
49
+
50
+ reset: str = "\033[0m"
51
+ bold: str = "\033[1m"
52
+ dim: str = "\033[2m"
53
+ italic: str = "\033[3m"
54
+ underline: str = "\033[4m"
55
+ reverse: str = "\033[7m"
56
+
57
+ red: str = "\033[31m"
58
+ green: str = "\033[32m"
59
+ yellow: str = "\033[33m"
60
+ blue: str = "\033[34m"
61
+ magenta: str = "\033[35m"
62
+ cyan: str = "\033[36m"
63
+ white: str = "\033[37m"
64
+ grey: str = "\033[90m"
65
+
66
+ bright_magenta: str = "\033[95m"
67
+ bright_cyan: str = "\033[96m"
68
+ bright_green: str = "\033[92m"
69
+ bright_yellow: str = "\033[93m"
70
+ bright_red: str = "\033[91m"
71
+
72
+ on_magenta: str = "\033[45m"
73
+ on_grey: str = "\033[100m"
74
+
75
+
76
+ PLAIN = Palette(**{field: "" for field in Palette.__dataclass_fields__})
77
+
78
+
79
+ def palette() -> Palette:
80
+ """Colors when the terminal wants them, empty strings when it does not."""
81
+ return Palette() if color_enabled() else PLAIN
82
+
83
+
84
+ _ANSI = re.compile(r"\033\[[0-9;]*[A-Za-z]|\033\][^\a]*(?:\a|\033\\)")
85
+
86
+
87
+ def strip_ansi(text: str) -> str:
88
+ return _ANSI.sub("", text)
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Width
93
+ # ---------------------------------------------------------------------------
94
+
95
+ _VARIATION_SELECTOR_16 = "️"
96
+
97
+
98
+ def display_width(text: str) -> int:
99
+ """Columns ``text`` occupies, ignoring escape sequences.
100
+
101
+ ``len()`` is wrong for the two things that show up in this UI: colour
102
+ escapes (zero width) and emoji (two columns). Getting it wrong misaligns
103
+ every row after it, so the menu measures with this instead.
104
+ """
105
+ chars = list(strip_ansi(text))
106
+ width = 0
107
+
108
+ for index, char in enumerate(chars):
109
+ if char == _VARIATION_SELECTOR_16 or unicodedata.combining(char):
110
+ # U+FE0F only asks for the emoji presentation of the character
111
+ # before it; it takes no columns of its own. Counting it as one
112
+ # was worth two columns of drift per row on 🗑️ and friends.
113
+ continue
114
+
115
+ if unicodedata.east_asian_width(char) in ("W", "F"):
116
+ width += 2
117
+ elif ord(char) >= 0x1F300:
118
+ # Emoji that Unicode still classifies as neutral (🗑, 🎞) but every
119
+ # terminal draws double width.
120
+ width += 2
121
+ elif index + 1 < len(chars) and chars[index + 1] == _VARIATION_SELECTOR_16:
122
+ # A narrow symbol promoted to emoji presentation (⌨️, ⬆️).
123
+ width += 2
124
+ else:
125
+ width += 1
126
+
127
+ return width
128
+
129
+
130
+ def truncate(text: str, width: int, *, ellipsis: str = "…") -> str:
131
+ """Cut ``text`` to ``width`` columns, appending an ellipsis when it had to."""
132
+ if width <= 0:
133
+ return ""
134
+ if display_width(text) <= width:
135
+ return text
136
+
137
+ budget = width - display_width(ellipsis)
138
+ out: list[str] = []
139
+ used = 0
140
+ for char in strip_ansi(text):
141
+ step = display_width(char)
142
+ if used + step > budget:
143
+ break
144
+ out.append(char)
145
+ used += step
146
+ return "".join(out) + ellipsis
147
+
148
+
149
+ def fit(text: str, width: int) -> str:
150
+ """Truncate to ``width`` columns, then pad with spaces to exactly that."""
151
+ cut = truncate(text, width)
152
+ return cut + " " * max(0, width - display_width(cut))
153
+
154
+
155
+ def hyperlink(label: str, url: str) -> str:
156
+ """An OSC 8 terminal hyperlink, or the bare label when colour is off."""
157
+ if not color_enabled():
158
+ return label
159
+ return f"\033]8;;{url}\033\\{label}\033]8;;\033\\"
160
+
161
+
162
+ # ---------------------------------------------------------------------------
163
+ # Terminal control
164
+ # ---------------------------------------------------------------------------
165
+
166
+ ALT_SCREEN_ON = "\033[?1049h"
167
+ ALT_SCREEN_OFF = "\033[?1049l"
168
+ CURSOR_HIDE = "\033[?25l"
169
+ CURSOR_SHOW = "\033[?25h"
170
+ CLEAR = "\033[H\033[2J"
171
+
172
+
173
+ def size() -> tuple[int, int]:
174
+ """Terminal size as ``(columns, rows)``, with sane fallbacks."""
175
+ try:
176
+ columns, rows = os.get_terminal_size(sys.__stdout__.fileno())
177
+ except (OSError, ValueError, AttributeError):
178
+ columns, rows = 80, 24
179
+ return max(40, columns), max(10, rows)
180
+
181
+
182
+ def is_interactive() -> bool:
183
+ """Whether a full-screen UI is possible at all."""
184
+ try:
185
+ return sys.stdin.isatty() and sys.stdout.isatty()
186
+ except (ValueError, AttributeError):
187
+ return False
188
+
189
+
190
+ class Term:
191
+ """Alternate screen + raw input, restored no matter how the block exits.
192
+
193
+ A TUI that leaves the terminal in raw mode with the cursor hidden is worse
194
+ than no TUI at all, so teardown runs from ``__exit__`` *and* from handlers
195
+ for SIGINT/SIGTERM/SIGHUP, and is written to be safe to call twice.
196
+ """
197
+
198
+ def __init__(self, *, alt_screen: bool = True) -> None:
199
+ self.alt_screen = alt_screen
200
+ self.fd = -1
201
+ self._saved: list | None = None
202
+ self._active = False
203
+ self._previous_handlers: dict[int, object] = {}
204
+ self.resized = False
205
+
206
+ # -- lifecycle ------------------------------------------------------
207
+ def __enter__(self) -> Term:
208
+ self.fd = sys.stdin.fileno()
209
+ with suppress(termios.error, ValueError):
210
+ self._saved = termios.tcgetattr(self.fd)
211
+ tty.setraw(self.fd)
212
+
213
+ if self.alt_screen:
214
+ self._write(ALT_SCREEN_ON + CURSOR_HIDE)
215
+
216
+ self._active = True
217
+ self._install_handlers()
218
+ return self
219
+
220
+ def __exit__(
221
+ self,
222
+ exc_type: type[BaseException] | None,
223
+ exc: BaseException | None,
224
+ traceback: TracebackType | None,
225
+ ) -> None:
226
+ self.close()
227
+
228
+ def close(self) -> None:
229
+ if not self._active:
230
+ return
231
+ self._active = False
232
+
233
+ if self.alt_screen:
234
+ self._write(CURSOR_SHOW + ALT_SCREEN_OFF)
235
+ if self._saved is not None:
236
+ with suppress(termios.error, ValueError):
237
+ termios.tcsetattr(self.fd, termios.TCSADRAIN, self._saved)
238
+
239
+ self._restore_handlers()
240
+
241
+ # -- suspending, for running a child command in the normal screen ----
242
+ def suspend(self) -> None:
243
+ """Drop back to the normal screen and cooked input."""
244
+ if self.alt_screen:
245
+ self._write(CURSOR_SHOW + ALT_SCREEN_OFF)
246
+ if self._saved is not None:
247
+ with suppress(termios.error, ValueError):
248
+ termios.tcsetattr(self.fd, termios.TCSADRAIN, self._saved)
249
+
250
+ def resume(self) -> None:
251
+ """Return to the full-screen UI after :meth:`suspend`."""
252
+ with suppress(termios.error, ValueError):
253
+ tty.setraw(self.fd)
254
+ if self.alt_screen:
255
+ self._write(ALT_SCREEN_ON + CURSOR_HIDE)
256
+
257
+ # -- signals --------------------------------------------------------
258
+ def _install_handlers(self) -> None:
259
+ def on_resize(_signum: int, _frame: object) -> None:
260
+ self.resized = True
261
+
262
+ def on_kill(signum: int, _frame: object) -> None:
263
+ self.close()
264
+ # Re-raise with the default disposition so the exit status is the
265
+ # 128+N a shell expects, rather than a plain 0.
266
+ signal.signal(signum, signal.SIG_DFL)
267
+ os.kill(os.getpid(), signum)
268
+
269
+ for signum, handler in ((signal.SIGWINCH, on_resize),):
270
+ with suppress(ValueError, OSError):
271
+ self._previous_handlers[signum] = signal.signal(signum, handler)
272
+
273
+ for signum in (signal.SIGTERM, signal.SIGHUP):
274
+ with suppress(ValueError, OSError):
275
+ self._previous_handlers[signum] = signal.signal(signum, on_kill)
276
+
277
+ def _restore_handlers(self) -> None:
278
+ for signum, handler in self._previous_handlers.items():
279
+ with suppress(ValueError, OSError, TypeError):
280
+ signal.signal(signum, handler) # type: ignore[arg-type]
281
+ self._previous_handlers.clear()
282
+
283
+ # -- output ---------------------------------------------------------
284
+ @staticmethod
285
+ def _write(text: str) -> None:
286
+ with suppress(OSError, ValueError):
287
+ sys.stdout.write(text)
288
+ sys.stdout.flush()
289
+
290
+
291
+ # ---------------------------------------------------------------------------
292
+ # Input
293
+ # ---------------------------------------------------------------------------
294
+
295
+ _CSI_KEYS = {
296
+ "A": "up",
297
+ "B": "down",
298
+ "C": "right",
299
+ "D": "left",
300
+ "H": "home",
301
+ "F": "end",
302
+ "Z": "shift-tab",
303
+ }
304
+ _CSI_TILDE_KEYS = {
305
+ "1": "home",
306
+ "3": "delete",
307
+ "4": "end",
308
+ "5": "pgup",
309
+ "6": "pgdn",
310
+ "7": "home",
311
+ "8": "end",
312
+ }
313
+
314
+
315
+ def read_key(timeout: float | None = None) -> str | None:
316
+ """Read one keypress in raw mode and return a name for it.
317
+
318
+ Returns ``None`` on timeout (used so a SIGWINCH can redraw a frame that is
319
+ otherwise blocked in ``read``). Unknown escape sequences are swallowed
320
+ rather than leaking their bytes into a filter box.
321
+ """
322
+ fd = sys.stdin.fileno()
323
+
324
+ if timeout is not None:
325
+ ready, _, _ = select.select([fd], [], [], timeout)
326
+ if not ready:
327
+ return None
328
+
329
+ try:
330
+ first = os.read(fd, 1).decode("utf-8", "replace")
331
+ except (OSError, InterruptedError):
332
+ return None
333
+ if not first:
334
+ return "eof"
335
+
336
+ if first == "\x03":
337
+ return "ctrl-c"
338
+ if first == "\x04":
339
+ return "eof"
340
+ if first in ("\r", "\n"):
341
+ return "enter"
342
+ if first == "\t":
343
+ return "tab"
344
+ if first in ("\x7f", "\b"):
345
+ return "backspace"
346
+ if first == "\x15":
347
+ return "ctrl-u"
348
+ if first == "\x17":
349
+ return "ctrl-w"
350
+ if first != "\033":
351
+ # A multi-byte UTF-8 character arrives one byte at a time.
352
+ if ord(first[0]) >= 0x80:
353
+ extra = b""
354
+ for _ in range(3):
355
+ ready, _, _ = select.select([fd], [], [], 0.01)
356
+ if not ready:
357
+ break
358
+ extra += os.read(fd, 1)
359
+ return (first.encode("utf-8", "surrogateescape") + extra).decode("utf-8", "replace")
360
+ return first
361
+
362
+ # Escape, possibly introducing a sequence. A lone Escape has nothing
363
+ # following it, which is what the short select() below distinguishes.
364
+ ready, _, _ = select.select([fd], [], [], 0.05)
365
+ if not ready:
366
+ return "esc"
367
+
368
+ second = os.read(fd, 1).decode("utf-8", "replace")
369
+ if second not in ("[", "O"):
370
+ return "esc"
371
+
372
+ params = ""
373
+ while True:
374
+ ready, _, _ = select.select([fd], [], [], 0.05)
375
+ if not ready:
376
+ return "esc"
377
+ char = os.read(fd, 1).decode("utf-8", "replace")
378
+ if char.isdigit() or char == ";":
379
+ params += char
380
+ continue
381
+ if char == "~":
382
+ return _CSI_TILDE_KEYS.get(params, "unknown")
383
+ return _CSI_KEYS.get(char, "unknown")
384
+
385
+
386
+ # ---------------------------------------------------------------------------
387
+ # Frame buffer
388
+ # ---------------------------------------------------------------------------
389
+
390
+
391
+ class Screen:
392
+ """Compose a frame in memory, then paint it in one write."""
393
+
394
+ def __init__(self) -> None:
395
+ self.columns, self.rows = size()
396
+ self._lines: list[str] = []
397
+
398
+ def start(self) -> None:
399
+ self.columns, self.rows = size()
400
+ self._lines = []
401
+
402
+ def line(self, text: str = "") -> None:
403
+ self._lines.append(truncate(text, self.columns))
404
+
405
+ def lines(self, texts: Iterable[str]) -> None:
406
+ for text in texts:
407
+ self.line(text)
408
+
409
+ @property
410
+ def used(self) -> int:
411
+ return len(self._lines)
412
+
413
+ def pad_to(self, row: int) -> None:
414
+ while len(self._lines) < row:
415
+ self._lines.append("")
416
+
417
+ def flush(self) -> None:
418
+ # \033[K after each line clears whatever the previous frame left there,
419
+ # which is cheaper and steadier than clearing the whole screen first.
420
+ body = "\033[K\r\n".join(self._lines[: self.rows])
421
+ with suppress(OSError, ValueError):
422
+ sys.stdout.write("\033[H" + body + "\033[K\033[J")
423
+ sys.stdout.flush()
424
+
425
+
426
+ # ---------------------------------------------------------------------------
427
+ # Line prompts (used outside the alternate screen)
428
+ # ---------------------------------------------------------------------------
429
+
430
+
431
+ def _setup_readline(history_file: Path | None = None) -> object | None:
432
+ """Enable editing, filename completion and history for ``input()``.
433
+
434
+ macOS ships readline as libedit, which needs a different bind syntax; both
435
+ are handled. A missing readline module is not fatal - the prompt just loses
436
+ arrow-key editing.
437
+ """
438
+ try:
439
+ import readline
440
+ except ImportError:
441
+ return None
442
+
443
+ if "libedit" in (getattr(readline, "__doc__", "") or ""):
444
+ readline.parse_and_bind("bind ^I rl_complete")
445
+ else:
446
+ readline.parse_and_bind("tab: complete")
447
+
448
+ readline.set_completer_delims(" \t\n=")
449
+
450
+ if history_file is not None:
451
+ with suppress(OSError):
452
+ history_file.parent.mkdir(parents=True, exist_ok=True)
453
+ if history_file.exists():
454
+ readline.read_history_file(history_file)
455
+ readline.set_history_length(200)
456
+
457
+ return readline
458
+
459
+
460
+ def ask(
461
+ label: str,
462
+ *,
463
+ default: str = "",
464
+ kind: str = "text",
465
+ choices: Sequence[str] = (),
466
+ history_file: Path | None = None,
467
+ ) -> str:
468
+ """Ask one question and return the answer, or the default on empty input.
469
+
470
+ ``kind="path"`` turns on filename completion; ``kind="choice"`` shows the
471
+ valid values and re-asks until one of them is given.
472
+ """
473
+ colors = palette()
474
+ readline = _setup_readline(history_file)
475
+
476
+ if readline is not None:
477
+ if kind == "path":
478
+ readline.set_completer(_path_completer)
479
+ elif choices:
480
+ readline.set_completer(_choice_completer(choices))
481
+ else:
482
+ readline.set_completer(lambda *_: None)
483
+
484
+ suffix = ""
485
+ if choices:
486
+ suffix = f" {colors.grey}[{'/'.join(choices)}]{colors.reset}"
487
+ elif default:
488
+ suffix = f" {colors.grey}[{default}]{colors.reset}"
489
+
490
+ prompt = f"{colors.cyan}?{colors.reset} {colors.bold}{label}{colors.reset}{suffix}: "
491
+
492
+ while True:
493
+ try:
494
+ answer = input(prompt).strip()
495
+ except EOFError:
496
+ print()
497
+ return default
498
+
499
+ if not answer:
500
+ answer = default
501
+ if kind == "path" and answer:
502
+ answer = str(Path(os.path.expandvars(answer)).expanduser())
503
+ if choices and answer not in choices:
504
+ print(f"{colors.yellow} Pick one of: {', '.join(choices)}{colors.reset}")
505
+ continue
506
+
507
+ if readline is not None and history_file is not None and answer:
508
+ with suppress(OSError):
509
+ readline.write_history_file(history_file)
510
+ return answer
511
+
512
+
513
+ def confirm(question: str, *, default: bool = True) -> bool:
514
+ """A yes/no prompt. Anything unparseable re-asks rather than guessing."""
515
+ colors = palette()
516
+ hint = "Y/n" if default else "y/N"
517
+ while True:
518
+ try:
519
+ answer = (
520
+ input(
521
+ f"{colors.cyan}?{colors.reset} {colors.bold}{question}{colors.reset} "
522
+ f"{colors.grey}[{hint}]{colors.reset} "
523
+ )
524
+ .strip()
525
+ .lower()
526
+ )
527
+ except EOFError:
528
+ print()
529
+ return default
530
+ if not answer:
531
+ return default
532
+ if answer in ("y", "yes"):
533
+ return True
534
+ if answer in ("n", "no"):
535
+ return False
536
+
537
+
538
+ def choose(
539
+ title: str,
540
+ options: Sequence[tuple[str, str]],
541
+ *,
542
+ selected: Iterable[int] = (),
543
+ multi: bool = True,
544
+ ) -> list[int]:
545
+ """A checkbox / radio list, driven with the arrow keys.
546
+
547
+ Returns the indices the user accepted. Falls back to a numbered text
548
+ prompt when there is no tty, so the wizard still works over a pipe.
549
+ """
550
+ chosen = set(selected)
551
+
552
+ if not is_interactive():
553
+ return sorted(chosen)
554
+
555
+ colors = palette()
556
+ cursor = min(chosen, default=0)
557
+
558
+ with Term() as term:
559
+ screen = Screen()
560
+ while True:
561
+ screen.start()
562
+ screen.line()
563
+ screen.line(f" {colors.bold}{title}{colors.reset}")
564
+ screen.line()
565
+ for index, (label, hint) in enumerate(options):
566
+ mark = "◉" if index in chosen else "○"
567
+ if not multi:
568
+ mark = "●" if index in chosen else "○"
569
+ pointer = f"{colors.cyan}▸{colors.reset}" if index == cursor else " "
570
+ body = f"{mark} {label}"
571
+ if index == cursor:
572
+ body = f"{colors.bold}{body}{colors.reset}"
573
+ detail = f" {colors.grey}{hint}{colors.reset}" if hint else ""
574
+ screen.line(f" {pointer} {body}{detail}")
575
+ screen.line()
576
+ screen.line(
577
+ f" {colors.grey}↑↓ move space toggle enter accept q cancel{colors.reset}"
578
+ )
579
+ screen.flush()
580
+
581
+ key = read_key(timeout=0.5)
582
+ if key is None:
583
+ continue
584
+ if key in ("up", "k"):
585
+ cursor = (cursor - 1) % len(options)
586
+ elif key in ("down", "j"):
587
+ cursor = (cursor + 1) % len(options)
588
+ elif key == " ":
589
+ if multi:
590
+ chosen.symmetric_difference_update({cursor})
591
+ else:
592
+ chosen = {cursor}
593
+ elif key == "a" and multi:
594
+ chosen = set(range(len(options))) if len(chosen) < len(options) else set()
595
+ elif key == "enter":
596
+ if not chosen and not multi:
597
+ chosen = {cursor}
598
+ term.close()
599
+ return sorted(chosen)
600
+ elif key in ("q", "esc", "ctrl-c"):
601
+ term.close()
602
+ return []
603
+
604
+
605
+ def _path_completer(text: str, state: int) -> str | None:
606
+ """readline completer offering filesystem paths, with a / on directories."""
607
+ expanded = Path(os.path.expandvars(text)).expanduser()
608
+ typed_directory = (
609
+ str(expanded.parent) if str(expanded.parent) != "." or text.startswith(".") else ""
610
+ )
611
+ directory = expanded.parent if str(expanded.parent) else Path()
612
+ stem = expanded.name if not text.endswith("/") else ""
613
+ if text.endswith("/"):
614
+ directory = expanded
615
+
616
+ try:
617
+ entries = sorted(entry.name for entry in directory.iterdir())
618
+ except OSError:
619
+ return None
620
+
621
+ matches = []
622
+ for entry in entries:
623
+ if not entry.startswith(stem):
624
+ continue
625
+ full = str(directory / entry) if typed_directory or text.endswith("/") else entry
626
+ matches.append(f"{full}/" if (directory / entry).is_dir() else full)
627
+
628
+ return matches[state] if state < len(matches) else None
629
+
630
+
631
+ def _choice_completer(choices: Sequence[str]):
632
+ def complete(text: str, state: int) -> str | None:
633
+ matches = [choice for choice in choices if choice.startswith(text)]
634
+ return matches[state] if state < len(matches) else None
635
+
636
+ return complete
637
+
638
+
639
+ def pause(message: str = "Press any key to continue") -> None:
640
+ """Wait for a keypress, in raw mode so any key works (not just Enter)."""
641
+ colors = palette()
642
+ sys.stdout.write(f"\n{colors.grey}{message}…{colors.reset}")
643
+ sys.stdout.flush()
644
+ if is_interactive():
645
+ with Term(alt_screen=False):
646
+ read_key()
647
+ sys.stdout.write("\n")
648
+ sys.stdout.flush()