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.
- shotgun/__init__.py +5 -0
- shotgun/agents/__init__.py +1 -0
- shotgun/agents/agent_manager.py +651 -0
- shotgun/agents/common.py +549 -0
- shotgun/agents/config/__init__.py +13 -0
- shotgun/agents/config/constants.py +17 -0
- shotgun/agents/config/manager.py +294 -0
- shotgun/agents/config/models.py +185 -0
- shotgun/agents/config/provider.py +206 -0
- shotgun/agents/conversation_history.py +106 -0
- shotgun/agents/conversation_manager.py +105 -0
- shotgun/agents/export.py +96 -0
- shotgun/agents/history/__init__.py +5 -0
- shotgun/agents/history/compaction.py +85 -0
- shotgun/agents/history/constants.py +19 -0
- shotgun/agents/history/context_extraction.py +108 -0
- shotgun/agents/history/history_building.py +104 -0
- shotgun/agents/history/history_processors.py +426 -0
- shotgun/agents/history/message_utils.py +84 -0
- shotgun/agents/history/token_counting.py +429 -0
- shotgun/agents/history/token_estimation.py +138 -0
- shotgun/agents/messages.py +35 -0
- shotgun/agents/models.py +275 -0
- shotgun/agents/plan.py +98 -0
- shotgun/agents/research.py +108 -0
- shotgun/agents/specify.py +98 -0
- shotgun/agents/tasks.py +96 -0
- shotgun/agents/tools/__init__.py +34 -0
- shotgun/agents/tools/codebase/__init__.py +28 -0
- shotgun/agents/tools/codebase/codebase_shell.py +256 -0
- shotgun/agents/tools/codebase/directory_lister.py +141 -0
- shotgun/agents/tools/codebase/file_read.py +144 -0
- shotgun/agents/tools/codebase/models.py +252 -0
- shotgun/agents/tools/codebase/query_graph.py +67 -0
- shotgun/agents/tools/codebase/retrieve_code.py +81 -0
- shotgun/agents/tools/file_management.py +218 -0
- shotgun/agents/tools/user_interaction.py +37 -0
- shotgun/agents/tools/web_search/__init__.py +60 -0
- shotgun/agents/tools/web_search/anthropic.py +144 -0
- shotgun/agents/tools/web_search/gemini.py +85 -0
- shotgun/agents/tools/web_search/openai.py +98 -0
- shotgun/agents/tools/web_search/utils.py +20 -0
- shotgun/build_constants.py +20 -0
- shotgun/cli/__init__.py +1 -0
- shotgun/cli/codebase/__init__.py +5 -0
- shotgun/cli/codebase/commands.py +202 -0
- shotgun/cli/codebase/models.py +21 -0
- shotgun/cli/config.py +275 -0
- shotgun/cli/export.py +81 -0
- shotgun/cli/models.py +10 -0
- shotgun/cli/plan.py +73 -0
- shotgun/cli/research.py +85 -0
- shotgun/cli/specify.py +69 -0
- shotgun/cli/tasks.py +78 -0
- shotgun/cli/update.py +152 -0
- shotgun/cli/utils.py +25 -0
- shotgun/codebase/__init__.py +12 -0
- shotgun/codebase/core/__init__.py +46 -0
- shotgun/codebase/core/change_detector.py +358 -0
- shotgun/codebase/core/code_retrieval.py +243 -0
- shotgun/codebase/core/ingestor.py +1497 -0
- shotgun/codebase/core/language_config.py +297 -0
- shotgun/codebase/core/manager.py +1662 -0
- shotgun/codebase/core/nl_query.py +331 -0
- shotgun/codebase/core/parser_loader.py +128 -0
- shotgun/codebase/models.py +111 -0
- shotgun/codebase/service.py +206 -0
- shotgun/logging_config.py +227 -0
- shotgun/main.py +167 -0
- shotgun/posthog_telemetry.py +158 -0
- shotgun/prompts/__init__.py +5 -0
- shotgun/prompts/agents/__init__.py +1 -0
- shotgun/prompts/agents/export.j2 +350 -0
- shotgun/prompts/agents/partials/codebase_understanding.j2 +87 -0
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +37 -0
- shotgun/prompts/agents/partials/content_formatting.j2 +65 -0
- shotgun/prompts/agents/partials/interactive_mode.j2 +26 -0
- shotgun/prompts/agents/plan.j2 +144 -0
- shotgun/prompts/agents/research.j2 +69 -0
- shotgun/prompts/agents/specify.j2 +51 -0
- shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2 +19 -0
- shotgun/prompts/agents/state/system_state.j2 +31 -0
- shotgun/prompts/agents/tasks.j2 +143 -0
- shotgun/prompts/codebase/__init__.py +1 -0
- shotgun/prompts/codebase/cypher_query_patterns.j2 +223 -0
- shotgun/prompts/codebase/cypher_system.j2 +28 -0
- shotgun/prompts/codebase/enhanced_query_context.j2 +10 -0
- shotgun/prompts/codebase/partials/cypher_rules.j2 +24 -0
- shotgun/prompts/codebase/partials/graph_schema.j2 +30 -0
- shotgun/prompts/codebase/partials/temporal_context.j2 +21 -0
- shotgun/prompts/history/__init__.py +1 -0
- shotgun/prompts/history/incremental_summarization.j2 +53 -0
- shotgun/prompts/history/summarization.j2 +46 -0
- shotgun/prompts/loader.py +140 -0
- shotgun/py.typed +0 -0
- shotgun/sdk/__init__.py +13 -0
- shotgun/sdk/codebase.py +219 -0
- shotgun/sdk/exceptions.py +17 -0
- shotgun/sdk/models.py +189 -0
- shotgun/sdk/services.py +23 -0
- shotgun/sentry_telemetry.py +87 -0
- shotgun/telemetry.py +93 -0
- shotgun/tui/__init__.py +0 -0
- shotgun/tui/app.py +116 -0
- shotgun/tui/commands/__init__.py +76 -0
- shotgun/tui/components/prompt_input.py +69 -0
- shotgun/tui/components/spinner.py +86 -0
- shotgun/tui/components/splash.py +25 -0
- shotgun/tui/components/vertical_tail.py +13 -0
- shotgun/tui/screens/chat.py +782 -0
- shotgun/tui/screens/chat.tcss +43 -0
- shotgun/tui/screens/chat_screen/__init__.py +0 -0
- shotgun/tui/screens/chat_screen/command_providers.py +219 -0
- shotgun/tui/screens/chat_screen/hint_message.py +40 -0
- shotgun/tui/screens/chat_screen/history.py +221 -0
- shotgun/tui/screens/directory_setup.py +113 -0
- shotgun/tui/screens/provider_config.py +221 -0
- shotgun/tui/screens/splash.py +31 -0
- shotgun/tui/styles.tcss +10 -0
- shotgun/tui/utils/__init__.py +5 -0
- shotgun/tui/utils/mode_progress.py +257 -0
- shotgun/utils/__init__.py +5 -0
- shotgun/utils/env_utils.py +35 -0
- shotgun/utils/file_system_utils.py +36 -0
- shotgun/utils/update_checker.py +375 -0
- shotgun_sh-0.1.0.dist-info/METADATA +466 -0
- shotgun_sh-0.1.0.dist-info/RECORD +130 -0
- shotgun_sh-0.1.0.dist-info/WHEEL +4 -0
- shotgun_sh-0.1.0.dist-info/entry_points.txt +2 -0
- 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()
|