shotgun-sh 0.2.8.dev2__py3-none-any.whl → 0.2.17__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 (117) hide show
  1. shotgun/agents/agent_manager.py +354 -46
  2. shotgun/agents/common.py +14 -8
  3. shotgun/agents/config/constants.py +0 -6
  4. shotgun/agents/config/manager.py +66 -35
  5. shotgun/agents/config/models.py +41 -1
  6. shotgun/agents/config/provider.py +33 -5
  7. shotgun/agents/context_analyzer/__init__.py +28 -0
  8. shotgun/agents/context_analyzer/analyzer.py +471 -0
  9. shotgun/agents/context_analyzer/constants.py +9 -0
  10. shotgun/agents/context_analyzer/formatter.py +115 -0
  11. shotgun/agents/context_analyzer/models.py +212 -0
  12. shotgun/agents/conversation_history.py +2 -0
  13. shotgun/agents/conversation_manager.py +35 -19
  14. shotgun/agents/export.py +2 -2
  15. shotgun/agents/history/compaction.py +9 -4
  16. shotgun/agents/history/history_processors.py +113 -5
  17. shotgun/agents/history/token_counting/anthropic.py +17 -1
  18. shotgun/agents/history/token_counting/base.py +14 -3
  19. shotgun/agents/history/token_counting/openai.py +11 -1
  20. shotgun/agents/history/token_counting/sentencepiece_counter.py +8 -0
  21. shotgun/agents/history/token_counting/tokenizer_cache.py +3 -1
  22. shotgun/agents/history/token_counting/utils.py +0 -3
  23. shotgun/agents/plan.py +2 -2
  24. shotgun/agents/research.py +3 -3
  25. shotgun/agents/specify.py +2 -2
  26. shotgun/agents/tasks.py +2 -2
  27. shotgun/agents/tools/codebase/codebase_shell.py +6 -0
  28. shotgun/agents/tools/codebase/directory_lister.py +6 -0
  29. shotgun/agents/tools/codebase/file_read.py +11 -2
  30. shotgun/agents/tools/codebase/query_graph.py +6 -0
  31. shotgun/agents/tools/codebase/retrieve_code.py +6 -0
  32. shotgun/agents/tools/file_management.py +27 -7
  33. shotgun/agents/tools/registry.py +217 -0
  34. shotgun/agents/tools/web_search/__init__.py +8 -8
  35. shotgun/agents/tools/web_search/anthropic.py +8 -2
  36. shotgun/agents/tools/web_search/gemini.py +7 -1
  37. shotgun/agents/tools/web_search/openai.py +7 -1
  38. shotgun/agents/tools/web_search/utils.py +2 -2
  39. shotgun/agents/usage_manager.py +16 -11
  40. shotgun/api_endpoints.py +7 -3
  41. shotgun/build_constants.py +3 -3
  42. shotgun/cli/clear.py +53 -0
  43. shotgun/cli/compact.py +186 -0
  44. shotgun/cli/config.py +8 -5
  45. shotgun/cli/context.py +111 -0
  46. shotgun/cli/export.py +1 -1
  47. shotgun/cli/feedback.py +4 -2
  48. shotgun/cli/models.py +1 -0
  49. shotgun/cli/plan.py +1 -1
  50. shotgun/cli/research.py +1 -1
  51. shotgun/cli/specify.py +1 -1
  52. shotgun/cli/tasks.py +1 -1
  53. shotgun/cli/update.py +16 -2
  54. shotgun/codebase/core/change_detector.py +5 -3
  55. shotgun/codebase/core/code_retrieval.py +4 -2
  56. shotgun/codebase/core/ingestor.py +10 -8
  57. shotgun/codebase/core/manager.py +13 -4
  58. shotgun/codebase/core/nl_query.py +1 -1
  59. shotgun/exceptions.py +32 -0
  60. shotgun/logging_config.py +18 -27
  61. shotgun/main.py +73 -11
  62. shotgun/posthog_telemetry.py +37 -28
  63. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +3 -2
  64. shotgun/sentry_telemetry.py +163 -16
  65. shotgun/settings.py +238 -0
  66. shotgun/telemetry.py +10 -33
  67. shotgun/tui/app.py +243 -43
  68. shotgun/tui/commands/__init__.py +1 -1
  69. shotgun/tui/components/context_indicator.py +179 -0
  70. shotgun/tui/components/mode_indicator.py +70 -0
  71. shotgun/tui/components/status_bar.py +48 -0
  72. shotgun/tui/containers.py +91 -0
  73. shotgun/tui/dependencies.py +39 -0
  74. shotgun/tui/protocols.py +45 -0
  75. shotgun/tui/screens/chat/__init__.py +5 -0
  76. shotgun/tui/screens/chat/chat.tcss +54 -0
  77. shotgun/tui/screens/chat/chat_screen.py +1254 -0
  78. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +64 -0
  79. shotgun/tui/screens/chat/codebase_index_selection.py +12 -0
  80. shotgun/tui/screens/chat/help_text.py +40 -0
  81. shotgun/tui/screens/chat/prompt_history.py +48 -0
  82. shotgun/tui/screens/chat.tcss +11 -0
  83. shotgun/tui/screens/chat_screen/command_providers.py +78 -2
  84. shotgun/tui/screens/chat_screen/history/__init__.py +22 -0
  85. shotgun/tui/screens/chat_screen/history/agent_response.py +66 -0
  86. shotgun/tui/screens/chat_screen/history/chat_history.py +115 -0
  87. shotgun/tui/screens/chat_screen/history/formatters.py +115 -0
  88. shotgun/tui/screens/chat_screen/history/partial_response.py +43 -0
  89. shotgun/tui/screens/chat_screen/history/user_question.py +42 -0
  90. shotgun/tui/screens/confirmation_dialog.py +151 -0
  91. shotgun/tui/screens/feedback.py +4 -4
  92. shotgun/tui/screens/github_issue.py +102 -0
  93. shotgun/tui/screens/model_picker.py +49 -24
  94. shotgun/tui/screens/onboarding.py +431 -0
  95. shotgun/tui/screens/pipx_migration.py +153 -0
  96. shotgun/tui/screens/provider_config.py +50 -27
  97. shotgun/tui/screens/shotgun_auth.py +2 -2
  98. shotgun/tui/screens/welcome.py +14 -11
  99. shotgun/tui/services/__init__.py +5 -0
  100. shotgun/tui/services/conversation_service.py +184 -0
  101. shotgun/tui/state/__init__.py +7 -0
  102. shotgun/tui/state/processing_state.py +185 -0
  103. shotgun/tui/utils/mode_progress.py +14 -7
  104. shotgun/tui/widgets/__init__.py +5 -0
  105. shotgun/tui/widgets/widget_coordinator.py +263 -0
  106. shotgun/utils/file_system_utils.py +22 -2
  107. shotgun/utils/marketing.py +110 -0
  108. shotgun/utils/update_checker.py +69 -14
  109. shotgun_sh-0.2.17.dist-info/METADATA +465 -0
  110. shotgun_sh-0.2.17.dist-info/RECORD +194 -0
  111. {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.2.17.dist-info}/entry_points.txt +1 -0
  112. {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.2.17.dist-info}/licenses/LICENSE +1 -1
  113. shotgun/tui/screens/chat.py +0 -996
  114. shotgun/tui/screens/chat_screen/history.py +0 -335
  115. shotgun_sh-0.2.8.dev2.dist-info/METADATA +0 -126
  116. shotgun_sh-0.2.8.dev2.dist-info/RECORD +0 -155
  117. {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.2.17.dist-info}/WHEEL +0 -0
@@ -40,13 +40,30 @@ from pydantic_ai.messages import (
40
40
  SystemPromptPart,
41
41
  ToolCallPart,
42
42
  ToolCallPartDelta,
43
+ UserPromptPart,
43
44
  )
44
45
  from textual.message import Message
45
46
  from textual.widget import Widget
46
47
 
47
48
  from shotgun.agents.common import add_system_prompt_message, add_system_status_message
48
- from shotgun.agents.config.models import KeyProvider
49
- from shotgun.agents.models import AgentResponse, AgentType, FileOperation
49
+ from shotgun.agents.config.models import (
50
+ KeyProvider,
51
+ ModelConfig,
52
+ ModelName,
53
+ ProviderType,
54
+ )
55
+ from shotgun.agents.context_analyzer import (
56
+ ContextAnalysis,
57
+ ContextAnalyzer,
58
+ ContextCompositionTelemetry,
59
+ ContextFormatter,
60
+ )
61
+ from shotgun.agents.models import (
62
+ AgentResponse,
63
+ AgentType,
64
+ FileOperation,
65
+ FileOperationTracker,
66
+ )
50
67
  from shotgun.posthog_telemetry import track_event
51
68
  from shotgun.tui.screens.chat_screen.hint_message import HintMessage
52
69
  from shotgun.utils.source_detection import detect_source
@@ -149,6 +166,44 @@ class ClarifyingQuestionsMessage(Message):
149
166
  self.response_text = response_text
150
167
 
151
168
 
169
+ class CompactionStartedMessage(Message):
170
+ """Event posted when conversation compaction starts."""
171
+
172
+
173
+ class CompactionCompletedMessage(Message):
174
+ """Event posted when conversation compaction completes."""
175
+
176
+
177
+ class AgentStreamingStarted(Message):
178
+ """Event posted when agent starts streaming responses."""
179
+
180
+
181
+ class AgentStreamingCompleted(Message):
182
+ """Event posted when agent finishes streaming responses."""
183
+
184
+
185
+ @dataclass(frozen=True)
186
+ class ModelConfigUpdated:
187
+ """Data returned when AI model configuration changes.
188
+
189
+ Used as a return value from ModelPickerScreen to communicate model
190
+ selection back to the calling screen.
191
+
192
+ Attributes:
193
+ old_model: Previous model name (None if first selection)
194
+ new_model: New model name
195
+ provider: LLM provider (OpenAI, Anthropic, Google)
196
+ key_provider: Authentication method (BYOK or Shotgun)
197
+ model_config: Complete model configuration
198
+ """
199
+
200
+ old_model: ModelName | None
201
+ new_model: ModelName
202
+ provider: ProviderType
203
+ key_provider: KeyProvider
204
+ model_config: ModelConfig
205
+
206
+
152
207
  @dataclass(slots=True)
153
208
  class _PartialStreamState:
154
209
  """Tracks streamed messages while handling a single agent run."""
@@ -180,7 +235,7 @@ class AgentManager(Widget):
180
235
  self.deps = deps
181
236
 
182
237
  # Create AgentRuntimeOptions from deps for agent creation
183
- agent_runtime_options = AgentRuntimeOptions(
238
+ self._agent_runtime_options = AgentRuntimeOptions(
184
239
  interactive_mode=self.deps.interactive_mode,
185
240
  working_directory=self.deps.working_directory,
186
241
  is_tui_context=self.deps.is_tui_context,
@@ -189,22 +244,18 @@ class AgentManager(Widget):
189
244
  tasks=self.deps.tasks,
190
245
  )
191
246
 
192
- # Initialize all agents and store their specific deps
193
- self.research_agent, self.research_deps = create_research_agent(
194
- agent_runtime_options=agent_runtime_options
195
- )
196
- self.plan_agent, self.plan_deps = create_plan_agent(
197
- agent_runtime_options=agent_runtime_options
198
- )
199
- self.tasks_agent, self.tasks_deps = create_tasks_agent(
200
- agent_runtime_options=agent_runtime_options
201
- )
202
- self.specify_agent, self.specify_deps = create_specify_agent(
203
- agent_runtime_options=agent_runtime_options
204
- )
205
- self.export_agent, self.export_deps = create_export_agent(
206
- agent_runtime_options=agent_runtime_options
207
- )
247
+ # Lazy initialization - agents created on first access
248
+ self._research_agent: Agent[AgentDeps, AgentResponse] | None = None
249
+ self._research_deps: AgentDeps | None = None
250
+ self._plan_agent: Agent[AgentDeps, AgentResponse] | None = None
251
+ self._plan_deps: AgentDeps | None = None
252
+ self._tasks_agent: Agent[AgentDeps, AgentResponse] | None = None
253
+ self._tasks_deps: AgentDeps | None = None
254
+ self._specify_agent: Agent[AgentDeps, AgentResponse] | None = None
255
+ self._specify_deps: AgentDeps | None = None
256
+ self._export_agent: Agent[AgentDeps, AgentResponse] | None = None
257
+ self._export_deps: AgentDeps | None = None
258
+ self._agents_initialized = False
208
259
 
209
260
  # Track current active agent
210
261
  self._current_agent_type: AgentType = initial_type
@@ -219,6 +270,119 @@ class AgentManager(Widget):
219
270
  self._qa_questions: list[str] | None = None
220
271
  self._qa_mode_active: bool = False
221
272
 
273
+ async def _ensure_agents_initialized(self) -> None:
274
+ """Ensure all agents are initialized (lazy initialization)."""
275
+ if self._agents_initialized:
276
+ return
277
+
278
+ # Initialize all agents asynchronously
279
+ self._research_agent, self._research_deps = await create_research_agent(
280
+ agent_runtime_options=self._agent_runtime_options
281
+ )
282
+ self._plan_agent, self._plan_deps = await create_plan_agent(
283
+ agent_runtime_options=self._agent_runtime_options
284
+ )
285
+ self._tasks_agent, self._tasks_deps = await create_tasks_agent(
286
+ agent_runtime_options=self._agent_runtime_options
287
+ )
288
+ self._specify_agent, self._specify_deps = await create_specify_agent(
289
+ agent_runtime_options=self._agent_runtime_options
290
+ )
291
+ self._export_agent, self._export_deps = await create_export_agent(
292
+ agent_runtime_options=self._agent_runtime_options
293
+ )
294
+ self._agents_initialized = True
295
+
296
+ @property
297
+ def research_agent(self) -> Agent[AgentDeps, AgentResponse]:
298
+ """Get research agent (must call _ensure_agents_initialized first)."""
299
+ if self._research_agent is None:
300
+ raise RuntimeError(
301
+ "Agents not initialized. Call _ensure_agents_initialized() first."
302
+ )
303
+ return self._research_agent
304
+
305
+ @property
306
+ def research_deps(self) -> AgentDeps:
307
+ """Get research deps (must call _ensure_agents_initialized first)."""
308
+ if self._research_deps is None:
309
+ raise RuntimeError(
310
+ "Agents not initialized. Call _ensure_agents_initialized() first."
311
+ )
312
+ return self._research_deps
313
+
314
+ @property
315
+ def plan_agent(self) -> Agent[AgentDeps, AgentResponse]:
316
+ """Get plan agent (must call _ensure_agents_initialized first)."""
317
+ if self._plan_agent is None:
318
+ raise RuntimeError(
319
+ "Agents not initialized. Call _ensure_agents_initialized() first."
320
+ )
321
+ return self._plan_agent
322
+
323
+ @property
324
+ def plan_deps(self) -> AgentDeps:
325
+ """Get plan deps (must call _ensure_agents_initialized first)."""
326
+ if self._plan_deps is None:
327
+ raise RuntimeError(
328
+ "Agents not initialized. Call _ensure_agents_initialized() first."
329
+ )
330
+ return self._plan_deps
331
+
332
+ @property
333
+ def tasks_agent(self) -> Agent[AgentDeps, AgentResponse]:
334
+ """Get tasks agent (must call _ensure_agents_initialized first)."""
335
+ if self._tasks_agent is None:
336
+ raise RuntimeError(
337
+ "Agents not initialized. Call _ensure_agents_initialized() first."
338
+ )
339
+ return self._tasks_agent
340
+
341
+ @property
342
+ def tasks_deps(self) -> AgentDeps:
343
+ """Get tasks deps (must call _ensure_agents_initialized first)."""
344
+ if self._tasks_deps is None:
345
+ raise RuntimeError(
346
+ "Agents not initialized. Call _ensure_agents_initialized() first."
347
+ )
348
+ return self._tasks_deps
349
+
350
+ @property
351
+ def specify_agent(self) -> Agent[AgentDeps, AgentResponse]:
352
+ """Get specify agent (must call _ensure_agents_initialized first)."""
353
+ if self._specify_agent is None:
354
+ raise RuntimeError(
355
+ "Agents not initialized. Call _ensure_agents_initialized() first."
356
+ )
357
+ return self._specify_agent
358
+
359
+ @property
360
+ def specify_deps(self) -> AgentDeps:
361
+ """Get specify deps (must call _ensure_agents_initialized first)."""
362
+ if self._specify_deps is None:
363
+ raise RuntimeError(
364
+ "Agents not initialized. Call _ensure_agents_initialized() first."
365
+ )
366
+ return self._specify_deps
367
+
368
+ @property
369
+ def export_agent(self) -> Agent[AgentDeps, AgentResponse]:
370
+ """Get export agent (must call _ensure_agents_initialized first)."""
371
+ if self._export_agent is None:
372
+ raise RuntimeError(
373
+ "Agents not initialized. Call _ensure_agents_initialized() first."
374
+ )
375
+ return self._export_agent
376
+
377
+ @property
378
+ def export_deps(self) -> AgentDeps:
379
+ """Get export deps (must call _ensure_agents_initialized first)."""
380
+ if self._export_deps is None:
381
+ raise RuntimeError(
382
+ "Agents not initialized. Call _ensure_agents_initialized() first."
383
+ )
384
+ return self._export_deps
385
+
222
386
  @property
223
387
  def current_agent(self) -> Agent[AgentDeps, AgentResponse]:
224
388
  """Get the currently active agent.
@@ -370,6 +534,9 @@ class AgentManager(Widget):
370
534
  Returns:
371
535
  The agent run result.
372
536
  """
537
+ # Ensure agents are initialized before running
538
+ await self._ensure_agents_initialized()
539
+
373
540
  logger.info(f"Running agent {self._current_agent_type.value}")
374
541
  # Use merged deps (shared state + agent-specific system prompt) if not provided
375
542
  if deps is None:
@@ -382,19 +549,11 @@ class AgentManager(Widget):
382
549
  # Clear file tracker before each run to track only this run's operations
383
550
  deps.file_tracker.clear()
384
551
 
385
- # Add user prompt if present (will be shown immediately via post_messages_updated)
386
- if prompt:
387
- user_request = ModelRequest.user_text_prompt(prompt)
388
- self.ui_message_history.append(user_request)
389
-
390
- # Always post update before run to show user message (or current state if no prompt)
391
- self._post_messages_updated()
552
+ # Don't manually add the user prompt - Pydantic AI will include it in result.new_messages()
553
+ # This prevents duplicates and confusion with incremental mounting
392
554
 
393
- # Save history WITHOUT the just-added prompt to avoid duplicates
394
- # (result.new_messages() will include the prompt)
395
- original_messages = (
396
- self.ui_message_history[:-1] if prompt else self.ui_message_history.copy()
397
- )
555
+ # Save current message history before the run
556
+ original_messages = self.ui_message_history.copy()
398
557
 
399
558
  # Start with persistent message history
400
559
  message_history = self.message_history
@@ -562,11 +721,35 @@ class AgentManager(Widget):
562
721
  },
563
722
  )
564
723
 
565
- # Always add the agent's response messages to maintain conversation history
566
- self.ui_message_history = original_messages + cast(
724
+ # Merge agent's response messages, avoiding duplicates
725
+ # The TUI may have already added the user prompt, so check for it
726
+ new_messages = cast(
567
727
  list[ModelRequest | ModelResponse | HintMessage], result.new_messages()
568
728
  )
569
729
 
730
+ # Deduplicate: skip user prompts that are already in original_messages
731
+ deduplicated_new_messages = []
732
+ for msg in new_messages:
733
+ # Check if this is a user prompt that's already in original_messages
734
+ if isinstance(msg, ModelRequest) and any(
735
+ isinstance(part, UserPromptPart) for part in msg.parts
736
+ ):
737
+ # Check if an identical user prompt is already in original_messages
738
+ already_exists = any(
739
+ isinstance(existing, ModelRequest)
740
+ and any(isinstance(p, UserPromptPart) for p in existing.parts)
741
+ and existing.parts == msg.parts
742
+ for existing in original_messages[
743
+ -5:
744
+ ] # Check last 5 messages for efficiency
745
+ )
746
+ if already_exists:
747
+ continue # Skip this duplicate user prompt
748
+
749
+ deduplicated_new_messages.append(msg)
750
+
751
+ self.ui_message_history = original_messages + deduplicated_new_messages
752
+
570
753
  # Get file operations early so we can use them for contextual messages
571
754
  file_operations = deps.file_tracker.operations.copy()
572
755
  self.recently_change_files = file_operations
@@ -591,6 +774,12 @@ class AgentManager(Widget):
591
774
  HintMessage(message=agent_response.response)
592
775
  )
593
776
 
777
+ # Add file operation hints before questions (so they appear first in UI)
778
+ if file_operations:
779
+ file_hint = self._create_file_operation_hint(file_operations)
780
+ if file_hint:
781
+ self.ui_message_history.append(HintMessage(message=file_hint))
782
+
594
783
  if len(agent_response.clarifying_questions) == 1:
595
784
  # Single question - treat as non-blocking suggestion, DON'T enter Q&A mode
596
785
  self.ui_message_history.append(
@@ -626,11 +815,9 @@ class AgentManager(Widget):
626
815
  )
627
816
  )
628
817
 
629
- # Post UI update with hint messages and file operations
630
- logger.debug(
631
- "Posting UI update for Q&A mode with hint messages and file operations"
632
- )
633
- self._post_messages_updated(file_operations)
818
+ # Post UI update with hint messages (file operations will be posted after compaction)
819
+ logger.debug("Posting UI update for Q&A mode with hint messages")
820
+ self._post_messages_updated([])
634
821
  else:
635
822
  # No clarifying questions - show the response or a default success message
636
823
  if agent_response.response and agent_response.response.strip():
@@ -665,19 +852,31 @@ class AgentManager(Widget):
665
852
  )
666
853
 
667
854
  # Post UI update immediately so user sees the response without delay
668
- logger.debug(
669
- "Posting immediate UI update with hint message and file operations"
670
- )
671
- self._post_messages_updated(file_operations)
855
+ # (file operations will be posted after compaction to avoid duplicates)
856
+ logger.debug("Posting immediate UI update with hint message")
857
+ self._post_messages_updated([])
672
858
 
673
859
  # Apply compaction to persistent message history to prevent cascading growth
674
860
  all_messages = result.all_messages()
861
+ messages_before_compaction = len(all_messages)
862
+ compaction_occurred = False
863
+
675
864
  try:
676
865
  logger.debug(
677
866
  "Starting message history compaction",
678
867
  extra={"message_count": len(all_messages)},
679
868
  )
869
+ # Notify UI that compaction is starting
870
+ self.post_message(CompactionStartedMessage())
871
+
680
872
  self.message_history = await apply_persistent_compaction(all_messages, deps)
873
+
874
+ # Track if compaction actually modified the history
875
+ compaction_occurred = len(self.message_history) != len(all_messages)
876
+
877
+ # Notify UI that compaction is complete
878
+ self.post_message(CompactionCompletedMessage())
879
+
681
880
  logger.debug(
682
881
  "Completed message history compaction",
683
882
  extra={
@@ -699,9 +898,17 @@ class AgentManager(Widget):
699
898
  # Fallback: use uncompacted messages to prevent data loss
700
899
  self.message_history = all_messages
701
900
 
901
+ # Track context composition telemetry
902
+ await self._track_context_analysis(
903
+ compaction_occurred=compaction_occurred,
904
+ messages_before_compaction=messages_before_compaction
905
+ if compaction_occurred
906
+ else None,
907
+ )
908
+
702
909
  usage = result.usage()
703
910
  if hasattr(deps, "llm_model") and deps.llm_model is not None:
704
- deps.usage_manager.add_usage(
911
+ await deps.usage_manager.add_usage(
705
912
  usage, model_name=deps.llm_model.name, provider=deps.llm_model.provider
706
913
  )
707
914
  else:
@@ -710,8 +917,13 @@ class AgentManager(Widget):
710
917
  extra={"agent_mode": self._current_agent_type.value},
711
918
  )
712
919
 
713
- # UI updates are now posted immediately in each branch (Q&A or non-Q&A)
714
- # before compaction, so no duplicate posting needed here
920
+ # Post final UI update after compaction completes
921
+ # This ensures widgets that depend on message_history (like context indicator)
922
+ # receive the updated history after compaction
923
+ logger.debug(
924
+ "Posting final UI update after compaction with updated message_history"
925
+ )
926
+ self._post_messages_updated(file_operations)
715
927
 
716
928
  return result
717
929
 
@@ -722,6 +934,9 @@ class AgentManager(Widget):
722
934
  ) -> None:
723
935
  """Process streamed events and forward partial updates to the UI."""
724
936
 
937
+ # Notify UI that streaming has started
938
+ self.post_message(AgentStreamingStarted())
939
+
725
940
  state = self._stream_state
726
941
  if state is None:
727
942
  state = self._stream_state = _PartialStreamState()
@@ -900,6 +1115,9 @@ class AgentManager(Widget):
900
1115
  self._post_partial_message(True)
901
1116
  state.current_response = None
902
1117
 
1118
+ # Notify UI that streaming has completed
1119
+ self.post_message(AgentStreamingCompleted())
1120
+
903
1121
  def _build_partial_response(
904
1122
  self, parts: list[ModelResponsePart | ToolCallPartDelta]
905
1123
  ) -> ModelResponse | None:
@@ -927,6 +1145,38 @@ class AgentManager(Widget):
927
1145
  )
928
1146
  )
929
1147
 
1148
+ def _create_file_operation_hint(
1149
+ self, file_operations: list[FileOperation]
1150
+ ) -> str | None:
1151
+ """Create a hint message for file operations.
1152
+
1153
+ Args:
1154
+ file_operations: List of file operations to create a hint for
1155
+
1156
+ Returns:
1157
+ Hint message string or None if no operations
1158
+ """
1159
+ if not file_operations:
1160
+ return None
1161
+
1162
+ tracker = FileOperationTracker(operations=file_operations)
1163
+ display_path = tracker.get_display_path()
1164
+
1165
+ if not display_path:
1166
+ return None
1167
+
1168
+ path_obj = Path(display_path)
1169
+
1170
+ if len(file_operations) == 1:
1171
+ return f"📝 Modified: `{display_path}`"
1172
+ else:
1173
+ num_files = len({op.file_path for op in file_operations})
1174
+ if path_obj.is_dir():
1175
+ return f"📁 Modified {num_files} files in: `{display_path}`"
1176
+ else:
1177
+ # Common path is a file, show parent directory
1178
+ return f"📁 Modified {num_files} files in: `{path_obj.parent}`"
1179
+
930
1180
  def _post_messages_updated(
931
1181
  self, file_operations: list[FileOperation] | None = None
932
1182
  ) -> None:
@@ -988,6 +1238,62 @@ class AgentManager(Widget):
988
1238
  def get_usage_hint(self) -> str | None:
989
1239
  return self.deps.usage_manager.build_usage_hint()
990
1240
 
1241
+ async def get_context_hint(self) -> str | None:
1242
+ """Get conversation context analysis as a formatted hint.
1243
+
1244
+ Returns:
1245
+ Markdown-formatted string with context composition statistics, or None if unavailable
1246
+ """
1247
+ analysis = await self.get_context_analysis()
1248
+ if analysis:
1249
+ return ContextFormatter.format_markdown(analysis)
1250
+ return None
1251
+
1252
+ async def get_context_analysis(self) -> ContextAnalysis | None:
1253
+ """Get conversation context analysis as structured data.
1254
+
1255
+ Returns:
1256
+ ContextAnalysis object with token usage data, or None if unavailable
1257
+ """
1258
+
1259
+ try:
1260
+ analyzer = ContextAnalyzer(self.deps.llm_model)
1261
+ return await analyzer.analyze_conversation(
1262
+ self.message_history, self.ui_message_history
1263
+ )
1264
+ except Exception as e:
1265
+ logger.error(f"Failed to generate context analysis: {e}", exc_info=True)
1266
+ return None
1267
+
1268
+ async def _track_context_analysis(
1269
+ self,
1270
+ compaction_occurred: bool = False,
1271
+ messages_before_compaction: int | None = None,
1272
+ ) -> None:
1273
+ """Track context composition telemetry to PostHog.
1274
+
1275
+ Args:
1276
+ compaction_occurred: Whether compaction was applied
1277
+ messages_before_compaction: Message count before compaction, if it occurred
1278
+ """
1279
+ try:
1280
+ analyzer = ContextAnalyzer(self.deps.llm_model)
1281
+ analysis = await analyzer.analyze_conversation(
1282
+ self.message_history, self.ui_message_history
1283
+ )
1284
+
1285
+ # Create telemetry model from analysis
1286
+ telemetry = ContextCompositionTelemetry.from_analysis(
1287
+ analysis,
1288
+ compaction_occurred=compaction_occurred,
1289
+ messages_before_compaction=messages_before_compaction,
1290
+ )
1291
+
1292
+ # Send to PostHog using model_dump() for dict conversion
1293
+ track_event("agent_context_composition", telemetry.model_dump())
1294
+ except Exception as e:
1295
+ logger.warning(f"Failed to track context analysis: {e}")
1296
+
991
1297
  def get_conversation_state(self) -> "ConversationState":
992
1298
  """Get the current conversation state.
993
1299
 
@@ -1035,7 +1341,9 @@ class AgentManager(Widget):
1035
1341
  __all__ = [
1036
1342
  "AgentManager",
1037
1343
  "AgentType",
1344
+ "ClarifyingQuestionsMessage",
1345
+ "CompactionCompletedMessage",
1346
+ "CompactionStartedMessage",
1038
1347
  "MessageHistoryUpdated",
1039
1348
  "PartialResponseMessage",
1040
- "ClarifyingQuestionsMessage",
1041
1349
  ]
shotgun/agents/common.py CHANGED
@@ -4,6 +4,7 @@ from collections.abc import Callable
4
4
  from pathlib import Path
5
5
  from typing import Any
6
6
 
7
+ import aiofiles
7
8
  from pydantic_ai import (
8
9
  Agent,
9
10
  RunContext,
@@ -68,7 +69,7 @@ async def add_system_status_message(
68
69
  existing_files = get_agent_existing_files(deps.agent_mode)
69
70
 
70
71
  # Extract table of contents from the agent's markdown file
71
- markdown_toc = extract_markdown_toc(deps.agent_mode)
72
+ markdown_toc = await extract_markdown_toc(deps.agent_mode)
72
73
 
73
74
  # Get current datetime with timezone information
74
75
  dt_context = get_datetime_context()
@@ -94,7 +95,7 @@ async def add_system_status_message(
94
95
  return message_history
95
96
 
96
97
 
97
- def create_base_agent(
98
+ async def create_base_agent(
98
99
  system_prompt_fn: Callable[[RunContext[AgentDeps]], str],
99
100
  agent_runtime_options: AgentRuntimeOptions,
100
101
  load_codebase_understanding_tools: bool = True,
@@ -119,7 +120,7 @@ def create_base_agent(
119
120
 
120
121
  # Get configured model or fall back to first available provider
121
122
  try:
122
- model_config = get_provider_model(provider)
123
+ model_config = await get_provider_model(provider)
123
124
  provider_name = model_config.provider
124
125
  logger.debug(
125
126
  "🤖 Creating agent with configured %s model: %s",
@@ -194,7 +195,7 @@ def create_base_agent(
194
195
  return agent, deps
195
196
 
196
197
 
197
- def _extract_file_toc_content(
198
+ async def _extract_file_toc_content(
198
199
  file_path: Path, max_depth: int | None = None, max_chars: int = 500
199
200
  ) -> str | None:
200
201
  """Extract TOC from a single file with depth and character limits.
@@ -211,7 +212,8 @@ def _extract_file_toc_content(
211
212
  return None
212
213
 
213
214
  try:
214
- content = file_path.read_text(encoding="utf-8")
215
+ async with aiofiles.open(file_path, encoding="utf-8") as f:
216
+ content = await f.read()
215
217
  lines = content.split("\n")
216
218
 
217
219
  # Extract headings
@@ -257,7 +259,7 @@ def _extract_file_toc_content(
257
259
  return None
258
260
 
259
261
 
260
- def extract_markdown_toc(agent_mode: AgentType | None) -> str | None:
262
+ async def extract_markdown_toc(agent_mode: AgentType | None) -> str | None:
261
263
  """Extract TOCs from current and prior agents' files in the pipeline.
262
264
 
263
265
  Shows full TOC of agent's own file and high-level summaries of prior agents'
@@ -309,7 +311,9 @@ def extract_markdown_toc(agent_mode: AgentType | None) -> str | None:
309
311
  for prior_file in config.prior_files:
310
312
  file_path = base_path / prior_file
311
313
  # Only show # and ## headings from prior files, max 500 chars each
312
- prior_toc = _extract_file_toc_content(file_path, max_depth=2, max_chars=500)
314
+ prior_toc = await _extract_file_toc_content(
315
+ file_path, max_depth=2, max_chars=500
316
+ )
313
317
  if prior_toc:
314
318
  # Add section with XML tags
315
319
  toc_sections.append(
@@ -321,7 +325,9 @@ def extract_markdown_toc(agent_mode: AgentType | None) -> str | None:
321
325
  # Extract TOC from own file (full detail)
322
326
  if config.own_file:
323
327
  own_path = base_path / config.own_file
324
- own_toc = _extract_file_toc_content(own_path, max_depth=None, max_chars=2000)
328
+ own_toc = await _extract_file_toc_content(
329
+ own_path, max_depth=None, max_chars=2000
330
+ )
325
331
  if own_toc:
326
332
  # Put own file TOC at the beginning with XML tags
327
333
  toc_sections.insert(
@@ -24,11 +24,5 @@ ANTHROPIC_PROVIDER = ConfigSection.ANTHROPIC.value
24
24
  GOOGLE_PROVIDER = ConfigSection.GOOGLE.value
25
25
  SHOTGUN_PROVIDER = ConfigSection.SHOTGUN.value
26
26
 
27
- # Environment variable names
28
- OPENAI_API_KEY_ENV = "OPENAI_API_KEY"
29
- ANTHROPIC_API_KEY_ENV = "ANTHROPIC_API_KEY"
30
- GEMINI_API_KEY_ENV = "GEMINI_API_KEY"
31
- SHOTGUN_API_KEY_ENV = "SHOTGUN_API_KEY"
32
-
33
27
  # Token limits
34
28
  MEDIUM_TEXT_8K_TOKENS = 8192 # Default max_tokens for web search requests