kiwimatecoder 0.1.0__py3-none-any.whl
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.
- kiwimatecoder/__init__.py +8 -0
- kiwimatecoder/__main__.py +12 -0
- kiwimatecoder/acp/__init__.py +13 -0
- kiwimatecoder/acp/protocol.py +115 -0
- kiwimatecoder/acp/server.py +823 -0
- kiwimatecoder/acp/stdio.py +32 -0
- kiwimatecoder/agent.py +1162 -0
- kiwimatecoder/ai.py +68 -0
- kiwimatecoder/audit.py +65 -0
- kiwimatecoder/browser.py +261 -0
- kiwimatecoder/catalog.py +445 -0
- kiwimatecoder/checkpoints.py +103 -0
- kiwimatecoder/client.py +820 -0
- kiwimatecoder/clipboard.py +99 -0
- kiwimatecoder/commands.py +6757 -0
- kiwimatecoder/config.py +5335 -0
- kiwimatecoder/diagnostics.py +295 -0
- kiwimatecoder/documents.py +299 -0
- kiwimatecoder/evals/__init__.py +39 -0
- kiwimatecoder/evals/cases.py +230 -0
- kiwimatecoder/evals/render.py +46 -0
- kiwimatecoder/evals/runner.py +319 -0
- kiwimatecoder/events.py +95 -0
- kiwimatecoder/forge.py +172 -0
- kiwimatecoder/git.py +164 -0
- kiwimatecoder/headless.py +255 -0
- kiwimatecoder/hooks.py +170 -0
- kiwimatecoder/hunks.py +260 -0
- kiwimatecoder/i18n.py +368 -0
- kiwimatecoder/images.py +195 -0
- kiwimatecoder/index/__init__.py +14 -0
- kiwimatecoder/index/builder.py +376 -0
- kiwimatecoder/index/embeddings.py +151 -0
- kiwimatecoder/index/search.py +247 -0
- kiwimatecoder/index/store.py +251 -0
- kiwimatecoder/instructions.py +83 -0
- kiwimatecoder/jobs.py +594 -0
- kiwimatecoder/lsp/__init__.py +56 -0
- kiwimatecoder/lsp/client.py +548 -0
- kiwimatecoder/lsp/manager.py +353 -0
- kiwimatecoder/lsp/protocol.py +79 -0
- kiwimatecoder/main.py +4258 -0
- kiwimatecoder/mcp/__init__.py +45 -0
- kiwimatecoder/mcp/client.py +385 -0
- kiwimatecoder/mcp/errors.py +7 -0
- kiwimatecoder/mcp/manager.py +253 -0
- kiwimatecoder/mcp/protocol.py +132 -0
- kiwimatecoder/media.py +904 -0
- kiwimatecoder/memory.py +141 -0
- kiwimatecoder/network.py +112 -0
- kiwimatecoder/notify.py +139 -0
- kiwimatecoder/permissions.py +171 -0
- kiwimatecoder/pickers.py +417 -0
- kiwimatecoder/plugins.py +245 -0
- kiwimatecoder/pricing.py +167 -0
- kiwimatecoder/prompts.py +213 -0
- kiwimatecoder/providers.py +342 -0
- kiwimatecoder/redaction.py +66 -0
- kiwimatecoder/remote.py +294 -0
- kiwimatecoder/repl.py +1388 -0
- kiwimatecoder/routing.py +65 -0
- kiwimatecoder/sandbox.py +169 -0
- kiwimatecoder/sdk.py +146 -0
- kiwimatecoder/session.py +574 -0
- kiwimatecoder/share.py +386 -0
- kiwimatecoder/shell.py +660 -0
- kiwimatecoder/skills.py +138 -0
- kiwimatecoder/sync.py +513 -0
- kiwimatecoder/team.py +249 -0
- kiwimatecoder/telemetry.py +392 -0
- kiwimatecoder/templates.py +108 -0
- kiwimatecoder/tools/__init__.py +190 -0
- kiwimatecoder/tools/ask.py +62 -0
- kiwimatecoder/tools/base.py +69 -0
- kiwimatecoder/tools/browser.py +261 -0
- kiwimatecoder/tools/edit_file.py +143 -0
- kiwimatecoder/tools/forge.py +207 -0
- kiwimatecoder/tools/git.py +158 -0
- kiwimatecoder/tools/image.py +300 -0
- kiwimatecoder/tools/io.py +346 -0
- kiwimatecoder/tools/list_dir.py +67 -0
- kiwimatecoder/tools/lsp.py +254 -0
- kiwimatecoder/tools/memory.py +115 -0
- kiwimatecoder/tools/paths.py +234 -0
- kiwimatecoder/tools/read_file.py +113 -0
- kiwimatecoder/tools/registry.py +91 -0
- kiwimatecoder/tools/run_bash.py +189 -0
- kiwimatecoder/tools/search.py +224 -0
- kiwimatecoder/tools/selection.py +108 -0
- kiwimatecoder/tools/shell.py +158 -0
- kiwimatecoder/tools/skill.py +46 -0
- kiwimatecoder/tools/task.py +61 -0
- kiwimatecoder/tools/todo.py +77 -0
- kiwimatecoder/tools/web.py +146 -0
- kiwimatecoder/tools/write_file.py +83 -0
- kiwimatecoder/ui.py +189 -0
- kiwimatecoder/updater.py +502 -0
- kiwimatecoder/web.py +499 -0
- kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
- kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
- kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
- kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
- kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
- kiwimatecoder-0.1.0.dist-info/top_level.txt +1 -0
kiwimatecoder/pickers.py
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
"""Grouped option pickers: ``choice``-style prompts split into framed boxes.
|
|
2
|
+
|
|
3
|
+
Provider pickers show the experimental providers (currently KiwiMate) at the
|
|
4
|
+
top in their own box titled "Experimental", above the normal list in a second
|
|
5
|
+
box. prompt_toolkit's ``choice`` and ``repl.checkbox_choice`` render a single
|
|
6
|
+
flat list, so this module draws each group of options in its own
|
|
7
|
+
:class:`~prompt_toolkit.widgets.Frame` while one flat cursor moves across all
|
|
8
|
+
of them as if they were a single list.
|
|
9
|
+
|
|
10
|
+
:func:`grouped_choice` picks one value (rows numbered continuously across
|
|
11
|
+
groups, digits 1-9 jump); :func:`grouped_checkbox_choice` picks several and
|
|
12
|
+
returns them in the order they were checked, so the first checked value can be
|
|
13
|
+
treated as the primary one.
|
|
14
|
+
|
|
15
|
+
The module only depends on prompt_toolkit so ``main`` can import it without
|
|
16
|
+
pulling in the REPL.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from collections.abc import Sequence
|
|
22
|
+
from functools import partial
|
|
23
|
+
from typing import Any, Generic, TypeAlias, TypeVar
|
|
24
|
+
|
|
25
|
+
from prompt_toolkit.application import Application, get_app
|
|
26
|
+
from prompt_toolkit.filters import Condition, is_done, renderer_height_is_known
|
|
27
|
+
from prompt_toolkit.formatted_text import (
|
|
28
|
+
AnyFormattedText,
|
|
29
|
+
StyleAndTextTuples,
|
|
30
|
+
fragment_list_to_text,
|
|
31
|
+
to_formatted_text,
|
|
32
|
+
)
|
|
33
|
+
from prompt_toolkit.input import Input
|
|
34
|
+
from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
|
|
35
|
+
from prompt_toolkit.keys import Keys
|
|
36
|
+
from prompt_toolkit.layout.containers import (
|
|
37
|
+
AnyContainer,
|
|
38
|
+
ConditionalContainer,
|
|
39
|
+
HSplit,
|
|
40
|
+
Window,
|
|
41
|
+
)
|
|
42
|
+
from prompt_toolkit.layout.controls import FormattedTextControl
|
|
43
|
+
from prompt_toolkit.layout.dimension import Dimension
|
|
44
|
+
from prompt_toolkit.layout.layout import Layout
|
|
45
|
+
from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
|
|
46
|
+
from prompt_toolkit.output import Output
|
|
47
|
+
from prompt_toolkit.shortcuts.choice_input import create_default_choice_input_style
|
|
48
|
+
from prompt_toolkit.styles import BaseStyle, Style, merge_styles
|
|
49
|
+
from prompt_toolkit.widgets import Box, Frame, Label
|
|
50
|
+
|
|
51
|
+
__all__ = ["OptionGroup", "grouped_checkbox_choice", "grouped_choice"]
|
|
52
|
+
|
|
53
|
+
_T = TypeVar("_T")
|
|
54
|
+
|
|
55
|
+
# Rows moved by PageUp/PageDown.
|
|
56
|
+
_PAGE_ROWS = 10
|
|
57
|
+
|
|
58
|
+
# ``(title, options)``: a titled group is drawn in its own titled box; an
|
|
59
|
+
# untitled one (``None``) in a plain box, or unframed when ``show_frame`` is off.
|
|
60
|
+
OptionGroup: TypeAlias = tuple[str | None, Sequence[tuple[_T, AnyFormattedText]]]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _GroupedPicker(Generic[_T]):
|
|
64
|
+
"""Flat cursor and check state spanning every group, plus one window per group."""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
groups: Sequence[OptionGroup[_T]],
|
|
69
|
+
*,
|
|
70
|
+
multiple: bool,
|
|
71
|
+
default_values: Sequence[_T] = (),
|
|
72
|
+
) -> None:
|
|
73
|
+
self.multiple = multiple
|
|
74
|
+
self.values: list[_T] = []
|
|
75
|
+
self.labels: list[AnyFormattedText] = []
|
|
76
|
+
# ``(title, start, end)`` per non-empty group; its rows are values[start:end].
|
|
77
|
+
self.groups: list[tuple[str | None, int, int]] = []
|
|
78
|
+
for title, options in groups:
|
|
79
|
+
start = len(self.values)
|
|
80
|
+
for value, label in options:
|
|
81
|
+
self.values.append(value)
|
|
82
|
+
self.labels.append(label)
|
|
83
|
+
# Tested after iterating: an empty generator is truthy but adds no rows.
|
|
84
|
+
if len(self.values) > start:
|
|
85
|
+
self.groups.append((title, start, len(self.values)))
|
|
86
|
+
|
|
87
|
+
present = [row for row in map(self._row_of, default_values) if row is not None]
|
|
88
|
+
self.cursor = present[0] if present else 0
|
|
89
|
+
# Checked rows in the order they were checked (multi-choice only).
|
|
90
|
+
self.checked: list[int] = list(dict.fromkeys(present)) if multiple else []
|
|
91
|
+
|
|
92
|
+
self.windows = [
|
|
93
|
+
Window(
|
|
94
|
+
FormattedTextControl(
|
|
95
|
+
partial(self._fragments, index), focusable=True, show_cursor=False
|
|
96
|
+
),
|
|
97
|
+
style="class:input-selection",
|
|
98
|
+
dont_extend_height=True,
|
|
99
|
+
)
|
|
100
|
+
for index in range(len(self.groups))
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
def _row_of(self, value: _T) -> int | None:
|
|
104
|
+
try:
|
|
105
|
+
return self.values.index(value)
|
|
106
|
+
except ValueError:
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def current_window(self) -> Window:
|
|
111
|
+
"""The window of the group holding the cursor."""
|
|
112
|
+
for window, (_, start, end) in zip(self.windows, self.groups):
|
|
113
|
+
if start <= self.cursor < end:
|
|
114
|
+
return window
|
|
115
|
+
return self.windows[0]
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def checked_values(self) -> list[_T]:
|
|
119
|
+
return [self.values[row] for row in self.checked]
|
|
120
|
+
|
|
121
|
+
def move_to(self, row: int) -> None:
|
|
122
|
+
"""Move the cursor (clamped, no wrap) and focus the window it lands in."""
|
|
123
|
+
self.cursor = max(0, min(len(self.values) - 1, row))
|
|
124
|
+
# Focusing the cursor's window keeps the terminal cursor on that row.
|
|
125
|
+
get_app().layout.focus(self.current_window)
|
|
126
|
+
|
|
127
|
+
def find(self, text: str) -> None:
|
|
128
|
+
"""Move to the next row whose label starts with ``text`` (type-to-find)."""
|
|
129
|
+
needle = text.lower()
|
|
130
|
+
if not needle.strip():
|
|
131
|
+
return
|
|
132
|
+
count = len(self.values)
|
|
133
|
+
for step in range(1, count + 1):
|
|
134
|
+
row = (self.cursor + step) % count
|
|
135
|
+
label = fragment_list_to_text(to_formatted_text(self.labels[row]))
|
|
136
|
+
if label.lower().startswith(needle):
|
|
137
|
+
self.move_to(row)
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
def toggle(self) -> None:
|
|
141
|
+
if self.cursor in self.checked:
|
|
142
|
+
self.checked.remove(self.cursor)
|
|
143
|
+
else:
|
|
144
|
+
self.checked.append(self.cursor)
|
|
145
|
+
|
|
146
|
+
def _click(self, row: int, mouse_event: MouseEvent) -> None:
|
|
147
|
+
if mouse_event.event_type == MouseEventType.MOUSE_UP:
|
|
148
|
+
self.move_to(row)
|
|
149
|
+
if self.multiple:
|
|
150
|
+
self.toggle()
|
|
151
|
+
|
|
152
|
+
def _fragments(self, index: int) -> StyleAndTextTuples:
|
|
153
|
+
_, start, end = self.groups[index]
|
|
154
|
+
result: StyleAndTextTuples = []
|
|
155
|
+
for row in range(start, end):
|
|
156
|
+
fragments: StyleAndTextTuples = []
|
|
157
|
+
if self.multiple:
|
|
158
|
+
self._checkbox_row(row, fragments)
|
|
159
|
+
else:
|
|
160
|
+
self._choice_row(row, fragments)
|
|
161
|
+
fragments.append(("", "\n"))
|
|
162
|
+
# A handler bound to the row itself (not the clicked line number)
|
|
163
|
+
# keeps clicks right when a label spans several lines.
|
|
164
|
+
handler = partial(self._click, row)
|
|
165
|
+
result.extend((fragment[0], fragment[1], handler) for fragment in fragments)
|
|
166
|
+
result.pop()
|
|
167
|
+
return result
|
|
168
|
+
|
|
169
|
+
def _choice_row(self, row: int, result: StyleAndTextTuples) -> None:
|
|
170
|
+
# Mirrors the RadioList rows drawn by prompt_toolkit's ``choice``.
|
|
171
|
+
selected = row == self.cursor
|
|
172
|
+
style = " class:selected-option" if selected else ""
|
|
173
|
+
if selected:
|
|
174
|
+
result.append(("[SetCursorPosition]", ""))
|
|
175
|
+
result.append((style, ">" if selected else " "))
|
|
176
|
+
result.append((f"{style} class:option", " "))
|
|
177
|
+
result.append((f"{style} class:number", f"{row + 1:2d}. "))
|
|
178
|
+
result.extend(to_formatted_text(self.labels[row], style=f"{style} class:option"))
|
|
179
|
+
|
|
180
|
+
def _checkbox_row(self, row: int, result: StyleAndTextTuples) -> None:
|
|
181
|
+
# Mirrors ``repl._CheckboxChoiceList`` rows.
|
|
182
|
+
checked = row in self.checked
|
|
183
|
+
selected = row == self.cursor
|
|
184
|
+
text_style = "class:option"
|
|
185
|
+
if checked:
|
|
186
|
+
text_style += " class:selected-option"
|
|
187
|
+
if selected:
|
|
188
|
+
text_style += " class:focused-option"
|
|
189
|
+
|
|
190
|
+
result.append(("class:cursor" if selected else "", "> " if selected else " "))
|
|
191
|
+
if selected:
|
|
192
|
+
result.append(("[SetCursorPosition]", ""))
|
|
193
|
+
result.append(
|
|
194
|
+
("class:checkbox-checked", "[*] ")
|
|
195
|
+
if checked
|
|
196
|
+
else ("class:checkbox-unchecked", "[ ] ")
|
|
197
|
+
)
|
|
198
|
+
result.extend(to_formatted_text(self.labels[row], style=text_style))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _picker_style(style: BaseStyle | None) -> BaseStyle:
|
|
202
|
+
"""``choice`` defaults, the checkbox colours and the titled-group colours."""
|
|
203
|
+
return merge_styles(
|
|
204
|
+
[
|
|
205
|
+
create_default_choice_input_style(),
|
|
206
|
+
Style.from_dict(
|
|
207
|
+
{
|
|
208
|
+
"cursor": "bold cyan",
|
|
209
|
+
"checkbox-checked": "bold green",
|
|
210
|
+
"checkbox-unchecked": "dim",
|
|
211
|
+
"focused-option": "bold",
|
|
212
|
+
# Frame styles its container "class:frame class:option-group"
|
|
213
|
+
# and its border/title windows "class:frame.border"/".label".
|
|
214
|
+
"option-group frame.border": "ansiyellow",
|
|
215
|
+
"option-group frame.label": "bold ansiyellow",
|
|
216
|
+
}
|
|
217
|
+
),
|
|
218
|
+
style if style is not None else Style([]),
|
|
219
|
+
]
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _build_application(
|
|
224
|
+
message: AnyFormattedText,
|
|
225
|
+
picker: _GroupedPicker[_T],
|
|
226
|
+
*,
|
|
227
|
+
bottom_toolbar: AnyFormattedText = None,
|
|
228
|
+
show_frame: bool = True,
|
|
229
|
+
mouse_support: bool = False,
|
|
230
|
+
style: BaseStyle | None = None,
|
|
231
|
+
input: Input | None = None,
|
|
232
|
+
output: Output | None = None,
|
|
233
|
+
) -> Application[Any]:
|
|
234
|
+
"""Lay out the message, one box per group and the toolbar; bind the keys."""
|
|
235
|
+
boxes: list[AnyContainer] = [
|
|
236
|
+
Box(
|
|
237
|
+
Label(text=message, dont_extend_height=True),
|
|
238
|
+
padding_top=0,
|
|
239
|
+
padding_left=1,
|
|
240
|
+
padding_right=1,
|
|
241
|
+
padding_bottom=0,
|
|
242
|
+
)
|
|
243
|
+
]
|
|
244
|
+
for window, (title, _, _) in zip(picker.windows, picker.groups):
|
|
245
|
+
if title or show_frame:
|
|
246
|
+
body = Box(window, padding_top=0, padding_left=1, padding_right=1, padding_bottom=0)
|
|
247
|
+
if title:
|
|
248
|
+
boxes.append(Frame(body, title=title, style="class:option-group"))
|
|
249
|
+
else:
|
|
250
|
+
boxes.append(Frame(body))
|
|
251
|
+
else:
|
|
252
|
+
# One extra column keeps the rows aligned with framed groups.
|
|
253
|
+
boxes.append(
|
|
254
|
+
Box(window, padding_top=0, padding_left=2, padding_right=1, padding_bottom=0)
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
show_bottom_toolbar = (
|
|
258
|
+
Condition(lambda: bottom_toolbar is not None) & ~is_done & renderer_height_is_known
|
|
259
|
+
)
|
|
260
|
+
toolbar_container = ConditionalContainer(
|
|
261
|
+
Window(
|
|
262
|
+
FormattedTextControl(lambda: bottom_toolbar, style="class:bottom-toolbar.text"),
|
|
263
|
+
style="class:bottom-toolbar",
|
|
264
|
+
dont_extend_height=True,
|
|
265
|
+
height=Dimension(min=1),
|
|
266
|
+
),
|
|
267
|
+
filter=show_bottom_toolbar,
|
|
268
|
+
)
|
|
269
|
+
layout = Layout(
|
|
270
|
+
HSplit(
|
|
271
|
+
[
|
|
272
|
+
HSplit(boxes),
|
|
273
|
+
ConditionalContainer(Window(), filter=show_bottom_toolbar),
|
|
274
|
+
toolbar_container,
|
|
275
|
+
]
|
|
276
|
+
),
|
|
277
|
+
focused_element=picker.current_window,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
# Bound at the application level so they work whichever group has focus.
|
|
281
|
+
kb = KeyBindings()
|
|
282
|
+
|
|
283
|
+
@kb.add("up")
|
|
284
|
+
@kb.add("k") # Vi-like.
|
|
285
|
+
def _up(event: KeyPressEvent) -> None:
|
|
286
|
+
picker.move_to(picker.cursor - 1)
|
|
287
|
+
|
|
288
|
+
@kb.add("down")
|
|
289
|
+
@kb.add("j") # Vi-like.
|
|
290
|
+
def _down(event: KeyPressEvent) -> None:
|
|
291
|
+
picker.move_to(picker.cursor + 1)
|
|
292
|
+
|
|
293
|
+
@kb.add("pageup")
|
|
294
|
+
def _page_up(event: KeyPressEvent) -> None:
|
|
295
|
+
picker.move_to(picker.cursor - _PAGE_ROWS)
|
|
296
|
+
|
|
297
|
+
@kb.add("pagedown")
|
|
298
|
+
def _page_down(event: KeyPressEvent) -> None:
|
|
299
|
+
picker.move_to(picker.cursor + _PAGE_ROWS)
|
|
300
|
+
|
|
301
|
+
@kb.add("home")
|
|
302
|
+
def _first(event: KeyPressEvent) -> None:
|
|
303
|
+
picker.move_to(0)
|
|
304
|
+
|
|
305
|
+
@kb.add("end")
|
|
306
|
+
def _last(event: KeyPressEvent) -> None:
|
|
307
|
+
picker.move_to(len(picker.values) - 1)
|
|
308
|
+
|
|
309
|
+
@kb.add(Keys.Any)
|
|
310
|
+
def _type_to_find(event: KeyPressEvent) -> None:
|
|
311
|
+
# Like prompt_toolkit's lists: a letter jumps to the next matching row.
|
|
312
|
+
picker.find(event.data)
|
|
313
|
+
|
|
314
|
+
if picker.multiple:
|
|
315
|
+
|
|
316
|
+
@kb.add(" ")
|
|
317
|
+
def _toggle(event: KeyPressEvent) -> None:
|
|
318
|
+
picker.toggle()
|
|
319
|
+
|
|
320
|
+
else:
|
|
321
|
+
for digit in range(1, 10):
|
|
322
|
+
|
|
323
|
+
@kb.add(str(digit))
|
|
324
|
+
def _jump(event: KeyPressEvent, row: int = digit - 1) -> None:
|
|
325
|
+
picker.move_to(row)
|
|
326
|
+
|
|
327
|
+
@kb.add("enter", eager=True)
|
|
328
|
+
def _accept_input(event: KeyPressEvent) -> None:
|
|
329
|
+
if picker.multiple:
|
|
330
|
+
event.app.exit(result=picker.checked_values, style="class:accepted")
|
|
331
|
+
else:
|
|
332
|
+
event.app.exit(result=picker.values[picker.cursor], style="class:accepted")
|
|
333
|
+
|
|
334
|
+
@kb.add("c-c", eager=True)
|
|
335
|
+
@kb.add("<sigint>", eager=True)
|
|
336
|
+
def _keyboard_interrupt(event: KeyPressEvent) -> None:
|
|
337
|
+
event.app.exit(exception=KeyboardInterrupt(), style="class:aborting")
|
|
338
|
+
|
|
339
|
+
return Application(
|
|
340
|
+
layout=layout,
|
|
341
|
+
full_screen=False,
|
|
342
|
+
mouse_support=mouse_support,
|
|
343
|
+
key_bindings=kb,
|
|
344
|
+
style=_picker_style(style),
|
|
345
|
+
input=input,
|
|
346
|
+
output=output,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def grouped_choice(
|
|
351
|
+
message: AnyFormattedText,
|
|
352
|
+
*,
|
|
353
|
+
groups: Sequence[OptionGroup[_T]],
|
|
354
|
+
default: _T | None = None,
|
|
355
|
+
bottom_toolbar: AnyFormattedText = None,
|
|
356
|
+
show_frame: bool = True,
|
|
357
|
+
mouse_support: bool = False,
|
|
358
|
+
style: BaseStyle | None = None,
|
|
359
|
+
input: Input | None = None,
|
|
360
|
+
output: Output | None = None,
|
|
361
|
+
) -> _T:
|
|
362
|
+
"""Single-choice prompt like ``choice`` whose options are split into boxes.
|
|
363
|
+
|
|
364
|
+
Empty groups are skipped. Raises ``ValueError`` when no group has options
|
|
365
|
+
and ``KeyboardInterrupt`` on Ctrl-C.
|
|
366
|
+
"""
|
|
367
|
+
picker = _GroupedPicker(
|
|
368
|
+
groups, multiple=False, default_values=[default] if default is not None else []
|
|
369
|
+
)
|
|
370
|
+
if not picker.values:
|
|
371
|
+
raise ValueError("grouped_choice() needs at least one option")
|
|
372
|
+
app: Application[_T] = _build_application(
|
|
373
|
+
message,
|
|
374
|
+
picker,
|
|
375
|
+
bottom_toolbar=bottom_toolbar,
|
|
376
|
+
show_frame=show_frame,
|
|
377
|
+
mouse_support=mouse_support,
|
|
378
|
+
style=style,
|
|
379
|
+
input=input,
|
|
380
|
+
output=output,
|
|
381
|
+
)
|
|
382
|
+
return app.run()
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def grouped_checkbox_choice(
|
|
386
|
+
message: AnyFormattedText,
|
|
387
|
+
*,
|
|
388
|
+
groups: Sequence[OptionGroup[_T]],
|
|
389
|
+
default_values: Sequence[_T] | None = None,
|
|
390
|
+
bottom_toolbar: AnyFormattedText = None,
|
|
391
|
+
show_frame: bool = True,
|
|
392
|
+
mouse_support: bool = False,
|
|
393
|
+
style: BaseStyle | None = None,
|
|
394
|
+
input: Input | None = None,
|
|
395
|
+
output: Output | None = None,
|
|
396
|
+
) -> list[_T]:
|
|
397
|
+
"""Checklist like ``repl.checkbox_choice`` whose options are split into boxes.
|
|
398
|
+
|
|
399
|
+
Returns the checked values in the order they were checked, starting with
|
|
400
|
+
``default_values`` in their given order. Empty groups are skipped; with no
|
|
401
|
+
options at all it returns ``[]`` without prompting. Raises
|
|
402
|
+
``KeyboardInterrupt`` on Ctrl-C.
|
|
403
|
+
"""
|
|
404
|
+
picker = _GroupedPicker(groups, multiple=True, default_values=default_values or ())
|
|
405
|
+
if not picker.values:
|
|
406
|
+
return []
|
|
407
|
+
app: Application[list[_T]] = _build_application(
|
|
408
|
+
message,
|
|
409
|
+
picker,
|
|
410
|
+
bottom_toolbar=bottom_toolbar,
|
|
411
|
+
show_frame=show_frame,
|
|
412
|
+
mouse_support=mouse_support,
|
|
413
|
+
style=style,
|
|
414
|
+
input=input,
|
|
415
|
+
output=output,
|
|
416
|
+
)
|
|
417
|
+
return app.run()
|
kiwimatecoder/plugins.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""External plugins: user and project Python files extending the CLI.
|
|
2
|
+
|
|
3
|
+
A plugin is a ``*.py`` file with a ``register(api)`` function. It can add
|
|
4
|
+
tools, slash commands, and event subscribers without touching core:
|
|
5
|
+
|
|
6
|
+
from kiwimatecoder.tools.base import FunctionTool, ToolResult
|
|
7
|
+
|
|
8
|
+
def register(api):
|
|
9
|
+
api.register_tool(FunctionTool(...))
|
|
10
|
+
api.register_command("hello", hello_handler, "Say hello.")
|
|
11
|
+
api.subscribe("post_tool", on_tool)
|
|
12
|
+
|
|
13
|
+
Plugins load from ``~/.kiwimatecoder/plugins/*.py`` always. Project plugins in
|
|
14
|
+
``<workspace>/.kiwimatecoder/plugins/*.py`` are disabled by default — cloning
|
|
15
|
+
a repository must never execute code just because the CLI started there — and
|
|
16
|
+
are enabled with ``"plugins": {"allow_project": true}``. A failed plugin is
|
|
17
|
+
recorded and skipped so it can never prevent startup.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import importlib.util
|
|
23
|
+
import sys
|
|
24
|
+
from collections.abc import Callable
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from types import ModuleType
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from rich.console import Console
|
|
31
|
+
from rich.markup import escape
|
|
32
|
+
|
|
33
|
+
from kiwimatecoder import commands, config, events, tools
|
|
34
|
+
from kiwimatecoder.events import EventBus, Subscriber
|
|
35
|
+
from kiwimatecoder.session import Session
|
|
36
|
+
from kiwimatecoder.tools.base import FunctionTool, ToolResult
|
|
37
|
+
from kiwimatecoder.tools.registry import PLUGIN_SOURCE
|
|
38
|
+
|
|
39
|
+
PLUGINS_DIR_NAME = "plugins"
|
|
40
|
+
PROJECT_PLUGINS_DIR = Path(".kiwimatecoder") / "plugins"
|
|
41
|
+
|
|
42
|
+
ToolFactory = Callable[[], FunctionTool]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class PluginLoadResult:
|
|
47
|
+
"""Outcome of one :func:`load_plugins` pass."""
|
|
48
|
+
|
|
49
|
+
loaded: list[str] = field(default_factory=list)
|
|
50
|
+
failed: list[tuple[str, str]] = field(default_factory=list)
|
|
51
|
+
skipped: list[str] = field(default_factory=list)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class PluginAPI:
|
|
55
|
+
"""The extension surface handed to each plugin's ``register(api)``.
|
|
56
|
+
|
|
57
|
+
Registrations are tracked so a partially loaded plugin can be cleaned up
|
|
58
|
+
and so :func:`unload_plugins` can reverse everything on reload.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
name: str,
|
|
64
|
+
console: Console | None = None,
|
|
65
|
+
bus: EventBus | None = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
self.name = name
|
|
68
|
+
self._console = console if console is not None else Console()
|
|
69
|
+
self._bus = bus if bus is not None else events.BUS
|
|
70
|
+
self._tools: list[str] = []
|
|
71
|
+
self._commands: list[str] = []
|
|
72
|
+
self._unsubscribes: list[Callable[[], None]] = []
|
|
73
|
+
|
|
74
|
+
def register_tool(
|
|
75
|
+
self,
|
|
76
|
+
tool: FunctionTool | ToolFactory | None = None,
|
|
77
|
+
*,
|
|
78
|
+
name: str | None = None,
|
|
79
|
+
description: str = "",
|
|
80
|
+
parameters: dict[str, Any] | None = None,
|
|
81
|
+
func: Callable[[dict[str, Any], Any], ToolResult] | None = None,
|
|
82
|
+
writes: bool = False,
|
|
83
|
+
runs: bool = False,
|
|
84
|
+
) -> FunctionTool:
|
|
85
|
+
"""Register a tool (an instance, a zero-arg factory, or keyword args)."""
|
|
86
|
+
resolved: Any = tool
|
|
87
|
+
if resolved is not None and not isinstance(resolved, FunctionTool):
|
|
88
|
+
if not callable(resolved):
|
|
89
|
+
raise ValueError(
|
|
90
|
+
"register_tool expects a FunctionTool, a factory, or keyword args."
|
|
91
|
+
)
|
|
92
|
+
resolved = resolved()
|
|
93
|
+
if resolved is None:
|
|
94
|
+
if not name or parameters is None or func is None:
|
|
95
|
+
raise ValueError(
|
|
96
|
+
"register_tool needs a FunctionTool or name, parameters, and func."
|
|
97
|
+
)
|
|
98
|
+
resolved = FunctionTool(
|
|
99
|
+
name=name,
|
|
100
|
+
description=description,
|
|
101
|
+
parameters=parameters,
|
|
102
|
+
func=func,
|
|
103
|
+
writes=writes,
|
|
104
|
+
runs=runs,
|
|
105
|
+
)
|
|
106
|
+
if not isinstance(resolved, FunctionTool):
|
|
107
|
+
raise ValueError("register_tool expects a FunctionTool instance.")
|
|
108
|
+
registered = tools.register_tool(resolved, source=PLUGIN_SOURCE)
|
|
109
|
+
self._tools.append(registered.name)
|
|
110
|
+
return registered
|
|
111
|
+
|
|
112
|
+
def register_command(
|
|
113
|
+
self,
|
|
114
|
+
name: str,
|
|
115
|
+
handler: Callable[[str, Session, Console], str],
|
|
116
|
+
description: str = "",
|
|
117
|
+
) -> str:
|
|
118
|
+
"""Register a slash command with the given handler."""
|
|
119
|
+
key = commands.register_command(name, handler, description)
|
|
120
|
+
self._commands.append(key)
|
|
121
|
+
return key
|
|
122
|
+
|
|
123
|
+
def subscribe(self, event: str, callback: Subscriber) -> Callable[[], None]:
|
|
124
|
+
"""Subscribe to an event on the loader's bus for the whole session."""
|
|
125
|
+
unsubscribe = self._bus.subscribe(event, callback)
|
|
126
|
+
self._unsubscribes.append(unsubscribe)
|
|
127
|
+
return unsubscribe
|
|
128
|
+
|
|
129
|
+
def log(self, message: str) -> None:
|
|
130
|
+
"""Print a dim status line through the loader's console."""
|
|
131
|
+
self._console.print(f"[dim]plugin {escape(self.name)}: {escape(message)}[/dim]")
|
|
132
|
+
|
|
133
|
+
def cleanup(self) -> None:
|
|
134
|
+
"""Undo every registration this plugin made."""
|
|
135
|
+
for name in self._tools:
|
|
136
|
+
tools.unregister_tool(name)
|
|
137
|
+
for name in self._commands:
|
|
138
|
+
commands.unregister_command(name)
|
|
139
|
+
for unsubscribe in self._unsubscribes:
|
|
140
|
+
unsubscribe()
|
|
141
|
+
self._tools.clear()
|
|
142
|
+
self._commands.clear()
|
|
143
|
+
self._unsubscribes.clear()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
_ACTIVE: dict[str, PluginAPI] = {}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def unload_plugins() -> None:
|
|
150
|
+
"""Unregister every plugin loaded by :func:`load_plugins`."""
|
|
151
|
+
for api in list(_ACTIVE.values()):
|
|
152
|
+
api.cleanup()
|
|
153
|
+
_ACTIVE.clear()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _plugin_files(directory: Path) -> list[Path]:
|
|
157
|
+
if not directory.is_dir():
|
|
158
|
+
return []
|
|
159
|
+
return [
|
|
160
|
+
path
|
|
161
|
+
for path in sorted(directory.glob("*.py"))
|
|
162
|
+
if path.is_file() and not path.name.startswith("_")
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _load_module(name: str, path: Path) -> ModuleType:
|
|
167
|
+
module_name = f"kiwimatecoder_plugin_{name}"
|
|
168
|
+
spec = importlib.util.spec_from_file_location(module_name, path)
|
|
169
|
+
if spec is None or spec.loader is None:
|
|
170
|
+
raise ImportError(f"cannot load plugin module from {path}")
|
|
171
|
+
module = importlib.util.module_from_spec(spec)
|
|
172
|
+
sys.modules[module_name] = module
|
|
173
|
+
try:
|
|
174
|
+
spec.loader.exec_module(module)
|
|
175
|
+
except BaseException:
|
|
176
|
+
sys.modules.pop(module_name, None)
|
|
177
|
+
raise
|
|
178
|
+
return module
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _load_one(
|
|
182
|
+
name: str,
|
|
183
|
+
path: Path,
|
|
184
|
+
console: Console | None,
|
|
185
|
+
bus: EventBus | None,
|
|
186
|
+
result: PluginLoadResult,
|
|
187
|
+
) -> None:
|
|
188
|
+
try:
|
|
189
|
+
module = _load_module(name, path)
|
|
190
|
+
except (Exception, SystemExit) as exc:
|
|
191
|
+
result.failed.append((name, f"import failed: {exc}"))
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
register = getattr(module, "register", None)
|
|
195
|
+
if not callable(register):
|
|
196
|
+
result.failed.append((name, "no register(api) function found"))
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
api = PluginAPI(name, console=console, bus=bus)
|
|
200
|
+
try:
|
|
201
|
+
register(api)
|
|
202
|
+
except (Exception, SystemExit) as exc:
|
|
203
|
+
api.cleanup()
|
|
204
|
+
result.failed.append((name, f"register failed: {exc}"))
|
|
205
|
+
return
|
|
206
|
+
_ACTIVE[str(path)] = api
|
|
207
|
+
result.loaded.append(name)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def load_plugins(
|
|
211
|
+
workspace_root: Path,
|
|
212
|
+
console: Console | None = None,
|
|
213
|
+
bus: EventBus | None = None,
|
|
214
|
+
) -> PluginLoadResult:
|
|
215
|
+
"""Load user plugins and, when opted in, project plugins. Never raises.
|
|
216
|
+
|
|
217
|
+
Reloading first unloads everything registered by a previous call so tools
|
|
218
|
+
and commands do not pile up across sessions.
|
|
219
|
+
"""
|
|
220
|
+
unload_plugins()
|
|
221
|
+
result = PluginLoadResult()
|
|
222
|
+
settings = config.get_plugins_config()
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
user_dir = config.ensure_config_dir() / PLUGINS_DIR_NAME
|
|
226
|
+
except OSError:
|
|
227
|
+
# An unwritable home directory just means no user-level plugins.
|
|
228
|
+
user_dir = None
|
|
229
|
+
planned: list[tuple[str, Path]] = (
|
|
230
|
+
[(path.stem, path) for path in _plugin_files(user_dir)] if user_dir else []
|
|
231
|
+
)
|
|
232
|
+
project_dir = workspace_root / PROJECT_PLUGINS_DIR
|
|
233
|
+
project_files = _plugin_files(project_dir)
|
|
234
|
+
if settings["allow_project"]:
|
|
235
|
+
planned.extend((path.stem, path) for path in project_files)
|
|
236
|
+
else:
|
|
237
|
+
result.skipped.extend(path.stem for path in project_files)
|
|
238
|
+
|
|
239
|
+
disabled = set(settings["disabled"])
|
|
240
|
+
for name, path in planned:
|
|
241
|
+
if name in disabled:
|
|
242
|
+
result.skipped.append(name)
|
|
243
|
+
continue
|
|
244
|
+
_load_one(name, path, console, bus, result)
|
|
245
|
+
return result
|