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,381 @@
1
+ """The Shikiru tmux session: name, collision check, options, home and task windows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shlex
6
+ from typing import TYPE_CHECKING
7
+ from uuid import uuid4
8
+
9
+ from shikiru.errors import ConfigError
10
+ from shikiru.palette import (
11
+ BACKGROUND_COLOR,
12
+ NEUTRAL_BORDER_COLOR,
13
+ PRIMARY_TEXT_COLOR,
14
+ SECONDARY_TEXT_COLOR,
15
+ SELECTED_SURFACE_COLOR,
16
+ STEEL_BLUE,
17
+ )
18
+ from shikiru.tmux.client import (
19
+ FORMAT_PANE_ID,
20
+ FORMAT_PANE_START_COMMAND,
21
+ FORMAT_WINDOW_ID,
22
+ FORMAT_WINDOW_INDEX,
23
+ FORMAT_WINDOW_NAME,
24
+ HOME_PANE_OPTION,
25
+ HOME_PANE_OPTION_VALUE,
26
+ exact_target,
27
+ new_session_command,
28
+ new_window_command,
29
+ pane_option_commands,
30
+ rename_window_command,
31
+ session_option_commands,
32
+ window_option_commands,
33
+ )
34
+ from shikiru.tmux.decorator import (
35
+ HOME_WINDOW_STATUS_CURRENT_FORMAT,
36
+ HOME_WINDOW_STATUS_FORMAT,
37
+ PANE_BORDER_FORMAT,
38
+ WINDOW_STATE_COLOR_OPTION,
39
+ WINDOW_STATUS_CURRENT_FORMAT,
40
+ WINDOW_STATUS_FORMAT,
41
+ )
42
+
43
+ if TYPE_CHECKING:
44
+ from collections.abc import Sequence
45
+ from pathlib import Path
46
+
47
+ from shikiru.tmux.client import Tmux
48
+
49
+ SESSION_PREFIX = "[shikiru] "
50
+ _SANITIZED_CHARS = (".", ":")
51
+ _SANITIZED_REPLACEMENT = "_"
52
+
53
+ PROJECT_DIR_ENV_VAR = "SHIKIRU_PROJECT_DIR"
54
+ HOME_WINDOW_NAME = "dashboard"
55
+ LEGACY_HOME_WINDOW_NAME = "shikiru"
56
+ DETACHED_SIZE = (240, 64)
57
+ _HOME_WINDOW_INDEX = "0"
58
+ _PENDING_WINDOW_PREFIX = "shikiru-pending-"
59
+ _HOME_INDEX_TARGET = f":{_HOME_WINDOW_INDEX}"
60
+
61
+ _PALETTE_KEY = "Space"
62
+ _DASHBOARD_PALETTE_EVENT = "F12"
63
+ _TMUX_COMMAND = "tmux"
64
+ _GLOB_SPECIAL_CHARS = "\\[]*?"
65
+
66
+
67
+ def _escape_glob(text: str) -> str:
68
+ return "".join(f"\\{char}" if char in _GLOB_SPECIAL_CHARS else char for char in text)
69
+
70
+
71
+ SHIKIRU_SESSION_PATTERN = f"#{{m:{_escape_glob(SESSION_PREFIX)}*,#{{session_name}}}}"
72
+ _DEFAULT_PALETTE_KEY_COMMAND = "next-layout"
73
+ _HOME_COMMAND_MARKER = "dash --home"
74
+ _HOME_WINDOW_NAMES = frozenset((HOME_WINDOW_NAME, LEGACY_HOME_WINDOW_NAME))
75
+ _HOME_ROW_MAX_SPLITS = 5
76
+ _HOME_WINDOW_NAME_FIELD = 2
77
+ _HOME_WINDOW_ACTIVE_FIELD = 3
78
+ _ACTIVE_WINDOW_VALUE = "1"
79
+ _FIELD_SEPARATOR = "\t"
80
+ _STATUS_CLOCK_FORMAT = (
81
+ f"#[fg={SECONDARY_TEXT_COLOR},bg={BACKGROUND_COLOR}]%H:%M:%S %Y-%m-%d"
82
+ f"#[fg={PRIMARY_TEXT_COLOR},bg={BACKGROUND_COLOR}]"
83
+ )
84
+ _HOME_PANE_FORMAT = (
85
+ f"{FORMAT_WINDOW_ID}{_FIELD_SEPARATOR}{FORMAT_PANE_ID}{_FIELD_SEPARATOR}"
86
+ f"{FORMAT_WINDOW_NAME}{_FIELD_SEPARATOR}#{{window_active}}{_FIELD_SEPARATOR}"
87
+ f"{FORMAT_PANE_START_COMMAND}{_FIELD_SEPARATOR}"
88
+ f"#{{{HOME_PANE_OPTION}}}"
89
+ )
90
+ _WINDOW_STATE_ROW_FORMAT = f"{FORMAT_WINDOW_ID}{_FIELD_SEPARATOR}#{{{WINDOW_STATE_COLOR_OPTION}}}"
91
+
92
+ _SESSION_SCOPED_OPTIONS: dict[str, str] = {
93
+ "base-index": _HOME_WINDOW_INDEX,
94
+ "renumber-windows": "off",
95
+ "mouse": "on",
96
+ "set-titles": "on",
97
+ "status-style": f"fg={PRIMARY_TEXT_COLOR},bg={BACKGROUND_COLOR}",
98
+ "status-left-style": f"fg={SECONDARY_TEXT_COLOR},bg={BACKGROUND_COLOR}",
99
+ "status-right-style": f"fg={SECONDARY_TEXT_COLOR},bg={BACKGROUND_COLOR}",
100
+ "status-left": "",
101
+ "status-right": _STATUS_CLOCK_FORMAT,
102
+ "status-interval": "1",
103
+ }
104
+
105
+ _OWNED_WINDOW_BEHAVIOR_OPTIONS: dict[str, str] = {
106
+ "remain-on-exit": "on",
107
+ "allow-rename": "off",
108
+ "automatic-rename": "off",
109
+ "allow-set-title": "off",
110
+ }
111
+
112
+ _PANE_TITLE_OPTIONS: dict[str, str] = {
113
+ "pane-border-status": "top",
114
+ "pane-border-lines": "heavy",
115
+ "pane-border-style": f"fg={NEUTRAL_BORDER_COLOR},bg={BACKGROUND_COLOR}",
116
+ "pane-active-border-style": f"fg={STEEL_BLUE},bg={BACKGROUND_COLOR}",
117
+ "pane-border-format": PANE_BORDER_FORMAT,
118
+ }
119
+
120
+ _WINDOW_STATUS_OPTIONS: dict[str, str] = {
121
+ "window-status-style": f"fg={SECONDARY_TEXT_COLOR},bg={BACKGROUND_COLOR}",
122
+ "window-status-current-style": f"fg={PRIMARY_TEXT_COLOR},bg={SELECTED_SURFACE_COLOR},bold",
123
+ "window-status-format": WINDOW_STATUS_FORMAT,
124
+ "window-status-current-format": WINDOW_STATUS_CURRENT_FORMAT,
125
+ }
126
+
127
+ _TASK_WINDOW_OPTIONS: dict[str, str] = {
128
+ **_OWNED_WINDOW_BEHAVIOR_OPTIONS,
129
+ **_PANE_TITLE_OPTIONS,
130
+ **_WINDOW_STATUS_OPTIONS,
131
+ }
132
+
133
+ _HOME_WINDOW_OPTIONS: dict[str, str] = {
134
+ **_TASK_WINDOW_OPTIONS,
135
+ "pane-border-status": "off",
136
+ "window-status-format": HOME_WINDOW_STATUS_FORMAT,
137
+ "window-status-current-format": HOME_WINDOW_STATUS_CURRENT_FORMAT,
138
+ }
139
+
140
+
141
+ async def bind_palette_key(tmux: Tmux, palette_command: Sequence[str]) -> None:
142
+ """Bind prefix+Space to the mounted Home dashboard or the contextual popup.
143
+
144
+ The Home dashboard receives a dedicated F12 terminal event. Other Shikiru panes retain the
145
+ standalone palette popup and originating pane context. Elsewhere the key keeps tmux's own
146
+ default, `next-layout`: there is no global override, only this one conditional
147
+ binding.
148
+ """
149
+ popup_command = f"{_TMUX_COMMAND} display-popup -E " + " ".join(
150
+ shlex.quote(part) for part in palette_command
151
+ )
152
+ expanded_popup_command = f"run-shell {shlex.quote(popup_command)}"
153
+ dashboard_pane = "#{&&:#{m:*dash --home*,#{pane_start_command}},#{!=:#{pane_dead},1}}"
154
+ dashboard_command = f"send-keys {_DASHBOARD_PALETTE_EVENT}"
155
+ contextual_command = (
156
+ f"if-shell -F {shlex.quote(dashboard_pane)} "
157
+ f"{shlex.quote(dashboard_command)} {shlex.quote(expanded_popup_command)}"
158
+ )
159
+ await tmux.bind_key(
160
+ _PALETTE_KEY,
161
+ "if-shell",
162
+ "-F",
163
+ SHIKIRU_SESSION_PATTERN,
164
+ contextual_command,
165
+ _DEFAULT_PALETTE_KEY_COMMAND,
166
+ )
167
+
168
+
169
+ def session_name(project_name: str) -> str:
170
+ """Build the session name for `project_name`: `.` and `:` become `_`."""
171
+ sanitized = project_name
172
+ for char in _SANITIZED_CHARS:
173
+ sanitized = sanitized.replace(char, _SANITIZED_REPLACEMENT)
174
+ return f"{SESSION_PREFIX}{sanitized}"
175
+
176
+
177
+ def _home_window_target(session_target: str) -> str:
178
+ return f"{session_target}:={HOME_WINDOW_NAME}"
179
+
180
+
181
+ def _home_window_setup(window_target: str) -> list[tuple[str, ...]]:
182
+ return [
183
+ *window_option_commands(window_target, _HOME_WINDOW_OPTIONS),
184
+ *pane_option_commands(window_target, {HOME_PANE_OPTION: HOME_PANE_OPTION_VALUE}),
185
+ ]
186
+
187
+
188
+ class SessionView:
189
+ """Ensures the project's tmux session, its options, and its home and task windows."""
190
+
191
+ def __init__(self, tmux: Tmux) -> None:
192
+ """Drive every session operation through `tmux`."""
193
+ self._tmux = tmux
194
+
195
+ async def ensure(
196
+ self, project_name: str, project_dir: Path, *, home_command: Sequence[str]
197
+ ) -> str:
198
+ """Ensure the session for `project_name` exists with its options and home window.
199
+
200
+ Returns the session's exact target. Idempotent: calling this twice creates nothing
201
+ twice. Raises `ConfigError` if a session of that name already belongs to another
202
+ project (a different `SHIKIRU_PROJECT_DIR`).
203
+ """
204
+ name = session_name(project_name)
205
+ target = exact_target(name)
206
+ session_options = {**_SESSION_SCOPED_OPTIONS, "set-titles-string": project_name}
207
+ if await self._tmux.target_exists(target):
208
+ await self._check_no_collision(target, project_dir)
209
+ await self._tmux.batch(session_option_commands(target, session_options))
210
+ else:
211
+ await self._tmux.batch(
212
+ [
213
+ new_session_command(
214
+ name,
215
+ cwd=project_dir,
216
+ env={PROJECT_DIR_ENV_VAR: str(project_dir)},
217
+ window_name=HOME_WINDOW_NAME,
218
+ size=DETACHED_SIZE,
219
+ command=home_command,
220
+ ),
221
+ *session_option_commands(target, session_options),
222
+ *_home_window_setup(_home_window_target(target)),
223
+ ]
224
+ )
225
+ home = await self._ensure_home_window(target, project_dir, home_command)
226
+ await self._apply_session_window_options(target, home)
227
+ return target
228
+
229
+ async def create_task_window(
230
+ self,
231
+ session_target: str,
232
+ *,
233
+ window_name: str,
234
+ cwd: Path,
235
+ command: Sequence[str] | None = None,
236
+ ) -> str:
237
+ """Create a task window in `session_target`, returning the id of its base pane.
238
+
239
+ The window is created with its options and renamed in one tmux call, so its
240
+ `remain-on-exit` is set before its command can exit. It is created under a unique
241
+ pending name, so its options can target it exactly even when `window_name` is taken.
242
+ """
243
+ pending = f"{_PENDING_WINDOW_PREFIX}{uuid4().hex}"
244
+ pending_target = f"{session_target}:={pending}"
245
+ result = await self._tmux.batch(
246
+ [
247
+ new_window_command(
248
+ session_target, name=pending, cwd=cwd, command=command, detached=True
249
+ ),
250
+ *window_option_commands(pending_target, _TASK_WINDOW_OPTIONS),
251
+ rename_window_command(pending_target, window_name),
252
+ ]
253
+ )
254
+ return result.stdout.strip()
255
+
256
+ async def close_task_window(self, window_target: str) -> None:
257
+ """Close a task window."""
258
+ await self._tmux.kill_window(window_target)
259
+
260
+ async def _check_no_collision(self, target: str, project_dir: Path) -> None:
261
+ other = await self._tmux.show_session_environment(target, PROJECT_DIR_ENV_VAR)
262
+ if other != str(project_dir):
263
+ other_label = other if other is not None else "an unknown project"
264
+ message = f"tmux session {target} already belongs to {other_label}, not {project_dir}"
265
+ raise ConfigError(message)
266
+
267
+ async def _ensure_home_window(
268
+ self, target: str, project_dir: Path, home_command: Sequence[str]
269
+ ) -> tuple[str, str]:
270
+ matches = await home_window_targets(self._tmux, target)
271
+ if not matches:
272
+ await self._create_home_window(target, project_dir, home_command)
273
+ matches = await home_window_targets(self._tmux, target)
274
+ window_target, pane_id, window_name, _ = _preferred_home(matches)
275
+ if window_name != HOME_WINDOW_NAME:
276
+ await self._tmux.rename_window(window_target, HOME_WINDOW_NAME)
277
+ await self._ensure_home_index(target, window_target)
278
+ await self._remove_duplicate_home_windows(matches, window_target)
279
+ return window_target, pane_id
280
+
281
+ async def _create_home_window(
282
+ self, target: str, project_dir: Path, home_command: Sequence[str]
283
+ ) -> None:
284
+ await self._tmux.batch(
285
+ [
286
+ new_window_command(
287
+ target,
288
+ name=HOME_WINDOW_NAME,
289
+ cwd=project_dir,
290
+ command=home_command,
291
+ detached=True,
292
+ ),
293
+ *_home_window_setup(_home_window_target(target)),
294
+ ]
295
+ )
296
+
297
+ async def _apply_session_window_options(self, target: str, home: tuple[str, str]) -> None:
298
+ home_window, home_pane = home
299
+ commands: list[tuple[str, ...]] = []
300
+ for row in await self._tmux.list_session_windows(
301
+ target, row_format=_WINDOW_STATE_ROW_FORMAT
302
+ ):
303
+ window_id, _, state_color = row.partition(_FIELD_SEPARATOR)
304
+ if window_id == home_window:
305
+ options = _HOME_WINDOW_OPTIONS
306
+ elif state_color:
307
+ options = _TASK_WINDOW_OPTIONS
308
+ else:
309
+ options = _WINDOW_STATUS_OPTIONS
310
+ commands.extend(window_option_commands(window_id, options))
311
+ commands.extend(pane_option_commands(home_pane, {HOME_PANE_OPTION: HOME_PANE_OPTION_VALUE}))
312
+ await self._tmux.batch(commands)
313
+
314
+ async def _ensure_home_index(self, target: str, home_window: str) -> None:
315
+ if await self._tmux.display(home_window, FORMAT_WINDOW_INDEX) == _HOME_WINDOW_INDEX:
316
+ return
317
+ home_target = f"{target}{_HOME_INDEX_TARGET}"
318
+ if await self._tmux.target_exists(home_target):
319
+ occupant_window = await self._tmux.window_id(home_target)
320
+ homes = await home_window_targets(self._tmux, target)
321
+ if any(match[0] == occupant_window for match in homes):
322
+ await self._tmux.swap_windows(home_window, home_target)
323
+ return
324
+ occupant = await self._tmux.display(home_target, FORMAT_WINDOW_NAME)
325
+ message = (
326
+ f"cannot move Home window to index 0; {home_target} is occupied by {occupant!r}"
327
+ )
328
+ raise ConfigError(message)
329
+ await self._tmux.move_window(home_window, home_target)
330
+
331
+ async def _remove_duplicate_home_windows(
332
+ self, matches: list[tuple[str, str, str, str]], home_window: str
333
+ ) -> None:
334
+ duplicates = dict.fromkeys(window_id for window_id, _, _, _ in matches)
335
+ for duplicate in duplicates:
336
+ if duplicate != home_window:
337
+ await self._tmux.kill_window(duplicate)
338
+
339
+
340
+ async def home_window_target(tmux: Tmux, session_target: str) -> tuple[str, str] | None:
341
+ """Find Home by its owned pane marker or its shipped window name."""
342
+ matches = await home_window_targets(tmux, session_target)
343
+ if not matches:
344
+ return None
345
+ home = _preferred_home(matches)
346
+ return home[0], home[1]
347
+
348
+
349
+ async def home_window_targets(tmux: Tmux, session_target: str) -> list[tuple[str, str, str, str]]:
350
+ """Find owned Home windows, falling back to shipped names for legacy sessions."""
351
+ panes = await tmux.list_session_panes(session_target, _HOME_PANE_FORMAT)
352
+ identified: list[tuple[str, str, str, str]] = []
353
+ named: list[tuple[str, str, str, str]] = []
354
+ for row in panes:
355
+ window_id, pane_id, window_name, active, start_command, marker = row.split(
356
+ _FIELD_SEPARATOR, _HOME_ROW_MAX_SPLITS
357
+ )
358
+ if marker == HOME_PANE_OPTION_VALUE or _HOME_COMMAND_MARKER in start_command:
359
+ identified.append((window_id, pane_id, window_name, active))
360
+ elif window_name in _HOME_WINDOW_NAMES:
361
+ named.append((window_id, pane_id, window_name, active))
362
+ return identified or named
363
+
364
+
365
+ def _preferred_home(
366
+ matches: list[tuple[str, str, str, str]],
367
+ ) -> tuple[str, str, str, str]:
368
+ return next(
369
+ (match for match in matches if match[_HOME_WINDOW_ACTIVE_FIELD] == _ACTIVE_WINDOW_VALUE),
370
+ next(
371
+ (match for match in matches if match[_HOME_WINDOW_NAME_FIELD] == HOME_WINDOW_NAME),
372
+ next(
373
+ (
374
+ match
375
+ for match in matches
376
+ if match[_HOME_WINDOW_NAME_FIELD] == LEGACY_HOME_WINDOW_NAME
377
+ ),
378
+ matches[0],
379
+ ),
380
+ ),
381
+ )
shikiru/ui/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """Shared visual language for the CLI and the dashboard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from shikiru.ui.bubbles import SpeechBubble
6
+ from shikiru.ui.gauge import Gauge
7
+ from shikiru.ui.splash import SplashPanel
8
+
9
+ __all__ = ("Gauge", "SpeechBubble", "SplashPanel")
shikiru/ui/age.py ADDED
@@ -0,0 +1,45 @@
1
+ """Elapsed-time text shared by the CLI and the dashboard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import timedelta
6
+
7
+ AGE_NOW = "now"
8
+ SECONDS_PER_MINUTE = 60
9
+ MINUTES_PER_HOUR = 60
10
+ HOURS_PER_DAY = 24
11
+ SECONDS_PER_HOUR = SECONDS_PER_MINUTE * MINUTES_PER_HOUR
12
+ SECONDS_PER_DAY = SECONDS_PER_HOUR * HOURS_PER_DAY
13
+
14
+
15
+ def format_age(seconds: float) -> str:
16
+ """Return `seconds` as `H:MM:SS` (with a day count past 24 hours)."""
17
+ return str(timedelta(seconds=int(seconds)))
18
+
19
+
20
+ def format_short_age(duration: timedelta) -> str:
21
+ """Return `duration` as `now`, `5m`, `3h` or `2d`, the form of the Usage panel."""
22
+ seconds = max(int(duration.total_seconds()), 0)
23
+ if seconds < SECONDS_PER_MINUTE:
24
+ return AGE_NOW
25
+ minutes = seconds // SECONDS_PER_MINUTE
26
+ if minutes < MINUTES_PER_HOUR:
27
+ return f"{minutes}m"
28
+ hours = minutes // MINUTES_PER_HOUR
29
+ if hours < HOURS_PER_DAY:
30
+ return f"{hours}h"
31
+ return f"{hours // HOURS_PER_DAY}d"
32
+
33
+
34
+ def format_duration(seconds: float) -> str:
35
+ """Return `seconds` as days, hours and minutes, or the remaining smaller units."""
36
+ days, remainder = divmod(int(seconds), SECONDS_PER_DAY)
37
+ hours, remainder = divmod(remainder, SECONDS_PER_HOUR)
38
+ minutes, remainder = divmod(remainder, SECONDS_PER_MINUTE)
39
+ if days:
40
+ return f"{days}d {hours}h {minutes}m"
41
+ if hours:
42
+ return f"{hours}h {minutes}m"
43
+ if minutes:
44
+ return f"{minutes}m"
45
+ return f"{remainder}s"
shikiru/ui/bubbles.py ADDED
@@ -0,0 +1,41 @@
1
+ """Speech bubbles for the dialogue widget."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from rich.text import Text
8
+
9
+ from shikiru.domain.tasks import Role
10
+ from shikiru.ui.glyphs import GLYPH_ENGINE, ROLE_GLYPHS
11
+ from shikiru.ui.tokens import ENGINE_COLOR, ROLE_COLORS
12
+
13
+ _SPEAKER_LABELS: dict[Role | None, str] = {
14
+ Role.LEADER: "Leader",
15
+ Role.IMPLEMENTER: "Implementer",
16
+ Role.HELPER: "Helper",
17
+ None: "Engine",
18
+ }
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class SpeechBubble:
23
+ """Render one event as a one-line, speaker-coloured dialogue bubble."""
24
+
25
+ role: Role | None
26
+ text: str
27
+ task_id: str
28
+ age: str
29
+
30
+ def render(self) -> Text:
31
+ """Render the speaker, shortened display text, task and event age."""
32
+ color = ROLE_COLORS[self.role] if self.role is not None else ENGINE_COLOR
33
+ glyph = ROLE_GLYPHS[self.role] if self.role is not None else GLYPH_ENGINE
34
+ speaker = _SPEAKER_LABELS[self.role]
35
+ line = " ".join(self.text.split())
36
+ result = Text()
37
+ result.append(f"{glyph} {speaker}: ", style=color)
38
+ result.append(f"“{line}”")
39
+ context = f"{self.task_id} · {self.age}" if self.task_id else self.age
40
+ result.append(f" {context}", style="dim")
41
+ return result