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
shikiru/ui/popup.py ADDED
@@ -0,0 +1,311 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from textual.containers import Vertical
6
+ from textual.css.scalar import Unit
7
+ from textual.geometry import Size
8
+
9
+ from shikiru.ui.keys import key_hint
10
+ from shikiru.ui.screen import widget_is_attached
11
+
12
+ if TYPE_CHECKING:
13
+ from rich.text import Text
14
+ from textual import events
15
+ from textual.widget import Widget
16
+
17
+ POPUP_FRAME_CLASS = "popup-frame"
18
+ POPUP_HEADER_CLASS = "popup-header"
19
+ POPUP_TITLE_CLASS = "popup-title"
20
+ POPUP_CLOSE_CLASS = "popup-close-control"
21
+ POPUP_RULE_CLASS = "popup-rule"
22
+ POPUP_BODY_CLASS = "popup-body"
23
+ POPUP_SECONDARY_CLASS = "popup-secondary"
24
+ POPUP_EDIT_CLASS = "popup-edit"
25
+ POPUP_ROW_CLASS = "palette-row"
26
+ POPUP_CLOSE_LABEL = "Esc \N{MULTIPLICATION SIGN}"
27
+
28
+
29
+ def popup_close_label() -> Text:
30
+ """Return the Esc close label with its key in the key style."""
31
+ return key_hint(POPUP_CLOSE_LABEL)
32
+
33
+
34
+ class PopupFrame(Vertical):
35
+ """Keep a content-sized popup frame stable when its screen is resized."""
36
+
37
+ def __init__(
38
+ self,
39
+ *children: Widget,
40
+ name: str | None = None,
41
+ widget_id: str | None = None,
42
+ classes: str | None = None,
43
+ disabled: bool = False,
44
+ markup: bool = True,
45
+ ) -> None:
46
+ super().__init__(
47
+ *children,
48
+ name=name,
49
+ id=widget_id,
50
+ classes=classes,
51
+ disabled=disabled,
52
+ markup=markup,
53
+ )
54
+ self._content_ready = False
55
+ self._preferred_size: Size | None = None
56
+ self._applied_size: Size | None = None
57
+
58
+ def content_ready(self) -> None:
59
+ """Mark meaningful initial content as ready for preferred-size capture."""
60
+ if not self.is_mounted or not widget_is_attached(self):
61
+ return
62
+ self._content_ready = True
63
+ self.call_after_refresh(self.update_preferred_size)
64
+
65
+ def reset_preferred_size(self) -> None:
66
+ """Forget the prior measurement when a popup changes content purpose."""
67
+ self._preferred_size = None
68
+ self._applied_size = None
69
+ self._content_ready = False
70
+ self.remove_class("stable")
71
+ self.styles.width = "auto"
72
+ self.styles.height = "auto"
73
+
74
+ def on_resize(self, event: events.Resize) -> None:
75
+ del event
76
+ self.call_after_refresh(self.update_preferred_size)
77
+
78
+ def update_preferred_size(self) -> None:
79
+ """Capture an unclamped outer size once, then clamp it to the current screen."""
80
+ if not self.is_mounted or not widget_is_attached(self):
81
+ return
82
+ viewport = self.screen.size
83
+ if self._preferred_size is None:
84
+ self._remember_preferred_size()
85
+ if self._preferred_size is not None:
86
+ self._apply_preferred_size(viewport)
87
+
88
+ def _remember_preferred_size(self) -> None:
89
+ if not self._content_ready:
90
+ return
91
+ height = self.styles.height
92
+ children = tuple(self.children)
93
+ viewport = self.screen.size
94
+ natural_width = (
95
+ max(
96
+ (child.virtual_size.width + child.styles.margin.width for child in children),
97
+ default=0,
98
+ )
99
+ + self.styles.gutter.width
100
+ )
101
+ max_width = self.styles.max_width
102
+ max_width_cells = max_width.cells if max_width is not None else None
103
+ preferred_width = max(
104
+ self.outer_size.width,
105
+ max_width_cells if max_width_cells is not None else natural_width,
106
+ )
107
+ relative_height = height is not None and height.unit in {
108
+ Unit.PERCENT,
109
+ Unit.HEIGHT,
110
+ Unit.VIEW_HEIGHT,
111
+ }
112
+ height_clamped = self.outer_size.height >= viewport.height - self.styles.margin.height
113
+ if relative_height and not height_clamped:
114
+ natural_height = self.outer_size.height
115
+ elif relative_height:
116
+ natural_height = sum(_preferred_child_height(child) for child in children)
117
+ natural_height += self.styles.gutter.height
118
+ else:
119
+ content_width = max(0, preferred_width - self.styles.gutter.width)
120
+ natural_height = sum(
121
+ _preferred_child_height_at_width(child, viewport, content_width)
122
+ for child in children
123
+ )
124
+ natural_height += self.styles.gutter.height
125
+ preferred_height = (
126
+ natural_height
127
+ if relative_height and height_clamped
128
+ else max(self.outer_size.height, natural_height)
129
+ )
130
+ self._preferred_size = Size(
131
+ preferred_width,
132
+ preferred_height,
133
+ )
134
+ self.add_class("stable")
135
+
136
+ def _apply_preferred_size(self, viewport: Size) -> None:
137
+ if self._preferred_size is None:
138
+ return
139
+ margin = self.styles.margin
140
+ minimum_height = sum(_minimum_child_height(child) for child in self.children)
141
+ minimum_height += self.styles.gutter.height
142
+ size = Size(
143
+ max(1, min(self._preferred_size.width, viewport.width - margin.width)),
144
+ max(
145
+ 1,
146
+ min(
147
+ max(self._preferred_size.height, minimum_height),
148
+ viewport.height - margin.height,
149
+ ),
150
+ ),
151
+ )
152
+ if size != self._applied_size:
153
+ self.styles.width = size.width
154
+ self.styles.height = size.height
155
+ self._applied_size = size
156
+
157
+
158
+ def _preferred_child_height(child: Widget) -> int:
159
+ if not child.display:
160
+ height = child.styles.height
161
+ fixed_height = height.cells if height is not None else None
162
+ return (fixed_height or 0) + child.styles.margin.height
163
+ height = child.styles.height
164
+ fixed_height = height.cells if height is not None else None
165
+ return (
166
+ max(child.outer_size.height, child.virtual_size.height, fixed_height or 0)
167
+ + child.styles.margin.height
168
+ )
169
+
170
+
171
+ def _preferred_child_height_at_width(child: Widget, viewport: Size, width: int) -> int:
172
+ if not child.display:
173
+ return _preferred_child_height(child)
174
+ content_width = max(0, width - child.styles.gutter.width)
175
+ content_height = child.get_content_height(Size(content_width, 0), viewport, content_width)
176
+ height = child.styles.height
177
+ fixed_height = height.cells if height is not None else None
178
+ return (
179
+ max(
180
+ child.outer_size.height,
181
+ fixed_height or 0,
182
+ content_height + child.styles.gutter.height,
183
+ )
184
+ + child.styles.margin.height
185
+ )
186
+
187
+
188
+ def _minimum_child_height(child: Widget) -> int:
189
+ if not child.display:
190
+ return 0
191
+ height = child.styles.height
192
+ fixed_height = height.cells if height is not None else None
193
+ minimum_height = child.styles.min_height
194
+ minimum_height_cells = minimum_height.cells if minimum_height is not None else None
195
+ flexible_height = height is not None and height.unit is Unit.FRACTION
196
+ outer_height = 0 if flexible_height else child.outer_size.height
197
+ return (
198
+ max(outer_height, fixed_height or 0, minimum_height_cells or 0) + child.styles.margin.height
199
+ )
200
+
201
+
202
+ POPUP_BUTTON_CSS = """
203
+ .popup-frame Button.-style-default {
204
+ color: $primary-text; background: $surface; border: solid $neutral-border;
205
+ border-top: solid $neutral-border; border-bottom: solid $neutral-border;
206
+ text-style: none;
207
+ }
208
+ .popup-frame Button.-style-default.-primary {
209
+ border: solid $neutral-border;
210
+ border-top: solid $neutral-border; border-bottom: solid $neutral-border;
211
+ }
212
+ .popup-frame Button.-style-default:focus,
213
+ .popup-frame Button.-style-default.-primary:focus {
214
+ color: $primary-text; background: $selected-surface;
215
+ border: solid $focus; border-top: solid $focus; border-bottom: solid $focus;
216
+ text-style: reverse;
217
+ }
218
+ .popup-frame Button.-style-default:disabled,
219
+ .popup-frame Button.-style-default.-primary:disabled {
220
+ color: $secondary-text; background: $surface;
221
+ border: solid $neutral-border;
222
+ border-top: solid $neutral-border; border-bottom: solid $neutral-border;
223
+ text-opacity: 76%;
224
+ }
225
+ """
226
+
227
+ POPUP_CONTROL_CSS = """
228
+ .popup-frame Button:focus {
229
+ color: $primary-text; background: $selected-surface; text-style: reverse;
230
+ }
231
+ .popup-frame Button:disabled { color: $secondary-text; }
232
+ .popup-frame SelectCurrent {
233
+ border: solid $neutral-border; padding: 0 1; background: $detail-surface;
234
+ }
235
+ .popup-frame Select:focus > SelectCurrent,
236
+ .popup-frame Select.-expanded > SelectCurrent {
237
+ border: solid $focus; background: $selected-surface;
238
+ }
239
+ .popup-frame SelectOverlay {
240
+ border: solid $neutral-border; scrollbar-size-vertical: $scrollbar-width;
241
+ }
242
+ .popup-frame SelectOverlay:focus { border: solid $focus; }
243
+ .popup-frame TextArea { scrollbar-size-vertical: $scrollbar-width; }
244
+ .popup-frame TextArea.popup-edit {
245
+ height: auto; min-height: 2; max-height: 6; padding: 0;
246
+ border: none; border-bottom: solid $neutral-border; background: $surface;
247
+ }
248
+ .popup-frame TextArea.popup-edit > .text-area--cursor-line { background: transparent; }
249
+ .popup-frame TextArea.popup-edit:focus {
250
+ border: none; border-bottom: solid $focus; background: $selected-surface;
251
+ }
252
+ .popup-frame Select:disabled > SelectCurrent { color: $secondary-text; }
253
+ .popup-frame Button.popup-close-control {
254
+ width: auto; min-width: 5; height: 1; margin: 0; padding: 0 1;
255
+ border: none; border-top: none; border-bottom: none;
256
+ background: $surface; color: $primary-text; text-style: none;
257
+ }
258
+ .popup-frame Button.popup-close-control.-style-default:focus {
259
+ border: none; border-top: none; border-right: none; border-bottom: none; border-left: none;
260
+ background: $selected-surface; color: $primary-text; text-style: reverse;
261
+ }
262
+ """
263
+
264
+ POPUP_COMPACT_CONTROL_CSS = """
265
+ .palette-row { layout: horizontal; height: auto; margin-bottom: 0; }
266
+ .palette-row Label { width: 9; height: 1; color: $secondary-text; text-style: none; }
267
+ .palette-row Input, .palette-row Select { width: 1fr; }
268
+ .palette-row Input {
269
+ height: 1; border: none; padding: 0 1; background: $surface;
270
+ }
271
+ .palette-row Input:focus {
272
+ border: none; background: $selected-surface; text-style: none;
273
+ }
274
+ .popup-frame .palette-row SelectCurrent {
275
+ height: 1; border: none; padding: 0 1; background: $detail-surface;
276
+ }
277
+ .popup-frame .palette-row Select:focus > SelectCurrent,
278
+ .popup-frame .palette-row Select.-expanded > SelectCurrent {
279
+ border: none; background: $selected-surface;
280
+ }
281
+ """
282
+
283
+ POPUP_CSS = (
284
+ """
285
+ .popup-frame {
286
+ width: 100%; max-width: 64; height: auto; max-height: 100%; margin: 1 2;
287
+ padding: 1 2; border: solid $steel-blue; background: $surface; color: $primary-text;
288
+ }
289
+ .popup-header { width: 100%; height: 1; }
290
+ .popup-title { width: 1fr; height: 1; color: $primary-text; text-style: bold; }
291
+ .popup-frame Rule.popup-rule { width: 100%; height: 1; margin: 0; color: $neutral-border; }
292
+ .popup-body {
293
+ height: auto; max-height: 1fr; min-height: 0; padding: 0;
294
+ }
295
+ .popup-frame.stable > .popup-body { height: 1fr; }
296
+ .popup-body, .popup-frame #task-detail-body {
297
+ overflow-x: hidden; overflow-y: auto;
298
+ scrollbar-size-horizontal: 0; scrollbar-size-vertical: $scrollbar-width;
299
+ }
300
+ .popup-secondary { color: $secondary-text; }
301
+ """
302
+ + POPUP_BUTTON_CSS
303
+ + POPUP_CONTROL_CSS
304
+ )
305
+
306
+
307
+ def prepare_popup(screen: Widget) -> None:
308
+ screen.styles.align_horizontal = "center"
309
+ screen.styles.align_vertical = "middle"
310
+ screen.styles.background = "transparent"
311
+ screen.styles.padding = 0
@@ -0,0 +1,26 @@
1
+ """The status line of an agent pane whose turn or dispatch waits on a quota block."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from shikiru.domain.quota_waits import QUOTA_BANNER_SEPARATOR, quota_reset_clock
8
+ from shikiru.ui.glyphs import GLYPH_QUOTA
9
+
10
+ if TYPE_CHECKING:
11
+ from datetime import datetime
12
+
13
+ from shikiru.domain.quota_waits import QuotaAction
14
+
15
+ QUOTA_BLOCKED_STATUS = "Quota blocked"
16
+
17
+
18
+ def quota_status_text(action: QuotaAction | None, at: datetime | None, now: datetime) -> str:
19
+ """Return the pane status line; the engine's next `action` and its time show only when known.
20
+
21
+ `at` and `now` are rendered in the zone they carry, so callers pass the viewer's local time.
22
+ """
23
+ text = f"{GLYPH_QUOTA} {QUOTA_BLOCKED_STATUS}"
24
+ if action is None or at is None:
25
+ return text
26
+ return f"{text}{QUOTA_BANNER_SEPARATOR}{action.value} {quota_reset_clock(at, now)}"
@@ -0,0 +1,78 @@
1
+ """Paged window over the most recent terminal tasks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class RecentWindow:
10
+ """A fixed-size window at `offset` over `total` most recent tasks, newest first."""
11
+
12
+ offset: int
13
+ size: int
14
+ total: int
15
+
16
+ @property
17
+ def shown(self) -> int:
18
+ """Return how many tasks the window holds; the oldest page may be short."""
19
+ return max(min(self.size, self.total - self.offset), 0)
20
+
21
+ @property
22
+ def paged(self) -> bool:
23
+ """Return whether more tasks exist than one window holds."""
24
+ return self.total > self.size
25
+
26
+ @property
27
+ def has_later(self) -> bool:
28
+ """Return whether a newer page exists."""
29
+ return self.offset > 0
30
+
31
+ @property
32
+ def has_earlier(self) -> bool:
33
+ """Return whether an older page exists."""
34
+ return self.offset + self.size < self.total
35
+
36
+ def at_page_of(self, position: int) -> RecentWindow:
37
+ """Return the window whose page holds the task at absolute `position`."""
38
+ return RecentWindow(position // self.size * self.size, self.size, self.total)
39
+
40
+ def clamped(self) -> RecentWindow:
41
+ """Return the window moved to the last page when `offset` lies past the end."""
42
+ return self.at_page_of(max(min(self.offset, self.total - 1), 0))
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class StepTarget:
47
+ """Where Up/Down lands: a visible row, or a Recent task on another page."""
48
+
49
+ visible_index: int | None = None
50
+ recent_position: int | None = None
51
+
52
+
53
+ def step_selection(
54
+ active_count: int,
55
+ window: RecentWindow,
56
+ current: int | None,
57
+ step: int,
58
+ ) -> StepTarget:
59
+ """Step one row through active rows then the visible Recent rows, paging at window edges.
60
+
61
+ `current` indexes that visible order, or is `None` when the selection is not visible.
62
+ Entering Recent from the active rows never pages; leaving the window pages by one task.
63
+ """
64
+ count = active_count + window.shown
65
+ if current is None:
66
+ return StepTarget(visible_index=0 if step > 0 else count - 1)
67
+ if window.shown:
68
+ if current == count - 1 and step > 0:
69
+ if window.has_earlier:
70
+ return StepTarget(recent_position=window.offset + window.shown)
71
+ if active_count == 0:
72
+ return StepTarget(recent_position=0)
73
+ if current == active_count and step < 0:
74
+ if window.has_later:
75
+ return StepTarget(recent_position=window.offset - 1)
76
+ if active_count == 0:
77
+ return StepTarget(recent_position=window.total - 1)
78
+ return StepTarget(visible_index=(current + step) % count)
shikiru/ui/screen.py ADDED
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
4
+
5
+ from textual.dom import NoScreen
6
+ from textual.screen import Screen
7
+
8
+ if TYPE_CHECKING:
9
+ from textual.widget import Widget
10
+
11
+
12
+ @runtime_checkable
13
+ class ConditionalTabStop(Protocol):
14
+ """A widget that Tab and Shift+Tab visit only while `is_tab_stop()` holds."""
15
+
16
+ def is_tab_stop(self) -> bool: ...
17
+
18
+
19
+ class TabStopScreen(Screen[object]):
20
+ """Keep widgets that are not stops now out of the Tab order, except the focused widget."""
21
+
22
+ @property
23
+ def focus_chain(self) -> list[Widget]:
24
+ focused = self.focused
25
+ return [
26
+ widget
27
+ for widget in super().focus_chain
28
+ if widget is focused
29
+ or not isinstance(widget, ConditionalTabStop)
30
+ or widget.is_tab_stop()
31
+ ]
32
+
33
+
34
+ def is_first_child(widget: Widget) -> bool:
35
+ return widget.parent is not None and widget.parent.children[0] is widget
36
+
37
+
38
+ def widget_is_attached(widget: Widget) -> bool:
39
+ return _screen_for(widget) is not None
40
+
41
+
42
+ def widget_is_on_screen(widget: Widget, screen: object) -> bool:
43
+ return _screen_for(widget) is screen
44
+
45
+
46
+ def widget_is_current(widget: Widget) -> bool:
47
+ screen = _screen_for(widget)
48
+ return screen is not None and screen.is_current
49
+
50
+
51
+ def _screen_for(widget: Widget) -> Screen[object] | None:
52
+ if not widget.is_attached:
53
+ return None
54
+ try:
55
+ return widget.screen
56
+ except NoScreen:
57
+ return None
shikiru/ui/section.py ADDED
@@ -0,0 +1,193 @@
1
+ """Rule-and-heading sections with labelled, separator-wrapped rows shared by the panels."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, replace
6
+ from typing import TYPE_CHECKING
7
+
8
+ from rich.cells import cell_len
9
+ from rich.console import Group, RenderableType
10
+ from rich.rule import Rule
11
+ from rich.table import Table
12
+ from rich.text import Text
13
+
14
+ from shikiru.ui.age import AGE_NOW
15
+ from shikiru.ui.tokens import (
16
+ AMBER,
17
+ NEUTRAL_BORDER_COLOR,
18
+ PRIMARY_TEXT_COLOR,
19
+ SECONDARY_TEXT_COLOR,
20
+ STEEL_BLUE,
21
+ STYLE_BOLD,
22
+ )
23
+
24
+ if TYPE_CHECKING:
25
+ from rich.console import Console, ConsoleOptions, RenderResult
26
+
27
+ GROUP_SEPARATOR = " · "
28
+ LABEL_GAP = 1
29
+ MIN_VALUE_WIDTH = 24
30
+ VALUE_OVERFLOW = "fold"
31
+ HEADING_MIN_GAP = 1
32
+ STACKED_BELOW_WIDTH = 32
33
+ OBSERVED_WORD = "observed"
34
+ AGO_WORD = "ago"
35
+ UNKNOWN_AGE = "\N{EN DASH}"
36
+ STALE_WORD = "stale"
37
+ FAILED_WORD = "failed"
38
+ UNAVAILABLE_WORD = "unavailable"
39
+ BOLD_STEEL = f"{STYLE_BOLD} {STEEL_BLUE}"
40
+ BOLD_PRIMARY = f"{STYLE_BOLD} {PRIMARY_TEXT_COLOR}"
41
+
42
+ Row = tuple[Text, tuple[Text, ...]]
43
+
44
+
45
+ def grey(text: str) -> Text:
46
+ """Return `text` in the secondary grey."""
47
+ return Text(text, style=SECONDARY_TEXT_COLOR)
48
+
49
+
50
+ def row_name(text: str) -> Text:
51
+ """Return `text` as a bold white row name."""
52
+ return Text(text, style=BOLD_PRIMARY)
53
+
54
+
55
+ def number(value: str) -> Text:
56
+ """Return `value` as a bold cyan figure."""
57
+ return Text(value, style=BOLD_STEEL)
58
+
59
+
60
+ def key_label(key: str, label: str) -> Text:
61
+ """Return `key` in the bold cyan key style followed by its grey `label`."""
62
+ return Text.assemble((key, BOLD_STEEL), grey(f" {label}"))
63
+
64
+
65
+ def observed_text(observed_ago: str) -> str:
66
+ """Return `observed <age> ago`, or `observed now`."""
67
+ suffix = "" if observed_ago == AGE_NOW else f" {AGO_WORD}"
68
+ return f"{OBSERVED_WORD} {observed_ago}{suffix}"
69
+
70
+
71
+ def observation(observed_ago: str, *, stale: bool) -> Text | None:
72
+ """Return the amber `stale · observed <age> ago` heading note, or nothing unless `stale`."""
73
+ if not stale:
74
+ return None
75
+ return Text(f"{STALE_WORD}{GROUP_SEPARATOR}{observed_text(observed_ago)}", style=AMBER)
76
+
77
+
78
+ def state_line(word: str, colour: str, error: str | None) -> Text:
79
+ """Return a sampling state `word` in `colour`, with any recorded `error` after it."""
80
+ detail = f"{GROUP_SEPARATOR}{error}" if error else ""
81
+ return Text(f"{word}{detail}", style=colour)
82
+
83
+
84
+ def wrap_groups(
85
+ groups: tuple[Text, ...], width: int | None, separator: str = GROUP_SEPARATOR
86
+ ) -> Text:
87
+ """Join `groups` with grey `separator`s, breaking lines only between groups."""
88
+ result = Text()
89
+ line_width = 0
90
+ for group in groups:
91
+ gap = separator if line_width else ""
92
+ group_width = cell_len(group.plain)
93
+ exceeds_width = line_width + cell_len(gap) + group_width > width if width else False
94
+ if width is not None and line_width and exceeds_width:
95
+ result.append("\n")
96
+ line_width = 0
97
+ gap = ""
98
+ result.append(gap, style=SECONDARY_TEXT_COLOR)
99
+ result.append(group)
100
+ line_width += cell_len(gap) + group_width
101
+ return result
102
+
103
+
104
+ class WrappedGroups:
105
+ """Renderable that wraps its groups with `separator` at the width it is rendered in."""
106
+
107
+ def __init__(self, groups: tuple[Text, ...], separator: str = GROUP_SEPARATOR) -> None:
108
+ self._groups = groups
109
+ self._separator = separator
110
+
111
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
112
+ del console
113
+ yield wrap_groups(self._groups, options.max_width, self._separator)
114
+
115
+
116
+ def labelled_rows(
117
+ rows: list[Row], width: int, label_width: int, stack_below: int = STACKED_BELOW_WIDTH
118
+ ) -> RenderableType:
119
+ """Lay rows out as a label column and wrapped values, stacked below `stack_below` cells."""
120
+ value_width = width - label_width - LABEL_GAP
121
+ value_indent = labelled_value_indent(width, label_width, stack_below)
122
+ if value_indent == 0 and width < stack_below and label_width <= width - LABEL_GAP:
123
+ return Group(*_stacked_lines(rows, width))
124
+ if value_indent == 0:
125
+ grid = Table.grid()
126
+ grid.add_column(width=width, overflow=VALUE_OVERFLOW)
127
+ for label, groups in rows:
128
+ if label.plain:
129
+ grid.add_row(label)
130
+ grid.add_row(wrap_groups(groups, width))
131
+ return grid
132
+ grid = Table.grid()
133
+ grid.add_column(width=label_width + LABEL_GAP, no_wrap=True)
134
+ grid.add_column(width=value_width, overflow=VALUE_OVERFLOW)
135
+ for label, groups in rows:
136
+ grid.add_row(label, wrap_groups(groups, value_width))
137
+ return grid
138
+
139
+
140
+ def labelled_value_indent(
141
+ width: int, label_width: int, stack_below: int = STACKED_BELOW_WIDTH
142
+ ) -> int:
143
+ """Return the value cell's indent when `labelled_rows` has a value column."""
144
+ value_width = width - label_width - LABEL_GAP
145
+ if value_width < MIN_VALUE_WIDTH:
146
+ return 0
147
+ if width < stack_below and label_width <= width - LABEL_GAP:
148
+ return 0
149
+ return label_width + LABEL_GAP
150
+
151
+
152
+ def _stacked_lines(rows: list[Row], width: int) -> list[Text]:
153
+ lines: list[Text] = []
154
+ for label, groups in rows:
155
+ if label.plain:
156
+ lines.append(label)
157
+ lines.append(wrap_groups(groups, width))
158
+ return lines
159
+
160
+
161
+ @dataclass(frozen=True)
162
+ class Section:
163
+ """Render `body` below an optional rule and a heading, with an optional `trailing`."""
164
+
165
+ title: str
166
+ body: RenderableType
167
+ trailing: Text | None = None
168
+ ruled: bool = True
169
+ title_style: str = SECONDARY_TEXT_COLOR
170
+
171
+ def with_ruled(self, *, ruled: bool) -> Section:
172
+ """Return this section drawn with or without its leading rule."""
173
+ return replace(self, ruled=ruled)
174
+
175
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
176
+ """Yield the rule when ruled, the heading and the body."""
177
+ del console
178
+ if self.ruled:
179
+ yield Rule(style=NEUTRAL_BORDER_COLOR)
180
+ yield self._heading(options.max_width)
181
+ yield self.body
182
+
183
+ def _heading(self, width: int) -> RenderableType:
184
+ title = Text()
185
+ title.append(self.title, style=self.title_style)
186
+ if self.trailing is None:
187
+ return title
188
+ gap = width - cell_len(title.plain) - cell_len(self.trailing.plain)
189
+ if gap < HEADING_MIN_GAP:
190
+ return Group(title, self.trailing)
191
+ title.append(" " * gap)
192
+ title.append(self.trailing)
193
+ return title