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,781 @@
1
+ """Status bar widget."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from contextlib import suppress
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING, Any, Literal, get_args
9
+
10
+ from textual.containers import Horizontal
11
+ from textual.content import Content
12
+ from textual.css.query import NoMatches
13
+ from textual.reactive import reactive
14
+ from textual.widget import Widget
15
+ from textual.widgets import Static
16
+
17
+ from deepagents_code._constants import FIREWORKS_MODEL_ID_PREFIXES
18
+ from deepagents_code._env_vars import HIDE_CWD, HIDE_GIT_BRANCH, is_env_truthy
19
+ from deepagents_code.config import get_glyphs
20
+ from deepagents_code.tui.widgets.loading import Spinner
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ if TYPE_CHECKING:
25
+ from textual import events
26
+ from textual.app import ComposeResult, RenderResult
27
+ from textual.geometry import Size
28
+ from textual.timer import Timer
29
+
30
+ PROVIDER_PREFIX_STRIPS: dict[str, tuple[str, ...]] = {
31
+ "fireworks": FIREWORKS_MODEL_ID_PREFIXES,
32
+ }
33
+ """Some providers (e.g. Fireworks) require fully-qualified IDs like
34
+ `accounts/fireworks/models/...` or `accounts/fireworks/routers/...` that crowd
35
+ out the rest of the status bar; strip the registered prefixes before display."""
36
+
37
+ ConnectionState = Literal["", "connecting", "reconnecting", "resuming"]
38
+ """Connection states the status bar can display (`''` means cleared)."""
39
+
40
+ CONNECTION_STATES = frozenset(get_args(ConnectionState))
41
+ """Runtime view of `ConnectionState` for `set_connection`'s defensive guard.
42
+
43
+ Derived from the `Literal` so the two can never drift."""
44
+
45
+
46
+ class ModelLabel(Widget):
47
+ """A label that displays a model name, right-aligned with smart truncation.
48
+
49
+ When the full `provider:model` text doesn't fit, the provider is dropped
50
+ first. If the bare model name still doesn't fit, it is left-truncated
51
+ with a leading ellipsis so the most distinctive tail stays visible.
52
+
53
+ When a reasoning effort is set, its label is appended to the model and
54
+ participates in the same ladder: the effort suffix is preserved (with the
55
+ model left-truncated to make room) and is only dropped once even the
56
+ left-truncated model plus effort cannot fit.
57
+ """
58
+
59
+ provider: reactive[str] = reactive("", layout=True)
60
+ model: reactive[str] = reactive("", layout=True)
61
+ effort: reactive[str] = reactive("", layout=True)
62
+
63
+ def _clean_model(self) -> str:
64
+ """Strip the provider's registered prefix so the status bar stays compact.
65
+
66
+ Returns:
67
+ Model name with the provider's registered prefix removed if present,
68
+ otherwise the original name.
69
+ """
70
+ name = self.model
71
+ if not name or not self.provider:
72
+ return name
73
+ # Match on normalized text but slice the original to preserve its casing.
74
+ name_lower = name.lower()
75
+ for prefix in PROVIDER_PREFIX_STRIPS.get(self.provider, ()):
76
+ if name_lower.startswith(prefix):
77
+ return name[len(prefix) :]
78
+ return name
79
+
80
+ def _with_effort(self, text: str) -> str:
81
+ """Append the reasoning effort label when one is set.
82
+
83
+ Args:
84
+ text: Base model display text.
85
+
86
+ Returns:
87
+ Model display text with the effort suffix (a per-session override or
88
+ the provider default) when one is present, else
89
+ `text` unchanged.
90
+ """
91
+ return f"{text} {self.effort}" if self.effort else text
92
+
93
+ def get_content_width(self, container: Size, viewport: Size) -> int: # noqa: ARG002
94
+ """Return the intrinsic width so `width: auto` works.
95
+
96
+ Args:
97
+ container: Size of the container.
98
+ viewport: Size of the viewport.
99
+
100
+ Returns:
101
+ Character length of the full provider:model string.
102
+ """
103
+ if not self.model:
104
+ return 0
105
+ model = self._clean_model()
106
+ full = f"{self.provider}:{model}" if self.provider else model
107
+ return len(self._with_effort(full))
108
+
109
+ def render(self) -> RenderResult:
110
+ """Render the model label with width-aware truncation.
111
+
112
+ Returns:
113
+ Text content, truncated from the left when necessary.
114
+ """
115
+ width = self.content_size.width
116
+ if not self.model or width <= 0:
117
+ return ""
118
+ model = self._clean_model()
119
+ full = f"{self.provider}:{model}" if self.provider else model
120
+ full_with_effort = self._with_effort(full)
121
+ model_with_effort = self._with_effort(model)
122
+ if len(full_with_effort) <= width:
123
+ return Content(full_with_effort)
124
+ if len(model_with_effort) <= width:
125
+ return Content(model_with_effort)
126
+ if self.effort and width > len(self.effort) + 2:
127
+ model_width = width - len(self.effort) - 1
128
+ return Content(f"\u2026{model[-(model_width - 1) :]} {self.effort}")
129
+ if len(model) <= width:
130
+ return Content(model)
131
+ if width > 1:
132
+ return Content("\u2026" + model[-(width - 1) :])
133
+ return Content("\u2026")
134
+
135
+
136
+ class BranchLabel(Widget):
137
+ """A label that displays the git branch with glyph-aware truncation.
138
+
139
+ Unlike CSS `text-overflow: ellipsis` (which always uses the Unicode
140
+ ellipsis character), this widget truncates manually in :meth:`render` using
141
+ :func:`get_glyphs` so ASCII mode (`DEEPAGENTS_CODE_UI_CHARSET_MODE=ascii`)
142
+ gets `"..."` instead of `"…"`.
143
+ """
144
+
145
+ branch: reactive[str] = reactive("", layout=True)
146
+
147
+ def get_content_width(self, container: Size, viewport: Size) -> int: # noqa: ARG002
148
+ """Return the intrinsic width so the widget participates in flex layout.
149
+
150
+ Args:
151
+ container: Size of the container.
152
+ viewport: Size of the viewport.
153
+
154
+ Returns:
155
+ Character length of the full branch string (icon + space + name),
156
+ or `0` when the branch is empty.
157
+ """
158
+ if not self.branch:
159
+ return 0
160
+ icon = get_glyphs().git_branch
161
+ return len(icon) + 1 + len(self.branch)
162
+
163
+ def render(self) -> RenderResult:
164
+ """Render the branch label, truncating with the configured glyph.
165
+
166
+ Returns:
167
+ Branch text (icon + name) truncated from the right with
168
+ :func:`get_glyphs`'s ellipsis when it overflows the available
169
+ width, or an empty string when no branch is set.
170
+ """
171
+ width = self.content_size.width
172
+ if not self.branch or width <= 0:
173
+ return ""
174
+ icon = get_glyphs().git_branch
175
+ full = f"{icon} {self.branch}"
176
+ if len(full) <= width:
177
+ return full
178
+ ellipsis = get_glyphs().ellipsis
179
+ if width <= len(ellipsis):
180
+ return full[:width]
181
+ return full[: width - len(ellipsis)] + ellipsis
182
+
183
+
184
+ class StatusBar(Horizontal):
185
+ """Status bar showing mode, auto-approve, cwd, git branch, tokens, and model."""
186
+
187
+ DEFAULT_CSS = """
188
+ StatusBar {
189
+ height: 1;
190
+ dock: bottom;
191
+ background: $background;
192
+ }
193
+
194
+ StatusBar .status-mode {
195
+ width: auto;
196
+ padding: 0 1;
197
+ }
198
+
199
+ StatusBar .status-mode.normal {
200
+ display: none;
201
+ }
202
+
203
+ StatusBar .status-mode.shell {
204
+ background: $mode-bash;
205
+ color: white;
206
+ text-style: bold;
207
+ }
208
+
209
+ StatusBar .status-mode.command {
210
+ background: $mode-command;
211
+ color: white;
212
+ }
213
+
214
+ StatusBar .status-mode.shell-incognito {
215
+ background: $mode-incognito;
216
+ color: $background;
217
+ text-style: bold;
218
+ }
219
+
220
+ StatusBar .status-auto-approve {
221
+ width: auto;
222
+ padding: 0 1;
223
+ }
224
+
225
+ StatusBar .status-auto-approve.on {
226
+ background: $success;
227
+ color: $background;
228
+ }
229
+
230
+ StatusBar .status-auto-approve.off {
231
+ background: $warning;
232
+ color: $background;
233
+ }
234
+
235
+ StatusBar .status-connection {
236
+ width: auto;
237
+ padding: 0 1;
238
+ color: $warning;
239
+ text-style: bold;
240
+ }
241
+
242
+ StatusBar .status-message {
243
+ width: auto;
244
+ padding: 0 1;
245
+ color: $text-muted;
246
+ }
247
+
248
+ StatusBar .status-message.thinking {
249
+ color: $warning;
250
+ }
251
+
252
+ StatusBar .status-cwd {
253
+ width: auto;
254
+ text-align: right;
255
+ color: $text-muted;
256
+ /* Own both adjacent gaps so they disappear with the cwd: the left gap
257
+ separates it from the auto-approve pill when transient status slots
258
+ are hidden, and the right gap separates it from the branch. */
259
+ padding: 0 1 0 1;
260
+ }
261
+
262
+ StatusBar .status-branch {
263
+ width: 1fr;
264
+ min-width: 0;
265
+ overflow-x: hidden;
266
+ text-wrap: nowrap;
267
+ }
268
+
269
+ StatusBar .status-left-collapsible {
270
+ width: 1fr;
271
+ min-width: 0;
272
+ height: 1;
273
+ overflow-x: hidden;
274
+ }
275
+
276
+ StatusBar .status-tokens {
277
+ width: auto;
278
+ padding: 0 1;
279
+ color: $text-muted;
280
+ }
281
+
282
+ StatusBar .status-rubric {
283
+ width: auto;
284
+ padding: 0 1;
285
+ color: $success;
286
+ text-style: bold;
287
+ }
288
+
289
+ StatusBar ModelLabel {
290
+ width: auto;
291
+ padding: 0 2;
292
+ color: $text-muted;
293
+ text-align: right;
294
+ }
295
+
296
+ StatusBar BranchLabel {
297
+ color: $text-muted;
298
+ /* No left pad while cwd is visible: the cwd owns that gap. */
299
+ padding: 0 1 0 0;
300
+ }
301
+
302
+ StatusBar .status-cwd.after-status {
303
+ padding: 0 1 0 0;
304
+ }
305
+
306
+ StatusBar BranchLabel.cwd-hidden {
307
+ /* When cwd is hidden, the branch needs the same left separator that
308
+ cwd normally provides after the auto-approve pill. */
309
+ padding: 0 1 0 1;
310
+ }
311
+
312
+ StatusBar BranchLabel.cwd-hidden.after-status {
313
+ padding: 0 1 0 0;
314
+ }
315
+ """
316
+ """Mode badges and auto-approve pills use distinct colors for at-a-glance status."""
317
+
318
+ mode: reactive[str] = reactive("normal", init=False)
319
+ status_message: reactive[str] = reactive("", init=False)
320
+ connection_state: reactive[ConnectionState] = reactive("", init=False)
321
+ queued_count: reactive[int] = reactive(0, init=False)
322
+ auto_approve: reactive[bool] = reactive(default=False, init=False)
323
+ cwd: reactive[str] = reactive("", init=False)
324
+ branch: reactive[str] = reactive("", init=False)
325
+ tokens: reactive[int] = reactive(0, init=False)
326
+ rubric_label: reactive[str] = reactive("", init=False)
327
+
328
+ def __init__(self, cwd: str | Path | None = None, **kwargs: Any) -> None:
329
+ """Initialize the status bar.
330
+
331
+ Args:
332
+ cwd: Current working directory to display
333
+ **kwargs: Additional arguments passed to parent
334
+ """
335
+ super().__init__(**kwargs)
336
+ # Store initial cwd - will be used in compose()
337
+ self._initial_cwd = str(cwd) if cwd else str(Path.cwd())
338
+ self._hide_cwd = is_env_truthy(HIDE_CWD)
339
+ self._hide_git_branch = is_env_truthy(HIDE_GIT_BRANCH)
340
+ self._spinner = Spinner()
341
+ self._spinner_timer: Timer | None = None
342
+ self._busy_message = ""
343
+
344
+ def compose(self) -> ComposeResult: # noqa: PLR6301 — Textual widget method
345
+ """Compose the status bar layout.
346
+
347
+ Yields:
348
+ Widgets for mode, auto-approve, message, cwd, branch, tokens, and
349
+ model display.
350
+ """
351
+ yield Static("", classes="status-mode normal", id="mode-indicator")
352
+ yield Static(
353
+ "manual",
354
+ classes="status-auto-approve off",
355
+ id="auto-approve-indicator",
356
+ )
357
+ with Horizontal(classes="status-left-collapsible"):
358
+ yield Static("", classes="status-connection", id="connection-indicator")
359
+ yield Static("", classes="status-message", id="status-message")
360
+ yield Static("", classes="status-cwd", id="cwd-display")
361
+ yield BranchLabel(classes="status-branch", id="branch-display")
362
+ yield Static("", classes="status-rubric", id="rubric-display")
363
+ yield Static("", classes="status-tokens", id="tokens-display")
364
+ yield ModelLabel(id="model-display")
365
+
366
+ _CWD_WIDTH_THRESHOLD = 70
367
+ """Hide cwd display below this terminal width."""
368
+
369
+ def on_resize(self, event: events.Resize) -> None:
370
+ """Hide the cwd on very narrow terminals.
371
+
372
+ The git branch stays visible at any width (unless disabled via
373
+ `HIDE_GIT_BRANCH`) and ellipsizes to fit; only the cwd is dropped
374
+ outright to reclaim space when the terminal gets narrow.
375
+ """
376
+ width = event.size.width
377
+ self._set_cwd_visible(not self._hide_cwd and width >= self._CWD_WIDTH_THRESHOLD)
378
+
379
+ def _set_cwd_visible(self, visible: bool) -> None:
380
+ """Show or hide cwd and keep adjacent branch spacing in sync."""
381
+ with suppress(NoMatches):
382
+ self.query_one("#cwd-display", Static).display = visible
383
+ with suppress(NoMatches):
384
+ branch = self.query_one("#branch-display", BranchLabel)
385
+ if visible:
386
+ branch.remove_class("cwd-hidden")
387
+ else:
388
+ branch.add_class("cwd-hidden")
389
+ self._sync_left_separator()
390
+
391
+ def _sync_left_separator(self) -> None:
392
+ """Use one separator between the last transient status item and cwd/branch."""
393
+ preceded_by_status = False
394
+ with suppress(NoMatches):
395
+ preceded_by_status = bool(
396
+ self.query_one("#connection-indicator", Static).display
397
+ or self.query_one("#status-message", Static).display
398
+ )
399
+ for selector, widget_type in (
400
+ ("#cwd-display", Static),
401
+ ("#branch-display", BranchLabel),
402
+ ):
403
+ with suppress(NoMatches):
404
+ widget = self.query_one(selector, widget_type)
405
+ if preceded_by_status:
406
+ widget.add_class("after-status")
407
+ else:
408
+ widget.remove_class("after-status")
409
+
410
+ def on_unmount(self) -> None:
411
+ """Stop the spinner timer so it can't tick on a detached widget."""
412
+ self._stop_spinner()
413
+
414
+ def on_mount(self) -> None:
415
+ """Set reactive values after mount to trigger watchers safely."""
416
+ from deepagents_code.config import settings
417
+
418
+ self.cwd = self._initial_cwd
419
+ if self._hide_cwd:
420
+ self._set_cwd_visible(False)
421
+ if self._hide_git_branch:
422
+ with suppress(NoMatches):
423
+ self.query_one("#branch-display", BranchLabel).display = False
424
+ # Set initial model display
425
+ label = self.query_one("#model-display", ModelLabel)
426
+ label.provider = settings.model_provider or ""
427
+ label.model = settings.model_name or ""
428
+ with suppress(NoMatches):
429
+ self.query_one("#rubric-display", Static).display = False
430
+ # Reactives are `init=False`, so the connection watcher never fires on
431
+ # mount; render once to hide the empty indicator (and its padding).
432
+ self._render_connection()
433
+ # Same reasoning for the message and token slots: both start empty, so
434
+ # hide them on mount so their padding doesn't reserve a blank gap.
435
+ self.watch_status_message(self.status_message)
436
+ with suppress(NoMatches):
437
+ self.query_one("#tokens-display", Static).display = False
438
+
439
+ def watch_mode(self, mode: str) -> None:
440
+ """Update mode indicator when mode changes."""
441
+ try:
442
+ indicator = self.query_one("#mode-indicator", Static)
443
+ except NoMatches:
444
+ return
445
+ indicator.remove_class("normal", "shell", "command", "shell-incognito")
446
+
447
+ if mode == "shell":
448
+ indicator.update("SHELL")
449
+ indicator.add_class("shell")
450
+ elif mode == "shell_incognito":
451
+ indicator.update("SHELL")
452
+ indicator.add_class("shell-incognito")
453
+ elif mode == "command":
454
+ indicator.update("CMD")
455
+ indicator.add_class("command")
456
+ else:
457
+ indicator.update("")
458
+ indicator.add_class("normal")
459
+
460
+ def watch_auto_approve(self, new_value: bool) -> None:
461
+ """Update auto-approve indicator when state changes."""
462
+ try:
463
+ indicator = self.query_one("#auto-approve-indicator", Static)
464
+ except NoMatches:
465
+ return
466
+ indicator.remove_class("on", "off")
467
+
468
+ if new_value:
469
+ indicator.update("auto")
470
+ indicator.add_class("on")
471
+ else:
472
+ indicator.update("manual")
473
+ indicator.add_class("off")
474
+
475
+ def watch_cwd(self, new_value: str) -> None:
476
+ """Update cwd display when it changes."""
477
+ try:
478
+ display = self.query_one("#cwd-display", Static)
479
+ except NoMatches:
480
+ return
481
+ display.update(self._format_cwd(new_value))
482
+
483
+ def watch_branch(self, new_value: str) -> None:
484
+ """Update branch display when it changes."""
485
+ try:
486
+ display = self.query_one("#branch-display", BranchLabel)
487
+ except NoMatches:
488
+ return
489
+ display.branch = new_value
490
+
491
+ def watch_status_message(self, new_value: str) -> None:
492
+ """Update status message display."""
493
+ if self._busy_message:
494
+ # The busy indicator owns the status-message slot while active;
495
+ # defer regular status updates until `set_busy("")` clears it.
496
+ return
497
+ try:
498
+ msg_widget = self.query_one("#status-message", Static)
499
+ except NoMatches:
500
+ return
501
+
502
+ msg_widget.remove_class("thinking")
503
+ # Hide when empty so the widget's padding doesn't reserve a blank gap
504
+ # in the footer (mirrors the connection indicator).
505
+ msg_widget.display = bool(new_value)
506
+ if new_value:
507
+ msg_widget.update(new_value)
508
+ if "thinking" in new_value.lower() or "executing" in new_value.lower():
509
+ msg_widget.add_class("thinking")
510
+ else:
511
+ msg_widget.update("")
512
+ self._sync_left_separator()
513
+
514
+ def watch_connection_state(self, _new_value: ConnectionState) -> None:
515
+ """Start or stop the spinner and re-render when connection state changes."""
516
+ self._sync_spinner()
517
+ self._render_connection()
518
+
519
+ def watch_queued_count(self, _new_value: int) -> None:
520
+ """Re-render the connection indicator when the queued count changes."""
521
+ self._render_connection()
522
+
523
+ def _spinner_active(self) -> bool:
524
+ """Whether any indicator (connection or busy) needs the shared spinner.
525
+
526
+ Returns:
527
+ `True` when a connection state or a busy message is active.
528
+ """
529
+ return bool(self.connection_state) or bool(self._busy_message)
530
+
531
+ def _sync_spinner(self) -> None:
532
+ """Start or stop the shared spinner to match connection/busy state."""
533
+ if self._spinner_active():
534
+ self._start_spinner()
535
+ else:
536
+ self._stop_spinner()
537
+
538
+ def _start_spinner(self) -> None:
539
+ """Begin cycling the shared spinner frames.
540
+
541
+ No-op when not yet running (e.g. before mount) since `set_interval`
542
+ requires a live event loop, or when an animation is already active.
543
+ """
544
+ if self._spinner_timer is not None or not self._running:
545
+ return
546
+ # 0.1s mirrors LoadingWidget so this spinner ticks in step with the
547
+ # in-thread "Thinking" spinner.
548
+ self._spinner_timer = self.set_interval(0.1, self._tick_spinner)
549
+
550
+ def _stop_spinner(self) -> None:
551
+ """Stop the spinner animation and reset to the first frame."""
552
+ if self._spinner_timer is not None:
553
+ self._spinner_timer.stop()
554
+ self._spinner_timer = None
555
+ self._spinner = Spinner()
556
+
557
+ def _tick_spinner(self) -> None:
558
+ """Advance the spinner frame and re-render the animated indicators."""
559
+ self._spinner.next_frame()
560
+ self._render_connection()
561
+ self._render_busy()
562
+
563
+ def _render_connection(self) -> None:
564
+ """Render the combined connection + queued-count indicator text."""
565
+ try:
566
+ widget = self.query_one("#connection-indicator", Static)
567
+ except NoMatches:
568
+ return
569
+
570
+ parts: list[str] = []
571
+ if self.connection_state == "reconnecting":
572
+ parts.append(f"{self._spinner.current_frame()} Reconnecting")
573
+ elif self.connection_state == "resuming":
574
+ parts.append(f"{self._spinner.current_frame()} Resuming")
575
+ elif self.connection_state == "connecting":
576
+ parts.append(f"{self._spinner.current_frame()} Connecting")
577
+ if self.queued_count > 0:
578
+ label = "message" if self.queued_count == 1 else "messages"
579
+ parts.append(f"{self.queued_count} {label} queued")
580
+ separator = f" {get_glyphs().bullet} "
581
+ text = separator.join(parts)
582
+ # Hide the widget entirely when empty so its `padding: 0 1` doesn't
583
+ # leave a 2-column gap between the auto-approve pill and the cwd.
584
+ widget.display = bool(text)
585
+ widget.update(text)
586
+ self._sync_left_separator()
587
+
588
+ def _render_busy(self) -> None:
589
+ """Render the animated busy indicator into the status-message slot."""
590
+ if not self._busy_message:
591
+ return
592
+ try:
593
+ widget = self.query_one("#status-message", Static)
594
+ except NoMatches:
595
+ return
596
+ widget.remove_class("thinking")
597
+ widget.display = True
598
+ frame = self._spinner.current_frame()
599
+ widget.update(Content.assemble(frame, " ", Content(self._busy_message)))
600
+ self._sync_left_separator()
601
+
602
+ def set_busy(self, message: str) -> None:
603
+ """Show or clear an animated busy indicator in the status-message slot.
604
+
605
+ Reuses the shared status-bar spinner so heavier UI operations (e.g. a
606
+ model switch that imports a provider package) show activity instead of
607
+ appearing to hang.
608
+
609
+ Args:
610
+ message: Busy text to animate with a spinner, or empty string to
611
+ clear it and restore the regular status message.
612
+ """
613
+ self._busy_message = message
614
+ self._sync_spinner()
615
+ if message:
616
+ self._render_busy()
617
+ else:
618
+ self.watch_status_message(self.status_message)
619
+
620
+ def set_connection(self, state: ConnectionState) -> None:
621
+ """Set the connection indicator state.
622
+
623
+ Args:
624
+ state: One of `''` (clear), `'connecting'`, `'reconnecting'`, or
625
+ `'resuming'`.
626
+
627
+ Raises:
628
+ ValueError: If `state` is not a recognized connection state.
629
+ """
630
+ if state not in CONNECTION_STATES:
631
+ msg = f"Unknown connection state: {state!r}"
632
+ raise ValueError(msg)
633
+ self.connection_state = state
634
+
635
+ def set_queued(self, count: int) -> None:
636
+ """Set the number of messages waiting in the queue.
637
+
638
+ Args:
639
+ count: Count of queued messages (negative values clamp to `0`).
640
+ """
641
+ self.queued_count = max(count, 0)
642
+
643
+ def _format_cwd(self, cwd_path: str = "") -> str:
644
+ """Format the current working directory for display.
645
+
646
+ Returns:
647
+ Formatted path string, using ~ for home directory when possible.
648
+ """
649
+ path = Path(cwd_path or self.cwd or self._initial_cwd)
650
+ try:
651
+ # Try to use ~ for home directory
652
+ home = Path.home()
653
+ if path.is_relative_to(home):
654
+ return "~/" + path.relative_to(home).as_posix()
655
+ except (ValueError, RuntimeError):
656
+ pass
657
+ return str(path)
658
+
659
+ def set_mode(self, mode: str) -> None:
660
+ """Set the current input mode.
661
+
662
+ Args:
663
+ mode: One of "normal", "shell", or "command"
664
+ """
665
+ self.mode = mode
666
+
667
+ def set_auto_approve(self, *, enabled: bool) -> None:
668
+ """Set the auto-approve state.
669
+
670
+ Args:
671
+ enabled: Whether auto-approve is enabled
672
+ """
673
+ self.auto_approve = enabled
674
+
675
+ def set_status_message(self, message: str) -> None:
676
+ """Set the status message.
677
+
678
+ Args:
679
+ message: Status message to display (empty string to clear)
680
+ """
681
+ self.status_message = message
682
+
683
+ _approximate: bool = False
684
+ """Append "+" to the token count to signal that the displayed value is stale.
685
+
686
+ (The actual context is larger because the generation was interrupted before
687
+ the model reported final usage.)
688
+ """
689
+ _has_token_count: bool = False
690
+ """Whether the status bar has displayed a real token count this session."""
691
+
692
+ def watch_tokens(self, new_value: int) -> None:
693
+ """Update token display when count changes."""
694
+ self._render_tokens(new_value, approximate=self._approximate)
695
+
696
+ def watch_rubric_label(self, new_value: str) -> None:
697
+ """Update rubric display when active rubric state changes."""
698
+ try:
699
+ display = self.query_one("#rubric-display", Static)
700
+ except NoMatches:
701
+ return
702
+ display.display = bool(new_value)
703
+ display.update(new_value)
704
+
705
+ def _render_tokens(self, count: int, *, approximate: bool = False) -> None:
706
+ """Render the token count into the display widget.
707
+
708
+ Args:
709
+ count: Total context token count.
710
+ approximate: Append "+" suffix to indicate the count is stale
711
+ (e.g. after an interrupted generation).
712
+ """
713
+ try:
714
+ display = self.query_one("#tokens-display", Static)
715
+ except NoMatches:
716
+ return
717
+
718
+ # Hide when empty so the widget's padding doesn't reserve a blank gap.
719
+ display.display = count > 0
720
+ if count > 0:
721
+ suffix = "+" if approximate else ""
722
+ # Format with K suffix for thousands
723
+ if count >= 1000: # noqa: PLR2004 # Count formatting threshold
724
+ display.update(f"{count / 1000:.1f}K{suffix} tokens")
725
+ else:
726
+ display.update(f"{count}{suffix} tokens")
727
+ else:
728
+ display.update("")
729
+
730
+ def set_rubric_label(self, label: str) -> None:
731
+ """Set the rubric status label.
732
+
733
+ Args:
734
+ label: Label to display, or empty string to hide the badge.
735
+ """
736
+ self.rubric_label = label
737
+
738
+ def set_tokens(self, count: int, *, approximate: bool = False) -> None:
739
+ """Set the token count.
740
+
741
+ Forces a display refresh even when the value is unchanged. During
742
+ streaming, `show_pending_tokens` replaces the widget text without
743
+ changing the reactive token value, so a later update with the same
744
+ count still needs to re-render the exact count.
745
+
746
+ Args:
747
+ count: Current context token count.
748
+ approximate: Append "+" to indicate the count is stale.
749
+ """
750
+ self._approximate = approximate
751
+ self._has_token_count = count > 0
752
+ if self.tokens == count:
753
+ # Reactive dedup would skip the watcher — call render directly.
754
+ self._render_tokens(count, approximate=approximate)
755
+ else:
756
+ # Reactive assignment triggers watch_tokens, which reads
757
+ # self._approximate for the suffix.
758
+ self.tokens = count
759
+
760
+ def show_pending_tokens(self) -> None:
761
+ """Show an unknown token count placeholder during streaming."""
762
+ if not self._has_token_count:
763
+ return
764
+ try:
765
+ self.query_one("#tokens-display", Static).update("... tokens")
766
+ except NoMatches:
767
+ return
768
+
769
+ def set_model(self, *, provider: str, model: str, effort: str = "") -> None:
770
+ """Update the model display text.
771
+
772
+ Args:
773
+ provider: Model provider name (e.g., `'anthropic'`).
774
+ model: Model name (e.g., `'claude-sonnet-4-5'`).
775
+ effort: Reasoning effort label to display (per-session override or
776
+ provider default), or empty when none applies.
777
+ """
778
+ label = self.query_one("#model-display", ModelLabel)
779
+ label.provider = provider
780
+ label.model = model
781
+ label.effort = effort