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,1389 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib import resources
4
+ from typing import TYPE_CHECKING, ClassVar, cast
5
+
6
+ from rich.console import Group
7
+ from rich.text import Text
8
+ from textual import on
9
+ from textual.app import App, ComposeResult
10
+ from textual.binding import Binding
11
+ from textual.containers import Horizontal, Vertical, VerticalScroll
12
+ from textual.message import Message
13
+ from textual.widgets import Button, Input, Label, Rule, Select, Static
14
+
15
+ from shikiru.agents.settings import AgentSetting, AgentSettingError, SettingKind
16
+ from shikiru.config.settings import (
17
+ ANY_COMMENT_FEEDBACK,
18
+ CHANGES_REQUESTED_FEEDBACK,
19
+ MERGE_INTEGRATION,
20
+ PULL_REQUEST_INTEGRATION,
21
+ SQUASH_INTEGRATION,
22
+ )
23
+ from shikiru.dashboard.ladder_editor import LADDER_ACTION_CSS, LadderConfiguration, LadderEditor
24
+ from shikiru.dashboard.orchestration_fields import (
25
+ MAX_CPU,
26
+ MAX_ITERATIONS,
27
+ MAX_PARALLEL,
28
+ MAX_QUOTA_WAIT,
29
+ MIN_FREE_RAM,
30
+ ORCHESTRATION_FIELDS,
31
+ PUSH_INTERVAL,
32
+ STALL_TIMEOUT,
33
+ TOOL_TIMEOUT,
34
+ OrchestrationFieldError,
35
+ )
36
+ from shikiru.dashboard.row_list import RowList
37
+ from shikiru.domain.tasks import Role
38
+ from shikiru.errors import ConfigError, ShikiruError
39
+ from shikiru.operator.detectors import (
40
+ DetectedProject,
41
+ InitWizardResult,
42
+ OrchestrationChoices,
43
+ ProjectDetectors,
44
+ )
45
+ from shikiru.paths import project_config_path
46
+ from shikiru.ui.keys import key_hint
47
+ from shikiru.ui.popup import (
48
+ POPUP_BODY_CLASS,
49
+ POPUP_COMPACT_CONTROL_CSS,
50
+ POPUP_CSS,
51
+ POPUP_FRAME_CLASS,
52
+ POPUP_HEADER_CLASS,
53
+ POPUP_RULE_CLASS,
54
+ POPUP_TITLE_CLASS,
55
+ PopupFrame,
56
+ )
57
+ from shikiru.ui.theme import install_theme
58
+ from shikiru.ui.tokens import (
59
+ ALERT_COLOR,
60
+ AMBER,
61
+ PRIMARY_TEXT_COLOR,
62
+ SECONDARY_TEXT_COLOR,
63
+ STEEL_BLUE,
64
+ STYLE_BOLD,
65
+ SUCCESS_COLOR,
66
+ )
67
+
68
+ if TYPE_CHECKING:
69
+ from collections.abc import Callable
70
+ from pathlib import Path
71
+
72
+ from rich.console import RenderableType
73
+ from textual import events
74
+ from textual.binding import BindingType
75
+ from textual.timer import Timer
76
+
77
+ from shikiru.agents.contracts import ModelCatalog
78
+ from shikiru.agents.settings import AgentSettings
79
+ from shikiru.config.settings import OrchestrationConfig
80
+ from shikiru.dashboard.orchestration_fields import OrchestrationField
81
+ from shikiru.operator.doctor import Doctor, DoctorReport
82
+ from shikiru.operator.plugin_checks import AgentCapabilityResult
83
+ from shikiru.workspace.git import LineChangeStats
84
+
85
+ STEP_REPOSITORY = "Repository"
86
+ STEP_AGENTS = "Agents and ladders"
87
+ STEP_TRACKERS = "Trackers"
88
+ STEP_CACHES = "Caches and CoW"
89
+ STEP_ORCHESTRATION = "Orchestration"
90
+ STEP_AGENT_SETTINGS = "Agents"
91
+ STEP_INTEGRATION = "Integration mode"
92
+ STEP_FINISH = "Finish"
93
+ TRACKERS_INPUT_ID = "trackers"
94
+ CACHES_INPUT_ID = "caches"
95
+ TRACKERS_LIST_ID = "tracker-candidates"
96
+ CACHES_LIST_ID = "cache-candidates"
97
+ INTEGRATION_SELECT_ID = "integration"
98
+ AGENT_SETTING_ID_PREFIX = "agent-setting"
99
+ UNSET_CHOICE_LABEL = "unset"
100
+ REPOSITORY_CHECKOUT_ID = "wizard-checkout"
101
+ WIZARD_STEPS = (
102
+ STEP_REPOSITORY,
103
+ STEP_AGENTS,
104
+ STEP_TRACKERS,
105
+ STEP_CACHES,
106
+ STEP_ORCHESTRATION,
107
+ STEP_AGENT_SETTINGS,
108
+ STEP_INTEGRATION,
109
+ STEP_FINISH,
110
+ )
111
+ REPOSITORY_STEP_INDEX = WIZARD_STEPS.index(STEP_REPOSITORY)
112
+ AGENTS_STEP_INDEX = WIZARD_STEPS.index(STEP_AGENTS)
113
+ TRACKERS_STEP_INDEX = WIZARD_STEPS.index(STEP_TRACKERS)
114
+ COW_STEP_INDEX = WIZARD_STEPS.index(STEP_CACHES)
115
+ ORCHESTRATION_STEP_INDEX = WIZARD_STEPS.index(STEP_ORCHESTRATION)
116
+ AGENT_SETTINGS_STEP_INDEX = WIZARD_STEPS.index(STEP_AGENT_SETTINGS)
117
+ INTEGRATION_STEP_INDEX = WIZARD_STEPS.index(STEP_INTEGRATION)
118
+ FINISH_STEP_INDEX = WIZARD_STEPS.index(STEP_FINISH)
119
+ BUTTON_FOCUS_IDS = {REPOSITORY_STEP_INDEX: "#wizard-next", FINISH_STEP_INDEX: "#wizard-finish"}
120
+ INTEGRATION_OPTIONS = (
121
+ ("Local merge", MERGE_INTEGRATION),
122
+ ("Local squash and merge", SQUASH_INTEGRATION),
123
+ ("Pull request", PULL_REQUEST_INTEGRATION),
124
+ )
125
+ INTEGRATION_LABELS = {value: label for label, value in INTEGRATION_OPTIONS}
126
+ INTEGRATION_UNSELECTED = "needs a choice"
127
+ PR_FEEDBACK_ROW_ID = "pr-feedback-row"
128
+ PR_FEEDBACK_SELECT_ID = "pr-feedback"
129
+ PR_FEEDBACK_OPTIONS = (
130
+ ("Changes requested only", CHANGES_REQUESTED_FEEDBACK),
131
+ ("Any review or discussion comment", ANY_COMMENT_FEEDBACK),
132
+ )
133
+ WIZARD_TITLE = "SHIKIRU"
134
+ LEGACY_CATALOG_DETAIL = "agent does not expose an authoritative model catalog"
135
+ WIZARD_FRAME_MAX_WIDTH = 96
136
+ WIZARD_FRAME_HEIGHT = 36
137
+ WIZARD_HINT_NARROW_WIDTH = 72
138
+ WIZARD_ART_WIDTH = 120
139
+ WIZARD_ART_HEIGHT = 40
140
+ WIZARD_COMPACT_HEIGHT = 12
141
+ WIZARD_COMPACT_WIDTH = 48
142
+ WIZARD_ART_NAME = "shikiru_init_ascii_art.txt"
143
+ ART_REVEAL_SECONDS = 5
144
+ FIELD_EDIT_HINT = "Tab / Enter edit"
145
+ NARROW_AGENT_HINT = "Tab / Enter select"
146
+ NARROW_CANDIDATE_HINT = "Enter add"
147
+ WIZARD_HINTS = (
148
+ "",
149
+ "↑↓ select scope · Enter edit",
150
+ "↑↓ select · Enter add",
151
+ "↑↓ select · Enter add",
152
+ FIELD_EDIT_HINT,
153
+ FIELD_EDIT_HINT,
154
+ "Tab / Enter choose",
155
+ "Enter run Doctor",
156
+ )
157
+ SCROLL_HINT = "PgUp/PgDn scroll"
158
+ EXIT_HINT = "Enter Exit"
159
+ PATH_SEPARATOR = ","
160
+ NO_CANDIDATES = "No detected candidates."
161
+ DETAIL_LABEL_WIDTH = 15
162
+ MINUS_SIGN = "\N{MINUS SIGN}"
163
+
164
+ WIZARD_LAYER_CSS = f"""
165
+ #wizard-art {{
166
+ layer: art; position: absolute; offset: 0 0;
167
+ width: {WIZARD_ART_WIDTH}; height: {WIZARD_ART_HEIGHT}; overflow: hidden;
168
+ }}
169
+ #wizard-frame {{
170
+ layer: panel; width: auto; min-width: 0; max-width: {WIZARD_FRAME_MAX_WIDTH};
171
+ height: {WIZARD_FRAME_HEIGHT}; min-height: {WIZARD_FRAME_HEIGHT};
172
+ max-height: 100%; margin: 0; padding: 1 2;
173
+ background: $surface; color: $primary-text;
174
+ }}
175
+ """
176
+
177
+
178
+ def _wizard_art() -> Text:
179
+ contents = (
180
+ resources.files("shikiru.dashboard").joinpath(WIZARD_ART_NAME).read_text(encoding="utf-8")
181
+ )
182
+ art = Text.from_ansi(contents)
183
+ art.no_wrap = True
184
+ return art
185
+
186
+
187
+ def _step_counter(step: int) -> str:
188
+ return f"{step + 1} / {len(WIZARD_STEPS)}"
189
+
190
+
191
+ def _step_title(step: int) -> Text:
192
+ title = Text()
193
+ title.append(WIZARD_TITLE, style=f"{STYLE_BOLD} {STEEL_BLUE}")
194
+ title.append(" · ", style=PRIMARY_TEXT_COLOR)
195
+ title.append(WIZARD_STEPS[step], style=f"{STYLE_BOLD} {PRIMARY_TEXT_COLOR}")
196
+ return title
197
+
198
+
199
+ class WizardDetailRow(Horizontal):
200
+ """Render read-only facts with a fixed label and wrapping value column."""
201
+
202
+ def __init__(
203
+ self,
204
+ label: str,
205
+ value: str | Text,
206
+ value_style: str = PRIMARY_TEXT_COLOR,
207
+ *,
208
+ widget_id: str | None = None,
209
+ ) -> None:
210
+ super().__init__(id=widget_id, classes="wizard-detail-row")
211
+ self._label = label
212
+ self._value = value if isinstance(value, Text) else Text(value, style=value_style)
213
+
214
+ def compose(self) -> ComposeResult:
215
+ yield Static(
216
+ Text(self._label, style=SECONDARY_TEXT_COLOR),
217
+ classes="wizard-detail-label",
218
+ markup=False,
219
+ )
220
+ yield Static(self._value, classes="wizard-detail-value", markup=False)
221
+
222
+ def update_value(self, value: str | Text) -> None:
223
+ """Replace the complete read-only value while keeping its label column."""
224
+ self._value = value if isinstance(value, Text) else Text(value, style=PRIMARY_TEXT_COLOR)
225
+ self.query_one(".wizard-detail-value", Static).update(self._value)
226
+
227
+
228
+ def _detail_row(
229
+ label: str,
230
+ value: str | Text,
231
+ value_style: str = PRIMARY_TEXT_COLOR,
232
+ *,
233
+ widget_id: str | None = None,
234
+ ) -> WizardDetailRow:
235
+ if isinstance(value, str):
236
+ value = Text(value, style=value_style)
237
+ return WizardDetailRow(label, value, widget_id=widget_id)
238
+
239
+
240
+ def _section_heading(value: str) -> Text:
241
+ return Text(value, style=f"{STYLE_BOLD} {STEEL_BLUE}")
242
+
243
+
244
+ def _agent_setting_id(agent: int, key: int) -> str:
245
+ return f"{AGENT_SETTING_ID_PREFIX}-{agent}-{key}"
246
+
247
+
248
+ def _candidate_add_id(field_id: str, index: int) -> str:
249
+ return f"{field_id}-candidate-add-{index}"
250
+
251
+
252
+ def _repository_checkout(
253
+ base: str,
254
+ *,
255
+ clean: bool,
256
+ changes: LineChangeStats | None = None,
257
+ error: str | None = None,
258
+ ) -> Text:
259
+ checkout = Text()
260
+ checkout.append(base, style=PRIMARY_TEXT_COLOR)
261
+ checkout.append(" · ", style=SECONDARY_TEXT_COLOR)
262
+ if clean:
263
+ checkout.append("clean", style=f"{STYLE_BOLD} {SUCCESS_COLOR}")
264
+ elif changes is None and error is None:
265
+ checkout.append("changed · loading line totals…", style=PRIMARY_TEXT_COLOR)
266
+ elif changes is None:
267
+ checkout.append(
268
+ f"changed · line totals unavailable: {error or 'unknown error'}",
269
+ style=f"{STYLE_BOLD} {AMBER}",
270
+ )
271
+ else:
272
+ checkout.append(f"+{changes.added_lines}", style=SUCCESS_COLOR)
273
+ checkout.append(" ", style=SECONDARY_TEXT_COLOR)
274
+ checkout.append(f"{MINUS_SIGN}{changes.deleted_lines}", style=ALERT_COLOR)
275
+ details: list[str] = []
276
+ if changes.untracked_paths:
277
+ label = "path" if changes.untracked_paths == 1 else "paths"
278
+ details.append(f"{changes.untracked_paths} untracked {label}")
279
+ if changes.binary_entries:
280
+ label = "entry" if changes.binary_entries == 1 else "entries"
281
+ details.append(f"{changes.binary_entries} binary {label}")
282
+ if details:
283
+ checkout.append(f" · {' · '.join(details)}", style=AMBER)
284
+ return checkout
285
+
286
+
287
+ class _CandidatePath(Static):
288
+ class Chosen(Message):
289
+ def __init__(self, index: int) -> None:
290
+ super().__init__()
291
+ self.index = index
292
+
293
+ def __init__(self, path: str, index: int) -> None:
294
+ super().__init__(path, markup=False, classes="candidate-path")
295
+ self._index = index
296
+
297
+ def on_click(self, event: events.Click) -> None:
298
+ event.stop()
299
+ self.post_message(self.Chosen(self._index))
300
+
301
+
302
+ class CandidateRows(Vertical, can_focus=True):
303
+ """Render detected paths beside their own Add actions, wrapping paths in place."""
304
+
305
+ BINDINGS: ClassVar[list[BindingType]] = [
306
+ Binding("up", "move(-1)", "Previous", show=False),
307
+ Binding("down", "move(1)", "Next", show=False),
308
+ Binding("enter", "open", "Add", show=False),
309
+ ]
310
+
311
+ class Opened(Message):
312
+ """The selected candidate was added."""
313
+
314
+ def __init__(self, candidate_rows: CandidateRows, index: int) -> None:
315
+ super().__init__()
316
+ self._candidate_rows = candidate_rows
317
+ self.index = index
318
+
319
+ @property
320
+ def control(self) -> CandidateRows:
321
+ """The candidate rows that posted this message."""
322
+ return self._candidate_rows
323
+
324
+ def __init__(self, widget_id: str, field_id: str, candidates: tuple[str, ...]) -> None:
325
+ super().__init__(id=widget_id, classes="candidate-list")
326
+ self._field_id = field_id
327
+ self._candidates = candidates
328
+ self._selected = 0
329
+ self._rows: list[Horizontal] = []
330
+
331
+ @property
332
+ def candidates(self) -> tuple[str, ...]:
333
+ """Detected project-relative paths in display order."""
334
+ return self._candidates
335
+
336
+ def compose(self) -> ComposeResult:
337
+ if not self._candidates:
338
+ yield Static(NO_CANDIDATES, classes="candidate-empty", markup=False)
339
+ for index, path in enumerate(self._candidates):
340
+ with Horizontal(classes="wizard-candidate-row") as row:
341
+ row.set_class(index == self._selected, "-selected")
342
+ yield _CandidatePath(path, index)
343
+ yield Button(
344
+ "Add",
345
+ id=_candidate_add_id(self._field_id, index),
346
+ variant="primary",
347
+ )
348
+ self._rows.append(row)
349
+
350
+ @on(_CandidatePath.Chosen)
351
+ def _path_chosen(self, message: _CandidatePath.Chosen) -> None:
352
+ message.stop()
353
+ self._choose(message.index)
354
+ self.action_open()
355
+
356
+ def set_included(self, included: set[str]) -> None:
357
+ """Disable Add for paths already included in the editable field."""
358
+ for index, path in enumerate(self._candidates):
359
+ button = self.query_one(f"#{_candidate_add_id(self._field_id, index)}", Button)
360
+ button.disabled = path in included
361
+
362
+ def action_move(self, step: int) -> None:
363
+ """Move the selected candidate within the detected paths."""
364
+ self._choose(min(max(self._selected + step, 0), max(len(self._candidates) - 1, 0)))
365
+
366
+ def action_open(self) -> None:
367
+ """Add the selected candidate to its editable path field."""
368
+ if self._candidates:
369
+ self.post_message(self.Opened(self, self._selected))
370
+
371
+ def _choose(self, index: int) -> None:
372
+ if not 0 <= index < len(self._candidates) or index == self._selected:
373
+ return
374
+ self._selected = index
375
+ for row_index, row in enumerate(self._rows):
376
+ row.set_class(row_index == index, "-selected")
377
+ self._rows[index].scroll_visible(animate=False)
378
+
379
+
380
+ class InitWizardBody(VerticalScroll):
381
+ def on_descendant_focus(self, event: events.DescendantFocus) -> None:
382
+ self.scroll_to_widget(event.widget, animate=False, immediate=True)
383
+
384
+
385
+ class InitWizardApp(App[InitWizardResult | None]):
386
+ """Guide an operator through project setup and show the final Doctor checks."""
387
+
388
+ ENABLE_COMMAND_PALETTE = False
389
+ BINDINGS: ClassVar[list[BindingType]] = [
390
+ Binding("ctrl+q", "block_quit", show=False, priority=True),
391
+ Binding("ctrl+f", "show_art", "Show artwork", show=False),
392
+ ("pageup", "scroll_body_up", "Scroll up"),
393
+ ("pagedown", "scroll_body_down", "Scroll down"),
394
+ ]
395
+ CSS = (
396
+ POPUP_CSS
397
+ + WIZARD_LAYER_CSS
398
+ + """
399
+ Screen {
400
+ layers: art panel;
401
+ align: center middle;
402
+ border: solid $neutral-border;
403
+ background: $background;
404
+ overflow: hidden;
405
+ }
406
+ #wizard-header { width: 100%; height: 1; }
407
+ #wizard-title { width: 1fr; height: 1; color: $primary-text; text-style: bold; }
408
+ #wizard-step { width: auto; height: 1; color: $steel-blue; text-style: bold; }
409
+ #wizard-body {
410
+ height: auto; max-height: 1fr; min-height: 0; padding: 1 0 0 0;
411
+ background: $surface; color: $primary-text;
412
+ scrollbar-size-vertical: $scrollbar-width;
413
+ }
414
+ #wizard-frame.stable > #wizard-body { height: 1fr; min-height: 0; }
415
+ .wizard-page { height: auto; padding: 0; }
416
+ #wizard-frame.height-clamped { min-height: 0; }
417
+ #wizard-frame.compact { padding: 0 1; }
418
+ #wizard-frame.compact > #wizard-header { height: auto; min-height: 1; align: left top; }
419
+ #wizard-frame.compact #wizard-title { height: auto; min-height: 1; }
420
+ #wizard-frame.compact > #wizard-body { padding: 0; }
421
+ #wizard-frame.narrow #wizard-hint-left { height: 2; min-height: 2; }
422
+ #wizard-finish-before, #wizard-finish-after { height: auto; min-height: 0; padding: 0; }
423
+ #wizard-frame Select { height: 1; margin: 0; }
424
+ .wizard-description { margin-bottom: 1; }
425
+ .wizard-page Button {
426
+ width: auto; min-width: 0; height: 1; min-height: 1;
427
+ margin: 0 1 0 0; padding: 0 1; border: none;
428
+ }
429
+ .wizard-page Static { height: auto; color: $primary-text; }
430
+ #catalog-discovery, #wizard-error { height: auto; }
431
+ #wizard-error { color: $dusty-rose; text-style: bold; }
432
+ #wizard-navigation-hint {
433
+ layout: grid; grid-size: 2 0; grid-columns: 1fr auto; grid-gutter: 0 1;
434
+ height: auto; min-height: 1; width: 100%;
435
+ }
436
+ #wizard-hint-left { width: 100%; height: auto; min-height: 1; color: $secondary-text; }
437
+ #wizard-hint-right {
438
+ width: auto; height: 1; color: $secondary-text; text-align: right;
439
+ }
440
+ #wizard-navigation { height: 1; min-height: 1; width: 100%; align: right middle; }
441
+ #wizard-navigation Button {
442
+ width: auto; min-width: 0; height: 1; min-height: 1;
443
+ margin: 0 1 0 0; padding: 0 1; border: none;
444
+ border-top: none; border-right: none; border-bottom: none; border-left: none;
445
+ }
446
+ #wizard-navigation Button:focus {
447
+ color: $primary-text; background: $selected-surface; text-style: none;
448
+ border: none; border-top: none; border-right: none; border-bottom: none; border-left: none;
449
+ }
450
+ #wizard-navigation Button:disabled {
451
+ color: $secondary-text;
452
+ border: none; border-top: none; border-right: none; border-bottom: none; border-left: none;
453
+ }
454
+ #wizard-navigation Button.-primary {
455
+ color: $steel-blue; background: $surface; text-style: bold;
456
+ }
457
+ #wizard-navigation Button.-primary:focus {
458
+ color: $steel-blue; background: $selected-surface; text-style: bold;
459
+ }
460
+ #wizard-navigation Button.-primary:disabled {
461
+ color: $secondary-text; background: $surface; text-style: none;
462
+ }
463
+ .candidate-list { width: 100%; height: auto; }
464
+ .wizard-candidate-row { width: 100%; height: auto; }
465
+ .wizard-candidate-row.-selected { background: $selected-surface; }
466
+ .wizard-candidate-row .candidate-path { width: 1fr; height: auto; }
467
+ #wizard-frame .wizard-candidate-row Button {
468
+ width: auto; min-width: 4; height: 1; min-height: 1;
469
+ margin: 0; padding: 0 1; border: none;
470
+ color: $steel-blue; background: $surface; text-style: bold;
471
+ }
472
+ #wizard-frame .wizard-candidate-row Button:focus {
473
+ border: none; color: $primary-text; background: $selected-surface; text-style: bold;
474
+ }
475
+ #wizard-frame .wizard-candidate-row Button:disabled {
476
+ border: none; color: $secondary-text; background: $surface; text-style: none;
477
+ }
478
+ #wizard-page-2 .section-heading, #wizard-page-3 .section-heading {
479
+ height: auto; color: $steel-blue; text-style: bold;
480
+ }
481
+ #wizard-cow-result { height: auto; }
482
+ """
483
+ + LADDER_ACTION_CSS
484
+ + POPUP_COMPACT_CONTROL_CSS
485
+ + """
486
+ .wizard-page .palette-row Label { color: $secondary-text; text-style: none; }
487
+ .wizard-page .wizard-long-label Label { height: auto; }
488
+ """
489
+ + f"""
490
+ #wizard-frame .wizard-wide-label Label {{ width: {DETAIL_LABEL_WIDTH}; }}
491
+ #wizard-frame .wizard-long-label Label {{ width: {DETAIL_LABEL_WIDTH}; }}
492
+ .orchestration-field {{ height: auto; margin-bottom: 1; }}
493
+ .wizard-page .orchestration-unit {{
494
+ width: auto; height: 1; padding: 0 1; color: $secondary-text;
495
+ }}
496
+ .wizard-page .orchestration-help {{
497
+ height: auto; padding-left: {DETAIL_LABEL_WIDTH}; color: $secondary-text;
498
+ }}
499
+ .wizard-detail-row {{ width: 100%; height: auto; }}
500
+ .wizard-detail-row .wizard-detail-label {{
501
+ width: {DETAIL_LABEL_WIDTH}; height: auto; color: $secondary-text; text-style: none;
502
+ }}
503
+ .wizard-detail-row .wizard-detail-value {{ width: 1fr; height: auto; }}
504
+ """
505
+ )
506
+
507
+ def __init__(
508
+ self,
509
+ *,
510
+ project_root: Path,
511
+ project: DetectedProject,
512
+ detectors: ProjectDetectors,
513
+ doctor: Doctor,
514
+ doctor_renderer: Callable[..., RenderableType],
515
+ orchestration_defaults: OrchestrationConfig,
516
+ ) -> None:
517
+ super().__init__()
518
+ install_theme(self)
519
+ self._root = project_root.resolve()
520
+ self._config_target = project_config_path(self._root)
521
+ self._project = project
522
+ self._detectors = detectors
523
+ self._doctor = doctor
524
+ self._doctor_renderer = doctor_renderer
525
+ self._orchestration_defaults = orchestration_defaults
526
+ self._step = 0
527
+ self._cow_supported: bool | None = None
528
+ self._config_path: Path | None = None
529
+ self._doctor_report: DoctorReport | None = None
530
+ self._doctor_report_width: int | None = None
531
+ self._doctor_error: str | None = None
532
+ self._error = ""
533
+ self._model_catalogs: dict[str, ModelCatalog] = {}
534
+ self._catalog_errors: dict[str, str] = {}
535
+ self._agent_capabilities: dict[str, AgentCapabilityResult] = {}
536
+ self._catalogs_loaded = False
537
+ self._frame_ready = False
538
+ self._writing_config = False
539
+ self._finishing = False
540
+ self._last_focus_by_step: dict[int, str] = {}
541
+ self._art_timer: Timer | None = None
542
+ self._art_focus_id = ""
543
+ self._art_scroll_y = 0.0
544
+
545
+ def compose(self) -> ComposeResult:
546
+ art = _wizard_art()
547
+ yield Static(art, id="wizard-art", markup=False)
548
+ with PopupFrame(widget_id="wizard-frame", classes=POPUP_FRAME_CLASS):
549
+ with Horizontal(classes=POPUP_HEADER_CLASS, id="wizard-header"):
550
+ yield Static(
551
+ _step_title(self._step),
552
+ id="wizard-title",
553
+ classes=POPUP_TITLE_CLASS,
554
+ markup=False,
555
+ )
556
+ yield Static(_step_counter(self._step), id="wizard-step", markup=False)
557
+ yield Rule(classes=POPUP_RULE_CLASS, id="wizard-rule")
558
+ with InitWizardBody(id="wizard-body", classes=POPUP_BODY_CLASS):
559
+ yield from self._repository_page()
560
+ with Vertical(id="wizard-page-1", classes="wizard-page"):
561
+ yield LadderEditor()
562
+ yield from self._trackers_page()
563
+ yield from self._caches_page()
564
+ yield from self._orchestration_page()
565
+ yield from self._agent_settings_page()
566
+ yield from self._integration_page()
567
+ yield from self._finish_page()
568
+ yield Static("", id="wizard-error", markup=False)
569
+ with Horizontal(id="wizard-navigation-hint"):
570
+ yield Static("", id="wizard-hint-left", markup=False)
571
+ right_hint = Text()
572
+ right_hint.append("Ctrl+F", style=f"{STYLE_BOLD} {STEEL_BLUE}")
573
+ right_hint.append(" ")
574
+ right_hint.append("富士山", style=SECONDARY_TEXT_COLOR)
575
+ yield Static(right_hint, id="wizard-hint-right", markup=False)
576
+ yield Rule(classes=POPUP_RULE_CLASS, id="wizard-navigation-rule")
577
+ with Horizontal(id="wizard-navigation"):
578
+ yield Button("Back", id="wizard-back")
579
+ yield Button("Next", variant="primary", id="wizard-next")
580
+ yield Button("Run doctor", variant="primary", id="wizard-finish")
581
+ yield Button("Cancel", id="wizard-cancel")
582
+
583
+ def on_resize(self, event: events.Resize) -> None:
584
+ del event
585
+ self.call_after_refresh(self._adapt_to_viewport)
586
+
587
+ def _adapt_to_viewport(self) -> None:
588
+ frame = self.query_one("#wizard-frame", PopupFrame)
589
+ compact = (
590
+ self.size.height <= WIZARD_COMPACT_HEIGHT or self.size.width <= WIZARD_COMPACT_WIDTH
591
+ )
592
+ frame.set_class(compact, "compact")
593
+ frame.set_class(self.size.width <= WIZARD_HINT_NARROW_WIDTH, "narrow")
594
+ frame.set_class(self.size.height < WIZARD_FRAME_HEIGHT, "height-clamped")
595
+ frame.update_preferred_size()
596
+ if self._config_path is not None:
597
+ self.call_after_refresh(self._update_doctor_report)
598
+ self.call_after_refresh(self._update_navigation_hint)
599
+
600
+ def on_mount(self) -> None:
601
+ self.query_one("#wizard-frame", PopupFrame).visible = False
602
+ for input_widget in self.query(Input):
603
+ input_widget.cursor_blink = False
604
+ self._update_pages()
605
+ self._update_pr_feedback_row()
606
+ self._refresh_candidate_rows()
607
+ self.call_after_refresh(self._capture_agent_page_size)
608
+ self.run_worker(self._load_model_catalogs(), group="model-catalogs", exclusive=True)
609
+ if not self._project.worktree_clean:
610
+ self.run_worker(
611
+ self._load_checkout_line_changes(), group="checkout-lines", exclusive=True
612
+ )
613
+
614
+ def on_unmount(self) -> None:
615
+ self._stop_art_timer()
616
+
617
+ def _capture_agent_page_size(self) -> None:
618
+ editor = self.query_one("#ladder-editor", LadderEditor)
619
+ editor.set_scope(Role.HELPER)
620
+ self._step = AGENTS_STEP_INDEX
621
+ self._update_pages()
622
+ self.call_after_refresh(self._mark_frame_content_ready)
623
+
624
+ def _mark_frame_content_ready(self) -> None:
625
+ frame = self.query_one("#wizard-frame", PopupFrame)
626
+ frame.content_ready()
627
+ self.call_after_refresh(self._capture_completed_frame_size)
628
+
629
+ def _capture_completed_frame_size(self) -> None:
630
+ self.query_one("#wizard-frame", PopupFrame).update_preferred_size()
631
+ self.call_after_refresh(self._restore_repository_page)
632
+
633
+ def _restore_repository_page(self) -> None:
634
+ self.query_one("#ladder-editor", LadderEditor).set_scope(Role.LEADER)
635
+ self._step = REPOSITORY_STEP_INDEX
636
+ self._update_pages()
637
+ self.call_after_refresh(self._enable_wizard)
638
+
639
+ def _enable_wizard(self) -> None:
640
+ self.query_one("#wizard-frame", PopupFrame).visible = True
641
+ self._frame_ready = True
642
+ self._update_pages()
643
+ self._adapt_to_viewport()
644
+ self.call_after_refresh(self._focus_step)
645
+
646
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
647
+ if not self.query_one("#wizard-frame", PopupFrame).visible:
648
+ return
649
+ button_id = event.button.id or ""
650
+ for field_id, _, candidates in self._candidate_rows():
651
+ for index, candidate in enumerate(candidates):
652
+ if button_id == _candidate_add_id(field_id, index):
653
+ self._add_candidate(field_id, candidate)
654
+ event.stop()
655
+ return
656
+ match button_id:
657
+ case "wizard-back":
658
+ await self._move(-1)
659
+ case "wizard-next":
660
+ await self._move(1)
661
+ case "wizard-finish":
662
+ if self._config_path is None:
663
+ await self._finish()
664
+ else:
665
+ self._stop_art_timer()
666
+ self.exit(self._result())
667
+ case "wizard-cancel":
668
+ self.action_cancel()
669
+ case _:
670
+ return
671
+
672
+ @on(Input.Changed)
673
+ def _input_changed(self, event: Input.Changed) -> None:
674
+ if event.input.id in {TRACKERS_INPUT_ID, CACHES_INPUT_ID}:
675
+ self._refresh_candidate_rows()
676
+ self.call_after_refresh(self._update_navigation_hint)
677
+ self._update_finish_summary()
678
+
679
+ @on(Select.Changed)
680
+ def _select_changed(self, event: Select.Changed) -> None:
681
+ if event.select.id == INTEGRATION_SELECT_ID:
682
+ self._update_pr_feedback_row()
683
+ self._update_finish_summary()
684
+
685
+ @on(CandidateRows.Opened)
686
+ def _candidate_opened(self, message: CandidateRows.Opened) -> None:
687
+ list_id = message.control.id
688
+ if list_id == TRACKERS_LIST_ID:
689
+ field_id = TRACKERS_INPUT_ID
690
+ elif list_id == CACHES_LIST_ID:
691
+ field_id = CACHES_INPUT_ID
692
+ else:
693
+ return
694
+ message.stop()
695
+ self._add_candidate(field_id, message.control.candidates[message.index])
696
+
697
+ def _add_candidate(self, field_id: str, candidate: str) -> None:
698
+ field = self.query_one(f"#{field_id}", Input)
699
+ entries = [entry.strip() for entry in field.value.split(PATH_SEPARATOR) if entry.strip()]
700
+ if candidate not in entries:
701
+ entries.append(candidate)
702
+ field.value = f"{PATH_SEPARATOR} ".join(entries)
703
+ field.cursor_position = len(field.value)
704
+ self._refresh_candidate_rows()
705
+ self.call_after_refresh(field.focus)
706
+
707
+ def _refresh_candidate_rows(self) -> None:
708
+ for field_id, list_id, _candidates in self._candidate_rows():
709
+ input_widget = self.query_one(f"#{field_id}", Input)
710
+ candidate_list = self.query_one(f"#{list_id}", CandidateRows)
711
+ included = {
712
+ entry.strip() for entry in input_widget.value.split(PATH_SEPARATOR) if entry.strip()
713
+ }
714
+ candidate_list.set_included(included)
715
+
716
+ def _candidate_rows(
717
+ self,
718
+ ) -> tuple[tuple[str, str, tuple[str, ...]], ...]:
719
+ return (
720
+ (TRACKERS_INPUT_ID, TRACKERS_LIST_ID, self._project.trackers),
721
+ (CACHES_INPUT_ID, CACHES_LIST_ID, self._project.caches),
722
+ )
723
+
724
+ async def _load_checkout_line_changes(self) -> None:
725
+ try:
726
+ changes = await self._detectors.working_tree_line_changes()
727
+ except (OSError, ShikiruError) as error:
728
+ checkout = _repository_checkout(
729
+ self._project.base,
730
+ clean=False,
731
+ error=str(error) or type(error).__name__,
732
+ )
733
+ else:
734
+ checkout = _repository_checkout(
735
+ self._project.base,
736
+ clean=False,
737
+ changes=changes,
738
+ )
739
+ if self._config_path is not None or not self._wizard_mounted():
740
+ return
741
+ self.query_one(f"#{REPOSITORY_CHECKOUT_ID}", WizardDetailRow).update_value(checkout)
742
+
743
+ def action_show_art(self) -> None:
744
+ if self._art_timer is not None:
745
+ self._stop_art_timer()
746
+ self._art_timer = self.set_timer(ART_REVEAL_SECONDS, self._restore_art_panel)
747
+ return
748
+ focused = self.focused
749
+ if focused is not None and (
750
+ isinstance(focused, (Input, Select))
751
+ or any(isinstance(ancestor, Select) for ancestor in focused.ancestors)
752
+ ):
753
+ return
754
+ if any(select.expanded for select in self.query(Select[str])):
755
+ return
756
+ frame = self.query_one("#wizard-frame", PopupFrame)
757
+ if not frame.visible:
758
+ return
759
+ body = self.query_one("#wizard-body", InitWizardBody)
760
+ self._art_focus_id = focused.id if focused is not None and focused.id else ""
761
+ self._art_scroll_y = body.scroll_y
762
+ frame.visible = False
763
+ self._art_timer = self.set_timer(ART_REVEAL_SECONDS, self._restore_art_panel)
764
+
765
+ def _restore_art_panel(self) -> None:
766
+ self._art_timer = None
767
+ self.query_one("#wizard-frame", PopupFrame).visible = True
768
+ self.call_after_refresh(self._restore_art_state)
769
+
770
+ def _restore_art_state(self) -> None:
771
+ if self._art_focus_id:
772
+ target = self.query(f"#{self._art_focus_id}")
773
+ if target:
774
+ target.first().focus(scroll_visible=False)
775
+ self.call_after_refresh(self._restore_art_scroll)
776
+
777
+ def _restore_art_scroll(self) -> None:
778
+ body = self.query_one("#wizard-body", InitWizardBody)
779
+ body.scroll_to(
780
+ y=min(max(self._art_scroll_y, 0), body.max_scroll_y),
781
+ animate=False,
782
+ immediate=True,
783
+ )
784
+ self._update_navigation_hint()
785
+
786
+ def _stop_art_timer(self) -> None:
787
+ if self._art_timer is not None:
788
+ self._art_timer.stop()
789
+ self._art_timer = None
790
+
791
+ def _update_navigation_hint(self) -> None:
792
+ self.call_after_refresh(self._render_navigation_hint)
793
+
794
+ def _render_navigation_hint(self) -> None:
795
+ text = EXIT_HINT if self._config_path is not None else self._step_hint()
796
+ body = self.query_one("#wizard-body", InitWizardBody)
797
+ overflows = body.max_scroll_y > 0
798
+ if self.size.width <= WIZARD_HINT_NARROW_WIDTH:
799
+ hint = Group(key_hint(text), key_hint(SCROLL_HINT)) if overflows else key_hint(text)
800
+ else:
801
+ if overflows:
802
+ text = f"{text} · {SCROLL_HINT}" if text else SCROLL_HINT
803
+ hint = key_hint(text)
804
+ self.query_one("#wizard-hint-left", Static).update(hint)
805
+
806
+ def _step_hint(self) -> str:
807
+ if self._step == TRACKERS_STEP_INDEX and not self._project.trackers:
808
+ return FIELD_EDIT_HINT
809
+ if self._step == COW_STEP_INDEX and not self._project.caches:
810
+ return FIELD_EDIT_HINT
811
+ if self.size.width <= WIZARD_HINT_NARROW_WIDTH:
812
+ if self._step == AGENTS_STEP_INDEX:
813
+ return NARROW_AGENT_HINT
814
+ if self._step in {TRACKERS_STEP_INDEX, COW_STEP_INDEX}:
815
+ return NARROW_CANDIDATE_HINT
816
+ return WIZARD_HINTS[self._step]
817
+
818
+ def _update_pr_feedback_row(self) -> None:
819
+ integration = cast("Select[str]", self.query_one(f"#{INTEGRATION_SELECT_ID}", Select)).value
820
+ self.query_one(f"#{PR_FEEDBACK_ROW_ID}").display = integration == PULL_REQUEST_INTEGRATION
821
+
822
+ def _update_finish_summary(self) -> None:
823
+ integration_value = cast(
824
+ "Select[str]", self.query_one(f"#{INTEGRATION_SELECT_ID}", Select)
825
+ ).value
826
+ integration = (
827
+ INTEGRATION_LABELS[integration_value]
828
+ if isinstance(integration_value, str)
829
+ else INTEGRATION_UNSELECTED
830
+ )
831
+ parts = (*(self._field_summary(field) for field in ORCHESTRATION_FIELDS), integration)
832
+ self.query_one("#wizard-summary", WizardDetailRow).update_value(" · ".join(parts))
833
+
834
+ def _field_summary(self, field: OrchestrationField) -> str:
835
+ try:
836
+ value = field.parse(self.query_one(f"#{field.input_id}", Input).value)
837
+ except OrchestrationFieldError:
838
+ return f"{field.summary} needs a value"
839
+ return f"{field.summary} {value} {field.unit}"
840
+
841
+ def action_cancel(self) -> None:
842
+ if self._writing_config:
843
+ return
844
+ self._stop_art_timer()
845
+ if self._config_path is None:
846
+ self.exit(None)
847
+ else:
848
+ self.exit(self._result())
849
+
850
+ def action_block_quit(self) -> None:
851
+ return
852
+
853
+ def action_scroll_body_up(self) -> None:
854
+ self.query_one("#wizard-body", InitWizardBody).action_page_up()
855
+
856
+ def action_scroll_body_down(self) -> None:
857
+ self.query_one("#wizard-body", InitWizardBody).action_page_down()
858
+
859
+ async def _load_model_catalogs(self) -> None:
860
+ for agent in self._project.agents:
861
+ self._agent_capabilities[agent] = self._detectors.agent_capabilities(agent, {})
862
+ try:
863
+ catalog = await self._detectors.model_catalog(agent, {})
864
+ except (OSError, ShikiruError, ValueError) as error:
865
+ self._catalog_errors[agent] = str(error) or type(error).__name__
866
+ continue
867
+ if catalog is None:
868
+ self._catalog_errors[agent] = LEGACY_CATALOG_DETAIL
869
+ elif catalog.error is not None:
870
+ self._catalog_errors[agent] = catalog.error
871
+ elif any(choice.offered for choice in catalog.models):
872
+ self._model_catalogs[agent] = catalog
873
+ else:
874
+ self._catalog_errors[agent] = "agent reported no offered model identifiers"
875
+ if not self._wizard_mounted():
876
+ return
877
+ self.query_one("#ladder-editor", LadderEditor).set_model_catalogs(
878
+ self._model_catalogs, self._catalog_errors
879
+ )
880
+ self.query_one("#ladder-editor", LadderEditor).set_agent_capabilities(
881
+ self._agent_capabilities
882
+ )
883
+ self.query_one("#catalog-discovery", Static).display = False
884
+ self._catalogs_loaded = True
885
+ self._update_pages()
886
+
887
+ async def _move(self, direction: int) -> None:
888
+ if (
889
+ not self.query_one("#wizard-frame", PopupFrame).visible
890
+ or self._config_path is not None
891
+ or self._writing_config
892
+ or self._finishing
893
+ ):
894
+ return
895
+ if direction > 0 and self._step == REPOSITORY_STEP_INDEX and not self._catalogs_loaded:
896
+ return
897
+ next_step = self._step + direction
898
+ if not 0 <= next_step < len(WIZARD_STEPS):
899
+ return
900
+ self._stop_art_timer()
901
+ if direction > 0 and self._step == AGENTS_STEP_INDEX:
902
+ try:
903
+ self._read_ladders()
904
+ except ConfigError as error:
905
+ self._show_step_error(AGENTS_STEP_INDEX, "ladder-input", error)
906
+ return
907
+ if direction > 0 and self._step == AGENT_SETTINGS_STEP_INDEX:
908
+ try:
909
+ self._read_agent_options()
910
+ except AgentSettingError as error:
911
+ self._show_step_error(AGENT_SETTINGS_STEP_INDEX, error.input_id, error)
912
+ return
913
+ focused = self.focused
914
+ if focused is not None and focused.id:
915
+ self._last_focus_by_step[self._step] = focused.id
916
+ self._error = ""
917
+ if direction > 0 and next_step == COW_STEP_INDEX:
918
+ await self._probe_cow()
919
+ self._step = next_step
920
+ self._update_pages()
921
+ self.call_after_refresh(self._focus_step)
922
+
923
+ def _focus_previous(self) -> bool:
924
+ previous_id = self._last_focus_by_step.get(self._step)
925
+ previous = self.query(f"#{previous_id}") if previous_id else None
926
+ if previous:
927
+ previous.first().focus()
928
+ return True
929
+ return False
930
+
931
+ def _focus_step(self) -> None:
932
+ if self._focus_previous():
933
+ return
934
+ if self._step in BUTTON_FOCUS_IDS:
935
+ self.query_one(BUTTON_FOCUS_IDS[self._step], Button).focus()
936
+ elif self._step == AGENTS_STEP_INDEX:
937
+ self.query_one("#ladder-scopes", RowList).focus()
938
+ elif self._step == TRACKERS_STEP_INDEX:
939
+ self._focus_candidate_list(TRACKERS_LIST_ID, TRACKERS_INPUT_ID)
940
+ elif self._step == COW_STEP_INDEX:
941
+ self._focus_candidate_list(CACHES_LIST_ID, CACHES_INPUT_ID)
942
+ elif self._step == ORCHESTRATION_STEP_INDEX:
943
+ self.query_one(f"#{MAX_PARALLEL.input_id}", Input).focus()
944
+ elif self._step == AGENT_SETTINGS_STEP_INDEX:
945
+ self._focus_first_agent_setting()
946
+ elif self._step == INTEGRATION_STEP_INDEX:
947
+ self.query_one(f"#{INTEGRATION_SELECT_ID}", Select).focus()
948
+
949
+ def _focus_first_agent_setting(self) -> None:
950
+ editors = self.query(".agent-setting-editor")
951
+ if editors:
952
+ editors.first().focus()
953
+
954
+ def _focus_candidate_list(self, list_id: str, input_id: str) -> None:
955
+ candidate_list = self.query_one(f"#{list_id}", CandidateRows)
956
+ if candidate_list.candidates:
957
+ candidate_list.focus()
958
+ else:
959
+ self.query_one(f"#{input_id}", Input).focus()
960
+
961
+ async def _probe_cow(self) -> None:
962
+ try:
963
+ self._cow_supported = await self._detectors.check_cow_support()
964
+ except (OSError, ShikiruError) as error:
965
+ self._error = f"CoW check failed: {error}"
966
+ self.query_one("#cow-result", WizardDetailRow).update_value(
967
+ Text(self._error, style=f"{STYLE_BOLD} {ALERT_COLOR}")
968
+ )
969
+
970
+ async def _finish(self) -> None:
971
+ if self._finishing:
972
+ return
973
+ try:
974
+ ladders = self._read_ladders()
975
+ except ConfigError as error:
976
+ self._show_step_error(AGENTS_STEP_INDEX, "ladder-input", error)
977
+ return
978
+ paths = self._read_both_path_lists()
979
+ if paths is None:
980
+ return
981
+ trackers, caches = paths
982
+ try:
983
+ integration = self._read_integration()
984
+ except ConfigError as error:
985
+ self._show_step_error(INTEGRATION_STEP_INDEX, INTEGRATION_SELECT_ID, error)
986
+ return
987
+ try:
988
+ orchestration = self._read_orchestration(integration)
989
+ except OrchestrationFieldError as error:
990
+ self._show_step_error(ORCHESTRATION_STEP_INDEX, error.input_id, error)
991
+ return
992
+ try:
993
+ agent_options = self._read_agent_options()
994
+ except AgentSettingError as error:
995
+ self._show_step_error(AGENT_SETTINGS_STEP_INDEX, error.input_id, error)
996
+ return
997
+ self._finishing = True
998
+ self.query_one("#wizard-finish", Button).disabled = True
999
+ try:
1000
+ self._writing_config = True
1001
+ config_path = await self._detectors.initialize_config(
1002
+ trackers=trackers,
1003
+ caches=caches,
1004
+ ladders=ladders.ladders,
1005
+ complexity=ladders.complexity,
1006
+ orchestration=orchestration,
1007
+ agent_options=agent_options,
1008
+ )
1009
+ self._config_path = config_path.resolve()
1010
+ self._writing_config = False
1011
+ self._doctor_report = await self._doctor.run()
1012
+ self._doctor_error = None
1013
+ self._error = ""
1014
+ except (OSError, ShikiruError, ValueError) as error:
1015
+ self._writing_config = False
1016
+ if self._config_path is None:
1017
+ self._show_error(str(error))
1018
+ else:
1019
+ self._doctor_error = f"Doctor could not complete: {error}"
1020
+ finally:
1021
+ self._finishing = False
1022
+ self._update_pages()
1023
+
1024
+ def _read_both_path_lists(self) -> tuple[tuple[str, ...], tuple[str, ...]] | None:
1025
+ try:
1026
+ trackers = self._read_paths(TRACKERS_INPUT_ID)
1027
+ except ConfigError as error:
1028
+ self._show_step_error(TRACKERS_STEP_INDEX, TRACKERS_INPUT_ID, error)
1029
+ return None
1030
+ try:
1031
+ caches = self._read_paths(CACHES_INPUT_ID)
1032
+ except ConfigError as error:
1033
+ self._show_step_error(COW_STEP_INDEX, CACHES_INPUT_ID, error)
1034
+ return None
1035
+ return trackers, caches
1036
+
1037
+ def _show_step_error(self, step: int, focus_id: str, error: ConfigError) -> None:
1038
+ self._step = step
1039
+ self._error = ""
1040
+ self._update_pages()
1041
+ if step == AGENTS_STEP_INDEX:
1042
+ self.query_one("#ladder-editor", LadderEditor).show_validation_error(str(error))
1043
+ else:
1044
+ self._show_error(str(error))
1045
+ if focus_id:
1046
+ self.call_after_refresh(lambda: self.query_one(f"#{focus_id}").focus())
1047
+
1048
+ def _result(self) -> InitWizardResult:
1049
+ if self._config_path is None:
1050
+ message = "wizard result requested before config was written"
1051
+ raise RuntimeError(message)
1052
+ return InitWizardResult(self._config_path, self._doctor_report, self._doctor_error)
1053
+
1054
+ def _read_ladders(self) -> LadderConfiguration:
1055
+ return self.query_one("#ladder-editor", LadderEditor).configuration()
1056
+
1057
+ def _read_paths(self, field: str) -> tuple[str, ...]:
1058
+ value = self.query_one(f"#{field}", Input).value
1059
+ if not value.strip():
1060
+ return ()
1061
+ entries = tuple(item.strip() for item in value.split(PATH_SEPARATOR))
1062
+ if any(not item for item in entries):
1063
+ message = "remove empty comma-separated entries"
1064
+ raise ConfigError(message)
1065
+ return entries
1066
+
1067
+ def _read_field(self, field: OrchestrationField) -> int | float:
1068
+ return field.parse(self.query_one(f"#{field.input_id}", Input).value)
1069
+
1070
+ def _read_orchestration(self, integration: str) -> OrchestrationChoices:
1071
+ return OrchestrationChoices(
1072
+ max_parallel=int(self._read_field(MAX_PARALLEL)),
1073
+ max_iterations=int(self._read_field(MAX_ITERATIONS)),
1074
+ integration=integration,
1075
+ push_interval_seconds=int(self._read_field(PUSH_INTERVAL)),
1076
+ max_quota_wait=int(self._read_field(MAX_QUOTA_WAIT)),
1077
+ stall=int(self._read_field(STALL_TIMEOUT)),
1078
+ tool=int(self._read_field(TOOL_TIMEOUT)),
1079
+ max_cpu_pct=int(self._read_field(MAX_CPU)),
1080
+ min_free_ram_gb=float(self._read_field(MIN_FREE_RAM)),
1081
+ pr_feedback=self._read_pr_feedback(),
1082
+ )
1083
+
1084
+ def _read_pr_feedback(self) -> str:
1085
+ value = cast("Select[str]", self.query_one(f"#{PR_FEEDBACK_SELECT_ID}", Select)).value
1086
+ return cast("str", value)
1087
+
1088
+ def _read_agent_options(self) -> dict[str, dict[str, object]]:
1089
+ options: dict[str, dict[str, object]] = {}
1090
+ for agent_index, offered in enumerate(self._project.agent_settings):
1091
+ values: dict[str, object] = {}
1092
+ for key, setting in enumerate(offered.settings):
1093
+ input_id = _agent_setting_id(agent_index, key)
1094
+ try:
1095
+ value = setting.parse(self._read_agent_setting(input_id, setting))
1096
+ except AgentSettingError as error:
1097
+ raise AgentSettingError(error.key, str(error), input_id) from error
1098
+ if value is not None:
1099
+ values[setting.key] = value
1100
+ rejection = self._detectors.agent_options_rejection(offered.agent, values)
1101
+ if rejection is not None:
1102
+ keys = [setting.key for setting in offered.settings]
1103
+ position = keys.index(rejection.key) if rejection.key in keys else None
1104
+ focus = "" if position is None else _agent_setting_id(agent_index, position)
1105
+ message = f"The {offered.agent} agent rejected its settings: {rejection.detail}"
1106
+ raise AgentSettingError(rejection.key or offered.agent, message, focus)
1107
+ options[offered.agent] = values
1108
+ return options
1109
+
1110
+ def _read_agent_setting(self, input_id: str, setting: AgentSetting) -> str:
1111
+ widget = self.query_one(f"#{input_id}")
1112
+ if setting.kind is SettingKind.CHOICE:
1113
+ value = cast("Select[str]", widget).value
1114
+ return value if isinstance(value, str) else ""
1115
+ return cast("Input", widget).value
1116
+
1117
+ def _read_integration(self) -> str:
1118
+ value = cast("Select[str]", self.query_one(f"#{INTEGRATION_SELECT_ID}", Select)).value
1119
+ if not isinstance(value, str) or value not in INTEGRATION_LABELS:
1120
+ message = "choose an integration mode"
1121
+ raise ConfigError(message)
1122
+ return value
1123
+
1124
+ def _show_error(self, message: str) -> None:
1125
+ self._error = message
1126
+ error = self.query_one("#wizard-error", Static)
1127
+ error.update(message)
1128
+ error.display = True
1129
+ self.query_one("#wizard-body", InitWizardBody).scroll_to_widget(
1130
+ error, animate=False, immediate=True
1131
+ )
1132
+
1133
+ def _wizard_mounted(self) -> bool:
1134
+ return len(self.query("#wizard-frame")) > 0
1135
+
1136
+ def _update_pages(self) -> None:
1137
+ self.query_one("#wizard-title", Static).update(_step_title(self._step))
1138
+ self.query_one("#wizard-step", Static).update(_step_counter(self._step))
1139
+ for index in range(len(WIZARD_STEPS)):
1140
+ self.query_one(f"#wizard-page-{index}").display = index == self._step
1141
+ self.query_one("#wizard-body", InitWizardBody).scroll_home(animate=False)
1142
+ back = self.query_one("#wizard-back", Button)
1143
+ back.display = self._config_path is None
1144
+ back.disabled = self._step == 0 or self._finishing
1145
+ next_button = self.query_one("#wizard-next", Button)
1146
+ next_button.display = self._step < FINISH_STEP_INDEX and self._config_path is None
1147
+ next_button.disabled = (
1148
+ (self._step == REPOSITORY_STEP_INDEX and not self._catalogs_loaded)
1149
+ or not self._frame_ready
1150
+ or self._finishing
1151
+ or self._writing_config
1152
+ )
1153
+ finish_button = self.query_one("#wizard-finish", Button)
1154
+ finish_button.display = self._step == FINISH_STEP_INDEX or self._config_path is not None
1155
+ finish_button.label = "Exit" if self._config_path is not None else "Run doctor"
1156
+ finish_button.disabled = self._finishing
1157
+ if self.focused is next_button and not next_button.display:
1158
+ finish_button.focus()
1159
+ cancel = self.query_one("#wizard-cancel", Button)
1160
+ cancel.display = self._config_path is None
1161
+ cancel.disabled = self._writing_config
1162
+ cow_result = self.query_one("#cow-result", WizardDetailRow)
1163
+ if self._cow_supported is not None:
1164
+ cow_result.update_value(self._cow_status())
1165
+ wizard_error = self.query_one("#wizard-error", Static)
1166
+ wizard_error.update(self._error if self._config_path is None else "")
1167
+ wizard_error.display = self._config_path is None and bool(self._error)
1168
+ self._update_finish_summary()
1169
+ self.query_one("#wizard-finish-before").display = self._config_path is None
1170
+ self.query_one("#wizard-finish-after").display = self._config_path is not None
1171
+ if self._config_path is None:
1172
+ self._update_doctor_report()
1173
+ else:
1174
+ self.call_after_refresh(self._update_doctor_report)
1175
+ self.call_after_refresh(self._update_navigation_hint)
1176
+
1177
+ def _update_doctor_report(self) -> None:
1178
+ report_widget = self.query_one("#doctor-report", Static)
1179
+ if self._config_path is None:
1180
+ report_widget.update("")
1181
+ self._doctor_report_width = None
1182
+ return
1183
+ width = report_widget.content_region.width
1184
+ if width < 1:
1185
+ return
1186
+ if width == self._doctor_report_width:
1187
+ self.call_after_refresh(self._update_navigation_hint)
1188
+ return
1189
+ self._doctor_report_width = width
1190
+ self._render_doctor_report(report_widget, width)
1191
+ self.call_after_refresh(self._settle_doctor_report_width)
1192
+
1193
+ def _settle_doctor_report_width(self) -> None:
1194
+ report_widget = self.query_one("#doctor-report", Static)
1195
+ width = report_widget.content_region.width
1196
+ if width > 0 and width != self._doctor_report_width:
1197
+ self._doctor_report_width = width
1198
+ self._render_doctor_report(report_widget, width)
1199
+ self.call_after_refresh(self._update_navigation_hint)
1200
+
1201
+ def _render_doctor_report(self, report_widget: Static, width: int) -> None:
1202
+ self.query_one("#doctor-config", WizardDetailRow).update_value(str(self._config_path))
1203
+ if self._doctor_report is not None:
1204
+ report_widget.update(self._doctor_renderer(self._doctor_report, width=width))
1205
+ else:
1206
+ detail = self._doctor_error or "Doctor did not return a report."
1207
+ report_widget.update(Text(detail, style=ALERT_COLOR))
1208
+
1209
+ def _cow_status(self) -> Text:
1210
+ status = Text()
1211
+ if self._cow_supported is None:
1212
+ status.append("checking this project filesystem", style=PRIMARY_TEXT_COLOR)
1213
+ elif self._cow_supported:
1214
+ status.append(
1215
+ "supported on this project filesystem", style=f"{STYLE_BOLD} {SUCCESS_COLOR}"
1216
+ )
1217
+ else:
1218
+ status.append(
1219
+ "unavailable · cache copies will be skipped",
1220
+ style=f"{STYLE_BOLD} {AMBER}",
1221
+ )
1222
+ return status
1223
+
1224
+ def _repository_page(self) -> ComposeResult:
1225
+ with Vertical(id="wizard-page-0", classes="wizard-page"):
1226
+ yield Static(
1227
+ "Read-only facts from the current checkout.",
1228
+ classes="wizard-description",
1229
+ markup=False,
1230
+ )
1231
+ yield _detail_row("Project", str(self._root))
1232
+ yield _detail_row("Base", self._project.base)
1233
+ yield _detail_row("Config target", str(self._config_target))
1234
+ yield _detail_row(
1235
+ "Checkout",
1236
+ _repository_checkout(self._project.base, clean=self._project.worktree_clean),
1237
+ widget_id=REPOSITORY_CHECKOUT_ID,
1238
+ )
1239
+ yield Static("Discovering supported agent model choices…", id="catalog-discovery")
1240
+
1241
+ def _trackers_page(self) -> ComposeResult:
1242
+ with Vertical(id="wizard-page-2", classes="wizard-page"):
1243
+ yield Static(
1244
+ "Tracker files start blank. Select project-relative Markdown candidates "
1245
+ "used as task trackers; an empty field sends no tracker files to tasks.",
1246
+ classes="wizard-description",
1247
+ markup=False,
1248
+ )
1249
+ yield Static(_section_heading("Detected tracker files"), classes="section-heading")
1250
+ yield CandidateRows(TRACKERS_LIST_ID, TRACKERS_INPUT_ID, self._project.trackers)
1251
+ with Horizontal(classes="palette-row wizard-wide-label"):
1252
+ yield Label("Tracker files")
1253
+ yield Input(id=TRACKERS_INPUT_ID)
1254
+
1255
+ def _caches_page(self) -> ComposeResult:
1256
+ with Vertical(id="wizard-page-3", classes="wizard-page"):
1257
+ yield Static(
1258
+ "Cache paths start blank and are registered for task worktrees. Select "
1259
+ "detected ignored directories to add; an empty field copies no caches. "
1260
+ "CoW is checked on this project filesystem.",
1261
+ classes="wizard-description",
1262
+ markup=False,
1263
+ )
1264
+ yield Static(_section_heading("Detected cache directories"), classes="section-heading")
1265
+ yield _detail_row("Copy-on-write", self._cow_status(), widget_id="cow-result")
1266
+ yield CandidateRows(CACHES_LIST_ID, CACHES_INPUT_ID, self._project.caches)
1267
+ with Horizontal(classes="palette-row wizard-wide-label"):
1268
+ yield Label("Cache paths")
1269
+ yield Input(id=CACHES_INPUT_ID)
1270
+
1271
+ def _orchestration_page(self) -> ComposeResult:
1272
+ with Vertical(id="wizard-page-4", classes="wizard-page"):
1273
+ yield Static(
1274
+ "Set how many tasks run at once and when new work may start. "
1275
+ "Each field starts at the project default.",
1276
+ classes="wizard-description",
1277
+ markup=False,
1278
+ )
1279
+ for field in ORCHESTRATION_FIELDS:
1280
+ yield self._orchestration_field(field)
1281
+
1282
+ def _orchestration_field(self, field: OrchestrationField) -> Vertical:
1283
+ return Vertical(
1284
+ Horizontal(
1285
+ Label(field.label),
1286
+ Input(
1287
+ value=str(field.default(self._orchestration_defaults)),
1288
+ id=field.input_id,
1289
+ ),
1290
+ Static(field.unit, classes="orchestration-unit", markup=False),
1291
+ classes="palette-row wizard-long-label",
1292
+ ),
1293
+ Static(field.explanation, classes="orchestration-help", markup=False),
1294
+ classes="orchestration-field",
1295
+ )
1296
+
1297
+ def _agent_settings_page(self) -> ComposeResult:
1298
+ with Vertical(id="wizard-page-5", classes="wizard-page"):
1299
+ yield Static(
1300
+ "Review the options each agent plugin offers; they are written to "
1301
+ ".shikiru/config.toml so you can change them later.",
1302
+ classes="wizard-description",
1303
+ markup=False,
1304
+ )
1305
+ for agent_index, offered in enumerate(self._project.agent_settings):
1306
+ yield from self._agent_settings_section(agent_index, offered)
1307
+
1308
+ def _agent_settings_section(self, agent_index: int, offered: AgentSettings) -> ComposeResult:
1309
+ yield Static(_section_heading(offered.agent), classes="section-heading", markup=False)
1310
+ if not offered.supported:
1311
+ yield Static(
1312
+ f"{offered.error}; the plugin's own defaults apply and nothing is written.",
1313
+ classes="orchestration-help",
1314
+ markup=False,
1315
+ )
1316
+ for key, setting in enumerate(offered.settings):
1317
+ yield self._agent_setting_field(agent_index, key, setting)
1318
+
1319
+ def _agent_setting_field(self, agent: int, key: int, setting: AgentSetting) -> Vertical:
1320
+ input_id = _agent_setting_id(agent, key)
1321
+ editor: Input | Select[str]
1322
+ if setting.kind is SettingKind.CHOICE:
1323
+ editor = Select(
1324
+ [(choice, choice) for choice in setting.choices],
1325
+ value=setting.display() if setting.default is not None else Select.BLANK,
1326
+ allow_blank=setting.optional,
1327
+ prompt=UNSET_CHOICE_LABEL,
1328
+ id=input_id,
1329
+ classes="agent-setting-editor",
1330
+ )
1331
+ else:
1332
+ editor = Input(value=setting.display(), id=input_id, classes="agent-setting-editor")
1333
+ return Vertical(
1334
+ Horizontal(
1335
+ Label(setting.label),
1336
+ editor,
1337
+ Static(setting.unit, classes="orchestration-unit", markup=False),
1338
+ classes="palette-row wizard-long-label",
1339
+ ),
1340
+ Static(setting.help, classes="orchestration-help", markup=False),
1341
+ classes="orchestration-field",
1342
+ )
1343
+
1344
+ def _integration_page(self) -> ComposeResult:
1345
+ with Vertical(id="wizard-page-6", classes="wizard-page"):
1346
+ yield Static(
1347
+ "Choose how completed work is integrated back into the repository.",
1348
+ classes="wizard-description",
1349
+ markup=False,
1350
+ )
1351
+ with Horizontal(classes="palette-row wizard-long-label"):
1352
+ yield Label("Integration mode")
1353
+ yield Select(
1354
+ INTEGRATION_OPTIONS,
1355
+ value=self._orchestration_defaults.integration,
1356
+ allow_blank=False,
1357
+ id=INTEGRATION_SELECT_ID,
1358
+ )
1359
+ yield _detail_row("Local merge", "merge a finished task branch locally")
1360
+ yield _detail_row(
1361
+ "Local squash and merge", "land a finished task as one commit on the base"
1362
+ )
1363
+ yield _detail_row("Pull request", "open a pull request for review")
1364
+ with Horizontal(id=PR_FEEDBACK_ROW_ID, classes="palette-row wizard-long-label"):
1365
+ yield Label("Pull request feedback")
1366
+ yield Select(
1367
+ PR_FEEDBACK_OPTIONS,
1368
+ value=self._orchestration_defaults.pr_feedback,
1369
+ allow_blank=False,
1370
+ id=PR_FEEDBACK_SELECT_ID,
1371
+ )
1372
+
1373
+ def _finish_page(self) -> ComposeResult:
1374
+ with Vertical(id="wizard-page-7", classes="wizard-page"):
1375
+ with Vertical(id="wizard-finish-before"):
1376
+ yield Static(
1377
+ "Finish writes .shikiru/config.toml, excludes .shikiru/ in Git, "
1378
+ "and runs Doctor.",
1379
+ id="wizard-finish-hint",
1380
+ classes="wizard-description",
1381
+ markup=False,
1382
+ )
1383
+ yield _detail_row(
1384
+ "Config", str(self._config_target), widget_id="wizard-config-target"
1385
+ )
1386
+ yield _detail_row("Summary", "", widget_id="wizard-summary")
1387
+ with Vertical(id="wizard-finish-after"):
1388
+ yield _detail_row("Config", str(self._config_target), widget_id="doctor-config")
1389
+ yield Static("", id="doctor-report", markup=False)