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,402 @@
|
|
|
1
|
+
"""The `escalations` widget: open packets as splash panels."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, cast
|
|
6
|
+
|
|
7
|
+
from rich.console import Group, RenderableType
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
from textual.containers import VerticalScroll
|
|
10
|
+
from textual.widgets import Static
|
|
11
|
+
|
|
12
|
+
from shikiru.dashboard.contract import (
|
|
13
|
+
FOCUS_FAULTS_VIEW,
|
|
14
|
+
AttentionGroupView,
|
|
15
|
+
AttentionView,
|
|
16
|
+
EscalationPanelView,
|
|
17
|
+
WidgetPlacement,
|
|
18
|
+
WorkspaceSummaryView,
|
|
19
|
+
)
|
|
20
|
+
from shikiru.ui import SplashPanel
|
|
21
|
+
from shikiru.ui.compact import (
|
|
22
|
+
ACTION_HINT,
|
|
23
|
+
FOCUS_ATTENTION_MAX_HEIGHT,
|
|
24
|
+
FocusAttentionStatus,
|
|
25
|
+
display_escalation_headline,
|
|
26
|
+
)
|
|
27
|
+
from shikiru.ui.gated_static import GatedStatic
|
|
28
|
+
from shikiru.ui.keys import key_hint
|
|
29
|
+
from shikiru.ui.screen import widget_is_attached, widget_is_current
|
|
30
|
+
from shikiru.ui.tokens import SECONDARY_TEXT_COLOR
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from collections.abc import Callable
|
|
34
|
+
|
|
35
|
+
from textual import events
|
|
36
|
+
from textual.app import App, ComposeResult
|
|
37
|
+
from textual.widget import Widget
|
|
38
|
+
|
|
39
|
+
from shikiru.dashboard.contract import WidgetContext
|
|
40
|
+
|
|
41
|
+
_SELECTED_MARKER = "▶ "
|
|
42
|
+
_MOVE_STEPS = {"up": -1, "down": 1}
|
|
43
|
+
_OPEN_TASK_KEY = "enter"
|
|
44
|
+
MAX_ESCALATION_PANEL_HEIGHT = 12
|
|
45
|
+
ARMED_CLASS = "armed"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class EscalationsPanel(Static):
|
|
49
|
+
"""Show grouped decisions, diagnosed stalls and recorded engine faults."""
|
|
50
|
+
|
|
51
|
+
DEFAULT_CSS = f"""
|
|
52
|
+
EscalationsPanel {{ height: auto; }}
|
|
53
|
+
#escalation-panels {{ height: auto; max-height: {MAX_ESCALATION_PANEL_HEIGHT}; }}
|
|
54
|
+
EscalationsPanel.focus-row {{ width: 100%; }}
|
|
55
|
+
EscalationsPanel.focus-row #escalation-panels {{
|
|
56
|
+
width: 100%; height: auto; max-height: {FOCUS_ATTENTION_MAX_HEIGHT};
|
|
57
|
+
}}
|
|
58
|
+
EscalationsPanel.focus-row #escalation-panels Static {{ width: 100%; }}
|
|
59
|
+
"""
|
|
60
|
+
can_focus = True
|
|
61
|
+
|
|
62
|
+
def __init__(self, context: WidgetContext) -> None:
|
|
63
|
+
"""Hold `context`; views are read on mount and on each state change."""
|
|
64
|
+
classes = "focus-row" if context.placement is WidgetPlacement.ATTENTION else ""
|
|
65
|
+
super().__init__(id="escalations", classes=classes)
|
|
66
|
+
self._widget_context = context
|
|
67
|
+
self._focus_row = context.placement is WidgetPlacement.ATTENTION
|
|
68
|
+
self._attention = AttentionView((), decisions=0, stalls=0, faults=0)
|
|
69
|
+
self._summary: WorkspaceSummaryView | None = None
|
|
70
|
+
self._selected = 0
|
|
71
|
+
self._followed_task_id: str | None = None
|
|
72
|
+
self._rendered_selection: tuple[object, ...] | None = None
|
|
73
|
+
self._has_rendered = False
|
|
74
|
+
self._keep_empty_visible = False
|
|
75
|
+
self._focus_requested = False
|
|
76
|
+
self._remove_motion_callback: Callable[[], None] | None = None
|
|
77
|
+
|
|
78
|
+
def follow_task(self, task_id: str | None) -> None:
|
|
79
|
+
"""Show the decision of the selected task, or that it has none; `None` clears the follow."""
|
|
80
|
+
if task_id == self._followed_task_id:
|
|
81
|
+
return
|
|
82
|
+
self._followed_task_id = task_id
|
|
83
|
+
if task_id is None:
|
|
84
|
+
self._selected = 0
|
|
85
|
+
if widget_is_attached(self):
|
|
86
|
+
self.call_later(self._refresh)
|
|
87
|
+
|
|
88
|
+
def allow_focus(self) -> bool:
|
|
89
|
+
"""Join the Tab chain only while it holds attention groups or was opened on request."""
|
|
90
|
+
return bool(self._attention.groups) or self._focus_requested
|
|
91
|
+
|
|
92
|
+
def compose(self) -> ComposeResult:
|
|
93
|
+
"""Reserve a bounded, scrollable area for home attention groups."""
|
|
94
|
+
yield VerticalScroll(id="escalation-panels", can_focus=False)
|
|
95
|
+
|
|
96
|
+
async def on_mount(self) -> None:
|
|
97
|
+
"""Render current attention and refresh on state or motion changes."""
|
|
98
|
+
await self._refresh()
|
|
99
|
+
self.run_worker(self._widget_context.on_change(self._refresh_if_attached), exclusive=True)
|
|
100
|
+
self._remove_motion_callback = self._widget_context.on_motion(self._refresh_while_active)
|
|
101
|
+
|
|
102
|
+
def on_unmount(self) -> None:
|
|
103
|
+
if self._remove_motion_callback is not None:
|
|
104
|
+
self._remove_motion_callback()
|
|
105
|
+
self._remove_motion_callback = None
|
|
106
|
+
|
|
107
|
+
def on_focus(self, event: events.Focus) -> None:
|
|
108
|
+
del event
|
|
109
|
+
self._keep_empty_visible = True
|
|
110
|
+
|
|
111
|
+
def on_blur(self, event: events.Blur) -> None:
|
|
112
|
+
del event
|
|
113
|
+
self._focus_requested = False
|
|
114
|
+
if self._focus_row:
|
|
115
|
+
self.display = True
|
|
116
|
+
|
|
117
|
+
async def _refresh_while_active(self) -> None:
|
|
118
|
+
if widget_is_current(self) and cast("App[None]", self.app).is_running:
|
|
119
|
+
await self._refresh()
|
|
120
|
+
|
|
121
|
+
async def _refresh_if_attached(self) -> None:
|
|
122
|
+
if widget_is_attached(self):
|
|
123
|
+
await self._refresh()
|
|
124
|
+
|
|
125
|
+
def activate_attention(self) -> None:
|
|
126
|
+
"""Show the attention content, retaining its useful empty state while selected."""
|
|
127
|
+
self._keep_empty_visible = True
|
|
128
|
+
self._focus_requested = True
|
|
129
|
+
self.display = True
|
|
130
|
+
self.focus()
|
|
131
|
+
|
|
132
|
+
async def activate_selected_decision(self) -> bool:
|
|
133
|
+
if not self._focus_row or not widget_is_current(self):
|
|
134
|
+
return False
|
|
135
|
+
decision = self._selected_decision()
|
|
136
|
+
if decision is None:
|
|
137
|
+
return False
|
|
138
|
+
await self._open_resolution(decision)
|
|
139
|
+
return True
|
|
140
|
+
|
|
141
|
+
async def on_key(self, event: events.Key) -> None:
|
|
142
|
+
"""Open the selected task or its resolution."""
|
|
143
|
+
if event.key in _MOVE_STEPS and not self._attention.groups:
|
|
144
|
+
self._handle_vertical_key(event)
|
|
145
|
+
return
|
|
146
|
+
if not self._attention.groups:
|
|
147
|
+
return
|
|
148
|
+
if event.key == _OPEN_TASK_KEY:
|
|
149
|
+
if self._focus_row:
|
|
150
|
+
await self._activate_focus_task(event)
|
|
151
|
+
else:
|
|
152
|
+
await self._open_task_detail(event)
|
|
153
|
+
|
|
154
|
+
async def on_click(self, event: events.Click) -> None:
|
|
155
|
+
if not self._focus_row or not self._attention.groups:
|
|
156
|
+
return
|
|
157
|
+
self.focus()
|
|
158
|
+
if self._not_escalated_task_id() is not None:
|
|
159
|
+
event.stop()
|
|
160
|
+
return
|
|
161
|
+
group = self._attention.groups[self._selected]
|
|
162
|
+
if group.task_id is not None and self._widget_context.on_task_selected is not None:
|
|
163
|
+
self._widget_context.on_task_selected(group.task_id)
|
|
164
|
+
if group.escalations:
|
|
165
|
+
await self._open_resolution(self._selected_decision() or group.escalations[0])
|
|
166
|
+
elif self._focus_row and group.task_id is not None:
|
|
167
|
+
event.stop()
|
|
168
|
+
elif group.task_id is not None and self._widget_context.open_task_detail is not None:
|
|
169
|
+
await self._widget_context.open_task_detail(group.task_id)
|
|
170
|
+
else:
|
|
171
|
+
await self._open_fault_evidence()
|
|
172
|
+
event.stop()
|
|
173
|
+
|
|
174
|
+
def _handle_vertical_key(self, event: events.Key) -> None:
|
|
175
|
+
if self._focus_row:
|
|
176
|
+
panels = self.query_one("#escalation-panels", VerticalScroll)
|
|
177
|
+
panels.scroll_to(
|
|
178
|
+
y=panels.scroll_y + _MOVE_STEPS[event.key],
|
|
179
|
+
animate=False,
|
|
180
|
+
immediate=True,
|
|
181
|
+
)
|
|
182
|
+
event.stop()
|
|
183
|
+
|
|
184
|
+
async def _open_task_detail(self, event: events.Key) -> None:
|
|
185
|
+
group = self._attention.groups[self._selected]
|
|
186
|
+
if group.task_id is not None and self._widget_context.open_task_detail is not None:
|
|
187
|
+
on_task_selected = self._widget_context.on_task_selected
|
|
188
|
+
if on_task_selected is not None:
|
|
189
|
+
on_task_selected(group.task_id)
|
|
190
|
+
await self._widget_context.open_task_detail(group.task_id)
|
|
191
|
+
event.stop()
|
|
192
|
+
|
|
193
|
+
async def _activate_focus_task(self, event: events.Key) -> None:
|
|
194
|
+
if self._not_escalated_task_id() is not None:
|
|
195
|
+
event.stop()
|
|
196
|
+
return
|
|
197
|
+
decision = self._selected_decision()
|
|
198
|
+
if decision is not None:
|
|
199
|
+
await self._open_resolution(decision)
|
|
200
|
+
event.stop()
|
|
201
|
+
return
|
|
202
|
+
group = self._attention.groups[self._selected]
|
|
203
|
+
if group.task_id is not None and self._widget_context.on_task_selected is not None:
|
|
204
|
+
self._widget_context.on_task_selected(group.task_id)
|
|
205
|
+
event.stop()
|
|
206
|
+
|
|
207
|
+
async def _open_resolution(self, decision: EscalationPanelView) -> None:
|
|
208
|
+
if not widget_is_current(self):
|
|
209
|
+
return
|
|
210
|
+
if self._widget_context.open_resolution is not None:
|
|
211
|
+
await self._widget_context.open_resolution(decision.escalation_id)
|
|
212
|
+
|
|
213
|
+
async def _open_fault_evidence(self) -> None:
|
|
214
|
+
open_view = self._widget_context.open_contextual_view
|
|
215
|
+
if open_view is not None:
|
|
216
|
+
await open_view(FOCUS_FAULTS_VIEW)
|
|
217
|
+
|
|
218
|
+
async def _refresh(self) -> None:
|
|
219
|
+
if not widget_is_attached(self):
|
|
220
|
+
return
|
|
221
|
+
attention = self._widget_context.views.attention()
|
|
222
|
+
summary = self._widget_context.views.workspace_summary()
|
|
223
|
+
current_status = _attention_status_state(attention, summary)
|
|
224
|
+
previous_status = (
|
|
225
|
+
_attention_status_state(self._attention, self._summary)
|
|
226
|
+
if self._summary is not None
|
|
227
|
+
else None
|
|
228
|
+
)
|
|
229
|
+
groups_unchanged = attention == self._attention
|
|
230
|
+
had_selection = bool(self._attention.groups)
|
|
231
|
+
selected_id = self._selected_group_id()
|
|
232
|
+
self._attention = attention
|
|
233
|
+
self._summary = summary
|
|
234
|
+
self.display = self._focus_row or bool(attention.groups) or self._keep_empty_visible
|
|
235
|
+
if not attention.groups:
|
|
236
|
+
self._selected = 0
|
|
237
|
+
elif had_selection:
|
|
238
|
+
selected_index = next(
|
|
239
|
+
(
|
|
240
|
+
index
|
|
241
|
+
for index, group in enumerate(attention.groups)
|
|
242
|
+
if group.task_id == selected_id
|
|
243
|
+
),
|
|
244
|
+
None,
|
|
245
|
+
)
|
|
246
|
+
self._selected = selected_index if selected_index is not None else 0
|
|
247
|
+
else:
|
|
248
|
+
self._selected = min(self._selected, len(attention.groups) - 1)
|
|
249
|
+
self._follow_selected_task()
|
|
250
|
+
unchanged = groups_unchanged and current_status == previous_status
|
|
251
|
+
if unchanged and self._has_rendered and self._rendered_selection == self._selection_state():
|
|
252
|
+
return
|
|
253
|
+
await self._render_panels()
|
|
254
|
+
self._has_rendered = True
|
|
255
|
+
|
|
256
|
+
def _follow_selected_task(self) -> None:
|
|
257
|
+
followed = self._followed_task_id
|
|
258
|
+
if followed is None:
|
|
259
|
+
return
|
|
260
|
+
groups = self._attention.groups
|
|
261
|
+
candidates = [index for index, group in enumerate(groups) if group.task_id == followed] or [
|
|
262
|
+
index for index, group in enumerate(groups) if not group.escalations
|
|
263
|
+
]
|
|
264
|
+
if candidates:
|
|
265
|
+
self._selected = candidates[0]
|
|
266
|
+
|
|
267
|
+
def _not_escalated_task_id(self) -> str | None:
|
|
268
|
+
followed = self._followed_task_id
|
|
269
|
+
if followed is None or not self._focus_row:
|
|
270
|
+
return None
|
|
271
|
+
has_row = any(
|
|
272
|
+
group.task_id == followed or not group.escalations for group in self._attention.groups
|
|
273
|
+
)
|
|
274
|
+
return None if has_row else followed
|
|
275
|
+
|
|
276
|
+
def _selection_state(self) -> tuple[object, ...]:
|
|
277
|
+
return (self._selected, self._selected_escalation_id(), self._not_escalated_task_id())
|
|
278
|
+
|
|
279
|
+
def _selected_group_id(self) -> str | None:
|
|
280
|
+
if not self._attention.groups or self._selected >= len(self._attention.groups):
|
|
281
|
+
return None
|
|
282
|
+
return self._attention.groups[self._selected].task_id
|
|
283
|
+
|
|
284
|
+
def _selected_decision(self) -> EscalationPanelView | None:
|
|
285
|
+
if self._not_escalated_task_id() is not None:
|
|
286
|
+
return None
|
|
287
|
+
if not self._attention.groups or self._selected >= len(self._attention.groups):
|
|
288
|
+
return None
|
|
289
|
+
escalations = self._attention.groups[self._selected].escalations
|
|
290
|
+
return escalations[0] if escalations else None
|
|
291
|
+
|
|
292
|
+
def _selected_escalation_id(self) -> str | None:
|
|
293
|
+
decision = self._selected_decision()
|
|
294
|
+
return decision.escalation_id if decision is not None else None
|
|
295
|
+
|
|
296
|
+
async def _render_panels(self) -> None:
|
|
297
|
+
self._rendered_selection = self._selection_state()
|
|
298
|
+
self.set_class(self._focus_row and self._selected_decision() is not None, ARMED_CLASS)
|
|
299
|
+
panels = self.query_one("#escalation-panels", VerticalScroll)
|
|
300
|
+
scroll_x, scroll_y = panels.scroll_x, panels.scroll_y
|
|
301
|
+
if self._focus_row or not self._attention.groups:
|
|
302
|
+
status = (
|
|
303
|
+
self._render_compact_attention()
|
|
304
|
+
if self._focus_row
|
|
305
|
+
else "No open decisions, diagnosed stalls or recorded engine faults."
|
|
306
|
+
)
|
|
307
|
+
children = list(panels.query(GatedStatic))
|
|
308
|
+
if len(children) == 1:
|
|
309
|
+
children[0].update_if_changed(status)
|
|
310
|
+
return
|
|
311
|
+
await self._replace_panels(panels, [GatedStatic(status, markup=False)])
|
|
312
|
+
return
|
|
313
|
+
children = list(panels.query(GatedStatic))
|
|
314
|
+
if len(children) == len(self._attention.groups):
|
|
315
|
+
for index, (card, group) in enumerate(
|
|
316
|
+
zip(children, self._attention.groups, strict=True)
|
|
317
|
+
):
|
|
318
|
+
card.update_if_changed(self._render_group(index, group))
|
|
319
|
+
panels.scroll_to(x=scroll_x, y=scroll_y, animate=False)
|
|
320
|
+
return
|
|
321
|
+
cards = [
|
|
322
|
+
GatedStatic(self._render_group(index, group), markup=False)
|
|
323
|
+
for index, group in enumerate(self._attention.groups)
|
|
324
|
+
]
|
|
325
|
+
if await self._replace_panels(panels, cards):
|
|
326
|
+
panels.scroll_to(x=scroll_x, y=scroll_y, animate=False)
|
|
327
|
+
|
|
328
|
+
async def _replace_panels(self, panels: VerticalScroll, cards: list[GatedStatic]) -> bool:
|
|
329
|
+
await panels.remove_children()
|
|
330
|
+
if not widget_is_attached(self):
|
|
331
|
+
return False
|
|
332
|
+
await panels.mount(*cards)
|
|
333
|
+
return widget_is_attached(self)
|
|
334
|
+
|
|
335
|
+
def _render_compact_attention(self) -> RenderableType:
|
|
336
|
+
if self._summary is None:
|
|
337
|
+
return Text("○ Engine status unavailable", style=SECONDARY_TEXT_COLOR)
|
|
338
|
+
decision = self._selected_decision()
|
|
339
|
+
action_hint = ACTION_HINT
|
|
340
|
+
if self._focus_row and decision is not None:
|
|
341
|
+
action_hint = "Enter answer ↵" if decision.answer_available else "Enter resolve ↵"
|
|
342
|
+
return FocusAttentionStatus(
|
|
343
|
+
self._attention,
|
|
344
|
+
self._summary,
|
|
345
|
+
action_hint=action_hint,
|
|
346
|
+
selected_group_index=self._selected,
|
|
347
|
+
selected_escalation_id=self._selected_escalation_id(),
|
|
348
|
+
not_escalated_task_id=self._not_escalated_task_id(),
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
def _render_group(self, index: int, group: AttentionGroupView) -> Group:
|
|
352
|
+
marker = _SELECTED_MARKER if index == self._selected else " "
|
|
353
|
+
identity = f"{group.task_id} · {group.title}" if group.task_id is not None else group.title
|
|
354
|
+
lines = [f"{marker}{identity}"]
|
|
355
|
+
lines.extend(f" {reason}" for reason in group.reasons)
|
|
356
|
+
content: list[RenderableType] = ["\n".join(lines)]
|
|
357
|
+
selected_id = self._selected_escalation_id()
|
|
358
|
+
for item in group.escalations:
|
|
359
|
+
shown = display_escalation_headline(item.task_id, item.headline, item.reason)
|
|
360
|
+
headline = f"{item.escalation_id} · {shown} · {item.age}"
|
|
361
|
+
if index == self._selected and item.escalation_id == selected_id:
|
|
362
|
+
headline = f"{_SELECTED_MARKER}{headline}"
|
|
363
|
+
content.append(
|
|
364
|
+
SplashPanel(
|
|
365
|
+
headline,
|
|
366
|
+
item.question or "Evidence unavailable",
|
|
367
|
+
item.options,
|
|
368
|
+
item.pulse_red,
|
|
369
|
+
).render()
|
|
370
|
+
)
|
|
371
|
+
if item.resolution_actions:
|
|
372
|
+
content.append(f" Allowed actions: {', '.join(item.resolution_actions)}")
|
|
373
|
+
if item.evidence_error is not None:
|
|
374
|
+
content.append(f" Evidence error: {item.evidence_error}")
|
|
375
|
+
if group.task_id is not None:
|
|
376
|
+
content.append(key_hint(" Enter: task detail"))
|
|
377
|
+
return Group(*content)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _attention_status_state(
|
|
381
|
+
attention: AttentionView,
|
|
382
|
+
summary: WorkspaceSummaryView,
|
|
383
|
+
) -> tuple[object, ...]:
|
|
384
|
+
return (
|
|
385
|
+
attention,
|
|
386
|
+
summary.engine_up,
|
|
387
|
+
summary.held_tasks,
|
|
388
|
+
summary.holds,
|
|
389
|
+
summary.operator_owned,
|
|
390
|
+
summary.held_task_states,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
class EscalationsWidget:
|
|
395
|
+
"""The `escalations` dashboard widget plugin."""
|
|
396
|
+
|
|
397
|
+
name = "escalations"
|
|
398
|
+
placement = WidgetPlacement.ATTENTION
|
|
399
|
+
|
|
400
|
+
def create(self, context: WidgetContext) -> Widget:
|
|
401
|
+
"""Build the escalation panel for `context`."""
|
|
402
|
+
return EscalationsPanel(context)
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""The `faults` widget: recorded open engine fault streaks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from rich.console import Group
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
from textual.containers import ScrollableContainer, Vertical
|
|
10
|
+
from textual.widgets import Collapsible, Static
|
|
11
|
+
|
|
12
|
+
from shikiru.dashboard.contract import WidgetPlacement
|
|
13
|
+
from shikiru.ui.gated_static import GatedStatic
|
|
14
|
+
from shikiru.ui.screen import is_first_child, widget_is_attached
|
|
15
|
+
from shikiru.ui.section import GROUP_SEPARATOR, Section, grey, row_name
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
|
|
20
|
+
from textual.app import ComposeResult
|
|
21
|
+
from textual.widget import Widget
|
|
22
|
+
|
|
23
|
+
from shikiru.dashboard.contract import EngineFaultView, FaultDetailRow, WidgetContext
|
|
24
|
+
|
|
25
|
+
SECTION_TITLE = "Faults"
|
|
26
|
+
TITLE_SEPARATOR = " \N{MIDDLE DOT} "
|
|
27
|
+
PROJECT_TARGET = "project"
|
|
28
|
+
DETAILS_TITLE = "Details"
|
|
29
|
+
COLLAPSED_MARK = "\N{BLACK RIGHT-POINTING SMALL TRIANGLE}"
|
|
30
|
+
EXPANDED_MARK = "\N{BLACK DOWN-POINTING SMALL TRIANGLE}"
|
|
31
|
+
IMPACT_LABEL = "Impact:"
|
|
32
|
+
CAUSE_LABEL = "Cause:"
|
|
33
|
+
NEXT_LABEL = "Next:"
|
|
34
|
+
DETAIL_INDENT = " "
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _summary(fault: EngineFaultView) -> Group:
|
|
38
|
+
headline = Text.assemble(
|
|
39
|
+
row_name(fault.task_id or PROJECT_TARGET),
|
|
40
|
+
grey(GROUP_SEPARATOR),
|
|
41
|
+
Text(fault.scope),
|
|
42
|
+
grey(GROUP_SEPARATOR),
|
|
43
|
+
Text(fault.status),
|
|
44
|
+
)
|
|
45
|
+
cause = Text.assemble(grey(f"{CAUSE_LABEL} "), Text(fault.cause))
|
|
46
|
+
return Group(
|
|
47
|
+
headline,
|
|
48
|
+
Text.assemble(grey(f"{IMPACT_LABEL} "), Text(fault.impact)),
|
|
49
|
+
cause,
|
|
50
|
+
Text.assemble(grey(f"{NEXT_LABEL} "), Text(fault.next_step)),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _detail_lines(row: FaultDetailRow) -> Text:
|
|
55
|
+
lines = row.value.strip().splitlines() or [row.value]
|
|
56
|
+
if len(lines) == 1:
|
|
57
|
+
return Text.assemble(grey(f"{row.label} "), Text(lines[0]))
|
|
58
|
+
body = "\n".join(f"{DETAIL_INDENT}{line}" for line in lines)
|
|
59
|
+
return Text.assemble(grey(row.label), Text("\n"), Text(body))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _details(fault: EngineFaultView) -> Group:
|
|
63
|
+
return Group(*(_detail_lines(row) for row in fault.details))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class FaultCard(Vertical):
|
|
67
|
+
"""One open fault: its compact summary and a Details disclosure that starts collapsed."""
|
|
68
|
+
|
|
69
|
+
DEFAULT_CSS = """
|
|
70
|
+
FaultCard { height: auto; margin-bottom: 1; }
|
|
71
|
+
FaultCard Collapsible { padding: 0; border-top: none; }
|
|
72
|
+
FaultCard Collapsible > Contents { padding: 0 0 0 2; }
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self, fault: EngineFaultView) -> None:
|
|
76
|
+
"""Show `fault`; later `show` calls update it in place, keeping the disclosure state."""
|
|
77
|
+
super().__init__()
|
|
78
|
+
self._summary = GatedStatic(_summary(fault))
|
|
79
|
+
self._details = Static(_details(fault))
|
|
80
|
+
self._fault = fault
|
|
81
|
+
|
|
82
|
+
def compose(self) -> ComposeResult:
|
|
83
|
+
"""Lay out the summary above the collapsed Details disclosure."""
|
|
84
|
+
yield self._summary
|
|
85
|
+
yield Collapsible(
|
|
86
|
+
self._details,
|
|
87
|
+
title=DETAILS_TITLE,
|
|
88
|
+
collapsed=True,
|
|
89
|
+
collapsed_symbol=COLLAPSED_MARK,
|
|
90
|
+
expanded_symbol=EXPANDED_MARK,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def show(self, fault: EngineFaultView) -> None:
|
|
94
|
+
"""Repaint from `fault`, the latest view of the same fault identity."""
|
|
95
|
+
if fault == self._fault:
|
|
96
|
+
return
|
|
97
|
+
self._fault = fault
|
|
98
|
+
self._summary.update_if_changed(_summary(fault), key=fault)
|
|
99
|
+
self._details.update(_details(fault))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class FaultsPanel(Vertical):
|
|
103
|
+
"""Show one card per open fault and take no room while there are none."""
|
|
104
|
+
|
|
105
|
+
DEFAULT_CSS = "FaultsPanel { height: auto; }"
|
|
106
|
+
|
|
107
|
+
def __init__(self, context: WidgetContext) -> None:
|
|
108
|
+
"""Hold the read-only dashboard view and its state-change subscription."""
|
|
109
|
+
super().__init__(id="faults")
|
|
110
|
+
self._widget_context = context
|
|
111
|
+
self._unsubscribe_resources: Callable[[], None] | None = None
|
|
112
|
+
self._title = GatedStatic()
|
|
113
|
+
self._cards: dict[str, FaultCard] = {}
|
|
114
|
+
self._pending_anchor: tuple[Widget, ScrollableContainer, int] | None = None
|
|
115
|
+
|
|
116
|
+
def compose(self) -> ComposeResult:
|
|
117
|
+
"""Start with the title; cards are mounted as faults open."""
|
|
118
|
+
yield self._title
|
|
119
|
+
|
|
120
|
+
def on_mount(self) -> None:
|
|
121
|
+
"""Render fault cards; refresh with the Resources bar and on durable changes."""
|
|
122
|
+
self._refresh()
|
|
123
|
+
self._unsubscribe_resources = self._widget_context.views.on_resources_refreshed(
|
|
124
|
+
self._refresh
|
|
125
|
+
)
|
|
126
|
+
self.run_worker(self._widget_context.on_change(self._refresh_if_attached), exclusive=True)
|
|
127
|
+
|
|
128
|
+
def on_unmount(self) -> None:
|
|
129
|
+
if self._unsubscribe_resources is not None:
|
|
130
|
+
self._unsubscribe_resources()
|
|
131
|
+
self._unsubscribe_resources = None
|
|
132
|
+
|
|
133
|
+
async def _refresh_if_attached(self) -> None:
|
|
134
|
+
self._refresh()
|
|
135
|
+
|
|
136
|
+
def _refresh(self) -> None:
|
|
137
|
+
if not widget_is_attached(self):
|
|
138
|
+
return
|
|
139
|
+
faults = self._widget_context.views.engine_faults()
|
|
140
|
+
self.display = bool(faults)
|
|
141
|
+
ruled = not is_first_child(self)
|
|
142
|
+
title = f"{SECTION_TITLE}{TITLE_SEPARATOR}{len(faults)}"
|
|
143
|
+
self._title.update_if_changed(Section(title, Group(), ruled=ruled), key=(title, ruled))
|
|
144
|
+
open_keys = {fault.key for fault in faults}
|
|
145
|
+
for key in [key for key in self._cards if key not in open_keys]:
|
|
146
|
+
self._cards.pop(key).remove()
|
|
147
|
+
cards = [self._card(fault) for fault in faults]
|
|
148
|
+
anchor = self._anchor()
|
|
149
|
+
self._arrange(cards)
|
|
150
|
+
if anchor is not None and self._pending_anchor is None:
|
|
151
|
+
self._pending_anchor = anchor
|
|
152
|
+
self.call_after_refresh(self._restore)
|
|
153
|
+
|
|
154
|
+
def _anchor(self) -> tuple[Widget, ScrollableContainer, int] | None:
|
|
155
|
+
focused = self.screen.focused
|
|
156
|
+
if focused is None or self not in focused.ancestors:
|
|
157
|
+
return None
|
|
158
|
+
scroller = next(
|
|
159
|
+
(node for node in focused.ancestors if isinstance(node, ScrollableContainer)), None
|
|
160
|
+
)
|
|
161
|
+
if scroller is None:
|
|
162
|
+
return None
|
|
163
|
+
return focused, scroller, focused.region.y
|
|
164
|
+
|
|
165
|
+
def _restore(self) -> None:
|
|
166
|
+
anchor, self._pending_anchor = self._pending_anchor, None
|
|
167
|
+
if anchor is None:
|
|
168
|
+
return
|
|
169
|
+
focused, scroller, old_y = anchor
|
|
170
|
+
if not focused.is_attached or self.screen.focused is not focused:
|
|
171
|
+
return
|
|
172
|
+
shift = focused.region.y - old_y
|
|
173
|
+
if shift:
|
|
174
|
+
scroller.scroll_to(y=scroller.scroll_y + shift, animate=False, immediate=True)
|
|
175
|
+
|
|
176
|
+
def _card(self, fault: EngineFaultView) -> FaultCard:
|
|
177
|
+
card = self._cards.get(fault.key)
|
|
178
|
+
if card is None:
|
|
179
|
+
card = self._cards[fault.key] = FaultCard(fault)
|
|
180
|
+
else:
|
|
181
|
+
card.show(fault)
|
|
182
|
+
return card
|
|
183
|
+
|
|
184
|
+
def _arrange(self, cards: list[FaultCard]) -> None:
|
|
185
|
+
previous: Widget = self._title
|
|
186
|
+
placed = [child for child in self.children if child in cards]
|
|
187
|
+
in_order = placed == [card for card in cards if card in placed]
|
|
188
|
+
for card in cards:
|
|
189
|
+
if card.parent is None:
|
|
190
|
+
self.mount(card, after=previous)
|
|
191
|
+
elif not in_order:
|
|
192
|
+
self.move_child(card, after=previous)
|
|
193
|
+
previous = card
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class FaultsWidget:
|
|
197
|
+
"""The `faults` dashboard widget plugin."""
|
|
198
|
+
|
|
199
|
+
name = "faults"
|
|
200
|
+
placement = WidgetPlacement.DIAGNOSTICS
|
|
201
|
+
|
|
202
|
+
def create(self, context: WidgetContext) -> Widget:
|
|
203
|
+
"""Build the fault diagnostics panel for `context`."""
|
|
204
|
+
return FaultsPanel(context)
|