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.
Files changed (309) hide show
  1. opscloud/__init__.py +5 -0
  2. opscloud/__main__.py +6 -0
  3. opscloud/_constants.py +53 -0
  4. opscloud/_debug.py +145 -0
  5. opscloud/_debug_buffer.py +250 -0
  6. opscloud/_textual_patches.py +367 -0
  7. opscloud/_version.py +15 -0
  8. opscloud/agent/__init__.py +27 -0
  9. opscloud/agent/config.py +168 -0
  10. opscloud/agent/factory.py +1103 -0
  11. opscloud/approval_mode.py +44 -0
  12. opscloud/backend/__init__.py +49 -0
  13. opscloud/backend/composite.py +89 -0
  14. opscloud/backend/local.py +183 -0
  15. opscloud/backend/registry.py +79 -0
  16. opscloud/backend/sandbox_config.py +107 -0
  17. opscloud/backend/sandbox_factory.py +432 -0
  18. opscloud/built_in_skills/remember/SKILL.md +124 -0
  19. opscloud/built_in_skills/skill-creator/SKILL.md +348 -0
  20. opscloud/built_in_skills/skill-creator/scripts/init_skill.py +371 -0
  21. opscloud/built_in_skills/skill-creator/scripts/quick_validate.py +165 -0
  22. opscloud/cli/__init__.py +1 -0
  23. opscloud/cli/commands/__init__.py +15 -0
  24. opscloud/cli/commands/auth.py +45 -0
  25. opscloud/cli/commands/doctor.py +100 -0
  26. opscloud/cli/commands/mcp.py +137 -0
  27. opscloud/cli/commands/threads.py +51 -0
  28. opscloud/cli/interactive.py +154 -0
  29. opscloud/cli/main.py +903 -0
  30. opscloud/cli/non_interactive.py +425 -0
  31. opscloud/cli/server_manager.py +58 -0
  32. opscloud/commands/__init__.py +19 -0
  33. opscloud/commands/_base.py +110 -0
  34. opscloud/commands/_guards.py +52 -0
  35. opscloud/commands/_router.py +146 -0
  36. opscloud/commands/_types.py +36 -0
  37. opscloud/commands/core/__init__.py +47 -0
  38. opscloud/commands/core/auth.py +94 -0
  39. opscloud/commands/core/bug.py +46 -0
  40. opscloud/commands/core/clear.py +106 -0
  41. opscloud/commands/core/cloud.py +185 -0
  42. opscloud/commands/core/compact.py +128 -0
  43. opscloud/commands/core/config_cmd.py +120 -0
  44. opscloud/commands/core/context.py +220 -0
  45. opscloud/commands/core/cost.py +44 -0
  46. opscloud/commands/core/doctor.py +535 -0
  47. opscloud/commands/core/effort.py +108 -0
  48. opscloud/commands/core/exit_cmd.py +41 -0
  49. opscloud/commands/core/fast.py +148 -0
  50. opscloud/commands/core/help_cmd.py +94 -0
  51. opscloud/commands/core/mcp.py +134 -0
  52. opscloud/commands/core/model.py +109 -0
  53. opscloud/commands/core/permissions.py +196 -0
  54. opscloud/commands/core/plugins.py +92 -0
  55. opscloud/commands/core/pool.py +108 -0
  56. opscloud/commands/core/resume.py +97 -0
  57. opscloud/commands/core/skills.py +178 -0
  58. opscloud/commands/power/__init__.py +51 -0
  59. opscloud/commands/power/agents.py +98 -0
  60. opscloud/commands/power/btw.py +85 -0
  61. opscloud/commands/power/copy.py +97 -0
  62. opscloud/commands/power/goal.py +548 -0
  63. opscloud/commands/power/loop.py +295 -0
  64. opscloud/commands/power/memory.py +287 -0
  65. opscloud/commands/power/review.py +152 -0
  66. opscloud/commands/power/rubric.py +287 -0
  67. opscloud/commands/power/runtime.py +321 -0
  68. opscloud/commands/power/skill_creator.py +70 -0
  69. opscloud/commands/power/skill_invoke.py +165 -0
  70. opscloud/commands/power/tasks.py +209 -0
  71. opscloud/commands/power/trace.py +117 -0
  72. opscloud/commands/power/ui_toggles.py +135 -0
  73. opscloud/commands/power/version.py +52 -0
  74. opscloud/commands/registry.py +176 -0
  75. opscloud/config/__init__.py +114 -0
  76. opscloud/config/adapters/__init__.py +5 -0
  77. opscloud/config/adapters/postgres.py +776 -0
  78. opscloud/config/adapters/sqlite.py +769 -0
  79. opscloud/config/aws.py +248 -0
  80. opscloud/config/cloud_profiles.py +437 -0
  81. opscloud/config/env_vars.py +81 -0
  82. opscloud/config/langsmith.py +319 -0
  83. opscloud/config/manifest.py +817 -0
  84. opscloud/config/metadata.py +205 -0
  85. opscloud/config/paths.py +604 -0
  86. opscloud/config/plugins.py +130 -0
  87. opscloud/config/settings.py +834 -0
  88. opscloud/config/store.py +453 -0
  89. opscloud/config/store_factory.py +97 -0
  90. opscloud/config/toml_config.py +562 -0
  91. opscloud/editor.py +173 -0
  92. opscloud/exceptions.py +65 -0
  93. opscloud/file_ops.py +217 -0
  94. opscloud/hooks/__init__.py +139 -0
  95. opscloud/hooks/env.py +44 -0
  96. opscloud/hooks/interrupt.py +121 -0
  97. opscloud/hooks/models/__init__.py +109 -0
  98. opscloud/hooks/models/adapters.py +19 -0
  99. opscloud/hooks/models/config.py +79 -0
  100. opscloud/hooks/models/domain.py +434 -0
  101. opscloud/hooks/models/transport.py +37 -0
  102. opscloud/hooks/tools.py +231 -0
  103. opscloud/input.py +48 -0
  104. opscloud/integrations/__init__.py +80 -0
  105. opscloud/integrations/base.py +153 -0
  106. opscloud/integrations/content_filter.py +119 -0
  107. opscloud/integrations/event_bus.py +480 -0
  108. opscloud/integrations/hooks.py +238 -0
  109. opscloud/integrations/identity.py +181 -0
  110. opscloud/integrations/notifications.py +134 -0
  111. opscloud/integrations/stream_bridge.py +397 -0
  112. opscloud/integrations/thread_store.py +81 -0
  113. opscloud/json_types.py +20 -0
  114. opscloud/mcp/__init__.py +57 -0
  115. opscloud/mcp/config.py +203 -0
  116. opscloud/mcp/discovery.py +229 -0
  117. opscloud/mcp/mcp_info.py +109 -0
  118. opscloud/mcp/middleware.py +102 -0
  119. opscloud/mcp/preload.py +505 -0
  120. opscloud/mcp/raw_config.py +149 -0
  121. opscloud/mcp/semantic_profiler.py +305 -0
  122. opscloud/mcp/session_manager.py +727 -0
  123. opscloud/mcp/trust.py +105 -0
  124. opscloud/memory/__init__.py +27 -0
  125. opscloud/memory/branch.py +61 -0
  126. opscloud/memory/guard.py +19 -0
  127. opscloud/memory/onboarding.py +120 -0
  128. opscloud/memory/registry.py +134 -0
  129. opscloud/memory/store.py +223 -0
  130. opscloud/middleware/__init__.py +106 -0
  131. opscloud/middleware/_repository_bounds.py +459 -0
  132. opscloud/middleware/ask_user.py +152 -0
  133. opscloud/middleware/auto_mode.py +60 -0
  134. opscloud/middleware/auto_mode_hitl.py +2434 -0
  135. opscloud/middleware/compaction.py +566 -0
  136. opscloud/middleware/configurable_model.py +233 -0
  137. opscloud/middleware/cost_tracking.py +1021 -0
  138. opscloud/middleware/glm_stall_recovery.py +109 -0
  139. opscloud/middleware/goal_criteria.py +1234 -0
  140. opscloud/middleware/goal_state_notice.py +712 -0
  141. opscloud/middleware/goal_tools.py +407 -0
  142. opscloud/middleware/headless_mcp_guard.py +88 -0
  143. opscloud/middleware/jev_model_router.py +1018 -0
  144. opscloud/middleware/local_context.py +460 -0
  145. opscloud/middleware/mcp_context.py +151 -0
  146. opscloud/middleware/mcp_middleware.py +146 -0
  147. opscloud/middleware/memory_guard.py +393 -0
  148. opscloud/middleware/model_retry.py +205 -0
  149. opscloud/middleware/registry.py +137 -0
  150. opscloud/middleware/reliable_rubric.py +709 -0
  151. opscloud/middleware/resume_state.py +91 -0
  152. opscloud/middleware/server_hooks.py +1189 -0
  153. opscloud/middleware/shell_allow_list.py +90 -0
  154. opscloud/middleware/skills.py +382 -0
  155. opscloud/middleware/subagent_artifacts.py +84 -0
  156. opscloud/middleware/subagent_telemetry.py +261 -0
  157. opscloud/middleware/subagents.py +243 -0
  158. opscloud/middleware/tool_filter.py +384 -0
  159. opscloud/middleware/unified_system_message.py +62 -0
  160. opscloud/model/__init__.py +37 -0
  161. opscloud/model/config.py +2992 -0
  162. opscloud/model/factory.py +477 -0
  163. opscloud/model/pool.py +459 -0
  164. opscloud/model/reasoning.py +282 -0
  165. opscloud/offload.py +173 -0
  166. opscloud/output.py +64 -0
  167. opscloud/plugins/__init__.py +189 -0
  168. opscloud/plugins/_json.py +22 -0
  169. opscloud/plugins/adapters/__init__.py +13 -0
  170. opscloud/plugins/adapters/agents.py +112 -0
  171. opscloud/plugins/adapters/commands.py +190 -0
  172. opscloud/plugins/adapters/mcp.py +363 -0
  173. opscloud/plugins/adapters/skills.py +132 -0
  174. opscloud/plugins/commands_cli.py +541 -0
  175. opscloud/plugins/discovery.py +512 -0
  176. opscloud/plugins/manifest.py +335 -0
  177. opscloud/plugins/marketplace.py +893 -0
  178. opscloud/plugins/models.py +334 -0
  179. opscloud/plugins/project_plugins.py +317 -0
  180. opscloud/plugins/protocol.py +31 -0
  181. opscloud/plugins/store.py +905 -0
  182. opscloud/plugins/substitution.py +122 -0
  183. opscloud/project_utils.py +145 -0
  184. opscloud/prompts/__init__.py +37 -0
  185. opscloud/prompts/system.py +483 -0
  186. opscloud/prompts/templates/system_prompt.md +166 -0
  187. opscloud/prompts/types.py +54 -0
  188. opscloud/rubrics/__init__.py +42 -0
  189. opscloud/rubrics/evaluator.py +404 -0
  190. opscloud/rubrics/evidence_extractor.py +229 -0
  191. opscloud/rubrics/generator.py +239 -0
  192. opscloud/rubrics/jev_compiler.py +175 -0
  193. opscloud/rubrics/jev_grader.py +297 -0
  194. opscloud/rubrics/middleware.py +8 -0
  195. opscloud/schema/__init__.py +107 -0
  196. opscloud/schema/interrupts.py +981 -0
  197. opscloud/security/__init__.py +91 -0
  198. opscloud/security/approval_mode.py +528 -0
  199. opscloud/security/approval_mode_source.py +230 -0
  200. opscloud/security/cli_ast_evaluator.py +552 -0
  201. opscloud/security/jev_classifier.py +651 -0
  202. opscloud/security/shell_safety.py +340 -0
  203. opscloud/security/unicode_security.py +439 -0
  204. opscloud/security/url_validation.py +160 -0
  205. opscloud/server/__init__.py +13 -0
  206. opscloud/server/_server_config.py +158 -0
  207. opscloud/server/server.py +242 -0
  208. opscloud/server/server_graph.py +93 -0
  209. opscloud/skills/__init__.py +91 -0
  210. opscloud/skills/commands.py +1099 -0
  211. opscloud/skills/invocation.py +200 -0
  212. opscloud/skills/load.py +388 -0
  213. opscloud/skills/loader.py +23 -0
  214. opscloud/skills/merge.py +65 -0
  215. opscloud/skills/registry.py +263 -0
  216. opscloud/skills/sources.py +174 -0
  217. opscloud/skills/trust.py +417 -0
  218. opscloud/state/__init__.py +142 -0
  219. opscloud/state/base.py +154 -0
  220. opscloud/state/cloud_context.py +262 -0
  221. opscloud/state/goal_channels.py +124 -0
  222. opscloud/state/goal_state_limits.py +175 -0
  223. opscloud/state/resume_state.py +58 -0
  224. opscloud/state/service_context.py +281 -0
  225. opscloud/state/session.py +957 -0
  226. opscloud/state/state_migration.py +86 -0
  227. opscloud/subagents/__init__.py +30 -0
  228. opscloud/subagents/loader.py +331 -0
  229. opscloud/subagents/subagents_parser.py +117 -0
  230. opscloud/subagents/types.py +25 -0
  231. opscloud/tools/__init__.py +36 -0
  232. opscloud/tools/catalog.py +37 -0
  233. opscloud/tools/display.py +24 -0
  234. opscloud/tools/fetch_url.py +176 -0
  235. opscloud/tools/goal_tools.py +423 -0
  236. opscloud/tools/registry.py +105 -0
  237. opscloud/tools/thread.py +32 -0
  238. opscloud/tools/web_search.py +215 -0
  239. opscloud/ui/__init__.py +119 -0
  240. opscloud/ui/app.py +5400 -0
  241. opscloud/ui/app.tcss +479 -0
  242. opscloud/ui/clipboard.py +147 -0
  243. opscloud/ui/command_registry.py +482 -0
  244. opscloud/ui/message_store.py +152 -0
  245. opscloud/ui/permission_store.py +360 -0
  246. opscloud/ui/preferences.py +113 -0
  247. opscloud/ui/prompt_manager.py +96 -0
  248. opscloud/ui/remote_client.py +505 -0
  249. opscloud/ui/textual_adapter.py +1269 -0
  250. opscloud/ui/theme.py +728 -0
  251. opscloud/ui/ui_help.py +415 -0
  252. opscloud/ui/widgets/__init__.py +383 -0
  253. opscloud/ui/widgets/_ask_user_types.py +81 -0
  254. opscloud/ui/widgets/_copy_spans.py +52 -0
  255. opscloud/ui/widgets/_inline_prompt.py +290 -0
  256. opscloud/ui/widgets/_js_eval_display.py +139 -0
  257. opscloud/ui/widgets/_links.py +175 -0
  258. opscloud/ui/widgets/_tool_stream.py +308 -0
  259. opscloud/ui/widgets/agent_selector.py +297 -0
  260. opscloud/ui/widgets/approval.py +741 -0
  261. opscloud/ui/widgets/ask_user.py +539 -0
  262. opscloud/ui/widgets/auth.py +695 -0
  263. opscloud/ui/widgets/auth_manager.py +214 -0
  264. opscloud/ui/widgets/auto_mode_notice.py +146 -0
  265. opscloud/ui/widgets/autocomplete.py +709 -0
  266. opscloud/ui/widgets/chat_input.py +805 -0
  267. opscloud/ui/widgets/cloud_selector.py +528 -0
  268. opscloud/ui/widgets/config_manager.py +652 -0
  269. opscloud/ui/widgets/debug_console.py +1269 -0
  270. opscloud/ui/widgets/devops_renderers.py +184 -0
  271. opscloud/ui/widgets/diff.py +237 -0
  272. opscloud/ui/widgets/effort_selector.py +166 -0
  273. opscloud/ui/widgets/goal_review.py +612 -0
  274. opscloud/ui/widgets/goal_status.py +57 -0
  275. opscloud/ui/widgets/infra_panel.py +141 -0
  276. opscloud/ui/widgets/install_confirm.py +114 -0
  277. opscloud/ui/widgets/loading.py +170 -0
  278. opscloud/ui/widgets/mcp_viewer.py +1204 -0
  279. opscloud/ui/widgets/messages.py +4461 -0
  280. opscloud/ui/widgets/model_selector.py +863 -0
  281. opscloud/ui/widgets/notification_center.py +108 -0
  282. opscloud/ui/widgets/notification_settings.py +119 -0
  283. opscloud/ui/widgets/operation_card.py +106 -0
  284. opscloud/ui/widgets/permissions_manager.py +667 -0
  285. opscloud/ui/widgets/plugin_manager.py +1212 -0
  286. opscloud/ui/widgets/pool_selector.py +376 -0
  287. opscloud/ui/widgets/preamble.py +53 -0
  288. opscloud/ui/widgets/skills_viewer.py +432 -0
  289. opscloud/ui/widgets/status.py +1037 -0
  290. opscloud/ui/widgets/subagent_panel.py +927 -0
  291. opscloud/ui/widgets/theme_selector.py +232 -0
  292. opscloud/ui/widgets/thread_selector.py +358 -0
  293. opscloud/ui/widgets/toast.py +31 -0
  294. opscloud/ui/widgets/tool_display.py +325 -0
  295. opscloud/ui/widgets/tool_renderers.py +234 -0
  296. opscloud/ui/widgets/tool_widgets.py +278 -0
  297. opscloud/ui/widgets/welcome.py +421 -0
  298. opscloud/ui/widgets/welcome_popup.py +102 -0
  299. opscloud/utils/__init__.py +24 -0
  300. opscloud/utils/cost_estimation.py +169 -0
  301. opscloud/utils/git.py +390 -0
  302. opscloud/utils/logger.py +571 -0
  303. opscloud/utils/session_stats.py +115 -0
  304. opscloud/utils/startup_error.py +37 -0
  305. talkops_opscloud-0.3.0b1.dist-info/METADATA +449 -0
  306. talkops_opscloud-0.3.0b1.dist-info/RECORD +309 -0
  307. talkops_opscloud-0.3.0b1.dist-info/WHEEL +4 -0
  308. talkops_opscloud-0.3.0b1.dist-info/entry_points.txt +2 -0
  309. 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
+ )