shikiru-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- shikiru/__init__.py +1 -0
- shikiru/__main__.py +8 -0
- shikiru/agents/__init__.py +3 -0
- shikiru/agents/contracts.py +590 -0
- shikiru/agents/registry.py +87 -0
- shikiru/agents/sdk/__init__.py +17 -0
- shikiru/agents/sdk/reset_time.py +137 -0
- shikiru/agents/sdk/turn.py +335 -0
- shikiru/agents/settings.py +151 -0
- shikiru/agents/testing/__init__.py +26 -0
- shikiru/agents/testing/contract_suite.py +653 -0
- shikiru/agents/testing/resume.py +15 -0
- shikiru/bootstrap.py +1277 -0
- shikiru/cli/__init__.py +1 -0
- shikiru/cli/app.py +770 -0
- shikiru/cli/context.py +63 -0
- shikiru/cli/render.py +1475 -0
- shikiru/cli/report.py +156 -0
- shikiru/cli/session_prompt.py +63 -0
- shikiru/cli/view.py +131 -0
- shikiru/clock.py +41 -0
- shikiru/config/__init__.py +8 -0
- shikiru/config/defaults.toml +131 -0
- shikiru/config/loader.py +178 -0
- shikiru/config/settings.py +392 -0
- shikiru/dashboard/__init__.py +3 -0
- shikiru/dashboard/actions_list.py +799 -0
- shikiru/dashboard/activity.py +150 -0
- shikiru/dashboard/app.py +1765 -0
- shikiru/dashboard/blocker_evidence.py +122 -0
- shikiru/dashboard/blocker_rows.py +84 -0
- shikiru/dashboard/briefing_view.py +871 -0
- shikiru/dashboard/contract.py +1086 -0
- shikiru/dashboard/covered.py +32 -0
- shikiru/dashboard/detail.py +1209 -0
- shikiru/dashboard/detail_fold.py +244 -0
- shikiru/dashboard/detail_hint.py +68 -0
- shikiru/dashboard/details_list_view.py +150 -0
- shikiru/dashboard/diagnostics.py +80 -0
- shikiru/dashboard/diagnostics_sections.py +186 -0
- shikiru/dashboard/evidence_table.py +123 -0
- shikiru/dashboard/extra_details.py +214 -0
- shikiru/dashboard/fault_guidance.py +216 -0
- shikiru/dashboard/fold_bodies.py +276 -0
- shikiru/dashboard/help_keys.py +166 -0
- shikiru/dashboard/help_screen.py +204 -0
- shikiru/dashboard/interaction.py +17 -0
- shikiru/dashboard/ladder_editor.py +544 -0
- shikiru/dashboard/live_settings.py +62 -0
- shikiru/dashboard/motion.py +368 -0
- shikiru/dashboard/orchestration_fields.py +201 -0
- shikiru/dashboard/palette.py +1606 -0
- shikiru/dashboard/palette_app.py +89 -0
- shikiru/dashboard/palette_drafts.py +33 -0
- shikiru/dashboard/resolution.py +573 -0
- shikiru/dashboard/resolution_view.py +302 -0
- shikiru/dashboard/row_list.py +256 -0
- shikiru/dashboard/section_rows.py +173 -0
- shikiru/dashboard/services.py +665 -0
- shikiru/dashboard/session_history.py +402 -0
- shikiru/dashboard/shikiru_init_ascii_art.txt +40 -0
- shikiru/dashboard/task_picker.py +358 -0
- shikiru/dashboard/tick.py +59 -0
- shikiru/dashboard/timeline_filter.py +129 -0
- shikiru/dashboard/timeline_section.py +385 -0
- shikiru/dashboard/usage_body.py +269 -0
- shikiru/dashboard/views.py +2314 -0
- shikiru/dashboard/wizard.py +1389 -0
- shikiru/domain/__init__.py +1 -0
- shikiru/domain/acceptance.py +130 -0
- shikiru/domain/activity.py +28 -0
- shikiru/domain/attempts.py +333 -0
- shikiru/domain/briefing.py +257 -0
- shikiru/domain/commands.py +219 -0
- shikiru/domain/decisions.py +231 -0
- shikiru/domain/docker.py +262 -0
- shikiru/domain/engine_uptime.py +87 -0
- shikiru/domain/escalation.py +194 -0
- shikiru/domain/escalation_evidence.py +186 -0
- shikiru/domain/escalation_panel.py +144 -0
- shikiru/domain/events.py +71 -0
- shikiru/domain/evidence_export.py +89 -0
- shikiru/domain/faults.py +86 -0
- shikiru/domain/feedback.py +180 -0
- shikiru/domain/feedback_rounds.py +172 -0
- shikiru/domain/fencing.py +31 -0
- shikiru/domain/ladder.py +102 -0
- shikiru/domain/ladder_text.py +44 -0
- shikiru/domain/levels.py +44 -0
- shikiru/domain/motion.py +199 -0
- shikiru/domain/packet_evidence.py +377 -0
- shikiru/domain/pushes.py +47 -0
- shikiru/domain/quota_blocks.py +44 -0
- shikiru/domain/quota_waits.py +219 -0
- shikiru/domain/recovery.py +194 -0
- shikiru/domain/reports.py +498 -0
- shikiru/domain/results.py +217 -0
- shikiru/domain/runs.py +85 -0
- shikiru/domain/scheduling.py +231 -0
- shikiru/domain/status_tasks.py +40 -0
- shikiru/domain/steps.py +325 -0
- shikiru/domain/tasks.py +174 -0
- shikiru/domain/timeline.py +208 -0
- shikiru/domain/timeline_phrases.py +271 -0
- shikiru/domain/versions.py +18 -0
- shikiru/domain/workflow.py +372 -0
- shikiru/engine/__init__.py +0 -0
- shikiru/engine/acceptance.py +90 -0
- shikiru/engine/agents.py +61 -0
- shikiru/engine/cosmetic_decorator.py +99 -0
- shikiru/engine/daemon.py +409 -0
- shikiru/engine/dependency.py +37 -0
- shikiru/engine/escalation_filing.py +110 -0
- shikiru/engine/escalations.py +401 -0
- shikiru/engine/events.py +285 -0
- shikiru/engine/evidence.py +81 -0
- shikiru/engine/fault_reporter.py +144 -0
- shikiru/engine/feedback.py +577 -0
- shikiru/engine/feedback_audit.py +80 -0
- shikiru/engine/feedback_intake.py +95 -0
- shikiru/engine/feedback_snapshot.py +148 -0
- shikiru/engine/flow.py +87 -0
- shikiru/engine/handlers/__init__.py +33 -0
- shikiru/engine/handlers/base.py +148 -0
- shikiru/engine/handlers/engine.py +24 -0
- shikiru/engine/handlers/escalations.py +174 -0
- shikiru/engine/handlers/operator.py +81 -0
- shikiru/engine/handlers/tasks.py +237 -0
- shikiru/engine/history.py +128 -0
- shikiru/engine/integration.py +1234 -0
- shikiru/engine/integration_turns.py +337 -0
- shikiru/engine/layout_guard.py +362 -0
- shikiru/engine/lifecycle.py +60 -0
- shikiru/engine/monitor.py +317 -0
- shikiru/engine/notification_delivery.py +124 -0
- shikiru/engine/notification_feed.py +57 -0
- shikiru/engine/notifier.py +91 -0
- shikiru/engine/pull_request_closing.py +117 -0
- shikiru/engine/push_service.py +177 -0
- shikiru/engine/quota_probe_threads.py +79 -0
- shikiru/engine/quota_reconciler.py +210 -0
- shikiru/engine/quota_schedule.py +24 -0
- shikiru/engine/quota_waits.py +102 -0
- shikiru/engine/reconcile.py +91 -0
- shikiru/engine/recovery.py +284 -0
- shikiru/engine/retention.py +102 -0
- shikiru/engine/root_preparation.py +86 -0
- shikiru/engine/scheduler.py +320 -0
- shikiru/engine/session_guard.py +46 -0
- shikiru/engine/sessions.py +95 -0
- shikiru/engine/signalled.py +75 -0
- shikiru/engine/step_runner.py +140 -0
- shikiru/engine/supervisor.py +101 -0
- shikiru/engine/takeover.py +223 -0
- shikiru/engine/task_facts.py +46 -0
- shikiru/engine/tick_guard.py +95 -0
- shikiru/engine/titles.py +297 -0
- shikiru/engine/transitions.py +79 -0
- shikiru/engine/turn_context.py +206 -0
- shikiru/engine/turn_execution.py +30 -0
- shikiru/engine/turn_loop.py +230 -0
- shikiru/engine/turn_processes.py +79 -0
- shikiru/engine/turns.py +457 -0
- shikiru/errors.py +144 -0
- shikiru/helper/__init__.py +3 -0
- shikiru/helper/helper.py +182 -0
- shikiru/helper/models.py +11 -0
- shikiru/mcp/__init__.py +1 -0
- shikiru/mcp/server.py +196 -0
- shikiru/operator/__init__.py +33 -0
- shikiru/operator/attachment.py +24 -0
- shikiru/operator/commands.py +319 -0
- shikiru/operator/detectors.py +374 -0
- shikiru/operator/doctor.py +351 -0
- shikiru/operator/events.py +57 -0
- shikiru/operator/evidence_export.py +498 -0
- shikiru/operator/evidence_writer.py +78 -0
- shikiru/operator/lifecycle.py +294 -0
- shikiru/operator/plugin_checks.py +194 -0
- shikiru/operator/probe.py +287 -0
- shikiru/operator/queries.py +1601 -0
- shikiru/operator/services.py +36 -0
- shikiru/operator/sessions.py +378 -0
- shikiru/operator/strip_views.py +162 -0
- shikiru/operator/timeline.py +416 -0
- shikiru/operator/toml_text.py +62 -0
- shikiru/palette.py +18 -0
- shikiru/paths.py +91 -0
- shikiru/plugins/__init__.py +0 -0
- shikiru/plugins/agents/__init__.py +1 -0
- shikiru/plugins/agents/claude/__init__.py +5 -0
- shikiru/plugins/agents/claude/agent.py +251 -0
- shikiru/plugins/agents/claude/context.py +116 -0
- shikiru/plugins/agents/claude/headless.py +92 -0
- shikiru/plugins/agents/claude/hook.py +57 -0
- shikiru/plugins/agents/claude/model_catalog.py +105 -0
- shikiru/plugins/agents/claude/options.py +62 -0
- shikiru/plugins/agents/claude/turn.py +1018 -0
- shikiru/plugins/agents/codex/__init__.py +5 -0
- shikiru/plugins/agents/codex/agent.py +486 -0
- shikiru/plugins/agents/codex/headless.py +166 -0
- shikiru/plugins/agents/codex/home.py +14 -0
- shikiru/plugins/agents/codex/model_catalog.py +128 -0
- shikiru/plugins/agents/codex/options.py +94 -0
- shikiru/plugins/agents/codex/quota.py +195 -0
- shikiru/plugins/agents/codex/sandbox.py +31 -0
- shikiru/plugins/agents/codex/sandbox_probe.py +97 -0
- shikiru/plugins/agents/codex/turn.py +767 -0
- shikiru/plugins/agents/codex/usage.py +106 -0
- shikiru/plugins/agents/shared_settings.py +62 -0
- shikiru/plugins/widgets/__init__.py +3 -0
- shikiru/plugins/widgets/dialogue.py +144 -0
- shikiru/plugins/widgets/disk.py +123 -0
- shikiru/plugins/widgets/docker.py +225 -0
- shikiru/plugins/widgets/escalations.py +402 -0
- shikiru/plugins/widgets/faults.py +204 -0
- shikiru/plugins/widgets/header.py +321 -0
- shikiru/plugins/widgets/queue.py +1140 -0
- shikiru/plugins/widgets/queue_hint.py +93 -0
- shikiru/plugins/widgets/resources.py +105 -0
- shikiru/plugins/widgets/strips.py +82 -0
- shikiru/plugins/widgets/usage.py +176 -0
- shikiru/private.py +94 -0
- shikiru/process.py +535 -0
- shikiru/prompts/__init__.py +3 -0
- shikiru/prompts/renderer.py +85 -0
- shikiru/prompts/templates/_base.md.j2 +17 -0
- shikiru/prompts/templates/_macros.md.j2 +61 -0
- shikiru/prompts/templates/conflicts.md.j2 +22 -0
- shikiru/prompts/templates/consult.md.j2 +5 -0
- shikiru/prompts/templates/continue.md.j2 +4 -0
- shikiru/prompts/templates/feedback_analysis.md.j2 +29 -0
- shikiru/prompts/templates/feedback_rework.md.j2 +33 -0
- shikiru/prompts/templates/feedback_verify.md.j2 +33 -0
- shikiru/prompts/templates/handoff.md.j2 +23 -0
- shikiru/prompts/templates/implement.md.j2 +13 -0
- shikiru/prompts/templates/plan.md.j2 +30 -0
- shikiru/prompts/templates/rework.md.j2 +13 -0
- shikiru/prompts/templates/triage.md.j2 +20 -0
- shikiru/prompts/templates/verify.md.j2 +29 -0
- shikiru/store/__init__.py +1 -0
- shikiru/store/commands.py +242 -0
- shikiru/store/database.py +297 -0
- shikiru/store/escalations.py +240 -0
- shikiru/store/events.py +213 -0
- shikiru/store/feedback.py +266 -0
- shikiru/store/migrations/001_initial.sql +63 -0
- shikiru/store/migrations/002_headless_turns.sql +31 -0
- shikiru/store/migrations/003_drop_command_handled_at.sql +1 -0
- shikiru/store/migrations/004_repo_base_ref.sql +3 -0
- shikiru/store/migrations/005_complexity_explicit.sql +3 -0
- shikiru/store/migrations/006_events_kind_index.sql +1 -0
- shikiru/store/migrations/007_notification_cursor.sql +1 -0
- shikiru/store/migrations/008_reported_usage.sql +1 -0
- shikiru/store/migrations/009_billing_declarations.sql +2 -0
- shikiru/store/migrations/010_canonical_quota_windows.sql +3 -0
- shikiru/store/migrations/012_drop_task_quota_evidence.sql +2 -0
- shikiru/store/migrations/013_push_targets.sql +7 -0
- shikiru/store/migrations/014_agent_block_time.sql +6 -0
- shikiru/store/migrations/015_repair_orphan_agent_blocks.sql +3 -0
- shikiru/store/migrations/016_feedback_rounds.sql +29 -0
- shikiru/store/migrations/017_feedback_round_comments.sql +5 -0
- shikiru/store/migrations/__init__.py +1 -0
- shikiru/store/notification_cursor.py +29 -0
- shikiru/store/pushes.py +117 -0
- shikiru/store/quotas.py +153 -0
- shikiru/store/reader.py +310 -0
- shikiru/store/retention.py +92 -0
- shikiru/store/runs.py +227 -0
- shikiru/store/tasks.py +949 -0
- shikiru/telemetry/__init__.py +1 -0
- shikiru/telemetry/disk.py +167 -0
- shikiru/telemetry/docker.py +164 -0
- shikiru/telemetry/resources.py +296 -0
- shikiru/tmux/__init__.py +3 -0
- shikiru/tmux/client.py +637 -0
- shikiru/tmux/decorator.py +225 -0
- shikiru/tmux/layout.py +200 -0
- shikiru/tmux/session.py +381 -0
- shikiru/ui/__init__.py +9 -0
- shikiru/ui/age.py +45 -0
- shikiru/ui/bubbles.py +41 -0
- shikiru/ui/compact.py +1209 -0
- shikiru/ui/console.py +49 -0
- shikiru/ui/fold.py +50 -0
- shikiru/ui/gated_static.py +117 -0
- shikiru/ui/gauge.py +40 -0
- shikiru/ui/glyphs.py +109 -0
- shikiru/ui/keys.py +57 -0
- shikiru/ui/live_interval.py +42 -0
- shikiru/ui/popup.py +311 -0
- shikiru/ui/quota_status.py +26 -0
- shikiru/ui/recent_window.py +78 -0
- shikiru/ui/screen.py +57 -0
- shikiru/ui/section.py +193 -0
- shikiru/ui/session.py +65 -0
- shikiru/ui/size.py +17 -0
- shikiru/ui/splash.py +42 -0
- shikiru/ui/status.py +47 -0
- shikiru/ui/status_strip.py +149 -0
- shikiru/ui/stream.py +152 -0
- shikiru/ui/strip.py +332 -0
- shikiru/ui/theme.py +83 -0
- shikiru/ui/tokens.py +51 -0
- shikiru/view_log.py +126 -0
- shikiru/workspace/__init__.py +3 -0
- shikiru/workspace/base_refs.py +160 -0
- shikiru/workspace/caches.py +136 -0
- shikiru/workspace/facts.py +118 -0
- shikiru/workspace/git.py +601 -0
- shikiru/workspace/github.py +744 -0
- shikiru/workspace/messages.py +107 -0
- shikiru/workspace/reports.py +145 -0
- shikiru/workspace/roots.py +180 -0
- shikiru_cli-0.2.0.dist-info/METADATA +428 -0
- shikiru_cli-0.2.0.dist-info/RECORD +319 -0
- shikiru_cli-0.2.0.dist-info/WHEEL +4 -0
- shikiru_cli-0.2.0.dist-info/entry_points.txt +18 -0
- shikiru_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1140 @@
|
|
|
1
|
+
"""The `queue` widget: searchable task browser."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
from typing import TYPE_CHECKING, cast
|
|
8
|
+
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
from textual.containers import Horizontal, Vertical
|
|
11
|
+
from textual.widgets import Input, ListItem, ListView, Static
|
|
12
|
+
|
|
13
|
+
from shikiru.dashboard.contract import (
|
|
14
|
+
TASK_STATUS_QUEUED,
|
|
15
|
+
TASK_TERMINAL_STATUSES,
|
|
16
|
+
TaskBrowserFilter,
|
|
17
|
+
TaskBrowserRow,
|
|
18
|
+
TaskBrowserView,
|
|
19
|
+
WidgetPlacement,
|
|
20
|
+
)
|
|
21
|
+
from shikiru.plugins.widgets.queue_hint import load_more_hint, task_list_header
|
|
22
|
+
from shikiru.ui.gated_static import GatedStatic
|
|
23
|
+
from shikiru.ui.glyphs import QUOTA_MARKER
|
|
24
|
+
from shikiru.ui.keys import key_hint
|
|
25
|
+
from shikiru.ui.recent_window import RecentWindow, step_selection
|
|
26
|
+
from shikiru.ui.screen import widget_is_current
|
|
27
|
+
from shikiru.ui.tokens import (
|
|
28
|
+
ALERT_COLOR,
|
|
29
|
+
AMBER,
|
|
30
|
+
PRIMARY_TEXT_COLOR,
|
|
31
|
+
SAGE,
|
|
32
|
+
SECONDARY_TEXT_COLOR,
|
|
33
|
+
STEEL_BLUE,
|
|
34
|
+
STOPPED_DURING,
|
|
35
|
+
STYLE_BOLD,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
40
|
+
|
|
41
|
+
from textual import events
|
|
42
|
+
from textual.app import App, ComposeResult
|
|
43
|
+
from textual.widget import Widget
|
|
44
|
+
|
|
45
|
+
from shikiru.dashboard.contract import WidgetContext
|
|
46
|
+
|
|
47
|
+
BROWSER_PAGE_SIZE = 24
|
|
48
|
+
FOCUS_MAIN_CONTAINER_ID = "focus-main"
|
|
49
|
+
TASK_ROW_LINES = 2
|
|
50
|
+
TASK_ROW_GAP_LINES = 1
|
|
51
|
+
RECENT_LATER_LABEL = "↑ later"
|
|
52
|
+
RECENT_EARLIER_LABEL = "↓ earlier"
|
|
53
|
+
RECENT_LATER_COMPACT_LABEL = "↑"
|
|
54
|
+
RECENT_EARLIER_COMPACT_LABEL = "↓"
|
|
55
|
+
RECENT_CONTROL_GAP = 1
|
|
56
|
+
TASK_STATE_DECISION_NEEDED = "decision needed"
|
|
57
|
+
WAIT_REASON_DEPENDENCY = "dependency"
|
|
58
|
+
FOCUS_LIST_CLASS = "focus-list"
|
|
59
|
+
TASK_STATUS_DONE = "done"
|
|
60
|
+
TASK_STATUS_CANCELLED = "cancelled"
|
|
61
|
+
TASK_HOLD_PAUSED = "paused"
|
|
62
|
+
TASK_HOLD_INTERVENTION = "intervention"
|
|
63
|
+
TASK_MARKER_DECISION = "!"
|
|
64
|
+
TASK_MARKER_OPERATOR = "@"
|
|
65
|
+
TASK_MARKER_PAUSED = "="
|
|
66
|
+
TASK_MARKER_DONE = "✓"
|
|
67
|
+
TASK_MARKER_CANCELLED = "○"
|
|
68
|
+
TASK_MARKER_QUEUED = "·"
|
|
69
|
+
TASK_MARKER_CURRENT = "\N{SINGLE RIGHT-POINTING ANGLE QUOTATION MARK}"
|
|
70
|
+
TASK_STATUS_RUNNING = "running"
|
|
71
|
+
TASK_STATUS_INTEGRATING = "integrating"
|
|
72
|
+
TASK_STATUS_ESCALATED = "escalated"
|
|
73
|
+
TASK_MARKER_STALL = "!"
|
|
74
|
+
TASK_MARKER_RECENT = "Recent"
|
|
75
|
+
RECENT_HEADING_FULL_COLUMNS = (
|
|
76
|
+
len(TASK_MARKER_RECENT)
|
|
77
|
+
+ RECENT_CONTROL_GAP * 2
|
|
78
|
+
+ len(RECENT_LATER_LABEL)
|
|
79
|
+
+ len(RECENT_EARLIER_LABEL)
|
|
80
|
+
)
|
|
81
|
+
TASK_ACTIVE_BLOCK_ID = "task-browser-active"
|
|
82
|
+
TASK_RECENT_BLOCK_ID = "task-browser-recent"
|
|
83
|
+
RECENT_LATER_ID = "task-browser-recent-later"
|
|
84
|
+
RECENT_EARLIER_ID = "task-browser-recent-earlier"
|
|
85
|
+
RECENT_INACTIVE_CLASS = "-inactive"
|
|
86
|
+
TASK_RECENT_HEADING_ID = "task-browser-recent-heading"
|
|
87
|
+
TASK_RECENT_TITLE_ID = "task-browser-recent-title"
|
|
88
|
+
RECENT_PAGER_CLASS = "recent-pager"
|
|
89
|
+
TASK_RECENT_LIST_ID = "task-browser-recent-list"
|
|
90
|
+
QUEUE_REFRESH_WORKER = "task-browser-refresh"
|
|
91
|
+
STEP_LABELS = {
|
|
92
|
+
"triage": "Triaging",
|
|
93
|
+
"plan": "Planning",
|
|
94
|
+
"implement": "Implementing",
|
|
95
|
+
"consult": "Consulting",
|
|
96
|
+
"verify": "Verifying",
|
|
97
|
+
"integrate": "Integrating",
|
|
98
|
+
}
|
|
99
|
+
WAIT_REASON_LABELS = {
|
|
100
|
+
"paused": "paused",
|
|
101
|
+
WAIT_REASON_DEPENDENCY: "dependency",
|
|
102
|
+
"slot": "slot",
|
|
103
|
+
"resources": "resources",
|
|
104
|
+
"quota": "quota",
|
|
105
|
+
"PR open": "PR merge",
|
|
106
|
+
}
|
|
107
|
+
SEARCH_INPUT_ID = "task-browser-search"
|
|
108
|
+
TASK_LIST_ID = "task-browser-list"
|
|
109
|
+
COUNT_ID = "task-browser-count"
|
|
110
|
+
EMPTY_ID = "task-browser-empty"
|
|
111
|
+
MORE_HINT_ID = "task-browser-more"
|
|
112
|
+
ESCAPE_KEY = "escape"
|
|
113
|
+
MOVE_STEPS = {"up": -1, "down": 1}
|
|
114
|
+
STATUS_FILTER_CYCLE = (
|
|
115
|
+
TaskBrowserFilter.ALL,
|
|
116
|
+
TaskBrowserFilter.RUNNING,
|
|
117
|
+
TaskBrowserFilter.INTEGRATING,
|
|
118
|
+
TaskBrowserFilter.ESCALATED,
|
|
119
|
+
TaskBrowserFilter.QUEUED,
|
|
120
|
+
TaskBrowserFilter.DONE,
|
|
121
|
+
TaskBrowserFilter.CANCELLED,
|
|
122
|
+
)
|
|
123
|
+
FILTER_LABELS = {
|
|
124
|
+
TaskBrowserFilter.ACTIVE: "Active",
|
|
125
|
+
TaskBrowserFilter.WAITING: "Waiting",
|
|
126
|
+
TaskBrowserFilter.HISTORY: "History",
|
|
127
|
+
TaskBrowserFilter.ALL: "All",
|
|
128
|
+
**{task_filter: task_filter.value.capitalize() for task_filter in STATUS_FILTER_CYCLE[1:]},
|
|
129
|
+
}
|
|
130
|
+
STATUS_FILTER_WORKER = "task-browser-status-filter"
|
|
131
|
+
MORE_WORKER = "task-browser-more"
|
|
132
|
+
SEARCH_FIELD_CLASS = "focus-search"
|
|
133
|
+
SEARCH_OPEN_CLASS = "search-open"
|
|
134
|
+
HEADER_TINY_MAX_ROWS = 8
|
|
135
|
+
ROW_STATUS_MARKERS = {
|
|
136
|
+
TASK_STATUS_DONE: (TASK_MARKER_DONE, SAGE),
|
|
137
|
+
TASK_STATUS_CANCELLED: (TASK_MARKER_CANCELLED, SECONDARY_TEXT_COLOR),
|
|
138
|
+
TASK_STATUS_QUEUED: (TASK_MARKER_QUEUED, SECONDARY_TEXT_COLOR),
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class _BrowserListItem(ListItem):
|
|
143
|
+
def __init__(self, row: TaskBrowserRow) -> None:
|
|
144
|
+
self._status_bright = False
|
|
145
|
+
self._body = GatedStatic(_focus_row_text(row), markup=False)
|
|
146
|
+
super().__init__(self._body, id=f"task-row-{row.task_id}")
|
|
147
|
+
self.task_id = row.task_id
|
|
148
|
+
self.row = row
|
|
149
|
+
|
|
150
|
+
def update_row(self, row: TaskBrowserRow) -> bool:
|
|
151
|
+
if row == self.row:
|
|
152
|
+
return False
|
|
153
|
+
self.row = row
|
|
154
|
+
self._status_bright = False
|
|
155
|
+
self._render_row()
|
|
156
|
+
return True
|
|
157
|
+
|
|
158
|
+
def update_status_motion(self, status_bright: bool) -> None:
|
|
159
|
+
if not self.row.agent_processing:
|
|
160
|
+
return
|
|
161
|
+
if self._status_bright == status_bright:
|
|
162
|
+
return
|
|
163
|
+
self._status_bright = status_bright
|
|
164
|
+
self._render_row()
|
|
165
|
+
|
|
166
|
+
def _render_row(self) -> None:
|
|
167
|
+
self._body.update_if_changed(_focus_row_text(self.row, status_bright=self._status_bright))
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class _TaskBrowserListView(ListView):
|
|
171
|
+
def __init__(
|
|
172
|
+
self,
|
|
173
|
+
*,
|
|
174
|
+
widget_id: str,
|
|
175
|
+
move_selection: Callable[[int], Awaitable[None]],
|
|
176
|
+
select_task: Callable[[str], None],
|
|
177
|
+
activate_attention_decision: Callable[[], Awaitable[bool]] | None,
|
|
178
|
+
) -> None:
|
|
179
|
+
super().__init__(id=widget_id)
|
|
180
|
+
self._move_selection = move_selection
|
|
181
|
+
self._select_task = select_task
|
|
182
|
+
self._activate_attention_decision = activate_attention_decision
|
|
183
|
+
|
|
184
|
+
def is_tab_stop(self) -> bool:
|
|
185
|
+
"""Keep a task list a Tab stop only while it holds the selection."""
|
|
186
|
+
return self.highlighted_child is not None
|
|
187
|
+
|
|
188
|
+
def action_cursor_up(self) -> None:
|
|
189
|
+
self.run_worker(self._move_selection(MOVE_STEPS["up"]))
|
|
190
|
+
|
|
191
|
+
def action_cursor_down(self) -> None:
|
|
192
|
+
self.run_worker(self._move_selection(MOVE_STEPS["down"]))
|
|
193
|
+
|
|
194
|
+
def action_select_cursor(self) -> None:
|
|
195
|
+
item = self.highlighted_child
|
|
196
|
+
self.run_worker(
|
|
197
|
+
self._activate_decision_or_select(item if isinstance(item, _BrowserListItem) else None)
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
async def _activate_decision_or_select(self, item: _BrowserListItem | None) -> None:
|
|
201
|
+
if not widget_is_current(self):
|
|
202
|
+
return
|
|
203
|
+
if (
|
|
204
|
+
self._activate_attention_decision is not None
|
|
205
|
+
and await self._activate_attention_decision()
|
|
206
|
+
):
|
|
207
|
+
return
|
|
208
|
+
if not widget_is_current(self):
|
|
209
|
+
return
|
|
210
|
+
if item is not None and item.parent is self:
|
|
211
|
+
self._select_task(item.task_id)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _focus_row_text(row: TaskBrowserRow, *, status_bright: bool = False) -> Text:
|
|
215
|
+
marker, marker_style = _focus_row_marker(row)
|
|
216
|
+
stage = STEP_LABELS.get(row.step or "")
|
|
217
|
+
state = _focus_row_state(row, stage)
|
|
218
|
+
details = _focus_row_details(row, stage, state)
|
|
219
|
+
if row.agent_processing and status_bright:
|
|
220
|
+
details[0] = (details[0][0], f"{details[0][1]} bold")
|
|
221
|
+
content = Text.assemble(
|
|
222
|
+
(f"{marker} ", marker_style),
|
|
223
|
+
(row.task_id, f"{PRIMARY_TEXT_COLOR} {STYLE_BOLD}"),
|
|
224
|
+
(f" · {row.title}", PRIMARY_TEXT_COLOR),
|
|
225
|
+
"\n ",
|
|
226
|
+
)
|
|
227
|
+
for index, (detail, style) in enumerate(details):
|
|
228
|
+
if index:
|
|
229
|
+
content.append(" · ", style=SECONDARY_TEXT_COLOR)
|
|
230
|
+
content.append(detail, style=style)
|
|
231
|
+
return content
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _focus_row_marker(row: TaskBrowserRow) -> tuple[str, str]:
|
|
235
|
+
if row.status in TASK_TERMINAL_STATUSES:
|
|
236
|
+
return ROW_STATUS_MARKERS[row.status]
|
|
237
|
+
if row.open_decision:
|
|
238
|
+
return TASK_MARKER_DECISION, AMBER
|
|
239
|
+
if row.diagnosed_stall:
|
|
240
|
+
return TASK_MARKER_STALL, ALERT_COLOR
|
|
241
|
+
if TASK_HOLD_INTERVENTION in row.holds:
|
|
242
|
+
return TASK_MARKER_OPERATOR, STEEL_BLUE
|
|
243
|
+
if TASK_HOLD_PAUSED in row.holds:
|
|
244
|
+
return TASK_MARKER_PAUSED, AMBER
|
|
245
|
+
return status_marker(row.status)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def status_marker(status: str) -> tuple[str, str]:
|
|
249
|
+
return ROW_STATUS_MARKERS.get(status, (TASK_MARKER_CURRENT, STEEL_BLUE))
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _terminal_state(row: TaskBrowserRow) -> str:
|
|
253
|
+
if row.status == TASK_STATUS_DONE:
|
|
254
|
+
return "Done"
|
|
255
|
+
stopped = f" · {STOPPED_DURING} {row.step}" if row.step else ""
|
|
256
|
+
return f"Cancelled{stopped}"
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _focus_row_state(row: TaskBrowserRow, stage: str | None) -> str:
|
|
260
|
+
if row.status in TASK_TERMINAL_STATUSES:
|
|
261
|
+
return _terminal_state(row)
|
|
262
|
+
if row.wait_reason:
|
|
263
|
+
wait_reason = WAIT_REASON_LABELS.get(row.wait_reason, row.wait_reason)
|
|
264
|
+
if row.wait_reason == WAIT_REASON_DEPENDENCY and row.dependencies:
|
|
265
|
+
waiting = f"waiting on dependency {', '.join(row.dependencies)}"
|
|
266
|
+
else:
|
|
267
|
+
waiting = f"waiting on {wait_reason}"
|
|
268
|
+
return f"{stage} · {waiting}" if stage else waiting
|
|
269
|
+
state = stage or row.status.replace("_", " ")
|
|
270
|
+
if row.status == TASK_STATUS_QUEUED:
|
|
271
|
+
state = "queued"
|
|
272
|
+
elif row.status == TASK_STATUS_ESCALATED:
|
|
273
|
+
state = TASK_STATE_DECISION_NEEDED if row.open_decision else "escalated"
|
|
274
|
+
elif row.status == TASK_STATUS_RUNNING:
|
|
275
|
+
state = stage or "running"
|
|
276
|
+
elif row.status == TASK_STATUS_INTEGRATING:
|
|
277
|
+
state = stage or "integrating"
|
|
278
|
+
return state
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _focus_row_details(
|
|
282
|
+
row: TaskBrowserRow,
|
|
283
|
+
stage: str | None,
|
|
284
|
+
state: str,
|
|
285
|
+
) -> list[tuple[str, str]]:
|
|
286
|
+
state_style = (
|
|
287
|
+
_focus_row_marker(row)[1]
|
|
288
|
+
if row.status in TASK_TERMINAL_STATUSES
|
|
289
|
+
else SECONDARY_TEXT_COLOR
|
|
290
|
+
if row.status == TASK_STATUS_QUEUED
|
|
291
|
+
else AMBER
|
|
292
|
+
if row.wait_reason or row.holds or row.open_decision
|
|
293
|
+
else STEEL_BLUE
|
|
294
|
+
)
|
|
295
|
+
details: list[tuple[str, str]] = [(state, state_style)]
|
|
296
|
+
if row.status in TASK_TERMINAL_STATUSES:
|
|
297
|
+
return details
|
|
298
|
+
if row.acting_role and not stage:
|
|
299
|
+
details.append((row.acting_role.title(), SECONDARY_TEXT_COLOR))
|
|
300
|
+
if (
|
|
301
|
+
row.status not in TASK_TERMINAL_STATUSES
|
|
302
|
+
and row.dependencies
|
|
303
|
+
and row.wait_reason != WAIT_REASON_DEPENDENCY
|
|
304
|
+
):
|
|
305
|
+
details.append((f"depends on {', '.join(row.dependencies)}", SECONDARY_TEXT_COLOR))
|
|
306
|
+
details.extend((hold, AMBER) for hold in row.holds)
|
|
307
|
+
if row.quota_wait:
|
|
308
|
+
details.append((QUOTA_MARKER, AMBER))
|
|
309
|
+
if row.diagnosed_stall:
|
|
310
|
+
details.append(("stalled", ALERT_COLOR))
|
|
311
|
+
if row.open_decision and TASK_STATE_DECISION_NEEDED not in state:
|
|
312
|
+
details.append((TASK_STATE_DECISION_NEEDED, AMBER))
|
|
313
|
+
return details
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _all_attached(widgets: Sequence[Widget]) -> bool:
|
|
317
|
+
return all(widget.is_attached for widget in widgets)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
class _RecentPager(Static):
|
|
321
|
+
def __init__(
|
|
322
|
+
self,
|
|
323
|
+
*,
|
|
324
|
+
widget_id: str,
|
|
325
|
+
full_label: str,
|
|
326
|
+
compact_label: str,
|
|
327
|
+
page: Callable[[], Awaitable[None]],
|
|
328
|
+
) -> None:
|
|
329
|
+
super().__init__(key_hint(full_label), id=widget_id, markup=False)
|
|
330
|
+
self._full_label = full_label
|
|
331
|
+
self._compact_label = compact_label
|
|
332
|
+
self._page = page
|
|
333
|
+
|
|
334
|
+
def show(self, *, enabled: bool, compact: bool) -> None:
|
|
335
|
+
self.update(key_hint(self._compact_label if compact else self._full_label))
|
|
336
|
+
self.set_class(not enabled, RECENT_INACTIVE_CLASS)
|
|
337
|
+
|
|
338
|
+
async def on_click(self, event: events.Click) -> None:
|
|
339
|
+
event.stop()
|
|
340
|
+
if not self.has_class(RECENT_INACTIVE_CLASS):
|
|
341
|
+
await self._page()
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
class _LoadMoreHint(Static):
|
|
345
|
+
def __init__(self, *, load: Callable[[], Awaitable[None]]) -> None:
|
|
346
|
+
super().__init__(id=MORE_HINT_ID, markup=False)
|
|
347
|
+
self._load = load
|
|
348
|
+
|
|
349
|
+
async def on_click(self, event: events.Click) -> None:
|
|
350
|
+
event.stop()
|
|
351
|
+
await self._load()
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
class QueuePanel(Vertical):
|
|
355
|
+
"""Render the Home task list with status filter, search, selection and Recent."""
|
|
356
|
+
|
|
357
|
+
DEFAULT_CSS = """
|
|
358
|
+
#queue { height: auto; }
|
|
359
|
+
#task-browser-count {
|
|
360
|
+
height: 1; margin-bottom: 1; color: $steel-blue;
|
|
361
|
+
text-wrap: nowrap; text-overflow: ellipsis;
|
|
362
|
+
}
|
|
363
|
+
#task-browser-count.search-open { margin-bottom: 0; }
|
|
364
|
+
#task-browser-search.focus-search { height: 1; border: none; padding: 0; margin-bottom: 1; }
|
|
365
|
+
#task-browser-list {
|
|
366
|
+
height: 12; min-height: 2; border: none; padding: 0; background: $background;
|
|
367
|
+
scrollbar-size-vertical: $scrollbar-width;
|
|
368
|
+
}
|
|
369
|
+
#task-browser-list > ListItem {
|
|
370
|
+
height: auto; min-height: 2; margin-bottom: 1; padding: 0;
|
|
371
|
+
border: none; background: $background; color: $primary-text;
|
|
372
|
+
}
|
|
373
|
+
#task-browser-list.focus-list > ListItem { padding: 0 0 0 1; }
|
|
374
|
+
#task-browser-list > ListItem:last-child { margin-bottom: 0; }
|
|
375
|
+
#task-browser-list > ListItem > Static {
|
|
376
|
+
height: auto; padding: 0; color: $primary-text;
|
|
377
|
+
text-wrap: wrap; text-overflow: clip;
|
|
378
|
+
}
|
|
379
|
+
#task-browser-list > ListItem.-highlight {
|
|
380
|
+
outline: none; background: $selected-surface; color: $primary-text; text-style: none;
|
|
381
|
+
}
|
|
382
|
+
#task-browser-list > ListItem.-highlight > Static {
|
|
383
|
+
background: $selected-surface; color: $primary-text; text-style: none;
|
|
384
|
+
}
|
|
385
|
+
#task-browser-active { height: auto; }
|
|
386
|
+
#task-browser-recent { display: none; height: auto; }
|
|
387
|
+
#task-browser-recent-heading {
|
|
388
|
+
height: 3; min-height: 3; margin: 0; border-top: solid $neutral-border; padding: 0;
|
|
389
|
+
}
|
|
390
|
+
#task-browser-recent-title { width: 1fr; height: 1; color: $steel-blue; }
|
|
391
|
+
.recent-pager { width: auto; height: 1; margin-left: 1; color: $secondary-text; }
|
|
392
|
+
.recent-pager:hover { text-style: bold; }
|
|
393
|
+
.recent-pager.-inactive { color: $muted-text; text-style: dim; }
|
|
394
|
+
.recent-pager.-inactive:hover { text-style: dim; }
|
|
395
|
+
#task-browser-recent-list {
|
|
396
|
+
height: auto; min-height: 2; border: none; padding: 0; background: $background;
|
|
397
|
+
scrollbar-size-vertical: 0;
|
|
398
|
+
}
|
|
399
|
+
#task-browser-recent-list > ListItem {
|
|
400
|
+
height: auto; min-height: 2; margin: 0 0 1 0; padding: 0 0 0 1;
|
|
401
|
+
border: none; background: $background; color: $primary-text;
|
|
402
|
+
}
|
|
403
|
+
#task-browser-recent-list > ListItem:last-child { margin-bottom: 0; }
|
|
404
|
+
#task-browser-recent-list > ListItem > Static {
|
|
405
|
+
height: auto; padding: 0; color: $primary-text;
|
|
406
|
+
text-wrap: wrap; text-overflow: clip;
|
|
407
|
+
}
|
|
408
|
+
#task-browser-recent-list > ListItem.-highlight {
|
|
409
|
+
outline: none; background: $selected-surface; color: $primary-text; text-style: none;
|
|
410
|
+
}
|
|
411
|
+
#task-browser-recent-list > ListItem.-highlight > Static {
|
|
412
|
+
background: $selected-surface; color: $primary-text; text-style: none;
|
|
413
|
+
}
|
|
414
|
+
#task-browser-list:focus { background-tint: transparent; }
|
|
415
|
+
#task-browser-empty { height: auto; padding: 1; color: $muted-text; }
|
|
416
|
+
#task-browser-more {
|
|
417
|
+
width: auto; height: 1; margin-top: 1; text-wrap: nowrap; text-overflow: ellipsis;
|
|
418
|
+
}
|
|
419
|
+
"""
|
|
420
|
+
|
|
421
|
+
def __init__(self, context: WidgetContext) -> None:
|
|
422
|
+
super().__init__(id="queue")
|
|
423
|
+
self._widget_context = context
|
|
424
|
+
self._filter = TaskBrowserFilter.ALL
|
|
425
|
+
self._search = ""
|
|
426
|
+
self._selected_task_id = context.focus_task
|
|
427
|
+
self._selection_cleared = False
|
|
428
|
+
self._loaded_page_count = 1
|
|
429
|
+
self._recent_offset = 0
|
|
430
|
+
self._terminal_count: int | None = None
|
|
431
|
+
self._recent_window = RecentWindow(0, context.views.recent_task_window, 0)
|
|
432
|
+
self._view: TaskBrowserView | None = None
|
|
433
|
+
self._rendered: tuple[TaskBrowserView, str | None] | None = None
|
|
434
|
+
self._refreshing = False
|
|
435
|
+
self._refresh_lock = asyncio.Lock()
|
|
436
|
+
self._list_render_versions: dict[str, int] = {}
|
|
437
|
+
self._status_bright = False
|
|
438
|
+
self._remove_motion_callback: Callable[[], None] | None = None
|
|
439
|
+
self._count = Static("", id=COUNT_ID, markup=False)
|
|
440
|
+
self._empty = Static("", id=EMPTY_ID, markup=False)
|
|
441
|
+
self._task_list = _TaskBrowserListView(
|
|
442
|
+
widget_id=TASK_LIST_ID,
|
|
443
|
+
move_selection=self.move_selection,
|
|
444
|
+
select_task=self.select_task,
|
|
445
|
+
activate_attention_decision=context.activate_attention_decision,
|
|
446
|
+
)
|
|
447
|
+
self._recent_block = Vertical(id=TASK_RECENT_BLOCK_ID)
|
|
448
|
+
self._active_block = Vertical(id=TASK_ACTIVE_BLOCK_ID)
|
|
449
|
+
self._later = _RecentPager(
|
|
450
|
+
widget_id=RECENT_LATER_ID,
|
|
451
|
+
full_label=RECENT_LATER_LABEL,
|
|
452
|
+
compact_label=RECENT_LATER_COMPACT_LABEL,
|
|
453
|
+
page=lambda: self.page_recent(-1),
|
|
454
|
+
)
|
|
455
|
+
self._earlier = _RecentPager(
|
|
456
|
+
widget_id=RECENT_EARLIER_ID,
|
|
457
|
+
full_label=RECENT_EARLIER_LABEL,
|
|
458
|
+
compact_label=RECENT_EARLIER_COMPACT_LABEL,
|
|
459
|
+
page=lambda: self.page_recent(1),
|
|
460
|
+
)
|
|
461
|
+
self._recent_heading = Horizontal(
|
|
462
|
+
Static(TASK_MARKER_RECENT, id=TASK_RECENT_TITLE_ID, markup=False),
|
|
463
|
+
self._later,
|
|
464
|
+
self._earlier,
|
|
465
|
+
id=TASK_RECENT_HEADING_ID,
|
|
466
|
+
)
|
|
467
|
+
self._later.add_class(RECENT_PAGER_CLASS)
|
|
468
|
+
self._earlier.add_class(RECENT_PAGER_CLASS)
|
|
469
|
+
self._recent_list = _TaskBrowserListView(
|
|
470
|
+
widget_id=TASK_RECENT_LIST_ID,
|
|
471
|
+
move_selection=self.move_selection,
|
|
472
|
+
select_task=self.select_task,
|
|
473
|
+
activate_attention_decision=context.activate_attention_decision,
|
|
474
|
+
)
|
|
475
|
+
self._more_hint = _LoadMoreHint(load=self.load_more)
|
|
476
|
+
|
|
477
|
+
def compose(self) -> ComposeResult:
|
|
478
|
+
search = Input(placeholder="Search task ID or title", id=SEARCH_INPUT_ID)
|
|
479
|
+
search.display = False
|
|
480
|
+
search.add_class(SEARCH_FIELD_CLASS)
|
|
481
|
+
self._task_list.add_class(FOCUS_LIST_CLASS)
|
|
482
|
+
self._recent_list.add_class(FOCUS_LIST_CLASS)
|
|
483
|
+
self._more_hint.display = False
|
|
484
|
+
with self._active_block:
|
|
485
|
+
yield self._count
|
|
486
|
+
yield search
|
|
487
|
+
yield self._empty
|
|
488
|
+
yield self._task_list
|
|
489
|
+
yield self._more_hint
|
|
490
|
+
with self._recent_block:
|
|
491
|
+
yield self._recent_heading
|
|
492
|
+
yield self._recent_list
|
|
493
|
+
|
|
494
|
+
async def on_mount(self) -> None:
|
|
495
|
+
await self._refresh()
|
|
496
|
+
self.run_worker(
|
|
497
|
+
self._widget_context.on_change(self._refresh_if_current),
|
|
498
|
+
exclusive=True,
|
|
499
|
+
)
|
|
500
|
+
self._remove_motion_callback = self._widget_context.on_motion(self._refresh_while_active)
|
|
501
|
+
|
|
502
|
+
def on_resize(self, event: events.Resize) -> None:
|
|
503
|
+
del event
|
|
504
|
+
self._render_recent_pager()
|
|
505
|
+
if self._view is not None and self._count.is_attached:
|
|
506
|
+
self._render_count(self._view)
|
|
507
|
+
if self._more_hint.is_attached:
|
|
508
|
+
self._more_hint.update(load_more_hint(keys_only=self._keys_only()))
|
|
509
|
+
|
|
510
|
+
def on_unmount(self) -> None:
|
|
511
|
+
if self._remove_motion_callback is not None:
|
|
512
|
+
self._remove_motion_callback()
|
|
513
|
+
self._remove_motion_callback = None
|
|
514
|
+
|
|
515
|
+
async def _refresh_while_active(self) -> None:
|
|
516
|
+
if (
|
|
517
|
+
not self.is_mounted
|
|
518
|
+
or not widget_is_current(self)
|
|
519
|
+
or not cast("App[object]", self.app).is_running
|
|
520
|
+
):
|
|
521
|
+
return
|
|
522
|
+
await self._refresh(motion=True)
|
|
523
|
+
if not self._render_targets_are_attached():
|
|
524
|
+
return
|
|
525
|
+
animations = self._widget_context.settings.current().dashboard.animations
|
|
526
|
+
animated = animations and self._widget_context.views.engine_up()
|
|
527
|
+
status_bright = not self._status_bright if animated else False
|
|
528
|
+
if animated or self._status_bright:
|
|
529
|
+
self._apply_status_motion(status_bright)
|
|
530
|
+
|
|
531
|
+
def _apply_status_motion(self, status_bright: bool) -> None:
|
|
532
|
+
self._status_bright = status_bright
|
|
533
|
+
for list_view in self._browser_list_views():
|
|
534
|
+
for item in list_view.children:
|
|
535
|
+
if isinstance(item, _BrowserListItem):
|
|
536
|
+
item.update_status_motion(status_bright)
|
|
537
|
+
|
|
538
|
+
async def _refresh_if_current(self) -> None:
|
|
539
|
+
if widget_is_current(self):
|
|
540
|
+
await self._refresh()
|
|
541
|
+
|
|
542
|
+
def refresh_after_resume(self) -> None:
|
|
543
|
+
if widget_is_current(self):
|
|
544
|
+
self.run_worker(
|
|
545
|
+
self._refresh_if_current(),
|
|
546
|
+
group=QUEUE_REFRESH_WORKER,
|
|
547
|
+
exclusive=True,
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
async def on_input_changed(self, event: Input.Changed) -> None:
|
|
551
|
+
if event.input.id != SEARCH_INPUT_ID:
|
|
552
|
+
return
|
|
553
|
+
await self._refresh(search=event.value, reset_pages=True)
|
|
554
|
+
|
|
555
|
+
async def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
556
|
+
if event.input.id == SEARCH_INPUT_ID:
|
|
557
|
+
self._close_search()
|
|
558
|
+
|
|
559
|
+
def focus_task_results(self) -> None:
|
|
560
|
+
"""Focus the visible task list, keeping empty result sets usable."""
|
|
561
|
+
list_view = next((item for item in self._browser_list_views() if item.display), None)
|
|
562
|
+
if list_view is not None:
|
|
563
|
+
list_view.focus()
|
|
564
|
+
return
|
|
565
|
+
search = self.query_one(f"#{SEARCH_INPUT_ID}", Input)
|
|
566
|
+
if search.display:
|
|
567
|
+
search.focus()
|
|
568
|
+
return
|
|
569
|
+
app = cast("App[object]", self.app)
|
|
570
|
+
footer_buttons = app.query("#focus-footer Button")
|
|
571
|
+
app.set_focus(footer_buttons.first() if footer_buttons else None)
|
|
572
|
+
|
|
573
|
+
def open_search(self) -> None:
|
|
574
|
+
"""Focus the task search from contextual keyboard navigation."""
|
|
575
|
+
search = self.query_one(f"#{SEARCH_INPUT_ID}", Input)
|
|
576
|
+
self._show_search_field(search, visible=True)
|
|
577
|
+
search.focus()
|
|
578
|
+
|
|
579
|
+
def cycle_status_filter(self) -> None:
|
|
580
|
+
"""Show the next task status filter, keeping the search text and the selection."""
|
|
581
|
+
cycle = STATUS_FILTER_CYCLE
|
|
582
|
+
position = cycle.index(self._filter) if self._filter in cycle else 0
|
|
583
|
+
self._filter = cycle[(position + 1) % len(cycle)]
|
|
584
|
+
self.run_worker(self._refresh(reset_pages=True), group=STATUS_FILTER_WORKER)
|
|
585
|
+
|
|
586
|
+
def _close_search(self) -> None:
|
|
587
|
+
search = self.query_one(f"#{SEARCH_INPUT_ID}", Input)
|
|
588
|
+
self._show_search_field(search, visible=False)
|
|
589
|
+
self.focus_task_results()
|
|
590
|
+
|
|
591
|
+
def _show_search_field(self, search: Input, *, visible: bool) -> None:
|
|
592
|
+
search.display = visible
|
|
593
|
+
self._count.set_class(visible, SEARCH_OPEN_CLASS)
|
|
594
|
+
|
|
595
|
+
async def load_more(self) -> None:
|
|
596
|
+
"""Append the next bounded browser page and return focus to the task list."""
|
|
597
|
+
await self._refresh(append_page=True)
|
|
598
|
+
self.focus_task_results()
|
|
599
|
+
|
|
600
|
+
def request_more(self) -> None:
|
|
601
|
+
"""Load the next page from the `m` key while the hint is shown."""
|
|
602
|
+
if self._more_hint.display:
|
|
603
|
+
self.run_worker(self.load_more(), group=MORE_WORKER)
|
|
604
|
+
|
|
605
|
+
async def activate_filter(self, task_filter: TaskBrowserFilter) -> None:
|
|
606
|
+
"""Navigate to a task category while retaining search and selected identity."""
|
|
607
|
+
await self._refresh(task_filter=task_filter, reset_pages=True)
|
|
608
|
+
self.focus_task_results()
|
|
609
|
+
|
|
610
|
+
def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
|
|
611
|
+
if event.list_view.id not in {TASK_LIST_ID, TASK_RECENT_LIST_ID} or self._refreshing:
|
|
612
|
+
return
|
|
613
|
+
if event.item is event.list_view.highlighted_child and isinstance(
|
|
614
|
+
event.item, _BrowserListItem
|
|
615
|
+
):
|
|
616
|
+
self.select_task(event.item.task_id)
|
|
617
|
+
|
|
618
|
+
async def on_list_view_selected(self, event: ListView.Selected) -> None:
|
|
619
|
+
if event.list_view.id in {TASK_LIST_ID, TASK_RECENT_LIST_ID} and isinstance(
|
|
620
|
+
event.item, _BrowserListItem
|
|
621
|
+
):
|
|
622
|
+
self.select_task(event.item.task_id)
|
|
623
|
+
|
|
624
|
+
def on_key(self, event: events.Key) -> None:
|
|
625
|
+
focused = cast("App[object]", self.app).focused
|
|
626
|
+
if event.key == ESCAPE_KEY and isinstance(focused, Input) and focused.id == SEARCH_INPUT_ID:
|
|
627
|
+
event.stop()
|
|
628
|
+
focused.value = ""
|
|
629
|
+
self._close_search()
|
|
630
|
+
|
|
631
|
+
async def move_selection(self, step: int) -> None:
|
|
632
|
+
"""Move the task selection by `step` rows across the visible task lists."""
|
|
633
|
+
rows = self._visible_rows()
|
|
634
|
+
if not rows:
|
|
635
|
+
return
|
|
636
|
+
active_count = len(self._list_items(self._task_list))
|
|
637
|
+
current = next(
|
|
638
|
+
(index for index, item in enumerate(rows) if item.task_id == self._selected_task_id),
|
|
639
|
+
None,
|
|
640
|
+
)
|
|
641
|
+
target = step_selection(active_count, self._recent_window, current, step)
|
|
642
|
+
if target.recent_position is not None:
|
|
643
|
+
self._recent_offset = self._recent_window.at_page_of(target.recent_position).offset
|
|
644
|
+
await self._refresh()
|
|
645
|
+
rows = self._visible_rows()
|
|
646
|
+
active_count = len(self._list_items(self._task_list))
|
|
647
|
+
visible_index = active_count + target.recent_position - self._recent_window.offset
|
|
648
|
+
else:
|
|
649
|
+
visible_index = cast("int", target.visible_index)
|
|
650
|
+
if visible_index >= len(rows):
|
|
651
|
+
return
|
|
652
|
+
item = rows[visible_index]
|
|
653
|
+
self.select_task(item.task_id)
|
|
654
|
+
list_view = cast("ListView", item.parent)
|
|
655
|
+
list_view.index = list_view.children.index(item)
|
|
656
|
+
list_view.scroll_to_widget(item, animate=False, immediate=True)
|
|
657
|
+
item.scroll_visible(animate=False, immediate=True)
|
|
658
|
+
|
|
659
|
+
async def page_recent(self, direction: int) -> None:
|
|
660
|
+
"""Show the older (`1`) or newer (`-1`) Recent window without changing the selection."""
|
|
661
|
+
window = self._recent_window
|
|
662
|
+
offset = window.offset + direction * window.size
|
|
663
|
+
if not 0 <= offset < window.total:
|
|
664
|
+
return
|
|
665
|
+
self._recent_offset = offset
|
|
666
|
+
await self._refresh()
|
|
667
|
+
self.focus_task_results()
|
|
668
|
+
|
|
669
|
+
def _visible_rows(self) -> tuple[_BrowserListItem, ...]:
|
|
670
|
+
return tuple(
|
|
671
|
+
item for list_view in self._browser_list_views() for item in self._list_items(list_view)
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
@staticmethod
|
|
675
|
+
def _list_items(list_view: ListView) -> tuple[_BrowserListItem, ...]:
|
|
676
|
+
return tuple(item for item in list_view.children if isinstance(item, _BrowserListItem))
|
|
677
|
+
|
|
678
|
+
def select_task(self, task_id: str) -> None:
|
|
679
|
+
"""Select a task and update the Home inspector without opening a route."""
|
|
680
|
+
self._selected_task_id = task_id
|
|
681
|
+
self._selection_cleared = False
|
|
682
|
+
self._sync_selection(self._task_list, self._recent_list)
|
|
683
|
+
callback = self._widget_context.on_task_selected
|
|
684
|
+
if callback is not None:
|
|
685
|
+
callback(task_id)
|
|
686
|
+
if self._view is None:
|
|
687
|
+
return
|
|
688
|
+
row = next(
|
|
689
|
+
(candidate for candidate in self._view.rows if candidate.task_id == task_id), None
|
|
690
|
+
)
|
|
691
|
+
if row is None:
|
|
692
|
+
return
|
|
693
|
+
self._view = replace(self._view, selected_task=row)
|
|
694
|
+
|
|
695
|
+
def _browser_list_views(self) -> tuple[ListView, ...]:
|
|
696
|
+
return tuple(
|
|
697
|
+
list_view for list_view in (self._task_list, self._recent_list) if list_view.is_attached
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
def clear_selection(self) -> None:
|
|
701
|
+
"""Clear the Home inspector until an operator selects another task."""
|
|
702
|
+
self._selected_task_id = None
|
|
703
|
+
self._selection_cleared = True
|
|
704
|
+
if self._view is not None:
|
|
705
|
+
self._view = replace(self._view, selected_task=None)
|
|
706
|
+
for list_view in self._browser_list_views():
|
|
707
|
+
list_view.index = None
|
|
708
|
+
callback = self._widget_context.on_task_selected
|
|
709
|
+
if callback is not None:
|
|
710
|
+
callback(None)
|
|
711
|
+
|
|
712
|
+
def sync_context_selection(self, task_id: str | None) -> None:
|
|
713
|
+
"""Keep list identity aligned with another Home widget's task selection."""
|
|
714
|
+
if task_id == self._selected_task_id:
|
|
715
|
+
return
|
|
716
|
+
self._selected_task_id = task_id
|
|
717
|
+
self._selection_cleared = task_id is None
|
|
718
|
+
if not widget_is_current(self):
|
|
719
|
+
return
|
|
720
|
+
for list_view in self._browser_list_views():
|
|
721
|
+
item = next(
|
|
722
|
+
(
|
|
723
|
+
candidate
|
|
724
|
+
for candidate in list_view.children
|
|
725
|
+
if isinstance(candidate, _BrowserListItem) and candidate.task_id == task_id
|
|
726
|
+
),
|
|
727
|
+
None,
|
|
728
|
+
)
|
|
729
|
+
list_view.index = list_view.children.index(item) if item is not None else None
|
|
730
|
+
if item is not None:
|
|
731
|
+
list_view.scroll_to_widget(item, animate=False, immediate=True)
|
|
732
|
+
|
|
733
|
+
async def _refresh(
|
|
734
|
+
self,
|
|
735
|
+
*,
|
|
736
|
+
append_page: bool = False,
|
|
737
|
+
task_filter: TaskBrowserFilter | None = None,
|
|
738
|
+
search: str | None = None,
|
|
739
|
+
reset_pages: bool = False,
|
|
740
|
+
motion: bool = False,
|
|
741
|
+
) -> None:
|
|
742
|
+
async with self._refresh_lock:
|
|
743
|
+
if not widget_is_current(self):
|
|
744
|
+
return
|
|
745
|
+
self._refreshing = True
|
|
746
|
+
try:
|
|
747
|
+
if task_filter is not None:
|
|
748
|
+
self._filter = task_filter
|
|
749
|
+
if search is not None:
|
|
750
|
+
self._search = search
|
|
751
|
+
if reset_pages:
|
|
752
|
+
self._loaded_page_count = 1
|
|
753
|
+
self._recent_offset = 0
|
|
754
|
+
view = self._load_next_page() if append_page else self._load_pages()
|
|
755
|
+
if self._selected_task_id is None and view.rows and not self._selection_cleared:
|
|
756
|
+
self._selected_task_id = view.rows[0].task_id
|
|
757
|
+
view = replace(view, selected_task=view.rows[0])
|
|
758
|
+
self._view = view
|
|
759
|
+
callback = self._widget_context.on_task_selected
|
|
760
|
+
if callback is not None:
|
|
761
|
+
callback(self._selected_task_id)
|
|
762
|
+
if not self._render_targets_are_attached():
|
|
763
|
+
return
|
|
764
|
+
rendering = (view, self._selected_task_id)
|
|
765
|
+
if motion and rendering == self._rendered:
|
|
766
|
+
return
|
|
767
|
+
self._rendered = None
|
|
768
|
+
await self._sync_task_lists(view)
|
|
769
|
+
if not self._render_targets_are_attached():
|
|
770
|
+
return
|
|
771
|
+
self._render_view(view)
|
|
772
|
+
self._rendered = rendering
|
|
773
|
+
finally:
|
|
774
|
+
self._refreshing = False
|
|
775
|
+
|
|
776
|
+
def _load_pages(self, page_count: int | None = None) -> TaskBrowserView:
|
|
777
|
+
requested_page_count = page_count if page_count is not None else self._loaded_page_count
|
|
778
|
+
pages = self._widget_context.views.task_browser_pages(
|
|
779
|
+
task_filter=self._filter,
|
|
780
|
+
search=self._search,
|
|
781
|
+
limit=BROWSER_PAGE_SIZE,
|
|
782
|
+
page_count=requested_page_count,
|
|
783
|
+
selected_task_id=self._selected_task_id,
|
|
784
|
+
)
|
|
785
|
+
self._loaded_page_count = len(pages)
|
|
786
|
+
rows = tuple(row for page in pages for row in page.rows)
|
|
787
|
+
matched_ids = {row.task_id for row in rows}
|
|
788
|
+
view = pages[-1]
|
|
789
|
+
selected_task = view.selected_task
|
|
790
|
+
is_focus_recent = self._filter is TaskBrowserFilter.ALL and not self._search
|
|
791
|
+
self._recent_window = RecentWindow(0, self._recent_window.size, 0)
|
|
792
|
+
if is_focus_recent:
|
|
793
|
+
recent_page, self._recent_window = self._load_recent_window()
|
|
794
|
+
recent_rows = tuple(
|
|
795
|
+
row for row in recent_page.rows if row.status in TASK_TERMINAL_STATUSES
|
|
796
|
+
)[: self._recent_window.shown]
|
|
797
|
+
recent_by_id = {row.task_id: row for row in recent_rows}
|
|
798
|
+
matched_ids.update(row.task_id for row in recent_rows)
|
|
799
|
+
active_rows = tuple(
|
|
800
|
+
row
|
|
801
|
+
for row in rows
|
|
802
|
+
if row.status not in TASK_TERMINAL_STATUSES and row.task_id not in recent_by_id
|
|
803
|
+
)
|
|
804
|
+
rows = (*active_rows, *recent_rows)
|
|
805
|
+
if selected_task is not None and selected_task.task_id in recent_by_id:
|
|
806
|
+
selected_task = recent_by_id[selected_task.task_id]
|
|
807
|
+
elif selected_task is None:
|
|
808
|
+
selected_task = recent_page.selected_task
|
|
809
|
+
visible_active_count = len(active_rows)
|
|
810
|
+
matching_active_count = max(
|
|
811
|
+
view.matching_count - recent_page.matching_count,
|
|
812
|
+
0,
|
|
813
|
+
)
|
|
814
|
+
view = replace(
|
|
815
|
+
view,
|
|
816
|
+
has_more=visible_active_count < matching_active_count,
|
|
817
|
+
)
|
|
818
|
+
return replace(view, rows=rows, selected_task=selected_task)
|
|
819
|
+
|
|
820
|
+
def _load_recent_window(self) -> tuple[TaskBrowserView, RecentWindow]:
|
|
821
|
+
views = self._widget_context.views
|
|
822
|
+
size = views.recent_task_window
|
|
823
|
+
page = self._recent_page(self._recent_offset)
|
|
824
|
+
completed = self._terminal_count is not None and page.matching_count > self._terminal_count
|
|
825
|
+
self._terminal_count = page.matching_count
|
|
826
|
+
window = RecentWindow(
|
|
827
|
+
0 if completed else self._recent_offset,
|
|
828
|
+
size,
|
|
829
|
+
min(page.matching_count, views.recent_task_limit),
|
|
830
|
+
).clamped()
|
|
831
|
+
if window.offset != self._recent_offset:
|
|
832
|
+
page = self._recent_page(window.offset)
|
|
833
|
+
self._recent_offset = window.offset
|
|
834
|
+
return page, window
|
|
835
|
+
|
|
836
|
+
def _recent_page(self, offset: int) -> TaskBrowserView:
|
|
837
|
+
views = self._widget_context.views
|
|
838
|
+
size = views.recent_task_window
|
|
839
|
+
return views.task_browser_pages(
|
|
840
|
+
task_filter=TaskBrowserFilter.HISTORY,
|
|
841
|
+
search=self._search,
|
|
842
|
+
limit=size,
|
|
843
|
+
page_count=offset // size + 1,
|
|
844
|
+
selected_task_id=self._selected_task_id,
|
|
845
|
+
)[-1]
|
|
846
|
+
|
|
847
|
+
def _load_next_page(self) -> TaskBrowserView:
|
|
848
|
+
current = self._view
|
|
849
|
+
if (
|
|
850
|
+
current is not None
|
|
851
|
+
and current.filter is self._filter
|
|
852
|
+
and current.search == self._search
|
|
853
|
+
):
|
|
854
|
+
return self._load_pages(self._loaded_page_count + 1)
|
|
855
|
+
return self._load_pages()
|
|
856
|
+
|
|
857
|
+
async def _sync_task_lists(self, view: TaskBrowserView) -> None:
|
|
858
|
+
recent_rows = (
|
|
859
|
+
tuple(row for row in view.rows if row.status in TASK_TERMINAL_STATUSES)
|
|
860
|
+
if view.filter is TaskBrowserFilter.ALL and not view.search
|
|
861
|
+
else ()
|
|
862
|
+
)
|
|
863
|
+
recent_ids = {row.task_id for row in recent_rows}
|
|
864
|
+
active_rows = tuple(row for row in view.rows if row.task_id not in recent_ids)
|
|
865
|
+
active_list = self._task_list
|
|
866
|
+
recent_list = self._recent_list
|
|
867
|
+
active_list.display = bool(active_rows)
|
|
868
|
+
self._recent_block.display = bool(recent_rows)
|
|
869
|
+
await self._sync_list_rows(active_list, active_rows)
|
|
870
|
+
await self._sync_list_rows(recent_list, recent_rows)
|
|
871
|
+
self._render_recent_pager()
|
|
872
|
+
if self._list_view_is_current(active_list) and self._list_view_is_current(recent_list):
|
|
873
|
+
self._sync_selection(active_list, recent_list)
|
|
874
|
+
focused = cast("App[object]", self.app).focused
|
|
875
|
+
if (focused is active_list and not active_list.display) or (
|
|
876
|
+
focused is recent_list and not self._recent_block.display
|
|
877
|
+
):
|
|
878
|
+
self.focus_task_results()
|
|
879
|
+
|
|
880
|
+
async def _sync_list_rows(
|
|
881
|
+
self,
|
|
882
|
+
list_view: ListView,
|
|
883
|
+
rows: tuple[TaskBrowserRow, ...],
|
|
884
|
+
) -> None:
|
|
885
|
+
if not self._list_view_is_current(list_view):
|
|
886
|
+
return
|
|
887
|
+
items = {
|
|
888
|
+
item.task_id: item for item in list_view.children if isinstance(item, _BrowserListItem)
|
|
889
|
+
}
|
|
890
|
+
desired_ids = tuple(row.task_id for row in rows)
|
|
891
|
+
wanted_ids = set(desired_ids)
|
|
892
|
+
if tuple(items) == desired_ids and len(list_view.children) == len(rows):
|
|
893
|
+
anchor, anchor_offset = self._visible_anchor(list_view, tuple(items.values()))
|
|
894
|
+
changed = self._update_browser_items(items, rows)
|
|
895
|
+
if changed:
|
|
896
|
+
self._restore_list_position_after_refresh(
|
|
897
|
+
list_view,
|
|
898
|
+
anchor,
|
|
899
|
+
anchor_offset,
|
|
900
|
+
)
|
|
901
|
+
return
|
|
902
|
+
anchor, anchor_offset = self._visible_anchor(
|
|
903
|
+
list_view,
|
|
904
|
+
tuple(item for task_id, item in items.items() if task_id in wanted_ids),
|
|
905
|
+
)
|
|
906
|
+
await self._remove_stale_list_items(items, wanted_ids)
|
|
907
|
+
if not self._list_view_is_current(list_view):
|
|
908
|
+
return
|
|
909
|
+
self._update_browser_items(items, rows)
|
|
910
|
+
desired_children = [items[row.task_id] for row in rows]
|
|
911
|
+
await self._place_list_items(list_view, desired_children)
|
|
912
|
+
if not self._list_view_is_current(list_view):
|
|
913
|
+
return
|
|
914
|
+
self._restore_list_position_after_refresh(
|
|
915
|
+
list_view,
|
|
916
|
+
anchor,
|
|
917
|
+
anchor_offset,
|
|
918
|
+
)
|
|
919
|
+
|
|
920
|
+
async def _remove_stale_list_items(
|
|
921
|
+
self,
|
|
922
|
+
items: dict[str, _BrowserListItem],
|
|
923
|
+
wanted_ids: set[str],
|
|
924
|
+
) -> None:
|
|
925
|
+
removed = [item for task_id, item in items.items() if task_id not in wanted_ids]
|
|
926
|
+
if removed:
|
|
927
|
+
await asyncio.gather(*(item.remove() for item in removed))
|
|
928
|
+
retained = {task_id: item for task_id, item in items.items() if task_id in wanted_ids}
|
|
929
|
+
items.clear()
|
|
930
|
+
items.update(retained)
|
|
931
|
+
|
|
932
|
+
def _update_browser_items(
|
|
933
|
+
self,
|
|
934
|
+
items: dict[str, _BrowserListItem],
|
|
935
|
+
rows: tuple[TaskBrowserRow, ...],
|
|
936
|
+
) -> bool:
|
|
937
|
+
changed = False
|
|
938
|
+
for row in rows:
|
|
939
|
+
if row.task_id not in items:
|
|
940
|
+
items[row.task_id] = _BrowserListItem(row)
|
|
941
|
+
changed = True
|
|
942
|
+
changed = items[row.task_id].update_row(row) or changed
|
|
943
|
+
return changed
|
|
944
|
+
|
|
945
|
+
async def _place_list_items(
|
|
946
|
+
self,
|
|
947
|
+
list_view: ListView,
|
|
948
|
+
desired_children: Sequence[ListItem],
|
|
949
|
+
) -> None:
|
|
950
|
+
additions = [child for child in desired_children if child.parent is None]
|
|
951
|
+
if additions:
|
|
952
|
+
await list_view.extend(additions)
|
|
953
|
+
if not self._list_view_is_current(list_view):
|
|
954
|
+
return
|
|
955
|
+
for index, child in enumerate(desired_children):
|
|
956
|
+
if list_view.children[index] is not child:
|
|
957
|
+
list_view.move_child(child, before=index)
|
|
958
|
+
|
|
959
|
+
def _visible_anchor(
|
|
960
|
+
self,
|
|
961
|
+
list_view: ListView,
|
|
962
|
+
items: tuple[_BrowserListItem, ...],
|
|
963
|
+
) -> tuple[_BrowserListItem | None, int]:
|
|
964
|
+
content_region = list_view.scrollable_content_region
|
|
965
|
+
visible_items = [item for item in items if item.region.overlaps(content_region)]
|
|
966
|
+
if not visible_items:
|
|
967
|
+
return None, 0
|
|
968
|
+
anchor = min(visible_items, key=lambda item: item.region.y)
|
|
969
|
+
return anchor, anchor.region.y - content_region.y
|
|
970
|
+
|
|
971
|
+
def _restore_list_position_after_refresh(
|
|
972
|
+
self,
|
|
973
|
+
list_view: ListView,
|
|
974
|
+
anchor: _BrowserListItem | None,
|
|
975
|
+
anchor_offset: int,
|
|
976
|
+
) -> None:
|
|
977
|
+
list_key = list_view.id or TASK_LIST_ID
|
|
978
|
+
render_version = self._list_render_versions.get(list_key, 0) + 1
|
|
979
|
+
self._list_render_versions[list_key] = render_version
|
|
980
|
+
cast("App[object]", self.app).call_after_refresh(
|
|
981
|
+
self._restore_list_position,
|
|
982
|
+
list_view,
|
|
983
|
+
anchor,
|
|
984
|
+
anchor_offset,
|
|
985
|
+
render_version,
|
|
986
|
+
False,
|
|
987
|
+
)
|
|
988
|
+
|
|
989
|
+
def _restore_list_position(
|
|
990
|
+
self,
|
|
991
|
+
list_view: ListView,
|
|
992
|
+
anchor: _BrowserListItem | None,
|
|
993
|
+
anchor_offset: int,
|
|
994
|
+
render_version: int,
|
|
995
|
+
settled: bool,
|
|
996
|
+
) -> None:
|
|
997
|
+
list_key = list_view.id or TASK_LIST_ID
|
|
998
|
+
if render_version != self._list_render_versions.get(
|
|
999
|
+
list_key
|
|
1000
|
+
) or not self._list_view_is_current(list_view):
|
|
1001
|
+
return
|
|
1002
|
+
if anchor is not None and anchor.parent is list_view:
|
|
1003
|
+
content_region = list_view.scrollable_content_region
|
|
1004
|
+
current_offset = anchor.region.y - content_region.y
|
|
1005
|
+
scroll_delta = current_offset - anchor_offset
|
|
1006
|
+
if scroll_delta:
|
|
1007
|
+
list_view.scroll_to(
|
|
1008
|
+
y=list_view.scroll_y + scroll_delta,
|
|
1009
|
+
animate=False,
|
|
1010
|
+
immediate=True,
|
|
1011
|
+
)
|
|
1012
|
+
selected_item = next(
|
|
1013
|
+
(
|
|
1014
|
+
item
|
|
1015
|
+
for item in list_view.children
|
|
1016
|
+
if isinstance(item, _BrowserListItem) and item.task_id == self._selected_task_id
|
|
1017
|
+
),
|
|
1018
|
+
None,
|
|
1019
|
+
)
|
|
1020
|
+
if (
|
|
1021
|
+
anchor is None
|
|
1022
|
+
and selected_item is not None
|
|
1023
|
+
and selected_item.parent is list_view
|
|
1024
|
+
and not selected_item.region.overlaps(list_view.scrollable_content_region)
|
|
1025
|
+
):
|
|
1026
|
+
list_view.scroll_to_widget(selected_item, animate=False, immediate=True)
|
|
1027
|
+
if not settled:
|
|
1028
|
+
cast("App[object]", self.app).call_after_refresh(
|
|
1029
|
+
self._restore_list_position,
|
|
1030
|
+
list_view,
|
|
1031
|
+
anchor,
|
|
1032
|
+
anchor_offset,
|
|
1033
|
+
render_version,
|
|
1034
|
+
True,
|
|
1035
|
+
)
|
|
1036
|
+
list_view.refresh(layout=True)
|
|
1037
|
+
|
|
1038
|
+
def _render_recent_pager(self) -> None:
|
|
1039
|
+
window = self._recent_window
|
|
1040
|
+
controls = (self._later, self._earlier)
|
|
1041
|
+
if not _all_attached((*controls, self._recent_list)):
|
|
1042
|
+
return
|
|
1043
|
+
compact = 0 < self.size.width < RECENT_HEADING_FULL_COLUMNS
|
|
1044
|
+
self._later.show(enabled=window.has_later, compact=compact)
|
|
1045
|
+
self._earlier.show(enabled=window.has_earlier, compact=compact)
|
|
1046
|
+
for control in controls:
|
|
1047
|
+
control.display = window.paged
|
|
1048
|
+
self._recent_list.styles.min_height = (
|
|
1049
|
+
TASK_ROW_LINES * window.size + TASK_ROW_GAP_LINES * (window.size - 1)
|
|
1050
|
+
if window.paged and self._is_wide()
|
|
1051
|
+
else None
|
|
1052
|
+
)
|
|
1053
|
+
|
|
1054
|
+
def _is_wide(self) -> bool:
|
|
1055
|
+
return any(
|
|
1056
|
+
ancestor.id == FOCUS_MAIN_CONTAINER_ID and ancestor.has_class("wide")
|
|
1057
|
+
for ancestor in self.ancestors
|
|
1058
|
+
)
|
|
1059
|
+
|
|
1060
|
+
def _sync_selection(
|
|
1061
|
+
self,
|
|
1062
|
+
active_list: ListView,
|
|
1063
|
+
recent_list: ListView,
|
|
1064
|
+
) -> None:
|
|
1065
|
+
selected_task_id = self._selected_task_id
|
|
1066
|
+
for list_view in (active_list, recent_list):
|
|
1067
|
+
selected_item = next(
|
|
1068
|
+
(
|
|
1069
|
+
item
|
|
1070
|
+
for item in list_view.children
|
|
1071
|
+
if isinstance(item, _BrowserListItem) and item.task_id == selected_task_id
|
|
1072
|
+
),
|
|
1073
|
+
None,
|
|
1074
|
+
)
|
|
1075
|
+
selected_index = (
|
|
1076
|
+
list_view.children.index(selected_item) if selected_item is not None else None
|
|
1077
|
+
)
|
|
1078
|
+
if list_view.index != selected_index:
|
|
1079
|
+
list_view.index = selected_index
|
|
1080
|
+
|
|
1081
|
+
def _render_view(self, view: TaskBrowserView) -> None:
|
|
1082
|
+
if not self._render_targets_are_attached():
|
|
1083
|
+
return
|
|
1084
|
+
self._render_count(view)
|
|
1085
|
+
more = self._more_hint
|
|
1086
|
+
more.display = view.has_more
|
|
1087
|
+
more.update(load_more_hint(keys_only=self._keys_only()))
|
|
1088
|
+
empty = self._empty
|
|
1089
|
+
empty.display = not view.rows
|
|
1090
|
+
if view.rows:
|
|
1091
|
+
empty.update("")
|
|
1092
|
+
elif view.project_count == 0:
|
|
1093
|
+
empty.update("No tasks yet.")
|
|
1094
|
+
elif view.search:
|
|
1095
|
+
empty.update(f"No tasks match {view.search!r} in {FILTER_LABELS[view.filter]}.")
|
|
1096
|
+
else:
|
|
1097
|
+
empty.update(f"No {FILTER_LABELS[view.filter].lower()} tasks.")
|
|
1098
|
+
|
|
1099
|
+
def _render_targets_are_attached(self) -> bool:
|
|
1100
|
+
return widget_is_current(self) and _all_attached(self._render_targets())
|
|
1101
|
+
|
|
1102
|
+
def _render_targets(self) -> tuple[Widget, ...]:
|
|
1103
|
+
return (
|
|
1104
|
+
self._count,
|
|
1105
|
+
self._empty,
|
|
1106
|
+
self._more_hint,
|
|
1107
|
+
self._task_list,
|
|
1108
|
+
self._recent_heading,
|
|
1109
|
+
self._recent_list,
|
|
1110
|
+
)
|
|
1111
|
+
|
|
1112
|
+
def _list_view_is_current(self, list_view: ListView) -> bool:
|
|
1113
|
+
return widget_is_current(self) and list_view.is_attached and self in list_view.ancestors
|
|
1114
|
+
|
|
1115
|
+
def _render_count(self, view: TaskBrowserView) -> None:
|
|
1116
|
+
self._count.update(self._task_list_header(view))
|
|
1117
|
+
|
|
1118
|
+
def _task_list_header(self, view: TaskBrowserView) -> Text:
|
|
1119
|
+
is_all = view.filter is TaskBrowserFilter.ALL
|
|
1120
|
+
return task_list_header(
|
|
1121
|
+
name=view.filter.value,
|
|
1122
|
+
marker=None if is_all else status_marker(view.filter.value),
|
|
1123
|
+
search=view.search,
|
|
1124
|
+
width=self.size.width,
|
|
1125
|
+
keys_only=self._keys_only(),
|
|
1126
|
+
)
|
|
1127
|
+
|
|
1128
|
+
def _keys_only(self) -> bool:
|
|
1129
|
+
return cast("App[object]", self.app).size.height <= HEADER_TINY_MAX_ROWS
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
class QueueWidget:
|
|
1133
|
+
"""The `queue` dashboard widget plugin."""
|
|
1134
|
+
|
|
1135
|
+
name = "queue"
|
|
1136
|
+
placement = WidgetPlacement.TASK_LIST
|
|
1137
|
+
|
|
1138
|
+
def create(self, context: WidgetContext) -> Widget:
|
|
1139
|
+
"""Build the task browser for `context` while retaining the `queue` entry point."""
|
|
1140
|
+
return QueuePanel(context)
|