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
shikiru/tmux/client.py
ADDED
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
"""The only tmux gateway: argv lists through `SubprocessRunner`, exact `=` targets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import shlex
|
|
7
|
+
import uuid
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING, Literal
|
|
10
|
+
|
|
11
|
+
from shikiru.errors import CommandFailed
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Mapping, Sequence
|
|
15
|
+
|
|
16
|
+
from shikiru.process import ProcessResult, SubprocessRunner
|
|
17
|
+
|
|
18
|
+
_TMUX_BIN = "tmux"
|
|
19
|
+
_UNKNOWN_ENVIRONMENT_VARIABLE_PREFIX = "unknown variable: "
|
|
20
|
+
_SOCKET_FLAG = "-L"
|
|
21
|
+
_CONFIG_FLAG = "-f"
|
|
22
|
+
_COMMAND_TIMEOUT_SECONDS = 5.0
|
|
23
|
+
_END_OF_OPTIONS = "--"
|
|
24
|
+
_COMMAND_SEPARATOR = ";"
|
|
25
|
+
TMUX_ENV_COMMAND = "env"
|
|
26
|
+
TMUX_ENV_UNSET_OPTION = "-u"
|
|
27
|
+
TMUX_ENV_UNSET_OPTION_ARGUMENT_COUNT = 2
|
|
28
|
+
ENV_VARIABLE_ASSIGNMENT_SEPARATOR = "="
|
|
29
|
+
_SESSION_SEPARATOR = ":"
|
|
30
|
+
_HORIZONTAL_AXIS = "h"
|
|
31
|
+
_FLAG_TRUE = "1"
|
|
32
|
+
_PRIVATE_SOCKET_SUFFIX_LENGTH = 8
|
|
33
|
+
_PREFIX_MODIFIER_LABELS = {"C": "Ctrl", "M": "Alt", "S": "Shift"}
|
|
34
|
+
_SPACE_KEY_NAME = "Space"
|
|
35
|
+
_TMUX_KEY_SEPARATOR = "-"
|
|
36
|
+
_KEY_LABEL_SEPARATOR = "+"
|
|
37
|
+
_PREFIX_OPTION = "prefix"
|
|
38
|
+
_DETACH_CLIENT_COMMAND = "detach-client"
|
|
39
|
+
_LIST_CLIENTS_COMMAND = "list-clients"
|
|
40
|
+
_NO_CLIENTS = "no clients"
|
|
41
|
+
_CLIENT_FIELD_SEPARATOR = "\t"
|
|
42
|
+
_DETACHED_FLAG = "-d"
|
|
43
|
+
_BEFORE_FLAG = "-b"
|
|
44
|
+
_RESIZE_HEIGHT_FLAG = "-y"
|
|
45
|
+
_LAYOUT_OPTION = "-t"
|
|
46
|
+
_IF_SHELL_COMMAND = "if-shell"
|
|
47
|
+
_ATTACHED_CLIENTS_FORMAT = "#{>:#{session_attached},0}"
|
|
48
|
+
_ATTACH_REFUSAL_MESSAGE = (
|
|
49
|
+
"A terminal is already attached to Shikiru session {session!r}. Detach it first, then retry."
|
|
50
|
+
)
|
|
51
|
+
_ATTACH_REFUSAL_COMMAND = "; "
|
|
52
|
+
_ATTACH_COMMAND = "attach-session"
|
|
53
|
+
_SWITCH_CLIENT_COMMAND = "switch-client"
|
|
54
|
+
_RUN_SHELL_COMMAND = "run-shell"
|
|
55
|
+
_FAILED_SHELL_COMMAND = "exit 1"
|
|
56
|
+
|
|
57
|
+
FORMAT_PANE_ID = "#{pane_id}"
|
|
58
|
+
FORMAT_PANE_PID = "#{pane_pid}"
|
|
59
|
+
FORMAT_PANE_DEAD = "#{pane_dead}"
|
|
60
|
+
FORMAT_WINDOW_ID = "#{window_id}"
|
|
61
|
+
FORMAT_WINDOW_NAME = "#{window_name}"
|
|
62
|
+
FORMAT_WINDOW_INDEX = "#{window_index}"
|
|
63
|
+
_TMUX_FORMAT_HASH = "#"
|
|
64
|
+
_TMUX_FORMAT_OPEN_BRACE = "{"
|
|
65
|
+
FORMAT_PANE_START_COMMAND = "#{pane_start_command}"
|
|
66
|
+
FORMAT_SOCKET_PATH = "#{socket_path}"
|
|
67
|
+
FORMAT_SESSION_NAME = "#{session_name}"
|
|
68
|
+
FORMAT_CLIENT_TTY = "#{client_tty}"
|
|
69
|
+
FORMAT_CLIENT_SESSION = "#{client_session}"
|
|
70
|
+
FORMAT_CLIENT_PANE_ID = "#{pane_id}"
|
|
71
|
+
FORMAT_SESSION_ID = "#{session_id}"
|
|
72
|
+
SESSION_ID_NAME_SEPARATOR = "\t"
|
|
73
|
+
SESSION_ID_LOOKUP_FORMAT = f"{FORMAT_SESSION_ID}{SESSION_ID_NAME_SEPARATOR}{FORMAT_SESSION_NAME}"
|
|
74
|
+
NATIVE_SESSION_ID_PREFIX = "$"
|
|
75
|
+
HOME_PANE_OPTION = "@shk_home"
|
|
76
|
+
HOME_PANE_OPTION_VALUE = "1"
|
|
77
|
+
LAYOUT_PANE_OPTION = "@shk_layout_pane"
|
|
78
|
+
FORMAT_LAYOUT_PANE = f"#{{{LAYOUT_PANE_OPTION}}}"
|
|
79
|
+
PANE_DEAD_FLAG = _FLAG_TRUE
|
|
80
|
+
SYSTEM_LAYOUT_PANE = "system"
|
|
81
|
+
NO_SERVER_RUNNING = "no server running on "
|
|
82
|
+
ERROR_CONNECTING = "error connecting to "
|
|
83
|
+
SOCKET_MISSING = "(No such file or directory)"
|
|
84
|
+
|
|
85
|
+
SplitAxis = Literal["h", "v"]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def private_socket_name(prefix: str) -> str:
|
|
89
|
+
"""Return a unique socket name short enough for the OS socket path limit."""
|
|
90
|
+
return f"{prefix}{uuid.uuid4().hex[:_PRIVATE_SOCKET_SUFFIX_LENGTH]}"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def exact_target(name: str) -> str:
|
|
94
|
+
"""Build the exact-match target (`=<name>`) tmux uses for a session, window or pane name."""
|
|
95
|
+
return f"={name}"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _escape_pane_title_formats(title: str) -> str:
|
|
99
|
+
escaped: list[str] = []
|
|
100
|
+
index = 0
|
|
101
|
+
while index < len(title):
|
|
102
|
+
if title[index] != _TMUX_FORMAT_HASH:
|
|
103
|
+
escaped.append(title[index])
|
|
104
|
+
index += 1
|
|
105
|
+
continue
|
|
106
|
+
run_end = index
|
|
107
|
+
while run_end < len(title) and title[run_end] == _TMUX_FORMAT_HASH:
|
|
108
|
+
run_end += 1
|
|
109
|
+
hash_run = title[index:run_end]
|
|
110
|
+
if run_end < len(title) and title[run_end] == _TMUX_FORMAT_OPEN_BRACE:
|
|
111
|
+
escaped.append(hash_run + hash_run)
|
|
112
|
+
else:
|
|
113
|
+
escaped.append(hash_run)
|
|
114
|
+
index = run_end
|
|
115
|
+
return "".join(escaped)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def tmux_key_label(key: str) -> str:
|
|
119
|
+
"""Format a tmux key such as `C-b` for an operator-facing hint."""
|
|
120
|
+
parts = key.split(_TMUX_KEY_SEPARATOR)
|
|
121
|
+
modifiers = parts[:-1]
|
|
122
|
+
if any(modifier not in _PREFIX_MODIFIER_LABELS for modifier in modifiers):
|
|
123
|
+
return key
|
|
124
|
+
name = parts[-1]
|
|
125
|
+
key_name = (
|
|
126
|
+
name.upper() if len(name) == 1 else _SPACE_KEY_NAME if name == _SPACE_KEY_NAME else name
|
|
127
|
+
)
|
|
128
|
+
return _KEY_LABEL_SEPARATOR.join(
|
|
129
|
+
(*(_PREFIX_MODIFIER_LABELS[modifier] for modifier in modifiers), key_name)
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _session_scoped(target: str) -> str:
|
|
134
|
+
return target if _SESSION_SEPARATOR in target else f"{target}{_SESSION_SEPARATOR}"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _command_args(command: Sequence[str] | None) -> list[str]:
|
|
138
|
+
if command is None:
|
|
139
|
+
return []
|
|
140
|
+
return [_END_OF_OPTIONS, *command]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def new_session_command(
|
|
144
|
+
name: str,
|
|
145
|
+
*,
|
|
146
|
+
cwd: Path,
|
|
147
|
+
env: Mapping[str, str],
|
|
148
|
+
window_name: str,
|
|
149
|
+
size: tuple[int, int],
|
|
150
|
+
command: Sequence[str] | None = None,
|
|
151
|
+
) -> tuple[str, ...]:
|
|
152
|
+
"""Build the `new-session` argv for a detached session sized `size`, printing its pane id."""
|
|
153
|
+
columns, rows = size
|
|
154
|
+
args = [
|
|
155
|
+
"new-session",
|
|
156
|
+
"-d",
|
|
157
|
+
"-x",
|
|
158
|
+
str(columns),
|
|
159
|
+
"-y",
|
|
160
|
+
str(rows),
|
|
161
|
+
"-P",
|
|
162
|
+
"-F",
|
|
163
|
+
FORMAT_PANE_ID,
|
|
164
|
+
"-s",
|
|
165
|
+
name,
|
|
166
|
+
"-c",
|
|
167
|
+
str(cwd),
|
|
168
|
+
"-n",
|
|
169
|
+
window_name,
|
|
170
|
+
]
|
|
171
|
+
for key, value in env.items():
|
|
172
|
+
args += ["-e", f"{key}={value}"]
|
|
173
|
+
args += _command_args(command)
|
|
174
|
+
return tuple(args)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def new_window_command(
|
|
178
|
+
target_session: str,
|
|
179
|
+
*,
|
|
180
|
+
name: str,
|
|
181
|
+
cwd: Path | None = None,
|
|
182
|
+
command: Sequence[str] | None = None,
|
|
183
|
+
detached: bool = False,
|
|
184
|
+
) -> tuple[str, ...]:
|
|
185
|
+
"""Build the `new-window` argv for `name` in `target_session`; it prints its base pane id."""
|
|
186
|
+
args = ["new-window", "-P", "-F", FORMAT_PANE_ID, "-t", target_session, "-n", name]
|
|
187
|
+
if detached:
|
|
188
|
+
args.append(_DETACHED_FLAG)
|
|
189
|
+
if cwd is not None:
|
|
190
|
+
args += ["-c", str(cwd)]
|
|
191
|
+
args += _command_args(command)
|
|
192
|
+
return tuple(args)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def session_option_commands(target: str, options: Mapping[str, str]) -> list[tuple[str, ...]]:
|
|
196
|
+
"""Build one `set-option` per entry of `options`, scoped to session `target`."""
|
|
197
|
+
return [
|
|
198
|
+
("set-option", "-t", _session_scoped(target), name, value)
|
|
199
|
+
for name, value in options.items()
|
|
200
|
+
]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def window_option_commands(target: str, options: Mapping[str, str]) -> list[tuple[str, ...]]:
|
|
204
|
+
"""Build one window `set-option` per entry of `options`; `target` may also be a pane."""
|
|
205
|
+
return [("set-option", "-w", "-t", target, name, value) for name, value in options.items()]
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def pane_option_commands(target: str, options: Mapping[str, str]) -> list[tuple[str, ...]]:
|
|
209
|
+
"""Build one pane `set-option` per entry of `options`, scoped to pane `target`."""
|
|
210
|
+
return [("set-option", "-p", "-t", target, name, value) for name, value in options.items()]
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def rename_window_command(target: str, name: str) -> tuple[str, ...]:
|
|
214
|
+
"""Build the argv that renames window `target` to `name`."""
|
|
215
|
+
return ("rename-window", "-t", target, name)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def pane_title_command(target: str, title: str) -> tuple[str, ...]:
|
|
219
|
+
"""Build the argv that titles pane `target` without changing the active pane."""
|
|
220
|
+
return ("select-pane", "-t", target, "-T", _escape_pane_title_formats(title))
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class Tmux:
|
|
224
|
+
"""Small, intention-revealing wrapper over the tmux CLI for one server (socket)."""
|
|
225
|
+
|
|
226
|
+
def __init__(
|
|
227
|
+
self,
|
|
228
|
+
runner: SubprocessRunner,
|
|
229
|
+
*,
|
|
230
|
+
socket: str | None = None,
|
|
231
|
+
env: Mapping[str, str] | None = None,
|
|
232
|
+
config_file: Path | None = None,
|
|
233
|
+
) -> None:
|
|
234
|
+
"""Use `runner` for every call, scoped to `socket` when one is given.
|
|
235
|
+
|
|
236
|
+
A `config_file` replaces the user's tmux configuration when this client starts a server.
|
|
237
|
+
"""
|
|
238
|
+
self._runner = runner
|
|
239
|
+
self._socket = socket
|
|
240
|
+
self._config_file = config_file
|
|
241
|
+
self._env = env
|
|
242
|
+
|
|
243
|
+
async def new_session(
|
|
244
|
+
self,
|
|
245
|
+
name: str,
|
|
246
|
+
*,
|
|
247
|
+
cwd: Path,
|
|
248
|
+
env: Mapping[str, str],
|
|
249
|
+
window_name: str,
|
|
250
|
+
size: tuple[int, int],
|
|
251
|
+
command: Sequence[str] | None = None,
|
|
252
|
+
) -> str:
|
|
253
|
+
"""Create a detached session `name` sized `size` until a client attaches.
|
|
254
|
+
|
|
255
|
+
Returns the id of its first (base) pane.
|
|
256
|
+
"""
|
|
257
|
+
result = await self._run(
|
|
258
|
+
*new_session_command(
|
|
259
|
+
name, cwd=cwd, env=env, window_name=window_name, size=size, command=command
|
|
260
|
+
)
|
|
261
|
+
)
|
|
262
|
+
return result.stdout.strip()
|
|
263
|
+
|
|
264
|
+
async def batch(self, commands: Sequence[Sequence[str]]) -> ProcessResult:
|
|
265
|
+
"""Run `commands` in one tmux process, joined by tmux's `;` separator.
|
|
266
|
+
|
|
267
|
+
tmux stops at the first command that fails: the call raises `CommandFailed` as a single
|
|
268
|
+
command would, and the commands after it do not run. The result is the whole process's,
|
|
269
|
+
so a `new-session` in the batch prints its pane id there.
|
|
270
|
+
"""
|
|
271
|
+
if not commands:
|
|
272
|
+
message = "a tmux batch needs at least one command"
|
|
273
|
+
raise ValueError(message)
|
|
274
|
+
args: list[str] = []
|
|
275
|
+
for index, command in enumerate(commands):
|
|
276
|
+
if index:
|
|
277
|
+
args.append(_COMMAND_SEPARATOR)
|
|
278
|
+
args.extend(command)
|
|
279
|
+
return await self._run(*args)
|
|
280
|
+
|
|
281
|
+
async def target_exists(self, target: str) -> bool:
|
|
282
|
+
"""Return whether `target` (a session or a window, exactly) exists.
|
|
283
|
+
|
|
284
|
+
`has-session` reports on either: it errors with "can't find window" for a missing
|
|
285
|
+
window target just as it does with "can't find session" for a missing session one.
|
|
286
|
+
"""
|
|
287
|
+
result = await self._run("has-session", "-t", target, check=False)
|
|
288
|
+
return result.returncode == 0
|
|
289
|
+
|
|
290
|
+
async def show_session_environment(self, target: str, name: str) -> str | None:
|
|
291
|
+
"""Read session environment variable `name`, or `None` if unset or removed.
|
|
292
|
+
|
|
293
|
+
`show-environment` exits 0 and prints `-<name>` (no `=`) for a variable that was
|
|
294
|
+
*removed* from the session (e.g. via `set-environment -r`); an `unknown variable` error
|
|
295
|
+
also means unset. Other command failures remain visible.
|
|
296
|
+
"""
|
|
297
|
+
result = await self._run("show-environment", "-t", target, name, check=False)
|
|
298
|
+
if result.returncode != 0:
|
|
299
|
+
if result.stderr.strip() == f"{_UNKNOWN_ENVIRONMENT_VARIABLE_PREFIX}{name}":
|
|
300
|
+
return None
|
|
301
|
+
raise CommandFailed(result.argv, result.returncode, result.stderr)
|
|
302
|
+
_, separator, value = result.stdout.strip().partition("=")
|
|
303
|
+
if not separator:
|
|
304
|
+
return None
|
|
305
|
+
return value
|
|
306
|
+
|
|
307
|
+
async def current_prefix(self, target: str) -> str:
|
|
308
|
+
"""Return the effective prefix key for session `target`."""
|
|
309
|
+
result = await self._run(
|
|
310
|
+
"show-options", "-Aqv", "-t", _session_scoped(target), _PREFIX_OPTION
|
|
311
|
+
)
|
|
312
|
+
prefix = result.stdout.strip()
|
|
313
|
+
if not prefix:
|
|
314
|
+
message = "tmux returned an empty prefix key"
|
|
315
|
+
raise ValueError(message)
|
|
316
|
+
return prefix
|
|
317
|
+
|
|
318
|
+
async def client_for_pane(self, session_target: str, pane_target: str) -> str | None:
|
|
319
|
+
"""Return tmux's input-selected client when it still shows `pane_target`."""
|
|
320
|
+
if not await self.pane_exists(pane_target):
|
|
321
|
+
return None
|
|
322
|
+
session_name = await self.display(pane_target, FORMAT_SESSION_NAME)
|
|
323
|
+
if exact_target(session_name) != session_target:
|
|
324
|
+
return None
|
|
325
|
+
clients = await self.list_clients(
|
|
326
|
+
session_target,
|
|
327
|
+
_CLIENT_FIELD_SEPARATOR.join(
|
|
328
|
+
(FORMAT_CLIENT_TTY, FORMAT_CLIENT_SESSION, FORMAT_CLIENT_PANE_ID)
|
|
329
|
+
),
|
|
330
|
+
)
|
|
331
|
+
if not clients:
|
|
332
|
+
return None
|
|
333
|
+
selected_client = await self.display(pane_target, FORMAT_CLIENT_TTY)
|
|
334
|
+
for line in clients:
|
|
335
|
+
client_tty, client_session, client_pane = line.split(_CLIENT_FIELD_SEPARATOR)
|
|
336
|
+
if (
|
|
337
|
+
client_session == session_name
|
|
338
|
+
and client_pane == pane_target
|
|
339
|
+
and client_tty == selected_client
|
|
340
|
+
):
|
|
341
|
+
return client_tty
|
|
342
|
+
return None
|
|
343
|
+
|
|
344
|
+
async def detach_client_for_pane(self, session_target: str, pane_target: str) -> bool:
|
|
345
|
+
"""Detach the input-selected client after revalidating its pane and session."""
|
|
346
|
+
client_target = await self.client_for_pane(session_target, pane_target)
|
|
347
|
+
if client_target is None:
|
|
348
|
+
return False
|
|
349
|
+
if await self.client_for_pane(session_target, pane_target) != client_target:
|
|
350
|
+
return False
|
|
351
|
+
await self.detach_client(client_target)
|
|
352
|
+
return True
|
|
353
|
+
|
|
354
|
+
async def list_clients(self, target: str, fmt: str) -> list[str]:
|
|
355
|
+
"""List client formats for `target`, returning no rows when it has no clients."""
|
|
356
|
+
result = await self._run(_LIST_CLIENTS_COMMAND, "-t", target, "-F", fmt, check=False)
|
|
357
|
+
if result.returncode == 0:
|
|
358
|
+
return result.stdout.splitlines()
|
|
359
|
+
if result.stderr.strip() == _NO_CLIENTS:
|
|
360
|
+
return []
|
|
361
|
+
raise CommandFailed(result.argv, result.returncode, result.stderr)
|
|
362
|
+
|
|
363
|
+
async def session_client_panes(self, name: str) -> list[str]:
|
|
364
|
+
"""Return the panes shown by live clients in session `name`."""
|
|
365
|
+
if name not in await self.list_sessions():
|
|
366
|
+
return []
|
|
367
|
+
return await self.list_clients(exact_target(name), FORMAT_CLIENT_PANE_ID)
|
|
368
|
+
|
|
369
|
+
def guarded_client_argv(self, name: str, *, switch: bool) -> tuple[str, ...]:
|
|
370
|
+
"""Build a client transition guarded by tmux's live attached-client count."""
|
|
371
|
+
target = exact_target(name)
|
|
372
|
+
session_target = f"{target}{_SESSION_SEPARATOR}"
|
|
373
|
+
refusal = _ATTACH_REFUSAL_COMMAND.join(
|
|
374
|
+
(
|
|
375
|
+
shlex.join(
|
|
376
|
+
(
|
|
377
|
+
"display-message",
|
|
378
|
+
"-p",
|
|
379
|
+
"-l",
|
|
380
|
+
_ATTACH_REFUSAL_MESSAGE.format(session=name),
|
|
381
|
+
)
|
|
382
|
+
),
|
|
383
|
+
shlex.join((_RUN_SHELL_COMMAND, _FAILED_SHELL_COMMAND)),
|
|
384
|
+
)
|
|
385
|
+
)
|
|
386
|
+
operation = _SWITCH_CLIENT_COMMAND if switch else _ATTACH_COMMAND
|
|
387
|
+
transition = shlex.join((operation, "-t", target))
|
|
388
|
+
argv = [_TMUX_BIN]
|
|
389
|
+
if self._socket is not None:
|
|
390
|
+
argv += [_SOCKET_FLAG, self._socket]
|
|
391
|
+
if self._config_file is not None:
|
|
392
|
+
argv += [_CONFIG_FLAG, str(self._config_file)]
|
|
393
|
+
argv += [
|
|
394
|
+
_IF_SHELL_COMMAND,
|
|
395
|
+
"-F",
|
|
396
|
+
"-t",
|
|
397
|
+
session_target,
|
|
398
|
+
_ATTACHED_CLIENTS_FORMAT,
|
|
399
|
+
refusal,
|
|
400
|
+
transition,
|
|
401
|
+
]
|
|
402
|
+
return tuple(argv)
|
|
403
|
+
|
|
404
|
+
async def detach_client(self, target: str) -> None:
|
|
405
|
+
"""Detach only the explicitly named tmux client `target`."""
|
|
406
|
+
await self._run(_DETACH_CLIENT_COMMAND, "-t", target)
|
|
407
|
+
|
|
408
|
+
async def set_session_option(self, target: str, name: str, value: str) -> None:
|
|
409
|
+
"""Set session option `name` on `target`.
|
|
410
|
+
|
|
411
|
+
`set-option` takes a target-pane, so a session-only `target` (no `:window` part) is
|
|
412
|
+
qualified with a trailing `:` to mean that session's current window; tmux still applies
|
|
413
|
+
a non-pane, non-window option at session scope.
|
|
414
|
+
"""
|
|
415
|
+
await self.batch(session_option_commands(target, {name: value}))
|
|
416
|
+
|
|
417
|
+
async def set_window_option(self, target: str, name: str, value: str) -> None:
|
|
418
|
+
"""Set window option `name` on `target`."""
|
|
419
|
+
await self.batch(window_option_commands(target, {name: value}))
|
|
420
|
+
|
|
421
|
+
async def set_pane_option(self, target: str, name: str, value: str) -> None:
|
|
422
|
+
"""Set pane option `name` on `target`."""
|
|
423
|
+
await self.batch(pane_option_commands(target, {name: value}))
|
|
424
|
+
|
|
425
|
+
async def rename_window(self, target: str, name: str) -> None:
|
|
426
|
+
"""Rename window `target` to `name`."""
|
|
427
|
+
await self._run(*rename_window_command(target, name))
|
|
428
|
+
|
|
429
|
+
async def set_pane_title(self, target: str, title: str) -> None:
|
|
430
|
+
"""Set pane `target`'s title without changing the active pane."""
|
|
431
|
+
await self._run(*pane_title_command(target, title))
|
|
432
|
+
|
|
433
|
+
async def select_pane(self, target: str) -> None:
|
|
434
|
+
"""Select the window containing pane `target`, then make that pane active."""
|
|
435
|
+
await self._run("select-window", "-t", target)
|
|
436
|
+
await self._run("select-pane", "-t", target)
|
|
437
|
+
|
|
438
|
+
async def select_window(self, target: str) -> None:
|
|
439
|
+
"""Select an existing window target."""
|
|
440
|
+
await self._run("select-window", "-t", target)
|
|
441
|
+
|
|
442
|
+
async def new_window(
|
|
443
|
+
self,
|
|
444
|
+
target_session: str,
|
|
445
|
+
*,
|
|
446
|
+
name: str,
|
|
447
|
+
cwd: Path | None = None,
|
|
448
|
+
command: Sequence[str] | None = None,
|
|
449
|
+
detached: bool = False,
|
|
450
|
+
) -> str:
|
|
451
|
+
"""Create a window named `name` in `target_session`, returning its base pane id."""
|
|
452
|
+
result = await self._run(
|
|
453
|
+
*new_window_command(
|
|
454
|
+
target_session, name=name, cwd=cwd, command=command, detached=detached
|
|
455
|
+
)
|
|
456
|
+
)
|
|
457
|
+
return result.stdout.strip()
|
|
458
|
+
|
|
459
|
+
async def kill_window(self, target: str) -> None:
|
|
460
|
+
"""Kill window `target`."""
|
|
461
|
+
await self._run("kill-window", "-t", target)
|
|
462
|
+
|
|
463
|
+
async def move_window(self, source: str, target: str) -> None:
|
|
464
|
+
"""Move a window to an explicit target without replacing an existing window."""
|
|
465
|
+
await self._run("move-window", "-s", source, "-t", target)
|
|
466
|
+
|
|
467
|
+
async def swap_windows(self, source: str, target: str) -> None:
|
|
468
|
+
"""Exchange two windows' positions without changing their identities."""
|
|
469
|
+
await self._run("swap-window", "-s", source, "-t", target)
|
|
470
|
+
|
|
471
|
+
async def kill_session(self, target: str) -> None:
|
|
472
|
+
"""Kill session `target`."""
|
|
473
|
+
await self._run("kill-session", "-t", target)
|
|
474
|
+
|
|
475
|
+
async def kill_server(self) -> None:
|
|
476
|
+
"""Stop this server and remove the socket file tmux leaves; no-op without a server."""
|
|
477
|
+
located = await self._run("display-message", "-p", FORMAT_SOCKET_PATH, check=False)
|
|
478
|
+
if located.returncode != 0:
|
|
479
|
+
return
|
|
480
|
+
await self._run("kill-server")
|
|
481
|
+
await asyncio.to_thread(Path(located.stdout.strip()).unlink, missing_ok=True)
|
|
482
|
+
|
|
483
|
+
async def display_message(self, target: str, message: str) -> None:
|
|
484
|
+
"""Display `message` in the status line for `target`."""
|
|
485
|
+
await self._run("display-message", "-t", target, message)
|
|
486
|
+
|
|
487
|
+
async def split_window(
|
|
488
|
+
self,
|
|
489
|
+
target: str,
|
|
490
|
+
*,
|
|
491
|
+
axis: SplitAxis,
|
|
492
|
+
size_pct: int,
|
|
493
|
+
cwd: Path | None = None,
|
|
494
|
+
detached: bool = False,
|
|
495
|
+
before: bool = False,
|
|
496
|
+
) -> str:
|
|
497
|
+
"""Split pane `target`, returning the new pane's id sized `size_pct`% of `target`."""
|
|
498
|
+
axis_flag = "-h" if axis == _HORIZONTAL_AXIS else "-v"
|
|
499
|
+
args = [
|
|
500
|
+
"split-window",
|
|
501
|
+
axis_flag,
|
|
502
|
+
"-P",
|
|
503
|
+
"-F",
|
|
504
|
+
FORMAT_PANE_ID,
|
|
505
|
+
"-l",
|
|
506
|
+
f"{size_pct}%",
|
|
507
|
+
"-t",
|
|
508
|
+
target,
|
|
509
|
+
]
|
|
510
|
+
if detached:
|
|
511
|
+
args.append(_DETACHED_FLAG)
|
|
512
|
+
if before:
|
|
513
|
+
args.append(_BEFORE_FLAG)
|
|
514
|
+
if cwd is not None:
|
|
515
|
+
args += ["-c", str(cwd)]
|
|
516
|
+
result = await self._run(*args)
|
|
517
|
+
return result.stdout.strip()
|
|
518
|
+
|
|
519
|
+
async def select_layout(self, target: str, layout: str) -> None:
|
|
520
|
+
"""Apply tmux's named layout to `target` without rebuilding its panes."""
|
|
521
|
+
await self._run("select-layout", _LAYOUT_OPTION, target, layout)
|
|
522
|
+
|
|
523
|
+
async def resize_pane_height(self, target: str, height: int) -> None:
|
|
524
|
+
"""Set pane `target` to an exact number of rows."""
|
|
525
|
+
await self._run("resize-pane", _RESIZE_HEIGHT_FLAG, str(height), "-t", target)
|
|
526
|
+
|
|
527
|
+
async def respawn_pane(
|
|
528
|
+
self,
|
|
529
|
+
target: str,
|
|
530
|
+
argv: Sequence[str],
|
|
531
|
+
*,
|
|
532
|
+
env: Mapping[str, str | None],
|
|
533
|
+
cwd: Path,
|
|
534
|
+
) -> None:
|
|
535
|
+
"""Restart pane `target`'s process, killing any survivor first."""
|
|
536
|
+
args = ["respawn-pane", "-k", "-t", target, "-c", str(cwd)]
|
|
537
|
+
command = [TMUX_ENV_COMMAND]
|
|
538
|
+
for key, value in env.items():
|
|
539
|
+
if value is None:
|
|
540
|
+
command += [TMUX_ENV_UNSET_OPTION, key]
|
|
541
|
+
for key, value in env.items():
|
|
542
|
+
if value is not None:
|
|
543
|
+
command.append(f"{key}={value}")
|
|
544
|
+
args += [_END_OF_OPTIONS, *command, *argv]
|
|
545
|
+
await self._run(*args)
|
|
546
|
+
|
|
547
|
+
async def is_pane_dead(self, target: str) -> bool:
|
|
548
|
+
"""Return whether pane `target`'s process has exited (kept alive by `remain-on-exit`)."""
|
|
549
|
+
if not await self.pane_exists(target):
|
|
550
|
+
return True
|
|
551
|
+
result = await self.display(target, FORMAT_PANE_DEAD)
|
|
552
|
+
return result == _FLAG_TRUE
|
|
553
|
+
|
|
554
|
+
async def list_panes(self, target: str, fmt: str) -> list[str]:
|
|
555
|
+
"""List every pane of `target`, one formatted line per pane."""
|
|
556
|
+
result = await self._run("list-panes", "-t", target, "-F", fmt)
|
|
557
|
+
return result.stdout.splitlines()
|
|
558
|
+
|
|
559
|
+
async def list_session_panes(self, target: str, fmt: str) -> list[str]:
|
|
560
|
+
"""List every pane across every window of session `target`."""
|
|
561
|
+
result = await self._run("list-panes", "-s", "-t", target, "-F", fmt)
|
|
562
|
+
return result.stdout.splitlines()
|
|
563
|
+
|
|
564
|
+
async def list_session_windows(
|
|
565
|
+
self, target: str, *, row_format: str = FORMAT_WINDOW_ID
|
|
566
|
+
) -> list[str]:
|
|
567
|
+
"""List one `row_format` line per window of `target`, without selecting a window."""
|
|
568
|
+
result = await self._run("list-windows", "-t", target, "-F", row_format)
|
|
569
|
+
return result.stdout.splitlines()
|
|
570
|
+
|
|
571
|
+
async def list_sessions(self) -> list[str]:
|
|
572
|
+
"""List session names, returning an empty list when this server is absent."""
|
|
573
|
+
result = await self._run("list-sessions", "-F", FORMAT_SESSION_NAME, check=False)
|
|
574
|
+
if result.returncode == 0:
|
|
575
|
+
return result.stdout.splitlines()
|
|
576
|
+
error = result.stderr.strip()
|
|
577
|
+
if error.startswith(NO_SERVER_RUNNING) or (
|
|
578
|
+
error.startswith(ERROR_CONNECTING) and error.endswith(SOCKET_MISSING)
|
|
579
|
+
):
|
|
580
|
+
return []
|
|
581
|
+
raise CommandFailed(result.argv, result.returncode, result.stderr)
|
|
582
|
+
|
|
583
|
+
async def session_id(self, name: str) -> str:
|
|
584
|
+
"""Return the native ID of the exact session named `name`."""
|
|
585
|
+
result = await self._run("list-sessions", "-F", SESSION_ID_LOOKUP_FORMAT, check=False)
|
|
586
|
+
if result.returncode != 0:
|
|
587
|
+
raise CommandFailed(result.argv, result.returncode, result.stderr)
|
|
588
|
+
session_ids: list[str] = []
|
|
589
|
+
for row in result.stdout.splitlines():
|
|
590
|
+
session_id, separator, session_name = row.partition(SESSION_ID_NAME_SEPARATOR)
|
|
591
|
+
if separator and session_name == name:
|
|
592
|
+
session_ids.append(session_id)
|
|
593
|
+
if len(session_ids) != 1:
|
|
594
|
+
message = f"tmux did not return one native session ID for {name!r}"
|
|
595
|
+
raise ValueError(message)
|
|
596
|
+
session_id = session_ids[0]
|
|
597
|
+
if not session_id.startswith(NATIVE_SESSION_ID_PREFIX) or not session_id[1:].isdecimal():
|
|
598
|
+
message = f"tmux returned an invalid native session ID: {session_id!r}"
|
|
599
|
+
raise ValueError(message)
|
|
600
|
+
return session_id
|
|
601
|
+
|
|
602
|
+
async def pane_exists(self, target: str) -> bool:
|
|
603
|
+
"""Return whether an exact pane target exists."""
|
|
604
|
+
result = await self._run(
|
|
605
|
+
"display-message", "-p", "-t", target, "-F", FORMAT_PANE_ID, check=False
|
|
606
|
+
)
|
|
607
|
+
return result.returncode == 0 and result.stdout.strip() == target
|
|
608
|
+
|
|
609
|
+
async def window_id(self, target: str) -> str:
|
|
610
|
+
"""Return the stable window id for pane or window target `target`."""
|
|
611
|
+
return await self.display(target, FORMAT_WINDOW_ID)
|
|
612
|
+
|
|
613
|
+
async def display(self, target: str, fmt: str) -> str:
|
|
614
|
+
"""Evaluate format `fmt` in the context of `target` and return it, trimmed."""
|
|
615
|
+
result = await self._run("display-message", "-p", "-t", target, "-F", fmt)
|
|
616
|
+
return result.stdout.strip()
|
|
617
|
+
|
|
618
|
+
async def bind_key(self, key: str, *command: str, table: str = "prefix") -> None:
|
|
619
|
+
"""Bind `key` in key table `table` to run `command` (a single tmux command line)."""
|
|
620
|
+
await self._run("bind-key", "-T", table, key, *command)
|
|
621
|
+
|
|
622
|
+
async def _run(
|
|
623
|
+
self, *args: str, input_text: str | None = None, check: bool = True
|
|
624
|
+
) -> ProcessResult:
|
|
625
|
+
argv = [_TMUX_BIN]
|
|
626
|
+
if self._socket is not None:
|
|
627
|
+
argv += [_SOCKET_FLAG, self._socket]
|
|
628
|
+
if self._config_file is not None:
|
|
629
|
+
argv += [_CONFIG_FLAG, str(self._config_file)]
|
|
630
|
+
argv += list(args)
|
|
631
|
+
return await self._runner.run(
|
|
632
|
+
argv,
|
|
633
|
+
timeout=_COMMAND_TIMEOUT_SECONDS,
|
|
634
|
+
env=self._env,
|
|
635
|
+
input_text=input_text,
|
|
636
|
+
check=check,
|
|
637
|
+
)
|