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.
- shikiru/__init__.py +1 -0
- shikiru/__main__.py +8 -0
- shikiru/agents/__init__.py +3 -0
- shikiru/agents/contracts.py +590 -0
- shikiru/agents/registry.py +87 -0
- shikiru/agents/sdk/__init__.py +17 -0
- shikiru/agents/sdk/reset_time.py +137 -0
- shikiru/agents/sdk/turn.py +335 -0
- shikiru/agents/settings.py +151 -0
- shikiru/agents/testing/__init__.py +26 -0
- shikiru/agents/testing/contract_suite.py +653 -0
- shikiru/agents/testing/resume.py +15 -0
- shikiru/bootstrap.py +1277 -0
- shikiru/cli/__init__.py +1 -0
- shikiru/cli/app.py +770 -0
- shikiru/cli/context.py +63 -0
- shikiru/cli/render.py +1475 -0
- shikiru/cli/report.py +156 -0
- shikiru/cli/session_prompt.py +63 -0
- shikiru/cli/view.py +131 -0
- shikiru/clock.py +41 -0
- shikiru/config/__init__.py +8 -0
- shikiru/config/defaults.toml +131 -0
- shikiru/config/loader.py +178 -0
- shikiru/config/settings.py +392 -0
- shikiru/dashboard/__init__.py +3 -0
- shikiru/dashboard/actions_list.py +799 -0
- shikiru/dashboard/activity.py +150 -0
- shikiru/dashboard/app.py +1765 -0
- shikiru/dashboard/blocker_evidence.py +122 -0
- shikiru/dashboard/blocker_rows.py +84 -0
- shikiru/dashboard/briefing_view.py +871 -0
- shikiru/dashboard/contract.py +1086 -0
- shikiru/dashboard/covered.py +32 -0
- shikiru/dashboard/detail.py +1209 -0
- shikiru/dashboard/detail_fold.py +244 -0
- shikiru/dashboard/detail_hint.py +68 -0
- shikiru/dashboard/details_list_view.py +150 -0
- shikiru/dashboard/diagnostics.py +80 -0
- shikiru/dashboard/diagnostics_sections.py +186 -0
- shikiru/dashboard/evidence_table.py +123 -0
- shikiru/dashboard/extra_details.py +214 -0
- shikiru/dashboard/fault_guidance.py +216 -0
- shikiru/dashboard/fold_bodies.py +276 -0
- shikiru/dashboard/help_keys.py +166 -0
- shikiru/dashboard/help_screen.py +204 -0
- shikiru/dashboard/interaction.py +17 -0
- shikiru/dashboard/ladder_editor.py +544 -0
- shikiru/dashboard/live_settings.py +62 -0
- shikiru/dashboard/motion.py +368 -0
- shikiru/dashboard/orchestration_fields.py +201 -0
- shikiru/dashboard/palette.py +1606 -0
- shikiru/dashboard/palette_app.py +89 -0
- shikiru/dashboard/palette_drafts.py +33 -0
- shikiru/dashboard/resolution.py +573 -0
- shikiru/dashboard/resolution_view.py +302 -0
- shikiru/dashboard/row_list.py +256 -0
- shikiru/dashboard/section_rows.py +173 -0
- shikiru/dashboard/services.py +665 -0
- shikiru/dashboard/session_history.py +402 -0
- shikiru/dashboard/shikiru_init_ascii_art.txt +40 -0
- shikiru/dashboard/task_picker.py +358 -0
- shikiru/dashboard/tick.py +59 -0
- shikiru/dashboard/timeline_filter.py +129 -0
- shikiru/dashboard/timeline_section.py +385 -0
- shikiru/dashboard/usage_body.py +269 -0
- shikiru/dashboard/views.py +2314 -0
- shikiru/dashboard/wizard.py +1389 -0
- shikiru/domain/__init__.py +1 -0
- shikiru/domain/acceptance.py +130 -0
- shikiru/domain/activity.py +28 -0
- shikiru/domain/attempts.py +333 -0
- shikiru/domain/briefing.py +257 -0
- shikiru/domain/commands.py +219 -0
- shikiru/domain/decisions.py +231 -0
- shikiru/domain/docker.py +262 -0
- shikiru/domain/engine_uptime.py +87 -0
- shikiru/domain/escalation.py +194 -0
- shikiru/domain/escalation_evidence.py +186 -0
- shikiru/domain/escalation_panel.py +144 -0
- shikiru/domain/events.py +71 -0
- shikiru/domain/evidence_export.py +89 -0
- shikiru/domain/faults.py +86 -0
- shikiru/domain/feedback.py +180 -0
- shikiru/domain/feedback_rounds.py +172 -0
- shikiru/domain/fencing.py +31 -0
- shikiru/domain/ladder.py +102 -0
- shikiru/domain/ladder_text.py +44 -0
- shikiru/domain/levels.py +44 -0
- shikiru/domain/motion.py +199 -0
- shikiru/domain/packet_evidence.py +377 -0
- shikiru/domain/pushes.py +47 -0
- shikiru/domain/quota_blocks.py +44 -0
- shikiru/domain/quota_waits.py +219 -0
- shikiru/domain/recovery.py +194 -0
- shikiru/domain/reports.py +498 -0
- shikiru/domain/results.py +217 -0
- shikiru/domain/runs.py +85 -0
- shikiru/domain/scheduling.py +231 -0
- shikiru/domain/status_tasks.py +40 -0
- shikiru/domain/steps.py +325 -0
- shikiru/domain/tasks.py +174 -0
- shikiru/domain/timeline.py +208 -0
- shikiru/domain/timeline_phrases.py +271 -0
- shikiru/domain/versions.py +18 -0
- shikiru/domain/workflow.py +372 -0
- shikiru/engine/__init__.py +0 -0
- shikiru/engine/acceptance.py +90 -0
- shikiru/engine/agents.py +61 -0
- shikiru/engine/cosmetic_decorator.py +99 -0
- shikiru/engine/daemon.py +409 -0
- shikiru/engine/dependency.py +37 -0
- shikiru/engine/escalation_filing.py +110 -0
- shikiru/engine/escalations.py +401 -0
- shikiru/engine/events.py +285 -0
- shikiru/engine/evidence.py +81 -0
- shikiru/engine/fault_reporter.py +144 -0
- shikiru/engine/feedback.py +577 -0
- shikiru/engine/feedback_audit.py +80 -0
- shikiru/engine/feedback_intake.py +95 -0
- shikiru/engine/feedback_snapshot.py +148 -0
- shikiru/engine/flow.py +87 -0
- shikiru/engine/handlers/__init__.py +33 -0
- shikiru/engine/handlers/base.py +148 -0
- shikiru/engine/handlers/engine.py +24 -0
- shikiru/engine/handlers/escalations.py +174 -0
- shikiru/engine/handlers/operator.py +81 -0
- shikiru/engine/handlers/tasks.py +237 -0
- shikiru/engine/history.py +128 -0
- shikiru/engine/integration.py +1234 -0
- shikiru/engine/integration_turns.py +337 -0
- shikiru/engine/layout_guard.py +362 -0
- shikiru/engine/lifecycle.py +60 -0
- shikiru/engine/monitor.py +317 -0
- shikiru/engine/notification_delivery.py +124 -0
- shikiru/engine/notification_feed.py +57 -0
- shikiru/engine/notifier.py +91 -0
- shikiru/engine/pull_request_closing.py +117 -0
- shikiru/engine/push_service.py +177 -0
- shikiru/engine/quota_probe_threads.py +79 -0
- shikiru/engine/quota_reconciler.py +210 -0
- shikiru/engine/quota_schedule.py +24 -0
- shikiru/engine/quota_waits.py +102 -0
- shikiru/engine/reconcile.py +91 -0
- shikiru/engine/recovery.py +284 -0
- shikiru/engine/retention.py +102 -0
- shikiru/engine/root_preparation.py +86 -0
- shikiru/engine/scheduler.py +320 -0
- shikiru/engine/session_guard.py +46 -0
- shikiru/engine/sessions.py +95 -0
- shikiru/engine/signalled.py +75 -0
- shikiru/engine/step_runner.py +140 -0
- shikiru/engine/supervisor.py +101 -0
- shikiru/engine/takeover.py +223 -0
- shikiru/engine/task_facts.py +46 -0
- shikiru/engine/tick_guard.py +95 -0
- shikiru/engine/titles.py +297 -0
- shikiru/engine/transitions.py +79 -0
- shikiru/engine/turn_context.py +206 -0
- shikiru/engine/turn_execution.py +30 -0
- shikiru/engine/turn_loop.py +230 -0
- shikiru/engine/turn_processes.py +79 -0
- shikiru/engine/turns.py +457 -0
- shikiru/errors.py +144 -0
- shikiru/helper/__init__.py +3 -0
- shikiru/helper/helper.py +182 -0
- shikiru/helper/models.py +11 -0
- shikiru/mcp/__init__.py +1 -0
- shikiru/mcp/server.py +196 -0
- shikiru/operator/__init__.py +33 -0
- shikiru/operator/attachment.py +24 -0
- shikiru/operator/commands.py +319 -0
- shikiru/operator/detectors.py +374 -0
- shikiru/operator/doctor.py +351 -0
- shikiru/operator/events.py +57 -0
- shikiru/operator/evidence_export.py +498 -0
- shikiru/operator/evidence_writer.py +78 -0
- shikiru/operator/lifecycle.py +294 -0
- shikiru/operator/plugin_checks.py +194 -0
- shikiru/operator/probe.py +287 -0
- shikiru/operator/queries.py +1601 -0
- shikiru/operator/services.py +36 -0
- shikiru/operator/sessions.py +378 -0
- shikiru/operator/strip_views.py +162 -0
- shikiru/operator/timeline.py +416 -0
- shikiru/operator/toml_text.py +62 -0
- shikiru/palette.py +18 -0
- shikiru/paths.py +91 -0
- shikiru/plugins/__init__.py +0 -0
- shikiru/plugins/agents/__init__.py +1 -0
- shikiru/plugins/agents/claude/__init__.py +5 -0
- shikiru/plugins/agents/claude/agent.py +251 -0
- shikiru/plugins/agents/claude/context.py +116 -0
- shikiru/plugins/agents/claude/headless.py +92 -0
- shikiru/plugins/agents/claude/hook.py +57 -0
- shikiru/plugins/agents/claude/model_catalog.py +105 -0
- shikiru/plugins/agents/claude/options.py +62 -0
- shikiru/plugins/agents/claude/turn.py +1018 -0
- shikiru/plugins/agents/codex/__init__.py +5 -0
- shikiru/plugins/agents/codex/agent.py +486 -0
- shikiru/plugins/agents/codex/headless.py +166 -0
- shikiru/plugins/agents/codex/home.py +14 -0
- shikiru/plugins/agents/codex/model_catalog.py +128 -0
- shikiru/plugins/agents/codex/options.py +94 -0
- shikiru/plugins/agents/codex/quota.py +195 -0
- shikiru/plugins/agents/codex/sandbox.py +31 -0
- shikiru/plugins/agents/codex/sandbox_probe.py +97 -0
- shikiru/plugins/agents/codex/turn.py +767 -0
- shikiru/plugins/agents/codex/usage.py +106 -0
- shikiru/plugins/agents/shared_settings.py +62 -0
- shikiru/plugins/widgets/__init__.py +3 -0
- shikiru/plugins/widgets/dialogue.py +144 -0
- shikiru/plugins/widgets/disk.py +123 -0
- shikiru/plugins/widgets/docker.py +225 -0
- shikiru/plugins/widgets/escalations.py +402 -0
- shikiru/plugins/widgets/faults.py +204 -0
- shikiru/plugins/widgets/header.py +321 -0
- shikiru/plugins/widgets/queue.py +1140 -0
- shikiru/plugins/widgets/queue_hint.py +93 -0
- shikiru/plugins/widgets/resources.py +105 -0
- shikiru/plugins/widgets/strips.py +82 -0
- shikiru/plugins/widgets/usage.py +176 -0
- shikiru/private.py +94 -0
- shikiru/process.py +535 -0
- shikiru/prompts/__init__.py +3 -0
- shikiru/prompts/renderer.py +85 -0
- shikiru/prompts/templates/_base.md.j2 +17 -0
- shikiru/prompts/templates/_macros.md.j2 +61 -0
- shikiru/prompts/templates/conflicts.md.j2 +22 -0
- shikiru/prompts/templates/consult.md.j2 +5 -0
- shikiru/prompts/templates/continue.md.j2 +4 -0
- shikiru/prompts/templates/feedback_analysis.md.j2 +29 -0
- shikiru/prompts/templates/feedback_rework.md.j2 +33 -0
- shikiru/prompts/templates/feedback_verify.md.j2 +33 -0
- shikiru/prompts/templates/handoff.md.j2 +23 -0
- shikiru/prompts/templates/implement.md.j2 +13 -0
- shikiru/prompts/templates/plan.md.j2 +30 -0
- shikiru/prompts/templates/rework.md.j2 +13 -0
- shikiru/prompts/templates/triage.md.j2 +20 -0
- shikiru/prompts/templates/verify.md.j2 +29 -0
- shikiru/store/__init__.py +1 -0
- shikiru/store/commands.py +242 -0
- shikiru/store/database.py +297 -0
- shikiru/store/escalations.py +240 -0
- shikiru/store/events.py +213 -0
- shikiru/store/feedback.py +266 -0
- shikiru/store/migrations/001_initial.sql +63 -0
- shikiru/store/migrations/002_headless_turns.sql +31 -0
- shikiru/store/migrations/003_drop_command_handled_at.sql +1 -0
- shikiru/store/migrations/004_repo_base_ref.sql +3 -0
- shikiru/store/migrations/005_complexity_explicit.sql +3 -0
- shikiru/store/migrations/006_events_kind_index.sql +1 -0
- shikiru/store/migrations/007_notification_cursor.sql +1 -0
- shikiru/store/migrations/008_reported_usage.sql +1 -0
- shikiru/store/migrations/009_billing_declarations.sql +2 -0
- shikiru/store/migrations/010_canonical_quota_windows.sql +3 -0
- shikiru/store/migrations/012_drop_task_quota_evidence.sql +2 -0
- shikiru/store/migrations/013_push_targets.sql +7 -0
- shikiru/store/migrations/014_agent_block_time.sql +6 -0
- shikiru/store/migrations/015_repair_orphan_agent_blocks.sql +3 -0
- shikiru/store/migrations/016_feedback_rounds.sql +29 -0
- shikiru/store/migrations/017_feedback_round_comments.sql +5 -0
- shikiru/store/migrations/__init__.py +1 -0
- shikiru/store/notification_cursor.py +29 -0
- shikiru/store/pushes.py +117 -0
- shikiru/store/quotas.py +153 -0
- shikiru/store/reader.py +310 -0
- shikiru/store/retention.py +92 -0
- shikiru/store/runs.py +227 -0
- shikiru/store/tasks.py +949 -0
- shikiru/telemetry/__init__.py +1 -0
- shikiru/telemetry/disk.py +167 -0
- shikiru/telemetry/docker.py +164 -0
- shikiru/telemetry/resources.py +296 -0
- shikiru/tmux/__init__.py +3 -0
- shikiru/tmux/client.py +637 -0
- shikiru/tmux/decorator.py +225 -0
- shikiru/tmux/layout.py +200 -0
- shikiru/tmux/session.py +381 -0
- shikiru/ui/__init__.py +9 -0
- shikiru/ui/age.py +45 -0
- shikiru/ui/bubbles.py +41 -0
- shikiru/ui/compact.py +1209 -0
- shikiru/ui/console.py +49 -0
- shikiru/ui/fold.py +50 -0
- shikiru/ui/gated_static.py +117 -0
- shikiru/ui/gauge.py +40 -0
- shikiru/ui/glyphs.py +109 -0
- shikiru/ui/keys.py +57 -0
- shikiru/ui/live_interval.py +42 -0
- shikiru/ui/popup.py +311 -0
- shikiru/ui/quota_status.py +26 -0
- shikiru/ui/recent_window.py +78 -0
- shikiru/ui/screen.py +57 -0
- shikiru/ui/section.py +193 -0
- shikiru/ui/session.py +65 -0
- shikiru/ui/size.py +17 -0
- shikiru/ui/splash.py +42 -0
- shikiru/ui/status.py +47 -0
- shikiru/ui/status_strip.py +149 -0
- shikiru/ui/stream.py +152 -0
- shikiru/ui/strip.py +332 -0
- shikiru/ui/theme.py +83 -0
- shikiru/ui/tokens.py +51 -0
- shikiru/view_log.py +126 -0
- shikiru/workspace/__init__.py +3 -0
- shikiru/workspace/base_refs.py +160 -0
- shikiru/workspace/caches.py +136 -0
- shikiru/workspace/facts.py +118 -0
- shikiru/workspace/git.py +601 -0
- shikiru/workspace/github.py +744 -0
- shikiru/workspace/messages.py +107 -0
- shikiru/workspace/reports.py +145 -0
- shikiru/workspace/roots.py +180 -0
- shikiru_cli-0.2.0.dist-info/METADATA +428 -0
- shikiru_cli-0.2.0.dist-info/RECORD +319 -0
- shikiru_cli-0.2.0.dist-info/WHEEL +4 -0
- shikiru_cli-0.2.0.dist-info/entry_points.txt +18 -0
- shikiru_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Pane and window decorations and titles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from string import Formatter
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from shikiru.errors import ConfigError
|
|
9
|
+
from shikiru.palette import (
|
|
10
|
+
BACKGROUND_COLOR,
|
|
11
|
+
PRIMARY_TEXT_COLOR,
|
|
12
|
+
SECONDARY_TEXT_COLOR,
|
|
13
|
+
SELECTED_SURFACE_COLOR,
|
|
14
|
+
)
|
|
15
|
+
from shikiru.tmux.client import (
|
|
16
|
+
pane_option_commands,
|
|
17
|
+
pane_title_command,
|
|
18
|
+
rename_window_command,
|
|
19
|
+
window_option_commands,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from collections.abc import Mapping
|
|
24
|
+
|
|
25
|
+
from shikiru.tmux.client import Tmux
|
|
26
|
+
|
|
27
|
+
PANE_COLOR_OPTION = "@shk_color"
|
|
28
|
+
PANE_GLYPH_OPTION = "@shk_glyph"
|
|
29
|
+
WINDOW_STATE_COLOR_OPTION = "@shk_state_color"
|
|
30
|
+
WINDOW_STATE_GLYPH_OPTION = "@shk_state_glyph"
|
|
31
|
+
PANE_TITLE_FORMAT_OPTION = "@shk_title_format"
|
|
32
|
+
|
|
33
|
+
PANE_BORDER_FORMAT = (
|
|
34
|
+
f"#[fg={PRIMARY_TEXT_COLOR},bg={BACKGROUND_COLOR}] "
|
|
35
|
+
f"#{{?{PANE_TITLE_FORMAT_OPTION},#{{{PANE_TITLE_FORMAT_OPTION}}},#{{pane_title}}}}"
|
|
36
|
+
f" #[fg={PRIMARY_TEXT_COLOR},bg={BACKGROUND_COLOR}]"
|
|
37
|
+
)
|
|
38
|
+
WINDOW_STATUS_FORMAT = (
|
|
39
|
+
f"#[default]#[fg=#{{{WINDOW_STATE_COLOR_OPTION}}},bg={BACKGROUND_COLOR}] "
|
|
40
|
+
f"#{{{WINDOW_STATE_GLYPH_OPTION}}} "
|
|
41
|
+
f"#[fg={SECONDARY_TEXT_COLOR},bg={BACKGROUND_COLOR}]#{{window_index}}:"
|
|
42
|
+
f"#[fg={PRIMARY_TEXT_COLOR},bg={BACKGROUND_COLOR}]#{{window_name}}"
|
|
43
|
+
f"#[default]#[fg={PRIMARY_TEXT_COLOR},bg={BACKGROUND_COLOR}]"
|
|
44
|
+
)
|
|
45
|
+
WINDOW_STATUS_CURRENT_FORMAT = (
|
|
46
|
+
f"#[fg={PRIMARY_TEXT_COLOR},bg={SELECTED_SURFACE_COLOR},bold]["
|
|
47
|
+
f"#[fg=#{{{WINDOW_STATE_COLOR_OPTION}}},bg={SELECTED_SURFACE_COLOR}]"
|
|
48
|
+
f"#{{{WINDOW_STATE_GLYPH_OPTION}}} "
|
|
49
|
+
f"#[fg={SECONDARY_TEXT_COLOR},bg={SELECTED_SURFACE_COLOR}]#{{window_index}}:"
|
|
50
|
+
f"#[fg={PRIMARY_TEXT_COLOR},bg={SELECTED_SURFACE_COLOR}]#{{window_name}}"
|
|
51
|
+
f" #[fg={PRIMARY_TEXT_COLOR},bg={SELECTED_SURFACE_COLOR},bold]]"
|
|
52
|
+
f"#[default]#[fg={PRIMARY_TEXT_COLOR},bg={BACKGROUND_COLOR}]"
|
|
53
|
+
)
|
|
54
|
+
HOME_WINDOW_STATUS_FORMAT = (
|
|
55
|
+
f"#[default]#[fg={PRIMARY_TEXT_COLOR},bg={BACKGROUND_COLOR}]#{{window_index}}:"
|
|
56
|
+
f"#{{window_name}}#[default]#[fg={PRIMARY_TEXT_COLOR},bg={BACKGROUND_COLOR}]"
|
|
57
|
+
)
|
|
58
|
+
HOME_WINDOW_STATUS_CURRENT_FORMAT = (
|
|
59
|
+
f"#[fg={PRIMARY_TEXT_COLOR},bg={SELECTED_SURFACE_COLOR},bold]["
|
|
60
|
+
f"#{{window_index}}:#{{window_name}}]"
|
|
61
|
+
f"#[default]#[fg={PRIMARY_TEXT_COLOR},bg={BACKGROUND_COLOR}]"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
_PANE_BORDER_FORMAT_OPTION = "pane-border-format"
|
|
65
|
+
_WINDOW_STATUS_FORMAT_OPTION = "window-status-format"
|
|
66
|
+
_WINDOW_STATUS_CURRENT_FORMAT_OPTION = "window-status-current-format"
|
|
67
|
+
_TMUX_FORMAT_HASH = "#"
|
|
68
|
+
_TMUX_FORMAT_ESCAPED_HASH = "##"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def render_title(template: str, fields: Mapping[str, object]) -> str:
|
|
72
|
+
"""Render a `dashboard.titles` template, failing loudly on any field it can't fill."""
|
|
73
|
+
try:
|
|
74
|
+
return template.format(**fields)
|
|
75
|
+
except KeyError as error:
|
|
76
|
+
message = f"title template {template!r} references unknown field {error.args[0]!r}"
|
|
77
|
+
raise ConfigError(message) from error
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _render_styled_title(
|
|
81
|
+
template: str,
|
|
82
|
+
fields: Mapping[str, object],
|
|
83
|
+
field_colors: Mapping[str, str],
|
|
84
|
+
field_segments: Mapping[str, tuple[tuple[str, str], ...]],
|
|
85
|
+
) -> str:
|
|
86
|
+
render_title(template, fields)
|
|
87
|
+
formatter = Formatter()
|
|
88
|
+
output: list[str] = [f"#[fg={PRIMARY_TEXT_COLOR},bg={BACKGROUND_COLOR}]"]
|
|
89
|
+
literal_parts: list[str] = []
|
|
90
|
+
for literal, field_name, format_spec, conversion in formatter.parse(template):
|
|
91
|
+
literal_parts.append(literal)
|
|
92
|
+
if field_name is None:
|
|
93
|
+
continue
|
|
94
|
+
if literal_parts:
|
|
95
|
+
output.extend(
|
|
96
|
+
(
|
|
97
|
+
f"#[fg={PRIMARY_TEXT_COLOR},bg={BACKGROUND_COLOR}]",
|
|
98
|
+
_escape_tmux_format("".join(literal_parts)),
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
literal_parts.clear()
|
|
102
|
+
value, _ = formatter.get_field(field_name, (), fields)
|
|
103
|
+
if conversion is not None:
|
|
104
|
+
value = formatter.convert_field(value, conversion)
|
|
105
|
+
resolved_spec = ""
|
|
106
|
+
if format_spec is not None:
|
|
107
|
+
resolved_spec = formatter.vformat(format_spec, (), fields)
|
|
108
|
+
rendered = formatter.format_field(value, resolved_spec)
|
|
109
|
+
segments = field_segments.get(field_name)
|
|
110
|
+
if segments is not None and "".join(text for text, _ in segments) == rendered:
|
|
111
|
+
for segment_text, color in segments:
|
|
112
|
+
output.extend(
|
|
113
|
+
(
|
|
114
|
+
f"#[fg={color},bg={BACKGROUND_COLOR}]",
|
|
115
|
+
_escape_tmux_format(segment_text),
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
else:
|
|
119
|
+
color = field_colors.get(field_name, PRIMARY_TEXT_COLOR)
|
|
120
|
+
output.extend((f"#[fg={color},bg={BACKGROUND_COLOR}]", _escape_tmux_format(rendered)))
|
|
121
|
+
if literal_parts:
|
|
122
|
+
output.extend(
|
|
123
|
+
(
|
|
124
|
+
f"#[fg={PRIMARY_TEXT_COLOR},bg={BACKGROUND_COLOR}]",
|
|
125
|
+
_escape_tmux_format("".join(literal_parts)),
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
output.append(f"#[fg={PRIMARY_TEXT_COLOR},bg={BACKGROUND_COLOR}]")
|
|
129
|
+
return "".join(output)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _escape_tmux_format(text: str) -> str:
|
|
133
|
+
return text.replace(_TMUX_FORMAT_HASH, _TMUX_FORMAT_ESCAPED_HASH)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class PaneDecorator:
|
|
137
|
+
"""Sets the `@shk_*` decorations, their formats, and renders pane and window titles."""
|
|
138
|
+
|
|
139
|
+
def __init__(self, tmux: Tmux) -> None:
|
|
140
|
+
"""Decorate through `tmux`."""
|
|
141
|
+
self._tmux = tmux
|
|
142
|
+
|
|
143
|
+
async def configure_formats(self, window_target: str) -> None:
|
|
144
|
+
"""Set the border and window-status formats that read the `@shk_*` options."""
|
|
145
|
+
await self._tmux.batch(
|
|
146
|
+
window_option_commands(
|
|
147
|
+
window_target,
|
|
148
|
+
{
|
|
149
|
+
_PANE_BORDER_FORMAT_OPTION: PANE_BORDER_FORMAT,
|
|
150
|
+
_WINDOW_STATUS_FORMAT_OPTION: WINDOW_STATUS_FORMAT,
|
|
151
|
+
_WINDOW_STATUS_CURRENT_FORMAT_OPTION: WINDOW_STATUS_CURRENT_FORMAT,
|
|
152
|
+
},
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
async def set_pane_title_styling(
|
|
157
|
+
self,
|
|
158
|
+
pane_target: str,
|
|
159
|
+
template: str,
|
|
160
|
+
fields: Mapping[str, object],
|
|
161
|
+
*,
|
|
162
|
+
field_colors: Mapping[str, str],
|
|
163
|
+
field_segments: Mapping[str, tuple[tuple[str, str], ...]],
|
|
164
|
+
) -> None:
|
|
165
|
+
"""Set pane `pane_target`'s title and its styled border row in one tmux call."""
|
|
166
|
+
await self._set_pane_title(
|
|
167
|
+
pane_target,
|
|
168
|
+
template,
|
|
169
|
+
fields,
|
|
170
|
+
field_colors=field_colors,
|
|
171
|
+
field_segments=field_segments,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
async def set_pane_state(self, pane_target: str, *, color: str, glyph: str) -> None:
|
|
175
|
+
"""Set pane `pane_target`'s `@shk_color` and `@shk_glyph`."""
|
|
176
|
+
await self._tmux.batch(
|
|
177
|
+
pane_option_commands(pane_target, {PANE_COLOR_OPTION: color, PANE_GLYPH_OPTION: glyph})
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
async def refresh_window(
|
|
181
|
+
self,
|
|
182
|
+
window_target: str,
|
|
183
|
+
template: str,
|
|
184
|
+
fields: Mapping[str, object],
|
|
185
|
+
*,
|
|
186
|
+
color: str,
|
|
187
|
+
glyph: str,
|
|
188
|
+
) -> None:
|
|
189
|
+
"""Set window `window_target`'s state and rename it to `template` rendered with `fields`."""
|
|
190
|
+
title = render_title(template, fields)
|
|
191
|
+
await self._tmux.batch(
|
|
192
|
+
[
|
|
193
|
+
rename_window_command(window_target, title),
|
|
194
|
+
*window_option_commands(
|
|
195
|
+
window_target,
|
|
196
|
+
{WINDOW_STATE_COLOR_OPTION: color, WINDOW_STATE_GLYPH_OPTION: glyph},
|
|
197
|
+
),
|
|
198
|
+
]
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
async def set_pane_title(
|
|
202
|
+
self, pane_target: str, template: str, fields: Mapping[str, object]
|
|
203
|
+
) -> None:
|
|
204
|
+
"""Render `template` with `fields` and set it as pane `pane_target`'s title."""
|
|
205
|
+
await self._set_pane_title(
|
|
206
|
+
pane_target, template, fields, field_colors={}, field_segments={}
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
async def _set_pane_title(
|
|
210
|
+
self,
|
|
211
|
+
pane_target: str,
|
|
212
|
+
template: str,
|
|
213
|
+
fields: Mapping[str, object],
|
|
214
|
+
*,
|
|
215
|
+
field_colors: Mapping[str, str],
|
|
216
|
+
field_segments: Mapping[str, tuple[tuple[str, str], ...]],
|
|
217
|
+
) -> None:
|
|
218
|
+
title = render_title(template, fields)
|
|
219
|
+
title_format = _render_styled_title(template, fields, field_colors, field_segments)
|
|
220
|
+
await self._tmux.batch(
|
|
221
|
+
[
|
|
222
|
+
pane_title_command(pane_target, title),
|
|
223
|
+
*pane_option_commands(pane_target, {PANE_TITLE_FORMAT_OPTION: title_format}),
|
|
224
|
+
]
|
|
225
|
+
)
|
shikiru/tmux/layout.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Builds a task window's panes from `dashboard.layout`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from shikiru.config.settings import LayoutSplit, PaneLeaf
|
|
8
|
+
from shikiru.errors import ConfigError
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from shikiru.config.settings import LayoutNode, LayoutSplit
|
|
15
|
+
from shikiru.tmux.client import Tmux
|
|
16
|
+
|
|
17
|
+
_FULL_PERCENT = 100
|
|
18
|
+
_RESTORE_SPLIT_PERCENT = 50
|
|
19
|
+
_VERTICAL_STACK_LAYOUT = "even-vertical"
|
|
20
|
+
_VERTICAL_AXIS = "v"
|
|
21
|
+
_MIN_STACK_PANES = 2
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class LayoutBuilder:
|
|
25
|
+
"""Splits a window's base pane into the tree `dashboard.layout` describes."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, tmux: Tmux) -> None:
|
|
28
|
+
"""Split panes through `tmux`."""
|
|
29
|
+
self._tmux = tmux
|
|
30
|
+
|
|
31
|
+
async def build(
|
|
32
|
+
self, base_pane_id: str, root: LayoutNode, *, cwd: Path | None = None
|
|
33
|
+
) -> dict[str, str]:
|
|
34
|
+
"""Build `root` starting from `base_pane_id`, returning `{pane name: pane id}`.
|
|
35
|
+
|
|
36
|
+
Raises `ConfigError` if two leaves share a pane name.
|
|
37
|
+
"""
|
|
38
|
+
panes: dict[str, str] = {}
|
|
39
|
+
await self._place(base_pane_id, root, panes, cwd)
|
|
40
|
+
return panes
|
|
41
|
+
|
|
42
|
+
async def restore_missing(
|
|
43
|
+
self,
|
|
44
|
+
root: LayoutNode,
|
|
45
|
+
existing: Mapping[str, str],
|
|
46
|
+
*,
|
|
47
|
+
cwd: Path,
|
|
48
|
+
) -> dict[str, str]:
|
|
49
|
+
"""Restore absent layout leaves beside surviving leaves and return their pane ids."""
|
|
50
|
+
panes = dict(existing)
|
|
51
|
+
await self._restore_node(root, panes, cwd)
|
|
52
|
+
return panes
|
|
53
|
+
|
|
54
|
+
async def restore_vertical_stack(
|
|
55
|
+
self, window_id: str, root: LayoutSplit, panes: Mapping[str, str]
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Reapply configured sizes to a direct vertical leaf stack without replacing panes."""
|
|
58
|
+
if root.split != _VERTICAL_AXIS or not all(
|
|
59
|
+
isinstance(child, PaneLeaf) for child in root.panes
|
|
60
|
+
):
|
|
61
|
+
return
|
|
62
|
+
leaves = [child for child in root.panes if isinstance(child, PaneLeaf)]
|
|
63
|
+
if len(leaves) < _MIN_STACK_PANES:
|
|
64
|
+
return
|
|
65
|
+
await self._tmux.select_layout(window_id, _VERTICAL_STACK_LAYOUT)
|
|
66
|
+
fractions = _resolve_fractions([leaf.size for leaf in leaves])
|
|
67
|
+
available_height = sum(
|
|
68
|
+
[int(await self._tmux.display(panes[leaf.pane], "#{pane_height}")) for leaf in leaves]
|
|
69
|
+
)
|
|
70
|
+
heights = _allocate_heights(fractions, available_height)
|
|
71
|
+
for leaf, height in zip(leaves[:-1], heights[:-1], strict=True):
|
|
72
|
+
await self._tmux.resize_pane_height(panes[leaf.pane], height)
|
|
73
|
+
|
|
74
|
+
async def _restore_node(self, node: LayoutNode, panes: dict[str, str], cwd: Path) -> None:
|
|
75
|
+
if isinstance(node, PaneLeaf):
|
|
76
|
+
return
|
|
77
|
+
for index, child in enumerate(node.panes):
|
|
78
|
+
names = _leaf_names(child)
|
|
79
|
+
if names.issubset(panes):
|
|
80
|
+
if not isinstance(child, PaneLeaf):
|
|
81
|
+
await self._restore_node(child, panes, cwd)
|
|
82
|
+
continue
|
|
83
|
+
if names.intersection(panes):
|
|
84
|
+
await self._restore_node(child, panes, cwd)
|
|
85
|
+
continue
|
|
86
|
+
target, before = _neighbor_pane(node.panes, index, panes)
|
|
87
|
+
if target is None:
|
|
88
|
+
message = "dashboard.layout cannot restore panes without a surviving neighbor"
|
|
89
|
+
raise ConfigError(message)
|
|
90
|
+
base_pane = await self._tmux.split_window(
|
|
91
|
+
target,
|
|
92
|
+
axis=node.split,
|
|
93
|
+
size_pct=_RESTORE_SPLIT_PERCENT,
|
|
94
|
+
cwd=cwd,
|
|
95
|
+
detached=True,
|
|
96
|
+
before=before,
|
|
97
|
+
)
|
|
98
|
+
await self._place(base_pane, child, panes, cwd)
|
|
99
|
+
|
|
100
|
+
async def _place(
|
|
101
|
+
self, pane_id: str, node: LayoutNode, panes: dict[str, str], cwd: Path | None
|
|
102
|
+
) -> None:
|
|
103
|
+
if isinstance(node, PaneLeaf):
|
|
104
|
+
if node.pane in panes:
|
|
105
|
+
message = f"dashboard.layout has more than one pane named {node.pane!r}"
|
|
106
|
+
raise ConfigError(message)
|
|
107
|
+
panes[node.pane] = pane_id
|
|
108
|
+
return
|
|
109
|
+
await self._place_split(pane_id, node, panes, cwd)
|
|
110
|
+
|
|
111
|
+
async def _place_split(
|
|
112
|
+
self, pane_id: str, node: LayoutSplit, panes: dict[str, str], cwd: Path | None
|
|
113
|
+
) -> None:
|
|
114
|
+
sizes = [child.size if isinstance(child, PaneLeaf) else None for child in node.panes]
|
|
115
|
+
fractions = _resolve_fractions(sizes)
|
|
116
|
+
remaining_fraction = 1.0
|
|
117
|
+
current_pane_id = pane_id
|
|
118
|
+
for child, fraction in zip(node.panes[:-1], fractions[:-1], strict=True):
|
|
119
|
+
rest_fraction = remaining_fraction - fraction
|
|
120
|
+
rest_pct = round(rest_fraction / remaining_fraction * _FULL_PERCENT)
|
|
121
|
+
rest_pane_id = await self._tmux.split_window(
|
|
122
|
+
current_pane_id,
|
|
123
|
+
axis=node.split,
|
|
124
|
+
size_pct=rest_pct,
|
|
125
|
+
cwd=cwd,
|
|
126
|
+
detached=True,
|
|
127
|
+
)
|
|
128
|
+
await self._place(current_pane_id, child, panes, cwd)
|
|
129
|
+
current_pane_id = rest_pane_id
|
|
130
|
+
remaining_fraction = rest_fraction
|
|
131
|
+
await self._place(current_pane_id, node.panes[-1], panes, cwd)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _leaf_names(node: LayoutNode) -> set[str]:
|
|
135
|
+
if isinstance(node, PaneLeaf):
|
|
136
|
+
return {node.pane}
|
|
137
|
+
names: set[str] = set()
|
|
138
|
+
for child in node.panes:
|
|
139
|
+
names.update(_leaf_names(child))
|
|
140
|
+
return names
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _neighbor_pane(
|
|
144
|
+
siblings: list[LayoutNode], index: int, panes: Mapping[str, str]
|
|
145
|
+
) -> tuple[str | None, bool]:
|
|
146
|
+
for sibling in reversed(siblings[:index]):
|
|
147
|
+
names = _leaf_names(sibling).intersection(panes)
|
|
148
|
+
if names:
|
|
149
|
+
return panes[_last_leaf_name(sibling, names)], False
|
|
150
|
+
for sibling in siblings[index + 1 :]:
|
|
151
|
+
names = _leaf_names(sibling).intersection(panes)
|
|
152
|
+
if names:
|
|
153
|
+
return panes[_first_leaf_name(sibling, names)], True
|
|
154
|
+
return None, False
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _first_leaf_name(node: LayoutNode, existing: set[str]) -> str:
|
|
158
|
+
if isinstance(node, PaneLeaf):
|
|
159
|
+
return node.pane
|
|
160
|
+
for child in node.panes:
|
|
161
|
+
names = _leaf_names(child).intersection(existing)
|
|
162
|
+
if names:
|
|
163
|
+
return _first_leaf_name(child, names)
|
|
164
|
+
message = "dashboard.layout has no first surviving pane"
|
|
165
|
+
raise ConfigError(message)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _last_leaf_name(node: LayoutNode, existing: set[str]) -> str:
|
|
169
|
+
if isinstance(node, PaneLeaf):
|
|
170
|
+
return node.pane
|
|
171
|
+
for child in reversed(node.panes):
|
|
172
|
+
names = _leaf_names(child).intersection(existing)
|
|
173
|
+
if names:
|
|
174
|
+
return _last_leaf_name(child, names)
|
|
175
|
+
message = "dashboard.layout has no last surviving pane"
|
|
176
|
+
raise ConfigError(message)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _resolve_fractions(sizes: list[int | None]) -> list[float]:
|
|
180
|
+
explicit_total = sum(size for size in sizes if size is not None)
|
|
181
|
+
if explicit_total > _FULL_PERCENT:
|
|
182
|
+
message = f"dashboard.layout pane sizes add up to more than 100% ({explicit_total}%)"
|
|
183
|
+
raise ConfigError(message)
|
|
184
|
+
unsized_count = sizes.count(None)
|
|
185
|
+
share = (_FULL_PERCENT - explicit_total) / unsized_count if unsized_count else 0.0
|
|
186
|
+
return [(size if size is not None else share) / _FULL_PERCENT for size in sizes]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _allocate_heights(fractions: list[float], total_height: int) -> list[int]:
|
|
190
|
+
exact_heights = [fraction * total_height for fraction in fractions]
|
|
191
|
+
heights = [int(height) for height in exact_heights]
|
|
192
|
+
remaining = total_height - sum(heights)
|
|
193
|
+
remainders = sorted(
|
|
194
|
+
range(len(heights)),
|
|
195
|
+
key=lambda index: exact_heights[index] - heights[index],
|
|
196
|
+
reverse=True,
|
|
197
|
+
)
|
|
198
|
+
for index in remainders[:remaining]:
|
|
199
|
+
heights[index] += 1
|
|
200
|
+
return heights
|