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,1209 @@
1
+ """Shared task detail presentation for the workspace and fixed task view."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Awaitable, Callable
7
+ from dataclasses import replace
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING, ClassVar, cast
10
+
11
+ from rich.cells import cell_len
12
+ from rich.console import Group
13
+ from rich.text import Text
14
+ from textual import on
15
+ from textual.binding import Binding
16
+ from textual.containers import Horizontal, Vertical, VerticalScroll
17
+ from textual.screen import ModalScreen
18
+ from textual.widgets import Button, Label, Rule, Static
19
+
20
+ from shikiru.clock import to_local
21
+ from shikiru.dashboard.blocker_evidence import evidence_body, evidence_headline, raw_evidence_text
22
+ from shikiru.dashboard.briefing_view import (
23
+ ACTION_LABELS,
24
+ TERMINAL_STATUSES,
25
+ Briefing,
26
+ BriefingHalf,
27
+ BriefingLayout,
28
+ blockers_value_indent,
29
+ briefing_verbs,
30
+ header_text,
31
+ )
32
+ from shikiru.dashboard.contract import (
33
+ PaletteRequest,
34
+ TaskTimelineCategory,
35
+ )
36
+ from shikiru.dashboard.detail_fold import DetailFold, DetailFolds
37
+ from shikiru.dashboard.detail_hint import DetailHintBar, HintTarget, hint_for
38
+ from shikiru.dashboard.details_list_view import ROW_LEAD, DetailsSection, section_headline
39
+ from shikiru.dashboard.fold_bodies import (
40
+ DecisionsBody,
41
+ DescriptionBody,
42
+ ResultsBody,
43
+ SettingsBody,
44
+ WorkspaceBody,
45
+ )
46
+ from shikiru.dashboard.row_list import RowList
47
+ from shikiru.dashboard.section_rows import RelatedRow, relation_rows
48
+ from shikiru.dashboard.tick import tick_now
49
+ from shikiru.dashboard.timeline_filter import TimelineFilterBar
50
+ from shikiru.dashboard.timeline_section import (
51
+ TimelineHost,
52
+ TimelineIds,
53
+ TimelineSection,
54
+ screen_is_active,
55
+ )
56
+ from shikiru.dashboard.usage_body import UsageBody
57
+ from shikiru.domain.briefing import BriefingAction
58
+ from shikiru.domain.quota_waits import quota_clock_text
59
+ from shikiru.domain.tasks import Role, Status, StatusReason
60
+ from shikiru.errors import ShikiruError
61
+ from shikiru.ui.gated_static import GatedStatic
62
+ from shikiru.ui.popup import (
63
+ POPUP_CLOSE_CLASS,
64
+ POPUP_CSS,
65
+ POPUP_FRAME_CLASS,
66
+ POPUP_HEADER_CLASS,
67
+ POPUP_RULE_CLASS,
68
+ POPUP_TITLE_CLASS,
69
+ PopupFrame,
70
+ popup_close_label,
71
+ prepare_popup,
72
+ )
73
+ from shikiru.ui.screen import widget_is_attached
74
+ from shikiru.ui.section import BOLD_PRIMARY, grey, key_label
75
+ from shikiru.ui.tokens import SECONDARY_TEXT_COLOR, STEEL_BLUE
76
+
77
+ if TYPE_CHECKING:
78
+ from datetime import datetime
79
+
80
+ from textual import events
81
+ from textual.app import ComposeResult
82
+ from textual.binding import BindingType
83
+ from textual.geometry import Size
84
+ from textual.widget import Widget
85
+ from textual.worker import Worker
86
+
87
+ from shikiru.dashboard.app import DashboardApp
88
+ from shikiru.dashboard.contract import (
89
+ BlockerEvidenceView,
90
+ TaskDetailView,
91
+ WidgetContext,
92
+ )
93
+ from shikiru.dashboard.services import TaskWorkSample
94
+ from shikiru.ui.strip import TaskStripData
95
+
96
+ TASK_DETAIL_WORKER_GROUP = "task-detail-updates"
97
+ TASK_DETAIL_ESCAPE = "escape"
98
+ SESSION_STATE_CURRENT = "Current"
99
+ SESSION_STATE_USED = "Used"
100
+ TASK_DETAIL_IDENTITY = "task-detail-identity"
101
+ TASK_DETAIL_OWNERSHIP = "task-detail-ownership"
102
+ TASK_DETAIL_BRIEFING = "task-detail-briefing"
103
+ TASK_DETAIL_BRIEFING_BELOW = "task-detail-briefing-below"
104
+ TASK_DETAIL_PROBLEM_FOLDS = "task-detail-problem-folds"
105
+ TASK_DETAIL_ACTIONS = "task-detail-actions"
106
+ TASK_DETAIL_VERBS = "task-detail-verbs"
107
+ TASK_DETAIL_ACTION_PREFIX = "task-detail-action-"
108
+ TASK_DETAIL_POPUP_FRAME = "task-detail-popup-frame"
109
+ TASK_DETAIL_POPUP_HEADER = "task-detail-popup-header"
110
+ TASK_DETAIL_POPUP_TITLE = "task-detail-popup-title"
111
+ TASK_DETAIL_POPUP_CLOSE = "task-detail-popup-close"
112
+ TASK_DETAIL_REQUEST = "task-detail-request"
113
+ TASK_DETAIL_RESULTS = "task-detail-results"
114
+ TASK_DETAIL_DECISIONS = "task-detail-decisions"
115
+ TASK_DETAIL_WORKSPACE = "task-detail-workspace"
116
+ TASK_DETAIL_SESSIONS = "task-detail-sessions"
117
+ TASK_DETAIL_FEEDBACK = "task-detail-feedback"
118
+ TASK_DETAIL_BODY = "task-detail-body"
119
+ TASK_DETAIL_FOLDS = "task-detail-folds"
120
+ FOLDS_COMPACT_CLASS = "folds-compact"
121
+ FOLDS_TINY_CLASS = "folds-tiny"
122
+ TASK_DETAIL_CONTEXT_INFO = "task-detail-context-info"
123
+ TASK_DETAIL_TINY_MAX_ROWS = 8
124
+ _TASK_DETAIL_COMPACT_MAX_COLUMNS = 48
125
+ _TASK_DETAIL_COMPACT_MAX_ROWS = 20
126
+ TASK_DETAIL_VIEWS_KEY = "v"
127
+ DETAILS_HINT_LABEL = "details"
128
+ DETAILS_HINT_TEXT = f"{TASK_DETAIL_VIEWS_KEY} {DETAILS_HINT_LABEL}"
129
+ TASK_DETAIL_DETAILS_HINT = "task-detail-details-hint"
130
+ VERB_MARGIN = 2
131
+ TASK_DETAIL_HELP_KEY = "question_mark"
132
+ TASK_DETAIL_HINT = "task-detail-hint"
133
+ TASK_WINDOW_KEY = "t"
134
+ TASK_WINDOW_ACTION = "task_window"
135
+ TASK_DETAIL_EVIDENCE = "task-detail-evidence"
136
+ TASK_DETAIL_RAW_EVENTS = "task-detail-raw-events"
137
+ RAW_EVENTS_TITLE = "Raw events"
138
+ TASK_DETAIL_RELATIONSHIPS = "task-detail-relationships"
139
+ TASK_DETAIL_RELATED_LIST = "task-detail-related-list"
140
+ EVENTS_TIMELINE_IDS = TimelineIds.with_prefix("task-detail-timeline")
141
+ AGENT_TIMELINE_IDS = TimelineIds.with_prefix("task-detail-agent")
142
+ TASK_DETAIL_AGENT_LIST = AGENT_TIMELINE_IDS.list
143
+ TASK_DETAIL_AGENT_SELECTED = AGENT_TIMELINE_IDS.selected
144
+ TASK_DETAIL_TIMELINE_LIST = EVENTS_TIMELINE_IDS.list
145
+ TASK_DETAIL_TIMELINE_SELECTED = EVENTS_TIMELINE_IDS.selected
146
+ TASK_VIEW_SUMMARY = "task-summary"
147
+ TASK_VIEW_CONTEXT = "task-context"
148
+ TASK_VIEW_USAGE = "task-usage"
149
+ TASK_VIEW_RESULTS = "task-results"
150
+ TASK_VIEW_DECISIONS = "task-decisions"
151
+ TASK_VIEW_REQUEST = "task-request"
152
+ TASK_VIEW_EVIDENCE = "task-evidence"
153
+ TASK_VIEW_RELATIONSHIPS = "task-relationships"
154
+ TASK_VIEW_BLOCKER_EVIDENCE = "task-blocker-evidence"
155
+ TASK_VIEW_RAW_EVENTS = "task-raw-events"
156
+ TASK_VIEW_WORKSPACE = "task-workspace"
157
+ TASK_VIEW_AGENT = "task-agent"
158
+ DETAILS_SECTION_VIEWS = {
159
+ DetailsSection.REQUEST: TASK_VIEW_REQUEST,
160
+ DetailsSection.RESULTS: TASK_VIEW_RESULTS,
161
+ DetailsSection.DECISIONS: TASK_VIEW_DECISIONS,
162
+ DetailsSection.EVENTS: TASK_VIEW_EVIDENCE,
163
+ DetailsSection.AGENT_ACTIVITY: TASK_VIEW_AGENT,
164
+ DetailsSection.USAGE: TASK_VIEW_USAGE,
165
+ DetailsSection.BLOCKERS: TASK_VIEW_RELATIONSHIPS,
166
+ DetailsSection.BLOCKER_EVIDENCE: TASK_VIEW_BLOCKER_EVIDENCE,
167
+ DetailsSection.WORKSPACE: TASK_VIEW_WORKSPACE,
168
+ DetailsSection.SETTINGS: TASK_VIEW_CONTEXT,
169
+ }
170
+ TIMELINE_SECTIONS = {
171
+ DetailsSection.EVENTS: (EVENTS_TIMELINE_IDS, TaskTimelineCategory.ACTIVITY, False),
172
+ DetailsSection.AGENT_ACTIVITY: (AGENT_TIMELINE_IDS, TaskTimelineCategory.AGENT, True),
173
+ }
174
+ PROBLEM_SECTION = DetailsSection.BLOCKER_EVIDENCE
175
+ MAIN_SECTIONS = tuple(section for section in DetailsSection if section is not PROBLEM_SECTION)
176
+ FOLD_STATICS = {
177
+ TASK_VIEW_USAGE: (TASK_DETAIL_SESSIONS,),
178
+ TASK_VIEW_CONTEXT: (TASK_DETAIL_CONTEXT_INFO,),
179
+ TASK_VIEW_RESULTS: (TASK_DETAIL_RESULTS,),
180
+ TASK_VIEW_DECISIONS: (TASK_DETAIL_DECISIONS,),
181
+ TASK_VIEW_REQUEST: (TASK_DETAIL_REQUEST,),
182
+ TASK_VIEW_RELATIONSHIPS: (TASK_DETAIL_RELATIONSHIPS,),
183
+ TASK_VIEW_BLOCKER_EVIDENCE: (TASK_DETAIL_EVIDENCE,),
184
+ TASK_VIEW_WORKSPACE: (TASK_DETAIL_WORKSPACE,),
185
+ }
186
+ FOLD_HINT_TARGETS = {
187
+ TASK_VIEW_EVIDENCE: HintTarget.EVENTS,
188
+ TASK_VIEW_AGENT: HintTarget.AGENT,
189
+ TASK_VIEW_RELATIONSHIPS: HintTarget.DEPENDENCIES,
190
+ }
191
+ NO_DECISION_TO_ANSWER = "No open decision to answer for this task."
192
+ NO_RELATED_ROWS = "No related tasks are recorded."
193
+ RELATIONSHIPS_UNAVAILABLE = "Relationship evidence is unavailable."
194
+ NO_REPLACEMENT_TASKS = "No replacement task IDs are present in the recorded scope-change event."
195
+ CLOSED_RUN_STATUS = "closed"
196
+ PAGE_LABEL = "page"
197
+ COST_VALUE_FORMAT = ".2f"
198
+ AGE_NOW = "now"
199
+ COMPACT_INSPECTOR_CLASS = "compact-inspector"
200
+
201
+
202
+ class TaskDetailBody(VerticalScroll, can_focus=False):
203
+ """Scrolls the detail to its focused child; it is never a Tab stop itself."""
204
+
205
+ def on_resize(self, event: events.Resize) -> None:
206
+ del event
207
+ focused = self.screen.focused
208
+ if focused is not None and self in focused.ancestors and not self.can_view_entire(focused):
209
+ self.call_after_refresh(
210
+ self.scroll_to_center,
211
+ focused,
212
+ animate=False,
213
+ )
214
+
215
+
216
+ class TaskDetailHint(Static):
217
+ """The right-aligned `v details` hint of the actions row; clicking it focuses the sections."""
218
+
219
+ def __init__(self, hint: Text) -> None:
220
+ super().__init__(hint, id=TASK_DETAIL_DETAILS_HINT, markup=False)
221
+
222
+ def on_click(self) -> None:
223
+ """Focus the section headers, like pressing the key."""
224
+ self.query_ancestor(TaskDetailPanel).focus_folds()
225
+
226
+
227
+ class TaskDetailVerb(Button):
228
+ """A briefing action button of the task detail, drawn without the stock line padding."""
229
+
230
+ def on_mount(self) -> None:
231
+ """Drop the one-cell line padding the stock button adds on each side of its label."""
232
+ self.styles.line_pad = 0
233
+
234
+ def get_content_width(self, container: Size, viewport: Size) -> int:
235
+ """Return the label's own width, without the stock two cells of line padding."""
236
+ del container, viewport
237
+ return cell_len(str(self.label))
238
+
239
+
240
+ class TaskDetailPanel(Vertical):
241
+ """Render shared task detail from the injected dashboard view contract."""
242
+
243
+ BINDINGS: ClassVar[list[BindingType]] = [
244
+ Binding(TASK_WINDOW_KEY, TASK_WINDOW_ACTION, "Task window", show=False)
245
+ ]
246
+ DEFAULT_CSS = """
247
+ #task-detail-panel { width: 100%; height: auto; }
248
+ #task-detail-identity { height: auto; padding: 0; color: $steel-blue; }
249
+ #task-detail-context-info { height: auto; color: $primary-text; }
250
+ #task-detail-ownership { height: auto; padding: 0; color: $amber; }
251
+ #task-detail-feedback { height: auto; min-height: 0; color: $secondary-text; }
252
+ #task-detail-body {
253
+ height: auto; max-height: 32; padding: 0 1; scrollbar-size-vertical: $scrollbar-width;
254
+ }
255
+ #task-detail-briefing, #task-detail-briefing-below { height: auto; padding: 0; }
256
+ #task-detail-problem-folds { border-top: none; }
257
+ #task-detail-actions {
258
+ width: 100%; height: auto; border-top: solid $neutral-border;
259
+ }
260
+ #task-detail-verbs { width: 1fr; height: 1; }
261
+ #task-detail-details-hint { width: auto; height: 1; }
262
+ #task-detail-verbs Button {
263
+ width: auto; min-width: 1; height: 1; margin: 0 2 0 0; padding: 0;
264
+ border: none; background: $background; color: $steel-blue;
265
+ content-align: left middle; text-align: left; text-style: none;
266
+ }
267
+ #task-detail-actions Button:focus { text-style: reverse; }
268
+ #task-detail-actions Button:hover { color: $primary-text; }
269
+ #task-detail-hint { height: 1; padding: 0 1; }
270
+ .task-detail-section { height: auto; padding: 0; color: $primary-text; }
271
+ TaskDetailPanel RowList { max-height: 10; overflow-y: auto; scrollbar-size-vertical: 1; }
272
+ TaskDetailPanel.folds-compact RowList { max-height: 6; }
273
+ TaskDetailPanel.folds-tiny RowList { max-height: 3; }
274
+ """
275
+
276
+ def __init__(
277
+ self,
278
+ context: WidgetContext,
279
+ task_id: str | None,
280
+ *,
281
+ fixed: bool,
282
+ close_detail: CloseDetail | None = None,
283
+ initial_view: str = TASK_VIEW_SUMMARY,
284
+ on_initial_content: Callable[[], None] | None = None,
285
+ ) -> None:
286
+ classes = "task-detail-fixed" if fixed else ""
287
+ super().__init__(id="task-detail-panel", classes=classes)
288
+ self._widget_context = context
289
+ self._task_id = task_id
290
+ self._fixed = fixed
291
+ self._close_detail = close_detail
292
+ self._refresh_lock = asyncio.Lock()
293
+ self._refresh_worker: Worker[None] | None = None
294
+ self._change_worker: Worker[None] | None = None
295
+ self._relationship_offset = 0
296
+ self._relationship_previous_offset: int | None = None
297
+ self._relationship_next_offset: int | None = None
298
+ self._related_rows: tuple[RelatedRow, ...] = ()
299
+ host = TimelineHost(
300
+ task_id=lambda: self._task_id,
301
+ lock=self._refresh_lock,
302
+ report=self._set_feedback,
303
+ )
304
+ self._timelines = {
305
+ DETAILS_SECTION_VIEWS[section]: TimelineSection(
306
+ context, host, ids=ids, category=category, filterable=filterable
307
+ )
308
+ for section, (ids, category, filterable) in TIMELINE_SECTIONS.items()
309
+ }
310
+ self._folds: dict[str, DetailFold] = {}
311
+ self._initial_view = initial_view
312
+ self._timeline_worker: Worker[None] | None = None
313
+ self._remove_motion_callback: Callable[[], None] | None = None
314
+ self._body_focus_generation = 0
315
+ self._rendered_task_id: str | None = None
316
+ self._view: TaskDetailView | None = None
317
+ self._observed_work: tuple[str, TaskWorkSample | None] | None = None
318
+ self._shown_quota_wording: tuple[str, str] | None = None
319
+ self._on_initial_content = on_initial_content
320
+ self._initial_content_reported = False
321
+
322
+ def compose(self) -> ComposeResult:
323
+ with TaskDetailBody(id=TASK_DETAIL_BODY):
324
+ yield GatedStatic("Loading task detail…", id=TASK_DETAIL_IDENTITY, markup=False)
325
+ yield Static("", id=TASK_DETAIL_OWNERSHIP, markup=False)
326
+ yield Static("", id=TASK_DETAIL_FEEDBACK, markup=False)
327
+ yield GatedStatic("", id=TASK_DETAIL_BRIEFING, markup=False)
328
+ with DetailFolds(widget_id=TASK_DETAIL_PROBLEM_FOLDS):
329
+ yield self._new_fold(PROBLEM_SECTION, name_color=SECONDARY_TEXT_COLOR)
330
+ yield GatedStatic("", id=TASK_DETAIL_BRIEFING_BELOW, markup=False)
331
+ with Horizontal(id=TASK_DETAIL_ACTIONS):
332
+ with Horizontal(id=TASK_DETAIL_VERBS):
333
+ for action in BriefingAction:
334
+ yield TaskDetailVerb(
335
+ ACTION_LABELS[action], id=f"{TASK_DETAIL_ACTION_PREFIX}{action.value}"
336
+ )
337
+ yield TaskDetailHint(key_label(TASK_DETAIL_VIEWS_KEY, DETAILS_HINT_LABEL))
338
+ with DetailFolds(widget_id=TASK_DETAIL_FOLDS):
339
+ for section in MAIN_SECTIONS:
340
+ yield self._new_fold(section)
341
+ yield DetailHintBar(widget_id=TASK_DETAIL_HINT)
342
+
343
+ def _new_fold(self, section: DetailsSection, *, name_color: str = STEEL_BLUE) -> DetailFold:
344
+ view_id = DETAILS_SECTION_VIEWS[section]
345
+ fold = DetailFold(
346
+ view_id,
347
+ section.value,
348
+ self._section_widgets(view_id),
349
+ is_open=view_id == self._initial_view,
350
+ name_color=name_color,
351
+ )
352
+ self._folds[view_id] = fold
353
+ return fold
354
+
355
+ def _section_widgets(self, view_id: str) -> list[Widget]:
356
+ timeline = self._timelines.get(view_id)
357
+ if timeline is not None:
358
+ return [timeline]
359
+ widgets: list[Widget] = [
360
+ GatedStatic("", id=widget_id, classes="task-detail-section", markup=False)
361
+ for widget_id in FOLD_STATICS.get(view_id, ())
362
+ ]
363
+ if view_id == TASK_VIEW_RELATIONSHIPS:
364
+ widgets.append(RowList(widget_id=TASK_DETAIL_RELATED_LIST))
365
+ if view_id == TASK_VIEW_BLOCKER_EVIDENCE:
366
+ widgets.append(self._raw_events_fold())
367
+ return widgets
368
+
369
+ def _raw_events_fold(self) -> DetailFold:
370
+ fold = DetailFold(
371
+ TASK_VIEW_RAW_EVENTS,
372
+ RAW_EVENTS_TITLE,
373
+ [
374
+ GatedStatic(
375
+ "", id=TASK_DETAIL_RAW_EVENTS, classes="task-detail-section", markup=False
376
+ )
377
+ ],
378
+ name_color=SECONDARY_TEXT_COLOR,
379
+ )
380
+ self._folds[TASK_VIEW_RAW_EVENTS] = fold
381
+ return fold
382
+
383
+ async def on_mount(self) -> None:
384
+ self.disabled = self._task_id is None
385
+ self._folds_list().display = self._task_id is not None
386
+ self._hide_blocker_evidence()
387
+ self._sync_timelines()
388
+ identity = self.query_one(f"#{TASK_DETAIL_IDENTITY}", Static)
389
+ identity.update(
390
+ _task_identity_text(f"{self._task_id} · loading selected task")
391
+ if self._task_id is not None
392
+ else "Select a task to inspect it."
393
+ )
394
+ self._set_feedback(None)
395
+ self._refresh_worker = self.run_worker(self._refresh(), group=TASK_DETAIL_WORKER_GROUP)
396
+ self._change_worker = self.run_worker(
397
+ self._widget_context.on_change(self._refresh),
398
+ group=TASK_DETAIL_WORKER_GROUP,
399
+ )
400
+ self._timeline_worker = self.run_worker(
401
+ self._watch_timeline(), group=TASK_DETAIL_WORKER_GROUP
402
+ )
403
+ self._remove_motion_callback = self._widget_context.on_motion(self._refresh_activity)
404
+
405
+ def on_unmount(self) -> None:
406
+ if self._refresh_worker is not None:
407
+ self._refresh_worker.cancel()
408
+ if self._change_worker is not None:
409
+ self._change_worker.cancel()
410
+ if self._timeline_worker is not None:
411
+ self._timeline_worker.cancel()
412
+ if self._remove_motion_callback is not None:
413
+ self._remove_motion_callback()
414
+ self._remove_motion_callback = None
415
+
416
+ def on_descendant_focus(self, event: events.DescendantFocus) -> None:
417
+ fold = self._fold_of(event.widget)
418
+ if fold is not None:
419
+ fold.folds.make_current(fold)
420
+ self._paint_hint()
421
+ body = self.query_one(f"#{TASK_DETAIL_BODY}", TaskDetailBody)
422
+ if body in event.widget.ancestors:
423
+ self._body_focus_generation += 1
424
+ if not body.can_view_entire(event.widget):
425
+ self.call_after_refresh(
426
+ body.scroll_to_center,
427
+ event.widget,
428
+ animate=False,
429
+ )
430
+ return
431
+
432
+ def on_descendant_blur(self, event: events.DescendantBlur) -> None:
433
+ del event
434
+ self.call_after_refresh(self._paint_hint)
435
+
436
+ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
437
+ del parameters
438
+ return self._task_window_available() if action == TASK_WINDOW_ACTION else True
439
+
440
+ async def action_task_window(self) -> None:
441
+ await self._navigate_task_window()
442
+
443
+ def _task_window_available(self) -> bool:
444
+ view = self._view
445
+ fold = self._fold_of(self.screen.focused)
446
+ return (
447
+ view is not None
448
+ and fold is not None
449
+ and fold.view_id == TASK_VIEW_WORKSPACE
450
+ and view.status not in TERMINAL_STATUSES
451
+ and view.window_id is not None
452
+ )
453
+
454
+ def _hint_target(self) -> HintTarget | None:
455
+ focused = self.screen.focused
456
+ fold = self._fold_of(focused)
457
+ if fold is None:
458
+ return None
459
+ if focused is fold.header:
460
+ return HintTarget.HEADER
461
+ if isinstance(focused, TimelineFilterBar):
462
+ return HintTarget.AGENT_FILTER
463
+ return FOLD_HINT_TARGETS.get(fold.view_id)
464
+
465
+ def _paint_hint(self) -> None:
466
+ bars = self.query(DetailHintBar)
467
+ if not widget_is_attached(self) or not bars:
468
+ return
469
+ target = self._hint_target()
470
+ bars.first().show(
471
+ None
472
+ if target is None
473
+ else hint_for(
474
+ target,
475
+ task_window=self._task_window_available(),
476
+ keys_only=self._briefing_layout().tiny,
477
+ )
478
+ )
479
+
480
+ def select_task(self, task_id: str | None) -> None:
481
+ if task_id == self._task_id:
482
+ return
483
+ self._task_id = task_id
484
+ self._reset_task_identity_state()
485
+ self.disabled = task_id is None
486
+ if not self.is_mounted:
487
+ return
488
+ self._folds_list().display = task_id is not None
489
+ identity = self.query_one(f"#{TASK_DETAIL_IDENTITY}", Static)
490
+ identity.update(
491
+ _task_identity_text(f"{task_id} · loading selected task")
492
+ if task_id is not None
493
+ else "Select a task to inspect it."
494
+ )
495
+ self.query_one(f"#{TASK_DETAIL_BODY}", TaskDetailBody).scroll_to(y=0, animate=False)
496
+ if task_id is None:
497
+ return
498
+ self._queue_refresh()
499
+
500
+ def _reset_task_identity_state(self) -> None:
501
+ self._relationship_offset = 0
502
+ self._view = None
503
+ self._relationship_previous_offset = None
504
+ self._relationship_next_offset = None
505
+ self._related_rows = ()
506
+ for timeline in self._timelines.values():
507
+ timeline.reset()
508
+ if not self.is_mounted:
509
+ return
510
+ for section_id in (
511
+ TASK_DETAIL_OWNERSHIP,
512
+ TASK_DETAIL_REQUEST,
513
+ TASK_DETAIL_RESULTS,
514
+ TASK_DETAIL_DECISIONS,
515
+ TASK_DETAIL_WORKSPACE,
516
+ TASK_DETAIL_SESSIONS,
517
+ ):
518
+ self.query_one(f"#{section_id}", Static).update("")
519
+ self.query_one(f"#{TASK_DETAIL_BRIEFING}", GatedStatic).update("")
520
+ self.query_one(f"#{TASK_DETAIL_BRIEFING_BELOW}", GatedStatic).update("")
521
+ self._hide_blocker_evidence()
522
+ self._set_feedback(None)
523
+ self.query_one(f"#{TASK_DETAIL_RELATIONSHIPS}", Static).update("")
524
+ self.query_one(f"#{TASK_DETAIL_RELATED_LIST}", RowList).set_rows(
525
+ (), selected=0, empty=NO_RELATED_ROWS
526
+ )
527
+
528
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
529
+ button_id = event.button.id or ""
530
+ if button_id.startswith(TASK_DETAIL_ACTION_PREFIX):
531
+ await self._run_briefing_action(
532
+ BriefingAction(button_id.removeprefix(TASK_DETAIL_ACTION_PREFIX))
533
+ )
534
+
535
+ @on(RowList.Paged, f"#{TASK_DETAIL_RELATED_LIST}")
536
+ async def _related_paged(self, message: RowList.Paged) -> None:
537
+ message.stop()
538
+ await (
539
+ self._next_relationship_page() if message.older else self._previous_relationship_page()
540
+ )
541
+
542
+ @on(RowList.Opened, f"#{TASK_DETAIL_RELATED_LIST}")
543
+ async def _related_opened(self, message: RowList.Opened) -> None:
544
+ message.stop()
545
+ callback = self._widget_context.open_task_detail
546
+ if callback is not None and message.index < len(self._related_rows):
547
+ await callback(self._related_rows[message.index].task_id)
548
+
549
+ async def _watch_timeline(self) -> None:
550
+ while True:
551
+ seconds = self._widget_context.settings.current().dashboard.timeline_refresh_seconds
552
+ await self._widget_context.clock.sleep(seconds)
553
+ if self._task_id is None or not self._screen_is_active():
554
+ continue
555
+ await self._refresh_activity()
556
+ await self._refresh_live_work()
557
+ await self._refresh_countdowns()
558
+ for timeline in self._timelines.values():
559
+ await timeline.refresh_when_due()
560
+
561
+ async def _refresh_live_work(self) -> None:
562
+ task_id = self._task_id
563
+ if task_id is None or not self._screen_is_active():
564
+ return
565
+ sample = await self._widget_context.views.live_work(task_id)
566
+ if (task_id, sample) == self._observed_work:
567
+ return
568
+ await self._load_view()
569
+
570
+ async def _refresh_countdowns(self) -> None:
571
+ view = self._view
572
+ if view is None:
573
+ return
574
+ now = tick_now(self._widget_context.clock)
575
+ quota = view.quota_wait
576
+ if _has_expired_quota_reading(view.blocker_evidence, now):
577
+ await self._load_view()
578
+ return
579
+ if quota is not None and now >= quota.refresh_at:
580
+ await self._load_view()
581
+ return
582
+ if quota is not None and (
583
+ quota_clock_text(quota.agent, to_local(quota.until), to_local(now))
584
+ != self._shown_quota_wording
585
+ ):
586
+ self._render_briefing(view, now, self._briefing_layout())
587
+ if view.blocker_evidence is not None:
588
+ self._show_blocker_evidence(view.blocker_evidence, now)
589
+
590
+ async def _refresh_activity(self) -> None:
591
+ if (
592
+ self._task_id is None
593
+ or self._view is None
594
+ or self._view.task_id != self._task_id
595
+ or not self._screen_is_active()
596
+ ):
597
+ return
598
+ strip = next(
599
+ (item for item in self._widget_context.views.strips() if item.task_id == self._task_id),
600
+ None,
601
+ )
602
+ if strip is None:
603
+ return
604
+ activity = self._activity_view(self._view, strip)
605
+ self._render_activity(activity)
606
+
607
+ def _activity_view(self, view: TaskDetailView, strip: TaskStripData) -> TaskDetailView:
608
+ signal_is_newer = (
609
+ strip.last_signal is not None
610
+ and strip.last_signal_at is not None
611
+ and view.latest_signal_at is not None
612
+ and strip.last_signal_at > view.latest_signal_at
613
+ )
614
+ signal_has_no_recorded_predecessor = (
615
+ strip.last_signal is not None
616
+ and strip.last_signal_at is not None
617
+ and view.latest_signal is None
618
+ )
619
+ signal_values = (
620
+ {
621
+ "latest_signal": strip.last_signal,
622
+ "latest_signal_at": strip.last_signal_at,
623
+ }
624
+ if signal_is_newer or signal_has_no_recorded_predecessor
625
+ else {}
626
+ )
627
+ return replace(
628
+ view,
629
+ progress=strip,
630
+ last_words=strip.last_words if strip.last_words is not None else view.last_words,
631
+ signal_observed=strip.last_signal_at is not None
632
+ and any(panel.spinner_frame for panel in strip.panels),
633
+ actual_stage_started_at=strip.actual_step_started_at,
634
+ **signal_values,
635
+ )
636
+
637
+ def _render_activity(self, view: TaskDetailView) -> None:
638
+ now = self._widget_context.clock.now()
639
+ layout = self._briefing_layout()
640
+ self.set_class(layout.compact, FOLDS_COMPACT_CLASS)
641
+ self.set_class(layout.tiny, FOLDS_TINY_CLASS)
642
+ self.query_one(f"#{TASK_DETAIL_IDENTITY}", GatedStatic).update_if_changed(
643
+ header_text(view, now, compact=layout.compact)
644
+ )
645
+ self._render_briefing(view, now, layout)
646
+ if view.blocker_evidence is None:
647
+ self._hide_blocker_evidence()
648
+ else:
649
+ self._show_blocker_evidence(view.blocker_evidence, now)
650
+ self._render_verbs(view)
651
+
652
+ def _briefing_layout(self) -> BriefingLayout:
653
+ compact = self.size.width <= _TASK_DETAIL_COMPACT_MAX_COLUMNS or self.has_class(
654
+ COMPACT_INSPECTOR_CLASS
655
+ )
656
+ dashboard = self._widget_context.settings.current().dashboard
657
+ return BriefingLayout(
658
+ now_lines=dashboard.now_compact_lines if compact else dashboard.now_max_lines,
659
+ compact=compact,
660
+ tiny=self.screen.size.height <= TASK_DETAIL_TINY_MAX_ROWS,
661
+ )
662
+
663
+ def _render_verbs(self, view: TaskDetailView) -> None:
664
+ verbs = briefing_verbs(view)
665
+ for action in BriefingAction:
666
+ self.query_one(f"#{TASK_DETAIL_ACTION_PREFIX}{action.value}", Button).display = (
667
+ action in verbs
668
+ )
669
+ used = sum(cell_len(ACTION_LABELS[action]) + VERB_MARGIN for action in verbs)
670
+ free = self.query_one(f"#{TASK_DETAIL_ACTIONS}").size.width - used
671
+ hint = self.query_one(f"#{TASK_DETAIL_DETAILS_HINT}", TaskDetailHint)
672
+ hint.display = free >= cell_len(DETAILS_HINT_TEXT)
673
+ focused = self.screen.focused
674
+ if isinstance(focused, TaskDetailVerb) and not focused.display:
675
+ self.focus_summary()
676
+
677
+ def on_resize(self, event: events.Resize) -> None:
678
+ del event
679
+ if self._view is not None and self.is_mounted:
680
+ self._render_activity(self._view)
681
+ self.call_after_refresh(self._align_problem_folds)
682
+ self._paint_hint()
683
+
684
+ def _screen_is_active(self) -> bool:
685
+ return screen_is_active(self)
686
+
687
+ async def _back(self) -> None:
688
+ if self._fixed:
689
+ await self._navigate_home()
690
+ return
691
+ if self._close_detail is not None:
692
+ await self._close_detail()
693
+
694
+ async def _open_palette(
695
+ self,
696
+ role: Role | None = None,
697
+ *,
698
+ action: str | None = None,
699
+ ) -> None:
700
+ if self._task_id is None:
701
+ return
702
+ callback = self._widget_context.open_palette_context
703
+ if callback is not None:
704
+ await callback(
705
+ PaletteRequest(
706
+ action=action,
707
+ task_id=self._task_id,
708
+ role=role.value if role is not None else None,
709
+ )
710
+ )
711
+ return
712
+ await self._widget_context.open_palette(action or "")
713
+
714
+ async def open_palette(self) -> None:
715
+ """Open the shared palette with this detail's current task and selected role."""
716
+ await self._open_palette(self._action_role())
717
+
718
+ async def open_help(self) -> None:
719
+ """Open help scoped to the current task and detail view."""
720
+ if self._task_id is None:
721
+ return
722
+ await cast("DashboardApp", self.app).open_task_detail_help(
723
+ self._task_id,
724
+ )
725
+
726
+ @property
727
+ def task_id(self) -> str | None:
728
+ return self._task_id
729
+
730
+ @property
731
+ def open_views(self) -> frozenset[str]:
732
+ """The view ids of the folds that are open."""
733
+ return frozenset(view_id for view_id, fold in self._folds.items() if fold.is_open)
734
+
735
+ def focus_folds(self) -> None:
736
+ """Move focus down to the section headers."""
737
+ folds = self._folds_list()
738
+ folds.focus_header(folds.current_fold)
739
+
740
+ def focus_summary(self) -> None:
741
+ """Focus the first verb the task offers, or the current fold header when it offers none."""
742
+ first_verb = next(iter(self.query_one(f"#{TASK_DETAIL_VERBS}").displayed_children), None)
743
+ if first_verb is not None:
744
+ first_verb.focus()
745
+ return
746
+ self.focus_folds()
747
+
748
+ def open_view(self, view_id: str) -> None:
749
+ """Open the fold for `view_id`, bring it into view and focus it."""
750
+ fold = self._folds.get(view_id)
751
+ if fold is None:
752
+ return
753
+ fold.set_open(True)
754
+ self.call_after_refresh(fold.folds.focus_fold, fold)
755
+
756
+ def step_out(self) -> bool:
757
+ """Move focus from inside a fold body to its header; False when it is not in a body."""
758
+ focused = self.screen.focused
759
+ fold = self._fold_of(focused)
760
+ if focused is None or fold is None or focused is fold.header:
761
+ return False
762
+ if isinstance(focused, TimelineFilterBar):
763
+ focused.action_close()
764
+ return True
765
+ fold.folds.focus_header(fold)
766
+ return True
767
+
768
+ def _fold_of(self, widget: Widget | None) -> DetailFold | None:
769
+ ancestors = widget.ancestors if widget is not None else ()
770
+ return next((item for item in ancestors if isinstance(item, DetailFold)), None)
771
+
772
+ def _folds_list(self) -> DetailFolds:
773
+ return self.query_one(f"#{TASK_DETAIL_FOLDS}", DetailFolds)
774
+
775
+ async def return_from_detail(self) -> None:
776
+ await self._back()
777
+
778
+ async def _navigate_task_window(self) -> None:
779
+ if self._task_id is None:
780
+ await self._show_navigation_result("Select a task before opening its window.")
781
+ return
782
+ callback = self._widget_context.navigate_task_window
783
+ await self._show_navigation_result(
784
+ await callback(self._task_id)
785
+ if callback is not None
786
+ else "Task window navigation is unavailable."
787
+ )
788
+
789
+ async def _navigate_home(self) -> None:
790
+ callback = self._widget_context.navigate_home
791
+ await self._show_navigation_result(
792
+ await callback()
793
+ if callback is not None
794
+ else "Home workspace navigation is unavailable."
795
+ )
796
+
797
+ async def _show_navigation_result(self, result: str) -> None:
798
+ self._set_feedback(result)
799
+
800
+ def _set_feedback(self, result: str | None) -> None:
801
+ feedback = self.query_one(f"#{TASK_DETAIL_FEEDBACK}", Static)
802
+ feedback.update(result or "")
803
+ feedback.display = result is not None
804
+
805
+ async def _refresh(self) -> None:
806
+ await self._load_view()
807
+ for timeline in self._timelines.values():
808
+ if timeline.shown:
809
+ await timeline.refresh_page()
810
+
811
+ async def _load_view(self) -> None:
812
+ async with self._refresh_lock:
813
+ if not self._screen_is_active():
814
+ return
815
+ task_id = self._task_id
816
+ if task_id is None:
817
+ self.query_one(f"#{TASK_DETAIL_IDENTITY}", Static).update(
818
+ "Select a task to inspect it."
819
+ )
820
+ return
821
+ try:
822
+ sample = await self._widget_context.views.live_work(task_id)
823
+ view = await self._widget_context.views.task_detail(
824
+ task_id,
825
+ relationship_offset=self._relationship_offset,
826
+ )
827
+ except (ShikiruError, OSError, ValueError, TypeError) as error:
828
+ if self._screen_is_active():
829
+ self._set_feedback(f"Task detail refresh failed: {error}")
830
+ return
831
+ if not self._screen_is_active():
832
+ return
833
+ if self._task_id != task_id:
834
+ return
835
+ if view is None:
836
+ self.query_one(f"#{TASK_DETAIL_IDENTITY}", Static).update(
837
+ f"{task_id} · task record is unavailable"
838
+ )
839
+ self._set_feedback("Task state could not be read.")
840
+ return
841
+ self._observed_work = (task_id, sample)
842
+ self._render_view(view)
843
+
844
+ def _usage_body(self, view: TaskDetailView) -> UsageBody:
845
+ return UsageBody(view)
846
+
847
+ def _queue_refresh(self) -> None:
848
+ if self._task_id is None:
849
+ return
850
+ self.run_worker(
851
+ self._refresh(),
852
+ group=TASK_DETAIL_WORKER_GROUP,
853
+ exclusive=False,
854
+ )
855
+
856
+ def refresh_after_resume(self) -> None:
857
+ """Refresh the current task after this detail screen becomes active again."""
858
+ self._queue_refresh()
859
+
860
+ def _render_view(self, view: TaskDetailView) -> None:
861
+ self._view = view
862
+ body = self.query_one(f"#{TASK_DETAIL_BODY}", TaskDetailBody)
863
+ scroll_y = body.scroll_target_y
864
+ focus_generation = self._body_focus_generation
865
+ focused = self.screen.focused
866
+ restore_scroll = self._rendered_task_id == view.task_id
867
+ self._rendered_task_id = view.task_id
868
+ self._render_ownership(view)
869
+ self.query_one(f"#{TASK_DETAIL_CONTEXT_INFO}", Static).update(SettingsBody(view))
870
+ self._render_relationships(view)
871
+ self._render_activity(view)
872
+ self.query_one(f"#{TASK_DETAIL_REQUEST}", Static).update(DescriptionBody(view))
873
+ self.query_one(f"#{TASK_DETAIL_RESULTS}", Static).update(ResultsBody(view.results))
874
+ self.query_one(f"#{TASK_DETAIL_DECISIONS}", Static).update(DecisionsBody(view))
875
+ self.query_one(f"#{TASK_DETAIL_WORKSPACE}", Static).update(WorkspaceBody(view, Path.home()))
876
+ if self._folds[TASK_VIEW_USAGE].is_open:
877
+ self.query_one(f"#{TASK_DETAIL_SESSIONS}", Static).update(self._usage_body(view))
878
+ self._paint_headlines(view)
879
+ self._paint_hint()
880
+ if self._on_initial_content is not None and not self._initial_content_reported:
881
+ self._initial_content_reported = True
882
+ self.call_after_refresh(self._on_initial_content)
883
+ if restore_scroll:
884
+ body.call_after_refresh(
885
+ self._restore_body_scroll,
886
+ body,
887
+ scroll_y,
888
+ focus_generation,
889
+ focused,
890
+ )
891
+
892
+ def _render_briefing(self, view: TaskDetailView, now: datetime, layout: BriefingLayout) -> None:
893
+ quota = view.quota_wait
894
+ self._shown_quota_wording = (
895
+ None
896
+ if quota is None
897
+ else quota_clock_text(quota.agent, to_local(quota.until), to_local(now))
898
+ )
899
+ self.query_one(f"#{TASK_DETAIL_BRIEFING}", GatedStatic).update_if_changed(
900
+ Briefing(view, now, layout, BriefingHalf.ABOVE)
901
+ )
902
+ self.query_one(f"#{TASK_DETAIL_BRIEFING_BELOW}", GatedStatic).update_if_changed(
903
+ Briefing(view, now, layout, BriefingHalf.BELOW)
904
+ )
905
+ self._align_problem_folds()
906
+
907
+ def _align_problem_folds(self) -> None:
908
+ layout = self._briefing_layout()
909
+ width = self.query_one(f"#{TASK_DETAIL_BRIEFING}", GatedStatic).size.width
910
+ self.query_one(f"#{TASK_DETAIL_PROBLEM_FOLDS}", DetailFolds).styles.margin = (
911
+ 0,
912
+ 0,
913
+ 0,
914
+ blockers_value_indent(compact=layout.compact, width=width) - cell_len(ROW_LEAD),
915
+ )
916
+
917
+ def _show_blocker_evidence(self, evidence: BlockerEvidenceView, now: datetime) -> None:
918
+ fold = self._folds[TASK_VIEW_BLOCKER_EVIDENCE]
919
+ raw_fold = self._folds[TASK_VIEW_RAW_EVENTS]
920
+ fold.display = True
921
+ fold.set_headline(evidence_headline(evidence, now))
922
+ self.query_one(f"#{TASK_DETAIL_EVIDENCE}", GatedStatic).update_if_changed(
923
+ evidence_body(evidence, now)
924
+ )
925
+ raw_fold.display = bool(evidence.raw)
926
+ self.query_one(f"#{TASK_DETAIL_RAW_EVENTS}", GatedStatic).update_if_changed(
927
+ raw_evidence_text(evidence)
928
+ )
929
+
930
+ def _hide_blocker_evidence(self) -> None:
931
+ fold = self._folds[TASK_VIEW_BLOCKER_EVIDENCE]
932
+ fold.display = False
933
+ fold.set_headline(None)
934
+ fold.set_open(False)
935
+ self._folds[TASK_VIEW_RAW_EVENTS].set_open(False)
936
+
937
+ def _paint_headlines(self, view: TaskDetailView) -> None:
938
+ for section in MAIN_SECTIONS:
939
+ view_id = DETAILS_SECTION_VIEWS[section]
940
+ if view_id not in self._timelines:
941
+ self._folds[view_id].set_headline(section_headline(section, view))
942
+
943
+ @on(TimelineSection.StatusChanged)
944
+ def _timeline_status_changed(self, message: TimelineSection.StatusChanged) -> None:
945
+ message.stop()
946
+ view_id = next(
947
+ view_id for view_id, timeline in self._timelines.items() if timeline is message.section
948
+ )
949
+ self._folds[view_id].set_headline(message.status)
950
+
951
+ def _sync_timelines(self) -> None:
952
+ for view_id, timeline in self._timelines.items():
953
+ timeline.shown = self._folds[view_id].is_open
954
+
955
+ @on(DetailFold.Toggled)
956
+ def _fold_toggled(self, message: DetailFold.Toggled) -> None:
957
+ message.stop()
958
+ self._sync_timelines()
959
+ fold = message.fold
960
+ if not fold.is_open:
961
+ return
962
+ timeline = self._timelines.get(fold.view_id)
963
+ if timeline is not None:
964
+ self.run_worker(
965
+ timeline.refresh_page(), group=TASK_DETAIL_WORKER_GROUP, exclusive=False
966
+ )
967
+ elif fold.view_id == TASK_VIEW_USAGE and self._view is not None:
968
+ self.query_one(f"#{TASK_DETAIL_SESSIONS}", Static).update(self._usage_body(self._view))
969
+
970
+ def _restore_body_scroll(
971
+ self,
972
+ body: TaskDetailBody,
973
+ scroll_y: float,
974
+ focus_generation: int,
975
+ focused: Widget | None,
976
+ ) -> None:
977
+ if (
978
+ not self._screen_is_active()
979
+ or focus_generation != self._body_focus_generation
980
+ or self.screen.focused is not focused
981
+ or body.scroll_target_y != scroll_y
982
+ ):
983
+ return
984
+ body.scroll_to(y=scroll_y, animate=False, immediate=True)
985
+ if (
986
+ focused is not None
987
+ and focused.is_mounted
988
+ and focused.display
989
+ and body in focused.ancestors
990
+ and not body.can_view_entire(focused)
991
+ ):
992
+ body.scroll_to_center(focused, animate=False)
993
+
994
+ async def _next_relationship_page(self) -> None:
995
+ if self._relationship_next_offset is not None:
996
+ await self._show_relationship_page(self._relationship_next_offset)
997
+
998
+ async def _previous_relationship_page(self) -> None:
999
+ if self._relationship_previous_offset is not None:
1000
+ await self._show_relationship_page(self._relationship_previous_offset)
1001
+
1002
+ async def _show_relationship_page(self, offset: int) -> None:
1003
+ self._relationship_offset = offset
1004
+ self._related_rows = ()
1005
+ await self._refresh()
1006
+
1007
+ def _render_relationships(self, view: TaskDetailView) -> None:
1008
+ related = self.query_one(f"#{TASK_DETAIL_RELATED_LIST}", RowList)
1009
+ chosen = (
1010
+ self._related_rows[related.selected].task_id
1011
+ if related.selected < len(self._related_rows)
1012
+ else None
1013
+ )
1014
+ self._related_rows = relation_rows(view)
1015
+ related.set_rows(
1016
+ [row.cells for row in self._related_rows],
1017
+ selected=next(
1018
+ (index for index, row in enumerate(self._related_rows) if row.task_id == chosen),
1019
+ 0,
1020
+ ),
1021
+ empty=NO_RELATED_ROWS,
1022
+ )
1023
+ relationships = view.relationships
1024
+ self._relationship_previous_offset = (
1025
+ max(relationships.page_offset - relationships.page_size, 0)
1026
+ if relationships is not None and relationships.page_offset > 0
1027
+ else None
1028
+ )
1029
+ self._relationship_next_offset = (
1030
+ relationships.page_offset + relationships.page_size
1031
+ if relationships is not None and relationships.has_more
1032
+ else None
1033
+ )
1034
+ notes: list[str] = []
1035
+ if relationships is None:
1036
+ notes.append(RELATIONSHIPS_UNAVAILABLE)
1037
+ else:
1038
+ if relationships.page_offset > 0 or relationships.has_more:
1039
+ page = relationships.page_offset // relationships.page_size + 1
1040
+ notes.append(f"{PAGE_LABEL} {page}")
1041
+ if (
1042
+ view.status_reason == StatusReason.SUPERSEDED.value
1043
+ and relationships.replacement_count == 0
1044
+ ):
1045
+ notes.append(NO_REPLACEMENT_TASKS)
1046
+ self.query_one(f"#{TASK_DETAIL_RELATIONSHIPS}", Static).update(
1047
+ Group(*(grey(note) for note in notes))
1048
+ )
1049
+
1050
+ def _render_ownership(self, view: TaskDetailView) -> None:
1051
+ ownership_lines: list[str] = []
1052
+ if view.ownership:
1053
+ owner_label = (
1054
+ "Operator owns"
1055
+ if view.status in {Status.RUNNING.value, Status.INTEGRATING.value}
1056
+ and view.actual_stage is not None
1057
+ else "Recorded operator handoff"
1058
+ )
1059
+ ownership_lines.append(
1060
+ f"{owner_label} {', '.join(role.title() for role in view.ownership)}"
1061
+ )
1062
+ if view.holds:
1063
+ ownership_lines.append(f"Holds · {', '.join(view.holds)}")
1064
+ ownership = self.query_one(f"#{TASK_DETAIL_OWNERSHIP}", Static)
1065
+ ownership.update("\n".join(ownership_lines))
1066
+ ownership.display = bool(ownership_lines)
1067
+
1068
+ async def _run_briefing_action(self, action: BriefingAction) -> None:
1069
+ if action is not BriefingAction.ANSWER:
1070
+ await self._open_palette(self._action_role(action), action=action.value)
1071
+ return
1072
+ resolve = self._widget_context.open_resolution
1073
+ escalation = next(
1074
+ (
1075
+ item
1076
+ for item in self._widget_context.views.escalations()
1077
+ if item.task_id == self._task_id
1078
+ ),
1079
+ None,
1080
+ )
1081
+ if resolve is None or escalation is None:
1082
+ self._set_feedback(NO_DECISION_TO_ANSWER)
1083
+ return
1084
+ await resolve(escalation.escalation_id)
1085
+
1086
+ def _action_role(self, action: BriefingAction | None = None) -> Role | None:
1087
+ view = self._view
1088
+ if view is None or view.task_id != self._task_id:
1089
+ return None
1090
+ if action is BriefingAction.HAND_BACK and view.ownership:
1091
+ return Role(view.ownership[0])
1092
+ return Role(view.acting_role)
1093
+
1094
+
1095
+ class TaskDetailScreen(ModalScreen[None]):
1096
+ """Show a selected task's shared detail over the Home browser."""
1097
+
1098
+ DEFAULT_CSS = (
1099
+ POPUP_CSS
1100
+ + """
1101
+ #task-detail-popup-frame { min-height: 1; }
1102
+ #task-detail-popup-frame.compact { margin: 0 1; padding: 0 1; }
1103
+ #task-detail-popup-frame.tiny {
1104
+ height: 100%; margin: 0; padding: 0;
1105
+ }
1106
+ #task-detail-panel { width: 100%; height: 1fr; min-height: 0; }
1107
+ #task-detail-panel #task-detail-body {
1108
+ height: 1fr; min-height: 0; max-height: 1fr; background: $surface;
1109
+ }
1110
+ """
1111
+ )
1112
+
1113
+ def __init__(
1114
+ self,
1115
+ context: WidgetContext,
1116
+ task_id: str,
1117
+ *,
1118
+ initial_view: str = TASK_VIEW_SUMMARY,
1119
+ ) -> None:
1120
+ super().__init__()
1121
+ prepare_popup(self)
1122
+ self._widget_context = context
1123
+ self._task_id = task_id
1124
+ self._initial_view = initial_view
1125
+
1126
+ def compose(self) -> ComposeResult:
1127
+ with PopupFrame(widget_id=TASK_DETAIL_POPUP_FRAME, classes=POPUP_FRAME_CLASS):
1128
+ with Horizontal(id=TASK_DETAIL_POPUP_HEADER, classes=POPUP_HEADER_CLASS):
1129
+ yield Label(
1130
+ self._heading(),
1131
+ id=TASK_DETAIL_POPUP_TITLE,
1132
+ classes=POPUP_TITLE_CLASS,
1133
+ )
1134
+ yield Button(
1135
+ popup_close_label(),
1136
+ id=TASK_DETAIL_POPUP_CLOSE,
1137
+ classes=POPUP_CLOSE_CLASS,
1138
+ )
1139
+ yield Rule(classes=POPUP_RULE_CLASS)
1140
+ yield TaskDetailPanel(
1141
+ self._widget_context,
1142
+ self._task_id,
1143
+ fixed=False,
1144
+ close_detail=self._close,
1145
+ initial_view=self._initial_view,
1146
+ on_initial_content=self.mark_popup_content_ready,
1147
+ )
1148
+
1149
+ def on_mount(self) -> None:
1150
+ self._apply_layout(self.size.width, self.size.height)
1151
+ panel = self.query_one("#task-detail-panel", TaskDetailPanel)
1152
+ if self._initial_view == TASK_VIEW_SUMMARY:
1153
+ panel.focus_summary()
1154
+ else:
1155
+ panel.open_view(self._initial_view)
1156
+
1157
+ def on_resize(self, event: events.Resize) -> None:
1158
+ self._apply_layout(event.size.width, event.size.height)
1159
+ self.call_after_refresh(
1160
+ self.query_one(f"#{TASK_DETAIL_POPUP_FRAME}", PopupFrame).update_preferred_size
1161
+ )
1162
+
1163
+ def _apply_layout(self, width: int, height: int) -> None:
1164
+ frame = self.query_one(f"#{TASK_DETAIL_POPUP_FRAME}", PopupFrame)
1165
+ compact = (
1166
+ width <= _TASK_DETAIL_COMPACT_MAX_COLUMNS or height <= _TASK_DETAIL_COMPACT_MAX_ROWS
1167
+ )
1168
+ frame.set_class(compact, "compact")
1169
+ frame.set_class(height <= TASK_DETAIL_TINY_MAX_ROWS, "tiny")
1170
+
1171
+ def mark_popup_content_ready(self) -> None:
1172
+ """Mark meaningful task content ready for popup sizing."""
1173
+ if not self.is_mounted or not widget_is_attached(self):
1174
+ return
1175
+ self.query_one(f"#{TASK_DETAIL_POPUP_FRAME}", PopupFrame).content_ready()
1176
+
1177
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
1178
+ if event.button.id == TASK_DETAIL_POPUP_CLOSE:
1179
+ self.dismiss()
1180
+
1181
+ def _heading(self) -> str:
1182
+ return f"Task details · {self._task_id}"
1183
+
1184
+ async def _close(self) -> None:
1185
+ self.dismiss()
1186
+
1187
+ async def action_close(self) -> None:
1188
+ self.dismiss()
1189
+
1190
+ async def action_open_palette(self) -> None:
1191
+ await self.query_one("#task-detail-panel", TaskDetailPanel).open_palette()
1192
+
1193
+ async def action_open_help(self) -> None:
1194
+ await cast("DashboardApp", self.app).action_show_help()
1195
+
1196
+
1197
+ type CloseDetail = Callable[[], Awaitable[None]]
1198
+
1199
+
1200
+ def _task_identity_text(identity: str) -> Text:
1201
+ return Text(identity, style=BOLD_PRIMARY)
1202
+
1203
+
1204
+ def _has_expired_quota_reading(evidence: BlockerEvidenceView | None, now: datetime) -> bool:
1205
+ return evidence is not None and any(
1206
+ reading.resets_at is not None and reading.resets_at <= now
1207
+ for quota in evidence.quotas
1208
+ for reading in quota.readings
1209
+ )