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,302 @@
1
+ """Renderables of the escalation resolution screen: title, Decision, Choose, choices, evidence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import StrEnum, auto
7
+ from typing import TYPE_CHECKING
8
+
9
+ from rich.console import Group, RenderableType
10
+ from rich.table import Table
11
+ from rich.text import Text
12
+
13
+ from shikiru.domain.attempts import AttemptItem, AttemptKind, AttemptTone
14
+ from shikiru.domain.escalation_panel import ChoiceKind
15
+ from shikiru.ui.fold import fold_arrow, fold_toggle_hint
16
+ from shikiru.ui.keys import key_hint
17
+ from shikiru.ui.section import (
18
+ BOLD_STEEL,
19
+ GROUP_SEPARATOR,
20
+ Row,
21
+ Section,
22
+ grey,
23
+ key_label,
24
+ labelled_rows,
25
+ row_name,
26
+ )
27
+ from shikiru.ui.tokens import (
28
+ AMBER,
29
+ DUSTY_ROSE,
30
+ PRIMARY_TEXT_COLOR,
31
+ SAGE,
32
+ SECONDARY_TEXT_COLOR,
33
+ STEEL_BLUE,
34
+ STYLE_BOLD,
35
+ )
36
+
37
+ if TYPE_CHECKING:
38
+ from rich.console import Console, ConsoleOptions, RenderResult
39
+
40
+ from shikiru.domain.escalation_panel import PanelChoice, ResolutionPanel
41
+
42
+ NARROW_MAX_COLUMNS = 48
43
+ FOLDED_MAX_COLUMNS = 40
44
+ TINY_MAX_ROWS = 8
45
+ _LABEL_WIDTH = 9
46
+ _MARK_WIDTH = 3
47
+ _RECOMMENDED_STAR = "\N{BLACK STAR}"
48
+ _OWN_ANSWER_LABEL = "Write your own answer\N{HORIZONTAL ELLIPSIS}"
49
+ _ACCEPT_LABEL = "Accept"
50
+ _RETRY_LABEL = "Retry"
51
+ _CANCEL_LABEL = "Cancel task"
52
+ _CANCEL_CONFIRM_LABEL = "Press Enter again to cancel"
53
+ _DECISION_TITLE = "Decision"
54
+ _CHOOSE_TITLE = "Choose"
55
+ _SELECT_HINT = "\N{UPWARDS ARROW}\N{DOWNWARDS ARROW} select"
56
+ _SEND_HINT = "Enter send"
57
+ _EXPAND_HINT = "Enter expand"
58
+ _BACK_HINT = "\N{UPWARDS ARROW} choices"
59
+ _COLLAPSE_HINT = "Enter collapse"
60
+ _EDIT_HINT = "Enter edit"
61
+ _CHOICE_HINTS = frozenset({_SEND_HINT, _EDIT_HINT})
62
+ _BOX_HINTS = ("Ctrl+S send", "Enter new line", "Esc back")
63
+ _NEEDS_INPUT_MARK = "\N{LOWER RIGHT PENCIL}"
64
+ _NEEDS_INPUT_TEXT = "needs your input"
65
+ _DETAILS_TITLE = "Details"
66
+ EXTRA_DETAILS_KEY = "o"
67
+ _EXTRA_DETAILS_LABEL = "extra details"
68
+ _TURNS_LABEL = "Turns"
69
+ _PART_SEPARATOR = " \N{MIDDLE DOT} "
70
+ _TURN_KINDS = frozenset({AttemptKind.TURN_OK, AttemptKind.TURN_FAILED, AttemptKind.VERDICT})
71
+ _DETAIL_LABELS = {
72
+ AttemptKind.REASON: "Reason",
73
+ AttemptKind.ROUNDS: "Rounds",
74
+ AttemptKind.RECOVERY: "Recovery",
75
+ AttemptKind.PRODUCED: "Produced",
76
+ AttemptKind.OPERATOR: "Operator",
77
+ }
78
+ _TONE_STYLES = {
79
+ AttemptTone.PLAIN: PRIMARY_TEXT_COLOR,
80
+ AttemptTone.STEP: STEEL_BLUE,
81
+ AttemptTone.SUCCESS: SAGE,
82
+ AttemptTone.FAILURE: DUSTY_ROSE,
83
+ AttemptTone.REWORK: AMBER,
84
+ }
85
+ _WAITING_WORD = "waiting"
86
+ _TITLE_WIDE = "Resolve escalation"
87
+ _TITLE_NARROW = "Resolve"
88
+ _LABEL_TASK = "Task"
89
+ _LABEL_QUESTION = "Question"
90
+ _LABEL_REASON = "Reason"
91
+
92
+ _LABEL_STYLES = {
93
+ ChoiceKind.ANSWER_OPTION: STEEL_BLUE,
94
+ ChoiceKind.OWN_ANSWER: STEEL_BLUE,
95
+ ChoiceKind.RETRY: STEEL_BLUE,
96
+ ChoiceKind.ACCEPT: SAGE,
97
+ ChoiceKind.CANCEL: DUSTY_ROSE,
98
+ }
99
+ _FIXED_LABELS = {
100
+ ChoiceKind.OWN_ANSWER: _OWN_ANSWER_LABEL,
101
+ ChoiceKind.ACCEPT: _ACCEPT_LABEL,
102
+ ChoiceKind.RETRY: _RETRY_LABEL,
103
+ ChoiceKind.CANCEL: _CANCEL_LABEL,
104
+ }
105
+
106
+
107
+ class ResolutionLayout(StrEnum):
108
+ """How much of the panel a pane size leaves room for."""
109
+
110
+ WIDE = auto()
111
+ NARROW = auto()
112
+ FOLDED = auto()
113
+ TINY = auto()
114
+
115
+
116
+ _FOLDED_LAYOUTS = frozenset({ResolutionLayout.FOLDED, ResolutionLayout.TINY})
117
+
118
+
119
+ def layout_for(width: int, height: int) -> ResolutionLayout:
120
+ """Pick the layout for a screen of `width` by `height` cells."""
121
+ if height <= TINY_MAX_ROWS:
122
+ return ResolutionLayout.TINY
123
+ if width <= FOLDED_MAX_COLUMNS:
124
+ return ResolutionLayout.FOLDED
125
+ if width <= NARROW_MAX_COLUMNS:
126
+ return ResolutionLayout.NARROW
127
+ return ResolutionLayout.WIDE
128
+
129
+
130
+ def resolution_title(
131
+ escalation_id: str, panel: ResolutionPanel | None, layout: ResolutionLayout
132
+ ) -> str:
133
+ """Return the popup title: ids, with task and reason folded in when the pane is small."""
134
+ if panel is None:
135
+ return GROUP_SEPARATOR.join((_TITLE_NARROW, escalation_id))
136
+ if layout in _FOLDED_LAYOUTS:
137
+ return GROUP_SEPARATOR.join((panel.task_id, panel.reason_label))
138
+ head = _TITLE_WIDE if layout is ResolutionLayout.WIDE else _TITLE_NARROW
139
+ return GROUP_SEPARATOR.join((head, escalation_id, panel.task_id))
140
+
141
+
142
+ @dataclass(frozen=True)
143
+ class _DecisionRows:
144
+ panel: ResolutionPanel
145
+ folded: bool
146
+
147
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
148
+ del console
149
+ panel = self.panel
150
+ if self.folded:
151
+ yield row_name(panel.question) if panel.question else grey(panel.cause or "")
152
+ return
153
+ rows: list[Row] = [
154
+ (grey(_LABEL_TASK), (row_name(panel.task_id), Text(panel.task_title))),
155
+ ]
156
+ if panel.question:
157
+ rows.append((grey(_LABEL_QUESTION), (row_name(panel.question),)))
158
+ if panel.cause or not panel.question:
159
+ reason = Text()
160
+ reason.append_text(row_name(panel.reason_label))
161
+ if panel.cause:
162
+ reason.append("\n")
163
+ reason.append(panel.cause, style=SECONDARY_TEXT_COLOR)
164
+ rows.append((grey(_LABEL_REASON), (reason,)))
165
+ yield labelled_rows(rows, options.max_width, _LABEL_WIDTH)
166
+
167
+
168
+ def decision_renderable(
169
+ panel: ResolutionPanel, age: str, layout: ResolutionLayout
170
+ ) -> RenderableType:
171
+ """Return the Decision section, or only its question when the pane is folded."""
172
+ folded = layout in _FOLDED_LAYOUTS
173
+ rows = _DecisionRows(panel, folded)
174
+ if folded:
175
+ return rows
176
+ return Section(_DECISION_TITLE, rows, grey(f"{_WAITING_WORD} {age}"), ruled=False)
177
+
178
+
179
+ def _hints(selected: PanelChoice, details_open: bool | None, *, editing: bool) -> tuple[str, ...]:
180
+ if editing:
181
+ return _BOX_HINTS
182
+ if details_open is not None:
183
+ return (_COLLAPSE_HINT if details_open else _EXPAND_HINT, _BACK_HINT)
184
+ editable = selected.kind is ChoiceKind.OWN_ANSWER or selected.needs_input
185
+ return (_SELECT_HINT, _EDIT_HINT if editable else _SEND_HINT)
186
+
187
+
188
+ def choose_renderable(
189
+ selected: PanelChoice,
190
+ *,
191
+ details_open: bool | None = None,
192
+ editing: bool = False,
193
+ choosing_disabled: bool = False,
194
+ ) -> RenderableType:
195
+ """Return the Choose heading with its key hint.
196
+
197
+ `details_open` is `None` while the list has focus, else whether the focused Details is open.
198
+ `choosing_disabled` greys the `Enter` key of the list hint, which then does nothing.
199
+ """
200
+ hints = _hints(selected, details_open, editing=editing)
201
+ inert = choosing_disabled and details_open is None and not editing
202
+ parts = [
203
+ key_hint(hint, SECONDARY_TEXT_COLOR, enabled=not (inert and hint in _CHOICE_HINTS))
204
+ for hint in hints
205
+ ]
206
+ hint = Text(GROUP_SEPARATOR, style=SECONDARY_TEXT_COLOR).join(parts)
207
+ return Section(_CHOOSE_TITLE, Group(), hint, title_style=BOLD_STEEL)
208
+
209
+
210
+ def _toned(item: AttemptItem) -> Text:
211
+ if item.kind is AttemptKind.EARLIER:
212
+ return grey(item.text)
213
+ return Text.assemble(*((part.text, _TONE_STYLES[part.tone]) for part in item.parts))
214
+
215
+
216
+ def _turn_row(item: AttemptItem) -> Text:
217
+ styled = [(part.text, _TONE_STYLES[part.tone]) for part in item.parts]
218
+ role = (item.label, PRIMARY_TEXT_COLOR)
219
+ step = item.parts[0]
220
+ if step.tone is AttemptTone.STEP and item.text.startswith(step.text + _PART_SEPARATOR):
221
+ return Text.assemble(styled[0], _PART_SEPARATOR, role, *styled[1:])
222
+ return Text.assemble(role, _PART_SEPARATOR, *styled)
223
+
224
+
225
+ def _detail_rows(items: tuple[AttemptItem, ...]) -> RenderableType:
226
+ grid = Table.grid()
227
+ grid.add_column(width=_LABEL_WIDTH + 1, no_wrap=True)
228
+ grid.add_column(ratio=1)
229
+ turns = [item for item in items if item.kind in _TURN_KINDS]
230
+ for item in items:
231
+ if item.kind in _TURN_KINDS:
232
+ if item is turns[0]:
233
+ grid.add_row(grey(_TURNS_LABEL), Group(*map(_turn_row, turns)))
234
+ elif item.kind is not AttemptKind.EARLIER:
235
+ grid.add_row(grey(_DETAIL_LABELS[item.kind]), _toned(item))
236
+ for item in items:
237
+ if item.kind is AttemptKind.EARLIER:
238
+ grid.add_row(Text(), _toned(item))
239
+ return grid
240
+
241
+
242
+ def tried_renderable(
243
+ items: tuple[AttemptItem, ...], *, expanded: bool, extra_details: bool = False
244
+ ) -> RenderableType:
245
+ """Return the collapsible `Details` section with its toggle hint, without its rule.
246
+
247
+ An expanded section ends with the `extra details` hint when `extra_details` is set.
248
+ """
249
+ arrow = fold_arrow(expanded=expanded)
250
+ body: RenderableType = Group()
251
+ if expanded:
252
+ rows = _detail_rows(items)
253
+ body = (
254
+ Group(rows, key_label(EXTRA_DETAILS_KEY, _EXTRA_DETAILS_LABEL))
255
+ if extra_details
256
+ else rows
257
+ )
258
+ return Section(
259
+ f"{_DETAILS_TITLE} {arrow}",
260
+ body,
261
+ trailing=fold_toggle_hint(),
262
+ ruled=False,
263
+ )
264
+
265
+
266
+ def starred_indexes(panel: ResolutionPanel) -> frozenset[int]:
267
+ """Return the rows to star: recommended ones, the own-answer row only when it stands alone."""
268
+ has_alternatives = any(c.kind is ChoiceKind.ANSWER_OPTION for c in panel.choices)
269
+ return frozenset(
270
+ index
271
+ for index, choice in enumerate(panel.choices)
272
+ if choice.recommended and not (has_alternatives and choice.kind is ChoiceKind.OWN_ANSWER)
273
+ )
274
+
275
+
276
+ def _consequence(choice: PanelChoice, *, disabled: bool) -> Text:
277
+ if not choice.needs_input:
278
+ return grey(choice.consequence)
279
+ lead = Text(_NEEDS_INPUT_TEXT, style=SECONDARY_TEXT_COLOR if disabled else PRIMARY_TEXT_COLOR)
280
+ lead.append(GROUP_SEPARATOR + choice.consequence, style=SECONDARY_TEXT_COLOR)
281
+ return lead
282
+
283
+
284
+ def choice_renderable(
285
+ choice: PanelChoice, *, starred: bool, armed: bool, disabled: bool
286
+ ) -> RenderableType:
287
+ """Return one choice row: star beside the label, its consequence beneath in grey."""
288
+ style = SECONDARY_TEXT_COLOR if disabled else _LABEL_STYLES[choice.kind]
289
+ if armed:
290
+ label = key_hint(_CANCEL_CONFIRM_LABEL, f"{STYLE_BOLD} {DUSTY_ROSE}")
291
+ else:
292
+ label = Text(choice.label or _FIXED_LABELS[choice.kind], style=style)
293
+ if choice.needs_input:
294
+ label.append(f" {_NEEDS_INPUT_MARK}")
295
+ star = _RECOMMENDED_STAR if starred else " "
296
+ grid = Table.grid()
297
+ grid.add_column(width=_MARK_WIDTH, no_wrap=True)
298
+ grid.add_column(ratio=1)
299
+ grid.add_row(Text(f" {star} ", style=style), label)
300
+ if choice.kind is not ChoiceKind.OWN_ANSWER:
301
+ grid.add_row(Text(" " * _MARK_WIDTH), _consequence(choice, disabled=disabled))
302
+ return grid
@@ -0,0 +1,256 @@
1
+ """A keyboard-driven list of one-line rows shared by the task Events and Relationships views."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING, ClassVar
7
+
8
+ from rich.cells import cell_len
9
+ from rich.text import Text
10
+ from textual import on
11
+ from textual.binding import Binding
12
+ from textual.containers import Vertical
13
+ from textual.message import Message
14
+ from textual.widgets import Static
15
+
16
+ from shikiru.dashboard.details_list_view import ROW_LEAD
17
+ from shikiru.ui.section import BOLD_PRIMARY, grey
18
+ from shikiru.ui.tokens import PRIMARY_TEXT_COLOR
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Sequence
22
+
23
+ from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
24
+ from textual import events
25
+ from textual.binding import BindingType
26
+
27
+ _LEAD_GAP = " "
28
+ _TAIL_GAP = " "
29
+ _STEP_UP = -1
30
+ _STEP_DOWN = 1
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class RowCells:
35
+ """One row: a grey lead, the primary text and an optional styled tail that is never cut."""
36
+
37
+ lead: str
38
+ text: str | Text
39
+ tail: Text | None = None
40
+ columns: tuple[str | Text, str | Text] | None = None
41
+ column_width: int = 0
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class _RowLine:
46
+ cells: RowCells
47
+ selected: bool
48
+
49
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
50
+ if self.cells.columns is not None:
51
+ yield from self._column_lines(console, options)
52
+ return
53
+ line = Text(ROW_LEAD)
54
+ line.append_text(grey(f"{self.cells.lead}{_LEAD_GAP}" if self.cells.lead else ""))
55
+ tail = self.cells.tail or Text()
56
+ gap = _TAIL_GAP if tail else ""
57
+ body = Text(style=BOLD_PRIMARY if self.selected else PRIMARY_TEXT_COLOR)
58
+ body.append_text(
59
+ Text(self.cells.text) if isinstance(self.cells.text, str) else self.cells.text
60
+ )
61
+ room = max(options.max_width - line.cell_len - cell_len(gap) - tail.cell_len, 1)
62
+ body.truncate(room, overflow="ellipsis")
63
+ line.append_text(body)
64
+ line.append(gap)
65
+ line.append_text(tail)
66
+ line.truncate(max(options.max_width, 1), overflow="ellipsis", pad=True)
67
+ yield line
68
+
69
+ def _column_lines(self, console: Console, options: ConsoleOptions) -> RenderResult:
70
+ columns = self.cells.columns
71
+ if columns is None:
72
+ return
73
+ label, value = columns
74
+ label_text = Text(label) if isinstance(label, str) else label.copy()
75
+ value_text = Text(value) if isinstance(value, str) else value.copy()
76
+ label_text.pad_right(max(self.cells.column_width - label_text.cell_len, 0))
77
+ prefix_width = len(ROW_LEAD) + self.cells.column_width + 1
78
+ value_width = max(options.max_width - prefix_width, 1)
79
+ lines = value_text.wrap(console, value_width, overflow="fold") or [Text()]
80
+ for index, value_line in enumerate(lines):
81
+ line = Text(ROW_LEAD)
82
+ if index == 0:
83
+ line.append_text(label_text)
84
+ else:
85
+ line.append(" " * self.cells.column_width)
86
+ line.append(" ")
87
+ line.append_text(value_line)
88
+ yield line
89
+
90
+
91
+ class _Row(Static):
92
+ class Chosen(Message):
93
+ def __init__(self, index: int) -> None:
94
+ super().__init__()
95
+ self.index = index
96
+
97
+ def __init__(self, index: int, content: RenderableType, *, selected: bool) -> None:
98
+ super().__init__(content, classes="row-list-row", markup=False)
99
+ self._index = index
100
+ self.set_class(selected, "-selected")
101
+
102
+ def on_click(self, event: events.Click) -> None:
103
+ event.stop()
104
+ self.post_message(self.Chosen(self._index))
105
+
106
+
107
+ class RowList(Vertical, can_focus=True):
108
+ """Rows with a selected background; arrows move, PgUp/PgDn page, End asks for the latest."""
109
+
110
+ BINDINGS: ClassVar[list[BindingType]] = [
111
+ Binding("up", f"move({_STEP_UP})", "Previous", show=False),
112
+ Binding("down", f"move({_STEP_DOWN})", "Next", show=False),
113
+ Binding("pageup", "page(0)", "Newer page", show=False),
114
+ Binding("pagedown", "page(1)", "Older page", show=False),
115
+ Binding("end", "latest", "Latest", show=False),
116
+ Binding("enter", "open", "Open", show=False),
117
+ ]
118
+ DEFAULT_CSS = """
119
+ RowList { height: auto; }
120
+ RowList:focus { background-tint: transparent; }
121
+ .row-list-row { height: 1; width: 100%; }
122
+ .row-list-row.-selected { background: $selected-surface; }
123
+ """
124
+
125
+ class _Posted(Message):
126
+ def __init__(self, row_list: RowList) -> None:
127
+ super().__init__()
128
+ self.row_list = row_list
129
+
130
+ @property
131
+ def control(self) -> RowList:
132
+ return self.row_list
133
+
134
+ class Moved(_Posted):
135
+ """The selection moved to `index`."""
136
+
137
+ def __init__(self, row_list: RowList, index: int) -> None:
138
+ super().__init__(row_list)
139
+ self.index = index
140
+
141
+ class Opened(_Posted):
142
+ """The row at `index` was opened."""
143
+
144
+ def __init__(self, row_list: RowList, index: int) -> None:
145
+ super().__init__(row_list)
146
+ self.index = index
147
+
148
+ class Paged(_Posted):
149
+ """The next (`older`) or previous page was asked for, by an arrow past an end or by key."""
150
+
151
+ def __init__(self, row_list: RowList, *, older: bool, by_arrow: bool = False) -> None:
152
+ super().__init__(row_list)
153
+ self.older = older
154
+ self.by_arrow = by_arrow
155
+
156
+ class Latest(_Posted):
157
+ """The latest rows were asked for."""
158
+
159
+ class Scrolled(_Posted):
160
+ """The operator scrolled the rows away from the first one."""
161
+
162
+ def __init__(self, *, widget_id: str, edge_paging: bool = False) -> None:
163
+ """Create an empty list; rows arrive through `set_rows`.
164
+
165
+ With `edge_paging`, moving past the last or first row asks for the older or newer page.
166
+ """
167
+ super().__init__(id=widget_id)
168
+ self._edge_paging = edge_paging
169
+ self._cells: tuple[RowCells, ...] = ()
170
+ self._selected = 0
171
+ self._empty = ""
172
+ self._widgets: list[_Row] = []
173
+
174
+ @property
175
+ def selected(self) -> int:
176
+ """The index of the selected row."""
177
+ return self._selected
178
+
179
+ def set_rows(self, rows: Sequence[RowCells], *, selected: int, empty: str) -> None:
180
+ """Replace the rows, select `selected` (clamped) and show `empty` when none."""
181
+ previous = self._selected
182
+ self._cells = tuple(rows)
183
+ self._empty = empty
184
+ self._selected = min(max(selected, 0), max(len(self._cells) - 1, 0))
185
+ self._paint()
186
+ if self._selected != previous:
187
+ self._reveal()
188
+
189
+ def action_move(self, step: int) -> None:
190
+ """Move the selection; at an end, page instead when edge paging is on."""
191
+ target = self._selected + step
192
+ if 0 <= target < len(self._cells):
193
+ self._choose(target)
194
+ elif self._edge_paging:
195
+ self.post_message(self.Paged(self, older=step > 0, by_arrow=True))
196
+
197
+ def action_page(self, older: int) -> None:
198
+ """Ask for the older (PgDn) or newer (PgUp) page."""
199
+ self.post_message(self.Paged(self, older=bool(older)))
200
+
201
+ def action_latest(self) -> None:
202
+ """Ask to jump to the latest rows and follow them."""
203
+ self.scroll_home(animate=False)
204
+ self.post_message(self.Latest(self))
205
+
206
+ def action_open(self) -> None:
207
+ """Open the selected row."""
208
+ if self._cells:
209
+ self.post_message(self.Opened(self, self._selected))
210
+
211
+ def watch_scroll_y(self, old_value: float, new_value: float) -> None:
212
+ """Tell the owner when the rows are scrolled away from the first one."""
213
+ super().watch_scroll_y(old_value, new_value)
214
+ if new_value > 0:
215
+ self.post_message(self.Scrolled(self))
216
+
217
+ @on(_Row.Chosen)
218
+ def _row_chosen(self, message: _Row.Chosen) -> None:
219
+ message.stop()
220
+ self._choose(message.index)
221
+ self.action_open()
222
+
223
+ def _choose(self, index: int) -> None:
224
+ if index == self._selected:
225
+ return
226
+ self._selected = index
227
+ self._paint()
228
+ self._reveal()
229
+ self.post_message(self.Moved(self, index))
230
+
231
+ def _lines(self) -> list[RenderableType]:
232
+ if not self._cells:
233
+ return [grey(self._empty)]
234
+ return [_RowLine(cells, index == self._selected) for index, cells in enumerate(self._cells)]
235
+
236
+ def _paint(self) -> None:
237
+ lines = self._lines()
238
+ highlighted = bool(self._cells)
239
+ for index, (row, line) in enumerate(zip(self._widgets, lines, strict=False)):
240
+ row.update(line)
241
+ row.set_class(highlighted and index == self._selected, "-selected")
242
+ for row in self._widgets[len(lines) :]:
243
+ row.remove()
244
+ del self._widgets[len(lines) :]
245
+ fresh = [
246
+ _Row(index, line, selected=highlighted and index == self._selected)
247
+ for index, line in enumerate(lines)
248
+ if index >= len(self._widgets)
249
+ ]
250
+ self._widgets.extend(fresh)
251
+ if fresh:
252
+ self.mount(*fresh)
253
+
254
+ def _reveal(self) -> None:
255
+ if self._selected < len(self._widgets):
256
+ self._widgets[self._selected].scroll_visible(animate=False)