bookmark-research 0.5.0-beta.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.
Files changed (106) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/.codex-plugin/plugin.json +46 -0
  3. package/LICENSE +674 -0
  4. package/README.md +128 -0
  5. package/README.zh.md +126 -0
  6. package/bin/bookmark-research.js +30 -0
  7. package/config/providers.json +98 -0
  8. package/docs/bookmark-research-architecture.md +124 -0
  9. package/docs/cli-launcher-plan.md +58 -0
  10. package/docs/details.en.md +272 -0
  11. package/docs/details.md +93 -0
  12. package/docs/harness-compatibility.md +304 -0
  13. package/docs/harness-sources.json +342 -0
  14. package/docs/host-validation.json +298 -0
  15. package/docs/install-design-0.2.0.md +57 -0
  16. package/docs/installation.en.md +197 -0
  17. package/docs/installation.md +316 -0
  18. package/docs/instructions.en.md +42 -0
  19. package/docs/instructions.md +42 -0
  20. package/docs/mcp-aggregation-0.2.0.md +92 -0
  21. package/docs/multi-host-installation-plan.md +163 -0
  22. package/docs/prompt-reference.en.md +104 -0
  23. package/docs/prompt-reference.md +104 -0
  24. package/docs/provider-smoke-0.2.0.json +235 -0
  25. package/docs/release-notes-0.5.0-beta.1.md +33 -0
  26. package/docs/research-0.2.0.md +140 -0
  27. package/docs/research-sources-0.2.0.json +9360 -0
  28. package/docs/user-guide.en.md +74 -0
  29. package/docs/user-guide.md +74 -0
  30. package/docs/validation-0.2.0.md +126 -0
  31. package/docs/validation-0.4.0.md +154 -0
  32. package/docs/wiki-quality.md +105 -0
  33. package/docs/wiki-quality.zh.md +105 -0
  34. package/hosts/claude/runtime.js +13 -0
  35. package/hosts/codex/delegate.md +14 -0
  36. package/hosts/codex/prepare.py +61 -0
  37. package/hosts/dsh/plugin.js +36 -0
  38. package/hosts/dsh/runtime.js +14 -0
  39. package/hosts/dsh/workflow-call.py +45 -0
  40. package/hosts/pi/register-workflow.py +72 -0
  41. package/hosts/pi/runtime.js +18 -0
  42. package/hosts/shared/research-call.py +76 -0
  43. package/hosts/shared/research-flow.js +208 -0
  44. package/hosts/shared/workflow.json +15 -0
  45. package/install.sh +310 -0
  46. package/package.json +12 -0
  47. package/scripts/build_zip.py +143 -0
  48. package/scripts/export_bundle.py +496 -0
  49. package/scripts/host_assets.py +70 -0
  50. package/scripts/host_clients.py +196 -0
  51. package/scripts/host_install.py +277 -0
  52. package/scripts/install.py +593 -0
  53. package/scripts/launcher.py +186 -0
  54. package/scripts/verify_fixture.py +220 -0
  55. package/scripts/verify_live.py +62 -0
  56. package/scripts/verify_quality.py +87 -0
  57. package/skills/bookmark-research/SKILL.md +80 -0
  58. package/skills/bookmark-research/agents/openai.yaml +3 -0
  59. package/skills/bookmark-research/references/cli.md +150 -0
  60. package/skills/bookmark-research/references/deep-research.md +164 -0
  61. package/skills/bookmark-research/references/github-and-sync.md +30 -0
  62. package/skills/bookmark-research/references/host-workflows.md +101 -0
  63. package/skills/bookmark-research/references/package-semantics.md +78 -0
  64. package/skills/bookmark-research/references/research-methods.md +51 -0
  65. package/skills/bookmark-research/references/research-services.md +72 -0
  66. package/skills/bookmark-research/references/research-workflow.md +57 -0
  67. package/skills/bookmark-research/references/settings-and-archive.md +104 -0
  68. package/skills/bookmark-research/references/source-lifecycle.md +75 -0
  69. package/skills/bookmark-research/references/wiki-and-evaluation.md +63 -0
  70. package/skills/bookmark-research/references/zh/cli.md +150 -0
  71. package/skills/bookmark-research/references/zh/deep-research.md +164 -0
  72. package/skills/bookmark-research/references/zh/github-and-sync.md +30 -0
  73. package/skills/bookmark-research/references/zh/host-workflows.md +101 -0
  74. package/skills/bookmark-research/references/zh/package-semantics.md +78 -0
  75. package/skills/bookmark-research/references/zh/research-methods.md +51 -0
  76. package/skills/bookmark-research/references/zh/research-services.md +70 -0
  77. package/skills/bookmark-research/references/zh/research-workflow.md +57 -0
  78. package/skills/bookmark-research/references/zh/settings-and-archive.md +104 -0
  79. package/skills/bookmark-research/references/zh/skill-guide.md +75 -0
  80. package/skills/bookmark-research/references/zh/source-lifecycle.md +75 -0
  81. package/skills/bookmark-research/references/zh/wiki-and-evaluation.md +63 -0
  82. package/src/archive.py +302 -0
  83. package/src/bookmark_index.py +913 -0
  84. package/src/cli.py +435 -0
  85. package/src/credentials.py +82 -0
  86. package/src/evaluation.py +337 -0
  87. package/src/jina.py +96 -0
  88. package/src/mcp_server.py +641 -0
  89. package/src/onboarding.py +352 -0
  90. package/src/provider_adapters.py +152 -0
  91. package/src/readiness.py +283 -0
  92. package/src/remote_mcp.py +295 -0
  93. package/src/research.py +1334 -0
  94. package/src/research_coverage.py +160 -0
  95. package/src/research_services.py +684 -0
  96. package/src/routing.py +202 -0
  97. package/src/search_results.py +244 -0
  98. package/src/service_http.py +139 -0
  99. package/src/settings.py +247 -0
  100. package/src/source_inputs.py +201 -0
  101. package/src/source_manager.py +348 -0
  102. package/src/source_watcher.py +68 -0
  103. package/src/textio.py +9 -0
  104. package/src/tui.py +586 -0
  105. package/src/web_search.py +603 -0
  106. package/src/wiki.py +383 -0
package/src/tui.py ADDED
@@ -0,0 +1,586 @@
1
+ """Terminal prompts with a clack-style rich mode and a plain numbered fallback."""
2
+
3
+ import contextlib
4
+ import getpass
5
+ import os
6
+ import re
7
+ import select as _select
8
+ import shutil
9
+ import unicodedata
10
+ import threading
11
+ import time as _time
12
+ import warnings
13
+ ANSI = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]")
14
+
15
+ try:
16
+ import termios
17
+ except ImportError: # pragma: no cover - Windows
18
+ termios = None
19
+ try:
20
+ import msvcrt
21
+ except ImportError:
22
+ msvcrt = None
23
+
24
+ # Windows console scan codes after a \x00/\xe0 prefix.
25
+ WINDOWS_KEYS = {"H": "up", "P": "down", "K": "left", "M": "right"}
26
+
27
+
28
+ def enable_windows_ansi():
29
+ """Turn on VT escape processing for the Windows console; False if unavailable."""
30
+ if os.name != "nt":
31
+ return True
32
+ try:
33
+ import ctypes
34
+ kernel = ctypes.windll.kernel32
35
+ handle = kernel.GetStdHandle(-11)
36
+ mode = ctypes.c_uint32()
37
+ return bool(kernel.GetConsoleMode(handle, ctypes.byref(mode))
38
+ and kernel.SetConsoleMode(handle, mode.value | 0x0004))
39
+ except (AttributeError, OSError):
40
+ return False
41
+
42
+ BAR = "│"
43
+ ACTIVE = "◆"
44
+ DONE = "◇"
45
+ ON = "◉"
46
+ OFF = "◯"
47
+ HIDE = "\x1b[?25l"
48
+ SHOW = "\x1b[?25h"
49
+
50
+
51
+ class Option:
52
+ def __init__(self, value, label_en, label_zh=None, hint_en="", hint_zh=None, disabled=False, hint_always=False):
53
+ self.value = value
54
+ self.label_en = label_en
55
+ self.label_zh = label_zh
56
+ self.hint_en = hint_en
57
+ self.hint_zh = hint_zh
58
+ self.hint_always = hint_always
59
+ self.disabled = disabled
60
+
61
+ def __repr__(self):
62
+ return "Option(%r)" % (self.value,)
63
+
64
+
65
+ class Prompter:
66
+ def __init__(self, reader, writer, language="en", rich=None):
67
+ self.reader = reader
68
+ self.writer = writer
69
+ self.language = language
70
+ if rich is None:
71
+ rich = (
72
+ _isatty(reader)
73
+ and _isatty(writer)
74
+ and os.environ.get("TERM") != "dumb"
75
+ and not os.environ.get("BOOKMARK_RESEARCH_PLAIN")
76
+ and (termios is not None or msvcrt is not None)
77
+ and enable_windows_ansi()
78
+ )
79
+ self.rich = bool(rich)
80
+ self._fd = None
81
+
82
+ # -- text helpers -------------------------------------------------
83
+ def text(self, english, chinese=None):
84
+ return chinese if self.language == "zh" and chinese is not None else english
85
+
86
+ def _label(self, option):
87
+ return self.text(option.label_en, option.label_zh)
88
+
89
+ def _hint(self, option):
90
+ return self.text(option.hint_en or "", option.hint_zh)
91
+
92
+ def _color(self, code, value):
93
+ if not self.rich or os.environ.get("NO_COLOR"):
94
+ return value
95
+ return "\x1b[%sm%s\x1b[0m" % (code, value)
96
+
97
+ def _write(self, value):
98
+ self.writer.write(value)
99
+ self.writer.flush()
100
+
101
+ def _line(self, value=""):
102
+ self._write(value + "\n")
103
+
104
+ @staticmethod
105
+ def _width(value):
106
+ plain = ANSI.sub("", value)
107
+ return sum(2 if unicodedata.east_asian_width(char) in ("W", "F") else 1 for char in plain)
108
+
109
+ def _fit(self, value):
110
+ # Redraw moves up by line count, so a wrapped row would corrupt the frame.
111
+ limit = _columns() - 1
112
+ if self._width(value) <= limit:
113
+ return value
114
+ result, used = "", 0
115
+ for token in re.split("(" + ANSI.pattern + ")", value):
116
+ if ANSI.fullmatch(token):
117
+ result += token
118
+ continue
119
+ for char in token:
120
+ size = self._width(char)
121
+ if used + size > limit - 1:
122
+ return result + "…\x1b[0m" if "\x1b[" in result else result + "…"
123
+ result, used = result + char, used + size
124
+ return result
125
+
126
+ # -- output -------------------------------------------------------
127
+ def say(self, english, chinese=None):
128
+ message = self.text(english, chinese)
129
+ if not self.rich:
130
+ self._line(message)
131
+ return
132
+ for line in message.strip("\n").split("\n"):
133
+ self._line("%s %s" % (self._color("90", BAR), line))
134
+
135
+ def intro(self, english, chinese=None):
136
+ message = self.text(english, chinese)
137
+ if self.rich:
138
+ self._line(self._color("90", "┌") + " " + self._color("1", message))
139
+ self._line(self._color("90", BAR))
140
+ else:
141
+ self._line(message)
142
+
143
+ def outro(self, english, chinese=None):
144
+ message = self.text(english, chinese)
145
+ if self.rich:
146
+ self._line(self._color("90", BAR))
147
+ self._line(self._color("90", "└") + " " + message)
148
+ else:
149
+ self._line(message)
150
+
151
+ def _mark(self, symbol, code, english, chinese):
152
+ message = self.text(english, chinese)
153
+ if self.rich:
154
+ self._line("%s %s" % (self._color(code, symbol), message))
155
+ else:
156
+ self._line("%s %s" % (symbol, message))
157
+
158
+ def step(self, english, chinese=None):
159
+ self._mark(DONE, "32", english, chinese)
160
+
161
+ def success(self, english, chinese=None):
162
+ self._mark("✓", "32", english, chinese)
163
+
164
+ def warn(self, english, chinese=None):
165
+ self._mark("!", "33", english, chinese)
166
+
167
+ def error(self, english, chinese=None):
168
+ self._mark("✗", "31", english, chinese)
169
+
170
+ def note(self, title_en, title_zh, lines):
171
+ title = self.text(title_en, title_zh)
172
+ body = [self.text(en, zh) for en, zh in lines]
173
+ if not self.rich:
174
+ self._line(title)
175
+ for item in body:
176
+ self._line(" " + item)
177
+ return
178
+ width = max([self._width(title)] + [self._width(item) for item in body]) + 2
179
+ self._line(self._color("90", BAR))
180
+ if width + 6 > _columns():
181
+ # Too wide for a box: keep the rail and let long paths wrap naturally.
182
+ self._line("%s %s" % (self._color("32", DONE), title))
183
+ for item in body:
184
+ self._line("%s %s" % (self._color("90", BAR), item))
185
+ return
186
+ self._line("%s %s %s╮" % (self._color("32", DONE), title, self._color("90", "─" * (width - self._width(title)))))
187
+ for item in body:
188
+ self._line("%s %s%s%s" % (self._color("90", BAR), item, " " * (width - self._width(item) + 1), self._color("90", BAR)))
189
+ self._line(self._color("90", "├" + "─" * (width + 3) + "╯"))
190
+
191
+ @contextlib.contextmanager
192
+ def spinner(self, english, chinese=None):
193
+ message = self.text(english, chinese)
194
+ if not self.rich:
195
+ self._line(message)
196
+ yield
197
+ return
198
+ stop = threading.Event()
199
+ animate = _isatty(self.writer)
200
+
201
+ def run():
202
+ frames = "◒◐◓◑"
203
+ index = 0
204
+ while not stop.wait(0.1):
205
+ index += 1
206
+ self._write("\r\x1b[2K%s %s" % (self._color("35", frames[index % 4]), message))
207
+
208
+ self._write("%s %s" % (self._color("35", "◒"), message))
209
+ thread = threading.Thread(target=run, daemon=True) if animate else None
210
+ if thread:
211
+ thread.start()
212
+ ok = False
213
+ try:
214
+ yield
215
+ ok = True
216
+ finally:
217
+ stop.set()
218
+ if thread:
219
+ thread.join()
220
+ symbol = self._color("32", "✓") if ok else self._color("31", "✗")
221
+ self._write("\r\x1b[2K%s %s\n" % (symbol, message))
222
+
223
+ # -- plain input --------------------------------------------------
224
+ def _readline(self, prompt):
225
+ self._write(prompt)
226
+ value = self.reader.readline()
227
+ if value == "":
228
+ raise KeyboardInterrupt
229
+ return value.strip()
230
+
231
+ # -- raw key input ------------------------------------------------
232
+ @contextlib.contextmanager
233
+ def _raw(self, hide=True):
234
+ fd = None
235
+ saved = None
236
+ try:
237
+ if termios is not None and _isatty(self.reader):
238
+ fd = self.reader.fileno()
239
+ saved = termios.tcgetattr(fd)
240
+ # Not tty.setcbreak: its TCSAFLUSH discards keys typed ahead.
241
+ attrs = termios.tcgetattr(fd)
242
+ attrs[3] &= ~(termios.ECHO | termios.ICANON | termios.ISIG)
243
+ attrs[6][termios.VMIN], attrs[6][termios.VTIME] = 1, 0
244
+ termios.tcsetattr(fd, termios.TCSANOW, attrs)
245
+ self._fd = fd
246
+ elif termios is None and msvcrt is not None and _isatty(self.reader):
247
+ self._fd = "msvcrt"
248
+ if hide:
249
+ self._write(HIDE)
250
+ yield
251
+ finally:
252
+ self._fd = None
253
+ if saved is not None:
254
+ termios.tcsetattr(fd, termios.TCSADRAIN, saved)
255
+ if hide:
256
+ self._write(SHOW)
257
+
258
+ def _char(self, timeout=None):
259
+ if self._fd == "msvcrt":
260
+ if timeout is not None:
261
+ deadline = _time.monotonic() + timeout
262
+ while not msvcrt.kbhit():
263
+ if _time.monotonic() >= deadline:
264
+ return None
265
+ _time.sleep(0.01)
266
+ return msvcrt.getwch()
267
+ if self._fd is not None:
268
+ if timeout is not None and not _select.select([self._fd], [], [], timeout)[0]:
269
+ return None
270
+ data = os.read(self._fd, 1)
271
+ return data.decode("latin-1") if data else ""
272
+ return self.reader.read(1)
273
+
274
+ def _readchar(self, timeout=None):
275
+ if self._fd is None or self._fd == "msvcrt":
276
+ return self._char(timeout)
277
+ first = self._char(timeout)
278
+ if not first or ord(first) < 0x80:
279
+ return first
280
+ raw = first.encode("latin-1")
281
+ need = 1 if ord(first) >= 0xC0 else 0
282
+ need = 3 if ord(first) >= 0xF0 else 2 if ord(first) >= 0xE0 else need
283
+ for _ in range(need):
284
+ more = self._char(0.05)
285
+ if not more:
286
+ break
287
+ raw += more.encode("latin-1")
288
+ return raw.decode("utf-8", "replace")
289
+
290
+ def _key(self):
291
+ char = self._readchar()
292
+ if self._fd == "msvcrt" and char in ("\x00", "\xe0"):
293
+ return WINDOWS_KEYS.get(self._readchar(), "other")
294
+ if char == "" or char == "\x03" or char == "\x04":
295
+ raise KeyboardInterrupt
296
+ if char == "\x1b":
297
+ nxt = self._readchar(0.05)
298
+ if nxt not in ("[", "O"):
299
+ raise KeyboardInterrupt
300
+ final = self._readchar(0.05)
301
+ while final and final in "0123456789;":
302
+ final = self._readchar(0.05)
303
+ return {"A": "up", "B": "down", "C": "right", "D": "left"}.get(final or "", "other")
304
+ if char in ("\r", "\n"):
305
+ return "enter"
306
+ if char in ("\x7f", "\x08"):
307
+ return "backspace"
308
+ return char
309
+
310
+ def _redraw(self, lines, previous):
311
+ if previous:
312
+ self._write("\x1b[%dA\x1b[J" % previous)
313
+ for line in lines:
314
+ self._line(self._fit(line))
315
+ return len(lines)
316
+
317
+ def _collapse(self, previous, question, answer):
318
+ if previous:
319
+ self._write("\x1b[%dA\x1b[J" % previous)
320
+ self._line("%s %s %s %s" % (self._color("32", DONE), question, self._color("90", "·"), self._color("2", answer)))
321
+
322
+ # -- list prompts -------------------------------------------------
323
+ def _option_text(self, option):
324
+ hint = self._hint(option)
325
+ return "%s (%s)" % (self._label(option), hint) if hint else self._label(option)
326
+
327
+ def _move(self, options, cursor, step):
328
+ count = len(options)
329
+ for offset in range(1, count + 1):
330
+ index = (cursor + step * offset) % count
331
+ if not options[index].disabled:
332
+ return index
333
+ return cursor
334
+
335
+ def _render_list(self, question, options, cursor, chosen, multi, message=None):
336
+ lines = ["%s %s" % (self._color("36", ACTIVE), question)]
337
+ for index, option in enumerate(options):
338
+ hint = self._hint(option)
339
+ if option.disabled:
340
+ mark = OFF if multi else "○"
341
+ row = self._color("2", "%s %s%s" % (mark, self._label(option), " (%s)" % hint if hint else ""))
342
+ else:
343
+ if multi:
344
+ mark = self._color("32", ON) if index in chosen else OFF
345
+ else:
346
+ mark = self._color("32", "●") if index == cursor else "○"
347
+ label = self._label(option)
348
+ if index == cursor:
349
+ label = self._color("1", label)
350
+ if hint and (index == cursor or option.hint_always):
351
+ label += " " + self._color("2", "(%s)" % hint)
352
+ row = "%s %s" % (mark, label)
353
+ lines.append("%s %s" % (self._color("36", BAR), row))
354
+ if message:
355
+ lines.append("%s %s" % (self._color("33", "▲"), self._color("33", message)))
356
+ else:
357
+ lines.append(self._color("36", "└"))
358
+ return lines
359
+
360
+ def _index_of(self, options, value):
361
+ for index, option in enumerate(options):
362
+ if option.value == value and not option.disabled:
363
+ return index
364
+ return None
365
+
366
+ def _lookup(self, options, token):
367
+ if token.isdigit() and 1 <= int(token) <= len(options):
368
+ index = int(token) - 1
369
+ else:
370
+ index = next((i for i, o in enumerate(options) if str(o.value) == token), None)
371
+ if index is None or options[index].disabled:
372
+ return None
373
+ return index
374
+
375
+ def _print_numbered(self, question, options):
376
+ self._line(question)
377
+ for number, option in enumerate(options, 1):
378
+ text = self._option_text(option)
379
+ if option.disabled:
380
+ text += " " + self.text("[unavailable]", "[不可用]")
381
+ self._line(" %d. %s" % (number, text))
382
+
383
+ def select(self, english, chinese, options, default=None):
384
+ options = list(options)
385
+ question = self.text(english, chinese)
386
+ enabled = [i for i, o in enumerate(options) if not o.disabled]
387
+ if not enabled:
388
+ raise ValueError("No selectable options")
389
+ start = self._index_of(options, default)
390
+ if start is None:
391
+ start = enabled[0]
392
+ if not self.rich:
393
+ self._print_numbered(question, options)
394
+ while True:
395
+ token = self._readline(self.text("Choose [%d]: ", "请选择 [%d]: ") % (start + 1))
396
+ if not token:
397
+ return options[start].value
398
+ index = self._lookup(options, token)
399
+ if index is not None:
400
+ return options[index].value
401
+ self._line(self.text("Enter a listed number or name.", "请输入列表中的序号或名称。"))
402
+ cursor = start
403
+ drawn = 0
404
+ with self._raw():
405
+ while True:
406
+ drawn = self._redraw(self._render_list(question, options, cursor, (), False), drawn)
407
+ key = self._key()
408
+ if key in ("up", "k"):
409
+ cursor = self._move(options, cursor, -1)
410
+ elif key in ("down", "j"):
411
+ cursor = self._move(options, cursor, 1)
412
+ elif key == "enter":
413
+ self._collapse(drawn, question, self._label(options[cursor]))
414
+ return options[cursor].value
415
+
416
+ def multiselect(self, english, chinese, options, defaults=(), required=True):
417
+ options = list(options)
418
+ question = self.text(english, chinese)
419
+ chosen = set(i for i in (self._index_of(options, v) for v in defaults) if i is not None)
420
+ enabled = [i for i, o in enumerate(options) if not o.disabled]
421
+ if not self.rich:
422
+ self._print_numbered(question, options)
423
+ shown = ",".join(str(i + 1) for i in sorted(chosen)) or "-"
424
+ prompt = self.text(
425
+ "Choose numbers or names, comma-separated [%s]: ",
426
+ "请输入序号或名称,用逗号分隔 [%s]: ",
427
+ ) % shown
428
+ while True:
429
+ raw = self._readline(prompt)
430
+ if not raw:
431
+ picked = set(chosen)
432
+ elif raw == "-":
433
+ picked = set()
434
+ else:
435
+ tokens = [t.strip() for t in raw.replace(",", ",").split(",") if t.strip()]
436
+ indexes = [self._lookup(options, t) for t in tokens]
437
+ if None in indexes:
438
+ self._line(self.text("Enter a listed number or name.", "请输入列表中的序号或名称。"))
439
+ continue
440
+ picked = set(indexes)
441
+ if required and not picked:
442
+ self._line(self.text("Select at least one option.", "请至少选择一项。"))
443
+ continue
444
+ return [options[i].value for i in sorted(picked)]
445
+ cursor = enabled[0] if enabled else 0
446
+ message = None
447
+ drawn = 0
448
+ with self._raw():
449
+ while True:
450
+ lines = self._render_list(question, options, cursor, chosen, True, message)
451
+ if not message:
452
+ lines[-1] = "%s %s" % (self._color("36", "└"), self._color("2", self.text(
453
+ "space toggle · a all · enter confirm", "空格 切换 · a 全选 · 回车 确认")))
454
+ drawn = self._redraw(lines, drawn)
455
+ key = self._key()
456
+ message = None
457
+ if key in ("up", "k"):
458
+ cursor = self._move(options, cursor, -1)
459
+ elif key in ("down", "j"):
460
+ cursor = self._move(options, cursor, 1)
461
+ elif key == " ":
462
+ if enabled and not options[cursor].disabled:
463
+ chosen ^= {cursor}
464
+ elif key == "a":
465
+ chosen = set() if all(i in chosen for i in enabled) else set(enabled)
466
+ elif key == "enter":
467
+ if required and not chosen:
468
+ message = self.text("Select at least one option.", "请至少选择一项。")
469
+ continue
470
+ values = [options[i] for i in sorted(chosen)]
471
+ answer = ", ".join(self._label(o) for o in values) or self.text("(none)", "(无)")
472
+ self._collapse(drawn, question, answer)
473
+ return [o.value for o in values]
474
+
475
+ def confirm(self, english, chinese, default=True):
476
+ question = self.text(english, chinese)
477
+ if not self.rich:
478
+ suffix = "[Y/n]" if default else "[y/N]"
479
+ while True:
480
+ raw = self._readline("%s %s: " % (question, suffix)).lower()
481
+ if not raw:
482
+ return default
483
+ if raw in ("y", "yes", "是", "1"):
484
+ return True
485
+ if raw in ("n", "no", "否", "2"):
486
+ return False
487
+ self._line(self.text("Please answer y or n.", "请输入 y 或 n。"))
488
+ yes, no = self.text("Yes", "是"), self.text("No", "否")
489
+ value = default
490
+ drawn = 0
491
+ with self._raw():
492
+ while True:
493
+ marks = [
494
+ (self._color("32", "●") + " " + yes) if value else ("○ " + self._color("2", yes)),
495
+ (self._color("32", "●") + " " + no) if not value else ("○ " + self._color("2", no)),
496
+ ]
497
+ lines = [
498
+ "%s %s" % (self._color("36", ACTIVE), question),
499
+ "%s %s / %s" % (self._color("36", BAR), marks[0], marks[1]),
500
+ self._color("36", "└"),
501
+ ]
502
+ drawn = self._redraw(lines, drawn)
503
+ key = self._key()
504
+ if key in ("left", "right", "up", "down", "h", "l", "k", "j", "\t"):
505
+ value = not value
506
+ elif key in ("y", "Y"):
507
+ value = True
508
+ elif key in ("n", "N"):
509
+ value = False
510
+ elif key == "enter":
511
+ self._collapse(drawn, question, yes if value else no)
512
+ return value
513
+
514
+ # -- text input ---------------------------------------------------
515
+ def input(self, english, chinese, default="", validate=None):
516
+ question = self.text(english, chinese)
517
+ while True:
518
+ if self.rich:
519
+ value = self._rich_text(question, default, None, False)
520
+ else:
521
+ value = self._readline("%s [%s]: " % (question, default) if default else "%s: " % question)
522
+ value = value or default
523
+ problem = validate(value) if validate else None
524
+ if not problem:
525
+ if self.rich:
526
+ self._collapse(1, question, value)
527
+ return value
528
+ if self.rich:
529
+ self._write("\x1b[1A\x1b[J")
530
+ self._mark("✗" if self.rich else "!", "31", problem[0], problem[1])
531
+
532
+ def password(self, english, chinese):
533
+ question = self.text(english, chinese)
534
+ if not self.rich:
535
+ with warnings.catch_warnings():
536
+ warnings.simplefilter("error", getpass.GetPassWarning)
537
+ try:
538
+ return getpass.getpass(question + ": ", stream=self.writer)
539
+ except getpass.GetPassWarning:
540
+ raise ValueError(
541
+ "Cannot hide terminal input; configure the API key in the host environment instead"
542
+ ) from None
543
+ value = self._rich_text(question, "", "•", True)
544
+ answer = "••••••" if value else self.text("(skipped)", "(已跳过)")
545
+ self._collapse(1, question, answer)
546
+ return value
547
+
548
+ def _rich_text(self, question, default, mask, secret):
549
+ buffer = []
550
+ placeholder = self._color("2", default) if default else ""
551
+ with self._raw(hide=False):
552
+ self._write("%s %s: %s" % (self._color("36", ACTIVE), question, placeholder))
553
+ if default:
554
+ self._write("\x1b[%dD" % len(default))
555
+ try:
556
+ while True:
557
+ key = self._key()
558
+ if key == "enter":
559
+ break
560
+ if key == "backspace":
561
+ if buffer:
562
+ buffer.pop()
563
+ self._write("\x08 \x08")
564
+ continue
565
+ if len(key) != 1 or key < " ":
566
+ continue
567
+ if not buffer and default:
568
+ self._write("\x1b[K")
569
+ buffer.append(key)
570
+ self._write(mask if mask else key)
571
+ finally:
572
+ self._write("\n")
573
+ return "".join(buffer)
574
+
575
+
576
+ def _columns():
577
+ # Unsized pseudo-terminals report 0 columns; treat that like an unknown size.
578
+ columns = shutil.get_terminal_size((80, 24)).columns
579
+ return columns if columns > 0 else 80
580
+
581
+
582
+ def _isatty(stream):
583
+ try:
584
+ return bool(stream.isatty())
585
+ except (AttributeError, ValueError):
586
+ return False