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,1606 @@
1
+ """The shared Textual operator palette for the popup and dashboard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Awaitable, Callable
7
+ from typing import TYPE_CHECKING, ClassVar, cast, get_args
8
+
9
+ from rich.text import Text
10
+ from textual import on
11
+ from textual.binding import Binding
12
+ from textual.containers import Horizontal, Vertical, VerticalScroll
13
+ from textual.content import Content
14
+ from textual.screen import ModalScreen
15
+ from textual.widget import Widget
16
+ from textual.widgets import Button, Input, Label, Rule, Select, Static, TextArea
17
+
18
+ from shikiru.agents.contracts import parse_rung
19
+ from shikiru.config.settings import ComplexityName
20
+ from shikiru.dashboard.contract import (
21
+ PaletteAction,
22
+ PaletteDraft,
23
+ PaletteRequest,
24
+ )
25
+ from shikiru.domain.commands import (
26
+ COMMAND_REJECTED_FALLBACK,
27
+ CommandKind,
28
+ CommandStatus,
29
+ command_rejection_cause,
30
+ )
31
+ from shikiru.domain.tasks import Mode, Role
32
+ from shikiru.errors import ShikiruError
33
+ from shikiru.operator.commands import (
34
+ ACK_TIMEOUT_MESSAGE,
35
+ QUEUED_MESSAGE,
36
+ AckStatus,
37
+ )
38
+ from shikiru.operator.evidence_export import (
39
+ EXPORT_FORMAT_JSON,
40
+ EXPORT_FORMAT_MARKDOWN,
41
+ TaskEvidenceExporter,
42
+ )
43
+ from shikiru.operator.lifecycle import StartResult
44
+ from shikiru.operator.queries import (
45
+ TASK_ACTION_ROLES,
46
+ OperatorTaskAction,
47
+ )
48
+ from shikiru.ui.fold import FoldLine, fold_arrow
49
+ from shikiru.ui.keys import key_hint
50
+ from shikiru.ui.popup import (
51
+ POPUP_BODY_CLASS,
52
+ POPUP_CLOSE_CLASS,
53
+ POPUP_COMPACT_CONTROL_CSS,
54
+ POPUP_CSS,
55
+ POPUP_EDIT_CLASS,
56
+ POPUP_FRAME_CLASS,
57
+ POPUP_HEADER_CLASS,
58
+ POPUP_RULE_CLASS,
59
+ POPUP_TITLE_CLASS,
60
+ PopupFrame,
61
+ popup_close_label,
62
+ prepare_popup,
63
+ )
64
+ from shikiru.ui.screen import widget_is_attached
65
+ from shikiru.ui.tokens import (
66
+ AMBER,
67
+ DUSTY_ROSE,
68
+ SECONDARY_TEXT_COLOR,
69
+ STYLE_BOLD,
70
+ SUCCESS_COLOR,
71
+ )
72
+
73
+ if TYPE_CHECKING:
74
+ from textual import events
75
+ from textual.app import App, ComposeResult
76
+ from textual.timer import Timer
77
+ from textual.worker import Worker
78
+
79
+ from shikiru.agents.contracts import Rung
80
+ from shikiru.dashboard.contract import OnChangeRegistration
81
+ from shikiru.domain.commands import Command
82
+ from shikiru.operator.commands import Ack, OperatorCommands
83
+ from shikiru.operator.evidence_export import EvidenceFormat
84
+ from shikiru.operator.queries import (
85
+ CommandHistoryEntry,
86
+ OperatorQueries,
87
+ TaskActionAvailability,
88
+ )
89
+
90
+
91
+ _ACTION_SUBMIT = PaletteAction.SUBMIT.value
92
+ _ACTION_PRIORITIZE = PaletteAction.PRIORITIZE.value
93
+ _ACTION_PAUSE = PaletteAction.PAUSE.value
94
+ _ACTION_PAUSE_NOW = PaletteAction.PAUSE_NOW.value
95
+ _ACTION_RESUME = PaletteAction.RESUME.value
96
+ _ACTION_TAKE_OVER = PaletteAction.TAKE_OVER.value
97
+ _ACTION_HAND_BACK = PaletteAction.HAND_BACK.value
98
+ _ACTION_CANCEL = PaletteAction.CANCEL.value
99
+ _ACTION_RETRY = PaletteAction.RETRY.value
100
+ _ACTION_RUNG = PaletteAction.RUNG.value
101
+ _ACTION_MESSAGE = PaletteAction.MESSAGE.value
102
+ _ACTION_PEEK = PaletteAction.PEEK.value
103
+ _ACTION_STOP = PaletteAction.STOP.value
104
+ _ACTION_START = PaletteAction.START.value
105
+ _ACTION_EXPORT = PaletteAction.EXPORT.value
106
+ _FIELD_EXPORT_DESTINATION = "export-destination"
107
+ _FIELD_EXPORT_FORMAT = "export-format"
108
+ _FIELD_EXPORT_OVERWRITE = "export-overwrite"
109
+ _FIELD_EXPORT_SIGNALS = "export-signals"
110
+ _EXPORT_REFUSE = "refuse"
111
+ _EXPORT_OVERWRITE = "overwrite"
112
+ _EXPORT_SIGNALS_EXCLUDE = "exclude"
113
+ _EXPORT_SIGNALS_INCLUDE = "include"
114
+ _EXPORT_OVERWRITE_REQUIRED = "choose how to handle an existing destination"
115
+ _EXPORT_NOT_CONFIGURED = "task evidence export is not configured"
116
+ _TASK_ACTIONS = frozenset(
117
+ {
118
+ _ACTION_PRIORITIZE,
119
+ _ACTION_PAUSE,
120
+ _ACTION_PAUSE_NOW,
121
+ _ACTION_RESUME,
122
+ _ACTION_TAKE_OVER,
123
+ _ACTION_HAND_BACK,
124
+ _ACTION_CANCEL,
125
+ _ACTION_RETRY,
126
+ _ACTION_RUNG,
127
+ _ACTION_MESSAGE,
128
+ }
129
+ )
130
+ _TASK_TARGET_ACTIONS = _TASK_ACTIONS | {_ACTION_PEEK, _ACTION_EXPORT}
131
+ SUBMIT_ACTION_LABELS = {
132
+ _ACTION_SUBMIT: "Submit task",
133
+ _ACTION_PRIORITIZE: "Priority",
134
+ _ACTION_PAUSE: "Pause",
135
+ _ACTION_PAUSE_NOW: "Pause now",
136
+ _ACTION_RESUME: "Resume",
137
+ _ACTION_TAKE_OVER: "Take over",
138
+ _ACTION_HAND_BACK: "Hand back",
139
+ _ACTION_CANCEL: "Cancel",
140
+ _ACTION_RETRY: "Retry",
141
+ _ACTION_RUNG: "Rung",
142
+ _ACTION_MESSAGE: "Message",
143
+ _ACTION_PEEK: "Open session",
144
+ _ACTION_EXPORT: "Export evidence",
145
+ _ACTION_STOP: "Stop engine",
146
+ _ACTION_START: "Start engine",
147
+ }
148
+ _ENTER_HINT_VERBS = {
149
+ _ACTION_EXPORT: "export evidence",
150
+ _ACTION_PAUSE: "pause",
151
+ _ACTION_RESUME: "resume",
152
+ _ACTION_CANCEL: "cancel",
153
+ _ACTION_TAKE_OVER: "take over",
154
+ _ACTION_HAND_BACK: "hand back",
155
+ _ACTION_RETRY: "retry",
156
+ _ACTION_PAUSE_NOW: "pause now",
157
+ _ACTION_PRIORITIZE: "set priority",
158
+ _ACTION_RUNG: "set rung",
159
+ _ACTION_PEEK: "open session",
160
+ _ACTION_STOP: "stop engine",
161
+ _ACTION_START: "start engine",
162
+ }
163
+ _ENTER_HINT_ACTIONS = frozenset(_ENTER_HINT_VERBS)
164
+ _HINT_LABELS = {
165
+ _ACTION_SUBMIT: ("Ctrl+S submit task \N{MIDDLE DOT} Enter new line", "Ctrl+S submit"),
166
+ _ACTION_MESSAGE: ("Ctrl+S queue message \N{MIDDLE DOT} Enter new line", "Ctrl+S queue"),
167
+ **{action: (f"Enter {verb}", f"Enter {verb}") for action, verb in _ENTER_HINT_VERBS.items()},
168
+ }
169
+ _HINT_CLASS = "hint"
170
+ _CONFIRM_HINTS = {
171
+ _ACTION_CANCEL: ("Enter again to cancel", "Enter again"),
172
+ _ACTION_RETRY: ("Enter again to retry", "Enter again"),
173
+ _ACTION_STOP: ("Enter again to stop engine", "Enter again"),
174
+ }
175
+ _HINT_KEY_ACTIONS = {
176
+ "confirm": _ENTER_HINT_ACTIONS,
177
+ "queue": frozenset({_ACTION_MESSAGE, _ACTION_SUBMIT}),
178
+ }
179
+ _CONFIRM_TWICE_ACTIONS = frozenset(_CONFIRM_HINTS)
180
+ _CLEAR_AFTER_QUEUE_ACTIONS = frozenset({_ACTION_MESSAGE})
181
+ _CONFIRMED_ACTIONS = _ENTER_HINT_ACTIONS | _CLEAR_AFTER_QUEUE_ACTIONS | {_ACTION_SUBMIT}
182
+ _CONFIRMATION_GLYPH = "\N{CHECK MARK}"
183
+ _ENTER_KEY = "enter"
184
+ _TASK_ROLES = TASK_ACTION_ROLES
185
+ _ROLE_OPTIONS = tuple((role.value.title(), role.value) for role in _TASK_ROLES)
186
+ _EMPTY = ""
187
+ _MODE_OPTIONS = (
188
+ ("Auto detect", _EMPTY),
189
+ ("Implement", Mode.IMPLEMENT.value),
190
+ ("Plan", Mode.PLAN.value),
191
+ )
192
+ _COMPLEXITY_OPTIONS = (
193
+ ("Triage decides", _EMPTY),
194
+ ("Low", "low"),
195
+ ("Normal", "normal"),
196
+ ("High", "high"),
197
+ )
198
+ _FIELD_ROLE = "role"
199
+ _FIELD_PRIORITY = "priority"
200
+ _FIELD_REQUEST = "request"
201
+ _FIELD_TITLE = "title"
202
+ _FIELD_MODE = "mode"
203
+ _FIELD_COMPLEXITY = "complexity"
204
+ _FIELD_SUBMISSION_PRIORITY = "submission-priority"
205
+ _FIELD_DEPENDENCIES = "dependencies"
206
+ _FIELD_LEADER_RUNG = "leader-rung"
207
+ _FIELD_IMPLEMENTER_RUNG = "implementer-rung"
208
+ _FIELD_RUNG = "rung"
209
+ _FIELD_MESSAGE = "message"
210
+ _ROLE_FIRST_ACTIONS = frozenset({_ACTION_MESSAGE, _ACTION_TAKE_OVER, _ACTION_HAND_BACK})
211
+ _INITIAL_FIELD_BY_ACTION = {
212
+ _ACTION_SUBMIT: _FIELD_REQUEST,
213
+ _ACTION_PRIORITIZE: _FIELD_PRIORITY,
214
+ _ACTION_RUNG: _FIELD_RUNG,
215
+ _ACTION_MESSAGE: _FIELD_ROLE,
216
+ _ACTION_TAKE_OVER: _FIELD_ROLE,
217
+ _ACTION_HAND_BACK: _FIELD_ROLE,
218
+ _ACTION_PEEK: _FIELD_ROLE,
219
+ _ACTION_EXPORT: _FIELD_EXPORT_DESTINATION,
220
+ }
221
+ _EXPORT_FORMAT_OPTIONS = (
222
+ ("Markdown", EXPORT_FORMAT_MARKDOWN),
223
+ ("JSON", EXPORT_FORMAT_JSON),
224
+ )
225
+ _EXPORT_OVERWRITE_OPTIONS = (
226
+ ("Refuse", _EXPORT_REFUSE),
227
+ ("Replace", _EXPORT_OVERWRITE),
228
+ )
229
+ _EXPORT_SIGNALS_OPTIONS = (
230
+ ("Exclude", _EXPORT_SIGNALS_EXCLUDE),
231
+ ("Include", _EXPORT_SIGNALS_INCLUDE),
232
+ )
233
+ _ACTION_STATE_ID = "palette-action-state"
234
+ _GUIDANCE_ID = "palette-guidance"
235
+ _SUBMIT_ID = "palette-submit"
236
+ _OPTIONS_FOLD_ID = "palette-options-fold"
237
+ _OPTIONS_ID = "palette-options"
238
+ _OPTIONS_TITLE = "Options"
239
+ _PALETTE_FORM_ID = "palette-form"
240
+ _PALETTE_CLOSE = "palette-close"
241
+ _PALETTE_ESCAPE_KEY = "escape"
242
+ _PALETTE_SHORT_PANE_MAX_ROWS = 18
243
+ _PALETTE_SHORT_PANE_MAX_COLUMNS = 40
244
+ _PALETTE_WIDE_FIELD_LABEL_COLUMNS = 17
245
+ _PALETTE_CONTROL_MIN_COLUMNS = 8
246
+ _PALETTE_FRAME_BORDER_COLUMNS = 1
247
+ _PALETTE_SHORT_FRAME_PADDING_COLUMNS = 1
248
+ _PALETTE_INLINE_FRAME_HORIZONTAL_COLUMNS = 2 * (
249
+ _PALETTE_FRAME_BORDER_COLUMNS + _PALETTE_SHORT_FRAME_PADDING_COLUMNS
250
+ )
251
+ _PALETTE_STACKED_FIELDS_MAX_COLUMNS = (
252
+ _PALETTE_WIDE_FIELD_LABEL_COLUMNS
253
+ + _PALETTE_CONTROL_MIN_COLUMNS
254
+ + _PALETTE_INLINE_FRAME_HORIZONTAL_COLUMNS
255
+ - 1
256
+ )
257
+ _PALETTE_TINY_PANE_MAX_ROWS = 8
258
+ _PALETTE_ACTION_BUTTON_MIN_COLUMNS = 16
259
+ _PALETTE_COMPACT_TEXT_AREA_MAX_HEIGHT = 3
260
+ _PALETTE_COMPACT_RESULT_MAX_HEIGHT = 3
261
+ _PALETTE_RESULT_MAX_HEIGHT = 12
262
+ _PALETTE_FORM_STANDARD_FIXED_ROWS = 9
263
+ _PALETTE_FORM_COMPACT_FIXED_ROWS = 4
264
+ _PALETTE_FRAME_VERTICAL_MARGIN_ROWS = 2
265
+ _PALETTE_TEXT_AREA_MINIMUM_WIDTH = 5
266
+ _PALETTE_INLINE_ACTIONS_ROWS = 1
267
+ _PALETTE_STACKED_ACTIONS_ROWS = 2
268
+ _EXPORT_DEFAULT_EXTENSION = "md"
269
+ _EXPORT_DESTINATION_PLACEHOLDER = f"T-1-evidence.{_EXPORT_DEFAULT_EXTENSION}"
270
+ _PALETTE_TEXT_AREA_FIELDS = (_FIELD_REQUEST, _FIELD_MESSAGE)
271
+ _TAKEOVER_SELECTION_FAILURE = "take-over succeeded, but the role pane could not be selected"
272
+ _PALETTE_STATE_WORKER = "palette-state-updates"
273
+ _FORM_IDENTITY_FIELDS = {
274
+ _ACTION_SUBMIT: (
275
+ _FIELD_REQUEST,
276
+ _FIELD_TITLE,
277
+ _FIELD_MODE,
278
+ _FIELD_COMPLEXITY,
279
+ _FIELD_SUBMISSION_PRIORITY,
280
+ _FIELD_DEPENDENCIES,
281
+ _FIELD_LEADER_RUNG,
282
+ _FIELD_IMPLEMENTER_RUNG,
283
+ ),
284
+ _ACTION_PRIORITIZE: (_FIELD_PRIORITY,),
285
+ _ACTION_RUNG: (_FIELD_RUNG,),
286
+ _ACTION_MESSAGE: (_FIELD_MESSAGE,),
287
+ _ACTION_EXPORT: (
288
+ _FIELD_EXPORT_DESTINATION,
289
+ _FIELD_EXPORT_FORMAT,
290
+ _FIELD_EXPORT_OVERWRITE,
291
+ _FIELD_EXPORT_SIGNALS,
292
+ ),
293
+ }
294
+ _FORM_ROLE_ACTIONS = frozenset(
295
+ {_ACTION_TAKE_OVER, _ACTION_HAND_BACK, _ACTION_RUNG, _ACTION_MESSAGE, _ACTION_PEEK}
296
+ )
297
+ _REQUIRED_FIELDS = {
298
+ _ACTION_SUBMIT: _FIELD_REQUEST,
299
+ _ACTION_EXPORT: _FIELD_EXPORT_DESTINATION,
300
+ _ACTION_MESSAGE: _FIELD_MESSAGE,
301
+ _ACTION_PRIORITIZE: _FIELD_PRIORITY,
302
+ _ACTION_RUNG: _FIELD_RUNG,
303
+ }
304
+ _ACTION_GUIDANCE = {
305
+ _ACTION_PRIORITIZE: "Changes dispatch order while the task is queued.",
306
+ _ACTION_PAUSE: "Holds the task before its next turn or integration effect.",
307
+ _ACTION_PAUSE_NOW: "Stops the current turn and cancels its step, then holds the task.",
308
+ _ACTION_RESUME: (
309
+ "Clears task holds; dependencies, slots and other scheduling limits still apply."
310
+ ),
311
+ _ACTION_TAKE_OVER: (
312
+ "Stops the running turn and holds this task while you work in the role's pane. "
313
+ "Other tasks continue."
314
+ ),
315
+ _ACTION_HAND_BACK: (
316
+ "Returns this role to automation; another taken-over role or pause can still hold the task."
317
+ ),
318
+ _ACTION_CANCEL: (
319
+ "Cancels only this task and keeps its branch. Dirty or off-branch worktrees are preserved "
320
+ "with their path returned; a clean worktree is removed during cleanup."
321
+ ),
322
+ _ACTION_RETRY: "Cancels the current step and runs it again, closing any open decision.",
323
+ _ACTION_RUNG: "The rung is the agent:model:effort level a role uses from its next turn.",
324
+ _ACTION_MESSAGE: (
325
+ "Queues guidance for this role's next turn; it does not interrupt a running turn."
326
+ ),
327
+ _ACTION_PEEK: "Shows recorded session activity for this task and role.",
328
+ _ACTION_STOP: (
329
+ "Stops the engine for every task. Running turns are stopped and continue when the "
330
+ "engine starts again."
331
+ ),
332
+ _ACTION_START: "Starts the engine and ensures its session without attaching.",
333
+ _ACTION_EXPORT: (
334
+ "Writes retained evidence to a local file; relative paths start at the project root, and "
335
+ "parent folders must exist."
336
+ ),
337
+ }
338
+ _TASK_ACTION_BY_ID = {action.value: action for action in OperatorTaskAction}
339
+ _FIELD_CLASS = "palette-field"
340
+ _ROW_CLASS = "palette-row"
341
+ _WIDE_CLASS = "palette-wide"
342
+ _NO_TASK_TARGET = "Choose a task target first"
343
+
344
+ PaneSelector = Callable[[str], Awaitable[None]]
345
+ StartEngine = Callable[[], Awaitable[StartResult]]
346
+ _START_NOT_CONFIGURED = "project start is not configured"
347
+ _ENGINE_ALREADY_RUNNING = "engine is already running"
348
+ _ENGINE_ALREADY_STOPPED = "engine is already stopped"
349
+
350
+
351
+ def _field_classes(*, inline: bool, wide: bool = False) -> str:
352
+ classes = f"{_FIELD_CLASS} {_ROW_CLASS}" if inline else _FIELD_CLASS
353
+ return f"{classes} {_WIDE_CLASS}" if wide else classes
354
+
355
+
356
+ def _default_export_destination(task_id: str) -> str:
357
+ return f"{task_id}-evidence.{_EXPORT_DEFAULT_EXTENSION}"
358
+
359
+
360
+ class PaletteScreen(ModalScreen[str | None]):
361
+ """Show one preset action popup; it dismisses with the action name once it queued it."""
362
+
363
+ AUTO_FOCUS = ""
364
+ BINDINGS: ClassVar[list[Binding]] = [
365
+ Binding(_ENTER_KEY, "confirm", "Confirm", show=False),
366
+ Binding("ctrl+s", "queue", "Queue", show=False),
367
+ ]
368
+ DEFAULT_CSS = (
369
+ POPUP_CSS
370
+ + """
371
+ #palette-frame.short { margin: 0 1; padding: 0 1; }
372
+ #palette-frame.stack-fields { padding: 1 0; }
373
+ #palette-frame.tiny { margin: 0; padding: 0; }
374
+ #palette-frame.tiny #palette-close { padding: 0; }
375
+ #palette-frame.stack-fields .palette-row { layout: vertical; }
376
+ #palette-frame.stack-fields .palette-row Label { width: 100%; }
377
+ #palette-frame.stack-fields .palette-row SelectCurrent { padding: 0; }
378
+ #palette-close {
379
+ width: auto; min-width: 5; height: 1; margin: 0;
380
+ }
381
+ #palette-form {
382
+ height: auto; max-height: 1fr; min-height: 0; margin-top: 0; padding-top: 0;
383
+ border-top: none; background: $surface;
384
+ }
385
+ #field-export-destination { margin-top: 1; }
386
+ #field-role, #field-priority { margin-top: 1; }
387
+ .palette-field { height: auto; margin-bottom: 1; background: $surface; }
388
+ .palette-field Label { height: 1; color: $secondary-text; text-style: none; }
389
+ .palette-field Input, .palette-field Select { width: 100%; }
390
+ .palette-field Input:focus {
391
+ border: round $focus;
392
+ text-style: reverse;
393
+ }
394
+ """
395
+ + POPUP_COMPACT_CONTROL_CSS
396
+ + """
397
+ #palette-options-fold { height: auto; margin: 0 0 1 0; color: $secondary-text; }
398
+ #palette-options-fold:focus { background: $selected-surface; background-tint: transparent; }
399
+ #palette-options { height: auto; margin-top: 1; }
400
+ .palette-note { height: auto; color: $secondary-text; }
401
+ #palette-result-scroll { display: none; height: auto; max-height: 12; margin-top: 1; }
402
+ #palette-frame.short #palette-result-scroll { margin-top: 0; }
403
+ #palette-result { height: auto; }
404
+ #palette-submit {
405
+ height: 1; min-height: 1; margin: 0 0 0 1; padding: 0 1; border: none;
406
+ text-style: none;
407
+ }
408
+ #palette-actions {
409
+ layout: horizontal; width: 100%; height: auto; margin-top: 1; align-horizontal: right;
410
+ }
411
+ #palette-actions.short { margin-top: 0; }
412
+ #palette-actions.hint { align-horizontal: left; }
413
+ #palette-actions.hint #palette-submit {
414
+ width: 100%; min-width: 1; height: auto; margin: 0; padding: 0;
415
+ border: none; border-top: none; border-bottom: none;
416
+ background: $surface; color: $secondary-text; text-opacity: 1; text-wrap: wrap;
417
+ content-align: left middle; text-align: left;
418
+ }
419
+ #palette-actions.hint #palette-submit:disabled { color: $secondary-text; text-opacity: 1; }
420
+ #palette-actions Button {
421
+ width: auto; max-width: 100%; height: auto; min-height: 1; padding: 0;
422
+ border: none; border-top: none; border-bottom: none;
423
+ text-wrap: wrap; text-style: none;
424
+ }
425
+ #palette-submit:focus { text-style: reverse; }
426
+ """
427
+ + f"""
428
+ .palette-wide Label {{ width: {_PALETTE_WIDE_FIELD_LABEL_COLUMNS}; }}
429
+ .palette-row Input, .palette-row Select {{ min-width: {_PALETTE_CONTROL_MIN_COLUMNS}; }}
430
+ """
431
+ )
432
+
433
+ def __init__(
434
+ self,
435
+ commands: OperatorCommands,
436
+ queries: OperatorQueries,
437
+ pane_selector: PaneSelector,
438
+ *,
439
+ on_change: OnChangeRegistration,
440
+ action_confirmation_seconds: float,
441
+ initial_context: PaletteRequest,
442
+ start_engine: StartEngine | None = None,
443
+ evidence_exporter: TaskEvidenceExporter | None = None,
444
+ remember_contextual_draft: Callable[[PaletteDraft], None] | None = None,
445
+ ) -> None:
446
+ """Use the operator services for actions and queries and inject pane selection."""
447
+ super().__init__()
448
+ prepare_popup(self)
449
+ self._commands = commands
450
+ self._queries = queries
451
+ self._initial_context = initial_context
452
+ self._action = initial_context.action or _EMPTY
453
+ self._task_id = initial_context.task_id or _EMPTY
454
+ self._pane_selector = pane_selector
455
+ self._on_change = on_change
456
+ self._start_engine = start_engine
457
+ self._evidence_exporter = evidence_exporter
458
+ self._remember_contextual_draft = remember_contextual_draft
459
+ self._action_confirmation_seconds = action_confirmation_seconds
460
+ self._confirmed_action: str | None = None
461
+ self._confirmation_timer: Timer | None = None
462
+ self._role_is_explicit = initial_context.role is not None
463
+ self._action_states: dict[str, TaskActionAvailability] = {}
464
+ self._route_error: str | None = None
465
+ self._options_expanded = False
466
+ self._action_in_flight = False
467
+ self._confirm_armed = False
468
+ self._closing_after_queue = False
469
+ self._result_reveal_revision = 0
470
+ self._pending_form_commands: dict[tuple[str, ...], int] = {}
471
+ self._submitting_form_identity: tuple[str, ...] | None = None
472
+ self._command_action = _EMPTY
473
+ self._command_target = _EMPTY
474
+ self._action_worker: Worker[None] | None = None
475
+ self._change_worker: Worker[None] | None = None
476
+ self._form_scroll_y = 0
477
+ self._form_focus_id: str | None = None
478
+ self._layout_form_max_height = 0
479
+ self._restored_contextual_draft_actions: set[str] = set()
480
+
481
+ def compose(self) -> ComposeResult:
482
+ """Show one compact form with fields enabled for the selected action."""
483
+ with PopupFrame(widget_id="palette-frame", classes=POPUP_FRAME_CLASS):
484
+ with Horizontal(id="palette-heading", classes=POPUP_HEADER_CLASS):
485
+ yield Label("Actions", id="palette-title", classes=POPUP_TITLE_CLASS)
486
+ yield Button(
487
+ popup_close_label(),
488
+ id=_PALETTE_CLOSE,
489
+ classes=POPUP_CLOSE_CLASS,
490
+ )
491
+ yield Rule(classes=POPUP_RULE_CLASS)
492
+ yield from self._compose_form()
493
+ with Vertical(id="palette-actions"):
494
+ yield Button("Submit", id=_SUBMIT_ID, variant="primary")
495
+
496
+ def _compose_form(self) -> ComposeResult:
497
+ with VerticalScroll(id=_PALETTE_FORM_ID, classes=POPUP_BODY_CLASS):
498
+ yield Static("", id=_GUIDANCE_ID, markup=False)
499
+ yield from self._compose_task_submission_fields()
500
+ yield from self._compose_task_role_fields()
501
+ yield Static("", id=_ACTION_STATE_ID, markup=False)
502
+ with VerticalScroll(id="palette-result-scroll", classes=POPUP_BODY_CLASS):
503
+ yield Static("", id="palette-result", markup=False)
504
+
505
+ def _compose_task_submission_fields(self) -> ComposeResult:
506
+ yield from self._text_area_field("Request", "request", "Describe the work")
507
+ yield FoldLine("", id=_OPTIONS_FOLD_ID, markup=False)
508
+ with Vertical(id=_OPTIONS_ID):
509
+ yield from self._input_field(
510
+ "Title (optional)", _FIELD_TITLE, "Task title", inline=True, wide=True
511
+ )
512
+ yield from self._select_field(
513
+ "Mode", _FIELD_MODE, _MODE_OPTIONS, value=_EMPTY, inline=True, wide=True
514
+ )
515
+ yield from self._select_field(
516
+ "Complexity",
517
+ _FIELD_COMPLEXITY,
518
+ _COMPLEXITY_OPTIONS,
519
+ value=_EMPTY,
520
+ inline=True,
521
+ wide=True,
522
+ )
523
+ yield from self._input_field(
524
+ "Priority", _FIELD_SUBMISSION_PRIORITY, "0", inline=True, wide=True
525
+ )
526
+ yield from self._input_field(
527
+ "Dependencies", _FIELD_DEPENDENCIES, "T-1, T-2", inline=True, wide=True
528
+ )
529
+ yield from self._input_field(
530
+ "Leader rung", _FIELD_LEADER_RUNG, "agent:model:effort", inline=True, wide=True
531
+ )
532
+ yield from self._input_field(
533
+ "Implementer rung",
534
+ _FIELD_IMPLEMENTER_RUNG,
535
+ "agent:model:effort",
536
+ inline=True,
537
+ wide=True,
538
+ )
539
+ yield from self._input_field(
540
+ "Destination",
541
+ _FIELD_EXPORT_DESTINATION,
542
+ _EXPORT_DESTINATION_PLACEHOLDER,
543
+ inline=True,
544
+ wide=True,
545
+ )
546
+ yield from self._select_field(
547
+ "Format",
548
+ _FIELD_EXPORT_FORMAT,
549
+ _EXPORT_FORMAT_OPTIONS,
550
+ value=EXPORT_FORMAT_MARKDOWN,
551
+ inline=True,
552
+ wide=True,
553
+ )
554
+ yield from self._select_field(
555
+ "Existing file",
556
+ _FIELD_EXPORT_OVERWRITE,
557
+ _EXPORT_OVERWRITE_OPTIONS,
558
+ value=_EXPORT_REFUSE,
559
+ inline=True,
560
+ wide=True,
561
+ )
562
+ yield from self._select_field(
563
+ "Agent signals",
564
+ _FIELD_EXPORT_SIGNALS,
565
+ _EXPORT_SIGNALS_OPTIONS,
566
+ value=_EXPORT_SIGNALS_EXCLUDE,
567
+ inline=True,
568
+ wide=True,
569
+ )
570
+
571
+ def _compose_task_role_fields(self) -> ComposeResult:
572
+ yield from self._input_field("Priority", _FIELD_PRIORITY, "0", inline=True)
573
+ yield from self._select_field(
574
+ "Role", _FIELD_ROLE, _ROLE_OPTIONS, value=Role.LEADER.value, inline=True
575
+ )
576
+ yield from self._input_field("Rung", _FIELD_RUNG, "agent:model:effort", inline=True)
577
+ yield from self._text_area_field("Message", "message", "Message for the next turn")
578
+
579
+ def on_descendant_focus(self, event: events.DescendantFocus) -> None:
580
+ parent = event.widget.parent
581
+ while parent is not None:
582
+ if parent.id == _PALETTE_FORM_ID:
583
+ self.query_one("#palette-form", VerticalScroll).scroll_to_widget(
584
+ event.widget,
585
+ animate=False,
586
+ immediate=True,
587
+ force=True,
588
+ )
589
+ return
590
+ parent = parent.parent
591
+
592
+ def on_resize(self, event: events.Resize) -> None:
593
+ if not self.is_mounted or not widget_is_attached(self):
594
+ return
595
+ form = self.query_one("#palette-form", VerticalScroll)
596
+ scroll_y = form.scroll_y
597
+ focused = cast("App[None]", self.app).focused
598
+ result_revision = self._result_reveal_revision
599
+ self._set_text_area_heights(event.size.width, event.size.height)
600
+ self._apply_layout()
601
+ self.call_after_refresh(
602
+ self._restore_form_layout,
603
+ scroll_y,
604
+ focused,
605
+ result_revision,
606
+ )
607
+
608
+ async def on_mount(self) -> None:
609
+ """Activate the action routed from a widget and follow database changes."""
610
+ self._set_text_area_heights(self.size.width, self.size.height)
611
+ self._initialize_action_route()
612
+ self._initialize_form_state()
613
+ self._refresh_action_availability()
614
+ self._apply_layout()
615
+ self.call_after_refresh(self._restore_context_form_position)
616
+ self._change_worker = self.run_worker(
617
+ self._on_change(self._refresh_after_database_change),
618
+ group=_PALETTE_STATE_WORKER,
619
+ )
620
+ if self._route_error is not None:
621
+ self._result_reveal_revision += 1
622
+ self._paint_result(self._route_error)
623
+ self.call_after_refresh(self._reveal_result)
624
+ self.query_one(f"#{_SUBMIT_ID}", Button).disabled = True
625
+
626
+ def _initialize_action_route(self) -> None:
627
+ if self._action not in SUBMIT_ACTION_LABELS:
628
+ action = self._action or "<missing>"
629
+ self._route_error = f"unknown palette action route: {action}"
630
+
631
+ def _initialize_form_state(self) -> None:
632
+ cast("Select[str]", self.query_one("#mode", Select)).value = _EMPTY
633
+ cast("Select[str]", self.query_one("#complexity", Select)).value = _EMPTY
634
+ task_id = self._task_id or None
635
+ default_role = (
636
+ self._queries.task_action_role(task_id).value
637
+ if task_id is not None
638
+ else Role.LEADER.value
639
+ )
640
+ role = self._initial_context.role or default_role
641
+ if role not in {item.value for item in _TASK_ROLES}:
642
+ if self._initial_context.role is not None:
643
+ self._route_error = f"unknown role context: {role}"
644
+ role = Role.LEADER.value
645
+ cast("Select[str]", self.query_one(f"#{_FIELD_ROLE}", Select)).value = role
646
+ self.query_one("#priority", Input).value = "0"
647
+ self.query_one("#submission-priority", Input).value = "0"
648
+ self._initialize_export_defaults(task_id)
649
+ self._update_fields()
650
+ self._restore_contextual_form_draft(self._action)
651
+
652
+ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
653
+ del parameters
654
+ hint_actions = _HINT_KEY_ACTIONS.get(action)
655
+ if hint_actions is None:
656
+ return True
657
+ return (
658
+ self._action in hint_actions
659
+ and self._hint_mode()
660
+ and not self.query_one(f"#{_SUBMIT_ID}", Button).disabled
661
+ )
662
+
663
+ def action_confirm(self) -> None:
664
+ """Run the action named by the Enter hint."""
665
+ self._activate_submit()
666
+
667
+ def action_queue(self) -> None:
668
+ """Queue the message named by the Ctrl+S hint."""
669
+ self._activate_submit()
670
+
671
+ def _hint_mode(self) -> bool:
672
+ return self._action in _HINT_LABELS
673
+
674
+ def _confirm_submitted(self, action: str) -> None:
675
+ self._paint_result(_EMPTY)
676
+ if action in _CLEAR_AFTER_QUEUE_ACTIONS:
677
+ self._clear_message()
678
+ self._show_confirmation(action)
679
+ return
680
+ self._closing_after_queue = True
681
+ self.call_later(self._dismiss_queued, action)
682
+
683
+ def _dismiss_queued(self, action: str) -> None:
684
+ self.dismiss(action)
685
+
686
+ def _clear_message(self) -> None:
687
+ message = self.query_one(f"#{_FIELD_MESSAGE}", TextArea)
688
+ message.text = _EMPTY
689
+ message.focus()
690
+
691
+ def _show_confirmation(self, action: str) -> None:
692
+ self._stop_confirmation()
693
+ self._confirmed_action = action
694
+ self._confirmation_timer = self.set_timer(
695
+ self._action_confirmation_seconds, self._end_confirmation
696
+ )
697
+ self._refresh_confirmation()
698
+
699
+ def _end_confirmation(self) -> None:
700
+ self._confirmed_action = None
701
+ self._confirmation_timer = None
702
+ self._refresh_confirmation()
703
+
704
+ def _stop_confirmation(self) -> None:
705
+ if self._confirmation_timer is not None:
706
+ self._confirmation_timer.stop()
707
+ self._confirmed_action = None
708
+ self._confirmation_timer = None
709
+
710
+ def _refresh_confirmation(self) -> None:
711
+ self._refresh_submit()
712
+
713
+ def _marked(self, label: Text, action: str) -> Text:
714
+ if self._confirmed_action != action:
715
+ return label
716
+ return Text.assemble(label, " ", (_CONFIRMATION_GLYPH, f"{STYLE_BOLD} {SUCCESS_COLOR}"))
717
+
718
+ def _activate_submit(self) -> None:
719
+ if self._action_in_flight or self._closing_after_queue:
720
+ return
721
+ confirming = self._action in _CONFIRM_TWICE_ACTIONS and self._hint_mode()
722
+ if confirming and not self._confirm_armed:
723
+ self._confirm_armed = True
724
+ self._refresh_submit()
725
+ return
726
+ self._confirm_armed = False
727
+ self._stop_confirmation()
728
+ self._action_in_flight = True
729
+ self.query_one(f"#{_SUBMIT_ID}", Button).disabled = True
730
+ self._refresh_submit()
731
+ self._action_worker = self.run_worker(self._run_action(), exclusive=True)
732
+
733
+ def _refresh_submit(self) -> None:
734
+ action = self._action
735
+ submit = self.query_one(f"#{_SUBMIT_ID}", Button)
736
+ hint = self._hint_mode()
737
+ tiny = self.size.height <= _PALETTE_TINY_PANE_MAX_ROWS
738
+ self.query_one("#palette-actions").set_class(hint, _HINT_CLASS)
739
+ self._size_action_buttons(hint=hint)
740
+ submit.can_focus = not hint or action in _ENTER_HINT_ACTIONS
741
+ if not hint:
742
+ compact_label = "Queue" if action == _ACTION_MESSAGE else "Submit"
743
+ label = Text(
744
+ compact_label if tiny else SUBMIT_ACTION_LABELS.get(action, "Submit action")
745
+ )
746
+ elif self._confirm_armed:
747
+ label = key_hint(_CONFIRM_HINTS[action][tiny], f"{STYLE_BOLD} {DUSTY_ROSE}")
748
+ else:
749
+ label = key_hint(
750
+ _HINT_LABELS[action][tiny],
751
+ SECONDARY_TEXT_COLOR,
752
+ enabled=not (submit.disabled or self._closing_after_queue),
753
+ )
754
+ marked = self._marked(label, action)
755
+ submit.set_reactive(Button.label, Content.from_text(marked))
756
+ submit.refresh(layout=True)
757
+ submit.styles.height = "auto"
758
+
759
+ def _size_action_buttons(self, *, hint: bool) -> None:
760
+ tiny = self.size.height <= _PALETTE_TINY_PANE_MAX_ROWS
761
+ narrow = self.size.width <= _PALETTE_SHORT_PANE_MAX_COLUMNS
762
+ button = self.query_one(f"#{_SUBMIT_ID}", Button)
763
+ button.styles.width = "100%" if hint or (narrow and not tiny) else "auto"
764
+ button.styles.min_width = 1 if tiny or hint else _PALETTE_ACTION_BUTTON_MIN_COLUMNS
765
+ button.styles.margin = 0 if narrow or hint else (0, 0, 0, 1)
766
+ button.styles.height = 1 if narrow and (tiny or not hint) else "auto"
767
+ button.styles.line_pad = 0 if hint else None
768
+
769
+ async def on_key(self, event: events.Key) -> None:
770
+ if event.key == _ENTER_KEY:
771
+ submit = self.query_one(f"#{_SUBMIT_ID}", Button)
772
+ if self._confirm_armed or (self._hint_mode() and self.focused is submit):
773
+ event.stop()
774
+ event.prevent_default()
775
+ self._activate_submit()
776
+ return
777
+ if self._confirm_armed and event.key != _ENTER_KEY:
778
+ self._confirm_armed = False
779
+ self._refresh_submit()
780
+ if event.key == _PALETTE_ESCAPE_KEY:
781
+ event.stop()
782
+ event.prevent_default()
783
+ return
784
+ if event.key != _PALETTE_ESCAPE_KEY:
785
+ return
786
+ if any(select.expanded for select in self.query(Select[str])):
787
+ return
788
+ await self.action_close()
789
+ event.stop()
790
+ event.prevent_default()
791
+
792
+ def _set_text_area_heights(self, width: int, height: int) -> None:
793
+ compact = height <= _PALETTE_SHORT_PANE_MAX_ROWS
794
+ compact_layout = compact or width <= _PALETTE_SHORT_PANE_MAX_COLUMNS
795
+ self.query_one("#palette-result-scroll").styles.max_height = (
796
+ _PALETTE_COMPACT_RESULT_MAX_HEIGHT if compact_layout else _PALETTE_RESULT_MAX_HEIGHT
797
+ )
798
+ for field in _PALETTE_TEXT_AREA_FIELDS:
799
+ self.query_one(f"#{field}", TextArea).styles.max_height = (
800
+ _PALETTE_COMPACT_TEXT_AREA_MAX_HEIGHT if compact else None
801
+ )
802
+
803
+ def _initialize_export_defaults(self, task_id: str | None) -> None:
804
+ if task_id is not None:
805
+ self.query_one(
806
+ f"#{_FIELD_EXPORT_DESTINATION}", Input
807
+ ).value = _default_export_destination(task_id)
808
+ cast(
809
+ "Select[str]", self.query_one(f"#{_FIELD_EXPORT_FORMAT}", Select)
810
+ ).value = EXPORT_FORMAT_MARKDOWN
811
+ cast(
812
+ "Select[str]", self.query_one(f"#{_FIELD_EXPORT_OVERWRITE}", Select)
813
+ ).value = _EXPORT_REFUSE
814
+ cast(
815
+ "Select[str]", self.query_one(f"#{_FIELD_EXPORT_SIGNALS}", Select)
816
+ ).value = _EXPORT_SIGNALS_EXCLUDE
817
+
818
+ def _restore_form_layout(
819
+ self,
820
+ scroll_y: float,
821
+ focused: Widget | None,
822
+ result_revision: int,
823
+ ) -> None:
824
+ if not self.is_mounted or not widget_is_attached(self):
825
+ return
826
+ if self._fit_form_to_submit_height():
827
+ self.call_after_refresh(
828
+ self._restore_form_layout,
829
+ scroll_y,
830
+ focused,
831
+ result_revision,
832
+ )
833
+ return
834
+ if self._result_reveal_revision != result_revision:
835
+ return
836
+ form = self.query_one("#palette-form", VerticalScroll)
837
+ if focused is not None and not self.query_one("#palette-result-scroll").display:
838
+ parent = focused.parent
839
+ while parent is not None:
840
+ if parent.id == _PALETTE_FORM_ID:
841
+ form.scroll_to_widget(focused, animate=False, immediate=True)
842
+ return
843
+ parent = parent.parent
844
+ form.scroll_to(y=scroll_y, animate=False, immediate=True)
845
+
846
+ async def on_select_changed(self, event: Select.Changed) -> None:
847
+ """Refresh visible fields when a selection changes."""
848
+ if event.select.id == _FIELD_ROLE:
849
+ if not self._role_is_explicit and event.select.has_focus:
850
+ self._role_is_explicit = True
851
+ self._refresh_action_availability()
852
+ elif event.select.id in {
853
+ _FIELD_EXPORT_FORMAT,
854
+ _FIELD_EXPORT_OVERWRITE,
855
+ _FIELD_EXPORT_SIGNALS,
856
+ }:
857
+ self._refresh_action_availability()
858
+
859
+ async def on_input_changed(self, event: Input.Changed) -> None:
860
+ """Refresh current eligibility when a field changes."""
861
+ del event
862
+ self._refresh_action_availability()
863
+
864
+ def on_input_submitted(self) -> None:
865
+ """Run the Enter hint's action when a field of its popup is submitted."""
866
+ if self.check_action("confirm", ()):
867
+ self.action_confirm()
868
+
869
+ async def on_text_area_changed(self, event: TextArea.Changed) -> None:
870
+ if event.text_area.id in {_FIELD_REQUEST, _FIELD_MESSAGE}:
871
+ self._refresh_action_availability()
872
+
873
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
874
+ """Run the selected action in a worker so the popup stays responsive."""
875
+ button_id = event.button.id or _EMPTY
876
+ if button_id == _PALETTE_CLOSE:
877
+ await self.action_close()
878
+ elif button_id == _SUBMIT_ID:
879
+ self._activate_submit()
880
+
881
+ @on(FoldLine.Toggled)
882
+ def _options_toggled(self, message: FoldLine.Toggled) -> None:
883
+ message.stop()
884
+ self.action_toggle_options()
885
+
886
+ def action_toggle_options(self) -> None:
887
+ if self._action != _ACTION_SUBMIT:
888
+ return
889
+ self._options_expanded = not self._options_expanded
890
+ self.query_one(f"#{_OPTIONS_ID}").display = self._options_expanded
891
+ self._paint_options_fold()
892
+
893
+ def on_unmount(self) -> None:
894
+ worker = self._action_worker
895
+ if worker is not None and worker.is_running:
896
+ worker.cancel()
897
+ if self._change_worker is not None:
898
+ self._change_worker.cancel()
899
+
900
+ async def action_close(self) -> None:
901
+ """Dismiss the popup, keeping the Message draft."""
902
+ self._remember_form_draft()
903
+ self.dismiss(None)
904
+
905
+ def _apply_layout(self) -> None:
906
+ frame = self.query_one("#palette-frame", PopupFrame)
907
+ form = self.query_one(f"#{_PALETTE_FORM_ID}", VerticalScroll)
908
+ tiny_pane = self.size.height <= _PALETTE_TINY_PANE_MAX_ROWS
909
+ short_pane = (
910
+ self.size.height <= _PALETTE_SHORT_PANE_MAX_ROWS
911
+ or self.size.width <= _PALETTE_SHORT_PANE_MAX_COLUMNS
912
+ )
913
+ narrow_pane = self.size.width <= _PALETTE_SHORT_PANE_MAX_COLUMNS
914
+ frame.styles.max_height = max(
915
+ 1,
916
+ self.size.height - (0 if short_pane else _PALETTE_FRAME_VERTICAL_MARGIN_ROWS),
917
+ )
918
+ actions = self.query_one("#palette-actions", Vertical)
919
+ actions.styles.layout = "horizontal" if tiny_pane or not narrow_pane else "vertical"
920
+ actions.set_class(short_pane, "short")
921
+ frame.set_class(short_pane, "short")
922
+ frame.set_class(tiny_pane, "tiny")
923
+ frame.set_class(
924
+ self.size.width <= _PALETTE_STACKED_FIELDS_MAX_COLUMNS,
925
+ "stack-fields",
926
+ )
927
+ form.set_class(tiny_pane, "tiny")
928
+ fixed_rows = (
929
+ _PALETTE_FORM_COMPACT_FIXED_ROWS if short_pane else _PALETTE_FORM_STANDARD_FIXED_ROWS
930
+ )
931
+ action_rows = (
932
+ _PALETTE_STACKED_ACTIONS_ROWS
933
+ if narrow_pane and not tiny_pane
934
+ else _PALETTE_INLINE_ACTIONS_ROWS
935
+ )
936
+ self._layout_form_max_height = max(1, self.size.height - fixed_rows - action_rows)
937
+ form.styles.max_height = self._layout_form_max_height
938
+ self._refresh_submit()
939
+ self.query_one("#palette-title", Label).update(self._context_form_title())
940
+ self.call_after_refresh(self._refresh_context_form_title)
941
+
942
+ def _fit_form_to_submit_height(self) -> bool:
943
+ if (
944
+ self.size.height <= _PALETTE_TINY_PANE_MAX_ROWS
945
+ or self.size.width > _PALETTE_SHORT_PANE_MAX_COLUMNS
946
+ ):
947
+ return False
948
+ button = self.query_one(f"#{_SUBMIT_ID}", Button)
949
+ fixed_rows = (
950
+ _PALETTE_FORM_COMPACT_FIXED_ROWS
951
+ if self.size.height <= _PALETTE_SHORT_PANE_MAX_ROWS
952
+ else _PALETTE_FORM_STANDARD_FIXED_ROWS
953
+ )
954
+ max_height = max(
955
+ 1,
956
+ self.size.height
957
+ - fixed_rows
958
+ - max(_PALETTE_STACKED_ACTIONS_ROWS, button.region.height),
959
+ )
960
+ if max_height >= self._layout_form_max_height:
961
+ return False
962
+ self._layout_form_max_height = max_height
963
+ self.query_one(f"#{_PALETTE_FORM_ID}", VerticalScroll).styles.max_height = max_height
964
+ return True
965
+
966
+ def _context_form_title(self) -> str:
967
+ action = self._action
968
+ label = SUBMIT_ACTION_LABELS.get(action, "Actions")
969
+ task_id = self._initial_context.task_id
970
+ if task_id is None or action in {_ACTION_SUBMIT, _ACTION_STOP, _ACTION_START}:
971
+ return label
972
+ return f"{label} \N{MIDDLE DOT} {task_id}"
973
+
974
+ def _paint_options_fold(self) -> None:
975
+ label = Text(
976
+ f"{fold_arrow(expanded=self._options_expanded)} {_OPTIONS_TITLE}",
977
+ style=SECONDARY_TEXT_COLOR,
978
+ )
979
+ self.query_one(f"#{_OPTIONS_FOLD_ID}", FoldLine).update(label)
980
+
981
+ def _refresh_context_form_title(self) -> None:
982
+ if not self.is_mounted or not widget_is_attached(self):
983
+ return
984
+ self.query_one("#palette-title", Label).update(self._context_form_title())
985
+
986
+ def _remember_form_draft(self) -> None:
987
+ task_id = self._initial_context.task_id
988
+ action = self._action
989
+ if task_id is None or action != _ACTION_MESSAGE:
990
+ return
991
+ role = self._selected_value(_FIELD_ROLE)
992
+ if self._remember_contextual_draft is not None:
993
+ self._remember_contextual_draft(
994
+ PaletteDraft(
995
+ task_id=task_id,
996
+ action=action,
997
+ guidance=self._text(_FIELD_MESSAGE),
998
+ role=role,
999
+ role_explicit=self._role_is_explicit,
1000
+ )
1001
+ )
1002
+
1003
+ def _restore_contextual_form_draft(self, action: str | None) -> None:
1004
+ if action is None or action in self._restored_contextual_draft_actions:
1005
+ return
1006
+ drafts = tuple(
1007
+ draft
1008
+ for draft in self._initial_context.drafts
1009
+ if draft.task_id == self._initial_context.task_id and draft.action == action
1010
+ )
1011
+ if self._initial_context.role is None:
1012
+ draft = drafts[-1] if drafts else None
1013
+ else:
1014
+ draft = next(
1015
+ (item for item in reversed(drafts) if item.role == self._initial_context.role),
1016
+ None,
1017
+ )
1018
+ if draft is None:
1019
+ return
1020
+ if self._initial_context.role is None and draft.role is not None:
1021
+ cast("Select[str]", self.query_one(f"#{_FIELD_ROLE}", Select)).value = draft.role
1022
+ self._role_is_explicit = draft.role_explicit
1023
+ if action == _ACTION_MESSAGE:
1024
+ self.query_one(f"#{_FIELD_MESSAGE}", TextArea).text = draft.guidance
1025
+ self._restored_contextual_draft_actions.add(action)
1026
+
1027
+ def _restore_context_form_position(self) -> None:
1028
+ if not self.is_mounted or not widget_is_attached(self):
1029
+ return
1030
+ if self._fit_form_to_submit_height():
1031
+ self.call_after_refresh(self._restore_context_form_position)
1032
+ return
1033
+ form = self.query_one(f"#{_PALETTE_FORM_ID}", VerticalScroll)
1034
+ control = self.query_one(f"#{self._form_focus_id}") if self._form_focus_id else None
1035
+ action = self._action
1036
+ if (
1037
+ action not in _ROLE_FIRST_ACTIONS
1038
+ and control is not None
1039
+ and self._is_visible_in_form(control)
1040
+ and control.can_focus
1041
+ ):
1042
+ self.set_focus(control)
1043
+ form.scroll_to(y=self._form_scroll_y, animate=False, immediate=True)
1044
+ return
1045
+ candidate = _INITIAL_FIELD_BY_ACTION.get(action)
1046
+ if candidate is not None:
1047
+ field = self.query_one(f"#{candidate}")
1048
+ if self._is_visible_in_form(field) and field.can_focus:
1049
+ self.set_focus(field)
1050
+ form.scroll_to_widget(field, animate=False, immediate=True, force=True)
1051
+ return
1052
+ submit = self.query_one(f"#{_SUBMIT_ID}", Button)
1053
+ if submit.display and submit.can_focus:
1054
+ self.set_focus(submit)
1055
+ else:
1056
+ self.set_focus(None)
1057
+
1058
+ @staticmethod
1059
+ def _is_visible_in_form(widget: Widget) -> bool:
1060
+ for ancestor in widget.ancestors_with_self:
1061
+ if not isinstance(ancestor, Widget):
1062
+ return False
1063
+ if not ancestor.display:
1064
+ return False
1065
+ if ancestor.id == _PALETTE_FORM_ID:
1066
+ return True
1067
+ return False
1068
+
1069
+ async def _run_action(self) -> None:
1070
+ try:
1071
+ if self._action == _ACTION_START:
1072
+ await self._run_project_start()
1073
+ elif self._action == _ACTION_EXPORT:
1074
+ await self._run_evidence_export()
1075
+ else:
1076
+ await self._run_submission()
1077
+ except (OSError, ShikiruError, ValueError) as error:
1078
+ if self._is_current_screen():
1079
+ self._show_result(str(error))
1080
+ finally:
1081
+ self._action_in_flight = False
1082
+ self._submitting_form_identity = None
1083
+ self._action_worker = None
1084
+ if self._is_current_screen():
1085
+ self._refresh_action_availability()
1086
+
1087
+ async def _run_project_start(self) -> None:
1088
+ if self._start_engine is None:
1089
+ raise ShikiruError(_START_NOT_CONFIGURED)
1090
+ self._show_result("Starting engine…")
1091
+ await self._start_engine()
1092
+ if self._is_current_screen():
1093
+ self._confirm_submitted(_ACTION_START)
1094
+
1095
+ async def _run_evidence_export(self) -> None:
1096
+ if self._evidence_exporter is None:
1097
+ raise ShikiruError(_EXPORT_NOT_CONFIGURED)
1098
+ task_id = self._task_id.strip()
1099
+ destination = self._input(_FIELD_EXPORT_DESTINATION)
1100
+ export_format = cast("EvidenceFormat", self._value(_FIELD_EXPORT_FORMAT))
1101
+ overwrite_value = self._selected_value(_FIELD_EXPORT_OVERWRITE)
1102
+ if overwrite_value not in {_EXPORT_REFUSE, _EXPORT_OVERWRITE}:
1103
+ raise ValueError(_EXPORT_OVERWRITE_REQUIRED)
1104
+ include_signals = self._selected_value(_FIELD_EXPORT_SIGNALS) == _EXPORT_SIGNALS_INCLUDE
1105
+ form_identity = self._form_identity()
1106
+ self._set_export_controls_disabled(True)
1107
+ try:
1108
+ result = await self._evidence_exporter.export(
1109
+ task_id,
1110
+ destination,
1111
+ export_format=export_format,
1112
+ overwrite=overwrite_value == _EXPORT_OVERWRITE,
1113
+ include_signals=include_signals,
1114
+ )
1115
+ finally:
1116
+ if self._is_current_screen():
1117
+ self._set_export_controls_disabled(False)
1118
+ if self._is_current_screen() and self._form_identity() == form_identity:
1119
+ format_label = result.format.upper()
1120
+ signal_label = (
1121
+ "Normalized signal excerpts included."
1122
+ if result.signals_included
1123
+ else "Normalized signal excerpts excluded."
1124
+ )
1125
+ destination_label = json.dumps(str(result.destination), ensure_ascii=False)
1126
+ self._show_result(
1127
+ f"Exported {format_label} task evidence to {destination_label}.\n{signal_label}"
1128
+ )
1129
+
1130
+ def _set_export_controls_disabled(self, disabled: bool) -> None:
1131
+ for field in (
1132
+ _FIELD_EXPORT_FORMAT,
1133
+ _FIELD_EXPORT_OVERWRITE,
1134
+ _FIELD_EXPORT_SIGNALS,
1135
+ ):
1136
+ self.query_one(f"#{field}").disabled = disabled
1137
+ self.query_one(f"#{_FIELD_EXPORT_DESTINATION}", Input).disabled = disabled
1138
+
1139
+ async def _run_submission(self) -> None:
1140
+ action = self._action
1141
+ task_id = self._task_id.strip()
1142
+ role = self._role() if action in _FORM_ROLE_ACTIONS | {_ACTION_PEEK} else None
1143
+ self._command_action = action
1144
+ self._command_target = self._target_for_action(action, task_id, role)
1145
+ form_identity = self._form_identity()
1146
+ if action != _ACTION_PEEK and self._observe_pending_form(form_identity):
1147
+ return
1148
+ self._submitting_form_identity = form_identity
1149
+ if action != _ACTION_PEEK:
1150
+ self._show_result(f"Sending · {self._command_target}")
1151
+ ack = await self._dispatch()
1152
+ if ack is None:
1153
+ return
1154
+ if ack.status is AckStatus.QUEUED:
1155
+ self._pending_form_commands[form_identity] = ack.id
1156
+ failure = None
1157
+ if action == _ACTION_TAKE_OVER and ack.status is AckStatus.DONE and role is not None:
1158
+ failure = await self._select_role_pane(task_id, role)
1159
+ if ack.status is not AckStatus.REJECTED and (
1160
+ action == _ACTION_SUBMIT
1161
+ or (
1162
+ failure is None
1163
+ and ack.message != ACK_TIMEOUT_MESSAGE
1164
+ and action in _CONFIRMED_ACTIONS
1165
+ )
1166
+ ):
1167
+ self._confirm_submitted(action)
1168
+ elif self._is_current_screen():
1169
+ self._show_ack(ack, failure)
1170
+
1171
+ async def _dispatch(self) -> Ack | None:
1172
+ action = self._action
1173
+ task_id = self._task_id.strip()
1174
+ if action == _ACTION_PEEK:
1175
+ self._show_peek(task_id)
1176
+ return None
1177
+ if action == _ACTION_STOP and not self._queries.engine_is_up():
1178
+ raise ValueError(_ENGINE_ALREADY_STOPPED)
1179
+ if action in _TASK_ACTIONS:
1180
+ self._validate_task_action(action, task_id)
1181
+ command = self._command_for_current_form()
1182
+ if command is not None:
1183
+ return await self._commands.send(command)
1184
+ message = f"unsupported palette action: {action}"
1185
+ raise ValueError(message)
1186
+
1187
+ def _validate_task_action(self, action: str, task_id: str) -> None:
1188
+ if not task_id:
1189
+ raise ValueError(_NO_TASK_TARGET)
1190
+ selected_action = _TASK_ACTION_BY_ID[action]
1191
+ role = self._role() if action in _FORM_ROLE_ACTIONS else None
1192
+ state = next(
1193
+ item
1194
+ for item in self._queries.task_action_availability(task_id, role)
1195
+ if item.action is selected_action
1196
+ )
1197
+ if state.disabled_reason is not None:
1198
+ raise ValueError(state.disabled_reason)
1199
+
1200
+ def _refresh_action_availability(self) -> None:
1201
+ self._refresh_pending_commands()
1202
+ form_identity = self._form_identity()
1203
+ if form_identity != self._submitting_form_identity:
1204
+ self._observe_pending_form(form_identity)
1205
+ task_id = self._task_id.strip()
1206
+ self._action_states = (
1207
+ {
1208
+ state.action.value: state
1209
+ for state in self._queries.task_action_availability(task_id, None)
1210
+ }
1211
+ if task_id
1212
+ else {}
1213
+ )
1214
+ action = self._action
1215
+ disabled_reason = self._disabled_reason(action, task_id)
1216
+ if action in _FORM_ROLE_ACTIONS and task_id:
1217
+ selected_action = _TASK_ACTION_BY_ID[action]
1218
+ role_state = next(
1219
+ item
1220
+ for item in self._queries.task_action_availability(task_id, self._role())
1221
+ if item.action is selected_action
1222
+ )
1223
+ disabled_reason = role_state.disabled_reason
1224
+ state = self.query_one(f"#{_ACTION_STATE_ID}", Static)
1225
+ state.update(
1226
+ Text(f"Unavailable: {disabled_reason}", style=AMBER)
1227
+ if disabled_reason is not None
1228
+ else _EMPTY
1229
+ )
1230
+ state.display = disabled_reason is not None
1231
+ self.query_one(f"#{_GUIDANCE_ID}", Static).update(
1232
+ _ACTION_GUIDANCE.get(action or "", _EMPTY)
1233
+ )
1234
+ pending_same_form = form_identity in self._pending_form_commands
1235
+ self.query_one(f"#{_SUBMIT_ID}", Button).disabled = (
1236
+ self._route_error is not None
1237
+ or disabled_reason is not None
1238
+ or self._action_in_flight
1239
+ or pending_same_form
1240
+ or self._required_field_is_blank(action)
1241
+ )
1242
+ self._refresh_submit()
1243
+
1244
+ async def _refresh_after_database_change(self) -> None:
1245
+ if self._is_current_screen():
1246
+ self.call_after_refresh(self._refresh_if_current_screen)
1247
+
1248
+ def _refresh_if_current_screen(self) -> None:
1249
+ if self._is_current_screen():
1250
+ self._refresh_action_availability()
1251
+
1252
+ def _required_field_is_blank(self, action: str | None) -> bool:
1253
+ field = _REQUIRED_FIELDS.get(action or _EMPTY)
1254
+ return field is not None and not self._identity_value(field).strip()
1255
+
1256
+ def _refresh_pending_commands(self) -> None:
1257
+ for identity, command_id in tuple(self._pending_form_commands.items()):
1258
+ outcome = self._queries.command_status(command_id)
1259
+ if outcome is None:
1260
+ message = f"pending command {command_id} is missing from durable history"
1261
+ raise RuntimeError(message)
1262
+ if outcome.status is CommandStatus.PENDING:
1263
+ continue
1264
+ del self._pending_form_commands[identity]
1265
+ if outcome.status is CommandStatus.DONE and identity[0] in _CONFIRMED_ACTIONS:
1266
+ self._show_result(_EMPTY)
1267
+ else:
1268
+ self._show_stored_command(outcome)
1269
+
1270
+ def _observe_pending_form(self, identity: tuple[str, ...]) -> bool:
1271
+ if identity in self._pending_form_commands:
1272
+ return True
1273
+ try:
1274
+ command = self._command_for_current_form()
1275
+ except ShikiruError, ValueError:
1276
+ return False
1277
+ if command is None:
1278
+ return False
1279
+ outcome = self._queries.pending_command(command)
1280
+ if outcome is None:
1281
+ return False
1282
+ self._pending_form_commands[identity] = outcome.id
1283
+ self._show_stored_command(outcome)
1284
+ return True
1285
+
1286
+ def _command_for_current_form(self) -> Command | None:
1287
+ task_id = self._task_id.strip()
1288
+ action = self._action
1289
+ builders: dict[str, Callable[[], Command]] = {
1290
+ _ACTION_SUBMIT: self._submit_command,
1291
+ _ACTION_PRIORITIZE: lambda: self._commands.prepare_prioritize_task(
1292
+ task_id, self._integer(_FIELD_PRIORITY)
1293
+ ),
1294
+ _ACTION_PAUSE: lambda: self._commands.prepare_pause_task(task_id),
1295
+ _ACTION_PAUSE_NOW: lambda: self._commands.prepare_pause_task(task_id, now=True),
1296
+ _ACTION_RESUME: lambda: self._commands.prepare_resume_task(task_id),
1297
+ _ACTION_TAKE_OVER: lambda: self._commands.prepare_take_over(task_id, self._role()),
1298
+ _ACTION_HAND_BACK: lambda: self._commands.prepare_hand_back(task_id, self._role()),
1299
+ _ACTION_CANCEL: lambda: self._commands.prepare_cancel_task(task_id),
1300
+ _ACTION_RETRY: lambda: self._commands.prepare_retry_task(task_id),
1301
+ _ACTION_RUNG: lambda: self._commands.prepare_set_rung(
1302
+ task_id, self._role(), parse_rung(self._input(_FIELD_RUNG))
1303
+ ),
1304
+ _ACTION_MESSAGE: lambda: self._commands.prepare_send_message(
1305
+ task_id, self._role(), self._text(_FIELD_MESSAGE)
1306
+ ),
1307
+ _ACTION_STOP: self._commands.prepare_stop_engine,
1308
+ }
1309
+ builder = builders.get(action or _EMPTY)
1310
+ return builder() if builder is not None else None
1311
+
1312
+ def _submit_command(self) -> Command:
1313
+ request = self._text(_FIELD_REQUEST)
1314
+ return self._commands.prepare_submit_task(
1315
+ request,
1316
+ title=self._optional_input(_FIELD_TITLE),
1317
+ mode=self._mode(),
1318
+ complexity=self._complexity(),
1319
+ priority=self._integer(_FIELD_SUBMISSION_PRIORITY),
1320
+ depends_on=self._dependencies(),
1321
+ leader=self._optional_rung(_FIELD_LEADER_RUNG),
1322
+ implementer=self._optional_rung(_FIELD_IMPLEMENTER_RUNG),
1323
+ )
1324
+
1325
+ def _form_identity(self) -> tuple[str, ...]:
1326
+ action = self._action
1327
+ return (
1328
+ action,
1329
+ self._task_id.strip(),
1330
+ (self._selected_value(_FIELD_ROLE) or _EMPTY)
1331
+ if action in _FORM_ROLE_ACTIONS
1332
+ else _EMPTY,
1333
+ *(self._identity_value(field) for field in _FORM_IDENTITY_FIELDS.get(action, ())),
1334
+ )
1335
+
1336
+ def _identity_value(self, field: str) -> str:
1337
+ control = self.query_one(f"#{field}")
1338
+ if isinstance(control, Input):
1339
+ return control.value
1340
+ if isinstance(control, TextArea):
1341
+ return control.text
1342
+ if isinstance(control, Select):
1343
+ value = cast("Select[str]", control).value
1344
+ return value if isinstance(value, str) else _EMPTY
1345
+ message = f"unsupported palette form field: {field}"
1346
+ raise TypeError(message)
1347
+
1348
+ def _target_for_action(self, action: str, task_id: str, role: Role | None) -> str:
1349
+ if action in {_ACTION_SUBMIT, _ACTION_STOP, _ACTION_START}:
1350
+ return "project"
1351
+ if role is not None:
1352
+ return f"{task_id} · {role.value}"
1353
+ return task_id or "task"
1354
+
1355
+ def _disabled_reason(self, action: str | None, task_id: str) -> str | None:
1356
+ if action in {_ACTION_START, _ACTION_STOP}:
1357
+ return self._project_engine_disabled_reason(action)
1358
+ if action == _ACTION_EXPORT:
1359
+ return (
1360
+ _NO_TASK_TARGET
1361
+ if not task_id
1362
+ else _EXPORT_NOT_CONFIGURED
1363
+ if self._evidence_exporter is None
1364
+ else None
1365
+ )
1366
+ if action in _TASK_TARGET_ACTIONS:
1367
+ state = self._action_states.get(action)
1368
+ return state.disabled_reason if state is not None and task_id else _NO_TASK_TARGET
1369
+ return None
1370
+
1371
+ def _project_engine_disabled_reason(self, action: str) -> str | None:
1372
+ engine_up = self._queries.engine_is_up()
1373
+ if action == _ACTION_START and self._start_engine is None:
1374
+ return _START_NOT_CONFIGURED
1375
+ if action == _ACTION_START and engine_up:
1376
+ return _ENGINE_ALREADY_RUNNING
1377
+ if action == _ACTION_STOP and not engine_up:
1378
+ return _ENGINE_ALREADY_STOPPED
1379
+ return None
1380
+
1381
+ async def _select_role_pane(self, task_id: str, role: Role) -> str | None:
1382
+ detail = self._queries.get_task(task_id)
1383
+ task = detail.task if detail is not None else None
1384
+ pane_id = task.panes.get(role) if task is not None else None
1385
+ if pane_id is None:
1386
+ return "task is missing" if task is None else f"{role.value} pane is missing"
1387
+ try:
1388
+ await self._pane_selector(pane_id)
1389
+ except ShikiruError as error:
1390
+ return f"{_TAKEOVER_SELECTION_FAILURE}: {error}"
1391
+ return None
1392
+
1393
+ def _show_peek(self, task_id: str) -> None:
1394
+ selected_role = self._selected_value(_FIELD_ROLE)
1395
+ view = self._queries.peek_session(task_id, Role(selected_role) if selected_role else None)
1396
+ if view is None:
1397
+ self._show_result(f"task not found: {task_id}")
1398
+ return
1399
+ lines: list[str] = [view.task_id]
1400
+ for role, pane in view.panes.items():
1401
+ lines.extend(
1402
+ (
1403
+ f"{role} · {pane.agent}",
1404
+ f"last message: {pane.last_message or '-'}",
1405
+ f"tokens: {pane.usage.input_tokens} in / {pane.usage.output_tokens} out",
1406
+ *pane.view,
1407
+ )
1408
+ )
1409
+ self._show_result("\n".join(lines))
1410
+
1411
+ def _update_fields(self) -> None:
1412
+ action = self._action
1413
+ groups = {
1414
+ _FIELD_EXPORT_DESTINATION: action == _ACTION_EXPORT,
1415
+ _FIELD_EXPORT_FORMAT: action == _ACTION_EXPORT,
1416
+ _FIELD_EXPORT_OVERWRITE: action == _ACTION_EXPORT,
1417
+ _FIELD_EXPORT_SIGNALS: action == _ACTION_EXPORT,
1418
+ "request": action == _ACTION_SUBMIT,
1419
+ "title": action == _ACTION_SUBMIT,
1420
+ "mode": action == _ACTION_SUBMIT,
1421
+ "complexity": action == _ACTION_SUBMIT,
1422
+ "priority": action == _ACTION_PRIORITIZE,
1423
+ "submission-priority": action == _ACTION_SUBMIT,
1424
+ "dependencies": action == _ACTION_SUBMIT,
1425
+ "leader-rung": action == _ACTION_SUBMIT,
1426
+ "implementer-rung": action == _ACTION_SUBMIT,
1427
+ _FIELD_ROLE: action
1428
+ in {_ACTION_TAKE_OVER, _ACTION_HAND_BACK, _ACTION_RUNG, _ACTION_MESSAGE, _ACTION_PEEK},
1429
+ "rung": action == _ACTION_RUNG,
1430
+ "message": action == _ACTION_MESSAGE,
1431
+ }
1432
+ for field, visible in groups.items():
1433
+ self.query_one(f"#field-{field}").display = visible
1434
+ if action != _ACTION_SUBMIT:
1435
+ self._options_expanded = False
1436
+ self.query_one(f"#{_OPTIONS_FOLD_ID}").display = action == _ACTION_SUBMIT
1437
+ self.query_one(f"#{_OPTIONS_ID}").display = (
1438
+ action == _ACTION_SUBMIT and self._options_expanded
1439
+ )
1440
+ self._paint_options_fold()
1441
+ self._confirm_armed = False
1442
+ self._refresh_submit()
1443
+ self.query_one(f"#{_GUIDANCE_ID}").display = action in _ACTION_GUIDANCE
1444
+
1445
+ def _show_ack(self, ack: Ack, additional_detail: str | None = None) -> None:
1446
+ outcome = self._queries.command_status(ack.id)
1447
+ if outcome is None:
1448
+ message = f"acknowledged command {ack.id} is missing from durable history"
1449
+ raise RuntimeError(message)
1450
+ target = outcome.target
1451
+ if ack.status is AckStatus.QUEUED:
1452
+ label = "Pending"
1453
+ if ack.message == QUEUED_MESSAGE:
1454
+ details = "queued while the engine was stopped"
1455
+ elif ack.message == ACK_TIMEOUT_MESSAGE:
1456
+ engine_state = "running" if self._queries.engine_is_up() else "stopped"
1457
+ details = (
1458
+ f"acknowledgement wait elapsed; engine is currently {engine_state}; "
1459
+ "still pending"
1460
+ )
1461
+ else:
1462
+ details = "waiting for engine acknowledgement"
1463
+ elif ack.status is AckStatus.DONE:
1464
+ label = "Applied"
1465
+ details = self._applied_text()
1466
+ else:
1467
+ label = "Rejected"
1468
+ details = ack.message or COMMAND_REJECTED_FALLBACK
1469
+ message = f"{label} · command {ack.id} · {target}\n{details}"
1470
+ if additional_detail is not None:
1471
+ message = f"{message}\n{additional_detail}"
1472
+ self._show_result(message)
1473
+
1474
+ def _show_stored_command(self, entry: CommandHistoryEntry) -> None:
1475
+ if entry.status is CommandStatus.DONE:
1476
+ label = "Applied"
1477
+ details = (
1478
+ "guidance queued for the selected role's next turn"
1479
+ if entry.action == CommandKind.SAY.value.capitalize()
1480
+ else "engine applied the command effect"
1481
+ )
1482
+ elif entry.status is CommandStatus.REJECTED:
1483
+ label = "Rejected"
1484
+ details = command_rejection_cause(entry.result)
1485
+ else:
1486
+ label = "Pending"
1487
+ engine_state = "running" if self._queries.engine_is_up() else "stopped"
1488
+ details = (
1489
+ f"command remains pending; engine currently {engine_state}; "
1490
+ "original acknowledgement wait context unknown"
1491
+ )
1492
+ self._show_result(f"{label} · command {entry.id} · {entry.target}\n{details}")
1493
+
1494
+ def _applied_text(self) -> str:
1495
+ if self._command_action == _ACTION_MESSAGE:
1496
+ details = "guidance queued for the selected role's next turn"
1497
+ elif self._command_action == _ACTION_RUNG:
1498
+ details = "selected rung applies to this role's next turn"
1499
+ else:
1500
+ details = "engine applied the command effect"
1501
+ return details
1502
+
1503
+ def _paint_result(self, text: str) -> None:
1504
+ self.query_one("#palette-result", Static).update(text)
1505
+ self.query_one("#palette-result-scroll").display = bool(text)
1506
+
1507
+ def _show_result(self, text: str) -> None:
1508
+ if self._is_current_screen():
1509
+ self._result_reveal_revision += 1
1510
+ self._paint_result(text)
1511
+ self.call_after_refresh(self._reveal_result)
1512
+
1513
+ def _reveal_result(self) -> None:
1514
+ if self._is_current_screen():
1515
+ self.query_one("#palette-form", VerticalScroll).scroll_end(animate=False)
1516
+
1517
+ def _is_current_screen(self) -> bool:
1518
+ app = cast("App[None]", self.app)
1519
+ return app.is_running and self.is_mounted and app.screen is self
1520
+
1521
+ def _input(self, name: str) -> str:
1522
+ return self.query_one(f"#{name}", Input).value
1523
+
1524
+ def _text(self, name: str) -> str:
1525
+ return self.query_one(f"#{name}", TextArea).text
1526
+
1527
+ def _optional_input(self, name: str) -> str | None:
1528
+ return self._input(name).strip() or None
1529
+
1530
+ def _integer(self, name: str) -> int:
1531
+ try:
1532
+ return int(self._input(name))
1533
+ except ValueError:
1534
+ message = f"{name.replace('-', ' ')} must be an integer"
1535
+ raise ValueError(message) from None
1536
+
1537
+ def _dependencies(self) -> tuple[str, ...]:
1538
+ return tuple(
1539
+ item.strip() for item in self._input("dependencies").split(",") if item.strip()
1540
+ )
1541
+
1542
+ def _optional_rung(self, name: str) -> Rung | None:
1543
+ value = self._input(name).strip()
1544
+ return parse_rung(value) if value else None
1545
+
1546
+ def _mode(self) -> Mode | None:
1547
+ value = self._selected_value("mode")
1548
+ return Mode(value) if value else None
1549
+
1550
+ def _complexity(self) -> ComplexityName | None:
1551
+ value = self._selected_value("complexity")
1552
+ if not value:
1553
+ return None
1554
+ if value in get_args(ComplexityName):
1555
+ return cast("ComplexityName", value)
1556
+ message = f"unsupported complexity: {value}"
1557
+ raise ValueError(message)
1558
+
1559
+ def _role(self) -> Role:
1560
+ return Role(self._value(_FIELD_ROLE))
1561
+
1562
+ def _value(self, name: str) -> str:
1563
+ value = self._selected_value(name)
1564
+ if value is None:
1565
+ message = f"{name.replace('-', ' ')} is required"
1566
+ raise ValueError(message)
1567
+ return value
1568
+
1569
+ def _selected_value(self, name: str) -> str | None:
1570
+ value = cast("Select[str]", self.query_one(f"#{name}", Select)).value
1571
+ return value if isinstance(value, str) else None
1572
+
1573
+ @staticmethod
1574
+ def _select_field(
1575
+ label: str,
1576
+ field_id: str,
1577
+ options: tuple[tuple[str, str], ...],
1578
+ *,
1579
+ value: str,
1580
+ inline: bool = False,
1581
+ wide: bool = False,
1582
+ ) -> ComposeResult:
1583
+ with Vertical(id=f"field-{field_id}", classes=_field_classes(inline=inline, wide=wide)):
1584
+ yield Label(label)
1585
+ yield Select(options, value=value, allow_blank=False, id=field_id)
1586
+
1587
+ @staticmethod
1588
+ def _input_field(
1589
+ label: str,
1590
+ field_id: str,
1591
+ placeholder: str,
1592
+ *,
1593
+ inline: bool = False,
1594
+ wide: bool = False,
1595
+ ) -> ComposeResult:
1596
+ with Vertical(id=f"field-{field_id}", classes=_field_classes(inline=inline, wide=wide)):
1597
+ yield Label(label)
1598
+ yield Input(placeholder=placeholder, id=field_id)
1599
+
1600
+ @staticmethod
1601
+ def _text_area_field(label: str, field_id: str, placeholder: str) -> ComposeResult:
1602
+ with Vertical(id=f"field-{field_id}", classes="palette-field"):
1603
+ yield Label(label)
1604
+ text_area = TextArea(placeholder=placeholder, id=field_id, classes=POPUP_EDIT_CLASS)
1605
+ text_area.styles.min_width = _PALETTE_TEXT_AREA_MINIMUM_WIDTH
1606
+ yield text_area