@anastops/cli 1.0.0 → 1.2.0

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.
Files changed (74) hide show
  1. package/dist/commands/doctor.d.ts.map +1 -1
  2. package/dist/commands/doctor.js +8 -6
  3. package/dist/commands/doctor.js.map +1 -1
  4. package/dist/commands/init.d.ts +17 -0
  5. package/dist/commands/init.d.ts.map +1 -1
  6. package/dist/commands/init.js +315 -17
  7. package/dist/commands/init.js.map +1 -1
  8. package/dist/commands/ranger.d.ts +7 -2
  9. package/dist/commands/ranger.d.ts.map +1 -1
  10. package/dist/commands/ranger.js +99 -21
  11. package/dist/commands/ranger.js.map +1 -1
  12. package/dist/commands/uninstall.d.ts +16 -0
  13. package/dist/commands/uninstall.d.ts.map +1 -0
  14. package/dist/commands/uninstall.js +206 -0
  15. package/dist/commands/uninstall.js.map +1 -0
  16. package/dist/index.js +11 -0
  17. package/dist/index.js.map +1 -1
  18. package/package.json +8 -6
  19. package/ranger-tui/pyproject.toml +51 -0
  20. package/ranger-tui/ranger_tui/__init__.py +5 -0
  21. package/ranger-tui/ranger_tui/__main__.py +16 -0
  22. package/ranger-tui/ranger_tui/__pycache__/__init__.cpython-314.pyc +0 -0
  23. package/ranger-tui/ranger_tui/__pycache__/__main__.cpython-314.pyc +0 -0
  24. package/ranger-tui/ranger_tui/__pycache__/accessibility.cpython-314.pyc +0 -0
  25. package/ranger-tui/ranger_tui/__pycache__/app.cpython-314.pyc +0 -0
  26. package/ranger-tui/ranger_tui/__pycache__/config.cpython-314.pyc +0 -0
  27. package/ranger-tui/ranger_tui/__pycache__/theme.cpython-314.pyc +0 -0
  28. package/ranger-tui/ranger_tui/accessibility.py +499 -0
  29. package/ranger-tui/ranger_tui/actions/__init__.py +13 -0
  30. package/ranger-tui/ranger_tui/actions/agent_actions.py +74 -0
  31. package/ranger-tui/ranger_tui/actions/session_actions.py +110 -0
  32. package/ranger-tui/ranger_tui/actions/task_actions.py +107 -0
  33. package/ranger-tui/ranger_tui/app.py +93 -0
  34. package/ranger-tui/ranger_tui/assets/ranger_head.png +0 -0
  35. package/ranger-tui/ranger_tui/config.py +100 -0
  36. package/ranger-tui/ranger_tui/data/__init__.py +16 -0
  37. package/ranger-tui/ranger_tui/data/__pycache__/__init__.cpython-314.pyc +0 -0
  38. package/ranger-tui/ranger_tui/data/__pycache__/client.cpython-314.pyc +0 -0
  39. package/ranger-tui/ranger_tui/data/__pycache__/models.cpython-314.pyc +0 -0
  40. package/ranger-tui/ranger_tui/data/client.py +858 -0
  41. package/ranger-tui/ranger_tui/data/models.py +151 -0
  42. package/ranger-tui/ranger_tui/screens/__init__.py +16 -0
  43. package/ranger-tui/ranger_tui/screens/__pycache__/__init__.cpython-314.pyc +0 -0
  44. package/ranger-tui/ranger_tui/screens/__pycache__/dashboard.cpython-314.pyc +0 -0
  45. package/ranger-tui/ranger_tui/screens/__pycache__/modals.cpython-314.pyc +0 -0
  46. package/ranger-tui/ranger_tui/screens/__pycache__/session.cpython-314.pyc +0 -0
  47. package/ranger-tui/ranger_tui/screens/__pycache__/task.cpython-314.pyc +0 -0
  48. package/ranger-tui/ranger_tui/screens/command_palette.py +357 -0
  49. package/ranger-tui/ranger_tui/screens/dashboard.py +232 -0
  50. package/ranger-tui/ranger_tui/screens/help.py +103 -0
  51. package/ranger-tui/ranger_tui/screens/modals.py +95 -0
  52. package/ranger-tui/ranger_tui/screens/session.py +289 -0
  53. package/ranger-tui/ranger_tui/screens/task.py +187 -0
  54. package/ranger-tui/ranger_tui/styles/ranger.tcss +254 -0
  55. package/ranger-tui/ranger_tui/theme.py +93 -0
  56. package/ranger-tui/ranger_tui/widgets/__init__.py +23 -0
  57. package/ranger-tui/ranger_tui/widgets/__pycache__/__init__.cpython-314.pyc +0 -0
  58. package/ranger-tui/ranger_tui/widgets/__pycache__/accessible.cpython-314.pyc +0 -0
  59. package/ranger-tui/ranger_tui/widgets/__pycache__/logo.cpython-314.pyc +0 -0
  60. package/ranger-tui/ranger_tui/widgets/__pycache__/logo_assets.cpython-314.pyc +0 -0
  61. package/ranger-tui/ranger_tui/widgets/__pycache__/ranger_image.cpython-314.pyc +0 -0
  62. package/ranger-tui/ranger_tui/widgets/__pycache__/sidebar.cpython-314.pyc +0 -0
  63. package/ranger-tui/ranger_tui/widgets/__pycache__/topbar.cpython-314.pyc +0 -0
  64. package/ranger-tui/ranger_tui/widgets/accessible.py +176 -0
  65. package/ranger-tui/ranger_tui/widgets/agents_table.py +151 -0
  66. package/ranger-tui/ranger_tui/widgets/header.py +141 -0
  67. package/ranger-tui/ranger_tui/widgets/logo.py +258 -0
  68. package/ranger-tui/ranger_tui/widgets/logo_assets.py +62 -0
  69. package/ranger-tui/ranger_tui/widgets/metrics_panel.py +121 -0
  70. package/ranger-tui/ranger_tui/widgets/ranger_image.py +91 -0
  71. package/ranger-tui/ranger_tui/widgets/sessions_table.py +191 -0
  72. package/ranger-tui/ranger_tui/widgets/sidebar.py +91 -0
  73. package/ranger-tui/ranger_tui/widgets/tasks_table.py +189 -0
  74. package/ranger-tui/ranger_tui/widgets/topbar.py +168 -0
@@ -0,0 +1,121 @@
1
+ """
2
+ Metrics panel widget showing session statistics.
3
+ """
4
+
5
+ from textual.app import ComposeResult
6
+ from textual.widget import Widget
7
+ from textual.widgets import Static
8
+ from textual.containers import Vertical
9
+ from textual.reactive import reactive
10
+
11
+ from ranger_tui.data import Session
12
+
13
+
14
+ class MetricsPanel(Widget):
15
+ """Panel widget for displaying session metrics."""
16
+
17
+ DEFAULT_CSS = """
18
+ MetricsPanel {
19
+ height: 1fr;
20
+ padding: 1;
21
+ }
22
+
23
+ MetricsPanel .metric-row {
24
+ height: auto;
25
+ margin-bottom: 1;
26
+ }
27
+
28
+ MetricsPanel .metric-label {
29
+ color: $text-muted;
30
+ }
31
+
32
+ MetricsPanel .metric-value {
33
+ text-style: bold;
34
+ }
35
+
36
+ MetricsPanel .metric-value-success {
37
+ color: $success;
38
+ text-style: bold;
39
+ }
40
+
41
+ MetricsPanel .metric-value-warning {
42
+ color: $warning;
43
+ text-style: bold;
44
+ }
45
+
46
+ MetricsPanel .metric-value-error {
47
+ color: $error;
48
+ text-style: bold;
49
+ }
50
+ """
51
+
52
+ total_sessions = reactive(0)
53
+ active_sessions = reactive(0)
54
+ archived_sessions = reactive(0)
55
+ total_tasks = reactive(0)
56
+ completed_tasks = reactive(0)
57
+ failed_tasks = reactive(0)
58
+ total_tokens = reactive(0)
59
+ total_cost = reactive(0.0)
60
+
61
+ def compose(self) -> ComposeResult:
62
+ """Compose the metrics panel."""
63
+ with Vertical():
64
+ yield Static("[bold]Sessions[/]", classes="section-title", markup=True)
65
+ yield Static("", id="sessions-metric")
66
+
67
+ yield Static("") # Spacer
68
+
69
+ yield Static("[bold]Tasks[/]", classes="section-title", markup=True)
70
+ yield Static("", id="tasks-metric")
71
+
72
+ yield Static("") # Spacer
73
+
74
+ yield Static("[bold]Usage[/]", classes="section-title", markup=True)
75
+ yield Static("", id="usage-metric")
76
+
77
+ async def update_metrics(self, sessions: list[Session]) -> None:
78
+ """Update metrics from session list."""
79
+ self.total_sessions = len(sessions)
80
+ self.active_sessions = sum(1 for s in sessions if s.status == "active")
81
+ self.archived_sessions = sum(1 for s in sessions if s.status == "archived")
82
+
83
+ self.total_tasks = sum(s.metadata.tasks_total for s in sessions)
84
+ self.completed_tasks = sum(s.metadata.tasks_completed for s in sessions)
85
+ self.failed_tasks = sum(s.metadata.tasks_failed for s in sessions)
86
+
87
+ self.total_tokens = sum(s.metadata.total_tokens for s in sessions)
88
+ self.total_cost = sum(s.metadata.total_cost for s in sessions)
89
+
90
+ await self._update_display()
91
+
92
+ async def _update_display(self) -> None:
93
+ """Update the display with current metrics."""
94
+ # Sessions metric
95
+ sessions_text = (
96
+ f"Total: [bold]{self.total_sessions}[/]\n"
97
+ f"Active: [green]{self.active_sessions}[/]\n"
98
+ f"Archived: [dim]{self.archived_sessions}[/]"
99
+ )
100
+ self.query_one("#sessions-metric", Static).update(sessions_text)
101
+
102
+ # Tasks metric
103
+ success_rate = (
104
+ (self.completed_tasks / self.total_tasks * 100)
105
+ if self.total_tasks > 0
106
+ else 0
107
+ )
108
+ tasks_text = (
109
+ f"Total: [bold]{self.total_tasks}[/]\n"
110
+ f"Completed: [green]{self.completed_tasks}[/]\n"
111
+ f"Failed: [red]{self.failed_tasks}[/]\n"
112
+ f"Success: [cyan]{success_rate:.1f}%[/]"
113
+ )
114
+ self.query_one("#tasks-metric", Static).update(tasks_text)
115
+
116
+ # Usage metric
117
+ usage_text = (
118
+ f"Tokens: [bold]{self.total_tokens:,}[/]\n"
119
+ f"Cost: [yellow]${self.total_cost:.4f}[/]"
120
+ )
121
+ self.query_one("#usage-metric", Static).update(usage_text)
@@ -0,0 +1,91 @@
1
+ """
2
+ RangerImage widget - Displays the ranger head image using textual-image with circular frame.
3
+ """
4
+
5
+ from pathlib import Path
6
+ from textual.widget import Widget
7
+ from textual.app import ComposeResult
8
+ from textual.containers import Horizontal, Vertical
9
+ from textual.widgets import Static
10
+
11
+ try:
12
+ from textual_image.widget import Image
13
+ HAS_IMAGE_SUPPORT = True
14
+ except ImportError:
15
+ HAS_IMAGE_SUPPORT = False
16
+ Image = None
17
+
18
+
19
+ class RangerImage(Widget):
20
+ """Widget to display the ranger head image with circular frame."""
21
+
22
+ DEFAULT_CSS = """
23
+ RangerImage {
24
+ width: auto;
25
+ height: auto;
26
+ align: center middle;
27
+ }
28
+
29
+ RangerImage .circle-frame {
30
+ width: auto;
31
+ height: auto;
32
+ }
33
+
34
+ RangerImage .circle-top {
35
+ width: auto;
36
+ height: 1;
37
+ content-align: center middle;
38
+ }
39
+
40
+ RangerImage .circle-middle {
41
+ width: auto;
42
+ height: auto;
43
+ layout: horizontal;
44
+ }
45
+
46
+ RangerImage .circle-left, RangerImage .circle-right {
47
+ width: 1;
48
+ height: auto;
49
+ content-align: center middle;
50
+ }
51
+
52
+ RangerImage .image-container {
53
+ width: 8;
54
+ height: 4;
55
+ margin: 0 1;
56
+ }
57
+
58
+ RangerImage .circle-bottom {
59
+ width: auto;
60
+ height: 1;
61
+ content-align: center middle;
62
+ }
63
+
64
+ RangerImage Image {
65
+ width: 100%;
66
+ height: 100%;
67
+ }
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ name: str | None = None,
73
+ id: str | None = None,
74
+ classes: str | None = None,
75
+ ) -> None:
76
+ super().__init__(name=name, id=id, classes=classes)
77
+
78
+ # Get the path to the ranger head image
79
+ self._image_path = Path(__file__).parent.parent / "assets" / "ranger_head.png"
80
+
81
+ def compose(self) -> ComposeResult:
82
+ # Circular frame using ASCII art (Looney Tunes style) - matching theme colors
83
+ with Vertical(classes="circle-frame"):
84
+ yield Static("[#3d5c8c]╭──────────╮[/]", classes="circle-top", markup=True)
85
+ with Horizontal(classes="circle-middle"):
86
+ yield Static("[#3d5c8c]│[/]", classes="circle-left", markup=True)
87
+ with Vertical(classes="image-container"):
88
+ if HAS_IMAGE_SUPPORT and self._image_path.exists():
89
+ yield Image(self._image_path)
90
+ yield Static("[#3d5c8c]│[/]", classes="circle-right", markup=True)
91
+ yield Static("[#3d5c8c]╰──────────╯[/]", classes="circle-bottom", markup=True)
@@ -0,0 +1,191 @@
1
+ """
2
+ Sessions table widget using Textual DataTable.
3
+ """
4
+
5
+ from datetime import datetime, UTC
6
+ from textual.app import ComposeResult
7
+ from textual.widget import Widget
8
+ from textual.widgets import DataTable
9
+ from textual.reactive import reactive
10
+
11
+ from ranger_tui.data import Session, SessionMetadata
12
+
13
+
14
+ class SessionsTable(Widget):
15
+ """Table widget for displaying sessions."""
16
+
17
+ DEFAULT_CSS = """
18
+ SessionsTable {
19
+ height: 1fr;
20
+ }
21
+
22
+ SessionsTable DataTable {
23
+ height: 100%;
24
+ }
25
+ """
26
+
27
+ sessions: reactive[list[Session]] = reactive(list, init=False)
28
+ _selected_row_key: str | None = None
29
+
30
+ def __init__(self, **kwargs):
31
+ super().__init__(**kwargs)
32
+ self.sessions = []
33
+
34
+ def compose(self) -> ComposeResult:
35
+ """Compose the table."""
36
+ table = DataTable(
37
+ id="sessions-data-table",
38
+ cursor_type="row",
39
+ zebra_stripes=True,
40
+ )
41
+ yield table
42
+
43
+ def on_mount(self) -> None:
44
+ """Set up the table columns with proportional widths."""
45
+ # Calculate available width based on parent container
46
+ try:
47
+ table_width = self.app.size.width - 34 # Sidebar(28) + padding(6)
48
+ except Exception:
49
+ table_width = 100
50
+
51
+ table_width = max(table_width, 60)
52
+ usable_width = table_width - 6 # Column separators
53
+
54
+ # Proportional: Status 10%, ID 14%, Objective 38%, Tasks 8%, Tokens 10%, Cost 10%, Age 10%
55
+ status_w = max(8, int(usable_width * 0.10))
56
+ id_w = max(10, int(usable_width * 0.14))
57
+ tasks_w = max(5, int(usable_width * 0.08))
58
+ tokens_w = max(6, int(usable_width * 0.10))
59
+ cost_w = max(6, int(usable_width * 0.10))
60
+ age_w = max(4, int(usable_width * 0.10))
61
+ objective_w = usable_width - status_w - id_w - tasks_w - tokens_w - cost_w - age_w
62
+ objective_w = max(15, objective_w)
63
+
64
+ table = self.query_one(DataTable)
65
+ table.add_column("Status", key="status", width=status_w)
66
+ table.add_column("ID", key="id", width=id_w)
67
+ table.add_column("Objective", key="objective", width=objective_w)
68
+ table.add_column("Tasks", key="tasks", width=tasks_w)
69
+ table.add_column("Tokens", key="tokens", width=tokens_w)
70
+ table.add_column("Cost", key="cost", width=cost_w)
71
+ table.add_column("Age", key="age", width=age_w)
72
+
73
+ async def update_sessions(self, sessions: list[Session]) -> None:
74
+ """Update the table with new session data."""
75
+ self.sessions = sessions
76
+
77
+ table = self.query_one(DataTable)
78
+
79
+ # Remember current selection
80
+ current_key = self._selected_row_key
81
+
82
+ # Clear and repopulate
83
+ table.clear()
84
+
85
+ for session in sessions:
86
+ status_display = self._format_status(session.status)
87
+ tasks_display = self._format_tasks(session.metadata)
88
+ tokens_display = f"{session.metadata.total_tokens:,}"
89
+ cost_display = f"${session.metadata.total_cost:.4f}"
90
+ age_display = self._format_age(session.created_at)
91
+ objective_display = session.objective[:38] + "..." if len(session.objective) > 40 else session.objective
92
+
93
+ table.add_row(
94
+ status_display,
95
+ session.id,
96
+ objective_display,
97
+ tasks_display,
98
+ tokens_display,
99
+ cost_display,
100
+ age_display,
101
+ key=session.id,
102
+ )
103
+
104
+ # Restore selection if possible
105
+ if current_key:
106
+ try:
107
+ row_index = table.get_row_index(current_key)
108
+ table.cursor_coordinate = (row_index, 0)
109
+ self._selected_row_key = current_key
110
+ except Exception:
111
+ pass
112
+
113
+ def _format_status(self, status: str) -> str:
114
+ """Format status with color markup."""
115
+ status_colors = {
116
+ "active": "[green]● active[/]",
117
+ "completed": "[blue]✓ done[/]",
118
+ "archived": "[dim]◌ archived[/]",
119
+ }
120
+ return status_colors.get(status, f"[yellow]{status}[/]")
121
+
122
+ def _format_tasks(self, metadata: SessionMetadata) -> str:
123
+ """Format tasks as completed/total with running indicator."""
124
+ total = metadata.tasks_total
125
+ completed = metadata.tasks_completed
126
+ running = metadata.tasks_running
127
+
128
+ if running > 0:
129
+ # Animated spinner chars for running tasks
130
+ spinner_frames = ["◐", "◓", "◑", "◒"]
131
+ frame_idx = int(datetime.now(UTC).timestamp() * 2) % len(spinner_frames)
132
+ spinner = spinner_frames[frame_idx]
133
+ return f"[cyan]{spinner}[/] {completed}/{total}"
134
+ elif total == 0:
135
+ return "[dim]0/0[/]"
136
+ elif completed == total:
137
+ return f"[green]✓[/] {completed}/{total}"
138
+ else:
139
+ return f"{completed}/{total}"
140
+
141
+ def _format_age(self, created_at: datetime) -> str:
142
+ """Format age in human-readable form."""
143
+ # Handle naive datetimes from MongoDB (assume UTC)
144
+ if created_at.tzinfo is None:
145
+ created_at = created_at.replace(tzinfo=UTC)
146
+ age = datetime.now(UTC) - created_at
147
+
148
+ if age.total_seconds() < 60:
149
+ return f"{int(age.total_seconds())}s"
150
+ elif age.total_seconds() < 3600:
151
+ return f"{int(age.total_seconds() / 60)}m"
152
+ elif age.total_seconds() < 86400:
153
+ return f"{int(age.total_seconds() / 3600)}h"
154
+ else:
155
+ return f"{int(age.total_seconds() / 86400)}d"
156
+
157
+ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
158
+ """Handle row selection."""
159
+ self._selected_row_key = str(event.row_key.value) if event.row_key else None
160
+
161
+ def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
162
+ """Handle row highlight (cursor movement)."""
163
+ self._selected_row_key = str(event.row_key.value) if event.row_key else None
164
+
165
+ def get_selected_session_id(self) -> str | None:
166
+ """Get the currently selected session ID."""
167
+ table = self.query_one(DataTable)
168
+ if table.cursor_row is not None:
169
+ row_key = table.get_row_at(table.cursor_row)
170
+ if row_key:
171
+ # Get the ID from the second column (index 1)
172
+ return str(table.get_cell_at((table.cursor_row, 1)))
173
+ return self._selected_row_key
174
+
175
+ @property
176
+ def row_count(self) -> int:
177
+ """Get the number of rows in the table."""
178
+ table = self.query_one(DataTable)
179
+ return table.row_count
180
+
181
+ @property
182
+ def column_count(self) -> int:
183
+ """Get the number of columns in the table."""
184
+ table = self.query_one(DataTable)
185
+ return len(table.columns)
186
+
187
+ @property
188
+ def cursor_row(self) -> int | None:
189
+ """Get the current cursor row."""
190
+ table = self.query_one(DataTable)
191
+ return table.cursor_row
@@ -0,0 +1,91 @@
1
+ """
2
+ Sidebar widget - Right panel for stats and details.
3
+ """
4
+
5
+ from textual.widget import Widget
6
+ from textual.app import ComposeResult
7
+ from textual.widgets import Static
8
+
9
+
10
+ from ranger_tui.widgets.logo import Logo
11
+
12
+
13
+ class Sidebar(Widget):
14
+ """Sidebar panel for displaying stats and details."""
15
+
16
+ DEFAULT_CSS = """
17
+ Sidebar {
18
+ width: 28;
19
+ min-width: 26;
20
+ max-width: 32;
21
+ background: $panel;
22
+ border: round $secondary-darken-2;
23
+ padding: 1 2;
24
+ layout: vertical;
25
+ }
26
+
27
+ Sidebar > .sidebar-title {
28
+ text-style: bold;
29
+ color: $primary;
30
+ margin-bottom: 1;
31
+ dock: top;
32
+ }
33
+
34
+ Sidebar > .sidebar-content {
35
+ color: $foreground;
36
+ }
37
+
38
+ Sidebar > #spacer {
39
+ height: 1fr;
40
+ }
41
+
42
+ Sidebar > Logo {
43
+ margin-top: 1;
44
+ dock: bottom;
45
+ }
46
+
47
+ Sidebar .stat-label {
48
+ color: $foreground-muted;
49
+ }
50
+
51
+ Sidebar .stat-value {
52
+ color: $foreground;
53
+ }
54
+
55
+ Sidebar .stat-success {
56
+ color: $success;
57
+ }
58
+
59
+ Sidebar .stat-error {
60
+ color: $error;
61
+ }
62
+
63
+ Sidebar .stat-warning {
64
+ color: $warning;
65
+ }
66
+ """
67
+
68
+ def __init__(self, title: str = "Details", content: str = "") -> None:
69
+ super().__init__()
70
+ self._title = title
71
+ self._content = content
72
+
73
+ def compose(self) -> ComposeResult:
74
+ yield Static(self._title, classes="sidebar-title", id="sidebar-title")
75
+ yield Static(self._content, classes="sidebar-content", id="sidebar-content")
76
+
77
+ def update_title(self, title: str) -> None:
78
+ """Update sidebar title."""
79
+ self._title = title
80
+ try:
81
+ self.query_one("#sidebar-title", Static).update(title)
82
+ except Exception:
83
+ pass
84
+
85
+ def update_content(self, content: str) -> None:
86
+ """Update sidebar content."""
87
+ self._content = content
88
+ try:
89
+ self.query_one("#sidebar-content", Static).update(content)
90
+ except Exception:
91
+ pass
@@ -0,0 +1,189 @@
1
+ """
2
+ Tasks table widget using Textual DataTable.
3
+ """
4
+
5
+ from datetime import datetime, UTC
6
+ from textual.app import ComposeResult
7
+ from textual.widget import Widget
8
+ from textual.widgets import DataTable
9
+ from textual.reactive import reactive
10
+
11
+ from ranger_tui.data import Task
12
+ from ranger_tui.theme import format_model
13
+
14
+
15
+ class TasksTable(Widget):
16
+ """Table widget for displaying tasks."""
17
+
18
+ DEFAULT_CSS = """
19
+ TasksTable {
20
+ height: 1fr;
21
+ }
22
+
23
+ TasksTable DataTable {
24
+ height: 100%;
25
+ }
26
+ """
27
+
28
+ tasks: reactive[list[Task]] = reactive(list, init=False)
29
+ _selected_row_key: str | None = None
30
+
31
+ def __init__(self, **kwargs):
32
+ super().__init__(**kwargs)
33
+ self.tasks = []
34
+
35
+ def compose(self) -> ComposeResult:
36
+ """Compose the table."""
37
+ table = DataTable(
38
+ id="tasks-data-table",
39
+ cursor_type="row",
40
+ zebra_stripes=True,
41
+ )
42
+ yield table
43
+
44
+ def on_mount(self) -> None:
45
+ """Set up the table columns with proportional widths."""
46
+ # Calculate available width based on parent container
47
+ try:
48
+ table_width = self.app.size.width - 34 # Sidebar(28) + padding(6)
49
+ except Exception:
50
+ table_width = 100
51
+
52
+ table_width = max(table_width, 60)
53
+ usable_width = table_width - 6 # Column separators
54
+
55
+ # Proportional: Status 10%, ID 11%, Type 7%, Description 22%, Provider 8%, Model 11%, Cost 8%, Tokens 8%, Age 7%
56
+ status_w = max(8, int(usable_width * 0.10))
57
+ id_w = max(10, int(usable_width * 0.11))
58
+ type_w = max(6, int(usable_width * 0.07))
59
+ provider_w = max(8, int(usable_width * 0.08))
60
+ model_w = max(10, int(usable_width * 0.11))
61
+ cost_w = max(6, int(usable_width * 0.08))
62
+ tokens_w = max(6, int(usable_width * 0.08))
63
+ age_w = max(4, int(usable_width * 0.07))
64
+ desc_w = usable_width - status_w - id_w - type_w - provider_w - model_w - cost_w - tokens_w - age_w
65
+ desc_w = max(15, desc_w)
66
+
67
+ table = self.query_one(DataTable)
68
+ table.add_column("Status", key="status", width=status_w)
69
+ table.add_column("ID", key="id", width=id_w)
70
+ table.add_column("Type", key="type", width=type_w)
71
+ table.add_column("Description", key="description", width=desc_w)
72
+ table.add_column("Provider", key="provider", width=provider_w)
73
+ table.add_column("Model", key="model", width=model_w)
74
+ table.add_column("Cost", key="cost", width=cost_w)
75
+ table.add_column("Tokens", key="tokens", width=tokens_w)
76
+ table.add_column("Age", key="age", width=age_w)
77
+
78
+ async def update_tasks(self, tasks: list[Task]) -> None:
79
+ """Update the table with new task data."""
80
+ self.tasks = tasks
81
+
82
+ table = self.query_one(DataTable)
83
+
84
+ # Remember current selection
85
+ current_key = self._selected_row_key
86
+
87
+ # Clear and repopulate
88
+ table.clear()
89
+
90
+ for task in tasks:
91
+ status_display = self._format_status(task.status)
92
+ tokens_display = f"{task.token_usage.total_tokens:,}"
93
+ cost_display = self._format_cost(task.token_usage.cost)
94
+ age_display = self._format_age(task.created_at)
95
+ model_display = format_model(task.model)
96
+ description_display = task.description[:33] + "..." if len(task.description) > 35 else task.description
97
+
98
+ table.add_row(
99
+ status_display,
100
+ task.id,
101
+ task.type,
102
+ description_display,
103
+ task.provider,
104
+ model_display,
105
+ cost_display,
106
+ tokens_display,
107
+ age_display,
108
+ key=task.id,
109
+ )
110
+
111
+ # Restore selection if possible
112
+ if current_key:
113
+ try:
114
+ row_index = table.get_row_index(current_key)
115
+ table.cursor_coordinate = (row_index, 0)
116
+ self._selected_row_key = current_key
117
+ except Exception:
118
+ pass
119
+
120
+ def _format_status(self, status: str) -> str:
121
+ """Format status with color markup."""
122
+ status_colors = {
123
+ "pending": "[dim]○ pending[/]",
124
+ "queued": "[yellow]◐ queued[/]",
125
+ "running": "[cyan]● running[/]",
126
+ "completed": "[green]✓ done[/]",
127
+ "failed": "[red]✗ failed[/]",
128
+ "cancelled": "[dim]⊘ cancel[/]",
129
+ }
130
+ return status_colors.get(status, f"[yellow]{status}[/]")
131
+
132
+ def _format_cost(self, cost: float) -> str:
133
+ """Format cost in dollars."""
134
+ if cost == 0:
135
+ return "[dim]$0.00[/]"
136
+ elif cost < 0.01:
137
+ return f"${cost:.4f}"
138
+ else:
139
+ return f"${cost:.2f}"
140
+
141
+ def _format_age(self, created_at: datetime) -> str:
142
+ """Format age in human-readable form."""
143
+ # Handle naive datetimes from MongoDB (assume UTC)
144
+ if created_at.tzinfo is None:
145
+ created_at = created_at.replace(tzinfo=UTC)
146
+ age = datetime.now(UTC) - created_at
147
+
148
+ if age.total_seconds() < 60:
149
+ return f"{int(age.total_seconds())}s"
150
+ elif age.total_seconds() < 3600:
151
+ return f"{int(age.total_seconds() / 60)}m"
152
+ elif age.total_seconds() < 86400:
153
+ return f"{int(age.total_seconds() / 3600)}h"
154
+ else:
155
+ return f"{int(age.total_seconds() / 86400)}d"
156
+
157
+ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
158
+ """Handle row selection."""
159
+ self._selected_row_key = str(event.row_key.value) if event.row_key else None
160
+
161
+ def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
162
+ """Handle row highlight (cursor movement)."""
163
+ self._selected_row_key = str(event.row_key.value) if event.row_key else None
164
+
165
+ def get_selected_task_id(self) -> str | None:
166
+ """Get the currently selected task ID."""
167
+ table = self.query_one(DataTable)
168
+ if table.cursor_row is not None:
169
+ # Get the ID from the second column (index 1)
170
+ return str(table.get_cell_at((table.cursor_row, 1)))
171
+ return self._selected_row_key
172
+
173
+ @property
174
+ def row_count(self) -> int:
175
+ """Get the number of rows in the table."""
176
+ table = self.query_one(DataTable)
177
+ return table.row_count
178
+
179
+ @property
180
+ def column_count(self) -> int:
181
+ """Get the number of columns in the table."""
182
+ table = self.query_one(DataTable)
183
+ return len(table.columns)
184
+
185
+ @property
186
+ def cursor_row(self) -> int | None:
187
+ """Get the current cursor row."""
188
+ table = self.query_one(DataTable)
189
+ return table.cursor_row