kader 0.1.5__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.
- cli/README.md +169 -0
- cli/__init__.py +5 -0
- cli/__main__.py +6 -0
- cli/app.py +707 -0
- cli/app.tcss +664 -0
- cli/utils.py +68 -0
- cli/widgets/__init__.py +13 -0
- cli/widgets/confirmation.py +309 -0
- cli/widgets/conversation.py +55 -0
- cli/widgets/loading.py +59 -0
- kader/__init__.py +22 -0
- kader/agent/__init__.py +8 -0
- kader/agent/agents.py +126 -0
- kader/agent/base.py +927 -0
- kader/agent/logger.py +170 -0
- kader/config.py +139 -0
- kader/memory/__init__.py +66 -0
- kader/memory/conversation.py +409 -0
- kader/memory/session.py +385 -0
- kader/memory/state.py +211 -0
- kader/memory/types.py +116 -0
- kader/prompts/__init__.py +9 -0
- kader/prompts/agent_prompts.py +27 -0
- kader/prompts/base.py +81 -0
- kader/prompts/templates/planning_agent.j2 +26 -0
- kader/prompts/templates/react_agent.j2 +18 -0
- kader/providers/__init__.py +9 -0
- kader/providers/base.py +581 -0
- kader/providers/mock.py +96 -0
- kader/providers/ollama.py +447 -0
- kader/tools/README.md +483 -0
- kader/tools/__init__.py +130 -0
- kader/tools/base.py +955 -0
- kader/tools/exec_commands.py +249 -0
- kader/tools/filesys.py +650 -0
- kader/tools/filesystem.py +607 -0
- kader/tools/protocol.py +456 -0
- kader/tools/rag.py +555 -0
- kader/tools/todo.py +210 -0
- kader/tools/utils.py +456 -0
- kader/tools/web.py +246 -0
- kader-0.1.5.dist-info/METADATA +321 -0
- kader-0.1.5.dist-info/RECORD +45 -0
- kader-0.1.5.dist-info/WHEEL +4 -0
- kader-0.1.5.dist-info/entry_points.txt +2 -0
cli/app.py
ADDED
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
"""Kader CLI - Modern Vibe Coding CLI with Textual."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import threading
|
|
5
|
+
from importlib.metadata import version as get_version
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from textual.app import App, ComposeResult
|
|
10
|
+
from textual.binding import Binding
|
|
11
|
+
from textual.containers import Container, Horizontal, Vertical
|
|
12
|
+
from textual.widgets import (
|
|
13
|
+
Footer,
|
|
14
|
+
Header,
|
|
15
|
+
Input,
|
|
16
|
+
Markdown,
|
|
17
|
+
Static,
|
|
18
|
+
Tree,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
from kader.agent.agents import ReActAgent
|
|
22
|
+
from kader.memory import (
|
|
23
|
+
FileSessionManager,
|
|
24
|
+
MemoryConfig,
|
|
25
|
+
SlidingWindowConversationManager,
|
|
26
|
+
)
|
|
27
|
+
from kader.tools import get_default_registry
|
|
28
|
+
|
|
29
|
+
from .utils import (
|
|
30
|
+
DEFAULT_MODEL,
|
|
31
|
+
HELP_TEXT,
|
|
32
|
+
THEME_NAMES,
|
|
33
|
+
)
|
|
34
|
+
from .widgets import ConversationView, InlineSelector, LoadingSpinner, ModelSelector
|
|
35
|
+
|
|
36
|
+
WELCOME_MESSAGE = """
|
|
37
|
+
<div align="center">
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
██╗ ██╗ ██╗ █████╗ ██████╗ ███████╗██████╗
|
|
41
|
+
██╔╝ ██║ ██╔╝██╔══██╗██╔══██╗██╔════╝██╔══██╗
|
|
42
|
+
██╔╝ █████╔╝ ███████║██║ ██║█████╗ ██████╔╝
|
|
43
|
+
██╔╝ ██╔═██╗ ██╔══██║██║ ██║██╔══╝ ██╔══██╗
|
|
44
|
+
██╔╝ ██║ ██╗██║ ██║██████╔╝███████╗██║ ██║
|
|
45
|
+
╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚══════╝╚═╝ ╚═╝
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
Type a message below to start chatting, or use one of the commands:
|
|
51
|
+
|
|
52
|
+
- `/help` - Show available commands
|
|
53
|
+
- `/models` - View available LLM models
|
|
54
|
+
- `/theme` - Change the color theme
|
|
55
|
+
- `/clear` - Clear the conversation
|
|
56
|
+
- `/save` - Save current session
|
|
57
|
+
- `/load` - Load a saved session
|
|
58
|
+
- `/sessions` - List saved sessions
|
|
59
|
+
- `/cost` - Show the cost of the conversation
|
|
60
|
+
- `/exit` - Exit the application
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# Minimum terminal size to prevent UI breakage
|
|
65
|
+
MIN_WIDTH = 89
|
|
66
|
+
MIN_HEIGHT = 29
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ASCIITree(Tree):
|
|
70
|
+
"""A Tree widget that uses no icons."""
|
|
71
|
+
|
|
72
|
+
ICON_NODE = ""
|
|
73
|
+
ICON_NODE_EXPANDED = ""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class KaderApp(App):
|
|
77
|
+
"""Main Kader CLI application."""
|
|
78
|
+
|
|
79
|
+
TITLE = "Kader CLI"
|
|
80
|
+
SUB_TITLE = f"v{get_version('kader')}"
|
|
81
|
+
CSS_PATH = "app.tcss"
|
|
82
|
+
|
|
83
|
+
BINDINGS = [
|
|
84
|
+
Binding("ctrl+q", "quit", "Quit"),
|
|
85
|
+
Binding("ctrl+l", "clear", "Clear"),
|
|
86
|
+
Binding("ctrl+t", "cycle_theme", "Theme"),
|
|
87
|
+
Binding("ctrl+s", "save_session", "Save"),
|
|
88
|
+
Binding("ctrl+r", "refresh_tree", "Refresh"),
|
|
89
|
+
Binding("tab", "focus_next", "Next", show=False),
|
|
90
|
+
Binding("shift+tab", "focus_previous", "Previous", show=False),
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
def __init__(self) -> None:
|
|
94
|
+
super().__init__()
|
|
95
|
+
self._current_theme_index = 0
|
|
96
|
+
self._is_processing = False
|
|
97
|
+
self._current_model = DEFAULT_MODEL
|
|
98
|
+
self._current_session_id: str | None = None
|
|
99
|
+
# Session manager with sessions stored in ~/.kader/sessions/
|
|
100
|
+
self._session_manager = FileSessionManager(
|
|
101
|
+
MemoryConfig(memory_dir=Path.home() / ".kader")
|
|
102
|
+
)
|
|
103
|
+
# Tool confirmation coordination
|
|
104
|
+
self._confirmation_event: Optional[threading.Event] = None
|
|
105
|
+
self._confirmation_result: tuple[bool, Optional[str]] = (True, None)
|
|
106
|
+
self._inline_selector: Optional[InlineSelector] = None
|
|
107
|
+
self._model_selector: Optional[ModelSelector] = None
|
|
108
|
+
self._update_info: Optional[str] = None # Latest version if update available
|
|
109
|
+
|
|
110
|
+
self._agent = self._create_agent(self._current_model)
|
|
111
|
+
|
|
112
|
+
def _create_agent(self, model_name: str) -> ReActAgent:
|
|
113
|
+
"""Create a new ReActAgent with the specified model."""
|
|
114
|
+
registry = get_default_registry()
|
|
115
|
+
memory = SlidingWindowConversationManager(window_size=10)
|
|
116
|
+
return ReActAgent(
|
|
117
|
+
name="kader_cli",
|
|
118
|
+
tools=registry,
|
|
119
|
+
memory=memory,
|
|
120
|
+
model_name=model_name,
|
|
121
|
+
use_persistence=True,
|
|
122
|
+
interrupt_before_tool=True,
|
|
123
|
+
tool_confirmation_callback=self._tool_confirmation_callback,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def _tool_confirmation_callback(self, message: str) -> tuple[bool, Optional[str]]:
|
|
127
|
+
"""
|
|
128
|
+
Callback for tool confirmation - called from agent thread.
|
|
129
|
+
|
|
130
|
+
Shows inline selector with arrow key navigation.
|
|
131
|
+
"""
|
|
132
|
+
# Set up synchronization
|
|
133
|
+
self._confirmation_event = threading.Event()
|
|
134
|
+
self._confirmation_result = (True, None) # Default
|
|
135
|
+
|
|
136
|
+
# Schedule selector to be shown on main thread
|
|
137
|
+
# Use call_from_thread to safely call from background thread
|
|
138
|
+
self.call_from_thread(self._show_inline_selector, message)
|
|
139
|
+
|
|
140
|
+
# Wait for user response (blocking in agent thread)
|
|
141
|
+
# This is safe because we're in a background thread
|
|
142
|
+
self._confirmation_event.wait()
|
|
143
|
+
|
|
144
|
+
# Return the result
|
|
145
|
+
return self._confirmation_result
|
|
146
|
+
|
|
147
|
+
def _show_inline_selector(self, message: str) -> None:
|
|
148
|
+
"""Show the inline selector in the conversation view."""
|
|
149
|
+
# Stop spinner while waiting for confirmation
|
|
150
|
+
try:
|
|
151
|
+
spinner = self.query_one(LoadingSpinner)
|
|
152
|
+
spinner.stop()
|
|
153
|
+
except Exception:
|
|
154
|
+
pass
|
|
155
|
+
|
|
156
|
+
conversation = self.query_one("#conversation-view", ConversationView)
|
|
157
|
+
|
|
158
|
+
# Create and mount the selector
|
|
159
|
+
self._inline_selector = InlineSelector(message, id="tool-selector")
|
|
160
|
+
conversation.mount(self._inline_selector)
|
|
161
|
+
conversation.scroll_end()
|
|
162
|
+
|
|
163
|
+
# Disable input and focus selector
|
|
164
|
+
prompt_input = self.query_one("#prompt-input", Input)
|
|
165
|
+
prompt_input.disabled = True
|
|
166
|
+
|
|
167
|
+
# Force focus on the selector widget
|
|
168
|
+
self.set_focus(self._inline_selector)
|
|
169
|
+
|
|
170
|
+
# Force refresh
|
|
171
|
+
self.refresh()
|
|
172
|
+
|
|
173
|
+
def on_inline_selector_confirmed(self, event: InlineSelector.Confirmed) -> None:
|
|
174
|
+
"""Handle confirmation from inline selector."""
|
|
175
|
+
conversation = self.query_one("#conversation-view", ConversationView)
|
|
176
|
+
|
|
177
|
+
# Set result
|
|
178
|
+
self._confirmation_result = (event.confirmed, None)
|
|
179
|
+
|
|
180
|
+
# Remove selector and show result message
|
|
181
|
+
tool_message = None
|
|
182
|
+
if self._inline_selector:
|
|
183
|
+
tool_message = self._inline_selector.message
|
|
184
|
+
self._inline_selector.remove()
|
|
185
|
+
self._inline_selector = None
|
|
186
|
+
|
|
187
|
+
if event.confirmed:
|
|
188
|
+
if tool_message:
|
|
189
|
+
conversation.add_message(tool_message, "assistant")
|
|
190
|
+
conversation.add_message("(+) Executing tool...", "assistant")
|
|
191
|
+
# Restart spinner
|
|
192
|
+
try:
|
|
193
|
+
spinner = self.query_one(LoadingSpinner)
|
|
194
|
+
spinner.start()
|
|
195
|
+
except Exception:
|
|
196
|
+
pass
|
|
197
|
+
else:
|
|
198
|
+
conversation.add_message("(-) Tool execution skipped.", "assistant")
|
|
199
|
+
|
|
200
|
+
# Re-enable input
|
|
201
|
+
prompt_input = self.query_one("#prompt-input", Input)
|
|
202
|
+
prompt_input.disabled = False
|
|
203
|
+
|
|
204
|
+
# Signal the waiting thread BEFORE focusing input
|
|
205
|
+
# This ensures the agent thread can continue
|
|
206
|
+
if self._confirmation_event:
|
|
207
|
+
self._confirmation_event.set()
|
|
208
|
+
|
|
209
|
+
# Now focus input
|
|
210
|
+
prompt_input.focus()
|
|
211
|
+
|
|
212
|
+
async def _show_model_selector(self, conversation: ConversationView) -> None:
|
|
213
|
+
"""Show the model selector widget."""
|
|
214
|
+
from kader.providers import OllamaProvider
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
models = OllamaProvider.get_supported_models()
|
|
218
|
+
if not models:
|
|
219
|
+
conversation.add_message(
|
|
220
|
+
"## Models (^^)\n\n*No models found. Is Ollama running?*",
|
|
221
|
+
"assistant",
|
|
222
|
+
)
|
|
223
|
+
return
|
|
224
|
+
|
|
225
|
+
# Create and mount the model selector
|
|
226
|
+
self._model_selector = ModelSelector(
|
|
227
|
+
models=models, current_model=self._current_model, id="model-selector"
|
|
228
|
+
)
|
|
229
|
+
conversation.mount(self._model_selector)
|
|
230
|
+
conversation.scroll_end()
|
|
231
|
+
|
|
232
|
+
# Disable input and focus selector
|
|
233
|
+
prompt_input = self.query_one("#prompt-input", Input)
|
|
234
|
+
prompt_input.disabled = True
|
|
235
|
+
self.set_focus(self._model_selector)
|
|
236
|
+
|
|
237
|
+
except Exception as e:
|
|
238
|
+
conversation.add_message(
|
|
239
|
+
f"## Models (^^)\n\n*Error fetching models: {e}*", "assistant"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
def on_model_selector_model_selected(
|
|
243
|
+
self, event: ModelSelector.ModelSelected
|
|
244
|
+
) -> None:
|
|
245
|
+
"""Handle model selection."""
|
|
246
|
+
conversation = self.query_one("#conversation-view", ConversationView)
|
|
247
|
+
|
|
248
|
+
# Remove selector
|
|
249
|
+
if self._model_selector:
|
|
250
|
+
self._model_selector.remove()
|
|
251
|
+
self._model_selector = None
|
|
252
|
+
|
|
253
|
+
# Update model and recreate agent
|
|
254
|
+
old_model = self._current_model
|
|
255
|
+
self._current_model = event.model
|
|
256
|
+
self._agent = self._create_agent(self._current_model)
|
|
257
|
+
|
|
258
|
+
conversation.add_message(
|
|
259
|
+
f"(+) Model changed from `{old_model}` to `{self._current_model}`",
|
|
260
|
+
"assistant",
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
# Re-enable input
|
|
264
|
+
prompt_input = self.query_one("#prompt-input", Input)
|
|
265
|
+
prompt_input.disabled = False
|
|
266
|
+
prompt_input.focus()
|
|
267
|
+
|
|
268
|
+
def on_model_selector_model_cancelled(
|
|
269
|
+
self, event: ModelSelector.ModelCancelled
|
|
270
|
+
) -> None:
|
|
271
|
+
"""Handle model selection cancelled."""
|
|
272
|
+
conversation = self.query_one("#conversation-view", ConversationView)
|
|
273
|
+
|
|
274
|
+
# Remove selector
|
|
275
|
+
if self._model_selector:
|
|
276
|
+
self._model_selector.remove()
|
|
277
|
+
self._model_selector = None
|
|
278
|
+
|
|
279
|
+
conversation.add_message(
|
|
280
|
+
f"Model selection cancelled. Current model: `{self._current_model}`",
|
|
281
|
+
"assistant",
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
# Re-enable input
|
|
285
|
+
prompt_input = self.query_one("#prompt-input", Input)
|
|
286
|
+
prompt_input.disabled = False
|
|
287
|
+
prompt_input.focus()
|
|
288
|
+
|
|
289
|
+
def compose(self) -> ComposeResult:
|
|
290
|
+
"""Create the application layout."""
|
|
291
|
+
yield Header()
|
|
292
|
+
|
|
293
|
+
with Horizontal(id="main-container"):
|
|
294
|
+
# Sidebar with directory tree
|
|
295
|
+
with Vertical(id="sidebar"):
|
|
296
|
+
yield Static("Files", id="sidebar-title")
|
|
297
|
+
yield ASCIITree(str(Path.cwd().name), id="directory-tree")
|
|
298
|
+
|
|
299
|
+
# Main content area
|
|
300
|
+
with Vertical(id="content-area"):
|
|
301
|
+
# Conversation view
|
|
302
|
+
with Container(id="conversation"):
|
|
303
|
+
yield ConversationView(id="conversation-view")
|
|
304
|
+
yield LoadingSpinner()
|
|
305
|
+
|
|
306
|
+
# Input area
|
|
307
|
+
with Container(id="input-container"):
|
|
308
|
+
yield Input(
|
|
309
|
+
placeholder="Enter your prompt or /help for commands...",
|
|
310
|
+
id="prompt-input",
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
yield Footer()
|
|
314
|
+
|
|
315
|
+
def on_mount(self) -> None:
|
|
316
|
+
"""Initialize the app when mounted."""
|
|
317
|
+
# Show welcome message
|
|
318
|
+
conversation = self.query_one("#conversation-view", ConversationView)
|
|
319
|
+
conversation.mount(Markdown(WELCOME_MESSAGE, id="welcome"))
|
|
320
|
+
|
|
321
|
+
# Focus the input
|
|
322
|
+
self.query_one("#prompt-input", Input).focus()
|
|
323
|
+
|
|
324
|
+
# Check initial size
|
|
325
|
+
self._check_terminal_size()
|
|
326
|
+
|
|
327
|
+
# Start background update check
|
|
328
|
+
# Start background update check
|
|
329
|
+
threading.Thread(target=self._check_for_updates, daemon=True).start()
|
|
330
|
+
|
|
331
|
+
# Initial tree population
|
|
332
|
+
self._refresh_directory_tree()
|
|
333
|
+
|
|
334
|
+
def _populate_tree(self, node, path: Path) -> None:
|
|
335
|
+
"""Recursively populate the tree with ASCII symbols."""
|
|
336
|
+
try:
|
|
337
|
+
# Sort: directories first, then files
|
|
338
|
+
items = sorted(
|
|
339
|
+
path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())
|
|
340
|
+
)
|
|
341
|
+
for child in items:
|
|
342
|
+
if child.name.startswith((".", "__pycache__")):
|
|
343
|
+
continue
|
|
344
|
+
|
|
345
|
+
if child.is_dir():
|
|
346
|
+
new_node = node.add(f"[+] {child.name}", expand=False)
|
|
347
|
+
self._populate_tree(new_node, child)
|
|
348
|
+
else:
|
|
349
|
+
node.add(f"{child.name}")
|
|
350
|
+
except Exception:
|
|
351
|
+
pass
|
|
352
|
+
|
|
353
|
+
def _check_for_updates(self) -> None:
|
|
354
|
+
"""Check for package updates in background thread."""
|
|
355
|
+
try:
|
|
356
|
+
from outdated import check_outdated
|
|
357
|
+
|
|
358
|
+
current_version = get_version("kader")
|
|
359
|
+
is_outdated, latest_version = check_outdated("kader", current_version)
|
|
360
|
+
|
|
361
|
+
if is_outdated:
|
|
362
|
+
self._update_info = latest_version
|
|
363
|
+
# Schedule UI update on main thread
|
|
364
|
+
self.call_from_thread(self._show_update_notification)
|
|
365
|
+
except Exception:
|
|
366
|
+
# Silently ignore update check failures
|
|
367
|
+
pass
|
|
368
|
+
|
|
369
|
+
def _show_update_notification(self) -> None:
|
|
370
|
+
"""Show update notification as a toast."""
|
|
371
|
+
if not self._update_info:
|
|
372
|
+
return
|
|
373
|
+
|
|
374
|
+
try:
|
|
375
|
+
current = get_version("kader")
|
|
376
|
+
message = (
|
|
377
|
+
f">> Update available! v{current} → v{self._update_info} "
|
|
378
|
+
f"Run: uv tool upgrade kader"
|
|
379
|
+
)
|
|
380
|
+
self.notify(message, severity="information", timeout=10)
|
|
381
|
+
except Exception:
|
|
382
|
+
pass
|
|
383
|
+
|
|
384
|
+
def on_resize(self) -> None:
|
|
385
|
+
"""Handle terminal resize events."""
|
|
386
|
+
self._check_terminal_size()
|
|
387
|
+
|
|
388
|
+
def _check_terminal_size(self) -> None:
|
|
389
|
+
"""Check if terminal is large enough and show warning if not."""
|
|
390
|
+
width = self.console.size.width
|
|
391
|
+
height = self.console.size.height
|
|
392
|
+
|
|
393
|
+
# Check if we need to show/hide the size warning
|
|
394
|
+
too_small = width < MIN_WIDTH or height < MIN_HEIGHT
|
|
395
|
+
|
|
396
|
+
try:
|
|
397
|
+
warning = self.query_one("#size-warning", Static)
|
|
398
|
+
if not too_small:
|
|
399
|
+
warning.remove()
|
|
400
|
+
except Exception:
|
|
401
|
+
if too_small:
|
|
402
|
+
# Show warning overlay
|
|
403
|
+
warning_text = f"""<!> Terminal Too Small
|
|
404
|
+
|
|
405
|
+
Current: {width}x{height}
|
|
406
|
+
Minimum: {MIN_WIDTH}x{MIN_HEIGHT}
|
|
407
|
+
|
|
408
|
+
Please resize your terminal."""
|
|
409
|
+
warning = Static(warning_text, id="size-warning")
|
|
410
|
+
self.mount(warning)
|
|
411
|
+
|
|
412
|
+
async def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
413
|
+
"""Handle user input submission."""
|
|
414
|
+
user_input = event.value.strip()
|
|
415
|
+
if not user_input:
|
|
416
|
+
return
|
|
417
|
+
|
|
418
|
+
# Clear the input
|
|
419
|
+
event.input.value = ""
|
|
420
|
+
|
|
421
|
+
# Check if it's a command
|
|
422
|
+
if user_input.startswith("/"):
|
|
423
|
+
await self._handle_command(user_input)
|
|
424
|
+
else:
|
|
425
|
+
await self._handle_chat(user_input)
|
|
426
|
+
|
|
427
|
+
async def _handle_command(self, command: str) -> None:
|
|
428
|
+
"""Handle CLI commands."""
|
|
429
|
+
cmd = command.lower().strip()
|
|
430
|
+
conversation = self.query_one("#conversation-view", ConversationView)
|
|
431
|
+
|
|
432
|
+
if cmd == "/help":
|
|
433
|
+
conversation.add_message(HELP_TEXT, "assistant")
|
|
434
|
+
elif cmd == "/models":
|
|
435
|
+
await self._show_model_selector(conversation)
|
|
436
|
+
elif cmd == "/theme":
|
|
437
|
+
self._cycle_theme()
|
|
438
|
+
theme_name = THEME_NAMES[self._current_theme_index]
|
|
439
|
+
conversation.add_message(
|
|
440
|
+
f"{{~}} Theme changed to **{theme_name}**!", "assistant"
|
|
441
|
+
)
|
|
442
|
+
elif cmd == "/clear":
|
|
443
|
+
conversation.clear_messages()
|
|
444
|
+
self._agent.memory.clear()
|
|
445
|
+
self._agent.provider.reset_tracking() # Reset usage/cost tracking
|
|
446
|
+
self._current_session_id = None
|
|
447
|
+
self.notify("Conversation cleared!", severity="information")
|
|
448
|
+
elif cmd == "/save":
|
|
449
|
+
self._handle_save_session(conversation)
|
|
450
|
+
elif cmd == "/sessions":
|
|
451
|
+
self._handle_list_sessions(conversation)
|
|
452
|
+
elif cmd.startswith("/load"):
|
|
453
|
+
parts = command.strip().split(maxsplit=1)
|
|
454
|
+
if len(parts) < 2:
|
|
455
|
+
conversation.add_message(
|
|
456
|
+
"❌ Usage: `/load <session_id>`\n\nUse `/sessions` to see available sessions.",
|
|
457
|
+
"assistant",
|
|
458
|
+
)
|
|
459
|
+
else:
|
|
460
|
+
self._handle_load_session(parts[1], conversation)
|
|
461
|
+
elif cmd == "/refresh":
|
|
462
|
+
self._refresh_directory_tree()
|
|
463
|
+
self.notify("Directory tree refreshed!", severity="information")
|
|
464
|
+
elif cmd == "/cost":
|
|
465
|
+
self._handle_cost(conversation)
|
|
466
|
+
elif cmd == "/exit":
|
|
467
|
+
self.exit()
|
|
468
|
+
else:
|
|
469
|
+
conversation.add_message(
|
|
470
|
+
f"❌ Unknown command: `{command}`\n\nType `/help` to see available commands.",
|
|
471
|
+
"assistant",
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
async def _handle_chat(self, message: str) -> None:
|
|
475
|
+
"""Handle regular chat messages with ReActAgent."""
|
|
476
|
+
if self._is_processing:
|
|
477
|
+
self.notify("Please wait for the current response...", severity="warning")
|
|
478
|
+
return
|
|
479
|
+
|
|
480
|
+
self._is_processing = True
|
|
481
|
+
conversation = self.query_one("#conversation-view", ConversationView)
|
|
482
|
+
spinner = self.query_one(LoadingSpinner)
|
|
483
|
+
|
|
484
|
+
# Add user message to UI
|
|
485
|
+
conversation.add_message(message, "user")
|
|
486
|
+
|
|
487
|
+
# Show loading spinner
|
|
488
|
+
spinner.start()
|
|
489
|
+
|
|
490
|
+
# Use run_worker to run agent in background without blocking event loop
|
|
491
|
+
self.run_worker(
|
|
492
|
+
self._invoke_agent_worker(message),
|
|
493
|
+
name="agent_worker",
|
|
494
|
+
exclusive=True,
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
async def _invoke_agent_worker(self, message: str) -> None:
|
|
498
|
+
"""Worker to invoke agent in background."""
|
|
499
|
+
conversation = self.query_one("#conversation-view", ConversationView)
|
|
500
|
+
spinner = self.query_one(LoadingSpinner)
|
|
501
|
+
|
|
502
|
+
try:
|
|
503
|
+
# Run the agent invoke in a thread
|
|
504
|
+
loop = asyncio.get_event_loop()
|
|
505
|
+
response = await loop.run_in_executor(
|
|
506
|
+
None, lambda: self._agent.invoke(message)
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
# Hide spinner and show response (this runs on main thread via await)
|
|
510
|
+
spinner.stop()
|
|
511
|
+
if response and response.content:
|
|
512
|
+
conversation.add_message(response.content, "assistant")
|
|
513
|
+
|
|
514
|
+
except Exception as e:
|
|
515
|
+
spinner.stop()
|
|
516
|
+
error_msg = f"(-) **Error:** {str(e)}\n\nMake sure Ollama is running and the model `{self._current_model}` is available."
|
|
517
|
+
conversation.add_message(error_msg, "assistant")
|
|
518
|
+
self.notify(f"Error: {e}", severity="error")
|
|
519
|
+
|
|
520
|
+
finally:
|
|
521
|
+
self._is_processing = False
|
|
522
|
+
# Auto-refresh directory tree in case agent created/modified files
|
|
523
|
+
self._refresh_directory_tree()
|
|
524
|
+
|
|
525
|
+
def _cycle_theme(self) -> None:
|
|
526
|
+
"""Cycle through available themes."""
|
|
527
|
+
# Remove current theme class if it's not dark
|
|
528
|
+
current_theme = THEME_NAMES[self._current_theme_index]
|
|
529
|
+
if current_theme != "dark":
|
|
530
|
+
self.remove_class(f"theme-{current_theme}")
|
|
531
|
+
|
|
532
|
+
# Move to next theme
|
|
533
|
+
self._current_theme_index = (self._current_theme_index + 1) % len(THEME_NAMES)
|
|
534
|
+
new_theme = THEME_NAMES[self._current_theme_index]
|
|
535
|
+
|
|
536
|
+
# Apply new theme class (dark is default, no class needed)
|
|
537
|
+
if new_theme != "dark":
|
|
538
|
+
self.add_class(f"theme-{new_theme}")
|
|
539
|
+
|
|
540
|
+
def action_clear(self) -> None:
|
|
541
|
+
"""Clear the conversation (Ctrl+L)."""
|
|
542
|
+
conversation = self.query_one("#conversation-view", ConversationView)
|
|
543
|
+
conversation.clear_messages()
|
|
544
|
+
self._agent.memory.clear()
|
|
545
|
+
self.notify("Conversation cleared!", severity="information")
|
|
546
|
+
|
|
547
|
+
def action_cycle_theme(self) -> None:
|
|
548
|
+
"""Cycle theme (Ctrl+T)."""
|
|
549
|
+
self._cycle_theme()
|
|
550
|
+
theme_name = THEME_NAMES[self._current_theme_index]
|
|
551
|
+
self.notify(f"Theme: {theme_name}", severity="information")
|
|
552
|
+
|
|
553
|
+
def action_save_session(self) -> None:
|
|
554
|
+
"""Save session (Ctrl+S)."""
|
|
555
|
+
conversation = self.query_one("#conversation-view", ConversationView)
|
|
556
|
+
self._handle_save_session(conversation)
|
|
557
|
+
|
|
558
|
+
def action_refresh_tree(self) -> None:
|
|
559
|
+
"""Refresh directory tree (Ctrl+R)."""
|
|
560
|
+
self._refresh_directory_tree()
|
|
561
|
+
self.notify("Directory tree refreshed!", severity="information")
|
|
562
|
+
|
|
563
|
+
def _refresh_directory_tree(self) -> None:
|
|
564
|
+
"""Refresh the directory tree with ASCII symbols."""
|
|
565
|
+
try:
|
|
566
|
+
tree = self.query_one("#directory-tree", ASCIITree)
|
|
567
|
+
tree.clear()
|
|
568
|
+
tree.root.label = str(Path.cwd().name)
|
|
569
|
+
self._populate_tree(tree.root, Path.cwd())
|
|
570
|
+
tree.root.expand()
|
|
571
|
+
except Exception:
|
|
572
|
+
pass # Silently ignore if tree not found
|
|
573
|
+
|
|
574
|
+
def _handle_save_session(self, conversation: ConversationView) -> None:
|
|
575
|
+
"""Save the current session."""
|
|
576
|
+
try:
|
|
577
|
+
# Create a new session if none exists
|
|
578
|
+
if not self._current_session_id:
|
|
579
|
+
session = self._session_manager.create_session("kader_cli")
|
|
580
|
+
self._current_session_id = session.session_id
|
|
581
|
+
|
|
582
|
+
# Get messages from agent memory and save
|
|
583
|
+
messages = [msg.message for msg in self._agent.memory.get_messages()]
|
|
584
|
+
self._session_manager.save_conversation(self._current_session_id, messages)
|
|
585
|
+
|
|
586
|
+
conversation.add_message(
|
|
587
|
+
f"(+) Session saved!\n\n**Session ID:** `{self._current_session_id}`",
|
|
588
|
+
"assistant",
|
|
589
|
+
)
|
|
590
|
+
self.notify("Session saved!", severity="information")
|
|
591
|
+
except Exception as e:
|
|
592
|
+
conversation.add_message(f"(-) Error saving session: {e}", "assistant")
|
|
593
|
+
self.notify(f"Error: {e}", severity="error")
|
|
594
|
+
|
|
595
|
+
def _handle_load_session(
|
|
596
|
+
self, session_id: str, conversation: ConversationView
|
|
597
|
+
) -> None:
|
|
598
|
+
"""Load a saved session by ID."""
|
|
599
|
+
try:
|
|
600
|
+
# Check if session exists
|
|
601
|
+
session = self._session_manager.get_session(session_id)
|
|
602
|
+
if not session:
|
|
603
|
+
conversation.add_message(
|
|
604
|
+
f"(-) Session `{session_id}` not found.\n\nUse `/sessions` to see available sessions.",
|
|
605
|
+
"assistant",
|
|
606
|
+
)
|
|
607
|
+
return
|
|
608
|
+
|
|
609
|
+
# Load conversation history
|
|
610
|
+
messages = self._session_manager.load_conversation(session_id)
|
|
611
|
+
|
|
612
|
+
# Clear current state
|
|
613
|
+
conversation.clear_messages()
|
|
614
|
+
self._agent.memory.clear()
|
|
615
|
+
|
|
616
|
+
# Add loaded messages to memory and UI
|
|
617
|
+
for msg in messages:
|
|
618
|
+
self._agent.memory.add_message(msg)
|
|
619
|
+
role = msg.get("role", "user")
|
|
620
|
+
content = msg.get("content", "")
|
|
621
|
+
if role in ["user", "assistant"] and content:
|
|
622
|
+
conversation.add_message(content, role)
|
|
623
|
+
|
|
624
|
+
self._current_session_id = session_id
|
|
625
|
+
conversation.add_message(
|
|
626
|
+
f"(+) Session `{session_id}` loaded with {len(messages)} messages.",
|
|
627
|
+
"assistant",
|
|
628
|
+
)
|
|
629
|
+
self.notify("Session loaded!", severity="information")
|
|
630
|
+
except Exception as e:
|
|
631
|
+
conversation.add_message(f"(-) Error loading session: {e}", "assistant")
|
|
632
|
+
self.notify(f"Error: {e}", severity="error")
|
|
633
|
+
|
|
634
|
+
def _handle_list_sessions(self, conversation: ConversationView) -> None:
|
|
635
|
+
"""List all saved sessions."""
|
|
636
|
+
try:
|
|
637
|
+
sessions = self._session_manager.list_sessions()
|
|
638
|
+
|
|
639
|
+
if not sessions:
|
|
640
|
+
conversation.add_message(
|
|
641
|
+
"[ ] No saved sessions found.\n\nUse `/save` to save the current session.",
|
|
642
|
+
"assistant",
|
|
643
|
+
)
|
|
644
|
+
return
|
|
645
|
+
|
|
646
|
+
lines = [
|
|
647
|
+
"## Saved Sessions [=]\n",
|
|
648
|
+
"| Session ID | Created | Updated |",
|
|
649
|
+
"|------------|---------|---------|",
|
|
650
|
+
]
|
|
651
|
+
for session in sessions:
|
|
652
|
+
# Shorten UUID for display
|
|
653
|
+
created = session.created_at[:10] # Just date
|
|
654
|
+
updated = session.updated_at[:10]
|
|
655
|
+
lines.append(f"| `{session.session_id}` | {created} | {updated} |")
|
|
656
|
+
|
|
657
|
+
lines.append("\n*Use `/load <session_id>` to load a session.*")
|
|
658
|
+
conversation.add_message("\n".join(lines), "assistant")
|
|
659
|
+
except Exception as e:
|
|
660
|
+
conversation.add_message(f"❌ Error listing sessions: {e}", "assistant")
|
|
661
|
+
self.notify(f"Error: {e}", severity="error")
|
|
662
|
+
|
|
663
|
+
def _handle_cost(self, conversation: ConversationView) -> None:
|
|
664
|
+
"""Display LLM usage costs."""
|
|
665
|
+
try:
|
|
666
|
+
# Get cost and usage from the provider
|
|
667
|
+
cost = self._agent.provider.total_cost
|
|
668
|
+
usage = self._agent.provider.total_usage
|
|
669
|
+
model = self._agent.provider.model
|
|
670
|
+
|
|
671
|
+
lines = [
|
|
672
|
+
"## Usage Costs ($)\n",
|
|
673
|
+
f"**Model:** `{model}`\n",
|
|
674
|
+
"### Cost Breakdown",
|
|
675
|
+
"| Type | Amount |",
|
|
676
|
+
"|------|--------|",
|
|
677
|
+
f"| Input Cost | ${cost.input_cost:.6f} |",
|
|
678
|
+
f"| Output Cost | ${cost.output_cost:.6f} |",
|
|
679
|
+
f"| **Total Cost** | **${cost.total_cost:.6f}** |",
|
|
680
|
+
"",
|
|
681
|
+
"### Token Usage",
|
|
682
|
+
"| Type | Tokens |",
|
|
683
|
+
"|------|--------|",
|
|
684
|
+
f"| Prompt Tokens | {usage.prompt_tokens:,} |",
|
|
685
|
+
f"| Completion Tokens | {usage.completion_tokens:,} |",
|
|
686
|
+
f"| **Total Tokens** | **{usage.total_tokens:,}** |",
|
|
687
|
+
]
|
|
688
|
+
|
|
689
|
+
if cost.total_cost == 0.0:
|
|
690
|
+
lines.append(
|
|
691
|
+
"\n> (!) *Note: Ollama runs locally, so there are no API costs.*"
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
conversation.add_message("\n".join(lines), "assistant")
|
|
695
|
+
except Exception as e:
|
|
696
|
+
conversation.add_message(f"(-) Error getting costs: {e}", "assistant")
|
|
697
|
+
self.notify(f"Error: {e}", severity="error")
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def main() -> None:
|
|
701
|
+
"""Run the Kader CLI application."""
|
|
702
|
+
app = KaderApp()
|
|
703
|
+
app.run()
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
if __name__ == "__main__":
|
|
707
|
+
main()
|