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,131 @@
|
|
|
1
|
+
"""Command palette modal for DevObin AI."""
|
|
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, OptionList
|
|
8
|
+
from textual.containers import Vertical
|
|
9
|
+
from textual.binding import Binding
|
|
10
|
+
from textual import on
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CommandPalette(ModalScreen[None]):
|
|
14
|
+
"""Command palette modal with keyboard navigation."""
|
|
15
|
+
|
|
16
|
+
CSS = """
|
|
17
|
+
CommandPalette {
|
|
18
|
+
align: center middle;
|
|
19
|
+
}
|
|
20
|
+
#palette-container {
|
|
21
|
+
width: 50;
|
|
22
|
+
max-height: 30;
|
|
23
|
+
background: $surface;
|
|
24
|
+
border: tall $primary;
|
|
25
|
+
padding: 1 2;
|
|
26
|
+
}
|
|
27
|
+
#palette-title {
|
|
28
|
+
text-align: center;
|
|
29
|
+
text-style: bold;
|
|
30
|
+
color: $primary;
|
|
31
|
+
margin-bottom: 1;
|
|
32
|
+
}
|
|
33
|
+
#command-list {
|
|
34
|
+
height: auto;
|
|
35
|
+
max-height: 20;
|
|
36
|
+
}
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
BINDINGS = [
|
|
40
|
+
Binding("escape", "close", "Close", show=True),
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
COMMANDS = [
|
|
44
|
+
("connect", "Connect AI Provider", "🔌"),
|
|
45
|
+
("model", "Select Model", "🤖"),
|
|
46
|
+
("project", "Manage Projects", "📂"),
|
|
47
|
+
("memory", "View Memory", "🧠"),
|
|
48
|
+
("history", "Chat History", "📜"),
|
|
49
|
+
("export", "Export Prompt", "📤"),
|
|
50
|
+
("scan", "Scan Workspace", "🔍"),
|
|
51
|
+
("settings", "Settings", "⚙"),
|
|
52
|
+
("run", "Run Shell Command", "▶"),
|
|
53
|
+
("read", "Read File", "📄"),
|
|
54
|
+
("write", "Write / Create File", "✏"),
|
|
55
|
+
("ls", "List Directory", "📁"),
|
|
56
|
+
("tree", "Show File Tree", "🌲"),
|
|
57
|
+
("git", "Git Commands", "🔀"),
|
|
58
|
+
("pip", "Pip Commands", "📦"),
|
|
59
|
+
("python", "Run Python", "🐍"),
|
|
60
|
+
("clear", "Clear Chat", "🗑"),
|
|
61
|
+
("exit", "Exit DevObin", "🚪"),
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
def compose(self) -> ComposeResult:
|
|
65
|
+
with Vertical(id="palette-container"):
|
|
66
|
+
yield Static("DevObin Commands", id="palette-title")
|
|
67
|
+
labels = [f"{icon} {label}" for _, label, icon in self.COMMANDS]
|
|
68
|
+
yield OptionList(*labels, id="command-list")
|
|
69
|
+
|
|
70
|
+
def on_mount(self) -> None:
|
|
71
|
+
self.query_one("#command-list", OptionList).focus()
|
|
72
|
+
|
|
73
|
+
@on(OptionList.OptionSelected, "#command-list")
|
|
74
|
+
def on_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
75
|
+
"""Handle command selection."""
|
|
76
|
+
idx = event.option_index
|
|
77
|
+
if 0 <= idx < len(self.COMMANDS):
|
|
78
|
+
cmd = self.COMMANDS[idx][0]
|
|
79
|
+
self.dismiss()
|
|
80
|
+
self.execute_command(cmd)
|
|
81
|
+
|
|
82
|
+
def execute_command(self, cmd: str) -> None:
|
|
83
|
+
"""Execute the selected command."""
|
|
84
|
+
from devobin.ui.connect_modal import ConnectModal
|
|
85
|
+
from devobin.ui.model_modal import ModelModal
|
|
86
|
+
from devobin.ui.project_modal import ProjectModal
|
|
87
|
+
from devobin.ui.memory_modal import MemoryModal
|
|
88
|
+
from devobin.ui.history_modal import HistoryModal
|
|
89
|
+
from devobin.ui.export_modal import ExportModal
|
|
90
|
+
from devobin.ui.settings_modal import SettingsModal
|
|
91
|
+
|
|
92
|
+
modals = {
|
|
93
|
+
"connect": ConnectModal,
|
|
94
|
+
"model": ModelModal,
|
|
95
|
+
"project": ProjectModal,
|
|
96
|
+
"memory": MemoryModal,
|
|
97
|
+
"history": HistoryModal,
|
|
98
|
+
"export": ExportModal,
|
|
99
|
+
"settings": SettingsModal,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
# Commands that go to the chat input
|
|
103
|
+
input_cmds = {
|
|
104
|
+
"run": "/run ", "read": "/read ", "write": "/write ",
|
|
105
|
+
"ls": "/ls ", "tree": "/tree ", "git": "/git ",
|
|
106
|
+
"pip": "/pip ", "python": "/python ", "npm": "/npm ",
|
|
107
|
+
"scan": "/scan",
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if cmd == "clear":
|
|
111
|
+
screen = self.app.screen
|
|
112
|
+
if hasattr(screen, "cmd_clear"):
|
|
113
|
+
screen.cmd_clear("")
|
|
114
|
+
elif cmd == "exit":
|
|
115
|
+
self.app.exit()
|
|
116
|
+
elif cmd in modals:
|
|
117
|
+
self.app.push_screen(modals[cmd]())
|
|
118
|
+
elif cmd in input_cmds:
|
|
119
|
+
# Focus the chat input and pre-fill the command
|
|
120
|
+
try:
|
|
121
|
+
chat_screen = self.app.screen
|
|
122
|
+
if hasattr(chat_screen, "query_one"):
|
|
123
|
+
inp = chat_screen.query_one("#chat-input")
|
|
124
|
+
inp.value = input_cmds[cmd]
|
|
125
|
+
inp.focus()
|
|
126
|
+
except Exception:
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
def action_close(self) -> None:
|
|
130
|
+
"""Close the palette."""
|
|
131
|
+
self.dismiss()
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Connect modal for DevObin AI."""
|
|
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, Select, Button
|
|
8
|
+
from textual.containers import Vertical
|
|
9
|
+
from textual.binding import Binding
|
|
10
|
+
from textual import on
|
|
11
|
+
|
|
12
|
+
from devobin.config.settings import get_config
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ConnectModal(ModalScreen[None]):
|
|
16
|
+
"""Modal for connecting AI provider."""
|
|
17
|
+
|
|
18
|
+
CSS = """
|
|
19
|
+
ConnectModal {
|
|
20
|
+
align: center middle;
|
|
21
|
+
}
|
|
22
|
+
#connect-container {
|
|
23
|
+
width: 60;
|
|
24
|
+
max-height: 35;
|
|
25
|
+
background: $surface;
|
|
26
|
+
border: tall $primary;
|
|
27
|
+
padding: 2;
|
|
28
|
+
}
|
|
29
|
+
#connect-title {
|
|
30
|
+
text-align: center;
|
|
31
|
+
text-style: bold;
|
|
32
|
+
color: $primary;
|
|
33
|
+
margin-bottom: 1;
|
|
34
|
+
}
|
|
35
|
+
.field-label {
|
|
36
|
+
color: $text-muted;
|
|
37
|
+
margin-top: 1;
|
|
38
|
+
}
|
|
39
|
+
#provider-select {
|
|
40
|
+
width: 100%;
|
|
41
|
+
}
|
|
42
|
+
#base-url-input, #api-key-input, #model-input {
|
|
43
|
+
width: 100%;
|
|
44
|
+
}
|
|
45
|
+
#save-btn {
|
|
46
|
+
width: 100%;
|
|
47
|
+
margin-top: 2;
|
|
48
|
+
}
|
|
49
|
+
#status-message {
|
|
50
|
+
margin-top: 1;
|
|
51
|
+
text-align: center;
|
|
52
|
+
}
|
|
53
|
+
.success {
|
|
54
|
+
color: $success;
|
|
55
|
+
}
|
|
56
|
+
.error {
|
|
57
|
+
color: $error;
|
|
58
|
+
}
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
BINDINGS = [
|
|
62
|
+
Binding("escape", "close", "Close", show=True),
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
PROVIDERS = [
|
|
66
|
+
("openai", "OpenAI"),
|
|
67
|
+
("anthropic", "Anthropic Claude"),
|
|
68
|
+
("google", "Google Gemini"),
|
|
69
|
+
("ollama", "Ollama (Local)"),
|
|
70
|
+
("openai-compatible", "OpenAI Compatible"),
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
def __init__(self) -> None:
|
|
74
|
+
super().__init__()
|
|
75
|
+
self.config = get_config()
|
|
76
|
+
|
|
77
|
+
def compose(self) -> ComposeResult:
|
|
78
|
+
with Vertical(id="connect-container"):
|
|
79
|
+
yield Static("Connect AI Provider", id="connect-title")
|
|
80
|
+
|
|
81
|
+
yield Static("Provider:", classes="field-label")
|
|
82
|
+
yield Select(
|
|
83
|
+
[(label, value) for value, label in self.PROVIDERS],
|
|
84
|
+
id="provider-select",
|
|
85
|
+
prompt="Select provider...",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
yield Static("Base URL:", classes="field-label")
|
|
89
|
+
yield Input(placeholder="https://api.openai.com/v1", id="base-url-input")
|
|
90
|
+
|
|
91
|
+
yield Static("API Key:", classes="field-label")
|
|
92
|
+
yield Input(placeholder="sk-...", password=True, id="api-key-input")
|
|
93
|
+
|
|
94
|
+
yield Static("Model:", classes="field-label")
|
|
95
|
+
yield Input(placeholder="gpt-4o", id="model-input")
|
|
96
|
+
|
|
97
|
+
yield Button("Save", id="save-btn", variant="primary")
|
|
98
|
+
yield Static("", id="status-message")
|
|
99
|
+
|
|
100
|
+
@on(Select.Changed, "#provider-select")
|
|
101
|
+
def on_provider_changed(self, event: Select.Changed) -> None:
|
|
102
|
+
"""Update placeholder based on provider."""
|
|
103
|
+
provider = event.value
|
|
104
|
+
base_url_input = self.query_one("#base-url-input", Input)
|
|
105
|
+
model_input = self.query_one("#model-input", Input)
|
|
106
|
+
|
|
107
|
+
defaults = {
|
|
108
|
+
"openai": ("https://api.openai.com/v1", "gpt-4o"),
|
|
109
|
+
"anthropic": ("", "claude-sonnet-4-20250514"),
|
|
110
|
+
"google": ("", "gemini-2.0-flash"),
|
|
111
|
+
"ollama": ("http://localhost:11434", "llama3"),
|
|
112
|
+
"openai-compatible": ("", ""),
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if provider in defaults:
|
|
116
|
+
url, model = defaults[provider]
|
|
117
|
+
base_url_input.placeholder = url
|
|
118
|
+
model_input.placeholder = model
|
|
119
|
+
|
|
120
|
+
@on(Button.Pressed, "#save-btn")
|
|
121
|
+
def on_save_pressed(self, event: Button.Pressed) -> None:
|
|
122
|
+
"""Save provider configuration."""
|
|
123
|
+
provider = self.query_one("#provider-select", Select).value
|
|
124
|
+
if provider is Select.BLANK:
|
|
125
|
+
self.update_status("Please select a provider", "error")
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
base_url = self.query_one("#base-url-input", Input).value.strip()
|
|
129
|
+
api_key = self.query_one("#api-key-input", Input).value.strip()
|
|
130
|
+
model = self.query_one("#model-input", Input).value.strip()
|
|
131
|
+
|
|
132
|
+
if not api_key and provider != "ollama":
|
|
133
|
+
self.update_status("API key is required", "error")
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
if not model:
|
|
137
|
+
self.update_status("Model is required", "error")
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
provider_config = {
|
|
141
|
+
"type": provider,
|
|
142
|
+
"api_key": api_key,
|
|
143
|
+
"base_url": base_url or None,
|
|
144
|
+
"model": model,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
self.config.add_provider(provider, provider_config)
|
|
148
|
+
self.config.active_provider = provider
|
|
149
|
+
self.config.active_model = model
|
|
150
|
+
|
|
151
|
+
self.update_status(f"Connected to {provider} — {model}", "success")
|
|
152
|
+
self.set_timer(0.9, self.dismiss)
|
|
153
|
+
|
|
154
|
+
def update_status(self, message: str, style: str) -> None:
|
|
155
|
+
"""Update status message."""
|
|
156
|
+
from rich.markup import escape as _escape
|
|
157
|
+
status = self.query_one("#status-message", Static)
|
|
158
|
+
safe = _escape(str(message))
|
|
159
|
+
if style == "success":
|
|
160
|
+
status.update(f"[green]✓[/] {safe}")
|
|
161
|
+
else:
|
|
162
|
+
status.update(f"[red]✗[/] {safe}")
|
|
163
|
+
|
|
164
|
+
def action_close(self) -> None:
|
|
165
|
+
"""Close the modal."""
|
|
166
|
+
self.dismiss()
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Export modal for DevObin AI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from textual.app import ComposeResult
|
|
7
|
+
from textual.screen import ModalScreen
|
|
8
|
+
from textual.widgets import Static, Button
|
|
9
|
+
from textual.containers import Vertical
|
|
10
|
+
from textual.binding import Binding
|
|
11
|
+
from textual import on
|
|
12
|
+
from rich.markup import escape
|
|
13
|
+
|
|
14
|
+
from devobin.config.settings import get_config
|
|
15
|
+
from devobin.storage.database import ProjectStorage
|
|
16
|
+
from devobin.core.analyzer import analyze_input
|
|
17
|
+
from devobin.core.researcher import research_analysis
|
|
18
|
+
from devobin.core.prompt_builder import build_prompt
|
|
19
|
+
from devobin.core.memory import MemoryManager
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ExportModal(ModalScreen[None]):
|
|
23
|
+
"""Modal for exporting project prompt."""
|
|
24
|
+
|
|
25
|
+
CSS = """
|
|
26
|
+
ExportModal {
|
|
27
|
+
align: center middle;
|
|
28
|
+
}
|
|
29
|
+
#export-container {
|
|
30
|
+
width: 70;
|
|
31
|
+
max-height: 40;
|
|
32
|
+
background: $surface;
|
|
33
|
+
border: tall $primary;
|
|
34
|
+
padding: 2;
|
|
35
|
+
}
|
|
36
|
+
#export-title {
|
|
37
|
+
text-align: center;
|
|
38
|
+
text-style: bold;
|
|
39
|
+
color: $primary;
|
|
40
|
+
margin-bottom: 1;
|
|
41
|
+
}
|
|
42
|
+
#export-content {
|
|
43
|
+
height: auto;
|
|
44
|
+
max-height: 25;
|
|
45
|
+
overflow-y: auto;
|
|
46
|
+
}
|
|
47
|
+
#status-message {
|
|
48
|
+
margin-top: 1;
|
|
49
|
+
text-align: center;
|
|
50
|
+
}
|
|
51
|
+
#close-btn {
|
|
52
|
+
width: 100%;
|
|
53
|
+
margin-top: 1;
|
|
54
|
+
}
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
BINDINGS = [
|
|
58
|
+
Binding("escape", "close", "Close", show=True),
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
def __init__(self, custom_filename: str = "", ai_response: str = "") -> None:
|
|
62
|
+
super().__init__()
|
|
63
|
+
self.config = get_config()
|
|
64
|
+
self._custom_filename = custom_filename
|
|
65
|
+
self._ai_response = ai_response
|
|
66
|
+
|
|
67
|
+
def compose(self) -> ComposeResult:
|
|
68
|
+
with Vertical(id="export-container"):
|
|
69
|
+
yield Static("Export Prompt", id="export-title")
|
|
70
|
+
yield Static("[dim]Generating...[/]", id="export-content")
|
|
71
|
+
yield Static("", id="status-message")
|
|
72
|
+
yield Button("Close", id="close-btn", variant="default")
|
|
73
|
+
|
|
74
|
+
def on_mount(self) -> None:
|
|
75
|
+
"""Generate prompt after the modal is shown."""
|
|
76
|
+
self.set_timer(0.05, self.generate_prompt)
|
|
77
|
+
|
|
78
|
+
def generate_prompt(self) -> None:
|
|
79
|
+
"""Generate the project prompt and save to workspace."""
|
|
80
|
+
project = self.config.get("active_project")
|
|
81
|
+
filename = "devobin_prompt.md"
|
|
82
|
+
|
|
83
|
+
# If we have the actual AI response, use it directly
|
|
84
|
+
if self._ai_response and self._ai_response.strip():
|
|
85
|
+
prompt_content = self._ai_response.strip()
|
|
86
|
+
else:
|
|
87
|
+
# Fallback to prompt_builder template
|
|
88
|
+
if not project:
|
|
89
|
+
self.update_status("No active project. Create one with /project.", "error")
|
|
90
|
+
self.query_one("#export-content", Static).update("[dim]Nothing to export.[/]")
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
storage = ProjectStorage(project)
|
|
94
|
+
history = storage.get_history()
|
|
95
|
+
|
|
96
|
+
if not history:
|
|
97
|
+
self.update_status("No conversation history yet.", "error")
|
|
98
|
+
self.query_one("#export-content", Static).update("[dim]Chat first, then export.[/]")
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
user_msgs = " ".join(m["content"] for m in history if m["role"] == "user")
|
|
103
|
+
analysis = analyze_input(user_msgs)
|
|
104
|
+
research = research_analysis(analysis)
|
|
105
|
+
|
|
106
|
+
memory_ctx = MemoryManager(project).get_memory_context()
|
|
107
|
+
|
|
108
|
+
# Scan the workspace so the exported document is grounded
|
|
109
|
+
from devobin.core.scanner import scan_workspace
|
|
110
|
+
pm = scan_workspace()
|
|
111
|
+
|
|
112
|
+
filename, prompt_content = build_prompt(
|
|
113
|
+
analysis, research,
|
|
114
|
+
memory_context=memory_ctx,
|
|
115
|
+
project_name=self._custom_filename or project,
|
|
116
|
+
project_map=pm,
|
|
117
|
+
)
|
|
118
|
+
except Exception as e:
|
|
119
|
+
self.update_status(f"Export failed: {e}", "error")
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
# Save to current working directory (where CLI was opened)
|
|
124
|
+
cwd = os.getcwd()
|
|
125
|
+
output_path = os.path.join(cwd, filename)
|
|
126
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
127
|
+
f.write(prompt_content)
|
|
128
|
+
except Exception as e:
|
|
129
|
+
self.update_status(f"Export failed: {e}", "error")
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
content = self.query_one("#export-content", Static)
|
|
133
|
+
preview = prompt_content[:2000]
|
|
134
|
+
if len(prompt_content) > 2000:
|
|
135
|
+
preview += "\n\n... (truncated)"
|
|
136
|
+
content.update(escape(preview))
|
|
137
|
+
|
|
138
|
+
self.update_status(
|
|
139
|
+
f"Created: {output_path}",
|
|
140
|
+
"success",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def update_status(self, message: str, style: str) -> None:
|
|
144
|
+
"""Update status message."""
|
|
145
|
+
status = self.query_one("#status-message", Static)
|
|
146
|
+
safe = escape(str(message))
|
|
147
|
+
if style == "success":
|
|
148
|
+
status.update(f"[green]✓[/] {safe}")
|
|
149
|
+
else:
|
|
150
|
+
status.update(f"[red]✗[/] {safe}")
|
|
151
|
+
|
|
152
|
+
@on(Button.Pressed, "#close-btn")
|
|
153
|
+
def on_close_pressed(self) -> None:
|
|
154
|
+
"""Close the modal."""
|
|
155
|
+
self.dismiss()
|
|
156
|
+
|
|
157
|
+
def action_close(self) -> None:
|
|
158
|
+
"""Close the modal."""
|
|
159
|
+
self.dismiss()
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""History modal — shows past chat sessions for the active project."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from textual.app import ComposeResult
|
|
8
|
+
from textual.screen import ModalScreen
|
|
9
|
+
from textual.widgets import Static, Button, ListItem, ListView
|
|
10
|
+
from textual.containers import Vertical, Horizontal
|
|
11
|
+
from textual.binding import Binding
|
|
12
|
+
from textual import on
|
|
13
|
+
from rich.markup import escape
|
|
14
|
+
|
|
15
|
+
from devobin.config.settings import get_config
|
|
16
|
+
from devobin.storage.database import ProjectStorage
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class HistoryModal(ModalScreen[None]):
|
|
20
|
+
"""Modal that lists past chat sessions from the active project."""
|
|
21
|
+
|
|
22
|
+
CSS = """
|
|
23
|
+
HistoryModal {
|
|
24
|
+
align: center middle;
|
|
25
|
+
}
|
|
26
|
+
#hist-container {
|
|
27
|
+
width: 70;
|
|
28
|
+
height: 35;
|
|
29
|
+
background: $surface;
|
|
30
|
+
border: tall $primary;
|
|
31
|
+
padding: 1;
|
|
32
|
+
}
|
|
33
|
+
#hist-title {
|
|
34
|
+
text-align: center;
|
|
35
|
+
text-style: bold;
|
|
36
|
+
color: $primary;
|
|
37
|
+
height: 1;
|
|
38
|
+
margin-bottom: 1;
|
|
39
|
+
}
|
|
40
|
+
#hist-list {
|
|
41
|
+
height: 1fr;
|
|
42
|
+
overflow-y: auto;
|
|
43
|
+
}
|
|
44
|
+
#hist-list ListItem {
|
|
45
|
+
padding: 0 1;
|
|
46
|
+
}
|
|
47
|
+
#hist-detail {
|
|
48
|
+
height: 12;
|
|
49
|
+
overflow-y: auto;
|
|
50
|
+
border: solid $primary;
|
|
51
|
+
margin-top: 1;
|
|
52
|
+
padding: 1;
|
|
53
|
+
}
|
|
54
|
+
#hist-buttons {
|
|
55
|
+
height: auto;
|
|
56
|
+
margin-top: 1;
|
|
57
|
+
}
|
|
58
|
+
#hist-buttons Button {
|
|
59
|
+
margin: 0 1;
|
|
60
|
+
}
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
BINDINGS = [
|
|
64
|
+
Binding("escape", "close", "Close", show=True),
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
def __init__(self) -> None:
|
|
68
|
+
super().__init__()
|
|
69
|
+
self.config = get_config()
|
|
70
|
+
self._project = self.config.get("active_project")
|
|
71
|
+
self._sessions: list[dict] = []
|
|
72
|
+
self._selected_index: int = -1
|
|
73
|
+
|
|
74
|
+
def compose(self) -> ComposeResult:
|
|
75
|
+
with Vertical(id="hist-container"):
|
|
76
|
+
yield Static("Chat History", id="hist-title")
|
|
77
|
+
yield ListView(id="hist-list")
|
|
78
|
+
yield Static("[dim]Select a session to view details[/]", id="hist-detail")
|
|
79
|
+
with Horizontal(id="hist-buttons"):
|
|
80
|
+
yield Button("Close", id="hist-close", variant="default")
|
|
81
|
+
|
|
82
|
+
def on_mount(self) -> None:
|
|
83
|
+
if not self._project:
|
|
84
|
+
self.query_one("#hist-title", Static).update(
|
|
85
|
+
"[dim]No active project. Use /project create first.[/]"
|
|
86
|
+
)
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
storage = ProjectStorage(self._project)
|
|
90
|
+
self._sessions = storage.list_sessions()
|
|
91
|
+
|
|
92
|
+
if not self._sessions:
|
|
93
|
+
self.query_one("#hist-title", Static).update(
|
|
94
|
+
"[dim]No sessions yet. Start chatting![/]"
|
|
95
|
+
)
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
lv = self.query_one("#hist-list", ListView)
|
|
99
|
+
for i, s in enumerate(self._sessions):
|
|
100
|
+
label = f"Session {i+1} — {s['count']} messages — {s['preview']}"
|
|
101
|
+
lv.append(ListItem(Static(escape(label))))
|
|
102
|
+
|
|
103
|
+
self.query_one("#hist-title", Static).update(
|
|
104
|
+
f"Chat History — {self._project} ({len(self._sessions)} sessions)"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
@on(ListView.Selected)
|
|
108
|
+
def on_selected(self, event: ListView.Selected) -> None:
|
|
109
|
+
# Find the index by matching the item
|
|
110
|
+
lv = self.query_one("#hist-list", ListView)
|
|
111
|
+
idx = event.item_index if hasattr(event, "item_index") else -1
|
|
112
|
+
if idx < 0:
|
|
113
|
+
# Fallback: count items
|
|
114
|
+
for i, item in enumerate(lv.children):
|
|
115
|
+
if item is event.item:
|
|
116
|
+
idx = i
|
|
117
|
+
break
|
|
118
|
+
|
|
119
|
+
if 0 <= idx < len(self._sessions):
|
|
120
|
+
self._selected_index = idx
|
|
121
|
+
session = self._sessions[idx]
|
|
122
|
+
lines = []
|
|
123
|
+
for msg in session["messages"][:20]:
|
|
124
|
+
role = msg.get("role", "?")
|
|
125
|
+
content = msg.get("content", "")[:100]
|
|
126
|
+
lines.append(f"[bold]{role}[/bold]: {content}")
|
|
127
|
+
detail = "\n".join(lines)
|
|
128
|
+
if len(session["messages"]) > 20:
|
|
129
|
+
detail += f"\n\n... ({len(session['messages']) - 20} more messages)"
|
|
130
|
+
self.query_one("#hist-detail", Static).update(detail)
|
|
131
|
+
|
|
132
|
+
@on(Button.Pressed, "#hist-close")
|
|
133
|
+
def on_close_pressed(self) -> None:
|
|
134
|
+
self.dismiss()
|
|
135
|
+
|
|
136
|
+
def action_close(self) -> None:
|
|
137
|
+
self.dismiss()
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Memory modal for DevObin AI."""
|
|
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, Button, DataTable
|
|
8
|
+
from textual.containers import Vertical
|
|
9
|
+
from textual.binding import Binding
|
|
10
|
+
from textual import on
|
|
11
|
+
|
|
12
|
+
from devobin.config.settings import get_config
|
|
13
|
+
from devobin.core.memory import MemoryManager
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MemoryModal(ModalScreen[None]):
|
|
17
|
+
"""Modal for viewing memories."""
|
|
18
|
+
|
|
19
|
+
CSS = """
|
|
20
|
+
MemoryModal {
|
|
21
|
+
align: center middle;
|
|
22
|
+
}
|
|
23
|
+
#memory-container {
|
|
24
|
+
width: 60;
|
|
25
|
+
max-height: 30;
|
|
26
|
+
background: $surface;
|
|
27
|
+
border: tall $primary;
|
|
28
|
+
padding: 2;
|
|
29
|
+
}
|
|
30
|
+
#memory-title {
|
|
31
|
+
text-align: center;
|
|
32
|
+
text-style: bold;
|
|
33
|
+
color: $primary;
|
|
34
|
+
margin-bottom: 1;
|
|
35
|
+
}
|
|
36
|
+
#memory-table {
|
|
37
|
+
height: auto;
|
|
38
|
+
max-height: 18;
|
|
39
|
+
}
|
|
40
|
+
#close-btn {
|
|
41
|
+
width: 100%;
|
|
42
|
+
margin-top: 1;
|
|
43
|
+
}
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
BINDINGS = [
|
|
47
|
+
Binding("escape", "close", "Close", show=True),
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
def __init__(self) -> None:
|
|
51
|
+
super().__init__()
|
|
52
|
+
self.config = get_config()
|
|
53
|
+
|
|
54
|
+
def compose(self) -> ComposeResult:
|
|
55
|
+
with Vertical(id="memory-container"):
|
|
56
|
+
yield Static("Memory", id="memory-title")
|
|
57
|
+
|
|
58
|
+
table = DataTable(id="memory-table")
|
|
59
|
+
table.add_columns("Type", "Content")
|
|
60
|
+
yield table
|
|
61
|
+
|
|
62
|
+
yield Button("Close", id="close-btn", variant="default")
|
|
63
|
+
|
|
64
|
+
def on_mount(self) -> None:
|
|
65
|
+
"""Load memories on mount."""
|
|
66
|
+
table = self.query_one("#memory-table", DataTable)
|
|
67
|
+
manager = MemoryManager()
|
|
68
|
+
|
|
69
|
+
global_mems = manager.get_global_memories()
|
|
70
|
+
project_mems = manager.get_project_memories()
|
|
71
|
+
|
|
72
|
+
for mem in global_mems[-10:]:
|
|
73
|
+
table.add_row("Global", mem.get("content", "")[:60])
|
|
74
|
+
|
|
75
|
+
for mem in project_mems[-10:]:
|
|
76
|
+
table.add_row("Project", mem.get("content", "")[:60])
|
|
77
|
+
|
|
78
|
+
if not global_mems and not project_mems:
|
|
79
|
+
table.add_row("-", "No memories saved yet")
|
|
80
|
+
|
|
81
|
+
@on(Button.Pressed, "#close-btn")
|
|
82
|
+
def on_close_pressed(self) -> None:
|
|
83
|
+
"""Close the modal."""
|
|
84
|
+
self.dismiss()
|
|
85
|
+
|
|
86
|
+
def action_close(self) -> None:
|
|
87
|
+
"""Close the modal."""
|
|
88
|
+
self.dismiss()
|