shotgun-sh 0.2.29.dev2__py3-none-any.whl → 0.6.1.dev1__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.

Potentially problematic release.


This version of shotgun-sh might be problematic. Click here for more details.

Files changed (161) hide show
  1. shotgun/agents/agent_manager.py +497 -30
  2. shotgun/agents/cancellation.py +103 -0
  3. shotgun/agents/common.py +90 -77
  4. shotgun/agents/config/README.md +0 -1
  5. shotgun/agents/config/manager.py +52 -8
  6. shotgun/agents/config/models.py +48 -45
  7. shotgun/agents/config/provider.py +44 -29
  8. shotgun/agents/conversation/history/file_content_deduplication.py +66 -43
  9. shotgun/agents/conversation/history/token_counting/base.py +51 -9
  10. shotgun/agents/export.py +12 -13
  11. shotgun/agents/file_read.py +176 -0
  12. shotgun/agents/messages.py +15 -3
  13. shotgun/agents/models.py +90 -2
  14. shotgun/agents/plan.py +12 -13
  15. shotgun/agents/research.py +13 -10
  16. shotgun/agents/router/__init__.py +47 -0
  17. shotgun/agents/router/models.py +384 -0
  18. shotgun/agents/router/router.py +185 -0
  19. shotgun/agents/router/tools/__init__.py +18 -0
  20. shotgun/agents/router/tools/delegation_tools.py +557 -0
  21. shotgun/agents/router/tools/plan_tools.py +403 -0
  22. shotgun/agents/runner.py +17 -2
  23. shotgun/agents/specify.py +12 -13
  24. shotgun/agents/tasks.py +12 -13
  25. shotgun/agents/tools/__init__.py +8 -0
  26. shotgun/agents/tools/codebase/directory_lister.py +27 -39
  27. shotgun/agents/tools/codebase/file_read.py +26 -35
  28. shotgun/agents/tools/codebase/query_graph.py +9 -0
  29. shotgun/agents/tools/codebase/retrieve_code.py +9 -0
  30. shotgun/agents/tools/file_management.py +81 -3
  31. shotgun/agents/tools/file_read_tools/__init__.py +7 -0
  32. shotgun/agents/tools/file_read_tools/multimodal_file_read.py +167 -0
  33. shotgun/agents/tools/markdown_tools/__init__.py +62 -0
  34. shotgun/agents/tools/markdown_tools/insert_section.py +148 -0
  35. shotgun/agents/tools/markdown_tools/models.py +86 -0
  36. shotgun/agents/tools/markdown_tools/remove_section.py +114 -0
  37. shotgun/agents/tools/markdown_tools/replace_section.py +119 -0
  38. shotgun/agents/tools/markdown_tools/utils.py +453 -0
  39. shotgun/agents/tools/registry.py +41 -0
  40. shotgun/agents/tools/web_search/__init__.py +1 -2
  41. shotgun/agents/tools/web_search/gemini.py +1 -3
  42. shotgun/agents/tools/web_search/openai.py +42 -23
  43. shotgun/attachments/__init__.py +41 -0
  44. shotgun/attachments/errors.py +60 -0
  45. shotgun/attachments/models.py +107 -0
  46. shotgun/attachments/parser.py +257 -0
  47. shotgun/attachments/processor.py +193 -0
  48. shotgun/cli/clear.py +2 -2
  49. shotgun/cli/codebase/commands.py +181 -65
  50. shotgun/cli/compact.py +2 -2
  51. shotgun/cli/context.py +2 -2
  52. shotgun/cli/run.py +90 -0
  53. shotgun/cli/spec/backup.py +2 -1
  54. shotgun/cli/spec/commands.py +2 -0
  55. shotgun/cli/spec/models.py +18 -0
  56. shotgun/cli/spec/pull_service.py +122 -68
  57. shotgun/codebase/__init__.py +2 -0
  58. shotgun/codebase/benchmarks/__init__.py +35 -0
  59. shotgun/codebase/benchmarks/benchmark_runner.py +309 -0
  60. shotgun/codebase/benchmarks/exporters.py +119 -0
  61. shotgun/codebase/benchmarks/formatters/__init__.py +49 -0
  62. shotgun/codebase/benchmarks/formatters/base.py +34 -0
  63. shotgun/codebase/benchmarks/formatters/json_formatter.py +106 -0
  64. shotgun/codebase/benchmarks/formatters/markdown.py +136 -0
  65. shotgun/codebase/benchmarks/models.py +129 -0
  66. shotgun/codebase/core/__init__.py +4 -0
  67. shotgun/codebase/core/call_resolution.py +91 -0
  68. shotgun/codebase/core/change_detector.py +11 -6
  69. shotgun/codebase/core/errors.py +159 -0
  70. shotgun/codebase/core/extractors/__init__.py +23 -0
  71. shotgun/codebase/core/extractors/base.py +138 -0
  72. shotgun/codebase/core/extractors/factory.py +63 -0
  73. shotgun/codebase/core/extractors/go/__init__.py +7 -0
  74. shotgun/codebase/core/extractors/go/extractor.py +122 -0
  75. shotgun/codebase/core/extractors/javascript/__init__.py +7 -0
  76. shotgun/codebase/core/extractors/javascript/extractor.py +132 -0
  77. shotgun/codebase/core/extractors/protocol.py +109 -0
  78. shotgun/codebase/core/extractors/python/__init__.py +7 -0
  79. shotgun/codebase/core/extractors/python/extractor.py +141 -0
  80. shotgun/codebase/core/extractors/rust/__init__.py +7 -0
  81. shotgun/codebase/core/extractors/rust/extractor.py +139 -0
  82. shotgun/codebase/core/extractors/types.py +15 -0
  83. shotgun/codebase/core/extractors/typescript/__init__.py +7 -0
  84. shotgun/codebase/core/extractors/typescript/extractor.py +92 -0
  85. shotgun/codebase/core/gitignore.py +252 -0
  86. shotgun/codebase/core/ingestor.py +644 -354
  87. shotgun/codebase/core/kuzu_compat.py +119 -0
  88. shotgun/codebase/core/language_config.py +239 -0
  89. shotgun/codebase/core/manager.py +256 -46
  90. shotgun/codebase/core/metrics_collector.py +310 -0
  91. shotgun/codebase/core/metrics_types.py +347 -0
  92. shotgun/codebase/core/parallel_executor.py +424 -0
  93. shotgun/codebase/core/work_distributor.py +254 -0
  94. shotgun/codebase/core/worker.py +768 -0
  95. shotgun/codebase/indexing_state.py +86 -0
  96. shotgun/codebase/models.py +94 -0
  97. shotgun/codebase/service.py +13 -0
  98. shotgun/exceptions.py +1 -1
  99. shotgun/main.py +2 -10
  100. shotgun/prompts/agents/export.j2 +2 -0
  101. shotgun/prompts/agents/file_read.j2 +48 -0
  102. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +20 -28
  103. shotgun/prompts/agents/partials/content_formatting.j2 +12 -33
  104. shotgun/prompts/agents/partials/interactive_mode.j2 +9 -32
  105. shotgun/prompts/agents/partials/router_delegation_mode.j2 +35 -0
  106. shotgun/prompts/agents/plan.j2 +43 -1
  107. shotgun/prompts/agents/research.j2 +75 -20
  108. shotgun/prompts/agents/router.j2 +713 -0
  109. shotgun/prompts/agents/specify.j2 +94 -4
  110. shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2 +14 -1
  111. shotgun/prompts/agents/state/system_state.j2 +24 -15
  112. shotgun/prompts/agents/tasks.j2 +77 -23
  113. shotgun/settings.py +44 -0
  114. shotgun/shotgun_web/shared_specs/upload_pipeline.py +38 -0
  115. shotgun/tui/app.py +90 -23
  116. shotgun/tui/commands/__init__.py +9 -1
  117. shotgun/tui/components/attachment_bar.py +87 -0
  118. shotgun/tui/components/mode_indicator.py +120 -25
  119. shotgun/tui/components/prompt_input.py +23 -28
  120. shotgun/tui/components/status_bar.py +5 -4
  121. shotgun/tui/dependencies.py +58 -8
  122. shotgun/tui/protocols.py +37 -0
  123. shotgun/tui/screens/chat/chat.tcss +24 -1
  124. shotgun/tui/screens/chat/chat_screen.py +1374 -211
  125. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +8 -4
  126. shotgun/tui/screens/chat_screen/attachment_hint.py +40 -0
  127. shotgun/tui/screens/chat_screen/command_providers.py +0 -97
  128. shotgun/tui/screens/chat_screen/history/agent_response.py +7 -3
  129. shotgun/tui/screens/chat_screen/history/chat_history.py +49 -6
  130. shotgun/tui/screens/chat_screen/history/formatters.py +75 -15
  131. shotgun/tui/screens/chat_screen/history/partial_response.py +11 -1
  132. shotgun/tui/screens/chat_screen/history/user_question.py +25 -3
  133. shotgun/tui/screens/chat_screen/messages.py +219 -0
  134. shotgun/tui/screens/database_locked_dialog.py +219 -0
  135. shotgun/tui/screens/database_timeout_dialog.py +158 -0
  136. shotgun/tui/screens/kuzu_error_dialog.py +135 -0
  137. shotgun/tui/screens/model_picker.py +14 -9
  138. shotgun/tui/screens/models.py +11 -0
  139. shotgun/tui/screens/shotgun_auth.py +50 -0
  140. shotgun/tui/screens/spec_pull.py +2 -0
  141. shotgun/tui/state/processing_state.py +19 -0
  142. shotgun/tui/utils/mode_progress.py +20 -86
  143. shotgun/tui/widgets/__init__.py +2 -1
  144. shotgun/tui/widgets/approval_widget.py +152 -0
  145. shotgun/tui/widgets/cascade_confirmation_widget.py +203 -0
  146. shotgun/tui/widgets/plan_panel.py +129 -0
  147. shotgun/tui/widgets/step_checkpoint_widget.py +180 -0
  148. shotgun/tui/widgets/widget_coordinator.py +18 -0
  149. shotgun/utils/file_system_utils.py +4 -1
  150. {shotgun_sh-0.2.29.dev2.dist-info → shotgun_sh-0.6.1.dev1.dist-info}/METADATA +88 -34
  151. shotgun_sh-0.6.1.dev1.dist-info/RECORD +292 -0
  152. shotgun/cli/export.py +0 -81
  153. shotgun/cli/plan.py +0 -73
  154. shotgun/cli/research.py +0 -93
  155. shotgun/cli/specify.py +0 -70
  156. shotgun/cli/tasks.py +0 -78
  157. shotgun/tui/screens/onboarding.py +0 -580
  158. shotgun_sh-0.2.29.dev2.dist-info/RECORD +0 -229
  159. {shotgun_sh-0.2.29.dev2.dist-info → shotgun_sh-0.6.1.dev1.dist-info}/WHEEL +0 -0
  160. {shotgun_sh-0.2.29.dev2.dist-info → shotgun_sh-0.6.1.dev1.dist-info}/entry_points.txt +0 -0
  161. {shotgun_sh-0.2.29.dev2.dist-info → shotgun_sh-0.6.1.dev1.dist-info}/licenses/LICENSE +0 -0
@@ -19,8 +19,10 @@ from tenacity import (
19
19
  if TYPE_CHECKING:
20
20
  from shotgun.agents.conversation import ConversationState
21
21
 
22
+ import base64
23
+
22
24
  from pydantic_ai import (
23
- Agent,
25
+ BinaryContent,
24
26
  RunContext,
25
27
  UsageLimits,
26
28
  )
@@ -38,13 +40,16 @@ from pydantic_ai.messages import (
38
40
  PartDeltaEvent,
39
41
  PartStartEvent,
40
42
  SystemPromptPart,
43
+ TextPartDelta,
41
44
  ToolCallPart,
42
45
  ToolCallPartDelta,
46
+ UserContent,
43
47
  UserPromptPart,
44
48
  )
45
49
  from textual.message import Message
46
50
  from textual.widget import Widget
47
51
 
52
+ from shotgun.agents.cancellation import CancellableStreamIterator
48
53
  from shotgun.agents.common import add_system_prompt_message, add_system_status_message
49
54
  from shotgun.agents.config.models import (
50
55
  KeyProvider,
@@ -61,19 +66,25 @@ from shotgun.agents.context_analyzer import (
61
66
  from shotgun.agents.models import (
62
67
  AgentResponse,
63
68
  AgentType,
69
+ AnyAgent,
64
70
  FileOperation,
65
71
  FileOperationTracker,
72
+ RouterAgent,
73
+ ShotgunAgent,
66
74
  )
75
+ from shotgun.attachments import FileAttachment
67
76
  from shotgun.posthog_telemetry import track_event
68
77
  from shotgun.tui.screens.chat_screen.hint_message import HintMessage
69
78
  from shotgun.utils.source_detection import detect_source
70
79
 
71
80
  from .conversation.history.compaction import apply_persistent_compaction
72
81
  from .export import create_export_agent
73
- from .messages import AgentSystemPrompt
82
+ from .messages import AgentSystemPrompt, InternalPromptPart
74
83
  from .models import AgentDeps, AgentRuntimeOptions
75
84
  from .plan import create_plan_agent
76
85
  from .research import create_research_agent
86
+ from .router import create_router_agent
87
+ from .router.models import RouterDeps, RouterMode
77
88
  from .specify import create_specify_agent
78
89
  from .tasks import create_tasks_agent
79
90
 
@@ -166,6 +177,29 @@ class ClarifyingQuestionsMessage(Message):
166
177
  self.response_text = response_text
167
178
 
168
179
 
180
+ class FileRequestPendingMessage(Message):
181
+ """Event posted when agent requests files to be loaded.
182
+
183
+ This triggers the TUI to load the requested files and resume
184
+ the agent with the file contents in the next prompt.
185
+ """
186
+
187
+ def __init__(
188
+ self,
189
+ file_paths: list[str],
190
+ response_text: str,
191
+ ) -> None:
192
+ """Initialize the file request pending message.
193
+
194
+ Args:
195
+ file_paths: List of file paths the agent wants to read
196
+ response_text: The agent's response text before requesting files
197
+ """
198
+ super().__init__()
199
+ self.file_paths = file_paths
200
+ self.response_text = response_text
201
+
202
+
169
203
  class CompactionStartedMessage(Message):
170
204
  """Event posted when conversation compaction starts."""
171
205
 
@@ -174,6 +208,67 @@ class CompactionCompletedMessage(Message):
174
208
  """Event posted when conversation compaction completes."""
175
209
 
176
210
 
211
+ class ToolExecutionStartedMessage(Message):
212
+ """Event posted when a tool starts executing.
213
+
214
+ This allows the UI to update the spinner text to provide feedback
215
+ during long-running tool executions.
216
+ """
217
+
218
+ def __init__(self, spinner_text: str = "Processing...") -> None:
219
+ """Initialize the tool execution started message.
220
+
221
+ Args:
222
+ spinner_text: The spinner message to display
223
+ """
224
+ super().__init__()
225
+ self.spinner_text = spinner_text
226
+
227
+
228
+ class ToolStreamingProgressMessage(Message):
229
+ """Event posted during tool call streaming to show progress.
230
+
231
+ This provides visual feedback while tool arguments are streaming,
232
+ especially useful for long-running writes like file content.
233
+ """
234
+
235
+ def __init__(self, streamed_tokens: int, spinner_text: str) -> None:
236
+ """Initialize the tool streaming progress message.
237
+
238
+ Args:
239
+ streamed_tokens: Approximate number of tokens streamed so far
240
+ spinner_text: The current spinner message to preserve
241
+ """
242
+ super().__init__()
243
+ self.streamed_tokens = streamed_tokens
244
+ self.spinner_text = spinner_text
245
+
246
+
247
+ # Fun spinner messages to show during tool execution
248
+ SPINNER_MESSAGES = [
249
+ "Pontificating...",
250
+ "Ruminating...",
251
+ "Cogitating...",
252
+ "Deliberating...",
253
+ "Contemplating...",
254
+ "Reticulating splines...",
255
+ "Consulting the oracle...",
256
+ "Gathering thoughts...",
257
+ "Processing neurons...",
258
+ "Summoning wisdom...",
259
+ "Brewing ideas...",
260
+ "Polishing pixels...",
261
+ "Herding electrons...",
262
+ "Warming up the flux capacitor...",
263
+ "Consulting ancient tomes...",
264
+ "Channeling the muses...",
265
+ "Percolating possibilities...",
266
+ "Untangling complexity...",
267
+ "Shuffling priorities...",
268
+ "Aligning the stars...",
269
+ ]
270
+
271
+
177
272
  class AgentStreamingStarted(Message):
178
273
  """Event posted when agent starts streaming responses."""
179
274
 
@@ -210,6 +305,11 @@ class _PartialStreamState:
210
305
 
211
306
  messages: list[ModelRequest | ModelResponse] = field(default_factory=list)
212
307
  current_response: ModelResponse | None = None
308
+ # Token counting for tool call streaming progress
309
+ streamed_tokens: int = 0
310
+ current_spinner_text: str = "Processing..."
311
+ # Track last reported tokens to throttle UI updates
312
+ last_reported_tokens: int = 0
213
313
 
214
314
 
215
315
  class AgentManager(Widget):
@@ -245,16 +345,18 @@ class AgentManager(Widget):
245
345
  )
246
346
 
247
347
  # Lazy initialization - agents created on first access
248
- self._research_agent: Agent[AgentDeps, AgentResponse] | None = None
348
+ self._research_agent: ShotgunAgent | None = None
249
349
  self._research_deps: AgentDeps | None = None
250
- self._plan_agent: Agent[AgentDeps, AgentResponse] | None = None
350
+ self._plan_agent: ShotgunAgent | None = None
251
351
  self._plan_deps: AgentDeps | None = None
252
- self._tasks_agent: Agent[AgentDeps, AgentResponse] | None = None
352
+ self._tasks_agent: ShotgunAgent | None = None
253
353
  self._tasks_deps: AgentDeps | None = None
254
- self._specify_agent: Agent[AgentDeps, AgentResponse] | None = None
354
+ self._specify_agent: ShotgunAgent | None = None
255
355
  self._specify_deps: AgentDeps | None = None
256
- self._export_agent: Agent[AgentDeps, AgentResponse] | None = None
356
+ self._export_agent: ShotgunAgent | None = None
257
357
  self._export_deps: AgentDeps | None = None
358
+ self._router_agent: RouterAgent | None = None
359
+ self._router_deps: RouterDeps | None = None
258
360
  self._agents_initialized = False
259
361
 
260
362
  # Track current active agent
@@ -270,6 +372,10 @@ class AgentManager(Widget):
270
372
  self._qa_questions: list[str] | None = None
271
373
  self._qa_mode_active: bool = False
272
374
 
375
+ # File request state for structured output file loading
376
+ self._file_request_pending: bool = False
377
+ self._pending_file_requests: list[str] = []
378
+
273
379
  async def _ensure_agents_initialized(self) -> None:
274
380
  """Ensure all agents are initialized (lazy initialization)."""
275
381
  if self._agents_initialized:
@@ -291,10 +397,13 @@ class AgentManager(Widget):
291
397
  self._export_agent, self._export_deps = await create_export_agent(
292
398
  agent_runtime_options=self._agent_runtime_options
293
399
  )
400
+ self._router_agent, self._router_deps = await create_router_agent(
401
+ agent_runtime_options=self._agent_runtime_options
402
+ )
294
403
  self._agents_initialized = True
295
404
 
296
405
  @property
297
- def research_agent(self) -> Agent[AgentDeps, AgentResponse]:
406
+ def research_agent(self) -> ShotgunAgent:
298
407
  """Get research agent (must call _ensure_agents_initialized first)."""
299
408
  if self._research_agent is None:
300
409
  raise RuntimeError(
@@ -312,7 +421,7 @@ class AgentManager(Widget):
312
421
  return self._research_deps
313
422
 
314
423
  @property
315
- def plan_agent(self) -> Agent[AgentDeps, AgentResponse]:
424
+ def plan_agent(self) -> ShotgunAgent:
316
425
  """Get plan agent (must call _ensure_agents_initialized first)."""
317
426
  if self._plan_agent is None:
318
427
  raise RuntimeError(
@@ -330,7 +439,7 @@ class AgentManager(Widget):
330
439
  return self._plan_deps
331
440
 
332
441
  @property
333
- def tasks_agent(self) -> Agent[AgentDeps, AgentResponse]:
442
+ def tasks_agent(self) -> ShotgunAgent:
334
443
  """Get tasks agent (must call _ensure_agents_initialized first)."""
335
444
  if self._tasks_agent is None:
336
445
  raise RuntimeError(
@@ -348,7 +457,7 @@ class AgentManager(Widget):
348
457
  return self._tasks_deps
349
458
 
350
459
  @property
351
- def specify_agent(self) -> Agent[AgentDeps, AgentResponse]:
460
+ def specify_agent(self) -> ShotgunAgent:
352
461
  """Get specify agent (must call _ensure_agents_initialized first)."""
353
462
  if self._specify_agent is None:
354
463
  raise RuntimeError(
@@ -366,7 +475,7 @@ class AgentManager(Widget):
366
475
  return self._specify_deps
367
476
 
368
477
  @property
369
- def export_agent(self) -> Agent[AgentDeps, AgentResponse]:
478
+ def export_agent(self) -> ShotgunAgent:
370
479
  """Get export agent (must call _ensure_agents_initialized first)."""
371
480
  if self._export_agent is None:
372
481
  raise RuntimeError(
@@ -384,29 +493,114 @@ class AgentManager(Widget):
384
493
  return self._export_deps
385
494
 
386
495
  @property
387
- def current_agent(self) -> Agent[AgentDeps, AgentResponse]:
496
+ def router_agent(self) -> RouterAgent:
497
+ """Get router agent (must call _ensure_agents_initialized first)."""
498
+ if self._router_agent is None:
499
+ raise RuntimeError(
500
+ "Agents not initialized. Call _ensure_agents_initialized() first."
501
+ )
502
+ return self._router_agent
503
+
504
+ @property
505
+ def router_deps(self) -> RouterDeps:
506
+ """Get router deps (must call _ensure_agents_initialized first)."""
507
+ if self._router_deps is None:
508
+ raise RuntimeError(
509
+ "Agents not initialized. Call _ensure_agents_initialized() first."
510
+ )
511
+ return self._router_deps
512
+
513
+ @property
514
+ def current_agent(self) -> AnyAgent:
388
515
  """Get the currently active agent.
389
516
 
390
517
  Returns:
391
- The currently selected agent instance.
518
+ The currently selected agent instance (ShotgunAgent or RouterAgent).
392
519
  """
393
520
  return self._get_agent(self._current_agent_type)
394
521
 
395
- def _get_agent(self, agent_type: AgentType) -> Agent[AgentDeps, AgentResponse]:
522
+ @property
523
+ def file_request_pending(self) -> bool:
524
+ """Check if there's a pending file request."""
525
+ return self._file_request_pending
526
+
527
+ @property
528
+ def pending_file_requests(self) -> list[str]:
529
+ """Get the list of pending file requests."""
530
+ return self._pending_file_requests
531
+
532
+ def process_file_requests(self) -> list[tuple[str, BinaryContent]]:
533
+ """Process pending file requests and return loaded content.
534
+
535
+ This method is called by the TUI after FileRequestPendingMessage is received.
536
+ It loads the requested files as BinaryContent and clears the pending state.
537
+
538
+ Returns:
539
+ List of (file_path, BinaryContent) tuples for files that were successfully loaded.
540
+ """
541
+ if not self._file_request_pending:
542
+ return []
543
+
544
+ # MIME type mapping for supported file types
545
+ mime_types: dict[str, str] = {
546
+ ".pdf": "application/pdf",
547
+ ".png": "image/png",
548
+ ".jpg": "image/jpeg",
549
+ ".jpeg": "image/jpeg",
550
+ ".gif": "image/gif",
551
+ ".webp": "image/webp",
552
+ }
553
+
554
+ loaded_files: list[tuple[str, BinaryContent]] = []
555
+ for file_path_str in self._pending_file_requests:
556
+ try:
557
+ path = Path(file_path_str).expanduser().resolve()
558
+ if not path.exists():
559
+ logger.warning(f"Requested file not found: {path}")
560
+ continue
561
+
562
+ # Get MIME type
563
+ suffix = path.suffix.lower()
564
+ mime_type = mime_types.get(suffix)
565
+ if mime_type is None:
566
+ logger.warning(f"Unsupported file type: {suffix} for {path}")
567
+ continue
568
+
569
+ # Read file and create BinaryContent
570
+ data = path.read_bytes()
571
+ loaded_files.append(
572
+ (str(path), BinaryContent(data=data, media_type=mime_type))
573
+ )
574
+ logger.debug(f"Loaded file: {path} ({len(data)} bytes)")
575
+
576
+ except Exception as e:
577
+ logger.error(f"Error loading file {file_path_str}: {e}")
578
+
579
+ # Clear pending state
580
+ self._file_request_pending = False
581
+ self._pending_file_requests = []
582
+
583
+ logger.info(
584
+ f"Loaded {len(loaded_files)} of {len(self._pending_file_requests)} requested files"
585
+ )
586
+ return loaded_files
587
+
588
+ def _get_agent(self, agent_type: AgentType) -> AnyAgent:
396
589
  """Get agent by type.
397
590
 
398
591
  Args:
399
592
  agent_type: The type of agent to retrieve.
400
593
 
401
594
  Returns:
402
- The requested agent instance.
595
+ The requested agent instance (ShotgunAgent or RouterAgent).
403
596
  """
404
- agent_map = {
597
+ agent_map: dict[AgentType, AnyAgent] = {
405
598
  AgentType.RESEARCH: self.research_agent,
406
599
  AgentType.PLAN: self.plan_agent,
407
600
  AgentType.TASKS: self.tasks_agent,
408
601
  AgentType.SPECIFY: self.specify_agent,
409
602
  AgentType.EXPORT: self.export_agent,
603
+ AgentType.ROUTER: self.router_agent,
410
604
  }
411
605
  return agent_map[agent_type]
412
606
 
@@ -419,12 +613,13 @@ class AgentManager(Widget):
419
613
  Returns:
420
614
  The agent-specific dependencies.
421
615
  """
422
- deps_map = {
616
+ deps_map: dict[AgentType, AgentDeps] = {
423
617
  AgentType.RESEARCH: self.research_deps,
424
618
  AgentType.PLAN: self.plan_deps,
425
619
  AgentType.TASKS: self.tasks_deps,
426
620
  AgentType.SPECIFY: self.specify_deps,
427
621
  AgentType.EXPORT: self.export_deps,
622
+ AgentType.ROUTER: self.router_deps,
428
623
  }
429
624
  return deps_map[agent_type]
430
625
 
@@ -433,6 +628,10 @@ class AgentManager(Widget):
433
628
 
434
629
  This preserves the agent's system_prompt_fn while using shared runtime state.
435
630
 
631
+ For Router agent, returns the shared deps directly (not a copy) because
632
+ Router state (pending_approval, current_plan, etc.) must be shared with
633
+ the TUI for features like plan approval widgets.
634
+
436
635
  Args:
437
636
  agent_type: The type of agent to create merged deps for.
438
637
 
@@ -445,8 +644,14 @@ class AgentManager(Widget):
445
644
  if self.deps is None:
446
645
  raise ValueError("Shared deps is None - this should not happen")
447
646
 
448
- # Create new deps with shared runtime state but agent's system_prompt_fn
449
- # Use a copy of the shared deps and update the system_prompt_fn
647
+ # For Router, use shared deps directly so state mutations are visible to TUI
648
+ # (e.g., pending_approval, current_plan need to be seen by ChatScreen)
649
+ if agent_type == AgentType.ROUTER:
650
+ # Update system_prompt_fn on shared deps in place
651
+ self.deps.system_prompt_fn = agent_deps.system_prompt_fn
652
+ return self.deps
653
+
654
+ # For other agents, create a copy with agent-specific system_prompt_fn
450
655
  merged_deps = self.deps.model_copy(
451
656
  update={"system_prompt_fn": agent_deps.system_prompt_fn}
452
657
  )
@@ -478,8 +683,8 @@ class AgentManager(Widget):
478
683
  )
479
684
  async def _run_agent_with_retry(
480
685
  self,
481
- agent: Agent[AgentDeps, AgentResponse],
482
- prompt: str | None,
686
+ agent: AnyAgent,
687
+ prompt: str | Sequence[UserContent] | None,
483
688
  deps: AgentDeps,
484
689
  usage_limits: UsageLimits | None,
485
690
  message_history: list[ModelMessage],
@@ -489,9 +694,10 @@ class AgentManager(Widget):
489
694
  """Run agent with automatic retry on transient errors.
490
695
 
491
696
  Args:
492
- agent: The agent to run.
493
- prompt: Optional prompt to send to the agent.
494
- deps: Agent dependencies.
697
+ agent: The agent to run (ShotgunAgent or RouterAgent).
698
+ prompt: Optional prompt to send to the agent. Can be a string,
699
+ a sequence of UserContent (for multimodal), or None.
700
+ deps: Agent dependencies (AgentDeps or RouterDeps).
495
701
  usage_limits: Optional usage limits.
496
702
  message_history: Message history to provide to agent.
497
703
  event_stream_handler: Event handler for streaming.
@@ -502,8 +708,16 @@ class AgentManager(Widget):
502
708
 
503
709
  Raises:
504
710
  Various exceptions if all retries fail.
711
+
712
+ Note:
713
+ Type safety for agent/deps pairing is maintained by AgentManager's
714
+ _get_agent_deps which ensures the correct deps type is used for each
715
+ agent type. The cast is needed because Agent is contravariant in deps.
505
716
  """
506
- return await agent.run(
717
+ # Cast needed because Agent is contravariant in deps type parameter.
718
+ # The agent/deps pairing is ensured by _get_agent_deps returning the
719
+ # correct deps type for each agent type.
720
+ return await cast(ShotgunAgent, agent).run(
507
721
  prompt,
508
722
  deps=deps,
509
723
  usage_limits=usage_limits,
@@ -516,6 +730,8 @@ class AgentManager(Widget):
516
730
  self,
517
731
  prompt: str | None = None,
518
732
  *,
733
+ attachment: FileAttachment | None = None,
734
+ file_contents: list[tuple[str, BinaryContent]] | None = None,
519
735
  deps: AgentDeps | None = None,
520
736
  usage_limits: UsageLimits | None = None,
521
737
  **kwargs: Any,
@@ -527,6 +743,9 @@ class AgentManager(Widget):
527
743
 
528
744
  Args:
529
745
  prompt: Optional prompt to send to the agent.
746
+ attachment: Optional file attachment to include as multimodal content.
747
+ file_contents: Optional list of (file_path, BinaryContent) tuples to include
748
+ as multimodal content. Used when resuming after file_requests.
530
749
  deps: Optional dependencies override (defaults to manager's deps).
531
750
  usage_limits: Optional usage limits for the agent run.
532
751
  **kwargs: Additional keyword arguments to pass to the agent.
@@ -560,6 +779,11 @@ class AgentManager(Widget):
560
779
 
561
780
  deps.agent_mode = self._current_agent_type
562
781
 
782
+ # For router agent, set up the parent stream handler so sub-agents can stream
783
+ if self._current_agent_type == AgentType.ROUTER:
784
+ if isinstance(deps, RouterDeps):
785
+ deps.parent_stream_handler = self._handle_event_stream # type: ignore[assignment]
786
+
563
787
  # Filter out system prompts from other agent types
564
788
  from pydantic_ai.messages import ModelRequestPart
565
789
 
@@ -634,7 +858,7 @@ class AgentManager(Widget):
634
858
  "**Options:**\n"
635
859
  "- Get a [Shotgun Account](https://shotgun.sh) - streaming works out of the box\n"
636
860
  "- Complete [Biometric Verification](https://platform.openai.com/settings/organization/general) with OpenAI, then:\n"
637
- " 1. Press `Ctrl+P` → Open Provider Setup\n"
861
+ " 1. Press `/` → Open Provider Setup\n"
638
862
  " 2. Select OpenAI → Clear key\n"
639
863
  " 3. Re-add your OpenAI API key\n\n"
640
864
  "Continuing without streaming (responses will appear all at once)."
@@ -650,13 +874,46 @@ class AgentManager(Widget):
650
874
  {
651
875
  "has_prompt": prompt is not None,
652
876
  "model_name": model_name,
877
+ "has_attachment": attachment is not None,
653
878
  },
654
879
  )
655
880
 
881
+ # Construct multimodal prompt if attachment or file_contents is provided
882
+ user_prompt: str | Sequence[UserContent] | None = prompt
883
+
884
+ if file_contents:
885
+ # File contents from file_requests - construct multimodal prompt with files
886
+ content_parts: list[UserContent] = [
887
+ prompt or "Here are the files you requested:"
888
+ ]
889
+ for file_path, binary in file_contents:
890
+ content_parts.append(f"\n\n--- File: {file_path} ---")
891
+ content_parts.append(binary)
892
+ user_prompt = content_parts
893
+ logger.debug(
894
+ "Constructed multimodal prompt with requested files",
895
+ extra={"num_files": len(file_contents)},
896
+ )
897
+ elif attachment and attachment.content_base64:
898
+ # Use BinaryContent which is supported by all providers (OpenAI, Anthropic, Google)
899
+ binary_data = base64.b64decode(attachment.content_base64)
900
+ binary_content = BinaryContent(
901
+ data=binary_data,
902
+ media_type=attachment.mime_type,
903
+ )
904
+ user_prompt = [prompt or "", binary_content]
905
+ logger.debug(
906
+ "Constructed multimodal prompt with attachment",
907
+ extra={
908
+ "attachment_type": attachment.file_type.value,
909
+ "attachment_size": attachment.file_size_bytes,
910
+ },
911
+ )
912
+
656
913
  try:
657
914
  result: AgentRunResult[AgentResponse] = await self._run_agent_with_retry(
658
915
  agent=self.current_agent,
659
- prompt=prompt,
916
+ prompt=user_prompt,
660
917
  deps=deps,
661
918
  usage_limits=usage_limits,
662
919
  message_history=message_history,
@@ -742,17 +999,38 @@ class AgentManager(Widget):
742
999
  )
743
1000
 
744
1001
  # Deduplicate: skip user prompts that are already in original_messages
1002
+ # Note: We compare content only, not timestamps, since UserPromptPart
1003
+ # has a timestamp field that differs between instances
1004
+ def get_user_prompt_text(
1005
+ request: ModelRequest,
1006
+ ) -> str | None:
1007
+ """Extract just the text content from a ModelRequest for deduplication.
1008
+
1009
+ When content is multimodal (list with text + binary), extract just the text.
1010
+ This ensures text-only and multimodal versions of the same prompt match.
1011
+ """
1012
+ for part in request.parts:
1013
+ if isinstance(part, UserPromptPart):
1014
+ content = part.content
1015
+ if isinstance(content, str):
1016
+ return content
1017
+ elif isinstance(content, list):
1018
+ # Multimodal content - extract text strings only
1019
+ text_parts = [item for item in content if isinstance(item, str)]
1020
+ return text_parts[0] if text_parts else None
1021
+ return None
1022
+
745
1023
  deduplicated_new_messages = []
746
1024
  for msg in new_messages:
747
1025
  # Check if this is a user prompt that's already in original_messages
748
1026
  if isinstance(msg, ModelRequest) and any(
749
1027
  isinstance(part, UserPromptPart) for part in msg.parts
750
1028
  ):
1029
+ msg_text = get_user_prompt_text(msg)
751
1030
  # Check if an identical user prompt is already in original_messages
752
1031
  already_exists = any(
753
1032
  isinstance(existing, ModelRequest)
754
- and any(isinstance(p, UserPromptPart) for p in existing.parts)
755
- and existing.parts == msg.parts
1033
+ and get_user_prompt_text(existing) == msg_text
756
1034
  for existing in original_messages[
757
1035
  -5:
758
1036
  ] # Check last 5 messages for efficiency
@@ -762,6 +1040,13 @@ class AgentManager(Widget):
762
1040
 
763
1041
  deduplicated_new_messages.append(msg)
764
1042
 
1043
+ # Mark file resume prompts as internal (hidden from UI)
1044
+ # When file_contents is provided, the prompt is system-generated, not user input
1045
+ if file_contents:
1046
+ deduplicated_new_messages = self._mark_as_internal_prompts(
1047
+ deduplicated_new_messages
1048
+ )
1049
+
765
1050
  self.ui_message_history = original_messages + deduplicated_new_messages
766
1051
 
767
1052
  # Get file operations early so we can use them for contextual messages
@@ -776,6 +1061,48 @@ class AgentManager(Widget):
776
1061
  },
777
1062
  )
778
1063
 
1064
+ # Check if there are file requests (takes priority over clarifying questions)
1065
+ # But ignore file_requests if we just provided file_contents (prevents infinite loops)
1066
+ if agent_response.file_requests and not file_contents:
1067
+ logger.info(
1068
+ f"Agent requested {len(agent_response.file_requests)} files to be loaded"
1069
+ )
1070
+
1071
+ # Set pending state
1072
+ self._file_request_pending = True
1073
+ self._pending_file_requests = agent_response.file_requests
1074
+
1075
+ # Add agent's response as hint if present
1076
+ if agent_response.response:
1077
+ self.ui_message_history.append(
1078
+ HintMessage(message=agent_response.response)
1079
+ )
1080
+
1081
+ # Add file loading indicator
1082
+ files_list = "\n".join(f"- `{p}`" for p in agent_response.file_requests)
1083
+ self.ui_message_history.append(
1084
+ HintMessage(message=f"📁 Loading requested files:\n{files_list}")
1085
+ )
1086
+
1087
+ # Post UI update with hint messages
1088
+ self._post_messages_updated([])
1089
+
1090
+ # Post event to TUI to load files and resume
1091
+ self.post_message(
1092
+ FileRequestPendingMessage(
1093
+ file_paths=agent_response.file_requests,
1094
+ response_text=agent_response.response,
1095
+ )
1096
+ )
1097
+
1098
+ return result
1099
+ elif agent_response.file_requests and file_contents:
1100
+ # We just provided files, ignore any new file_requests to prevent loops
1101
+ logger.debug(
1102
+ "Ignoring file_requests (files were just provided): %s",
1103
+ agent_response.file_requests,
1104
+ )
1105
+
779
1106
  # Check if there are clarifying questions
780
1107
  if agent_response.clarifying_questions:
781
1108
  logger.info(
@@ -799,11 +1126,16 @@ class AgentManager(Widget):
799
1126
  self.ui_message_history.append(
800
1127
  HintMessage(message=f"💡 {agent_response.clarifying_questions[0]}")
801
1128
  )
1129
+ # Add plan hint for Drafting mode (Planning mode uses PlanPanelWidget)
1130
+ self._maybe_add_plan_hint_drafting_mode(deps)
802
1131
  else:
803
1132
  # Multiple questions (2+) - enter Q&A mode
804
1133
  self._qa_questions = agent_response.clarifying_questions
805
1134
  self._qa_mode_active = True
806
1135
 
1136
+ # In Drafting mode, show plan BEFORE Q&A questions (without "Shall I continue?")
1137
+ self._maybe_add_plan_hint_drafting_mode(deps, in_qa_mode=True)
1138
+
807
1139
  # Show intro with list, then first question
808
1140
  questions_list_with_intro = (
809
1141
  f"I have {len(agent_response.clarifying_questions)} questions:\n\n"
@@ -865,6 +1197,9 @@ class AgentManager(Widget):
865
1197
  HintMessage(message="✅ Task completed")
866
1198
  )
867
1199
 
1200
+ # Add plan hint for Drafting mode (Planning mode uses PlanPanelWidget)
1201
+ self._maybe_add_plan_hint_drafting_mode(deps)
1202
+
868
1203
  # Post UI update immediately so user sees the response without delay
869
1204
  # (file operations will be posted after compaction to avoid duplicates)
870
1205
  logger.debug("Posting immediate UI update with hint message")
@@ -963,6 +1298,11 @@ class AgentManager(Widget):
963
1298
  else:
964
1299
  partial_parts = []
965
1300
 
1301
+ # Wrap stream with cancellable iterator for responsive ESC handling
1302
+ deps = _ctx.deps
1303
+ if deps.cancellation_event:
1304
+ stream = CancellableStreamIterator(stream, deps.cancellation_event)
1305
+
966
1306
  async for event in stream:
967
1307
  try:
968
1308
  if isinstance(event, PartStartEvent):
@@ -992,6 +1332,44 @@ class AgentManager(Widget):
992
1332
  )
993
1333
  continue
994
1334
 
1335
+ # Count tokens from the delta for progress indication
1336
+ delta_len = 0
1337
+ is_tool_call_delta = False
1338
+ if isinstance(event.delta, ToolCallPartDelta):
1339
+ is_tool_call_delta = True
1340
+ # args_delta can be str or dict depending on provider
1341
+ args_delta = event.delta.args_delta
1342
+ if isinstance(args_delta, str):
1343
+ delta_len = len(args_delta)
1344
+ elif isinstance(args_delta, dict):
1345
+ # For dict deltas, estimate from JSON representation
1346
+ delta_len = len(json.dumps(args_delta))
1347
+ # Pick a spinner message when tool streaming starts
1348
+ if state.current_spinner_text == "Processing...":
1349
+ import random
1350
+
1351
+ state.current_spinner_text = random.choice( # noqa: S311
1352
+ SPINNER_MESSAGES
1353
+ )
1354
+ elif isinstance(event.delta, TextPartDelta):
1355
+ delta_len = len(event.delta.content_delta)
1356
+
1357
+ if delta_len > 0:
1358
+ # Approximate tokens: len / 4 is a rough estimate
1359
+ state.streamed_tokens += delta_len // 4 + 1
1360
+ # Send progress update for tool call streaming
1361
+ # Throttle updates to every ~75 tokens to avoid flooding UI
1362
+ if is_tool_call_delta and (
1363
+ state.streamed_tokens - state.last_reported_tokens >= 75
1364
+ ):
1365
+ state.last_reported_tokens = state.streamed_tokens
1366
+ self.post_message(
1367
+ ToolStreamingProgressMessage(
1368
+ state.streamed_tokens,
1369
+ state.current_spinner_text,
1370
+ )
1371
+ )
1372
+
995
1373
  try:
996
1374
  updated_part = event.delta.apply(
997
1375
  cast(ModelResponsePart, partial_parts[index])
@@ -1087,6 +1465,17 @@ class AgentManager(Widget):
1087
1465
  if partial_message is not None:
1088
1466
  state.current_response = partial_message
1089
1467
  self._post_partial_message(False)
1468
+
1469
+ # Notify UI that a tool is about to execute
1470
+ # This updates the spinner with a fun message during tool execution
1471
+ # Pick a random spinner message and store it for progress updates
1472
+ import random
1473
+
1474
+ spinner_text = random.choice(SPINNER_MESSAGES) # noqa: S311
1475
+ state.current_spinner_text = spinner_text
1476
+ state.streamed_tokens = 0 # Reset token count for new tool
1477
+ self.post_message(ToolExecutionStartedMessage(spinner_text))
1478
+
1090
1479
  elif isinstance(event, FunctionToolResultEvent):
1091
1480
  # Track tool completion event
1092
1481
 
@@ -1191,6 +1580,47 @@ class AgentManager(Widget):
1191
1580
  # Common path is a file, show parent directory
1192
1581
  return f"📁 Modified {num_files} files in: `{path_obj.parent}`"
1193
1582
 
1583
+ def _maybe_add_plan_hint_drafting_mode(
1584
+ self, deps: AgentDeps, in_qa_mode: bool = False
1585
+ ) -> None:
1586
+ """Add execution plan hint for router agent in Drafting mode only.
1587
+
1588
+ In Drafting mode, there's no PlanPanelWidget, so we show the plan
1589
+ in the chat history with a "Shall I continue?" prompt (unless in Q&A mode).
1590
+
1591
+ In Planning mode, the PlanPanelWidget handles plan display.
1592
+
1593
+ Args:
1594
+ deps: Agent dependencies (may be RouterDeps for router agent)
1595
+ in_qa_mode: If True, skip the "Shall I continue?" prompt since user
1596
+ needs to answer Q&A questions first.
1597
+ """
1598
+ if self._current_agent_type != AgentType.ROUTER:
1599
+ return
1600
+
1601
+ if not isinstance(deps, RouterDeps):
1602
+ return
1603
+
1604
+ # Only show plan hints in Drafting mode
1605
+ # Planning mode uses PlanPanelWidget instead
1606
+ if deps.router_mode != RouterMode.DRAFTING:
1607
+ return
1608
+
1609
+ if deps.current_plan is None:
1610
+ return
1611
+
1612
+ plan_display = deps.current_plan.format_for_display()
1613
+
1614
+ # In drafting mode, if plan is not complete and NOT in Q&A mode,
1615
+ # prompt user to continue
1616
+ if not deps.current_plan.is_complete() and not in_qa_mode:
1617
+ plan_display += "\n\n**Shall I continue?**"
1618
+
1619
+ logger.debug("Adding plan hint to UI history (Drafting mode)")
1620
+ self.ui_message_history.append(
1621
+ HintMessage(message=f"**Current Plan**\n\n{plan_display}")
1622
+ )
1623
+
1194
1624
  def _post_messages_updated(
1195
1625
  self, file_operations: list[FileOperation] | None = None
1196
1626
  ) -> None:
@@ -1203,6 +1633,43 @@ class AgentManager(Widget):
1203
1633
  )
1204
1634
  )
1205
1635
 
1636
+ def _mark_as_internal_prompts(
1637
+ self,
1638
+ messages: list[ModelRequest | ModelResponse | HintMessage],
1639
+ ) -> list[ModelRequest | ModelResponse | HintMessage]:
1640
+ """Mark UserPromptPart as InternalPromptPart for system-generated prompts.
1641
+
1642
+ Used when file_contents is provided - the resume prompt is system-generated,
1643
+ not actual user input, and should be hidden from the UI.
1644
+
1645
+ Args:
1646
+ messages: List of messages that may contain user prompts to mark as internal
1647
+
1648
+ Returns:
1649
+ List of messages with UserPromptPart converted to InternalPromptPart
1650
+ """
1651
+ result: list[ModelRequest | ModelResponse | HintMessage] = []
1652
+ for msg in messages:
1653
+ if isinstance(msg, ModelRequest):
1654
+ new_parts: list[ModelRequestPart] = []
1655
+ for part in msg.parts:
1656
+ if isinstance(part, UserPromptPart) and not isinstance(
1657
+ part, InternalPromptPart
1658
+ ):
1659
+ # Convert to InternalPromptPart
1660
+ new_parts.append(
1661
+ InternalPromptPart(
1662
+ content=part.content,
1663
+ timestamp=part.timestamp,
1664
+ )
1665
+ )
1666
+ else:
1667
+ new_parts.append(part)
1668
+ result.append(ModelRequest(parts=new_parts))
1669
+ else:
1670
+ result.append(msg)
1671
+ return result
1672
+
1206
1673
  def _filter_system_prompts(
1207
1674
  self, messages: list[ModelMessage | HintMessage]
1208
1675
  ) -> list[ModelMessage | HintMessage]: