Coverage for openhcs/textual_tui/services/terminal_launcher.py: 0.0%
116 statements
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-14 05:57 +0000
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-14 05:57 +0000
1"""
2Terminal launcher service for running commands in TUI terminal windows.
4Provides a clean interface for launching terminal applications within the existing
5TUI terminal infrastructure instead of external processes.
6"""
8import tempfile
9import os
10from pathlib import Path
11from typing import Optional, Callable
12import logging
14logger = logging.getLogger(__name__)
17class TerminalLauncher:
18 """Service for launching terminal applications within TUI terminal windows."""
20 def __init__(self, app):
21 """
22 Initialize terminal launcher.
24 Args:
25 app: The TUI application instance
26 """
27 self.app = app
29 async def launch_editor_for_file(self, file_content: str, file_extension: str = '.py',
30 on_save_callback: Optional[Callable[[str], None]] = None) -> None:
31 """
32 Launch an editor in a terminal window for editing file content.
34 Args:
35 file_content: Initial content to edit
36 file_extension: File extension (e.g., '.py', '.txt')
37 on_save_callback: Callback function called with edited content when saved
38 """
39 try:
40 # Create temporary file with content
41 with tempfile.NamedTemporaryFile(mode='w', suffix=file_extension, delete=False) as f:
42 f.write(file_content)
43 temp_path = f.name
45 # Get editor from environment
46 editor = os.environ.get('EDITOR', 'nano')
48 # Create command that will edit the file and then read it back
49 command = self._create_editor_command(editor, temp_path, on_save_callback)
51 # Launch terminal window with the command
52 await self._launch_terminal_with_command(command, f"Edit File ({editor})")
54 except Exception as e:
55 logger.error(f"Failed to launch editor: {e}")
56 self.app.show_error("Editor Error", f"Failed to launch editor: {str(e)}")
58 def _create_editor_command(self, editor: str, file_path: str,
59 on_save_callback: Optional[Callable[[str], None]]) -> str:
60 """
61 Create a shell command that runs the editor and handles the callback.
63 Args:
64 editor: Editor command (e.g., 'vim', 'nano')
65 file_path: Path to temporary file
66 on_save_callback: Callback for when file is saved
68 Returns:
69 Shell command string
70 """
71 # Create a wrapper script that:
72 # 1. Runs the editor
73 # 2. Reads the file content after editing
74 # 3. Calls the callback with the content
75 # 4. Cleans up the temp file
77 callback_script = self._create_callback_script(file_path, on_save_callback)
79 # Command that runs editor in a proper login shell environment
80 command = f"""
81# Source user's shell configuration
82if [ -f ~/.bashrc ]; then source ~/.bashrc; fi
83if [ -f ~/.zshrc ]; then source ~/.zshrc; fi
84if [ -f ~/.profile ]; then source ~/.profile; fi
86echo "Opening {editor} for editing..."
87echo "Save and exit to apply changes, or exit without saving to cancel."
88echo ""
89{editor} "{file_path}"
90echo ""
91echo "Editor closed. Processing changes..."
92python3 "{callback_script}"
93echo "Terminal will close automatically."
94exit 0
95"""
96 return command.strip()
98 def _create_callback_script(self, file_path: str,
99 on_save_callback: Optional[Callable[[str], None]]) -> str:
100 """
101 Create a simple script that signals completion without importing OpenHCS.
103 Args:
104 file_path: Path to the edited file
105 on_save_callback: Callback function
107 Returns:
108 Path to callback script
109 """
110 if on_save_callback:
111 # Store callback and create signal file approach
112 callback_id = id(on_save_callback)
113 self.app._terminal_callbacks = getattr(self.app, '_terminal_callbacks', {})
114 self.app._terminal_callbacks[callback_id] = on_save_callback
116 # Create signal file path
117 signal_file = f"{file_path}.done"
119 script_content = f"""
120# Simple completion signal - no OpenHCS imports needed
121import os
123try:
124 print("Editor session completed.")
126 # Create signal file to notify main process
127 with open("{signal_file}", 'w') as f:
128 f.write("{callback_id}")
130 print("Changes will be processed by main application.")
132except Exception as e:
133 print(f"Error creating signal file: {{e}}")
134"""
136 # Start polling for the signal file in main process
137 # We'll pass the terminal window reference when we create it
138 self._pending_callback = (file_path, signal_file, callback_id)
140 else:
141 # No callback, just clean up
142 script_content = f"""
143import os
144try:
145 os.unlink("{file_path}")
146 print("Temporary file cleaned up.")
147except:
148 pass
149"""
151 # Write callback script to temp file
152 with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
153 f.write(script_content)
154 return f.name
156 def _start_polling(self, file_path: str, signal_file: str, callback_id: int, terminal_window=None) -> None:
157 """Start polling for completion signal file."""
158 import asyncio
160 async def poll_for_completion():
161 """Poll for signal file and handle callback."""
162 while True:
163 try:
164 if os.path.exists(signal_file):
165 logger.info(f"Signal file detected: {signal_file}")
166 # Signal file exists, read the edited content
167 with open(file_path, 'r') as f:
168 content = f.read()
170 # Get and call the callback
171 callbacks = getattr(self.app, '_terminal_callbacks', {})
172 callback = callbacks.get(callback_id)
173 if callback:
174 logger.info("Calling editor callback")
175 callback(content)
176 else:
177 logger.warning(f"No callback found for ID: {callback_id}")
179 # Close the terminal window if provided
180 if terminal_window:
181 try:
182 logger.info(f"Closing terminal window: {terminal_window}")
183 terminal_window.close_window()
184 logger.info("Terminal window closed successfully")
185 except Exception as e:
186 logger.error(f"Error closing terminal window: {e}")
187 else:
188 logger.warning("No terminal window reference found for cleanup")
190 # Clean up
191 try:
192 os.unlink(file_path)
193 os.unlink(signal_file)
194 callbacks.pop(callback_id, None)
195 except:
196 pass
198 break
200 # Wait before checking again
201 await asyncio.sleep(0.5)
203 except Exception as e:
204 logger.error(f"Error in polling: {e}")
205 break
207 # Start the polling task
208 asyncio.create_task(poll_for_completion())
210 def _create_login_shell_wrapper(self, command: str) -> str:
211 """Create a wrapper script that runs command in a login shell environment."""
212 import os
213 import tempfile
214 import stat
216 # Get user's shell (same logic as TerminalWindow)
217 user_shell = self._get_user_shell()
219 # Create script that sources user configs and runs command
220 script_content = f'''#!/bin/bash
221# Run in login shell to load user environment
222exec {user_shell} -l -c "{command.replace('"', '\\"')}"
223'''
225 # Write to temporary file
226 with tempfile.NamedTemporaryFile(mode='w', suffix='.sh', delete=False) as f:
227 f.write(script_content)
228 script_path = f.name
230 # Make executable
231 os.chmod(script_path, stat.S_IRWXU | stat.S_IRGRP | stat.S_IROTH)
233 return script_path
235 def _get_user_shell(self) -> str:
236 """Get user's preferred shell (same logic as TerminalWindow)."""
237 import os
239 # Method 1: Check SHELL environment variable
240 if 'SHELL' in os.environ and os.path.exists(os.environ['SHELL']):
241 return os.environ['SHELL']
243 # Method 2: Check /etc/passwd
244 try:
245 import pwd
246 user_shell = pwd.getpwuid(os.getuid()).pw_shell
247 if user_shell and os.path.exists(user_shell):
248 return user_shell
249 except (ImportError, KeyError, OSError):
250 pass
252 # Method 3: Try common shells
253 common_shells = ['/bin/zsh', '/usr/bin/zsh', '/bin/bash', '/usr/bin/bash']
254 for shell_path in common_shells:
255 if os.path.exists(shell_path):
256 return shell_path
258 # Fallback
259 return '/bin/bash'
261 async def _launch_terminal_with_command(self, command: str, title: str = "Terminal") -> None:
262 """
263 Launch a terminal window with a specific command.
265 Args:
266 command: Shell command to run
267 title: Window title
268 """
269 from openhcs.textual_tui.windows.terminal_window import TerminalWindow
270 from textual.css.query import NoMatches
272 # Create wrapper command that runs our command in a login shell (like regular terminal)
273 shell_command = self._create_login_shell_wrapper(command)
275 try:
276 # Try to find existing terminal window
277 window = self.app.query_one(TerminalWindow)
278 # If terminal exists, we could either reuse it or create a new one
279 # For now, let's create a new one for the editor
280 window = TerminalWindow(shell_command=shell_command)
281 await self.app.mount(window)
282 window.open_state = True
284 except NoMatches:
285 # No existing terminal, create new one
286 window = TerminalWindow(shell_command=shell_command)
287 await self.app.mount(window)
288 window.open_state = True
290 # Start polling with the terminal window reference if we have a pending callback
291 if hasattr(self, '_pending_callback'):
292 file_path, signal_file, callback_id = self._pending_callback
293 logger.info(f"Starting polling with terminal window reference: {window}")
294 self._start_polling(file_path, signal_file, callback_id, window)
295 delattr(self, '_pending_callback')