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/ui/console.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Human terminal output shared by CLI surfaces."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
OSC8_TERMINAL_PROGRAMS = frozenset(
|
|
10
|
+
{
|
|
11
|
+
"Hyper",
|
|
12
|
+
"WezTerm",
|
|
13
|
+
"contour",
|
|
14
|
+
"ghostty",
|
|
15
|
+
"vscode",
|
|
16
|
+
"iTerm.app",
|
|
17
|
+
}
|
|
18
|
+
)
|
|
19
|
+
OSC8_TERMINAL_NAMES = frozenset(
|
|
20
|
+
{
|
|
21
|
+
"alacritty",
|
|
22
|
+
"contour",
|
|
23
|
+
"foot",
|
|
24
|
+
"kitty",
|
|
25
|
+
"wezterm",
|
|
26
|
+
"xterm-ghostty",
|
|
27
|
+
"xterm-kitty",
|
|
28
|
+
}
|
|
29
|
+
)
|
|
30
|
+
MIN_VTE_OSC8_VERSION = 5000
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def human_console(*, stderr: bool = False) -> Console:
|
|
34
|
+
"""Return a Rich console that keeps human data literal and detects terminal output modes."""
|
|
35
|
+
return Console(stderr=stderr, soft_wrap=True, markup=False, highlight=False, emoji=False)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def supports_osc8(console: Console) -> bool:
|
|
39
|
+
"""Return whether the active terminal is known to support OSC 8 hyperlinks."""
|
|
40
|
+
if not console.is_terminal or console.is_dumb_terminal:
|
|
41
|
+
return False
|
|
42
|
+
if os.environ.get("WT_SESSION"):
|
|
43
|
+
return True
|
|
44
|
+
if os.environ.get("TERM_PROGRAM") in OSC8_TERMINAL_PROGRAMS:
|
|
45
|
+
return True
|
|
46
|
+
if os.environ.get("TERM") in OSC8_TERMINAL_NAMES:
|
|
47
|
+
return True
|
|
48
|
+
vte_version = os.environ.get("VTE_VERSION", "")
|
|
49
|
+
return vte_version.isdecimal() and int(vte_version) >= MIN_VTE_OSC8_VERSION
|
shikiru/ui/fold.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""The focusable one-line fold heading shared by the popups that expand a section in place."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
6
|
+
|
|
7
|
+
from textual.binding import Binding
|
|
8
|
+
from textual.message import Message
|
|
9
|
+
from textual.widgets import Static
|
|
10
|
+
|
|
11
|
+
from shikiru.ui.section import key_label
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
from textual import events
|
|
16
|
+
from textual.binding import BindingType
|
|
17
|
+
|
|
18
|
+
COLLAPSED_ARROW = "\N{BLACK RIGHT-POINTING SMALL TRIANGLE}"
|
|
19
|
+
EXPANDED_ARROW = "\N{BLACK DOWN-POINTING SMALL TRIANGLE}"
|
|
20
|
+
FOLD_TOGGLE_KEY = "t"
|
|
21
|
+
FOLD_TOGGLE_LABEL = "show/hide"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def fold_arrow(*, expanded: bool) -> str:
|
|
25
|
+
"""Return the arrow that shows whether a fold is open."""
|
|
26
|
+
return EXPANDED_ARROW if expanded else COLLAPSED_ARROW
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def fold_toggle_hint() -> Text:
|
|
30
|
+
"""Return the `t show/hide` hint shown at the right of a fold heading."""
|
|
31
|
+
return key_label(FOLD_TOGGLE_KEY, FOLD_TOGGLE_LABEL)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class FoldLine(Static, can_focus=True):
|
|
35
|
+
"""A fold heading that asks to toggle on Enter, Space or a click."""
|
|
36
|
+
|
|
37
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
38
|
+
Binding("enter,space", "toggle_fold", "Toggle", show=False),
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
class Toggled(Message):
|
|
42
|
+
"""The heading asked to expand or collapse its section."""
|
|
43
|
+
|
|
44
|
+
def action_toggle_fold(self) -> None:
|
|
45
|
+
"""Ask to expand or collapse the section."""
|
|
46
|
+
self.post_message(self.Toggled())
|
|
47
|
+
|
|
48
|
+
def on_click(self, event: events.Click) -> None:
|
|
49
|
+
event.stop()
|
|
50
|
+
self.post_message(self.Toggled())
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""A `Static` that repaints only on changed output and reflows only when its size can change."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from rich.console import Group
|
|
9
|
+
from rich.rule import Rule
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
from textual.visual import Visual, visualize
|
|
12
|
+
from textual.widgets import Static
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from textual.strip import Strip
|
|
16
|
+
from textual.visual import VisualType
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class _Shown:
|
|
21
|
+
width: int
|
|
22
|
+
strips: tuple[Strip, ...]
|
|
23
|
+
|
|
24
|
+
def has_same_shape(self, other: _Shown) -> bool:
|
|
25
|
+
return self.width == other.width and self._extent() == other._extent()
|
|
26
|
+
|
|
27
|
+
def _extent(self) -> tuple[int, int]:
|
|
28
|
+
return len(self.strips), max((strip.cell_length for strip in self.strips), default=0)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class _Signature:
|
|
33
|
+
width: int
|
|
34
|
+
selection: object
|
|
35
|
+
key: object
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _renderable_key(content: object) -> object | None:
|
|
39
|
+
if isinstance(content, str):
|
|
40
|
+
return content
|
|
41
|
+
if type(content) is Group:
|
|
42
|
+
keys = tuple(_renderable_key(item) for item in content.renderables)
|
|
43
|
+
return None if None in keys else (content.fit, keys)
|
|
44
|
+
if type(content) is Rule:
|
|
45
|
+
return (content.title, content.characters, content.style, content.end, content.align)
|
|
46
|
+
if type(content) is Text:
|
|
47
|
+
return (
|
|
48
|
+
content.plain,
|
|
49
|
+
tuple(content.spans),
|
|
50
|
+
content.style,
|
|
51
|
+
content.justify,
|
|
52
|
+
content.overflow,
|
|
53
|
+
content.no_wrap,
|
|
54
|
+
content.end,
|
|
55
|
+
content.tab_size,
|
|
56
|
+
)
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class GatedStatic(Static):
|
|
61
|
+
"""Skips updates that show the same output; repaints without reflow when the extent holds."""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
content: VisualType = "",
|
|
66
|
+
*,
|
|
67
|
+
id: str | None = None, # noqa: A002
|
|
68
|
+
classes: str | None = None,
|
|
69
|
+
markup: bool = True,
|
|
70
|
+
) -> None:
|
|
71
|
+
super().__init__(content, id=id, classes=classes, markup=markup)
|
|
72
|
+
self._shown: _Shown | None = None
|
|
73
|
+
self._signature: _Signature | None = None
|
|
74
|
+
|
|
75
|
+
def update(self, content: VisualType = "", *, layout: bool = True) -> None:
|
|
76
|
+
"""Update unconditionally and forget what was last shown."""
|
|
77
|
+
self._shown = None
|
|
78
|
+
self._signature = None
|
|
79
|
+
super().update(content, layout=layout)
|
|
80
|
+
|
|
81
|
+
def notify_style_update(self) -> None:
|
|
82
|
+
"""Forget the shown signature because the styles that shape the output changed."""
|
|
83
|
+
self._signature = None
|
|
84
|
+
super().notify_style_update()
|
|
85
|
+
|
|
86
|
+
def update_if_changed(self, content: VisualType, *, key: object | None = None) -> bool:
|
|
87
|
+
"""Show `content` unless it renders identically to what is shown; True when updated.
|
|
88
|
+
|
|
89
|
+
`key`, when given, asserts that `content` renders as a pure function of it and of the
|
|
90
|
+
widget's width, so an equal key skips rendering altogether.
|
|
91
|
+
"""
|
|
92
|
+
width = self.content_size.width
|
|
93
|
+
has_content_sized_width = self.styles.width is not None and self.styles.width.is_auto
|
|
94
|
+
if width <= 0 or has_content_sized_width:
|
|
95
|
+
self.update(content)
|
|
96
|
+
return True
|
|
97
|
+
render_key = key if key is not None else _renderable_key(content)
|
|
98
|
+
signature = (
|
|
99
|
+
None
|
|
100
|
+
if render_key is None
|
|
101
|
+
else _Signature(width, self.text_selection, (self._render_markup, render_key))
|
|
102
|
+
)
|
|
103
|
+
if signature is not None and signature == self._signature:
|
|
104
|
+
return False
|
|
105
|
+
visual = visualize(self, content, markup=self._render_markup)
|
|
106
|
+
shown = _Shown(
|
|
107
|
+
width,
|
|
108
|
+
tuple(Visual.to_strips(self, visual, width, None, self.visual_style)),
|
|
109
|
+
)
|
|
110
|
+
previous = self._shown
|
|
111
|
+
if previous == shown:
|
|
112
|
+
self._signature = signature
|
|
113
|
+
return False
|
|
114
|
+
super().update(content, layout=previous is None or not previous.has_same_shape(shown))
|
|
115
|
+
self._shown = shown
|
|
116
|
+
self._signature = signature
|
|
117
|
+
return True
|
shikiru/ui/gauge.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Small coloured percentage gauges."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
|
|
9
|
+
from shikiru.domain.levels import usage_level
|
|
10
|
+
from shikiru.ui.glyphs import GAUGE_GLYPHS
|
|
11
|
+
from shikiru.ui.tokens import USAGE_LEVEL_COLORS
|
|
12
|
+
|
|
13
|
+
GAUGE_LEVELS = len(GAUGE_GLYPHS) - 1
|
|
14
|
+
GAUGE_MAX_PCT = 100
|
|
15
|
+
PERCENT_FORMAT = ".0f"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Gauge:
|
|
20
|
+
"""Render a label and a glyph and percentage coloured by the usage level they reach."""
|
|
21
|
+
|
|
22
|
+
label: str
|
|
23
|
+
used_pct: float
|
|
24
|
+
warn_pct: int
|
|
25
|
+
alert_pct: int
|
|
26
|
+
|
|
27
|
+
def render(self) -> Text:
|
|
28
|
+
"""Render the nearest gauge glyph and the unbounded reported percentage."""
|
|
29
|
+
level = round(min(max(self.used_pct, 0.0), GAUGE_MAX_PCT) * GAUGE_LEVELS / GAUGE_MAX_PCT)
|
|
30
|
+
glyph = GAUGE_GLYPHS[level]
|
|
31
|
+
color = USAGE_LEVEL_COLORS[
|
|
32
|
+
usage_level(self.used_pct, warn_pct=self.warn_pct, alert_pct=self.alert_pct)
|
|
33
|
+
]
|
|
34
|
+
text = Text()
|
|
35
|
+
if self.label:
|
|
36
|
+
text.append(self.label, style="dim")
|
|
37
|
+
text.append(" ")
|
|
38
|
+
text.append(glyph, style=color)
|
|
39
|
+
text.append(f" {self.used_pct:{PERCENT_FORMAT}}%", style=color)
|
|
40
|
+
return text
|
shikiru/ui/glyphs.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""The one glyph set shared by the CLI and the dashboard.
|
|
2
|
+
|
|
3
|
+
Ambiguous-width characters (`◆ ▲ ≡ · ○ ◑ ●`) are drawn one cell wide, as Textual assumes;
|
|
4
|
+
terminals set to CJK ambiguous-wide are unsupported. A character with an emoji variant
|
|
5
|
+
(`⚖ ⚙ ⏸ ⚠`) carries U+FE0E, the text-presentation variation selector, because some terminals
|
|
6
|
+
render it as wide color emoji without it. `is_single_cell` is the check a unit test runs over
|
|
7
|
+
every glyph registered here.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import unicodedata
|
|
13
|
+
|
|
14
|
+
from shikiru.domain.tasks import Role
|
|
15
|
+
|
|
16
|
+
_TEXT_PRESENTATION_SELECTOR = "︎"
|
|
17
|
+
_ZERO_WIDTH_CATEGORIES = frozenset({"Mn", "Me", "Cf"})
|
|
18
|
+
_WIDE_EAST_ASIAN_WIDTHS = frozenset({"W", "F"})
|
|
19
|
+
|
|
20
|
+
_EMOJI_CAPABLE_CODE_POINTS = frozenset({"⚖", "⚙", "⏸", "⚠"})
|
|
21
|
+
"""The base characters we use that have an emoji-presentation variant (Unicode has no stdlib
|
|
22
|
+
property for this, so this is the small explicit set our own glyphs draw from)."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def is_single_cell(glyph: str) -> bool:
|
|
26
|
+
"""Return whether `glyph` renders as exactly one terminal cell in every terminal."""
|
|
27
|
+
cells = 0
|
|
28
|
+
characters = list(glyph)
|
|
29
|
+
for index, character in enumerate(characters):
|
|
30
|
+
if character == _TEXT_PRESENTATION_SELECTOR:
|
|
31
|
+
continue
|
|
32
|
+
if unicodedata.category(character) in _ZERO_WIDTH_CATEGORIES:
|
|
33
|
+
continue
|
|
34
|
+
if unicodedata.east_asian_width(character) in _WIDE_EAST_ASIAN_WIDTHS:
|
|
35
|
+
return False
|
|
36
|
+
followed_by_selector = (
|
|
37
|
+
index + 1 < len(characters) and characters[index + 1] == _TEXT_PRESENTATION_SELECTOR
|
|
38
|
+
)
|
|
39
|
+
if character in _EMOJI_CAPABLE_CODE_POINTS and not followed_by_selector:
|
|
40
|
+
return False
|
|
41
|
+
cells += 1
|
|
42
|
+
return cells == 1
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
PANEL_KANJI = ("起", "承", "転", "結")
|
|
46
|
+
"""Decorative kanji for the strip's four panel positions: ki-shō-ten-ketsu. Kanji
|
|
47
|
+
are decorative marks, not state glyphs, so they are wide and excluded from `ALL_GLYPHS`."""
|
|
48
|
+
|
|
49
|
+
STAGE_TRIAGE = "⌕"
|
|
50
|
+
STAGE_PLAN = "≡"
|
|
51
|
+
STAGE_IMPLEMENT = "⚒"
|
|
52
|
+
STAGE_VERIFY = "⚖︎"
|
|
53
|
+
STAGE_MERGE = "⑂"
|
|
54
|
+
STAGE_PR = "⇪"
|
|
55
|
+
|
|
56
|
+
ROLE_GLYPHS: dict[Role, str] = {
|
|
57
|
+
Role.LEADER: "◆",
|
|
58
|
+
Role.IMPLEMENTER: "▲",
|
|
59
|
+
Role.HELPER: "✦",
|
|
60
|
+
}
|
|
61
|
+
GLYPH_OPERATOR = "⌨"
|
|
62
|
+
GLYPH_ENGINE = "⚙︎"
|
|
63
|
+
|
|
64
|
+
GLYPH_DONE = "✔"
|
|
65
|
+
GLYPH_REWORK = "↺"
|
|
66
|
+
GLYPH_CONSULT = "?"
|
|
67
|
+
GLYPH_PAUSED = "⏸︎"
|
|
68
|
+
GLYPH_QUOTA = "⧗"
|
|
69
|
+
QUOTA_LABEL = "quota"
|
|
70
|
+
QUOTA_MARKER = f"{GLYPH_QUOTA} {QUOTA_LABEL}"
|
|
71
|
+
GLYPH_STALLED = "⚠︎"
|
|
72
|
+
GLYPH_WARNING = "⚠︎"
|
|
73
|
+
GLYPH_ESCALATED = "‼"
|
|
74
|
+
GLYPH_BLOCKED = "⊘"
|
|
75
|
+
GLYPH_SLOT = "◷"
|
|
76
|
+
GLYPH_PENDING = "·"
|
|
77
|
+
GLYPH_STOPPED = "✕"
|
|
78
|
+
GLYPH_CURRENT_STAGE = "\u203a"
|
|
79
|
+
|
|
80
|
+
GAUGE_GLYPHS = ("○", "◔", "◑", "◕", "●")
|
|
81
|
+
|
|
82
|
+
SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
|
|
83
|
+
|
|
84
|
+
ALL_GLYPHS = (
|
|
85
|
+
STAGE_TRIAGE,
|
|
86
|
+
STAGE_PLAN,
|
|
87
|
+
STAGE_IMPLEMENT,
|
|
88
|
+
STAGE_VERIFY,
|
|
89
|
+
STAGE_MERGE,
|
|
90
|
+
STAGE_PR,
|
|
91
|
+
*ROLE_GLYPHS.values(),
|
|
92
|
+
GLYPH_OPERATOR,
|
|
93
|
+
GLYPH_ENGINE,
|
|
94
|
+
GLYPH_DONE,
|
|
95
|
+
GLYPH_REWORK,
|
|
96
|
+
GLYPH_CONSULT,
|
|
97
|
+
GLYPH_PAUSED,
|
|
98
|
+
GLYPH_QUOTA,
|
|
99
|
+
GLYPH_STALLED,
|
|
100
|
+
GLYPH_WARNING,
|
|
101
|
+
GLYPH_ESCALATED,
|
|
102
|
+
GLYPH_BLOCKED,
|
|
103
|
+
GLYPH_SLOT,
|
|
104
|
+
GLYPH_PENDING,
|
|
105
|
+
GLYPH_STOPPED,
|
|
106
|
+
GLYPH_CURRENT_STAGE,
|
|
107
|
+
*GAUGE_GLYPHS,
|
|
108
|
+
*SPINNER_FRAMES,
|
|
109
|
+
)
|
shikiru/ui/keys.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Key names painted in the shared cyan inside hint text."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
|
|
9
|
+
from shikiru.ui.section import BOLD_STEEL
|
|
10
|
+
from shikiru.ui.tokens import SECONDARY_TEXT_COLOR, STYLE_BOLD
|
|
11
|
+
|
|
12
|
+
KEY_NAMES: tuple[str, ...] = (
|
|
13
|
+
"Ctrl+P",
|
|
14
|
+
"Ctrl+S",
|
|
15
|
+
"Escape",
|
|
16
|
+
"Esc",
|
|
17
|
+
"Enter",
|
|
18
|
+
"Tab",
|
|
19
|
+
"PgUp/PgDn",
|
|
20
|
+
"PgUp",
|
|
21
|
+
"PgDn",
|
|
22
|
+
"End",
|
|
23
|
+
"Up/Down",
|
|
24
|
+
"Left/Right",
|
|
25
|
+
)
|
|
26
|
+
KEY_SYMBOLS: tuple[str, ...] = ("↑↓", "↑", "↓", "←", "→")
|
|
27
|
+
KEY_LABELLED_CHARACTERS = "a-z?/"
|
|
28
|
+
BOLD_DISABLED_KEY = f"{STYLE_BOLD} {SECONDARY_TEXT_COLOR}"
|
|
29
|
+
|
|
30
|
+
_KEY_PATTERN = re.compile(
|
|
31
|
+
"|".join(
|
|
32
|
+
(
|
|
33
|
+
rf"(?<!\w)(?:{'|'.join(re.escape(name) for name in KEY_NAMES)})(?!\w)",
|
|
34
|
+
"|".join(re.escape(symbol) for symbol in KEY_SYMBOLS),
|
|
35
|
+
rf"(?<!\w)[{KEY_LABELLED_CHARACTERS}](?=:)",
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def key_hint(text: str, base_style: str = "", *, enabled: bool = True) -> Text:
|
|
42
|
+
"""Return `text` in `base_style` with every known key name in the cyan key style.
|
|
43
|
+
|
|
44
|
+
A disabled hint (its key does nothing now) paints the keys bold grey instead.
|
|
45
|
+
"""
|
|
46
|
+
key_style = BOLD_STEEL if enabled else BOLD_DISABLED_KEY
|
|
47
|
+
hint = Text(text)
|
|
48
|
+
if base_style:
|
|
49
|
+
hint.stylize(base_style)
|
|
50
|
+
for match in _KEY_PATTERN.finditer(text):
|
|
51
|
+
hint.stylize(key_style, match.start(), match.end())
|
|
52
|
+
return hint
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def append_key_hint(target: Text, text: str, base_style: str = "") -> None:
|
|
56
|
+
"""Append `text` to `target` as `key_hint` does."""
|
|
57
|
+
target.append_text(key_hint(text, base_style))
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""A repeating widget timer that restarts when its configured period changes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from collections.abc import Awaitable, Callable
|
|
9
|
+
|
|
10
|
+
from textual.timer import Timer
|
|
11
|
+
from textual.widget import Widget
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LiveInterval:
|
|
15
|
+
"""Run `tick` every `period()` seconds, restarting the timer only when that period changes."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
widget: Widget,
|
|
20
|
+
period: Callable[[], float],
|
|
21
|
+
tick: Callable[[], Awaitable[None]],
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Hold the timer target; nothing runs until `apply`."""
|
|
24
|
+
self._widget = widget
|
|
25
|
+
self._period = period
|
|
26
|
+
self._tick = tick
|
|
27
|
+
self._applied: float | None = None
|
|
28
|
+
self._timer: Timer | None = None
|
|
29
|
+
|
|
30
|
+
def apply(self) -> None:
|
|
31
|
+
"""Tick at the current period, replacing the running timer when the period differs."""
|
|
32
|
+
period = self._period()
|
|
33
|
+
if period == self._applied:
|
|
34
|
+
return
|
|
35
|
+
if self._timer is not None:
|
|
36
|
+
self._timer.stop()
|
|
37
|
+
self._applied = period
|
|
38
|
+
self._timer = self._widget.set_interval(period, self._tick)
|
|
39
|
+
|
|
40
|
+
async def reapply(self) -> None:
|
|
41
|
+
"""Apply the current period again; a state change calls this to pick up new settings."""
|
|
42
|
+
self.apply()
|