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,89 @@
|
|
|
1
|
+
"""The standalone tmux popup that shows the Actions list for the pane it was opened from."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from textual.widgets import Static
|
|
8
|
+
|
|
9
|
+
from shikiru.dashboard.actions_list import ActionsListScreen
|
|
10
|
+
from shikiru.dashboard.contract import PaletteRequest
|
|
11
|
+
from shikiru.dashboard.covered import CoveredScreens
|
|
12
|
+
from shikiru.dashboard.palette import PaletteScreen
|
|
13
|
+
from shikiru.dashboard.palette_drafts import PaletteDrafts
|
|
14
|
+
from shikiru.ui.theme import install_theme
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from textual.app import ComposeResult
|
|
18
|
+
from textual.screen import ModalScreen
|
|
19
|
+
|
|
20
|
+
from shikiru.dashboard.contract import OnChangeRegistration, ResolutionScreenFactory
|
|
21
|
+
from shikiru.dashboard.palette import PaneSelector, StartEngine
|
|
22
|
+
from shikiru.operator.commands import OperatorCommands
|
|
23
|
+
from shikiru.operator.evidence_export import TaskEvidenceExporter
|
|
24
|
+
from shikiru.operator.queries import OperatorQueries
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PaletteApp(CoveredScreens):
|
|
28
|
+
"""Run the Actions list without dashboard routes or Extensions in a tmux popup."""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
commands: OperatorCommands,
|
|
33
|
+
queries: OperatorQueries,
|
|
34
|
+
pane_selector: PaneSelector,
|
|
35
|
+
*,
|
|
36
|
+
on_change: OnChangeRegistration,
|
|
37
|
+
action_confirmation_seconds: float,
|
|
38
|
+
engine_state_refresh_hz: int | None = None,
|
|
39
|
+
start_engine: StartEngine | None = None,
|
|
40
|
+
initial_context: PaletteRequest | None = None,
|
|
41
|
+
evidence_exporter: TaskEvidenceExporter | None = None,
|
|
42
|
+
resolution_screen: ResolutionScreenFactory | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Retain the services used by the list and its popups."""
|
|
45
|
+
super().__init__()
|
|
46
|
+
install_theme(self)
|
|
47
|
+
self._commands = commands
|
|
48
|
+
self._queries = queries
|
|
49
|
+
self._pane_selector = pane_selector
|
|
50
|
+
self._on_change = on_change
|
|
51
|
+
self._action_confirmation_seconds = action_confirmation_seconds
|
|
52
|
+
self._engine_state_refresh_hz = engine_state_refresh_hz
|
|
53
|
+
self._start_engine = start_engine
|
|
54
|
+
self._initial_context = initial_context or PaletteRequest()
|
|
55
|
+
self._evidence_exporter = evidence_exporter
|
|
56
|
+
self._resolution_screen = resolution_screen
|
|
57
|
+
self._drafts = PaletteDrafts()
|
|
58
|
+
|
|
59
|
+
def compose(self) -> ComposeResult:
|
|
60
|
+
"""Keep a neutral base screen beneath the modal list."""
|
|
61
|
+
yield Static()
|
|
62
|
+
|
|
63
|
+
async def on_mount(self) -> None:
|
|
64
|
+
"""Open the Actions list and exit with it."""
|
|
65
|
+
self.push_screen(
|
|
66
|
+
ActionsListScreen(
|
|
67
|
+
self._queries,
|
|
68
|
+
self._popup,
|
|
69
|
+
self._initial_context,
|
|
70
|
+
on_change=self._on_change,
|
|
71
|
+
action_confirmation_seconds=self._action_confirmation_seconds,
|
|
72
|
+
engine_state_refresh_hz=self._engine_state_refresh_hz,
|
|
73
|
+
resolution_screen=self._resolution_screen,
|
|
74
|
+
),
|
|
75
|
+
lambda _: self.exit(),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def _popup(self, request: PaletteRequest) -> ModalScreen[str | None]:
|
|
79
|
+
return PaletteScreen(
|
|
80
|
+
self._commands,
|
|
81
|
+
self._queries,
|
|
82
|
+
self._pane_selector,
|
|
83
|
+
on_change=self._on_change,
|
|
84
|
+
action_confirmation_seconds=self._action_confirmation_seconds,
|
|
85
|
+
initial_context=self._drafts.attach(request),
|
|
86
|
+
start_engine=self._start_engine,
|
|
87
|
+
evidence_exporter=self._evidence_exporter,
|
|
88
|
+
remember_contextual_draft=self._drafts.remember,
|
|
89
|
+
)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""In-memory contextual form drafts kept while a palette caller is visible."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from shikiru.dashboard.contract import PaletteDraft, PaletteRequest
|
|
10
|
+
|
|
11
|
+
MAX_CONTEXTUAL_PALETTE_DRAFTS = 16
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PaletteDrafts:
|
|
15
|
+
"""Remember the latest draft per task, action and role, dropping the oldest beyond a bound."""
|
|
16
|
+
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
self._drafts: dict[tuple[str, str, str | None], PaletteDraft] = {}
|
|
19
|
+
|
|
20
|
+
def remember(self, draft: PaletteDraft) -> None:
|
|
21
|
+
"""Keep `draft` as the latest for its task, action and role."""
|
|
22
|
+
key = (draft.task_id, draft.action, draft.role)
|
|
23
|
+
self._drafts.pop(key, None)
|
|
24
|
+
self._drafts[key] = draft
|
|
25
|
+
if len(self._drafts) > MAX_CONTEXTUAL_PALETTE_DRAFTS:
|
|
26
|
+
self._drafts.pop(next(iter(self._drafts)))
|
|
27
|
+
|
|
28
|
+
def attach(self, request: PaletteRequest) -> PaletteRequest:
|
|
29
|
+
"""Return `request` carrying the drafts remembered for its task."""
|
|
30
|
+
drafts = tuple(
|
|
31
|
+
draft for (task_id, _, _), draft in self._drafts.items() if task_id == request.task_id
|
|
32
|
+
)
|
|
33
|
+
return replace(request, drafts=drafts) if drafts else request
|
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
"""The escalation resolution screen: one concrete decision, a choice list, one send."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, ClassVar, cast
|
|
6
|
+
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
from textual import on
|
|
9
|
+
from textual.binding import Binding
|
|
10
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
11
|
+
from textual.message import Message
|
|
12
|
+
from textual.screen import ModalScreen
|
|
13
|
+
from textual.widgets import Button, Label, Rule, Static, TextArea
|
|
14
|
+
|
|
15
|
+
from shikiru.dashboard.extra_details import ExtraDetailsScreen
|
|
16
|
+
from shikiru.dashboard.resolution_view import (
|
|
17
|
+
EXTRA_DETAILS_KEY,
|
|
18
|
+
ResolutionLayout,
|
|
19
|
+
choice_renderable,
|
|
20
|
+
choose_renderable,
|
|
21
|
+
decision_renderable,
|
|
22
|
+
layout_for,
|
|
23
|
+
resolution_title,
|
|
24
|
+
starred_indexes,
|
|
25
|
+
tried_renderable,
|
|
26
|
+
)
|
|
27
|
+
from shikiru.domain.commands import ResolutionKind, command_rejection_cause
|
|
28
|
+
from shikiru.domain.escalation_evidence import parse_escalation_packet
|
|
29
|
+
from shikiru.domain.escalation_panel import ChoiceKind, build_resolution_panel
|
|
30
|
+
from shikiru.errors import ShikiruError
|
|
31
|
+
from shikiru.operator.commands import AckStatus
|
|
32
|
+
from shikiru.ui.age import format_short_age
|
|
33
|
+
from shikiru.ui.fold import FOLD_TOGGLE_KEY, FoldLine
|
|
34
|
+
from shikiru.ui.popup import (
|
|
35
|
+
POPUP_BODY_CLASS,
|
|
36
|
+
POPUP_CLOSE_CLASS,
|
|
37
|
+
POPUP_CSS,
|
|
38
|
+
POPUP_EDIT_CLASS,
|
|
39
|
+
POPUP_FRAME_CLASS,
|
|
40
|
+
POPUP_HEADER_CLASS,
|
|
41
|
+
POPUP_RULE_CLASS,
|
|
42
|
+
POPUP_TITLE_CLASS,
|
|
43
|
+
PopupFrame,
|
|
44
|
+
popup_close_label,
|
|
45
|
+
prepare_popup,
|
|
46
|
+
)
|
|
47
|
+
from shikiru.ui.tokens import AMBER, DUSTY_ROSE
|
|
48
|
+
|
|
49
|
+
if TYPE_CHECKING:
|
|
50
|
+
from textual import events
|
|
51
|
+
from textual.app import App, ComposeResult
|
|
52
|
+
from textual.binding import BindingType
|
|
53
|
+
from textual.worker import Worker
|
|
54
|
+
|
|
55
|
+
from shikiru.clock import Clock
|
|
56
|
+
from shikiru.dashboard.contract import OnChangeRegistration
|
|
57
|
+
from shikiru.domain.escalation_panel import PanelChoice, ResolutionPanel
|
|
58
|
+
from shikiru.operator.commands import OperatorCommands
|
|
59
|
+
from shikiru.operator.queries import OperatorQueries
|
|
60
|
+
|
|
61
|
+
RESOLUTION_CLOSE_BUTTON = "resolution-close"
|
|
62
|
+
_REFRESH_GROUP = "resolution-refresh"
|
|
63
|
+
_SEND_GROUP = "resolution-send"
|
|
64
|
+
_SEND_KEY = "ctrl+s"
|
|
65
|
+
_LEAVE_KEY = "escape"
|
|
66
|
+
_ACTION_MOVE = "move"
|
|
67
|
+
_ACTION_ACTIVATE = "activate"
|
|
68
|
+
_ACTION_TOGGLE_TRIED = "toggle_tried"
|
|
69
|
+
_ACTION_EXTRA_DETAILS = "extra_details"
|
|
70
|
+
_SELECTION_ACTIONS = frozenset({_ACTION_MOVE, _ACTION_ACTIVATE, _ACTION_TOGGLE_TRIED})
|
|
71
|
+
_PREFILL_SEPARATOR = " \N{EM DASH} "
|
|
72
|
+
_SUBMIT_KEY = "enter"
|
|
73
|
+
_MOVE_STEPS = {"up": -1, "down": 1}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ResolutionDrafts:
|
|
77
|
+
"""The typed answers kept per task and escalation while the dashboard runs."""
|
|
78
|
+
|
|
79
|
+
def __init__(self) -> None:
|
|
80
|
+
"""Start without drafts."""
|
|
81
|
+
self._texts: dict[tuple[str, str], str] = {}
|
|
82
|
+
|
|
83
|
+
def get(self, task_id: str, escalation_id: str) -> str:
|
|
84
|
+
"""Return the remembered answer text, empty when none."""
|
|
85
|
+
return self._texts.get((task_id, escalation_id), "")
|
|
86
|
+
|
|
87
|
+
def remember(self, task_id: str, escalation_id: str, text: str) -> None:
|
|
88
|
+
"""Keep `text` as the answer draft of one escalation."""
|
|
89
|
+
self._texts[(task_id, escalation_id)] = text
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class _ChoiceList(Vertical, can_focus=True):
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class _ChoiceRow(Static):
|
|
97
|
+
class Chosen(Message):
|
|
98
|
+
def __init__(self, index: int) -> None:
|
|
99
|
+
super().__init__()
|
|
100
|
+
self.index = index
|
|
101
|
+
|
|
102
|
+
def __init__(self, index: int) -> None:
|
|
103
|
+
super().__init__("", classes="resolution-choice")
|
|
104
|
+
self._index = index
|
|
105
|
+
|
|
106
|
+
def on_click(self, event: events.Click) -> None:
|
|
107
|
+
event.stop()
|
|
108
|
+
self.post_message(self.Chosen(self._index))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class _TriedSection(FoldLine):
|
|
112
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
113
|
+
Binding("up", "back_to_choices", "Back to choices", show=False),
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
class Left(Message):
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
def action_back_to_choices(self) -> None:
|
|
120
|
+
self.post_message(self.Left())
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class _AnswerBox(TextArea):
|
|
124
|
+
class Submitted(Message):
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
class Exited(Message):
|
|
128
|
+
pass
|
|
129
|
+
|
|
130
|
+
def on_key(self, event: events.Key) -> None:
|
|
131
|
+
if event.key == _SEND_KEY:
|
|
132
|
+
event.stop()
|
|
133
|
+
event.prevent_default()
|
|
134
|
+
self.post_message(self.Submitted())
|
|
135
|
+
elif event.key == _LEAVE_KEY:
|
|
136
|
+
event.stop()
|
|
137
|
+
event.prevent_default()
|
|
138
|
+
self.post_message(self.Exited())
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class ResolutionScreen(ModalScreen[bool]):
|
|
142
|
+
"""Resolve one escalation from a choice list; dismisses `True` once a resolution is queued."""
|
|
143
|
+
|
|
144
|
+
AUTO_FOCUS = ""
|
|
145
|
+
BINDINGS: ClassVar[list[Binding]] = [
|
|
146
|
+
Binding("escape", "close", "Close"),
|
|
147
|
+
Binding("up", f"{_ACTION_MOVE}(-1)", "Previous choice", show=False, priority=True),
|
|
148
|
+
Binding("down", f"{_ACTION_MOVE}(1)", "Next choice", show=False, priority=True),
|
|
149
|
+
Binding(_SUBMIT_KEY, _ACTION_ACTIVATE, "Send", show=False),
|
|
150
|
+
Binding(FOLD_TOGGLE_KEY, _ACTION_TOGGLE_TRIED, "Details", show=False),
|
|
151
|
+
Binding(EXTRA_DETAILS_KEY, _ACTION_EXTRA_DETAILS, "Extra details", show=False),
|
|
152
|
+
]
|
|
153
|
+
DEFAULT_CSS = (
|
|
154
|
+
POPUP_CSS
|
|
155
|
+
+ """
|
|
156
|
+
#resolution-frame.compact { margin: 0 1; padding: 0 1; }
|
|
157
|
+
#resolution-title { text-wrap: nowrap; text-overflow: ellipsis; }
|
|
158
|
+
#resolution-notice, #resolution-decision, #resolution-choose, #resolution-tried {
|
|
159
|
+
height: auto;
|
|
160
|
+
}
|
|
161
|
+
#resolution-tried:focus { background: $selected-surface; background-tint: transparent; }
|
|
162
|
+
#resolution-choices { height: auto; }
|
|
163
|
+
#resolution-choices:focus { background-tint: transparent; }
|
|
164
|
+
.resolution-choice { height: auto; width: 100%; }
|
|
165
|
+
.resolution-choice.-selected { background: $selected-surface; }
|
|
166
|
+
#resolution-answer { margin: 0 0 0 3; }
|
|
167
|
+
"""
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def __init__(
|
|
171
|
+
self,
|
|
172
|
+
commands: OperatorCommands,
|
|
173
|
+
queries: OperatorQueries,
|
|
174
|
+
*,
|
|
175
|
+
clock: Clock,
|
|
176
|
+
on_change: OnChangeRegistration,
|
|
177
|
+
drafts: ResolutionDrafts,
|
|
178
|
+
escalation_id: str,
|
|
179
|
+
) -> None:
|
|
180
|
+
"""Hold the services for one escalation; nothing is dispatched until a choice is sent."""
|
|
181
|
+
super().__init__()
|
|
182
|
+
prepare_popup(self)
|
|
183
|
+
self._commands = commands
|
|
184
|
+
self._queries = queries
|
|
185
|
+
self._clock = clock
|
|
186
|
+
self._on_change = on_change
|
|
187
|
+
self._drafts = drafts
|
|
188
|
+
self._escalation_id = escalation_id
|
|
189
|
+
self._packet_text = ""
|
|
190
|
+
self._panel = self._load()
|
|
191
|
+
self._selected = self._panel.preselected if self._panel is not None else 0
|
|
192
|
+
self._answer_for: int | None = None
|
|
193
|
+
self._tried_open = False
|
|
194
|
+
self._cancel_armed = False
|
|
195
|
+
self._sending = False
|
|
196
|
+
self._send_error: str | None = None
|
|
197
|
+
self._change_worker: Worker[None] | None = None
|
|
198
|
+
if self._draft():
|
|
199
|
+
self._answer_for = self._selected = self._own_answer_index()
|
|
200
|
+
|
|
201
|
+
def compose(self) -> ComposeResult:
|
|
202
|
+
"""Lay out the popup frame; contents are filled on mount."""
|
|
203
|
+
panel = self._panel
|
|
204
|
+
with PopupFrame(widget_id="resolution-frame", classes=POPUP_FRAME_CLASS):
|
|
205
|
+
with Horizontal(classes=POPUP_HEADER_CLASS):
|
|
206
|
+
yield Label(
|
|
207
|
+
resolution_title(self._escalation_id, panel, ResolutionLayout.WIDE),
|
|
208
|
+
id="resolution-title",
|
|
209
|
+
classes=POPUP_TITLE_CLASS,
|
|
210
|
+
)
|
|
211
|
+
yield Button(
|
|
212
|
+
popup_close_label(), id=RESOLUTION_CLOSE_BUTTON, classes=POPUP_CLOSE_CLASS
|
|
213
|
+
)
|
|
214
|
+
yield Rule(classes=POPUP_RULE_CLASS)
|
|
215
|
+
with VerticalScroll(id="resolution-body", classes=POPUP_BODY_CLASS):
|
|
216
|
+
yield Static("", id="resolution-notice", markup=False)
|
|
217
|
+
if panel is not None:
|
|
218
|
+
yield Static("", id="resolution-decision", markup=False)
|
|
219
|
+
yield Static("", id="resolution-choose", markup=False)
|
|
220
|
+
with _ChoiceList(id="resolution-choices"):
|
|
221
|
+
for index, choice in enumerate(panel.choices):
|
|
222
|
+
yield _ChoiceRow(index)
|
|
223
|
+
if choice.kind is ChoiceKind.OWN_ANSWER:
|
|
224
|
+
yield _AnswerBox(
|
|
225
|
+
id="resolution-answer",
|
|
226
|
+
text=self._draft(),
|
|
227
|
+
classes=POPUP_EDIT_CLASS,
|
|
228
|
+
)
|
|
229
|
+
if panel.details:
|
|
230
|
+
yield Rule(classes=POPUP_RULE_CLASS)
|
|
231
|
+
yield _TriedSection("", id="resolution-tried", markup=False)
|
|
232
|
+
|
|
233
|
+
def on_mount(self) -> None:
|
|
234
|
+
self._paint()
|
|
235
|
+
if self._panel is not None:
|
|
236
|
+
self.set_focus(self.query_one("#resolution-choices", _ChoiceList), scroll_visible=False)
|
|
237
|
+
self.call_after_refresh(self.query_one("#resolution-frame", PopupFrame).content_ready)
|
|
238
|
+
self._change_worker = self.run_worker(self._on_change(self._refresh), group=_REFRESH_GROUP)
|
|
239
|
+
|
|
240
|
+
def on_unmount(self) -> None:
|
|
241
|
+
if self._change_worker is not None:
|
|
242
|
+
self._change_worker.cancel()
|
|
243
|
+
|
|
244
|
+
def on_resize(self, event: events.Resize) -> None:
|
|
245
|
+
del event
|
|
246
|
+
if not self.is_mounted:
|
|
247
|
+
return
|
|
248
|
+
self._paint()
|
|
249
|
+
self.call_after_refresh(
|
|
250
|
+
self.query_one("#resolution-frame", PopupFrame).update_preferred_size
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
def on_key(self, event: events.Key) -> None:
|
|
254
|
+
if self._cancel_armed and event.key != _SUBMIT_KEY:
|
|
255
|
+
self._disarm()
|
|
256
|
+
|
|
257
|
+
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
|
|
258
|
+
del parameters
|
|
259
|
+
if action == _ACTION_EXTRA_DETAILS:
|
|
260
|
+
return self._extra_details_shown() and not isinstance(self.focused, TextArea)
|
|
261
|
+
if action in _SELECTION_ACTIONS:
|
|
262
|
+
if isinstance(self.focused, TextArea):
|
|
263
|
+
return False
|
|
264
|
+
if isinstance(self.focused, _TriedSection) and action != _ACTION_TOGGLE_TRIED:
|
|
265
|
+
return False
|
|
266
|
+
return True
|
|
267
|
+
|
|
268
|
+
def action_close(self) -> None:
|
|
269
|
+
"""Close without sending; the typed answer stays remembered."""
|
|
270
|
+
self.dismiss(False)
|
|
271
|
+
|
|
272
|
+
def action_move(self, step: int) -> None:
|
|
273
|
+
"""Select the neighbouring row, stopping at both ends."""
|
|
274
|
+
panel = self._panel
|
|
275
|
+
if panel is not None:
|
|
276
|
+
self._select(min(max(self._selected + step, 0), len(panel.choices) - 1))
|
|
277
|
+
|
|
278
|
+
def action_activate(self) -> None:
|
|
279
|
+
"""Send the selected choice; Cancel asks for a second Enter."""
|
|
280
|
+
if self._panel is not None:
|
|
281
|
+
self._activate(self._selected)
|
|
282
|
+
|
|
283
|
+
def action_toggle_tried(self) -> None:
|
|
284
|
+
"""Expand or collapse `Details`."""
|
|
285
|
+
if self._panel is not None and self._panel.details:
|
|
286
|
+
self._tried_open = not self._tried_open
|
|
287
|
+
self._paint()
|
|
288
|
+
self._relayout()
|
|
289
|
+
|
|
290
|
+
def action_extra_details(self) -> None:
|
|
291
|
+
"""Open the evidence popup while `Details` is open and a packet exists."""
|
|
292
|
+
if self._extra_details_shown():
|
|
293
|
+
task_id = cast("ResolutionPanel", self._panel).task_id
|
|
294
|
+
cast("App[None]", self.app).push_screen(ExtraDetailsScreen(task_id, self._packet_text))
|
|
295
|
+
|
|
296
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
297
|
+
if event.button.id == RESOLUTION_CLOSE_BUTTON:
|
|
298
|
+
self.action_close()
|
|
299
|
+
|
|
300
|
+
@on(_ChoiceRow.Chosen)
|
|
301
|
+
def _row_chosen(self, message: _ChoiceRow.Chosen) -> None:
|
|
302
|
+
message.stop()
|
|
303
|
+
self._activate(message.index)
|
|
304
|
+
|
|
305
|
+
@on(_TriedSection.Toggled)
|
|
306
|
+
def _tried_toggled(self, message: _TriedSection.Toggled) -> None:
|
|
307
|
+
message.stop()
|
|
308
|
+
self.action_toggle_tried()
|
|
309
|
+
|
|
310
|
+
@on(_TriedSection.Left)
|
|
311
|
+
def _tried_left(self, message: _TriedSection.Left) -> None:
|
|
312
|
+
message.stop()
|
|
313
|
+
self.set_focus(self.query_one("#resolution-choices", _ChoiceList), scroll_visible=False)
|
|
314
|
+
|
|
315
|
+
@on(_AnswerBox.Submitted)
|
|
316
|
+
def _answer_submitted(self, message: _AnswerBox.Submitted) -> None:
|
|
317
|
+
message.stop()
|
|
318
|
+
panel = self._panel
|
|
319
|
+
if panel is None or self._answer_for is None:
|
|
320
|
+
return
|
|
321
|
+
details = self.query_one("#resolution-answer", _AnswerBox).text.strip()
|
|
322
|
+
choice = panel.choices[self._answer_for]
|
|
323
|
+
if choice.kind is ChoiceKind.ANSWER_OPTION:
|
|
324
|
+
label = choice.label or ""
|
|
325
|
+
self._start_send(
|
|
326
|
+
ResolutionKind.ANSWER,
|
|
327
|
+
f"{label}{_PREFILL_SEPARATOR}{details}" if details else label,
|
|
328
|
+
)
|
|
329
|
+
elif details:
|
|
330
|
+
self._start_send(ResolutionKind.ANSWER, details)
|
|
331
|
+
|
|
332
|
+
@on(_AnswerBox.Exited)
|
|
333
|
+
def _answer_exited(self, message: _AnswerBox.Exited) -> None:
|
|
334
|
+
message.stop()
|
|
335
|
+
self.set_focus(self.query_one("#resolution-choices", _ChoiceList), scroll_visible=False)
|
|
336
|
+
self._show_answer(self._resting_answer_row())
|
|
337
|
+
|
|
338
|
+
def on_descendant_focus(self, event: events.DescendantFocus) -> None:
|
|
339
|
+
del event
|
|
340
|
+
if self.is_mounted:
|
|
341
|
+
self._paint()
|
|
342
|
+
|
|
343
|
+
def on_descendant_blur(self, event: events.DescendantBlur) -> None:
|
|
344
|
+
del event
|
|
345
|
+
if self.is_mounted:
|
|
346
|
+
self._paint()
|
|
347
|
+
|
|
348
|
+
def on_text_area_changed(self, event: TextArea.Changed) -> None:
|
|
349
|
+
panel = self._panel
|
|
350
|
+
if panel is not None and self._answer_for == self._own_answer_index():
|
|
351
|
+
self._drafts.remember(panel.task_id, self._escalation_id, event.text_area.text)
|
|
352
|
+
|
|
353
|
+
def _load(self) -> ResolutionPanel | None:
|
|
354
|
+
view = self._queries.get_escalation(self._escalation_id)
|
|
355
|
+
if view is None:
|
|
356
|
+
return None
|
|
357
|
+
self._packet_text = view.packet or ""
|
|
358
|
+
task = self._queries.get_task_state(view.escalation.task_id)
|
|
359
|
+
packet = parse_escalation_packet(view.packet) if view.packet is not None else None
|
|
360
|
+
return build_resolution_panel(
|
|
361
|
+
view.escalation,
|
|
362
|
+
packet,
|
|
363
|
+
task.title if task is not None else "",
|
|
364
|
+
self._queries.escalation_resolution_disabled_reason(self._escalation_id),
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
def _extra_details_shown(self) -> bool:
|
|
368
|
+
panel = self._panel
|
|
369
|
+
return (
|
|
370
|
+
panel is not None
|
|
371
|
+
and bool(panel.details)
|
|
372
|
+
and self._tried_open
|
|
373
|
+
and bool(self._packet_text)
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
def _disabled_reason(self) -> str | None:
|
|
377
|
+
if self._panel is None:
|
|
378
|
+
return self._queries.escalation_resolution_disabled_reason(self._escalation_id)
|
|
379
|
+
return self._panel.disabled_reason
|
|
380
|
+
|
|
381
|
+
def _draft(self) -> str:
|
|
382
|
+
if self._panel is None:
|
|
383
|
+
return ""
|
|
384
|
+
return self._drafts.get(self._panel.task_id, self._escalation_id)
|
|
385
|
+
|
|
386
|
+
def _own_answer_index(self) -> int:
|
|
387
|
+
panel = cast("ResolutionPanel", self._panel)
|
|
388
|
+
return next(
|
|
389
|
+
(i for i, c in enumerate(panel.choices) if c.kind is ChoiceKind.OWN_ANSWER),
|
|
390
|
+
self._selected,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
def _resting_answer_row(self) -> int | None:
|
|
394
|
+
return self._own_answer_index() if self._draft() else None
|
|
395
|
+
|
|
396
|
+
async def _refresh(self) -> None:
|
|
397
|
+
if not self.is_mounted:
|
|
398
|
+
return
|
|
399
|
+
panel = self._load()
|
|
400
|
+
previous = self._panel
|
|
401
|
+
self._panel = panel
|
|
402
|
+
if panel is not None and previous is not None and panel.choices != previous.choices:
|
|
403
|
+
self._selected = min(self._selected, len(panel.choices) - 1)
|
|
404
|
+
self._answer_for = self._resting_answer_row()
|
|
405
|
+
await self.recompose()
|
|
406
|
+
self.call_after_refresh(self._after_recompose)
|
|
407
|
+
return
|
|
408
|
+
self._paint()
|
|
409
|
+
self._relayout()
|
|
410
|
+
|
|
411
|
+
def _after_recompose(self) -> None:
|
|
412
|
+
self._paint()
|
|
413
|
+
if self._panel is not None:
|
|
414
|
+
self.set_focus(self.query_one("#resolution-choices", _ChoiceList), scroll_visible=False)
|
|
415
|
+
self._relayout()
|
|
416
|
+
|
|
417
|
+
def _paint(self) -> None:
|
|
418
|
+
panel = self._panel
|
|
419
|
+
layout = layout_for(self.size.width, self.size.height)
|
|
420
|
+
frame = self.query_one("#resolution-frame", PopupFrame)
|
|
421
|
+
frame.set_class(layout is not ResolutionLayout.WIDE, "compact")
|
|
422
|
+
self.query_one("#resolution-title", Label).update(
|
|
423
|
+
resolution_title(self._escalation_id, panel, layout)
|
|
424
|
+
)
|
|
425
|
+
self._render_notice()
|
|
426
|
+
if panel is None:
|
|
427
|
+
return
|
|
428
|
+
age = format_short_age(self._clock.now() - panel.created_at)
|
|
429
|
+
self.query_one("#resolution-decision", Static).update(
|
|
430
|
+
decision_renderable(panel, age, layout)
|
|
431
|
+
)
|
|
432
|
+
choose = self.query_one("#resolution-choose", Static)
|
|
433
|
+
choose.update(
|
|
434
|
+
choose_renderable(
|
|
435
|
+
panel.choices[self._selected],
|
|
436
|
+
details_open=self._tried_open if isinstance(self.focused, _TriedSection) else None,
|
|
437
|
+
editing=isinstance(self.focused, _AnswerBox),
|
|
438
|
+
choosing_disabled=self._disabled_reason() is not None or self._sending,
|
|
439
|
+
)
|
|
440
|
+
)
|
|
441
|
+
choose.display = layout is not ResolutionLayout.TINY
|
|
442
|
+
disabled = self._disabled_reason() is not None
|
|
443
|
+
starred = starred_indexes(panel)
|
|
444
|
+
for index, row in enumerate(self.query(_ChoiceRow)):
|
|
445
|
+
choice = panel.choices[index]
|
|
446
|
+
selected = index == self._selected
|
|
447
|
+
row.set_class(selected, "-selected")
|
|
448
|
+
row.update(
|
|
449
|
+
choice_renderable(
|
|
450
|
+
choice,
|
|
451
|
+
starred=index in starred,
|
|
452
|
+
armed=selected and self._cancel_armed,
|
|
453
|
+
disabled=disabled,
|
|
454
|
+
)
|
|
455
|
+
)
|
|
456
|
+
for answer in self.query(_AnswerBox):
|
|
457
|
+
answer.display = self._answer_for is not None
|
|
458
|
+
answer.disabled = disabled
|
|
459
|
+
if panel.details:
|
|
460
|
+
self.query_one("#resolution-tried", _TriedSection).update(
|
|
461
|
+
tried_renderable(
|
|
462
|
+
panel.details,
|
|
463
|
+
expanded=self._tried_open,
|
|
464
|
+
extra_details=self._extra_details_shown(),
|
|
465
|
+
)
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
def _render_notice(self) -> None:
|
|
469
|
+
reason = self._disabled_reason()
|
|
470
|
+
notice = self.query_one("#resolution-notice", Static)
|
|
471
|
+
if reason is not None:
|
|
472
|
+
notice.update(Text(reason, style=AMBER))
|
|
473
|
+
elif self._send_error is not None:
|
|
474
|
+
notice.update(Text(self._send_error, style=DUSTY_ROSE))
|
|
475
|
+
notice.display = reason is not None or self._send_error is not None
|
|
476
|
+
|
|
477
|
+
def _relayout(self) -> None:
|
|
478
|
+
frame = self.query_one("#resolution-frame", PopupFrame)
|
|
479
|
+
frame.reset_preferred_size()
|
|
480
|
+
self.call_after_refresh(frame.content_ready)
|
|
481
|
+
|
|
482
|
+
def _select(self, index: int) -> None:
|
|
483
|
+
if index != self._selected and not isinstance(self.focused, _AnswerBox):
|
|
484
|
+
self._show_answer(self._resting_answer_row())
|
|
485
|
+
self._selected = index
|
|
486
|
+
self._cancel_armed = False
|
|
487
|
+
self._send_error = None
|
|
488
|
+
self._paint()
|
|
489
|
+
self.query_one("#resolution-body", VerticalScroll).scroll_to_widget(
|
|
490
|
+
list(self.query(_ChoiceRow))[index], animate=False
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
def _disarm(self) -> None:
|
|
494
|
+
self._cancel_armed = False
|
|
495
|
+
self._paint()
|
|
496
|
+
|
|
497
|
+
def _activate(self, index: int) -> None:
|
|
498
|
+
panel = self._panel
|
|
499
|
+
if panel is None or self._disabled_reason() is not None:
|
|
500
|
+
return
|
|
501
|
+
confirming = self._cancel_armed and index == self._selected
|
|
502
|
+
self._select(index)
|
|
503
|
+
choice = panel.choices[index]
|
|
504
|
+
if self._editable(index):
|
|
505
|
+
self._open_answer(index)
|
|
506
|
+
elif choice.kind is ChoiceKind.CANCEL and not confirming:
|
|
507
|
+
self._cancel_armed = True
|
|
508
|
+
self._paint()
|
|
509
|
+
else:
|
|
510
|
+
self._send_choice(choice)
|
|
511
|
+
|
|
512
|
+
def _editable(self, index: int) -> bool:
|
|
513
|
+
panel = self._panel
|
|
514
|
+
if panel is None or index >= len(panel.choices) or self._disabled_reason() is not None:
|
|
515
|
+
return False
|
|
516
|
+
choice = panel.choices[index]
|
|
517
|
+
return choice.kind is ChoiceKind.OWN_ANSWER or choice.needs_input
|
|
518
|
+
|
|
519
|
+
def _open_answer(self, index: int) -> None:
|
|
520
|
+
if not self._editable(index):
|
|
521
|
+
return
|
|
522
|
+
self._show_answer(index)
|
|
523
|
+
box = self.query_one("#resolution-answer", _AnswerBox)
|
|
524
|
+
box.move_cursor(box.document.end)
|
|
525
|
+
self.set_focus(box)
|
|
526
|
+
|
|
527
|
+
def _show_answer(self, index: int | None) -> None:
|
|
528
|
+
if index != self._answer_for:
|
|
529
|
+
self._answer_for = index
|
|
530
|
+
box = self.query_one("#resolution-answer", _AnswerBox)
|
|
531
|
+
if index is not None:
|
|
532
|
+
self.query_one("#resolution-choices", _ChoiceList).move_child(
|
|
533
|
+
box, after=list(self.query(_ChoiceRow))[index]
|
|
534
|
+
)
|
|
535
|
+
box.load_text(self._draft() if index == self._own_answer_index() else "")
|
|
536
|
+
self._relayout()
|
|
537
|
+
self._paint()
|
|
538
|
+
|
|
539
|
+
def _send_choice(self, choice: PanelChoice) -> None:
|
|
540
|
+
if choice.kind is ChoiceKind.ANSWER_OPTION:
|
|
541
|
+
self._start_send(ResolutionKind.ANSWER, choice.label or "")
|
|
542
|
+
else:
|
|
543
|
+
self._start_send(ResolutionKind(choice.kind.value), "")
|
|
544
|
+
|
|
545
|
+
def _start_send(self, kind: ResolutionKind, answer: str) -> None:
|
|
546
|
+
if self._sending or self._disabled_reason() is not None:
|
|
547
|
+
return
|
|
548
|
+
self._sending = True
|
|
549
|
+
self._paint()
|
|
550
|
+
self.run_worker(self._send(kind, answer), group=_SEND_GROUP)
|
|
551
|
+
|
|
552
|
+
async def _send(self, kind: ResolutionKind, answer: str) -> None:
|
|
553
|
+
try:
|
|
554
|
+
command = self._commands.prepare_palette_resolution(
|
|
555
|
+
self._escalation_id, kind, answer=answer, retry_rung=None
|
|
556
|
+
)
|
|
557
|
+
if self._queries.pending_command(command) is None:
|
|
558
|
+
ack = await self._commands.send(command)
|
|
559
|
+
if ack.status is AckStatus.REJECTED:
|
|
560
|
+
self._fail(command_rejection_cause(ack.result))
|
|
561
|
+
return
|
|
562
|
+
except ShikiruError as error:
|
|
563
|
+
self._fail(str(error))
|
|
564
|
+
return
|
|
565
|
+
finally:
|
|
566
|
+
self._sending = False
|
|
567
|
+
self.dismiss(True)
|
|
568
|
+
|
|
569
|
+
def _fail(self, reason: str) -> None:
|
|
570
|
+
self._cancel_armed = False
|
|
571
|
+
self._send_error = reason
|
|
572
|
+
self._paint()
|
|
573
|
+
self._relayout()
|