@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.
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +8 -6
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/init.d.ts +17 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +315 -17
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/ranger.d.ts +7 -2
- package/dist/commands/ranger.d.ts.map +1 -1
- package/dist/commands/ranger.js +99 -21
- package/dist/commands/ranger.js.map +1 -1
- package/dist/commands/uninstall.d.ts +16 -0
- package/dist/commands/uninstall.d.ts.map +1 -0
- package/dist/commands/uninstall.js +206 -0
- package/dist/commands/uninstall.js.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/package.json +8 -6
- package/ranger-tui/pyproject.toml +51 -0
- package/ranger-tui/ranger_tui/__init__.py +5 -0
- package/ranger-tui/ranger_tui/__main__.py +16 -0
- package/ranger-tui/ranger_tui/__pycache__/__init__.cpython-314.pyc +0 -0
- package/ranger-tui/ranger_tui/__pycache__/__main__.cpython-314.pyc +0 -0
- package/ranger-tui/ranger_tui/__pycache__/accessibility.cpython-314.pyc +0 -0
- package/ranger-tui/ranger_tui/__pycache__/app.cpython-314.pyc +0 -0
- package/ranger-tui/ranger_tui/__pycache__/config.cpython-314.pyc +0 -0
- package/ranger-tui/ranger_tui/__pycache__/theme.cpython-314.pyc +0 -0
- package/ranger-tui/ranger_tui/accessibility.py +499 -0
- package/ranger-tui/ranger_tui/actions/__init__.py +13 -0
- package/ranger-tui/ranger_tui/actions/agent_actions.py +74 -0
- package/ranger-tui/ranger_tui/actions/session_actions.py +110 -0
- package/ranger-tui/ranger_tui/actions/task_actions.py +107 -0
- package/ranger-tui/ranger_tui/app.py +93 -0
- package/ranger-tui/ranger_tui/assets/ranger_head.png +0 -0
- package/ranger-tui/ranger_tui/config.py +100 -0
- package/ranger-tui/ranger_tui/data/__init__.py +16 -0
- package/ranger-tui/ranger_tui/data/__pycache__/__init__.cpython-314.pyc +0 -0
- package/ranger-tui/ranger_tui/data/__pycache__/client.cpython-314.pyc +0 -0
- package/ranger-tui/ranger_tui/data/__pycache__/models.cpython-314.pyc +0 -0
- package/ranger-tui/ranger_tui/data/client.py +858 -0
- package/ranger-tui/ranger_tui/data/models.py +151 -0
- package/ranger-tui/ranger_tui/screens/__init__.py +16 -0
- package/ranger-tui/ranger_tui/screens/__pycache__/__init__.cpython-314.pyc +0 -0
- package/ranger-tui/ranger_tui/screens/__pycache__/dashboard.cpython-314.pyc +0 -0
- package/ranger-tui/ranger_tui/screens/__pycache__/modals.cpython-314.pyc +0 -0
- package/ranger-tui/ranger_tui/screens/__pycache__/session.cpython-314.pyc +0 -0
- package/ranger-tui/ranger_tui/screens/__pycache__/task.cpython-314.pyc +0 -0
- package/ranger-tui/ranger_tui/screens/command_palette.py +357 -0
- package/ranger-tui/ranger_tui/screens/dashboard.py +232 -0
- package/ranger-tui/ranger_tui/screens/help.py +103 -0
- package/ranger-tui/ranger_tui/screens/modals.py +95 -0
- package/ranger-tui/ranger_tui/screens/session.py +289 -0
- package/ranger-tui/ranger_tui/screens/task.py +187 -0
- package/ranger-tui/ranger_tui/styles/ranger.tcss +254 -0
- package/ranger-tui/ranger_tui/theme.py +93 -0
- package/ranger-tui/ranger_tui/widgets/__init__.py +23 -0
- package/ranger-tui/ranger_tui/widgets/__pycache__/__init__.cpython-314.pyc +0 -0
- package/ranger-tui/ranger_tui/widgets/__pycache__/accessible.cpython-314.pyc +0 -0
- package/ranger-tui/ranger_tui/widgets/__pycache__/logo.cpython-314.pyc +0 -0
- package/ranger-tui/ranger_tui/widgets/__pycache__/logo_assets.cpython-314.pyc +0 -0
- package/ranger-tui/ranger_tui/widgets/__pycache__/ranger_image.cpython-314.pyc +0 -0
- package/ranger-tui/ranger_tui/widgets/__pycache__/sidebar.cpython-314.pyc +0 -0
- package/ranger-tui/ranger_tui/widgets/__pycache__/topbar.cpython-314.pyc +0 -0
- package/ranger-tui/ranger_tui/widgets/accessible.py +176 -0
- package/ranger-tui/ranger_tui/widgets/agents_table.py +151 -0
- package/ranger-tui/ranger_tui/widgets/header.py +141 -0
- package/ranger-tui/ranger_tui/widgets/logo.py +258 -0
- package/ranger-tui/ranger_tui/widgets/logo_assets.py +62 -0
- package/ranger-tui/ranger_tui/widgets/metrics_panel.py +121 -0
- package/ranger-tui/ranger_tui/widgets/ranger_image.py +91 -0
- package/ranger-tui/ranger_tui/widgets/sessions_table.py +191 -0
- package/ranger-tui/ranger_tui/widgets/sidebar.py +91 -0
- package/ranger-tui/ranger_tui/widgets/tasks_table.py +189 -0
- 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
|