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,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