shikiru-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- shikiru/__init__.py +1 -0
- shikiru/__main__.py +8 -0
- shikiru/agents/__init__.py +3 -0
- shikiru/agents/contracts.py +590 -0
- shikiru/agents/registry.py +87 -0
- shikiru/agents/sdk/__init__.py +17 -0
- shikiru/agents/sdk/reset_time.py +137 -0
- shikiru/agents/sdk/turn.py +335 -0
- shikiru/agents/settings.py +151 -0
- shikiru/agents/testing/__init__.py +26 -0
- shikiru/agents/testing/contract_suite.py +653 -0
- shikiru/agents/testing/resume.py +15 -0
- shikiru/bootstrap.py +1277 -0
- shikiru/cli/__init__.py +1 -0
- shikiru/cli/app.py +770 -0
- shikiru/cli/context.py +63 -0
- shikiru/cli/render.py +1475 -0
- shikiru/cli/report.py +156 -0
- shikiru/cli/session_prompt.py +63 -0
- shikiru/cli/view.py +131 -0
- shikiru/clock.py +41 -0
- shikiru/config/__init__.py +8 -0
- shikiru/config/defaults.toml +131 -0
- shikiru/config/loader.py +178 -0
- shikiru/config/settings.py +392 -0
- shikiru/dashboard/__init__.py +3 -0
- shikiru/dashboard/actions_list.py +799 -0
- shikiru/dashboard/activity.py +150 -0
- shikiru/dashboard/app.py +1765 -0
- shikiru/dashboard/blocker_evidence.py +122 -0
- shikiru/dashboard/blocker_rows.py +84 -0
- shikiru/dashboard/briefing_view.py +871 -0
- shikiru/dashboard/contract.py +1086 -0
- shikiru/dashboard/covered.py +32 -0
- shikiru/dashboard/detail.py +1209 -0
- shikiru/dashboard/detail_fold.py +244 -0
- shikiru/dashboard/detail_hint.py +68 -0
- shikiru/dashboard/details_list_view.py +150 -0
- shikiru/dashboard/diagnostics.py +80 -0
- shikiru/dashboard/diagnostics_sections.py +186 -0
- shikiru/dashboard/evidence_table.py +123 -0
- shikiru/dashboard/extra_details.py +214 -0
- shikiru/dashboard/fault_guidance.py +216 -0
- shikiru/dashboard/fold_bodies.py +276 -0
- shikiru/dashboard/help_keys.py +166 -0
- shikiru/dashboard/help_screen.py +204 -0
- shikiru/dashboard/interaction.py +17 -0
- shikiru/dashboard/ladder_editor.py +544 -0
- shikiru/dashboard/live_settings.py +62 -0
- shikiru/dashboard/motion.py +368 -0
- shikiru/dashboard/orchestration_fields.py +201 -0
- shikiru/dashboard/palette.py +1606 -0
- shikiru/dashboard/palette_app.py +89 -0
- shikiru/dashboard/palette_drafts.py +33 -0
- shikiru/dashboard/resolution.py +573 -0
- shikiru/dashboard/resolution_view.py +302 -0
- shikiru/dashboard/row_list.py +256 -0
- shikiru/dashboard/section_rows.py +173 -0
- shikiru/dashboard/services.py +665 -0
- shikiru/dashboard/session_history.py +402 -0
- shikiru/dashboard/shikiru_init_ascii_art.txt +40 -0
- shikiru/dashboard/task_picker.py +358 -0
- shikiru/dashboard/tick.py +59 -0
- shikiru/dashboard/timeline_filter.py +129 -0
- shikiru/dashboard/timeline_section.py +385 -0
- shikiru/dashboard/usage_body.py +269 -0
- shikiru/dashboard/views.py +2314 -0
- shikiru/dashboard/wizard.py +1389 -0
- shikiru/domain/__init__.py +1 -0
- shikiru/domain/acceptance.py +130 -0
- shikiru/domain/activity.py +28 -0
- shikiru/domain/attempts.py +333 -0
- shikiru/domain/briefing.py +257 -0
- shikiru/domain/commands.py +219 -0
- shikiru/domain/decisions.py +231 -0
- shikiru/domain/docker.py +262 -0
- shikiru/domain/engine_uptime.py +87 -0
- shikiru/domain/escalation.py +194 -0
- shikiru/domain/escalation_evidence.py +186 -0
- shikiru/domain/escalation_panel.py +144 -0
- shikiru/domain/events.py +71 -0
- shikiru/domain/evidence_export.py +89 -0
- shikiru/domain/faults.py +86 -0
- shikiru/domain/feedback.py +180 -0
- shikiru/domain/feedback_rounds.py +172 -0
- shikiru/domain/fencing.py +31 -0
- shikiru/domain/ladder.py +102 -0
- shikiru/domain/ladder_text.py +44 -0
- shikiru/domain/levels.py +44 -0
- shikiru/domain/motion.py +199 -0
- shikiru/domain/packet_evidence.py +377 -0
- shikiru/domain/pushes.py +47 -0
- shikiru/domain/quota_blocks.py +44 -0
- shikiru/domain/quota_waits.py +219 -0
- shikiru/domain/recovery.py +194 -0
- shikiru/domain/reports.py +498 -0
- shikiru/domain/results.py +217 -0
- shikiru/domain/runs.py +85 -0
- shikiru/domain/scheduling.py +231 -0
- shikiru/domain/status_tasks.py +40 -0
- shikiru/domain/steps.py +325 -0
- shikiru/domain/tasks.py +174 -0
- shikiru/domain/timeline.py +208 -0
- shikiru/domain/timeline_phrases.py +271 -0
- shikiru/domain/versions.py +18 -0
- shikiru/domain/workflow.py +372 -0
- shikiru/engine/__init__.py +0 -0
- shikiru/engine/acceptance.py +90 -0
- shikiru/engine/agents.py +61 -0
- shikiru/engine/cosmetic_decorator.py +99 -0
- shikiru/engine/daemon.py +409 -0
- shikiru/engine/dependency.py +37 -0
- shikiru/engine/escalation_filing.py +110 -0
- shikiru/engine/escalations.py +401 -0
- shikiru/engine/events.py +285 -0
- shikiru/engine/evidence.py +81 -0
- shikiru/engine/fault_reporter.py +144 -0
- shikiru/engine/feedback.py +577 -0
- shikiru/engine/feedback_audit.py +80 -0
- shikiru/engine/feedback_intake.py +95 -0
- shikiru/engine/feedback_snapshot.py +148 -0
- shikiru/engine/flow.py +87 -0
- shikiru/engine/handlers/__init__.py +33 -0
- shikiru/engine/handlers/base.py +148 -0
- shikiru/engine/handlers/engine.py +24 -0
- shikiru/engine/handlers/escalations.py +174 -0
- shikiru/engine/handlers/operator.py +81 -0
- shikiru/engine/handlers/tasks.py +237 -0
- shikiru/engine/history.py +128 -0
- shikiru/engine/integration.py +1234 -0
- shikiru/engine/integration_turns.py +337 -0
- shikiru/engine/layout_guard.py +362 -0
- shikiru/engine/lifecycle.py +60 -0
- shikiru/engine/monitor.py +317 -0
- shikiru/engine/notification_delivery.py +124 -0
- shikiru/engine/notification_feed.py +57 -0
- shikiru/engine/notifier.py +91 -0
- shikiru/engine/pull_request_closing.py +117 -0
- shikiru/engine/push_service.py +177 -0
- shikiru/engine/quota_probe_threads.py +79 -0
- shikiru/engine/quota_reconciler.py +210 -0
- shikiru/engine/quota_schedule.py +24 -0
- shikiru/engine/quota_waits.py +102 -0
- shikiru/engine/reconcile.py +91 -0
- shikiru/engine/recovery.py +284 -0
- shikiru/engine/retention.py +102 -0
- shikiru/engine/root_preparation.py +86 -0
- shikiru/engine/scheduler.py +320 -0
- shikiru/engine/session_guard.py +46 -0
- shikiru/engine/sessions.py +95 -0
- shikiru/engine/signalled.py +75 -0
- shikiru/engine/step_runner.py +140 -0
- shikiru/engine/supervisor.py +101 -0
- shikiru/engine/takeover.py +223 -0
- shikiru/engine/task_facts.py +46 -0
- shikiru/engine/tick_guard.py +95 -0
- shikiru/engine/titles.py +297 -0
- shikiru/engine/transitions.py +79 -0
- shikiru/engine/turn_context.py +206 -0
- shikiru/engine/turn_execution.py +30 -0
- shikiru/engine/turn_loop.py +230 -0
- shikiru/engine/turn_processes.py +79 -0
- shikiru/engine/turns.py +457 -0
- shikiru/errors.py +144 -0
- shikiru/helper/__init__.py +3 -0
- shikiru/helper/helper.py +182 -0
- shikiru/helper/models.py +11 -0
- shikiru/mcp/__init__.py +1 -0
- shikiru/mcp/server.py +196 -0
- shikiru/operator/__init__.py +33 -0
- shikiru/operator/attachment.py +24 -0
- shikiru/operator/commands.py +319 -0
- shikiru/operator/detectors.py +374 -0
- shikiru/operator/doctor.py +351 -0
- shikiru/operator/events.py +57 -0
- shikiru/operator/evidence_export.py +498 -0
- shikiru/operator/evidence_writer.py +78 -0
- shikiru/operator/lifecycle.py +294 -0
- shikiru/operator/plugin_checks.py +194 -0
- shikiru/operator/probe.py +287 -0
- shikiru/operator/queries.py +1601 -0
- shikiru/operator/services.py +36 -0
- shikiru/operator/sessions.py +378 -0
- shikiru/operator/strip_views.py +162 -0
- shikiru/operator/timeline.py +416 -0
- shikiru/operator/toml_text.py +62 -0
- shikiru/palette.py +18 -0
- shikiru/paths.py +91 -0
- shikiru/plugins/__init__.py +0 -0
- shikiru/plugins/agents/__init__.py +1 -0
- shikiru/plugins/agents/claude/__init__.py +5 -0
- shikiru/plugins/agents/claude/agent.py +251 -0
- shikiru/plugins/agents/claude/context.py +116 -0
- shikiru/plugins/agents/claude/headless.py +92 -0
- shikiru/plugins/agents/claude/hook.py +57 -0
- shikiru/plugins/agents/claude/model_catalog.py +105 -0
- shikiru/plugins/agents/claude/options.py +62 -0
- shikiru/plugins/agents/claude/turn.py +1018 -0
- shikiru/plugins/agents/codex/__init__.py +5 -0
- shikiru/plugins/agents/codex/agent.py +486 -0
- shikiru/plugins/agents/codex/headless.py +166 -0
- shikiru/plugins/agents/codex/home.py +14 -0
- shikiru/plugins/agents/codex/model_catalog.py +128 -0
- shikiru/plugins/agents/codex/options.py +94 -0
- shikiru/plugins/agents/codex/quota.py +195 -0
- shikiru/plugins/agents/codex/sandbox.py +31 -0
- shikiru/plugins/agents/codex/sandbox_probe.py +97 -0
- shikiru/plugins/agents/codex/turn.py +767 -0
- shikiru/plugins/agents/codex/usage.py +106 -0
- shikiru/plugins/agents/shared_settings.py +62 -0
- shikiru/plugins/widgets/__init__.py +3 -0
- shikiru/plugins/widgets/dialogue.py +144 -0
- shikiru/plugins/widgets/disk.py +123 -0
- shikiru/plugins/widgets/docker.py +225 -0
- shikiru/plugins/widgets/escalations.py +402 -0
- shikiru/plugins/widgets/faults.py +204 -0
- shikiru/plugins/widgets/header.py +321 -0
- shikiru/plugins/widgets/queue.py +1140 -0
- shikiru/plugins/widgets/queue_hint.py +93 -0
- shikiru/plugins/widgets/resources.py +105 -0
- shikiru/plugins/widgets/strips.py +82 -0
- shikiru/plugins/widgets/usage.py +176 -0
- shikiru/private.py +94 -0
- shikiru/process.py +535 -0
- shikiru/prompts/__init__.py +3 -0
- shikiru/prompts/renderer.py +85 -0
- shikiru/prompts/templates/_base.md.j2 +17 -0
- shikiru/prompts/templates/_macros.md.j2 +61 -0
- shikiru/prompts/templates/conflicts.md.j2 +22 -0
- shikiru/prompts/templates/consult.md.j2 +5 -0
- shikiru/prompts/templates/continue.md.j2 +4 -0
- shikiru/prompts/templates/feedback_analysis.md.j2 +29 -0
- shikiru/prompts/templates/feedback_rework.md.j2 +33 -0
- shikiru/prompts/templates/feedback_verify.md.j2 +33 -0
- shikiru/prompts/templates/handoff.md.j2 +23 -0
- shikiru/prompts/templates/implement.md.j2 +13 -0
- shikiru/prompts/templates/plan.md.j2 +30 -0
- shikiru/prompts/templates/rework.md.j2 +13 -0
- shikiru/prompts/templates/triage.md.j2 +20 -0
- shikiru/prompts/templates/verify.md.j2 +29 -0
- shikiru/store/__init__.py +1 -0
- shikiru/store/commands.py +242 -0
- shikiru/store/database.py +297 -0
- shikiru/store/escalations.py +240 -0
- shikiru/store/events.py +213 -0
- shikiru/store/feedback.py +266 -0
- shikiru/store/migrations/001_initial.sql +63 -0
- shikiru/store/migrations/002_headless_turns.sql +31 -0
- shikiru/store/migrations/003_drop_command_handled_at.sql +1 -0
- shikiru/store/migrations/004_repo_base_ref.sql +3 -0
- shikiru/store/migrations/005_complexity_explicit.sql +3 -0
- shikiru/store/migrations/006_events_kind_index.sql +1 -0
- shikiru/store/migrations/007_notification_cursor.sql +1 -0
- shikiru/store/migrations/008_reported_usage.sql +1 -0
- shikiru/store/migrations/009_billing_declarations.sql +2 -0
- shikiru/store/migrations/010_canonical_quota_windows.sql +3 -0
- shikiru/store/migrations/012_drop_task_quota_evidence.sql +2 -0
- shikiru/store/migrations/013_push_targets.sql +7 -0
- shikiru/store/migrations/014_agent_block_time.sql +6 -0
- shikiru/store/migrations/015_repair_orphan_agent_blocks.sql +3 -0
- shikiru/store/migrations/016_feedback_rounds.sql +29 -0
- shikiru/store/migrations/017_feedback_round_comments.sql +5 -0
- shikiru/store/migrations/__init__.py +1 -0
- shikiru/store/notification_cursor.py +29 -0
- shikiru/store/pushes.py +117 -0
- shikiru/store/quotas.py +153 -0
- shikiru/store/reader.py +310 -0
- shikiru/store/retention.py +92 -0
- shikiru/store/runs.py +227 -0
- shikiru/store/tasks.py +949 -0
- shikiru/telemetry/__init__.py +1 -0
- shikiru/telemetry/disk.py +167 -0
- shikiru/telemetry/docker.py +164 -0
- shikiru/telemetry/resources.py +296 -0
- shikiru/tmux/__init__.py +3 -0
- shikiru/tmux/client.py +637 -0
- shikiru/tmux/decorator.py +225 -0
- shikiru/tmux/layout.py +200 -0
- shikiru/tmux/session.py +381 -0
- shikiru/ui/__init__.py +9 -0
- shikiru/ui/age.py +45 -0
- shikiru/ui/bubbles.py +41 -0
- shikiru/ui/compact.py +1209 -0
- shikiru/ui/console.py +49 -0
- shikiru/ui/fold.py +50 -0
- shikiru/ui/gated_static.py +117 -0
- shikiru/ui/gauge.py +40 -0
- shikiru/ui/glyphs.py +109 -0
- shikiru/ui/keys.py +57 -0
- shikiru/ui/live_interval.py +42 -0
- shikiru/ui/popup.py +311 -0
- shikiru/ui/quota_status.py +26 -0
- shikiru/ui/recent_window.py +78 -0
- shikiru/ui/screen.py +57 -0
- shikiru/ui/section.py +193 -0
- shikiru/ui/session.py +65 -0
- shikiru/ui/size.py +17 -0
- shikiru/ui/splash.py +42 -0
- shikiru/ui/status.py +47 -0
- shikiru/ui/status_strip.py +149 -0
- shikiru/ui/stream.py +152 -0
- shikiru/ui/strip.py +332 -0
- shikiru/ui/theme.py +83 -0
- shikiru/ui/tokens.py +51 -0
- shikiru/view_log.py +126 -0
- shikiru/workspace/__init__.py +3 -0
- shikiru/workspace/base_refs.py +160 -0
- shikiru/workspace/caches.py +136 -0
- shikiru/workspace/facts.py +118 -0
- shikiru/workspace/git.py +601 -0
- shikiru/workspace/github.py +744 -0
- shikiru/workspace/messages.py +107 -0
- shikiru/workspace/reports.py +145 -0
- shikiru/workspace/roots.py +180 -0
- shikiru_cli-0.2.0.dist-info/METADATA +428 -0
- shikiru_cli-0.2.0.dist-info/RECORD +319 -0
- shikiru_cli-0.2.0.dist-info/WHEEL +4 -0
- shikiru_cli-0.2.0.dist-info/entry_points.txt +18 -0
- shikiru_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""The Task picker popup: choose one project task by ID or title."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
6
|
+
|
|
7
|
+
from rich.cells import cell_len
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
from textual.binding import Binding
|
|
10
|
+
from textual.containers import Horizontal
|
|
11
|
+
from textual.screen import ModalScreen
|
|
12
|
+
from textual.widgets import Button, Input, Label, ListItem, ListView, Rule, Static
|
|
13
|
+
|
|
14
|
+
from shikiru.plugins.widgets.queue import status_marker
|
|
15
|
+
from shikiru.plugins.widgets.queue_hint import MORE_KEY, load_more_hint
|
|
16
|
+
from shikiru.ui.keys import BOLD_DISABLED_KEY, key_hint
|
|
17
|
+
from shikiru.ui.popup import (
|
|
18
|
+
POPUP_BODY_CLASS,
|
|
19
|
+
POPUP_CLOSE_CLASS,
|
|
20
|
+
POPUP_CSS,
|
|
21
|
+
POPUP_FRAME_CLASS,
|
|
22
|
+
POPUP_HEADER_CLASS,
|
|
23
|
+
POPUP_RULE_CLASS,
|
|
24
|
+
POPUP_TITLE_CLASS,
|
|
25
|
+
PopupFrame,
|
|
26
|
+
popup_close_label,
|
|
27
|
+
prepare_popup,
|
|
28
|
+
)
|
|
29
|
+
from shikiru.ui.section import BOLD_PRIMARY, WrappedGroups, grey, key_label
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from rich.console import Console, ConsoleOptions, RenderResult
|
|
33
|
+
from textual import events
|
|
34
|
+
from textual.app import ComposeResult
|
|
35
|
+
from textual.binding import BindingType
|
|
36
|
+
|
|
37
|
+
from shikiru.operator.queries import OperatorQueries, TaskSummary
|
|
38
|
+
|
|
39
|
+
PICKER_TITLE = "Switch task"
|
|
40
|
+
PICKER_FRAME = "task-picker-frame"
|
|
41
|
+
PICKER_CLOSE_BUTTON = "task-picker-close"
|
|
42
|
+
PICKER_SEARCH = "task-picker-search"
|
|
43
|
+
PICKER_LIST = "task-picker-list"
|
|
44
|
+
PICKER_EMPTY = "task-picker-empty"
|
|
45
|
+
PICKER_HINT = "task-picker-hint"
|
|
46
|
+
PICKER_PAGE_LIMIT = 20
|
|
47
|
+
PICKER_VISIBLE_ROWS = 10
|
|
48
|
+
SEARCH_KEY = "/"
|
|
49
|
+
SEARCH_LABEL = "search"
|
|
50
|
+
HINT_GAP = " "
|
|
51
|
+
TINY_HINT_GAP = " "
|
|
52
|
+
COMPACT_CLASS = "compact"
|
|
53
|
+
TINY_CLASS = "tiny"
|
|
54
|
+
COMPACT_MAX_COLUMNS = 48
|
|
55
|
+
COMPACT_MAX_ROWS = 20
|
|
56
|
+
TINY_MAX_COLUMNS = 32
|
|
57
|
+
TINY_MAX_ROWS = 10
|
|
58
|
+
MIN_TITLE_CELLS = 8
|
|
59
|
+
NAME_GAP = " "
|
|
60
|
+
PAGE_OVERLAP_ROWS = 1
|
|
61
|
+
CLOSE_ACTION = "close"
|
|
62
|
+
MOVE_ACTION = "move"
|
|
63
|
+
PAGE_ACTION = "page"
|
|
64
|
+
JUMP_ACTION = "jump"
|
|
65
|
+
LOAD_MORE_ACTION = "load_more"
|
|
66
|
+
OPEN_SEARCH_ACTION = "open_search"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def picker_row_text(task: TaskSummary, width: int) -> Text:
|
|
70
|
+
"""Return `glyph T-n title status` on one line of `width` cells, dropping the status last."""
|
|
71
|
+
marker, marker_style = status_marker(task.status.value)
|
|
72
|
+
head = Text.assemble((marker, marker_style), NAME_GAP, (task.id, BOLD_PRIMARY), NAME_GAP)
|
|
73
|
+
tail = Text.assemble(NAME_GAP, grey(task.status.value.replace("_", NAME_GAP)))
|
|
74
|
+
room = width - cell_len(head.plain) - cell_len(tail.plain)
|
|
75
|
+
if room < MIN_TITLE_CELLS:
|
|
76
|
+
tail = Text("")
|
|
77
|
+
room = width - cell_len(head.plain)
|
|
78
|
+
title = Text(task.title.replace("\n", NAME_GAP))
|
|
79
|
+
title.truncate(max(room, 0), overflow="ellipsis", pad=True)
|
|
80
|
+
return Text.assemble(head, title, tail, no_wrap=True, overflow="ellipsis")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class _RowText:
|
|
84
|
+
def __init__(self, task: TaskSummary) -> None:
|
|
85
|
+
self._task = task
|
|
86
|
+
|
|
87
|
+
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
88
|
+
del console
|
|
89
|
+
yield picker_row_text(self._task, options.max_width)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class PickerRow(ListItem):
|
|
93
|
+
def __init__(self, task: TaskSummary) -> None:
|
|
94
|
+
super().__init__(Static(_RowText(task), markup=False))
|
|
95
|
+
self.task_id = task.id
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class _PickerList(ListView):
|
|
99
|
+
def on_resize(self) -> None:
|
|
100
|
+
self.call_after_refresh(self._reveal_selection)
|
|
101
|
+
|
|
102
|
+
def _reveal_selection(self) -> None:
|
|
103
|
+
if self.highlighted_child is not None:
|
|
104
|
+
self.highlighted_child.scroll_visible(animate=False)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class TaskPickerScreen(ModalScreen[str | None]):
|
|
108
|
+
"""List project tasks one per line; Enter dismisses with the chosen task ID."""
|
|
109
|
+
|
|
110
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
111
|
+
Binding("escape", CLOSE_ACTION, "Close"),
|
|
112
|
+
Binding("up", f"{MOVE_ACTION}(-1)", "Up", show=False, priority=True),
|
|
113
|
+
Binding("down", f"{MOVE_ACTION}(1)", "Down", show=False, priority=True),
|
|
114
|
+
Binding("pageup", f"{PAGE_ACTION}(-1)", "Page up", show=False, priority=True),
|
|
115
|
+
Binding("pagedown", f"{PAGE_ACTION}(1)", "Page down", show=False, priority=True),
|
|
116
|
+
Binding("home", f"{JUMP_ACTION}(-1)", "First", show=False, priority=True),
|
|
117
|
+
Binding("end", f"{JUMP_ACTION}(1)", "Last", show=False, priority=True),
|
|
118
|
+
Binding(SEARCH_KEY, OPEN_SEARCH_ACTION, "Search", show=False),
|
|
119
|
+
Binding(MORE_KEY, LOAD_MORE_ACTION, "Load more", show=False),
|
|
120
|
+
]
|
|
121
|
+
DEFAULT_CSS = (
|
|
122
|
+
POPUP_CSS
|
|
123
|
+
+ f"""
|
|
124
|
+
#{PICKER_SEARCH} {{ height: 1; border: none; padding: 0; margin-bottom: 1; }}
|
|
125
|
+
#{PICKER_LIST} {{
|
|
126
|
+
border: none; padding: 0; background: $surface;
|
|
127
|
+
scrollbar-size-vertical: $scrollbar-width;
|
|
128
|
+
}}
|
|
129
|
+
#{PICKER_LIST} > ListItem {{
|
|
130
|
+
height: 1; padding: 0; border: none; background: $surface; color: $primary-text;
|
|
131
|
+
}}
|
|
132
|
+
#{PICKER_LIST} > ListItem > Static {{
|
|
133
|
+
width: 100%; height: 1; padding: 0; color: $primary-text;
|
|
134
|
+
text-wrap: nowrap; text-overflow: ellipsis;
|
|
135
|
+
}}
|
|
136
|
+
#{PICKER_LIST} > ListItem.-highlight,
|
|
137
|
+
#{PICKER_LIST} > ListItem.-highlight > Static {{
|
|
138
|
+
outline: none; background: $selected-surface; color: $primary-text; text-style: none;
|
|
139
|
+
}}
|
|
140
|
+
#{PICKER_LIST}:focus {{ background-tint: transparent; }}
|
|
141
|
+
#{PICKER_EMPTY} {{ height: auto; color: $muted-text; }}
|
|
142
|
+
#{PICKER_HINT} {{ height: auto; margin-top: 1; }}
|
|
143
|
+
#{PICKER_FRAME}.{COMPACT_CLASS} {{ margin: 0 1; padding: 0 1; }}
|
|
144
|
+
#{PICKER_FRAME}.{TINY_CLASS} #{PICKER_SEARCH} {{ margin-bottom: 0; }}
|
|
145
|
+
#{PICKER_FRAME}.{TINY_CLASS} #{PICKER_HINT} {{ margin-top: 0; }}
|
|
146
|
+
"""
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def __init__(self, queries: OperatorQueries, current_task_id: str | None = None) -> None:
|
|
150
|
+
super().__init__()
|
|
151
|
+
prepare_popup(self)
|
|
152
|
+
self._queries = queries
|
|
153
|
+
self._current_task_id = current_task_id
|
|
154
|
+
self._search = ""
|
|
155
|
+
self._tasks: tuple[TaskSummary, ...] = ()
|
|
156
|
+
self._has_more = False
|
|
157
|
+
|
|
158
|
+
def compose(self) -> ComposeResult:
|
|
159
|
+
"""Render the header, the hidden search field, the task rows and the hint line."""
|
|
160
|
+
with PopupFrame(widget_id=PICKER_FRAME, classes=POPUP_FRAME_CLASS):
|
|
161
|
+
with Horizontal(classes=POPUP_HEADER_CLASS):
|
|
162
|
+
yield Label(PICKER_TITLE, classes=POPUP_TITLE_CLASS)
|
|
163
|
+
yield Button(popup_close_label(), id=PICKER_CLOSE_BUTTON, classes=POPUP_CLOSE_CLASS)
|
|
164
|
+
yield Rule(classes=POPUP_RULE_CLASS)
|
|
165
|
+
yield Static(id=PICKER_EMPTY, markup=False)
|
|
166
|
+
yield _PickerList(id=PICKER_LIST, classes=POPUP_BODY_CLASS)
|
|
167
|
+
yield Static(id=PICKER_HINT, markup=False)
|
|
168
|
+
|
|
169
|
+
async def on_mount(self) -> None:
|
|
170
|
+
"""Load the first page, select the current task and size the popup."""
|
|
171
|
+
await self._reload()
|
|
172
|
+
self._list.focus()
|
|
173
|
+
|
|
174
|
+
def on_resize(self, event: events.Resize) -> None:
|
|
175
|
+
self._apply_compact_layout(event.size.width, event.size.height)
|
|
176
|
+
|
|
177
|
+
def _apply_compact_layout(self, width: int, height: int) -> None:
|
|
178
|
+
frame = self.query_one(f"#{PICKER_FRAME}", PopupFrame)
|
|
179
|
+
compact = width <= COMPACT_MAX_COLUMNS or height <= COMPACT_MAX_ROWS
|
|
180
|
+
tiny = height <= TINY_MAX_ROWS
|
|
181
|
+
frame.set_class(compact, COMPACT_CLASS)
|
|
182
|
+
frame.set_class(tiny, TINY_CLASS)
|
|
183
|
+
frame.styles.max_height = max(1, height - frame.styles.margin.height)
|
|
184
|
+
self._list.styles.height = min(PICKER_VISIBLE_ROWS, len(self._tasks))
|
|
185
|
+
self._paint_hint()
|
|
186
|
+
frame.reset_preferred_size()
|
|
187
|
+
self.call_after_refresh(self._fit_content_to_viewport)
|
|
188
|
+
|
|
189
|
+
def _fit_content_to_viewport(self) -> None:
|
|
190
|
+
frame = self.query_one(f"#{PICKER_FRAME}", PopupFrame)
|
|
191
|
+
height = self.size.height
|
|
192
|
+
fixed_height = sum(
|
|
193
|
+
child.region.height + child.styles.margin.height
|
|
194
|
+
for child in frame.children
|
|
195
|
+
if child is not self._list and child.display
|
|
196
|
+
)
|
|
197
|
+
available_rows = max(
|
|
198
|
+
0,
|
|
199
|
+
height - frame.styles.margin.height - frame.styles.gutter.height - fixed_height,
|
|
200
|
+
)
|
|
201
|
+
list_height = min(PICKER_VISIBLE_ROWS, len(self._tasks), available_rows)
|
|
202
|
+
self._list.styles.height = list_height
|
|
203
|
+
content_height = fixed_height + list_height + frame.styles.gutter.height
|
|
204
|
+
frame.styles.max_height = max(
|
|
205
|
+
1,
|
|
206
|
+
min(height - frame.styles.margin.height, content_height),
|
|
207
|
+
)
|
|
208
|
+
frame.content_ready()
|
|
209
|
+
|
|
210
|
+
def _keys_only(self, width: int, height: int) -> bool:
|
|
211
|
+
return width <= TINY_MAX_COLUMNS or height <= TINY_MAX_ROWS
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def _list(self) -> ListView:
|
|
215
|
+
return self.query_one(f"#{PICKER_LIST}", ListView)
|
|
216
|
+
|
|
217
|
+
@property
|
|
218
|
+
def _search_open(self) -> bool:
|
|
219
|
+
return bool(self.query(f"#{PICKER_SEARCH}"))
|
|
220
|
+
|
|
221
|
+
async def _reload(self) -> None:
|
|
222
|
+
self._tasks = ()
|
|
223
|
+
await self._list.clear()
|
|
224
|
+
await self._append_page(preselect=self._current_task_id)
|
|
225
|
+
|
|
226
|
+
async def _append_page(self, *, preselect: str | None = None) -> None:
|
|
227
|
+
page = self._queries.search_task_targets(
|
|
228
|
+
self._search, limit=PICKER_PAGE_LIMIT, offset=len(self._tasks)
|
|
229
|
+
)
|
|
230
|
+
known = {task.id for task in self._tasks}
|
|
231
|
+
added = tuple(task for task in page.tasks if task.id not in known)
|
|
232
|
+
self._tasks += added
|
|
233
|
+
self._has_more = page.has_more
|
|
234
|
+
await self._list.extend(PickerRow(task) for task in added)
|
|
235
|
+
if self._list.index is None and self._tasks:
|
|
236
|
+
self._list.index = self._index_of(preselect)
|
|
237
|
+
self._paint_empty(page.project_count)
|
|
238
|
+
self._apply_compact_layout(self.size.width, self.size.height)
|
|
239
|
+
|
|
240
|
+
def _index_of(self, task_id: str | None) -> int:
|
|
241
|
+
return next((index for index, task in enumerate(self._tasks) if task.id == task_id), 0)
|
|
242
|
+
|
|
243
|
+
def _paint_empty(self, project_count: int) -> None:
|
|
244
|
+
empty = self.query_one(f"#{PICKER_EMPTY}", Static)
|
|
245
|
+
empty.display = not self._tasks
|
|
246
|
+
if self._tasks:
|
|
247
|
+
return
|
|
248
|
+
message = "No tasks" if project_count == 0 else "No matches"
|
|
249
|
+
empty.update(message)
|
|
250
|
+
|
|
251
|
+
def _paint_hint(self) -> None:
|
|
252
|
+
if not self.is_attached:
|
|
253
|
+
return
|
|
254
|
+
has_rows = bool(self._tasks)
|
|
255
|
+
keys_only = self._keys_only(self.size.width, self.size.height)
|
|
256
|
+
choose = (
|
|
257
|
+
key_hint("Enter keep filter")
|
|
258
|
+
if self._search_open
|
|
259
|
+
else key_hint("Enter choose", enabled=has_rows)
|
|
260
|
+
)
|
|
261
|
+
groups = self._hint_groups(has_rows, choose, keys_only)
|
|
262
|
+
gap = TINY_HINT_GAP if keys_only else HINT_GAP
|
|
263
|
+
self.query_one(f"#{PICKER_HINT}", Static).update(WrappedGroups(groups, gap))
|
|
264
|
+
|
|
265
|
+
def _hint_groups(self, has_rows: bool, choose: Text, keys_only: bool) -> tuple[Text, ...]:
|
|
266
|
+
if keys_only:
|
|
267
|
+
return (
|
|
268
|
+
key_hint("↑↓", enabled=has_rows),
|
|
269
|
+
Text.assemble(
|
|
270
|
+
key_hint("Enter", enabled=has_rows or self._search_open),
|
|
271
|
+
key_label(SEARCH_KEY, ""),
|
|
272
|
+
self._load_more_hint(keys_only=True),
|
|
273
|
+
),
|
|
274
|
+
)
|
|
275
|
+
return (
|
|
276
|
+
key_hint("↑↓ select", enabled=has_rows),
|
|
277
|
+
choose,
|
|
278
|
+
key_label(SEARCH_KEY, SEARCH_LABEL),
|
|
279
|
+
self._load_more_hint(keys_only=False),
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
def _load_more_hint(self, *, keys_only: bool) -> Text:
|
|
283
|
+
if self._has_more:
|
|
284
|
+
return load_more_hint(keys_only=keys_only)
|
|
285
|
+
inert = Text(MORE_KEY, style=BOLD_DISABLED_KEY)
|
|
286
|
+
return inert if keys_only else Text.assemble(inert, grey(" load more"))
|
|
287
|
+
|
|
288
|
+
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
|
|
289
|
+
"""Leave Home and End to the search field's cursor while it has the keyboard."""
|
|
290
|
+
del parameters
|
|
291
|
+
return not (action == JUMP_ACTION and isinstance(self.focused, Input))
|
|
292
|
+
|
|
293
|
+
def action_move(self, step: int) -> None:
|
|
294
|
+
"""Move the selection by `step` rows, stopping at the first and last loaded task."""
|
|
295
|
+
if self._tasks:
|
|
296
|
+
self._list.index = min(max((self._list.index or 0) + step, 0), len(self._tasks) - 1)
|
|
297
|
+
|
|
298
|
+
def action_page(self, direction: int) -> None:
|
|
299
|
+
"""Move the selection by one visible page up (`-1`) or down (`1`)."""
|
|
300
|
+
self.action_move(direction * max(1, self._list.size.height - PAGE_OVERLAP_ROWS))
|
|
301
|
+
|
|
302
|
+
def action_jump(self, direction: int) -> None:
|
|
303
|
+
"""Select the first (`-1`) or last (`1`) loaded task."""
|
|
304
|
+
if self._tasks:
|
|
305
|
+
self._list.index = 0 if direction < 0 else len(self._tasks) - 1
|
|
306
|
+
|
|
307
|
+
async def action_open_search(self) -> None:
|
|
308
|
+
"""Show the search field under the title and focus it."""
|
|
309
|
+
if not self._search_open:
|
|
310
|
+
search = Input(
|
|
311
|
+
value=self._search,
|
|
312
|
+
placeholder="Search task ID or title",
|
|
313
|
+
id=PICKER_SEARCH,
|
|
314
|
+
)
|
|
315
|
+
await self.mount(search, before=f"#{PICKER_EMPTY}")
|
|
316
|
+
self.query_one(f"#{PICKER_SEARCH}", Input).focus()
|
|
317
|
+
self._apply_compact_layout(self.size.width, self.size.height)
|
|
318
|
+
|
|
319
|
+
async def action_load_more(self) -> None:
|
|
320
|
+
"""Append the next bounded page, keeping the selection."""
|
|
321
|
+
if self._has_more:
|
|
322
|
+
await self._append_page()
|
|
323
|
+
|
|
324
|
+
async def action_close(self) -> None:
|
|
325
|
+
"""Clear and close the search first; a second press closes the popup."""
|
|
326
|
+
if not self._search_open:
|
|
327
|
+
self.dismiss(None)
|
|
328
|
+
return
|
|
329
|
+
self._search = ""
|
|
330
|
+
await self._close_search()
|
|
331
|
+
await self._reload()
|
|
332
|
+
|
|
333
|
+
async def _close_search(self) -> None:
|
|
334
|
+
await self.query_one(f"#{PICKER_SEARCH}", Input).remove()
|
|
335
|
+
self._apply_compact_layout(self.size.width, self.size.height)
|
|
336
|
+
self._list.focus()
|
|
337
|
+
|
|
338
|
+
async def on_input_changed(self, event: Input.Changed) -> None:
|
|
339
|
+
"""Narrow the list to tasks whose ID or title match the typed text."""
|
|
340
|
+
search = event.value.strip()
|
|
341
|
+
if search == self._search:
|
|
342
|
+
return
|
|
343
|
+
self._search = search
|
|
344
|
+
await self._reload()
|
|
345
|
+
|
|
346
|
+
async def on_input_submitted(self) -> None:
|
|
347
|
+
"""Close the search field and keep its filter."""
|
|
348
|
+
await self._close_search()
|
|
349
|
+
|
|
350
|
+
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
|
351
|
+
"""Dismiss with the ID of the task chosen with Enter or a click."""
|
|
352
|
+
if isinstance(event.item, PickerRow):
|
|
353
|
+
self.dismiss(event.item.task_id)
|
|
354
|
+
|
|
355
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
356
|
+
"""Close the popup from its close control."""
|
|
357
|
+
if event.button.id == PICKER_CLOSE_BUTTON:
|
|
358
|
+
self.dismiss(None)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""The instant shared by every motion callback of one tick."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from contextvars import ContextVar
|
|
7
|
+
from typing import TYPE_CHECKING, cast
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import Callable, Generator, Hashable
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
|
|
13
|
+
from shikiru.clock import Clock
|
|
14
|
+
|
|
15
|
+
_TICK_INSTANT: ContextVar[datetime | None] = ContextVar("tick_instant", default=None)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def current_tick() -> datetime | None:
|
|
19
|
+
"""Return the instant of the tick the caller runs in, or None outside any tick."""
|
|
20
|
+
return _TICK_INSTANT.get()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def tick_now(clock: Clock) -> datetime:
|
|
24
|
+
"""Return the tick's instant inside a tick, otherwise the clock's current time."""
|
|
25
|
+
instant = _TICK_INSTANT.get()
|
|
26
|
+
return instant if instant is not None else clock.now()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@contextmanager
|
|
30
|
+
def pinned_tick(instant: datetime) -> Generator[None]:
|
|
31
|
+
"""Make tasks created inside the block see `instant` as their tick."""
|
|
32
|
+
token = _TICK_INSTANT.set(instant)
|
|
33
|
+
try:
|
|
34
|
+
yield
|
|
35
|
+
finally:
|
|
36
|
+
_TICK_INSTANT.reset(token)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TickMemo:
|
|
40
|
+
"""Values built once per tick and data version, never reused outside a tick."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, data_version: Callable[[], int]) -> None:
|
|
43
|
+
"""Key the memo on the tick and on the `data_version` it reads."""
|
|
44
|
+
self._data_version = data_version
|
|
45
|
+
self._key: tuple[datetime, int] | None = None
|
|
46
|
+
self._values: dict[Hashable, object] = {}
|
|
47
|
+
|
|
48
|
+
def get[V](self, name: Hashable, load: Callable[[], V]) -> V:
|
|
49
|
+
"""Return the value for `name` built by `load` earlier in this tick, else build it."""
|
|
50
|
+
tick = _TICK_INSTANT.get()
|
|
51
|
+
if tick is None:
|
|
52
|
+
return load()
|
|
53
|
+
key = (tick, self._data_version())
|
|
54
|
+
if key != self._key:
|
|
55
|
+
self._key = key
|
|
56
|
+
self._values = {}
|
|
57
|
+
if name not in self._values:
|
|
58
|
+
self._values[name] = load()
|
|
59
|
+
return cast("V", self._values[name])
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""The one-line text filter of the Agent activity list: `role: any · turn: any`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
6
|
+
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
from textual.binding import Binding
|
|
9
|
+
from textual.message import Message
|
|
10
|
+
from textual.widgets import Static
|
|
11
|
+
|
|
12
|
+
from shikiru.dashboard.section_rows import ROLE_LABELS, turn_label
|
|
13
|
+
from shikiru.domain.tasks import Role
|
|
14
|
+
from shikiru.ui.section import BOLD_STEEL, GROUP_SEPARATOR, grey
|
|
15
|
+
from shikiru.ui.tokens import PRIMARY_TEXT_COLOR
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Sequence
|
|
19
|
+
|
|
20
|
+
from textual.binding import BindingType
|
|
21
|
+
|
|
22
|
+
ANY_LABEL = "any"
|
|
23
|
+
ROLE_CHOICES = (Role.LEADER.value, Role.IMPLEMENTER.value, Role.HELPER.value)
|
|
24
|
+
ROLE_NAME = "role:"
|
|
25
|
+
TURN_NAME = "turn:"
|
|
26
|
+
CYCLE_ROLE_KEY = "r"
|
|
27
|
+
CYCLE_TURN_KEY = "t"
|
|
28
|
+
CLEAR_KEY = "x"
|
|
29
|
+
CLOSE_KEY = "f"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def next_choice(current: str | None, choices: Sequence[str]) -> str | None:
|
|
33
|
+
"""Return the choice after `current`; `None` (any) follows the last, or starts the cycle."""
|
|
34
|
+
cycle = [*choices, None]
|
|
35
|
+
return cycle[choices.index(current) + 1] if current in choices else cycle[0]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TimelineFilterBar(Static, can_focus=True):
|
|
39
|
+
"""Role and turn filter shown as text; `r` and `t` cycle them, `x` clears, `f` closes."""
|
|
40
|
+
|
|
41
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
42
|
+
Binding(CYCLE_ROLE_KEY, "cycle_role", "Role", show=False),
|
|
43
|
+
Binding(CYCLE_TURN_KEY, "cycle_turn", "Turn", show=False),
|
|
44
|
+
Binding(CLEAR_KEY, "clear", "Clear", show=False),
|
|
45
|
+
Binding(CLOSE_KEY, "close", "Close", show=False),
|
|
46
|
+
]
|
|
47
|
+
DEFAULT_CSS = """
|
|
48
|
+
TimelineFilterBar { height: 1; width: 100%; margin: 1 0 0 0; }
|
|
49
|
+
TimelineFilterBar:focus { background-tint: transparent; }
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
class Changed(Message):
|
|
53
|
+
"""The role or turn filter changed."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, role: str | None, turn: str | None) -> None:
|
|
56
|
+
super().__init__()
|
|
57
|
+
self.role = role
|
|
58
|
+
self.turn = turn
|
|
59
|
+
|
|
60
|
+
class Closed(Message):
|
|
61
|
+
"""The operator closed the bar."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, *, widget_id: str) -> None:
|
|
64
|
+
"""Create a hidden bar with no filter."""
|
|
65
|
+
super().__init__("", id=widget_id, markup=False)
|
|
66
|
+
self.display = False
|
|
67
|
+
self._role: str | None = None
|
|
68
|
+
self._turn: str | None = None
|
|
69
|
+
self._turns: tuple[str, ...] = ()
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def active(self) -> bool:
|
|
73
|
+
"""Whether a role or turn filter is applied."""
|
|
74
|
+
return self._role is not None or self._turn is not None
|
|
75
|
+
|
|
76
|
+
def on_mount(self) -> None:
|
|
77
|
+
self._paint()
|
|
78
|
+
|
|
79
|
+
def on_focus(self) -> None:
|
|
80
|
+
self._paint()
|
|
81
|
+
|
|
82
|
+
def on_blur(self) -> None:
|
|
83
|
+
self._paint()
|
|
84
|
+
|
|
85
|
+
def set_turns(self, turns: Sequence[str]) -> None:
|
|
86
|
+
"""Set the turns `t` cycles through."""
|
|
87
|
+
self._turns = tuple(turns)
|
|
88
|
+
|
|
89
|
+
def reset(self) -> None:
|
|
90
|
+
"""Forget the filter and the known turns."""
|
|
91
|
+
self._role = self._turn = None
|
|
92
|
+
self._turns = ()
|
|
93
|
+
self._paint()
|
|
94
|
+
|
|
95
|
+
def action_cycle_role(self) -> None:
|
|
96
|
+
"""Pick the next role, or any."""
|
|
97
|
+
self._set(next_choice(self._role, ROLE_CHOICES), self._turn)
|
|
98
|
+
|
|
99
|
+
def action_cycle_turn(self) -> None:
|
|
100
|
+
"""Pick the next seen turn, or any."""
|
|
101
|
+
self._set(self._role, next_choice(self._turn, self._turns))
|
|
102
|
+
|
|
103
|
+
def action_clear(self) -> None:
|
|
104
|
+
"""Drop both filters."""
|
|
105
|
+
self._set(None, None)
|
|
106
|
+
|
|
107
|
+
def action_close(self) -> None:
|
|
108
|
+
"""Ask the owner to hide the bar."""
|
|
109
|
+
self.post_message(self.Closed())
|
|
110
|
+
|
|
111
|
+
def _set(self, role: str | None, turn: str | None) -> None:
|
|
112
|
+
if (role, turn) == (self._role, self._turn):
|
|
113
|
+
return
|
|
114
|
+
self._role, self._turn = role, turn
|
|
115
|
+
self._paint()
|
|
116
|
+
self.post_message(self.Changed(role, turn))
|
|
117
|
+
|
|
118
|
+
def _paint(self) -> None:
|
|
119
|
+
name_style = BOLD_STEEL if self.has_focus else ""
|
|
120
|
+
role = ROLE_LABELS[self._role] if self._role is not None else ANY_LABEL
|
|
121
|
+
line = Text()
|
|
122
|
+
for index, (name, value) in enumerate(
|
|
123
|
+
((ROLE_NAME, role), (TURN_NAME, turn_label(self._turn) or ANY_LABEL))
|
|
124
|
+
):
|
|
125
|
+
if index:
|
|
126
|
+
line.append_text(grey(GROUP_SEPARATOR))
|
|
127
|
+
line.append_text(Text(name, style=name_style) if name_style else grey(name))
|
|
128
|
+
line.append(f" {value}", style=PRIMARY_TEXT_COLOR)
|
|
129
|
+
self.update(line)
|