comodor 0.10.0__tar.gz → 0.10.2__tar.gz
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.
- {comodor-0.10.0 → comodor-0.10.2}/PKG-INFO +1 -1
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/_version.py +2 -2
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/setup.py +61 -10
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/chooser.py +91 -16
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/console.py +17 -1
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/theme.py +6 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/widgets/overlay.py +10 -3
- comodor-0.10.2/tests/test_chooser.py +442 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_real_desktop.py +12 -4
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_setup.py +85 -0
- comodor-0.10.0/tests/test_chooser.py +0 -208
- {comodor-0.10.0 → comodor-0.10.2}/.gitignore +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/LICENSE +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/README.md +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/docs/README.md +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/pyproject.toml +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/__init__.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/__main__.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/agent/__init__.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/agent/context.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/agent/loop.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/agent/prompts.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/agent/spawn.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/agent/tokens.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/browser/__init__.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/browser/cdp.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/browser/launch.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/browser/page.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/catalogue.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/cli.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/config.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/desktop/__init__.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/desktop/guard.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/desktop/keys.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/desktop/overlay.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/desktop/png.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/desktop/screen.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/desktop/win32.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/doctor.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/events.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/help.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/learning/__init__.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/learning/associations.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/learning/bm25.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/learning/hotindex.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/learning/memory.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/learning/progress.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/learning/reflect.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/learning/rules.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/learning/signals.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/learning/store.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/learning/writer.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/mcp/__init__.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/mcp/catalogue.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/mcp/commands.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/mcp/http.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/mcp/manager.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/mcp/protocol.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/migrate.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/net/__init__.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/net/http.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/net/sse.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/paths.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/providers/__init__.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/providers/anthropic.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/providers/base.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/providers/caching.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/providers/fake.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/providers/gateway.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/providers/openai_compat.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/providers/registry.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/safety/__init__.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/safety/checkpoints.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/safety/permissions.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/safety/redact.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/session/__init__.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/session/search.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/session/store.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/skills/__init__.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/skills/catalogue.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/skills/commands.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/skills/examples.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/skills/loader.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/skills/propose.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/skills/registry.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/tools/__init__.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/tools/base.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/tools/browse.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/tools/browser.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/tools/computer.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/tools/delegate.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/tools/fs.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/tools/history.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/tools/mcp.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/tools/overflow.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/tools/registry.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/tools/search.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/tools/shell.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/tools/skills.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/tools/todo.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/tools/web.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/__init__.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/app.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/banner.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/bidi.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/clipboard.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/input/__init__.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/input/keys.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/input/reader.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/layout.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/markdown.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/screen.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/widgets/__init__.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/widgets/buttons.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/widgets/chat.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/widgets/history.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/widgets/panel.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/widgets/progress.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/widgets/prompt.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/widgets/statusbar.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/widgets/toast.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/uninstall.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/update.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/web/__init__.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/web/commands.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/web/index.html +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/web/server.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/web/session.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/web/ui.css +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/web/ui.js +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/web/vazirmatn-OFL.txt +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/web/vazirmatn.woff2 +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/src/comodor/workspace.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/conftest.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/support/fake_mcp_server.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_agent_loop.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_app.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_associations.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_banner.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_bidi.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_browser.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_caching.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_computer_tool.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_config_save.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_copy_and_bands.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_delegate.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_desktop.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_doctor.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_help.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_history.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_import_command.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_input.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_layout.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_learning.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_markdown.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_mcp.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_mcp_http.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_migrate.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_overflow.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_overlay.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_performance.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_prefix_stability.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_progress.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_propose.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_providers.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_real_browser.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_real_web_ui.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_reflex.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_run_loop.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_setup_import.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_skill_budget.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_skill_catalogue.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_skills.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_switching_model.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_tools.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_uninstall.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_update.py +0 -0
- {comodor-0.10.0 → comodor-0.10.2}/tests/test_web.py +0 -0
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.10.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 10,
|
|
21
|
+
__version__ = version = '0.10.2'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 10, 2)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -172,6 +172,54 @@ class SetupWizard:
|
|
|
172
172
|
Text(f" enter a number between 1 and {len(options)}",
|
|
173
173
|
style=self.theme.style("bad")))
|
|
174
174
|
|
|
175
|
+
def _choose_many(self, options: Sequence[tuple[str, str, str]],
|
|
176
|
+
title: str = "", verb: str = "choose") -> list[str]:
|
|
177
|
+
"""Return every chosen value. An empty list is a real answer.
|
|
178
|
+
|
|
179
|
+
The list is tried first; without a terminal to take over, the numbered
|
|
180
|
+
form asks the same question. Both accept "none", because a question
|
|
181
|
+
offering four things has to accept zero as an answer without a fifth
|
|
182
|
+
option that means nothing.
|
|
183
|
+
"""
|
|
184
|
+
if self._keys:
|
|
185
|
+
picked = chooser.choose_many(
|
|
186
|
+
self.console, self.theme,
|
|
187
|
+
[chooser.Option(value, label, note) for value, label, note in options],
|
|
188
|
+
title=title, verb=verb,
|
|
189
|
+
)
|
|
190
|
+
if picked is not None:
|
|
191
|
+
return picked
|
|
192
|
+
# Escaped, or the list would not run. Either way the question is
|
|
193
|
+
# still unanswered, so the numbered form takes over.
|
|
194
|
+
|
|
195
|
+
table = Table.grid(padding=(0, 2))
|
|
196
|
+
table.add_column(justify="right", no_wrap=True)
|
|
197
|
+
table.add_column(no_wrap=True)
|
|
198
|
+
table.add_column()
|
|
199
|
+
for index, (_, label, note) in enumerate(options, start=1):
|
|
200
|
+
table.add_row(
|
|
201
|
+
Text(f"{index}.", style=self.theme.style("accent")),
|
|
202
|
+
Text(label, style=self.theme.style("value")),
|
|
203
|
+
Text(note, style=self.theme.style("dim")),
|
|
204
|
+
)
|
|
205
|
+
self.console.print(table)
|
|
206
|
+
|
|
207
|
+
while True:
|
|
208
|
+
raw = self._prompt(" numbers, separated by commas "
|
|
209
|
+
"(enter for none): ").strip()
|
|
210
|
+
if not raw:
|
|
211
|
+
return []
|
|
212
|
+
wanted = [part for part in raw.replace(",", " ").split() if part]
|
|
213
|
+
if all(part.isdigit() and 1 <= int(part) <= len(options)
|
|
214
|
+
for part in wanted):
|
|
215
|
+
# Deduplicated, and in the order they were offered — the same
|
|
216
|
+
# order the list hands back, so the two paths agree.
|
|
217
|
+
taken = {int(part) - 1 for part in wanted}
|
|
218
|
+
return [options[index][0] for index in sorted(taken)]
|
|
219
|
+
self.console.print(
|
|
220
|
+
Text(f" numbers between 1 and {len(options)}, "
|
|
221
|
+
f"or enter for none", style=self.theme.style("bad")))
|
|
222
|
+
|
|
175
223
|
def _ask(self, message: str, default: str = "") -> str:
|
|
176
224
|
suffix = f" [{default}]" if default else ""
|
|
177
225
|
answer = self._prompt(f" {message}{suffix}: ").strip()
|
|
@@ -326,17 +374,22 @@ class SetupWizard:
|
|
|
326
374
|
Skills are not in the package: they live on a branch and are fetched on
|
|
327
375
|
request, which keeps the install small and means adding one needs no
|
|
328
376
|
release. The cost of that is that nobody would ever find them, so they
|
|
329
|
-
are offered here — with nothing
|
|
377
|
+
are offered here — with nothing ticked, because a setup wizard that
|
|
330
378
|
installs things you did not ask for is a setup wizard people learn to
|
|
331
379
|
distrust.
|
|
332
380
|
|
|
381
|
+
As many as you like. Wanting the review skill and the test skill is the
|
|
382
|
+
ordinary case, not an unusual one, and a list that took a single answer
|
|
383
|
+
made the second of them somebody else's problem for another day.
|
|
384
|
+
|
|
333
385
|
A network that is not there is not a reason to fail a first run. It
|
|
334
386
|
skips, and `comodor skills browse` is still there tomorrow.
|
|
335
387
|
"""
|
|
336
388
|
self._rule("Any skills to start with?", step, total)
|
|
337
389
|
self.console.print(Text(
|
|
338
390
|
" A skill is a written procedure the agent follows when the work "
|
|
339
|
-
"calls for it.\n
|
|
391
|
+
"calls for it.\n Take as many as you like — you can add more "
|
|
392
|
+
"later with `comodor skills`.\n",
|
|
340
393
|
style=self.theme.style("dim")))
|
|
341
394
|
|
|
342
395
|
try:
|
|
@@ -355,17 +408,15 @@ class SetupWizard:
|
|
|
355
408
|
self._answered("skills", "none")
|
|
356
409
|
return []
|
|
357
410
|
|
|
358
|
-
options = [(
|
|
359
|
-
|
|
360
|
-
options += [(entry.id, entry.id, entry.description)
|
|
361
|
-
for entry in catalogue.skills]
|
|
411
|
+
options = [(entry.id, entry.id, entry.description)
|
|
412
|
+
for entry in catalogue.skills]
|
|
362
413
|
|
|
363
|
-
chosen = self.
|
|
364
|
-
if chosen
|
|
414
|
+
chosen = self._choose_many(options, title="Skills", verb="install")
|
|
415
|
+
if not chosen:
|
|
365
416
|
self._answered("skills", "none")
|
|
366
417
|
return []
|
|
367
|
-
self._answered("skills", chosen)
|
|
368
|
-
return
|
|
418
|
+
self._answered("skills", ", ".join(chosen))
|
|
419
|
+
return chosen
|
|
369
420
|
|
|
370
421
|
def _banner(self) -> None:
|
|
371
422
|
body = Text.assemble(
|
|
@@ -20,6 +20,10 @@ What is here instead is one framed list at a time.
|
|
|
20
20
|
editor's console, `curl | sh` — the numbered prompt is still there. A setup
|
|
21
21
|
wizard that requires a particular kind of terminal is a setup wizard that
|
|
22
22
|
cannot be scripted.
|
|
23
|
+
* **Some questions take more than one answer.** In that mode each row carries
|
|
24
|
+
a box, space ticks the one under the cursor, and enter takes everything
|
|
25
|
+
ticked. The ticks are held by value rather than by row, so filtering the
|
|
26
|
+
list down and clearing the filter again leaves them where they were.
|
|
23
27
|
"""
|
|
24
28
|
|
|
25
29
|
from __future__ import annotations
|
|
@@ -74,7 +78,8 @@ class Chooser:
|
|
|
74
78
|
"""One list, one choice."""
|
|
75
79
|
|
|
76
80
|
def __init__(self, console: Console, theme: Theme, options: Sequence[Option],
|
|
77
|
-
title: str = "", default: int = 0
|
|
81
|
+
title: str = "", default: int = 0, multi: bool = False,
|
|
82
|
+
verb: str = "choose") -> None:
|
|
78
83
|
self.console = console
|
|
79
84
|
self.theme = theme
|
|
80
85
|
self.options = list(options)
|
|
@@ -82,6 +87,14 @@ class Chooser:
|
|
|
82
87
|
self.cursor = max(0, min(default, len(self.options) - 1))
|
|
83
88
|
self.filter = ""
|
|
84
89
|
self.offset = 0
|
|
90
|
+
#: Whether enter takes one row or every ticked one.
|
|
91
|
+
self.multi = multi
|
|
92
|
+
#: What enter is going to do, for the hint line. "install" rather than
|
|
93
|
+
#: "choose" when that is what happens next.
|
|
94
|
+
self.verb = verb
|
|
95
|
+
#: Ticked values, not ticked rows. Filtering changes which options are
|
|
96
|
+
#: on screen and at which index; it must not change what was asked for.
|
|
97
|
+
self.picked: set[str] = set()
|
|
85
98
|
|
|
86
99
|
# -- what is currently visible ---------------------------------------- #
|
|
87
100
|
|
|
@@ -114,28 +127,37 @@ class Chooser:
|
|
|
114
127
|
self._scroll_into_view()
|
|
115
128
|
visible = items[self.offset:self.offset + window]
|
|
116
129
|
|
|
130
|
+
box_width = max(len(theme.glyphs.ticked), len(theme.glyphs.unticked))
|
|
131
|
+
|
|
117
132
|
table = Table.grid(padding=(0, 1))
|
|
118
133
|
table.add_column(width=2, no_wrap=True)
|
|
134
|
+
if self.multi:
|
|
135
|
+
table.add_column(width=box_width, no_wrap=True)
|
|
119
136
|
table.add_column(no_wrap=True)
|
|
120
137
|
table.add_column(overflow="ellipsis")
|
|
121
138
|
|
|
122
139
|
if not items:
|
|
123
|
-
|
|
124
|
-
|
|
140
|
+
blank = ["", Text("nothing matches", style=theme.style("bad")), Text("")]
|
|
141
|
+
table.add_row(*([blank[0], ""] + blank[1:] if self.multi else blank))
|
|
125
142
|
|
|
126
143
|
for index, option in enumerate(visible, start=self.offset):
|
|
127
144
|
chosen = index == self.cursor
|
|
128
145
|
# The arrow, not just a colour: a highlighted row that relies on
|
|
129
146
|
# background alone disappears on a terminal that renders it faintly,
|
|
130
147
|
# and this is the only thing on screen saying where you are.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
148
|
+
cells = [Text(theme.glyphs.arrow if chosen else " ",
|
|
149
|
+
style=theme.style("accent", bold=True))]
|
|
150
|
+
if self.multi:
|
|
151
|
+
ticked = option.value in self.picked
|
|
152
|
+
cells.append(Text(
|
|
153
|
+
theme.glyphs.ticked if ticked else theme.glyphs.unticked,
|
|
154
|
+
style=theme.style("good" if ticked else "dim",
|
|
155
|
+
bold=ticked)))
|
|
156
|
+
cells.append(Text(
|
|
157
|
+
option.label,
|
|
158
|
+
style=theme.style("accent" if chosen else "value", bold=chosen)))
|
|
159
|
+
cells.append(Text(option.note, style=theme.style("dim")))
|
|
160
|
+
table.add_row(*cells)
|
|
139
161
|
|
|
140
162
|
blocks: list[RenderableType] = []
|
|
141
163
|
above = self.offset
|
|
@@ -149,6 +171,9 @@ class Chooser:
|
|
|
149
171
|
subtitle = None
|
|
150
172
|
if self.filter:
|
|
151
173
|
subtitle = Text(f" filter: {self.filter} ", style=theme.style("accent"))
|
|
174
|
+
elif self.multi and self.picked:
|
|
175
|
+
subtitle = Text(f" {len(self.picked)} selected ",
|
|
176
|
+
style=theme.style("good"))
|
|
152
177
|
elif len(items) != len(self.options):
|
|
153
178
|
subtitle = Text(f" {len(items)} of {len(self.options)} ",
|
|
154
179
|
style=theme.style("dim"))
|
|
@@ -168,17 +193,36 @@ class Chooser:
|
|
|
168
193
|
|
|
169
194
|
def _hint(self) -> Text:
|
|
170
195
|
theme = self.theme
|
|
196
|
+
# Through the glyph table, like everything else in the frame. Written
|
|
197
|
+
# as a literal, this line stayed in arrows on a terminal that had just
|
|
198
|
+
# had its borders downgraded for not being able to draw them.
|
|
199
|
+
updown = f"{theme.glyphs.rise}{theme.glyphs.fall}"
|
|
200
|
+
if self.multi:
|
|
201
|
+
# The count is on the hint rather than only in the corner, because
|
|
202
|
+
# this is the line that says what enter will do and "install"
|
|
203
|
+
# without a number is a question.
|
|
204
|
+
taking = (f"{self.verb} {len(self.picked)}" if self.picked
|
|
205
|
+
else f"{self.verb} nothing")
|
|
206
|
+
keys = ((updown, "move"), ("space", "select"), ("enter", taking),
|
|
207
|
+
("type", "filter"), ("esc", "cancel"))
|
|
208
|
+
else:
|
|
209
|
+
keys = ((updown, "move"), ("enter", "choose"),
|
|
210
|
+
("type", "filter"), ("esc", "cancel"))
|
|
171
211
|
hint = Text(" ", style=theme.style("dim"))
|
|
172
|
-
for key, what in
|
|
173
|
-
("type", "filter"), ("esc", "cancel")):
|
|
212
|
+
for key, what in keys:
|
|
174
213
|
hint.append(key, style=theme.style("accent"))
|
|
175
214
|
hint.append(f" {what} ", style=theme.style("dim"))
|
|
176
215
|
return hint
|
|
177
216
|
|
|
178
217
|
# -- the loop ----------------------------------------------------------- #
|
|
179
218
|
|
|
180
|
-
def run(self) -> str | None:
|
|
181
|
-
"""
|
|
219
|
+
def run(self) -> str | list[str] | None:
|
|
220
|
+
"""The chosen value, or the chosen values, or None if backed out.
|
|
221
|
+
|
|
222
|
+
A list when `multi` is set — possibly an empty one, which is an answer
|
|
223
|
+
of "none of them" rather than a refusal to answer. None is only ever
|
|
224
|
+
escape or ctrl-c.
|
|
225
|
+
"""
|
|
182
226
|
from rich.live import Live
|
|
183
227
|
|
|
184
228
|
items = self.matching
|
|
@@ -210,7 +254,18 @@ class Chooser:
|
|
|
210
254
|
if event.matches("ctrl+c") or event.key == "escape":
|
|
211
255
|
return _CANCEL
|
|
212
256
|
if event.key == "enter":
|
|
257
|
+
if self.multi:
|
|
258
|
+
# In the order they were offered, not the order they were
|
|
259
|
+
# ticked: the list on screen is what the reader remembers.
|
|
260
|
+
return [option.value for option in self.options
|
|
261
|
+
if option.value in self.picked]
|
|
213
262
|
return items[self.cursor].value if items else _CANCEL
|
|
263
|
+
if self.multi and event.key == "char" and event.char == " " \
|
|
264
|
+
and not event.ctrl and not event.alt:
|
|
265
|
+
if items:
|
|
266
|
+
value = items[self.cursor].value
|
|
267
|
+
self.picked.symmetric_difference_update({value})
|
|
268
|
+
return None
|
|
214
269
|
|
|
215
270
|
if event.key in ("up", "down", "pgup", "pgdn", "home", "end"):
|
|
216
271
|
self._move(event.key, len(items))
|
|
@@ -260,10 +315,30 @@ _CANCEL = _Cancel()
|
|
|
260
315
|
def choose(console: Console, theme: Theme, options: Sequence[Option],
|
|
261
316
|
title: str = "", default: int = 0) -> str | None:
|
|
262
317
|
"""The whole interaction, or None if there is no terminal to run it in."""
|
|
318
|
+
picked = _run(console, theme, options, title=title, default=default)
|
|
319
|
+
return picked if isinstance(picked, str) else None
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def choose_many(console: Console, theme: Theme, options: Sequence[Option],
|
|
323
|
+
title: str = "", verb: str = "choose") -> list[str] | None:
|
|
324
|
+
"""Tick as many as you like. None means the list could not run.
|
|
325
|
+
|
|
326
|
+
An empty list is a real answer — nothing ticked, enter pressed — and the
|
|
327
|
+
caller must tell it apart from None, which means fall back to asking some
|
|
328
|
+
other way.
|
|
329
|
+
"""
|
|
330
|
+
picked = _run(console, theme, options, title=title, multi=True, verb=verb)
|
|
331
|
+
return picked if isinstance(picked, list) else None
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _run(console: Console, theme: Theme, options: Sequence[Option],
|
|
335
|
+
title: str = "", default: int = 0, multi: bool = False,
|
|
336
|
+
verb: str = "choose") -> str | list[str] | None:
|
|
263
337
|
if not interactive(console) or not options:
|
|
264
338
|
return None
|
|
265
339
|
try:
|
|
266
|
-
return Chooser(console, theme, options, title=title, default=default
|
|
340
|
+
return Chooser(console, theme, options, title=title, default=default,
|
|
341
|
+
multi=multi, verb=verb).run()
|
|
267
342
|
except Exception:
|
|
268
343
|
# A terminal that will not do raw mode, a reader that will not start:
|
|
269
344
|
# the caller still has the numbered prompt, and a failed experiment
|
|
@@ -65,11 +65,27 @@ def force_utf8() -> bool:
|
|
|
65
65
|
|
|
66
66
|
|
|
67
67
|
def supports_unicode() -> bool:
|
|
68
|
+
"""Whether this terminal can encode what the interface actually draws.
|
|
69
|
+
|
|
70
|
+
The probe was three box-drawing characters. Those are in cp437 and cp850 -
|
|
71
|
+
still what a fresh `cmd.exe` gives a lot of people - so the answer came
|
|
72
|
+
back yes, and then fourteen glyphs those code pages do not have went to
|
|
73
|
+
the screen: the bullet, the tick, the warning triangle, and the arrow that
|
|
74
|
+
is the only thing marking which row the cursor is on.
|
|
75
|
+
|
|
76
|
+
Built from the glyph table rather than from a literal, so a glyph added
|
|
77
|
+
later widens the probe by existing rather than by somebody remembering.
|
|
78
|
+
"""
|
|
68
79
|
encoding = (getattr(sys.stdout, "encoding", "") or "").lower()
|
|
69
80
|
if "utf" in encoding:
|
|
70
81
|
return True
|
|
82
|
+
|
|
83
|
+
from .theme import Glyphs
|
|
84
|
+
|
|
85
|
+
wanted = "─┌│" + "".join(
|
|
86
|
+
value for value in vars(Glyphs()).values() if isinstance(value, str))
|
|
71
87
|
try:
|
|
72
|
-
|
|
88
|
+
wanted.encode(encoding or "ascii")
|
|
73
89
|
return True
|
|
74
90
|
except (UnicodeEncodeError, LookupError):
|
|
75
91
|
return False
|
|
@@ -227,6 +227,11 @@ class Glyphs:
|
|
|
227
227
|
arrow: str = "›"
|
|
228
228
|
check: str = "●"
|
|
229
229
|
pending: str = "○"
|
|
230
|
+
#: A box you can tick, which is a different thing from the filled and
|
|
231
|
+
#: hollow dots above: those report whether a step has happened, these
|
|
232
|
+
#: report whether you have asked for something.
|
|
233
|
+
ticked: str = "☑"
|
|
234
|
+
unticked: str = "☐"
|
|
230
235
|
active: str = "◐"
|
|
231
236
|
blocked: str = "✗"
|
|
232
237
|
spinner: tuple[str, ...] = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
|
|
@@ -245,6 +250,7 @@ class Glyphs:
|
|
|
245
250
|
|
|
246
251
|
ASCII_GLYPHS = Glyphs(
|
|
247
252
|
bullet="*", arrow=">", check="[x]", pending="[ ]", active="[~]", blocked="[!]",
|
|
253
|
+
ticked="[x]", unticked="[ ]",
|
|
248
254
|
spinner=("|", "/", "-", "\\"), gauge_full="#", gauge_empty=".", cursor="_",
|
|
249
255
|
divider="-", tool="*", memory="+", warn="!",
|
|
250
256
|
rise="^", fall="v", dot="-", dash="-",
|
|
@@ -123,7 +123,7 @@ def render_overlay(overlay: Overlay, width: int, height: int,
|
|
|
123
123
|
body,
|
|
124
124
|
title=Text(f" {overlay.title} ", style=theme.style("title")),
|
|
125
125
|
title_align="left",
|
|
126
|
-
subtitle=Text(_footer_hint(overlay), style=theme.style("dim")),
|
|
126
|
+
subtitle=Text(_footer_hint(overlay, theme), style=theme.style("dim")),
|
|
127
127
|
subtitle_align="right",
|
|
128
128
|
box=theme.heavy_box,
|
|
129
129
|
border_style=theme.style("bad" if overlay.kind == "permission" else "border"),
|
|
@@ -134,11 +134,18 @@ def render_overlay(overlay: Overlay, width: int, height: int,
|
|
|
134
134
|
return Align.center(panel, vertical="middle")
|
|
135
135
|
|
|
136
136
|
|
|
137
|
-
def _footer_hint(overlay: Overlay) -> str:
|
|
137
|
+
def _footer_hint(overlay: Overlay, theme: Theme) -> str:
|
|
138
|
+
"""The theme comes in because the arrows do.
|
|
139
|
+
|
|
140
|
+
Written as literals, this line stayed in `↑↓` on a terminal that had just
|
|
141
|
+
had its borders downgraded to `+-|` for being unable to draw them.
|
|
142
|
+
"""
|
|
138
143
|
if overlay.kind == "permission":
|
|
139
144
|
return " y / a / n "
|
|
140
145
|
if overlay.kind == "select":
|
|
141
|
-
|
|
146
|
+
glyphs = theme.glyphs
|
|
147
|
+
return (f" {glyphs.rise}{glyphs.fall} select {glyphs.dot} enter confirm "
|
|
148
|
+
f"{glyphs.dot} esc cancel ")
|
|
142
149
|
return " esc to close "
|
|
143
150
|
|
|
144
151
|
|