minion-code 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.
Files changed (59) hide show
  1. examples/advance_tui.py +508 -0
  2. examples/agent_with_todos.py +165 -0
  3. examples/file_freshness_example.py +97 -0
  4. examples/file_watching_example.py +110 -0
  5. examples/interruptible_tui.py +5 -0
  6. examples/message_response_children_demo.py +226 -0
  7. examples/rich_example.py +4 -0
  8. examples/simple_file_watching.py +57 -0
  9. examples/simple_tui.py +267 -0
  10. examples/simple_usage.py +69 -0
  11. minion_code/__init__.py +16 -0
  12. minion_code/agents/__init__.py +11 -0
  13. minion_code/agents/code_agent.py +320 -0
  14. minion_code/cli.py +502 -0
  15. minion_code/commands/__init__.py +90 -0
  16. minion_code/commands/clear_command.py +70 -0
  17. minion_code/commands/help_command.py +90 -0
  18. minion_code/commands/history_command.py +104 -0
  19. minion_code/commands/quit_command.py +32 -0
  20. minion_code/commands/status_command.py +115 -0
  21. minion_code/commands/tools_command.py +86 -0
  22. minion_code/commands/version_command.py +104 -0
  23. minion_code/components/Message.py +304 -0
  24. minion_code/components/MessageResponse.py +188 -0
  25. minion_code/components/PromptInput.py +534 -0
  26. minion_code/components/__init__.py +29 -0
  27. minion_code/screens/REPL.py +925 -0
  28. minion_code/screens/__init__.py +4 -0
  29. minion_code/services/__init__.py +50 -0
  30. minion_code/services/event_system.py +108 -0
  31. minion_code/services/file_freshness_service.py +582 -0
  32. minion_code/tools/__init__.py +69 -0
  33. minion_code/tools/bash_tool.py +58 -0
  34. minion_code/tools/file_edit_tool.py +238 -0
  35. minion_code/tools/file_read_tool.py +73 -0
  36. minion_code/tools/file_write_tool.py +36 -0
  37. minion_code/tools/glob_tool.py +58 -0
  38. minion_code/tools/grep_tool.py +105 -0
  39. minion_code/tools/ls_tool.py +65 -0
  40. minion_code/tools/multi_edit_tool.py +271 -0
  41. minion_code/tools/python_interpreter_tool.py +105 -0
  42. minion_code/tools/todo_read_tool.py +100 -0
  43. minion_code/tools/todo_write_tool.py +234 -0
  44. minion_code/tools/user_input_tool.py +53 -0
  45. minion_code/types.py +88 -0
  46. minion_code/utils/__init__.py +44 -0
  47. minion_code/utils/mcp_loader.py +211 -0
  48. minion_code/utils/todo_file_utils.py +110 -0
  49. minion_code/utils/todo_storage.py +149 -0
  50. minion_code-0.1.0.dist-info/METADATA +350 -0
  51. minion_code-0.1.0.dist-info/RECORD +59 -0
  52. minion_code-0.1.0.dist-info/WHEEL +5 -0
  53. minion_code-0.1.0.dist-info/entry_points.txt +4 -0
  54. minion_code-0.1.0.dist-info/licenses/LICENSE +661 -0
  55. minion_code-0.1.0.dist-info/top_level.txt +3 -0
  56. tests/__init__.py +1 -0
  57. tests/test_basic.py +20 -0
  58. tests/test_readonly_tools.py +102 -0
  59. tests/test_tools.py +83 -0
@@ -0,0 +1,925 @@
1
+ """
2
+ REPL Screen Implementation using Textual UI Components
3
+ Python equivalent of /Users/femtozheng/web-project/Kode/src/screens/REPL.tsx
4
+ Simulates React-like component structure as documented in AGENTS.md
5
+ """
6
+
7
+ from textual.app import App, ComposeResult
8
+ from textual.containers import Container, Horizontal, Vertical, ScrollableContainer
9
+ from textual.widgets import Input, RichLog, Button, Static, Header, Footer, Label
10
+ from textual.reactive import reactive, var
11
+ from textual import on, work
12
+ from textual.screen import Screen
13
+ from rich.text import Text
14
+ from rich.syntax import Syntax
15
+ from rich.console import Console
16
+ import asyncio
17
+ from typing import List, Dict, Any, Optional, Callable, Union, Set
18
+ from dataclasses import dataclass, field
19
+ from enum import Enum
20
+ import uuid
21
+ import time
22
+ from pathlib import Path
23
+
24
+ # Simple logging setup for TUI - disable to prevent screen interference
25
+ import logging
26
+ logger = logging.getLogger(__name__)
27
+ logger.disabled = True
28
+
29
+
30
+ # Import shared types
31
+ from ..types import (
32
+ MessageType, InputMode, MessageContent, Message,
33
+ ToolUseConfirm, BinaryFeedbackContext, ToolJSXContext,
34
+ REPLConfig, ModelInfo
35
+ )
36
+
37
+
38
+ class Logo(Static):
39
+ """Logo component equivalent to React Logo component"""
40
+
41
+ def __init__(self, mcp_clients=None, is_default_model=True, update_banner_version=None, **kwargs):
42
+ super().__init__(**kwargs)
43
+ self.mcp_clients = mcp_clients or []
44
+ self.is_default_model = is_default_model
45
+ self.update_banner_version = update_banner_version
46
+
47
+ def render(self) -> str:
48
+ logo_text = "🤖 Minion Code Assistant"
49
+ if self.update_banner_version:
50
+ logo_text += f" (Update available: {self.update_banner_version})"
51
+ return logo_text
52
+
53
+ class ModeIndicator(Static):
54
+ """Mode indicator component"""
55
+
56
+ def __init__(self, mode: InputMode = InputMode.PROMPT, **kwargs):
57
+ super().__init__(**kwargs)
58
+ self.mode = mode
59
+
60
+ def render(self) -> str:
61
+ return f"Mode: {self.mode.value.upper()}"
62
+
63
+ class Spinner(Static):
64
+ """Loading spinner component"""
65
+
66
+ def __init__(self, **kwargs):
67
+ super().__init__("⠋ Loading...", **kwargs)
68
+ self.auto_refresh = 0.1
69
+ self.spinner_chars = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
70
+ self.spinner_index = 0
71
+
72
+ def on_mount(self):
73
+ self.set_interval(0.1, self.update_spinner)
74
+
75
+ def update_spinner(self):
76
+ self.spinner_index = (self.spinner_index + 1) % len(self.spinner_chars)
77
+ self.update(f"{self.spinner_chars[self.spinner_index]} Loading...")
78
+
79
+ class MessageWidget(Container):
80
+ """Individual message display widget"""
81
+
82
+ def __init__(self, message: Message, verbose: bool = False, debug: bool = False, **kwargs):
83
+ super().__init__(**kwargs)
84
+ self.message = message
85
+ self.verbose = verbose
86
+ self.debug = debug
87
+
88
+ def compose(self) -> ComposeResult:
89
+ if self.message.type == MessageType.USER:
90
+ yield Static(f"👤 User: {self._get_content_text()}", classes="user-message")
91
+ elif self.message.type == MessageType.ASSISTANT:
92
+ yield Static(f"🤖 Assistant: {self._get_content_text()}", classes="assistant-message")
93
+ elif self.message.type == MessageType.PROGRESS:
94
+ yield Static(f"⚙️ Progress: {self._get_content_text()}", classes="progress-message")
95
+
96
+ def _get_content_text(self) -> str:
97
+ if isinstance(self.message.message.content, str):
98
+ return self.message.message.content
99
+ elif isinstance(self.message.message.content, list):
100
+ # Extract text from structured content
101
+ text_parts = []
102
+ for block in self.message.message.content:
103
+ if isinstance(block, dict) and block.get("type") == "text":
104
+ text_parts.append(block.get("text", ""))
105
+ return "\n".join(text_parts)
106
+ return str(self.message.message.content)
107
+
108
+
109
+ # PromptInput moved to components/PromptInput.py
110
+ from ..components.PromptInput import PromptInput
111
+
112
+ class CostThresholdDialog(Container):
113
+ """Cost threshold warning dialog"""
114
+
115
+ def compose(self) -> ComposeResult:
116
+ yield Static("⚠️ Cost Threshold Warning", classes="dialog-title")
117
+ yield Static("You have exceeded $5 in API costs. Please be mindful of usage.")
118
+ yield Button("Acknowledge", id="acknowledge_btn", variant="primary")
119
+
120
+ class PermissionRequest(Container):
121
+ """Permission request dialog for tool usage"""
122
+
123
+ def __init__(self, tool_use_confirm: ToolUseConfirm, **kwargs):
124
+ super().__init__(**kwargs)
125
+ self.tool_use_confirm = tool_use_confirm
126
+
127
+ def compose(self) -> ComposeResult:
128
+ yield Static(f"🔧 Tool Permission Request", classes="dialog-title")
129
+ yield Static(f"Tool: {self.tool_use_confirm.tool_name}")
130
+ yield Static(f"Parameters: {self.tool_use_confirm.parameters}")
131
+ with Horizontal():
132
+ yield Button("Allow", id="allow_btn", variant="success")
133
+ yield Button("Deny", id="deny_btn", variant="error")
134
+
135
+ @on(Button.Pressed, "#allow_btn")
136
+ def allow_tool(self):
137
+ self.tool_use_confirm.on_confirm()
138
+
139
+ @on(Button.Pressed, "#deny_btn")
140
+ def deny_tool(self):
141
+ self.tool_use_confirm.on_abort()
142
+
143
+
144
+ class MessageSelector(Container):
145
+ """Message selector for conversation forking"""
146
+
147
+ def __init__(self, messages: List[Message], **kwargs):
148
+ super().__init__(**kwargs)
149
+ self.messages = messages
150
+
151
+ def compose(self) -> ComposeResult:
152
+ yield Static("📝 Select Message to Fork From", classes="dialog-title")
153
+ with ScrollableContainer():
154
+ for i, message in enumerate(self.messages[-10:]): # Show last 10 messages
155
+ content = self._get_message_preview(message)
156
+ yield Button(f"{i}: {content[:50]}...", id=f"msg_{i}")
157
+ yield Button("Cancel", id="cancel_selector", variant="error")
158
+
159
+ def _get_message_preview(self, message: Message) -> str:
160
+ if isinstance(message.message.content, str):
161
+ return message.message.content
162
+ return str(message.message.content)[:50]
163
+
164
+
165
+ class REPL(Container):
166
+ """
167
+ Main REPL Component - Python equivalent of React REPL component
168
+ Manages the entire conversation interface with AI assistant
169
+ """
170
+
171
+ # Reactive properties equivalent to React useState
172
+ fork_number = reactive(0)
173
+ is_loading = reactive(False)
174
+ messages = var(list) # List[Message]
175
+ input_value = reactive("")
176
+ input_mode = reactive(InputMode.PROMPT)
177
+ submit_count = reactive(0)
178
+ is_message_selector_visible = reactive(False)
179
+ show_cost_dialog = reactive(False)
180
+ have_shown_cost_dialog = reactive(False)
181
+
182
+ def __init__(self,
183
+ commands=None,
184
+ safe_mode=False,
185
+ debug=False,
186
+ initial_fork_number=0,
187
+ initial_prompt=None,
188
+ message_log_name="default",
189
+ should_show_prompt_input=True,
190
+ tools=None,
191
+ verbose=False,
192
+ initial_messages=None,
193
+ mcp_clients=None,
194
+ is_default_model=True,
195
+ initial_update_version=None,
196
+ initial_update_commands=None,
197
+ **kwargs):
198
+ super().__init__(**kwargs)
199
+
200
+ # Props equivalent to TypeScript Props interface
201
+ self.commands = commands or []
202
+ self.safe_mode = safe_mode
203
+ self.debug = debug
204
+ self.initial_fork_number = initial_fork_number
205
+ self.initial_prompt = initial_prompt
206
+ self.message_log_name = message_log_name
207
+ self.should_show_prompt_input = should_show_prompt_input
208
+ self.tools = tools or []
209
+ self.verbose = verbose
210
+ self.mcp_clients = mcp_clients or []
211
+ self.is_default_model = is_default_model
212
+ self.initial_update_version = initial_update_version
213
+ self.initial_update_commands = initial_update_commands
214
+
215
+ # Initialize state
216
+ self.messages = initial_messages or []
217
+ self.fork_number = initial_fork_number
218
+
219
+ # Internal state
220
+ self.config = REPLConfig()
221
+ self.abort_controller: Optional[asyncio.Task] = None
222
+ self.tool_jsx: Optional[ToolJSXContext] = None
223
+ self.tool_use_confirm: Optional[ToolUseConfirm] = None
224
+ self.binary_feedback_context: Optional[BinaryFeedbackContext] = None
225
+ self.read_file_timestamps: Dict[str, float] = {}
226
+ self.fork_convo_with_messages_on_next_render: Optional[List[Message]] = None
227
+
228
+ logger.info(f"REPL initialized with {len(self.messages)} initial messages")
229
+
230
+ def compose(self) -> ComposeResult:
231
+ """Compose the REPL interface - equivalent to React render method"""
232
+ # Static header section (equivalent to Static items in React)
233
+ with Vertical():
234
+ yield Logo(
235
+ mcp_clients=self.mcp_clients,
236
+ is_default_model=self.is_default_model,
237
+ update_banner_version=self.initial_update_version
238
+ )
239
+ yield ModeIndicator(mode=self.input_mode)
240
+
241
+ # Messages container (equivalent to messagesJSX mapping)
242
+ with ScrollableContainer(id="messages_container"):
243
+ for message in self.messages:
244
+ yield MessageWidget(
245
+ message=message,
246
+ verbose=self.verbose,
247
+ debug=self.debug
248
+ )
249
+
250
+ # Dynamic content area (equivalent to conditional rendering in React)
251
+ with Container(id="dynamic_content"):
252
+ # Spinner (equivalent to {!toolJSX && !toolUseConfirm && !binaryFeedbackContext && isLoading && <Spinner />})
253
+ if self.is_loading and not self.tool_jsx and not self.tool_use_confirm and not self.binary_feedback_context:
254
+ yield Spinner()
255
+
256
+ # Tool JSX (equivalent to {toolJSX ? toolJSX.jsx : null})
257
+ if self.tool_jsx and self.tool_jsx.jsx:
258
+ yield self.tool_jsx.jsx
259
+
260
+ # Binary feedback (equivalent to BinaryFeedback component)
261
+ if self.binary_feedback_context and not self.is_message_selector_visible:
262
+ yield Static("🔄 Binary feedback component would render here")
263
+
264
+ # Permission request (equivalent to PermissionRequest component)
265
+ if self.tool_use_confirm and not self.is_message_selector_visible and not self.binary_feedback_context:
266
+ yield PermissionRequest(self.tool_use_confirm)
267
+
268
+ # Cost dialog (equivalent to CostThresholdDialog component)
269
+ if self.show_cost_dialog and not self.is_loading:
270
+ yield CostThresholdDialog()
271
+
272
+ # PromptInput component (now working)
273
+ if True: # Always show for now
274
+ prompt_input = PromptInput(
275
+ commands=self.commands,
276
+ fork_number=self.fork_number,
277
+ message_log_name=self.message_log_name,
278
+ is_disabled=False,
279
+ is_loading=self.is_loading,
280
+ debug=self.debug,
281
+ verbose=self.verbose,
282
+ messages=self.messages,
283
+ tools=self.tools,
284
+ input_value=self.input_value,
285
+ mode=self.input_mode,
286
+ submit_count=self.submit_count,
287
+ read_file_timestamps=self.read_file_timestamps,
288
+ abort_controller=self.abort_controller
289
+ )
290
+
291
+ # Set up callbacks
292
+ prompt_input.on_query = self.on_query_from_prompt
293
+ prompt_input.on_input_change = self.on_input_change_from_prompt
294
+ prompt_input.on_mode_change = self.on_mode_change_from_prompt
295
+ prompt_input.on_submit_count_change = self.on_submit_count_change_from_prompt
296
+ prompt_input.set_is_loading = self.set_loading_from_prompt
297
+ prompt_input.set_abort_controller = self.set_abort_controller_from_prompt
298
+ prompt_input.on_show_message_selector = self.show_message_selector
299
+ prompt_input.set_fork_convo_with_messages = self.set_fork_convo_messages
300
+ prompt_input.on_model_change = self.on_model_change_from_prompt
301
+ prompt_input.set_tool_jsx = self.set_tool_jsx_from_prompt
302
+
303
+ yield prompt_input
304
+
305
+ # Message selector (equivalent to {isMessageSelectorVisible && <MessageSelector />})
306
+ if self.is_message_selector_visible:
307
+ yield MessageSelector(messages=self.messages)
308
+
309
+ def on_mount(self):
310
+ """Component lifecycle - equivalent to React useEffect(() => { onInit() }, [])"""
311
+ logger.info("REPL mounted, starting initialization")
312
+ self.call_later(self.on_init)
313
+ # Set focus to the input after a short delay to ensure it's mounted
314
+ self.set_timer(0.1, self._set_focus_to_input)
315
+
316
+ def _set_focus_to_input(self):
317
+ """Set focus to the main input widget"""
318
+ try:
319
+ # Try to find the simplified input
320
+ input_widget = self.query_one("#simple_input", expect_type=Input)
321
+ input_widget.focus()
322
+ logger.info("Focus set to simple input from REPL")
323
+ except Exception as e:
324
+ logger.warning(f"Could not set focus to simple input: {e}")
325
+ # If that fails, try to focus any Input widget
326
+ try:
327
+ inputs = self.query("Input")
328
+ if inputs:
329
+ inputs[0].focus()
330
+ logger.info("Focus set to first available input")
331
+ except Exception as e2:
332
+ logger.warning(f"Could not set focus to any input: {e2}")
333
+
334
+ async def on_init(self):
335
+ """Initialize REPL - equivalent to React onInit function"""
336
+ logger.info("REPL initialization started")
337
+
338
+ if not self.initial_prompt:
339
+ logger.info("No initial prompt provided")
340
+ return
341
+
342
+ self.is_loading = True
343
+
344
+ try:
345
+ # Process initial prompt (equivalent to processUserInput)
346
+ new_messages = await self.process_user_input(
347
+ self.initial_prompt,
348
+ self.input_mode
349
+ )
350
+
351
+ if new_messages:
352
+ # Add to history (equivalent to addToHistory)
353
+ self.add_to_history(self.initial_prompt)
354
+
355
+ # Update messages (equivalent to setMessages)
356
+ self.messages = [*self.messages, *new_messages]
357
+
358
+ # Query API if needed (equivalent to query function)
359
+ await self.query_api(new_messages)
360
+
361
+ except Exception as e:
362
+ logger.error(f"Initialization failed: {e}")
363
+ finally:
364
+ self.is_loading = False
365
+ logger.info("REPL initialization completed")
366
+
367
+ async def process_user_input(self, input_text: str, mode: InputMode) -> List[Message]:
368
+ """Process user input - equivalent to processUserInput function"""
369
+ logger.info(f"Processing user input: {input_text[:50]}... (mode: {mode.value})")
370
+
371
+ # Create user message
372
+ user_message = Message(
373
+ type=MessageType.USER,
374
+ message=MessageContent(input_text),
375
+ options={"isKodingRequest": mode == InputMode.KODING}
376
+ )
377
+
378
+ # Handle different input modes
379
+ if mode == InputMode.BASH:
380
+ # Handle bash command
381
+ result = await self.execute_bash_command(input_text)
382
+ assistant_message = Message(
383
+ type=MessageType.ASSISTANT,
384
+ message=MessageContent(result)
385
+ )
386
+ return [user_message, assistant_message]
387
+ elif mode == InputMode.KODING:
388
+ # Handle koding request
389
+ return [user_message]
390
+ else:
391
+ # Handle regular prompt
392
+ return [user_message]
393
+
394
+ async def execute_bash_command(self, command: str) -> str:
395
+ """Execute bash command - simplified version"""
396
+ try:
397
+ import subprocess
398
+ result = subprocess.run(
399
+ command,
400
+ shell=True,
401
+ capture_output=True,
402
+ text=True,
403
+ timeout=30
404
+ )
405
+ if result.returncode == 0:
406
+ return result.stdout or "Command executed successfully"
407
+ else:
408
+ return f"Error: {result.stderr}"
409
+ except Exception as e:
410
+ return f"Error executing command: {str(e)}"
411
+
412
+ async def query_api(self, new_messages: List[Message]):
413
+ """Query the AI API - equivalent to query function"""
414
+ logger.info("Querying AI API")
415
+
416
+ # This would integrate with the actual AI API
417
+ # For now, create a mock response
418
+ if new_messages and new_messages[-1].type == MessageType.USER:
419
+ user_content = new_messages[-1].message.content
420
+
421
+ # Mock AI response
422
+ response_content = f"I received your message: {user_content}"
423
+
424
+ assistant_message = Message(
425
+ type=MessageType.ASSISTANT,
426
+ message=MessageContent(response_content)
427
+ )
428
+
429
+ # Update messages
430
+ self.messages = [*self.messages, assistant_message]
431
+
432
+ # Handle Koding mode special case
433
+ if (new_messages[-1].options and
434
+ new_messages[-1].options.get("isKodingRequest")):
435
+ await self.handle_koding_response(assistant_message)
436
+
437
+ async def handle_koding_response(self, assistant_message: Message):
438
+ """Handle Koding mode response - equivalent to handleHashCommand"""
439
+ logger.info("Handling Koding response")
440
+
441
+ content = assistant_message.message.content
442
+ if isinstance(content, str) and content.strip():
443
+ # Save to AGENTS.md (equivalent to handleHashCommand)
444
+ try:
445
+ agents_md_path = Path("AGENTS.md")
446
+ if agents_md_path.exists():
447
+ with open(agents_md_path, "a") as f:
448
+ f.write(f"\n\n## Koding Response - {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n")
449
+ f.write(content)
450
+ f.write("\n")
451
+ logger.info("Saved Koding response to AGENTS.md")
452
+ except Exception as e:
453
+ logger.error(f"Error saving to AGENTS.md: {e}")
454
+
455
+ def add_to_history(self, command: str):
456
+ """Add command to history - equivalent to addToHistory"""
457
+ # This would integrate with the history system
458
+ logger.info(f"Added to history: {command[:50]}...")
459
+
460
+ def on_cancel(self):
461
+ """Cancel current operation - equivalent to onCancel function"""
462
+ if not self.is_loading:
463
+ return
464
+
465
+ self.is_loading = False
466
+
467
+ if self.tool_use_confirm:
468
+ self.tool_use_confirm.on_abort()
469
+ elif self.abort_controller:
470
+ self.abort_controller.cancel()
471
+
472
+ logger.info("Operation cancelled")
473
+
474
+ # Callback methods for PromptInput component
475
+ async def on_query_from_prompt(self, messages: List[Message], abort_controller=None):
476
+ """Handle query from PromptInput - equivalent to onQuery prop"""
477
+ logger.info(f"Received query from PromptInput with {len(messages)} messages")
478
+
479
+ # Use passed AbortController or create new one
480
+ controller_to_use = abort_controller or asyncio.create_task(asyncio.sleep(0))
481
+ if not abort_controller:
482
+ self.abort_controller = controller_to_use
483
+
484
+ # Update messages
485
+ self.messages = [*self.messages, *messages]
486
+
487
+ # Query API
488
+ await self.query_api(messages)
489
+
490
+ def on_input_change_from_prompt(self, value: str):
491
+ """Handle input change from PromptInput"""
492
+ self.input_value = value
493
+
494
+ def on_mode_change_from_prompt(self, mode: InputMode):
495
+ """Handle mode change from PromptInput"""
496
+ self.input_mode = mode
497
+
498
+ def on_submit_count_change_from_prompt(self, updater):
499
+ """Handle submit count change from PromptInput"""
500
+ if callable(updater):
501
+ self.submit_count = updater(self.submit_count)
502
+ else:
503
+ self.submit_count = updater
504
+
505
+ def set_loading_from_prompt(self, is_loading: bool):
506
+ """Set loading state from PromptInput"""
507
+ self.is_loading = is_loading
508
+
509
+ def set_abort_controller_from_prompt(self, controller):
510
+ """Set abort controller from PromptInput"""
511
+ self.abort_controller = controller
512
+
513
+ def show_message_selector(self):
514
+ """Show message selector from PromptInput"""
515
+ self.is_message_selector_visible = True
516
+
517
+ def set_fork_convo_messages(self, messages: List[Message]):
518
+ """Set fork conversation messages from PromptInput"""
519
+ self.fork_convo_with_messages_on_next_render = messages
520
+
521
+ def on_model_change_from_prompt(self):
522
+ """Handle model change from PromptInput"""
523
+ self.fork_number += 1
524
+ logger.info("Model changed, incrementing fork number")
525
+
526
+ def set_tool_jsx_from_prompt(self, tool_jsx):
527
+ """Set tool JSX from PromptInput"""
528
+ self.tool_jsx = tool_jsx
529
+
530
+ @on(Button.Pressed, "#acknowledge_btn")
531
+ def acknowledge_cost_dialog(self):
532
+ """Acknowledge cost threshold dialog"""
533
+ self.show_cost_dialog = False
534
+ self.have_shown_cost_dialog = True
535
+ self.config.has_acknowledged_cost_threshold = True
536
+ logger.info("Cost threshold acknowledged")
537
+
538
+ def normalize_messages(self) -> List[Message]:
539
+ """Normalize messages - equivalent to normalizeMessages function"""
540
+ # Filter out empty messages and normalize structure
541
+ return [msg for msg in self.messages if self.is_not_empty_message(msg)]
542
+
543
+ def is_not_empty_message(self, message: Message) -> bool:
544
+ """Check if message is not empty - equivalent to isNotEmptyMessage"""
545
+ if isinstance(message.message.content, str):
546
+ return bool(message.message.content.strip())
547
+ return bool(message.message.content)
548
+
549
+ def get_unresolved_tool_use_ids(self) -> Set[str]:
550
+ """Get unresolved tool use IDs - equivalent to getUnresolvedToolUseIDs"""
551
+ # This would analyze messages for unresolved tool uses
552
+ return set()
553
+
554
+ def get_in_progress_tool_use_ids(self) -> Set[str]:
555
+ """Get in-progress tool use IDs - equivalent to getInProgressToolUseIDs"""
556
+ # This would analyze messages for in-progress tool uses
557
+ return set()
558
+
559
+ def get_errored_tool_use_ids(self) -> Set[str]:
560
+ """Get errored tool use IDs - equivalent to getErroredToolUseMessages"""
561
+ # This would analyze messages for errored tool uses
562
+ return set()
563
+
564
+ # Reactive property watchers (equivalent to React useEffect)
565
+ def watch_fork_number(self, fork_number: int):
566
+ """Watch fork number changes"""
567
+ logger.info(f"Fork number changed to: {fork_number}")
568
+
569
+ def watch_is_loading(self, is_loading: bool):
570
+ """Watch loading state changes"""
571
+ logger.info(f"Loading state changed to: {is_loading}")
572
+
573
+ def watch_messages(self, messages: List[Message]):
574
+ """Watch messages changes - equivalent to useEffect([messages], ...)"""
575
+ logger.info(f"Messages updated, count: {len(messages)}")
576
+
577
+ # Check cost threshold (equivalent to cost threshold useEffect)
578
+ total_cost = self.get_total_cost()
579
+ if (total_cost >= 5.0 and
580
+ not self.show_cost_dialog and
581
+ not self.have_shown_cost_dialog):
582
+ self.show_cost_dialog = True
583
+
584
+ def get_total_cost(self) -> float:
585
+ """Get total API cost - equivalent to getTotalCost"""
586
+ # This would calculate actual API costs
587
+ return len(self.messages) * 0.01 # Mock cost calculation
588
+
589
+ def _get_mode_prefix(self) -> str:
590
+ """Get the mode prefix character"""
591
+ if self.input_mode == InputMode.BASH:
592
+ return "!"
593
+ elif self.input_mode == InputMode.KODING:
594
+ return "#"
595
+ else:
596
+ return ">"
597
+
598
+ # Simplified event handlers for debugging
599
+ @on(Input.Changed, "#simple_input")
600
+ def on_simple_input_changed(self, event):
601
+ """Handle simple input changes"""
602
+ self.input_value = event.value
603
+ logger.info(f"Input changed: {event.value}")
604
+
605
+ @on(Input.Submitted, "#simple_input")
606
+ @on(Button.Pressed, "#simple_send")
607
+ async def on_simple_submit(self, event):
608
+ """Handle simple input submission"""
609
+ input_widget = self.query_one("#simple_input", expect_type=Input)
610
+ input_text = input_widget.value.strip()
611
+
612
+ if not input_text:
613
+ return
614
+
615
+ logger.info(f"Submitting: {input_text}")
616
+
617
+ # Add user message to display
618
+ user_message = Message(
619
+ type=MessageType.USER,
620
+ message=MessageContent(input_text),
621
+ options={"mode": self.input_mode.value}
622
+ )
623
+ self.messages = [*self.messages, user_message]
624
+
625
+ # Create simple response
626
+ response_text = f"Received: {input_text} (mode: {self.input_mode.value})"
627
+ assistant_message = Message(
628
+ type=MessageType.ASSISTANT,
629
+ message=MessageContent(response_text)
630
+ )
631
+ self.messages = [*self.messages, assistant_message]
632
+
633
+ # Clear input
634
+ input_widget.value = ""
635
+ self.input_value = ""
636
+
637
+ # Keep focus
638
+ input_widget.focus()
639
+
640
+ @on(Button.Pressed, "#simple_mode")
641
+ def on_simple_mode_change(self):
642
+ """Handle mode change"""
643
+ modes = list(InputMode)
644
+ current_index = modes.index(self.input_mode)
645
+ self.input_mode = modes[(current_index + 1) % len(modes)]
646
+
647
+ # Update mode indicator
648
+ try:
649
+ mode_indicator = self.query_one("#mode_indicator", expect_type=Static)
650
+ mode_indicator.update(f" {self._get_mode_prefix()} ")
651
+
652
+ # Update input placeholder
653
+ input_widget = self.query_one("#simple_input", expect_type=Input)
654
+ input_widget.placeholder = f"Enter {self.input_mode.value} command..."
655
+ except:
656
+ pass
657
+
658
+ logger.info(f"Mode changed to: {self.input_mode.value}")
659
+
660
+
661
+ class REPLApp(App):
662
+ """
663
+ Main REPL Application - equivalent to the main App wrapper in React
664
+ Provides the application context and styling
665
+ """
666
+
667
+ CSS = """
668
+ /* Equivalent to CSS styling in React component */
669
+ .user-message {
670
+ background: blue 20%;
671
+ color: white;
672
+ margin: 1;
673
+ padding: 1;
674
+ border: solid blue;
675
+ }
676
+
677
+ .assistant-message {
678
+ background: green 20%;
679
+ color: white;
680
+ margin: 1;
681
+ padding: 1;
682
+ border: solid green;
683
+ }
684
+
685
+ .progress-message {
686
+ background: yellow 20%;
687
+ color: black;
688
+ margin: 1;
689
+ padding: 1;
690
+ border: solid yellow;
691
+ }
692
+
693
+ .dialog-title {
694
+ text-style: bold;
695
+ content-align: center middle;
696
+ margin: 1;
697
+ background: cyan 30%;
698
+ color: black;
699
+ }
700
+
701
+ #messages_container {
702
+ height: 1fr;
703
+ border: solid cyan;
704
+ margin: 1;
705
+ scrollbar-background: gray 50%;
706
+ scrollbar-color: white;
707
+ }
708
+
709
+ #dynamic_content {
710
+ dock: bottom;
711
+ height: auto;
712
+ margin: 1;
713
+ background: gray 10%;
714
+ }
715
+
716
+ #main_input {
717
+ width: 1fr;
718
+ margin-right: 1;
719
+ border: solid white;
720
+ }
721
+
722
+ #simple_input_area {
723
+ dock: bottom;
724
+ height: 3;
725
+ margin: 1;
726
+ }
727
+
728
+ #mode_indicator {
729
+ width: 3;
730
+ content-align: center middle;
731
+ text-style: bold;
732
+ background: gray 20%;
733
+ }
734
+
735
+ #simple_input {
736
+ width: 1fr;
737
+ margin-right: 1;
738
+ }
739
+
740
+ /* PromptInput component styles */
741
+ .model-info {
742
+ dock: top;
743
+ height: 1;
744
+ content-align: right middle;
745
+ background: gray 10%;
746
+ color: white;
747
+ margin-bottom: 1;
748
+ }
749
+
750
+ #input_container {
751
+ border: solid white;
752
+ margin: 1;
753
+ padding: 1;
754
+ }
755
+
756
+ .mode-bash #input_container {
757
+ border: solid yellow;
758
+ }
759
+
760
+ .mode-koding #input_container {
761
+ border: solid cyan;
762
+ }
763
+
764
+ #mode_prefix {
765
+ width: 3;
766
+ content-align: center middle;
767
+ text-style: bold;
768
+ }
769
+
770
+ .mode-bash #mode_prefix {
771
+ color: yellow;
772
+ }
773
+
774
+ .mode-koding #mode_prefix {
775
+ color: cyan;
776
+ }
777
+
778
+ #status_area {
779
+ dock: bottom;
780
+ height: 2;
781
+ margin: 1;
782
+ }
783
+
784
+ .status-message {
785
+ color: white;
786
+ text-style: dim;
787
+ }
788
+
789
+ .model-switch-message {
790
+ color: green;
791
+ text-style: bold;
792
+ }
793
+
794
+ .help-text {
795
+ margin-right: 2;
796
+ }
797
+
798
+ .help-text.active {
799
+ color: white;
800
+ text-style: bold;
801
+ }
802
+
803
+ .help-text.inactive {
804
+ color: gray;
805
+ text-style: dim;
806
+ }
807
+
808
+ Button {
809
+ margin: 1;
810
+ }
811
+
812
+ Input {
813
+ border: solid white;
814
+ }
815
+ """
816
+
817
+ def __init__(self, **kwargs):
818
+ super().__init__(**kwargs)
819
+ # Initialize with default props (equivalent to React props)
820
+ self.repl_props = {
821
+ "commands": [],
822
+ "safe_mode": False,
823
+ "debug": False,
824
+ "initial_fork_number": 0,
825
+ "initial_prompt": None,
826
+ "message_log_name": "default",
827
+ "should_show_prompt_input": True,
828
+ "tools": [],
829
+ "verbose": False,
830
+ "initial_messages": [],
831
+ "mcp_clients": [],
832
+ "is_default_model": True,
833
+ "initial_update_version": None,
834
+ "initial_update_commands": None
835
+ }
836
+
837
+ def compose(self) -> ComposeResult:
838
+ """Compose the main application - equivalent to React App render"""
839
+ yield Header(show_clock=True)
840
+ yield REPL(**self.repl_props)
841
+ yield Footer()
842
+
843
+ def on_mount(self):
844
+ """Application mount lifecycle"""
845
+ self.title = "Minion Code Assistant"
846
+ logger.info("REPL Application started")
847
+
848
+
849
+ # Utility functions equivalent to TypeScript utility functions
850
+ def should_render_statically(
851
+ message: Message,
852
+ messages: List[Message],
853
+ unresolved_tool_use_ids: Set[str]
854
+ ) -> bool:
855
+ """
856
+ Determine if message should render statically
857
+ Equivalent to shouldRenderStatically function in TypeScript
858
+ """
859
+ if message.type in [MessageType.USER, MessageType.ASSISTANT]:
860
+ # For now, render all user and assistant messages statically
861
+ return True
862
+ elif message.type == MessageType.PROGRESS:
863
+ # Progress messages depend on tool use resolution
864
+ return len(unresolved_tool_use_ids) == 0
865
+ return True
866
+
867
+ def intersects(set_a: Set[str], set_b: Set[str]) -> bool:
868
+ """Check if two sets intersect - equivalent to intersects function"""
869
+ return len(set_a & set_b) > 0
870
+
871
+
872
+ # Factory function to create REPL with specific configuration
873
+ def create_repl(
874
+ commands=None,
875
+ safe_mode=False,
876
+ debug=False,
877
+ initial_prompt=None,
878
+ verbose=False,
879
+ **kwargs
880
+ ) -> REPLApp:
881
+ """
882
+ Create a configured REPL application
883
+ Equivalent to calling REPL component with props in React
884
+ """
885
+ app = REPLApp()
886
+ app.repl_props.update({
887
+ "commands": commands or [],
888
+ "safe_mode": safe_mode,
889
+ "debug": debug,
890
+ "initial_prompt": initial_prompt,
891
+ "verbose": verbose,
892
+ **kwargs
893
+ })
894
+ return app
895
+
896
+
897
+ def run(initial_prompt=None, debug=False, verbose=False):
898
+ """Run the REPL application with optional configuration"""
899
+ app = create_repl(
900
+ initial_prompt=initial_prompt,
901
+ debug=debug,
902
+ verbose=verbose
903
+ )
904
+ app.run()
905
+
906
+
907
+ if __name__ == "__main__":
908
+ import sys
909
+
910
+ # Parse command line arguments (basic implementation)
911
+ initial_prompt = None
912
+ debug = False
913
+ verbose = False
914
+
915
+ if len(sys.argv) > 1:
916
+ if "--debug" in sys.argv:
917
+ debug = True
918
+ if "--verbose" in sys.argv:
919
+ verbose = True
920
+ if "--prompt" in sys.argv:
921
+ prompt_index = sys.argv.index("--prompt")
922
+ if prompt_index + 1 < len(sys.argv):
923
+ initial_prompt = sys.argv[prompt_index + 1]
924
+
925
+ run(initial_prompt=initial_prompt, debug=debug, verbose=verbose)