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
shikiru/ui/strip.py ADDED
@@ -0,0 +1,332 @@
1
+ """The task strip renderable."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import StrEnum, auto
7
+ from typing import TYPE_CHECKING
8
+
9
+ from rich.cells import cell_len
10
+ from rich.text import Text
11
+
12
+ from shikiru.domain.tasks import Mode, Role
13
+ from shikiru.ui.glyphs import (
14
+ GLYPH_DONE,
15
+ GLYPH_ESCALATED,
16
+ GLYPH_OPERATOR,
17
+ GLYPH_PAUSED,
18
+ GLYPH_PENDING,
19
+ GLYPH_STALLED,
20
+ PANEL_KANJI,
21
+ ROLE_GLYPHS,
22
+ STAGE_IMPLEMENT,
23
+ STAGE_MERGE,
24
+ STAGE_PLAN,
25
+ STAGE_TRIAGE,
26
+ STAGE_VERIFY,
27
+ )
28
+ from shikiru.ui.tokens import (
29
+ ALERT_COLOR,
30
+ ENGINE_COLOR,
31
+ HOLD_COLOR,
32
+ IDLE_COLOR,
33
+ ROLE_COLORS,
34
+ STYLE_BOLD,
35
+ STYLE_DIM,
36
+ STYLE_REVERSE,
37
+ SUCCESS_COLOR,
38
+ )
39
+
40
+ if TYPE_CHECKING:
41
+ from datetime import datetime
42
+
43
+ STRIP_WIDE_MIN_COLUMNS = 60
44
+
45
+
46
+ class PanelState(StrEnum):
47
+ """One panel's progress."""
48
+
49
+ PENDING = auto()
50
+ ACTIVE = auto()
51
+ DONE = auto()
52
+ ESCALATED = auto()
53
+ STALLED = auto()
54
+
55
+
56
+ class StampKind(StrEnum):
57
+ """A hold drawn over the active panel."""
58
+
59
+ PAUSED = auto()
60
+ OPERATOR = auto()
61
+ VERDICT = auto()
62
+ MERGED = auto()
63
+
64
+
65
+ _STATE_GLYPHS: dict[PanelState, str] = {
66
+ PanelState.PENDING: GLYPH_PENDING,
67
+ PanelState.DONE: GLYPH_DONE,
68
+ PanelState.ESCALATED: GLYPH_ESCALATED,
69
+ PanelState.STALLED: GLYPH_STALLED,
70
+ }
71
+ _STATE_STYLES: dict[PanelState, str] = {
72
+ PanelState.PENDING: f"{STYLE_DIM} {IDLE_COLOR}",
73
+ PanelState.DONE: f"{STYLE_BOLD} {SUCCESS_COLOR}",
74
+ PanelState.ESCALATED: f"{STYLE_REVERSE} {ALERT_COLOR}",
75
+ PanelState.STALLED: f"{STYLE_BOLD} {HOLD_COLOR}",
76
+ }
77
+ _STAMP_GLYPHS: dict[StampKind, str] = {
78
+ StampKind.PAUSED: GLYPH_PAUSED,
79
+ StampKind.OPERATOR: GLYPH_OPERATOR,
80
+ StampKind.VERDICT: GLYPH_DONE,
81
+ StampKind.MERGED: GLYPH_DONE,
82
+ }
83
+ _STAMP_WORDS: dict[StampKind, str] = {
84
+ StampKind.PAUSED: "PAUSED",
85
+ StampKind.OPERATOR: "OPERATOR",
86
+ StampKind.VERDICT: "VERDICT",
87
+ StampKind.MERGED: "MERGED",
88
+ }
89
+
90
+ _NARROW_SEPARATOR = " "
91
+ _HEADER_SEPARATOR = " · "
92
+
93
+ _BORDER_TOP_LEFT = "┏"
94
+ _BORDER_TOP_RIGHT = "┓"
95
+ _BORDER_BOTTOM_LEFT = "┗"
96
+ _BORDER_BOTTOM_RIGHT = "┛"
97
+ _BORDER_BOTTOM_JOIN = "┻"
98
+ _BORDER_HORIZONTAL = "━"
99
+ _BORDER_VERTICAL = "┃"
100
+ _TOP_BORDER_PREFIX = f"{_BORDER_TOP_LEFT}{_BORDER_HORIZONTAL} "
101
+
102
+ _MODE_PANEL_LABELS: dict[Mode, tuple[tuple[str, str], ...]] = {
103
+ Mode.IMPLEMENT: (
104
+ ("Triage", STAGE_TRIAGE),
105
+ ("Implement", STAGE_IMPLEMENT),
106
+ ("Verify", STAGE_VERIFY),
107
+ ("Merge", STAGE_MERGE),
108
+ ),
109
+ Mode.PLAN: (
110
+ ("Triage", STAGE_TRIAGE),
111
+ ("Plan", STAGE_PLAN),
112
+ ("—", ""),
113
+ ("Merge", STAGE_MERGE),
114
+ ),
115
+ }
116
+
117
+
118
+ def panel_specs_for_mode(mode: Mode) -> tuple[tuple[str, str], ...]:
119
+ """Return this mode's `(label, stage glyph)` panels."""
120
+ return _MODE_PANEL_LABELS[mode]
121
+
122
+
123
+ @dataclass(frozen=True)
124
+ class PanelView:
125
+ """One panel of a task strip.
126
+
127
+ `role` is the acting role, shown as the panel colour while `state` is `ACTIVE`; other
128
+ states carry a fixed colour regardless of role.
129
+ """
130
+
131
+ label: str
132
+ state: PanelState
133
+ stage_glyph: str = ""
134
+ detail: str = ""
135
+ role_line: str = ""
136
+ spinner_frame: str = ""
137
+ role: Role | None = None
138
+
139
+
140
+ @dataclass(frozen=True)
141
+ class StripStamp:
142
+ """A hold drawn over the strip's active panel."""
143
+
144
+ kind: StampKind
145
+ detail: str = ""
146
+ flash: bool = False
147
+
148
+ def render(self) -> str:
149
+ """Render as `<glyph> <WORD>`, with `detail` appended when set (e.g. a reset time)."""
150
+ text = f"{_STAMP_GLYPHS[self.kind]} {_STAMP_WORDS[self.kind]}"
151
+ return f"{text} · {self.detail}" if self.detail else text
152
+
153
+
154
+ @dataclass(frozen=True)
155
+ class TaskStripData:
156
+ """Everything `TaskStrip` needs to render one task, already resolved by its caller."""
157
+
158
+ task_id: str
159
+ title: str
160
+ panels: tuple[PanelView, ...] = ()
161
+ stamp: StripStamp | None = None
162
+ actual_step_started_at: datetime | None = None
163
+ last_signal_at: datetime | None = None
164
+ last_signal: str | None = None
165
+ last_words: str | None = None
166
+
167
+
168
+ def _fit(text: str, width: int) -> str:
169
+ if cell_len(text) <= width:
170
+ return text + " " * (width - cell_len(text))
171
+ truncated = Text(text)
172
+ truncated.truncate(width, overflow="crop")
173
+ return truncated.plain
174
+
175
+
176
+ def _panel_style(panel: PanelView) -> str:
177
+ if panel.state is PanelState.ACTIVE:
178
+ color = ROLE_COLORS[panel.role] if panel.role is not None else ENGINE_COLOR
179
+ return f"{STYLE_BOLD} {color}"
180
+ return _STATE_STYLES[panel.state]
181
+
182
+
183
+ def _narrow_glyph(panel: PanelView) -> str:
184
+ if panel.state is PanelState.ACTIVE:
185
+ return panel.spinner_frame or GLYPH_PENDING
186
+ return _STATE_GLYPHS[panel.state]
187
+
188
+
189
+ def _header_cell(index: int, panel: PanelView) -> str:
190
+ pieces = (PANEL_KANJI[index], panel.stage_glyph, panel.label)
191
+ return " ".join(piece for piece in pieces if piece)
192
+
193
+
194
+ def _body_cell(panel: PanelView) -> str:
195
+ if panel.state is PanelState.ACTIVE:
196
+ role_glyph = ROLE_GLYPHS[panel.role] if panel.role is not None else ""
197
+ pieces = (panel.spinner_frame or GLYPH_PENDING, role_glyph, panel.detail)
198
+ else:
199
+ pieces = (_STATE_GLYPHS[panel.state], panel.detail)
200
+ return " ".join(piece for piece in pieces if piece)
201
+
202
+
203
+ def _stamp_index(panels: tuple[PanelView, ...]) -> int | None:
204
+ active = _active_index(panels)
205
+ if active is not None:
206
+ return active
207
+ for index in range(len(panels) - 1, -1, -1):
208
+ if panels[index].state is not PanelState.PENDING:
209
+ return index
210
+ return 0 if panels else None
211
+
212
+
213
+ def _active_or_last(panels: tuple[PanelView, ...]) -> PanelView:
214
+ for panel in panels:
215
+ if panel.state is PanelState.ACTIVE:
216
+ return panel
217
+ for panel in reversed(panels):
218
+ if panel.state is not PanelState.PENDING:
219
+ return panel
220
+ return panels[0]
221
+
222
+
223
+ def _active_index(panels: tuple[PanelView, ...]) -> int | None:
224
+ for index, panel in enumerate(panels):
225
+ if panel.state is PanelState.ACTIVE:
226
+ return index
227
+ return None
228
+
229
+
230
+ def _column_widths(width: int, headers: list[str]) -> list[int]:
231
+ count = len(headers)
232
+ available = width - (count + 1)
233
+ minimums = [cell_len(header) for header in headers]
234
+ if sum(minimums) > available:
235
+ base = available // count
236
+ widths = [base] * count
237
+ widths[-1] += available - base * count
238
+ return widths
239
+ widths = list(minimums)
240
+ widths[-1] += available - sum(minimums)
241
+ return widths
242
+
243
+
244
+ def _top_border(header: str, width: int) -> str:
245
+ fixed = cell_len(_TOP_BORDER_PREFIX) + 1 + cell_len(_BORDER_TOP_RIGHT)
246
+ header = _fit(header, max(width - fixed, 0)).rstrip()
247
+ fill = width - cell_len(_TOP_BORDER_PREFIX) - cell_len(header) - 1 - cell_len(_BORDER_TOP_RIGHT)
248
+ return f"{_TOP_BORDER_PREFIX}{header} {_BORDER_HORIZONTAL * max(fill, 0)}{_BORDER_TOP_RIGHT}"
249
+
250
+
251
+ def _bottom_border(widths: list[int]) -> str:
252
+ segments = (_BORDER_HORIZONTAL * column for column in widths)
253
+ return f"{_BORDER_BOTTOM_LEFT}{_BORDER_BOTTOM_JOIN.join(segments)}{_BORDER_BOTTOM_RIGHT}"
254
+
255
+
256
+ class TaskStrip:
257
+ """Renders one task's strip at a given terminal width."""
258
+
259
+ def __init__(self, data: TaskStripData) -> None:
260
+ """Hold `data`; nothing here mutates it."""
261
+ self._data = data
262
+
263
+ def render(self, width: int) -> Text:
264
+ """Render the full 4-panel strip at `width >= STRIP_WIDE_MIN_COLUMNS`, else one line."""
265
+ if width >= STRIP_WIDE_MIN_COLUMNS:
266
+ return self._render_wide(width)
267
+ return self._render_narrow(width)
268
+
269
+ def _header_text(self) -> str:
270
+ return f"{self._data.task_id}{_HEADER_SEPARATOR}{self._data.title}"
271
+
272
+ def _render_narrow(self, width: int) -> Text:
273
+ panel = _active_or_last(self._data.panels)
274
+ pieces = [
275
+ self._data.task_id,
276
+ panel.stage_glyph,
277
+ panel.label,
278
+ panel.detail,
279
+ panel.role_line,
280
+ _narrow_glyph(panel),
281
+ ]
282
+ line = _NARROW_SEPARATOR.join(piece for piece in pieces if piece)
283
+ if self._data.stamp is not None:
284
+ line = f"{line}{_HEADER_SEPARATOR}{self._data.stamp.render()}"
285
+ text = Text(line)
286
+ if self._data.stamp is not None and self._data.stamp.flash:
287
+ stamp = self._data.stamp.render()
288
+ start = line.rfind(stamp)
289
+ if start >= 0:
290
+ text.stylize(STYLE_REVERSE, start, start + cell_len(stamp))
291
+ if cell_len(line) > width:
292
+ text.truncate(width, overflow="crop")
293
+ return text
294
+
295
+ def _render_wide(self, width: int) -> Text:
296
+ panels = self._data.panels
297
+ headers = [_header_cell(index, panel) for index, panel in enumerate(panels)]
298
+ widths = _column_widths(width, headers)
299
+
300
+ header_cells = [
301
+ _fit(header, column) for header, column in zip(headers, widths, strict=True)
302
+ ]
303
+ body_cells = [
304
+ _fit(_body_cell(panel), column) for panel, column in zip(panels, widths, strict=True)
305
+ ]
306
+ role_cells = [
307
+ _fit(panel.role_line, column) for panel, column in zip(panels, widths, strict=True)
308
+ ]
309
+
310
+ stamp_index = _stamp_index(panels) if self._data.stamp is not None else None
311
+ if stamp_index is not None and self._data.stamp is not None:
312
+ body_cells[stamp_index] = _fit(self._data.stamp.render(), widths[stamp_index])
313
+ role_cells[stamp_index] = _fit("", widths[stamp_index])
314
+
315
+ body = Text(_top_border(self._header_text(), width), style=STYLE_BOLD)
316
+ for row in (header_cells, body_cells, role_cells):
317
+ body.append("\n")
318
+ body.append(_BORDER_VERTICAL)
319
+ for index, cell in enumerate(row):
320
+ style = _panel_style(panels[index]) if row is body_cells else None
321
+ if (
322
+ row is body_cells
323
+ and index == stamp_index
324
+ and self._data.stamp is not None
325
+ and self._data.stamp.flash
326
+ ):
327
+ style = f"{style} {STYLE_REVERSE}"
328
+ body.append(cell, style=style)
329
+ body.append(_BORDER_VERTICAL)
330
+ body.append("\n")
331
+ body.append(_bottom_border(widths))
332
+ return body
shikiru/ui/theme.py ADDED
@@ -0,0 +1,83 @@
1
+ """Shared semantic colors for Textual screens and evidence views."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from textual.theme import Theme
8
+
9
+ from shikiru.ui.tokens import (
10
+ ALERT_COLOR,
11
+ AMBER,
12
+ BACKGROUND_COLOR,
13
+ DETAIL_SURFACE_COLOR,
14
+ HOLD_COLOR,
15
+ NEUTRAL_BORDER_COLOR,
16
+ PRIMARY_TEXT_COLOR,
17
+ RAISED_SURFACE_COLOR,
18
+ SCROLLBAR_THUMB_ACTIVE_COLOR,
19
+ SCROLLBAR_THUMB_COLOR,
20
+ SCROLLBAR_THUMB_HOVER_COLOR,
21
+ SCROLLBAR_TRACK_COLOR,
22
+ SECONDARY_TEXT_COLOR,
23
+ SELECTED_SURFACE_COLOR,
24
+ STEEL_BLUE,
25
+ SUCCESS_COLOR,
26
+ SURFACE_COLOR,
27
+ )
28
+
29
+ if TYPE_CHECKING:
30
+ from textual.app import App
31
+
32
+ BACKGROUND = BACKGROUND_COLOR
33
+ SURFACE = SURFACE_COLOR
34
+ RAISED_SURFACE = RAISED_SURFACE_COLOR
35
+ NEUTRAL_BORDER = NEUTRAL_BORDER_COLOR
36
+ PRIMARY_TEXT = PRIMARY_TEXT_COLOR
37
+ SECONDARY_TEXT = SECONDARY_TEXT_COLOR
38
+ FOCUS = STEEL_BLUE
39
+ THEME_NAME = "shikiru"
40
+ SCROLLBAR_WIDTH = 1
41
+
42
+ SHIKIRU_THEME = Theme(
43
+ name=THEME_NAME,
44
+ primary=FOCUS,
45
+ secondary=SECONDARY_TEXT,
46
+ warning=HOLD_COLOR,
47
+ error=ALERT_COLOR,
48
+ success=SUCCESS_COLOR,
49
+ accent=FOCUS,
50
+ foreground=PRIMARY_TEXT,
51
+ background=BACKGROUND,
52
+ surface=SURFACE,
53
+ panel=RAISED_SURFACE,
54
+ variables={
55
+ "border": NEUTRAL_BORDER,
56
+ "accent": FOCUS,
57
+ "amber": AMBER,
58
+ "detail-surface": DETAIL_SURFACE_COLOR,
59
+ "dusty-rose": ALERT_COLOR,
60
+ "neutral-border": NEUTRAL_BORDER,
61
+ "focus": FOCUS,
62
+ "muted-text": SECONDARY_TEXT,
63
+ "primary-text": PRIMARY_TEXT,
64
+ "sage": SUCCESS_COLOR,
65
+ "scrollbar": SCROLLBAR_THUMB_COLOR,
66
+ "scrollbar-active": SCROLLBAR_THUMB_ACTIVE_COLOR,
67
+ "scrollbar-background": SCROLLBAR_TRACK_COLOR,
68
+ "scrollbar-background-active": SCROLLBAR_TRACK_COLOR,
69
+ "scrollbar-background-hover": SCROLLBAR_TRACK_COLOR,
70
+ "scrollbar-corner-color": SCROLLBAR_TRACK_COLOR,
71
+ "scrollbar-hover": SCROLLBAR_THUMB_HOVER_COLOR,
72
+ "scrollbar-width": str(SCROLLBAR_WIDTH),
73
+ "secondary-text": SECONDARY_TEXT,
74
+ "selected-surface": SELECTED_SURFACE_COLOR,
75
+ "steel-blue": STEEL_BLUE,
76
+ },
77
+ )
78
+
79
+
80
+ def install_theme[ReturnT](app: App[ReturnT]) -> None:
81
+ """Register the shared Shikiru palette on a Textual application."""
82
+ app.register_theme(SHIKIRU_THEME)
83
+ app.theme = THEME_NAME
shikiru/ui/tokens.py ADDED
@@ -0,0 +1,51 @@
1
+ """Colour tokens for the shared visual language."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from shikiru import palette
6
+ from shikiru.domain.levels import UsageLevel
7
+ from shikiru.domain.tasks import Role
8
+
9
+ BACKGROUND_COLOR = palette.BACKGROUND_COLOR
10
+ SURFACE_COLOR = palette.SURFACE_COLOR
11
+ RAISED_SURFACE_COLOR = palette.RAISED_SURFACE_COLOR
12
+ SELECTED_SURFACE_COLOR = palette.SELECTED_SURFACE_COLOR
13
+ DETAIL_SURFACE_COLOR = palette.DETAIL_SURFACE_COLOR
14
+ NEUTRAL_BORDER_COLOR = palette.NEUTRAL_BORDER_COLOR
15
+ PRIMARY_TEXT_COLOR = palette.PRIMARY_TEXT_COLOR
16
+ SECONDARY_TEXT_COLOR = palette.SECONDARY_TEXT_COLOR
17
+ STEEL_BLUE = palette.STEEL_BLUE
18
+ SAGE = palette.SAGE
19
+ AMBER = palette.AMBER
20
+ DUSTY_ROSE = palette.DUSTY_ROSE
21
+ SCROLLBAR_THUMB_COLOR = palette.SCROLLBAR_THUMB_COLOR
22
+ SCROLLBAR_THUMB_HOVER_COLOR = palette.SCROLLBAR_THUMB_HOVER_COLOR
23
+ SCROLLBAR_THUMB_ACTIVE_COLOR = palette.SCROLLBAR_THUMB_ACTIVE_COLOR
24
+ SCROLLBAR_TRACK_COLOR = palette.SCROLLBAR_TRACK_COLOR
25
+
26
+ ROLE_COLORS: dict[Role, str] = {
27
+ Role.LEADER: STEEL_BLUE,
28
+ Role.IMPLEMENTER: STEEL_BLUE,
29
+ Role.HELPER: STEEL_BLUE,
30
+ }
31
+
32
+ OPERATOR_COLOR = palette.STEEL_BLUE
33
+ ENGINE_COLOR = palette.SECONDARY_TEXT_COLOR
34
+ SUCCESS_COLOR = palette.SAGE
35
+ REWORK_COLOR = palette.AMBER
36
+ HOLD_COLOR = palette.AMBER
37
+ ALERT_COLOR = palette.DUSTY_ROSE
38
+ STOPPED_DURING = "stopped during"
39
+ IDLE_COLOR = palette.SECONDARY_TEXT_COLOR
40
+
41
+ STYLE_DIM = "dim"
42
+ STYLE_BOLD = "bold"
43
+ STYLE_REVERSE = "reverse"
44
+ """Attributes paired with every panel colour: under `NO_COLOR`, Rich keeps the
45
+ attribute and drops the colour, giving the required monochrome bold/dim/reverse."""
46
+
47
+ USAGE_LEVEL_COLORS: dict[UsageLevel, str] = {
48
+ UsageLevel.OK: SAGE,
49
+ UsageLevel.WARN: AMBER,
50
+ UsageLevel.ALERT: DUSTY_ROSE,
51
+ }
shikiru/view_log.py ADDED
@@ -0,0 +1,126 @@
1
+ """The role view log: its path, row keys and the pane command tailing it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from datetime import datetime
8
+ from typing import TYPE_CHECKING
9
+
10
+ from shikiru.agents.contracts import (
11
+ Message,
12
+ SessionStarted,
13
+ ToolFinished,
14
+ ToolStarted,
15
+ TurnEnded,
16
+ TurnFailed,
17
+ role_dir,
18
+ shikiru_argv,
19
+ )
20
+ from shikiru.domain.quota_waits import QuotaAction
21
+ from shikiru.private import TEXT_ENCODING, make_private_dir, write_private_text
22
+
23
+ if TYPE_CHECKING:
24
+ from pathlib import Path
25
+
26
+ VIEW_LOG_NAME = "view.jsonl"
27
+ VIEW_TIME_KEY = "ts"
28
+ VIEW_TURN_KEY = "turn"
29
+ VIEW_RUNG_KEY = "rung"
30
+ VIEW_SIGNAL_KEY = "signal"
31
+ VIEW_MESSAGE_SIGNAL = Message.__name__
32
+ VIEW_MESSAGE_TEXT_KEY = "text"
33
+ VIEW_TURN_ENDED_TEXT_KEY = "last_message"
34
+ VIEW_SESSION_STARTED_SIGNAL = SessionStarted.__name__
35
+ VIEW_TOOL_STARTED_SIGNAL = ToolStarted.__name__
36
+ VIEW_TOOL_FINISHED_SIGNAL = ToolFinished.__name__
37
+ VIEW_TURN_ENDED_SIGNAL = TurnEnded.__name__
38
+ VIEW_TURN_FAILED_SIGNAL = TurnFailed.__name__
39
+ QUOTA_STATUS_NAME = "quota-status.json"
40
+ QUOTA_STATUS_ACTION_KEY = "action"
41
+ QUOTA_STATUS_AT_KEY = "at"
42
+ TURN_DIR_SEPARATOR = "-"
43
+ TURN_DIR_ITERATION_MARKER = "-i"
44
+ CLI_VIEW_COMMAND = "view"
45
+ CLI_PROJECT_FLAG = "--project"
46
+ PARTIAL_SUFFIX = ".partial"
47
+ _TAIL_BLOCK_BYTES = 65536
48
+ _LINE_BREAK = b"\n"
49
+
50
+
51
+ def ordered_turn_dirs(turns_root: Path) -> list[Path]:
52
+ """Return the turn directories of `turns_root` ordered by turn number, however many digits."""
53
+ return sorted(
54
+ (entry for entry in turns_root.iterdir() if entry.is_dir()),
55
+ key=lambda entry: int(entry.name.partition(TURN_DIR_SEPARATOR)[0]),
56
+ )
57
+
58
+
59
+ def turn_iteration(turn_name: str) -> int | None:
60
+ """Return the task iteration a turn directory name (`NN-<step>-i<n>`) was started in."""
61
+ _, marker, iteration = turn_name.rpartition(TURN_DIR_ITERATION_MARKER)
62
+ return int(iteration) if marker and iteration.isdecimal() else None
63
+
64
+
65
+ def view_argv(project_root: Path, task_id: str, role: str) -> tuple[str, ...]:
66
+ """Return the argv of `role`'s read-only view pane for `task_id`."""
67
+ return shikiru_argv(CLI_PROJECT_FLAG, str(project_root), CLI_VIEW_COMMAND, task_id, role)
68
+
69
+
70
+ def view_log_path(run_dir: Path, role: str) -> Path:
71
+ """Return `role`'s live view log, one JSON row per agent signal."""
72
+ return role_dir(run_dir, role) / VIEW_LOG_NAME
73
+
74
+
75
+ def quota_status_path(run_dir: Path, role: str) -> Path:
76
+ """Return the file holding `role`'s current quota status, present only during a quota wait."""
77
+ return role_dir(run_dir, role) / QUOTA_STATUS_NAME
78
+
79
+
80
+ def write_quota_status(path: Path, action: QuotaAction | None, at: datetime | None) -> None:
81
+ """Publish a quota wait for `path`'s view pane, replacing any earlier one in a single step."""
82
+ payload = {
83
+ QUOTA_STATUS_ACTION_KEY: action.value if action is not None else None,
84
+ QUOTA_STATUS_AT_KEY: at.isoformat() if at is not None else None,
85
+ }
86
+ make_private_dir(path.parent)
87
+ partial = path.with_name(f"{path.name}{PARTIAL_SUFFIX}")
88
+ write_private_text(partial, json.dumps(payload))
89
+ partial.replace(path)
90
+
91
+
92
+ def clear_quota_status(path: Path) -> None:
93
+ """Remove the quota status of `path`'s view pane; nothing happens when none is published."""
94
+ path.unlink(missing_ok=True)
95
+
96
+
97
+ def read_quota_status(path: Path) -> tuple[QuotaAction | None, datetime | None] | None:
98
+ """Return the published quota status as its action and time, or none outside a quota wait."""
99
+ try:
100
+ text = path.read_text(encoding=TEXT_ENCODING)
101
+ except FileNotFoundError:
102
+ return None
103
+ payload = json.loads(text)
104
+ action = payload[QUOTA_STATUS_ACTION_KEY]
105
+ at = payload[QUOTA_STATUS_AT_KEY]
106
+ return (
107
+ QuotaAction(action) if action is not None else None,
108
+ datetime.fromisoformat(at) if at is not None else None,
109
+ )
110
+
111
+
112
+ def read_tail_lines(path: Path, count: int) -> list[str]:
113
+ """Return the last `count` lines of `path`, reading no more of the file than they need."""
114
+ blocks: list[bytes] = []
115
+ line_breaks = 0
116
+ with path.open("rb") as handle:
117
+ position = handle.seek(0, os.SEEK_END)
118
+ while position > 0 and line_breaks <= count:
119
+ size = min(_TAIL_BLOCK_BYTES, position)
120
+ position -= size
121
+ handle.seek(position)
122
+ block = handle.read(size)
123
+ blocks.append(block)
124
+ line_breaks += block.count(_LINE_BREAK)
125
+ text = b"".join(reversed(blocks)).decode(TEXT_ENCODING, errors="replace")
126
+ return text.splitlines()[-count:]
@@ -0,0 +1,3 @@
1
+ """Task roots, worktrees, caches and git facts."""
2
+
3
+ from __future__ import annotations