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,868 @@
1
+ r"""Read-only in-app Debug Console modal.
2
+
3
+ Toggled with `Ctrl+\` (or the hidden `/debug` command), this overlay shows a
4
+ point-in-time session/runtime snapshot plus a live tail of recent
5
+ `deepagents_code.*` log records sourced from the in-memory ring buffer in
6
+ `_debug_buffer`. It never mutates session state.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import bisect
12
+ import logging
13
+ from typing import TYPE_CHECKING, ClassVar, Literal, NamedTuple, cast, get_args
14
+
15
+ from rich.segment import Segment
16
+ from rich.style import Style as RichStyle
17
+ from textual.binding import Binding, BindingType
18
+ from textual.cache import LRUCache
19
+ from textual.containers import Horizontal, Vertical
20
+ from textual.content import Content
21
+ from textual.geometry import Size
22
+ from textual.screen import ModalScreen
23
+ from textual.scroll_view import ScrollView
24
+ from textual.strip import Strip
25
+ from textual.widgets import Select, Static
26
+ from textual.widgets._select import ( # noqa: PLC2701 # needed to keep Tab navigation inside the open Select overlay
27
+ SelectCurrent,
28
+ SelectOverlay,
29
+ )
30
+
31
+ from deepagents_code import theme
32
+ from deepagents_code._debug import LOG_LEVELS
33
+ from deepagents_code._debug_buffer import (
34
+ DEFAULT_CAPACITY,
35
+ InMemoryLogRecord,
36
+ get_log_buffer,
37
+ )
38
+ from deepagents_code.clipboard import copy_text_to_clipboard
39
+ from deepagents_code.unicode_security import sanitize_control_chars
40
+
41
+ if TYPE_CHECKING:
42
+ from collections.abc import Callable, Sequence
43
+
44
+ from textual import events
45
+ from textual.app import ComposeResult
46
+
47
+ logger = logging.getLogger(__name__)
48
+
49
+ DEBUG_TOGGLE_KEY = "ctrl+backslash"
50
+ r"""Textual key name for the `Ctrl+\` chord that toggles the console."""
51
+
52
+
53
+ class SnapshotField(NamedTuple):
54
+ """A single `label`/`value` row in the console's session snapshot.
55
+
56
+ A named pair (rather than a bare ``tuple[str, str]``) so the two display-only
57
+ string slots cannot be silently transposed at the construction site.
58
+ """
59
+
60
+ label: str
61
+ value: str
62
+
63
+
64
+ _REFRESH_INTERVAL = 0.5
65
+ """Seconds between log-tail refresh ticks."""
66
+
67
+ _RECORD_LIMIT = DEFAULT_CAPACITY
68
+ """Maximum records retained by an open debug console view."""
69
+
70
+ _FILTER_SELECT_ID = "debug-level-filter"
71
+ FilterValue = Literal[
72
+ "all",
73
+ "min:DEBUG",
74
+ "min:INFO",
75
+ "min:WARNING",
76
+ "min:ERROR",
77
+ "min:CRITICAL",
78
+ "only:DEBUG",
79
+ "only:INFO",
80
+ "only:WARNING",
81
+ "only:ERROR",
82
+ "only:CRITICAL",
83
+ ]
84
+ _BASE_FILTER_OPTIONS: tuple[tuple[str, FilterValue], ...] = (
85
+ ("All", "all"),
86
+ ("INFO", "min:INFO"),
87
+ ("WARNING", "min:WARNING"),
88
+ ("ERROR", "min:ERROR"),
89
+ ("CRITICAL", "min:CRITICAL"),
90
+ ("Only INFO", "only:INFO"),
91
+ ("Only WARNING", "only:WARNING"),
92
+ ("Only ERROR", "only:ERROR"),
93
+ ("Only CRITICAL", "only:CRITICAL"),
94
+ )
95
+ _DEBUG_FILTER_OPTIONS: tuple[tuple[str, FilterValue], ...] = (
96
+ ("DEBUG", "min:DEBUG"),
97
+ ("Only DEBUG", "only:DEBUG"),
98
+ )
99
+ _VALID_FILTER_VALUES: frozenset[str] = frozenset(get_args(FilterValue))
100
+ """Every legal `FilterValue`, used to validate values crossing the Select
101
+ boundary before they are trusted as a `FilterValue`."""
102
+ _LEVEL_STYLES = {
103
+ "DEBUG": "dim",
104
+ "INFO": "cyan",
105
+ "WARNING": "yellow",
106
+ "ERROR": "red",
107
+ "CRITICAL": "bold red",
108
+ }
109
+ _EMPTY_STYLE = RichStyle()
110
+
111
+
112
+ def _sanitize_display_text(text: str, *, keep_newlines: bool = False) -> str:
113
+ """Return text safe to render in the debug console."""
114
+ return sanitize_control_chars(
115
+ text,
116
+ keep_newlines=keep_newlines,
117
+ collapse_whitespace=False,
118
+ )
119
+
120
+
121
+ def _debug_records_enabled() -> bool:
122
+ """Return whether the package logger can emit DEBUG records."""
123
+ return logging.getLogger("deepagents_code").isEnabledFor(logging.DEBUG)
124
+
125
+
126
+ def _filter_options() -> tuple[tuple[str, FilterValue], ...]:
127
+ """Return level filter options valid for the current logging configuration."""
128
+ if not _debug_records_enabled():
129
+ return _BASE_FILTER_OPTIONS
130
+ all_option = _BASE_FILTER_OPTIONS[:1]
131
+ rest = _BASE_FILTER_OPTIONS[1:]
132
+ return (*all_option, *_DEBUG_FILTER_OPTIONS, *rest)
133
+
134
+
135
+ def _record_matches_filter(
136
+ record: InMemoryLogRecord, level_filter: FilterValue
137
+ ) -> bool:
138
+ """Return whether *record* should be visible for *level_filter*."""
139
+ if level_filter == "all":
140
+ return True
141
+ mode, selected_level = level_filter.split(":", maxsplit=1)
142
+ if mode == "only":
143
+ return record.level == selected_level
144
+ threshold = LOG_LEVELS.get(selected_level)
145
+ if threshold is None:
146
+ # An unrecognized level should never reach here (FilterValue enumerates
147
+ # only LOG_LEVELS keys), but a diagnostic must not hide records on a bad
148
+ # filter: show everything rather than raise on the poll timer.
149
+ return True
150
+ return record.levelno >= threshold
151
+
152
+
153
+ def _record_to_content(record: InMemoryLogRecord) -> Content:
154
+ """Render a structured log record as styled Textual content.
155
+
156
+ Returns:
157
+ Styled content for the log view.
158
+ """
159
+ timestamp = _sanitize_display_text(record.timestamp)
160
+ level = _sanitize_display_text(record.level)
161
+ logger = _sanitize_display_text(record.logger)
162
+ message = _sanitize_display_text(record.message, keep_newlines=True)
163
+ level_style = _LEVEL_STYLES.get(record.level, "dim")
164
+ return Content.assemble(
165
+ (timestamp, "dim"),
166
+ " ",
167
+ (f"{level:<8}", level_style),
168
+ " ",
169
+ (logger, "dim"),
170
+ " ",
171
+ message,
172
+ )
173
+
174
+
175
+ class _LogLevelOverlay(SelectOverlay):
176
+ """Select overlay that treats Tab and Shift+Tab like down and up arrows."""
177
+
178
+ def key_tab(self, event: events.Key) -> None:
179
+ """Move the highlighted option down while the menu is open."""
180
+ event.prevent_default()
181
+ event.stop()
182
+ self.action_cursor_down()
183
+
184
+ def key_shift_tab(self, event: events.Key) -> None:
185
+ """Move the highlighted option up while the menu is open."""
186
+ event.prevent_default()
187
+ event.stop()
188
+ self.action_cursor_up()
189
+
190
+ def key_escape(self, event: events.Key) -> None:
191
+ """Close the dropdown without dismissing the debug console."""
192
+ event.prevent_default()
193
+ event.stop()
194
+ self.action_dismiss()
195
+
196
+ def check_consume_key(self, key: str, character: str | None = None) -> bool:
197
+ """Prevent screen-level focus traversal while the menu is open.
198
+
199
+ Returns:
200
+ `True` when this overlay should handle the key itself.
201
+ """
202
+ return key in {"escape", "tab", "shift+tab"} or super().check_consume_key(
203
+ key, character
204
+ )
205
+
206
+
207
+ class _LogLevelSelect(Select[FilterValue]):
208
+ """Level dropdown whose open menu treats Tab like arrow navigation."""
209
+
210
+ def compose(self) -> ComposeResult:
211
+ """Compose the select with a Tab-aware overlay.
212
+
213
+ Yields:
214
+ Current value display and dropdown overlay widgets.
215
+ """
216
+ yield SelectCurrent(self.prompt)
217
+ yield _LogLevelOverlay(type_to_search=self._type_to_search).data_bind(
218
+ compact=Select.compact
219
+ )
220
+
221
+
222
+ class _DebugLogView(ScrollView, can_focus=True):
223
+ """Scrollable styled log view with logical-record hover and click handling."""
224
+
225
+ def __init__(
226
+ self,
227
+ on_copy_record: Callable[[InMemoryLogRecord], None],
228
+ *,
229
+ widget_id: str | None = None,
230
+ classes: str | None = None,
231
+ ) -> None:
232
+ super().__init__(id=widget_id, classes=classes)
233
+ self._on_copy_record = on_copy_record
234
+ self._records: list[InMemoryLogRecord] = []
235
+ self._notice: Content | None = None
236
+ self._contents: list[Content] = []
237
+ self._wrap_counts: list[int] = []
238
+ self._wrap_prefix: list[int] = [0]
239
+ self._total_visual = 0
240
+ self._cached_width = 0
241
+ self._hover_index: int | None = None
242
+ self._selected_index: int | None = None
243
+ self._render_line_cache: LRUCache[
244
+ tuple[int, int, int, int | None, int | None], Strip
245
+ ] = LRUCache(1024)
246
+
247
+ @property
248
+ def line_count(self) -> int:
249
+ """The current visual line count."""
250
+ return self._total_visual
251
+
252
+ @property
253
+ def records(self) -> Sequence[InMemoryLogRecord]:
254
+ """The currently visible logical records."""
255
+ return self._records
256
+
257
+ def set_records(
258
+ self, records: Sequence[InMemoryLogRecord], *, scroll_end: bool = True
259
+ ) -> None:
260
+ """Replace the visible records and optionally scroll to the bottom."""
261
+ self._notice = None
262
+ self._records = list(records)
263
+ self._hover_index = None
264
+ self._selected_index = self._coerce_selected_index(self._selected_index)
265
+ self._rebuild_contents()
266
+ self._reflow()
267
+ if scroll_end:
268
+ self.scroll_end(animate=False, immediate=True, x_axis=False)
269
+
270
+ def append_records(self, records: Sequence[InMemoryLogRecord]) -> None:
271
+ """Append records to the visible view."""
272
+ if not records:
273
+ return
274
+ if self._notice is not None:
275
+ self.set_records(records)
276
+ return
277
+ at_bottom = self.is_vertical_scroll_end
278
+ start = len(self._contents)
279
+ self._records.extend(records)
280
+ self._contents.extend(_record_to_content(record) for record in records)
281
+ width = self._cached_width or self.size.width
282
+ if width <= 0:
283
+ # Not yet sized (e.g. first poll before layout). Assume one visual
284
+ # line per new content so `_wrap_counts` stays 1:1 with `_contents`;
285
+ # the first `on_resize` reflow recomputes real counts. Skipping this
286
+ # would leave the new records out of `_wrap_prefix`/`_total_visual`
287
+ # and invisible until that resize.
288
+ self._wrap_counts.extend(1 for _ in self._contents[start:])
289
+ self._recompute_prefix()
290
+ self.refresh()
291
+ return
292
+ self._cached_width = width
293
+ counts = [
294
+ self._wrap_count(content, width) for content in self._contents[start:]
295
+ ]
296
+ self._wrap_counts.extend(counts)
297
+ self._recompute_prefix()
298
+ self.virtual_size = Size(width, self._total_visual)
299
+ self._render_line_cache.clear()
300
+ self.refresh()
301
+ if at_bottom:
302
+ self.scroll_end(animate=False, immediate=True, x_axis=False)
303
+
304
+ def clear_records(self) -> None:
305
+ """Clear the visible records."""
306
+ self._notice = None
307
+ self._records.clear()
308
+ self._contents.clear()
309
+ self._wrap_counts.clear()
310
+ self._wrap_prefix = [0]
311
+ self._total_visual = 0
312
+ self._hover_index = None
313
+ self._selected_index = None
314
+ self._render_line_cache.clear()
315
+ self.virtual_size = Size(self.size.width, 0)
316
+ self.refresh()
317
+
318
+ def show_notice(self, message: str) -> None:
319
+ """Render a one-line notice in place of log records."""
320
+ self._records.clear()
321
+ self._notice = Content.styled(_sanitize_display_text(message), "dim italic")
322
+ self._hover_index = None
323
+ self._selected_index = None
324
+ self._rebuild_contents()
325
+ self._reflow()
326
+
327
+ def _rebuild_contents(self) -> None:
328
+ if self._notice is not None:
329
+ self._contents = [self._notice]
330
+ return
331
+ self._contents = [_record_to_content(record) for record in self._records]
332
+
333
+ @staticmethod
334
+ def _wrap_count(content: Content, width: int) -> int:
335
+ if width <= 0:
336
+ return 1
337
+ return max(1, len(content.wrap(width)))
338
+
339
+ def _recompute_prefix(self) -> None:
340
+ self._wrap_prefix = [0]
341
+ for count in self._wrap_counts:
342
+ self._wrap_prefix.append(self._wrap_prefix[-1] + count)
343
+ self._total_visual = self._wrap_prefix[-1]
344
+
345
+ def _reflow(self) -> None:
346
+ width = self.size.width
347
+ if width <= 0:
348
+ width = self._cached_width
349
+ if width <= 0:
350
+ self._wrap_counts = [1 for _content in self._contents]
351
+ self._recompute_prefix()
352
+ self.refresh()
353
+ return
354
+ self._cached_width = width
355
+ self._render_line_cache.clear()
356
+ self._wrap_counts = [
357
+ self._wrap_count(content, width) for content in self._contents
358
+ ]
359
+ self._recompute_prefix()
360
+ self.virtual_size = Size(width, self._total_visual)
361
+ self.refresh()
362
+
363
+ def _content_index_at_visual_y(self, visual_y: int) -> int | None:
364
+ if visual_y < 0 or visual_y >= self._total_visual:
365
+ return None
366
+ index = bisect.bisect_right(self._wrap_prefix, visual_y) - 1
367
+ if 0 <= index < len(self._contents):
368
+ return index
369
+ return None
370
+
371
+ def _record_at_visual_y(self, visual_y: int) -> InMemoryLogRecord | None:
372
+ if self._notice is not None:
373
+ return None
374
+ index = self._content_index_at_visual_y(visual_y)
375
+ if index is None or index >= len(self._records):
376
+ return None
377
+ return self._records[index]
378
+
379
+ def _coerce_selected_index(self, index: int | None) -> int | None:
380
+ if not self._records:
381
+ return None
382
+ if index is None:
383
+ return None
384
+ return min(max(index, 0), len(self._records) - 1)
385
+
386
+ def _select_record(self, index: int) -> None:
387
+ if not self._records:
388
+ return
389
+ self._selected_index = min(max(index, 0), len(self._records) - 1)
390
+ self._hover_index = None
391
+ self._render_line_cache.clear()
392
+ self._scroll_selected_visible()
393
+ self.refresh()
394
+
395
+ def _scroll_selected_visible(self) -> None:
396
+ if self._selected_index is None or not self._wrap_prefix:
397
+ return
398
+ start = self._wrap_prefix[self._selected_index]
399
+ end = self._wrap_prefix[self._selected_index + 1] - 1
400
+ _scroll_x, scroll_y = self.scroll_offset
401
+ height = max(self.size.height, 1)
402
+ if start < scroll_y:
403
+ self.scroll_to(y=start, animate=False, immediate=True)
404
+ elif end >= scroll_y + height:
405
+ self.scroll_to(y=end - height + 1, animate=False, immediate=True)
406
+
407
+ def _copy_selected_record(self) -> None:
408
+ if self._selected_index is None:
409
+ if not self._records:
410
+ return
411
+ self._selected_index = len(self._records) - 1
412
+ record = self._records[self._selected_index]
413
+ self._on_copy_record(record)
414
+
415
+ def render_line(self, y: int) -> Strip:
416
+ _scroll_x, scroll_y = self.scroll_offset
417
+ abs_y = scroll_y + y
418
+ width = self.size.width
419
+ key = (
420
+ abs_y,
421
+ width,
422
+ self._cached_width,
423
+ self._hover_index,
424
+ self._selected_index,
425
+ )
426
+ cached = self._render_line_cache.get(key)
427
+ if cached is not None:
428
+ return cached
429
+ if abs_y >= self._total_visual:
430
+ return Strip.blank(width, self.rich_style)
431
+
432
+ content_index = self._content_index_at_visual_y(abs_y)
433
+ if content_index is None:
434
+ return Strip.blank(width, self.rich_style)
435
+ content = self._contents[content_index]
436
+ row_style: RichStyle | None = None
437
+ if self._selected_index == content_index and self._notice is None:
438
+ colors = theme.get_theme_colors(self)
439
+ row_style = RichStyle(
440
+ color=colors.background,
441
+ bgcolor=colors.primary,
442
+ bold=True,
443
+ )
444
+ elif self._hover_index == content_index and self._notice is None:
445
+ colors = theme.get_theme_colors(self)
446
+ row_style = RichStyle(bgcolor=colors.panel)
447
+ wrapped = content.wrap(self._cached_width or width)
448
+ base = self._wrap_prefix[content_index]
449
+ line = wrapped[abs_y - base] if abs_y - base < len(wrapped) else Content()
450
+ segments = [
451
+ segment
452
+ if segment.style is not None
453
+ else Segment(segment.text, _EMPTY_STYLE)
454
+ for segment in line.render_segments(end="")
455
+ ]
456
+ strip = Strip(segments, line.cell_length).crop_extend(0, width, self.rich_style)
457
+ if row_style is not None:
458
+ strip = Strip(
459
+ Segment.apply_style(strip, None, row_style),
460
+ strip.cell_length,
461
+ )
462
+ self._render_line_cache[key] = strip
463
+ return strip
464
+
465
+ def notify_style_update(self) -> None:
466
+ """Clear cached render lines after a style update."""
467
+ super().notify_style_update()
468
+ self._render_line_cache.clear()
469
+
470
+ def on_resize(self, event: events.Resize) -> None:
471
+ """Re-wrap log entries when the view width changes."""
472
+ if event.size.width != self._cached_width:
473
+ self._reflow()
474
+
475
+ def on_mouse_move(self, event: events.MouseMove) -> None:
476
+ """Highlight the logical log record under the pointer."""
477
+ _scroll_x, scroll_y = self.scroll_offset
478
+ hover_index = self._content_index_at_visual_y(scroll_y + event.y)
479
+ if self._notice is not None:
480
+ hover_index = None
481
+ self.styles.pointer = "pointer" if hover_index is not None else "default"
482
+ if hover_index == self._hover_index:
483
+ return
484
+ self._hover_index = hover_index
485
+ self._render_line_cache.clear()
486
+ self.refresh()
487
+
488
+ def on_leave(self) -> None:
489
+ """Clear hover highlighting when the pointer leaves the log."""
490
+ self.styles.pointer = "default"
491
+ if self._hover_index is None:
492
+ return
493
+ self._hover_index = None
494
+ self._render_line_cache.clear()
495
+ self.refresh()
496
+
497
+ def on_focus(self) -> None:
498
+ """Select the latest log record when keyboard focus enters the log."""
499
+ if self._selected_index is None and self._records:
500
+ self._select_record(len(self._records) - 1)
501
+
502
+ def key_up(self, event: events.Key) -> None:
503
+ """Move keyboard selection to the previous logical log record."""
504
+ event.prevent_default()
505
+ event.stop()
506
+ if not self._records:
507
+ return
508
+ index = (
509
+ len(self._records) if self._selected_index is None else self._selected_index
510
+ )
511
+ self._select_record(index - 1)
512
+
513
+ def key_down(self, event: events.Key) -> None:
514
+ """Move keyboard selection to the next logical log record."""
515
+ event.prevent_default()
516
+ event.stop()
517
+ if not self._records:
518
+ return
519
+ index = -1 if self._selected_index is None else self._selected_index
520
+ self._select_record(index + 1)
521
+
522
+ def key_enter(self, event: events.Key) -> None:
523
+ """Copy the selected logical log record."""
524
+ event.prevent_default()
525
+ event.stop()
526
+ self._copy_selected_record()
527
+
528
+ def key_tab(self, event: events.Key) -> None:
529
+ """Move focus from the log to the level filter."""
530
+ event.prevent_default()
531
+ event.stop()
532
+ self.screen.focus_next("#debug-level-filter, #debug-log")
533
+
534
+ def key_shift_tab(self, event: events.Key) -> None:
535
+ """Move focus from the log to the level filter."""
536
+ event.prevent_default()
537
+ event.stop()
538
+ self.screen.focus_previous("#debug-level-filter, #debug-log")
539
+
540
+ def on_click(self, event: events.Click) -> None:
541
+ """Copy the clicked logical log record."""
542
+ _scroll_x, scroll_y = self.scroll_offset
543
+ record = self._record_at_visual_y(scroll_y + event.y)
544
+ if record is None:
545
+ return
546
+ index = self._content_index_at_visual_y(scroll_y + event.y)
547
+ if index is not None:
548
+ self._select_record(index)
549
+ event.stop()
550
+ self._on_copy_record(record)
551
+
552
+
553
+ class DebugConsoleScreen(ModalScreen[None]):
554
+ """Modal showing a session snapshot and a live tail of recent log records."""
555
+
556
+ BINDINGS: ClassVar[list[BindingType]] = [
557
+ Binding("escape", "close", "Close", show=False),
558
+ Binding(DEBUG_TOGGLE_KEY, "close", "Close", show=False, priority=True),
559
+ Binding("ctrl+l", "clear_view", "Clear view", show=False, priority=True),
560
+ # Not `priority`: a priority `c` would pre-empt type-to-search in the
561
+ # open level dropdown (e.g. typing "c" to reach CRITICAL). The log view
562
+ # has no `c` binding, so it still bubbles up to this copy action.
563
+ Binding("c", "copy", "Copy", show=False),
564
+ ]
565
+ """The toggle-key close (`ctrl+backslash`) and `ctrl+l` clear-view are
566
+ `priority`. Escape close and `c` copy are deliberately *not* `priority`:
567
+ Escape must reach the open level dropdown's overlay first so it closes only
568
+ the menu (a priority Escape would tear down the whole console instead), and
569
+ `c` must not pre-empt the dropdown's type-to-search."""
570
+
571
+ CSS = """
572
+ DebugConsoleScreen {
573
+ align: center middle;
574
+ }
575
+
576
+ DebugConsoleScreen > Vertical {
577
+ width: 100;
578
+ max-width: 95%;
579
+ height: 85%;
580
+ background: $surface;
581
+ border: solid $primary;
582
+ padding: 1 2;
583
+ }
584
+
585
+ DebugConsoleScreen .debug-console-title {
586
+ text-style: bold;
587
+ color: $primary;
588
+ text-align: center;
589
+ margin-bottom: 1;
590
+ }
591
+
592
+ DebugConsoleScreen .debug-console-snapshot {
593
+ margin-bottom: 1;
594
+ }
595
+
596
+ DebugConsoleScreen .debug-console-toolbar {
597
+ height: auto;
598
+ margin-bottom: 1;
599
+ }
600
+
601
+ DebugConsoleScreen .debug-console-filter-label {
602
+ width: auto;
603
+ content-align: center middle;
604
+ color: $text-muted;
605
+ margin-right: 1;
606
+ }
607
+
608
+ DebugConsoleScreen #debug-level-filter {
609
+ width: 18;
610
+ }
611
+
612
+ DebugConsoleScreen .debug-console-log {
613
+ height: 1fr;
614
+ min-height: 5;
615
+ scrollbar-gutter: stable;
616
+ background: $background;
617
+ border: solid $primary;
618
+ overflow-x: hidden;
619
+ overflow-y: scroll;
620
+ }
621
+
622
+ DebugConsoleScreen .debug-console-log:focus {
623
+ border: solid $primary-lighten-2;
624
+ }
625
+
626
+ DebugConsoleScreen .debug-console-help {
627
+ height: 1;
628
+ color: $text-muted;
629
+ text-style: italic;
630
+ margin-top: 1;
631
+ text-align: center;
632
+ }
633
+ """
634
+
635
+ def __init__(self, snapshot: Sequence[SnapshotField]) -> None:
636
+ """Initialize with a captured *snapshot* of session/runtime fields.
637
+
638
+ Args:
639
+ snapshot: Ordered `(label, value)` fields rendered in the header.
640
+ """
641
+ super().__init__()
642
+ self._snapshot = list(snapshot)
643
+ self._records: list[InMemoryLogRecord] = []
644
+ # Absolute index of the next unrendered log record (incremental writes).
645
+ self._rendered_upto = 0
646
+ # One-shot guard so the "buffer unavailable" notice is written only once.
647
+ self._missing_notice_shown = False
648
+ self._level_filter: FilterValue = "all"
649
+
650
+ def compose(self) -> ComposeResult:
651
+ """Lay out the title, snapshot, filter, log tail, and key-hint footer.
652
+
653
+ Yields:
654
+ The child widgets composing the console.
655
+ """
656
+ with Vertical():
657
+ yield Static("Debug Console", classes="debug-console-title")
658
+ yield Static(self._render_snapshot(), classes="debug-console-snapshot")
659
+ with Horizontal(classes="debug-console-toolbar"):
660
+ yield Static("Level", classes="debug-console-filter-label")
661
+ yield _LogLevelSelect(
662
+ _filter_options(),
663
+ value="all",
664
+ allow_blank=False,
665
+ id=_FILTER_SELECT_ID,
666
+ compact=True,
667
+ )
668
+ yield _DebugLogView(
669
+ self._copy_record,
670
+ widget_id="debug-log",
671
+ classes="debug-console-log",
672
+ )
673
+ yield Static(self._render_help(), classes="debug-console-help")
674
+
675
+ def on_mount(self) -> None:
676
+ """Start the refresh timer and render the current buffer contents."""
677
+ self.set_interval(_REFRESH_INTERVAL, self._poll_logs)
678
+ self._poll_logs()
679
+ self.call_after_refresh(self.query_one("#debug-log", _DebugLogView).focus)
680
+
681
+ def key_tab(self, event: events.Key) -> None:
682
+ """Cycle focus between the level filter and log lines."""
683
+ if self._level_select().expanded:
684
+ return
685
+ event.prevent_default()
686
+ event.stop()
687
+ self.focus_next("#debug-level-filter, #debug-log")
688
+
689
+ def key_shift_tab(self, event: events.Key) -> None:
690
+ """Cycle focus between the log lines and level filter."""
691
+ if self._level_select().expanded:
692
+ return
693
+ event.prevent_default()
694
+ event.stop()
695
+ self.focus_previous("#debug-level-filter, #debug-log")
696
+
697
+ def on_select_changed(self, event: Select.Changed) -> None:
698
+ """Refresh visible records when the log-level filter changes."""
699
+ if event.select.id != _FILTER_SELECT_ID:
700
+ return
701
+ value = str(event.value)
702
+ if value == self._level_filter:
703
+ return
704
+ if value not in _VALID_FILTER_VALUES:
705
+ # The Select only offers known options, so this is unreachable in
706
+ # practice; validate anyway so an unexpected value degrades to the
707
+ # current filter instead of being trusted as a FilterValue.
708
+ logger.warning("Ignoring unknown debug level filter %r", value)
709
+ return
710
+ self._level_filter = cast("FilterValue", value)
711
+ self._refresh_log_view(scroll_end=True)
712
+
713
+ def _render_snapshot(self) -> Content:
714
+ """Build the right-aligned `label: value` snapshot block.
715
+
716
+ Returns:
717
+ The formatted snapshot block.
718
+ """
719
+ if not self._snapshot:
720
+ return Content.styled("(no session data)", "dim italic")
721
+ width = max(len(label) for label, _ in self._snapshot)
722
+ lines = [
723
+ Content.assemble((f"{label:>{width}} ", "bold"), value)
724
+ for label, value in self._snapshot
725
+ ]
726
+ return Content("\n").join(lines)
727
+
728
+ @staticmethod
729
+ def _render_help() -> Content:
730
+ """Build the footer key-hint line.
731
+
732
+ Returns:
733
+ The formatted key-hint line.
734
+ """
735
+ return Content.styled(
736
+ "Esc close · Ctrl+L clear view · c copy visible logs · click copy line",
737
+ "dim italic",
738
+ )
739
+
740
+ def _poll_logs(self) -> None:
741
+ """Append log records emitted since the last tick, guarding the timer.
742
+
743
+ Runs on a repeating `set_interval` timer, so an unhandled exception here
744
+ would propagate out of the callback and tear down the whole host app.
745
+ A diagnostic overlay must degrade instead: a tick that races teardown
746
+ (`NoMatches`) is logged at DEBUG and skipped, and any other failure
747
+ degrades the tail to a notice rather than crashing the app it exists to
748
+ inspect.
749
+ """
750
+ from textual.css.query import NoMatches
751
+
752
+ try:
753
+ self._poll_logs_once()
754
+ except NoMatches:
755
+ # Expected when a queued tick races console teardown: the log widget
756
+ # is already gone. Logged at DEBUG (not swallowed outright) so a
757
+ # genuine missing/mis-typed-widget bug still leaves a breadcrumb in
758
+ # the buffer instead of silently rendering nothing forever.
759
+ logger.debug("Debug console poll skipped (widget unavailable)")
760
+ return
761
+ except Exception: # a diagnostic must never crash the app it inspects
762
+ logger.warning("Debug console log poll failed", exc_info=True)
763
+ try:
764
+ self.query_one("#debug-log", _DebugLogView).show_notice(
765
+ "(log tail unavailable)"
766
+ )
767
+ except Exception: # best-effort notice; never re-raise from here
768
+ logger.debug("Debug console poll-error notice failed", exc_info=True)
769
+
770
+ def _poll_logs_once(self) -> None:
771
+ """Append any log records emitted since the last tick to the log view."""
772
+ log = self.query_one("#debug-log", _DebugLogView)
773
+ buffer = get_log_buffer()
774
+ if buffer is None:
775
+ if not self._missing_notice_shown:
776
+ log.show_notice("(log buffer unavailable)")
777
+ self._missing_notice_shown = True
778
+ return
779
+ records, total = buffer.snapshot_records_since(self._rendered_upto)
780
+ self._records.extend(records)
781
+ pruned = self._prune_records()
782
+ self._rendered_upto = total
783
+ if pruned:
784
+ self._refresh_log_view(scroll_end=log.is_vertical_scroll_end)
785
+ return
786
+ log.append_records(self._visible_records(records))
787
+
788
+ def _prune_records(self) -> bool:
789
+ """Trim retained records to the in-memory ring buffer capacity.
790
+
791
+ Returns:
792
+ `True` when records were pruned.
793
+ """
794
+ overflow = len(self._records) - _RECORD_LIMIT
795
+ if overflow <= 0:
796
+ return False
797
+ del self._records[:overflow]
798
+ return True
799
+
800
+ def _visible_records(
801
+ self, records: Sequence[InMemoryLogRecord]
802
+ ) -> list[InMemoryLogRecord]:
803
+ """Return the subset of *records* matching the current level filter."""
804
+ return [
805
+ record
806
+ for record in records
807
+ if _record_matches_filter(record, self._level_filter)
808
+ ]
809
+
810
+ def _refresh_log_view(self, *, scroll_end: bool) -> None:
811
+ """Rebuild the log view using the current filter."""
812
+ self.query_one("#debug-log", _DebugLogView).set_records(
813
+ self._visible_records(self._records), scroll_end=scroll_end
814
+ )
815
+
816
+ def action_clear_view(self) -> None:
817
+ """Clear the on-screen log view; the in-memory buffer keeps accruing."""
818
+ self.query_one("#debug-log", _DebugLogView).clear_records()
819
+ self._records.clear()
820
+ buffer = get_log_buffer()
821
+ if buffer is not None:
822
+ self._rendered_upto = buffer.total_emitted
823
+
824
+ def action_copy(self) -> None:
825
+ """Copy visible retained log records since the last clear to the clipboard."""
826
+ lines = [record.plain_line for record in self._visible_records(self._records)]
827
+ self._copy_lines(lines, empty_message="No visible log lines to copy")
828
+
829
+ def _copy_record(self, record: InMemoryLogRecord) -> None:
830
+ """Copy a clicked logical log record to the clipboard."""
831
+ self._copy_lines([record.plain_line], empty_message="No log line to copy")
832
+
833
+ def _level_select(self) -> Select[FilterValue]:
834
+ """Return the level-filter dropdown."""
835
+ return cast(
836
+ "Select[FilterValue]", self.query_one("#debug-level-filter", Select)
837
+ )
838
+
839
+ def _copy_lines(self, lines: Sequence[str], *, empty_message: str) -> None:
840
+ """Copy lines to clipboard with user-visible feedback."""
841
+ text = "\n".join(lines)
842
+ if not text:
843
+ self.app.notify(
844
+ empty_message, severity="information", timeout=2, markup=False
845
+ )
846
+ return
847
+ success, error = copy_text_to_clipboard(self.app, text)
848
+ if success:
849
+ self.app.notify(
850
+ "Debug log copied", severity="information", timeout=2, markup=False
851
+ )
852
+ return
853
+ suffix = f": {error}" if error else ""
854
+ self.app.notify(
855
+ f"Failed to copy debug log{suffix}",
856
+ severity="warning",
857
+ timeout=3,
858
+ markup=False,
859
+ )
860
+
861
+ def action_close(self) -> None:
862
+ """Close the open level dropdown, or close the debug console."""
863
+ level_select = self._level_select()
864
+ if level_select.expanded:
865
+ level_select.expanded = False
866
+ level_select.focus()
867
+ return
868
+ self.dismiss(None)