repl-toolkit 1.0.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.
Potentially problematic release.
This version of repl-toolkit might be problematic. Click here for more details.
- repl_toolkit/__init__.py +58 -0
- repl_toolkit/actions/__init__.py +25 -0
- repl_toolkit/actions/action.py +217 -0
- repl_toolkit/actions/registry.py +538 -0
- repl_toolkit/actions/shell.py +62 -0
- repl_toolkit/async_repl.py +367 -0
- repl_toolkit/headless_repl.py +247 -0
- repl_toolkit/ptypes.py +119 -0
- repl_toolkit/tests/__init__.py +5 -0
- repl_toolkit/tests/test_actions.py +453 -0
- repl_toolkit/tests/test_async_repl.py +376 -0
- repl_toolkit/tests/test_headless.py +682 -0
- repl_toolkit/tests/test_types.py +173 -0
- repl_toolkit-1.0.0.dist-info/METADATA +641 -0
- repl_toolkit-1.0.0.dist-info/RECORD +18 -0
- repl_toolkit-1.0.0.dist-info/WHEEL +5 -0
- repl_toolkit-1.0.0.dist-info/licenses/LICENSE +21 -0
- repl_toolkit-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Async REPL interface with action support for repl_toolkit.
|
|
3
|
+
|
|
4
|
+
Provides an interactive chat interface with full UI features including
|
|
5
|
+
history, action handling (commands + keyboard shortcuts), and
|
|
6
|
+
robust cancellation of long-running tasks.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import sys
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from loguru import logger
|
|
15
|
+
from prompt_toolkit import HTML, PromptSession, print_formatted_text as print
|
|
16
|
+
from prompt_toolkit.application import Application
|
|
17
|
+
from prompt_toolkit.input import create_input
|
|
18
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
19
|
+
from prompt_toolkit.output import DummyOutput
|
|
20
|
+
from prompt_toolkit.completion import Completer
|
|
21
|
+
from prompt_toolkit.keys import Keys
|
|
22
|
+
from prompt_toolkit.history import FileHistory
|
|
23
|
+
|
|
24
|
+
from .ptypes import AsyncBackend, ActionHandler, Completer
|
|
25
|
+
from .actions import ActionRegistry, ActionContext
|
|
26
|
+
|
|
27
|
+
THINKING = HTML("<i><grey>Thinking... (Press Alt+C to cancel)</grey></i>")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AsyncREPL:
|
|
31
|
+
"""
|
|
32
|
+
Manages an interactive async REPL session with action support.
|
|
33
|
+
|
|
34
|
+
Provides user input handling, action processing (commands and shortcuts),
|
|
35
|
+
and robust cancellation of long-running tasks with a clean, extensible interface.
|
|
36
|
+
|
|
37
|
+
The AsyncREPL supports late backend binding, allowing initialization without
|
|
38
|
+
a backend for scenarios where the backend is only available within a resource
|
|
39
|
+
context block.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
action_registry: Optional[ActionHandler] = None,
|
|
45
|
+
completer: Optional[Completer] = None,
|
|
46
|
+
prompt_string: Optional[str] = None,
|
|
47
|
+
history_path: Optional[Path] = None
|
|
48
|
+
):
|
|
49
|
+
"""
|
|
50
|
+
Initialize the async REPL interface.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
action_registry: Action registry for commands and shortcuts (optional)
|
|
54
|
+
completer: Optional tab-completion provider
|
|
55
|
+
prompt_string: Custom prompt string (default: "User: ")
|
|
56
|
+
history_path: Optional path for command history storage
|
|
57
|
+
|
|
58
|
+
Note:
|
|
59
|
+
Backend is provided later via the run() method to support scenarios
|
|
60
|
+
where the backend is only available within a resource context.
|
|
61
|
+
"""
|
|
62
|
+
logger.trace("AsyncREPL.__init__() entry")
|
|
63
|
+
|
|
64
|
+
self.prompt_string = HTML(prompt_string or "User: ")
|
|
65
|
+
self.action_registry = action_registry or ActionRegistry()
|
|
66
|
+
self.session = PromptSession(
|
|
67
|
+
history=self._create_history(history_path),
|
|
68
|
+
key_bindings=self._create_key_bindings(),
|
|
69
|
+
multiline=True,
|
|
70
|
+
completer=completer,
|
|
71
|
+
)
|
|
72
|
+
self.main_app = self.session.app
|
|
73
|
+
|
|
74
|
+
logger.trace("AsyncREPL.__init__() exit")
|
|
75
|
+
|
|
76
|
+
def _create_history(self, path: Optional[Path]) -> Optional[FileHistory]:
|
|
77
|
+
"""
|
|
78
|
+
Create file history if path is provided.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
path: Optional path to history file
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
FileHistory instance or None
|
|
85
|
+
"""
|
|
86
|
+
logger.trace("AsyncREPL._create_history() entry")
|
|
87
|
+
|
|
88
|
+
if path: # pragma: no cover
|
|
89
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
result = FileHistory(str(path))
|
|
91
|
+
logger.trace("AsyncREPL._create_history() exit - with history")
|
|
92
|
+
return result
|
|
93
|
+
|
|
94
|
+
logger.trace("AsyncREPL._create_history() exit - no history")
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
def _create_key_bindings(self) -> KeyBindings:
|
|
98
|
+
"""
|
|
99
|
+
Create key bindings for the REPL session.
|
|
100
|
+
|
|
101
|
+
This method creates both built-in key bindings and dynamic bindings
|
|
102
|
+
from the action registry, providing a shortcut system.
|
|
103
|
+
|
|
104
|
+
Built-in Key Bindings:
|
|
105
|
+
- Enter: Add new line
|
|
106
|
+
- Alt+Enter: Send message
|
|
107
|
+
- Alt+C: Cancel operation (during processing)
|
|
108
|
+
|
|
109
|
+
Dynamic bindings are loaded from the action registry.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
KeyBindings instance with configured shortcuts
|
|
113
|
+
"""
|
|
114
|
+
logger.trace("AsyncREPL._create_key_bindings() entry")
|
|
115
|
+
|
|
116
|
+
bindings = KeyBindings()
|
|
117
|
+
|
|
118
|
+
# Built-in bindings for core REPL functionality
|
|
119
|
+
@bindings.add("enter") # pragma: no cover
|
|
120
|
+
def _(event):
|
|
121
|
+
"""Handle Enter key - add new line."""
|
|
122
|
+
event.app.current_buffer.insert_text("\n")
|
|
123
|
+
|
|
124
|
+
@bindings.add(Keys.Escape, "enter") # pragma: no cover
|
|
125
|
+
def _(event):
|
|
126
|
+
"""Handle Alt+Enter - send message."""
|
|
127
|
+
event.app.current_buffer.validate_and_handle()
|
|
128
|
+
|
|
129
|
+
# Register dynamic key bindings from action registry
|
|
130
|
+
self._register_action_shortcuts(bindings)
|
|
131
|
+
|
|
132
|
+
logger.trace("AsyncREPL._create_key_bindings() exit")
|
|
133
|
+
return bindings
|
|
134
|
+
|
|
135
|
+
def _register_action_shortcuts(self, bindings: KeyBindings) -> None:
|
|
136
|
+
"""
|
|
137
|
+
Register keyboard shortcuts from the action registry.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
bindings: KeyBindings instance to add shortcuts to
|
|
141
|
+
"""
|
|
142
|
+
logger.trace("AsyncREPL._register_action_shortcuts() entry")
|
|
143
|
+
|
|
144
|
+
if not hasattr(self.action_registry, 'key_map'):
|
|
145
|
+
logger.trace("AsyncREPL._register_action_shortcuts() exit - no key_map")
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
for key_combo, action_name in self.action_registry.key_map.items():
|
|
149
|
+
self._register_shortcut(bindings, key_combo, action_name)
|
|
150
|
+
|
|
151
|
+
logger.trace("AsyncREPL._register_action_shortcuts() exit")
|
|
152
|
+
|
|
153
|
+
def _register_shortcut(self, bindings: KeyBindings, key_combo: str, action_name: str) -> None:
|
|
154
|
+
"""
|
|
155
|
+
Register a single keyboard shortcut.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
bindings: KeyBindings instance
|
|
159
|
+
key_combo: Key combination string (e.g., "F1", "ctrl-s")
|
|
160
|
+
action_name: Name of action to execute
|
|
161
|
+
"""
|
|
162
|
+
logger.trace("AsyncREPL._register_shortcut() entry")
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
# Parse key combination - handle common formats
|
|
166
|
+
keys = self._parse_key_combination(key_combo)
|
|
167
|
+
|
|
168
|
+
@bindings.add(*keys) # pragma: no cover
|
|
169
|
+
def _(event):
|
|
170
|
+
# Execute action synchronously
|
|
171
|
+
try:
|
|
172
|
+
self.action_registry.handle_shortcut(key_combo, event)
|
|
173
|
+
except Exception as e:
|
|
174
|
+
logger.error(f"Error executing shortcut '{key_combo}': {e}")
|
|
175
|
+
print(f"Error: {e}")
|
|
176
|
+
|
|
177
|
+
logger.debug(f"Registered shortcut '{key_combo}' -> '{action_name}'")
|
|
178
|
+
logger.trace("AsyncREPL._register_shortcut() exit - success")
|
|
179
|
+
|
|
180
|
+
except Exception as e: # pragma: no cover
|
|
181
|
+
logger.error(f"Failed to register shortcut '{key_combo}' for action '{action_name}': {e}")
|
|
182
|
+
logger.trace("AsyncREPL._register_shortcut() exit - error")
|
|
183
|
+
|
|
184
|
+
def _parse_key_combination(self, key_combo: str) -> tuple:
|
|
185
|
+
"""
|
|
186
|
+
Parse key combination string into prompt_toolkit format.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
key_combo: Key combination (e.g., "F1", "ctrl-s", "alt-enter")
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
Tuple of keys for prompt_toolkit
|
|
193
|
+
"""
|
|
194
|
+
logger.trace("AsyncREPL._parse_key_combination() entry")
|
|
195
|
+
|
|
196
|
+
# Handle common key formats
|
|
197
|
+
key_combo = key_combo.lower().strip()
|
|
198
|
+
|
|
199
|
+
# Single function keys
|
|
200
|
+
if key_combo.startswith('f') and key_combo[1:].isdigit():
|
|
201
|
+
logger.trace("AsyncREPL._parse_key_combination() exit - function key")
|
|
202
|
+
return (key_combo,)
|
|
203
|
+
|
|
204
|
+
# Handle modifier combinations
|
|
205
|
+
if '-' in key_combo:
|
|
206
|
+
parts = key_combo.split('-')
|
|
207
|
+
if len(parts) == 2:
|
|
208
|
+
modifier, key = parts
|
|
209
|
+
|
|
210
|
+
# Map common modifiers
|
|
211
|
+
if modifier == 'ctrl':
|
|
212
|
+
logger.trace("AsyncREPL._parse_key_combination() exit - ctrl combo")
|
|
213
|
+
return ('c-' + key,)
|
|
214
|
+
elif modifier == 'alt':
|
|
215
|
+
logger.trace("AsyncREPL._parse_key_combination() exit - alt combo")
|
|
216
|
+
return (Keys.Escape, key)
|
|
217
|
+
elif modifier == 'shift': # pragma: no cover
|
|
218
|
+
logger.trace("AsyncREPL._parse_key_combination() exit - shift combo")
|
|
219
|
+
return ('s-' + key,)
|
|
220
|
+
|
|
221
|
+
# Single keys
|
|
222
|
+
logger.trace("AsyncREPL._parse_key_combination() exit - single key")
|
|
223
|
+
return (key_combo,)
|
|
224
|
+
|
|
225
|
+
async def run(self, backend: AsyncBackend, initial_message: Optional[str] = None): # pragma: no cover
|
|
226
|
+
"""
|
|
227
|
+
Run the async REPL session with the provided backend.
|
|
228
|
+
|
|
229
|
+
This method accepts the backend at runtime, supporting scenarios where
|
|
230
|
+
the backend is only available within a resource context block.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
backend: Backend responsible for processing user input
|
|
234
|
+
initial_message: Optional message to process before starting loop
|
|
235
|
+
"""
|
|
236
|
+
logger.trace("AsyncREPL.run() entry")
|
|
237
|
+
|
|
238
|
+
# Set backend in action registry for action handlers to access
|
|
239
|
+
self.action_registry.backend = backend
|
|
240
|
+
|
|
241
|
+
if initial_message:
|
|
242
|
+
print(self.prompt_string, end="")
|
|
243
|
+
print(initial_message)
|
|
244
|
+
await self._process_input(initial_message, backend)
|
|
245
|
+
print()
|
|
246
|
+
|
|
247
|
+
while True:
|
|
248
|
+
try:
|
|
249
|
+
user_input = await self.session.prompt_async(self.prompt_string)
|
|
250
|
+
if self._should_exit(user_input):
|
|
251
|
+
break
|
|
252
|
+
if not user_input.strip():
|
|
253
|
+
continue
|
|
254
|
+
if user_input.strip().startswith("/"):
|
|
255
|
+
# Handle commands synchronously
|
|
256
|
+
self.action_registry.handle_command(user_input.strip())
|
|
257
|
+
continue
|
|
258
|
+
|
|
259
|
+
logger.debug(f"Processing user input: {user_input}")
|
|
260
|
+
await self._process_input(user_input, backend)
|
|
261
|
+
|
|
262
|
+
except (KeyboardInterrupt, EOFError):
|
|
263
|
+
print()
|
|
264
|
+
break
|
|
265
|
+
except Exception as e:
|
|
266
|
+
logger.error(f"Error in REPL loop: {e}")
|
|
267
|
+
print(f"An error occurred: {e}", file=sys.stderr)
|
|
268
|
+
|
|
269
|
+
logger.trace("AsyncREPL.run() exit")
|
|
270
|
+
|
|
271
|
+
def _should_exit(self, user_input: str) -> bool:
|
|
272
|
+
"""Check if input is an exit command."""
|
|
273
|
+
logger.trace("AsyncREPL._should_exit() entry/exit")
|
|
274
|
+
return user_input.strip().lower() in ["/exit", "/quit"]
|
|
275
|
+
|
|
276
|
+
async def _process_input(self, user_input: str, backend: AsyncBackend): # pragma: no cover
|
|
277
|
+
"""
|
|
278
|
+
Process user input with cancellation support.
|
|
279
|
+
|
|
280
|
+
Runs the backend processing task concurrently with a cancellation
|
|
281
|
+
listener, allowing users to cancel long-running operations.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
user_input: Input string to process
|
|
285
|
+
backend: Backend to process the input
|
|
286
|
+
"""
|
|
287
|
+
logger.trace("AsyncREPL._process_input() entry")
|
|
288
|
+
|
|
289
|
+
cancel_future = asyncio.Future()
|
|
290
|
+
|
|
291
|
+
kb = KeyBindings()
|
|
292
|
+
@kb.add("escape", "c")
|
|
293
|
+
def _(event):
|
|
294
|
+
if not cancel_future.done():
|
|
295
|
+
cancel_future.set_result(None)
|
|
296
|
+
event.app.exit()
|
|
297
|
+
|
|
298
|
+
cancel_app = Application(
|
|
299
|
+
key_bindings=kb, output=DummyOutput(), input=create_input()
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
backend_task = asyncio.create_task(backend.handle_input(user_input))
|
|
303
|
+
listener_task = asyncio.create_task(cancel_app.run_async())
|
|
304
|
+
print(THINKING)
|
|
305
|
+
|
|
306
|
+
try:
|
|
307
|
+
done, pending = await asyncio.wait(
|
|
308
|
+
[backend_task, cancel_future],
|
|
309
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
if cancel_future in done:
|
|
313
|
+
print("\nOperation cancelled by user.")
|
|
314
|
+
backend_task.cancel()
|
|
315
|
+
else:
|
|
316
|
+
success = backend_task.result()
|
|
317
|
+
if not success:
|
|
318
|
+
print("Operation failed.")
|
|
319
|
+
|
|
320
|
+
except Exception as e:
|
|
321
|
+
print(f"\nAn error occurred: {e}")
|
|
322
|
+
if not backend_task.done():
|
|
323
|
+
backend_task.cancel()
|
|
324
|
+
|
|
325
|
+
finally:
|
|
326
|
+
# Cleanup
|
|
327
|
+
if not cancel_app.is_done:
|
|
328
|
+
cancel_app.exit()
|
|
329
|
+
|
|
330
|
+
await listener_task
|
|
331
|
+
|
|
332
|
+
self.main_app.renderer.reset()
|
|
333
|
+
self.main_app.invalidate()
|
|
334
|
+
await asyncio.sleep(0)
|
|
335
|
+
|
|
336
|
+
logger.trace("AsyncREPL._process_input() exit")
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# Convenience function
|
|
340
|
+
async def run_async_repl( # pragma: no cover
|
|
341
|
+
backend: AsyncBackend,
|
|
342
|
+
action_registry: Optional[ActionHandler] = None,
|
|
343
|
+
completer: Optional[Completer] = None,
|
|
344
|
+
initial_message: Optional[str] = None,
|
|
345
|
+
prompt_string: Optional[str] = None,
|
|
346
|
+
history_path: Optional[Path] = None,
|
|
347
|
+
):
|
|
348
|
+
"""
|
|
349
|
+
Convenience function to create and run an AsyncREPL with action support.
|
|
350
|
+
|
|
351
|
+
This function creates an AsyncREPL instance and runs it with the provided
|
|
352
|
+
backend, supporting the late backend binding pattern.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
backend: Backend for processing input
|
|
356
|
+
action_registry: Action registry for commands and shortcuts (optional)
|
|
357
|
+
completer: Optional completer
|
|
358
|
+
initial_message: Optional initial message
|
|
359
|
+
prompt_string: Optional custom prompt
|
|
360
|
+
history_path: Optional history file path
|
|
361
|
+
"""
|
|
362
|
+
logger.trace("run_async_repl() entry")
|
|
363
|
+
|
|
364
|
+
repl = AsyncREPL(action_registry, completer, prompt_string, history_path)
|
|
365
|
+
await repl.run(backend, initial_message)
|
|
366
|
+
|
|
367
|
+
logger.trace("run_async_repl() exit")
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
from loguru import logger
|
|
3
|
+
|
|
4
|
+
from .ptypes import AsyncBackend
|
|
5
|
+
from .ptypes import ActionHandler
|
|
6
|
+
from .actions.registry import ActionRegistry
|
|
7
|
+
|
|
8
|
+
class HeadlessREPL:
|
|
9
|
+
"""
|
|
10
|
+
Headless REPL that reads from stdin and processes through action framework.
|
|
11
|
+
|
|
12
|
+
Similar to interactive mode but:
|
|
13
|
+
- Reads from stdin instead of prompt_toolkit
|
|
14
|
+
- Accumulates content in buffer until /send
|
|
15
|
+
- Processes commands through action system
|
|
16
|
+
- Supports multiple /send cycles
|
|
17
|
+
- Auto-sends remaining buffer on EOF
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, action_registry: Optional[ActionHandler] = None):
|
|
21
|
+
"""Initialize headless REPL."""
|
|
22
|
+
logger.trace("HeadlessREPL.__init__() entry")
|
|
23
|
+
|
|
24
|
+
# Simple string buffer for content accumulation
|
|
25
|
+
self.buffer = ""
|
|
26
|
+
self.action_registry = action_registry or ActionRegistry()
|
|
27
|
+
|
|
28
|
+
# State tracking
|
|
29
|
+
self.send_count = 0
|
|
30
|
+
self.total_success = True
|
|
31
|
+
self.running = True
|
|
32
|
+
|
|
33
|
+
logger.trace("HeadlessREPL.__init__() exit")
|
|
34
|
+
|
|
35
|
+
async def run(self, backend: AsyncBackend, initial_message: Optional[str] = None) -> bool:
|
|
36
|
+
"""
|
|
37
|
+
Run headless mode with stdin processing.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
backend: Backend for processing input
|
|
41
|
+
initial_message: Optional message to process before stdin loop
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
bool: True if all operations succeeded
|
|
45
|
+
"""
|
|
46
|
+
logger.trace("HeadlessREPL.run() entry")
|
|
47
|
+
|
|
48
|
+
# Set backend in action registry
|
|
49
|
+
self.action_registry.backend = backend
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
# Process initial message if provided
|
|
53
|
+
if initial_message:
|
|
54
|
+
logger.info(f"Processing initial message: {initial_message}")
|
|
55
|
+
success = await backend.handle_input(initial_message)
|
|
56
|
+
if not success:
|
|
57
|
+
logger.warning("Initial message processing failed")
|
|
58
|
+
self.total_success = False
|
|
59
|
+
|
|
60
|
+
# Enter stdin processing loop
|
|
61
|
+
await self._stdin_loop(backend)
|
|
62
|
+
|
|
63
|
+
logger.trace("HeadlessREPL.run() exit - success")
|
|
64
|
+
return self.total_success
|
|
65
|
+
|
|
66
|
+
except Exception as e:
|
|
67
|
+
logger.error(f"Error in headless processing: {e}")
|
|
68
|
+
logger.trace("HeadlessREPL.run() exit - exception")
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
async def _stdin_loop(self, backend: AsyncBackend):
|
|
72
|
+
"""
|
|
73
|
+
Process stdin with synchronous reading, async backend calls.
|
|
74
|
+
|
|
75
|
+
Reads lines from stdin synchronously (blocking), processes commands
|
|
76
|
+
through the action system, and accumulates content in buffer until
|
|
77
|
+
/send commands trigger backend processing.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
backend: Backend for processing accumulated content
|
|
81
|
+
"""
|
|
82
|
+
import sys
|
|
83
|
+
|
|
84
|
+
line_num = 0
|
|
85
|
+
|
|
86
|
+
while True:
|
|
87
|
+
# Simple synchronous readline - blocks until line available
|
|
88
|
+
# This is perfectly fine! We want to wait for the next line.
|
|
89
|
+
line = sys.stdin.readline()
|
|
90
|
+
|
|
91
|
+
if not line: # EOF
|
|
92
|
+
break
|
|
93
|
+
|
|
94
|
+
line_num += 1
|
|
95
|
+
line = line.rstrip('\n\r')
|
|
96
|
+
|
|
97
|
+
if line.startswith('/'):
|
|
98
|
+
if line == '/send':
|
|
99
|
+
await self._execute_send(backend, f"line {line_num}")
|
|
100
|
+
else:
|
|
101
|
+
# Synchronous command processing
|
|
102
|
+
self._execute_command(line)
|
|
103
|
+
else:
|
|
104
|
+
# Synchronous buffer addition
|
|
105
|
+
self._add_to_buffer(line)
|
|
106
|
+
|
|
107
|
+
await self._handle_eof(backend)
|
|
108
|
+
|
|
109
|
+
def _add_to_buffer(self, line: str):
|
|
110
|
+
"""
|
|
111
|
+
Add a line to the buffer.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
line: Line of text to add to the buffer
|
|
115
|
+
"""
|
|
116
|
+
logger.trace("HeadlessREPL._add_to_buffer() entry")
|
|
117
|
+
|
|
118
|
+
if self.buffer:
|
|
119
|
+
self.buffer += "\n" + line
|
|
120
|
+
else:
|
|
121
|
+
self.buffer = line
|
|
122
|
+
|
|
123
|
+
logger.debug(f"Added line to buffer, total length: {len(self.buffer)}")
|
|
124
|
+
logger.trace("HeadlessREPL._add_to_buffer() exit")
|
|
125
|
+
|
|
126
|
+
async def _execute_send(self, backend: AsyncBackend, context_info: str):
|
|
127
|
+
"""
|
|
128
|
+
Execute /send command - send buffer to backend and wait.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
backend: Backend to send buffer content to
|
|
132
|
+
context_info: Context information for logging (e.g., "line 5", "EOF")
|
|
133
|
+
"""
|
|
134
|
+
logger.trace("HeadlessREPL._execute_send() entry")
|
|
135
|
+
|
|
136
|
+
buffer_content = self.buffer.strip()
|
|
137
|
+
|
|
138
|
+
if not buffer_content:
|
|
139
|
+
logger.debug(f"Send #{self.send_count + 1} at {context_info}: empty buffer, skipping")
|
|
140
|
+
logger.trace("HeadlessREPL._execute_send() exit - empty buffer")
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
self.send_count += 1
|
|
144
|
+
logger.info(f"Send #{self.send_count} at {context_info}: sending {len(buffer_content)} characters")
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
# Send to backend and wait for completion
|
|
148
|
+
success = await backend.handle_input(buffer_content)
|
|
149
|
+
|
|
150
|
+
if success:
|
|
151
|
+
logger.info(f"Send #{self.send_count} completed successfully")
|
|
152
|
+
else:
|
|
153
|
+
logger.warning(f"Send #{self.send_count} completed with backend reporting failure")
|
|
154
|
+
self.total_success = False
|
|
155
|
+
|
|
156
|
+
# Clear buffer after send (successful or not)
|
|
157
|
+
self.buffer = ""
|
|
158
|
+
|
|
159
|
+
except Exception as e:
|
|
160
|
+
logger.error(f"Send #{self.send_count} failed with exception: {e}")
|
|
161
|
+
self.total_success = False
|
|
162
|
+
# Clear buffer even on exception to continue processing
|
|
163
|
+
self.buffer = ""
|
|
164
|
+
|
|
165
|
+
logger.trace("HeadlessREPL._execute_send() exit")
|
|
166
|
+
|
|
167
|
+
def _execute_command(self, command: str):
|
|
168
|
+
"""
|
|
169
|
+
Execute a command through the action system.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
command: Command string to execute (e.g., "/help", "/shell ls")
|
|
173
|
+
"""
|
|
174
|
+
logger.trace("HeadlessREPL._execute_command() entry")
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
self.action_registry.handle_command(command, headless_mode=True, buffer=self.buffer)
|
|
178
|
+
|
|
179
|
+
except Exception as e:
|
|
180
|
+
logger.error(f"Error executing command '{command}': {e}")
|
|
181
|
+
# Don't fail entire process for command errors
|
|
182
|
+
|
|
183
|
+
logger.trace("HeadlessREPL._execute_command() exit")
|
|
184
|
+
|
|
185
|
+
async def _handle_eof(self, backend: AsyncBackend):
|
|
186
|
+
"""
|
|
187
|
+
Handle end of stdin - send remaining buffer if not empty.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
backend: Backend to send remaining buffer content to
|
|
191
|
+
"""
|
|
192
|
+
logger.trace("HeadlessREPL._handle_eof() entry")
|
|
193
|
+
|
|
194
|
+
buffer_content = self.buffer.strip()
|
|
195
|
+
|
|
196
|
+
if buffer_content:
|
|
197
|
+
logger.info("EOF reached with non-empty buffer, triggering final send")
|
|
198
|
+
await self._execute_send(backend, "EOF")
|
|
199
|
+
else:
|
|
200
|
+
logger.debug("EOF reached with empty buffer")
|
|
201
|
+
|
|
202
|
+
logger.trace("HeadlessREPL._handle_eof() exit")
|
|
203
|
+
|
|
204
|
+
async def run_headless_mode(
|
|
205
|
+
backend: AsyncBackend,
|
|
206
|
+
action_registry: Optional[ActionHandler] = None,
|
|
207
|
+
initial_message: Optional[str] = None,
|
|
208
|
+
) -> bool:
|
|
209
|
+
"""
|
|
210
|
+
Run headless mode reading from stdin with action framework support.
|
|
211
|
+
|
|
212
|
+
Processes an optional initial message, then reads from stdin line by line.
|
|
213
|
+
Content lines are accumulated in a buffer, commands are processed through
|
|
214
|
+
the action system, and /send commands trigger backend processing.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
backend: Backend for processing input
|
|
218
|
+
action_registry: Optional action registry for command processing
|
|
219
|
+
initial_message: Optional message to process before stdin loop
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
bool: True if all send operations succeeded
|
|
223
|
+
|
|
224
|
+
Example:
|
|
225
|
+
# stdin input:
|
|
226
|
+
# Message part 1
|
|
227
|
+
# Message part 2
|
|
228
|
+
# /send # Send parts 1-2, wait for completion
|
|
229
|
+
# Message part 3
|
|
230
|
+
# /help # Process command
|
|
231
|
+
# Message part 4
|
|
232
|
+
# /send # Send parts 3-4, wait for completion
|
|
233
|
+
# Final message
|
|
234
|
+
# ^D # EOF triggers final send
|
|
235
|
+
|
|
236
|
+
success = await run_headless_mode(
|
|
237
|
+
backend=my_backend,
|
|
238
|
+
initial_message="Starting headless session"
|
|
239
|
+
)
|
|
240
|
+
"""
|
|
241
|
+
logger.trace("run_headless_mode() entry")
|
|
242
|
+
|
|
243
|
+
headless_repl = HeadlessREPL(action_registry)
|
|
244
|
+
result = await headless_repl.run(backend, initial_message)
|
|
245
|
+
|
|
246
|
+
logger.trace("run_headless_mode() exit")
|
|
247
|
+
return result
|