shikiru-cli 0.2.0__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.
Files changed (319) hide show
  1. shikiru/__init__.py +1 -0
  2. shikiru/__main__.py +8 -0
  3. shikiru/agents/__init__.py +3 -0
  4. shikiru/agents/contracts.py +590 -0
  5. shikiru/agents/registry.py +87 -0
  6. shikiru/agents/sdk/__init__.py +17 -0
  7. shikiru/agents/sdk/reset_time.py +137 -0
  8. shikiru/agents/sdk/turn.py +335 -0
  9. shikiru/agents/settings.py +151 -0
  10. shikiru/agents/testing/__init__.py +26 -0
  11. shikiru/agents/testing/contract_suite.py +653 -0
  12. shikiru/agents/testing/resume.py +15 -0
  13. shikiru/bootstrap.py +1277 -0
  14. shikiru/cli/__init__.py +1 -0
  15. shikiru/cli/app.py +770 -0
  16. shikiru/cli/context.py +63 -0
  17. shikiru/cli/render.py +1475 -0
  18. shikiru/cli/report.py +156 -0
  19. shikiru/cli/session_prompt.py +63 -0
  20. shikiru/cli/view.py +131 -0
  21. shikiru/clock.py +41 -0
  22. shikiru/config/__init__.py +8 -0
  23. shikiru/config/defaults.toml +131 -0
  24. shikiru/config/loader.py +178 -0
  25. shikiru/config/settings.py +392 -0
  26. shikiru/dashboard/__init__.py +3 -0
  27. shikiru/dashboard/actions_list.py +799 -0
  28. shikiru/dashboard/activity.py +150 -0
  29. shikiru/dashboard/app.py +1765 -0
  30. shikiru/dashboard/blocker_evidence.py +122 -0
  31. shikiru/dashboard/blocker_rows.py +84 -0
  32. shikiru/dashboard/briefing_view.py +871 -0
  33. shikiru/dashboard/contract.py +1086 -0
  34. shikiru/dashboard/covered.py +32 -0
  35. shikiru/dashboard/detail.py +1209 -0
  36. shikiru/dashboard/detail_fold.py +244 -0
  37. shikiru/dashboard/detail_hint.py +68 -0
  38. shikiru/dashboard/details_list_view.py +150 -0
  39. shikiru/dashboard/diagnostics.py +80 -0
  40. shikiru/dashboard/diagnostics_sections.py +186 -0
  41. shikiru/dashboard/evidence_table.py +123 -0
  42. shikiru/dashboard/extra_details.py +214 -0
  43. shikiru/dashboard/fault_guidance.py +216 -0
  44. shikiru/dashboard/fold_bodies.py +276 -0
  45. shikiru/dashboard/help_keys.py +166 -0
  46. shikiru/dashboard/help_screen.py +204 -0
  47. shikiru/dashboard/interaction.py +17 -0
  48. shikiru/dashboard/ladder_editor.py +544 -0
  49. shikiru/dashboard/live_settings.py +62 -0
  50. shikiru/dashboard/motion.py +368 -0
  51. shikiru/dashboard/orchestration_fields.py +201 -0
  52. shikiru/dashboard/palette.py +1606 -0
  53. shikiru/dashboard/palette_app.py +89 -0
  54. shikiru/dashboard/palette_drafts.py +33 -0
  55. shikiru/dashboard/resolution.py +573 -0
  56. shikiru/dashboard/resolution_view.py +302 -0
  57. shikiru/dashboard/row_list.py +256 -0
  58. shikiru/dashboard/section_rows.py +173 -0
  59. shikiru/dashboard/services.py +665 -0
  60. shikiru/dashboard/session_history.py +402 -0
  61. shikiru/dashboard/shikiru_init_ascii_art.txt +40 -0
  62. shikiru/dashboard/task_picker.py +358 -0
  63. shikiru/dashboard/tick.py +59 -0
  64. shikiru/dashboard/timeline_filter.py +129 -0
  65. shikiru/dashboard/timeline_section.py +385 -0
  66. shikiru/dashboard/usage_body.py +269 -0
  67. shikiru/dashboard/views.py +2314 -0
  68. shikiru/dashboard/wizard.py +1389 -0
  69. shikiru/domain/__init__.py +1 -0
  70. shikiru/domain/acceptance.py +130 -0
  71. shikiru/domain/activity.py +28 -0
  72. shikiru/domain/attempts.py +333 -0
  73. shikiru/domain/briefing.py +257 -0
  74. shikiru/domain/commands.py +219 -0
  75. shikiru/domain/decisions.py +231 -0
  76. shikiru/domain/docker.py +262 -0
  77. shikiru/domain/engine_uptime.py +87 -0
  78. shikiru/domain/escalation.py +194 -0
  79. shikiru/domain/escalation_evidence.py +186 -0
  80. shikiru/domain/escalation_panel.py +144 -0
  81. shikiru/domain/events.py +71 -0
  82. shikiru/domain/evidence_export.py +89 -0
  83. shikiru/domain/faults.py +86 -0
  84. shikiru/domain/feedback.py +180 -0
  85. shikiru/domain/feedback_rounds.py +172 -0
  86. shikiru/domain/fencing.py +31 -0
  87. shikiru/domain/ladder.py +102 -0
  88. shikiru/domain/ladder_text.py +44 -0
  89. shikiru/domain/levels.py +44 -0
  90. shikiru/domain/motion.py +199 -0
  91. shikiru/domain/packet_evidence.py +377 -0
  92. shikiru/domain/pushes.py +47 -0
  93. shikiru/domain/quota_blocks.py +44 -0
  94. shikiru/domain/quota_waits.py +219 -0
  95. shikiru/domain/recovery.py +194 -0
  96. shikiru/domain/reports.py +498 -0
  97. shikiru/domain/results.py +217 -0
  98. shikiru/domain/runs.py +85 -0
  99. shikiru/domain/scheduling.py +231 -0
  100. shikiru/domain/status_tasks.py +40 -0
  101. shikiru/domain/steps.py +325 -0
  102. shikiru/domain/tasks.py +174 -0
  103. shikiru/domain/timeline.py +208 -0
  104. shikiru/domain/timeline_phrases.py +271 -0
  105. shikiru/domain/versions.py +18 -0
  106. shikiru/domain/workflow.py +372 -0
  107. shikiru/engine/__init__.py +0 -0
  108. shikiru/engine/acceptance.py +90 -0
  109. shikiru/engine/agents.py +61 -0
  110. shikiru/engine/cosmetic_decorator.py +99 -0
  111. shikiru/engine/daemon.py +409 -0
  112. shikiru/engine/dependency.py +37 -0
  113. shikiru/engine/escalation_filing.py +110 -0
  114. shikiru/engine/escalations.py +401 -0
  115. shikiru/engine/events.py +285 -0
  116. shikiru/engine/evidence.py +81 -0
  117. shikiru/engine/fault_reporter.py +144 -0
  118. shikiru/engine/feedback.py +577 -0
  119. shikiru/engine/feedback_audit.py +80 -0
  120. shikiru/engine/feedback_intake.py +95 -0
  121. shikiru/engine/feedback_snapshot.py +148 -0
  122. shikiru/engine/flow.py +87 -0
  123. shikiru/engine/handlers/__init__.py +33 -0
  124. shikiru/engine/handlers/base.py +148 -0
  125. shikiru/engine/handlers/engine.py +24 -0
  126. shikiru/engine/handlers/escalations.py +174 -0
  127. shikiru/engine/handlers/operator.py +81 -0
  128. shikiru/engine/handlers/tasks.py +237 -0
  129. shikiru/engine/history.py +128 -0
  130. shikiru/engine/integration.py +1234 -0
  131. shikiru/engine/integration_turns.py +337 -0
  132. shikiru/engine/layout_guard.py +362 -0
  133. shikiru/engine/lifecycle.py +60 -0
  134. shikiru/engine/monitor.py +317 -0
  135. shikiru/engine/notification_delivery.py +124 -0
  136. shikiru/engine/notification_feed.py +57 -0
  137. shikiru/engine/notifier.py +91 -0
  138. shikiru/engine/pull_request_closing.py +117 -0
  139. shikiru/engine/push_service.py +177 -0
  140. shikiru/engine/quota_probe_threads.py +79 -0
  141. shikiru/engine/quota_reconciler.py +210 -0
  142. shikiru/engine/quota_schedule.py +24 -0
  143. shikiru/engine/quota_waits.py +102 -0
  144. shikiru/engine/reconcile.py +91 -0
  145. shikiru/engine/recovery.py +284 -0
  146. shikiru/engine/retention.py +102 -0
  147. shikiru/engine/root_preparation.py +86 -0
  148. shikiru/engine/scheduler.py +320 -0
  149. shikiru/engine/session_guard.py +46 -0
  150. shikiru/engine/sessions.py +95 -0
  151. shikiru/engine/signalled.py +75 -0
  152. shikiru/engine/step_runner.py +140 -0
  153. shikiru/engine/supervisor.py +101 -0
  154. shikiru/engine/takeover.py +223 -0
  155. shikiru/engine/task_facts.py +46 -0
  156. shikiru/engine/tick_guard.py +95 -0
  157. shikiru/engine/titles.py +297 -0
  158. shikiru/engine/transitions.py +79 -0
  159. shikiru/engine/turn_context.py +206 -0
  160. shikiru/engine/turn_execution.py +30 -0
  161. shikiru/engine/turn_loop.py +230 -0
  162. shikiru/engine/turn_processes.py +79 -0
  163. shikiru/engine/turns.py +457 -0
  164. shikiru/errors.py +144 -0
  165. shikiru/helper/__init__.py +3 -0
  166. shikiru/helper/helper.py +182 -0
  167. shikiru/helper/models.py +11 -0
  168. shikiru/mcp/__init__.py +1 -0
  169. shikiru/mcp/server.py +196 -0
  170. shikiru/operator/__init__.py +33 -0
  171. shikiru/operator/attachment.py +24 -0
  172. shikiru/operator/commands.py +319 -0
  173. shikiru/operator/detectors.py +374 -0
  174. shikiru/operator/doctor.py +351 -0
  175. shikiru/operator/events.py +57 -0
  176. shikiru/operator/evidence_export.py +498 -0
  177. shikiru/operator/evidence_writer.py +78 -0
  178. shikiru/operator/lifecycle.py +294 -0
  179. shikiru/operator/plugin_checks.py +194 -0
  180. shikiru/operator/probe.py +287 -0
  181. shikiru/operator/queries.py +1601 -0
  182. shikiru/operator/services.py +36 -0
  183. shikiru/operator/sessions.py +378 -0
  184. shikiru/operator/strip_views.py +162 -0
  185. shikiru/operator/timeline.py +416 -0
  186. shikiru/operator/toml_text.py +62 -0
  187. shikiru/palette.py +18 -0
  188. shikiru/paths.py +91 -0
  189. shikiru/plugins/__init__.py +0 -0
  190. shikiru/plugins/agents/__init__.py +1 -0
  191. shikiru/plugins/agents/claude/__init__.py +5 -0
  192. shikiru/plugins/agents/claude/agent.py +251 -0
  193. shikiru/plugins/agents/claude/context.py +116 -0
  194. shikiru/plugins/agents/claude/headless.py +92 -0
  195. shikiru/plugins/agents/claude/hook.py +57 -0
  196. shikiru/plugins/agents/claude/model_catalog.py +105 -0
  197. shikiru/plugins/agents/claude/options.py +62 -0
  198. shikiru/plugins/agents/claude/turn.py +1018 -0
  199. shikiru/plugins/agents/codex/__init__.py +5 -0
  200. shikiru/plugins/agents/codex/agent.py +486 -0
  201. shikiru/plugins/agents/codex/headless.py +166 -0
  202. shikiru/plugins/agents/codex/home.py +14 -0
  203. shikiru/plugins/agents/codex/model_catalog.py +128 -0
  204. shikiru/plugins/agents/codex/options.py +94 -0
  205. shikiru/plugins/agents/codex/quota.py +195 -0
  206. shikiru/plugins/agents/codex/sandbox.py +31 -0
  207. shikiru/plugins/agents/codex/sandbox_probe.py +97 -0
  208. shikiru/plugins/agents/codex/turn.py +767 -0
  209. shikiru/plugins/agents/codex/usage.py +106 -0
  210. shikiru/plugins/agents/shared_settings.py +62 -0
  211. shikiru/plugins/widgets/__init__.py +3 -0
  212. shikiru/plugins/widgets/dialogue.py +144 -0
  213. shikiru/plugins/widgets/disk.py +123 -0
  214. shikiru/plugins/widgets/docker.py +225 -0
  215. shikiru/plugins/widgets/escalations.py +402 -0
  216. shikiru/plugins/widgets/faults.py +204 -0
  217. shikiru/plugins/widgets/header.py +321 -0
  218. shikiru/plugins/widgets/queue.py +1140 -0
  219. shikiru/plugins/widgets/queue_hint.py +93 -0
  220. shikiru/plugins/widgets/resources.py +105 -0
  221. shikiru/plugins/widgets/strips.py +82 -0
  222. shikiru/plugins/widgets/usage.py +176 -0
  223. shikiru/private.py +94 -0
  224. shikiru/process.py +535 -0
  225. shikiru/prompts/__init__.py +3 -0
  226. shikiru/prompts/renderer.py +85 -0
  227. shikiru/prompts/templates/_base.md.j2 +17 -0
  228. shikiru/prompts/templates/_macros.md.j2 +61 -0
  229. shikiru/prompts/templates/conflicts.md.j2 +22 -0
  230. shikiru/prompts/templates/consult.md.j2 +5 -0
  231. shikiru/prompts/templates/continue.md.j2 +4 -0
  232. shikiru/prompts/templates/feedback_analysis.md.j2 +29 -0
  233. shikiru/prompts/templates/feedback_rework.md.j2 +33 -0
  234. shikiru/prompts/templates/feedback_verify.md.j2 +33 -0
  235. shikiru/prompts/templates/handoff.md.j2 +23 -0
  236. shikiru/prompts/templates/implement.md.j2 +13 -0
  237. shikiru/prompts/templates/plan.md.j2 +30 -0
  238. shikiru/prompts/templates/rework.md.j2 +13 -0
  239. shikiru/prompts/templates/triage.md.j2 +20 -0
  240. shikiru/prompts/templates/verify.md.j2 +29 -0
  241. shikiru/store/__init__.py +1 -0
  242. shikiru/store/commands.py +242 -0
  243. shikiru/store/database.py +297 -0
  244. shikiru/store/escalations.py +240 -0
  245. shikiru/store/events.py +213 -0
  246. shikiru/store/feedback.py +266 -0
  247. shikiru/store/migrations/001_initial.sql +63 -0
  248. shikiru/store/migrations/002_headless_turns.sql +31 -0
  249. shikiru/store/migrations/003_drop_command_handled_at.sql +1 -0
  250. shikiru/store/migrations/004_repo_base_ref.sql +3 -0
  251. shikiru/store/migrations/005_complexity_explicit.sql +3 -0
  252. shikiru/store/migrations/006_events_kind_index.sql +1 -0
  253. shikiru/store/migrations/007_notification_cursor.sql +1 -0
  254. shikiru/store/migrations/008_reported_usage.sql +1 -0
  255. shikiru/store/migrations/009_billing_declarations.sql +2 -0
  256. shikiru/store/migrations/010_canonical_quota_windows.sql +3 -0
  257. shikiru/store/migrations/012_drop_task_quota_evidence.sql +2 -0
  258. shikiru/store/migrations/013_push_targets.sql +7 -0
  259. shikiru/store/migrations/014_agent_block_time.sql +6 -0
  260. shikiru/store/migrations/015_repair_orphan_agent_blocks.sql +3 -0
  261. shikiru/store/migrations/016_feedback_rounds.sql +29 -0
  262. shikiru/store/migrations/017_feedback_round_comments.sql +5 -0
  263. shikiru/store/migrations/__init__.py +1 -0
  264. shikiru/store/notification_cursor.py +29 -0
  265. shikiru/store/pushes.py +117 -0
  266. shikiru/store/quotas.py +153 -0
  267. shikiru/store/reader.py +310 -0
  268. shikiru/store/retention.py +92 -0
  269. shikiru/store/runs.py +227 -0
  270. shikiru/store/tasks.py +949 -0
  271. shikiru/telemetry/__init__.py +1 -0
  272. shikiru/telemetry/disk.py +167 -0
  273. shikiru/telemetry/docker.py +164 -0
  274. shikiru/telemetry/resources.py +296 -0
  275. shikiru/tmux/__init__.py +3 -0
  276. shikiru/tmux/client.py +637 -0
  277. shikiru/tmux/decorator.py +225 -0
  278. shikiru/tmux/layout.py +200 -0
  279. shikiru/tmux/session.py +381 -0
  280. shikiru/ui/__init__.py +9 -0
  281. shikiru/ui/age.py +45 -0
  282. shikiru/ui/bubbles.py +41 -0
  283. shikiru/ui/compact.py +1209 -0
  284. shikiru/ui/console.py +49 -0
  285. shikiru/ui/fold.py +50 -0
  286. shikiru/ui/gated_static.py +117 -0
  287. shikiru/ui/gauge.py +40 -0
  288. shikiru/ui/glyphs.py +109 -0
  289. shikiru/ui/keys.py +57 -0
  290. shikiru/ui/live_interval.py +42 -0
  291. shikiru/ui/popup.py +311 -0
  292. shikiru/ui/quota_status.py +26 -0
  293. shikiru/ui/recent_window.py +78 -0
  294. shikiru/ui/screen.py +57 -0
  295. shikiru/ui/section.py +193 -0
  296. shikiru/ui/session.py +65 -0
  297. shikiru/ui/size.py +17 -0
  298. shikiru/ui/splash.py +42 -0
  299. shikiru/ui/status.py +47 -0
  300. shikiru/ui/status_strip.py +149 -0
  301. shikiru/ui/stream.py +152 -0
  302. shikiru/ui/strip.py +332 -0
  303. shikiru/ui/theme.py +83 -0
  304. shikiru/ui/tokens.py +51 -0
  305. shikiru/view_log.py +126 -0
  306. shikiru/workspace/__init__.py +3 -0
  307. shikiru/workspace/base_refs.py +160 -0
  308. shikiru/workspace/caches.py +136 -0
  309. shikiru/workspace/facts.py +118 -0
  310. shikiru/workspace/git.py +601 -0
  311. shikiru/workspace/github.py +744 -0
  312. shikiru/workspace/messages.py +107 -0
  313. shikiru/workspace/reports.py +145 -0
  314. shikiru/workspace/roots.py +180 -0
  315. shikiru_cli-0.2.0.dist-info/METADATA +428 -0
  316. shikiru_cli-0.2.0.dist-info/RECORD +319 -0
  317. shikiru_cli-0.2.0.dist-info/WHEEL +4 -0
  318. shikiru_cli-0.2.0.dist-info/entry_points.txt +18 -0
  319. shikiru_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,385 @@
1
+ """One task timeline: a category's rows, cursor stack, filters and follow state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, fields
6
+ from typing import TYPE_CHECKING, ClassVar, cast
7
+
8
+ from rich.text import Text
9
+ from textual.binding import Binding
10
+ from textual.containers import Vertical
11
+ from textual.message import Message
12
+ from textual.widgets import Static
13
+
14
+ from shikiru.dashboard.row_list import RowList
15
+ from shikiru.dashboard.section_rows import timeline_cells
16
+ from shikiru.dashboard.timeline_filter import TimelineFilterBar
17
+ from shikiru.errors import ShikiruError
18
+ from shikiru.ui.screen import widget_is_current
19
+
20
+ if TYPE_CHECKING:
21
+ import asyncio
22
+ from collections.abc import Callable
23
+
24
+ from rich.console import Console, ConsoleOptions, RenderResult
25
+ from textual.app import App, ComposeResult
26
+ from textual.binding import BindingType
27
+ from textual.widget import Widget
28
+
29
+ from shikiru.dashboard.contract import (
30
+ TaskTimelineCategory,
31
+ TaskTimelineEntryView,
32
+ TaskTimelinePageView,
33
+ WidgetContext,
34
+ )
35
+
36
+ NO_TIMELINE_ROWS = "No recorded entries match."
37
+ FOLLOWING_LABEL = "following"
38
+ PAUSED_LABEL = "paused"
39
+ REFRESH_FAILED = "Evidence timeline refresh failed"
40
+ FILTER_KEY = "f"
41
+ OPEN_FILTER_ACTION = "open_filter"
42
+ SELECTED_MAX_LINES = 4
43
+ ELLIPSIS = "\N{HORIZONTAL ELLIPSIS}"
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class TimelineIds:
48
+ """The widget ids of one timeline section, derived from a shared prefix."""
49
+
50
+ root: str
51
+ filters: str
52
+ list: str
53
+ selected: str
54
+
55
+ @classmethod
56
+ def with_prefix(cls, prefix: str) -> TimelineIds:
57
+ """Build the ids `<prefix>-<part>` for every part of a timeline section."""
58
+ return cls(*(f"{prefix}-{part.name}" for part in fields(cls)))
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class TimelineRequest:
63
+ """The page a timeline asks the views for."""
64
+
65
+ cursor: str | None
66
+ role: str | None
67
+ turn: str | None
68
+ category: str
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class TimelineHost:
73
+ """What a timeline needs from the panel that owns it."""
74
+
75
+ task_id: Callable[[], str | None]
76
+ lock: asyncio.Lock
77
+ report: Callable[[str], None]
78
+
79
+
80
+ def screen_is_active(widget: Widget) -> bool:
81
+ """Whether the widget's screen is the current one in a running app."""
82
+ return widget_is_current(widget) and cast("App[None]", widget.app).is_running
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class _SelectedEntry:
87
+ text: Text
88
+
89
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
90
+ lines = self.text.wrap(console, options.max_width)
91
+ shown = lines[:SELECTED_MAX_LINES]
92
+ if len(lines) > SELECTED_MAX_LINES:
93
+ last = shown[-1]
94
+ last.rstrip()
95
+ last.truncate(max(options.max_width - 1, 1))
96
+ last.append(ELLIPSIS)
97
+ yield from shown
98
+
99
+
100
+ class TimelineSection(Vertical):
101
+ """Own one timeline's rows, paging, filters and follow state; `shown` while open."""
102
+
103
+ BINDINGS: ClassVar[list[BindingType]] = [
104
+ Binding(FILTER_KEY, OPEN_FILTER_ACTION, "Filter", show=False)
105
+ ]
106
+ DEFAULT_CSS = """
107
+ TimelineSection { height: auto; }
108
+ TimelineSection .timeline-selected {
109
+ height: auto; padding: 0 1; margin: 1 0 0 0;
110
+ color: $primary-text; background: $detail-surface; text-style: italic;
111
+ }
112
+ """
113
+
114
+ class StatusChanged(Message):
115
+ """The section's `following` or `paused` status, announced on mount and on every change."""
116
+
117
+ def __init__(self, section: TimelineSection, status: str) -> None:
118
+ super().__init__()
119
+ self.section = section
120
+ self.status = status
121
+
122
+ def __init__(
123
+ self,
124
+ context: WidgetContext,
125
+ host: TimelineHost,
126
+ *,
127
+ ids: TimelineIds,
128
+ category: TaskTimelineCategory,
129
+ filterable: bool,
130
+ ) -> None:
131
+ super().__init__(id=ids.root)
132
+ self.ids = ids
133
+ self._widget_context = context
134
+ self._host = host
135
+ self._category = category.value
136
+ self._filterable = filterable
137
+ self._page: TaskTimelinePageView | None = None
138
+ self._generation = 0
139
+ self.shown = False
140
+ self._reset_state()
141
+
142
+ def compose(self) -> ComposeResult:
143
+ yield RowList(widget_id=self.ids.list, edge_paging=True)
144
+ selected = Static("", id=self.ids.selected, classes="timeline-selected", markup=False)
145
+ selected.display = False
146
+ yield selected
147
+ if self._filterable:
148
+ yield TimelineFilterBar(widget_id=self.ids.filters)
149
+
150
+ def on_mount(self) -> None:
151
+ """Announce the initial status so its fold header shows it before it is ever opened."""
152
+ self._announce_status()
153
+
154
+ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
155
+ del parameters
156
+ return self._filterable if action == OPEN_FILTER_ACTION else True
157
+
158
+ def action_open_filter(self) -> None:
159
+ """Show the filter bar and move focus into it."""
160
+ bar = self.query_one(f"#{self.ids.filters}", TimelineFilterBar)
161
+ bar.display = True
162
+ bar.focus()
163
+
164
+ def close_filter(self) -> bool:
165
+ """Hide the focused filter bar and return focus to the list; False when it has none."""
166
+ if not self._filterable:
167
+ return False
168
+ bar = self.query_one(f"#{self.ids.filters}", TimelineFilterBar)
169
+ if not bar.has_focus:
170
+ return False
171
+ self.query_one(f"#{self.ids.list}", RowList).focus()
172
+ bar.display = bar.active
173
+ return True
174
+
175
+ @property
176
+ def request(self) -> TimelineRequest:
177
+ """The page the current cursor, filters and category ask for."""
178
+ return TimelineRequest(
179
+ None if self._following else self._cursors[-1],
180
+ self._role,
181
+ self._turn,
182
+ self._category,
183
+ )
184
+
185
+ @property
186
+ def generation(self) -> int:
187
+ """Bumped whenever the request changes, so older replies can be dropped."""
188
+ return self._generation
189
+
190
+ @property
191
+ def following(self) -> bool:
192
+ """Whether the section tracks the latest entries."""
193
+ return self._following
194
+
195
+ @property
196
+ def page(self) -> TaskTimelinePageView | None:
197
+ """The last page painted."""
198
+ return self._page
199
+
200
+ def reset(self) -> None:
201
+ """Forget the cursor, filters and rows, as for a newly selected task."""
202
+ self._reset_state()
203
+ self._page = None
204
+ self._generation += 1
205
+ if not self.is_mounted:
206
+ return
207
+ selected = self.query_one(f"#{self.ids.selected}", Static)
208
+ selected.update("")
209
+ selected.display = False
210
+ if self._filterable:
211
+ bar = self.query_one(f"#{self.ids.filters}", TimelineFilterBar)
212
+ bar.reset()
213
+ bar.display = False
214
+ self._announce_status()
215
+ self.query_one(f"#{self.ids.list}", RowList).set_rows(
216
+ (), selected=0, empty=NO_TIMELINE_ROWS
217
+ )
218
+
219
+ def show_page(self, page: TaskTimelinePageView | None) -> None:
220
+ """Paint a page of rows, keeping the selected entry when it is still listed."""
221
+ self._page = page
222
+ entries = page.entries if page is not None else ()
223
+ selected = (
224
+ len(entries) - 1
225
+ if self._select_last
226
+ else next(
227
+ (
228
+ index
229
+ for index, item in enumerate(entries)
230
+ if item.identity == self._selected_identity
231
+ ),
232
+ 0,
233
+ )
234
+ )
235
+ if self._select_last and selected > 0:
236
+ self._selected_identity = entries[selected].identity
237
+ self._following = False
238
+ self._select_last = False
239
+ self._remember_turns(entries)
240
+ self.query_one(f"#{self.ids.list}", RowList).set_rows(
241
+ [timeline_cells(item) for item in entries], selected=selected, empty=NO_TIMELINE_ROWS
242
+ )
243
+ self._announce_status()
244
+ self._paint_selection()
245
+
246
+ def pause_follow(self) -> None:
247
+ """Stop following the latest entries until the operator asks for them again."""
248
+ if not self._following:
249
+ return
250
+ self._following = False
251
+ self._generation += 1
252
+ if screen_is_active(self):
253
+ self._announce_status()
254
+
255
+ def restart(self) -> None:
256
+ """Return to the latest page and follow it."""
257
+ self._cursors = [None]
258
+ self._following = True
259
+ self._invalidate()
260
+
261
+ async def refresh_when_due(self) -> None:
262
+ """Fetch the latest page for the 1 Hz tick; only a shown, following section does."""
263
+ if self.shown and self._following:
264
+ await self.refresh_page()
265
+
266
+ async def refresh_page(self) -> None:
267
+ """Fetch this section's own page and paint it unless the request changed meanwhile."""
268
+ async with self._host.lock:
269
+ task_id = self._host.task_id()
270
+ if task_id is None or not screen_is_active(self):
271
+ return
272
+ generation = self._generation
273
+ request = self.request
274
+ try:
275
+ page = await self._widget_context.views.task_timeline(
276
+ task_id,
277
+ cursor=request.cursor,
278
+ role=request.role,
279
+ turn=request.turn,
280
+ category=request.category,
281
+ )
282
+ except (ShikiruError, OSError, ValueError, TypeError) as error:
283
+ if screen_is_active(self) and generation == self._generation:
284
+ self._host.report(f"{REFRESH_FAILED}: {error}")
285
+ return
286
+ if (
287
+ screen_is_active(self)
288
+ and task_id == self._host.task_id()
289
+ and generation == self._generation
290
+ ):
291
+ self.show_page(page)
292
+
293
+ def on_timeline_filter_bar_changed(self, message: TimelineFilterBar.Changed) -> None:
294
+ message.stop()
295
+ self._role, self._turn = message.role, message.turn
296
+ self.restart()
297
+
298
+ def on_timeline_filter_bar_closed(self, message: TimelineFilterBar.Closed) -> None:
299
+ message.stop()
300
+ self.close_filter()
301
+
302
+ def on_row_list_moved(self, message: RowList.Moved) -> None:
303
+ message.stop()
304
+ page = self._page
305
+ if page is None:
306
+ return
307
+ self._selected_identity = (
308
+ page.entries[message.index].identity if message.index > 0 else None
309
+ )
310
+ if message.index > 0:
311
+ self.pause_follow()
312
+ self._paint_selection()
313
+
314
+ def on_row_list_paged(self, message: RowList.Paged) -> None:
315
+ message.stop()
316
+ if message.older:
317
+ self._older_page()
318
+ else:
319
+ self._newer_page(select_last=message.by_arrow)
320
+
321
+ def on_row_list_scrolled(self, message: RowList.Scrolled) -> None:
322
+ message.stop()
323
+ self.pause_follow()
324
+
325
+ def on_row_list_latest(self, message: RowList.Latest) -> None:
326
+ message.stop()
327
+ self.restart()
328
+
329
+ def _reset_state(self) -> None:
330
+ self._cursors: list[str | None] = [None]
331
+ self._following = True
332
+ self._role: str | None = None
333
+ self._turn: str | None = None
334
+ self._selected_identity: str | None = None
335
+ self._select_last = False
336
+ self._known_turns: set[str] = set()
337
+
338
+ def _invalidate(self) -> None:
339
+ self._generation += 1
340
+ self._selected_identity = None
341
+ self.run_worker(self.refresh_page(), exclusive=False)
342
+
343
+ def _older_page(self) -> None:
344
+ page = self._page
345
+ if (
346
+ page is None
347
+ or not page.has_older
348
+ or page.older_cursor is None
349
+ or page.older_cursor == self._cursors[-1]
350
+ ):
351
+ return
352
+ self._cursors.append(page.older_cursor)
353
+ self._following = False
354
+ self._invalidate()
355
+
356
+ def _newer_page(self, *, select_last: bool) -> None:
357
+ if len(self._cursors) <= 1:
358
+ return
359
+ self._cursors.pop()
360
+ self._following = len(self._cursors) == 1
361
+ self._select_last = select_last
362
+ self._invalidate()
363
+
364
+ def _announce_status(self) -> None:
365
+ self.post_message(
366
+ self.StatusChanged(self, FOLLOWING_LABEL if self._following else PAUSED_LABEL)
367
+ )
368
+
369
+ def _remember_turns(self, entries: tuple[TaskTimelineEntryView, ...]) -> None:
370
+ if not self._filterable:
371
+ return
372
+ self._known_turns.update(item.turn for item in entries if item.turn)
373
+ self.query_one(f"#{self.ids.filters}", TimelineFilterBar).set_turns(
374
+ sorted(self._known_turns)
375
+ )
376
+
377
+ def _paint_selection(self) -> None:
378
+ page = self._page
379
+ entries = page.entries if page is not None else ()
380
+ chosen = self.query_one(f"#{self.ids.list}", RowList).selected
381
+ target = self.query_one(f"#{self.ids.selected}", Static)
382
+ selected = entries[chosen] if chosen < len(entries) else None
383
+ target.display = selected is not None
384
+ if selected is not None:
385
+ target.update(_SelectedEntry(Text(selected.phrase)))
@@ -0,0 +1,269 @@
1
+ """Body of the Usage fold: a totals line, one aligned line per session, one note."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from decimal import ROUND_HALF_UP, Decimal
7
+ from enum import StrEnum
8
+ from typing import TYPE_CHECKING
9
+
10
+ from rich.cells import cell_len
11
+ from rich.console import Group
12
+ from rich.text import Text
13
+
14
+ from shikiru.agents.contracts import BILLING_MODE_SUBSCRIPTION, BILLING_MODE_UNKNOWN
15
+ from shikiru.dashboard.briefing_view import counted, total_cost_text
16
+ from shikiru.domain.levels import UsageLevel, displayed_pct
17
+ from shikiru.ui.compact import (
18
+ COUNT_SHORT_MILLION,
19
+ COUNT_SHORT_SCALE,
20
+ ROLE_LABELS,
21
+ compact_count,
22
+ )
23
+ from shikiru.ui.section import BOLD_STEEL, GROUP_SEPARATOR, grey
24
+ from shikiru.ui.tokens import AMBER, SECONDARY_TEXT_COLOR, USAGE_LEVEL_COLORS
25
+
26
+ if TYPE_CHECKING:
27
+ from collections.abc import Sequence
28
+
29
+ from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
30
+
31
+ from shikiru.dashboard.contract import TaskDetailView, TaskSessionView
32
+
33
+ NO_VALUE = "\N{EM DASH}"
34
+ NO_USAGE = "No usage is recorded yet."
35
+ COVERAGE_UNAVAILABLE = "Task usage coverage unavailable"
36
+ OLD_TAG = "old"
37
+ TOKENS_WORD = " tokens"
38
+ NOTE_LEAD = "! "
39
+ LEAD = " "
40
+ GAP = " "
41
+ ELLIPSIS = "\N{HORIZONTAL ELLIPSIS}"
42
+ MODEL_MIN_WIDTH = 8
43
+ SIGNIFICANT_DIGITS = 3
44
+ PAIR_SEPARATOR = " / "
45
+ PAIR_LABEL = "r/w"
46
+ _NOUN_SESSION = "session"
47
+ _NOUN_GAP = "model-history gap"
48
+ _VERB_NO_USAGE = "reported no usage"
49
+ _PHRASE_BILLING_UNKNOWN = "billing unknown for"
50
+
51
+
52
+ class Column(StrEnum):
53
+ """The session table columns, in display order, named as their header shows them."""
54
+
55
+ ROLE = "Role"
56
+ MODEL = "Model"
57
+ TOKENS = "Tokens r/w"
58
+ COST = "Cost"
59
+ CONTEXT = "Ctx"
60
+
61
+
62
+ _LEFT_ALIGNED = (Column.ROLE, Column.MODEL)
63
+ _DROP_ORDER = (Column.MODEL, Column.CONTEXT, Column.COST)
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class UsageRow:
68
+ """One session's cell texts, its context level and whether it is an older session."""
69
+
70
+ cells: dict[Column, str]
71
+ level: UsageLevel | None
72
+ old: bool
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class UsageTable:
77
+ """The columns that fit the width, their widths, the rows and whether they stack."""
78
+
79
+ columns: tuple[Column, ...]
80
+ widths: dict[Column, int]
81
+ rows: tuple[UsageRow, ...]
82
+ stacked: bool
83
+
84
+
85
+ def usage_rows(sessions: Sequence[TaskSessionView], width: int) -> UsageTable:
86
+ """Derive the session table for `width`: shrink the model, drop it, then drop Ctx and Cost."""
87
+ ordered = sorted(sessions, key=lambda session: not session.current_lineage)
88
+ rows = tuple(_row(session) for session in ordered)
89
+ columns = list(Column)
90
+ widths = {
91
+ column: max(cell_len(column.value), *(cell_len(row.cells[column]) for row in rows))
92
+ for column in columns
93
+ }
94
+ old_room = (len(GAP) + len(OLD_TAG)) if any(row.old for row in rows) else 0
95
+
96
+ def total() -> int:
97
+ gaps = len(GAP) * (len(columns) - 1)
98
+ return len(LEAD) + sum(widths[c] for c in columns) + gaps + old_room
99
+
100
+ overflow = total() - width
101
+ if overflow > 0:
102
+ widths[Column.MODEL] = max(MODEL_MIN_WIDTH, widths[Column.MODEL] - overflow)
103
+ for column in _DROP_ORDER:
104
+ if total() > width:
105
+ columns.remove(column)
106
+ return UsageTable(tuple(columns), widths, rows, stacked=total() > width)
107
+
108
+
109
+ def _row(session: TaskSessionView) -> UsageRow:
110
+ return UsageRow(
111
+ {
112
+ Column.ROLE: ROLE_LABELS.get(session.role, session.role.title()),
113
+ Column.MODEL: session.model,
114
+ Column.TOKENS: _tokens(session),
115
+ Column.COST: _cost(session),
116
+ Column.CONTEXT: _context(session),
117
+ },
118
+ session.context_level,
119
+ not session.current_lineage,
120
+ )
121
+
122
+
123
+ def _tokens_known(session: TaskSessionView) -> bool:
124
+ return session.usage_reported is True or session.legacy_token_totals
125
+
126
+
127
+ def _session_tokens(session: TaskSessionView) -> int:
128
+ return session.input_tokens + session.output_tokens
129
+
130
+
131
+ def _tokens(session: TaskSessionView) -> str:
132
+ if not _tokens_known(session):
133
+ return NO_VALUE
134
+ return PAIR_SEPARATOR.join(
135
+ (_abbreviated(session.input_tokens), _abbreviated(session.output_tokens))
136
+ )
137
+
138
+
139
+ def _abbreviated(count: int) -> str:
140
+ if count < COUNT_SHORT_SCALE:
141
+ return str(count)
142
+ thousands = _significant(Decimal(count) / COUNT_SHORT_SCALE)
143
+ if thousands < COUNT_SHORT_SCALE:
144
+ return f"{_plain(thousands)}k"
145
+ return f"{_plain(_significant(Decimal(count) / COUNT_SHORT_MILLION))}M"
146
+
147
+
148
+ def _significant(value: Decimal) -> Decimal:
149
+ exponent = value.adjusted() - (SIGNIFICANT_DIGITS - 1)
150
+ return value.quantize(Decimal(1).scaleb(exponent), rounding=ROUND_HALF_UP)
151
+
152
+
153
+ def _plain(value: Decimal) -> str:
154
+ return format(value.normalize(), "f")
155
+
156
+
157
+ def _cost(session: TaskSessionView) -> str:
158
+ if session.cost_usd is None or session.billing_mode == BILLING_MODE_SUBSCRIPTION:
159
+ return NO_VALUE
160
+ return f"${session.cost_usd:.2f}"
161
+
162
+
163
+ def _context(session: TaskSessionView) -> str:
164
+ if session.usage_reported is False or session.context_used_pct is None:
165
+ return NO_VALUE
166
+ return f"{displayed_pct(session.context_used_pct)}%"
167
+
168
+
169
+ def _fit(text: str, width: int) -> str:
170
+ if cell_len(text) <= width:
171
+ return text
172
+ return text[: width - len(ELLIPSIS)] + ELLIPSIS
173
+
174
+
175
+ def _padded(text: str, width: int, *, left: bool) -> str:
176
+ fitted = _fit(text, width)
177
+ padding = " " * (width - cell_len(fitted))
178
+ return fitted + padding if left else padding + fitted
179
+
180
+
181
+ def _line(table: UsageTable, cells: dict[Column, str], style: str) -> Text:
182
+ return Text(
183
+ LEAD
184
+ + GAP.join(
185
+ _padded(cells[column], table.widths[column], left=column in _LEFT_ALIGNED)
186
+ for column in table.columns
187
+ ),
188
+ style=style,
189
+ )
190
+
191
+
192
+ def _row_text(table: UsageTable, row: UsageRow) -> Text:
193
+ line = _line(table, row.cells, "")
194
+ if row.level is not None and row.level is not UsageLevel.OK and Column.CONTEXT in table.columns:
195
+ context_width = table.widths[Column.CONTEXT]
196
+ line.stylize(USAGE_LEVEL_COLORS[row.level], len(line) - context_width, len(line))
197
+ return _old_tagged(line, row)
198
+
199
+
200
+ def _old_tagged(line: Text, row: UsageRow) -> Text:
201
+ if row.old:
202
+ line.append(f"{GAP}{OLD_TAG}", style=SECONDARY_TEXT_COLOR)
203
+ return line
204
+
205
+
206
+ def _table_lines(table: UsageTable) -> list[Text]:
207
+ if table.stacked:
208
+ return [line for row in table.rows for line in _stacked_row(row)]
209
+ header = _line(table, {column: column.value for column in Column}, SECONDARY_TEXT_COLOR)
210
+ return [header, *(_row_text(table, row) for row in table.rows)]
211
+
212
+
213
+ def _stacked_row(row: UsageRow) -> list[Text]:
214
+ role = _old_tagged(Text(LEAD + row.cells[Column.ROLE]), row)
215
+ pair = Text.assemble(LEAD, GAP, grey(PAIR_LABEL), GAP, row.cells[Column.TOKENS])
216
+ return [role, pair]
217
+
218
+
219
+ def _heading(sessions: Sequence[TaskSessionView]) -> Text | None:
220
+ parts: list[Text] = []
221
+ cost = total_cost_text(sessions)
222
+ if cost is not None:
223
+ parts.append(Text(cost))
224
+ known = [_session_tokens(session) for session in sessions if _tokens_known(session)]
225
+ if known:
226
+ parts.append(Text.assemble((compact_count(sum(known)), BOLD_STEEL), grey(TOKENS_WORD)))
227
+ return Text(GROUP_SEPARATOR).join(parts) if parts else None
228
+
229
+
230
+ def _note(sessions: Sequence[TaskSessionView]) -> Text | None:
231
+ silent = sum(session.usage_reported is False for session in sessions)
232
+ gaps = sum(len(session.history_gaps) for session in sessions)
233
+ unknown = sum(session.billing_mode == BILLING_MODE_UNKNOWN for session in sessions)
234
+ parts: list[str] = []
235
+ if silent:
236
+ parts.append(f"{counted(silent, _NOUN_SESSION)} {_VERB_NO_USAGE}")
237
+ if gaps:
238
+ parts.append(counted(gaps, _NOUN_GAP))
239
+ if unknown:
240
+ parts.append(f"{_PHRASE_BILLING_UNKNOWN} {counted(unknown, _NOUN_SESSION)}")
241
+ return Text(NOTE_LEAD + GROUP_SEPARATOR.join(parts), style=AMBER) if parts else None
242
+
243
+
244
+ @dataclass(frozen=True)
245
+ class UsageBody:
246
+ """Render the Usage fold of a task at the width it is drawn at."""
247
+
248
+ view: TaskDetailView
249
+
250
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
251
+ """Yield the heading, the session table and the note."""
252
+ del console
253
+ view = self.view
254
+ if view.usage is None:
255
+ yield grey(COVERAGE_UNAVAILABLE)
256
+ if not view.sessions:
257
+ if view.usage is not None:
258
+ yield grey(NO_USAGE)
259
+ return
260
+ table = usage_rows(view.sessions, options.max_width)
261
+ heading = _heading(view.sessions)
262
+ blocks: list[RenderableType] = [
263
+ *([heading] if heading is not None else []),
264
+ *_table_lines(table),
265
+ ]
266
+ note = _note(view.sessions)
267
+ if note is not None:
268
+ blocks.extend([Text(), note])
269
+ yield Group(*blocks)