zjcode 0.0.1__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.
- deepagents_code/__init__.py +42 -0
- deepagents_code/__main__.py +6 -0
- deepagents_code/_ask_user_types.py +90 -0
- deepagents_code/_cli_context.py +96 -0
- deepagents_code/_constants.py +41 -0
- deepagents_code/_debug.py +148 -0
- deepagents_code/_debug_buffer.py +204 -0
- deepagents_code/_env_vars.py +411 -0
- deepagents_code/_fake_models.py +66 -0
- deepagents_code/_git.py +521 -0
- deepagents_code/_paths.py +69 -0
- deepagents_code/_server_config.py +576 -0
- deepagents_code/_session_stats.py +235 -0
- deepagents_code/_startup_error.py +45 -0
- deepagents_code/_testing_models.py +305 -0
- deepagents_code/_textual_patches.py +420 -0
- deepagents_code/_tool_stream.py +694 -0
- deepagents_code/_version.py +46 -0
- deepagents_code/agent.py +1976 -0
- deepagents_code/app.py +19239 -0
- deepagents_code/app.tcss +438 -0
- deepagents_code/approval_mode.py +131 -0
- deepagents_code/ask_user.py +306 -0
- deepagents_code/auth_display.py +185 -0
- deepagents_code/auth_store.py +545 -0
- deepagents_code/built_in_skills/__init__.py +5 -0
- deepagents_code/built_in_skills/remember/SKILL.md +118 -0
- deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
- deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
- deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
- deepagents_code/client/__init__.py +1 -0
- deepagents_code/client/commands/__init__.py +1 -0
- deepagents_code/client/commands/auth.py +541 -0
- deepagents_code/client/commands/config.py +714 -0
- deepagents_code/client/commands/mcp.py +250 -0
- deepagents_code/client/commands/tools.py +416 -0
- deepagents_code/client/launch/__init__.py +1 -0
- deepagents_code/client/launch/server.py +978 -0
- deepagents_code/client/launch/server_manager.py +540 -0
- deepagents_code/client/non_interactive.py +1758 -0
- deepagents_code/client/remote_client.py +794 -0
- deepagents_code/clipboard.py +217 -0
- deepagents_code/command_registry.py +450 -0
- deepagents_code/config.py +4829 -0
- deepagents_code/config_manifest.py +1451 -0
- deepagents_code/configurable_model.py +577 -0
- deepagents_code/default_agent_prompt.md +12 -0
- deepagents_code/doctor.py +559 -0
- deepagents_code/editor.py +142 -0
- deepagents_code/event_bus.py +411 -0
- deepagents_code/extras_info.py +661 -0
- deepagents_code/file_ops.py +576 -0
- deepagents_code/formatting.py +135 -0
- deepagents_code/goal_rubric.py +497 -0
- deepagents_code/goal_tools.py +459 -0
- deepagents_code/hooks.py +348 -0
- deepagents_code/input.py +1041 -0
- deepagents_code/integrations/__init__.py +1 -0
- deepagents_code/integrations/openai_codex.py +551 -0
- deepagents_code/integrations/sandbox_config.py +198 -0
- deepagents_code/integrations/sandbox_factory.py +1124 -0
- deepagents_code/integrations/sandbox_provider.py +137 -0
- deepagents_code/integrations/sandbox_registry.py +350 -0
- deepagents_code/iterm_cursor_guide.py +176 -0
- deepagents_code/local_context.py +926 -0
- deepagents_code/main.py +3985 -0
- deepagents_code/managed_tools.py +642 -0
- deepagents_code/mcp_auth.py +1950 -0
- deepagents_code/mcp_config.py +176 -0
- deepagents_code/mcp_disabled.py +212 -0
- deepagents_code/mcp_login_service.py +281 -0
- deepagents_code/mcp_oauth_ui.py +199 -0
- deepagents_code/mcp_providers/__init__.py +23 -0
- deepagents_code/mcp_providers/_registry.py +39 -0
- deepagents_code/mcp_providers/base.py +133 -0
- deepagents_code/mcp_providers/github.py +102 -0
- deepagents_code/mcp_providers/slack.py +175 -0
- deepagents_code/mcp_tools.py +2427 -0
- deepagents_code/mcp_trust.py +207 -0
- deepagents_code/media_utils.py +826 -0
- deepagents_code/memory_guard.py +474 -0
- deepagents_code/model_config.py +4156 -0
- deepagents_code/notifications.py +247 -0
- deepagents_code/offload.py +402 -0
- deepagents_code/onboarding.py +223 -0
- deepagents_code/output.py +69 -0
- deepagents_code/paste_collapse.py +103 -0
- deepagents_code/project_utils.py +231 -0
- deepagents_code/py.typed +0 -0
- deepagents_code/reasoning_effort.py +641 -0
- deepagents_code/reliable_rubric.py +97 -0
- deepagents_code/resume_state.py +237 -0
- deepagents_code/server_graph.py +310 -0
- deepagents_code/session_end_summary.py +329 -0
- deepagents_code/sessions.py +1716 -0
- deepagents_code/skills/__init__.py +18 -0
- deepagents_code/skills/commands.py +1252 -0
- deepagents_code/skills/invocation.py +112 -0
- deepagents_code/skills/load.py +196 -0
- deepagents_code/skills/trust.py +547 -0
- deepagents_code/state_migration.py +136 -0
- deepagents_code/subagents.py +278 -0
- deepagents_code/system_prompt.md +204 -0
- deepagents_code/terminal_capabilities.py +115 -0
- deepagents_code/terminal_escape.py +287 -0
- deepagents_code/theme.py +891 -0
- deepagents_code/todo_list_prompt.md +12 -0
- deepagents_code/tool_catalog.py +509 -0
- deepagents_code/tool_display.py +367 -0
- deepagents_code/tools.py +516 -0
- deepagents_code/tui/__init__.py +1 -0
- deepagents_code/tui/textual_adapter.py +2553 -0
- deepagents_code/tui/widgets/__init__.py +9 -0
- deepagents_code/tui/widgets/_js_eval_display.py +139 -0
- deepagents_code/tui/widgets/_links.py +261 -0
- deepagents_code/tui/widgets/agent_selector.py +420 -0
- deepagents_code/tui/widgets/approval.py +602 -0
- deepagents_code/tui/widgets/ask_user.py +515 -0
- deepagents_code/tui/widgets/auth.py +1997 -0
- deepagents_code/tui/widgets/autocomplete.py +890 -0
- deepagents_code/tui/widgets/chat_input.py +3181 -0
- deepagents_code/tui/widgets/codex_auth.py +452 -0
- deepagents_code/tui/widgets/cwd_switch.py +242 -0
- deepagents_code/tui/widgets/debug_console.py +868 -0
- deepagents_code/tui/widgets/diff.py +252 -0
- deepagents_code/tui/widgets/effort_selector.py +189 -0
- deepagents_code/tui/widgets/goal_review.py +390 -0
- deepagents_code/tui/widgets/goal_status.py +50 -0
- deepagents_code/tui/widgets/history.py +194 -0
- deepagents_code/tui/widgets/install_confirm.py +248 -0
- deepagents_code/tui/widgets/launch_init.py +482 -0
- deepagents_code/tui/widgets/loading.py +227 -0
- deepagents_code/tui/widgets/mcp_login.py +539 -0
- deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
- deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
- deepagents_code/tui/widgets/message_store.py +999 -0
- deepagents_code/tui/widgets/messages.py +3846 -0
- deepagents_code/tui/widgets/model_selector.py +2343 -0
- deepagents_code/tui/widgets/notification_center.py +456 -0
- deepagents_code/tui/widgets/notification_detail.py +257 -0
- deepagents_code/tui/widgets/notification_settings.py +170 -0
- deepagents_code/tui/widgets/restart_prompt.py +158 -0
- deepagents_code/tui/widgets/skill_trust.py +131 -0
- deepagents_code/tui/widgets/startup_tip.py +91 -0
- deepagents_code/tui/widgets/status.py +781 -0
- deepagents_code/tui/widgets/subagent_panel.py +969 -0
- deepagents_code/tui/widgets/theme_selector.py +401 -0
- deepagents_code/tui/widgets/thread_selector.py +2564 -0
- deepagents_code/tui/widgets/tool_renderers.py +190 -0
- deepagents_code/tui/widgets/tool_widgets.py +304 -0
- deepagents_code/tui/widgets/update_available.py +311 -0
- deepagents_code/tui/widgets/update_confirm.py +184 -0
- deepagents_code/tui/widgets/update_progress.py +327 -0
- deepagents_code/tui/widgets/welcome.py +508 -0
- deepagents_code/turn_end_summary.py +522 -0
- deepagents_code/ui.py +858 -0
- deepagents_code/unicode_security.py +563 -0
- deepagents_code/update_check.py +3124 -0
- zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
- zjcode-0.0.1.dist-info/METADATA +220 -0
- zjcode-0.0.1.dist-info/RECORD +163 -0
- zjcode-0.0.1.dist-info/WHEEL +4 -0
- zjcode-0.0.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
"""In-TUI MCP OAuth login modal.
|
|
2
|
+
|
|
3
|
+
`MCPLoginScreen` is both a Textual `ModalScreen` and an implementation of
|
|
4
|
+
`OAuthInteraction`. The login worker awaits its interaction methods while
|
|
5
|
+
the user sees and acts on the modal's widgets — authorize URLs become
|
|
6
|
+
clickable links, paste-back callback URLs go through an inline input row,
|
|
7
|
+
device-code instructions render inline, and the modal closes itself on
|
|
8
|
+
success.
|
|
9
|
+
|
|
10
|
+
The screen runs on the Textual event loop (same loop as the worker), so
|
|
11
|
+
methods called from the worker can `await` modal-bound futures directly
|
|
12
|
+
without `app.call_from_thread`.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
from typing import TYPE_CHECKING, ClassVar, Literal
|
|
19
|
+
|
|
20
|
+
from textual.binding import Binding, BindingType
|
|
21
|
+
from textual.containers import Vertical, VerticalScroll
|
|
22
|
+
from textual.content import Content
|
|
23
|
+
from textual.events import (
|
|
24
|
+
Click, # noqa: TC002 - needed at runtime for Textual event dispatch
|
|
25
|
+
MouseMove, # noqa: TC002 - needed at runtime for Textual event dispatch
|
|
26
|
+
)
|
|
27
|
+
from textual.screen import ModalScreen
|
|
28
|
+
from textual.style import Style as TStyle
|
|
29
|
+
from textual.widgets import Input, Static
|
|
30
|
+
|
|
31
|
+
from deepagents_code import theme
|
|
32
|
+
from deepagents_code.config import get_glyphs, is_ascii_mode
|
|
33
|
+
from deepagents_code.tui.widgets._links import open_style_link
|
|
34
|
+
from deepagents_code.tui.widgets.loading import Spinner
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from textual.app import ComposeResult
|
|
38
|
+
from textual.timer import Timer
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
LoginOutcome = Literal["success", "cancelled", "failed"]
|
|
42
|
+
"""Discriminator returned by the modal when it dismisses."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class MCPLoginCancelledError(RuntimeError):
|
|
46
|
+
"""Raised by `MCPLoginScreen.action_cancel` when the user cancels the flow."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_PROMPT_CALLBACK = "Paste the full callback URL after approving in the browser:"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class MCPLoginScreen(ModalScreen[LoginOutcome]):
|
|
53
|
+
"""Modal that renders the OAuth login flow and collects user input.
|
|
54
|
+
|
|
55
|
+
Implements the `OAuthInteraction` Protocol structurally so a
|
|
56
|
+
`mcp_auth.login(..., ui=screen)` call drives the same modal. Each
|
|
57
|
+
interaction method updates a status line, a clickable link area, and
|
|
58
|
+
an inline input prompt for the callback URL. Slack workspace selection
|
|
59
|
+
is deferred to Slack's browser page rather than prompted inline.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
63
|
+
Binding("enter", "toggle_authorize_url", "Toggle URL", show=False),
|
|
64
|
+
Binding("escape", "cancel", "Cancel", show=False, priority=True),
|
|
65
|
+
]
|
|
66
|
+
"""Esc unblocks the worker; the worker performs the actual shutdown.
|
|
67
|
+
|
|
68
|
+
Cancellation completes any outstanding input future with
|
|
69
|
+
`MCPLoginCancelledError`. The worker (`_run_mcp_login_worker`) sees
|
|
70
|
+
that exception, calls `finish(success=False)`, and tears down the
|
|
71
|
+
OAuth handshake. Doing the teardown here would race the worker.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
CSS = """
|
|
75
|
+
MCPLoginScreen {
|
|
76
|
+
align: center middle;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
MCPLoginScreen > Vertical {
|
|
80
|
+
width: 80;
|
|
81
|
+
max-width: 92%;
|
|
82
|
+
height: auto;
|
|
83
|
+
max-height: 85%;
|
|
84
|
+
background: $surface;
|
|
85
|
+
border: solid $primary;
|
|
86
|
+
padding: 1 2;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
MCPLoginScreen .ml-title {
|
|
90
|
+
text-style: bold;
|
|
91
|
+
color: $primary;
|
|
92
|
+
text-align: center;
|
|
93
|
+
margin-bottom: 1;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
MCPLoginScreen .ml-status {
|
|
97
|
+
height: auto;
|
|
98
|
+
color: $text;
|
|
99
|
+
margin-bottom: 1;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
MCPLoginScreen .ml-link {
|
|
103
|
+
height: auto;
|
|
104
|
+
color: $accent;
|
|
105
|
+
margin-bottom: 1;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
MCPLoginScreen .ml-history {
|
|
109
|
+
height: auto;
|
|
110
|
+
max-height: 8;
|
|
111
|
+
background: $surface-lighten-1;
|
|
112
|
+
margin-bottom: 1;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
MCPLoginScreen .ml-history-line {
|
|
116
|
+
height: auto;
|
|
117
|
+
color: $text-muted;
|
|
118
|
+
padding: 0 1;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
MCPLoginScreen .ml-prompt {
|
|
122
|
+
height: 1;
|
|
123
|
+
color: $text;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
MCPLoginScreen #ml-input {
|
|
127
|
+
margin-bottom: 1;
|
|
128
|
+
border: solid $primary-lighten-2;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
MCPLoginScreen #ml-input:focus {
|
|
132
|
+
border: solid $primary;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
MCPLoginScreen .ml-help {
|
|
136
|
+
height: 1;
|
|
137
|
+
color: $text-muted;
|
|
138
|
+
text-style: italic;
|
|
139
|
+
text-align: center;
|
|
140
|
+
}
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
def __init__(self, server_name: str) -> None:
|
|
144
|
+
"""Initialize a login modal for `server_name`.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
server_name: MCP server name shown in the modal title.
|
|
148
|
+
"""
|
|
149
|
+
super().__init__()
|
|
150
|
+
self._server_name = server_name
|
|
151
|
+
self._status = f"Starting OAuth login for {server_name}…"
|
|
152
|
+
self._title_widget: Static | None = None
|
|
153
|
+
self._status_widget: Static | None = None
|
|
154
|
+
self._link_widget: Static | None = None
|
|
155
|
+
self._history_widget: VerticalScroll | None = None
|
|
156
|
+
self._prompt_widget: Static | None = None
|
|
157
|
+
self._input_widget: Input | None = None
|
|
158
|
+
self._help_widget: Static | None = None
|
|
159
|
+
self._spinner = Spinner()
|
|
160
|
+
self._spinner_timer: Timer | None = None
|
|
161
|
+
self._authorize_url: str | None = None
|
|
162
|
+
self._authorize_url_opened_in_browser = False
|
|
163
|
+
self._authorize_url_expanded = False
|
|
164
|
+
self._waiting_for_authorization = False
|
|
165
|
+
|
|
166
|
+
# Each prompt method blocks on a fresh Future; cancellation completes
|
|
167
|
+
# it with MCPLoginCancelledError so the worker unblocks rather than
|
|
168
|
+
# hanging on a dismissed modal.
|
|
169
|
+
self._pending_input: asyncio.Future[str] | None = None
|
|
170
|
+
self._cancelled = False
|
|
171
|
+
self._done = False
|
|
172
|
+
self._outcome: LoginOutcome | None = None
|
|
173
|
+
self._last_history_line: str | None = None
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def is_done(self) -> bool:
|
|
177
|
+
"""`True` once the modal has been told to finish (success or failure).
|
|
178
|
+
|
|
179
|
+
Public accessor for callers that need to coordinate teardown from
|
|
180
|
+
outside the screen, e.g. the worker's `BaseException` branch that
|
|
181
|
+
unblocks dismiss without re-finishing.
|
|
182
|
+
"""
|
|
183
|
+
return self._done
|
|
184
|
+
|
|
185
|
+
def compose(self) -> ComposeResult:
|
|
186
|
+
"""Compose the modal body inside a `Vertical` container.
|
|
187
|
+
|
|
188
|
+
Yields:
|
|
189
|
+
Title, status, optional link, history, prompt label, input,
|
|
190
|
+
and help footer widgets, all parented inside a `Vertical`.
|
|
191
|
+
"""
|
|
192
|
+
with Vertical():
|
|
193
|
+
self._title_widget = Static(
|
|
194
|
+
Content.from_markup("MCP login: $name", name=self._server_name),
|
|
195
|
+
classes="ml-title",
|
|
196
|
+
markup=False,
|
|
197
|
+
)
|
|
198
|
+
yield self._title_widget
|
|
199
|
+
self._status_widget = Static(
|
|
200
|
+
self._status, classes="ml-status", markup=False
|
|
201
|
+
)
|
|
202
|
+
yield self._status_widget
|
|
203
|
+
self._link_widget = Static("", classes="ml-link")
|
|
204
|
+
self._link_widget.display = False
|
|
205
|
+
yield self._link_widget
|
|
206
|
+
self._history_widget = VerticalScroll(classes="ml-history")
|
|
207
|
+
self._history_widget.display = False
|
|
208
|
+
yield self._history_widget
|
|
209
|
+
self._prompt_widget = Static("", classes="ml-prompt", markup=False)
|
|
210
|
+
self._prompt_widget.display = False
|
|
211
|
+
yield self._prompt_widget
|
|
212
|
+
self._input_widget = Input(id="ml-input")
|
|
213
|
+
self._input_widget.display = False
|
|
214
|
+
yield self._input_widget
|
|
215
|
+
self._help_widget = Static("Esc to cancel", classes="ml-help")
|
|
216
|
+
yield self._help_widget
|
|
217
|
+
|
|
218
|
+
def on_mount(self) -> None:
|
|
219
|
+
"""Apply ASCII border when configured and start the spinner ticker."""
|
|
220
|
+
if is_ascii_mode():
|
|
221
|
+
container = self.query_one(Vertical)
|
|
222
|
+
colors = theme.get_theme_colors(self)
|
|
223
|
+
container.styles.border = ("ascii", colors.primary)
|
|
224
|
+
self._spinner_timer = self.set_interval(0.1, self._tick_spinner)
|
|
225
|
+
|
|
226
|
+
def on_click(self, event: Click) -> None:
|
|
227
|
+
"""Open style links, or expand/collapse the manual authorize URL."""
|
|
228
|
+
if (
|
|
229
|
+
event.widget is self._link_widget
|
|
230
|
+
and self._authorize_url is not None
|
|
231
|
+
and self._authorize_url_opened_in_browser
|
|
232
|
+
and not event.style.link
|
|
233
|
+
):
|
|
234
|
+
self._toggle_authorize_url()
|
|
235
|
+
event.stop()
|
|
236
|
+
return
|
|
237
|
+
open_style_link(event)
|
|
238
|
+
|
|
239
|
+
def on_mouse_move(self, event: MouseMove) -> None:
|
|
240
|
+
"""Show a pointer over links and the manual URL disclosure row."""
|
|
241
|
+
self.styles.pointer = (
|
|
242
|
+
"pointer"
|
|
243
|
+
if event.style.link or event.widget is self._link_widget
|
|
244
|
+
else "default"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
def on_leave(self) -> None:
|
|
248
|
+
"""Reset the pointer shape when the mouse leaves the modal."""
|
|
249
|
+
self.styles.pointer = "default"
|
|
250
|
+
|
|
251
|
+
# ------------------------------------------------------------------
|
|
252
|
+
# OAuthInteraction implementation.
|
|
253
|
+
# ------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
async def show_authorize_url(self, url: str, *, opened_in_browser: bool) -> None:
|
|
256
|
+
"""Render browser-open status and only reveal the URL when needed."""
|
|
257
|
+
self._authorize_url = url
|
|
258
|
+
self._authorize_url_opened_in_browser = opened_in_browser
|
|
259
|
+
self._authorize_url_expanded = not opened_in_browser
|
|
260
|
+
self._waiting_for_authorization = opened_in_browser
|
|
261
|
+
if opened_in_browser:
|
|
262
|
+
self._render_authorization_wait_status(self._spinner.next_frame())
|
|
263
|
+
else:
|
|
264
|
+
self._set_status(
|
|
265
|
+
f"Open the authorization URL manually to connect {self._server_name}.",
|
|
266
|
+
)
|
|
267
|
+
self._render_authorize_url()
|
|
268
|
+
|
|
269
|
+
async def request_callback_url(self) -> str:
|
|
270
|
+
"""Wait for the user to paste back the OAuth callback URL.
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
The trimmed callback URL.
|
|
274
|
+
"""
|
|
275
|
+
return await self._await_input(_PROMPT_CALLBACK)
|
|
276
|
+
|
|
277
|
+
async def show_device_code(
|
|
278
|
+
self,
|
|
279
|
+
*,
|
|
280
|
+
verification_uri: str,
|
|
281
|
+
user_code: str,
|
|
282
|
+
expires_in: int,
|
|
283
|
+
) -> None:
|
|
284
|
+
"""Render RFC 8628 device-code instructions inline."""
|
|
285
|
+
self._waiting_for_authorization = False
|
|
286
|
+
self._set_status(
|
|
287
|
+
f"Visit the URL below and enter the code (expires in {expires_in}s):",
|
|
288
|
+
)
|
|
289
|
+
if self._link_widget is not None:
|
|
290
|
+
self._link_widget.display = True
|
|
291
|
+
self._link_widget.update(
|
|
292
|
+
Content.assemble(
|
|
293
|
+
("Verification URL: ", "bold"),
|
|
294
|
+
(verification_uri, TStyle(link=verification_uri, underline=True)),
|
|
295
|
+
("\nUser code: ", "bold"),
|
|
296
|
+
(user_code, "bold"),
|
|
297
|
+
),
|
|
298
|
+
)
|
|
299
|
+
self._append_history(
|
|
300
|
+
f"Device code: visit {verification_uri} and enter {user_code}",
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
async def show_success(self, message: str) -> None:
|
|
304
|
+
"""Render a success status line without leaving OAuth fallback UI behind."""
|
|
305
|
+
self._waiting_for_authorization = False
|
|
306
|
+
self._hide_authorize_url()
|
|
307
|
+
self._set_status(message)
|
|
308
|
+
|
|
309
|
+
async def show_notice(self, message: str) -> None:
|
|
310
|
+
"""Append a progress notice without disrupting the active prompt."""
|
|
311
|
+
self._append_history(message)
|
|
312
|
+
|
|
313
|
+
async def show_error(self, message: str) -> None:
|
|
314
|
+
"""Render a fatal (flow-ending) error status line and history entry."""
|
|
315
|
+
self._waiting_for_authorization = False
|
|
316
|
+
self._set_status(message)
|
|
317
|
+
self._append_history(message)
|
|
318
|
+
|
|
319
|
+
# ------------------------------------------------------------------
|
|
320
|
+
# Internal helpers.
|
|
321
|
+
# ------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
def _hide_authorize_url(self) -> None:
|
|
324
|
+
"""Hide any OAuth authorization URL or fallback affordance."""
|
|
325
|
+
self._authorize_url = None
|
|
326
|
+
self._authorize_url_opened_in_browser = False
|
|
327
|
+
self._authorize_url_expanded = False
|
|
328
|
+
self._waiting_for_authorization = False
|
|
329
|
+
self._render_authorize_url()
|
|
330
|
+
self._set_help("Esc to cancel")
|
|
331
|
+
|
|
332
|
+
def _toggle_authorize_url(self) -> None:
|
|
333
|
+
"""Toggle the manual authorize URL when the fallback affordance is active."""
|
|
334
|
+
if (
|
|
335
|
+
self._pending_input is not None
|
|
336
|
+
or self._authorize_url is None
|
|
337
|
+
or not self._authorize_url_opened_in_browser
|
|
338
|
+
):
|
|
339
|
+
return
|
|
340
|
+
self._authorize_url_expanded = not self._authorize_url_expanded
|
|
341
|
+
self._render_authorize_url()
|
|
342
|
+
|
|
343
|
+
def _render_authorize_url(self) -> None:
|
|
344
|
+
"""Render the manual authorize URL affordance."""
|
|
345
|
+
if self._link_widget is None:
|
|
346
|
+
return
|
|
347
|
+
url = self._authorize_url
|
|
348
|
+
if url is None:
|
|
349
|
+
self._link_widget.display = False
|
|
350
|
+
self._link_widget.update("")
|
|
351
|
+
return
|
|
352
|
+
self._link_widget.display = True
|
|
353
|
+
glyphs = get_glyphs()
|
|
354
|
+
if self._authorize_url_opened_in_browser and not self._authorize_url_expanded:
|
|
355
|
+
self._link_widget.update(
|
|
356
|
+
Content.assemble(
|
|
357
|
+
("Having trouble? Show manual authorization URL ", "dim"),
|
|
358
|
+
(glyphs.cursor, "dim"),
|
|
359
|
+
),
|
|
360
|
+
)
|
|
361
|
+
self._set_help("Enter to show URL · Esc to cancel")
|
|
362
|
+
return
|
|
363
|
+
prefix = (
|
|
364
|
+
f"Having trouble? Hide manual authorization URL {glyphs.arrow_down}\n"
|
|
365
|
+
if self._authorize_url_opened_in_browser
|
|
366
|
+
else "Authorization URL:\n"
|
|
367
|
+
)
|
|
368
|
+
self._link_widget.update(
|
|
369
|
+
Content.assemble(
|
|
370
|
+
(prefix, "bold"),
|
|
371
|
+
(url, TStyle(link=url, underline=True)),
|
|
372
|
+
),
|
|
373
|
+
)
|
|
374
|
+
if self._authorize_url_opened_in_browser:
|
|
375
|
+
self._set_help("Enter to hide URL · Esc to cancel")
|
|
376
|
+
else:
|
|
377
|
+
self._set_help("Esc to cancel")
|
|
378
|
+
|
|
379
|
+
def _render_authorization_wait_status(self, frame: str) -> None:
|
|
380
|
+
"""Render the browser-opened waiting state with an animated status line."""
|
|
381
|
+
self._set_status(
|
|
382
|
+
f"We opened your browser to connect {self._server_name}.\n"
|
|
383
|
+
"Complete authorization there to continue.\n\n"
|
|
384
|
+
f"Status: {frame} Waiting…",
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
def _set_status(self, message: str) -> None:
|
|
388
|
+
"""Update the top status line."""
|
|
389
|
+
self._status = message
|
|
390
|
+
if self._status_widget is not None:
|
|
391
|
+
self._status_widget.update(Content.from_markup("$msg", msg=message))
|
|
392
|
+
|
|
393
|
+
def _set_help(self, message: str) -> None:
|
|
394
|
+
"""Update the footer help text."""
|
|
395
|
+
if self._help_widget is not None:
|
|
396
|
+
self._help_widget.update(message)
|
|
397
|
+
|
|
398
|
+
def _hide_history(self) -> None:
|
|
399
|
+
"""Hide the history pane for clean terminal states."""
|
|
400
|
+
if self._history_widget is not None:
|
|
401
|
+
self._history_widget.display = False
|
|
402
|
+
self._last_history_line = None
|
|
403
|
+
|
|
404
|
+
def _append_history(self, line: str) -> None:
|
|
405
|
+
"""Append a line to the scrolling history pane."""
|
|
406
|
+
if self._history_widget is None or line == self._last_history_line:
|
|
407
|
+
return
|
|
408
|
+
self._last_history_line = line
|
|
409
|
+
self._history_widget.display = True
|
|
410
|
+
self._history_widget.mount(
|
|
411
|
+
Static(line, classes="ml-history-line", markup=False)
|
|
412
|
+
)
|
|
413
|
+
self._history_widget.scroll_end(animate=False)
|
|
414
|
+
|
|
415
|
+
async def _await_input(self, prompt: str) -> str:
|
|
416
|
+
"""Show `prompt`, wait for `Enter`, and return the typed value.
|
|
417
|
+
|
|
418
|
+
Returns:
|
|
419
|
+
The raw input value the user submitted.
|
|
420
|
+
|
|
421
|
+
Raises:
|
|
422
|
+
MCPLoginCancelledError: When the modal is cancelled before or
|
|
423
|
+
during submission.
|
|
424
|
+
RuntimeError: When a concurrent prompt is already active.
|
|
425
|
+
"""
|
|
426
|
+
if self._cancelled:
|
|
427
|
+
msg = "MCP login was cancelled before the prompt could be shown."
|
|
428
|
+
raise MCPLoginCancelledError(msg)
|
|
429
|
+
if self._pending_input is not None:
|
|
430
|
+
msg = (
|
|
431
|
+
"MCP login modal cannot have two concurrent input prompts; "
|
|
432
|
+
"the previous prompt was not resolved."
|
|
433
|
+
)
|
|
434
|
+
raise RuntimeError(msg)
|
|
435
|
+
|
|
436
|
+
loop = asyncio.get_running_loop()
|
|
437
|
+
future: asyncio.Future[str] = loop.create_future()
|
|
438
|
+
self._pending_input = future
|
|
439
|
+
if self._prompt_widget is not None:
|
|
440
|
+
self._prompt_widget.display = True
|
|
441
|
+
self._prompt_widget.update(prompt)
|
|
442
|
+
if self._input_widget is not None:
|
|
443
|
+
self._input_widget.value = ""
|
|
444
|
+
self._input_widget.display = True
|
|
445
|
+
self._input_widget.focus()
|
|
446
|
+
try:
|
|
447
|
+
return await future
|
|
448
|
+
finally:
|
|
449
|
+
self._pending_input = None
|
|
450
|
+
if self._input_widget is not None:
|
|
451
|
+
self._input_widget.display = False
|
|
452
|
+
if self._prompt_widget is not None:
|
|
453
|
+
self._prompt_widget.display = False
|
|
454
|
+
|
|
455
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
456
|
+
"""Resolve the active prompt with the submitted value."""
|
|
457
|
+
if event.input.id != "ml-input":
|
|
458
|
+
return
|
|
459
|
+
future = self._pending_input
|
|
460
|
+
if future is not None and not future.done():
|
|
461
|
+
future.set_result(event.value)
|
|
462
|
+
|
|
463
|
+
def action_toggle_authorize_url(self) -> None:
|
|
464
|
+
"""Toggle the manual authorize URL fallback via Enter."""
|
|
465
|
+
self._toggle_authorize_url()
|
|
466
|
+
|
|
467
|
+
def action_cancel(self) -> None:
|
|
468
|
+
"""Cancel the login flow.
|
|
469
|
+
|
|
470
|
+
Sets a cancelled flag, completes any outstanding prompt with
|
|
471
|
+
`MCPLoginCancelledError` so the worker unblocks, and dismisses
|
|
472
|
+
the modal with the `cancelled` outcome.
|
|
473
|
+
"""
|
|
474
|
+
if self._done:
|
|
475
|
+
return
|
|
476
|
+
self._cancelled = True
|
|
477
|
+
self._done = True
|
|
478
|
+
self._outcome = "cancelled"
|
|
479
|
+
future = self._pending_input
|
|
480
|
+
if future is not None and not future.done():
|
|
481
|
+
future.set_exception(
|
|
482
|
+
MCPLoginCancelledError("MCP login was cancelled by the user.")
|
|
483
|
+
)
|
|
484
|
+
self._stop_spinner_timer()
|
|
485
|
+
self.dismiss("cancelled")
|
|
486
|
+
|
|
487
|
+
def finish(self, *, success: bool, message: str | None = None) -> None:
|
|
488
|
+
"""Close the modal from the worker, reporting the final outcome.
|
|
489
|
+
|
|
490
|
+
Dismiss is deferred by 0.6s so the user sees the final status
|
|
491
|
+
before the modal disappears.
|
|
492
|
+
|
|
493
|
+
Args:
|
|
494
|
+
success: `True` when login succeeded; drives the dismiss value.
|
|
495
|
+
message: Optional final status line shown before close.
|
|
496
|
+
"""
|
|
497
|
+
if self._done:
|
|
498
|
+
return
|
|
499
|
+
self._done = True
|
|
500
|
+
self._outcome = "success" if success else "failed"
|
|
501
|
+
self._stop_spinner_timer()
|
|
502
|
+
self._waiting_for_authorization = False
|
|
503
|
+
if success:
|
|
504
|
+
self._hide_authorize_url()
|
|
505
|
+
self._hide_history()
|
|
506
|
+
if message is not None:
|
|
507
|
+
self._set_status(message)
|
|
508
|
+
if not success:
|
|
509
|
+
self._append_history(message)
|
|
510
|
+
glyphs = get_glyphs()
|
|
511
|
+
marker = glyphs.checkmark if success else glyphs.error
|
|
512
|
+
if self._title_widget is not None:
|
|
513
|
+
self._title_widget.update(
|
|
514
|
+
Content.from_markup(
|
|
515
|
+
"MCP login: $name $marker",
|
|
516
|
+
name=self._server_name,
|
|
517
|
+
marker=marker,
|
|
518
|
+
)
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
def _deferred_dismiss() -> None:
|
|
522
|
+
# Must be a def (not a lambda): Textual's `_invoke` auto-awaits
|
|
523
|
+
# any awaitable return, and `dismiss()` returns `AwaitComplete`;
|
|
524
|
+
# awaiting it inside the screen's own message pump raises
|
|
525
|
+
# `ScreenError`. A `None`-returning def discards the awaitable.
|
|
526
|
+
self.dismiss(self._outcome or "failed")
|
|
527
|
+
|
|
528
|
+
self.set_timer(0.6, _deferred_dismiss)
|
|
529
|
+
|
|
530
|
+
def _tick_spinner(self) -> None:
|
|
531
|
+
"""Advance the status-line spinner while waiting for browser auth."""
|
|
532
|
+
if self._done or not self._waiting_for_authorization:
|
|
533
|
+
return
|
|
534
|
+
self._render_authorization_wait_status(self._spinner.next_frame())
|
|
535
|
+
|
|
536
|
+
def _stop_spinner_timer(self) -> None:
|
|
537
|
+
if self._spinner_timer is not None:
|
|
538
|
+
self._spinner_timer.stop()
|
|
539
|
+
self._spinner_timer = None
|