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,799 @@
|
|
|
1
|
+
"""The grouped Actions list: choose what to do with a task or the project."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass, replace
|
|
7
|
+
from typing import TYPE_CHECKING, ClassVar, cast
|
|
8
|
+
|
|
9
|
+
from rich.cells import cell_len
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
from textual.binding import Binding
|
|
12
|
+
from textual.containers import Horizontal
|
|
13
|
+
from textual.screen import ModalScreen
|
|
14
|
+
from textual.widgets import Button, Label, ListItem, ListView, Rule, Static
|
|
15
|
+
|
|
16
|
+
from shikiru.dashboard.contract import (
|
|
17
|
+
ExtensionEntry,
|
|
18
|
+
PaletteAction,
|
|
19
|
+
PaletteRequest,
|
|
20
|
+
PaletteReturn,
|
|
21
|
+
ProjectRouteChoice,
|
|
22
|
+
TaskPaletteRoute,
|
|
23
|
+
TaskRouteChoice,
|
|
24
|
+
)
|
|
25
|
+
from shikiru.dashboard.covered import Coverable
|
|
26
|
+
from shikiru.dashboard.palette import SUBMIT_ACTION_LABELS
|
|
27
|
+
from shikiru.dashboard.task_picker import TaskPickerScreen
|
|
28
|
+
from shikiru.domain.commands import ResolutionKind
|
|
29
|
+
from shikiru.domain.escalation import EscalationStatus
|
|
30
|
+
from shikiru.errors import ConfigError
|
|
31
|
+
from shikiru.operator.queries import (
|
|
32
|
+
TASK_ACTION_OTHER_ROLE_TAKEN_OVER,
|
|
33
|
+
TASK_ACTION_ROLES,
|
|
34
|
+
OperatorTaskAction,
|
|
35
|
+
)
|
|
36
|
+
from shikiru.ui.keys import key_hint
|
|
37
|
+
from shikiru.ui.popup import (
|
|
38
|
+
POPUP_BODY_CLASS,
|
|
39
|
+
POPUP_CLOSE_CLASS,
|
|
40
|
+
POPUP_CSS,
|
|
41
|
+
POPUP_FRAME_CLASS,
|
|
42
|
+
POPUP_HEADER_CLASS,
|
|
43
|
+
POPUP_RULE_CLASS,
|
|
44
|
+
POPUP_TITLE_CLASS,
|
|
45
|
+
PopupFrame,
|
|
46
|
+
popup_close_label,
|
|
47
|
+
prepare_popup,
|
|
48
|
+
)
|
|
49
|
+
from shikiru.ui.section import GROUP_SEPARATOR, WrappedGroups, grey
|
|
50
|
+
from shikiru.ui.tokens import (
|
|
51
|
+
PRIMARY_TEXT_COLOR,
|
|
52
|
+
SECONDARY_TEXT_COLOR,
|
|
53
|
+
STYLE_BOLD,
|
|
54
|
+
SUCCESS_COLOR,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if TYPE_CHECKING:
|
|
58
|
+
from rich.console import Console, ConsoleOptions, RenderResult
|
|
59
|
+
from textual import events
|
|
60
|
+
from textual.app import App, ComposeResult
|
|
61
|
+
from textual.binding import BindingType
|
|
62
|
+
from textual.screen import Screen
|
|
63
|
+
from textual.timer import Timer
|
|
64
|
+
from textual.worker import Worker
|
|
65
|
+
|
|
66
|
+
from shikiru.dashboard.contract import OnChangeRegistration, ResolutionScreenFactory
|
|
67
|
+
from shikiru.operator.queries import OperatorQueries
|
|
68
|
+
|
|
69
|
+
PopupFactory = Callable[[PaletteRequest], ModalScreen[str | None]]
|
|
70
|
+
|
|
71
|
+
ACTIONS_FRAME = "actions-frame"
|
|
72
|
+
ACTIONS_TITLE = "actions-title"
|
|
73
|
+
ACTIONS_CLOSE_BUTTON = "actions-close"
|
|
74
|
+
ACTIONS_LIST = "actions-list"
|
|
75
|
+
ACTIONS_HINT = "actions-hint"
|
|
76
|
+
ACTIONS_HEADING = "Actions"
|
|
77
|
+
PROJECT_CONTEXT = "Project"
|
|
78
|
+
TASK_GROUP = "Task"
|
|
79
|
+
EXTENSIONS_GROUP = "Extensions"
|
|
80
|
+
PROJECT_GROUP = "Project"
|
|
81
|
+
ELLIPSIS = "\N{HORIZONTAL ELLIPSIS}"
|
|
82
|
+
MORE_LABEL = f"More{ELLIPSIS}"
|
|
83
|
+
FEWER_LABEL = f"Fewer{ELLIPSIS}"
|
|
84
|
+
SWITCH_LABEL = f"Switch task{ELLIPSIS}"
|
|
85
|
+
ANSWER_LABEL = "Answer"
|
|
86
|
+
COMMAND_HISTORY_LABEL = "Command history"
|
|
87
|
+
DIAGNOSTICS_LABEL = "Diagnostics"
|
|
88
|
+
ANSWER_ROW = "answer"
|
|
89
|
+
MORE_ROW = "more"
|
|
90
|
+
SWITCH_ROW = "switch-task"
|
|
91
|
+
ACTION_ROW_PREFIX = "action:"
|
|
92
|
+
ROUTE_ROW_PREFIX = "route:"
|
|
93
|
+
EXTENSION_ROW_PREFIX = "extension:"
|
|
94
|
+
MEANINGS: dict[str, str] = {
|
|
95
|
+
ANSWER_ROW: "Answer the recorded decision",
|
|
96
|
+
PaletteAction.MESSAGE: "Delivered on the next turn",
|
|
97
|
+
PaletteAction.TAKE_OVER: "Stop the turn, work in the pane",
|
|
98
|
+
PaletteAction.HAND_BACK: "Return this role to automation",
|
|
99
|
+
PaletteAction.PAUSE: "Stop after the current turn",
|
|
100
|
+
PaletteAction.RESUME: "Clear the task holds",
|
|
101
|
+
PaletteAction.CANCEL: "Abandon this task",
|
|
102
|
+
PaletteAction.PAUSE_NOW: "Stop the current turn, then hold",
|
|
103
|
+
PaletteAction.PRIORITIZE: "Change the dispatch order",
|
|
104
|
+
PaletteAction.RETRY: "Re-run the current step",
|
|
105
|
+
PaletteAction.RUNG: "Set the level for the next turn",
|
|
106
|
+
PaletteAction.EXPORT: "Write the task evidence to a file",
|
|
107
|
+
PaletteAction.SUBMIT: "Create a new task",
|
|
108
|
+
PaletteAction.STOP: "Stop running turns, keep the state",
|
|
109
|
+
PaletteAction.START: "Start the engine and keep the project session",
|
|
110
|
+
TaskPaletteRoute.COMMANDS: "Pending, applied and rejected commands",
|
|
111
|
+
TaskPaletteRoute.DIAGNOSTICS: "Project health and resources",
|
|
112
|
+
SWITCH_ROW: "Act on another task",
|
|
113
|
+
FEWER_LABEL: "Hide the extra actions",
|
|
114
|
+
}
|
|
115
|
+
ROUTE_LABELS = {
|
|
116
|
+
TaskPaletteRoute.COMMANDS: COMMAND_HISTORY_LABEL,
|
|
117
|
+
TaskPaletteRoute.DIAGNOSTICS: DIAGNOSTICS_LABEL,
|
|
118
|
+
}
|
|
119
|
+
PRIMARY_TASK_ACTIONS = (
|
|
120
|
+
PaletteAction.MESSAGE,
|
|
121
|
+
PaletteAction.TAKE_OVER,
|
|
122
|
+
PaletteAction.HAND_BACK,
|
|
123
|
+
PaletteAction.PAUSE,
|
|
124
|
+
PaletteAction.RESUME,
|
|
125
|
+
PaletteAction.CANCEL,
|
|
126
|
+
)
|
|
127
|
+
EXTRA_TASK_ACTIONS = (
|
|
128
|
+
PaletteAction.PAUSE_NOW,
|
|
129
|
+
PaletteAction.PRIORITIZE,
|
|
130
|
+
PaletteAction.RETRY,
|
|
131
|
+
PaletteAction.RUNG,
|
|
132
|
+
PaletteAction.EXPORT,
|
|
133
|
+
)
|
|
134
|
+
ROLE_ACTIONS = frozenset(
|
|
135
|
+
{
|
|
136
|
+
PaletteAction.TAKE_OVER,
|
|
137
|
+
PaletteAction.HAND_BACK,
|
|
138
|
+
PaletteAction.RUNG,
|
|
139
|
+
PaletteAction.MESSAGE,
|
|
140
|
+
PaletteAction.PEEK,
|
|
141
|
+
}
|
|
142
|
+
)
|
|
143
|
+
TASK_LABEL_WIDTH = max(
|
|
144
|
+
cell_len(label)
|
|
145
|
+
for label in (
|
|
146
|
+
ANSWER_LABEL,
|
|
147
|
+
MORE_LABEL,
|
|
148
|
+
FEWER_LABEL,
|
|
149
|
+
*(SUBMIT_ACTION_LABELS[action] for action in (*PRIMARY_TASK_ACTIONS, *EXTRA_TASK_ACTIONS)),
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
LABEL_GAP = " "
|
|
153
|
+
ROW_INDENT = " "
|
|
154
|
+
TITLE_INDENT = " "
|
|
155
|
+
CONFIRMATION_GLYPH = " \N{CHECK MARK}"
|
|
156
|
+
CONFIRMATION_STYLE = f"{STYLE_BOLD} {SUCCESS_COLOR}"
|
|
157
|
+
MIN_MEANING_CELLS = 8
|
|
158
|
+
MIN_TASK_TITLE_CELLS = 8
|
|
159
|
+
HINT_GAP = " "
|
|
160
|
+
HINT_SELECT = "↑↓ select"
|
|
161
|
+
HINT_OPEN = "Enter open"
|
|
162
|
+
COMPACT_CLASS = "compact"
|
|
163
|
+
COMPACT_MAX_COLUMNS = 48
|
|
164
|
+
COMPACT_MAX_ROWS = 20
|
|
165
|
+
CLOSE_ACTION = "close"
|
|
166
|
+
MOVE_ACTION = "move"
|
|
167
|
+
JUMP_ACTION = "jump"
|
|
168
|
+
SYNC_WORKER = "actions-list-sync"
|
|
169
|
+
CHANGE_WORKER = "actions-list-changes"
|
|
170
|
+
RESOLUTION_NOT_CONFIGURED = "the escalation resolution screen is not configured"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass(frozen=True)
|
|
174
|
+
class ActionRow:
|
|
175
|
+
"""One selectable row: a stable `key`, its `label` and a short grey `meaning`."""
|
|
176
|
+
|
|
177
|
+
key: str
|
|
178
|
+
label: str
|
|
179
|
+
meaning: str = ""
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass(frozen=True)
|
|
183
|
+
class ActionGroup:
|
|
184
|
+
"""A titled group of rows whose labels share one column `label_width` wide."""
|
|
185
|
+
|
|
186
|
+
title: str
|
|
187
|
+
rows: tuple[ActionRow, ...]
|
|
188
|
+
label_width: int
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@dataclass(frozen=True)
|
|
192
|
+
class ListContext:
|
|
193
|
+
"""What the Actions list can offer now; `task_actions` is empty when there is no task."""
|
|
194
|
+
|
|
195
|
+
has_task: bool
|
|
196
|
+
task_actions: frozenset[PaletteAction]
|
|
197
|
+
answerable: bool
|
|
198
|
+
expanded: bool
|
|
199
|
+
project_routes: bool
|
|
200
|
+
diagnostics: bool
|
|
201
|
+
engine_up: bool
|
|
202
|
+
extensions: tuple[ExtensionEntry, ...]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _action_row(action: PaletteAction) -> ActionRow:
|
|
206
|
+
return ActionRow(
|
|
207
|
+
f"{ACTION_ROW_PREFIX}{action.value}", SUBMIT_ACTION_LABELS[action], MEANINGS[action]
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _route_row(route: TaskPaletteRoute) -> ActionRow:
|
|
212
|
+
return ActionRow(f"{ROUTE_ROW_PREFIX}{route.value}", ROUTE_LABELS[route], MEANINGS[route])
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _widest(rows: tuple[ActionRow, ...]) -> int:
|
|
216
|
+
return max((cell_len(row.label) for row in rows), default=0)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _task_group(context: ListContext) -> ActionGroup:
|
|
220
|
+
rows: list[ActionRow] = []
|
|
221
|
+
if context.has_task:
|
|
222
|
+
if context.answerable:
|
|
223
|
+
rows.append(ActionRow(ANSWER_ROW, ANSWER_LABEL, MEANINGS[ANSWER_ROW]))
|
|
224
|
+
rows.extend(_action_row(a) for a in PRIMARY_TASK_ACTIONS if a in context.task_actions)
|
|
225
|
+
extras = tuple(a for a in EXTRA_TASK_ACTIONS if a in context.task_actions)
|
|
226
|
+
if extras:
|
|
227
|
+
rows.append(_more_row(context.expanded, extras))
|
|
228
|
+
if context.expanded:
|
|
229
|
+
rows.extend(_action_row(action) for action in extras)
|
|
230
|
+
return ActionGroup(TASK_GROUP, tuple(rows), TASK_LABEL_WIDTH)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _more_row(expanded: bool, extras: tuple[PaletteAction, ...]) -> ActionRow:
|
|
234
|
+
if expanded:
|
|
235
|
+
return ActionRow(MORE_ROW, FEWER_LABEL, MEANINGS[FEWER_LABEL])
|
|
236
|
+
return ActionRow(
|
|
237
|
+
MORE_ROW,
|
|
238
|
+
MORE_LABEL,
|
|
239
|
+
GROUP_SEPARATOR.join(SUBMIT_ACTION_LABELS[action] for action in extras),
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _extensions_group(context: ListContext) -> ActionGroup:
|
|
244
|
+
rows = tuple(
|
|
245
|
+
ActionRow(f"{EXTENSION_ROW_PREFIX}{index}", extension.label)
|
|
246
|
+
for index, extension in enumerate(context.extensions)
|
|
247
|
+
)
|
|
248
|
+
return ActionGroup(EXTENSIONS_GROUP, rows, _widest(rows))
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _project_group(context: ListContext) -> ActionGroup:
|
|
252
|
+
rows = [
|
|
253
|
+
_action_row(PaletteAction.SUBMIT),
|
|
254
|
+
_action_row(PaletteAction.STOP if context.engine_up else PaletteAction.START),
|
|
255
|
+
]
|
|
256
|
+
if context.project_routes:
|
|
257
|
+
rows.append(_route_row(TaskPaletteRoute.COMMANDS))
|
|
258
|
+
if context.diagnostics:
|
|
259
|
+
rows.append(_route_row(TaskPaletteRoute.DIAGNOSTICS))
|
|
260
|
+
rows.append(ActionRow(SWITCH_ROW, SWITCH_LABEL, MEANINGS[SWITCH_ROW]))
|
|
261
|
+
action_rows = tuple(rows)
|
|
262
|
+
return ActionGroup(PROJECT_GROUP, action_rows, _widest(action_rows))
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def action_groups(context: ListContext) -> tuple[ActionGroup, ...]:
|
|
266
|
+
"""Return the non-empty Task, Extensions and Project groups."""
|
|
267
|
+
groups = (
|
|
268
|
+
_task_group(context),
|
|
269
|
+
_extensions_group(context),
|
|
270
|
+
_project_group(context),
|
|
271
|
+
)
|
|
272
|
+
return tuple(group for group in groups if group.rows)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def row_text(row: ActionRow, label_width: int, width: int, *, confirmed: bool = False) -> Text:
|
|
276
|
+
"""Return `label meaning` on one line of `width` cells; the meaning shrinks or drops first."""
|
|
277
|
+
label = Text(row.label, style=PRIMARY_TEXT_COLOR)
|
|
278
|
+
if confirmed:
|
|
279
|
+
label.append(CONFIRMATION_GLYPH, style=CONFIRMATION_STYLE)
|
|
280
|
+
head = Text.assemble(ROW_INDENT, label)
|
|
281
|
+
column = cell_len(ROW_INDENT) + label_width + cell_len(LABEL_GAP)
|
|
282
|
+
room = width - column
|
|
283
|
+
if not row.meaning or room < MIN_MEANING_CELLS:
|
|
284
|
+
head.truncate(width, overflow="ellipsis")
|
|
285
|
+
return Text.assemble(head, no_wrap=True, overflow="ellipsis")
|
|
286
|
+
head.pad_right(column - cell_len(head.plain))
|
|
287
|
+
meaning = grey(row.meaning)
|
|
288
|
+
meaning.truncate(room, overflow="ellipsis")
|
|
289
|
+
return Text.assemble(head, meaning, no_wrap=True, overflow="ellipsis")
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def list_title(task_id: str | None, task_title: str, width: int | None = None) -> str:
|
|
293
|
+
"""Return `Actions · T-n title` or `Actions · Project`; the title shrinks to fit."""
|
|
294
|
+
if task_id is None:
|
|
295
|
+
return f"{ACTIONS_HEADING}{GROUP_SEPARATOR}{PROJECT_CONTEXT}"
|
|
296
|
+
head = f"{ACTIONS_HEADING}{GROUP_SEPARATOR}{task_id}"
|
|
297
|
+
title = task_title.replace("\n", " ")
|
|
298
|
+
room = None if not width else width - cell_len(head) - 1
|
|
299
|
+
if room is not None and cell_len(title) > room:
|
|
300
|
+
if room < MIN_TASK_TITLE_CELLS:
|
|
301
|
+
return head
|
|
302
|
+
shown = Text(title)
|
|
303
|
+
shown.truncate(room, overflow="ellipsis")
|
|
304
|
+
title = shown.plain
|
|
305
|
+
return f"{head} {title}"
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class _RowText:
|
|
309
|
+
def __init__(self, row: ActionRow, label_width: int, *, confirmed: bool) -> None:
|
|
310
|
+
self._row = row
|
|
311
|
+
self._label_width = label_width
|
|
312
|
+
self._confirmed = confirmed
|
|
313
|
+
|
|
314
|
+
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
315
|
+
del console
|
|
316
|
+
yield row_text(self._row, self._label_width, options.max_width, confirmed=self._confirmed)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
class GroupTitleItem(ListItem):
|
|
320
|
+
def __init__(self, title: str) -> None:
|
|
321
|
+
super().__init__(Static(Text(f"{TITLE_INDENT}{title}"), markup=False), disabled=True)
|
|
322
|
+
self.title = title
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
class ActionItem(ListItem):
|
|
326
|
+
def __init__(self, row: ActionRow, label_width: int, *, confirmed: bool) -> None:
|
|
327
|
+
self._text = Static(_RowText(row, label_width, confirmed=confirmed), markup=False)
|
|
328
|
+
super().__init__(self._text)
|
|
329
|
+
self.row = row
|
|
330
|
+
|
|
331
|
+
def show(self, row: ActionRow, label_width: int, *, confirmed: bool) -> None:
|
|
332
|
+
self.row = row
|
|
333
|
+
self._text.update(_RowText(row, label_width, confirmed=confirmed))
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
class _TitleLabel(Label):
|
|
337
|
+
def __init__(self, refit: Callable[[], None], *, widget_id: str) -> None:
|
|
338
|
+
super().__init__(ACTIONS_HEADING, id=widget_id, classes=POPUP_TITLE_CLASS)
|
|
339
|
+
self._refit = refit
|
|
340
|
+
|
|
341
|
+
def on_resize(self) -> None:
|
|
342
|
+
self._refit()
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
class _ActionList(ListView):
|
|
346
|
+
def on_resize(self) -> None:
|
|
347
|
+
self.call_after_refresh(self._reveal_selection)
|
|
348
|
+
|
|
349
|
+
def watch_index(self, old_index: int | None, new_index: int | None) -> None:
|
|
350
|
+
super().watch_index(old_index, new_index)
|
|
351
|
+
self.call_after_refresh(self._reveal_selection)
|
|
352
|
+
|
|
353
|
+
def _reveal_selection(self) -> None:
|
|
354
|
+
item = self.highlighted_child
|
|
355
|
+
if item is None or self.index is None:
|
|
356
|
+
return
|
|
357
|
+
above = self.index - 1
|
|
358
|
+
heading = self._nodes[above] if above >= 0 else None
|
|
359
|
+
(heading if isinstance(heading, GroupTitleItem) else item).scroll_visible(animate=False)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
class ActionsListScreen(Coverable, ModalScreen[PaletteReturn | None]):
|
|
363
|
+
"""Show the grouped Actions list; rows open popups, views, extensions or another task."""
|
|
364
|
+
|
|
365
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
366
|
+
Binding("escape", CLOSE_ACTION, "Close"),
|
|
367
|
+
Binding("up", f"{MOVE_ACTION}(-1)", "Up", show=False, priority=True),
|
|
368
|
+
Binding("down", f"{MOVE_ACTION}(1)", "Down", show=False, priority=True),
|
|
369
|
+
Binding("home", f"{JUMP_ACTION}(-1)", "First", show=False, priority=True),
|
|
370
|
+
Binding("end", f"{JUMP_ACTION}(1)", "Last", show=False, priority=True),
|
|
371
|
+
]
|
|
372
|
+
DEFAULT_CSS = (
|
|
373
|
+
POPUP_CSS
|
|
374
|
+
+ f"""
|
|
375
|
+
#{ACTIONS_TITLE} {{ text-wrap: nowrap; text-overflow: ellipsis; }}
|
|
376
|
+
#{ACTIONS_LIST} {{
|
|
377
|
+
border: none; padding: 0; background: $surface;
|
|
378
|
+
scrollbar-size-vertical: $scrollbar-width;
|
|
379
|
+
}}
|
|
380
|
+
#{ACTIONS_LIST} > ListItem {{
|
|
381
|
+
height: 1; padding: 0; border: none; background: $surface; color: $primary-text;
|
|
382
|
+
}}
|
|
383
|
+
#{ACTIONS_LIST} > ListItem > Static {{
|
|
384
|
+
width: 100%; height: 1; padding: 0; color: $primary-text;
|
|
385
|
+
text-wrap: nowrap; text-overflow: ellipsis;
|
|
386
|
+
}}
|
|
387
|
+
#{ACTIONS_LIST} > ListItem:disabled, #{ACTIONS_LIST} > ListItem:disabled > Static {{
|
|
388
|
+
color: $secondary-text; text-opacity: 100%; opacity: 100%;
|
|
389
|
+
}}
|
|
390
|
+
#{ACTIONS_LIST} > ListItem.-highlight,
|
|
391
|
+
#{ACTIONS_LIST} > ListItem.-highlight > Static {{
|
|
392
|
+
outline: none; background: $selected-surface; color: $primary-text; text-style: none;
|
|
393
|
+
}}
|
|
394
|
+
#{ACTIONS_LIST}:focus {{ background-tint: transparent; }}
|
|
395
|
+
#{ACTIONS_HINT} {{ height: auto; margin-top: 1; }}
|
|
396
|
+
#{ACTIONS_FRAME}.{COMPACT_CLASS} {{ margin: 0 1; padding: 0 1; }}
|
|
397
|
+
"""
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
def __init__(
|
|
401
|
+
self,
|
|
402
|
+
queries: OperatorQueries,
|
|
403
|
+
open_popup: PopupFactory,
|
|
404
|
+
request: PaletteRequest,
|
|
405
|
+
*,
|
|
406
|
+
on_change: OnChangeRegistration,
|
|
407
|
+
action_confirmation_seconds: float,
|
|
408
|
+
engine_state_refresh_hz: int | None = None,
|
|
409
|
+
task_routes_available: bool = False,
|
|
410
|
+
project_diagnostics_available: bool = False,
|
|
411
|
+
extensions: tuple[ExtensionEntry, ...] = (),
|
|
412
|
+
resolution_screen: ResolutionScreenFactory | None = None,
|
|
413
|
+
) -> None:
|
|
414
|
+
"""Offer the actions for `request`'s task, or the project when it has none."""
|
|
415
|
+
super().__init__()
|
|
416
|
+
prepare_popup(self)
|
|
417
|
+
self._queries = queries
|
|
418
|
+
self._open_popup = open_popup
|
|
419
|
+
self._request = replace(request, action=None)
|
|
420
|
+
self._on_change = on_change
|
|
421
|
+
self._confirmation_seconds = action_confirmation_seconds
|
|
422
|
+
self._engine_state_refresh_hz = engine_state_refresh_hz
|
|
423
|
+
self._task_routes_available = task_routes_available
|
|
424
|
+
self._project_diagnostics_available = project_diagnostics_available
|
|
425
|
+
self._extensions = extensions
|
|
426
|
+
self._resolution_screen = resolution_screen
|
|
427
|
+
self._expanded = False
|
|
428
|
+
self._finishing = False
|
|
429
|
+
self._confirmed: str | None = None
|
|
430
|
+
self._confirmation_timer: Timer | None = None
|
|
431
|
+
self._shown: tuple[tuple[str, str], ...] | None = None
|
|
432
|
+
self._groups: tuple[ActionGroup, ...] = ()
|
|
433
|
+
self._change_worker: Worker[None] | None = None
|
|
434
|
+
self._sync_active = False
|
|
435
|
+
self._sync_pending = False
|
|
436
|
+
self._sync_select_first = False
|
|
437
|
+
self._finish_result: PaletteReturn | None = None
|
|
438
|
+
self._engine_state_timer: Timer | None = None
|
|
439
|
+
self._engine_up: bool | None = None
|
|
440
|
+
|
|
441
|
+
def compose(self) -> ComposeResult:
|
|
442
|
+
"""Render the header, the grouped rows and the hint line."""
|
|
443
|
+
with PopupFrame(widget_id=ACTIONS_FRAME, classes=POPUP_FRAME_CLASS):
|
|
444
|
+
with Horizontal(classes=POPUP_HEADER_CLASS):
|
|
445
|
+
yield _TitleLabel(self._paint_title, widget_id=ACTIONS_TITLE)
|
|
446
|
+
yield Button(
|
|
447
|
+
popup_close_label(), id=ACTIONS_CLOSE_BUTTON, classes=POPUP_CLOSE_CLASS
|
|
448
|
+
)
|
|
449
|
+
yield Rule(classes=POPUP_RULE_CLASS)
|
|
450
|
+
yield _ActionList(id=ACTIONS_LIST, classes=POPUP_BODY_CLASS)
|
|
451
|
+
yield Static(id=ACTIONS_HINT, markup=False)
|
|
452
|
+
|
|
453
|
+
async def on_mount(self) -> None:
|
|
454
|
+
"""Build the rows, select the first one and follow database changes."""
|
|
455
|
+
await self._sync(select_first=True)
|
|
456
|
+
self._apply_compact_layout(self.size.width, self.size.height)
|
|
457
|
+
self._list.focus()
|
|
458
|
+
self.call_after_refresh(self.query_one(f"#{ACTIONS_FRAME}", PopupFrame).content_ready)
|
|
459
|
+
self._change_worker = self.run_worker(
|
|
460
|
+
self._on_change(self._refresh_after_database_change), group=CHANGE_WORKER
|
|
461
|
+
)
|
|
462
|
+
if self._engine_state_refresh_hz is not None:
|
|
463
|
+
self._engine_state_timer = self.set_interval(
|
|
464
|
+
1 / self._engine_state_refresh_hz, self._refresh_engine_state
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
def on_unmount(self) -> None:
|
|
468
|
+
"""Stop following database changes and the confirmation timer."""
|
|
469
|
+
if self._change_worker is not None:
|
|
470
|
+
self._change_worker.cancel()
|
|
471
|
+
if self._confirmation_timer is not None:
|
|
472
|
+
self._confirmation_timer.stop()
|
|
473
|
+
if self._engine_state_timer is not None:
|
|
474
|
+
self._engine_state_timer.stop()
|
|
475
|
+
|
|
476
|
+
def on_resize(self, event: events.Resize) -> None:
|
|
477
|
+
"""Refit the frame, the title and the hint to the new size."""
|
|
478
|
+
self._apply_compact_layout(event.size.width, event.size.height)
|
|
479
|
+
frame = self.query_one(f"#{ACTIONS_FRAME}", PopupFrame)
|
|
480
|
+
frame.reset_preferred_size()
|
|
481
|
+
self.call_after_refresh(frame.content_ready)
|
|
482
|
+
|
|
483
|
+
def _apply_compact_layout(self, width: int, height: int) -> None:
|
|
484
|
+
frame = self.query_one(f"#{ACTIONS_FRAME}", PopupFrame)
|
|
485
|
+
frame.set_class(width <= COMPACT_MAX_COLUMNS or height <= COMPACT_MAX_ROWS, COMPACT_CLASS)
|
|
486
|
+
self.query_one(f"#{ACTIONS_HINT}", Static).update(
|
|
487
|
+
WrappedGroups(
|
|
488
|
+
(
|
|
489
|
+
key_hint(HINT_SELECT, SECONDARY_TEXT_COLOR),
|
|
490
|
+
key_hint(HINT_OPEN, SECONDARY_TEXT_COLOR),
|
|
491
|
+
),
|
|
492
|
+
HINT_GAP,
|
|
493
|
+
)
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
@property
|
|
497
|
+
def _host(self) -> App[None]:
|
|
498
|
+
return cast("App[None]", self.app)
|
|
499
|
+
|
|
500
|
+
@property
|
|
501
|
+
def _list(self) -> ListView:
|
|
502
|
+
return self.query_one(f"#{ACTIONS_LIST}", ListView)
|
|
503
|
+
|
|
504
|
+
@property
|
|
505
|
+
def _selectable(self) -> list[int]:
|
|
506
|
+
return [i for i, item in enumerate(self._list.children) if isinstance(item, ActionItem)]
|
|
507
|
+
|
|
508
|
+
def _list_context(self) -> ListContext:
|
|
509
|
+
task_id = self._request.task_id
|
|
510
|
+
task_exists = task_id is not None and self._queries.get_task_state(task_id) is not None
|
|
511
|
+
engine_up = self._queries.engine_is_up()
|
|
512
|
+
self._engine_up = engine_up
|
|
513
|
+
return ListContext(
|
|
514
|
+
has_task=task_exists,
|
|
515
|
+
task_actions=self._task_actions(task_id) if task_exists else frozenset(),
|
|
516
|
+
answerable=task_exists and self._answerable_escalation_id() is not None,
|
|
517
|
+
expanded=self._expanded,
|
|
518
|
+
project_routes=self._task_routes_available,
|
|
519
|
+
diagnostics=self._project_diagnostics_available,
|
|
520
|
+
engine_up=engine_up,
|
|
521
|
+
extensions=self._extensions,
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
def _refresh_engine_state(self) -> None:
|
|
525
|
+
if self.covered or not self.is_attached:
|
|
526
|
+
return
|
|
527
|
+
engine_up = self._queries.engine_is_up()
|
|
528
|
+
if engine_up != self._engine_up:
|
|
529
|
+
self._engine_up = engine_up
|
|
530
|
+
self._schedule_sync()
|
|
531
|
+
|
|
532
|
+
def _task_actions(self, task_id: str | None) -> frozenset[PaletteAction]:
|
|
533
|
+
if task_id is None:
|
|
534
|
+
return frozenset()
|
|
535
|
+
task = self._queries.get_task_state(task_id)
|
|
536
|
+
if task is None:
|
|
537
|
+
return frozenset()
|
|
538
|
+
enabled = {
|
|
539
|
+
PaletteAction(state.action.value)
|
|
540
|
+
for state in self._queries.task_action_availability(task_id, None)
|
|
541
|
+
if state.enabled
|
|
542
|
+
or (
|
|
543
|
+
state.action is OperatorTaskAction.RESUME
|
|
544
|
+
and bool(task.holds)
|
|
545
|
+
and state.disabled_reason == TASK_ACTION_OTHER_ROLE_TAKEN_OVER
|
|
546
|
+
)
|
|
547
|
+
}
|
|
548
|
+
return frozenset(enabled | {PaletteAction.EXPORT})
|
|
549
|
+
|
|
550
|
+
def _answerable_escalation_id(self) -> str | None:
|
|
551
|
+
task_id = self._request.task_id
|
|
552
|
+
for item in self._queries.list_escalations():
|
|
553
|
+
escalation = item.escalation
|
|
554
|
+
if escalation.task_id != task_id or escalation.status is not EscalationStatus.OPEN:
|
|
555
|
+
continue
|
|
556
|
+
answerable = any(option.label is ResolutionKind.ANSWER for option in escalation.options)
|
|
557
|
+
if (
|
|
558
|
+
answerable
|
|
559
|
+
and self._queries.escalation_resolution_disabled_reason(escalation.id) is None
|
|
560
|
+
):
|
|
561
|
+
return escalation.id
|
|
562
|
+
return None
|
|
563
|
+
|
|
564
|
+
async def _sync(self, *, select_first: bool = False) -> None:
|
|
565
|
+
if not self._content_mounted():
|
|
566
|
+
return
|
|
567
|
+
groups = action_groups(self._list_context())
|
|
568
|
+
self._paint_title()
|
|
569
|
+
layout = tuple((group.title, row.key) for group in groups for row in group.rows)
|
|
570
|
+
keys = [key for _, key in layout]
|
|
571
|
+
selection = self._selected_key()
|
|
572
|
+
position = self._selected_position()
|
|
573
|
+
if layout != self._shown:
|
|
574
|
+
self._shown = None
|
|
575
|
+
await self._rebuild(groups)
|
|
576
|
+
if not self._content_mounted():
|
|
577
|
+
return
|
|
578
|
+
self._shown = layout
|
|
579
|
+
else:
|
|
580
|
+
self._repaint(groups)
|
|
581
|
+
if select_first:
|
|
582
|
+
self._select_row(0)
|
|
583
|
+
elif selection in keys:
|
|
584
|
+
self._select_row(keys.index(selection))
|
|
585
|
+
else:
|
|
586
|
+
self._select_row(position)
|
|
587
|
+
|
|
588
|
+
def _finish(self, result: PaletteReturn | None) -> None:
|
|
589
|
+
self._finishing = True
|
|
590
|
+
self._finish_result = result
|
|
591
|
+
if not self._sync_active:
|
|
592
|
+
self.dismiss(result)
|
|
593
|
+
|
|
594
|
+
def _schedule_sync(self, *, select_first: bool = False) -> None:
|
|
595
|
+
if self._finishing:
|
|
596
|
+
return
|
|
597
|
+
self._sync_pending = True
|
|
598
|
+
self._sync_select_first = self._sync_select_first or select_first
|
|
599
|
+
if self._sync_active:
|
|
600
|
+
return
|
|
601
|
+
self._sync_active = True
|
|
602
|
+
self.run_worker(self._sync_until_current, group=SYNC_WORKER)
|
|
603
|
+
|
|
604
|
+
async def _sync_until_current(self) -> None:
|
|
605
|
+
try:
|
|
606
|
+
while self._sync_pending and not self._finishing:
|
|
607
|
+
select_first = self._sync_select_first
|
|
608
|
+
self._sync_pending = False
|
|
609
|
+
self._sync_select_first = False
|
|
610
|
+
await self._sync(select_first=select_first)
|
|
611
|
+
if self._finishing:
|
|
612
|
+
self.dismiss(self._finish_result)
|
|
613
|
+
finally:
|
|
614
|
+
self._sync_active = False
|
|
615
|
+
|
|
616
|
+
def _selected_position(self) -> int:
|
|
617
|
+
indices = self._selectable
|
|
618
|
+
index = self._list.index
|
|
619
|
+
return indices.index(index) if index in indices else 0
|
|
620
|
+
|
|
621
|
+
async def _rebuild(self, groups: tuple[ActionGroup, ...]) -> None:
|
|
622
|
+
await self._list.clear()
|
|
623
|
+
if not self._content_mounted():
|
|
624
|
+
return
|
|
625
|
+
items: list[ListItem] = []
|
|
626
|
+
for group in groups:
|
|
627
|
+
items.append(GroupTitleItem(group.title))
|
|
628
|
+
items.extend(
|
|
629
|
+
ActionItem(row, group.label_width, confirmed=self._is_confirmed(row))
|
|
630
|
+
for row in group.rows
|
|
631
|
+
)
|
|
632
|
+
await self._list.extend(items)
|
|
633
|
+
|
|
634
|
+
def _repaint(self, groups: tuple[ActionGroup, ...]) -> None:
|
|
635
|
+
widths = {row.key: group.label_width for group in groups for row in group.rows}
|
|
636
|
+
rows = {row.key: row for group in groups for row in group.rows}
|
|
637
|
+
for item in self._list.children:
|
|
638
|
+
if isinstance(item, ActionItem):
|
|
639
|
+
key = item.row.key
|
|
640
|
+
item.show(rows[key], widths[key], confirmed=self._is_confirmed(rows[key]))
|
|
641
|
+
|
|
642
|
+
def _is_confirmed(self, row: ActionRow) -> bool:
|
|
643
|
+
return self._confirmed is not None and row.key == f"{ACTION_ROW_PREFIX}{self._confirmed}"
|
|
644
|
+
|
|
645
|
+
def _selected_key(self) -> str | None:
|
|
646
|
+
item = self._list.highlighted_child
|
|
647
|
+
return item.row.key if isinstance(item, ActionItem) else None
|
|
648
|
+
|
|
649
|
+
def _select_row(self, position: int) -> None:
|
|
650
|
+
indices = self._selectable
|
|
651
|
+
if indices:
|
|
652
|
+
self._list.index = indices[min(max(position, 0), len(indices) - 1)]
|
|
653
|
+
|
|
654
|
+
def _content_mounted(self) -> bool:
|
|
655
|
+
return (
|
|
656
|
+
self.is_attached
|
|
657
|
+
and len(self.query(f"#{ACTIONS_TITLE}")) > 0
|
|
658
|
+
and len(self.query(f"#{ACTIONS_LIST}")) > 0
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
def _paint_title(self) -> None:
|
|
662
|
+
if not self._content_mounted():
|
|
663
|
+
return
|
|
664
|
+
task_id = self._request.task_id
|
|
665
|
+
task = self._queries.get_task_state(task_id) if task_id is not None else None
|
|
666
|
+
title = self.query_one(f"#{ACTIONS_TITLE}", Label)
|
|
667
|
+
if task_id is None or task is None:
|
|
668
|
+
title.update(list_title(None, ""))
|
|
669
|
+
return
|
|
670
|
+
title.update(list_title(task_id, task.title, title.size.width))
|
|
671
|
+
|
|
672
|
+
def action_move(self, step: int) -> None:
|
|
673
|
+
"""Move the selection by `step` rows, wrapping from the last row to the first."""
|
|
674
|
+
indices = self._selectable
|
|
675
|
+
if not indices:
|
|
676
|
+
return
|
|
677
|
+
current = self._list.index
|
|
678
|
+
position = indices.index(current) if current in indices else -1
|
|
679
|
+
self._list.index = indices[(position + step) % len(indices)]
|
|
680
|
+
|
|
681
|
+
def action_jump(self, direction: int) -> None:
|
|
682
|
+
"""Select the first (`-1`) or last (`1`) row."""
|
|
683
|
+
indices = self._selectable
|
|
684
|
+
if indices:
|
|
685
|
+
self._list.index = indices[0 if direction < 0 else -1]
|
|
686
|
+
if direction < 0:
|
|
687
|
+
self._list.scroll_home(animate=False)
|
|
688
|
+
|
|
689
|
+
def action_close(self) -> None:
|
|
690
|
+
"""Close the list without choosing anything."""
|
|
691
|
+
self._finish(None)
|
|
692
|
+
|
|
693
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
694
|
+
"""Close the list from its close control."""
|
|
695
|
+
if event.button.id == ACTIONS_CLOSE_BUTTON:
|
|
696
|
+
self._finish(None)
|
|
697
|
+
|
|
698
|
+
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
|
699
|
+
"""Open what the chosen row names."""
|
|
700
|
+
if isinstance(event.item, ActionItem):
|
|
701
|
+
self._open_row(event.item.row.key)
|
|
702
|
+
|
|
703
|
+
def _open_row(self, key: str) -> None:
|
|
704
|
+
if key == MORE_ROW:
|
|
705
|
+
self._expanded = not self._expanded
|
|
706
|
+
self._schedule_sync()
|
|
707
|
+
elif key == SWITCH_ROW:
|
|
708
|
+
self._push_over(
|
|
709
|
+
TaskPickerScreen(self._queries, self._request.task_id), self._task_chosen
|
|
710
|
+
)
|
|
711
|
+
elif key == ANSWER_ROW:
|
|
712
|
+
self._open_resolution()
|
|
713
|
+
elif key.startswith(ACTION_ROW_PREFIX):
|
|
714
|
+
action = PaletteAction(key.removeprefix(ACTION_ROW_PREFIX))
|
|
715
|
+
self._push_over(self._open_popup(self._popup_request(action)), self._popup_closed)
|
|
716
|
+
elif key.startswith(ROUTE_ROW_PREFIX):
|
|
717
|
+
route = TaskPaletteRoute(key.removeprefix(ROUTE_ROW_PREFIX))
|
|
718
|
+
if route in {TaskPaletteRoute.COMMANDS, TaskPaletteRoute.DIAGNOSTICS}:
|
|
719
|
+
self._finish(ProjectRouteChoice(route))
|
|
720
|
+
else:
|
|
721
|
+
task_id = self._request.task_id
|
|
722
|
+
if task_id is not None:
|
|
723
|
+
self._finish(TaskRouteChoice(task_id, route))
|
|
724
|
+
elif key.startswith(EXTENSION_ROW_PREFIX):
|
|
725
|
+
self._finish(self._extensions[int(key.removeprefix(EXTENSION_ROW_PREFIX))])
|
|
726
|
+
|
|
727
|
+
def _push_over[Result](
|
|
728
|
+
self, screen: Screen[Result], callback: Callable[[Result | None], None]
|
|
729
|
+
) -> None:
|
|
730
|
+
self.covered = True
|
|
731
|
+
|
|
732
|
+
def uncovered(result: Result | None) -> None:
|
|
733
|
+
self.covered = False
|
|
734
|
+
self.refresh(layout=True)
|
|
735
|
+
callback(result)
|
|
736
|
+
|
|
737
|
+
self._host.push_screen(screen, uncovered)
|
|
738
|
+
|
|
739
|
+
def _popup_request(self, action: PaletteAction) -> PaletteRequest:
|
|
740
|
+
return replace(self._request, action=action.value, role=self._role_for(action))
|
|
741
|
+
|
|
742
|
+
def _role_for(self, action: PaletteAction) -> str | None:
|
|
743
|
+
task_id = self._request.task_id
|
|
744
|
+
if self._request.role is not None or task_id is None or action not in ROLE_ACTIONS:
|
|
745
|
+
return self._request.role
|
|
746
|
+
selected = OperatorTaskAction(action.value)
|
|
747
|
+
default = self._queries.task_action_role(task_id)
|
|
748
|
+
for role in (default, *(role for role in TASK_ACTION_ROLES if role is not default)):
|
|
749
|
+
state = next(
|
|
750
|
+
item
|
|
751
|
+
for item in self._queries.task_action_availability(task_id, role)
|
|
752
|
+
if item.action is selected
|
|
753
|
+
)
|
|
754
|
+
if state.enabled:
|
|
755
|
+
return role.value if role is not default else None
|
|
756
|
+
return None
|
|
757
|
+
|
|
758
|
+
def _popup_closed(self, queued: str | None) -> None:
|
|
759
|
+
self._schedule_sync()
|
|
760
|
+
if queued is None:
|
|
761
|
+
return
|
|
762
|
+
self._confirmed = queued
|
|
763
|
+
if self._confirmation_timer is not None:
|
|
764
|
+
self._confirmation_timer.stop()
|
|
765
|
+
self._confirmation_timer = self.set_timer(
|
|
766
|
+
self._confirmation_seconds, self._end_confirmation
|
|
767
|
+
)
|
|
768
|
+
|
|
769
|
+
def _end_confirmation(self) -> None:
|
|
770
|
+
if self._confirmation_timer is not None:
|
|
771
|
+
self._confirmation_timer.stop()
|
|
772
|
+
self._confirmed = None
|
|
773
|
+
self._confirmation_timer = None
|
|
774
|
+
self._schedule_sync()
|
|
775
|
+
|
|
776
|
+
def _task_chosen(self, task_id: str | None) -> None:
|
|
777
|
+
if task_id is None:
|
|
778
|
+
return
|
|
779
|
+
if task_id != self._request.task_id:
|
|
780
|
+
self._request = replace(self._request, task_id=task_id, role=None)
|
|
781
|
+
self._end_confirmation()
|
|
782
|
+
self._expanded = False
|
|
783
|
+
self._schedule_sync(select_first=True)
|
|
784
|
+
|
|
785
|
+
def _open_resolution(self) -> None:
|
|
786
|
+
escalation_id = self._answerable_escalation_id()
|
|
787
|
+
if escalation_id is None:
|
|
788
|
+
return
|
|
789
|
+
if self._resolution_screen is None:
|
|
790
|
+
raise ConfigError(RESOLUTION_NOT_CONFIGURED)
|
|
791
|
+
self._push_over(self._resolution_screen(escalation_id), self._resolution_closed)
|
|
792
|
+
|
|
793
|
+
def _resolution_closed(self, sent: bool | None) -> None:
|
|
794
|
+
if sent:
|
|
795
|
+
self._finish(None)
|
|
796
|
+
|
|
797
|
+
async def _refresh_after_database_change(self) -> None:
|
|
798
|
+
if self.is_attached:
|
|
799
|
+
self._schedule_sync()
|