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,452 @@
|
|
|
1
|
+
"""ChatGPT OAuth sign-in screen, reachable via `/auth` -> `openai_codex`.
|
|
2
|
+
|
|
3
|
+
Mirrors the MCP loopback flow in `mcp_auth` from the user's POV: a modal
|
|
4
|
+
shows progress, surfaces the authorize URL inline (so headless / SSH users
|
|
5
|
+
can copy it when the browser launch fails), and dismisses once the OAuth
|
|
6
|
+
callback completes. The OAuth primitives themselves (PKCE, callback HTTP
|
|
7
|
+
server, token exchange, refresh, atomic file write) are delegated to
|
|
8
|
+
`langchain_openai.chatgpt_oauth` via the
|
|
9
|
+
`deepagents_code.integrations.openai_codex` adapter.
|
|
10
|
+
|
|
11
|
+
Security notes:
|
|
12
|
+
|
|
13
|
+
- The authorize URL displayed inline does not contain secrets — it carries
|
|
14
|
+
only the PKCE *challenge* (the verifier never leaves this process).
|
|
15
|
+
- The success / error messages reported back via `notify` never include
|
|
16
|
+
the access token, refresh token, or ID token.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
import threading
|
|
23
|
+
import webbrowser
|
|
24
|
+
from enum import StrEnum
|
|
25
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
26
|
+
|
|
27
|
+
from textual.binding import Binding, BindingType
|
|
28
|
+
from textual.color import Color as TColor
|
|
29
|
+
from textual.containers import Vertical
|
|
30
|
+
from textual.content import Content
|
|
31
|
+
from textual.screen import ModalScreen
|
|
32
|
+
from textual.style import Style as TStyle
|
|
33
|
+
from textual.widgets import Static
|
|
34
|
+
from textual.worker import Worker, WorkerCancelled, WorkerFailed, WorkerState
|
|
35
|
+
|
|
36
|
+
from deepagents_code import theme
|
|
37
|
+
from deepagents_code.config import get_glyphs, is_ascii_mode
|
|
38
|
+
from deepagents_code.integrations import openai_codex as codex_integration
|
|
39
|
+
from deepagents_code.model_config import clear_caches
|
|
40
|
+
from deepagents_code.tui.widgets._links import open_style_link
|
|
41
|
+
|
|
42
|
+
if TYPE_CHECKING:
|
|
43
|
+
from textual.app import ComposeResult
|
|
44
|
+
from textual.events import Click, MouseMove
|
|
45
|
+
|
|
46
|
+
logger = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _ScreenInteraction(codex_integration.CodexLoginInteraction):
|
|
50
|
+
"""Bridge `CodexLoginInteraction` callbacks into the modal.
|
|
51
|
+
|
|
52
|
+
`run_browser_login` runs from a Textual *async* worker (not a thread),
|
|
53
|
+
so it shares the app's event loop. That means the callbacks land on
|
|
54
|
+
the same thread Textual renders from and can mutate widgets directly —
|
|
55
|
+
no `call_from_thread` round-trip required (using it from the UI thread
|
|
56
|
+
would raise).
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, screen: CodexAuthScreen) -> None:
|
|
60
|
+
"""Bind the interaction to the modal it should drive."""
|
|
61
|
+
self._screen = screen
|
|
62
|
+
|
|
63
|
+
async def show_authorize_url( # awaited by the interaction protocol
|
|
64
|
+
self, url: str, *, opened_in_browser: bool
|
|
65
|
+
) -> None:
|
|
66
|
+
self._screen.on_authorize_url(url, opened_in_browser)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class CodexAuthScreen(ModalScreen[bool]):
|
|
70
|
+
"""Run the ChatGPT OAuth Authorization Code Flow with PKCE inline.
|
|
71
|
+
|
|
72
|
+
Dismissal value:
|
|
73
|
+
|
|
74
|
+
- `True`: a token was saved (caller should refresh provider lists /
|
|
75
|
+
retry the operation that needed the credential).
|
|
76
|
+
- `False`: the user cancelled, or the flow failed irrecoverably.
|
|
77
|
+
|
|
78
|
+
The flow lives in a worker so the modal stays responsive to the cancel
|
|
79
|
+
keybinding while `_wait_for_oauth_callback` blocks for up to 5 minutes;
|
|
80
|
+
pressing Esc sets the worker's `cancel_event`, which frees the loopback
|
|
81
|
+
port within one poll interval rather than holding it until the timeout.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
85
|
+
Binding("escape", "cancel", "Cancel", show=False, priority=True),
|
|
86
|
+
Binding("ctrl+c", "cancel", "Cancel", show=False, priority=True),
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
CSS = """
|
|
90
|
+
CodexAuthScreen {
|
|
91
|
+
align: center middle;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
CodexAuthScreen > Vertical {
|
|
95
|
+
width: 80;
|
|
96
|
+
max-width: 90%;
|
|
97
|
+
height: auto;
|
|
98
|
+
background: $surface;
|
|
99
|
+
border: solid $primary;
|
|
100
|
+
padding: 1 2;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
CodexAuthScreen .codex-auth-title {
|
|
104
|
+
text-style: bold;
|
|
105
|
+
color: $primary;
|
|
106
|
+
text-align: center;
|
|
107
|
+
margin-bottom: 1;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
CodexAuthScreen .codex-auth-copy {
|
|
111
|
+
height: auto;
|
|
112
|
+
color: $text;
|
|
113
|
+
margin-bottom: 1;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
CodexAuthScreen .codex-auth-status {
|
|
117
|
+
height: auto;
|
|
118
|
+
color: $text-muted;
|
|
119
|
+
margin-bottom: 1;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
CodexAuthScreen .codex-auth-url {
|
|
123
|
+
height: auto;
|
|
124
|
+
color: $text;
|
|
125
|
+
margin-bottom: 1;
|
|
126
|
+
text-style: italic;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
CodexAuthScreen .codex-auth-help {
|
|
130
|
+
height: 1;
|
|
131
|
+
color: $text-muted;
|
|
132
|
+
text-style: italic;
|
|
133
|
+
text-align: center;
|
|
134
|
+
}
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
def __init__(self) -> None:
|
|
138
|
+
"""Initialize with no active worker; the flow starts on mount."""
|
|
139
|
+
super().__init__()
|
|
140
|
+
self._cancel_event = threading.Event()
|
|
141
|
+
self._worker: Worker[codex_integration.CodexAuthStatus] | None = None
|
|
142
|
+
|
|
143
|
+
def compose(self) -> ComposeResult: # noqa: PLR6301 # Textual handler signature
|
|
144
|
+
"""Compose the modal layout.
|
|
145
|
+
|
|
146
|
+
Yields:
|
|
147
|
+
Title, copy, status line, URL line, and help footer widgets.
|
|
148
|
+
"""
|
|
149
|
+
glyphs = get_glyphs()
|
|
150
|
+
with Vertical():
|
|
151
|
+
yield Static(
|
|
152
|
+
"Sign in with ChatGPT",
|
|
153
|
+
classes="codex-auth-title",
|
|
154
|
+
)
|
|
155
|
+
yield Static(
|
|
156
|
+
Content.assemble(
|
|
157
|
+
"Authorize Deep Agents to call ChatGPT Codex models on "
|
|
158
|
+
"your behalf. We will open your default browser to "
|
|
159
|
+
"openai.com to sign in.",
|
|
160
|
+
),
|
|
161
|
+
classes="codex-auth-copy",
|
|
162
|
+
)
|
|
163
|
+
yield Static(
|
|
164
|
+
"Preparing OAuth flow...",
|
|
165
|
+
id="codex-auth-status",
|
|
166
|
+
classes="codex-auth-status",
|
|
167
|
+
)
|
|
168
|
+
yield Static(
|
|
169
|
+
"",
|
|
170
|
+
id="codex-auth-url",
|
|
171
|
+
classes="codex-auth-url",
|
|
172
|
+
)
|
|
173
|
+
yield Static(
|
|
174
|
+
f"Esc cancel {glyphs.bullet} a browser window will open shortly",
|
|
175
|
+
classes="codex-auth-help",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def on_mount(self) -> None:
|
|
179
|
+
"""Apply ASCII border when needed and kick off the OAuth worker."""
|
|
180
|
+
if is_ascii_mode():
|
|
181
|
+
container = self.query_one(Vertical)
|
|
182
|
+
colors = theme.get_theme_colors(self)
|
|
183
|
+
container.styles.border = ("ascii", colors.success)
|
|
184
|
+
self._worker = self.run_worker(
|
|
185
|
+
self._run_login(),
|
|
186
|
+
name="codex-oauth",
|
|
187
|
+
exclusive=True,
|
|
188
|
+
thread=False,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
def on_click(self, event: Click) -> None: # noqa: PLR6301 - Textual handler
|
|
192
|
+
"""Open the authorize URL when the user clicks it."""
|
|
193
|
+
open_style_link(event)
|
|
194
|
+
|
|
195
|
+
def on_mouse_move(self, event: MouseMove) -> None:
|
|
196
|
+
"""Show a pointer over inline authorization links."""
|
|
197
|
+
self.styles.pointer = "pointer" if event.style.link else "default"
|
|
198
|
+
|
|
199
|
+
def on_leave(self) -> None:
|
|
200
|
+
"""Reset the pointer shape when the mouse leaves the modal."""
|
|
201
|
+
self.styles.pointer = "default"
|
|
202
|
+
|
|
203
|
+
async def _run_login(self) -> codex_integration.CodexAuthStatus:
|
|
204
|
+
"""Worker body: drive the upstream OAuth flow with our UI hooks.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
The fresh `CodexAuthStatus` returned by `run_browser_login`, used
|
|
208
|
+
by `on_worker_state_changed` to render the success toast.
|
|
209
|
+
|
|
210
|
+
Raises:
|
|
211
|
+
CodexLoginCancelledError: Re-raised so the worker enters the
|
|
212
|
+
ERROR state and `on_worker_state_changed` can translate it
|
|
213
|
+
into a "cancelled" toast and modal dismissal.
|
|
214
|
+
"""
|
|
215
|
+
try:
|
|
216
|
+
status = await codex_integration.run_browser_login(
|
|
217
|
+
_ScreenInteraction(self),
|
|
218
|
+
cancel_event=self._cancel_event,
|
|
219
|
+
)
|
|
220
|
+
except codex_integration.CodexLoginCancelledError:
|
|
221
|
+
logger.info("ChatGPT OAuth sign-in cancelled by user")
|
|
222
|
+
raise
|
|
223
|
+
clear_caches()
|
|
224
|
+
return status
|
|
225
|
+
|
|
226
|
+
def on_authorize_url(self, url: str, opened_in_browser: bool) -> None:
|
|
227
|
+
"""Render the authorize URL in the modal.
|
|
228
|
+
|
|
229
|
+
Called on the event loop from the async sign-in worker (the worker is
|
|
230
|
+
started with `thread=False`), so it can mutate widgets directly.
|
|
231
|
+
"""
|
|
232
|
+
status = self.query_one("#codex-auth-status", Static)
|
|
233
|
+
url_label = self.query_one("#codex-auth-url", Static)
|
|
234
|
+
if opened_in_browser:
|
|
235
|
+
status.update("Waiting for you to finish signing in...")
|
|
236
|
+
else:
|
|
237
|
+
status.update("Could not launch a browser — open this URL manually:")
|
|
238
|
+
colors = theme.get_theme_colors(self)
|
|
239
|
+
ansi = self.app.theme in {"ansi-dark", "ansi-light"}
|
|
240
|
+
link_style: str | TStyle = (
|
|
241
|
+
TStyle(bold=True, underline=True, link=url)
|
|
242
|
+
if ansi
|
|
243
|
+
else TStyle(
|
|
244
|
+
foreground=TColor.parse(colors.primary),
|
|
245
|
+
underline=True,
|
|
246
|
+
link=url,
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
# `Content.assemble` with a (text, style) tuple skips markup parsing,
|
|
250
|
+
# so a URL containing `[` (rare but possible in state params) cannot
|
|
251
|
+
# crash the renderer.
|
|
252
|
+
url_label.update(Content.assemble((url, link_style)))
|
|
253
|
+
|
|
254
|
+
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
|
|
255
|
+
"""React to worker completion: notify, then dismiss the modal."""
|
|
256
|
+
if event.worker is not self._worker:
|
|
257
|
+
return
|
|
258
|
+
state = event.state
|
|
259
|
+
if state is WorkerState.SUCCESS:
|
|
260
|
+
result = event.worker.result
|
|
261
|
+
detail = "Signed in to ChatGPT."
|
|
262
|
+
if (
|
|
263
|
+
isinstance(result, codex_integration.CodexAuthStatus)
|
|
264
|
+
and result.plan_type
|
|
265
|
+
):
|
|
266
|
+
detail = f"Signed in to ChatGPT ({result.plan_type})."
|
|
267
|
+
self.app.notify(detail, markup=False)
|
|
268
|
+
self.dismiss(True)
|
|
269
|
+
elif state is WorkerState.CANCELLED:
|
|
270
|
+
self.app.notify("Sign-in cancelled.", markup=False)
|
|
271
|
+
self.dismiss(False)
|
|
272
|
+
elif state is WorkerState.ERROR:
|
|
273
|
+
error = event.worker.error
|
|
274
|
+
if isinstance(error, WorkerCancelled):
|
|
275
|
+
self.app.notify("Sign-in cancelled.", markup=False)
|
|
276
|
+
self.dismiss(False)
|
|
277
|
+
return
|
|
278
|
+
# `WorkerFailed` wraps the real exception (surfaced via
|
|
279
|
+
# `error.error`); unwrap it so we can both detect a cancellation
|
|
280
|
+
# that arrived via ERROR rather than CANCELLED and render an
|
|
281
|
+
# accurate message. Test `inner`, not the wrapper.
|
|
282
|
+
inner = (
|
|
283
|
+
getattr(error, "error", error)
|
|
284
|
+
if isinstance(error, WorkerFailed)
|
|
285
|
+
else error
|
|
286
|
+
)
|
|
287
|
+
if isinstance(inner, codex_integration.CodexLoginCancelledError):
|
|
288
|
+
self.app.notify("Sign-in cancelled.", markup=False)
|
|
289
|
+
self.dismiss(False)
|
|
290
|
+
return
|
|
291
|
+
detail = str(inner) if inner else "Sign-in failed."
|
|
292
|
+
logger.warning("ChatGPT OAuth sign-in failed: %s", detail)
|
|
293
|
+
self.app.notify(
|
|
294
|
+
f"Sign-in failed: {detail}",
|
|
295
|
+
severity="error",
|
|
296
|
+
markup=False,
|
|
297
|
+
)
|
|
298
|
+
self.dismiss(False)
|
|
299
|
+
|
|
300
|
+
def action_cancel(self) -> None:
|
|
301
|
+
"""Cancel the sign-in flow and dismiss the modal."""
|
|
302
|
+
self._cancel_event.set()
|
|
303
|
+
if self._worker is not None:
|
|
304
|
+
self._worker.cancel()
|
|
305
|
+
# `cancel()` triggers `WorkerState.CANCELLED` on the worker, which
|
|
306
|
+
# `on_worker_state_changed` translates into the dismissal. Don't
|
|
307
|
+
# dismiss eagerly here — that would race the success / error path
|
|
308
|
+
# if the callback already landed.
|
|
309
|
+
# However, if the worker hasn't been created yet (mount race), make
|
|
310
|
+
# sure the modal still goes away.
|
|
311
|
+
if self._worker is None:
|
|
312
|
+
self.dismiss(False)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class CodexSignedInAction(StrEnum):
|
|
316
|
+
"""Outcome of the `CodexSignedInScreen` quick-action overlay.
|
|
317
|
+
|
|
318
|
+
Encoded as an enum (mirroring `AuthResult`) rather than bare strings so a
|
|
319
|
+
typo in either the producing `action_*` method or the consuming dispatch
|
|
320
|
+
is a type error, not a silent no-op.
|
|
321
|
+
"""
|
|
322
|
+
|
|
323
|
+
SIGN_OUT = "signout"
|
|
324
|
+
"""Delete the stored ChatGPT token."""
|
|
325
|
+
|
|
326
|
+
REAUTH = "reauth"
|
|
327
|
+
"""Open the OAuth flow again (e.g., to switch account)."""
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
class CodexSignedInScreen(ModalScreen["CodexSignedInAction | None"]):
|
|
331
|
+
"""Quick-action overlay shown when `openai_codex` is already signed in.
|
|
332
|
+
|
|
333
|
+
Dismissal values:
|
|
334
|
+
|
|
335
|
+
- `CodexSignedInAction.SIGN_OUT`: delete the stored token.
|
|
336
|
+
- `CodexSignedInAction.REAUTH`: open the OAuth flow again.
|
|
337
|
+
- `None`: close without changes.
|
|
338
|
+
"""
|
|
339
|
+
|
|
340
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
341
|
+
Binding("escape", "cancel", "Cancel", show=False, priority=True),
|
|
342
|
+
Binding("s", "signout", "Sign out", show=False, priority=True),
|
|
343
|
+
Binding("r", "reauth", "Reauth", show=False, priority=True),
|
|
344
|
+
]
|
|
345
|
+
|
|
346
|
+
CSS = """
|
|
347
|
+
CodexSignedInScreen {
|
|
348
|
+
align: center middle;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
CodexSignedInScreen > Vertical {
|
|
352
|
+
width: 64;
|
|
353
|
+
height: auto;
|
|
354
|
+
background: $surface;
|
|
355
|
+
border: solid $primary;
|
|
356
|
+
padding: 1 2;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
CodexSignedInScreen .codex-signed-title {
|
|
360
|
+
text-style: bold;
|
|
361
|
+
color: $primary;
|
|
362
|
+
text-align: center;
|
|
363
|
+
margin-bottom: 1;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
CodexSignedInScreen .codex-signed-copy {
|
|
367
|
+
height: auto;
|
|
368
|
+
margin-bottom: 1;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
CodexSignedInScreen .codex-signed-help {
|
|
372
|
+
height: 1;
|
|
373
|
+
color: $text-muted;
|
|
374
|
+
text-style: italic;
|
|
375
|
+
text-align: center;
|
|
376
|
+
}
|
|
377
|
+
"""
|
|
378
|
+
|
|
379
|
+
def compose(self) -> ComposeResult: # noqa: PLR6301 # Textual handler signature
|
|
380
|
+
"""Compose the overlay.
|
|
381
|
+
|
|
382
|
+
Yields:
|
|
383
|
+
Title + body + key-hint widgets.
|
|
384
|
+
"""
|
|
385
|
+
glyphs = get_glyphs()
|
|
386
|
+
status = codex_integration.get_status()
|
|
387
|
+
if status.plan_type and status.account_id:
|
|
388
|
+
body = (
|
|
389
|
+
f"Signed in to ChatGPT ({status.plan_type}) as account "
|
|
390
|
+
f"{status.account_id}."
|
|
391
|
+
)
|
|
392
|
+
elif status.plan_type:
|
|
393
|
+
body = f"Signed in to ChatGPT ({status.plan_type})."
|
|
394
|
+
else:
|
|
395
|
+
body = "Signed in to ChatGPT."
|
|
396
|
+
with Vertical():
|
|
397
|
+
yield Static("ChatGPT sign-in", classes="codex-signed-title")
|
|
398
|
+
yield Static(
|
|
399
|
+
Content.from_markup("$body", body=body),
|
|
400
|
+
classes="codex-signed-copy",
|
|
401
|
+
)
|
|
402
|
+
yield Static(
|
|
403
|
+
f"S sign out {glyphs.bullet} R sign in again {glyphs.bullet} Esc close",
|
|
404
|
+
classes="codex-signed-help",
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
def on_mount(self) -> None:
|
|
408
|
+
"""Apply ASCII border when needed."""
|
|
409
|
+
if is_ascii_mode():
|
|
410
|
+
container = self.query_one(Vertical)
|
|
411
|
+
colors = theme.get_theme_colors(self)
|
|
412
|
+
container.styles.border = ("ascii", colors.success)
|
|
413
|
+
|
|
414
|
+
def action_signout(self) -> None:
|
|
415
|
+
"""Dismiss with `SIGN_OUT` so the manager deletes the stored token."""
|
|
416
|
+
self.dismiss(CodexSignedInAction.SIGN_OUT)
|
|
417
|
+
|
|
418
|
+
def action_reauth(self) -> None:
|
|
419
|
+
"""Dismiss with `REAUTH` so the manager kicks off a new flow."""
|
|
420
|
+
self.dismiss(CodexSignedInAction.REAUTH)
|
|
421
|
+
|
|
422
|
+
def action_cancel(self) -> None:
|
|
423
|
+
"""Close without changes."""
|
|
424
|
+
self.dismiss(None)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def open_chatgpt_login_url() -> bool:
|
|
428
|
+
"""Open the ChatGPT account page in the user's browser.
|
|
429
|
+
|
|
430
|
+
Used by `/auth` to let signed-in users jump to chatgpt.com (e.g., to
|
|
431
|
+
change account, manage billing).
|
|
432
|
+
|
|
433
|
+
Returns:
|
|
434
|
+
Whether a browser actually launched — callers can fall back to a
|
|
435
|
+
manual-URL toast on `False`.
|
|
436
|
+
"""
|
|
437
|
+
try:
|
|
438
|
+
return webbrowser.open("https://chatgpt.com/")
|
|
439
|
+
except (webbrowser.Error, OSError) as exc:
|
|
440
|
+
# `OSError` (not just `webbrowser.Error`) escapes when a configured
|
|
441
|
+
# launcher's binary is missing; treat it as "no browser launched" so
|
|
442
|
+
# the caller can fall back to a manual-URL toast.
|
|
443
|
+
logger.warning("Could not open chatgpt.com: %s", exc)
|
|
444
|
+
return False
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
__all__ = [
|
|
448
|
+
"CodexAuthScreen",
|
|
449
|
+
"CodexSignedInAction",
|
|
450
|
+
"CodexSignedInScreen",
|
|
451
|
+
"open_chatgpt_login_url",
|
|
452
|
+
]
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Prompt for switching cwd when resuming or switching threads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, ClassVar, Literal, assert_never, cast
|
|
6
|
+
|
|
7
|
+
from textual.binding import Binding, BindingType
|
|
8
|
+
from textual.containers import Vertical
|
|
9
|
+
from textual.screen import ModalScreen
|
|
10
|
+
from textual.widgets import Static
|
|
11
|
+
|
|
12
|
+
from deepagents_code.sessions import format_path
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from textual.app import ComposeResult
|
|
16
|
+
|
|
17
|
+
from deepagents_code.app import DeepAgentsApp
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
CwdSwitchChoice = Literal["switch", "stay", "abort"]
|
|
21
|
+
"""Outcome of the cwd switch prompt.
|
|
22
|
+
|
|
23
|
+
`"abort"` is only offered when the prompt is opened with an `abort` mode set;
|
|
24
|
+
its meaning depends on that mode (see `CwdSwitchAbortMode`).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
CwdSwitchAbortMode = Literal["resume", "thread_switch"]
|
|
28
|
+
"""Which flow opened an abort-capable prompt, selecting the abort wording.
|
|
29
|
+
|
|
30
|
+
Passed as the prompt's `abort` argument; `None` there means abort is not
|
|
31
|
+
offered. `"resume"` is the launch-time `-r` resume (abort starts a new
|
|
32
|
+
session); `"thread_switch"` is the in-session `/threads` switcher (abort keeps
|
|
33
|
+
the current thread). Its members are kept disjoint from `CwdSwitchChoice`'s as a
|
|
34
|
+
naming convention -- not a type guarantee (these are distinct `Literal` types
|
|
35
|
+
used at distinct sites, so a checker already keeps them apart) -- so a mode token
|
|
36
|
+
is never mistaken for an outcome token in a log, test, or debugger.
|
|
37
|
+
`test_abort_mode_tokens_disjoint_from_choice` enforces it.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class CwdSwitchPromptScreen(ModalScreen[CwdSwitchChoice]):
|
|
42
|
+
"""Modal asking whether to switch cwd when resuming or switching to a thread."""
|
|
43
|
+
|
|
44
|
+
can_focus = True
|
|
45
|
+
can_focus_children = False
|
|
46
|
+
|
|
47
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
48
|
+
Binding("enter", "switch", "Switch", show=False, priority=True),
|
|
49
|
+
Binding("escape", "stay", "Stay", show=False, priority=True),
|
|
50
|
+
Binding("a", "abort", "Abort", show=False, priority=True),
|
|
51
|
+
Binding(
|
|
52
|
+
"ctrl+c",
|
|
53
|
+
"quit_or_interrupt",
|
|
54
|
+
"Quit/Interrupt",
|
|
55
|
+
show=False,
|
|
56
|
+
priority=True,
|
|
57
|
+
),
|
|
58
|
+
Binding("ctrl+d", "quit_app", "Quit", show=False, priority=True),
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
CSS = """
|
|
62
|
+
CwdSwitchPromptScreen {
|
|
63
|
+
align: center middle;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
CwdSwitchPromptScreen > Vertical {
|
|
67
|
+
width: 72;
|
|
68
|
+
max-width: 90%;
|
|
69
|
+
height: auto;
|
|
70
|
+
background: $surface;
|
|
71
|
+
border: solid $warning;
|
|
72
|
+
padding: 1 2;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
CwdSwitchPromptScreen .cwd-switch-title {
|
|
76
|
+
text-style: bold;
|
|
77
|
+
color: $warning;
|
|
78
|
+
text-align: center;
|
|
79
|
+
margin-bottom: 1;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
CwdSwitchPromptScreen .cwd-switch-body {
|
|
83
|
+
height: auto;
|
|
84
|
+
color: $text;
|
|
85
|
+
margin-bottom: 1;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
CwdSwitchPromptScreen .cwd-switch-help {
|
|
89
|
+
height: 1;
|
|
90
|
+
color: $text-muted;
|
|
91
|
+
text-style: italic;
|
|
92
|
+
text-align: center;
|
|
93
|
+
}
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
*,
|
|
99
|
+
current_cwd: str,
|
|
100
|
+
thread_cwd: str,
|
|
101
|
+
project_settings_change_detected: bool = False,
|
|
102
|
+
abort: CwdSwitchAbortMode | None = None,
|
|
103
|
+
) -> None:
|
|
104
|
+
"""Initialize the prompt."""
|
|
105
|
+
super().__init__()
|
|
106
|
+
self._current_cwd = current_cwd
|
|
107
|
+
self._thread_cwd = thread_cwd
|
|
108
|
+
self._project_settings_change_detected = project_settings_change_detected
|
|
109
|
+
self._abort: CwdSwitchAbortMode | None = abort
|
|
110
|
+
|
|
111
|
+
def _title_text(self) -> str:
|
|
112
|
+
"""Return the title, phrased for the flow that opened the prompt.
|
|
113
|
+
|
|
114
|
+
The in-session `/threads` switcher (`"thread_switch"`) asks about
|
|
115
|
+
switching; every other flow (launch-time resume, or no abort mode) asks
|
|
116
|
+
about resuming. Structured for `assert_never` exhaustiveness so a new
|
|
117
|
+
mode fails statically here rather than silently inheriting the resume
|
|
118
|
+
wording.
|
|
119
|
+
"""
|
|
120
|
+
if self._abort is None or self._abort == "resume":
|
|
121
|
+
return "Resume from the thread's original directory?"
|
|
122
|
+
if self._abort == "thread_switch":
|
|
123
|
+
return "Switch to the thread's original directory?"
|
|
124
|
+
assert_never(self._abort)
|
|
125
|
+
|
|
126
|
+
def _body_text(self) -> str:
|
|
127
|
+
"""Return the prompt body text."""
|
|
128
|
+
current = format_path(self._current_cwd)
|
|
129
|
+
target = format_path(self._thread_cwd)
|
|
130
|
+
settings_note = (
|
|
131
|
+
"\n\nSwitching may also reload project-specific config like .env, "
|
|
132
|
+
"MCP, skills, and AGENTS.md."
|
|
133
|
+
if self._project_settings_change_detected
|
|
134
|
+
else ""
|
|
135
|
+
)
|
|
136
|
+
if self._abort is None or self._abort == "thread_switch":
|
|
137
|
+
abort_note = ""
|
|
138
|
+
elif self._abort == "resume":
|
|
139
|
+
abort_note = "\n\nOr abort to start a new session instead of resuming."
|
|
140
|
+
else:
|
|
141
|
+
assert_never(self._abort)
|
|
142
|
+
return (
|
|
143
|
+
"This thread was last used from:\n"
|
|
144
|
+
f" {target}\n\n"
|
|
145
|
+
"You're currently in:\n"
|
|
146
|
+
f" {current}\n\n"
|
|
147
|
+
"Switch if you want local context, project instructions, skills, "
|
|
148
|
+
"MCP config, and env files to match the original directory. Stay "
|
|
149
|
+
"here if you intentionally want to continue this thread against "
|
|
150
|
+
f"the current directory.{settings_note}{abort_note}"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def _help_text(self) -> str:
|
|
154
|
+
"""Return the help line text, naming the mode's abort action if offered."""
|
|
155
|
+
help_text = "Enter: switch · Esc: stay in cwd"
|
|
156
|
+
if self._abort is None:
|
|
157
|
+
return help_text
|
|
158
|
+
if self._abort == "resume":
|
|
159
|
+
abort_help = "A: don't resume"
|
|
160
|
+
elif self._abort == "thread_switch":
|
|
161
|
+
abort_help = "A: don't switch"
|
|
162
|
+
else:
|
|
163
|
+
assert_never(self._abort)
|
|
164
|
+
return f"{help_text} · {abort_help}"
|
|
165
|
+
|
|
166
|
+
def compose(self) -> ComposeResult:
|
|
167
|
+
"""Compose the confirmation dialog.
|
|
168
|
+
|
|
169
|
+
Yields:
|
|
170
|
+
Widgets for the cwd switch prompt.
|
|
171
|
+
"""
|
|
172
|
+
with Vertical():
|
|
173
|
+
yield Static(
|
|
174
|
+
self._title_text(),
|
|
175
|
+
classes="cwd-switch-title",
|
|
176
|
+
markup=False,
|
|
177
|
+
)
|
|
178
|
+
yield Static(
|
|
179
|
+
self._body_text(),
|
|
180
|
+
classes="cwd-switch-body",
|
|
181
|
+
markup=False,
|
|
182
|
+
)
|
|
183
|
+
yield Static(
|
|
184
|
+
self._help_text(),
|
|
185
|
+
classes="cwd-switch-help",
|
|
186
|
+
markup=False,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def on_mount(self) -> None:
|
|
190
|
+
"""Focus the modal so screen bindings work after nested modal flows."""
|
|
191
|
+
self.focus()
|
|
192
|
+
|
|
193
|
+
def check_action(
|
|
194
|
+
self,
|
|
195
|
+
action: str,
|
|
196
|
+
parameters: tuple[object, ...], # noqa: ARG002 # required by Textual's DOMNode.check_action override signature
|
|
197
|
+
) -> bool | None:
|
|
198
|
+
"""Disable the `abort` binding unless the prompt was opened for it.
|
|
199
|
+
|
|
200
|
+
Textual gates a binding's action on a truthy `check_action` result, so
|
|
201
|
+
both `False` and `None` stop `a` from dispatching `action_abort` -- in
|
|
202
|
+
neither case does the key fire the action, and it falls through the same
|
|
203
|
+
way. They differ only in footer presentation (`False` hides the binding,
|
|
204
|
+
`None` shows it grayed out), which is moot here anyway: every binding is
|
|
205
|
+
declared `show=False` and the modal renders its own help line instead of
|
|
206
|
+
a `Footer`. We return `False` to mark the disabled state explicitly; the
|
|
207
|
+
actual inertness backstop is `action_abort`'s own `self._abort is None`
|
|
208
|
+
guard, should the action ever be dispatched.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
`self._abort is not None` for the `abort` action, so the binding is
|
|
212
|
+
enabled only when abort was offered; `True` for every other action.
|
|
213
|
+
"""
|
|
214
|
+
if action == "abort":
|
|
215
|
+
return self._abort is not None
|
|
216
|
+
return True
|
|
217
|
+
|
|
218
|
+
def action_switch(self) -> None:
|
|
219
|
+
"""Dismiss with `switch`."""
|
|
220
|
+
self.dismiss("switch")
|
|
221
|
+
|
|
222
|
+
def action_stay(self) -> None:
|
|
223
|
+
"""Dismiss with `stay`."""
|
|
224
|
+
self.dismiss("stay")
|
|
225
|
+
|
|
226
|
+
def action_abort(self) -> None:
|
|
227
|
+
"""Dismiss with `abort` to skip the resume/switch, when the prompt allows it."""
|
|
228
|
+
if self._abort is None:
|
|
229
|
+
return
|
|
230
|
+
self.dismiss("abort")
|
|
231
|
+
|
|
232
|
+
def action_cancel(self) -> None:
|
|
233
|
+
"""Treat cancellation as staying in the current cwd."""
|
|
234
|
+
self.action_stay()
|
|
235
|
+
|
|
236
|
+
def action_quit_or_interrupt(self) -> None:
|
|
237
|
+
"""Delegate Ctrl+C to the app-level quit/interrupt handler."""
|
|
238
|
+
cast("DeepAgentsApp", self.app).action_quit_or_interrupt()
|
|
239
|
+
|
|
240
|
+
def action_quit_app(self) -> None:
|
|
241
|
+
"""Delegate Ctrl+D to the app-level quit handler."""
|
|
242
|
+
cast("DeepAgentsApp", self.app).action_quit_app()
|