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/session.py ADDED
@@ -0,0 +1,65 @@
1
+ """Shared facts for session listings and attach options."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ from rich.text import Text
8
+
9
+ from shikiru.ui.glyphs import GLYPH_ENGINE
10
+ from shikiru.ui.section import BOLD_PRIMARY, number
11
+ from shikiru.ui.tokens import AMBER, SECONDARY_TEXT_COLOR, STYLE_BOLD, SUCCESS_COLOR
12
+
13
+ _SessionEngineState = Literal["up", "down"]
14
+
15
+
16
+ def session_identity(session: str) -> Text:
17
+ """Return a session identity as bold literal text."""
18
+ return Text(session, style=BOLD_PRIMARY, overflow="fold")
19
+
20
+
21
+ def session_engine_value(engine_state: _SessionEngineState) -> Text:
22
+ """Return the engine glyph and muted or active state word."""
23
+ match engine_state:
24
+ case "up":
25
+ style = f"{STYLE_BOLD} {SUCCESS_COLOR}"
26
+ case "down":
27
+ style = SECONDARY_TEXT_COLOR
28
+ return Text.assemble(
29
+ (GLYPH_ENGINE, style),
30
+ (" ", SECONDARY_TEXT_COLOR),
31
+ (engine_state, style),
32
+ )
33
+
34
+
35
+ def session_engine_fact(engine_state: _SessionEngineState) -> Text:
36
+ """Return the labeled engine state used in a stacked session row."""
37
+ text = Text("Engine ", style=SECONDARY_TEXT_COLOR)
38
+ text.append_text(session_engine_value(engine_state))
39
+ return text
40
+
41
+
42
+ def session_count(label: str, count: int) -> Text:
43
+ """Return one labeled session count."""
44
+ return Text.assemble((f"{label} ", SECONDARY_TEXT_COLOR), number(str(count)))
45
+
46
+
47
+ def escalation_count(count: int) -> Text:
48
+ """Return an amber nonzero escalation count or a muted zero."""
49
+ style = f"{STYLE_BOLD} {AMBER}" if count else SECONDARY_TEXT_COLOR
50
+ return Text(str(count), style=style)
51
+
52
+
53
+ def session_facts(
54
+ engine_state: _SessionEngineState,
55
+ running: int,
56
+ queued: int,
57
+ escalated: int,
58
+ ) -> tuple[Text, ...]:
59
+ """Return engine and progress facts in their shared display order."""
60
+ return (
61
+ session_engine_fact(engine_state),
62
+ session_count("running", running),
63
+ session_count("queued", queued),
64
+ Text.assemble(("escalated ", SECONDARY_TEXT_COLOR), escalation_count(escalated)),
65
+ )
shikiru/ui/size.py ADDED
@@ -0,0 +1,17 @@
1
+ """Human-readable byte counts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ _UNITS = ("B", "KiB", "MiB", "GiB", "TiB")
6
+ _UNIT_STEP = 1024
7
+
8
+
9
+ def format_size(size_bytes: int) -> str:
10
+ """Return `size_bytes` in the largest binary unit that keeps it at or above one."""
11
+ value = float(size_bytes)
12
+ unit = _UNITS[0]
13
+ for unit in _UNITS:
14
+ if value < _UNIT_STEP or unit == _UNITS[-1]:
15
+ break
16
+ value /= _UNIT_STEP
17
+ return f"{value:.0f} {unit}" if unit == _UNITS[0] else f"{value:.1f} {unit}"
shikiru/ui/splash.py ADDED
@@ -0,0 +1,42 @@
1
+ """Escalation splash panels."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from rich.text import Text
8
+
9
+ from shikiru.ui.tokens import ALERT_COLOR, STYLE_BOLD, STYLE_DIM
10
+
11
+ _SPEED_LINE = "\N{BOX DRAWINGS LIGHT DIAGONAL UPPER RIGHT TO LOWER LEFT}"
12
+ _OPTION_PREFIX = " "
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class SplashPanel:
17
+ """Render an escalation headline, question and packet options in an alert frame."""
18
+
19
+ headline: str
20
+ question: str
21
+ options: tuple[str, ...]
22
+ pulse_red: bool = True
23
+
24
+ def render(self) -> Text:
25
+ """Render the panel with numbered options and red speed-line framing."""
26
+ lines = Text()
27
+ prefix = f"{_SPEED_LINE} {_SPEED_LINE} "
28
+ suffix = f" {_SPEED_LINE} {_SPEED_LINE}"
29
+ top = f"{prefix}{self.headline}{suffix}"
30
+ lines.append(f"{top}\n")
31
+ lines.append(self.question)
32
+ for number, option in enumerate(self.options, start=1):
33
+ lines.append(f"\n{_OPTION_PREFIX}{number}. {option}")
34
+ bottom = f"{_SPEED_LINE} {_SPEED_LINE} {_SPEED_LINE}"
35
+ lines.append(f"\n{bottom}")
36
+ border_style = f"{STYLE_BOLD} {ALERT_COLOR}" if self.pulse_red else STYLE_DIM
37
+ lines.stylize(border_style, 0, len(prefix))
38
+ suffix_start = len(prefix) + len(self.headline)
39
+ lines.stylize(border_style, suffix_start, len(top))
40
+ bottom_start = len(lines.plain) - len(bottom)
41
+ lines.stylize(border_style, bottom_start, len(lines.plain))
42
+ return lines
shikiru/ui/status.py ADDED
@@ -0,0 +1,47 @@
1
+ """Shared task-status visuals for operator surfaces."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from shikiru.domain.tasks import Status
8
+ from shikiru.ui.glyphs import (
9
+ GLYPH_BLOCKED,
10
+ GLYPH_DONE,
11
+ GLYPH_ENGINE,
12
+ GLYPH_ESCALATED,
13
+ GLYPH_PENDING,
14
+ )
15
+ from shikiru.ui.tokens import (
16
+ ALERT_COLOR,
17
+ ENGINE_COLOR,
18
+ IDLE_COLOR,
19
+ REWORK_COLOR,
20
+ STYLE_BOLD,
21
+ STYLE_DIM,
22
+ STYLE_REVERSE,
23
+ SUCCESS_COLOR,
24
+ )
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class StatusVisual:
29
+ """The glyph and Rich style representing one task status."""
30
+
31
+ glyph: str
32
+ style: str
33
+
34
+
35
+ _STATUS_VISUALS: dict[Status, StatusVisual] = {
36
+ Status.QUEUED: StatusVisual(GLYPH_PENDING, f"{STYLE_DIM} {IDLE_COLOR}"),
37
+ Status.RUNNING: StatusVisual(GLYPH_ENGINE, f"{STYLE_BOLD} {ENGINE_COLOR}"),
38
+ Status.INTEGRATING: StatusVisual(GLYPH_ENGINE, f"{STYLE_BOLD} {ENGINE_COLOR}"),
39
+ Status.ESCALATED: StatusVisual(GLYPH_ESCALATED, f"{STYLE_REVERSE} {ALERT_COLOR}"),
40
+ Status.DONE: StatusVisual(GLYPH_DONE, f"{STYLE_BOLD} {SUCCESS_COLOR}"),
41
+ Status.CANCELLED: StatusVisual(GLYPH_BLOCKED, f"{STYLE_BOLD} {REWORK_COLOR}"),
42
+ }
43
+
44
+
45
+ def status_visual(status: Status) -> StatusVisual:
46
+ """Return the shared glyph and style for `status`."""
47
+ return _STATUS_VISUALS[status]
@@ -0,0 +1,149 @@
1
+ """A status strip pinned above a scrolling terminal view, without touching the output below it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import select
8
+ import termios
9
+ import textwrap
10
+ import tty
11
+ from typing import TYPE_CHECKING
12
+
13
+ from rich.text import Text
14
+
15
+ if TYPE_CHECKING:
16
+ from typing import TextIO
17
+
18
+ from rich.console import Console
19
+
20
+ _ESCAPE = "\x1b"
21
+ _CSI = f"{_ESCAPE}["
22
+ _SAVE_CURSOR = f"{_ESCAPE}7"
23
+ _RESTORE_CURSOR = f"{_ESCAPE}8"
24
+ _RESET_REGION = f"{_CSI}r"
25
+ _ERASE_LINE = f"{_CSI}2K"
26
+ _CURSOR_REPORT_REQUEST = f"{_CSI}6n"
27
+ _CURSOR_REPORT = re.compile(rf"{_ESCAPE}\[(\d+);\d+R")
28
+ _CURSOR_REPORT_TIMEOUT_SECONDS = 2.0
29
+ _REPORT_READ_BYTES = 64
30
+ _FIRST_COLUMN = 1
31
+ _FIRST_ROW = 1
32
+ _NO_ROWS: tuple[str, ...] = ()
33
+ _CLEAR_SCROLLBACK = f"{_CSI}3J"
34
+ _CLEAR_SCREEN = f"{_CSI}2J"
35
+ _HOME = f"{_CSI}H"
36
+
37
+
38
+ class CursorReportError(RuntimeError):
39
+ """The terminal did not report where its cursor is."""
40
+
41
+
42
+ def _go_to(row: int) -> str:
43
+ return f"{_CSI}{row};{_FIRST_COLUMN}H"
44
+
45
+
46
+ class StatusStrip:
47
+ """Reserves the top rows of a terminal for a status text and keeps the output below it.
48
+
49
+ The strip sits in rows `1..height`; output scrolls in the rows beneath it through a scroll
50
+ region, so it never overwrites or duplicates the strip. Showing or hiding the strip shifts
51
+ the visible output by its height instead of overwriting it; the rows pushed off the top
52
+ stay in the terminal's scrollback. A horizontal resize clears the reflowed terminal buffer;
53
+ the view then restores its output from the event log under the newly sized strip.
54
+ """
55
+
56
+ def __init__(self, console: Console, keyboard: TextIO, style: str) -> None:
57
+ """Draw onto `console` in `style`, asking the terminal behind `keyboard` for its cursor."""
58
+ self._console = console
59
+ self._keyboard = keyboard
60
+ self._style = style
61
+ self._rows: tuple[str, ...] = _NO_ROWS
62
+ self._width: int = console.size.width
63
+ self._height: int = console.size.height
64
+
65
+ def sync(self, text: str | None) -> bool:
66
+ """Draw `text` unless its row count changed; return whether output must be replayed."""
67
+ size = self._console.size
68
+ rows = self._wrap(text, size.width, size.height)
69
+ if rows == self._rows and size.width == self._width and size.height == self._height:
70
+ return False
71
+ if self._rows and len(rows) != len(self._rows):
72
+ return True
73
+ self._width = size.width
74
+ self._height = size.height
75
+ self._apply(rows, size.height)
76
+ return False
77
+
78
+ @property
79
+ def active(self) -> bool:
80
+ """Return whether a status currently reserves terminal rows."""
81
+ return bool(self._rows)
82
+
83
+ def reset_for_replay(self) -> None:
84
+ """Clear the screen and scrollback before the view restores its logged output."""
85
+ out = self._console.file
86
+ out.write(_CLEAR_SCREEN + _CLEAR_SCROLLBACK + _RESET_REGION + _HOME)
87
+ out.flush()
88
+ self._rows = _NO_ROWS
89
+ self._width = self._console.size.width
90
+ self._height = self._console.size.height
91
+
92
+ def _wrap(self, text: str | None, width: int, height: int) -> tuple[str, ...]:
93
+ if text is None:
94
+ return _NO_ROWS
95
+ return tuple(textwrap.wrap(text, width) or [""])[: max(height - 1, 1)]
96
+
97
+ def _apply(self, rows: tuple[str, ...], terminal_height: int) -> None:
98
+ previous = len(self._rows)
99
+ row = min(self._cursor_row(), terminal_height)
100
+ out = self._console.file
101
+ out.write(_RESET_REGION)
102
+ if len(rows) > previous:
103
+ grown = len(rows) - previous
104
+ overflow = max(row + grown - terminal_height, 0)
105
+ out.write(_go_to(terminal_height) + "\n" * overflow)
106
+ out.write(_go_to(previous + 1) + f"{_CSI}{grown}L")
107
+ row = min(row + grown, terminal_height)
108
+ elif len(rows) < previous:
109
+ shrunk = previous - len(rows)
110
+ out.write(_go_to(len(rows) + 1) + f"{_CSI}{shrunk}M")
111
+ row = max(row - shrunk, _FIRST_ROW)
112
+ if rows:
113
+ out.write(f"{_CSI}{len(rows) + 1};{terminal_height}r")
114
+ out.write(_go_to(row))
115
+ out.flush()
116
+ self._rows = rows
117
+ self._draw(rows)
118
+
119
+ def _draw(self, rows: tuple[str, ...]) -> None:
120
+ out = self._console.file
121
+ out.write(_SAVE_CURSOR)
122
+ for index, line in enumerate(rows):
123
+ out.write(_go_to(index + 1) + _ERASE_LINE)
124
+ self._console.print(Text(line, style=self._style), end="")
125
+ out.write(_RESTORE_CURSOR)
126
+ out.flush()
127
+
128
+ def _cursor_row(self) -> int:
129
+ descriptor = self._keyboard.fileno()
130
+ saved = termios.tcgetattr(descriptor)
131
+ try:
132
+ tty.setcbreak(descriptor)
133
+ self._console.file.write(_CURSOR_REPORT_REQUEST)
134
+ self._console.file.flush()
135
+ received = ""
136
+ while True:
137
+ ready, _, _ = select.select([descriptor], [], [], _CURSOR_REPORT_TIMEOUT_SECONDS)
138
+ if not ready:
139
+ message = "the terminal did not answer the cursor position request"
140
+ raise CursorReportError(message)
141
+ received += self._read(descriptor)
142
+ report = _CURSOR_REPORT.search(received)
143
+ if report is not None:
144
+ return int(report.group(1))
145
+ finally:
146
+ termios.tcsetattr(descriptor, termios.TCSADRAIN, saved)
147
+
148
+ def _read(self, descriptor: int) -> str:
149
+ return os.read(descriptor, _REPORT_READ_BYTES).decode(errors="ignore")
shikiru/ui/stream.py ADDED
@@ -0,0 +1,152 @@
1
+ """Renders a role's `view.jsonl` rows as terminal lines."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from rich.text import Text
8
+
9
+ from shikiru.agents.contracts import (
10
+ Compacted,
11
+ Message,
12
+ QuotaReported,
13
+ SessionStarted,
14
+ ToolFinished,
15
+ ToolOutcome,
16
+ ToolStarted,
17
+ TurnEnded,
18
+ TurnFailed,
19
+ UsageReported,
20
+ )
21
+ from shikiru.ui.glyphs import GLYPH_DONE, GLYPH_ESCALATED, GLYPH_PENDING, ROLE_GLYPHS
22
+ from shikiru.ui.tokens import ALERT_COLOR, IDLE_COLOR, ROLE_COLORS, STYLE_BOLD, STYLE_DIM
23
+ from shikiru.view_log import VIEW_RUNG_KEY, VIEW_SIGNAL_KEY, VIEW_TURN_KEY
24
+
25
+ if TYPE_CHECKING:
26
+ from collections.abc import Callable, Mapping
27
+
28
+ from shikiru.domain.tasks import Role
29
+
30
+ type ViewRow = Mapping[str, Any]
31
+
32
+ _TOOL_GLYPH = "▸"
33
+ _RUNG_SEPARATOR = ":"
34
+ _EFFORT_SEPARATOR = "/"
35
+ _DETAIL_SEPARATOR = " · "
36
+ _OUTCOME_FIELD = "outcome"
37
+ _EXIT_CODE_FIELD = "exit_code"
38
+ _EXIT_LABEL = "exit"
39
+
40
+
41
+ class UnknownViewSignalError(ValueError):
42
+ """A `view.jsonl` row names a signal this renderer does not know."""
43
+
44
+
45
+ class StreamLine:
46
+ """Turns view rows into Rich lines, opening each new turn with its step and rung."""
47
+
48
+ def __init__(self, role: Role) -> None:
49
+ """Render rows for `role`, whose colour and glyph mark turn boundaries."""
50
+ self._role = role
51
+ self._turn: str | None = None
52
+ self._renderers: dict[str, Callable[[ViewRow], Text | None]] = {
53
+ SessionStarted.__name__: _session_started,
54
+ Message.__name__: _message,
55
+ ToolStarted.__name__: _tool_started,
56
+ ToolFinished.__name__: _tool_finished,
57
+ UsageReported.__name__: _usage,
58
+ QuotaReported.__name__: _silent,
59
+ Compacted.__name__: _compacted,
60
+ TurnEnded.__name__: _turn_ended,
61
+ TurnFailed.__name__: _turn_failed,
62
+ }
63
+
64
+ def reset(self) -> None:
65
+ """Reset turn-boundary tracking before replaying a view log from its start."""
66
+ self._turn = None
67
+
68
+ def render(self, row: ViewRow) -> list[Text]:
69
+ """Return the lines `row` adds to the view, headed by a boundary on a new turn."""
70
+ signal = row[VIEW_SIGNAL_KEY]
71
+ renderer = self._renderers.get(signal)
72
+ if renderer is None:
73
+ message = f"unknown view signal {signal!r}"
74
+ raise UnknownViewSignalError(message)
75
+ lines: list[Text] = []
76
+ if row[VIEW_TURN_KEY] != self._turn:
77
+ self._turn = row[VIEW_TURN_KEY]
78
+ lines.append(self._boundary(row))
79
+ line = renderer(row)
80
+ if line is not None:
81
+ lines.append(line)
82
+ return lines
83
+
84
+ def _boundary(self, row: ViewRow) -> Text:
85
+ rung = row[VIEW_RUNG_KEY]
86
+ label = _RUNG_SEPARATOR.join((rung["agent"], rung["model"]))
87
+ if rung["effort"]:
88
+ label = f"{label}{_EFFORT_SEPARATOR}{rung['effort']}"
89
+ return Text(
90
+ f"{ROLE_GLYPHS[self._role]} {row[VIEW_TURN_KEY]} · {label}",
91
+ style=f"{STYLE_BOLD} {ROLE_COLORS[self._role]}",
92
+ )
93
+
94
+
95
+ def _silent(_row: ViewRow) -> None:
96
+ return None
97
+
98
+
99
+ def _session_started(row: ViewRow) -> Text:
100
+ return Text(f"{GLYPH_PENDING} session {row['session_id']}", style=STYLE_DIM)
101
+
102
+
103
+ def _message(row: ViewRow) -> Text:
104
+ return Text(row["text"])
105
+
106
+
107
+ def _tool_started(row: ViewRow) -> Text:
108
+ detail = f" {row['detail']}" if row["detail"] else ""
109
+ return Text(f"{_TOOL_GLYPH} {row['name']}{detail}", style=IDLE_COLOR)
110
+
111
+
112
+ def _tool_finished(row: ViewRow) -> Text | None:
113
+ outcome = row.get(_OUTCOME_FIELD)
114
+ exit_code = row.get(_EXIT_CODE_FIELD)
115
+ if (
116
+ outcome not in (ToolOutcome.SUCCESS.value, ToolOutcome.FAILURE.value)
117
+ and type(exit_code) is not int
118
+ ):
119
+ return None
120
+ details = [row["name"]]
121
+ if outcome in (ToolOutcome.SUCCESS.value, ToolOutcome.FAILURE.value):
122
+ details.append(outcome)
123
+ if type(exit_code) is int:
124
+ details.append(f"{_EXIT_LABEL} {exit_code}")
125
+ style = _tool_outcome_style(outcome)
126
+ return Text(f"{_TOOL_GLYPH} {_DETAIL_SEPARATOR.join(details)}", style=style)
127
+
128
+
129
+ def _tool_outcome_style(outcome: object) -> str:
130
+ if outcome == ToolOutcome.FAILURE.value:
131
+ return ALERT_COLOR
132
+ if outcome == ToolOutcome.SUCCESS.value:
133
+ return IDLE_COLOR
134
+ return STYLE_DIM
135
+
136
+
137
+ def _usage(row: ViewRow) -> Text:
138
+ usage = row["usage"]
139
+ tokens = usage["input_tokens"] + usage["output_tokens"]
140
+ return Text(f"{GLYPH_PENDING} {tokens} tokens", style=STYLE_DIM)
141
+
142
+
143
+ def _compacted(_row: ViewRow) -> Text:
144
+ return Text(f"{GLYPH_PENDING} context compacted", style=STYLE_DIM)
145
+
146
+
147
+ def _turn_ended(_row: ViewRow) -> Text:
148
+ return Text(f"{GLYPH_DONE} turn ended", style=STYLE_DIM)
149
+
150
+
151
+ def _turn_failed(row: ViewRow) -> Text:
152
+ return Text(f"{GLYPH_ESCALATED} {row['kind']}: {row['message']}", style=ALERT_COLOR)