shotgun-sh 0.1.0.dev15__py3-none-any.whl → 0.1.0.dev16__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/agents/agent_manager.py +243 -24
- shotgun/agents/tools/artifact_management.py +2 -3
- shotgun/artifacts/templates/research/sdk_comparison.yaml +73 -73
- shotgun/prompts/agents/partials/interactive_mode.j2 +10 -1
- shotgun/prompts/agents/plan.j2 +9 -12
- shotgun/prompts/agents/research.j2 +6 -3
- shotgun/prompts/agents/specify.j2 +8 -9
- shotgun/prompts/agents/state/artifact_templates_available.j2 +3 -1
- shotgun/prompts/agents/state/existing_artifacts_available.j2 +2 -0
- shotgun/prompts/agents/tasks.j2 +1 -1
- shotgun/prompts/codebase/cypher_query_patterns.j2 +2 -0
- shotgun/prompts/codebase/partials/graph_schema.j2 +4 -2
- shotgun/tui/app.py +9 -1
- shotgun/tui/commands/__init__.py +73 -0
- shotgun/tui/screens/chat.py +361 -178
- shotgun/tui/screens/chat.tcss +11 -0
- shotgun/tui/screens/chat_screen/__init__.py +0 -0
- shotgun/tui/screens/chat_screen/command_providers.py +197 -0
- shotgun/tui/screens/chat_screen/history.py +160 -0
- {shotgun_sh-0.1.0.dev15.dist-info → shotgun_sh-0.1.0.dev16.dist-info}/METADATA +1 -1
- {shotgun_sh-0.1.0.dev15.dist-info → shotgun_sh-0.1.0.dev16.dist-info}/RECORD +24 -20
- {shotgun_sh-0.1.0.dev15.dist-info → shotgun_sh-0.1.0.dev16.dist-info}/WHEEL +0 -0
- {shotgun_sh-0.1.0.dev15.dist-info → shotgun_sh-0.1.0.dev16.dist-info}/entry_points.txt +0 -0
- {shotgun_sh-0.1.0.dev15.dist-info → shotgun_sh-0.1.0.dev16.dist-info}/licenses/LICENSE +0 -0
shotgun/tui/screens/chat.py
CHANGED
|
@@ -1,27 +1,31 @@
|
|
|
1
|
-
|
|
2
|
-
from
|
|
1
|
+
import logging
|
|
2
|
+
from collections.abc import Iterable
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
3
5
|
|
|
4
6
|
from pydantic_ai import DeferredToolResults, RunContext
|
|
5
7
|
from pydantic_ai.messages import (
|
|
6
|
-
BuiltinToolCallPart,
|
|
7
|
-
BuiltinToolReturnPart,
|
|
8
8
|
ModelMessage,
|
|
9
9
|
ModelRequest,
|
|
10
10
|
ModelResponse,
|
|
11
11
|
TextPart,
|
|
12
|
-
|
|
13
|
-
ToolCallPart,
|
|
12
|
+
UserPromptPart,
|
|
14
13
|
)
|
|
15
14
|
from textual import on, work
|
|
16
15
|
from textual.app import ComposeResult
|
|
17
|
-
from textual.command import
|
|
18
|
-
from textual.containers import Container
|
|
16
|
+
from textual.command import CommandPalette
|
|
17
|
+
from textual.containers import Container, Grid
|
|
19
18
|
from textual.reactive import reactive
|
|
20
|
-
from textual.screen import Screen
|
|
19
|
+
from textual.screen import ModalScreen, Screen
|
|
21
20
|
from textual.widget import Widget
|
|
22
|
-
from textual.widgets import Markdown
|
|
21
|
+
from textual.widgets import Button, DirectoryTree, Input, Label, Markdown, Static
|
|
23
22
|
|
|
24
|
-
from shotgun.agents.agent_manager import
|
|
23
|
+
from shotgun.agents.agent_manager import (
|
|
24
|
+
AgentManager,
|
|
25
|
+
AgentType,
|
|
26
|
+
MessageHistoryUpdated,
|
|
27
|
+
PartialResponseMessage,
|
|
28
|
+
)
|
|
25
29
|
from shotgun.agents.config import get_provider_model
|
|
26
30
|
from shotgun.agents.models import (
|
|
27
31
|
AgentDeps,
|
|
@@ -29,16 +33,22 @@ from shotgun.agents.models import (
|
|
|
29
33
|
UserAnswer,
|
|
30
34
|
UserQuestion,
|
|
31
35
|
)
|
|
36
|
+
from shotgun.sdk.codebase import CodebaseSDK
|
|
37
|
+
from shotgun.sdk.exceptions import CodebaseNotFoundError, InvalidPathError
|
|
32
38
|
from shotgun.sdk.services import get_artifact_service, get_codebase_service
|
|
39
|
+
from shotgun.tui.commands import CommandHandler
|
|
40
|
+
from shotgun.tui.screens.chat_screen.history import ChatHistory
|
|
33
41
|
|
|
34
42
|
from ..components.prompt_input import PromptInput
|
|
35
43
|
from ..components.spinner import Spinner
|
|
36
|
-
from
|
|
37
|
-
|
|
44
|
+
from .chat_screen.command_providers import (
|
|
45
|
+
AgentModeProvider,
|
|
46
|
+
CodebaseCommandProvider,
|
|
47
|
+
DeleteCodebasePaletteProvider,
|
|
48
|
+
ProviderSetupProvider,
|
|
49
|
+
)
|
|
38
50
|
|
|
39
|
-
|
|
40
|
-
"""Dummy system prompt function for TUI chat interface."""
|
|
41
|
-
return "You are a helpful AI assistant."
|
|
51
|
+
logger = logging.getLogger(__name__)
|
|
42
52
|
|
|
43
53
|
|
|
44
54
|
class PromptHistory:
|
|
@@ -67,91 +77,19 @@ class PromptHistory:
|
|
|
67
77
|
self.curr = None
|
|
68
78
|
|
|
69
79
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
align: left bottom;
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
VerticalTail > * {
|
|
77
|
-
height: auto;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
Horizontal {
|
|
81
|
-
height: auto;
|
|
82
|
-
background: $secondary-muted;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
Markdown {
|
|
86
|
-
height: auto;
|
|
87
|
-
}
|
|
88
|
-
"""
|
|
89
|
-
|
|
90
|
-
def __init__(self) -> None:
|
|
91
|
-
super().__init__()
|
|
92
|
-
self.items: list[ModelMessage] = []
|
|
93
|
-
self.vertical_tail: VerticalTail | None = None
|
|
94
|
-
|
|
95
|
-
def compose(self) -> ComposeResult:
|
|
96
|
-
self.vertical_tail = VerticalTail()
|
|
97
|
-
yield self.vertical_tail
|
|
98
|
-
|
|
99
|
-
def update_messages(self, messages: list[ModelMessage]) -> None:
|
|
100
|
-
"""Update the displayed messages without recomposing."""
|
|
101
|
-
if not self.vertical_tail:
|
|
102
|
-
return
|
|
103
|
-
|
|
104
|
-
# Clear existing widgets
|
|
105
|
-
self.vertical_tail.remove_children()
|
|
106
|
-
|
|
107
|
-
# Add new message widgets
|
|
108
|
-
for item in messages:
|
|
109
|
-
if isinstance(item, ModelRequest):
|
|
110
|
-
self.vertical_tail.mount(UserQuestionWidget(item))
|
|
111
|
-
elif isinstance(item, ModelResponse):
|
|
112
|
-
self.vertical_tail.mount(AgentResponseWidget(item))
|
|
80
|
+
@dataclass
|
|
81
|
+
class CodebaseIndexSelection:
|
|
82
|
+
"""User-selected repository path and name for indexing."""
|
|
113
83
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
class UserQuestionWidget(Widget):
|
|
118
|
-
def __init__(self, item: ModelRequest) -> None:
|
|
119
|
-
super().__init__()
|
|
120
|
-
self.item = item
|
|
121
|
-
|
|
122
|
-
def compose(self) -> ComposeResult:
|
|
123
|
-
prompt = "".join(str(part.content) for part in self.item.parts if part.content)
|
|
124
|
-
yield Markdown(markdown=f"**>** {prompt}")
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
class AgentResponseWidget(Widget):
|
|
128
|
-
def __init__(self, item: ModelResponse) -> None:
|
|
129
|
-
super().__init__()
|
|
130
|
-
self.item = item
|
|
131
|
-
|
|
132
|
-
def compose(self) -> ComposeResult:
|
|
133
|
-
yield Markdown(markdown=f"**⏺** {self.compute_output()}")
|
|
134
|
-
|
|
135
|
-
def compute_output(self) -> str:
|
|
136
|
-
acc = ""
|
|
137
|
-
for part in self.item.parts: # TextPart | ToolCallPart | BuiltinToolCallPart | BuiltinToolReturnPart | ThinkingPart
|
|
138
|
-
if isinstance(part, TextPart):
|
|
139
|
-
acc += part.content
|
|
140
|
-
elif isinstance(part, ToolCallPart):
|
|
141
|
-
acc += f"{part.tool_name}({part.args})\n"
|
|
142
|
-
elif isinstance(part, BuiltinToolCallPart):
|
|
143
|
-
acc += f"{part.tool_name}({part.args})\n"
|
|
144
|
-
elif isinstance(part, BuiltinToolReturnPart):
|
|
145
|
-
acc += f"{part.tool_name}()\n"
|
|
146
|
-
elif isinstance(part, ThinkingPart):
|
|
147
|
-
acc += f"Thinking: {part.content}\n"
|
|
148
|
-
return acc
|
|
84
|
+
repo_path: Path
|
|
85
|
+
name: str
|
|
149
86
|
|
|
150
87
|
|
|
151
88
|
class StatusBar(Widget):
|
|
152
89
|
DEFAULT_CSS = """
|
|
153
90
|
StatusBar {
|
|
154
91
|
text-wrap: wrap;
|
|
92
|
+
padding-left: 1;
|
|
155
93
|
}
|
|
156
94
|
"""
|
|
157
95
|
|
|
@@ -165,6 +103,7 @@ class ModeIndicator(Widget):
|
|
|
165
103
|
DEFAULT_CSS = """
|
|
166
104
|
ModeIndicator {
|
|
167
105
|
text-wrap: wrap;
|
|
106
|
+
padding-left: 1;
|
|
168
107
|
}
|
|
169
108
|
"""
|
|
170
109
|
|
|
@@ -183,11 +122,13 @@ class ModeIndicator(Widget):
|
|
|
183
122
|
AgentType.RESEARCH: "Research",
|
|
184
123
|
AgentType.PLAN: "Planning",
|
|
185
124
|
AgentType.TASKS: "Tasks",
|
|
125
|
+
AgentType.SPECIFY: "Specify",
|
|
186
126
|
}
|
|
187
127
|
mode_description = {
|
|
188
128
|
AgentType.RESEARCH: "Research topics with web search and synthesize findings",
|
|
189
129
|
AgentType.PLAN: "Create comprehensive, actionable plans with milestones",
|
|
190
130
|
AgentType.TASKS: "Generate specific, actionable tasks from research and plans",
|
|
131
|
+
AgentType.SPECIFY: "Create detailed specifications and requirements documents",
|
|
191
132
|
}
|
|
192
133
|
|
|
193
134
|
mode_title = mode_display.get(self.mode, self.mode.value.title())
|
|
@@ -196,97 +137,154 @@ class ModeIndicator(Widget):
|
|
|
196
137
|
return f"[bold $text-accent]{mode_title} mode[/][$foreground-muted] ({description})[/]"
|
|
197
138
|
|
|
198
139
|
|
|
199
|
-
class
|
|
200
|
-
|
|
140
|
+
class FilteredDirectoryTree(DirectoryTree):
|
|
141
|
+
def filter_paths(self, paths: Iterable[Path]) -> Iterable[Path]:
|
|
142
|
+
return [path for path in paths if path.is_dir()]
|
|
201
143
|
|
|
202
|
-
@property
|
|
203
|
-
def chat_screen(self) -> "ChatScreen":
|
|
204
|
-
return cast(ChatScreen, self.screen)
|
|
205
144
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
self.chat_screen.mode = mode
|
|
145
|
+
class CodebaseIndexPromptScreen(ModalScreen[bool]):
|
|
146
|
+
"""Modal dialog asking whether to index the detected codebase."""
|
|
209
147
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
help="🔬 Research topics with web search and synthesize findings",
|
|
216
|
-
)
|
|
217
|
-
yield DiscoveryHit(
|
|
218
|
-
"Switch to Plan Mode",
|
|
219
|
-
lambda: self.set_mode(AgentType.PLAN),
|
|
220
|
-
help="📋 Create comprehensive, actionable plans with milestones",
|
|
221
|
-
)
|
|
222
|
-
yield DiscoveryHit(
|
|
223
|
-
"Switch to Tasks Mode",
|
|
224
|
-
lambda: self.set_mode(AgentType.TASKS),
|
|
225
|
-
help="✅ Generate specific, actionable tasks from research and plans",
|
|
226
|
-
)
|
|
148
|
+
DEFAULT_CSS = """
|
|
149
|
+
CodebaseIndexPromptScreen {
|
|
150
|
+
align: center middle;
|
|
151
|
+
background: rgba(0, 0, 0, 0.0);
|
|
152
|
+
}
|
|
227
153
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
154
|
+
CodebaseIndexPromptScreen > #index-prompt-dialog {
|
|
155
|
+
width: 60%;
|
|
156
|
+
max-width: 60;
|
|
157
|
+
height: auto;
|
|
158
|
+
border: wide $primary;
|
|
159
|
+
padding: 1 2;
|
|
160
|
+
layout: vertical;
|
|
161
|
+
background: $surface;
|
|
162
|
+
height: auto;
|
|
163
|
+
}
|
|
231
164
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
),
|
|
239
|
-
(
|
|
240
|
-
"Switch to Plan Mode",
|
|
241
|
-
"📋 Create comprehensive, actionable plans with milestones",
|
|
242
|
-
lambda: self.set_mode(AgentType.PLAN),
|
|
243
|
-
AgentType.PLAN,
|
|
244
|
-
),
|
|
245
|
-
(
|
|
246
|
-
"Switch to Tasks Mode",
|
|
247
|
-
"✅ Generate specific, actionable tasks from research and plans",
|
|
248
|
-
lambda: self.set_mode(AgentType.TASKS),
|
|
249
|
-
AgentType.TASKS,
|
|
250
|
-
),
|
|
251
|
-
]
|
|
165
|
+
#index-prompt-buttons {
|
|
166
|
+
layout: horizontal;
|
|
167
|
+
align-horizontal: right;
|
|
168
|
+
height: auto;
|
|
169
|
+
}
|
|
170
|
+
"""
|
|
252
171
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
172
|
+
def compose(self) -> ComposeResult:
|
|
173
|
+
with Container(id="index-prompt-dialog"):
|
|
174
|
+
yield Label("Index your codebase?", id="index-prompt-title")
|
|
175
|
+
yield Static(
|
|
176
|
+
"We found project files but no index yet. Indexing enables smarter chat."
|
|
177
|
+
)
|
|
178
|
+
with Container(id="index-prompt-buttons"):
|
|
179
|
+
yield Button(
|
|
180
|
+
"Index now",
|
|
181
|
+
id="index-prompt-confirm",
|
|
182
|
+
variant="primary",
|
|
183
|
+
)
|
|
184
|
+
yield Button("Not now", id="index-prompt-cancel")
|
|
259
185
|
|
|
186
|
+
@on(Button.Pressed, "#index-prompt-cancel")
|
|
187
|
+
def handle_cancel(self, event: Button.Pressed) -> None:
|
|
188
|
+
event.stop()
|
|
189
|
+
self.dismiss(False)
|
|
260
190
|
|
|
261
|
-
|
|
262
|
-
|
|
191
|
+
@on(Button.Pressed, "#index-prompt-confirm")
|
|
192
|
+
def handle_confirm(self, event: Button.Pressed) -> None:
|
|
193
|
+
event.stop()
|
|
194
|
+
self.dismiss(True)
|
|
263
195
|
|
|
264
|
-
@property
|
|
265
|
-
def chat_screen(self) -> "ChatScreen":
|
|
266
|
-
return cast(ChatScreen, self.screen)
|
|
267
196
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
self.chat_screen.app.push_screen("provider_config")
|
|
197
|
+
class CodebaseIndexScreen(ModalScreen[CodebaseIndexSelection | None]):
|
|
198
|
+
"""Modal dialog for choosing a repository and name to index."""
|
|
271
199
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
200
|
+
DEFAULT_CSS = """
|
|
201
|
+
CodebaseIndexScreen {
|
|
202
|
+
align: center middle;
|
|
203
|
+
background: rgba(0, 0, 0, 0.0);
|
|
204
|
+
}
|
|
205
|
+
CodebaseIndexScreen > #index-dialog {
|
|
206
|
+
width: 80%;
|
|
207
|
+
max-width: 80;
|
|
208
|
+
height: 80%;
|
|
209
|
+
max-height: 40;
|
|
210
|
+
border: wide $primary;
|
|
211
|
+
padding: 1;
|
|
212
|
+
layout: vertical;
|
|
213
|
+
background: $surface;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
#index-dialog DirectoryTree {
|
|
217
|
+
height: 1fr;
|
|
218
|
+
border: solid $accent;
|
|
219
|
+
overflow: auto;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
#index-dialog-controls {
|
|
223
|
+
layout: horizontal;
|
|
224
|
+
align-horizontal: right;
|
|
225
|
+
padding-top: 1;
|
|
226
|
+
}
|
|
227
|
+
"""
|
|
278
228
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
229
|
+
def __init__(self, start_path: Path | None = None) -> None:
|
|
230
|
+
super().__init__()
|
|
231
|
+
self.start_path = Path(start_path or Path.cwd())
|
|
232
|
+
self.selected_path: Path | None = self.start_path
|
|
233
|
+
|
|
234
|
+
def compose(self) -> ComposeResult:
|
|
235
|
+
with Container(id="index-dialog"):
|
|
236
|
+
yield Label("Index a codebase", id="index-dialog-title")
|
|
237
|
+
yield FilteredDirectoryTree(self.start_path, id="index-directory-tree")
|
|
238
|
+
yield Input(
|
|
239
|
+
placeholder="Enter a name for the codebase",
|
|
240
|
+
id="index-codebase-name",
|
|
289
241
|
)
|
|
242
|
+
with Container(id="index-dialog-controls"):
|
|
243
|
+
yield Button("Cancel", id="index-cancel")
|
|
244
|
+
yield Button(
|
|
245
|
+
"Index",
|
|
246
|
+
id="index-confirm",
|
|
247
|
+
variant="primary",
|
|
248
|
+
disabled=True,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
def _update_confirm(self) -> None:
|
|
252
|
+
confirm = self.query_one("#index-confirm", Button)
|
|
253
|
+
name_input = self.query_one("#index-codebase-name", Input)
|
|
254
|
+
confirm.disabled = not (self.selected_path and name_input.value.strip())
|
|
255
|
+
|
|
256
|
+
@on(DirectoryTree.DirectorySelected, "#index-directory-tree")
|
|
257
|
+
def handle_directory_selected(self, event: DirectoryTree.DirectorySelected) -> None:
|
|
258
|
+
event.stop()
|
|
259
|
+
selected = event.path if event.path.is_dir() else event.path.parent
|
|
260
|
+
self.selected_path = selected
|
|
261
|
+
name_input = self.query_one("#index-codebase-name", Input)
|
|
262
|
+
if not name_input.value:
|
|
263
|
+
name_input.value = selected.name
|
|
264
|
+
self._update_confirm()
|
|
265
|
+
|
|
266
|
+
@on(Input.Changed, "#index-codebase-name")
|
|
267
|
+
def handle_name_changed(self, event: Input.Changed) -> None:
|
|
268
|
+
event.stop()
|
|
269
|
+
self._update_confirm()
|
|
270
|
+
|
|
271
|
+
@on(Button.Pressed, "#index-cancel")
|
|
272
|
+
def handle_cancel(self, event: Button.Pressed) -> None:
|
|
273
|
+
event.stop()
|
|
274
|
+
self.dismiss(None)
|
|
275
|
+
|
|
276
|
+
@on(Button.Pressed, "#index-confirm")
|
|
277
|
+
def handle_confirm(self, event: Button.Pressed) -> None:
|
|
278
|
+
event.stop()
|
|
279
|
+
name_input = self.query_one("#index-codebase-name", Input)
|
|
280
|
+
if not self.selected_path:
|
|
281
|
+
self.dismiss(None)
|
|
282
|
+
return
|
|
283
|
+
selection = CodebaseIndexSelection(
|
|
284
|
+
repo_path=self.selected_path,
|
|
285
|
+
name=name_input.value.strip(),
|
|
286
|
+
)
|
|
287
|
+
self.dismiss(selection)
|
|
290
288
|
|
|
291
289
|
|
|
292
290
|
class ChatScreen(Screen[None]):
|
|
@@ -297,7 +295,7 @@ class ChatScreen(Screen[None]):
|
|
|
297
295
|
("shift+tab", "toggle_mode", "Toggle mode"),
|
|
298
296
|
]
|
|
299
297
|
|
|
300
|
-
COMMANDS = {AgentModeProvider, ProviderSetupProvider}
|
|
298
|
+
COMMANDS = {AgentModeProvider, ProviderSetupProvider, CodebaseCommandProvider}
|
|
301
299
|
|
|
302
300
|
_PLACEHOLDER_BY_MODE: dict[AgentType, str] = {
|
|
303
301
|
AgentType.RESEARCH: (
|
|
@@ -309,6 +307,9 @@ class ChatScreen(Screen[None]):
|
|
|
309
307
|
AgentType.TASKS: (
|
|
310
308
|
"Request actionable work, e.g. break down tasks to wire OpenTelemetry into the API"
|
|
311
309
|
),
|
|
310
|
+
AgentType.SPECIFY: (
|
|
311
|
+
"Request detailed specifications, e.g. create a comprehensive spec for user authentication system"
|
|
312
|
+
),
|
|
312
313
|
}
|
|
313
314
|
|
|
314
315
|
value = reactive("")
|
|
@@ -317,6 +318,8 @@ class ChatScreen(Screen[None]):
|
|
|
317
318
|
messages = reactive(list[ModelMessage]())
|
|
318
319
|
working = reactive(False)
|
|
319
320
|
question: reactive[UserQuestion | None] = reactive(None)
|
|
321
|
+
indexing_job: reactive[CodebaseIndexSelection | None] = reactive(None)
|
|
322
|
+
partial_message: reactive[ModelMessage | None] = reactive(None)
|
|
320
323
|
|
|
321
324
|
def __init__(self) -> None:
|
|
322
325
|
super().__init__()
|
|
@@ -324,19 +327,62 @@ class ChatScreen(Screen[None]):
|
|
|
324
327
|
model_config = get_provider_model()
|
|
325
328
|
codebase_service = get_codebase_service()
|
|
326
329
|
artifact_service = get_artifact_service()
|
|
330
|
+
self.codebase_sdk = CodebaseSDK()
|
|
331
|
+
|
|
332
|
+
# Create shared deps without system_prompt_fn (agents provide their own)
|
|
333
|
+
# We need a placeholder system_prompt_fn to satisfy the field requirement
|
|
334
|
+
def _placeholder_system_prompt_fn(ctx: RunContext[AgentDeps]) -> str:
|
|
335
|
+
raise RuntimeError(
|
|
336
|
+
"This should not be called - agents provide their own system_prompt_fn"
|
|
337
|
+
)
|
|
338
|
+
|
|
327
339
|
self.deps = AgentDeps(
|
|
328
340
|
interactive_mode=True,
|
|
329
341
|
llm_model=model_config,
|
|
330
342
|
codebase_service=codebase_service,
|
|
331
343
|
artifact_service=artifact_service,
|
|
332
|
-
system_prompt_fn=
|
|
344
|
+
system_prompt_fn=_placeholder_system_prompt_fn,
|
|
333
345
|
)
|
|
334
346
|
self.agent_manager = AgentManager(deps=self.deps, initial_type=self.mode)
|
|
347
|
+
self.command_handler = CommandHandler()
|
|
335
348
|
|
|
336
349
|
def on_mount(self) -> None:
|
|
337
350
|
self.query_one(PromptInput).focus(scroll_visible=True)
|
|
338
351
|
# Hide spinner initially
|
|
339
352
|
self.query_one("#spinner").display = False
|
|
353
|
+
self.call_later(self.check_if_codebase_is_indexed)
|
|
354
|
+
# Start the question listener worker to handle ask_user interactions
|
|
355
|
+
self.call_later(self.add_question_listener)
|
|
356
|
+
|
|
357
|
+
@work
|
|
358
|
+
async def check_if_codebase_is_indexed(self) -> None:
|
|
359
|
+
cur_dir = Path.cwd().resolve()
|
|
360
|
+
is_empty = all(
|
|
361
|
+
dir.is_dir() and dir.name in ["__pycache__", ".git", ".shotgun"]
|
|
362
|
+
for dir in cur_dir.iterdir()
|
|
363
|
+
)
|
|
364
|
+
if is_empty:
|
|
365
|
+
return
|
|
366
|
+
|
|
367
|
+
# find at least one codebase that is indexed in the current directory
|
|
368
|
+
directory_indexed = next(
|
|
369
|
+
(
|
|
370
|
+
dir
|
|
371
|
+
for dir in (await self.codebase_sdk.list_codebases()).graphs
|
|
372
|
+
if cur_dir.is_relative_to(Path(dir.repo_path).resolve())
|
|
373
|
+
),
|
|
374
|
+
None,
|
|
375
|
+
)
|
|
376
|
+
if directory_indexed:
|
|
377
|
+
self.mount_hint(help_text_with_codebase())
|
|
378
|
+
return
|
|
379
|
+
|
|
380
|
+
should_index = await self.app.push_screen_wait(CodebaseIndexPromptScreen())
|
|
381
|
+
if not should_index:
|
|
382
|
+
self.mount_hint(help_text_empty_dir())
|
|
383
|
+
return
|
|
384
|
+
|
|
385
|
+
self.index_codebase_command()
|
|
340
386
|
|
|
341
387
|
def watch_mode(self, new_mode: AgentType) -> None:
|
|
342
388
|
"""React to mode changes by updating the agent manager."""
|
|
@@ -377,7 +423,7 @@ class ChatScreen(Screen[None]):
|
|
|
377
423
|
question_display.display = False
|
|
378
424
|
|
|
379
425
|
def action_toggle_mode(self) -> None:
|
|
380
|
-
modes = [AgentType.RESEARCH, AgentType.PLAN, AgentType.TASKS]
|
|
426
|
+
modes = [AgentType.RESEARCH, AgentType.PLAN, AgentType.TASKS, AgentType.SPECIFY]
|
|
381
427
|
self.mode = modes[(modes.index(self.mode) + 1) % len(modes)]
|
|
382
428
|
self.agent_manager.set_agent(self.mode)
|
|
383
429
|
# whoops it actually changes focus. Let's be brutal for now
|
|
@@ -394,9 +440,9 @@ class ChatScreen(Screen[None]):
|
|
|
394
440
|
def compose(self) -> ComposeResult:
|
|
395
441
|
"""Create child widgets for the app."""
|
|
396
442
|
with Container(id="window"):
|
|
443
|
+
yield self.agent_manager
|
|
397
444
|
yield ChatHistory()
|
|
398
445
|
yield Markdown(markdown="", id="question-display")
|
|
399
|
-
yield self.agent_manager
|
|
400
446
|
with Container(id="footer"):
|
|
401
447
|
yield Spinner(
|
|
402
448
|
text="Processing...",
|
|
@@ -410,7 +456,24 @@ class ChatScreen(Screen[None]):
|
|
|
410
456
|
id="prompt-input",
|
|
411
457
|
placeholder=self._placeholder_for_mode(self.mode),
|
|
412
458
|
)
|
|
413
|
-
|
|
459
|
+
with Grid():
|
|
460
|
+
yield ModeIndicator(mode=self.mode)
|
|
461
|
+
yield Static("", id="indexing-job-display")
|
|
462
|
+
|
|
463
|
+
def mount_hint(self, markdown: str) -> None:
|
|
464
|
+
chat_history = self.query_one(ChatHistory)
|
|
465
|
+
if not chat_history.vertical_tail:
|
|
466
|
+
return
|
|
467
|
+
chat_history.vertical_tail.mount(Markdown(markdown))
|
|
468
|
+
|
|
469
|
+
@on(PartialResponseMessage)
|
|
470
|
+
def handle_partial_response(self, event: PartialResponseMessage) -> None:
|
|
471
|
+
self.partial_message = event.message
|
|
472
|
+
|
|
473
|
+
partial_response_widget = self.query_one(ChatHistory)
|
|
474
|
+
partial_response_widget.partial_response = self.partial_message
|
|
475
|
+
if event.is_last:
|
|
476
|
+
partial_response_widget.partial_response = None
|
|
414
477
|
|
|
415
478
|
@on(MessageHistoryUpdated)
|
|
416
479
|
def handle_message_history_updated(self, event: MessageHistoryUpdated) -> None:
|
|
@@ -451,11 +514,42 @@ class ChatScreen(Screen[None]):
|
|
|
451
514
|
|
|
452
515
|
@on(PromptInput.Submitted)
|
|
453
516
|
async def handle_submit(self, message: PromptInput.Submitted) -> None:
|
|
517
|
+
text = message.text.strip()
|
|
518
|
+
|
|
519
|
+
# If empty text, just clear input and return
|
|
520
|
+
if not text:
|
|
521
|
+
prompt_input = self.query_one(PromptInput)
|
|
522
|
+
prompt_input.clear()
|
|
523
|
+
self.value = ""
|
|
524
|
+
return
|
|
525
|
+
|
|
526
|
+
# Check if it's a command
|
|
527
|
+
if self.command_handler.is_command(text):
|
|
528
|
+
success, response = self.command_handler.handle_command(text)
|
|
529
|
+
|
|
530
|
+
# Add the command to history
|
|
531
|
+
self.history.append(message.text)
|
|
532
|
+
|
|
533
|
+
# Display the command in chat history
|
|
534
|
+
user_message = ModelRequest(parts=[UserPromptPart(content=text)])
|
|
535
|
+
self.messages = self.messages + [user_message]
|
|
536
|
+
|
|
537
|
+
# Display the response (help text or error message)
|
|
538
|
+
response_message = ModelResponse(parts=[TextPart(content=response)])
|
|
539
|
+
self.messages = self.messages + [response_message]
|
|
540
|
+
|
|
541
|
+
# Clear the input
|
|
542
|
+
prompt_input = self.query_one(PromptInput)
|
|
543
|
+
prompt_input.clear()
|
|
544
|
+
self.value = ""
|
|
545
|
+
return
|
|
546
|
+
|
|
547
|
+
# Not a command, process as normal
|
|
454
548
|
self.history.append(message.text)
|
|
455
549
|
|
|
456
550
|
# Clear the input
|
|
457
551
|
self.value = ""
|
|
458
|
-
self.run_agent(
|
|
552
|
+
self.run_agent(text) # Use stripped text
|
|
459
553
|
|
|
460
554
|
prompt_input = self.query_one(PromptInput)
|
|
461
555
|
prompt_input.clear()
|
|
@@ -464,6 +558,69 @@ class ChatScreen(Screen[None]):
|
|
|
464
558
|
"""Return the placeholder text appropriate for the current mode."""
|
|
465
559
|
return self._PLACEHOLDER_BY_MODE.get(mode, "Type your message")
|
|
466
560
|
|
|
561
|
+
def index_codebase_command(self) -> None:
|
|
562
|
+
start_path = Path.cwd()
|
|
563
|
+
|
|
564
|
+
def handle_result(result: CodebaseIndexSelection | None) -> None:
|
|
565
|
+
if result:
|
|
566
|
+
self.call_later(lambda: self.index_codebase(result))
|
|
567
|
+
|
|
568
|
+
self.app.push_screen(
|
|
569
|
+
CodebaseIndexScreen(start_path=start_path),
|
|
570
|
+
handle_result,
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
def delete_codebase_command(self) -> None:
|
|
574
|
+
self.app.push_screen(
|
|
575
|
+
CommandPalette(
|
|
576
|
+
providers=[DeleteCodebasePaletteProvider],
|
|
577
|
+
placeholder="Select a codebase to delete…",
|
|
578
|
+
)
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
def delete_codebase_from_palette(self, graph_id: str) -> None:
|
|
582
|
+
stack = getattr(self.app, "screen_stack", None)
|
|
583
|
+
if stack and isinstance(stack[-1], CommandPalette):
|
|
584
|
+
self.app.pop_screen()
|
|
585
|
+
|
|
586
|
+
self.call_later(lambda: self.delete_codebase(graph_id))
|
|
587
|
+
|
|
588
|
+
@work
|
|
589
|
+
async def delete_codebase(self, graph_id: str) -> None:
|
|
590
|
+
try:
|
|
591
|
+
await self.codebase_sdk.delete_codebase(graph_id)
|
|
592
|
+
self.notify(f"Deleted codebase: {graph_id}", severity="information")
|
|
593
|
+
except CodebaseNotFoundError as exc:
|
|
594
|
+
self.notify(str(exc), severity="error")
|
|
595
|
+
except Exception as exc: # pragma: no cover - defensive UI path
|
|
596
|
+
self.notify(f"Failed to delete codebase: {exc}", severity="error")
|
|
597
|
+
|
|
598
|
+
@work
|
|
599
|
+
async def index_codebase(self, selection: CodebaseIndexSelection) -> None:
|
|
600
|
+
label = self.query_one("#indexing-job-display", Static)
|
|
601
|
+
label.update(
|
|
602
|
+
f"[$foreground-muted]Indexing [bold $text-accent]{selection.name}[/]...[/]"
|
|
603
|
+
)
|
|
604
|
+
label.refresh()
|
|
605
|
+
try:
|
|
606
|
+
result = await self.codebase_sdk.index_codebase(
|
|
607
|
+
selection.repo_path, selection.name
|
|
608
|
+
)
|
|
609
|
+
self.notify(
|
|
610
|
+
f"Indexed codebase '{result.name}' (ID: {result.graph_id})",
|
|
611
|
+
severity="information",
|
|
612
|
+
timeout=8,
|
|
613
|
+
)
|
|
614
|
+
except InvalidPathError as exc:
|
|
615
|
+
self.notify(str(exc), severity="error")
|
|
616
|
+
|
|
617
|
+
except Exception as exc: # pragma: no cover - defensive UI path
|
|
618
|
+
self.notify(f"Failed to index codebase: {exc}", severity="error")
|
|
619
|
+
finally:
|
|
620
|
+
label.update("")
|
|
621
|
+
label.refresh()
|
|
622
|
+
self.mount_hint(codebase_indexed_hint(selection.name))
|
|
623
|
+
|
|
467
624
|
@work
|
|
468
625
|
async def run_agent(self, message: str) -> None:
|
|
469
626
|
deferred_tool_results = None
|
|
@@ -495,3 +652,29 @@ class ChatScreen(Screen[None]):
|
|
|
495
652
|
|
|
496
653
|
prompt_input = self.query_one(PromptInput)
|
|
497
654
|
prompt_input.focus()
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def codebase_indexed_hint(codebase_name: str) -> str:
|
|
658
|
+
return (
|
|
659
|
+
f"Codebase **{codebase_name}** indexed successfully. You can now use it in your chat.\n\n"
|
|
660
|
+
+ help_text_with_codebase()
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def help_text_with_codebase() -> str:
|
|
665
|
+
return (
|
|
666
|
+
"I can help with:\n\n"
|
|
667
|
+
"- Speccing out a new feature\n"
|
|
668
|
+
"- Onboarding you onto this project\n"
|
|
669
|
+
"- Helping with a refactor spec\n"
|
|
670
|
+
"- Creating AGENTS.md file for this project\n"
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def help_text_empty_dir() -> str:
|
|
675
|
+
return (
|
|
676
|
+
"What would you like to build? Here are some examples:\n\n"
|
|
677
|
+
"- Research FastAPI vs Django\n"
|
|
678
|
+
"- Plan my new web app using React\n"
|
|
679
|
+
"- Create PRD for my planned product\n"
|
|
680
|
+
)
|