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,1765 @@
1
+ """The `dash --home | --task T-n` Textual workspace shell."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from dataclasses import dataclass
8
+ from datetime import UTC, datetime
9
+ from typing import TYPE_CHECKING, ClassVar, Protocol, cast
10
+
11
+ from rich.text import Text
12
+ from textual.containers import Grid, Horizontal, Vertical, VerticalScroll
13
+ from textual.screen import ModalScreen
14
+ from textual.widget import Widget
15
+ from textual.widgets import Button, Label, ListView, Rule, Select, Static
16
+
17
+ from shikiru.agents.registry import load_unique_entry_point
18
+ from shikiru.dashboard.contract import (
19
+ FOCUS_FAULTS_VIEW,
20
+ FOCUS_PROJECT_DIAGNOSTICS_VIEW,
21
+ WIDGET_ENTRY_POINT_GROUP,
22
+ CommandFeedbackEntryView,
23
+ CommandFeedbackStatus,
24
+ CommandHistoryView,
25
+ ExtensionEntry,
26
+ PaletteDraft,
27
+ PaletteRequest,
28
+ PaletteReturn,
29
+ ProjectRouteChoice,
30
+ TaskBrowserFilter,
31
+ TaskPaletteRoute,
32
+ WidgetContext,
33
+ WidgetPlacement,
34
+ )
35
+ from shikiru.dashboard.covered import CoveredScreens
36
+ from shikiru.dashboard.detail import (
37
+ COMPACT_INSPECTOR_CLASS,
38
+ TASK_DETAIL_ESCAPE,
39
+ TASK_DETAIL_HELP_KEY,
40
+ TASK_DETAIL_VIEWS_KEY,
41
+ TASK_VIEW_EVIDENCE,
42
+ TASK_VIEW_SUMMARY,
43
+ TASK_VIEW_USAGE,
44
+ TaskDetailPanel,
45
+ TaskDetailScreen,
46
+ TaskDetailVerb,
47
+ )
48
+ from shikiru.dashboard.detail_fold import FoldHeader
49
+ from shikiru.dashboard.diagnostics import ProjectDiagnosticsPanel
50
+ from shikiru.dashboard.help_keys import HelpAvailability, HelpTab
51
+ from shikiru.dashboard.help_screen import HelpContext, WorkspaceHelpScreen
52
+ from shikiru.dashboard.interaction import native_editor_has_focus
53
+ from shikiru.dashboard.palette_drafts import PaletteDrafts
54
+ from shikiru.dashboard.row_list import RowList
55
+ from shikiru.dashboard.tick import pinned_tick
56
+ from shikiru.dashboard.timeline_filter import TimelineFilterBar
57
+ from shikiru.dashboard.timeline_section import TimelineSection
58
+ from shikiru.domain.commands import command_rejection_cause
59
+ from shikiru.errors import ConfigError
60
+ from shikiru.ui.compact import ACTION_HINT, FocusAttentionStatus
61
+ from shikiru.ui.keys import key_hint
62
+ from shikiru.ui.popup import (
63
+ POPUP_BODY_CLASS,
64
+ POPUP_CLOSE_CLASS,
65
+ POPUP_COMPACT_CONTROL_CSS,
66
+ POPUP_CSS,
67
+ POPUP_FRAME_CLASS,
68
+ POPUP_HEADER_CLASS,
69
+ POPUP_ROW_CLASS,
70
+ POPUP_RULE_CLASS,
71
+ POPUP_SECONDARY_CLASS,
72
+ POPUP_TITLE_CLASS,
73
+ PopupFrame,
74
+ popup_close_label,
75
+ prepare_popup,
76
+ )
77
+ from shikiru.ui.screen import (
78
+ TabStopScreen,
79
+ widget_is_attached,
80
+ widget_is_current,
81
+ )
82
+ from shikiru.ui.section import BOLD_STEEL, GROUP_SEPARATOR, grey, row_name
83
+ from shikiru.ui.theme import install_theme
84
+ from shikiru.ui.tokens import (
85
+ AMBER,
86
+ DUSTY_ROSE,
87
+ PRIMARY_TEXT_COLOR,
88
+ SAGE,
89
+ SECONDARY_TEXT_COLOR,
90
+ )
91
+
92
+ if TYPE_CHECKING:
93
+ from collections.abc import Awaitable, Callable, Sequence
94
+
95
+ from textual import events
96
+ from textual.app import ComposeResult
97
+ from textual.binding import BindingType
98
+ from textual.screen import Screen
99
+ from textual.worker import Worker
100
+
101
+ from shikiru.clock import Clock
102
+ from shikiru.dashboard.contract import (
103
+ AttentionView,
104
+ DashboardViews,
105
+ DashboardWidget,
106
+ NavigateHome,
107
+ NavigateTaskWindow,
108
+ OnChange,
109
+ OnChangeRegistration,
110
+ OpenPalette,
111
+ OpenPaletteContext,
112
+ ResolutionScreenFactory,
113
+ SettingsSource,
114
+ )
115
+
116
+ _WIDGET_PLUGIN_KIND = "dashboard widget plugin"
117
+ _WIDGET_DIALOGUE = "dialogue"
118
+ _WIDGET_ESCALATIONS = "escalations"
119
+ _WIDGET_QUEUE = "queue"
120
+ _TASK_VIEW_OMITTED_PLACEMENTS = frozenset(
121
+ {
122
+ WidgetPlacement.IDENTITY,
123
+ WidgetPlacement.TASK_LIST,
124
+ WidgetPlacement.ATTENTION,
125
+ WidgetPlacement.RESOURCES,
126
+ WidgetPlacement.WORKFLOW,
127
+ WidgetPlacement.DIAGNOSTICS,
128
+ }
129
+ )
130
+ _COMMAND_HISTORY_CLOSE = "command-history-close"
131
+ _COMMAND_HISTORY_COPY = "command-history-copy"
132
+ _COMMAND_HISTORY_WORKER = "command-history-updates"
133
+ _COMMAND_HISTORY_SCROLL = "command-history-scroll"
134
+ _COMMAND_HISTORY_FILTER = "command-history-filter"
135
+ _COMMAND_HISTORY_FILTER_ROW = "command-history-filter-row"
136
+ _COMMAND_HISTORY_FILTER_LABEL = "command-history-filter-label"
137
+ _COMMAND_HISTORY_PREVIOUS = "command-history-previous"
138
+ _COMMAND_HISTORY_NEXT = "command-history-next"
139
+ _COMMAND_HISTORY_PAGE = "command-history-page"
140
+ _COMMAND_HISTORY_HEADER = "command-history-header"
141
+ _COMMAND_HISTORY_PAGE_SIZE = 20
142
+ _COMMAND_HISTORY_COMPACT_MAX_COLUMNS = 32
143
+ _COMMAND_HISTORY_PAGE_COMPACT_MAX_COLUMNS = 48
144
+ _COMMAND_HISTORY_TINY_MAX_ROWS = 8
145
+ _COMMAND_HISTORY_ALL = "all"
146
+ _COMMAND_HISTORY_PREVIOUS_KEY = "left"
147
+ _COMMAND_HISTORY_NEXT_KEY = "right"
148
+ _COMMAND_HISTORY_PREVIOUS_ACTION = "previous_page"
149
+ _COMMAND_HISTORY_NEXT_ACTION = "next_page"
150
+ COMMAND_ACTION_SAY = "Say"
151
+ COMMAND_RESULT_TASK_ID = "task_id"
152
+ COMMAND_RESULT_STATUS = "status"
153
+ COMMAND_RESULT_EFFECT = "effect"
154
+ COMMAND_RESULT_STOPPING = "stopping"
155
+ COMMAND_RESULT_PRIORITY = "priority"
156
+ COMMAND_RESULT_PAUSED = "paused"
157
+ COMMAND_RESULT_NOW = "now"
158
+ _SEARCH_KEY = "slash"
159
+ _FILTER_KEY = "f"
160
+ _MORE_KEY = "m"
161
+ _QUEUE_KEY_ACTIONS = {
162
+ _SEARCH_KEY: "open_search",
163
+ _FILTER_KEY: "cycle_status_filter",
164
+ _MORE_KEY: "request_more",
165
+ }
166
+ _QUEUE_KEY_OWNERS = {_FILTER_KEY: (TimelineSection,)}
167
+ _ACTIONS_KEY = "ctrl+p"
168
+ _HOME_FOOTER_CLEAR = "focus-footer-clear"
169
+ _HOME_FOOTER_ACTIONS = "focus-footer-actions"
170
+ _RESOURCES_RULE = "resources-rule"
171
+ _HOME_FOOTER_HELP = "focus-footer-help"
172
+ _HOME_FOOTER_DETACH = "focus-footer-detach"
173
+ _HOME_FOOTER_COMMAND_STATUS = "workspace-command-status"
174
+ _CONTEXT_WIDGET_HEADER = "context-widget-header"
175
+ _PROJECT_USAGE_TITLE = "Project usage"
176
+ _CONTEXT_WIDGET_CLOSE = "context-widget-close"
177
+ _CONTEXT_WIDGET_COMPACT_MAX_ROWS = 20
178
+ _CONTEXT_WIDGET_COMPACT_MAX_COLUMNS = 48
179
+ _CONTEXT_WIDGET_FRAME_VERTICAL_MARGIN_ROWS = 2
180
+ _DASHBOARD_PALETTE_EVENT_KEY = "f12"
181
+ _SUMMARY_ATTENTION = "attention"
182
+ _SUMMARY_RUNNING = "running"
183
+ _SUMMARY_WAITING = "waiting"
184
+ HOME_SPLIT_MIN_COLUMNS = 73
185
+ FOCUS_FOOTER_GROUP_MIN_COLUMNS = 48
186
+ FOCUS_FOOTER_STATUS_WIDE_LABEL_MIN_COLUMNS = 54
187
+ FOCUS_FOOTER_COMPACT_CONTROL_COLUMN_WIDTH = 13
188
+ FOCUS_FOOTER_COMPACT_WRAP_ROWS = 1
189
+ FOCUS_FOOTER_STACKED_COLUMN_COUNT = 1
190
+ FOCUS_FOOTER_GROUP_COUNT = 2
191
+ FOCUS_FOOTER_ACTION_COUNT = 5
192
+ FOCUS_SUMMARY_MAX_ROWS = 12
193
+ FOCUS_MAIN_MIN_ROWS = 3
194
+ FOCUS_COMPACT_MAIN_ROWS = 8
195
+ FOCUS_COMPACT_TASK_LIST_ROWS = 6
196
+ FOCUS_MIN_TASK_LIST_ROWS = 12
197
+ FOCUS_TASK_LIST_RECENT_RESERVE_ROWS = 3
198
+ FOCUS_FRAME_BORDER_ROWS = 2
199
+ FOCUS_FOOTER_MIN_HEIGHT = 9
200
+ FOCUS_ATTENTION_HEIGHT = 3
201
+ FOCUS_SHORT_HOME_HEIGHT = 14
202
+ FOCUS_COMPACT_SHORT_HOME_HEIGHT = 20
203
+ FOCUS_COMPACT_SHORT_MAIN_ROWS = 7
204
+ FOCUS_COMMAND_HISTORY = "commands"
205
+ FOCUS_USAGE_DETAILS = "usage"
206
+ FOCUS_WIDGET_PREFIX = "widget-"
207
+ _CONTEXT_WIDGET_PLACEMENTS = frozenset(
208
+ {WidgetPlacement.DETAILED_USAGE, WidgetPlacement.DIAGNOSTICS, WidgetPlacement.EXTENSIONS}
209
+ )
210
+ TASK_PALETTE_ROUTE_WORKER = "task-palette-route"
211
+ _TASK_MOVE_STEPS = {"up": -1, "down": 1}
212
+ _ARROW_KEY_OWNERS = (ListView, RowList, FoldHeader, TaskDetailVerb, TimelineFilterBar)
213
+
214
+
215
+ @dataclass
216
+ class _MotionCallback:
217
+ callback: OnChange
218
+ task: asyncio.Future[None] | None = None
219
+
220
+ @property
221
+ def in_flight(self) -> bool:
222
+ return self.task is not None and not self.task.done()
223
+
224
+ def cancel(self) -> None:
225
+ if self.task is not None and not self.task.done():
226
+ self.task.cancel()
227
+
228
+
229
+ class _TaskBrowserNavigation(Protocol):
230
+ async def activate_filter(self, task_filter: TaskBrowserFilter) -> None: ...
231
+
232
+ async def move_selection(self, step: int) -> None: ...
233
+
234
+ def focus_task_results(self) -> None: ...
235
+
236
+ def refresh_after_resume(self) -> None: ...
237
+
238
+ def clear_selection(self) -> None: ...
239
+
240
+ def sync_context_selection(self, task_id: str | None) -> None: ...
241
+
242
+
243
+ class _AttentionPanelNavigation(Protocol):
244
+ def activate_attention(self) -> None: ...
245
+
246
+ async def activate_selected_decision(self) -> bool: ...
247
+
248
+ def follow_task(self, task_id: str | None) -> None: ...
249
+
250
+
251
+ class WidgetPluginError(ConfigError):
252
+ """A configured dashboard widget name has no matching, loadable entry point."""
253
+
254
+
255
+ def _load_widget(name: str) -> DashboardWidget:
256
+ loaded = load_unique_entry_point(
257
+ WIDGET_ENTRY_POINT_GROUP, name, WidgetPluginError, _WIDGET_PLUGIN_KIND
258
+ )
259
+ widget_class = cast("type[DashboardWidget]", loaded)
260
+ try:
261
+ plugin = widget_class()
262
+ except Exception as error:
263
+ message = f"configured dashboard widget {name!r} failed to initialize: {error}"
264
+ raise WidgetPluginError(message) from error
265
+ if not callable(getattr(plugin, "create", None)):
266
+ message = f"configured dashboard widget {name!r} has no create(context) entry point"
267
+ raise WidgetPluginError(message)
268
+ return plugin
269
+
270
+
271
+ def _placement_for_widget(name: str, plugin: DashboardWidget) -> WidgetPlacement:
272
+ configured = getattr(plugin, "placement", None)
273
+ if configured is None:
274
+ return WidgetPlacement.EXTENSIONS
275
+ try:
276
+ return WidgetPlacement(configured)
277
+ except (TypeError, ValueError) as error:
278
+ message = (
279
+ f"configured dashboard widget {name!r} has an unsupported placement {configured!r}"
280
+ )
281
+ raise WidgetPluginError(message) from error
282
+
283
+
284
+ def _extension_name(name: str) -> str:
285
+ return name.replace("_", " ").capitalize()
286
+
287
+
288
+ def _contextual_widget_label(name: str, placement: WidgetPlacement) -> str:
289
+ category = {
290
+ WidgetPlacement.DETAILED_USAGE: "Usage",
291
+ WidgetPlacement.EXTENSIONS: "Extensions",
292
+ }.get(placement)
293
+ label = name.replace("_", " ")
294
+ return f"{category} · {label}" if category is not None else label.capitalize()
295
+
296
+
297
+ @dataclass(frozen=True)
298
+ class _ContextChoice:
299
+ choice_id: str
300
+ label: str
301
+ title: str | None = None
302
+ widget_name: str | None = None
303
+ placement: WidgetPlacement | None = None
304
+ command_history: bool = False
305
+
306
+
307
+ class _ContextWidgetScreen(ModalScreen[None]):
308
+ BINDINGS: ClassVar[list[BindingType]] = [("escape", "close", "Back")]
309
+ DEFAULT_CSS = (
310
+ POPUP_CSS
311
+ + """
312
+ #context-widget-frame.compact { margin: 0 2; padding: 0 2; }
313
+ #context-widget-content { max-height: 1fr; min-height: 0; }
314
+ """
315
+ )
316
+
317
+ def __init__(self, label: str, widget: Widget) -> None:
318
+ super().__init__()
319
+ prepare_popup(self)
320
+ self._label = label
321
+ self._widget = widget
322
+
323
+ def compose(self) -> ComposeResult:
324
+ with PopupFrame(widget_id="context-widget-frame", classes=POPUP_FRAME_CLASS):
325
+ with Horizontal(id=_CONTEXT_WIDGET_HEADER, classes=POPUP_HEADER_CLASS):
326
+ yield Label(self._label, id="context-widget-title", classes=POPUP_TITLE_CLASS)
327
+ yield Button(
328
+ popup_close_label(), id=_CONTEXT_WIDGET_CLOSE, classes=POPUP_CLOSE_CLASS
329
+ )
330
+ yield Rule(classes=POPUP_RULE_CLASS)
331
+ with VerticalScroll(id="context-widget-content", classes=POPUP_BODY_CLASS):
332
+ yield self._widget
333
+
334
+ def on_mount(self) -> None:
335
+ self._apply_compact_layout(self.size.width, self.size.height)
336
+ self.set_focus(self.query_one("#context-widget-content", VerticalScroll))
337
+ self.call_after_refresh(self.query_one("#context-widget-frame", PopupFrame).content_ready)
338
+
339
+ def on_resize(self, event: events.Resize) -> None:
340
+ self._apply_compact_layout(event.size.width, event.size.height)
341
+ self.call_after_refresh(
342
+ self.query_one("#context-widget-frame", PopupFrame).update_preferred_size
343
+ )
344
+
345
+ def _apply_compact_layout(self, width: int, height: int) -> None:
346
+ frame = self.query_one("#context-widget-frame")
347
+ compact = (
348
+ height <= _CONTEXT_WIDGET_COMPACT_MAX_ROWS
349
+ or width <= _CONTEXT_WIDGET_COMPACT_MAX_COLUMNS
350
+ )
351
+ frame.set_class(compact, "compact")
352
+ frame.styles.max_height = max(
353
+ 1,
354
+ height - (0 if compact else _CONTEXT_WIDGET_FRAME_VERTICAL_MARGIN_ROWS),
355
+ )
356
+
357
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
358
+ if event.button.id == _CONTEXT_WIDGET_CLOSE:
359
+ self.dismiss()
360
+
361
+ async def action_close(self) -> None:
362
+ self.dismiss()
363
+
364
+
365
+ _COMMAND_STATUS_STYLES = {
366
+ CommandFeedbackStatus.PENDING: f"{AMBER} bold",
367
+ CommandFeedbackStatus.APPLIED: f"{SAGE} bold",
368
+ CommandFeedbackStatus.REJECTED: f"{DUSTY_ROSE} bold",
369
+ }
370
+
371
+
372
+ def _command_history_text(history: CommandHistoryView) -> Text:
373
+ if not history.entries:
374
+ return Text("No commands recorded.")
375
+ return Text("\n").join(
376
+ _command_entry_text(entry, engine_up=history.engine_up) for entry in history.entries
377
+ )
378
+
379
+
380
+ def _command_history_page_label(arrow: str, word: str, *, compact: bool = False) -> Text:
381
+ if compact:
382
+ return Text(arrow, style=BOLD_STEEL)
383
+ return key_hint(f"{arrow} {word}")
384
+
385
+
386
+ def _command_queue_time(timestamp: str | None) -> str | None:
387
+ if timestamp is None:
388
+ return None
389
+ queued_at = datetime.fromisoformat(timestamp).astimezone(UTC)
390
+ return f"{queued_at.month}/{queued_at.day} {queued_at:%H:%M}Z"
391
+
392
+
393
+ def _command_entry_text(entry: CommandFeedbackEntryView, *, engine_up: bool) -> Text:
394
+ result = _command_result_data(entry)
395
+ if entry.status is CommandFeedbackStatus.PENDING:
396
+ engine_state = "running" if engine_up else "stopped"
397
+ queued_at = _command_queue_time(entry.queued_at)
398
+ queued = f"queued {queued_at}; " if queued_at is not None else ""
399
+ detail = (
400
+ f"{queued}engine currently {engine_state}; "
401
+ "original acknowledgement wait context unknown"
402
+ )
403
+ elif entry.status is CommandFeedbackStatus.APPLIED:
404
+ detail = _command_applied_effect(entry, result)
405
+ else:
406
+ detail = command_rejection_cause(result)
407
+ return Text.assemble(
408
+ grey(f"#{entry.command_id}"),
409
+ grey(GROUP_SEPARATOR),
410
+ Text(entry.status.value, style=_COMMAND_STATUS_STYLES[entry.status]),
411
+ grey(GROUP_SEPARATOR),
412
+ Text(entry.action, style=PRIMARY_TEXT_COLOR),
413
+ grey(GROUP_SEPARATOR),
414
+ row_name(entry.target),
415
+ grey(GROUP_SEPARATOR),
416
+ grey(detail),
417
+ )
418
+
419
+
420
+ def _command_result_data(entry: CommandFeedbackEntryView) -> dict[str, object] | None:
421
+ if entry.result is None:
422
+ return None
423
+ return cast("dict[str, object]", json.loads(entry.result))
424
+
425
+
426
+ def _command_applied_effect(
427
+ entry: CommandFeedbackEntryView,
428
+ result: dict[str, object] | None,
429
+ ) -> str:
430
+ if entry.action == COMMAND_ACTION_SAY:
431
+ return "guidance queued for the selected role's next turn"
432
+ if result is None:
433
+ return "engine applied the command effect"
434
+ effect = result.get(COMMAND_RESULT_EFFECT)
435
+ if isinstance(effect, str):
436
+ return effect
437
+ if result.get(COMMAND_RESULT_STOPPING) is True:
438
+ return "shutdown requested"
439
+ task_id = result.get(COMMAND_RESULT_TASK_ID)
440
+ if not isinstance(task_id, str):
441
+ return "engine applied the command effect"
442
+ return (
443
+ _task_command_effect(result, task_id, entry.target) or "engine applied the command effect"
444
+ )
445
+
446
+
447
+ def _task_command_effect(result: dict[str, object], task_id: str, target: str) -> str | None:
448
+ task_prefix = "" if task_id == target else f"{task_id} "
449
+ status = result.get(COMMAND_RESULT_STATUS)
450
+ if isinstance(status, str):
451
+ return f"{task_prefix}{status}"
452
+ priority = result.get(COMMAND_RESULT_PRIORITY)
453
+ if isinstance(priority, int) and not isinstance(priority, bool):
454
+ return f"{task_prefix}priority {priority}"
455
+ paused = result.get(COMMAND_RESULT_PAUSED)
456
+ if paused is True:
457
+ suffix = " now" if result.get(COMMAND_RESULT_NOW) is True else ""
458
+ return f"{task_prefix}paused{suffix}"
459
+ if paused is False:
460
+ return f"{task_prefix}resumed"
461
+ return None
462
+
463
+
464
+ def _command_summary_text(history: CommandHistoryView) -> Text:
465
+ entry = history.entries[0]
466
+ summary = Text()
467
+ summary.append(entry.status.value, style=_COMMAND_STATUS_STYLES[entry.status])
468
+ detail = f"{entry.action} {entry.target}"
469
+ if entry.status is CommandFeedbackStatus.PENDING:
470
+ queued_at = _command_queue_time(entry.queued_at)
471
+ detail = f"{detail}: queued {queued_at}" if queued_at is not None else detail
472
+ elif entry.status is CommandFeedbackStatus.APPLIED:
473
+ result = _command_result_data(entry)
474
+ detail = f"{detail}: {_command_applied_effect(entry, result)}"
475
+ else:
476
+ result = _command_result_data(entry)
477
+ detail = f"{detail}: {command_rejection_cause(result)}"
478
+ summary.append(f": {detail}", style=SECONDARY_TEXT_COLOR)
479
+ return summary
480
+
481
+
482
+ def _truncate_command_summary(summary: Text, width: int) -> Text:
483
+ truncated = summary.copy()
484
+ truncated.truncate(max(0, width), overflow="ellipsis")
485
+ return truncated
486
+
487
+
488
+ class _CommandHistoryScreen(ModalScreen[None]):
489
+ BINDINGS: ClassVar[list[BindingType]] = [
490
+ ("escape", "close", "Back"),
491
+ (_COMMAND_HISTORY_PREVIOUS_KEY, _COMMAND_HISTORY_PREVIOUS_ACTION, "Newer history"),
492
+ (_COMMAND_HISTORY_NEXT_KEY, _COMMAND_HISTORY_NEXT_ACTION, "Older history"),
493
+ ]
494
+ DEFAULT_CSS = (
495
+ POPUP_CSS
496
+ + POPUP_COMPACT_CONTROL_CSS
497
+ + """
498
+ #command-history-filter-row { width: 100%; }
499
+ #command-history-scroll { min-height: 0; }
500
+ #command-history-copy { height: auto; padding: 0; }
501
+ #command-history-page { height: 1; }
502
+ #command-history-controls { height: 1; width: 100%; }
503
+ #command-history-controls Button {
504
+ width: auto; min-width: 1; height: 1; margin: 0; padding: 0; border: none;
505
+ }
506
+ #command-history-frame.compact { margin: 0 1; padding: 0 1; }
507
+ #command-history-frame.tiny #command-history-filter-row,
508
+ #command-history-frame.tiny #command-history-page { display: none; }
509
+ """
510
+ )
511
+
512
+ def __init__(self, views: DashboardViews, on_change: OnChangeRegistration) -> None:
513
+ super().__init__()
514
+ prepare_popup(self)
515
+ self._views = views
516
+ self._on_change = on_change
517
+ self._offset = 0
518
+ self._status: CommandFeedbackStatus | None = None
519
+ self._has_more = False
520
+ self._change_worker: Worker[None] | None = None
521
+
522
+ def compose(self) -> ComposeResult:
523
+ with PopupFrame(widget_id="command-history-frame", classes=POPUP_FRAME_CLASS):
524
+ with Horizontal(id=_COMMAND_HISTORY_HEADER, classes=POPUP_HEADER_CLASS):
525
+ yield Label(
526
+ "Command history",
527
+ id="command-history-title",
528
+ classes=POPUP_TITLE_CLASS,
529
+ )
530
+ yield Button(
531
+ popup_close_label(), id=_COMMAND_HISTORY_CLOSE, classes=POPUP_CLOSE_CLASS
532
+ )
533
+ yield Rule(classes=POPUP_RULE_CLASS)
534
+ with Horizontal(id=_COMMAND_HISTORY_FILTER_ROW, classes=POPUP_ROW_CLASS):
535
+ yield Label("Status", id=_COMMAND_HISTORY_FILTER_LABEL)
536
+ yield Select(
537
+ (
538
+ ("All statuses", _COMMAND_HISTORY_ALL),
539
+ *((status.value, status.value) for status in CommandFeedbackStatus),
540
+ ),
541
+ value=_COMMAND_HISTORY_ALL,
542
+ allow_blank=False,
543
+ id=_COMMAND_HISTORY_FILTER,
544
+ )
545
+ with VerticalScroll(id=_COMMAND_HISTORY_SCROLL, classes=POPUP_BODY_CLASS):
546
+ yield Static("Loading command history…", id=_COMMAND_HISTORY_COPY, markup=False)
547
+ yield Label("", id=_COMMAND_HISTORY_PAGE, classes=POPUP_SECONDARY_CLASS)
548
+ with Horizontal(id="command-history-controls"):
549
+ yield Button(
550
+ _command_history_page_label("←", "newer"),
551
+ id=_COMMAND_HISTORY_PREVIOUS,
552
+ disabled=True,
553
+ )
554
+ yield Button(
555
+ _command_history_page_label("→", "older"),
556
+ id=_COMMAND_HISTORY_NEXT,
557
+ disabled=True,
558
+ )
559
+
560
+ async def on_mount(self) -> None:
561
+ self._apply_compact_layout(self.size.width, self.size.height)
562
+ self.set_focus(cast("Select[str]", self.query_one(f"#{_COMMAND_HISTORY_FILTER}", Select)))
563
+ self._display_history()
564
+ self.call_after_refresh(self._limit_history_body, self.size.height)
565
+ self.call_after_refresh(self.query_one("#command-history-frame", PopupFrame).content_ready)
566
+ self._change_worker = self.run_worker(
567
+ self._on_change(self._refresh), group=_COMMAND_HISTORY_WORKER
568
+ )
569
+
570
+ def on_resize(self, event: events.Resize) -> None:
571
+ self._apply_compact_layout(event.size.width, event.size.height)
572
+ self.call_after_refresh(self._limit_history_body, event.size.height)
573
+ self.call_after_refresh(
574
+ self.query_one("#command-history-frame", PopupFrame).update_preferred_size
575
+ )
576
+
577
+ def _apply_compact_layout(self, width: int, height: int) -> None:
578
+ frame = self.query_one("#command-history-frame", PopupFrame)
579
+ compact = width <= _COMMAND_HISTORY_COMPACT_MAX_COLUMNS
580
+ tiny = height <= _COMMAND_HISTORY_TINY_MAX_ROWS
581
+ frame.set_class(compact or tiny, "compact")
582
+ frame.set_class(tiny, "tiny")
583
+ self.query_one("#command-history-title", Label).update(
584
+ "Hist." if compact or tiny else "Command history"
585
+ )
586
+ self.query_one(f"#{_COMMAND_HISTORY_PREVIOUS}", Button).label = _command_history_page_label(
587
+ "←", "newer", compact=compact
588
+ )
589
+ self.query_one(f"#{_COMMAND_HISTORY_NEXT}", Button).label = _command_history_page_label(
590
+ "→", "older", compact=compact
591
+ )
592
+
593
+ def _limit_history_body(self, viewport_height: int) -> None:
594
+ if not self.is_mounted:
595
+ return
596
+ frame = self.query_one("#command-history-frame")
597
+ body = self.query_one("#command-history-scroll", VerticalScroll)
598
+ fixed_height = sum(child.region.height for child in frame.children if child is not body)
599
+ body.styles.max_height = max(
600
+ 0,
601
+ viewport_height
602
+ - frame.styles.margin.height
603
+ - frame.styles.gutter.height
604
+ - fixed_height,
605
+ )
606
+
607
+ def on_unmount(self) -> None:
608
+ if self._change_worker is not None:
609
+ self._change_worker.cancel()
610
+
611
+ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
612
+ del parameters
613
+ pages = action in {_COMMAND_HISTORY_PREVIOUS_ACTION, _COMMAND_HISTORY_NEXT_ACTION}
614
+ if pages and not self._arrow_paging_available():
615
+ return None
616
+ if action == _COMMAND_HISTORY_PREVIOUS_ACTION:
617
+ return self._offset > 0
618
+ if action == _COMMAND_HISTORY_NEXT_ACTION:
619
+ return self._has_more
620
+ return True
621
+
622
+ def _arrow_paging_available(self) -> bool:
623
+ filter_select = cast("Select[str]", self.query_one(f"#{_COMMAND_HISTORY_FILTER}", Select))
624
+ if filter_select.expanded:
625
+ return False
626
+ return self.focused is filter_select or not native_editor_has_focus(self.focused)
627
+
628
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
629
+ if event.button.id == _COMMAND_HISTORY_CLOSE:
630
+ self.dismiss()
631
+ elif event.button.id == _COMMAND_HISTORY_PREVIOUS:
632
+ self.action_previous_page()
633
+ elif event.button.id == _COMMAND_HISTORY_NEXT and self._has_more:
634
+ self.action_next_page()
635
+
636
+ async def on_select_changed(self, event: Select.Changed) -> None:
637
+ if event.select.id != _COMMAND_HISTORY_FILTER:
638
+ return
639
+ selected = cast("str", event.value)
640
+ self._status = None if selected == _COMMAND_HISTORY_ALL else CommandFeedbackStatus(selected)
641
+ self._offset = 0
642
+ self._display_history()
643
+
644
+ async def action_close(self) -> None:
645
+ self.dismiss()
646
+
647
+ def action_previous_page(self) -> None:
648
+ if self._offset == 0:
649
+ return
650
+ self._offset = max(0, self._offset - _COMMAND_HISTORY_PAGE_SIZE)
651
+ self._display_history()
652
+
653
+ def action_next_page(self) -> None:
654
+ if self._has_more:
655
+ self._offset += _COMMAND_HISTORY_PAGE_SIZE
656
+ self._display_history()
657
+
658
+ async def _refresh(self) -> None:
659
+ if widget_is_attached(self):
660
+ self._display_history(preserve_scroll=True)
661
+
662
+ def _display_history(self, *, preserve_scroll: bool = False) -> None:
663
+ scroll = self.query_one(f"#{_COMMAND_HISTORY_SCROLL}", VerticalScroll)
664
+ scroll_y = scroll.scroll_y if preserve_scroll and self.is_mounted else 0
665
+ scroll_at_bottom = preserve_scroll and scroll_y >= scroll.max_scroll_y
666
+ history = self._views.command_history(
667
+ expanded=True,
668
+ limit=_COMMAND_HISTORY_PAGE_SIZE,
669
+ offset=self._offset,
670
+ status=self._status,
671
+ )
672
+ if history.total_count and history.offset >= history.total_count:
673
+ self._offset = max(0, (history.total_count - 1) // history.limit * history.limit)
674
+ history = self._views.command_history(
675
+ expanded=True,
676
+ limit=_COMMAND_HISTORY_PAGE_SIZE,
677
+ offset=self._offset,
678
+ status=self._status,
679
+ )
680
+ elif not history.total_count:
681
+ self._offset = 0
682
+ self.query_one(f"#{_COMMAND_HISTORY_COPY}", Static).update(_command_history_text(history))
683
+ self._has_more = history.has_more
684
+ start = history.offset + 1 if history.entries else 0
685
+ end = history.offset + len(history.entries)
686
+ page_number = history.offset // history.limit + 1
687
+ filter_name = history.status.value if history.status is not None else "all statuses"
688
+ page_text = (
689
+ f"{page_number} · {end}/{history.total_count}"
690
+ if self.size.width <= _COMMAND_HISTORY_PAGE_COMPACT_MAX_COLUMNS
691
+ else f"{filter_name} · page {page_number} · rows {start}-{end} of {history.total_count}"
692
+ )
693
+ self.query_one(f"#{_COMMAND_HISTORY_PAGE}", Label).update(page_text)
694
+ self.query_one(f"#{_COMMAND_HISTORY_PREVIOUS}", Button).disabled = history.offset == 0
695
+ self.query_one(f"#{_COMMAND_HISTORY_NEXT}", Button).disabled = not history.has_more
696
+ if preserve_scroll:
697
+ self.call_after_refresh(
698
+ lambda: scroll.scroll_to(
699
+ y=scroll.max_scroll_y if scroll_at_bottom else scroll_y,
700
+ animate=False,
701
+ )
702
+ )
703
+
704
+
705
+ class _CommandStatusLine(Button):
706
+ def __init__(self, views: DashboardViews, on_change: OnChangeRegistration) -> None:
707
+ super().__init__(
708
+ "",
709
+ id=_HOME_FOOTER_COMMAND_STATUS,
710
+ tooltip="Open full command history",
711
+ flat=True,
712
+ )
713
+ self._views = views
714
+ self._on_change = on_change
715
+ self._history: CommandHistoryView | None = None
716
+ self._transient_message: str | None = None
717
+
718
+ async def on_mount(self) -> None:
719
+ self._display_history()
720
+ self.call_after_refresh(self._render_status)
721
+ self.run_worker(self._on_change(self._refresh), group=_COMMAND_HISTORY_WORKER)
722
+
723
+ def on_resize(self, event: events.Resize) -> None:
724
+ del event
725
+ self.call_after_refresh(self._render_status)
726
+
727
+ async def _refresh(self) -> None:
728
+ if widget_is_attached(self):
729
+ self._display_history()
730
+
731
+ def _display_history(self) -> None:
732
+ self._transient_message = None
733
+ self._history = self._views.command_history()
734
+ self._render_status()
735
+
736
+ def show_transient(self, message: str) -> None:
737
+ self._transient_message = message
738
+ self._render_status()
739
+
740
+ def _render_status(self) -> None:
741
+ if not widget_is_attached(self):
742
+ return
743
+ was_displayed = self.display
744
+ if self._transient_message is not None:
745
+ self.label = _truncate_command_summary(
746
+ Text(self._transient_message, style=SECONDARY_TEXT_COLOR),
747
+ self._summary_render_width(),
748
+ )
749
+ self.display = True
750
+ else:
751
+ history = self._history
752
+ self.display = bool(history and history.entries)
753
+ if history is not None and history.entries:
754
+ self.label = _truncate_command_summary(
755
+ _command_summary_text(history), self._summary_render_width()
756
+ )
757
+ if (
758
+ was_displayed != self.display
759
+ and isinstance(self.parent, _HomeFooter)
760
+ and widget_is_attached(self.parent)
761
+ ):
762
+ app = cast("DashboardApp", self.app)
763
+ self.call_after_refresh(app.refresh_layout)
764
+ self.call_after_refresh(self._render_status)
765
+
766
+ def _summary_render_width(self) -> int:
767
+ return max(0, self.content_size.width)
768
+
769
+
770
+ class _HomeAttentionStatus(Static):
771
+ def __init__(self, context: WidgetContext) -> None:
772
+ super().__init__("", id="escalations", markup=False)
773
+ self._widget_context = context
774
+ self._attention: AttentionView | None = None
775
+ self._remove_motion_callback: Callable[[], None] | None = None
776
+
777
+ async def on_mount(self) -> None:
778
+ await self._refresh()
779
+ self.run_worker(self._widget_context.on_change(self._refresh_if_attached), exclusive=True)
780
+ self._remove_motion_callback = self._widget_context.on_motion(self._refresh_if_active)
781
+
782
+ def on_unmount(self) -> None:
783
+ if self._remove_motion_callback is not None:
784
+ self._remove_motion_callback()
785
+ self._remove_motion_callback = None
786
+
787
+ async def on_click(self, event: events.Click) -> None:
788
+ attention = self._attention
789
+ if attention is None:
790
+ return
791
+ if not attention.groups:
792
+ summary = self._widget_context.views.workspace_summary()
793
+ if summary.engine_up is False and self._widget_context.open_contextual_view is not None:
794
+ await self._widget_context.open_contextual_view(FOCUS_COMMAND_HISTORY)
795
+ event.stop()
796
+ return
797
+ group = attention.groups[0]
798
+ if group.task_id is not None and self._widget_context.on_task_selected is not None:
799
+ self._widget_context.on_task_selected(group.task_id)
800
+ if group.escalations:
801
+ if self._widget_context.open_resolution is not None:
802
+ await self._widget_context.open_resolution(group.escalations[0].escalation_id)
803
+ event.stop()
804
+ elif group.task_id is not None:
805
+ if self._widget_context.on_task_selected is not None:
806
+ self._widget_context.on_task_selected(group.task_id)
807
+ event.stop()
808
+ elif self._widget_context.open_contextual_view is not None:
809
+ await self._widget_context.open_contextual_view(FOCUS_FAULTS_VIEW)
810
+ event.stop()
811
+
812
+ async def _refresh_if_active(self) -> None:
813
+ if widget_is_current(self):
814
+ await self._refresh()
815
+
816
+ async def _refresh_if_attached(self) -> None:
817
+ if widget_is_attached(self):
818
+ await self._refresh()
819
+
820
+ async def _refresh(self) -> None:
821
+ attention = self._widget_context.views.attention()
822
+ summary = self._widget_context.views.workspace_summary()
823
+ self._attention = attention
824
+ self.update(
825
+ FocusAttentionStatus(
826
+ attention,
827
+ summary,
828
+ action_hint=ACTION_HINT,
829
+ )
830
+ )
831
+
832
+
833
+ class _HomeFooter(Grid):
834
+ def __init__(
835
+ self,
836
+ tmux_detach_hint: str | None,
837
+ views: DashboardViews,
838
+ on_change: OnChangeRegistration,
839
+ ) -> None:
840
+ super().__init__(id="focus-footer")
841
+ self._tmux_detach_hint = tmux_detach_hint
842
+ self._views = views
843
+ self._on_change = on_change
844
+
845
+ def compose(self) -> ComposeResult:
846
+ yield Static(
847
+ key_hint("↑↓ select", SECONDARY_TEXT_COLOR),
848
+ markup=False,
849
+ )
850
+ yield Button(
851
+ key_hint("Esc clear", SECONDARY_TEXT_COLOR),
852
+ id=_HOME_FOOTER_CLEAR,
853
+ )
854
+ yield Button(
855
+ key_hint("Ctrl+P actions", SECONDARY_TEXT_COLOR),
856
+ id=_HOME_FOOTER_ACTIONS,
857
+ )
858
+ detach_hint = (
859
+ Text.assemble(
860
+ (self._tmux_detach_hint, BOLD_STEEL),
861
+ (" d", BOLD_STEEL),
862
+ (" detach", SECONDARY_TEXT_COLOR),
863
+ )
864
+ if self._tmux_detach_hint is not None
865
+ else Text("tmux detach unavailable", style=SECONDARY_TEXT_COLOR)
866
+ )
867
+ yield Button(
868
+ detach_hint,
869
+ id=_HOME_FOOTER_DETACH,
870
+ disabled=self._tmux_detach_hint is None,
871
+ )
872
+ yield Button(
873
+ Text.assemble(("?", BOLD_STEEL), (" help", SECONDARY_TEXT_COLOR)),
874
+ id=_HOME_FOOTER_HELP,
875
+ )
876
+ yield _CommandStatusLine(self._views, self._on_change)
877
+
878
+
879
+ class DashboardApp(CoveredScreens):
880
+ """Present the configured Home focus or one shared task detail view."""
881
+
882
+ ENABLE_COMMAND_PALETTE = False
883
+ CSS = """
884
+ Screen { color: $primary-text; }
885
+ #focus-root {
886
+ height: 100%; border: solid $neutral-border; padding: 0 1;
887
+ background: $background; scrollbar-size-vertical: 0;
888
+ }
889
+ #focus-readouts {
890
+ width: 100%; height: auto; padding: 0; background: $background;
891
+ scrollbar-size-vertical: 0;
892
+ }
893
+ #header { height: auto; margin-bottom: 0; text-wrap: wrap; text-overflow: clip; }
894
+ #usage { height: auto; margin-bottom: 0; }
895
+ #resources { height: auto; margin-bottom: 1; }
896
+ #resources-rule { margin: 0; color: $neutral-border; }
897
+ #escalations {
898
+ width: 100%; height: auto; max-height: 3; padding: 0 1; margin-bottom: 1;
899
+ background: $surface;
900
+ }
901
+ #escalations #escalation-panels { scrollbar-size-vertical: $scrollbar-width; }
902
+ #usage:focus, #resources:focus, #escalations:focus, #escalations.armed {
903
+ background: $selected-surface;
904
+ }
905
+ #focus-main { height: 1fr; min-height: 3; }
906
+ #focus-main.wide { layout: horizontal; }
907
+ #focus-main.compact { layout: vertical; overflow-y: auto; scrollbar-size-vertical: 0; }
908
+ #focus-main.wide #focus-task-list-column {
909
+ width: 42%; min-width: 0; height: 100%; padding-right: 1;
910
+ }
911
+ #focus-main.wide #queue {
912
+ width: 100%; min-width: 0; height: 100%;
913
+ }
914
+ #focus-main.wide #task-browser-active { height: 1fr; min-height: 4; }
915
+ #focus-main.wide #task-browser-list { height: 1fr; min-height: 2; }
916
+ #focus-main.wide #task-detail-panel {
917
+ width: 58%; min-width: 0; height: 100%; padding-left: 2;
918
+ border-left: solid $neutral-border;
919
+ }
920
+ #focus-main.wide #task-detail-body { height: 1fr; max-height: 1fr; }
921
+ #focus-main.compact #focus-task-list-column { width: 100%; height: auto; }
922
+ #focus-main.compact #queue { width: 100%; height: auto; }
923
+ #focus-main.compact #task-browser-list { width: 100%; height: auto; min-height: 3; }
924
+ #focus-main.compact #task-detail-panel { width: 100%; height: auto; }
925
+ #focus-main.compact #task-detail-body { max-height: 12; }
926
+ #task-view-root { height: 100%; }
927
+ #task-view-root #task-detail-panel { height: 1fr; min-height: 0; }
928
+ #task-view-root #task-detail-body { height: 1fr; min-height: 0; max-height: 1fr; }
929
+ #focus-empty { height: 1fr; padding: 1; color: $secondary-text; }
930
+ #focus-footer {
931
+ layout: grid; grid-size: 3 0; grid-columns: auto auto auto; grid-gutter: 0 1;
932
+ height: auto; min-height: 1; padding: 0; margin-top: 0; border-top: solid $neutral-border;
933
+ background: $background; color: $secondary-text;
934
+ }
935
+ #focus-footer Static { width: auto; height: auto; padding: 0 1; color: $secondary-text; }
936
+ #focus-footer Button {
937
+ width: auto; min-width: 1; height: auto; min-height: 1; margin: 0; padding: 0;
938
+ border: none; background: $background; color: $secondary-text;
939
+ content-align: left top; text-align: left; text-wrap: wrap; text-style: none;
940
+ }
941
+ #focus-footer Button:hover { color: $steel-blue; text-style: none; }
942
+ #focus-footer Button:focus { color: $primary-text; text-style: reverse; }
943
+ #workspace-command-status {
944
+ width: 100%; height: 1; min-width: 0; max-height: 1; padding: 0 1;
945
+ border: none; background: $background; color: $secondary-text;
946
+ content-align: right middle; text-align: right; text-wrap: nowrap;
947
+ }
948
+ #workspace-command-status:hover { color: $steel-blue; }
949
+ #workspace-command-status:focus { color: $primary-text; text-style: reverse; }
950
+ #focus-footer #workspace-command-status {
951
+ width: 1fr; min-width: 0; padding: 0; text-wrap: nowrap;
952
+ }
953
+ #focus-footer.grouped #workspace-command-status {
954
+ content-align: right middle; text-align: right;
955
+ }
956
+ """
957
+
958
+ def __init__(
959
+ self,
960
+ *,
961
+ focus_task: str | None,
962
+ views: DashboardViews,
963
+ on_change: OnChangeRegistration,
964
+ clock: Clock,
965
+ motion_interval_seconds: float | None,
966
+ settings: SettingsSource,
967
+ open_palette: OpenPalette,
968
+ widget_names: Sequence[str],
969
+ open_palette_context: OpenPaletteContext | None = None,
970
+ resolution_screen: ResolutionScreenFactory | None = None,
971
+ navigate_task_window: NavigateTaskWindow | None = None,
972
+ navigate_home: NavigateHome | None = None,
973
+ tmux_detach_hint: str | None = None,
974
+ detach_tmux_client: Callable[[], Awaitable[bool]] | None = None,
975
+ ) -> None:
976
+ """Build the home or task workspace with the configured widgets."""
977
+ super().__init__()
978
+ install_theme(self)
979
+ self._focus_task = focus_task
980
+ self._views = views
981
+ self._on_change = on_change
982
+ self._clock = clock
983
+ self._motion_interval_seconds = motion_interval_seconds
984
+ self._settings = settings
985
+ self._open_palette = open_palette
986
+ self._open_palette_context = open_palette_context
987
+ self._resolution_screen = resolution_screen
988
+ self._palette_drafts = PaletteDrafts()
989
+ self._selected_task_id = focus_task
990
+ self._navigate_task_window = navigate_task_window
991
+ self._navigate_home = navigate_home
992
+ self._tmux_detach_hint = tmux_detach_hint
993
+ self._detach_tmux_client = detach_tmux_client
994
+ self._configured_widget_names = tuple(widget_names)
995
+ self._widget_names = self._configured_widget_names
996
+ self._motion_callbacks: list[_MotionCallback] = []
997
+ self._home_detail_panel: TaskDetailPanel | None = None
998
+ self._widget_plugins: dict[str, DashboardWidget] = {}
999
+ self._widget_placements: dict[str, WidgetPlacement] = {}
1000
+ self._widget_names_by_placement: dict[WidgetPlacement, list[str]] = {}
1001
+ self._widgets_configured = False
1002
+ self._widget_instances: dict[str, Widget] = {}
1003
+
1004
+ def compose(self) -> ComposeResult:
1005
+ """Mount configured primary content and keep other widgets behind contextual views."""
1006
+ self._configure_widgets()
1007
+ if self._focus_task is None:
1008
+ yield from self._compose_focus()
1009
+ else:
1010
+ yield from self._compose_task_workspace()
1011
+
1012
+ def _configure_widgets(self) -> None:
1013
+ if self._widgets_configured:
1014
+ return
1015
+ for name in self._configured_widget_names:
1016
+ if name in self._widget_plugins:
1017
+ message = f"configured dashboard widget {name!r} appears more than once"
1018
+ raise WidgetPluginError(message)
1019
+ plugin = _load_widget(name)
1020
+ placement = _placement_for_widget(name, plugin)
1021
+ self._widget_plugins[name] = plugin
1022
+ self._widget_placements[name] = placement
1023
+ self._widget_names_by_placement.setdefault(placement, []).append(name)
1024
+ self._widget_names = self._configured_widget_names
1025
+ self._widgets_configured = True
1026
+
1027
+ def _compose_focus(self) -> ComposeResult:
1028
+ context = self._widget_context()
1029
+ with VerticalScroll(id="focus-root", can_focus=False):
1030
+ with VerticalScroll(id="focus-readouts", can_focus=False):
1031
+ for placement in (
1032
+ WidgetPlacement.IDENTITY,
1033
+ WidgetPlacement.PROJECT_USAGE,
1034
+ WidgetPlacement.RESOURCES,
1035
+ ):
1036
+ for name in self._widget_names_by_placement.get(placement, ()):
1037
+ if placement is WidgetPlacement.RESOURCES:
1038
+ yield Rule(id=_RESOURCES_RULE)
1039
+ widget = self._create_widget(name)
1040
+ self._widget_instances[name] = widget
1041
+ yield widget
1042
+ attention_names = self._widget_names_by_placement.get(WidgetPlacement.ATTENTION, ())
1043
+ if attention_names:
1044
+ for name in attention_names:
1045
+ widget = self._create_widget(name)
1046
+ self._widget_instances[name] = widget
1047
+ yield widget
1048
+ else:
1049
+ yield _HomeAttentionStatus(context)
1050
+ with Horizontal(id="focus-main"):
1051
+ task_list_names = self._widget_names_by_placement.get(WidgetPlacement.TASK_LIST, ())
1052
+ if task_list_names:
1053
+ with Vertical(id="focus-task-list-column"):
1054
+ for name in task_list_names:
1055
+ widget = self._create_widget(name)
1056
+ self._widget_instances[name] = widget
1057
+ yield widget
1058
+ self._home_detail_panel = TaskDetailPanel(
1059
+ context,
1060
+ self._selected_task_id,
1061
+ fixed=False,
1062
+ )
1063
+ yield self._home_detail_panel
1064
+ else:
1065
+ yield Static("Task list is not configured.", id="focus-empty", markup=False)
1066
+ yield _HomeFooter(self._tmux_detach_hint, self._views, self._on_change)
1067
+
1068
+ def _compose_task_workspace(self) -> ComposeResult:
1069
+ context = self._widget_context()
1070
+ with Vertical(id="task-view-root"):
1071
+ yield TaskDetailPanel(
1072
+ context,
1073
+ self._focus_task,
1074
+ fixed=True,
1075
+ )
1076
+ yield _CommandStatusLine(self._views, self._on_change)
1077
+
1078
+ def _create_widget(
1079
+ self,
1080
+ name: str,
1081
+ *,
1082
+ placement: WidgetPlacement | None = None,
1083
+ ) -> Widget:
1084
+ plugin = self._widget_plugins[name]
1085
+ selected_placement = placement or self._widget_placements[name]
1086
+ try:
1087
+ widget = cast("object", plugin.create(self._widget_context(selected_placement)))
1088
+ except Exception as error:
1089
+ message = f"configured dashboard widget {name!r} failed to create: {error}"
1090
+ raise WidgetPluginError(message) from error
1091
+ if not isinstance(widget, Widget):
1092
+ message = (
1093
+ f"configured dashboard widget {name!r} returned {type(widget).__name__}, "
1094
+ "expected a Textual Widget"
1095
+ )
1096
+ raise WidgetPluginError(message)
1097
+ return widget
1098
+
1099
+ def _widget_context(
1100
+ self,
1101
+ placement: WidgetPlacement = WidgetPlacement.EXTENSIONS,
1102
+ ) -> WidgetContext:
1103
+ async def open_task_detail(task_id: str) -> None:
1104
+ await self._open_task_detail(task_id)
1105
+
1106
+ async def open_task_detail_view(task_id: str, view_id: str) -> None:
1107
+ await self.open_task_detail_view(task_id, view_id)
1108
+
1109
+ return WidgetContext(
1110
+ views=self._views,
1111
+ focus_task=self._focus_task,
1112
+ settings=self._settings,
1113
+ clock=self._clock,
1114
+ on_change=self._on_change,
1115
+ on_motion=self.register_motion_callback,
1116
+ open_palette=self._open_palette,
1117
+ open_palette_context=self._open_contextual_palette,
1118
+ open_resolution=self.open_resolution,
1119
+ open_task_detail=open_task_detail,
1120
+ open_task_detail_view=open_task_detail_view,
1121
+ navigate_task_window=self._navigate_task_window,
1122
+ navigate_home=self._navigate_home,
1123
+ on_task_selected=self._select_task_context,
1124
+ activate_home_summary=self._activate_home_summary,
1125
+ activate_attention_decision=self._activate_attention_decision,
1126
+ placement=placement,
1127
+ home_attention_present=self._focus_task is None,
1128
+ open_contextual_view=self._open_contextual_view,
1129
+ )
1130
+
1131
+ async def _activate_home_summary(self, summary: str) -> None:
1132
+ if self._focus_task is not None:
1133
+ return
1134
+ if summary == _SUMMARY_ATTENTION:
1135
+ panel = self._widget_instances.get(_WIDGET_ESCALATIONS)
1136
+ if panel is None:
1137
+ self._show_navigation_feedback("Attention is not configured.")
1138
+ return
1139
+ attention_panel = cast("_AttentionPanelNavigation", panel)
1140
+ attention_panel.activate_attention()
1141
+ return
1142
+ task_filter = {
1143
+ _SUMMARY_RUNNING: TaskBrowserFilter.ACTIVE,
1144
+ _SUMMARY_WAITING: TaskBrowserFilter.WAITING,
1145
+ }.get(summary)
1146
+ if task_filter is None:
1147
+ message = f"unknown home summary {summary!r}"
1148
+ raise ValueError(message)
1149
+ browser = self._widget_instances.get(_WIDGET_QUEUE)
1150
+ if browser is None:
1151
+ self._show_navigation_feedback("Task browser is not configured.")
1152
+ return
1153
+ await cast("_TaskBrowserNavigation", browser).activate_filter(task_filter)
1154
+
1155
+ async def _activate_attention_decision(self) -> bool:
1156
+ if self._focus_task is not None:
1157
+ return False
1158
+ panel = self._widget_instances.get(_WIDGET_ESCALATIONS)
1159
+ if panel is None or not widget_is_current(panel):
1160
+ return False
1161
+ return await cast("_AttentionPanelNavigation", panel).activate_selected_decision()
1162
+
1163
+ def _show_navigation_feedback(self, message: str) -> None:
1164
+ self.query_one(f"#{_HOME_FOOTER_COMMAND_STATUS}", _CommandStatusLine).show_transient(
1165
+ message
1166
+ )
1167
+
1168
+ def _context_choices(self) -> tuple[_ContextChoice, ...]:
1169
+ choices = [
1170
+ _ContextChoice(
1171
+ FOCUS_COMMAND_HISTORY,
1172
+ "Command history · pending, applied and rejected",
1173
+ command_history=True,
1174
+ )
1175
+ ]
1176
+ for name in self._configured_widget_names:
1177
+ placement = self._widget_placements[name]
1178
+ if placement is WidgetPlacement.PROJECT_USAGE:
1179
+ choices.append(
1180
+ _ContextChoice(
1181
+ FOCUS_USAGE_DETAILS,
1182
+ "Usage details",
1183
+ title=_PROJECT_USAGE_TITLE,
1184
+ widget_name=name,
1185
+ placement=WidgetPlacement.DETAILED_USAGE,
1186
+ )
1187
+ )
1188
+ elif placement in _CONTEXT_WIDGET_PLACEMENTS:
1189
+ choices.append(
1190
+ _ContextChoice(
1191
+ f"{FOCUS_WIDGET_PREFIX}{name}",
1192
+ _contextual_widget_label(name, placement),
1193
+ widget_name=name,
1194
+ placement=placement,
1195
+ )
1196
+ )
1197
+ return tuple(choices)
1198
+
1199
+ def extension_entries(self) -> tuple[ExtensionEntry, ...]:
1200
+ """List the extension widgets the operator can open from the palette."""
1201
+ return tuple(
1202
+ ExtensionEntry(f"{FOCUS_WIDGET_PREFIX}{name}", _extension_name(name))
1203
+ for name in self._widget_names_by_placement.get(WidgetPlacement.EXTENSIONS, ())
1204
+ )
1205
+
1206
+ async def _open_contextual_view(self, choice_id: str) -> None:
1207
+ if choice_id == FOCUS_PROJECT_DIAGNOSTICS_VIEW:
1208
+ await self._show_project_diagnostics()
1209
+ return
1210
+ choice = self._find_context_choice(choice_id)
1211
+ if choice is None and choice_id == FOCUS_FAULTS_VIEW:
1212
+ self._show_navigation_feedback("Faults not configured")
1213
+ history = self._find_context_choice(FOCUS_COMMAND_HISTORY)
1214
+ if history is not None:
1215
+ await self._run_context_choice(history)
1216
+ return
1217
+ if choice is None:
1218
+ message = f"unknown contextual dashboard view {choice_id!r}"
1219
+ raise ValueError(message)
1220
+ await self._run_context_choice(choice)
1221
+
1222
+ async def _show_project_diagnostics(self) -> None:
1223
+ panel = ProjectDiagnosticsPanel(
1224
+ self._views,
1225
+ self._on_change,
1226
+ tuple(
1227
+ self._create_widget(name, placement=WidgetPlacement.DIAGNOSTICS)
1228
+ for name in self._widget_names_by_placement.get(WidgetPlacement.DIAGNOSTICS, ())
1229
+ ),
1230
+ )
1231
+ await self.push_screen(
1232
+ _ContextWidgetScreen("Project diagnostics", panel),
1233
+ callback=lambda _: self.refresh_task_detail_after_resume(),
1234
+ )
1235
+
1236
+ async def _open_contextual_palette(self, request: PaletteRequest) -> None:
1237
+ callback = self._open_palette_context
1238
+ if callback is None:
1239
+ await self._open_palette(request.action or "")
1240
+ return
1241
+ await callback(self._palette_drafts.attach(request))
1242
+
1243
+ async def open_resolution(self, escalation_id: str) -> None:
1244
+ """Open the resolution screen for one escalation."""
1245
+ factory = self._resolution_screen
1246
+ if factory is None:
1247
+ message = "the escalation resolution screen is not configured"
1248
+ raise ConfigError(message)
1249
+ await self.push_screen(
1250
+ factory(escalation_id),
1251
+ callback=lambda _: self.refresh_task_detail_after_resume(),
1252
+ )
1253
+
1254
+ def remember_contextual_palette_draft(self, draft: PaletteDraft) -> None:
1255
+ """Keep the latest contextual form draft in memory while its caller is visible."""
1256
+ self._palette_drafts.remember(draft)
1257
+
1258
+ def attach_palette_drafts(self, request: PaletteRequest) -> PaletteRequest:
1259
+ """Return `request` carrying the drafts remembered for its task."""
1260
+ return self._palette_drafts.attach(request)
1261
+
1262
+ def route_task_palette(self, task_id: str, route: TaskPaletteRoute) -> None:
1263
+ """Open a task detail destination selected from its contextual action surface."""
1264
+ self.run_worker(
1265
+ self._open_task_palette_route(task_id, route),
1266
+ group=TASK_PALETTE_ROUTE_WORKER,
1267
+ )
1268
+
1269
+ def palette_return_callback(self) -> Callable[[PaletteReturn | None], None]:
1270
+ """Return from the Actions list to its task context and optional secondary route."""
1271
+
1272
+ def on_return(choice: PaletteReturn | None) -> None:
1273
+ self.refresh_task_detail_after_resume()
1274
+ if isinstance(choice, ExtensionEntry):
1275
+ self.run_worker(
1276
+ self._open_contextual_view(choice.choice_id),
1277
+ group=TASK_PALETTE_ROUTE_WORKER,
1278
+ )
1279
+ elif isinstance(choice, ProjectRouteChoice):
1280
+ self.run_worker(
1281
+ self._open_project_palette_route(choice.route),
1282
+ group=TASK_PALETTE_ROUTE_WORKER,
1283
+ )
1284
+ elif choice is not None:
1285
+ self.route_task_palette(choice.task_id, choice.route)
1286
+
1287
+ return on_return
1288
+
1289
+ async def _open_project_palette_route(self, route: TaskPaletteRoute) -> None:
1290
+ if route is TaskPaletteRoute.COMMANDS:
1291
+ await self._open_contextual_view(FOCUS_COMMAND_HISTORY)
1292
+ elif route is TaskPaletteRoute.DIAGNOSTICS:
1293
+ await self._open_contextual_view(FOCUS_PROJECT_DIAGNOSTICS_VIEW)
1294
+ else:
1295
+ message = f"unsupported project palette route: {route.value}"
1296
+ raise ValueError(message)
1297
+
1298
+ async def _open_task_palette_route(self, task_id: str, route: TaskPaletteRoute) -> None:
1299
+ if self._focus_task is not None and self._focus_task != task_id:
1300
+ return
1301
+ if self._focus_task is None:
1302
+ self._select_task_context(task_id)
1303
+ if route is TaskPaletteRoute.INSPECT:
1304
+ await self._open_task_detail(task_id)
1305
+ return
1306
+ if route is TaskPaletteRoute.EVIDENCE:
1307
+ await self.action_open_task_evidence()
1308
+ return
1309
+ if route is TaskPaletteRoute.USAGE:
1310
+ await self.action_open_task_usage()
1311
+ return
1312
+ if route is TaskPaletteRoute.COMMANDS:
1313
+ await self.push_screen(
1314
+ _CommandHistoryScreen(self._views, self._on_change),
1315
+ callback=lambda _: self.refresh_task_detail_after_resume(),
1316
+ )
1317
+ elif route is TaskPaletteRoute.DIAGNOSTICS:
1318
+ await self._open_contextual_view(FOCUS_PROJECT_DIAGNOSTICS_VIEW)
1319
+
1320
+ def _find_context_choice(self, choice_id: str) -> _ContextChoice | None:
1321
+ return next(
1322
+ (item for item in self._context_choices() if item.choice_id == choice_id),
1323
+ None,
1324
+ )
1325
+
1326
+ async def _run_context_choice(self, choice: _ContextChoice) -> None:
1327
+ if choice.command_history:
1328
+ await self.push_screen(
1329
+ _CommandHistoryScreen(self._views, self._on_change),
1330
+ callback=lambda _: self.refresh_task_detail_after_resume(),
1331
+ )
1332
+ return
1333
+ await self._show_context_widget(choice)
1334
+
1335
+ async def _open_task_detail(
1336
+ self,
1337
+ task_id: str,
1338
+ *,
1339
+ initial_view: str = TASK_VIEW_SUMMARY,
1340
+ ) -> None:
1341
+ def refresh_resumed_detail(_: None) -> None:
1342
+ self.refresh_task_detail_after_resume()
1343
+
1344
+ await self.push_screen(
1345
+ TaskDetailScreen(
1346
+ self._widget_context(),
1347
+ task_id,
1348
+ initial_view=initial_view,
1349
+ ),
1350
+ callback=refresh_resumed_detail,
1351
+ )
1352
+
1353
+ async def _show_context_widget(self, choice: _ContextChoice) -> None:
1354
+ if choice.widget_name is None or choice.placement is None:
1355
+ message = f"contextual dashboard view {choice.choice_id!r} has no target"
1356
+ raise WidgetPluginError(message)
1357
+ widget = self._create_widget(choice.widget_name, placement=choice.placement)
1358
+ await self.push_screen(
1359
+ _ContextWidgetScreen(choice.title or choice.label, widget),
1360
+ callback=lambda _: self.refresh_task_detail_after_resume(),
1361
+ )
1362
+
1363
+ def _select_task_context(self, task_id: str | None) -> None:
1364
+ self._selected_task_id = task_id
1365
+ if self._home_detail_panel is not None:
1366
+ self._home_detail_panel.select_task(task_id)
1367
+ queue = self._widget_instances.get(_WIDGET_QUEUE)
1368
+ if queue is not None:
1369
+ cast("_TaskBrowserNavigation", queue).sync_context_selection(task_id)
1370
+ attention = self._widget_instances.get(_WIDGET_ESCALATIONS)
1371
+ if attention is not None:
1372
+ cast("_AttentionPanelNavigation", attention).follow_task(task_id)
1373
+
1374
+ def _clear_home_selection(self) -> None:
1375
+ queue = self._widget_instances.get(_WIDGET_QUEUE)
1376
+ if queue is None:
1377
+ self._select_task_context(None)
1378
+ return
1379
+ browser = cast("_TaskBrowserNavigation", queue)
1380
+ browser.clear_selection()
1381
+ browser.focus_task_results()
1382
+
1383
+ def get_default_screen(self) -> Screen[object]:
1384
+ return TabStopScreen()
1385
+
1386
+ async def on_mount(self) -> None:
1387
+ """Focus the task browser or task view and start signal-driven refresh."""
1388
+ self._apply_layout(self.size.width, self.size.height)
1389
+ if self._focus_task is None:
1390
+ task_list = self.query("#task-browser-list")
1391
+ queue = self._widget_instances.get(_WIDGET_QUEUE)
1392
+ if queue is not None:
1393
+ cast("_TaskBrowserNavigation", queue).focus_task_results()
1394
+ elif task_list:
1395
+ self.set_focus(task_list.first())
1396
+ else:
1397
+ self.set_focus(self.query_one(f"#{_HOME_FOOTER_ACTIONS}", Button))
1398
+ else:
1399
+ self.query_one("#task-detail-panel", TaskDetailPanel).focus_summary()
1400
+ if self._motion_interval_seconds is not None:
1401
+ self.run_worker(self._motion_loop(), group="dashboard-motion", exclusive=True)
1402
+
1403
+ def on_resize(self, event: events.Resize) -> None:
1404
+ self._apply_layout(event.size.width, event.size.height)
1405
+
1406
+ def refresh_layout(self) -> None:
1407
+ """Recalculate Home row allocations after a footer status becomes visible or hidden."""
1408
+ if not self.is_running or self._focus_task is not None:
1409
+ return
1410
+ if any(
1411
+ not self.query(selector)
1412
+ for selector in (
1413
+ "#focus-main",
1414
+ "#focus-readouts",
1415
+ "#focus-footer",
1416
+ "#workspace-command-status",
1417
+ )
1418
+ ):
1419
+ return
1420
+ self._apply_layout(self.size.width, self.size.height)
1421
+
1422
+ def refresh_task_detail_after_resume(self) -> None:
1423
+ """Refresh task detail mounted in the screen that resumed after an overlay."""
1424
+ if not self.is_running:
1425
+ return
1426
+ if self._focus_task is None:
1427
+ queue = self._widget_instances.get(_WIDGET_QUEUE)
1428
+ if queue is not None:
1429
+ cast("_TaskBrowserNavigation", queue).refresh_after_resume()
1430
+ panels = self.screen.query(TaskDetailPanel)
1431
+ if panels:
1432
+ panels.first().refresh_after_resume()
1433
+
1434
+ async def on_key(self, event: events.Key) -> None:
1435
+ task_detail_screen = isinstance(self.screen, TaskDetailScreen)
1436
+ if event.key == TASK_DETAIL_ESCAPE and self._focused_select_is_expanded():
1437
+ return
1438
+ if event.key == _DASHBOARD_PALETTE_EVENT_KEY:
1439
+ if isinstance(self.screen, ModalScreen) and not task_detail_screen:
1440
+ return
1441
+ await self.action_open_palette()
1442
+ event.stop()
1443
+ event.prevent_default()
1444
+ return
1445
+ if (native_editor_has_focus(self.focused) and event.key != TASK_DETAIL_ESCAPE) or (
1446
+ isinstance(self.screen, ModalScreen) and not task_detail_screen
1447
+ ):
1448
+ return
1449
+ if await self._move_task_selection(event.key):
1450
+ event.stop()
1451
+ event.prevent_default()
1452
+ return
1453
+ if await self._dispatch_workspace_key(event):
1454
+ event.stop()
1455
+ if event.key in {_ACTIONS_KEY, TASK_DETAIL_ESCAPE}:
1456
+ event.prevent_default()
1457
+
1458
+ async def _move_task_selection(self, key: str) -> bool:
1459
+ if (
1460
+ key not in _TASK_MOVE_STEPS
1461
+ or isinstance(self.screen, ModalScreen)
1462
+ or isinstance(self.focused, _ARROW_KEY_OWNERS)
1463
+ ):
1464
+ return False
1465
+ queue = self._widget_instances.get(_WIDGET_QUEUE)
1466
+ if queue is None:
1467
+ return False
1468
+ await cast("_TaskBrowserNavigation", queue).move_selection(_TASK_MOVE_STEPS[key])
1469
+ return True
1470
+
1471
+ def _focused_select_is_expanded(self) -> bool:
1472
+ focused = self.focused
1473
+ while focused is not None:
1474
+ if isinstance(focused, Select):
1475
+ return focused.expanded
1476
+ focused = focused.parent
1477
+ return False
1478
+
1479
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
1480
+ if event.button.id == _HOME_FOOTER_CLEAR:
1481
+ self._clear_home_selection()
1482
+ elif event.button.id == _HOME_FOOTER_ACTIONS:
1483
+ await self.action_open_palette()
1484
+ elif event.button.id == _HOME_FOOTER_DETACH and self._detach_tmux_client is not None:
1485
+ if not await self._detach_tmux_client():
1486
+ self._show_navigation_feedback("No project tmux client is currently showing Home.")
1487
+ elif event.button.id == _HOME_FOOTER_HELP:
1488
+ await self.action_show_help()
1489
+ elif event.button.id == _HOME_FOOTER_COMMAND_STATUS:
1490
+ await self.push_screen(
1491
+ _CommandHistoryScreen(self._views, self._on_change),
1492
+ callback=lambda _: self.refresh_task_detail_after_resume(),
1493
+ )
1494
+
1495
+ async def _dispatch_workspace_key(self, event: events.Key) -> bool:
1496
+ if event.key == _ACTIONS_KEY:
1497
+ await self.action_open_palette()
1498
+ elif event.key == TASK_DETAIL_ESCAPE:
1499
+ await self.action_return_from_section()
1500
+ elif event.key == TASK_DETAIL_HELP_KEY:
1501
+ await self.action_show_help()
1502
+ elif event.key == TASK_DETAIL_VIEWS_KEY:
1503
+ panel = self._context_task_detail_panel()
1504
+ if panel is not None:
1505
+ panel.focus_folds()
1506
+ else:
1507
+ return self._run_queue_key(event.key)
1508
+ return True
1509
+
1510
+ def _focus_inside(self, owners: tuple[type[Widget], ...]) -> bool:
1511
+ focused = self.focused
1512
+ return focused is not None and any(
1513
+ isinstance(widget, owners) for widget in (focused, *focused.ancestors)
1514
+ )
1515
+
1516
+ def _run_queue_key(self, key: str) -> bool:
1517
+ action = _QUEUE_KEY_ACTIONS.get(key)
1518
+ if (
1519
+ action is None
1520
+ or self._focus_task is not None
1521
+ or _WIDGET_QUEUE not in self._widget_names
1522
+ or self._focus_inside(_QUEUE_KEY_OWNERS.get(key, ()))
1523
+ ):
1524
+ return False
1525
+ queue_action = getattr(self._widget_instances.get(_WIDGET_QUEUE), action, None)
1526
+ if not callable(queue_action):
1527
+ return False
1528
+ queue_action()
1529
+ return True
1530
+
1531
+ async def _motion_loop(self) -> None:
1532
+ interval = self._motion_interval_seconds
1533
+ if interval is None:
1534
+ return
1535
+ while True:
1536
+ await self._clock.sleep(interval)
1537
+ tick = self._clock.now()
1538
+ for registration in tuple(self._motion_callbacks):
1539
+ if registration.in_flight:
1540
+ continue
1541
+ with pinned_tick(tick):
1542
+ registration.task = asyncio.ensure_future(registration.callback())
1543
+ registration.task.add_done_callback(self._surface_motion_callback_error)
1544
+
1545
+ def _surface_motion_callback_error(self, task: asyncio.Future[None]) -> None:
1546
+ if task.cancelled():
1547
+ return
1548
+ error = task.exception()
1549
+ if isinstance(error, Exception):
1550
+ self._handle_exception(error)
1551
+
1552
+ def register_motion_callback(self, callback: OnChange) -> Callable[[], None]:
1553
+ """Register a motion callback and return its cancellation subscription."""
1554
+ registration = _MotionCallback(callback)
1555
+ self._motion_callbacks.append(registration)
1556
+
1557
+ def unsubscribe() -> None:
1558
+ self._motion_callbacks = [
1559
+ item for item in self._motion_callbacks if item is not registration
1560
+ ]
1561
+ registration.cancel()
1562
+
1563
+ return unsubscribe
1564
+
1565
+ def on_unmount(self) -> None:
1566
+ for registration in self._motion_callbacks:
1567
+ registration.cancel()
1568
+ self._motion_callbacks.clear()
1569
+
1570
+ def _apply_layout(self, width: int, height: int) -> None:
1571
+ if self._focus_task is None:
1572
+ main = self.query_one("#focus-main", Horizontal)
1573
+ was_wide = main.has_class("wide")
1574
+ wide = width >= HOME_SPLIT_MIN_COLUMNS
1575
+ main.set_class(wide, "wide")
1576
+ main.set_class(not wide, "compact")
1577
+ if self._home_detail_panel is not None:
1578
+ self._home_detail_panel.set_class(not wide, COMPACT_INSPECTOR_CLASS)
1579
+ readouts = self.query_one("#focus-readouts", VerticalScroll)
1580
+ task_list_configured = bool(
1581
+ self._widget_names_by_placement.get(WidgetPlacement.TASK_LIST)
1582
+ )
1583
+ minimum_main_height = (
1584
+ FOCUS_MIN_TASK_LIST_ROWS
1585
+ if wide and task_list_configured
1586
+ else FOCUS_COMPACT_SHORT_MAIN_ROWS
1587
+ if not wide and FOCUS_FOOTER_MIN_HEIGHT <= height <= FOCUS_COMPACT_SHORT_HOME_HEIGHT
1588
+ else FOCUS_MAIN_MIN_ROWS
1589
+ if FOCUS_FOOTER_MIN_HEIGHT <= height <= FOCUS_SHORT_HOME_HEIGHT
1590
+ else FOCUS_COMPACT_MAIN_ROWS
1591
+ if not wide and task_list_configured
1592
+ else FOCUS_MAIN_MIN_ROWS
1593
+ )
1594
+ main.styles.min_height = minimum_main_height
1595
+ footer = self.query_one("#focus-footer", _HomeFooter)
1596
+ footer.display = height >= FOCUS_FOOTER_MIN_HEIGHT
1597
+ footer_columns = (
1598
+ FOCUS_FOOTER_STACKED_COLUMN_COUNT
1599
+ if width < FOCUS_FOOTER_GROUP_MIN_COLUMNS
1600
+ else FOCUS_FOOTER_ACTION_COUNT + 1
1601
+ if wide
1602
+ else FOCUS_FOOTER_GROUP_COUNT
1603
+ )
1604
+ footer.styles.grid_size_columns = footer_columns
1605
+ footer.set_class(footer_columns != FOCUS_FOOTER_STACKED_COLUMN_COUNT, "grouped")
1606
+ footer.styles.grid_columns = (
1607
+ "auto auto auto auto auto 1fr"
1608
+ if wide
1609
+ else f"{FOCUS_FOOTER_COMPACT_CONTROL_COLUMN_WIDTH} 1fr"
1610
+ if width < FOCUS_FOOTER_STATUS_WIDE_LABEL_MIN_COLUMNS
1611
+ and footer_columns == FOCUS_FOOTER_GROUP_COUNT
1612
+ else "auto 1fr"
1613
+ if footer_columns == FOCUS_FOOTER_GROUP_COUNT
1614
+ else "1fr"
1615
+ )
1616
+ status_visible = self.query_one(
1617
+ f"#{_HOME_FOOTER_COMMAND_STATUS}", _CommandStatusLine
1618
+ ).display
1619
+ footer_items = FOCUS_FOOTER_ACTION_COUNT + int(status_visible)
1620
+ footer_rows = (footer_items + footer_columns - 1) // footer_columns
1621
+ if (
1622
+ width < FOCUS_FOOTER_STATUS_WIDE_LABEL_MIN_COLUMNS
1623
+ and footer_columns == FOCUS_FOOTER_GROUP_COUNT
1624
+ ):
1625
+ footer_rows += FOCUS_FOOTER_COMPACT_WRAP_ROWS
1626
+ readout_main_height = (
1627
+ minimum_main_height - FOCUS_TASK_LIST_RECENT_RESERVE_ROWS
1628
+ if wide and task_list_configured
1629
+ else minimum_main_height
1630
+ )
1631
+ readouts.styles.max_height = (
1632
+ None
1633
+ if not wide and height < FOCUS_FOOTER_MIN_HEIGHT
1634
+ else max(
1635
+ 1,
1636
+ min(
1637
+ FOCUS_SUMMARY_MAX_ROWS,
1638
+ height
1639
+ - readout_main_height
1640
+ - footer_rows
1641
+ - FOCUS_ATTENTION_HEIGHT
1642
+ - FOCUS_FRAME_BORDER_ROWS,
1643
+ ),
1644
+ )
1645
+ )
1646
+ task_list = self.query("#task-browser-list")
1647
+ if task_list:
1648
+ task_list.first().styles.height = None if wide else FOCUS_COMPACT_TASK_LIST_ROWS
1649
+ if self._home_detail_panel is not None:
1650
+ self._home_detail_panel.display = True
1651
+ self._home_detail_panel.styles.height = "100%" if wide else "auto"
1652
+ if wide != was_wide:
1653
+ self._home_detail_panel.refresh_after_resume()
1654
+ return
1655
+ return
1656
+
1657
+ async def action_return_from_section(self) -> None:
1658
+ """Return from task-scoped details or the fixed task view, one level per press."""
1659
+ panel = self._context_task_detail_panel()
1660
+ if panel is not None and panel.step_out():
1661
+ return
1662
+ if isinstance(self.screen, TaskDetailScreen):
1663
+ self.screen.dismiss()
1664
+ return
1665
+ if self._focus_task is None:
1666
+ self._clear_home_selection()
1667
+ return
1668
+ if panel is not None:
1669
+ await panel.return_from_detail()
1670
+
1671
+ async def action_show_help(self) -> None:
1672
+ """Show the bindings and current home or task workspace context."""
1673
+ panel = self._context_task_detail_panel()
1674
+ if panel is not None:
1675
+ await panel.open_help()
1676
+ return
1677
+ await self._push_help(self._focus_task)
1678
+
1679
+ async def open_task_detail_help(self, task_id: str) -> None:
1680
+ """Open help with the selected task and current detail context."""
1681
+ await self._push_help(task_id)
1682
+
1683
+ async def _push_help(self, task_id: str | None) -> None:
1684
+ availability = HelpAvailability(
1685
+ queue=_WIDGET_QUEUE in self._widget_names,
1686
+ escalations=_WIDGET_ESCALATIONS in self._widget_names,
1687
+ detach_prefix=self._tmux_detach_hint,
1688
+ )
1689
+ context = (
1690
+ HelpContext(HelpTab.TASK, availability)
1691
+ if task_id is not None
1692
+ else HelpContext(HelpTab.HOME, availability)
1693
+ )
1694
+ await self.push_screen(
1695
+ WorkspaceHelpScreen(context),
1696
+ callback=lambda _: self.refresh_task_detail_after_resume(),
1697
+ )
1698
+
1699
+ async def action_open_palette(self) -> None:
1700
+ """Open the operator palette through the shared dashboard callback."""
1701
+ panel = self._context_task_detail_panel()
1702
+ if panel is not None:
1703
+ await panel.open_palette()
1704
+ return
1705
+ if self._focus_task is not None:
1706
+ await self.query_one("#task-detail-panel", TaskDetailPanel).open_palette()
1707
+ return
1708
+ if self._home_detail_panel is not None and self._selected_task_id is not None:
1709
+ self._home_detail_panel.select_task(self._selected_task_id)
1710
+ await self._home_detail_panel.open_palette()
1711
+ return
1712
+ if self._open_palette_context is not None:
1713
+ await self._open_contextual_palette(PaletteRequest(task_id=self._selected_task_id))
1714
+ return
1715
+ await self._open_palette("")
1716
+
1717
+ async def action_open_task_usage(self) -> None:
1718
+ """Open recorded usage details for the selected task or task view."""
1719
+ panel = self._context_task_detail_panel()
1720
+ task_id = panel.task_id if panel is not None else self._selected_task_id
1721
+ if task_id is None:
1722
+ return
1723
+ await self.open_task_detail_view(task_id, TASK_VIEW_USAGE)
1724
+
1725
+ async def action_open_task_evidence(self) -> None:
1726
+ """Open retained evidence for the selected task or task view."""
1727
+ panel = self._context_task_detail_panel()
1728
+ task_id = panel.task_id if panel is not None else self._selected_task_id
1729
+ if task_id is None:
1730
+ return
1731
+ await self.open_task_detail_view(task_id, TASK_VIEW_EVIDENCE)
1732
+
1733
+ async def open_task_detail_view(self, task_id: str, view_id: str) -> None:
1734
+ """Open a task's fold where its panel is shown, otherwise in the task popup."""
1735
+ panel = self._inline_task_panel(task_id)
1736
+ if panel is None:
1737
+ await self._open_task_detail(task_id, initial_view=view_id)
1738
+ return
1739
+ panel.open_view(view_id)
1740
+
1741
+ def _inline_task_panel(self, task_id: str) -> TaskDetailPanel | None:
1742
+ panel = self._context_task_detail_panel()
1743
+ if panel is not None and panel.task_id == task_id:
1744
+ return panel
1745
+ if isinstance(self.screen, TaskDetailScreen):
1746
+ return None
1747
+ home = self._home_detail_panel
1748
+ if home is not None:
1749
+ home.select_task(task_id)
1750
+ return home
1751
+
1752
+ def _context_task_detail_panel(self) -> TaskDetailPanel | None:
1753
+ if isinstance(self.screen, TaskDetailScreen):
1754
+ return self.screen.query_one("#task-detail-panel", TaskDetailPanel)
1755
+ if self._focus_task is not None:
1756
+ return self.query_one("#task-detail-panel", TaskDetailPanel)
1757
+ panel = self._home_detail_panel
1758
+ focused = self.focused
1759
+ if (
1760
+ panel is not None
1761
+ and focused is not None
1762
+ and (focused is panel or panel in focused.ancestors)
1763
+ ):
1764
+ return panel
1765
+ return None