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.
- shotgun/agents/agent_manager.py +219 -37
- shotgun/agents/common.py +79 -78
- shotgun/agents/config/README.md +89 -0
- shotgun/agents/config/__init__.py +10 -1
- shotgun/agents/config/manager.py +364 -53
- shotgun/agents/config/models.py +101 -21
- shotgun/agents/config/provider.py +51 -13
- shotgun/agents/config/streaming_test.py +119 -0
- shotgun/agents/context_analyzer/analyzer.py +6 -2
- shotgun/agents/conversation/__init__.py +18 -0
- shotgun/agents/conversation/filters.py +164 -0
- shotgun/agents/conversation/history/chunking.py +278 -0
- shotgun/agents/{history → conversation/history}/compaction.py +27 -1
- shotgun/agents/{history → conversation/history}/constants.py +5 -0
- shotgun/agents/conversation/history/file_content_deduplication.py +239 -0
- shotgun/agents/{history → conversation/history}/history_processors.py +267 -3
- shotgun/agents/{history → conversation/history}/token_counting/anthropic.py +8 -0
- shotgun/agents/{conversation_manager.py → conversation/manager.py} +1 -1
- shotgun/agents/{conversation_history.py → conversation/models.py} +8 -94
- shotgun/agents/error/__init__.py +11 -0
- shotgun/agents/error/models.py +19 -0
- shotgun/agents/export.py +12 -13
- shotgun/agents/models.py +66 -1
- shotgun/agents/plan.py +12 -13
- shotgun/agents/research.py +13 -10
- shotgun/agents/router/__init__.py +47 -0
- shotgun/agents/router/models.py +376 -0
- shotgun/agents/router/router.py +185 -0
- shotgun/agents/router/tools/__init__.py +18 -0
- shotgun/agents/router/tools/delegation_tools.py +503 -0
- shotgun/agents/router/tools/plan_tools.py +322 -0
- shotgun/agents/runner.py +230 -0
- shotgun/agents/specify.py +12 -13
- shotgun/agents/tasks.py +12 -13
- shotgun/agents/tools/file_management.py +49 -1
- shotgun/agents/tools/registry.py +2 -0
- shotgun/agents/tools/web_search/__init__.py +1 -2
- shotgun/agents/tools/web_search/gemini.py +1 -3
- shotgun/agents/tools/web_search/openai.py +1 -1
- shotgun/build_constants.py +2 -2
- shotgun/cli/clear.py +1 -1
- shotgun/cli/compact.py +5 -3
- shotgun/cli/context.py +44 -1
- shotgun/cli/error_handler.py +24 -0
- shotgun/cli/export.py +34 -34
- shotgun/cli/plan.py +34 -34
- shotgun/cli/research.py +17 -9
- shotgun/cli/spec/__init__.py +5 -0
- shotgun/cli/spec/backup.py +81 -0
- shotgun/cli/spec/commands.py +132 -0
- shotgun/cli/spec/models.py +48 -0
- shotgun/cli/spec/pull_service.py +219 -0
- shotgun/cli/specify.py +20 -19
- shotgun/cli/tasks.py +34 -34
- shotgun/codebase/core/change_detector.py +1 -1
- shotgun/codebase/core/ingestor.py +154 -8
- shotgun/codebase/core/manager.py +1 -1
- shotgun/codebase/models.py +2 -0
- shotgun/exceptions.py +325 -0
- shotgun/llm_proxy/__init__.py +17 -0
- shotgun/llm_proxy/client.py +215 -0
- shotgun/llm_proxy/models.py +137 -0
- shotgun/logging_config.py +42 -0
- shotgun/main.py +4 -0
- shotgun/posthog_telemetry.py +1 -1
- shotgun/prompts/agents/export.j2 +2 -0
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +23 -3
- shotgun/prompts/agents/partials/interactive_mode.j2 +3 -3
- shotgun/prompts/agents/partials/router_delegation_mode.j2 +36 -0
- shotgun/prompts/agents/plan.j2 +29 -1
- shotgun/prompts/agents/research.j2 +75 -23
- shotgun/prompts/agents/router.j2 +440 -0
- shotgun/prompts/agents/specify.j2 +80 -4
- shotgun/prompts/agents/state/system_state.j2 +15 -8
- shotgun/prompts/agents/tasks.j2 +63 -23
- shotgun/prompts/history/chunk_summarization.j2 +34 -0
- shotgun/prompts/history/combine_summaries.j2 +53 -0
- shotgun/sdk/codebase.py +14 -3
- shotgun/settings.py +5 -0
- shotgun/shotgun_web/__init__.py +67 -1
- shotgun/shotgun_web/client.py +42 -1
- shotgun/shotgun_web/constants.py +46 -0
- shotgun/shotgun_web/exceptions.py +29 -0
- shotgun/shotgun_web/models.py +390 -0
- shotgun/shotgun_web/shared_specs/__init__.py +32 -0
- shotgun/shotgun_web/shared_specs/file_scanner.py +175 -0
- shotgun/shotgun_web/shared_specs/hasher.py +83 -0
- shotgun/shotgun_web/shared_specs/models.py +71 -0
- shotgun/shotgun_web/shared_specs/upload_pipeline.py +329 -0
- shotgun/shotgun_web/shared_specs/utils.py +34 -0
- shotgun/shotgun_web/specs_client.py +703 -0
- shotgun/shotgun_web/supabase_client.py +31 -0
- shotgun/tui/app.py +78 -15
- shotgun/tui/components/mode_indicator.py +120 -25
- shotgun/tui/components/status_bar.py +2 -2
- shotgun/tui/containers.py +1 -1
- shotgun/tui/dependencies.py +64 -9
- shotgun/tui/layout.py +5 -0
- shotgun/tui/protocols.py +37 -0
- shotgun/tui/screens/chat/chat.tcss +9 -1
- shotgun/tui/screens/chat/chat_screen.py +1015 -106
- shotgun/tui/screens/chat/codebase_index_prompt_screen.py +196 -17
- shotgun/tui/screens/chat_screen/command_providers.py +13 -89
- shotgun/tui/screens/chat_screen/hint_message.py +76 -1
- shotgun/tui/screens/chat_screen/history/agent_response.py +7 -3
- shotgun/tui/screens/chat_screen/history/chat_history.py +12 -0
- shotgun/tui/screens/chat_screen/history/formatters.py +53 -15
- shotgun/tui/screens/chat_screen/history/partial_response.py +11 -1
- shotgun/tui/screens/chat_screen/messages.py +219 -0
- shotgun/tui/screens/confirmation_dialog.py +40 -0
- shotgun/tui/screens/directory_setup.py +45 -41
- shotgun/tui/screens/feedback.py +10 -3
- shotgun/tui/screens/github_issue.py +11 -2
- shotgun/tui/screens/model_picker.py +28 -8
- shotgun/tui/screens/onboarding.py +179 -26
- shotgun/tui/screens/pipx_migration.py +58 -6
- shotgun/tui/screens/provider_config.py +66 -8
- shotgun/tui/screens/shared_specs/__init__.py +21 -0
- shotgun/tui/screens/shared_specs/create_spec_dialog.py +273 -0
- shotgun/tui/screens/shared_specs/models.py +56 -0
- shotgun/tui/screens/shared_specs/share_specs_dialog.py +390 -0
- shotgun/tui/screens/shared_specs/upload_progress_screen.py +452 -0
- shotgun/tui/screens/shotgun_auth.py +110 -16
- shotgun/tui/screens/spec_pull.py +288 -0
- shotgun/tui/screens/welcome.py +123 -0
- shotgun/tui/services/conversation_service.py +5 -2
- shotgun/tui/utils/mode_progress.py +20 -86
- shotgun/tui/widgets/__init__.py +2 -1
- shotgun/tui/widgets/approval_widget.py +152 -0
- shotgun/tui/widgets/cascade_confirmation_widget.py +203 -0
- shotgun/tui/widgets/plan_panel.py +129 -0
- shotgun/tui/widgets/step_checkpoint_widget.py +180 -0
- shotgun/tui/widgets/widget_coordinator.py +1 -1
- {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/METADATA +11 -4
- shotgun_sh-0.4.0.dev1.dist-info/RECORD +242 -0
- {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/WHEEL +1 -1
- shotgun_sh-0.2.17.dist-info/RECORD +0 -194
- /shotgun/agents/{history → conversation/history}/__init__.py +0 -0
- /shotgun/agents/{history → conversation/history}/context_extraction.py +0 -0
- /shotgun/agents/{history → conversation/history}/history_building.py +0 -0
- /shotgun/agents/{history → conversation/history}/message_utils.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/__init__.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/base.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/openai.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/sentencepiece_counter.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/tokenizer_cache.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/utils.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_estimation.py +0 -0
- {shotgun_sh-0.2.17.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/entry_points.txt +0 -0
- {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:
|
|
23
|
-
max-width:
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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.
|
|
275
|
-
f"Unable to load codebases: {exc}"
|
|
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
|
|
44
|
+
# Only show the prefix if there's actual content
|
|
41
45
|
if part.content and part.content.strip():
|
|
42
|
-
acc += f"
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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(
|
|
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
|
|