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,385 @@
|
|
|
1
|
+
"""One task timeline: a category's rows, cursor stack, filters and follow state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, fields
|
|
6
|
+
from typing import TYPE_CHECKING, ClassVar, cast
|
|
7
|
+
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
from textual.binding import Binding
|
|
10
|
+
from textual.containers import Vertical
|
|
11
|
+
from textual.message import Message
|
|
12
|
+
from textual.widgets import Static
|
|
13
|
+
|
|
14
|
+
from shikiru.dashboard.row_list import RowList
|
|
15
|
+
from shikiru.dashboard.section_rows import timeline_cells
|
|
16
|
+
from shikiru.dashboard.timeline_filter import TimelineFilterBar
|
|
17
|
+
from shikiru.errors import ShikiruError
|
|
18
|
+
from shikiru.ui.screen import widget_is_current
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
import asyncio
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
|
|
24
|
+
from rich.console import Console, ConsoleOptions, RenderResult
|
|
25
|
+
from textual.app import App, ComposeResult
|
|
26
|
+
from textual.binding import BindingType
|
|
27
|
+
from textual.widget import Widget
|
|
28
|
+
|
|
29
|
+
from shikiru.dashboard.contract import (
|
|
30
|
+
TaskTimelineCategory,
|
|
31
|
+
TaskTimelineEntryView,
|
|
32
|
+
TaskTimelinePageView,
|
|
33
|
+
WidgetContext,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
NO_TIMELINE_ROWS = "No recorded entries match."
|
|
37
|
+
FOLLOWING_LABEL = "following"
|
|
38
|
+
PAUSED_LABEL = "paused"
|
|
39
|
+
REFRESH_FAILED = "Evidence timeline refresh failed"
|
|
40
|
+
FILTER_KEY = "f"
|
|
41
|
+
OPEN_FILTER_ACTION = "open_filter"
|
|
42
|
+
SELECTED_MAX_LINES = 4
|
|
43
|
+
ELLIPSIS = "\N{HORIZONTAL ELLIPSIS}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class TimelineIds:
|
|
48
|
+
"""The widget ids of one timeline section, derived from a shared prefix."""
|
|
49
|
+
|
|
50
|
+
root: str
|
|
51
|
+
filters: str
|
|
52
|
+
list: str
|
|
53
|
+
selected: str
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def with_prefix(cls, prefix: str) -> TimelineIds:
|
|
57
|
+
"""Build the ids `<prefix>-<part>` for every part of a timeline section."""
|
|
58
|
+
return cls(*(f"{prefix}-{part.name}" for part in fields(cls)))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class TimelineRequest:
|
|
63
|
+
"""The page a timeline asks the views for."""
|
|
64
|
+
|
|
65
|
+
cursor: str | None
|
|
66
|
+
role: str | None
|
|
67
|
+
turn: str | None
|
|
68
|
+
category: str
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class TimelineHost:
|
|
73
|
+
"""What a timeline needs from the panel that owns it."""
|
|
74
|
+
|
|
75
|
+
task_id: Callable[[], str | None]
|
|
76
|
+
lock: asyncio.Lock
|
|
77
|
+
report: Callable[[str], None]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def screen_is_active(widget: Widget) -> bool:
|
|
81
|
+
"""Whether the widget's screen is the current one in a running app."""
|
|
82
|
+
return widget_is_current(widget) and cast("App[None]", widget.app).is_running
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class _SelectedEntry:
|
|
87
|
+
text: Text
|
|
88
|
+
|
|
89
|
+
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
90
|
+
lines = self.text.wrap(console, options.max_width)
|
|
91
|
+
shown = lines[:SELECTED_MAX_LINES]
|
|
92
|
+
if len(lines) > SELECTED_MAX_LINES:
|
|
93
|
+
last = shown[-1]
|
|
94
|
+
last.rstrip()
|
|
95
|
+
last.truncate(max(options.max_width - 1, 1))
|
|
96
|
+
last.append(ELLIPSIS)
|
|
97
|
+
yield from shown
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class TimelineSection(Vertical):
|
|
101
|
+
"""Own one timeline's rows, paging, filters and follow state; `shown` while open."""
|
|
102
|
+
|
|
103
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
104
|
+
Binding(FILTER_KEY, OPEN_FILTER_ACTION, "Filter", show=False)
|
|
105
|
+
]
|
|
106
|
+
DEFAULT_CSS = """
|
|
107
|
+
TimelineSection { height: auto; }
|
|
108
|
+
TimelineSection .timeline-selected {
|
|
109
|
+
height: auto; padding: 0 1; margin: 1 0 0 0;
|
|
110
|
+
color: $primary-text; background: $detail-surface; text-style: italic;
|
|
111
|
+
}
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
class StatusChanged(Message):
|
|
115
|
+
"""The section's `following` or `paused` status, announced on mount and on every change."""
|
|
116
|
+
|
|
117
|
+
def __init__(self, section: TimelineSection, status: str) -> None:
|
|
118
|
+
super().__init__()
|
|
119
|
+
self.section = section
|
|
120
|
+
self.status = status
|
|
121
|
+
|
|
122
|
+
def __init__(
|
|
123
|
+
self,
|
|
124
|
+
context: WidgetContext,
|
|
125
|
+
host: TimelineHost,
|
|
126
|
+
*,
|
|
127
|
+
ids: TimelineIds,
|
|
128
|
+
category: TaskTimelineCategory,
|
|
129
|
+
filterable: bool,
|
|
130
|
+
) -> None:
|
|
131
|
+
super().__init__(id=ids.root)
|
|
132
|
+
self.ids = ids
|
|
133
|
+
self._widget_context = context
|
|
134
|
+
self._host = host
|
|
135
|
+
self._category = category.value
|
|
136
|
+
self._filterable = filterable
|
|
137
|
+
self._page: TaskTimelinePageView | None = None
|
|
138
|
+
self._generation = 0
|
|
139
|
+
self.shown = False
|
|
140
|
+
self._reset_state()
|
|
141
|
+
|
|
142
|
+
def compose(self) -> ComposeResult:
|
|
143
|
+
yield RowList(widget_id=self.ids.list, edge_paging=True)
|
|
144
|
+
selected = Static("", id=self.ids.selected, classes="timeline-selected", markup=False)
|
|
145
|
+
selected.display = False
|
|
146
|
+
yield selected
|
|
147
|
+
if self._filterable:
|
|
148
|
+
yield TimelineFilterBar(widget_id=self.ids.filters)
|
|
149
|
+
|
|
150
|
+
def on_mount(self) -> None:
|
|
151
|
+
"""Announce the initial status so its fold header shows it before it is ever opened."""
|
|
152
|
+
self._announce_status()
|
|
153
|
+
|
|
154
|
+
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
|
|
155
|
+
del parameters
|
|
156
|
+
return self._filterable if action == OPEN_FILTER_ACTION else True
|
|
157
|
+
|
|
158
|
+
def action_open_filter(self) -> None:
|
|
159
|
+
"""Show the filter bar and move focus into it."""
|
|
160
|
+
bar = self.query_one(f"#{self.ids.filters}", TimelineFilterBar)
|
|
161
|
+
bar.display = True
|
|
162
|
+
bar.focus()
|
|
163
|
+
|
|
164
|
+
def close_filter(self) -> bool:
|
|
165
|
+
"""Hide the focused filter bar and return focus to the list; False when it has none."""
|
|
166
|
+
if not self._filterable:
|
|
167
|
+
return False
|
|
168
|
+
bar = self.query_one(f"#{self.ids.filters}", TimelineFilterBar)
|
|
169
|
+
if not bar.has_focus:
|
|
170
|
+
return False
|
|
171
|
+
self.query_one(f"#{self.ids.list}", RowList).focus()
|
|
172
|
+
bar.display = bar.active
|
|
173
|
+
return True
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def request(self) -> TimelineRequest:
|
|
177
|
+
"""The page the current cursor, filters and category ask for."""
|
|
178
|
+
return TimelineRequest(
|
|
179
|
+
None if self._following else self._cursors[-1],
|
|
180
|
+
self._role,
|
|
181
|
+
self._turn,
|
|
182
|
+
self._category,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def generation(self) -> int:
|
|
187
|
+
"""Bumped whenever the request changes, so older replies can be dropped."""
|
|
188
|
+
return self._generation
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def following(self) -> bool:
|
|
192
|
+
"""Whether the section tracks the latest entries."""
|
|
193
|
+
return self._following
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def page(self) -> TaskTimelinePageView | None:
|
|
197
|
+
"""The last page painted."""
|
|
198
|
+
return self._page
|
|
199
|
+
|
|
200
|
+
def reset(self) -> None:
|
|
201
|
+
"""Forget the cursor, filters and rows, as for a newly selected task."""
|
|
202
|
+
self._reset_state()
|
|
203
|
+
self._page = None
|
|
204
|
+
self._generation += 1
|
|
205
|
+
if not self.is_mounted:
|
|
206
|
+
return
|
|
207
|
+
selected = self.query_one(f"#{self.ids.selected}", Static)
|
|
208
|
+
selected.update("")
|
|
209
|
+
selected.display = False
|
|
210
|
+
if self._filterable:
|
|
211
|
+
bar = self.query_one(f"#{self.ids.filters}", TimelineFilterBar)
|
|
212
|
+
bar.reset()
|
|
213
|
+
bar.display = False
|
|
214
|
+
self._announce_status()
|
|
215
|
+
self.query_one(f"#{self.ids.list}", RowList).set_rows(
|
|
216
|
+
(), selected=0, empty=NO_TIMELINE_ROWS
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def show_page(self, page: TaskTimelinePageView | None) -> None:
|
|
220
|
+
"""Paint a page of rows, keeping the selected entry when it is still listed."""
|
|
221
|
+
self._page = page
|
|
222
|
+
entries = page.entries if page is not None else ()
|
|
223
|
+
selected = (
|
|
224
|
+
len(entries) - 1
|
|
225
|
+
if self._select_last
|
|
226
|
+
else next(
|
|
227
|
+
(
|
|
228
|
+
index
|
|
229
|
+
for index, item in enumerate(entries)
|
|
230
|
+
if item.identity == self._selected_identity
|
|
231
|
+
),
|
|
232
|
+
0,
|
|
233
|
+
)
|
|
234
|
+
)
|
|
235
|
+
if self._select_last and selected > 0:
|
|
236
|
+
self._selected_identity = entries[selected].identity
|
|
237
|
+
self._following = False
|
|
238
|
+
self._select_last = False
|
|
239
|
+
self._remember_turns(entries)
|
|
240
|
+
self.query_one(f"#{self.ids.list}", RowList).set_rows(
|
|
241
|
+
[timeline_cells(item) for item in entries], selected=selected, empty=NO_TIMELINE_ROWS
|
|
242
|
+
)
|
|
243
|
+
self._announce_status()
|
|
244
|
+
self._paint_selection()
|
|
245
|
+
|
|
246
|
+
def pause_follow(self) -> None:
|
|
247
|
+
"""Stop following the latest entries until the operator asks for them again."""
|
|
248
|
+
if not self._following:
|
|
249
|
+
return
|
|
250
|
+
self._following = False
|
|
251
|
+
self._generation += 1
|
|
252
|
+
if screen_is_active(self):
|
|
253
|
+
self._announce_status()
|
|
254
|
+
|
|
255
|
+
def restart(self) -> None:
|
|
256
|
+
"""Return to the latest page and follow it."""
|
|
257
|
+
self._cursors = [None]
|
|
258
|
+
self._following = True
|
|
259
|
+
self._invalidate()
|
|
260
|
+
|
|
261
|
+
async def refresh_when_due(self) -> None:
|
|
262
|
+
"""Fetch the latest page for the 1 Hz tick; only a shown, following section does."""
|
|
263
|
+
if self.shown and self._following:
|
|
264
|
+
await self.refresh_page()
|
|
265
|
+
|
|
266
|
+
async def refresh_page(self) -> None:
|
|
267
|
+
"""Fetch this section's own page and paint it unless the request changed meanwhile."""
|
|
268
|
+
async with self._host.lock:
|
|
269
|
+
task_id = self._host.task_id()
|
|
270
|
+
if task_id is None or not screen_is_active(self):
|
|
271
|
+
return
|
|
272
|
+
generation = self._generation
|
|
273
|
+
request = self.request
|
|
274
|
+
try:
|
|
275
|
+
page = await self._widget_context.views.task_timeline(
|
|
276
|
+
task_id,
|
|
277
|
+
cursor=request.cursor,
|
|
278
|
+
role=request.role,
|
|
279
|
+
turn=request.turn,
|
|
280
|
+
category=request.category,
|
|
281
|
+
)
|
|
282
|
+
except (ShikiruError, OSError, ValueError, TypeError) as error:
|
|
283
|
+
if screen_is_active(self) and generation == self._generation:
|
|
284
|
+
self._host.report(f"{REFRESH_FAILED}: {error}")
|
|
285
|
+
return
|
|
286
|
+
if (
|
|
287
|
+
screen_is_active(self)
|
|
288
|
+
and task_id == self._host.task_id()
|
|
289
|
+
and generation == self._generation
|
|
290
|
+
):
|
|
291
|
+
self.show_page(page)
|
|
292
|
+
|
|
293
|
+
def on_timeline_filter_bar_changed(self, message: TimelineFilterBar.Changed) -> None:
|
|
294
|
+
message.stop()
|
|
295
|
+
self._role, self._turn = message.role, message.turn
|
|
296
|
+
self.restart()
|
|
297
|
+
|
|
298
|
+
def on_timeline_filter_bar_closed(self, message: TimelineFilterBar.Closed) -> None:
|
|
299
|
+
message.stop()
|
|
300
|
+
self.close_filter()
|
|
301
|
+
|
|
302
|
+
def on_row_list_moved(self, message: RowList.Moved) -> None:
|
|
303
|
+
message.stop()
|
|
304
|
+
page = self._page
|
|
305
|
+
if page is None:
|
|
306
|
+
return
|
|
307
|
+
self._selected_identity = (
|
|
308
|
+
page.entries[message.index].identity if message.index > 0 else None
|
|
309
|
+
)
|
|
310
|
+
if message.index > 0:
|
|
311
|
+
self.pause_follow()
|
|
312
|
+
self._paint_selection()
|
|
313
|
+
|
|
314
|
+
def on_row_list_paged(self, message: RowList.Paged) -> None:
|
|
315
|
+
message.stop()
|
|
316
|
+
if message.older:
|
|
317
|
+
self._older_page()
|
|
318
|
+
else:
|
|
319
|
+
self._newer_page(select_last=message.by_arrow)
|
|
320
|
+
|
|
321
|
+
def on_row_list_scrolled(self, message: RowList.Scrolled) -> None:
|
|
322
|
+
message.stop()
|
|
323
|
+
self.pause_follow()
|
|
324
|
+
|
|
325
|
+
def on_row_list_latest(self, message: RowList.Latest) -> None:
|
|
326
|
+
message.stop()
|
|
327
|
+
self.restart()
|
|
328
|
+
|
|
329
|
+
def _reset_state(self) -> None:
|
|
330
|
+
self._cursors: list[str | None] = [None]
|
|
331
|
+
self._following = True
|
|
332
|
+
self._role: str | None = None
|
|
333
|
+
self._turn: str | None = None
|
|
334
|
+
self._selected_identity: str | None = None
|
|
335
|
+
self._select_last = False
|
|
336
|
+
self._known_turns: set[str] = set()
|
|
337
|
+
|
|
338
|
+
def _invalidate(self) -> None:
|
|
339
|
+
self._generation += 1
|
|
340
|
+
self._selected_identity = None
|
|
341
|
+
self.run_worker(self.refresh_page(), exclusive=False)
|
|
342
|
+
|
|
343
|
+
def _older_page(self) -> None:
|
|
344
|
+
page = self._page
|
|
345
|
+
if (
|
|
346
|
+
page is None
|
|
347
|
+
or not page.has_older
|
|
348
|
+
or page.older_cursor is None
|
|
349
|
+
or page.older_cursor == self._cursors[-1]
|
|
350
|
+
):
|
|
351
|
+
return
|
|
352
|
+
self._cursors.append(page.older_cursor)
|
|
353
|
+
self._following = False
|
|
354
|
+
self._invalidate()
|
|
355
|
+
|
|
356
|
+
def _newer_page(self, *, select_last: bool) -> None:
|
|
357
|
+
if len(self._cursors) <= 1:
|
|
358
|
+
return
|
|
359
|
+
self._cursors.pop()
|
|
360
|
+
self._following = len(self._cursors) == 1
|
|
361
|
+
self._select_last = select_last
|
|
362
|
+
self._invalidate()
|
|
363
|
+
|
|
364
|
+
def _announce_status(self) -> None:
|
|
365
|
+
self.post_message(
|
|
366
|
+
self.StatusChanged(self, FOLLOWING_LABEL if self._following else PAUSED_LABEL)
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
def _remember_turns(self, entries: tuple[TaskTimelineEntryView, ...]) -> None:
|
|
370
|
+
if not self._filterable:
|
|
371
|
+
return
|
|
372
|
+
self._known_turns.update(item.turn for item in entries if item.turn)
|
|
373
|
+
self.query_one(f"#{self.ids.filters}", TimelineFilterBar).set_turns(
|
|
374
|
+
sorted(self._known_turns)
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
def _paint_selection(self) -> None:
|
|
378
|
+
page = self._page
|
|
379
|
+
entries = page.entries if page is not None else ()
|
|
380
|
+
chosen = self.query_one(f"#{self.ids.list}", RowList).selected
|
|
381
|
+
target = self.query_one(f"#{self.ids.selected}", Static)
|
|
382
|
+
selected = entries[chosen] if chosen < len(entries) else None
|
|
383
|
+
target.display = selected is not None
|
|
384
|
+
if selected is not None:
|
|
385
|
+
target.update(_SelectedEntry(Text(selected.phrase)))
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Body of the Usage fold: a totals line, one aligned line per session, one note."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from decimal import ROUND_HALF_UP, Decimal
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from rich.cells import cell_len
|
|
11
|
+
from rich.console import Group
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
from shikiru.agents.contracts import BILLING_MODE_SUBSCRIPTION, BILLING_MODE_UNKNOWN
|
|
15
|
+
from shikiru.dashboard.briefing_view import counted, total_cost_text
|
|
16
|
+
from shikiru.domain.levels import UsageLevel, displayed_pct
|
|
17
|
+
from shikiru.ui.compact import (
|
|
18
|
+
COUNT_SHORT_MILLION,
|
|
19
|
+
COUNT_SHORT_SCALE,
|
|
20
|
+
ROLE_LABELS,
|
|
21
|
+
compact_count,
|
|
22
|
+
)
|
|
23
|
+
from shikiru.ui.section import BOLD_STEEL, GROUP_SEPARATOR, grey
|
|
24
|
+
from shikiru.ui.tokens import AMBER, SECONDARY_TEXT_COLOR, USAGE_LEVEL_COLORS
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from collections.abc import Sequence
|
|
28
|
+
|
|
29
|
+
from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
|
|
30
|
+
|
|
31
|
+
from shikiru.dashboard.contract import TaskDetailView, TaskSessionView
|
|
32
|
+
|
|
33
|
+
NO_VALUE = "\N{EM DASH}"
|
|
34
|
+
NO_USAGE = "No usage is recorded yet."
|
|
35
|
+
COVERAGE_UNAVAILABLE = "Task usage coverage unavailable"
|
|
36
|
+
OLD_TAG = "old"
|
|
37
|
+
TOKENS_WORD = " tokens"
|
|
38
|
+
NOTE_LEAD = "! "
|
|
39
|
+
LEAD = " "
|
|
40
|
+
GAP = " "
|
|
41
|
+
ELLIPSIS = "\N{HORIZONTAL ELLIPSIS}"
|
|
42
|
+
MODEL_MIN_WIDTH = 8
|
|
43
|
+
SIGNIFICANT_DIGITS = 3
|
|
44
|
+
PAIR_SEPARATOR = " / "
|
|
45
|
+
PAIR_LABEL = "r/w"
|
|
46
|
+
_NOUN_SESSION = "session"
|
|
47
|
+
_NOUN_GAP = "model-history gap"
|
|
48
|
+
_VERB_NO_USAGE = "reported no usage"
|
|
49
|
+
_PHRASE_BILLING_UNKNOWN = "billing unknown for"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Column(StrEnum):
|
|
53
|
+
"""The session table columns, in display order, named as their header shows them."""
|
|
54
|
+
|
|
55
|
+
ROLE = "Role"
|
|
56
|
+
MODEL = "Model"
|
|
57
|
+
TOKENS = "Tokens r/w"
|
|
58
|
+
COST = "Cost"
|
|
59
|
+
CONTEXT = "Ctx"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_LEFT_ALIGNED = (Column.ROLE, Column.MODEL)
|
|
63
|
+
_DROP_ORDER = (Column.MODEL, Column.CONTEXT, Column.COST)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class UsageRow:
|
|
68
|
+
"""One session's cell texts, its context level and whether it is an older session."""
|
|
69
|
+
|
|
70
|
+
cells: dict[Column, str]
|
|
71
|
+
level: UsageLevel | None
|
|
72
|
+
old: bool
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class UsageTable:
|
|
77
|
+
"""The columns that fit the width, their widths, the rows and whether they stack."""
|
|
78
|
+
|
|
79
|
+
columns: tuple[Column, ...]
|
|
80
|
+
widths: dict[Column, int]
|
|
81
|
+
rows: tuple[UsageRow, ...]
|
|
82
|
+
stacked: bool
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def usage_rows(sessions: Sequence[TaskSessionView], width: int) -> UsageTable:
|
|
86
|
+
"""Derive the session table for `width`: shrink the model, drop it, then drop Ctx and Cost."""
|
|
87
|
+
ordered = sorted(sessions, key=lambda session: not session.current_lineage)
|
|
88
|
+
rows = tuple(_row(session) for session in ordered)
|
|
89
|
+
columns = list(Column)
|
|
90
|
+
widths = {
|
|
91
|
+
column: max(cell_len(column.value), *(cell_len(row.cells[column]) for row in rows))
|
|
92
|
+
for column in columns
|
|
93
|
+
}
|
|
94
|
+
old_room = (len(GAP) + len(OLD_TAG)) if any(row.old for row in rows) else 0
|
|
95
|
+
|
|
96
|
+
def total() -> int:
|
|
97
|
+
gaps = len(GAP) * (len(columns) - 1)
|
|
98
|
+
return len(LEAD) + sum(widths[c] for c in columns) + gaps + old_room
|
|
99
|
+
|
|
100
|
+
overflow = total() - width
|
|
101
|
+
if overflow > 0:
|
|
102
|
+
widths[Column.MODEL] = max(MODEL_MIN_WIDTH, widths[Column.MODEL] - overflow)
|
|
103
|
+
for column in _DROP_ORDER:
|
|
104
|
+
if total() > width:
|
|
105
|
+
columns.remove(column)
|
|
106
|
+
return UsageTable(tuple(columns), widths, rows, stacked=total() > width)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _row(session: TaskSessionView) -> UsageRow:
|
|
110
|
+
return UsageRow(
|
|
111
|
+
{
|
|
112
|
+
Column.ROLE: ROLE_LABELS.get(session.role, session.role.title()),
|
|
113
|
+
Column.MODEL: session.model,
|
|
114
|
+
Column.TOKENS: _tokens(session),
|
|
115
|
+
Column.COST: _cost(session),
|
|
116
|
+
Column.CONTEXT: _context(session),
|
|
117
|
+
},
|
|
118
|
+
session.context_level,
|
|
119
|
+
not session.current_lineage,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _tokens_known(session: TaskSessionView) -> bool:
|
|
124
|
+
return session.usage_reported is True or session.legacy_token_totals
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _session_tokens(session: TaskSessionView) -> int:
|
|
128
|
+
return session.input_tokens + session.output_tokens
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _tokens(session: TaskSessionView) -> str:
|
|
132
|
+
if not _tokens_known(session):
|
|
133
|
+
return NO_VALUE
|
|
134
|
+
return PAIR_SEPARATOR.join(
|
|
135
|
+
(_abbreviated(session.input_tokens), _abbreviated(session.output_tokens))
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _abbreviated(count: int) -> str:
|
|
140
|
+
if count < COUNT_SHORT_SCALE:
|
|
141
|
+
return str(count)
|
|
142
|
+
thousands = _significant(Decimal(count) / COUNT_SHORT_SCALE)
|
|
143
|
+
if thousands < COUNT_SHORT_SCALE:
|
|
144
|
+
return f"{_plain(thousands)}k"
|
|
145
|
+
return f"{_plain(_significant(Decimal(count) / COUNT_SHORT_MILLION))}M"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _significant(value: Decimal) -> Decimal:
|
|
149
|
+
exponent = value.adjusted() - (SIGNIFICANT_DIGITS - 1)
|
|
150
|
+
return value.quantize(Decimal(1).scaleb(exponent), rounding=ROUND_HALF_UP)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _plain(value: Decimal) -> str:
|
|
154
|
+
return format(value.normalize(), "f")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _cost(session: TaskSessionView) -> str:
|
|
158
|
+
if session.cost_usd is None or session.billing_mode == BILLING_MODE_SUBSCRIPTION:
|
|
159
|
+
return NO_VALUE
|
|
160
|
+
return f"${session.cost_usd:.2f}"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _context(session: TaskSessionView) -> str:
|
|
164
|
+
if session.usage_reported is False or session.context_used_pct is None:
|
|
165
|
+
return NO_VALUE
|
|
166
|
+
return f"{displayed_pct(session.context_used_pct)}%"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _fit(text: str, width: int) -> str:
|
|
170
|
+
if cell_len(text) <= width:
|
|
171
|
+
return text
|
|
172
|
+
return text[: width - len(ELLIPSIS)] + ELLIPSIS
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _padded(text: str, width: int, *, left: bool) -> str:
|
|
176
|
+
fitted = _fit(text, width)
|
|
177
|
+
padding = " " * (width - cell_len(fitted))
|
|
178
|
+
return fitted + padding if left else padding + fitted
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _line(table: UsageTable, cells: dict[Column, str], style: str) -> Text:
|
|
182
|
+
return Text(
|
|
183
|
+
LEAD
|
|
184
|
+
+ GAP.join(
|
|
185
|
+
_padded(cells[column], table.widths[column], left=column in _LEFT_ALIGNED)
|
|
186
|
+
for column in table.columns
|
|
187
|
+
),
|
|
188
|
+
style=style,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _row_text(table: UsageTable, row: UsageRow) -> Text:
|
|
193
|
+
line = _line(table, row.cells, "")
|
|
194
|
+
if row.level is not None and row.level is not UsageLevel.OK and Column.CONTEXT in table.columns:
|
|
195
|
+
context_width = table.widths[Column.CONTEXT]
|
|
196
|
+
line.stylize(USAGE_LEVEL_COLORS[row.level], len(line) - context_width, len(line))
|
|
197
|
+
return _old_tagged(line, row)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _old_tagged(line: Text, row: UsageRow) -> Text:
|
|
201
|
+
if row.old:
|
|
202
|
+
line.append(f"{GAP}{OLD_TAG}", style=SECONDARY_TEXT_COLOR)
|
|
203
|
+
return line
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _table_lines(table: UsageTable) -> list[Text]:
|
|
207
|
+
if table.stacked:
|
|
208
|
+
return [line for row in table.rows for line in _stacked_row(row)]
|
|
209
|
+
header = _line(table, {column: column.value for column in Column}, SECONDARY_TEXT_COLOR)
|
|
210
|
+
return [header, *(_row_text(table, row) for row in table.rows)]
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _stacked_row(row: UsageRow) -> list[Text]:
|
|
214
|
+
role = _old_tagged(Text(LEAD + row.cells[Column.ROLE]), row)
|
|
215
|
+
pair = Text.assemble(LEAD, GAP, grey(PAIR_LABEL), GAP, row.cells[Column.TOKENS])
|
|
216
|
+
return [role, pair]
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _heading(sessions: Sequence[TaskSessionView]) -> Text | None:
|
|
220
|
+
parts: list[Text] = []
|
|
221
|
+
cost = total_cost_text(sessions)
|
|
222
|
+
if cost is not None:
|
|
223
|
+
parts.append(Text(cost))
|
|
224
|
+
known = [_session_tokens(session) for session in sessions if _tokens_known(session)]
|
|
225
|
+
if known:
|
|
226
|
+
parts.append(Text.assemble((compact_count(sum(known)), BOLD_STEEL), grey(TOKENS_WORD)))
|
|
227
|
+
return Text(GROUP_SEPARATOR).join(parts) if parts else None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _note(sessions: Sequence[TaskSessionView]) -> Text | None:
|
|
231
|
+
silent = sum(session.usage_reported is False for session in sessions)
|
|
232
|
+
gaps = sum(len(session.history_gaps) for session in sessions)
|
|
233
|
+
unknown = sum(session.billing_mode == BILLING_MODE_UNKNOWN for session in sessions)
|
|
234
|
+
parts: list[str] = []
|
|
235
|
+
if silent:
|
|
236
|
+
parts.append(f"{counted(silent, _NOUN_SESSION)} {_VERB_NO_USAGE}")
|
|
237
|
+
if gaps:
|
|
238
|
+
parts.append(counted(gaps, _NOUN_GAP))
|
|
239
|
+
if unknown:
|
|
240
|
+
parts.append(f"{_PHRASE_BILLING_UNKNOWN} {counted(unknown, _NOUN_SESSION)}")
|
|
241
|
+
return Text(NOTE_LEAD + GROUP_SEPARATOR.join(parts), style=AMBER) if parts else None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@dataclass(frozen=True)
|
|
245
|
+
class UsageBody:
|
|
246
|
+
"""Render the Usage fold of a task at the width it is drawn at."""
|
|
247
|
+
|
|
248
|
+
view: TaskDetailView
|
|
249
|
+
|
|
250
|
+
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
251
|
+
"""Yield the heading, the session table and the note."""
|
|
252
|
+
del console
|
|
253
|
+
view = self.view
|
|
254
|
+
if view.usage is None:
|
|
255
|
+
yield grey(COVERAGE_UNAVAILABLE)
|
|
256
|
+
if not view.sessions:
|
|
257
|
+
if view.usage is not None:
|
|
258
|
+
yield grey(NO_USAGE)
|
|
259
|
+
return
|
|
260
|
+
table = usage_rows(view.sessions, options.max_width)
|
|
261
|
+
heading = _heading(view.sessions)
|
|
262
|
+
blocks: list[RenderableType] = [
|
|
263
|
+
*([heading] if heading is not None else []),
|
|
264
|
+
*_table_lines(table),
|
|
265
|
+
]
|
|
266
|
+
note = _note(view.sessions)
|
|
267
|
+
if note is not None:
|
|
268
|
+
blocks.extend([Text(), note])
|
|
269
|
+
yield Group(*blocks)
|