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.
Files changed (178) hide show
  1. {comodor-0.10.0 → comodor-0.10.2}/PKG-INFO +1 -1
  2. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/_version.py +2 -2
  3. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/setup.py +61 -10
  4. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/chooser.py +91 -16
  5. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/console.py +17 -1
  6. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/theme.py +6 -0
  7. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/widgets/overlay.py +10 -3
  8. comodor-0.10.2/tests/test_chooser.py +442 -0
  9. {comodor-0.10.0 → comodor-0.10.2}/tests/test_real_desktop.py +12 -4
  10. {comodor-0.10.0 → comodor-0.10.2}/tests/test_setup.py +85 -0
  11. comodor-0.10.0/tests/test_chooser.py +0 -208
  12. {comodor-0.10.0 → comodor-0.10.2}/.gitignore +0 -0
  13. {comodor-0.10.0 → comodor-0.10.2}/LICENSE +0 -0
  14. {comodor-0.10.0 → comodor-0.10.2}/README.md +0 -0
  15. {comodor-0.10.0 → comodor-0.10.2}/docs/README.md +0 -0
  16. {comodor-0.10.0 → comodor-0.10.2}/pyproject.toml +0 -0
  17. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/__init__.py +0 -0
  18. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/__main__.py +0 -0
  19. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/agent/__init__.py +0 -0
  20. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/agent/context.py +0 -0
  21. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/agent/loop.py +0 -0
  22. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/agent/prompts.py +0 -0
  23. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/agent/spawn.py +0 -0
  24. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/agent/tokens.py +0 -0
  25. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/browser/__init__.py +0 -0
  26. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/browser/cdp.py +0 -0
  27. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/browser/launch.py +0 -0
  28. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/browser/page.py +0 -0
  29. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/catalogue.py +0 -0
  30. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/cli.py +0 -0
  31. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/config.py +0 -0
  32. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/desktop/__init__.py +0 -0
  33. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/desktop/guard.py +0 -0
  34. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/desktop/keys.py +0 -0
  35. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/desktop/overlay.py +0 -0
  36. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/desktop/png.py +0 -0
  37. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/desktop/screen.py +0 -0
  38. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/desktop/win32.py +0 -0
  39. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/doctor.py +0 -0
  40. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/events.py +0 -0
  41. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/help.py +0 -0
  42. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/learning/__init__.py +0 -0
  43. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/learning/associations.py +0 -0
  44. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/learning/bm25.py +0 -0
  45. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/learning/hotindex.py +0 -0
  46. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/learning/memory.py +0 -0
  47. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/learning/progress.py +0 -0
  48. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/learning/reflect.py +0 -0
  49. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/learning/rules.py +0 -0
  50. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/learning/signals.py +0 -0
  51. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/learning/store.py +0 -0
  52. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/learning/writer.py +0 -0
  53. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/mcp/__init__.py +0 -0
  54. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/mcp/catalogue.py +0 -0
  55. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/mcp/commands.py +0 -0
  56. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/mcp/http.py +0 -0
  57. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/mcp/manager.py +0 -0
  58. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/mcp/protocol.py +0 -0
  59. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/migrate.py +0 -0
  60. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/net/__init__.py +0 -0
  61. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/net/http.py +0 -0
  62. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/net/sse.py +0 -0
  63. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/paths.py +0 -0
  64. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/providers/__init__.py +0 -0
  65. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/providers/anthropic.py +0 -0
  66. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/providers/base.py +0 -0
  67. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/providers/caching.py +0 -0
  68. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/providers/fake.py +0 -0
  69. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/providers/gateway.py +0 -0
  70. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/providers/openai_compat.py +0 -0
  71. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/providers/registry.py +0 -0
  72. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/safety/__init__.py +0 -0
  73. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/safety/checkpoints.py +0 -0
  74. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/safety/permissions.py +0 -0
  75. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/safety/redact.py +0 -0
  76. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/session/__init__.py +0 -0
  77. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/session/search.py +0 -0
  78. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/session/store.py +0 -0
  79. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/skills/__init__.py +0 -0
  80. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/skills/catalogue.py +0 -0
  81. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/skills/commands.py +0 -0
  82. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/skills/examples.py +0 -0
  83. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/skills/loader.py +0 -0
  84. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/skills/propose.py +0 -0
  85. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/skills/registry.py +0 -0
  86. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/tools/__init__.py +0 -0
  87. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/tools/base.py +0 -0
  88. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/tools/browse.py +0 -0
  89. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/tools/browser.py +0 -0
  90. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/tools/computer.py +0 -0
  91. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/tools/delegate.py +0 -0
  92. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/tools/fs.py +0 -0
  93. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/tools/history.py +0 -0
  94. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/tools/mcp.py +0 -0
  95. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/tools/overflow.py +0 -0
  96. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/tools/registry.py +0 -0
  97. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/tools/search.py +0 -0
  98. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/tools/shell.py +0 -0
  99. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/tools/skills.py +0 -0
  100. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/tools/todo.py +0 -0
  101. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/tools/web.py +0 -0
  102. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/__init__.py +0 -0
  103. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/app.py +0 -0
  104. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/banner.py +0 -0
  105. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/bidi.py +0 -0
  106. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/clipboard.py +0 -0
  107. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/input/__init__.py +0 -0
  108. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/input/keys.py +0 -0
  109. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/input/reader.py +0 -0
  110. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/layout.py +0 -0
  111. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/markdown.py +0 -0
  112. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/screen.py +0 -0
  113. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/widgets/__init__.py +0 -0
  114. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/widgets/buttons.py +0 -0
  115. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/widgets/chat.py +0 -0
  116. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/widgets/history.py +0 -0
  117. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/widgets/panel.py +0 -0
  118. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/widgets/progress.py +0 -0
  119. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/widgets/prompt.py +0 -0
  120. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/widgets/statusbar.py +0 -0
  121. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/ui/widgets/toast.py +0 -0
  122. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/uninstall.py +0 -0
  123. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/update.py +0 -0
  124. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/web/__init__.py +0 -0
  125. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/web/commands.py +0 -0
  126. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/web/index.html +0 -0
  127. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/web/server.py +0 -0
  128. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/web/session.py +0 -0
  129. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/web/ui.css +0 -0
  130. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/web/ui.js +0 -0
  131. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/web/vazirmatn-OFL.txt +0 -0
  132. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/web/vazirmatn.woff2 +0 -0
  133. {comodor-0.10.0 → comodor-0.10.2}/src/comodor/workspace.py +0 -0
  134. {comodor-0.10.0 → comodor-0.10.2}/tests/conftest.py +0 -0
  135. {comodor-0.10.0 → comodor-0.10.2}/tests/support/fake_mcp_server.py +0 -0
  136. {comodor-0.10.0 → comodor-0.10.2}/tests/test_agent_loop.py +0 -0
  137. {comodor-0.10.0 → comodor-0.10.2}/tests/test_app.py +0 -0
  138. {comodor-0.10.0 → comodor-0.10.2}/tests/test_associations.py +0 -0
  139. {comodor-0.10.0 → comodor-0.10.2}/tests/test_banner.py +0 -0
  140. {comodor-0.10.0 → comodor-0.10.2}/tests/test_bidi.py +0 -0
  141. {comodor-0.10.0 → comodor-0.10.2}/tests/test_browser.py +0 -0
  142. {comodor-0.10.0 → comodor-0.10.2}/tests/test_caching.py +0 -0
  143. {comodor-0.10.0 → comodor-0.10.2}/tests/test_computer_tool.py +0 -0
  144. {comodor-0.10.0 → comodor-0.10.2}/tests/test_config_save.py +0 -0
  145. {comodor-0.10.0 → comodor-0.10.2}/tests/test_copy_and_bands.py +0 -0
  146. {comodor-0.10.0 → comodor-0.10.2}/tests/test_delegate.py +0 -0
  147. {comodor-0.10.0 → comodor-0.10.2}/tests/test_desktop.py +0 -0
  148. {comodor-0.10.0 → comodor-0.10.2}/tests/test_doctor.py +0 -0
  149. {comodor-0.10.0 → comodor-0.10.2}/tests/test_help.py +0 -0
  150. {comodor-0.10.0 → comodor-0.10.2}/tests/test_history.py +0 -0
  151. {comodor-0.10.0 → comodor-0.10.2}/tests/test_import_command.py +0 -0
  152. {comodor-0.10.0 → comodor-0.10.2}/tests/test_input.py +0 -0
  153. {comodor-0.10.0 → comodor-0.10.2}/tests/test_layout.py +0 -0
  154. {comodor-0.10.0 → comodor-0.10.2}/tests/test_learning.py +0 -0
  155. {comodor-0.10.0 → comodor-0.10.2}/tests/test_markdown.py +0 -0
  156. {comodor-0.10.0 → comodor-0.10.2}/tests/test_mcp.py +0 -0
  157. {comodor-0.10.0 → comodor-0.10.2}/tests/test_mcp_http.py +0 -0
  158. {comodor-0.10.0 → comodor-0.10.2}/tests/test_migrate.py +0 -0
  159. {comodor-0.10.0 → comodor-0.10.2}/tests/test_overflow.py +0 -0
  160. {comodor-0.10.0 → comodor-0.10.2}/tests/test_overlay.py +0 -0
  161. {comodor-0.10.0 → comodor-0.10.2}/tests/test_performance.py +0 -0
  162. {comodor-0.10.0 → comodor-0.10.2}/tests/test_prefix_stability.py +0 -0
  163. {comodor-0.10.0 → comodor-0.10.2}/tests/test_progress.py +0 -0
  164. {comodor-0.10.0 → comodor-0.10.2}/tests/test_propose.py +0 -0
  165. {comodor-0.10.0 → comodor-0.10.2}/tests/test_providers.py +0 -0
  166. {comodor-0.10.0 → comodor-0.10.2}/tests/test_real_browser.py +0 -0
  167. {comodor-0.10.0 → comodor-0.10.2}/tests/test_real_web_ui.py +0 -0
  168. {comodor-0.10.0 → comodor-0.10.2}/tests/test_reflex.py +0 -0
  169. {comodor-0.10.0 → comodor-0.10.2}/tests/test_run_loop.py +0 -0
  170. {comodor-0.10.0 → comodor-0.10.2}/tests/test_setup_import.py +0 -0
  171. {comodor-0.10.0 → comodor-0.10.2}/tests/test_skill_budget.py +0 -0
  172. {comodor-0.10.0 → comodor-0.10.2}/tests/test_skill_catalogue.py +0 -0
  173. {comodor-0.10.0 → comodor-0.10.2}/tests/test_skills.py +0 -0
  174. {comodor-0.10.0 → comodor-0.10.2}/tests/test_switching_model.py +0 -0
  175. {comodor-0.10.0 → comodor-0.10.2}/tests/test_tools.py +0 -0
  176. {comodor-0.10.0 → comodor-0.10.2}/tests/test_uninstall.py +0 -0
  177. {comodor-0.10.0 → comodor-0.10.2}/tests/test_update.py +0 -0
  178. {comodor-0.10.0 → comodor-0.10.2}/tests/test_web.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: comodor
3
- Version: 0.10.0
3
+ Version: 0.10.2
4
4
  Summary: Comodor — a self-improving terminal coding agent with a Rich TUI
5
5
  Project-URL: Homepage, https://comodor.ai
6
6
  Project-URL: Repository, https://github.com/ifekri/Comodor
@@ -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.0'
22
- __version_tuple__ = version_tuple = (0, 10, 0)
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 selected, because a setup wizard that
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 You can add more later with `comodor skills`.\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 = [("__none__", "None for now",
359
- "comodor skills browse, whenever you want them")]
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._choose(options, default=1, title="Skills")
364
- if chosen == "__none__":
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 [chosen]
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) -> None:
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
- table.add_row("", Text("nothing matches", style=theme.style("bad")),
124
- Text(""))
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
- table.add_row(
132
- Text(theme.glyphs.arrow if chosen else " ",
133
- style=theme.style("accent", bold=True)),
134
- Text(option.label,
135
- style=theme.style("accent" if chosen else "value",
136
- bold=chosen)),
137
- Text(option.note, style=theme.style("dim")),
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 (("↑↓", "move"), ("enter", "choose"),
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
- """Returns the chosen value, or None if the user backed out."""
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).run()
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
- "─┌│".encode(encoding or "ascii")
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
- return " ↑↓ select · enter confirm · esc cancel "
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