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/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()