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,89 @@
1
+ """The standalone tmux popup that shows the Actions list for the pane it was opened from."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from textual.widgets import Static
8
+
9
+ from shikiru.dashboard.actions_list import ActionsListScreen
10
+ from shikiru.dashboard.contract import PaletteRequest
11
+ from shikiru.dashboard.covered import CoveredScreens
12
+ from shikiru.dashboard.palette import PaletteScreen
13
+ from shikiru.dashboard.palette_drafts import PaletteDrafts
14
+ from shikiru.ui.theme import install_theme
15
+
16
+ if TYPE_CHECKING:
17
+ from textual.app import ComposeResult
18
+ from textual.screen import ModalScreen
19
+
20
+ from shikiru.dashboard.contract import OnChangeRegistration, ResolutionScreenFactory
21
+ from shikiru.dashboard.palette import PaneSelector, StartEngine
22
+ from shikiru.operator.commands import OperatorCommands
23
+ from shikiru.operator.evidence_export import TaskEvidenceExporter
24
+ from shikiru.operator.queries import OperatorQueries
25
+
26
+
27
+ class PaletteApp(CoveredScreens):
28
+ """Run the Actions list without dashboard routes or Extensions in a tmux popup."""
29
+
30
+ def __init__(
31
+ self,
32
+ commands: OperatorCommands,
33
+ queries: OperatorQueries,
34
+ pane_selector: PaneSelector,
35
+ *,
36
+ on_change: OnChangeRegistration,
37
+ action_confirmation_seconds: float,
38
+ engine_state_refresh_hz: int | None = None,
39
+ start_engine: StartEngine | None = None,
40
+ initial_context: PaletteRequest | None = None,
41
+ evidence_exporter: TaskEvidenceExporter | None = None,
42
+ resolution_screen: ResolutionScreenFactory | None = None,
43
+ ) -> None:
44
+ """Retain the services used by the list and its popups."""
45
+ super().__init__()
46
+ install_theme(self)
47
+ self._commands = commands
48
+ self._queries = queries
49
+ self._pane_selector = pane_selector
50
+ self._on_change = on_change
51
+ self._action_confirmation_seconds = action_confirmation_seconds
52
+ self._engine_state_refresh_hz = engine_state_refresh_hz
53
+ self._start_engine = start_engine
54
+ self._initial_context = initial_context or PaletteRequest()
55
+ self._evidence_exporter = evidence_exporter
56
+ self._resolution_screen = resolution_screen
57
+ self._drafts = PaletteDrafts()
58
+
59
+ def compose(self) -> ComposeResult:
60
+ """Keep a neutral base screen beneath the modal list."""
61
+ yield Static()
62
+
63
+ async def on_mount(self) -> None:
64
+ """Open the Actions list and exit with it."""
65
+ self.push_screen(
66
+ ActionsListScreen(
67
+ self._queries,
68
+ self._popup,
69
+ self._initial_context,
70
+ on_change=self._on_change,
71
+ action_confirmation_seconds=self._action_confirmation_seconds,
72
+ engine_state_refresh_hz=self._engine_state_refresh_hz,
73
+ resolution_screen=self._resolution_screen,
74
+ ),
75
+ lambda _: self.exit(),
76
+ )
77
+
78
+ def _popup(self, request: PaletteRequest) -> ModalScreen[str | None]:
79
+ return PaletteScreen(
80
+ self._commands,
81
+ self._queries,
82
+ self._pane_selector,
83
+ on_change=self._on_change,
84
+ action_confirmation_seconds=self._action_confirmation_seconds,
85
+ initial_context=self._drafts.attach(request),
86
+ start_engine=self._start_engine,
87
+ evidence_exporter=self._evidence_exporter,
88
+ remember_contextual_draft=self._drafts.remember,
89
+ )
@@ -0,0 +1,33 @@
1
+ """In-memory contextual form drafts kept while a palette caller is visible."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import replace
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from shikiru.dashboard.contract import PaletteDraft, PaletteRequest
10
+
11
+ MAX_CONTEXTUAL_PALETTE_DRAFTS = 16
12
+
13
+
14
+ class PaletteDrafts:
15
+ """Remember the latest draft per task, action and role, dropping the oldest beyond a bound."""
16
+
17
+ def __init__(self) -> None:
18
+ self._drafts: dict[tuple[str, str, str | None], PaletteDraft] = {}
19
+
20
+ def remember(self, draft: PaletteDraft) -> None:
21
+ """Keep `draft` as the latest for its task, action and role."""
22
+ key = (draft.task_id, draft.action, draft.role)
23
+ self._drafts.pop(key, None)
24
+ self._drafts[key] = draft
25
+ if len(self._drafts) > MAX_CONTEXTUAL_PALETTE_DRAFTS:
26
+ self._drafts.pop(next(iter(self._drafts)))
27
+
28
+ def attach(self, request: PaletteRequest) -> PaletteRequest:
29
+ """Return `request` carrying the drafts remembered for its task."""
30
+ drafts = tuple(
31
+ draft for (task_id, _, _), draft in self._drafts.items() if task_id == request.task_id
32
+ )
33
+ return replace(request, drafts=drafts) if drafts else request
@@ -0,0 +1,573 @@
1
+ """The escalation resolution screen: one concrete decision, a choice list, one send."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, ClassVar, cast
6
+
7
+ from rich.text import Text
8
+ from textual import on
9
+ from textual.binding import Binding
10
+ from textual.containers import Horizontal, Vertical, VerticalScroll
11
+ from textual.message import Message
12
+ from textual.screen import ModalScreen
13
+ from textual.widgets import Button, Label, Rule, Static, TextArea
14
+
15
+ from shikiru.dashboard.extra_details import ExtraDetailsScreen
16
+ from shikiru.dashboard.resolution_view import (
17
+ EXTRA_DETAILS_KEY,
18
+ ResolutionLayout,
19
+ choice_renderable,
20
+ choose_renderable,
21
+ decision_renderable,
22
+ layout_for,
23
+ resolution_title,
24
+ starred_indexes,
25
+ tried_renderable,
26
+ )
27
+ from shikiru.domain.commands import ResolutionKind, command_rejection_cause
28
+ from shikiru.domain.escalation_evidence import parse_escalation_packet
29
+ from shikiru.domain.escalation_panel import ChoiceKind, build_resolution_panel
30
+ from shikiru.errors import ShikiruError
31
+ from shikiru.operator.commands import AckStatus
32
+ from shikiru.ui.age import format_short_age
33
+ from shikiru.ui.fold import FOLD_TOGGLE_KEY, FoldLine
34
+ from shikiru.ui.popup import (
35
+ POPUP_BODY_CLASS,
36
+ POPUP_CLOSE_CLASS,
37
+ POPUP_CSS,
38
+ POPUP_EDIT_CLASS,
39
+ POPUP_FRAME_CLASS,
40
+ POPUP_HEADER_CLASS,
41
+ POPUP_RULE_CLASS,
42
+ POPUP_TITLE_CLASS,
43
+ PopupFrame,
44
+ popup_close_label,
45
+ prepare_popup,
46
+ )
47
+ from shikiru.ui.tokens import AMBER, DUSTY_ROSE
48
+
49
+ if TYPE_CHECKING:
50
+ from textual import events
51
+ from textual.app import App, ComposeResult
52
+ from textual.binding import BindingType
53
+ from textual.worker import Worker
54
+
55
+ from shikiru.clock import Clock
56
+ from shikiru.dashboard.contract import OnChangeRegistration
57
+ from shikiru.domain.escalation_panel import PanelChoice, ResolutionPanel
58
+ from shikiru.operator.commands import OperatorCommands
59
+ from shikiru.operator.queries import OperatorQueries
60
+
61
+ RESOLUTION_CLOSE_BUTTON = "resolution-close"
62
+ _REFRESH_GROUP = "resolution-refresh"
63
+ _SEND_GROUP = "resolution-send"
64
+ _SEND_KEY = "ctrl+s"
65
+ _LEAVE_KEY = "escape"
66
+ _ACTION_MOVE = "move"
67
+ _ACTION_ACTIVATE = "activate"
68
+ _ACTION_TOGGLE_TRIED = "toggle_tried"
69
+ _ACTION_EXTRA_DETAILS = "extra_details"
70
+ _SELECTION_ACTIONS = frozenset({_ACTION_MOVE, _ACTION_ACTIVATE, _ACTION_TOGGLE_TRIED})
71
+ _PREFILL_SEPARATOR = " \N{EM DASH} "
72
+ _SUBMIT_KEY = "enter"
73
+ _MOVE_STEPS = {"up": -1, "down": 1}
74
+
75
+
76
+ class ResolutionDrafts:
77
+ """The typed answers kept per task and escalation while the dashboard runs."""
78
+
79
+ def __init__(self) -> None:
80
+ """Start without drafts."""
81
+ self._texts: dict[tuple[str, str], str] = {}
82
+
83
+ def get(self, task_id: str, escalation_id: str) -> str:
84
+ """Return the remembered answer text, empty when none."""
85
+ return self._texts.get((task_id, escalation_id), "")
86
+
87
+ def remember(self, task_id: str, escalation_id: str, text: str) -> None:
88
+ """Keep `text` as the answer draft of one escalation."""
89
+ self._texts[(task_id, escalation_id)] = text
90
+
91
+
92
+ class _ChoiceList(Vertical, can_focus=True):
93
+ pass
94
+
95
+
96
+ class _ChoiceRow(Static):
97
+ class Chosen(Message):
98
+ def __init__(self, index: int) -> None:
99
+ super().__init__()
100
+ self.index = index
101
+
102
+ def __init__(self, index: int) -> None:
103
+ super().__init__("", classes="resolution-choice")
104
+ self._index = index
105
+
106
+ def on_click(self, event: events.Click) -> None:
107
+ event.stop()
108
+ self.post_message(self.Chosen(self._index))
109
+
110
+
111
+ class _TriedSection(FoldLine):
112
+ BINDINGS: ClassVar[list[BindingType]] = [
113
+ Binding("up", "back_to_choices", "Back to choices", show=False),
114
+ ]
115
+
116
+ class Left(Message):
117
+ pass
118
+
119
+ def action_back_to_choices(self) -> None:
120
+ self.post_message(self.Left())
121
+
122
+
123
+ class _AnswerBox(TextArea):
124
+ class Submitted(Message):
125
+ pass
126
+
127
+ class Exited(Message):
128
+ pass
129
+
130
+ def on_key(self, event: events.Key) -> None:
131
+ if event.key == _SEND_KEY:
132
+ event.stop()
133
+ event.prevent_default()
134
+ self.post_message(self.Submitted())
135
+ elif event.key == _LEAVE_KEY:
136
+ event.stop()
137
+ event.prevent_default()
138
+ self.post_message(self.Exited())
139
+
140
+
141
+ class ResolutionScreen(ModalScreen[bool]):
142
+ """Resolve one escalation from a choice list; dismisses `True` once a resolution is queued."""
143
+
144
+ AUTO_FOCUS = ""
145
+ BINDINGS: ClassVar[list[Binding]] = [
146
+ Binding("escape", "close", "Close"),
147
+ Binding("up", f"{_ACTION_MOVE}(-1)", "Previous choice", show=False, priority=True),
148
+ Binding("down", f"{_ACTION_MOVE}(1)", "Next choice", show=False, priority=True),
149
+ Binding(_SUBMIT_KEY, _ACTION_ACTIVATE, "Send", show=False),
150
+ Binding(FOLD_TOGGLE_KEY, _ACTION_TOGGLE_TRIED, "Details", show=False),
151
+ Binding(EXTRA_DETAILS_KEY, _ACTION_EXTRA_DETAILS, "Extra details", show=False),
152
+ ]
153
+ DEFAULT_CSS = (
154
+ POPUP_CSS
155
+ + """
156
+ #resolution-frame.compact { margin: 0 1; padding: 0 1; }
157
+ #resolution-title { text-wrap: nowrap; text-overflow: ellipsis; }
158
+ #resolution-notice, #resolution-decision, #resolution-choose, #resolution-tried {
159
+ height: auto;
160
+ }
161
+ #resolution-tried:focus { background: $selected-surface; background-tint: transparent; }
162
+ #resolution-choices { height: auto; }
163
+ #resolution-choices:focus { background-tint: transparent; }
164
+ .resolution-choice { height: auto; width: 100%; }
165
+ .resolution-choice.-selected { background: $selected-surface; }
166
+ #resolution-answer { margin: 0 0 0 3; }
167
+ """
168
+ )
169
+
170
+ def __init__(
171
+ self,
172
+ commands: OperatorCommands,
173
+ queries: OperatorQueries,
174
+ *,
175
+ clock: Clock,
176
+ on_change: OnChangeRegistration,
177
+ drafts: ResolutionDrafts,
178
+ escalation_id: str,
179
+ ) -> None:
180
+ """Hold the services for one escalation; nothing is dispatched until a choice is sent."""
181
+ super().__init__()
182
+ prepare_popup(self)
183
+ self._commands = commands
184
+ self._queries = queries
185
+ self._clock = clock
186
+ self._on_change = on_change
187
+ self._drafts = drafts
188
+ self._escalation_id = escalation_id
189
+ self._packet_text = ""
190
+ self._panel = self._load()
191
+ self._selected = self._panel.preselected if self._panel is not None else 0
192
+ self._answer_for: int | None = None
193
+ self._tried_open = False
194
+ self._cancel_armed = False
195
+ self._sending = False
196
+ self._send_error: str | None = None
197
+ self._change_worker: Worker[None] | None = None
198
+ if self._draft():
199
+ self._answer_for = self._selected = self._own_answer_index()
200
+
201
+ def compose(self) -> ComposeResult:
202
+ """Lay out the popup frame; contents are filled on mount."""
203
+ panel = self._panel
204
+ with PopupFrame(widget_id="resolution-frame", classes=POPUP_FRAME_CLASS):
205
+ with Horizontal(classes=POPUP_HEADER_CLASS):
206
+ yield Label(
207
+ resolution_title(self._escalation_id, panel, ResolutionLayout.WIDE),
208
+ id="resolution-title",
209
+ classes=POPUP_TITLE_CLASS,
210
+ )
211
+ yield Button(
212
+ popup_close_label(), id=RESOLUTION_CLOSE_BUTTON, classes=POPUP_CLOSE_CLASS
213
+ )
214
+ yield Rule(classes=POPUP_RULE_CLASS)
215
+ with VerticalScroll(id="resolution-body", classes=POPUP_BODY_CLASS):
216
+ yield Static("", id="resolution-notice", markup=False)
217
+ if panel is not None:
218
+ yield Static("", id="resolution-decision", markup=False)
219
+ yield Static("", id="resolution-choose", markup=False)
220
+ with _ChoiceList(id="resolution-choices"):
221
+ for index, choice in enumerate(panel.choices):
222
+ yield _ChoiceRow(index)
223
+ if choice.kind is ChoiceKind.OWN_ANSWER:
224
+ yield _AnswerBox(
225
+ id="resolution-answer",
226
+ text=self._draft(),
227
+ classes=POPUP_EDIT_CLASS,
228
+ )
229
+ if panel.details:
230
+ yield Rule(classes=POPUP_RULE_CLASS)
231
+ yield _TriedSection("", id="resolution-tried", markup=False)
232
+
233
+ def on_mount(self) -> None:
234
+ self._paint()
235
+ if self._panel is not None:
236
+ self.set_focus(self.query_one("#resolution-choices", _ChoiceList), scroll_visible=False)
237
+ self.call_after_refresh(self.query_one("#resolution-frame", PopupFrame).content_ready)
238
+ self._change_worker = self.run_worker(self._on_change(self._refresh), group=_REFRESH_GROUP)
239
+
240
+ def on_unmount(self) -> None:
241
+ if self._change_worker is not None:
242
+ self._change_worker.cancel()
243
+
244
+ def on_resize(self, event: events.Resize) -> None:
245
+ del event
246
+ if not self.is_mounted:
247
+ return
248
+ self._paint()
249
+ self.call_after_refresh(
250
+ self.query_one("#resolution-frame", PopupFrame).update_preferred_size
251
+ )
252
+
253
+ def on_key(self, event: events.Key) -> None:
254
+ if self._cancel_armed and event.key != _SUBMIT_KEY:
255
+ self._disarm()
256
+
257
+ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
258
+ del parameters
259
+ if action == _ACTION_EXTRA_DETAILS:
260
+ return self._extra_details_shown() and not isinstance(self.focused, TextArea)
261
+ if action in _SELECTION_ACTIONS:
262
+ if isinstance(self.focused, TextArea):
263
+ return False
264
+ if isinstance(self.focused, _TriedSection) and action != _ACTION_TOGGLE_TRIED:
265
+ return False
266
+ return True
267
+
268
+ def action_close(self) -> None:
269
+ """Close without sending; the typed answer stays remembered."""
270
+ self.dismiss(False)
271
+
272
+ def action_move(self, step: int) -> None:
273
+ """Select the neighbouring row, stopping at both ends."""
274
+ panel = self._panel
275
+ if panel is not None:
276
+ self._select(min(max(self._selected + step, 0), len(panel.choices) - 1))
277
+
278
+ def action_activate(self) -> None:
279
+ """Send the selected choice; Cancel asks for a second Enter."""
280
+ if self._panel is not None:
281
+ self._activate(self._selected)
282
+
283
+ def action_toggle_tried(self) -> None:
284
+ """Expand or collapse `Details`."""
285
+ if self._panel is not None and self._panel.details:
286
+ self._tried_open = not self._tried_open
287
+ self._paint()
288
+ self._relayout()
289
+
290
+ def action_extra_details(self) -> None:
291
+ """Open the evidence popup while `Details` is open and a packet exists."""
292
+ if self._extra_details_shown():
293
+ task_id = cast("ResolutionPanel", self._panel).task_id
294
+ cast("App[None]", self.app).push_screen(ExtraDetailsScreen(task_id, self._packet_text))
295
+
296
+ def on_button_pressed(self, event: Button.Pressed) -> None:
297
+ if event.button.id == RESOLUTION_CLOSE_BUTTON:
298
+ self.action_close()
299
+
300
+ @on(_ChoiceRow.Chosen)
301
+ def _row_chosen(self, message: _ChoiceRow.Chosen) -> None:
302
+ message.stop()
303
+ self._activate(message.index)
304
+
305
+ @on(_TriedSection.Toggled)
306
+ def _tried_toggled(self, message: _TriedSection.Toggled) -> None:
307
+ message.stop()
308
+ self.action_toggle_tried()
309
+
310
+ @on(_TriedSection.Left)
311
+ def _tried_left(self, message: _TriedSection.Left) -> None:
312
+ message.stop()
313
+ self.set_focus(self.query_one("#resolution-choices", _ChoiceList), scroll_visible=False)
314
+
315
+ @on(_AnswerBox.Submitted)
316
+ def _answer_submitted(self, message: _AnswerBox.Submitted) -> None:
317
+ message.stop()
318
+ panel = self._panel
319
+ if panel is None or self._answer_for is None:
320
+ return
321
+ details = self.query_one("#resolution-answer", _AnswerBox).text.strip()
322
+ choice = panel.choices[self._answer_for]
323
+ if choice.kind is ChoiceKind.ANSWER_OPTION:
324
+ label = choice.label or ""
325
+ self._start_send(
326
+ ResolutionKind.ANSWER,
327
+ f"{label}{_PREFILL_SEPARATOR}{details}" if details else label,
328
+ )
329
+ elif details:
330
+ self._start_send(ResolutionKind.ANSWER, details)
331
+
332
+ @on(_AnswerBox.Exited)
333
+ def _answer_exited(self, message: _AnswerBox.Exited) -> None:
334
+ message.stop()
335
+ self.set_focus(self.query_one("#resolution-choices", _ChoiceList), scroll_visible=False)
336
+ self._show_answer(self._resting_answer_row())
337
+
338
+ def on_descendant_focus(self, event: events.DescendantFocus) -> None:
339
+ del event
340
+ if self.is_mounted:
341
+ self._paint()
342
+
343
+ def on_descendant_blur(self, event: events.DescendantBlur) -> None:
344
+ del event
345
+ if self.is_mounted:
346
+ self._paint()
347
+
348
+ def on_text_area_changed(self, event: TextArea.Changed) -> None:
349
+ panel = self._panel
350
+ if panel is not None and self._answer_for == self._own_answer_index():
351
+ self._drafts.remember(panel.task_id, self._escalation_id, event.text_area.text)
352
+
353
+ def _load(self) -> ResolutionPanel | None:
354
+ view = self._queries.get_escalation(self._escalation_id)
355
+ if view is None:
356
+ return None
357
+ self._packet_text = view.packet or ""
358
+ task = self._queries.get_task_state(view.escalation.task_id)
359
+ packet = parse_escalation_packet(view.packet) if view.packet is not None else None
360
+ return build_resolution_panel(
361
+ view.escalation,
362
+ packet,
363
+ task.title if task is not None else "",
364
+ self._queries.escalation_resolution_disabled_reason(self._escalation_id),
365
+ )
366
+
367
+ def _extra_details_shown(self) -> bool:
368
+ panel = self._panel
369
+ return (
370
+ panel is not None
371
+ and bool(panel.details)
372
+ and self._tried_open
373
+ and bool(self._packet_text)
374
+ )
375
+
376
+ def _disabled_reason(self) -> str | None:
377
+ if self._panel is None:
378
+ return self._queries.escalation_resolution_disabled_reason(self._escalation_id)
379
+ return self._panel.disabled_reason
380
+
381
+ def _draft(self) -> str:
382
+ if self._panel is None:
383
+ return ""
384
+ return self._drafts.get(self._panel.task_id, self._escalation_id)
385
+
386
+ def _own_answer_index(self) -> int:
387
+ panel = cast("ResolutionPanel", self._panel)
388
+ return next(
389
+ (i for i, c in enumerate(panel.choices) if c.kind is ChoiceKind.OWN_ANSWER),
390
+ self._selected,
391
+ )
392
+
393
+ def _resting_answer_row(self) -> int | None:
394
+ return self._own_answer_index() if self._draft() else None
395
+
396
+ async def _refresh(self) -> None:
397
+ if not self.is_mounted:
398
+ return
399
+ panel = self._load()
400
+ previous = self._panel
401
+ self._panel = panel
402
+ if panel is not None and previous is not None and panel.choices != previous.choices:
403
+ self._selected = min(self._selected, len(panel.choices) - 1)
404
+ self._answer_for = self._resting_answer_row()
405
+ await self.recompose()
406
+ self.call_after_refresh(self._after_recompose)
407
+ return
408
+ self._paint()
409
+ self._relayout()
410
+
411
+ def _after_recompose(self) -> None:
412
+ self._paint()
413
+ if self._panel is not None:
414
+ self.set_focus(self.query_one("#resolution-choices", _ChoiceList), scroll_visible=False)
415
+ self._relayout()
416
+
417
+ def _paint(self) -> None:
418
+ panel = self._panel
419
+ layout = layout_for(self.size.width, self.size.height)
420
+ frame = self.query_one("#resolution-frame", PopupFrame)
421
+ frame.set_class(layout is not ResolutionLayout.WIDE, "compact")
422
+ self.query_one("#resolution-title", Label).update(
423
+ resolution_title(self._escalation_id, panel, layout)
424
+ )
425
+ self._render_notice()
426
+ if panel is None:
427
+ return
428
+ age = format_short_age(self._clock.now() - panel.created_at)
429
+ self.query_one("#resolution-decision", Static).update(
430
+ decision_renderable(panel, age, layout)
431
+ )
432
+ choose = self.query_one("#resolution-choose", Static)
433
+ choose.update(
434
+ choose_renderable(
435
+ panel.choices[self._selected],
436
+ details_open=self._tried_open if isinstance(self.focused, _TriedSection) else None,
437
+ editing=isinstance(self.focused, _AnswerBox),
438
+ choosing_disabled=self._disabled_reason() is not None or self._sending,
439
+ )
440
+ )
441
+ choose.display = layout is not ResolutionLayout.TINY
442
+ disabled = self._disabled_reason() is not None
443
+ starred = starred_indexes(panel)
444
+ for index, row in enumerate(self.query(_ChoiceRow)):
445
+ choice = panel.choices[index]
446
+ selected = index == self._selected
447
+ row.set_class(selected, "-selected")
448
+ row.update(
449
+ choice_renderable(
450
+ choice,
451
+ starred=index in starred,
452
+ armed=selected and self._cancel_armed,
453
+ disabled=disabled,
454
+ )
455
+ )
456
+ for answer in self.query(_AnswerBox):
457
+ answer.display = self._answer_for is not None
458
+ answer.disabled = disabled
459
+ if panel.details:
460
+ self.query_one("#resolution-tried", _TriedSection).update(
461
+ tried_renderable(
462
+ panel.details,
463
+ expanded=self._tried_open,
464
+ extra_details=self._extra_details_shown(),
465
+ )
466
+ )
467
+
468
+ def _render_notice(self) -> None:
469
+ reason = self._disabled_reason()
470
+ notice = self.query_one("#resolution-notice", Static)
471
+ if reason is not None:
472
+ notice.update(Text(reason, style=AMBER))
473
+ elif self._send_error is not None:
474
+ notice.update(Text(self._send_error, style=DUSTY_ROSE))
475
+ notice.display = reason is not None or self._send_error is not None
476
+
477
+ def _relayout(self) -> None:
478
+ frame = self.query_one("#resolution-frame", PopupFrame)
479
+ frame.reset_preferred_size()
480
+ self.call_after_refresh(frame.content_ready)
481
+
482
+ def _select(self, index: int) -> None:
483
+ if index != self._selected and not isinstance(self.focused, _AnswerBox):
484
+ self._show_answer(self._resting_answer_row())
485
+ self._selected = index
486
+ self._cancel_armed = False
487
+ self._send_error = None
488
+ self._paint()
489
+ self.query_one("#resolution-body", VerticalScroll).scroll_to_widget(
490
+ list(self.query(_ChoiceRow))[index], animate=False
491
+ )
492
+
493
+ def _disarm(self) -> None:
494
+ self._cancel_armed = False
495
+ self._paint()
496
+
497
+ def _activate(self, index: int) -> None:
498
+ panel = self._panel
499
+ if panel is None or self._disabled_reason() is not None:
500
+ return
501
+ confirming = self._cancel_armed and index == self._selected
502
+ self._select(index)
503
+ choice = panel.choices[index]
504
+ if self._editable(index):
505
+ self._open_answer(index)
506
+ elif choice.kind is ChoiceKind.CANCEL and not confirming:
507
+ self._cancel_armed = True
508
+ self._paint()
509
+ else:
510
+ self._send_choice(choice)
511
+
512
+ def _editable(self, index: int) -> bool:
513
+ panel = self._panel
514
+ if panel is None or index >= len(panel.choices) or self._disabled_reason() is not None:
515
+ return False
516
+ choice = panel.choices[index]
517
+ return choice.kind is ChoiceKind.OWN_ANSWER or choice.needs_input
518
+
519
+ def _open_answer(self, index: int) -> None:
520
+ if not self._editable(index):
521
+ return
522
+ self._show_answer(index)
523
+ box = self.query_one("#resolution-answer", _AnswerBox)
524
+ box.move_cursor(box.document.end)
525
+ self.set_focus(box)
526
+
527
+ def _show_answer(self, index: int | None) -> None:
528
+ if index != self._answer_for:
529
+ self._answer_for = index
530
+ box = self.query_one("#resolution-answer", _AnswerBox)
531
+ if index is not None:
532
+ self.query_one("#resolution-choices", _ChoiceList).move_child(
533
+ box, after=list(self.query(_ChoiceRow))[index]
534
+ )
535
+ box.load_text(self._draft() if index == self._own_answer_index() else "")
536
+ self._relayout()
537
+ self._paint()
538
+
539
+ def _send_choice(self, choice: PanelChoice) -> None:
540
+ if choice.kind is ChoiceKind.ANSWER_OPTION:
541
+ self._start_send(ResolutionKind.ANSWER, choice.label or "")
542
+ else:
543
+ self._start_send(ResolutionKind(choice.kind.value), "")
544
+
545
+ def _start_send(self, kind: ResolutionKind, answer: str) -> None:
546
+ if self._sending or self._disabled_reason() is not None:
547
+ return
548
+ self._sending = True
549
+ self._paint()
550
+ self.run_worker(self._send(kind, answer), group=_SEND_GROUP)
551
+
552
+ async def _send(self, kind: ResolutionKind, answer: str) -> None:
553
+ try:
554
+ command = self._commands.prepare_palette_resolution(
555
+ self._escalation_id, kind, answer=answer, retry_rung=None
556
+ )
557
+ if self._queries.pending_command(command) is None:
558
+ ack = await self._commands.send(command)
559
+ if ack.status is AckStatus.REJECTED:
560
+ self._fail(command_rejection_cause(ack.result))
561
+ return
562
+ except ShikiruError as error:
563
+ self._fail(str(error))
564
+ return
565
+ finally:
566
+ self._sending = False
567
+ self.dismiss(True)
568
+
569
+ def _fail(self, reason: str) -> None:
570
+ self._cancel_armed = False
571
+ self._send_error = reason
572
+ self._paint()
573
+ self._relayout()