Coverage for openhcs/textual_tui/windows/advanced_terminal_window.py: 0.0%

190 statements  

« prev     ^ index     » next       coverage.py v7.10.3, created at 2025-08-14 05:57 +0000

1""" 

2Advanced Terminal Window for OpenHCS Textual TUI 

3 

4Uses the Gate One terminal emulator for advanced terminal functionality. 

5This supersedes the basic terminal window with full VT-* emulation support. 

6""" 

7 

8import asyncio 

9import logging 

10import os 

11import pty 

12import subprocess 

13import threading 

14from pathlib import Path 

15from typing import Optional 

16 

17from textual.app import ComposeResult 

18from textual.containers import Container, Vertical 

19from textual.reactive import reactive 

20from textual.widget import Widget 

21from textual.widgets import Static 

22 

23from openhcs.textual_tui.windows.base_window import BaseOpenHCSWindow 

24 

25# Import the Gate One terminal emulator and callback constants 

26from openhcs.textual_tui.services.terminal import ( 

27 Terminal as GateOneTerminal, 

28 CALLBACK_CHANGED, 

29 CALLBACK_CURSOR_POS, 

30 CALLBACK_TITLE, 

31 CALLBACK_BELL 

32) 

33 

34logger = logging.getLogger(__name__) 

35 

36 

37class AdvancedTerminalWidget(Widget): 

38 """ 

39 Advanced terminal widget using Gate One terminal emulator. 

40  

41 Provides full VT-* terminal emulation with: 

42 - Complete ECMA-48/ANSI X3.64 support 

43 - VT-52, VT-100, VT-220, VT-320, VT-420, VT-520 emulation 

44 - Linux console emulation 

45 - Image support (with PIL) 

46 - Scrollback buffer 

47 - HTML output capabilities 

48 """ 

49 

50 DEFAULT_CSS = """ 

51 AdvancedTerminalWidget { 

52 width: 100%; 

53 height: 100%; 

54 } 

55 """ 

56 

57 # Reactive properties 

58 cursor_visible = reactive(True) 

59 terminal_title = reactive("Advanced Terminal") 

60 

61 def __init__(self, command: Optional[str] = None, rows: int = 24, cols: int = 80, **kwargs): 

62 """ 

63 Initialize the advanced terminal widget. 

64  

65 Args: 

66 command: Shell command to run (defaults to current shell) 

67 rows: Terminal height in rows 

68 cols: Terminal width in columns 

69 """ 

70 super().__init__(**kwargs) 

71 

72 self.command = command or self._get_default_shell() 

73 self.rows = rows 

74 self.cols = cols 

75 

76 # Initialize Gate One terminal 

77 self.terminal = GateOneTerminal(rows=rows, cols=cols) 

78 

79 # Setup terminal callbacks 

80 self._setup_terminal_callbacks() 

81 

82 # Process management 

83 self.process = None 

84 self.master_fd = None 

85 self.reader_thread = None 

86 self.running = False 

87 

88 logger.info(f"AdvancedTerminalWidget initialized: {rows}x{cols}, command: {self.command}") 

89 

90 def _get_default_shell(self) -> str: 

91 """Get the default shell for the current user.""" 

92 # Try to get user's shell from environment or passwd 

93 shell = os.environ.get('SHELL') 

94 if shell and os.path.exists(shell): 

95 return shell 

96 

97 # Fallback to common shells 

98 for shell_path in ['/bin/bash', '/bin/zsh', '/bin/sh']: 

99 if os.path.exists(shell_path): 

100 return shell_path 

101 

102 return '/bin/sh' # Ultimate fallback 

103 

104 def _setup_terminal_callbacks(self): 

105 """Setup callbacks for the Gate One terminal.""" 

106 # Callback when screen changes - trigger refresh 

107 def on_screen_changed(): 

108 self.call_after_refresh(self._update_display) 

109 

110 # Callback when cursor position changes 

111 def on_cursor_changed(): 

112 self.call_after_refresh(self._update_cursor) 

113 

114 # Callback when title changes 

115 def on_title_changed(): 

116 title = getattr(self.terminal, 'title', 'Advanced Terminal') 

117 self.terminal_title = title 

118 

119 # Callback for bell 

120 def on_bell(): 

121 # Could implement visual bell here 

122 logger.debug("Terminal bell") 

123 

124 # Set up the callbacks using the proper add_callback method 

125 self.terminal.add_callback(CALLBACK_CHANGED, on_screen_changed, identifier="widget_changed") 

126 self.terminal.add_callback(CALLBACK_CURSOR_POS, on_cursor_changed, identifier="widget_cursor") 

127 self.terminal.add_callback(CALLBACK_TITLE, on_title_changed, identifier="widget_title") 

128 self.terminal.add_callback(CALLBACK_BELL, on_bell, identifier="widget_bell") 

129 

130 def compose(self) -> ComposeResult: 

131 """Compose the terminal widget layout.""" 

132 # Just the terminal screen display, no control buttons 

133 yield Static("", id="terminal_screen") 

134 

135 def on_mount(self) -> None: 

136 """Start the terminal when widget is mounted.""" 

137 logger.info("AdvancedTerminalWidget mounting - starting terminal process") 

138 self.start_terminal() 

139 

140 def on_unmount(self) -> None: 

141 """Clean up when widget is unmounted.""" 

142 logger.info("AdvancedTerminalWidget unmounting - stopping terminal process") 

143 self.stop_terminal() 

144 

145 def start_terminal(self): 

146 """Start the terminal process and reader thread.""" 

147 try: 

148 # Create a pseudo-terminal 

149 self.master_fd, slave_fd = pty.openpty() 

150 

151 # Start the shell process 

152 self.process = subprocess.Popen( 

153 self.command, 

154 stdin=slave_fd, 

155 stdout=slave_fd, 

156 stderr=slave_fd, 

157 shell=True, 

158 preexec_fn=os.setsid # Create new session 

159 ) 

160 

161 # Close slave fd in parent process 

162 os.close(slave_fd) 

163 

164 # Start reader thread 

165 self.running = True 

166 self.reader_thread = threading.Thread(target=self._read_from_terminal, daemon=True) 

167 self.reader_thread.start() 

168 

169 logger.info(f"Terminal process started: PID {self.process.pid}") 

170 

171 except Exception as e: 

172 logger.error(f"Failed to start terminal process: {e}") 

173 self._show_error(f"Failed to start terminal: {e}") 

174 

175 def stop_terminal(self): 

176 """Stop the terminal process and cleanup.""" 

177 self.running = False 

178 

179 if self.process: 

180 try: 

181 # Terminate the process group 

182 os.killpg(os.getpgid(self.process.pid), 15) # SIGTERM 

183 self.process.wait(timeout=2) 

184 except (ProcessLookupError, subprocess.TimeoutExpired): 

185 try: 

186 # Force kill if needed 

187 os.killpg(os.getpgid(self.process.pid), 9) # SIGKILL 

188 except ProcessLookupError: 

189 pass 

190 self.process = None 

191 

192 if self.master_fd: 

193 try: 

194 os.close(self.master_fd) 

195 except OSError: 

196 pass 

197 self.master_fd = None 

198 

199 if self.reader_thread and self.reader_thread.is_alive(): 

200 self.reader_thread.join(timeout=1) 

201 

202 def _read_from_terminal(self): 

203 """Read output from terminal in background thread.""" 

204 while self.running and self.master_fd: 

205 try: 

206 # Read from terminal with timeout 

207 import select 

208 ready, _, _ = select.select([self.master_fd], [], [], 0.1) 

209 

210 if ready: 

211 data = os.read(self.master_fd, 1024) 

212 if data: 

213 # Decode and write to Gate One terminal 

214 text = data.decode('utf-8', errors='replace') 

215 self.terminal.write(text) 

216 else: 

217 # EOF - process ended 

218 break 

219 

220 except (OSError, ValueError) as e: 

221 if self.running: 

222 logger.error(f"Error reading from terminal: {e}") 

223 break 

224 

225 logger.info("Terminal reader thread stopped") 

226 

227 def _update_display(self): 

228 """Update the terminal display with current screen content.""" 

229 try: 

230 # Get screen content from Gate One terminal 

231 screen_lines = self.terminal.dump() 

232 

233 # Join lines and update display 

234 screen_content = '\n'.join(screen_lines) 

235 

236 # Update the display widget 

237 screen_widget = self.query_one("#terminal_screen", Static) 

238 screen_widget.update(screen_content) 

239 

240 except Exception as e: 

241 logger.error(f"Error updating terminal display: {e}") 

242 

243 def _update_cursor(self): 

244 """Update cursor position display.""" 

245 # Could implement cursor position indicator here 

246 pass 

247 

248 def _show_error(self, message: str): 

249 """Show error message in terminal display.""" 

250 try: 

251 screen_widget = self.query_one("#terminal_screen", Static) 

252 screen_widget.update(f"[red]Error: {message}[/red]") 

253 except Exception: 

254 pass 

255 

256 

257 

258 async def clear_terminal(self): 

259 """Clear the terminal screen.""" 

260 if self.master_fd: 

261 try: 

262 # Send clear command 

263 os.write(self.master_fd, b'\x0c') # Form feed (clear) 

264 except OSError as e: 

265 logger.error(f"Error clearing terminal: {e}") 

266 

267 async def reset_terminal(self): 

268 """Reset the terminal.""" 

269 if self.master_fd: 

270 try: 

271 # Send reset sequence 

272 os.write(self.master_fd, b'\x1bc') # ESC c (reset) 

273 except OSError as e: 

274 logger.error(f"Error resetting terminal: {e}") 

275 

276 async def send_input(self, text: str): 

277 """Send input to the terminal.""" 

278 if self.master_fd: 

279 try: 

280 os.write(self.master_fd, text.encode('utf-8')) 

281 except OSError as e: 

282 logger.error(f"Error sending input to terminal: {e}") 

283 

284 async def on_key(self, event) -> None: 

285 """Handle key input and send to terminal.""" 

286 if self.master_fd: 

287 try: 

288 # Convert key to appropriate bytes 

289 key_bytes = self._key_to_bytes(event.key) 

290 if key_bytes: 

291 os.write(self.master_fd, key_bytes) 

292 except OSError as e: 

293 logger.error(f"Error sending key to terminal: {e}") 

294 

295 def _key_to_bytes(self, key: str) -> bytes: 

296 """Convert Textual key to terminal bytes.""" 

297 # Handle special keys 

298 key_map = { 

299 'enter': b'\r', 

300 'escape': b'\x1b', 

301 'backspace': b'\x7f', 

302 'tab': b'\t', 

303 'up': b'\x1b[A', 

304 'down': b'\x1b[B', 

305 'right': b'\x1b[C', 

306 'left': b'\x1b[D', 

307 'home': b'\x1b[H', 

308 'end': b'\x1b[F', 

309 'page_up': b'\x1b[5~', 

310 'page_down': b'\x1b[6~', 

311 'delete': b'\x1b[3~', 

312 'insert': b'\x1b[2~', 

313 } 

314 

315 # Handle ctrl combinations 

316 if key.startswith('ctrl+'): 

317 char = key[5:] 

318 if len(char) == 1 and 'a' <= char <= 'z': 

319 return bytes([ord(char) - ord('a') + 1]) 

320 

321 # Handle regular keys 

322 if key in key_map: 

323 return key_map[key] 

324 elif len(key) == 1: 

325 return key.encode('utf-8') 

326 

327 return b'' 

328 

329 

330class AdvancedTerminalWindow(BaseOpenHCSWindow): 

331 """ 

332 Advanced Terminal Window using Gate One terminal emulator. 

333  

334 This supersedes the basic TerminalWindow with advanced features: 

335 - Full VT-* terminal emulation 

336 - Complete ANSI escape sequence support 

337 - Image display capabilities 

338 - Advanced scrollback buffer 

339 - HTML output support 

340 """ 

341 

342 DEFAULT_CSS = """ 

343 AdvancedTerminalWindow { 

344 width: 100; height: 30; 

345 min-width: 80; min-height: 24; 

346 } 

347 """ 

348 

349 def __init__(self, shell_command: str = None, **kwargs): 

350 """ 

351 Initialize advanced terminal window. 

352 

353 Args: 

354 shell_command: Optional command to run (defaults to current shell) 

355 """ 

356 # Get shell before calling super() so we can use it in title 

357 self.shell_command = shell_command or self._get_current_shell() 

358 

359 # Extract shell name for title 

360 shell_name = os.path.basename(self.shell_command) 

361 

362 logger.info(f"AdvancedTerminal: Initializing with shell: {self.shell_command}") 

363 

364 super().__init__( 

365 window_id="advanced_terminal", 

366 title=f"Advanced Terminal ({shell_name})", 

367 mode="temporary", 

368 **kwargs 

369 ) 

370 

371 def _get_current_shell(self) -> str: 

372 """Get the current shell from environment.""" 

373 return os.environ.get('SHELL', '/bin/bash') 

374 

375 def compose(self) -> ComposeResult: 

376 """Compose the advanced terminal window content.""" 

377 yield AdvancedTerminalWidget( 

378 command=self.shell_command, 

379 rows=24, 

380 cols=80, 

381 id="advanced_terminal" 

382 ) 

383 

384 async def on_key(self, event) -> None: 

385 """Handle key presses for terminal shortcuts.""" 

386 # Ctrl+Shift+C to copy (if we implement clipboard) 

387 if event.key == "ctrl+shift+c": 

388 # Could implement copy functionality here 

389 pass 

390 # Ctrl+Shift+V to paste (if we implement clipboard) 

391 elif event.key == "ctrl+shift+v": 

392 # Could implement paste functionality here 

393 pass 

394 # Ctrl+Shift+T to open new terminal 

395 elif event.key == "ctrl+shift+t": 

396 # Could open new terminal window 

397 pass 

398 else: 

399 # Forward all other keys to the terminal widget 

400 terminal = self.query_one("#advanced_terminal", AdvancedTerminalWidget) 

401 await terminal.on_key(event) 

402 

403 async def send_command(self, command: str): 

404 """Send a command to the terminal.""" 

405 terminal = self.query_one("#advanced_terminal", AdvancedTerminalWidget) 

406 await terminal.send_input(command + '\n') 

407 

408 def on_mount(self) -> None: 

409 """Called when advanced terminal window is mounted.""" 

410 terminal = self.query_one("#advanced_terminal", AdvancedTerminalWidget) 

411 terminal.focus() 

412 logger.info("AdvancedTerminalWindow mounted and focused")