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,402 @@
1
+ """The `escalations` widget: open packets as splash panels."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, cast
6
+
7
+ from rich.console import Group, RenderableType
8
+ from rich.text import Text
9
+ from textual.containers import VerticalScroll
10
+ from textual.widgets import Static
11
+
12
+ from shikiru.dashboard.contract import (
13
+ FOCUS_FAULTS_VIEW,
14
+ AttentionGroupView,
15
+ AttentionView,
16
+ EscalationPanelView,
17
+ WidgetPlacement,
18
+ WorkspaceSummaryView,
19
+ )
20
+ from shikiru.ui import SplashPanel
21
+ from shikiru.ui.compact import (
22
+ ACTION_HINT,
23
+ FOCUS_ATTENTION_MAX_HEIGHT,
24
+ FocusAttentionStatus,
25
+ display_escalation_headline,
26
+ )
27
+ from shikiru.ui.gated_static import GatedStatic
28
+ from shikiru.ui.keys import key_hint
29
+ from shikiru.ui.screen import widget_is_attached, widget_is_current
30
+ from shikiru.ui.tokens import SECONDARY_TEXT_COLOR
31
+
32
+ if TYPE_CHECKING:
33
+ from collections.abc import Callable
34
+
35
+ from textual import events
36
+ from textual.app import App, ComposeResult
37
+ from textual.widget import Widget
38
+
39
+ from shikiru.dashboard.contract import WidgetContext
40
+
41
+ _SELECTED_MARKER = "▶ "
42
+ _MOVE_STEPS = {"up": -1, "down": 1}
43
+ _OPEN_TASK_KEY = "enter"
44
+ MAX_ESCALATION_PANEL_HEIGHT = 12
45
+ ARMED_CLASS = "armed"
46
+
47
+
48
+ class EscalationsPanel(Static):
49
+ """Show grouped decisions, diagnosed stalls and recorded engine faults."""
50
+
51
+ DEFAULT_CSS = f"""
52
+ EscalationsPanel {{ height: auto; }}
53
+ #escalation-panels {{ height: auto; max-height: {MAX_ESCALATION_PANEL_HEIGHT}; }}
54
+ EscalationsPanel.focus-row {{ width: 100%; }}
55
+ EscalationsPanel.focus-row #escalation-panels {{
56
+ width: 100%; height: auto; max-height: {FOCUS_ATTENTION_MAX_HEIGHT};
57
+ }}
58
+ EscalationsPanel.focus-row #escalation-panels Static {{ width: 100%; }}
59
+ """
60
+ can_focus = True
61
+
62
+ def __init__(self, context: WidgetContext) -> None:
63
+ """Hold `context`; views are read on mount and on each state change."""
64
+ classes = "focus-row" if context.placement is WidgetPlacement.ATTENTION else ""
65
+ super().__init__(id="escalations", classes=classes)
66
+ self._widget_context = context
67
+ self._focus_row = context.placement is WidgetPlacement.ATTENTION
68
+ self._attention = AttentionView((), decisions=0, stalls=0, faults=0)
69
+ self._summary: WorkspaceSummaryView | None = None
70
+ self._selected = 0
71
+ self._followed_task_id: str | None = None
72
+ self._rendered_selection: tuple[object, ...] | None = None
73
+ self._has_rendered = False
74
+ self._keep_empty_visible = False
75
+ self._focus_requested = False
76
+ self._remove_motion_callback: Callable[[], None] | None = None
77
+
78
+ def follow_task(self, task_id: str | None) -> None:
79
+ """Show the decision of the selected task, or that it has none; `None` clears the follow."""
80
+ if task_id == self._followed_task_id:
81
+ return
82
+ self._followed_task_id = task_id
83
+ if task_id is None:
84
+ self._selected = 0
85
+ if widget_is_attached(self):
86
+ self.call_later(self._refresh)
87
+
88
+ def allow_focus(self) -> bool:
89
+ """Join the Tab chain only while it holds attention groups or was opened on request."""
90
+ return bool(self._attention.groups) or self._focus_requested
91
+
92
+ def compose(self) -> ComposeResult:
93
+ """Reserve a bounded, scrollable area for home attention groups."""
94
+ yield VerticalScroll(id="escalation-panels", can_focus=False)
95
+
96
+ async def on_mount(self) -> None:
97
+ """Render current attention and refresh on state or motion changes."""
98
+ await self._refresh()
99
+ self.run_worker(self._widget_context.on_change(self._refresh_if_attached), exclusive=True)
100
+ self._remove_motion_callback = self._widget_context.on_motion(self._refresh_while_active)
101
+
102
+ def on_unmount(self) -> None:
103
+ if self._remove_motion_callback is not None:
104
+ self._remove_motion_callback()
105
+ self._remove_motion_callback = None
106
+
107
+ def on_focus(self, event: events.Focus) -> None:
108
+ del event
109
+ self._keep_empty_visible = True
110
+
111
+ def on_blur(self, event: events.Blur) -> None:
112
+ del event
113
+ self._focus_requested = False
114
+ if self._focus_row:
115
+ self.display = True
116
+
117
+ async def _refresh_while_active(self) -> None:
118
+ if widget_is_current(self) and cast("App[None]", self.app).is_running:
119
+ await self._refresh()
120
+
121
+ async def _refresh_if_attached(self) -> None:
122
+ if widget_is_attached(self):
123
+ await self._refresh()
124
+
125
+ def activate_attention(self) -> None:
126
+ """Show the attention content, retaining its useful empty state while selected."""
127
+ self._keep_empty_visible = True
128
+ self._focus_requested = True
129
+ self.display = True
130
+ self.focus()
131
+
132
+ async def activate_selected_decision(self) -> bool:
133
+ if not self._focus_row or not widget_is_current(self):
134
+ return False
135
+ decision = self._selected_decision()
136
+ if decision is None:
137
+ return False
138
+ await self._open_resolution(decision)
139
+ return True
140
+
141
+ async def on_key(self, event: events.Key) -> None:
142
+ """Open the selected task or its resolution."""
143
+ if event.key in _MOVE_STEPS and not self._attention.groups:
144
+ self._handle_vertical_key(event)
145
+ return
146
+ if not self._attention.groups:
147
+ return
148
+ if event.key == _OPEN_TASK_KEY:
149
+ if self._focus_row:
150
+ await self._activate_focus_task(event)
151
+ else:
152
+ await self._open_task_detail(event)
153
+
154
+ async def on_click(self, event: events.Click) -> None:
155
+ if not self._focus_row or not self._attention.groups:
156
+ return
157
+ self.focus()
158
+ if self._not_escalated_task_id() is not None:
159
+ event.stop()
160
+ return
161
+ group = self._attention.groups[self._selected]
162
+ if group.task_id is not None and self._widget_context.on_task_selected is not None:
163
+ self._widget_context.on_task_selected(group.task_id)
164
+ if group.escalations:
165
+ await self._open_resolution(self._selected_decision() or group.escalations[0])
166
+ elif self._focus_row and group.task_id is not None:
167
+ event.stop()
168
+ elif group.task_id is not None and self._widget_context.open_task_detail is not None:
169
+ await self._widget_context.open_task_detail(group.task_id)
170
+ else:
171
+ await self._open_fault_evidence()
172
+ event.stop()
173
+
174
+ def _handle_vertical_key(self, event: events.Key) -> None:
175
+ if self._focus_row:
176
+ panels = self.query_one("#escalation-panels", VerticalScroll)
177
+ panels.scroll_to(
178
+ y=panels.scroll_y + _MOVE_STEPS[event.key],
179
+ animate=False,
180
+ immediate=True,
181
+ )
182
+ event.stop()
183
+
184
+ async def _open_task_detail(self, event: events.Key) -> None:
185
+ group = self._attention.groups[self._selected]
186
+ if group.task_id is not None and self._widget_context.open_task_detail is not None:
187
+ on_task_selected = self._widget_context.on_task_selected
188
+ if on_task_selected is not None:
189
+ on_task_selected(group.task_id)
190
+ await self._widget_context.open_task_detail(group.task_id)
191
+ event.stop()
192
+
193
+ async def _activate_focus_task(self, event: events.Key) -> None:
194
+ if self._not_escalated_task_id() is not None:
195
+ event.stop()
196
+ return
197
+ decision = self._selected_decision()
198
+ if decision is not None:
199
+ await self._open_resolution(decision)
200
+ event.stop()
201
+ return
202
+ group = self._attention.groups[self._selected]
203
+ if group.task_id is not None and self._widget_context.on_task_selected is not None:
204
+ self._widget_context.on_task_selected(group.task_id)
205
+ event.stop()
206
+
207
+ async def _open_resolution(self, decision: EscalationPanelView) -> None:
208
+ if not widget_is_current(self):
209
+ return
210
+ if self._widget_context.open_resolution is not None:
211
+ await self._widget_context.open_resolution(decision.escalation_id)
212
+
213
+ async def _open_fault_evidence(self) -> None:
214
+ open_view = self._widget_context.open_contextual_view
215
+ if open_view is not None:
216
+ await open_view(FOCUS_FAULTS_VIEW)
217
+
218
+ async def _refresh(self) -> None:
219
+ if not widget_is_attached(self):
220
+ return
221
+ attention = self._widget_context.views.attention()
222
+ summary = self._widget_context.views.workspace_summary()
223
+ current_status = _attention_status_state(attention, summary)
224
+ previous_status = (
225
+ _attention_status_state(self._attention, self._summary)
226
+ if self._summary is not None
227
+ else None
228
+ )
229
+ groups_unchanged = attention == self._attention
230
+ had_selection = bool(self._attention.groups)
231
+ selected_id = self._selected_group_id()
232
+ self._attention = attention
233
+ self._summary = summary
234
+ self.display = self._focus_row or bool(attention.groups) or self._keep_empty_visible
235
+ if not attention.groups:
236
+ self._selected = 0
237
+ elif had_selection:
238
+ selected_index = next(
239
+ (
240
+ index
241
+ for index, group in enumerate(attention.groups)
242
+ if group.task_id == selected_id
243
+ ),
244
+ None,
245
+ )
246
+ self._selected = selected_index if selected_index is not None else 0
247
+ else:
248
+ self._selected = min(self._selected, len(attention.groups) - 1)
249
+ self._follow_selected_task()
250
+ unchanged = groups_unchanged and current_status == previous_status
251
+ if unchanged and self._has_rendered and self._rendered_selection == self._selection_state():
252
+ return
253
+ await self._render_panels()
254
+ self._has_rendered = True
255
+
256
+ def _follow_selected_task(self) -> None:
257
+ followed = self._followed_task_id
258
+ if followed is None:
259
+ return
260
+ groups = self._attention.groups
261
+ candidates = [index for index, group in enumerate(groups) if group.task_id == followed] or [
262
+ index for index, group in enumerate(groups) if not group.escalations
263
+ ]
264
+ if candidates:
265
+ self._selected = candidates[0]
266
+
267
+ def _not_escalated_task_id(self) -> str | None:
268
+ followed = self._followed_task_id
269
+ if followed is None or not self._focus_row:
270
+ return None
271
+ has_row = any(
272
+ group.task_id == followed or not group.escalations for group in self._attention.groups
273
+ )
274
+ return None if has_row else followed
275
+
276
+ def _selection_state(self) -> tuple[object, ...]:
277
+ return (self._selected, self._selected_escalation_id(), self._not_escalated_task_id())
278
+
279
+ def _selected_group_id(self) -> str | None:
280
+ if not self._attention.groups or self._selected >= len(self._attention.groups):
281
+ return None
282
+ return self._attention.groups[self._selected].task_id
283
+
284
+ def _selected_decision(self) -> EscalationPanelView | None:
285
+ if self._not_escalated_task_id() is not None:
286
+ return None
287
+ if not self._attention.groups or self._selected >= len(self._attention.groups):
288
+ return None
289
+ escalations = self._attention.groups[self._selected].escalations
290
+ return escalations[0] if escalations else None
291
+
292
+ def _selected_escalation_id(self) -> str | None:
293
+ decision = self._selected_decision()
294
+ return decision.escalation_id if decision is not None else None
295
+
296
+ async def _render_panels(self) -> None:
297
+ self._rendered_selection = self._selection_state()
298
+ self.set_class(self._focus_row and self._selected_decision() is not None, ARMED_CLASS)
299
+ panels = self.query_one("#escalation-panels", VerticalScroll)
300
+ scroll_x, scroll_y = panels.scroll_x, panels.scroll_y
301
+ if self._focus_row or not self._attention.groups:
302
+ status = (
303
+ self._render_compact_attention()
304
+ if self._focus_row
305
+ else "No open decisions, diagnosed stalls or recorded engine faults."
306
+ )
307
+ children = list(panels.query(GatedStatic))
308
+ if len(children) == 1:
309
+ children[0].update_if_changed(status)
310
+ return
311
+ await self._replace_panels(panels, [GatedStatic(status, markup=False)])
312
+ return
313
+ children = list(panels.query(GatedStatic))
314
+ if len(children) == len(self._attention.groups):
315
+ for index, (card, group) in enumerate(
316
+ zip(children, self._attention.groups, strict=True)
317
+ ):
318
+ card.update_if_changed(self._render_group(index, group))
319
+ panels.scroll_to(x=scroll_x, y=scroll_y, animate=False)
320
+ return
321
+ cards = [
322
+ GatedStatic(self._render_group(index, group), markup=False)
323
+ for index, group in enumerate(self._attention.groups)
324
+ ]
325
+ if await self._replace_panels(panels, cards):
326
+ panels.scroll_to(x=scroll_x, y=scroll_y, animate=False)
327
+
328
+ async def _replace_panels(self, panels: VerticalScroll, cards: list[GatedStatic]) -> bool:
329
+ await panels.remove_children()
330
+ if not widget_is_attached(self):
331
+ return False
332
+ await panels.mount(*cards)
333
+ return widget_is_attached(self)
334
+
335
+ def _render_compact_attention(self) -> RenderableType:
336
+ if self._summary is None:
337
+ return Text("○ Engine status unavailable", style=SECONDARY_TEXT_COLOR)
338
+ decision = self._selected_decision()
339
+ action_hint = ACTION_HINT
340
+ if self._focus_row and decision is not None:
341
+ action_hint = "Enter answer ↵" if decision.answer_available else "Enter resolve ↵"
342
+ return FocusAttentionStatus(
343
+ self._attention,
344
+ self._summary,
345
+ action_hint=action_hint,
346
+ selected_group_index=self._selected,
347
+ selected_escalation_id=self._selected_escalation_id(),
348
+ not_escalated_task_id=self._not_escalated_task_id(),
349
+ )
350
+
351
+ def _render_group(self, index: int, group: AttentionGroupView) -> Group:
352
+ marker = _SELECTED_MARKER if index == self._selected else " "
353
+ identity = f"{group.task_id} · {group.title}" if group.task_id is not None else group.title
354
+ lines = [f"{marker}{identity}"]
355
+ lines.extend(f" {reason}" for reason in group.reasons)
356
+ content: list[RenderableType] = ["\n".join(lines)]
357
+ selected_id = self._selected_escalation_id()
358
+ for item in group.escalations:
359
+ shown = display_escalation_headline(item.task_id, item.headline, item.reason)
360
+ headline = f"{item.escalation_id} · {shown} · {item.age}"
361
+ if index == self._selected and item.escalation_id == selected_id:
362
+ headline = f"{_SELECTED_MARKER}{headline}"
363
+ content.append(
364
+ SplashPanel(
365
+ headline,
366
+ item.question or "Evidence unavailable",
367
+ item.options,
368
+ item.pulse_red,
369
+ ).render()
370
+ )
371
+ if item.resolution_actions:
372
+ content.append(f" Allowed actions: {', '.join(item.resolution_actions)}")
373
+ if item.evidence_error is not None:
374
+ content.append(f" Evidence error: {item.evidence_error}")
375
+ if group.task_id is not None:
376
+ content.append(key_hint(" Enter: task detail"))
377
+ return Group(*content)
378
+
379
+
380
+ def _attention_status_state(
381
+ attention: AttentionView,
382
+ summary: WorkspaceSummaryView,
383
+ ) -> tuple[object, ...]:
384
+ return (
385
+ attention,
386
+ summary.engine_up,
387
+ summary.held_tasks,
388
+ summary.holds,
389
+ summary.operator_owned,
390
+ summary.held_task_states,
391
+ )
392
+
393
+
394
+ class EscalationsWidget:
395
+ """The `escalations` dashboard widget plugin."""
396
+
397
+ name = "escalations"
398
+ placement = WidgetPlacement.ATTENTION
399
+
400
+ def create(self, context: WidgetContext) -> Widget:
401
+ """Build the escalation panel for `context`."""
402
+ return EscalationsPanel(context)
@@ -0,0 +1,204 @@
1
+ """The `faults` widget: recorded open engine fault streaks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from rich.console import Group
8
+ from rich.text import Text
9
+ from textual.containers import ScrollableContainer, Vertical
10
+ from textual.widgets import Collapsible, Static
11
+
12
+ from shikiru.dashboard.contract import WidgetPlacement
13
+ from shikiru.ui.gated_static import GatedStatic
14
+ from shikiru.ui.screen import is_first_child, widget_is_attached
15
+ from shikiru.ui.section import GROUP_SEPARATOR, Section, grey, row_name
16
+
17
+ if TYPE_CHECKING:
18
+ from collections.abc import Callable
19
+
20
+ from textual.app import ComposeResult
21
+ from textual.widget import Widget
22
+
23
+ from shikiru.dashboard.contract import EngineFaultView, FaultDetailRow, WidgetContext
24
+
25
+ SECTION_TITLE = "Faults"
26
+ TITLE_SEPARATOR = " \N{MIDDLE DOT} "
27
+ PROJECT_TARGET = "project"
28
+ DETAILS_TITLE = "Details"
29
+ COLLAPSED_MARK = "\N{BLACK RIGHT-POINTING SMALL TRIANGLE}"
30
+ EXPANDED_MARK = "\N{BLACK DOWN-POINTING SMALL TRIANGLE}"
31
+ IMPACT_LABEL = "Impact:"
32
+ CAUSE_LABEL = "Cause:"
33
+ NEXT_LABEL = "Next:"
34
+ DETAIL_INDENT = " "
35
+
36
+
37
+ def _summary(fault: EngineFaultView) -> Group:
38
+ headline = Text.assemble(
39
+ row_name(fault.task_id or PROJECT_TARGET),
40
+ grey(GROUP_SEPARATOR),
41
+ Text(fault.scope),
42
+ grey(GROUP_SEPARATOR),
43
+ Text(fault.status),
44
+ )
45
+ cause = Text.assemble(grey(f"{CAUSE_LABEL} "), Text(fault.cause))
46
+ return Group(
47
+ headline,
48
+ Text.assemble(grey(f"{IMPACT_LABEL} "), Text(fault.impact)),
49
+ cause,
50
+ Text.assemble(grey(f"{NEXT_LABEL} "), Text(fault.next_step)),
51
+ )
52
+
53
+
54
+ def _detail_lines(row: FaultDetailRow) -> Text:
55
+ lines = row.value.strip().splitlines() or [row.value]
56
+ if len(lines) == 1:
57
+ return Text.assemble(grey(f"{row.label} "), Text(lines[0]))
58
+ body = "\n".join(f"{DETAIL_INDENT}{line}" for line in lines)
59
+ return Text.assemble(grey(row.label), Text("\n"), Text(body))
60
+
61
+
62
+ def _details(fault: EngineFaultView) -> Group:
63
+ return Group(*(_detail_lines(row) for row in fault.details))
64
+
65
+
66
+ class FaultCard(Vertical):
67
+ """One open fault: its compact summary and a Details disclosure that starts collapsed."""
68
+
69
+ DEFAULT_CSS = """
70
+ FaultCard { height: auto; margin-bottom: 1; }
71
+ FaultCard Collapsible { padding: 0; border-top: none; }
72
+ FaultCard Collapsible > Contents { padding: 0 0 0 2; }
73
+ """
74
+
75
+ def __init__(self, fault: EngineFaultView) -> None:
76
+ """Show `fault`; later `show` calls update it in place, keeping the disclosure state."""
77
+ super().__init__()
78
+ self._summary = GatedStatic(_summary(fault))
79
+ self._details = Static(_details(fault))
80
+ self._fault = fault
81
+
82
+ def compose(self) -> ComposeResult:
83
+ """Lay out the summary above the collapsed Details disclosure."""
84
+ yield self._summary
85
+ yield Collapsible(
86
+ self._details,
87
+ title=DETAILS_TITLE,
88
+ collapsed=True,
89
+ collapsed_symbol=COLLAPSED_MARK,
90
+ expanded_symbol=EXPANDED_MARK,
91
+ )
92
+
93
+ def show(self, fault: EngineFaultView) -> None:
94
+ """Repaint from `fault`, the latest view of the same fault identity."""
95
+ if fault == self._fault:
96
+ return
97
+ self._fault = fault
98
+ self._summary.update_if_changed(_summary(fault), key=fault)
99
+ self._details.update(_details(fault))
100
+
101
+
102
+ class FaultsPanel(Vertical):
103
+ """Show one card per open fault and take no room while there are none."""
104
+
105
+ DEFAULT_CSS = "FaultsPanel { height: auto; }"
106
+
107
+ def __init__(self, context: WidgetContext) -> None:
108
+ """Hold the read-only dashboard view and its state-change subscription."""
109
+ super().__init__(id="faults")
110
+ self._widget_context = context
111
+ self._unsubscribe_resources: Callable[[], None] | None = None
112
+ self._title = GatedStatic()
113
+ self._cards: dict[str, FaultCard] = {}
114
+ self._pending_anchor: tuple[Widget, ScrollableContainer, int] | None = None
115
+
116
+ def compose(self) -> ComposeResult:
117
+ """Start with the title; cards are mounted as faults open."""
118
+ yield self._title
119
+
120
+ def on_mount(self) -> None:
121
+ """Render fault cards; refresh with the Resources bar and on durable changes."""
122
+ self._refresh()
123
+ self._unsubscribe_resources = self._widget_context.views.on_resources_refreshed(
124
+ self._refresh
125
+ )
126
+ self.run_worker(self._widget_context.on_change(self._refresh_if_attached), exclusive=True)
127
+
128
+ def on_unmount(self) -> None:
129
+ if self._unsubscribe_resources is not None:
130
+ self._unsubscribe_resources()
131
+ self._unsubscribe_resources = None
132
+
133
+ async def _refresh_if_attached(self) -> None:
134
+ self._refresh()
135
+
136
+ def _refresh(self) -> None:
137
+ if not widget_is_attached(self):
138
+ return
139
+ faults = self._widget_context.views.engine_faults()
140
+ self.display = bool(faults)
141
+ ruled = not is_first_child(self)
142
+ title = f"{SECTION_TITLE}{TITLE_SEPARATOR}{len(faults)}"
143
+ self._title.update_if_changed(Section(title, Group(), ruled=ruled), key=(title, ruled))
144
+ open_keys = {fault.key for fault in faults}
145
+ for key in [key for key in self._cards if key not in open_keys]:
146
+ self._cards.pop(key).remove()
147
+ cards = [self._card(fault) for fault in faults]
148
+ anchor = self._anchor()
149
+ self._arrange(cards)
150
+ if anchor is not None and self._pending_anchor is None:
151
+ self._pending_anchor = anchor
152
+ self.call_after_refresh(self._restore)
153
+
154
+ def _anchor(self) -> tuple[Widget, ScrollableContainer, int] | None:
155
+ focused = self.screen.focused
156
+ if focused is None or self not in focused.ancestors:
157
+ return None
158
+ scroller = next(
159
+ (node for node in focused.ancestors if isinstance(node, ScrollableContainer)), None
160
+ )
161
+ if scroller is None:
162
+ return None
163
+ return focused, scroller, focused.region.y
164
+
165
+ def _restore(self) -> None:
166
+ anchor, self._pending_anchor = self._pending_anchor, None
167
+ if anchor is None:
168
+ return
169
+ focused, scroller, old_y = anchor
170
+ if not focused.is_attached or self.screen.focused is not focused:
171
+ return
172
+ shift = focused.region.y - old_y
173
+ if shift:
174
+ scroller.scroll_to(y=scroller.scroll_y + shift, animate=False, immediate=True)
175
+
176
+ def _card(self, fault: EngineFaultView) -> FaultCard:
177
+ card = self._cards.get(fault.key)
178
+ if card is None:
179
+ card = self._cards[fault.key] = FaultCard(fault)
180
+ else:
181
+ card.show(fault)
182
+ return card
183
+
184
+ def _arrange(self, cards: list[FaultCard]) -> None:
185
+ previous: Widget = self._title
186
+ placed = [child for child in self.children if child in cards]
187
+ in_order = placed == [card for card in cards if card in placed]
188
+ for card in cards:
189
+ if card.parent is None:
190
+ self.mount(card, after=previous)
191
+ elif not in_order:
192
+ self.move_child(card, after=previous)
193
+ previous = card
194
+
195
+
196
+ class FaultsWidget:
197
+ """The `faults` dashboard widget plugin."""
198
+
199
+ name = "faults"
200
+ placement = WidgetPlacement.DIAGNOSTICS
201
+
202
+ def create(self, context: WidgetContext) -> Widget:
203
+ """Build the fault diagnostics panel for `context`."""
204
+ return FaultsPanel(context)