titan-cli 0.1.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.
Files changed (146) hide show
  1. titan_cli/__init__.py +3 -0
  2. titan_cli/__main__.py +4 -0
  3. titan_cli/ai/__init__.py +0 -0
  4. titan_cli/ai/agents/__init__.py +15 -0
  5. titan_cli/ai/agents/base.py +152 -0
  6. titan_cli/ai/client.py +170 -0
  7. titan_cli/ai/constants.py +56 -0
  8. titan_cli/ai/exceptions.py +48 -0
  9. titan_cli/ai/models.py +34 -0
  10. titan_cli/ai/oauth_helper.py +120 -0
  11. titan_cli/ai/providers/__init__.py +9 -0
  12. titan_cli/ai/providers/anthropic.py +117 -0
  13. titan_cli/ai/providers/base.py +75 -0
  14. titan_cli/ai/providers/gemini.py +278 -0
  15. titan_cli/cli.py +59 -0
  16. titan_cli/clients/__init__.py +1 -0
  17. titan_cli/clients/gcloud_client.py +52 -0
  18. titan_cli/core/__init__.py +3 -0
  19. titan_cli/core/config.py +274 -0
  20. titan_cli/core/discovery.py +51 -0
  21. titan_cli/core/errors.py +81 -0
  22. titan_cli/core/models.py +52 -0
  23. titan_cli/core/plugins/available.py +36 -0
  24. titan_cli/core/plugins/models.py +67 -0
  25. titan_cli/core/plugins/plugin_base.py +108 -0
  26. titan_cli/core/plugins/plugin_registry.py +163 -0
  27. titan_cli/core/secrets.py +141 -0
  28. titan_cli/core/workflows/__init__.py +22 -0
  29. titan_cli/core/workflows/models.py +88 -0
  30. titan_cli/core/workflows/project_step_source.py +86 -0
  31. titan_cli/core/workflows/workflow_exceptions.py +17 -0
  32. titan_cli/core/workflows/workflow_filter_service.py +137 -0
  33. titan_cli/core/workflows/workflow_registry.py +419 -0
  34. titan_cli/core/workflows/workflow_sources.py +307 -0
  35. titan_cli/engine/__init__.py +39 -0
  36. titan_cli/engine/builder.py +159 -0
  37. titan_cli/engine/context.py +82 -0
  38. titan_cli/engine/mock_context.py +176 -0
  39. titan_cli/engine/results.py +91 -0
  40. titan_cli/engine/steps/ai_assistant_step.py +185 -0
  41. titan_cli/engine/steps/command_step.py +93 -0
  42. titan_cli/engine/utils/__init__.py +3 -0
  43. titan_cli/engine/utils/venv.py +31 -0
  44. titan_cli/engine/workflow_executor.py +187 -0
  45. titan_cli/external_cli/__init__.py +0 -0
  46. titan_cli/external_cli/configs.py +17 -0
  47. titan_cli/external_cli/launcher.py +65 -0
  48. titan_cli/messages.py +121 -0
  49. titan_cli/ui/tui/__init__.py +205 -0
  50. titan_cli/ui/tui/__previews__/statusbar_preview.py +88 -0
  51. titan_cli/ui/tui/app.py +113 -0
  52. titan_cli/ui/tui/icons.py +70 -0
  53. titan_cli/ui/tui/screens/__init__.py +24 -0
  54. titan_cli/ui/tui/screens/ai_config.py +498 -0
  55. titan_cli/ui/tui/screens/ai_config_wizard.py +882 -0
  56. titan_cli/ui/tui/screens/base.py +110 -0
  57. titan_cli/ui/tui/screens/cli_launcher.py +151 -0
  58. titan_cli/ui/tui/screens/global_setup_wizard.py +363 -0
  59. titan_cli/ui/tui/screens/main_menu.py +162 -0
  60. titan_cli/ui/tui/screens/plugin_config_wizard.py +550 -0
  61. titan_cli/ui/tui/screens/plugin_management.py +377 -0
  62. titan_cli/ui/tui/screens/project_setup_wizard.py +686 -0
  63. titan_cli/ui/tui/screens/workflow_execution.py +592 -0
  64. titan_cli/ui/tui/screens/workflows.py +249 -0
  65. titan_cli/ui/tui/textual_components.py +537 -0
  66. titan_cli/ui/tui/textual_workflow_executor.py +405 -0
  67. titan_cli/ui/tui/theme.py +102 -0
  68. titan_cli/ui/tui/widgets/__init__.py +40 -0
  69. titan_cli/ui/tui/widgets/button.py +108 -0
  70. titan_cli/ui/tui/widgets/header.py +116 -0
  71. titan_cli/ui/tui/widgets/panel.py +81 -0
  72. titan_cli/ui/tui/widgets/status_bar.py +115 -0
  73. titan_cli/ui/tui/widgets/table.py +77 -0
  74. titan_cli/ui/tui/widgets/text.py +177 -0
  75. titan_cli/utils/__init__.py +0 -0
  76. titan_cli/utils/autoupdate.py +155 -0
  77. titan_cli-0.1.0.dist-info/METADATA +149 -0
  78. titan_cli-0.1.0.dist-info/RECORD +146 -0
  79. titan_cli-0.1.0.dist-info/WHEEL +4 -0
  80. titan_cli-0.1.0.dist-info/entry_points.txt +9 -0
  81. titan_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
  82. titan_plugin_git/__init__.py +1 -0
  83. titan_plugin_git/clients/__init__.py +8 -0
  84. titan_plugin_git/clients/git_client.py +772 -0
  85. titan_plugin_git/exceptions.py +40 -0
  86. titan_plugin_git/messages.py +112 -0
  87. titan_plugin_git/models.py +39 -0
  88. titan_plugin_git/plugin.py +118 -0
  89. titan_plugin_git/steps/__init__.py +1 -0
  90. titan_plugin_git/steps/ai_commit_message_step.py +171 -0
  91. titan_plugin_git/steps/branch_steps.py +104 -0
  92. titan_plugin_git/steps/commit_step.py +80 -0
  93. titan_plugin_git/steps/push_step.py +63 -0
  94. titan_plugin_git/steps/status_step.py +59 -0
  95. titan_plugin_git/workflows/__previews__/__init__.py +1 -0
  96. titan_plugin_git/workflows/__previews__/commit_ai_preview.py +124 -0
  97. titan_plugin_git/workflows/commit-ai.yaml +28 -0
  98. titan_plugin_github/__init__.py +11 -0
  99. titan_plugin_github/agents/__init__.py +6 -0
  100. titan_plugin_github/agents/config_loader.py +130 -0
  101. titan_plugin_github/agents/issue_generator.py +353 -0
  102. titan_plugin_github/agents/pr_agent.py +528 -0
  103. titan_plugin_github/clients/__init__.py +8 -0
  104. titan_plugin_github/clients/github_client.py +1105 -0
  105. titan_plugin_github/config/__init__.py +0 -0
  106. titan_plugin_github/config/pr_agent.toml +85 -0
  107. titan_plugin_github/exceptions.py +28 -0
  108. titan_plugin_github/messages.py +88 -0
  109. titan_plugin_github/models.py +330 -0
  110. titan_plugin_github/plugin.py +131 -0
  111. titan_plugin_github/steps/__init__.py +12 -0
  112. titan_plugin_github/steps/ai_pr_step.py +172 -0
  113. titan_plugin_github/steps/create_pr_step.py +86 -0
  114. titan_plugin_github/steps/github_prompt_steps.py +171 -0
  115. titan_plugin_github/steps/issue_steps.py +143 -0
  116. titan_plugin_github/steps/preview_step.py +40 -0
  117. titan_plugin_github/utils.py +82 -0
  118. titan_plugin_github/workflows/__previews__/__init__.py +1 -0
  119. titan_plugin_github/workflows/__previews__/create_pr_ai_preview.py +140 -0
  120. titan_plugin_github/workflows/create-issue-ai.yaml +32 -0
  121. titan_plugin_github/workflows/create-pr-ai.yaml +49 -0
  122. titan_plugin_jira/__init__.py +8 -0
  123. titan_plugin_jira/agents/__init__.py +6 -0
  124. titan_plugin_jira/agents/config_loader.py +154 -0
  125. titan_plugin_jira/agents/jira_agent.py +553 -0
  126. titan_plugin_jira/agents/prompts.py +364 -0
  127. titan_plugin_jira/agents/response_parser.py +435 -0
  128. titan_plugin_jira/agents/token_tracker.py +223 -0
  129. titan_plugin_jira/agents/validators.py +246 -0
  130. titan_plugin_jira/clients/jira_client.py +745 -0
  131. titan_plugin_jira/config/jira_agent.toml +92 -0
  132. titan_plugin_jira/config/templates/issue_analysis.md.j2 +78 -0
  133. titan_plugin_jira/exceptions.py +37 -0
  134. titan_plugin_jira/formatters/__init__.py +6 -0
  135. titan_plugin_jira/formatters/markdown_formatter.py +245 -0
  136. titan_plugin_jira/messages.py +115 -0
  137. titan_plugin_jira/models.py +89 -0
  138. titan_plugin_jira/plugin.py +264 -0
  139. titan_plugin_jira/steps/ai_analyze_issue_step.py +105 -0
  140. titan_plugin_jira/steps/get_issue_step.py +82 -0
  141. titan_plugin_jira/steps/prompt_select_issue_step.py +80 -0
  142. titan_plugin_jira/steps/search_saved_query_step.py +238 -0
  143. titan_plugin_jira/utils/__init__.py +13 -0
  144. titan_plugin_jira/utils/issue_sorter.py +140 -0
  145. titan_plugin_jira/utils/saved_queries.py +150 -0
  146. titan_plugin_jira/workflows/analyze-jira-issues.yaml +34 -0
@@ -0,0 +1,537 @@
1
+ """
2
+ Textual UI Components container for workflow context.
3
+
4
+ Provides utilities for workflow steps to mount widgets and request user input in the TUI.
5
+
6
+ Steps can import widgets directly from titan_cli.ui.tui.widgets and mount them using ctx.textual.
7
+ """
8
+
9
+ import threading
10
+ from typing import Optional, Callable
11
+ from contextlib import contextmanager
12
+ from textual.widget import Widget
13
+ from textual.widgets import Input, LoadingIndicator, Static, Markdown, TextArea
14
+ from textual.containers import Container
15
+ from textual.message import Message
16
+
17
+
18
+ class PromptInput(Widget):
19
+ """Widget wrapper for Input that handles submission events."""
20
+
21
+ # Allow this widget and its children to receive focus
22
+ can_focus = True
23
+ can_focus_children = True
24
+
25
+ DEFAULT_CSS = """
26
+ PromptInput {
27
+ width: 100%;
28
+ height: auto;
29
+ padding: 1;
30
+ margin: 1 0;
31
+ background: $surface-lighten-1;
32
+ border: round $accent;
33
+ }
34
+
35
+ PromptInput > Static {
36
+ width: 100%;
37
+ height: auto;
38
+ margin-bottom: 1;
39
+ }
40
+
41
+ PromptInput > Input {
42
+ width: 100%;
43
+ }
44
+ """
45
+
46
+ def __init__(self, question: str, default: str, placeholder: str, on_submit: Callable[[str], None], **kwargs):
47
+ super().__init__(**kwargs)
48
+ self.question = question
49
+ self.default = default
50
+ self.placeholder = placeholder
51
+ self.on_submit_callback = on_submit
52
+
53
+ def compose(self):
54
+ from textual.widgets import Static
55
+ yield Static(f"[bold cyan]{self.question}[/bold cyan]")
56
+ yield Input(
57
+ value=self.default,
58
+ placeholder=self.placeholder,
59
+ id="prompt-input"
60
+ )
61
+
62
+ def on_mount(self):
63
+ """Focus input when mounted and scroll into view."""
64
+ # Use call_after_refresh to ensure widget tree is ready
65
+ self.call_after_refresh(self._focus_input)
66
+
67
+ def _focus_input(self):
68
+ """Focus the input widget and scroll into view."""
69
+ try:
70
+ input_widget = self.query_one(Input)
71
+ # Use app.set_focus() to force focus change from steps-panel
72
+ self.app.set_focus(input_widget)
73
+ # Scroll to make this widget visible
74
+ self.scroll_visible(animate=False)
75
+ except Exception:
76
+ pass
77
+
78
+ def on_input_submitted(self, event: Input.Submitted) -> None:
79
+ """Handle input submission."""
80
+ value = event.value
81
+ self.on_submit_callback(value)
82
+
83
+
84
+ class MultilineInput(TextArea):
85
+ """Custom TextArea that handles Enter for submission and Shift+Enter for new lines."""
86
+
87
+ class Submitted(Message):
88
+ """Message sent when the input is submitted."""
89
+ def __init__(self, sender: Widget, value: str):
90
+ super().__init__()
91
+ self.sender = sender
92
+ self.value = value
93
+
94
+ def _on_key(self, event) -> None:
95
+ """Intercept key events before TextArea processes them."""
96
+ from textual.events import Key
97
+
98
+ # Check if it's Enter without shift
99
+ if isinstance(event, Key) and event.key == "enter":
100
+ # Submit the input
101
+ self.post_message(self.Submitted(self, self.text))
102
+ event.prevent_default()
103
+ event.stop()
104
+ return
105
+
106
+ # For all other keys, let TextArea handle it
107
+ super()._on_key(event)
108
+
109
+
110
+ class PromptTextArea(Widget):
111
+ """Widget wrapper for MultilineInput that handles multiline input submission."""
112
+
113
+ can_focus = True
114
+ can_focus_children = True
115
+
116
+ DEFAULT_CSS = """
117
+ PromptTextArea {
118
+ width: 100%;
119
+ height: auto;
120
+ padding: 1;
121
+ margin: 1 0;
122
+ background: $surface-lighten-1;
123
+ border: round $accent;
124
+ }
125
+
126
+ PromptTextArea > Static {
127
+ width: 100%;
128
+ height: auto;
129
+ margin-bottom: 1;
130
+ }
131
+
132
+ PromptTextArea > MultilineInput {
133
+ width: 100%;
134
+ height: auto;
135
+ }
136
+
137
+ PromptTextArea .hint-text {
138
+ width: 100%;
139
+ height: auto;
140
+ margin-top: 1;
141
+ color: $text-muted;
142
+ }
143
+ """
144
+
145
+ def __init__(self, question: str, default: str, on_submit: Callable[[str], None], **kwargs):
146
+ super().__init__(**kwargs)
147
+ self.question = question
148
+ self.default = default
149
+ self.on_submit_callback = on_submit
150
+
151
+ def compose(self):
152
+ from textual.widgets import Static
153
+ yield Static(f"[bold cyan]{self.question}[/bold cyan]")
154
+ yield MultilineInput(
155
+ text=self.default,
156
+ id="prompt-textarea",
157
+ soft_wrap=True
158
+ )
159
+ yield Static("[dim]Press Enter to submit, Shift+Enter for new line[/dim]", classes="hint-text")
160
+
161
+ def on_mount(self):
162
+ """Focus textarea when mounted and scroll into view."""
163
+ self.call_after_refresh(self._focus_textarea)
164
+
165
+ def _focus_textarea(self):
166
+ """Focus the textarea widget and scroll into view."""
167
+ try:
168
+ textarea = self.query_one(MultilineInput)
169
+ self.app.set_focus(textarea)
170
+ self.scroll_visible(animate=False)
171
+ except Exception:
172
+ pass
173
+
174
+ def on_multiline_input_submitted(self, message: MultilineInput.Submitted):
175
+ """Handle submission from MultilineInput."""
176
+ self.on_submit_callback(message.value)
177
+
178
+
179
+ class TextualComponents:
180
+ """
181
+ Textual UI utilities for workflow steps.
182
+
183
+ Steps import widgets directly (Panel, DimText, etc.) and use these utilities to:
184
+ - Mount widgets to the output panel
185
+ - Append simple text with markup
186
+ - Request user input interactively
187
+
188
+ Example:
189
+ from titan_cli.ui.tui.widgets import Panel, DimText
190
+
191
+ def my_step(ctx):
192
+ # Mount a panel widget
193
+ ctx.textual.mount(Panel("Warning message", panel_type="warning"))
194
+
195
+ # Append inline text
196
+ ctx.textual.text("Analyzing changes...")
197
+
198
+ # Ask for input
199
+ response = ctx.textual.ask_confirm("Continue?", default=True)
200
+ """
201
+
202
+ def __init__(self, app, output_widget):
203
+ """
204
+ Initialize Textual components.
205
+
206
+ Args:
207
+ app: TitanApp instance for thread synchronization
208
+ output_widget: WorkflowExecutionContent widget to render to
209
+ """
210
+ self.app = app
211
+ self.output_widget = output_widget
212
+
213
+ def mount(self, widget: Widget) -> None:
214
+ """
215
+ Mount a widget to the output panel.
216
+
217
+ Args:
218
+ widget: Any Textual widget to mount (Panel, DimText, etc.)
219
+
220
+ Example:
221
+ from titan_cli.ui.tui.widgets import Panel
222
+ ctx.textual.mount(Panel("Success!", panel_type="success"))
223
+ """
224
+ def _mount():
225
+ self.output_widget.mount(widget)
226
+
227
+ # call_from_thread already blocks until the function completes
228
+ try:
229
+ self.app.call_from_thread(_mount)
230
+ except Exception:
231
+ # App is closing or worker was cancelled
232
+ pass
233
+
234
+ def text(self, text: str, markup: str = "") -> None:
235
+ """
236
+ Append inline text with optional Rich markup.
237
+
238
+ Args:
239
+ text: Text to append
240
+ markup: Optional Rich markup style (e.g., "cyan", "bold green")
241
+
242
+ Example:
243
+ ctx.textual.text("Analyzing changes...", markup="cyan")
244
+ ctx.textual.text("Done!")
245
+ """
246
+ def _append():
247
+ if markup:
248
+ self.output_widget.append_output(f"[{markup}]{text}[/{markup}]")
249
+ else:
250
+ self.output_widget.append_output(text)
251
+
252
+ # call_from_thread already blocks until the function completes
253
+ try:
254
+ self.app.call_from_thread(_append)
255
+ except Exception:
256
+ # App is closing or worker was cancelled
257
+ pass
258
+
259
+ def markdown(self, markdown_text: str) -> None:
260
+ """
261
+ Render markdown content (parent container handles scrolling).
262
+
263
+ Args:
264
+ markdown_text: Markdown content to render
265
+
266
+ Example:
267
+ ctx.textual.markdown("## My Title\n\nSome **bold** text")
268
+ """
269
+ # Create markdown widget directly (Textual's Markdown already handles wrapping)
270
+ md_widget = Markdown(markdown_text)
271
+
272
+ # Apply basic styling - let it expand fully, parent has scroll
273
+ md_widget.styles.width = "100%"
274
+ md_widget.styles.height = "auto"
275
+ md_widget.styles.padding = (1, 2)
276
+ md_widget.styles.margin = (0, 0, 1, 0)
277
+
278
+ def _mount():
279
+ # Mount markdown to output
280
+ self.output_widget.mount(md_widget)
281
+ # Trigger autoscroll after mounting
282
+ self.output_widget._scroll_to_end()
283
+
284
+ # call_from_thread already blocks until the function completes
285
+ try:
286
+ self.app.call_from_thread(_mount)
287
+ except Exception:
288
+ # App is closing or worker was cancelled
289
+ pass
290
+
291
+ def ask_text(self, question: str, default: str = "") -> Optional[str]:
292
+ """
293
+ Ask user for text input (blocks until user responds).
294
+
295
+ Args:
296
+ question: Question to ask
297
+ default: Default value
298
+
299
+ Returns:
300
+ User's input text, or None if empty
301
+
302
+ Example:
303
+ message = ctx.textual.ask_text("Enter commit message:", default="")
304
+ """
305
+ # Event and result container for synchronization
306
+ result_event = threading.Event()
307
+ result_container = {"value": None}
308
+
309
+ def _mount_input():
310
+ # Handler when Enter is pressed
311
+ def on_submitted(value: str):
312
+ result_container["value"] = value
313
+
314
+ # Show what user entered (confirmation)
315
+ self.output_widget.append_output(f" → {value}")
316
+
317
+ # Remove the input widget
318
+ input_widget.remove()
319
+
320
+ # Unblock the step
321
+ result_event.set()
322
+
323
+ # Create PromptInput widget that handles the submission
324
+ input_widget = PromptInput(
325
+ question=question,
326
+ default=default,
327
+ placeholder="Type here and press Enter...",
328
+ on_submit=on_submitted
329
+ )
330
+
331
+ # Mount the widget (it will auto-focus)
332
+ self.output_widget.mount(input_widget)
333
+
334
+ # Call from thread since executor runs in background thread
335
+ try:
336
+ self.app.call_from_thread(_mount_input)
337
+ except Exception:
338
+ # App is closing or worker was cancelled
339
+ return default
340
+
341
+ # BLOCK here until user responds (with timeout to allow cancellation)
342
+ # Wait in loop with timeout so we can be interrupted
343
+ while not result_event.is_set():
344
+ if result_event.wait(timeout=0.5):
345
+ break
346
+ # Check if app is still running
347
+ if not self.app.is_running:
348
+ return default
349
+
350
+ return result_container["value"]
351
+
352
+ def ask_multiline(self, question: str, default: str = "") -> Optional[str]:
353
+ """
354
+ Ask user for multiline text input (blocks until user responds).
355
+
356
+ Args:
357
+ question: Question to ask
358
+ default: Default value
359
+
360
+ Returns:
361
+ User's multiline input text, or None if empty
362
+
363
+ Example:
364
+ body = ctx.textual.ask_multiline("Enter issue description:", default="")
365
+ """
366
+ # Event and result container for synchronization
367
+ result_event = threading.Event()
368
+ result_container = {"value": None}
369
+
370
+ def _mount_textarea():
371
+ # Handler when Ctrl+D is pressed
372
+ def on_submitted(value: str):
373
+ result_container["value"] = value
374
+
375
+ # Show confirmation (truncated preview for multiline)
376
+ preview = value.split('\n')[0][:50]
377
+ if len(value.split('\n')) > 1 or len(value) > 50:
378
+ preview += "..."
379
+ self.output_widget.append_output(f" → {preview}")
380
+
381
+ # Remove the textarea widget
382
+ textarea_widget.remove()
383
+
384
+ # Unblock the step
385
+ result_event.set()
386
+
387
+ # Create PromptTextArea widget that handles the submission
388
+ textarea_widget = PromptTextArea(
389
+ question=question,
390
+ default=default,
391
+ on_submit=on_submitted
392
+ )
393
+
394
+ # Mount the widget (it will auto-focus)
395
+ self.output_widget.mount(textarea_widget)
396
+
397
+ # Call from thread since executor runs in background thread
398
+ try:
399
+ self.app.call_from_thread(_mount_textarea)
400
+ except Exception:
401
+ # App is closing or worker was cancelled
402
+ return default
403
+
404
+ # BLOCK here until user responds (with timeout to allow cancellation)
405
+ # Wait in loop with timeout so we can be interrupted
406
+ while not result_event.is_set():
407
+ if result_event.wait(timeout=0.5):
408
+ break
409
+ # Check if app is still running
410
+ if not self.app.is_running:
411
+ return default
412
+
413
+ return result_container["value"]
414
+
415
+ def ask_confirm(self, question: str, default: bool = True) -> bool:
416
+ """
417
+ Ask user for confirmation (Y/N).
418
+
419
+ Args:
420
+ question: Question to ask
421
+ default: Default value (True = Y, False = N)
422
+
423
+ Returns:
424
+ True if user confirmed, False otherwise
425
+
426
+ Example:
427
+ if ctx.textual.ask_confirm("Use AI message?", default=True):
428
+ # User said yes
429
+ """
430
+ default_hint = "Y/n" if default else "y/N"
431
+ response = self.ask_text(f"{question} ({default_hint})", default="")
432
+
433
+ # Parse response
434
+ if response is None or response.strip() == "":
435
+ return default
436
+
437
+ response_lower = response.strip().lower()
438
+ if response_lower in ["y", "yes"]:
439
+ return True
440
+ elif response_lower in ["n", "no"]:
441
+ return False
442
+ else:
443
+ # Invalid response, use default
444
+ return default
445
+
446
+ @contextmanager
447
+ def loading(self, message: str = "Loading..."):
448
+ """
449
+ Show a loading indicator with a message (context manager).
450
+
451
+ Args:
452
+ message: Message to display while loading
453
+
454
+ Example:
455
+ with ctx.textual.loading("Generating commit message..."):
456
+ response = ctx.ai.generate(messages)
457
+ """
458
+ # Create loading container with message and spinner
459
+ loading_container = Container(
460
+ Static(f"[dim]{message}[/dim]"),
461
+ LoadingIndicator()
462
+ )
463
+ loading_container.styles.height = "auto"
464
+
465
+ # Mount the loading widget
466
+ self.mount(loading_container)
467
+
468
+ try:
469
+ yield
470
+ finally:
471
+ # Remove loading widget when done
472
+ def _remove():
473
+ try:
474
+ loading_container.remove()
475
+ except Exception:
476
+ pass
477
+
478
+ try:
479
+ self.app.call_from_thread(_remove)
480
+ except Exception:
481
+ # App is closing or worker was cancelled
482
+ pass
483
+
484
+ def launch_external_cli(self, cli_name: str, prompt: str = None, cwd: str = None) -> int:
485
+ """
486
+ Launch an external CLI tool, suspending the TUI while it runs.
487
+
488
+ Args:
489
+ cli_name: Name of the CLI to launch (e.g., "claude", "gemini")
490
+ prompt: Optional initial prompt to pass to the CLI
491
+ cwd: Working directory (default: current)
492
+
493
+ Returns:
494
+ Exit code from the CLI tool
495
+
496
+ Example:
497
+ exit_code = ctx.textual.launch_external_cli("claude", prompt="Fix this bug")
498
+ """
499
+ from titan_cli.external_cli.launcher import CLILauncher
500
+ from titan_cli.external_cli.configs import CLI_REGISTRY
501
+
502
+ # Container for result (since we need to pass it from main thread back to worker)
503
+ result_container = {"exit_code": None}
504
+ result_event = threading.Event()
505
+
506
+ def _launch():
507
+ # Suspend TUI, launch CLI, restore TUI
508
+ with self.app.suspend():
509
+ # Get CLI configuration for proper flag usage
510
+ config = CLI_REGISTRY.get(cli_name, {})
511
+ launcher = CLILauncher(
512
+ cli_name,
513
+ install_instructions=config.get("install_instructions"),
514
+ prompt_flag=config.get("prompt_flag")
515
+ )
516
+ exit_code = launcher.launch(prompt=prompt, cwd=cwd)
517
+ result_container["exit_code"] = exit_code
518
+
519
+ # Signal completion
520
+ result_event.set()
521
+
522
+ # Run in main thread (because suspend() must run on main thread)
523
+ try:
524
+ self.app.call_from_thread(_launch)
525
+ except Exception:
526
+ # App is closing or worker was cancelled
527
+ return -1
528
+
529
+ # Wait for completion (with timeout to allow cancellation)
530
+ while not result_event.is_set():
531
+ if result_event.wait(timeout=0.5):
532
+ break
533
+ # Check if app is still running
534
+ if not self.app.is_running:
535
+ return -1
536
+
537
+ return result_container["exit_code"]