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
shikiru/dashboard/app.py
ADDED
|
@@ -0,0 +1,1765 @@
|
|
|
1
|
+
"""The `dash --home | --task T-n` Textual workspace shell."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from typing import TYPE_CHECKING, ClassVar, Protocol, cast
|
|
10
|
+
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
from textual.containers import Grid, Horizontal, Vertical, VerticalScroll
|
|
13
|
+
from textual.screen import ModalScreen
|
|
14
|
+
from textual.widget import Widget
|
|
15
|
+
from textual.widgets import Button, Label, ListView, Rule, Select, Static
|
|
16
|
+
|
|
17
|
+
from shikiru.agents.registry import load_unique_entry_point
|
|
18
|
+
from shikiru.dashboard.contract import (
|
|
19
|
+
FOCUS_FAULTS_VIEW,
|
|
20
|
+
FOCUS_PROJECT_DIAGNOSTICS_VIEW,
|
|
21
|
+
WIDGET_ENTRY_POINT_GROUP,
|
|
22
|
+
CommandFeedbackEntryView,
|
|
23
|
+
CommandFeedbackStatus,
|
|
24
|
+
CommandHistoryView,
|
|
25
|
+
ExtensionEntry,
|
|
26
|
+
PaletteDraft,
|
|
27
|
+
PaletteRequest,
|
|
28
|
+
PaletteReturn,
|
|
29
|
+
ProjectRouteChoice,
|
|
30
|
+
TaskBrowserFilter,
|
|
31
|
+
TaskPaletteRoute,
|
|
32
|
+
WidgetContext,
|
|
33
|
+
WidgetPlacement,
|
|
34
|
+
)
|
|
35
|
+
from shikiru.dashboard.covered import CoveredScreens
|
|
36
|
+
from shikiru.dashboard.detail import (
|
|
37
|
+
COMPACT_INSPECTOR_CLASS,
|
|
38
|
+
TASK_DETAIL_ESCAPE,
|
|
39
|
+
TASK_DETAIL_HELP_KEY,
|
|
40
|
+
TASK_DETAIL_VIEWS_KEY,
|
|
41
|
+
TASK_VIEW_EVIDENCE,
|
|
42
|
+
TASK_VIEW_SUMMARY,
|
|
43
|
+
TASK_VIEW_USAGE,
|
|
44
|
+
TaskDetailPanel,
|
|
45
|
+
TaskDetailScreen,
|
|
46
|
+
TaskDetailVerb,
|
|
47
|
+
)
|
|
48
|
+
from shikiru.dashboard.detail_fold import FoldHeader
|
|
49
|
+
from shikiru.dashboard.diagnostics import ProjectDiagnosticsPanel
|
|
50
|
+
from shikiru.dashboard.help_keys import HelpAvailability, HelpTab
|
|
51
|
+
from shikiru.dashboard.help_screen import HelpContext, WorkspaceHelpScreen
|
|
52
|
+
from shikiru.dashboard.interaction import native_editor_has_focus
|
|
53
|
+
from shikiru.dashboard.palette_drafts import PaletteDrafts
|
|
54
|
+
from shikiru.dashboard.row_list import RowList
|
|
55
|
+
from shikiru.dashboard.tick import pinned_tick
|
|
56
|
+
from shikiru.dashboard.timeline_filter import TimelineFilterBar
|
|
57
|
+
from shikiru.dashboard.timeline_section import TimelineSection
|
|
58
|
+
from shikiru.domain.commands import command_rejection_cause
|
|
59
|
+
from shikiru.errors import ConfigError
|
|
60
|
+
from shikiru.ui.compact import ACTION_HINT, FocusAttentionStatus
|
|
61
|
+
from shikiru.ui.keys import key_hint
|
|
62
|
+
from shikiru.ui.popup import (
|
|
63
|
+
POPUP_BODY_CLASS,
|
|
64
|
+
POPUP_CLOSE_CLASS,
|
|
65
|
+
POPUP_COMPACT_CONTROL_CSS,
|
|
66
|
+
POPUP_CSS,
|
|
67
|
+
POPUP_FRAME_CLASS,
|
|
68
|
+
POPUP_HEADER_CLASS,
|
|
69
|
+
POPUP_ROW_CLASS,
|
|
70
|
+
POPUP_RULE_CLASS,
|
|
71
|
+
POPUP_SECONDARY_CLASS,
|
|
72
|
+
POPUP_TITLE_CLASS,
|
|
73
|
+
PopupFrame,
|
|
74
|
+
popup_close_label,
|
|
75
|
+
prepare_popup,
|
|
76
|
+
)
|
|
77
|
+
from shikiru.ui.screen import (
|
|
78
|
+
TabStopScreen,
|
|
79
|
+
widget_is_attached,
|
|
80
|
+
widget_is_current,
|
|
81
|
+
)
|
|
82
|
+
from shikiru.ui.section import BOLD_STEEL, GROUP_SEPARATOR, grey, row_name
|
|
83
|
+
from shikiru.ui.theme import install_theme
|
|
84
|
+
from shikiru.ui.tokens import (
|
|
85
|
+
AMBER,
|
|
86
|
+
DUSTY_ROSE,
|
|
87
|
+
PRIMARY_TEXT_COLOR,
|
|
88
|
+
SAGE,
|
|
89
|
+
SECONDARY_TEXT_COLOR,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
if TYPE_CHECKING:
|
|
93
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
94
|
+
|
|
95
|
+
from textual import events
|
|
96
|
+
from textual.app import ComposeResult
|
|
97
|
+
from textual.binding import BindingType
|
|
98
|
+
from textual.screen import Screen
|
|
99
|
+
from textual.worker import Worker
|
|
100
|
+
|
|
101
|
+
from shikiru.clock import Clock
|
|
102
|
+
from shikiru.dashboard.contract import (
|
|
103
|
+
AttentionView,
|
|
104
|
+
DashboardViews,
|
|
105
|
+
DashboardWidget,
|
|
106
|
+
NavigateHome,
|
|
107
|
+
NavigateTaskWindow,
|
|
108
|
+
OnChange,
|
|
109
|
+
OnChangeRegistration,
|
|
110
|
+
OpenPalette,
|
|
111
|
+
OpenPaletteContext,
|
|
112
|
+
ResolutionScreenFactory,
|
|
113
|
+
SettingsSource,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
_WIDGET_PLUGIN_KIND = "dashboard widget plugin"
|
|
117
|
+
_WIDGET_DIALOGUE = "dialogue"
|
|
118
|
+
_WIDGET_ESCALATIONS = "escalations"
|
|
119
|
+
_WIDGET_QUEUE = "queue"
|
|
120
|
+
_TASK_VIEW_OMITTED_PLACEMENTS = frozenset(
|
|
121
|
+
{
|
|
122
|
+
WidgetPlacement.IDENTITY,
|
|
123
|
+
WidgetPlacement.TASK_LIST,
|
|
124
|
+
WidgetPlacement.ATTENTION,
|
|
125
|
+
WidgetPlacement.RESOURCES,
|
|
126
|
+
WidgetPlacement.WORKFLOW,
|
|
127
|
+
WidgetPlacement.DIAGNOSTICS,
|
|
128
|
+
}
|
|
129
|
+
)
|
|
130
|
+
_COMMAND_HISTORY_CLOSE = "command-history-close"
|
|
131
|
+
_COMMAND_HISTORY_COPY = "command-history-copy"
|
|
132
|
+
_COMMAND_HISTORY_WORKER = "command-history-updates"
|
|
133
|
+
_COMMAND_HISTORY_SCROLL = "command-history-scroll"
|
|
134
|
+
_COMMAND_HISTORY_FILTER = "command-history-filter"
|
|
135
|
+
_COMMAND_HISTORY_FILTER_ROW = "command-history-filter-row"
|
|
136
|
+
_COMMAND_HISTORY_FILTER_LABEL = "command-history-filter-label"
|
|
137
|
+
_COMMAND_HISTORY_PREVIOUS = "command-history-previous"
|
|
138
|
+
_COMMAND_HISTORY_NEXT = "command-history-next"
|
|
139
|
+
_COMMAND_HISTORY_PAGE = "command-history-page"
|
|
140
|
+
_COMMAND_HISTORY_HEADER = "command-history-header"
|
|
141
|
+
_COMMAND_HISTORY_PAGE_SIZE = 20
|
|
142
|
+
_COMMAND_HISTORY_COMPACT_MAX_COLUMNS = 32
|
|
143
|
+
_COMMAND_HISTORY_PAGE_COMPACT_MAX_COLUMNS = 48
|
|
144
|
+
_COMMAND_HISTORY_TINY_MAX_ROWS = 8
|
|
145
|
+
_COMMAND_HISTORY_ALL = "all"
|
|
146
|
+
_COMMAND_HISTORY_PREVIOUS_KEY = "left"
|
|
147
|
+
_COMMAND_HISTORY_NEXT_KEY = "right"
|
|
148
|
+
_COMMAND_HISTORY_PREVIOUS_ACTION = "previous_page"
|
|
149
|
+
_COMMAND_HISTORY_NEXT_ACTION = "next_page"
|
|
150
|
+
COMMAND_ACTION_SAY = "Say"
|
|
151
|
+
COMMAND_RESULT_TASK_ID = "task_id"
|
|
152
|
+
COMMAND_RESULT_STATUS = "status"
|
|
153
|
+
COMMAND_RESULT_EFFECT = "effect"
|
|
154
|
+
COMMAND_RESULT_STOPPING = "stopping"
|
|
155
|
+
COMMAND_RESULT_PRIORITY = "priority"
|
|
156
|
+
COMMAND_RESULT_PAUSED = "paused"
|
|
157
|
+
COMMAND_RESULT_NOW = "now"
|
|
158
|
+
_SEARCH_KEY = "slash"
|
|
159
|
+
_FILTER_KEY = "f"
|
|
160
|
+
_MORE_KEY = "m"
|
|
161
|
+
_QUEUE_KEY_ACTIONS = {
|
|
162
|
+
_SEARCH_KEY: "open_search",
|
|
163
|
+
_FILTER_KEY: "cycle_status_filter",
|
|
164
|
+
_MORE_KEY: "request_more",
|
|
165
|
+
}
|
|
166
|
+
_QUEUE_KEY_OWNERS = {_FILTER_KEY: (TimelineSection,)}
|
|
167
|
+
_ACTIONS_KEY = "ctrl+p"
|
|
168
|
+
_HOME_FOOTER_CLEAR = "focus-footer-clear"
|
|
169
|
+
_HOME_FOOTER_ACTIONS = "focus-footer-actions"
|
|
170
|
+
_RESOURCES_RULE = "resources-rule"
|
|
171
|
+
_HOME_FOOTER_HELP = "focus-footer-help"
|
|
172
|
+
_HOME_FOOTER_DETACH = "focus-footer-detach"
|
|
173
|
+
_HOME_FOOTER_COMMAND_STATUS = "workspace-command-status"
|
|
174
|
+
_CONTEXT_WIDGET_HEADER = "context-widget-header"
|
|
175
|
+
_PROJECT_USAGE_TITLE = "Project usage"
|
|
176
|
+
_CONTEXT_WIDGET_CLOSE = "context-widget-close"
|
|
177
|
+
_CONTEXT_WIDGET_COMPACT_MAX_ROWS = 20
|
|
178
|
+
_CONTEXT_WIDGET_COMPACT_MAX_COLUMNS = 48
|
|
179
|
+
_CONTEXT_WIDGET_FRAME_VERTICAL_MARGIN_ROWS = 2
|
|
180
|
+
_DASHBOARD_PALETTE_EVENT_KEY = "f12"
|
|
181
|
+
_SUMMARY_ATTENTION = "attention"
|
|
182
|
+
_SUMMARY_RUNNING = "running"
|
|
183
|
+
_SUMMARY_WAITING = "waiting"
|
|
184
|
+
HOME_SPLIT_MIN_COLUMNS = 73
|
|
185
|
+
FOCUS_FOOTER_GROUP_MIN_COLUMNS = 48
|
|
186
|
+
FOCUS_FOOTER_STATUS_WIDE_LABEL_MIN_COLUMNS = 54
|
|
187
|
+
FOCUS_FOOTER_COMPACT_CONTROL_COLUMN_WIDTH = 13
|
|
188
|
+
FOCUS_FOOTER_COMPACT_WRAP_ROWS = 1
|
|
189
|
+
FOCUS_FOOTER_STACKED_COLUMN_COUNT = 1
|
|
190
|
+
FOCUS_FOOTER_GROUP_COUNT = 2
|
|
191
|
+
FOCUS_FOOTER_ACTION_COUNT = 5
|
|
192
|
+
FOCUS_SUMMARY_MAX_ROWS = 12
|
|
193
|
+
FOCUS_MAIN_MIN_ROWS = 3
|
|
194
|
+
FOCUS_COMPACT_MAIN_ROWS = 8
|
|
195
|
+
FOCUS_COMPACT_TASK_LIST_ROWS = 6
|
|
196
|
+
FOCUS_MIN_TASK_LIST_ROWS = 12
|
|
197
|
+
FOCUS_TASK_LIST_RECENT_RESERVE_ROWS = 3
|
|
198
|
+
FOCUS_FRAME_BORDER_ROWS = 2
|
|
199
|
+
FOCUS_FOOTER_MIN_HEIGHT = 9
|
|
200
|
+
FOCUS_ATTENTION_HEIGHT = 3
|
|
201
|
+
FOCUS_SHORT_HOME_HEIGHT = 14
|
|
202
|
+
FOCUS_COMPACT_SHORT_HOME_HEIGHT = 20
|
|
203
|
+
FOCUS_COMPACT_SHORT_MAIN_ROWS = 7
|
|
204
|
+
FOCUS_COMMAND_HISTORY = "commands"
|
|
205
|
+
FOCUS_USAGE_DETAILS = "usage"
|
|
206
|
+
FOCUS_WIDGET_PREFIX = "widget-"
|
|
207
|
+
_CONTEXT_WIDGET_PLACEMENTS = frozenset(
|
|
208
|
+
{WidgetPlacement.DETAILED_USAGE, WidgetPlacement.DIAGNOSTICS, WidgetPlacement.EXTENSIONS}
|
|
209
|
+
)
|
|
210
|
+
TASK_PALETTE_ROUTE_WORKER = "task-palette-route"
|
|
211
|
+
_TASK_MOVE_STEPS = {"up": -1, "down": 1}
|
|
212
|
+
_ARROW_KEY_OWNERS = (ListView, RowList, FoldHeader, TaskDetailVerb, TimelineFilterBar)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@dataclass
|
|
216
|
+
class _MotionCallback:
|
|
217
|
+
callback: OnChange
|
|
218
|
+
task: asyncio.Future[None] | None = None
|
|
219
|
+
|
|
220
|
+
@property
|
|
221
|
+
def in_flight(self) -> bool:
|
|
222
|
+
return self.task is not None and not self.task.done()
|
|
223
|
+
|
|
224
|
+
def cancel(self) -> None:
|
|
225
|
+
if self.task is not None and not self.task.done():
|
|
226
|
+
self.task.cancel()
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class _TaskBrowserNavigation(Protocol):
|
|
230
|
+
async def activate_filter(self, task_filter: TaskBrowserFilter) -> None: ...
|
|
231
|
+
|
|
232
|
+
async def move_selection(self, step: int) -> None: ...
|
|
233
|
+
|
|
234
|
+
def focus_task_results(self) -> None: ...
|
|
235
|
+
|
|
236
|
+
def refresh_after_resume(self) -> None: ...
|
|
237
|
+
|
|
238
|
+
def clear_selection(self) -> None: ...
|
|
239
|
+
|
|
240
|
+
def sync_context_selection(self, task_id: str | None) -> None: ...
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class _AttentionPanelNavigation(Protocol):
|
|
244
|
+
def activate_attention(self) -> None: ...
|
|
245
|
+
|
|
246
|
+
async def activate_selected_decision(self) -> bool: ...
|
|
247
|
+
|
|
248
|
+
def follow_task(self, task_id: str | None) -> None: ...
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class WidgetPluginError(ConfigError):
|
|
252
|
+
"""A configured dashboard widget name has no matching, loadable entry point."""
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _load_widget(name: str) -> DashboardWidget:
|
|
256
|
+
loaded = load_unique_entry_point(
|
|
257
|
+
WIDGET_ENTRY_POINT_GROUP, name, WidgetPluginError, _WIDGET_PLUGIN_KIND
|
|
258
|
+
)
|
|
259
|
+
widget_class = cast("type[DashboardWidget]", loaded)
|
|
260
|
+
try:
|
|
261
|
+
plugin = widget_class()
|
|
262
|
+
except Exception as error:
|
|
263
|
+
message = f"configured dashboard widget {name!r} failed to initialize: {error}"
|
|
264
|
+
raise WidgetPluginError(message) from error
|
|
265
|
+
if not callable(getattr(plugin, "create", None)):
|
|
266
|
+
message = f"configured dashboard widget {name!r} has no create(context) entry point"
|
|
267
|
+
raise WidgetPluginError(message)
|
|
268
|
+
return plugin
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _placement_for_widget(name: str, plugin: DashboardWidget) -> WidgetPlacement:
|
|
272
|
+
configured = getattr(plugin, "placement", None)
|
|
273
|
+
if configured is None:
|
|
274
|
+
return WidgetPlacement.EXTENSIONS
|
|
275
|
+
try:
|
|
276
|
+
return WidgetPlacement(configured)
|
|
277
|
+
except (TypeError, ValueError) as error:
|
|
278
|
+
message = (
|
|
279
|
+
f"configured dashboard widget {name!r} has an unsupported placement {configured!r}"
|
|
280
|
+
)
|
|
281
|
+
raise WidgetPluginError(message) from error
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _extension_name(name: str) -> str:
|
|
285
|
+
return name.replace("_", " ").capitalize()
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _contextual_widget_label(name: str, placement: WidgetPlacement) -> str:
|
|
289
|
+
category = {
|
|
290
|
+
WidgetPlacement.DETAILED_USAGE: "Usage",
|
|
291
|
+
WidgetPlacement.EXTENSIONS: "Extensions",
|
|
292
|
+
}.get(placement)
|
|
293
|
+
label = name.replace("_", " ")
|
|
294
|
+
return f"{category} · {label}" if category is not None else label.capitalize()
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@dataclass(frozen=True)
|
|
298
|
+
class _ContextChoice:
|
|
299
|
+
choice_id: str
|
|
300
|
+
label: str
|
|
301
|
+
title: str | None = None
|
|
302
|
+
widget_name: str | None = None
|
|
303
|
+
placement: WidgetPlacement | None = None
|
|
304
|
+
command_history: bool = False
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class _ContextWidgetScreen(ModalScreen[None]):
|
|
308
|
+
BINDINGS: ClassVar[list[BindingType]] = [("escape", "close", "Back")]
|
|
309
|
+
DEFAULT_CSS = (
|
|
310
|
+
POPUP_CSS
|
|
311
|
+
+ """
|
|
312
|
+
#context-widget-frame.compact { margin: 0 2; padding: 0 2; }
|
|
313
|
+
#context-widget-content { max-height: 1fr; min-height: 0; }
|
|
314
|
+
"""
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
def __init__(self, label: str, widget: Widget) -> None:
|
|
318
|
+
super().__init__()
|
|
319
|
+
prepare_popup(self)
|
|
320
|
+
self._label = label
|
|
321
|
+
self._widget = widget
|
|
322
|
+
|
|
323
|
+
def compose(self) -> ComposeResult:
|
|
324
|
+
with PopupFrame(widget_id="context-widget-frame", classes=POPUP_FRAME_CLASS):
|
|
325
|
+
with Horizontal(id=_CONTEXT_WIDGET_HEADER, classes=POPUP_HEADER_CLASS):
|
|
326
|
+
yield Label(self._label, id="context-widget-title", classes=POPUP_TITLE_CLASS)
|
|
327
|
+
yield Button(
|
|
328
|
+
popup_close_label(), id=_CONTEXT_WIDGET_CLOSE, classes=POPUP_CLOSE_CLASS
|
|
329
|
+
)
|
|
330
|
+
yield Rule(classes=POPUP_RULE_CLASS)
|
|
331
|
+
with VerticalScroll(id="context-widget-content", classes=POPUP_BODY_CLASS):
|
|
332
|
+
yield self._widget
|
|
333
|
+
|
|
334
|
+
def on_mount(self) -> None:
|
|
335
|
+
self._apply_compact_layout(self.size.width, self.size.height)
|
|
336
|
+
self.set_focus(self.query_one("#context-widget-content", VerticalScroll))
|
|
337
|
+
self.call_after_refresh(self.query_one("#context-widget-frame", PopupFrame).content_ready)
|
|
338
|
+
|
|
339
|
+
def on_resize(self, event: events.Resize) -> None:
|
|
340
|
+
self._apply_compact_layout(event.size.width, event.size.height)
|
|
341
|
+
self.call_after_refresh(
|
|
342
|
+
self.query_one("#context-widget-frame", PopupFrame).update_preferred_size
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
def _apply_compact_layout(self, width: int, height: int) -> None:
|
|
346
|
+
frame = self.query_one("#context-widget-frame")
|
|
347
|
+
compact = (
|
|
348
|
+
height <= _CONTEXT_WIDGET_COMPACT_MAX_ROWS
|
|
349
|
+
or width <= _CONTEXT_WIDGET_COMPACT_MAX_COLUMNS
|
|
350
|
+
)
|
|
351
|
+
frame.set_class(compact, "compact")
|
|
352
|
+
frame.styles.max_height = max(
|
|
353
|
+
1,
|
|
354
|
+
height - (0 if compact else _CONTEXT_WIDGET_FRAME_VERTICAL_MARGIN_ROWS),
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
358
|
+
if event.button.id == _CONTEXT_WIDGET_CLOSE:
|
|
359
|
+
self.dismiss()
|
|
360
|
+
|
|
361
|
+
async def action_close(self) -> None:
|
|
362
|
+
self.dismiss()
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
_COMMAND_STATUS_STYLES = {
|
|
366
|
+
CommandFeedbackStatus.PENDING: f"{AMBER} bold",
|
|
367
|
+
CommandFeedbackStatus.APPLIED: f"{SAGE} bold",
|
|
368
|
+
CommandFeedbackStatus.REJECTED: f"{DUSTY_ROSE} bold",
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _command_history_text(history: CommandHistoryView) -> Text:
|
|
373
|
+
if not history.entries:
|
|
374
|
+
return Text("No commands recorded.")
|
|
375
|
+
return Text("\n").join(
|
|
376
|
+
_command_entry_text(entry, engine_up=history.engine_up) for entry in history.entries
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _command_history_page_label(arrow: str, word: str, *, compact: bool = False) -> Text:
|
|
381
|
+
if compact:
|
|
382
|
+
return Text(arrow, style=BOLD_STEEL)
|
|
383
|
+
return key_hint(f"{arrow} {word}")
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _command_queue_time(timestamp: str | None) -> str | None:
|
|
387
|
+
if timestamp is None:
|
|
388
|
+
return None
|
|
389
|
+
queued_at = datetime.fromisoformat(timestamp).astimezone(UTC)
|
|
390
|
+
return f"{queued_at.month}/{queued_at.day} {queued_at:%H:%M}Z"
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _command_entry_text(entry: CommandFeedbackEntryView, *, engine_up: bool) -> Text:
|
|
394
|
+
result = _command_result_data(entry)
|
|
395
|
+
if entry.status is CommandFeedbackStatus.PENDING:
|
|
396
|
+
engine_state = "running" if engine_up else "stopped"
|
|
397
|
+
queued_at = _command_queue_time(entry.queued_at)
|
|
398
|
+
queued = f"queued {queued_at}; " if queued_at is not None else ""
|
|
399
|
+
detail = (
|
|
400
|
+
f"{queued}engine currently {engine_state}; "
|
|
401
|
+
"original acknowledgement wait context unknown"
|
|
402
|
+
)
|
|
403
|
+
elif entry.status is CommandFeedbackStatus.APPLIED:
|
|
404
|
+
detail = _command_applied_effect(entry, result)
|
|
405
|
+
else:
|
|
406
|
+
detail = command_rejection_cause(result)
|
|
407
|
+
return Text.assemble(
|
|
408
|
+
grey(f"#{entry.command_id}"),
|
|
409
|
+
grey(GROUP_SEPARATOR),
|
|
410
|
+
Text(entry.status.value, style=_COMMAND_STATUS_STYLES[entry.status]),
|
|
411
|
+
grey(GROUP_SEPARATOR),
|
|
412
|
+
Text(entry.action, style=PRIMARY_TEXT_COLOR),
|
|
413
|
+
grey(GROUP_SEPARATOR),
|
|
414
|
+
row_name(entry.target),
|
|
415
|
+
grey(GROUP_SEPARATOR),
|
|
416
|
+
grey(detail),
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _command_result_data(entry: CommandFeedbackEntryView) -> dict[str, object] | None:
|
|
421
|
+
if entry.result is None:
|
|
422
|
+
return None
|
|
423
|
+
return cast("dict[str, object]", json.loads(entry.result))
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _command_applied_effect(
|
|
427
|
+
entry: CommandFeedbackEntryView,
|
|
428
|
+
result: dict[str, object] | None,
|
|
429
|
+
) -> str:
|
|
430
|
+
if entry.action == COMMAND_ACTION_SAY:
|
|
431
|
+
return "guidance queued for the selected role's next turn"
|
|
432
|
+
if result is None:
|
|
433
|
+
return "engine applied the command effect"
|
|
434
|
+
effect = result.get(COMMAND_RESULT_EFFECT)
|
|
435
|
+
if isinstance(effect, str):
|
|
436
|
+
return effect
|
|
437
|
+
if result.get(COMMAND_RESULT_STOPPING) is True:
|
|
438
|
+
return "shutdown requested"
|
|
439
|
+
task_id = result.get(COMMAND_RESULT_TASK_ID)
|
|
440
|
+
if not isinstance(task_id, str):
|
|
441
|
+
return "engine applied the command effect"
|
|
442
|
+
return (
|
|
443
|
+
_task_command_effect(result, task_id, entry.target) or "engine applied the command effect"
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _task_command_effect(result: dict[str, object], task_id: str, target: str) -> str | None:
|
|
448
|
+
task_prefix = "" if task_id == target else f"{task_id} "
|
|
449
|
+
status = result.get(COMMAND_RESULT_STATUS)
|
|
450
|
+
if isinstance(status, str):
|
|
451
|
+
return f"{task_prefix}{status}"
|
|
452
|
+
priority = result.get(COMMAND_RESULT_PRIORITY)
|
|
453
|
+
if isinstance(priority, int) and not isinstance(priority, bool):
|
|
454
|
+
return f"{task_prefix}priority {priority}"
|
|
455
|
+
paused = result.get(COMMAND_RESULT_PAUSED)
|
|
456
|
+
if paused is True:
|
|
457
|
+
suffix = " now" if result.get(COMMAND_RESULT_NOW) is True else ""
|
|
458
|
+
return f"{task_prefix}paused{suffix}"
|
|
459
|
+
if paused is False:
|
|
460
|
+
return f"{task_prefix}resumed"
|
|
461
|
+
return None
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _command_summary_text(history: CommandHistoryView) -> Text:
|
|
465
|
+
entry = history.entries[0]
|
|
466
|
+
summary = Text()
|
|
467
|
+
summary.append(entry.status.value, style=_COMMAND_STATUS_STYLES[entry.status])
|
|
468
|
+
detail = f"{entry.action} {entry.target}"
|
|
469
|
+
if entry.status is CommandFeedbackStatus.PENDING:
|
|
470
|
+
queued_at = _command_queue_time(entry.queued_at)
|
|
471
|
+
detail = f"{detail}: queued {queued_at}" if queued_at is not None else detail
|
|
472
|
+
elif entry.status is CommandFeedbackStatus.APPLIED:
|
|
473
|
+
result = _command_result_data(entry)
|
|
474
|
+
detail = f"{detail}: {_command_applied_effect(entry, result)}"
|
|
475
|
+
else:
|
|
476
|
+
result = _command_result_data(entry)
|
|
477
|
+
detail = f"{detail}: {command_rejection_cause(result)}"
|
|
478
|
+
summary.append(f": {detail}", style=SECONDARY_TEXT_COLOR)
|
|
479
|
+
return summary
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _truncate_command_summary(summary: Text, width: int) -> Text:
|
|
483
|
+
truncated = summary.copy()
|
|
484
|
+
truncated.truncate(max(0, width), overflow="ellipsis")
|
|
485
|
+
return truncated
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
class _CommandHistoryScreen(ModalScreen[None]):
|
|
489
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
490
|
+
("escape", "close", "Back"),
|
|
491
|
+
(_COMMAND_HISTORY_PREVIOUS_KEY, _COMMAND_HISTORY_PREVIOUS_ACTION, "Newer history"),
|
|
492
|
+
(_COMMAND_HISTORY_NEXT_KEY, _COMMAND_HISTORY_NEXT_ACTION, "Older history"),
|
|
493
|
+
]
|
|
494
|
+
DEFAULT_CSS = (
|
|
495
|
+
POPUP_CSS
|
|
496
|
+
+ POPUP_COMPACT_CONTROL_CSS
|
|
497
|
+
+ """
|
|
498
|
+
#command-history-filter-row { width: 100%; }
|
|
499
|
+
#command-history-scroll { min-height: 0; }
|
|
500
|
+
#command-history-copy { height: auto; padding: 0; }
|
|
501
|
+
#command-history-page { height: 1; }
|
|
502
|
+
#command-history-controls { height: 1; width: 100%; }
|
|
503
|
+
#command-history-controls Button {
|
|
504
|
+
width: auto; min-width: 1; height: 1; margin: 0; padding: 0; border: none;
|
|
505
|
+
}
|
|
506
|
+
#command-history-frame.compact { margin: 0 1; padding: 0 1; }
|
|
507
|
+
#command-history-frame.tiny #command-history-filter-row,
|
|
508
|
+
#command-history-frame.tiny #command-history-page { display: none; }
|
|
509
|
+
"""
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
def __init__(self, views: DashboardViews, on_change: OnChangeRegistration) -> None:
|
|
513
|
+
super().__init__()
|
|
514
|
+
prepare_popup(self)
|
|
515
|
+
self._views = views
|
|
516
|
+
self._on_change = on_change
|
|
517
|
+
self._offset = 0
|
|
518
|
+
self._status: CommandFeedbackStatus | None = None
|
|
519
|
+
self._has_more = False
|
|
520
|
+
self._change_worker: Worker[None] | None = None
|
|
521
|
+
|
|
522
|
+
def compose(self) -> ComposeResult:
|
|
523
|
+
with PopupFrame(widget_id="command-history-frame", classes=POPUP_FRAME_CLASS):
|
|
524
|
+
with Horizontal(id=_COMMAND_HISTORY_HEADER, classes=POPUP_HEADER_CLASS):
|
|
525
|
+
yield Label(
|
|
526
|
+
"Command history",
|
|
527
|
+
id="command-history-title",
|
|
528
|
+
classes=POPUP_TITLE_CLASS,
|
|
529
|
+
)
|
|
530
|
+
yield Button(
|
|
531
|
+
popup_close_label(), id=_COMMAND_HISTORY_CLOSE, classes=POPUP_CLOSE_CLASS
|
|
532
|
+
)
|
|
533
|
+
yield Rule(classes=POPUP_RULE_CLASS)
|
|
534
|
+
with Horizontal(id=_COMMAND_HISTORY_FILTER_ROW, classes=POPUP_ROW_CLASS):
|
|
535
|
+
yield Label("Status", id=_COMMAND_HISTORY_FILTER_LABEL)
|
|
536
|
+
yield Select(
|
|
537
|
+
(
|
|
538
|
+
("All statuses", _COMMAND_HISTORY_ALL),
|
|
539
|
+
*((status.value, status.value) for status in CommandFeedbackStatus),
|
|
540
|
+
),
|
|
541
|
+
value=_COMMAND_HISTORY_ALL,
|
|
542
|
+
allow_blank=False,
|
|
543
|
+
id=_COMMAND_HISTORY_FILTER,
|
|
544
|
+
)
|
|
545
|
+
with VerticalScroll(id=_COMMAND_HISTORY_SCROLL, classes=POPUP_BODY_CLASS):
|
|
546
|
+
yield Static("Loading command history…", id=_COMMAND_HISTORY_COPY, markup=False)
|
|
547
|
+
yield Label("", id=_COMMAND_HISTORY_PAGE, classes=POPUP_SECONDARY_CLASS)
|
|
548
|
+
with Horizontal(id="command-history-controls"):
|
|
549
|
+
yield Button(
|
|
550
|
+
_command_history_page_label("←", "newer"),
|
|
551
|
+
id=_COMMAND_HISTORY_PREVIOUS,
|
|
552
|
+
disabled=True,
|
|
553
|
+
)
|
|
554
|
+
yield Button(
|
|
555
|
+
_command_history_page_label("→", "older"),
|
|
556
|
+
id=_COMMAND_HISTORY_NEXT,
|
|
557
|
+
disabled=True,
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
async def on_mount(self) -> None:
|
|
561
|
+
self._apply_compact_layout(self.size.width, self.size.height)
|
|
562
|
+
self.set_focus(cast("Select[str]", self.query_one(f"#{_COMMAND_HISTORY_FILTER}", Select)))
|
|
563
|
+
self._display_history()
|
|
564
|
+
self.call_after_refresh(self._limit_history_body, self.size.height)
|
|
565
|
+
self.call_after_refresh(self.query_one("#command-history-frame", PopupFrame).content_ready)
|
|
566
|
+
self._change_worker = self.run_worker(
|
|
567
|
+
self._on_change(self._refresh), group=_COMMAND_HISTORY_WORKER
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
def on_resize(self, event: events.Resize) -> None:
|
|
571
|
+
self._apply_compact_layout(event.size.width, event.size.height)
|
|
572
|
+
self.call_after_refresh(self._limit_history_body, event.size.height)
|
|
573
|
+
self.call_after_refresh(
|
|
574
|
+
self.query_one("#command-history-frame", PopupFrame).update_preferred_size
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
def _apply_compact_layout(self, width: int, height: int) -> None:
|
|
578
|
+
frame = self.query_one("#command-history-frame", PopupFrame)
|
|
579
|
+
compact = width <= _COMMAND_HISTORY_COMPACT_MAX_COLUMNS
|
|
580
|
+
tiny = height <= _COMMAND_HISTORY_TINY_MAX_ROWS
|
|
581
|
+
frame.set_class(compact or tiny, "compact")
|
|
582
|
+
frame.set_class(tiny, "tiny")
|
|
583
|
+
self.query_one("#command-history-title", Label).update(
|
|
584
|
+
"Hist." if compact or tiny else "Command history"
|
|
585
|
+
)
|
|
586
|
+
self.query_one(f"#{_COMMAND_HISTORY_PREVIOUS}", Button).label = _command_history_page_label(
|
|
587
|
+
"←", "newer", compact=compact
|
|
588
|
+
)
|
|
589
|
+
self.query_one(f"#{_COMMAND_HISTORY_NEXT}", Button).label = _command_history_page_label(
|
|
590
|
+
"→", "older", compact=compact
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
def _limit_history_body(self, viewport_height: int) -> None:
|
|
594
|
+
if not self.is_mounted:
|
|
595
|
+
return
|
|
596
|
+
frame = self.query_one("#command-history-frame")
|
|
597
|
+
body = self.query_one("#command-history-scroll", VerticalScroll)
|
|
598
|
+
fixed_height = sum(child.region.height for child in frame.children if child is not body)
|
|
599
|
+
body.styles.max_height = max(
|
|
600
|
+
0,
|
|
601
|
+
viewport_height
|
|
602
|
+
- frame.styles.margin.height
|
|
603
|
+
- frame.styles.gutter.height
|
|
604
|
+
- fixed_height,
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
def on_unmount(self) -> None:
|
|
608
|
+
if self._change_worker is not None:
|
|
609
|
+
self._change_worker.cancel()
|
|
610
|
+
|
|
611
|
+
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
|
|
612
|
+
del parameters
|
|
613
|
+
pages = action in {_COMMAND_HISTORY_PREVIOUS_ACTION, _COMMAND_HISTORY_NEXT_ACTION}
|
|
614
|
+
if pages and not self._arrow_paging_available():
|
|
615
|
+
return None
|
|
616
|
+
if action == _COMMAND_HISTORY_PREVIOUS_ACTION:
|
|
617
|
+
return self._offset > 0
|
|
618
|
+
if action == _COMMAND_HISTORY_NEXT_ACTION:
|
|
619
|
+
return self._has_more
|
|
620
|
+
return True
|
|
621
|
+
|
|
622
|
+
def _arrow_paging_available(self) -> bool:
|
|
623
|
+
filter_select = cast("Select[str]", self.query_one(f"#{_COMMAND_HISTORY_FILTER}", Select))
|
|
624
|
+
if filter_select.expanded:
|
|
625
|
+
return False
|
|
626
|
+
return self.focused is filter_select or not native_editor_has_focus(self.focused)
|
|
627
|
+
|
|
628
|
+
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
629
|
+
if event.button.id == _COMMAND_HISTORY_CLOSE:
|
|
630
|
+
self.dismiss()
|
|
631
|
+
elif event.button.id == _COMMAND_HISTORY_PREVIOUS:
|
|
632
|
+
self.action_previous_page()
|
|
633
|
+
elif event.button.id == _COMMAND_HISTORY_NEXT and self._has_more:
|
|
634
|
+
self.action_next_page()
|
|
635
|
+
|
|
636
|
+
async def on_select_changed(self, event: Select.Changed) -> None:
|
|
637
|
+
if event.select.id != _COMMAND_HISTORY_FILTER:
|
|
638
|
+
return
|
|
639
|
+
selected = cast("str", event.value)
|
|
640
|
+
self._status = None if selected == _COMMAND_HISTORY_ALL else CommandFeedbackStatus(selected)
|
|
641
|
+
self._offset = 0
|
|
642
|
+
self._display_history()
|
|
643
|
+
|
|
644
|
+
async def action_close(self) -> None:
|
|
645
|
+
self.dismiss()
|
|
646
|
+
|
|
647
|
+
def action_previous_page(self) -> None:
|
|
648
|
+
if self._offset == 0:
|
|
649
|
+
return
|
|
650
|
+
self._offset = max(0, self._offset - _COMMAND_HISTORY_PAGE_SIZE)
|
|
651
|
+
self._display_history()
|
|
652
|
+
|
|
653
|
+
def action_next_page(self) -> None:
|
|
654
|
+
if self._has_more:
|
|
655
|
+
self._offset += _COMMAND_HISTORY_PAGE_SIZE
|
|
656
|
+
self._display_history()
|
|
657
|
+
|
|
658
|
+
async def _refresh(self) -> None:
|
|
659
|
+
if widget_is_attached(self):
|
|
660
|
+
self._display_history(preserve_scroll=True)
|
|
661
|
+
|
|
662
|
+
def _display_history(self, *, preserve_scroll: bool = False) -> None:
|
|
663
|
+
scroll = self.query_one(f"#{_COMMAND_HISTORY_SCROLL}", VerticalScroll)
|
|
664
|
+
scroll_y = scroll.scroll_y if preserve_scroll and self.is_mounted else 0
|
|
665
|
+
scroll_at_bottom = preserve_scroll and scroll_y >= scroll.max_scroll_y
|
|
666
|
+
history = self._views.command_history(
|
|
667
|
+
expanded=True,
|
|
668
|
+
limit=_COMMAND_HISTORY_PAGE_SIZE,
|
|
669
|
+
offset=self._offset,
|
|
670
|
+
status=self._status,
|
|
671
|
+
)
|
|
672
|
+
if history.total_count and history.offset >= history.total_count:
|
|
673
|
+
self._offset = max(0, (history.total_count - 1) // history.limit * history.limit)
|
|
674
|
+
history = self._views.command_history(
|
|
675
|
+
expanded=True,
|
|
676
|
+
limit=_COMMAND_HISTORY_PAGE_SIZE,
|
|
677
|
+
offset=self._offset,
|
|
678
|
+
status=self._status,
|
|
679
|
+
)
|
|
680
|
+
elif not history.total_count:
|
|
681
|
+
self._offset = 0
|
|
682
|
+
self.query_one(f"#{_COMMAND_HISTORY_COPY}", Static).update(_command_history_text(history))
|
|
683
|
+
self._has_more = history.has_more
|
|
684
|
+
start = history.offset + 1 if history.entries else 0
|
|
685
|
+
end = history.offset + len(history.entries)
|
|
686
|
+
page_number = history.offset // history.limit + 1
|
|
687
|
+
filter_name = history.status.value if history.status is not None else "all statuses"
|
|
688
|
+
page_text = (
|
|
689
|
+
f"{page_number} · {end}/{history.total_count}"
|
|
690
|
+
if self.size.width <= _COMMAND_HISTORY_PAGE_COMPACT_MAX_COLUMNS
|
|
691
|
+
else f"{filter_name} · page {page_number} · rows {start}-{end} of {history.total_count}"
|
|
692
|
+
)
|
|
693
|
+
self.query_one(f"#{_COMMAND_HISTORY_PAGE}", Label).update(page_text)
|
|
694
|
+
self.query_one(f"#{_COMMAND_HISTORY_PREVIOUS}", Button).disabled = history.offset == 0
|
|
695
|
+
self.query_one(f"#{_COMMAND_HISTORY_NEXT}", Button).disabled = not history.has_more
|
|
696
|
+
if preserve_scroll:
|
|
697
|
+
self.call_after_refresh(
|
|
698
|
+
lambda: scroll.scroll_to(
|
|
699
|
+
y=scroll.max_scroll_y if scroll_at_bottom else scroll_y,
|
|
700
|
+
animate=False,
|
|
701
|
+
)
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
class _CommandStatusLine(Button):
|
|
706
|
+
def __init__(self, views: DashboardViews, on_change: OnChangeRegistration) -> None:
|
|
707
|
+
super().__init__(
|
|
708
|
+
"",
|
|
709
|
+
id=_HOME_FOOTER_COMMAND_STATUS,
|
|
710
|
+
tooltip="Open full command history",
|
|
711
|
+
flat=True,
|
|
712
|
+
)
|
|
713
|
+
self._views = views
|
|
714
|
+
self._on_change = on_change
|
|
715
|
+
self._history: CommandHistoryView | None = None
|
|
716
|
+
self._transient_message: str | None = None
|
|
717
|
+
|
|
718
|
+
async def on_mount(self) -> None:
|
|
719
|
+
self._display_history()
|
|
720
|
+
self.call_after_refresh(self._render_status)
|
|
721
|
+
self.run_worker(self._on_change(self._refresh), group=_COMMAND_HISTORY_WORKER)
|
|
722
|
+
|
|
723
|
+
def on_resize(self, event: events.Resize) -> None:
|
|
724
|
+
del event
|
|
725
|
+
self.call_after_refresh(self._render_status)
|
|
726
|
+
|
|
727
|
+
async def _refresh(self) -> None:
|
|
728
|
+
if widget_is_attached(self):
|
|
729
|
+
self._display_history()
|
|
730
|
+
|
|
731
|
+
def _display_history(self) -> None:
|
|
732
|
+
self._transient_message = None
|
|
733
|
+
self._history = self._views.command_history()
|
|
734
|
+
self._render_status()
|
|
735
|
+
|
|
736
|
+
def show_transient(self, message: str) -> None:
|
|
737
|
+
self._transient_message = message
|
|
738
|
+
self._render_status()
|
|
739
|
+
|
|
740
|
+
def _render_status(self) -> None:
|
|
741
|
+
if not widget_is_attached(self):
|
|
742
|
+
return
|
|
743
|
+
was_displayed = self.display
|
|
744
|
+
if self._transient_message is not None:
|
|
745
|
+
self.label = _truncate_command_summary(
|
|
746
|
+
Text(self._transient_message, style=SECONDARY_TEXT_COLOR),
|
|
747
|
+
self._summary_render_width(),
|
|
748
|
+
)
|
|
749
|
+
self.display = True
|
|
750
|
+
else:
|
|
751
|
+
history = self._history
|
|
752
|
+
self.display = bool(history and history.entries)
|
|
753
|
+
if history is not None and history.entries:
|
|
754
|
+
self.label = _truncate_command_summary(
|
|
755
|
+
_command_summary_text(history), self._summary_render_width()
|
|
756
|
+
)
|
|
757
|
+
if (
|
|
758
|
+
was_displayed != self.display
|
|
759
|
+
and isinstance(self.parent, _HomeFooter)
|
|
760
|
+
and widget_is_attached(self.parent)
|
|
761
|
+
):
|
|
762
|
+
app = cast("DashboardApp", self.app)
|
|
763
|
+
self.call_after_refresh(app.refresh_layout)
|
|
764
|
+
self.call_after_refresh(self._render_status)
|
|
765
|
+
|
|
766
|
+
def _summary_render_width(self) -> int:
|
|
767
|
+
return max(0, self.content_size.width)
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
class _HomeAttentionStatus(Static):
|
|
771
|
+
def __init__(self, context: WidgetContext) -> None:
|
|
772
|
+
super().__init__("", id="escalations", markup=False)
|
|
773
|
+
self._widget_context = context
|
|
774
|
+
self._attention: AttentionView | None = None
|
|
775
|
+
self._remove_motion_callback: Callable[[], None] | None = None
|
|
776
|
+
|
|
777
|
+
async def on_mount(self) -> None:
|
|
778
|
+
await self._refresh()
|
|
779
|
+
self.run_worker(self._widget_context.on_change(self._refresh_if_attached), exclusive=True)
|
|
780
|
+
self._remove_motion_callback = self._widget_context.on_motion(self._refresh_if_active)
|
|
781
|
+
|
|
782
|
+
def on_unmount(self) -> None:
|
|
783
|
+
if self._remove_motion_callback is not None:
|
|
784
|
+
self._remove_motion_callback()
|
|
785
|
+
self._remove_motion_callback = None
|
|
786
|
+
|
|
787
|
+
async def on_click(self, event: events.Click) -> None:
|
|
788
|
+
attention = self._attention
|
|
789
|
+
if attention is None:
|
|
790
|
+
return
|
|
791
|
+
if not attention.groups:
|
|
792
|
+
summary = self._widget_context.views.workspace_summary()
|
|
793
|
+
if summary.engine_up is False and self._widget_context.open_contextual_view is not None:
|
|
794
|
+
await self._widget_context.open_contextual_view(FOCUS_COMMAND_HISTORY)
|
|
795
|
+
event.stop()
|
|
796
|
+
return
|
|
797
|
+
group = attention.groups[0]
|
|
798
|
+
if group.task_id is not None and self._widget_context.on_task_selected is not None:
|
|
799
|
+
self._widget_context.on_task_selected(group.task_id)
|
|
800
|
+
if group.escalations:
|
|
801
|
+
if self._widget_context.open_resolution is not None:
|
|
802
|
+
await self._widget_context.open_resolution(group.escalations[0].escalation_id)
|
|
803
|
+
event.stop()
|
|
804
|
+
elif group.task_id is not None:
|
|
805
|
+
if self._widget_context.on_task_selected is not None:
|
|
806
|
+
self._widget_context.on_task_selected(group.task_id)
|
|
807
|
+
event.stop()
|
|
808
|
+
elif self._widget_context.open_contextual_view is not None:
|
|
809
|
+
await self._widget_context.open_contextual_view(FOCUS_FAULTS_VIEW)
|
|
810
|
+
event.stop()
|
|
811
|
+
|
|
812
|
+
async def _refresh_if_active(self) -> None:
|
|
813
|
+
if widget_is_current(self):
|
|
814
|
+
await self._refresh()
|
|
815
|
+
|
|
816
|
+
async def _refresh_if_attached(self) -> None:
|
|
817
|
+
if widget_is_attached(self):
|
|
818
|
+
await self._refresh()
|
|
819
|
+
|
|
820
|
+
async def _refresh(self) -> None:
|
|
821
|
+
attention = self._widget_context.views.attention()
|
|
822
|
+
summary = self._widget_context.views.workspace_summary()
|
|
823
|
+
self._attention = attention
|
|
824
|
+
self.update(
|
|
825
|
+
FocusAttentionStatus(
|
|
826
|
+
attention,
|
|
827
|
+
summary,
|
|
828
|
+
action_hint=ACTION_HINT,
|
|
829
|
+
)
|
|
830
|
+
)
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
class _HomeFooter(Grid):
|
|
834
|
+
def __init__(
|
|
835
|
+
self,
|
|
836
|
+
tmux_detach_hint: str | None,
|
|
837
|
+
views: DashboardViews,
|
|
838
|
+
on_change: OnChangeRegistration,
|
|
839
|
+
) -> None:
|
|
840
|
+
super().__init__(id="focus-footer")
|
|
841
|
+
self._tmux_detach_hint = tmux_detach_hint
|
|
842
|
+
self._views = views
|
|
843
|
+
self._on_change = on_change
|
|
844
|
+
|
|
845
|
+
def compose(self) -> ComposeResult:
|
|
846
|
+
yield Static(
|
|
847
|
+
key_hint("↑↓ select", SECONDARY_TEXT_COLOR),
|
|
848
|
+
markup=False,
|
|
849
|
+
)
|
|
850
|
+
yield Button(
|
|
851
|
+
key_hint("Esc clear", SECONDARY_TEXT_COLOR),
|
|
852
|
+
id=_HOME_FOOTER_CLEAR,
|
|
853
|
+
)
|
|
854
|
+
yield Button(
|
|
855
|
+
key_hint("Ctrl+P actions", SECONDARY_TEXT_COLOR),
|
|
856
|
+
id=_HOME_FOOTER_ACTIONS,
|
|
857
|
+
)
|
|
858
|
+
detach_hint = (
|
|
859
|
+
Text.assemble(
|
|
860
|
+
(self._tmux_detach_hint, BOLD_STEEL),
|
|
861
|
+
(" d", BOLD_STEEL),
|
|
862
|
+
(" detach", SECONDARY_TEXT_COLOR),
|
|
863
|
+
)
|
|
864
|
+
if self._tmux_detach_hint is not None
|
|
865
|
+
else Text("tmux detach unavailable", style=SECONDARY_TEXT_COLOR)
|
|
866
|
+
)
|
|
867
|
+
yield Button(
|
|
868
|
+
detach_hint,
|
|
869
|
+
id=_HOME_FOOTER_DETACH,
|
|
870
|
+
disabled=self._tmux_detach_hint is None,
|
|
871
|
+
)
|
|
872
|
+
yield Button(
|
|
873
|
+
Text.assemble(("?", BOLD_STEEL), (" help", SECONDARY_TEXT_COLOR)),
|
|
874
|
+
id=_HOME_FOOTER_HELP,
|
|
875
|
+
)
|
|
876
|
+
yield _CommandStatusLine(self._views, self._on_change)
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
class DashboardApp(CoveredScreens):
|
|
880
|
+
"""Present the configured Home focus or one shared task detail view."""
|
|
881
|
+
|
|
882
|
+
ENABLE_COMMAND_PALETTE = False
|
|
883
|
+
CSS = """
|
|
884
|
+
Screen { color: $primary-text; }
|
|
885
|
+
#focus-root {
|
|
886
|
+
height: 100%; border: solid $neutral-border; padding: 0 1;
|
|
887
|
+
background: $background; scrollbar-size-vertical: 0;
|
|
888
|
+
}
|
|
889
|
+
#focus-readouts {
|
|
890
|
+
width: 100%; height: auto; padding: 0; background: $background;
|
|
891
|
+
scrollbar-size-vertical: 0;
|
|
892
|
+
}
|
|
893
|
+
#header { height: auto; margin-bottom: 0; text-wrap: wrap; text-overflow: clip; }
|
|
894
|
+
#usage { height: auto; margin-bottom: 0; }
|
|
895
|
+
#resources { height: auto; margin-bottom: 1; }
|
|
896
|
+
#resources-rule { margin: 0; color: $neutral-border; }
|
|
897
|
+
#escalations {
|
|
898
|
+
width: 100%; height: auto; max-height: 3; padding: 0 1; margin-bottom: 1;
|
|
899
|
+
background: $surface;
|
|
900
|
+
}
|
|
901
|
+
#escalations #escalation-panels { scrollbar-size-vertical: $scrollbar-width; }
|
|
902
|
+
#usage:focus, #resources:focus, #escalations:focus, #escalations.armed {
|
|
903
|
+
background: $selected-surface;
|
|
904
|
+
}
|
|
905
|
+
#focus-main { height: 1fr; min-height: 3; }
|
|
906
|
+
#focus-main.wide { layout: horizontal; }
|
|
907
|
+
#focus-main.compact { layout: vertical; overflow-y: auto; scrollbar-size-vertical: 0; }
|
|
908
|
+
#focus-main.wide #focus-task-list-column {
|
|
909
|
+
width: 42%; min-width: 0; height: 100%; padding-right: 1;
|
|
910
|
+
}
|
|
911
|
+
#focus-main.wide #queue {
|
|
912
|
+
width: 100%; min-width: 0; height: 100%;
|
|
913
|
+
}
|
|
914
|
+
#focus-main.wide #task-browser-active { height: 1fr; min-height: 4; }
|
|
915
|
+
#focus-main.wide #task-browser-list { height: 1fr; min-height: 2; }
|
|
916
|
+
#focus-main.wide #task-detail-panel {
|
|
917
|
+
width: 58%; min-width: 0; height: 100%; padding-left: 2;
|
|
918
|
+
border-left: solid $neutral-border;
|
|
919
|
+
}
|
|
920
|
+
#focus-main.wide #task-detail-body { height: 1fr; max-height: 1fr; }
|
|
921
|
+
#focus-main.compact #focus-task-list-column { width: 100%; height: auto; }
|
|
922
|
+
#focus-main.compact #queue { width: 100%; height: auto; }
|
|
923
|
+
#focus-main.compact #task-browser-list { width: 100%; height: auto; min-height: 3; }
|
|
924
|
+
#focus-main.compact #task-detail-panel { width: 100%; height: auto; }
|
|
925
|
+
#focus-main.compact #task-detail-body { max-height: 12; }
|
|
926
|
+
#task-view-root { height: 100%; }
|
|
927
|
+
#task-view-root #task-detail-panel { height: 1fr; min-height: 0; }
|
|
928
|
+
#task-view-root #task-detail-body { height: 1fr; min-height: 0; max-height: 1fr; }
|
|
929
|
+
#focus-empty { height: 1fr; padding: 1; color: $secondary-text; }
|
|
930
|
+
#focus-footer {
|
|
931
|
+
layout: grid; grid-size: 3 0; grid-columns: auto auto auto; grid-gutter: 0 1;
|
|
932
|
+
height: auto; min-height: 1; padding: 0; margin-top: 0; border-top: solid $neutral-border;
|
|
933
|
+
background: $background; color: $secondary-text;
|
|
934
|
+
}
|
|
935
|
+
#focus-footer Static { width: auto; height: auto; padding: 0 1; color: $secondary-text; }
|
|
936
|
+
#focus-footer Button {
|
|
937
|
+
width: auto; min-width: 1; height: auto; min-height: 1; margin: 0; padding: 0;
|
|
938
|
+
border: none; background: $background; color: $secondary-text;
|
|
939
|
+
content-align: left top; text-align: left; text-wrap: wrap; text-style: none;
|
|
940
|
+
}
|
|
941
|
+
#focus-footer Button:hover { color: $steel-blue; text-style: none; }
|
|
942
|
+
#focus-footer Button:focus { color: $primary-text; text-style: reverse; }
|
|
943
|
+
#workspace-command-status {
|
|
944
|
+
width: 100%; height: 1; min-width: 0; max-height: 1; padding: 0 1;
|
|
945
|
+
border: none; background: $background; color: $secondary-text;
|
|
946
|
+
content-align: right middle; text-align: right; text-wrap: nowrap;
|
|
947
|
+
}
|
|
948
|
+
#workspace-command-status:hover { color: $steel-blue; }
|
|
949
|
+
#workspace-command-status:focus { color: $primary-text; text-style: reverse; }
|
|
950
|
+
#focus-footer #workspace-command-status {
|
|
951
|
+
width: 1fr; min-width: 0; padding: 0; text-wrap: nowrap;
|
|
952
|
+
}
|
|
953
|
+
#focus-footer.grouped #workspace-command-status {
|
|
954
|
+
content-align: right middle; text-align: right;
|
|
955
|
+
}
|
|
956
|
+
"""
|
|
957
|
+
|
|
958
|
+
def __init__(
|
|
959
|
+
self,
|
|
960
|
+
*,
|
|
961
|
+
focus_task: str | None,
|
|
962
|
+
views: DashboardViews,
|
|
963
|
+
on_change: OnChangeRegistration,
|
|
964
|
+
clock: Clock,
|
|
965
|
+
motion_interval_seconds: float | None,
|
|
966
|
+
settings: SettingsSource,
|
|
967
|
+
open_palette: OpenPalette,
|
|
968
|
+
widget_names: Sequence[str],
|
|
969
|
+
open_palette_context: OpenPaletteContext | None = None,
|
|
970
|
+
resolution_screen: ResolutionScreenFactory | None = None,
|
|
971
|
+
navigate_task_window: NavigateTaskWindow | None = None,
|
|
972
|
+
navigate_home: NavigateHome | None = None,
|
|
973
|
+
tmux_detach_hint: str | None = None,
|
|
974
|
+
detach_tmux_client: Callable[[], Awaitable[bool]] | None = None,
|
|
975
|
+
) -> None:
|
|
976
|
+
"""Build the home or task workspace with the configured widgets."""
|
|
977
|
+
super().__init__()
|
|
978
|
+
install_theme(self)
|
|
979
|
+
self._focus_task = focus_task
|
|
980
|
+
self._views = views
|
|
981
|
+
self._on_change = on_change
|
|
982
|
+
self._clock = clock
|
|
983
|
+
self._motion_interval_seconds = motion_interval_seconds
|
|
984
|
+
self._settings = settings
|
|
985
|
+
self._open_palette = open_palette
|
|
986
|
+
self._open_palette_context = open_palette_context
|
|
987
|
+
self._resolution_screen = resolution_screen
|
|
988
|
+
self._palette_drafts = PaletteDrafts()
|
|
989
|
+
self._selected_task_id = focus_task
|
|
990
|
+
self._navigate_task_window = navigate_task_window
|
|
991
|
+
self._navigate_home = navigate_home
|
|
992
|
+
self._tmux_detach_hint = tmux_detach_hint
|
|
993
|
+
self._detach_tmux_client = detach_tmux_client
|
|
994
|
+
self._configured_widget_names = tuple(widget_names)
|
|
995
|
+
self._widget_names = self._configured_widget_names
|
|
996
|
+
self._motion_callbacks: list[_MotionCallback] = []
|
|
997
|
+
self._home_detail_panel: TaskDetailPanel | None = None
|
|
998
|
+
self._widget_plugins: dict[str, DashboardWidget] = {}
|
|
999
|
+
self._widget_placements: dict[str, WidgetPlacement] = {}
|
|
1000
|
+
self._widget_names_by_placement: dict[WidgetPlacement, list[str]] = {}
|
|
1001
|
+
self._widgets_configured = False
|
|
1002
|
+
self._widget_instances: dict[str, Widget] = {}
|
|
1003
|
+
|
|
1004
|
+
def compose(self) -> ComposeResult:
|
|
1005
|
+
"""Mount configured primary content and keep other widgets behind contextual views."""
|
|
1006
|
+
self._configure_widgets()
|
|
1007
|
+
if self._focus_task is None:
|
|
1008
|
+
yield from self._compose_focus()
|
|
1009
|
+
else:
|
|
1010
|
+
yield from self._compose_task_workspace()
|
|
1011
|
+
|
|
1012
|
+
def _configure_widgets(self) -> None:
|
|
1013
|
+
if self._widgets_configured:
|
|
1014
|
+
return
|
|
1015
|
+
for name in self._configured_widget_names:
|
|
1016
|
+
if name in self._widget_plugins:
|
|
1017
|
+
message = f"configured dashboard widget {name!r} appears more than once"
|
|
1018
|
+
raise WidgetPluginError(message)
|
|
1019
|
+
plugin = _load_widget(name)
|
|
1020
|
+
placement = _placement_for_widget(name, plugin)
|
|
1021
|
+
self._widget_plugins[name] = plugin
|
|
1022
|
+
self._widget_placements[name] = placement
|
|
1023
|
+
self._widget_names_by_placement.setdefault(placement, []).append(name)
|
|
1024
|
+
self._widget_names = self._configured_widget_names
|
|
1025
|
+
self._widgets_configured = True
|
|
1026
|
+
|
|
1027
|
+
def _compose_focus(self) -> ComposeResult:
|
|
1028
|
+
context = self._widget_context()
|
|
1029
|
+
with VerticalScroll(id="focus-root", can_focus=False):
|
|
1030
|
+
with VerticalScroll(id="focus-readouts", can_focus=False):
|
|
1031
|
+
for placement in (
|
|
1032
|
+
WidgetPlacement.IDENTITY,
|
|
1033
|
+
WidgetPlacement.PROJECT_USAGE,
|
|
1034
|
+
WidgetPlacement.RESOURCES,
|
|
1035
|
+
):
|
|
1036
|
+
for name in self._widget_names_by_placement.get(placement, ()):
|
|
1037
|
+
if placement is WidgetPlacement.RESOURCES:
|
|
1038
|
+
yield Rule(id=_RESOURCES_RULE)
|
|
1039
|
+
widget = self._create_widget(name)
|
|
1040
|
+
self._widget_instances[name] = widget
|
|
1041
|
+
yield widget
|
|
1042
|
+
attention_names = self._widget_names_by_placement.get(WidgetPlacement.ATTENTION, ())
|
|
1043
|
+
if attention_names:
|
|
1044
|
+
for name in attention_names:
|
|
1045
|
+
widget = self._create_widget(name)
|
|
1046
|
+
self._widget_instances[name] = widget
|
|
1047
|
+
yield widget
|
|
1048
|
+
else:
|
|
1049
|
+
yield _HomeAttentionStatus(context)
|
|
1050
|
+
with Horizontal(id="focus-main"):
|
|
1051
|
+
task_list_names = self._widget_names_by_placement.get(WidgetPlacement.TASK_LIST, ())
|
|
1052
|
+
if task_list_names:
|
|
1053
|
+
with Vertical(id="focus-task-list-column"):
|
|
1054
|
+
for name in task_list_names:
|
|
1055
|
+
widget = self._create_widget(name)
|
|
1056
|
+
self._widget_instances[name] = widget
|
|
1057
|
+
yield widget
|
|
1058
|
+
self._home_detail_panel = TaskDetailPanel(
|
|
1059
|
+
context,
|
|
1060
|
+
self._selected_task_id,
|
|
1061
|
+
fixed=False,
|
|
1062
|
+
)
|
|
1063
|
+
yield self._home_detail_panel
|
|
1064
|
+
else:
|
|
1065
|
+
yield Static("Task list is not configured.", id="focus-empty", markup=False)
|
|
1066
|
+
yield _HomeFooter(self._tmux_detach_hint, self._views, self._on_change)
|
|
1067
|
+
|
|
1068
|
+
def _compose_task_workspace(self) -> ComposeResult:
|
|
1069
|
+
context = self._widget_context()
|
|
1070
|
+
with Vertical(id="task-view-root"):
|
|
1071
|
+
yield TaskDetailPanel(
|
|
1072
|
+
context,
|
|
1073
|
+
self._focus_task,
|
|
1074
|
+
fixed=True,
|
|
1075
|
+
)
|
|
1076
|
+
yield _CommandStatusLine(self._views, self._on_change)
|
|
1077
|
+
|
|
1078
|
+
def _create_widget(
|
|
1079
|
+
self,
|
|
1080
|
+
name: str,
|
|
1081
|
+
*,
|
|
1082
|
+
placement: WidgetPlacement | None = None,
|
|
1083
|
+
) -> Widget:
|
|
1084
|
+
plugin = self._widget_plugins[name]
|
|
1085
|
+
selected_placement = placement or self._widget_placements[name]
|
|
1086
|
+
try:
|
|
1087
|
+
widget = cast("object", plugin.create(self._widget_context(selected_placement)))
|
|
1088
|
+
except Exception as error:
|
|
1089
|
+
message = f"configured dashboard widget {name!r} failed to create: {error}"
|
|
1090
|
+
raise WidgetPluginError(message) from error
|
|
1091
|
+
if not isinstance(widget, Widget):
|
|
1092
|
+
message = (
|
|
1093
|
+
f"configured dashboard widget {name!r} returned {type(widget).__name__}, "
|
|
1094
|
+
"expected a Textual Widget"
|
|
1095
|
+
)
|
|
1096
|
+
raise WidgetPluginError(message)
|
|
1097
|
+
return widget
|
|
1098
|
+
|
|
1099
|
+
def _widget_context(
|
|
1100
|
+
self,
|
|
1101
|
+
placement: WidgetPlacement = WidgetPlacement.EXTENSIONS,
|
|
1102
|
+
) -> WidgetContext:
|
|
1103
|
+
async def open_task_detail(task_id: str) -> None:
|
|
1104
|
+
await self._open_task_detail(task_id)
|
|
1105
|
+
|
|
1106
|
+
async def open_task_detail_view(task_id: str, view_id: str) -> None:
|
|
1107
|
+
await self.open_task_detail_view(task_id, view_id)
|
|
1108
|
+
|
|
1109
|
+
return WidgetContext(
|
|
1110
|
+
views=self._views,
|
|
1111
|
+
focus_task=self._focus_task,
|
|
1112
|
+
settings=self._settings,
|
|
1113
|
+
clock=self._clock,
|
|
1114
|
+
on_change=self._on_change,
|
|
1115
|
+
on_motion=self.register_motion_callback,
|
|
1116
|
+
open_palette=self._open_palette,
|
|
1117
|
+
open_palette_context=self._open_contextual_palette,
|
|
1118
|
+
open_resolution=self.open_resolution,
|
|
1119
|
+
open_task_detail=open_task_detail,
|
|
1120
|
+
open_task_detail_view=open_task_detail_view,
|
|
1121
|
+
navigate_task_window=self._navigate_task_window,
|
|
1122
|
+
navigate_home=self._navigate_home,
|
|
1123
|
+
on_task_selected=self._select_task_context,
|
|
1124
|
+
activate_home_summary=self._activate_home_summary,
|
|
1125
|
+
activate_attention_decision=self._activate_attention_decision,
|
|
1126
|
+
placement=placement,
|
|
1127
|
+
home_attention_present=self._focus_task is None,
|
|
1128
|
+
open_contextual_view=self._open_contextual_view,
|
|
1129
|
+
)
|
|
1130
|
+
|
|
1131
|
+
async def _activate_home_summary(self, summary: str) -> None:
|
|
1132
|
+
if self._focus_task is not None:
|
|
1133
|
+
return
|
|
1134
|
+
if summary == _SUMMARY_ATTENTION:
|
|
1135
|
+
panel = self._widget_instances.get(_WIDGET_ESCALATIONS)
|
|
1136
|
+
if panel is None:
|
|
1137
|
+
self._show_navigation_feedback("Attention is not configured.")
|
|
1138
|
+
return
|
|
1139
|
+
attention_panel = cast("_AttentionPanelNavigation", panel)
|
|
1140
|
+
attention_panel.activate_attention()
|
|
1141
|
+
return
|
|
1142
|
+
task_filter = {
|
|
1143
|
+
_SUMMARY_RUNNING: TaskBrowserFilter.ACTIVE,
|
|
1144
|
+
_SUMMARY_WAITING: TaskBrowserFilter.WAITING,
|
|
1145
|
+
}.get(summary)
|
|
1146
|
+
if task_filter is None:
|
|
1147
|
+
message = f"unknown home summary {summary!r}"
|
|
1148
|
+
raise ValueError(message)
|
|
1149
|
+
browser = self._widget_instances.get(_WIDGET_QUEUE)
|
|
1150
|
+
if browser is None:
|
|
1151
|
+
self._show_navigation_feedback("Task browser is not configured.")
|
|
1152
|
+
return
|
|
1153
|
+
await cast("_TaskBrowserNavigation", browser).activate_filter(task_filter)
|
|
1154
|
+
|
|
1155
|
+
async def _activate_attention_decision(self) -> bool:
|
|
1156
|
+
if self._focus_task is not None:
|
|
1157
|
+
return False
|
|
1158
|
+
panel = self._widget_instances.get(_WIDGET_ESCALATIONS)
|
|
1159
|
+
if panel is None or not widget_is_current(panel):
|
|
1160
|
+
return False
|
|
1161
|
+
return await cast("_AttentionPanelNavigation", panel).activate_selected_decision()
|
|
1162
|
+
|
|
1163
|
+
def _show_navigation_feedback(self, message: str) -> None:
|
|
1164
|
+
self.query_one(f"#{_HOME_FOOTER_COMMAND_STATUS}", _CommandStatusLine).show_transient(
|
|
1165
|
+
message
|
|
1166
|
+
)
|
|
1167
|
+
|
|
1168
|
+
def _context_choices(self) -> tuple[_ContextChoice, ...]:
|
|
1169
|
+
choices = [
|
|
1170
|
+
_ContextChoice(
|
|
1171
|
+
FOCUS_COMMAND_HISTORY,
|
|
1172
|
+
"Command history · pending, applied and rejected",
|
|
1173
|
+
command_history=True,
|
|
1174
|
+
)
|
|
1175
|
+
]
|
|
1176
|
+
for name in self._configured_widget_names:
|
|
1177
|
+
placement = self._widget_placements[name]
|
|
1178
|
+
if placement is WidgetPlacement.PROJECT_USAGE:
|
|
1179
|
+
choices.append(
|
|
1180
|
+
_ContextChoice(
|
|
1181
|
+
FOCUS_USAGE_DETAILS,
|
|
1182
|
+
"Usage details",
|
|
1183
|
+
title=_PROJECT_USAGE_TITLE,
|
|
1184
|
+
widget_name=name,
|
|
1185
|
+
placement=WidgetPlacement.DETAILED_USAGE,
|
|
1186
|
+
)
|
|
1187
|
+
)
|
|
1188
|
+
elif placement in _CONTEXT_WIDGET_PLACEMENTS:
|
|
1189
|
+
choices.append(
|
|
1190
|
+
_ContextChoice(
|
|
1191
|
+
f"{FOCUS_WIDGET_PREFIX}{name}",
|
|
1192
|
+
_contextual_widget_label(name, placement),
|
|
1193
|
+
widget_name=name,
|
|
1194
|
+
placement=placement,
|
|
1195
|
+
)
|
|
1196
|
+
)
|
|
1197
|
+
return tuple(choices)
|
|
1198
|
+
|
|
1199
|
+
def extension_entries(self) -> tuple[ExtensionEntry, ...]:
|
|
1200
|
+
"""List the extension widgets the operator can open from the palette."""
|
|
1201
|
+
return tuple(
|
|
1202
|
+
ExtensionEntry(f"{FOCUS_WIDGET_PREFIX}{name}", _extension_name(name))
|
|
1203
|
+
for name in self._widget_names_by_placement.get(WidgetPlacement.EXTENSIONS, ())
|
|
1204
|
+
)
|
|
1205
|
+
|
|
1206
|
+
async def _open_contextual_view(self, choice_id: str) -> None:
|
|
1207
|
+
if choice_id == FOCUS_PROJECT_DIAGNOSTICS_VIEW:
|
|
1208
|
+
await self._show_project_diagnostics()
|
|
1209
|
+
return
|
|
1210
|
+
choice = self._find_context_choice(choice_id)
|
|
1211
|
+
if choice is None and choice_id == FOCUS_FAULTS_VIEW:
|
|
1212
|
+
self._show_navigation_feedback("Faults not configured")
|
|
1213
|
+
history = self._find_context_choice(FOCUS_COMMAND_HISTORY)
|
|
1214
|
+
if history is not None:
|
|
1215
|
+
await self._run_context_choice(history)
|
|
1216
|
+
return
|
|
1217
|
+
if choice is None:
|
|
1218
|
+
message = f"unknown contextual dashboard view {choice_id!r}"
|
|
1219
|
+
raise ValueError(message)
|
|
1220
|
+
await self._run_context_choice(choice)
|
|
1221
|
+
|
|
1222
|
+
async def _show_project_diagnostics(self) -> None:
|
|
1223
|
+
panel = ProjectDiagnosticsPanel(
|
|
1224
|
+
self._views,
|
|
1225
|
+
self._on_change,
|
|
1226
|
+
tuple(
|
|
1227
|
+
self._create_widget(name, placement=WidgetPlacement.DIAGNOSTICS)
|
|
1228
|
+
for name in self._widget_names_by_placement.get(WidgetPlacement.DIAGNOSTICS, ())
|
|
1229
|
+
),
|
|
1230
|
+
)
|
|
1231
|
+
await self.push_screen(
|
|
1232
|
+
_ContextWidgetScreen("Project diagnostics", panel),
|
|
1233
|
+
callback=lambda _: self.refresh_task_detail_after_resume(),
|
|
1234
|
+
)
|
|
1235
|
+
|
|
1236
|
+
async def _open_contextual_palette(self, request: PaletteRequest) -> None:
|
|
1237
|
+
callback = self._open_palette_context
|
|
1238
|
+
if callback is None:
|
|
1239
|
+
await self._open_palette(request.action or "")
|
|
1240
|
+
return
|
|
1241
|
+
await callback(self._palette_drafts.attach(request))
|
|
1242
|
+
|
|
1243
|
+
async def open_resolution(self, escalation_id: str) -> None:
|
|
1244
|
+
"""Open the resolution screen for one escalation."""
|
|
1245
|
+
factory = self._resolution_screen
|
|
1246
|
+
if factory is None:
|
|
1247
|
+
message = "the escalation resolution screen is not configured"
|
|
1248
|
+
raise ConfigError(message)
|
|
1249
|
+
await self.push_screen(
|
|
1250
|
+
factory(escalation_id),
|
|
1251
|
+
callback=lambda _: self.refresh_task_detail_after_resume(),
|
|
1252
|
+
)
|
|
1253
|
+
|
|
1254
|
+
def remember_contextual_palette_draft(self, draft: PaletteDraft) -> None:
|
|
1255
|
+
"""Keep the latest contextual form draft in memory while its caller is visible."""
|
|
1256
|
+
self._palette_drafts.remember(draft)
|
|
1257
|
+
|
|
1258
|
+
def attach_palette_drafts(self, request: PaletteRequest) -> PaletteRequest:
|
|
1259
|
+
"""Return `request` carrying the drafts remembered for its task."""
|
|
1260
|
+
return self._palette_drafts.attach(request)
|
|
1261
|
+
|
|
1262
|
+
def route_task_palette(self, task_id: str, route: TaskPaletteRoute) -> None:
|
|
1263
|
+
"""Open a task detail destination selected from its contextual action surface."""
|
|
1264
|
+
self.run_worker(
|
|
1265
|
+
self._open_task_palette_route(task_id, route),
|
|
1266
|
+
group=TASK_PALETTE_ROUTE_WORKER,
|
|
1267
|
+
)
|
|
1268
|
+
|
|
1269
|
+
def palette_return_callback(self) -> Callable[[PaletteReturn | None], None]:
|
|
1270
|
+
"""Return from the Actions list to its task context and optional secondary route."""
|
|
1271
|
+
|
|
1272
|
+
def on_return(choice: PaletteReturn | None) -> None:
|
|
1273
|
+
self.refresh_task_detail_after_resume()
|
|
1274
|
+
if isinstance(choice, ExtensionEntry):
|
|
1275
|
+
self.run_worker(
|
|
1276
|
+
self._open_contextual_view(choice.choice_id),
|
|
1277
|
+
group=TASK_PALETTE_ROUTE_WORKER,
|
|
1278
|
+
)
|
|
1279
|
+
elif isinstance(choice, ProjectRouteChoice):
|
|
1280
|
+
self.run_worker(
|
|
1281
|
+
self._open_project_palette_route(choice.route),
|
|
1282
|
+
group=TASK_PALETTE_ROUTE_WORKER,
|
|
1283
|
+
)
|
|
1284
|
+
elif choice is not None:
|
|
1285
|
+
self.route_task_palette(choice.task_id, choice.route)
|
|
1286
|
+
|
|
1287
|
+
return on_return
|
|
1288
|
+
|
|
1289
|
+
async def _open_project_palette_route(self, route: TaskPaletteRoute) -> None:
|
|
1290
|
+
if route is TaskPaletteRoute.COMMANDS:
|
|
1291
|
+
await self._open_contextual_view(FOCUS_COMMAND_HISTORY)
|
|
1292
|
+
elif route is TaskPaletteRoute.DIAGNOSTICS:
|
|
1293
|
+
await self._open_contextual_view(FOCUS_PROJECT_DIAGNOSTICS_VIEW)
|
|
1294
|
+
else:
|
|
1295
|
+
message = f"unsupported project palette route: {route.value}"
|
|
1296
|
+
raise ValueError(message)
|
|
1297
|
+
|
|
1298
|
+
async def _open_task_palette_route(self, task_id: str, route: TaskPaletteRoute) -> None:
|
|
1299
|
+
if self._focus_task is not None and self._focus_task != task_id:
|
|
1300
|
+
return
|
|
1301
|
+
if self._focus_task is None:
|
|
1302
|
+
self._select_task_context(task_id)
|
|
1303
|
+
if route is TaskPaletteRoute.INSPECT:
|
|
1304
|
+
await self._open_task_detail(task_id)
|
|
1305
|
+
return
|
|
1306
|
+
if route is TaskPaletteRoute.EVIDENCE:
|
|
1307
|
+
await self.action_open_task_evidence()
|
|
1308
|
+
return
|
|
1309
|
+
if route is TaskPaletteRoute.USAGE:
|
|
1310
|
+
await self.action_open_task_usage()
|
|
1311
|
+
return
|
|
1312
|
+
if route is TaskPaletteRoute.COMMANDS:
|
|
1313
|
+
await self.push_screen(
|
|
1314
|
+
_CommandHistoryScreen(self._views, self._on_change),
|
|
1315
|
+
callback=lambda _: self.refresh_task_detail_after_resume(),
|
|
1316
|
+
)
|
|
1317
|
+
elif route is TaskPaletteRoute.DIAGNOSTICS:
|
|
1318
|
+
await self._open_contextual_view(FOCUS_PROJECT_DIAGNOSTICS_VIEW)
|
|
1319
|
+
|
|
1320
|
+
def _find_context_choice(self, choice_id: str) -> _ContextChoice | None:
|
|
1321
|
+
return next(
|
|
1322
|
+
(item for item in self._context_choices() if item.choice_id == choice_id),
|
|
1323
|
+
None,
|
|
1324
|
+
)
|
|
1325
|
+
|
|
1326
|
+
async def _run_context_choice(self, choice: _ContextChoice) -> None:
|
|
1327
|
+
if choice.command_history:
|
|
1328
|
+
await self.push_screen(
|
|
1329
|
+
_CommandHistoryScreen(self._views, self._on_change),
|
|
1330
|
+
callback=lambda _: self.refresh_task_detail_after_resume(),
|
|
1331
|
+
)
|
|
1332
|
+
return
|
|
1333
|
+
await self._show_context_widget(choice)
|
|
1334
|
+
|
|
1335
|
+
async def _open_task_detail(
|
|
1336
|
+
self,
|
|
1337
|
+
task_id: str,
|
|
1338
|
+
*,
|
|
1339
|
+
initial_view: str = TASK_VIEW_SUMMARY,
|
|
1340
|
+
) -> None:
|
|
1341
|
+
def refresh_resumed_detail(_: None) -> None:
|
|
1342
|
+
self.refresh_task_detail_after_resume()
|
|
1343
|
+
|
|
1344
|
+
await self.push_screen(
|
|
1345
|
+
TaskDetailScreen(
|
|
1346
|
+
self._widget_context(),
|
|
1347
|
+
task_id,
|
|
1348
|
+
initial_view=initial_view,
|
|
1349
|
+
),
|
|
1350
|
+
callback=refresh_resumed_detail,
|
|
1351
|
+
)
|
|
1352
|
+
|
|
1353
|
+
async def _show_context_widget(self, choice: _ContextChoice) -> None:
|
|
1354
|
+
if choice.widget_name is None or choice.placement is None:
|
|
1355
|
+
message = f"contextual dashboard view {choice.choice_id!r} has no target"
|
|
1356
|
+
raise WidgetPluginError(message)
|
|
1357
|
+
widget = self._create_widget(choice.widget_name, placement=choice.placement)
|
|
1358
|
+
await self.push_screen(
|
|
1359
|
+
_ContextWidgetScreen(choice.title or choice.label, widget),
|
|
1360
|
+
callback=lambda _: self.refresh_task_detail_after_resume(),
|
|
1361
|
+
)
|
|
1362
|
+
|
|
1363
|
+
def _select_task_context(self, task_id: str | None) -> None:
|
|
1364
|
+
self._selected_task_id = task_id
|
|
1365
|
+
if self._home_detail_panel is not None:
|
|
1366
|
+
self._home_detail_panel.select_task(task_id)
|
|
1367
|
+
queue = self._widget_instances.get(_WIDGET_QUEUE)
|
|
1368
|
+
if queue is not None:
|
|
1369
|
+
cast("_TaskBrowserNavigation", queue).sync_context_selection(task_id)
|
|
1370
|
+
attention = self._widget_instances.get(_WIDGET_ESCALATIONS)
|
|
1371
|
+
if attention is not None:
|
|
1372
|
+
cast("_AttentionPanelNavigation", attention).follow_task(task_id)
|
|
1373
|
+
|
|
1374
|
+
def _clear_home_selection(self) -> None:
|
|
1375
|
+
queue = self._widget_instances.get(_WIDGET_QUEUE)
|
|
1376
|
+
if queue is None:
|
|
1377
|
+
self._select_task_context(None)
|
|
1378
|
+
return
|
|
1379
|
+
browser = cast("_TaskBrowserNavigation", queue)
|
|
1380
|
+
browser.clear_selection()
|
|
1381
|
+
browser.focus_task_results()
|
|
1382
|
+
|
|
1383
|
+
def get_default_screen(self) -> Screen[object]:
|
|
1384
|
+
return TabStopScreen()
|
|
1385
|
+
|
|
1386
|
+
async def on_mount(self) -> None:
|
|
1387
|
+
"""Focus the task browser or task view and start signal-driven refresh."""
|
|
1388
|
+
self._apply_layout(self.size.width, self.size.height)
|
|
1389
|
+
if self._focus_task is None:
|
|
1390
|
+
task_list = self.query("#task-browser-list")
|
|
1391
|
+
queue = self._widget_instances.get(_WIDGET_QUEUE)
|
|
1392
|
+
if queue is not None:
|
|
1393
|
+
cast("_TaskBrowserNavigation", queue).focus_task_results()
|
|
1394
|
+
elif task_list:
|
|
1395
|
+
self.set_focus(task_list.first())
|
|
1396
|
+
else:
|
|
1397
|
+
self.set_focus(self.query_one(f"#{_HOME_FOOTER_ACTIONS}", Button))
|
|
1398
|
+
else:
|
|
1399
|
+
self.query_one("#task-detail-panel", TaskDetailPanel).focus_summary()
|
|
1400
|
+
if self._motion_interval_seconds is not None:
|
|
1401
|
+
self.run_worker(self._motion_loop(), group="dashboard-motion", exclusive=True)
|
|
1402
|
+
|
|
1403
|
+
def on_resize(self, event: events.Resize) -> None:
|
|
1404
|
+
self._apply_layout(event.size.width, event.size.height)
|
|
1405
|
+
|
|
1406
|
+
def refresh_layout(self) -> None:
|
|
1407
|
+
"""Recalculate Home row allocations after a footer status becomes visible or hidden."""
|
|
1408
|
+
if not self.is_running or self._focus_task is not None:
|
|
1409
|
+
return
|
|
1410
|
+
if any(
|
|
1411
|
+
not self.query(selector)
|
|
1412
|
+
for selector in (
|
|
1413
|
+
"#focus-main",
|
|
1414
|
+
"#focus-readouts",
|
|
1415
|
+
"#focus-footer",
|
|
1416
|
+
"#workspace-command-status",
|
|
1417
|
+
)
|
|
1418
|
+
):
|
|
1419
|
+
return
|
|
1420
|
+
self._apply_layout(self.size.width, self.size.height)
|
|
1421
|
+
|
|
1422
|
+
def refresh_task_detail_after_resume(self) -> None:
|
|
1423
|
+
"""Refresh task detail mounted in the screen that resumed after an overlay."""
|
|
1424
|
+
if not self.is_running:
|
|
1425
|
+
return
|
|
1426
|
+
if self._focus_task is None:
|
|
1427
|
+
queue = self._widget_instances.get(_WIDGET_QUEUE)
|
|
1428
|
+
if queue is not None:
|
|
1429
|
+
cast("_TaskBrowserNavigation", queue).refresh_after_resume()
|
|
1430
|
+
panels = self.screen.query(TaskDetailPanel)
|
|
1431
|
+
if panels:
|
|
1432
|
+
panels.first().refresh_after_resume()
|
|
1433
|
+
|
|
1434
|
+
async def on_key(self, event: events.Key) -> None:
|
|
1435
|
+
task_detail_screen = isinstance(self.screen, TaskDetailScreen)
|
|
1436
|
+
if event.key == TASK_DETAIL_ESCAPE and self._focused_select_is_expanded():
|
|
1437
|
+
return
|
|
1438
|
+
if event.key == _DASHBOARD_PALETTE_EVENT_KEY:
|
|
1439
|
+
if isinstance(self.screen, ModalScreen) and not task_detail_screen:
|
|
1440
|
+
return
|
|
1441
|
+
await self.action_open_palette()
|
|
1442
|
+
event.stop()
|
|
1443
|
+
event.prevent_default()
|
|
1444
|
+
return
|
|
1445
|
+
if (native_editor_has_focus(self.focused) and event.key != TASK_DETAIL_ESCAPE) or (
|
|
1446
|
+
isinstance(self.screen, ModalScreen) and not task_detail_screen
|
|
1447
|
+
):
|
|
1448
|
+
return
|
|
1449
|
+
if await self._move_task_selection(event.key):
|
|
1450
|
+
event.stop()
|
|
1451
|
+
event.prevent_default()
|
|
1452
|
+
return
|
|
1453
|
+
if await self._dispatch_workspace_key(event):
|
|
1454
|
+
event.stop()
|
|
1455
|
+
if event.key in {_ACTIONS_KEY, TASK_DETAIL_ESCAPE}:
|
|
1456
|
+
event.prevent_default()
|
|
1457
|
+
|
|
1458
|
+
async def _move_task_selection(self, key: str) -> bool:
|
|
1459
|
+
if (
|
|
1460
|
+
key not in _TASK_MOVE_STEPS
|
|
1461
|
+
or isinstance(self.screen, ModalScreen)
|
|
1462
|
+
or isinstance(self.focused, _ARROW_KEY_OWNERS)
|
|
1463
|
+
):
|
|
1464
|
+
return False
|
|
1465
|
+
queue = self._widget_instances.get(_WIDGET_QUEUE)
|
|
1466
|
+
if queue is None:
|
|
1467
|
+
return False
|
|
1468
|
+
await cast("_TaskBrowserNavigation", queue).move_selection(_TASK_MOVE_STEPS[key])
|
|
1469
|
+
return True
|
|
1470
|
+
|
|
1471
|
+
def _focused_select_is_expanded(self) -> bool:
|
|
1472
|
+
focused = self.focused
|
|
1473
|
+
while focused is not None:
|
|
1474
|
+
if isinstance(focused, Select):
|
|
1475
|
+
return focused.expanded
|
|
1476
|
+
focused = focused.parent
|
|
1477
|
+
return False
|
|
1478
|
+
|
|
1479
|
+
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
1480
|
+
if event.button.id == _HOME_FOOTER_CLEAR:
|
|
1481
|
+
self._clear_home_selection()
|
|
1482
|
+
elif event.button.id == _HOME_FOOTER_ACTIONS:
|
|
1483
|
+
await self.action_open_palette()
|
|
1484
|
+
elif event.button.id == _HOME_FOOTER_DETACH and self._detach_tmux_client is not None:
|
|
1485
|
+
if not await self._detach_tmux_client():
|
|
1486
|
+
self._show_navigation_feedback("No project tmux client is currently showing Home.")
|
|
1487
|
+
elif event.button.id == _HOME_FOOTER_HELP:
|
|
1488
|
+
await self.action_show_help()
|
|
1489
|
+
elif event.button.id == _HOME_FOOTER_COMMAND_STATUS:
|
|
1490
|
+
await self.push_screen(
|
|
1491
|
+
_CommandHistoryScreen(self._views, self._on_change),
|
|
1492
|
+
callback=lambda _: self.refresh_task_detail_after_resume(),
|
|
1493
|
+
)
|
|
1494
|
+
|
|
1495
|
+
async def _dispatch_workspace_key(self, event: events.Key) -> bool:
|
|
1496
|
+
if event.key == _ACTIONS_KEY:
|
|
1497
|
+
await self.action_open_palette()
|
|
1498
|
+
elif event.key == TASK_DETAIL_ESCAPE:
|
|
1499
|
+
await self.action_return_from_section()
|
|
1500
|
+
elif event.key == TASK_DETAIL_HELP_KEY:
|
|
1501
|
+
await self.action_show_help()
|
|
1502
|
+
elif event.key == TASK_DETAIL_VIEWS_KEY:
|
|
1503
|
+
panel = self._context_task_detail_panel()
|
|
1504
|
+
if panel is not None:
|
|
1505
|
+
panel.focus_folds()
|
|
1506
|
+
else:
|
|
1507
|
+
return self._run_queue_key(event.key)
|
|
1508
|
+
return True
|
|
1509
|
+
|
|
1510
|
+
def _focus_inside(self, owners: tuple[type[Widget], ...]) -> bool:
|
|
1511
|
+
focused = self.focused
|
|
1512
|
+
return focused is not None and any(
|
|
1513
|
+
isinstance(widget, owners) for widget in (focused, *focused.ancestors)
|
|
1514
|
+
)
|
|
1515
|
+
|
|
1516
|
+
def _run_queue_key(self, key: str) -> bool:
|
|
1517
|
+
action = _QUEUE_KEY_ACTIONS.get(key)
|
|
1518
|
+
if (
|
|
1519
|
+
action is None
|
|
1520
|
+
or self._focus_task is not None
|
|
1521
|
+
or _WIDGET_QUEUE not in self._widget_names
|
|
1522
|
+
or self._focus_inside(_QUEUE_KEY_OWNERS.get(key, ()))
|
|
1523
|
+
):
|
|
1524
|
+
return False
|
|
1525
|
+
queue_action = getattr(self._widget_instances.get(_WIDGET_QUEUE), action, None)
|
|
1526
|
+
if not callable(queue_action):
|
|
1527
|
+
return False
|
|
1528
|
+
queue_action()
|
|
1529
|
+
return True
|
|
1530
|
+
|
|
1531
|
+
async def _motion_loop(self) -> None:
|
|
1532
|
+
interval = self._motion_interval_seconds
|
|
1533
|
+
if interval is None:
|
|
1534
|
+
return
|
|
1535
|
+
while True:
|
|
1536
|
+
await self._clock.sleep(interval)
|
|
1537
|
+
tick = self._clock.now()
|
|
1538
|
+
for registration in tuple(self._motion_callbacks):
|
|
1539
|
+
if registration.in_flight:
|
|
1540
|
+
continue
|
|
1541
|
+
with pinned_tick(tick):
|
|
1542
|
+
registration.task = asyncio.ensure_future(registration.callback())
|
|
1543
|
+
registration.task.add_done_callback(self._surface_motion_callback_error)
|
|
1544
|
+
|
|
1545
|
+
def _surface_motion_callback_error(self, task: asyncio.Future[None]) -> None:
|
|
1546
|
+
if task.cancelled():
|
|
1547
|
+
return
|
|
1548
|
+
error = task.exception()
|
|
1549
|
+
if isinstance(error, Exception):
|
|
1550
|
+
self._handle_exception(error)
|
|
1551
|
+
|
|
1552
|
+
def register_motion_callback(self, callback: OnChange) -> Callable[[], None]:
|
|
1553
|
+
"""Register a motion callback and return its cancellation subscription."""
|
|
1554
|
+
registration = _MotionCallback(callback)
|
|
1555
|
+
self._motion_callbacks.append(registration)
|
|
1556
|
+
|
|
1557
|
+
def unsubscribe() -> None:
|
|
1558
|
+
self._motion_callbacks = [
|
|
1559
|
+
item for item in self._motion_callbacks if item is not registration
|
|
1560
|
+
]
|
|
1561
|
+
registration.cancel()
|
|
1562
|
+
|
|
1563
|
+
return unsubscribe
|
|
1564
|
+
|
|
1565
|
+
def on_unmount(self) -> None:
|
|
1566
|
+
for registration in self._motion_callbacks:
|
|
1567
|
+
registration.cancel()
|
|
1568
|
+
self._motion_callbacks.clear()
|
|
1569
|
+
|
|
1570
|
+
def _apply_layout(self, width: int, height: int) -> None:
|
|
1571
|
+
if self._focus_task is None:
|
|
1572
|
+
main = self.query_one("#focus-main", Horizontal)
|
|
1573
|
+
was_wide = main.has_class("wide")
|
|
1574
|
+
wide = width >= HOME_SPLIT_MIN_COLUMNS
|
|
1575
|
+
main.set_class(wide, "wide")
|
|
1576
|
+
main.set_class(not wide, "compact")
|
|
1577
|
+
if self._home_detail_panel is not None:
|
|
1578
|
+
self._home_detail_panel.set_class(not wide, COMPACT_INSPECTOR_CLASS)
|
|
1579
|
+
readouts = self.query_one("#focus-readouts", VerticalScroll)
|
|
1580
|
+
task_list_configured = bool(
|
|
1581
|
+
self._widget_names_by_placement.get(WidgetPlacement.TASK_LIST)
|
|
1582
|
+
)
|
|
1583
|
+
minimum_main_height = (
|
|
1584
|
+
FOCUS_MIN_TASK_LIST_ROWS
|
|
1585
|
+
if wide and task_list_configured
|
|
1586
|
+
else FOCUS_COMPACT_SHORT_MAIN_ROWS
|
|
1587
|
+
if not wide and FOCUS_FOOTER_MIN_HEIGHT <= height <= FOCUS_COMPACT_SHORT_HOME_HEIGHT
|
|
1588
|
+
else FOCUS_MAIN_MIN_ROWS
|
|
1589
|
+
if FOCUS_FOOTER_MIN_HEIGHT <= height <= FOCUS_SHORT_HOME_HEIGHT
|
|
1590
|
+
else FOCUS_COMPACT_MAIN_ROWS
|
|
1591
|
+
if not wide and task_list_configured
|
|
1592
|
+
else FOCUS_MAIN_MIN_ROWS
|
|
1593
|
+
)
|
|
1594
|
+
main.styles.min_height = minimum_main_height
|
|
1595
|
+
footer = self.query_one("#focus-footer", _HomeFooter)
|
|
1596
|
+
footer.display = height >= FOCUS_FOOTER_MIN_HEIGHT
|
|
1597
|
+
footer_columns = (
|
|
1598
|
+
FOCUS_FOOTER_STACKED_COLUMN_COUNT
|
|
1599
|
+
if width < FOCUS_FOOTER_GROUP_MIN_COLUMNS
|
|
1600
|
+
else FOCUS_FOOTER_ACTION_COUNT + 1
|
|
1601
|
+
if wide
|
|
1602
|
+
else FOCUS_FOOTER_GROUP_COUNT
|
|
1603
|
+
)
|
|
1604
|
+
footer.styles.grid_size_columns = footer_columns
|
|
1605
|
+
footer.set_class(footer_columns != FOCUS_FOOTER_STACKED_COLUMN_COUNT, "grouped")
|
|
1606
|
+
footer.styles.grid_columns = (
|
|
1607
|
+
"auto auto auto auto auto 1fr"
|
|
1608
|
+
if wide
|
|
1609
|
+
else f"{FOCUS_FOOTER_COMPACT_CONTROL_COLUMN_WIDTH} 1fr"
|
|
1610
|
+
if width < FOCUS_FOOTER_STATUS_WIDE_LABEL_MIN_COLUMNS
|
|
1611
|
+
and footer_columns == FOCUS_FOOTER_GROUP_COUNT
|
|
1612
|
+
else "auto 1fr"
|
|
1613
|
+
if footer_columns == FOCUS_FOOTER_GROUP_COUNT
|
|
1614
|
+
else "1fr"
|
|
1615
|
+
)
|
|
1616
|
+
status_visible = self.query_one(
|
|
1617
|
+
f"#{_HOME_FOOTER_COMMAND_STATUS}", _CommandStatusLine
|
|
1618
|
+
).display
|
|
1619
|
+
footer_items = FOCUS_FOOTER_ACTION_COUNT + int(status_visible)
|
|
1620
|
+
footer_rows = (footer_items + footer_columns - 1) // footer_columns
|
|
1621
|
+
if (
|
|
1622
|
+
width < FOCUS_FOOTER_STATUS_WIDE_LABEL_MIN_COLUMNS
|
|
1623
|
+
and footer_columns == FOCUS_FOOTER_GROUP_COUNT
|
|
1624
|
+
):
|
|
1625
|
+
footer_rows += FOCUS_FOOTER_COMPACT_WRAP_ROWS
|
|
1626
|
+
readout_main_height = (
|
|
1627
|
+
minimum_main_height - FOCUS_TASK_LIST_RECENT_RESERVE_ROWS
|
|
1628
|
+
if wide and task_list_configured
|
|
1629
|
+
else minimum_main_height
|
|
1630
|
+
)
|
|
1631
|
+
readouts.styles.max_height = (
|
|
1632
|
+
None
|
|
1633
|
+
if not wide and height < FOCUS_FOOTER_MIN_HEIGHT
|
|
1634
|
+
else max(
|
|
1635
|
+
1,
|
|
1636
|
+
min(
|
|
1637
|
+
FOCUS_SUMMARY_MAX_ROWS,
|
|
1638
|
+
height
|
|
1639
|
+
- readout_main_height
|
|
1640
|
+
- footer_rows
|
|
1641
|
+
- FOCUS_ATTENTION_HEIGHT
|
|
1642
|
+
- FOCUS_FRAME_BORDER_ROWS,
|
|
1643
|
+
),
|
|
1644
|
+
)
|
|
1645
|
+
)
|
|
1646
|
+
task_list = self.query("#task-browser-list")
|
|
1647
|
+
if task_list:
|
|
1648
|
+
task_list.first().styles.height = None if wide else FOCUS_COMPACT_TASK_LIST_ROWS
|
|
1649
|
+
if self._home_detail_panel is not None:
|
|
1650
|
+
self._home_detail_panel.display = True
|
|
1651
|
+
self._home_detail_panel.styles.height = "100%" if wide else "auto"
|
|
1652
|
+
if wide != was_wide:
|
|
1653
|
+
self._home_detail_panel.refresh_after_resume()
|
|
1654
|
+
return
|
|
1655
|
+
return
|
|
1656
|
+
|
|
1657
|
+
async def action_return_from_section(self) -> None:
|
|
1658
|
+
"""Return from task-scoped details or the fixed task view, one level per press."""
|
|
1659
|
+
panel = self._context_task_detail_panel()
|
|
1660
|
+
if panel is not None and panel.step_out():
|
|
1661
|
+
return
|
|
1662
|
+
if isinstance(self.screen, TaskDetailScreen):
|
|
1663
|
+
self.screen.dismiss()
|
|
1664
|
+
return
|
|
1665
|
+
if self._focus_task is None:
|
|
1666
|
+
self._clear_home_selection()
|
|
1667
|
+
return
|
|
1668
|
+
if panel is not None:
|
|
1669
|
+
await panel.return_from_detail()
|
|
1670
|
+
|
|
1671
|
+
async def action_show_help(self) -> None:
|
|
1672
|
+
"""Show the bindings and current home or task workspace context."""
|
|
1673
|
+
panel = self._context_task_detail_panel()
|
|
1674
|
+
if panel is not None:
|
|
1675
|
+
await panel.open_help()
|
|
1676
|
+
return
|
|
1677
|
+
await self._push_help(self._focus_task)
|
|
1678
|
+
|
|
1679
|
+
async def open_task_detail_help(self, task_id: str) -> None:
|
|
1680
|
+
"""Open help with the selected task and current detail context."""
|
|
1681
|
+
await self._push_help(task_id)
|
|
1682
|
+
|
|
1683
|
+
async def _push_help(self, task_id: str | None) -> None:
|
|
1684
|
+
availability = HelpAvailability(
|
|
1685
|
+
queue=_WIDGET_QUEUE in self._widget_names,
|
|
1686
|
+
escalations=_WIDGET_ESCALATIONS in self._widget_names,
|
|
1687
|
+
detach_prefix=self._tmux_detach_hint,
|
|
1688
|
+
)
|
|
1689
|
+
context = (
|
|
1690
|
+
HelpContext(HelpTab.TASK, availability)
|
|
1691
|
+
if task_id is not None
|
|
1692
|
+
else HelpContext(HelpTab.HOME, availability)
|
|
1693
|
+
)
|
|
1694
|
+
await self.push_screen(
|
|
1695
|
+
WorkspaceHelpScreen(context),
|
|
1696
|
+
callback=lambda _: self.refresh_task_detail_after_resume(),
|
|
1697
|
+
)
|
|
1698
|
+
|
|
1699
|
+
async def action_open_palette(self) -> None:
|
|
1700
|
+
"""Open the operator palette through the shared dashboard callback."""
|
|
1701
|
+
panel = self._context_task_detail_panel()
|
|
1702
|
+
if panel is not None:
|
|
1703
|
+
await panel.open_palette()
|
|
1704
|
+
return
|
|
1705
|
+
if self._focus_task is not None:
|
|
1706
|
+
await self.query_one("#task-detail-panel", TaskDetailPanel).open_palette()
|
|
1707
|
+
return
|
|
1708
|
+
if self._home_detail_panel is not None and self._selected_task_id is not None:
|
|
1709
|
+
self._home_detail_panel.select_task(self._selected_task_id)
|
|
1710
|
+
await self._home_detail_panel.open_palette()
|
|
1711
|
+
return
|
|
1712
|
+
if self._open_palette_context is not None:
|
|
1713
|
+
await self._open_contextual_palette(PaletteRequest(task_id=self._selected_task_id))
|
|
1714
|
+
return
|
|
1715
|
+
await self._open_palette("")
|
|
1716
|
+
|
|
1717
|
+
async def action_open_task_usage(self) -> None:
|
|
1718
|
+
"""Open recorded usage details for the selected task or task view."""
|
|
1719
|
+
panel = self._context_task_detail_panel()
|
|
1720
|
+
task_id = panel.task_id if panel is not None else self._selected_task_id
|
|
1721
|
+
if task_id is None:
|
|
1722
|
+
return
|
|
1723
|
+
await self.open_task_detail_view(task_id, TASK_VIEW_USAGE)
|
|
1724
|
+
|
|
1725
|
+
async def action_open_task_evidence(self) -> None:
|
|
1726
|
+
"""Open retained evidence for the selected task or task view."""
|
|
1727
|
+
panel = self._context_task_detail_panel()
|
|
1728
|
+
task_id = panel.task_id if panel is not None else self._selected_task_id
|
|
1729
|
+
if task_id is None:
|
|
1730
|
+
return
|
|
1731
|
+
await self.open_task_detail_view(task_id, TASK_VIEW_EVIDENCE)
|
|
1732
|
+
|
|
1733
|
+
async def open_task_detail_view(self, task_id: str, view_id: str) -> None:
|
|
1734
|
+
"""Open a task's fold where its panel is shown, otherwise in the task popup."""
|
|
1735
|
+
panel = self._inline_task_panel(task_id)
|
|
1736
|
+
if panel is None:
|
|
1737
|
+
await self._open_task_detail(task_id, initial_view=view_id)
|
|
1738
|
+
return
|
|
1739
|
+
panel.open_view(view_id)
|
|
1740
|
+
|
|
1741
|
+
def _inline_task_panel(self, task_id: str) -> TaskDetailPanel | None:
|
|
1742
|
+
panel = self._context_task_detail_panel()
|
|
1743
|
+
if panel is not None and panel.task_id == task_id:
|
|
1744
|
+
return panel
|
|
1745
|
+
if isinstance(self.screen, TaskDetailScreen):
|
|
1746
|
+
return None
|
|
1747
|
+
home = self._home_detail_panel
|
|
1748
|
+
if home is not None:
|
|
1749
|
+
home.select_task(task_id)
|
|
1750
|
+
return home
|
|
1751
|
+
|
|
1752
|
+
def _context_task_detail_panel(self) -> TaskDetailPanel | None:
|
|
1753
|
+
if isinstance(self.screen, TaskDetailScreen):
|
|
1754
|
+
return self.screen.query_one("#task-detail-panel", TaskDetailPanel)
|
|
1755
|
+
if self._focus_task is not None:
|
|
1756
|
+
return self.query_one("#task-detail-panel", TaskDetailPanel)
|
|
1757
|
+
panel = self._home_detail_panel
|
|
1758
|
+
focused = self.focused
|
|
1759
|
+
if (
|
|
1760
|
+
panel is not None
|
|
1761
|
+
and focused is not None
|
|
1762
|
+
and (focused is panel or panel in focused.ancestors)
|
|
1763
|
+
):
|
|
1764
|
+
return panel
|
|
1765
|
+
return None
|