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.
- devobin/__init__.py +4 -0
- devobin/app.py +42 -0
- devobin/cli/__init__.py +1 -0
- devobin/cli/chat.py +831 -0
- devobin/cli/commands.py +362 -0
- devobin/cli/slash_menu.py +74 -0
- devobin/cli/terminal.py +155 -0
- devobin/cli/ui.py +129 -0
- devobin/config/__init__.py +1 -0
- devobin/config/settings.py +124 -0
- devobin/core/__init__.py +1 -0
- devobin/core/analyzer.py +166 -0
- devobin/core/executor.py +244 -0
- devobin/core/memory.py +78 -0
- devobin/core/optimizer.py +106 -0
- devobin/core/prompt_builder.py +513 -0
- devobin/core/researcher.py +283 -0
- devobin/core/rtl.py +93 -0
- devobin/core/scanner.py +206 -0
- devobin/core/tools.py +283 -0
- devobin/core/validator.py +235 -0
- devobin/main.py +33 -0
- devobin/output/__init__.py +1 -0
- devobin/providers/__init__.py +85 -0
- devobin/providers/anthropic_provider.py +72 -0
- devobin/providers/google_provider.py +90 -0
- devobin/providers/ollama_provider.py +81 -0
- devobin/providers/openai_provider.py +102 -0
- devobin/storage/__init__.py +1 -0
- devobin/storage/database.py +207 -0
- devobin/ui/__init__.py +1 -0
- devobin/ui/ask_modal.py +96 -0
- devobin/ui/chat_screen.py +1029 -0
- devobin/ui/command_palette.py +131 -0
- devobin/ui/connect_modal.py +166 -0
- devobin/ui/export_modal.py +159 -0
- devobin/ui/history_modal.py +137 -0
- devobin/ui/memory_modal.py +88 -0
- devobin/ui/model_modal.py +143 -0
- devobin/ui/permission_modal.py +92 -0
- devobin/ui/project_modal.py +133 -0
- devobin/ui/settings_modal.py +89 -0
- devobin-1.0.0.dist-info/METADATA +364 -0
- devobin-1.0.0.dist-info/RECORD +47 -0
- devobin-1.0.0.dist-info/WHEEL +4 -0
- devobin-1.0.0.dist-info/entry_points.txt +2 -0
- devobin-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1029 @@
|
|
|
1
|
+
"""Chat screen for DevObin AI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from rich.markup import escape
|
|
7
|
+
|
|
8
|
+
from textual.app import ComposeResult
|
|
9
|
+
from textual.screen import Screen
|
|
10
|
+
from textual.widgets import Static, Input
|
|
11
|
+
from textual.containers import Vertical, VerticalScroll
|
|
12
|
+
from textual.binding import Binding
|
|
13
|
+
from textual import on
|
|
14
|
+
|
|
15
|
+
from devobin.config.settings import get_config
|
|
16
|
+
from devobin.core.analyzer import analyze_input
|
|
17
|
+
from devobin.core.researcher import research_analysis
|
|
18
|
+
from devobin.core.memory import MemoryManager
|
|
19
|
+
from devobin.core.optimizer import compress_context
|
|
20
|
+
from devobin.core.executor import get_executor, get_file_manager
|
|
21
|
+
from devobin.core.validator import validate_output, validate_grounding, build_rewrite_prompt, get_mode_message
|
|
22
|
+
from devobin.core.rtl import smart_display, reshape_rtl
|
|
23
|
+
from devobin.storage.database import ProjectStorage
|
|
24
|
+
|
|
25
|
+
SYSTEM_PROMPT = """You are DevObin — an AI Prompt Engineering System.
|
|
26
|
+
|
|
27
|
+
You are a PROMPT ARCHITECT, not a CODER.
|
|
28
|
+
You create engineering instructions FOR other AI coding agents to implement.
|
|
29
|
+
You do NOT deliver the implementation yourself.
|
|
30
|
+
|
|
31
|
+
## HOW YOU WORK (Step by Step)
|
|
32
|
+
|
|
33
|
+
1. **Understand**: The user describes WHAT they want to build/change. They do NOT tell you the tech stack — you figure that out yourself.
|
|
34
|
+
2. **Explore**: Use tools to read the actual code. Read ALL key files. From the code, determine:
|
|
35
|
+
- What language(s) are used
|
|
36
|
+
- What frameworks/libraries are used
|
|
37
|
+
- What patterns are followed
|
|
38
|
+
- What the architecture looks like
|
|
39
|
+
3. **Analyze**: Based on what you read, understand the REAL architecture. Do NOT guess — verify from the code.
|
|
40
|
+
4. **Write**: Produce a CUSTOMIZED engineering prompt based on what you ACTUALLY found.
|
|
41
|
+
- Reference SPECIFIC files, SPECIFIC classes, SPECIFIC functions
|
|
42
|
+
- Use the REAL tech stack you discovered from reading the code
|
|
43
|
+
- Do NOT ask the user "what language do you use?" — you already know from reading the files
|
|
44
|
+
- Do NOT suggest technologies not in the project unless the user explicitly asked for them
|
|
45
|
+
|
|
46
|
+
## YOUR ROLE: You are the detective, not the user
|
|
47
|
+
|
|
48
|
+
- The user tells you the GOAL ("add caching", "fix the export", "add a new feature")
|
|
49
|
+
- YOU discover the TECH by reading the code
|
|
50
|
+
- YOU figure out the ARCHITECTURE by exploring the files
|
|
51
|
+
- NEVER ask "what language is this?" or "what framework do you use?" — you can see it in the files
|
|
52
|
+
- NEVER assume or guess — always verify from the actual code you read
|
|
53
|
+
|
|
54
|
+
## TOOLS — EXPLORE AND CREATE
|
|
55
|
+
|
|
56
|
+
[TOOL:glob:**/*.py] — find files matching a glob pattern
|
|
57
|
+
[TOOL:grep:regex] — search file contents with a regex
|
|
58
|
+
[TOOL:read:devobin/main.py] — read a file's full content
|
|
59
|
+
[TOOL:ls:devobin/core/] — list a directory's entries
|
|
60
|
+
[TOOL:write:my_prompt.md] — create/update a .md prompt file
|
|
61
|
+
|
|
62
|
+
## MANDATORY: YOU MUST WRITE THE FILE YOURSELF
|
|
63
|
+
|
|
64
|
+
NEVER just produce the prompt as text in your response.
|
|
65
|
+
ALWAYS use [TOOL:write:filename.md] to save the prompt to a file.
|
|
66
|
+
|
|
67
|
+
The user should NEVER have to type /export. YOU create the file.
|
|
68
|
+
|
|
69
|
+
### Complete workflow:
|
|
70
|
+
1. [TOOL:ls:.] — see project structure
|
|
71
|
+
2. [TOOL:glob:**/*.py] — find all source files
|
|
72
|
+
3. [TOOL:read:path] — read EVERY important file
|
|
73
|
+
4. [TOOL:read:existing_prompt.md] — check if prompt already exists
|
|
74
|
+
5. [TOOL:write:my_feature_prompt.md] — CREATE THE FILE with your prompt
|
|
75
|
+
|
|
76
|
+
### After writing the file, tell the user:
|
|
77
|
+
"Created: my_feature_prompt.md"
|
|
78
|
+
|
|
79
|
+
Do NOT dump the prompt content in your response text.
|
|
80
|
+
Do NOT ask the user to run /export.
|
|
81
|
+
Just write the file and confirm it was created.
|
|
82
|
+
|
|
83
|
+
### When user gives a new request:
|
|
84
|
+
1. [TOOL:glob:**/*_prompt.md] — find existing prompts
|
|
85
|
+
2. [TOOL:read:old_prompt.md] — read the old prompt
|
|
86
|
+
3. [TOOL:read:source_code.py] — read the code
|
|
87
|
+
4. [TOOL:write:old_prompt.md] — UPDATE the existing prompt file
|
|
88
|
+
|
|
89
|
+
## IF NO FILES EXIST
|
|
90
|
+
|
|
91
|
+
If the workspace scan shows 0 files, do NOT call any tools.
|
|
92
|
+
Work only from the user's description. Ask clarifying questions if needed.
|
|
93
|
+
|
|
94
|
+
## CRITICAL: ONLY REFERENCE WHAT ACTUALLY EXISTS
|
|
95
|
+
|
|
96
|
+
The "Detected Workspace" block below shows the REAL files, languages, and structure.
|
|
97
|
+
- Read the code to DETERMINE the tech stack — don't guess
|
|
98
|
+
- If there is NO database file → do NOT mention databases, PostgreSQL, MySQL, SQL, ORMs
|
|
99
|
+
- If there is NO Dockerfile → do NOT mention Docker or containers
|
|
100
|
+
- If there is NO frontend code → do NOT mention HTML, CSS, JavaScript, React
|
|
101
|
+
- NEVER invent technologies not found in the code you read
|
|
102
|
+
- NEVER ask the user "what language/framework?" — you already know from reading the files
|
|
103
|
+
- ONLY suggest changes that extend what ALREADY EXISTS in the project
|
|
104
|
+
|
|
105
|
+
## WHAT TO SHOW THE USER
|
|
106
|
+
|
|
107
|
+
When reading files, do NOT dump the code to the user. The system shows only:
|
|
108
|
+
- File name and line count
|
|
109
|
+
- Key classes and functions found
|
|
110
|
+
|
|
111
|
+
Your response to the user should be a summary of what you found, not the code itself.
|
|
112
|
+
The full code is available to you internally — use it to build the prompt.
|
|
113
|
+
|
|
114
|
+
## FORBIDDEN
|
|
115
|
+
|
|
116
|
+
- Complete source files or full applications
|
|
117
|
+
- Generic template prompts that could apply to any project
|
|
118
|
+
- Inventing technologies not in the detected workspace
|
|
119
|
+
- Code blocks longer than ~12 lines
|
|
120
|
+
- Mentioning databases, ORMs, or SQL when the project has none
|
|
121
|
+
|
|
122
|
+
## IF THE USER ASKS YOU TO BUILD / WRITE THE CODE
|
|
123
|
+
|
|
124
|
+
Do NOT deliver the implementation. Say:
|
|
125
|
+
"I'm DevObin — a prompt engineering tool. I craft the engineering prompt so a
|
|
126
|
+
coding agent (Codex/Claude/Cursor) builds it for you.
|
|
127
|
+
Use /export to save your engineering document."
|
|
128
|
+
|
|
129
|
+
## OUTPUT FORMAT
|
|
130
|
+
|
|
131
|
+
Write a CUSTOMIZED engineering prompt with these sections, but ONLY include
|
|
132
|
+
sections relevant to THIS specific project:
|
|
133
|
+
|
|
134
|
+
- **Role** — who the coding agent should be (based on the REAL stack)
|
|
135
|
+
- **Project Context** — what the project ACTUALLY is (reference specific files)
|
|
136
|
+
- **Current Architecture** — what you found when reading the code
|
|
137
|
+
- **User Objective** — what the user wants to add/change
|
|
138
|
+
- **Technical Requirements** — based on REAL patterns in the codebase
|
|
139
|
+
- **Constraints** — based on REAL conventions in the project
|
|
140
|
+
- **Security / Performance** — relevant to THIS stack
|
|
141
|
+
|
|
142
|
+
Do NOT include sections that don't apply (e.g., no "Responsive Design" for a CLI tool).
|
|
143
|
+
|
|
144
|
+
## AVAILABLE COMMANDS
|
|
145
|
+
|
|
146
|
+
/run, /read, /write, /ls, /export, /connect, /model, /project, /memory, /scan
|
|
147
|
+
When ready: tell user "Ready — use /export to save your engineering document." """
|
|
148
|
+
|
|
149
|
+
LOGO = r"""[#00d4aa]
|
|
150
|
+
___ ___ _ _
|
|
151
|
+
| \ _____ __ / _ \| |__(_)_ _
|
|
152
|
+
| |) / -_) V / | (_) | '_ \ | ' \
|
|
153
|
+
|___/\___|\_/ \___/|_.__/_|_||_|[/]
|
|
154
|
+
[#4a4a4a] Engineering Context Compiler · Prompt Architect[/]
|
|
155
|
+
[#545d68] by [bold #e6edf3]Mobin Hasanghasemi[/] · github.com/mobinhasanghasemi[/]"""
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class ChatScreen(Screen):
|
|
159
|
+
"""Main chat screen."""
|
|
160
|
+
|
|
161
|
+
CSS = """
|
|
162
|
+
Screen { background: #0d1117; }
|
|
163
|
+
|
|
164
|
+
#header {
|
|
165
|
+
height: auto;
|
|
166
|
+
dock: top;
|
|
167
|
+
background: #0d1117;
|
|
168
|
+
padding: 1 2 0 2;
|
|
169
|
+
}
|
|
170
|
+
#logo { height: auto; }
|
|
171
|
+
#status-line {
|
|
172
|
+
height: 1;
|
|
173
|
+
color: #7d8590;
|
|
174
|
+
margin-top: 1;
|
|
175
|
+
}
|
|
176
|
+
#header-rule {
|
|
177
|
+
height: 1;
|
|
178
|
+
color: #1f2733;
|
|
179
|
+
background: #0d1117;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
#chat-area {
|
|
183
|
+
height: 1fr;
|
|
184
|
+
padding: 1 2;
|
|
185
|
+
scrollbar-size-vertical: 1;
|
|
186
|
+
scrollbar-color: #2b3440;
|
|
187
|
+
scrollbar-color-hover: #00d4aa;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.msg {
|
|
191
|
+
height: auto;
|
|
192
|
+
margin: 0 0 1 0;
|
|
193
|
+
padding: 0 1;
|
|
194
|
+
}
|
|
195
|
+
.msg-user {
|
|
196
|
+
border-left: thick #00d4aa;
|
|
197
|
+
background: #11171f;
|
|
198
|
+
}
|
|
199
|
+
.msg-ai {
|
|
200
|
+
border-left: thick #2f81f7;
|
|
201
|
+
}
|
|
202
|
+
.msg-sys { border-left: thick #3a3a3a; }
|
|
203
|
+
.msg-err { border-left: thick #f85149; }
|
|
204
|
+
.msg-tech { border-left: thick #d29922; }
|
|
205
|
+
.msg-run { border-left: thick #a371f7; background: #161b22; }
|
|
206
|
+
.msg-output { border-left: thick #3fb950; background: #0d1117; }
|
|
207
|
+
.msg-file { border-left: thick #79c0ff; background: #0d1117; }
|
|
208
|
+
|
|
209
|
+
.role {
|
|
210
|
+
height: 1;
|
|
211
|
+
text-style: bold;
|
|
212
|
+
margin-bottom: 0;
|
|
213
|
+
}
|
|
214
|
+
.role-user { color: #00d4aa; }
|
|
215
|
+
.role-ai { color: #2f81f7; }
|
|
216
|
+
.role-sys { color: #7d8590; }
|
|
217
|
+
.role-err { color: #f85149; }
|
|
218
|
+
.role-tech { color: #d29922; }
|
|
219
|
+
.role-run { color: #a371f7; }
|
|
220
|
+
.role-output { color: #3fb950; }
|
|
221
|
+
.role-file { color: #79c0ff; }
|
|
222
|
+
|
|
223
|
+
.body { height: auto; color: #e6edf3; padding: 0; }
|
|
224
|
+
.body-sys { color: #7d8590; }
|
|
225
|
+
.body-err { color: #f85149; }
|
|
226
|
+
.body-tech { color: #d29922; }
|
|
227
|
+
|
|
228
|
+
#input-box {
|
|
229
|
+
height: auto;
|
|
230
|
+
dock: bottom;
|
|
231
|
+
background: #0d1117;
|
|
232
|
+
padding: 0 2 1 2;
|
|
233
|
+
}
|
|
234
|
+
#chat-input {
|
|
235
|
+
width: 100%;
|
|
236
|
+
background: #161b22;
|
|
237
|
+
color: #e6edf3;
|
|
238
|
+
border: round #2b3440;
|
|
239
|
+
padding: 0 1;
|
|
240
|
+
}
|
|
241
|
+
#chat-input:focus { border: round #00d4aa; }
|
|
242
|
+
#hint-bar {
|
|
243
|
+
height: 1;
|
|
244
|
+
color: #545d68;
|
|
245
|
+
margin-top: 0;
|
|
246
|
+
}
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
BINDINGS = [
|
|
250
|
+
Binding("ctrl+k", "show_commands", "Commands"),
|
|
251
|
+
Binding("ctrl+l", "clear_chat", "Clear"),
|
|
252
|
+
]
|
|
253
|
+
|
|
254
|
+
def __init__(self) -> None:
|
|
255
|
+
super().__init__()
|
|
256
|
+
self.config = get_config()
|
|
257
|
+
project = self.config.get("active_project")
|
|
258
|
+
self.memory = MemoryManager(project)
|
|
259
|
+
self.storage = ProjectStorage(project) if project else None
|
|
260
|
+
if self.storage:
|
|
261
|
+
self.storage.new_session() # Start a new session for this chat
|
|
262
|
+
self._loading = False
|
|
263
|
+
self._stream_body: Static | None = None
|
|
264
|
+
self._executor = get_executor()
|
|
265
|
+
self._files = get_file_manager()
|
|
266
|
+
self._last_ai_response: str = ""
|
|
267
|
+
self._project_map = None # cached ProjectMap from scanning the workspace
|
|
268
|
+
self._scanned = False
|
|
269
|
+
self._last_update_len = 0 # throttle streaming updates
|
|
270
|
+
|
|
271
|
+
def compose(self) -> ComposeResult:
|
|
272
|
+
with Vertical(id="header"):
|
|
273
|
+
yield Static(LOGO, id="logo")
|
|
274
|
+
yield Static(self._status_text(), id="status-line")
|
|
275
|
+
yield Static("─" * 200, id="header-rule")
|
|
276
|
+
|
|
277
|
+
with VerticalScroll(id="chat-area"):
|
|
278
|
+
yield Static(
|
|
279
|
+
"[#00d4aa]DevObin AI — Prompt Architect Mode[/]\n"
|
|
280
|
+
"[#7d8590]Describe your project idea.[/]\n"
|
|
281
|
+
"[#545d68]I craft engineering prompts for AI coding agents.[/] [bold #e6edf3]No full code[/][#545d68] — only short snippets to sharpen the spec.[/]\n"
|
|
282
|
+
"[#545d68]Type[/] [bold]/[/] [ #545d68]for commands · /export to save[/]",
|
|
283
|
+
classes="msg msg-sys",
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
with Vertical(id="input-box"):
|
|
287
|
+
yield Input(placeholder="Describe your project or type / for commands…", id="chat-input")
|
|
288
|
+
yield Static(self._hint_text(), id="hint-bar")
|
|
289
|
+
|
|
290
|
+
# ── Header helpers ─────────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
def _status_text(self) -> str:
|
|
293
|
+
model = self.config.active_model or "not connected"
|
|
294
|
+
provider = self.config.active_provider or "none"
|
|
295
|
+
project = self.config.get("active_project") or "default"
|
|
296
|
+
dot = "[#3fb950]●[/]" if self.config.active_provider else "[#f85149]●[/]"
|
|
297
|
+
return (
|
|
298
|
+
f"{dot} [#e6edf3]{escape(provider)}[/] "
|
|
299
|
+
f"[#545d68]·[/] model [#e6edf3]{escape(model)}[/] "
|
|
300
|
+
f"[#545d68]·[/] project [#e6edf3]{escape(project)}[/]"
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
def _hint_text(self) -> str:
|
|
304
|
+
return "[#545d68]Prompt Architect Mode · Full Code Off · Snippets OK · /export to save[/]"
|
|
305
|
+
|
|
306
|
+
def _refresh_status(self) -> None:
|
|
307
|
+
try:
|
|
308
|
+
self.query_one("#status-line", Static).update(self._status_text())
|
|
309
|
+
except Exception:
|
|
310
|
+
pass
|
|
311
|
+
|
|
312
|
+
def on_mount(self) -> None:
|
|
313
|
+
self.query_one("#chat-input", Input).focus()
|
|
314
|
+
|
|
315
|
+
# ── Input handling ─────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
@on(Input.Submitted, "#chat-input")
|
|
318
|
+
def on_submitted(self, event: Input.Submitted) -> None:
|
|
319
|
+
text = event.value.strip()
|
|
320
|
+
event.input.value = ""
|
|
321
|
+
if not text or self._loading:
|
|
322
|
+
return
|
|
323
|
+
|
|
324
|
+
if text == "/":
|
|
325
|
+
self.action_show_commands()
|
|
326
|
+
elif text.startswith("/"):
|
|
327
|
+
self._handle_cmd(text)
|
|
328
|
+
else:
|
|
329
|
+
self._process(text)
|
|
330
|
+
|
|
331
|
+
def action_show_commands(self) -> None:
|
|
332
|
+
from devobin.ui.command_palette import CommandPalette
|
|
333
|
+
self.app.push_screen(CommandPalette())
|
|
334
|
+
|
|
335
|
+
def action_clear_chat(self) -> None:
|
|
336
|
+
self.cmd_clear("")
|
|
337
|
+
|
|
338
|
+
def _handle_cmd(self, text: str) -> None:
|
|
339
|
+
parts = text.split(maxsplit=1)
|
|
340
|
+
cmd = parts[0].lower()
|
|
341
|
+
args = parts[1] if len(parts) > 1 else ""
|
|
342
|
+
|
|
343
|
+
def _open(module: str, klass: str):
|
|
344
|
+
mod = __import__(f"devobin.ui.{module}", fromlist=[klass])
|
|
345
|
+
self.app.push_screen(getattr(mod, klass)())
|
|
346
|
+
|
|
347
|
+
cmds = {
|
|
348
|
+
"/help": lambda a: _open("command_palette", "CommandPalette"),
|
|
349
|
+
"/connect": lambda a: _open("connect_modal", "ConnectModal"),
|
|
350
|
+
"/models": lambda a: _open("model_modal", "ModelModal"),
|
|
351
|
+
"/model": self._cmd_model,
|
|
352
|
+
"/project": lambda a: _open("project_modal", "ProjectModal"),
|
|
353
|
+
"/memory": lambda a: _open("memory_modal", "MemoryModal"),
|
|
354
|
+
"/export": self._cmd_export,
|
|
355
|
+
"/scan": self._cmd_scan,
|
|
356
|
+
"/history": self._cmd_history,
|
|
357
|
+
"/settings": lambda a: _open("settings_modal", "SettingsModal"),
|
|
358
|
+
"/clear": self.cmd_clear,
|
|
359
|
+
"/exit": lambda a: self.app.exit(),
|
|
360
|
+
"/quit": lambda a: self.app.exit(),
|
|
361
|
+
# Shell & file commands
|
|
362
|
+
"/run": self._cmd_run,
|
|
363
|
+
"/exec": self._cmd_run,
|
|
364
|
+
"/read": self._cmd_read,
|
|
365
|
+
"/cat": self._cmd_read,
|
|
366
|
+
"/write": self._cmd_write,
|
|
367
|
+
"/create": self._cmd_write,
|
|
368
|
+
"/ls": self._cmd_ls,
|
|
369
|
+
"/dir": self._cmd_ls,
|
|
370
|
+
"/tree": self._cmd_tree,
|
|
371
|
+
"/cwd": self._cmd_cwd,
|
|
372
|
+
"/cd": self._cmd_cd,
|
|
373
|
+
"/rm": self._cmd_rm,
|
|
374
|
+
"/delete": self._cmd_rm,
|
|
375
|
+
"/touch": self._cmd_touch,
|
|
376
|
+
"/mkdir": self._cmd_mkdir,
|
|
377
|
+
"/env": self._cmd_env,
|
|
378
|
+
"/pip": self._cmd_pip,
|
|
379
|
+
"/npm": self._cmd_npm,
|
|
380
|
+
"/git": self._cmd_git,
|
|
381
|
+
"/python": self._cmd_python,
|
|
382
|
+
"/paste": self._cmd_paste,
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
handler = cmds.get(cmd)
|
|
386
|
+
if handler:
|
|
387
|
+
handler(args)
|
|
388
|
+
else:
|
|
389
|
+
self._msg("sys", f"Unknown command: {escape(cmd)}")
|
|
390
|
+
|
|
391
|
+
def _cmd_model(self, args: str) -> None:
|
|
392
|
+
if args:
|
|
393
|
+
self.config.active_model = args.strip()
|
|
394
|
+
self._msg("sys", f"Model set to {args.strip()}")
|
|
395
|
+
self._refresh_status()
|
|
396
|
+
else:
|
|
397
|
+
self._msg("sys", f"Current model: {self.config.active_model or 'None'}")
|
|
398
|
+
|
|
399
|
+
def _cmd_export(self, args: str) -> None:
|
|
400
|
+
"""Export prompt, optionally with a custom filename."""
|
|
401
|
+
from devobin.ui.export_modal import ExportModal
|
|
402
|
+
custom = args.strip() if args.strip() else ""
|
|
403
|
+
self.app.push_screen(ExportModal(
|
|
404
|
+
custom_filename=custom,
|
|
405
|
+
ai_response=self._last_ai_response,
|
|
406
|
+
))
|
|
407
|
+
|
|
408
|
+
def cmd_clear(self, args: str = "") -> None:
|
|
409
|
+
if self.storage:
|
|
410
|
+
self.storage.clear_history()
|
|
411
|
+
area = self.query_one("#chat-area")
|
|
412
|
+
area.remove_children()
|
|
413
|
+
self._msg("sys", "Conversation cleared.")
|
|
414
|
+
|
|
415
|
+
# ── Workspace scanning ─────────────────────────────────────────
|
|
416
|
+
|
|
417
|
+
def _ensure_scan(self, announce: bool = False):
|
|
418
|
+
"""Scan the workspace once and cache the project map."""
|
|
419
|
+
if self._scanned and self._project_map is not None:
|
|
420
|
+
return self._project_map
|
|
421
|
+
try:
|
|
422
|
+
from devobin.core.scanner import scan_workspace
|
|
423
|
+
self._project_map = scan_workspace()
|
|
424
|
+
self._scanned = True
|
|
425
|
+
if announce:
|
|
426
|
+
pm = self._project_map
|
|
427
|
+
self._msg(
|
|
428
|
+
"tech",
|
|
429
|
+
f"Scanned workspace: {pm.total_files} files · "
|
|
430
|
+
f"primary {pm.primary_language} · {len(pm.dirs)} dirs",
|
|
431
|
+
)
|
|
432
|
+
except Exception as e:
|
|
433
|
+
self._msg("err", f"Scan failed: {escape(str(e))}")
|
|
434
|
+
return self._project_map
|
|
435
|
+
|
|
436
|
+
def _cmd_history(self, args: str) -> None:
|
|
437
|
+
"""Open the chat history modal."""
|
|
438
|
+
from devobin.ui.history_modal import HistoryModal
|
|
439
|
+
self.app.push_screen(HistoryModal())
|
|
440
|
+
|
|
441
|
+
def _cmd_scan(self, args: str) -> None:
|
|
442
|
+
"""Force a fresh scan of the current working directory."""
|
|
443
|
+
self._scanned = False
|
|
444
|
+
self._project_map = None
|
|
445
|
+
pm = self._ensure_scan(announce=True)
|
|
446
|
+
if pm is not None:
|
|
447
|
+
langs = ", ".join(f"{k} ({v})" for k, v in
|
|
448
|
+
sorted(pm.languages.items(), key=lambda kv: kv[1], reverse=True)[:8])
|
|
449
|
+
entries = ", ".join(pm.entry_points[:6]) or "none detected"
|
|
450
|
+
manifests = ", ".join(pm.manifests.keys()) or "none"
|
|
451
|
+
self._msg(
|
|
452
|
+
"file",
|
|
453
|
+
f"[bold]Project map[/]\n"
|
|
454
|
+
f"root: {escape(pm.root)}\n"
|
|
455
|
+
f"languages: {escape(langs)}\n"
|
|
456
|
+
f"entry points: {escape(entries)}\n"
|
|
457
|
+
f"manifests: {escape(manifests)}",
|
|
458
|
+
styled=True,
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
# ── Shell & File Commands ──────────────────────────────────────
|
|
462
|
+
|
|
463
|
+
def _cmd_run(self, args: str) -> None:
|
|
464
|
+
"""Execute a shell command."""
|
|
465
|
+
if not args.strip():
|
|
466
|
+
self._msg("sys", "Usage: /run <command>\nExample: /run python --version")
|
|
467
|
+
return
|
|
468
|
+
self._msg("run", f"$ {args}")
|
|
469
|
+
result = self._executor.run(args)
|
|
470
|
+
if result.success:
|
|
471
|
+
self._msg("output", result.output)
|
|
472
|
+
else:
|
|
473
|
+
self._msg("err", result.output)
|
|
474
|
+
if self.storage:
|
|
475
|
+
self.storage.add_message("user", f"/run {args}")
|
|
476
|
+
self.storage.add_message("assistant", f"[command output]\n{result.output}")
|
|
477
|
+
|
|
478
|
+
def _cmd_read(self, args: str) -> None:
|
|
479
|
+
"""Read a file."""
|
|
480
|
+
if not args.strip():
|
|
481
|
+
self._msg("sys", "Usage: /read <file_path>\nExample: /read main.py")
|
|
482
|
+
return
|
|
483
|
+
result = self._files.read(args.strip())
|
|
484
|
+
self._msg("file", result.output)
|
|
485
|
+
if self.storage and result.success:
|
|
486
|
+
self.storage.add_message("user", f"/read {args}")
|
|
487
|
+
|
|
488
|
+
def _cmd_write(self, args: str) -> None:
|
|
489
|
+
"""Write content to a file. Content is everything after the filename."""
|
|
490
|
+
parts = args.strip().split("\n", 1)
|
|
491
|
+
if not parts[0].strip():
|
|
492
|
+
self._msg("sys", "Usage: /write <file_path>\n<content>\n\nExample:\n/write hello.py\nprint('hello')")
|
|
493
|
+
return
|
|
494
|
+
filepath = parts[0].strip()
|
|
495
|
+
content = parts[1] if len(parts) > 1 else ""
|
|
496
|
+
if not content:
|
|
497
|
+
self._msg("sys", f"Provide content after the filename.\nUsage: /write {filepath}\n<your content here>")
|
|
498
|
+
return
|
|
499
|
+
result = self._files.write(filepath, content)
|
|
500
|
+
if result.success:
|
|
501
|
+
self._msg("file", result.output)
|
|
502
|
+
else:
|
|
503
|
+
self._msg("err", result.output)
|
|
504
|
+
if self.storage:
|
|
505
|
+
self.storage.add_message("user", f"/write {filepath}")
|
|
506
|
+
|
|
507
|
+
def _cmd_ls(self, args: str) -> None:
|
|
508
|
+
"""List directory contents."""
|
|
509
|
+
path = args.strip() or "."
|
|
510
|
+
result = self._files.list_dir(path)
|
|
511
|
+
self._msg("file", result.output)
|
|
512
|
+
|
|
513
|
+
def _cmd_tree(self, args: str) -> None:
|
|
514
|
+
"""Show project file tree."""
|
|
515
|
+
path = args.strip() or "."
|
|
516
|
+
result = self._executor.run(f'find "{path}" -not -path "*/.git/*" -not -path "*/__pycache__/*" -not -path "*/node_modules/*" -not -path "*/.venv/*" | head -50')
|
|
517
|
+
if result.stdout.strip():
|
|
518
|
+
self._msg("file", f"[bold]{escape(path)}[/]\n{escape(result.stdout.strip())}", styled=True)
|
|
519
|
+
else:
|
|
520
|
+
self._cmd_ls(args)
|
|
521
|
+
|
|
522
|
+
def _cmd_cwd(self, _args: str = "") -> None:
|
|
523
|
+
"""Show current working directory."""
|
|
524
|
+
self._msg("sys", f"cwd: {os.getcwd()}")
|
|
525
|
+
|
|
526
|
+
def _cmd_cd(self, args: str) -> None:
|
|
527
|
+
"""Change working directory."""
|
|
528
|
+
if not args.strip():
|
|
529
|
+
self._msg("sys", "Usage: /cd <path>")
|
|
530
|
+
return
|
|
531
|
+
try:
|
|
532
|
+
os.chdir(args.strip())
|
|
533
|
+
self._msg("sys", f"cwd: {os.getcwd()}")
|
|
534
|
+
self._executor = get_executor(os.getcwd())
|
|
535
|
+
self._files = get_file_manager(os.getcwd())
|
|
536
|
+
except Exception as e:
|
|
537
|
+
self._msg("err", f"cd failed: {e}")
|
|
538
|
+
|
|
539
|
+
def _cmd_rm(self, args: str) -> None:
|
|
540
|
+
"""Delete a file or directory."""
|
|
541
|
+
if not args.strip():
|
|
542
|
+
self._msg("sys", "Usage: /rm <file_or_dir>")
|
|
543
|
+
return
|
|
544
|
+
result = self._files.delete(args.strip())
|
|
545
|
+
if result.success:
|
|
546
|
+
self._msg("file", result.output)
|
|
547
|
+
else:
|
|
548
|
+
self._msg("err", result.output)
|
|
549
|
+
|
|
550
|
+
def _cmd_touch(self, args: str) -> None:
|
|
551
|
+
"""Create an empty file."""
|
|
552
|
+
if not args.strip():
|
|
553
|
+
self._msg("sys", "Usage: /touch <file_path>")
|
|
554
|
+
return
|
|
555
|
+
result = self._files.write(args.strip(), "")
|
|
556
|
+
if result.success:
|
|
557
|
+
self._msg("file", f"[green]✓[/] Created: [bold]{escape(args.strip())}[/]", styled=True)
|
|
558
|
+
else:
|
|
559
|
+
self._msg("err", result.output)
|
|
560
|
+
|
|
561
|
+
def _cmd_mkdir(self, args: str) -> None:
|
|
562
|
+
"""Create a directory."""
|
|
563
|
+
if not args.strip():
|
|
564
|
+
self._msg("sys", "Usage: /mkdir <directory>")
|
|
565
|
+
return
|
|
566
|
+
try:
|
|
567
|
+
os.makedirs(args.strip(), exist_ok=True)
|
|
568
|
+
self._msg("file", f"[green]✓[/] Created directory: [bold]{escape(args.strip())}[/]", styled=True)
|
|
569
|
+
except Exception as e:
|
|
570
|
+
self._msg("err", f"mkdir failed: {e}")
|
|
571
|
+
|
|
572
|
+
def _cmd_env(self, args: str) -> None:
|
|
573
|
+
"""Show environment variables (filtered)."""
|
|
574
|
+
safe_keys = ["PATH", "HOME", "USER", "SHELL", "PYTHON", "NODE", "PWD",
|
|
575
|
+
"VIRTUAL_ENV", "CONDA_DEFAULT_ENV", "TERM", "LANG", "EDITOR"]
|
|
576
|
+
env_vars = {k: v for k, v in os.environ.items()
|
|
577
|
+
if any(sk in k.upper() for sk in safe_keys)}
|
|
578
|
+
if args.strip():
|
|
579
|
+
val = os.environ.get(args.strip().upper(), "not set")
|
|
580
|
+
self._msg("sys", f"{args.strip().upper()}={val}")
|
|
581
|
+
else:
|
|
582
|
+
lines = "\n".join(f" {k}={v}" for k, v in sorted(env_vars.items()))
|
|
583
|
+
self._msg("sys", f"Environment:\n{lines}")
|
|
584
|
+
|
|
585
|
+
def _cmd_pip(self, args: str) -> None:
|
|
586
|
+
"""Run pip command."""
|
|
587
|
+
if not args.strip():
|
|
588
|
+
self._cmd_run("pip --version")
|
|
589
|
+
else:
|
|
590
|
+
self._cmd_run(f"pip {args}")
|
|
591
|
+
|
|
592
|
+
def _cmd_npm(self, args: str) -> None:
|
|
593
|
+
"""Run npm command."""
|
|
594
|
+
if not args.strip():
|
|
595
|
+
self._cmd_run("npm --version")
|
|
596
|
+
else:
|
|
597
|
+
self._cmd_run(f"npm {args}")
|
|
598
|
+
|
|
599
|
+
def _cmd_git(self, args: str) -> None:
|
|
600
|
+
"""Run git command."""
|
|
601
|
+
if not args.strip():
|
|
602
|
+
self._cmd_run("git --version")
|
|
603
|
+
else:
|
|
604
|
+
self._cmd_run(f"git {args}")
|
|
605
|
+
|
|
606
|
+
def _cmd_python(self, args: str) -> None:
|
|
607
|
+
"""Run Python code or command."""
|
|
608
|
+
if not args.strip():
|
|
609
|
+
self._cmd_run("python --version")
|
|
610
|
+
elif args.strip().endswith(".py"):
|
|
611
|
+
self._cmd_run(f"python {args}")
|
|
612
|
+
else:
|
|
613
|
+
# Inline python code
|
|
614
|
+
self._msg("run", f"$ python -c \"{args}\"")
|
|
615
|
+
result = self._executor.run(f'python -c "{args}"')
|
|
616
|
+
if result.success:
|
|
617
|
+
self._msg("output", result.output)
|
|
618
|
+
else:
|
|
619
|
+
self._msg("err", result.output)
|
|
620
|
+
|
|
621
|
+
def _cmd_paste(self, _args: str = "") -> None:
|
|
622
|
+
"""Paste text from clipboard and send it."""
|
|
623
|
+
try:
|
|
624
|
+
import subprocess
|
|
625
|
+
result = subprocess.run(
|
|
626
|
+
["powershell", "-command", "Get-Clipboard"],
|
|
627
|
+
capture_output=True, text=True, timeout=5
|
|
628
|
+
)
|
|
629
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
630
|
+
text = result.stdout.strip()
|
|
631
|
+
self._msg("user", text)
|
|
632
|
+
if self.storage:
|
|
633
|
+
self.storage.add_message("user", text)
|
|
634
|
+
if self.config.active_provider:
|
|
635
|
+
self._loading = True
|
|
636
|
+
self._begin_stream()
|
|
637
|
+
self.run_worker(self._ai_chat(text), exclusive=True)
|
|
638
|
+
else:
|
|
639
|
+
analysis = analyze_input(text)
|
|
640
|
+
self._local(analysis)
|
|
641
|
+
else:
|
|
642
|
+
self._msg("sys", "Clipboard is empty or not accessible.")
|
|
643
|
+
except Exception as e:
|
|
644
|
+
self._msg("err", f"Paste failed: {e}")
|
|
645
|
+
|
|
646
|
+
# ── Message pipeline ───────────────────────────────────────────
|
|
647
|
+
|
|
648
|
+
def _process(self, text: str) -> None:
|
|
649
|
+
self._msg("user", text)
|
|
650
|
+
if self.storage:
|
|
651
|
+
self.storage.add_message("user", text)
|
|
652
|
+
|
|
653
|
+
# Auto-explore the real workspace on the first message (like Claude Code).
|
|
654
|
+
if not self._scanned:
|
|
655
|
+
self._ensure_scan(announce=True)
|
|
656
|
+
|
|
657
|
+
analysis = analyze_input(text)
|
|
658
|
+
if analysis.detected_techs:
|
|
659
|
+
names = sorted({t.name for t in analysis.detected_techs})
|
|
660
|
+
self._msg("tech", f"Detected: {', '.join(names)}")
|
|
661
|
+
|
|
662
|
+
if self.config.active_provider:
|
|
663
|
+
self._loading = True
|
|
664
|
+
self._begin_stream()
|
|
665
|
+
self.run_worker(self._ai_chat(text), exclusive=True)
|
|
666
|
+
else:
|
|
667
|
+
self._local(analysis)
|
|
668
|
+
|
|
669
|
+
def _finalize_stream(self, text: str) -> None:
|
|
670
|
+
"""Convert the current streaming block into a permanent AI message."""
|
|
671
|
+
if self._stream_body is not None:
|
|
672
|
+
self._stream_body.update(escape(smart_display(str(text))))
|
|
673
|
+
self._stream_body.parent.set_class(True, "msg-ai")
|
|
674
|
+
self._stream_body = None
|
|
675
|
+
self._last_update_len = 0
|
|
676
|
+
else:
|
|
677
|
+
self._msg("ai", text)
|
|
678
|
+
|
|
679
|
+
def _begin_stream(self) -> None:
|
|
680
|
+
"""Mount the AI message block with a thinking placeholder."""
|
|
681
|
+
area = self.query_one("#chat-area", VerticalScroll)
|
|
682
|
+
block = Vertical(classes="msg msg-ai")
|
|
683
|
+
area.mount(block)
|
|
684
|
+
block.mount(Static("DevObin", classes="role role-ai"))
|
|
685
|
+
body = Static("[dim italic]thinking…[/]", classes="body")
|
|
686
|
+
block.mount(body)
|
|
687
|
+
self._stream_body = body
|
|
688
|
+
self._last_update_len = 0
|
|
689
|
+
area.scroll_end(animate=False)
|
|
690
|
+
|
|
691
|
+
async def _ai_chat(self, text: str) -> None:
|
|
692
|
+
from devobin.providers import get_provider
|
|
693
|
+
from devobin.core.tools import parse_tool_calls, strip_tool_calls, execute_tool_calls
|
|
694
|
+
|
|
695
|
+
msgs = self.storage.get_context_messages(max_messages=20) if self.storage else []
|
|
696
|
+
|
|
697
|
+
system = SYSTEM_PROMPT
|
|
698
|
+
mem = self.memory.get_memory_context()
|
|
699
|
+
if mem:
|
|
700
|
+
system += f"\n\n## User Preferences\n{mem}"
|
|
701
|
+
|
|
702
|
+
# Inject the real project map so DevObin reasons about the actual
|
|
703
|
+
# codebase in the working directory, not just the user's words.
|
|
704
|
+
pm = self._ensure_scan()
|
|
705
|
+
if pm is not None and pm.total_files:
|
|
706
|
+
system += (
|
|
707
|
+
"\n\n## Detected Workspace (scanned from the current directory)\n"
|
|
708
|
+
+ pm.to_context(max_chars=8000)
|
|
709
|
+
+ "\n\nTHIS IS THE AUTHORITATIVE SOURCE. Ground every instruction in "
|
|
710
|
+
"what is ACTUALLY here. Do NOT reference, suggest, or hallucinate "
|
|
711
|
+
"technologies that do not appear in the scan above. "
|
|
712
|
+
"If it's a Python project, write Python instructions. "
|
|
713
|
+
"If it's a Go project, write Go instructions. "
|
|
714
|
+
"Never default to frontend/React/Bootstrap unless the scan proves it."
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
max_out = int(self.config.get("max_output_tokens", 16000))
|
|
718
|
+
MAX_AGENT_ROUNDS = 10
|
|
719
|
+
|
|
720
|
+
# Permission state for this conversation turn
|
|
721
|
+
file_permission: str | None = None # "all", "one", or None (denied)
|
|
722
|
+
|
|
723
|
+
resp = ""
|
|
724
|
+
try:
|
|
725
|
+
provider = get_provider()
|
|
726
|
+
|
|
727
|
+
for agent_round in range(MAX_AGENT_ROUNDS + 1):
|
|
728
|
+
compressed = compress_context(msgs, max_tokens=6000)
|
|
729
|
+
resp = ""
|
|
730
|
+
async for chunk in provider.chat_stream(
|
|
731
|
+
messages=compressed, system=system, temperature=0.7, max_tokens=max_out
|
|
732
|
+
):
|
|
733
|
+
resp += chunk
|
|
734
|
+
self._update(resp)
|
|
735
|
+
|
|
736
|
+
# Check for tool calls
|
|
737
|
+
tool_calls = parse_tool_calls(resp)
|
|
738
|
+
if not tool_calls:
|
|
739
|
+
break # No tool calls — this is the final response
|
|
740
|
+
|
|
741
|
+
# ── Permission check before first tool use ──
|
|
742
|
+
if file_permission is None:
|
|
743
|
+
# Check if there are actually files to read
|
|
744
|
+
if pm is None or pm.total_files == 0:
|
|
745
|
+
self._msg("sys", "No project files found. Skipping file reading.")
|
|
746
|
+
break
|
|
747
|
+
|
|
748
|
+
# Show permission modal
|
|
749
|
+
from devobin.ui.permission_modal import PermissionModal
|
|
750
|
+
file_permission = await self.app.push_screen_wait(
|
|
751
|
+
PermissionModal(file_count=pm.total_files)
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
if file_permission is None:
|
|
755
|
+
# Denied — strip tool calls and continue without reading
|
|
756
|
+
clean = strip_tool_calls(resp)
|
|
757
|
+
if clean:
|
|
758
|
+
self._finalize_stream(clean)
|
|
759
|
+
self._msg("sys", "File reading denied. Working from description only.")
|
|
760
|
+
# Tell the model to work without files
|
|
761
|
+
msgs.append({"role": "assistant", "content": resp})
|
|
762
|
+
msgs.append({"role": "user", "content": (
|
|
763
|
+
"The user denied file access. Do NOT call any more tools. "
|
|
764
|
+
"Write your engineering prompt based only on what the user "
|
|
765
|
+
"told you and the workspace scan (file names and structure only)."
|
|
766
|
+
)})
|
|
767
|
+
self._begin_stream()
|
|
768
|
+
continue
|
|
769
|
+
|
|
770
|
+
# ── For "ask one by one" permission, check each read ──
|
|
771
|
+
if file_permission == "one":
|
|
772
|
+
allowed_calls = []
|
|
773
|
+
for tc in tool_calls:
|
|
774
|
+
if tc.tool == "read":
|
|
775
|
+
from devobin.ui.ask_modal import AskModal
|
|
776
|
+
answer = await self.app.push_screen_wait(
|
|
777
|
+
AskModal(f"Allow reading: {tc.arg}?")
|
|
778
|
+
)
|
|
779
|
+
if answer is not None: # user said yes (or typed something)
|
|
780
|
+
allowed_calls.append(tc)
|
|
781
|
+
else:
|
|
782
|
+
self._msg("sys", f"Skipped reading: {tc.arg}")
|
|
783
|
+
else:
|
|
784
|
+
allowed_calls.append(tc) # glob/grep/ls are always OK
|
|
785
|
+
tool_calls = allowed_calls
|
|
786
|
+
if not tool_calls:
|
|
787
|
+
# All reads were denied, continue without tools
|
|
788
|
+
clean = strip_tool_calls(resp)
|
|
789
|
+
if clean:
|
|
790
|
+
self._finalize_stream(clean)
|
|
791
|
+
self._msg("sys", "All file reads skipped.")
|
|
792
|
+
break
|
|
793
|
+
|
|
794
|
+
# ── Show brief status, not full process ──
|
|
795
|
+
read_count = sum(1 for tc in tool_calls if tc.tool == "read")
|
|
796
|
+
write_count = sum(1 for tc in tool_calls if tc.tool == "write")
|
|
797
|
+
if write_count:
|
|
798
|
+
status = f"Creating prompt file... (round {agent_round + 1})"
|
|
799
|
+
elif read_count:
|
|
800
|
+
status = f"Reading files... (round {agent_round + 1})"
|
|
801
|
+
else:
|
|
802
|
+
status = f"Exploring... (round {agent_round + 1})"
|
|
803
|
+
|
|
804
|
+
# Finalize the current streaming block with status text
|
|
805
|
+
self._finalize_stream(status)
|
|
806
|
+
|
|
807
|
+
# Execute tools (full results go to model only)
|
|
808
|
+
results = execute_tool_calls(tool_calls)
|
|
809
|
+
|
|
810
|
+
# Show write results to user
|
|
811
|
+
for tc in tool_calls:
|
|
812
|
+
if tc.tool == "write":
|
|
813
|
+
self._msg("file", f"Created: {tc.arg}")
|
|
814
|
+
|
|
815
|
+
# Feed FULL results back into conversation for the model
|
|
816
|
+
msgs.append({"role": "assistant", "content": resp})
|
|
817
|
+
msgs.append({"role": "user", "content": (
|
|
818
|
+
f"Tool results from round {agent_round + 1}:\n\n{results}\n\n"
|
|
819
|
+
"IMPORTANT: You MUST read ALL important files before producing "
|
|
820
|
+
"the final prompt. Do not stop after reading just a few files. "
|
|
821
|
+
"Read every significant source file in the project. "
|
|
822
|
+
"You may call more tools to continue reading, or if you have "
|
|
823
|
+
"read enough, produce your final customized engineering prompt."
|
|
824
|
+
)})
|
|
825
|
+
|
|
826
|
+
# Start a new streaming block for the next round
|
|
827
|
+
self._begin_stream()
|
|
828
|
+
|
|
829
|
+
# Final response handling
|
|
830
|
+
if not resp.strip():
|
|
831
|
+
self._update("_(no response)_")
|
|
832
|
+
else:
|
|
833
|
+
# Check for <devobin-ask> tags — if the model wants to ask the user
|
|
834
|
+
# a question, show the modal and feed the answer back.
|
|
835
|
+
ask_match = self._extract_ask_tag(resp)
|
|
836
|
+
if ask_match is not None:
|
|
837
|
+
resp_clean = self._strip_ask_tag(resp)
|
|
838
|
+
self._last_ai_response = resp_clean
|
|
839
|
+
if self.storage:
|
|
840
|
+
self.storage.add_message("assistant", resp_clean)
|
|
841
|
+
self.run_worker(self._handle_ask(ask_match, resp_clean, msgs, system, max_out), exclusive=True)
|
|
842
|
+
return
|
|
843
|
+
|
|
844
|
+
# ── Grounding check: did the model hallucinate technologies? ──
|
|
845
|
+
grounding = validate_grounding(resp, pm)
|
|
846
|
+
if not grounding.is_grounded:
|
|
847
|
+
warning_text = "; ".join(grounding.warnings)
|
|
848
|
+
self._msg("err", f"[bold red]Grounding warning:[/] {warning_text}", styled=True)
|
|
849
|
+
self._msg("sys", "Asking model to rewrite without hallucinated technologies...")
|
|
850
|
+
|
|
851
|
+
msgs.append({"role": "assistant", "content": resp})
|
|
852
|
+
msgs.append({"role": "user", "content": (
|
|
853
|
+
"IMPORTANT: Your response mentions technologies that do NOT "
|
|
854
|
+
f"exist in this project: {', '.join(grounding.hallucinations)}. "
|
|
855
|
+
"The scanned workspace has NONE of these. "
|
|
856
|
+
"Rewrite your response using ONLY technologies actually found "
|
|
857
|
+
"in the project. Do NOT suggest adding new databases, frameworks, "
|
|
858
|
+
"or tools unless the user explicitly asked for them. "
|
|
859
|
+
"Reference ONLY files and patterns that exist in the codebase."
|
|
860
|
+
)})
|
|
861
|
+
self._begin_stream()
|
|
862
|
+
compressed = compress_context(msgs, max_tokens=6000)
|
|
863
|
+
resp = ""
|
|
864
|
+
async for chunk in provider.chat_stream(
|
|
865
|
+
messages=compressed, system=system, temperature=0.7, max_tokens=max_out
|
|
866
|
+
):
|
|
867
|
+
resp += chunk
|
|
868
|
+
self._update(resp)
|
|
869
|
+
|
|
870
|
+
self._last_ai_response = resp
|
|
871
|
+
if self.storage:
|
|
872
|
+
self.storage.add_message("assistant", resp)
|
|
873
|
+
|
|
874
|
+
if resp.strip():
|
|
875
|
+
self._extract(text)
|
|
876
|
+
except Exception as e:
|
|
877
|
+
self._update(f"Error: {escape(str(e))}")
|
|
878
|
+
if self._stream_body is not None:
|
|
879
|
+
self._stream_body.parent.set_class(True, "msg-err")
|
|
880
|
+
finally:
|
|
881
|
+
self._loading = False
|
|
882
|
+
self._stream_body = None
|
|
883
|
+
|
|
884
|
+
def _local(self, analysis) -> None:
|
|
885
|
+
if analysis.detected_techs:
|
|
886
|
+
research = research_analysis(analysis)
|
|
887
|
+
parts = ["Based on your input:\n"]
|
|
888
|
+
for name, topic in research.items():
|
|
889
|
+
if topic.best_practices:
|
|
890
|
+
parts.append(f" {escape(name)}:")
|
|
891
|
+
for bp in topic.best_practices[:3]:
|
|
892
|
+
parts.append(f" · {escape(bp)}")
|
|
893
|
+
parts.append("")
|
|
894
|
+
parts.append("\nConnect a provider with /connect to enable AI.")
|
|
895
|
+
self._msg("ai", "\n".join(parts))
|
|
896
|
+
else:
|
|
897
|
+
self._msg(
|
|
898
|
+
"ai",
|
|
899
|
+
"Tell me about your project:\n\n"
|
|
900
|
+
" · What do you want to build?\n"
|
|
901
|
+
" · Which technologies?\n\n"
|
|
902
|
+
"Use /connect to set up an AI provider.",
|
|
903
|
+
)
|
|
904
|
+
|
|
905
|
+
def _extract_ask_tag(self, text: str) -> str | None:
|
|
906
|
+
"""Extract the question from <devobin-ask>...</devobin-ask> if present."""
|
|
907
|
+
import re
|
|
908
|
+
m = re.search(r"<devobin-ask>(.*?)</devobin-ask>", text, re.DOTALL)
|
|
909
|
+
return m.group(1).strip() if m else None
|
|
910
|
+
|
|
911
|
+
def _strip_ask_tag(self, text: str) -> str:
|
|
912
|
+
"""Remove <devobin-ask>...</devobin-ask> from the response."""
|
|
913
|
+
import re
|
|
914
|
+
cleaned = re.sub(r"\s*<devobin-ask>.*?</devobin-ask>\s*", "", text, flags=re.DOTALL)
|
|
915
|
+
return cleaned.strip()
|
|
916
|
+
|
|
917
|
+
async def _handle_ask(self, question: str, prior_resp: str, msgs: list, system: str, max_out: int) -> None:
|
|
918
|
+
"""Show the AskModal, get the user's answer, and continue the conversation."""
|
|
919
|
+
from devobin.ui.ask_modal import AskModal
|
|
920
|
+
|
|
921
|
+
answer = await self.app.push_screen_wait(AskModal(question))
|
|
922
|
+
if answer is None:
|
|
923
|
+
self._msg("sys", "Skipped.")
|
|
924
|
+
self._loading = False
|
|
925
|
+
self._stream_body = None
|
|
926
|
+
return
|
|
927
|
+
|
|
928
|
+
# Feed the answer back as a new user message and continue streaming.
|
|
929
|
+
self._msg("user", answer)
|
|
930
|
+
if self.storage:
|
|
931
|
+
self.storage.add_message("user", answer)
|
|
932
|
+
|
|
933
|
+
msgs.append({"role": "assistant", "content": prior_resp})
|
|
934
|
+
msgs.append({"role": "user", "content": answer})
|
|
935
|
+
|
|
936
|
+
self._begin_stream()
|
|
937
|
+
resp = ""
|
|
938
|
+
try:
|
|
939
|
+
provider = get_provider()
|
|
940
|
+
async for chunk in provider.chat_stream(
|
|
941
|
+
messages=msgs, system=system, temperature=0.7, max_tokens=max_out
|
|
942
|
+
):
|
|
943
|
+
resp += chunk
|
|
944
|
+
self._update(resp)
|
|
945
|
+
|
|
946
|
+
if resp.strip():
|
|
947
|
+
# Check for another ask tag (multi-turn clarification)
|
|
948
|
+
ask2 = self._extract_ask_tag(resp)
|
|
949
|
+
if ask2 is not None:
|
|
950
|
+
resp_clean = self._strip_ask_tag(resp)
|
|
951
|
+
self._last_ai_response = resp_clean
|
|
952
|
+
if self.storage:
|
|
953
|
+
self.storage.add_message("assistant", resp_clean)
|
|
954
|
+
self.run_worker(self._handle_ask(ask2, resp_clean, msgs, system, max_out), exclusive=True)
|
|
955
|
+
return
|
|
956
|
+
|
|
957
|
+
self._last_ai_response = resp
|
|
958
|
+
if self.storage:
|
|
959
|
+
self.storage.add_message("assistant", resp)
|
|
960
|
+
else:
|
|
961
|
+
self._update("_(no response)_")
|
|
962
|
+
except Exception as e:
|
|
963
|
+
self._update(f"Error: {escape(str(e))}")
|
|
964
|
+
if self._stream_body is not None:
|
|
965
|
+
self._stream_body.parent.set_class(True, "msg-err")
|
|
966
|
+
finally:
|
|
967
|
+
self._loading = False
|
|
968
|
+
self._stream_body = None
|
|
969
|
+
|
|
970
|
+
def _extract(self, user_input: str) -> None:
|
|
971
|
+
lower = user_input.lower()
|
|
972
|
+
for p in ("i prefer", "i always use", "let's use", "i use"):
|
|
973
|
+
if p in lower:
|
|
974
|
+
self.memory.remember(user_input, scope="global", category="preference")
|
|
975
|
+
break
|
|
976
|
+
|
|
977
|
+
for tech in analyze_input(user_input).detected_techs:
|
|
978
|
+
self.memory.remember(f"Technology: {tech.name}", scope="project", category="technology")
|
|
979
|
+
|
|
980
|
+
# ── UI helpers ─────────────────────────────────────────────────
|
|
981
|
+
|
|
982
|
+
def _msg(self, role: str, text: str, styled: bool = False) -> None:
|
|
983
|
+
"""Mount a message block. All text is RTL-reshaped.
|
|
984
|
+
|
|
985
|
+
If styled=True, Rich markup is preserved (for [bold], [dim], etc.).
|
|
986
|
+
If styled=False (default), all text is escaped for safety.
|
|
987
|
+
"""
|
|
988
|
+
area = self.query_one("#chat-area", VerticalScroll)
|
|
989
|
+
labels = {
|
|
990
|
+
"user": "You", "ai": "DevObin", "sys": "System", "err": "Error",
|
|
991
|
+
"tech": "Detected", "run": "Shell", "output": "Output",
|
|
992
|
+
"file": "File",
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
block = Vertical(classes=f"msg msg-{role}")
|
|
996
|
+
area.mount(block)
|
|
997
|
+
|
|
998
|
+
if role in ("user", "ai"):
|
|
999
|
+
block.mount(Static(labels.get(role, role), classes=f"role role-{role}"))
|
|
1000
|
+
|
|
1001
|
+
if styled:
|
|
1002
|
+
safe_text = smart_display(str(text))
|
|
1003
|
+
else:
|
|
1004
|
+
safe_text = escape(smart_display(str(text)))
|
|
1005
|
+
body = Static(safe_text, classes=f"body body-{role}")
|
|
1006
|
+
body.expand = True
|
|
1007
|
+
block.mount(body)
|
|
1008
|
+
|
|
1009
|
+
area.scroll_end(animate=False)
|
|
1010
|
+
|
|
1011
|
+
def _update(self, text: str) -> None:
|
|
1012
|
+
"""Update the currently streaming AI body.
|
|
1013
|
+
|
|
1014
|
+
Throttles updates: only re-renders when the text has grown by at
|
|
1015
|
+
least 8 characters or when called for the first time. This keeps
|
|
1016
|
+
the UI responsive even when tokens arrive faster than the renderer
|
|
1017
|
+
can keep up.
|
|
1018
|
+
"""
|
|
1019
|
+
if self._stream_body is None:
|
|
1020
|
+
return
|
|
1021
|
+
# Skip tiny incremental updates to avoid renderer overload
|
|
1022
|
+
if len(text) - self._last_update_len < 8:
|
|
1023
|
+
return
|
|
1024
|
+
self._last_update_len = len(text)
|
|
1025
|
+
self._stream_body.update(escape(smart_display(str(text))))
|
|
1026
|
+
try:
|
|
1027
|
+
self.query_one("#chat-area", VerticalScroll).scroll_end(animate=False)
|
|
1028
|
+
except Exception:
|
|
1029
|
+
pass
|