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.
- shikiru/__init__.py +1 -0
- shikiru/__main__.py +8 -0
- shikiru/agents/__init__.py +3 -0
- shikiru/agents/contracts.py +590 -0
- shikiru/agents/registry.py +87 -0
- shikiru/agents/sdk/__init__.py +17 -0
- shikiru/agents/sdk/reset_time.py +137 -0
- shikiru/agents/sdk/turn.py +335 -0
- shikiru/agents/settings.py +151 -0
- shikiru/agents/testing/__init__.py +26 -0
- shikiru/agents/testing/contract_suite.py +653 -0
- shikiru/agents/testing/resume.py +15 -0
- shikiru/bootstrap.py +1277 -0
- shikiru/cli/__init__.py +1 -0
- shikiru/cli/app.py +770 -0
- shikiru/cli/context.py +63 -0
- shikiru/cli/render.py +1475 -0
- shikiru/cli/report.py +156 -0
- shikiru/cli/session_prompt.py +63 -0
- shikiru/cli/view.py +131 -0
- shikiru/clock.py +41 -0
- shikiru/config/__init__.py +8 -0
- shikiru/config/defaults.toml +131 -0
- shikiru/config/loader.py +178 -0
- shikiru/config/settings.py +392 -0
- shikiru/dashboard/__init__.py +3 -0
- shikiru/dashboard/actions_list.py +799 -0
- shikiru/dashboard/activity.py +150 -0
- shikiru/dashboard/app.py +1765 -0
- shikiru/dashboard/blocker_evidence.py +122 -0
- shikiru/dashboard/blocker_rows.py +84 -0
- shikiru/dashboard/briefing_view.py +871 -0
- shikiru/dashboard/contract.py +1086 -0
- shikiru/dashboard/covered.py +32 -0
- shikiru/dashboard/detail.py +1209 -0
- shikiru/dashboard/detail_fold.py +244 -0
- shikiru/dashboard/detail_hint.py +68 -0
- shikiru/dashboard/details_list_view.py +150 -0
- shikiru/dashboard/diagnostics.py +80 -0
- shikiru/dashboard/diagnostics_sections.py +186 -0
- shikiru/dashboard/evidence_table.py +123 -0
- shikiru/dashboard/extra_details.py +214 -0
- shikiru/dashboard/fault_guidance.py +216 -0
- shikiru/dashboard/fold_bodies.py +276 -0
- shikiru/dashboard/help_keys.py +166 -0
- shikiru/dashboard/help_screen.py +204 -0
- shikiru/dashboard/interaction.py +17 -0
- shikiru/dashboard/ladder_editor.py +544 -0
- shikiru/dashboard/live_settings.py +62 -0
- shikiru/dashboard/motion.py +368 -0
- shikiru/dashboard/orchestration_fields.py +201 -0
- shikiru/dashboard/palette.py +1606 -0
- shikiru/dashboard/palette_app.py +89 -0
- shikiru/dashboard/palette_drafts.py +33 -0
- shikiru/dashboard/resolution.py +573 -0
- shikiru/dashboard/resolution_view.py +302 -0
- shikiru/dashboard/row_list.py +256 -0
- shikiru/dashboard/section_rows.py +173 -0
- shikiru/dashboard/services.py +665 -0
- shikiru/dashboard/session_history.py +402 -0
- shikiru/dashboard/shikiru_init_ascii_art.txt +40 -0
- shikiru/dashboard/task_picker.py +358 -0
- shikiru/dashboard/tick.py +59 -0
- shikiru/dashboard/timeline_filter.py +129 -0
- shikiru/dashboard/timeline_section.py +385 -0
- shikiru/dashboard/usage_body.py +269 -0
- shikiru/dashboard/views.py +2314 -0
- shikiru/dashboard/wizard.py +1389 -0
- shikiru/domain/__init__.py +1 -0
- shikiru/domain/acceptance.py +130 -0
- shikiru/domain/activity.py +28 -0
- shikiru/domain/attempts.py +333 -0
- shikiru/domain/briefing.py +257 -0
- shikiru/domain/commands.py +219 -0
- shikiru/domain/decisions.py +231 -0
- shikiru/domain/docker.py +262 -0
- shikiru/domain/engine_uptime.py +87 -0
- shikiru/domain/escalation.py +194 -0
- shikiru/domain/escalation_evidence.py +186 -0
- shikiru/domain/escalation_panel.py +144 -0
- shikiru/domain/events.py +71 -0
- shikiru/domain/evidence_export.py +89 -0
- shikiru/domain/faults.py +86 -0
- shikiru/domain/feedback.py +180 -0
- shikiru/domain/feedback_rounds.py +172 -0
- shikiru/domain/fencing.py +31 -0
- shikiru/domain/ladder.py +102 -0
- shikiru/domain/ladder_text.py +44 -0
- shikiru/domain/levels.py +44 -0
- shikiru/domain/motion.py +199 -0
- shikiru/domain/packet_evidence.py +377 -0
- shikiru/domain/pushes.py +47 -0
- shikiru/domain/quota_blocks.py +44 -0
- shikiru/domain/quota_waits.py +219 -0
- shikiru/domain/recovery.py +194 -0
- shikiru/domain/reports.py +498 -0
- shikiru/domain/results.py +217 -0
- shikiru/domain/runs.py +85 -0
- shikiru/domain/scheduling.py +231 -0
- shikiru/domain/status_tasks.py +40 -0
- shikiru/domain/steps.py +325 -0
- shikiru/domain/tasks.py +174 -0
- shikiru/domain/timeline.py +208 -0
- shikiru/domain/timeline_phrases.py +271 -0
- shikiru/domain/versions.py +18 -0
- shikiru/domain/workflow.py +372 -0
- shikiru/engine/__init__.py +0 -0
- shikiru/engine/acceptance.py +90 -0
- shikiru/engine/agents.py +61 -0
- shikiru/engine/cosmetic_decorator.py +99 -0
- shikiru/engine/daemon.py +409 -0
- shikiru/engine/dependency.py +37 -0
- shikiru/engine/escalation_filing.py +110 -0
- shikiru/engine/escalations.py +401 -0
- shikiru/engine/events.py +285 -0
- shikiru/engine/evidence.py +81 -0
- shikiru/engine/fault_reporter.py +144 -0
- shikiru/engine/feedback.py +577 -0
- shikiru/engine/feedback_audit.py +80 -0
- shikiru/engine/feedback_intake.py +95 -0
- shikiru/engine/feedback_snapshot.py +148 -0
- shikiru/engine/flow.py +87 -0
- shikiru/engine/handlers/__init__.py +33 -0
- shikiru/engine/handlers/base.py +148 -0
- shikiru/engine/handlers/engine.py +24 -0
- shikiru/engine/handlers/escalations.py +174 -0
- shikiru/engine/handlers/operator.py +81 -0
- shikiru/engine/handlers/tasks.py +237 -0
- shikiru/engine/history.py +128 -0
- shikiru/engine/integration.py +1234 -0
- shikiru/engine/integration_turns.py +337 -0
- shikiru/engine/layout_guard.py +362 -0
- shikiru/engine/lifecycle.py +60 -0
- shikiru/engine/monitor.py +317 -0
- shikiru/engine/notification_delivery.py +124 -0
- shikiru/engine/notification_feed.py +57 -0
- shikiru/engine/notifier.py +91 -0
- shikiru/engine/pull_request_closing.py +117 -0
- shikiru/engine/push_service.py +177 -0
- shikiru/engine/quota_probe_threads.py +79 -0
- shikiru/engine/quota_reconciler.py +210 -0
- shikiru/engine/quota_schedule.py +24 -0
- shikiru/engine/quota_waits.py +102 -0
- shikiru/engine/reconcile.py +91 -0
- shikiru/engine/recovery.py +284 -0
- shikiru/engine/retention.py +102 -0
- shikiru/engine/root_preparation.py +86 -0
- shikiru/engine/scheduler.py +320 -0
- shikiru/engine/session_guard.py +46 -0
- shikiru/engine/sessions.py +95 -0
- shikiru/engine/signalled.py +75 -0
- shikiru/engine/step_runner.py +140 -0
- shikiru/engine/supervisor.py +101 -0
- shikiru/engine/takeover.py +223 -0
- shikiru/engine/task_facts.py +46 -0
- shikiru/engine/tick_guard.py +95 -0
- shikiru/engine/titles.py +297 -0
- shikiru/engine/transitions.py +79 -0
- shikiru/engine/turn_context.py +206 -0
- shikiru/engine/turn_execution.py +30 -0
- shikiru/engine/turn_loop.py +230 -0
- shikiru/engine/turn_processes.py +79 -0
- shikiru/engine/turns.py +457 -0
- shikiru/errors.py +144 -0
- shikiru/helper/__init__.py +3 -0
- shikiru/helper/helper.py +182 -0
- shikiru/helper/models.py +11 -0
- shikiru/mcp/__init__.py +1 -0
- shikiru/mcp/server.py +196 -0
- shikiru/operator/__init__.py +33 -0
- shikiru/operator/attachment.py +24 -0
- shikiru/operator/commands.py +319 -0
- shikiru/operator/detectors.py +374 -0
- shikiru/operator/doctor.py +351 -0
- shikiru/operator/events.py +57 -0
- shikiru/operator/evidence_export.py +498 -0
- shikiru/operator/evidence_writer.py +78 -0
- shikiru/operator/lifecycle.py +294 -0
- shikiru/operator/plugin_checks.py +194 -0
- shikiru/operator/probe.py +287 -0
- shikiru/operator/queries.py +1601 -0
- shikiru/operator/services.py +36 -0
- shikiru/operator/sessions.py +378 -0
- shikiru/operator/strip_views.py +162 -0
- shikiru/operator/timeline.py +416 -0
- shikiru/operator/toml_text.py +62 -0
- shikiru/palette.py +18 -0
- shikiru/paths.py +91 -0
- shikiru/plugins/__init__.py +0 -0
- shikiru/plugins/agents/__init__.py +1 -0
- shikiru/plugins/agents/claude/__init__.py +5 -0
- shikiru/plugins/agents/claude/agent.py +251 -0
- shikiru/plugins/agents/claude/context.py +116 -0
- shikiru/plugins/agents/claude/headless.py +92 -0
- shikiru/plugins/agents/claude/hook.py +57 -0
- shikiru/plugins/agents/claude/model_catalog.py +105 -0
- shikiru/plugins/agents/claude/options.py +62 -0
- shikiru/plugins/agents/claude/turn.py +1018 -0
- shikiru/plugins/agents/codex/__init__.py +5 -0
- shikiru/plugins/agents/codex/agent.py +486 -0
- shikiru/plugins/agents/codex/headless.py +166 -0
- shikiru/plugins/agents/codex/home.py +14 -0
- shikiru/plugins/agents/codex/model_catalog.py +128 -0
- shikiru/plugins/agents/codex/options.py +94 -0
- shikiru/plugins/agents/codex/quota.py +195 -0
- shikiru/plugins/agents/codex/sandbox.py +31 -0
- shikiru/plugins/agents/codex/sandbox_probe.py +97 -0
- shikiru/plugins/agents/codex/turn.py +767 -0
- shikiru/plugins/agents/codex/usage.py +106 -0
- shikiru/plugins/agents/shared_settings.py +62 -0
- shikiru/plugins/widgets/__init__.py +3 -0
- shikiru/plugins/widgets/dialogue.py +144 -0
- shikiru/plugins/widgets/disk.py +123 -0
- shikiru/plugins/widgets/docker.py +225 -0
- shikiru/plugins/widgets/escalations.py +402 -0
- shikiru/plugins/widgets/faults.py +204 -0
- shikiru/plugins/widgets/header.py +321 -0
- shikiru/plugins/widgets/queue.py +1140 -0
- shikiru/plugins/widgets/queue_hint.py +93 -0
- shikiru/plugins/widgets/resources.py +105 -0
- shikiru/plugins/widgets/strips.py +82 -0
- shikiru/plugins/widgets/usage.py +176 -0
- shikiru/private.py +94 -0
- shikiru/process.py +535 -0
- shikiru/prompts/__init__.py +3 -0
- shikiru/prompts/renderer.py +85 -0
- shikiru/prompts/templates/_base.md.j2 +17 -0
- shikiru/prompts/templates/_macros.md.j2 +61 -0
- shikiru/prompts/templates/conflicts.md.j2 +22 -0
- shikiru/prompts/templates/consult.md.j2 +5 -0
- shikiru/prompts/templates/continue.md.j2 +4 -0
- shikiru/prompts/templates/feedback_analysis.md.j2 +29 -0
- shikiru/prompts/templates/feedback_rework.md.j2 +33 -0
- shikiru/prompts/templates/feedback_verify.md.j2 +33 -0
- shikiru/prompts/templates/handoff.md.j2 +23 -0
- shikiru/prompts/templates/implement.md.j2 +13 -0
- shikiru/prompts/templates/plan.md.j2 +30 -0
- shikiru/prompts/templates/rework.md.j2 +13 -0
- shikiru/prompts/templates/triage.md.j2 +20 -0
- shikiru/prompts/templates/verify.md.j2 +29 -0
- shikiru/store/__init__.py +1 -0
- shikiru/store/commands.py +242 -0
- shikiru/store/database.py +297 -0
- shikiru/store/escalations.py +240 -0
- shikiru/store/events.py +213 -0
- shikiru/store/feedback.py +266 -0
- shikiru/store/migrations/001_initial.sql +63 -0
- shikiru/store/migrations/002_headless_turns.sql +31 -0
- shikiru/store/migrations/003_drop_command_handled_at.sql +1 -0
- shikiru/store/migrations/004_repo_base_ref.sql +3 -0
- shikiru/store/migrations/005_complexity_explicit.sql +3 -0
- shikiru/store/migrations/006_events_kind_index.sql +1 -0
- shikiru/store/migrations/007_notification_cursor.sql +1 -0
- shikiru/store/migrations/008_reported_usage.sql +1 -0
- shikiru/store/migrations/009_billing_declarations.sql +2 -0
- shikiru/store/migrations/010_canonical_quota_windows.sql +3 -0
- shikiru/store/migrations/012_drop_task_quota_evidence.sql +2 -0
- shikiru/store/migrations/013_push_targets.sql +7 -0
- shikiru/store/migrations/014_agent_block_time.sql +6 -0
- shikiru/store/migrations/015_repair_orphan_agent_blocks.sql +3 -0
- shikiru/store/migrations/016_feedback_rounds.sql +29 -0
- shikiru/store/migrations/017_feedback_round_comments.sql +5 -0
- shikiru/store/migrations/__init__.py +1 -0
- shikiru/store/notification_cursor.py +29 -0
- shikiru/store/pushes.py +117 -0
- shikiru/store/quotas.py +153 -0
- shikiru/store/reader.py +310 -0
- shikiru/store/retention.py +92 -0
- shikiru/store/runs.py +227 -0
- shikiru/store/tasks.py +949 -0
- shikiru/telemetry/__init__.py +1 -0
- shikiru/telemetry/disk.py +167 -0
- shikiru/telemetry/docker.py +164 -0
- shikiru/telemetry/resources.py +296 -0
- shikiru/tmux/__init__.py +3 -0
- shikiru/tmux/client.py +637 -0
- shikiru/tmux/decorator.py +225 -0
- shikiru/tmux/layout.py +200 -0
- shikiru/tmux/session.py +381 -0
- shikiru/ui/__init__.py +9 -0
- shikiru/ui/age.py +45 -0
- shikiru/ui/bubbles.py +41 -0
- shikiru/ui/compact.py +1209 -0
- shikiru/ui/console.py +49 -0
- shikiru/ui/fold.py +50 -0
- shikiru/ui/gated_static.py +117 -0
- shikiru/ui/gauge.py +40 -0
- shikiru/ui/glyphs.py +109 -0
- shikiru/ui/keys.py +57 -0
- shikiru/ui/live_interval.py +42 -0
- shikiru/ui/popup.py +311 -0
- shikiru/ui/quota_status.py +26 -0
- shikiru/ui/recent_window.py +78 -0
- shikiru/ui/screen.py +57 -0
- shikiru/ui/section.py +193 -0
- shikiru/ui/session.py +65 -0
- shikiru/ui/size.py +17 -0
- shikiru/ui/splash.py +42 -0
- shikiru/ui/status.py +47 -0
- shikiru/ui/status_strip.py +149 -0
- shikiru/ui/stream.py +152 -0
- shikiru/ui/strip.py +332 -0
- shikiru/ui/theme.py +83 -0
- shikiru/ui/tokens.py +51 -0
- shikiru/view_log.py +126 -0
- shikiru/workspace/__init__.py +3 -0
- shikiru/workspace/base_refs.py +160 -0
- shikiru/workspace/caches.py +136 -0
- shikiru/workspace/facts.py +118 -0
- shikiru/workspace/git.py +601 -0
- shikiru/workspace/github.py +744 -0
- shikiru/workspace/messages.py +107 -0
- shikiru/workspace/reports.py +145 -0
- shikiru/workspace/roots.py +180 -0
- shikiru_cli-0.2.0.dist-info/METADATA +428 -0
- shikiru_cli-0.2.0.dist-info/RECORD +319 -0
- shikiru_cli-0.2.0.dist-info/WHEEL +4 -0
- shikiru_cli-0.2.0.dist-info/entry_points.txt +18 -0
- 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
|