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,358 @@
1
+ """The Task picker popup: choose one project task by ID or title."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, ClassVar
6
+
7
+ from rich.cells import cell_len
8
+ from rich.text import Text
9
+ from textual.binding import Binding
10
+ from textual.containers import Horizontal
11
+ from textual.screen import ModalScreen
12
+ from textual.widgets import Button, Input, Label, ListItem, ListView, Rule, Static
13
+
14
+ from shikiru.plugins.widgets.queue import status_marker
15
+ from shikiru.plugins.widgets.queue_hint import MORE_KEY, load_more_hint
16
+ from shikiru.ui.keys import BOLD_DISABLED_KEY, key_hint
17
+ from shikiru.ui.popup import (
18
+ POPUP_BODY_CLASS,
19
+ POPUP_CLOSE_CLASS,
20
+ POPUP_CSS,
21
+ POPUP_FRAME_CLASS,
22
+ POPUP_HEADER_CLASS,
23
+ POPUP_RULE_CLASS,
24
+ POPUP_TITLE_CLASS,
25
+ PopupFrame,
26
+ popup_close_label,
27
+ prepare_popup,
28
+ )
29
+ from shikiru.ui.section import BOLD_PRIMARY, WrappedGroups, grey, key_label
30
+
31
+ if TYPE_CHECKING:
32
+ from rich.console import Console, ConsoleOptions, RenderResult
33
+ from textual import events
34
+ from textual.app import ComposeResult
35
+ from textual.binding import BindingType
36
+
37
+ from shikiru.operator.queries import OperatorQueries, TaskSummary
38
+
39
+ PICKER_TITLE = "Switch task"
40
+ PICKER_FRAME = "task-picker-frame"
41
+ PICKER_CLOSE_BUTTON = "task-picker-close"
42
+ PICKER_SEARCH = "task-picker-search"
43
+ PICKER_LIST = "task-picker-list"
44
+ PICKER_EMPTY = "task-picker-empty"
45
+ PICKER_HINT = "task-picker-hint"
46
+ PICKER_PAGE_LIMIT = 20
47
+ PICKER_VISIBLE_ROWS = 10
48
+ SEARCH_KEY = "/"
49
+ SEARCH_LABEL = "search"
50
+ HINT_GAP = " "
51
+ TINY_HINT_GAP = " "
52
+ COMPACT_CLASS = "compact"
53
+ TINY_CLASS = "tiny"
54
+ COMPACT_MAX_COLUMNS = 48
55
+ COMPACT_MAX_ROWS = 20
56
+ TINY_MAX_COLUMNS = 32
57
+ TINY_MAX_ROWS = 10
58
+ MIN_TITLE_CELLS = 8
59
+ NAME_GAP = " "
60
+ PAGE_OVERLAP_ROWS = 1
61
+ CLOSE_ACTION = "close"
62
+ MOVE_ACTION = "move"
63
+ PAGE_ACTION = "page"
64
+ JUMP_ACTION = "jump"
65
+ LOAD_MORE_ACTION = "load_more"
66
+ OPEN_SEARCH_ACTION = "open_search"
67
+
68
+
69
+ def picker_row_text(task: TaskSummary, width: int) -> Text:
70
+ """Return `glyph T-n title status` on one line of `width` cells, dropping the status last."""
71
+ marker, marker_style = status_marker(task.status.value)
72
+ head = Text.assemble((marker, marker_style), NAME_GAP, (task.id, BOLD_PRIMARY), NAME_GAP)
73
+ tail = Text.assemble(NAME_GAP, grey(task.status.value.replace("_", NAME_GAP)))
74
+ room = width - cell_len(head.plain) - cell_len(tail.plain)
75
+ if room < MIN_TITLE_CELLS:
76
+ tail = Text("")
77
+ room = width - cell_len(head.plain)
78
+ title = Text(task.title.replace("\n", NAME_GAP))
79
+ title.truncate(max(room, 0), overflow="ellipsis", pad=True)
80
+ return Text.assemble(head, title, tail, no_wrap=True, overflow="ellipsis")
81
+
82
+
83
+ class _RowText:
84
+ def __init__(self, task: TaskSummary) -> None:
85
+ self._task = task
86
+
87
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
88
+ del console
89
+ yield picker_row_text(self._task, options.max_width)
90
+
91
+
92
+ class PickerRow(ListItem):
93
+ def __init__(self, task: TaskSummary) -> None:
94
+ super().__init__(Static(_RowText(task), markup=False))
95
+ self.task_id = task.id
96
+
97
+
98
+ class _PickerList(ListView):
99
+ def on_resize(self) -> None:
100
+ self.call_after_refresh(self._reveal_selection)
101
+
102
+ def _reveal_selection(self) -> None:
103
+ if self.highlighted_child is not None:
104
+ self.highlighted_child.scroll_visible(animate=False)
105
+
106
+
107
+ class TaskPickerScreen(ModalScreen[str | None]):
108
+ """List project tasks one per line; Enter dismisses with the chosen task ID."""
109
+
110
+ BINDINGS: ClassVar[list[BindingType]] = [
111
+ Binding("escape", CLOSE_ACTION, "Close"),
112
+ Binding("up", f"{MOVE_ACTION}(-1)", "Up", show=False, priority=True),
113
+ Binding("down", f"{MOVE_ACTION}(1)", "Down", show=False, priority=True),
114
+ Binding("pageup", f"{PAGE_ACTION}(-1)", "Page up", show=False, priority=True),
115
+ Binding("pagedown", f"{PAGE_ACTION}(1)", "Page down", show=False, priority=True),
116
+ Binding("home", f"{JUMP_ACTION}(-1)", "First", show=False, priority=True),
117
+ Binding("end", f"{JUMP_ACTION}(1)", "Last", show=False, priority=True),
118
+ Binding(SEARCH_KEY, OPEN_SEARCH_ACTION, "Search", show=False),
119
+ Binding(MORE_KEY, LOAD_MORE_ACTION, "Load more", show=False),
120
+ ]
121
+ DEFAULT_CSS = (
122
+ POPUP_CSS
123
+ + f"""
124
+ #{PICKER_SEARCH} {{ height: 1; border: none; padding: 0; margin-bottom: 1; }}
125
+ #{PICKER_LIST} {{
126
+ border: none; padding: 0; background: $surface;
127
+ scrollbar-size-vertical: $scrollbar-width;
128
+ }}
129
+ #{PICKER_LIST} > ListItem {{
130
+ height: 1; padding: 0; border: none; background: $surface; color: $primary-text;
131
+ }}
132
+ #{PICKER_LIST} > ListItem > Static {{
133
+ width: 100%; height: 1; padding: 0; color: $primary-text;
134
+ text-wrap: nowrap; text-overflow: ellipsis;
135
+ }}
136
+ #{PICKER_LIST} > ListItem.-highlight,
137
+ #{PICKER_LIST} > ListItem.-highlight > Static {{
138
+ outline: none; background: $selected-surface; color: $primary-text; text-style: none;
139
+ }}
140
+ #{PICKER_LIST}:focus {{ background-tint: transparent; }}
141
+ #{PICKER_EMPTY} {{ height: auto; color: $muted-text; }}
142
+ #{PICKER_HINT} {{ height: auto; margin-top: 1; }}
143
+ #{PICKER_FRAME}.{COMPACT_CLASS} {{ margin: 0 1; padding: 0 1; }}
144
+ #{PICKER_FRAME}.{TINY_CLASS} #{PICKER_SEARCH} {{ margin-bottom: 0; }}
145
+ #{PICKER_FRAME}.{TINY_CLASS} #{PICKER_HINT} {{ margin-top: 0; }}
146
+ """
147
+ )
148
+
149
+ def __init__(self, queries: OperatorQueries, current_task_id: str | None = None) -> None:
150
+ super().__init__()
151
+ prepare_popup(self)
152
+ self._queries = queries
153
+ self._current_task_id = current_task_id
154
+ self._search = ""
155
+ self._tasks: tuple[TaskSummary, ...] = ()
156
+ self._has_more = False
157
+
158
+ def compose(self) -> ComposeResult:
159
+ """Render the header, the hidden search field, the task rows and the hint line."""
160
+ with PopupFrame(widget_id=PICKER_FRAME, classes=POPUP_FRAME_CLASS):
161
+ with Horizontal(classes=POPUP_HEADER_CLASS):
162
+ yield Label(PICKER_TITLE, classes=POPUP_TITLE_CLASS)
163
+ yield Button(popup_close_label(), id=PICKER_CLOSE_BUTTON, classes=POPUP_CLOSE_CLASS)
164
+ yield Rule(classes=POPUP_RULE_CLASS)
165
+ yield Static(id=PICKER_EMPTY, markup=False)
166
+ yield _PickerList(id=PICKER_LIST, classes=POPUP_BODY_CLASS)
167
+ yield Static(id=PICKER_HINT, markup=False)
168
+
169
+ async def on_mount(self) -> None:
170
+ """Load the first page, select the current task and size the popup."""
171
+ await self._reload()
172
+ self._list.focus()
173
+
174
+ def on_resize(self, event: events.Resize) -> None:
175
+ self._apply_compact_layout(event.size.width, event.size.height)
176
+
177
+ def _apply_compact_layout(self, width: int, height: int) -> None:
178
+ frame = self.query_one(f"#{PICKER_FRAME}", PopupFrame)
179
+ compact = width <= COMPACT_MAX_COLUMNS or height <= COMPACT_MAX_ROWS
180
+ tiny = height <= TINY_MAX_ROWS
181
+ frame.set_class(compact, COMPACT_CLASS)
182
+ frame.set_class(tiny, TINY_CLASS)
183
+ frame.styles.max_height = max(1, height - frame.styles.margin.height)
184
+ self._list.styles.height = min(PICKER_VISIBLE_ROWS, len(self._tasks))
185
+ self._paint_hint()
186
+ frame.reset_preferred_size()
187
+ self.call_after_refresh(self._fit_content_to_viewport)
188
+
189
+ def _fit_content_to_viewport(self) -> None:
190
+ frame = self.query_one(f"#{PICKER_FRAME}", PopupFrame)
191
+ height = self.size.height
192
+ fixed_height = sum(
193
+ child.region.height + child.styles.margin.height
194
+ for child in frame.children
195
+ if child is not self._list and child.display
196
+ )
197
+ available_rows = max(
198
+ 0,
199
+ height - frame.styles.margin.height - frame.styles.gutter.height - fixed_height,
200
+ )
201
+ list_height = min(PICKER_VISIBLE_ROWS, len(self._tasks), available_rows)
202
+ self._list.styles.height = list_height
203
+ content_height = fixed_height + list_height + frame.styles.gutter.height
204
+ frame.styles.max_height = max(
205
+ 1,
206
+ min(height - frame.styles.margin.height, content_height),
207
+ )
208
+ frame.content_ready()
209
+
210
+ def _keys_only(self, width: int, height: int) -> bool:
211
+ return width <= TINY_MAX_COLUMNS or height <= TINY_MAX_ROWS
212
+
213
+ @property
214
+ def _list(self) -> ListView:
215
+ return self.query_one(f"#{PICKER_LIST}", ListView)
216
+
217
+ @property
218
+ def _search_open(self) -> bool:
219
+ return bool(self.query(f"#{PICKER_SEARCH}"))
220
+
221
+ async def _reload(self) -> None:
222
+ self._tasks = ()
223
+ await self._list.clear()
224
+ await self._append_page(preselect=self._current_task_id)
225
+
226
+ async def _append_page(self, *, preselect: str | None = None) -> None:
227
+ page = self._queries.search_task_targets(
228
+ self._search, limit=PICKER_PAGE_LIMIT, offset=len(self._tasks)
229
+ )
230
+ known = {task.id for task in self._tasks}
231
+ added = tuple(task for task in page.tasks if task.id not in known)
232
+ self._tasks += added
233
+ self._has_more = page.has_more
234
+ await self._list.extend(PickerRow(task) for task in added)
235
+ if self._list.index is None and self._tasks:
236
+ self._list.index = self._index_of(preselect)
237
+ self._paint_empty(page.project_count)
238
+ self._apply_compact_layout(self.size.width, self.size.height)
239
+
240
+ def _index_of(self, task_id: str | None) -> int:
241
+ return next((index for index, task in enumerate(self._tasks) if task.id == task_id), 0)
242
+
243
+ def _paint_empty(self, project_count: int) -> None:
244
+ empty = self.query_one(f"#{PICKER_EMPTY}", Static)
245
+ empty.display = not self._tasks
246
+ if self._tasks:
247
+ return
248
+ message = "No tasks" if project_count == 0 else "No matches"
249
+ empty.update(message)
250
+
251
+ def _paint_hint(self) -> None:
252
+ if not self.is_attached:
253
+ return
254
+ has_rows = bool(self._tasks)
255
+ keys_only = self._keys_only(self.size.width, self.size.height)
256
+ choose = (
257
+ key_hint("Enter keep filter")
258
+ if self._search_open
259
+ else key_hint("Enter choose", enabled=has_rows)
260
+ )
261
+ groups = self._hint_groups(has_rows, choose, keys_only)
262
+ gap = TINY_HINT_GAP if keys_only else HINT_GAP
263
+ self.query_one(f"#{PICKER_HINT}", Static).update(WrappedGroups(groups, gap))
264
+
265
+ def _hint_groups(self, has_rows: bool, choose: Text, keys_only: bool) -> tuple[Text, ...]:
266
+ if keys_only:
267
+ return (
268
+ key_hint("↑↓", enabled=has_rows),
269
+ Text.assemble(
270
+ key_hint("Enter", enabled=has_rows or self._search_open),
271
+ key_label(SEARCH_KEY, ""),
272
+ self._load_more_hint(keys_only=True),
273
+ ),
274
+ )
275
+ return (
276
+ key_hint("↑↓ select", enabled=has_rows),
277
+ choose,
278
+ key_label(SEARCH_KEY, SEARCH_LABEL),
279
+ self._load_more_hint(keys_only=False),
280
+ )
281
+
282
+ def _load_more_hint(self, *, keys_only: bool) -> Text:
283
+ if self._has_more:
284
+ return load_more_hint(keys_only=keys_only)
285
+ inert = Text(MORE_KEY, style=BOLD_DISABLED_KEY)
286
+ return inert if keys_only else Text.assemble(inert, grey(" load more"))
287
+
288
+ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
289
+ """Leave Home and End to the search field's cursor while it has the keyboard."""
290
+ del parameters
291
+ return not (action == JUMP_ACTION and isinstance(self.focused, Input))
292
+
293
+ def action_move(self, step: int) -> None:
294
+ """Move the selection by `step` rows, stopping at the first and last loaded task."""
295
+ if self._tasks:
296
+ self._list.index = min(max((self._list.index or 0) + step, 0), len(self._tasks) - 1)
297
+
298
+ def action_page(self, direction: int) -> None:
299
+ """Move the selection by one visible page up (`-1`) or down (`1`)."""
300
+ self.action_move(direction * max(1, self._list.size.height - PAGE_OVERLAP_ROWS))
301
+
302
+ def action_jump(self, direction: int) -> None:
303
+ """Select the first (`-1`) or last (`1`) loaded task."""
304
+ if self._tasks:
305
+ self._list.index = 0 if direction < 0 else len(self._tasks) - 1
306
+
307
+ async def action_open_search(self) -> None:
308
+ """Show the search field under the title and focus it."""
309
+ if not self._search_open:
310
+ search = Input(
311
+ value=self._search,
312
+ placeholder="Search task ID or title",
313
+ id=PICKER_SEARCH,
314
+ )
315
+ await self.mount(search, before=f"#{PICKER_EMPTY}")
316
+ self.query_one(f"#{PICKER_SEARCH}", Input).focus()
317
+ self._apply_compact_layout(self.size.width, self.size.height)
318
+
319
+ async def action_load_more(self) -> None:
320
+ """Append the next bounded page, keeping the selection."""
321
+ if self._has_more:
322
+ await self._append_page()
323
+
324
+ async def action_close(self) -> None:
325
+ """Clear and close the search first; a second press closes the popup."""
326
+ if not self._search_open:
327
+ self.dismiss(None)
328
+ return
329
+ self._search = ""
330
+ await self._close_search()
331
+ await self._reload()
332
+
333
+ async def _close_search(self) -> None:
334
+ await self.query_one(f"#{PICKER_SEARCH}", Input).remove()
335
+ self._apply_compact_layout(self.size.width, self.size.height)
336
+ self._list.focus()
337
+
338
+ async def on_input_changed(self, event: Input.Changed) -> None:
339
+ """Narrow the list to tasks whose ID or title match the typed text."""
340
+ search = event.value.strip()
341
+ if search == self._search:
342
+ return
343
+ self._search = search
344
+ await self._reload()
345
+
346
+ async def on_input_submitted(self) -> None:
347
+ """Close the search field and keep its filter."""
348
+ await self._close_search()
349
+
350
+ def on_list_view_selected(self, event: ListView.Selected) -> None:
351
+ """Dismiss with the ID of the task chosen with Enter or a click."""
352
+ if isinstance(event.item, PickerRow):
353
+ self.dismiss(event.item.task_id)
354
+
355
+ def on_button_pressed(self, event: Button.Pressed) -> None:
356
+ """Close the popup from its close control."""
357
+ if event.button.id == PICKER_CLOSE_BUTTON:
358
+ self.dismiss(None)
@@ -0,0 +1,59 @@
1
+ """The instant shared by every motion callback of one tick."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextlib import contextmanager
6
+ from contextvars import ContextVar
7
+ from typing import TYPE_CHECKING, cast
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import Callable, Generator, Hashable
11
+ from datetime import datetime
12
+
13
+ from shikiru.clock import Clock
14
+
15
+ _TICK_INSTANT: ContextVar[datetime | None] = ContextVar("tick_instant", default=None)
16
+
17
+
18
+ def current_tick() -> datetime | None:
19
+ """Return the instant of the tick the caller runs in, or None outside any tick."""
20
+ return _TICK_INSTANT.get()
21
+
22
+
23
+ def tick_now(clock: Clock) -> datetime:
24
+ """Return the tick's instant inside a tick, otherwise the clock's current time."""
25
+ instant = _TICK_INSTANT.get()
26
+ return instant if instant is not None else clock.now()
27
+
28
+
29
+ @contextmanager
30
+ def pinned_tick(instant: datetime) -> Generator[None]:
31
+ """Make tasks created inside the block see `instant` as their tick."""
32
+ token = _TICK_INSTANT.set(instant)
33
+ try:
34
+ yield
35
+ finally:
36
+ _TICK_INSTANT.reset(token)
37
+
38
+
39
+ class TickMemo:
40
+ """Values built once per tick and data version, never reused outside a tick."""
41
+
42
+ def __init__(self, data_version: Callable[[], int]) -> None:
43
+ """Key the memo on the tick and on the `data_version` it reads."""
44
+ self._data_version = data_version
45
+ self._key: tuple[datetime, int] | None = None
46
+ self._values: dict[Hashable, object] = {}
47
+
48
+ def get[V](self, name: Hashable, load: Callable[[], V]) -> V:
49
+ """Return the value for `name` built by `load` earlier in this tick, else build it."""
50
+ tick = _TICK_INSTANT.get()
51
+ if tick is None:
52
+ return load()
53
+ key = (tick, self._data_version())
54
+ if key != self._key:
55
+ self._key = key
56
+ self._values = {}
57
+ if name not in self._values:
58
+ self._values[name] = load()
59
+ return cast("V", self._values[name])
@@ -0,0 +1,129 @@
1
+ """The one-line text filter of the Agent activity list: `role: any · turn: any`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, ClassVar
6
+
7
+ from rich.text import Text
8
+ from textual.binding import Binding
9
+ from textual.message import Message
10
+ from textual.widgets import Static
11
+
12
+ from shikiru.dashboard.section_rows import ROLE_LABELS, turn_label
13
+ from shikiru.domain.tasks import Role
14
+ from shikiru.ui.section import BOLD_STEEL, GROUP_SEPARATOR, grey
15
+ from shikiru.ui.tokens import PRIMARY_TEXT_COLOR
16
+
17
+ if TYPE_CHECKING:
18
+ from collections.abc import Sequence
19
+
20
+ from textual.binding import BindingType
21
+
22
+ ANY_LABEL = "any"
23
+ ROLE_CHOICES = (Role.LEADER.value, Role.IMPLEMENTER.value, Role.HELPER.value)
24
+ ROLE_NAME = "role:"
25
+ TURN_NAME = "turn:"
26
+ CYCLE_ROLE_KEY = "r"
27
+ CYCLE_TURN_KEY = "t"
28
+ CLEAR_KEY = "x"
29
+ CLOSE_KEY = "f"
30
+
31
+
32
+ def next_choice(current: str | None, choices: Sequence[str]) -> str | None:
33
+ """Return the choice after `current`; `None` (any) follows the last, or starts the cycle."""
34
+ cycle = [*choices, None]
35
+ return cycle[choices.index(current) + 1] if current in choices else cycle[0]
36
+
37
+
38
+ class TimelineFilterBar(Static, can_focus=True):
39
+ """Role and turn filter shown as text; `r` and `t` cycle them, `x` clears, `f` closes."""
40
+
41
+ BINDINGS: ClassVar[list[BindingType]] = [
42
+ Binding(CYCLE_ROLE_KEY, "cycle_role", "Role", show=False),
43
+ Binding(CYCLE_TURN_KEY, "cycle_turn", "Turn", show=False),
44
+ Binding(CLEAR_KEY, "clear", "Clear", show=False),
45
+ Binding(CLOSE_KEY, "close", "Close", show=False),
46
+ ]
47
+ DEFAULT_CSS = """
48
+ TimelineFilterBar { height: 1; width: 100%; margin: 1 0 0 0; }
49
+ TimelineFilterBar:focus { background-tint: transparent; }
50
+ """
51
+
52
+ class Changed(Message):
53
+ """The role or turn filter changed."""
54
+
55
+ def __init__(self, role: str | None, turn: str | None) -> None:
56
+ super().__init__()
57
+ self.role = role
58
+ self.turn = turn
59
+
60
+ class Closed(Message):
61
+ """The operator closed the bar."""
62
+
63
+ def __init__(self, *, widget_id: str) -> None:
64
+ """Create a hidden bar with no filter."""
65
+ super().__init__("", id=widget_id, markup=False)
66
+ self.display = False
67
+ self._role: str | None = None
68
+ self._turn: str | None = None
69
+ self._turns: tuple[str, ...] = ()
70
+
71
+ @property
72
+ def active(self) -> bool:
73
+ """Whether a role or turn filter is applied."""
74
+ return self._role is not None or self._turn is not None
75
+
76
+ def on_mount(self) -> None:
77
+ self._paint()
78
+
79
+ def on_focus(self) -> None:
80
+ self._paint()
81
+
82
+ def on_blur(self) -> None:
83
+ self._paint()
84
+
85
+ def set_turns(self, turns: Sequence[str]) -> None:
86
+ """Set the turns `t` cycles through."""
87
+ self._turns = tuple(turns)
88
+
89
+ def reset(self) -> None:
90
+ """Forget the filter and the known turns."""
91
+ self._role = self._turn = None
92
+ self._turns = ()
93
+ self._paint()
94
+
95
+ def action_cycle_role(self) -> None:
96
+ """Pick the next role, or any."""
97
+ self._set(next_choice(self._role, ROLE_CHOICES), self._turn)
98
+
99
+ def action_cycle_turn(self) -> None:
100
+ """Pick the next seen turn, or any."""
101
+ self._set(self._role, next_choice(self._turn, self._turns))
102
+
103
+ def action_clear(self) -> None:
104
+ """Drop both filters."""
105
+ self._set(None, None)
106
+
107
+ def action_close(self) -> None:
108
+ """Ask the owner to hide the bar."""
109
+ self.post_message(self.Closed())
110
+
111
+ def _set(self, role: str | None, turn: str | None) -> None:
112
+ if (role, turn) == (self._role, self._turn):
113
+ return
114
+ self._role, self._turn = role, turn
115
+ self._paint()
116
+ self.post_message(self.Changed(role, turn))
117
+
118
+ def _paint(self) -> None:
119
+ name_style = BOLD_STEEL if self.has_focus else ""
120
+ role = ROLE_LABELS[self._role] if self._role is not None else ANY_LABEL
121
+ line = Text()
122
+ for index, (name, value) in enumerate(
123
+ ((ROLE_NAME, role), (TURN_NAME, turn_label(self._turn) or ANY_LABEL))
124
+ ):
125
+ if index:
126
+ line.append_text(grey(GROUP_SEPARATOR))
127
+ line.append_text(Text(name, style=name_style) if name_style else grey(name))
128
+ line.append(f" {value}", style=PRIMARY_TEXT_COLOR)
129
+ self.update(line)