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,244 @@
1
+ """Folds of the task details: a header with a live headline above a body that shows when open."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, ClassVar
6
+
7
+ from textual.binding import Binding
8
+ from textual.containers import Vertical
9
+ from textual.message import Message
10
+ from textual.widgets import Static
11
+
12
+ from shikiru.dashboard.details_list_view import DetailsEntry, entry_renderable
13
+ from shikiru.dashboard.row_list import RowList
14
+ from shikiru.ui.tokens import STEEL_BLUE
15
+
16
+ if TYPE_CHECKING:
17
+ from collections.abc import Sequence
18
+
19
+ from textual import events
20
+ from textual.app import ComposeResult
21
+ from textual.binding import BindingType
22
+ from textual.widget import Widget
23
+
24
+ OPEN_MARK = "\N{BLACK DOWN-POINTING SMALL TRIANGLE}"
25
+ CLOSED_MARK = "\N{BLACK RIGHT-POINTING SMALL TRIANGLE}"
26
+ _STEP_UP = -1
27
+ _STEP_DOWN = 1
28
+ _DISPLAY_NONE = "none"
29
+
30
+
31
+ class FoldHeader(Static):
32
+ """One focusable header row; ↑↓ move, Enter opens and enters or folds, Space toggles."""
33
+
34
+ BINDINGS: ClassVar[list[BindingType]] = [
35
+ Binding("up", f"move({_STEP_UP})", "Previous section", show=False),
36
+ Binding("down", f"move({_STEP_DOWN})", "Next section", show=False),
37
+ Binding("enter", "enter", "Open or close", show=False),
38
+ Binding("space", "toggle_open", "Open or close", show=False),
39
+ ]
40
+ DEFAULT_CSS = """
41
+ FoldHeader { height: 1; width: 100%; }
42
+ FoldHeader:focus { background: $selected-surface; background-tint: transparent; }
43
+ """
44
+
45
+ def __init__(self, fold: DetailFold) -> None:
46
+ super().__init__("", markup=False)
47
+ self._fold = fold
48
+
49
+ def allow_focus(self) -> bool:
50
+ """Only the current header is a Tab stop, so the whole list is one stop."""
51
+ return self._fold.is_current
52
+
53
+ def on_focus(self) -> None:
54
+ self._fold.set_focused(focused=True)
55
+
56
+ def on_blur(self) -> None:
57
+ self._fold.set_focused(focused=False)
58
+
59
+ def on_click(self, event: events.Click) -> None:
60
+ event.stop()
61
+ self._fold.folds.focus_header(self._fold)
62
+ self._fold.toggle()
63
+
64
+ def action_move(self, step: int) -> None:
65
+ """Focus the neighbouring header."""
66
+ self._fold.folds.move(self._fold, step)
67
+
68
+ def action_enter(self) -> None:
69
+ """Fold an open fold; open a closed one and enter its list."""
70
+ self._fold.toggle()
71
+
72
+ def action_toggle_open(self) -> None:
73
+ """Open or fold the body without leaving the header."""
74
+ self._fold.set_open(not self._fold.is_open)
75
+
76
+
77
+ class DetailFold(Vertical):
78
+ """A section header with a live headline and a body whose display follows the fold state."""
79
+
80
+ DEFAULT_CSS = """
81
+ DetailFold { height: auto; }
82
+ DetailFold .fold-body { height: auto; padding: 0 0 1 2; }
83
+ """
84
+
85
+ class Toggled(Message):
86
+ """The fold opened or closed."""
87
+
88
+ def __init__(self, fold: DetailFold) -> None:
89
+ super().__init__()
90
+ self.fold = fold
91
+
92
+ def __init__(
93
+ self,
94
+ view_id: str,
95
+ title: str,
96
+ body: Sequence[Widget],
97
+ *,
98
+ is_open: bool = False,
99
+ name_color: str = STEEL_BLUE,
100
+ ) -> None:
101
+ """Hold the section's view id, name in its colour, and body."""
102
+ super().__init__()
103
+ self.view_id = view_id
104
+ self.title = title
105
+ self.headline: str | None = None
106
+ self.name_color = name_color
107
+ self.header = FoldHeader(self)
108
+ self._body = Vertical(*body, classes="fold-body")
109
+ self._open = is_open
110
+ self._focused = False
111
+ self._body.display = self._open
112
+
113
+ def compose(self) -> ComposeResult:
114
+ """Yield the header, then the body."""
115
+ yield self.header
116
+ yield self._body
117
+
118
+ def on_mount(self) -> None:
119
+ """Paint the header once it exists."""
120
+ self.paint()
121
+
122
+ @property
123
+ def folds(self) -> DetailFolds:
124
+ """The list this fold belongs to: the nearest one above it, so a nested fold has its own."""
125
+ return next(node for node in self.ancestors if isinstance(node, DetailFolds))
126
+
127
+ @property
128
+ def is_shown(self) -> bool:
129
+ """Whether the header is on screen: shown itself, inside no hidden or folded fold."""
130
+ if self.styles.display == _DISPLAY_NONE:
131
+ return False
132
+ enclosing = next((node for node in self.ancestors if isinstance(node, DetailFold)), None)
133
+ return enclosing is None or (enclosing.is_open and enclosing.is_shown)
134
+
135
+ @property
136
+ def is_open(self) -> bool:
137
+ """Whether the body is shown."""
138
+ return self._open
139
+
140
+ @property
141
+ def is_current(self) -> bool:
142
+ """Whether the header is the list's Tab stop."""
143
+ return self.folds.is_current(self)
144
+
145
+ @property
146
+ def focus_target(self) -> Widget:
147
+ """The row list inside an open body, otherwise the header."""
148
+ lists = self._body.query(RowList) if self._open else None
149
+ return lists.first(RowList) if lists else self.header
150
+
151
+ def set_open(self, is_open: bool) -> None:
152
+ """Show or hide the body and tell the owner when the state changed."""
153
+ if is_open == self._open:
154
+ return
155
+ self._open = is_open
156
+ self._body.display = is_open
157
+ self.paint()
158
+ self.post_message(self.Toggled(self))
159
+
160
+ def set_focused(self, *, focused: bool) -> None:
161
+ """Highlight the header while it has focus."""
162
+ self._focused = focused
163
+ self.paint()
164
+
165
+ def set_headline(self, headline: str | None) -> None:
166
+ """Replace the grey right-hand headline."""
167
+ if headline != self.headline:
168
+ self.headline = headline
169
+ self.paint()
170
+
171
+ def activate(self) -> None:
172
+ """Open the fold and move into its list when it has one."""
173
+ self.set_open(True)
174
+ self.call_after_refresh(self.folds.focus_fold, self)
175
+
176
+ def toggle(self) -> None:
177
+ """Fold an open fold back to its header; open a closed one and enter its list."""
178
+ if self._open:
179
+ self.set_open(False)
180
+ self.folds.focus_header(self)
181
+ else:
182
+ self.activate()
183
+
184
+ def paint(self) -> None:
185
+ """Draw the header: mark, name and headline, highlighted while it has focus."""
186
+ if not self.header.is_mounted:
187
+ return
188
+ mark = OPEN_MARK if self._open else CLOSED_MARK
189
+ entry = DetailsEntry(
190
+ self.view_id, f"{mark} {self.title}", self.headline, name_color=self.name_color
191
+ )
192
+ self.header.update(entry_renderable(entry, selected=self._focused))
193
+
194
+
195
+ class DetailFolds(Vertical):
196
+ """The list of folds: one Tab stop, ↑↓ between headers."""
197
+
198
+ DEFAULT_CSS = "DetailFolds { height: auto; border-top: solid $neutral-border; }"
199
+
200
+ def __init__(self, *, widget_id: str) -> None:
201
+ super().__init__(id=widget_id)
202
+ self._current: DetailFold | None = None
203
+
204
+ @property
205
+ def all_folds(self) -> list[DetailFold]:
206
+ """Every fold in display order, shown or not."""
207
+ return list(self.query(DetailFold))
208
+
209
+ @property
210
+ def visible_folds(self) -> list[DetailFold]:
211
+ """The folds shown, in display order; a fold inside a closed or hidden one has no header."""
212
+ return [fold for fold in self.all_folds if fold.is_shown]
213
+
214
+ @property
215
+ def current_fold(self) -> DetailFold:
216
+ """The fold holding the roving Tab stop: the last one focused, else the first shown."""
217
+ visible = self.visible_folds
218
+ if self._current is not None and self._current in visible:
219
+ return self._current
220
+ return visible[0]
221
+
222
+ def is_current(self, fold: DetailFold) -> bool:
223
+ """Whether `fold` holds the roving Tab stop; a hidden fold never does."""
224
+ return fold.is_shown and self.current_fold is fold
225
+
226
+ def make_current(self, fold: DetailFold) -> None:
227
+ """Move the Tab stop to `fold`."""
228
+ self._current = fold
229
+
230
+ def move(self, fold: DetailFold, step: int) -> None:
231
+ """Focus the header `step` places from `fold`, stopping at both ends."""
232
+ folds = self.visible_folds
233
+ target = folds[min(max(folds.index(fold) + step, 0), len(folds) - 1)]
234
+ self.focus_header(target)
235
+
236
+ def focus_header(self, fold: DetailFold) -> None:
237
+ """Make `fold` the Tab stop and focus its header."""
238
+ self.make_current(fold)
239
+ self.screen.set_focus(fold.header)
240
+
241
+ def focus_fold(self, fold: DetailFold) -> None:
242
+ """Focus the row list of an open fold, or its header."""
243
+ self.make_current(fold)
244
+ self.screen.set_focus(fold.focus_target)
@@ -0,0 +1,68 @@
1
+ """The one hint line at the bottom of the task details, following the focused part."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import StrEnum
6
+
7
+ from rich.text import Text
8
+ from textual.widgets import Static
9
+
10
+ from shikiru.ui.section import BOLD_STEEL, grey
11
+
12
+ _ARROWS = "\N{UPWARDS ARROW}\N{DOWNWARDS ARROW}"
13
+ _SEPARATOR = " · "
14
+ _BACK = ("Esc", "back")
15
+ _SELECT = (_ARROWS, "select")
16
+ _FOLLOW = ("End", "follow")
17
+ _TASK_WINDOW = ("t", "task window")
18
+
19
+
20
+ class HintTarget(StrEnum):
21
+ """What holds focus inside the task details."""
22
+
23
+ HEADER = "header"
24
+ EVENTS = "events"
25
+ AGENT = "agent"
26
+ AGENT_FILTER = "agent_filter"
27
+ DEPENDENCIES = "dependencies"
28
+
29
+
30
+ _HINTS: dict[HintTarget, tuple[tuple[str, str], ...]] = {
31
+ HintTarget.HEADER: ((_ARROWS, "move"), ("Enter", "open/close")),
32
+ HintTarget.EVENTS: (_SELECT, _FOLLOW, _BACK),
33
+ HintTarget.AGENT: (_SELECT, _FOLLOW, ("f", "filter"), _BACK),
34
+ HintTarget.AGENT_FILTER: (("r", "role"), ("t", "turn"), ("x", "clear"), _BACK),
35
+ HintTarget.DEPENDENCIES: (_SELECT, ("Enter", "open"), _BACK),
36
+ }
37
+
38
+
39
+ def hint_for(target: HintTarget, *, task_window: bool, keys_only: bool) -> Text:
40
+ """Return the hint for `target`: cyan bold keys, grey labels, only keys in tiny panes.
41
+
42
+ Esc is named only inside a list, where it returns to the header.
43
+ """
44
+ entries = _HINTS[target] + ((_TASK_WINDOW,) if task_window else ())
45
+ hint = Text()
46
+ for index, (key, label) in enumerate(entries):
47
+ if index:
48
+ hint.append_text(grey(_SEPARATOR))
49
+ hint.append(key, style=BOLD_STEEL)
50
+ if not keys_only:
51
+ hint.append_text(grey(f" {label}"))
52
+ return hint
53
+
54
+
55
+ class DetailHintBar(Static):
56
+ """One-row line below the scrolling details; hidden while nothing in them has focus."""
57
+
58
+ def __init__(self, *, widget_id: str) -> None:
59
+ super().__init__("", id=widget_id, markup=False)
60
+ self.display = False
61
+ self._hint: Text | None = None
62
+
63
+ def show(self, hint: Text | None) -> None:
64
+ """Show `hint`, or hide the line when there is none."""
65
+ self.display = hint is not None
66
+ if hint is not None and hint != self._hint:
67
+ self.update(hint)
68
+ self._hint = hint
@@ -0,0 +1,150 @@
1
+ """Rows of the task detail folds: section names, their data headlines and the row renderable."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import StrEnum
7
+ from typing import TYPE_CHECKING
8
+
9
+ from rich.cells import cell_len
10
+ from rich.text import Text
11
+
12
+ from shikiru.dashboard.briefing_view import total_cost_text
13
+ from shikiru.dashboard.fold_bodies import priority_word
14
+ from shikiru.domain.results import ResultOutcome
15
+ from shikiru.domain.tasks import Status
16
+ from shikiru.ui.section import GROUP_SEPARATOR, grey
17
+ from shikiru.ui.tokens import STEEL_BLUE, STYLE_BOLD
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Callable
21
+
22
+ from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
23
+
24
+ from shikiru.dashboard.contract import TaskDetailView
25
+
26
+ ROW_LEAD = " "
27
+ _HEADLINE_GAP = " "
28
+ DECISION_OPEN = "question open"
29
+ _PR_WORD = "PR"
30
+ _DECISION_NOUNS = ("decision", "decisions")
31
+ _COMMIT_NOUNS = {True: "commit", False: "commits"}
32
+ _BRANCH_WORD = "branch"
33
+ _PRIORITY_WORD = "priority"
34
+ _DEPENDENCY_NOUNS = ("dependency", "dependencies")
35
+
36
+
37
+ class DetailsSection(StrEnum):
38
+ """The task detail sections, in fold order, named as the headers show them."""
39
+
40
+ REQUEST = "Task description"
41
+ RESULTS = "Results"
42
+ DECISIONS = "Decisions"
43
+ EVENTS = "Events"
44
+ AGENT_ACTIVITY = "Agent activity"
45
+ USAGE = "Usage"
46
+ BLOCKERS = "Dependencies"
47
+ BLOCKER_EVIDENCE = "Evidence"
48
+ WORKSPACE = "Workspace"
49
+ SETTINGS = "Settings"
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class DetailsEntry:
54
+ """One fold header row: what it opens, its name in its colour and its optional grey headline."""
55
+
56
+ entry_id: str
57
+ name: str
58
+ headline: str | None = None
59
+ name_color: str = STEEL_BLUE
60
+
61
+
62
+ def section_headline(section: DetailsSection, view: TaskDetailView | None) -> str | None:
63
+ """Return a section's headline from recorded fields, or nothing when it has none."""
64
+ headline = _HEADLINES.get(section)
65
+ return headline(view) if headline is not None and view is not None else None
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class _EntryLine:
70
+ entry: DetailsEntry
71
+ selected: bool
72
+
73
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
74
+ del console
75
+ entry = self.entry
76
+ room = max(options.max_width - len(ROW_LEAD), 1)
77
+ headline = entry.headline or ""
78
+ if headline and cell_len(entry.name) + cell_len(_HEADLINE_GAP) + cell_len(headline) > room:
79
+ headline = ""
80
+ name = Text(entry.name, style=_name_style(entry, selected=self.selected))
81
+ name.truncate(room - cell_len(headline), overflow="ellipsis", pad=True)
82
+ line = Text(ROW_LEAD)
83
+ line.append_text(name)
84
+ line.append_text(grey(headline))
85
+ yield line
86
+
87
+
88
+ def entry_renderable(entry: DetailsEntry, *, selected: bool) -> RenderableType:
89
+ """Return one row: the name in its colour (bold when selected), grey headline at the right."""
90
+ return _EntryLine(entry, selected)
91
+
92
+
93
+ def _name_style(entry: DetailsEntry, *, selected: bool) -> str:
94
+ return f"{STYLE_BOLD} {entry.name_color}" if selected else entry.name_color
95
+
96
+
97
+ def _usage_headline(view: TaskDetailView) -> str | None:
98
+ return total_cost_text(view.sessions)
99
+
100
+
101
+ def _workspace_headline(view: TaskDetailView) -> str | None:
102
+ return f"{_BRANCH_WORD} {view.branch}" if view.branch else None
103
+
104
+
105
+ def _settings_headline(view: TaskDetailView) -> str | None:
106
+ parts = [
107
+ view.mode or "",
108
+ f"{priority_word(view.priority)} {_PRIORITY_WORD}" if view.priority is not None else "",
109
+ ]
110
+ return GROUP_SEPARATOR.join(part for part in parts if part) or None
111
+
112
+
113
+ def _results_headline(view: TaskDetailView) -> str | None:
114
+ results = view.results
115
+ if results is None:
116
+ return None
117
+ if results.outcome is ResultOutcome.SO_FAR:
118
+ return ResultOutcome.SO_FAR.value
119
+ commits = len(results.commits) + results.more_commits
120
+ pull_request = results.pull_request
121
+ parts = [
122
+ f"{commits} {_COMMIT_NOUNS[commits == 1]}" if commits else "",
123
+ f"{_PR_WORD} #{pull_request.number}"
124
+ if pull_request is not None and pull_request.number is not None
125
+ else "",
126
+ ]
127
+ return GROUP_SEPARATOR.join(part for part in parts if part) or None
128
+
129
+
130
+ def _decisions_headline(view: TaskDetailView) -> str | None:
131
+ if view.status == Status.ESCALATED.value:
132
+ return DECISION_OPEN
133
+ count = len(view.decisions.entries) if view.decisions is not None else 0
134
+ return f"{count} {_DECISION_NOUNS[count != 1]}" if count else None
135
+
136
+
137
+ def _dependencies_headline(view: TaskDetailView) -> str | None:
138
+ relationships = view.relationships
139
+ count = relationships.blocking_dependency_count if relationships is not None else 0
140
+ return f"{count} {_DEPENDENCY_NOUNS[count != 1]}" if count else None
141
+
142
+
143
+ _HEADLINES: dict[DetailsSection, Callable[[TaskDetailView], str | None]] = {
144
+ DetailsSection.RESULTS: _results_headline,
145
+ DetailsSection.DECISIONS: _decisions_headline,
146
+ DetailsSection.USAGE: _usage_headline,
147
+ DetailsSection.WORKSPACE: _workspace_headline,
148
+ DetailsSection.BLOCKERS: _dependencies_headline,
149
+ DetailsSection.SETTINGS: _settings_headline,
150
+ }
@@ -0,0 +1,80 @@
1
+ """Project-level diagnostics from the dashboard's existing observations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from textual.containers import VerticalGroup
8
+
9
+ from shikiru.dashboard.diagnostics_sections import engine_section, resources_section
10
+ from shikiru.ui.gated_static import GatedStatic
11
+ from shikiru.ui.screen import widget_is_attached
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Callable
15
+
16
+ from textual.app import ComposeResult
17
+ from textual.widget import Widget
18
+ from textual.worker import Worker
19
+
20
+ from shikiru.dashboard.contract import DashboardViews, OnChangeRegistration
21
+
22
+ DIAGNOSTICS_REFRESH_GROUP = "project-diagnostics-refresh"
23
+ RESOURCES_SECTION_ID = "project-diagnostics-resources"
24
+ ENGINE_SECTION_ID = "project-diagnostics-engine"
25
+
26
+
27
+ class ProjectDiagnosticsPanel(VerticalGroup):
28
+ """Show the cached Resources and Engine sections above every configured diagnostics widget."""
29
+
30
+ def __init__(
31
+ self,
32
+ views: DashboardViews,
33
+ on_change: OnChangeRegistration,
34
+ widgets: tuple[Widget, ...],
35
+ ) -> None:
36
+ """Use existing dashboard projections and the configured diagnostics widgets in order."""
37
+ super().__init__(id="project-diagnostics")
38
+ self._views = views
39
+ self._on_change = on_change
40
+ self._widgets = widgets
41
+ self._resources = GatedStatic(id=RESOURCES_SECTION_ID, markup=False)
42
+ self._engine = GatedStatic(id=ENGINE_SECTION_ID, markup=False)
43
+ self._change_worker: Worker[None] | None = None
44
+ self._unsubscribe_resources: Callable[[], None] | None = None
45
+
46
+ def compose(self) -> ComposeResult:
47
+ """Build the permanent sections, then the configured diagnostics widgets."""
48
+ yield self._resources
49
+ yield self._engine
50
+ yield from self._widgets
51
+
52
+ def on_mount(self) -> None:
53
+ """Render the cached observations and follow the Resources bar's refreshes and commits."""
54
+ self._refresh()
55
+ self._unsubscribe_resources = self._views.on_resources_refreshed(self._refresh)
56
+ self._change_worker = self.run_worker(
57
+ self._on_change(self._refresh_if_attached),
58
+ group=DIAGNOSTICS_REFRESH_GROUP,
59
+ )
60
+
61
+ def on_unmount(self) -> None:
62
+ if self._unsubscribe_resources is not None:
63
+ self._unsubscribe_resources()
64
+ self._unsubscribe_resources = None
65
+ if self._change_worker is not None:
66
+ self._change_worker.cancel()
67
+
68
+ async def _refresh_if_attached(self) -> None:
69
+ self._refresh()
70
+
71
+ def _refresh(self) -> None:
72
+ if not widget_is_attached(self):
73
+ return
74
+ sample = self._views.resource_sample()
75
+ status = self._views.engine_status()
76
+ fault_count = len(self._views.engine_faults())
77
+ self._resources.update_if_changed(resources_section(sample), key=sample)
78
+ self._engine.update_if_changed(
79
+ engine_section(status, fault_count), key=(status, fault_count)
80
+ )