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/console.py ADDED
@@ -0,0 +1,49 @@
1
+ """Human terminal output shared by CLI surfaces."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from rich.console import Console
8
+
9
+ OSC8_TERMINAL_PROGRAMS = frozenset(
10
+ {
11
+ "Hyper",
12
+ "WezTerm",
13
+ "contour",
14
+ "ghostty",
15
+ "vscode",
16
+ "iTerm.app",
17
+ }
18
+ )
19
+ OSC8_TERMINAL_NAMES = frozenset(
20
+ {
21
+ "alacritty",
22
+ "contour",
23
+ "foot",
24
+ "kitty",
25
+ "wezterm",
26
+ "xterm-ghostty",
27
+ "xterm-kitty",
28
+ }
29
+ )
30
+ MIN_VTE_OSC8_VERSION = 5000
31
+
32
+
33
+ def human_console(*, stderr: bool = False) -> Console:
34
+ """Return a Rich console that keeps human data literal and detects terminal output modes."""
35
+ return Console(stderr=stderr, soft_wrap=True, markup=False, highlight=False, emoji=False)
36
+
37
+
38
+ def supports_osc8(console: Console) -> bool:
39
+ """Return whether the active terminal is known to support OSC 8 hyperlinks."""
40
+ if not console.is_terminal or console.is_dumb_terminal:
41
+ return False
42
+ if os.environ.get("WT_SESSION"):
43
+ return True
44
+ if os.environ.get("TERM_PROGRAM") in OSC8_TERMINAL_PROGRAMS:
45
+ return True
46
+ if os.environ.get("TERM") in OSC8_TERMINAL_NAMES:
47
+ return True
48
+ vte_version = os.environ.get("VTE_VERSION", "")
49
+ return vte_version.isdecimal() and int(vte_version) >= MIN_VTE_OSC8_VERSION
shikiru/ui/fold.py ADDED
@@ -0,0 +1,50 @@
1
+ """The focusable one-line fold heading shared by the popups that expand a section in place."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, ClassVar
6
+
7
+ from textual.binding import Binding
8
+ from textual.message import Message
9
+ from textual.widgets import Static
10
+
11
+ from shikiru.ui.section import key_label
12
+
13
+ if TYPE_CHECKING:
14
+ from rich.text import Text
15
+ from textual import events
16
+ from textual.binding import BindingType
17
+
18
+ COLLAPSED_ARROW = "\N{BLACK RIGHT-POINTING SMALL TRIANGLE}"
19
+ EXPANDED_ARROW = "\N{BLACK DOWN-POINTING SMALL TRIANGLE}"
20
+ FOLD_TOGGLE_KEY = "t"
21
+ FOLD_TOGGLE_LABEL = "show/hide"
22
+
23
+
24
+ def fold_arrow(*, expanded: bool) -> str:
25
+ """Return the arrow that shows whether a fold is open."""
26
+ return EXPANDED_ARROW if expanded else COLLAPSED_ARROW
27
+
28
+
29
+ def fold_toggle_hint() -> Text:
30
+ """Return the `t show/hide` hint shown at the right of a fold heading."""
31
+ return key_label(FOLD_TOGGLE_KEY, FOLD_TOGGLE_LABEL)
32
+
33
+
34
+ class FoldLine(Static, can_focus=True):
35
+ """A fold heading that asks to toggle on Enter, Space or a click."""
36
+
37
+ BINDINGS: ClassVar[list[BindingType]] = [
38
+ Binding("enter,space", "toggle_fold", "Toggle", show=False),
39
+ ]
40
+
41
+ class Toggled(Message):
42
+ """The heading asked to expand or collapse its section."""
43
+
44
+ def action_toggle_fold(self) -> None:
45
+ """Ask to expand or collapse the section."""
46
+ self.post_message(self.Toggled())
47
+
48
+ def on_click(self, event: events.Click) -> None:
49
+ event.stop()
50
+ self.post_message(self.Toggled())
@@ -0,0 +1,117 @@
1
+ """A `Static` that repaints only on changed output and reflows only when its size can change."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING
7
+
8
+ from rich.console import Group
9
+ from rich.rule import Rule
10
+ from rich.text import Text
11
+ from textual.visual import Visual, visualize
12
+ from textual.widgets import Static
13
+
14
+ if TYPE_CHECKING:
15
+ from textual.strip import Strip
16
+ from textual.visual import VisualType
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class _Shown:
21
+ width: int
22
+ strips: tuple[Strip, ...]
23
+
24
+ def has_same_shape(self, other: _Shown) -> bool:
25
+ return self.width == other.width and self._extent() == other._extent()
26
+
27
+ def _extent(self) -> tuple[int, int]:
28
+ return len(self.strips), max((strip.cell_length for strip in self.strips), default=0)
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class _Signature:
33
+ width: int
34
+ selection: object
35
+ key: object
36
+
37
+
38
+ def _renderable_key(content: object) -> object | None:
39
+ if isinstance(content, str):
40
+ return content
41
+ if type(content) is Group:
42
+ keys = tuple(_renderable_key(item) for item in content.renderables)
43
+ return None if None in keys else (content.fit, keys)
44
+ if type(content) is Rule:
45
+ return (content.title, content.characters, content.style, content.end, content.align)
46
+ if type(content) is Text:
47
+ return (
48
+ content.plain,
49
+ tuple(content.spans),
50
+ content.style,
51
+ content.justify,
52
+ content.overflow,
53
+ content.no_wrap,
54
+ content.end,
55
+ content.tab_size,
56
+ )
57
+ return None
58
+
59
+
60
+ class GatedStatic(Static):
61
+ """Skips updates that show the same output; repaints without reflow when the extent holds."""
62
+
63
+ def __init__(
64
+ self,
65
+ content: VisualType = "",
66
+ *,
67
+ id: str | None = None, # noqa: A002
68
+ classes: str | None = None,
69
+ markup: bool = True,
70
+ ) -> None:
71
+ super().__init__(content, id=id, classes=classes, markup=markup)
72
+ self._shown: _Shown | None = None
73
+ self._signature: _Signature | None = None
74
+
75
+ def update(self, content: VisualType = "", *, layout: bool = True) -> None:
76
+ """Update unconditionally and forget what was last shown."""
77
+ self._shown = None
78
+ self._signature = None
79
+ super().update(content, layout=layout)
80
+
81
+ def notify_style_update(self) -> None:
82
+ """Forget the shown signature because the styles that shape the output changed."""
83
+ self._signature = None
84
+ super().notify_style_update()
85
+
86
+ def update_if_changed(self, content: VisualType, *, key: object | None = None) -> bool:
87
+ """Show `content` unless it renders identically to what is shown; True when updated.
88
+
89
+ `key`, when given, asserts that `content` renders as a pure function of it and of the
90
+ widget's width, so an equal key skips rendering altogether.
91
+ """
92
+ width = self.content_size.width
93
+ has_content_sized_width = self.styles.width is not None and self.styles.width.is_auto
94
+ if width <= 0 or has_content_sized_width:
95
+ self.update(content)
96
+ return True
97
+ render_key = key if key is not None else _renderable_key(content)
98
+ signature = (
99
+ None
100
+ if render_key is None
101
+ else _Signature(width, self.text_selection, (self._render_markup, render_key))
102
+ )
103
+ if signature is not None and signature == self._signature:
104
+ return False
105
+ visual = visualize(self, content, markup=self._render_markup)
106
+ shown = _Shown(
107
+ width,
108
+ tuple(Visual.to_strips(self, visual, width, None, self.visual_style)),
109
+ )
110
+ previous = self._shown
111
+ if previous == shown:
112
+ self._signature = signature
113
+ return False
114
+ super().update(content, layout=previous is None or not previous.has_same_shape(shown))
115
+ self._shown = shown
116
+ self._signature = signature
117
+ return True
shikiru/ui/gauge.py ADDED
@@ -0,0 +1,40 @@
1
+ """Small coloured percentage gauges."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from rich.text import Text
8
+
9
+ from shikiru.domain.levels import usage_level
10
+ from shikiru.ui.glyphs import GAUGE_GLYPHS
11
+ from shikiru.ui.tokens import USAGE_LEVEL_COLORS
12
+
13
+ GAUGE_LEVELS = len(GAUGE_GLYPHS) - 1
14
+ GAUGE_MAX_PCT = 100
15
+ PERCENT_FORMAT = ".0f"
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Gauge:
20
+ """Render a label and a glyph and percentage coloured by the usage level they reach."""
21
+
22
+ label: str
23
+ used_pct: float
24
+ warn_pct: int
25
+ alert_pct: int
26
+
27
+ def render(self) -> Text:
28
+ """Render the nearest gauge glyph and the unbounded reported percentage."""
29
+ level = round(min(max(self.used_pct, 0.0), GAUGE_MAX_PCT) * GAUGE_LEVELS / GAUGE_MAX_PCT)
30
+ glyph = GAUGE_GLYPHS[level]
31
+ color = USAGE_LEVEL_COLORS[
32
+ usage_level(self.used_pct, warn_pct=self.warn_pct, alert_pct=self.alert_pct)
33
+ ]
34
+ text = Text()
35
+ if self.label:
36
+ text.append(self.label, style="dim")
37
+ text.append(" ")
38
+ text.append(glyph, style=color)
39
+ text.append(f" {self.used_pct:{PERCENT_FORMAT}}%", style=color)
40
+ return text
shikiru/ui/glyphs.py ADDED
@@ -0,0 +1,109 @@
1
+ """The one glyph set shared by the CLI and the dashboard.
2
+
3
+ Ambiguous-width characters (`◆ ▲ ≡ · ○ ◑ ●`) are drawn one cell wide, as Textual assumes;
4
+ terminals set to CJK ambiguous-wide are unsupported. A character with an emoji variant
5
+ (`⚖ ⚙ ⏸ ⚠`) carries U+FE0E, the text-presentation variation selector, because some terminals
6
+ render it as wide color emoji without it. `is_single_cell` is the check a unit test runs over
7
+ every glyph registered here.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import unicodedata
13
+
14
+ from shikiru.domain.tasks import Role
15
+
16
+ _TEXT_PRESENTATION_SELECTOR = "︎"
17
+ _ZERO_WIDTH_CATEGORIES = frozenset({"Mn", "Me", "Cf"})
18
+ _WIDE_EAST_ASIAN_WIDTHS = frozenset({"W", "F"})
19
+
20
+ _EMOJI_CAPABLE_CODE_POINTS = frozenset({"⚖", "⚙", "⏸", "⚠"})
21
+ """The base characters we use that have an emoji-presentation variant (Unicode has no stdlib
22
+ property for this, so this is the small explicit set our own glyphs draw from)."""
23
+
24
+
25
+ def is_single_cell(glyph: str) -> bool:
26
+ """Return whether `glyph` renders as exactly one terminal cell in every terminal."""
27
+ cells = 0
28
+ characters = list(glyph)
29
+ for index, character in enumerate(characters):
30
+ if character == _TEXT_PRESENTATION_SELECTOR:
31
+ continue
32
+ if unicodedata.category(character) in _ZERO_WIDTH_CATEGORIES:
33
+ continue
34
+ if unicodedata.east_asian_width(character) in _WIDE_EAST_ASIAN_WIDTHS:
35
+ return False
36
+ followed_by_selector = (
37
+ index + 1 < len(characters) and characters[index + 1] == _TEXT_PRESENTATION_SELECTOR
38
+ )
39
+ if character in _EMOJI_CAPABLE_CODE_POINTS and not followed_by_selector:
40
+ return False
41
+ cells += 1
42
+ return cells == 1
43
+
44
+
45
+ PANEL_KANJI = ("起", "承", "転", "結")
46
+ """Decorative kanji for the strip's four panel positions: ki-shō-ten-ketsu. Kanji
47
+ are decorative marks, not state glyphs, so they are wide and excluded from `ALL_GLYPHS`."""
48
+
49
+ STAGE_TRIAGE = "⌕"
50
+ STAGE_PLAN = "≡"
51
+ STAGE_IMPLEMENT = "⚒"
52
+ STAGE_VERIFY = "⚖︎"
53
+ STAGE_MERGE = "⑂"
54
+ STAGE_PR = "⇪"
55
+
56
+ ROLE_GLYPHS: dict[Role, str] = {
57
+ Role.LEADER: "◆",
58
+ Role.IMPLEMENTER: "▲",
59
+ Role.HELPER: "✦",
60
+ }
61
+ GLYPH_OPERATOR = "⌨"
62
+ GLYPH_ENGINE = "⚙︎"
63
+
64
+ GLYPH_DONE = "✔"
65
+ GLYPH_REWORK = "↺"
66
+ GLYPH_CONSULT = "?"
67
+ GLYPH_PAUSED = "⏸︎"
68
+ GLYPH_QUOTA = "⧗"
69
+ QUOTA_LABEL = "quota"
70
+ QUOTA_MARKER = f"{GLYPH_QUOTA} {QUOTA_LABEL}"
71
+ GLYPH_STALLED = "⚠︎"
72
+ GLYPH_WARNING = "⚠︎"
73
+ GLYPH_ESCALATED = "‼"
74
+ GLYPH_BLOCKED = "⊘"
75
+ GLYPH_SLOT = "◷"
76
+ GLYPH_PENDING = "·"
77
+ GLYPH_STOPPED = "✕"
78
+ GLYPH_CURRENT_STAGE = "\u203a"
79
+
80
+ GAUGE_GLYPHS = ("○", "◔", "◑", "◕", "●")
81
+
82
+ SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
83
+
84
+ ALL_GLYPHS = (
85
+ STAGE_TRIAGE,
86
+ STAGE_PLAN,
87
+ STAGE_IMPLEMENT,
88
+ STAGE_VERIFY,
89
+ STAGE_MERGE,
90
+ STAGE_PR,
91
+ *ROLE_GLYPHS.values(),
92
+ GLYPH_OPERATOR,
93
+ GLYPH_ENGINE,
94
+ GLYPH_DONE,
95
+ GLYPH_REWORK,
96
+ GLYPH_CONSULT,
97
+ GLYPH_PAUSED,
98
+ GLYPH_QUOTA,
99
+ GLYPH_STALLED,
100
+ GLYPH_WARNING,
101
+ GLYPH_ESCALATED,
102
+ GLYPH_BLOCKED,
103
+ GLYPH_SLOT,
104
+ GLYPH_PENDING,
105
+ GLYPH_STOPPED,
106
+ GLYPH_CURRENT_STAGE,
107
+ *GAUGE_GLYPHS,
108
+ *SPINNER_FRAMES,
109
+ )
shikiru/ui/keys.py ADDED
@@ -0,0 +1,57 @@
1
+ """Key names painted in the shared cyan inside hint text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from rich.text import Text
8
+
9
+ from shikiru.ui.section import BOLD_STEEL
10
+ from shikiru.ui.tokens import SECONDARY_TEXT_COLOR, STYLE_BOLD
11
+
12
+ KEY_NAMES: tuple[str, ...] = (
13
+ "Ctrl+P",
14
+ "Ctrl+S",
15
+ "Escape",
16
+ "Esc",
17
+ "Enter",
18
+ "Tab",
19
+ "PgUp/PgDn",
20
+ "PgUp",
21
+ "PgDn",
22
+ "End",
23
+ "Up/Down",
24
+ "Left/Right",
25
+ )
26
+ KEY_SYMBOLS: tuple[str, ...] = ("↑↓", "↑", "↓", "←", "→")
27
+ KEY_LABELLED_CHARACTERS = "a-z?/"
28
+ BOLD_DISABLED_KEY = f"{STYLE_BOLD} {SECONDARY_TEXT_COLOR}"
29
+
30
+ _KEY_PATTERN = re.compile(
31
+ "|".join(
32
+ (
33
+ rf"(?<!\w)(?:{'|'.join(re.escape(name) for name in KEY_NAMES)})(?!\w)",
34
+ "|".join(re.escape(symbol) for symbol in KEY_SYMBOLS),
35
+ rf"(?<!\w)[{KEY_LABELLED_CHARACTERS}](?=:)",
36
+ )
37
+ )
38
+ )
39
+
40
+
41
+ def key_hint(text: str, base_style: str = "", *, enabled: bool = True) -> Text:
42
+ """Return `text` in `base_style` with every known key name in the cyan key style.
43
+
44
+ A disabled hint (its key does nothing now) paints the keys bold grey instead.
45
+ """
46
+ key_style = BOLD_STEEL if enabled else BOLD_DISABLED_KEY
47
+ hint = Text(text)
48
+ if base_style:
49
+ hint.stylize(base_style)
50
+ for match in _KEY_PATTERN.finditer(text):
51
+ hint.stylize(key_style, match.start(), match.end())
52
+ return hint
53
+
54
+
55
+ def append_key_hint(target: Text, text: str, base_style: str = "") -> None:
56
+ """Append `text` to `target` as `key_hint` does."""
57
+ target.append_text(key_hint(text, base_style))
@@ -0,0 +1,42 @@
1
+ """A repeating widget timer that restarts when its configured period changes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from collections.abc import Awaitable, Callable
9
+
10
+ from textual.timer import Timer
11
+ from textual.widget import Widget
12
+
13
+
14
+ class LiveInterval:
15
+ """Run `tick` every `period()` seconds, restarting the timer only when that period changes."""
16
+
17
+ def __init__(
18
+ self,
19
+ widget: Widget,
20
+ period: Callable[[], float],
21
+ tick: Callable[[], Awaitable[None]],
22
+ ) -> None:
23
+ """Hold the timer target; nothing runs until `apply`."""
24
+ self._widget = widget
25
+ self._period = period
26
+ self._tick = tick
27
+ self._applied: float | None = None
28
+ self._timer: Timer | None = None
29
+
30
+ def apply(self) -> None:
31
+ """Tick at the current period, replacing the running timer when the period differs."""
32
+ period = self._period()
33
+ if period == self._applied:
34
+ return
35
+ if self._timer is not None:
36
+ self._timer.stop()
37
+ self._applied = period
38
+ self._timer = self._widget.set_interval(period, self._tick)
39
+
40
+ async def reapply(self) -> None:
41
+ """Apply the current period again; a state change calls this to pick up new settings."""
42
+ self.apply()