shotgun-sh 0.1.0.dev15__py3-none-any.whl → 0.1.0.dev17__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.

@@ -1,27 +1,31 @@
1
- from collections.abc import AsyncGenerator
2
- from typing import cast
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
- ThinkingPart,
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 DiscoveryHit, Hit, Provider
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 AgentManager, AgentType, MessageHistoryUpdated
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 ..components.vertical_tail import VerticalTail
37
-
44
+ from .chat_screen.command_providers import (
45
+ AgentModeProvider,
46
+ CodebaseCommandProvider,
47
+ DeleteCodebasePaletteProvider,
48
+ ProviderSetupProvider,
49
+ )
38
50
 
39
- def _dummy_system_prompt_fn(ctx: RunContext[AgentDeps]) -> str:
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
- class ChatHistory(Widget):
71
- DEFAULT_CSS = """
72
- VerticalTail {
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
- self.items = messages
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 AgentModeProvider(Provider):
200
- """Command provider for agent mode switching."""
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
- def set_mode(self, mode: AgentType) -> None:
207
- """Switch to research mode."""
208
- self.chat_screen.mode = mode
145
+ class CodebaseIndexPromptScreen(ModalScreen[bool]):
146
+ """Modal dialog asking whether to index the detected codebase."""
209
147
 
210
- async def discover(self) -> AsyncGenerator[DiscoveryHit, None]:
211
- """Provide default mode switching commands when palette opens."""
212
- yield DiscoveryHit(
213
- "Switch to Research Mode",
214
- lambda: self.set_mode(AgentType.RESEARCH),
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
- async def search(self, query: str) -> AsyncGenerator[Hit, None]:
229
- """Search for mode commands."""
230
- matcher = self.matcher(query)
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
- commands = [
233
- (
234
- "Switch to Research Mode",
235
- "🔬 Research topics with web search and synthesize findings",
236
- lambda: self.set_mode(AgentType.RESEARCH),
237
- AgentType.RESEARCH,
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
- for title, help_text, callback, mode in commands:
254
- if self.chat_screen.mode == mode:
255
- continue
256
- score = matcher.match(title)
257
- if score > 0:
258
- yield Hit(score, matcher.highlight(title), callback, help=help_text)
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
- class ProviderSetupProvider(Provider):
262
- """Command palette entries for provider configuration."""
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
- def open_provider_config(self) -> None:
269
- """Show the provider configuration screen."""
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
- async def discover(self) -> AsyncGenerator[DiscoveryHit, None]:
273
- yield DiscoveryHit(
274
- "Open Provider Setup",
275
- self.open_provider_config,
276
- help="⚙️ Manage API keys for available providers",
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
- async def search(self, query: str) -> AsyncGenerator[Hit, None]:
280
- matcher = self.matcher(query)
281
- title = "Open Provider Setup"
282
- score = matcher.match(title)
283
- if score > 0:
284
- yield Hit(
285
- score,
286
- matcher.highlight(title),
287
- self.open_provider_config,
288
- help="⚙️ Manage API keys for available providers",
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=_dummy_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.SPECIFY, AgentType.PLAN, AgentType.TASKS]
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
- yield ModeIndicator(mode=self.mode)
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(message.text)
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
+ )