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.
- package/.agents/plugins/marketplace.json +20 -0
- package/.codex-plugin/plugin.json +46 -0
- package/LICENSE +674 -0
- package/README.md +128 -0
- package/README.zh.md +126 -0
- package/bin/bookmark-research.js +30 -0
- package/config/providers.json +98 -0
- package/docs/bookmark-research-architecture.md +124 -0
- package/docs/cli-launcher-plan.md +58 -0
- package/docs/details.en.md +272 -0
- package/docs/details.md +93 -0
- package/docs/harness-compatibility.md +304 -0
- package/docs/harness-sources.json +342 -0
- package/docs/host-validation.json +298 -0
- package/docs/install-design-0.2.0.md +57 -0
- package/docs/installation.en.md +197 -0
- package/docs/installation.md +316 -0
- package/docs/instructions.en.md +42 -0
- package/docs/instructions.md +42 -0
- package/docs/mcp-aggregation-0.2.0.md +92 -0
- package/docs/multi-host-installation-plan.md +163 -0
- package/docs/prompt-reference.en.md +104 -0
- package/docs/prompt-reference.md +104 -0
- package/docs/provider-smoke-0.2.0.json +235 -0
- package/docs/release-notes-0.5.0-beta.1.md +33 -0
- package/docs/research-0.2.0.md +140 -0
- package/docs/research-sources-0.2.0.json +9360 -0
- package/docs/user-guide.en.md +74 -0
- package/docs/user-guide.md +74 -0
- package/docs/validation-0.2.0.md +126 -0
- package/docs/validation-0.4.0.md +154 -0
- package/docs/wiki-quality.md +105 -0
- package/docs/wiki-quality.zh.md +105 -0
- package/hosts/claude/runtime.js +13 -0
- package/hosts/codex/delegate.md +14 -0
- package/hosts/codex/prepare.py +61 -0
- package/hosts/dsh/plugin.js +36 -0
- package/hosts/dsh/runtime.js +14 -0
- package/hosts/dsh/workflow-call.py +45 -0
- package/hosts/pi/register-workflow.py +72 -0
- package/hosts/pi/runtime.js +18 -0
- package/hosts/shared/research-call.py +76 -0
- package/hosts/shared/research-flow.js +208 -0
- package/hosts/shared/workflow.json +15 -0
- package/install.sh +310 -0
- package/package.json +12 -0
- package/scripts/build_zip.py +143 -0
- package/scripts/export_bundle.py +496 -0
- package/scripts/host_assets.py +70 -0
- package/scripts/host_clients.py +196 -0
- package/scripts/host_install.py +277 -0
- package/scripts/install.py +593 -0
- package/scripts/launcher.py +186 -0
- package/scripts/verify_fixture.py +220 -0
- package/scripts/verify_live.py +62 -0
- package/scripts/verify_quality.py +87 -0
- package/skills/bookmark-research/SKILL.md +80 -0
- package/skills/bookmark-research/agents/openai.yaml +3 -0
- package/skills/bookmark-research/references/cli.md +150 -0
- package/skills/bookmark-research/references/deep-research.md +164 -0
- package/skills/bookmark-research/references/github-and-sync.md +30 -0
- package/skills/bookmark-research/references/host-workflows.md +101 -0
- package/skills/bookmark-research/references/package-semantics.md +78 -0
- package/skills/bookmark-research/references/research-methods.md +51 -0
- package/skills/bookmark-research/references/research-services.md +72 -0
- package/skills/bookmark-research/references/research-workflow.md +57 -0
- package/skills/bookmark-research/references/settings-and-archive.md +104 -0
- package/skills/bookmark-research/references/source-lifecycle.md +75 -0
- package/skills/bookmark-research/references/wiki-and-evaluation.md +63 -0
- package/skills/bookmark-research/references/zh/cli.md +150 -0
- package/skills/bookmark-research/references/zh/deep-research.md +164 -0
- package/skills/bookmark-research/references/zh/github-and-sync.md +30 -0
- package/skills/bookmark-research/references/zh/host-workflows.md +101 -0
- package/skills/bookmark-research/references/zh/package-semantics.md +78 -0
- package/skills/bookmark-research/references/zh/research-methods.md +51 -0
- package/skills/bookmark-research/references/zh/research-services.md +70 -0
- package/skills/bookmark-research/references/zh/research-workflow.md +57 -0
- package/skills/bookmark-research/references/zh/settings-and-archive.md +104 -0
- package/skills/bookmark-research/references/zh/skill-guide.md +75 -0
- package/skills/bookmark-research/references/zh/source-lifecycle.md +75 -0
- package/skills/bookmark-research/references/zh/wiki-and-evaluation.md +63 -0
- package/src/archive.py +302 -0
- package/src/bookmark_index.py +913 -0
- package/src/cli.py +435 -0
- package/src/credentials.py +82 -0
- package/src/evaluation.py +337 -0
- package/src/jina.py +96 -0
- package/src/mcp_server.py +641 -0
- package/src/onboarding.py +352 -0
- package/src/provider_adapters.py +152 -0
- package/src/readiness.py +283 -0
- package/src/remote_mcp.py +295 -0
- package/src/research.py +1334 -0
- package/src/research_coverage.py +160 -0
- package/src/research_services.py +684 -0
- package/src/routing.py +202 -0
- package/src/search_results.py +244 -0
- package/src/service_http.py +139 -0
- package/src/settings.py +247 -0
- package/src/source_inputs.py +201 -0
- package/src/source_manager.py +348 -0
- package/src/source_watcher.py +68 -0
- package/src/textio.py +9 -0
- package/src/tui.py +586 -0
- package/src/web_search.py +603 -0
- 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
|