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