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.

@@ -1,39 +1,54 @@
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
- from shotgun.agents.models import AgentDeps, UserAnswer, UserQuestion
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 ..components.vertical_tail import VerticalTail
32
-
44
+ from .chat_screen.command_providers import (
45
+ AgentModeProvider,
46
+ CodebaseCommandProvider,
47
+ DeleteCodebasePaletteProvider,
48
+ ProviderSetupProvider,
49
+ )
33
50
 
34
- def _dummy_system_prompt_fn(ctx: RunContext[AgentDeps]) -> str:
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
- class ChatHistory(Widget):
66
- DEFAULT_CSS = """
67
- VerticalTail {
68
- align: left bottom;
80
+ @dataclass
81
+ class CodebaseIndexSelection:
82
+ """User-selected repository path and name for indexing."""
69
83
 
70
- }
71
- VerticalTail > * {
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 AgentModeProvider(Provider):
195
- """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()]
196
143
 
197
- @property
198
- def chat_screen(self) -> "ChatScreen":
199
- return cast(ChatScreen, self.screen)
200
144
 
201
- def set_mode(self, mode: AgentType) -> None:
202
- """Switch to research mode."""
203
- self.chat_screen.mode = mode
145
+ class CodebaseIndexPromptScreen(ModalScreen[bool]):
146
+ """Modal dialog asking whether to index the detected codebase."""
204
147
 
205
- async def discover(self) -> AsyncGenerator[DiscoveryHit, None]:
206
- """Provide default mode switching commands when palette opens."""
207
- yield DiscoveryHit(
208
- "Switch to Research Mode",
209
- lambda: self.set_mode(AgentType.RESEARCH),
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
- async def search(self, query: str) -> AsyncGenerator[Hit, None]:
224
- """Search for mode commands."""
225
- 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
+ }
226
164
 
227
- commands = [
228
- (
229
- "Switch to Research Mode",
230
- "🔬 Research topics with web search and synthesize findings",
231
- lambda: self.set_mode(AgentType.RESEARCH),
232
- AgentType.RESEARCH,
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
- for title, help_text, callback, mode in commands:
249
- if self.chat_screen.mode == mode:
250
- continue
251
- score = matcher.match(title)
252
- if score > 0:
253
- 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")
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
- class ProviderSetupProvider(Provider):
257
- """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)
258
195
 
259
- @property
260
- def chat_screen(self) -> "ChatScreen":
261
- return cast(ChatScreen, self.screen)
262
196
 
263
- def open_provider_config(self) -> None:
264
- """Show the provider configuration screen."""
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
- async def discover(self) -> AsyncGenerator[DiscoveryHit, None]:
268
- yield DiscoveryHit(
269
- "Open Provider Setup",
270
- self.open_provider_config,
271
- help="⚙️ Manage API keys for available providers",
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
- async def search(self, query: str) -> AsyncGenerator[Hit, None]:
275
- matcher = self.matcher(query)
276
- title = "Open Provider Setup"
277
- score = matcher.match(title)
278
- if score > 0:
279
- yield Hit(
280
- score,
281
- matcher.highlight(title),
282
- self.open_provider_config,
283
- help="⚙️ Manage API keys for available providers",
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=_dummy_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
- 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
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(message.text)
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
+ )