shotgun-sh 0.2.8.dev2__py3-none-any.whl → 0.3.3.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 (175) hide show
  1. shotgun/agents/agent_manager.py +382 -60
  2. shotgun/agents/common.py +15 -9
  3. shotgun/agents/config/README.md +89 -0
  4. shotgun/agents/config/__init__.py +10 -1
  5. shotgun/agents/config/constants.py +0 -6
  6. shotgun/agents/config/manager.py +383 -82
  7. shotgun/agents/config/models.py +122 -18
  8. shotgun/agents/config/provider.py +81 -15
  9. shotgun/agents/config/streaming_test.py +119 -0
  10. shotgun/agents/context_analyzer/__init__.py +28 -0
  11. shotgun/agents/context_analyzer/analyzer.py +475 -0
  12. shotgun/agents/context_analyzer/constants.py +9 -0
  13. shotgun/agents/context_analyzer/formatter.py +115 -0
  14. shotgun/agents/context_analyzer/models.py +212 -0
  15. shotgun/agents/conversation/__init__.py +18 -0
  16. shotgun/agents/conversation/filters.py +164 -0
  17. shotgun/agents/conversation/history/chunking.py +278 -0
  18. shotgun/agents/{history → conversation/history}/compaction.py +36 -5
  19. shotgun/agents/{history → conversation/history}/constants.py +5 -0
  20. shotgun/agents/conversation/history/file_content_deduplication.py +216 -0
  21. shotgun/agents/{history → conversation/history}/history_processors.py +380 -8
  22. shotgun/agents/{history → conversation/history}/token_counting/anthropic.py +25 -1
  23. shotgun/agents/{history → conversation/history}/token_counting/base.py +14 -3
  24. shotgun/agents/{history → conversation/history}/token_counting/openai.py +11 -1
  25. shotgun/agents/{history → conversation/history}/token_counting/sentencepiece_counter.py +8 -0
  26. shotgun/agents/{history → conversation/history}/token_counting/tokenizer_cache.py +3 -1
  27. shotgun/agents/{history → conversation/history}/token_counting/utils.py +0 -3
  28. shotgun/agents/{conversation_manager.py → conversation/manager.py} +36 -20
  29. shotgun/agents/{conversation_history.py → conversation/models.py} +8 -92
  30. shotgun/agents/error/__init__.py +11 -0
  31. shotgun/agents/error/models.py +19 -0
  32. shotgun/agents/export.py +2 -2
  33. shotgun/agents/plan.py +2 -2
  34. shotgun/agents/research.py +3 -3
  35. shotgun/agents/runner.py +230 -0
  36. shotgun/agents/specify.py +2 -2
  37. shotgun/agents/tasks.py +2 -2
  38. shotgun/agents/tools/codebase/codebase_shell.py +6 -0
  39. shotgun/agents/tools/codebase/directory_lister.py +6 -0
  40. shotgun/agents/tools/codebase/file_read.py +11 -2
  41. shotgun/agents/tools/codebase/query_graph.py +6 -0
  42. shotgun/agents/tools/codebase/retrieve_code.py +6 -0
  43. shotgun/agents/tools/file_management.py +27 -7
  44. shotgun/agents/tools/registry.py +217 -0
  45. shotgun/agents/tools/web_search/__init__.py +8 -8
  46. shotgun/agents/tools/web_search/anthropic.py +8 -2
  47. shotgun/agents/tools/web_search/gemini.py +7 -1
  48. shotgun/agents/tools/web_search/openai.py +8 -2
  49. shotgun/agents/tools/web_search/utils.py +2 -2
  50. shotgun/agents/usage_manager.py +16 -11
  51. shotgun/api_endpoints.py +7 -3
  52. shotgun/build_constants.py +2 -2
  53. shotgun/cli/clear.py +53 -0
  54. shotgun/cli/compact.py +188 -0
  55. shotgun/cli/config.py +8 -5
  56. shotgun/cli/context.py +154 -0
  57. shotgun/cli/error_handler.py +24 -0
  58. shotgun/cli/export.py +34 -34
  59. shotgun/cli/feedback.py +4 -2
  60. shotgun/cli/models.py +1 -0
  61. shotgun/cli/plan.py +34 -34
  62. shotgun/cli/research.py +18 -10
  63. shotgun/cli/spec/__init__.py +5 -0
  64. shotgun/cli/spec/backup.py +81 -0
  65. shotgun/cli/spec/commands.py +132 -0
  66. shotgun/cli/spec/models.py +48 -0
  67. shotgun/cli/spec/pull_service.py +219 -0
  68. shotgun/cli/specify.py +20 -19
  69. shotgun/cli/tasks.py +34 -34
  70. shotgun/cli/update.py +16 -2
  71. shotgun/codebase/core/change_detector.py +5 -3
  72. shotgun/codebase/core/code_retrieval.py +4 -2
  73. shotgun/codebase/core/ingestor.py +163 -15
  74. shotgun/codebase/core/manager.py +13 -4
  75. shotgun/codebase/core/nl_query.py +1 -1
  76. shotgun/codebase/models.py +2 -0
  77. shotgun/exceptions.py +357 -0
  78. shotgun/llm_proxy/__init__.py +17 -0
  79. shotgun/llm_proxy/client.py +215 -0
  80. shotgun/llm_proxy/models.py +137 -0
  81. shotgun/logging_config.py +60 -27
  82. shotgun/main.py +77 -11
  83. shotgun/posthog_telemetry.py +38 -29
  84. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +28 -2
  85. shotgun/prompts/agents/partials/interactive_mode.j2 +3 -3
  86. shotgun/prompts/agents/plan.j2 +16 -0
  87. shotgun/prompts/agents/research.j2 +16 -3
  88. shotgun/prompts/agents/specify.j2 +54 -1
  89. shotgun/prompts/agents/state/system_state.j2 +0 -2
  90. shotgun/prompts/agents/tasks.j2 +16 -0
  91. shotgun/prompts/history/chunk_summarization.j2 +34 -0
  92. shotgun/prompts/history/combine_summaries.j2 +53 -0
  93. shotgun/sdk/codebase.py +14 -3
  94. shotgun/sentry_telemetry.py +163 -16
  95. shotgun/settings.py +243 -0
  96. shotgun/shotgun_web/__init__.py +67 -1
  97. shotgun/shotgun_web/client.py +42 -1
  98. shotgun/shotgun_web/constants.py +46 -0
  99. shotgun/shotgun_web/exceptions.py +29 -0
  100. shotgun/shotgun_web/models.py +390 -0
  101. shotgun/shotgun_web/shared_specs/__init__.py +32 -0
  102. shotgun/shotgun_web/shared_specs/file_scanner.py +175 -0
  103. shotgun/shotgun_web/shared_specs/hasher.py +83 -0
  104. shotgun/shotgun_web/shared_specs/models.py +71 -0
  105. shotgun/shotgun_web/shared_specs/upload_pipeline.py +329 -0
  106. shotgun/shotgun_web/shared_specs/utils.py +34 -0
  107. shotgun/shotgun_web/specs_client.py +703 -0
  108. shotgun/shotgun_web/supabase_client.py +31 -0
  109. shotgun/telemetry.py +10 -33
  110. shotgun/tui/app.py +310 -46
  111. shotgun/tui/commands/__init__.py +1 -1
  112. shotgun/tui/components/context_indicator.py +179 -0
  113. shotgun/tui/components/mode_indicator.py +70 -0
  114. shotgun/tui/components/status_bar.py +48 -0
  115. shotgun/tui/containers.py +91 -0
  116. shotgun/tui/dependencies.py +39 -0
  117. shotgun/tui/layout.py +5 -0
  118. shotgun/tui/protocols.py +45 -0
  119. shotgun/tui/screens/chat/__init__.py +5 -0
  120. shotgun/tui/screens/chat/chat.tcss +54 -0
  121. shotgun/tui/screens/chat/chat_screen.py +1531 -0
  122. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +243 -0
  123. shotgun/tui/screens/chat/codebase_index_selection.py +12 -0
  124. shotgun/tui/screens/chat/help_text.py +40 -0
  125. shotgun/tui/screens/chat/prompt_history.py +48 -0
  126. shotgun/tui/screens/chat.tcss +11 -0
  127. shotgun/tui/screens/chat_screen/command_providers.py +91 -4
  128. shotgun/tui/screens/chat_screen/hint_message.py +76 -1
  129. shotgun/tui/screens/chat_screen/history/__init__.py +22 -0
  130. shotgun/tui/screens/chat_screen/history/agent_response.py +66 -0
  131. shotgun/tui/screens/chat_screen/history/chat_history.py +115 -0
  132. shotgun/tui/screens/chat_screen/history/formatters.py +115 -0
  133. shotgun/tui/screens/chat_screen/history/partial_response.py +43 -0
  134. shotgun/tui/screens/chat_screen/history/user_question.py +42 -0
  135. shotgun/tui/screens/confirmation_dialog.py +191 -0
  136. shotgun/tui/screens/directory_setup.py +45 -41
  137. shotgun/tui/screens/feedback.py +14 -7
  138. shotgun/tui/screens/github_issue.py +111 -0
  139. shotgun/tui/screens/model_picker.py +77 -32
  140. shotgun/tui/screens/onboarding.py +580 -0
  141. shotgun/tui/screens/pipx_migration.py +205 -0
  142. shotgun/tui/screens/provider_config.py +116 -35
  143. shotgun/tui/screens/shared_specs/__init__.py +21 -0
  144. shotgun/tui/screens/shared_specs/create_spec_dialog.py +273 -0
  145. shotgun/tui/screens/shared_specs/models.py +56 -0
  146. shotgun/tui/screens/shared_specs/share_specs_dialog.py +390 -0
  147. shotgun/tui/screens/shared_specs/upload_progress_screen.py +452 -0
  148. shotgun/tui/screens/shotgun_auth.py +112 -18
  149. shotgun/tui/screens/spec_pull.py +288 -0
  150. shotgun/tui/screens/welcome.py +137 -11
  151. shotgun/tui/services/__init__.py +5 -0
  152. shotgun/tui/services/conversation_service.py +187 -0
  153. shotgun/tui/state/__init__.py +7 -0
  154. shotgun/tui/state/processing_state.py +185 -0
  155. shotgun/tui/utils/mode_progress.py +14 -7
  156. shotgun/tui/widgets/__init__.py +5 -0
  157. shotgun/tui/widgets/widget_coordinator.py +263 -0
  158. shotgun/utils/file_system_utils.py +22 -2
  159. shotgun/utils/marketing.py +110 -0
  160. shotgun/utils/update_checker.py +69 -14
  161. shotgun_sh-0.3.3.dev1.dist-info/METADATA +472 -0
  162. shotgun_sh-0.3.3.dev1.dist-info/RECORD +229 -0
  163. {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/WHEEL +1 -1
  164. {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/entry_points.txt +1 -0
  165. {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/licenses/LICENSE +1 -1
  166. shotgun/tui/screens/chat.py +0 -996
  167. shotgun/tui/screens/chat_screen/history.py +0 -335
  168. shotgun_sh-0.2.8.dev2.dist-info/METADATA +0 -126
  169. shotgun_sh-0.2.8.dev2.dist-info/RECORD +0 -155
  170. /shotgun/agents/{history → conversation/history}/__init__.py +0 -0
  171. /shotgun/agents/{history → conversation/history}/context_extraction.py +0 -0
  172. /shotgun/agents/{history → conversation/history}/history_building.py +0 -0
  173. /shotgun/agents/{history → conversation/history}/message_utils.py +0 -0
  174. /shotgun/agents/{history → conversation/history}/token_counting/__init__.py +0 -0
  175. /shotgun/agents/{history → conversation/history}/token_estimation.py +0 -0
@@ -1,996 +0,0 @@
1
- import asyncio
2
- import logging
3
- from dataclasses import dataclass
4
- from pathlib import Path
5
- from typing import cast
6
-
7
- from pydantic_ai import RunContext
8
- from pydantic_ai.messages import (
9
- ModelMessage,
10
- ModelRequest,
11
- ModelResponse,
12
- TextPart,
13
- UserPromptPart,
14
- )
15
- from textual import events, on, work
16
- from textual.app import ComposeResult
17
- from textual.command import CommandPalette
18
- from textual.containers import Container, Grid
19
- from textual.keys import Keys
20
- from textual.reactive import reactive
21
- from textual.screen import ModalScreen, Screen
22
- from textual.widget import Widget
23
- from textual.widgets import Button, Label, Static
24
-
25
- from shotgun.agents.agent_manager import (
26
- AgentManager,
27
- ClarifyingQuestionsMessage,
28
- MessageHistoryUpdated,
29
- PartialResponseMessage,
30
- )
31
- from shotgun.agents.config import get_provider_model
32
- from shotgun.agents.conversation_history import (
33
- ConversationHistory,
34
- ConversationState,
35
- )
36
- from shotgun.agents.conversation_manager import ConversationManager
37
- from shotgun.agents.models import (
38
- AgentDeps,
39
- AgentType,
40
- FileOperationTracker,
41
- )
42
- from shotgun.codebase.core.manager import CodebaseAlreadyIndexedError
43
- from shotgun.codebase.models import IndexProgress, ProgressPhase
44
- from shotgun.posthog_telemetry import track_event
45
- from shotgun.sdk.codebase import CodebaseSDK
46
- from shotgun.sdk.exceptions import CodebaseNotFoundError, InvalidPathError
47
- from shotgun.tui.commands import CommandHandler
48
- from shotgun.tui.filtered_codebase_service import FilteredCodebaseService
49
- from shotgun.tui.screens.chat_screen.hint_message import HintMessage
50
- from shotgun.tui.screens.chat_screen.history import ChatHistory
51
- from shotgun.utils import get_shotgun_home
52
-
53
- from ..components.prompt_input import PromptInput
54
- from ..components.spinner import Spinner
55
- from ..utils.mode_progress import PlaceholderHints
56
- from .chat_screen.command_providers import (
57
- DeleteCodebasePaletteProvider,
58
- UnifiedCommandProvider,
59
- )
60
-
61
- logger = logging.getLogger(__name__)
62
-
63
-
64
- class PromptHistory:
65
- def __init__(self) -> None:
66
- self.prompts: list[str] = ["Hello there!"]
67
- self.curr: int | None = None
68
-
69
- def next(self) -> str:
70
- if self.curr is None:
71
- self.curr = -1
72
- else:
73
- self.curr = -1
74
- return self.prompts[self.curr]
75
-
76
- def prev(self) -> str:
77
- if self.curr is None:
78
- raise Exception("current entry is none")
79
- if self.curr == -1:
80
- self.curr = None
81
- return ""
82
- self.curr += 1
83
- return ""
84
-
85
- def append(self, text: str) -> None:
86
- self.prompts.append(text)
87
- self.curr = None
88
-
89
-
90
- @dataclass
91
- class CodebaseIndexSelection:
92
- """User-selected repository path and name for indexing."""
93
-
94
- repo_path: Path
95
- name: str
96
-
97
-
98
- class StatusBar(Widget):
99
- DEFAULT_CSS = """
100
- StatusBar {
101
- text-wrap: wrap;
102
- padding-left: 1;
103
- }
104
- """
105
-
106
- def __init__(self, working: bool = False) -> None:
107
- """Initialize the status bar.
108
-
109
- Args:
110
- working: Whether an agent is currently working.
111
- """
112
- super().__init__()
113
- self.working = working
114
-
115
- def render(self) -> str:
116
- # Check if in Q&A mode first (highest priority)
117
- try:
118
- chat_screen = self.screen
119
- if isinstance(chat_screen, ChatScreen) and chat_screen.qa_mode:
120
- return (
121
- "[$foreground-muted][bold $text]esc[/] to exit Q&A mode • "
122
- "[bold $text]enter[/] to send answer • [bold $text]ctrl+j[/] for newline[/]"
123
- )
124
- except Exception: # noqa: S110
125
- # If we can't access chat screen, continue with normal display
126
- pass
127
-
128
- if self.working:
129
- return (
130
- "[$foreground-muted][bold $text]esc[/] to stop • "
131
- "[bold $text]enter[/] to send • [bold $text]ctrl+j[/] for newline • "
132
- "[bold $text]ctrl+p[/] command palette • [bold $text]shift+tab[/] cycle modes • "
133
- "/help for commands[/]"
134
- )
135
- else:
136
- return (
137
- "[$foreground-muted][bold $text]enter[/] to send • "
138
- "[bold $text]ctrl+j[/] for newline • [bold $text]ctrl+p[/] command palette • "
139
- "[bold $text]shift+tab[/] cycle modes • /help for commands[/]"
140
- )
141
-
142
-
143
- class ModeIndicator(Widget):
144
- """Widget to display the current agent mode."""
145
-
146
- DEFAULT_CSS = """
147
- ModeIndicator {
148
- text-wrap: wrap;
149
- padding-left: 1;
150
- }
151
- """
152
-
153
- def __init__(self, mode: AgentType) -> None:
154
- """Initialize the mode indicator.
155
-
156
- Args:
157
- mode: The current agent type/mode.
158
- """
159
- super().__init__()
160
- self.mode = mode
161
- self.progress_checker = PlaceholderHints().progress_checker
162
-
163
- def render(self) -> str:
164
- """Render the mode indicator."""
165
- # Check if in Q&A mode first
166
- try:
167
- chat_screen = self.screen
168
- if isinstance(chat_screen, ChatScreen) and chat_screen.qa_mode:
169
- return (
170
- "[bold $text-accent]Q&A mode[/]"
171
- "[$foreground-muted] (Answer the clarifying questions or ESC to cancel)[/]"
172
- )
173
- except Exception: # noqa: S110
174
- # If we can't access chat screen, continue with normal display
175
- pass
176
-
177
- mode_display = {
178
- AgentType.RESEARCH: "Research",
179
- AgentType.PLAN: "Planning",
180
- AgentType.TASKS: "Tasks",
181
- AgentType.SPECIFY: "Specify",
182
- AgentType.EXPORT: "Export",
183
- }
184
- mode_description = {
185
- AgentType.RESEARCH: (
186
- "Research topics with web search and synthesize findings"
187
- ),
188
- AgentType.PLAN: "Create comprehensive, actionable plans with milestones",
189
- AgentType.TASKS: (
190
- "Generate specific, actionable tasks from research and plans"
191
- ),
192
- AgentType.SPECIFY: (
193
- "Create detailed specifications and requirements documents"
194
- ),
195
- AgentType.EXPORT: "Export artifacts and findings to various formats",
196
- }
197
-
198
- mode_title = mode_display.get(self.mode, self.mode.value.title())
199
- description = mode_description.get(self.mode, "")
200
-
201
- # Check if mode has content
202
- has_content = self.progress_checker.has_mode_content(self.mode)
203
- status_icon = " ✓" if has_content else ""
204
-
205
- return (
206
- f"[bold $text-accent]{mode_title}{status_icon} mode[/]"
207
- f"[$foreground-muted] ({description})[/]"
208
- )
209
-
210
-
211
- class CodebaseIndexPromptScreen(ModalScreen[bool]):
212
- """Modal dialog asking whether to index the detected codebase."""
213
-
214
- DEFAULT_CSS = """
215
- CodebaseIndexPromptScreen {
216
- align: center middle;
217
- background: rgba(0, 0, 0, 0.0);
218
- }
219
-
220
- CodebaseIndexPromptScreen > #index-prompt-dialog {
221
- width: 60%;
222
- max-width: 60;
223
- height: auto;
224
- border: wide $primary;
225
- padding: 1 2;
226
- layout: vertical;
227
- background: $surface;
228
- height: auto;
229
- }
230
-
231
- #index-prompt-buttons {
232
- layout: horizontal;
233
- align-horizontal: right;
234
- height: auto;
235
- }
236
- """
237
-
238
- def compose(self) -> ComposeResult:
239
- with Container(id="index-prompt-dialog"):
240
- yield Label("Index this codebase?", id="index-prompt-title")
241
- yield Static(
242
- f"Would you like to index the codebase at:\n{Path.cwd()}\n\n"
243
- "This is required for the agent to understand your code and answer "
244
- "questions about it. Without indexing, the agent cannot analyze "
245
- "your codebase."
246
- )
247
- with Container(id="index-prompt-buttons"):
248
- yield Button(
249
- "Index now",
250
- id="index-prompt-confirm",
251
- variant="primary",
252
- )
253
- yield Button("Not now", id="index-prompt-cancel")
254
-
255
- @on(Button.Pressed, "#index-prompt-cancel")
256
- def handle_cancel(self, event: Button.Pressed) -> None:
257
- event.stop()
258
- self.dismiss(False)
259
-
260
- @on(Button.Pressed, "#index-prompt-confirm")
261
- def handle_confirm(self, event: Button.Pressed) -> None:
262
- event.stop()
263
- self.dismiss(True)
264
-
265
-
266
- class ChatScreen(Screen[None]):
267
- CSS_PATH = "chat.tcss"
268
-
269
- BINDINGS = [
270
- ("ctrl+p", "command_palette", "Command Palette"),
271
- ("shift+tab", "toggle_mode", "Toggle mode"),
272
- ("ctrl+u", "show_usage", "Show usage"),
273
- ]
274
-
275
- COMMANDS = {
276
- UnifiedCommandProvider,
277
- }
278
-
279
- value = reactive("")
280
- mode = reactive(AgentType.RESEARCH)
281
- history: PromptHistory = PromptHistory()
282
- messages = reactive(list[ModelMessage | HintMessage]())
283
- working = reactive(False)
284
- indexing_job: reactive[CodebaseIndexSelection | None] = reactive(None)
285
- partial_message: reactive[ModelMessage | None] = reactive(None)
286
- _current_worker = None # Track the current running worker for cancellation
287
-
288
- # Q&A mode state (for structured output clarifying questions)
289
- qa_mode = reactive(False)
290
- qa_questions: list[str] = []
291
- qa_current_index = reactive(0)
292
- qa_answers: list[str] = []
293
-
294
- def __init__(self, continue_session: bool = False) -> None:
295
- super().__init__()
296
- # Get the model configuration and services
297
- model_config = get_provider_model()
298
- # Use filtered service in TUI to restrict access to CWD codebase only
299
- storage_dir = get_shotgun_home() / "codebases"
300
- codebase_service = FilteredCodebaseService(storage_dir)
301
- self.codebase_sdk = CodebaseSDK()
302
-
303
- # Create shared deps without system_prompt_fn (agents provide their own)
304
- # We need a placeholder system_prompt_fn to satisfy the field requirement
305
- def _placeholder_system_prompt_fn(ctx: RunContext[AgentDeps]) -> str:
306
- raise RuntimeError(
307
- "This should not be called - agents provide their own system_prompt_fn"
308
- )
309
-
310
- self.deps = AgentDeps(
311
- interactive_mode=True,
312
- is_tui_context=True,
313
- llm_model=model_config,
314
- codebase_service=codebase_service,
315
- system_prompt_fn=_placeholder_system_prompt_fn,
316
- )
317
- self.agent_manager = AgentManager(deps=self.deps, initial_type=self.mode)
318
- self.command_handler = CommandHandler()
319
- self.placeholder_hints = PlaceholderHints()
320
- self.conversation_manager = ConversationManager()
321
- self.continue_session = continue_session
322
-
323
- def on_mount(self) -> None:
324
- self.query_one(PromptInput).focus(scroll_visible=True)
325
- # Hide spinner initially
326
- self.query_one("#spinner").display = False
327
-
328
- # Load conversation history if --continue flag was provided
329
- if self.continue_session and self.conversation_manager.exists():
330
- self._load_conversation()
331
-
332
- self.call_later(self.check_if_codebase_is_indexed)
333
-
334
- async def on_key(self, event: events.Key) -> None:
335
- """Handle key presses for cancellation."""
336
- # If escape is pressed during Q&A mode, exit Q&A
337
- if event.key in (Keys.Escape, Keys.ControlC) and self.qa_mode:
338
- self._exit_qa_mode()
339
- # Re-enable the input
340
- prompt_input = self.query_one(PromptInput)
341
- prompt_input.focus()
342
- # Prevent the event from propagating (don't quit the app)
343
- event.stop()
344
- return
345
-
346
- # If escape or ctrl+c is pressed while agent is working, cancel the operation
347
- if (
348
- event.key in (Keys.Escape, Keys.ControlC)
349
- and self.working
350
- and self._current_worker
351
- ):
352
- # Track cancellation event
353
- track_event(
354
- "agent_cancelled",
355
- {
356
- "agent_mode": self.mode.value,
357
- "cancel_key": event.key,
358
- },
359
- )
360
-
361
- # Cancel the running agent worker
362
- self._current_worker.cancel()
363
- # Show cancellation message
364
- self.mount_hint("⚠️ Cancelling operation...")
365
- # Re-enable the input
366
- prompt_input = self.query_one(PromptInput)
367
- prompt_input.focus()
368
- # Prevent the event from propagating (don't quit the app)
369
- event.stop()
370
-
371
- @work
372
- async def check_if_codebase_is_indexed(self) -> None:
373
- cur_dir = Path.cwd().resolve()
374
- is_empty = all(
375
- dir.is_dir() and dir.name in ["__pycache__", ".git", ".shotgun"]
376
- for dir in cur_dir.iterdir()
377
- )
378
- if is_empty or self.continue_session:
379
- return
380
-
381
- # Check if the current directory has any accessible codebases
382
- accessible_graphs = (
383
- await self.codebase_sdk.list_codebases_for_directory()
384
- ).graphs
385
- if accessible_graphs:
386
- self.mount_hint(help_text_with_codebase(already_indexed=True))
387
- return
388
-
389
- # Ask user if they want to index the current directory
390
- should_index = await self.app.push_screen_wait(CodebaseIndexPromptScreen())
391
- if not should_index:
392
- self.mount_hint(help_text_empty_dir())
393
- return
394
-
395
- self.mount_hint(help_text_with_codebase(already_indexed=False))
396
-
397
- # Auto-index the current directory with its name
398
- cwd_name = cur_dir.name
399
- selection = CodebaseIndexSelection(repo_path=cur_dir, name=cwd_name)
400
- self.call_later(lambda: self.index_codebase(selection))
401
-
402
- def watch_mode(self, new_mode: AgentType) -> None:
403
- """React to mode changes by updating the agent manager."""
404
-
405
- if self.is_mounted:
406
- self.agent_manager.set_agent(new_mode)
407
-
408
- mode_indicator = self.query_one(ModeIndicator)
409
- mode_indicator.mode = new_mode
410
- mode_indicator.refresh()
411
-
412
- prompt_input = self.query_one(PromptInput)
413
- # Force new hint selection when mode changes
414
- prompt_input.placeholder = self._placeholder_for_mode(
415
- new_mode, force_new=True
416
- )
417
- prompt_input.refresh()
418
-
419
- def watch_working(self, is_working: bool) -> None:
420
- """Show or hide the spinner based on working state."""
421
- if self.is_mounted:
422
- spinner = self.query_one("#spinner")
423
- spinner.set_classes("" if is_working else "hidden")
424
- spinner.display = is_working
425
-
426
- # Update the status bar to show/hide "ESC to stop"
427
- status_bar = self.query_one(StatusBar)
428
- status_bar.working = is_working
429
- status_bar.refresh()
430
-
431
- def watch_qa_mode(self, qa_mode_active: bool) -> None:
432
- """Update UI when Q&A mode state changes."""
433
- if self.is_mounted:
434
- # Update status bar to show "ESC to exit Q&A mode"
435
- status_bar = self.query_one(StatusBar)
436
- status_bar.refresh()
437
-
438
- # Update mode indicator to show "Q&A mode"
439
- mode_indicator = self.query_one(ModeIndicator)
440
- mode_indicator.refresh()
441
-
442
- def watch_messages(self, messages: list[ModelMessage | HintMessage]) -> None:
443
- """Update the chat history when messages change."""
444
- if self.is_mounted:
445
- chat_history = self.query_one(ChatHistory)
446
- chat_history.update_messages(messages)
447
-
448
- def action_toggle_mode(self) -> None:
449
- # Prevent mode switching during Q&A
450
- if self.qa_mode:
451
- self.notify(
452
- "Cannot switch modes while answering questions",
453
- severity="warning",
454
- timeout=3,
455
- )
456
- return
457
-
458
- modes = [
459
- AgentType.RESEARCH,
460
- AgentType.SPECIFY,
461
- AgentType.PLAN,
462
- AgentType.TASKS,
463
- AgentType.EXPORT,
464
- ]
465
- self.mode = modes[(modes.index(self.mode) + 1) % len(modes)]
466
- self.agent_manager.set_agent(self.mode)
467
- # whoops it actually changes focus. Let's be brutal for now
468
- self.call_later(lambda: self.query_one(PromptInput).focus())
469
-
470
- def action_show_usage(self) -> None:
471
- usage_hint = self.agent_manager.get_usage_hint()
472
- logger.info(f"Usage hint: {usage_hint}")
473
- if usage_hint:
474
- self.mount_hint(usage_hint)
475
- else:
476
- self.notify("No usage hint available", severity="error")
477
-
478
- def compose(self) -> ComposeResult:
479
- """Create child widgets for the app."""
480
- with Container(id="window"):
481
- yield self.agent_manager
482
- yield ChatHistory()
483
- with Container(id="footer"):
484
- yield Spinner(
485
- text="Processing...",
486
- id="spinner",
487
- classes="" if self.working else "hidden",
488
- )
489
- yield StatusBar(working=self.working)
490
- yield PromptInput(
491
- text=self.value,
492
- highlight_cursor_line=False,
493
- id="prompt-input",
494
- placeholder=self._placeholder_for_mode(self.mode),
495
- )
496
- with Grid():
497
- yield ModeIndicator(mode=self.mode)
498
- yield Static("", id="indexing-job-display")
499
-
500
- def mount_hint(self, markdown: str) -> None:
501
- hint = HintMessage(message=markdown)
502
- self.agent_manager.add_hint_message(hint)
503
-
504
- @on(PartialResponseMessage)
505
- def handle_partial_response(self, event: PartialResponseMessage) -> None:
506
- self.partial_message = event.message
507
- history = self.query_one(ChatHistory)
508
-
509
- # Only update messages if the message list changed
510
- new_message_list = self.messages + cast(
511
- list[ModelMessage | HintMessage], event.messages
512
- )
513
- if len(new_message_list) != len(history.items):
514
- history.update_messages(new_message_list)
515
-
516
- # Always update the partial response (reactive property handles the update)
517
- history.partial_response = self.partial_message
518
-
519
- def _clear_partial_response(self) -> None:
520
- partial_response_widget = self.query_one(ChatHistory)
521
- partial_response_widget.partial_response = None
522
-
523
- def _exit_qa_mode(self) -> None:
524
- """Exit Q&A mode and clean up state."""
525
- # Track cancellation event
526
- track_event(
527
- "qa_mode_cancelled",
528
- {
529
- "questions_total": len(self.qa_questions),
530
- "questions_answered": len(self.qa_answers),
531
- },
532
- )
533
-
534
- # Clear Q&A state
535
- self.qa_mode = False
536
- self.qa_questions = []
537
- self.qa_answers = []
538
- self.qa_current_index = 0
539
-
540
- # Show cancellation message
541
- self.mount_hint("⚠️ Q&A cancelled - You can continue the conversation.")
542
-
543
- @on(ClarifyingQuestionsMessage)
544
- def handle_clarifying_questions(self, event: ClarifyingQuestionsMessage) -> None:
545
- """Handle clarifying questions from agent structured output.
546
-
547
- Note: Hints are now added synchronously in agent_manager.run() before this
548
- handler is called, so we only need to set up Q&A mode state here.
549
- """
550
- # Clear any streaming partial response (removes final_result JSON)
551
- self._clear_partial_response()
552
-
553
- # Enter Q&A mode
554
- self.qa_mode = True
555
- self.qa_questions = event.questions
556
- self.qa_current_index = 0
557
- self.qa_answers = []
558
-
559
- @on(MessageHistoryUpdated)
560
- def handle_message_history_updated(self, event: MessageHistoryUpdated) -> None:
561
- """Handle message history updates from the agent manager."""
562
- self._clear_partial_response()
563
- self.messages = event.messages
564
-
565
- # Refresh placeholder and mode indicator in case artifacts were created
566
- prompt_input = self.query_one(PromptInput)
567
- prompt_input.placeholder = self._placeholder_for_mode(self.mode)
568
- prompt_input.refresh()
569
-
570
- mode_indicator = self.query_one(ModeIndicator)
571
- mode_indicator.refresh()
572
-
573
- # If there are file operations, add a message showing the modified files
574
- if event.file_operations:
575
- chat_history = self.query_one(ChatHistory)
576
- if chat_history.vertical_tail:
577
- tracker = FileOperationTracker(operations=event.file_operations)
578
- display_path = tracker.get_display_path()
579
-
580
- if display_path:
581
- # Create a simple markdown message with the file path
582
- # The terminal emulator will make this clickable automatically
583
- from pathlib import Path
584
-
585
- path_obj = Path(display_path)
586
-
587
- if len(event.file_operations) == 1:
588
- message = f"📝 Modified: `{display_path}`"
589
- else:
590
- num_files = len({op.file_path for op in event.file_operations})
591
- if path_obj.is_dir():
592
- message = (
593
- f"📁 Modified {num_files} files in: `{display_path}`"
594
- )
595
- else:
596
- # Common path is a file, show parent directory
597
- message = (
598
- f"📁 Modified {num_files} files in: `{path_obj.parent}`"
599
- )
600
-
601
- self.mount_hint(message)
602
-
603
- @on(PromptInput.Submitted)
604
- async def handle_submit(self, message: PromptInput.Submitted) -> None:
605
- text = message.text.strip()
606
-
607
- # If empty text, just clear input and return
608
- if not text:
609
- prompt_input = self.query_one(PromptInput)
610
- prompt_input.clear()
611
- self.value = ""
612
- return
613
-
614
- # Handle Q&A mode (from structured output clarifying questions)
615
- if self.qa_mode and self.qa_questions:
616
- # Collect answer
617
- self.qa_answers.append(text)
618
-
619
- # Show answer
620
- if len(self.qa_questions) == 1:
621
- self.agent_manager.add_hint_message(
622
- HintMessage(message=f"**A:** {text}")
623
- )
624
- else:
625
- q_num = self.qa_current_index + 1
626
- self.agent_manager.add_hint_message(
627
- HintMessage(message=f"**A{q_num}:** {text}")
628
- )
629
-
630
- # Move to next or finish
631
- self.qa_current_index += 1
632
-
633
- if self.qa_current_index < len(self.qa_questions):
634
- # Show next question
635
- next_q = self.qa_questions[self.qa_current_index]
636
- next_q_num = self.qa_current_index + 1
637
- self.agent_manager.add_hint_message(
638
- HintMessage(message=f"**Q{next_q_num}:** {next_q}")
639
- )
640
- else:
641
- # All answered - format and send back
642
- if len(self.qa_questions) == 1:
643
- # Single question - just send the answer
644
- formatted_qa = f"Q: {self.qa_questions[0]}\nA: {self.qa_answers[0]}"
645
- else:
646
- # Multiple questions - format all Q&A pairs
647
- formatted_qa = "\n\n".join(
648
- f"Q{i + 1}: {q}\nA{i + 1}: {a}"
649
- for i, (q, a) in enumerate(
650
- zip(self.qa_questions, self.qa_answers, strict=True)
651
- )
652
- )
653
-
654
- # Exit Q&A mode
655
- self.qa_mode = False
656
- self.qa_questions = []
657
- self.qa_answers = []
658
- self.qa_current_index = 0
659
-
660
- # Send answers back to agent
661
- self.run_agent(formatted_qa)
662
-
663
- # Clear input
664
- prompt_input = self.query_one(PromptInput)
665
- prompt_input.clear()
666
- self.value = ""
667
- return
668
-
669
- # Check if it's a command
670
- if self.command_handler.is_command(text):
671
- success, response = self.command_handler.handle_command(text)
672
-
673
- # Add the command to history
674
- self.history.append(message.text)
675
-
676
- # Display the command in chat history
677
- user_message = ModelRequest(parts=[UserPromptPart(content=text)])
678
- self.messages = self.messages + [user_message]
679
-
680
- # Display the response (help text or error message)
681
- response_message = ModelResponse(parts=[TextPart(content=response)])
682
- self.messages = self.messages + [response_message]
683
-
684
- # Clear the input
685
- prompt_input = self.query_one(PromptInput)
686
- prompt_input.clear()
687
- self.value = ""
688
- return
689
-
690
- # Not a command, process as normal
691
- self.history.append(message.text)
692
-
693
- # Clear the input
694
- self.value = ""
695
- self.run_agent(text) # Use stripped text
696
-
697
- prompt_input = self.query_one(PromptInput)
698
- prompt_input.clear()
699
-
700
- def _placeholder_for_mode(self, mode: AgentType, force_new: bool = False) -> str:
701
- """Return the placeholder text appropriate for the current mode.
702
-
703
- Args:
704
- mode: The current agent mode.
705
- force_new: If True, force selection of a new random hint.
706
-
707
- Returns:
708
- Dynamic placeholder hint based on mode and progress.
709
- """
710
- return self.placeholder_hints.get_placeholder_for_mode(mode)
711
-
712
- def index_codebase_command(self) -> None:
713
- # Simplified: always index current working directory with its name
714
- cur_dir = Path.cwd().resolve()
715
- cwd_name = cur_dir.name
716
- selection = CodebaseIndexSelection(repo_path=cur_dir, name=cwd_name)
717
- self.call_later(lambda: self.index_codebase(selection))
718
-
719
- def delete_codebase_command(self) -> None:
720
- self.app.push_screen(
721
- CommandPalette(
722
- providers=[DeleteCodebasePaletteProvider],
723
- placeholder="Select a codebase to delete…",
724
- )
725
- )
726
-
727
- def delete_codebase_from_palette(self, graph_id: str) -> None:
728
- stack = getattr(self.app, "screen_stack", None)
729
- if stack and isinstance(stack[-1], CommandPalette):
730
- self.app.pop_screen()
731
-
732
- self.call_later(lambda: self.delete_codebase(graph_id))
733
-
734
- @work
735
- async def delete_codebase(self, graph_id: str) -> None:
736
- try:
737
- await self.codebase_sdk.delete_codebase(graph_id)
738
- self.notify(f"Deleted codebase: {graph_id}", severity="information")
739
- except CodebaseNotFoundError as exc:
740
- self.notify(str(exc), severity="error")
741
- except Exception as exc: # pragma: no cover - defensive UI path
742
- self.notify(f"Failed to delete codebase: {exc}", severity="error")
743
-
744
- @work
745
- async def index_codebase(self, selection: CodebaseIndexSelection) -> None:
746
- label = self.query_one("#indexing-job-display", Static)
747
- label.update(
748
- f"[$foreground-muted]Indexing codebase: [bold $text-accent]{selection.name}[/][/]"
749
- )
750
- label.refresh()
751
-
752
- def create_progress_bar(percentage: float, width: int = 20) -> str:
753
- """Create a visual progress bar using Unicode block characters."""
754
- filled = int((percentage / 100) * width)
755
- empty = width - filled
756
- return "▓" * filled + "░" * empty
757
-
758
- # Spinner animation frames
759
- spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
760
-
761
- # Progress state (shared between timer and progress callback)
762
- progress_state: dict[str, int | float] = {
763
- "frame_index": 0,
764
- "percentage": 0.0,
765
- }
766
-
767
- def update_progress_display() -> None:
768
- """Update progress bar on timer - runs every 100ms."""
769
- # Advance spinner frame
770
- frame_idx = int(progress_state["frame_index"])
771
- progress_state["frame_index"] = (frame_idx + 1) % len(spinner_frames)
772
- spinner = spinner_frames[frame_idx]
773
-
774
- # Get current state
775
- pct = float(progress_state["percentage"])
776
- bar = create_progress_bar(pct)
777
-
778
- # Update label
779
- label.update(
780
- f"[$foreground-muted]Indexing codebase: {spinner} {bar} {pct:.0f}%[/]"
781
- )
782
-
783
- def progress_callback(progress_info: IndexProgress) -> None:
784
- """Update progress state (timer renders it independently)."""
785
- # Calculate overall percentage (0-95%, reserve 95-100% for finalization)
786
- if progress_info.phase == ProgressPhase.STRUCTURE:
787
- # Phase 1: 0-10%, always show 5% while running, 10% when complete
788
- overall_pct = 10.0 if progress_info.phase_complete else 5.0
789
- elif progress_info.phase == ProgressPhase.DEFINITIONS:
790
- # Phase 2: 10-80% based on files processed
791
- if progress_info.total and progress_info.total > 0:
792
- phase_pct = (progress_info.current / progress_info.total) * 70.0
793
- overall_pct = 10.0 + phase_pct
794
- else:
795
- overall_pct = 10.0
796
- elif progress_info.phase == ProgressPhase.RELATIONSHIPS:
797
- # Phase 3: 80-95% based on relationships processed (cap at 95%)
798
- if progress_info.total and progress_info.total > 0:
799
- phase_pct = (progress_info.current / progress_info.total) * 15.0
800
- overall_pct = 80.0 + phase_pct
801
- else:
802
- overall_pct = 80.0
803
- else:
804
- overall_pct = 0.0
805
-
806
- # Update shared state (timer will render it)
807
- progress_state["percentage"] = overall_pct
808
-
809
- # Start progress animation timer (10 fps = 100ms interval)
810
- progress_timer = self.set_interval(0.1, update_progress_display)
811
-
812
- try:
813
- # Pass the current working directory as the indexed_from_cwd
814
- logger.debug(
815
- f"Starting indexing - repo_path: {selection.repo_path}, "
816
- f"name: {selection.name}, cwd: {Path.cwd().resolve()}"
817
- )
818
- result = await self.codebase_sdk.index_codebase(
819
- selection.repo_path,
820
- selection.name,
821
- indexed_from_cwd=str(Path.cwd().resolve()),
822
- progress_callback=progress_callback,
823
- )
824
-
825
- # Stop progress animation
826
- progress_timer.stop()
827
-
828
- # Show 100% completion after indexing finishes
829
- final_bar = create_progress_bar(100.0)
830
- label.update(f"[$foreground-muted]Indexing codebase: {final_bar} 100%[/]")
831
- label.refresh()
832
-
833
- logger.info(
834
- f"Successfully indexed codebase '{result.name}' (ID: {result.graph_id})"
835
- )
836
- self.notify(
837
- f"Indexed codebase '{result.name}' (ID: {result.graph_id})",
838
- severity="information",
839
- timeout=8,
840
- )
841
-
842
- except CodebaseAlreadyIndexedError as exc:
843
- progress_timer.stop()
844
- logger.warning(f"Codebase already indexed: {exc}")
845
- self.notify(str(exc), severity="warning")
846
- return
847
- except InvalidPathError as exc:
848
- progress_timer.stop()
849
- logger.error(f"Invalid path error: {exc}")
850
- self.notify(str(exc), severity="error")
851
-
852
- except Exception as exc: # pragma: no cover - defensive UI path
853
- # Log full exception details with stack trace
854
- logger.exception(
855
- f"Failed to index codebase - repo_path: {selection.repo_path}, "
856
- f"name: {selection.name}, error: {exc}"
857
- )
858
- self.notify(f"Failed to index codebase: {exc}", severity="error")
859
- finally:
860
- # Always stop the progress timer
861
- progress_timer.stop()
862
- label.update("")
863
- label.refresh()
864
-
865
- @work
866
- async def run_agent(self, message: str) -> None:
867
- prompt = None
868
- self.working = True
869
-
870
- # Store the worker so we can cancel it if needed
871
- from textual.worker import get_current_worker
872
-
873
- self._current_worker = get_current_worker()
874
-
875
- prompt = message
876
-
877
- try:
878
- await self.agent_manager.run(
879
- prompt=prompt,
880
- )
881
- except asyncio.CancelledError:
882
- # Handle cancellation gracefully - DO NOT re-raise
883
- self.mount_hint("⚠️ Operation cancelled by user")
884
- except Exception as e:
885
- # Log with full stack trace to shotgun.log
886
- logger.exception(
887
- "Agent run failed",
888
- extra={
889
- "agent_mode": self.mode.value,
890
- "error_type": type(e).__name__,
891
- },
892
- )
893
-
894
- # Determine user-friendly message based on error type
895
- error_name = type(e).__name__
896
- error_message = str(e)
897
-
898
- if "APIStatusError" in error_name and "overload" in error_message.lower():
899
- hint = "⚠️ The AI service is temporarily overloaded. Please wait a moment and try again."
900
- elif "APIStatusError" in error_name and "rate" in error_message.lower():
901
- hint = "⚠️ Rate limit reached. Please wait before trying again."
902
- elif "APIStatusError" in error_name:
903
- hint = f"⚠️ AI service error: {error_message}"
904
- else:
905
- hint = f"⚠️ An error occurred: {error_message}\n\nCheck logs at ~/.shotgun-sh/logs/shotgun.log"
906
-
907
- self.mount_hint(hint)
908
- finally:
909
- self.working = False
910
- self._current_worker = None
911
-
912
- # Save conversation after each interaction
913
- self._save_conversation()
914
-
915
- prompt_input = self.query_one(PromptInput)
916
- prompt_input.focus()
917
-
918
- def _save_conversation(self) -> None:
919
- """Save the current conversation to persistent storage."""
920
- # Get conversation state from agent manager
921
- state = self.agent_manager.get_conversation_state()
922
-
923
- # Create conversation history object
924
- conversation = ConversationHistory(
925
- last_agent_model=state.agent_type,
926
- )
927
- conversation.set_agent_messages(state.agent_messages)
928
- conversation.set_ui_messages(state.ui_messages)
929
-
930
- # Save to file
931
- self.conversation_manager.save(conversation)
932
-
933
- def _load_conversation(self) -> None:
934
- """Load conversation from persistent storage."""
935
- conversation = self.conversation_manager.load()
936
- if conversation is None:
937
- # Check if file existed but was corrupted (backup was created)
938
- backup_path = self.conversation_manager.conversation_path.with_suffix(
939
- ".json.backup"
940
- )
941
- if backup_path.exists():
942
- # File was corrupted - show friendly notification
943
- self.mount_hint(
944
- "⚠️ Previous session was corrupted and has been backed up. Starting fresh conversation."
945
- )
946
- return
947
-
948
- try:
949
- # Restore agent state
950
- agent_messages = conversation.get_agent_messages()
951
- ui_messages = conversation.get_ui_messages()
952
-
953
- # Create ConversationState for restoration
954
- state = ConversationState(
955
- agent_messages=agent_messages,
956
- ui_messages=ui_messages,
957
- agent_type=conversation.last_agent_model,
958
- )
959
-
960
- self.agent_manager.restore_conversation_state(state)
961
-
962
- # Update the current mode
963
- self.mode = AgentType(conversation.last_agent_model)
964
- self.deps.usage_manager.restore_usage_state()
965
-
966
- except Exception as e: # pragma: no cover
967
- # If anything goes wrong during restoration, log it and continue
968
- logger.error("Failed to restore conversation state: %s", e)
969
- self.mount_hint(
970
- "⚠️ Could not restore previous session. Starting fresh conversation."
971
- )
972
-
973
-
974
- def help_text_with_codebase(already_indexed: bool = False) -> str:
975
- return (
976
- "Howdy! Welcome to Shotgun - the context tool for software engineering. \n\n"
977
- "You can research, build specs, plan, create tasks, and export context to your "
978
- "favorite code-gen agents.\n\n"
979
- f"{'' if already_indexed else 'Once your codebase is indexed, '}I can help with:\n\n"
980
- "- Speccing out a new feature\n"
981
- "- Onboarding you onto this project\n"
982
- "- Helping with a refactor spec\n"
983
- "- Creating AGENTS.md file for this project\n"
984
- )
985
-
986
-
987
- def help_text_empty_dir() -> str:
988
- return (
989
- "Howdy! Welcome to Shotgun - the context tool for software engineering.\n\n"
990
- "You can research, build specs, plan, create tasks, and export context to your "
991
- "favorite code-gen agents.\n\n"
992
- "What would you like to build? Here are some examples:\n\n"
993
- "- Research FastAPI vs Django\n"
994
- "- Plan my new web app using React\n"
995
- "- Create PRD for my planned product\n"
996
- )