talkops-opscloud 0.3.0b1__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.
- opscloud/__init__.py +5 -0
- opscloud/__main__.py +6 -0
- opscloud/_constants.py +53 -0
- opscloud/_debug.py +145 -0
- opscloud/_debug_buffer.py +250 -0
- opscloud/_textual_patches.py +367 -0
- opscloud/_version.py +15 -0
- opscloud/agent/__init__.py +27 -0
- opscloud/agent/config.py +168 -0
- opscloud/agent/factory.py +1103 -0
- opscloud/approval_mode.py +44 -0
- opscloud/backend/__init__.py +49 -0
- opscloud/backend/composite.py +89 -0
- opscloud/backend/local.py +183 -0
- opscloud/backend/registry.py +79 -0
- opscloud/backend/sandbox_config.py +107 -0
- opscloud/backend/sandbox_factory.py +432 -0
- opscloud/built_in_skills/remember/SKILL.md +124 -0
- opscloud/built_in_skills/skill-creator/SKILL.md +348 -0
- opscloud/built_in_skills/skill-creator/scripts/init_skill.py +371 -0
- opscloud/built_in_skills/skill-creator/scripts/quick_validate.py +165 -0
- opscloud/cli/__init__.py +1 -0
- opscloud/cli/commands/__init__.py +15 -0
- opscloud/cli/commands/auth.py +45 -0
- opscloud/cli/commands/doctor.py +100 -0
- opscloud/cli/commands/mcp.py +137 -0
- opscloud/cli/commands/threads.py +51 -0
- opscloud/cli/interactive.py +154 -0
- opscloud/cli/main.py +903 -0
- opscloud/cli/non_interactive.py +425 -0
- opscloud/cli/server_manager.py +58 -0
- opscloud/commands/__init__.py +19 -0
- opscloud/commands/_base.py +110 -0
- opscloud/commands/_guards.py +52 -0
- opscloud/commands/_router.py +146 -0
- opscloud/commands/_types.py +36 -0
- opscloud/commands/core/__init__.py +47 -0
- opscloud/commands/core/auth.py +94 -0
- opscloud/commands/core/bug.py +46 -0
- opscloud/commands/core/clear.py +106 -0
- opscloud/commands/core/cloud.py +185 -0
- opscloud/commands/core/compact.py +128 -0
- opscloud/commands/core/config_cmd.py +120 -0
- opscloud/commands/core/context.py +220 -0
- opscloud/commands/core/cost.py +44 -0
- opscloud/commands/core/doctor.py +535 -0
- opscloud/commands/core/effort.py +108 -0
- opscloud/commands/core/exit_cmd.py +41 -0
- opscloud/commands/core/fast.py +148 -0
- opscloud/commands/core/help_cmd.py +94 -0
- opscloud/commands/core/mcp.py +134 -0
- opscloud/commands/core/model.py +109 -0
- opscloud/commands/core/permissions.py +196 -0
- opscloud/commands/core/plugins.py +92 -0
- opscloud/commands/core/pool.py +108 -0
- opscloud/commands/core/resume.py +97 -0
- opscloud/commands/core/skills.py +178 -0
- opscloud/commands/power/__init__.py +51 -0
- opscloud/commands/power/agents.py +98 -0
- opscloud/commands/power/btw.py +85 -0
- opscloud/commands/power/copy.py +97 -0
- opscloud/commands/power/goal.py +548 -0
- opscloud/commands/power/loop.py +295 -0
- opscloud/commands/power/memory.py +287 -0
- opscloud/commands/power/review.py +152 -0
- opscloud/commands/power/rubric.py +287 -0
- opscloud/commands/power/runtime.py +321 -0
- opscloud/commands/power/skill_creator.py +70 -0
- opscloud/commands/power/skill_invoke.py +165 -0
- opscloud/commands/power/tasks.py +209 -0
- opscloud/commands/power/trace.py +117 -0
- opscloud/commands/power/ui_toggles.py +135 -0
- opscloud/commands/power/version.py +52 -0
- opscloud/commands/registry.py +176 -0
- opscloud/config/__init__.py +114 -0
- opscloud/config/adapters/__init__.py +5 -0
- opscloud/config/adapters/postgres.py +776 -0
- opscloud/config/adapters/sqlite.py +769 -0
- opscloud/config/aws.py +248 -0
- opscloud/config/cloud_profiles.py +437 -0
- opscloud/config/env_vars.py +81 -0
- opscloud/config/langsmith.py +319 -0
- opscloud/config/manifest.py +817 -0
- opscloud/config/metadata.py +205 -0
- opscloud/config/paths.py +604 -0
- opscloud/config/plugins.py +130 -0
- opscloud/config/settings.py +834 -0
- opscloud/config/store.py +453 -0
- opscloud/config/store_factory.py +97 -0
- opscloud/config/toml_config.py +562 -0
- opscloud/editor.py +173 -0
- opscloud/exceptions.py +65 -0
- opscloud/file_ops.py +217 -0
- opscloud/hooks/__init__.py +139 -0
- opscloud/hooks/env.py +44 -0
- opscloud/hooks/interrupt.py +121 -0
- opscloud/hooks/models/__init__.py +109 -0
- opscloud/hooks/models/adapters.py +19 -0
- opscloud/hooks/models/config.py +79 -0
- opscloud/hooks/models/domain.py +434 -0
- opscloud/hooks/models/transport.py +37 -0
- opscloud/hooks/tools.py +231 -0
- opscloud/input.py +48 -0
- opscloud/integrations/__init__.py +80 -0
- opscloud/integrations/base.py +153 -0
- opscloud/integrations/content_filter.py +119 -0
- opscloud/integrations/event_bus.py +480 -0
- opscloud/integrations/hooks.py +238 -0
- opscloud/integrations/identity.py +181 -0
- opscloud/integrations/notifications.py +134 -0
- opscloud/integrations/stream_bridge.py +397 -0
- opscloud/integrations/thread_store.py +81 -0
- opscloud/json_types.py +20 -0
- opscloud/mcp/__init__.py +57 -0
- opscloud/mcp/config.py +203 -0
- opscloud/mcp/discovery.py +229 -0
- opscloud/mcp/mcp_info.py +109 -0
- opscloud/mcp/middleware.py +102 -0
- opscloud/mcp/preload.py +505 -0
- opscloud/mcp/raw_config.py +149 -0
- opscloud/mcp/semantic_profiler.py +305 -0
- opscloud/mcp/session_manager.py +727 -0
- opscloud/mcp/trust.py +105 -0
- opscloud/memory/__init__.py +27 -0
- opscloud/memory/branch.py +61 -0
- opscloud/memory/guard.py +19 -0
- opscloud/memory/onboarding.py +120 -0
- opscloud/memory/registry.py +134 -0
- opscloud/memory/store.py +223 -0
- opscloud/middleware/__init__.py +106 -0
- opscloud/middleware/_repository_bounds.py +459 -0
- opscloud/middleware/ask_user.py +152 -0
- opscloud/middleware/auto_mode.py +60 -0
- opscloud/middleware/auto_mode_hitl.py +2434 -0
- opscloud/middleware/compaction.py +566 -0
- opscloud/middleware/configurable_model.py +233 -0
- opscloud/middleware/cost_tracking.py +1021 -0
- opscloud/middleware/glm_stall_recovery.py +109 -0
- opscloud/middleware/goal_criteria.py +1234 -0
- opscloud/middleware/goal_state_notice.py +712 -0
- opscloud/middleware/goal_tools.py +407 -0
- opscloud/middleware/headless_mcp_guard.py +88 -0
- opscloud/middleware/jev_model_router.py +1018 -0
- opscloud/middleware/local_context.py +460 -0
- opscloud/middleware/mcp_context.py +151 -0
- opscloud/middleware/mcp_middleware.py +146 -0
- opscloud/middleware/memory_guard.py +393 -0
- opscloud/middleware/model_retry.py +205 -0
- opscloud/middleware/registry.py +137 -0
- opscloud/middleware/reliable_rubric.py +709 -0
- opscloud/middleware/resume_state.py +91 -0
- opscloud/middleware/server_hooks.py +1189 -0
- opscloud/middleware/shell_allow_list.py +90 -0
- opscloud/middleware/skills.py +382 -0
- opscloud/middleware/subagent_artifacts.py +84 -0
- opscloud/middleware/subagent_telemetry.py +261 -0
- opscloud/middleware/subagents.py +243 -0
- opscloud/middleware/tool_filter.py +384 -0
- opscloud/middleware/unified_system_message.py +62 -0
- opscloud/model/__init__.py +37 -0
- opscloud/model/config.py +2992 -0
- opscloud/model/factory.py +477 -0
- opscloud/model/pool.py +459 -0
- opscloud/model/reasoning.py +282 -0
- opscloud/offload.py +173 -0
- opscloud/output.py +64 -0
- opscloud/plugins/__init__.py +189 -0
- opscloud/plugins/_json.py +22 -0
- opscloud/plugins/adapters/__init__.py +13 -0
- opscloud/plugins/adapters/agents.py +112 -0
- opscloud/plugins/adapters/commands.py +190 -0
- opscloud/plugins/adapters/mcp.py +363 -0
- opscloud/plugins/adapters/skills.py +132 -0
- opscloud/plugins/commands_cli.py +541 -0
- opscloud/plugins/discovery.py +512 -0
- opscloud/plugins/manifest.py +335 -0
- opscloud/plugins/marketplace.py +893 -0
- opscloud/plugins/models.py +334 -0
- opscloud/plugins/project_plugins.py +317 -0
- opscloud/plugins/protocol.py +31 -0
- opscloud/plugins/store.py +905 -0
- opscloud/plugins/substitution.py +122 -0
- opscloud/project_utils.py +145 -0
- opscloud/prompts/__init__.py +37 -0
- opscloud/prompts/system.py +483 -0
- opscloud/prompts/templates/system_prompt.md +166 -0
- opscloud/prompts/types.py +54 -0
- opscloud/rubrics/__init__.py +42 -0
- opscloud/rubrics/evaluator.py +404 -0
- opscloud/rubrics/evidence_extractor.py +229 -0
- opscloud/rubrics/generator.py +239 -0
- opscloud/rubrics/jev_compiler.py +175 -0
- opscloud/rubrics/jev_grader.py +297 -0
- opscloud/rubrics/middleware.py +8 -0
- opscloud/schema/__init__.py +107 -0
- opscloud/schema/interrupts.py +981 -0
- opscloud/security/__init__.py +91 -0
- opscloud/security/approval_mode.py +528 -0
- opscloud/security/approval_mode_source.py +230 -0
- opscloud/security/cli_ast_evaluator.py +552 -0
- opscloud/security/jev_classifier.py +651 -0
- opscloud/security/shell_safety.py +340 -0
- opscloud/security/unicode_security.py +439 -0
- opscloud/security/url_validation.py +160 -0
- opscloud/server/__init__.py +13 -0
- opscloud/server/_server_config.py +158 -0
- opscloud/server/server.py +242 -0
- opscloud/server/server_graph.py +93 -0
- opscloud/skills/__init__.py +91 -0
- opscloud/skills/commands.py +1099 -0
- opscloud/skills/invocation.py +200 -0
- opscloud/skills/load.py +388 -0
- opscloud/skills/loader.py +23 -0
- opscloud/skills/merge.py +65 -0
- opscloud/skills/registry.py +263 -0
- opscloud/skills/sources.py +174 -0
- opscloud/skills/trust.py +417 -0
- opscloud/state/__init__.py +142 -0
- opscloud/state/base.py +154 -0
- opscloud/state/cloud_context.py +262 -0
- opscloud/state/goal_channels.py +124 -0
- opscloud/state/goal_state_limits.py +175 -0
- opscloud/state/resume_state.py +58 -0
- opscloud/state/service_context.py +281 -0
- opscloud/state/session.py +957 -0
- opscloud/state/state_migration.py +86 -0
- opscloud/subagents/__init__.py +30 -0
- opscloud/subagents/loader.py +331 -0
- opscloud/subagents/subagents_parser.py +117 -0
- opscloud/subagents/types.py +25 -0
- opscloud/tools/__init__.py +36 -0
- opscloud/tools/catalog.py +37 -0
- opscloud/tools/display.py +24 -0
- opscloud/tools/fetch_url.py +176 -0
- opscloud/tools/goal_tools.py +423 -0
- opscloud/tools/registry.py +105 -0
- opscloud/tools/thread.py +32 -0
- opscloud/tools/web_search.py +215 -0
- opscloud/ui/__init__.py +119 -0
- opscloud/ui/app.py +5400 -0
- opscloud/ui/app.tcss +479 -0
- opscloud/ui/clipboard.py +147 -0
- opscloud/ui/command_registry.py +482 -0
- opscloud/ui/message_store.py +152 -0
- opscloud/ui/permission_store.py +360 -0
- opscloud/ui/preferences.py +113 -0
- opscloud/ui/prompt_manager.py +96 -0
- opscloud/ui/remote_client.py +505 -0
- opscloud/ui/textual_adapter.py +1269 -0
- opscloud/ui/theme.py +728 -0
- opscloud/ui/ui_help.py +415 -0
- opscloud/ui/widgets/__init__.py +383 -0
- opscloud/ui/widgets/_ask_user_types.py +81 -0
- opscloud/ui/widgets/_copy_spans.py +52 -0
- opscloud/ui/widgets/_inline_prompt.py +290 -0
- opscloud/ui/widgets/_js_eval_display.py +139 -0
- opscloud/ui/widgets/_links.py +175 -0
- opscloud/ui/widgets/_tool_stream.py +308 -0
- opscloud/ui/widgets/agent_selector.py +297 -0
- opscloud/ui/widgets/approval.py +741 -0
- opscloud/ui/widgets/ask_user.py +539 -0
- opscloud/ui/widgets/auth.py +695 -0
- opscloud/ui/widgets/auth_manager.py +214 -0
- opscloud/ui/widgets/auto_mode_notice.py +146 -0
- opscloud/ui/widgets/autocomplete.py +709 -0
- opscloud/ui/widgets/chat_input.py +805 -0
- opscloud/ui/widgets/cloud_selector.py +528 -0
- opscloud/ui/widgets/config_manager.py +652 -0
- opscloud/ui/widgets/debug_console.py +1269 -0
- opscloud/ui/widgets/devops_renderers.py +184 -0
- opscloud/ui/widgets/diff.py +237 -0
- opscloud/ui/widgets/effort_selector.py +166 -0
- opscloud/ui/widgets/goal_review.py +612 -0
- opscloud/ui/widgets/goal_status.py +57 -0
- opscloud/ui/widgets/infra_panel.py +141 -0
- opscloud/ui/widgets/install_confirm.py +114 -0
- opscloud/ui/widgets/loading.py +170 -0
- opscloud/ui/widgets/mcp_viewer.py +1204 -0
- opscloud/ui/widgets/messages.py +4461 -0
- opscloud/ui/widgets/model_selector.py +863 -0
- opscloud/ui/widgets/notification_center.py +108 -0
- opscloud/ui/widgets/notification_settings.py +119 -0
- opscloud/ui/widgets/operation_card.py +106 -0
- opscloud/ui/widgets/permissions_manager.py +667 -0
- opscloud/ui/widgets/plugin_manager.py +1212 -0
- opscloud/ui/widgets/pool_selector.py +376 -0
- opscloud/ui/widgets/preamble.py +53 -0
- opscloud/ui/widgets/skills_viewer.py +432 -0
- opscloud/ui/widgets/status.py +1037 -0
- opscloud/ui/widgets/subagent_panel.py +927 -0
- opscloud/ui/widgets/theme_selector.py +232 -0
- opscloud/ui/widgets/thread_selector.py +358 -0
- opscloud/ui/widgets/toast.py +31 -0
- opscloud/ui/widgets/tool_display.py +325 -0
- opscloud/ui/widgets/tool_renderers.py +234 -0
- opscloud/ui/widgets/tool_widgets.py +278 -0
- opscloud/ui/widgets/welcome.py +421 -0
- opscloud/ui/widgets/welcome_popup.py +102 -0
- opscloud/utils/__init__.py +24 -0
- opscloud/utils/cost_estimation.py +169 -0
- opscloud/utils/git.py +390 -0
- opscloud/utils/logger.py +571 -0
- opscloud/utils/session_stats.py +115 -0
- opscloud/utils/startup_error.py +37 -0
- talkops_opscloud-0.3.0b1.dist-info/METADATA +449 -0
- talkops_opscloud-0.3.0b1.dist-info/RECORD +309 -0
- talkops_opscloud-0.3.0b1.dist-info/WHEEL +4 -0
- talkops_opscloud-0.3.0b1.dist-info/entry_points.txt +2 -0
- talkops_opscloud-0.3.0b1.dist-info/licenses/LICENSE +175 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Tool-specific approval widgets for HITL display."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from textual.containers import Vertical
|
|
9
|
+
from textual.content import Content
|
|
10
|
+
from textual.widgets import Markdown, Static
|
|
11
|
+
|
|
12
|
+
from opscloud.ui import theme
|
|
13
|
+
from opscloud.file_ops import is_sensitive_file_path
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from textual.app import ComposeResult
|
|
17
|
+
|
|
18
|
+
_CREDENTIAL_NOTICE = "Contents hidden — file may contain credentials"
|
|
19
|
+
|
|
20
|
+
_MAX_VALUE_LEN = 200
|
|
21
|
+
_MAX_LINES = 30
|
|
22
|
+
_MAX_DIFF_LINES = 50
|
|
23
|
+
_MAX_PREVIEW_LINES = 20
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def format_display_content(content: object) -> str:
|
|
27
|
+
"""Coerce arbitrary tool-arg content into a displayable string."""
|
|
28
|
+
if isinstance(content, str):
|
|
29
|
+
return content
|
|
30
|
+
try:
|
|
31
|
+
return json.dumps(content, ensure_ascii=False, indent=2)
|
|
32
|
+
except (TypeError, ValueError, RecursionError):
|
|
33
|
+
return str(content)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _format_stats(additions: int, deletions: int) -> Content:
|
|
37
|
+
"""Format addition/deletion stats as styled Content."""
|
|
38
|
+
colors = theme.get_theme_colors()
|
|
39
|
+
parts: list[str | tuple[str, str] | Content] = []
|
|
40
|
+
if additions:
|
|
41
|
+
parts.append((f"+{additions}", colors.success))
|
|
42
|
+
if deletions:
|
|
43
|
+
if parts:
|
|
44
|
+
parts.append(" ")
|
|
45
|
+
parts.append((f"-{deletions}", colors.error))
|
|
46
|
+
return Content.assemble(*parts) if parts else Content("")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _file_header(
|
|
50
|
+
file_path: str, additions: int = 0, deletions: int = 0
|
|
51
|
+
) -> ComposeResult:
|
|
52
|
+
"""Yield the `File:` path header with optional `+N -M` stats."""
|
|
53
|
+
stats = _format_stats(additions, deletions)
|
|
54
|
+
yield Static(
|
|
55
|
+
Content.assemble(
|
|
56
|
+
Content.from_markup("[bold cyan]File:[/bold cyan] $path ", path=file_path),
|
|
57
|
+
stats,
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
yield Static("")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _count_diff_stats(
|
|
64
|
+
diff_lines: list[str], old_string: str, new_string: str
|
|
65
|
+
) -> tuple[int, int]:
|
|
66
|
+
"""Count additions and deletions from diff data."""
|
|
67
|
+
if diff_lines:
|
|
68
|
+
additions = sum(
|
|
69
|
+
1
|
|
70
|
+
for line in diff_lines
|
|
71
|
+
if line.startswith("+") and not line.startswith("+++")
|
|
72
|
+
)
|
|
73
|
+
deletions = sum(
|
|
74
|
+
1
|
|
75
|
+
for line in diff_lines
|
|
76
|
+
if line.startswith("-") and not line.startswith("---")
|
|
77
|
+
)
|
|
78
|
+
else:
|
|
79
|
+
additions = new_string.count("\n") + 1 if new_string else 0
|
|
80
|
+
deletions = old_string.count("\n") + 1 if old_string else 0
|
|
81
|
+
return additions, deletions
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ToolApprovalWidget(Vertical):
|
|
85
|
+
"""Base class for tool approval widgets."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, data: dict[str, Any]) -> None:
|
|
88
|
+
"""Initialize the tool approval widget with data."""
|
|
89
|
+
super().__init__(classes="tool-approval-widget")
|
|
90
|
+
self.data = data
|
|
91
|
+
|
|
92
|
+
def compose(self) -> ComposeResult:
|
|
93
|
+
"""Default compose - override in subclasses."""
|
|
94
|
+
yield Static("Tool details not available", classes="approval-description")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class GenericApprovalWidget(ToolApprovalWidget):
|
|
98
|
+
"""Generic approval widget for unknown tools."""
|
|
99
|
+
|
|
100
|
+
def compose(self) -> ComposeResult:
|
|
101
|
+
"""Compose the generic tool display."""
|
|
102
|
+
for key, value in self.data.items():
|
|
103
|
+
if value is None:
|
|
104
|
+
continue
|
|
105
|
+
value_str = str(value)
|
|
106
|
+
if len(value_str) > _MAX_VALUE_LEN:
|
|
107
|
+
hidden = len(value_str) - _MAX_VALUE_LEN
|
|
108
|
+
value_str = value_str[:_MAX_VALUE_LEN] + f"... ({hidden} more chars)"
|
|
109
|
+
yield Static(
|
|
110
|
+
f"{key}: {value_str}", markup=False, classes="approval-description"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class WriteFileApprovalWidget(ToolApprovalWidget):
|
|
115
|
+
"""Approval widget for write_file / write_to_file - shows file content with syntax highlighting."""
|
|
116
|
+
|
|
117
|
+
def compose(self) -> ComposeResult:
|
|
118
|
+
"""Compose the file content display with syntax highlighting."""
|
|
119
|
+
file_path = self.data.get("file_path", "")
|
|
120
|
+
content = format_display_content(self.data.get("content", ""))
|
|
121
|
+
file_extension = self.data.get("file_extension", "text")
|
|
122
|
+
|
|
123
|
+
# Never render the contents of credential files (e.g. `.env`).
|
|
124
|
+
if is_sensitive_file_path(file_path):
|
|
125
|
+
yield from _file_header(file_path)
|
|
126
|
+
yield Static(Content.styled(_CREDENTIAL_NOTICE, "dim"))
|
|
127
|
+
else:
|
|
128
|
+
lines = content.split("\n")
|
|
129
|
+
total_lines = len(lines)
|
|
130
|
+
|
|
131
|
+
yield from _file_header(file_path, additions=total_lines if content else 0)
|
|
132
|
+
|
|
133
|
+
if total_lines > _MAX_LINES:
|
|
134
|
+
shown_lines = lines[:_MAX_LINES]
|
|
135
|
+
remaining = total_lines - _MAX_LINES
|
|
136
|
+
truncated_content = (
|
|
137
|
+
"\n".join(shown_lines) + f"\n... ({remaining} more lines)"
|
|
138
|
+
)
|
|
139
|
+
yield Markdown(f"```{file_extension}\n{truncated_content}\n```")
|
|
140
|
+
else:
|
|
141
|
+
yield Markdown(f"```{file_extension}\n{content}\n```")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class EditFileApprovalWidget(ToolApprovalWidget):
|
|
145
|
+
"""Approval widget for edit_file / replace_file_content - shows clean diff with colors."""
|
|
146
|
+
|
|
147
|
+
def compose(self) -> ComposeResult:
|
|
148
|
+
"""Compose the diff display with colored additions and deletions."""
|
|
149
|
+
file_path = self.data.get("file_path", "")
|
|
150
|
+
diff_lines = self.data.get("diff_lines", [])
|
|
151
|
+
old_string = format_display_content(self.data.get("old_string", ""))
|
|
152
|
+
new_string = format_display_content(self.data.get("new_string", ""))
|
|
153
|
+
|
|
154
|
+
additions, deletions = _count_diff_stats(diff_lines, old_string, new_string)
|
|
155
|
+
yield from _file_header(file_path, additions, deletions)
|
|
156
|
+
|
|
157
|
+
# Never render the diff of credential files (e.g. `.env`)
|
|
158
|
+
if is_sensitive_file_path(file_path):
|
|
159
|
+
yield Static(Content.styled(_CREDENTIAL_NOTICE, "dim"))
|
|
160
|
+
elif not diff_lines and not old_string and not new_string:
|
|
161
|
+
yield Static("No changes to display", classes="approval-description")
|
|
162
|
+
elif diff_lines:
|
|
163
|
+
yield from self._render_diff_lines_only(diff_lines)
|
|
164
|
+
else:
|
|
165
|
+
yield from self._render_strings_only(old_string, new_string)
|
|
166
|
+
|
|
167
|
+
def _render_diff_lines_only(self, diff_lines: list[str]) -> ComposeResult:
|
|
168
|
+
"""Render unified diff lines."""
|
|
169
|
+
lines_shown = 0
|
|
170
|
+
|
|
171
|
+
for line in diff_lines:
|
|
172
|
+
if lines_shown >= _MAX_DIFF_LINES:
|
|
173
|
+
yield Static(
|
|
174
|
+
Content.styled(
|
|
175
|
+
f"... ({len(diff_lines) - lines_shown} more lines)", "dim"
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
break
|
|
179
|
+
|
|
180
|
+
if line.startswith(("@@", "---", "+++")):
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
widget = self._render_diff_line(line)
|
|
184
|
+
if widget:
|
|
185
|
+
yield widget
|
|
186
|
+
lines_shown += 1
|
|
187
|
+
|
|
188
|
+
def _render_strings_only(self, old_string: str, new_string: str) -> ComposeResult:
|
|
189
|
+
"""Render old/new strings without stats."""
|
|
190
|
+
colors = theme.get_theme_colors()
|
|
191
|
+
if old_string:
|
|
192
|
+
yield Static(Content.styled("Removing:", f"bold {colors.error}"))
|
|
193
|
+
yield from self._render_string_lines(old_string, is_addition=False)
|
|
194
|
+
yield Static("")
|
|
195
|
+
|
|
196
|
+
if new_string:
|
|
197
|
+
yield Static(Content.styled("Adding:", f"bold {colors.success}"))
|
|
198
|
+
yield from self._render_string_lines(new_string, is_addition=True)
|
|
199
|
+
|
|
200
|
+
@staticmethod
|
|
201
|
+
def _render_diff_line(line: str) -> Static | None:
|
|
202
|
+
"""Render a single diff line with appropriate styling."""
|
|
203
|
+
raw = line[1:] if len(line) > 1 else ""
|
|
204
|
+
|
|
205
|
+
if line.startswith("-"):
|
|
206
|
+
return Static(
|
|
207
|
+
Content.from_markup("- $text", text=raw), classes="diff-removed"
|
|
208
|
+
)
|
|
209
|
+
if line.startswith("+"):
|
|
210
|
+
return Static(
|
|
211
|
+
Content.from_markup("+ $text", text=raw), classes="diff-added"
|
|
212
|
+
)
|
|
213
|
+
if line.startswith(" "):
|
|
214
|
+
return Static(
|
|
215
|
+
Content.from_markup(" $text", text=raw), classes="diff-context"
|
|
216
|
+
)
|
|
217
|
+
if line.strip():
|
|
218
|
+
return Static(line, markup=False)
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
@staticmethod
|
|
222
|
+
def _render_string_lines(text: str, *, is_addition: bool) -> ComposeResult:
|
|
223
|
+
"""Render lines from a string with appropriate styling."""
|
|
224
|
+
lines = text.split("\n")
|
|
225
|
+
sign = "+" if is_addition else "-"
|
|
226
|
+
cls = "diff-added" if is_addition else "diff-removed"
|
|
227
|
+
|
|
228
|
+
for line in lines[:_MAX_PREVIEW_LINES]:
|
|
229
|
+
yield Static(Content.from_markup(f"{sign} $text", text=line), classes=cls)
|
|
230
|
+
|
|
231
|
+
if len(lines) > _MAX_PREVIEW_LINES:
|
|
232
|
+
remaining = len(lines) - _MAX_PREVIEW_LINES
|
|
233
|
+
yield Static(Content.styled(f"... ({remaining} more lines)", "dim"))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class TaskApprovalWidget(ToolApprovalWidget):
|
|
237
|
+
"""Approval widget for task tool call (subagent dispatch)."""
|
|
238
|
+
|
|
239
|
+
def compose(self) -> ComposeResult:
|
|
240
|
+
subagent_type = (
|
|
241
|
+
self.data.get("subagent_type")
|
|
242
|
+
or self.data.get("agent_name")
|
|
243
|
+
or self.data.get("subagent")
|
|
244
|
+
or "general-purpose"
|
|
245
|
+
)
|
|
246
|
+
description = (
|
|
247
|
+
self.data.get("description")
|
|
248
|
+
or self.data.get("prompt")
|
|
249
|
+
or self.data.get("task")
|
|
250
|
+
or "N/A"
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
warning_msg = "Subagent will have access to file operations and shell commands"
|
|
254
|
+
separator = "─" * 40
|
|
255
|
+
|
|
256
|
+
yield Static(
|
|
257
|
+
Content.from_markup("Subagent Type: [bold]$sub[/bold]\n", sub=subagent_type),
|
|
258
|
+
classes="task-subagent-type",
|
|
259
|
+
)
|
|
260
|
+
yield Static(
|
|
261
|
+
Content.styled(f"⚠ {warning_msg} ⚠\n", "yellow"),
|
|
262
|
+
classes="task-warning",
|
|
263
|
+
)
|
|
264
|
+
yield Static(
|
|
265
|
+
Content.from_markup("Task Instructions:\n[dim]$sep[/dim]", sep=separator),
|
|
266
|
+
classes="task-instructions-header",
|
|
267
|
+
)
|
|
268
|
+
yield Static(description, classes="task-instructions-body")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
__all__ = [
|
|
272
|
+
"EditFileApprovalWidget",
|
|
273
|
+
"GenericApprovalWidget",
|
|
274
|
+
"TaskApprovalWidget",
|
|
275
|
+
"ToolApprovalWidget",
|
|
276
|
+
"WriteFileApprovalWidget",
|
|
277
|
+
"format_display_content",
|
|
278
|
+
]
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
"""Welcome banner & first-run onboarding widget for OpsCloud TUI.
|
|
2
|
+
|
|
3
|
+
Displays a Claude-Code-inspired two-panel split layout:
|
|
4
|
+
Left — OpsCloud ASCII art logo, version, project directory, connection status.
|
|
5
|
+
Right — Prompt help, discovered skills list, loaded subagents list.
|
|
6
|
+
|
|
7
|
+
Lists that exceed MAX_DISPLAY_ITEMS show a clickable "…more" span that
|
|
8
|
+
opens a WelcomeDetailPopup modal with the full list and descriptions.
|
|
9
|
+
|
|
10
|
+
The left panel status indicator transitions from yellow "starting" to
|
|
11
|
+
green "connected" when the agent server becomes ready.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from rich.console import ConsoleRenderable, Group
|
|
21
|
+
from rich.style import Style
|
|
22
|
+
from rich.text import Text
|
|
23
|
+
from textual.app import ComposeResult
|
|
24
|
+
from textual.containers import Horizontal
|
|
25
|
+
from textual.reactive import reactive
|
|
26
|
+
from textual.widget import Widget
|
|
27
|
+
from textual.widgets import Static
|
|
28
|
+
|
|
29
|
+
from opscloud.ui.theme import DC_CYAN, DC_TEAL
|
|
30
|
+
from opscloud.ui.widgets.welcome_popup import WelcomeDetailPopup
|
|
31
|
+
from opscloud.utils.logger import get_logger
|
|
32
|
+
|
|
33
|
+
logger = get_logger(__name__)
|
|
34
|
+
|
|
35
|
+
# Maximum number of items shown inline before truncating with "…more".
|
|
36
|
+
MAX_DISPLAY_ITEMS = 5
|
|
37
|
+
|
|
38
|
+
# Maximum character width for the inline list before truncating.
|
|
39
|
+
# Applied after MAX_DISPLAY_ITEMS to catch long names that wrap.
|
|
40
|
+
MAX_INLINE_CHARS = 38
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ── OpsCloud ASCII Art Logo ───────────────────────────────────────
|
|
44
|
+
# Multi-line ASCII art wordmark for OpsCloud in classic FIGlet lettering.
|
|
45
|
+
# Rendered with brand teal & cyan accents, with a travelling light beam
|
|
46
|
+
# that animates across the wordmark from 'O' to 'D'.
|
|
47
|
+
|
|
48
|
+
_LOGO_LINES: list[str] = [
|
|
49
|
+
r" ___ ____ ____ ____ _ ___ _ _ ____ ",
|
|
50
|
+
r" / _ \| _ \/ ___| / ___| | / _ \| | | | _ \ ",
|
|
51
|
+
r"| | | | |_) \___ \| | | | | | | | | | | | | |",
|
|
52
|
+
r"| |_| | __/ ___) | |___| |__| |_| | |_| | |_| |",
|
|
53
|
+
r" \___/|_| |____/ \____|_____\___/ \___/|____/ ",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _build_logo_text(beam_x: int = -10, status: str = "connected") -> Text:
|
|
58
|
+
"""Return an ASCII art wordmark for OpsCloud with an animated light beam.
|
|
59
|
+
|
|
60
|
+
When status is 'connected', a vibrant green ray sweeps from 'O' to 'D'.
|
|
61
|
+
When 'starting', a warm amber ray pulses across the letters.
|
|
62
|
+
When 'error', an alert red beam scans across.
|
|
63
|
+
"""
|
|
64
|
+
t = Text()
|
|
65
|
+
for row_idx, line in enumerate(_LOGO_LINES):
|
|
66
|
+
for x, ch in enumerate(line):
|
|
67
|
+
if ch == " ":
|
|
68
|
+
t.append(" ")
|
|
69
|
+
continue
|
|
70
|
+
dist = abs(x - beam_x)
|
|
71
|
+
if status == "connected":
|
|
72
|
+
if dist <= 1:
|
|
73
|
+
t.append(ch, style="bold #FFFFFF")
|
|
74
|
+
elif dist <= 2:
|
|
75
|
+
t.append(ch, style="bold #4ADE80")
|
|
76
|
+
elif dist <= 3:
|
|
77
|
+
t.append(ch, style="bold #22C55E")
|
|
78
|
+
elif dist <= 4:
|
|
79
|
+
t.append(ch, style="#16A34A")
|
|
80
|
+
else:
|
|
81
|
+
style = f"bold {DC_TEAL}" if x < 19 else f"bold {DC_CYAN}"
|
|
82
|
+
t.append(ch, style=style)
|
|
83
|
+
elif status == "starting":
|
|
84
|
+
if dist <= 1:
|
|
85
|
+
t.append(ch, style="bold #FFFFFF")
|
|
86
|
+
elif dist <= 2:
|
|
87
|
+
t.append(ch, style="bold #FEF08A")
|
|
88
|
+
elif dist <= 3:
|
|
89
|
+
t.append(ch, style="bold #FACC15")
|
|
90
|
+
elif dist <= 4:
|
|
91
|
+
t.append(ch, style="#CA8A04")
|
|
92
|
+
else:
|
|
93
|
+
t.append(ch, style="dim #D97706")
|
|
94
|
+
else: # error
|
|
95
|
+
if dist <= 1:
|
|
96
|
+
t.append(ch, style="bold #FFFFFF")
|
|
97
|
+
elif dist <= 2:
|
|
98
|
+
t.append(ch, style="bold #FCA5A5")
|
|
99
|
+
elif dist <= 3:
|
|
100
|
+
t.append(ch, style="bold #EF4444")
|
|
101
|
+
elif dist <= 4:
|
|
102
|
+
t.append(ch, style="#DC2626")
|
|
103
|
+
else:
|
|
104
|
+
t.append(ch, style="dim #991B1B")
|
|
105
|
+
if row_idx < len(_LOGO_LINES) - 1:
|
|
106
|
+
t.append("\n")
|
|
107
|
+
return t
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _collect_skills() -> list[tuple[str, str]]:
|
|
111
|
+
"""Return (name, description) for all discovered skills."""
|
|
112
|
+
try:
|
|
113
|
+
from pathlib import Path
|
|
114
|
+
import opscloud
|
|
115
|
+
from opscloud.config.settings import settings
|
|
116
|
+
from opscloud.skills.loader import list_skills
|
|
117
|
+
|
|
118
|
+
built_in_dir = Path(opscloud.__file__).parent / "built_in_skills"
|
|
119
|
+
user_skills_dir = settings.get_user_skills_dir()
|
|
120
|
+
project_skills_dir = settings.get_project_skills_dir()
|
|
121
|
+
project_root = settings.project_root
|
|
122
|
+
|
|
123
|
+
skills = list_skills(
|
|
124
|
+
built_in_skills_dir=built_in_dir,
|
|
125
|
+
user_skills_dir=user_skills_dir,
|
|
126
|
+
project_skills_dir=project_skills_dir,
|
|
127
|
+
include_plugins=True,
|
|
128
|
+
project_root=project_root,
|
|
129
|
+
)
|
|
130
|
+
return sorted(
|
|
131
|
+
[(s.get("name", ""), s.get("description", "")) for s in skills if s.get("name")],
|
|
132
|
+
key=lambda x: x[0],
|
|
133
|
+
)
|
|
134
|
+
except Exception:
|
|
135
|
+
logger.debug("Could not discover skills for welcome banner", exc_info=True)
|
|
136
|
+
return []
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _collect_subagents() -> list[tuple[str, str]]:
|
|
140
|
+
"""Return (name, description) for all loaded subagents."""
|
|
141
|
+
try:
|
|
142
|
+
from opscloud.config.settings import settings
|
|
143
|
+
from opscloud.subagents import list_subagents
|
|
144
|
+
|
|
145
|
+
effective_root = settings.effective_project_root
|
|
146
|
+
user_dir = settings.get_user_agents_dir() if hasattr(settings, "get_user_agents_dir") else None
|
|
147
|
+
proj_dir = settings.get_project_agents_dir() if hasattr(settings, "get_project_agents_dir") else None
|
|
148
|
+
|
|
149
|
+
metas = list_subagents(
|
|
150
|
+
user_agents_dir=user_dir,
|
|
151
|
+
project_agents_dir=proj_dir,
|
|
152
|
+
project_root=effective_root,
|
|
153
|
+
include_plugins=True,
|
|
154
|
+
)
|
|
155
|
+
seen = {meta["name"]: meta.get("description", "") for meta in metas if meta.get("name")}
|
|
156
|
+
return sorted(seen.items(), key=lambda x: x[0])
|
|
157
|
+
except Exception:
|
|
158
|
+
logger.debug("Could not discover subagents for welcome banner", exc_info=True)
|
|
159
|
+
return []
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _truncate_to_width(
|
|
163
|
+
names: list[str], max_chars: int, max_items: int,
|
|
164
|
+
) -> tuple[list[str], int]:
|
|
165
|
+
"""Return (shown_names, remaining_count) fitting within both limits.
|
|
166
|
+
|
|
167
|
+
Truncates by *item count* first (MAX_DISPLAY_ITEMS) then by cumulative
|
|
168
|
+
*character width* so long agent names don't wrap the line.
|
|
169
|
+
"""
|
|
170
|
+
candidates = names[:max_items]
|
|
171
|
+
result: list[str] = []
|
|
172
|
+
current_len = 0
|
|
173
|
+
for name in candidates:
|
|
174
|
+
sep = 2 if result else 0 # ", " separator
|
|
175
|
+
if current_len + sep + len(name) > max_chars and result:
|
|
176
|
+
break
|
|
177
|
+
result.append(name)
|
|
178
|
+
current_len += sep + len(name)
|
|
179
|
+
remaining = len(names) - len(result)
|
|
180
|
+
return result, remaining
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ── Left panel widget ─────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class _LeftPanel(Widget):
|
|
187
|
+
"""Reactive left panel: animated logo + tagline + connection status.
|
|
188
|
+
|
|
189
|
+
The ``status`` reactive property drives the status indicator colour and
|
|
190
|
+
light-beam animation:
|
|
191
|
+
``"starting"`` → warm amber pulse, yellow dot.
|
|
192
|
+
``"connected"`` → vibrant green stream travelling 'O' to 'D', green dot.
|
|
193
|
+
``"error"`` → red alert scan, red dot.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
DEFAULT_CSS = """
|
|
197
|
+
_LeftPanel {
|
|
198
|
+
width: 51;
|
|
199
|
+
height: auto;
|
|
200
|
+
padding: 0 0 0 1;
|
|
201
|
+
}
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
status: reactive[str] = reactive("starting")
|
|
205
|
+
beam_x: reactive[int] = reactive(-6)
|
|
206
|
+
|
|
207
|
+
def on_mount(self) -> None:
|
|
208
|
+
"""Start the light-beam travelling animation."""
|
|
209
|
+
self._anim_timer = self.set_interval(0.08, self._advance_beam)
|
|
210
|
+
|
|
211
|
+
def on_unmount(self) -> None:
|
|
212
|
+
"""Stop animation timer on unmount."""
|
|
213
|
+
if hasattr(self, "_anim_timer"):
|
|
214
|
+
self._anim_timer.stop()
|
|
215
|
+
|
|
216
|
+
def _advance_beam(self) -> None:
|
|
217
|
+
"""Advance the light beam position from left to right."""
|
|
218
|
+
next_x = self.beam_x + 2
|
|
219
|
+
# Logo is 48 chars wide. Travel past the end (up to 64) for a short pause.
|
|
220
|
+
if next_x > 64:
|
|
221
|
+
self.beam_x = -6
|
|
222
|
+
else:
|
|
223
|
+
self.beam_x = next_x
|
|
224
|
+
|
|
225
|
+
def render(self) -> ConsoleRenderable:
|
|
226
|
+
"""Build the left-panel content."""
|
|
227
|
+
logo = _build_logo_text(beam_x=self.beam_x, status=self.status)
|
|
228
|
+
|
|
229
|
+
# Tagline aligned directly under the 'OUD' in OPSCLOUD (cols 34-47)
|
|
230
|
+
tagline = Text()
|
|
231
|
+
tagline.append(" " * 34, style="")
|
|
232
|
+
tagline.append("by talkops.ai\n", style=f"dim italic {DC_CYAN}")
|
|
233
|
+
|
|
234
|
+
return Group(logo, tagline)
|
|
235
|
+
|
|
236
|
+
def watch_status(self, _old: str, _new: str) -> None:
|
|
237
|
+
"""Auto-refresh when status changes."""
|
|
238
|
+
self.refresh()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ── Main banner ───────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class WelcomeBanner(Widget):
|
|
245
|
+
"""Two-panel welcome banner card shown on app startup.
|
|
246
|
+
|
|
247
|
+
Left panel: OpsCloud ASCII art logo, version, project dir, status.
|
|
248
|
+
Right panel: prompt help, skills list, subagents list.
|
|
249
|
+
|
|
250
|
+
The banner auto-dismisses on first user submission; no explicit
|
|
251
|
+
dismiss button.
|
|
252
|
+
"""
|
|
253
|
+
|
|
254
|
+
DEFAULT_CSS = """
|
|
255
|
+
WelcomeBanner {
|
|
256
|
+
width: 100%;
|
|
257
|
+
height: auto;
|
|
258
|
+
margin: 0 0 1 0;
|
|
259
|
+
align-horizontal: center;
|
|
260
|
+
background: transparent;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
WelcomeBanner #welcome-card {
|
|
264
|
+
width: 114;
|
|
265
|
+
max-width: 100%;
|
|
266
|
+
height: auto;
|
|
267
|
+
border: round $primary;
|
|
268
|
+
background: transparent;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
WelcomeBanner _LeftPanel {
|
|
272
|
+
width: 51;
|
|
273
|
+
height: auto;
|
|
274
|
+
padding: 0 0 0 1;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
WelcomeBanner #welcome-right {
|
|
278
|
+
width: 1fr;
|
|
279
|
+
height: auto;
|
|
280
|
+
padding: 0 1 0 1;
|
|
281
|
+
border-left: solid $primary;
|
|
282
|
+
}
|
|
283
|
+
"""
|
|
284
|
+
|
|
285
|
+
# Store item data for popup actions
|
|
286
|
+
_skills_data: list[tuple[str, str]]
|
|
287
|
+
_subagents_data: list[tuple[str, str]]
|
|
288
|
+
|
|
289
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
290
|
+
super().__init__(**kwargs)
|
|
291
|
+
self.border_title = None
|
|
292
|
+
self._skills_data = []
|
|
293
|
+
self._subagents_data = []
|
|
294
|
+
|
|
295
|
+
def compose(self) -> ComposeResult:
|
|
296
|
+
# Collect data
|
|
297
|
+
self._skills_data = _collect_skills()
|
|
298
|
+
self._subagents_data = _collect_subagents()
|
|
299
|
+
|
|
300
|
+
with Horizontal(id="welcome-card"):
|
|
301
|
+
yield _LeftPanel(id="welcome-left")
|
|
302
|
+
# Use _ClickablePanel so @click actions resolve on the widget
|
|
303
|
+
# that actually renders the text with clickable "…more" spans.
|
|
304
|
+
yield _ClickablePanel(
|
|
305
|
+
self._build_right_panel(),
|
|
306
|
+
skills_data=self._skills_data,
|
|
307
|
+
subagents_data=self._subagents_data,
|
|
308
|
+
id="welcome-right",
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
# ── Public API ────────────────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
def update_cloud(self, profile: str = "", region: str = "") -> None:
|
|
314
|
+
"""No-op: cloud context is displayed on the status bar."""
|
|
315
|
+
pass
|
|
316
|
+
|
|
317
|
+
def set_connected(self) -> None:
|
|
318
|
+
"""Transition the status indicator to green 'connected'."""
|
|
319
|
+
try:
|
|
320
|
+
left = self.query_one(_LeftPanel)
|
|
321
|
+
left.status = "connected"
|
|
322
|
+
except Exception:
|
|
323
|
+
logger.debug("Could not update banner status", exc_info=True)
|
|
324
|
+
|
|
325
|
+
def set_error(self) -> None:
|
|
326
|
+
"""Transition the status indicator to red 'error'."""
|
|
327
|
+
try:
|
|
328
|
+
left = self.query_one(_LeftPanel)
|
|
329
|
+
left.status = "error"
|
|
330
|
+
except Exception:
|
|
331
|
+
logger.debug("Could not update banner status", exc_info=True)
|
|
332
|
+
|
|
333
|
+
# ── Private builders ──────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
def _build_right_panel(self) -> Text:
|
|
336
|
+
"""Prompt help + skills + subagents + cloud profile."""
|
|
337
|
+
text = Text()
|
|
338
|
+
|
|
339
|
+
# Quick-start help
|
|
340
|
+
text.append("Type a prompt", style="bold")
|
|
341
|
+
text.append(" or use ", style="dim")
|
|
342
|
+
text.append("/help", style="bold cyan")
|
|
343
|
+
text.append(" for commands. ", style="dim")
|
|
344
|
+
text.append("!cmd", style="bold green")
|
|
345
|
+
text.append(" runs shell.\n", style="dim")
|
|
346
|
+
|
|
347
|
+
# Skills section
|
|
348
|
+
text.append("\n")
|
|
349
|
+
text.append("Skills ", style="bold #BB9AF7")
|
|
350
|
+
if self._skills_data:
|
|
351
|
+
names = [n for n, _ in self._skills_data]
|
|
352
|
+
shown, remaining = _truncate_to_width(names, MAX_INLINE_CHARS, MAX_DISPLAY_ITEMS)
|
|
353
|
+
text.append(", ".join(shown), style="#9ECE6A")
|
|
354
|
+
if remaining:
|
|
355
|
+
text.append(" ", style="")
|
|
356
|
+
text.append(
|
|
357
|
+
f"…{remaining} more",
|
|
358
|
+
style=Style(
|
|
359
|
+
bold=True, underline=True, color="rgb(122,162,247)",
|
|
360
|
+
meta={"@click": "show_skills_popup()"},
|
|
361
|
+
),
|
|
362
|
+
)
|
|
363
|
+
else:
|
|
364
|
+
text.append("none discovered", style="dim italic")
|
|
365
|
+
text.append("\n", style="")
|
|
366
|
+
|
|
367
|
+
# Agents section
|
|
368
|
+
text.append("\n")
|
|
369
|
+
text.append("Agents ", style="bold #BB9AF7")
|
|
370
|
+
if self._subagents_data:
|
|
371
|
+
names = [n for n, _ in self._subagents_data]
|
|
372
|
+
shown, remaining = _truncate_to_width(names, MAX_INLINE_CHARS, MAX_DISPLAY_ITEMS)
|
|
373
|
+
text.append(", ".join(shown), style="#7AA2F7")
|
|
374
|
+
if remaining:
|
|
375
|
+
text.append(" ", style="")
|
|
376
|
+
text.append(
|
|
377
|
+
f"…{remaining} more",
|
|
378
|
+
style=Style(
|
|
379
|
+
bold=True, underline=True, color="rgb(122,162,247)",
|
|
380
|
+
meta={"@click": "show_agents_popup()"},
|
|
381
|
+
),
|
|
382
|
+
)
|
|
383
|
+
else:
|
|
384
|
+
text.append("none loaded", style="dim italic")
|
|
385
|
+
text.append("\n", style="")
|
|
386
|
+
|
|
387
|
+
return text
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
class _ClickablePanel(Static):
|
|
391
|
+
"""Static widget that handles '…more' click actions.
|
|
392
|
+
|
|
393
|
+
Textual dispatches @click actions from Rich text meta on the widget
|
|
394
|
+
that *renders* the text. Since the right panel text lives inside a
|
|
395
|
+
Static child (not the parent WelcomeBanner), the action methods must
|
|
396
|
+
be defined here so Textual can find them.
|
|
397
|
+
"""
|
|
398
|
+
|
|
399
|
+
def __init__(
|
|
400
|
+
self,
|
|
401
|
+
content: ConsoleRenderable | str,
|
|
402
|
+
*,
|
|
403
|
+
skills_data: list[tuple[str, str]],
|
|
404
|
+
subagents_data: list[tuple[str, str]],
|
|
405
|
+
**kwargs: Any,
|
|
406
|
+
) -> None:
|
|
407
|
+
super().__init__(content, **kwargs)
|
|
408
|
+
self._skills_data = skills_data
|
|
409
|
+
self._subagents_data = subagents_data
|
|
410
|
+
|
|
411
|
+
async def action_show_skills_popup(self) -> None:
|
|
412
|
+
"""Push the skills detail popup."""
|
|
413
|
+
await self.app.push_screen(
|
|
414
|
+
WelcomeDetailPopup(title="Discovered Skills", items=self._skills_data)
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
async def action_show_agents_popup(self) -> None:
|
|
418
|
+
"""Push the subagents detail popup."""
|
|
419
|
+
await self.app.push_screen(
|
|
420
|
+
WelcomeDetailPopup(title="Loaded Subagents", items=self._subagents_data)
|
|
421
|
+
)
|