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,1021 @@
1
+ """Textual-based TUI for Clue2App CLI.
2
+
3
+ Provides a k9s-like interactive terminal interface using the Textual framework.
4
+ """
5
+
6
+ from textual.app import App, ComposeResult
7
+ from textual.binding import Binding
8
+ from textual.containers import Container, Horizontal, Vertical
9
+ from textual.screen import Screen
10
+ from textual.widgets import DataTable, Footer, Header, Static, Label, Button
11
+ from textual.reactive import reactive
12
+ from textual import work
13
+ from textual.worker import Worker, get_current_worker
14
+
15
+ from typing import Optional, List
16
+ from rich.text import Text
17
+
18
+ from .data import DataProvider, AppInfo, ProjectInfo, AppStatus, BuildStatus
19
+
20
+
21
+ class AppRow:
22
+ """Helper to format app data for table display."""
23
+
24
+ @staticmethod
25
+ def build_status_text(status: BuildStatus) -> Text:
26
+ """Format build status with color."""
27
+ colors = {
28
+ BuildStatus.READY: "green",
29
+ BuildStatus.BUILDING: "yellow",
30
+ BuildStatus.FAILED: "red",
31
+ BuildStatus.UNKNOWN: "dim",
32
+ }
33
+ symbol = {
34
+ BuildStatus.READY: "✓",
35
+ BuildStatus.BUILDING: "⟳",
36
+ BuildStatus.FAILED: "✗",
37
+ BuildStatus.UNKNOWN: "?",
38
+ }
39
+ return Text(f"{symbol[status]} {status.value}", style=colors[status])
40
+
41
+ @staticmethod
42
+ def runtime_status_text(status: AppStatus, pods: int) -> Text:
43
+ """Format runtime status with color."""
44
+ colors = {
45
+ AppStatus.RUNNING: "green",
46
+ AppStatus.SCALED_TO_ZERO: "yellow",
47
+ AppStatus.STOPPED: "red",
48
+ AppStatus.BUILDING: "cyan",
49
+ AppStatus.FAILED: "red",
50
+ AppStatus.UNKNOWN: "dim",
51
+ }
52
+ return Text(f"{status.value} ({pods})", style=colors[status])
53
+
54
+
55
+ class StatusBar(Static):
56
+ """Bottom status bar showing context and help."""
57
+
58
+ project_name: reactive[str] = reactive("")
59
+
60
+ def render(self) -> Text:
61
+ project = f"Project: {self.project_name}" if self.project_name else "No project selected"
62
+ return Text.from_markup(f"[bold cyan]{project}[/] | Press [bold]?[/] for help")
63
+
64
+
65
+ class CommandBar(Static):
66
+ """k9s-style command bar showing available shortcuts."""
67
+
68
+ DEFAULT_CSS = """
69
+ CommandBar {
70
+ dock: top;
71
+ height: 1;
72
+ background: $surface-darken-1;
73
+ padding: 0 1;
74
+ }
75
+ """
76
+
77
+ commands: reactive[str] = reactive("")
78
+
79
+ def render(self) -> Text:
80
+ return Text.from_markup(self.commands)
81
+
82
+ @staticmethod
83
+ def format_commands(commands: list[tuple[str, str]]) -> str:
84
+ """Format commands as k9s-style bar.
85
+
86
+ Args:
87
+ commands: List of (key, description) tuples
88
+ """
89
+ parts = []
90
+ for key, desc in commands:
91
+ parts.append(f"[bold cyan]<{key}>[/] {desc}")
92
+ return " | ".join(parts)
93
+
94
+
95
+ class AppsScreen(Screen):
96
+ """Main apps list screen."""
97
+
98
+ BINDINGS = [
99
+ Binding("q", "quit", "Quit"),
100
+ Binding("escape", "noop", "Home", priority=True, show=False), # Prevent escape from closing app
101
+ Binding("?", "help", "Help"),
102
+ Binding("enter", "select", "Details"),
103
+ Binding("l", "logs", "Logs"),
104
+ Binding("r", "rebuild", "Rebuild"),
105
+ Binding("d", "delete", "Delete"),
106
+ Binding("p", "projects", "Projects"),
107
+ Binding("R", "refresh", "Refresh"),
108
+ Binding("u", "toggle_unlinked", "Unlinked"),
109
+ Binding("a", "toggle_all", "All Projects"),
110
+ ]
111
+
112
+ show_unlinked: reactive[bool] = reactive(False)
113
+ show_all: reactive[bool] = reactive(False) # False = show only current project
114
+ apps: reactive[List[AppInfo]] = reactive([])
115
+
116
+ def __init__(self, data_provider: DataProvider, *args, **kwargs):
117
+ super().__init__(*args, **kwargs)
118
+ self.data_provider = data_provider
119
+ self._selected_app: Optional[AppInfo] = None
120
+ self._current_project: Optional[ProjectInfo] = None
121
+
122
+ def compose(self) -> ComposeResult:
123
+ yield Header()
124
+ yield CommandBar(id="command-bar")
125
+ yield Container(
126
+ Static("Loading apps...", id="loading"),
127
+ DataTable(id="apps-table"),
128
+ id="main-container"
129
+ )
130
+ yield StatusBar(id="status-bar")
131
+ yield Footer()
132
+
133
+ def on_mount(self) -> None:
134
+ """Set up the table and load data."""
135
+ # Get current project context
136
+ self._current_project = self.data_provider.get_current_project()
137
+
138
+ # Set up command bar
139
+ self._update_command_bar()
140
+
141
+ table = self.query_one("#apps-table", DataTable)
142
+ table.cursor_type = "row"
143
+ table.zebra_stripes = True
144
+
145
+ # Add columns
146
+ table.add_column("Name", key="name", width=30)
147
+ table.add_column("Project", key="project", width=20)
148
+ table.add_column("Build", key="build", width=15)
149
+ table.add_column("Runtime", key="runtime", width=20)
150
+ table.add_column("URL", key="url", width=40)
151
+
152
+ # Update status bar with current project
153
+ status_bar = self.query_one("#status-bar", StatusBar)
154
+ status_bar.project_name = self._current_project.name if self._current_project else ""
155
+
156
+ # Load apps
157
+ self.load_apps()
158
+
159
+ def _update_command_bar(self) -> None:
160
+ """Update command bar based on current state."""
161
+ cmd_bar = self.query_one("#command-bar", CommandBar)
162
+ commands = [
163
+ ("Enter", "Details"),
164
+ ("l", "Logs"),
165
+ ("r", "Rebuild"),
166
+ ("d", "Delete"),
167
+ ("p", "Projects"),
168
+ ]
169
+ # Show "a" toggle only if a project is selected
170
+ if self._current_project:
171
+ scope = "All" if self.show_all else self._current_project.name
172
+ commands.append(("a", f"Scope:{scope}"))
173
+ commands.extend([
174
+ ("u", "Unlinked"),
175
+ ("?", "Help"),
176
+ ("q", "Quit"),
177
+ ])
178
+ cmd_bar.commands = CommandBar.format_commands(commands)
179
+
180
+ @work(exclusive=True)
181
+ async def load_apps(self) -> None:
182
+ """Load apps in background."""
183
+ worker = get_current_worker()
184
+
185
+ try:
186
+ # Determine project filter
187
+ project_id = None
188
+ if self._current_project and not self.show_all:
189
+ project_id = self._current_project.id
190
+
191
+ apps = self.data_provider.get_apps(
192
+ project_id=project_id,
193
+ include_unlinked=self.show_unlinked
194
+ )
195
+
196
+ if not worker.is_cancelled:
197
+ self.call_from_thread(self._update_table, apps)
198
+ except Exception as e:
199
+ if not worker.is_cancelled:
200
+ self.call_from_thread(self._show_error, str(e))
201
+
202
+ def _update_table(self, apps: List[AppInfo]) -> None:
203
+ """Update the table with app data."""
204
+ self.apps = apps
205
+
206
+ # Hide loading indicator
207
+ loading = self.query_one("#loading", Static)
208
+
209
+ # Update table
210
+ table = self.query_one("#apps-table", DataTable)
211
+ table.clear()
212
+
213
+ # Show helpful message if no apps found with project filter
214
+ if not apps and self._current_project and not self.show_all:
215
+ loading.update(
216
+ f"[yellow]No apps linked to project '{self._current_project.name}'[/yellow]\n\n"
217
+ "[dim]Press [bold]a[/bold] to show all apps, or [bold]u[/bold] to show unlinked apps.\n"
218
+ "Use [bold]c2a apps link <app> --project-id <id>[/bold] to link apps to this project.[/dim]"
219
+ )
220
+ loading.display = True
221
+ table.display = False
222
+ return
223
+
224
+ loading.display = False
225
+ table.display = True
226
+
227
+ for app in apps:
228
+ project_text = Text(app.project_name or "(unlinked)", style="yellow" if not app.project_name else "green")
229
+ url_text = Text(app.url or "-", style="cyan" if app.url else "dim")
230
+
231
+ table.add_row(
232
+ app.display_name,
233
+ project_text,
234
+ AppRow.build_status_text(app.build_status),
235
+ AppRow.runtime_status_text(app.runtime_status, app.pod_count),
236
+ url_text,
237
+ key=app.name
238
+ )
239
+
240
+ def _show_error(self, message: str) -> None:
241
+ """Show error message."""
242
+ loading = self.query_one("#loading", Static)
243
+ loading.update(f"[red]Error: {message}[/red]")
244
+
245
+ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
246
+ """Handle row selection."""
247
+ if event.row_key:
248
+ app_name = str(event.row_key.value)
249
+ for app in self.apps:
250
+ if app.name == app_name:
251
+ self._selected_app = app
252
+ break
253
+
254
+ def action_refresh(self) -> None:
255
+ """Refresh the apps list."""
256
+ loading = self.query_one("#loading", Static)
257
+ loading.update("Refreshing...")
258
+ loading.display = True
259
+ self.load_apps()
260
+
261
+ def action_toggle_unlinked(self) -> None:
262
+ """Toggle showing unlinked apps."""
263
+ self.show_unlinked = not self.show_unlinked
264
+ self.action_refresh()
265
+
266
+ def action_toggle_all(self) -> None:
267
+ """Toggle showing all projects vs current project only."""
268
+ if not self._current_project:
269
+ self.notify("No project selected - showing all apps", severity="warning")
270
+ return
271
+ self.show_all = not self.show_all
272
+ self._update_command_bar()
273
+ self.action_refresh()
274
+
275
+ def action_noop(self) -> None:
276
+ """Do nothing - prevents escape from closing app on home screen."""
277
+ pass
278
+
279
+ def action_logs(self) -> None:
280
+ """View logs for selected app."""
281
+ table = self.query_one("#apps-table", DataTable)
282
+ if table.cursor_row is not None and self.apps:
283
+ row_key = table.get_row_at(table.cursor_row)
284
+ # Find the app
285
+ for app in self.apps:
286
+ if app.name == str(table.get_row_key_at(table.cursor_row).value):
287
+ self.app.push_screen(LogsScreen(self.data_provider, app))
288
+ break
289
+
290
+ def action_rebuild(self) -> None:
291
+ """Rebuild selected app."""
292
+ table = self.query_one("#apps-table", DataTable)
293
+ if table.cursor_row is not None and self.apps:
294
+ for app in self.apps:
295
+ if app.name == str(table.get_row_key_at(table.cursor_row).value):
296
+ self.app.push_screen(ConfirmScreen(
297
+ f"Rebuild '{app.display_name}'?",
298
+ lambda: self._do_rebuild(app)
299
+ ))
300
+ break
301
+
302
+ def _do_rebuild(self, app: AppInfo) -> None:
303
+ """Execute rebuild."""
304
+ result = self.data_provider.rebuild_app(app.name)
305
+ if result:
306
+ self.notify(f"Rebuild triggered for {app.display_name}")
307
+ else:
308
+ self.notify(f"Failed to rebuild {app.display_name}", severity="error")
309
+ self.action_refresh()
310
+
311
+ def action_delete(self) -> None:
312
+ """Delete selected app."""
313
+ table = self.query_one("#apps-table", DataTable)
314
+ if table.cursor_row is not None and self.apps:
315
+ for app in self.apps:
316
+ if app.name == str(table.get_row_key_at(table.cursor_row).value):
317
+ self.app.push_screen(ConfirmScreen(
318
+ f"Delete '{app.display_name}'? This cannot be undone!",
319
+ lambda: self._do_delete(app)
320
+ ))
321
+ break
322
+
323
+ def _do_delete(self, app: AppInfo) -> None:
324
+ """Execute delete."""
325
+ if self.data_provider.delete_app(app.name):
326
+ self.notify(f"Deleted {app.display_name}")
327
+ else:
328
+ self.notify(f"Failed to delete {app.display_name}", severity="error")
329
+ self.action_refresh()
330
+
331
+ def action_select(self) -> None:
332
+ """Show details for selected app."""
333
+ table = self.query_one("#apps-table", DataTable)
334
+ if table.cursor_row is not None and self.apps:
335
+ for app in self.apps:
336
+ if app.name == str(table.get_row_key_at(table.cursor_row).value):
337
+ self.app.push_screen(DetailsScreen(self.data_provider, app))
338
+ break
339
+
340
+ def action_projects(self) -> None:
341
+ """Switch to projects screen."""
342
+ self.app.push_screen(ProjectsScreen(self.data_provider))
343
+
344
+ def action_help(self) -> None:
345
+ """Show help screen."""
346
+ self.app.push_screen(HelpScreen())
347
+
348
+
349
+ class LogsScreen(Screen):
350
+ """Log viewer screen."""
351
+
352
+ BINDINGS = [
353
+ Binding("q", "back", "Back"),
354
+ Binding("escape", "back", "Back", priority=True),
355
+ Binding("f", "toggle_follow", "Follow"),
356
+ Binding("g", "scroll_top", "Top"),
357
+ Binding("G", "scroll_bottom", "Bottom"),
358
+ Binding("b", "toggle_log_type", "Build/Instance"),
359
+ Binding("R", "refresh_logs", "Refresh"),
360
+ # vi-style navigation
361
+ Binding("j", "scroll_down", "Down", show=False),
362
+ Binding("k", "scroll_up", "Up", show=False),
363
+ Binding("ctrl+d", "page_down", "Page Down", show=False),
364
+ Binding("ctrl+u", "page_up", "Page Up", show=False),
365
+ Binding("ctrl+f", "page_down", "Page Down", show=False),
366
+ Binding("ctrl+b", "page_up", "Page Up", show=False),
367
+ Binding("/", "search", "Search"),
368
+ Binding("n", "search_next", "Next", show=False),
369
+ Binding("N", "search_prev", "Prev", show=False),
370
+ ]
371
+
372
+ following: reactive[bool] = reactive(False)
373
+ log_type: reactive[str] = reactive("auto") # "auto", "instance", "build"
374
+ search_term: reactive[str] = reactive("")
375
+ search_matches: reactive[List[int]] = reactive([])
376
+ current_match: reactive[int] = reactive(-1)
377
+
378
+ def __init__(self, data_provider: DataProvider, app: AppInfo, *args, **kwargs):
379
+ super().__init__(*args, **kwargs)
380
+ self.data_provider = data_provider
381
+ self.app_info = app
382
+ self._log_worker: Optional[Worker] = None
383
+ self._log_lines: List[str] = []
384
+
385
+ def compose(self) -> ComposeResult:
386
+ yield Header()
387
+ yield CommandBar(id="command-bar")
388
+ yield Container(
389
+ Static(f"[bold]Logs: {self.app_info.display_name}[/bold]", id="logs-header"),
390
+ Static("Loading logs...", id="logs-content"),
391
+ id="logs-container"
392
+ )
393
+ yield Footer()
394
+
395
+ def on_mount(self) -> None:
396
+ """Load initial logs."""
397
+ self._update_command_bar()
398
+ self.load_logs()
399
+
400
+ def _update_command_bar(self) -> None:
401
+ """Update command bar based on current state."""
402
+ cmd_bar = self.query_one("#command-bar", CommandBar)
403
+ log_type_label = {"auto": "Auto", "instance": "Instance", "build": "Build"}[self.log_type]
404
+ cmd_bar.commands = CommandBar.format_commands([
405
+ ("b", f"Type:{log_type_label}"),
406
+ ("j/k", "Up/Down"),
407
+ ("/", "Search"),
408
+ ("R", "Refresh"),
409
+ ("g/G", "Top/Bottom"),
410
+ ("Esc", "Back"),
411
+ ])
412
+
413
+ def _update_header(self) -> None:
414
+ """Update header with current log type."""
415
+ header = self.query_one("#logs-header", Static)
416
+ log_type_label = {"auto": "", "instance": " [cyan][Instance][/cyan]", "build": " [yellow][Build][/yellow]"}[self.log_type]
417
+ follow_label = " [green][Following][/green]" if self.following else ""
418
+ header.update(f"[bold]Logs: {self.app_info.display_name}[/bold]{log_type_label}{follow_label}")
419
+
420
+ @work(exclusive=True)
421
+ async def load_logs(self) -> None:
422
+ """Load logs in background."""
423
+ worker = get_current_worker()
424
+ try:
425
+ logs = self.data_provider.get_logs(self.app_info.name, lines=200, log_type=self.log_type)
426
+ if not worker.is_cancelled:
427
+ self.call_from_thread(self._update_logs, logs)
428
+ except Exception as e:
429
+ if not worker.is_cancelled:
430
+ self.call_from_thread(self._show_error, str(e))
431
+
432
+ def _update_logs(self, logs: str) -> None:
433
+ """Update logs display."""
434
+ self._log_lines = logs.split('\n') if logs else []
435
+ content = self.query_one("#logs-content", Static)
436
+ content.update(logs if logs else "[dim]No logs available[/dim]")
437
+
438
+ def _show_error(self, message: str) -> None:
439
+ """Show error message."""
440
+ content = self.query_one("#logs-content", Static)
441
+ content.update(f"[red]Error: {message}[/red]")
442
+
443
+ def action_back(self) -> None:
444
+ """Go back to apps screen."""
445
+ self.app.pop_screen()
446
+
447
+ def action_toggle_follow(self) -> None:
448
+ """Toggle log following."""
449
+ self.following = not self.following
450
+ self._update_header()
451
+
452
+ def action_toggle_log_type(self) -> None:
453
+ """Toggle between log types (auto -> instance -> build -> auto)."""
454
+ if self.log_type == "auto":
455
+ self.log_type = "instance"
456
+ elif self.log_type == "instance":
457
+ self.log_type = "build"
458
+ else:
459
+ self.log_type = "auto"
460
+ self._update_command_bar()
461
+ self._update_header()
462
+ self.load_logs()
463
+
464
+ def action_refresh_logs(self) -> None:
465
+ """Refresh logs."""
466
+ content = self.query_one("#logs-content", Static)
467
+ content.update("Refreshing logs...")
468
+ self.load_logs()
469
+
470
+ def action_scroll_top(self) -> None:
471
+ """Scroll to top."""
472
+ container = self.query_one("#logs-container", Container)
473
+ container.scroll_home()
474
+
475
+ def action_scroll_bottom(self) -> None:
476
+ """Scroll to bottom."""
477
+ container = self.query_one("#logs-container", Container)
478
+ container.scroll_end()
479
+
480
+ def action_scroll_down(self) -> None:
481
+ """Scroll down one line (vi j)."""
482
+ container = self.query_one("#logs-container", Container)
483
+ container.scroll_relative(y=1)
484
+
485
+ def action_scroll_up(self) -> None:
486
+ """Scroll up one line (vi k)."""
487
+ container = self.query_one("#logs-container", Container)
488
+ container.scroll_relative(y=-1)
489
+
490
+ def action_page_down(self) -> None:
491
+ """Scroll down half page (vi Ctrl-d)."""
492
+ container = self.query_one("#logs-container", Container)
493
+ container.scroll_relative(y=container.size.height // 2)
494
+
495
+ def action_page_up(self) -> None:
496
+ """Scroll up half page (vi Ctrl-u)."""
497
+ container = self.query_one("#logs-container", Container)
498
+ container.scroll_relative(y=-(container.size.height // 2))
499
+
500
+ def action_search(self) -> None:
501
+ """Start search (vi /)."""
502
+ self.app.push_screen(SearchScreen(self._do_search))
503
+
504
+ def _do_search(self, term: str) -> None:
505
+ """Execute search."""
506
+ if not term:
507
+ return
508
+ self.search_term = term
509
+ self.search_matches = []
510
+ self.current_match = -1
511
+
512
+ # Find all matching lines
513
+ for i, line in enumerate(self._log_lines):
514
+ if term.lower() in line.lower():
515
+ self.search_matches.append(i)
516
+
517
+ if self.search_matches:
518
+ self.current_match = 0
519
+ self._highlight_match()
520
+ self.notify(f"Found {len(self.search_matches)} matches")
521
+ else:
522
+ self.notify(f"No matches for '{term}'", severity="warning")
523
+
524
+ def _highlight_match(self) -> None:
525
+ """Highlight current match and scroll to it."""
526
+ if self.current_match < 0 or self.current_match >= len(self.search_matches):
527
+ return
528
+
529
+ # Re-render logs with highlighting
530
+ content = self.query_one("#logs-content", Static)
531
+ highlighted_lines = []
532
+ for i, line in enumerate(self._log_lines):
533
+ if i == self.search_matches[self.current_match]:
534
+ highlighted_lines.append(f"[reverse]{line}[/reverse]")
535
+ elif self.search_term.lower() in line.lower():
536
+ # Highlight search term in other matches
537
+ highlighted_lines.append(line.replace(
538
+ self.search_term,
539
+ f"[bold yellow]{self.search_term}[/bold yellow]"
540
+ ))
541
+ else:
542
+ highlighted_lines.append(line)
543
+ content.update('\n'.join(highlighted_lines))
544
+
545
+ # Scroll to match
546
+ container = self.query_one("#logs-container", Container)
547
+ match_line = self.search_matches[self.current_match]
548
+ container.scroll_to(y=match_line)
549
+
550
+ def action_search_next(self) -> None:
551
+ """Go to next search match (vi n)."""
552
+ if not self.search_matches:
553
+ return
554
+ self.current_match = (self.current_match + 1) % len(self.search_matches)
555
+ self._highlight_match()
556
+
557
+ def action_search_prev(self) -> None:
558
+ """Go to previous search match (vi N)."""
559
+ if not self.search_matches:
560
+ return
561
+ self.current_match = (self.current_match - 1) % len(self.search_matches)
562
+ self._highlight_match()
563
+
564
+
565
+ class SearchScreen(Screen):
566
+ """Search input dialog."""
567
+
568
+ BINDINGS = [
569
+ Binding("escape", "cancel", "Cancel", priority=True),
570
+ ]
571
+
572
+ def __init__(self, on_search: callable, *args, **kwargs):
573
+ super().__init__(*args, **kwargs)
574
+ self.on_search = on_search
575
+
576
+ def compose(self) -> ComposeResult:
577
+ from textual.widgets import Input
578
+ yield Container(
579
+ Static("[bold]Search:[/bold]", id="search-label"),
580
+ Input(placeholder="Enter search term...", id="search-input"),
581
+ id="search-dialog"
582
+ )
583
+
584
+ def on_mount(self) -> None:
585
+ """Focus the input."""
586
+ self.query_one("#search-input").focus()
587
+
588
+ def on_input_submitted(self, event) -> None:
589
+ """Handle search submission."""
590
+ term = event.value
591
+ self.app.pop_screen()
592
+ self.on_search(term)
593
+
594
+ def action_cancel(self) -> None:
595
+ """Cancel search."""
596
+ self.app.pop_screen()
597
+
598
+
599
+ class DetailsScreen(Screen):
600
+ """App details screen."""
601
+
602
+ BINDINGS = [
603
+ Binding("q", "back", "Back"),
604
+ Binding("escape", "back", "Back", priority=True),
605
+ Binding("l", "logs", "Logs"),
606
+ Binding("r", "rebuild", "Rebuild"),
607
+ Binding("o", "open_url", "Open URL"),
608
+ ]
609
+
610
+ def __init__(self, data_provider: DataProvider, app: AppInfo, *args, **kwargs):
611
+ super().__init__(*args, **kwargs)
612
+ self.data_provider = data_provider
613
+ self.app_info = app
614
+
615
+ def compose(self) -> ComposeResult:
616
+ yield Header()
617
+ yield CommandBar(id="command-bar")
618
+ yield Container(
619
+ Static(self._render_details(), id="details-content"),
620
+ id="details-container"
621
+ )
622
+ yield Footer()
623
+
624
+ def on_mount(self) -> None:
625
+ """Set up command bar."""
626
+ cmd_bar = self.query_one("#command-bar", CommandBar)
627
+ cmd_bar.commands = CommandBar.format_commands([
628
+ ("l", "Logs"),
629
+ ("r", "Rebuild"),
630
+ ("o", "Open URL"),
631
+ ("Esc", "Back"),
632
+ ])
633
+
634
+ def _render_details(self) -> str:
635
+ """Render app details."""
636
+ app = self.app_info
637
+ lines = [
638
+ f"[bold cyan]{'='*60}[/bold cyan]",
639
+ f"[bold]{app.display_name}[/bold]",
640
+ f"[bold cyan]{'='*60}[/bold cyan]",
641
+ "",
642
+ "[bold]General[/bold]",
643
+ f" Name: {app.name}",
644
+ f" Namespace: {app.namespace}",
645
+ f" Project: {app.project_name or '(unlinked)'}",
646
+ "",
647
+ "[bold]Build[/bold]",
648
+ f" Status: {app.build_status.value}",
649
+ f" Builds: {app.build_count}",
650
+ f" Reason: {app.build_reason or 'N/A'}",
651
+ f" Stack: {app.stack or 'N/A'}",
652
+ "",
653
+ "[bold]Source[/bold]",
654
+ f" Git URL: {app.git_url or 'N/A'}",
655
+ f" Revision: {app.git_revision or 'N/A'}",
656
+ "",
657
+ "[bold]Runtime[/bold]",
658
+ f" Status: {app.runtime_status.value}",
659
+ f" Pods: {app.pod_count}",
660
+ f" URL: {app.url or 'N/A'}",
661
+ "",
662
+ "[bold]Image[/bold]",
663
+ f" Latest: {app.latest_image[:60] + '...' if app.latest_image and len(app.latest_image) > 60 else app.latest_image or 'N/A'}",
664
+ ]
665
+ return "\n".join(lines)
666
+
667
+ def action_back(self) -> None:
668
+ """Go back."""
669
+ self.app.pop_screen()
670
+
671
+ def action_logs(self) -> None:
672
+ """View logs."""
673
+ self.app.switch_screen(LogsScreen(self.data_provider, self.app_info))
674
+
675
+ def action_rebuild(self) -> None:
676
+ """Rebuild app."""
677
+ self.app.push_screen(ConfirmScreen(
678
+ f"Rebuild '{self.app_info.display_name}'?",
679
+ self._do_rebuild
680
+ ))
681
+
682
+ def _do_rebuild(self) -> None:
683
+ """Execute rebuild."""
684
+ result = self.data_provider.rebuild_app(self.app_info.name)
685
+ if result:
686
+ self.notify(f"Rebuild triggered for {self.app_info.display_name}")
687
+ else:
688
+ self.notify(f"Failed to rebuild", severity="error")
689
+
690
+ def action_open_url(self) -> None:
691
+ """Open app URL in browser."""
692
+ if self.app_info.url:
693
+ import webbrowser
694
+ webbrowser.open(self.app_info.url)
695
+ self.notify(f"Opening {self.app_info.url}")
696
+ else:
697
+ self.notify("No URL available", severity="warning")
698
+
699
+
700
+ class ProjectsScreen(Screen):
701
+ """Projects list screen."""
702
+
703
+ BINDINGS = [
704
+ Binding("q", "back", "Back"),
705
+ Binding("escape", "back", "Back", priority=True),
706
+ Binding("enter", "select", "Select"),
707
+ Binding("R", "refresh", "Refresh"),
708
+ ]
709
+
710
+ def __init__(self, data_provider: DataProvider, *args, **kwargs):
711
+ super().__init__(*args, **kwargs)
712
+ self.data_provider = data_provider
713
+ self.projects: List[ProjectInfo] = []
714
+
715
+ def compose(self) -> ComposeResult:
716
+ yield Header()
717
+ yield CommandBar(id="command-bar")
718
+ yield Container(
719
+ Static("Loading projects...", id="loading"),
720
+ DataTable(id="projects-table"),
721
+ id="main-container"
722
+ )
723
+ yield Footer()
724
+
725
+ def on_mount(self) -> None:
726
+ """Set up table and load data."""
727
+ cmd_bar = self.query_one("#command-bar", CommandBar)
728
+ cmd_bar.commands = CommandBar.format_commands([
729
+ ("Enter", "Select"),
730
+ ("R", "Refresh"),
731
+ ("Esc", "Back"),
732
+ ])
733
+
734
+ table = self.query_one("#projects-table", DataTable)
735
+ table.cursor_type = "row"
736
+ table.zebra_stripes = True
737
+
738
+ table.add_column("Name", key="name", width=30)
739
+ table.add_column("ID", key="id", width=40)
740
+ table.add_column("Namespace", key="namespace", width=20)
741
+
742
+ self.load_projects()
743
+
744
+ @work(exclusive=True)
745
+ async def load_projects(self) -> None:
746
+ """Load projects in background."""
747
+ worker = get_current_worker()
748
+ try:
749
+ projects = self.data_provider.get_projects()
750
+ if not worker.is_cancelled:
751
+ self.call_from_thread(self._update_table, projects)
752
+ except Exception as e:
753
+ if not worker.is_cancelled:
754
+ self.call_from_thread(self._show_error, str(e))
755
+
756
+ def _update_table(self, projects: List[ProjectInfo]) -> None:
757
+ """Update table with projects."""
758
+ self.projects = projects
759
+
760
+ loading = self.query_one("#loading", Static)
761
+ loading.display = False
762
+
763
+ table = self.query_one("#projects-table", DataTable)
764
+ table.display = True
765
+ table.clear()
766
+
767
+ for p in projects:
768
+ table.add_row(p.name, p.id, p.namespace, key=p.id)
769
+
770
+ def _show_error(self, message: str) -> None:
771
+ """Show error message."""
772
+ loading = self.query_one("#loading", Static)
773
+ loading.update(f"[red]Error: {message}[/red]")
774
+
775
+ def action_back(self) -> None:
776
+ """Go back."""
777
+ self.app.pop_screen()
778
+
779
+ def action_select(self) -> None:
780
+ """Select project and go to apps."""
781
+ table = self.query_one("#projects-table", DataTable)
782
+ if table.cursor_row is not None and self.projects:
783
+ row_key = table.get_row_key_at(table.cursor_row)
784
+ for p in self.projects:
785
+ if p.id == str(row_key.value):
786
+ # TODO: Set project context
787
+ self.notify(f"Selected project: {p.name}")
788
+ self.app.pop_screen()
789
+ break
790
+
791
+ def action_refresh(self) -> None:
792
+ """Refresh projects."""
793
+ loading = self.query_one("#loading", Static)
794
+ loading.update("Refreshing...")
795
+ loading.display = True
796
+ self.load_projects()
797
+
798
+
799
+ class ConfirmScreen(Screen):
800
+ """Confirmation dialog screen."""
801
+
802
+ BINDINGS = [
803
+ Binding("y", "confirm", "Yes"),
804
+ Binding("n", "cancel", "No"),
805
+ Binding("escape", "cancel", "Cancel", priority=True),
806
+ ]
807
+
808
+ def __init__(self, message: str, on_confirm: callable, *args, **kwargs):
809
+ super().__init__(*args, **kwargs)
810
+ self.message = message
811
+ self.on_confirm = on_confirm
812
+
813
+ def compose(self) -> ComposeResult:
814
+ yield Container(
815
+ Static(f"[bold yellow]{self.message}[/bold yellow]", id="confirm-message"),
816
+ Horizontal(
817
+ Button("Yes (y)", id="yes-btn", variant="warning"),
818
+ Button("No (n)", id="no-btn", variant="primary"),
819
+ id="confirm-buttons"
820
+ ),
821
+ id="confirm-dialog"
822
+ )
823
+
824
+ def action_confirm(self) -> None:
825
+ """Confirm action."""
826
+ self.app.pop_screen()
827
+ self.on_confirm()
828
+
829
+ def action_cancel(self) -> None:
830
+ """Cancel action."""
831
+ self.app.pop_screen()
832
+
833
+ def on_button_pressed(self, event: Button.Pressed) -> None:
834
+ """Handle button press."""
835
+ if event.button.id == "yes-btn":
836
+ self.action_confirm()
837
+ else:
838
+ self.action_cancel()
839
+
840
+
841
+ class HelpScreen(Screen):
842
+ """Help screen showing keyboard shortcuts."""
843
+
844
+ BINDINGS = [
845
+ Binding("q", "back", "Back"),
846
+ Binding("escape", "back", "Back", priority=True),
847
+ ]
848
+
849
+ def compose(self) -> ComposeResult:
850
+ yield Container(
851
+ Static(self._render_help(), id="help-content"),
852
+ id="help-container"
853
+ )
854
+ yield Footer()
855
+
856
+ def _render_help(self) -> str:
857
+ """Render help content."""
858
+ return """
859
+ [bold cyan]╔══════════════════════════════════════════════════════════════╗[/bold cyan]
860
+ [bold cyan]║[/bold cyan] [bold white]Clue2App TUI - Quick Reference[/bold white] [bold cyan]║[/bold cyan]
861
+ [bold cyan]╚══════════════════════════════════════════════════════════════╝[/bold cyan]
862
+
863
+ [bold green]┌─ NAVIGATION ─────────────────────────────────────────────────┐[/bold green]
864
+ │ │
865
+ │ [bold cyan]↑[/bold cyan] or [bold cyan]k[/bold cyan] Move selection up │
866
+ │ [bold cyan]↓[/bold cyan] or [bold cyan]j[/bold cyan] Move selection down │
867
+ │ [bold cyan]Enter[/bold cyan] Open / Select item │
868
+ │ [bold cyan]Escape[/bold cyan] Go back to previous screen │
869
+ │ [bold cyan]q[/bold cyan] Quit (from main screen) / Back │
870
+ │ │
871
+ [bold green]└───────────────────────────────────────────────────────────────┘[/bold green]
872
+
873
+ [bold yellow]┌─ APPS SCREEN ─────────────────────────────────────────────────┐[/bold yellow]
874
+ │ │
875
+ │ [bold cyan]Enter[/bold cyan] View app details │
876
+ │ [bold cyan]l[/bold cyan] View app logs │
877
+ │ [bold cyan]r[/bold cyan] Rebuild app (triggers new build) │
878
+ │ [bold cyan]d[/bold cyan] Delete app (asks for confirmation) │
879
+ │ [bold cyan]p[/bold cyan] Switch to projects view │
880
+ │ [bold cyan]a[/bold cyan] Toggle all projects / current project only │
881
+ │ [bold cyan]u[/bold cyan] Toggle unlinked apps visibility │
882
+ │ [bold cyan]R[/bold cyan] Refresh the list │
883
+ │ │
884
+ [bold yellow]└───────────────────────────────────────────────────────────────┘[/bold yellow]
885
+
886
+ [bold magenta]┌─ LOGS SCREEN ─────────────────────────────────────────────────┐[/bold magenta]
887
+ │ │
888
+ │ [bold cyan]j[/bold cyan] / [bold cyan]k[/bold cyan] Scroll down / up (one line) │
889
+ │ [bold cyan]Ctrl+D[/bold cyan] Scroll down half page │
890
+ │ [bold cyan]Ctrl+U[/bold cyan] Scroll up half page │
891
+ │ [bold cyan]g[/bold cyan] Jump to top of logs │
892
+ │ [bold cyan]G[/bold cyan] Jump to bottom of logs │
893
+ │ [bold cyan]f[/bold cyan] Toggle follow mode (live updates) │
894
+ │ [bold cyan]/[/bold cyan] Search in logs │
895
+ │ [bold cyan]n[/bold cyan] Go to next search match │
896
+ │ [bold cyan]N[/bold cyan] Go to previous search match │
897
+ │ │
898
+ [bold magenta]└───────────────────────────────────────────────────────────────┘[/bold magenta]
899
+
900
+ [bold blue]┌─ DETAILS SCREEN ───────────────────────────────────────────────┐[/bold blue]
901
+ │ │
902
+ │ [bold cyan]l[/bold cyan] View logs │
903
+ │ [bold cyan]r[/bold cyan] Rebuild app │
904
+ │ [bold cyan]o[/bold cyan] Open app URL in browser │
905
+ │ │
906
+ [bold blue]└───────────────────────────────────────────────────────────────┘[/bold blue]
907
+
908
+ [dim]Press [bold]q[/bold] or [bold]Escape[/bold] to close this help and return.[/dim]
909
+ """
910
+
911
+ def action_back(self) -> None:
912
+ """Close help."""
913
+ self.app.pop_screen()
914
+
915
+
916
+ class Clue2AppTUI(App):
917
+ """Main Clue2App TUI application."""
918
+
919
+ CSS = """
920
+ #main-container {
921
+ height: 100%;
922
+ padding: 1;
923
+ }
924
+
925
+ #loading {
926
+ text-align: center;
927
+ padding: 2;
928
+ }
929
+
930
+ #apps-table {
931
+ height: 100%;
932
+ }
933
+
934
+ #status-bar {
935
+ dock: bottom;
936
+ height: 1;
937
+ background: $surface;
938
+ padding: 0 1;
939
+ }
940
+
941
+ #logs-container {
942
+ height: 100%;
943
+ padding: 1;
944
+ overflow-y: auto;
945
+ }
946
+
947
+ #logs-header {
948
+ padding: 0 0 1 0;
949
+ }
950
+
951
+ #logs-content {
952
+ height: auto;
953
+ }
954
+
955
+ #details-container {
956
+ height: 100%;
957
+ padding: 1;
958
+ overflow-y: auto;
959
+ }
960
+
961
+ #confirm-dialog {
962
+ align: center middle;
963
+ width: 60;
964
+ height: 10;
965
+ border: solid $primary;
966
+ padding: 1 2;
967
+ }
968
+
969
+ #confirm-message {
970
+ text-align: center;
971
+ padding: 1;
972
+ }
973
+
974
+ #confirm-buttons {
975
+ align: center middle;
976
+ height: 3;
977
+ }
978
+
979
+ #confirm-buttons Button {
980
+ margin: 0 1;
981
+ }
982
+
983
+ #help-container {
984
+ padding: 2;
985
+ overflow-y: auto;
986
+ }
987
+
988
+ #search-dialog {
989
+ align: center middle;
990
+ width: 60;
991
+ height: 5;
992
+ border: solid $primary;
993
+ padding: 1 2;
994
+ background: $surface;
995
+ }
996
+
997
+ #search-label {
998
+ padding: 0 0 1 0;
999
+ }
1000
+
1001
+ #search-input {
1002
+ width: 100%;
1003
+ }
1004
+ """
1005
+
1006
+ TITLE = "Clue2App TUI"
1007
+ BINDINGS = [
1008
+ Binding("q", "quit", "Quit", show=True),
1009
+ ]
1010
+
1011
+ def __init__(self, initial_screen: str = 'apps', *args, **kwargs):
1012
+ super().__init__(*args, **kwargs)
1013
+ self.initial_screen = initial_screen
1014
+ self.data_provider = DataProvider()
1015
+
1016
+ def on_mount(self) -> None:
1017
+ """Set up initial screen."""
1018
+ if self.initial_screen == 'projects':
1019
+ self.push_screen(ProjectsScreen(self.data_provider))
1020
+ else:
1021
+ self.push_screen(AppsScreen(self.data_provider))