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,216 @@
|
|
|
1
|
+
"""Operator-facing summary and evidence of an open engine fault, from what was recorded."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import shlex
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from shikiru.dashboard.contract import EngineFaultView, FaultDetailRow
|
|
12
|
+
from shikiru.engine.pull_request_closing import CLOSE_FAULT_SCOPE
|
|
13
|
+
from shikiru.engine.push_service import PUSH_FAULT_SCOPE, REMOTE_CHECK_FAULT_SCOPE
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from shikiru.domain.faults import FaultStreak
|
|
17
|
+
|
|
18
|
+
_RECONCILE_SCOPE_PREFIX = "reconcile "
|
|
19
|
+
_NOT_RECORDED = "not recorded"
|
|
20
|
+
_KEY_SEPARATOR = "\x00"
|
|
21
|
+
_CAUSE_LIMIT = 160
|
|
22
|
+
_ELLIPSIS = "\N{HORIZONTAL ELLIPSIS}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Recovery(StrEnum):
|
|
26
|
+
"""How Shikiru itself retries a fault: the distinctions the Operator plans around."""
|
|
27
|
+
|
|
28
|
+
BACKOFF = "backoff"
|
|
29
|
+
BOUNDED = "bounded"
|
|
30
|
+
STARTUP_ONLY = "startup-only"
|
|
31
|
+
UNKNOWN = "unknown"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_STATUS = {
|
|
35
|
+
Recovery.BACKOFF: "Retrying automatically",
|
|
36
|
+
Recovery.BOUNDED: "Retrying a few times, then waiting for an engine restart",
|
|
37
|
+
Recovery.STARTUP_ONLY: "Retried only at the next engine start",
|
|
38
|
+
Recovery.UNKNOWN: "Retry timing unknown",
|
|
39
|
+
}
|
|
40
|
+
_UNEXPECTED_BACKOFF_STATUS = "Unexpected error · retrying automatically"
|
|
41
|
+
_UNRECOVERABLE_STEP = "Inspect the traceback and fix the unexpected error"
|
|
42
|
+
_UNKNOWN_CAUSE = "unknown: no error text was recorded"
|
|
43
|
+
_UNKNOWN_IMPACT = "not recorded for this kind of fault"
|
|
44
|
+
_UNKNOWN_STEP = "Read the cause in Details; no retry is known for this fault"
|
|
45
|
+
_RESTART_STEP = "Fix the cause, then restart the engine to retry sooner"
|
|
46
|
+
_LEGACY_COMMAND_FAILURE = re.compile(
|
|
47
|
+
r"(?:CommandFailed: )?command .*?\) exited (-?\d+):\s*(.*)\Z", re.DOTALL
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class RetryPolicy:
|
|
53
|
+
"""The configured retry numbers the recovery descriptions quote."""
|
|
54
|
+
|
|
55
|
+
backoff_initial_seconds: float
|
|
56
|
+
backoff_max_seconds: float
|
|
57
|
+
close_attempts: int
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _target(scope: str, prefix: str) -> str | None:
|
|
61
|
+
head = f"{prefix} "
|
|
62
|
+
return scope.removeprefix(head) if scope.startswith(head) else None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def recovery_of(scope: str, *, recoverable: bool) -> Recovery:
|
|
66
|
+
"""Return how `scope` is retried; a scope this view does not know retries at unknown times.
|
|
67
|
+
|
|
68
|
+
Whether the error is `recoverable` (environmental) does not change who schedules the retry,
|
|
69
|
+
except that the pull request close loop only retries environmental errors.
|
|
70
|
+
"""
|
|
71
|
+
if _target(scope, PUSH_FAULT_SCOPE) is not None or _target(scope, REMOTE_CHECK_FAULT_SCOPE):
|
|
72
|
+
return Recovery.BACKOFF
|
|
73
|
+
if scope == CLOSE_FAULT_SCOPE:
|
|
74
|
+
return Recovery.BOUNDED if recoverable else Recovery.UNKNOWN
|
|
75
|
+
if scope.startswith(_RECONCILE_SCOPE_PREFIX):
|
|
76
|
+
return Recovery.STARTUP_ONLY
|
|
77
|
+
return Recovery.UNKNOWN
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _impact_and_step(scope: str) -> tuple[str, str] | None:
|
|
81
|
+
base = _target(scope, PUSH_FAULT_SCOPE)
|
|
82
|
+
if base is not None:
|
|
83
|
+
return (
|
|
84
|
+
f"commits on {base} stay local",
|
|
85
|
+
"Fix the cause git reports; Shikiru never forces a push",
|
|
86
|
+
)
|
|
87
|
+
if _target(scope, REMOTE_CHECK_FAULT_SCOPE) is not None:
|
|
88
|
+
return (
|
|
89
|
+
"no base is pushed until the remote can be read",
|
|
90
|
+
"Fix the git remote configuration, for example a malformed .git/config",
|
|
91
|
+
)
|
|
92
|
+
if scope == CLOSE_FAULT_SCOPE:
|
|
93
|
+
return "the pull request is not closed", _RESTART_STEP
|
|
94
|
+
if scope.startswith(_RECONCILE_SCOPE_PREFIX):
|
|
95
|
+
return "a startup recovery step did not complete", _RESTART_STEP
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _policy_text(recovery: Recovery, policy: RetryPolicy) -> str:
|
|
100
|
+
backoff = f"{policy.backoff_initial_seconds:g}s doubling up to {policy.backoff_max_seconds:g}s"
|
|
101
|
+
return {
|
|
102
|
+
Recovery.BACKOFF: (
|
|
103
|
+
f"background push service logs failures and retries with backoff ({backoff}) "
|
|
104
|
+
"while the engine runs, even if the guard reaches its unexpected fault limit"
|
|
105
|
+
),
|
|
106
|
+
Recovery.BOUNDED: (
|
|
107
|
+
f"up to {policy.close_attempts} attempts with backoff, {backoff}; "
|
|
108
|
+
"then again only at the next engine start"
|
|
109
|
+
),
|
|
110
|
+
Recovery.STARTUP_ONLY: "attempted again only at the next engine start",
|
|
111
|
+
Recovery.UNKNOWN: "not recorded for this kind of fault",
|
|
112
|
+
}[recovery]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _is_logged(scope: str) -> bool:
|
|
116
|
+
return (
|
|
117
|
+
_target(scope, PUSH_FAULT_SCOPE) is not None
|
|
118
|
+
or _target(scope, REMOTE_CHECK_FAULT_SCOPE) is not None
|
|
119
|
+
or scope.startswith(_RECONCILE_SCOPE_PREFIX)
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _shorten(text: str) -> str:
|
|
124
|
+
one_line = " ".join(text.split())
|
|
125
|
+
return one_line if len(one_line) <= _CAUSE_LIMIT else one_line[: _CAUSE_LIMIT - 1] + _ELLIPSIS
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _recorded_error(fault: FaultStreak) -> str:
|
|
129
|
+
if fault.detail is not None:
|
|
130
|
+
return fault.detail
|
|
131
|
+
return fault.message.removeprefix(f"{fault.scope} failed: ")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _legacy_command_cause(error: str) -> str | None:
|
|
135
|
+
match = _LEGACY_COMMAND_FAILURE.fullmatch(error)
|
|
136
|
+
if match is None:
|
|
137
|
+
return None
|
|
138
|
+
exit_code, stderr = match.groups()
|
|
139
|
+
return f"exit {exit_code}: {stderr.strip()}" if stderr.strip() else f"exit {exit_code}"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _cause(fault: FaultStreak) -> str:
|
|
143
|
+
if fault.exit_code is not None:
|
|
144
|
+
stderr = (fault.stderr or "").strip()
|
|
145
|
+
return _shorten(
|
|
146
|
+
f"exit {fault.exit_code}: {stderr}" if stderr else f"exit {fault.exit_code}"
|
|
147
|
+
)
|
|
148
|
+
error = _recorded_error(fault)
|
|
149
|
+
return _shorten(_legacy_command_cause(error) or error) or _UNKNOWN_CAUSE
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _command_rows(fault: FaultStreak) -> tuple[FaultDetailRow, ...]:
|
|
153
|
+
command = _NOT_RECORDED if fault.command is None else shlex.join(fault.command)
|
|
154
|
+
exit_code = _NOT_RECORDED if fault.exit_code is None else str(fault.exit_code)
|
|
155
|
+
return (
|
|
156
|
+
FaultDetailRow("Command", command),
|
|
157
|
+
FaultDetailRow("Exit code", exit_code),
|
|
158
|
+
FaultDetailRow("Stderr", _NOT_RECORDED if fault.stderr is None else fault.stderr.strip()),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _details(
|
|
163
|
+
fault: FaultStreak,
|
|
164
|
+
recovery: Recovery,
|
|
165
|
+
policy: RetryPolicy,
|
|
166
|
+
*,
|
|
167
|
+
log_path: str,
|
|
168
|
+
state_path: str,
|
|
169
|
+
) -> tuple[FaultDetailRow, ...]:
|
|
170
|
+
logged = (
|
|
171
|
+
(FaultDetailRow("Log", f"{log_path} (traceback of each failed attempt)"),)
|
|
172
|
+
if _is_logged(fault.scope)
|
|
173
|
+
else ()
|
|
174
|
+
)
|
|
175
|
+
return (
|
|
176
|
+
FaultDetailRow("Error", _recorded_error(fault).strip() or _UNKNOWN_CAUSE),
|
|
177
|
+
*_command_rows(fault),
|
|
178
|
+
FaultDetailRow("First seen", fault.first_seen.isoformat()),
|
|
179
|
+
FaultDetailRow("Latest", fault.last_seen.isoformat()),
|
|
180
|
+
FaultDetailRow("Failures", str(fault.repeat)),
|
|
181
|
+
FaultDetailRow(
|
|
182
|
+
"Classification",
|
|
183
|
+
"environmental failure" if fault.recoverable else "unexpected software error",
|
|
184
|
+
),
|
|
185
|
+
FaultDetailRow("Retry policy", _policy_text(recovery, policy)),
|
|
186
|
+
FaultDetailRow("Next retry", _NOT_RECORDED),
|
|
187
|
+
FaultDetailRow("Traceback", _NOT_RECORDED if fault.traceback is None else fault.traceback),
|
|
188
|
+
FaultDetailRow("Event", f"#{fault.event_id} in {state_path}"),
|
|
189
|
+
*logged,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def engine_fault_view(
|
|
194
|
+
fault: FaultStreak, policy: RetryPolicy, *, log_path: str, state_path: str
|
|
195
|
+
) -> EngineFaultView:
|
|
196
|
+
"""Describe the open `fault` as a card summary plus the evidence recorded for it."""
|
|
197
|
+
recovery = recovery_of(fault.scope, recoverable=fault.recoverable)
|
|
198
|
+
known = _impact_and_step(fault.scope)
|
|
199
|
+
impact, step = known if known is not None else (_UNKNOWN_IMPACT, _UNKNOWN_STEP)
|
|
200
|
+
if not fault.recoverable:
|
|
201
|
+
step = _UNRECOVERABLE_STEP
|
|
202
|
+
return EngineFaultView(
|
|
203
|
+
key=f"{fault.task_id or ''}{_KEY_SEPARATOR}{fault.scope}",
|
|
204
|
+
task_id=fault.task_id,
|
|
205
|
+
scope=fault.scope,
|
|
206
|
+
status=(
|
|
207
|
+
_UNEXPECTED_BACKOFF_STATUS
|
|
208
|
+
if recovery is Recovery.BACKOFF and not fault.recoverable
|
|
209
|
+
else _STATUS[recovery]
|
|
210
|
+
),
|
|
211
|
+
impact=impact,
|
|
212
|
+
cause=_cause(fault),
|
|
213
|
+
next_step=step,
|
|
214
|
+
needs_action=recovery is not Recovery.BACKOFF or not fault.recoverable,
|
|
215
|
+
details=_details(fault, recovery, policy, log_path=log_path, state_path=state_path),
|
|
216
|
+
)
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Bodies of the Results and Decisions folds: grey labels, white values, one row per fact."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from rich.console import Group
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
from shikiru.dashboard.briefing_view import (
|
|
13
|
+
BRANCH_ARROW,
|
|
14
|
+
BRANCH_LABEL,
|
|
15
|
+
COMMITS_LABEL,
|
|
16
|
+
MINUS,
|
|
17
|
+
PR_LABEL,
|
|
18
|
+
RISKS_LABEL,
|
|
19
|
+
SUMMARY_LABEL,
|
|
20
|
+
TESTS_LABEL,
|
|
21
|
+
branch_row,
|
|
22
|
+
decision_rows,
|
|
23
|
+
live_unavailable_rows,
|
|
24
|
+
pull_request_groups,
|
|
25
|
+
tests_groups,
|
|
26
|
+
)
|
|
27
|
+
from shikiru.domain.acceptance import request_without_criteria_section
|
|
28
|
+
from shikiru.domain.decisions import Decider
|
|
29
|
+
from shikiru.domain.results import ResultOutcome
|
|
30
|
+
from shikiru.ui.section import BOLD_STEEL, Row, grey, labelled_rows
|
|
31
|
+
from shikiru.ui.tokens import (
|
|
32
|
+
ALERT_COLOR,
|
|
33
|
+
AMBER,
|
|
34
|
+
STEEL_BLUE,
|
|
35
|
+
SUCCESS_COLOR,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
|
|
40
|
+
|
|
41
|
+
from shikiru.dashboard.contract import TaskDetailView
|
|
42
|
+
from shikiru.domain.briefing import ProducedNumbers
|
|
43
|
+
from shikiru.domain.decisions import DecisionEntry
|
|
44
|
+
from shikiru.domain.results import PlanChange, TaskResults
|
|
45
|
+
|
|
46
|
+
PRIORITY_WORDS = {0: "standard", 1: "high", -1: "low"}
|
|
47
|
+
CHANGES_LABEL = "Changes"
|
|
48
|
+
PLAN_LABEL = "Plan"
|
|
49
|
+
OPEN_LABEL = "Open"
|
|
50
|
+
NOTHING_RECORDED = "Nothing is recorded yet."
|
|
51
|
+
CRITERIA_LABEL = "Acceptance criteria"
|
|
52
|
+
NO_CRITERIA = "No validated acceptance criteria are recorded."
|
|
53
|
+
CRITERION_LEAD = "\N{BULLET} "
|
|
54
|
+
PATH_LABEL = "Path"
|
|
55
|
+
HOME_MARK = "~"
|
|
56
|
+
MODE_LABEL = "Mode"
|
|
57
|
+
SUBMITTED_LABEL = "Submitted"
|
|
58
|
+
REQUEUED_LABEL = "Requeued"
|
|
59
|
+
STATUS_REASON_LABEL = "Status reason"
|
|
60
|
+
PRIORITY_LABEL = "Priority"
|
|
61
|
+
MORE_COMMITS = "+{count} more"
|
|
62
|
+
ADDED_TASK = "added {id}"
|
|
63
|
+
CLOCK_FORMAT = "%H:%M"
|
|
64
|
+
TIME_GAP = " "
|
|
65
|
+
DECIDER_WIDTH = max(len(decider.value) for decider in Decider)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _counted(count: int, noun: str) -> str:
|
|
69
|
+
return f"{count} {noun}{'' if count == 1 else 's'}"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _stacked(label: str, lines: list[Text]) -> list[Row]:
|
|
73
|
+
return [(grey(label if index == 0 else ""), (line,)) for index, line in enumerate(lines)]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _commit_rows(results: TaskResults) -> list[Row]:
|
|
77
|
+
lines = [
|
|
78
|
+
Text.assemble(Text(commit.sha, style=STEEL_BLUE), TIME_GAP, commit.subject)
|
|
79
|
+
for commit in results.commits
|
|
80
|
+
]
|
|
81
|
+
if results.more_commits:
|
|
82
|
+
lines.append(grey(MORE_COMMITS.format(count=results.more_commits)))
|
|
83
|
+
return _stacked(COMMITS_LABEL, lines)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _changes_groups(produced: ProducedNumbers | None) -> tuple[Text, ...]:
|
|
87
|
+
if produced is None:
|
|
88
|
+
return ()
|
|
89
|
+
return (
|
|
90
|
+
Text.assemble(
|
|
91
|
+
_counted(produced.files, "file"),
|
|
92
|
+
TIME_GAP,
|
|
93
|
+
(f"+{produced.insertions}", SUCCESS_COLOR),
|
|
94
|
+
" ",
|
|
95
|
+
(f"{MINUS}{produced.deletions}", ALERT_COLOR),
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _plan_lines(change: PlanChange) -> list[Text]:
|
|
101
|
+
added = [
|
|
102
|
+
Text(ADDED_TASK.format(id=" ".join(filter(None, task))), style=SUCCESS_COLOR)
|
|
103
|
+
for task in change.new_tasks
|
|
104
|
+
]
|
|
105
|
+
return [*added, *(Text(scope) for scope in change.scope_changes)]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _summary_rows(results: TaskResults) -> list[Row]:
|
|
109
|
+
if results.plan_summary is None:
|
|
110
|
+
return []
|
|
111
|
+
return [(grey(SUMMARY_LABEL), (Text(results.plan_summary),))]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _results_rows(results: TaskResults) -> list[Row]:
|
|
115
|
+
optional = (
|
|
116
|
+
(CHANGES_LABEL, _changes_groups(results.produced)),
|
|
117
|
+
(TESTS_LABEL, tests_groups(results.tests)),
|
|
118
|
+
(PR_LABEL, pull_request_groups(results.pull_request)),
|
|
119
|
+
)
|
|
120
|
+
plan = _stacked(
|
|
121
|
+
PLAN_LABEL, [line for change in results.plan_changes for line in _plan_lines(change)]
|
|
122
|
+
)
|
|
123
|
+
lead = [*plan, *_summary_rows(results)] if results.plan_summary is not None else []
|
|
124
|
+
return [
|
|
125
|
+
*live_unavailable_rows(results),
|
|
126
|
+
*lead,
|
|
127
|
+
*branch_row(results),
|
|
128
|
+
*_commit_rows(results),
|
|
129
|
+
*((grey(label), groups) for label, groups in optional if groups),
|
|
130
|
+
*([] if lead else plan),
|
|
131
|
+
*_stacked(RISKS_LABEL, [Text(risk, style=AMBER) for risk in results.residual_risks]),
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _body(width: int, rows: list[Row], notes: list[Text]) -> RenderableType:
|
|
136
|
+
if not rows:
|
|
137
|
+
return Group(*notes, grey(NOTHING_RECORDED))
|
|
138
|
+
label_width = max(len(label.plain) for label, _ in rows)
|
|
139
|
+
return Group(*notes, labelled_rows(rows, width, label_width))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass(frozen=True)
|
|
143
|
+
class ResultsBody:
|
|
144
|
+
"""The Results fold body: where the work went, what it contains and what is left open."""
|
|
145
|
+
|
|
146
|
+
results: TaskResults | None
|
|
147
|
+
|
|
148
|
+
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
149
|
+
"""Yield the so-far note when work is in progress, then the labelled rows."""
|
|
150
|
+
del console
|
|
151
|
+
results = self.results
|
|
152
|
+
rows = _results_rows(results) if results is not None else []
|
|
153
|
+
so_far = results is not None and results.outcome is ResultOutcome.SO_FAR
|
|
154
|
+
yield _body(
|
|
155
|
+
options.max_width, rows, [grey(ResultOutcome.SO_FAR.value)] if so_far and rows else []
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _decision_row(entry: DecisionEntry) -> Row:
|
|
160
|
+
decider = Text(entry.decider.value.ljust(DECIDER_WIDTH), style=STEEL_BLUE)
|
|
161
|
+
label = Text.assemble(grey(entry.at.strftime(CLOCK_FORMAT)), TIME_GAP, decider)
|
|
162
|
+
return label, (
|
|
163
|
+
Text("\n").join([Text(entry.headline), *(grey(line) for line in entry.details)]),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _decision_rows(view: TaskDetailView) -> list[Row]:
|
|
168
|
+
rows = decision_rows(view, OPEN_LABEL)
|
|
169
|
+
if view.decisions is not None:
|
|
170
|
+
rows.extend(_decision_row(entry) for entry in view.decisions.entries)
|
|
171
|
+
return rows
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass(frozen=True)
|
|
175
|
+
class DecisionsBody:
|
|
176
|
+
"""The Decisions fold body: the open question, then every decision oldest first."""
|
|
177
|
+
|
|
178
|
+
view: TaskDetailView
|
|
179
|
+
|
|
180
|
+
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
181
|
+
"""Yield the labelled rows in order, or the nothing-recorded note."""
|
|
182
|
+
del console
|
|
183
|
+
yield _body(options.max_width, _decision_rows(self.view), [])
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@dataclass(frozen=True)
|
|
187
|
+
class DescriptionBody:
|
|
188
|
+
"""The Task description fold body: the request, then the acceptance criteria as a list.
|
|
189
|
+
|
|
190
|
+
When the criteria are listed, the request's own criteria section is left out of the text so
|
|
191
|
+
each criterion is shown once.
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
view: TaskDetailView
|
|
195
|
+
|
|
196
|
+
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
197
|
+
"""Yield the request text and the criteria list."""
|
|
198
|
+
del console, options
|
|
199
|
+
criteria = self.view.acceptance_criteria
|
|
200
|
+
request = self.view.request
|
|
201
|
+
if criteria:
|
|
202
|
+
request = request_without_criteria_section(request)
|
|
203
|
+
yield Text(request)
|
|
204
|
+
yield Text(CRITERIA_LABEL, style=BOLD_STEEL)
|
|
205
|
+
if not criteria:
|
|
206
|
+
yield grey(NO_CRITERIA)
|
|
207
|
+
for item in criteria:
|
|
208
|
+
yield Text.assemble(grey(CRITERION_LEAD), item)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def home_shortened(path: str, home: Path) -> str:
|
|
212
|
+
"""Return `path` as `~/...` when it lies under `home`, otherwise unchanged."""
|
|
213
|
+
location = Path(path)
|
|
214
|
+
return (
|
|
215
|
+
str(Path(HOME_MARK) / location.relative_to(home)) if location.is_relative_to(home) else path
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _workspace_rows(view: TaskDetailView, home: Path) -> list[Row]:
|
|
220
|
+
rows: list[Row] = []
|
|
221
|
+
if view.branch:
|
|
222
|
+
base = view.results.base if view.results is not None else None
|
|
223
|
+
target = view.branch if base is None else f"{view.branch} {BRANCH_ARROW} {base}"
|
|
224
|
+
rows.append((grey(BRANCH_LABEL), (Text(target),)))
|
|
225
|
+
rows.append((grey(PATH_LABEL), (Text(home_shortened(view.worktree_path, home)),)))
|
|
226
|
+
return rows
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@dataclass(frozen=True)
|
|
230
|
+
class WorkspaceBody:
|
|
231
|
+
"""The Workspace fold body: the task branch and where its worktree lives."""
|
|
232
|
+
|
|
233
|
+
view: TaskDetailView
|
|
234
|
+
home: Path
|
|
235
|
+
|
|
236
|
+
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
237
|
+
"""Yield the labelled branch and path rows."""
|
|
238
|
+
del console
|
|
239
|
+
yield _body(options.max_width, _workspace_rows(self.view, self.home), [])
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def priority_word(priority: int) -> str:
|
|
243
|
+
"""Name a dispatch priority: `standard` at 0, `high` above it and `low` below it."""
|
|
244
|
+
return PRIORITY_WORDS[(priority > 0) - (priority < 0)]
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _present(value: str | None, style: str = "") -> Text | None:
|
|
248
|
+
return Text(value, style=style) if value else None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _settings_rows(view: TaskDetailView) -> list[Row]:
|
|
252
|
+
entries = (
|
|
253
|
+
(MODE_LABEL, _present(view.mode)),
|
|
254
|
+
(SUBMITTED_LABEL, _present(view.submitted_age)),
|
|
255
|
+
(REQUEUED_LABEL, _present(view.requeued_age)),
|
|
256
|
+
(STATUS_REASON_LABEL, _present(view.status_reason, AMBER)),
|
|
257
|
+
(
|
|
258
|
+
PRIORITY_LABEL,
|
|
259
|
+
None
|
|
260
|
+
if view.priority is None
|
|
261
|
+
else Text(f"{priority_word(view.priority)} ({view.priority})"),
|
|
262
|
+
),
|
|
263
|
+
)
|
|
264
|
+
return [(grey(label), (value,)) for label, value in entries if value is not None]
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@dataclass(frozen=True)
|
|
268
|
+
class SettingsBody:
|
|
269
|
+
"""The Settings fold body: mode, ages, status reason and dispatch priority."""
|
|
270
|
+
|
|
271
|
+
view: TaskDetailView
|
|
272
|
+
|
|
273
|
+
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
274
|
+
"""Yield the labelled settings rows."""
|
|
275
|
+
del console
|
|
276
|
+
yield _body(options.max_width, _settings_rows(self.view), [])
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""The one key table behind the Help panel: groups of key and action rows per context."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
|
|
8
|
+
from shikiru.dashboard.detail import TASK_DETAIL_VIEWS_KEY, TASK_WINDOW_KEY
|
|
9
|
+
from shikiru.dashboard.resolution_view import EXTRA_DETAILS_KEY
|
|
10
|
+
from shikiru.dashboard.timeline_filter import CLEAR_KEY, CYCLE_ROLE_KEY, CYCLE_TURN_KEY
|
|
11
|
+
from shikiru.dashboard.timeline_section import FILTER_KEY
|
|
12
|
+
|
|
13
|
+
_ARROWS = "Up/Down"
|
|
14
|
+
_ESC = "Esc"
|
|
15
|
+
_ENTER = "Enter"
|
|
16
|
+
_PALETTE = "Ctrl+P"
|
|
17
|
+
_DETACH_KEY = "d"
|
|
18
|
+
_PALETTE_POPUP_KEY = "Space"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class HelpTab(StrEnum):
|
|
22
|
+
"""A Help context; the value is the tab label."""
|
|
23
|
+
|
|
24
|
+
HOME = "Home"
|
|
25
|
+
TASK = "Task details"
|
|
26
|
+
ESCALATIONS = "Escalations"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class HelpRow:
|
|
31
|
+
"""One key and what it does."""
|
|
32
|
+
|
|
33
|
+
keys: str
|
|
34
|
+
action: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class HelpGroup:
|
|
39
|
+
"""A titled run of rows."""
|
|
40
|
+
|
|
41
|
+
title: str
|
|
42
|
+
rows: tuple[HelpRow, ...]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class HelpAvailability:
|
|
47
|
+
"""Which optional parts of Home exist, so their keys are listed only when they work."""
|
|
48
|
+
|
|
49
|
+
queue: bool
|
|
50
|
+
escalations: bool
|
|
51
|
+
detach_prefix: str | None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def help_groups(tab: HelpTab, availability: HelpAvailability) -> tuple[HelpGroup, ...]:
|
|
55
|
+
"""Return the groups of `tab`, with the Home keys that exist under `availability`."""
|
|
56
|
+
if tab is HelpTab.HOME:
|
|
57
|
+
return _home_groups(availability)
|
|
58
|
+
if tab is HelpTab.TASK:
|
|
59
|
+
return _task_groups()
|
|
60
|
+
return _escalation_groups()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _home_groups(availability: HelpAvailability) -> tuple[HelpGroup, ...]:
|
|
64
|
+
tasks = [HelpRow("Click", "Select a task and show its details")]
|
|
65
|
+
if availability.queue:
|
|
66
|
+
tasks[:0] = [
|
|
67
|
+
HelpRow(_ARROWS, "Select a task, wherever focus is"),
|
|
68
|
+
HelpRow("f", "Cycle the task status filter"),
|
|
69
|
+
HelpRow("/", "Search by task ID or title; Enter keeps it, Esc clears it"),
|
|
70
|
+
HelpRow("m", "Load more tasks, while the list shows the hint"),
|
|
71
|
+
]
|
|
72
|
+
tasks.append(HelpRow(_ESC, "Close a popup, clear the search, then clear the selection"))
|
|
73
|
+
groups = [HelpGroup("Tasks", tuple(tasks))]
|
|
74
|
+
if availability.escalations:
|
|
75
|
+
groups.append(
|
|
76
|
+
HelpGroup(
|
|
77
|
+
"Escalations",
|
|
78
|
+
(HelpRow(_ENTER, "Open the decision"),),
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
everywhere = [
|
|
82
|
+
HelpRow(f"{_PALETTE} / F12", "Open the actions for the selected task"),
|
|
83
|
+
HelpRow("Tab", "Move to the footer, command status and resources; Enter uses them"),
|
|
84
|
+
HelpRow("?", "Show this help"),
|
|
85
|
+
]
|
|
86
|
+
if availability.detach_prefix is not None:
|
|
87
|
+
prefix = availability.detach_prefix
|
|
88
|
+
everywhere.append(
|
|
89
|
+
HelpRow(
|
|
90
|
+
f"{prefix} {_PALETTE_POPUP_KEY}",
|
|
91
|
+
"Open the actions in any Shikiru pane (a popup in task and role panes)",
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
everywhere.append(HelpRow(f"{prefix} {_DETACH_KEY}", "Detach from tmux"))
|
|
95
|
+
groups.append(HelpGroup("Everywhere", tuple(everywhere)))
|
|
96
|
+
return tuple(groups)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _task_groups() -> tuple[HelpGroup, ...]:
|
|
100
|
+
return (
|
|
101
|
+
HelpGroup(
|
|
102
|
+
"Task",
|
|
103
|
+
(
|
|
104
|
+
HelpRow("Tab", "Move between the task's actions and its sections"),
|
|
105
|
+
HelpRow(_ENTER, "Run the focused action"),
|
|
106
|
+
HelpRow(TASK_DETAIL_VIEWS_KEY, "Focus task details sections"),
|
|
107
|
+
HelpRow(_PALETTE, "Open the actions for this task"),
|
|
108
|
+
HelpRow(_ESC, "Step back, then return to Home"),
|
|
109
|
+
),
|
|
110
|
+
),
|
|
111
|
+
HelpGroup(
|
|
112
|
+
"Sections",
|
|
113
|
+
(
|
|
114
|
+
HelpRow(_ARROWS, "Move between section headers"),
|
|
115
|
+
HelpRow(_ENTER, "Open the section, or enter its list"),
|
|
116
|
+
HelpRow("Space / click", "Open or close a section"),
|
|
117
|
+
),
|
|
118
|
+
),
|
|
119
|
+
HelpGroup(
|
|
120
|
+
"Lists",
|
|
121
|
+
(
|
|
122
|
+
HelpRow(_ARROWS, "Select a row"),
|
|
123
|
+
HelpRow("PgUp/PgDn", "Page through the rows"),
|
|
124
|
+
HelpRow("End", "Jump to the latest and follow"),
|
|
125
|
+
HelpRow(_ENTER, "Open the selected row"),
|
|
126
|
+
HelpRow(_ESC, "Back to the section header"),
|
|
127
|
+
),
|
|
128
|
+
),
|
|
129
|
+
HelpGroup(
|
|
130
|
+
"Agent activity filter",
|
|
131
|
+
(
|
|
132
|
+
HelpRow(FILTER_KEY, "Open the filter"),
|
|
133
|
+
HelpRow(CYCLE_ROLE_KEY, "Cycle the role"),
|
|
134
|
+
HelpRow(CYCLE_TURN_KEY, "Cycle the turn"),
|
|
135
|
+
HelpRow(CLEAR_KEY, "Clear the filter"),
|
|
136
|
+
HelpRow(_ESC, "Close the filter"),
|
|
137
|
+
),
|
|
138
|
+
),
|
|
139
|
+
HelpGroup(
|
|
140
|
+
"Workspace section",
|
|
141
|
+
(HelpRow(TASK_WINDOW_KEY, "Open the task's tmux window while its section is focused"),),
|
|
142
|
+
),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _escalation_groups() -> tuple[HelpGroup, ...]:
|
|
147
|
+
return (
|
|
148
|
+
HelpGroup(
|
|
149
|
+
"Choose",
|
|
150
|
+
(
|
|
151
|
+
HelpRow(_ARROWS, "Choose an answer"),
|
|
152
|
+
HelpRow(_ENTER, "Send it, or edit or expand the chosen row"),
|
|
153
|
+
HelpRow("t", "Show or hide what was tried"),
|
|
154
|
+
HelpRow(EXTRA_DETAILS_KEY, "Open extra details (when Details is open)"),
|
|
155
|
+
HelpRow(_ESC, "Close without sending"),
|
|
156
|
+
),
|
|
157
|
+
),
|
|
158
|
+
HelpGroup(
|
|
159
|
+
"Write an answer",
|
|
160
|
+
(
|
|
161
|
+
HelpRow("Ctrl+S", "Send the typed answer"),
|
|
162
|
+
HelpRow(_ESC, "Back to the choices"),
|
|
163
|
+
),
|
|
164
|
+
),
|
|
165
|
+
HelpGroup("Cancel the task", (HelpRow(_ENTER, "Press Enter again to confirm"),)),
|
|
166
|
+
)
|