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,244 @@
|
|
|
1
|
+
"""Folds of the task details: a header with a live headline above a body that shows when open."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
6
|
+
|
|
7
|
+
from textual.binding import Binding
|
|
8
|
+
from textual.containers import Vertical
|
|
9
|
+
from textual.message import Message
|
|
10
|
+
from textual.widgets import Static
|
|
11
|
+
|
|
12
|
+
from shikiru.dashboard.details_list_view import DetailsEntry, entry_renderable
|
|
13
|
+
from shikiru.dashboard.row_list import RowList
|
|
14
|
+
from shikiru.ui.tokens import STEEL_BLUE
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import Sequence
|
|
18
|
+
|
|
19
|
+
from textual import events
|
|
20
|
+
from textual.app import ComposeResult
|
|
21
|
+
from textual.binding import BindingType
|
|
22
|
+
from textual.widget import Widget
|
|
23
|
+
|
|
24
|
+
OPEN_MARK = "\N{BLACK DOWN-POINTING SMALL TRIANGLE}"
|
|
25
|
+
CLOSED_MARK = "\N{BLACK RIGHT-POINTING SMALL TRIANGLE}"
|
|
26
|
+
_STEP_UP = -1
|
|
27
|
+
_STEP_DOWN = 1
|
|
28
|
+
_DISPLAY_NONE = "none"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class FoldHeader(Static):
|
|
32
|
+
"""One focusable header row; ↑↓ move, Enter opens and enters or folds, Space toggles."""
|
|
33
|
+
|
|
34
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
35
|
+
Binding("up", f"move({_STEP_UP})", "Previous section", show=False),
|
|
36
|
+
Binding("down", f"move({_STEP_DOWN})", "Next section", show=False),
|
|
37
|
+
Binding("enter", "enter", "Open or close", show=False),
|
|
38
|
+
Binding("space", "toggle_open", "Open or close", show=False),
|
|
39
|
+
]
|
|
40
|
+
DEFAULT_CSS = """
|
|
41
|
+
FoldHeader { height: 1; width: 100%; }
|
|
42
|
+
FoldHeader:focus { background: $selected-surface; background-tint: transparent; }
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, fold: DetailFold) -> None:
|
|
46
|
+
super().__init__("", markup=False)
|
|
47
|
+
self._fold = fold
|
|
48
|
+
|
|
49
|
+
def allow_focus(self) -> bool:
|
|
50
|
+
"""Only the current header is a Tab stop, so the whole list is one stop."""
|
|
51
|
+
return self._fold.is_current
|
|
52
|
+
|
|
53
|
+
def on_focus(self) -> None:
|
|
54
|
+
self._fold.set_focused(focused=True)
|
|
55
|
+
|
|
56
|
+
def on_blur(self) -> None:
|
|
57
|
+
self._fold.set_focused(focused=False)
|
|
58
|
+
|
|
59
|
+
def on_click(self, event: events.Click) -> None:
|
|
60
|
+
event.stop()
|
|
61
|
+
self._fold.folds.focus_header(self._fold)
|
|
62
|
+
self._fold.toggle()
|
|
63
|
+
|
|
64
|
+
def action_move(self, step: int) -> None:
|
|
65
|
+
"""Focus the neighbouring header."""
|
|
66
|
+
self._fold.folds.move(self._fold, step)
|
|
67
|
+
|
|
68
|
+
def action_enter(self) -> None:
|
|
69
|
+
"""Fold an open fold; open a closed one and enter its list."""
|
|
70
|
+
self._fold.toggle()
|
|
71
|
+
|
|
72
|
+
def action_toggle_open(self) -> None:
|
|
73
|
+
"""Open or fold the body without leaving the header."""
|
|
74
|
+
self._fold.set_open(not self._fold.is_open)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class DetailFold(Vertical):
|
|
78
|
+
"""A section header with a live headline and a body whose display follows the fold state."""
|
|
79
|
+
|
|
80
|
+
DEFAULT_CSS = """
|
|
81
|
+
DetailFold { height: auto; }
|
|
82
|
+
DetailFold .fold-body { height: auto; padding: 0 0 1 2; }
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
class Toggled(Message):
|
|
86
|
+
"""The fold opened or closed."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, fold: DetailFold) -> None:
|
|
89
|
+
super().__init__()
|
|
90
|
+
self.fold = fold
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
view_id: str,
|
|
95
|
+
title: str,
|
|
96
|
+
body: Sequence[Widget],
|
|
97
|
+
*,
|
|
98
|
+
is_open: bool = False,
|
|
99
|
+
name_color: str = STEEL_BLUE,
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Hold the section's view id, name in its colour, and body."""
|
|
102
|
+
super().__init__()
|
|
103
|
+
self.view_id = view_id
|
|
104
|
+
self.title = title
|
|
105
|
+
self.headline: str | None = None
|
|
106
|
+
self.name_color = name_color
|
|
107
|
+
self.header = FoldHeader(self)
|
|
108
|
+
self._body = Vertical(*body, classes="fold-body")
|
|
109
|
+
self._open = is_open
|
|
110
|
+
self._focused = False
|
|
111
|
+
self._body.display = self._open
|
|
112
|
+
|
|
113
|
+
def compose(self) -> ComposeResult:
|
|
114
|
+
"""Yield the header, then the body."""
|
|
115
|
+
yield self.header
|
|
116
|
+
yield self._body
|
|
117
|
+
|
|
118
|
+
def on_mount(self) -> None:
|
|
119
|
+
"""Paint the header once it exists."""
|
|
120
|
+
self.paint()
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def folds(self) -> DetailFolds:
|
|
124
|
+
"""The list this fold belongs to: the nearest one above it, so a nested fold has its own."""
|
|
125
|
+
return next(node for node in self.ancestors if isinstance(node, DetailFolds))
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def is_shown(self) -> bool:
|
|
129
|
+
"""Whether the header is on screen: shown itself, inside no hidden or folded fold."""
|
|
130
|
+
if self.styles.display == _DISPLAY_NONE:
|
|
131
|
+
return False
|
|
132
|
+
enclosing = next((node for node in self.ancestors if isinstance(node, DetailFold)), None)
|
|
133
|
+
return enclosing is None or (enclosing.is_open and enclosing.is_shown)
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def is_open(self) -> bool:
|
|
137
|
+
"""Whether the body is shown."""
|
|
138
|
+
return self._open
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def is_current(self) -> bool:
|
|
142
|
+
"""Whether the header is the list's Tab stop."""
|
|
143
|
+
return self.folds.is_current(self)
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def focus_target(self) -> Widget:
|
|
147
|
+
"""The row list inside an open body, otherwise the header."""
|
|
148
|
+
lists = self._body.query(RowList) if self._open else None
|
|
149
|
+
return lists.first(RowList) if lists else self.header
|
|
150
|
+
|
|
151
|
+
def set_open(self, is_open: bool) -> None:
|
|
152
|
+
"""Show or hide the body and tell the owner when the state changed."""
|
|
153
|
+
if is_open == self._open:
|
|
154
|
+
return
|
|
155
|
+
self._open = is_open
|
|
156
|
+
self._body.display = is_open
|
|
157
|
+
self.paint()
|
|
158
|
+
self.post_message(self.Toggled(self))
|
|
159
|
+
|
|
160
|
+
def set_focused(self, *, focused: bool) -> None:
|
|
161
|
+
"""Highlight the header while it has focus."""
|
|
162
|
+
self._focused = focused
|
|
163
|
+
self.paint()
|
|
164
|
+
|
|
165
|
+
def set_headline(self, headline: str | None) -> None:
|
|
166
|
+
"""Replace the grey right-hand headline."""
|
|
167
|
+
if headline != self.headline:
|
|
168
|
+
self.headline = headline
|
|
169
|
+
self.paint()
|
|
170
|
+
|
|
171
|
+
def activate(self) -> None:
|
|
172
|
+
"""Open the fold and move into its list when it has one."""
|
|
173
|
+
self.set_open(True)
|
|
174
|
+
self.call_after_refresh(self.folds.focus_fold, self)
|
|
175
|
+
|
|
176
|
+
def toggle(self) -> None:
|
|
177
|
+
"""Fold an open fold back to its header; open a closed one and enter its list."""
|
|
178
|
+
if self._open:
|
|
179
|
+
self.set_open(False)
|
|
180
|
+
self.folds.focus_header(self)
|
|
181
|
+
else:
|
|
182
|
+
self.activate()
|
|
183
|
+
|
|
184
|
+
def paint(self) -> None:
|
|
185
|
+
"""Draw the header: mark, name and headline, highlighted while it has focus."""
|
|
186
|
+
if not self.header.is_mounted:
|
|
187
|
+
return
|
|
188
|
+
mark = OPEN_MARK if self._open else CLOSED_MARK
|
|
189
|
+
entry = DetailsEntry(
|
|
190
|
+
self.view_id, f"{mark} {self.title}", self.headline, name_color=self.name_color
|
|
191
|
+
)
|
|
192
|
+
self.header.update(entry_renderable(entry, selected=self._focused))
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class DetailFolds(Vertical):
|
|
196
|
+
"""The list of folds: one Tab stop, ↑↓ between headers."""
|
|
197
|
+
|
|
198
|
+
DEFAULT_CSS = "DetailFolds { height: auto; border-top: solid $neutral-border; }"
|
|
199
|
+
|
|
200
|
+
def __init__(self, *, widget_id: str) -> None:
|
|
201
|
+
super().__init__(id=widget_id)
|
|
202
|
+
self._current: DetailFold | None = None
|
|
203
|
+
|
|
204
|
+
@property
|
|
205
|
+
def all_folds(self) -> list[DetailFold]:
|
|
206
|
+
"""Every fold in display order, shown or not."""
|
|
207
|
+
return list(self.query(DetailFold))
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def visible_folds(self) -> list[DetailFold]:
|
|
211
|
+
"""The folds shown, in display order; a fold inside a closed or hidden one has no header."""
|
|
212
|
+
return [fold for fold in self.all_folds if fold.is_shown]
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def current_fold(self) -> DetailFold:
|
|
216
|
+
"""The fold holding the roving Tab stop: the last one focused, else the first shown."""
|
|
217
|
+
visible = self.visible_folds
|
|
218
|
+
if self._current is not None and self._current in visible:
|
|
219
|
+
return self._current
|
|
220
|
+
return visible[0]
|
|
221
|
+
|
|
222
|
+
def is_current(self, fold: DetailFold) -> bool:
|
|
223
|
+
"""Whether `fold` holds the roving Tab stop; a hidden fold never does."""
|
|
224
|
+
return fold.is_shown and self.current_fold is fold
|
|
225
|
+
|
|
226
|
+
def make_current(self, fold: DetailFold) -> None:
|
|
227
|
+
"""Move the Tab stop to `fold`."""
|
|
228
|
+
self._current = fold
|
|
229
|
+
|
|
230
|
+
def move(self, fold: DetailFold, step: int) -> None:
|
|
231
|
+
"""Focus the header `step` places from `fold`, stopping at both ends."""
|
|
232
|
+
folds = self.visible_folds
|
|
233
|
+
target = folds[min(max(folds.index(fold) + step, 0), len(folds) - 1)]
|
|
234
|
+
self.focus_header(target)
|
|
235
|
+
|
|
236
|
+
def focus_header(self, fold: DetailFold) -> None:
|
|
237
|
+
"""Make `fold` the Tab stop and focus its header."""
|
|
238
|
+
self.make_current(fold)
|
|
239
|
+
self.screen.set_focus(fold.header)
|
|
240
|
+
|
|
241
|
+
def focus_fold(self, fold: DetailFold) -> None:
|
|
242
|
+
"""Focus the row list of an open fold, or its header."""
|
|
243
|
+
self.make_current(fold)
|
|
244
|
+
self.screen.set_focus(fold.focus_target)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""The one hint line at the bottom of the task details, following the focused part."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
from textual.widgets import Static
|
|
9
|
+
|
|
10
|
+
from shikiru.ui.section import BOLD_STEEL, grey
|
|
11
|
+
|
|
12
|
+
_ARROWS = "\N{UPWARDS ARROW}\N{DOWNWARDS ARROW}"
|
|
13
|
+
_SEPARATOR = " · "
|
|
14
|
+
_BACK = ("Esc", "back")
|
|
15
|
+
_SELECT = (_ARROWS, "select")
|
|
16
|
+
_FOLLOW = ("End", "follow")
|
|
17
|
+
_TASK_WINDOW = ("t", "task window")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HintTarget(StrEnum):
|
|
21
|
+
"""What holds focus inside the task details."""
|
|
22
|
+
|
|
23
|
+
HEADER = "header"
|
|
24
|
+
EVENTS = "events"
|
|
25
|
+
AGENT = "agent"
|
|
26
|
+
AGENT_FILTER = "agent_filter"
|
|
27
|
+
DEPENDENCIES = "dependencies"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_HINTS: dict[HintTarget, tuple[tuple[str, str], ...]] = {
|
|
31
|
+
HintTarget.HEADER: ((_ARROWS, "move"), ("Enter", "open/close")),
|
|
32
|
+
HintTarget.EVENTS: (_SELECT, _FOLLOW, _BACK),
|
|
33
|
+
HintTarget.AGENT: (_SELECT, _FOLLOW, ("f", "filter"), _BACK),
|
|
34
|
+
HintTarget.AGENT_FILTER: (("r", "role"), ("t", "turn"), ("x", "clear"), _BACK),
|
|
35
|
+
HintTarget.DEPENDENCIES: (_SELECT, ("Enter", "open"), _BACK),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def hint_for(target: HintTarget, *, task_window: bool, keys_only: bool) -> Text:
|
|
40
|
+
"""Return the hint for `target`: cyan bold keys, grey labels, only keys in tiny panes.
|
|
41
|
+
|
|
42
|
+
Esc is named only inside a list, where it returns to the header.
|
|
43
|
+
"""
|
|
44
|
+
entries = _HINTS[target] + ((_TASK_WINDOW,) if task_window else ())
|
|
45
|
+
hint = Text()
|
|
46
|
+
for index, (key, label) in enumerate(entries):
|
|
47
|
+
if index:
|
|
48
|
+
hint.append_text(grey(_SEPARATOR))
|
|
49
|
+
hint.append(key, style=BOLD_STEEL)
|
|
50
|
+
if not keys_only:
|
|
51
|
+
hint.append_text(grey(f" {label}"))
|
|
52
|
+
return hint
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class DetailHintBar(Static):
|
|
56
|
+
"""One-row line below the scrolling details; hidden while nothing in them has focus."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, *, widget_id: str) -> None:
|
|
59
|
+
super().__init__("", id=widget_id, markup=False)
|
|
60
|
+
self.display = False
|
|
61
|
+
self._hint: Text | None = None
|
|
62
|
+
|
|
63
|
+
def show(self, hint: Text | None) -> None:
|
|
64
|
+
"""Show `hint`, or hide the line when there is none."""
|
|
65
|
+
self.display = hint is not None
|
|
66
|
+
if hint is not None and hint != self._hint:
|
|
67
|
+
self.update(hint)
|
|
68
|
+
self._hint = hint
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Rows of the task detail folds: section names, their data headlines and the row renderable."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from rich.cells import cell_len
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
from shikiru.dashboard.briefing_view import total_cost_text
|
|
13
|
+
from shikiru.dashboard.fold_bodies import priority_word
|
|
14
|
+
from shikiru.domain.results import ResultOutcome
|
|
15
|
+
from shikiru.domain.tasks import Status
|
|
16
|
+
from shikiru.ui.section import GROUP_SEPARATOR, grey
|
|
17
|
+
from shikiru.ui.tokens import STEEL_BLUE, STYLE_BOLD
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
|
|
22
|
+
from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
|
|
23
|
+
|
|
24
|
+
from shikiru.dashboard.contract import TaskDetailView
|
|
25
|
+
|
|
26
|
+
ROW_LEAD = " "
|
|
27
|
+
_HEADLINE_GAP = " "
|
|
28
|
+
DECISION_OPEN = "question open"
|
|
29
|
+
_PR_WORD = "PR"
|
|
30
|
+
_DECISION_NOUNS = ("decision", "decisions")
|
|
31
|
+
_COMMIT_NOUNS = {True: "commit", False: "commits"}
|
|
32
|
+
_BRANCH_WORD = "branch"
|
|
33
|
+
_PRIORITY_WORD = "priority"
|
|
34
|
+
_DEPENDENCY_NOUNS = ("dependency", "dependencies")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DetailsSection(StrEnum):
|
|
38
|
+
"""The task detail sections, in fold order, named as the headers show them."""
|
|
39
|
+
|
|
40
|
+
REQUEST = "Task description"
|
|
41
|
+
RESULTS = "Results"
|
|
42
|
+
DECISIONS = "Decisions"
|
|
43
|
+
EVENTS = "Events"
|
|
44
|
+
AGENT_ACTIVITY = "Agent activity"
|
|
45
|
+
USAGE = "Usage"
|
|
46
|
+
BLOCKERS = "Dependencies"
|
|
47
|
+
BLOCKER_EVIDENCE = "Evidence"
|
|
48
|
+
WORKSPACE = "Workspace"
|
|
49
|
+
SETTINGS = "Settings"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class DetailsEntry:
|
|
54
|
+
"""One fold header row: what it opens, its name in its colour and its optional grey headline."""
|
|
55
|
+
|
|
56
|
+
entry_id: str
|
|
57
|
+
name: str
|
|
58
|
+
headline: str | None = None
|
|
59
|
+
name_color: str = STEEL_BLUE
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def section_headline(section: DetailsSection, view: TaskDetailView | None) -> str | None:
|
|
63
|
+
"""Return a section's headline from recorded fields, or nothing when it has none."""
|
|
64
|
+
headline = _HEADLINES.get(section)
|
|
65
|
+
return headline(view) if headline is not None and view is not None else None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class _EntryLine:
|
|
70
|
+
entry: DetailsEntry
|
|
71
|
+
selected: bool
|
|
72
|
+
|
|
73
|
+
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
74
|
+
del console
|
|
75
|
+
entry = self.entry
|
|
76
|
+
room = max(options.max_width - len(ROW_LEAD), 1)
|
|
77
|
+
headline = entry.headline or ""
|
|
78
|
+
if headline and cell_len(entry.name) + cell_len(_HEADLINE_GAP) + cell_len(headline) > room:
|
|
79
|
+
headline = ""
|
|
80
|
+
name = Text(entry.name, style=_name_style(entry, selected=self.selected))
|
|
81
|
+
name.truncate(room - cell_len(headline), overflow="ellipsis", pad=True)
|
|
82
|
+
line = Text(ROW_LEAD)
|
|
83
|
+
line.append_text(name)
|
|
84
|
+
line.append_text(grey(headline))
|
|
85
|
+
yield line
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def entry_renderable(entry: DetailsEntry, *, selected: bool) -> RenderableType:
|
|
89
|
+
"""Return one row: the name in its colour (bold when selected), grey headline at the right."""
|
|
90
|
+
return _EntryLine(entry, selected)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _name_style(entry: DetailsEntry, *, selected: bool) -> str:
|
|
94
|
+
return f"{STYLE_BOLD} {entry.name_color}" if selected else entry.name_color
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _usage_headline(view: TaskDetailView) -> str | None:
|
|
98
|
+
return total_cost_text(view.sessions)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _workspace_headline(view: TaskDetailView) -> str | None:
|
|
102
|
+
return f"{_BRANCH_WORD} {view.branch}" if view.branch else None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _settings_headline(view: TaskDetailView) -> str | None:
|
|
106
|
+
parts = [
|
|
107
|
+
view.mode or "",
|
|
108
|
+
f"{priority_word(view.priority)} {_PRIORITY_WORD}" if view.priority is not None else "",
|
|
109
|
+
]
|
|
110
|
+
return GROUP_SEPARATOR.join(part for part in parts if part) or None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _results_headline(view: TaskDetailView) -> str | None:
|
|
114
|
+
results = view.results
|
|
115
|
+
if results is None:
|
|
116
|
+
return None
|
|
117
|
+
if results.outcome is ResultOutcome.SO_FAR:
|
|
118
|
+
return ResultOutcome.SO_FAR.value
|
|
119
|
+
commits = len(results.commits) + results.more_commits
|
|
120
|
+
pull_request = results.pull_request
|
|
121
|
+
parts = [
|
|
122
|
+
f"{commits} {_COMMIT_NOUNS[commits == 1]}" if commits else "",
|
|
123
|
+
f"{_PR_WORD} #{pull_request.number}"
|
|
124
|
+
if pull_request is not None and pull_request.number is not None
|
|
125
|
+
else "",
|
|
126
|
+
]
|
|
127
|
+
return GROUP_SEPARATOR.join(part for part in parts if part) or None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _decisions_headline(view: TaskDetailView) -> str | None:
|
|
131
|
+
if view.status == Status.ESCALATED.value:
|
|
132
|
+
return DECISION_OPEN
|
|
133
|
+
count = len(view.decisions.entries) if view.decisions is not None else 0
|
|
134
|
+
return f"{count} {_DECISION_NOUNS[count != 1]}" if count else None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _dependencies_headline(view: TaskDetailView) -> str | None:
|
|
138
|
+
relationships = view.relationships
|
|
139
|
+
count = relationships.blocking_dependency_count if relationships is not None else 0
|
|
140
|
+
return f"{count} {_DEPENDENCY_NOUNS[count != 1]}" if count else None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
_HEADLINES: dict[DetailsSection, Callable[[TaskDetailView], str | None]] = {
|
|
144
|
+
DetailsSection.RESULTS: _results_headline,
|
|
145
|
+
DetailsSection.DECISIONS: _decisions_headline,
|
|
146
|
+
DetailsSection.USAGE: _usage_headline,
|
|
147
|
+
DetailsSection.WORKSPACE: _workspace_headline,
|
|
148
|
+
DetailsSection.BLOCKERS: _dependencies_headline,
|
|
149
|
+
DetailsSection.SETTINGS: _settings_headline,
|
|
150
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Project-level diagnostics from the dashboard's existing observations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from textual.containers import VerticalGroup
|
|
8
|
+
|
|
9
|
+
from shikiru.dashboard.diagnostics_sections import engine_section, resources_section
|
|
10
|
+
from shikiru.ui.gated_static import GatedStatic
|
|
11
|
+
from shikiru.ui.screen import widget_is_attached
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
|
|
16
|
+
from textual.app import ComposeResult
|
|
17
|
+
from textual.widget import Widget
|
|
18
|
+
from textual.worker import Worker
|
|
19
|
+
|
|
20
|
+
from shikiru.dashboard.contract import DashboardViews, OnChangeRegistration
|
|
21
|
+
|
|
22
|
+
DIAGNOSTICS_REFRESH_GROUP = "project-diagnostics-refresh"
|
|
23
|
+
RESOURCES_SECTION_ID = "project-diagnostics-resources"
|
|
24
|
+
ENGINE_SECTION_ID = "project-diagnostics-engine"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ProjectDiagnosticsPanel(VerticalGroup):
|
|
28
|
+
"""Show the cached Resources and Engine sections above every configured diagnostics widget."""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
views: DashboardViews,
|
|
33
|
+
on_change: OnChangeRegistration,
|
|
34
|
+
widgets: tuple[Widget, ...],
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Use existing dashboard projections and the configured diagnostics widgets in order."""
|
|
37
|
+
super().__init__(id="project-diagnostics")
|
|
38
|
+
self._views = views
|
|
39
|
+
self._on_change = on_change
|
|
40
|
+
self._widgets = widgets
|
|
41
|
+
self._resources = GatedStatic(id=RESOURCES_SECTION_ID, markup=False)
|
|
42
|
+
self._engine = GatedStatic(id=ENGINE_SECTION_ID, markup=False)
|
|
43
|
+
self._change_worker: Worker[None] | None = None
|
|
44
|
+
self._unsubscribe_resources: Callable[[], None] | None = None
|
|
45
|
+
|
|
46
|
+
def compose(self) -> ComposeResult:
|
|
47
|
+
"""Build the permanent sections, then the configured diagnostics widgets."""
|
|
48
|
+
yield self._resources
|
|
49
|
+
yield self._engine
|
|
50
|
+
yield from self._widgets
|
|
51
|
+
|
|
52
|
+
def on_mount(self) -> None:
|
|
53
|
+
"""Render the cached observations and follow the Resources bar's refreshes and commits."""
|
|
54
|
+
self._refresh()
|
|
55
|
+
self._unsubscribe_resources = self._views.on_resources_refreshed(self._refresh)
|
|
56
|
+
self._change_worker = self.run_worker(
|
|
57
|
+
self._on_change(self._refresh_if_attached),
|
|
58
|
+
group=DIAGNOSTICS_REFRESH_GROUP,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def on_unmount(self) -> None:
|
|
62
|
+
if self._unsubscribe_resources is not None:
|
|
63
|
+
self._unsubscribe_resources()
|
|
64
|
+
self._unsubscribe_resources = None
|
|
65
|
+
if self._change_worker is not None:
|
|
66
|
+
self._change_worker.cancel()
|
|
67
|
+
|
|
68
|
+
async def _refresh_if_attached(self) -> None:
|
|
69
|
+
self._refresh()
|
|
70
|
+
|
|
71
|
+
def _refresh(self) -> None:
|
|
72
|
+
if not widget_is_attached(self):
|
|
73
|
+
return
|
|
74
|
+
sample = self._views.resource_sample()
|
|
75
|
+
status = self._views.engine_status()
|
|
76
|
+
fault_count = len(self._views.engine_faults())
|
|
77
|
+
self._resources.update_if_changed(resources_section(sample), key=sample)
|
|
78
|
+
self._engine.update_if_changed(
|
|
79
|
+
engine_section(status, fault_count), key=(status, fault_count)
|
|
80
|
+
)
|