clue2app-cli 0.2.7__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.
@@ -0,0 +1,733 @@
1
+ """ncurses-based TUI for Clue2App CLI.
2
+
3
+ Provides a traditional terminal interface using ncurses for systems
4
+ without Textual support or minimal terminal environments.
5
+ """
6
+
7
+ import curses
8
+ import sys
9
+ from typing import Optional, List, Callable
10
+ from enum import Enum
11
+
12
+ from .data import DataProvider, AppInfo, ProjectInfo, AppStatus, BuildStatus
13
+
14
+
15
+ class Screen(Enum):
16
+ """Available screens."""
17
+ APPS = "apps"
18
+ DETAILS = "details"
19
+ LOGS = "logs"
20
+ PROJECTS = "projects"
21
+ HELP = "help"
22
+ CONFIRM = "confirm"
23
+ STATUS_LOG = "status_log"
24
+
25
+
26
+ class Colors:
27
+ """Color pair indices."""
28
+ NORMAL = 0
29
+ GREEN = 1
30
+ RED = 2
31
+ YELLOW = 3
32
+ CYAN = 4
33
+ HEADER = 5
34
+ SELECTED = 6
35
+ WHITE = 7
36
+
37
+
38
+ class CursesTUI:
39
+ """ncurses-based interactive TUI."""
40
+
41
+ def __init__(self, stdscr, initial_screen: str = 'apps'):
42
+ self.stdscr = stdscr
43
+ self.data_provider = DataProvider()
44
+
45
+ self.current_screen = Screen.APPS if initial_screen == 'apps' else Screen.PROJECTS
46
+ self.selected_index = 0
47
+ self.scroll_offset = 0
48
+
49
+ self.apps: List[AppInfo] = []
50
+ self.projects: List[ProjectInfo] = []
51
+ self.selected_app: Optional[AppInfo] = None
52
+ self.show_unlinked = False
53
+ self.show_all = False # False = show only current project
54
+ self._current_project: Optional[ProjectInfo] = self.data_provider.get_current_project()
55
+
56
+ self.logs_content = ""
57
+ self.confirm_message = ""
58
+ self.confirm_callback: Optional[Callable] = None
59
+
60
+ # Status log for displaying messages/errors
61
+ self._status_messages: List[tuple] = [] # [(timestamp, level, message), ...]
62
+ self._max_messages = 50
63
+
64
+ self._init_colors()
65
+
66
+ def _init_colors(self) -> None:
67
+ """Initialize color pairs."""
68
+ curses.start_color()
69
+ curses.use_default_colors()
70
+
71
+ curses.init_pair(Colors.GREEN, curses.COLOR_GREEN, -1)
72
+ curses.init_pair(Colors.RED, curses.COLOR_RED, -1)
73
+ curses.init_pair(Colors.YELLOW, curses.COLOR_YELLOW, -1)
74
+ curses.init_pair(Colors.CYAN, curses.COLOR_CYAN, -1)
75
+ curses.init_pair(Colors.HEADER, curses.COLOR_BLACK, curses.COLOR_CYAN)
76
+ curses.init_pair(Colors.SELECTED, curses.COLOR_BLACK, curses.COLOR_WHITE)
77
+ curses.init_pair(Colors.WHITE, curses.COLOR_WHITE, -1)
78
+
79
+ def _log(self, message: str, level: str = "info") -> None:
80
+ """Add a message to the status log.
81
+
82
+ Args:
83
+ message: The message to log
84
+ level: One of "info", "warn", "error"
85
+ """
86
+ from datetime import datetime
87
+ timestamp = datetime.now().strftime("%H:%M:%S")
88
+ full_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
89
+ self._status_messages.append((timestamp, level, message))
90
+ # Keep only the last N messages
91
+ if len(self._status_messages) > self._max_messages:
92
+ self._status_messages = self._status_messages[-self._max_messages:]
93
+
94
+ # Also write to file for easy access
95
+ try:
96
+ with open("/tmp/c2a_tui.log", "a") as f:
97
+ f.write(f"[{full_timestamp}] [{level.upper()}] {message}\n")
98
+ except:
99
+ pass
100
+
101
+ def run(self) -> None:
102
+ """Main event loop."""
103
+ curses.curs_set(0) # Hide cursor
104
+ self.stdscr.keypad(True)
105
+ self.stdscr.timeout(1000) # 1s timeout - only redraw on input or timeout
106
+
107
+ # Initial data load
108
+ self._load_data()
109
+
110
+ running = True
111
+ needs_redraw = True
112
+ while running:
113
+ if needs_redraw:
114
+ self._draw()
115
+ self.stdscr.refresh()
116
+ needs_redraw = False
117
+
118
+ result = self._handle_input()
119
+ if result is False:
120
+ running = False
121
+ elif result is True:
122
+ needs_redraw = True # Input received, redraw
123
+
124
+ def _load_data(self) -> None:
125
+ """Load data for current screen."""
126
+ try:
127
+ if self.current_screen == Screen.APPS:
128
+ # Determine project filter
129
+ project_id = None
130
+ if self._current_project and not self.show_all:
131
+ project_id = self._current_project.id
132
+ self.apps = self.data_provider.get_apps(
133
+ project_id=project_id,
134
+ include_unlinked=self.show_unlinked
135
+ )
136
+ self._log(f"Loaded {len(self.apps)} apps", "info")
137
+ elif self.current_screen == Screen.PROJECTS:
138
+ self.projects = self.data_provider.get_projects()
139
+ self._log(f"Loaded {len(self.projects)} projects", "info")
140
+ elif self.current_screen == Screen.LOGS and self.selected_app:
141
+ self.logs_content = self.data_provider.get_logs(self.selected_app.name, lines=100)
142
+ self._log(f"Loaded logs for {self.selected_app.display_name}", "info")
143
+ except Exception as e:
144
+ error_msg = str(e)
145
+ # Extract useful info from common errors
146
+ if "500" in error_msg:
147
+ self._log(f"Server Error (500): API request failed", "error")
148
+ elif "401" in error_msg or "403" in error_msg:
149
+ self._log(f"Auth Error: Please re-login", "error")
150
+ elif "Connection" in error_msg or "timeout" in error_msg.lower():
151
+ self._log(f"Network Error: Cannot reach server", "error")
152
+ else:
153
+ self._log(f"Error: {error_msg[:60]}", "error")
154
+ self._show_error(str(e))
155
+
156
+ def _draw(self) -> None:
157
+ """Draw the current screen."""
158
+ self.stdscr.clear()
159
+ height, width = self.stdscr.getmaxyx()
160
+
161
+ # Draw header
162
+ self._draw_header(width)
163
+
164
+ # Draw main content based on screen
165
+ if self.current_screen == Screen.APPS:
166
+ self._draw_apps_screen(height, width)
167
+ elif self.current_screen == Screen.DETAILS:
168
+ self._draw_details_screen(height, width)
169
+ elif self.current_screen == Screen.LOGS:
170
+ self._draw_logs_screen(height, width)
171
+ elif self.current_screen == Screen.PROJECTS:
172
+ self._draw_projects_screen(height, width)
173
+ elif self.current_screen == Screen.HELP:
174
+ self._draw_help_screen(height, width)
175
+ elif self.current_screen == Screen.CONFIRM:
176
+ self._draw_confirm_screen(height, width)
177
+ elif self.current_screen == Screen.STATUS_LOG:
178
+ self._draw_status_log_screen(height, width)
179
+
180
+ # Draw footer
181
+ self._draw_footer(height, width)
182
+
183
+ self.stdscr.refresh()
184
+
185
+ def _draw_header(self, width: int) -> None:
186
+ """Draw the header bar."""
187
+ header = " Clue2App TUI "
188
+ project = self.data_provider.get_current_project()
189
+ if project:
190
+ header += f"| Project: {project.name} "
191
+
192
+ header = header.ljust(width)
193
+ self.stdscr.attron(curses.color_pair(Colors.HEADER) | curses.A_BOLD)
194
+ self.stdscr.addstr(0, 0, header[:width-1])
195
+ self.stdscr.attroff(curses.color_pair(Colors.HEADER) | curses.A_BOLD)
196
+
197
+ def _draw_footer(self, height: int, width: int) -> None:
198
+ """Draw the footer with shortcuts and status log."""
199
+ # Draw status log line (above footer)
200
+ if self._status_messages:
201
+ timestamp, level, message = self._status_messages[-1]
202
+ status_line = f"[{timestamp}] {message}"
203
+
204
+ # Choose color based on level
205
+ if level == "error":
206
+ color = curses.color_pair(Colors.RED)
207
+ elif level == "warn":
208
+ color = curses.color_pair(Colors.YELLOW)
209
+ else:
210
+ color = curses.color_pair(Colors.CYAN)
211
+
212
+ try:
213
+ self.stdscr.attron(color)
214
+ self.stdscr.addstr(height - 2, 0, status_line[:width-1].ljust(width-1))
215
+ self.stdscr.attroff(color)
216
+ except curses.error:
217
+ pass
218
+
219
+ # Draw shortcuts footer
220
+ shortcuts = {
221
+ Screen.APPS: "[↵]View [l]Logs [r]Rebuild [s]Start [p]Proj [a]All [L]Log [q]Quit",
222
+ Screen.DETAILS: "[l]Logs [r]Rebuild [o]URL [L]Log [q]Back",
223
+ Screen.LOGS: "[f]Follow [g]Top [G]End [L]Log [q]Back",
224
+ Screen.PROJECTS: "[↵]Select [R]Refresh [L]Log [q]Back",
225
+ Screen.HELP: "[q]Back",
226
+ Screen.CONFIRM: "[y]Yes [n]No",
227
+ Screen.STATUS_LOG: "[c]Clear [q]Back",
228
+ }
229
+
230
+ footer = shortcuts.get(self.current_screen, "[q]Back")
231
+ footer = footer.center(width)
232
+
233
+ self.stdscr.attron(curses.color_pair(Colors.HEADER))
234
+ try:
235
+ self.stdscr.addstr(height - 1, 0, footer[:width-1])
236
+ except curses.error:
237
+ pass
238
+ self.stdscr.attroff(curses.color_pair(Colors.HEADER))
239
+
240
+ def _draw_apps_screen(self, height: int, width: int) -> None:
241
+ """Draw the apps list screen."""
242
+ # Column headers
243
+ self.stdscr.attron(curses.A_BOLD)
244
+ header = f"{'NAME':<25} {'PROJECT':<15} {'BUILD':<12} {'RUNTIME':<15} {'PODS':<5}"
245
+ self.stdscr.addstr(2, 2, header[:width-4])
246
+ self.stdscr.attroff(curses.A_BOLD)
247
+
248
+ # Separator
249
+ self.stdscr.addstr(3, 2, "─" * min(width - 4, 80))
250
+
251
+ # Apps list
252
+ visible_rows = height - 6
253
+ start_idx = self.scroll_offset
254
+ end_idx = min(start_idx + visible_rows, len(self.apps))
255
+
256
+ for i, app in enumerate(self.apps[start_idx:end_idx]):
257
+ row = 4 + i
258
+ is_selected = (start_idx + i) == self.selected_index
259
+
260
+ # Format columns
261
+ project_name = app.project_name or "(unlinked)"
262
+ build_symbol = self._status_symbol(app.build_status)
263
+ build_text = app.build_status.value[:8]
264
+ runtime_text = app.runtime_status.value[:12]
265
+
266
+ # Determine runtime status color
267
+ if app.runtime_status == AppStatus.RUNNING:
268
+ runtime_color = Colors.GREEN
269
+ elif app.runtime_status == AppStatus.SCALED_TO_ZERO:
270
+ runtime_color = Colors.YELLOW
271
+ elif app.runtime_status in (AppStatus.STOPPED, AppStatus.FAILED):
272
+ runtime_color = Colors.RED
273
+ else:
274
+ runtime_color = Colors.WHITE
275
+
276
+ if is_selected:
277
+ # Selected row - use selection color for everything
278
+ self.stdscr.attron(curses.color_pair(Colors.SELECTED))
279
+ line = f"{app.display_name:<25} {project_name:<15} {build_symbol} {build_text:<10} {runtime_text:<15} {app.pod_count:<5}"
280
+ self.stdscr.addstr(row, 2, line[:width-4])
281
+ self.stdscr.attroff(curses.color_pair(Colors.SELECTED))
282
+ else:
283
+ # Render columns separately with colors
284
+ col = 2
285
+ # Name
286
+ self.stdscr.addstr(row, col, f"{app.display_name:<25}")
287
+ col += 25
288
+ # Project
289
+ self.stdscr.addstr(row, col, f"{project_name:<15}")
290
+ col += 15 + 1
291
+ # Build status with symbol
292
+ self.stdscr.addstr(row, col, f"{build_symbol} {build_text:<10}")
293
+ col += 12
294
+ # Runtime status with color
295
+ self.stdscr.attron(curses.color_pair(runtime_color))
296
+ self.stdscr.addstr(row, col, f"{runtime_text:<15}")
297
+ self.stdscr.attroff(curses.color_pair(runtime_color))
298
+ col += 15
299
+ # Pod count
300
+ self.stdscr.addstr(row, col, f"{app.pod_count:<5}")
301
+
302
+ # Show scroll indicator if needed
303
+ if len(self.apps) > visible_rows:
304
+ scroll_info = f"[{self.selected_index + 1}/{len(self.apps)}]"
305
+ self.stdscr.addstr(2, width - len(scroll_info) - 2, scroll_info)
306
+
307
+ def _draw_details_screen(self, height: int, width: int) -> None:
308
+ """Draw app details screen."""
309
+ if not self.selected_app:
310
+ return
311
+
312
+ app = self.selected_app
313
+ row = 2
314
+
315
+ self.stdscr.attron(curses.A_BOLD)
316
+ self.stdscr.addstr(row, 2, f"App Details: {app.display_name}")
317
+ self.stdscr.attroff(curses.A_BOLD)
318
+ row += 2
319
+
320
+ details = [
321
+ ("Name", app.name),
322
+ ("Namespace", app.namespace),
323
+ ("Project", app.project_name or "(unlinked)"),
324
+ ("", ""),
325
+ ("Build Status", app.build_status.value),
326
+ ("Build Count", str(app.build_count)),
327
+ ("Build Reason", app.build_reason or "N/A"),
328
+ ("Stack", app.stack or "N/A"),
329
+ ("", ""),
330
+ ("Git URL", app.git_url or "N/A"),
331
+ ("Git Revision", app.git_revision or "N/A"),
332
+ ("", ""),
333
+ ("Runtime Status", app.runtime_status.value),
334
+ ("Pods", str(app.pod_count)),
335
+ ("URL", app.url or "N/A"),
336
+ ]
337
+
338
+ for label, value in details:
339
+ if label:
340
+ self.stdscr.attron(curses.color_pair(Colors.CYAN))
341
+ self.stdscr.addstr(row, 2, f"{label}:")
342
+ self.stdscr.attroff(curses.color_pair(Colors.CYAN))
343
+ self.stdscr.addstr(row, 18, str(value)[:width-20])
344
+ row += 1
345
+ if row >= height - 2:
346
+ break
347
+
348
+ def _draw_logs_screen(self, height: int, width: int) -> None:
349
+ """Draw logs screen."""
350
+ if not self.selected_app:
351
+ return
352
+
353
+ self.stdscr.attron(curses.A_BOLD)
354
+ self.stdscr.addstr(2, 2, f"Logs: {self.selected_app.display_name}")
355
+ self.stdscr.attroff(curses.A_BOLD)
356
+
357
+ # Display logs
358
+ lines = self.logs_content.split('\n') if self.logs_content else ["No logs available"]
359
+ visible_rows = height - 5
360
+
361
+ start_line = max(0, len(lines) - visible_rows - self.scroll_offset)
362
+ end_line = start_line + visible_rows
363
+
364
+ for i, line in enumerate(lines[start_line:end_line]):
365
+ row = 4 + i
366
+ if row < height - 1:
367
+ self.stdscr.addstr(row, 2, line[:width-4])
368
+
369
+ def _draw_projects_screen(self, height: int, width: int) -> None:
370
+ """Draw projects list screen."""
371
+ self.stdscr.attron(curses.A_BOLD)
372
+ header = f"{'NAME':<30} {'ID':<40}"
373
+ self.stdscr.addstr(2, 2, header[:width-4])
374
+ self.stdscr.attroff(curses.A_BOLD)
375
+
376
+ self.stdscr.addstr(3, 2, "─" * min(width - 4, 80))
377
+
378
+ visible_rows = height - 6
379
+
380
+ for i, project in enumerate(self.projects[:visible_rows]):
381
+ row = 4 + i
382
+ is_selected = i == self.selected_index
383
+
384
+ if is_selected:
385
+ self.stdscr.attron(curses.color_pair(Colors.SELECTED))
386
+
387
+ line = f"{project.name:<30} {project.id:<40}"
388
+ self.stdscr.addstr(row, 2, line[:width-4])
389
+
390
+ if is_selected:
391
+ self.stdscr.attroff(curses.color_pair(Colors.SELECTED))
392
+
393
+ def _draw_help_screen(self, height: int, width: int) -> None:
394
+ """Draw help screen."""
395
+ help_text = [
396
+ "Clue2App TUI - Keyboard Shortcuts",
397
+ "",
398
+ "Navigation:",
399
+ " j / ↓ Move down",
400
+ " k / ↑ Move up",
401
+ " Enter Select / View details",
402
+ " q / Escape Back / Quit",
403
+ "",
404
+ "Apps Screen:",
405
+ " l View app logs",
406
+ " r Rebuild app",
407
+ " d Delete app",
408
+ " p Switch to projects",
409
+ " a Toggle all/current project",
410
+ " u Toggle unlinked apps",
411
+ " R Refresh",
412
+ " L View status log",
413
+ "",
414
+ "Logs Screen:",
415
+ " f Toggle follow mode",
416
+ " g Scroll to top",
417
+ " G Scroll to bottom",
418
+ "",
419
+ "Status Log:",
420
+ " c Clear log messages",
421
+ "",
422
+ "Press q to close this help.",
423
+ ]
424
+
425
+ for i, line in enumerate(help_text):
426
+ if i + 2 < height - 1:
427
+ self.stdscr.addstr(i + 2, 2, line[:width-4])
428
+
429
+ def _draw_status_log_screen(self, height: int, width: int) -> None:
430
+ """Draw status log screen showing all messages."""
431
+ self.stdscr.attron(curses.A_BOLD)
432
+ self.stdscr.addstr(2, 2, "Status Log (most recent at bottom)")
433
+ self.stdscr.attroff(curses.A_BOLD)
434
+ self.stdscr.addstr(3, 2, "─" * min(width - 4, 80))
435
+
436
+ visible_rows = height - 7 # Leave room for header, title, and footer
437
+ messages = self._status_messages[-visible_rows:] if self._status_messages else []
438
+
439
+ for i, (timestamp, level, message) in enumerate(messages):
440
+ row = 4 + i
441
+ if row >= height - 3:
442
+ break
443
+
444
+ # Choose color based on level
445
+ if level == "error":
446
+ color = curses.color_pair(Colors.RED)
447
+ elif level == "warn":
448
+ color = curses.color_pair(Colors.YELLOW)
449
+ else:
450
+ color = curses.color_pair(Colors.CYAN)
451
+
452
+ line = f"[{timestamp}] {message}"
453
+ try:
454
+ self.stdscr.attron(color)
455
+ self.stdscr.addstr(row, 2, line[:width-4])
456
+ self.stdscr.attroff(color)
457
+ except curses.error:
458
+ pass
459
+
460
+ if not messages:
461
+ self.stdscr.addstr(5, 2, "(No messages yet)", curses.A_DIM)
462
+
463
+ def _draw_confirm_screen(self, height: int, width: int) -> None:
464
+ """Draw confirmation dialog."""
465
+ # Center the dialog
466
+ dialog_width = 50
467
+ dialog_height = 5
468
+ start_y = (height - dialog_height) // 2
469
+ start_x = (width - dialog_width) // 2
470
+
471
+ # Draw border
472
+ for i in range(dialog_height):
473
+ self.stdscr.addstr(start_y + i, start_x, " " * dialog_width, curses.color_pair(Colors.HEADER))
474
+
475
+ # Draw message
476
+ self.stdscr.attron(curses.A_BOLD)
477
+ msg = self.confirm_message[:dialog_width - 4]
478
+ self.stdscr.addstr(start_y + 1, start_x + 2, msg.center(dialog_width - 4))
479
+ self.stdscr.attroff(curses.A_BOLD)
480
+
481
+ # Draw options
482
+ self.stdscr.addstr(start_y + 3, start_x + 2, "[y] Yes [n] No".center(dialog_width - 4))
483
+
484
+ def _handle_input(self):
485
+ """Handle keyboard input. Returns False to exit, None for no input, True for redraw."""
486
+ try:
487
+ key = self.stdscr.getch()
488
+ except:
489
+ return None
490
+
491
+ if key == -1: # No input (timeout)
492
+ return None
493
+
494
+ # Global keys
495
+ if key == ord('q') or key == 27: # q or Escape
496
+ if self.current_screen in (Screen.APPS,):
497
+ return False # Exit app
498
+ else:
499
+ self._go_back()
500
+ return True
501
+
502
+ # Screen-specific handlers
503
+ if self.current_screen == Screen.APPS:
504
+ return self._handle_apps_input(key)
505
+ elif self.current_screen == Screen.DETAILS:
506
+ return self._handle_details_input(key)
507
+ elif self.current_screen == Screen.LOGS:
508
+ return self._handle_logs_input(key)
509
+ elif self.current_screen == Screen.PROJECTS:
510
+ return self._handle_projects_input(key)
511
+ elif self.current_screen == Screen.HELP:
512
+ return self._handle_help_input(key)
513
+ elif self.current_screen == Screen.CONFIRM:
514
+ return self._handle_confirm_input(key)
515
+ elif self.current_screen == Screen.STATUS_LOG:
516
+ return self._handle_status_log_input(key)
517
+
518
+ return True
519
+
520
+ def _handle_apps_input(self, key: int) -> bool:
521
+ """Handle input on apps screen."""
522
+ if key == ord('j') or key == curses.KEY_DOWN:
523
+ self.selected_index = min(self.selected_index + 1, len(self.apps) - 1)
524
+ self._adjust_scroll()
525
+ elif key == ord('k') or key == curses.KEY_UP:
526
+ self.selected_index = max(self.selected_index - 1, 0)
527
+ self._adjust_scroll()
528
+ elif key == ord('\n') or key == curses.KEY_ENTER or key == 10:
529
+ if self.apps:
530
+ self.selected_app = self.apps[self.selected_index]
531
+ self.current_screen = Screen.DETAILS
532
+ elif key == ord('l'):
533
+ if self.apps:
534
+ self.selected_app = self.apps[self.selected_index]
535
+ self.scroll_offset = 0
536
+ self._load_data()
537
+ self.current_screen = Screen.LOGS
538
+ elif key == ord('r'):
539
+ if self.apps:
540
+ self.selected_app = self.apps[self.selected_index]
541
+ self.confirm_message = f"Rebuild {self.selected_app.display_name}?"
542
+ self.confirm_callback = self._do_rebuild
543
+ self.current_screen = Screen.CONFIRM
544
+ elif key == ord('s'):
545
+ if self.apps:
546
+ self.selected_app = self.apps[self.selected_index]
547
+ self.confirm_message = f"Start/Restart {self.selected_app.display_name}?"
548
+ self.confirm_callback = self._do_restart
549
+ self.current_screen = Screen.CONFIRM
550
+ elif key == ord('d'):
551
+ if self.apps:
552
+ self.selected_app = self.apps[self.selected_index]
553
+ self.confirm_message = f"Delete {self.selected_app.display_name}?"
554
+ self.confirm_callback = self._do_delete
555
+ self.current_screen = Screen.CONFIRM
556
+ elif key == ord('p'):
557
+ self.selected_index = 0
558
+ self.current_screen = Screen.PROJECTS
559
+ self._load_data()
560
+ elif key == ord('u'):
561
+ self.show_unlinked = not self.show_unlinked
562
+ self.selected_index = 0
563
+ self._load_data()
564
+ elif key == ord('a'):
565
+ if self._current_project:
566
+ self.show_all = not self.show_all
567
+ self.selected_index = 0
568
+ self._load_data()
569
+ elif key == ord('R'):
570
+ self._load_data()
571
+ elif key == ord('?'):
572
+ self.current_screen = Screen.HELP
573
+ elif key == ord('L'):
574
+ self.current_screen = Screen.STATUS_LOG
575
+
576
+ return True
577
+
578
+ def _handle_details_input(self, key: int) -> bool:
579
+ """Handle input on details screen."""
580
+ if key == ord('l'):
581
+ self.scroll_offset = 0
582
+ self.current_screen = Screen.LOGS
583
+ self._load_data()
584
+ elif key == ord('r'):
585
+ self.confirm_message = f"Rebuild {self.selected_app.display_name}?"
586
+ self.confirm_callback = self._do_rebuild
587
+ self.current_screen = Screen.CONFIRM
588
+ elif key == ord('o'):
589
+ if self.selected_app and self.selected_app.url:
590
+ import webbrowser
591
+ webbrowser.open(self.selected_app.url)
592
+ elif key == ord('L'):
593
+ self.current_screen = Screen.STATUS_LOG
594
+
595
+ return True
596
+
597
+ def _handle_logs_input(self, key: int) -> bool:
598
+ """Handle input on logs screen."""
599
+ if key == ord('g'):
600
+ self.scroll_offset = 0
601
+ elif key == ord('G'):
602
+ lines = self.logs_content.split('\n') if self.logs_content else []
603
+ height, _ = self.stdscr.getmaxyx()
604
+ visible = height - 5
605
+ self.scroll_offset = max(0, len(lines) - visible)
606
+ elif key == ord('R'):
607
+ self._load_data()
608
+ elif key == ord('L'):
609
+ self.current_screen = Screen.STATUS_LOG
610
+
611
+ return True
612
+
613
+ def _handle_projects_input(self, key: int) -> bool:
614
+ """Handle input on projects screen."""
615
+ if key == ord('j') or key == curses.KEY_DOWN:
616
+ self.selected_index = min(self.selected_index + 1, len(self.projects) - 1)
617
+ elif key == ord('k') or key == curses.KEY_UP:
618
+ self.selected_index = max(self.selected_index - 1, 0)
619
+ elif key == ord('\n') or key == curses.KEY_ENTER or key == 10:
620
+ if self.projects and self.selected_index < len(self.projects):
621
+ # Set the selected project as current
622
+ selected_project = self.projects[self.selected_index]
623
+ self._current_project = selected_project
624
+ self.show_all = False # Reset to show only this project's apps
625
+ self._log(f"Switched to project: {selected_project.name}", "info")
626
+ # Go back to apps screen and reload
627
+ self.current_screen = Screen.APPS
628
+ self.selected_index = 0
629
+ self.scroll_offset = 0
630
+ self._load_data()
631
+ elif key == ord('R'):
632
+ self._load_data()
633
+ elif key == ord('L'):
634
+ self.current_screen = Screen.STATUS_LOG
635
+
636
+ return True
637
+
638
+ def _handle_help_input(self, key: int) -> bool:
639
+ """Handle input on help screen."""
640
+ # Any key returns to previous screen
641
+ return True
642
+
643
+ def _handle_confirm_input(self, key: int) -> bool:
644
+ """Handle input on confirm screen."""
645
+ if key == ord('y') or key == ord('Y'):
646
+ if self.confirm_callback:
647
+ self.confirm_callback()
648
+ self._go_back()
649
+ elif key == ord('n') or key == ord('N'):
650
+ self._go_back()
651
+
652
+ return True
653
+
654
+ def _handle_status_log_input(self, key: int) -> bool:
655
+ """Handle input on status log screen."""
656
+ if key == ord('c') or key == ord('C'):
657
+ # Clear log messages
658
+ self._status_messages = []
659
+ self._log("Log cleared", "info")
660
+ # Any other key (including q/Escape handled globally) does nothing special
661
+ return True
662
+
663
+ def _go_back(self) -> None:
664
+ """Go back to previous screen."""
665
+ self.current_screen = Screen.APPS
666
+ self.selected_index = 0
667
+ self.scroll_offset = 0
668
+ self._load_data()
669
+
670
+ def _adjust_scroll(self) -> None:
671
+ """Adjust scroll offset to keep selection visible."""
672
+ height, _ = self.stdscr.getmaxyx()
673
+ visible_rows = height - 6
674
+
675
+ if self.selected_index < self.scroll_offset:
676
+ self.scroll_offset = self.selected_index
677
+ elif self.selected_index >= self.scroll_offset + visible_rows:
678
+ self.scroll_offset = self.selected_index - visible_rows + 1
679
+
680
+ def _status_symbol(self, status: BuildStatus) -> str:
681
+ """Get symbol for build status."""
682
+ symbols = {
683
+ BuildStatus.READY: "✓",
684
+ BuildStatus.BUILDING: "⟳",
685
+ BuildStatus.FAILED: "✗",
686
+ BuildStatus.UNKNOWN: "?",
687
+ }
688
+ return symbols.get(status, "?")
689
+
690
+ def _do_rebuild(self) -> None:
691
+ """Execute rebuild."""
692
+ if self.selected_app:
693
+ self.data_provider.rebuild_app(self.selected_app.name)
694
+ self._load_data()
695
+
696
+ def _do_delete(self) -> None:
697
+ """Execute delete."""
698
+ if self.selected_app:
699
+ self.data_provider.delete_app(self.selected_app.name)
700
+ self._load_data()
701
+
702
+ def _do_restart(self) -> None:
703
+ """Execute restart/start service."""
704
+ if self.selected_app:
705
+ result = self.data_provider.restart_service(
706
+ self.selected_app.name,
707
+ namespace=self.selected_app.namespace
708
+ )
709
+ if result.get("success"):
710
+ self._log(f"Started {self.selected_app.display_name}", "info")
711
+ else:
712
+ error = result.get("error", "Unknown error")
713
+ self._log(f"Failed to start {self.selected_app.display_name}: {error}", "error")
714
+ self._load_data()
715
+
716
+ def _show_error(self, message: str) -> None:
717
+ """Show error message and add to status log."""
718
+ # Add to status log
719
+ self._log(f"Error: {message[:100]}", "error")
720
+ # Also display on screen
721
+ try:
722
+ self.stdscr.addstr(1, 2, f"Error: {message[:60]}", curses.color_pair(Colors.RED))
723
+ except curses.error:
724
+ pass
725
+
726
+
727
+ def run_curses_tui(initial_screen: str = 'apps') -> None:
728
+ """Run the ncurses TUI."""
729
+ def main(stdscr):
730
+ tui = CursesTUI(stdscr, initial_screen=initial_screen)
731
+ tui.run()
732
+
733
+ curses.wrapper(main)