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,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
+ )