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.
Files changed (319) hide show
  1. shikiru/__init__.py +1 -0
  2. shikiru/__main__.py +8 -0
  3. shikiru/agents/__init__.py +3 -0
  4. shikiru/agents/contracts.py +590 -0
  5. shikiru/agents/registry.py +87 -0
  6. shikiru/agents/sdk/__init__.py +17 -0
  7. shikiru/agents/sdk/reset_time.py +137 -0
  8. shikiru/agents/sdk/turn.py +335 -0
  9. shikiru/agents/settings.py +151 -0
  10. shikiru/agents/testing/__init__.py +26 -0
  11. shikiru/agents/testing/contract_suite.py +653 -0
  12. shikiru/agents/testing/resume.py +15 -0
  13. shikiru/bootstrap.py +1277 -0
  14. shikiru/cli/__init__.py +1 -0
  15. shikiru/cli/app.py +770 -0
  16. shikiru/cli/context.py +63 -0
  17. shikiru/cli/render.py +1475 -0
  18. shikiru/cli/report.py +156 -0
  19. shikiru/cli/session_prompt.py +63 -0
  20. shikiru/cli/view.py +131 -0
  21. shikiru/clock.py +41 -0
  22. shikiru/config/__init__.py +8 -0
  23. shikiru/config/defaults.toml +131 -0
  24. shikiru/config/loader.py +178 -0
  25. shikiru/config/settings.py +392 -0
  26. shikiru/dashboard/__init__.py +3 -0
  27. shikiru/dashboard/actions_list.py +799 -0
  28. shikiru/dashboard/activity.py +150 -0
  29. shikiru/dashboard/app.py +1765 -0
  30. shikiru/dashboard/blocker_evidence.py +122 -0
  31. shikiru/dashboard/blocker_rows.py +84 -0
  32. shikiru/dashboard/briefing_view.py +871 -0
  33. shikiru/dashboard/contract.py +1086 -0
  34. shikiru/dashboard/covered.py +32 -0
  35. shikiru/dashboard/detail.py +1209 -0
  36. shikiru/dashboard/detail_fold.py +244 -0
  37. shikiru/dashboard/detail_hint.py +68 -0
  38. shikiru/dashboard/details_list_view.py +150 -0
  39. shikiru/dashboard/diagnostics.py +80 -0
  40. shikiru/dashboard/diagnostics_sections.py +186 -0
  41. shikiru/dashboard/evidence_table.py +123 -0
  42. shikiru/dashboard/extra_details.py +214 -0
  43. shikiru/dashboard/fault_guidance.py +216 -0
  44. shikiru/dashboard/fold_bodies.py +276 -0
  45. shikiru/dashboard/help_keys.py +166 -0
  46. shikiru/dashboard/help_screen.py +204 -0
  47. shikiru/dashboard/interaction.py +17 -0
  48. shikiru/dashboard/ladder_editor.py +544 -0
  49. shikiru/dashboard/live_settings.py +62 -0
  50. shikiru/dashboard/motion.py +368 -0
  51. shikiru/dashboard/orchestration_fields.py +201 -0
  52. shikiru/dashboard/palette.py +1606 -0
  53. shikiru/dashboard/palette_app.py +89 -0
  54. shikiru/dashboard/palette_drafts.py +33 -0
  55. shikiru/dashboard/resolution.py +573 -0
  56. shikiru/dashboard/resolution_view.py +302 -0
  57. shikiru/dashboard/row_list.py +256 -0
  58. shikiru/dashboard/section_rows.py +173 -0
  59. shikiru/dashboard/services.py +665 -0
  60. shikiru/dashboard/session_history.py +402 -0
  61. shikiru/dashboard/shikiru_init_ascii_art.txt +40 -0
  62. shikiru/dashboard/task_picker.py +358 -0
  63. shikiru/dashboard/tick.py +59 -0
  64. shikiru/dashboard/timeline_filter.py +129 -0
  65. shikiru/dashboard/timeline_section.py +385 -0
  66. shikiru/dashboard/usage_body.py +269 -0
  67. shikiru/dashboard/views.py +2314 -0
  68. shikiru/dashboard/wizard.py +1389 -0
  69. shikiru/domain/__init__.py +1 -0
  70. shikiru/domain/acceptance.py +130 -0
  71. shikiru/domain/activity.py +28 -0
  72. shikiru/domain/attempts.py +333 -0
  73. shikiru/domain/briefing.py +257 -0
  74. shikiru/domain/commands.py +219 -0
  75. shikiru/domain/decisions.py +231 -0
  76. shikiru/domain/docker.py +262 -0
  77. shikiru/domain/engine_uptime.py +87 -0
  78. shikiru/domain/escalation.py +194 -0
  79. shikiru/domain/escalation_evidence.py +186 -0
  80. shikiru/domain/escalation_panel.py +144 -0
  81. shikiru/domain/events.py +71 -0
  82. shikiru/domain/evidence_export.py +89 -0
  83. shikiru/domain/faults.py +86 -0
  84. shikiru/domain/feedback.py +180 -0
  85. shikiru/domain/feedback_rounds.py +172 -0
  86. shikiru/domain/fencing.py +31 -0
  87. shikiru/domain/ladder.py +102 -0
  88. shikiru/domain/ladder_text.py +44 -0
  89. shikiru/domain/levels.py +44 -0
  90. shikiru/domain/motion.py +199 -0
  91. shikiru/domain/packet_evidence.py +377 -0
  92. shikiru/domain/pushes.py +47 -0
  93. shikiru/domain/quota_blocks.py +44 -0
  94. shikiru/domain/quota_waits.py +219 -0
  95. shikiru/domain/recovery.py +194 -0
  96. shikiru/domain/reports.py +498 -0
  97. shikiru/domain/results.py +217 -0
  98. shikiru/domain/runs.py +85 -0
  99. shikiru/domain/scheduling.py +231 -0
  100. shikiru/domain/status_tasks.py +40 -0
  101. shikiru/domain/steps.py +325 -0
  102. shikiru/domain/tasks.py +174 -0
  103. shikiru/domain/timeline.py +208 -0
  104. shikiru/domain/timeline_phrases.py +271 -0
  105. shikiru/domain/versions.py +18 -0
  106. shikiru/domain/workflow.py +372 -0
  107. shikiru/engine/__init__.py +0 -0
  108. shikiru/engine/acceptance.py +90 -0
  109. shikiru/engine/agents.py +61 -0
  110. shikiru/engine/cosmetic_decorator.py +99 -0
  111. shikiru/engine/daemon.py +409 -0
  112. shikiru/engine/dependency.py +37 -0
  113. shikiru/engine/escalation_filing.py +110 -0
  114. shikiru/engine/escalations.py +401 -0
  115. shikiru/engine/events.py +285 -0
  116. shikiru/engine/evidence.py +81 -0
  117. shikiru/engine/fault_reporter.py +144 -0
  118. shikiru/engine/feedback.py +577 -0
  119. shikiru/engine/feedback_audit.py +80 -0
  120. shikiru/engine/feedback_intake.py +95 -0
  121. shikiru/engine/feedback_snapshot.py +148 -0
  122. shikiru/engine/flow.py +87 -0
  123. shikiru/engine/handlers/__init__.py +33 -0
  124. shikiru/engine/handlers/base.py +148 -0
  125. shikiru/engine/handlers/engine.py +24 -0
  126. shikiru/engine/handlers/escalations.py +174 -0
  127. shikiru/engine/handlers/operator.py +81 -0
  128. shikiru/engine/handlers/tasks.py +237 -0
  129. shikiru/engine/history.py +128 -0
  130. shikiru/engine/integration.py +1234 -0
  131. shikiru/engine/integration_turns.py +337 -0
  132. shikiru/engine/layout_guard.py +362 -0
  133. shikiru/engine/lifecycle.py +60 -0
  134. shikiru/engine/monitor.py +317 -0
  135. shikiru/engine/notification_delivery.py +124 -0
  136. shikiru/engine/notification_feed.py +57 -0
  137. shikiru/engine/notifier.py +91 -0
  138. shikiru/engine/pull_request_closing.py +117 -0
  139. shikiru/engine/push_service.py +177 -0
  140. shikiru/engine/quota_probe_threads.py +79 -0
  141. shikiru/engine/quota_reconciler.py +210 -0
  142. shikiru/engine/quota_schedule.py +24 -0
  143. shikiru/engine/quota_waits.py +102 -0
  144. shikiru/engine/reconcile.py +91 -0
  145. shikiru/engine/recovery.py +284 -0
  146. shikiru/engine/retention.py +102 -0
  147. shikiru/engine/root_preparation.py +86 -0
  148. shikiru/engine/scheduler.py +320 -0
  149. shikiru/engine/session_guard.py +46 -0
  150. shikiru/engine/sessions.py +95 -0
  151. shikiru/engine/signalled.py +75 -0
  152. shikiru/engine/step_runner.py +140 -0
  153. shikiru/engine/supervisor.py +101 -0
  154. shikiru/engine/takeover.py +223 -0
  155. shikiru/engine/task_facts.py +46 -0
  156. shikiru/engine/tick_guard.py +95 -0
  157. shikiru/engine/titles.py +297 -0
  158. shikiru/engine/transitions.py +79 -0
  159. shikiru/engine/turn_context.py +206 -0
  160. shikiru/engine/turn_execution.py +30 -0
  161. shikiru/engine/turn_loop.py +230 -0
  162. shikiru/engine/turn_processes.py +79 -0
  163. shikiru/engine/turns.py +457 -0
  164. shikiru/errors.py +144 -0
  165. shikiru/helper/__init__.py +3 -0
  166. shikiru/helper/helper.py +182 -0
  167. shikiru/helper/models.py +11 -0
  168. shikiru/mcp/__init__.py +1 -0
  169. shikiru/mcp/server.py +196 -0
  170. shikiru/operator/__init__.py +33 -0
  171. shikiru/operator/attachment.py +24 -0
  172. shikiru/operator/commands.py +319 -0
  173. shikiru/operator/detectors.py +374 -0
  174. shikiru/operator/doctor.py +351 -0
  175. shikiru/operator/events.py +57 -0
  176. shikiru/operator/evidence_export.py +498 -0
  177. shikiru/operator/evidence_writer.py +78 -0
  178. shikiru/operator/lifecycle.py +294 -0
  179. shikiru/operator/plugin_checks.py +194 -0
  180. shikiru/operator/probe.py +287 -0
  181. shikiru/operator/queries.py +1601 -0
  182. shikiru/operator/services.py +36 -0
  183. shikiru/operator/sessions.py +378 -0
  184. shikiru/operator/strip_views.py +162 -0
  185. shikiru/operator/timeline.py +416 -0
  186. shikiru/operator/toml_text.py +62 -0
  187. shikiru/palette.py +18 -0
  188. shikiru/paths.py +91 -0
  189. shikiru/plugins/__init__.py +0 -0
  190. shikiru/plugins/agents/__init__.py +1 -0
  191. shikiru/plugins/agents/claude/__init__.py +5 -0
  192. shikiru/plugins/agents/claude/agent.py +251 -0
  193. shikiru/plugins/agents/claude/context.py +116 -0
  194. shikiru/plugins/agents/claude/headless.py +92 -0
  195. shikiru/plugins/agents/claude/hook.py +57 -0
  196. shikiru/plugins/agents/claude/model_catalog.py +105 -0
  197. shikiru/plugins/agents/claude/options.py +62 -0
  198. shikiru/plugins/agents/claude/turn.py +1018 -0
  199. shikiru/plugins/agents/codex/__init__.py +5 -0
  200. shikiru/plugins/agents/codex/agent.py +486 -0
  201. shikiru/plugins/agents/codex/headless.py +166 -0
  202. shikiru/plugins/agents/codex/home.py +14 -0
  203. shikiru/plugins/agents/codex/model_catalog.py +128 -0
  204. shikiru/plugins/agents/codex/options.py +94 -0
  205. shikiru/plugins/agents/codex/quota.py +195 -0
  206. shikiru/plugins/agents/codex/sandbox.py +31 -0
  207. shikiru/plugins/agents/codex/sandbox_probe.py +97 -0
  208. shikiru/plugins/agents/codex/turn.py +767 -0
  209. shikiru/plugins/agents/codex/usage.py +106 -0
  210. shikiru/plugins/agents/shared_settings.py +62 -0
  211. shikiru/plugins/widgets/__init__.py +3 -0
  212. shikiru/plugins/widgets/dialogue.py +144 -0
  213. shikiru/plugins/widgets/disk.py +123 -0
  214. shikiru/plugins/widgets/docker.py +225 -0
  215. shikiru/plugins/widgets/escalations.py +402 -0
  216. shikiru/plugins/widgets/faults.py +204 -0
  217. shikiru/plugins/widgets/header.py +321 -0
  218. shikiru/plugins/widgets/queue.py +1140 -0
  219. shikiru/plugins/widgets/queue_hint.py +93 -0
  220. shikiru/plugins/widgets/resources.py +105 -0
  221. shikiru/plugins/widgets/strips.py +82 -0
  222. shikiru/plugins/widgets/usage.py +176 -0
  223. shikiru/private.py +94 -0
  224. shikiru/process.py +535 -0
  225. shikiru/prompts/__init__.py +3 -0
  226. shikiru/prompts/renderer.py +85 -0
  227. shikiru/prompts/templates/_base.md.j2 +17 -0
  228. shikiru/prompts/templates/_macros.md.j2 +61 -0
  229. shikiru/prompts/templates/conflicts.md.j2 +22 -0
  230. shikiru/prompts/templates/consult.md.j2 +5 -0
  231. shikiru/prompts/templates/continue.md.j2 +4 -0
  232. shikiru/prompts/templates/feedback_analysis.md.j2 +29 -0
  233. shikiru/prompts/templates/feedback_rework.md.j2 +33 -0
  234. shikiru/prompts/templates/feedback_verify.md.j2 +33 -0
  235. shikiru/prompts/templates/handoff.md.j2 +23 -0
  236. shikiru/prompts/templates/implement.md.j2 +13 -0
  237. shikiru/prompts/templates/plan.md.j2 +30 -0
  238. shikiru/prompts/templates/rework.md.j2 +13 -0
  239. shikiru/prompts/templates/triage.md.j2 +20 -0
  240. shikiru/prompts/templates/verify.md.j2 +29 -0
  241. shikiru/store/__init__.py +1 -0
  242. shikiru/store/commands.py +242 -0
  243. shikiru/store/database.py +297 -0
  244. shikiru/store/escalations.py +240 -0
  245. shikiru/store/events.py +213 -0
  246. shikiru/store/feedback.py +266 -0
  247. shikiru/store/migrations/001_initial.sql +63 -0
  248. shikiru/store/migrations/002_headless_turns.sql +31 -0
  249. shikiru/store/migrations/003_drop_command_handled_at.sql +1 -0
  250. shikiru/store/migrations/004_repo_base_ref.sql +3 -0
  251. shikiru/store/migrations/005_complexity_explicit.sql +3 -0
  252. shikiru/store/migrations/006_events_kind_index.sql +1 -0
  253. shikiru/store/migrations/007_notification_cursor.sql +1 -0
  254. shikiru/store/migrations/008_reported_usage.sql +1 -0
  255. shikiru/store/migrations/009_billing_declarations.sql +2 -0
  256. shikiru/store/migrations/010_canonical_quota_windows.sql +3 -0
  257. shikiru/store/migrations/012_drop_task_quota_evidence.sql +2 -0
  258. shikiru/store/migrations/013_push_targets.sql +7 -0
  259. shikiru/store/migrations/014_agent_block_time.sql +6 -0
  260. shikiru/store/migrations/015_repair_orphan_agent_blocks.sql +3 -0
  261. shikiru/store/migrations/016_feedback_rounds.sql +29 -0
  262. shikiru/store/migrations/017_feedback_round_comments.sql +5 -0
  263. shikiru/store/migrations/__init__.py +1 -0
  264. shikiru/store/notification_cursor.py +29 -0
  265. shikiru/store/pushes.py +117 -0
  266. shikiru/store/quotas.py +153 -0
  267. shikiru/store/reader.py +310 -0
  268. shikiru/store/retention.py +92 -0
  269. shikiru/store/runs.py +227 -0
  270. shikiru/store/tasks.py +949 -0
  271. shikiru/telemetry/__init__.py +1 -0
  272. shikiru/telemetry/disk.py +167 -0
  273. shikiru/telemetry/docker.py +164 -0
  274. shikiru/telemetry/resources.py +296 -0
  275. shikiru/tmux/__init__.py +3 -0
  276. shikiru/tmux/client.py +637 -0
  277. shikiru/tmux/decorator.py +225 -0
  278. shikiru/tmux/layout.py +200 -0
  279. shikiru/tmux/session.py +381 -0
  280. shikiru/ui/__init__.py +9 -0
  281. shikiru/ui/age.py +45 -0
  282. shikiru/ui/bubbles.py +41 -0
  283. shikiru/ui/compact.py +1209 -0
  284. shikiru/ui/console.py +49 -0
  285. shikiru/ui/fold.py +50 -0
  286. shikiru/ui/gated_static.py +117 -0
  287. shikiru/ui/gauge.py +40 -0
  288. shikiru/ui/glyphs.py +109 -0
  289. shikiru/ui/keys.py +57 -0
  290. shikiru/ui/live_interval.py +42 -0
  291. shikiru/ui/popup.py +311 -0
  292. shikiru/ui/quota_status.py +26 -0
  293. shikiru/ui/recent_window.py +78 -0
  294. shikiru/ui/screen.py +57 -0
  295. shikiru/ui/section.py +193 -0
  296. shikiru/ui/session.py +65 -0
  297. shikiru/ui/size.py +17 -0
  298. shikiru/ui/splash.py +42 -0
  299. shikiru/ui/status.py +47 -0
  300. shikiru/ui/status_strip.py +149 -0
  301. shikiru/ui/stream.py +152 -0
  302. shikiru/ui/strip.py +332 -0
  303. shikiru/ui/theme.py +83 -0
  304. shikiru/ui/tokens.py +51 -0
  305. shikiru/view_log.py +126 -0
  306. shikiru/workspace/__init__.py +3 -0
  307. shikiru/workspace/base_refs.py +160 -0
  308. shikiru/workspace/caches.py +136 -0
  309. shikiru/workspace/facts.py +118 -0
  310. shikiru/workspace/git.py +601 -0
  311. shikiru/workspace/github.py +744 -0
  312. shikiru/workspace/messages.py +107 -0
  313. shikiru/workspace/reports.py +145 -0
  314. shikiru/workspace/roots.py +180 -0
  315. shikiru_cli-0.2.0.dist-info/METADATA +428 -0
  316. shikiru_cli-0.2.0.dist-info/RECORD +319 -0
  317. shikiru_cli-0.2.0.dist-info/WHEEL +4 -0
  318. shikiru_cli-0.2.0.dist-info/entry_points.txt +18 -0
  319. 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)