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,482 @@
|
|
|
1
|
+
"""Onboarding screens for the interactive TUI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import TYPE_CHECKING, Any, ClassVar
|
|
7
|
+
|
|
8
|
+
from textual.app import ScreenStackError
|
|
9
|
+
from textual.binding import Binding, BindingType
|
|
10
|
+
from textual.containers import Vertical, VerticalScroll
|
|
11
|
+
from textual.content import Content
|
|
12
|
+
from textual.css.query import NoMatches
|
|
13
|
+
from textual.screen import ModalScreen
|
|
14
|
+
from textual.widgets import Input, Static
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
|
|
19
|
+
from textual.app import ComposeResult
|
|
20
|
+
from textual.screen import Screen
|
|
21
|
+
|
|
22
|
+
from deepagents_code.extras_info import ExtraDependencyStatus
|
|
23
|
+
|
|
24
|
+
from deepagents_code import theme
|
|
25
|
+
from deepagents_code.config import get_glyphs, is_ascii_mode
|
|
26
|
+
from deepagents_code.extras_info import (
|
|
27
|
+
MODEL_PROVIDER_EXTRAS,
|
|
28
|
+
SANDBOX_EXTRAS,
|
|
29
|
+
STANDALONE_EXTRAS,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
_DEPENDENCY_BODY_MAX_HEIGHT = 16
|
|
35
|
+
"""Upper bound (in cells) for the scrollable dependency list.
|
|
36
|
+
|
|
37
|
+
Keep in sync with the `max-height: 16` in the `#launch-dependencies-body` CSS;
|
|
38
|
+
Textual CSS cannot reference Python constants, so the static cap and the
|
|
39
|
+
runtime `_fit_dependencies_body` clamp must agree.
|
|
40
|
+
"""
|
|
41
|
+
_DEPENDENCY_BODY_MIN_HEIGHT = 1
|
|
42
|
+
"""Floor (in cells) so the list never collapses to zero on tiny terminals."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _normalize_name(value: str) -> str:
|
|
46
|
+
"""Normalize submitted onboarding names for display.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
value: Raw submitted name.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
The stripped name, title-cased when it was entered in lowercase.
|
|
53
|
+
"""
|
|
54
|
+
name = value.strip()
|
|
55
|
+
if name.islower():
|
|
56
|
+
return name.title()
|
|
57
|
+
return name
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class LaunchNameScreen(ModalScreen[str | None]):
|
|
61
|
+
"""First-step onboarding screen that asks for the user's name.
|
|
62
|
+
|
|
63
|
+
Dismissal values:
|
|
64
|
+
|
|
65
|
+
- Non-empty stripped/title-cased name when the user submits one.
|
|
66
|
+
- `""` when the user submits an empty input (continue, but skip name memory).
|
|
67
|
+
- `None` when the user dismisses with Escape (skip remaining onboarding).
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
AUTO_FOCUS = "#launch-name-input"
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
*,
|
|
75
|
+
continue_screen: Screen[Any] | None = None,
|
|
76
|
+
on_continue: Callable[[str], None] | None = None,
|
|
77
|
+
on_continue_failed: Callable[[str], None] | None = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Initialize the name-entry screen.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
continue_screen: Optional screen to switch to after submitting a name.
|
|
83
|
+
on_continue: Optional callback invoked with the submitted name before
|
|
84
|
+
switching to `continue_screen`.
|
|
85
|
+
on_continue_failed: Optional callback invoked with the submitted
|
|
86
|
+
name when switching to `continue_screen` fails.
|
|
87
|
+
"""
|
|
88
|
+
super().__init__()
|
|
89
|
+
self._continue_screen = continue_screen
|
|
90
|
+
self._on_continue = on_continue
|
|
91
|
+
self._on_continue_failed = on_continue_failed
|
|
92
|
+
|
|
93
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
94
|
+
Binding("escape", "skip", "Skip", show=False, priority=True),
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
CSS = """
|
|
98
|
+
LaunchNameScreen {
|
|
99
|
+
align: center middle;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
LaunchNameScreen > Vertical {
|
|
103
|
+
width: 64;
|
|
104
|
+
max-width: 90%;
|
|
105
|
+
height: auto;
|
|
106
|
+
background: $surface;
|
|
107
|
+
border: solid $primary;
|
|
108
|
+
padding: 1 2;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
LaunchNameScreen .launch-init-title {
|
|
112
|
+
text-style: bold;
|
|
113
|
+
color: $primary;
|
|
114
|
+
text-align: center;
|
|
115
|
+
margin-bottom: 1;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
LaunchNameScreen .launch-init-copy {
|
|
119
|
+
height: auto;
|
|
120
|
+
color: $text;
|
|
121
|
+
margin-bottom: 1;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
LaunchNameScreen #launch-name-input {
|
|
125
|
+
margin-bottom: 1;
|
|
126
|
+
border: solid $primary-lighten-2;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
LaunchNameScreen #launch-name-input:focus {
|
|
130
|
+
border: solid $primary;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
LaunchNameScreen .launch-init-help {
|
|
134
|
+
height: 1;
|
|
135
|
+
color: $text-muted;
|
|
136
|
+
text-style: italic;
|
|
137
|
+
text-align: center;
|
|
138
|
+
}
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
def compose(self) -> ComposeResult: # noqa: PLR6301 # Textual override
|
|
142
|
+
"""Compose the name-entry screen.
|
|
143
|
+
|
|
144
|
+
Yields:
|
|
145
|
+
Widgets for the modal content.
|
|
146
|
+
"""
|
|
147
|
+
with Vertical():
|
|
148
|
+
yield Static("Welcome to Deep Agents Code", classes="launch-init-title")
|
|
149
|
+
yield Static(
|
|
150
|
+
Content.assemble("What should Deep Agents call you?"),
|
|
151
|
+
classes="launch-init-copy",
|
|
152
|
+
)
|
|
153
|
+
yield Input(
|
|
154
|
+
placeholder="Your name (optional)",
|
|
155
|
+
id="launch-name-input",
|
|
156
|
+
)
|
|
157
|
+
yield Static(
|
|
158
|
+
"Enter to continue",
|
|
159
|
+
classes="launch-init-help",
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def on_mount(self) -> None:
|
|
163
|
+
"""Apply ASCII border when needed."""
|
|
164
|
+
if is_ascii_mode():
|
|
165
|
+
container = self.query_one(Vertical)
|
|
166
|
+
colors = theme.get_theme_colors(self)
|
|
167
|
+
container.styles.border = ("ascii", colors.success)
|
|
168
|
+
|
|
169
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
170
|
+
"""Continue with the submitted name.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
event: The input submission event.
|
|
174
|
+
"""
|
|
175
|
+
event.stop()
|
|
176
|
+
value = _normalize_name(event.value)
|
|
177
|
+
if self._continue_screen is None:
|
|
178
|
+
self.dismiss(value)
|
|
179
|
+
return
|
|
180
|
+
if self._on_continue is not None:
|
|
181
|
+
self._on_continue(value)
|
|
182
|
+
try:
|
|
183
|
+
self.app.switch_screen(self._continue_screen)
|
|
184
|
+
except ScreenStackError:
|
|
185
|
+
logger.warning(
|
|
186
|
+
"Could not switch from launch name screen; dismissing instead",
|
|
187
|
+
exc_info=True,
|
|
188
|
+
)
|
|
189
|
+
if self._on_continue_failed is not None:
|
|
190
|
+
self._on_continue_failed(value)
|
|
191
|
+
self.dismiss(value)
|
|
192
|
+
|
|
193
|
+
def action_skip(self) -> None:
|
|
194
|
+
"""Skip the onboarding sequence."""
|
|
195
|
+
self.dismiss(None)
|
|
196
|
+
|
|
197
|
+
def action_cancel(self) -> None:
|
|
198
|
+
"""Alias for `action_skip` invoked by the global Esc binding.
|
|
199
|
+
|
|
200
|
+
Textual's `Screen.action_cancel` is the conventional cancel hook used
|
|
201
|
+
by the app-level Esc handler in `DeepAgentsApp`; routing it to
|
|
202
|
+
`action_skip` keeps the screen-specific binding and the global path
|
|
203
|
+
in sync.
|
|
204
|
+
"""
|
|
205
|
+
self.action_skip()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class LaunchDependenciesScreen(ModalScreen[bool | None]):
|
|
209
|
+
"""Onboarding screen that summarizes installed optional integrations."""
|
|
210
|
+
|
|
211
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
212
|
+
Binding("enter", "continue", "Continue", show=False, priority=True),
|
|
213
|
+
Binding("escape", "skip", "Skip", show=False, priority=True),
|
|
214
|
+
]
|
|
215
|
+
|
|
216
|
+
CSS = """
|
|
217
|
+
LaunchDependenciesScreen {
|
|
218
|
+
align: center middle;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
LaunchDependenciesScreen > Vertical {
|
|
222
|
+
width: 76;
|
|
223
|
+
max-width: 90%;
|
|
224
|
+
height: auto;
|
|
225
|
+
background: $surface;
|
|
226
|
+
border: solid $primary;
|
|
227
|
+
padding: 1 2;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
LaunchDependenciesScreen .launch-init-title {
|
|
231
|
+
text-style: bold;
|
|
232
|
+
color: $primary;
|
|
233
|
+
text-align: center;
|
|
234
|
+
margin-bottom: 1;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
LaunchDependenciesScreen .launch-init-copy {
|
|
238
|
+
height: auto;
|
|
239
|
+
color: $text-muted;
|
|
240
|
+
margin-bottom: 1;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
LaunchDependenciesScreen #launch-dependencies-body {
|
|
244
|
+
height: auto;
|
|
245
|
+
max-height: 16; /* keep in sync with `_DEPENDENCY_BODY_MAX_HEIGHT` */
|
|
246
|
+
scrollbar-gutter: stable;
|
|
247
|
+
margin-bottom: 1;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
LaunchDependenciesScreen .launch-dependencies-section {
|
|
251
|
+
height: auto;
|
|
252
|
+
color: $text;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
LaunchDependenciesScreen .launch-dependencies-section.is-available {
|
|
256
|
+
margin-top: 1;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
LaunchDependenciesScreen .launch-init-help {
|
|
260
|
+
height: 1;
|
|
261
|
+
color: $text-muted;
|
|
262
|
+
text-style: italic;
|
|
263
|
+
text-align: center;
|
|
264
|
+
}
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
def __init__(
|
|
268
|
+
self,
|
|
269
|
+
statuses: tuple[ExtraDependencyStatus, ...] | None = None,
|
|
270
|
+
*,
|
|
271
|
+
continue_screen: Screen[Any] | None = None,
|
|
272
|
+
on_done: Callable[[bool | None], None] | None = None,
|
|
273
|
+
) -> None:
|
|
274
|
+
"""Initialize the dependency summary screen.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
statuses: Optional dependency statuses to display. When omitted,
|
|
278
|
+
the status is read from the installed package metadata.
|
|
279
|
+
continue_screen: Optional screen to switch to when the user
|
|
280
|
+
continues, avoiding an intermediate base-screen frame.
|
|
281
|
+
on_done: Optional callback invoked when this screen finishes without
|
|
282
|
+
switching to `continue_screen`.
|
|
283
|
+
"""
|
|
284
|
+
super().__init__()
|
|
285
|
+
if statuses is None:
|
|
286
|
+
from deepagents_code.extras_info import get_optional_dependency_status
|
|
287
|
+
|
|
288
|
+
statuses = get_optional_dependency_status()
|
|
289
|
+
self._statuses = statuses
|
|
290
|
+
self._continue_screen = continue_screen
|
|
291
|
+
self._on_done = on_done
|
|
292
|
+
|
|
293
|
+
def compose(self) -> ComposeResult:
|
|
294
|
+
"""Compose the dependency summary screen.
|
|
295
|
+
|
|
296
|
+
Yields:
|
|
297
|
+
Widgets for the modal content.
|
|
298
|
+
"""
|
|
299
|
+
glyphs = get_glyphs()
|
|
300
|
+
with Vertical():
|
|
301
|
+
yield Static("Installed Integrations", classes="launch-init-title")
|
|
302
|
+
yield Static(
|
|
303
|
+
"Model providers and sandboxes are enabled by optional add-on "
|
|
304
|
+
"packages. The ones already present in your environment are "
|
|
305
|
+
"ready to use now.",
|
|
306
|
+
classes="launch-init-copy",
|
|
307
|
+
)
|
|
308
|
+
if self._statuses:
|
|
309
|
+
with VerticalScroll(id="launch-dependencies-body"):
|
|
310
|
+
yield Static(
|
|
311
|
+
self._format_section(
|
|
312
|
+
title="Ready now",
|
|
313
|
+
ready=True,
|
|
314
|
+
glyph=glyphs.checkmark,
|
|
315
|
+
empty="Nothing installed yet — add one below.",
|
|
316
|
+
),
|
|
317
|
+
classes="launch-dependencies-section",
|
|
318
|
+
)
|
|
319
|
+
yield Static(
|
|
320
|
+
self._format_section(
|
|
321
|
+
title="Available to add",
|
|
322
|
+
ready=False,
|
|
323
|
+
glyph=glyphs.circle_empty,
|
|
324
|
+
empty="All bundled integrations are installed.",
|
|
325
|
+
),
|
|
326
|
+
classes="launch-dependencies-section is-available",
|
|
327
|
+
)
|
|
328
|
+
yield Static(
|
|
329
|
+
"Pick a model on the next screen and its provider installs "
|
|
330
|
+
"automatically. Add more anytime with `/install`.",
|
|
331
|
+
classes="launch-init-copy",
|
|
332
|
+
)
|
|
333
|
+
else:
|
|
334
|
+
# `get_optional_dependency_status` returns an empty tuple when
|
|
335
|
+
# `importlib.metadata` cannot find the distribution (editable
|
|
336
|
+
# install renamed, dev checkout without dist-info). Render a
|
|
337
|
+
# single explanatory line rather than empty status sections.
|
|
338
|
+
yield Static(
|
|
339
|
+
"Could not read installed dependency metadata. Reinstall "
|
|
340
|
+
"with `/install <extra>` to populate.",
|
|
341
|
+
classes="launch-dependencies-section",
|
|
342
|
+
)
|
|
343
|
+
yield Static(
|
|
344
|
+
"Enter to continue",
|
|
345
|
+
classes="launch-init-help",
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
def on_mount(self) -> None:
|
|
349
|
+
"""Apply ASCII border when needed."""
|
|
350
|
+
if is_ascii_mode():
|
|
351
|
+
container = self.query_one(Vertical)
|
|
352
|
+
colors = theme.get_theme_colors(self)
|
|
353
|
+
container.styles.border = ("ascii", colors.success)
|
|
354
|
+
self.call_after_refresh(self._fit_dependencies_body)
|
|
355
|
+
|
|
356
|
+
def on_resize(self) -> None:
|
|
357
|
+
"""Refit the scroll body when terminal dimensions change."""
|
|
358
|
+
self.call_after_refresh(self._fit_dependencies_body)
|
|
359
|
+
|
|
360
|
+
def _fit_dependencies_body(self) -> None:
|
|
361
|
+
"""Cap the dependency list height so modal controls stay in view."""
|
|
362
|
+
# `#launch-dependencies-body` is only composed when statuses are
|
|
363
|
+
# non-empty (see `compose`); skip the structural always-empty case
|
|
364
|
+
# here. The `NoMatches` catch below still handles the teardown race.
|
|
365
|
+
if not self._statuses:
|
|
366
|
+
return
|
|
367
|
+
|
|
368
|
+
try:
|
|
369
|
+
container = self.query_one(Vertical)
|
|
370
|
+
body = self.query_one("#launch-dependencies-body", VerticalScroll)
|
|
371
|
+
except NoMatches:
|
|
372
|
+
# This runs deferred via `call_after_refresh`; the screen may have
|
|
373
|
+
# been popped or recomposed before it fires (e.g. a resize racing
|
|
374
|
+
# dismissal). Sizing is cosmetic, so skip quietly but leave a
|
|
375
|
+
# breadcrumb rather than letting it surface in the event loop.
|
|
376
|
+
logger.debug(
|
|
377
|
+
"Skipping dependency-body refit; widgets not mounted",
|
|
378
|
+
exc_info=True,
|
|
379
|
+
)
|
|
380
|
+
return
|
|
381
|
+
non_body_height = max(0, container.region.height - body.region.height)
|
|
382
|
+
available_height = self.size.height - non_body_height
|
|
383
|
+
max_height = max(
|
|
384
|
+
_DEPENDENCY_BODY_MIN_HEIGHT,
|
|
385
|
+
min(_DEPENDENCY_BODY_MAX_HEIGHT, available_height),
|
|
386
|
+
)
|
|
387
|
+
current = body.styles.max_height
|
|
388
|
+
if current is not None and current.cells == max_height:
|
|
389
|
+
return
|
|
390
|
+
body.styles.max_height = max_height
|
|
391
|
+
|
|
392
|
+
def _format_section(
|
|
393
|
+
self, *, title: str, ready: bool, glyph: str, empty: str
|
|
394
|
+
) -> str:
|
|
395
|
+
"""Format one status section as per-extra rows grouped by category.
|
|
396
|
+
|
|
397
|
+
Every matching extra is listed (no truncation); each category that
|
|
398
|
+
has matches is shown under a sub-header, and the section title carries
|
|
399
|
+
a total count. When nothing matches, the `empty` placeholder is shown
|
|
400
|
+
in place of the sub-headers.
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
title: Section title.
|
|
404
|
+
ready: Whether to include ready or not-yet-ready extras.
|
|
405
|
+
glyph: Status glyph rendered before each extra name.
|
|
406
|
+
empty: Placeholder line shown when the section has no extras.
|
|
407
|
+
|
|
408
|
+
Returns:
|
|
409
|
+
Multi-line section text.
|
|
410
|
+
"""
|
|
411
|
+
groups: tuple[tuple[str, frozenset[str]], ...] = (
|
|
412
|
+
("Model providers", MODEL_PROVIDER_EXTRAS),
|
|
413
|
+
("Sandboxes", SANDBOX_EXTRAS),
|
|
414
|
+
("Other", STANDALONE_EXTRAS),
|
|
415
|
+
)
|
|
416
|
+
grouped = [
|
|
417
|
+
(label, self._extra_names(names, ready=ready)) for label, names in groups
|
|
418
|
+
]
|
|
419
|
+
total = sum(len(extras) for _, extras in grouped)
|
|
420
|
+
lines = [f"{title} ({total})"]
|
|
421
|
+
if total == 0:
|
|
422
|
+
lines.append(f" {empty}")
|
|
423
|
+
return "\n".join(lines)
|
|
424
|
+
for label, extras in grouped:
|
|
425
|
+
if not extras:
|
|
426
|
+
continue
|
|
427
|
+
lines.append(f" {label}")
|
|
428
|
+
lines.extend(f" {glyph} {name}" for name in extras)
|
|
429
|
+
return "\n".join(lines)
|
|
430
|
+
|
|
431
|
+
def _extra_names(self, names: frozenset[str], *, ready: bool) -> list[str]:
|
|
432
|
+
"""Return sorted extra names matching a category and readiness state.
|
|
433
|
+
|
|
434
|
+
Args:
|
|
435
|
+
names: Category names to include.
|
|
436
|
+
ready: Desired readiness state.
|
|
437
|
+
|
|
438
|
+
Returns:
|
|
439
|
+
Sorted matching extra names.
|
|
440
|
+
"""
|
|
441
|
+
return sorted(
|
|
442
|
+
status.name
|
|
443
|
+
for status in self._statuses
|
|
444
|
+
if status.name in names and status.ready is ready
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
def action_continue(self) -> None:
|
|
448
|
+
"""Continue onboarding."""
|
|
449
|
+
if self._continue_screen is not None:
|
|
450
|
+
try:
|
|
451
|
+
self.app.switch_screen(self._continue_screen)
|
|
452
|
+
except ScreenStackError:
|
|
453
|
+
# Stack was torn down (app exiting, screen popped under us).
|
|
454
|
+
# Fall back to dismissal so the launch-init task can finish
|
|
455
|
+
# rather than leaving the user staring at this modal.
|
|
456
|
+
logger.warning(
|
|
457
|
+
"Could not switch to continue screen; dismissing instead",
|
|
458
|
+
exc_info=True,
|
|
459
|
+
)
|
|
460
|
+
self.app.notify(
|
|
461
|
+
"Could not open the model selector. Use /model to pick "
|
|
462
|
+
"one when you're ready.",
|
|
463
|
+
severity="warning",
|
|
464
|
+
markup=False,
|
|
465
|
+
)
|
|
466
|
+
self._finish(True)
|
|
467
|
+
return
|
|
468
|
+
self._finish(True)
|
|
469
|
+
|
|
470
|
+
def action_skip(self) -> None:
|
|
471
|
+
"""Skip the remaining onboarding sequence."""
|
|
472
|
+
self._finish(None)
|
|
473
|
+
|
|
474
|
+
def _finish(self, result: bool | None) -> None:
|
|
475
|
+
"""Resolve the screen-specific callback before dismissing."""
|
|
476
|
+
if self._on_done is not None:
|
|
477
|
+
self._on_done(result)
|
|
478
|
+
self.dismiss(result)
|
|
479
|
+
|
|
480
|
+
def action_cancel(self) -> None:
|
|
481
|
+
"""See `LaunchNameScreen.action_cancel`."""
|
|
482
|
+
self.action_skip()
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Loading widget with animated spinner for agent activity."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from time import time
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from textual.containers import Horizontal
|
|
9
|
+
from textual.content import Content
|
|
10
|
+
from textual.widgets import Static
|
|
11
|
+
|
|
12
|
+
from deepagents_code.config import get_glyphs
|
|
13
|
+
from deepagents_code.formatting import format_duration
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from textual.app import ComposeResult
|
|
17
|
+
from textual.await_remove import AwaitRemove
|
|
18
|
+
from textual.timer import Timer
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Spinner:
|
|
22
|
+
"""Animated spinner using charset-appropriate frames."""
|
|
23
|
+
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
"""Initialize spinner."""
|
|
26
|
+
self._position = 0
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def frames(self) -> tuple[str, ...]:
|
|
30
|
+
"""Spinner frames from glyphs config."""
|
|
31
|
+
return get_glyphs().spinner_frames
|
|
32
|
+
|
|
33
|
+
def next_frame(self) -> str:
|
|
34
|
+
"""Get next animation frame.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
The next spinner character in the animation sequence.
|
|
38
|
+
"""
|
|
39
|
+
frames = self.frames
|
|
40
|
+
frame = frames[self._position]
|
|
41
|
+
self._position = (self._position + 1) % len(frames)
|
|
42
|
+
return frame
|
|
43
|
+
|
|
44
|
+
def current_frame(self) -> str:
|
|
45
|
+
"""Get current frame without advancing.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
The current spinner character.
|
|
49
|
+
"""
|
|
50
|
+
return self.frames[self._position]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class LoadingWidget(Static):
|
|
54
|
+
"""Animated loading indicator with status text and elapsed time.
|
|
55
|
+
|
|
56
|
+
Displays: <spinner> Thinking... (3s, esc to interrupt)
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
DEFAULT_CSS = """
|
|
60
|
+
LoadingWidget {
|
|
61
|
+
height: auto;
|
|
62
|
+
padding: 0 1;
|
|
63
|
+
margin: 0 0 1 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
LoadingWidget .loading-container {
|
|
67
|
+
height: auto;
|
|
68
|
+
width: 100%;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
LoadingWidget .loading-spinner {
|
|
72
|
+
width: auto;
|
|
73
|
+
color: $primary;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
LoadingWidget .loading-status {
|
|
77
|
+
width: auto;
|
|
78
|
+
color: $primary;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
LoadingWidget .loading-hint {
|
|
82
|
+
width: auto;
|
|
83
|
+
color: $text-muted;
|
|
84
|
+
margin-left: 1;
|
|
85
|
+
}
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(self, status: str = "Thinking") -> None:
|
|
89
|
+
"""Initialize loading widget.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
status: Initial status text to display
|
|
93
|
+
"""
|
|
94
|
+
super().__init__()
|
|
95
|
+
self._status = status
|
|
96
|
+
self._spinner = Spinner()
|
|
97
|
+
self._start_time: float | None = None
|
|
98
|
+
self._spinner_widget: Static | None = None
|
|
99
|
+
self._status_widget: Static | None = None
|
|
100
|
+
self._hint_widget: Static | None = None
|
|
101
|
+
self._animation_timer: Timer | None = None
|
|
102
|
+
self._paused = False
|
|
103
|
+
self._paused_elapsed: float = 0.0
|
|
104
|
+
|
|
105
|
+
def compose(self) -> ComposeResult:
|
|
106
|
+
"""Compose the loading widget layout.
|
|
107
|
+
|
|
108
|
+
Yields:
|
|
109
|
+
Widgets for spinner, status text, and hint.
|
|
110
|
+
"""
|
|
111
|
+
with Horizontal(classes="loading-container"):
|
|
112
|
+
self._spinner_widget = Static(
|
|
113
|
+
self._spinner.current_frame(), classes="loading-spinner"
|
|
114
|
+
)
|
|
115
|
+
yield self._spinner_widget
|
|
116
|
+
|
|
117
|
+
self._status_widget = Static(
|
|
118
|
+
f" {self._status}... ", classes="loading-status"
|
|
119
|
+
)
|
|
120
|
+
yield self._status_widget
|
|
121
|
+
|
|
122
|
+
self._hint_widget = Static("(0s, esc to interrupt)", classes="loading-hint")
|
|
123
|
+
yield self._hint_widget
|
|
124
|
+
|
|
125
|
+
def on_mount(self) -> None:
|
|
126
|
+
"""Start animation on mount.
|
|
127
|
+
|
|
128
|
+
Preserves `_start_time` when the widget is remounted (e.g., after
|
|
129
|
+
being removed and re-added for repositioning) so the elapsed-time
|
|
130
|
+
counter doesn't reset. Repositioning via `move_child` avoids the
|
|
131
|
+
remount path entirely, but this guard keeps the behavior correct
|
|
132
|
+
if any caller ever falls back to remove + mount.
|
|
133
|
+
"""
|
|
134
|
+
if self._start_time is None:
|
|
135
|
+
self._start_time = time()
|
|
136
|
+
self._animation_timer = self.set_interval(0.1, self._update_animation)
|
|
137
|
+
|
|
138
|
+
def on_unmount(self) -> None:
|
|
139
|
+
"""Stop the animation timer when the widget leaves the DOM."""
|
|
140
|
+
self._stop_timer()
|
|
141
|
+
|
|
142
|
+
def remove(self) -> AwaitRemove:
|
|
143
|
+
"""Stop animation before delegating DOM removal to Textual.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Awaitable that completes once the widget is removed from the DOM.
|
|
147
|
+
"""
|
|
148
|
+
self._stop_timer()
|
|
149
|
+
return super().remove()
|
|
150
|
+
|
|
151
|
+
def _stop_timer(self) -> None:
|
|
152
|
+
"""Stop the animation timer if it is running."""
|
|
153
|
+
if self._animation_timer is not None:
|
|
154
|
+
self._animation_timer.stop()
|
|
155
|
+
self._animation_timer = None
|
|
156
|
+
|
|
157
|
+
def _update_animation(self) -> None:
|
|
158
|
+
"""Update spinner and elapsed time."""
|
|
159
|
+
if self._paused:
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
if self._spinner_widget:
|
|
163
|
+
frame = self._spinner.next_frame()
|
|
164
|
+
self._spinner_widget.update(frame)
|
|
165
|
+
|
|
166
|
+
if self._hint_widget and self._start_time is not None:
|
|
167
|
+
elapsed = int(time() - self._start_time)
|
|
168
|
+
self._hint_widget.update(f"({format_duration(elapsed)}, esc to interrupt)")
|
|
169
|
+
|
|
170
|
+
def set_status(self, status: str) -> None:
|
|
171
|
+
"""Update the status text.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
status: New status text
|
|
175
|
+
"""
|
|
176
|
+
self._status = status
|
|
177
|
+
if self._status_widget:
|
|
178
|
+
self._status_widget.update(f" {self._status}... ")
|
|
179
|
+
|
|
180
|
+
def pause(self, status: str = "Awaiting decision") -> None:
|
|
181
|
+
"""Pause the animation and update status.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
status: Status to show while paused
|
|
185
|
+
"""
|
|
186
|
+
self._paused = True
|
|
187
|
+
if self._start_time is not None:
|
|
188
|
+
self._paused_elapsed = time() - self._start_time
|
|
189
|
+
self._status = status
|
|
190
|
+
if self._status_widget:
|
|
191
|
+
self._status_widget.update(f" {status}... ")
|
|
192
|
+
if self._hint_widget:
|
|
193
|
+
# Display whole seconds to match the live counter in
|
|
194
|
+
# `_update_animation`; `_paused_elapsed` stays a float only so
|
|
195
|
+
# `resume()` can rebase `_start_time` with sub-second precision.
|
|
196
|
+
self._hint_widget.update(
|
|
197
|
+
f"(paused at {format_duration(int(self._paused_elapsed))})"
|
|
198
|
+
)
|
|
199
|
+
if self._spinner_widget:
|
|
200
|
+
self._spinner_widget.update(Content.styled(get_glyphs().pause, "dim"))
|
|
201
|
+
|
|
202
|
+
def resume(self) -> None:
|
|
203
|
+
"""Resume the animation, excluding the paused interval from elapsed time.
|
|
204
|
+
|
|
205
|
+
Rebases `_start_time` forward by the paused duration so the elapsed-time
|
|
206
|
+
counter continues from where it paused rather than counting the wait.
|
|
207
|
+
|
|
208
|
+
No-op when not currently paused. This method is wired both as a
|
|
209
|
+
`Future.add_done_callback` and as a self-healing net in the app's
|
|
210
|
+
`_set_spinner`, so it can fire on a widget that was never paused (e.g.
|
|
211
|
+
one created to replace the paused spinner mid-approval). Returning early
|
|
212
|
+
there avoids rebasing the start time or clobbering that widget's status.
|
|
213
|
+
"""
|
|
214
|
+
if not self._paused:
|
|
215
|
+
# Load-bearing guard: resuming a never-paused (or replacement)
|
|
216
|
+
# widget must not rebase `_start_time` with a stale
|
|
217
|
+
# `_paused_elapsed`, which would silently jump its timer.
|
|
218
|
+
return
|
|
219
|
+
self._start_time = time() - self._paused_elapsed
|
|
220
|
+
self._paused = False
|
|
221
|
+
self._status = "Thinking"
|
|
222
|
+
if self._status_widget:
|
|
223
|
+
self._status_widget.update(f" {self._status}... ")
|
|
224
|
+
|
|
225
|
+
def stop(self) -> None:
|
|
226
|
+
"""Stop the animation (widget will be removed by caller)."""
|
|
227
|
+
self._stop_timer()
|