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.
Files changed (163) hide show
  1. deepagents_code/__init__.py +42 -0
  2. deepagents_code/__main__.py +6 -0
  3. deepagents_code/_ask_user_types.py +90 -0
  4. deepagents_code/_cli_context.py +96 -0
  5. deepagents_code/_constants.py +41 -0
  6. deepagents_code/_debug.py +148 -0
  7. deepagents_code/_debug_buffer.py +204 -0
  8. deepagents_code/_env_vars.py +411 -0
  9. deepagents_code/_fake_models.py +66 -0
  10. deepagents_code/_git.py +521 -0
  11. deepagents_code/_paths.py +69 -0
  12. deepagents_code/_server_config.py +576 -0
  13. deepagents_code/_session_stats.py +235 -0
  14. deepagents_code/_startup_error.py +45 -0
  15. deepagents_code/_testing_models.py +305 -0
  16. deepagents_code/_textual_patches.py +420 -0
  17. deepagents_code/_tool_stream.py +694 -0
  18. deepagents_code/_version.py +46 -0
  19. deepagents_code/agent.py +1976 -0
  20. deepagents_code/app.py +19239 -0
  21. deepagents_code/app.tcss +438 -0
  22. deepagents_code/approval_mode.py +131 -0
  23. deepagents_code/ask_user.py +306 -0
  24. deepagents_code/auth_display.py +185 -0
  25. deepagents_code/auth_store.py +545 -0
  26. deepagents_code/built_in_skills/__init__.py +5 -0
  27. deepagents_code/built_in_skills/remember/SKILL.md +118 -0
  28. deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
  29. deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
  30. deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
  31. deepagents_code/client/__init__.py +1 -0
  32. deepagents_code/client/commands/__init__.py +1 -0
  33. deepagents_code/client/commands/auth.py +541 -0
  34. deepagents_code/client/commands/config.py +714 -0
  35. deepagents_code/client/commands/mcp.py +250 -0
  36. deepagents_code/client/commands/tools.py +416 -0
  37. deepagents_code/client/launch/__init__.py +1 -0
  38. deepagents_code/client/launch/server.py +978 -0
  39. deepagents_code/client/launch/server_manager.py +540 -0
  40. deepagents_code/client/non_interactive.py +1758 -0
  41. deepagents_code/client/remote_client.py +794 -0
  42. deepagents_code/clipboard.py +217 -0
  43. deepagents_code/command_registry.py +450 -0
  44. deepagents_code/config.py +4829 -0
  45. deepagents_code/config_manifest.py +1451 -0
  46. deepagents_code/configurable_model.py +577 -0
  47. deepagents_code/default_agent_prompt.md +12 -0
  48. deepagents_code/doctor.py +559 -0
  49. deepagents_code/editor.py +142 -0
  50. deepagents_code/event_bus.py +411 -0
  51. deepagents_code/extras_info.py +661 -0
  52. deepagents_code/file_ops.py +576 -0
  53. deepagents_code/formatting.py +135 -0
  54. deepagents_code/goal_rubric.py +497 -0
  55. deepagents_code/goal_tools.py +459 -0
  56. deepagents_code/hooks.py +348 -0
  57. deepagents_code/input.py +1041 -0
  58. deepagents_code/integrations/__init__.py +1 -0
  59. deepagents_code/integrations/openai_codex.py +551 -0
  60. deepagents_code/integrations/sandbox_config.py +198 -0
  61. deepagents_code/integrations/sandbox_factory.py +1124 -0
  62. deepagents_code/integrations/sandbox_provider.py +137 -0
  63. deepagents_code/integrations/sandbox_registry.py +350 -0
  64. deepagents_code/iterm_cursor_guide.py +176 -0
  65. deepagents_code/local_context.py +926 -0
  66. deepagents_code/main.py +3985 -0
  67. deepagents_code/managed_tools.py +642 -0
  68. deepagents_code/mcp_auth.py +1950 -0
  69. deepagents_code/mcp_config.py +176 -0
  70. deepagents_code/mcp_disabled.py +212 -0
  71. deepagents_code/mcp_login_service.py +281 -0
  72. deepagents_code/mcp_oauth_ui.py +199 -0
  73. deepagents_code/mcp_providers/__init__.py +23 -0
  74. deepagents_code/mcp_providers/_registry.py +39 -0
  75. deepagents_code/mcp_providers/base.py +133 -0
  76. deepagents_code/mcp_providers/github.py +102 -0
  77. deepagents_code/mcp_providers/slack.py +175 -0
  78. deepagents_code/mcp_tools.py +2427 -0
  79. deepagents_code/mcp_trust.py +207 -0
  80. deepagents_code/media_utils.py +826 -0
  81. deepagents_code/memory_guard.py +474 -0
  82. deepagents_code/model_config.py +4156 -0
  83. deepagents_code/notifications.py +247 -0
  84. deepagents_code/offload.py +402 -0
  85. deepagents_code/onboarding.py +223 -0
  86. deepagents_code/output.py +69 -0
  87. deepagents_code/paste_collapse.py +103 -0
  88. deepagents_code/project_utils.py +231 -0
  89. deepagents_code/py.typed +0 -0
  90. deepagents_code/reasoning_effort.py +641 -0
  91. deepagents_code/reliable_rubric.py +97 -0
  92. deepagents_code/resume_state.py +237 -0
  93. deepagents_code/server_graph.py +310 -0
  94. deepagents_code/session_end_summary.py +329 -0
  95. deepagents_code/sessions.py +1716 -0
  96. deepagents_code/skills/__init__.py +18 -0
  97. deepagents_code/skills/commands.py +1252 -0
  98. deepagents_code/skills/invocation.py +112 -0
  99. deepagents_code/skills/load.py +196 -0
  100. deepagents_code/skills/trust.py +547 -0
  101. deepagents_code/state_migration.py +136 -0
  102. deepagents_code/subagents.py +278 -0
  103. deepagents_code/system_prompt.md +204 -0
  104. deepagents_code/terminal_capabilities.py +115 -0
  105. deepagents_code/terminal_escape.py +287 -0
  106. deepagents_code/theme.py +891 -0
  107. deepagents_code/todo_list_prompt.md +12 -0
  108. deepagents_code/tool_catalog.py +509 -0
  109. deepagents_code/tool_display.py +367 -0
  110. deepagents_code/tools.py +516 -0
  111. deepagents_code/tui/__init__.py +1 -0
  112. deepagents_code/tui/textual_adapter.py +2553 -0
  113. deepagents_code/tui/widgets/__init__.py +9 -0
  114. deepagents_code/tui/widgets/_js_eval_display.py +139 -0
  115. deepagents_code/tui/widgets/_links.py +261 -0
  116. deepagents_code/tui/widgets/agent_selector.py +420 -0
  117. deepagents_code/tui/widgets/approval.py +602 -0
  118. deepagents_code/tui/widgets/ask_user.py +515 -0
  119. deepagents_code/tui/widgets/auth.py +1997 -0
  120. deepagents_code/tui/widgets/autocomplete.py +890 -0
  121. deepagents_code/tui/widgets/chat_input.py +3181 -0
  122. deepagents_code/tui/widgets/codex_auth.py +452 -0
  123. deepagents_code/tui/widgets/cwd_switch.py +242 -0
  124. deepagents_code/tui/widgets/debug_console.py +868 -0
  125. deepagents_code/tui/widgets/diff.py +252 -0
  126. deepagents_code/tui/widgets/effort_selector.py +189 -0
  127. deepagents_code/tui/widgets/goal_review.py +390 -0
  128. deepagents_code/tui/widgets/goal_status.py +50 -0
  129. deepagents_code/tui/widgets/history.py +194 -0
  130. deepagents_code/tui/widgets/install_confirm.py +248 -0
  131. deepagents_code/tui/widgets/launch_init.py +482 -0
  132. deepagents_code/tui/widgets/loading.py +227 -0
  133. deepagents_code/tui/widgets/mcp_login.py +539 -0
  134. deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
  135. deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
  136. deepagents_code/tui/widgets/message_store.py +999 -0
  137. deepagents_code/tui/widgets/messages.py +3846 -0
  138. deepagents_code/tui/widgets/model_selector.py +2343 -0
  139. deepagents_code/tui/widgets/notification_center.py +456 -0
  140. deepagents_code/tui/widgets/notification_detail.py +257 -0
  141. deepagents_code/tui/widgets/notification_settings.py +170 -0
  142. deepagents_code/tui/widgets/restart_prompt.py +158 -0
  143. deepagents_code/tui/widgets/skill_trust.py +131 -0
  144. deepagents_code/tui/widgets/startup_tip.py +91 -0
  145. deepagents_code/tui/widgets/status.py +781 -0
  146. deepagents_code/tui/widgets/subagent_panel.py +969 -0
  147. deepagents_code/tui/widgets/theme_selector.py +401 -0
  148. deepagents_code/tui/widgets/thread_selector.py +2564 -0
  149. deepagents_code/tui/widgets/tool_renderers.py +190 -0
  150. deepagents_code/tui/widgets/tool_widgets.py +304 -0
  151. deepagents_code/tui/widgets/update_available.py +311 -0
  152. deepagents_code/tui/widgets/update_confirm.py +184 -0
  153. deepagents_code/tui/widgets/update_progress.py +327 -0
  154. deepagents_code/tui/widgets/welcome.py +508 -0
  155. deepagents_code/turn_end_summary.py +522 -0
  156. deepagents_code/ui.py +858 -0
  157. deepagents_code/unicode_security.py +563 -0
  158. deepagents_code/update_check.py +3124 -0
  159. zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
  160. zjcode-0.0.1.dist-info/METADATA +220 -0
  161. zjcode-0.0.1.dist-info/RECORD +163 -0
  162. zjcode-0.0.1.dist-info/WHEEL +4 -0
  163. 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()