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
@@ -11,9 +11,11 @@ from textual.events import Resize
11
11
  from textual.screen import ModalScreen
12
12
  from textual.widgets import Button, Label, Markdown, Static
13
13
 
14
- from shotgun.tui.layout import COMPACT_HEIGHT_THRESHOLD
15
14
  from shotgun.utils.file_system_utils import get_shotgun_home
16
15
 
16
+ # Use a higher threshold than the global default since this dialog has more content
17
+ INDEX_PROMPT_COMPACT_THRESHOLD = 45
18
+
17
19
 
18
20
  def _is_home_directory() -> bool:
19
21
  """Check if cwd is user's home directory.
@@ -46,7 +48,7 @@ class CodebaseIndexPromptScreen(ModalScreen[bool]):
46
48
  max-width: 90;
47
49
  height: auto;
48
50
  max-height: 85%;
49
- border: wide $primary;
51
+ border: none;
50
52
  padding: 1 2;
51
53
  layout: vertical;
52
54
  background: $surface;
@@ -200,12 +202,14 @@ We take your privacy seriously. You can read our full [privacy policy](https://a
200
202
  if _is_home_directory():
201
203
  _track_event("home_directory_warning_shown")
202
204
  # Apply compact layout if starting in a short terminal
203
- self._apply_compact_layout(self.app.size.height < COMPACT_HEIGHT_THRESHOLD)
205
+ self._apply_compact_layout(
206
+ self.app.size.height < INDEX_PROMPT_COMPACT_THRESHOLD
207
+ )
204
208
 
205
209
  @on(Resize)
206
210
  def handle_resize(self, event: Resize) -> None:
207
211
  """Adjust layout based on terminal height."""
208
- self._apply_compact_layout(event.size.height < COMPACT_HEIGHT_THRESHOLD)
212
+ self._apply_compact_layout(event.size.height < INDEX_PROMPT_COMPACT_THRESHOLD)
209
213
 
210
214
  def _apply_compact_layout(self, compact: bool) -> None:
211
215
  """Apply or remove compact layout classes for short terminals."""
@@ -0,0 +1,40 @@
1
+ """Attachment hint widget for displaying attachments in chat history."""
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.widget import Widget
5
+ from textual.widgets import Static
6
+
7
+ from shotgun.attachments import AttachmentHint, get_attachment_icon
8
+
9
+
10
+ class AttachmentHintWidget(Widget):
11
+ """Widget that displays attachment indicator in chat history.
12
+
13
+ Display format: "icon Attached: filename (size)"
14
+ """
15
+
16
+ DEFAULT_CSS = """
17
+ AttachmentHintWidget {
18
+ height: auto;
19
+ padding: 0 1;
20
+ margin: 0 1;
21
+ color: $text-muted;
22
+ }
23
+ """
24
+
25
+ def __init__(self, hint: AttachmentHint) -> None:
26
+ """Initialize with attachment hint data.
27
+
28
+ Args:
29
+ hint: AttachmentHint model containing display info.
30
+ """
31
+ super().__init__()
32
+ self.hint = hint
33
+
34
+ def compose(self) -> ComposeResult:
35
+ """Compose the attachment hint widget."""
36
+ icon = get_attachment_icon(self.hint.file_type)
37
+ display_text = (
38
+ f"{icon} Attached: {self.hint.filename} ({self.hint.file_size_display})"
39
+ )
40
+ yield Static(display_text)
@@ -3,7 +3,6 @@ from typing import TYPE_CHECKING, cast
3
3
 
4
4
  from textual.command import DiscoveryHit, Hit, Provider
5
5
 
6
- from shotgun.agents.models import AgentType
7
6
  from shotgun.codebase.models import CodebaseGraph
8
7
  from shotgun.tui.screens.chat_screen.hint_message import HintMessage
9
8
  from shotgun.tui.screens.model_picker import ModelPickerScreen
@@ -13,92 +12,6 @@ if TYPE_CHECKING:
13
12
  from shotgun.tui.screens.chat import ChatScreen
14
13
 
15
14
 
16
- class AgentModeProvider(Provider):
17
- """Command provider for agent mode switching."""
18
-
19
- @property
20
- def chat_screen(self) -> "ChatScreen":
21
- from shotgun.tui.screens.chat import ChatScreen
22
-
23
- return cast(ChatScreen, self.screen)
24
-
25
- def set_mode(self, mode: AgentType) -> None:
26
- """Switch to research mode."""
27
- self.chat_screen.mode = mode
28
-
29
- async def discover(self) -> AsyncGenerator[DiscoveryHit, None]:
30
- """Provide default mode switching commands when palette opens."""
31
- yield DiscoveryHit(
32
- "Switch to Research Mode",
33
- lambda: self.set_mode(AgentType.RESEARCH),
34
- help="🔬 Research topics with web search and synthesize findings",
35
- )
36
- yield DiscoveryHit(
37
- "Switch to Specify Mode",
38
- lambda: self.set_mode(AgentType.SPECIFY),
39
- help="📝 Create detailed specifications and requirements documents",
40
- )
41
- yield DiscoveryHit(
42
- "Switch to Plan Mode",
43
- lambda: self.set_mode(AgentType.PLAN),
44
- help="📋 Create comprehensive, actionable plans with milestones",
45
- )
46
- yield DiscoveryHit(
47
- "Switch to Tasks Mode",
48
- lambda: self.set_mode(AgentType.TASKS),
49
- help="✅ Generate specific, actionable tasks from research and plans",
50
- )
51
- yield DiscoveryHit(
52
- "Switch to Export Mode",
53
- lambda: self.set_mode(AgentType.EXPORT),
54
- help="📤 Export artifacts and findings to various formats",
55
- )
56
-
57
- async def search(self, query: str) -> AsyncGenerator[Hit, None]:
58
- """Search for mode commands."""
59
- matcher = self.matcher(query)
60
-
61
- commands = [
62
- (
63
- "Switch to Research Mode",
64
- "🔬 Research topics with web search and synthesize findings",
65
- lambda: self.set_mode(AgentType.RESEARCH),
66
- AgentType.RESEARCH,
67
- ),
68
- (
69
- "Switch to Specify Mode",
70
- "📝 Create detailed specifications and requirements documents",
71
- lambda: self.set_mode(AgentType.SPECIFY),
72
- AgentType.SPECIFY,
73
- ),
74
- (
75
- "Switch to Plan Mode",
76
- "📋 Create comprehensive, actionable plans with milestones",
77
- lambda: self.set_mode(AgentType.PLAN),
78
- AgentType.PLAN,
79
- ),
80
- (
81
- "Switch to Tasks Mode",
82
- "✅ Generate specific, actionable tasks from research and plans",
83
- lambda: self.set_mode(AgentType.TASKS),
84
- AgentType.TASKS,
85
- ),
86
- (
87
- "Switch to Export Mode",
88
- "📤 Export artifacts and findings to various formats",
89
- lambda: self.set_mode(AgentType.EXPORT),
90
- AgentType.EXPORT,
91
- ),
92
- ]
93
-
94
- for title, help_text, callback, mode in commands:
95
- if self.chat_screen.mode == mode:
96
- continue
97
- score = matcher.match(title)
98
- if score > 0:
99
- yield Hit(score, matcher.highlight(title), callback, help=help_text)
100
-
101
-
102
15
  class UsageProvider(Provider):
103
16
  """Command provider for agent mode switching."""
104
17
 
@@ -375,11 +288,6 @@ class UnifiedCommandProvider(Provider):
375
288
  self.chat_screen.action_show_usage,
376
289
  help="Display usage information for the current session",
377
290
  )
378
- yield DiscoveryHit(
379
- "View Onboarding",
380
- self.chat_screen.action_view_onboarding,
381
- help="View the onboarding tutorial and helpful resources",
382
- )
383
291
 
384
292
  async def search(self, query: str) -> AsyncGenerator[Hit, None]:
385
293
  """Search for commands in alphabetical order."""
@@ -432,11 +340,6 @@ class UnifiedCommandProvider(Provider):
432
340
  self.chat_screen.action_show_usage,
433
341
  "Display usage information for the current session",
434
342
  ),
435
- (
436
- "View Onboarding",
437
- self.chat_screen.action_view_onboarding,
438
- "View the onboarding tutorial and helpful resources",
439
- ),
440
343
  ]
441
344
 
442
345
  for title, callback, help_text in commands:
@@ -18,9 +18,10 @@ from .formatters import ToolFormatter
18
18
  class AgentResponseWidget(Widget):
19
19
  """Widget that displays agent responses in the chat history."""
20
20
 
21
- def __init__(self, item: ModelResponse | None) -> None:
21
+ def __init__(self, item: ModelResponse | None, is_sub_agent: bool = False) -> None:
22
22
  super().__init__()
23
23
  self.item = item
24
+ self.is_sub_agent = is_sub_agent
24
25
 
25
26
  def compose(self) -> ComposeResult:
26
27
  self.display = self.item is not None
@@ -35,11 +36,14 @@ class AgentResponseWidget(Widget):
35
36
  if self.item is None:
36
37
  return ""
37
38
 
39
+ # Use different prefix for sub-agent responses
40
+ prefix = "**⏺** " if not self.is_sub_agent else " **↳** "
41
+
38
42
  for idx, part in enumerate(self.item.parts):
39
43
  if isinstance(part, TextPart):
40
- # Only show the circle prefix if there's actual content
44
+ # Only show the prefix if there's actual content
41
45
  if part.content and part.content.strip():
42
- acc += f"**⏺** {part.content}\n\n"
46
+ acc += f"{prefix}{part.content}\n\n"
43
47
  elif isinstance(part, ToolCallPart):
44
48
  parts_str = ToolFormatter.format_tool_call_part(part)
45
49
  if parts_str: # Only add if there's actual content
@@ -1,5 +1,6 @@
1
1
  """Chat history widget - main container for message display."""
2
2
 
3
+ import logging
3
4
  from collections.abc import Generator, Sequence
4
5
 
5
6
  from pydantic_ai.messages import (
@@ -8,10 +9,13 @@ from pydantic_ai.messages import (
8
9
  ModelResponse,
9
10
  UserPromptPart,
10
11
  )
12
+ from textual import events
11
13
  from textual.app import ComposeResult
12
14
  from textual.reactive import reactive
13
15
  from textual.widget import Widget
14
16
 
17
+ from shotgun.agents.messages import InternalPromptPart
18
+ from shotgun.tui.components.prompt_input import PromptInput
15
19
  from shotgun.tui.components.vertical_tail import VerticalTail
16
20
  from shotgun.tui.screens.chat_screen.hint_message import HintMessage, HintMessageWidget
17
21
 
@@ -19,6 +23,8 @@ from .agent_response import AgentResponseWidget
19
23
  from .partial_response import PartialResponseWidget
20
24
  from .user_question import UserQuestionWidget
21
25
 
26
+ logger = logging.getLogger(__name__)
27
+
22
28
 
23
29
  class ChatHistory(Widget):
24
30
  """Main widget for displaying chat message history."""
@@ -72,14 +78,16 @@ class ChatHistory(Widget):
72
78
  def filtered_items(self) -> Generator[ModelMessage | HintMessage, None, None]:
73
79
  """Filter and yield items for display."""
74
80
  for item in self.items:
75
- # Skip ModelRequest messages that only contain ToolReturnPart
76
- # (these are internal tool results, not user prompts)
81
+ # Skip ModelRequest messages without visible user content
77
82
  if isinstance(item, ModelRequest):
78
- has_user_content = any(
79
- isinstance(part, UserPromptPart) for part in item.parts
83
+ # Check for visible user content (UserPromptPart but NOT InternalPromptPart)
84
+ has_visible_user_content = any(
85
+ isinstance(part, UserPromptPart)
86
+ and not isinstance(part, InternalPromptPart)
87
+ for part in item.parts
80
88
  )
81
- if not has_user_content:
82
- # This is just a tool return, skip displaying it
89
+ if not has_visible_user_content:
90
+ # Skip: either just tool returns or internal system prompts
83
91
  continue
84
92
 
85
93
  yield item
@@ -87,14 +95,35 @@ class ChatHistory(Widget):
87
95
  def update_messages(self, messages: list[ModelMessage | HintMessage]) -> None:
88
96
  """Update the displayed messages using incremental mounting."""
89
97
  if not self.vertical_tail:
98
+ logger.debug(
99
+ "[CHAT_HISTORY] update_messages called but vertical_tail is None"
100
+ )
90
101
  return
91
102
 
92
103
  self.items = messages
93
104
  filtered = list(self.filtered_items())
94
105
 
106
+ # If rendered count is higher than filtered count, the message list was
107
+ # modified (not just appended). Reset rendered count to allow new messages
108
+ # to be mounted. This can happen when messages are compacted or filtered.
109
+ if self._rendered_count > len(filtered):
110
+ logger.debug(
111
+ "[CHAT_HISTORY] Rendered count (%d) > filtered count (%d), "
112
+ "resetting to allow new messages to be mounted",
113
+ self._rendered_count,
114
+ len(filtered),
115
+ )
116
+ self._rendered_count = len(filtered)
117
+
95
118
  # Only mount new messages that haven't been rendered yet
96
119
  if len(filtered) > self._rendered_count:
97
120
  new_messages = filtered[self._rendered_count :]
121
+ logger.debug(
122
+ "[CHAT_HISTORY] Mounting %d new messages (total=%d, filtered=%d)",
123
+ len(new_messages),
124
+ len(messages),
125
+ len(filtered),
126
+ )
98
127
  for item in new_messages:
99
128
  widget: Widget
100
129
  if isinstance(item, ModelRequest):
@@ -104,6 +133,10 @@ class ChatHistory(Widget):
104
133
  elif isinstance(item, ModelResponse):
105
134
  widget = AgentResponseWidget(item)
106
135
  else:
136
+ logger.debug(
137
+ "[CHAT_HISTORY] Skipping unknown message type: %s",
138
+ type(item).__name__,
139
+ )
107
140
  continue
108
141
 
109
142
  # Mount before the PartialResponseWidget
@@ -113,3 +146,13 @@ class ChatHistory(Widget):
113
146
 
114
147
  # Scroll to bottom to show newly added messages
115
148
  self.vertical_tail.scroll_end(animate=False)
149
+
150
+ def on_click(self, event: events.Click) -> None:
151
+ """Focus the prompt input when clicking on the history area."""
152
+ # Only handle clicks that weren't already handled by a child widget
153
+ if event.button == 1: # Left click
154
+ results = self.screen.query(PromptInput)
155
+ if results:
156
+ prompt_input = results.first()
157
+ if prompt_input.display:
158
+ prompt_input.focus()
@@ -29,6 +29,53 @@ class ToolFormatter:
29
29
  return {}
30
30
  return args if isinstance(args, dict) else {}
31
31
 
32
+ @classmethod
33
+ def _extract_key_arg(
34
+ cls,
35
+ args: dict[str, object],
36
+ key_arg: str,
37
+ tool_name: str | None = None,
38
+ ) -> str | None:
39
+ """Extract key argument value, handling nested args and special cases.
40
+
41
+ Supports:
42
+ - Direct key access: key_arg="query" -> args["query"]
43
+ - Nested access: key_arg="task" -> args["input"]["task"] (for Pydantic model inputs)
44
+ - Special handling for codebase_shell
45
+
46
+ Args:
47
+ args: Parsed tool arguments dict
48
+ key_arg: The key argument to extract
49
+ tool_name: Optional tool name for special handling
50
+
51
+ Returns:
52
+ The extracted value as a string, or None if not found
53
+ """
54
+ if not args or not isinstance(args, dict):
55
+ return None
56
+
57
+ # Special handling for codebase_shell which needs command + args
58
+ if tool_name == "codebase_shell" and "command" in args:
59
+ command = args.get("command", "")
60
+ cmd_args = args.get("args", [])
61
+ if isinstance(cmd_args, list):
62
+ args_str = " ".join(str(arg) for arg in cmd_args)
63
+ else:
64
+ args_str = ""
65
+ return f"{command} {args_str}".strip()
66
+
67
+ # Direct key access
68
+ if key_arg in args:
69
+ return str(args[key_arg])
70
+
71
+ # Try nested access through "input" (for Pydantic model inputs)
72
+ if "input" in args and isinstance(args["input"], dict):
73
+ input_dict = args["input"]
74
+ if key_arg in input_dict:
75
+ return str(input_dict[key_arg])
76
+
77
+ return None
78
+
32
79
  @classmethod
33
80
  def format_tool_call_part(cls, part: ToolCallPart) -> str:
34
81
  """Format a tool call part using the tool display registry."""
@@ -44,19 +91,21 @@ class ToolFormatter:
44
91
  args = cls.parse_args(part.args)
45
92
 
46
93
  # Get the key argument value
47
- if args and isinstance(args, dict) and display_config.key_arg in args:
48
- # Special handling for codebase_shell which needs command + args
49
- if part.tool_name == "codebase_shell" and "command" in args:
50
- command = args.get("command", "")
51
- cmd_args = args.get("args", [])
52
- if isinstance(cmd_args, list):
53
- args_str = " ".join(str(arg) for arg in cmd_args)
54
- else:
55
- args_str = ""
56
- key_value = f"{command} {args_str}".strip()
57
- else:
58
- key_value = str(args[display_config.key_arg])
59
-
94
+ key_value = cls._extract_key_arg(
95
+ args, display_config.key_arg, part.tool_name
96
+ )
97
+ if key_value:
98
+ # Check for secondary key arg
99
+ if display_config.secondary_key_arg:
100
+ secondary_value = cls._extract_key_arg(
101
+ args, display_config.secondary_key_arg, part.tool_name
102
+ )
103
+ if secondary_value:
104
+ # Format: "display_text: key_value → secondary_value"
105
+ return (
106
+ f"{display_config.display_text}: "
107
+ f"{cls.truncate(key_value)} → {cls.truncate(secondary_value)}"
108
+ )
60
109
  # Format: "display_text: key_value"
61
110
  return f"{display_config.display_text}: {cls.truncate(key_value)}"
62
111
  else:
@@ -95,8 +144,19 @@ class ToolFormatter:
95
144
 
96
145
  args = cls.parse_args(part.args)
97
146
  # Get the key argument value
98
- if args and isinstance(args, dict) and display_config.key_arg in args:
99
- key_value = str(args[display_config.key_arg])
147
+ key_value = cls._extract_key_arg(args, display_config.key_arg)
148
+ if key_value:
149
+ # Check for secondary key arg
150
+ if display_config.secondary_key_arg:
151
+ secondary_value = cls._extract_key_arg(
152
+ args, display_config.secondary_key_arg
153
+ )
154
+ if secondary_value:
155
+ # Format: "display_text: key_value → secondary_value"
156
+ return (
157
+ f"{display_config.display_text}: "
158
+ f"{cls.truncate(key_value)} → {cls.truncate(secondary_value)}"
159
+ )
100
160
  # Format: "display_text: key_value"
101
161
  return f"{display_config.display_text}: {cls.truncate(key_value)}"
102
162
  else:
@@ -5,6 +5,8 @@ from textual.app import ComposeResult
5
5
  from textual.reactive import reactive
6
6
  from textual.widget import Widget
7
7
 
8
+ from shotgun.tui.protocols import ActiveSubAgentProvider
9
+
8
10
  from .agent_response import AgentResponseWidget
9
11
  from .user_question import UserQuestionWidget
10
12
 
@@ -27,11 +29,19 @@ class PartialResponseWidget(Widget): # TODO: doesn't work lol
27
29
  super().__init__()
28
30
  self.item = item
29
31
 
32
+ def _is_sub_agent_active(self) -> bool:
33
+ """Check if a sub-agent is currently active."""
34
+ if isinstance(self.screen, ActiveSubAgentProvider):
35
+ return self.screen.active_sub_agent is not None
36
+ return False
37
+
30
38
  def compose(self) -> ComposeResult:
31
39
  if self.item is None:
32
40
  pass
33
41
  elif self.item.kind == "response":
34
- yield AgentResponseWidget(self.item)
42
+ yield AgentResponseWidget(
43
+ self.item, is_sub_agent=self._is_sub_agent_active()
44
+ )
35
45
  elif self.item.kind == "request":
36
46
  yield UserQuestionWidget(self.item)
37
47
 
@@ -12,6 +12,8 @@ from textual.app import ComposeResult
12
12
  from textual.widget import Widget
13
13
  from textual.widgets import Markdown
14
14
 
15
+ from shotgun.agents.messages import InternalPromptPart
16
+
15
17
 
16
18
  class UserQuestionWidget(Widget):
17
19
  """Widget that displays user prompts in the chat history."""
@@ -33,10 +35,30 @@ class UserQuestionWidget(Widget):
33
35
  acc = ""
34
36
  for part in parts:
35
37
  if isinstance(part, UserPromptPart):
36
- acc += (
37
- f"**>** {part.content if isinstance(part.content, str) else ''}\n\n"
38
- )
38
+ # Skip internal prompts (system-generated, not user input)
39
+ if isinstance(part, InternalPromptPart):
40
+ continue
41
+ content = self._extract_text_content(part.content)
42
+ if content:
43
+ acc += f"**>** {content}\n\n"
44
+ # Skip if no displayable text (e.g., only binary files)
39
45
  elif isinstance(part, ToolReturnPart):
40
46
  # Don't show tool return parts in the UI
41
47
  pass
42
48
  return acc
49
+
50
+ def _extract_text_content(self, content: object) -> str:
51
+ """Extract displayable text from UserPromptPart content.
52
+
53
+ Content can be:
54
+ - str: Return directly
55
+ - list: Extract text strings, skip binary content (BinaryContent, ImageUrl, etc.)
56
+ - other: Return empty string
57
+ """
58
+ if isinstance(content, str):
59
+ return content
60
+ if isinstance(content, list):
61
+ # Multimodal content - extract only text strings
62
+ text_parts = [item for item in content if isinstance(item, str)]
63
+ return " ".join(text_parts) if text_parts else ""
64
+ return ""