soothe-cli 0.1.0__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 (107) hide show
  1. soothe_cli/__init__.py +5 -0
  2. soothe_cli/cli/__init__.py +1 -0
  3. soothe_cli/cli/commands/__init__.py +1 -0
  4. soothe_cli/cli/commands/autopilot_cmd.py +410 -0
  5. soothe_cli/cli/commands/config_cmd.py +277 -0
  6. soothe_cli/cli/commands/run_cmd.py +87 -0
  7. soothe_cli/cli/commands/status_cmd.py +121 -0
  8. soothe_cli/cli/commands/subagent_names.py +17 -0
  9. soothe_cli/cli/commands/thread_cmd.py +657 -0
  10. soothe_cli/cli/execution/__init__.py +6 -0
  11. soothe_cli/cli/execution/daemon.py +194 -0
  12. soothe_cli/cli/execution/headless.py +99 -0
  13. soothe_cli/cli/execution/launcher.py +31 -0
  14. soothe_cli/cli/main.py +509 -0
  15. soothe_cli/cli/renderer.py +444 -0
  16. soothe_cli/cli/stream/__init__.py +17 -0
  17. soothe_cli/cli/stream/context.py +138 -0
  18. soothe_cli/cli/stream/display_line.py +83 -0
  19. soothe_cli/cli/stream/formatter.py +412 -0
  20. soothe_cli/cli/stream/pipeline.py +521 -0
  21. soothe_cli/cli/utils.py +46 -0
  22. soothe_cli/config/__init__.py +5 -0
  23. soothe_cli/config/cli_config.py +155 -0
  24. soothe_cli/plan/__init__.py +5 -0
  25. soothe_cli/plan/rich_tree.py +54 -0
  26. soothe_cli/shared/__init__.py +107 -0
  27. soothe_cli/shared/command_router.py +246 -0
  28. soothe_cli/shared/config_loader.py +68 -0
  29. soothe_cli/shared/display_policy.py +413 -0
  30. soothe_cli/shared/essential_events.py +68 -0
  31. soothe_cli/shared/event_processor.py +823 -0
  32. soothe_cli/shared/message_processing.py +393 -0
  33. soothe_cli/shared/presentation_engine.py +173 -0
  34. soothe_cli/shared/processor_state.py +80 -0
  35. soothe_cli/shared/renderer_protocol.py +158 -0
  36. soothe_cli/shared/rendering.py +43 -0
  37. soothe_cli/shared/slash_commands.py +354 -0
  38. soothe_cli/shared/subagent_routing.py +63 -0
  39. soothe_cli/shared/suppression_state.py +188 -0
  40. soothe_cli/shared/tool_formatters/__init__.py +27 -0
  41. soothe_cli/shared/tool_formatters/base.py +109 -0
  42. soothe_cli/shared/tool_formatters/execution.py +297 -0
  43. soothe_cli/shared/tool_formatters/fallback.py +128 -0
  44. soothe_cli/shared/tool_formatters/file_ops.py +299 -0
  45. soothe_cli/shared/tool_formatters/goal_formatter.py +331 -0
  46. soothe_cli/shared/tool_formatters/media.py +291 -0
  47. soothe_cli/shared/tool_formatters/structured.py +202 -0
  48. soothe_cli/shared/tool_formatters/web.py +143 -0
  49. soothe_cli/shared/tool_output_formatter.py +227 -0
  50. soothe_cli/shared/tui_trace_log.py +40 -0
  51. soothe_cli/tui/__init__.py +5 -0
  52. soothe_cli/tui/_ask_user_types.py +50 -0
  53. soothe_cli/tui/_cli_context.py +27 -0
  54. soothe_cli/tui/_env_vars.py +56 -0
  55. soothe_cli/tui/_session_stats.py +114 -0
  56. soothe_cli/tui/_version.py +21 -0
  57. soothe_cli/tui/app.py +4992 -0
  58. soothe_cli/tui/app.tcss +302 -0
  59. soothe_cli/tui/command_registry.py +310 -0
  60. soothe_cli/tui/config.py +2381 -0
  61. soothe_cli/tui/daemon_session.py +233 -0
  62. soothe_cli/tui/file_ops.py +409 -0
  63. soothe_cli/tui/formatting.py +28 -0
  64. soothe_cli/tui/hooks.py +23 -0
  65. soothe_cli/tui/input.py +782 -0
  66. soothe_cli/tui/media_utils.py +471 -0
  67. soothe_cli/tui/model_config.py +518 -0
  68. soothe_cli/tui/output.py +69 -0
  69. soothe_cli/tui/project_utils.py +188 -0
  70. soothe_cli/tui/sessions.py +1248 -0
  71. soothe_cli/tui/skills/__init__.py +5 -0
  72. soothe_cli/tui/skills/invocation.py +74 -0
  73. soothe_cli/tui/skills/load.py +93 -0
  74. soothe_cli/tui/textual_adapter.py +1430 -0
  75. soothe_cli/tui/theme.py +838 -0
  76. soothe_cli/tui/tool_display.py +297 -0
  77. soothe_cli/tui/unicode_security.py +502 -0
  78. soothe_cli/tui/update_check.py +447 -0
  79. soothe_cli/tui/widgets/__init__.py +9 -0
  80. soothe_cli/tui/widgets/_links.py +63 -0
  81. soothe_cli/tui/widgets/approval.py +430 -0
  82. soothe_cli/tui/widgets/ask_user.py +392 -0
  83. soothe_cli/tui/widgets/autocomplete.py +666 -0
  84. soothe_cli/tui/widgets/autopilot_dashboard.py +308 -0
  85. soothe_cli/tui/widgets/autopilot_screen.py +64 -0
  86. soothe_cli/tui/widgets/chat_input.py +1834 -0
  87. soothe_cli/tui/widgets/clipboard.py +128 -0
  88. soothe_cli/tui/widgets/diff.py +240 -0
  89. soothe_cli/tui/widgets/editor.py +140 -0
  90. soothe_cli/tui/widgets/history.py +221 -0
  91. soothe_cli/tui/widgets/loading.py +194 -0
  92. soothe_cli/tui/widgets/mcp_viewer.py +352 -0
  93. soothe_cli/tui/widgets/message_store.py +693 -0
  94. soothe_cli/tui/widgets/messages.py +1720 -0
  95. soothe_cli/tui/widgets/model_selector.py +988 -0
  96. soothe_cli/tui/widgets/notification_settings.py +155 -0
  97. soothe_cli/tui/widgets/status.py +403 -0
  98. soothe_cli/tui/widgets/theme_selector.py +158 -0
  99. soothe_cli/tui/widgets/thread_selector.py +1865 -0
  100. soothe_cli/tui/widgets/tool_renderers.py +148 -0
  101. soothe_cli/tui/widgets/tool_widgets.py +254 -0
  102. soothe_cli/tui/widgets/tools.py +165 -0
  103. soothe_cli/tui/widgets/welcome.py +330 -0
  104. soothe_cli-0.1.0.dist-info/METADATA +100 -0
  105. soothe_cli-0.1.0.dist-info/RECORD +107 -0
  106. soothe_cli-0.1.0.dist-info/WHEEL +4 -0
  107. soothe_cli-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,413 @@
1
+ """Unified Display Policy Module for CLI and TUI.
2
+
3
+ This module centralizes all event filtering, content processing, and display
4
+ policy decisions in one place. Both CLI and TUI renderers use this policy
5
+ to determine:
6
+
7
+ 1. Which events to show/hide based on verbosity
8
+ 2. Which content to filter from assistant text
9
+ 3. Which message types are internal vs user-facing
10
+ 4. How to handle different event categories
11
+
12
+ Design Principles:
13
+ - Event-based filtering over content-based filtering
14
+ - Explicit policy rules over implicit pattern matching
15
+ - Centralized configuration for consistency
16
+ - Easy to extend without modifying multiple files
17
+
18
+ Usage:
19
+ from soothe_cli.shared.display_policy import DisplayPolicy
20
+
21
+ policy = DisplayPolicy(verbosity="normal")
22
+
23
+ if policy.should_show_event(event_type, data):
24
+ render_event(data)
25
+
26
+ if policy.should_show_assistant_text(text, is_main=True):
27
+ display_text(policy.filter_content(text))
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import re
33
+ from dataclasses import dataclass, field
34
+ from typing import Any
35
+
36
+ from soothe_sdk.internal import (
37
+ INTERNAL_JSON_KEYS,
38
+ filter_confused_responses,
39
+ filter_json_code_blocks,
40
+ filter_plain_json,
41
+ filter_search_data_tags,
42
+ normalize_internal_whitespace,
43
+ )
44
+ from soothe_sdk.verbosity import (
45
+ VerbosityLevel,
46
+ VerbosityTier,
47
+ classify_event_to_tier,
48
+ should_show,
49
+ )
50
+
51
+ # =============================================================================
52
+ # Type Definitions
53
+ # =============================================================================
54
+
55
+
56
+ def normalize_verbosity(verbosity: str) -> VerbosityLevel:
57
+ """Normalize external verbosity values to canonical internal names."""
58
+ if verbosity == "minimal":
59
+ return "normal"
60
+ if verbosity in {"quiet", "normal", "detailed", "debug"}:
61
+ return verbosity
62
+ return "normal"
63
+
64
+
65
+ # =============================================================================
66
+ # Policy Configuration Constants
67
+ # =============================================================================
68
+
69
+ # Event types that should NEVER be shown (internal implementation details)
70
+ INTERNAL_EVENT_TYPES = frozenset(
71
+ {
72
+ "soothe.capability.research.internal_llm.run",
73
+ }
74
+ )
75
+
76
+ # Event types to skip in progress display (handled by plan update mechanism or not rendered)
77
+ SKIP_EVENT_TYPES = frozenset(
78
+ {
79
+ # Plan events handled by renderer's plan update mechanism
80
+ "soothe.cognition.plan.batch.started",
81
+ "soothe.cognition.plan.step.started",
82
+ "soothe.cognition.plan.step.completed",
83
+ "soothe.cognition.plan.step.failed",
84
+ # Policy events not rendered (RFC-0019)
85
+ "soothe.protocol.policy.checked",
86
+ "soothe.protocol.policy.denied",
87
+ }
88
+ )
89
+
90
+ PLAN_EVENT_TYPES = frozenset(
91
+ {
92
+ "soothe.cognition.plan.created",
93
+ "soothe.cognition.plan.reflected",
94
+ "soothe.cognition.plan.step.started",
95
+ "soothe.cognition.plan.step.completed",
96
+ "soothe.cognition.plan.step.failed",
97
+ }
98
+ )
99
+
100
+ MILESTONE_EVENT_TYPES = frozenset(
101
+ {
102
+ "soothe.cognition.plan.step.completed",
103
+ "soothe.cognition.plan.step.failed",
104
+ }
105
+ )
106
+
107
+ QUIET_SENTENCE_MAX_LEN = 120
108
+ QUIET_FALLBACK_MAX_LEN = 160
109
+ QUIET_TRUNCATED_MAX_LEN = 157
110
+ TRAILING_EMBELLISHMENT_WORDS = frozenset(
111
+ {
112
+ "beautiful",
113
+ "historic",
114
+ "wonderful",
115
+ "amazing",
116
+ "great",
117
+ "lovely",
118
+ "fantastic",
119
+ "famous",
120
+ "vibrant",
121
+ }
122
+ )
123
+
124
+ DECORATIVE_FILLER_PATTERNS = (
125
+ r"\n?\s*Let me know if you(?:'d| would)? like .*?$",
126
+ r"\n?\s*If you(?:'d| would) like, I can .*?$",
127
+ r"\n?\s*Feel free to ask if .*?$",
128
+ r"\n?\s*I(?:'m| am) happy to help you with .*?$",
129
+ )
130
+
131
+
132
+ # =============================================================================
133
+ # Display Policy Class
134
+ # =============================================================================
135
+
136
+
137
+ @dataclass
138
+ class DisplayPolicy:
139
+ """Unified display policy for CLI and TUI.
140
+
141
+ This class centralizes all decisions about what to show/hide,
142
+ what content to filter, and how to process events for display.
143
+ """
144
+
145
+ verbosity: VerbosityLevel = "normal"
146
+
147
+ def __post_init__(self) -> None:
148
+ """Normalize compatibility aliases after initialization."""
149
+ self.verbosity = normalize_verbosity(self.verbosity)
150
+
151
+ # Track internal context state
152
+ internal_context_active: bool = field(default=False, repr=False)
153
+ internal_context_types: set[str] = field(default_factory=set, repr=False)
154
+
155
+ # ==========================================================================
156
+ # Event Filtering
157
+ # ==========================================================================
158
+
159
+ def should_show_event(
160
+ self,
161
+ event_type: str,
162
+ data: dict[str, Any] | None = None, # noqa: ARG002
163
+ namespace: tuple[str, ...] = (),
164
+ ) -> bool:
165
+ """Determine if an event should be displayed.
166
+
167
+ Args:
168
+ event_type: The event type string (e.g., "soothe.tool.research.analyze")
169
+ data: Optional event data dict
170
+ namespace: Subagent namespace tuple
171
+
172
+ Returns:
173
+ True if the event should be shown, False otherwise
174
+ """
175
+ # Internal events are NEVER shown
176
+ if event_type in INTERNAL_EVENT_TYPES:
177
+ return False
178
+
179
+ # Skip certain event types (handled by plan update mechanism)
180
+ if event_type in SKIP_EVENT_TYPES:
181
+ return False
182
+
183
+ # Classify and check verbosity
184
+ tier = self._classify_event(event_type, namespace)
185
+ return self._should_show_tier(tier)
186
+
187
+ def _classify_event(
188
+ self,
189
+ event_type: str,
190
+ namespace: tuple[str, ...] = (),
191
+ ) -> VerbosityTier:
192
+ """Classify an event directly to a VerbosityTier."""
193
+ return classify_event_to_tier(event_type, namespace)
194
+
195
+ def _should_show_tier(self, tier: VerbosityTier) -> bool:
196
+ """Check if a tier should be shown at current verbosity."""
197
+ return should_show(tier, self.verbosity)
198
+
199
+ # ==========================================================================
200
+ # Internal Context Tracking
201
+ # ==========================================================================
202
+
203
+ def enter_internal_context(self, context_type: str) -> None:
204
+ """Mark entry into an internal processing context.
205
+
206
+ Call this when starting internal LLM calls (e.g., research analysis).
207
+ """
208
+ self.internal_context_active = True
209
+ self.internal_context_types.add(context_type)
210
+
211
+ def exit_internal_context(self) -> None:
212
+ """Mark exit from internal processing context."""
213
+ self.internal_context_active = False
214
+ self.internal_context_types.clear()
215
+
216
+ def is_in_internal_context(self) -> bool:
217
+ """Check if currently in an internal processing context."""
218
+ return self.internal_context_active
219
+
220
+ # ==========================================================================
221
+ # Assistant Text Filtering
222
+ # ==========================================================================
223
+
224
+ def should_show_assistant_text(
225
+ self,
226
+ text: str, # noqa: ARG002
227
+ *,
228
+ is_main: bool,
229
+ is_multi_step_active: bool = False,
230
+ ) -> bool:
231
+ """Determine if assistant text should be displayed.
232
+
233
+ Args:
234
+ text: The text content
235
+ is_main: True if from main agent
236
+ is_multi_step_active: True if in multi-step plan execution
237
+
238
+ Returns:
239
+ True if the text should be shown
240
+ """
241
+ # During internal context, suppress non-main agent text
242
+ if self.internal_context_active and not is_main:
243
+ return False
244
+
245
+ # During multi-step plans, suppress intermediate main agent text
246
+ if is_multi_step_active and is_main:
247
+ return False
248
+
249
+ # Check verbosity
250
+ return self._should_show_tier(VerbosityTier.QUIET)
251
+
252
+ def filter_content(self, text: str, *, preserve_boundary_whitespace: bool = False) -> str:
253
+ """Filter internal content from text for display.
254
+
255
+ Args:
256
+ text: Text to filter.
257
+ preserve_boundary_whitespace: If True, preserve leading/trailing whitespace
258
+ for proper streaming chunk concatenation.
259
+ """
260
+ # Preserve leading/trailing whitespace for streaming chunks
261
+ if preserve_boundary_whitespace:
262
+ leading_ws = len(text) - len(text.lstrip())
263
+ trailing_ws = len(text) - len(text.rstrip())
264
+ lead = text[:leading_ws]
265
+ trail = text[len(text) - trailing_ws :] if trailing_ws > 0 else ""
266
+
267
+ text = filter_json_code_blocks(text)
268
+ text = filter_plain_json(text)
269
+ text = filter_confused_responses(text)
270
+ text = filter_search_data_tags(text)
271
+ text = self._filter_decorative_filler(text)
272
+ text = normalize_internal_whitespace(text)
273
+ text = self._strip_sentence_embellishment(text)
274
+ text = self._normalize_factual_ending(text)
275
+
276
+ if preserve_boundary_whitespace:
277
+ # Restore boundary whitespace for streaming concatenation
278
+ return lead + text.strip() + trail
279
+ return text.strip()
280
+
281
+ def _filter_decorative_filler(self, text: str) -> str:
282
+ """Remove polite trailing filler that adds no user value."""
283
+ for pattern in DECORATIVE_FILLER_PATTERNS:
284
+ text = re.sub(pattern, "", text, flags=re.IGNORECASE | re.MULTILINE)
285
+ return text
286
+
287
+ def extract_quiet_answer(self, text: str) -> str:
288
+ """Extract a compact answer for quiet mode with safe fallback."""
289
+ cleaned = self.filter_content(text)
290
+ if not cleaned:
291
+ return ""
292
+
293
+ single_line = re.sub(r"\s+", " ", cleaned).strip()
294
+ if re.fullmatch(r"[-+]?\d+(?:\.\d+)?", single_line):
295
+ return single_line
296
+
297
+ if re.fullmatch(
298
+ r"[-+]?\d+(?:\.\d+)?\s*[+\-*/]\s*[-+]?\d+(?:\.\d+)?\s*=\s*([-+]?\d+(?:\.\d+)?)",
299
+ single_line,
300
+ ):
301
+ equation_match = re.search(r"=\s*([-+]?\d+(?:\.\d+)?)$", single_line)
302
+ if equation_match:
303
+ return equation_match.group(1)
304
+
305
+ numeric_match = re.search(
306
+ r"\b(?:that(?:'s| is)|it(?:'s| is)|answer(?: is)?|result(?: is)?)\s+([-+]?\d+(?:\.\d+)?)\b",
307
+ single_line,
308
+ re.IGNORECASE,
309
+ )
310
+ if numeric_match:
311
+ return numeric_match.group(1)
312
+
313
+ sentences = [
314
+ part.strip() for part in re.split(r"(?<=[.!?])\s+", single_line) if part.strip()
315
+ ]
316
+ if sentences:
317
+ first = self._strip_sentence_embellishment(sentences[0])
318
+ if len(first) <= QUIET_SENTENCE_MAX_LEN:
319
+ return first
320
+
321
+ if len(single_line) <= QUIET_FALLBACK_MAX_LEN:
322
+ return self._strip_sentence_embellishment(single_line)
323
+ return (
324
+ self._strip_sentence_embellishment(
325
+ single_line[:QUIET_TRUNCATED_MAX_LEN].rsplit(" ", 1)[0]
326
+ )
327
+ + "..."
328
+ )
329
+
330
+ def _strip_sentence_embellishment(self, text: str) -> str:
331
+ """Remove lightweight trailing flourish from otherwise factual answers."""
332
+ text = re.sub(r"\s*[🇦-🇿✨🎉👍😊😄😃😀😉🙌]+$", "", text).strip()
333
+
334
+ inline_match = re.match(r"^(.*?),\s+(?:a|an)\s+(.+?)([.!?])$", text, flags=re.IGNORECASE)
335
+ if inline_match:
336
+ descriptor_words = re.findall(r"[A-Za-z']+", inline_match.group(2).lower())
337
+ if descriptor_words and any(
338
+ word in TRAILING_EMBELLISHMENT_WORDS for word in descriptor_words
339
+ ):
340
+ return inline_match.group(1) + inline_match.group(3)
341
+
342
+ sentence_match = re.match(r"^(.*?\.)\s+(?:a|an)\s+(.+)$", text, flags=re.IGNORECASE)
343
+ if not sentence_match:
344
+ return text
345
+
346
+ descriptor_words = re.findall(r"[A-Za-z']+", sentence_match.group(2).lower())
347
+ if descriptor_words and any(
348
+ word in TRAILING_EMBELLISHMENT_WORDS for word in descriptor_words
349
+ ):
350
+ return sentence_match.group(1)
351
+ return text
352
+
353
+ def _normalize_factual_ending(self, text: str) -> str:
354
+ """Convert lightweight factual exclamation endings to periods."""
355
+ if re.search(
356
+ r"\b(?:capital|answer|result|sum|total|equals|is)\b", text, flags=re.IGNORECASE
357
+ ):
358
+ return re.sub(r"!$", ".", text)
359
+ return text
360
+
361
+ def _normalize_whitespace(self, text: str) -> str:
362
+ """Normalize excessive whitespace."""
363
+ return normalize_internal_whitespace(text)
364
+
365
+ # ==========================================================================
366
+ # Event Type Helpers
367
+ # ==========================================================================
368
+
369
+ def is_plan_event(self, event_type: str) -> bool:
370
+ """Check if this is a plan-related event."""
371
+ return event_type.startswith("soothe.cognition.plan.")
372
+
373
+ def is_research_event(self, event_type: str) -> bool:
374
+ """Check if this is a research subagent event."""
375
+ return event_type.startswith("soothe.subagent.research.")
376
+
377
+ def is_internal_event(self, event_type: str) -> bool:
378
+ """Check if this is an internal (never-shown) event."""
379
+ return event_type in INTERNAL_EVENT_TYPES or "internal" in event_type
380
+
381
+
382
+ # =============================================================================
383
+ # Factory Function
384
+ # =============================================================================
385
+
386
+
387
+ def create_display_policy(
388
+ verbosity: VerbosityLevel = "normal",
389
+ ) -> DisplayPolicy:
390
+ """Create a display policy with the given verbosity level.
391
+
392
+ Args:
393
+ verbosity: Verbosity level for filtering
394
+
395
+ Returns:
396
+ Configured DisplayPolicy instance
397
+ """
398
+ return DisplayPolicy(verbosity=verbosity)
399
+
400
+
401
+ # =============================================================================
402
+ # Exports
403
+ # =============================================================================
404
+
405
+ __all__ = [
406
+ "INTERNAL_EVENT_TYPES",
407
+ "INTERNAL_JSON_KEYS",
408
+ "SKIP_EVENT_TYPES",
409
+ "DisplayPolicy",
410
+ "VerbosityLevel",
411
+ "VerbosityTier",
412
+ "create_display_policy",
413
+ ]
@@ -0,0 +1,68 @@
1
+ """Shared essential event-type filtering for UX surfaces."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Final
6
+
7
+ GOAL_START_EVENT_TYPES: Final[frozenset[str]] = frozenset(
8
+ {
9
+ "soothe.cognition.agent_loop.started",
10
+ "soothe.cognition.plan.creating",
11
+ }
12
+ )
13
+
14
+ STEP_START_EVENT_TYPES: Final[frozenset[str]] = frozenset(
15
+ {
16
+ "soothe.cognition.plan.step.started",
17
+ "soothe.cognition.agent_loop.step.started",
18
+ }
19
+ )
20
+
21
+ STEP_COMPLETE_EVENT_TYPES: Final[frozenset[str]] = frozenset(
22
+ {
23
+ "soothe.cognition.plan.step.completed",
24
+ "soothe.cognition.agent_loop.step.completed",
25
+ }
26
+ )
27
+
28
+ LOOP_REASON_EVENT_TYPE: Final[str] = "soothe.cognition.agent_loop.reasoned"
29
+
30
+ ESSENTIAL_PROGRESS_EVENT_TYPES: Final[frozenset[str]] = frozenset(
31
+ set(GOAL_START_EVENT_TYPES)
32
+ | set(STEP_START_EVENT_TYPES)
33
+ | set(STEP_COMPLETE_EVENT_TYPES)
34
+ | {LOOP_REASON_EVENT_TYPE}
35
+ )
36
+
37
+
38
+ def is_essential_progress_event_type(event_type: str) -> bool:
39
+ """Return whether an event type is part of essential progress output."""
40
+ return event_type in ESSENTIAL_PROGRESS_EVENT_TYPES
41
+
42
+
43
+ def is_goal_start_event_type(event_type: str) -> bool:
44
+ """Return whether an event starts a goal header display."""
45
+ return event_type in GOAL_START_EVENT_TYPES
46
+
47
+
48
+ def is_step_start_event_type(event_type: str) -> bool:
49
+ """Return whether an event starts a step header display."""
50
+ return event_type in STEP_START_EVENT_TYPES
51
+
52
+
53
+ def is_step_complete_event_type(event_type: str) -> bool:
54
+ """Return whether an event marks step completion."""
55
+ return event_type in STEP_COMPLETE_EVENT_TYPES
56
+
57
+
58
+ __all__ = [
59
+ "ESSENTIAL_PROGRESS_EVENT_TYPES",
60
+ "GOAL_START_EVENT_TYPES",
61
+ "LOOP_REASON_EVENT_TYPE",
62
+ "STEP_COMPLETE_EVENT_TYPES",
63
+ "STEP_START_EVENT_TYPES",
64
+ "is_essential_progress_event_type",
65
+ "is_goal_start_event_type",
66
+ "is_step_complete_event_type",
67
+ "is_step_start_event_type",
68
+ ]