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,1209 @@
|
|
|
1
|
+
"""Shared task detail presentation for the workspace and fixed task view."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
7
|
+
from dataclasses import replace
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING, ClassVar, cast
|
|
10
|
+
|
|
11
|
+
from rich.cells import cell_len
|
|
12
|
+
from rich.console import Group
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
from textual import on
|
|
15
|
+
from textual.binding import Binding
|
|
16
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
17
|
+
from textual.screen import ModalScreen
|
|
18
|
+
from textual.widgets import Button, Label, Rule, Static
|
|
19
|
+
|
|
20
|
+
from shikiru.clock import to_local
|
|
21
|
+
from shikiru.dashboard.blocker_evidence import evidence_body, evidence_headline, raw_evidence_text
|
|
22
|
+
from shikiru.dashboard.briefing_view import (
|
|
23
|
+
ACTION_LABELS,
|
|
24
|
+
TERMINAL_STATUSES,
|
|
25
|
+
Briefing,
|
|
26
|
+
BriefingHalf,
|
|
27
|
+
BriefingLayout,
|
|
28
|
+
blockers_value_indent,
|
|
29
|
+
briefing_verbs,
|
|
30
|
+
header_text,
|
|
31
|
+
)
|
|
32
|
+
from shikiru.dashboard.contract import (
|
|
33
|
+
PaletteRequest,
|
|
34
|
+
TaskTimelineCategory,
|
|
35
|
+
)
|
|
36
|
+
from shikiru.dashboard.detail_fold import DetailFold, DetailFolds
|
|
37
|
+
from shikiru.dashboard.detail_hint import DetailHintBar, HintTarget, hint_for
|
|
38
|
+
from shikiru.dashboard.details_list_view import ROW_LEAD, DetailsSection, section_headline
|
|
39
|
+
from shikiru.dashboard.fold_bodies import (
|
|
40
|
+
DecisionsBody,
|
|
41
|
+
DescriptionBody,
|
|
42
|
+
ResultsBody,
|
|
43
|
+
SettingsBody,
|
|
44
|
+
WorkspaceBody,
|
|
45
|
+
)
|
|
46
|
+
from shikiru.dashboard.row_list import RowList
|
|
47
|
+
from shikiru.dashboard.section_rows import RelatedRow, relation_rows
|
|
48
|
+
from shikiru.dashboard.tick import tick_now
|
|
49
|
+
from shikiru.dashboard.timeline_filter import TimelineFilterBar
|
|
50
|
+
from shikiru.dashboard.timeline_section import (
|
|
51
|
+
TimelineHost,
|
|
52
|
+
TimelineIds,
|
|
53
|
+
TimelineSection,
|
|
54
|
+
screen_is_active,
|
|
55
|
+
)
|
|
56
|
+
from shikiru.dashboard.usage_body import UsageBody
|
|
57
|
+
from shikiru.domain.briefing import BriefingAction
|
|
58
|
+
from shikiru.domain.quota_waits import quota_clock_text
|
|
59
|
+
from shikiru.domain.tasks import Role, Status, StatusReason
|
|
60
|
+
from shikiru.errors import ShikiruError
|
|
61
|
+
from shikiru.ui.gated_static import GatedStatic
|
|
62
|
+
from shikiru.ui.popup import (
|
|
63
|
+
POPUP_CLOSE_CLASS,
|
|
64
|
+
POPUP_CSS,
|
|
65
|
+
POPUP_FRAME_CLASS,
|
|
66
|
+
POPUP_HEADER_CLASS,
|
|
67
|
+
POPUP_RULE_CLASS,
|
|
68
|
+
POPUP_TITLE_CLASS,
|
|
69
|
+
PopupFrame,
|
|
70
|
+
popup_close_label,
|
|
71
|
+
prepare_popup,
|
|
72
|
+
)
|
|
73
|
+
from shikiru.ui.screen import widget_is_attached
|
|
74
|
+
from shikiru.ui.section import BOLD_PRIMARY, grey, key_label
|
|
75
|
+
from shikiru.ui.tokens import SECONDARY_TEXT_COLOR, STEEL_BLUE
|
|
76
|
+
|
|
77
|
+
if TYPE_CHECKING:
|
|
78
|
+
from datetime import datetime
|
|
79
|
+
|
|
80
|
+
from textual import events
|
|
81
|
+
from textual.app import ComposeResult
|
|
82
|
+
from textual.binding import BindingType
|
|
83
|
+
from textual.geometry import Size
|
|
84
|
+
from textual.widget import Widget
|
|
85
|
+
from textual.worker import Worker
|
|
86
|
+
|
|
87
|
+
from shikiru.dashboard.app import DashboardApp
|
|
88
|
+
from shikiru.dashboard.contract import (
|
|
89
|
+
BlockerEvidenceView,
|
|
90
|
+
TaskDetailView,
|
|
91
|
+
WidgetContext,
|
|
92
|
+
)
|
|
93
|
+
from shikiru.dashboard.services import TaskWorkSample
|
|
94
|
+
from shikiru.ui.strip import TaskStripData
|
|
95
|
+
|
|
96
|
+
TASK_DETAIL_WORKER_GROUP = "task-detail-updates"
|
|
97
|
+
TASK_DETAIL_ESCAPE = "escape"
|
|
98
|
+
SESSION_STATE_CURRENT = "Current"
|
|
99
|
+
SESSION_STATE_USED = "Used"
|
|
100
|
+
TASK_DETAIL_IDENTITY = "task-detail-identity"
|
|
101
|
+
TASK_DETAIL_OWNERSHIP = "task-detail-ownership"
|
|
102
|
+
TASK_DETAIL_BRIEFING = "task-detail-briefing"
|
|
103
|
+
TASK_DETAIL_BRIEFING_BELOW = "task-detail-briefing-below"
|
|
104
|
+
TASK_DETAIL_PROBLEM_FOLDS = "task-detail-problem-folds"
|
|
105
|
+
TASK_DETAIL_ACTIONS = "task-detail-actions"
|
|
106
|
+
TASK_DETAIL_VERBS = "task-detail-verbs"
|
|
107
|
+
TASK_DETAIL_ACTION_PREFIX = "task-detail-action-"
|
|
108
|
+
TASK_DETAIL_POPUP_FRAME = "task-detail-popup-frame"
|
|
109
|
+
TASK_DETAIL_POPUP_HEADER = "task-detail-popup-header"
|
|
110
|
+
TASK_DETAIL_POPUP_TITLE = "task-detail-popup-title"
|
|
111
|
+
TASK_DETAIL_POPUP_CLOSE = "task-detail-popup-close"
|
|
112
|
+
TASK_DETAIL_REQUEST = "task-detail-request"
|
|
113
|
+
TASK_DETAIL_RESULTS = "task-detail-results"
|
|
114
|
+
TASK_DETAIL_DECISIONS = "task-detail-decisions"
|
|
115
|
+
TASK_DETAIL_WORKSPACE = "task-detail-workspace"
|
|
116
|
+
TASK_DETAIL_SESSIONS = "task-detail-sessions"
|
|
117
|
+
TASK_DETAIL_FEEDBACK = "task-detail-feedback"
|
|
118
|
+
TASK_DETAIL_BODY = "task-detail-body"
|
|
119
|
+
TASK_DETAIL_FOLDS = "task-detail-folds"
|
|
120
|
+
FOLDS_COMPACT_CLASS = "folds-compact"
|
|
121
|
+
FOLDS_TINY_CLASS = "folds-tiny"
|
|
122
|
+
TASK_DETAIL_CONTEXT_INFO = "task-detail-context-info"
|
|
123
|
+
TASK_DETAIL_TINY_MAX_ROWS = 8
|
|
124
|
+
_TASK_DETAIL_COMPACT_MAX_COLUMNS = 48
|
|
125
|
+
_TASK_DETAIL_COMPACT_MAX_ROWS = 20
|
|
126
|
+
TASK_DETAIL_VIEWS_KEY = "v"
|
|
127
|
+
DETAILS_HINT_LABEL = "details"
|
|
128
|
+
DETAILS_HINT_TEXT = f"{TASK_DETAIL_VIEWS_KEY} {DETAILS_HINT_LABEL}"
|
|
129
|
+
TASK_DETAIL_DETAILS_HINT = "task-detail-details-hint"
|
|
130
|
+
VERB_MARGIN = 2
|
|
131
|
+
TASK_DETAIL_HELP_KEY = "question_mark"
|
|
132
|
+
TASK_DETAIL_HINT = "task-detail-hint"
|
|
133
|
+
TASK_WINDOW_KEY = "t"
|
|
134
|
+
TASK_WINDOW_ACTION = "task_window"
|
|
135
|
+
TASK_DETAIL_EVIDENCE = "task-detail-evidence"
|
|
136
|
+
TASK_DETAIL_RAW_EVENTS = "task-detail-raw-events"
|
|
137
|
+
RAW_EVENTS_TITLE = "Raw events"
|
|
138
|
+
TASK_DETAIL_RELATIONSHIPS = "task-detail-relationships"
|
|
139
|
+
TASK_DETAIL_RELATED_LIST = "task-detail-related-list"
|
|
140
|
+
EVENTS_TIMELINE_IDS = TimelineIds.with_prefix("task-detail-timeline")
|
|
141
|
+
AGENT_TIMELINE_IDS = TimelineIds.with_prefix("task-detail-agent")
|
|
142
|
+
TASK_DETAIL_AGENT_LIST = AGENT_TIMELINE_IDS.list
|
|
143
|
+
TASK_DETAIL_AGENT_SELECTED = AGENT_TIMELINE_IDS.selected
|
|
144
|
+
TASK_DETAIL_TIMELINE_LIST = EVENTS_TIMELINE_IDS.list
|
|
145
|
+
TASK_DETAIL_TIMELINE_SELECTED = EVENTS_TIMELINE_IDS.selected
|
|
146
|
+
TASK_VIEW_SUMMARY = "task-summary"
|
|
147
|
+
TASK_VIEW_CONTEXT = "task-context"
|
|
148
|
+
TASK_VIEW_USAGE = "task-usage"
|
|
149
|
+
TASK_VIEW_RESULTS = "task-results"
|
|
150
|
+
TASK_VIEW_DECISIONS = "task-decisions"
|
|
151
|
+
TASK_VIEW_REQUEST = "task-request"
|
|
152
|
+
TASK_VIEW_EVIDENCE = "task-evidence"
|
|
153
|
+
TASK_VIEW_RELATIONSHIPS = "task-relationships"
|
|
154
|
+
TASK_VIEW_BLOCKER_EVIDENCE = "task-blocker-evidence"
|
|
155
|
+
TASK_VIEW_RAW_EVENTS = "task-raw-events"
|
|
156
|
+
TASK_VIEW_WORKSPACE = "task-workspace"
|
|
157
|
+
TASK_VIEW_AGENT = "task-agent"
|
|
158
|
+
DETAILS_SECTION_VIEWS = {
|
|
159
|
+
DetailsSection.REQUEST: TASK_VIEW_REQUEST,
|
|
160
|
+
DetailsSection.RESULTS: TASK_VIEW_RESULTS,
|
|
161
|
+
DetailsSection.DECISIONS: TASK_VIEW_DECISIONS,
|
|
162
|
+
DetailsSection.EVENTS: TASK_VIEW_EVIDENCE,
|
|
163
|
+
DetailsSection.AGENT_ACTIVITY: TASK_VIEW_AGENT,
|
|
164
|
+
DetailsSection.USAGE: TASK_VIEW_USAGE,
|
|
165
|
+
DetailsSection.BLOCKERS: TASK_VIEW_RELATIONSHIPS,
|
|
166
|
+
DetailsSection.BLOCKER_EVIDENCE: TASK_VIEW_BLOCKER_EVIDENCE,
|
|
167
|
+
DetailsSection.WORKSPACE: TASK_VIEW_WORKSPACE,
|
|
168
|
+
DetailsSection.SETTINGS: TASK_VIEW_CONTEXT,
|
|
169
|
+
}
|
|
170
|
+
TIMELINE_SECTIONS = {
|
|
171
|
+
DetailsSection.EVENTS: (EVENTS_TIMELINE_IDS, TaskTimelineCategory.ACTIVITY, False),
|
|
172
|
+
DetailsSection.AGENT_ACTIVITY: (AGENT_TIMELINE_IDS, TaskTimelineCategory.AGENT, True),
|
|
173
|
+
}
|
|
174
|
+
PROBLEM_SECTION = DetailsSection.BLOCKER_EVIDENCE
|
|
175
|
+
MAIN_SECTIONS = tuple(section for section in DetailsSection if section is not PROBLEM_SECTION)
|
|
176
|
+
FOLD_STATICS = {
|
|
177
|
+
TASK_VIEW_USAGE: (TASK_DETAIL_SESSIONS,),
|
|
178
|
+
TASK_VIEW_CONTEXT: (TASK_DETAIL_CONTEXT_INFO,),
|
|
179
|
+
TASK_VIEW_RESULTS: (TASK_DETAIL_RESULTS,),
|
|
180
|
+
TASK_VIEW_DECISIONS: (TASK_DETAIL_DECISIONS,),
|
|
181
|
+
TASK_VIEW_REQUEST: (TASK_DETAIL_REQUEST,),
|
|
182
|
+
TASK_VIEW_RELATIONSHIPS: (TASK_DETAIL_RELATIONSHIPS,),
|
|
183
|
+
TASK_VIEW_BLOCKER_EVIDENCE: (TASK_DETAIL_EVIDENCE,),
|
|
184
|
+
TASK_VIEW_WORKSPACE: (TASK_DETAIL_WORKSPACE,),
|
|
185
|
+
}
|
|
186
|
+
FOLD_HINT_TARGETS = {
|
|
187
|
+
TASK_VIEW_EVIDENCE: HintTarget.EVENTS,
|
|
188
|
+
TASK_VIEW_AGENT: HintTarget.AGENT,
|
|
189
|
+
TASK_VIEW_RELATIONSHIPS: HintTarget.DEPENDENCIES,
|
|
190
|
+
}
|
|
191
|
+
NO_DECISION_TO_ANSWER = "No open decision to answer for this task."
|
|
192
|
+
NO_RELATED_ROWS = "No related tasks are recorded."
|
|
193
|
+
RELATIONSHIPS_UNAVAILABLE = "Relationship evidence is unavailable."
|
|
194
|
+
NO_REPLACEMENT_TASKS = "No replacement task IDs are present in the recorded scope-change event."
|
|
195
|
+
CLOSED_RUN_STATUS = "closed"
|
|
196
|
+
PAGE_LABEL = "page"
|
|
197
|
+
COST_VALUE_FORMAT = ".2f"
|
|
198
|
+
AGE_NOW = "now"
|
|
199
|
+
COMPACT_INSPECTOR_CLASS = "compact-inspector"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class TaskDetailBody(VerticalScroll, can_focus=False):
|
|
203
|
+
"""Scrolls the detail to its focused child; it is never a Tab stop itself."""
|
|
204
|
+
|
|
205
|
+
def on_resize(self, event: events.Resize) -> None:
|
|
206
|
+
del event
|
|
207
|
+
focused = self.screen.focused
|
|
208
|
+
if focused is not None and self in focused.ancestors and not self.can_view_entire(focused):
|
|
209
|
+
self.call_after_refresh(
|
|
210
|
+
self.scroll_to_center,
|
|
211
|
+
focused,
|
|
212
|
+
animate=False,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class TaskDetailHint(Static):
|
|
217
|
+
"""The right-aligned `v details` hint of the actions row; clicking it focuses the sections."""
|
|
218
|
+
|
|
219
|
+
def __init__(self, hint: Text) -> None:
|
|
220
|
+
super().__init__(hint, id=TASK_DETAIL_DETAILS_HINT, markup=False)
|
|
221
|
+
|
|
222
|
+
def on_click(self) -> None:
|
|
223
|
+
"""Focus the section headers, like pressing the key."""
|
|
224
|
+
self.query_ancestor(TaskDetailPanel).focus_folds()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class TaskDetailVerb(Button):
|
|
228
|
+
"""A briefing action button of the task detail, drawn without the stock line padding."""
|
|
229
|
+
|
|
230
|
+
def on_mount(self) -> None:
|
|
231
|
+
"""Drop the one-cell line padding the stock button adds on each side of its label."""
|
|
232
|
+
self.styles.line_pad = 0
|
|
233
|
+
|
|
234
|
+
def get_content_width(self, container: Size, viewport: Size) -> int:
|
|
235
|
+
"""Return the label's own width, without the stock two cells of line padding."""
|
|
236
|
+
del container, viewport
|
|
237
|
+
return cell_len(str(self.label))
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class TaskDetailPanel(Vertical):
|
|
241
|
+
"""Render shared task detail from the injected dashboard view contract."""
|
|
242
|
+
|
|
243
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
244
|
+
Binding(TASK_WINDOW_KEY, TASK_WINDOW_ACTION, "Task window", show=False)
|
|
245
|
+
]
|
|
246
|
+
DEFAULT_CSS = """
|
|
247
|
+
#task-detail-panel { width: 100%; height: auto; }
|
|
248
|
+
#task-detail-identity { height: auto; padding: 0; color: $steel-blue; }
|
|
249
|
+
#task-detail-context-info { height: auto; color: $primary-text; }
|
|
250
|
+
#task-detail-ownership { height: auto; padding: 0; color: $amber; }
|
|
251
|
+
#task-detail-feedback { height: auto; min-height: 0; color: $secondary-text; }
|
|
252
|
+
#task-detail-body {
|
|
253
|
+
height: auto; max-height: 32; padding: 0 1; scrollbar-size-vertical: $scrollbar-width;
|
|
254
|
+
}
|
|
255
|
+
#task-detail-briefing, #task-detail-briefing-below { height: auto; padding: 0; }
|
|
256
|
+
#task-detail-problem-folds { border-top: none; }
|
|
257
|
+
#task-detail-actions {
|
|
258
|
+
width: 100%; height: auto; border-top: solid $neutral-border;
|
|
259
|
+
}
|
|
260
|
+
#task-detail-verbs { width: 1fr; height: 1; }
|
|
261
|
+
#task-detail-details-hint { width: auto; height: 1; }
|
|
262
|
+
#task-detail-verbs Button {
|
|
263
|
+
width: auto; min-width: 1; height: 1; margin: 0 2 0 0; padding: 0;
|
|
264
|
+
border: none; background: $background; color: $steel-blue;
|
|
265
|
+
content-align: left middle; text-align: left; text-style: none;
|
|
266
|
+
}
|
|
267
|
+
#task-detail-actions Button:focus { text-style: reverse; }
|
|
268
|
+
#task-detail-actions Button:hover { color: $primary-text; }
|
|
269
|
+
#task-detail-hint { height: 1; padding: 0 1; }
|
|
270
|
+
.task-detail-section { height: auto; padding: 0; color: $primary-text; }
|
|
271
|
+
TaskDetailPanel RowList { max-height: 10; overflow-y: auto; scrollbar-size-vertical: 1; }
|
|
272
|
+
TaskDetailPanel.folds-compact RowList { max-height: 6; }
|
|
273
|
+
TaskDetailPanel.folds-tiny RowList { max-height: 3; }
|
|
274
|
+
"""
|
|
275
|
+
|
|
276
|
+
def __init__(
|
|
277
|
+
self,
|
|
278
|
+
context: WidgetContext,
|
|
279
|
+
task_id: str | None,
|
|
280
|
+
*,
|
|
281
|
+
fixed: bool,
|
|
282
|
+
close_detail: CloseDetail | None = None,
|
|
283
|
+
initial_view: str = TASK_VIEW_SUMMARY,
|
|
284
|
+
on_initial_content: Callable[[], None] | None = None,
|
|
285
|
+
) -> None:
|
|
286
|
+
classes = "task-detail-fixed" if fixed else ""
|
|
287
|
+
super().__init__(id="task-detail-panel", classes=classes)
|
|
288
|
+
self._widget_context = context
|
|
289
|
+
self._task_id = task_id
|
|
290
|
+
self._fixed = fixed
|
|
291
|
+
self._close_detail = close_detail
|
|
292
|
+
self._refresh_lock = asyncio.Lock()
|
|
293
|
+
self._refresh_worker: Worker[None] | None = None
|
|
294
|
+
self._change_worker: Worker[None] | None = None
|
|
295
|
+
self._relationship_offset = 0
|
|
296
|
+
self._relationship_previous_offset: int | None = None
|
|
297
|
+
self._relationship_next_offset: int | None = None
|
|
298
|
+
self._related_rows: tuple[RelatedRow, ...] = ()
|
|
299
|
+
host = TimelineHost(
|
|
300
|
+
task_id=lambda: self._task_id,
|
|
301
|
+
lock=self._refresh_lock,
|
|
302
|
+
report=self._set_feedback,
|
|
303
|
+
)
|
|
304
|
+
self._timelines = {
|
|
305
|
+
DETAILS_SECTION_VIEWS[section]: TimelineSection(
|
|
306
|
+
context, host, ids=ids, category=category, filterable=filterable
|
|
307
|
+
)
|
|
308
|
+
for section, (ids, category, filterable) in TIMELINE_SECTIONS.items()
|
|
309
|
+
}
|
|
310
|
+
self._folds: dict[str, DetailFold] = {}
|
|
311
|
+
self._initial_view = initial_view
|
|
312
|
+
self._timeline_worker: Worker[None] | None = None
|
|
313
|
+
self._remove_motion_callback: Callable[[], None] | None = None
|
|
314
|
+
self._body_focus_generation = 0
|
|
315
|
+
self._rendered_task_id: str | None = None
|
|
316
|
+
self._view: TaskDetailView | None = None
|
|
317
|
+
self._observed_work: tuple[str, TaskWorkSample | None] | None = None
|
|
318
|
+
self._shown_quota_wording: tuple[str, str] | None = None
|
|
319
|
+
self._on_initial_content = on_initial_content
|
|
320
|
+
self._initial_content_reported = False
|
|
321
|
+
|
|
322
|
+
def compose(self) -> ComposeResult:
|
|
323
|
+
with TaskDetailBody(id=TASK_DETAIL_BODY):
|
|
324
|
+
yield GatedStatic("Loading task detail…", id=TASK_DETAIL_IDENTITY, markup=False)
|
|
325
|
+
yield Static("", id=TASK_DETAIL_OWNERSHIP, markup=False)
|
|
326
|
+
yield Static("", id=TASK_DETAIL_FEEDBACK, markup=False)
|
|
327
|
+
yield GatedStatic("", id=TASK_DETAIL_BRIEFING, markup=False)
|
|
328
|
+
with DetailFolds(widget_id=TASK_DETAIL_PROBLEM_FOLDS):
|
|
329
|
+
yield self._new_fold(PROBLEM_SECTION, name_color=SECONDARY_TEXT_COLOR)
|
|
330
|
+
yield GatedStatic("", id=TASK_DETAIL_BRIEFING_BELOW, markup=False)
|
|
331
|
+
with Horizontal(id=TASK_DETAIL_ACTIONS):
|
|
332
|
+
with Horizontal(id=TASK_DETAIL_VERBS):
|
|
333
|
+
for action in BriefingAction:
|
|
334
|
+
yield TaskDetailVerb(
|
|
335
|
+
ACTION_LABELS[action], id=f"{TASK_DETAIL_ACTION_PREFIX}{action.value}"
|
|
336
|
+
)
|
|
337
|
+
yield TaskDetailHint(key_label(TASK_DETAIL_VIEWS_KEY, DETAILS_HINT_LABEL))
|
|
338
|
+
with DetailFolds(widget_id=TASK_DETAIL_FOLDS):
|
|
339
|
+
for section in MAIN_SECTIONS:
|
|
340
|
+
yield self._new_fold(section)
|
|
341
|
+
yield DetailHintBar(widget_id=TASK_DETAIL_HINT)
|
|
342
|
+
|
|
343
|
+
def _new_fold(self, section: DetailsSection, *, name_color: str = STEEL_BLUE) -> DetailFold:
|
|
344
|
+
view_id = DETAILS_SECTION_VIEWS[section]
|
|
345
|
+
fold = DetailFold(
|
|
346
|
+
view_id,
|
|
347
|
+
section.value,
|
|
348
|
+
self._section_widgets(view_id),
|
|
349
|
+
is_open=view_id == self._initial_view,
|
|
350
|
+
name_color=name_color,
|
|
351
|
+
)
|
|
352
|
+
self._folds[view_id] = fold
|
|
353
|
+
return fold
|
|
354
|
+
|
|
355
|
+
def _section_widgets(self, view_id: str) -> list[Widget]:
|
|
356
|
+
timeline = self._timelines.get(view_id)
|
|
357
|
+
if timeline is not None:
|
|
358
|
+
return [timeline]
|
|
359
|
+
widgets: list[Widget] = [
|
|
360
|
+
GatedStatic("", id=widget_id, classes="task-detail-section", markup=False)
|
|
361
|
+
for widget_id in FOLD_STATICS.get(view_id, ())
|
|
362
|
+
]
|
|
363
|
+
if view_id == TASK_VIEW_RELATIONSHIPS:
|
|
364
|
+
widgets.append(RowList(widget_id=TASK_DETAIL_RELATED_LIST))
|
|
365
|
+
if view_id == TASK_VIEW_BLOCKER_EVIDENCE:
|
|
366
|
+
widgets.append(self._raw_events_fold())
|
|
367
|
+
return widgets
|
|
368
|
+
|
|
369
|
+
def _raw_events_fold(self) -> DetailFold:
|
|
370
|
+
fold = DetailFold(
|
|
371
|
+
TASK_VIEW_RAW_EVENTS,
|
|
372
|
+
RAW_EVENTS_TITLE,
|
|
373
|
+
[
|
|
374
|
+
GatedStatic(
|
|
375
|
+
"", id=TASK_DETAIL_RAW_EVENTS, classes="task-detail-section", markup=False
|
|
376
|
+
)
|
|
377
|
+
],
|
|
378
|
+
name_color=SECONDARY_TEXT_COLOR,
|
|
379
|
+
)
|
|
380
|
+
self._folds[TASK_VIEW_RAW_EVENTS] = fold
|
|
381
|
+
return fold
|
|
382
|
+
|
|
383
|
+
async def on_mount(self) -> None:
|
|
384
|
+
self.disabled = self._task_id is None
|
|
385
|
+
self._folds_list().display = self._task_id is not None
|
|
386
|
+
self._hide_blocker_evidence()
|
|
387
|
+
self._sync_timelines()
|
|
388
|
+
identity = self.query_one(f"#{TASK_DETAIL_IDENTITY}", Static)
|
|
389
|
+
identity.update(
|
|
390
|
+
_task_identity_text(f"{self._task_id} · loading selected task")
|
|
391
|
+
if self._task_id is not None
|
|
392
|
+
else "Select a task to inspect it."
|
|
393
|
+
)
|
|
394
|
+
self._set_feedback(None)
|
|
395
|
+
self._refresh_worker = self.run_worker(self._refresh(), group=TASK_DETAIL_WORKER_GROUP)
|
|
396
|
+
self._change_worker = self.run_worker(
|
|
397
|
+
self._widget_context.on_change(self._refresh),
|
|
398
|
+
group=TASK_DETAIL_WORKER_GROUP,
|
|
399
|
+
)
|
|
400
|
+
self._timeline_worker = self.run_worker(
|
|
401
|
+
self._watch_timeline(), group=TASK_DETAIL_WORKER_GROUP
|
|
402
|
+
)
|
|
403
|
+
self._remove_motion_callback = self._widget_context.on_motion(self._refresh_activity)
|
|
404
|
+
|
|
405
|
+
def on_unmount(self) -> None:
|
|
406
|
+
if self._refresh_worker is not None:
|
|
407
|
+
self._refresh_worker.cancel()
|
|
408
|
+
if self._change_worker is not None:
|
|
409
|
+
self._change_worker.cancel()
|
|
410
|
+
if self._timeline_worker is not None:
|
|
411
|
+
self._timeline_worker.cancel()
|
|
412
|
+
if self._remove_motion_callback is not None:
|
|
413
|
+
self._remove_motion_callback()
|
|
414
|
+
self._remove_motion_callback = None
|
|
415
|
+
|
|
416
|
+
def on_descendant_focus(self, event: events.DescendantFocus) -> None:
|
|
417
|
+
fold = self._fold_of(event.widget)
|
|
418
|
+
if fold is not None:
|
|
419
|
+
fold.folds.make_current(fold)
|
|
420
|
+
self._paint_hint()
|
|
421
|
+
body = self.query_one(f"#{TASK_DETAIL_BODY}", TaskDetailBody)
|
|
422
|
+
if body in event.widget.ancestors:
|
|
423
|
+
self._body_focus_generation += 1
|
|
424
|
+
if not body.can_view_entire(event.widget):
|
|
425
|
+
self.call_after_refresh(
|
|
426
|
+
body.scroll_to_center,
|
|
427
|
+
event.widget,
|
|
428
|
+
animate=False,
|
|
429
|
+
)
|
|
430
|
+
return
|
|
431
|
+
|
|
432
|
+
def on_descendant_blur(self, event: events.DescendantBlur) -> None:
|
|
433
|
+
del event
|
|
434
|
+
self.call_after_refresh(self._paint_hint)
|
|
435
|
+
|
|
436
|
+
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
|
|
437
|
+
del parameters
|
|
438
|
+
return self._task_window_available() if action == TASK_WINDOW_ACTION else True
|
|
439
|
+
|
|
440
|
+
async def action_task_window(self) -> None:
|
|
441
|
+
await self._navigate_task_window()
|
|
442
|
+
|
|
443
|
+
def _task_window_available(self) -> bool:
|
|
444
|
+
view = self._view
|
|
445
|
+
fold = self._fold_of(self.screen.focused)
|
|
446
|
+
return (
|
|
447
|
+
view is not None
|
|
448
|
+
and fold is not None
|
|
449
|
+
and fold.view_id == TASK_VIEW_WORKSPACE
|
|
450
|
+
and view.status not in TERMINAL_STATUSES
|
|
451
|
+
and view.window_id is not None
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
def _hint_target(self) -> HintTarget | None:
|
|
455
|
+
focused = self.screen.focused
|
|
456
|
+
fold = self._fold_of(focused)
|
|
457
|
+
if fold is None:
|
|
458
|
+
return None
|
|
459
|
+
if focused is fold.header:
|
|
460
|
+
return HintTarget.HEADER
|
|
461
|
+
if isinstance(focused, TimelineFilterBar):
|
|
462
|
+
return HintTarget.AGENT_FILTER
|
|
463
|
+
return FOLD_HINT_TARGETS.get(fold.view_id)
|
|
464
|
+
|
|
465
|
+
def _paint_hint(self) -> None:
|
|
466
|
+
bars = self.query(DetailHintBar)
|
|
467
|
+
if not widget_is_attached(self) or not bars:
|
|
468
|
+
return
|
|
469
|
+
target = self._hint_target()
|
|
470
|
+
bars.first().show(
|
|
471
|
+
None
|
|
472
|
+
if target is None
|
|
473
|
+
else hint_for(
|
|
474
|
+
target,
|
|
475
|
+
task_window=self._task_window_available(),
|
|
476
|
+
keys_only=self._briefing_layout().tiny,
|
|
477
|
+
)
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
def select_task(self, task_id: str | None) -> None:
|
|
481
|
+
if task_id == self._task_id:
|
|
482
|
+
return
|
|
483
|
+
self._task_id = task_id
|
|
484
|
+
self._reset_task_identity_state()
|
|
485
|
+
self.disabled = task_id is None
|
|
486
|
+
if not self.is_mounted:
|
|
487
|
+
return
|
|
488
|
+
self._folds_list().display = task_id is not None
|
|
489
|
+
identity = self.query_one(f"#{TASK_DETAIL_IDENTITY}", Static)
|
|
490
|
+
identity.update(
|
|
491
|
+
_task_identity_text(f"{task_id} · loading selected task")
|
|
492
|
+
if task_id is not None
|
|
493
|
+
else "Select a task to inspect it."
|
|
494
|
+
)
|
|
495
|
+
self.query_one(f"#{TASK_DETAIL_BODY}", TaskDetailBody).scroll_to(y=0, animate=False)
|
|
496
|
+
if task_id is None:
|
|
497
|
+
return
|
|
498
|
+
self._queue_refresh()
|
|
499
|
+
|
|
500
|
+
def _reset_task_identity_state(self) -> None:
|
|
501
|
+
self._relationship_offset = 0
|
|
502
|
+
self._view = None
|
|
503
|
+
self._relationship_previous_offset = None
|
|
504
|
+
self._relationship_next_offset = None
|
|
505
|
+
self._related_rows = ()
|
|
506
|
+
for timeline in self._timelines.values():
|
|
507
|
+
timeline.reset()
|
|
508
|
+
if not self.is_mounted:
|
|
509
|
+
return
|
|
510
|
+
for section_id in (
|
|
511
|
+
TASK_DETAIL_OWNERSHIP,
|
|
512
|
+
TASK_DETAIL_REQUEST,
|
|
513
|
+
TASK_DETAIL_RESULTS,
|
|
514
|
+
TASK_DETAIL_DECISIONS,
|
|
515
|
+
TASK_DETAIL_WORKSPACE,
|
|
516
|
+
TASK_DETAIL_SESSIONS,
|
|
517
|
+
):
|
|
518
|
+
self.query_one(f"#{section_id}", Static).update("")
|
|
519
|
+
self.query_one(f"#{TASK_DETAIL_BRIEFING}", GatedStatic).update("")
|
|
520
|
+
self.query_one(f"#{TASK_DETAIL_BRIEFING_BELOW}", GatedStatic).update("")
|
|
521
|
+
self._hide_blocker_evidence()
|
|
522
|
+
self._set_feedback(None)
|
|
523
|
+
self.query_one(f"#{TASK_DETAIL_RELATIONSHIPS}", Static).update("")
|
|
524
|
+
self.query_one(f"#{TASK_DETAIL_RELATED_LIST}", RowList).set_rows(
|
|
525
|
+
(), selected=0, empty=NO_RELATED_ROWS
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
529
|
+
button_id = event.button.id or ""
|
|
530
|
+
if button_id.startswith(TASK_DETAIL_ACTION_PREFIX):
|
|
531
|
+
await self._run_briefing_action(
|
|
532
|
+
BriefingAction(button_id.removeprefix(TASK_DETAIL_ACTION_PREFIX))
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
@on(RowList.Paged, f"#{TASK_DETAIL_RELATED_LIST}")
|
|
536
|
+
async def _related_paged(self, message: RowList.Paged) -> None:
|
|
537
|
+
message.stop()
|
|
538
|
+
await (
|
|
539
|
+
self._next_relationship_page() if message.older else self._previous_relationship_page()
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
@on(RowList.Opened, f"#{TASK_DETAIL_RELATED_LIST}")
|
|
543
|
+
async def _related_opened(self, message: RowList.Opened) -> None:
|
|
544
|
+
message.stop()
|
|
545
|
+
callback = self._widget_context.open_task_detail
|
|
546
|
+
if callback is not None and message.index < len(self._related_rows):
|
|
547
|
+
await callback(self._related_rows[message.index].task_id)
|
|
548
|
+
|
|
549
|
+
async def _watch_timeline(self) -> None:
|
|
550
|
+
while True:
|
|
551
|
+
seconds = self._widget_context.settings.current().dashboard.timeline_refresh_seconds
|
|
552
|
+
await self._widget_context.clock.sleep(seconds)
|
|
553
|
+
if self._task_id is None or not self._screen_is_active():
|
|
554
|
+
continue
|
|
555
|
+
await self._refresh_activity()
|
|
556
|
+
await self._refresh_live_work()
|
|
557
|
+
await self._refresh_countdowns()
|
|
558
|
+
for timeline in self._timelines.values():
|
|
559
|
+
await timeline.refresh_when_due()
|
|
560
|
+
|
|
561
|
+
async def _refresh_live_work(self) -> None:
|
|
562
|
+
task_id = self._task_id
|
|
563
|
+
if task_id is None or not self._screen_is_active():
|
|
564
|
+
return
|
|
565
|
+
sample = await self._widget_context.views.live_work(task_id)
|
|
566
|
+
if (task_id, sample) == self._observed_work:
|
|
567
|
+
return
|
|
568
|
+
await self._load_view()
|
|
569
|
+
|
|
570
|
+
async def _refresh_countdowns(self) -> None:
|
|
571
|
+
view = self._view
|
|
572
|
+
if view is None:
|
|
573
|
+
return
|
|
574
|
+
now = tick_now(self._widget_context.clock)
|
|
575
|
+
quota = view.quota_wait
|
|
576
|
+
if _has_expired_quota_reading(view.blocker_evidence, now):
|
|
577
|
+
await self._load_view()
|
|
578
|
+
return
|
|
579
|
+
if quota is not None and now >= quota.refresh_at:
|
|
580
|
+
await self._load_view()
|
|
581
|
+
return
|
|
582
|
+
if quota is not None and (
|
|
583
|
+
quota_clock_text(quota.agent, to_local(quota.until), to_local(now))
|
|
584
|
+
!= self._shown_quota_wording
|
|
585
|
+
):
|
|
586
|
+
self._render_briefing(view, now, self._briefing_layout())
|
|
587
|
+
if view.blocker_evidence is not None:
|
|
588
|
+
self._show_blocker_evidence(view.blocker_evidence, now)
|
|
589
|
+
|
|
590
|
+
async def _refresh_activity(self) -> None:
|
|
591
|
+
if (
|
|
592
|
+
self._task_id is None
|
|
593
|
+
or self._view is None
|
|
594
|
+
or self._view.task_id != self._task_id
|
|
595
|
+
or not self._screen_is_active()
|
|
596
|
+
):
|
|
597
|
+
return
|
|
598
|
+
strip = next(
|
|
599
|
+
(item for item in self._widget_context.views.strips() if item.task_id == self._task_id),
|
|
600
|
+
None,
|
|
601
|
+
)
|
|
602
|
+
if strip is None:
|
|
603
|
+
return
|
|
604
|
+
activity = self._activity_view(self._view, strip)
|
|
605
|
+
self._render_activity(activity)
|
|
606
|
+
|
|
607
|
+
def _activity_view(self, view: TaskDetailView, strip: TaskStripData) -> TaskDetailView:
|
|
608
|
+
signal_is_newer = (
|
|
609
|
+
strip.last_signal is not None
|
|
610
|
+
and strip.last_signal_at is not None
|
|
611
|
+
and view.latest_signal_at is not None
|
|
612
|
+
and strip.last_signal_at > view.latest_signal_at
|
|
613
|
+
)
|
|
614
|
+
signal_has_no_recorded_predecessor = (
|
|
615
|
+
strip.last_signal is not None
|
|
616
|
+
and strip.last_signal_at is not None
|
|
617
|
+
and view.latest_signal is None
|
|
618
|
+
)
|
|
619
|
+
signal_values = (
|
|
620
|
+
{
|
|
621
|
+
"latest_signal": strip.last_signal,
|
|
622
|
+
"latest_signal_at": strip.last_signal_at,
|
|
623
|
+
}
|
|
624
|
+
if signal_is_newer or signal_has_no_recorded_predecessor
|
|
625
|
+
else {}
|
|
626
|
+
)
|
|
627
|
+
return replace(
|
|
628
|
+
view,
|
|
629
|
+
progress=strip,
|
|
630
|
+
last_words=strip.last_words if strip.last_words is not None else view.last_words,
|
|
631
|
+
signal_observed=strip.last_signal_at is not None
|
|
632
|
+
and any(panel.spinner_frame for panel in strip.panels),
|
|
633
|
+
actual_stage_started_at=strip.actual_step_started_at,
|
|
634
|
+
**signal_values,
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
def _render_activity(self, view: TaskDetailView) -> None:
|
|
638
|
+
now = self._widget_context.clock.now()
|
|
639
|
+
layout = self._briefing_layout()
|
|
640
|
+
self.set_class(layout.compact, FOLDS_COMPACT_CLASS)
|
|
641
|
+
self.set_class(layout.tiny, FOLDS_TINY_CLASS)
|
|
642
|
+
self.query_one(f"#{TASK_DETAIL_IDENTITY}", GatedStatic).update_if_changed(
|
|
643
|
+
header_text(view, now, compact=layout.compact)
|
|
644
|
+
)
|
|
645
|
+
self._render_briefing(view, now, layout)
|
|
646
|
+
if view.blocker_evidence is None:
|
|
647
|
+
self._hide_blocker_evidence()
|
|
648
|
+
else:
|
|
649
|
+
self._show_blocker_evidence(view.blocker_evidence, now)
|
|
650
|
+
self._render_verbs(view)
|
|
651
|
+
|
|
652
|
+
def _briefing_layout(self) -> BriefingLayout:
|
|
653
|
+
compact = self.size.width <= _TASK_DETAIL_COMPACT_MAX_COLUMNS or self.has_class(
|
|
654
|
+
COMPACT_INSPECTOR_CLASS
|
|
655
|
+
)
|
|
656
|
+
dashboard = self._widget_context.settings.current().dashboard
|
|
657
|
+
return BriefingLayout(
|
|
658
|
+
now_lines=dashboard.now_compact_lines if compact else dashboard.now_max_lines,
|
|
659
|
+
compact=compact,
|
|
660
|
+
tiny=self.screen.size.height <= TASK_DETAIL_TINY_MAX_ROWS,
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
def _render_verbs(self, view: TaskDetailView) -> None:
|
|
664
|
+
verbs = briefing_verbs(view)
|
|
665
|
+
for action in BriefingAction:
|
|
666
|
+
self.query_one(f"#{TASK_DETAIL_ACTION_PREFIX}{action.value}", Button).display = (
|
|
667
|
+
action in verbs
|
|
668
|
+
)
|
|
669
|
+
used = sum(cell_len(ACTION_LABELS[action]) + VERB_MARGIN for action in verbs)
|
|
670
|
+
free = self.query_one(f"#{TASK_DETAIL_ACTIONS}").size.width - used
|
|
671
|
+
hint = self.query_one(f"#{TASK_DETAIL_DETAILS_HINT}", TaskDetailHint)
|
|
672
|
+
hint.display = free >= cell_len(DETAILS_HINT_TEXT)
|
|
673
|
+
focused = self.screen.focused
|
|
674
|
+
if isinstance(focused, TaskDetailVerb) and not focused.display:
|
|
675
|
+
self.focus_summary()
|
|
676
|
+
|
|
677
|
+
def on_resize(self, event: events.Resize) -> None:
|
|
678
|
+
del event
|
|
679
|
+
if self._view is not None and self.is_mounted:
|
|
680
|
+
self._render_activity(self._view)
|
|
681
|
+
self.call_after_refresh(self._align_problem_folds)
|
|
682
|
+
self._paint_hint()
|
|
683
|
+
|
|
684
|
+
def _screen_is_active(self) -> bool:
|
|
685
|
+
return screen_is_active(self)
|
|
686
|
+
|
|
687
|
+
async def _back(self) -> None:
|
|
688
|
+
if self._fixed:
|
|
689
|
+
await self._navigate_home()
|
|
690
|
+
return
|
|
691
|
+
if self._close_detail is not None:
|
|
692
|
+
await self._close_detail()
|
|
693
|
+
|
|
694
|
+
async def _open_palette(
|
|
695
|
+
self,
|
|
696
|
+
role: Role | None = None,
|
|
697
|
+
*,
|
|
698
|
+
action: str | None = None,
|
|
699
|
+
) -> None:
|
|
700
|
+
if self._task_id is None:
|
|
701
|
+
return
|
|
702
|
+
callback = self._widget_context.open_palette_context
|
|
703
|
+
if callback is not None:
|
|
704
|
+
await callback(
|
|
705
|
+
PaletteRequest(
|
|
706
|
+
action=action,
|
|
707
|
+
task_id=self._task_id,
|
|
708
|
+
role=role.value if role is not None else None,
|
|
709
|
+
)
|
|
710
|
+
)
|
|
711
|
+
return
|
|
712
|
+
await self._widget_context.open_palette(action or "")
|
|
713
|
+
|
|
714
|
+
async def open_palette(self) -> None:
|
|
715
|
+
"""Open the shared palette with this detail's current task and selected role."""
|
|
716
|
+
await self._open_palette(self._action_role())
|
|
717
|
+
|
|
718
|
+
async def open_help(self) -> None:
|
|
719
|
+
"""Open help scoped to the current task and detail view."""
|
|
720
|
+
if self._task_id is None:
|
|
721
|
+
return
|
|
722
|
+
await cast("DashboardApp", self.app).open_task_detail_help(
|
|
723
|
+
self._task_id,
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
@property
|
|
727
|
+
def task_id(self) -> str | None:
|
|
728
|
+
return self._task_id
|
|
729
|
+
|
|
730
|
+
@property
|
|
731
|
+
def open_views(self) -> frozenset[str]:
|
|
732
|
+
"""The view ids of the folds that are open."""
|
|
733
|
+
return frozenset(view_id for view_id, fold in self._folds.items() if fold.is_open)
|
|
734
|
+
|
|
735
|
+
def focus_folds(self) -> None:
|
|
736
|
+
"""Move focus down to the section headers."""
|
|
737
|
+
folds = self._folds_list()
|
|
738
|
+
folds.focus_header(folds.current_fold)
|
|
739
|
+
|
|
740
|
+
def focus_summary(self) -> None:
|
|
741
|
+
"""Focus the first verb the task offers, or the current fold header when it offers none."""
|
|
742
|
+
first_verb = next(iter(self.query_one(f"#{TASK_DETAIL_VERBS}").displayed_children), None)
|
|
743
|
+
if first_verb is not None:
|
|
744
|
+
first_verb.focus()
|
|
745
|
+
return
|
|
746
|
+
self.focus_folds()
|
|
747
|
+
|
|
748
|
+
def open_view(self, view_id: str) -> None:
|
|
749
|
+
"""Open the fold for `view_id`, bring it into view and focus it."""
|
|
750
|
+
fold = self._folds.get(view_id)
|
|
751
|
+
if fold is None:
|
|
752
|
+
return
|
|
753
|
+
fold.set_open(True)
|
|
754
|
+
self.call_after_refresh(fold.folds.focus_fold, fold)
|
|
755
|
+
|
|
756
|
+
def step_out(self) -> bool:
|
|
757
|
+
"""Move focus from inside a fold body to its header; False when it is not in a body."""
|
|
758
|
+
focused = self.screen.focused
|
|
759
|
+
fold = self._fold_of(focused)
|
|
760
|
+
if focused is None or fold is None or focused is fold.header:
|
|
761
|
+
return False
|
|
762
|
+
if isinstance(focused, TimelineFilterBar):
|
|
763
|
+
focused.action_close()
|
|
764
|
+
return True
|
|
765
|
+
fold.folds.focus_header(fold)
|
|
766
|
+
return True
|
|
767
|
+
|
|
768
|
+
def _fold_of(self, widget: Widget | None) -> DetailFold | None:
|
|
769
|
+
ancestors = widget.ancestors if widget is not None else ()
|
|
770
|
+
return next((item for item in ancestors if isinstance(item, DetailFold)), None)
|
|
771
|
+
|
|
772
|
+
def _folds_list(self) -> DetailFolds:
|
|
773
|
+
return self.query_one(f"#{TASK_DETAIL_FOLDS}", DetailFolds)
|
|
774
|
+
|
|
775
|
+
async def return_from_detail(self) -> None:
|
|
776
|
+
await self._back()
|
|
777
|
+
|
|
778
|
+
async def _navigate_task_window(self) -> None:
|
|
779
|
+
if self._task_id is None:
|
|
780
|
+
await self._show_navigation_result("Select a task before opening its window.")
|
|
781
|
+
return
|
|
782
|
+
callback = self._widget_context.navigate_task_window
|
|
783
|
+
await self._show_navigation_result(
|
|
784
|
+
await callback(self._task_id)
|
|
785
|
+
if callback is not None
|
|
786
|
+
else "Task window navigation is unavailable."
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
async def _navigate_home(self) -> None:
|
|
790
|
+
callback = self._widget_context.navigate_home
|
|
791
|
+
await self._show_navigation_result(
|
|
792
|
+
await callback()
|
|
793
|
+
if callback is not None
|
|
794
|
+
else "Home workspace navigation is unavailable."
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
async def _show_navigation_result(self, result: str) -> None:
|
|
798
|
+
self._set_feedback(result)
|
|
799
|
+
|
|
800
|
+
def _set_feedback(self, result: str | None) -> None:
|
|
801
|
+
feedback = self.query_one(f"#{TASK_DETAIL_FEEDBACK}", Static)
|
|
802
|
+
feedback.update(result or "")
|
|
803
|
+
feedback.display = result is not None
|
|
804
|
+
|
|
805
|
+
async def _refresh(self) -> None:
|
|
806
|
+
await self._load_view()
|
|
807
|
+
for timeline in self._timelines.values():
|
|
808
|
+
if timeline.shown:
|
|
809
|
+
await timeline.refresh_page()
|
|
810
|
+
|
|
811
|
+
async def _load_view(self) -> None:
|
|
812
|
+
async with self._refresh_lock:
|
|
813
|
+
if not self._screen_is_active():
|
|
814
|
+
return
|
|
815
|
+
task_id = self._task_id
|
|
816
|
+
if task_id is None:
|
|
817
|
+
self.query_one(f"#{TASK_DETAIL_IDENTITY}", Static).update(
|
|
818
|
+
"Select a task to inspect it."
|
|
819
|
+
)
|
|
820
|
+
return
|
|
821
|
+
try:
|
|
822
|
+
sample = await self._widget_context.views.live_work(task_id)
|
|
823
|
+
view = await self._widget_context.views.task_detail(
|
|
824
|
+
task_id,
|
|
825
|
+
relationship_offset=self._relationship_offset,
|
|
826
|
+
)
|
|
827
|
+
except (ShikiruError, OSError, ValueError, TypeError) as error:
|
|
828
|
+
if self._screen_is_active():
|
|
829
|
+
self._set_feedback(f"Task detail refresh failed: {error}")
|
|
830
|
+
return
|
|
831
|
+
if not self._screen_is_active():
|
|
832
|
+
return
|
|
833
|
+
if self._task_id != task_id:
|
|
834
|
+
return
|
|
835
|
+
if view is None:
|
|
836
|
+
self.query_one(f"#{TASK_DETAIL_IDENTITY}", Static).update(
|
|
837
|
+
f"{task_id} · task record is unavailable"
|
|
838
|
+
)
|
|
839
|
+
self._set_feedback("Task state could not be read.")
|
|
840
|
+
return
|
|
841
|
+
self._observed_work = (task_id, sample)
|
|
842
|
+
self._render_view(view)
|
|
843
|
+
|
|
844
|
+
def _usage_body(self, view: TaskDetailView) -> UsageBody:
|
|
845
|
+
return UsageBody(view)
|
|
846
|
+
|
|
847
|
+
def _queue_refresh(self) -> None:
|
|
848
|
+
if self._task_id is None:
|
|
849
|
+
return
|
|
850
|
+
self.run_worker(
|
|
851
|
+
self._refresh(),
|
|
852
|
+
group=TASK_DETAIL_WORKER_GROUP,
|
|
853
|
+
exclusive=False,
|
|
854
|
+
)
|
|
855
|
+
|
|
856
|
+
def refresh_after_resume(self) -> None:
|
|
857
|
+
"""Refresh the current task after this detail screen becomes active again."""
|
|
858
|
+
self._queue_refresh()
|
|
859
|
+
|
|
860
|
+
def _render_view(self, view: TaskDetailView) -> None:
|
|
861
|
+
self._view = view
|
|
862
|
+
body = self.query_one(f"#{TASK_DETAIL_BODY}", TaskDetailBody)
|
|
863
|
+
scroll_y = body.scroll_target_y
|
|
864
|
+
focus_generation = self._body_focus_generation
|
|
865
|
+
focused = self.screen.focused
|
|
866
|
+
restore_scroll = self._rendered_task_id == view.task_id
|
|
867
|
+
self._rendered_task_id = view.task_id
|
|
868
|
+
self._render_ownership(view)
|
|
869
|
+
self.query_one(f"#{TASK_DETAIL_CONTEXT_INFO}", Static).update(SettingsBody(view))
|
|
870
|
+
self._render_relationships(view)
|
|
871
|
+
self._render_activity(view)
|
|
872
|
+
self.query_one(f"#{TASK_DETAIL_REQUEST}", Static).update(DescriptionBody(view))
|
|
873
|
+
self.query_one(f"#{TASK_DETAIL_RESULTS}", Static).update(ResultsBody(view.results))
|
|
874
|
+
self.query_one(f"#{TASK_DETAIL_DECISIONS}", Static).update(DecisionsBody(view))
|
|
875
|
+
self.query_one(f"#{TASK_DETAIL_WORKSPACE}", Static).update(WorkspaceBody(view, Path.home()))
|
|
876
|
+
if self._folds[TASK_VIEW_USAGE].is_open:
|
|
877
|
+
self.query_one(f"#{TASK_DETAIL_SESSIONS}", Static).update(self._usage_body(view))
|
|
878
|
+
self._paint_headlines(view)
|
|
879
|
+
self._paint_hint()
|
|
880
|
+
if self._on_initial_content is not None and not self._initial_content_reported:
|
|
881
|
+
self._initial_content_reported = True
|
|
882
|
+
self.call_after_refresh(self._on_initial_content)
|
|
883
|
+
if restore_scroll:
|
|
884
|
+
body.call_after_refresh(
|
|
885
|
+
self._restore_body_scroll,
|
|
886
|
+
body,
|
|
887
|
+
scroll_y,
|
|
888
|
+
focus_generation,
|
|
889
|
+
focused,
|
|
890
|
+
)
|
|
891
|
+
|
|
892
|
+
def _render_briefing(self, view: TaskDetailView, now: datetime, layout: BriefingLayout) -> None:
|
|
893
|
+
quota = view.quota_wait
|
|
894
|
+
self._shown_quota_wording = (
|
|
895
|
+
None
|
|
896
|
+
if quota is None
|
|
897
|
+
else quota_clock_text(quota.agent, to_local(quota.until), to_local(now))
|
|
898
|
+
)
|
|
899
|
+
self.query_one(f"#{TASK_DETAIL_BRIEFING}", GatedStatic).update_if_changed(
|
|
900
|
+
Briefing(view, now, layout, BriefingHalf.ABOVE)
|
|
901
|
+
)
|
|
902
|
+
self.query_one(f"#{TASK_DETAIL_BRIEFING_BELOW}", GatedStatic).update_if_changed(
|
|
903
|
+
Briefing(view, now, layout, BriefingHalf.BELOW)
|
|
904
|
+
)
|
|
905
|
+
self._align_problem_folds()
|
|
906
|
+
|
|
907
|
+
def _align_problem_folds(self) -> None:
|
|
908
|
+
layout = self._briefing_layout()
|
|
909
|
+
width = self.query_one(f"#{TASK_DETAIL_BRIEFING}", GatedStatic).size.width
|
|
910
|
+
self.query_one(f"#{TASK_DETAIL_PROBLEM_FOLDS}", DetailFolds).styles.margin = (
|
|
911
|
+
0,
|
|
912
|
+
0,
|
|
913
|
+
0,
|
|
914
|
+
blockers_value_indent(compact=layout.compact, width=width) - cell_len(ROW_LEAD),
|
|
915
|
+
)
|
|
916
|
+
|
|
917
|
+
def _show_blocker_evidence(self, evidence: BlockerEvidenceView, now: datetime) -> None:
|
|
918
|
+
fold = self._folds[TASK_VIEW_BLOCKER_EVIDENCE]
|
|
919
|
+
raw_fold = self._folds[TASK_VIEW_RAW_EVENTS]
|
|
920
|
+
fold.display = True
|
|
921
|
+
fold.set_headline(evidence_headline(evidence, now))
|
|
922
|
+
self.query_one(f"#{TASK_DETAIL_EVIDENCE}", GatedStatic).update_if_changed(
|
|
923
|
+
evidence_body(evidence, now)
|
|
924
|
+
)
|
|
925
|
+
raw_fold.display = bool(evidence.raw)
|
|
926
|
+
self.query_one(f"#{TASK_DETAIL_RAW_EVENTS}", GatedStatic).update_if_changed(
|
|
927
|
+
raw_evidence_text(evidence)
|
|
928
|
+
)
|
|
929
|
+
|
|
930
|
+
def _hide_blocker_evidence(self) -> None:
|
|
931
|
+
fold = self._folds[TASK_VIEW_BLOCKER_EVIDENCE]
|
|
932
|
+
fold.display = False
|
|
933
|
+
fold.set_headline(None)
|
|
934
|
+
fold.set_open(False)
|
|
935
|
+
self._folds[TASK_VIEW_RAW_EVENTS].set_open(False)
|
|
936
|
+
|
|
937
|
+
def _paint_headlines(self, view: TaskDetailView) -> None:
|
|
938
|
+
for section in MAIN_SECTIONS:
|
|
939
|
+
view_id = DETAILS_SECTION_VIEWS[section]
|
|
940
|
+
if view_id not in self._timelines:
|
|
941
|
+
self._folds[view_id].set_headline(section_headline(section, view))
|
|
942
|
+
|
|
943
|
+
@on(TimelineSection.StatusChanged)
|
|
944
|
+
def _timeline_status_changed(self, message: TimelineSection.StatusChanged) -> None:
|
|
945
|
+
message.stop()
|
|
946
|
+
view_id = next(
|
|
947
|
+
view_id for view_id, timeline in self._timelines.items() if timeline is message.section
|
|
948
|
+
)
|
|
949
|
+
self._folds[view_id].set_headline(message.status)
|
|
950
|
+
|
|
951
|
+
def _sync_timelines(self) -> None:
|
|
952
|
+
for view_id, timeline in self._timelines.items():
|
|
953
|
+
timeline.shown = self._folds[view_id].is_open
|
|
954
|
+
|
|
955
|
+
@on(DetailFold.Toggled)
|
|
956
|
+
def _fold_toggled(self, message: DetailFold.Toggled) -> None:
|
|
957
|
+
message.stop()
|
|
958
|
+
self._sync_timelines()
|
|
959
|
+
fold = message.fold
|
|
960
|
+
if not fold.is_open:
|
|
961
|
+
return
|
|
962
|
+
timeline = self._timelines.get(fold.view_id)
|
|
963
|
+
if timeline is not None:
|
|
964
|
+
self.run_worker(
|
|
965
|
+
timeline.refresh_page(), group=TASK_DETAIL_WORKER_GROUP, exclusive=False
|
|
966
|
+
)
|
|
967
|
+
elif fold.view_id == TASK_VIEW_USAGE and self._view is not None:
|
|
968
|
+
self.query_one(f"#{TASK_DETAIL_SESSIONS}", Static).update(self._usage_body(self._view))
|
|
969
|
+
|
|
970
|
+
def _restore_body_scroll(
|
|
971
|
+
self,
|
|
972
|
+
body: TaskDetailBody,
|
|
973
|
+
scroll_y: float,
|
|
974
|
+
focus_generation: int,
|
|
975
|
+
focused: Widget | None,
|
|
976
|
+
) -> None:
|
|
977
|
+
if (
|
|
978
|
+
not self._screen_is_active()
|
|
979
|
+
or focus_generation != self._body_focus_generation
|
|
980
|
+
or self.screen.focused is not focused
|
|
981
|
+
or body.scroll_target_y != scroll_y
|
|
982
|
+
):
|
|
983
|
+
return
|
|
984
|
+
body.scroll_to(y=scroll_y, animate=False, immediate=True)
|
|
985
|
+
if (
|
|
986
|
+
focused is not None
|
|
987
|
+
and focused.is_mounted
|
|
988
|
+
and focused.display
|
|
989
|
+
and body in focused.ancestors
|
|
990
|
+
and not body.can_view_entire(focused)
|
|
991
|
+
):
|
|
992
|
+
body.scroll_to_center(focused, animate=False)
|
|
993
|
+
|
|
994
|
+
async def _next_relationship_page(self) -> None:
|
|
995
|
+
if self._relationship_next_offset is not None:
|
|
996
|
+
await self._show_relationship_page(self._relationship_next_offset)
|
|
997
|
+
|
|
998
|
+
async def _previous_relationship_page(self) -> None:
|
|
999
|
+
if self._relationship_previous_offset is not None:
|
|
1000
|
+
await self._show_relationship_page(self._relationship_previous_offset)
|
|
1001
|
+
|
|
1002
|
+
async def _show_relationship_page(self, offset: int) -> None:
|
|
1003
|
+
self._relationship_offset = offset
|
|
1004
|
+
self._related_rows = ()
|
|
1005
|
+
await self._refresh()
|
|
1006
|
+
|
|
1007
|
+
def _render_relationships(self, view: TaskDetailView) -> None:
|
|
1008
|
+
related = self.query_one(f"#{TASK_DETAIL_RELATED_LIST}", RowList)
|
|
1009
|
+
chosen = (
|
|
1010
|
+
self._related_rows[related.selected].task_id
|
|
1011
|
+
if related.selected < len(self._related_rows)
|
|
1012
|
+
else None
|
|
1013
|
+
)
|
|
1014
|
+
self._related_rows = relation_rows(view)
|
|
1015
|
+
related.set_rows(
|
|
1016
|
+
[row.cells for row in self._related_rows],
|
|
1017
|
+
selected=next(
|
|
1018
|
+
(index for index, row in enumerate(self._related_rows) if row.task_id == chosen),
|
|
1019
|
+
0,
|
|
1020
|
+
),
|
|
1021
|
+
empty=NO_RELATED_ROWS,
|
|
1022
|
+
)
|
|
1023
|
+
relationships = view.relationships
|
|
1024
|
+
self._relationship_previous_offset = (
|
|
1025
|
+
max(relationships.page_offset - relationships.page_size, 0)
|
|
1026
|
+
if relationships is not None and relationships.page_offset > 0
|
|
1027
|
+
else None
|
|
1028
|
+
)
|
|
1029
|
+
self._relationship_next_offset = (
|
|
1030
|
+
relationships.page_offset + relationships.page_size
|
|
1031
|
+
if relationships is not None and relationships.has_more
|
|
1032
|
+
else None
|
|
1033
|
+
)
|
|
1034
|
+
notes: list[str] = []
|
|
1035
|
+
if relationships is None:
|
|
1036
|
+
notes.append(RELATIONSHIPS_UNAVAILABLE)
|
|
1037
|
+
else:
|
|
1038
|
+
if relationships.page_offset > 0 or relationships.has_more:
|
|
1039
|
+
page = relationships.page_offset // relationships.page_size + 1
|
|
1040
|
+
notes.append(f"{PAGE_LABEL} {page}")
|
|
1041
|
+
if (
|
|
1042
|
+
view.status_reason == StatusReason.SUPERSEDED.value
|
|
1043
|
+
and relationships.replacement_count == 0
|
|
1044
|
+
):
|
|
1045
|
+
notes.append(NO_REPLACEMENT_TASKS)
|
|
1046
|
+
self.query_one(f"#{TASK_DETAIL_RELATIONSHIPS}", Static).update(
|
|
1047
|
+
Group(*(grey(note) for note in notes))
|
|
1048
|
+
)
|
|
1049
|
+
|
|
1050
|
+
def _render_ownership(self, view: TaskDetailView) -> None:
|
|
1051
|
+
ownership_lines: list[str] = []
|
|
1052
|
+
if view.ownership:
|
|
1053
|
+
owner_label = (
|
|
1054
|
+
"Operator owns"
|
|
1055
|
+
if view.status in {Status.RUNNING.value, Status.INTEGRATING.value}
|
|
1056
|
+
and view.actual_stage is not None
|
|
1057
|
+
else "Recorded operator handoff"
|
|
1058
|
+
)
|
|
1059
|
+
ownership_lines.append(
|
|
1060
|
+
f"{owner_label} {', '.join(role.title() for role in view.ownership)}"
|
|
1061
|
+
)
|
|
1062
|
+
if view.holds:
|
|
1063
|
+
ownership_lines.append(f"Holds · {', '.join(view.holds)}")
|
|
1064
|
+
ownership = self.query_one(f"#{TASK_DETAIL_OWNERSHIP}", Static)
|
|
1065
|
+
ownership.update("\n".join(ownership_lines))
|
|
1066
|
+
ownership.display = bool(ownership_lines)
|
|
1067
|
+
|
|
1068
|
+
async def _run_briefing_action(self, action: BriefingAction) -> None:
|
|
1069
|
+
if action is not BriefingAction.ANSWER:
|
|
1070
|
+
await self._open_palette(self._action_role(action), action=action.value)
|
|
1071
|
+
return
|
|
1072
|
+
resolve = self._widget_context.open_resolution
|
|
1073
|
+
escalation = next(
|
|
1074
|
+
(
|
|
1075
|
+
item
|
|
1076
|
+
for item in self._widget_context.views.escalations()
|
|
1077
|
+
if item.task_id == self._task_id
|
|
1078
|
+
),
|
|
1079
|
+
None,
|
|
1080
|
+
)
|
|
1081
|
+
if resolve is None or escalation is None:
|
|
1082
|
+
self._set_feedback(NO_DECISION_TO_ANSWER)
|
|
1083
|
+
return
|
|
1084
|
+
await resolve(escalation.escalation_id)
|
|
1085
|
+
|
|
1086
|
+
def _action_role(self, action: BriefingAction | None = None) -> Role | None:
|
|
1087
|
+
view = self._view
|
|
1088
|
+
if view is None or view.task_id != self._task_id:
|
|
1089
|
+
return None
|
|
1090
|
+
if action is BriefingAction.HAND_BACK and view.ownership:
|
|
1091
|
+
return Role(view.ownership[0])
|
|
1092
|
+
return Role(view.acting_role)
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
class TaskDetailScreen(ModalScreen[None]):
|
|
1096
|
+
"""Show a selected task's shared detail over the Home browser."""
|
|
1097
|
+
|
|
1098
|
+
DEFAULT_CSS = (
|
|
1099
|
+
POPUP_CSS
|
|
1100
|
+
+ """
|
|
1101
|
+
#task-detail-popup-frame { min-height: 1; }
|
|
1102
|
+
#task-detail-popup-frame.compact { margin: 0 1; padding: 0 1; }
|
|
1103
|
+
#task-detail-popup-frame.tiny {
|
|
1104
|
+
height: 100%; margin: 0; padding: 0;
|
|
1105
|
+
}
|
|
1106
|
+
#task-detail-panel { width: 100%; height: 1fr; min-height: 0; }
|
|
1107
|
+
#task-detail-panel #task-detail-body {
|
|
1108
|
+
height: 1fr; min-height: 0; max-height: 1fr; background: $surface;
|
|
1109
|
+
}
|
|
1110
|
+
"""
|
|
1111
|
+
)
|
|
1112
|
+
|
|
1113
|
+
def __init__(
|
|
1114
|
+
self,
|
|
1115
|
+
context: WidgetContext,
|
|
1116
|
+
task_id: str,
|
|
1117
|
+
*,
|
|
1118
|
+
initial_view: str = TASK_VIEW_SUMMARY,
|
|
1119
|
+
) -> None:
|
|
1120
|
+
super().__init__()
|
|
1121
|
+
prepare_popup(self)
|
|
1122
|
+
self._widget_context = context
|
|
1123
|
+
self._task_id = task_id
|
|
1124
|
+
self._initial_view = initial_view
|
|
1125
|
+
|
|
1126
|
+
def compose(self) -> ComposeResult:
|
|
1127
|
+
with PopupFrame(widget_id=TASK_DETAIL_POPUP_FRAME, classes=POPUP_FRAME_CLASS):
|
|
1128
|
+
with Horizontal(id=TASK_DETAIL_POPUP_HEADER, classes=POPUP_HEADER_CLASS):
|
|
1129
|
+
yield Label(
|
|
1130
|
+
self._heading(),
|
|
1131
|
+
id=TASK_DETAIL_POPUP_TITLE,
|
|
1132
|
+
classes=POPUP_TITLE_CLASS,
|
|
1133
|
+
)
|
|
1134
|
+
yield Button(
|
|
1135
|
+
popup_close_label(),
|
|
1136
|
+
id=TASK_DETAIL_POPUP_CLOSE,
|
|
1137
|
+
classes=POPUP_CLOSE_CLASS,
|
|
1138
|
+
)
|
|
1139
|
+
yield Rule(classes=POPUP_RULE_CLASS)
|
|
1140
|
+
yield TaskDetailPanel(
|
|
1141
|
+
self._widget_context,
|
|
1142
|
+
self._task_id,
|
|
1143
|
+
fixed=False,
|
|
1144
|
+
close_detail=self._close,
|
|
1145
|
+
initial_view=self._initial_view,
|
|
1146
|
+
on_initial_content=self.mark_popup_content_ready,
|
|
1147
|
+
)
|
|
1148
|
+
|
|
1149
|
+
def on_mount(self) -> None:
|
|
1150
|
+
self._apply_layout(self.size.width, self.size.height)
|
|
1151
|
+
panel = self.query_one("#task-detail-panel", TaskDetailPanel)
|
|
1152
|
+
if self._initial_view == TASK_VIEW_SUMMARY:
|
|
1153
|
+
panel.focus_summary()
|
|
1154
|
+
else:
|
|
1155
|
+
panel.open_view(self._initial_view)
|
|
1156
|
+
|
|
1157
|
+
def on_resize(self, event: events.Resize) -> None:
|
|
1158
|
+
self._apply_layout(event.size.width, event.size.height)
|
|
1159
|
+
self.call_after_refresh(
|
|
1160
|
+
self.query_one(f"#{TASK_DETAIL_POPUP_FRAME}", PopupFrame).update_preferred_size
|
|
1161
|
+
)
|
|
1162
|
+
|
|
1163
|
+
def _apply_layout(self, width: int, height: int) -> None:
|
|
1164
|
+
frame = self.query_one(f"#{TASK_DETAIL_POPUP_FRAME}", PopupFrame)
|
|
1165
|
+
compact = (
|
|
1166
|
+
width <= _TASK_DETAIL_COMPACT_MAX_COLUMNS or height <= _TASK_DETAIL_COMPACT_MAX_ROWS
|
|
1167
|
+
)
|
|
1168
|
+
frame.set_class(compact, "compact")
|
|
1169
|
+
frame.set_class(height <= TASK_DETAIL_TINY_MAX_ROWS, "tiny")
|
|
1170
|
+
|
|
1171
|
+
def mark_popup_content_ready(self) -> None:
|
|
1172
|
+
"""Mark meaningful task content ready for popup sizing."""
|
|
1173
|
+
if not self.is_mounted or not widget_is_attached(self):
|
|
1174
|
+
return
|
|
1175
|
+
self.query_one(f"#{TASK_DETAIL_POPUP_FRAME}", PopupFrame).content_ready()
|
|
1176
|
+
|
|
1177
|
+
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
1178
|
+
if event.button.id == TASK_DETAIL_POPUP_CLOSE:
|
|
1179
|
+
self.dismiss()
|
|
1180
|
+
|
|
1181
|
+
def _heading(self) -> str:
|
|
1182
|
+
return f"Task details · {self._task_id}"
|
|
1183
|
+
|
|
1184
|
+
async def _close(self) -> None:
|
|
1185
|
+
self.dismiss()
|
|
1186
|
+
|
|
1187
|
+
async def action_close(self) -> None:
|
|
1188
|
+
self.dismiss()
|
|
1189
|
+
|
|
1190
|
+
async def action_open_palette(self) -> None:
|
|
1191
|
+
await self.query_one("#task-detail-panel", TaskDetailPanel).open_palette()
|
|
1192
|
+
|
|
1193
|
+
async def action_open_help(self) -> None:
|
|
1194
|
+
await cast("DashboardApp", self.app).action_show_help()
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
type CloseDetail = Callable[[], Awaitable[None]]
|
|
1198
|
+
|
|
1199
|
+
|
|
1200
|
+
def _task_identity_text(identity: str) -> Text:
|
|
1201
|
+
return Text(identity, style=BOLD_PRIMARY)
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
def _has_expired_quota_reading(evidence: BlockerEvidenceView | None, now: datetime) -> bool:
|
|
1205
|
+
return evidence is not None and any(
|
|
1206
|
+
reading.resets_at is not None and reading.resets_at <= now
|
|
1207
|
+
for quota in evidence.quotas
|
|
1208
|
+
for reading in quota.readings
|
|
1209
|
+
)
|