kader 0.1.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.
- cli/README.md +169 -0
- cli/__init__.py +5 -0
- cli/__main__.py +6 -0
- cli/app.py +547 -0
- cli/app.tcss +648 -0
- cli/utils.py +62 -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 +920 -0
- kader/agent/logger.py +188 -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.0.dist-info/METADATA +319 -0
- kader-0.1.0.dist-info/RECORD +45 -0
- kader-0.1.0.dist-info/WHEEL +4 -0
- kader-0.1.0.dist-info/entry_points.txt +2 -0
cli/app.py
ADDED
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
"""Kader CLI - Modern Vibe Coding CLI with Textual."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import threading
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from textual.app import App, ComposeResult
|
|
9
|
+
from textual.binding import Binding
|
|
10
|
+
from textual.containers import Container, Horizontal, Vertical
|
|
11
|
+
from textual.widgets import (
|
|
12
|
+
DirectoryTree,
|
|
13
|
+
Footer,
|
|
14
|
+
Header,
|
|
15
|
+
Input,
|
|
16
|
+
Markdown,
|
|
17
|
+
Static,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from kader.agent.agents import ReActAgent
|
|
21
|
+
from kader.memory import (
|
|
22
|
+
FileSessionManager,
|
|
23
|
+
MemoryConfig,
|
|
24
|
+
SlidingWindowConversationManager,
|
|
25
|
+
)
|
|
26
|
+
from kader.tools import get_default_registry
|
|
27
|
+
|
|
28
|
+
from .utils import (
|
|
29
|
+
DEFAULT_MODEL,
|
|
30
|
+
HELP_TEXT,
|
|
31
|
+
THEME_NAMES,
|
|
32
|
+
)
|
|
33
|
+
from .widgets import ConversationView, InlineSelector, LoadingSpinner, ModelSelector
|
|
34
|
+
|
|
35
|
+
WELCOME_MESSAGE = """# Welcome to Kader CLI! 🚀
|
|
36
|
+
|
|
37
|
+
Your **modern AI-powered coding assistant**.
|
|
38
|
+
|
|
39
|
+
Type a message below to start chatting, or use one of the commands:
|
|
40
|
+
|
|
41
|
+
- `/help` - Show available commands
|
|
42
|
+
- `/models` - View available LLM models
|
|
43
|
+
- `/theme` - Change the color theme
|
|
44
|
+
- `/clear` - Clear the conversation
|
|
45
|
+
- `/save` - Save current session
|
|
46
|
+
- `/load` - Load a saved session
|
|
47
|
+
- `/sessions` - List saved sessions
|
|
48
|
+
- `/exit` - Exit the application
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class KaderApp(App):
|
|
53
|
+
"""Main Kader CLI application."""
|
|
54
|
+
|
|
55
|
+
TITLE = "Kader CLI"
|
|
56
|
+
SUB_TITLE = "Modern Vibe Coding Assistant"
|
|
57
|
+
CSS_PATH = "app.tcss"
|
|
58
|
+
|
|
59
|
+
BINDINGS = [
|
|
60
|
+
Binding("ctrl+q", "quit", "Quit"),
|
|
61
|
+
Binding("ctrl+l", "clear", "Clear"),
|
|
62
|
+
Binding("ctrl+t", "cycle_theme", "Theme"),
|
|
63
|
+
Binding("ctrl+s", "save_session", "Save"),
|
|
64
|
+
Binding("ctrl+r", "refresh_tree", "Refresh"),
|
|
65
|
+
Binding("tab", "focus_next", "Next", show=False),
|
|
66
|
+
Binding("shift+tab", "focus_previous", "Previous", show=False),
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
def __init__(self) -> None:
|
|
70
|
+
super().__init__()
|
|
71
|
+
self._current_theme_index = 0
|
|
72
|
+
self._is_processing = False
|
|
73
|
+
self._current_model = DEFAULT_MODEL
|
|
74
|
+
self._current_session_id: str | None = None
|
|
75
|
+
# Session manager with sessions stored in ~/.kader/sessions/
|
|
76
|
+
self._session_manager = FileSessionManager(
|
|
77
|
+
MemoryConfig(memory_dir=Path.home() / ".kader")
|
|
78
|
+
)
|
|
79
|
+
# Tool confirmation coordination
|
|
80
|
+
self._confirmation_event: Optional[threading.Event] = None
|
|
81
|
+
self._confirmation_result: tuple[bool, Optional[str]] = (True, None)
|
|
82
|
+
self._inline_selector: Optional[InlineSelector] = None
|
|
83
|
+
self._model_selector: Optional[ModelSelector] = None
|
|
84
|
+
|
|
85
|
+
self._agent = self._create_agent(self._current_model)
|
|
86
|
+
|
|
87
|
+
def _create_agent(self, model_name: str) -> ReActAgent:
|
|
88
|
+
"""Create a new ReActAgent with the specified model."""
|
|
89
|
+
registry = get_default_registry()
|
|
90
|
+
memory = SlidingWindowConversationManager(window_size=10)
|
|
91
|
+
return ReActAgent(
|
|
92
|
+
name="kader_cli",
|
|
93
|
+
tools=registry,
|
|
94
|
+
memory=memory,
|
|
95
|
+
model_name=model_name,
|
|
96
|
+
use_persistence=True,
|
|
97
|
+
interrupt_before_tool=True,
|
|
98
|
+
tool_confirmation_callback=self._tool_confirmation_callback,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def _tool_confirmation_callback(self, message: str) -> tuple[bool, Optional[str]]:
|
|
102
|
+
"""
|
|
103
|
+
Callback for tool confirmation - called from agent thread.
|
|
104
|
+
|
|
105
|
+
Shows inline selector with arrow key navigation.
|
|
106
|
+
"""
|
|
107
|
+
# Set up synchronization
|
|
108
|
+
self._confirmation_event = threading.Event()
|
|
109
|
+
self._confirmation_result = (True, None) # Default
|
|
110
|
+
|
|
111
|
+
# Schedule selector to be shown on main thread
|
|
112
|
+
# Use call_from_thread to safely call from background thread
|
|
113
|
+
self.call_from_thread(self._show_inline_selector, message)
|
|
114
|
+
|
|
115
|
+
# Wait for user response (blocking in agent thread)
|
|
116
|
+
# This is safe because we're in a background thread
|
|
117
|
+
self._confirmation_event.wait()
|
|
118
|
+
|
|
119
|
+
# Return the result
|
|
120
|
+
return self._confirmation_result
|
|
121
|
+
|
|
122
|
+
def _show_inline_selector(self, message: str) -> None:
|
|
123
|
+
"""Show the inline selector in the conversation view."""
|
|
124
|
+
# Stop spinner while waiting for confirmation
|
|
125
|
+
try:
|
|
126
|
+
spinner = self.query_one(LoadingSpinner)
|
|
127
|
+
spinner.stop()
|
|
128
|
+
except Exception:
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
conversation = self.query_one("#conversation-view", ConversationView)
|
|
132
|
+
|
|
133
|
+
# Create and mount the selector
|
|
134
|
+
self._inline_selector = InlineSelector(message, id="tool-selector")
|
|
135
|
+
conversation.mount(self._inline_selector)
|
|
136
|
+
conversation.scroll_end()
|
|
137
|
+
|
|
138
|
+
# Disable input and focus selector
|
|
139
|
+
prompt_input = self.query_one("#prompt-input", Input)
|
|
140
|
+
prompt_input.disabled = True
|
|
141
|
+
|
|
142
|
+
# Force focus on the selector widget
|
|
143
|
+
self.set_focus(self._inline_selector)
|
|
144
|
+
|
|
145
|
+
# Force refresh
|
|
146
|
+
self.refresh()
|
|
147
|
+
|
|
148
|
+
def on_inline_selector_confirmed(self, event: InlineSelector.Confirmed) -> None:
|
|
149
|
+
"""Handle confirmation from inline selector."""
|
|
150
|
+
conversation = self.query_one("#conversation-view", ConversationView)
|
|
151
|
+
|
|
152
|
+
# Set result
|
|
153
|
+
self._confirmation_result = (event.confirmed, None)
|
|
154
|
+
|
|
155
|
+
# Remove selector and show result message
|
|
156
|
+
if self._inline_selector:
|
|
157
|
+
self._inline_selector.remove()
|
|
158
|
+
self._inline_selector = None
|
|
159
|
+
|
|
160
|
+
if event.confirmed:
|
|
161
|
+
conversation.add_message("✅ Executing tool...", "assistant")
|
|
162
|
+
# Restart spinner
|
|
163
|
+
try:
|
|
164
|
+
spinner = self.query_one(LoadingSpinner)
|
|
165
|
+
spinner.start()
|
|
166
|
+
except Exception:
|
|
167
|
+
pass
|
|
168
|
+
else:
|
|
169
|
+
conversation.add_message("❌ Tool execution skipped.", "assistant")
|
|
170
|
+
|
|
171
|
+
# Re-enable input
|
|
172
|
+
prompt_input = self.query_one("#prompt-input", Input)
|
|
173
|
+
prompt_input.disabled = False
|
|
174
|
+
|
|
175
|
+
# Signal the waiting thread BEFORE focusing input
|
|
176
|
+
# This ensures the agent thread can continue
|
|
177
|
+
if self._confirmation_event:
|
|
178
|
+
self._confirmation_event.set()
|
|
179
|
+
|
|
180
|
+
# Now focus input
|
|
181
|
+
prompt_input.focus()
|
|
182
|
+
|
|
183
|
+
async def _show_model_selector(self, conversation: ConversationView) -> None:
|
|
184
|
+
"""Show the model selector widget."""
|
|
185
|
+
from kader.providers import OllamaProvider
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
models = OllamaProvider.get_supported_models()
|
|
189
|
+
if not models:
|
|
190
|
+
conversation.add_message(
|
|
191
|
+
"## Models 🤖\n\n*No models found. Is Ollama running?*", "assistant"
|
|
192
|
+
)
|
|
193
|
+
return
|
|
194
|
+
|
|
195
|
+
# Create and mount the model selector
|
|
196
|
+
self._model_selector = ModelSelector(
|
|
197
|
+
models=models, current_model=self._current_model, id="model-selector"
|
|
198
|
+
)
|
|
199
|
+
conversation.mount(self._model_selector)
|
|
200
|
+
conversation.scroll_end()
|
|
201
|
+
|
|
202
|
+
# Disable input and focus selector
|
|
203
|
+
prompt_input = self.query_one("#prompt-input", Input)
|
|
204
|
+
prompt_input.disabled = True
|
|
205
|
+
self.set_focus(self._model_selector)
|
|
206
|
+
|
|
207
|
+
except Exception as e:
|
|
208
|
+
conversation.add_message(
|
|
209
|
+
f"## Models 🤖\n\n*Error fetching models: {e}*", "assistant"
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def on_model_selector_model_selected(
|
|
213
|
+
self, event: ModelSelector.ModelSelected
|
|
214
|
+
) -> None:
|
|
215
|
+
"""Handle model selection."""
|
|
216
|
+
conversation = self.query_one("#conversation-view", ConversationView)
|
|
217
|
+
|
|
218
|
+
# Remove selector
|
|
219
|
+
if self._model_selector:
|
|
220
|
+
self._model_selector.remove()
|
|
221
|
+
self._model_selector = None
|
|
222
|
+
|
|
223
|
+
# Update model and recreate agent
|
|
224
|
+
old_model = self._current_model
|
|
225
|
+
self._current_model = event.model
|
|
226
|
+
self._agent = self._create_agent(self._current_model)
|
|
227
|
+
|
|
228
|
+
conversation.add_message(
|
|
229
|
+
f"✅ Model changed from `{old_model}` to `{self._current_model}`",
|
|
230
|
+
"assistant",
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# Re-enable input
|
|
234
|
+
prompt_input = self.query_one("#prompt-input", Input)
|
|
235
|
+
prompt_input.disabled = False
|
|
236
|
+
prompt_input.focus()
|
|
237
|
+
|
|
238
|
+
def on_model_selector_model_cancelled(
|
|
239
|
+
self, event: ModelSelector.ModelCancelled
|
|
240
|
+
) -> None:
|
|
241
|
+
"""Handle model selection cancelled."""
|
|
242
|
+
conversation = self.query_one("#conversation-view", ConversationView)
|
|
243
|
+
|
|
244
|
+
# Remove selector
|
|
245
|
+
if self._model_selector:
|
|
246
|
+
self._model_selector.remove()
|
|
247
|
+
self._model_selector = None
|
|
248
|
+
|
|
249
|
+
conversation.add_message(
|
|
250
|
+
f"Model selection cancelled. Current model: `{self._current_model}`",
|
|
251
|
+
"assistant",
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# Re-enable input
|
|
255
|
+
prompt_input = self.query_one("#prompt-input", Input)
|
|
256
|
+
prompt_input.disabled = False
|
|
257
|
+
prompt_input.focus()
|
|
258
|
+
|
|
259
|
+
def compose(self) -> ComposeResult:
|
|
260
|
+
"""Create the application layout."""
|
|
261
|
+
yield Header()
|
|
262
|
+
|
|
263
|
+
with Horizontal(id="main-container"):
|
|
264
|
+
# Sidebar with directory tree
|
|
265
|
+
with Vertical(id="sidebar"):
|
|
266
|
+
yield Static("📁 Files", id="sidebar-title")
|
|
267
|
+
yield DirectoryTree(Path.cwd(), id="directory-tree")
|
|
268
|
+
|
|
269
|
+
# Main content area
|
|
270
|
+
with Vertical(id="content-area"):
|
|
271
|
+
# Conversation view
|
|
272
|
+
with Container(id="conversation"):
|
|
273
|
+
yield ConversationView(id="conversation-view")
|
|
274
|
+
yield LoadingSpinner()
|
|
275
|
+
|
|
276
|
+
# Input area
|
|
277
|
+
with Container(id="input-container"):
|
|
278
|
+
yield Input(
|
|
279
|
+
placeholder="Enter your prompt or /help for commands...",
|
|
280
|
+
id="prompt-input",
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
yield Footer()
|
|
284
|
+
|
|
285
|
+
def on_mount(self) -> None:
|
|
286
|
+
"""Initialize the app when mounted."""
|
|
287
|
+
# Show welcome message
|
|
288
|
+
conversation = self.query_one("#conversation-view", ConversationView)
|
|
289
|
+
conversation.mount(Markdown(WELCOME_MESSAGE, id="welcome"))
|
|
290
|
+
|
|
291
|
+
# Focus the input
|
|
292
|
+
self.query_one("#prompt-input", Input).focus()
|
|
293
|
+
|
|
294
|
+
async def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
295
|
+
"""Handle user input submission."""
|
|
296
|
+
user_input = event.value.strip()
|
|
297
|
+
if not user_input:
|
|
298
|
+
return
|
|
299
|
+
|
|
300
|
+
# Clear the input
|
|
301
|
+
event.input.value = ""
|
|
302
|
+
|
|
303
|
+
# Check if it's a command
|
|
304
|
+
if user_input.startswith("/"):
|
|
305
|
+
await self._handle_command(user_input)
|
|
306
|
+
else:
|
|
307
|
+
await self._handle_chat(user_input)
|
|
308
|
+
|
|
309
|
+
async def _handle_command(self, command: str) -> None:
|
|
310
|
+
"""Handle CLI commands."""
|
|
311
|
+
cmd = command.lower().strip()
|
|
312
|
+
conversation = self.query_one("#conversation-view", ConversationView)
|
|
313
|
+
|
|
314
|
+
if cmd == "/help":
|
|
315
|
+
conversation.add_message(HELP_TEXT, "assistant")
|
|
316
|
+
elif cmd == "/models":
|
|
317
|
+
await self._show_model_selector(conversation)
|
|
318
|
+
elif cmd == "/theme":
|
|
319
|
+
self._cycle_theme()
|
|
320
|
+
theme_name = THEME_NAMES[self._current_theme_index]
|
|
321
|
+
conversation.add_message(
|
|
322
|
+
f"🎨 Theme changed to **{theme_name}**!", "assistant"
|
|
323
|
+
)
|
|
324
|
+
elif cmd == "/clear":
|
|
325
|
+
conversation.clear_messages()
|
|
326
|
+
self._agent.memory.clear()
|
|
327
|
+
self._current_session_id = None
|
|
328
|
+
self.notify("Conversation cleared!", severity="information")
|
|
329
|
+
elif cmd == "/save":
|
|
330
|
+
self._handle_save_session(conversation)
|
|
331
|
+
elif cmd == "/sessions":
|
|
332
|
+
self._handle_list_sessions(conversation)
|
|
333
|
+
elif cmd.startswith("/load"):
|
|
334
|
+
parts = command.strip().split(maxsplit=1)
|
|
335
|
+
if len(parts) < 2:
|
|
336
|
+
conversation.add_message(
|
|
337
|
+
"❌ Usage: `/load <session_id>`\n\nUse `/sessions` to see available sessions.",
|
|
338
|
+
"assistant",
|
|
339
|
+
)
|
|
340
|
+
else:
|
|
341
|
+
self._handle_load_session(parts[1], conversation)
|
|
342
|
+
elif cmd == "/refresh":
|
|
343
|
+
self._refresh_directory_tree()
|
|
344
|
+
self.notify("Directory tree refreshed!", severity="information")
|
|
345
|
+
elif cmd == "/exit":
|
|
346
|
+
self.exit()
|
|
347
|
+
else:
|
|
348
|
+
conversation.add_message(
|
|
349
|
+
f"❌ Unknown command: `{command}`\n\nType `/help` to see available commands.",
|
|
350
|
+
"assistant",
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
async def _handle_chat(self, message: str) -> None:
|
|
354
|
+
"""Handle regular chat messages with ReActAgent."""
|
|
355
|
+
if self._is_processing:
|
|
356
|
+
self.notify("Please wait for the current response...", severity="warning")
|
|
357
|
+
return
|
|
358
|
+
|
|
359
|
+
self._is_processing = True
|
|
360
|
+
conversation = self.query_one("#conversation-view", ConversationView)
|
|
361
|
+
spinner = self.query_one(LoadingSpinner)
|
|
362
|
+
|
|
363
|
+
# Add user message to UI
|
|
364
|
+
conversation.add_message(message, "user")
|
|
365
|
+
|
|
366
|
+
# Show loading spinner
|
|
367
|
+
spinner.start()
|
|
368
|
+
|
|
369
|
+
# Use run_worker to run agent in background without blocking event loop
|
|
370
|
+
self.run_worker(
|
|
371
|
+
self._invoke_agent_worker(message),
|
|
372
|
+
name="agent_worker",
|
|
373
|
+
exclusive=True,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
async def _invoke_agent_worker(self, message: str) -> None:
|
|
377
|
+
"""Worker to invoke agent in background."""
|
|
378
|
+
conversation = self.query_one("#conversation-view", ConversationView)
|
|
379
|
+
spinner = self.query_one(LoadingSpinner)
|
|
380
|
+
|
|
381
|
+
try:
|
|
382
|
+
# Run the agent invoke in a thread
|
|
383
|
+
loop = asyncio.get_event_loop()
|
|
384
|
+
response = await loop.run_in_executor(
|
|
385
|
+
None, lambda: self._agent.invoke(message)
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
# Hide spinner and show response (this runs on main thread via await)
|
|
389
|
+
spinner.stop()
|
|
390
|
+
if response and response.content:
|
|
391
|
+
conversation.add_message(response.content, "assistant")
|
|
392
|
+
|
|
393
|
+
except Exception as e:
|
|
394
|
+
spinner.stop()
|
|
395
|
+
error_msg = f"❌ **Error:** {str(e)}\n\nMake sure Ollama is running and the model `{self._current_model}` is available."
|
|
396
|
+
conversation.add_message(error_msg, "assistant")
|
|
397
|
+
self.notify(f"Error: {e}", severity="error")
|
|
398
|
+
|
|
399
|
+
finally:
|
|
400
|
+
self._is_processing = False
|
|
401
|
+
# Auto-refresh directory tree in case agent created/modified files
|
|
402
|
+
self._refresh_directory_tree()
|
|
403
|
+
|
|
404
|
+
def _cycle_theme(self) -> None:
|
|
405
|
+
"""Cycle through available themes."""
|
|
406
|
+
# Remove current theme class if it's not dark
|
|
407
|
+
current_theme = THEME_NAMES[self._current_theme_index]
|
|
408
|
+
if current_theme != "dark":
|
|
409
|
+
self.remove_class(f"theme-{current_theme}")
|
|
410
|
+
|
|
411
|
+
# Move to next theme
|
|
412
|
+
self._current_theme_index = (self._current_theme_index + 1) % len(THEME_NAMES)
|
|
413
|
+
new_theme = THEME_NAMES[self._current_theme_index]
|
|
414
|
+
|
|
415
|
+
# Apply new theme class (dark is default, no class needed)
|
|
416
|
+
if new_theme != "dark":
|
|
417
|
+
self.add_class(f"theme-{new_theme}")
|
|
418
|
+
|
|
419
|
+
def action_clear(self) -> None:
|
|
420
|
+
"""Clear the conversation (Ctrl+L)."""
|
|
421
|
+
conversation = self.query_one("#conversation-view", ConversationView)
|
|
422
|
+
conversation.clear_messages()
|
|
423
|
+
self._agent.memory.clear()
|
|
424
|
+
self.notify("Conversation cleared!", severity="information")
|
|
425
|
+
|
|
426
|
+
def action_cycle_theme(self) -> None:
|
|
427
|
+
"""Cycle theme (Ctrl+T)."""
|
|
428
|
+
self._cycle_theme()
|
|
429
|
+
theme_name = THEME_NAMES[self._current_theme_index]
|
|
430
|
+
self.notify(f"Theme: {theme_name}", severity="information")
|
|
431
|
+
|
|
432
|
+
def action_save_session(self) -> None:
|
|
433
|
+
"""Save session (Ctrl+S)."""
|
|
434
|
+
conversation = self.query_one("#conversation-view", ConversationView)
|
|
435
|
+
self._handle_save_session(conversation)
|
|
436
|
+
|
|
437
|
+
def action_refresh_tree(self) -> None:
|
|
438
|
+
"""Refresh directory tree (Ctrl+R)."""
|
|
439
|
+
self._refresh_directory_tree()
|
|
440
|
+
self.notify("Directory tree refreshed!", severity="information")
|
|
441
|
+
|
|
442
|
+
def _refresh_directory_tree(self) -> None:
|
|
443
|
+
"""Refresh the directory tree to show new/modified files."""
|
|
444
|
+
try:
|
|
445
|
+
tree = self.query_one("#directory-tree", DirectoryTree)
|
|
446
|
+
tree.reload()
|
|
447
|
+
except Exception:
|
|
448
|
+
pass # Silently ignore if tree not found
|
|
449
|
+
|
|
450
|
+
def _handle_save_session(self, conversation: ConversationView) -> None:
|
|
451
|
+
"""Save the current session."""
|
|
452
|
+
try:
|
|
453
|
+
# Create a new session if none exists
|
|
454
|
+
if not self._current_session_id:
|
|
455
|
+
session = self._session_manager.create_session("kader_cli")
|
|
456
|
+
self._current_session_id = session.session_id
|
|
457
|
+
|
|
458
|
+
# Get messages from agent memory and save
|
|
459
|
+
messages = [msg.message for msg in self._agent.memory.get_messages()]
|
|
460
|
+
self._session_manager.save_conversation(self._current_session_id, messages)
|
|
461
|
+
|
|
462
|
+
conversation.add_message(
|
|
463
|
+
f"✅ Session saved!\n\n**Session ID:** `{self._current_session_id}`",
|
|
464
|
+
"assistant",
|
|
465
|
+
)
|
|
466
|
+
self.notify("Session saved!", severity="information")
|
|
467
|
+
except Exception as e:
|
|
468
|
+
conversation.add_message(f"❌ Error saving session: {e}", "assistant")
|
|
469
|
+
self.notify(f"Error: {e}", severity="error")
|
|
470
|
+
|
|
471
|
+
def _handle_load_session(
|
|
472
|
+
self, session_id: str, conversation: ConversationView
|
|
473
|
+
) -> None:
|
|
474
|
+
"""Load a saved session by ID."""
|
|
475
|
+
try:
|
|
476
|
+
# Check if session exists
|
|
477
|
+
session = self._session_manager.get_session(session_id)
|
|
478
|
+
if not session:
|
|
479
|
+
conversation.add_message(
|
|
480
|
+
f"❌ Session `{session_id}` not found.\n\nUse `/sessions` to see available sessions.",
|
|
481
|
+
"assistant",
|
|
482
|
+
)
|
|
483
|
+
return
|
|
484
|
+
|
|
485
|
+
# Load conversation history
|
|
486
|
+
messages = self._session_manager.load_conversation(session_id)
|
|
487
|
+
|
|
488
|
+
# Clear current state
|
|
489
|
+
conversation.clear_messages()
|
|
490
|
+
self._agent.memory.clear()
|
|
491
|
+
|
|
492
|
+
# Add loaded messages to memory and UI
|
|
493
|
+
for msg in messages:
|
|
494
|
+
self._agent.memory.add_message(msg)
|
|
495
|
+
role = msg.get("role", "user")
|
|
496
|
+
content = msg.get("content", "")
|
|
497
|
+
if role in ["user", "assistant"] and content:
|
|
498
|
+
conversation.add_message(content, role)
|
|
499
|
+
|
|
500
|
+
self._current_session_id = session_id
|
|
501
|
+
conversation.add_message(
|
|
502
|
+
f"✅ Session `{session_id}` loaded with {len(messages)} messages.",
|
|
503
|
+
"assistant",
|
|
504
|
+
)
|
|
505
|
+
self.notify("Session loaded!", severity="information")
|
|
506
|
+
except Exception as e:
|
|
507
|
+
conversation.add_message(f"❌ Error loading session: {e}", "assistant")
|
|
508
|
+
self.notify(f"Error: {e}", severity="error")
|
|
509
|
+
|
|
510
|
+
def _handle_list_sessions(self, conversation: ConversationView) -> None:
|
|
511
|
+
"""List all saved sessions."""
|
|
512
|
+
try:
|
|
513
|
+
sessions = self._session_manager.list_sessions()
|
|
514
|
+
|
|
515
|
+
if not sessions:
|
|
516
|
+
conversation.add_message(
|
|
517
|
+
"📭 No saved sessions found.\n\nUse `/save` to save the current session.",
|
|
518
|
+
"assistant",
|
|
519
|
+
)
|
|
520
|
+
return
|
|
521
|
+
|
|
522
|
+
lines = [
|
|
523
|
+
"## Saved Sessions 📂\n",
|
|
524
|
+
"| Session ID | Created | Updated |",
|
|
525
|
+
"|------------|---------|---------|",
|
|
526
|
+
]
|
|
527
|
+
for session in sessions:
|
|
528
|
+
# Shorten UUID for display
|
|
529
|
+
created = session.created_at[:10] # Just date
|
|
530
|
+
updated = session.updated_at[:10]
|
|
531
|
+
lines.append(f"| `{session.session_id}` | {created} | {updated} |")
|
|
532
|
+
|
|
533
|
+
lines.append("\n*Use `/load <session_id>` to load a session.*")
|
|
534
|
+
conversation.add_message("\n".join(lines), "assistant")
|
|
535
|
+
except Exception as e:
|
|
536
|
+
conversation.add_message(f"❌ Error listing sessions: {e}", "assistant")
|
|
537
|
+
self.notify(f"Error: {e}", severity="error")
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def main() -> None:
|
|
541
|
+
"""Run the Kader CLI application."""
|
|
542
|
+
app = KaderApp()
|
|
543
|
+
app.run()
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
if __name__ == "__main__":
|
|
547
|
+
main()
|