shotgun-sh 0.3.3.dev1__py3-none-any.whl → 0.6.2__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.
Files changed (159) hide show
  1. shotgun/agents/agent_manager.py +497 -30
  2. shotgun/agents/cancellation.py +103 -0
  3. shotgun/agents/common.py +90 -77
  4. shotgun/agents/config/README.md +0 -1
  5. shotgun/agents/config/manager.py +52 -8
  6. shotgun/agents/config/models.py +21 -27
  7. shotgun/agents/config/provider.py +44 -27
  8. shotgun/agents/conversation/history/file_content_deduplication.py +66 -43
  9. shotgun/agents/conversation/history/token_counting/base.py +51 -9
  10. shotgun/agents/export.py +12 -13
  11. shotgun/agents/file_read.py +176 -0
  12. shotgun/agents/messages.py +15 -3
  13. shotgun/agents/models.py +90 -2
  14. shotgun/agents/plan.py +12 -13
  15. shotgun/agents/research.py +13 -10
  16. shotgun/agents/router/__init__.py +47 -0
  17. shotgun/agents/router/models.py +384 -0
  18. shotgun/agents/router/router.py +185 -0
  19. shotgun/agents/router/tools/__init__.py +18 -0
  20. shotgun/agents/router/tools/delegation_tools.py +557 -0
  21. shotgun/agents/router/tools/plan_tools.py +403 -0
  22. shotgun/agents/runner.py +17 -2
  23. shotgun/agents/specify.py +12 -13
  24. shotgun/agents/tasks.py +12 -13
  25. shotgun/agents/tools/__init__.py +8 -0
  26. shotgun/agents/tools/codebase/directory_lister.py +27 -39
  27. shotgun/agents/tools/codebase/file_read.py +26 -35
  28. shotgun/agents/tools/codebase/query_graph.py +9 -0
  29. shotgun/agents/tools/codebase/retrieve_code.py +9 -0
  30. shotgun/agents/tools/file_management.py +81 -3
  31. shotgun/agents/tools/file_read_tools/__init__.py +7 -0
  32. shotgun/agents/tools/file_read_tools/multimodal_file_read.py +167 -0
  33. shotgun/agents/tools/markdown_tools/__init__.py +62 -0
  34. shotgun/agents/tools/markdown_tools/insert_section.py +148 -0
  35. shotgun/agents/tools/markdown_tools/models.py +86 -0
  36. shotgun/agents/tools/markdown_tools/remove_section.py +114 -0
  37. shotgun/agents/tools/markdown_tools/replace_section.py +119 -0
  38. shotgun/agents/tools/markdown_tools/utils.py +453 -0
  39. shotgun/agents/tools/registry.py +46 -6
  40. shotgun/agents/tools/web_search/__init__.py +1 -2
  41. shotgun/agents/tools/web_search/gemini.py +1 -3
  42. shotgun/agents/tools/web_search/openai.py +42 -23
  43. shotgun/attachments/__init__.py +41 -0
  44. shotgun/attachments/errors.py +60 -0
  45. shotgun/attachments/models.py +107 -0
  46. shotgun/attachments/parser.py +257 -0
  47. shotgun/attachments/processor.py +193 -0
  48. shotgun/build_constants.py +4 -7
  49. shotgun/cli/clear.py +2 -2
  50. shotgun/cli/codebase/commands.py +181 -65
  51. shotgun/cli/compact.py +2 -2
  52. shotgun/cli/context.py +2 -2
  53. shotgun/cli/error_handler.py +2 -2
  54. shotgun/cli/run.py +90 -0
  55. shotgun/cli/spec/backup.py +2 -1
  56. shotgun/codebase/__init__.py +2 -0
  57. shotgun/codebase/benchmarks/__init__.py +35 -0
  58. shotgun/codebase/benchmarks/benchmark_runner.py +309 -0
  59. shotgun/codebase/benchmarks/exporters.py +119 -0
  60. shotgun/codebase/benchmarks/formatters/__init__.py +49 -0
  61. shotgun/codebase/benchmarks/formatters/base.py +34 -0
  62. shotgun/codebase/benchmarks/formatters/json_formatter.py +106 -0
  63. shotgun/codebase/benchmarks/formatters/markdown.py +136 -0
  64. shotgun/codebase/benchmarks/models.py +129 -0
  65. shotgun/codebase/core/__init__.py +4 -0
  66. shotgun/codebase/core/call_resolution.py +91 -0
  67. shotgun/codebase/core/change_detector.py +11 -6
  68. shotgun/codebase/core/errors.py +159 -0
  69. shotgun/codebase/core/extractors/__init__.py +23 -0
  70. shotgun/codebase/core/extractors/base.py +138 -0
  71. shotgun/codebase/core/extractors/factory.py +63 -0
  72. shotgun/codebase/core/extractors/go/__init__.py +7 -0
  73. shotgun/codebase/core/extractors/go/extractor.py +122 -0
  74. shotgun/codebase/core/extractors/javascript/__init__.py +7 -0
  75. shotgun/codebase/core/extractors/javascript/extractor.py +132 -0
  76. shotgun/codebase/core/extractors/protocol.py +109 -0
  77. shotgun/codebase/core/extractors/python/__init__.py +7 -0
  78. shotgun/codebase/core/extractors/python/extractor.py +141 -0
  79. shotgun/codebase/core/extractors/rust/__init__.py +7 -0
  80. shotgun/codebase/core/extractors/rust/extractor.py +139 -0
  81. shotgun/codebase/core/extractors/types.py +15 -0
  82. shotgun/codebase/core/extractors/typescript/__init__.py +7 -0
  83. shotgun/codebase/core/extractors/typescript/extractor.py +92 -0
  84. shotgun/codebase/core/gitignore.py +252 -0
  85. shotgun/codebase/core/ingestor.py +644 -354
  86. shotgun/codebase/core/kuzu_compat.py +119 -0
  87. shotgun/codebase/core/language_config.py +239 -0
  88. shotgun/codebase/core/manager.py +256 -46
  89. shotgun/codebase/core/metrics_collector.py +310 -0
  90. shotgun/codebase/core/metrics_types.py +347 -0
  91. shotgun/codebase/core/parallel_executor.py +424 -0
  92. shotgun/codebase/core/work_distributor.py +254 -0
  93. shotgun/codebase/core/worker.py +768 -0
  94. shotgun/codebase/indexing_state.py +86 -0
  95. shotgun/codebase/models.py +94 -0
  96. shotgun/codebase/service.py +13 -0
  97. shotgun/exceptions.py +9 -9
  98. shotgun/main.py +3 -16
  99. shotgun/posthog_telemetry.py +165 -24
  100. shotgun/prompts/agents/export.j2 +2 -0
  101. shotgun/prompts/agents/file_read.j2 +48 -0
  102. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +19 -52
  103. shotgun/prompts/agents/partials/content_formatting.j2 +12 -33
  104. shotgun/prompts/agents/partials/interactive_mode.j2 +9 -32
  105. shotgun/prompts/agents/partials/router_delegation_mode.j2 +35 -0
  106. shotgun/prompts/agents/plan.j2 +38 -12
  107. shotgun/prompts/agents/research.j2 +70 -31
  108. shotgun/prompts/agents/router.j2 +713 -0
  109. shotgun/prompts/agents/specify.j2 +53 -16
  110. shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2 +14 -1
  111. shotgun/prompts/agents/state/system_state.j2 +24 -13
  112. shotgun/prompts/agents/tasks.j2 +72 -34
  113. shotgun/settings.py +49 -10
  114. shotgun/tui/app.py +154 -24
  115. shotgun/tui/commands/__init__.py +9 -1
  116. shotgun/tui/components/attachment_bar.py +87 -0
  117. shotgun/tui/components/mode_indicator.py +120 -25
  118. shotgun/tui/components/prompt_input.py +25 -28
  119. shotgun/tui/components/status_bar.py +14 -7
  120. shotgun/tui/dependencies.py +58 -8
  121. shotgun/tui/protocols.py +55 -0
  122. shotgun/tui/screens/chat/chat.tcss +24 -1
  123. shotgun/tui/screens/chat/chat_screen.py +1376 -213
  124. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +8 -4
  125. shotgun/tui/screens/chat_screen/attachment_hint.py +40 -0
  126. shotgun/tui/screens/chat_screen/command_providers.py +0 -97
  127. shotgun/tui/screens/chat_screen/history/agent_response.py +7 -3
  128. shotgun/tui/screens/chat_screen/history/chat_history.py +58 -6
  129. shotgun/tui/screens/chat_screen/history/formatters.py +75 -15
  130. shotgun/tui/screens/chat_screen/history/partial_response.py +11 -1
  131. shotgun/tui/screens/chat_screen/history/user_question.py +25 -3
  132. shotgun/tui/screens/chat_screen/messages.py +219 -0
  133. shotgun/tui/screens/database_locked_dialog.py +219 -0
  134. shotgun/tui/screens/database_timeout_dialog.py +158 -0
  135. shotgun/tui/screens/kuzu_error_dialog.py +135 -0
  136. shotgun/tui/screens/model_picker.py +1 -3
  137. shotgun/tui/screens/models.py +11 -0
  138. shotgun/tui/state/processing_state.py +19 -0
  139. shotgun/tui/utils/mode_progress.py +20 -86
  140. shotgun/tui/widgets/__init__.py +2 -1
  141. shotgun/tui/widgets/approval_widget.py +152 -0
  142. shotgun/tui/widgets/cascade_confirmation_widget.py +203 -0
  143. shotgun/tui/widgets/plan_panel.py +129 -0
  144. shotgun/tui/widgets/step_checkpoint_widget.py +180 -0
  145. shotgun/tui/widgets/widget_coordinator.py +18 -0
  146. shotgun/utils/file_system_utils.py +4 -1
  147. {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/METADATA +88 -35
  148. shotgun_sh-0.6.2.dist-info/RECORD +291 -0
  149. shotgun/cli/export.py +0 -81
  150. shotgun/cli/plan.py +0 -73
  151. shotgun/cli/research.py +0 -93
  152. shotgun/cli/specify.py +0 -70
  153. shotgun/cli/tasks.py +0 -78
  154. shotgun/sentry_telemetry.py +0 -232
  155. shotgun/tui/screens/onboarding.py +0 -580
  156. shotgun_sh-0.3.3.dev1.dist-info/RECORD +0 -229
  157. {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/WHEEL +0 -0
  158. {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/entry_points.txt +0 -0
  159. {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/licenses/LICENSE +0 -0
shotgun/tui/app.py CHANGED
@@ -1,5 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import platform
5
+ import sys
1
6
  from collections.abc import Iterable
2
- from typing import Any
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ if TYPE_CHECKING:
10
+ from shotgun.codebase.core.errors import DatabaseIssue
3
11
 
4
12
  from textual.app import App, SystemCommand
5
13
  from textual.binding import Binding
@@ -13,6 +21,7 @@ from shotgun.agents.config import (
13
21
  from shotgun.agents.models import AgentType
14
22
  from shotgun.logging_config import get_logger
15
23
  from shotgun.tui.containers import TUIContainer
24
+ from shotgun.tui.dependencies import create_default_router_deps
16
25
  from shotgun.tui.screens.splash import SplashScreen
17
26
  from shotgun.utils.file_system_utils import (
18
27
  ensure_shotgun_directory_exists,
@@ -44,7 +53,10 @@ class ShotgunApp(App[None]):
44
53
  "github_issue": GitHubIssueScreen,
45
54
  }
46
55
  BINDINGS = [
47
- Binding("ctrl+c", "quit", "Quit the app"),
56
+ # Use smart_quit to support ctrl+c for copying selected text
57
+ Binding("ctrl+c", "smart_quit", "Quit/Copy", show=False),
58
+ # Cancel quit confirmation with ESC
59
+ Binding("escape", "cancel_quit", "Cancel Quit", show=False),
48
60
  ]
49
61
 
50
62
  CSS_PATH = "styles.tcss"
@@ -56,6 +68,7 @@ class ShotgunApp(App[None]):
56
68
  force_reindex: bool = False,
57
69
  show_pull_hint: bool = False,
58
70
  pull_version_id: str | None = None,
71
+ pending_db_issues: list[DatabaseIssue] | None = None,
59
72
  ) -> None:
60
73
  super().__init__()
61
74
  self.config_manager: ConfigManager = get_config_manager()
@@ -64,6 +77,12 @@ class ShotgunApp(App[None]):
64
77
  self.force_reindex = force_reindex
65
78
  self.show_pull_hint = show_pull_hint
66
79
  self.pull_version_id = pull_version_id
80
+ # Database issues detected at startup (locked, corrupted, timeout)
81
+ # These will be shown to the user via dialogs when ChatScreen mounts
82
+ self.pending_db_issues = pending_db_issues or []
83
+
84
+ # Quit confirmation state for double Ctrl+C to quit
85
+ self._quit_pending = False
67
86
 
68
87
  # Initialize dependency injection container
69
88
  self.container = TUIContainer()
@@ -166,13 +185,11 @@ class ShotgunApp(App[None]):
166
185
  return
167
186
 
168
187
  # Create ChatScreen with all dependencies injected from container
169
- # Get the default agent mode (RESEARCH)
170
- agent_mode = AgentType.RESEARCH
171
-
172
- # Create AgentDeps asynchronously (get_provider_model is now async)
173
- from shotgun.tui.dependencies import create_default_tui_deps
188
+ # Get the default agent mode (ROUTER)
189
+ agent_mode = AgentType.ROUTER
174
190
 
175
- agent_deps = await create_default_tui_deps()
191
+ # Create RouterDeps asynchronously (get_provider_model is now async)
192
+ agent_deps = await create_default_router_deps()
176
193
 
177
194
  # Create AgentManager with async initialization
178
195
  agent_manager = AgentManager(deps=agent_deps, initial_type=agent_mode)
@@ -228,6 +245,63 @@ class ShotgunApp(App[None]):
228
245
  # Continue to ChatScreen
229
246
  self.refresh_startup_screen()
230
247
 
248
+ @property
249
+ def quit_pending(self) -> bool:
250
+ """Whether a quit confirmation is pending.
251
+
252
+ Returns True if user pressed Ctrl+C and needs to press again or ESC to cancel.
253
+ """
254
+ return self._quit_pending
255
+
256
+ def _reset_quit_pending(self) -> None:
257
+ """Reset the quit confirmation state and refresh the status bar."""
258
+ self._quit_pending = False
259
+ self._refresh_status_bar()
260
+
261
+ def _refresh_status_bar(self) -> None:
262
+ """Refresh the StatusBar widget to reflect current state."""
263
+ from textual.css.query import NoMatches
264
+
265
+ from shotgun.tui.components.status_bar import StatusBar
266
+
267
+ try:
268
+ status_bar = self.screen.query_one(StatusBar)
269
+ status_bar.refresh()
270
+ except NoMatches:
271
+ # StatusBar might not exist on all screens
272
+ pass
273
+
274
+ def action_cancel_quit(self) -> None:
275
+ """Cancel the quit confirmation when ESC is pressed."""
276
+ if self._quit_pending:
277
+ self._reset_quit_pending()
278
+
279
+ async def action_smart_quit(self) -> None:
280
+ """Handle ctrl+c: copy selected text if any, otherwise quit.
281
+
282
+ This allows users to select text in the TUI and copy it with ctrl+c,
283
+ while still supporting ctrl+c to quit when no text is selected.
284
+ Requires pressing Ctrl+C twice to quit, or ESC to cancel.
285
+ """
286
+ # Check if there's selected text on the current screen
287
+ selected_text = self.screen.get_selected_text()
288
+ if selected_text:
289
+ # Copy selected text to clipboard
290
+ self.copy_to_clipboard(selected_text)
291
+ # Clear the selection after copying
292
+ self.screen.clear_selection()
293
+ self.notify("Copied to clipboard", timeout=2)
294
+ return
295
+
296
+ # No selection - check if quit is already pending
297
+ if self._quit_pending:
298
+ await self.action_quit()
299
+ return
300
+
301
+ # Start quit confirmation
302
+ self._quit_pending = True
303
+ self._refresh_status_bar()
304
+
231
305
  async def action_quit(self) -> None:
232
306
  """Quit the application."""
233
307
  # Shut down PostHog client to prevent threading errors
@@ -250,6 +324,22 @@ class ShotgunApp(App[None]):
250
324
  self.push_screen(GitHubIssueScreen())
251
325
 
252
326
 
327
+ def _log_startup_info() -> None:
328
+ """Log startup information for debugging purposes."""
329
+ # Import here to avoid circular import (shotgun.__init__ imports from submodules)
330
+ from shotgun import __version__
331
+
332
+ logger.info("=" * 60)
333
+ logger.info("Shotgun TUI Starting")
334
+ logger.info("=" * 60)
335
+ logger.info(f" Version: {__version__}")
336
+ logger.info(f" Python: {sys.version.split()[0]}")
337
+ logger.info(f" Platform: {platform.system()} {platform.release()}")
338
+ logger.info(f" Architecture: {platform.machine()}")
339
+ logger.info(f" Working Directory: {os.getcwd()}")
340
+ logger.info("=" * 60)
341
+
342
+
253
343
  def run(
254
344
  no_update_check: bool = False,
255
345
  continue_session: bool = False,
@@ -266,24 +356,54 @@ def run(
266
356
  show_pull_hint: If True, show hint about recently pulled spec.
267
357
  pull_version_id: If provided, pull this spec version before showing ChatScreen.
268
358
  """
269
- # Clean up any corrupted databases BEFORE starting the TUI
270
- # This prevents crashes from corrupted databases during initialization
359
+ # Log startup information
360
+ _log_startup_info()
361
+
362
+ # Detect database issues BEFORE starting the TUI (but don't auto-delete)
363
+ # Issues will be presented to the user via dialogs once the TUI is running
271
364
  import asyncio
272
365
 
366
+ from shotgun.codebase.core.errors import KuzuErrorType
273
367
  from shotgun.codebase.core.manager import CodebaseGraphManager
274
368
  from shotgun.utils import get_shotgun_home
275
369
 
276
370
  storage_dir = get_shotgun_home() / "codebases"
277
371
  manager = CodebaseGraphManager(storage_dir)
278
372
 
373
+ pending_db_issues: list[DatabaseIssue] = []
279
374
  try:
280
- removed = asyncio.run(manager.cleanup_corrupted_databases())
281
- if removed:
282
- logger.info(
283
- f"Cleaned up {len(removed)} corrupted database(s) before TUI startup"
284
- )
375
+ # First pass: 10-second timeout
376
+ issues = asyncio.run(manager.detect_database_issues(timeout_seconds=10.0))
377
+ if issues:
378
+ # Categorize issues for logging
379
+ for issue in issues:
380
+ logger.info(
381
+ f"Detected database issue: {issue.graph_id} - "
382
+ f"{issue.error_type.value}: {issue.message}"
383
+ )
384
+
385
+ # Only pass issues that require user interaction to the TUI
386
+ # Schema issues (incomplete builds) can be auto-cleaned silently
387
+ user_facing_issues = [
388
+ i
389
+ for i in issues
390
+ if i.error_type
391
+ in (
392
+ KuzuErrorType.LOCKED,
393
+ KuzuErrorType.CORRUPTION,
394
+ KuzuErrorType.TIMEOUT,
395
+ )
396
+ ]
397
+
398
+ # Auto-delete schema issues (incomplete builds) - safe to remove
399
+ schema_issues = [i for i in issues if i.error_type == KuzuErrorType.SCHEMA]
400
+ for issue in schema_issues:
401
+ asyncio.run(manager.delete_database(issue.graph_id))
402
+ logger.info(f"Auto-removed incomplete database: {issue.graph_id}")
403
+
404
+ pending_db_issues = user_facing_issues
285
405
  except Exception as e:
286
- logger.error(f"Failed to cleanup corrupted databases: {e}")
406
+ logger.error(f"Failed to detect database issues: {e}")
287
407
  # Continue anyway - the TUI can still function
288
408
 
289
409
  app = ShotgunApp(
@@ -292,6 +412,7 @@ def run(
292
412
  force_reindex=force_reindex,
293
413
  show_pull_hint=show_pull_hint,
294
414
  pull_version_id=pull_version_id,
415
+ pending_db_issues=pending_db_issues,
295
416
  )
296
417
  app.run(inline_no_clear=True)
297
418
 
@@ -314,12 +435,14 @@ def serve(
314
435
  continue_session: If True, continue from previous conversation.
315
436
  force_reindex: If True, force re-indexing of codebase (ignores existing index).
316
437
  """
317
- # Clean up any corrupted databases BEFORE starting the TUI
318
- # This prevents crashes from corrupted databases during initialization
438
+ # Detect database issues BEFORE starting the TUI
439
+ # Note: In serve mode, issues are logged but user interaction happens in
440
+ # the spawned process via run()
319
441
  import asyncio
320
442
 
321
443
  from textual_serve.server import Server
322
444
 
445
+ from shotgun.codebase.core.errors import KuzuErrorType
323
446
  from shotgun.codebase.core.manager import CodebaseGraphManager
324
447
  from shotgun.utils import get_shotgun_home
325
448
 
@@ -327,13 +450,20 @@ def serve(
327
450
  manager = CodebaseGraphManager(storage_dir)
328
451
 
329
452
  try:
330
- removed = asyncio.run(manager.cleanup_corrupted_databases())
331
- if removed:
332
- logger.info(
333
- f"Cleaned up {len(removed)} corrupted database(s) before TUI startup"
334
- )
453
+ issues = asyncio.run(manager.detect_database_issues(timeout_seconds=10.0))
454
+ if issues:
455
+ for issue in issues:
456
+ logger.info(
457
+ f"Detected database issue: {issue.graph_id} - "
458
+ f"{issue.error_type.value}: {issue.message}"
459
+ )
460
+ # Auto-delete only schema issues (incomplete builds)
461
+ schema_issues = [i for i in issues if i.error_type == KuzuErrorType.SCHEMA]
462
+ for issue in schema_issues:
463
+ asyncio.run(manager.delete_database(issue.graph_id))
464
+ logger.info(f"Auto-removed incomplete database: {issue.graph_id}")
335
465
  except Exception as e:
336
- logger.error(f"Failed to cleanup corrupted databases: {e}")
466
+ logger.error(f"Failed to detect database issues: {e}")
337
467
  # Continue anyway - the TUI can still function
338
468
 
339
469
  # Create a new event loop after asyncio.run() closes the previous one
@@ -54,10 +54,18 @@ class CommandHandler:
54
54
  **Commands:**
55
55
  • `/help` - Show this help message
56
56
 
57
+ **Shell Commands:**
58
+ • `!<command>` - Execute shell commands directly (e.g., `!ls`, `!git status`)
59
+ - Commands run in your current working directory
60
+ - Output is displayed in the chat (not sent to AI)
61
+ - Commands are NOT added to conversation history
62
+ - Leading whitespace is allowed: ` !echo hi` works
63
+ - Note: `!!` is treated as `!` (no history expansion in this version)
64
+
57
65
  **Keyboard Shortcuts:**
58
66
 
59
67
  * `Enter` - Send message
60
- * `Ctrl+P` - Open command palette (for usage, context, and other commands)
68
+ * `/` - Open command palette (for usage, context, and other commands)
61
69
  * `Shift+Tab` - Cycle agent modes
62
70
  * `Ctrl+C` - Quit application
63
71
 
@@ -0,0 +1,87 @@
1
+ """Attachment bar widget for showing pending file attachment."""
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.css.query import NoMatches
5
+ from textual.reactive import reactive
6
+ from textual.widget import Widget
7
+ from textual.widgets import Static
8
+
9
+ from shotgun.attachments import (
10
+ AttachmentBarState,
11
+ FileAttachment,
12
+ format_file_size,
13
+ get_attachment_icon,
14
+ )
15
+
16
+
17
+ class AttachmentBar(Widget):
18
+ """Widget showing pending attachment above input.
19
+
20
+ Displays format: [icon filename.ext (size)]
21
+ Hidden when no attachment is pending.
22
+
23
+ Styles defined in chat.tcss.
24
+ """
25
+
26
+ state: reactive[AttachmentBarState] = reactive(AttachmentBarState, init=False)
27
+
28
+ def __init__(
29
+ self,
30
+ *,
31
+ name: str | None = None,
32
+ id: str | None = None,
33
+ classes: str | None = None,
34
+ ) -> None:
35
+ """Initialize the attachment bar.
36
+
37
+ Args:
38
+ name: Optional widget name.
39
+ id: Optional widget ID.
40
+ classes: Optional CSS classes.
41
+ """
42
+ super().__init__(name=name, id=id, classes=classes)
43
+ self.state = AttachmentBarState(attachment=None)
44
+ self.add_class("hidden")
45
+
46
+ def compose(self) -> ComposeResult:
47
+ """Compose the attachment bar widget."""
48
+ yield Static("", id="attachment-display")
49
+
50
+ def update_attachment(self, attachment: FileAttachment | None) -> None:
51
+ """Update the displayed attachment.
52
+
53
+ Args:
54
+ attachment: FileAttachment to display, or None to hide bar.
55
+ """
56
+ self.state = AttachmentBarState(attachment=attachment)
57
+
58
+ if attachment is None:
59
+ self.add_class("hidden")
60
+ else:
61
+ self.remove_class("hidden")
62
+ self._refresh_display()
63
+
64
+ def _refresh_display(self) -> None:
65
+ """Refresh the attachment display text."""
66
+ attachment = self.state.attachment
67
+ if attachment is None:
68
+ return
69
+
70
+ icon = get_attachment_icon(attachment.file_type)
71
+ size_str = format_file_size(attachment.file_size_bytes)
72
+ display_text = f"[{icon} {attachment.file_name} ({size_str})]"
73
+
74
+ try:
75
+ display_widget = self.query_one("#attachment-display", Static)
76
+ display_widget.update(display_text)
77
+ except NoMatches:
78
+ pass # Widget not mounted yet
79
+
80
+ def watch_state(self, new_state: AttachmentBarState) -> None:
81
+ """React to state changes.
82
+
83
+ Args:
84
+ new_state: The new attachment bar state.
85
+ """
86
+ if new_state.attachment is not None:
87
+ self._refresh_display()
@@ -1,20 +1,68 @@
1
1
  """Widget to display the current agent mode."""
2
2
 
3
+ from enum import StrEnum
4
+
3
5
  from textual.widget import Widget
4
6
 
5
7
  from shotgun.agents.models import AgentType
6
- from shotgun.tui.protocols import QAStateProvider
8
+ from shotgun.agents.router.models import RouterMode
9
+ from shotgun.tui.protocols import (
10
+ ActiveSubAgentProvider,
11
+ QAStateProvider,
12
+ RouterModeProvider,
13
+ )
7
14
  from shotgun.tui.utils.mode_progress import PlaceholderHints
8
15
 
9
16
 
17
+ class RouterModeCssClass(StrEnum):
18
+ """CSS class names for router mode styling."""
19
+
20
+ PLANNING = "mode-planning"
21
+ DRAFTING = "mode-drafting"
22
+
23
+
24
+ # Shared display name mapping for agent types
25
+ AGENT_DISPLAY_NAMES: dict[AgentType, str] = {
26
+ AgentType.RESEARCH: "Research",
27
+ AgentType.SPECIFY: "Specify",
28
+ AgentType.PLAN: "Planning",
29
+ AgentType.TASKS: "Tasks",
30
+ AgentType.EXPORT: "Export",
31
+ }
32
+
33
+ # Mode descriptions for legacy agent display
34
+ AGENT_DESCRIPTIONS: dict[AgentType, str] = {
35
+ AgentType.RESEARCH: "Research topics with web search and synthesize findings",
36
+ AgentType.PLAN: "Create comprehensive, actionable plans with milestones",
37
+ AgentType.TASKS: "Generate specific, actionable tasks from research and plans",
38
+ AgentType.SPECIFY: "Create detailed specifications and requirements documents",
39
+ AgentType.EXPORT: "Export artifacts and findings to various formats",
40
+ }
41
+
42
+
10
43
  class ModeIndicator(Widget):
11
- """Widget to display the current agent mode."""
44
+ """Widget to display the current agent mode.
45
+
46
+ For router mode, displays:
47
+ - Idle: "📋 Planning mode" or "✍️ Drafting mode"
48
+ - During execution: "📋 Planning → Research" format
49
+
50
+ For legacy agents, displays the agent name and description.
51
+ """
12
52
 
13
53
  DEFAULT_CSS = """
14
54
  ModeIndicator {
15
55
  text-wrap: wrap;
16
56
  padding-left: 1;
17
57
  }
58
+
59
+ ModeIndicator.mode-planning {
60
+ /* Planning mode styling - blue/cyan accent */
61
+ }
62
+
63
+ ModeIndicator.mode-drafting {
64
+ /* Drafting mode styling - green accent */
65
+ }
18
66
  """
19
67
 
20
68
  def __init__(self, mode: AgentType) -> None:
@@ -29,41 +77,88 @@ class ModeIndicator(Widget):
29
77
 
30
78
  def render(self) -> str:
31
79
  """Render the mode indicator."""
32
- # Check if in Q&A mode first
80
+ # Check if in Q&A mode first - takes priority
33
81
  if isinstance(self.screen, QAStateProvider) and self.screen.qa_mode:
34
82
  return (
35
83
  "[bold $text-accent]Q&A mode[/]"
36
84
  "[$foreground-muted] (Answer the clarifying questions or ESC to cancel)[/]"
37
85
  )
38
86
 
39
- mode_display = {
40
- AgentType.RESEARCH: "Research",
41
- AgentType.PLAN: "Planning",
42
- AgentType.TASKS: "Tasks",
43
- AgentType.SPECIFY: "Specify",
44
- AgentType.EXPORT: "Export",
45
- }
46
- mode_description = {
47
- AgentType.RESEARCH: (
48
- "Research topics with web search and synthesize findings"
49
- ),
50
- AgentType.PLAN: "Create comprehensive, actionable plans with milestones",
51
- AgentType.TASKS: (
52
- "Generate specific, actionable tasks from research and plans"
53
- ),
54
- AgentType.SPECIFY: (
55
- "Create detailed specifications and requirements documents"
56
- ),
57
- AgentType.EXPORT: "Export artifacts and findings to various formats",
58
- }
87
+ # Router mode display
88
+ if self.mode == AgentType.ROUTER:
89
+ return self._render_router_mode()
59
90
 
60
- mode_title = mode_display.get(self.mode, self.mode.value.title())
61
- description = mode_description.get(self.mode, "")
91
+ # Legacy agent mode display
92
+ return self._render_legacy_mode()
93
+
94
+ def _render_router_mode(self) -> str:
95
+ """Render the router mode indicator.
96
+
97
+ Shows:
98
+ - "📋 Planning mode" or "✍️ Drafting mode" when idle
99
+ - "📋 Planning → Research" format when sub-agent is executing
100
+ """
101
+ # Get router mode from screen
102
+ router_mode: str | None = None
103
+ if isinstance(self.screen, RouterModeProvider):
104
+ router_mode = self.screen.router_mode
105
+
106
+ # Get active sub-agent from screen
107
+ active_sub_agent: AgentType | None = None
108
+ if isinstance(self.screen, ActiveSubAgentProvider):
109
+ sub_agent_str = self.screen.active_sub_agent
110
+ if sub_agent_str:
111
+ # Convert string back to AgentType enum
112
+ try:
113
+ active_sub_agent = AgentType(sub_agent_str)
114
+ except ValueError:
115
+ pass
116
+
117
+ # Determine mode display using RouterMode enum
118
+ if router_mode == RouterMode.DRAFTING.value:
119
+ icon = "✍️"
120
+ mode_name = "Drafting"
121
+ description = "Auto-execute without confirmation"
122
+ css_class = RouterModeCssClass.DRAFTING
123
+ else:
124
+ # Default to planning mode
125
+ icon = "📋"
126
+ mode_name = "Planning"
127
+ description = "Review plans before execution"
128
+ css_class = RouterModeCssClass.PLANNING
129
+
130
+ # Update CSS class for styling
131
+ self.set_classes(css_class)
132
+
133
+ # Add sub-agent suffix if executing
134
+ if active_sub_agent:
135
+ # Use shared display name mapping
136
+ sub_agent_name = AGENT_DISPLAY_NAMES.get(
137
+ active_sub_agent, active_sub_agent.value.title()
138
+ )
139
+ return f"[bold $text-accent]{icon} {mode_name} → {sub_agent_name}[/]"
140
+
141
+ return (
142
+ f"[bold $text-accent]{icon} {mode_name} mode[/]"
143
+ f"[$foreground-muted] ({description})[/]"
144
+ )
145
+
146
+ def _render_legacy_mode(self) -> str:
147
+ """Render the legacy agent mode indicator.
148
+
149
+ Shows the agent name with description and content status.
150
+ """
151
+ mode_title = AGENT_DISPLAY_NAMES.get(self.mode, self.mode.value.title())
152
+ description = AGENT_DESCRIPTIONS.get(self.mode, "")
62
153
 
63
154
  # Check if mode has content
64
155
  has_content = self.progress_checker.has_mode_content(self.mode)
65
156
  status_icon = " ✓" if has_content else ""
66
157
 
158
+ # Clear any router mode CSS classes
159
+ self.remove_class(RouterModeCssClass.PLANNING)
160
+ self.remove_class(RouterModeCssClass.DRAFTING)
161
+
67
162
  return (
68
163
  f"[bold $text-accent]{mode_title}{status_icon} mode[/]"
69
164
  f"[$foreground-muted] ({description})[/]"
@@ -27,43 +27,40 @@ class PromptInput(TextArea):
27
27
  super().__init__()
28
28
  self.text = text
29
29
 
30
+ class OpenCommandPalette(Message):
31
+ """Request to open the command palette."""
32
+
30
33
  def action_submit(self) -> None:
31
34
  """An action to submit the text."""
32
35
  self.post_message(self.Submitted(self.text))
33
36
 
34
- async def _on_key(self, event: events.Key) -> None:
35
- """Handle key presses which correspond to document inserts."""
36
-
37
- # Don't handle Enter key here - let the binding handle it
37
+ def on_key(self, event: events.Key) -> None:
38
+ """Handle key presses for special actions."""
39
+ # Submit on Enter
38
40
  if event.key == "enter":
41
+ event.stop()
42
+ event.prevent_default()
39
43
  self.action_submit()
40
-
41
- self._restart_blink()
42
-
43
- if self.read_only:
44
44
  return
45
45
 
46
- key = event.key
47
- insert_values = {
48
- "ctrl+j": "\n",
49
- }
50
- if self.tab_behavior == "indent":
51
- if key == "escape":
52
- event.stop()
53
- event.prevent_default()
54
- self.screen.focus_next()
55
- return
56
- if self.indent_type == "tabs":
57
- insert_values["tab"] = "\t"
58
- else:
59
- insert_values["tab"] = " " * self._find_columns_to_next_tab_stop()
46
+ # Detect "/" as first character to trigger command palette
47
+ if event.character == "/" and not self.text.strip():
48
+ event.stop()
49
+ event.prevent_default()
50
+ self.post_message(self.OpenCommandPalette())
51
+ return
60
52
 
61
- if event.is_printable or key in insert_values:
53
+ # Handle ctrl+j or shift+enter for newline (since enter is for submit)
54
+ # Note: shift+enter only works if terminal is configured to send escape sequence
55
+ # Common terminals: iTerm2, VS Code, WezTerm can be configured for this
56
+ if event.key in ("ctrl+j", "shift+enter"):
62
57
  event.stop()
63
58
  event.prevent_default()
64
- insert = insert_values.get(key, event.character)
65
- # `insert` is not None because event.character cannot be
66
- # None because we've checked that it's printable.
67
- assert insert is not None # noqa: S101
68
59
  start, end = self.selection
69
- self._replace_via_keyboard(insert, start, end)
60
+ self.replace(
61
+ "\n",
62
+ start,
63
+ end,
64
+ maintain_selection_offset=False,
65
+ )
66
+ return
@@ -2,7 +2,7 @@
2
2
 
3
3
  from textual.widget import Widget
4
4
 
5
- from shotgun.tui.protocols import QAStateProvider
5
+ from shotgun.tui.protocols import QAStateProvider, QuitConfirmationProvider
6
6
 
7
7
 
8
8
  class StatusBar(Widget):
@@ -26,7 +26,11 @@ class StatusBar(Widget):
26
26
 
27
27
  def render(self) -> str:
28
28
  """Render the status bar with contextual help text."""
29
- # Check if in Q&A mode first (highest priority)
29
+ # Check if quit confirmation is pending (highest priority)
30
+ if isinstance(self.app, QuitConfirmationProvider) and self.app.quit_pending:
31
+ return "[$foreground-muted][bold $warning]Press Ctrl+C again to quit[/] • [bold $text]esc[/] to cancel[/]"
32
+
33
+ # Check if in Q&A mode
30
34
  if isinstance(self.screen, QAStateProvider) and self.screen.qa_mode:
31
35
  return (
32
36
  "[$foreground-muted][bold $text]esc[/] to exit Q&A mode • "
@@ -36,13 +40,16 @@ class StatusBar(Widget):
36
40
  if self.working:
37
41
  return (
38
42
  "[$foreground-muted][bold $text]esc[/] to stop • "
39
- "[bold $text]enter[/] to send • [bold $text]ctrl+j[/] for newline • "
40
- "[bold $text]ctrl+p[/] command palette • [bold $text]shift+tab[/] cycle modes • "
41
- "/help for commands[/]"
43
+ "[bold $text]enter[/] to send • [bold $text]ctrl+j[/] newline • "
44
+ "[bold $text]/[/] command palette • "
45
+ "[bold $text]shift+tab[/] toggle mode • "
46
+ "[bold $text]ctrl+c[/] copy • [bold $text]ctrl+v[/] paste[/]"
42
47
  )
43
48
  else:
44
49
  return (
45
50
  "[$foreground-muted][bold $text]enter[/] to send • "
46
- "[bold $text]ctrl+j[/] for newline • [bold $text]ctrl+p[/] command palette • "
47
- "[bold $text]shift+tab[/] cycle modes/help for commands[/]"
51
+ "[bold $text]ctrl+j[/] newline • "
52
+ "[bold $text]/[/] command palette • "
53
+ "[bold $text]shift+tab[/] toggle mode • "
54
+ "[bold $text]ctrl+c[/] copy • [bold $text]ctrl+v[/] paste[/]"
48
55
  )