minion-code 0.1.0__py3-none-any.whl → 0.1.2__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 (115) hide show
  1. examples/cli_entrypoint.py +60 -0
  2. examples/{agent_with_todos.py → components/agent_with_todos.py} +58 -47
  3. examples/{message_response_children_demo.py → components/message_response_children_demo.py} +61 -55
  4. examples/components/messages_component.py +199 -0
  5. examples/file_freshness_example.py +22 -22
  6. examples/file_watching_example.py +32 -26
  7. examples/interruptible_tui.py +921 -3
  8. examples/repl_tui.py +129 -0
  9. examples/skills/example_usage.py +57 -0
  10. examples/start.py +173 -0
  11. minion_code/__init__.py +1 -1
  12. minion_code/acp_server/__init__.py +34 -0
  13. minion_code/acp_server/agent.py +539 -0
  14. minion_code/acp_server/hooks.py +354 -0
  15. minion_code/acp_server/main.py +194 -0
  16. minion_code/acp_server/permissions.py +142 -0
  17. minion_code/acp_server/test_client.py +104 -0
  18. minion_code/adapters/__init__.py +22 -0
  19. minion_code/adapters/output_adapter.py +207 -0
  20. minion_code/adapters/rich_adapter.py +169 -0
  21. minion_code/adapters/textual_adapter.py +254 -0
  22. minion_code/agents/__init__.py +2 -2
  23. minion_code/agents/code_agent.py +517 -104
  24. minion_code/agents/hooks.py +378 -0
  25. minion_code/cli.py +538 -429
  26. minion_code/cli_simple.py +665 -0
  27. minion_code/commands/__init__.py +136 -29
  28. minion_code/commands/clear_command.py +19 -46
  29. minion_code/commands/help_command.py +33 -49
  30. minion_code/commands/history_command.py +37 -55
  31. minion_code/commands/model_command.py +194 -0
  32. minion_code/commands/quit_command.py +9 -12
  33. minion_code/commands/resume_command.py +181 -0
  34. minion_code/commands/skill_command.py +89 -0
  35. minion_code/commands/status_command.py +48 -73
  36. minion_code/commands/tools_command.py +54 -52
  37. minion_code/commands/version_command.py +34 -69
  38. minion_code/components/ConfirmDialog.py +430 -0
  39. minion_code/components/Message.py +318 -97
  40. minion_code/components/MessageResponse.py +30 -29
  41. minion_code/components/Messages.py +351 -0
  42. minion_code/components/PromptInput.py +499 -245
  43. minion_code/components/__init__.py +24 -17
  44. minion_code/const.py +7 -0
  45. minion_code/screens/REPL.py +1453 -469
  46. minion_code/screens/__init__.py +1 -1
  47. minion_code/services/__init__.py +20 -20
  48. minion_code/services/event_system.py +19 -14
  49. minion_code/services/file_freshness_service.py +223 -170
  50. minion_code/skills/__init__.py +25 -0
  51. minion_code/skills/skill.py +128 -0
  52. minion_code/skills/skill_loader.py +198 -0
  53. minion_code/skills/skill_registry.py +177 -0
  54. minion_code/subagents/__init__.py +31 -0
  55. minion_code/subagents/builtin/__init__.py +30 -0
  56. minion_code/subagents/builtin/claude_code_guide.py +32 -0
  57. minion_code/subagents/builtin/explore.py +36 -0
  58. minion_code/subagents/builtin/general_purpose.py +19 -0
  59. minion_code/subagents/builtin/plan.py +61 -0
  60. minion_code/subagents/subagent.py +116 -0
  61. minion_code/subagents/subagent_loader.py +147 -0
  62. minion_code/subagents/subagent_registry.py +151 -0
  63. minion_code/tools/__init__.py +8 -2
  64. minion_code/tools/bash_tool.py +16 -3
  65. minion_code/tools/file_edit_tool.py +201 -104
  66. minion_code/tools/file_read_tool.py +183 -26
  67. minion_code/tools/file_write_tool.py +17 -3
  68. minion_code/tools/glob_tool.py +23 -2
  69. minion_code/tools/grep_tool.py +229 -21
  70. minion_code/tools/ls_tool.py +28 -3
  71. minion_code/tools/multi_edit_tool.py +89 -84
  72. minion_code/tools/python_interpreter_tool.py +9 -1
  73. minion_code/tools/skill_tool.py +210 -0
  74. minion_code/tools/task_tool.py +287 -0
  75. minion_code/tools/todo_read_tool.py +28 -24
  76. minion_code/tools/todo_write_tool.py +82 -65
  77. minion_code/{types.py → type_defs.py} +15 -2
  78. minion_code/utils/__init__.py +45 -17
  79. minion_code/utils/config.py +610 -0
  80. minion_code/utils/history.py +114 -0
  81. minion_code/utils/logs.py +53 -0
  82. minion_code/utils/mcp_loader.py +153 -55
  83. minion_code/utils/output_truncator.py +233 -0
  84. minion_code/utils/session_storage.py +369 -0
  85. minion_code/utils/todo_file_utils.py +26 -22
  86. minion_code/utils/todo_storage.py +43 -33
  87. minion_code/web/__init__.py +9 -0
  88. minion_code/web/adapters/__init__.py +5 -0
  89. minion_code/web/adapters/web_adapter.py +524 -0
  90. minion_code/web/api/__init__.py +7 -0
  91. minion_code/web/api/chat.py +277 -0
  92. minion_code/web/api/interactions.py +136 -0
  93. minion_code/web/api/sessions.py +135 -0
  94. minion_code/web/server.py +149 -0
  95. minion_code/web/services/__init__.py +5 -0
  96. minion_code/web/services/session_manager.py +420 -0
  97. minion_code-0.1.2.dist-info/METADATA +476 -0
  98. minion_code-0.1.2.dist-info/RECORD +111 -0
  99. {minion_code-0.1.0.dist-info → minion_code-0.1.2.dist-info}/WHEEL +1 -1
  100. minion_code-0.1.2.dist-info/entry_points.txt +6 -0
  101. tests/test_adapter.py +67 -0
  102. tests/test_adapter_simple.py +79 -0
  103. tests/test_file_read_tool.py +144 -0
  104. tests/test_readonly_tools.py +0 -2
  105. tests/test_skills.py +441 -0
  106. examples/advance_tui.py +0 -508
  107. examples/rich_example.py +0 -4
  108. examples/simple_file_watching.py +0 -57
  109. examples/simple_tui.py +0 -267
  110. examples/simple_usage.py +0 -69
  111. minion_code-0.1.0.dist-info/METADATA +0 -350
  112. minion_code-0.1.0.dist-info/RECORD +0 -59
  113. minion_code-0.1.0.dist-info/entry_points.txt +0 -4
  114. {minion_code-0.1.0.dist-info → minion_code-0.1.2.dist-info}/licenses/LICENSE +0 -0
  115. {minion_code-0.1.0.dist-info → minion_code-0.1.2.dist-info}/top_level.txt +0 -0
@@ -6,7 +6,16 @@ Simulates React-like component structure as documented in AGENTS.md
6
6
 
7
7
  from textual.app import App, ComposeResult
8
8
  from textual.containers import Container, Horizontal, Vertical, ScrollableContainer
9
- from textual.widgets import Input, RichLog, Button, Static, Header, Footer, Label
9
+ from textual.widgets import (
10
+ Input,
11
+ RichLog,
12
+ Button,
13
+ Static,
14
+ Header,
15
+ Footer,
16
+ Label,
17
+ TextArea,
18
+ )
10
19
  from textual.reactive import reactive, var
11
20
  from textual import on, work
12
21
  from textual.screen import Screen
@@ -14,6 +23,7 @@ from rich.text import Text
14
23
  from rich.syntax import Syntax
15
24
  from rich.console import Console
16
25
  import asyncio
26
+ import os
17
27
  from typing import List, Dict, Any, Optional, Callable, Union, Set
18
28
  from dataclasses import dataclass, field
19
29
  from enum import Enum
@@ -21,78 +31,147 @@ import uuid
21
31
  import time
22
32
  from pathlib import Path
23
33
 
24
- # Simple logging setup for TUI - disable to prevent screen interference
25
- import logging
26
- logger = logging.getLogger(__name__)
27
- logger.disabled = True
34
+ # Session storage imports
35
+ from ..utils.session_storage import (
36
+ Session,
37
+ create_session,
38
+ save_session,
39
+ load_session,
40
+ get_latest_session_id,
41
+ add_message as session_add_message,
42
+ restore_agent_history,
43
+ )
44
+
45
+ # No logging in UI components to reduce noise
28
46
 
29
47
 
30
48
  # Import shared types
31
- from ..types import (
32
- MessageType, InputMode, MessageContent, Message,
33
- ToolUseConfirm, BinaryFeedbackContext, ToolJSXContext,
34
- REPLConfig, ModelInfo
49
+ from ..type_defs import (
50
+ MessageType,
51
+ InputMode,
52
+ MessageContent,
53
+ Message as MessageData,
54
+ ToolUseConfirm,
55
+ BinaryFeedbackContext,
56
+ ToolJSXContext,
57
+ REPLConfig,
58
+ ModelInfo,
35
59
  )
36
60
 
37
61
 
38
62
  class Logo(Static):
39
63
  """Logo component equivalent to React Logo component"""
40
-
41
- def __init__(self, mcp_clients=None, is_default_model=True, update_banner_version=None, **kwargs):
64
+
65
+ def __init__(
66
+ self,
67
+ mcp_clients=None,
68
+ is_default_model=True,
69
+ update_banner_version=None,
70
+ **kwargs,
71
+ ):
42
72
  super().__init__(**kwargs)
43
73
  self.mcp_clients = mcp_clients or []
44
74
  self.is_default_model = is_default_model
45
75
  self.update_banner_version = update_banner_version
46
-
76
+
47
77
  def render(self) -> str:
48
78
  logo_text = "🤖 Minion Code Assistant"
49
79
  if self.update_banner_version:
50
80
  logo_text += f" (Update available: {self.update_banner_version})"
51
81
  return logo_text
52
82
 
83
+
53
84
  class ModeIndicator(Static):
54
85
  """Mode indicator component"""
55
-
86
+
56
87
  def __init__(self, mode: InputMode = InputMode.PROMPT, **kwargs):
57
88
  super().__init__(**kwargs)
58
89
  self.mode = mode
59
-
90
+
60
91
  def render(self) -> str:
61
92
  return f"Mode: {self.mode.value.upper()}"
62
93
 
94
+
63
95
  class Spinner(Static):
64
- """Loading spinner component"""
65
-
66
- def __init__(self, **kwargs):
67
- super().__init__("⠋ Loading...", **kwargs)
68
- self.auto_refresh = 0.1
96
+ """Simple loading spinner - just one line of animated text"""
97
+
98
+ DEFAULT_CSS = """
99
+ Spinner {
100
+ color: $primary;
101
+ text-style: italic;
102
+ height: 1;
103
+ margin: 1 0;
104
+ padding: 0 1;
105
+ }
106
+ """
107
+
108
+ def __init__(self, message: str = "Processing", **kwargs):
109
+ super().__init__("⠋ Processing...", **kwargs)
110
+ self.base_message = message
69
111
  self.spinner_chars = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
70
112
  self.spinner_index = 0
71
-
113
+ self._timer = None
114
+
72
115
  def on_mount(self):
73
- self.set_interval(0.1, self.update_spinner)
74
-
116
+ self._timer = self.set_interval(0.1, self.update_spinner)
117
+
118
+ def on_unmount(self):
119
+ if self._timer:
120
+ self._timer.stop()
121
+
75
122
  def update_spinner(self):
76
123
  self.spinner_index = (self.spinner_index + 1) % len(self.spinner_chars)
77
- self.update(f"{self.spinner_chars[self.spinner_index]} Loading...")
124
+ self.update(f"{self.spinner_chars[self.spinner_index]} {self.base_message}...")
125
+
126
+ def set_message(self, message: str):
127
+ """Update the spinner message"""
128
+ self.base_message = message
129
+
78
130
 
79
131
  class MessageWidget(Container):
80
- """Individual message display widget"""
81
-
82
- def __init__(self, message: Message, verbose: bool = False, debug: bool = False, **kwargs):
132
+ """Individual message display widget with streaming support"""
133
+
134
+ def __init__(
135
+ self, message: MessageData, verbose: bool = False, debug: bool = False, **kwargs
136
+ ):
83
137
  super().__init__(**kwargs)
84
138
  self.message = message
85
139
  self.verbose = verbose
86
140
  self.debug = debug
87
-
141
+ self.is_streaming = (
142
+ message.options.get("streaming", False) if message.options else False
143
+ )
144
+ self.is_error = (
145
+ message.options.get("error", False) if message.options else False
146
+ )
147
+
88
148
  def compose(self) -> ComposeResult:
149
+ content_text = self._get_content_text()
150
+
89
151
  if self.message.type == MessageType.USER:
90
- yield Static(f"👤 User: {self._get_content_text()}", classes="user-message")
152
+ yield Static(f"👤 User:", classes="user-label")
153
+ yield Static(content_text, classes="user-message")
91
154
  elif self.message.type == MessageType.ASSISTANT:
92
- yield Static(f"🤖 Assistant: {self._get_content_text()}", classes="assistant-message")
155
+ if self.is_streaming:
156
+ yield Static(
157
+ f"🤖 Assistant: ⠋ Thinking...", classes="assistant-streaming"
158
+ )
159
+ elif self.is_error:
160
+ yield Static(f"❌ Assistant:", classes="assistant-error-label")
161
+ yield Static(content_text, classes="assistant-error")
162
+ else:
163
+ yield Static(f"🤖 Assistant:", classes="assistant-label")
164
+ # Handle markdown content
165
+ if "```" in content_text or content_text.startswith("#"):
166
+ from rich.markdown import Markdown
167
+
168
+ yield Static(Markdown(content_text), classes="assistant-message")
169
+ else:
170
+ yield Static(content_text, classes="assistant-message")
93
171
  elif self.message.type == MessageType.PROGRESS:
94
- yield Static(f"⚙️ Progress: {self._get_content_text()}", classes="progress-message")
95
-
172
+ yield Static(f"⚙️ Progress:", classes="progress-label")
173
+ yield Static(content_text, classes="progress-message")
174
+
96
175
  def _get_content_text(self) -> str:
97
176
  if isinstance(self.message.message.content, str):
98
177
  return self.message.message.content
@@ -105,25 +184,54 @@ class MessageWidget(Container):
105
184
  return "\n".join(text_parts)
106
185
  return str(self.message.message.content)
107
186
 
187
+ def update_streaming_content(self, new_content: str):
188
+ """Update streaming message content"""
189
+ if self.is_streaming:
190
+ try:
191
+ # Update the message content
192
+ self.message.message.content = new_content
193
+ # Find and update the static widget
194
+ static_widgets = self.query("Static")
195
+ if len(static_widgets) > 1:
196
+ static_widgets[1].update(new_content)
197
+ except Exception:
198
+ pass # Silently handle streaming update errors
199
+
200
+ def finalize_streaming(self, final_content: str):
201
+ """Finalize streaming message with final content"""
202
+ if self.is_streaming:
203
+ self.is_streaming = False
204
+ self.message.options["streaming"] = False
205
+ self.message.message.content = final_content
206
+ # Refresh the entire widget
207
+ self.refresh()
208
+
108
209
 
109
- # PromptInput moved to components/PromptInput.py
210
+ # Import components
110
211
  from ..components.PromptInput import PromptInput
212
+ from ..components.Messages import Messages
213
+ from ..components.ConfirmDialog import ConfirmDialog, ChoiceDialog, InputDialog
214
+
215
+ # Import adapters
216
+ from ..adapters.textual_adapter import TextualOutputAdapter
217
+
111
218
 
112
219
  class CostThresholdDialog(Container):
113
220
  """Cost threshold warning dialog"""
114
-
221
+
115
222
  def compose(self) -> ComposeResult:
116
223
  yield Static("⚠️ Cost Threshold Warning", classes="dialog-title")
117
224
  yield Static("You have exceeded $5 in API costs. Please be mindful of usage.")
118
225
  yield Button("Acknowledge", id="acknowledge_btn", variant="primary")
119
226
 
227
+
120
228
  class PermissionRequest(Container):
121
229
  """Permission request dialog for tool usage"""
122
-
230
+
123
231
  def __init__(self, tool_use_confirm: ToolUseConfirm, **kwargs):
124
232
  super().__init__(**kwargs)
125
233
  self.tool_use_confirm = tool_use_confirm
126
-
234
+
127
235
  def compose(self) -> ComposeResult:
128
236
  yield Static(f"🔧 Tool Permission Request", classes="dialog-title")
129
237
  yield Static(f"Tool: {self.tool_use_confirm.tool_name}")
@@ -131,11 +239,11 @@ class PermissionRequest(Container):
131
239
  with Horizontal():
132
240
  yield Button("Allow", id="allow_btn", variant="success")
133
241
  yield Button("Deny", id="deny_btn", variant="error")
134
-
242
+
135
243
  @on(Button.Pressed, "#allow_btn")
136
244
  def allow_tool(self):
137
245
  self.tool_use_confirm.on_confirm()
138
-
246
+
139
247
  @on(Button.Pressed, "#deny_btn")
140
248
  def deny_tool(self):
141
249
  self.tool_use_confirm.on_abort()
@@ -143,11 +251,11 @@ class PermissionRequest(Container):
143
251
 
144
252
  class MessageSelector(Container):
145
253
  """Message selector for conversation forking"""
146
-
147
- def __init__(self, messages: List[Message], **kwargs):
254
+
255
+ def __init__(self, messages: List[MessageData], **kwargs):
148
256
  super().__init__(**kwargs)
149
257
  self.messages = messages
150
-
258
+
151
259
  def compose(self) -> ComposeResult:
152
260
  yield Static("📝 Select Message to Fork From", classes="dialog-title")
153
261
  with ScrollableContainer():
@@ -155,8 +263,8 @@ class MessageSelector(Container):
155
263
  content = self._get_message_preview(message)
156
264
  yield Button(f"{i}: {content[:50]}...", id=f"msg_{i}")
157
265
  yield Button("Cancel", id="cancel_selector", variant="error")
158
-
159
- def _get_message_preview(self, message: Message) -> str:
266
+
267
+ def _get_message_preview(self, message: MessageData) -> str:
160
268
  if isinstance(message.message.content, str):
161
269
  return message.message.content
162
270
  return str(message.message.content)[:50]
@@ -167,55 +275,241 @@ class REPL(Container):
167
275
  Main REPL Component - Python equivalent of React REPL component
168
276
  Manages the entire conversation interface with AI assistant
169
277
  """
278
+
279
+ DEFAULT_CSS = """
280
+ /* Message styling */
281
+ .user-label {
282
+ text-style: bold;
283
+ color: blue;
284
+ margin-top: 1;
285
+ margin-bottom: 0;
286
+ }
287
+
288
+ .user-message {
289
+ background: blue 20%;
290
+ color: white;
291
+ margin: 1;
292
+ margin-top: 0;
293
+ padding: 1;
294
+ border-left: solid blue;
295
+ }
296
+
297
+ .assistant-label {
298
+ text-style: bold;
299
+ color: green;
300
+ margin-top: 1;
301
+ margin-bottom: 0;
302
+ }
303
+
304
+ .assistant-message {
305
+ background: green 20%;
306
+ color: white;
307
+ margin: 1;
308
+ margin-top: 0;
309
+ padding: 1;
310
+ border-left: solid green;
311
+ }
170
312
 
313
+ .assistant-streaming {
314
+ background: yellow 20%;
315
+ color: black;
316
+ margin: 1;
317
+ padding: 1;
318
+ border-left: solid yellow;
319
+ text-style: italic;
320
+ }
321
+
322
+ .assistant-error-label {
323
+ text-style: bold;
324
+ color: red;
325
+ margin-top: 1;
326
+ margin-bottom: 0;
327
+ }
328
+
329
+ .assistant-error {
330
+ background: red 20%;
331
+ color: white;
332
+ margin: 1;
333
+ margin-top: 0;
334
+ padding: 1;
335
+ border-left: solid red;
336
+ }
337
+
338
+ .progress-label {
339
+ text-style: bold;
340
+ color: yellow;
341
+ margin-top: 1;
342
+ margin-bottom: 0;
343
+ }
344
+
345
+ .progress-message {
346
+ background: yellow 20%;
347
+ color: black;
348
+ margin: 1;
349
+ margin-top: 0;
350
+ padding: 1;
351
+ border-left: solid yellow;
352
+ }
353
+
354
+ .dialog-title {
355
+ text-style: bold;
356
+ content-align: center middle;
357
+ margin: 1;
358
+ background: cyan 30%;
359
+ color: black;
360
+ }
361
+
362
+ #messages_container {
363
+ height: 1fr;
364
+ margin: 0;
365
+ scrollbar-background: gray 50%;
366
+ scrollbar-color: white;
367
+ }
368
+
369
+ #main_input {
370
+ width: 1fr;
371
+ margin-right: 1;
372
+ border: solid white;
373
+ dock: bottom;
374
+ }
375
+
376
+ /* PromptInput component styles */
377
+ .model-info {
378
+
379
+ height: 1;
380
+ content-align: right middle;
381
+ color: white;
382
+ margin-bottom: 1;
383
+ }
384
+
385
+ #input_container {
386
+ margin: 1;
387
+ padding: 1;
388
+ }
389
+
390
+ #mode_prefix {
391
+ width: 3;
392
+ content-align: center middle;
393
+ text-style: bold;
394
+ }
395
+
396
+ .mode-bash #mode_prefix {
397
+ color: yellow;
398
+ }
399
+
400
+ .mode-koding #mode_prefix {
401
+ color: cyan;
402
+ }
403
+
404
+ #status_area {
405
+ dock: bottom;
406
+ height: 2;
407
+ margin: 1;
408
+ }
409
+
410
+ .status-message {
411
+ color: white;
412
+ text-style: dim;
413
+ }
414
+
415
+ .model-switch-message {
416
+ color: green;
417
+ text-style: bold;
418
+ }
419
+
420
+ .help-text {
421
+ margin-right: 2;
422
+ }
423
+
424
+ .help-text.active {
425
+ color: white;
426
+ text-style: bold;
427
+ }
428
+
429
+ .help-text.inactive {
430
+ color: gray;
431
+ text-style: dim;
432
+ }
433
+
434
+ Button {
435
+ margin: 1;
436
+ }
437
+
438
+ Input {
439
+ border: solid white;
440
+ }
441
+
442
+
443
+ """
444
+
171
445
  # Reactive properties equivalent to React useState
172
446
  fork_number = reactive(0)
173
- is_loading = reactive(False)
174
- messages = var(list) # List[Message]
447
+ is_loading = reactive(False) # Recompose when loading state changes
448
+ messages = var(list) # List[MessageData]
175
449
  input_value = reactive("")
176
450
  input_mode = reactive(InputMode.PROMPT)
177
451
  submit_count = reactive(0)
178
- is_message_selector_visible = reactive(False)
179
- show_cost_dialog = reactive(False)
452
+ is_message_selector_visible = reactive(
453
+ False, recompose=True
454
+ ) # Recompose when selector visibility changes
455
+ show_cost_dialog = reactive(
456
+ False, recompose=True
457
+ ) # Recompose when dialog visibility changes
180
458
  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):
459
+ should_show_prompt_input = reactive(True, recompose=True)
460
+
461
+ def __init__(
462
+ self,
463
+ commands=None,
464
+ safe_mode=False,
465
+ debug=False,
466
+ initial_fork_number=0,
467
+ initial_prompt=None,
468
+ message_log_name="default",
469
+ should_show_prompt_input=True,
470
+ tools=None,
471
+ verbose=False,
472
+ initial_messages=None,
473
+ mcp_clients=None,
474
+ is_default_model=True,
475
+ initial_update_version=None,
476
+ initial_update_commands=None,
477
+ agent=None, # Agent passed from app level
478
+ resume_session_id=None, # Session ID to resume
479
+ continue_last=False, # Continue most recent session
480
+ **kwargs,
481
+ ):
198
482
  super().__init__(**kwargs)
199
-
483
+
200
484
  # Props equivalent to TypeScript Props interface
201
485
  self.commands = commands or []
202
486
  self.safe_mode = safe_mode
203
487
  self.debug = debug
204
488
  self.initial_fork_number = initial_fork_number
205
489
  self.initial_prompt = initial_prompt
490
+ print(f"DEBUG REPL.__init__: initial_prompt={initial_prompt}")
206
491
  self.message_log_name = message_log_name
207
- self.should_show_prompt_input = should_show_prompt_input
208
492
  self.tools = tools or []
209
493
  self.verbose = verbose
210
494
  self.mcp_clients = mcp_clients or []
211
495
  self.is_default_model = is_default_model
212
496
  self.initial_update_version = initial_update_version
213
497
  self.initial_update_commands = initial_update_commands
214
-
498
+
499
+ # Session management
500
+ self.resume_session_id = resume_session_id
501
+ self.continue_last = continue_last
502
+ self.session: Optional[Session] = None
503
+
215
504
  # Initialize state
216
505
  self.messages = initial_messages or []
506
+ print(f"DEBUG: REPL initialized with {len(self.messages)} messages")
217
507
  self.fork_number = initial_fork_number
218
-
508
+ self.should_show_prompt_input = should_show_prompt_input
509
+
510
+ # Agent from app level
511
+ self.agent = agent
512
+
219
513
  # Internal state
220
514
  self.config = REPLConfig()
221
515
  self.abort_controller: Optional[asyncio.Task] = None
@@ -223,165 +517,346 @@ class REPL(Container):
223
517
  self.tool_use_confirm: Optional[ToolUseConfirm] = None
224
518
  self.binary_feedback_context: Optional[BinaryFeedbackContext] = None
225
519
  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
-
520
+ self.fork_convo_with_messages_on_next_render: Optional[List[MessageData]] = None
521
+
522
+ # Output adapter for command execution
523
+ self.output_adapter = TextualOutputAdapter(on_output=self.handle_command_output)
524
+ self.active_dialog: Optional[Container] = None
525
+
526
+ def _create_test_messages(self) -> List[MessageData]:
527
+ """Create some test messages for development/testing"""
528
+ import time
529
+
530
+ test_messages = []
531
+
532
+ # Welcome message from assistant
533
+ test_messages.append(
534
+ MessageData(
535
+ type=MessageType.ASSISTANT,
536
+ message=MessageContent(
537
+ "👋 Welcome to Minion Code Assistant! I'm here to help you with coding tasks, file operations, and more. What would you like to work on today?"
538
+ ),
539
+ timestamp=time.time() - 120,
540
+ options={},
541
+ )
542
+ )
543
+
544
+ # Example user message
545
+ test_messages.append(
546
+ MessageData(
547
+ type=MessageType.USER,
548
+ message=MessageContent(
549
+ "Can you help me understand how to use this REPL interface?"
550
+ ),
551
+ timestamp=time.time() - 100,
552
+ options={},
553
+ )
554
+ )
555
+
556
+ # Example assistant response with code
557
+ test_messages.append(
558
+ MessageData(
559
+ type=MessageType.ASSISTANT,
560
+ message=MessageContent(
561
+ """Absolutely! Here's how to use the REPL interface:
562
+
563
+ ## Input Modes
564
+ - **Prompt mode** (`>`): Regular conversation with the AI assistant
565
+ - **Bash mode** (`!`): Execute shell commands directly
566
+ - **Koding mode** (`#`): Add notes or generate content for AGENTS.md
567
+
568
+ ## Keyboard Shortcuts
569
+ - `Enter`: Submit your message
570
+ - `Ctrl+Enter`, `Tab`, or `Ctrl+J`: Add a new line
571
+ - `Escape`: Switch modes or show message selector
572
+ - `Shift+M`: Quick model switching
573
+
574
+ ## Examples
575
+ ```bash
576
+ # Bash mode - execute commands
577
+ !ls -la
578
+
579
+ # Koding mode - add to AGENTS.md
580
+ #Create a new Python function for data processing
581
+
582
+ # Regular prompt
583
+ How do I implement error handling in Python?
584
+ ```
585
+
586
+ Try typing something to get started!"""
587
+ ),
588
+ timestamp=time.time() - 80,
589
+ options={},
590
+ )
591
+ )
592
+
593
+ return test_messages
594
+
230
595
  def compose(self) -> ComposeResult:
231
596
  """Compose the REPL interface - equivalent to React render method"""
232
- # Static header section (equivalent to Static items in React)
233
597
  with Vertical():
598
+ # Logo at the top
234
599
  yield Logo(
235
600
  mcp_clients=self.mcp_clients,
236
601
  is_default_model=self.is_default_model,
237
- update_banner_version=self.initial_update_version
602
+ update_banner_version=self.initial_update_version,
238
603
  )
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
-
604
+
605
+ # Messages container (main content area) - takes remaining space
606
+ print(
607
+ f"DEBUG: REPL.compose() creating Messages component with {len(self.messages)} messages"
608
+ )
609
+ yield Messages(
610
+ messages=self.messages,
611
+ tools=self.tools,
612
+ verbose=self.verbose,
613
+ debug=self.debug,
614
+ id="messages_container",
615
+ )
616
+
617
+ # Loading indicator - simple one-line text with animation
618
+ if self.is_loading:
619
+ yield Spinner(message="Assistant is thinking")
620
+
621
+ # Other dynamic content (dialogs, etc.) - also between Messages and PromptInput
622
+ # Tool JSX (equivalent to {toolJSX ? toolJSX.jsx : null})
623
+ if self.tool_jsx and self.tool_jsx.jsx:
624
+ yield self.tool_jsx.jsx
625
+
626
+ # Binary feedback (equivalent to BinaryFeedback component)
627
+ if self.binary_feedback_context and not self.is_message_selector_visible:
628
+ yield Static("🔄 Binary feedback component would render here")
629
+
630
+ # Permission request (equivalent to PermissionRequest component)
631
+ if (
632
+ self.tool_use_confirm
633
+ and not self.is_message_selector_visible
634
+ and not self.binary_feedback_context
635
+ ):
636
+ yield PermissionRequest(self.tool_use_confirm)
637
+
638
+ # Cost dialog (equivalent to CostThresholdDialog component)
639
+ if self.show_cost_dialog and not self.is_loading:
640
+ yield CostThresholdDialog()
641
+
305
642
  # Message selector (equivalent to {isMessageSelectorVisible && <MessageSelector />})
306
643
  if self.is_message_selector_visible:
307
644
  yield MessageSelector(messages=self.messages)
308
-
645
+
646
+ # PromptInput component at the bottom (dock: bottom)
647
+ if self.should_show_prompt_input:
648
+ prompt_input = PromptInput(
649
+ commands=self.commands,
650
+ fork_number=self.fork_number,
651
+ message_log_name=self.message_log_name,
652
+ is_disabled=False,
653
+ is_loading=self.is_loading,
654
+ debug=self.debug,
655
+ verbose=self.verbose,
656
+ messages=self.messages,
657
+ tools=self.tools,
658
+ input_value=self.input_value,
659
+ mode=self.input_mode,
660
+ submit_count=self.submit_count,
661
+ read_file_timestamps=self.read_file_timestamps,
662
+ abort_controller=self.abort_controller,
663
+ )
664
+
665
+ # Set up callbacks
666
+ prompt_input.on_query = self.on_query_from_prompt
667
+ prompt_input.on_add_user_message = (
668
+ self.on_add_user_message_from_prompt
669
+ ) # New immediate display callback
670
+ prompt_input.on_input_change = self.on_input_change_from_prompt
671
+ prompt_input.on_mode_change = self.on_mode_change_from_prompt
672
+ prompt_input.on_submit_count_change = (
673
+ self.on_submit_count_change_from_prompt
674
+ )
675
+ prompt_input.set_is_loading = self.set_loading_from_prompt
676
+ prompt_input.set_abort_controller = (
677
+ self.set_abort_controller_from_prompt
678
+ )
679
+ prompt_input.on_show_message_selector = self.show_message_selector
680
+ prompt_input.set_fork_convo_with_messages = self.set_fork_convo_messages
681
+ prompt_input.on_model_change = self.on_model_change_from_prompt
682
+ prompt_input.set_tool_jsx = self.set_tool_jsx_from_prompt
683
+ prompt_input.on_execute_command = (
684
+ self.on_execute_command_from_prompt
685
+ ) # Command execution callback
686
+
687
+ yield prompt_input
688
+
309
689
  def on_mount(self):
310
690
  """Component lifecycle - equivalent to React useEffect(() => { onInit() }, [])"""
311
- logger.info("REPL mounted, starting initialization")
691
+ # Initialize session
692
+ self._init_session()
312
693
  self.call_later(self.on_init)
313
694
  # Set focus to the input after a short delay to ensure it's mounted
314
695
  self.set_timer(0.1, self._set_focus_to_input)
315
-
696
+
697
+ def _init_session(self):
698
+ """Initialize or restore session."""
699
+ current_project = os.getcwd()
700
+
701
+ # Try to restore session if requested
702
+ if self.resume_session_id:
703
+ self.session = load_session(self.resume_session_id)
704
+ if self.session:
705
+ print(
706
+ f"DEBUG: Restored session {self.resume_session_id} with {len(self.session.messages)} messages"
707
+ )
708
+ # Restore messages to UI
709
+ self._restore_ui_messages_from_session()
710
+ else:
711
+ print(
712
+ f"DEBUG: Session {self.resume_session_id} not found, creating new"
713
+ )
714
+ self.session = create_session(current_project)
715
+ elif self.continue_last:
716
+ latest_id = get_latest_session_id(project_path=current_project)
717
+ if latest_id:
718
+ self.session = load_session(latest_id)
719
+ if self.session:
720
+ print(
721
+ f"DEBUG: Continuing session {latest_id} with {len(self.session.messages)} messages"
722
+ )
723
+ # Restore messages to UI
724
+ self._restore_ui_messages_from_session()
725
+ else:
726
+ self.session = create_session(current_project)
727
+ else:
728
+ self.session = create_session(current_project)
729
+ else:
730
+ # Create new session
731
+ self.session = create_session(current_project)
732
+ print(f"DEBUG: Created new session {self.session.metadata.session_id}")
733
+
734
+ def _restore_ui_messages_from_session(self):
735
+ """Restore UI messages from session."""
736
+ if not self.session or not self.session.messages:
737
+ return
738
+
739
+ # Clear existing UI messages first
740
+ self.messages.clear()
741
+
742
+ # Convert session messages to MessageData for UI display
743
+ for msg in self.session.messages:
744
+ msg_type = MessageType.USER if msg.role == "user" else MessageType.ASSISTANT
745
+ ui_message = MessageData(
746
+ type=msg_type, message=MessageContent(msg.content), options={}
747
+ )
748
+ self.messages.append(ui_message)
749
+
750
+ print(f"DEBUG: Restored {len(self.session.messages)} messages to UI")
751
+
752
+ def _save_message_to_session(self, role: str, content: str):
753
+ """Save a message to the current session."""
754
+ if self.session:
755
+ session_add_message(self.session, role, content, auto_save=True)
756
+ if self.verbose:
757
+ print(
758
+ f"DEBUG: Saved {role} message to session {self.session.metadata.session_id}"
759
+ )
760
+
316
761
  def _set_focus_to_input(self):
317
762
  """Set focus to the main input widget"""
318
763
  try:
319
- # Try to find the simplified input
320
- input_widget = self.query_one("#simple_input", expect_type=Input)
764
+ # Try to find the main TextArea input
765
+ input_widget = self.query_one("#main_input", expect_type=TextArea)
321
766
  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
767
+ except Exception:
768
+ # If that fails, try to focus any TextArea or Input widget
326
769
  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
-
770
+ text_areas = self.query("TextArea")
771
+ if text_areas:
772
+ text_areas[0].focus()
773
+ else:
774
+ inputs = self.query("Input")
775
+ if inputs:
776
+ inputs[0].focus()
777
+ except Exception:
778
+ pass # Silently handle focus errors
779
+
334
780
  async def on_init(self):
335
781
  """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")
782
+ # Initial prompt processing moved to set_agent to ensure agent is ready
783
+ pass
784
+
785
+ @work(exclusive=False)
786
+ async def _start_initial_prompt_worker(self):
787
+ """Worker to process initial prompt."""
788
+ print("DEBUG: _start_initial_prompt_worker started")
789
+ await self._process_initial_prompt()
790
+
791
+ async def _process_initial_prompt(self):
792
+ """Process the initial prompt after agent is ready."""
793
+ print(
794
+ f"DEBUG _process_initial_prompt called: prompt={self.initial_prompt}, agent={self.agent}"
795
+ )
796
+ if not self.initial_prompt or not self.agent:
797
+ print("DEBUG: Skipping - no prompt or no agent")
340
798
  return
341
-
799
+
342
800
  self.is_loading = True
343
-
801
+ prompt_to_process = self.initial_prompt
802
+ # Clear immediately to prevent re-processing
803
+ self.initial_prompt = None
804
+
344
805
  try:
806
+ print(f"DEBUG: Processing prompt: {prompt_to_process}")
345
807
  # Process initial prompt (equivalent to processUserInput)
346
808
  new_messages = await self.process_user_input(
347
- self.initial_prompt,
348
- self.input_mode
809
+ prompt_to_process, self.input_mode
349
810
  )
350
-
811
+ print(f"DEBUG: Got {len(new_messages) if new_messages else 0} new messages")
812
+
351
813
  if new_messages:
352
814
  # Add to history (equivalent to addToHistory)
353
- self.add_to_history(self.initial_prompt)
354
-
815
+ self.add_to_history(prompt_to_process)
816
+
355
817
  # Update messages (equivalent to setMessages)
356
818
  self.messages = [*self.messages, *new_messages]
357
-
358
- # Query API if needed (equivalent to query function)
819
+
820
+ # Update UI
821
+ try:
822
+ messages_component = self.query_one(
823
+ "#messages_container", expect_type=Messages
824
+ )
825
+ messages_component.update_messages(self.messages)
826
+ except Exception:
827
+ self.refresh()
828
+
829
+ # Query API (equivalent to query function)
830
+ print("DEBUG: Calling query_api")
359
831
  await self.query_api(new_messages)
360
-
832
+ print("DEBUG: query_api completed")
833
+
361
834
  except Exception as e:
362
- logger.error(f"Initialization failed: {e}")
835
+ print(f"DEBUG: Error processing initial prompt: {e}")
836
+ import traceback
837
+
838
+ traceback.print_exc()
363
839
  finally:
364
840
  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]:
841
+
842
+ async def process_user_input(
843
+ self, input_text: str, mode: InputMode
844
+ ) -> List[MessageData]:
368
845
  """Process user input - equivalent to processUserInput function"""
369
- logger.info(f"Processing user input: {input_text[:50]}... (mode: {mode.value})")
370
-
846
+
371
847
  # Create user message
372
- user_message = Message(
848
+ user_message = MessageData(
373
849
  type=MessageType.USER,
374
850
  message=MessageContent(input_text),
375
- options={"isKodingRequest": mode == InputMode.KODING}
851
+ options={"isKodingRequest": mode == InputMode.KODING},
376
852
  )
377
-
853
+
378
854
  # Handle different input modes
379
855
  if mode == InputMode.BASH:
380
856
  # Handle bash command
381
857
  result = await self.execute_bash_command(input_text)
382
- assistant_message = Message(
383
- type=MessageType.ASSISTANT,
384
- message=MessageContent(result)
858
+ assistant_message = MessageData(
859
+ type=MessageType.ASSISTANT, message=MessageContent(result)
385
860
  )
386
861
  return [user_message, assistant_message]
387
862
  elif mode == InputMode.KODING:
@@ -390,17 +865,14 @@ class REPL(Container):
390
865
  else:
391
866
  # Handle regular prompt
392
867
  return [user_message]
393
-
868
+
394
869
  async def execute_bash_command(self, command: str) -> str:
395
870
  """Execute bash command - simplified version"""
396
871
  try:
397
872
  import subprocess
873
+
398
874
  result = subprocess.run(
399
- command,
400
- shell=True,
401
- capture_output=True,
402
- text=True,
403
- timeout=30
875
+ command, shell=True, capture_output=True, text=True, timeout=30
404
876
  )
405
877
  if result.returncode == 0:
406
878
  return result.stdout or "Command executed successfully"
@@ -408,36 +880,408 @@ class REPL(Container):
408
880
  return f"Error: {result.stderr}"
409
881
  except Exception as e:
410
882
  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(
883
+
884
+ def set_agent(self, agent):
885
+ """Set agent from app level and bind output adapter"""
886
+ print(f"DEBUG set_agent: agent={agent}, initial_prompt={self.initial_prompt}")
887
+ self.agent = agent
888
+ # Bind output adapter to agent if it supports confirmation
889
+ if hasattr(agent, "set_output_adapter"):
890
+ agent.set_output_adapter(self.output_adapter)
891
+
892
+ # Restore agent history from session if resuming
893
+ if self.session and self.session.messages:
894
+ restore_agent_history(agent, self.session, self.verbose)
895
+
896
+ # Process initial prompt now that agent is ready
897
+ if self.initial_prompt:
898
+ print(f"DEBUG: Triggering initial prompt processing: {self.initial_prompt}")
899
+ # Start the worker to process initial prompt
900
+ self._start_initial_prompt_worker()
901
+
902
+ def handle_command_output(self, output_type: str, data: dict):
903
+ """Handle output from command execution via adapter"""
904
+ if output_type == "panel":
905
+ self.display_panel_output(data)
906
+ elif output_type == "table":
907
+ self.display_table_output(data)
908
+ elif output_type == "text":
909
+ self.display_text_output(data)
910
+ elif output_type == "confirm":
911
+ self.show_confirm_dialog(data)
912
+ elif output_type == "choice":
913
+ self.show_choice_dialog(data)
914
+ elif output_type == "input":
915
+ self.show_input_dialog(data)
916
+
917
+ def display_panel_output(self, data: dict):
918
+ """Display panel output as a message"""
919
+ content = (
920
+ f"{data.get('title', '')}\n\n{data['content']}"
921
+ if data.get("title")
922
+ else data["content"]
923
+ )
924
+ message = MessageData(
925
+ type=MessageType.ASSISTANT,
926
+ message=MessageContent(content),
927
+ options={"border_style": data.get("border_style", "blue")},
928
+ )
929
+ self.messages = [*self.messages, message]
930
+ self._refresh_messages()
931
+
932
+ def display_table_output(self, data: dict):
933
+ """Display table output as formatted text"""
934
+ # Format table as text
935
+ headers = data.get("headers", [])
936
+ rows = data.get("rows", [])
937
+ title = data.get("title", "")
938
+
939
+ lines = []
940
+ if title:
941
+ lines.append(f"=== {title} ===\n")
942
+
943
+ if headers:
944
+ lines.append(" | ".join(headers))
945
+ lines.append("-" * (len(" | ".join(headers))))
946
+
947
+ for row in rows:
948
+ lines.append(" | ".join(str(cell) for cell in row))
949
+
950
+ content = "\n".join(lines)
951
+ message = MessageData(
952
+ type=MessageType.ASSISTANT, message=MessageContent(content), options={}
953
+ )
954
+ self.messages = [*self.messages, message]
955
+ self._refresh_messages()
956
+
957
+ def display_text_output(self, data: dict):
958
+ """Display plain text output"""
959
+ message = MessageData(
960
+ type=MessageType.ASSISTANT,
961
+ message=MessageContent(data["content"]),
962
+ options={"style": data.get("style", "")},
963
+ )
964
+ self.messages = [*self.messages, message]
965
+ self._refresh_messages()
966
+
967
+ def show_confirm_dialog(self, data: dict):
968
+ """Show confirmation dialog"""
969
+ if self.active_dialog:
970
+ self.active_dialog.remove()
971
+
972
+ self.active_dialog = ConfirmDialog(
973
+ interaction_id=data["interaction_id"],
974
+ message=data["message"],
975
+ title=data.get("title", "Confirm"),
976
+ ok_text=data.get("ok_text", "Yes"),
977
+ cancel_text=data.get("cancel_text", "No"),
978
+ on_result=self.handle_confirm_result,
979
+ )
980
+ self.mount(self.active_dialog)
981
+
982
+ def show_choice_dialog(self, data: dict):
983
+ """Show choice selection dialog"""
984
+ if self.active_dialog:
985
+ self.active_dialog.remove()
986
+
987
+ self.active_dialog = ChoiceDialog(
988
+ interaction_id=data["interaction_id"],
989
+ message=data["message"],
990
+ choices=data["choices"],
991
+ title=data.get("title", "Select"),
992
+ on_result=self.handle_choice_result,
993
+ )
994
+ self.mount(self.active_dialog)
995
+
996
+ def show_input_dialog(self, data: dict):
997
+ """Show text input dialog"""
998
+ if self.active_dialog:
999
+ self.active_dialog.remove()
1000
+
1001
+ self.active_dialog = InputDialog(
1002
+ interaction_id=data["interaction_id"],
1003
+ message=data["message"],
1004
+ title=data.get("title", "Input"),
1005
+ default=data.get("default", ""),
1006
+ placeholder=data.get("placeholder", ""),
1007
+ on_result=self.handle_input_result,
1008
+ )
1009
+ self.mount(self.active_dialog)
1010
+
1011
+ def handle_confirm_result(self, interaction_id: str, result: bool):
1012
+ """Handle confirmation dialog result"""
1013
+ self.output_adapter.resolve_interaction(interaction_id, result)
1014
+ self.active_dialog = None
1015
+
1016
+ def handle_choice_result(self, interaction_id: str, result: int):
1017
+ """Handle choice dialog result"""
1018
+ self.output_adapter.resolve_interaction(interaction_id, result)
1019
+ self.active_dialog = None
1020
+
1021
+ def handle_input_result(self, interaction_id: str, result: Optional[str]):
1022
+ """Handle input dialog result"""
1023
+ self.output_adapter.resolve_interaction(interaction_id, result)
1024
+ self.active_dialog = None
1025
+
1026
+ def _refresh_messages(self):
1027
+ """Helper to refresh messages component"""
1028
+ try:
1029
+ messages_component = self.query_one("#messages_container", Messages)
1030
+ messages_component.update_messages(self.messages)
1031
+ except Exception:
1032
+ self.refresh()
1033
+
1034
+ async def query_api(self, new_messages: List[MessageData]):
1035
+ """Query the AI API with streaming support - equivalent to query function"""
1036
+
1037
+ if not new_messages or new_messages[-1].type != MessageType.USER:
1038
+ return
1039
+
1040
+ user_content = new_messages[-1].message.content
1041
+
1042
+ # Check if agent is available
1043
+ if not self.agent:
1044
+ error_message = MessageData(
425
1045
  type=MessageType.ASSISTANT,
426
- message=MessageContent(response_content)
1046
+ message=MessageContent("❌ Agent not initialized yet. Please wait..."),
1047
+ options={"error": True},
427
1048
  )
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):
1049
+ self.messages = [*self.messages, error_message]
1050
+ return
1051
+
1052
+ try:
1053
+ # Set loading state with immediate UI feedback
1054
+ self.is_loading = True
1055
+
1056
+ # Add a temporary "thinking" message to show immediate feedback
1057
+ thinking_message = MessageData(
1058
+ type=MessageType.ASSISTANT,
1059
+ message=MessageContent("🤔 Thinking..."),
1060
+ options={"streaming": True, "temporary": True},
1061
+ )
1062
+ self.messages = [*self.messages, thinking_message]
1063
+
1064
+ # Update Messages component immediately
1065
+ try:
1066
+ messages_component = self.query_one(
1067
+ "#messages_container", expect_type=Messages
1068
+ )
1069
+ messages_component.update_messages(self.messages)
1070
+ except Exception:
1071
+ self.refresh() # Fallback to full refresh
1072
+
1073
+ # Process with agent - check if it supports streaming
1074
+ try:
1075
+ if hasattr(self.agent, "run_async"):
1076
+ # Try to use streaming if supported
1077
+ try:
1078
+ # Attempt streaming response with granular event handling
1079
+ response_content = ""
1080
+ current_status = ""
1081
+
1082
+ async for chunk in await self.agent.run_async(
1083
+ user_content, stream=True
1084
+ ):
1085
+ chunk_type = getattr(chunk, "chunk_type", "text")
1086
+ chunk_content = getattr(chunk, "content", str(chunk))
1087
+ chunk_metadata = getattr(chunk, "metadata", {})
1088
+
1089
+ # Handle different chunk types
1090
+ if chunk_type == "step_start":
1091
+ # Show step indicator
1092
+ current_status = f"🔄 {chunk_content}"
1093
+ status_message = MessageData(
1094
+ type=MessageType.PROGRESS,
1095
+ message=MessageContent(current_status),
1096
+ options={"streaming": True, "step_start": True},
1097
+ )
1098
+ self.messages = [*self.messages[:-1], status_message]
1099
+
1100
+ elif chunk_type == "thinking":
1101
+ # Accumulate thinking content (LLM response)
1102
+ response_content += chunk_content
1103
+ streaming_message = MessageData(
1104
+ type=MessageType.ASSISTANT,
1105
+ message=MessageContent(
1106
+ f"{current_status}\n\n{response_content}"
1107
+ if current_status
1108
+ else response_content
1109
+ ),
1110
+ options={"streaming": True},
1111
+ )
1112
+ self.messages = [*self.messages[:-1], streaming_message]
1113
+
1114
+ elif chunk_type == "code_start":
1115
+ # Show code execution indicator
1116
+ code_preview = chunk_metadata.get(
1117
+ "code_preview", chunk_content[:100]
1118
+ )
1119
+ exec_status = f"⚙️ Executing code...\n```python\n{code_preview}\n```"
1120
+ code_message = MessageData(
1121
+ type=MessageType.PROGRESS,
1122
+ message=MessageContent(
1123
+ f"{response_content}\n\n{exec_status}"
1124
+ ),
1125
+ options={"streaming": True, "code_executing": True},
1126
+ )
1127
+ self.messages = [*self.messages[:-1], code_message]
1128
+
1129
+ elif chunk_type == "code_result":
1130
+ # Show code execution result
1131
+ success = chunk_metadata.get("success", True)
1132
+ if success:
1133
+ result_status = (
1134
+ f"✅ Code executed:\n{chunk_content}"
1135
+ )
1136
+ else:
1137
+ result_status = (
1138
+ f"❌ Execution error:\n{chunk_content}"
1139
+ )
1140
+
1141
+ result_message = MessageData(
1142
+ type=MessageType.ASSISTANT,
1143
+ message=MessageContent(
1144
+ f"{response_content}\n\n{result_status}"
1145
+ ),
1146
+ options={"streaming": True, "code_result": True},
1147
+ )
1148
+ self.messages = [*self.messages[:-1], result_message]
1149
+
1150
+ elif chunk_type in (
1151
+ "agent_response",
1152
+ "final_answer",
1153
+ "completion",
1154
+ ):
1155
+ # Final response - extract answer
1156
+ final_content = (
1157
+ getattr(chunk, "answer", chunk_content)
1158
+ or chunk_content
1159
+ )
1160
+ response_content = str(final_content)
1161
+ final_message = MessageData(
1162
+ type=MessageType.ASSISTANT,
1163
+ message=MessageContent(response_content),
1164
+ options={"streaming": True},
1165
+ )
1166
+ self.messages = [*self.messages[:-1], final_message]
1167
+
1168
+ else:
1169
+ # Default: accumulate as text
1170
+ response_content += chunk_content
1171
+ streaming_message = MessageData(
1172
+ type=MessageType.ASSISTANT,
1173
+ message=MessageContent(response_content),
1174
+ options={"streaming": True},
1175
+ )
1176
+ self.messages = [*self.messages[:-1], streaming_message]
1177
+
1178
+ # Update UI
1179
+ try:
1180
+ messages_component = self.query_one(
1181
+ "#messages_container", expect_type=Messages
1182
+ )
1183
+ messages_component.update_messages(self.messages)
1184
+ except Exception:
1185
+ self.refresh() # Fallback to full refresh
1186
+
1187
+ # Finalize the streaming message
1188
+ final_message = MessageData(
1189
+ type=MessageType.ASSISTANT,
1190
+ message=MessageContent(response_content),
1191
+ options={}, # Remove streaming flag
1192
+ )
1193
+ self.messages = [*self.messages[:-1], final_message]
1194
+ messages_component = self.query_one(
1195
+ "#messages_container", expect_type=Messages
1196
+ )
1197
+ messages_component.update_messages(self.messages)
1198
+
1199
+ # Save assistant response to session
1200
+ if response_content:
1201
+ self._save_message_to_session("assistant", response_content)
1202
+
1203
+ except Exception as e:
1204
+ raise
1205
+ else:
1206
+ # Agent doesn't support async, show error
1207
+ error_message = MessageData(
1208
+ type=MessageType.ASSISTANT,
1209
+ message=MessageContent(
1210
+ "❌ Agent does not support async operations"
1211
+ ),
1212
+ options={"error": True},
1213
+ )
1214
+ self.messages = [*self.messages[:-1], error_message]
1215
+
1216
+ # Handle Koding mode special case
1217
+ if new_messages[-1].options and new_messages[-1].options.get(
1218
+ "isKodingRequest"
1219
+ ):
1220
+ await self.handle_koding_response(self.messages[-1])
1221
+
1222
+ except Exception as e:
1223
+ # Format error message for UI display
1224
+ error_text = self._format_error_for_ui(e)
1225
+
1226
+ error_message = MessageData(
1227
+ type=MessageType.ASSISTANT,
1228
+ message=MessageContent(error_text),
1229
+ options={"error": True},
1230
+ )
1231
+ # Replace thinking message with error
1232
+ self.messages = [*self.messages[:-1], error_message]
1233
+
1234
+ # Update Messages component
1235
+ try:
1236
+ messages_component = self.query_one(
1237
+ "#messages_container", expect_type=Messages
1238
+ )
1239
+ messages_component.update_messages(self.messages)
1240
+ except Exception:
1241
+ self.refresh() # Fallback to full refresh
1242
+
1243
+ except Exception as e:
1244
+ # Show error message to user
1245
+ error_text = self._format_error_for_ui(e)
1246
+ error_message = MessageData(
1247
+ type=MessageType.ASSISTANT,
1248
+ message=MessageContent(error_text),
1249
+ options={"error": True},
1250
+ )
1251
+
1252
+ # Replace thinking/streaming message with error
1253
+ if self.messages and (
1254
+ self.messages[-1].options.get("streaming")
1255
+ or self.messages[-1].options.get("temporary")
1256
+ ):
1257
+ self.messages = [*self.messages[:-1], error_message]
1258
+ else:
1259
+ self.messages = [*self.messages, error_message]
1260
+
1261
+ # Update Messages component
1262
+ try:
1263
+ messages_component = self.query_one(
1264
+ "#messages_container", expect_type=Messages
1265
+ )
1266
+ messages_component.update_messages(self.messages)
1267
+ except Exception:
1268
+ self.refresh() # Fallback to full refresh
1269
+
1270
+ finally:
1271
+ self.is_loading = False
1272
+
1273
+ # Final UI update to remove loading indicators
1274
+ try:
1275
+ messages_component = self.query_one(
1276
+ "#messages_container", expect_type=Messages
1277
+ )
1278
+ messages_component.update_messages(self.messages)
1279
+ except Exception:
1280
+ self.refresh() # Fallback to full refresh
1281
+
1282
+ async def handle_koding_response(self, assistant_message: MessageData):
438
1283
  """Handle Koding mode response - equivalent to handleHashCommand"""
439
- logger.info("Handling Koding response")
440
-
1284
+
441
1285
  content = assistant_message.message.content
442
1286
  if isinstance(content, str) and content.strip():
443
1287
  # Save to AGENTS.md (equivalent to handleHashCommand)
@@ -445,147 +1289,343 @@ class REPL(Container):
445
1289
  agents_md_path = Path("AGENTS.md")
446
1290
  if agents_md_path.exists():
447
1291
  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")
1292
+ f.write(
1293
+ f"\n\n## Response - {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n"
1294
+ )
449
1295
  f.write(content)
450
1296
  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
-
1297
+ except Exception:
1298
+ pass # Silently handle file write errors
1299
+
455
1300
  def add_to_history(self, command: str):
456
1301
  """Add command to history - equivalent to addToHistory"""
457
1302
  # This would integrate with the history system
458
- logger.info(f"Added to history: {command[:50]}...")
459
-
1303
+ pass
1304
+
460
1305
  def on_cancel(self):
461
1306
  """Cancel current operation - equivalent to onCancel function"""
462
1307
  if not self.is_loading:
463
1308
  return
464
-
1309
+
465
1310
  self.is_loading = False
466
-
1311
+ self.loading = False
1312
+
467
1313
  if self.tool_use_confirm:
468
1314
  self.tool_use_confirm.on_abort()
469
1315
  elif self.abort_controller:
470
1316
  self.abort_controller.cancel()
471
-
472
- logger.info("Operation cancelled")
473
-
1317
+
474
1318
  # 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
-
1319
+ def on_add_user_message_from_prompt(self, user_message: MessageData):
1320
+ """Handle immediate user message display (synchronous)"""
1321
+ # 立即显示用户消息 - 同步操作,不等待任何异步处理
1322
+ self.messages = [*self.messages, user_message]
1323
+
1324
+ # Save user message to session
1325
+ user_content = user_message.message.content
1326
+ if isinstance(user_content, str):
1327
+ self._save_message_to_session("user", user_content)
1328
+
1329
+ # 立即更新UI显示用户消息
1330
+ try:
1331
+ messages_component = self.query_one(
1332
+ "#messages_container", expect_type=Messages
1333
+ )
1334
+ messages_component.update_messages(self.messages)
1335
+ except Exception:
1336
+ self.refresh() # Fallback to full refresh
1337
+
1338
+ async def on_query_from_prompt(
1339
+ self, messages: List[MessageData], abort_controller=None
1340
+ ):
1341
+ """Handle AI query processing (user message already displayed)"""
1342
+ # 用户消息已经通过 on_add_user_message_from_prompt 显示了
1343
+ # 这里只处理AI响应
1344
+ self.run_worker(
1345
+ self._process_ai_response(messages, abort_controller), exclusive=False
1346
+ )
1347
+
1348
+ async def _process_ai_response(
1349
+ self, user_messages: List[MessageData], abort_controller=None
1350
+ ):
1351
+ """Process AI response in background worker"""
1352
+ try:
1353
+ # Use passed AbortController or create new one
1354
+ controller_to_use = abort_controller or asyncio.create_task(
1355
+ asyncio.sleep(0)
1356
+ )
1357
+ if not abort_controller:
1358
+ self.abort_controller = controller_to_use
1359
+
1360
+ # Query API for AI response (query_api handles its own loading state)
1361
+ await self.query_api(user_messages)
1362
+
1363
+ except Exception as e:
1364
+ # Handle errors in background processing
1365
+ error_message = MessageData(
1366
+ type=MessageType.ASSISTANT,
1367
+ message=MessageContent(f"❌ Error processing request: {str(e)}"),
1368
+ options={"error": True},
1369
+ )
1370
+ self.messages = [*self.messages, error_message]
1371
+
1372
+ # Update UI with error
1373
+ try:
1374
+ messages_component = self.query_one(
1375
+ "#messages_container", expect_type=Messages
1376
+ )
1377
+ messages_component.update_messages(self.messages)
1378
+ except Exception:
1379
+ self.refresh()
1380
+
1381
+ # Clear loading state on error
1382
+ self.is_loading = False
1383
+
490
1384
  def on_input_change_from_prompt(self, value: str):
491
1385
  """Handle input change from PromptInput"""
492
1386
  self.input_value = value
493
-
1387
+
494
1388
  def on_mode_change_from_prompt(self, mode: InputMode):
495
1389
  """Handle mode change from PromptInput"""
496
1390
  self.input_mode = mode
497
-
1391
+
498
1392
  def on_submit_count_change_from_prompt(self, updater):
499
1393
  """Handle submit count change from PromptInput"""
500
1394
  if callable(updater):
501
1395
  self.submit_count = updater(self.submit_count)
502
1396
  else:
503
1397
  self.submit_count = updater
504
-
1398
+
505
1399
  def set_loading_from_prompt(self, is_loading: bool):
506
1400
  """Set loading state from PromptInput"""
507
1401
  self.is_loading = is_loading
508
-
1402
+
509
1403
  def set_abort_controller_from_prompt(self, controller):
510
1404
  """Set abort controller from PromptInput"""
511
1405
  self.abort_controller = controller
512
-
1406
+
513
1407
  def show_message_selector(self):
514
1408
  """Show message selector from PromptInput"""
515
1409
  self.is_message_selector_visible = True
516
-
517
- def set_fork_convo_messages(self, messages: List[Message]):
1410
+
1411
+ def set_fork_convo_messages(self, messages: List[MessageData]):
518
1412
  """Set fork conversation messages from PromptInput"""
519
1413
  self.fork_convo_with_messages_on_next_render = messages
520
-
1414
+
521
1415
  def on_model_change_from_prompt(self):
522
1416
  """Handle model change from PromptInput"""
523
1417
  self.fork_number += 1
524
- logger.info("Model changed, incrementing fork number")
525
-
1418
+
526
1419
  def set_tool_jsx_from_prompt(self, tool_jsx):
527
1420
  """Set tool JSX from PromptInput"""
528
1421
  self.tool_jsx = tool_jsx
529
-
1422
+
1423
+ async def on_execute_command_from_prompt(self, command_name: str, args: str):
1424
+ """
1425
+ Execute a slash command (e.g., /clear, /help, /tools).
1426
+ Handles different command types:
1427
+ - LOCAL: Direct execution, returns result immediately
1428
+ - LOCAL_JSX: Requires UI interaction (dialogs, confirmations)
1429
+ - PROMPT: Replaces user input and sends to LLM for processing
1430
+ """
1431
+ from minion_code.commands import command_registry, CommandType
1432
+
1433
+ # Get command class from registry
1434
+ command_class = command_registry.get_command(command_name)
1435
+
1436
+ if not command_class:
1437
+ # Unknown command - show error
1438
+ error_message = MessageData(
1439
+ type=MessageType.ASSISTANT,
1440
+ message=MessageContent(
1441
+ f"❌ Unknown command: /{command_name}\n💡 Use '/help' to see available commands"
1442
+ ),
1443
+ options={"error": True},
1444
+ )
1445
+ self.messages = [*self.messages, error_message]
1446
+ self._refresh_messages()
1447
+ return
1448
+
1449
+ # Handle different command types
1450
+ command_type = getattr(command_class, "command_type", CommandType.LOCAL)
1451
+ is_skill = getattr(command_class, "is_skill", False)
1452
+
1453
+ if command_type == CommandType.PROMPT:
1454
+ # PROMPT type: Replace user input and send to LLM
1455
+ # Create command instance to get the expanded prompt
1456
+ command_instance = command_class(self.output_adapter, self.agent)
1457
+ try:
1458
+ expanded_prompt = await command_instance.get_prompt(args)
1459
+
1460
+ # Add as user message and send to LLM
1461
+ user_message = MessageData(
1462
+ type=MessageType.USER,
1463
+ message=MessageContent(expanded_prompt),
1464
+ options={"from_command": command_name},
1465
+ )
1466
+ self.messages = [*self.messages, user_message]
1467
+ self._refresh_messages()
1468
+
1469
+ # Process through AI (this will show "Thinking..." as expected)
1470
+ await self.query_api([user_message])
1471
+
1472
+ except Exception as e:
1473
+ error_message = MessageData(
1474
+ type=MessageType.ASSISTANT,
1475
+ message=MessageContent(
1476
+ f"❌ Error expanding /{command_name}: {str(e)}"
1477
+ ),
1478
+ options={"error": True},
1479
+ )
1480
+ self.messages = [*self.messages, error_message]
1481
+ self._refresh_messages()
1482
+ return
1483
+
1484
+ # LOCAL and LOCAL_JSX types: Direct execution
1485
+ # Determine status message based on is_skill
1486
+ if is_skill:
1487
+ status_text = f"⚙️ /{command_name} skill is executing..."
1488
+ else:
1489
+ status_text = f"⚙️ /{command_name} is executing..."
1490
+
1491
+ status_message = MessageData(
1492
+ type=MessageType.PROGRESS,
1493
+ message=MessageContent(status_text),
1494
+ options={"command": True},
1495
+ )
1496
+ self.messages = [*self.messages, status_message]
1497
+ self._refresh_messages()
1498
+
1499
+ try:
1500
+ # Create command instance with TextualOutputAdapter
1501
+ command_instance = command_class(self.output_adapter, self.agent)
1502
+
1503
+ # Special handling for quit command
1504
+ if command_name in ["quit", "exit", "q", "bye"]:
1505
+ command_instance._tui_instance = self
1506
+
1507
+ # Execute the command
1508
+ await command_instance.execute(args)
1509
+
1510
+ # Remove the status message after successful execution
1511
+ # (command output is handled by output_adapter callbacks)
1512
+ if self.messages and self.messages[-1].options.get("command"):
1513
+ self.messages = self.messages[:-1]
1514
+ self._refresh_messages()
1515
+
1516
+ except Exception as e:
1517
+ # Show error message
1518
+ error_message = MessageData(
1519
+ type=MessageType.ASSISTANT,
1520
+ message=MessageContent(f"❌ Error executing /{command_name}: {str(e)}"),
1521
+ options={"error": True},
1522
+ )
1523
+ # Replace status message with error
1524
+ self.messages = [*self.messages[:-1], error_message]
1525
+ self._refresh_messages()
1526
+
1527
+ def show_prompt_input(self):
1528
+ """Show the prompt input component"""
1529
+ self.should_show_prompt_input = True
1530
+
1531
+ def hide_prompt_input(self):
1532
+ """Hide the prompt input component"""
1533
+ self.should_show_prompt_input = False
1534
+
1535
+ def toggle_prompt_input(self):
1536
+ """Toggle the prompt input component visibility"""
1537
+ self.should_show_prompt_input = not self.should_show_prompt_input
1538
+
530
1539
  @on(Button.Pressed, "#acknowledge_btn")
531
1540
  def acknowledge_cost_dialog(self):
532
1541
  """Acknowledge cost threshold dialog"""
533
1542
  self.show_cost_dialog = False
534
1543
  self.have_shown_cost_dialog = True
535
1544
  self.config.has_acknowledged_cost_threshold = True
536
- logger.info("Cost threshold acknowledged")
537
-
538
- def normalize_messages(self) -> List[Message]:
1545
+
1546
+ def normalize_messages(self) -> List[MessageData]:
539
1547
  """Normalize messages - equivalent to normalizeMessages function"""
540
1548
  # Filter out empty messages and normalize structure
541
1549
  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:
1550
+
1551
+ def is_not_empty_message(self, message: MessageData) -> bool:
544
1552
  """Check if message is not empty - equivalent to isNotEmptyMessage"""
545
1553
  if isinstance(message.message.content, str):
546
1554
  return bool(message.message.content.strip())
547
1555
  return bool(message.message.content)
548
-
1556
+
549
1557
  def get_unresolved_tool_use_ids(self) -> Set[str]:
550
1558
  """Get unresolved tool use IDs - equivalent to getUnresolvedToolUseIDs"""
551
1559
  # This would analyze messages for unresolved tool uses
552
1560
  return set()
553
-
1561
+
554
1562
  def get_in_progress_tool_use_ids(self) -> Set[str]:
555
1563
  """Get in-progress tool use IDs - equivalent to getInProgressToolUseIDs"""
556
1564
  # This would analyze messages for in-progress tool uses
557
1565
  return set()
558
-
1566
+
559
1567
  def get_errored_tool_use_ids(self) -> Set[str]:
560
1568
  """Get errored tool use IDs - equivalent to getErroredToolUseMessages"""
561
1569
  # This would analyze messages for errored tool uses
562
1570
  return set()
563
-
1571
+
1572
+ def _format_error_for_ui(self, error: Exception) -> str:
1573
+ """Format error message for UI display with appropriate context"""
1574
+ error_type = type(error).__name__
1575
+ error_msg = str(error)
1576
+
1577
+ # Handle common error types with user-friendly messages
1578
+ if "ImportError" in error_type or "ModuleNotFoundError" in error_type:
1579
+ return f"❌ Module Error: {error_msg}\n💡 Try installing missing dependencies or check your environment setup."
1580
+
1581
+ elif "ConnectionError" in error_type or "TimeoutError" in error_type:
1582
+ return f"❌ Connection Error: {error_msg}\n💡 Check your internet connection or API configuration."
1583
+
1584
+ elif "PermissionError" in error_type:
1585
+ return f"❌ Permission Error: {error_msg}\n💡 Check file permissions or run with appropriate privileges."
1586
+
1587
+ elif "FileNotFoundError" in error_type:
1588
+ return f"❌ File Not Found: {error_msg}\n💡 Verify the file path exists and is accessible."
1589
+
1590
+ elif "ValueError" in error_type or "TypeError" in error_type:
1591
+ return f"❌ Input Error: {error_msg}\n💡 Please check your input format and try again."
1592
+
1593
+ else:
1594
+ # Generic error with helpful context
1595
+ return f"❌ {error_type}: {error_msg}\n💡 If this error persists, please check the logs for more details."
1596
+
564
1597
  # Reactive property watchers (equivalent to React useEffect)
565
1598
  def watch_fork_number(self, fork_number: int):
566
1599
  """Watch fork number changes"""
567
- logger.info(f"Fork number changed to: {fork_number}")
568
-
1600
+ pass
1601
+
569
1602
  def watch_is_loading(self, is_loading: bool):
570
1603
  """Watch loading state changes"""
571
- logger.info(f"Loading state changed to: {is_loading}")
572
-
573
- def watch_messages(self, messages: List[Message]):
1604
+ pass
1605
+
1606
+ def watch_should_show_prompt_input(self, should_show: bool):
1607
+ """Watch prompt input visibility changes"""
1608
+ # This will trigger recomposition when the property changes
1609
+ pass
1610
+
1611
+ def watch_messages(self, messages: List[MessageData]):
574
1612
  """Watch messages changes - equivalent to useEffect([messages], ...)"""
575
- logger.info(f"Messages updated, count: {len(messages)}")
576
-
1613
+ pass
1614
+
577
1615
  # Check cost threshold (equivalent to cost threshold useEffect)
578
1616
  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):
1617
+ if (
1618
+ total_cost >= 5.0
1619
+ and not self.show_cost_dialog
1620
+ and not self.have_shown_cost_dialog
1621
+ ):
582
1622
  self.show_cost_dialog = True
583
-
1623
+
584
1624
  def get_total_cost(self) -> float:
585
1625
  """Get total API cost - equivalent to getTotalCost"""
586
1626
  # This would calculate actual API costs
587
1627
  return len(self.messages) * 0.01 # Mock cost calculation
588
-
1628
+
589
1629
  def _get_mode_prefix(self) -> str:
590
1630
  """Get the mode prefix character"""
591
1631
  if self.input_mode == InputMode.BASH:
@@ -594,226 +1634,70 @@ class REPL(Container):
594
1634
  return "#"
595
1635
  else:
596
1636
  return ">"
597
-
1637
+
598
1638
  # Simplified event handlers for debugging
599
1639
  @on(Input.Changed, "#simple_input")
600
1640
  def on_simple_input_changed(self, event):
601
1641
  """Handle simple input changes"""
602
1642
  self.input_value = event.value
603
- logger.info(f"Input changed: {event.value}")
604
-
1643
+
605
1644
  @on(Input.Submitted, "#simple_input")
606
1645
  @on(Button.Pressed, "#simple_send")
607
1646
  async def on_simple_submit(self, event):
608
1647
  """Handle simple input submission"""
609
1648
  input_widget = self.query_one("#simple_input", expect_type=Input)
610
1649
  input_text = input_widget.value.strip()
611
-
1650
+
612
1651
  if not input_text:
613
1652
  return
614
-
615
- logger.info(f"Submitting: {input_text}")
616
-
1653
+
617
1654
  # Add user message to display
618
- user_message = Message(
1655
+ user_message = MessageData(
619
1656
  type=MessageType.USER,
620
1657
  message=MessageContent(input_text),
621
- options={"mode": self.input_mode.value}
1658
+ options={"mode": self.input_mode.value},
622
1659
  )
623
1660
  self.messages = [*self.messages, user_message]
624
-
1661
+
625
1662
  # Create simple response
626
1663
  response_text = f"Received: {input_text} (mode: {self.input_mode.value})"
627
- assistant_message = Message(
628
- type=MessageType.ASSISTANT,
629
- message=MessageContent(response_text)
1664
+ assistant_message = MessageData(
1665
+ type=MessageType.ASSISTANT, message=MessageContent(response_text)
630
1666
  )
631
1667
  self.messages = [*self.messages, assistant_message]
632
-
1668
+
633
1669
  # Clear input
634
1670
  input_widget.value = ""
635
1671
  self.input_value = ""
636
-
1672
+
637
1673
  # Keep focus
638
1674
  input_widget.focus()
639
-
1675
+
640
1676
  @on(Button.Pressed, "#simple_mode")
641
1677
  def on_simple_mode_change(self):
642
1678
  """Handle mode change"""
643
1679
  modes = list(InputMode)
644
1680
  current_index = modes.index(self.input_mode)
645
1681
  self.input_mode = modes[(current_index + 1) % len(modes)]
646
-
1682
+
647
1683
  # Update mode indicator
648
1684
  try:
649
1685
  mode_indicator = self.query_one("#mode_indicator", expect_type=Static)
650
1686
  mode_indicator.update(f" {self._get_mode_prefix()} ")
651
-
1687
+
652
1688
  # Update input placeholder
653
1689
  input_widget = self.query_one("#simple_input", expect_type=Input)
654
1690
  input_widget.placeholder = f"Enter {self.input_mode.value} command..."
655
1691
  except:
656
1692
  pass
657
-
658
- logger.info(f"Mode changed to: {self.input_mode.value}")
659
1693
 
660
1694
 
661
1695
  class REPLApp(App):
662
1696
  """
663
1697
  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
- }
1698
+ Provides the application context, agent management, and styling
815
1699
  """
816
-
1700
+
817
1701
  def __init__(self, **kwargs):
818
1702
  super().__init__(**kwargs)
819
1703
  # Initialize with default props (equivalent to React props)
@@ -831,26 +1715,90 @@ class REPLApp(App):
831
1715
  "mcp_clients": [],
832
1716
  "is_default_model": True,
833
1717
  "initial_update_version": None,
834
- "initial_update_commands": None
1718
+ "initial_update_commands": None,
1719
+ "resume_session_id": None,
1720
+ "continue_last": False,
835
1721
  }
836
-
1722
+
1723
+ # App-level agent management
1724
+ self.agent = None
1725
+ self.agent_ready = False
1726
+
837
1727
  def compose(self) -> ComposeResult:
838
1728
  """Compose the main application - equivalent to React App render"""
839
- yield Header(show_clock=True)
840
- yield REPL(**self.repl_props)
1729
+ yield Header(show_clock=False)
1730
+ # Pass agent to REPL component (filter out app-level props like 'model')
1731
+ repl_props_filtered = {k: v for k, v in self.repl_props.items() if k != "model"}
1732
+ repl_props_with_agent = {**repl_props_filtered, "agent": self.agent}
1733
+ yield REPL(**repl_props_with_agent)
841
1734
  yield Footer()
842
-
1735
+
843
1736
  def on_mount(self):
844
1737
  """Application mount lifecycle"""
845
1738
  self.title = "Minion Code Assistant"
846
- logger.info("REPL Application started")
1739
+ # Initialize agent at app level
1740
+ self.run_worker(self._initialize_agent())
1741
+
1742
+ async def _initialize_agent(self):
1743
+ """Initialize the MinionCodeAgent at app level"""
1744
+ try:
1745
+ from minion_code import MinionCodeAgent
1746
+ from minion_code.utils.logs import logger
1747
+ from minion_code.agents.hooks import create_default_hooks
1748
+
1749
+ # Check for model from CLI or use default
1750
+ # Users can override with --model flag or config
1751
+ model_from_props = self.repl_props.get("model")
1752
+ default_llm = model_from_props if model_from_props else "claude-sonnet-4-5"
1753
+
1754
+ # Get REPL component's output adapter for permission dialogs
1755
+ try:
1756
+ repl_component = self.query_one(REPL)
1757
+ output_adapter = repl_component.output_adapter
1758
+ hooks = create_default_hooks(output_adapter)
1759
+ logger.info(
1760
+ "Created hooks with TextualOutputAdapter for permission dialogs"
1761
+ )
1762
+ except Exception as e:
1763
+ logger.warning(
1764
+ f"Could not get output adapter, using autonomous hooks: {e}"
1765
+ )
1766
+ from minion_code.agents.hooks import create_autonomous_hooks
1767
+
1768
+ hooks = create_autonomous_hooks()
1769
+
1770
+ logger.info(f"Initializing agent with LLM: {default_llm}")
1771
+ self.agent = await MinionCodeAgent.create(
1772
+ name="REPL Assistant",
1773
+ llm=default_llm,
1774
+ hooks=hooks,
1775
+ # History decay: save large outputs to file after N steps
1776
+ decay_enabled=True,
1777
+ decay_ttl_steps=3,
1778
+ decay_min_size=100_000, # 100KB
1779
+ )
1780
+ self.agent_ready = True
1781
+
1782
+ logger.info(f"Agent initialized with {len(self.agent.tools)} tools")
1783
+
1784
+ # Update REPL component with agent
1785
+ try:
1786
+ repl_component = self.query_one(REPL)
1787
+ repl_component.set_agent(self.agent)
1788
+ logger.info("Agent set on REPL component")
1789
+ except Exception as e:
1790
+ logger.warning(f"Could not set agent on REPL: {e}")
1791
+
1792
+ except Exception as e:
1793
+ from minion_code.utils.logs import logger
1794
+
1795
+ logger.error(f"Failed to initialize agent: {e}")
1796
+ self.agent_ready = False
847
1797
 
848
1798
 
849
1799
  # Utility functions equivalent to TypeScript utility functions
850
1800
  def should_render_statically(
851
- message: Message,
852
- messages: List[Message],
853
- unresolved_tool_use_ids: Set[str]
1801
+ message: MessageData, messages: List[MessageData], unresolved_tool_use_ids: Set[str]
854
1802
  ) -> bool:
855
1803
  """
856
1804
  Determine if message should render statically
@@ -864,6 +1812,7 @@ def should_render_statically(
864
1812
  return len(unresolved_tool_use_ids) == 0
865
1813
  return True
866
1814
 
1815
+
867
1816
  def intersects(set_a: Set[str], set_b: Set[str]) -> bool:
868
1817
  """Check if two sets intersect - equivalent to intersects function"""
869
1818
  return len(set_a & set_b) > 0
@@ -876,42 +1825,77 @@ def create_repl(
876
1825
  debug=False,
877
1826
  initial_prompt=None,
878
1827
  verbose=False,
879
- **kwargs
1828
+ resume_session_id=None,
1829
+ continue_last=False,
1830
+ **kwargs,
880
1831
  ) -> REPLApp:
881
1832
  """
882
1833
  Create a configured REPL application
883
1834
  Equivalent to calling REPL component with props in React
884
1835
  """
1836
+ print(f"DEBUG create_repl: initial_prompt={initial_prompt}")
885
1837
  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
- })
1838
+ app.repl_props.update(
1839
+ {
1840
+ "commands": commands or [],
1841
+ "safe_mode": safe_mode,
1842
+ "debug": debug,
1843
+ "initial_prompt": initial_prompt,
1844
+ "verbose": verbose,
1845
+ "resume_session_id": resume_session_id,
1846
+ "continue_last": continue_last,
1847
+ **kwargs,
1848
+ }
1849
+ )
1850
+ print(f"DEBUG create_repl: repl_props={app.repl_props}")
894
1851
  return app
895
1852
 
896
1853
 
897
- def run(initial_prompt=None, debug=False, verbose=False):
1854
+ def run(
1855
+ initial_prompt=None,
1856
+ debug=False,
1857
+ verbose=False,
1858
+ resume_session_id=None,
1859
+ continue_last=False,
1860
+ model=None,
1861
+ ):
898
1862
  """Run the REPL application with optional configuration"""
1863
+ # File-based logging for TUI debugging
1864
+ import logging
1865
+
1866
+ logging.basicConfig(
1867
+ filename="/tmp/minion_repl_debug.log",
1868
+ level=logging.DEBUG,
1869
+ format="%(asctime)s - %(levelname)s - %(message)s",
1870
+ force=True, # Override any existing config
1871
+ )
1872
+ logging.debug(f"=== REPL run() called ===")
1873
+ logging.debug(f"initial_prompt: {repr(initial_prompt)}")
1874
+ logging.debug(f"debug: {debug}, verbose: {verbose}, model: {model}")
1875
+ logging.debug(
1876
+ f"resume_session_id: {resume_session_id}, continue_last: {continue_last}"
1877
+ )
1878
+
899
1879
  app = create_repl(
900
1880
  initial_prompt=initial_prompt,
901
1881
  debug=debug,
902
- verbose=verbose
1882
+ verbose=verbose,
1883
+ resume_session_id=resume_session_id,
1884
+ continue_last=continue_last,
1885
+ model=model,
903
1886
  )
1887
+ logging.debug(f"app.repl_props: {app.repl_props}")
904
1888
  app.run()
905
1889
 
906
1890
 
907
1891
  if __name__ == "__main__":
908
1892
  import sys
909
-
1893
+
910
1894
  # Parse command line arguments (basic implementation)
911
1895
  initial_prompt = None
912
1896
  debug = False
913
1897
  verbose = False
914
-
1898
+
915
1899
  if len(sys.argv) > 1:
916
1900
  if "--debug" in sys.argv:
917
1901
  debug = True
@@ -921,5 +1905,5 @@ if __name__ == "__main__":
921
1905
  prompt_index = sys.argv.index("--prompt")
922
1906
  if prompt_index + 1 < len(sys.argv):
923
1907
  initial_prompt = sys.argv[prompt_index + 1]
924
-
925
- run(initial_prompt=initial_prompt, debug=debug, verbose=verbose)
1908
+
1909
+ run(initial_prompt=initial_prompt, debug=debug, verbose=verbose)