shotgun-sh 0.1.9__py3-none-any.whl → 0.2.11__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

Files changed (150) hide show
  1. shotgun/agents/agent_manager.py +761 -52
  2. shotgun/agents/common.py +80 -75
  3. shotgun/agents/config/constants.py +21 -10
  4. shotgun/agents/config/manager.py +322 -97
  5. shotgun/agents/config/models.py +114 -84
  6. shotgun/agents/config/provider.py +232 -88
  7. shotgun/agents/context_analyzer/__init__.py +28 -0
  8. shotgun/agents/context_analyzer/analyzer.py +471 -0
  9. shotgun/agents/context_analyzer/constants.py +9 -0
  10. shotgun/agents/context_analyzer/formatter.py +115 -0
  11. shotgun/agents/context_analyzer/models.py +212 -0
  12. shotgun/agents/conversation_history.py +125 -2
  13. shotgun/agents/conversation_manager.py +57 -19
  14. shotgun/agents/export.py +6 -7
  15. shotgun/agents/history/compaction.py +23 -3
  16. shotgun/agents/history/context_extraction.py +93 -6
  17. shotgun/agents/history/history_processors.py +179 -11
  18. shotgun/agents/history/token_counting/__init__.py +31 -0
  19. shotgun/agents/history/token_counting/anthropic.py +127 -0
  20. shotgun/agents/history/token_counting/base.py +78 -0
  21. shotgun/agents/history/token_counting/openai.py +90 -0
  22. shotgun/agents/history/token_counting/sentencepiece_counter.py +127 -0
  23. shotgun/agents/history/token_counting/tokenizer_cache.py +92 -0
  24. shotgun/agents/history/token_counting/utils.py +144 -0
  25. shotgun/agents/history/token_estimation.py +12 -12
  26. shotgun/agents/llm.py +62 -0
  27. shotgun/agents/models.py +59 -4
  28. shotgun/agents/plan.py +6 -7
  29. shotgun/agents/research.py +7 -8
  30. shotgun/agents/specify.py +6 -7
  31. shotgun/agents/tasks.py +6 -7
  32. shotgun/agents/tools/__init__.py +0 -2
  33. shotgun/agents/tools/codebase/codebase_shell.py +6 -0
  34. shotgun/agents/tools/codebase/directory_lister.py +6 -0
  35. shotgun/agents/tools/codebase/file_read.py +11 -2
  36. shotgun/agents/tools/codebase/query_graph.py +6 -0
  37. shotgun/agents/tools/codebase/retrieve_code.py +6 -0
  38. shotgun/agents/tools/file_management.py +82 -16
  39. shotgun/agents/tools/registry.py +217 -0
  40. shotgun/agents/tools/web_search/__init__.py +55 -16
  41. shotgun/agents/tools/web_search/anthropic.py +76 -51
  42. shotgun/agents/tools/web_search/gemini.py +50 -27
  43. shotgun/agents/tools/web_search/openai.py +26 -17
  44. shotgun/agents/tools/web_search/utils.py +2 -2
  45. shotgun/agents/usage_manager.py +164 -0
  46. shotgun/api_endpoints.py +15 -0
  47. shotgun/cli/clear.py +53 -0
  48. shotgun/cli/codebase/commands.py +71 -2
  49. shotgun/cli/compact.py +186 -0
  50. shotgun/cli/config.py +41 -67
  51. shotgun/cli/context.py +111 -0
  52. shotgun/cli/export.py +1 -1
  53. shotgun/cli/feedback.py +50 -0
  54. shotgun/cli/models.py +3 -2
  55. shotgun/cli/plan.py +1 -1
  56. shotgun/cli/research.py +1 -1
  57. shotgun/cli/specify.py +1 -1
  58. shotgun/cli/tasks.py +1 -1
  59. shotgun/cli/update.py +18 -5
  60. shotgun/codebase/core/change_detector.py +5 -3
  61. shotgun/codebase/core/code_retrieval.py +4 -2
  62. shotgun/codebase/core/ingestor.py +169 -19
  63. shotgun/codebase/core/manager.py +177 -13
  64. shotgun/codebase/core/nl_query.py +1 -1
  65. shotgun/codebase/models.py +28 -3
  66. shotgun/codebase/service.py +14 -2
  67. shotgun/exceptions.py +32 -0
  68. shotgun/llm_proxy/__init__.py +19 -0
  69. shotgun/llm_proxy/clients.py +44 -0
  70. shotgun/llm_proxy/constants.py +15 -0
  71. shotgun/logging_config.py +18 -27
  72. shotgun/main.py +91 -4
  73. shotgun/posthog_telemetry.py +87 -40
  74. shotgun/prompts/agents/export.j2 +18 -1
  75. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +5 -1
  76. shotgun/prompts/agents/partials/interactive_mode.j2 +24 -7
  77. shotgun/prompts/agents/plan.j2 +1 -1
  78. shotgun/prompts/agents/research.j2 +1 -1
  79. shotgun/prompts/agents/specify.j2 +270 -3
  80. shotgun/prompts/agents/state/system_state.j2 +4 -0
  81. shotgun/prompts/agents/tasks.j2 +1 -1
  82. shotgun/prompts/codebase/partials/cypher_rules.j2 +13 -0
  83. shotgun/prompts/loader.py +2 -2
  84. shotgun/prompts/tools/web_search.j2 +14 -0
  85. shotgun/sdk/codebase.py +60 -2
  86. shotgun/sentry_telemetry.py +28 -21
  87. shotgun/settings.py +238 -0
  88. shotgun/shotgun_web/__init__.py +19 -0
  89. shotgun/shotgun_web/client.py +138 -0
  90. shotgun/shotgun_web/constants.py +21 -0
  91. shotgun/shotgun_web/models.py +47 -0
  92. shotgun/telemetry.py +24 -36
  93. shotgun/tui/app.py +275 -23
  94. shotgun/tui/commands/__init__.py +1 -1
  95. shotgun/tui/components/context_indicator.py +179 -0
  96. shotgun/tui/components/mode_indicator.py +70 -0
  97. shotgun/tui/components/status_bar.py +48 -0
  98. shotgun/tui/components/vertical_tail.py +6 -0
  99. shotgun/tui/containers.py +91 -0
  100. shotgun/tui/dependencies.py +39 -0
  101. shotgun/tui/filtered_codebase_service.py +46 -0
  102. shotgun/tui/protocols.py +45 -0
  103. shotgun/tui/screens/chat/__init__.py +5 -0
  104. shotgun/tui/screens/chat/chat.tcss +54 -0
  105. shotgun/tui/screens/chat/chat_screen.py +1234 -0
  106. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +64 -0
  107. shotgun/tui/screens/chat/codebase_index_selection.py +12 -0
  108. shotgun/tui/screens/chat/help_text.py +40 -0
  109. shotgun/tui/screens/chat/prompt_history.py +48 -0
  110. shotgun/tui/screens/chat.tcss +11 -0
  111. shotgun/tui/screens/chat_screen/command_providers.py +226 -11
  112. shotgun/tui/screens/chat_screen/history/__init__.py +22 -0
  113. shotgun/tui/screens/chat_screen/history/agent_response.py +66 -0
  114. shotgun/tui/screens/chat_screen/history/chat_history.py +116 -0
  115. shotgun/tui/screens/chat_screen/history/formatters.py +115 -0
  116. shotgun/tui/screens/chat_screen/history/partial_response.py +43 -0
  117. shotgun/tui/screens/chat_screen/history/user_question.py +42 -0
  118. shotgun/tui/screens/confirmation_dialog.py +151 -0
  119. shotgun/tui/screens/feedback.py +193 -0
  120. shotgun/tui/screens/github_issue.py +102 -0
  121. shotgun/tui/screens/model_picker.py +352 -0
  122. shotgun/tui/screens/onboarding.py +431 -0
  123. shotgun/tui/screens/pipx_migration.py +153 -0
  124. shotgun/tui/screens/provider_config.py +156 -39
  125. shotgun/tui/screens/shotgun_auth.py +295 -0
  126. shotgun/tui/screens/welcome.py +198 -0
  127. shotgun/tui/services/__init__.py +5 -0
  128. shotgun/tui/services/conversation_service.py +184 -0
  129. shotgun/tui/state/__init__.py +7 -0
  130. shotgun/tui/state/processing_state.py +185 -0
  131. shotgun/tui/utils/mode_progress.py +14 -7
  132. shotgun/tui/widgets/__init__.py +5 -0
  133. shotgun/tui/widgets/widget_coordinator.py +262 -0
  134. shotgun/utils/datetime_utils.py +77 -0
  135. shotgun/utils/env_utils.py +13 -0
  136. shotgun/utils/file_system_utils.py +22 -2
  137. shotgun/utils/marketing.py +110 -0
  138. shotgun/utils/source_detection.py +16 -0
  139. shotgun/utils/update_checker.py +73 -21
  140. shotgun_sh-0.2.11.dist-info/METADATA +130 -0
  141. shotgun_sh-0.2.11.dist-info/RECORD +194 -0
  142. {shotgun_sh-0.1.9.dist-info → shotgun_sh-0.2.11.dist-info}/entry_points.txt +1 -0
  143. {shotgun_sh-0.1.9.dist-info → shotgun_sh-0.2.11.dist-info}/licenses/LICENSE +1 -1
  144. shotgun/agents/history/token_counting.py +0 -429
  145. shotgun/agents/tools/user_interaction.py +0 -37
  146. shotgun/tui/screens/chat.py +0 -818
  147. shotgun/tui/screens/chat_screen/history.py +0 -222
  148. shotgun_sh-0.1.9.dist-info/METADATA +0 -466
  149. shotgun_sh-0.1.9.dist-info/RECORD +0 -131
  150. {shotgun_sh-0.1.9.dist-info → shotgun_sh-0.2.11.dist-info}/WHEEL +0 -0
@@ -1,818 +0,0 @@
1
- import asyncio
2
- import logging
3
- from collections.abc import Iterable
4
- from dataclasses import dataclass
5
- from pathlib import Path
6
- from typing import cast
7
-
8
- from pydantic_ai import DeferredToolResults, RunContext
9
- from pydantic_ai.messages import (
10
- ModelMessage,
11
- ModelRequest,
12
- ModelResponse,
13
- TextPart,
14
- UserPromptPart,
15
- )
16
- from textual import events, on, work
17
- from textual.app import ComposeResult
18
- from textual.command import CommandPalette
19
- from textual.containers import Container, Grid
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, DirectoryTree, Input, Label, Markdown, Static
24
-
25
- from shotgun.agents.agent_manager import (
26
- AgentManager,
27
- MessageHistoryUpdated,
28
- PartialResponseMessage,
29
- )
30
- from shotgun.agents.config import get_provider_model
31
- from shotgun.agents.conversation_history import (
32
- ConversationHistory,
33
- ConversationState,
34
- )
35
- from shotgun.agents.conversation_manager import ConversationManager
36
- from shotgun.agents.models import (
37
- AgentDeps,
38
- AgentType,
39
- FileOperationTracker,
40
- UserAnswer,
41
- UserQuestion,
42
- )
43
- from shotgun.codebase.core.manager import CodebaseAlreadyIndexedError
44
- from shotgun.sdk.codebase import CodebaseSDK
45
- from shotgun.sdk.exceptions import CodebaseNotFoundError, InvalidPathError
46
- from shotgun.sdk.services import get_codebase_service
47
- from shotgun.tui.commands import CommandHandler
48
- from shotgun.tui.screens.chat_screen.hint_message import HintMessage
49
- from shotgun.tui.screens.chat_screen.history import ChatHistory
50
-
51
- from ..components.prompt_input import PromptInput
52
- from ..components.spinner import Spinner
53
- from ..utils.mode_progress import PlaceholderHints
54
- from .chat_screen.command_providers import (
55
- AgentModeProvider,
56
- CodebaseCommandProvider,
57
- DeleteCodebasePaletteProvider,
58
- ProviderSetupProvider,
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
- if self.working:
117
- return """[$foreground-muted][bold $text]esc[/] to stop • [bold $text]enter[/] to send • [bold $text]ctrl+p[/] command palette • [bold $text]shift+tab[/] cycle modes • /help for commands[/]"""
118
- else:
119
- return """[$foreground-muted][bold $text]enter[/] to send • [bold $text]ctrl+p[/] command palette • [bold $text]shift+tab[/] cycle modes • /help for commands[/]"""
120
-
121
-
122
- class ModeIndicator(Widget):
123
- """Widget to display the current agent mode."""
124
-
125
- DEFAULT_CSS = """
126
- ModeIndicator {
127
- text-wrap: wrap;
128
- padding-left: 1;
129
- }
130
- """
131
-
132
- def __init__(self, mode: AgentType) -> None:
133
- """Initialize the mode indicator.
134
-
135
- Args:
136
- mode: The current agent type/mode.
137
- """
138
- super().__init__()
139
- self.mode = mode
140
- self.progress_checker = PlaceholderHints().progress_checker
141
-
142
- def render(self) -> str:
143
- """Render the mode indicator."""
144
- mode_display = {
145
- AgentType.RESEARCH: "Research",
146
- AgentType.PLAN: "Planning",
147
- AgentType.TASKS: "Tasks",
148
- AgentType.SPECIFY: "Specify",
149
- AgentType.EXPORT: "Export",
150
- }
151
- mode_description = {
152
- AgentType.RESEARCH: "Research topics with web search and synthesize findings",
153
- AgentType.PLAN: "Create comprehensive, actionable plans with milestones",
154
- AgentType.TASKS: "Generate specific, actionable tasks from research and plans",
155
- AgentType.SPECIFY: "Create detailed specifications and requirements documents",
156
- AgentType.EXPORT: "Export artifacts and findings to various formats",
157
- }
158
-
159
- mode_title = mode_display.get(self.mode, self.mode.value.title())
160
- description = mode_description.get(self.mode, "")
161
-
162
- # Check if mode has content
163
- has_content = self.progress_checker.has_mode_content(self.mode)
164
- status_icon = " ✓" if has_content else ""
165
-
166
- return f"[bold $text-accent]{mode_title}{status_icon} mode[/][$foreground-muted] ({description})[/]"
167
-
168
-
169
- class FilteredDirectoryTree(DirectoryTree):
170
- def filter_paths(self, paths: Iterable[Path]) -> Iterable[Path]:
171
- return [path for path in paths if path.is_dir()]
172
-
173
-
174
- class CodebaseIndexPromptScreen(ModalScreen[bool]):
175
- """Modal dialog asking whether to index the detected codebase."""
176
-
177
- DEFAULT_CSS = """
178
- CodebaseIndexPromptScreen {
179
- align: center middle;
180
- background: rgba(0, 0, 0, 0.0);
181
- }
182
-
183
- CodebaseIndexPromptScreen > #index-prompt-dialog {
184
- width: 60%;
185
- max-width: 60;
186
- height: auto;
187
- border: wide $primary;
188
- padding: 1 2;
189
- layout: vertical;
190
- background: $surface;
191
- height: auto;
192
- }
193
-
194
- #index-prompt-buttons {
195
- layout: horizontal;
196
- align-horizontal: right;
197
- height: auto;
198
- }
199
- """
200
-
201
- def compose(self) -> ComposeResult:
202
- with Container(id="index-prompt-dialog"):
203
- yield Label("Index this codebase?", id="index-prompt-title")
204
- yield Static(
205
- f"Would you like to index the codebase at:\n{Path.cwd()}\n\n"
206
- "This is required for the agent to understand your code and answer "
207
- "questions about it. Without indexing, the agent cannot analyze your codebase."
208
- )
209
- with Container(id="index-prompt-buttons"):
210
- yield Button(
211
- "Index now",
212
- id="index-prompt-confirm",
213
- variant="primary",
214
- )
215
- yield Button("Not now", id="index-prompt-cancel")
216
-
217
- @on(Button.Pressed, "#index-prompt-cancel")
218
- def handle_cancel(self, event: Button.Pressed) -> None:
219
- event.stop()
220
- self.dismiss(False)
221
-
222
- @on(Button.Pressed, "#index-prompt-confirm")
223
- def handle_confirm(self, event: Button.Pressed) -> None:
224
- event.stop()
225
- self.dismiss(True)
226
-
227
-
228
- class CodebaseIndexScreen(ModalScreen[CodebaseIndexSelection | None]):
229
- """Modal dialog for choosing a repository and name to index."""
230
-
231
- DEFAULT_CSS = """
232
- CodebaseIndexScreen {
233
- align: center middle;
234
- background: rgba(0, 0, 0, 0.0);
235
- }
236
- CodebaseIndexScreen > #index-dialog {
237
- width: 80%;
238
- max-width: 80;
239
- height: 80%;
240
- max-height: 40;
241
- border: wide $primary;
242
- padding: 1;
243
- layout: vertical;
244
- background: $surface;
245
- }
246
-
247
- #index-dialog DirectoryTree {
248
- height: 1fr;
249
- border: solid $accent;
250
- overflow: auto;
251
- }
252
-
253
- #index-dialog-controls {
254
- layout: horizontal;
255
- align-horizontal: right;
256
- padding-top: 1;
257
- }
258
- """
259
-
260
- def __init__(self, start_path: Path | None = None) -> None:
261
- super().__init__()
262
- self.start_path = Path(start_path or Path.cwd())
263
- self.selected_path: Path | None = self.start_path
264
-
265
- def compose(self) -> ComposeResult:
266
- with Container(id="index-dialog"):
267
- yield Label("Index a codebase", id="index-dialog-title")
268
- yield FilteredDirectoryTree(self.start_path, id="index-directory-tree")
269
- yield Input(
270
- placeholder="Enter a name for the codebase",
271
- id="index-codebase-name",
272
- )
273
- with Container(id="index-dialog-controls"):
274
- yield Button("Cancel", id="index-cancel")
275
- yield Button(
276
- "Index",
277
- id="index-confirm",
278
- variant="primary",
279
- disabled=True,
280
- )
281
-
282
- def on_mount(self) -> None:
283
- name_input = self.query_one("#index-codebase-name", Input)
284
- if not name_input.value and self.selected_path:
285
- name_input.value = self.selected_path.name
286
- self._update_confirm()
287
-
288
- def _update_confirm(self) -> None:
289
- confirm = self.query_one("#index-confirm", Button)
290
- name_input = self.query_one("#index-codebase-name", Input)
291
- confirm.disabled = not (self.selected_path and name_input.value.strip())
292
-
293
- @on(DirectoryTree.DirectorySelected, "#index-directory-tree")
294
- def handle_directory_selected(self, event: DirectoryTree.DirectorySelected) -> None:
295
- event.stop()
296
- selected = event.path if event.path.is_dir() else event.path.parent
297
- self.selected_path = selected
298
- name_input = self.query_one("#index-codebase-name", Input)
299
- if not name_input.value:
300
- name_input.value = selected.name
301
- self._update_confirm()
302
-
303
- @on(Input.Changed, "#index-codebase-name")
304
- def handle_name_changed(self, event: Input.Changed) -> None:
305
- event.stop()
306
- self._update_confirm()
307
-
308
- @on(Button.Pressed, "#index-cancel")
309
- def handle_cancel(self, event: Button.Pressed) -> None:
310
- event.stop()
311
- self.dismiss(None)
312
-
313
- @on(Button.Pressed, "#index-confirm")
314
- def handle_confirm(self, event: Button.Pressed) -> None:
315
- event.stop()
316
- name_input = self.query_one("#index-codebase-name", Input)
317
- if not self.selected_path:
318
- self.dismiss(None)
319
- return
320
- selection = CodebaseIndexSelection(
321
- repo_path=self.selected_path,
322
- name=name_input.value.strip(),
323
- )
324
- self.dismiss(selection)
325
-
326
-
327
- class ChatScreen(Screen[None]):
328
- CSS_PATH = "chat.tcss"
329
-
330
- BINDINGS = [
331
- ("ctrl+p", "command_palette", "Command Palette"),
332
- ("shift+tab", "toggle_mode", "Toggle mode"),
333
- ]
334
-
335
- COMMANDS = {AgentModeProvider, ProviderSetupProvider, CodebaseCommandProvider}
336
-
337
- value = reactive("")
338
- mode = reactive(AgentType.RESEARCH)
339
- history: PromptHistory = PromptHistory()
340
- messages = reactive(list[ModelMessage | HintMessage]())
341
- working = reactive(False)
342
- question: reactive[UserQuestion | None] = reactive(None)
343
- indexing_job: reactive[CodebaseIndexSelection | None] = reactive(None)
344
- partial_message: reactive[ModelMessage | None] = reactive(None)
345
- _current_worker = None # Track the current running worker for cancellation
346
-
347
- def __init__(self, continue_session: bool = False) -> None:
348
- super().__init__()
349
- # Get the model configuration and services
350
- model_config = get_provider_model()
351
- codebase_service = get_codebase_service()
352
- self.codebase_sdk = CodebaseSDK()
353
-
354
- # Create shared deps without system_prompt_fn (agents provide their own)
355
- # We need a placeholder system_prompt_fn to satisfy the field requirement
356
- def _placeholder_system_prompt_fn(ctx: RunContext[AgentDeps]) -> str:
357
- raise RuntimeError(
358
- "This should not be called - agents provide their own system_prompt_fn"
359
- )
360
-
361
- self.deps = AgentDeps(
362
- interactive_mode=True,
363
- is_tui_context=True,
364
- llm_model=model_config,
365
- codebase_service=codebase_service,
366
- system_prompt_fn=_placeholder_system_prompt_fn,
367
- )
368
- self.agent_manager = AgentManager(deps=self.deps, initial_type=self.mode)
369
- self.command_handler = CommandHandler()
370
- self.placeholder_hints = PlaceholderHints()
371
- self.conversation_manager = ConversationManager()
372
- self.continue_session = continue_session
373
-
374
- def on_mount(self) -> None:
375
- self.query_one(PromptInput).focus(scroll_visible=True)
376
- # Hide spinner initially
377
- self.query_one("#spinner").display = False
378
-
379
- # Load conversation history if --continue flag was provided
380
- if self.continue_session and self.conversation_manager.exists():
381
- self._load_conversation()
382
-
383
- self.call_later(self.check_if_codebase_is_indexed)
384
- # Start the question listener worker to handle ask_user interactions
385
- self.call_later(self.add_question_listener)
386
-
387
- async def on_key(self, event: events.Key) -> None:
388
- """Handle key presses for cancellation."""
389
- # If escape is pressed while agent is working, cancel the operation
390
- if event.key == "escape" and self.working and self._current_worker:
391
- # Cancel the running agent worker
392
- self._current_worker.cancel()
393
- # Show cancellation message
394
- self.mount_hint("⚠️ Cancelling operation...")
395
- # Re-enable the input
396
- prompt_input = self.query_one(PromptInput)
397
- prompt_input.focus()
398
-
399
- @work
400
- async def check_if_codebase_is_indexed(self) -> None:
401
- cur_dir = Path.cwd().resolve()
402
- is_empty = all(
403
- dir.is_dir() and dir.name in ["__pycache__", ".git", ".shotgun"]
404
- for dir in cur_dir.iterdir()
405
- )
406
- if is_empty or self.continue_session:
407
- return
408
-
409
- # Check if the current directory has any accessible codebases
410
- accessible_graphs = (
411
- await self.codebase_sdk.list_codebases_for_directory()
412
- ).graphs
413
- if accessible_graphs:
414
- self.mount_hint(help_text_with_codebase(already_indexed=True))
415
- return
416
-
417
- should_index = await self.app.push_screen_wait(CodebaseIndexPromptScreen())
418
- if not should_index:
419
- self.mount_hint(help_text_empty_dir())
420
- return
421
-
422
- self.mount_hint(help_text_with_codebase(already_indexed=False))
423
-
424
- self.index_codebase_command()
425
-
426
- def watch_mode(self, new_mode: AgentType) -> None:
427
- """React to mode changes by updating the agent manager."""
428
-
429
- if self.is_mounted:
430
- self.agent_manager.set_agent(new_mode)
431
-
432
- mode_indicator = self.query_one(ModeIndicator)
433
- mode_indicator.mode = new_mode
434
- mode_indicator.refresh()
435
-
436
- prompt_input = self.query_one(PromptInput)
437
- # Force new hint selection when mode changes
438
- prompt_input.placeholder = self._placeholder_for_mode(
439
- new_mode, force_new=True
440
- )
441
- prompt_input.refresh()
442
-
443
- def watch_working(self, is_working: bool) -> None:
444
- """Show or hide the spinner based on working state."""
445
- if self.is_mounted:
446
- spinner = self.query_one("#spinner")
447
- spinner.set_classes("" if is_working else "hidden")
448
- spinner.display = is_working
449
-
450
- # Update the status bar to show/hide "ESC to stop"
451
- status_bar = self.query_one(StatusBar)
452
- status_bar.working = is_working
453
- status_bar.refresh()
454
-
455
- def watch_messages(self, messages: list[ModelMessage | HintMessage]) -> None:
456
- """Update the chat history when messages change."""
457
- if self.is_mounted:
458
- chat_history = self.query_one(ChatHistory)
459
- chat_history.update_messages(messages)
460
-
461
- def watch_question(self, question: UserQuestion | None) -> None:
462
- """Update the question display."""
463
- if self.is_mounted:
464
- question_display = self.query_one("#question-display", Markdown)
465
- if question:
466
- question_display.update(f"Question:\n\n{question.question}")
467
- question_display.display = True
468
- else:
469
- question_display.update("")
470
- question_display.display = False
471
-
472
- def action_toggle_mode(self) -> None:
473
- modes = [
474
- AgentType.RESEARCH,
475
- AgentType.SPECIFY,
476
- AgentType.PLAN,
477
- AgentType.TASKS,
478
- AgentType.EXPORT,
479
- ]
480
- self.mode = modes[(modes.index(self.mode) + 1) % len(modes)]
481
- self.agent_manager.set_agent(self.mode)
482
- # whoops it actually changes focus. Let's be brutal for now
483
- self.call_later(lambda: self.query_one(PromptInput).focus())
484
-
485
- @work
486
- async def add_question_listener(self) -> None:
487
- while True:
488
- question = await self.deps.queue.get()
489
- self.question = question
490
- await question.result
491
- self.deps.queue.task_done()
492
-
493
- def compose(self) -> ComposeResult:
494
- """Create child widgets for the app."""
495
- with Container(id="window"):
496
- yield self.agent_manager
497
- yield ChatHistory()
498
- yield Markdown(markdown="", id="question-display")
499
- with Container(id="footer"):
500
- yield Spinner(
501
- text="Processing...",
502
- id="spinner",
503
- classes="" if self.working else "hidden",
504
- )
505
- yield StatusBar(working=self.working)
506
- yield PromptInput(
507
- text=self.value,
508
- highlight_cursor_line=False,
509
- id="prompt-input",
510
- placeholder=self._placeholder_for_mode(self.mode),
511
- )
512
- with Grid():
513
- yield ModeIndicator(mode=self.mode)
514
- yield Static("", id="indexing-job-display")
515
-
516
- def mount_hint(self, markdown: str) -> None:
517
- hint = HintMessage(message=markdown)
518
- self.agent_manager.add_hint_message(hint)
519
-
520
- @on(PartialResponseMessage)
521
- def handle_partial_response(self, event: PartialResponseMessage) -> None:
522
- self.partial_message = event.message
523
-
524
- history = self.query_one(ChatHistory)
525
- history.update_messages(
526
- self.messages + cast(list[ModelMessage | HintMessage], event.messages)
527
- )
528
-
529
- history.partial_response = self.partial_message
530
-
531
- def _clear_partial_response(self) -> None:
532
- partial_response_widget = self.query_one(ChatHistory)
533
- partial_response_widget.partial_response = None
534
-
535
- @on(MessageHistoryUpdated)
536
- def handle_message_history_updated(self, event: MessageHistoryUpdated) -> None:
537
- """Handle message history updates from the agent manager."""
538
- self._clear_partial_response()
539
- self.messages = event.messages
540
-
541
- # Refresh placeholder and mode indicator in case artifacts were created
542
- prompt_input = self.query_one(PromptInput)
543
- prompt_input.placeholder = self._placeholder_for_mode(self.mode)
544
- prompt_input.refresh()
545
-
546
- mode_indicator = self.query_one(ModeIndicator)
547
- mode_indicator.refresh()
548
-
549
- # If there are file operations, add a message showing the modified files
550
- if event.file_operations:
551
- chat_history = self.query_one(ChatHistory)
552
- if chat_history.vertical_tail:
553
- tracker = FileOperationTracker(operations=event.file_operations)
554
- display_path = tracker.get_display_path()
555
-
556
- if display_path:
557
- # Create a simple markdown message with the file path
558
- # The terminal emulator will make this clickable automatically
559
- from pathlib import Path
560
-
561
- path_obj = Path(display_path)
562
-
563
- if len(event.file_operations) == 1:
564
- message = f"📝 Modified: `{display_path}`"
565
- else:
566
- num_files = len({op.file_path for op in event.file_operations})
567
- if path_obj.is_dir():
568
- message = (
569
- f"📁 Modified {num_files} files in: `{display_path}`"
570
- )
571
- else:
572
- # Common path is a file, show parent directory
573
- message = (
574
- f"📁 Modified {num_files} files in: `{path_obj.parent}`"
575
- )
576
-
577
- self.mount_hint(message)
578
-
579
- @on(PromptInput.Submitted)
580
- async def handle_submit(self, message: PromptInput.Submitted) -> None:
581
- text = message.text.strip()
582
-
583
- # If empty text, just clear input and return
584
- if not text:
585
- prompt_input = self.query_one(PromptInput)
586
- prompt_input.clear()
587
- self.value = ""
588
- return
589
-
590
- # Check if it's a command
591
- if self.command_handler.is_command(text):
592
- success, response = self.command_handler.handle_command(text)
593
-
594
- # Add the command to history
595
- self.history.append(message.text)
596
-
597
- # Display the command in chat history
598
- user_message = ModelRequest(parts=[UserPromptPart(content=text)])
599
- self.messages = self.messages + [user_message]
600
-
601
- # Display the response (help text or error message)
602
- response_message = ModelResponse(parts=[TextPart(content=response)])
603
- self.messages = self.messages + [response_message]
604
-
605
- # Clear the input
606
- prompt_input = self.query_one(PromptInput)
607
- prompt_input.clear()
608
- self.value = ""
609
- return
610
-
611
- # Not a command, process as normal
612
- self.history.append(message.text)
613
-
614
- # Clear the input
615
- self.value = ""
616
- self.run_agent(text) # Use stripped text
617
-
618
- prompt_input = self.query_one(PromptInput)
619
- prompt_input.clear()
620
-
621
- def _placeholder_for_mode(self, mode: AgentType, force_new: bool = False) -> str:
622
- """Return the placeholder text appropriate for the current mode.
623
-
624
- Args:
625
- mode: The current agent mode.
626
- force_new: If True, force selection of a new random hint.
627
-
628
- Returns:
629
- Dynamic placeholder hint based on mode and progress.
630
- """
631
- return self.placeholder_hints.get_placeholder_for_mode(mode)
632
-
633
- def index_codebase_command(self) -> None:
634
- start_path = Path.cwd()
635
-
636
- def handle_result(result: CodebaseIndexSelection | None) -> None:
637
- if result:
638
- self.call_later(lambda: self.index_codebase(result))
639
-
640
- self.app.push_screen(
641
- CodebaseIndexScreen(start_path=start_path),
642
- handle_result,
643
- )
644
-
645
- def delete_codebase_command(self) -> None:
646
- self.app.push_screen(
647
- CommandPalette(
648
- providers=[DeleteCodebasePaletteProvider],
649
- placeholder="Select a codebase to delete…",
650
- )
651
- )
652
-
653
- def delete_codebase_from_palette(self, graph_id: str) -> None:
654
- stack = getattr(self.app, "screen_stack", None)
655
- if stack and isinstance(stack[-1], CommandPalette):
656
- self.app.pop_screen()
657
-
658
- self.call_later(lambda: self.delete_codebase(graph_id))
659
-
660
- @work
661
- async def delete_codebase(self, graph_id: str) -> None:
662
- try:
663
- await self.codebase_sdk.delete_codebase(graph_id)
664
- self.notify(f"Deleted codebase: {graph_id}", severity="information")
665
- except CodebaseNotFoundError as exc:
666
- self.notify(str(exc), severity="error")
667
- except Exception as exc: # pragma: no cover - defensive UI path
668
- self.notify(f"Failed to delete codebase: {exc}", severity="error")
669
-
670
- @work
671
- async def index_codebase(self, selection: CodebaseIndexSelection) -> None:
672
- label = self.query_one("#indexing-job-display", Static)
673
- label.update(
674
- f"[$foreground-muted]Indexing [bold $text-accent]{selection.name}[/]...[/]"
675
- )
676
- label.refresh()
677
- try:
678
- # Pass the current working directory as the indexed_from_cwd
679
- logger.debug(
680
- f"Starting indexing - repo_path: {selection.repo_path}, "
681
- f"name: {selection.name}, cwd: {Path.cwd().resolve()}"
682
- )
683
- result = await self.codebase_sdk.index_codebase(
684
- selection.repo_path,
685
- selection.name,
686
- indexed_from_cwd=str(Path.cwd().resolve()),
687
- )
688
- logger.info(
689
- f"Successfully indexed codebase '{result.name}' (ID: {result.graph_id})"
690
- )
691
- self.notify(
692
- f"Indexed codebase '{result.name}' (ID: {result.graph_id})",
693
- severity="information",
694
- timeout=8,
695
- )
696
-
697
- except CodebaseAlreadyIndexedError as exc:
698
- logger.warning(f"Codebase already indexed: {exc}")
699
- self.notify(str(exc), severity="warning")
700
- return
701
- except InvalidPathError as exc:
702
- logger.error(f"Invalid path error: {exc}")
703
- self.notify(str(exc), severity="error")
704
-
705
- except Exception as exc: # pragma: no cover - defensive UI path
706
- # Log full exception details with stack trace
707
- logger.exception(
708
- f"Failed to index codebase - repo_path: {selection.repo_path}, "
709
- f"name: {selection.name}, error: {exc}"
710
- )
711
- self.notify(f"Failed to index codebase: {exc}", severity="error")
712
- finally:
713
- label.update("")
714
- label.refresh()
715
-
716
- @work
717
- async def run_agent(self, message: str) -> None:
718
- deferred_tool_results = None
719
- prompt = None
720
- self.working = True
721
-
722
- # Store the worker so we can cancel it if needed
723
- from textual.worker import get_current_worker
724
-
725
- self._current_worker = get_current_worker()
726
-
727
- if self.question:
728
- # This is a response to a question from the agent
729
- self.question.result.set_result(
730
- UserAnswer(answer=message, tool_call_id=self.question.tool_call_id)
731
- )
732
-
733
- deferred_tool_results = DeferredToolResults()
734
-
735
- deferred_tool_results.calls[self.question.tool_call_id] = UserAnswer(
736
- answer=message, tool_call_id=self.question.tool_call_id
737
- )
738
-
739
- self.question = None
740
- else:
741
- # This is a new user prompt
742
- prompt = message
743
-
744
- try:
745
- await self.agent_manager.run(
746
- prompt=prompt,
747
- deferred_tool_results=deferred_tool_results,
748
- )
749
- except asyncio.CancelledError:
750
- # Handle cancellation gracefully - DO NOT re-raise
751
- self.mount_hint("⚠️ Operation cancelled by user")
752
- finally:
753
- self.working = False
754
- self._current_worker = None
755
-
756
- # Save conversation after each interaction
757
- self._save_conversation()
758
-
759
- prompt_input = self.query_one(PromptInput)
760
- prompt_input.focus()
761
-
762
- def _save_conversation(self) -> None:
763
- """Save the current conversation to persistent storage."""
764
- # Get conversation state from agent manager
765
- state = self.agent_manager.get_conversation_state()
766
-
767
- # Create conversation history object
768
- conversation = ConversationHistory(
769
- last_agent_model=state.agent_type,
770
- )
771
- conversation.set_agent_messages(state.agent_messages)
772
- conversation.set_ui_messages(state.ui_messages)
773
-
774
- # Save to file
775
- self.conversation_manager.save(conversation)
776
-
777
- def _load_conversation(self) -> None:
778
- """Load conversation from persistent storage."""
779
- conversation = self.conversation_manager.load()
780
- if conversation is None:
781
- return
782
-
783
- # Restore agent state
784
- agent_messages = conversation.get_agent_messages()
785
- ui_messages = conversation.get_ui_messages()
786
-
787
- # Create ConversationState for restoration
788
- state = ConversationState(
789
- agent_messages=agent_messages,
790
- ui_messages=ui_messages,
791
- agent_type=conversation.last_agent_model,
792
- )
793
-
794
- self.agent_manager.restore_conversation_state(state)
795
-
796
- # Update the current mode
797
- self.mode = AgentType(conversation.last_agent_model)
798
-
799
-
800
- def help_text_with_codebase(already_indexed: bool = False) -> str:
801
- return (
802
- "Howdy! Welcome to Shotgun - the context tool for software engineering. \n\nYou can research, build specs, plan, create tasks, and export context to your favorite code-gen agents.\n\n"
803
- f"{'' if already_indexed else 'Once your codebase is indexed, '}I can help with:\n\n"
804
- "- Speccing out a new feature\n"
805
- "- Onboarding you onto this project\n"
806
- "- Helping with a refactor spec\n"
807
- "- Creating AGENTS.md file for this project\n"
808
- )
809
-
810
-
811
- def help_text_empty_dir() -> str:
812
- return (
813
- "Howdy! Welcome to Shotgun - the context tool for software engineering.\n\nYou can research, build specs, plan, create tasks, and export context to your favorite code-gen agents.\n\n"
814
- "What would you like to build? Here are some examples:\n\n"
815
- "- Research FastAPI vs Django\n"
816
- "- Plan my new web app using React\n"
817
- "- Create PRD for my planned product\n"
818
- )