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