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