shotgun-sh 0.2.17__py3-none-any.whl → 0.4.0.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.
Files changed (150) hide show
  1. shotgun/agents/agent_manager.py +219 -37
  2. shotgun/agents/common.py +79 -78
  3. shotgun/agents/config/README.md +89 -0
  4. shotgun/agents/config/__init__.py +10 -1
  5. shotgun/agents/config/manager.py +364 -53
  6. shotgun/agents/config/models.py +101 -21
  7. shotgun/agents/config/provider.py +51 -13
  8. shotgun/agents/config/streaming_test.py +119 -0
  9. shotgun/agents/context_analyzer/analyzer.py +6 -2
  10. shotgun/agents/conversation/__init__.py +18 -0
  11. shotgun/agents/conversation/filters.py +164 -0
  12. shotgun/agents/conversation/history/chunking.py +278 -0
  13. shotgun/agents/{history → conversation/history}/compaction.py +27 -1
  14. shotgun/agents/{history → conversation/history}/constants.py +5 -0
  15. shotgun/agents/conversation/history/file_content_deduplication.py +239 -0
  16. shotgun/agents/{history → conversation/history}/history_processors.py +267 -3
  17. shotgun/agents/{history → conversation/history}/token_counting/anthropic.py +8 -0
  18. shotgun/agents/{conversation_manager.py → conversation/manager.py} +1 -1
  19. shotgun/agents/{conversation_history.py → conversation/models.py} +8 -94
  20. shotgun/agents/error/__init__.py +11 -0
  21. shotgun/agents/error/models.py +19 -0
  22. shotgun/agents/export.py +12 -13
  23. shotgun/agents/models.py +66 -1
  24. shotgun/agents/plan.py +12 -13
  25. shotgun/agents/research.py +13 -10
  26. shotgun/agents/router/__init__.py +47 -0
  27. shotgun/agents/router/models.py +376 -0
  28. shotgun/agents/router/router.py +185 -0
  29. shotgun/agents/router/tools/__init__.py +18 -0
  30. shotgun/agents/router/tools/delegation_tools.py +503 -0
  31. shotgun/agents/router/tools/plan_tools.py +322 -0
  32. shotgun/agents/runner.py +230 -0
  33. shotgun/agents/specify.py +12 -13
  34. shotgun/agents/tasks.py +12 -13
  35. shotgun/agents/tools/file_management.py +49 -1
  36. shotgun/agents/tools/registry.py +2 -0
  37. shotgun/agents/tools/web_search/__init__.py +1 -2
  38. shotgun/agents/tools/web_search/gemini.py +1 -3
  39. shotgun/agents/tools/web_search/openai.py +1 -1
  40. shotgun/build_constants.py +2 -2
  41. shotgun/cli/clear.py +1 -1
  42. shotgun/cli/compact.py +5 -3
  43. shotgun/cli/context.py +44 -1
  44. shotgun/cli/error_handler.py +24 -0
  45. shotgun/cli/export.py +34 -34
  46. shotgun/cli/plan.py +34 -34
  47. shotgun/cli/research.py +17 -9
  48. shotgun/cli/spec/__init__.py +5 -0
  49. shotgun/cli/spec/backup.py +81 -0
  50. shotgun/cli/spec/commands.py +132 -0
  51. shotgun/cli/spec/models.py +48 -0
  52. shotgun/cli/spec/pull_service.py +219 -0
  53. shotgun/cli/specify.py +20 -19
  54. shotgun/cli/tasks.py +34 -34
  55. shotgun/codebase/core/change_detector.py +1 -1
  56. shotgun/codebase/core/ingestor.py +154 -8
  57. shotgun/codebase/core/manager.py +1 -1
  58. shotgun/codebase/models.py +2 -0
  59. shotgun/exceptions.py +325 -0
  60. shotgun/llm_proxy/__init__.py +17 -0
  61. shotgun/llm_proxy/client.py +215 -0
  62. shotgun/llm_proxy/models.py +137 -0
  63. shotgun/logging_config.py +42 -0
  64. shotgun/main.py +4 -0
  65. shotgun/posthog_telemetry.py +1 -1
  66. shotgun/prompts/agents/export.j2 +2 -0
  67. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +23 -3
  68. shotgun/prompts/agents/partials/interactive_mode.j2 +3 -3
  69. shotgun/prompts/agents/partials/router_delegation_mode.j2 +36 -0
  70. shotgun/prompts/agents/plan.j2 +29 -1
  71. shotgun/prompts/agents/research.j2 +75 -23
  72. shotgun/prompts/agents/router.j2 +440 -0
  73. shotgun/prompts/agents/specify.j2 +80 -4
  74. shotgun/prompts/agents/state/system_state.j2 +15 -8
  75. shotgun/prompts/agents/tasks.j2 +63 -23
  76. shotgun/prompts/history/chunk_summarization.j2 +34 -0
  77. shotgun/prompts/history/combine_summaries.j2 +53 -0
  78. shotgun/sdk/codebase.py +14 -3
  79. shotgun/settings.py +5 -0
  80. shotgun/shotgun_web/__init__.py +67 -1
  81. shotgun/shotgun_web/client.py +42 -1
  82. shotgun/shotgun_web/constants.py +46 -0
  83. shotgun/shotgun_web/exceptions.py +29 -0
  84. shotgun/shotgun_web/models.py +390 -0
  85. shotgun/shotgun_web/shared_specs/__init__.py +32 -0
  86. shotgun/shotgun_web/shared_specs/file_scanner.py +175 -0
  87. shotgun/shotgun_web/shared_specs/hasher.py +83 -0
  88. shotgun/shotgun_web/shared_specs/models.py +71 -0
  89. shotgun/shotgun_web/shared_specs/upload_pipeline.py +329 -0
  90. shotgun/shotgun_web/shared_specs/utils.py +34 -0
  91. shotgun/shotgun_web/specs_client.py +703 -0
  92. shotgun/shotgun_web/supabase_client.py +31 -0
  93. shotgun/tui/app.py +78 -15
  94. shotgun/tui/components/mode_indicator.py +120 -25
  95. shotgun/tui/components/status_bar.py +2 -2
  96. shotgun/tui/containers.py +1 -1
  97. shotgun/tui/dependencies.py +64 -9
  98. shotgun/tui/layout.py +5 -0
  99. shotgun/tui/protocols.py +37 -0
  100. shotgun/tui/screens/chat/chat.tcss +9 -1
  101. shotgun/tui/screens/chat/chat_screen.py +1015 -106
  102. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +196 -17
  103. shotgun/tui/screens/chat_screen/command_providers.py +13 -89
  104. shotgun/tui/screens/chat_screen/hint_message.py +76 -1
  105. shotgun/tui/screens/chat_screen/history/agent_response.py +7 -3
  106. shotgun/tui/screens/chat_screen/history/chat_history.py +12 -0
  107. shotgun/tui/screens/chat_screen/history/formatters.py +53 -15
  108. shotgun/tui/screens/chat_screen/history/partial_response.py +11 -1
  109. shotgun/tui/screens/chat_screen/messages.py +219 -0
  110. shotgun/tui/screens/confirmation_dialog.py +40 -0
  111. shotgun/tui/screens/directory_setup.py +45 -41
  112. shotgun/tui/screens/feedback.py +10 -3
  113. shotgun/tui/screens/github_issue.py +11 -2
  114. shotgun/tui/screens/model_picker.py +28 -8
  115. shotgun/tui/screens/onboarding.py +179 -26
  116. shotgun/tui/screens/pipx_migration.py +58 -6
  117. shotgun/tui/screens/provider_config.py +66 -8
  118. shotgun/tui/screens/shared_specs/__init__.py +21 -0
  119. shotgun/tui/screens/shared_specs/create_spec_dialog.py +273 -0
  120. shotgun/tui/screens/shared_specs/models.py +56 -0
  121. shotgun/tui/screens/shared_specs/share_specs_dialog.py +390 -0
  122. shotgun/tui/screens/shared_specs/upload_progress_screen.py +452 -0
  123. shotgun/tui/screens/shotgun_auth.py +110 -16
  124. shotgun/tui/screens/spec_pull.py +288 -0
  125. shotgun/tui/screens/welcome.py +123 -0
  126. shotgun/tui/services/conversation_service.py +5 -2
  127. shotgun/tui/utils/mode_progress.py +20 -86
  128. shotgun/tui/widgets/__init__.py +2 -1
  129. shotgun/tui/widgets/approval_widget.py +152 -0
  130. shotgun/tui/widgets/cascade_confirmation_widget.py +203 -0
  131. shotgun/tui/widgets/plan_panel.py +129 -0
  132. shotgun/tui/widgets/step_checkpoint_widget.py +180 -0
  133. shotgun/tui/widgets/widget_coordinator.py +1 -1
  134. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/METADATA +11 -4
  135. shotgun_sh-0.4.0.dev1.dist-info/RECORD +242 -0
  136. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/WHEEL +1 -1
  137. shotgun_sh-0.2.17.dist-info/RECORD +0 -194
  138. /shotgun/agents/{history → conversation/history}/__init__.py +0 -0
  139. /shotgun/agents/{history → conversation/history}/context_extraction.py +0 -0
  140. /shotgun/agents/{history → conversation/history}/history_building.py +0 -0
  141. /shotgun/agents/{history → conversation/history}/message_utils.py +0 -0
  142. /shotgun/agents/{history → conversation/history}/token_counting/__init__.py +0 -0
  143. /shotgun/agents/{history → conversation/history}/token_counting/base.py +0 -0
  144. /shotgun/agents/{history → conversation/history}/token_counting/openai.py +0 -0
  145. /shotgun/agents/{history → conversation/history}/token_counting/sentencepiece_counter.py +0 -0
  146. /shotgun/agents/{history → conversation/history}/token_counting/tokenizer_cache.py +0 -0
  147. /shotgun/agents/{history → conversation/history}/token_counting/utils.py +0 -0
  148. /shotgun/agents/{history → conversation/history}/token_estimation.py +0 -0
  149. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/entry_points.txt +0 -0
  150. {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/licenses/LICENSE +0 -0
@@ -1,12 +1,35 @@
1
1
  """Modal dialog for codebase indexing prompts."""
2
2
 
3
+ import os
4
+ import webbrowser
3
5
  from pathlib import Path
4
6
 
5
7
  from textual import on
6
8
  from textual.app import ComposeResult
7
- from textual.containers import Container
9
+ from textual.containers import Container, VerticalScroll
10
+ from textual.events import Resize
8
11
  from textual.screen import ModalScreen
9
- from textual.widgets import Button, Label, Static
12
+ from textual.widgets import Button, Label, Markdown, Static
13
+
14
+ from shotgun.tui.layout import COMPACT_HEIGHT_THRESHOLD
15
+ from shotgun.utils.file_system_utils import get_shotgun_home
16
+
17
+
18
+ def _is_home_directory() -> bool:
19
+ """Check if cwd is user's home directory.
20
+
21
+ Can be simulated with HOME_DIRECTORY_SIMULATE=true env var for testing.
22
+ """
23
+ if os.environ.get("HOME_DIRECTORY_SIMULATE", "").lower() == "true":
24
+ return True
25
+ return Path.cwd() == Path.home()
26
+
27
+
28
+ def _track_event(event_name: str) -> None:
29
+ """Track an event to PostHog."""
30
+ from shotgun.posthog_telemetry import track_event
31
+
32
+ track_event(event_name)
10
33
 
11
34
 
12
35
  class CodebaseIndexPromptScreen(ModalScreen[bool]):
@@ -19,39 +42,182 @@ class CodebaseIndexPromptScreen(ModalScreen[bool]):
19
42
  }
20
43
 
21
44
  CodebaseIndexPromptScreen > #index-prompt-dialog {
22
- width: 60%;
23
- max-width: 60;
45
+ width: 80%;
46
+ max-width: 90;
24
47
  height: auto;
48
+ max-height: 85%;
25
49
  border: wide $primary;
26
50
  padding: 1 2;
27
51
  layout: vertical;
28
52
  background: $surface;
53
+ }
54
+
55
+ #index-prompt-title {
56
+ text-style: bold;
57
+ color: $text-accent;
58
+ text-align: center;
59
+ padding-bottom: 1;
60
+ }
61
+
62
+ #index-prompt-content {
29
63
  height: auto;
64
+ max-height: 1fr;
65
+ }
66
+
67
+ #index-prompt-info {
68
+ padding: 0 1;
30
69
  }
31
70
 
32
71
  #index-prompt-buttons {
33
72
  layout: horizontal;
34
73
  align-horizontal: right;
35
74
  height: auto;
75
+ padding-top: 1;
76
+ }
77
+
78
+ #index-prompt-buttons Button {
79
+ margin: 0 1;
80
+ min-width: 12;
81
+ }
82
+
83
+ #index-prompt-warning {
84
+ background: $surface-lighten-1;
85
+ color: $text;
86
+ padding: 1 2;
87
+ margin-bottom: 1;
88
+ text-align: center;
89
+ }
90
+
91
+ #compact-link {
92
+ text-align: center;
93
+ padding: 1 0;
94
+ display: none;
95
+ }
96
+
97
+ /* Compact styles for short terminals */
98
+ #index-prompt-dialog.compact {
99
+ padding: 0 1;
100
+ border: none;
101
+ max-height: 100%;
102
+ }
103
+
104
+ #index-prompt-dialog.compact #index-prompt-content {
105
+ display: none;
106
+ }
107
+
108
+ #index-prompt-dialog.compact #compact-link {
109
+ display: block;
110
+ }
111
+
112
+ #index-prompt-dialog.compact #index-prompt-warning {
113
+ padding: 0;
114
+ margin-bottom: 0;
115
+ background: transparent;
116
+ }
117
+
118
+ #index-prompt-dialog.compact #index-prompt-title {
119
+ padding-bottom: 0;
120
+ }
121
+
122
+ #index-prompt-dialog.compact #index-prompt-buttons {
123
+ padding-top: 0;
36
124
  }
37
125
  """
38
126
 
39
127
  def compose(self) -> ComposeResult:
128
+ storage_path = get_shotgun_home() / "codebases"
129
+ cwd = Path.cwd()
130
+ is_home = _is_home_directory()
131
+
40
132
  with Container(id="index-prompt-dialog"):
41
- yield Label("Index this codebase?", id="index-prompt-title")
42
- yield Static(
43
- f"Would you like to index the codebase at:\n{Path.cwd()}\n\n"
44
- "This is required for the agent to understand your code and answer "
45
- "questions about it. Without indexing, the agent cannot analyze "
46
- "your codebase."
47
- )
48
- with Container(id="index-prompt-buttons"):
49
- yield Button(
50
- "Index now",
51
- id="index-prompt-confirm",
52
- variant="primary",
133
+ if is_home:
134
+ # Show warning for home directory
135
+ yield Label(
136
+ "Home directory detected",
137
+ id="index-prompt-title",
138
+ )
139
+ yield Static(
140
+ "Running from home directory isn't recommended.",
141
+ id="index-prompt-warning",
142
+ )
143
+ with Container(id="index-prompt-buttons"):
144
+ yield Button(
145
+ "Quit",
146
+ id="index-prompt-quit",
147
+ )
148
+ yield Button(
149
+ "Continue without indexing",
150
+ id="index-prompt-continue",
151
+ )
152
+ else:
153
+ # Normal indexing prompt
154
+ content = f"""
155
+ ## 🔒 Your code never leaves your computer
156
+
157
+ Shotgun will index the codebase at:
158
+ **`{cwd}`**
159
+ _(This is the current working directory where you started Shotgun)_
160
+
161
+ ### What happens during indexing:
162
+
163
+ - **Stays on your computer**: Index is stored locally at `{storage_path}` - it will not be stored on a server
164
+ - **Zero cost**: Indexing runs entirely on your machine
165
+ - **Runs in the background**: Usually takes 1-3 minutes, and you can continue using Shotgun while it indexes
166
+ - **Enable code understanding**: Allows Shotgun to answer questions about your codebase
167
+
168
+ ---
169
+
170
+ If you're curious, you can review how Shotgun indexes/queries code by taking a look at the [source code](https://github.com/shotgun-sh/shotgun).
171
+
172
+ We take your privacy seriously. You can read our full [privacy policy](https://app.shotgun.sh/privacy) for more details.
173
+ """
174
+ yield Label(
175
+ "Want to index your codebase?",
176
+ id="index-prompt-title",
177
+ )
178
+ # Compact mode: show only a link
179
+ yield Static(
180
+ "[@click=screen.open_faq]Learn more about indexing[/]",
181
+ id="compact-link",
182
+ markup=True,
53
183
  )
54
- yield Button("Not now", id="index-prompt-cancel")
184
+ # Full mode: show detailed content
185
+ with VerticalScroll(id="index-prompt-content"):
186
+ yield Markdown(content, id="index-prompt-info")
187
+ with Container(id="index-prompt-buttons"):
188
+ yield Button(
189
+ "Not now",
190
+ id="index-prompt-cancel",
191
+ )
192
+ yield Button(
193
+ "Index now",
194
+ id="index-prompt-confirm",
195
+ variant="primary",
196
+ )
197
+
198
+ def on_mount(self) -> None:
199
+ """Track when the home directory warning screen is shown and apply compact layout."""
200
+ if _is_home_directory():
201
+ _track_event("home_directory_warning_shown")
202
+ # Apply compact layout if starting in a short terminal
203
+ self._apply_compact_layout(self.app.size.height < COMPACT_HEIGHT_THRESHOLD)
204
+
205
+ @on(Resize)
206
+ def handle_resize(self, event: Resize) -> None:
207
+ """Adjust layout based on terminal height."""
208
+ self._apply_compact_layout(event.size.height < COMPACT_HEIGHT_THRESHOLD)
209
+
210
+ def _apply_compact_layout(self, compact: bool) -> None:
211
+ """Apply or remove compact layout classes for short terminals."""
212
+ dialog = self.query_one("#index-prompt-dialog")
213
+ if compact:
214
+ dialog.add_class("compact")
215
+ else:
216
+ dialog.remove_class("compact")
217
+
218
+ def action_open_faq(self) -> None:
219
+ """Open the FAQ page in a browser."""
220
+ webbrowser.open("https://github.com/shotgun-sh/shotgun?tab=readme-ov-file#faq")
55
221
 
56
222
  @on(Button.Pressed, "#index-prompt-cancel")
57
223
  def handle_cancel(self, event: Button.Pressed) -> None:
@@ -62,3 +228,16 @@ class CodebaseIndexPromptScreen(ModalScreen[bool]):
62
228
  def handle_confirm(self, event: Button.Pressed) -> None:
63
229
  event.stop()
64
230
  self.dismiss(True)
231
+
232
+ @on(Button.Pressed, "#index-prompt-continue")
233
+ def handle_continue(self, event: Button.Pressed) -> None:
234
+ """Continue without indexing when in home directory."""
235
+ event.stop()
236
+ _track_event("home_directory_warning_continue")
237
+ self.dismiss(False)
238
+
239
+ @on(Button.Pressed, "#index-prompt-quit")
240
+ def handle_quit(self, event: Button.Pressed) -> None:
241
+ event.stop()
242
+ _track_event("home_directory_warning_quit")
243
+ self.app.exit()
@@ -3,8 +3,8 @@ 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
7
+ from shotgun.tui.screens.chat_screen.hint_message import HintMessage
8
8
  from shotgun.tui.screens.model_picker import ModelPickerScreen
9
9
  from shotgun.tui.screens.provider_config import ProviderConfigScreen
10
10
 
@@ -12,92 +12,6 @@ if TYPE_CHECKING:
12
12
  from shotgun.tui.screens.chat import ChatScreen
13
13
 
14
14
 
15
- class AgentModeProvider(Provider):
16
- """Command provider for agent mode switching."""
17
-
18
- @property
19
- def chat_screen(self) -> "ChatScreen":
20
- from shotgun.tui.screens.chat import ChatScreen
21
-
22
- return cast(ChatScreen, self.screen)
23
-
24
- def set_mode(self, mode: AgentType) -> None:
25
- """Switch to research mode."""
26
- self.chat_screen.mode = mode
27
-
28
- async def discover(self) -> AsyncGenerator[DiscoveryHit, None]:
29
- """Provide default mode switching commands when palette opens."""
30
- yield DiscoveryHit(
31
- "Switch to Research Mode",
32
- lambda: self.set_mode(AgentType.RESEARCH),
33
- help="🔬 Research topics with web search and synthesize findings",
34
- )
35
- yield DiscoveryHit(
36
- "Switch to Specify Mode",
37
- lambda: self.set_mode(AgentType.SPECIFY),
38
- help="📝 Create detailed specifications and requirements documents",
39
- )
40
- yield DiscoveryHit(
41
- "Switch to Plan Mode",
42
- lambda: self.set_mode(AgentType.PLAN),
43
- help="📋 Create comprehensive, actionable plans with milestones",
44
- )
45
- yield DiscoveryHit(
46
- "Switch to Tasks Mode",
47
- lambda: self.set_mode(AgentType.TASKS),
48
- help="✅ Generate specific, actionable tasks from research and plans",
49
- )
50
- yield DiscoveryHit(
51
- "Switch to Export Mode",
52
- lambda: self.set_mode(AgentType.EXPORT),
53
- help="📤 Export artifacts and findings to various formats",
54
- )
55
-
56
- async def search(self, query: str) -> AsyncGenerator[Hit, None]:
57
- """Search for mode commands."""
58
- matcher = self.matcher(query)
59
-
60
- commands = [
61
- (
62
- "Switch to Research Mode",
63
- "🔬 Research topics with web search and synthesize findings",
64
- lambda: self.set_mode(AgentType.RESEARCH),
65
- AgentType.RESEARCH,
66
- ),
67
- (
68
- "Switch to Specify Mode",
69
- "📝 Create detailed specifications and requirements documents",
70
- lambda: self.set_mode(AgentType.SPECIFY),
71
- AgentType.SPECIFY,
72
- ),
73
- (
74
- "Switch to Plan Mode",
75
- "📋 Create comprehensive, actionable plans with milestones",
76
- lambda: self.set_mode(AgentType.PLAN),
77
- AgentType.PLAN,
78
- ),
79
- (
80
- "Switch to Tasks Mode",
81
- "✅ Generate specific, actionable tasks from research and plans",
82
- lambda: self.set_mode(AgentType.TASKS),
83
- AgentType.TASKS,
84
- ),
85
- (
86
- "Switch to Export Mode",
87
- "📤 Export artifacts and findings to various formats",
88
- lambda: self.set_mode(AgentType.EXPORT),
89
- AgentType.EXPORT,
90
- ),
91
- ]
92
-
93
- for title, help_text, callback, mode in commands:
94
- if self.chat_screen.mode == mode:
95
- continue
96
- score = matcher.match(title)
97
- if score > 0:
98
- yield Hit(score, matcher.highlight(title), callback, help=help_text)
99
-
100
-
101
15
  class UsageProvider(Provider):
102
16
  """Command provider for agent mode switching."""
103
17
 
@@ -271,8 +185,8 @@ class DeleteCodebasePaletteProvider(Provider):
271
185
  try:
272
186
  result = await self.chat_screen.codebase_sdk.list_codebases()
273
187
  except Exception as exc: # pragma: no cover - defensive UI path
274
- self.chat_screen.notify(
275
- f"Unable to load codebases: {exc}", severity="error"
188
+ self.chat_screen.agent_manager.add_hint_message(
189
+ HintMessage(message=f"Unable to load codebases: {exc}")
276
190
  )
277
191
  return []
278
192
  return result.graphs
@@ -359,6 +273,11 @@ class UnifiedCommandProvider(Provider):
359
273
  self.open_model_picker,
360
274
  help="🤖 Choose which AI model to use",
361
275
  )
276
+ yield DiscoveryHit(
277
+ "Share specs to workspace",
278
+ self.chat_screen.share_specs_command,
279
+ help="📤 Upload .shotgun/ files to share with your team",
280
+ )
362
281
  yield DiscoveryHit(
363
282
  "Show context",
364
283
  self.chat_screen.action_show_context,
@@ -411,6 +330,11 @@ class UnifiedCommandProvider(Provider):
411
330
  self.open_model_picker,
412
331
  "🤖 Choose which AI model to use",
413
332
  ),
333
+ (
334
+ "Share specs to workspace",
335
+ self.chat_screen.share_specs_command,
336
+ "📤 Upload .shotgun/ files to share with your team",
337
+ ),
414
338
  (
415
339
  "Show context",
416
340
  self.chat_screen.action_show_context,
@@ -1,14 +1,23 @@
1
1
  from typing import Literal
2
2
 
3
3
  from pydantic import BaseModel
4
+ from textual import on
4
5
  from textual.app import ComposeResult
6
+ from textual.containers import Horizontal
5
7
  from textual.widget import Widget
6
- from textual.widgets import Markdown
8
+ from textual.widgets import Button, Label, Markdown, Static
9
+
10
+ from shotgun.logging_config import get_logger
11
+
12
+ logger = get_logger(__name__)
7
13
 
8
14
 
9
15
  class HintMessage(BaseModel):
10
16
  message: str
11
17
  kind: Literal["hint"] = "hint"
18
+ # Optional email copy functionality
19
+ email: str | None = None
20
+ markdown_after: str | None = None
12
21
 
13
22
 
14
23
  class HintMessageWidget(Widget):
@@ -30,6 +39,30 @@ class HintMessageWidget(Widget):
30
39
  }
31
40
  }
32
41
 
42
+ HintMessageWidget .email-copy-row {
43
+ width: auto;
44
+ height: auto;
45
+ margin: 1 0;
46
+ }
47
+
48
+ HintMessageWidget .email-text {
49
+ width: auto;
50
+ margin-right: 1;
51
+ content-align: left middle;
52
+ }
53
+
54
+ HintMessageWidget .copy-btn {
55
+ width: auto;
56
+ min-width: 12;
57
+ }
58
+
59
+ HintMessageWidget #copy-status {
60
+ height: 1;
61
+ width: 100%;
62
+ margin-top: 1;
63
+ content-align: left middle;
64
+ }
65
+
33
66
  """
34
67
 
35
68
  def __init__(self, message: HintMessage) -> None:
@@ -37,4 +70,46 @@ class HintMessageWidget(Widget):
37
70
  self.message = message
38
71
 
39
72
  def compose(self) -> ComposeResult:
73
+ # Main message markdown
40
74
  yield Markdown(markdown=f"{self.message.message}")
75
+
76
+ # Optional email copy section
77
+ if self.message.email:
78
+ # Email + copy button on same line
79
+ with Horizontal(classes="email-copy-row"):
80
+ yield Static(f"Contact: {self.message.email}", classes="email-text")
81
+ yield Button("Copy email", id="copy-email-btn", classes="copy-btn")
82
+
83
+ # Status feedback label
84
+ yield Label("", id="copy-status")
85
+
86
+ # Optional markdown after email
87
+ if self.message.markdown_after:
88
+ yield Markdown(self.message.markdown_after)
89
+
90
+ @on(Button.Pressed, "#copy-email-btn")
91
+ def _copy_email(self) -> None:
92
+ """Copy email address to clipboard when button is pressed."""
93
+ if not self.message.email:
94
+ return
95
+
96
+ status_label = self.query_one("#copy-status", Label)
97
+
98
+ try:
99
+ import pyperclip # type: ignore[import-untyped] # noqa: PGH003
100
+
101
+ pyperclip.copy(self.message.email)
102
+ status_label.update("✓ Copied to clipboard!")
103
+ logger.debug(
104
+ f"Successfully copied email to clipboard: {self.message.email}"
105
+ )
106
+
107
+ except ImportError:
108
+ status_label.update(
109
+ f"⚠️ Clipboard unavailable. Please manually copy: {self.message.email}"
110
+ )
111
+ logger.warning("pyperclip not available for clipboard operations")
112
+
113
+ except Exception as e:
114
+ status_label.update(f"⚠️ Copy failed: {e}")
115
+ logger.error(f"Failed to copy email to clipboard: {e}", exc_info=True)
@@ -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
@@ -8,10 +8,12 @@ from pydantic_ai.messages import (
8
8
  ModelResponse,
9
9
  UserPromptPart,
10
10
  )
11
+ from textual import events
11
12
  from textual.app import ComposeResult
12
13
  from textual.reactive import reactive
13
14
  from textual.widget import Widget
14
15
 
16
+ from shotgun.tui.components.prompt_input import PromptInput
15
17
  from shotgun.tui.components.vertical_tail import VerticalTail
16
18
  from shotgun.tui.screens.chat_screen.hint_message import HintMessage, HintMessageWidget
17
19
 
@@ -113,3 +115,13 @@ class ChatHistory(Widget):
113
115
 
114
116
  # Scroll to bottom to show newly added messages
115
117
  self.vertical_tail.scroll_end(animate=False)
118
+
119
+ def on_click(self, event: events.Click) -> None:
120
+ """Focus the prompt input when clicking on the history area."""
121
+ # Only handle clicks that weren't already handled by a child widget
122
+ if event.button == 1: # Left click
123
+ results = self.screen.query(PromptInput)
124
+ if results:
125
+ prompt_input = results.first()
126
+ if prompt_input.display:
127
+ 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,10 @@ 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:
60
98
  # Format: "display_text: key_value"
61
99
  return f"{display_config.display_text}: {cls.truncate(key_value)}"
62
100
  else:
@@ -95,8 +133,8 @@ class ToolFormatter:
95
133
 
96
134
  args = cls.parse_args(part.args)
97
135
  # 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])
136
+ key_value = cls._extract_key_arg(args, display_config.key_arg)
137
+ if key_value:
100
138
  # Format: "display_text: key_value"
101
139
  return f"{display_config.display_text}: {cls.truncate(key_value)}"
102
140
  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