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,106 @@
|
|
|
1
|
+
"""Codex rollout token usage parsing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Mapping
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
|
|
8
|
+
from shikiru.agents.contracts import Usage
|
|
9
|
+
from shikiru.plugins.agents.codex.options import USAGE_EVENT_TYPE
|
|
10
|
+
|
|
11
|
+
_EVENT_MESSAGE_TYPE = "event_msg"
|
|
12
|
+
_INFO_FIELD = "info"
|
|
13
|
+
_TOTAL_USAGE_FIELD = "total_token_usage"
|
|
14
|
+
_LAST_USAGE_FIELD = "last_token_usage"
|
|
15
|
+
_INPUT_TOKENS_FIELD = "input_tokens"
|
|
16
|
+
_OUTPUT_TOKENS_FIELD = "output_tokens"
|
|
17
|
+
_CACHE_READ_TOKENS_FIELD = "cached_input_tokens"
|
|
18
|
+
_CACHE_WRITE_TOKENS_FIELD = "cache_write_input_tokens"
|
|
19
|
+
_CONTEXT_WINDOW_FIELD = "model_context_window"
|
|
20
|
+
_PERCENT = 100
|
|
21
|
+
_ZERO_TOKENS = 0
|
|
22
|
+
_UNKNOWN_COST = "unknown"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def rollout_usage(
|
|
26
|
+
entries: Iterable[Mapping[str, Any]],
|
|
27
|
+
) -> Usage:
|
|
28
|
+
"""Report the latest rollout token count; context from its window, no priced cost."""
|
|
29
|
+
latest = _latest_token_count(entries)
|
|
30
|
+
if latest is None:
|
|
31
|
+
return Usage(
|
|
32
|
+
_ZERO_TOKENS,
|
|
33
|
+
_ZERO_TOKENS,
|
|
34
|
+
_ZERO_TOKENS,
|
|
35
|
+
_ZERO_TOKENS,
|
|
36
|
+
None,
|
|
37
|
+
_UNKNOWN_COST,
|
|
38
|
+
None,
|
|
39
|
+
)
|
|
40
|
+
total, last, context_window = latest
|
|
41
|
+
input_tokens = _integer(total, _INPUT_TOKENS_FIELD)
|
|
42
|
+
output_tokens = _integer(total, _OUTPUT_TOKENS_FIELD)
|
|
43
|
+
cache_read_tokens = _integer(total, _CACHE_READ_TOKENS_FIELD)
|
|
44
|
+
cache_write_tokens = _integer(total, _CACHE_WRITE_TOKENS_FIELD)
|
|
45
|
+
context_used_pct = _context_used_pct(last, context_window)
|
|
46
|
+
return Usage(
|
|
47
|
+
input_tokens=input_tokens,
|
|
48
|
+
output_tokens=output_tokens,
|
|
49
|
+
cache_read_tokens=cache_read_tokens,
|
|
50
|
+
cache_write_tokens=cache_write_tokens,
|
|
51
|
+
cost_usd=None,
|
|
52
|
+
cost_basis="unknown",
|
|
53
|
+
context_used_pct=context_used_pct,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def latest_token_count(
|
|
58
|
+
entries: Iterable[Mapping[str, Any]],
|
|
59
|
+
) -> tuple[Mapping[str, Any], Mapping[str, Any], int | None] | None:
|
|
60
|
+
"""Return the latest total and per-turn token usage records."""
|
|
61
|
+
return _latest_token_count(entries)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _latest_token_count(
|
|
65
|
+
entries: Iterable[Mapping[str, Any]],
|
|
66
|
+
) -> tuple[Mapping[str, Any], Mapping[str, Any], int | None] | None:
|
|
67
|
+
latest: tuple[Mapping[str, Any], Mapping[str, Any], int | None] | None = None
|
|
68
|
+
for entry in entries:
|
|
69
|
+
payload = _payload(entry)
|
|
70
|
+
if payload.get("type") != USAGE_EVENT_TYPE:
|
|
71
|
+
continue
|
|
72
|
+
info = payload.get(_INFO_FIELD)
|
|
73
|
+
if not isinstance(info, Mapping):
|
|
74
|
+
continue
|
|
75
|
+
typed_info = cast("Mapping[str, Any]", info)
|
|
76
|
+
total = typed_info.get(_TOTAL_USAGE_FIELD)
|
|
77
|
+
last = typed_info.get(_LAST_USAGE_FIELD)
|
|
78
|
+
if isinstance(total, Mapping) and isinstance(last, Mapping):
|
|
79
|
+
context_window = typed_info.get(_CONTEXT_WINDOW_FIELD)
|
|
80
|
+
latest = (
|
|
81
|
+
cast("Mapping[str, Any]", total),
|
|
82
|
+
cast("Mapping[str, Any]", last),
|
|
83
|
+
context_window if isinstance(context_window, int) else None,
|
|
84
|
+
)
|
|
85
|
+
return latest
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _payload(entry: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
89
|
+
if entry.get("type") != _EVENT_MESSAGE_TYPE:
|
|
90
|
+
return {}
|
|
91
|
+
payload = entry.get("payload")
|
|
92
|
+
return cast("Mapping[str, Any]", payload) if isinstance(payload, Mapping) else {}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _integer(entry: Mapping[str, Any], key: str) -> int:
|
|
96
|
+
value = entry.get(key, 0)
|
|
97
|
+
if not isinstance(value, int | float):
|
|
98
|
+
return 0
|
|
99
|
+
return int(value)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _context_used_pct(last: Mapping[str, Any], context_window: int | None) -> float | None:
|
|
103
|
+
if context_window is None or context_window <= 0 or _INPUT_TOKENS_FIELD not in last:
|
|
104
|
+
return None
|
|
105
|
+
tokens = _integer(last, _INPUT_TOKENS_FIELD)
|
|
106
|
+
return tokens / context_window * _PERCENT
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Option definitions and validation shared by the built-in agent plugins."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from shikiru.agents.contracts import BILLING_MODE_API, BILLING_MODE_SUBSCRIPTION, PERCENT
|
|
8
|
+
from shikiru.agents.settings import AgentOptionsError, AgentSetting, SettingKind
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from pydantic import ValidationError
|
|
12
|
+
|
|
13
|
+
COMPACT_AT_PCT_KEY = "compact_at_pct"
|
|
14
|
+
BILLING_MODE_KEY = "billing_mode"
|
|
15
|
+
ARGS_KEY = "args"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def compact_threshold_setting(default: float) -> AgentSetting:
|
|
19
|
+
"""Return the context-compaction threshold setting with the plugin's `default`."""
|
|
20
|
+
return AgentSetting(
|
|
21
|
+
key=COMPACT_AT_PCT_KEY,
|
|
22
|
+
label="Compact at",
|
|
23
|
+
help="Context use at which the agent is asked to compact its session; 0 compacts at once.",
|
|
24
|
+
kind=SettingKind.NUMBER,
|
|
25
|
+
default=default,
|
|
26
|
+
unit="percent",
|
|
27
|
+
minimum=0,
|
|
28
|
+
maximum=PERCENT,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def billing_declaration_setting() -> AgentSetting:
|
|
33
|
+
"""Return the billing declaration setting; unset declares nothing."""
|
|
34
|
+
return AgentSetting(
|
|
35
|
+
key=BILLING_MODE_KEY,
|
|
36
|
+
label="Billing mode",
|
|
37
|
+
help=(
|
|
38
|
+
"How this agent is billed, used to label cost as billed or API-equivalent; "
|
|
39
|
+
"leaving it blank declares nothing."
|
|
40
|
+
),
|
|
41
|
+
kind=SettingKind.CHOICE,
|
|
42
|
+
default=None,
|
|
43
|
+
choices=(BILLING_MODE_API, BILLING_MODE_SUBSCRIPTION),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def cli_args_setting(default: tuple[str, ...]) -> AgentSetting:
|
|
48
|
+
"""Return the extra CLI arguments setting with the plugin's `default`."""
|
|
49
|
+
return AgentSetting(
|
|
50
|
+
key=ARGS_KEY,
|
|
51
|
+
label="CLI arguments",
|
|
52
|
+
help="Arguments added to every agent CLI call, split like a shell command line.",
|
|
53
|
+
kind=SettingKind.ARGS,
|
|
54
|
+
default=default,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def options_error(agent: str, error: ValidationError) -> AgentOptionsError:
|
|
59
|
+
"""Return the options rejection for `agent`, naming the first option pydantic refused."""
|
|
60
|
+
first = error.errors()[0]
|
|
61
|
+
key = str(first["loc"][0]) if first["loc"] else None
|
|
62
|
+
return AgentOptionsError(f"invalid options for the {agent} agent: {error}", key)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""The `dialogue` widget: recent speech bubbles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, ClassVar, cast
|
|
6
|
+
|
|
7
|
+
from textual.containers import Horizontal, VerticalScroll
|
|
8
|
+
from textual.screen import ModalScreen
|
|
9
|
+
from textual.widgets import Button, DataTable, Label, Rule, Static
|
|
10
|
+
|
|
11
|
+
from shikiru.dashboard.contract import WidgetPlacement
|
|
12
|
+
from shikiru.ui import SpeechBubble
|
|
13
|
+
from shikiru.ui.popup import (
|
|
14
|
+
POPUP_BODY_CLASS,
|
|
15
|
+
POPUP_CLOSE_CLASS,
|
|
16
|
+
POPUP_CSS,
|
|
17
|
+
POPUP_FRAME_CLASS,
|
|
18
|
+
POPUP_HEADER_CLASS,
|
|
19
|
+
POPUP_RULE_CLASS,
|
|
20
|
+
POPUP_TITLE_CLASS,
|
|
21
|
+
PopupFrame,
|
|
22
|
+
popup_close_label,
|
|
23
|
+
prepare_popup,
|
|
24
|
+
)
|
|
25
|
+
from shikiru.ui.screen import widget_is_attached
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from textual.app import App, ComposeResult
|
|
29
|
+
from textual.widget import Widget
|
|
30
|
+
|
|
31
|
+
from shikiru.dashboard.contract import DialogueBubbleView, WidgetContext
|
|
32
|
+
|
|
33
|
+
_DIALOGUE_TEXT_CLOSE_BUTTON = "dialogue-text-close"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DialogueTextScreen(ModalScreen[None]):
|
|
37
|
+
"""Show the full text of one dialogue bubble."""
|
|
38
|
+
|
|
39
|
+
BINDINGS: ClassVar[list[tuple[str, str, str]]] = [("escape", "close", "Close")]
|
|
40
|
+
DEFAULT_CSS = (
|
|
41
|
+
POPUP_CSS
|
|
42
|
+
+ """
|
|
43
|
+
#dialogue-text-scroll { max-height: 1fr; min-height: 0; }
|
|
44
|
+
#dialogue-full-text {
|
|
45
|
+
background: $surface;
|
|
46
|
+
color: $foreground;
|
|
47
|
+
}
|
|
48
|
+
"""
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def __init__(self, text: str) -> None:
|
|
52
|
+
"""Hold the full event text to display."""
|
|
53
|
+
super().__init__()
|
|
54
|
+
prepare_popup(self)
|
|
55
|
+
self._text = text
|
|
56
|
+
|
|
57
|
+
def compose(self) -> ComposeResult:
|
|
58
|
+
"""Render the complete bubble text in a scrollable modal."""
|
|
59
|
+
with PopupFrame(widget_id="dialogue-text-frame", classes=POPUP_FRAME_CLASS):
|
|
60
|
+
with Horizontal(id="dialogue-text-header", classes=POPUP_HEADER_CLASS):
|
|
61
|
+
yield Label("Dialogue", id="dialogue-text-title", classes=POPUP_TITLE_CLASS)
|
|
62
|
+
yield Button(
|
|
63
|
+
popup_close_label(),
|
|
64
|
+
id=_DIALOGUE_TEXT_CLOSE_BUTTON,
|
|
65
|
+
classes=POPUP_CLOSE_CLASS,
|
|
66
|
+
)
|
|
67
|
+
yield Rule(classes=POPUP_RULE_CLASS)
|
|
68
|
+
with VerticalScroll(id="dialogue-text-scroll", classes=POPUP_BODY_CLASS):
|
|
69
|
+
yield Static(self._text, markup=False, id="dialogue-full-text")
|
|
70
|
+
|
|
71
|
+
def on_mount(self) -> None:
|
|
72
|
+
self.set_focus(self.query_one("#dialogue-text-scroll", VerticalScroll))
|
|
73
|
+
self.call_after_refresh(self.query_one("#dialogue-text-frame", PopupFrame).content_ready)
|
|
74
|
+
|
|
75
|
+
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
76
|
+
if event.button.id == _DIALOGUE_TEXT_CLOSE_BUTTON:
|
|
77
|
+
self.dismiss()
|
|
78
|
+
|
|
79
|
+
async def action_close(self) -> None:
|
|
80
|
+
"""Close the full-text modal."""
|
|
81
|
+
self.dismiss()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class DialoguePanel(Static):
|
|
85
|
+
"""List the latest twenty bubbles and open a selected bubble's full text on Enter."""
|
|
86
|
+
|
|
87
|
+
DEFAULT_CSS = """
|
|
88
|
+
DialoguePanel { height: auto; }
|
|
89
|
+
#dialogue-table { height: auto; }
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(self, context: WidgetContext) -> None:
|
|
93
|
+
"""Hold `context`; the event list is read on mount and on state changes."""
|
|
94
|
+
super().__init__(id="dialogue")
|
|
95
|
+
self._widget_context = context
|
|
96
|
+
self._bubbles: list[DialogueBubbleView] = []
|
|
97
|
+
|
|
98
|
+
def compose(self) -> ComposeResult:
|
|
99
|
+
"""Reserve a table with one row per recent speech bubble."""
|
|
100
|
+
yield DataTable(show_header=False, cursor_type="row", id="dialogue-table")
|
|
101
|
+
|
|
102
|
+
async def on_mount(self) -> None:
|
|
103
|
+
"""Render recent events and refresh after each state change."""
|
|
104
|
+
await self._refresh()
|
|
105
|
+
self.run_worker(self._widget_context.on_change(self._refresh_if_attached), exclusive=True)
|
|
106
|
+
|
|
107
|
+
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
108
|
+
"""Show the complete text of the selected event."""
|
|
109
|
+
if self._bubbles:
|
|
110
|
+
bubble = self._bubbles[event.cursor_row]
|
|
111
|
+
cast("App[None]", self.app).push_screen(DialogueTextScreen(bubble.text))
|
|
112
|
+
|
|
113
|
+
async def _refresh(self) -> None:
|
|
114
|
+
self._bubbles = self._widget_context.views.dialogue(self._widget_context.focus_task)
|
|
115
|
+
table = cast("DataTable[object]", self.query_one("#dialogue-table", DataTable))
|
|
116
|
+
table.clear(columns=True)
|
|
117
|
+
table.add_column("dialogue")
|
|
118
|
+
if not self._bubbles:
|
|
119
|
+
table.add_row("no dialogue yet")
|
|
120
|
+
return
|
|
121
|
+
for bubble in self._bubbles:
|
|
122
|
+
table.add_row(
|
|
123
|
+
SpeechBubble(
|
|
124
|
+
role=bubble.role,
|
|
125
|
+
text=bubble.text,
|
|
126
|
+
task_id=bubble.task_id,
|
|
127
|
+
age=bubble.age,
|
|
128
|
+
).render()
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
async def _refresh_if_attached(self) -> None:
|
|
132
|
+
if widget_is_attached(self):
|
|
133
|
+
await self._refresh()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class DialogueWidget:
|
|
137
|
+
"""The `dialogue` dashboard widget plugin."""
|
|
138
|
+
|
|
139
|
+
name = "dialogue"
|
|
140
|
+
placement = WidgetPlacement.ACTIVITY
|
|
141
|
+
|
|
142
|
+
def create(self, context: WidgetContext) -> Widget:
|
|
143
|
+
"""Build the dialogue panel for `context`."""
|
|
144
|
+
return DialoguePanel(context)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""The `disk` widget: free space of the project filesystem and the `.shikiru` size."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from rich.cells import cell_len
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
|
|
11
|
+
from shikiru.dashboard.contract import DiagnosticSampleState, WidgetPlacement
|
|
12
|
+
from shikiru.ui.compact import free_space_text
|
|
13
|
+
from shikiru.ui.gated_static import GatedStatic
|
|
14
|
+
from shikiru.ui.live_interval import LiveInterval
|
|
15
|
+
from shikiru.ui.screen import is_first_child, widget_is_attached
|
|
16
|
+
from shikiru.ui.section import (
|
|
17
|
+
FAILED_WORD,
|
|
18
|
+
GROUP_SEPARATOR,
|
|
19
|
+
UNAVAILABLE_WORD,
|
|
20
|
+
UNKNOWN_AGE,
|
|
21
|
+
Row,
|
|
22
|
+
Section,
|
|
23
|
+
grey,
|
|
24
|
+
labelled_rows,
|
|
25
|
+
number,
|
|
26
|
+
observation,
|
|
27
|
+
state_line,
|
|
28
|
+
)
|
|
29
|
+
from shikiru.ui.size import format_size
|
|
30
|
+
from shikiru.ui.tokens import ALERT_COLOR, AMBER, SECONDARY_TEXT_COLOR
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from rich.console import Console, ConsoleOptions, RenderResult
|
|
34
|
+
from textual.widget import Widget
|
|
35
|
+
|
|
36
|
+
from shikiru.dashboard.contract import DiskView, WidgetContext
|
|
37
|
+
|
|
38
|
+
SECTION_TITLE = "Disk"
|
|
39
|
+
LABEL_FREE = "Free"
|
|
40
|
+
LABEL_SHIKIRU = ".shikiru"
|
|
41
|
+
MEASURING_WORD = "measuring…"
|
|
42
|
+
LABEL_REFRESH = "Refresh"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class _DiskBody:
|
|
47
|
+
view: DiskView
|
|
48
|
+
refresh_error: str | None
|
|
49
|
+
|
|
50
|
+
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
51
|
+
del console
|
|
52
|
+
view = self.view
|
|
53
|
+
rows: list[Row] = []
|
|
54
|
+
if view.free_bytes is not None and view.total_bytes is not None:
|
|
55
|
+
free = free_space_text(view.free_bytes, view.total_bytes, view.free_level)
|
|
56
|
+
rows.append((grey(LABEL_FREE), (free,)))
|
|
57
|
+
if view.shikiru_bytes is not None:
|
|
58
|
+
rows.append((grey(LABEL_SHIKIRU), (number(format_size(view.shikiru_bytes)),)))
|
|
59
|
+
elif self.refresh_error is None:
|
|
60
|
+
rows.append((grey(LABEL_SHIKIRU), (grey(MEASURING_WORD),)))
|
|
61
|
+
if self.refresh_error is not None:
|
|
62
|
+
failure = Text(f"{FAILED_WORD}{GROUP_SEPARATOR}{self.refresh_error}", style=AMBER)
|
|
63
|
+
rows.append((grey(LABEL_REFRESH), (failure,)))
|
|
64
|
+
label_width = max((cell_len(label.plain) for label, _ in rows), default=0)
|
|
65
|
+
yield labelled_rows(rows, options.max_width, label_width)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def disk_section(view: DiskView) -> Section:
|
|
69
|
+
"""Render `view` as the Disk section in every state."""
|
|
70
|
+
ago = view.sampled_ago or UNKNOWN_AGE
|
|
71
|
+
if view.state is DiagnosticSampleState.UNAVAILABLE:
|
|
72
|
+
return Section(
|
|
73
|
+
SECTION_TITLE, state_line(UNAVAILABLE_WORD, SECONDARY_TEXT_COLOR, view.error)
|
|
74
|
+
)
|
|
75
|
+
if view.state is DiagnosticSampleState.FAILED:
|
|
76
|
+
return Section(SECTION_TITLE, state_line(FAILED_WORD, ALERT_COLOR, view.error))
|
|
77
|
+
stale = view.state is DiagnosticSampleState.STALE
|
|
78
|
+
return Section(
|
|
79
|
+
SECTION_TITLE, _DiskBody(view, view.error if stale else None), observation(ago, stale=stale)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class DiskPanel(GatedStatic):
|
|
84
|
+
"""Show the Disk section and re-render only when its content changed."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, context: WidgetContext) -> None:
|
|
87
|
+
"""Hold `context`; disk sampling starts on mount."""
|
|
88
|
+
super().__init__(id="disk")
|
|
89
|
+
self._widget_context = context
|
|
90
|
+
self._interval = LiveInterval(
|
|
91
|
+
self,
|
|
92
|
+
lambda: context.settings.current().dashboard.disk_refresh_seconds,
|
|
93
|
+
self._refresh,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
async def on_mount(self) -> None:
|
|
97
|
+
"""Render and refresh disk state at the configured cadence."""
|
|
98
|
+
await self._refresh()
|
|
99
|
+
self._interval.apply()
|
|
100
|
+
self.run_worker(self._widget_context.on_change(self._interval.reapply), exclusive=True)
|
|
101
|
+
|
|
102
|
+
async def _refresh(self) -> None:
|
|
103
|
+
if not widget_is_attached(self):
|
|
104
|
+
return
|
|
105
|
+
view = await self._widget_context.views.disk()
|
|
106
|
+
if not widget_is_attached(self):
|
|
107
|
+
return
|
|
108
|
+
if view.shikiru_bytes is None and view.state is DiagnosticSampleState.AVAILABLE:
|
|
109
|
+
resources = self._widget_context.settings.current().orchestration.resources
|
|
110
|
+
self.set_timer(resources.sample_seconds, self._refresh)
|
|
111
|
+
ruled = not is_first_child(self)
|
|
112
|
+
self.update_if_changed(disk_section(view).with_ruled(ruled=ruled), key=(view, ruled))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class DiskWidget:
|
|
116
|
+
"""The `disk` dashboard widget plugin."""
|
|
117
|
+
|
|
118
|
+
name = "disk"
|
|
119
|
+
placement = WidgetPlacement.DIAGNOSTICS
|
|
120
|
+
|
|
121
|
+
def create(self, context: WidgetContext) -> Widget:
|
|
122
|
+
"""Build the Disk panel for `context`."""
|
|
123
|
+
return DiskPanel(context)
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""The `docker` widget: container counts, problems and the top five by CPU."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from rich.cells import cell_len
|
|
9
|
+
from rich.console import Group, RenderableType
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
|
|
13
|
+
from shikiru.dashboard.contract import DiagnosticSampleState, WidgetPlacement
|
|
14
|
+
from shikiru.ui.compact import format_cpu_pct, format_memory
|
|
15
|
+
from shikiru.ui.gated_static import GatedStatic
|
|
16
|
+
from shikiru.ui.live_interval import LiveInterval
|
|
17
|
+
from shikiru.ui.screen import is_first_child, widget_is_attached
|
|
18
|
+
from shikiru.ui.section import (
|
|
19
|
+
BOLD_PRIMARY,
|
|
20
|
+
FAILED_WORD,
|
|
21
|
+
GROUP_SEPARATOR,
|
|
22
|
+
UNAVAILABLE_WORD,
|
|
23
|
+
UNKNOWN_AGE,
|
|
24
|
+
Row,
|
|
25
|
+
Section,
|
|
26
|
+
grey,
|
|
27
|
+
labelled_rows,
|
|
28
|
+
number,
|
|
29
|
+
observation,
|
|
30
|
+
state_line,
|
|
31
|
+
)
|
|
32
|
+
from shikiru.ui.tokens import (
|
|
33
|
+
ALERT_COLOR,
|
|
34
|
+
AMBER,
|
|
35
|
+
SECONDARY_TEXT_COLOR,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
from rich.console import Console, ConsoleOptions, RenderResult
|
|
40
|
+
from textual.widget import Widget
|
|
41
|
+
|
|
42
|
+
from shikiru.dashboard.contract import (
|
|
43
|
+
ContainerProblemView,
|
|
44
|
+
ContainerView,
|
|
45
|
+
DockerSummaryView,
|
|
46
|
+
DockerView,
|
|
47
|
+
WidgetContext,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
SECTION_TITLE = "Docker"
|
|
51
|
+
LABEL_RUNNING = "Running"
|
|
52
|
+
LABEL_PROBLEMS = "Problems"
|
|
53
|
+
LABEL_ELSEWHERE = "Elsewhere"
|
|
54
|
+
LABEL_REFRESH = "Refresh"
|
|
55
|
+
TOP_HEADING = "Top 5 by CPU"
|
|
56
|
+
TOP_INDENT = 2
|
|
57
|
+
TOP_COLUMN_GAP = 1
|
|
58
|
+
TOP_GAP_COUNT = 2
|
|
59
|
+
RIGHT = "right"
|
|
60
|
+
ELLIPSIS = "ellipsis"
|
|
61
|
+
CPU_WORD = "CPU"
|
|
62
|
+
RAM_WORD = "RAM"
|
|
63
|
+
OF_WORD = "of"
|
|
64
|
+
RUNNING_WORD = "running"
|
|
65
|
+
USAGE_UNAVAILABLE = "usage unavailable"
|
|
66
|
+
NO_CONTAINERS = "No containers"
|
|
67
|
+
PROBLEM_UNHEALTHY = "unhealthy"
|
|
68
|
+
PROBLEM_RESTARTING = "restarting"
|
|
69
|
+
_WARNING_REASONS = frozenset({PROBLEM_UNHEALTHY, PROBLEM_RESTARTING})
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _measure(label: str, value: str) -> Text:
|
|
73
|
+
return Text.assemble(grey(f"{label} "), number(value))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _usage_groups(cpu_pct: float | None, memory_bytes: int | None) -> list[Text]:
|
|
77
|
+
groups: list[Text] = []
|
|
78
|
+
if cpu_pct is not None:
|
|
79
|
+
groups.append(_measure(CPU_WORD, format_cpu_pct(cpu_pct)))
|
|
80
|
+
if memory_bytes is not None:
|
|
81
|
+
groups.append(_measure(RAM_WORD, format_memory(memory_bytes)))
|
|
82
|
+
return groups
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _running_groups(summary: DockerSummaryView) -> list[Text]:
|
|
86
|
+
counts = Text.assemble(
|
|
87
|
+
number(str(summary.running)), grey(f" {OF_WORD} "), number(str(summary.total))
|
|
88
|
+
)
|
|
89
|
+
if not summary.usage_available:
|
|
90
|
+
return [counts, Text(USAGE_UNAVAILABLE, style=AMBER)]
|
|
91
|
+
return [counts, *_usage_groups(summary.cpu_pct, summary.memory_bytes)]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _problem_group(problem: ContainerProblemView) -> Text:
|
|
95
|
+
colour = AMBER if problem.reason in _WARNING_REASONS else ALERT_COLOR
|
|
96
|
+
reason = (
|
|
97
|
+
problem.reason if problem.exit_code is None else f"{problem.reason} ({problem.exit_code})"
|
|
98
|
+
)
|
|
99
|
+
return Text.assemble((problem.name, BOLD_PRIMARY), " ", (reason, colour))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _elsewhere_groups(summary: DockerSummaryView) -> list[Text]:
|
|
103
|
+
elsewhere = summary.elsewhere
|
|
104
|
+
count = Text.assemble(number(str(elsewhere.running)), grey(f" {RUNNING_WORD}"))
|
|
105
|
+
return [count, *_usage_groups(elsewhere.cpu_pct, elsewhere.memory_bytes)]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class _TopContainers:
|
|
110
|
+
containers: tuple[ContainerView, ...]
|
|
111
|
+
|
|
112
|
+
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
113
|
+
del console
|
|
114
|
+
cpus = [format_cpu_pct(container.cpu_pct) for container in self.containers]
|
|
115
|
+
memories = [format_memory(container.memory_bytes) for container in self.containers]
|
|
116
|
+
cpu_width = max(map(len, cpus))
|
|
117
|
+
memory_width = max(map(len, memories))
|
|
118
|
+
name_width = max(
|
|
119
|
+
1,
|
|
120
|
+
options.max_width
|
|
121
|
+
- TOP_INDENT
|
|
122
|
+
- cpu_width
|
|
123
|
+
- memory_width
|
|
124
|
+
- TOP_GAP_COUNT * TOP_COLUMN_GAP,
|
|
125
|
+
)
|
|
126
|
+
grid = Table.grid(padding=(0, TOP_COLUMN_GAP, 0, 0))
|
|
127
|
+
grid.add_column(width=TOP_INDENT - TOP_COLUMN_GAP)
|
|
128
|
+
grid.add_column(width=min(name_width, max(len(c.name) for c in self.containers)))
|
|
129
|
+
grid.add_column(justify=RIGHT, width=cpu_width)
|
|
130
|
+
grid.add_column(justify=RIGHT, width=memory_width)
|
|
131
|
+
for container, cpu, memory in zip(self.containers, cpus, memories, strict=True):
|
|
132
|
+
name = Text(container.name, style=BOLD_PRIMARY, no_wrap=True)
|
|
133
|
+
name.truncate(name_width, overflow=ELLIPSIS)
|
|
134
|
+
grid.add_row("", name, number(cpu), number(memory))
|
|
135
|
+
yield grid
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True)
|
|
139
|
+
class _SummaryBody:
|
|
140
|
+
summary: DockerSummaryView
|
|
141
|
+
refresh_error: str | None
|
|
142
|
+
|
|
143
|
+
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
144
|
+
del console
|
|
145
|
+
summary = self.summary
|
|
146
|
+
head: list[Row] = [(grey(LABEL_RUNNING), tuple(_running_groups(summary)))]
|
|
147
|
+
if summary.problems:
|
|
148
|
+
problems = tuple(_problem_group(problem) for problem in summary.problems)
|
|
149
|
+
head.append((grey(LABEL_PROBLEMS), problems))
|
|
150
|
+
if self.refresh_error is not None:
|
|
151
|
+
failure = Text(f"{FAILED_WORD}{GROUP_SEPARATOR}{self.refresh_error}", style=AMBER)
|
|
152
|
+
head.append((grey(LABEL_REFRESH), (failure,)))
|
|
153
|
+
tail: list[Row] = []
|
|
154
|
+
if summary.elsewhere.running:
|
|
155
|
+
tail.append((grey(LABEL_ELSEWHERE), tuple(_elsewhere_groups(summary))))
|
|
156
|
+
label_width = max(cell_len(label.plain) for label, _ in [*head, *tail])
|
|
157
|
+
yield labelled_rows(head, options.max_width, label_width)
|
|
158
|
+
if summary.usage_available and summary.top:
|
|
159
|
+
yield grey(TOP_HEADING)
|
|
160
|
+
yield _TopContainers(summary.top)
|
|
161
|
+
if tail:
|
|
162
|
+
yield labelled_rows(tail, options.max_width, label_width)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def docker_section(snapshot: DockerView) -> Section:
|
|
166
|
+
"""Render `snapshot` as the Docker section in every state."""
|
|
167
|
+
state = snapshot.state or (
|
|
168
|
+
DiagnosticSampleState.AVAILABLE if snapshot.available else DiagnosticSampleState.UNAVAILABLE
|
|
169
|
+
)
|
|
170
|
+
ago = snapshot.sampled_ago or UNKNOWN_AGE
|
|
171
|
+
if state is DiagnosticSampleState.UNAVAILABLE:
|
|
172
|
+
return Section(
|
|
173
|
+
SECTION_TITLE, state_line(UNAVAILABLE_WORD, SECONDARY_TEXT_COLOR, snapshot.error)
|
|
174
|
+
)
|
|
175
|
+
if state is DiagnosticSampleState.FAILED:
|
|
176
|
+
return Section(SECTION_TITLE, state_line(FAILED_WORD, ALERT_COLOR, snapshot.error))
|
|
177
|
+
if state is DiagnosticSampleState.EMPTY:
|
|
178
|
+
return Section(SECTION_TITLE, grey(NO_CONTAINERS), observation(ago, stale=False))
|
|
179
|
+
stale = state is DiagnosticSampleState.STALE
|
|
180
|
+
body: RenderableType = Group()
|
|
181
|
+
if snapshot.summary is not None:
|
|
182
|
+
body = _SummaryBody(snapshot.summary, snapshot.error if stale else None)
|
|
183
|
+
return Section(SECTION_TITLE, body, observation(ago, stale=stale))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class DockerPanel(GatedStatic):
|
|
187
|
+
"""Show the Docker section and re-render only when its content changed."""
|
|
188
|
+
|
|
189
|
+
def __init__(self, context: WidgetContext) -> None:
|
|
190
|
+
"""Hold `context`; Docker sampling starts on mount."""
|
|
191
|
+
super().__init__(id="docker")
|
|
192
|
+
self._widget_context = context
|
|
193
|
+
self._interval = LiveInterval(
|
|
194
|
+
self,
|
|
195
|
+
lambda: context.settings.current().dashboard.docker_refresh_seconds,
|
|
196
|
+
self._refresh,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
async def on_mount(self) -> None:
|
|
200
|
+
"""Render and refresh Docker state at the configured cadence."""
|
|
201
|
+
await self._refresh()
|
|
202
|
+
self._interval.apply()
|
|
203
|
+
self.run_worker(self._widget_context.on_change(self._interval.reapply), exclusive=True)
|
|
204
|
+
|
|
205
|
+
async def _refresh(self) -> None:
|
|
206
|
+
if not widget_is_attached(self):
|
|
207
|
+
return
|
|
208
|
+
snapshot = await self._widget_context.views.containers()
|
|
209
|
+
if not widget_is_attached(self):
|
|
210
|
+
return
|
|
211
|
+
ruled = not is_first_child(self)
|
|
212
|
+
self.update_if_changed(
|
|
213
|
+
docker_section(snapshot).with_ruled(ruled=ruled), key=(snapshot, ruled)
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class DockerWidget:
|
|
218
|
+
"""The `docker` dashboard widget plugin."""
|
|
219
|
+
|
|
220
|
+
name = "docker"
|
|
221
|
+
placement = WidgetPlacement.DIAGNOSTICS
|
|
222
|
+
|
|
223
|
+
def create(self, context: WidgetContext) -> Widget:
|
|
224
|
+
"""Build the Docker panel for `context`."""
|
|
225
|
+
return DockerPanel(context)
|