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,93 @@
1
+ """The Home `Tasks` label with its filter and search key hints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.cells import cell_len, set_cell_size
6
+ from rich.text import Text
7
+
8
+ from shikiru.ui.section import BOLD_STEEL, grey, key_label
9
+ from shikiru.ui.tokens import PRIMARY_TEXT_COLOR
10
+
11
+ TASKS_LABEL = "Tasks"
12
+ FILTER_KEY = "f"
13
+ SEARCH_KEY = "/"
14
+ MORE_KEY = "m"
15
+ MORE_LABEL = "load more"
16
+ HINT_GAP = " "
17
+ ELLIPSIS = "\N{HORIZONTAL ELLIPSIS}"
18
+ MIN_SEARCH_CELLS = 3
19
+ LEVEL_FULL = 0
20
+ LEVEL_NAME = 1
21
+ LEVEL_GLYPH = 2
22
+ LEVEL_KEYS = 3
23
+
24
+
25
+ def task_list_header(
26
+ *,
27
+ name: str,
28
+ marker: tuple[str, str] | None,
29
+ search: str,
30
+ width: int,
31
+ keys_only: bool,
32
+ ) -> Text:
33
+ """Return `Tasks` and the key hints, shortened until they fit `width` cells.
34
+
35
+ A `width` of 0 means the width is not known yet and shows the full hint.
36
+ """
37
+ for level in range(LEVEL_KEYS if keys_only else LEVEL_FULL, LEVEL_KEYS):
38
+ header = _header(level, name, marker, search)
39
+ if width <= 0 or cell_len(header.plain) <= width:
40
+ return header
41
+ clipped = _clip(search, cell_len(header.plain) - width)
42
+ if clipped is not None:
43
+ return _header(level, name, marker, clipped)
44
+ return _header(LEVEL_KEYS, name, marker, search)
45
+
46
+
47
+ def load_more_hint(*, keys_only: bool) -> Text:
48
+ """Return the `m load more` hint, or only the key at tiny heights."""
49
+ return Text(MORE_KEY, style=BOLD_STEEL) if keys_only else key_label(MORE_KEY, MORE_LABEL)
50
+
51
+
52
+ def _clip(search: str, excess: int) -> str | None:
53
+ keep = cell_len(search) - excess
54
+ if not search or keep < MIN_SEARCH_CELLS:
55
+ return None
56
+ return set_cell_size(search, keep - 1) + ELLIPSIS
57
+
58
+
59
+ def _header(level: int, name: str, marker: tuple[str, str] | None, search: str) -> Text:
60
+ header = Text(TASKS_LABEL)
61
+ header.append(HINT_GAP)
62
+ header.append_text(_filter_part(level, name, marker))
63
+ header.append(HINT_GAP)
64
+ header.append_text(_search_part(level, search))
65
+ return header
66
+
67
+
68
+ def _filter_part(level: int, name: str, marker: tuple[str, str] | None) -> Text:
69
+ part = Text(FILTER_KEY, style=BOLD_STEEL)
70
+ if level == LEVEL_KEYS:
71
+ return part
72
+ if level == LEVEL_FULL:
73
+ part.append_text(grey(" filter:"))
74
+ part.append(" ")
75
+ if marker is None:
76
+ part.append(name, style=PRIMARY_TEXT_COLOR)
77
+ elif level == LEVEL_GLYPH:
78
+ part.append(marker[0], style=marker[1])
79
+ else:
80
+ part.append(marker[0], style=marker[1])
81
+ part.append(f" {name}", style=PRIMARY_TEXT_COLOR)
82
+ return part
83
+
84
+
85
+ def _search_part(level: int, search: str) -> Text:
86
+ part = Text(SEARCH_KEY, style=BOLD_STEEL)
87
+ if level == LEVEL_FULL:
88
+ part.append_text(grey(" search"))
89
+ if search and level <= LEVEL_NAME:
90
+ if level == LEVEL_FULL:
91
+ part.append_text(grey(":"))
92
+ part.append(f" {search}", style=PRIMARY_TEXT_COLOR)
93
+ return part
@@ -0,0 +1,105 @@
1
+ """The `resources` widget: host and Shikiru process-tree usage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from rich.console import Console
8
+
9
+ from shikiru.dashboard.contract import FOCUS_PROJECT_DIAGNOSTICS_VIEW, WidgetPlacement
10
+ from shikiru.ui.compact import render_resource_metric
11
+ from shikiru.ui.gated_static import GatedStatic
12
+ from shikiru.ui.live_interval import LiveInterval
13
+ from shikiru.ui.screen import widget_is_attached
14
+
15
+ RESOURCE_ENTER_KEY = "enter"
16
+
17
+ if TYPE_CHECKING:
18
+ from rich.text import Text
19
+ from textual import events
20
+ from textual.widget import Widget
21
+
22
+ from shikiru.dashboard.contract import ResourceView, WidgetContext
23
+
24
+
25
+ class ResourcesPanel(GatedStatic):
26
+ """Show host use and the engine plus every pane process tree."""
27
+
28
+ def __init__(self, context: WidgetContext) -> None:
29
+ """Hold `context`; resource sampling starts on mount."""
30
+ super().__init__(id="resources")
31
+ self.can_focus = context.placement is WidgetPlacement.RESOURCES
32
+ self._widget_context = context
33
+ self._sample: ResourceView | None = None
34
+ self._applied_min_height: int | None = None
35
+ self._interval = LiveInterval(
36
+ self,
37
+ lambda: context.settings.current().orchestration.resources.sample_seconds,
38
+ self._refresh,
39
+ )
40
+
41
+ async def on_mount(self) -> None:
42
+ """Render and resample resources at the configured cadence."""
43
+ await self._refresh()
44
+ if self._widget_context.placement is WidgetPlacement.RESOURCES:
45
+ self._interval.apply()
46
+ self.run_worker(self._widget_context.on_change(self._interval.reapply), exclusive=True)
47
+
48
+ async def on_click(self, event: events.Click) -> None:
49
+ if self._widget_context.placement is not WidgetPlacement.RESOURCES:
50
+ return
51
+ open_view = self._widget_context.open_contextual_view
52
+ if open_view is not None:
53
+ await open_view(FOCUS_PROJECT_DIAGNOSTICS_VIEW)
54
+ event.stop()
55
+
56
+ async def on_key(self, event: events.Key) -> None:
57
+ if (
58
+ event.key == RESOURCE_ENTER_KEY
59
+ and self._widget_context.placement is WidgetPlacement.RESOURCES
60
+ and self._widget_context.open_contextual_view is not None
61
+ ):
62
+ await self._widget_context.open_contextual_view(FOCUS_PROJECT_DIAGNOSTICS_VIEW)
63
+ event.stop()
64
+
65
+ def on_resize(self, event: events.Resize) -> None:
66
+ del event
67
+ if self._sample is not None:
68
+ self._render_sample_at_current_width()
69
+
70
+ async def _refresh(self) -> None:
71
+ if not widget_is_attached(self):
72
+ return
73
+ sample = await self._widget_context.views.resources()
74
+ if not widget_is_attached(self):
75
+ return
76
+ self._sample = sample
77
+ self._render_sample_at_current_width()
78
+
79
+ def _render_sample_at_current_width(self) -> None:
80
+ sample = self._sample
81
+ if sample is None:
82
+ return
83
+ self._update_content(render_resource_metric(sample, width=self.size.width))
84
+
85
+ def _update_content(self, content: Text) -> None:
86
+ self.update_if_changed(content)
87
+ if self.size.width:
88
+ rendered_lines = content.wrap(Console(), self.size.width)
89
+ min_height = len(rendered_lines)
90
+ if min_height != self._applied_min_height:
91
+ self._applied_min_height = min_height
92
+ self.styles.min_height = min_height
93
+ if self.parent is not None:
94
+ self.parent.refresh(layout=True)
95
+
96
+
97
+ class ResourcesWidget:
98
+ """The `resources` dashboard widget plugin."""
99
+
100
+ name = "resources"
101
+ placement = WidgetPlacement.RESOURCES
102
+
103
+ def create(self, context: WidgetContext) -> Widget:
104
+ """Build the resource panel for `context`."""
105
+ return ResourcesPanel(context)
@@ -0,0 +1,82 @@
1
+ """The `strips` widget: active tasks as comic-strip panels."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, cast
6
+
7
+ from rich.text import Text
8
+
9
+ from shikiru.dashboard.contract import WidgetPlacement
10
+ from shikiru.ui.gated_static import GatedStatic
11
+ from shikiru.ui.screen import widget_is_attached, widget_is_current
12
+ from shikiru.ui.strip import STRIP_WIDE_MIN_COLUMNS, TaskStrip
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Callable
16
+
17
+ from textual import events
18
+ from textual.app import App
19
+ from textual.widget import Widget
20
+
21
+ from shikiru.dashboard.contract import WidgetContext
22
+ from shikiru.ui.strip import TaskStripData
23
+
24
+
25
+ class StripsPanel(GatedStatic):
26
+ """Renders every active task's strip, in dispatch order, focused task first."""
27
+
28
+ def __init__(self, context: WidgetContext) -> None:
29
+ """Hold `context`; nothing here does I/O until `on_mount`."""
30
+ super().__init__()
31
+ self._widget_context = context
32
+ self._rendered: tuple[list[TaskStripData], int] | None = None
33
+ self._remove_motion_callback: Callable[[], None] | None = None
34
+
35
+ async def on_mount(self) -> None:
36
+ """Render the current strips, then refresh on every state change."""
37
+ await self._refresh()
38
+ self.run_worker(self._widget_context.on_change(self._refresh_if_attached), exclusive=True)
39
+ self._remove_motion_callback = self._widget_context.on_motion(self._refresh_while_active)
40
+
41
+ def on_unmount(self) -> None:
42
+ if self._remove_motion_callback is not None:
43
+ self._remove_motion_callback()
44
+ self._remove_motion_callback = None
45
+
46
+ async def _refresh_while_active(self) -> None:
47
+ if widget_is_current(self) and cast("App[None]", self.app).is_running:
48
+ await self._refresh(motion=True)
49
+
50
+ async def _refresh_if_attached(self) -> None:
51
+ if widget_is_attached(self):
52
+ await self._refresh()
53
+
54
+ async def on_resize(self, event: events.Resize) -> None:
55
+ """Re-render at the new width: a narrower pane switches every strip to its one-line form."""
56
+ del event
57
+ if widget_is_current(self) and cast("App[None]", self.app).is_running:
58
+ await self._refresh()
59
+
60
+ async def _refresh(self, *, motion: bool = False) -> None:
61
+ width = self.size.width or STRIP_WIDE_MIN_COLUMNS
62
+ views = self._widget_context.views.strips()
63
+ rendering = (views, width)
64
+ if motion and rendering == self._rendered:
65
+ return
66
+ self._rendered = rendering
67
+ strips = [TaskStrip(data).render(width) for data in views]
68
+ if not strips:
69
+ self.update_if_changed("no active tasks")
70
+ return
71
+ self.update_if_changed(Text("\n\n").join(strips))
72
+
73
+
74
+ class StripsWidget:
75
+ """The `strips` dashboard widget plugin."""
76
+
77
+ name = "strips"
78
+ placement = WidgetPlacement.WORKFLOW
79
+
80
+ def create(self, context: WidgetContext) -> Widget:
81
+ """Build the `StripsPanel` for `context`."""
82
+ return StripsPanel(context)
@@ -0,0 +1,176 @@
1
+ """The `usage` widget: token totals, reported costs and quota windows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, cast
6
+
7
+ from rich.console import Group, RenderableType
8
+ from rich.text import Text
9
+
10
+ from shikiru.dashboard.contract import WidgetPlacement
11
+ from shikiru.ui.compact import (
12
+ USAGE_BY_AGENT_LABEL,
13
+ USAGE_LIMITS_LABEL,
14
+ AgentQuotaLimits,
15
+ AgentUsageRows,
16
+ ProjectUsageMetric,
17
+ ProjectUsageTotals,
18
+ )
19
+ from shikiru.ui.gated_static import GatedStatic
20
+ from shikiru.ui.glyphs import GLYPH_QUOTA
21
+ from shikiru.ui.screen import widget_is_attached, widget_is_current
22
+ from shikiru.ui.section import Section
23
+ from shikiru.ui.tokens import AMBER, STYLE_BOLD
24
+
25
+ if TYPE_CHECKING:
26
+ from collections.abc import Callable
27
+
28
+ from textual import events
29
+ from textual.app import App
30
+ from textual.widget import Widget
31
+
32
+ from shikiru.dashboard.contract import (
33
+ AgentUsageView,
34
+ QuotaBlockView,
35
+ RecordedUsageView,
36
+ WidgetContext,
37
+ )
38
+
39
+ ENTER_KEY = "enter"
40
+ NO_USAGE_RECORDED = "No runs or quota windows recorded."
41
+ TASK_USAGE_UNAVAILABLE = "Task usage coverage unavailable"
42
+
43
+
44
+ def _banner_texts(blocks: list[QuotaBlockView]) -> list[Text]:
45
+ return [Text(f"{GLYPH_QUOTA} {block.banner}", style=AMBER) for block in blocks]
46
+
47
+
48
+ def _usage_body(
49
+ totals: RecordedUsageView,
50
+ agents: list[AgentUsageView],
51
+ context: WidgetContext,
52
+ scope: str | None,
53
+ blocks: list[QuotaBlockView],
54
+ ) -> Group:
55
+ parts: list[RenderableType] = [] if scope is None else [Text(scope, style=STYLE_BOLD)]
56
+ parts.extend(_banner_texts(blocks))
57
+ parts.append(ProjectUsageTotals(totals))
58
+ with_runs = [agent for agent in agents if agent.run_count]
59
+ with_quotas = [agent for agent in agents if agent.quotas]
60
+ if with_runs:
61
+ parts.append(Section(USAGE_BY_AGENT_LABEL, AgentUsageRows(with_runs)))
62
+ if with_quotas:
63
+ gauges = context.settings.current().dashboard.gauges
64
+ limits = AgentQuotaLimits(with_quotas, gauges.quota_warn_pct, gauges.quota_alert_pct)
65
+ parts.append(Section(USAGE_LIMITS_LABEL, limits))
66
+ if not agents:
67
+ parts.append(Text(NO_USAGE_RECORDED))
68
+ return Group(*parts)
69
+
70
+
71
+ class UsagePanel(GatedStatic):
72
+ """Show token totals and quota gauges for every known agent."""
73
+
74
+ def __init__(self, context: WidgetContext) -> None:
75
+ """Hold `context`; usage refreshes after state changes and for live countdowns."""
76
+ super().__init__(id="usage")
77
+ self.can_focus = context.placement is WidgetPlacement.PROJECT_USAGE
78
+ self._widget_context = context
79
+ self._rendered: (
80
+ tuple[RecordedUsageView, list[AgentUsageView], list[QuotaBlockView]] | None
81
+ ) = None
82
+ self._home_usage: RecordedUsageView | None = None
83
+ self._home_blocks: list[QuotaBlockView] = []
84
+ self._remove_motion_callback: Callable[[], None] | None = None
85
+
86
+ async def on_mount(self) -> None:
87
+ """Render current usage, subscribe to state and tick quota age/countdowns."""
88
+ await self._refresh()
89
+ self.run_worker(self._widget_context.on_change(self._refresh_if_attached), exclusive=True)
90
+ self._remove_motion_callback = self._widget_context.on_motion(self._refresh_on_motion)
91
+
92
+ def on_unmount(self) -> None:
93
+ if self._remove_motion_callback is not None:
94
+ self._remove_motion_callback()
95
+ self._remove_motion_callback = None
96
+
97
+ def on_resize(self, event: events.Resize) -> None:
98
+ del event
99
+ if self._home_usage is not None:
100
+ self._render_home_usage(self._home_usage, self._home_blocks)
101
+
102
+ async def on_click(self, event: events.Click) -> None:
103
+ if self._widget_context.placement is WidgetPlacement.PROJECT_USAGE:
104
+ open_view = self._widget_context.open_contextual_view
105
+ if open_view is not None:
106
+ await open_view("usage")
107
+ event.stop()
108
+
109
+ async def on_key(self, event: events.Key) -> None:
110
+ if (
111
+ event.key == ENTER_KEY
112
+ and self._widget_context.placement is WidgetPlacement.PROJECT_USAGE
113
+ and self._widget_context.open_contextual_view is not None
114
+ ):
115
+ await self._widget_context.open_contextual_view("usage")
116
+ event.stop()
117
+
118
+ async def _refresh(self, *, motion: bool = False) -> None:
119
+ if self._widget_context.placement is WidgetPlacement.PROJECT_USAGE:
120
+ self._home_usage = self._widget_context.views.project_usage()
121
+ self._home_blocks = self._widget_context.views.quota_blocks()
122
+ self._render_home_usage(self._home_usage, self._home_blocks)
123
+ return
124
+ usage = self._widget_context.views.usage(self._widget_context.focus_task)
125
+ if self._widget_context.focus_task is None:
126
+ project_usage = self._widget_context.views.project_usage()
127
+ blocks = self._widget_context.views.quota_blocks()
128
+ rendering = (project_usage, usage, blocks)
129
+ if motion and rendering == self._rendered:
130
+ return
131
+ self._rendered = rendering
132
+ body = _usage_body(project_usage, usage, self._widget_context, None, blocks)
133
+ else:
134
+ detail = await self._widget_context.views.task_detail(
135
+ self._widget_context.focus_task,
136
+ )
137
+ if not widget_is_attached(self):
138
+ return
139
+ if detail is None or detail.usage is None:
140
+ body = Group(Text(TASK_USAGE_UNAVAILABLE, style=STYLE_BOLD))
141
+ else:
142
+ scope = f"Task {self._widget_context.focus_task}"
143
+ body = _usage_body(detail.usage, usage, self._widget_context, scope, [])
144
+ self.update_if_changed(body)
145
+
146
+ def _render_home_usage(self, usage: RecordedUsageView, blocks: list[QuotaBlockView]) -> None:
147
+ metric = ProjectUsageMetric(usage).render_home(width=self.size.width or None)
148
+ self.update_if_changed(Group(*_banner_texts(blocks), metric))
149
+
150
+ async def _refresh_if_active(self, *, motion: bool = False) -> None:
151
+ if widget_is_current(self) and cast("App[None]", self.app).is_running:
152
+ await self._refresh(motion=motion)
153
+
154
+ async def _refresh_if_attached(self) -> None:
155
+ if widget_is_attached(self):
156
+ await self._refresh()
157
+
158
+ async def _refresh_on_motion(self) -> None:
159
+ if self._widget_context.focus_task is None and self._needs_motion_refresh():
160
+ await self._refresh_if_active(motion=True)
161
+
162
+ def _needs_motion_refresh(self) -> bool:
163
+ if self._widget_context.placement is WidgetPlacement.PROJECT_USAGE:
164
+ return bool(self._home_blocks)
165
+ return True
166
+
167
+
168
+ class UsageWidget:
169
+ """The `usage` dashboard widget plugin."""
170
+
171
+ name = "usage"
172
+ placement = WidgetPlacement.PROJECT_USAGE
173
+
174
+ def create(self, context: WidgetContext) -> Widget:
175
+ """Build the usage panel for `context`."""
176
+ return UsagePanel(context)
shikiru/private.py ADDED
@@ -0,0 +1,94 @@
1
+ """Owner-only creation and tightening of everything Shikiru keeps under `.shikiru/`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import stat
7
+ from logging.handlers import RotatingFileHandler
8
+ from typing import TYPE_CHECKING
9
+
10
+ from shikiru.paths import WORKTREES_DIR_NAME
11
+
12
+ if TYPE_CHECKING:
13
+ from io import TextIOWrapper
14
+ from pathlib import Path
15
+
16
+ PRIVATE_DIR_MODE = 0o700
17
+ PRIVATE_FILE_MODE = 0o600
18
+ _OTHERS_MASK = 0o077
19
+ TEXT_ENCODING = "utf-8"
20
+ _CREATE_WRITE = os.O_WRONLY | os.O_CREAT
21
+
22
+
23
+ def make_private_dir(path: Path) -> None:
24
+ """Create `path` and its missing parents as owner-only directories, racing creators included."""
25
+ for directory in reversed([path, *path.parents]):
26
+ if not directory.exists():
27
+ directory.mkdir(mode=PRIVATE_DIR_MODE, exist_ok=True)
28
+
29
+
30
+ def create_private_file(path: Path) -> None:
31
+ """Create the empty file `path` as owner-only unless it exists; its directory must exist."""
32
+ os.close(os.open(path, _CREATE_WRITE, PRIVATE_FILE_MODE))
33
+
34
+
35
+ def create_private_text(path: Path, text: str) -> None:
36
+ """Write `text` to the new file `path` as owner-only; raise `FileExistsError` if it exists."""
37
+ descriptor = os.open(path, _CREATE_WRITE | os.O_EXCL, PRIVATE_FILE_MODE)
38
+ with os.fdopen(descriptor, "w", encoding=TEXT_ENCODING) as handle:
39
+ handle.write(text)
40
+
41
+
42
+ def write_private_text(path: Path, text: str) -> None:
43
+ """Write `text` to `path`, creating it owner-only."""
44
+ descriptor = os.open(path, _CREATE_WRITE | os.O_TRUNC, PRIVATE_FILE_MODE)
45
+ with os.fdopen(descriptor, "w", encoding=TEXT_ENCODING) as handle:
46
+ handle.write(text)
47
+
48
+
49
+ def append_private_text(path: Path, text: str) -> None:
50
+ """Append `text` to `path`, creating it owner-only."""
51
+ descriptor = os.open(path, _CREATE_WRITE | os.O_APPEND, PRIVATE_FILE_MODE)
52
+ with os.fdopen(descriptor, "a", encoding=TEXT_ENCODING) as handle:
53
+ handle.write(text)
54
+
55
+
56
+ def append_private_bytes(path: Path, content: bytes) -> None:
57
+ """Append `content` to `path`, creating it owner-only."""
58
+ descriptor = os.open(path, _CREATE_WRITE | os.O_APPEND, PRIVATE_FILE_MODE)
59
+ with os.fdopen(descriptor, "ab") as handle:
60
+ handle.write(content)
61
+
62
+
63
+ class PrivateRotatingFileHandler(RotatingFileHandler):
64
+ """A size-rotated log whose current file is always created owner-only."""
65
+
66
+ def _open(self) -> TextIOWrapper:
67
+ descriptor = os.open(self.baseFilename, _CREATE_WRITE | os.O_APPEND, PRIVATE_FILE_MODE)
68
+ return os.fdopen(descriptor, "a", encoding=self.encoding)
69
+
70
+
71
+ def tighten_state_dir(state_dir: Path) -> list[Path]:
72
+ """Remove group and other access below `state_dir` and return the paths it changed.
73
+
74
+ Task worktrees hold the project's own files, so only the `worktrees/` directory itself is
75
+ tightened. Symlinks are never followed or changed.
76
+ """
77
+ changed: list[Path] = []
78
+ for directory, subdirectories, names in state_dir.walk():
79
+ entries = [directory]
80
+ if directory.name == WORKTREES_DIR_NAME and directory.parent == state_dir:
81
+ subdirectories.clear()
82
+ else:
83
+ entries.extend(directory / name for name in names)
84
+ changed.extend(entry for entry in entries if not entry.is_symlink() and _restrict(entry))
85
+ return changed
86
+
87
+
88
+ def _restrict(path: Path) -> bool:
89
+ mode = stat.S_IMODE(path.stat().st_mode)
90
+ restricted = PRIVATE_DIR_MODE if path.is_dir() else mode & ~_OTHERS_MASK
91
+ if restricted == mode:
92
+ return False
93
+ path.chmod(restricted)
94
+ return True