pidevkit-coding-agent 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. pidevkit_coding_agent-0.1.0/.gitignore +15 -0
  2. pidevkit_coding_agent-0.1.0/PKG-INFO +26 -0
  3. pidevkit_coding_agent-0.1.0/README.md +15 -0
  4. pidevkit_coding_agent-0.1.0/pyproject.toml +19 -0
  5. pidevkit_coding_agent-0.1.0/src/pidevkit/coding_agent/__init__.py +0 -0
  6. pidevkit_coding_agent-0.1.0/src/pidevkit/coding_agent/cli/__init__.py +0 -0
  7. pidevkit_coding_agent-0.1.0/src/pidevkit/coding_agent/cli/args.py +141 -0
  8. pidevkit_coding_agent-0.1.0/src/pidevkit/coding_agent/core/__init__.py +0 -0
  9. pidevkit_coding_agent-0.1.0/src/pidevkit/coding_agent/core/auth_storage.py +289 -0
  10. pidevkit_coding_agent-0.1.0/src/pidevkit/coding_agent/core/diagnostics.py +17 -0
  11. pidevkit_coding_agent-0.1.0/src/pidevkit/coding_agent/core/extensions_input_event.py +120 -0
  12. pidevkit_coding_agent-0.1.0/src/pidevkit/coding_agent/core/extensions_runner.py +157 -0
  13. pidevkit_coding_agent-0.1.0/src/pidevkit/coding_agent/core/image_processing.py +341 -0
  14. pidevkit_coding_agent-0.1.0/src/pidevkit/coding_agent/core/model_registry.py +301 -0
  15. pidevkit_coding_agent-0.1.0/src/pidevkit/coding_agent/core/model_resolver.py +353 -0
  16. pidevkit_coding_agent-0.1.0/src/pidevkit/coding_agent/core/package_manager.py +61 -0
  17. pidevkit_coding_agent-0.1.0/src/pidevkit/coding_agent/core/prompt_guard.py +27 -0
  18. pidevkit_coding_agent-0.1.0/src/pidevkit/coding_agent/core/prompt_templates.py +265 -0
  19. pidevkit_coding_agent-0.1.0/src/pidevkit/coding_agent/core/resolve_config_value.py +81 -0
  20. pidevkit_coding_agent-0.1.0/src/pidevkit/coding_agent/core/resource_loader.py +503 -0
  21. pidevkit_coding_agent-0.1.0/src/pidevkit/coding_agent/core/rpc.py +77 -0
  22. pidevkit_coding_agent-0.1.0/src/pidevkit/coding_agent/core/session_manager.py +636 -0
  23. pidevkit_coding_agent-0.1.0/src/pidevkit/coding_agent/core/settings_manager.py +382 -0
  24. pidevkit_coding_agent-0.1.0/src/pidevkit/coding_agent/core/skills.py +254 -0
  25. pidevkit_coding_agent-0.1.0/src/pidevkit/coding_agent/core/system_prompt.py +172 -0
  26. pidevkit_coding_agent-0.1.0/src/pidevkit/coding_agent/core/tools/__init__.py +0 -0
  27. pidevkit_coding_agent-0.1.0/src/pidevkit/coding_agent/core/tools/path_utils.py +74 -0
  28. pidevkit_coding_agent-0.1.0/src/pidevkit/coding_agent/examples/__init__.py +0 -0
  29. pidevkit_coding_agent-0.1.0/src/pidevkit/coding_agent/examples/extensions/__init__.py +0 -0
  30. pidevkit_coding_agent-0.1.0/src/pidevkit/coding_agent/examples/extensions/plan_mode/__init__.py +0 -0
  31. pidevkit_coding_agent-0.1.0/src/pidevkit/coding_agent/examples/extensions/plan_mode/utils.py +166 -0
  32. pidevkit_coding_agent-0.1.0/src/pidevkit/coding_agent/modes/__init__.py +1 -0
  33. pidevkit_coding_agent-0.1.0/src/pidevkit/coding_agent/modes/interactive/__init__.py +1 -0
  34. pidevkit_coding_agent-0.1.0/src/pidevkit/coding_agent/modes/interactive/components/__init__.py +25 -0
  35. pidevkit_coding_agent-0.1.0/src/pidevkit/coding_agent/modes/interactive/components/session_selector_search.py +205 -0
  36. pidevkit_coding_agent-0.1.0/src/pidevkit/coding_agent/modes/interactive/interactive_mode_status.py +56 -0
  37. pidevkit_coding_agent-0.1.0/src/pidevkit/coding_agent/utils/__init__.py +0 -0
  38. pidevkit_coding_agent-0.1.0/src/pidevkit/coding_agent/utils/clipboard_image.py +174 -0
  39. pidevkit_coding_agent-0.1.0/src/pidevkit/coding_agent/utils/frontmatter.py +36 -0
  40. pidevkit_coding_agent-0.1.0/src/pidevkit/coding_agent/utils/git.py +118 -0
@@ -0,0 +1,15 @@
1
+ __pycache__/
2
+ .pytest_cache/
3
+ .ruff_cache/
4
+ .venv/
5
+ *.pyc
6
+ *.pyo
7
+ *.pyd
8
+ .coverage
9
+ .coverage.*
10
+ htmlcov/
11
+ dist/
12
+ build/
13
+ *.egg-info/
14
+ .mypy_cache/
15
+ .DS_Store
@@ -0,0 +1,26 @@
1
+ Metadata-Version: 2.4
2
+ Name: pidevkit-coding-agent
3
+ Version: 0.1.0
4
+ Summary: Coding-agent implementation for pidevkit
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: pidevkit-ai>=0.1.0
7
+ Requires-Dist: pidevkit-tui>=0.1.0
8
+ Requires-Dist: pillow>=12.0.0
9
+ Requires-Dist: pyyaml>=6.0
10
+ Description-Content-Type: text/markdown
11
+
12
+ # pidevkit-coding-agent
13
+
14
+ Port of the coding-agent subsystem, including resource loading, session handling, extension hooks, and utilities.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pip install pidevkit-coding-agent
20
+ ```
21
+
22
+ ## Import
23
+
24
+ ```python
25
+ from pidevkit.coding_agent.core.session_manager import SessionManager
26
+ ```
@@ -0,0 +1,15 @@
1
+ # pidevkit-coding-agent
2
+
3
+ Port of the coding-agent subsystem, including resource loading, session handling, extension hooks, and utilities.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install pidevkit-coding-agent
9
+ ```
10
+
11
+ ## Import
12
+
13
+ ```python
14
+ from pidevkit.coding_agent.core.session_manager import SessionManager
15
+ ```
@@ -0,0 +1,19 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.24.0"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pidevkit-coding-agent"
7
+ version = "0.1.0"
8
+ description = "Coding-agent implementation for pidevkit"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ dependencies = [
12
+ "pidevkit-ai>=0.1.0",
13
+ "pidevkit-tui>=0.1.0",
14
+ "Pillow>=12.0.0",
15
+ "PyYAML>=6.0",
16
+ ]
17
+
18
+ [tool.hatch.build.targets.wheel]
19
+ packages = ["src/pidevkit/coding_agent"]
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Literal, Mapping
4
+
5
+ Mode = Literal["text", "json", "rpc"]
6
+ ThinkingLevel = Literal["off", "minimal", "low", "medium", "high", "xhigh"]
7
+ ToolName = Literal["read", "bash", "edit", "write", "grep", "find", "ls"]
8
+
9
+ VALID_THINKING_LEVELS: tuple[ThinkingLevel, ...] = ("off", "minimal", "low", "medium", "high", "xhigh")
10
+ VALID_TOOLS: tuple[ToolName, ...] = ("read", "bash", "edit", "write", "grep", "find", "ls")
11
+
12
+
13
+ def is_valid_thinking_level(level: str) -> bool:
14
+ return level in VALID_THINKING_LEVELS
15
+
16
+
17
+ def parse_args(
18
+ args: list[str],
19
+ extension_flags: Mapping[str, Mapping[str, Literal["boolean", "string"]]] | None = None,
20
+ ) -> dict[str, Any]:
21
+ """Parse coding-agent CLI args with compatibility to the TypeScript parser."""
22
+
23
+ result: dict[str, Any] = {
24
+ "messages": [],
25
+ "fileArgs": [],
26
+ "unknownFlags": {},
27
+ }
28
+
29
+ i = 0
30
+ while i < len(args):
31
+ arg = args[i]
32
+
33
+ if arg in {"--help", "-h"}:
34
+ result["help"] = True
35
+ elif arg in {"--version", "-v"}:
36
+ result["version"] = True
37
+ elif arg == "--mode" and i + 1 < len(args):
38
+ i += 1
39
+ mode = args[i]
40
+ if mode in {"text", "json", "rpc"}:
41
+ result["mode"] = mode
42
+ elif arg in {"--continue", "-c"}:
43
+ result["continue"] = True
44
+ elif arg in {"--resume", "-r"}:
45
+ result["resume"] = True
46
+ elif arg == "--provider" and i + 1 < len(args):
47
+ i += 1
48
+ result["provider"] = args[i]
49
+ elif arg == "--model" and i + 1 < len(args):
50
+ i += 1
51
+ result["model"] = args[i]
52
+ elif arg == "--api-key" and i + 1 < len(args):
53
+ i += 1
54
+ result["apiKey"] = args[i]
55
+ elif arg == "--system-prompt" and i + 1 < len(args):
56
+ i += 1
57
+ result["systemPrompt"] = args[i]
58
+ elif arg == "--append-system-prompt" and i + 1 < len(args):
59
+ i += 1
60
+ result["appendSystemPrompt"] = args[i]
61
+ elif arg == "--no-session":
62
+ result["noSession"] = True
63
+ elif arg == "--session" and i + 1 < len(args):
64
+ i += 1
65
+ result["session"] = args[i]
66
+ elif arg == "--session-dir" and i + 1 < len(args):
67
+ i += 1
68
+ result["sessionDir"] = args[i]
69
+ elif arg == "--models" and i + 1 < len(args):
70
+ i += 1
71
+ result["models"] = [part.strip() for part in args[i].split(",")]
72
+ elif arg == "--no-tools":
73
+ result["noTools"] = True
74
+ elif arg == "--tools" and i + 1 < len(args):
75
+ i += 1
76
+ tool_names = [part.strip() for part in args[i].split(",")]
77
+ result["tools"] = [name for name in tool_names if name in VALID_TOOLS]
78
+ elif arg == "--thinking" and i + 1 < len(args):
79
+ i += 1
80
+ level = args[i]
81
+ if is_valid_thinking_level(level):
82
+ result["thinking"] = level
83
+ elif arg in {"--print", "-p"}:
84
+ result["print"] = True
85
+ elif arg == "--export" and i + 1 < len(args):
86
+ i += 1
87
+ result["export"] = args[i]
88
+ elif arg in {"--extension", "-e"} and i + 1 < len(args):
89
+ i += 1
90
+ extensions: list[str] = result.setdefault("extensions", [])
91
+ extensions.append(args[i])
92
+ elif arg in {"--no-extensions", "-ne"}:
93
+ result["noExtensions"] = True
94
+ elif arg == "--skill" and i + 1 < len(args):
95
+ i += 1
96
+ skills: list[str] = result.setdefault("skills", [])
97
+ skills.append(args[i])
98
+ elif arg == "--prompt-template" and i + 1 < len(args):
99
+ i += 1
100
+ prompt_templates: list[str] = result.setdefault("promptTemplates", [])
101
+ prompt_templates.append(args[i])
102
+ elif arg == "--theme" and i + 1 < len(args):
103
+ i += 1
104
+ themes: list[str] = result.setdefault("themes", [])
105
+ themes.append(args[i])
106
+ elif arg in {"--no-skills", "-ns"}:
107
+ result["noSkills"] = True
108
+ elif arg in {"--no-prompt-templates", "-np"}:
109
+ result["noPromptTemplates"] = True
110
+ elif arg == "--no-themes":
111
+ result["noThemes"] = True
112
+ elif arg == "--list-models":
113
+ if i + 1 < len(args) and not args[i + 1].startswith("-") and not args[i + 1].startswith("@"):
114
+ i += 1
115
+ result["listModels"] = args[i]
116
+ else:
117
+ result["listModels"] = True
118
+ elif arg == "--verbose":
119
+ result["verbose"] = True
120
+ elif arg.startswith("@"):
121
+ file_args: list[str] = result["fileArgs"]
122
+ file_args.append(arg[1:])
123
+ elif arg.startswith("--") and extension_flags is not None:
124
+ flag_name = arg[2:]
125
+ ext_flag = extension_flags.get(flag_name)
126
+ if ext_flag:
127
+ flag_type = ext_flag.get("type")
128
+ if flag_type == "boolean":
129
+ unknown_flags: dict[str, bool | str] = result["unknownFlags"]
130
+ unknown_flags[flag_name] = True
131
+ elif flag_type == "string" and i + 1 < len(args):
132
+ i += 1
133
+ unknown_flags = result["unknownFlags"]
134
+ unknown_flags[flag_name] = args[i]
135
+ elif not arg.startswith("-"):
136
+ messages: list[str] = result["messages"]
137
+ messages.append(arg)
138
+
139
+ i += 1
140
+
141
+ return result
@@ -0,0 +1,289 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from threading import RLock
8
+ from typing import Any, Callable, Literal, TypedDict
9
+
10
+ from pidevkit.ai.stream import get_env_api_key
11
+
12
+ from .resolve_config_value import resolve_config_value
13
+
14
+ CredentialType = Literal["api_key", "oauth"]
15
+
16
+
17
+ class ApiKeyCredential(TypedDict):
18
+ type: Literal["api_key"]
19
+ key: str
20
+
21
+
22
+ class OAuthCredential(TypedDict, total=False):
23
+ type: Literal["oauth"]
24
+ refresh: str
25
+ access: str
26
+ expires: int
27
+
28
+
29
+ AuthCredential = ApiKeyCredential | OAuthCredential
30
+ AuthStorageData = dict[str, AuthCredential]
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class LockResult:
35
+ result: Any
36
+ next: str | None = None
37
+
38
+
39
+ class AuthStorageBackend:
40
+ def with_lock(self, fn: Callable[[str | None], LockResult]) -> Any:
41
+ raise NotImplementedError
42
+
43
+ async def with_lock_async(self, fn: Callable[[str | None], asyncio.Future | Any]) -> Any: # noqa: ANN401
44
+ raise NotImplementedError
45
+
46
+
47
+ class FileAuthStorageBackend(AuthStorageBackend):
48
+ def __init__(self, auth_path: str | Path) -> None:
49
+ self.auth_path = Path(auth_path)
50
+ self._lock = RLock()
51
+
52
+ def _ensure_file(self) -> None:
53
+ self.auth_path.parent.mkdir(parents=True, exist_ok=True)
54
+ if not self.auth_path.exists():
55
+ self.auth_path.write_text("{}", encoding="utf-8")
56
+
57
+ def with_lock(self, fn: Callable[[str | None], LockResult]) -> Any:
58
+ with self._lock:
59
+ self._ensure_file()
60
+ current = self.auth_path.read_text(encoding="utf-8") if self.auth_path.exists() else None
61
+ payload = fn(current)
62
+ if payload.next is not None:
63
+ self.auth_path.write_text(payload.next, encoding="utf-8")
64
+ return payload.result
65
+
66
+ async def with_lock_async(self, fn: Callable[[str | None], asyncio.Future | Any]) -> Any: # noqa: ANN401
67
+ with self._lock:
68
+ self._ensure_file()
69
+ current = self.auth_path.read_text(encoding="utf-8") if self.auth_path.exists() else None
70
+ value = fn(current)
71
+ payload = await value if asyncio.iscoroutine(value) else value
72
+ if payload.next is not None:
73
+ self.auth_path.write_text(payload.next, encoding="utf-8")
74
+ return payload.result
75
+
76
+
77
+ class InMemoryAuthStorageBackend(AuthStorageBackend):
78
+ def __init__(self, value: str | None = None) -> None:
79
+ self.value = value
80
+ self._lock = RLock()
81
+
82
+ def with_lock(self, fn: Callable[[str | None], LockResult]) -> Any:
83
+ with self._lock:
84
+ payload = fn(self.value)
85
+ if payload.next is not None:
86
+ self.value = payload.next
87
+ return payload.result
88
+
89
+ async def with_lock_async(self, fn: Callable[[str | None], asyncio.Future | Any]) -> Any: # noqa: ANN401
90
+ with self._lock:
91
+ value = fn(self.value)
92
+ payload = await value if asyncio.iscoroutine(value) else value
93
+ if payload.next is not None:
94
+ self.value = payload.next
95
+ return payload.result
96
+
97
+
98
+ class AuthStorage:
99
+ def __init__(self, backend: AuthStorageBackend) -> None:
100
+ self._backend = backend
101
+ self._data: AuthStorageData = {}
102
+ self._runtime_overrides: dict[str, str] = {}
103
+ self._fallback_resolver: Callable[[str], str | None] | None = None
104
+ self._errors: list[Exception] = []
105
+ self._load_error: Exception | None = None
106
+ self.reload()
107
+
108
+ @classmethod
109
+ def create(cls, auth_path: str | Path) -> AuthStorage:
110
+ return cls(FileAuthStorageBackend(auth_path))
111
+
112
+ @classmethod
113
+ def from_storage(cls, backend: AuthStorageBackend) -> AuthStorage:
114
+ return cls(backend)
115
+
116
+ @classmethod
117
+ def in_memory(cls, data: AuthStorageData | None = None) -> AuthStorage:
118
+ value = json.dumps(data or {}, ensure_ascii=False, indent=2)
119
+ return cls(InMemoryAuthStorageBackend(value))
120
+
121
+ def _parse_storage_data(self, content: str | None) -> AuthStorageData:
122
+ if not content:
123
+ return {}
124
+ parsed = json.loads(content)
125
+ if not isinstance(parsed, dict):
126
+ return {}
127
+ return parsed # type: ignore[return-value]
128
+
129
+ def _record_error(self, error: Exception) -> None:
130
+ self._errors.append(error)
131
+
132
+ def reload(self) -> None:
133
+ content: str | None = None
134
+ try:
135
+ def _capture(current: str | None) -> LockResult:
136
+ nonlocal content
137
+ content = current
138
+ return LockResult(result=None, next=None)
139
+
140
+ self._backend.with_lock(_capture)
141
+ except Exception as exc: # noqa: BLE001
142
+ self._load_error = exc
143
+ self._record_error(exc)
144
+ return
145
+
146
+ try:
147
+ self._data = self._parse_storage_data(content)
148
+ self._load_error = None
149
+ except Exception as exc: # noqa: BLE001
150
+ self._load_error = exc
151
+ self._record_error(exc)
152
+
153
+ def _persist_provider_change(self, provider: str, credential: AuthCredential | None) -> None:
154
+ if self._load_error is not None:
155
+ return
156
+
157
+ try:
158
+ def _mutate(current: str | None) -> LockResult:
159
+ merged = self._parse_storage_data(current)
160
+ if credential is None:
161
+ merged.pop(provider, None)
162
+ else:
163
+ merged[provider] = credential
164
+ return LockResult(result=None, next=json.dumps(merged, ensure_ascii=False, indent=2))
165
+
166
+ self._backend.with_lock(_mutate)
167
+ except Exception as exc: # noqa: BLE001
168
+ self._record_error(exc if isinstance(exc, Exception) else Exception(str(exc)))
169
+
170
+ def set_runtime_api_key(self, provider: str, api_key: str) -> None:
171
+ self._runtime_overrides[provider] = api_key
172
+
173
+ def setRuntimeApiKey(self, provider: str, api_key: str) -> None:
174
+ self.set_runtime_api_key(provider, api_key)
175
+
176
+ def remove_runtime_api_key(self, provider: str) -> None:
177
+ self._runtime_overrides.pop(provider, None)
178
+
179
+ def removeRuntimeApiKey(self, provider: str) -> None:
180
+ self.remove_runtime_api_key(provider)
181
+
182
+ def set_fallback_resolver(self, resolver: Callable[[str], str | None]) -> None:
183
+ self._fallback_resolver = resolver
184
+
185
+ def setFallbackResolver(self, resolver: Callable[[str], str | None]) -> None:
186
+ self.set_fallback_resolver(resolver)
187
+
188
+ def get(self, provider: str) -> AuthCredential | None:
189
+ value = self._data.get(provider)
190
+ return value.copy() if isinstance(value, dict) else None
191
+
192
+ def set(self, provider: str, credential: AuthCredential) -> None:
193
+ self._data[provider] = credential
194
+ self._persist_provider_change(provider, credential)
195
+
196
+ def remove(self, provider: str) -> None:
197
+ self._data.pop(provider, None)
198
+ self._persist_provider_change(provider, None)
199
+
200
+ def list(self) -> list[str]:
201
+ return list(self._data.keys())
202
+
203
+ def has(self, provider: str) -> bool:
204
+ return provider in self._data
205
+
206
+ def has_auth(self, provider: str) -> bool:
207
+ if provider in self._runtime_overrides:
208
+ return True
209
+ if provider in self._data:
210
+ return True
211
+ if get_env_api_key(provider):
212
+ return True
213
+ fallback = self._fallback_resolver(provider) if self._fallback_resolver else None
214
+ return bool(fallback)
215
+
216
+ def hasAuth(self, provider: str) -> bool:
217
+ return self.has_auth(provider)
218
+
219
+ def get_all(self) -> AuthStorageData:
220
+ return dict(self._data)
221
+
222
+ def getAll(self) -> AuthStorageData:
223
+ return self.get_all()
224
+
225
+ def drain_errors(self) -> list[Exception]:
226
+ drained = list(self._errors)
227
+ self._errors.clear()
228
+ return drained
229
+
230
+ def drainErrors(self) -> list[Exception]:
231
+ return self.drain_errors()
232
+
233
+ def _resolve_api_key_credential(self, credential: AuthCredential) -> str | None:
234
+ if credential.get("type") != "api_key":
235
+ return None
236
+ key = credential.get("key")
237
+ if not isinstance(key, str):
238
+ return None
239
+ return resolve_config_value(key)
240
+
241
+ async def get_api_key(self, provider: str) -> str | None:
242
+ runtime = self._runtime_overrides.get(provider)
243
+ if runtime:
244
+ return runtime
245
+
246
+ credential = self._data.get(provider)
247
+ if isinstance(credential, dict):
248
+ resolved = self._resolve_api_key_credential(credential)
249
+ if resolved:
250
+ return resolved
251
+
252
+ env = get_env_api_key(provider)
253
+ if env:
254
+ return env
255
+
256
+ if self._fallback_resolver:
257
+ fallback = self._fallback_resolver(provider)
258
+ if fallback:
259
+ return fallback
260
+
261
+ return None
262
+
263
+ async def getApiKey(self, provider: str) -> str | None:
264
+ return await self.get_api_key(provider)
265
+
266
+ # OAuth support is intentionally minimal in this port.
267
+ async def login(self, provider_id: str, callbacks: Any) -> None: # noqa: ANN401
268
+ raise NotImplementedError(f"OAuth login is not implemented for provider {provider_id}")
269
+
270
+ def logout(self, provider: str) -> None:
271
+ self.remove(provider)
272
+
273
+ def get_oauth_providers(self) -> list[Any]: # noqa: ANN401
274
+ return []
275
+
276
+ def getOAuthProviders(self) -> list[Any]: # noqa: ANN401
277
+ return self.get_oauth_providers()
278
+
279
+
280
+ __all__ = [
281
+ "ApiKeyCredential",
282
+ "AuthCredential",
283
+ "AuthStorage",
284
+ "AuthStorageBackend",
285
+ "AuthStorageData",
286
+ "FileAuthStorageBackend",
287
+ "InMemoryAuthStorageBackend",
288
+ "OAuthCredential",
289
+ ]
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal, TypedDict
4
+
5
+
6
+ class ResourceDiagnostic(TypedDict, total=False):
7
+ type: Literal["warning", "error"]
8
+ message: str
9
+ path: str
10
+
11
+
12
+ class ResourceCollision(TypedDict, total=False):
13
+ path: str
14
+ message: str
15
+
16
+
17
+ __all__ = ["ResourceCollision", "ResourceDiagnostic"]
@@ -0,0 +1,120 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from collections.abc import Awaitable, Callable
5
+ from dataclasses import dataclass
6
+ from typing import Any, Literal
7
+
8
+ InputSource = Literal["interactive", "rpc", "extension"]
9
+ InputAction = Literal["continue", "transform", "handled"]
10
+
11
+ InputHandler = Callable[[dict[str, Any]], Awaitable[dict[str, Any] | None] | dict[str, Any] | None]
12
+ ErrorListener = Callable[[Exception], None]
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class InputEventResult:
17
+ action: InputAction
18
+ text: str | None = None
19
+ images: list[dict[str, Any]] | None = None
20
+
21
+
22
+ async def _invoke_handler(handler: InputHandler, event: dict[str, Any]) -> dict[str, Any] | None:
23
+ result = handler(event)
24
+ if inspect.isawaitable(result):
25
+ awaited = await result
26
+ return awaited if isinstance(awaited, dict) else None
27
+ return result if isinstance(result, dict) else None
28
+
29
+
30
+ class InputEventRunner:
31
+ def __init__(self, handlers: list[InputHandler] | None = None) -> None:
32
+ self._handlers: list[InputHandler] = list(handlers or [])
33
+ self._error_listeners: list[ErrorListener] = []
34
+
35
+ def add_handler(self, handler: InputHandler) -> None:
36
+ self._handlers.append(handler)
37
+
38
+ def addHandler(self, handler: InputHandler) -> None:
39
+ self.add_handler(handler)
40
+
41
+ def on_error(self, listener: ErrorListener) -> None:
42
+ self._error_listeners.append(listener)
43
+
44
+ def onError(self, listener: ErrorListener) -> None:
45
+ self.on_error(listener)
46
+
47
+ def has_handlers(self) -> bool:
48
+ return bool(self._handlers)
49
+
50
+ def hasHandlers(self) -> bool:
51
+ return self.has_handlers()
52
+
53
+ async def emit_input(
54
+ self,
55
+ text: str,
56
+ images: list[dict[str, Any]] | None,
57
+ source: InputSource,
58
+ ) -> InputEventResult:
59
+ current_text = text
60
+ current_images = images
61
+
62
+ for handler in self._handlers:
63
+ event = {
64
+ "type": "input",
65
+ "text": current_text,
66
+ "images": current_images,
67
+ "source": source,
68
+ }
69
+ try:
70
+ result = await _invoke_handler(handler, event)
71
+ except Exception as exc: # noqa: BLE001
72
+ for listener in self._error_listeners:
73
+ listener(exc)
74
+ continue
75
+
76
+ if result is None:
77
+ continue
78
+
79
+ action = result.get("action")
80
+ if action == "handled":
81
+ return InputEventResult(
82
+ action="handled",
83
+ text=str(result.get("text")) if isinstance(result.get("text"), str) else None,
84
+ images=result.get("images") if isinstance(result.get("images"), list) else None,
85
+ )
86
+ if action == "transform":
87
+ next_text = result.get("text")
88
+ if isinstance(next_text, str):
89
+ current_text = next_text
90
+ if "images" in result:
91
+ images_value = result.get("images")
92
+ if images_value is None or isinstance(images_value, list):
93
+ current_images = images_value
94
+ continue
95
+
96
+ if current_text != text or current_images != images:
97
+ return InputEventResult(action="transform", text=current_text, images=current_images)
98
+ return InputEventResult(action="continue")
99
+
100
+ async def emitInput(
101
+ self,
102
+ text: str,
103
+ images: list[dict[str, Any]] | None,
104
+ source: InputSource,
105
+ ) -> InputEventResult:
106
+ return await self.emit_input(text, images, source)
107
+
108
+
109
+ async def emit_input(
110
+ handlers: list[InputHandler],
111
+ *,
112
+ text: str,
113
+ images: list[dict[str, Any]] | None,
114
+ source: InputSource,
115
+ ) -> InputEventResult:
116
+ runner = InputEventRunner(handlers)
117
+ return await runner.emit_input(text, images, source)
118
+
119
+
120
+ emitInput = emit_input