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,43 @@
1
+ ChatHistory {
2
+ height: auto;
3
+ }
4
+
5
+ PromptInput {
6
+ min-height: 3;
7
+ max-height: 7;
8
+ height: auto;
9
+ }
10
+
11
+ StatusBar {
12
+ height: auto;
13
+ }
14
+
15
+ ModeIndicator {
16
+ height: auto;
17
+ }
18
+
19
+ #footer {
20
+ dock: bottom;
21
+ height: auto;
22
+ padding: 1 1 1 2;
23
+ max-height: 14;
24
+ }
25
+
26
+ #window {
27
+ align: left bottom;
28
+ }
29
+
30
+ .hidden {
31
+ display: none;
32
+ }
33
+
34
+ #footer > Grid {
35
+ height: auto;
36
+ grid-columns: 1fr auto;
37
+ grid-size: 2;
38
+ }
39
+
40
+
41
+ #indexing-job-display {
42
+ text-align: end;
43
+ }
File without changes
@@ -0,0 +1,219 @@
1
+ from collections.abc import AsyncGenerator
2
+ from typing import TYPE_CHECKING, cast
3
+
4
+ from textual.command import DiscoveryHit, Hit, Provider
5
+
6
+ from shotgun.agents.models import AgentType
7
+ from shotgun.codebase.models import CodebaseGraph
8
+
9
+ if TYPE_CHECKING:
10
+ from shotgun.tui.screens.chat import ChatScreen
11
+
12
+
13
+ class AgentModeProvider(Provider):
14
+ """Command provider for agent mode switching."""
15
+
16
+ @property
17
+ def chat_screen(self) -> "ChatScreen":
18
+ from shotgun.tui.screens.chat import ChatScreen
19
+
20
+ return cast(ChatScreen, self.screen)
21
+
22
+ def set_mode(self, mode: AgentType) -> None:
23
+ """Switch to research mode."""
24
+ self.chat_screen.mode = mode
25
+
26
+ async def discover(self) -> AsyncGenerator[DiscoveryHit, None]:
27
+ """Provide default mode switching commands when palette opens."""
28
+ yield DiscoveryHit(
29
+ "Switch to Research Mode",
30
+ lambda: self.set_mode(AgentType.RESEARCH),
31
+ help="🔬 Research topics with web search and synthesize findings",
32
+ )
33
+ yield DiscoveryHit(
34
+ "Switch to Specify Mode",
35
+ lambda: self.set_mode(AgentType.SPECIFY),
36
+ help="📝 Create detailed specifications and requirements documents",
37
+ )
38
+ yield DiscoveryHit(
39
+ "Switch to Plan Mode",
40
+ lambda: self.set_mode(AgentType.PLAN),
41
+ help="📋 Create comprehensive, actionable plans with milestones",
42
+ )
43
+ yield DiscoveryHit(
44
+ "Switch to Tasks Mode",
45
+ lambda: self.set_mode(AgentType.TASKS),
46
+ help="✅ Generate specific, actionable tasks from research and plans",
47
+ )
48
+ yield DiscoveryHit(
49
+ "Switch to Export Mode",
50
+ lambda: self.set_mode(AgentType.EXPORT),
51
+ help="📤 Export artifacts and findings to various formats",
52
+ )
53
+
54
+ async def search(self, query: str) -> AsyncGenerator[Hit, None]:
55
+ """Search for mode commands."""
56
+ matcher = self.matcher(query)
57
+
58
+ commands = [
59
+ (
60
+ "Switch to Research Mode",
61
+ "🔬 Research topics with web search and synthesize findings",
62
+ lambda: self.set_mode(AgentType.RESEARCH),
63
+ AgentType.RESEARCH,
64
+ ),
65
+ (
66
+ "Switch to Specify Mode",
67
+ "📝 Create detailed specifications and requirements documents",
68
+ lambda: self.set_mode(AgentType.SPECIFY),
69
+ AgentType.SPECIFY,
70
+ ),
71
+ (
72
+ "Switch to Plan Mode",
73
+ "📋 Create comprehensive, actionable plans with milestones",
74
+ lambda: self.set_mode(AgentType.PLAN),
75
+ AgentType.PLAN,
76
+ ),
77
+ (
78
+ "Switch to Tasks Mode",
79
+ "✅ Generate specific, actionable tasks from research and plans",
80
+ lambda: self.set_mode(AgentType.TASKS),
81
+ AgentType.TASKS,
82
+ ),
83
+ (
84
+ "Switch to Export Mode",
85
+ "📤 Export artifacts and findings to various formats",
86
+ lambda: self.set_mode(AgentType.EXPORT),
87
+ AgentType.EXPORT,
88
+ ),
89
+ ]
90
+
91
+ for title, help_text, callback, mode in commands:
92
+ if self.chat_screen.mode == mode:
93
+ continue
94
+ score = matcher.match(title)
95
+ if score > 0:
96
+ yield Hit(score, matcher.highlight(title), callback, help=help_text)
97
+
98
+
99
+ class ProviderSetupProvider(Provider):
100
+ """Command palette entries for provider configuration."""
101
+
102
+ @property
103
+ def chat_screen(self) -> "ChatScreen":
104
+ from shotgun.tui.screens.chat import ChatScreen
105
+
106
+ return cast(ChatScreen, self.screen)
107
+
108
+ def open_provider_config(self) -> None:
109
+ """Show the provider configuration screen."""
110
+ self.chat_screen.app.push_screen("provider_config")
111
+
112
+ async def discover(self) -> AsyncGenerator[DiscoveryHit, None]:
113
+ yield DiscoveryHit(
114
+ "Open Provider Setup",
115
+ self.open_provider_config,
116
+ help="⚙️ Manage API keys for available providers",
117
+ )
118
+
119
+ async def search(self, query: str) -> AsyncGenerator[Hit, None]:
120
+ matcher = self.matcher(query)
121
+ title = "Open Provider Setup"
122
+ score = matcher.match(title)
123
+ if score > 0:
124
+ yield Hit(
125
+ score,
126
+ matcher.highlight(title),
127
+ self.open_provider_config,
128
+ help="⚙️ Manage API keys for available providers",
129
+ )
130
+
131
+
132
+ class CodebaseCommandProvider(Provider):
133
+ """Command palette entries for codebase management."""
134
+
135
+ @property
136
+ def chat_screen(self) -> "ChatScreen":
137
+ from shotgun.tui.screens.chat import ChatScreen
138
+
139
+ return cast(ChatScreen, self.screen)
140
+
141
+ async def discover(self) -> AsyncGenerator[DiscoveryHit, None]:
142
+ yield DiscoveryHit(
143
+ "Codebase: Index Codebase",
144
+ self.chat_screen.index_codebase_command,
145
+ help="Index a repository into the codebase graph",
146
+ )
147
+ yield DiscoveryHit(
148
+ "Codebase: Delete Codebase Index",
149
+ self.chat_screen.delete_codebase_command,
150
+ help="Delete an existing codebase index",
151
+ )
152
+
153
+ async def search(self, query: str) -> AsyncGenerator[Hit, None]:
154
+ matcher = self.matcher(query)
155
+ commands = [
156
+ (
157
+ "Codebase: Index Codebase",
158
+ self.chat_screen.index_codebase_command,
159
+ "Index a repository into the codebase graph",
160
+ ),
161
+ (
162
+ "Codebase: Delete Codebase Index",
163
+ self.chat_screen.delete_codebase_command,
164
+ "Delete an existing codebase index",
165
+ ),
166
+ ]
167
+ for title, callback, help_text in commands:
168
+ score = matcher.match(title)
169
+ if score > 0:
170
+ yield Hit(score, matcher.highlight(title), callback, help=help_text)
171
+
172
+
173
+ class DeleteCodebasePaletteProvider(Provider):
174
+ """Provider that lists indexed codebases for deletion."""
175
+
176
+ @property
177
+ def chat_screen(self) -> "ChatScreen":
178
+ from shotgun.tui.screens.chat import ChatScreen
179
+
180
+ return cast(ChatScreen, self.screen)
181
+
182
+ async def _codebases(self) -> list[CodebaseGraph]:
183
+ try:
184
+ result = await self.chat_screen.codebase_sdk.list_codebases()
185
+ except Exception as exc: # pragma: no cover - defensive UI path
186
+ self.chat_screen.notify(
187
+ f"Unable to load codebases: {exc}", severity="error"
188
+ )
189
+ return []
190
+ return result.graphs
191
+
192
+ async def discover(self) -> AsyncGenerator[DiscoveryHit, None]:
193
+ graphs = await self._codebases()
194
+ for graph in graphs:
195
+ title = f"Delete {graph.name}"
196
+ help_text = f"{graph.graph_id} • {graph.repo_path}"
197
+ yield DiscoveryHit(
198
+ title,
199
+ lambda graph_id=graph.graph_id: self.chat_screen.delete_codebase_from_palette(
200
+ graph_id
201
+ ),
202
+ help=help_text,
203
+ )
204
+
205
+ async def search(self, query: str) -> AsyncGenerator[Hit, None]:
206
+ matcher = self.matcher(query)
207
+ graphs = await self._codebases()
208
+ for graph in graphs:
209
+ display = f"{graph.name} ({graph.graph_id[:8]})"
210
+ score = matcher.match(display)
211
+ if score > 0:
212
+ yield Hit(
213
+ score,
214
+ matcher.highlight(display),
215
+ lambda graph_id=graph.graph_id: self.chat_screen.delete_codebase_from_palette(
216
+ graph_id
217
+ ),
218
+ help=graph.repo_path,
219
+ )
@@ -0,0 +1,40 @@
1
+ from typing import Literal
2
+
3
+ from pydantic import BaseModel
4
+ from textual.app import ComposeResult
5
+ from textual.widget import Widget
6
+ from textual.widgets import Markdown
7
+
8
+
9
+ class HintMessage(BaseModel):
10
+ message: str
11
+ kind: Literal["hint"] = "hint"
12
+
13
+
14
+ class HintMessageWidget(Widget):
15
+ """A message for the user generated by shotgun, not the agent.."""
16
+
17
+ DEFAULT_CSS = """
18
+ HintMessageWidget {
19
+ background: $secondary-background-darken-1;
20
+ height: auto;
21
+ margin: 1;
22
+ margin-left: 1;
23
+ padding: 1;
24
+
25
+ * > Markdown {
26
+ height: auto;
27
+ offset-x: -1;
28
+ padding: 0;
29
+ margin: 0;
30
+ }
31
+ }
32
+
33
+ """
34
+
35
+ def __init__(self, message: HintMessage) -> None:
36
+ super().__init__()
37
+ self.message = message
38
+
39
+ def compose(self) -> ComposeResult:
40
+ yield Markdown(markdown=f"{self.message.message}")
@@ -0,0 +1,221 @@
1
+ import json
2
+ from collections.abc import Sequence
3
+
4
+ from pydantic_ai.messages import (
5
+ BuiltinToolCallPart,
6
+ BuiltinToolReturnPart,
7
+ ModelMessage,
8
+ ModelRequest,
9
+ ModelRequestPart,
10
+ ModelResponse,
11
+ TextPart,
12
+ ThinkingPart,
13
+ ToolCallPart,
14
+ ToolReturnPart,
15
+ UserPromptPart,
16
+ )
17
+ from textual.app import ComposeResult
18
+ from textual.reactive import reactive
19
+ from textual.widget import Widget
20
+ from textual.widgets import Markdown
21
+
22
+ from shotgun.tui.components.vertical_tail import VerticalTail
23
+ from shotgun.tui.screens.chat_screen.hint_message import HintMessage, HintMessageWidget
24
+
25
+
26
+ class PartialResponseWidget(Widget): # TODO: doesn't work lol
27
+ DEFAULT_CSS = """
28
+ PartialResponseWidget {
29
+ height: auto;
30
+ }
31
+ Markdown, AgentResponseWidget, UserQuestionWidget {
32
+ height: auto;
33
+ }
34
+ """
35
+
36
+ item: reactive[ModelMessage | None] = reactive(None, recompose=True)
37
+
38
+ def __init__(self, item: ModelMessage | None) -> None:
39
+ super().__init__()
40
+ self.item = item
41
+
42
+ def compose(self) -> ComposeResult:
43
+ yield Markdown(markdown="**partial response**")
44
+ if self.item is None:
45
+ pass
46
+ elif self.item.kind == "response":
47
+ yield AgentResponseWidget(self.item)
48
+ elif self.item.kind == "request":
49
+ yield UserQuestionWidget(self.item)
50
+
51
+ def watch_item(self, item: ModelMessage | None) -> None:
52
+ if item is None:
53
+ self.display = False
54
+ else:
55
+ self.display = True
56
+
57
+
58
+ class ChatHistory(Widget):
59
+ DEFAULT_CSS = """
60
+ VerticalTail {
61
+ align: left bottom;
62
+
63
+ }
64
+ VerticalTail > * {
65
+ height: auto;
66
+ }
67
+
68
+ Horizontal {
69
+ height: auto;
70
+ background: $secondary-muted;
71
+ }
72
+
73
+ Markdown {
74
+ height: auto;
75
+ }
76
+ """
77
+ partial_response: reactive[ModelMessage | None] = reactive(None)
78
+
79
+ def __init__(self) -> None:
80
+ super().__init__()
81
+ self.items: Sequence[ModelMessage | HintMessage] = []
82
+ self.vertical_tail: VerticalTail | None = None
83
+ self.partial_response = None
84
+
85
+ def compose(self) -> ComposeResult:
86
+ self.vertical_tail = VerticalTail()
87
+
88
+ with self.vertical_tail:
89
+ for item in self.items:
90
+ if isinstance(item, ModelRequest):
91
+ yield UserQuestionWidget(item)
92
+ elif isinstance(item, HintMessage):
93
+ yield HintMessageWidget(item)
94
+ elif isinstance(item, ModelResponse):
95
+ yield AgentResponseWidget(item)
96
+ yield PartialResponseWidget(self.partial_response).data_bind(
97
+ item=ChatHistory.partial_response
98
+ )
99
+ self.call_later(self.autoscroll)
100
+
101
+ def update_messages(self, messages: list[ModelMessage | HintMessage]) -> None:
102
+ """Update the displayed messages without recomposing."""
103
+ if not self.vertical_tail:
104
+ return
105
+
106
+ self.items = messages
107
+ self.refresh(recompose=True)
108
+ self.call_later(self.autoscroll)
109
+
110
+ def autoscroll(self) -> None:
111
+ if self.vertical_tail:
112
+ self.vertical_tail.scroll_end(animate=False, immediate=False, force=True)
113
+
114
+
115
+ class UserQuestionWidget(Widget):
116
+ def __init__(self, item: ModelRequest | None) -> None:
117
+ super().__init__()
118
+ self.item = item
119
+
120
+ def compose(self) -> ComposeResult:
121
+ self.display = self.item is not None
122
+ if self.item is None:
123
+ yield Markdown(markdown="")
124
+ else:
125
+ prompt = self.format_prompt_parts(self.item.parts)
126
+ yield Markdown(markdown=prompt)
127
+
128
+ def format_prompt_parts(self, parts: Sequence[ModelRequestPart]) -> str:
129
+ acc = ""
130
+ for part in parts:
131
+ if isinstance(part, UserPromptPart):
132
+ acc += (
133
+ f"**>** {part.content if isinstance(part.content, str) else ''}\n\n"
134
+ )
135
+ elif isinstance(part, ToolReturnPart):
136
+ if part.tool_name == "ask_user" and isinstance(part.content, dict):
137
+ acc += f"**>** {part.content['answer']}\n\n"
138
+ else:
139
+ acc += " ∟ finished\n\n" # let's not show anything yet
140
+ elif isinstance(part, UserPromptPart):
141
+ acc += f"**>** {part.content}\n\n"
142
+ return acc
143
+
144
+
145
+ class AgentResponseWidget(Widget):
146
+ def __init__(self, item: ModelResponse | None) -> None:
147
+ super().__init__()
148
+ self.item = item
149
+
150
+ def compose(self) -> ComposeResult:
151
+ self.display = self.item is not None
152
+ if self.item is None:
153
+ yield Markdown(markdown="")
154
+ else:
155
+ yield Markdown(markdown=f"**⏺** {self.compute_output()}")
156
+
157
+ def compute_output(self) -> str:
158
+ acc = ""
159
+ if self.item is None:
160
+ return ""
161
+ for idx, part in enumerate(self.item.parts):
162
+ if isinstance(part, TextPart):
163
+ acc += f"{part.content}\n\n"
164
+ elif isinstance(part, ToolCallPart):
165
+ parts_str = self._format_tool_call_part(part)
166
+ acc += f"{part.tool_name}: " + parts_str + "\n\n"
167
+ elif isinstance(part, BuiltinToolCallPart):
168
+ acc += f"{part.tool_name}({part.args})\n\n"
169
+ elif isinstance(part, BuiltinToolReturnPart):
170
+ acc += f"builtin tool ({part.tool_name}) return: {part.content}\n\n"
171
+ elif isinstance(part, ThinkingPart):
172
+ if (
173
+ idx == len(self.item.parts) - 1
174
+ ): # show the thinking part only if it's the last part
175
+ acc += (
176
+ f"thinking: {part.content}\n\n"
177
+ if part.content
178
+ else "Thinking..."
179
+ )
180
+ else:
181
+ continue
182
+ return acc.strip()
183
+
184
+ def _format_tool_call_part(self, part: ToolCallPart) -> str:
185
+ if part.tool_name == "ask_user":
186
+ return self._format_ask_user_part(part)
187
+ # write_file
188
+ if part.tool_name == "write_file" or part.tool_name == "append_file":
189
+ if isinstance(part.args, dict) and "filename" in part.args:
190
+ return f"{part.tool_name}({part.args['filename']})"
191
+ else:
192
+ return f"{part.tool_name}()"
193
+ if part.tool_name == "write_artifact_section":
194
+ if isinstance(part.args, dict) and "section_title" in part.args:
195
+ return f"{part.tool_name}({part.args['section_title']})"
196
+ else:
197
+ return f"{part.tool_name}()"
198
+ if part.tool_name == "create_artifact":
199
+ if isinstance(part.args, dict) and "name" in part.args:
200
+ return f"{part.tool_name}({part.args['name']})"
201
+ else:
202
+ return f"▪ {part.tool_name}()"
203
+
204
+ return f"{part.tool_name}({part.args})"
205
+
206
+ def _format_ask_user_part(
207
+ self,
208
+ part: ToolCallPart,
209
+ ) -> str:
210
+ if isinstance(part.args, str):
211
+ try:
212
+ _args = json.loads(part.args) if part.args.strip() else {}
213
+ except json.JSONDecodeError:
214
+ _args = {}
215
+ else:
216
+ _args = part.args
217
+
218
+ if isinstance(_args, dict) and "question" in _args:
219
+ return f"{_args['question']}"
220
+ else:
221
+ return "❓ "
@@ -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()