shotgun-sh 0.1.0.dev14__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 +256 -26
- shotgun/agents/models.py +20 -0
- 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 +399 -179
- 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.dev14.dist-info → shotgun_sh-0.1.0.dev16.dist-info}/METADATA +1 -1
- {shotgun_sh-0.1.0.dev14.dist-info → shotgun_sh-0.1.0.dev16.dist-info}/RECORD +25 -21
- {shotgun_sh-0.1.0.dev14.dist-info → shotgun_sh-0.1.0.dev16.dist-info}/WHEEL +0 -0
- {shotgun_sh-0.1.0.dev14.dist-info → shotgun_sh-0.1.0.dev16.dist-info}/entry_points.txt +0 -0
- {shotgun_sh-0.1.0.dev14.dist-info → shotgun_sh-0.1.0.dev16.dist-info}/licenses/LICENSE +0 -0
shotgun/tui/screens/chat.py
CHANGED
|
@@ -1,39 +1,54 @@
|
|
|
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
|
-
from shotgun.agents.models import
|
|
30
|
+
from shotgun.agents.models import (
|
|
31
|
+
AgentDeps,
|
|
32
|
+
FileOperationTracker,
|
|
33
|
+
UserAnswer,
|
|
34
|
+
UserQuestion,
|
|
35
|
+
)
|
|
36
|
+
from shotgun.sdk.codebase import CodebaseSDK
|
|
37
|
+
from shotgun.sdk.exceptions import CodebaseNotFoundError, InvalidPathError
|
|
27
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
|
|
28
41
|
|
|
29
42
|
from ..components.prompt_input import PromptInput
|
|
30
43
|
from ..components.spinner import Spinner
|
|
31
|
-
from
|
|
32
|
-
|
|
44
|
+
from .chat_screen.command_providers import (
|
|
45
|
+
AgentModeProvider,
|
|
46
|
+
CodebaseCommandProvider,
|
|
47
|
+
DeleteCodebasePaletteProvider,
|
|
48
|
+
ProviderSetupProvider,
|
|
49
|
+
)
|
|
33
50
|
|
|
34
|
-
|
|
35
|
-
"""Dummy system prompt function for TUI chat interface."""
|
|
36
|
-
return "You are a helpful AI assistant."
|
|
51
|
+
logger = logging.getLogger(__name__)
|
|
37
52
|
|
|
38
53
|
|
|
39
54
|
class PromptHistory:
|
|
@@ -62,91 +77,19 @@ class PromptHistory:
|
|
|
62
77
|
self.curr = None
|
|
63
78
|
|
|
64
79
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
align: left bottom;
|
|
80
|
+
@dataclass
|
|
81
|
+
class CodebaseIndexSelection:
|
|
82
|
+
"""User-selected repository path and name for indexing."""
|
|
69
83
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
height: auto;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
Horizontal {
|
|
76
|
-
height: auto;
|
|
77
|
-
background: $secondary-muted;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
Markdown {
|
|
81
|
-
height: auto;
|
|
82
|
-
}
|
|
83
|
-
"""
|
|
84
|
-
|
|
85
|
-
def __init__(self) -> None:
|
|
86
|
-
super().__init__()
|
|
87
|
-
self.items: list[ModelMessage] = []
|
|
88
|
-
self.vertical_tail: VerticalTail | None = None
|
|
89
|
-
|
|
90
|
-
def compose(self) -> ComposeResult:
|
|
91
|
-
self.vertical_tail = VerticalTail()
|
|
92
|
-
yield self.vertical_tail
|
|
93
|
-
|
|
94
|
-
def update_messages(self, messages: list[ModelMessage]) -> None:
|
|
95
|
-
"""Update the displayed messages without recomposing."""
|
|
96
|
-
if not self.vertical_tail:
|
|
97
|
-
return
|
|
98
|
-
|
|
99
|
-
# Clear existing widgets
|
|
100
|
-
self.vertical_tail.remove_children()
|
|
101
|
-
|
|
102
|
-
# Add new message widgets
|
|
103
|
-
for item in messages:
|
|
104
|
-
if isinstance(item, ModelRequest):
|
|
105
|
-
self.vertical_tail.mount(UserQuestionWidget(item))
|
|
106
|
-
elif isinstance(item, ModelResponse):
|
|
107
|
-
self.vertical_tail.mount(AgentResponseWidget(item))
|
|
108
|
-
|
|
109
|
-
self.items = messages
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
class UserQuestionWidget(Widget):
|
|
113
|
-
def __init__(self, item: ModelRequest) -> None:
|
|
114
|
-
super().__init__()
|
|
115
|
-
self.item = item
|
|
116
|
-
|
|
117
|
-
def compose(self) -> ComposeResult:
|
|
118
|
-
prompt = "".join(str(part.content) for part in self.item.parts if part.content)
|
|
119
|
-
yield Markdown(markdown=f"**>** {prompt}")
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
class AgentResponseWidget(Widget):
|
|
123
|
-
def __init__(self, item: ModelResponse) -> None:
|
|
124
|
-
super().__init__()
|
|
125
|
-
self.item = item
|
|
126
|
-
|
|
127
|
-
def compose(self) -> ComposeResult:
|
|
128
|
-
yield Markdown(markdown=f"**⏺** {self.compute_output()}")
|
|
129
|
-
|
|
130
|
-
def compute_output(self) -> str:
|
|
131
|
-
acc = ""
|
|
132
|
-
for part in self.item.parts: # TextPart | ToolCallPart | BuiltinToolCallPart | BuiltinToolReturnPart | ThinkingPart
|
|
133
|
-
if isinstance(part, TextPart):
|
|
134
|
-
acc += part.content
|
|
135
|
-
elif isinstance(part, ToolCallPart):
|
|
136
|
-
acc += f"{part.tool_name}({part.args})\n"
|
|
137
|
-
elif isinstance(part, BuiltinToolCallPart):
|
|
138
|
-
acc += f"{part.tool_name}({part.args})\n"
|
|
139
|
-
elif isinstance(part, BuiltinToolReturnPart):
|
|
140
|
-
acc += f"{part.tool_name}()\n"
|
|
141
|
-
elif isinstance(part, ThinkingPart):
|
|
142
|
-
acc += f"Thinking: {part.content}\n"
|
|
143
|
-
return acc
|
|
84
|
+
repo_path: Path
|
|
85
|
+
name: str
|
|
144
86
|
|
|
145
87
|
|
|
146
88
|
class StatusBar(Widget):
|
|
147
89
|
DEFAULT_CSS = """
|
|
148
90
|
StatusBar {
|
|
149
91
|
text-wrap: wrap;
|
|
92
|
+
padding-left: 1;
|
|
150
93
|
}
|
|
151
94
|
"""
|
|
152
95
|
|
|
@@ -160,6 +103,7 @@ class ModeIndicator(Widget):
|
|
|
160
103
|
DEFAULT_CSS = """
|
|
161
104
|
ModeIndicator {
|
|
162
105
|
text-wrap: wrap;
|
|
106
|
+
padding-left: 1;
|
|
163
107
|
}
|
|
164
108
|
"""
|
|
165
109
|
|
|
@@ -178,11 +122,13 @@ class ModeIndicator(Widget):
|
|
|
178
122
|
AgentType.RESEARCH: "Research",
|
|
179
123
|
AgentType.PLAN: "Planning",
|
|
180
124
|
AgentType.TASKS: "Tasks",
|
|
125
|
+
AgentType.SPECIFY: "Specify",
|
|
181
126
|
}
|
|
182
127
|
mode_description = {
|
|
183
128
|
AgentType.RESEARCH: "Research topics with web search and synthesize findings",
|
|
184
129
|
AgentType.PLAN: "Create comprehensive, actionable plans with milestones",
|
|
185
130
|
AgentType.TASKS: "Generate specific, actionable tasks from research and plans",
|
|
131
|
+
AgentType.SPECIFY: "Create detailed specifications and requirements documents",
|
|
186
132
|
}
|
|
187
133
|
|
|
188
134
|
mode_title = mode_display.get(self.mode, self.mode.value.title())
|
|
@@ -191,97 +137,154 @@ class ModeIndicator(Widget):
|
|
|
191
137
|
return f"[bold $text-accent]{mode_title} mode[/][$foreground-muted] ({description})[/]"
|
|
192
138
|
|
|
193
139
|
|
|
194
|
-
class
|
|
195
|
-
|
|
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()]
|
|
196
143
|
|
|
197
|
-
@property
|
|
198
|
-
def chat_screen(self) -> "ChatScreen":
|
|
199
|
-
return cast(ChatScreen, self.screen)
|
|
200
144
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
self.chat_screen.mode = mode
|
|
145
|
+
class CodebaseIndexPromptScreen(ModalScreen[bool]):
|
|
146
|
+
"""Modal dialog asking whether to index the detected codebase."""
|
|
204
147
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
help="🔬 Research topics with web search and synthesize findings",
|
|
211
|
-
)
|
|
212
|
-
yield DiscoveryHit(
|
|
213
|
-
"Switch to Plan Mode",
|
|
214
|
-
lambda: self.set_mode(AgentType.PLAN),
|
|
215
|
-
help="📋 Create comprehensive, actionable plans with milestones",
|
|
216
|
-
)
|
|
217
|
-
yield DiscoveryHit(
|
|
218
|
-
"Switch to Tasks Mode",
|
|
219
|
-
lambda: self.set_mode(AgentType.TASKS),
|
|
220
|
-
help="✅ Generate specific, actionable tasks from research and plans",
|
|
221
|
-
)
|
|
148
|
+
DEFAULT_CSS = """
|
|
149
|
+
CodebaseIndexPromptScreen {
|
|
150
|
+
align: center middle;
|
|
151
|
+
background: rgba(0, 0, 0, 0.0);
|
|
152
|
+
}
|
|
222
153
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
+
}
|
|
226
164
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
),
|
|
234
|
-
(
|
|
235
|
-
"Switch to Plan Mode",
|
|
236
|
-
"📋 Create comprehensive, actionable plans with milestones",
|
|
237
|
-
lambda: self.set_mode(AgentType.PLAN),
|
|
238
|
-
AgentType.PLAN,
|
|
239
|
-
),
|
|
240
|
-
(
|
|
241
|
-
"Switch to Tasks Mode",
|
|
242
|
-
"✅ Generate specific, actionable tasks from research and plans",
|
|
243
|
-
lambda: self.set_mode(AgentType.TASKS),
|
|
244
|
-
AgentType.TASKS,
|
|
245
|
-
),
|
|
246
|
-
]
|
|
165
|
+
#index-prompt-buttons {
|
|
166
|
+
layout: horizontal;
|
|
167
|
+
align-horizontal: right;
|
|
168
|
+
height: auto;
|
|
169
|
+
}
|
|
170
|
+
"""
|
|
247
171
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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")
|
|
254
185
|
|
|
186
|
+
@on(Button.Pressed, "#index-prompt-cancel")
|
|
187
|
+
def handle_cancel(self, event: Button.Pressed) -> None:
|
|
188
|
+
event.stop()
|
|
189
|
+
self.dismiss(False)
|
|
255
190
|
|
|
256
|
-
|
|
257
|
-
|
|
191
|
+
@on(Button.Pressed, "#index-prompt-confirm")
|
|
192
|
+
def handle_confirm(self, event: Button.Pressed) -> None:
|
|
193
|
+
event.stop()
|
|
194
|
+
self.dismiss(True)
|
|
258
195
|
|
|
259
|
-
@property
|
|
260
|
-
def chat_screen(self) -> "ChatScreen":
|
|
261
|
-
return cast(ChatScreen, self.screen)
|
|
262
196
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
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."""
|
|
266
199
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
+
}
|
|
273
221
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
222
|
+
#index-dialog-controls {
|
|
223
|
+
layout: horizontal;
|
|
224
|
+
align-horizontal: right;
|
|
225
|
+
padding-top: 1;
|
|
226
|
+
}
|
|
227
|
+
"""
|
|
228
|
+
|
|
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",
|
|
284
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)
|
|
285
288
|
|
|
286
289
|
|
|
287
290
|
class ChatScreen(Screen[None]):
|
|
@@ -292,7 +295,7 @@ class ChatScreen(Screen[None]):
|
|
|
292
295
|
("shift+tab", "toggle_mode", "Toggle mode"),
|
|
293
296
|
]
|
|
294
297
|
|
|
295
|
-
COMMANDS = {AgentModeProvider, ProviderSetupProvider}
|
|
298
|
+
COMMANDS = {AgentModeProvider, ProviderSetupProvider, CodebaseCommandProvider}
|
|
296
299
|
|
|
297
300
|
_PLACEHOLDER_BY_MODE: dict[AgentType, str] = {
|
|
298
301
|
AgentType.RESEARCH: (
|
|
@@ -304,6 +307,9 @@ class ChatScreen(Screen[None]):
|
|
|
304
307
|
AgentType.TASKS: (
|
|
305
308
|
"Request actionable work, e.g. break down tasks to wire OpenTelemetry into the API"
|
|
306
309
|
),
|
|
310
|
+
AgentType.SPECIFY: (
|
|
311
|
+
"Request detailed specifications, e.g. create a comprehensive spec for user authentication system"
|
|
312
|
+
),
|
|
307
313
|
}
|
|
308
314
|
|
|
309
315
|
value = reactive("")
|
|
@@ -312,6 +318,8 @@ class ChatScreen(Screen[None]):
|
|
|
312
318
|
messages = reactive(list[ModelMessage]())
|
|
313
319
|
working = reactive(False)
|
|
314
320
|
question: reactive[UserQuestion | None] = reactive(None)
|
|
321
|
+
indexing_job: reactive[CodebaseIndexSelection | None] = reactive(None)
|
|
322
|
+
partial_message: reactive[ModelMessage | None] = reactive(None)
|
|
315
323
|
|
|
316
324
|
def __init__(self) -> None:
|
|
317
325
|
super().__init__()
|
|
@@ -319,19 +327,62 @@ class ChatScreen(Screen[None]):
|
|
|
319
327
|
model_config = get_provider_model()
|
|
320
328
|
codebase_service = get_codebase_service()
|
|
321
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
|
+
|
|
322
339
|
self.deps = AgentDeps(
|
|
323
340
|
interactive_mode=True,
|
|
324
341
|
llm_model=model_config,
|
|
325
342
|
codebase_service=codebase_service,
|
|
326
343
|
artifact_service=artifact_service,
|
|
327
|
-
system_prompt_fn=
|
|
344
|
+
system_prompt_fn=_placeholder_system_prompt_fn,
|
|
328
345
|
)
|
|
329
346
|
self.agent_manager = AgentManager(deps=self.deps, initial_type=self.mode)
|
|
347
|
+
self.command_handler = CommandHandler()
|
|
330
348
|
|
|
331
349
|
def on_mount(self) -> None:
|
|
332
350
|
self.query_one(PromptInput).focus(scroll_visible=True)
|
|
333
351
|
# Hide spinner initially
|
|
334
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()
|
|
335
386
|
|
|
336
387
|
def watch_mode(self, new_mode: AgentType) -> None:
|
|
337
388
|
"""React to mode changes by updating the agent manager."""
|
|
@@ -372,7 +423,7 @@ class ChatScreen(Screen[None]):
|
|
|
372
423
|
question_display.display = False
|
|
373
424
|
|
|
374
425
|
def action_toggle_mode(self) -> None:
|
|
375
|
-
modes = [AgentType.RESEARCH, AgentType.PLAN, AgentType.TASKS]
|
|
426
|
+
modes = [AgentType.RESEARCH, AgentType.PLAN, AgentType.TASKS, AgentType.SPECIFY]
|
|
376
427
|
self.mode = modes[(modes.index(self.mode) + 1) % len(modes)]
|
|
377
428
|
self.agent_manager.set_agent(self.mode)
|
|
378
429
|
# whoops it actually changes focus. Let's be brutal for now
|
|
@@ -389,9 +440,9 @@ class ChatScreen(Screen[None]):
|
|
|
389
440
|
def compose(self) -> ComposeResult:
|
|
390
441
|
"""Create child widgets for the app."""
|
|
391
442
|
with Container(id="window"):
|
|
443
|
+
yield self.agent_manager
|
|
392
444
|
yield ChatHistory()
|
|
393
445
|
yield Markdown(markdown="", id="question-display")
|
|
394
|
-
yield self.agent_manager
|
|
395
446
|
with Container(id="footer"):
|
|
396
447
|
yield Spinner(
|
|
397
448
|
text="Processing...",
|
|
@@ -405,20 +456,100 @@ class ChatScreen(Screen[None]):
|
|
|
405
456
|
id="prompt-input",
|
|
406
457
|
placeholder=self._placeholder_for_mode(self.mode),
|
|
407
458
|
)
|
|
408
|
-
|
|
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
|
|
409
477
|
|
|
410
478
|
@on(MessageHistoryUpdated)
|
|
411
479
|
def handle_message_history_updated(self, event: MessageHistoryUpdated) -> None:
|
|
412
480
|
"""Handle message history updates from the agent manager."""
|
|
413
481
|
self.messages = event.messages
|
|
414
482
|
|
|
483
|
+
# If there are file operations, add a message showing the modified files
|
|
484
|
+
if event.file_operations:
|
|
485
|
+
chat_history = self.query_one(ChatHistory)
|
|
486
|
+
if chat_history.vertical_tail:
|
|
487
|
+
tracker = FileOperationTracker(operations=event.file_operations)
|
|
488
|
+
display_path = tracker.get_display_path()
|
|
489
|
+
|
|
490
|
+
if display_path:
|
|
491
|
+
# Create a simple markdown message with the file path
|
|
492
|
+
# The terminal emulator will make this clickable automatically
|
|
493
|
+
from pathlib import Path
|
|
494
|
+
|
|
495
|
+
path_obj = Path(display_path)
|
|
496
|
+
|
|
497
|
+
if len(event.file_operations) == 1:
|
|
498
|
+
message = f"📝 Modified: `{display_path}`"
|
|
499
|
+
else:
|
|
500
|
+
num_files = len({op.file_path for op in event.file_operations})
|
|
501
|
+
if path_obj.is_dir():
|
|
502
|
+
message = (
|
|
503
|
+
f"📁 Modified {num_files} files in: `{display_path}`"
|
|
504
|
+
)
|
|
505
|
+
else:
|
|
506
|
+
# Common path is a file, show parent directory
|
|
507
|
+
message = (
|
|
508
|
+
f"📁 Modified {num_files} files in: `{path_obj.parent}`"
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
# Add this as a simple markdown widget
|
|
512
|
+
file_info_widget = Markdown(message)
|
|
513
|
+
chat_history.vertical_tail.mount(file_info_widget)
|
|
514
|
+
|
|
415
515
|
@on(PromptInput.Submitted)
|
|
416
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
|
|
417
548
|
self.history.append(message.text)
|
|
418
549
|
|
|
419
550
|
# Clear the input
|
|
420
551
|
self.value = ""
|
|
421
|
-
self.run_agent(
|
|
552
|
+
self.run_agent(text) # Use stripped text
|
|
422
553
|
|
|
423
554
|
prompt_input = self.query_one(PromptInput)
|
|
424
555
|
prompt_input.clear()
|
|
@@ -427,6 +558,69 @@ class ChatScreen(Screen[None]):
|
|
|
427
558
|
"""Return the placeholder text appropriate for the current mode."""
|
|
428
559
|
return self._PLACEHOLDER_BY_MODE.get(mode, "Type your message")
|
|
429
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
|
+
|
|
430
624
|
@work
|
|
431
625
|
async def run_agent(self, message: str) -> None:
|
|
432
626
|
deferred_tool_results = None
|
|
@@ -458,3 +652,29 @@ class ChatScreen(Screen[None]):
|
|
|
458
652
|
|
|
459
653
|
prompt_input = self.query_one(PromptInput)
|
|
460
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
|
+
)
|