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.
- titan_cli/__init__.py +3 -0
- titan_cli/__main__.py +4 -0
- titan_cli/ai/__init__.py +0 -0
- titan_cli/ai/agents/__init__.py +15 -0
- titan_cli/ai/agents/base.py +152 -0
- titan_cli/ai/client.py +170 -0
- titan_cli/ai/constants.py +56 -0
- titan_cli/ai/exceptions.py +48 -0
- titan_cli/ai/models.py +34 -0
- titan_cli/ai/oauth_helper.py +120 -0
- titan_cli/ai/providers/__init__.py +9 -0
- titan_cli/ai/providers/anthropic.py +117 -0
- titan_cli/ai/providers/base.py +75 -0
- titan_cli/ai/providers/gemini.py +278 -0
- titan_cli/cli.py +59 -0
- titan_cli/clients/__init__.py +1 -0
- titan_cli/clients/gcloud_client.py +52 -0
- titan_cli/core/__init__.py +3 -0
- titan_cli/core/config.py +274 -0
- titan_cli/core/discovery.py +51 -0
- titan_cli/core/errors.py +81 -0
- titan_cli/core/models.py +52 -0
- titan_cli/core/plugins/available.py +36 -0
- titan_cli/core/plugins/models.py +67 -0
- titan_cli/core/plugins/plugin_base.py +108 -0
- titan_cli/core/plugins/plugin_registry.py +163 -0
- titan_cli/core/secrets.py +141 -0
- titan_cli/core/workflows/__init__.py +22 -0
- titan_cli/core/workflows/models.py +88 -0
- titan_cli/core/workflows/project_step_source.py +86 -0
- titan_cli/core/workflows/workflow_exceptions.py +17 -0
- titan_cli/core/workflows/workflow_filter_service.py +137 -0
- titan_cli/core/workflows/workflow_registry.py +419 -0
- titan_cli/core/workflows/workflow_sources.py +307 -0
- titan_cli/engine/__init__.py +39 -0
- titan_cli/engine/builder.py +159 -0
- titan_cli/engine/context.py +82 -0
- titan_cli/engine/mock_context.py +176 -0
- titan_cli/engine/results.py +91 -0
- titan_cli/engine/steps/ai_assistant_step.py +185 -0
- titan_cli/engine/steps/command_step.py +93 -0
- titan_cli/engine/utils/__init__.py +3 -0
- titan_cli/engine/utils/venv.py +31 -0
- titan_cli/engine/workflow_executor.py +187 -0
- titan_cli/external_cli/__init__.py +0 -0
- titan_cli/external_cli/configs.py +17 -0
- titan_cli/external_cli/launcher.py +65 -0
- titan_cli/messages.py +121 -0
- titan_cli/ui/tui/__init__.py +205 -0
- titan_cli/ui/tui/__previews__/statusbar_preview.py +88 -0
- titan_cli/ui/tui/app.py +113 -0
- titan_cli/ui/tui/icons.py +70 -0
- titan_cli/ui/tui/screens/__init__.py +24 -0
- titan_cli/ui/tui/screens/ai_config.py +498 -0
- titan_cli/ui/tui/screens/ai_config_wizard.py +882 -0
- titan_cli/ui/tui/screens/base.py +110 -0
- titan_cli/ui/tui/screens/cli_launcher.py +151 -0
- titan_cli/ui/tui/screens/global_setup_wizard.py +363 -0
- titan_cli/ui/tui/screens/main_menu.py +162 -0
- titan_cli/ui/tui/screens/plugin_config_wizard.py +550 -0
- titan_cli/ui/tui/screens/plugin_management.py +377 -0
- titan_cli/ui/tui/screens/project_setup_wizard.py +686 -0
- titan_cli/ui/tui/screens/workflow_execution.py +592 -0
- titan_cli/ui/tui/screens/workflows.py +249 -0
- titan_cli/ui/tui/textual_components.py +537 -0
- titan_cli/ui/tui/textual_workflow_executor.py +405 -0
- titan_cli/ui/tui/theme.py +102 -0
- titan_cli/ui/tui/widgets/__init__.py +40 -0
- titan_cli/ui/tui/widgets/button.py +108 -0
- titan_cli/ui/tui/widgets/header.py +116 -0
- titan_cli/ui/tui/widgets/panel.py +81 -0
- titan_cli/ui/tui/widgets/status_bar.py +115 -0
- titan_cli/ui/tui/widgets/table.py +77 -0
- titan_cli/ui/tui/widgets/text.py +177 -0
- titan_cli/utils/__init__.py +0 -0
- titan_cli/utils/autoupdate.py +155 -0
- titan_cli-0.1.0.dist-info/METADATA +149 -0
- titan_cli-0.1.0.dist-info/RECORD +146 -0
- titan_cli-0.1.0.dist-info/WHEEL +4 -0
- titan_cli-0.1.0.dist-info/entry_points.txt +9 -0
- titan_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- titan_plugin_git/__init__.py +1 -0
- titan_plugin_git/clients/__init__.py +8 -0
- titan_plugin_git/clients/git_client.py +772 -0
- titan_plugin_git/exceptions.py +40 -0
- titan_plugin_git/messages.py +112 -0
- titan_plugin_git/models.py +39 -0
- titan_plugin_git/plugin.py +118 -0
- titan_plugin_git/steps/__init__.py +1 -0
- titan_plugin_git/steps/ai_commit_message_step.py +171 -0
- titan_plugin_git/steps/branch_steps.py +104 -0
- titan_plugin_git/steps/commit_step.py +80 -0
- titan_plugin_git/steps/push_step.py +63 -0
- titan_plugin_git/steps/status_step.py +59 -0
- titan_plugin_git/workflows/__previews__/__init__.py +1 -0
- titan_plugin_git/workflows/__previews__/commit_ai_preview.py +124 -0
- titan_plugin_git/workflows/commit-ai.yaml +28 -0
- titan_plugin_github/__init__.py +11 -0
- titan_plugin_github/agents/__init__.py +6 -0
- titan_plugin_github/agents/config_loader.py +130 -0
- titan_plugin_github/agents/issue_generator.py +353 -0
- titan_plugin_github/agents/pr_agent.py +528 -0
- titan_plugin_github/clients/__init__.py +8 -0
- titan_plugin_github/clients/github_client.py +1105 -0
- titan_plugin_github/config/__init__.py +0 -0
- titan_plugin_github/config/pr_agent.toml +85 -0
- titan_plugin_github/exceptions.py +28 -0
- titan_plugin_github/messages.py +88 -0
- titan_plugin_github/models.py +330 -0
- titan_plugin_github/plugin.py +131 -0
- titan_plugin_github/steps/__init__.py +12 -0
- titan_plugin_github/steps/ai_pr_step.py +172 -0
- titan_plugin_github/steps/create_pr_step.py +86 -0
- titan_plugin_github/steps/github_prompt_steps.py +171 -0
- titan_plugin_github/steps/issue_steps.py +143 -0
- titan_plugin_github/steps/preview_step.py +40 -0
- titan_plugin_github/utils.py +82 -0
- titan_plugin_github/workflows/__previews__/__init__.py +1 -0
- titan_plugin_github/workflows/__previews__/create_pr_ai_preview.py +140 -0
- titan_plugin_github/workflows/create-issue-ai.yaml +32 -0
- titan_plugin_github/workflows/create-pr-ai.yaml +49 -0
- titan_plugin_jira/__init__.py +8 -0
- titan_plugin_jira/agents/__init__.py +6 -0
- titan_plugin_jira/agents/config_loader.py +154 -0
- titan_plugin_jira/agents/jira_agent.py +553 -0
- titan_plugin_jira/agents/prompts.py +364 -0
- titan_plugin_jira/agents/response_parser.py +435 -0
- titan_plugin_jira/agents/token_tracker.py +223 -0
- titan_plugin_jira/agents/validators.py +246 -0
- titan_plugin_jira/clients/jira_client.py +745 -0
- titan_plugin_jira/config/jira_agent.toml +92 -0
- titan_plugin_jira/config/templates/issue_analysis.md.j2 +78 -0
- titan_plugin_jira/exceptions.py +37 -0
- titan_plugin_jira/formatters/__init__.py +6 -0
- titan_plugin_jira/formatters/markdown_formatter.py +245 -0
- titan_plugin_jira/messages.py +115 -0
- titan_plugin_jira/models.py +89 -0
- titan_plugin_jira/plugin.py +264 -0
- titan_plugin_jira/steps/ai_analyze_issue_step.py +105 -0
- titan_plugin_jira/steps/get_issue_step.py +82 -0
- titan_plugin_jira/steps/prompt_select_issue_step.py +80 -0
- titan_plugin_jira/steps/search_saved_query_step.py +238 -0
- titan_plugin_jira/utils/__init__.py +13 -0
- titan_plugin_jira/utils/issue_sorter.py +140 -0
- titan_plugin_jira/utils/saved_queries.py +150 -0
- 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"]
|