shotgun-sh 0.1.0.dev12__py3-none-any.whl → 0.1.0.dev13__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 (49) hide show
  1. shotgun/agents/common.py +94 -79
  2. shotgun/agents/config/constants.py +18 -0
  3. shotgun/agents/config/manager.py +68 -16
  4. shotgun/agents/config/provider.py +11 -6
  5. shotgun/agents/models.py +6 -0
  6. shotgun/agents/plan.py +15 -37
  7. shotgun/agents/research.py +10 -45
  8. shotgun/agents/specify.py +97 -0
  9. shotgun/agents/tasks.py +7 -36
  10. shotgun/agents/tools/artifact_management.py +450 -0
  11. shotgun/agents/tools/file_management.py +2 -2
  12. shotgun/artifacts/__init__.py +17 -0
  13. shotgun/artifacts/exceptions.py +89 -0
  14. shotgun/artifacts/manager.py +529 -0
  15. shotgun/artifacts/models.py +332 -0
  16. shotgun/artifacts/service.py +463 -0
  17. shotgun/artifacts/templates/__init__.py +10 -0
  18. shotgun/artifacts/templates/loader.py +252 -0
  19. shotgun/artifacts/templates/models.py +136 -0
  20. shotgun/artifacts/templates/plan/delivery_and_release_plan.yaml +66 -0
  21. shotgun/artifacts/templates/research/market_research.yaml +585 -0
  22. shotgun/artifacts/templates/research/sdk_comparison.yaml +257 -0
  23. shotgun/artifacts/templates/specify/prd.yaml +331 -0
  24. shotgun/artifacts/templates/specify/product_spec.yaml +301 -0
  25. shotgun/artifacts/utils.py +76 -0
  26. shotgun/cli/plan.py +1 -4
  27. shotgun/cli/specify.py +69 -0
  28. shotgun/cli/tasks.py +0 -4
  29. shotgun/logging_config.py +23 -7
  30. shotgun/main.py +7 -6
  31. shotgun/prompts/agents/partials/artifact_system.j2 +32 -0
  32. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +28 -2
  33. shotgun/prompts/agents/partials/content_formatting.j2 +65 -0
  34. shotgun/prompts/agents/partials/interactive_mode.j2 +10 -2
  35. shotgun/prompts/agents/plan.j2 +31 -32
  36. shotgun/prompts/agents/research.j2 +37 -29
  37. shotgun/prompts/agents/specify.j2 +31 -0
  38. shotgun/prompts/agents/tasks.j2 +27 -12
  39. shotgun/sdk/artifact_models.py +186 -0
  40. shotgun/sdk/artifacts.py +448 -0
  41. shotgun/tui/app.py +26 -7
  42. shotgun/tui/screens/chat.py +28 -3
  43. shotgun/tui/screens/directory_setup.py +113 -0
  44. {shotgun_sh-0.1.0.dev12.dist-info → shotgun_sh-0.1.0.dev13.dist-info}/METADATA +2 -2
  45. {shotgun_sh-0.1.0.dev12.dist-info → shotgun_sh-0.1.0.dev13.dist-info}/RECORD +48 -25
  46. shotgun/prompts/user/research.j2 +0 -5
  47. {shotgun_sh-0.1.0.dev12.dist-info → shotgun_sh-0.1.0.dev13.dist-info}/WHEEL +0 -0
  48. {shotgun_sh-0.1.0.dev12.dist-info → shotgun_sh-0.1.0.dev13.dist-info}/entry_points.txt +0 -0
  49. {shotgun_sh-0.1.0.dev12.dist-info → shotgun_sh-0.1.0.dev13.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,448 @@
1
+ """Artifact SDK for framework-agnostic business logic."""
2
+
3
+ from collections.abc import Callable
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from shotgun.artifacts.exceptions import (
8
+ ArtifactAlreadyExistsError,
9
+ ArtifactError,
10
+ ArtifactNotFoundError,
11
+ SectionAlreadyExistsError,
12
+ SectionNotFoundError,
13
+ )
14
+ from shotgun.artifacts.models import AgentMode, ArtifactSection
15
+ from shotgun.artifacts.service import ArtifactService
16
+ from shotgun.artifacts.utils import generate_artifact_name
17
+
18
+ from .artifact_models import (
19
+ ArtifactCreateResult,
20
+ ArtifactDeleteResult,
21
+ ArtifactErrorResult,
22
+ ArtifactInfoResult,
23
+ ArtifactListResult,
24
+ SectionContentResult,
25
+ SectionCreateResult,
26
+ SectionDeleteResult,
27
+ SectionUpdateResult,
28
+ )
29
+
30
+
31
+ class ArtifactSDK:
32
+ """Framework-agnostic SDK for artifact operations.
33
+
34
+ This SDK provides business logic for artifact management that can be
35
+ used by both CLI and TUI implementations without framework dependencies.
36
+ """
37
+
38
+ def __init__(self, base_path: Path | None = None):
39
+ """Initialize SDK with optional base path.
40
+
41
+ Args:
42
+ base_path: Optional custom base path for artifacts.
43
+ Defaults to .shotgun in current directory.
44
+ """
45
+ self.service = ArtifactService(base_path)
46
+
47
+ # Artifact operations
48
+
49
+ def list_artifacts(
50
+ self, agent_mode: AgentMode | None = None
51
+ ) -> ArtifactListResult | ArtifactErrorResult:
52
+ """List all artifacts, optionally filtered by agent mode.
53
+
54
+ Args:
55
+ agent_mode: Optional agent mode filter
56
+
57
+ Returns:
58
+ ArtifactListResult containing list of artifacts or ArtifactErrorResult
59
+ """
60
+ try:
61
+ artifacts = self.service.list_artifacts(agent_mode)
62
+ return ArtifactListResult(artifacts=artifacts, agent_mode=agent_mode)
63
+ except ArtifactError as e:
64
+ return ArtifactErrorResult(error_message=str(e), agent_mode=agent_mode)
65
+
66
+ def create_artifact(
67
+ self,
68
+ artifact_id: str,
69
+ agent_mode: AgentMode,
70
+ name: str,
71
+ template_id: str | None = None,
72
+ ) -> ArtifactCreateResult | ArtifactErrorResult:
73
+ """Create a new artifact.
74
+
75
+ Args:
76
+ artifact_id: Unique identifier for the artifact
77
+ agent_mode: Agent mode this artifact belongs to
78
+ name: Human-readable name for the artifact
79
+ template_id: Optional template ID to use for creating the artifact
80
+
81
+ Returns:
82
+ ArtifactCreateResult or ArtifactErrorResult
83
+ """
84
+ try:
85
+ self.service.create_artifact(artifact_id, agent_mode, name, template_id)
86
+ return ArtifactCreateResult(
87
+ artifact_id=artifact_id,
88
+ agent_mode=agent_mode,
89
+ name=name,
90
+ )
91
+ except ArtifactAlreadyExistsError as e:
92
+ return ArtifactErrorResult(
93
+ error_message="Artifact already exists",
94
+ artifact_id=artifact_id,
95
+ agent_mode=agent_mode,
96
+ details=str(e),
97
+ )
98
+ except ArtifactError as e:
99
+ return ArtifactErrorResult(
100
+ error_message=str(e),
101
+ artifact_id=artifact_id,
102
+ agent_mode=agent_mode,
103
+ )
104
+
105
+ def delete_artifact(
106
+ self,
107
+ artifact_id: str,
108
+ agent_mode: AgentMode,
109
+ confirm_callback: Callable[[str, AgentMode], bool] | None = None,
110
+ ) -> ArtifactDeleteResult | ArtifactErrorResult:
111
+ """Delete an artifact with optional confirmation.
112
+
113
+ Args:
114
+ artifact_id: ID of the artifact to delete
115
+ agent_mode: Agent mode
116
+ confirm_callback: Optional callback for confirmation that receives
117
+ artifact_id and agent_mode and returns boolean.
118
+
119
+ Returns:
120
+ ArtifactDeleteResult or ArtifactErrorResult
121
+ """
122
+ try:
123
+ # Handle confirmation callback if provided
124
+ if confirm_callback and not confirm_callback(artifact_id, agent_mode):
125
+ return ArtifactDeleteResult(
126
+ artifact_id=artifact_id,
127
+ agent_mode=agent_mode,
128
+ deleted=False,
129
+ cancelled=True,
130
+ )
131
+
132
+ self.service.delete_artifact(artifact_id, agent_mode)
133
+ return ArtifactDeleteResult(
134
+ artifact_id=artifact_id,
135
+ agent_mode=agent_mode,
136
+ deleted=True,
137
+ )
138
+ except ArtifactNotFoundError as e:
139
+ return ArtifactErrorResult(
140
+ error_message="Artifact not found",
141
+ artifact_id=artifact_id,
142
+ agent_mode=agent_mode,
143
+ details=str(e),
144
+ )
145
+ except ArtifactError as e:
146
+ return ArtifactErrorResult(
147
+ error_message=str(e),
148
+ artifact_id=artifact_id,
149
+ agent_mode=agent_mode,
150
+ )
151
+
152
+ def get_artifact_info(
153
+ self, artifact_id: str, agent_mode: AgentMode
154
+ ) -> ArtifactInfoResult | ArtifactErrorResult:
155
+ """Get detailed information about an artifact.
156
+
157
+ Args:
158
+ artifact_id: ID of the artifact to get info for
159
+ agent_mode: Agent mode
160
+
161
+ Returns:
162
+ ArtifactInfoResult or ArtifactErrorResult
163
+ """
164
+ try:
165
+ artifact = self.service.get_artifact(artifact_id, agent_mode, "")
166
+ return ArtifactInfoResult(artifact=artifact)
167
+ except ArtifactNotFoundError as e:
168
+ return ArtifactErrorResult(
169
+ error_message="Artifact not found",
170
+ artifact_id=artifact_id,
171
+ agent_mode=agent_mode,
172
+ details=str(e),
173
+ )
174
+ except ArtifactError as e:
175
+ return ArtifactErrorResult(
176
+ error_message=str(e),
177
+ artifact_id=artifact_id,
178
+ agent_mode=agent_mode,
179
+ )
180
+
181
+ # Section operations
182
+
183
+ def create_section(
184
+ self,
185
+ artifact_id: str,
186
+ agent_mode: AgentMode,
187
+ section_number: int,
188
+ section_slug: str,
189
+ section_title: str,
190
+ content: str = "",
191
+ ) -> SectionCreateResult | ArtifactErrorResult:
192
+ """Create a new section in an artifact.
193
+
194
+ Args:
195
+ artifact_id: Artifact identifier
196
+ agent_mode: Agent mode
197
+ section_number: Section number
198
+ section_slug: Section slug
199
+ section_title: Section title
200
+ content: Section content
201
+
202
+ Returns:
203
+ SectionCreateResult or ArtifactErrorResult
204
+ """
205
+ try:
206
+ section = ArtifactSection(
207
+ number=section_number,
208
+ slug=section_slug,
209
+ title=section_title,
210
+ content=content,
211
+ )
212
+ self.service.add_section(artifact_id, agent_mode, section)
213
+ return SectionCreateResult(
214
+ artifact_id=artifact_id,
215
+ agent_mode=agent_mode,
216
+ section_number=section_number,
217
+ section_title=section_title,
218
+ )
219
+ except (SectionAlreadyExistsError, ArtifactNotFoundError) as e:
220
+ return ArtifactErrorResult(
221
+ error_message=str(e),
222
+ artifact_id=artifact_id,
223
+ agent_mode=agent_mode,
224
+ section_number=section_number,
225
+ )
226
+ except ArtifactError as e:
227
+ return ArtifactErrorResult(
228
+ error_message=str(e),
229
+ artifact_id=artifact_id,
230
+ agent_mode=agent_mode,
231
+ section_number=section_number,
232
+ )
233
+
234
+ def update_section(
235
+ self,
236
+ artifact_id: str,
237
+ agent_mode: AgentMode,
238
+ section_number: int,
239
+ **kwargs: Any,
240
+ ) -> SectionUpdateResult | ArtifactErrorResult:
241
+ """Update a section in an artifact.
242
+
243
+ Args:
244
+ artifact_id: Artifact identifier
245
+ agent_mode: Agent mode
246
+ section_number: Section number to update
247
+ **kwargs: Fields to update
248
+
249
+ Returns:
250
+ SectionUpdateResult or ArtifactErrorResult
251
+ """
252
+ try:
253
+ self.service.update_section(
254
+ artifact_id, agent_mode, section_number, **kwargs
255
+ )
256
+ return SectionUpdateResult(
257
+ artifact_id=artifact_id,
258
+ agent_mode=agent_mode,
259
+ section_number=section_number,
260
+ updated_fields=list(kwargs.keys()),
261
+ )
262
+ except (SectionNotFoundError, ArtifactNotFoundError) as e:
263
+ return ArtifactErrorResult(
264
+ error_message=str(e),
265
+ artifact_id=artifact_id,
266
+ agent_mode=agent_mode,
267
+ section_number=section_number,
268
+ )
269
+ except ArtifactError as e:
270
+ return ArtifactErrorResult(
271
+ error_message=str(e),
272
+ artifact_id=artifact_id,
273
+ agent_mode=agent_mode,
274
+ section_number=section_number,
275
+ )
276
+
277
+ def delete_section(
278
+ self,
279
+ artifact_id: str,
280
+ agent_mode: AgentMode,
281
+ section_number: int,
282
+ ) -> SectionDeleteResult | ArtifactErrorResult:
283
+ """Delete a section from an artifact.
284
+
285
+ Args:
286
+ artifact_id: Artifact identifier
287
+ agent_mode: Agent mode
288
+ section_number: Section number to delete
289
+
290
+ Returns:
291
+ SectionDeleteResult or ArtifactErrorResult
292
+ """
293
+ try:
294
+ self.service.delete_section(artifact_id, agent_mode, section_number)
295
+ return SectionDeleteResult(
296
+ artifact_id=artifact_id,
297
+ agent_mode=agent_mode,
298
+ section_number=section_number,
299
+ )
300
+ except (SectionNotFoundError, ArtifactNotFoundError) as e:
301
+ return ArtifactErrorResult(
302
+ error_message=str(e),
303
+ artifact_id=artifact_id,
304
+ agent_mode=agent_mode,
305
+ section_number=section_number,
306
+ )
307
+ except ArtifactError as e:
308
+ return ArtifactErrorResult(
309
+ error_message=str(e),
310
+ artifact_id=artifact_id,
311
+ agent_mode=agent_mode,
312
+ section_number=section_number,
313
+ )
314
+
315
+ def get_section_content(
316
+ self,
317
+ artifact_id: str,
318
+ agent_mode: AgentMode,
319
+ section_number: int,
320
+ ) -> SectionContentResult | ArtifactErrorResult:
321
+ """Get the content of a section.
322
+
323
+ Args:
324
+ artifact_id: Artifact identifier
325
+ agent_mode: Agent mode
326
+ section_number: Section number
327
+
328
+ Returns:
329
+ SectionContentResult or ArtifactErrorResult
330
+ """
331
+ try:
332
+ content = self.service.get_section_content(
333
+ artifact_id, agent_mode, section_number
334
+ )
335
+ return SectionContentResult(
336
+ artifact_id=artifact_id,
337
+ agent_mode=agent_mode,
338
+ section_number=section_number,
339
+ content=content,
340
+ )
341
+ except (SectionNotFoundError, ArtifactNotFoundError) as e:
342
+ return ArtifactErrorResult(
343
+ error_message=str(e),
344
+ artifact_id=artifact_id,
345
+ agent_mode=agent_mode,
346
+ section_number=section_number,
347
+ )
348
+ except ArtifactError as e:
349
+ return ArtifactErrorResult(
350
+ error_message=str(e),
351
+ artifact_id=artifact_id,
352
+ agent_mode=agent_mode,
353
+ section_number=section_number,
354
+ )
355
+
356
+ # Template operations
357
+
358
+ def list_templates(
359
+ self, agent_mode: AgentMode | None = None
360
+ ) -> list[Any] | ArtifactErrorResult:
361
+ """List available artifact templates.
362
+
363
+ Args:
364
+ agent_mode: Optional agent mode filter
365
+
366
+ Returns:
367
+ List of template summaries or ArtifactErrorResult
368
+ """
369
+ try:
370
+ return self.service.list_templates(agent_mode)
371
+ except Exception as e:
372
+ return ArtifactErrorResult(
373
+ error_message=f"Failed to list templates: {str(e)}",
374
+ agent_mode=agent_mode,
375
+ )
376
+
377
+ # Convenience methods
378
+
379
+ def ensure_artifact_exists(
380
+ self,
381
+ artifact_id: str,
382
+ agent_mode: AgentMode,
383
+ name: str | None = None,
384
+ ) -> ArtifactCreateResult | ArtifactInfoResult | ArtifactErrorResult:
385
+ """Ensure an artifact exists, creating it if necessary.
386
+
387
+ Args:
388
+ artifact_id: Artifact identifier
389
+ agent_mode: Agent mode
390
+ name: Optional name (defaults to formatted artifact_id)
391
+
392
+ Returns:
393
+ ArtifactCreateResult if created, ArtifactInfoResult if already existed, ArtifactErrorResult on error
394
+ """
395
+ if name is None:
396
+ name = generate_artifact_name(artifact_id)
397
+
398
+ # Try to get existing artifact
399
+ info_result = self.get_artifact_info(artifact_id, agent_mode)
400
+ if isinstance(info_result, ArtifactInfoResult):
401
+ return info_result
402
+
403
+ # Create new artifact
404
+ create_result = self.create_artifact(artifact_id, agent_mode, name)
405
+ return create_result
406
+
407
+ def ensure_section_exists(
408
+ self,
409
+ artifact_id: str,
410
+ agent_mode: AgentMode,
411
+ section_number: int,
412
+ section_slug: str,
413
+ section_title: str,
414
+ initial_content: str = "",
415
+ ) -> SectionCreateResult | SectionContentResult | ArtifactErrorResult:
416
+ """Ensure a section exists, creating it if necessary.
417
+
418
+ Args:
419
+ artifact_id: Artifact identifier
420
+ agent_mode: Agent mode
421
+ section_number: Section number
422
+ section_slug: Section slug
423
+ section_title: Section title
424
+ initial_content: Initial content for new sections
425
+
426
+ Returns:
427
+ SectionCreateResult if created, SectionContentResult if already existed, ArtifactErrorResult on error
428
+ """
429
+ # Try to get existing section
430
+ content_result = self.get_section_content(
431
+ artifact_id, agent_mode, section_number
432
+ )
433
+ if isinstance(content_result, SectionContentResult):
434
+ return content_result
435
+
436
+ # Ensure artifact exists first
437
+ self.ensure_artifact_exists(artifact_id, agent_mode)
438
+
439
+ # Create new section
440
+ create_result = self.create_section(
441
+ artifact_id,
442
+ agent_mode,
443
+ section_number,
444
+ section_slug,
445
+ section_title,
446
+ initial_content,
447
+ )
448
+ return create_result
shotgun/tui/app.py CHANGED
@@ -2,18 +2,24 @@ from textual.app import App
2
2
  from textual.binding import Binding
3
3
 
4
4
  from shotgun.agents.config import ConfigManager, get_config_manager
5
+ from shotgun.agents.tools.file_management import get_shotgun_base_path
5
6
  from shotgun.logging_config import get_logger
6
7
  from shotgun.tui.screens.splash import SplashScreen
7
8
  from shotgun.utils.update_checker import check_for_updates_async
8
9
 
9
10
  from .screens.chat import ChatScreen
11
+ from .screens.directory_setup import DirectorySetupScreen
10
12
  from .screens.provider_config import ProviderConfigScreen
11
13
 
12
14
  logger = get_logger(__name__)
13
15
 
14
16
 
15
17
  class ShotgunApp(App[None]):
16
- SCREENS = {"chat": ChatScreen, "provider_config": ProviderConfigScreen}
18
+ SCREENS = {
19
+ "chat": ChatScreen,
20
+ "provider_config": ProviderConfigScreen,
21
+ "directory_setup": DirectorySetupScreen,
22
+ }
17
23
  BINDINGS = [
18
24
  Binding("ctrl+c", "quit", "Quit the app"),
19
25
  ]
@@ -43,21 +49,34 @@ class ShotgunApp(App[None]):
43
49
  self.push_screen(
44
50
  SplashScreen(), callback=lambda _arg: self.refresh_startup_screen()
45
51
  )
46
- # self.refresh_startup_screen()
47
52
 
48
53
  def refresh_startup_screen(self) -> None:
49
54
  """Push the appropriate screen based on configured providers."""
50
- if self.config_manager.has_any_provider_key():
51
- if isinstance(self.screen, ChatScreen):
52
- return
53
- self.push_screen("chat")
54
- else:
55
+ if not self.config_manager.has_any_provider_key():
55
56
  if isinstance(self.screen, ProviderConfigScreen):
56
57
  return
57
58
 
58
59
  self.push_screen(
59
60
  "provider_config", callback=lambda _arg: self.refresh_startup_screen()
60
61
  )
62
+ return
63
+
64
+ if not self.check_local_shotgun_directory_exists():
65
+ if isinstance(self.screen, DirectorySetupScreen):
66
+ return
67
+
68
+ self.push_screen(
69
+ "directory_setup", callback=lambda _arg: self.refresh_startup_screen()
70
+ )
71
+ return
72
+
73
+ if isinstance(self.screen, ChatScreen):
74
+ return
75
+ self.push_screen("chat")
76
+
77
+ def check_local_shotgun_directory_exists(self) -> bool:
78
+ shotgun_dir = get_shotgun_base_path()
79
+ return shotgun_dir.exists() and shotgun_dir.is_dir()
61
80
 
62
81
  async def action_quit(self) -> None:
63
82
  """Override quit action to show update notification."""
@@ -1,7 +1,7 @@
1
1
  from collections.abc import AsyncGenerator
2
2
  from typing import cast
3
3
 
4
- from pydantic_ai import DeferredToolResults
4
+ from pydantic_ai import DeferredToolResults, RunContext
5
5
  from pydantic_ai.messages import (
6
6
  BuiltinToolCallPart,
7
7
  BuiltinToolReturnPart,
@@ -31,6 +31,11 @@ from ..components.spinner import Spinner
31
31
  from ..components.vertical_tail import VerticalTail
32
32
 
33
33
 
34
+ def _dummy_system_prompt_fn(ctx: RunContext[AgentDeps]) -> str:
35
+ """Dummy system prompt function for TUI chat interface."""
36
+ return "You are a helpful AI assistant."
37
+
38
+
34
39
  class PromptHistory:
35
40
  def __init__(self) -> None:
36
41
  self.prompts: list[str] = ["Hello there!"]
@@ -289,6 +294,18 @@ class ChatScreen(Screen[None]):
289
294
 
290
295
  COMMANDS = {AgentModeProvider, ProviderSetupProvider}
291
296
 
297
+ _PLACEHOLDER_BY_MODE: dict[AgentType, str] = {
298
+ AgentType.RESEARCH: (
299
+ "Ask for investigations, e.g. research strengths and weaknesses of PydanticAI vs its rivals"
300
+ ),
301
+ AgentType.PLAN: (
302
+ "Describe a goal to plan, e.g. draft a rollout plan for launching our Slack automation"
303
+ ),
304
+ AgentType.TASKS: (
305
+ "Request actionable work, e.g. break down tasks to wire OpenTelemetry into the API"
306
+ ),
307
+ }
308
+
292
309
  value = reactive("")
293
310
  mode = reactive(AgentType.RESEARCH)
294
311
  history: PromptHistory = PromptHistory()
@@ -305,6 +322,7 @@ class ChatScreen(Screen[None]):
305
322
  interactive_mode=True,
306
323
  llm_model=model_config,
307
324
  codebase_service=codebase_service,
325
+ system_prompt_fn=_dummy_system_prompt_fn,
308
326
  )
309
327
  self.agent_manager = AgentManager(deps=self.deps, initial_type=self.mode)
310
328
 
@@ -323,6 +341,10 @@ class ChatScreen(Screen[None]):
323
341
  mode_indicator.mode = new_mode
324
342
  mode_indicator.refresh()
325
343
 
344
+ prompt_input = self.query_one(PromptInput)
345
+ prompt_input.placeholder = self._placeholder_for_mode(new_mode)
346
+ prompt_input.refresh()
347
+
326
348
  def watch_working(self, is_working: bool) -> None:
327
349
  """Show or hide the spinner based on working state."""
328
350
  if self.is_mounted:
@@ -379,7 +401,7 @@ class ChatScreen(Screen[None]):
379
401
  text=self.value,
380
402
  highlight_cursor_line=False,
381
403
  id="prompt-input",
382
- placeholder="Type your message",
404
+ placeholder=self._placeholder_for_mode(self.mode),
383
405
  )
384
406
  yield ModeIndicator(mode=self.mode)
385
407
 
@@ -398,7 +420,10 @@ class ChatScreen(Screen[None]):
398
420
 
399
421
  prompt_input = self.query_one(PromptInput)
400
422
  prompt_input.clear()
401
- prompt_input.focus()
423
+
424
+ def _placeholder_for_mode(self, mode: AgentType) -> str:
425
+ """Return the placeholder text appropriate for the current mode."""
426
+ return self._PLACEHOLDER_BY_MODE.get(mode, "Type your message")
402
427
 
403
428
  @work
404
429
  async def run_agent(self, message: str) -> None:
@@ -0,0 +1,113 @@
1
+ """Screen for setting up the local .shotgun directory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from textual import on
8
+ from textual.app import ComposeResult
9
+ from textual.containers import Horizontal, Vertical
10
+ from textual.screen import Screen
11
+ from textual.widgets import Button, Static
12
+
13
+ from shotgun.utils.file_system_utils import ensure_shotgun_directory_exists
14
+
15
+
16
+ class DirectorySetupScreen(Screen[None]):
17
+ """Prompt the user to initialize the .shotgun directory."""
18
+
19
+ CSS = """
20
+ DirectorySetupScreen {
21
+ layout: vertical;
22
+ }
23
+
24
+ DirectorySetupScreen > * {
25
+ height: auto;
26
+ }
27
+
28
+ #titlebox {
29
+ height: auto;
30
+ margin: 2 0;
31
+ padding: 1;
32
+ border: hkey $border;
33
+ content-align: center middle;
34
+
35
+ & > * {
36
+ text-align: center;
37
+ }
38
+ }
39
+
40
+ #directory-setup-title {
41
+ padding: 1 0;
42
+ text-style: bold;
43
+ color: $text-accent;
44
+ }
45
+
46
+ #directory-setup-summary {
47
+ padding: 0 1;
48
+ }
49
+
50
+ #directory-actions {
51
+ padding: 1;
52
+ content-align: center middle;
53
+ align: center middle;
54
+ }
55
+
56
+ #directory-actions > * {
57
+ margin-right: 2;
58
+ }
59
+ """
60
+
61
+ BINDINGS = [
62
+ ("enter", "confirm", "Initialize"),
63
+ ("escape", "cancel", "Exit"),
64
+ ]
65
+
66
+ def compose(self) -> ComposeResult:
67
+ with Vertical(id="titlebox"):
68
+ yield Static("Directory setup", id="directory-setup-title")
69
+ yield Static("Shotgun keeps workspace data in a .shotgun directory.\n")
70
+ yield Static("Initialize it in the current directory?\n")
71
+ yield Static(f"[$foreground-muted]({Path.cwd().resolve()})[/]")
72
+ with Horizontal(id="directory-actions"):
73
+ yield Button(
74
+ "Initialize and proceed \\[ENTER]", variant="primary", id="initialize"
75
+ )
76
+ yield Button("Exit without setup \\[ESC]", variant="default", id="exit")
77
+
78
+ def on_mount(self) -> None:
79
+ self.set_focus(self.query_one("#initialize", Button))
80
+
81
+ def action_confirm(self) -> None:
82
+ self._initialize_directory()
83
+
84
+ def action_cancel(self) -> None:
85
+ self._exit_application()
86
+
87
+ @on(Button.Pressed, "#initialize")
88
+ def _on_initialize_pressed(self) -> None:
89
+ self._initialize_directory()
90
+
91
+ @on(Button.Pressed, "#exit")
92
+ def _on_exit_pressed(self) -> None:
93
+ self._exit_application()
94
+
95
+ def _initialize_directory(self) -> None:
96
+ try:
97
+ path = ensure_shotgun_directory_exists()
98
+ except Exception as exc: # pragma: no cover - defensive; textual path
99
+ self.notify(f"Failed to initialize directory: {exc}", severity="error")
100
+ return
101
+
102
+ # Double-check a directory now exists; guard against unexpected filesystem state.
103
+ if not path.is_dir():
104
+ self.notify(
105
+ "Unable to initialize .shotgun directory due to filesystem conflict.",
106
+ severity="error",
107
+ )
108
+ return
109
+
110
+ self.dismiss()
111
+
112
+ def _exit_application(self) -> None:
113
+ self.app.exit()