zjcode 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- deepagents_code/__init__.py +42 -0
- deepagents_code/__main__.py +6 -0
- deepagents_code/_ask_user_types.py +90 -0
- deepagents_code/_cli_context.py +96 -0
- deepagents_code/_constants.py +41 -0
- deepagents_code/_debug.py +148 -0
- deepagents_code/_debug_buffer.py +204 -0
- deepagents_code/_env_vars.py +411 -0
- deepagents_code/_fake_models.py +66 -0
- deepagents_code/_git.py +521 -0
- deepagents_code/_paths.py +69 -0
- deepagents_code/_server_config.py +576 -0
- deepagents_code/_session_stats.py +235 -0
- deepagents_code/_startup_error.py +45 -0
- deepagents_code/_testing_models.py +305 -0
- deepagents_code/_textual_patches.py +420 -0
- deepagents_code/_tool_stream.py +694 -0
- deepagents_code/_version.py +46 -0
- deepagents_code/agent.py +1976 -0
- deepagents_code/app.py +19239 -0
- deepagents_code/app.tcss +438 -0
- deepagents_code/approval_mode.py +131 -0
- deepagents_code/ask_user.py +306 -0
- deepagents_code/auth_display.py +185 -0
- deepagents_code/auth_store.py +545 -0
- deepagents_code/built_in_skills/__init__.py +5 -0
- deepagents_code/built_in_skills/remember/SKILL.md +118 -0
- deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
- deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
- deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
- deepagents_code/client/__init__.py +1 -0
- deepagents_code/client/commands/__init__.py +1 -0
- deepagents_code/client/commands/auth.py +541 -0
- deepagents_code/client/commands/config.py +714 -0
- deepagents_code/client/commands/mcp.py +250 -0
- deepagents_code/client/commands/tools.py +416 -0
- deepagents_code/client/launch/__init__.py +1 -0
- deepagents_code/client/launch/server.py +978 -0
- deepagents_code/client/launch/server_manager.py +540 -0
- deepagents_code/client/non_interactive.py +1758 -0
- deepagents_code/client/remote_client.py +794 -0
- deepagents_code/clipboard.py +217 -0
- deepagents_code/command_registry.py +450 -0
- deepagents_code/config.py +4829 -0
- deepagents_code/config_manifest.py +1451 -0
- deepagents_code/configurable_model.py +577 -0
- deepagents_code/default_agent_prompt.md +12 -0
- deepagents_code/doctor.py +559 -0
- deepagents_code/editor.py +142 -0
- deepagents_code/event_bus.py +411 -0
- deepagents_code/extras_info.py +661 -0
- deepagents_code/file_ops.py +576 -0
- deepagents_code/formatting.py +135 -0
- deepagents_code/goal_rubric.py +497 -0
- deepagents_code/goal_tools.py +459 -0
- deepagents_code/hooks.py +348 -0
- deepagents_code/input.py +1041 -0
- deepagents_code/integrations/__init__.py +1 -0
- deepagents_code/integrations/openai_codex.py +551 -0
- deepagents_code/integrations/sandbox_config.py +198 -0
- deepagents_code/integrations/sandbox_factory.py +1124 -0
- deepagents_code/integrations/sandbox_provider.py +137 -0
- deepagents_code/integrations/sandbox_registry.py +350 -0
- deepagents_code/iterm_cursor_guide.py +176 -0
- deepagents_code/local_context.py +926 -0
- deepagents_code/main.py +3985 -0
- deepagents_code/managed_tools.py +642 -0
- deepagents_code/mcp_auth.py +1950 -0
- deepagents_code/mcp_config.py +176 -0
- deepagents_code/mcp_disabled.py +212 -0
- deepagents_code/mcp_login_service.py +281 -0
- deepagents_code/mcp_oauth_ui.py +199 -0
- deepagents_code/mcp_providers/__init__.py +23 -0
- deepagents_code/mcp_providers/_registry.py +39 -0
- deepagents_code/mcp_providers/base.py +133 -0
- deepagents_code/mcp_providers/github.py +102 -0
- deepagents_code/mcp_providers/slack.py +175 -0
- deepagents_code/mcp_tools.py +2427 -0
- deepagents_code/mcp_trust.py +207 -0
- deepagents_code/media_utils.py +826 -0
- deepagents_code/memory_guard.py +474 -0
- deepagents_code/model_config.py +4156 -0
- deepagents_code/notifications.py +247 -0
- deepagents_code/offload.py +402 -0
- deepagents_code/onboarding.py +223 -0
- deepagents_code/output.py +69 -0
- deepagents_code/paste_collapse.py +103 -0
- deepagents_code/project_utils.py +231 -0
- deepagents_code/py.typed +0 -0
- deepagents_code/reasoning_effort.py +641 -0
- deepagents_code/reliable_rubric.py +97 -0
- deepagents_code/resume_state.py +237 -0
- deepagents_code/server_graph.py +310 -0
- deepagents_code/session_end_summary.py +329 -0
- deepagents_code/sessions.py +1716 -0
- deepagents_code/skills/__init__.py +18 -0
- deepagents_code/skills/commands.py +1252 -0
- deepagents_code/skills/invocation.py +112 -0
- deepagents_code/skills/load.py +196 -0
- deepagents_code/skills/trust.py +547 -0
- deepagents_code/state_migration.py +136 -0
- deepagents_code/subagents.py +278 -0
- deepagents_code/system_prompt.md +204 -0
- deepagents_code/terminal_capabilities.py +115 -0
- deepagents_code/terminal_escape.py +287 -0
- deepagents_code/theme.py +891 -0
- deepagents_code/todo_list_prompt.md +12 -0
- deepagents_code/tool_catalog.py +509 -0
- deepagents_code/tool_display.py +367 -0
- deepagents_code/tools.py +516 -0
- deepagents_code/tui/__init__.py +1 -0
- deepagents_code/tui/textual_adapter.py +2553 -0
- deepagents_code/tui/widgets/__init__.py +9 -0
- deepagents_code/tui/widgets/_js_eval_display.py +139 -0
- deepagents_code/tui/widgets/_links.py +261 -0
- deepagents_code/tui/widgets/agent_selector.py +420 -0
- deepagents_code/tui/widgets/approval.py +602 -0
- deepagents_code/tui/widgets/ask_user.py +515 -0
- deepagents_code/tui/widgets/auth.py +1997 -0
- deepagents_code/tui/widgets/autocomplete.py +890 -0
- deepagents_code/tui/widgets/chat_input.py +3181 -0
- deepagents_code/tui/widgets/codex_auth.py +452 -0
- deepagents_code/tui/widgets/cwd_switch.py +242 -0
- deepagents_code/tui/widgets/debug_console.py +868 -0
- deepagents_code/tui/widgets/diff.py +252 -0
- deepagents_code/tui/widgets/effort_selector.py +189 -0
- deepagents_code/tui/widgets/goal_review.py +390 -0
- deepagents_code/tui/widgets/goal_status.py +50 -0
- deepagents_code/tui/widgets/history.py +194 -0
- deepagents_code/tui/widgets/install_confirm.py +248 -0
- deepagents_code/tui/widgets/launch_init.py +482 -0
- deepagents_code/tui/widgets/loading.py +227 -0
- deepagents_code/tui/widgets/mcp_login.py +539 -0
- deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
- deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
- deepagents_code/tui/widgets/message_store.py +999 -0
- deepagents_code/tui/widgets/messages.py +3846 -0
- deepagents_code/tui/widgets/model_selector.py +2343 -0
- deepagents_code/tui/widgets/notification_center.py +456 -0
- deepagents_code/tui/widgets/notification_detail.py +257 -0
- deepagents_code/tui/widgets/notification_settings.py +170 -0
- deepagents_code/tui/widgets/restart_prompt.py +158 -0
- deepagents_code/tui/widgets/skill_trust.py +131 -0
- deepagents_code/tui/widgets/startup_tip.py +91 -0
- deepagents_code/tui/widgets/status.py +781 -0
- deepagents_code/tui/widgets/subagent_panel.py +969 -0
- deepagents_code/tui/widgets/theme_selector.py +401 -0
- deepagents_code/tui/widgets/thread_selector.py +2564 -0
- deepagents_code/tui/widgets/tool_renderers.py +190 -0
- deepagents_code/tui/widgets/tool_widgets.py +304 -0
- deepagents_code/tui/widgets/update_available.py +311 -0
- deepagents_code/tui/widgets/update_confirm.py +184 -0
- deepagents_code/tui/widgets/update_progress.py +327 -0
- deepagents_code/tui/widgets/welcome.py +508 -0
- deepagents_code/turn_end_summary.py +522 -0
- deepagents_code/ui.py +858 -0
- deepagents_code/unicode_security.py +563 -0
- deepagents_code/update_check.py +3124 -0
- zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
- zjcode-0.0.1.dist-info/METADATA +220 -0
- zjcode-0.0.1.dist-info/RECORD +163 -0
- zjcode-0.0.1.dist-info/WHEEL +4 -0
- zjcode-0.0.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,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)
|