@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,289 @@
1
+ """
2
+ Session detail screen - View tasks and agents in a session.
3
+ """
4
+
5
+ from datetime import datetime
6
+ from textual.app import ComposeResult
7
+ from textual.screen import Screen
8
+ from textual.binding import Binding
9
+ from textual.containers import Horizontal
10
+ from textual.widgets import DataTable, Footer, TabbedContent, TabPane, RichLog
11
+
12
+ from ranger_tui.data import SessionReport
13
+ from ranger_tui.widgets import TopBar, Sidebar
14
+ from ranger_tui.theme import format_status, format_model, STATUS_SYMBOLS
15
+
16
+
17
+ class SessionScreen(Screen):
18
+ """Session detail screen with tasks and agents."""
19
+
20
+ BINDINGS = [
21
+ Binding("enter", "open_task", "Open", show=True),
22
+ Binding("t", "new_task", "New Task", show=True),
23
+ ]
24
+
25
+ session_id: str = ""
26
+ report: SessionReport | None = None
27
+
28
+ def __init__(self, session_id: str):
29
+ super().__init__()
30
+ self.session_id = session_id
31
+
32
+ def compose(self) -> ComposeResult:
33
+ """Compose the session detail layout."""
34
+ yield TopBar(title="Loading...")
35
+ with Horizontal(id="main"):
36
+ with TabbedContent(id="tabs"):
37
+ with TabPane("Tasks", id="tasks-tab"):
38
+ yield DataTable(id="tasks-table", cursor_type="row")
39
+ with TabPane("Agents", id="agents-tab"):
40
+ yield DataTable(id="agents-table", cursor_type="row")
41
+ with TabPane("Logs", id="logs-tab"):
42
+ yield RichLog(id="activity-log", highlight=True, markup=True)
43
+ yield Sidebar(title="Details")
44
+ yield Footer()
45
+
46
+ async def on_mount(self) -> None:
47
+ """Called when screen is mounted."""
48
+ # Setup tables after layout is complete
49
+ self.call_later(self._setup_tables)
50
+
51
+ # Auto-refresh
52
+ self.set_interval(2.0, self._load_session)
53
+
54
+ # Focus on tasks table after a brief delay (after data loads)
55
+ self.call_later(self._focus_tasks_table)
56
+
57
+ def _focus_tasks_table(self) -> None:
58
+ """Focus on the tasks table."""
59
+ try:
60
+ tasks_table = self.query_one("#tasks-table", DataTable)
61
+ tasks_table.focus()
62
+ # Set cursor to first row if there are rows
63
+ if tasks_table.row_count > 0:
64
+ tasks_table.cursor_coordinate = (0, 0)
65
+ except Exception:
66
+ pass
67
+
68
+ async def _setup_tables(self) -> None:
69
+ """Setup tables with proportional column widths after layout is complete."""
70
+ # Calculate available width: terminal width - sidebar(28) - padding/borders(~6)
71
+ try:
72
+ terminal_width = self.app.size.width
73
+ table_width = terminal_width - 28 - 6
74
+ except Exception:
75
+ table_width = 100
76
+
77
+ table_width = max(table_width, 60)
78
+ usable_width = table_width - 5 # Space for column separators
79
+
80
+ # Setup tasks table with proportional widths
81
+ # Status: 10%, Type: 8%, Description: 28%, Provider: 12%, Model: 16%, Cost: 10%, Tokens: 14%
82
+ tasks_table = self.query_one("#tasks-table", DataTable)
83
+ if not tasks_table.columns:
84
+ tasks_table.zebra_stripes = True
85
+ status_w = max(8, int(usable_width * 0.10))
86
+ type_w = max(6, int(usable_width * 0.08))
87
+ provider_w = max(8, int(usable_width * 0.12))
88
+ model_w = max(10, int(usable_width * 0.16))
89
+ cost_w = max(6, int(usable_width * 0.10))
90
+ tokens_w = max(8, int(usable_width * 0.14))
91
+ desc_w = usable_width - status_w - type_w - provider_w - model_w - cost_w - tokens_w
92
+ desc_w = max(15, desc_w)
93
+
94
+ tasks_table.add_column("Status", key="status", width=status_w)
95
+ tasks_table.add_column("Type", key="type", width=type_w)
96
+ tasks_table.add_column("Description", key="description", width=desc_w)
97
+ tasks_table.add_column("Provider", key="provider", width=provider_w)
98
+ tasks_table.add_column("Model", key="model", width=model_w)
99
+ tasks_table.add_column("Cost", key="cost", width=cost_w)
100
+ tasks_table.add_column("Tokens", key="tokens", width=tokens_w)
101
+
102
+ # Setup agents table with proportional widths
103
+ # Status: 12%, Name: 30%, Role: 28%, Provider: 15%, Tasks: 15%
104
+ agents_table = self.query_one("#agents-table", DataTable)
105
+ if not agents_table.columns:
106
+ agents_table.zebra_stripes = True
107
+ status_w = max(8, int(usable_width * 0.12))
108
+ name_w = max(12, int(usable_width * 0.30))
109
+ role_w = max(10, int(usable_width * 0.28))
110
+ provider_w = max(8, int(usable_width * 0.15))
111
+ tasks_w = max(6, int(usable_width * 0.15))
112
+
113
+ agents_table.add_column("Status", key="status", width=status_w)
114
+ agents_table.add_column("Name", key="name", width=name_w)
115
+ agents_table.add_column("Role", key="role", width=role_w)
116
+ agents_table.add_column("Provider", key="provider", width=provider_w)
117
+ agents_table.add_column("Tasks", key="tasks", width=tasks_w)
118
+
119
+ # Load data
120
+ await self._load_session()
121
+
122
+ async def _load_session(self) -> None:
123
+ """Load session data."""
124
+ self.report = await self.app.client.get_session_report(self.session_id)
125
+
126
+ if self.report:
127
+ self._update_header()
128
+ self._update_tasks()
129
+ self._update_agents()
130
+ self._update_details()
131
+ self._update_logs()
132
+
133
+ def _update_header(self) -> None:
134
+ """Update session header in topbar."""
135
+ if not self.report:
136
+ return
137
+
138
+ session = self.report.session
139
+ topbar = self.query_one(TopBar)
140
+
141
+ symbol = STATUS_SYMBOLS.get(session.status, "?")
142
+ topbar.title = f"{symbol} {session.objective}"
143
+ topbar.set_connection_status(self.app.mongo_connected, self.app.redis_connected)
144
+
145
+ def _update_tasks(self) -> None:
146
+ """Update tasks table."""
147
+ if not self.report:
148
+ return
149
+
150
+ table = self.query_one("#tasks-table", DataTable)
151
+ selected_row = table.cursor_row
152
+
153
+ table.clear()
154
+
155
+ for task in self.report.tasks:
156
+ status = format_status(task.status)
157
+ tokens = f"{task.token_usage.total_tokens:,}"
158
+ cost = self._format_cost(task.token_usage.cost)
159
+ model_display = format_model(task.model)
160
+
161
+ table.add_row(status, task.type, task.description, task.provider, model_display, cost, tokens, key=task.id)
162
+
163
+ if selected_row is not None and selected_row < table.row_count:
164
+ table.cursor_coordinate = (selected_row, 0)
165
+
166
+ def _format_cost(self, cost: float) -> str:
167
+ """Format cost in dollars."""
168
+ if cost == 0:
169
+ return "[dim]$0.00[/]"
170
+ elif cost < 0.01:
171
+ return f"${cost:.4f}"
172
+ else:
173
+ return f"${cost:.2f}"
174
+
175
+ def _update_agents(self) -> None:
176
+ """Update agents table."""
177
+ if not self.report:
178
+ return
179
+
180
+ table = self.query_one("#agents-table", DataTable)
181
+ selected_row = table.cursor_row
182
+
183
+ table.clear()
184
+
185
+ for agent in self.report.agents:
186
+ status = format_status(agent.status)
187
+ tasks = f"{agent.tasks_completed}/{agent.tasks_completed + agent.tasks_failed}"
188
+
189
+ table.add_row(status, agent.name, agent.role, agent.provider, tasks, key=agent.id)
190
+
191
+ if selected_row is not None and selected_row < table.row_count:
192
+ table.cursor_coordinate = (selected_row, 0)
193
+
194
+ def _update_details(self) -> None:
195
+ """Update details sidebar."""
196
+ if not self.report:
197
+ return
198
+
199
+ session = self.report.session
200
+ stats = self.report.statistics
201
+
202
+ sidebar = self.query_one(Sidebar)
203
+ sidebar.update_content(
204
+ f"ID: {session.id[:12]}...\n"
205
+ f"Status: {session.status}\n"
206
+ f"Created: {session.created_at.strftime('%m-%d %H:%M')}\n"
207
+ f"\n"
208
+ f"[bold]Tasks[/]\n"
209
+ f" [green]✓[/] {stats.tasks.completed} completed\n"
210
+ f" [red]✗[/] {stats.tasks.failed} failed\n"
211
+ f" [yellow]●[/] {stats.tasks.running} running\n"
212
+ f" [dim]○[/] {stats.tasks.pending} pending\n"
213
+ f"\n"
214
+ f"Tokens: {stats.total_tokens:,}\n"
215
+ f"Cost: ${stats.total_cost_usd:.4f}"
216
+ )
217
+
218
+ def _update_logs(self) -> None:
219
+ """Update activity log."""
220
+ if not self.report:
221
+ return
222
+
223
+ log = self.query_one("#activity-log", RichLog)
224
+ log.clear()
225
+
226
+ for task in sorted(self.report.tasks, key=lambda t: t.created_at):
227
+ time_str = task.created_at.strftime("%H:%M:%S")
228
+
229
+ # Status with color
230
+ status_colors = {
231
+ "completed": "green",
232
+ "failed": "red",
233
+ "running": "yellow",
234
+ "pending": "dim",
235
+ }
236
+ color = status_colors.get(task.status, "white")
237
+ symbol = STATUS_SYMBOLS.get(task.status, "?")
238
+
239
+ log.write(f"[dim]{time_str}[/] [{color}]{symbol}[/] [bold]{task.type}[/]: {task.description[:50]}")
240
+
241
+ if task.error:
242
+ log.write(f" [red]ERROR: {task.error[:60]}[/]")
243
+
244
+ if task.output and task.output.content:
245
+ preview = task.output.content[:80].replace('\n', ' ')
246
+ log.write(f" [dim]→ {preview}...[/]")
247
+
248
+ if not self.report.tasks:
249
+ log.write("[dim]No activity yet[/]")
250
+
251
+ async def action_open_task(self) -> None:
252
+ """Open selected task."""
253
+ tabs = self.query_one("#tabs", TabbedContent)
254
+
255
+ if tabs.active == "tasks-tab":
256
+ table = self.query_one("#tasks-table", DataTable)
257
+ if table.cursor_row is not None and table.row_count > 0:
258
+ row_key = table.coordinate_to_cell_key((table.cursor_row, 0)).row_key
259
+ task_id = str(row_key.value) if row_key else None
260
+
261
+ if task_id:
262
+ from ranger_tui.screens.task import TaskScreen
263
+ await self.app.push_screen(TaskScreen(task_id))
264
+
265
+ async def action_new_task(self) -> None:
266
+ """Create a new task."""
267
+ from ranger_tui.screens.modals import NewTaskModal
268
+
269
+ async def on_submit(data: dict) -> None:
270
+ if data:
271
+ task = await self.app.client.create_task(
272
+ session_id=self.session_id,
273
+ task_type=data.get("type", "other"),
274
+ description=data.get("description", ""),
275
+ prompt=data.get("prompt", ""),
276
+ provider=data.get("provider", "claude"),
277
+ )
278
+ self.app.notify(f"Created task: {task.id[:8]}")
279
+ await self._load_session()
280
+
281
+ await self.app.push_screen(NewTaskModal(), on_submit)
282
+
283
+ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
284
+ """Handle enter on row."""
285
+ if event.data_table.id == "tasks-table":
286
+ task_id = str(event.row_key.value) if event.row_key else None
287
+ if task_id:
288
+ from ranger_tui.screens.task import TaskScreen
289
+ self.app.push_screen(TaskScreen(task_id))
@@ -0,0 +1,187 @@
1
+ """
2
+ Task detail screen - View task details, input, output, and errors.
3
+ """
4
+
5
+ from textual.app import ComposeResult
6
+ from textual.screen import Screen
7
+ from textual.containers import Horizontal
8
+ from textual.widgets import Footer, TabbedContent, TabPane, RichLog, Markdown
9
+
10
+ from ranger_tui.data import Task
11
+ from ranger_tui.widgets import TopBar, Sidebar
12
+ from ranger_tui.theme import STATUS_SYMBOLS
13
+
14
+
15
+ class TaskScreen(Screen):
16
+ """Task detail screen."""
17
+
18
+ task_id: str = ""
19
+ task: Task | None = None
20
+
21
+ def __init__(self, task_id: str):
22
+ super().__init__()
23
+ self.task_id = task_id
24
+
25
+ def compose(self) -> ComposeResult:
26
+ """Compose the task detail layout."""
27
+ yield TopBar(title="Loading...")
28
+ with Horizontal(id="main"):
29
+ with TabbedContent(id="tabs"):
30
+ with TabPane("Output", id="output-tab"):
31
+ yield RichLog(id="output-log", highlight=True, markup=True)
32
+ with TabPane("Input", id="input-tab"):
33
+ yield RichLog(id="input-log", highlight=True, markup=True)
34
+ with TabPane("Error", id="error-tab"):
35
+ yield RichLog(id="error-log", highlight=True, markup=True)
36
+ yield Sidebar(title="Info")
37
+ yield Footer()
38
+
39
+ async def on_mount(self) -> None:
40
+ """Called when screen is mounted."""
41
+ await self._load_task()
42
+ self.set_interval(2.0, self._load_task)
43
+
44
+ # Focus on output log
45
+ self.call_later(self._focus_output)
46
+
47
+ def _focus_output(self) -> None:
48
+ """Focus on the output log."""
49
+ try:
50
+ output_log = self.query_one("#output-log", RichLog)
51
+ output_log.focus()
52
+ except Exception:
53
+ pass
54
+
55
+ async def _load_task(self) -> None:
56
+ """Load task data."""
57
+ self.task = await self.app.client.get_task(self.task_id)
58
+
59
+ if self.task:
60
+ self._update_header()
61
+ self._update_output()
62
+ self._update_input()
63
+ self._update_error()
64
+ self._update_info()
65
+
66
+ def _update_header(self) -> None:
67
+ """Update task header in topbar."""
68
+ if not self.task:
69
+ return
70
+
71
+ topbar = self.query_one(TopBar)
72
+ symbol = STATUS_SYMBOLS.get(self.task.status, "?")
73
+ topbar.title = f"{symbol} {self.task.description}"
74
+ topbar.set_connection_status(self.app.mongo_connected, self.app.redis_connected)
75
+
76
+ def _update_output(self) -> None:
77
+ """Update output tab."""
78
+ if not self.task:
79
+ return
80
+
81
+ log = self.query_one("#output-log", RichLog)
82
+ log.clear()
83
+
84
+ if self.task.output and self.task.output.content:
85
+ # Task completed - show final output
86
+ log.write(self.task.output.content)
87
+
88
+ if self.task.output.files_modified:
89
+ log.write("\n[bold]Files Modified[/]")
90
+ for f in self.task.output.files_modified:
91
+ log.write(f" [green]•[/] {f}")
92
+
93
+ if self.task.output.artifacts:
94
+ log.write("\n[bold]Artifacts[/]")
95
+ for a in self.task.output.artifacts:
96
+ log.write(f" [blue]•[/] {a}")
97
+ elif self.task.logs:
98
+ # Task running - show streaming logs
99
+ if self.task.status == "running":
100
+ log.write("[cyan]● LIVE[/] [dim]Streaming output...[/]\n")
101
+ log.write(self.task.logs)
102
+ else:
103
+ # No output yet
104
+ status_msgs = {
105
+ "pending": "[dim]Task is pending...[/]",
106
+ "queued": "[yellow]Task is queued...[/]",
107
+ "running": "[yellow]Task is running... (waiting for output)[/]",
108
+ "cancelled": "[red]Task was cancelled.[/]",
109
+ }
110
+ log.write(status_msgs.get(self.task.status, "[dim]No output.[/]"))
111
+
112
+ def _update_input(self) -> None:
113
+ """Update input tab."""
114
+ if not self.task:
115
+ return
116
+
117
+ log = self.query_one("#input-log", RichLog)
118
+ log.clear()
119
+
120
+ log.write("[bold]Prompt[/]")
121
+ log.write(self.task.input.prompt)
122
+
123
+ if self.task.input.context_files:
124
+ log.write("\n[bold]Context Files[/]")
125
+ for f in self.task.input.context_files:
126
+ log.write(f" [dim]•[/] {f}")
127
+
128
+ if self.task.input.agent:
129
+ log.write(f"\n[bold]Agent[/]\n{self.task.input.agent}")
130
+
131
+ if self.task.input.skills:
132
+ log.write("\n[bold]Skills[/]")
133
+ for s in self.task.input.skills:
134
+ log.write(f" [blue]•[/] {s}")
135
+
136
+ def _update_error(self) -> None:
137
+ """Update error tab."""
138
+ if not self.task:
139
+ return
140
+
141
+ log = self.query_one("#error-log", RichLog)
142
+ log.clear()
143
+
144
+ if self.task.error:
145
+ log.write("[bold red]Error[/]")
146
+ log.write(f"[red]{self.task.error}[/]")
147
+ else:
148
+ log.write("[green]No errors.[/]")
149
+
150
+ def _update_info(self) -> None:
151
+ """Update info sidebar."""
152
+ if not self.task:
153
+ return
154
+
155
+ sidebar = self.query_one(Sidebar)
156
+
157
+ duration = ""
158
+ if self.task.started_at and self.task.completed_at:
159
+ delta = self.task.completed_at - self.task.started_at
160
+ duration = f"{delta.total_seconds():.1f}s"
161
+ elif self.task.started_at:
162
+ duration = "[yellow]running...[/]"
163
+
164
+ # Status color
165
+ status_colors = {
166
+ "completed": "green",
167
+ "failed": "red",
168
+ "running": "yellow",
169
+ "pending": "dim",
170
+ }
171
+ color = status_colors.get(self.task.status, "white")
172
+
173
+ sidebar.update_content(
174
+ f"ID: {self.task.id[:12]}...\n"
175
+ f"Type: {self.task.type}\n"
176
+ f"Status: [{color}]{self.task.status}[/]\n"
177
+ f"Provider: {self.task.provider}\n"
178
+ f"Model: {self.task.model}\n"
179
+ f"\n"
180
+ f"Created: {self.task.created_at.strftime('%H:%M:%S')}\n"
181
+ f"Duration: {duration or 'N/A'}\n"
182
+ f"\n"
183
+ f"Tokens: {self.task.token_usage.total_tokens:,}\n"
184
+ f"Cost: ${self.task.token_usage.cost:.6f}\n"
185
+ f"\n"
186
+ f"Retries: {self.task.retry_count}/{self.task.max_retries}"
187
+ )