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.
- {comodor-0.2.1 → comodor-0.2.2}/.gitignore +4 -0
- {comodor-0.2.1 → comodor-0.2.2}/PKG-INFO +1 -1
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/_version.py +2 -2
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/setup.py +100 -24
- comodor-0.2.2/src/comodor/ui/chooser.py +271 -0
- comodor-0.2.2/tests/test_chooser.py +208 -0
- {comodor-0.2.1 → comodor-0.2.2}/LICENSE +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/README.md +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/pyproject.toml +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/__init__.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/__main__.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/agent/__init__.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/agent/context.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/agent/loop.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/agent/prompts.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/agent/tokens.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/catalogue.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/cli.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/config.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/doctor.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/events.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/learning/__init__.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/learning/bm25.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/learning/hotindex.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/learning/memory.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/learning/progress.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/learning/reflect.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/learning/rules.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/learning/signals.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/learning/store.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/learning/writer.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/mcp/__init__.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/mcp/catalogue.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/mcp/commands.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/mcp/manager.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/mcp/protocol.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/net/__init__.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/net/http.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/net/sse.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/paths.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/providers/__init__.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/providers/anthropic.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/providers/base.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/providers/fake.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/providers/gateway.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/providers/openai_compat.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/providers/registry.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/safety/__init__.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/safety/checkpoints.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/safety/permissions.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/safety/redact.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/session/__init__.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/session/search.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/session/store.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/skills/__init__.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/skills/examples.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/skills/loader.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/skills/propose.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/skills/registry.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/tools/__init__.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/tools/base.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/tools/fs.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/tools/history.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/tools/mcp.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/tools/registry.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/tools/search.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/tools/shell.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/tools/skills.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/tools/todo.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/tools/web.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/ui/__init__.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/ui/app.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/ui/console.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/ui/input/__init__.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/ui/input/keys.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/ui/input/reader.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/ui/layout.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/ui/markdown.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/ui/screen.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/ui/theme.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/ui/widgets/__init__.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/ui/widgets/buttons.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/ui/widgets/chat.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/ui/widgets/history.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/ui/widgets/overlay.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/ui/widgets/panel.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/ui/widgets/progress.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/ui/widgets/prompt.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/ui/widgets/statusbar.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/ui/widgets/toast.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/src/comodor/uninstall.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/tests/conftest.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/tests/support/fake_mcp_server.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/tests/test_agent_loop.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/tests/test_app.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/tests/test_doctor.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/tests/test_history.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/tests/test_input.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/tests/test_layout.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/tests/test_learning.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/tests/test_markdown.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/tests/test_mcp.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/tests/test_performance.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/tests/test_progress.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/tests/test_propose.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/tests/test_providers.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/tests/test_reflex.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/tests/test_run_loop.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/tests/test_setup.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/tests/test_skills.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/tests/test_tools.py +0 -0
- {comodor-0.2.1 → comodor-0.2.2}/tests/test_uninstall.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.2.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 2,
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
73
|
-
"""
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|