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,544 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING, cast
5
+
6
+ from rich.text import Text
7
+ from textual import on
8
+ from textual.containers import Horizontal, Vertical
9
+ from textual.widgets import Button, Input, Label, Select, Static
10
+
11
+ from shikiru.agents.contracts import Rung, assess_model_choice
12
+ from shikiru.dashboard.row_list import RowCells, RowList
13
+ from shikiru.domain.ladder_text import LadderTextError, parse_ladder_text
14
+ from shikiru.domain.tasks import Role
15
+ from shikiru.errors import ConfigError
16
+ from shikiru.ui.section import BOLD_STEEL
17
+ from shikiru.ui.tokens import DUSTY_ROSE, PRIMARY_TEXT_COLOR, SECONDARY_TEXT_COLOR, STYLE_BOLD
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Mapping
21
+
22
+ from rich.console import RenderableType
23
+ from textual.app import ComposeResult
24
+
25
+ from shikiru.agents.contracts import ModelCatalog, ModelChoice
26
+ from shikiru.config.settings import ComplexityName
27
+ from shikiru.operator.plugin_checks import AgentCapabilityResult
28
+
29
+ COMPLEXITY_VALUES: tuple[ComplexityName, ...] = ("low", "normal", "high")
30
+ SCOPE_KEYS: tuple[tuple[str, ComplexityName | None], ...] = (
31
+ (Role.LEADER.value, None),
32
+ (Role.IMPLEMENTER.value, None),
33
+ *((Role.IMPLEMENTER.value, value) for value in COMPLEXITY_VALUES),
34
+ (Role.HELPER.value, None),
35
+ )
36
+ SCOPE_LABELS: dict[tuple[str, ComplexityName | None], str] = {
37
+ (Role.LEADER.value, None): "Leader",
38
+ (Role.IMPLEMENTER.value, None): "Implementer · Base",
39
+ **{
40
+ (Role.IMPLEMENTER.value, value): f"Implementer · {value.title()}"
41
+ for value in COMPLEXITY_VALUES
42
+ },
43
+ (Role.HELPER.value, None): "Helper",
44
+ }
45
+ ADD_RUNG = "ladder-add"
46
+ AGENT_SELECT = "#ladder-agent"
47
+ MODEL_SELECT = "#ladder-model"
48
+ EFFORT_SELECT = "#ladder-effort"
49
+ LADDER_INPUT = "#ladder-input"
50
+ MODEL_PROMPT = "Choose model"
51
+ AGENT_PROMPT = "Choose installed agent"
52
+ EFFORT_PROMPT = "Native default"
53
+ EMPTY_LADDER_LABEL = "No entries yet"
54
+ LADDER_SEPARATOR = ", "
55
+ RUNG_SEPARATOR = ":"
56
+ SCOPE_LABEL_WIDTH = 22
57
+ HELPER_CAPABILITY_GUIDANCE = "Helper entries require an installed agent with headless support."
58
+ MODEL_CATALOG_GUIDANCE = (
59
+ "Install or repair the agent CLI, restart init, or choose an available agent."
60
+ )
61
+ LADDER_ACTION_CSS = """
62
+ #wizard-frame #ladder-editor #ladder-add {
63
+ width: auto; min-width: 0; height: 1; min-height: 1;
64
+ margin: 0; padding: 0 1; border: none; border-top: none;
65
+ border-right: none; border-bottom: none; border-left: none;
66
+ background: $selected-surface; color: $primary-text; text-opacity: 1; text-style: bold;
67
+ }
68
+ #wizard-frame #ladder-editor #ladder-add:disabled {
69
+ background: $surface; color: $secondary-text; text-opacity: 1; text-style: none;
70
+ }
71
+ #wizard-frame #ladder-editor #ladder-add:focus {
72
+ color: $background; background: $focus; text-style: bold;
73
+ }
74
+ """
75
+
76
+
77
+ @dataclass(frozen=True, slots=True)
78
+ class LadderConfiguration:
79
+ ladders: Mapping[str, tuple[Rung, ...]]
80
+ complexity: Mapping[ComplexityName, tuple[Rung, ...]]
81
+
82
+
83
+ class LadderEditor(Vertical):
84
+ """Edit the six initializer ladder scopes using text and catalog-backed additions."""
85
+
86
+ DEFAULT_CSS = """
87
+ LadderEditor { height: auto; min-height: 0; }
88
+ #ladder-description { margin-bottom: 1; }
89
+ #ladder-scope-title, #ladder-input-title {
90
+ width: 100%; height: auto; color: $steel-blue; text-style: bold;
91
+ }
92
+ #ladder-inheritance-context { width: 100%; height: auto; color: $secondary-text; }
93
+ #ladder-scopes { height: auto; }
94
+ #ladder-scopes .row-list-row { height: auto; }
95
+ #ladder-input { width: 1fr; height: 1; margin: 0; }
96
+ #ladder-input:focus { background: $selected-surface; }
97
+ #ladder-problem { height: auto; color: $dusty-rose; }
98
+ #ladder-form { height: auto; width: 100%; }
99
+ #ladder-add-row { height: 1; width: 100%; align: left middle; }
100
+ #ladder-add {
101
+ width: auto; min-width: 0; height: 1; min-height: 1; margin: 0;
102
+ padding: 0 1; border: none;
103
+ }
104
+ #ladder-guidance { height: auto; color: $secondary-text; }
105
+ """
106
+
107
+ def __init__(self) -> None:
108
+ super().__init__(id="ladder-editor")
109
+ self._catalogs: dict[str, ModelCatalog] = {}
110
+ self._catalog_errors: dict[str, str] = {}
111
+ self._capabilities: dict[str, AgentCapabilityResult] = {}
112
+ self._drafts: dict[tuple[str, ComplexityName | None], str] = dict.fromkeys(SCOPE_KEYS, "")
113
+ self._draft_cursors: dict[tuple[str, ComplexityName | None], int] = dict.fromkeys(
114
+ SCOPE_KEYS, 0
115
+ )
116
+ self._selected_scope = 0
117
+ self._new_agent = ""
118
+ self._new_model = ""
119
+ self._new_effort: str | None = None
120
+ self._validation_requested = False
121
+ self._validation_errors: dict[tuple[str, ComplexityName | None], tuple[str, str]] = {}
122
+
123
+ def compose(self) -> ComposeResult:
124
+ yield Static(
125
+ "Leader, Implementer and Helper use ordered agent:model[:effort] entries. "
126
+ "Blank Implementer Low, Normal and High fields inherit Base; entering text "
127
+ "creates an override. Choose installed agents and supported models, then Add "
128
+ "appends an entry.",
129
+ id="ladder-description",
130
+ markup=False,
131
+ )
132
+ yield Label(Text("Role and complexity ladders", style=BOLD_STEEL), id="ladder-scope-title")
133
+ yield RowList(widget_id="ladder-scopes")
134
+ yield Label("", id="ladder-input-title")
135
+ yield Static("", id="ladder-inheritance-context", markup=False)
136
+ with Horizontal(classes="palette-row wizard-long-label"):
137
+ yield Label("Ladder entries")
138
+ yield Input(value="", id=LADDER_INPUT[1:], select_on_focus=False)
139
+ yield Static("", id="ladder-problem", markup=False)
140
+ with Vertical(id="ladder-form"):
141
+ with Horizontal(classes="palette-row"):
142
+ yield Label("Agent")
143
+ yield Select((), allow_blank=True, prompt=AGENT_PROMPT, id=AGENT_SELECT[1:])
144
+ with Horizontal(classes="palette-row"):
145
+ yield Label("Model")
146
+ yield Select((), allow_blank=True, prompt=MODEL_PROMPT, id=MODEL_SELECT[1:])
147
+ with Horizontal(classes="palette-row"):
148
+ yield Label("Effort")
149
+ yield Select((), allow_blank=True, prompt=EFFORT_PROMPT, id=EFFORT_SELECT[1:])
150
+ with Horizontal(id="ladder-add-row"):
151
+ yield Button("Add", id=ADD_RUNG, variant="primary")
152
+ yield Static("", id="ladder-guidance", markup=False)
153
+
154
+ def on_mount(self) -> None:
155
+ self._refresh_scope()
156
+
157
+ @on(RowList.Moved, "#ladder-scopes")
158
+ def _scope_moved(self, message: RowList.Moved) -> None:
159
+ message.stop()
160
+ self._remember_field()
161
+ self._selected_scope = message.index
162
+ self._clear_form()
163
+ self._refresh_scope()
164
+
165
+ @on(RowList.Opened, "#ladder-scopes")
166
+ def _scope_opened(self, message: RowList.Opened) -> None:
167
+ message.stop()
168
+ self._remember_field()
169
+ self._selected_scope = message.index
170
+ self._clear_form()
171
+ self._refresh_scope()
172
+ self.call_after_refresh(self.query_one(LADDER_INPUT, Input).focus)
173
+
174
+ @on(Input.Changed, LADDER_INPUT)
175
+ def _ladder_changed(self, event: Input.Changed) -> None:
176
+ self._drafts[self._scope_key()] = event.value
177
+ self._draft_cursors[self._scope_key()] = event.input.cursor_position
178
+ self._refresh_rows()
179
+ self._refresh_problem()
180
+
181
+ def on_select_changed(self, event: Select.Changed) -> None:
182
+ if event.value != event.select.value:
183
+ event.stop()
184
+ return
185
+ control_id = event.select.id
186
+ if control_id == AGENT_SELECT[1:]:
187
+ selected = event.value if isinstance(event.value, str) else ""
188
+ self._new_agent = selected if selected in self._agent_names() else ""
189
+ self._new_model = ""
190
+ self._new_effort = None
191
+ self._refresh_form()
192
+ self.call_after_refresh(self.query_one(MODEL_SELECT, Select).focus)
193
+ elif control_id == MODEL_SELECT[1:]:
194
+ selected = event.value if isinstance(event.value, str) else ""
195
+ self._new_model = selected if self._model_offered(self._new_agent, selected) else ""
196
+ self._new_effort = None
197
+ self._refresh_form()
198
+ effort_select = cast("Select[str]", self.query_one(EFFORT_SELECT, Select))
199
+ if not effort_select.disabled:
200
+ self.call_after_refresh(effort_select.focus)
201
+ elif control_id == EFFORT_SELECT[1:]:
202
+ selected = event.value if isinstance(event.value, str) else ""
203
+ self._new_effort = selected or None
204
+ self._refresh_add_button()
205
+ else:
206
+ return
207
+ event.stop()
208
+
209
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
210
+ if event.button.id != ADD_RUNG:
211
+ return
212
+ self._append_entry()
213
+ event.stop()
214
+
215
+ def set_model_catalogs(
216
+ self,
217
+ catalogs: Mapping[str, ModelCatalog],
218
+ errors: Mapping[str, str],
219
+ ) -> None:
220
+ self._catalogs = {
221
+ agent: catalog for agent, catalog in catalogs.items() if self._offered_choices(catalog)
222
+ }
223
+ self._catalog_errors = dict(errors)
224
+ self._refresh_scope()
225
+
226
+ def set_agent_capabilities(self, capabilities: Mapping[str, AgentCapabilityResult]) -> None:
227
+ self._capabilities = dict(capabilities)
228
+ self._refresh_scope()
229
+
230
+ def set_scope(self, role: Role, complexity: ComplexityName | None = None) -> None:
231
+ """Select a scope for measuring the wizard's preferred content size."""
232
+ key = (role.value, complexity)
233
+ if key not in SCOPE_KEYS:
234
+ message = "complexity scopes belong to the Implementer role"
235
+ raise ValueError(message)
236
+ if self.is_mounted:
237
+ self._remember_field()
238
+ self._selected_scope = SCOPE_KEYS.index(key)
239
+ if self.is_mounted:
240
+ self._clear_form()
241
+ self._refresh_scope()
242
+
243
+ def configuration(self) -> LadderConfiguration:
244
+ self._validation_requested = True
245
+ self._validation_errors.clear()
246
+ ladders: dict[str, tuple[Rung, ...]] = {}
247
+ complexity: dict[ComplexityName, tuple[Rung, ...]] = {}
248
+ for index, key in enumerate(SCOPE_KEYS):
249
+ role, context = key
250
+ raw = self._drafts[key]
251
+ if role == Role.IMPLEMENTER.value and context is not None and not raw.strip():
252
+ continue
253
+ rungs = self._validated_scope(index, key, raw)
254
+ if context is None:
255
+ ladders[role] = rungs
256
+ else:
257
+ complexity[context] = rungs
258
+ return LadderConfiguration(ladders, complexity)
259
+
260
+ def show_validation_error(self, message: str) -> None:
261
+ """Show the submitted scope error beside its editable ladder field."""
262
+ self._validation_requested = True
263
+ key = self._scope_key()
264
+ self._validation_errors[key] = (self._drafts[key], message)
265
+ self._update_problem(message)
266
+
267
+ def _validated_scope(
268
+ self,
269
+ index: int,
270
+ key: tuple[str, ComplexityName | None],
271
+ raw: str,
272
+ ) -> tuple[Rung, ...]:
273
+ label = SCOPE_LABELS[key]
274
+ try:
275
+ rungs = parse_ladder_text(raw)
276
+ except LadderTextError as error:
277
+ self._focus_invalid(index)
278
+ message = f"{label} · entry {error.entry_index + 1}: {error.detail}"
279
+ raise ConfigError(message) from error
280
+ if not rungs:
281
+ self._focus_invalid(index)
282
+ message = f"{label}: enter at least one ladder entry"
283
+ raise ConfigError(message)
284
+ for rung_index, rung in enumerate(rungs):
285
+ if _is_helper_scope(key) and not self._headless_available(rung.agent):
286
+ self._focus_invalid(index)
287
+ message = f"{label} · entry {rung_index + 1}: {self._headless_detail(rung.agent)}"
288
+ raise ConfigError(message)
289
+ catalog = self._catalogs.get(rung.agent)
290
+ if catalog is None:
291
+ self._focus_invalid(index)
292
+ detail = self._catalog_errors.get(rung.agent, "choose a listed agent")
293
+ message = f"{label} · entry {rung_index + 1}: {detail}"
294
+ raise ConfigError(message)
295
+ assessment = assess_model_choice(rung.model, rung.effort, catalog)
296
+ if assessment.supported is not True:
297
+ self._focus_invalid(index)
298
+ message = f"{label} · entry {rung_index + 1}: {assessment.detail}"
299
+ raise ConfigError(message)
300
+ return rungs
301
+
302
+ def _append_entry(self) -> None:
303
+ rung = self._selected_rung()
304
+ if rung is None or not self._valid_catalog_choice(rung):
305
+ self._update_problem(
306
+ "Choose an available agent and model, plus a supported effort when offered."
307
+ )
308
+ return
309
+ key = self._scope_key()
310
+ raw = self._drafts[key]
311
+ try:
312
+ parse_ladder_text(raw)
313
+ except LadderTextError as error:
314
+ self._validation_requested = True
315
+ self._update_problem(f"Entry {error.entry_index + 1}: {error.detail}")
316
+ self.call_after_refresh(self.query_one(LADDER_INPUT, Input).focus)
317
+ return
318
+ if _is_helper_scope(key) and not self._headless_available(rung.agent):
319
+ self._update_problem(HELPER_CAPABILITY_GUIDANCE)
320
+ return
321
+ value = f"{rung.agent}{RUNG_SEPARATOR}{rung.model}"
322
+ if rung.effort is not None:
323
+ value = f"{value}{RUNG_SEPARATOR}{rung.effort}"
324
+ updated = f"{raw}{LADDER_SEPARATOR}{value}" if raw.strip() else value
325
+ field = self.query_one(LADDER_INPUT, Input)
326
+ self._drafts[key] = updated
327
+ self._draft_cursors[key] = len(updated)
328
+ field.value = updated
329
+ field.cursor_position = len(updated)
330
+ self._refresh_scope()
331
+ self.call_after_refresh(field.focus)
332
+
333
+ def _refresh_scope(self) -> None:
334
+ if not self.is_mounted:
335
+ return
336
+ field = self.query_one(LADDER_INPUT, Input)
337
+ key = self._scope_key()
338
+ self.query_one("#ladder-input-title", Label).update(
339
+ Text(f"Edit {SCOPE_LABELS[key]}", style=BOLD_STEEL)
340
+ )
341
+ raw = self._drafts[key]
342
+ inherited = key[0] == Role.IMPLEMENTER.value and key[1] is not None and not raw.strip()
343
+ base = self._drafts[(Role.IMPLEMENTER.value, None)]
344
+ inheritance = f"Blank inherits Implementer · Base: {base or '(empty)'}" if inherited else ""
345
+ inheritance_widget = self.query_one("#ladder-inheritance-context", Static)
346
+ inheritance_widget.update(inheritance)
347
+ inheritance_widget.display = bool(inheritance)
348
+ if field.value != raw:
349
+ field.value = raw
350
+ field.cursor_position = min(self._draft_cursors[key], len(raw))
351
+ self._refresh_rows()
352
+ self._refresh_form()
353
+ self._refresh_problem()
354
+ guidance = self.query_one("#ladder-guidance", Static)
355
+ guidance_text = self._guidance()
356
+ guidance.update(Text(guidance_text, style=SECONDARY_TEXT_COLOR))
357
+ guidance.display = bool(guidance_text)
358
+
359
+ def _refresh_rows(self) -> None:
360
+ rows = tuple(self._scope_row(key) for key in SCOPE_KEYS)
361
+ self.query_one("#ladder-scopes", RowList).set_rows(
362
+ rows,
363
+ selected=self._selected_scope,
364
+ empty=EMPTY_LADDER_LABEL,
365
+ )
366
+
367
+ def _scope_row(self, key: tuple[str, ComplexityName | None]) -> RowCells:
368
+ raw = self._drafts[key]
369
+ label = SCOPE_LABELS[key]
370
+ selected = SCOPE_KEYS.index(key) == self._selected_scope
371
+ emphasis = f"{STYLE_BOLD} " if selected else ""
372
+ if key[0] == Role.IMPLEMENTER.value and key[1] is not None and not raw.strip():
373
+ base = self._drafts[(Role.IMPLEMENTER.value, None)]
374
+ summary = f"inherits Base · {base or '(empty)'}"
375
+ else:
376
+ summary = raw or "(empty)"
377
+ return RowCells(
378
+ lead="",
379
+ text="",
380
+ columns=(
381
+ Text(label, style=f"{emphasis}{SECONDARY_TEXT_COLOR}"),
382
+ Text(summary, style=f"{emphasis}{PRIMARY_TEXT_COLOR}"),
383
+ ),
384
+ column_width=SCOPE_LABEL_WIDTH,
385
+ )
386
+
387
+ def _refresh_form(self) -> None:
388
+ agent_select = cast("Select[str]", self.query_one(AGENT_SELECT, Select))
389
+ model_select = cast("Select[str]", self.query_one(MODEL_SELECT, Select))
390
+ effort_select = cast("Select[str]", self.query_one(EFFORT_SELECT, Select))
391
+ agents = self._agent_options()
392
+ self._set_select_options(agent_select, agents, self._new_agent or None)
393
+ agent_select.disabled = not agents
394
+ models = self._model_options(self._new_agent) if self._new_agent else ()
395
+ self._set_select_options(model_select, models, self._new_model or None)
396
+ model_select.disabled = not self._new_agent or not models
397
+ efforts = self._effort_options(self._new_agent, self._new_model)
398
+ self._set_select_options(effort_select, efforts, self._new_effort)
399
+ effort_select.disabled = not self._new_model or not efforts
400
+ self._refresh_add_button()
401
+
402
+ def _refresh_add_button(self) -> None:
403
+ button = self.query_one(f"#{ADD_RUNG}", Button)
404
+ rung = self._selected_rung()
405
+ button.disabled = rung is None or not self._valid_catalog_choice(rung)
406
+
407
+ def _refresh_problem(self) -> None:
408
+ if not self._validation_requested:
409
+ self._update_problem("")
410
+ return
411
+ key = self._scope_key()
412
+ raw = self._drafts[key]
413
+ validation = self._validation_errors.get(key)
414
+ if validation is not None:
415
+ if validation[0] == raw:
416
+ self._update_problem(validation[1])
417
+ return
418
+ del self._validation_errors[key]
419
+ try:
420
+ parse_ladder_text(raw)
421
+ except LadderTextError as error:
422
+ self._update_problem(f"Entry {error.entry_index + 1}: {error.detail}")
423
+ else:
424
+ self._update_problem("")
425
+
426
+ def _update_problem(self, message: str) -> None:
427
+ problem = self.query_one("#ladder-problem", Static)
428
+ problem.update(Text(message, style=DUSTY_ROSE))
429
+ problem.display = bool(message)
430
+
431
+ def _set_select_options(
432
+ self,
433
+ select: Select[str],
434
+ options: tuple[tuple[RenderableType, str], ...],
435
+ value: str | None,
436
+ ) -> None:
437
+ target = Select.BLANK if value is None else value
438
+ with select.prevent(Select.Changed):
439
+ select.set_options(options)
440
+ if select.value != target:
441
+ select.value = target
442
+
443
+ def _clear_form(self) -> None:
444
+ self._new_agent = ""
445
+ self._new_model = ""
446
+ self._new_effort = None
447
+
448
+ def _remember_field(self) -> None:
449
+ if self.is_mounted:
450
+ field = self.query_one(LADDER_INPUT, Input)
451
+ key = self._scope_key()
452
+ self._drafts[key] = field.value
453
+ self._draft_cursors[key] = field.cursor_position
454
+
455
+ def _focus_invalid(self, scope_index: int) -> None:
456
+ self._remember_field()
457
+ self._clear_form()
458
+ self._selected_scope = scope_index
459
+ self._refresh_scope()
460
+ self.call_after_refresh(self.query_one(LADDER_INPUT, Input).focus)
461
+
462
+ def _scope_key(self) -> tuple[str, ComplexityName | None]:
463
+ return SCOPE_KEYS[self._selected_scope]
464
+
465
+ def _selected_rung(self) -> Rung | None:
466
+ if not self._new_agent or not self._new_model:
467
+ return None
468
+ return Rung(agent=self._new_agent, model=self._new_model, effort=self._new_effort)
469
+
470
+ def _valid_catalog_choice(self, rung: Rung) -> bool:
471
+ catalog = self._catalogs.get(rung.agent)
472
+ if (
473
+ catalog is None
474
+ or assess_model_choice(rung.model, rung.effort, catalog).supported is not True
475
+ ):
476
+ return False
477
+ return not _is_helper_scope(self._scope_key()) or self._headless_available(rung.agent)
478
+
479
+ def _agent_names(self) -> tuple[str, ...]:
480
+ if _is_helper_scope(self._scope_key()):
481
+ return tuple(agent for agent in self._catalogs if self._headless_available(agent))
482
+ return tuple(self._catalogs)
483
+
484
+ def _agent_options(self) -> tuple[tuple[RenderableType, str], ...]:
485
+ return tuple(
486
+ (Text(agent, style=PRIMARY_TEXT_COLOR), agent) for agent in self._agent_names()
487
+ )
488
+
489
+ def _model_options(self, agent: str) -> tuple[tuple[RenderableType, str], ...]:
490
+ catalog = self._catalogs.get(agent)
491
+ if catalog is None:
492
+ return ()
493
+ return tuple(
494
+ (Text(choice.model, style=PRIMARY_TEXT_COLOR), choice.model)
495
+ for choice in self._offered_choices(catalog)
496
+ )
497
+
498
+ def _effort_options(self, agent: str, model: str) -> tuple[tuple[RenderableType, str], ...]:
499
+ catalog = self._catalogs.get(agent)
500
+ if catalog is None or not model:
501
+ return ()
502
+ choice = next(item for item in self._offered_choices(catalog) if item.model == model)
503
+ return tuple(
504
+ (Text(effort, style=PRIMARY_TEXT_COLOR), effort) for effort in choice.efforts or ()
505
+ )
506
+
507
+ def _model_offered(self, agent: str, model: str) -> bool:
508
+ catalog = self._catalogs.get(agent)
509
+ return catalog is not None and model in {
510
+ choice.model for choice in self._offered_choices(catalog)
511
+ }
512
+
513
+ def _headless_available(self, agent: str) -> bool:
514
+ capability = self._capabilities.get(agent)
515
+ return capability is not None and capability.headless
516
+
517
+ def _headless_detail(self, agent: str) -> str:
518
+ capability = self._capabilities.get(agent)
519
+ if capability is not None and capability.error:
520
+ return f"headless support unavailable: {capability.error}"
521
+ return "Helper requires an agent with headless support"
522
+
523
+ def _guidance(self) -> str:
524
+ parts: list[str] = []
525
+ if not self._catalogs and not self._catalog_errors:
526
+ parts.append(MODEL_CATALOG_GUIDANCE)
527
+ if self._catalog_errors:
528
+ failures = "; ".join(
529
+ f"{agent}: {detail}" for agent, detail in self._catalog_errors.items()
530
+ )
531
+ parts.append(f"Model discovery unavailable for {failures}. {MODEL_CATALOG_GUIDANCE}")
532
+ if _is_helper_scope(self._scope_key()) and not any(
533
+ self._headless_available(agent) for agent in self._catalogs
534
+ ):
535
+ parts.append(HELPER_CAPABILITY_GUIDANCE)
536
+ return " ".join(parts)
537
+
538
+ @staticmethod
539
+ def _offered_choices(catalog: ModelCatalog) -> tuple[ModelChoice, ...]:
540
+ return tuple(choice for choice in catalog.models if choice.offered)
541
+
542
+
543
+ def _is_helper_scope(key: tuple[str, ComplexityName | None]) -> bool:
544
+ return key[0] == Role.HELPER.value
@@ -0,0 +1,62 @@
1
+ """The project settings a running dashboard applies, reloaded when the engine starts a new run."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from shikiru.domain.events import EventKind
8
+ from shikiru.errors import ConfigError
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Callable
12
+
13
+ from shikiru.config.settings import Settings
14
+ from shikiru.store.reader import StateReader
15
+
16
+ LATEST_ENGINE_START_LIMIT = 1
17
+ NO_ENGINE_START = 0
18
+
19
+
20
+ class LiveSettings:
21
+ """Hold the settings a dashboard reads, reloading them after each new engine start."""
22
+
23
+ def __init__(self, state: StateReader, load: Callable[[], Settings]) -> None:
24
+ """Record the latest engine start before loading, so a start during the load reloads."""
25
+ self._state = state
26
+ self._load = load
27
+ self._run_id = self._latest_run_id()
28
+ self._settings = load()
29
+ self._checked_version: int | None = None
30
+ self._reload_error: str | None = None
31
+
32
+ def current(self) -> Settings:
33
+ """Return the applied settings, reloading them first if a new engine run has started."""
34
+ self._reload_if_engine_started()
35
+ return self._settings
36
+
37
+ def reload_error(self) -> str | None:
38
+ """Return why the latest reload failed, or None when the applied settings are current."""
39
+ self._reload_if_engine_started()
40
+ return self._reload_error
41
+
42
+ def _reload_if_engine_started(self) -> None:
43
+ version = self._state.data_version()
44
+ if version == self._checked_version:
45
+ return
46
+ self._checked_version = version
47
+ run_id = self._latest_run_id()
48
+ if run_id in (NO_ENGINE_START, self._run_id):
49
+ return
50
+ self._run_id = run_id
51
+ try:
52
+ self._settings = self._load()
53
+ except ConfigError as error:
54
+ self._reload_error = str(error)
55
+ else:
56
+ self._reload_error = None
57
+
58
+ def _latest_run_id(self) -> int:
59
+ started = self._state.latest_events(
60
+ LATEST_ENGINE_START_LIMIT, kinds=(EventKind.ENGINE_STARTED,)
61
+ )
62
+ return started[0].id if started else NO_ENGINE_START