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