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,1606 @@
|
|
|
1
|
+
"""The shared Textual operator palette for the popup and dashboard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
7
|
+
from typing import TYPE_CHECKING, ClassVar, cast, get_args
|
|
8
|
+
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
from textual import on
|
|
11
|
+
from textual.binding import Binding
|
|
12
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
13
|
+
from textual.content import Content
|
|
14
|
+
from textual.screen import ModalScreen
|
|
15
|
+
from textual.widget import Widget
|
|
16
|
+
from textual.widgets import Button, Input, Label, Rule, Select, Static, TextArea
|
|
17
|
+
|
|
18
|
+
from shikiru.agents.contracts import parse_rung
|
|
19
|
+
from shikiru.config.settings import ComplexityName
|
|
20
|
+
from shikiru.dashboard.contract import (
|
|
21
|
+
PaletteAction,
|
|
22
|
+
PaletteDraft,
|
|
23
|
+
PaletteRequest,
|
|
24
|
+
)
|
|
25
|
+
from shikiru.domain.commands import (
|
|
26
|
+
COMMAND_REJECTED_FALLBACK,
|
|
27
|
+
CommandKind,
|
|
28
|
+
CommandStatus,
|
|
29
|
+
command_rejection_cause,
|
|
30
|
+
)
|
|
31
|
+
from shikiru.domain.tasks import Mode, Role
|
|
32
|
+
from shikiru.errors import ShikiruError
|
|
33
|
+
from shikiru.operator.commands import (
|
|
34
|
+
ACK_TIMEOUT_MESSAGE,
|
|
35
|
+
QUEUED_MESSAGE,
|
|
36
|
+
AckStatus,
|
|
37
|
+
)
|
|
38
|
+
from shikiru.operator.evidence_export import (
|
|
39
|
+
EXPORT_FORMAT_JSON,
|
|
40
|
+
EXPORT_FORMAT_MARKDOWN,
|
|
41
|
+
TaskEvidenceExporter,
|
|
42
|
+
)
|
|
43
|
+
from shikiru.operator.lifecycle import StartResult
|
|
44
|
+
from shikiru.operator.queries import (
|
|
45
|
+
TASK_ACTION_ROLES,
|
|
46
|
+
OperatorTaskAction,
|
|
47
|
+
)
|
|
48
|
+
from shikiru.ui.fold import FoldLine, fold_arrow
|
|
49
|
+
from shikiru.ui.keys import key_hint
|
|
50
|
+
from shikiru.ui.popup import (
|
|
51
|
+
POPUP_BODY_CLASS,
|
|
52
|
+
POPUP_CLOSE_CLASS,
|
|
53
|
+
POPUP_COMPACT_CONTROL_CSS,
|
|
54
|
+
POPUP_CSS,
|
|
55
|
+
POPUP_EDIT_CLASS,
|
|
56
|
+
POPUP_FRAME_CLASS,
|
|
57
|
+
POPUP_HEADER_CLASS,
|
|
58
|
+
POPUP_RULE_CLASS,
|
|
59
|
+
POPUP_TITLE_CLASS,
|
|
60
|
+
PopupFrame,
|
|
61
|
+
popup_close_label,
|
|
62
|
+
prepare_popup,
|
|
63
|
+
)
|
|
64
|
+
from shikiru.ui.screen import widget_is_attached
|
|
65
|
+
from shikiru.ui.tokens import (
|
|
66
|
+
AMBER,
|
|
67
|
+
DUSTY_ROSE,
|
|
68
|
+
SECONDARY_TEXT_COLOR,
|
|
69
|
+
STYLE_BOLD,
|
|
70
|
+
SUCCESS_COLOR,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
if TYPE_CHECKING:
|
|
74
|
+
from textual import events
|
|
75
|
+
from textual.app import App, ComposeResult
|
|
76
|
+
from textual.timer import Timer
|
|
77
|
+
from textual.worker import Worker
|
|
78
|
+
|
|
79
|
+
from shikiru.agents.contracts import Rung
|
|
80
|
+
from shikiru.dashboard.contract import OnChangeRegistration
|
|
81
|
+
from shikiru.domain.commands import Command
|
|
82
|
+
from shikiru.operator.commands import Ack, OperatorCommands
|
|
83
|
+
from shikiru.operator.evidence_export import EvidenceFormat
|
|
84
|
+
from shikiru.operator.queries import (
|
|
85
|
+
CommandHistoryEntry,
|
|
86
|
+
OperatorQueries,
|
|
87
|
+
TaskActionAvailability,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
_ACTION_SUBMIT = PaletteAction.SUBMIT.value
|
|
92
|
+
_ACTION_PRIORITIZE = PaletteAction.PRIORITIZE.value
|
|
93
|
+
_ACTION_PAUSE = PaletteAction.PAUSE.value
|
|
94
|
+
_ACTION_PAUSE_NOW = PaletteAction.PAUSE_NOW.value
|
|
95
|
+
_ACTION_RESUME = PaletteAction.RESUME.value
|
|
96
|
+
_ACTION_TAKE_OVER = PaletteAction.TAKE_OVER.value
|
|
97
|
+
_ACTION_HAND_BACK = PaletteAction.HAND_BACK.value
|
|
98
|
+
_ACTION_CANCEL = PaletteAction.CANCEL.value
|
|
99
|
+
_ACTION_RETRY = PaletteAction.RETRY.value
|
|
100
|
+
_ACTION_RUNG = PaletteAction.RUNG.value
|
|
101
|
+
_ACTION_MESSAGE = PaletteAction.MESSAGE.value
|
|
102
|
+
_ACTION_PEEK = PaletteAction.PEEK.value
|
|
103
|
+
_ACTION_STOP = PaletteAction.STOP.value
|
|
104
|
+
_ACTION_START = PaletteAction.START.value
|
|
105
|
+
_ACTION_EXPORT = PaletteAction.EXPORT.value
|
|
106
|
+
_FIELD_EXPORT_DESTINATION = "export-destination"
|
|
107
|
+
_FIELD_EXPORT_FORMAT = "export-format"
|
|
108
|
+
_FIELD_EXPORT_OVERWRITE = "export-overwrite"
|
|
109
|
+
_FIELD_EXPORT_SIGNALS = "export-signals"
|
|
110
|
+
_EXPORT_REFUSE = "refuse"
|
|
111
|
+
_EXPORT_OVERWRITE = "overwrite"
|
|
112
|
+
_EXPORT_SIGNALS_EXCLUDE = "exclude"
|
|
113
|
+
_EXPORT_SIGNALS_INCLUDE = "include"
|
|
114
|
+
_EXPORT_OVERWRITE_REQUIRED = "choose how to handle an existing destination"
|
|
115
|
+
_EXPORT_NOT_CONFIGURED = "task evidence export is not configured"
|
|
116
|
+
_TASK_ACTIONS = frozenset(
|
|
117
|
+
{
|
|
118
|
+
_ACTION_PRIORITIZE,
|
|
119
|
+
_ACTION_PAUSE,
|
|
120
|
+
_ACTION_PAUSE_NOW,
|
|
121
|
+
_ACTION_RESUME,
|
|
122
|
+
_ACTION_TAKE_OVER,
|
|
123
|
+
_ACTION_HAND_BACK,
|
|
124
|
+
_ACTION_CANCEL,
|
|
125
|
+
_ACTION_RETRY,
|
|
126
|
+
_ACTION_RUNG,
|
|
127
|
+
_ACTION_MESSAGE,
|
|
128
|
+
}
|
|
129
|
+
)
|
|
130
|
+
_TASK_TARGET_ACTIONS = _TASK_ACTIONS | {_ACTION_PEEK, _ACTION_EXPORT}
|
|
131
|
+
SUBMIT_ACTION_LABELS = {
|
|
132
|
+
_ACTION_SUBMIT: "Submit task",
|
|
133
|
+
_ACTION_PRIORITIZE: "Priority",
|
|
134
|
+
_ACTION_PAUSE: "Pause",
|
|
135
|
+
_ACTION_PAUSE_NOW: "Pause now",
|
|
136
|
+
_ACTION_RESUME: "Resume",
|
|
137
|
+
_ACTION_TAKE_OVER: "Take over",
|
|
138
|
+
_ACTION_HAND_BACK: "Hand back",
|
|
139
|
+
_ACTION_CANCEL: "Cancel",
|
|
140
|
+
_ACTION_RETRY: "Retry",
|
|
141
|
+
_ACTION_RUNG: "Rung",
|
|
142
|
+
_ACTION_MESSAGE: "Message",
|
|
143
|
+
_ACTION_PEEK: "Open session",
|
|
144
|
+
_ACTION_EXPORT: "Export evidence",
|
|
145
|
+
_ACTION_STOP: "Stop engine",
|
|
146
|
+
_ACTION_START: "Start engine",
|
|
147
|
+
}
|
|
148
|
+
_ENTER_HINT_VERBS = {
|
|
149
|
+
_ACTION_EXPORT: "export evidence",
|
|
150
|
+
_ACTION_PAUSE: "pause",
|
|
151
|
+
_ACTION_RESUME: "resume",
|
|
152
|
+
_ACTION_CANCEL: "cancel",
|
|
153
|
+
_ACTION_TAKE_OVER: "take over",
|
|
154
|
+
_ACTION_HAND_BACK: "hand back",
|
|
155
|
+
_ACTION_RETRY: "retry",
|
|
156
|
+
_ACTION_PAUSE_NOW: "pause now",
|
|
157
|
+
_ACTION_PRIORITIZE: "set priority",
|
|
158
|
+
_ACTION_RUNG: "set rung",
|
|
159
|
+
_ACTION_PEEK: "open session",
|
|
160
|
+
_ACTION_STOP: "stop engine",
|
|
161
|
+
_ACTION_START: "start engine",
|
|
162
|
+
}
|
|
163
|
+
_ENTER_HINT_ACTIONS = frozenset(_ENTER_HINT_VERBS)
|
|
164
|
+
_HINT_LABELS = {
|
|
165
|
+
_ACTION_SUBMIT: ("Ctrl+S submit task \N{MIDDLE DOT} Enter new line", "Ctrl+S submit"),
|
|
166
|
+
_ACTION_MESSAGE: ("Ctrl+S queue message \N{MIDDLE DOT} Enter new line", "Ctrl+S queue"),
|
|
167
|
+
**{action: (f"Enter {verb}", f"Enter {verb}") for action, verb in _ENTER_HINT_VERBS.items()},
|
|
168
|
+
}
|
|
169
|
+
_HINT_CLASS = "hint"
|
|
170
|
+
_CONFIRM_HINTS = {
|
|
171
|
+
_ACTION_CANCEL: ("Enter again to cancel", "Enter again"),
|
|
172
|
+
_ACTION_RETRY: ("Enter again to retry", "Enter again"),
|
|
173
|
+
_ACTION_STOP: ("Enter again to stop engine", "Enter again"),
|
|
174
|
+
}
|
|
175
|
+
_HINT_KEY_ACTIONS = {
|
|
176
|
+
"confirm": _ENTER_HINT_ACTIONS,
|
|
177
|
+
"queue": frozenset({_ACTION_MESSAGE, _ACTION_SUBMIT}),
|
|
178
|
+
}
|
|
179
|
+
_CONFIRM_TWICE_ACTIONS = frozenset(_CONFIRM_HINTS)
|
|
180
|
+
_CLEAR_AFTER_QUEUE_ACTIONS = frozenset({_ACTION_MESSAGE})
|
|
181
|
+
_CONFIRMED_ACTIONS = _ENTER_HINT_ACTIONS | _CLEAR_AFTER_QUEUE_ACTIONS | {_ACTION_SUBMIT}
|
|
182
|
+
_CONFIRMATION_GLYPH = "\N{CHECK MARK}"
|
|
183
|
+
_ENTER_KEY = "enter"
|
|
184
|
+
_TASK_ROLES = TASK_ACTION_ROLES
|
|
185
|
+
_ROLE_OPTIONS = tuple((role.value.title(), role.value) for role in _TASK_ROLES)
|
|
186
|
+
_EMPTY = ""
|
|
187
|
+
_MODE_OPTIONS = (
|
|
188
|
+
("Auto detect", _EMPTY),
|
|
189
|
+
("Implement", Mode.IMPLEMENT.value),
|
|
190
|
+
("Plan", Mode.PLAN.value),
|
|
191
|
+
)
|
|
192
|
+
_COMPLEXITY_OPTIONS = (
|
|
193
|
+
("Triage decides", _EMPTY),
|
|
194
|
+
("Low", "low"),
|
|
195
|
+
("Normal", "normal"),
|
|
196
|
+
("High", "high"),
|
|
197
|
+
)
|
|
198
|
+
_FIELD_ROLE = "role"
|
|
199
|
+
_FIELD_PRIORITY = "priority"
|
|
200
|
+
_FIELD_REQUEST = "request"
|
|
201
|
+
_FIELD_TITLE = "title"
|
|
202
|
+
_FIELD_MODE = "mode"
|
|
203
|
+
_FIELD_COMPLEXITY = "complexity"
|
|
204
|
+
_FIELD_SUBMISSION_PRIORITY = "submission-priority"
|
|
205
|
+
_FIELD_DEPENDENCIES = "dependencies"
|
|
206
|
+
_FIELD_LEADER_RUNG = "leader-rung"
|
|
207
|
+
_FIELD_IMPLEMENTER_RUNG = "implementer-rung"
|
|
208
|
+
_FIELD_RUNG = "rung"
|
|
209
|
+
_FIELD_MESSAGE = "message"
|
|
210
|
+
_ROLE_FIRST_ACTIONS = frozenset({_ACTION_MESSAGE, _ACTION_TAKE_OVER, _ACTION_HAND_BACK})
|
|
211
|
+
_INITIAL_FIELD_BY_ACTION = {
|
|
212
|
+
_ACTION_SUBMIT: _FIELD_REQUEST,
|
|
213
|
+
_ACTION_PRIORITIZE: _FIELD_PRIORITY,
|
|
214
|
+
_ACTION_RUNG: _FIELD_RUNG,
|
|
215
|
+
_ACTION_MESSAGE: _FIELD_ROLE,
|
|
216
|
+
_ACTION_TAKE_OVER: _FIELD_ROLE,
|
|
217
|
+
_ACTION_HAND_BACK: _FIELD_ROLE,
|
|
218
|
+
_ACTION_PEEK: _FIELD_ROLE,
|
|
219
|
+
_ACTION_EXPORT: _FIELD_EXPORT_DESTINATION,
|
|
220
|
+
}
|
|
221
|
+
_EXPORT_FORMAT_OPTIONS = (
|
|
222
|
+
("Markdown", EXPORT_FORMAT_MARKDOWN),
|
|
223
|
+
("JSON", EXPORT_FORMAT_JSON),
|
|
224
|
+
)
|
|
225
|
+
_EXPORT_OVERWRITE_OPTIONS = (
|
|
226
|
+
("Refuse", _EXPORT_REFUSE),
|
|
227
|
+
("Replace", _EXPORT_OVERWRITE),
|
|
228
|
+
)
|
|
229
|
+
_EXPORT_SIGNALS_OPTIONS = (
|
|
230
|
+
("Exclude", _EXPORT_SIGNALS_EXCLUDE),
|
|
231
|
+
("Include", _EXPORT_SIGNALS_INCLUDE),
|
|
232
|
+
)
|
|
233
|
+
_ACTION_STATE_ID = "palette-action-state"
|
|
234
|
+
_GUIDANCE_ID = "palette-guidance"
|
|
235
|
+
_SUBMIT_ID = "palette-submit"
|
|
236
|
+
_OPTIONS_FOLD_ID = "palette-options-fold"
|
|
237
|
+
_OPTIONS_ID = "palette-options"
|
|
238
|
+
_OPTIONS_TITLE = "Options"
|
|
239
|
+
_PALETTE_FORM_ID = "palette-form"
|
|
240
|
+
_PALETTE_CLOSE = "palette-close"
|
|
241
|
+
_PALETTE_ESCAPE_KEY = "escape"
|
|
242
|
+
_PALETTE_SHORT_PANE_MAX_ROWS = 18
|
|
243
|
+
_PALETTE_SHORT_PANE_MAX_COLUMNS = 40
|
|
244
|
+
_PALETTE_WIDE_FIELD_LABEL_COLUMNS = 17
|
|
245
|
+
_PALETTE_CONTROL_MIN_COLUMNS = 8
|
|
246
|
+
_PALETTE_FRAME_BORDER_COLUMNS = 1
|
|
247
|
+
_PALETTE_SHORT_FRAME_PADDING_COLUMNS = 1
|
|
248
|
+
_PALETTE_INLINE_FRAME_HORIZONTAL_COLUMNS = 2 * (
|
|
249
|
+
_PALETTE_FRAME_BORDER_COLUMNS + _PALETTE_SHORT_FRAME_PADDING_COLUMNS
|
|
250
|
+
)
|
|
251
|
+
_PALETTE_STACKED_FIELDS_MAX_COLUMNS = (
|
|
252
|
+
_PALETTE_WIDE_FIELD_LABEL_COLUMNS
|
|
253
|
+
+ _PALETTE_CONTROL_MIN_COLUMNS
|
|
254
|
+
+ _PALETTE_INLINE_FRAME_HORIZONTAL_COLUMNS
|
|
255
|
+
- 1
|
|
256
|
+
)
|
|
257
|
+
_PALETTE_TINY_PANE_MAX_ROWS = 8
|
|
258
|
+
_PALETTE_ACTION_BUTTON_MIN_COLUMNS = 16
|
|
259
|
+
_PALETTE_COMPACT_TEXT_AREA_MAX_HEIGHT = 3
|
|
260
|
+
_PALETTE_COMPACT_RESULT_MAX_HEIGHT = 3
|
|
261
|
+
_PALETTE_RESULT_MAX_HEIGHT = 12
|
|
262
|
+
_PALETTE_FORM_STANDARD_FIXED_ROWS = 9
|
|
263
|
+
_PALETTE_FORM_COMPACT_FIXED_ROWS = 4
|
|
264
|
+
_PALETTE_FRAME_VERTICAL_MARGIN_ROWS = 2
|
|
265
|
+
_PALETTE_TEXT_AREA_MINIMUM_WIDTH = 5
|
|
266
|
+
_PALETTE_INLINE_ACTIONS_ROWS = 1
|
|
267
|
+
_PALETTE_STACKED_ACTIONS_ROWS = 2
|
|
268
|
+
_EXPORT_DEFAULT_EXTENSION = "md"
|
|
269
|
+
_EXPORT_DESTINATION_PLACEHOLDER = f"T-1-evidence.{_EXPORT_DEFAULT_EXTENSION}"
|
|
270
|
+
_PALETTE_TEXT_AREA_FIELDS = (_FIELD_REQUEST, _FIELD_MESSAGE)
|
|
271
|
+
_TAKEOVER_SELECTION_FAILURE = "take-over succeeded, but the role pane could not be selected"
|
|
272
|
+
_PALETTE_STATE_WORKER = "palette-state-updates"
|
|
273
|
+
_FORM_IDENTITY_FIELDS = {
|
|
274
|
+
_ACTION_SUBMIT: (
|
|
275
|
+
_FIELD_REQUEST,
|
|
276
|
+
_FIELD_TITLE,
|
|
277
|
+
_FIELD_MODE,
|
|
278
|
+
_FIELD_COMPLEXITY,
|
|
279
|
+
_FIELD_SUBMISSION_PRIORITY,
|
|
280
|
+
_FIELD_DEPENDENCIES,
|
|
281
|
+
_FIELD_LEADER_RUNG,
|
|
282
|
+
_FIELD_IMPLEMENTER_RUNG,
|
|
283
|
+
),
|
|
284
|
+
_ACTION_PRIORITIZE: (_FIELD_PRIORITY,),
|
|
285
|
+
_ACTION_RUNG: (_FIELD_RUNG,),
|
|
286
|
+
_ACTION_MESSAGE: (_FIELD_MESSAGE,),
|
|
287
|
+
_ACTION_EXPORT: (
|
|
288
|
+
_FIELD_EXPORT_DESTINATION,
|
|
289
|
+
_FIELD_EXPORT_FORMAT,
|
|
290
|
+
_FIELD_EXPORT_OVERWRITE,
|
|
291
|
+
_FIELD_EXPORT_SIGNALS,
|
|
292
|
+
),
|
|
293
|
+
}
|
|
294
|
+
_FORM_ROLE_ACTIONS = frozenset(
|
|
295
|
+
{_ACTION_TAKE_OVER, _ACTION_HAND_BACK, _ACTION_RUNG, _ACTION_MESSAGE, _ACTION_PEEK}
|
|
296
|
+
)
|
|
297
|
+
_REQUIRED_FIELDS = {
|
|
298
|
+
_ACTION_SUBMIT: _FIELD_REQUEST,
|
|
299
|
+
_ACTION_EXPORT: _FIELD_EXPORT_DESTINATION,
|
|
300
|
+
_ACTION_MESSAGE: _FIELD_MESSAGE,
|
|
301
|
+
_ACTION_PRIORITIZE: _FIELD_PRIORITY,
|
|
302
|
+
_ACTION_RUNG: _FIELD_RUNG,
|
|
303
|
+
}
|
|
304
|
+
_ACTION_GUIDANCE = {
|
|
305
|
+
_ACTION_PRIORITIZE: "Changes dispatch order while the task is queued.",
|
|
306
|
+
_ACTION_PAUSE: "Holds the task before its next turn or integration effect.",
|
|
307
|
+
_ACTION_PAUSE_NOW: "Stops the current turn and cancels its step, then holds the task.",
|
|
308
|
+
_ACTION_RESUME: (
|
|
309
|
+
"Clears task holds; dependencies, slots and other scheduling limits still apply."
|
|
310
|
+
),
|
|
311
|
+
_ACTION_TAKE_OVER: (
|
|
312
|
+
"Stops the running turn and holds this task while you work in the role's pane. "
|
|
313
|
+
"Other tasks continue."
|
|
314
|
+
),
|
|
315
|
+
_ACTION_HAND_BACK: (
|
|
316
|
+
"Returns this role to automation; another taken-over role or pause can still hold the task."
|
|
317
|
+
),
|
|
318
|
+
_ACTION_CANCEL: (
|
|
319
|
+
"Cancels only this task and keeps its branch. Dirty or off-branch worktrees are preserved "
|
|
320
|
+
"with their path returned; a clean worktree is removed during cleanup."
|
|
321
|
+
),
|
|
322
|
+
_ACTION_RETRY: "Cancels the current step and runs it again, closing any open decision.",
|
|
323
|
+
_ACTION_RUNG: "The rung is the agent:model:effort level a role uses from its next turn.",
|
|
324
|
+
_ACTION_MESSAGE: (
|
|
325
|
+
"Queues guidance for this role's next turn; it does not interrupt a running turn."
|
|
326
|
+
),
|
|
327
|
+
_ACTION_PEEK: "Shows recorded session activity for this task and role.",
|
|
328
|
+
_ACTION_STOP: (
|
|
329
|
+
"Stops the engine for every task. Running turns are stopped and continue when the "
|
|
330
|
+
"engine starts again."
|
|
331
|
+
),
|
|
332
|
+
_ACTION_START: "Starts the engine and ensures its session without attaching.",
|
|
333
|
+
_ACTION_EXPORT: (
|
|
334
|
+
"Writes retained evidence to a local file; relative paths start at the project root, and "
|
|
335
|
+
"parent folders must exist."
|
|
336
|
+
),
|
|
337
|
+
}
|
|
338
|
+
_TASK_ACTION_BY_ID = {action.value: action for action in OperatorTaskAction}
|
|
339
|
+
_FIELD_CLASS = "palette-field"
|
|
340
|
+
_ROW_CLASS = "palette-row"
|
|
341
|
+
_WIDE_CLASS = "palette-wide"
|
|
342
|
+
_NO_TASK_TARGET = "Choose a task target first"
|
|
343
|
+
|
|
344
|
+
PaneSelector = Callable[[str], Awaitable[None]]
|
|
345
|
+
StartEngine = Callable[[], Awaitable[StartResult]]
|
|
346
|
+
_START_NOT_CONFIGURED = "project start is not configured"
|
|
347
|
+
_ENGINE_ALREADY_RUNNING = "engine is already running"
|
|
348
|
+
_ENGINE_ALREADY_STOPPED = "engine is already stopped"
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _field_classes(*, inline: bool, wide: bool = False) -> str:
|
|
352
|
+
classes = f"{_FIELD_CLASS} {_ROW_CLASS}" if inline else _FIELD_CLASS
|
|
353
|
+
return f"{classes} {_WIDE_CLASS}" if wide else classes
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _default_export_destination(task_id: str) -> str:
|
|
357
|
+
return f"{task_id}-evidence.{_EXPORT_DEFAULT_EXTENSION}"
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
class PaletteScreen(ModalScreen[str | None]):
|
|
361
|
+
"""Show one preset action popup; it dismisses with the action name once it queued it."""
|
|
362
|
+
|
|
363
|
+
AUTO_FOCUS = ""
|
|
364
|
+
BINDINGS: ClassVar[list[Binding]] = [
|
|
365
|
+
Binding(_ENTER_KEY, "confirm", "Confirm", show=False),
|
|
366
|
+
Binding("ctrl+s", "queue", "Queue", show=False),
|
|
367
|
+
]
|
|
368
|
+
DEFAULT_CSS = (
|
|
369
|
+
POPUP_CSS
|
|
370
|
+
+ """
|
|
371
|
+
#palette-frame.short { margin: 0 1; padding: 0 1; }
|
|
372
|
+
#palette-frame.stack-fields { padding: 1 0; }
|
|
373
|
+
#palette-frame.tiny { margin: 0; padding: 0; }
|
|
374
|
+
#palette-frame.tiny #palette-close { padding: 0; }
|
|
375
|
+
#palette-frame.stack-fields .palette-row { layout: vertical; }
|
|
376
|
+
#palette-frame.stack-fields .palette-row Label { width: 100%; }
|
|
377
|
+
#palette-frame.stack-fields .palette-row SelectCurrent { padding: 0; }
|
|
378
|
+
#palette-close {
|
|
379
|
+
width: auto; min-width: 5; height: 1; margin: 0;
|
|
380
|
+
}
|
|
381
|
+
#palette-form {
|
|
382
|
+
height: auto; max-height: 1fr; min-height: 0; margin-top: 0; padding-top: 0;
|
|
383
|
+
border-top: none; background: $surface;
|
|
384
|
+
}
|
|
385
|
+
#field-export-destination { margin-top: 1; }
|
|
386
|
+
#field-role, #field-priority { margin-top: 1; }
|
|
387
|
+
.palette-field { height: auto; margin-bottom: 1; background: $surface; }
|
|
388
|
+
.palette-field Label { height: 1; color: $secondary-text; text-style: none; }
|
|
389
|
+
.palette-field Input, .palette-field Select { width: 100%; }
|
|
390
|
+
.palette-field Input:focus {
|
|
391
|
+
border: round $focus;
|
|
392
|
+
text-style: reverse;
|
|
393
|
+
}
|
|
394
|
+
"""
|
|
395
|
+
+ POPUP_COMPACT_CONTROL_CSS
|
|
396
|
+
+ """
|
|
397
|
+
#palette-options-fold { height: auto; margin: 0 0 1 0; color: $secondary-text; }
|
|
398
|
+
#palette-options-fold:focus { background: $selected-surface; background-tint: transparent; }
|
|
399
|
+
#palette-options { height: auto; margin-top: 1; }
|
|
400
|
+
.palette-note { height: auto; color: $secondary-text; }
|
|
401
|
+
#palette-result-scroll { display: none; height: auto; max-height: 12; margin-top: 1; }
|
|
402
|
+
#palette-frame.short #palette-result-scroll { margin-top: 0; }
|
|
403
|
+
#palette-result { height: auto; }
|
|
404
|
+
#palette-submit {
|
|
405
|
+
height: 1; min-height: 1; margin: 0 0 0 1; padding: 0 1; border: none;
|
|
406
|
+
text-style: none;
|
|
407
|
+
}
|
|
408
|
+
#palette-actions {
|
|
409
|
+
layout: horizontal; width: 100%; height: auto; margin-top: 1; align-horizontal: right;
|
|
410
|
+
}
|
|
411
|
+
#palette-actions.short { margin-top: 0; }
|
|
412
|
+
#palette-actions.hint { align-horizontal: left; }
|
|
413
|
+
#palette-actions.hint #palette-submit {
|
|
414
|
+
width: 100%; min-width: 1; height: auto; margin: 0; padding: 0;
|
|
415
|
+
border: none; border-top: none; border-bottom: none;
|
|
416
|
+
background: $surface; color: $secondary-text; text-opacity: 1; text-wrap: wrap;
|
|
417
|
+
content-align: left middle; text-align: left;
|
|
418
|
+
}
|
|
419
|
+
#palette-actions.hint #palette-submit:disabled { color: $secondary-text; text-opacity: 1; }
|
|
420
|
+
#palette-actions Button {
|
|
421
|
+
width: auto; max-width: 100%; height: auto; min-height: 1; padding: 0;
|
|
422
|
+
border: none; border-top: none; border-bottom: none;
|
|
423
|
+
text-wrap: wrap; text-style: none;
|
|
424
|
+
}
|
|
425
|
+
#palette-submit:focus { text-style: reverse; }
|
|
426
|
+
"""
|
|
427
|
+
+ f"""
|
|
428
|
+
.palette-wide Label {{ width: {_PALETTE_WIDE_FIELD_LABEL_COLUMNS}; }}
|
|
429
|
+
.palette-row Input, .palette-row Select {{ min-width: {_PALETTE_CONTROL_MIN_COLUMNS}; }}
|
|
430
|
+
"""
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
def __init__(
|
|
434
|
+
self,
|
|
435
|
+
commands: OperatorCommands,
|
|
436
|
+
queries: OperatorQueries,
|
|
437
|
+
pane_selector: PaneSelector,
|
|
438
|
+
*,
|
|
439
|
+
on_change: OnChangeRegistration,
|
|
440
|
+
action_confirmation_seconds: float,
|
|
441
|
+
initial_context: PaletteRequest,
|
|
442
|
+
start_engine: StartEngine | None = None,
|
|
443
|
+
evidence_exporter: TaskEvidenceExporter | None = None,
|
|
444
|
+
remember_contextual_draft: Callable[[PaletteDraft], None] | None = None,
|
|
445
|
+
) -> None:
|
|
446
|
+
"""Use the operator services for actions and queries and inject pane selection."""
|
|
447
|
+
super().__init__()
|
|
448
|
+
prepare_popup(self)
|
|
449
|
+
self._commands = commands
|
|
450
|
+
self._queries = queries
|
|
451
|
+
self._initial_context = initial_context
|
|
452
|
+
self._action = initial_context.action or _EMPTY
|
|
453
|
+
self._task_id = initial_context.task_id or _EMPTY
|
|
454
|
+
self._pane_selector = pane_selector
|
|
455
|
+
self._on_change = on_change
|
|
456
|
+
self._start_engine = start_engine
|
|
457
|
+
self._evidence_exporter = evidence_exporter
|
|
458
|
+
self._remember_contextual_draft = remember_contextual_draft
|
|
459
|
+
self._action_confirmation_seconds = action_confirmation_seconds
|
|
460
|
+
self._confirmed_action: str | None = None
|
|
461
|
+
self._confirmation_timer: Timer | None = None
|
|
462
|
+
self._role_is_explicit = initial_context.role is not None
|
|
463
|
+
self._action_states: dict[str, TaskActionAvailability] = {}
|
|
464
|
+
self._route_error: str | None = None
|
|
465
|
+
self._options_expanded = False
|
|
466
|
+
self._action_in_flight = False
|
|
467
|
+
self._confirm_armed = False
|
|
468
|
+
self._closing_after_queue = False
|
|
469
|
+
self._result_reveal_revision = 0
|
|
470
|
+
self._pending_form_commands: dict[tuple[str, ...], int] = {}
|
|
471
|
+
self._submitting_form_identity: tuple[str, ...] | None = None
|
|
472
|
+
self._command_action = _EMPTY
|
|
473
|
+
self._command_target = _EMPTY
|
|
474
|
+
self._action_worker: Worker[None] | None = None
|
|
475
|
+
self._change_worker: Worker[None] | None = None
|
|
476
|
+
self._form_scroll_y = 0
|
|
477
|
+
self._form_focus_id: str | None = None
|
|
478
|
+
self._layout_form_max_height = 0
|
|
479
|
+
self._restored_contextual_draft_actions: set[str] = set()
|
|
480
|
+
|
|
481
|
+
def compose(self) -> ComposeResult:
|
|
482
|
+
"""Show one compact form with fields enabled for the selected action."""
|
|
483
|
+
with PopupFrame(widget_id="palette-frame", classes=POPUP_FRAME_CLASS):
|
|
484
|
+
with Horizontal(id="palette-heading", classes=POPUP_HEADER_CLASS):
|
|
485
|
+
yield Label("Actions", id="palette-title", classes=POPUP_TITLE_CLASS)
|
|
486
|
+
yield Button(
|
|
487
|
+
popup_close_label(),
|
|
488
|
+
id=_PALETTE_CLOSE,
|
|
489
|
+
classes=POPUP_CLOSE_CLASS,
|
|
490
|
+
)
|
|
491
|
+
yield Rule(classes=POPUP_RULE_CLASS)
|
|
492
|
+
yield from self._compose_form()
|
|
493
|
+
with Vertical(id="palette-actions"):
|
|
494
|
+
yield Button("Submit", id=_SUBMIT_ID, variant="primary")
|
|
495
|
+
|
|
496
|
+
def _compose_form(self) -> ComposeResult:
|
|
497
|
+
with VerticalScroll(id=_PALETTE_FORM_ID, classes=POPUP_BODY_CLASS):
|
|
498
|
+
yield Static("", id=_GUIDANCE_ID, markup=False)
|
|
499
|
+
yield from self._compose_task_submission_fields()
|
|
500
|
+
yield from self._compose_task_role_fields()
|
|
501
|
+
yield Static("", id=_ACTION_STATE_ID, markup=False)
|
|
502
|
+
with VerticalScroll(id="palette-result-scroll", classes=POPUP_BODY_CLASS):
|
|
503
|
+
yield Static("", id="palette-result", markup=False)
|
|
504
|
+
|
|
505
|
+
def _compose_task_submission_fields(self) -> ComposeResult:
|
|
506
|
+
yield from self._text_area_field("Request", "request", "Describe the work")
|
|
507
|
+
yield FoldLine("", id=_OPTIONS_FOLD_ID, markup=False)
|
|
508
|
+
with Vertical(id=_OPTIONS_ID):
|
|
509
|
+
yield from self._input_field(
|
|
510
|
+
"Title (optional)", _FIELD_TITLE, "Task title", inline=True, wide=True
|
|
511
|
+
)
|
|
512
|
+
yield from self._select_field(
|
|
513
|
+
"Mode", _FIELD_MODE, _MODE_OPTIONS, value=_EMPTY, inline=True, wide=True
|
|
514
|
+
)
|
|
515
|
+
yield from self._select_field(
|
|
516
|
+
"Complexity",
|
|
517
|
+
_FIELD_COMPLEXITY,
|
|
518
|
+
_COMPLEXITY_OPTIONS,
|
|
519
|
+
value=_EMPTY,
|
|
520
|
+
inline=True,
|
|
521
|
+
wide=True,
|
|
522
|
+
)
|
|
523
|
+
yield from self._input_field(
|
|
524
|
+
"Priority", _FIELD_SUBMISSION_PRIORITY, "0", inline=True, wide=True
|
|
525
|
+
)
|
|
526
|
+
yield from self._input_field(
|
|
527
|
+
"Dependencies", _FIELD_DEPENDENCIES, "T-1, T-2", inline=True, wide=True
|
|
528
|
+
)
|
|
529
|
+
yield from self._input_field(
|
|
530
|
+
"Leader rung", _FIELD_LEADER_RUNG, "agent:model:effort", inline=True, wide=True
|
|
531
|
+
)
|
|
532
|
+
yield from self._input_field(
|
|
533
|
+
"Implementer rung",
|
|
534
|
+
_FIELD_IMPLEMENTER_RUNG,
|
|
535
|
+
"agent:model:effort",
|
|
536
|
+
inline=True,
|
|
537
|
+
wide=True,
|
|
538
|
+
)
|
|
539
|
+
yield from self._input_field(
|
|
540
|
+
"Destination",
|
|
541
|
+
_FIELD_EXPORT_DESTINATION,
|
|
542
|
+
_EXPORT_DESTINATION_PLACEHOLDER,
|
|
543
|
+
inline=True,
|
|
544
|
+
wide=True,
|
|
545
|
+
)
|
|
546
|
+
yield from self._select_field(
|
|
547
|
+
"Format",
|
|
548
|
+
_FIELD_EXPORT_FORMAT,
|
|
549
|
+
_EXPORT_FORMAT_OPTIONS,
|
|
550
|
+
value=EXPORT_FORMAT_MARKDOWN,
|
|
551
|
+
inline=True,
|
|
552
|
+
wide=True,
|
|
553
|
+
)
|
|
554
|
+
yield from self._select_field(
|
|
555
|
+
"Existing file",
|
|
556
|
+
_FIELD_EXPORT_OVERWRITE,
|
|
557
|
+
_EXPORT_OVERWRITE_OPTIONS,
|
|
558
|
+
value=_EXPORT_REFUSE,
|
|
559
|
+
inline=True,
|
|
560
|
+
wide=True,
|
|
561
|
+
)
|
|
562
|
+
yield from self._select_field(
|
|
563
|
+
"Agent signals",
|
|
564
|
+
_FIELD_EXPORT_SIGNALS,
|
|
565
|
+
_EXPORT_SIGNALS_OPTIONS,
|
|
566
|
+
value=_EXPORT_SIGNALS_EXCLUDE,
|
|
567
|
+
inline=True,
|
|
568
|
+
wide=True,
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
def _compose_task_role_fields(self) -> ComposeResult:
|
|
572
|
+
yield from self._input_field("Priority", _FIELD_PRIORITY, "0", inline=True)
|
|
573
|
+
yield from self._select_field(
|
|
574
|
+
"Role", _FIELD_ROLE, _ROLE_OPTIONS, value=Role.LEADER.value, inline=True
|
|
575
|
+
)
|
|
576
|
+
yield from self._input_field("Rung", _FIELD_RUNG, "agent:model:effort", inline=True)
|
|
577
|
+
yield from self._text_area_field("Message", "message", "Message for the next turn")
|
|
578
|
+
|
|
579
|
+
def on_descendant_focus(self, event: events.DescendantFocus) -> None:
|
|
580
|
+
parent = event.widget.parent
|
|
581
|
+
while parent is not None:
|
|
582
|
+
if parent.id == _PALETTE_FORM_ID:
|
|
583
|
+
self.query_one("#palette-form", VerticalScroll).scroll_to_widget(
|
|
584
|
+
event.widget,
|
|
585
|
+
animate=False,
|
|
586
|
+
immediate=True,
|
|
587
|
+
force=True,
|
|
588
|
+
)
|
|
589
|
+
return
|
|
590
|
+
parent = parent.parent
|
|
591
|
+
|
|
592
|
+
def on_resize(self, event: events.Resize) -> None:
|
|
593
|
+
if not self.is_mounted or not widget_is_attached(self):
|
|
594
|
+
return
|
|
595
|
+
form = self.query_one("#palette-form", VerticalScroll)
|
|
596
|
+
scroll_y = form.scroll_y
|
|
597
|
+
focused = cast("App[None]", self.app).focused
|
|
598
|
+
result_revision = self._result_reveal_revision
|
|
599
|
+
self._set_text_area_heights(event.size.width, event.size.height)
|
|
600
|
+
self._apply_layout()
|
|
601
|
+
self.call_after_refresh(
|
|
602
|
+
self._restore_form_layout,
|
|
603
|
+
scroll_y,
|
|
604
|
+
focused,
|
|
605
|
+
result_revision,
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
async def on_mount(self) -> None:
|
|
609
|
+
"""Activate the action routed from a widget and follow database changes."""
|
|
610
|
+
self._set_text_area_heights(self.size.width, self.size.height)
|
|
611
|
+
self._initialize_action_route()
|
|
612
|
+
self._initialize_form_state()
|
|
613
|
+
self._refresh_action_availability()
|
|
614
|
+
self._apply_layout()
|
|
615
|
+
self.call_after_refresh(self._restore_context_form_position)
|
|
616
|
+
self._change_worker = self.run_worker(
|
|
617
|
+
self._on_change(self._refresh_after_database_change),
|
|
618
|
+
group=_PALETTE_STATE_WORKER,
|
|
619
|
+
)
|
|
620
|
+
if self._route_error is not None:
|
|
621
|
+
self._result_reveal_revision += 1
|
|
622
|
+
self._paint_result(self._route_error)
|
|
623
|
+
self.call_after_refresh(self._reveal_result)
|
|
624
|
+
self.query_one(f"#{_SUBMIT_ID}", Button).disabled = True
|
|
625
|
+
|
|
626
|
+
def _initialize_action_route(self) -> None:
|
|
627
|
+
if self._action not in SUBMIT_ACTION_LABELS:
|
|
628
|
+
action = self._action or "<missing>"
|
|
629
|
+
self._route_error = f"unknown palette action route: {action}"
|
|
630
|
+
|
|
631
|
+
def _initialize_form_state(self) -> None:
|
|
632
|
+
cast("Select[str]", self.query_one("#mode", Select)).value = _EMPTY
|
|
633
|
+
cast("Select[str]", self.query_one("#complexity", Select)).value = _EMPTY
|
|
634
|
+
task_id = self._task_id or None
|
|
635
|
+
default_role = (
|
|
636
|
+
self._queries.task_action_role(task_id).value
|
|
637
|
+
if task_id is not None
|
|
638
|
+
else Role.LEADER.value
|
|
639
|
+
)
|
|
640
|
+
role = self._initial_context.role or default_role
|
|
641
|
+
if role not in {item.value for item in _TASK_ROLES}:
|
|
642
|
+
if self._initial_context.role is not None:
|
|
643
|
+
self._route_error = f"unknown role context: {role}"
|
|
644
|
+
role = Role.LEADER.value
|
|
645
|
+
cast("Select[str]", self.query_one(f"#{_FIELD_ROLE}", Select)).value = role
|
|
646
|
+
self.query_one("#priority", Input).value = "0"
|
|
647
|
+
self.query_one("#submission-priority", Input).value = "0"
|
|
648
|
+
self._initialize_export_defaults(task_id)
|
|
649
|
+
self._update_fields()
|
|
650
|
+
self._restore_contextual_form_draft(self._action)
|
|
651
|
+
|
|
652
|
+
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
|
|
653
|
+
del parameters
|
|
654
|
+
hint_actions = _HINT_KEY_ACTIONS.get(action)
|
|
655
|
+
if hint_actions is None:
|
|
656
|
+
return True
|
|
657
|
+
return (
|
|
658
|
+
self._action in hint_actions
|
|
659
|
+
and self._hint_mode()
|
|
660
|
+
and not self.query_one(f"#{_SUBMIT_ID}", Button).disabled
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
def action_confirm(self) -> None:
|
|
664
|
+
"""Run the action named by the Enter hint."""
|
|
665
|
+
self._activate_submit()
|
|
666
|
+
|
|
667
|
+
def action_queue(self) -> None:
|
|
668
|
+
"""Queue the message named by the Ctrl+S hint."""
|
|
669
|
+
self._activate_submit()
|
|
670
|
+
|
|
671
|
+
def _hint_mode(self) -> bool:
|
|
672
|
+
return self._action in _HINT_LABELS
|
|
673
|
+
|
|
674
|
+
def _confirm_submitted(self, action: str) -> None:
|
|
675
|
+
self._paint_result(_EMPTY)
|
|
676
|
+
if action in _CLEAR_AFTER_QUEUE_ACTIONS:
|
|
677
|
+
self._clear_message()
|
|
678
|
+
self._show_confirmation(action)
|
|
679
|
+
return
|
|
680
|
+
self._closing_after_queue = True
|
|
681
|
+
self.call_later(self._dismiss_queued, action)
|
|
682
|
+
|
|
683
|
+
def _dismiss_queued(self, action: str) -> None:
|
|
684
|
+
self.dismiss(action)
|
|
685
|
+
|
|
686
|
+
def _clear_message(self) -> None:
|
|
687
|
+
message = self.query_one(f"#{_FIELD_MESSAGE}", TextArea)
|
|
688
|
+
message.text = _EMPTY
|
|
689
|
+
message.focus()
|
|
690
|
+
|
|
691
|
+
def _show_confirmation(self, action: str) -> None:
|
|
692
|
+
self._stop_confirmation()
|
|
693
|
+
self._confirmed_action = action
|
|
694
|
+
self._confirmation_timer = self.set_timer(
|
|
695
|
+
self._action_confirmation_seconds, self._end_confirmation
|
|
696
|
+
)
|
|
697
|
+
self._refresh_confirmation()
|
|
698
|
+
|
|
699
|
+
def _end_confirmation(self) -> None:
|
|
700
|
+
self._confirmed_action = None
|
|
701
|
+
self._confirmation_timer = None
|
|
702
|
+
self._refresh_confirmation()
|
|
703
|
+
|
|
704
|
+
def _stop_confirmation(self) -> None:
|
|
705
|
+
if self._confirmation_timer is not None:
|
|
706
|
+
self._confirmation_timer.stop()
|
|
707
|
+
self._confirmed_action = None
|
|
708
|
+
self._confirmation_timer = None
|
|
709
|
+
|
|
710
|
+
def _refresh_confirmation(self) -> None:
|
|
711
|
+
self._refresh_submit()
|
|
712
|
+
|
|
713
|
+
def _marked(self, label: Text, action: str) -> Text:
|
|
714
|
+
if self._confirmed_action != action:
|
|
715
|
+
return label
|
|
716
|
+
return Text.assemble(label, " ", (_CONFIRMATION_GLYPH, f"{STYLE_BOLD} {SUCCESS_COLOR}"))
|
|
717
|
+
|
|
718
|
+
def _activate_submit(self) -> None:
|
|
719
|
+
if self._action_in_flight or self._closing_after_queue:
|
|
720
|
+
return
|
|
721
|
+
confirming = self._action in _CONFIRM_TWICE_ACTIONS and self._hint_mode()
|
|
722
|
+
if confirming and not self._confirm_armed:
|
|
723
|
+
self._confirm_armed = True
|
|
724
|
+
self._refresh_submit()
|
|
725
|
+
return
|
|
726
|
+
self._confirm_armed = False
|
|
727
|
+
self._stop_confirmation()
|
|
728
|
+
self._action_in_flight = True
|
|
729
|
+
self.query_one(f"#{_SUBMIT_ID}", Button).disabled = True
|
|
730
|
+
self._refresh_submit()
|
|
731
|
+
self._action_worker = self.run_worker(self._run_action(), exclusive=True)
|
|
732
|
+
|
|
733
|
+
def _refresh_submit(self) -> None:
|
|
734
|
+
action = self._action
|
|
735
|
+
submit = self.query_one(f"#{_SUBMIT_ID}", Button)
|
|
736
|
+
hint = self._hint_mode()
|
|
737
|
+
tiny = self.size.height <= _PALETTE_TINY_PANE_MAX_ROWS
|
|
738
|
+
self.query_one("#palette-actions").set_class(hint, _HINT_CLASS)
|
|
739
|
+
self._size_action_buttons(hint=hint)
|
|
740
|
+
submit.can_focus = not hint or action in _ENTER_HINT_ACTIONS
|
|
741
|
+
if not hint:
|
|
742
|
+
compact_label = "Queue" if action == _ACTION_MESSAGE else "Submit"
|
|
743
|
+
label = Text(
|
|
744
|
+
compact_label if tiny else SUBMIT_ACTION_LABELS.get(action, "Submit action")
|
|
745
|
+
)
|
|
746
|
+
elif self._confirm_armed:
|
|
747
|
+
label = key_hint(_CONFIRM_HINTS[action][tiny], f"{STYLE_BOLD} {DUSTY_ROSE}")
|
|
748
|
+
else:
|
|
749
|
+
label = key_hint(
|
|
750
|
+
_HINT_LABELS[action][tiny],
|
|
751
|
+
SECONDARY_TEXT_COLOR,
|
|
752
|
+
enabled=not (submit.disabled or self._closing_after_queue),
|
|
753
|
+
)
|
|
754
|
+
marked = self._marked(label, action)
|
|
755
|
+
submit.set_reactive(Button.label, Content.from_text(marked))
|
|
756
|
+
submit.refresh(layout=True)
|
|
757
|
+
submit.styles.height = "auto"
|
|
758
|
+
|
|
759
|
+
def _size_action_buttons(self, *, hint: bool) -> None:
|
|
760
|
+
tiny = self.size.height <= _PALETTE_TINY_PANE_MAX_ROWS
|
|
761
|
+
narrow = self.size.width <= _PALETTE_SHORT_PANE_MAX_COLUMNS
|
|
762
|
+
button = self.query_one(f"#{_SUBMIT_ID}", Button)
|
|
763
|
+
button.styles.width = "100%" if hint or (narrow and not tiny) else "auto"
|
|
764
|
+
button.styles.min_width = 1 if tiny or hint else _PALETTE_ACTION_BUTTON_MIN_COLUMNS
|
|
765
|
+
button.styles.margin = 0 if narrow or hint else (0, 0, 0, 1)
|
|
766
|
+
button.styles.height = 1 if narrow and (tiny or not hint) else "auto"
|
|
767
|
+
button.styles.line_pad = 0 if hint else None
|
|
768
|
+
|
|
769
|
+
async def on_key(self, event: events.Key) -> None:
|
|
770
|
+
if event.key == _ENTER_KEY:
|
|
771
|
+
submit = self.query_one(f"#{_SUBMIT_ID}", Button)
|
|
772
|
+
if self._confirm_armed or (self._hint_mode() and self.focused is submit):
|
|
773
|
+
event.stop()
|
|
774
|
+
event.prevent_default()
|
|
775
|
+
self._activate_submit()
|
|
776
|
+
return
|
|
777
|
+
if self._confirm_armed and event.key != _ENTER_KEY:
|
|
778
|
+
self._confirm_armed = False
|
|
779
|
+
self._refresh_submit()
|
|
780
|
+
if event.key == _PALETTE_ESCAPE_KEY:
|
|
781
|
+
event.stop()
|
|
782
|
+
event.prevent_default()
|
|
783
|
+
return
|
|
784
|
+
if event.key != _PALETTE_ESCAPE_KEY:
|
|
785
|
+
return
|
|
786
|
+
if any(select.expanded for select in self.query(Select[str])):
|
|
787
|
+
return
|
|
788
|
+
await self.action_close()
|
|
789
|
+
event.stop()
|
|
790
|
+
event.prevent_default()
|
|
791
|
+
|
|
792
|
+
def _set_text_area_heights(self, width: int, height: int) -> None:
|
|
793
|
+
compact = height <= _PALETTE_SHORT_PANE_MAX_ROWS
|
|
794
|
+
compact_layout = compact or width <= _PALETTE_SHORT_PANE_MAX_COLUMNS
|
|
795
|
+
self.query_one("#palette-result-scroll").styles.max_height = (
|
|
796
|
+
_PALETTE_COMPACT_RESULT_MAX_HEIGHT if compact_layout else _PALETTE_RESULT_MAX_HEIGHT
|
|
797
|
+
)
|
|
798
|
+
for field in _PALETTE_TEXT_AREA_FIELDS:
|
|
799
|
+
self.query_one(f"#{field}", TextArea).styles.max_height = (
|
|
800
|
+
_PALETTE_COMPACT_TEXT_AREA_MAX_HEIGHT if compact else None
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
def _initialize_export_defaults(self, task_id: str | None) -> None:
|
|
804
|
+
if task_id is not None:
|
|
805
|
+
self.query_one(
|
|
806
|
+
f"#{_FIELD_EXPORT_DESTINATION}", Input
|
|
807
|
+
).value = _default_export_destination(task_id)
|
|
808
|
+
cast(
|
|
809
|
+
"Select[str]", self.query_one(f"#{_FIELD_EXPORT_FORMAT}", Select)
|
|
810
|
+
).value = EXPORT_FORMAT_MARKDOWN
|
|
811
|
+
cast(
|
|
812
|
+
"Select[str]", self.query_one(f"#{_FIELD_EXPORT_OVERWRITE}", Select)
|
|
813
|
+
).value = _EXPORT_REFUSE
|
|
814
|
+
cast(
|
|
815
|
+
"Select[str]", self.query_one(f"#{_FIELD_EXPORT_SIGNALS}", Select)
|
|
816
|
+
).value = _EXPORT_SIGNALS_EXCLUDE
|
|
817
|
+
|
|
818
|
+
def _restore_form_layout(
|
|
819
|
+
self,
|
|
820
|
+
scroll_y: float,
|
|
821
|
+
focused: Widget | None,
|
|
822
|
+
result_revision: int,
|
|
823
|
+
) -> None:
|
|
824
|
+
if not self.is_mounted or not widget_is_attached(self):
|
|
825
|
+
return
|
|
826
|
+
if self._fit_form_to_submit_height():
|
|
827
|
+
self.call_after_refresh(
|
|
828
|
+
self._restore_form_layout,
|
|
829
|
+
scroll_y,
|
|
830
|
+
focused,
|
|
831
|
+
result_revision,
|
|
832
|
+
)
|
|
833
|
+
return
|
|
834
|
+
if self._result_reveal_revision != result_revision:
|
|
835
|
+
return
|
|
836
|
+
form = self.query_one("#palette-form", VerticalScroll)
|
|
837
|
+
if focused is not None and not self.query_one("#palette-result-scroll").display:
|
|
838
|
+
parent = focused.parent
|
|
839
|
+
while parent is not None:
|
|
840
|
+
if parent.id == _PALETTE_FORM_ID:
|
|
841
|
+
form.scroll_to_widget(focused, animate=False, immediate=True)
|
|
842
|
+
return
|
|
843
|
+
parent = parent.parent
|
|
844
|
+
form.scroll_to(y=scroll_y, animate=False, immediate=True)
|
|
845
|
+
|
|
846
|
+
async def on_select_changed(self, event: Select.Changed) -> None:
|
|
847
|
+
"""Refresh visible fields when a selection changes."""
|
|
848
|
+
if event.select.id == _FIELD_ROLE:
|
|
849
|
+
if not self._role_is_explicit and event.select.has_focus:
|
|
850
|
+
self._role_is_explicit = True
|
|
851
|
+
self._refresh_action_availability()
|
|
852
|
+
elif event.select.id in {
|
|
853
|
+
_FIELD_EXPORT_FORMAT,
|
|
854
|
+
_FIELD_EXPORT_OVERWRITE,
|
|
855
|
+
_FIELD_EXPORT_SIGNALS,
|
|
856
|
+
}:
|
|
857
|
+
self._refresh_action_availability()
|
|
858
|
+
|
|
859
|
+
async def on_input_changed(self, event: Input.Changed) -> None:
|
|
860
|
+
"""Refresh current eligibility when a field changes."""
|
|
861
|
+
del event
|
|
862
|
+
self._refresh_action_availability()
|
|
863
|
+
|
|
864
|
+
def on_input_submitted(self) -> None:
|
|
865
|
+
"""Run the Enter hint's action when a field of its popup is submitted."""
|
|
866
|
+
if self.check_action("confirm", ()):
|
|
867
|
+
self.action_confirm()
|
|
868
|
+
|
|
869
|
+
async def on_text_area_changed(self, event: TextArea.Changed) -> None:
|
|
870
|
+
if event.text_area.id in {_FIELD_REQUEST, _FIELD_MESSAGE}:
|
|
871
|
+
self._refresh_action_availability()
|
|
872
|
+
|
|
873
|
+
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
874
|
+
"""Run the selected action in a worker so the popup stays responsive."""
|
|
875
|
+
button_id = event.button.id or _EMPTY
|
|
876
|
+
if button_id == _PALETTE_CLOSE:
|
|
877
|
+
await self.action_close()
|
|
878
|
+
elif button_id == _SUBMIT_ID:
|
|
879
|
+
self._activate_submit()
|
|
880
|
+
|
|
881
|
+
@on(FoldLine.Toggled)
|
|
882
|
+
def _options_toggled(self, message: FoldLine.Toggled) -> None:
|
|
883
|
+
message.stop()
|
|
884
|
+
self.action_toggle_options()
|
|
885
|
+
|
|
886
|
+
def action_toggle_options(self) -> None:
|
|
887
|
+
if self._action != _ACTION_SUBMIT:
|
|
888
|
+
return
|
|
889
|
+
self._options_expanded = not self._options_expanded
|
|
890
|
+
self.query_one(f"#{_OPTIONS_ID}").display = self._options_expanded
|
|
891
|
+
self._paint_options_fold()
|
|
892
|
+
|
|
893
|
+
def on_unmount(self) -> None:
|
|
894
|
+
worker = self._action_worker
|
|
895
|
+
if worker is not None and worker.is_running:
|
|
896
|
+
worker.cancel()
|
|
897
|
+
if self._change_worker is not None:
|
|
898
|
+
self._change_worker.cancel()
|
|
899
|
+
|
|
900
|
+
async def action_close(self) -> None:
|
|
901
|
+
"""Dismiss the popup, keeping the Message draft."""
|
|
902
|
+
self._remember_form_draft()
|
|
903
|
+
self.dismiss(None)
|
|
904
|
+
|
|
905
|
+
def _apply_layout(self) -> None:
|
|
906
|
+
frame = self.query_one("#palette-frame", PopupFrame)
|
|
907
|
+
form = self.query_one(f"#{_PALETTE_FORM_ID}", VerticalScroll)
|
|
908
|
+
tiny_pane = self.size.height <= _PALETTE_TINY_PANE_MAX_ROWS
|
|
909
|
+
short_pane = (
|
|
910
|
+
self.size.height <= _PALETTE_SHORT_PANE_MAX_ROWS
|
|
911
|
+
or self.size.width <= _PALETTE_SHORT_PANE_MAX_COLUMNS
|
|
912
|
+
)
|
|
913
|
+
narrow_pane = self.size.width <= _PALETTE_SHORT_PANE_MAX_COLUMNS
|
|
914
|
+
frame.styles.max_height = max(
|
|
915
|
+
1,
|
|
916
|
+
self.size.height - (0 if short_pane else _PALETTE_FRAME_VERTICAL_MARGIN_ROWS),
|
|
917
|
+
)
|
|
918
|
+
actions = self.query_one("#palette-actions", Vertical)
|
|
919
|
+
actions.styles.layout = "horizontal" if tiny_pane or not narrow_pane else "vertical"
|
|
920
|
+
actions.set_class(short_pane, "short")
|
|
921
|
+
frame.set_class(short_pane, "short")
|
|
922
|
+
frame.set_class(tiny_pane, "tiny")
|
|
923
|
+
frame.set_class(
|
|
924
|
+
self.size.width <= _PALETTE_STACKED_FIELDS_MAX_COLUMNS,
|
|
925
|
+
"stack-fields",
|
|
926
|
+
)
|
|
927
|
+
form.set_class(tiny_pane, "tiny")
|
|
928
|
+
fixed_rows = (
|
|
929
|
+
_PALETTE_FORM_COMPACT_FIXED_ROWS if short_pane else _PALETTE_FORM_STANDARD_FIXED_ROWS
|
|
930
|
+
)
|
|
931
|
+
action_rows = (
|
|
932
|
+
_PALETTE_STACKED_ACTIONS_ROWS
|
|
933
|
+
if narrow_pane and not tiny_pane
|
|
934
|
+
else _PALETTE_INLINE_ACTIONS_ROWS
|
|
935
|
+
)
|
|
936
|
+
self._layout_form_max_height = max(1, self.size.height - fixed_rows - action_rows)
|
|
937
|
+
form.styles.max_height = self._layout_form_max_height
|
|
938
|
+
self._refresh_submit()
|
|
939
|
+
self.query_one("#palette-title", Label).update(self._context_form_title())
|
|
940
|
+
self.call_after_refresh(self._refresh_context_form_title)
|
|
941
|
+
|
|
942
|
+
def _fit_form_to_submit_height(self) -> bool:
|
|
943
|
+
if (
|
|
944
|
+
self.size.height <= _PALETTE_TINY_PANE_MAX_ROWS
|
|
945
|
+
or self.size.width > _PALETTE_SHORT_PANE_MAX_COLUMNS
|
|
946
|
+
):
|
|
947
|
+
return False
|
|
948
|
+
button = self.query_one(f"#{_SUBMIT_ID}", Button)
|
|
949
|
+
fixed_rows = (
|
|
950
|
+
_PALETTE_FORM_COMPACT_FIXED_ROWS
|
|
951
|
+
if self.size.height <= _PALETTE_SHORT_PANE_MAX_ROWS
|
|
952
|
+
else _PALETTE_FORM_STANDARD_FIXED_ROWS
|
|
953
|
+
)
|
|
954
|
+
max_height = max(
|
|
955
|
+
1,
|
|
956
|
+
self.size.height
|
|
957
|
+
- fixed_rows
|
|
958
|
+
- max(_PALETTE_STACKED_ACTIONS_ROWS, button.region.height),
|
|
959
|
+
)
|
|
960
|
+
if max_height >= self._layout_form_max_height:
|
|
961
|
+
return False
|
|
962
|
+
self._layout_form_max_height = max_height
|
|
963
|
+
self.query_one(f"#{_PALETTE_FORM_ID}", VerticalScroll).styles.max_height = max_height
|
|
964
|
+
return True
|
|
965
|
+
|
|
966
|
+
def _context_form_title(self) -> str:
|
|
967
|
+
action = self._action
|
|
968
|
+
label = SUBMIT_ACTION_LABELS.get(action, "Actions")
|
|
969
|
+
task_id = self._initial_context.task_id
|
|
970
|
+
if task_id is None or action in {_ACTION_SUBMIT, _ACTION_STOP, _ACTION_START}:
|
|
971
|
+
return label
|
|
972
|
+
return f"{label} \N{MIDDLE DOT} {task_id}"
|
|
973
|
+
|
|
974
|
+
def _paint_options_fold(self) -> None:
|
|
975
|
+
label = Text(
|
|
976
|
+
f"{fold_arrow(expanded=self._options_expanded)} {_OPTIONS_TITLE}",
|
|
977
|
+
style=SECONDARY_TEXT_COLOR,
|
|
978
|
+
)
|
|
979
|
+
self.query_one(f"#{_OPTIONS_FOLD_ID}", FoldLine).update(label)
|
|
980
|
+
|
|
981
|
+
def _refresh_context_form_title(self) -> None:
|
|
982
|
+
if not self.is_mounted or not widget_is_attached(self):
|
|
983
|
+
return
|
|
984
|
+
self.query_one("#palette-title", Label).update(self._context_form_title())
|
|
985
|
+
|
|
986
|
+
def _remember_form_draft(self) -> None:
|
|
987
|
+
task_id = self._initial_context.task_id
|
|
988
|
+
action = self._action
|
|
989
|
+
if task_id is None or action != _ACTION_MESSAGE:
|
|
990
|
+
return
|
|
991
|
+
role = self._selected_value(_FIELD_ROLE)
|
|
992
|
+
if self._remember_contextual_draft is not None:
|
|
993
|
+
self._remember_contextual_draft(
|
|
994
|
+
PaletteDraft(
|
|
995
|
+
task_id=task_id,
|
|
996
|
+
action=action,
|
|
997
|
+
guidance=self._text(_FIELD_MESSAGE),
|
|
998
|
+
role=role,
|
|
999
|
+
role_explicit=self._role_is_explicit,
|
|
1000
|
+
)
|
|
1001
|
+
)
|
|
1002
|
+
|
|
1003
|
+
def _restore_contextual_form_draft(self, action: str | None) -> None:
|
|
1004
|
+
if action is None or action in self._restored_contextual_draft_actions:
|
|
1005
|
+
return
|
|
1006
|
+
drafts = tuple(
|
|
1007
|
+
draft
|
|
1008
|
+
for draft in self._initial_context.drafts
|
|
1009
|
+
if draft.task_id == self._initial_context.task_id and draft.action == action
|
|
1010
|
+
)
|
|
1011
|
+
if self._initial_context.role is None:
|
|
1012
|
+
draft = drafts[-1] if drafts else None
|
|
1013
|
+
else:
|
|
1014
|
+
draft = next(
|
|
1015
|
+
(item for item in reversed(drafts) if item.role == self._initial_context.role),
|
|
1016
|
+
None,
|
|
1017
|
+
)
|
|
1018
|
+
if draft is None:
|
|
1019
|
+
return
|
|
1020
|
+
if self._initial_context.role is None and draft.role is not None:
|
|
1021
|
+
cast("Select[str]", self.query_one(f"#{_FIELD_ROLE}", Select)).value = draft.role
|
|
1022
|
+
self._role_is_explicit = draft.role_explicit
|
|
1023
|
+
if action == _ACTION_MESSAGE:
|
|
1024
|
+
self.query_one(f"#{_FIELD_MESSAGE}", TextArea).text = draft.guidance
|
|
1025
|
+
self._restored_contextual_draft_actions.add(action)
|
|
1026
|
+
|
|
1027
|
+
def _restore_context_form_position(self) -> None:
|
|
1028
|
+
if not self.is_mounted or not widget_is_attached(self):
|
|
1029
|
+
return
|
|
1030
|
+
if self._fit_form_to_submit_height():
|
|
1031
|
+
self.call_after_refresh(self._restore_context_form_position)
|
|
1032
|
+
return
|
|
1033
|
+
form = self.query_one(f"#{_PALETTE_FORM_ID}", VerticalScroll)
|
|
1034
|
+
control = self.query_one(f"#{self._form_focus_id}") if self._form_focus_id else None
|
|
1035
|
+
action = self._action
|
|
1036
|
+
if (
|
|
1037
|
+
action not in _ROLE_FIRST_ACTIONS
|
|
1038
|
+
and control is not None
|
|
1039
|
+
and self._is_visible_in_form(control)
|
|
1040
|
+
and control.can_focus
|
|
1041
|
+
):
|
|
1042
|
+
self.set_focus(control)
|
|
1043
|
+
form.scroll_to(y=self._form_scroll_y, animate=False, immediate=True)
|
|
1044
|
+
return
|
|
1045
|
+
candidate = _INITIAL_FIELD_BY_ACTION.get(action)
|
|
1046
|
+
if candidate is not None:
|
|
1047
|
+
field = self.query_one(f"#{candidate}")
|
|
1048
|
+
if self._is_visible_in_form(field) and field.can_focus:
|
|
1049
|
+
self.set_focus(field)
|
|
1050
|
+
form.scroll_to_widget(field, animate=False, immediate=True, force=True)
|
|
1051
|
+
return
|
|
1052
|
+
submit = self.query_one(f"#{_SUBMIT_ID}", Button)
|
|
1053
|
+
if submit.display and submit.can_focus:
|
|
1054
|
+
self.set_focus(submit)
|
|
1055
|
+
else:
|
|
1056
|
+
self.set_focus(None)
|
|
1057
|
+
|
|
1058
|
+
@staticmethod
|
|
1059
|
+
def _is_visible_in_form(widget: Widget) -> bool:
|
|
1060
|
+
for ancestor in widget.ancestors_with_self:
|
|
1061
|
+
if not isinstance(ancestor, Widget):
|
|
1062
|
+
return False
|
|
1063
|
+
if not ancestor.display:
|
|
1064
|
+
return False
|
|
1065
|
+
if ancestor.id == _PALETTE_FORM_ID:
|
|
1066
|
+
return True
|
|
1067
|
+
return False
|
|
1068
|
+
|
|
1069
|
+
async def _run_action(self) -> None:
|
|
1070
|
+
try:
|
|
1071
|
+
if self._action == _ACTION_START:
|
|
1072
|
+
await self._run_project_start()
|
|
1073
|
+
elif self._action == _ACTION_EXPORT:
|
|
1074
|
+
await self._run_evidence_export()
|
|
1075
|
+
else:
|
|
1076
|
+
await self._run_submission()
|
|
1077
|
+
except (OSError, ShikiruError, ValueError) as error:
|
|
1078
|
+
if self._is_current_screen():
|
|
1079
|
+
self._show_result(str(error))
|
|
1080
|
+
finally:
|
|
1081
|
+
self._action_in_flight = False
|
|
1082
|
+
self._submitting_form_identity = None
|
|
1083
|
+
self._action_worker = None
|
|
1084
|
+
if self._is_current_screen():
|
|
1085
|
+
self._refresh_action_availability()
|
|
1086
|
+
|
|
1087
|
+
async def _run_project_start(self) -> None:
|
|
1088
|
+
if self._start_engine is None:
|
|
1089
|
+
raise ShikiruError(_START_NOT_CONFIGURED)
|
|
1090
|
+
self._show_result("Starting engine…")
|
|
1091
|
+
await self._start_engine()
|
|
1092
|
+
if self._is_current_screen():
|
|
1093
|
+
self._confirm_submitted(_ACTION_START)
|
|
1094
|
+
|
|
1095
|
+
async def _run_evidence_export(self) -> None:
|
|
1096
|
+
if self._evidence_exporter is None:
|
|
1097
|
+
raise ShikiruError(_EXPORT_NOT_CONFIGURED)
|
|
1098
|
+
task_id = self._task_id.strip()
|
|
1099
|
+
destination = self._input(_FIELD_EXPORT_DESTINATION)
|
|
1100
|
+
export_format = cast("EvidenceFormat", self._value(_FIELD_EXPORT_FORMAT))
|
|
1101
|
+
overwrite_value = self._selected_value(_FIELD_EXPORT_OVERWRITE)
|
|
1102
|
+
if overwrite_value not in {_EXPORT_REFUSE, _EXPORT_OVERWRITE}:
|
|
1103
|
+
raise ValueError(_EXPORT_OVERWRITE_REQUIRED)
|
|
1104
|
+
include_signals = self._selected_value(_FIELD_EXPORT_SIGNALS) == _EXPORT_SIGNALS_INCLUDE
|
|
1105
|
+
form_identity = self._form_identity()
|
|
1106
|
+
self._set_export_controls_disabled(True)
|
|
1107
|
+
try:
|
|
1108
|
+
result = await self._evidence_exporter.export(
|
|
1109
|
+
task_id,
|
|
1110
|
+
destination,
|
|
1111
|
+
export_format=export_format,
|
|
1112
|
+
overwrite=overwrite_value == _EXPORT_OVERWRITE,
|
|
1113
|
+
include_signals=include_signals,
|
|
1114
|
+
)
|
|
1115
|
+
finally:
|
|
1116
|
+
if self._is_current_screen():
|
|
1117
|
+
self._set_export_controls_disabled(False)
|
|
1118
|
+
if self._is_current_screen() and self._form_identity() == form_identity:
|
|
1119
|
+
format_label = result.format.upper()
|
|
1120
|
+
signal_label = (
|
|
1121
|
+
"Normalized signal excerpts included."
|
|
1122
|
+
if result.signals_included
|
|
1123
|
+
else "Normalized signal excerpts excluded."
|
|
1124
|
+
)
|
|
1125
|
+
destination_label = json.dumps(str(result.destination), ensure_ascii=False)
|
|
1126
|
+
self._show_result(
|
|
1127
|
+
f"Exported {format_label} task evidence to {destination_label}.\n{signal_label}"
|
|
1128
|
+
)
|
|
1129
|
+
|
|
1130
|
+
def _set_export_controls_disabled(self, disabled: bool) -> None:
|
|
1131
|
+
for field in (
|
|
1132
|
+
_FIELD_EXPORT_FORMAT,
|
|
1133
|
+
_FIELD_EXPORT_OVERWRITE,
|
|
1134
|
+
_FIELD_EXPORT_SIGNALS,
|
|
1135
|
+
):
|
|
1136
|
+
self.query_one(f"#{field}").disabled = disabled
|
|
1137
|
+
self.query_one(f"#{_FIELD_EXPORT_DESTINATION}", Input).disabled = disabled
|
|
1138
|
+
|
|
1139
|
+
async def _run_submission(self) -> None:
|
|
1140
|
+
action = self._action
|
|
1141
|
+
task_id = self._task_id.strip()
|
|
1142
|
+
role = self._role() if action in _FORM_ROLE_ACTIONS | {_ACTION_PEEK} else None
|
|
1143
|
+
self._command_action = action
|
|
1144
|
+
self._command_target = self._target_for_action(action, task_id, role)
|
|
1145
|
+
form_identity = self._form_identity()
|
|
1146
|
+
if action != _ACTION_PEEK and self._observe_pending_form(form_identity):
|
|
1147
|
+
return
|
|
1148
|
+
self._submitting_form_identity = form_identity
|
|
1149
|
+
if action != _ACTION_PEEK:
|
|
1150
|
+
self._show_result(f"Sending · {self._command_target}")
|
|
1151
|
+
ack = await self._dispatch()
|
|
1152
|
+
if ack is None:
|
|
1153
|
+
return
|
|
1154
|
+
if ack.status is AckStatus.QUEUED:
|
|
1155
|
+
self._pending_form_commands[form_identity] = ack.id
|
|
1156
|
+
failure = None
|
|
1157
|
+
if action == _ACTION_TAKE_OVER and ack.status is AckStatus.DONE and role is not None:
|
|
1158
|
+
failure = await self._select_role_pane(task_id, role)
|
|
1159
|
+
if ack.status is not AckStatus.REJECTED and (
|
|
1160
|
+
action == _ACTION_SUBMIT
|
|
1161
|
+
or (
|
|
1162
|
+
failure is None
|
|
1163
|
+
and ack.message != ACK_TIMEOUT_MESSAGE
|
|
1164
|
+
and action in _CONFIRMED_ACTIONS
|
|
1165
|
+
)
|
|
1166
|
+
):
|
|
1167
|
+
self._confirm_submitted(action)
|
|
1168
|
+
elif self._is_current_screen():
|
|
1169
|
+
self._show_ack(ack, failure)
|
|
1170
|
+
|
|
1171
|
+
async def _dispatch(self) -> Ack | None:
|
|
1172
|
+
action = self._action
|
|
1173
|
+
task_id = self._task_id.strip()
|
|
1174
|
+
if action == _ACTION_PEEK:
|
|
1175
|
+
self._show_peek(task_id)
|
|
1176
|
+
return None
|
|
1177
|
+
if action == _ACTION_STOP and not self._queries.engine_is_up():
|
|
1178
|
+
raise ValueError(_ENGINE_ALREADY_STOPPED)
|
|
1179
|
+
if action in _TASK_ACTIONS:
|
|
1180
|
+
self._validate_task_action(action, task_id)
|
|
1181
|
+
command = self._command_for_current_form()
|
|
1182
|
+
if command is not None:
|
|
1183
|
+
return await self._commands.send(command)
|
|
1184
|
+
message = f"unsupported palette action: {action}"
|
|
1185
|
+
raise ValueError(message)
|
|
1186
|
+
|
|
1187
|
+
def _validate_task_action(self, action: str, task_id: str) -> None:
|
|
1188
|
+
if not task_id:
|
|
1189
|
+
raise ValueError(_NO_TASK_TARGET)
|
|
1190
|
+
selected_action = _TASK_ACTION_BY_ID[action]
|
|
1191
|
+
role = self._role() if action in _FORM_ROLE_ACTIONS else None
|
|
1192
|
+
state = next(
|
|
1193
|
+
item
|
|
1194
|
+
for item in self._queries.task_action_availability(task_id, role)
|
|
1195
|
+
if item.action is selected_action
|
|
1196
|
+
)
|
|
1197
|
+
if state.disabled_reason is not None:
|
|
1198
|
+
raise ValueError(state.disabled_reason)
|
|
1199
|
+
|
|
1200
|
+
def _refresh_action_availability(self) -> None:
|
|
1201
|
+
self._refresh_pending_commands()
|
|
1202
|
+
form_identity = self._form_identity()
|
|
1203
|
+
if form_identity != self._submitting_form_identity:
|
|
1204
|
+
self._observe_pending_form(form_identity)
|
|
1205
|
+
task_id = self._task_id.strip()
|
|
1206
|
+
self._action_states = (
|
|
1207
|
+
{
|
|
1208
|
+
state.action.value: state
|
|
1209
|
+
for state in self._queries.task_action_availability(task_id, None)
|
|
1210
|
+
}
|
|
1211
|
+
if task_id
|
|
1212
|
+
else {}
|
|
1213
|
+
)
|
|
1214
|
+
action = self._action
|
|
1215
|
+
disabled_reason = self._disabled_reason(action, task_id)
|
|
1216
|
+
if action in _FORM_ROLE_ACTIONS and task_id:
|
|
1217
|
+
selected_action = _TASK_ACTION_BY_ID[action]
|
|
1218
|
+
role_state = next(
|
|
1219
|
+
item
|
|
1220
|
+
for item in self._queries.task_action_availability(task_id, self._role())
|
|
1221
|
+
if item.action is selected_action
|
|
1222
|
+
)
|
|
1223
|
+
disabled_reason = role_state.disabled_reason
|
|
1224
|
+
state = self.query_one(f"#{_ACTION_STATE_ID}", Static)
|
|
1225
|
+
state.update(
|
|
1226
|
+
Text(f"Unavailable: {disabled_reason}", style=AMBER)
|
|
1227
|
+
if disabled_reason is not None
|
|
1228
|
+
else _EMPTY
|
|
1229
|
+
)
|
|
1230
|
+
state.display = disabled_reason is not None
|
|
1231
|
+
self.query_one(f"#{_GUIDANCE_ID}", Static).update(
|
|
1232
|
+
_ACTION_GUIDANCE.get(action or "", _EMPTY)
|
|
1233
|
+
)
|
|
1234
|
+
pending_same_form = form_identity in self._pending_form_commands
|
|
1235
|
+
self.query_one(f"#{_SUBMIT_ID}", Button).disabled = (
|
|
1236
|
+
self._route_error is not None
|
|
1237
|
+
or disabled_reason is not None
|
|
1238
|
+
or self._action_in_flight
|
|
1239
|
+
or pending_same_form
|
|
1240
|
+
or self._required_field_is_blank(action)
|
|
1241
|
+
)
|
|
1242
|
+
self._refresh_submit()
|
|
1243
|
+
|
|
1244
|
+
async def _refresh_after_database_change(self) -> None:
|
|
1245
|
+
if self._is_current_screen():
|
|
1246
|
+
self.call_after_refresh(self._refresh_if_current_screen)
|
|
1247
|
+
|
|
1248
|
+
def _refresh_if_current_screen(self) -> None:
|
|
1249
|
+
if self._is_current_screen():
|
|
1250
|
+
self._refresh_action_availability()
|
|
1251
|
+
|
|
1252
|
+
def _required_field_is_blank(self, action: str | None) -> bool:
|
|
1253
|
+
field = _REQUIRED_FIELDS.get(action or _EMPTY)
|
|
1254
|
+
return field is not None and not self._identity_value(field).strip()
|
|
1255
|
+
|
|
1256
|
+
def _refresh_pending_commands(self) -> None:
|
|
1257
|
+
for identity, command_id in tuple(self._pending_form_commands.items()):
|
|
1258
|
+
outcome = self._queries.command_status(command_id)
|
|
1259
|
+
if outcome is None:
|
|
1260
|
+
message = f"pending command {command_id} is missing from durable history"
|
|
1261
|
+
raise RuntimeError(message)
|
|
1262
|
+
if outcome.status is CommandStatus.PENDING:
|
|
1263
|
+
continue
|
|
1264
|
+
del self._pending_form_commands[identity]
|
|
1265
|
+
if outcome.status is CommandStatus.DONE and identity[0] in _CONFIRMED_ACTIONS:
|
|
1266
|
+
self._show_result(_EMPTY)
|
|
1267
|
+
else:
|
|
1268
|
+
self._show_stored_command(outcome)
|
|
1269
|
+
|
|
1270
|
+
def _observe_pending_form(self, identity: tuple[str, ...]) -> bool:
|
|
1271
|
+
if identity in self._pending_form_commands:
|
|
1272
|
+
return True
|
|
1273
|
+
try:
|
|
1274
|
+
command = self._command_for_current_form()
|
|
1275
|
+
except ShikiruError, ValueError:
|
|
1276
|
+
return False
|
|
1277
|
+
if command is None:
|
|
1278
|
+
return False
|
|
1279
|
+
outcome = self._queries.pending_command(command)
|
|
1280
|
+
if outcome is None:
|
|
1281
|
+
return False
|
|
1282
|
+
self._pending_form_commands[identity] = outcome.id
|
|
1283
|
+
self._show_stored_command(outcome)
|
|
1284
|
+
return True
|
|
1285
|
+
|
|
1286
|
+
def _command_for_current_form(self) -> Command | None:
|
|
1287
|
+
task_id = self._task_id.strip()
|
|
1288
|
+
action = self._action
|
|
1289
|
+
builders: dict[str, Callable[[], Command]] = {
|
|
1290
|
+
_ACTION_SUBMIT: self._submit_command,
|
|
1291
|
+
_ACTION_PRIORITIZE: lambda: self._commands.prepare_prioritize_task(
|
|
1292
|
+
task_id, self._integer(_FIELD_PRIORITY)
|
|
1293
|
+
),
|
|
1294
|
+
_ACTION_PAUSE: lambda: self._commands.prepare_pause_task(task_id),
|
|
1295
|
+
_ACTION_PAUSE_NOW: lambda: self._commands.prepare_pause_task(task_id, now=True),
|
|
1296
|
+
_ACTION_RESUME: lambda: self._commands.prepare_resume_task(task_id),
|
|
1297
|
+
_ACTION_TAKE_OVER: lambda: self._commands.prepare_take_over(task_id, self._role()),
|
|
1298
|
+
_ACTION_HAND_BACK: lambda: self._commands.prepare_hand_back(task_id, self._role()),
|
|
1299
|
+
_ACTION_CANCEL: lambda: self._commands.prepare_cancel_task(task_id),
|
|
1300
|
+
_ACTION_RETRY: lambda: self._commands.prepare_retry_task(task_id),
|
|
1301
|
+
_ACTION_RUNG: lambda: self._commands.prepare_set_rung(
|
|
1302
|
+
task_id, self._role(), parse_rung(self._input(_FIELD_RUNG))
|
|
1303
|
+
),
|
|
1304
|
+
_ACTION_MESSAGE: lambda: self._commands.prepare_send_message(
|
|
1305
|
+
task_id, self._role(), self._text(_FIELD_MESSAGE)
|
|
1306
|
+
),
|
|
1307
|
+
_ACTION_STOP: self._commands.prepare_stop_engine,
|
|
1308
|
+
}
|
|
1309
|
+
builder = builders.get(action or _EMPTY)
|
|
1310
|
+
return builder() if builder is not None else None
|
|
1311
|
+
|
|
1312
|
+
def _submit_command(self) -> Command:
|
|
1313
|
+
request = self._text(_FIELD_REQUEST)
|
|
1314
|
+
return self._commands.prepare_submit_task(
|
|
1315
|
+
request,
|
|
1316
|
+
title=self._optional_input(_FIELD_TITLE),
|
|
1317
|
+
mode=self._mode(),
|
|
1318
|
+
complexity=self._complexity(),
|
|
1319
|
+
priority=self._integer(_FIELD_SUBMISSION_PRIORITY),
|
|
1320
|
+
depends_on=self._dependencies(),
|
|
1321
|
+
leader=self._optional_rung(_FIELD_LEADER_RUNG),
|
|
1322
|
+
implementer=self._optional_rung(_FIELD_IMPLEMENTER_RUNG),
|
|
1323
|
+
)
|
|
1324
|
+
|
|
1325
|
+
def _form_identity(self) -> tuple[str, ...]:
|
|
1326
|
+
action = self._action
|
|
1327
|
+
return (
|
|
1328
|
+
action,
|
|
1329
|
+
self._task_id.strip(),
|
|
1330
|
+
(self._selected_value(_FIELD_ROLE) or _EMPTY)
|
|
1331
|
+
if action in _FORM_ROLE_ACTIONS
|
|
1332
|
+
else _EMPTY,
|
|
1333
|
+
*(self._identity_value(field) for field in _FORM_IDENTITY_FIELDS.get(action, ())),
|
|
1334
|
+
)
|
|
1335
|
+
|
|
1336
|
+
def _identity_value(self, field: str) -> str:
|
|
1337
|
+
control = self.query_one(f"#{field}")
|
|
1338
|
+
if isinstance(control, Input):
|
|
1339
|
+
return control.value
|
|
1340
|
+
if isinstance(control, TextArea):
|
|
1341
|
+
return control.text
|
|
1342
|
+
if isinstance(control, Select):
|
|
1343
|
+
value = cast("Select[str]", control).value
|
|
1344
|
+
return value if isinstance(value, str) else _EMPTY
|
|
1345
|
+
message = f"unsupported palette form field: {field}"
|
|
1346
|
+
raise TypeError(message)
|
|
1347
|
+
|
|
1348
|
+
def _target_for_action(self, action: str, task_id: str, role: Role | None) -> str:
|
|
1349
|
+
if action in {_ACTION_SUBMIT, _ACTION_STOP, _ACTION_START}:
|
|
1350
|
+
return "project"
|
|
1351
|
+
if role is not None:
|
|
1352
|
+
return f"{task_id} · {role.value}"
|
|
1353
|
+
return task_id or "task"
|
|
1354
|
+
|
|
1355
|
+
def _disabled_reason(self, action: str | None, task_id: str) -> str | None:
|
|
1356
|
+
if action in {_ACTION_START, _ACTION_STOP}:
|
|
1357
|
+
return self._project_engine_disabled_reason(action)
|
|
1358
|
+
if action == _ACTION_EXPORT:
|
|
1359
|
+
return (
|
|
1360
|
+
_NO_TASK_TARGET
|
|
1361
|
+
if not task_id
|
|
1362
|
+
else _EXPORT_NOT_CONFIGURED
|
|
1363
|
+
if self._evidence_exporter is None
|
|
1364
|
+
else None
|
|
1365
|
+
)
|
|
1366
|
+
if action in _TASK_TARGET_ACTIONS:
|
|
1367
|
+
state = self._action_states.get(action)
|
|
1368
|
+
return state.disabled_reason if state is not None and task_id else _NO_TASK_TARGET
|
|
1369
|
+
return None
|
|
1370
|
+
|
|
1371
|
+
def _project_engine_disabled_reason(self, action: str) -> str | None:
|
|
1372
|
+
engine_up = self._queries.engine_is_up()
|
|
1373
|
+
if action == _ACTION_START and self._start_engine is None:
|
|
1374
|
+
return _START_NOT_CONFIGURED
|
|
1375
|
+
if action == _ACTION_START and engine_up:
|
|
1376
|
+
return _ENGINE_ALREADY_RUNNING
|
|
1377
|
+
if action == _ACTION_STOP and not engine_up:
|
|
1378
|
+
return _ENGINE_ALREADY_STOPPED
|
|
1379
|
+
return None
|
|
1380
|
+
|
|
1381
|
+
async def _select_role_pane(self, task_id: str, role: Role) -> str | None:
|
|
1382
|
+
detail = self._queries.get_task(task_id)
|
|
1383
|
+
task = detail.task if detail is not None else None
|
|
1384
|
+
pane_id = task.panes.get(role) if task is not None else None
|
|
1385
|
+
if pane_id is None:
|
|
1386
|
+
return "task is missing" if task is None else f"{role.value} pane is missing"
|
|
1387
|
+
try:
|
|
1388
|
+
await self._pane_selector(pane_id)
|
|
1389
|
+
except ShikiruError as error:
|
|
1390
|
+
return f"{_TAKEOVER_SELECTION_FAILURE}: {error}"
|
|
1391
|
+
return None
|
|
1392
|
+
|
|
1393
|
+
def _show_peek(self, task_id: str) -> None:
|
|
1394
|
+
selected_role = self._selected_value(_FIELD_ROLE)
|
|
1395
|
+
view = self._queries.peek_session(task_id, Role(selected_role) if selected_role else None)
|
|
1396
|
+
if view is None:
|
|
1397
|
+
self._show_result(f"task not found: {task_id}")
|
|
1398
|
+
return
|
|
1399
|
+
lines: list[str] = [view.task_id]
|
|
1400
|
+
for role, pane in view.panes.items():
|
|
1401
|
+
lines.extend(
|
|
1402
|
+
(
|
|
1403
|
+
f"{role} · {pane.agent}",
|
|
1404
|
+
f"last message: {pane.last_message or '-'}",
|
|
1405
|
+
f"tokens: {pane.usage.input_tokens} in / {pane.usage.output_tokens} out",
|
|
1406
|
+
*pane.view,
|
|
1407
|
+
)
|
|
1408
|
+
)
|
|
1409
|
+
self._show_result("\n".join(lines))
|
|
1410
|
+
|
|
1411
|
+
def _update_fields(self) -> None:
|
|
1412
|
+
action = self._action
|
|
1413
|
+
groups = {
|
|
1414
|
+
_FIELD_EXPORT_DESTINATION: action == _ACTION_EXPORT,
|
|
1415
|
+
_FIELD_EXPORT_FORMAT: action == _ACTION_EXPORT,
|
|
1416
|
+
_FIELD_EXPORT_OVERWRITE: action == _ACTION_EXPORT,
|
|
1417
|
+
_FIELD_EXPORT_SIGNALS: action == _ACTION_EXPORT,
|
|
1418
|
+
"request": action == _ACTION_SUBMIT,
|
|
1419
|
+
"title": action == _ACTION_SUBMIT,
|
|
1420
|
+
"mode": action == _ACTION_SUBMIT,
|
|
1421
|
+
"complexity": action == _ACTION_SUBMIT,
|
|
1422
|
+
"priority": action == _ACTION_PRIORITIZE,
|
|
1423
|
+
"submission-priority": action == _ACTION_SUBMIT,
|
|
1424
|
+
"dependencies": action == _ACTION_SUBMIT,
|
|
1425
|
+
"leader-rung": action == _ACTION_SUBMIT,
|
|
1426
|
+
"implementer-rung": action == _ACTION_SUBMIT,
|
|
1427
|
+
_FIELD_ROLE: action
|
|
1428
|
+
in {_ACTION_TAKE_OVER, _ACTION_HAND_BACK, _ACTION_RUNG, _ACTION_MESSAGE, _ACTION_PEEK},
|
|
1429
|
+
"rung": action == _ACTION_RUNG,
|
|
1430
|
+
"message": action == _ACTION_MESSAGE,
|
|
1431
|
+
}
|
|
1432
|
+
for field, visible in groups.items():
|
|
1433
|
+
self.query_one(f"#field-{field}").display = visible
|
|
1434
|
+
if action != _ACTION_SUBMIT:
|
|
1435
|
+
self._options_expanded = False
|
|
1436
|
+
self.query_one(f"#{_OPTIONS_FOLD_ID}").display = action == _ACTION_SUBMIT
|
|
1437
|
+
self.query_one(f"#{_OPTIONS_ID}").display = (
|
|
1438
|
+
action == _ACTION_SUBMIT and self._options_expanded
|
|
1439
|
+
)
|
|
1440
|
+
self._paint_options_fold()
|
|
1441
|
+
self._confirm_armed = False
|
|
1442
|
+
self._refresh_submit()
|
|
1443
|
+
self.query_one(f"#{_GUIDANCE_ID}").display = action in _ACTION_GUIDANCE
|
|
1444
|
+
|
|
1445
|
+
def _show_ack(self, ack: Ack, additional_detail: str | None = None) -> None:
|
|
1446
|
+
outcome = self._queries.command_status(ack.id)
|
|
1447
|
+
if outcome is None:
|
|
1448
|
+
message = f"acknowledged command {ack.id} is missing from durable history"
|
|
1449
|
+
raise RuntimeError(message)
|
|
1450
|
+
target = outcome.target
|
|
1451
|
+
if ack.status is AckStatus.QUEUED:
|
|
1452
|
+
label = "Pending"
|
|
1453
|
+
if ack.message == QUEUED_MESSAGE:
|
|
1454
|
+
details = "queued while the engine was stopped"
|
|
1455
|
+
elif ack.message == ACK_TIMEOUT_MESSAGE:
|
|
1456
|
+
engine_state = "running" if self._queries.engine_is_up() else "stopped"
|
|
1457
|
+
details = (
|
|
1458
|
+
f"acknowledgement wait elapsed; engine is currently {engine_state}; "
|
|
1459
|
+
"still pending"
|
|
1460
|
+
)
|
|
1461
|
+
else:
|
|
1462
|
+
details = "waiting for engine acknowledgement"
|
|
1463
|
+
elif ack.status is AckStatus.DONE:
|
|
1464
|
+
label = "Applied"
|
|
1465
|
+
details = self._applied_text()
|
|
1466
|
+
else:
|
|
1467
|
+
label = "Rejected"
|
|
1468
|
+
details = ack.message or COMMAND_REJECTED_FALLBACK
|
|
1469
|
+
message = f"{label} · command {ack.id} · {target}\n{details}"
|
|
1470
|
+
if additional_detail is not None:
|
|
1471
|
+
message = f"{message}\n{additional_detail}"
|
|
1472
|
+
self._show_result(message)
|
|
1473
|
+
|
|
1474
|
+
def _show_stored_command(self, entry: CommandHistoryEntry) -> None:
|
|
1475
|
+
if entry.status is CommandStatus.DONE:
|
|
1476
|
+
label = "Applied"
|
|
1477
|
+
details = (
|
|
1478
|
+
"guidance queued for the selected role's next turn"
|
|
1479
|
+
if entry.action == CommandKind.SAY.value.capitalize()
|
|
1480
|
+
else "engine applied the command effect"
|
|
1481
|
+
)
|
|
1482
|
+
elif entry.status is CommandStatus.REJECTED:
|
|
1483
|
+
label = "Rejected"
|
|
1484
|
+
details = command_rejection_cause(entry.result)
|
|
1485
|
+
else:
|
|
1486
|
+
label = "Pending"
|
|
1487
|
+
engine_state = "running" if self._queries.engine_is_up() else "stopped"
|
|
1488
|
+
details = (
|
|
1489
|
+
f"command remains pending; engine currently {engine_state}; "
|
|
1490
|
+
"original acknowledgement wait context unknown"
|
|
1491
|
+
)
|
|
1492
|
+
self._show_result(f"{label} · command {entry.id} · {entry.target}\n{details}")
|
|
1493
|
+
|
|
1494
|
+
def _applied_text(self) -> str:
|
|
1495
|
+
if self._command_action == _ACTION_MESSAGE:
|
|
1496
|
+
details = "guidance queued for the selected role's next turn"
|
|
1497
|
+
elif self._command_action == _ACTION_RUNG:
|
|
1498
|
+
details = "selected rung applies to this role's next turn"
|
|
1499
|
+
else:
|
|
1500
|
+
details = "engine applied the command effect"
|
|
1501
|
+
return details
|
|
1502
|
+
|
|
1503
|
+
def _paint_result(self, text: str) -> None:
|
|
1504
|
+
self.query_one("#palette-result", Static).update(text)
|
|
1505
|
+
self.query_one("#palette-result-scroll").display = bool(text)
|
|
1506
|
+
|
|
1507
|
+
def _show_result(self, text: str) -> None:
|
|
1508
|
+
if self._is_current_screen():
|
|
1509
|
+
self._result_reveal_revision += 1
|
|
1510
|
+
self._paint_result(text)
|
|
1511
|
+
self.call_after_refresh(self._reveal_result)
|
|
1512
|
+
|
|
1513
|
+
def _reveal_result(self) -> None:
|
|
1514
|
+
if self._is_current_screen():
|
|
1515
|
+
self.query_one("#palette-form", VerticalScroll).scroll_end(animate=False)
|
|
1516
|
+
|
|
1517
|
+
def _is_current_screen(self) -> bool:
|
|
1518
|
+
app = cast("App[None]", self.app)
|
|
1519
|
+
return app.is_running and self.is_mounted and app.screen is self
|
|
1520
|
+
|
|
1521
|
+
def _input(self, name: str) -> str:
|
|
1522
|
+
return self.query_one(f"#{name}", Input).value
|
|
1523
|
+
|
|
1524
|
+
def _text(self, name: str) -> str:
|
|
1525
|
+
return self.query_one(f"#{name}", TextArea).text
|
|
1526
|
+
|
|
1527
|
+
def _optional_input(self, name: str) -> str | None:
|
|
1528
|
+
return self._input(name).strip() or None
|
|
1529
|
+
|
|
1530
|
+
def _integer(self, name: str) -> int:
|
|
1531
|
+
try:
|
|
1532
|
+
return int(self._input(name))
|
|
1533
|
+
except ValueError:
|
|
1534
|
+
message = f"{name.replace('-', ' ')} must be an integer"
|
|
1535
|
+
raise ValueError(message) from None
|
|
1536
|
+
|
|
1537
|
+
def _dependencies(self) -> tuple[str, ...]:
|
|
1538
|
+
return tuple(
|
|
1539
|
+
item.strip() for item in self._input("dependencies").split(",") if item.strip()
|
|
1540
|
+
)
|
|
1541
|
+
|
|
1542
|
+
def _optional_rung(self, name: str) -> Rung | None:
|
|
1543
|
+
value = self._input(name).strip()
|
|
1544
|
+
return parse_rung(value) if value else None
|
|
1545
|
+
|
|
1546
|
+
def _mode(self) -> Mode | None:
|
|
1547
|
+
value = self._selected_value("mode")
|
|
1548
|
+
return Mode(value) if value else None
|
|
1549
|
+
|
|
1550
|
+
def _complexity(self) -> ComplexityName | None:
|
|
1551
|
+
value = self._selected_value("complexity")
|
|
1552
|
+
if not value:
|
|
1553
|
+
return None
|
|
1554
|
+
if value in get_args(ComplexityName):
|
|
1555
|
+
return cast("ComplexityName", value)
|
|
1556
|
+
message = f"unsupported complexity: {value}"
|
|
1557
|
+
raise ValueError(message)
|
|
1558
|
+
|
|
1559
|
+
def _role(self) -> Role:
|
|
1560
|
+
return Role(self._value(_FIELD_ROLE))
|
|
1561
|
+
|
|
1562
|
+
def _value(self, name: str) -> str:
|
|
1563
|
+
value = self._selected_value(name)
|
|
1564
|
+
if value is None:
|
|
1565
|
+
message = f"{name.replace('-', ' ')} is required"
|
|
1566
|
+
raise ValueError(message)
|
|
1567
|
+
return value
|
|
1568
|
+
|
|
1569
|
+
def _selected_value(self, name: str) -> str | None:
|
|
1570
|
+
value = cast("Select[str]", self.query_one(f"#{name}", Select)).value
|
|
1571
|
+
return value if isinstance(value, str) else None
|
|
1572
|
+
|
|
1573
|
+
@staticmethod
|
|
1574
|
+
def _select_field(
|
|
1575
|
+
label: str,
|
|
1576
|
+
field_id: str,
|
|
1577
|
+
options: tuple[tuple[str, str], ...],
|
|
1578
|
+
*,
|
|
1579
|
+
value: str,
|
|
1580
|
+
inline: bool = False,
|
|
1581
|
+
wide: bool = False,
|
|
1582
|
+
) -> ComposeResult:
|
|
1583
|
+
with Vertical(id=f"field-{field_id}", classes=_field_classes(inline=inline, wide=wide)):
|
|
1584
|
+
yield Label(label)
|
|
1585
|
+
yield Select(options, value=value, allow_blank=False, id=field_id)
|
|
1586
|
+
|
|
1587
|
+
@staticmethod
|
|
1588
|
+
def _input_field(
|
|
1589
|
+
label: str,
|
|
1590
|
+
field_id: str,
|
|
1591
|
+
placeholder: str,
|
|
1592
|
+
*,
|
|
1593
|
+
inline: bool = False,
|
|
1594
|
+
wide: bool = False,
|
|
1595
|
+
) -> ComposeResult:
|
|
1596
|
+
with Vertical(id=f"field-{field_id}", classes=_field_classes(inline=inline, wide=wide)):
|
|
1597
|
+
yield Label(label)
|
|
1598
|
+
yield Input(placeholder=placeholder, id=field_id)
|
|
1599
|
+
|
|
1600
|
+
@staticmethod
|
|
1601
|
+
def _text_area_field(label: str, field_id: str, placeholder: str) -> ComposeResult:
|
|
1602
|
+
with Vertical(id=f"field-{field_id}", classes="palette-field"):
|
|
1603
|
+
yield Label(label)
|
|
1604
|
+
text_area = TextArea(placeholder=placeholder, id=field_id, classes=POPUP_EDIT_CLASS)
|
|
1605
|
+
text_area.styles.min_width = _PALETTE_TEXT_AREA_MINIMUM_WIDTH
|
|
1606
|
+
yield text_area
|