comodor 0.2.1__tar.gz → 0.2.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 (112) hide show
  1. {comodor-0.2.1 → comodor-0.2.2}/.gitignore +4 -0
  2. {comodor-0.2.1 → comodor-0.2.2}/PKG-INFO +1 -1
  3. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/_version.py +2 -2
  4. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/setup.py +100 -24
  5. comodor-0.2.2/src/comodor/ui/chooser.py +271 -0
  6. comodor-0.2.2/tests/test_chooser.py +208 -0
  7. {comodor-0.2.1 → comodor-0.2.2}/LICENSE +0 -0
  8. {comodor-0.2.1 → comodor-0.2.2}/README.md +0 -0
  9. {comodor-0.2.1 → comodor-0.2.2}/pyproject.toml +0 -0
  10. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/__init__.py +0 -0
  11. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/__main__.py +0 -0
  12. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/agent/__init__.py +0 -0
  13. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/agent/context.py +0 -0
  14. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/agent/loop.py +0 -0
  15. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/agent/prompts.py +0 -0
  16. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/agent/tokens.py +0 -0
  17. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/catalogue.py +0 -0
  18. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/cli.py +0 -0
  19. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/config.py +0 -0
  20. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/doctor.py +0 -0
  21. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/events.py +0 -0
  22. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/learning/__init__.py +0 -0
  23. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/learning/bm25.py +0 -0
  24. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/learning/hotindex.py +0 -0
  25. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/learning/memory.py +0 -0
  26. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/learning/progress.py +0 -0
  27. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/learning/reflect.py +0 -0
  28. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/learning/rules.py +0 -0
  29. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/learning/signals.py +0 -0
  30. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/learning/store.py +0 -0
  31. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/learning/writer.py +0 -0
  32. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/mcp/__init__.py +0 -0
  33. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/mcp/catalogue.py +0 -0
  34. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/mcp/commands.py +0 -0
  35. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/mcp/manager.py +0 -0
  36. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/mcp/protocol.py +0 -0
  37. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/net/__init__.py +0 -0
  38. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/net/http.py +0 -0
  39. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/net/sse.py +0 -0
  40. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/paths.py +0 -0
  41. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/providers/__init__.py +0 -0
  42. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/providers/anthropic.py +0 -0
  43. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/providers/base.py +0 -0
  44. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/providers/fake.py +0 -0
  45. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/providers/gateway.py +0 -0
  46. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/providers/openai_compat.py +0 -0
  47. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/providers/registry.py +0 -0
  48. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/safety/__init__.py +0 -0
  49. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/safety/checkpoints.py +0 -0
  50. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/safety/permissions.py +0 -0
  51. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/safety/redact.py +0 -0
  52. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/session/__init__.py +0 -0
  53. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/session/search.py +0 -0
  54. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/session/store.py +0 -0
  55. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/skills/__init__.py +0 -0
  56. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/skills/examples.py +0 -0
  57. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/skills/loader.py +0 -0
  58. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/skills/propose.py +0 -0
  59. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/skills/registry.py +0 -0
  60. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/tools/__init__.py +0 -0
  61. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/tools/base.py +0 -0
  62. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/tools/fs.py +0 -0
  63. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/tools/history.py +0 -0
  64. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/tools/mcp.py +0 -0
  65. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/tools/registry.py +0 -0
  66. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/tools/search.py +0 -0
  67. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/tools/shell.py +0 -0
  68. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/tools/skills.py +0 -0
  69. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/tools/todo.py +0 -0
  70. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/tools/web.py +0 -0
  71. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/ui/__init__.py +0 -0
  72. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/ui/app.py +0 -0
  73. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/ui/console.py +0 -0
  74. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/ui/input/__init__.py +0 -0
  75. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/ui/input/keys.py +0 -0
  76. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/ui/input/reader.py +0 -0
  77. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/ui/layout.py +0 -0
  78. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/ui/markdown.py +0 -0
  79. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/ui/screen.py +0 -0
  80. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/ui/theme.py +0 -0
  81. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/ui/widgets/__init__.py +0 -0
  82. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/ui/widgets/buttons.py +0 -0
  83. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/ui/widgets/chat.py +0 -0
  84. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/ui/widgets/history.py +0 -0
  85. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/ui/widgets/overlay.py +0 -0
  86. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/ui/widgets/panel.py +0 -0
  87. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/ui/widgets/progress.py +0 -0
  88. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/ui/widgets/prompt.py +0 -0
  89. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/ui/widgets/statusbar.py +0 -0
  90. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/ui/widgets/toast.py +0 -0
  91. {comodor-0.2.1 → comodor-0.2.2}/src/comodor/uninstall.py +0 -0
  92. {comodor-0.2.1 → comodor-0.2.2}/tests/conftest.py +0 -0
  93. {comodor-0.2.1 → comodor-0.2.2}/tests/support/fake_mcp_server.py +0 -0
  94. {comodor-0.2.1 → comodor-0.2.2}/tests/test_agent_loop.py +0 -0
  95. {comodor-0.2.1 → comodor-0.2.2}/tests/test_app.py +0 -0
  96. {comodor-0.2.1 → comodor-0.2.2}/tests/test_doctor.py +0 -0
  97. {comodor-0.2.1 → comodor-0.2.2}/tests/test_history.py +0 -0
  98. {comodor-0.2.1 → comodor-0.2.2}/tests/test_input.py +0 -0
  99. {comodor-0.2.1 → comodor-0.2.2}/tests/test_layout.py +0 -0
  100. {comodor-0.2.1 → comodor-0.2.2}/tests/test_learning.py +0 -0
  101. {comodor-0.2.1 → comodor-0.2.2}/tests/test_markdown.py +0 -0
  102. {comodor-0.2.1 → comodor-0.2.2}/tests/test_mcp.py +0 -0
  103. {comodor-0.2.1 → comodor-0.2.2}/tests/test_performance.py +0 -0
  104. {comodor-0.2.1 → comodor-0.2.2}/tests/test_progress.py +0 -0
  105. {comodor-0.2.1 → comodor-0.2.2}/tests/test_propose.py +0 -0
  106. {comodor-0.2.1 → comodor-0.2.2}/tests/test_providers.py +0 -0
  107. {comodor-0.2.1 → comodor-0.2.2}/tests/test_reflex.py +0 -0
  108. {comodor-0.2.1 → comodor-0.2.2}/tests/test_run_loop.py +0 -0
  109. {comodor-0.2.1 → comodor-0.2.2}/tests/test_setup.py +0 -0
  110. {comodor-0.2.1 → comodor-0.2.2}/tests/test_skills.py +0 -0
  111. {comodor-0.2.1 → comodor-0.2.2}/tests/test_tools.py +0 -0
  112. {comodor-0.2.1 → comodor-0.2.2}/tests/test_uninstall.py +0 -0
@@ -34,3 +34,7 @@ TODO
34
34
 
35
35
  # Written by the build from the git tag.
36
36
  src/comodor/_version.py
37
+
38
+ # uv writes this when it resolves an environment here. Comodor is a library
39
+ # as well as an application; a lock file would pin its users, not just us.
40
+ uv.lock
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: comodor
3
- Version: 0.2.1
3
+ Version: 0.2.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.2.1'
22
- __version_tuple__ = version_tuple = (0, 2, 1)
21
+ __version__ = version = '0.2.2'
22
+ __version_tuple__ = version_tuple = (0, 2, 2)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -5,10 +5,19 @@ does not ask anybody to find a dotfile, learn an environment variable or read
5
5
  documentation before their first task — it asks what it needs, in the terminal,
6
6
  with the answers numbered, and writes them down.
7
7
 
8
- Everything here is deliberately plain input: numbered choices and one masked
9
- field. The full interface needs raw terminal mode and a running event loop, and
10
- neither is available yet at the point setup runs — nor should the first thing a
11
- new user meets be a mode their terminal might not support.
8
+ Two ways of asking, and the second one is not optional.
9
+
10
+ On a real terminal each question arrives on a screen of its own: what has
11
+ already been answered is summarised in two or three quiet lines at the top, and
12
+ below it one framed list you move through with the arrow keys. Questions no
13
+ longer pile up — by the fourth one the terminal used to be a transcript of
14
+ decisions already made — and a provider with sixty models is a list you can
15
+ filter by typing rather than sixty numbered rows to read.
16
+
17
+ Anywhere without a terminal — a pipe, a test, an editor's console — the
18
+ numbered prompt is exactly what it was. A setup wizard that only works in one
19
+ kind of terminal is a setup wizard that cannot be scripted, and the first thing
20
+ a new user meets must not be a mode their terminal might not support.
12
21
  """
13
22
 
14
23
  from __future__ import annotations
@@ -24,6 +33,7 @@ from rich.text import Text
24
33
 
25
34
  from . import catalogue
26
35
  from .config import Config
36
+ from .ui import chooser
27
37
  from .ui import console as console_module
28
38
  from .ui.theme import Theme
29
39
 
@@ -57,10 +67,26 @@ class SetupWizard:
57
67
  self.console = console or console_module.build(self.theme)
58
68
  self._prompt = prompt or (lambda message: input(message))
59
69
  self._secret = secret or (lambda message: getpass.getpass(message))
70
+ # An injected prompt means somebody is driving this without a keyboard,
71
+ # so the interactive list is off whatever the terminal says it can do.
72
+ self._keys = prompt is None and chooser.interactive(self.console)
73
+ #: What has been answered so far, shown at the top of each screen.
74
+ self._done: list[tuple[str, str]] = []
60
75
 
61
76
  # -- presentation ----------------------------------------------------- #
62
77
 
63
78
  def _rule(self, title: str, step: int, total: int) -> None:
79
+ """Start a question.
80
+
81
+ On a real terminal this is where the screen is cleared. The alternative
82
+ — letting the questions stack — meant that by the last one the useful
83
+ part of the screen was a few lines at the bottom under a wall of
84
+ choices already made. What replaces the wall is the same information in
85
+ one line each, which is all it was ever worth.
86
+ """
87
+ if self._keys:
88
+ self.console.clear()
89
+ self._recap()
64
90
  self.console.print()
65
91
  self.console.print(
66
92
  Text.assemble(
@@ -69,13 +95,50 @@ class SetupWizard:
69
95
  )
70
96
  )
71
97
 
72
- def _choose(self, options: Sequence[tuple[str, str, str]], default: int = 1) -> str:
73
- """Print a numbered list and return the chosen value.
98
+ def _recap(self) -> None:
99
+ """The questions already answered, one quiet line each."""
100
+ if not self._done:
101
+ self.console.print(
102
+ Text(" Comodor setup", style=self.theme.style("dim")))
103
+ return
104
+ for label, value in self._done:
105
+ self.console.print(Text.assemble(
106
+ (" ✓ ", self.theme.style("good")),
107
+ (f"{label} ", self.theme.style("dim")),
108
+ (value, self.theme.style("value")),
109
+ ))
74
110
 
75
- ``options`` is ``(value, label, note)``. Re-asks on a bad answer rather
76
- than falling through to a default the user did not pick — a silent
77
- wrong choice here is one they would have to undo later.
111
+ def _answered(self, label: str, value: str) -> None:
112
+ self._done.append((label, value))
113
+ if not self._keys:
114
+ return
115
+ # Echoed once here, because the list it came from is erased on the way
116
+ # out: a choice that leaves no trace reads as a choice that did not
117
+ # register.
118
+ self.console.print(Text.assemble(
119
+ (" ✓ ", self.theme.style("good")),
120
+ (value, self.theme.style("value", bold=True)),
121
+ ))
122
+
123
+ def _choose(self, options: Sequence[tuple[str, str, str]], default: int = 1,
124
+ title: str = "") -> str:
125
+ """Return the chosen value, by arrow key or by number.
126
+
127
+ ``options`` is ``(value, label, note)``. The numbered path re-asks on a
128
+ bad answer rather than falling through to a default the user did not
129
+ pick — a silent wrong choice here is one they would have to undo later.
78
130
  """
131
+ if self._keys:
132
+ picked = chooser.choose(
133
+ self.console, self.theme,
134
+ [chooser.Option(value, label, note) for value, label, note in options],
135
+ title=title, default=default - 1,
136
+ )
137
+ if picked is not None:
138
+ return picked
139
+ # The list could not run, or was escaped out of. Either way the
140
+ # question still needs an answer, so the numbered form takes over.
141
+
79
142
  table = Table.grid(padding=(0, 2))
80
143
  table.add_column(justify="right", no_wrap=True)
81
144
  table.add_column(no_wrap=True)
@@ -124,6 +187,7 @@ class SetupWizard:
124
187
  self.console.print(
125
188
  Text(" Not needed — this one runs on your machine.",
126
189
  style=self.theme.style("dim")))
190
+ self._answered("api key", "not needed")
127
191
 
128
192
  answers.model = self._ask_model(3, total, spec, answers)
129
193
  answers.approvals = self._ask_approvals(4, total)
@@ -152,7 +216,9 @@ class SetupWizard:
152
216
  Text(" You can add more later; this is just the one to start with.\n",
153
217
  style=self.theme.style("dim")))
154
218
  options = [(spec.id, spec.label, spec.blurb) for spec in catalogue.offered()]
155
- return self._choose(options, default=1)
219
+ chosen = self._choose(options, default=1, title="Providers")
220
+ self._answered("provider", dict((v, l) for v, l, _ in options).get(chosen, chosen))
221
+ return chosen
156
222
 
157
223
  def _ask_endpoint(self) -> str:
158
224
  self.console.print()
@@ -175,24 +241,33 @@ class SetupWizard:
175
241
  # off screen recordings.
176
242
  key = self._secret(" key (input hidden): ").strip()
177
243
  if key:
244
+ self._answered("api key", "set, and never shown again")
178
245
  return key
179
246
  self.console.print(Text(" a key is required for this provider",
180
247
  style=self.theme.style("bad")))
181
248
 
182
249
  def _ask_model(self, step: int, total: int,
183
250
  spec: catalogue.ProviderSpec | None, answers: Answers) -> str:
184
- self._rule("Which model?", step, total)
185
-
251
+ # Discovery first, and the question afterwards, because asking the
252
+ # provider what it has takes a second or two over the network. During
253
+ # that second the terminal is still in its ordinary mode, so anything
254
+ # impatient fingers press is echoed — an arrow key arrives on screen as
255
+ # `^[[B` and sits there. Clearing for the question is what wipes it, so
256
+ # the clearing has to come second. The keystrokes themselves are
257
+ # discarded when the reader takes the terminal.
186
258
  models = self._discover_models(spec, answers)
259
+
260
+ self._rule("Which model?", step, total)
187
261
  if not models:
188
262
  return self._ask("model id", spec.default_model if spec else "")
189
263
 
190
264
  options = [(model, model, "recommended" if index == 0 else "")
191
265
  for index, model in enumerate(models)]
192
266
  options.append(("__other__", "something else", "type the model id"))
193
- chosen = self._choose(options, default=1)
267
+ chosen = self._choose(options, default=1, title="Models")
194
268
  if chosen == "__other__":
195
- return self._ask("model id", models[0])
269
+ chosen = self._ask("model id", models[0])
270
+ self._answered("model", chosen)
196
271
  return chosen
197
272
 
198
273
  def _discover_models(self, spec: catalogue.ProviderSpec | None,
@@ -231,16 +306,17 @@ class SetupWizard:
231
306
 
232
307
  def _ask_approvals(self, step: int, total: int) -> str:
233
308
  self._rule("How much should it ask before acting?", step, total)
234
- return self._choose(
235
- [
236
- ("ask", "Ask before writing or running anything",
237
- "safest; you see a diff or the command first"),
238
- ("writes", "Write files freely, ask before running commands",
239
- "a good middle ground"),
240
- ("auto", "Do not ask", "fastest; everything is still checkpointed"),
241
- ],
242
- default=1,
243
- )
309
+ options = [
310
+ ("ask", "Ask before writing or running anything",
311
+ "safest; you see a diff or the command first"),
312
+ ("writes", "Write files freely, ask before running commands",
313
+ "a good middle ground"),
314
+ ("auto", "Do not ask", "fastest; everything is still checkpointed"),
315
+ ]
316
+ chosen = self._choose(options, default=1, title="Approvals")
317
+ self._answered("approvals",
318
+ dict((v, l) for v, l, _ in options).get(chosen, chosen))
319
+ return chosen
244
320
 
245
321
  # -- applying --------------------------------------------------------- #
246
322
 
@@ -0,0 +1,271 @@
1
+ """Choosing from a list, with the arrow keys.
2
+
3
+ The wizard used to print a numbered list and read a number. That is fine for
4
+ three options and wrong for a hundred: picking a model meant reading a wall of
5
+ identifiers, finding the one you wanted, remembering its number, scrolling back
6
+ down because the list had pushed the prompt off the screen, and typing a digit
7
+ you were no longer sure of. And every question stayed on screen afterwards, so
8
+ by the fourth one the terminal was a transcript of decisions already made.
9
+
10
+ What is here instead is one framed list at a time.
11
+
12
+ * **The frame never grows past the terminal.** However many options there are,
13
+ the list is windowed to what will fit and moves with the cursor, with a count
14
+ of what is above and below. Nothing is ever off screen with no sign that it
15
+ is there.
16
+ * **Typing filters.** With sixty models on offer, `son` is faster than sixty
17
+ presses of the down arrow, and it is what anybody who has used a fuzzy finder
18
+ will try first.
19
+ * **It refuses to be the only way in.** Without a terminal — a pipe, a test, an
20
+ editor's console, `curl | sh` — the numbered prompt is still there. A setup
21
+ wizard that requires a particular kind of terminal is a setup wizard that
22
+ cannot be scripted.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from dataclasses import dataclass
28
+ from typing import Sequence
29
+
30
+ from rich.console import Console, Group, RenderableType
31
+ from rich.panel import Panel
32
+ from rich.table import Table
33
+ from rich.text import Text
34
+
35
+ from .input.keys import KeyEvent
36
+ from .input.reader import TerminalInput
37
+ from .theme import Theme
38
+
39
+ #: Rows the frame spends on itself: two borders, and the hint line under it.
40
+ CHROME = 4
41
+ #: Never show fewer than this, even on a very short terminal.
42
+ MIN_ROWS = 3
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Option:
47
+ value: str
48
+ label: str
49
+ note: str = ""
50
+
51
+ def matches(self, needle: str) -> bool:
52
+ return needle in self.label.lower() or needle in self.note.lower()
53
+
54
+
55
+ def interactive(console: Console) -> bool:
56
+ """Can we take over the keyboard here?"""
57
+ try:
58
+ return bool(console.is_terminal and console.file.isatty()
59
+ and _stdin_is_a_terminal())
60
+ except Exception:
61
+ return False
62
+
63
+
64
+ def _stdin_is_a_terminal() -> bool:
65
+ import sys
66
+
67
+ try:
68
+ return bool(sys.stdin and sys.stdin.isatty())
69
+ except Exception:
70
+ return False
71
+
72
+
73
+ class Chooser:
74
+ """One list, one choice."""
75
+
76
+ def __init__(self, console: Console, theme: Theme, options: Sequence[Option],
77
+ title: str = "", default: int = 0) -> None:
78
+ self.console = console
79
+ self.theme = theme
80
+ self.options = list(options)
81
+ self.title = title
82
+ self.cursor = max(0, min(default, len(self.options) - 1))
83
+ self.filter = ""
84
+ self.offset = 0
85
+
86
+ # -- what is currently visible ---------------------------------------- #
87
+
88
+ @property
89
+ def matching(self) -> list[Option]:
90
+ if not self.filter:
91
+ return self.options
92
+ needle = self.filter.lower()
93
+ return [option for option in self.options if option.matches(needle)]
94
+
95
+ def rows(self) -> int:
96
+ """How many options fit, given the terminal we are in."""
97
+ available = max(MIN_ROWS, self.console.size.height - CHROME - 6)
98
+ return min(len(self.matching) or 1, available)
99
+
100
+ def _scroll_into_view(self) -> None:
101
+ window = self.rows()
102
+ if self.cursor < self.offset:
103
+ self.offset = self.cursor
104
+ elif self.cursor >= self.offset + window:
105
+ self.offset = self.cursor - window + 1
106
+ self.offset = max(0, min(self.offset, max(0, len(self.matching) - window)))
107
+
108
+ # -- drawing ----------------------------------------------------------- #
109
+
110
+ def render(self) -> RenderableType:
111
+ theme = self.theme
112
+ items = self.matching
113
+ window = self.rows()
114
+ self._scroll_into_view()
115
+ visible = items[self.offset:self.offset + window]
116
+
117
+ table = Table.grid(padding=(0, 1))
118
+ table.add_column(width=2, no_wrap=True)
119
+ table.add_column(no_wrap=True)
120
+ table.add_column(overflow="ellipsis")
121
+
122
+ if not items:
123
+ table.add_row("", Text("nothing matches", style=theme.style("bad")),
124
+ Text(""))
125
+
126
+ for index, option in enumerate(visible, start=self.offset):
127
+ chosen = index == self.cursor
128
+ # The arrow, not just a colour: a highlighted row that relies on
129
+ # background alone disappears on a terminal that renders it faintly,
130
+ # 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
+ )
139
+
140
+ blocks: list[RenderableType] = []
141
+ above = self.offset
142
+ below = max(0, len(items) - self.offset - window)
143
+ if above:
144
+ blocks.append(Text(f" {above} more above", style=theme.style("dim")))
145
+ blocks.append(table)
146
+ if below:
147
+ blocks.append(Text(f" {below} more below", style=theme.style("dim")))
148
+
149
+ subtitle = None
150
+ if self.filter:
151
+ subtitle = Text(f" filter: {self.filter} ", style=theme.style("accent"))
152
+ elif len(items) != len(self.options):
153
+ subtitle = Text(f" {len(items)} of {len(self.options)} ",
154
+ style=theme.style("dim"))
155
+
156
+ panel = Panel(
157
+ Group(*blocks),
158
+ box=self.theme.box,
159
+ border_style=theme.style("border"),
160
+ title=Text(f" {self.title} ", style=theme.style("title")) if self.title
161
+ else None,
162
+ title_align="left",
163
+ subtitle=subtitle,
164
+ subtitle_align="right",
165
+ padding=(0, 1),
166
+ )
167
+ return Group(panel, self._hint())
168
+
169
+ def _hint(self) -> Text:
170
+ theme = self.theme
171
+ hint = Text(" ", style=theme.style("dim"))
172
+ for key, what in (("↑↓", "move"), ("enter", "choose"),
173
+ ("type", "filter"), ("esc", "cancel")):
174
+ hint.append(key, style=theme.style("accent"))
175
+ hint.append(f" {what} ", style=theme.style("dim"))
176
+ return hint
177
+
178
+ # -- the loop ----------------------------------------------------------- #
179
+
180
+ def run(self) -> str | None:
181
+ """Returns the chosen value, or None if the user backed out."""
182
+ from rich.live import Live
183
+
184
+ items = self.matching
185
+ if not items:
186
+ return None
187
+
188
+ with TerminalInput(mouse=False, paste=False) as terminal, Live(
189
+ self.render(), console=self.console, auto_refresh=False,
190
+ transient=True,
191
+ ) as live:
192
+ while True:
193
+ event = terminal.wait(0.2)
194
+ if event is None:
195
+ continue
196
+ if not isinstance(event, KeyEvent):
197
+ continue
198
+
199
+ outcome = self._handle(event)
200
+ if outcome is _CANCEL:
201
+ return None
202
+ if outcome is not None:
203
+ return outcome
204
+ live.update(self.render(), refresh=True)
205
+
206
+ def _handle(self, event: KeyEvent) -> object:
207
+ """None to keep going, a string to accept it, ``_CANCEL`` to give up."""
208
+ items = self.matching
209
+
210
+ if event.matches("ctrl+c") or event.key == "escape":
211
+ return _CANCEL
212
+ if event.key == "enter":
213
+ return items[self.cursor].value if items else _CANCEL
214
+
215
+ if event.key in ("up", "down", "pgup", "pgdn", "home", "end"):
216
+ self._move(event.key, len(items))
217
+ return None
218
+ if event.key == "backspace":
219
+ self.filter = self.filter[:-1]
220
+ self._reset_cursor()
221
+ return None
222
+ if event.key == "char" and event.char and not event.ctrl and not event.alt:
223
+ self.filter += event.char
224
+ self._reset_cursor()
225
+ return None
226
+ return None
227
+
228
+ def _move(self, key: str, count: int) -> None:
229
+ if count == 0:
230
+ return
231
+ window = self.rows()
232
+ if key == "up":
233
+ # Wrapping, because a list that stops dead at the top makes you
234
+ # reach for the mouse to get to the bottom of a long one.
235
+ self.cursor = (self.cursor - 1) % count
236
+ elif key == "down":
237
+ self.cursor = (self.cursor + 1) % count
238
+ elif key == "pgup":
239
+ self.cursor = max(0, self.cursor - window)
240
+ elif key == "pgdn":
241
+ self.cursor = min(count - 1, self.cursor + window)
242
+ elif key == "home":
243
+ self.cursor = 0
244
+ elif key == "end":
245
+ self.cursor = count - 1
246
+
247
+ def _reset_cursor(self) -> None:
248
+ """A changed filter means a changed list; start at the top of it."""
249
+ self.cursor = 0
250
+ self.offset = 0
251
+
252
+
253
+ class _Cancel:
254
+ pass
255
+
256
+
257
+ _CANCEL = _Cancel()
258
+
259
+
260
+ def choose(console: Console, theme: Theme, options: Sequence[Option],
261
+ title: str = "", default: int = 0) -> str | None:
262
+ """The whole interaction, or None if there is no terminal to run it in."""
263
+ if not interactive(console) or not options:
264
+ return None
265
+ try:
266
+ return Chooser(console, theme, options, title=title, default=default).run()
267
+ except Exception:
268
+ # A terminal that will not do raw mode, a reader that will not start:
269
+ # the caller still has the numbered prompt, and a failed experiment
270
+ # must not cost somebody their first run.
271
+ return None
@@ -0,0 +1,208 @@
1
+ """Moving through a list with the arrow keys.
2
+
3
+ The behaviour that matters is not "does Enter return something" — it is what
4
+ happens when the list is longer than the terminal, which is the case this
5
+ replaces. So most of what is checked here is the window: that the cursor is
6
+ always inside it, that the counts above and below add up, and that filtering
7
+ does not leave the cursor pointing at a row that is no longer there.
8
+
9
+ The key handler is driven directly with `KeyEvent`s. Nothing here opens a
10
+ terminal, because the decoder that turns escape sequences into those events is
11
+ already tested on its own in `test_input.py`, and a test that needs a real
12
+ keyboard is a test that does not run in CI.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import pytest
18
+
19
+ from comodor.ui import theme as theme_module
20
+ from comodor.ui.chooser import Chooser, Option, choose
21
+ from comodor.ui.console import build
22
+ from comodor.ui.input.keys import KeyEvent
23
+
24
+
25
+ def make(count: int = 40, height: int = 24) -> Chooser:
26
+ theme = theme_module.load("ember")
27
+ console = build(theme, width=80, height=height)
28
+ options = [Option(f"m{i}", f"model-{i:02d}", "recommended" if i == 0 else "")
29
+ for i in range(count)]
30
+ return Chooser(console, theme, options, title="Models")
31
+
32
+
33
+ def press(chooser: Chooser, *keys: str) -> object:
34
+ outcome = None
35
+ for key in keys:
36
+ event = KeyEvent("char", char=key) if len(key) == 1 and key.isalnum() \
37
+ else KeyEvent(key)
38
+ outcome = chooser._handle(event)
39
+ chooser.render() # what the loop does between keystrokes
40
+ return outcome
41
+
42
+
43
+ # --------------------------------------------------------------------------- #
44
+ # the window
45
+ # --------------------------------------------------------------------------- #
46
+
47
+
48
+ def test_a_long_list_is_windowed_to_the_terminal():
49
+ chooser = make(count=200, height=24)
50
+ chooser.render()
51
+
52
+ assert chooser.rows() < 200
53
+ assert chooser.rows() <= 24
54
+
55
+
56
+ def test_the_cursor_never_leaves_the_window():
57
+ chooser = make(count=200, height=24)
58
+
59
+ for _ in range(150):
60
+ press(chooser, "down")
61
+ assert chooser.offset <= chooser.cursor < chooser.offset + chooser.rows()
62
+
63
+
64
+ def test_what_is_above_and_below_adds_up():
65
+ chooser = make(count=60, height=24)
66
+ press(chooser, *(["down"] * 30))
67
+ window = chooser.rows()
68
+
69
+ above = chooser.offset
70
+ below = len(chooser.matching) - chooser.offset - window
71
+
72
+ assert above + window + below == 60
73
+ assert above > 0 and below > 0
74
+
75
+
76
+ def test_the_ends_wrap():
77
+ """A list that stops dead at the top sends you looking for the mouse."""
78
+ chooser = make(count=10)
79
+
80
+ press(chooser, "up")
81
+ assert chooser.cursor == 9
82
+
83
+ press(chooser, "down")
84
+ assert chooser.cursor == 0
85
+
86
+
87
+ def test_home_and_end_and_the_page_keys():
88
+ chooser = make(count=100, height=24)
89
+
90
+ press(chooser, "end")
91
+ assert chooser.cursor == 99
92
+
93
+ press(chooser, "home")
94
+ assert chooser.cursor == 0
95
+
96
+ press(chooser, "pgdn")
97
+ assert chooser.cursor == chooser.rows()
98
+
99
+
100
+ def test_a_short_list_needs_no_window():
101
+ chooser = make(count=3, height=40)
102
+ chooser.render()
103
+
104
+ assert chooser.rows() == 3
105
+ assert chooser.offset == 0
106
+
107
+
108
+ # --------------------------------------------------------------------------- #
109
+ # filtering
110
+ # --------------------------------------------------------------------------- #
111
+
112
+
113
+ def test_typing_narrows_the_list():
114
+ chooser = make(count=40)
115
+
116
+ press(chooser, "3")
117
+ assert chooser.filter == "3"
118
+ # model-03, model-13, model-23, model-30..39
119
+ assert all("3" in option.label for option in chooser.matching)
120
+ assert 0 < len(chooser.matching) < 40
121
+
122
+
123
+ def test_backspace_widens_it_again():
124
+ chooser = make(count=40)
125
+ press(chooser, "3", "backspace")
126
+
127
+ assert chooser.filter == ""
128
+ assert len(chooser.matching) == 40
129
+
130
+
131
+ def test_a_changed_filter_puts_the_cursor_back_at_the_top():
132
+ """Otherwise it points at a row that is no longer in the list."""
133
+ chooser = make(count=40)
134
+ press(chooser, *(["down"] * 20))
135
+ assert chooser.cursor == 20
136
+
137
+ press(chooser, "7")
138
+
139
+ assert chooser.cursor == 0
140
+ assert chooser.cursor < len(chooser.matching)
141
+
142
+
143
+ def test_a_filter_that_matches_nothing_says_so_and_returns_nothing():
144
+ chooser = make(count=10)
145
+ press(chooser, "z", "z", "z")
146
+
147
+ assert chooser.matching == []
148
+ assert "nothing matches" in _text(chooser)
149
+ # Enter on an empty list cannot invent an answer.
150
+ from comodor.ui.chooser import _CANCEL
151
+
152
+ assert chooser._handle(KeyEvent("enter")) is _CANCEL
153
+
154
+
155
+ def test_the_filter_reads_the_note_as_well_as_the_label():
156
+ chooser = make(count=10)
157
+ press(chooser, "r", "e", "c")
158
+
159
+ assert [option.value for option in chooser.matching] == ["m0"]
160
+
161
+
162
+ # --------------------------------------------------------------------------- #
163
+ # choosing, and not choosing
164
+ # --------------------------------------------------------------------------- #
165
+
166
+
167
+ def test_enter_returns_the_row_under_the_cursor():
168
+ chooser = make(count=10)
169
+ press(chooser, "down", "down")
170
+
171
+ assert chooser._handle(KeyEvent("enter")) == "m2"
172
+
173
+
174
+ def test_enter_returns_the_row_under_the_cursor_after_filtering():
175
+ """The cursor indexes the filtered list, not the original one."""
176
+ chooser = make(count=40)
177
+ press(chooser, "1", "2")
178
+
179
+ assert chooser._handle(KeyEvent("enter")) == "m12"
180
+
181
+
182
+ @pytest.mark.parametrize("event", [KeyEvent("escape"), KeyEvent("c", ctrl=True)])
183
+ def test_backing_out_returns_nothing(event):
184
+ from comodor.ui.chooser import _CANCEL
185
+
186
+ assert make()._handle(event) is _CANCEL
187
+
188
+
189
+ def test_without_a_terminal_there_is_no_list_to_drive():
190
+ """The caller falls back to the numbered prompt, which always works."""
191
+ theme = theme_module.load("ember")
192
+ console = build(theme, width=80, height=24)
193
+
194
+ assert choose(console, theme, [Option("a", "A")], title="x") is None
195
+
196
+
197
+ def test_ctrl_and_alt_combinations_are_not_typed_into_the_filter():
198
+ chooser = make(count=10)
199
+ chooser._handle(KeyEvent("char", char="k", ctrl=True))
200
+
201
+ assert chooser.filter == ""
202
+
203
+
204
+ def _text(chooser: Chooser) -> str:
205
+ console = chooser.console
206
+ with console.capture() as captured:
207
+ console.print(chooser.render())
208
+ return captured.get()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes