batrachian-toad 0.5.22__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.
- batrachian_toad-0.5.22.dist-info/METADATA +197 -0
- batrachian_toad-0.5.22.dist-info/RECORD +120 -0
- batrachian_toad-0.5.22.dist-info/WHEEL +4 -0
- batrachian_toad-0.5.22.dist-info/entry_points.txt +2 -0
- batrachian_toad-0.5.22.dist-info/licenses/LICENSE +661 -0
- toad/__init__.py +46 -0
- toad/__main__.py +4 -0
- toad/_loop.py +86 -0
- toad/about.py +90 -0
- toad/acp/agent.py +671 -0
- toad/acp/api.py +47 -0
- toad/acp/encode_tool_call_id.py +12 -0
- toad/acp/messages.py +138 -0
- toad/acp/prompt.py +54 -0
- toad/acp/protocol.py +426 -0
- toad/agent.py +62 -0
- toad/agent_schema.py +70 -0
- toad/agents.py +45 -0
- toad/ansi/__init__.py +1 -0
- toad/ansi/_ansi.py +1612 -0
- toad/ansi/_ansi_colors.py +264 -0
- toad/ansi/_control_codes.py +37 -0
- toad/ansi/_keys.py +251 -0
- toad/ansi/_sgr_styles.py +64 -0
- toad/ansi/_stream_parser.py +418 -0
- toad/answer.py +22 -0
- toad/app.py +557 -0
- toad/atomic.py +37 -0
- toad/cli.py +257 -0
- toad/code_analyze.py +28 -0
- toad/complete.py +34 -0
- toad/constants.py +58 -0
- toad/conversation_markdown.py +19 -0
- toad/danger.py +371 -0
- toad/data/agents/ampcode.com.toml +51 -0
- toad/data/agents/augmentcode.com.toml +40 -0
- toad/data/agents/claude.com.toml +41 -0
- toad/data/agents/docker.com.toml +59 -0
- toad/data/agents/geminicli.com.toml +28 -0
- toad/data/agents/goose.ai.toml +51 -0
- toad/data/agents/inference.huggingface.co.toml +33 -0
- toad/data/agents/kimi.com.toml +35 -0
- toad/data/agents/openai.com.toml +53 -0
- toad/data/agents/opencode.ai.toml +61 -0
- toad/data/agents/openhands.dev.toml +44 -0
- toad/data/agents/stakpak.dev.toml +61 -0
- toad/data/agents/vibe.mistral.ai.toml +27 -0
- toad/data/agents/vtcode.dev.toml +62 -0
- toad/data/images/frog.png +0 -0
- toad/data/sounds/turn-over.wav +0 -0
- toad/db.py +5 -0
- toad/dec.py +332 -0
- toad/directory.py +234 -0
- toad/directory_watcher.py +96 -0
- toad/fuzzy.py +140 -0
- toad/gist.py +2 -0
- toad/history.py +138 -0
- toad/jsonrpc.py +576 -0
- toad/menus.py +14 -0
- toad/messages.py +74 -0
- toad/option_content.py +51 -0
- toad/os.py +0 -0
- toad/path_complete.py +145 -0
- toad/path_filter.py +124 -0
- toad/paths.py +71 -0
- toad/pill.py +23 -0
- toad/prompt/extract.py +19 -0
- toad/prompt/resource.py +68 -0
- toad/protocol.py +28 -0
- toad/screens/action_modal.py +94 -0
- toad/screens/agent_modal.py +172 -0
- toad/screens/command_edit_modal.py +58 -0
- toad/screens/main.py +192 -0
- toad/screens/permissions.py +390 -0
- toad/screens/permissions.tcss +72 -0
- toad/screens/settings.py +254 -0
- toad/screens/settings.tcss +101 -0
- toad/screens/store.py +476 -0
- toad/screens/store.tcss +261 -0
- toad/settings.py +354 -0
- toad/settings_schema.py +318 -0
- toad/shell.py +263 -0
- toad/shell_read.py +42 -0
- toad/slash_command.py +34 -0
- toad/toad.tcss +752 -0
- toad/version.py +80 -0
- toad/visuals/columns.py +273 -0
- toad/widgets/agent_response.py +79 -0
- toad/widgets/agent_thought.py +41 -0
- toad/widgets/command_pane.py +224 -0
- toad/widgets/condensed_path.py +93 -0
- toad/widgets/conversation.py +1626 -0
- toad/widgets/danger_warning.py +65 -0
- toad/widgets/diff_view.py +709 -0
- toad/widgets/flash.py +81 -0
- toad/widgets/future_text.py +126 -0
- toad/widgets/grid_select.py +223 -0
- toad/widgets/highlighted_textarea.py +180 -0
- toad/widgets/mandelbrot.py +294 -0
- toad/widgets/markdown_note.py +13 -0
- toad/widgets/menu.py +147 -0
- toad/widgets/non_selectable_label.py +5 -0
- toad/widgets/note.py +18 -0
- toad/widgets/path_search.py +381 -0
- toad/widgets/plan.py +180 -0
- toad/widgets/project_directory_tree.py +74 -0
- toad/widgets/prompt.py +741 -0
- toad/widgets/question.py +337 -0
- toad/widgets/shell_result.py +35 -0
- toad/widgets/shell_terminal.py +18 -0
- toad/widgets/side_bar.py +74 -0
- toad/widgets/slash_complete.py +211 -0
- toad/widgets/strike_text.py +66 -0
- toad/widgets/terminal.py +526 -0
- toad/widgets/terminal_tool.py +338 -0
- toad/widgets/throbber.py +90 -0
- toad/widgets/tool_call.py +303 -0
- toad/widgets/user_input.py +23 -0
- toad/widgets/version.py +5 -0
- toad/widgets/welcome.py +31 -0
toad/jsonrpc.py
ADDED
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from asyncio import Future, get_running_loop
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from functools import wraps
|
|
8
|
+
import inspect
|
|
9
|
+
from inspect import signature
|
|
10
|
+
from enum import IntEnum
|
|
11
|
+
import logging
|
|
12
|
+
from types import TracebackType
|
|
13
|
+
import weakref
|
|
14
|
+
|
|
15
|
+
import rich.repr
|
|
16
|
+
from typing import Callable, ParamSpec, TypeVar
|
|
17
|
+
from typeguard import check_type, CollectionCheckStrategy, TypeCheckError
|
|
18
|
+
|
|
19
|
+
import textual
|
|
20
|
+
|
|
21
|
+
type MethodType = Callable
|
|
22
|
+
type JSONValue = str | int | float | bool | None
|
|
23
|
+
type JSONType = dict[str, JSONType] | list[JSONType] | str | int | float | bool | None
|
|
24
|
+
type JSONObject = dict[str, JSONType]
|
|
25
|
+
type JSONList = list[JSONType]
|
|
26
|
+
|
|
27
|
+
log = logging.getLogger("jsonrpc")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def expose(name: str = "", prefix: str = ""):
|
|
31
|
+
"""Expose a method."""
|
|
32
|
+
|
|
33
|
+
def expose_method[T: Callable](callable: T) -> T:
|
|
34
|
+
setattr(callable, "_jsonrpc_expose", f"{prefix}{name or callable.__name__}")
|
|
35
|
+
return callable
|
|
36
|
+
|
|
37
|
+
return expose_method
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class NoDefault:
|
|
41
|
+
def __repr__(self) -> str:
|
|
42
|
+
return "NO_DEFAULT"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
NO_DEFAULT = NoDefault()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ErrorCode(IntEnum):
|
|
49
|
+
"""JSONRPC error codes"""
|
|
50
|
+
|
|
51
|
+
# https://www.jsonrpc.org/specification
|
|
52
|
+
PARSE_ERROR = -32700
|
|
53
|
+
INVALID_REQUEST = -32600
|
|
54
|
+
METHOD_NOT_FOUND = -32601
|
|
55
|
+
INVALID_PARAMS = -32602
|
|
56
|
+
INTERNAL_ERROR = -32603
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class Parameter:
|
|
61
|
+
type: type
|
|
62
|
+
default: JSONType | NoDefault
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class Method:
|
|
67
|
+
name: str
|
|
68
|
+
callable: Callable
|
|
69
|
+
parameters: dict[str, Parameter]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@rich.repr.auto
|
|
73
|
+
class JSONRPCError(Exception):
|
|
74
|
+
"""An error thrown by the JSONRPC system."""
|
|
75
|
+
|
|
76
|
+
CODE: ErrorCode = ErrorCode.INTERNAL_ERROR
|
|
77
|
+
"""Default code to use (may be overridden in the constructor)."""
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self, message: str, id: str | int | None = None, code: ErrorCode | None = None
|
|
81
|
+
) -> None:
|
|
82
|
+
self.message = message
|
|
83
|
+
self.id = id
|
|
84
|
+
self.code = code if code is not None else self.CODE
|
|
85
|
+
super().__init__(message)
|
|
86
|
+
|
|
87
|
+
def __rich_repr__(self) -> rich.repr.Result:
|
|
88
|
+
yield self.message
|
|
89
|
+
yield "id", self.id
|
|
90
|
+
yield "code", self.code
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class InvalidRequest(JSONRPCError):
|
|
94
|
+
CODE = ErrorCode.INVALID_REQUEST
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class InvalidParams(JSONRPCError):
|
|
98
|
+
CODE = ErrorCode.INVALID_PARAMS
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class MethodNotFound(JSONRPCError):
|
|
102
|
+
CODE = ErrorCode.METHOD_NOT_FOUND
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class InternalError(JSONRPCError):
|
|
106
|
+
CODE = ErrorCode.INTERNAL_ERROR
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@rich.repr.auto
|
|
110
|
+
class APIError(Exception):
|
|
111
|
+
def __init__(self, code: int, message: str, data: JSONType) -> None:
|
|
112
|
+
self.code = code
|
|
113
|
+
self.message = message
|
|
114
|
+
self.data = data
|
|
115
|
+
if data is None:
|
|
116
|
+
super().__init__(f"{message} ({code})")
|
|
117
|
+
else:
|
|
118
|
+
super().__init__(f"{message} ({code}); data={data!r}")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class Server:
|
|
122
|
+
def __init__(self) -> None:
|
|
123
|
+
self._methods: dict[str, Method] = {}
|
|
124
|
+
|
|
125
|
+
async def call(self, json: JSONObject | JSONList) -> JSONType:
|
|
126
|
+
if isinstance(json, dict):
|
|
127
|
+
# Single call
|
|
128
|
+
response = await self._dispatch_object(json)
|
|
129
|
+
else:
|
|
130
|
+
# Batch call
|
|
131
|
+
response = await self._dispatch_batch(json)
|
|
132
|
+
log.debug(f"OUT {response}")
|
|
133
|
+
return response
|
|
134
|
+
|
|
135
|
+
def expose_instance(self, instance: object) -> None:
|
|
136
|
+
"""Add methods from the given instance."""
|
|
137
|
+
for method_name in dir(instance):
|
|
138
|
+
method = getattr(instance, method_name)
|
|
139
|
+
if (jsonrpc_expose := getattr(method, "_jsonrpc_expose", None)) is not None:
|
|
140
|
+
self.method(jsonrpc_expose)(method)
|
|
141
|
+
|
|
142
|
+
async def _dispatch_object(self, json: JSONObject) -> JSONType | None:
|
|
143
|
+
json_id = json.get("id")
|
|
144
|
+
if isinstance(json_id, (int, str)):
|
|
145
|
+
request_id = json_id
|
|
146
|
+
else:
|
|
147
|
+
request_id = None
|
|
148
|
+
try:
|
|
149
|
+
return await self._dispatch_object_call(request_id, json)
|
|
150
|
+
except JSONRPCError as error:
|
|
151
|
+
return {
|
|
152
|
+
"jsonrpc": "2.0",
|
|
153
|
+
"id": error.id,
|
|
154
|
+
"error": {
|
|
155
|
+
"code": int(error.code),
|
|
156
|
+
"message": error.message,
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
except Exception as error:
|
|
160
|
+
log.exception("Error dispatching JSONRPC request")
|
|
161
|
+
return {
|
|
162
|
+
"jsonrpc": "2.0",
|
|
163
|
+
"id": request_id,
|
|
164
|
+
"error": {
|
|
165
|
+
"code": int(ErrorCode.INTERNAL_ERROR),
|
|
166
|
+
"message": f"An error occurred handling your request: {error!r}",
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async def _dispatch_object_call(
|
|
171
|
+
self, request_id: int | str | None, json: JSONObject
|
|
172
|
+
) -> JSONType | None:
|
|
173
|
+
"""Dispatch a JSONRPC call.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
request_id: The request ID.
|
|
177
|
+
json: JSON object with the remote call information.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
Result encoding in a JSONRPC result object, or `None` if the call is a notification
|
|
181
|
+
and doesn't require a result.
|
|
182
|
+
"""
|
|
183
|
+
if (jsonrpc := json.get("jsonrpc")) != "2.0":
|
|
184
|
+
raise InvalidRequest(
|
|
185
|
+
f"jsonrpc attribute should be '2.0'; found {jsonrpc!r}", id=request_id
|
|
186
|
+
)
|
|
187
|
+
if (method_name := json.get("method")) is None:
|
|
188
|
+
raise InvalidRequest(
|
|
189
|
+
"Invalid request; no value for 'method' given", id=request_id
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
if not isinstance(method_name, str):
|
|
193
|
+
raise InvalidRequest(
|
|
194
|
+
"Invalid request; 'method' should be a string type", id=request_id
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
if (method := self._methods.get(method_name)) is None:
|
|
198
|
+
raise MethodNotFound(
|
|
199
|
+
f"Method not found; {method_name!r} is not an exposed method",
|
|
200
|
+
id=request_id,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
no_params: JSONList = []
|
|
204
|
+
params = json.get("params", no_params)
|
|
205
|
+
|
|
206
|
+
if not isinstance(params, (list, dict)):
|
|
207
|
+
raise InvalidRequest(
|
|
208
|
+
"Invalid request; 'params' attribute should be a list or an object"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
arguments: dict[str, JSONType | Server | NoDefault] = {
|
|
212
|
+
name: parameter.default for name, parameter in method.parameters.items()
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
def validate(value: JSONType, parameter_type: type) -> None:
|
|
216
|
+
"""Validate types."""
|
|
217
|
+
try:
|
|
218
|
+
check_type(
|
|
219
|
+
value,
|
|
220
|
+
parameter_type,
|
|
221
|
+
collection_check_strategy=CollectionCheckStrategy.ALL_ITEMS,
|
|
222
|
+
)
|
|
223
|
+
except TypeCheckError as error:
|
|
224
|
+
raise InvalidParams(
|
|
225
|
+
f"Parameter is not the expected type ({parameter_type}); {error}",
|
|
226
|
+
id=request_id,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
if isinstance(params, list):
|
|
230
|
+
parameter_items = [
|
|
231
|
+
(name, parameter)
|
|
232
|
+
for name, parameter in method.parameters.items()
|
|
233
|
+
if not issubclass(parameter.type, Server)
|
|
234
|
+
]
|
|
235
|
+
for (parameter_name, parameter), value in zip(parameter_items, params):
|
|
236
|
+
if issubclass(parameter.type, Server):
|
|
237
|
+
value = self
|
|
238
|
+
else:
|
|
239
|
+
validate(value, parameter.type)
|
|
240
|
+
arguments[parameter_name] = value
|
|
241
|
+
else:
|
|
242
|
+
for parameter_name, value in params.items():
|
|
243
|
+
if parameter := method.parameters.get(parameter_name):
|
|
244
|
+
validate(value, parameter.type)
|
|
245
|
+
arguments[parameter_name] = value
|
|
246
|
+
|
|
247
|
+
for name, parameter in method.parameters.items():
|
|
248
|
+
if inspect.isclass(parameter.type) and issubclass(parameter.type, Server):
|
|
249
|
+
arguments[name] = self
|
|
250
|
+
|
|
251
|
+
try:
|
|
252
|
+
call_result = method.callable(**arguments)
|
|
253
|
+
if inspect.isawaitable(call_result):
|
|
254
|
+
result = await call_result
|
|
255
|
+
else:
|
|
256
|
+
result = call_result
|
|
257
|
+
except JSONRPCError as error:
|
|
258
|
+
error.id = request_id
|
|
259
|
+
raise error
|
|
260
|
+
except Exception as error:
|
|
261
|
+
# raise
|
|
262
|
+
# print("JSON error", repr(error))
|
|
263
|
+
# log.debug(f"Error in exposed JSONRPC method; {error}")
|
|
264
|
+
print("INTERNAL ERROR", error)
|
|
265
|
+
raise InternalError(str(error), id=request_id)
|
|
266
|
+
|
|
267
|
+
if request_id is None:
|
|
268
|
+
# Notification
|
|
269
|
+
return None
|
|
270
|
+
|
|
271
|
+
response_object = {"jsonrpc": "2.0", "result": result, "id": request_id}
|
|
272
|
+
return response_object
|
|
273
|
+
|
|
274
|
+
async def _dispatch_batch(self, json: JSONList) -> list[JSONType]:
|
|
275
|
+
batch_results: list[JSONType] = []
|
|
276
|
+
for request in json:
|
|
277
|
+
if not isinstance(request, dict):
|
|
278
|
+
continue
|
|
279
|
+
result = await self._dispatch_object(request)
|
|
280
|
+
if result is not None:
|
|
281
|
+
batch_results.append(result)
|
|
282
|
+
return batch_results
|
|
283
|
+
|
|
284
|
+
def process_callable(
|
|
285
|
+
self, callable: Callable[[MethodType], MethodType]
|
|
286
|
+
) -> Callable[[MethodType], MethodType]:
|
|
287
|
+
return callable
|
|
288
|
+
|
|
289
|
+
def method[MethodT: Callable](
|
|
290
|
+
self,
|
|
291
|
+
name: str = "",
|
|
292
|
+
*,
|
|
293
|
+
prefix: str = "",
|
|
294
|
+
) -> Callable[[MethodT], MethodT]:
|
|
295
|
+
"""Decorator to expose a method via JSONRPC.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
name: The name of the exposed method. Leave blank to auto-detect.
|
|
299
|
+
prefix: A prefix to be applied to the name.
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
Decorator.
|
|
303
|
+
"""
|
|
304
|
+
|
|
305
|
+
def expose_method[T: Callable](callable: T) -> T:
|
|
306
|
+
nonlocal name
|
|
307
|
+
if not name:
|
|
308
|
+
name = callable.__name__
|
|
309
|
+
name = f"{prefix}{name}"
|
|
310
|
+
|
|
311
|
+
parameters = {
|
|
312
|
+
name: Parameter(
|
|
313
|
+
(
|
|
314
|
+
eval(parameter.annotation)
|
|
315
|
+
if isinstance(parameter.annotation, str)
|
|
316
|
+
else parameter.annotation
|
|
317
|
+
),
|
|
318
|
+
(
|
|
319
|
+
NO_DEFAULT
|
|
320
|
+
if parameter.default is inspect._empty
|
|
321
|
+
else parameter.default
|
|
322
|
+
),
|
|
323
|
+
)
|
|
324
|
+
for name, parameter in signature(callable).parameters.items()
|
|
325
|
+
}
|
|
326
|
+
self._methods[name] = Method(name, callable, parameters)
|
|
327
|
+
return callable
|
|
328
|
+
|
|
329
|
+
return expose_method
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@rich.repr.auto
|
|
333
|
+
class MethodCall[ReturnType]:
|
|
334
|
+
def __init__(
|
|
335
|
+
self, method: str, id: int | None, parameters: dict[str, JSONType]
|
|
336
|
+
) -> None:
|
|
337
|
+
self.method = method
|
|
338
|
+
self.id = id
|
|
339
|
+
self.parameters = parameters
|
|
340
|
+
self.notification = False
|
|
341
|
+
self.future: Future[ReturnType] = get_running_loop().create_future()
|
|
342
|
+
|
|
343
|
+
def __rich_repr__(self) -> rich.repr.Result:
|
|
344
|
+
yield "method", self.method
|
|
345
|
+
yield "id", self.id, None
|
|
346
|
+
yield "parameters", self.parameters
|
|
347
|
+
yield "notification", self.notification, False
|
|
348
|
+
|
|
349
|
+
@property
|
|
350
|
+
def as_json_object(self) -> JSONType:
|
|
351
|
+
json: JSONType
|
|
352
|
+
if self.id is None:
|
|
353
|
+
json = {
|
|
354
|
+
"jsonrpc": "2.0",
|
|
355
|
+
"method": self.method,
|
|
356
|
+
"params": self.parameters,
|
|
357
|
+
}
|
|
358
|
+
else:
|
|
359
|
+
json = {
|
|
360
|
+
"jsonrpc": "2.0",
|
|
361
|
+
"method": self.method,
|
|
362
|
+
"params": self.parameters,
|
|
363
|
+
"id": self.id,
|
|
364
|
+
}
|
|
365
|
+
return json
|
|
366
|
+
|
|
367
|
+
async def wait(self, timeout: float | None = None) -> ReturnType | None:
|
|
368
|
+
if self.id is None:
|
|
369
|
+
return None
|
|
370
|
+
async with asyncio.timeout(timeout):
|
|
371
|
+
return await self.future
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
P = ParamSpec("P") # Captures parameter types
|
|
375
|
+
T = TypeVar("T") # Original return type
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
class Request:
|
|
379
|
+
def __init__(self, api: API, callback: Callable[[Request], None] | None) -> None:
|
|
380
|
+
self.api = api
|
|
381
|
+
self._calls: list[MethodCall] = []
|
|
382
|
+
self._callback = callback
|
|
383
|
+
|
|
384
|
+
def add_call(self, call: MethodCall) -> None:
|
|
385
|
+
self._calls.append(call)
|
|
386
|
+
|
|
387
|
+
def __enter__(self) -> Request:
|
|
388
|
+
self.api._requests.append(self)
|
|
389
|
+
return self
|
|
390
|
+
|
|
391
|
+
def __exit__(
|
|
392
|
+
self,
|
|
393
|
+
exc_type: type[BaseException] | None,
|
|
394
|
+
exc_val: type[BaseException],
|
|
395
|
+
exc_tb: TracebackType,
|
|
396
|
+
) -> None:
|
|
397
|
+
self.api._requests.pop()
|
|
398
|
+
if self._callback is not None:
|
|
399
|
+
self._callback(self)
|
|
400
|
+
|
|
401
|
+
@property
|
|
402
|
+
def body(self) -> JSONType | None:
|
|
403
|
+
"""The un-encoded JSON.
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
The body, or `None` if no calls present on the request.
|
|
407
|
+
"""
|
|
408
|
+
if not self._calls:
|
|
409
|
+
return None
|
|
410
|
+
calls = self._calls
|
|
411
|
+
if len(calls) == 1:
|
|
412
|
+
method_call = calls[0]
|
|
413
|
+
return method_call.as_json_object
|
|
414
|
+
else:
|
|
415
|
+
return [method_call.as_json_object for method_call in calls]
|
|
416
|
+
|
|
417
|
+
@property
|
|
418
|
+
def body_json(self) -> bytes:
|
|
419
|
+
"""Dump the body as encoded json."""
|
|
420
|
+
body_json = json.dumps(self.body).encode("utf-8")
|
|
421
|
+
return body_json
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
class API:
|
|
425
|
+
def __init__(self) -> None:
|
|
426
|
+
self._request_id = 0
|
|
427
|
+
self._requests: list[Request] = []
|
|
428
|
+
self._calls: weakref.WeakValueDictionary[int, MethodCall] = (
|
|
429
|
+
weakref.WeakValueDictionary()
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
def request(self, callback: Callable[[Request], None] | None = None) -> Request:
|
|
433
|
+
"""Create a Request context manager."""
|
|
434
|
+
request = Request(self, callback)
|
|
435
|
+
return request
|
|
436
|
+
|
|
437
|
+
def _process_method_response(self, response: JSONObject) -> None:
|
|
438
|
+
if (id := response.get("id")) is not None and isinstance(id, int):
|
|
439
|
+
if (method_call := self._calls.get(id)) is not None:
|
|
440
|
+
try:
|
|
441
|
+
result = response["result"]
|
|
442
|
+
except KeyError:
|
|
443
|
+
if (error := response.get("error")) is not None:
|
|
444
|
+
if isinstance(error, dict):
|
|
445
|
+
code = error.get("error", -1)
|
|
446
|
+
if not isinstance(code, int):
|
|
447
|
+
code = -1
|
|
448
|
+
message = str(error.get("message", "unknown error"))
|
|
449
|
+
data = error.get("data", None)
|
|
450
|
+
method_call.future.set_exception(
|
|
451
|
+
APIError(code, message, data)
|
|
452
|
+
)
|
|
453
|
+
else:
|
|
454
|
+
method_call.future.set_result(result)
|
|
455
|
+
|
|
456
|
+
def process_response(self, response: JSONType) -> None:
|
|
457
|
+
if isinstance(response, list):
|
|
458
|
+
for response_object in response:
|
|
459
|
+
if isinstance(response_object, dict):
|
|
460
|
+
self._process_method_response(response_object)
|
|
461
|
+
elif isinstance(response, dict):
|
|
462
|
+
self._process_method_response(response)
|
|
463
|
+
|
|
464
|
+
def method(
|
|
465
|
+
self, name: str = "", *, prefix: str = "", notification: bool = False
|
|
466
|
+
) -> Callable[[Callable[P, T]], Callable[P, MethodCall[T]]]:
|
|
467
|
+
"""Decorator to define a method.
|
|
468
|
+
|
|
469
|
+
Args:
|
|
470
|
+
name: Name of the method, or "" to auto-detect.
|
|
471
|
+
prefix: String to prefix the name.
|
|
472
|
+
|
|
473
|
+
Returns:
|
|
474
|
+
Decorator.
|
|
475
|
+
"""
|
|
476
|
+
|
|
477
|
+
def decorator(func: Callable[P, T]) -> Callable[P, MethodCall[T]]:
|
|
478
|
+
nonlocal name
|
|
479
|
+
if not name:
|
|
480
|
+
name = func.__name__
|
|
481
|
+
name = f"{prefix}{name}"
|
|
482
|
+
|
|
483
|
+
@wraps(func)
|
|
484
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> MethodCall[T]:
|
|
485
|
+
parameters = signature(func).parameters
|
|
486
|
+
call_parameters = {}
|
|
487
|
+
for arg, parameter_name in zip(args, parameters):
|
|
488
|
+
call_parameters[parameter_name] = arg
|
|
489
|
+
for parameter_name, arg in kwargs:
|
|
490
|
+
call_parameters[parameter_name] = arg
|
|
491
|
+
if notification:
|
|
492
|
+
method_call = MethodCall(name, None, call_parameters)
|
|
493
|
+
else:
|
|
494
|
+
self._request_id += 1
|
|
495
|
+
method_call = MethodCall(name, self._request_id, call_parameters)
|
|
496
|
+
self._requests[-1].add_call(method_call)
|
|
497
|
+
if method_call.id is not None:
|
|
498
|
+
self._calls[method_call.id] = method_call
|
|
499
|
+
return method_call
|
|
500
|
+
|
|
501
|
+
return wrapper
|
|
502
|
+
|
|
503
|
+
return decorator
|
|
504
|
+
|
|
505
|
+
def notification(
|
|
506
|
+
self,
|
|
507
|
+
name: str = "",
|
|
508
|
+
*,
|
|
509
|
+
prefix: str = "",
|
|
510
|
+
) -> Callable[[Callable[P, T]], Callable[P, MethodCall[T]]]:
|
|
511
|
+
return self.method(name, prefix=prefix, notification=True)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
if __name__ == "__main__":
|
|
515
|
+
from rich import print
|
|
516
|
+
|
|
517
|
+
server = Server()
|
|
518
|
+
|
|
519
|
+
@server.method()
|
|
520
|
+
def hello(name: str) -> str:
|
|
521
|
+
return f"hello {name}"
|
|
522
|
+
|
|
523
|
+
@server.method()
|
|
524
|
+
def add(server: Server, a: int, b: int) -> int:
|
|
525
|
+
print("SERVER", server)
|
|
526
|
+
return a + b
|
|
527
|
+
|
|
528
|
+
# print("!", add(1, 2))
|
|
529
|
+
print(server._methods)
|
|
530
|
+
|
|
531
|
+
# print(
|
|
532
|
+
# server.call(
|
|
533
|
+
# [
|
|
534
|
+
# {
|
|
535
|
+
# "jsonrpc": "2.0",
|
|
536
|
+
# "method": "hello",
|
|
537
|
+
# "params": {"name": "Will"},
|
|
538
|
+
# "id": "1",
|
|
539
|
+
# },
|
|
540
|
+
# {
|
|
541
|
+
# "jsonrpc": "2.0",
|
|
542
|
+
# "method": "add",
|
|
543
|
+
# "params": {"a": 10, "b": 20},
|
|
544
|
+
# "id": "2",
|
|
545
|
+
# },
|
|
546
|
+
# {"jsonrpc": "2.0", "method": "alert", "params": {"message": "Alert!"}},
|
|
547
|
+
# ]
|
|
548
|
+
# )
|
|
549
|
+
# )
|
|
550
|
+
|
|
551
|
+
async def test_proxy():
|
|
552
|
+
api = API()
|
|
553
|
+
|
|
554
|
+
@api.method()
|
|
555
|
+
def add(a: int, b: int) -> int: ...
|
|
556
|
+
|
|
557
|
+
@api.method()
|
|
558
|
+
def greet(name: str) -> str: ...
|
|
559
|
+
|
|
560
|
+
@api.notification()
|
|
561
|
+
def alert(text: str) -> None: ...
|
|
562
|
+
|
|
563
|
+
with api.request() as request:
|
|
564
|
+
add(2, 4)
|
|
565
|
+
greeting = greet("Will")
|
|
566
|
+
alert("test")
|
|
567
|
+
# add(1, "not a number")
|
|
568
|
+
|
|
569
|
+
# greeting = await greeting.wait()
|
|
570
|
+
# print(greeting)
|
|
571
|
+
|
|
572
|
+
print(greeting)
|
|
573
|
+
|
|
574
|
+
print(request.body)
|
|
575
|
+
|
|
576
|
+
asyncio.run(test_proxy())
|
toad/menus.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from typing import NamedTuple
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class MenuItem(NamedTuple):
|
|
5
|
+
"""An entry in a Menu."""
|
|
6
|
+
|
|
7
|
+
description: str
|
|
8
|
+
action: str | None
|
|
9
|
+
key: str | None = None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
CONVERSATION_MENUS: dict[str, list[MenuItem]] = {
|
|
13
|
+
"fence": [MenuItem("Run this code", "run", "r")]
|
|
14
|
+
}
|
toad/messages.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from textual.content import Content
|
|
6
|
+
from textual.widget import Widget
|
|
7
|
+
from textual.message import Message
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class WorkStarted(Message):
|
|
11
|
+
"""Work has started."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class WorkFinished(Message):
|
|
15
|
+
"""Work has finished."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class HistoryMove(Message):
|
|
20
|
+
"""Getting a new item form history."""
|
|
21
|
+
|
|
22
|
+
direction: Literal[-1, +1]
|
|
23
|
+
shell: bool
|
|
24
|
+
body: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class UserInputSubmitted(Message):
|
|
29
|
+
body: str
|
|
30
|
+
shell: bool = False
|
|
31
|
+
auto_complete: bool = False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class PromptSuggestion(Message):
|
|
36
|
+
suggestion: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Dismiss(Message):
|
|
41
|
+
widget: Widget
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def control(self) -> Widget:
|
|
45
|
+
return self.widget
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class InsertPath(Message):
|
|
50
|
+
path: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class ChangeMode(Message):
|
|
55
|
+
mode_id: str | None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class Flash(Message):
|
|
60
|
+
"""Request a message flash.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
Message: Content of flash.
|
|
64
|
+
style: Semantic style.
|
|
65
|
+
duration: Duration in seconds or `None` for default.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
content: str | Content
|
|
69
|
+
style: Literal["default", "warning", "success", "error"]
|
|
70
|
+
duration: float | None = None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ProjectDirectoryUpdated(Message):
|
|
74
|
+
"""The project directory may may changed."""
|
toad/option_content.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from textual.content import Content
|
|
2
|
+
from textual.css.styles import RulesMap
|
|
3
|
+
from textual.style import Style
|
|
4
|
+
from textual.visual import Visual, RenderOptions
|
|
5
|
+
from textual.strip import Strip
|
|
6
|
+
|
|
7
|
+
from itertools import zip_longest
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class OptionContent(Visual):
|
|
11
|
+
def __init__(self, option: str | Content, help: str | Content) -> None:
|
|
12
|
+
self.option = Content(option) if isinstance(option, str) else option
|
|
13
|
+
self.help = Content(help) if isinstance(help, str) else help
|
|
14
|
+
self._label = Content(f"{option} {help}")
|
|
15
|
+
|
|
16
|
+
def __str__(self) -> str:
|
|
17
|
+
return str(self.option)
|
|
18
|
+
|
|
19
|
+
def render_strips(
|
|
20
|
+
self, width: int, height: int | None, style: Style, options: RenderOptions
|
|
21
|
+
) -> list[Strip]:
|
|
22
|
+
option_strips = [
|
|
23
|
+
Strip(
|
|
24
|
+
self.option.render_segments(style), cell_length=self.option.cell_length
|
|
25
|
+
)
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
option_width = self.option.cell_length
|
|
29
|
+
remaining_width = width - self.option.cell_length
|
|
30
|
+
|
|
31
|
+
help_strips = self.help.render_strips(
|
|
32
|
+
remaining_width, None, style, options=options
|
|
33
|
+
)
|
|
34
|
+
help_width = max(strip.cell_length for strip in help_strips)
|
|
35
|
+
help_width = [strip.extend_cell_length(help_width) for strip in help_strips]
|
|
36
|
+
|
|
37
|
+
strips: list[Strip] = []
|
|
38
|
+
for option_strip, help_strip in zip_longest(option_strips, help_strips):
|
|
39
|
+
if option_strip is None:
|
|
40
|
+
option_strip = Strip.blank(option_width)
|
|
41
|
+
assert isinstance(help_strip, Strip)
|
|
42
|
+
strips.append(Strip.join([option_strip, help_strip]))
|
|
43
|
+
return strips
|
|
44
|
+
|
|
45
|
+
def get_optimal_width(self, rules: RulesMap, container_width: int) -> int:
|
|
46
|
+
return self._label.get_optimal_width(rules, container_width)
|
|
47
|
+
|
|
48
|
+
def get_height(self, rules: RulesMap, width: int) -> int:
|
|
49
|
+
label_width = self.option.cell_length + 1
|
|
50
|
+
height = self.help.get_height(rules, width - label_width)
|
|
51
|
+
return height
|