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,999 @@
|
|
|
1
|
+
"""Message store for virtualized chat history.
|
|
2
|
+
|
|
3
|
+
This module provides data structures and management for message virtualization,
|
|
4
|
+
allowing the TUI to handle large message histories efficiently by keeping only
|
|
5
|
+
a sliding window of widgets in the DOM while storing all message data as
|
|
6
|
+
lightweight dataclasses.
|
|
7
|
+
|
|
8
|
+
The approach is inspired by Textual's `Log` widget, which only keeps `N` lines
|
|
9
|
+
in the DOM and recreates older ones on demand.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import uuid
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from enum import StrEnum
|
|
18
|
+
from time import time
|
|
19
|
+
from typing import TYPE_CHECKING, Any
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from textual.widget import Widget
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
DEFAULT_HEIGHT_HINT = 5
|
|
27
|
+
"""Estimated terminal rows for a message whose rendered height is unknown."""
|
|
28
|
+
|
|
29
|
+
MIN_HEIGHT_HINT = 1
|
|
30
|
+
"""Smallest useful row estimate for spacer and range-height math."""
|
|
31
|
+
|
|
32
|
+
_ACTIVE_REASON = "active"
|
|
33
|
+
"""Protection reason for the currently-streaming message."""
|
|
34
|
+
|
|
35
|
+
_LIVE_REASON = "live"
|
|
36
|
+
"""Protection reason for a pending/running tool row (default for protect_message)."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_UPDATABLE_FIELDS: frozenset[str] = frozenset(
|
|
40
|
+
{
|
|
41
|
+
"content",
|
|
42
|
+
"tool_status",
|
|
43
|
+
"tool_output",
|
|
44
|
+
"tool_duration",
|
|
45
|
+
"tool_expanded",
|
|
46
|
+
"tool_reject_reason",
|
|
47
|
+
"skill_expanded",
|
|
48
|
+
"rubric_expanded",
|
|
49
|
+
"is_streaming",
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
"""Fields on `MessageData` that callers are allowed to update via `update_message`.
|
|
53
|
+
|
|
54
|
+
Prevents accidental overwriting of identity fields like `id`, `type`, or
|
|
55
|
+
`timestamp`.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class MessageType(StrEnum):
|
|
60
|
+
"""Types of messages in the chat."""
|
|
61
|
+
|
|
62
|
+
USER = "user"
|
|
63
|
+
"""Input authored by the human, rendered above the agent's response."""
|
|
64
|
+
|
|
65
|
+
ASSISTANT = "assistant"
|
|
66
|
+
"""Streamed agent response rendered with markdown."""
|
|
67
|
+
|
|
68
|
+
TOOL = "tool"
|
|
69
|
+
"""Record of a tool invocation, including its args, status, and output."""
|
|
70
|
+
|
|
71
|
+
SKILL = "skill"
|
|
72
|
+
"""Record of a skill invocation, carrying its SKILL.md body and metadata."""
|
|
73
|
+
|
|
74
|
+
ERROR = "error"
|
|
75
|
+
"""Error surfaced to the user (e.g., a failed tool call or SDK exception)."""
|
|
76
|
+
|
|
77
|
+
APP = "app"
|
|
78
|
+
"""App-status note from the app itself (version info, command feedback)."""
|
|
79
|
+
|
|
80
|
+
RUBRIC = "rubric"
|
|
81
|
+
"""Rubric grader result with a compact summary and expandable details."""
|
|
82
|
+
|
|
83
|
+
SUMMARIZATION = "summarization"
|
|
84
|
+
"""Notification that the prior conversation was summarized/offloaded."""
|
|
85
|
+
|
|
86
|
+
DIFF = "diff"
|
|
87
|
+
"""Unified diff preview attached to a file-modifying tool call."""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ToolStatus(StrEnum):
|
|
91
|
+
"""Status of a tool call."""
|
|
92
|
+
|
|
93
|
+
PENDING = "pending"
|
|
94
|
+
"""Queued for execution, typically awaiting human approval."""
|
|
95
|
+
|
|
96
|
+
RUNNING = "running"
|
|
97
|
+
"""Currently executing."""
|
|
98
|
+
|
|
99
|
+
SUCCESS = "success"
|
|
100
|
+
"""Completed without error."""
|
|
101
|
+
|
|
102
|
+
ERROR = "error"
|
|
103
|
+
"""Raised an exception or returned a non-zero exit status."""
|
|
104
|
+
|
|
105
|
+
REJECTED = "rejected"
|
|
106
|
+
"""Human explicitly denied the call at the approval prompt."""
|
|
107
|
+
|
|
108
|
+
SKIPPED = "skipped"
|
|
109
|
+
"""Bypassed without executing (e.g., the agent canceled the call)."""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class MessageData:
|
|
114
|
+
"""In-memory message data for virtualization.
|
|
115
|
+
|
|
116
|
+
This dataclass holds all information needed to recreate a message widget.
|
|
117
|
+
It is designed to be lightweight so that thousands of messages can be
|
|
118
|
+
stored without meaningful memory overhead.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
type: MessageType
|
|
122
|
+
"""The kind of message (user, assistant, tool, etc.)."""
|
|
123
|
+
|
|
124
|
+
content: str
|
|
125
|
+
"""Primary text content of the message.
|
|
126
|
+
|
|
127
|
+
For most message types this is the display text. For TOOL messages it is
|
|
128
|
+
typically empty because the tool's identity comes from `tool_name` /
|
|
129
|
+
`tool_args` instead.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
id: str = field(default_factory=lambda: f"msg-{uuid.uuid4().hex}")
|
|
133
|
+
"""Unique identifier used to match the dataclass to its DOM widget.
|
|
134
|
+
|
|
135
|
+
Uses the full 128-bit `uuid4` hex (not a truncated prefix) so IDs stay
|
|
136
|
+
unique across large histories and long sessions; a widget-ID collision
|
|
137
|
+
raises `DuplicateIds` when the widget is mounted.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
timestamp: float = field(default_factory=time)
|
|
141
|
+
"""Unix epoch timestamp of when the message was created."""
|
|
142
|
+
|
|
143
|
+
# TOOL message fields - only populated for TOOL messages
|
|
144
|
+
tool_name: str | None = None
|
|
145
|
+
"""Name of the tool that was called."""
|
|
146
|
+
|
|
147
|
+
tool_args: dict[str, Any] | None = None
|
|
148
|
+
"""Arguments passed to the tool call."""
|
|
149
|
+
|
|
150
|
+
tool_status: ToolStatus | None = None
|
|
151
|
+
"""Current execution status of the tool call."""
|
|
152
|
+
|
|
153
|
+
tool_output: str | None = None
|
|
154
|
+
"""Output returned by the tool after execution."""
|
|
155
|
+
|
|
156
|
+
tool_duration: float | None = None
|
|
157
|
+
"""Elapsed run time in seconds for a completed timed tool call."""
|
|
158
|
+
|
|
159
|
+
tool_expanded: bool = False
|
|
160
|
+
"""Whether the tool output section is expanded in the UI."""
|
|
161
|
+
|
|
162
|
+
tool_reject_reason: str | None = None
|
|
163
|
+
"""User-supplied reason attached to a HITL reject decision (if any)."""
|
|
164
|
+
|
|
165
|
+
# ---
|
|
166
|
+
|
|
167
|
+
diff_file_path: str | None = None
|
|
168
|
+
"""File path associated with the diff (DIFF messages only)."""
|
|
169
|
+
|
|
170
|
+
diff_tool_name: str | None = None
|
|
171
|
+
"""Name of the file tool that produced the diff (DIFF messages only)."""
|
|
172
|
+
|
|
173
|
+
# SKILL message fields - only populated for SKILL messages
|
|
174
|
+
skill_name: str | None = None
|
|
175
|
+
"""Name of the skill that was invoked."""
|
|
176
|
+
|
|
177
|
+
skill_description: str | None = None
|
|
178
|
+
"""Short description of the skill."""
|
|
179
|
+
|
|
180
|
+
skill_source: str | None = None
|
|
181
|
+
"""Origin of the skill (e.g., `'built-in'`, `'user'`, `'project'`)."""
|
|
182
|
+
|
|
183
|
+
skill_args: str | None = None
|
|
184
|
+
"""User-provided arguments to the skill invocation."""
|
|
185
|
+
|
|
186
|
+
skill_body: str | None = None
|
|
187
|
+
"""Full SKILL.md content sent to the agent."""
|
|
188
|
+
|
|
189
|
+
skill_expanded: bool = False
|
|
190
|
+
"""Whether the skill body is expanded in the UI."""
|
|
191
|
+
|
|
192
|
+
rubric_details: str | None = None
|
|
193
|
+
"""Complete grader details for RUBRIC messages."""
|
|
194
|
+
|
|
195
|
+
rubric_expanded: bool = False
|
|
196
|
+
"""Whether the grader details are expanded in the UI."""
|
|
197
|
+
|
|
198
|
+
is_streaming: bool = False
|
|
199
|
+
"""Whether the message is still being streamed.
|
|
200
|
+
|
|
201
|
+
While `True`, the corresponding widget is actively receiving content
|
|
202
|
+
chunks and should not be pruned or re-hydrated.
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
is_markdown: bool = False
|
|
206
|
+
"""For APP messages, whether `content` is a markdown source string.
|
|
207
|
+
|
|
208
|
+
When `True`, rehydration renders the content via Rich markdown instead of
|
|
209
|
+
the plain dim-italic `AppMessage` styling.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
height_hint: int | None = None
|
|
213
|
+
"""Cached rendered widget height in terminal rows, or None if unmeasured.
|
|
214
|
+
|
|
215
|
+
Measured after layout by `_measure_message_height` in `app.py` and stored
|
|
216
|
+
via `set_height_hint`. Consumed by `estimate_height`/`range_height` to size
|
|
217
|
+
the transcript spacers and to keep the scroll anchor stable across
|
|
218
|
+
hydrate-above/below. When None (not yet measured), `estimate_height` falls
|
|
219
|
+
back to `DEFAULT_HEIGHT_HINT`. Always `>= MIN_HEIGHT_HINT` once set.
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
def __post_init__(self) -> None:
|
|
223
|
+
"""Validate type-field coherence after construction.
|
|
224
|
+
|
|
225
|
+
Raises:
|
|
226
|
+
ValueError: If a TOOL message is missing `tool_name`, a SKILL
|
|
227
|
+
message is missing `skill_name`, or a RUBRIC message is missing
|
|
228
|
+
`rubric_details`.
|
|
229
|
+
"""
|
|
230
|
+
if self.type == MessageType.TOOL and not self.tool_name:
|
|
231
|
+
msg = "TOOL messages must have a tool_name"
|
|
232
|
+
raise ValueError(msg)
|
|
233
|
+
if self.type == MessageType.SKILL and not self.skill_name:
|
|
234
|
+
msg = "SKILL messages must have a skill_name"
|
|
235
|
+
raise ValueError(msg)
|
|
236
|
+
# A summary-only grader result stays an AppMessage; a RUBRIC message
|
|
237
|
+
# exists precisely to carry expandable details, so require them.
|
|
238
|
+
if self.type == MessageType.RUBRIC and not self.rubric_details:
|
|
239
|
+
msg = "RUBRIC messages must have rubric_details"
|
|
240
|
+
raise ValueError(msg)
|
|
241
|
+
|
|
242
|
+
def to_widget(self) -> Widget:
|
|
243
|
+
"""Recreate a widget from this message data.
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
The appropriate message widget for this data.
|
|
247
|
+
"""
|
|
248
|
+
# Import here to avoid circular imports
|
|
249
|
+
from deepagents_code.tui.widgets.messages import (
|
|
250
|
+
AppMessage,
|
|
251
|
+
AssistantMessage,
|
|
252
|
+
DiffMessage,
|
|
253
|
+
ErrorMessage,
|
|
254
|
+
RubricResultMessage,
|
|
255
|
+
SkillMessage,
|
|
256
|
+
SummarizationMessage,
|
|
257
|
+
ToolCallMessage,
|
|
258
|
+
UserMessage,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
match self.type:
|
|
262
|
+
case MessageType.USER:
|
|
263
|
+
return UserMessage(self.content, id=self.id)
|
|
264
|
+
|
|
265
|
+
case MessageType.ASSISTANT:
|
|
266
|
+
return AssistantMessage(self.content, id=self.id)
|
|
267
|
+
|
|
268
|
+
case MessageType.TOOL:
|
|
269
|
+
widget = ToolCallMessage(
|
|
270
|
+
self.tool_name or "unknown",
|
|
271
|
+
self.tool_args,
|
|
272
|
+
id=self.id,
|
|
273
|
+
)
|
|
274
|
+
# Deferred state is restored automatically during on_mount
|
|
275
|
+
# via _restore_deferred_state
|
|
276
|
+
widget._deferred_status = self.tool_status
|
|
277
|
+
widget._deferred_output = self.tool_output
|
|
278
|
+
widget._deferred_duration = self.tool_duration
|
|
279
|
+
widget._deferred_expanded = self.tool_expanded
|
|
280
|
+
widget._deferred_reject_reason = self.tool_reject_reason
|
|
281
|
+
return widget
|
|
282
|
+
|
|
283
|
+
case MessageType.SKILL:
|
|
284
|
+
widget = SkillMessage(
|
|
285
|
+
skill_name=self.skill_name or "unknown",
|
|
286
|
+
description=self.skill_description or "",
|
|
287
|
+
source=self.skill_source or "",
|
|
288
|
+
body=self.skill_body or "",
|
|
289
|
+
args=self.skill_args or "",
|
|
290
|
+
id=self.id,
|
|
291
|
+
)
|
|
292
|
+
widget._deferred_expanded = self.skill_expanded
|
|
293
|
+
return widget
|
|
294
|
+
|
|
295
|
+
case MessageType.ERROR:
|
|
296
|
+
return ErrorMessage(self.content, id=self.id)
|
|
297
|
+
|
|
298
|
+
case MessageType.APP:
|
|
299
|
+
return AppMessage(self.content, markdown=self.is_markdown, id=self.id)
|
|
300
|
+
|
|
301
|
+
case MessageType.RUBRIC:
|
|
302
|
+
widget = RubricResultMessage(
|
|
303
|
+
self.content,
|
|
304
|
+
self.rubric_details or "",
|
|
305
|
+
id=self.id,
|
|
306
|
+
)
|
|
307
|
+
widget._deferred_expanded = self.rubric_expanded
|
|
308
|
+
return widget
|
|
309
|
+
|
|
310
|
+
case MessageType.SUMMARIZATION:
|
|
311
|
+
return SummarizationMessage(self.content, id=self.id)
|
|
312
|
+
|
|
313
|
+
case MessageType.DIFF:
|
|
314
|
+
return DiffMessage(
|
|
315
|
+
self.content,
|
|
316
|
+
file_path=self.diff_file_path or "",
|
|
317
|
+
tool_name=self.diff_tool_name,
|
|
318
|
+
id=self.id,
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
case _:
|
|
322
|
+
logger.warning(
|
|
323
|
+
"Unknown MessageType %r for message %s, falling back to AppMessage",
|
|
324
|
+
self.type,
|
|
325
|
+
self.id,
|
|
326
|
+
)
|
|
327
|
+
return AppMessage(self.content, id=self.id)
|
|
328
|
+
|
|
329
|
+
@classmethod
|
|
330
|
+
def from_widget(cls, widget: Widget) -> MessageData:
|
|
331
|
+
"""Create MessageData from an existing widget.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
widget: The message widget to serialize.
|
|
335
|
+
|
|
336
|
+
Returns:
|
|
337
|
+
MessageData containing all the widget's state.
|
|
338
|
+
"""
|
|
339
|
+
# Deferred: prevents import-order issue — both modules live in the
|
|
340
|
+
# widgets package, and messages is re-exported from widgets/__init__.
|
|
341
|
+
from deepagents_code.tui.widgets.messages import (
|
|
342
|
+
AppMessage,
|
|
343
|
+
AssistantMessage,
|
|
344
|
+
DiffMessage,
|
|
345
|
+
ErrorMessage,
|
|
346
|
+
RubricResultMessage,
|
|
347
|
+
SkillMessage,
|
|
348
|
+
SummarizationMessage,
|
|
349
|
+
ToolCallMessage,
|
|
350
|
+
UserMessage,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
widget_id = widget.id or f"msg-{uuid.uuid4().hex}"
|
|
354
|
+
|
|
355
|
+
if isinstance(widget, SkillMessage):
|
|
356
|
+
return cls(
|
|
357
|
+
type=MessageType.SKILL,
|
|
358
|
+
content="",
|
|
359
|
+
id=widget_id,
|
|
360
|
+
skill_name=widget._skill_name,
|
|
361
|
+
skill_description=widget._description,
|
|
362
|
+
skill_source=widget._source,
|
|
363
|
+
skill_body=widget._body,
|
|
364
|
+
skill_args=widget._args,
|
|
365
|
+
skill_expanded=widget._expanded,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
if isinstance(widget, UserMessage):
|
|
369
|
+
return cls(
|
|
370
|
+
type=MessageType.USER,
|
|
371
|
+
content=widget._content,
|
|
372
|
+
id=widget_id,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
if isinstance(widget, AssistantMessage):
|
|
376
|
+
return cls(
|
|
377
|
+
type=MessageType.ASSISTANT,
|
|
378
|
+
content=widget._content,
|
|
379
|
+
id=widget_id,
|
|
380
|
+
is_streaming=widget._stream is not None,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
if isinstance(widget, ToolCallMessage):
|
|
384
|
+
tool_status: ToolStatus | None = None
|
|
385
|
+
if widget._status:
|
|
386
|
+
try:
|
|
387
|
+
tool_status = ToolStatus(widget._status)
|
|
388
|
+
except ValueError:
|
|
389
|
+
logger.warning(
|
|
390
|
+
"Unknown tool status %r for widget %s",
|
|
391
|
+
widget._status,
|
|
392
|
+
widget_id,
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
return cls(
|
|
396
|
+
type=MessageType.TOOL,
|
|
397
|
+
content="", # Tool messages don't have simple content
|
|
398
|
+
id=widget_id,
|
|
399
|
+
tool_name=widget._tool_name,
|
|
400
|
+
tool_args=widget._args,
|
|
401
|
+
tool_status=tool_status,
|
|
402
|
+
tool_output=widget._output,
|
|
403
|
+
tool_duration=widget._duration,
|
|
404
|
+
tool_expanded=widget._expanded,
|
|
405
|
+
tool_reject_reason=widget._reject_reason,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
if isinstance(widget, ErrorMessage):
|
|
409
|
+
return cls(
|
|
410
|
+
type=MessageType.ERROR,
|
|
411
|
+
# `_content` may be `Content` (link spans drop on resume).
|
|
412
|
+
content=str(widget._content),
|
|
413
|
+
id=widget_id,
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
# Check specialized subclasses before AppMessage so we keep their type
|
|
417
|
+
# when serializing and can restore their specific styling later.
|
|
418
|
+
if isinstance(widget, DiffMessage):
|
|
419
|
+
return cls(
|
|
420
|
+
type=MessageType.DIFF,
|
|
421
|
+
content=widget._diff_content,
|
|
422
|
+
id=widget_id,
|
|
423
|
+
diff_file_path=widget._file_path,
|
|
424
|
+
diff_tool_name=widget._tool_name,
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
if isinstance(widget, SummarizationMessage):
|
|
428
|
+
return cls(
|
|
429
|
+
type=MessageType.SUMMARIZATION,
|
|
430
|
+
content=str(widget._content),
|
|
431
|
+
id=widget_id,
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
if isinstance(widget, RubricResultMessage):
|
|
435
|
+
return cls(
|
|
436
|
+
type=MessageType.RUBRIC,
|
|
437
|
+
content=widget._summary,
|
|
438
|
+
id=widget_id,
|
|
439
|
+
rubric_details=widget._details,
|
|
440
|
+
rubric_expanded=widget._expanded,
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
if isinstance(widget, AppMessage):
|
|
444
|
+
return cls(
|
|
445
|
+
type=MessageType.APP,
|
|
446
|
+
content=str(widget._content),
|
|
447
|
+
id=widget_id,
|
|
448
|
+
is_markdown=widget._is_markdown,
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
logger.warning(
|
|
452
|
+
"Unknown widget type %s (id=%s), storing as APP message",
|
|
453
|
+
type(widget).__name__,
|
|
454
|
+
widget_id,
|
|
455
|
+
)
|
|
456
|
+
return cls(
|
|
457
|
+
type=MessageType.APP,
|
|
458
|
+
content=f"[Unknown widget: {type(widget).__name__}]",
|
|
459
|
+
id=widget_id,
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
class MessageStore:
|
|
464
|
+
"""Manages message data and widget window for virtualization.
|
|
465
|
+
|
|
466
|
+
This class stores all messages as data and manages a sliding window
|
|
467
|
+
of widgets that are actually mounted in the DOM.
|
|
468
|
+
|
|
469
|
+
Attributes:
|
|
470
|
+
WINDOW_SIZE: Maximum number of messages to keep mounted in the DOM.
|
|
471
|
+
|
|
472
|
+
Trades DOM cost against scroll smoothness. Note each message may
|
|
473
|
+
also mount a timestamp footer, so the live widget count is up to
|
|
474
|
+
~2x this value. Spacer rows above/below the window preserve full
|
|
475
|
+
scroll geometry, so this only bounds how much is rendered at once,
|
|
476
|
+
not what the user can scroll to.
|
|
477
|
+
HYDRATE_BUFFER: Number of messages to hydrate when scrolling near edge.
|
|
478
|
+
|
|
479
|
+
Provides enough buffer to avoid visible loading pauses.
|
|
480
|
+
"""
|
|
481
|
+
|
|
482
|
+
WINDOW_SIZE: int = 200
|
|
483
|
+
HYDRATE_BUFFER: int = 15
|
|
484
|
+
|
|
485
|
+
def __init__(self) -> None:
|
|
486
|
+
"""Initialize the message store."""
|
|
487
|
+
self._messages: list[MessageData] = []
|
|
488
|
+
self._index: dict[str, MessageData] = {}
|
|
489
|
+
"""ID -> MessageData lookup.
|
|
490
|
+
|
|
491
|
+
Must contain exactly one entry per element of `_messages`. Any method
|
|
492
|
+
that adds to or removes from `_messages` must update `_index`
|
|
493
|
+
in lockstep.
|
|
494
|
+
"""
|
|
495
|
+
self._visible_start: int = 0
|
|
496
|
+
self._visible_end: int = 0
|
|
497
|
+
|
|
498
|
+
self._protection_reasons: dict[str, set[str]] = {}
|
|
499
|
+
"""Message ID -> set of reasons it must stay mounted while live.
|
|
500
|
+
|
|
501
|
+
A message is protected from virtualization iff it has at least one
|
|
502
|
+
reason. Reasons are independent (`_ACTIVE_REASON` for the streaming
|
|
503
|
+
message, `_LIVE_REASON` for a pending/running tool), so releasing one
|
|
504
|
+
source never revokes another's protection.
|
|
505
|
+
"""
|
|
506
|
+
|
|
507
|
+
self._active_message_id: str | None = None
|
|
508
|
+
"""The single currently-streaming message, mirrored into
|
|
509
|
+
`_protection_reasons` under `_ACTIVE_REASON`. Retained so the
|
|
510
|
+
`is_active`/`set_active_message` API keeps working."""
|
|
511
|
+
|
|
512
|
+
@property
|
|
513
|
+
def total_count(self) -> int:
|
|
514
|
+
"""Total number of messages stored."""
|
|
515
|
+
return len(self._messages)
|
|
516
|
+
|
|
517
|
+
@property
|
|
518
|
+
def visible_count(self) -> int:
|
|
519
|
+
"""Number of messages currently visible (as widgets)."""
|
|
520
|
+
return self._visible_end - self._visible_start
|
|
521
|
+
|
|
522
|
+
@property
|
|
523
|
+
def has_messages_above(self) -> bool:
|
|
524
|
+
"""Check if there are archived messages above the visible window."""
|
|
525
|
+
return self._visible_start > 0
|
|
526
|
+
|
|
527
|
+
@property
|
|
528
|
+
def has_messages_below(self) -> bool:
|
|
529
|
+
"""Check if there are archived messages below the visible window."""
|
|
530
|
+
return self._visible_end < len(self._messages)
|
|
531
|
+
|
|
532
|
+
def append(self, message: MessageData) -> None:
|
|
533
|
+
"""Add a new message to the store.
|
|
534
|
+
|
|
535
|
+
Args:
|
|
536
|
+
message: The message data to add.
|
|
537
|
+
"""
|
|
538
|
+
was_at_tail = self._visible_end == len(self._messages)
|
|
539
|
+
if message.id in self._index:
|
|
540
|
+
logger.warning(
|
|
541
|
+
"Duplicate message ID %r appended; previous entry will be "
|
|
542
|
+
"unreachable via get_message()",
|
|
543
|
+
message.id,
|
|
544
|
+
)
|
|
545
|
+
self._messages.append(message)
|
|
546
|
+
self._index[message.id] = message
|
|
547
|
+
if was_at_tail:
|
|
548
|
+
self._visible_end = len(self._messages)
|
|
549
|
+
|
|
550
|
+
def bulk_load(
|
|
551
|
+
self, messages: list[MessageData]
|
|
552
|
+
) -> tuple[list[MessageData], list[MessageData]]:
|
|
553
|
+
"""Load many messages at once, keeping only the tail visible.
|
|
554
|
+
|
|
555
|
+
This is optimized for thread resumption: all messages are stored as
|
|
556
|
+
lightweight data, but only the last `WINDOW_SIZE` entries are marked
|
|
557
|
+
visible (i.e. will need DOM widgets).
|
|
558
|
+
|
|
559
|
+
Args:
|
|
560
|
+
messages: Ordered list of message data to load.
|
|
561
|
+
|
|
562
|
+
Returns:
|
|
563
|
+
Tuple of (archived, visible) message lists.
|
|
564
|
+
"""
|
|
565
|
+
self._messages.extend(messages)
|
|
566
|
+
for msg in messages:
|
|
567
|
+
if msg.id in self._index:
|
|
568
|
+
logger.warning(
|
|
569
|
+
"Duplicate message ID %r in bulk_load; previous entry "
|
|
570
|
+
"will be unreachable via get_message()",
|
|
571
|
+
msg.id,
|
|
572
|
+
)
|
|
573
|
+
self._index[msg.id] = msg
|
|
574
|
+
total = len(self._messages)
|
|
575
|
+
|
|
576
|
+
if total <= self.WINDOW_SIZE:
|
|
577
|
+
self._visible_start = 0
|
|
578
|
+
else:
|
|
579
|
+
self._visible_start = total - self.WINDOW_SIZE
|
|
580
|
+
|
|
581
|
+
self._visible_end = total
|
|
582
|
+
|
|
583
|
+
archived = self._messages[: self._visible_start]
|
|
584
|
+
visible = self._messages[self._visible_start : self._visible_end]
|
|
585
|
+
return archived, visible
|
|
586
|
+
|
|
587
|
+
def get_message(self, message_id: str) -> MessageData | None:
|
|
588
|
+
"""Get a message by its ID.
|
|
589
|
+
|
|
590
|
+
Args:
|
|
591
|
+
message_id: The ID of the message to find.
|
|
592
|
+
|
|
593
|
+
Returns:
|
|
594
|
+
The message data, or None if not found.
|
|
595
|
+
"""
|
|
596
|
+
return self._index.get(message_id)
|
|
597
|
+
|
|
598
|
+
def update_message(self, message_id: str, **updates: Any) -> bool:
|
|
599
|
+
"""Update a message's data.
|
|
600
|
+
|
|
601
|
+
Only fields in `_UPDATABLE_FIELDS` may be updated. Unknown field
|
|
602
|
+
names raise `ValueError` to catch typos early.
|
|
603
|
+
|
|
604
|
+
Args:
|
|
605
|
+
message_id: The ID of the message to update.
|
|
606
|
+
**updates: Fields to update.
|
|
607
|
+
|
|
608
|
+
Returns:
|
|
609
|
+
True if the message was found and updated.
|
|
610
|
+
|
|
611
|
+
Raises:
|
|
612
|
+
ValueError: If any key in `updates` is not in the updatable
|
|
613
|
+
allowlist.
|
|
614
|
+
"""
|
|
615
|
+
unknown = set(updates) - _UPDATABLE_FIELDS
|
|
616
|
+
if unknown:
|
|
617
|
+
msg = f"Cannot update unknown or protected fields: {unknown}"
|
|
618
|
+
raise ValueError(msg)
|
|
619
|
+
|
|
620
|
+
msg_data = self._index.get(message_id)
|
|
621
|
+
if msg_data is None:
|
|
622
|
+
logger.warning(
|
|
623
|
+
"update_message called for unknown ID %r; update discarded",
|
|
624
|
+
message_id,
|
|
625
|
+
)
|
|
626
|
+
return False
|
|
627
|
+
for key, value in updates.items():
|
|
628
|
+
setattr(msg_data, key, value)
|
|
629
|
+
return True
|
|
630
|
+
|
|
631
|
+
def set_active_message(self, message_id: str | None) -> None:
|
|
632
|
+
"""Set the currently active (streaming) message.
|
|
633
|
+
|
|
634
|
+
Active messages are never archived. Only the previous active message's
|
|
635
|
+
`_ACTIVE_REASON` is released, so a message also protected for another
|
|
636
|
+
reason (e.g. a live tool) stays protected.
|
|
637
|
+
|
|
638
|
+
Args:
|
|
639
|
+
message_id: The ID of the active message, or None to clear.
|
|
640
|
+
"""
|
|
641
|
+
if self._active_message_id is not None:
|
|
642
|
+
self.unprotect_message(self._active_message_id, reason=_ACTIVE_REASON)
|
|
643
|
+
self._active_message_id = message_id
|
|
644
|
+
if message_id is not None:
|
|
645
|
+
self.protect_message(message_id, reason=_ACTIVE_REASON)
|
|
646
|
+
|
|
647
|
+
def is_active(self, message_id: str) -> bool:
|
|
648
|
+
"""Check if a message is the active streaming message.
|
|
649
|
+
|
|
650
|
+
Args:
|
|
651
|
+
message_id: The message ID to check.
|
|
652
|
+
|
|
653
|
+
Returns:
|
|
654
|
+
True if this is the active message.
|
|
655
|
+
"""
|
|
656
|
+
return message_id == self._active_message_id
|
|
657
|
+
|
|
658
|
+
def protect_message(self, message_id: str, *, reason: str = _LIVE_REASON) -> None:
|
|
659
|
+
"""Keep a live message mounted during window updates.
|
|
660
|
+
|
|
661
|
+
Reasons accumulate independently; a message stays protected until every
|
|
662
|
+
reason is released. Idempotent per reason.
|
|
663
|
+
|
|
664
|
+
Args:
|
|
665
|
+
message_id: Message ID to protect.
|
|
666
|
+
reason: Why the message is protected. Defaults to a live tool row.
|
|
667
|
+
"""
|
|
668
|
+
self._protection_reasons.setdefault(message_id, set()).add(reason)
|
|
669
|
+
|
|
670
|
+
def unprotect_message(self, message_id: str, *, reason: str = _LIVE_REASON) -> None:
|
|
671
|
+
"""Release one protection reason from a message.
|
|
672
|
+
|
|
673
|
+
The message becomes virtualizable only once it has no remaining
|
|
674
|
+
reasons. Releasing a reason the message does not hold is a no-op.
|
|
675
|
+
|
|
676
|
+
Args:
|
|
677
|
+
message_id: Message ID to stop protecting.
|
|
678
|
+
reason: Which reason to release. Defaults to a live tool row.
|
|
679
|
+
"""
|
|
680
|
+
reasons = self._protection_reasons.get(message_id)
|
|
681
|
+
if reasons is None:
|
|
682
|
+
return
|
|
683
|
+
reasons.discard(reason)
|
|
684
|
+
if not reasons:
|
|
685
|
+
del self._protection_reasons[message_id]
|
|
686
|
+
|
|
687
|
+
def is_protected(self, message_id: str) -> bool:
|
|
688
|
+
"""Check whether a message is protected from virtualization.
|
|
689
|
+
|
|
690
|
+
Returns:
|
|
691
|
+
Whether the message is protected for at least one reason.
|
|
692
|
+
"""
|
|
693
|
+
return message_id in self._protection_reasons
|
|
694
|
+
|
|
695
|
+
def window_exceeded(self) -> bool:
|
|
696
|
+
"""Check if the visible window exceeds the maximum size.
|
|
697
|
+
|
|
698
|
+
Returns:
|
|
699
|
+
True if we should prune some widgets.
|
|
700
|
+
"""
|
|
701
|
+
return self.visible_count > self.WINDOW_SIZE
|
|
702
|
+
|
|
703
|
+
def get_messages_to_prune(self, count: int | None = None) -> list[MessageData]:
|
|
704
|
+
"""Get the oldest visible messages that should be pruned.
|
|
705
|
+
|
|
706
|
+
Returns a contiguous run of messages from the START of the visible
|
|
707
|
+
window. Stops at the first protected message (the active stream or a
|
|
708
|
+
live tool run) to avoid creating gaps in the visible window (which
|
|
709
|
+
would desync store state from the DOM).
|
|
710
|
+
|
|
711
|
+
Args:
|
|
712
|
+
count: Number of messages to prune, or None to prune
|
|
713
|
+
enough to get back to WINDOW_SIZE.
|
|
714
|
+
|
|
715
|
+
Returns:
|
|
716
|
+
List of messages to prune (remove widgets for).
|
|
717
|
+
"""
|
|
718
|
+
if count is None:
|
|
719
|
+
count = max(0, self.visible_count - self.WINDOW_SIZE)
|
|
720
|
+
|
|
721
|
+
if count <= 0:
|
|
722
|
+
return []
|
|
723
|
+
|
|
724
|
+
to_prune: list[MessageData] = []
|
|
725
|
+
idx = self._visible_start
|
|
726
|
+
|
|
727
|
+
while len(to_prune) < count and idx < self._visible_end:
|
|
728
|
+
msg = self._messages[idx]
|
|
729
|
+
# Stop at the first protected message to keep the window contiguous
|
|
730
|
+
if self.is_protected(msg.id):
|
|
731
|
+
break
|
|
732
|
+
to_prune.append(msg)
|
|
733
|
+
idx += 1
|
|
734
|
+
|
|
735
|
+
return to_prune
|
|
736
|
+
|
|
737
|
+
def get_messages_to_prune_below(
|
|
738
|
+
self, count: int | None = None
|
|
739
|
+
) -> list[MessageData]:
|
|
740
|
+
"""Get newest visible messages that should be pruned below the viewport.
|
|
741
|
+
|
|
742
|
+
Args:
|
|
743
|
+
count: Number of messages to prune, or enough to return to
|
|
744
|
+
`WINDOW_SIZE` when omitted.
|
|
745
|
+
|
|
746
|
+
Returns:
|
|
747
|
+
Messages to remove from the bottom of the visible window.
|
|
748
|
+
"""
|
|
749
|
+
if count is None:
|
|
750
|
+
count = max(0, self.visible_count - self.WINDOW_SIZE)
|
|
751
|
+
if count <= 0:
|
|
752
|
+
return []
|
|
753
|
+
|
|
754
|
+
to_prune: list[MessageData] = []
|
|
755
|
+
idx = self._visible_end - 1
|
|
756
|
+
while len(to_prune) < count and idx >= self._visible_start:
|
|
757
|
+
msg = self._messages[idx]
|
|
758
|
+
if self.is_protected(msg.id):
|
|
759
|
+
break
|
|
760
|
+
to_prune.append(msg)
|
|
761
|
+
idx -= 1
|
|
762
|
+
to_prune.reverse()
|
|
763
|
+
return to_prune
|
|
764
|
+
|
|
765
|
+
def mark_pruned(self, message_ids: list[str]) -> None:
|
|
766
|
+
"""Mark messages as pruned (widgets removed).
|
|
767
|
+
|
|
768
|
+
Advances `_visible_start` past consecutive pruned messages at the front
|
|
769
|
+
of the window.
|
|
770
|
+
|
|
771
|
+
Args:
|
|
772
|
+
message_ids: IDs of messages that were pruned.
|
|
773
|
+
"""
|
|
774
|
+
pruned_set = set(message_ids)
|
|
775
|
+
while (
|
|
776
|
+
self._visible_start < self._visible_end
|
|
777
|
+
and self._messages[self._visible_start].id in pruned_set
|
|
778
|
+
):
|
|
779
|
+
self._visible_start += 1
|
|
780
|
+
|
|
781
|
+
def mark_pruned_below(self, message_ids: list[str]) -> None:
|
|
782
|
+
"""Mark bottom-window messages as pruned.
|
|
783
|
+
|
|
784
|
+
Args:
|
|
785
|
+
message_ids: IDs removed from the bottom of the mounted window.
|
|
786
|
+
"""
|
|
787
|
+
pruned_set = set(message_ids)
|
|
788
|
+
while (
|
|
789
|
+
self._visible_end > self._visible_start
|
|
790
|
+
and self._messages[self._visible_end - 1].id in pruned_set
|
|
791
|
+
):
|
|
792
|
+
self._visible_end -= 1
|
|
793
|
+
|
|
794
|
+
def get_messages_to_hydrate(self, count: int | None = None) -> list[MessageData]:
|
|
795
|
+
"""Get messages above the visible window to hydrate.
|
|
796
|
+
|
|
797
|
+
Args:
|
|
798
|
+
count: Number of messages to hydrate, or None for `HYDRATE_BUFFER`.
|
|
799
|
+
|
|
800
|
+
Returns:
|
|
801
|
+
List of messages to hydrate (create widgets for), in order.
|
|
802
|
+
"""
|
|
803
|
+
if count is None:
|
|
804
|
+
count = self.HYDRATE_BUFFER
|
|
805
|
+
|
|
806
|
+
if self._visible_start <= 0:
|
|
807
|
+
return []
|
|
808
|
+
|
|
809
|
+
hydrate_start = max(0, self._visible_start - count)
|
|
810
|
+
return self._messages[hydrate_start : self._visible_start]
|
|
811
|
+
|
|
812
|
+
def mark_hydrated(self, count: int) -> None:
|
|
813
|
+
"""Mark that messages above were hydrated.
|
|
814
|
+
|
|
815
|
+
Args:
|
|
816
|
+
count: Number of messages that were hydrated.
|
|
817
|
+
"""
|
|
818
|
+
self._visible_start = max(0, self._visible_start - count)
|
|
819
|
+
|
|
820
|
+
def get_messages_to_hydrate_below(
|
|
821
|
+
self, count: int | None = None
|
|
822
|
+
) -> list[MessageData]:
|
|
823
|
+
"""Get messages below the visible window to hydrate.
|
|
824
|
+
|
|
825
|
+
Args:
|
|
826
|
+
count: Number of messages to hydrate; defaults to `HYDRATE_BUFFER`
|
|
827
|
+
when omitted.
|
|
828
|
+
|
|
829
|
+
Returns:
|
|
830
|
+
Messages below the mounted window, in order.
|
|
831
|
+
"""
|
|
832
|
+
if count is None:
|
|
833
|
+
count = self.HYDRATE_BUFFER
|
|
834
|
+
if self._visible_end >= len(self._messages):
|
|
835
|
+
return []
|
|
836
|
+
hydrate_end = min(len(self._messages), self._visible_end + count)
|
|
837
|
+
return self._messages[self._visible_end : hydrate_end]
|
|
838
|
+
|
|
839
|
+
def mark_hydrated_below(self, count: int) -> None:
|
|
840
|
+
"""Mark that messages below were hydrated.
|
|
841
|
+
|
|
842
|
+
Args:
|
|
843
|
+
count: Number of messages that were hydrated below the window.
|
|
844
|
+
"""
|
|
845
|
+
self._visible_end = min(len(self._messages), self._visible_end + count)
|
|
846
|
+
|
|
847
|
+
def should_hydrate_above(
|
|
848
|
+
self, scroll_position: float, viewport_height: int
|
|
849
|
+
) -> bool:
|
|
850
|
+
"""Check if we should hydrate messages above the current view.
|
|
851
|
+
|
|
852
|
+
Args:
|
|
853
|
+
scroll_position: Current scroll Y position.
|
|
854
|
+
viewport_height: Height of the viewport.
|
|
855
|
+
|
|
856
|
+
Returns:
|
|
857
|
+
True if user is scrolling near the top and we have archived messages.
|
|
858
|
+
"""
|
|
859
|
+
if not self.has_messages_above:
|
|
860
|
+
return False
|
|
861
|
+
|
|
862
|
+
# Hydrate when within 2x viewport height of the top
|
|
863
|
+
threshold = viewport_height * 2
|
|
864
|
+
return scroll_position < threshold
|
|
865
|
+
|
|
866
|
+
def should_prune_below(
|
|
867
|
+
self, scroll_position: float, viewport_height: int, content_height: int
|
|
868
|
+
) -> bool:
|
|
869
|
+
"""Check if we should prune messages below the current view.
|
|
870
|
+
|
|
871
|
+
Note:
|
|
872
|
+
Not yet integrated into the scroll handler. Intended for future
|
|
873
|
+
pruning of messages below the viewport when the user scrolls far up.
|
|
874
|
+
|
|
875
|
+
Args:
|
|
876
|
+
scroll_position: Current scroll Y position.
|
|
877
|
+
viewport_height: Height of the viewport.
|
|
878
|
+
content_height: Total height of all content.
|
|
879
|
+
|
|
880
|
+
Returns:
|
|
881
|
+
True if we have too many widgets and bottom ones are far from view.
|
|
882
|
+
"""
|
|
883
|
+
if self.visible_count <= self.WINDOW_SIZE:
|
|
884
|
+
return False
|
|
885
|
+
|
|
886
|
+
# Only prune if user is far from the bottom
|
|
887
|
+
distance_from_bottom = content_height - scroll_position - viewport_height
|
|
888
|
+
threshold = viewport_height * 3
|
|
889
|
+
return distance_from_bottom > threshold
|
|
890
|
+
|
|
891
|
+
def should_hydrate_below(
|
|
892
|
+
self,
|
|
893
|
+
scroll_position: float,
|
|
894
|
+
viewport_height: int,
|
|
895
|
+
bottom_spacer_top: int,
|
|
896
|
+
) -> bool:
|
|
897
|
+
"""Check if we should hydrate messages below the current view.
|
|
898
|
+
|
|
899
|
+
Args:
|
|
900
|
+
scroll_position: Current scroll Y position.
|
|
901
|
+
viewport_height: Height of the viewport.
|
|
902
|
+
bottom_spacer_top: Estimated row where the bottom spacer begins.
|
|
903
|
+
|
|
904
|
+
Returns:
|
|
905
|
+
True if the viewport is near the bottom spacer.
|
|
906
|
+
"""
|
|
907
|
+
if not self.has_messages_below:
|
|
908
|
+
return False
|
|
909
|
+
viewport_bottom = scroll_position + viewport_height
|
|
910
|
+
distance_from_bottom_spacer = bottom_spacer_top - viewport_bottom
|
|
911
|
+
threshold = viewport_height * 2
|
|
912
|
+
return distance_from_bottom_spacer < threshold
|
|
913
|
+
|
|
914
|
+
def clear(self) -> None:
|
|
915
|
+
"""Clear all messages."""
|
|
916
|
+
self._messages.clear()
|
|
917
|
+
self._index.clear()
|
|
918
|
+
self._visible_start = 0
|
|
919
|
+
self._visible_end = 0
|
|
920
|
+
self._protection_reasons.clear()
|
|
921
|
+
self._active_message_id = None
|
|
922
|
+
|
|
923
|
+
def get_visible_range(self) -> tuple[int, int]:
|
|
924
|
+
"""Get the range of visible message indices.
|
|
925
|
+
|
|
926
|
+
Returns:
|
|
927
|
+
Tuple of (start_index, end_index).
|
|
928
|
+
"""
|
|
929
|
+
return (self._visible_start, self._visible_end)
|
|
930
|
+
|
|
931
|
+
def get_all_messages(self) -> list[MessageData]:
|
|
932
|
+
"""Get all stored messages.
|
|
933
|
+
|
|
934
|
+
Returns:
|
|
935
|
+
List of all message data (shallow copy).
|
|
936
|
+
"""
|
|
937
|
+
return list(self._messages)
|
|
938
|
+
|
|
939
|
+
def get_visible_messages(self) -> list[MessageData]:
|
|
940
|
+
"""Get messages in the visible window.
|
|
941
|
+
|
|
942
|
+
Returns:
|
|
943
|
+
List of visible message data.
|
|
944
|
+
"""
|
|
945
|
+
return self._messages[self._visible_start : self._visible_end]
|
|
946
|
+
|
|
947
|
+
def set_height_hint(self, message_id: str, rows: int) -> bool:
|
|
948
|
+
"""Update a measured message height, clamped to `MIN_HEIGHT_HINT`.
|
|
949
|
+
|
|
950
|
+
The single write path for `height_hint`; `height_hint` is intentionally
|
|
951
|
+
excluded from `update_message`'s allowlist so every write clamps here.
|
|
952
|
+
|
|
953
|
+
Args:
|
|
954
|
+
message_id: Message ID to update.
|
|
955
|
+
rows: Rendered height in terminal rows.
|
|
956
|
+
|
|
957
|
+
Returns:
|
|
958
|
+
Whether the message existed and was updated.
|
|
959
|
+
"""
|
|
960
|
+
msg_data = self._index.get(message_id)
|
|
961
|
+
if msg_data is None:
|
|
962
|
+
return False
|
|
963
|
+
msg_data.height_hint = max(MIN_HEIGHT_HINT, rows)
|
|
964
|
+
return True
|
|
965
|
+
|
|
966
|
+
def invalidate_height_hints(self, *, scale: float | None = None) -> None:
|
|
967
|
+
"""Invalidate or scale cached height hints after terminal reflow.
|
|
968
|
+
|
|
969
|
+
Args:
|
|
970
|
+
scale: Optional multiplier used when terminal width changes. When
|
|
971
|
+
omitted, all cached hints are cleared.
|
|
972
|
+
"""
|
|
973
|
+
for msg in self._messages:
|
|
974
|
+
if msg.height_hint is None:
|
|
975
|
+
continue
|
|
976
|
+
if scale is None:
|
|
977
|
+
msg.height_hint = None
|
|
978
|
+
else:
|
|
979
|
+
msg.height_hint = max(MIN_HEIGHT_HINT, round(msg.height_hint * scale))
|
|
980
|
+
|
|
981
|
+
@staticmethod
|
|
982
|
+
def estimate_height(message: MessageData) -> int:
|
|
983
|
+
"""Return the best available row estimate for a message."""
|
|
984
|
+
if message.height_hint is None:
|
|
985
|
+
return DEFAULT_HEIGHT_HINT
|
|
986
|
+
return max(MIN_HEIGHT_HINT, message.height_hint)
|
|
987
|
+
|
|
988
|
+
def range_height(self, start: int, end: int) -> int:
|
|
989
|
+
"""Estimate rows in `[start:end]`.
|
|
990
|
+
|
|
991
|
+
Returns:
|
|
992
|
+
Estimated row count in the range.
|
|
993
|
+
"""
|
|
994
|
+
bounded_start = max(0, min(start, len(self._messages)))
|
|
995
|
+
bounded_end = max(bounded_start, min(end, len(self._messages)))
|
|
996
|
+
return sum(
|
|
997
|
+
self.estimate_height(msg)
|
|
998
|
+
for msg in self._messages[bounded_start:bounded_end]
|
|
999
|
+
)
|