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.
@@ -0,0 +1,309 @@
1
+ """Inline selection widget for tool confirmation and model selection."""
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.binding import Binding
5
+ from textual.containers import Vertical
6
+ from textual.message import Message as TextualMessage
7
+ from textual.reactive import reactive
8
+ from textual.widget import Widget
9
+ from textual.widgets import Static
10
+
11
+
12
+ class InlineSelector(Widget, can_focus=True):
13
+ """
14
+ Inline selector widget for Yes/No confirmation.
15
+
16
+ Uses arrow keys to navigate, Enter to confirm.
17
+ """
18
+
19
+ BINDINGS = [
20
+ Binding("up", "move_up", "Up", show=False),
21
+ Binding("down", "move_down", "Down", show=False),
22
+ Binding("left", "move_up", "Left", show=False),
23
+ Binding("right", "move_down", "Right", show=False),
24
+ Binding("enter", "confirm", "Confirm", show=False),
25
+ Binding("y", "confirm_yes", "Yes", show=False),
26
+ Binding("n", "confirm_no", "No", show=False),
27
+ ]
28
+
29
+ DEFAULT_CSS = """
30
+ InlineSelector {
31
+ width: 100%;
32
+ height: auto;
33
+ padding: 1;
34
+ border: solid $primary;
35
+ background: $surface;
36
+ }
37
+
38
+ InlineSelector:focus {
39
+ border: double $primary;
40
+ }
41
+
42
+ InlineSelector .selector-container {
43
+ width: 100%;
44
+ height: auto;
45
+ align: center middle;
46
+ }
47
+
48
+ InlineSelector .option {
49
+ padding: 0 3;
50
+ margin: 0 2;
51
+ min-width: 12;
52
+ text-align: center;
53
+ }
54
+
55
+ InlineSelector .option.selected {
56
+ background: $primary;
57
+ color: $text;
58
+ text-style: bold reverse;
59
+ }
60
+
61
+ InlineSelector .option.not-selected {
62
+ background: $surface-darken-1;
63
+ color: $text-muted;
64
+ }
65
+
66
+ InlineSelector .prompt-text {
67
+ margin-bottom: 1;
68
+ text-align: center;
69
+ width: 100%;
70
+ color: $text-muted;
71
+ }
72
+
73
+ InlineSelector .message-text {
74
+ margin-bottom: 1;
75
+ text-align: center;
76
+ width: 100%;
77
+ color: $warning;
78
+ text-style: bold;
79
+ }
80
+ """
81
+
82
+ selected_index: reactive[int] = reactive(0)
83
+
84
+ def __init__(self, message: str, options: list[str] = None, **kwargs) -> None:
85
+ super().__init__(**kwargs)
86
+ self.message = message
87
+ self.options = options or ["✅ Yes", "❌ No"]
88
+
89
+ def compose(self) -> ComposeResult:
90
+ from textual.containers import Horizontal
91
+
92
+ yield Static(f"🔧 {self.message}", classes="message-text")
93
+ yield Static(
94
+ "↑↓ to select • Enter to confirm • Y/N for quick select",
95
+ classes="prompt-text",
96
+ )
97
+ with Horizontal(classes="selector-container"):
98
+ for i, option in enumerate(self.options):
99
+ cls = (
100
+ "option selected"
101
+ if i == self.selected_index
102
+ else "option not-selected"
103
+ )
104
+ yield Static(option, classes=cls, id=f"option-{i}")
105
+
106
+ def on_mount(self) -> None:
107
+ """Focus self when mounted."""
108
+ self.focus()
109
+
110
+ def watch_selected_index(self, old_index: int, new_index: int) -> None:
111
+ """Update visual selection when index changes."""
112
+ try:
113
+ old_option = self.query_one(f"#option-{old_index}", Static)
114
+ old_option.remove_class("selected")
115
+ old_option.add_class("not-selected")
116
+
117
+ new_option = self.query_one(f"#option-{new_index}", Static)
118
+ new_option.remove_class("not-selected")
119
+ new_option.add_class("selected")
120
+ except Exception:
121
+ pass
122
+
123
+ def action_move_up(self) -> None:
124
+ """Move selection up/left."""
125
+ self.selected_index = (self.selected_index - 1) % len(self.options)
126
+
127
+ def action_move_down(self) -> None:
128
+ """Move selection down/right."""
129
+ self.selected_index = (self.selected_index + 1) % len(self.options)
130
+
131
+ def action_confirm(self) -> None:
132
+ """Confirm current selection."""
133
+ self.post_message(self.Confirmed(self.selected_index == 0))
134
+
135
+ def action_confirm_yes(self) -> None:
136
+ """Quick confirm Yes."""
137
+ self.selected_index = 0
138
+ self.post_message(self.Confirmed(True))
139
+
140
+ def action_confirm_no(self) -> None:
141
+ """Quick confirm No."""
142
+ self.selected_index = 1
143
+ self.post_message(self.Confirmed(False))
144
+
145
+ class Confirmed(TextualMessage):
146
+ """Message sent when user confirms selection."""
147
+
148
+ def __init__(self, confirmed: bool) -> None:
149
+ super().__init__()
150
+ self.confirmed = confirmed
151
+
152
+
153
+ class ModelSelector(Widget, can_focus=True):
154
+ """
155
+ Model selector widget for choosing LLM models.
156
+
157
+ Uses arrow keys to navigate, Enter to confirm, Escape to cancel.
158
+ """
159
+
160
+ BINDINGS = [
161
+ Binding("up", "move_up", "Up", show=False),
162
+ Binding("down", "move_down", "Down", show=False),
163
+ Binding("enter", "confirm", "Confirm", show=False),
164
+ Binding("escape", "cancel", "Cancel", show=False),
165
+ ]
166
+
167
+ DEFAULT_CSS = """
168
+ ModelSelector {
169
+ width: 100%;
170
+ height: auto;
171
+ padding: 1;
172
+ border: solid $primary;
173
+ background: $surface;
174
+ }
175
+
176
+ ModelSelector:focus {
177
+ border: double $primary;
178
+ }
179
+
180
+ ModelSelector .title-text {
181
+ margin-bottom: 1;
182
+ text-align: center;
183
+ width: 100%;
184
+ color: $warning;
185
+ text-style: bold;
186
+ }
187
+
188
+ ModelSelector .prompt-text {
189
+ margin-bottom: 1;
190
+ text-align: center;
191
+ width: 100%;
192
+ color: $text-muted;
193
+ }
194
+
195
+ ModelSelector .model-list {
196
+ width: 100%;
197
+ height: auto;
198
+ max-height: 15;
199
+ }
200
+
201
+ ModelSelector .model-option {
202
+ padding: 0 2;
203
+ width: 100%;
204
+ }
205
+
206
+ ModelSelector .model-option.selected {
207
+ background: $primary;
208
+ color: $text;
209
+ text-style: bold;
210
+ }
211
+
212
+ ModelSelector .model-option.not-selected {
213
+ background: $surface;
214
+ color: $text-muted;
215
+ }
216
+
217
+ ModelSelector .model-option.current {
218
+ color: $success;
219
+ }
220
+ """
221
+
222
+ selected_index: reactive[int] = reactive(0)
223
+
224
+ def __init__(self, models: list[str], current_model: str = "", **kwargs) -> None:
225
+ super().__init__(**kwargs)
226
+ self.models = models
227
+ self.current_model = current_model
228
+ # Start with current model selected if it exists
229
+ if current_model in models:
230
+ self.selected_index = models.index(current_model)
231
+
232
+ def compose(self) -> ComposeResult:
233
+ yield Static("🤖 Select Model", classes="title-text")
234
+ yield Static(
235
+ "↑↓ to navigate • Enter to select • Esc to cancel", classes="prompt-text"
236
+ )
237
+ with Vertical(classes="model-list"):
238
+ for i, model in enumerate(self.models):
239
+ is_current = model == self.current_model
240
+ is_selected = i == self.selected_index
241
+ classes = "model-option"
242
+ if is_selected:
243
+ classes += " selected"
244
+ else:
245
+ classes += " not-selected"
246
+ if is_current:
247
+ classes += " current"
248
+ label = f" ▶ {model}" if is_selected else f" {model}"
249
+ if is_current:
250
+ label += " (current)"
251
+ yield Static(label, classes=classes, id=f"model-{i}")
252
+
253
+ def on_mount(self) -> None:
254
+ """Focus self when mounted."""
255
+ self.focus()
256
+
257
+ def watch_selected_index(self, old_index: int, new_index: int) -> None:
258
+ """Update visual selection when index changes."""
259
+ try:
260
+ # Update old option
261
+ old_option = self.query_one(f"#model-{old_index}", Static)
262
+ old_option.remove_class("selected")
263
+ old_option.add_class("not-selected")
264
+ old_model = self.models[old_index]
265
+ old_label = f" {old_model}"
266
+ if old_model == self.current_model:
267
+ old_label += " (current)"
268
+ old_option.update(old_label)
269
+
270
+ # Update new option
271
+ new_option = self.query_one(f"#model-{new_index}", Static)
272
+ new_option.remove_class("not-selected")
273
+ new_option.add_class("selected")
274
+ new_model = self.models[new_index]
275
+ new_label = f" ▶ {new_model}"
276
+ if new_model == self.current_model:
277
+ new_label += " (current)"
278
+ new_option.update(new_label)
279
+ except Exception:
280
+ pass
281
+
282
+ def action_move_up(self) -> None:
283
+ """Move selection up."""
284
+ self.selected_index = (self.selected_index - 1) % len(self.models)
285
+
286
+ def action_move_down(self) -> None:
287
+ """Move selection down."""
288
+ self.selected_index = (self.selected_index + 1) % len(self.models)
289
+
290
+ def action_confirm(self) -> None:
291
+ """Confirm current selection."""
292
+ selected_model = self.models[self.selected_index]
293
+ self.post_message(self.ModelSelected(selected_model))
294
+
295
+ def action_cancel(self) -> None:
296
+ """Cancel selection."""
297
+ self.post_message(self.ModelCancelled())
298
+
299
+ class ModelSelected(TextualMessage):
300
+ """Message sent when user selects a model."""
301
+
302
+ def __init__(self, model: str) -> None:
303
+ super().__init__()
304
+ self.model = model
305
+
306
+ class ModelCancelled(TextualMessage):
307
+ """Message sent when user cancels selection."""
308
+
309
+ pass
@@ -0,0 +1,55 @@
1
+ """Conversation display widget for Kader CLI."""
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.containers import VerticalScroll
5
+ from textual.widgets import Markdown, Static
6
+
7
+
8
+ class Message(Static):
9
+ """A single message in the conversation."""
10
+
11
+ def __init__(self, content: str, role: str = "user") -> None:
12
+ super().__init__()
13
+ self.content = content
14
+ self.role = role
15
+ self.add_class(f"message-{role}")
16
+
17
+ def compose(self) -> ComposeResult:
18
+ prefix = "👤 **You:**" if self.role == "user" else "🤖 **Kader:**"
19
+ yield Markdown(f"{prefix}\n\n{self.content}")
20
+
21
+
22
+ class ConversationView(VerticalScroll):
23
+ """Scrollable conversation history with markdown rendering."""
24
+
25
+ DEFAULT_CSS = """
26
+ ConversationView {
27
+ padding: 1 2;
28
+ }
29
+
30
+ ConversationView Message {
31
+ margin-bottom: 1;
32
+ padding: 1;
33
+ }
34
+
35
+ ConversationView .message-user {
36
+ background: $surface;
37
+ border-left: thick $primary;
38
+ }
39
+
40
+ ConversationView .message-assistant {
41
+ background: $surface-darken-1;
42
+ border-left: thick $success;
43
+ }
44
+ """
45
+
46
+ def add_message(self, content: str, role: str = "user") -> None:
47
+ """Add a message to the conversation."""
48
+ message = Message(content, role)
49
+ self.mount(message)
50
+ self.scroll_end(animate=True)
51
+
52
+ def clear_messages(self) -> None:
53
+ """Clear all messages from the conversation."""
54
+ for child in self.query(Message):
55
+ child.remove()
cli/widgets/loading.py ADDED
@@ -0,0 +1,59 @@
1
+ """Loading spinner widget for Kader CLI."""
2
+
3
+ from textual.reactive import reactive
4
+ from textual.widgets import Static
5
+
6
+
7
+ class LoadingSpinner(Static):
8
+ """Animated loading spinner shown during LLM response generation."""
9
+
10
+ DEFAULT_CSS = """
11
+ LoadingSpinner {
12
+ width: 100%;
13
+ height: auto;
14
+ padding: 1 2;
15
+ color: $text-muted;
16
+ text-style: italic;
17
+ }
18
+
19
+ LoadingSpinner.hidden {
20
+ display: none;
21
+ }
22
+ """
23
+
24
+ SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
25
+
26
+ frame_index: reactive[int] = reactive(0)
27
+ is_spinning: reactive[bool] = reactive(False)
28
+
29
+ def __init__(self) -> None:
30
+ super().__init__()
31
+ self.add_class("hidden")
32
+ self._timer = None
33
+
34
+ def on_mount(self) -> None:
35
+ """Start the animation timer when mounted."""
36
+ self._timer = self.set_interval(0.1, self._advance_frame)
37
+
38
+ def _advance_frame(self) -> None:
39
+ """Advance to the next spinner frame."""
40
+ if self.is_spinning:
41
+ self.frame_index = (self.frame_index + 1) % len(self.SPINNER_FRAMES)
42
+
43
+ def watch_frame_index(self, frame_index: int) -> None:
44
+ """Update display when frame changes."""
45
+ if self.is_spinning:
46
+ spinner = self.SPINNER_FRAMES[frame_index]
47
+ self.update(f"{spinner} Kader is thinking...")
48
+
49
+ def start(self) -> None:
50
+ """Start the loading animation."""
51
+ self.is_spinning = True
52
+ self.remove_class("hidden")
53
+ self.frame_index = 0
54
+
55
+ def stop(self) -> None:
56
+ """Stop the loading animation."""
57
+ self.is_spinning = False
58
+ self.add_class("hidden")
59
+ self.update("")
kader/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """
2
+ Main Kader module initialization.
3
+
4
+ This module sets up the required configuration when imported, including
5
+ creating the .kader directory in the user's home directory.
6
+ """
7
+
8
+ from .config import ENV_FILE_PATH, KADER_DIR, initialize_kader_config
9
+ from .providers import * # noqa: F401, F403
10
+ from .tools import * # noqa: F401, F403
11
+
12
+ # Initialize the configuration when the module is imported
13
+ initialize_kader_config()
14
+
15
+ __version__ = "0.1.0"
16
+ __author__ = "Kader Project"
17
+ __all__ = [
18
+ "KADER_DIR",
19
+ "ENV_FILE_PATH",
20
+ "initialize_kader_config",
21
+ # Export everything from providers and tools
22
+ ]
@@ -0,0 +1,8 @@
1
+ """
2
+ Kader Agent module.
3
+ """
4
+
5
+ from .agents import PlanningAgent, ReActAgent
6
+ from .base import BaseAgent
7
+
8
+ __all__ = ["BaseAgent", "ReActAgent", "PlanningAgent"]
kader/agent/agents.py ADDED
@@ -0,0 +1,126 @@
1
+ """
2
+ Specific Agent Implementations.
3
+ """
4
+
5
+ from typing import Optional, Union
6
+
7
+ from kader.agent.base import BaseAgent
8
+ from kader.memory import ConversationManager
9
+ from kader.prompts import PlanningAgentPrompt, PromptBase, ReActAgentPrompt
10
+ from kader.providers.base import BaseLLMProvider
11
+ from kader.tools import BaseTool, TodoTool, ToolRegistry
12
+
13
+
14
+ class ReActAgent(BaseAgent):
15
+ """
16
+ ReAct (Reasoning and Acting) Agent.
17
+
18
+ Uses a ReAct prompt strategy to reason about tasks and use tools.
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ name: str,
24
+ tools: Union[list[BaseTool], ToolRegistry],
25
+ system_prompt: Optional[Union[str, PromptBase]] = None,
26
+ provider: Optional[BaseLLMProvider] = None,
27
+ memory: Optional[ConversationManager] = None,
28
+ retry_attempts: int = 3,
29
+ model_name: str = "qwen3-coder:480b-cloud",
30
+ session_id: Optional[str] = None,
31
+ use_persistence: bool = False,
32
+ interrupt_before_tool: bool = True,
33
+ tool_confirmation_callback: Optional[callable] = None,
34
+ ) -> None:
35
+ # Resolve tools for prompt context if necessary
36
+ # The base agent handles tool registration, but for the prompt template
37
+ # we might need to pass tool descriptions initially.
38
+
39
+ # Temporary logic to get tool names/descriptions for the prompt
40
+ # In a real scenario, this might need dynamic updates or be handled by the Prompt class itself
41
+ # accessing the agent's registry. Here we do a best-effort pre-fill.
42
+
43
+ _tools_list = []
44
+ if isinstance(tools, list):
45
+ _tools_list = tools
46
+ elif isinstance(tools, ToolRegistry):
47
+ _tools_list = tools.tools
48
+
49
+ tool_names = ", ".join([t.name for t in _tools_list])
50
+
51
+ if system_prompt is None:
52
+ system_prompt = ReActAgentPrompt(
53
+ tools=_tools_list,
54
+ tool_names=tool_names,
55
+ input="", # This acts as a placeholder or initial context
56
+ )
57
+
58
+ super().__init__(
59
+ name=name,
60
+ system_prompt=system_prompt,
61
+ tools=tools,
62
+ provider=provider,
63
+ memory=memory,
64
+ retry_attempts=retry_attempts,
65
+ model_name=model_name,
66
+ session_id=session_id,
67
+ use_persistence=use_persistence,
68
+ interrupt_before_tool=interrupt_before_tool,
69
+ tool_confirmation_callback=tool_confirmation_callback,
70
+ )
71
+
72
+
73
+ class PlanningAgent(BaseAgent):
74
+ """
75
+ Planning Agent.
76
+
77
+ Breaks tasks into plans and executes them.
78
+ """
79
+
80
+ def __init__(
81
+ self,
82
+ name: str,
83
+ tools: Union[list[BaseTool], ToolRegistry],
84
+ system_prompt: Optional[Union[str, PromptBase]] = None,
85
+ provider: Optional[BaseLLMProvider] = None,
86
+ memory: Optional[ConversationManager] = None,
87
+ retry_attempts: int = 3,
88
+ model_name: str = "qwen3-coder:480b-cloud",
89
+ session_id: Optional[str] = None,
90
+ use_persistence: bool = False,
91
+ interrupt_before_tool: bool = True,
92
+ tool_confirmation_callback: Optional[callable] = None,
93
+ ) -> None:
94
+ # Ensure TodoTool is available
95
+ _todo_tool = TodoTool()
96
+ if isinstance(tools, ToolRegistry):
97
+ if _todo_tool.name not in tools:
98
+ tools.register(_todo_tool)
99
+ elif isinstance(tools, list):
100
+ if not any(t.name == _todo_tool.name for t in tools):
101
+ tools.append(_todo_tool)
102
+
103
+ _tools_list = []
104
+ if isinstance(tools, list):
105
+ _tools_list = tools
106
+ elif isinstance(tools, ToolRegistry):
107
+ _tools_list = tools.tools
108
+
109
+ if system_prompt is None:
110
+ system_prompt = PlanningAgentPrompt(
111
+ tools=_tools_list, input="", agent_scratchpad=""
112
+ )
113
+
114
+ super().__init__(
115
+ name=name,
116
+ system_prompt=system_prompt,
117
+ tools=tools,
118
+ provider=provider,
119
+ memory=memory,
120
+ retry_attempts=retry_attempts,
121
+ model_name=model_name,
122
+ session_id=session_id,
123
+ use_persistence=use_persistence,
124
+ interrupt_before_tool=interrupt_before_tool,
125
+ tool_confirmation_callback=tool_confirmation_callback,
126
+ )