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,799 @@
1
+ """The grouped Actions list: choose what to do with a task or the project."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass, replace
7
+ from typing import TYPE_CHECKING, ClassVar, cast
8
+
9
+ from rich.cells import cell_len
10
+ from rich.text import Text
11
+ from textual.binding import Binding
12
+ from textual.containers import Horizontal
13
+ from textual.screen import ModalScreen
14
+ from textual.widgets import Button, Label, ListItem, ListView, Rule, Static
15
+
16
+ from shikiru.dashboard.contract import (
17
+ ExtensionEntry,
18
+ PaletteAction,
19
+ PaletteRequest,
20
+ PaletteReturn,
21
+ ProjectRouteChoice,
22
+ TaskPaletteRoute,
23
+ TaskRouteChoice,
24
+ )
25
+ from shikiru.dashboard.covered import Coverable
26
+ from shikiru.dashboard.palette import SUBMIT_ACTION_LABELS
27
+ from shikiru.dashboard.task_picker import TaskPickerScreen
28
+ from shikiru.domain.commands import ResolutionKind
29
+ from shikiru.domain.escalation import EscalationStatus
30
+ from shikiru.errors import ConfigError
31
+ from shikiru.operator.queries import (
32
+ TASK_ACTION_OTHER_ROLE_TAKEN_OVER,
33
+ TASK_ACTION_ROLES,
34
+ OperatorTaskAction,
35
+ )
36
+ from shikiru.ui.keys import key_hint
37
+ from shikiru.ui.popup import (
38
+ POPUP_BODY_CLASS,
39
+ POPUP_CLOSE_CLASS,
40
+ POPUP_CSS,
41
+ POPUP_FRAME_CLASS,
42
+ POPUP_HEADER_CLASS,
43
+ POPUP_RULE_CLASS,
44
+ POPUP_TITLE_CLASS,
45
+ PopupFrame,
46
+ popup_close_label,
47
+ prepare_popup,
48
+ )
49
+ from shikiru.ui.section import GROUP_SEPARATOR, WrappedGroups, grey
50
+ from shikiru.ui.tokens import (
51
+ PRIMARY_TEXT_COLOR,
52
+ SECONDARY_TEXT_COLOR,
53
+ STYLE_BOLD,
54
+ SUCCESS_COLOR,
55
+ )
56
+
57
+ if TYPE_CHECKING:
58
+ from rich.console import Console, ConsoleOptions, RenderResult
59
+ from textual import events
60
+ from textual.app import App, ComposeResult
61
+ from textual.binding import BindingType
62
+ from textual.screen import Screen
63
+ from textual.timer import Timer
64
+ from textual.worker import Worker
65
+
66
+ from shikiru.dashboard.contract import OnChangeRegistration, ResolutionScreenFactory
67
+ from shikiru.operator.queries import OperatorQueries
68
+
69
+ PopupFactory = Callable[[PaletteRequest], ModalScreen[str | None]]
70
+
71
+ ACTIONS_FRAME = "actions-frame"
72
+ ACTIONS_TITLE = "actions-title"
73
+ ACTIONS_CLOSE_BUTTON = "actions-close"
74
+ ACTIONS_LIST = "actions-list"
75
+ ACTIONS_HINT = "actions-hint"
76
+ ACTIONS_HEADING = "Actions"
77
+ PROJECT_CONTEXT = "Project"
78
+ TASK_GROUP = "Task"
79
+ EXTENSIONS_GROUP = "Extensions"
80
+ PROJECT_GROUP = "Project"
81
+ ELLIPSIS = "\N{HORIZONTAL ELLIPSIS}"
82
+ MORE_LABEL = f"More{ELLIPSIS}"
83
+ FEWER_LABEL = f"Fewer{ELLIPSIS}"
84
+ SWITCH_LABEL = f"Switch task{ELLIPSIS}"
85
+ ANSWER_LABEL = "Answer"
86
+ COMMAND_HISTORY_LABEL = "Command history"
87
+ DIAGNOSTICS_LABEL = "Diagnostics"
88
+ ANSWER_ROW = "answer"
89
+ MORE_ROW = "more"
90
+ SWITCH_ROW = "switch-task"
91
+ ACTION_ROW_PREFIX = "action:"
92
+ ROUTE_ROW_PREFIX = "route:"
93
+ EXTENSION_ROW_PREFIX = "extension:"
94
+ MEANINGS: dict[str, str] = {
95
+ ANSWER_ROW: "Answer the recorded decision",
96
+ PaletteAction.MESSAGE: "Delivered on the next turn",
97
+ PaletteAction.TAKE_OVER: "Stop the turn, work in the pane",
98
+ PaletteAction.HAND_BACK: "Return this role to automation",
99
+ PaletteAction.PAUSE: "Stop after the current turn",
100
+ PaletteAction.RESUME: "Clear the task holds",
101
+ PaletteAction.CANCEL: "Abandon this task",
102
+ PaletteAction.PAUSE_NOW: "Stop the current turn, then hold",
103
+ PaletteAction.PRIORITIZE: "Change the dispatch order",
104
+ PaletteAction.RETRY: "Re-run the current step",
105
+ PaletteAction.RUNG: "Set the level for the next turn",
106
+ PaletteAction.EXPORT: "Write the task evidence to a file",
107
+ PaletteAction.SUBMIT: "Create a new task",
108
+ PaletteAction.STOP: "Stop running turns, keep the state",
109
+ PaletteAction.START: "Start the engine and keep the project session",
110
+ TaskPaletteRoute.COMMANDS: "Pending, applied and rejected commands",
111
+ TaskPaletteRoute.DIAGNOSTICS: "Project health and resources",
112
+ SWITCH_ROW: "Act on another task",
113
+ FEWER_LABEL: "Hide the extra actions",
114
+ }
115
+ ROUTE_LABELS = {
116
+ TaskPaletteRoute.COMMANDS: COMMAND_HISTORY_LABEL,
117
+ TaskPaletteRoute.DIAGNOSTICS: DIAGNOSTICS_LABEL,
118
+ }
119
+ PRIMARY_TASK_ACTIONS = (
120
+ PaletteAction.MESSAGE,
121
+ PaletteAction.TAKE_OVER,
122
+ PaletteAction.HAND_BACK,
123
+ PaletteAction.PAUSE,
124
+ PaletteAction.RESUME,
125
+ PaletteAction.CANCEL,
126
+ )
127
+ EXTRA_TASK_ACTIONS = (
128
+ PaletteAction.PAUSE_NOW,
129
+ PaletteAction.PRIORITIZE,
130
+ PaletteAction.RETRY,
131
+ PaletteAction.RUNG,
132
+ PaletteAction.EXPORT,
133
+ )
134
+ ROLE_ACTIONS = frozenset(
135
+ {
136
+ PaletteAction.TAKE_OVER,
137
+ PaletteAction.HAND_BACK,
138
+ PaletteAction.RUNG,
139
+ PaletteAction.MESSAGE,
140
+ PaletteAction.PEEK,
141
+ }
142
+ )
143
+ TASK_LABEL_WIDTH = max(
144
+ cell_len(label)
145
+ for label in (
146
+ ANSWER_LABEL,
147
+ MORE_LABEL,
148
+ FEWER_LABEL,
149
+ *(SUBMIT_ACTION_LABELS[action] for action in (*PRIMARY_TASK_ACTIONS, *EXTRA_TASK_ACTIONS)),
150
+ )
151
+ )
152
+ LABEL_GAP = " "
153
+ ROW_INDENT = " "
154
+ TITLE_INDENT = " "
155
+ CONFIRMATION_GLYPH = " \N{CHECK MARK}"
156
+ CONFIRMATION_STYLE = f"{STYLE_BOLD} {SUCCESS_COLOR}"
157
+ MIN_MEANING_CELLS = 8
158
+ MIN_TASK_TITLE_CELLS = 8
159
+ HINT_GAP = " "
160
+ HINT_SELECT = "↑↓ select"
161
+ HINT_OPEN = "Enter open"
162
+ COMPACT_CLASS = "compact"
163
+ COMPACT_MAX_COLUMNS = 48
164
+ COMPACT_MAX_ROWS = 20
165
+ CLOSE_ACTION = "close"
166
+ MOVE_ACTION = "move"
167
+ JUMP_ACTION = "jump"
168
+ SYNC_WORKER = "actions-list-sync"
169
+ CHANGE_WORKER = "actions-list-changes"
170
+ RESOLUTION_NOT_CONFIGURED = "the escalation resolution screen is not configured"
171
+
172
+
173
+ @dataclass(frozen=True)
174
+ class ActionRow:
175
+ """One selectable row: a stable `key`, its `label` and a short grey `meaning`."""
176
+
177
+ key: str
178
+ label: str
179
+ meaning: str = ""
180
+
181
+
182
+ @dataclass(frozen=True)
183
+ class ActionGroup:
184
+ """A titled group of rows whose labels share one column `label_width` wide."""
185
+
186
+ title: str
187
+ rows: tuple[ActionRow, ...]
188
+ label_width: int
189
+
190
+
191
+ @dataclass(frozen=True)
192
+ class ListContext:
193
+ """What the Actions list can offer now; `task_actions` is empty when there is no task."""
194
+
195
+ has_task: bool
196
+ task_actions: frozenset[PaletteAction]
197
+ answerable: bool
198
+ expanded: bool
199
+ project_routes: bool
200
+ diagnostics: bool
201
+ engine_up: bool
202
+ extensions: tuple[ExtensionEntry, ...]
203
+
204
+
205
+ def _action_row(action: PaletteAction) -> ActionRow:
206
+ return ActionRow(
207
+ f"{ACTION_ROW_PREFIX}{action.value}", SUBMIT_ACTION_LABELS[action], MEANINGS[action]
208
+ )
209
+
210
+
211
+ def _route_row(route: TaskPaletteRoute) -> ActionRow:
212
+ return ActionRow(f"{ROUTE_ROW_PREFIX}{route.value}", ROUTE_LABELS[route], MEANINGS[route])
213
+
214
+
215
+ def _widest(rows: tuple[ActionRow, ...]) -> int:
216
+ return max((cell_len(row.label) for row in rows), default=0)
217
+
218
+
219
+ def _task_group(context: ListContext) -> ActionGroup:
220
+ rows: list[ActionRow] = []
221
+ if context.has_task:
222
+ if context.answerable:
223
+ rows.append(ActionRow(ANSWER_ROW, ANSWER_LABEL, MEANINGS[ANSWER_ROW]))
224
+ rows.extend(_action_row(a) for a in PRIMARY_TASK_ACTIONS if a in context.task_actions)
225
+ extras = tuple(a for a in EXTRA_TASK_ACTIONS if a in context.task_actions)
226
+ if extras:
227
+ rows.append(_more_row(context.expanded, extras))
228
+ if context.expanded:
229
+ rows.extend(_action_row(action) for action in extras)
230
+ return ActionGroup(TASK_GROUP, tuple(rows), TASK_LABEL_WIDTH)
231
+
232
+
233
+ def _more_row(expanded: bool, extras: tuple[PaletteAction, ...]) -> ActionRow:
234
+ if expanded:
235
+ return ActionRow(MORE_ROW, FEWER_LABEL, MEANINGS[FEWER_LABEL])
236
+ return ActionRow(
237
+ MORE_ROW,
238
+ MORE_LABEL,
239
+ GROUP_SEPARATOR.join(SUBMIT_ACTION_LABELS[action] for action in extras),
240
+ )
241
+
242
+
243
+ def _extensions_group(context: ListContext) -> ActionGroup:
244
+ rows = tuple(
245
+ ActionRow(f"{EXTENSION_ROW_PREFIX}{index}", extension.label)
246
+ for index, extension in enumerate(context.extensions)
247
+ )
248
+ return ActionGroup(EXTENSIONS_GROUP, rows, _widest(rows))
249
+
250
+
251
+ def _project_group(context: ListContext) -> ActionGroup:
252
+ rows = [
253
+ _action_row(PaletteAction.SUBMIT),
254
+ _action_row(PaletteAction.STOP if context.engine_up else PaletteAction.START),
255
+ ]
256
+ if context.project_routes:
257
+ rows.append(_route_row(TaskPaletteRoute.COMMANDS))
258
+ if context.diagnostics:
259
+ rows.append(_route_row(TaskPaletteRoute.DIAGNOSTICS))
260
+ rows.append(ActionRow(SWITCH_ROW, SWITCH_LABEL, MEANINGS[SWITCH_ROW]))
261
+ action_rows = tuple(rows)
262
+ return ActionGroup(PROJECT_GROUP, action_rows, _widest(action_rows))
263
+
264
+
265
+ def action_groups(context: ListContext) -> tuple[ActionGroup, ...]:
266
+ """Return the non-empty Task, Extensions and Project groups."""
267
+ groups = (
268
+ _task_group(context),
269
+ _extensions_group(context),
270
+ _project_group(context),
271
+ )
272
+ return tuple(group for group in groups if group.rows)
273
+
274
+
275
+ def row_text(row: ActionRow, label_width: int, width: int, *, confirmed: bool = False) -> Text:
276
+ """Return `label meaning` on one line of `width` cells; the meaning shrinks or drops first."""
277
+ label = Text(row.label, style=PRIMARY_TEXT_COLOR)
278
+ if confirmed:
279
+ label.append(CONFIRMATION_GLYPH, style=CONFIRMATION_STYLE)
280
+ head = Text.assemble(ROW_INDENT, label)
281
+ column = cell_len(ROW_INDENT) + label_width + cell_len(LABEL_GAP)
282
+ room = width - column
283
+ if not row.meaning or room < MIN_MEANING_CELLS:
284
+ head.truncate(width, overflow="ellipsis")
285
+ return Text.assemble(head, no_wrap=True, overflow="ellipsis")
286
+ head.pad_right(column - cell_len(head.plain))
287
+ meaning = grey(row.meaning)
288
+ meaning.truncate(room, overflow="ellipsis")
289
+ return Text.assemble(head, meaning, no_wrap=True, overflow="ellipsis")
290
+
291
+
292
+ def list_title(task_id: str | None, task_title: str, width: int | None = None) -> str:
293
+ """Return `Actions · T-n title` or `Actions · Project`; the title shrinks to fit."""
294
+ if task_id is None:
295
+ return f"{ACTIONS_HEADING}{GROUP_SEPARATOR}{PROJECT_CONTEXT}"
296
+ head = f"{ACTIONS_HEADING}{GROUP_SEPARATOR}{task_id}"
297
+ title = task_title.replace("\n", " ")
298
+ room = None if not width else width - cell_len(head) - 1
299
+ if room is not None and cell_len(title) > room:
300
+ if room < MIN_TASK_TITLE_CELLS:
301
+ return head
302
+ shown = Text(title)
303
+ shown.truncate(room, overflow="ellipsis")
304
+ title = shown.plain
305
+ return f"{head} {title}"
306
+
307
+
308
+ class _RowText:
309
+ def __init__(self, row: ActionRow, label_width: int, *, confirmed: bool) -> None:
310
+ self._row = row
311
+ self._label_width = label_width
312
+ self._confirmed = confirmed
313
+
314
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
315
+ del console
316
+ yield row_text(self._row, self._label_width, options.max_width, confirmed=self._confirmed)
317
+
318
+
319
+ class GroupTitleItem(ListItem):
320
+ def __init__(self, title: str) -> None:
321
+ super().__init__(Static(Text(f"{TITLE_INDENT}{title}"), markup=False), disabled=True)
322
+ self.title = title
323
+
324
+
325
+ class ActionItem(ListItem):
326
+ def __init__(self, row: ActionRow, label_width: int, *, confirmed: bool) -> None:
327
+ self._text = Static(_RowText(row, label_width, confirmed=confirmed), markup=False)
328
+ super().__init__(self._text)
329
+ self.row = row
330
+
331
+ def show(self, row: ActionRow, label_width: int, *, confirmed: bool) -> None:
332
+ self.row = row
333
+ self._text.update(_RowText(row, label_width, confirmed=confirmed))
334
+
335
+
336
+ class _TitleLabel(Label):
337
+ def __init__(self, refit: Callable[[], None], *, widget_id: str) -> None:
338
+ super().__init__(ACTIONS_HEADING, id=widget_id, classes=POPUP_TITLE_CLASS)
339
+ self._refit = refit
340
+
341
+ def on_resize(self) -> None:
342
+ self._refit()
343
+
344
+
345
+ class _ActionList(ListView):
346
+ def on_resize(self) -> None:
347
+ self.call_after_refresh(self._reveal_selection)
348
+
349
+ def watch_index(self, old_index: int | None, new_index: int | None) -> None:
350
+ super().watch_index(old_index, new_index)
351
+ self.call_after_refresh(self._reveal_selection)
352
+
353
+ def _reveal_selection(self) -> None:
354
+ item = self.highlighted_child
355
+ if item is None or self.index is None:
356
+ return
357
+ above = self.index - 1
358
+ heading = self._nodes[above] if above >= 0 else None
359
+ (heading if isinstance(heading, GroupTitleItem) else item).scroll_visible(animate=False)
360
+
361
+
362
+ class ActionsListScreen(Coverable, ModalScreen[PaletteReturn | None]):
363
+ """Show the grouped Actions list; rows open popups, views, extensions or another task."""
364
+
365
+ BINDINGS: ClassVar[list[BindingType]] = [
366
+ Binding("escape", CLOSE_ACTION, "Close"),
367
+ Binding("up", f"{MOVE_ACTION}(-1)", "Up", show=False, priority=True),
368
+ Binding("down", f"{MOVE_ACTION}(1)", "Down", show=False, priority=True),
369
+ Binding("home", f"{JUMP_ACTION}(-1)", "First", show=False, priority=True),
370
+ Binding("end", f"{JUMP_ACTION}(1)", "Last", show=False, priority=True),
371
+ ]
372
+ DEFAULT_CSS = (
373
+ POPUP_CSS
374
+ + f"""
375
+ #{ACTIONS_TITLE} {{ text-wrap: nowrap; text-overflow: ellipsis; }}
376
+ #{ACTIONS_LIST} {{
377
+ border: none; padding: 0; background: $surface;
378
+ scrollbar-size-vertical: $scrollbar-width;
379
+ }}
380
+ #{ACTIONS_LIST} > ListItem {{
381
+ height: 1; padding: 0; border: none; background: $surface; color: $primary-text;
382
+ }}
383
+ #{ACTIONS_LIST} > ListItem > Static {{
384
+ width: 100%; height: 1; padding: 0; color: $primary-text;
385
+ text-wrap: nowrap; text-overflow: ellipsis;
386
+ }}
387
+ #{ACTIONS_LIST} > ListItem:disabled, #{ACTIONS_LIST} > ListItem:disabled > Static {{
388
+ color: $secondary-text; text-opacity: 100%; opacity: 100%;
389
+ }}
390
+ #{ACTIONS_LIST} > ListItem.-highlight,
391
+ #{ACTIONS_LIST} > ListItem.-highlight > Static {{
392
+ outline: none; background: $selected-surface; color: $primary-text; text-style: none;
393
+ }}
394
+ #{ACTIONS_LIST}:focus {{ background-tint: transparent; }}
395
+ #{ACTIONS_HINT} {{ height: auto; margin-top: 1; }}
396
+ #{ACTIONS_FRAME}.{COMPACT_CLASS} {{ margin: 0 1; padding: 0 1; }}
397
+ """
398
+ )
399
+
400
+ def __init__(
401
+ self,
402
+ queries: OperatorQueries,
403
+ open_popup: PopupFactory,
404
+ request: PaletteRequest,
405
+ *,
406
+ on_change: OnChangeRegistration,
407
+ action_confirmation_seconds: float,
408
+ engine_state_refresh_hz: int | None = None,
409
+ task_routes_available: bool = False,
410
+ project_diagnostics_available: bool = False,
411
+ extensions: tuple[ExtensionEntry, ...] = (),
412
+ resolution_screen: ResolutionScreenFactory | None = None,
413
+ ) -> None:
414
+ """Offer the actions for `request`'s task, or the project when it has none."""
415
+ super().__init__()
416
+ prepare_popup(self)
417
+ self._queries = queries
418
+ self._open_popup = open_popup
419
+ self._request = replace(request, action=None)
420
+ self._on_change = on_change
421
+ self._confirmation_seconds = action_confirmation_seconds
422
+ self._engine_state_refresh_hz = engine_state_refresh_hz
423
+ self._task_routes_available = task_routes_available
424
+ self._project_diagnostics_available = project_diagnostics_available
425
+ self._extensions = extensions
426
+ self._resolution_screen = resolution_screen
427
+ self._expanded = False
428
+ self._finishing = False
429
+ self._confirmed: str | None = None
430
+ self._confirmation_timer: Timer | None = None
431
+ self._shown: tuple[tuple[str, str], ...] | None = None
432
+ self._groups: tuple[ActionGroup, ...] = ()
433
+ self._change_worker: Worker[None] | None = None
434
+ self._sync_active = False
435
+ self._sync_pending = False
436
+ self._sync_select_first = False
437
+ self._finish_result: PaletteReturn | None = None
438
+ self._engine_state_timer: Timer | None = None
439
+ self._engine_up: bool | None = None
440
+
441
+ def compose(self) -> ComposeResult:
442
+ """Render the header, the grouped rows and the hint line."""
443
+ with PopupFrame(widget_id=ACTIONS_FRAME, classes=POPUP_FRAME_CLASS):
444
+ with Horizontal(classes=POPUP_HEADER_CLASS):
445
+ yield _TitleLabel(self._paint_title, widget_id=ACTIONS_TITLE)
446
+ yield Button(
447
+ popup_close_label(), id=ACTIONS_CLOSE_BUTTON, classes=POPUP_CLOSE_CLASS
448
+ )
449
+ yield Rule(classes=POPUP_RULE_CLASS)
450
+ yield _ActionList(id=ACTIONS_LIST, classes=POPUP_BODY_CLASS)
451
+ yield Static(id=ACTIONS_HINT, markup=False)
452
+
453
+ async def on_mount(self) -> None:
454
+ """Build the rows, select the first one and follow database changes."""
455
+ await self._sync(select_first=True)
456
+ self._apply_compact_layout(self.size.width, self.size.height)
457
+ self._list.focus()
458
+ self.call_after_refresh(self.query_one(f"#{ACTIONS_FRAME}", PopupFrame).content_ready)
459
+ self._change_worker = self.run_worker(
460
+ self._on_change(self._refresh_after_database_change), group=CHANGE_WORKER
461
+ )
462
+ if self._engine_state_refresh_hz is not None:
463
+ self._engine_state_timer = self.set_interval(
464
+ 1 / self._engine_state_refresh_hz, self._refresh_engine_state
465
+ )
466
+
467
+ def on_unmount(self) -> None:
468
+ """Stop following database changes and the confirmation timer."""
469
+ if self._change_worker is not None:
470
+ self._change_worker.cancel()
471
+ if self._confirmation_timer is not None:
472
+ self._confirmation_timer.stop()
473
+ if self._engine_state_timer is not None:
474
+ self._engine_state_timer.stop()
475
+
476
+ def on_resize(self, event: events.Resize) -> None:
477
+ """Refit the frame, the title and the hint to the new size."""
478
+ self._apply_compact_layout(event.size.width, event.size.height)
479
+ frame = self.query_one(f"#{ACTIONS_FRAME}", PopupFrame)
480
+ frame.reset_preferred_size()
481
+ self.call_after_refresh(frame.content_ready)
482
+
483
+ def _apply_compact_layout(self, width: int, height: int) -> None:
484
+ frame = self.query_one(f"#{ACTIONS_FRAME}", PopupFrame)
485
+ frame.set_class(width <= COMPACT_MAX_COLUMNS or height <= COMPACT_MAX_ROWS, COMPACT_CLASS)
486
+ self.query_one(f"#{ACTIONS_HINT}", Static).update(
487
+ WrappedGroups(
488
+ (
489
+ key_hint(HINT_SELECT, SECONDARY_TEXT_COLOR),
490
+ key_hint(HINT_OPEN, SECONDARY_TEXT_COLOR),
491
+ ),
492
+ HINT_GAP,
493
+ )
494
+ )
495
+
496
+ @property
497
+ def _host(self) -> App[None]:
498
+ return cast("App[None]", self.app)
499
+
500
+ @property
501
+ def _list(self) -> ListView:
502
+ return self.query_one(f"#{ACTIONS_LIST}", ListView)
503
+
504
+ @property
505
+ def _selectable(self) -> list[int]:
506
+ return [i for i, item in enumerate(self._list.children) if isinstance(item, ActionItem)]
507
+
508
+ def _list_context(self) -> ListContext:
509
+ task_id = self._request.task_id
510
+ task_exists = task_id is not None and self._queries.get_task_state(task_id) is not None
511
+ engine_up = self._queries.engine_is_up()
512
+ self._engine_up = engine_up
513
+ return ListContext(
514
+ has_task=task_exists,
515
+ task_actions=self._task_actions(task_id) if task_exists else frozenset(),
516
+ answerable=task_exists and self._answerable_escalation_id() is not None,
517
+ expanded=self._expanded,
518
+ project_routes=self._task_routes_available,
519
+ diagnostics=self._project_diagnostics_available,
520
+ engine_up=engine_up,
521
+ extensions=self._extensions,
522
+ )
523
+
524
+ def _refresh_engine_state(self) -> None:
525
+ if self.covered or not self.is_attached:
526
+ return
527
+ engine_up = self._queries.engine_is_up()
528
+ if engine_up != self._engine_up:
529
+ self._engine_up = engine_up
530
+ self._schedule_sync()
531
+
532
+ def _task_actions(self, task_id: str | None) -> frozenset[PaletteAction]:
533
+ if task_id is None:
534
+ return frozenset()
535
+ task = self._queries.get_task_state(task_id)
536
+ if task is None:
537
+ return frozenset()
538
+ enabled = {
539
+ PaletteAction(state.action.value)
540
+ for state in self._queries.task_action_availability(task_id, None)
541
+ if state.enabled
542
+ or (
543
+ state.action is OperatorTaskAction.RESUME
544
+ and bool(task.holds)
545
+ and state.disabled_reason == TASK_ACTION_OTHER_ROLE_TAKEN_OVER
546
+ )
547
+ }
548
+ return frozenset(enabled | {PaletteAction.EXPORT})
549
+
550
+ def _answerable_escalation_id(self) -> str | None:
551
+ task_id = self._request.task_id
552
+ for item in self._queries.list_escalations():
553
+ escalation = item.escalation
554
+ if escalation.task_id != task_id or escalation.status is not EscalationStatus.OPEN:
555
+ continue
556
+ answerable = any(option.label is ResolutionKind.ANSWER for option in escalation.options)
557
+ if (
558
+ answerable
559
+ and self._queries.escalation_resolution_disabled_reason(escalation.id) is None
560
+ ):
561
+ return escalation.id
562
+ return None
563
+
564
+ async def _sync(self, *, select_first: bool = False) -> None:
565
+ if not self._content_mounted():
566
+ return
567
+ groups = action_groups(self._list_context())
568
+ self._paint_title()
569
+ layout = tuple((group.title, row.key) for group in groups for row in group.rows)
570
+ keys = [key for _, key in layout]
571
+ selection = self._selected_key()
572
+ position = self._selected_position()
573
+ if layout != self._shown:
574
+ self._shown = None
575
+ await self._rebuild(groups)
576
+ if not self._content_mounted():
577
+ return
578
+ self._shown = layout
579
+ else:
580
+ self._repaint(groups)
581
+ if select_first:
582
+ self._select_row(0)
583
+ elif selection in keys:
584
+ self._select_row(keys.index(selection))
585
+ else:
586
+ self._select_row(position)
587
+
588
+ def _finish(self, result: PaletteReturn | None) -> None:
589
+ self._finishing = True
590
+ self._finish_result = result
591
+ if not self._sync_active:
592
+ self.dismiss(result)
593
+
594
+ def _schedule_sync(self, *, select_first: bool = False) -> None:
595
+ if self._finishing:
596
+ return
597
+ self._sync_pending = True
598
+ self._sync_select_first = self._sync_select_first or select_first
599
+ if self._sync_active:
600
+ return
601
+ self._sync_active = True
602
+ self.run_worker(self._sync_until_current, group=SYNC_WORKER)
603
+
604
+ async def _sync_until_current(self) -> None:
605
+ try:
606
+ while self._sync_pending and not self._finishing:
607
+ select_first = self._sync_select_first
608
+ self._sync_pending = False
609
+ self._sync_select_first = False
610
+ await self._sync(select_first=select_first)
611
+ if self._finishing:
612
+ self.dismiss(self._finish_result)
613
+ finally:
614
+ self._sync_active = False
615
+
616
+ def _selected_position(self) -> int:
617
+ indices = self._selectable
618
+ index = self._list.index
619
+ return indices.index(index) if index in indices else 0
620
+
621
+ async def _rebuild(self, groups: tuple[ActionGroup, ...]) -> None:
622
+ await self._list.clear()
623
+ if not self._content_mounted():
624
+ return
625
+ items: list[ListItem] = []
626
+ for group in groups:
627
+ items.append(GroupTitleItem(group.title))
628
+ items.extend(
629
+ ActionItem(row, group.label_width, confirmed=self._is_confirmed(row))
630
+ for row in group.rows
631
+ )
632
+ await self._list.extend(items)
633
+
634
+ def _repaint(self, groups: tuple[ActionGroup, ...]) -> None:
635
+ widths = {row.key: group.label_width for group in groups for row in group.rows}
636
+ rows = {row.key: row for group in groups for row in group.rows}
637
+ for item in self._list.children:
638
+ if isinstance(item, ActionItem):
639
+ key = item.row.key
640
+ item.show(rows[key], widths[key], confirmed=self._is_confirmed(rows[key]))
641
+
642
+ def _is_confirmed(self, row: ActionRow) -> bool:
643
+ return self._confirmed is not None and row.key == f"{ACTION_ROW_PREFIX}{self._confirmed}"
644
+
645
+ def _selected_key(self) -> str | None:
646
+ item = self._list.highlighted_child
647
+ return item.row.key if isinstance(item, ActionItem) else None
648
+
649
+ def _select_row(self, position: int) -> None:
650
+ indices = self._selectable
651
+ if indices:
652
+ self._list.index = indices[min(max(position, 0), len(indices) - 1)]
653
+
654
+ def _content_mounted(self) -> bool:
655
+ return (
656
+ self.is_attached
657
+ and len(self.query(f"#{ACTIONS_TITLE}")) > 0
658
+ and len(self.query(f"#{ACTIONS_LIST}")) > 0
659
+ )
660
+
661
+ def _paint_title(self) -> None:
662
+ if not self._content_mounted():
663
+ return
664
+ task_id = self._request.task_id
665
+ task = self._queries.get_task_state(task_id) if task_id is not None else None
666
+ title = self.query_one(f"#{ACTIONS_TITLE}", Label)
667
+ if task_id is None or task is None:
668
+ title.update(list_title(None, ""))
669
+ return
670
+ title.update(list_title(task_id, task.title, title.size.width))
671
+
672
+ def action_move(self, step: int) -> None:
673
+ """Move the selection by `step` rows, wrapping from the last row to the first."""
674
+ indices = self._selectable
675
+ if not indices:
676
+ return
677
+ current = self._list.index
678
+ position = indices.index(current) if current in indices else -1
679
+ self._list.index = indices[(position + step) % len(indices)]
680
+
681
+ def action_jump(self, direction: int) -> None:
682
+ """Select the first (`-1`) or last (`1`) row."""
683
+ indices = self._selectable
684
+ if indices:
685
+ self._list.index = indices[0 if direction < 0 else -1]
686
+ if direction < 0:
687
+ self._list.scroll_home(animate=False)
688
+
689
+ def action_close(self) -> None:
690
+ """Close the list without choosing anything."""
691
+ self._finish(None)
692
+
693
+ def on_button_pressed(self, event: Button.Pressed) -> None:
694
+ """Close the list from its close control."""
695
+ if event.button.id == ACTIONS_CLOSE_BUTTON:
696
+ self._finish(None)
697
+
698
+ def on_list_view_selected(self, event: ListView.Selected) -> None:
699
+ """Open what the chosen row names."""
700
+ if isinstance(event.item, ActionItem):
701
+ self._open_row(event.item.row.key)
702
+
703
+ def _open_row(self, key: str) -> None:
704
+ if key == MORE_ROW:
705
+ self._expanded = not self._expanded
706
+ self._schedule_sync()
707
+ elif key == SWITCH_ROW:
708
+ self._push_over(
709
+ TaskPickerScreen(self._queries, self._request.task_id), self._task_chosen
710
+ )
711
+ elif key == ANSWER_ROW:
712
+ self._open_resolution()
713
+ elif key.startswith(ACTION_ROW_PREFIX):
714
+ action = PaletteAction(key.removeprefix(ACTION_ROW_PREFIX))
715
+ self._push_over(self._open_popup(self._popup_request(action)), self._popup_closed)
716
+ elif key.startswith(ROUTE_ROW_PREFIX):
717
+ route = TaskPaletteRoute(key.removeprefix(ROUTE_ROW_PREFIX))
718
+ if route in {TaskPaletteRoute.COMMANDS, TaskPaletteRoute.DIAGNOSTICS}:
719
+ self._finish(ProjectRouteChoice(route))
720
+ else:
721
+ task_id = self._request.task_id
722
+ if task_id is not None:
723
+ self._finish(TaskRouteChoice(task_id, route))
724
+ elif key.startswith(EXTENSION_ROW_PREFIX):
725
+ self._finish(self._extensions[int(key.removeprefix(EXTENSION_ROW_PREFIX))])
726
+
727
+ def _push_over[Result](
728
+ self, screen: Screen[Result], callback: Callable[[Result | None], None]
729
+ ) -> None:
730
+ self.covered = True
731
+
732
+ def uncovered(result: Result | None) -> None:
733
+ self.covered = False
734
+ self.refresh(layout=True)
735
+ callback(result)
736
+
737
+ self._host.push_screen(screen, uncovered)
738
+
739
+ def _popup_request(self, action: PaletteAction) -> PaletteRequest:
740
+ return replace(self._request, action=action.value, role=self._role_for(action))
741
+
742
+ def _role_for(self, action: PaletteAction) -> str | None:
743
+ task_id = self._request.task_id
744
+ if self._request.role is not None or task_id is None or action not in ROLE_ACTIONS:
745
+ return self._request.role
746
+ selected = OperatorTaskAction(action.value)
747
+ default = self._queries.task_action_role(task_id)
748
+ for role in (default, *(role for role in TASK_ACTION_ROLES if role is not default)):
749
+ state = next(
750
+ item
751
+ for item in self._queries.task_action_availability(task_id, role)
752
+ if item.action is selected
753
+ )
754
+ if state.enabled:
755
+ return role.value if role is not default else None
756
+ return None
757
+
758
+ def _popup_closed(self, queued: str | None) -> None:
759
+ self._schedule_sync()
760
+ if queued is None:
761
+ return
762
+ self._confirmed = queued
763
+ if self._confirmation_timer is not None:
764
+ self._confirmation_timer.stop()
765
+ self._confirmation_timer = self.set_timer(
766
+ self._confirmation_seconds, self._end_confirmation
767
+ )
768
+
769
+ def _end_confirmation(self) -> None:
770
+ if self._confirmation_timer is not None:
771
+ self._confirmation_timer.stop()
772
+ self._confirmed = None
773
+ self._confirmation_timer = None
774
+ self._schedule_sync()
775
+
776
+ def _task_chosen(self, task_id: str | None) -> None:
777
+ if task_id is None:
778
+ return
779
+ if task_id != self._request.task_id:
780
+ self._request = replace(self._request, task_id=task_id, role=None)
781
+ self._end_confirmation()
782
+ self._expanded = False
783
+ self._schedule_sync(select_first=True)
784
+
785
+ def _open_resolution(self) -> None:
786
+ escalation_id = self._answerable_escalation_id()
787
+ if escalation_id is None:
788
+ return
789
+ if self._resolution_screen is None:
790
+ raise ConfigError(RESOLUTION_NOT_CONFIGURED)
791
+ self._push_over(self._resolution_screen(escalation_id), self._resolution_closed)
792
+
793
+ def _resolution_closed(self, sent: bool | None) -> None:
794
+ if sent:
795
+ self._finish(None)
796
+
797
+ async def _refresh_after_database_change(self) -> None:
798
+ if self.is_attached:
799
+ self._schedule_sync()