devobin 1.0.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.
Files changed (47) hide show
  1. devobin/__init__.py +4 -0
  2. devobin/app.py +42 -0
  3. devobin/cli/__init__.py +1 -0
  4. devobin/cli/chat.py +831 -0
  5. devobin/cli/commands.py +362 -0
  6. devobin/cli/slash_menu.py +74 -0
  7. devobin/cli/terminal.py +155 -0
  8. devobin/cli/ui.py +129 -0
  9. devobin/config/__init__.py +1 -0
  10. devobin/config/settings.py +124 -0
  11. devobin/core/__init__.py +1 -0
  12. devobin/core/analyzer.py +166 -0
  13. devobin/core/executor.py +244 -0
  14. devobin/core/memory.py +78 -0
  15. devobin/core/optimizer.py +106 -0
  16. devobin/core/prompt_builder.py +513 -0
  17. devobin/core/researcher.py +283 -0
  18. devobin/core/rtl.py +93 -0
  19. devobin/core/scanner.py +206 -0
  20. devobin/core/tools.py +283 -0
  21. devobin/core/validator.py +235 -0
  22. devobin/main.py +33 -0
  23. devobin/output/__init__.py +1 -0
  24. devobin/providers/__init__.py +85 -0
  25. devobin/providers/anthropic_provider.py +72 -0
  26. devobin/providers/google_provider.py +90 -0
  27. devobin/providers/ollama_provider.py +81 -0
  28. devobin/providers/openai_provider.py +102 -0
  29. devobin/storage/__init__.py +1 -0
  30. devobin/storage/database.py +207 -0
  31. devobin/ui/__init__.py +1 -0
  32. devobin/ui/ask_modal.py +96 -0
  33. devobin/ui/chat_screen.py +1029 -0
  34. devobin/ui/command_palette.py +131 -0
  35. devobin/ui/connect_modal.py +166 -0
  36. devobin/ui/export_modal.py +159 -0
  37. devobin/ui/history_modal.py +137 -0
  38. devobin/ui/memory_modal.py +88 -0
  39. devobin/ui/model_modal.py +143 -0
  40. devobin/ui/permission_modal.py +92 -0
  41. devobin/ui/project_modal.py +133 -0
  42. devobin/ui/settings_modal.py +89 -0
  43. devobin-1.0.0.dist-info/METADATA +364 -0
  44. devobin-1.0.0.dist-info/RECORD +47 -0
  45. devobin-1.0.0.dist-info/WHEEL +4 -0
  46. devobin-1.0.0.dist-info/entry_points.txt +2 -0
  47. devobin-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,72 @@
1
+ """Anthropic Claude provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import AsyncIterator
6
+
7
+ from anthropic import AsyncAnthropic
8
+
9
+ from devobin.providers import AIProvider
10
+
11
+
12
+ class AnthropicProvider(AIProvider):
13
+ name = "anthropic"
14
+
15
+ def __init__(self, config: dict) -> None:
16
+ super().__init__(config)
17
+ self._client = AsyncAnthropic(api_key=self.api_key)
18
+
19
+ async def chat(
20
+ self,
21
+ messages: list[dict[str, str]],
22
+ system: str | None = None,
23
+ temperature: float = 0.7,
24
+ max_tokens: int = 4096,
25
+ ) -> str:
26
+ kwargs: dict = {
27
+ "model": self.model,
28
+ "messages": messages,
29
+ "temperature": temperature,
30
+ "max_tokens": max_tokens,
31
+ }
32
+ if system:
33
+ kwargs["system"] = system
34
+
35
+ response = await self._client.messages.create(**kwargs)
36
+ return response.content[0].text if response.content else ""
37
+
38
+ async def chat_stream(
39
+ self,
40
+ messages: list[dict[str, str]],
41
+ system: str | None = None,
42
+ temperature: float = 0.7,
43
+ max_tokens: int = 4096,
44
+ ) -> AsyncIterator[str]:
45
+ kwargs: dict = {
46
+ "model": self.model,
47
+ "messages": messages,
48
+ "temperature": temperature,
49
+ "max_tokens": max_tokens,
50
+ }
51
+ if system:
52
+ kwargs["system"] = system
53
+
54
+ async with self._client.messages.stream(**kwargs) as stream:
55
+ async for text in stream.text_stream:
56
+ yield text
57
+
58
+ async def validate_connection(self) -> bool:
59
+ try:
60
+ await self.chat([{"role": "user", "content": "hi"}], max_tokens=5)
61
+ return True
62
+ except Exception:
63
+ return self.api_key != ""
64
+
65
+ def available_models(self) -> list[str]:
66
+ return [
67
+ "claude-opus-4-20250514",
68
+ "claude-sonnet-4-20250514",
69
+ "claude-3-5-haiku-20241022",
70
+ "claude-3-5-sonnet-20241022",
71
+ "claude-3-opus-20240229",
72
+ ]
@@ -0,0 +1,90 @@
1
+ """Google Gemini provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import AsyncIterator
6
+
7
+ from google import genai
8
+ from google.genai import types
9
+
10
+ from devobin.providers import AIProvider
11
+
12
+
13
+ class GoogleProvider(AIProvider):
14
+ name = "google"
15
+
16
+ def __init__(self, config: dict) -> None:
17
+ super().__init__(config)
18
+ self._client = genai.Client(api_key=self.api_key)
19
+
20
+ async def chat(
21
+ self,
22
+ messages: list[dict[str, str]],
23
+ system: str | None = None,
24
+ temperature: float = 0.7,
25
+ max_tokens: int = 4096,
26
+ ) -> str:
27
+ contents = []
28
+ if system:
29
+ contents.append(types.Content(role="user", parts=[types.Part(text=system)]))
30
+ contents.append(types.Content(role="model", parts=[types.Part(text="Understood.")]))
31
+
32
+ for m in messages:
33
+ role = "user" if m["role"] == "user" else "model"
34
+ contents.append(types.Content(role=role, parts=[types.Part(text=m["content"])]))
35
+
36
+ response = await self._client.aio.models.generate_content(
37
+ model=self.model,
38
+ contents=contents,
39
+ config=types.GenerateContentConfig(
40
+ temperature=temperature,
41
+ max_output_tokens=max_tokens,
42
+ ),
43
+ )
44
+ return response.text or ""
45
+
46
+ async def chat_stream(
47
+ self,
48
+ messages: list[dict[str, str]],
49
+ system: str | None = None,
50
+ temperature: float = 0.7,
51
+ max_tokens: int = 4096,
52
+ ) -> AsyncIterator[str]:
53
+ contents = []
54
+ if system:
55
+ contents.append(types.Content(role="user", parts=[types.Part(text=system)]))
56
+ contents.append(types.Content(role="model", parts=[types.Part(text="Understood.")]))
57
+
58
+ for m in messages:
59
+ role = "user" if m["role"] == "user" else "model"
60
+ contents.append(types.Content(role=role, parts=[types.Part(text=m["content"])]))
61
+
62
+ async for chunk in self._client.aio.models.generate_content_stream(
63
+ model=self.model,
64
+ contents=contents,
65
+ config=types.GenerateContentConfig(
66
+ temperature=temperature,
67
+ max_output_tokens=max_tokens,
68
+ ),
69
+ ):
70
+ if chunk.text:
71
+ yield chunk.text
72
+
73
+ async def validate_connection(self) -> bool:
74
+ try:
75
+ await self._client.aio.models.generate_content(
76
+ model=self.model,
77
+ contents="hi",
78
+ config=types.GenerateContentConfig(max_output_tokens=5),
79
+ )
80
+ return True
81
+ except Exception:
82
+ return self.api_key != ""
83
+
84
+ def available_models(self) -> list[str]:
85
+ return [
86
+ "gemini-2.0-flash",
87
+ "gemini-1.5-pro",
88
+ "gemini-1.5-flash",
89
+ "gemini-1.0-pro",
90
+ ]
@@ -0,0 +1,81 @@
1
+ """Ollama local model provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import AsyncIterator
6
+
7
+ import httpx
8
+
9
+ from devobin.providers import AIProvider
10
+
11
+
12
+ class OllamaProvider(AIProvider):
13
+ name = "ollama"
14
+
15
+ def __init__(self, config: dict) -> None:
16
+ super().__init__(config)
17
+ self.base_url = config.get("base_url", "http://localhost:11434")
18
+ self._client = httpx.AsyncClient(base_url=self.base_url, timeout=120.0)
19
+
20
+ async def chat(
21
+ self,
22
+ messages: list[dict[str, str]],
23
+ system: str | None = None,
24
+ temperature: float = 0.7,
25
+ max_tokens: int = 4096,
26
+ ) -> str:
27
+ payload: dict = {
28
+ "model": self.model,
29
+ "messages": messages,
30
+ "stream": False,
31
+ "options": {"temperature": temperature, "num_predict": max_tokens},
32
+ }
33
+ if system:
34
+ payload["system"] = system
35
+
36
+ response = await self._client.post("/api/chat", json=payload)
37
+ response.raise_for_status()
38
+ data = response.json()
39
+ return data.get("message", {}).get("content", "")
40
+
41
+ async def chat_stream(
42
+ self,
43
+ messages: list[dict[str, str]],
44
+ system: str | None = None,
45
+ temperature: float = 0.7,
46
+ max_tokens: int = 4096,
47
+ ) -> AsyncIterator[str]:
48
+ payload: dict = {
49
+ "model": self.model,
50
+ "messages": messages,
51
+ "stream": True,
52
+ "options": {"temperature": temperature, "num_predict": max_tokens},
53
+ }
54
+ if system:
55
+ payload["system"] = system
56
+
57
+ async with self._client.stream("POST", "/api/chat", json=payload) as response:
58
+ response.raise_for_status()
59
+ async for line in response.aiter_lines():
60
+ if line:
61
+ import json
62
+ data = json.loads(line)
63
+ content = data.get("message", {}).get("content", "")
64
+ if content:
65
+ yield content
66
+
67
+ async def validate_connection(self) -> bool:
68
+ try:
69
+ resp = await self._client.get("/api/tags")
70
+ return resp.status_code == 200
71
+ except Exception:
72
+ return False
73
+
74
+ def available_models(self) -> list[str]:
75
+ try:
76
+ import json
77
+ resp = httpx.get(f"{self.base_url}/api/tags", timeout=5.0)
78
+ data = resp.json()
79
+ return [m["name"] for m in data.get("models", [])]
80
+ except Exception:
81
+ return ["llama3", "codellama", "mistral", "gemma"]
@@ -0,0 +1,102 @@
1
+ """OpenAI and OpenAI-compatible API provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import AsyncIterator
6
+
7
+ from openai import AsyncOpenAI
8
+
9
+ from devobin.providers import AIProvider
10
+
11
+
12
+ class OpenAIProvider(AIProvider):
13
+ name = "openai"
14
+
15
+ def __init__(self, config: dict) -> None:
16
+ super().__init__(config)
17
+ base = config.get("base_url") or "https://api.openai.com/v1"
18
+ self._client = AsyncOpenAI(api_key=self.api_key, base_url=base)
19
+
20
+ async def chat(
21
+ self,
22
+ messages: list[dict[str, str]],
23
+ system: str | None = None,
24
+ temperature: float = 0.7,
25
+ max_tokens: int = 4096,
26
+ ) -> str:
27
+ full_messages = []
28
+ if system:
29
+ full_messages.append({"role": "system", "content": system})
30
+ full_messages.extend(messages)
31
+
32
+ response = await self._client.chat.completions.create(
33
+ model=self.model,
34
+ messages=full_messages, # type: ignore[arg-type]
35
+ temperature=temperature,
36
+ max_tokens=max_tokens,
37
+ )
38
+ if not response.choices:
39
+ return ""
40
+ return response.choices[0].message.content or ""
41
+
42
+ async def chat_stream(
43
+ self,
44
+ messages: list[dict[str, str]],
45
+ system: str | None = None,
46
+ temperature: float = 0.7,
47
+ max_tokens: int = 4096,
48
+ ) -> AsyncIterator[str]:
49
+ full_messages = []
50
+ if system:
51
+ full_messages.append({"role": "system", "content": system})
52
+ full_messages.extend(messages)
53
+
54
+ stream = await self._client.chat.completions.create(
55
+ model=self.model,
56
+ messages=full_messages, # type: ignore[arg-type]
57
+ temperature=temperature,
58
+ max_tokens=max_tokens,
59
+ stream=True,
60
+ )
61
+ async for chunk in stream:
62
+ if not chunk.choices:
63
+ continue
64
+ delta = chunk.choices[0].delta
65
+ if delta and delta.content:
66
+ yield delta.content
67
+
68
+ async def validate_connection(self) -> bool:
69
+ try:
70
+ await self._client.models.list()
71
+ return True
72
+ except Exception:
73
+ return self.api_key != ""
74
+
75
+ def available_models(self) -> list[str]:
76
+ import httpx
77
+
78
+ base = self.config.get("base_url") or "https://api.openai.com/v1"
79
+
80
+ # For OpenAI official API, return known models
81
+ if "api.openai.com" in base:
82
+ return [
83
+ "gpt-4o",
84
+ "gpt-4o-mini",
85
+ "gpt-4-turbo",
86
+ "gpt-4",
87
+ "gpt-3.5-turbo",
88
+ "o1",
89
+ "o1-mini",
90
+ "o1-pro",
91
+ ]
92
+
93
+ # For compatible APIs, fetch models from the endpoint
94
+ try:
95
+ headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
96
+ resp = httpx.get(f"{base}/models", headers=headers, timeout=10.0)
97
+ resp.raise_for_status()
98
+ data = resp.json()
99
+ models = [m["id"] for m in data.get("data", [])]
100
+ return models if models else ["default"]
101
+ except Exception:
102
+ return ["default"]
@@ -0,0 +1 @@
1
+ """DevObin storage package."""
@@ -0,0 +1,207 @@
1
+ """Local storage for projects, history, and memories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import uuid
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from devobin.config.settings import get_config
12
+
13
+
14
+ def _now_iso() -> str:
15
+ return datetime.now(timezone.utc).isoformat()
16
+
17
+
18
+ class ProjectStorage:
19
+ """Per-project persistent storage (memory, sessions, prompts).
20
+
21
+ Data lives in .devobin/ inside the current working directory so the user
22
+ can find everything where they launched the CLI.
23
+
24
+ Sessions: each chat conversation is a separate JSON file in sessions/.
25
+ """
26
+
27
+ def __init__(self, project_name: str) -> None:
28
+ from devobin.config.settings import local_data_dir
29
+ self.name = project_name
30
+ self._dir = local_data_dir() / "projects" / project_name
31
+ self._dir.mkdir(parents=True, exist_ok=True)
32
+ self._memory_path = self._dir / "memory.json"
33
+ self._meta_path = self._dir / "meta.json"
34
+ self._sessions_dir = self._dir / "sessions"
35
+ self._sessions_dir.mkdir(parents=True, exist_ok=True)
36
+ self._memory: list[dict[str, Any]] = self._load_list(self._memory_path)
37
+ self._meta: dict[str, Any] = self._load_dict(self._meta_path)
38
+ self._current_session_id: str | None = None
39
+ self._current_session: list[dict[str, Any]] = []
40
+
41
+ # ── I/O helpers ───────────────────────────────────────────────
42
+
43
+ def _load_list(self, path: Path) -> list[dict[str, Any]]:
44
+ if path.exists():
45
+ return json.loads(path.read_text(encoding="utf-8"))
46
+ return []
47
+
48
+ def _load_dict(self, path: Path) -> dict[str, Any]:
49
+ if path.exists():
50
+ return json.loads(path.read_text(encoding="utf-8"))
51
+ return {}
52
+
53
+ def _save_list(self, path: Path, data: list[dict[str, Any]]) -> None:
54
+ path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
55
+
56
+ def _save_dict(self, path: Path, data: dict[str, Any]) -> None:
57
+ path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
58
+
59
+ # ── Memory ────────────────────────────────────────────────────
60
+
61
+ def add_memory(self, content: str, category: str = "general") -> dict[str, Any]:
62
+ entry = {
63
+ "id": uuid.uuid4().hex[:8],
64
+ "content": content,
65
+ "category": category,
66
+ "created_at": _now_iso(),
67
+ }
68
+ self._memory.append(entry)
69
+ self._save_list(self._memory_path, self._memory)
70
+ return entry
71
+
72
+ def get_memories(self, category: str | None = None) -> list[dict[str, Any]]:
73
+ if category:
74
+ return [m for m in self._memory if m.get("category") == category]
75
+ return list(self._memory)
76
+
77
+ def search_memories(self, query: str) -> list[dict[str, Any]]:
78
+ q = query.lower()
79
+ return [m for m in self._memory if q in m.get("content", "").lower()]
80
+
81
+ def clear_memories(self) -> None:
82
+ self._memory.clear()
83
+ self._save_list(self._memory_path, self._memory)
84
+
85
+ # ── Sessions (each chat = separate file) ──────────────────────
86
+
87
+ def new_session(self) -> str:
88
+ """Create a new session and return its ID."""
89
+ sid = _now_iso().replace(":", "-").replace(".", "-")[:19]
90
+ self._current_session_id = sid
91
+ self._current_session = []
92
+ self._save_session()
93
+ return sid
94
+
95
+ def _session_path(self, sid: str) -> Path:
96
+ return self._sessions_dir / f"{sid}.json"
97
+
98
+ def _save_session(self) -> None:
99
+ if self._current_session_id:
100
+ path = self._session_path(self._current_session_id)
101
+ data = {
102
+ "id": self._current_session_id,
103
+ "messages": self._current_session,
104
+ }
105
+ path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
106
+
107
+ def add_message(self, role: str, content: str) -> dict[str, Any]:
108
+ entry = {
109
+ "id": uuid.uuid4().hex[:8],
110
+ "role": role,
111
+ "content": content,
112
+ "timestamp": _now_iso(),
113
+ }
114
+ if self._current_session_id is None:
115
+ self.new_session()
116
+ self._current_session.append(entry)
117
+ self._save_session()
118
+ return entry
119
+
120
+ def get_history(self, limit: int = 50) -> list[dict[str, Any]]:
121
+ return self._current_session[-limit:]
122
+
123
+ def clear_history(self) -> None:
124
+ self._current_session.clear()
125
+ self._save_session()
126
+
127
+ def get_context_messages(self, max_messages: int = 20) -> list[dict[str, str]]:
128
+ recent = self._current_session[-max_messages:]
129
+ return [{"role": m["role"], "content": m["content"]} for m in recent]
130
+
131
+ def list_sessions(self) -> list[dict[str, Any]]:
132
+ """List all sessions for this project."""
133
+ sessions = []
134
+ for f in sorted(self._sessions_dir.glob("*.json"), reverse=True):
135
+ try:
136
+ data = json.loads(f.read_text(encoding="utf-8"))
137
+ msgs = data.get("messages", [])
138
+ if msgs:
139
+ first_user = next(
140
+ (m["content"][:60] for m in msgs if m.get("role") == "user"),
141
+ "(empty)"
142
+ )
143
+ sessions.append({
144
+ "id": data.get("id", f.stem),
145
+ "count": len(msgs),
146
+ "preview": first_user,
147
+ "messages": msgs,
148
+ })
149
+ except Exception:
150
+ continue
151
+ return sessions
152
+
153
+ # ── Meta ──────────────────────────────────────────────────────
154
+
155
+ def set_meta(self, key: str, value: Any) -> None:
156
+ self._meta[key] = value
157
+ self._save_dict(self._meta_path, self._meta)
158
+
159
+ def get_meta(self, key: str, default: Any = None) -> Any:
160
+ return self._meta.get(key, default)
161
+
162
+ @property
163
+ def created_at(self) -> str:
164
+ return self._meta.get("created_at", _now_iso())
165
+
166
+
167
+ class GlobalStorage:
168
+ """Global storage for user-level memory and preferences."""
169
+
170
+ def __init__(self) -> None:
171
+ self._dir = get_config().projects_dir().parent
172
+ self._memory_path = get_config()._settings_path.parent / "global_memory.json"
173
+ self._memory: list[dict[str, Any]] = self._load()
174
+
175
+ def _load(self) -> list[dict[str, Any]]:
176
+ if self._memory_path.exists():
177
+ return json.loads(self._memory_path.read_text(encoding="utf-8"))
178
+ return []
179
+
180
+ def _save(self) -> None:
181
+ self._memory_path.write_text(
182
+ json.dumps(self._memory, indent=2, ensure_ascii=False), encoding="utf-8"
183
+ )
184
+
185
+ def add_memory(self, content: str, category: str = "preference") -> dict[str, Any]:
186
+ entry = {
187
+ "id": uuid.uuid4().hex[:8],
188
+ "content": content,
189
+ "category": category,
190
+ "created_at": _now_iso(),
191
+ }
192
+ self._memory.append(entry)
193
+ self._save()
194
+ return entry
195
+
196
+ def get_memories(self, category: str | None = None) -> list[dict[str, Any]]:
197
+ if category:
198
+ return [m for m in self._memory if m.get("category") == category]
199
+ return list(self._memory)
200
+
201
+ def search_memories(self, query: str) -> list[dict[str, Any]]:
202
+ q = query.lower()
203
+ return [m for m in self._memory if q in m.get("content", "").lower()]
204
+
205
+ def clear_memories(self) -> None:
206
+ self._memory.clear()
207
+ self._save()
devobin/ui/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """DevObin UI package."""
@@ -0,0 +1,96 @@
1
+ """Interactive question modal — lets the AI ask the user for clarification or permission."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from textual.app import ComposeResult
6
+ from textual.screen import ModalScreen
7
+ from textual.widgets import Static, Input, Button
8
+ from textual.containers import Vertical
9
+ from textual.binding import Binding
10
+ from textual import on
11
+ from rich.markup import escape
12
+
13
+
14
+ class AskModal(ModalScreen[str | None]):
15
+ """Modal that shows an AI question and returns the user's answer.
16
+
17
+ Used when the model needs clarification or permission to read a file.
18
+ """
19
+
20
+ CSS = """
21
+ AskModal {
22
+ align: center middle;
23
+ }
24
+ #ask-container {
25
+ width: 60;
26
+ max-height: 30;
27
+ background: $surface;
28
+ border: tall $primary;
29
+ padding: 2;
30
+ }
31
+ #ask-title {
32
+ text-align: center;
33
+ text-style: bold;
34
+ color: $primary;
35
+ margin-bottom: 1;
36
+ }
37
+ #ask-question {
38
+ height: auto;
39
+ max-height: 15;
40
+ overflow-y: auto;
41
+ margin-bottom: 1;
42
+ color: $text;
43
+ }
44
+ #ask-input {
45
+ width: 100%;
46
+ background: #161b22;
47
+ color: #e6edf3;
48
+ border: round #2b3440;
49
+ padding: 0 1;
50
+ }
51
+ #ask-input:focus { border: round #00d4aa; }
52
+ #ask-buttons {
53
+ height: auto;
54
+ margin-top: 1;
55
+ }
56
+ #ask-buttons Button {
57
+ margin: 0 1;
58
+ }
59
+ """
60
+
61
+ BINDINGS = [
62
+ Binding("escape", "cancel", "Cancel", show=True),
63
+ ]
64
+
65
+ def __init__(self, question: str, title: str = "DevObin asks") -> None:
66
+ super().__init__()
67
+ self._question = question
68
+ self._title = title
69
+
70
+ def compose(self) -> ComposeResult:
71
+ with Vertical(id="ask-container"):
72
+ yield Static(self._title, id="ask-title")
73
+ yield Static(escape(self._question), id="ask-question")
74
+ yield Input(placeholder="Your answer…", id="ask-input")
75
+ with Vertical(id="ask-buttons"):
76
+ yield Button("Send", id="ask-send", variant="primary")
77
+ yield Button("Skip", id="ask-skip", variant="default")
78
+
79
+ def on_mount(self) -> None:
80
+ self.query_one("#ask-input", Input).focus()
81
+
82
+ @on(Input.Submitted, "#ask-input")
83
+ def on_submitted(self, event: Input.Submitted) -> None:
84
+ self.dismiss(event.value.strip() or None)
85
+
86
+ @on(Button.Pressed, "#ask-send")
87
+ def on_send_pressed(self) -> None:
88
+ value = self.query_one("#ask-input", Input).value.strip()
89
+ self.dismiss(value or None)
90
+
91
+ @on(Button.Pressed, "#ask-skip")
92
+ def on_skip_pressed(self) -> None:
93
+ self.dismiss(None)
94
+
95
+ def action_cancel(self) -> None:
96
+ self.dismiss(None)