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,186 @@
1
+ """The permanent Resources and Engine sections of the Project diagnostics popup."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING
7
+
8
+ from rich.cells import cell_len
9
+ from rich.console import Group, RenderableType
10
+ from rich.text import Text
11
+
12
+ from shikiru.dashboard.contract import DiagnosticSampleState
13
+ from shikiru.ui.age import format_duration
14
+ from shikiru.ui.compact import BYTES_PER_GIB, RESOURCE_STATE_UNKNOWN, format_cpu_pct, format_gib
15
+ from shikiru.ui.section import (
16
+ FAILED_WORD,
17
+ GROUP_SEPARATOR,
18
+ UNAVAILABLE_WORD,
19
+ Row,
20
+ Section,
21
+ grey,
22
+ labelled_rows,
23
+ number,
24
+ row_name,
25
+ state_line,
26
+ )
27
+ from shikiru.ui.tokens import ALERT_COLOR, AMBER, SAGE, SECONDARY_TEXT_COLOR
28
+
29
+ if TYPE_CHECKING:
30
+ from rich.console import Console, ConsoleOptions, RenderResult
31
+
32
+ from shikiru.dashboard.contract import EngineStatusView, ResourceView
33
+
34
+ RESOURCES_TITLE = "Resources"
35
+ ENGINE_TITLE = "Engine"
36
+ LABEL_HOST = "Host"
37
+ LABEL_SHIKIRU = "Shikiru"
38
+ LABEL_AGENTS = "Agents"
39
+ LABEL_REFRESH = "Refresh"
40
+ LABEL_STATUS = "Status"
41
+ LABEL_FAULTS = "Faults"
42
+ CPU_WORD = "CPU"
43
+ RAM_WORD = "RAM"
44
+ RSS_WORD = "RSS"
45
+ GIB_UNIT = "GiB"
46
+ RAM_DIVIDER = " / "
47
+ NO_SAMPLE_RECORDED = "no sample recorded"
48
+ PROCESS_TREE_EMPTY = "process tree is empty"
49
+ CPU_RSS_UNAVAILABLE = "CPU/RSS unavailable"
50
+ RAM_UNAVAILABLE = "RAM used/total unavailable"
51
+ STATUS_RUNNING = "Running"
52
+ STATUS_NOT_RUNNING = "Not running"
53
+ UPTIME_PREFIX = "up"
54
+ UPTIME_UNAVAILABLE = "uptime unavailable"
55
+ FAULTS_NONE = "None open"
56
+ FAULTS_OPEN = "open"
57
+ _OBSERVED_STATES = frozenset({DiagnosticSampleState.AVAILABLE, DiagnosticSampleState.EMPTY})
58
+
59
+
60
+ def _measure(word: str, value: Text) -> Text:
61
+ return Text.assemble(grey(f"{word} "), value)
62
+
63
+
64
+ def _host_groups(sample: ResourceView) -> tuple[Text, ...]:
65
+ cpu = _measure(CPU_WORD, number(format_cpu_pct(sample.host_cpu_pct)))
66
+ used = sample.host_ram_used_bytes
67
+ total = sample.host_ram_total_bytes
68
+ if used is None or total is None:
69
+ return (cpu, grey(RAM_UNAVAILABLE))
70
+ memory = Text.assemble(
71
+ number(format_gib(used / BYTES_PER_GIB)),
72
+ grey(RAM_DIVIDER),
73
+ number(f"{format_gib(total / BYTES_PER_GIB)} {GIB_UNIT}"),
74
+ )
75
+ return (cpu, _measure(RAM_WORD, memory))
76
+
77
+
78
+ def _process_groups(
79
+ cpu_pct: float | None, rss_gb: float | None, *, tree_empty: bool | None
80
+ ) -> tuple[Text, ...]:
81
+ if cpu_pct is None or rss_gb is None:
82
+ return (grey(PROCESS_TREE_EMPTY if tree_empty else CPU_RSS_UNAVAILABLE),)
83
+ return (
84
+ _measure(CPU_WORD, number(format_cpu_pct(cpu_pct))),
85
+ _measure(RSS_WORD, number(f"{format_gib(rss_gb)} {GIB_UNIT}")),
86
+ )
87
+
88
+
89
+ def _failure(error: str) -> Text:
90
+ return Text(f"{FAILED_WORD}{GROUP_SEPARATOR}{error}", style=AMBER)
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class _ResourceRows:
95
+ sample: ResourceView
96
+ refresh_error: str | None
97
+
98
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
99
+ del console
100
+ sample = self.sample
101
+ rows: list[Row] = [
102
+ (row_name(LABEL_HOST), _host_groups(sample)),
103
+ (
104
+ row_name(LABEL_SHIKIRU),
105
+ _process_groups(
106
+ sample.shikiru_cpu_pct,
107
+ sample.shikiru_rss_gb,
108
+ tree_empty=sample.shikiru_process_tree_empty,
109
+ ),
110
+ ),
111
+ (
112
+ row_name(LABEL_AGENTS),
113
+ _process_groups(
114
+ sample.agents_cpu_pct,
115
+ sample.agents_rss_gb,
116
+ tree_empty=sample.agents_process_tree_empty,
117
+ ),
118
+ ),
119
+ ]
120
+ if self.refresh_error is not None:
121
+ rows.append((grey(LABEL_REFRESH), (_failure(self.refresh_error),)))
122
+ label_width = max(cell_len(label.plain) for label, _ in rows)
123
+ yield labelled_rows(rows, options.max_width, label_width)
124
+
125
+
126
+ def resources_section(sample: ResourceView | None) -> Section:
127
+ """Render `sample` as the unruled first Resources section in every state, with no age hint."""
128
+ if sample is None:
129
+ return Section(
130
+ RESOURCES_TITLE,
131
+ state_line(UNAVAILABLE_WORD, SECONDARY_TEXT_COLOR, NO_SAMPLE_RECORDED),
132
+ ruled=False,
133
+ )
134
+ if sample.state is DiagnosticSampleState.FAILED:
135
+ return Section(
136
+ RESOURCES_TITLE, state_line(FAILED_WORD, ALERT_COLOR, sample.error), ruled=False
137
+ )
138
+ if sample.state is DiagnosticSampleState.UNAVAILABLE:
139
+ return Section(
140
+ RESOURCES_TITLE,
141
+ state_line(UNAVAILABLE_WORD, SECONDARY_TEXT_COLOR, sample.error),
142
+ ruled=False,
143
+ )
144
+ stale = sample.state is DiagnosticSampleState.STALE
145
+ rows = _ResourceRows(sample, sample.error if stale else None)
146
+ body: RenderableType = rows
147
+ if not stale and sample.state not in _OBSERVED_STATES:
148
+ body = Group(grey(RESOURCE_STATE_UNKNOWN), rows)
149
+ return Section(RESOURCES_TITLE, body, ruled=False)
150
+
151
+
152
+ def _status_groups(status: EngineStatusView) -> tuple[Text, ...]:
153
+ if not status.up:
154
+ return (Text(STATUS_NOT_RUNNING, style=ALERT_COLOR),)
155
+ uptime = (
156
+ UPTIME_UNAVAILABLE
157
+ if status.uptime_seconds is None
158
+ else f"{UPTIME_PREFIX} {format_duration(status.uptime_seconds)}"
159
+ )
160
+ return (Text(STATUS_RUNNING, style=SAGE), grey(uptime))
161
+
162
+
163
+ def _faults_groups(fault_count: int) -> tuple[Text, ...]:
164
+ if not fault_count:
165
+ return (grey(FAULTS_NONE),)
166
+ return (Text(f"{fault_count} {FAULTS_OPEN}", style=AMBER),)
167
+
168
+
169
+ @dataclass(frozen=True)
170
+ class _EngineRows:
171
+ status: EngineStatusView
172
+ fault_count: int
173
+
174
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
175
+ del console
176
+ rows: list[Row] = [
177
+ (grey(LABEL_STATUS), _status_groups(self.status)),
178
+ (grey(LABEL_FAULTS), _faults_groups(self.fault_count)),
179
+ ]
180
+ label_width = max(cell_len(label.plain) for label, _ in rows)
181
+ yield labelled_rows(rows, options.max_width, label_width)
182
+
183
+
184
+ def engine_section(status: EngineStatusView, fault_count: int) -> Section:
185
+ """Render engine liveness and the number of open faults as the Engine section."""
186
+ return Section(ENGINE_TITLE, _EngineRows(status, fault_count))
@@ -0,0 +1,123 @@
1
+ """A selectable table of evidence entries with a shaded, structured details block under it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING
7
+
8
+ from rich.cells import cell_len
9
+ from rich.console import Group
10
+ from rich.text import Text
11
+ from textual import on
12
+ from textual.containers import Vertical, VerticalScroll
13
+ from textual.widgets import Static
14
+
15
+ from shikiru.dashboard.row_list import RowCells, RowList
16
+ from shikiru.dashboard.section_rows import TONE_COLORS
17
+ from shikiru.ui.section import BOLD_STEEL, STACKED_BELOW_WIDTH, Row, grey, labelled_rows
18
+ from shikiru.ui.tokens import PRIMARY_TEXT_COLOR, STEEL_BLUE
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Sequence
22
+
23
+ from rich.console import Console, ConsoleOptions, RenderResult
24
+ from textual.app import ComposeResult
25
+
26
+ from shikiru.domain.packet_evidence import DetailRow, EvidenceEntry, Part
27
+
28
+ MAX_LABEL_WIDTH = 19
29
+ DETAIL_STACKED_BELOW_WIDTH = 50
30
+ _KIND_GAP = " "
31
+
32
+
33
+ def part_text(part: Part) -> Text:
34
+ """Return `part` in its tone colour, or the accent colour."""
35
+ style = STEEL_BLUE if part.accent else TONE_COLORS[part.tone.value] or PRIMARY_TEXT_COLOR
36
+ return Text(part.text, style=style)
37
+
38
+
39
+ def detail_row(row: DetailRow) -> Row:
40
+ """Return a details row as a grey label and its toned value."""
41
+ return grey(row.label), (Text("").join(part_text(part) for part in row.parts),)
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class LabelledRows:
46
+ """Rows as a capped-width label column and wrapped values; unlabelled rows as plain lines."""
47
+
48
+ rows: list[Row]
49
+ stack_below: int = STACKED_BELOW_WIDTH
50
+
51
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
52
+ del console
53
+ label_width = min(
54
+ max((cell_len(label.plain) for label, _ in self.rows), default=0), MAX_LABEL_WIDTH
55
+ )
56
+ if label_width:
57
+ yield labelled_rows(self.rows, options.max_width, label_width, self.stack_below)
58
+ else:
59
+ yield Group(*(values[0] for _, values in self.rows))
60
+
61
+
62
+ class _PageScrollingRowList(RowList):
63
+ def action_page(self, older: int) -> None:
64
+ scroller = self.query_ancestor(VerticalScroll)
65
+ if older:
66
+ scroller.scroll_page_down(animate=False)
67
+ else:
68
+ scroller.scroll_page_up(animate=False)
69
+
70
+
71
+ class EvidenceTable(Vertical):
72
+ """A titled one-line-per-entry table; the selected entry's details show beneath it."""
73
+
74
+ DEFAULT_CSS = """
75
+ EvidenceTable { height: auto; }
76
+ EvidenceTable RowList { max-height: 8; overflow-y: auto; scrollbar-size-vertical: 1; }
77
+ EvidenceTable .evidence-detail {
78
+ height: auto; padding: 0 1; margin: 1 0 0 0;
79
+ color: $primary-text; background: $detail-surface;
80
+ }
81
+ """
82
+
83
+ def __init__(self, title: str, entries: Sequence[EvidenceEntry], *, table_id: str) -> None:
84
+ """Show `title` over one row per entry in `entries`, which must not be empty."""
85
+ super().__init__()
86
+ self._title = title
87
+ self._entries = tuple(entries)
88
+ self._table_id = table_id
89
+
90
+ @property
91
+ def table(self) -> RowList:
92
+ """The focusable row list."""
93
+ return self.query_one(f"#{self._table_id}", RowList)
94
+
95
+ def compose(self) -> ComposeResult:
96
+ yield Static(Text(self._title, style=BOLD_STEEL))
97
+ yield _PageScrollingRowList(widget_id=self._table_id)
98
+ yield Static(classes="evidence-detail")
99
+
100
+ def on_mount(self) -> None:
101
+ kind_width = max(cell_len(entry.kind) for entry in self._entries)
102
+ self.table.set_rows(
103
+ [self._cells(entry, kind_width) for entry in self._entries], selected=0, empty=""
104
+ )
105
+ self._show(0)
106
+
107
+ @on(RowList.Moved)
108
+ def _moved(self, message: RowList.Moved) -> None:
109
+ message.stop()
110
+ self._show(message.index)
111
+
112
+ @staticmethod
113
+ def _cells(entry: EvidenceEntry, kind_width: int) -> RowCells:
114
+ text = Text(entry.kind.ljust(kind_width), style=STEEL_BLUE)
115
+ text.append(_KIND_GAP)
116
+ text.append_text(part_text(entry.outcome))
117
+ return RowCells(entry.lead, text)
118
+
119
+ def _show(self, index: int) -> None:
120
+ rows = [detail_row(row) for row in self._entries[index].details]
121
+ self.query_one(".evidence-detail", Static).update(
122
+ LabelledRows(rows, DETAIL_STACKED_BELOW_WIDTH)
123
+ )
@@ -0,0 +1,214 @@
1
+ """The Extra details popup: the escalation packet's evidence as one scrolling page."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import TYPE_CHECKING, ClassVar
7
+
8
+ from rich.console import Group, RenderableType
9
+ from rich.text import Text
10
+ from textual.containers import Horizontal, Vertical, VerticalScroll
11
+ from textual.screen import ModalScreen
12
+ from textual.widgets import Button, Label, Rule, Static
13
+
14
+ from shikiru.dashboard.evidence_table import EvidenceTable, LabelledRows
15
+ from shikiru.dashboard.resolution_view import ResolutionLayout, layout_for
16
+ from shikiru.dashboard.row_list import RowList
17
+ from shikiru.dashboard.section_rows import TONE_COLORS, glyph_prefix
18
+ from shikiru.domain.escalation_evidence import EscalationPacket, parse_escalation_packet
19
+ from shikiru.domain.packet_evidence import parse_reports, parse_signals
20
+ from shikiru.domain.timeline_phrases import NO_GLYPH, kind_phrase
21
+ from shikiru.ui.popup import (
22
+ POPUP_BODY_CLASS,
23
+ POPUP_CLOSE_CLASS,
24
+ POPUP_CSS,
25
+ POPUP_FRAME_CLASS,
26
+ POPUP_HEADER_CLASS,
27
+ POPUP_RULE_CLASS,
28
+ POPUP_TITLE_CLASS,
29
+ PopupFrame,
30
+ popup_close_label,
31
+ prepare_popup,
32
+ )
33
+ from shikiru.ui.section import BOLD_STEEL, GROUP_SEPARATOR, Row, Section, grey
34
+ from shikiru.ui.tokens import AMBER, PRIMARY_TEXT_COLOR
35
+
36
+ if TYPE_CHECKING:
37
+ from collections.abc import Callable
38
+
39
+ from textual import events
40
+ from textual.app import ComposeResult
41
+ from textual.widget import Widget
42
+
43
+ EXTRA_DETAILS_CLOSE_BUTTON = "extra-details-close"
44
+ _TITLE = "Extra details"
45
+ _ENTRY_PREFIX = "- "
46
+ _NONE_RECORDED = "(none)"
47
+ _TIME_FORMAT = "%m-%d %H:%M"
48
+ _KIND_SEPARATOR = ": "
49
+ _RESUME_SEPARATOR = " \N{EM DASH} "
50
+ _SESSION_MARKER = " session `"
51
+ _SESSION_ID_CLOSE = "`"
52
+ _ERROR_PREFIX = "Evidence error: "
53
+ _QUESTION_TITLE = "Question"
54
+ _TIMELINE = "Timeline"
55
+ _REPORTS = "Reports"
56
+ _RECENT_SIGNALS = "Recent turn signals"
57
+ _SESSIONS = "Sessions"
58
+ _SIGNALS_TITLE = "Recent signals"
59
+ _REPORTS_TABLE = "extra-details-reports"
60
+ _SIGNALS_TABLE = "extra-details-signals"
61
+
62
+
63
+ def _value(text: str) -> tuple[Text, ...]:
64
+ return (Text(text, style=PRIMARY_TEXT_COLOR),)
65
+
66
+
67
+ def _plain_row(line: str) -> Row:
68
+ return Text(), _value(line)
69
+
70
+
71
+ def _timeline_row(line: str) -> Row:
72
+ entry = line.removeprefix(_ENTRY_PREFIX)
73
+ stamp, _, message = entry.partition(" ")
74
+ try:
75
+ label = datetime.fromisoformat(stamp).strftime(_TIME_FORMAT)
76
+ except ValueError:
77
+ return _plain_row(entry)
78
+ kind, separator, detail = message.partition(_KIND_SEPARATOR)
79
+ phrase = kind_phrase(kind)
80
+ if phrase is None:
81
+ return grey(label), (Text(glyph_prefix(NO_GLYPH) + message, style=PRIMARY_TEXT_COLOR),)
82
+ text = glyph_prefix(phrase.glyph) + (phrase.text + separator + detail)
83
+ return grey(label), (Text(text, style=TONE_COLORS[phrase.tone.value] or PRIMARY_TEXT_COLOR),)
84
+
85
+
86
+ def _session_row(line: str) -> Row:
87
+ entry = line.removeprefix(_ENTRY_PREFIX).partition(_RESUME_SEPARATOR)[0]
88
+ role, _, rest = entry.partition(_KIND_SEPARATOR)
89
+ agent, marker, session = rest.partition(_SESSION_MARKER)
90
+ if not marker:
91
+ return _plain_row(entry)
92
+ return grey(role), (*_value(agent), grey(session.removesuffix(_SESSION_ID_CLOSE)))
93
+
94
+
95
+ def _line_rows(content: str, make: Callable[[str], Row]) -> list[Row]:
96
+ return [make(line) for line in content.splitlines() if line.strip()]
97
+
98
+
99
+ _ROW_SECTIONS: dict[str, tuple[str, Callable[[str], Row]]] = {
100
+ _TIMELINE: (_TIMELINE, _timeline_row),
101
+ _SESSIONS: (_SESSIONS, _session_row),
102
+ }
103
+
104
+
105
+ def _titled(title: str, body: RenderableType) -> Section:
106
+ return Section(title, body, ruled=False, title_style=BOLD_STEEL)
107
+
108
+
109
+ def _static(title: str, rows: list[Row]) -> Static:
110
+ return Static(_titled(title, LabelledRows(rows)))
111
+
112
+
113
+ def _page_widgets(packet: EscalationPacket) -> list[Widget]:
114
+ request = packet.agent_request
115
+ question = request.question if request is not None else packet.question
116
+ widgets: list[Widget] = []
117
+ if question:
118
+ widgets.append(Static(_titled(_QUESTION_TITLE, Text(question, style=PRIMARY_TEXT_COLOR))))
119
+ for label, content in packet.evidence_sections:
120
+ if not content or content == _NONE_RECORDED:
121
+ continue
122
+ if label in _ROW_SECTIONS:
123
+ title, make = _ROW_SECTIONS[label]
124
+ widgets.append(_static(title, _line_rows(content, make)))
125
+ elif label == _REPORTS and (reports := parse_reports(content)):
126
+ widgets.append(EvidenceTable(_REPORTS, reports, table_id=_REPORTS_TABLE))
127
+ elif label == _RECENT_SIGNALS and (signals := parse_signals(content)):
128
+ widgets.append(EvidenceTable(_SIGNALS_TITLE, signals, table_id=_SIGNALS_TABLE))
129
+ return widgets
130
+
131
+
132
+ def _error_widget(packet: EscalationPacket, packet_text: str) -> Static:
133
+ error = Text(_ERROR_PREFIX + GROUP_SEPARATOR.join(packet.errors), style=AMBER)
134
+ return Static(Group(error, Text(""), Text(packet_text, style=PRIMARY_TEXT_COLOR)))
135
+
136
+
137
+ class ExtraDetailsScreen(ModalScreen[None]):
138
+ """Show the evidence of one escalation until the Operator closes it with Esc."""
139
+
140
+ BINDINGS: ClassVar[list[tuple[str, str, str]]] = [("escape", "close", "Close")]
141
+ DEFAULT_CSS = (
142
+ POPUP_CSS
143
+ + """
144
+ #extra-details-frame.compact { margin: 0 1; padding: 0 1; }
145
+ #extra-details-title { text-wrap: nowrap; text-overflow: ellipsis; }
146
+ #extra-details-scroll { max-height: 1fr; min-height: 0; }
147
+ #extra-details-body { height: auto; }
148
+ #extra-details-body > * { margin-top: 1; }
149
+ #extra-details-body > :first-child { margin-top: 0; }
150
+ """
151
+ )
152
+
153
+ def __init__(self, task_id: str, packet_text: str) -> None:
154
+ """Hold the task id for the title and the packet text to lay out."""
155
+ super().__init__()
156
+ prepare_popup(self)
157
+ self._task_id = task_id
158
+ self._packet_text = packet_text
159
+
160
+ def compose(self) -> ComposeResult:
161
+ """Lay out the popup frame with its header, rule and scrolling body."""
162
+ with PopupFrame(widget_id="extra-details-frame", classes=POPUP_FRAME_CLASS):
163
+ with Horizontal(classes=POPUP_HEADER_CLASS):
164
+ yield Label(
165
+ GROUP_SEPARATOR.join((_TITLE, self._task_id)),
166
+ id="extra-details-title",
167
+ classes=POPUP_TITLE_CLASS,
168
+ )
169
+ yield Button(
170
+ popup_close_label(),
171
+ id=EXTRA_DETAILS_CLOSE_BUTTON,
172
+ classes=POPUP_CLOSE_CLASS,
173
+ )
174
+ yield Rule(classes=POPUP_RULE_CLASS)
175
+ with (
176
+ VerticalScroll(id="extra-details-scroll", classes=POPUP_BODY_CLASS),
177
+ Vertical(id="extra-details-body"),
178
+ ):
179
+ yield from self._page()
180
+
181
+ def _page(self) -> list[Widget]:
182
+ packet = parse_escalation_packet(self._packet_text)
183
+ if packet.errors:
184
+ return [_error_widget(packet, self._packet_text)]
185
+ return _page_widgets(packet)
186
+
187
+ def on_mount(self) -> None:
188
+ self._fit()
189
+ tables = self.query(RowList)
190
+ first = tables.first() if tables else self.query_one("#extra-details-scroll")
191
+ self.set_focus(first, scroll_visible=False)
192
+ self.call_after_refresh(self.query_one("#extra-details-frame", PopupFrame).content_ready)
193
+
194
+ def on_resize(self, event: events.Resize) -> None:
195
+ del event
196
+ if self.is_mounted:
197
+ self._fit()
198
+ self.call_after_refresh(
199
+ self.query_one("#extra-details-frame", PopupFrame).update_preferred_size
200
+ )
201
+
202
+ def on_button_pressed(self, event: Button.Pressed) -> None:
203
+ if event.button.id == EXTRA_DETAILS_CLOSE_BUTTON:
204
+ self.dismiss()
205
+
206
+ def action_close(self) -> None:
207
+ """Close the popup and return to the Escalations screen."""
208
+ self.dismiss()
209
+
210
+ def _fit(self) -> None:
211
+ layout = layout_for(self.size.width, self.size.height)
212
+ self.query_one("#extra-details-frame", PopupFrame).set_class(
213
+ layout is not ResolutionLayout.WIDE, "compact"
214
+ )