shotgun-sh 0.1.9__py3-none-any.whl → 0.2.11__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.

Files changed (150) hide show
  1. shotgun/agents/agent_manager.py +761 -52
  2. shotgun/agents/common.py +80 -75
  3. shotgun/agents/config/constants.py +21 -10
  4. shotgun/agents/config/manager.py +322 -97
  5. shotgun/agents/config/models.py +114 -84
  6. shotgun/agents/config/provider.py +232 -88
  7. shotgun/agents/context_analyzer/__init__.py +28 -0
  8. shotgun/agents/context_analyzer/analyzer.py +471 -0
  9. shotgun/agents/context_analyzer/constants.py +9 -0
  10. shotgun/agents/context_analyzer/formatter.py +115 -0
  11. shotgun/agents/context_analyzer/models.py +212 -0
  12. shotgun/agents/conversation_history.py +125 -2
  13. shotgun/agents/conversation_manager.py +57 -19
  14. shotgun/agents/export.py +6 -7
  15. shotgun/agents/history/compaction.py +23 -3
  16. shotgun/agents/history/context_extraction.py +93 -6
  17. shotgun/agents/history/history_processors.py +179 -11
  18. shotgun/agents/history/token_counting/__init__.py +31 -0
  19. shotgun/agents/history/token_counting/anthropic.py +127 -0
  20. shotgun/agents/history/token_counting/base.py +78 -0
  21. shotgun/agents/history/token_counting/openai.py +90 -0
  22. shotgun/agents/history/token_counting/sentencepiece_counter.py +127 -0
  23. shotgun/agents/history/token_counting/tokenizer_cache.py +92 -0
  24. shotgun/agents/history/token_counting/utils.py +144 -0
  25. shotgun/agents/history/token_estimation.py +12 -12
  26. shotgun/agents/llm.py +62 -0
  27. shotgun/agents/models.py +59 -4
  28. shotgun/agents/plan.py +6 -7
  29. shotgun/agents/research.py +7 -8
  30. shotgun/agents/specify.py +6 -7
  31. shotgun/agents/tasks.py +6 -7
  32. shotgun/agents/tools/__init__.py +0 -2
  33. shotgun/agents/tools/codebase/codebase_shell.py +6 -0
  34. shotgun/agents/tools/codebase/directory_lister.py +6 -0
  35. shotgun/agents/tools/codebase/file_read.py +11 -2
  36. shotgun/agents/tools/codebase/query_graph.py +6 -0
  37. shotgun/agents/tools/codebase/retrieve_code.py +6 -0
  38. shotgun/agents/tools/file_management.py +82 -16
  39. shotgun/agents/tools/registry.py +217 -0
  40. shotgun/agents/tools/web_search/__init__.py +55 -16
  41. shotgun/agents/tools/web_search/anthropic.py +76 -51
  42. shotgun/agents/tools/web_search/gemini.py +50 -27
  43. shotgun/agents/tools/web_search/openai.py +26 -17
  44. shotgun/agents/tools/web_search/utils.py +2 -2
  45. shotgun/agents/usage_manager.py +164 -0
  46. shotgun/api_endpoints.py +15 -0
  47. shotgun/cli/clear.py +53 -0
  48. shotgun/cli/codebase/commands.py +71 -2
  49. shotgun/cli/compact.py +186 -0
  50. shotgun/cli/config.py +41 -67
  51. shotgun/cli/context.py +111 -0
  52. shotgun/cli/export.py +1 -1
  53. shotgun/cli/feedback.py +50 -0
  54. shotgun/cli/models.py +3 -2
  55. shotgun/cli/plan.py +1 -1
  56. shotgun/cli/research.py +1 -1
  57. shotgun/cli/specify.py +1 -1
  58. shotgun/cli/tasks.py +1 -1
  59. shotgun/cli/update.py +18 -5
  60. shotgun/codebase/core/change_detector.py +5 -3
  61. shotgun/codebase/core/code_retrieval.py +4 -2
  62. shotgun/codebase/core/ingestor.py +169 -19
  63. shotgun/codebase/core/manager.py +177 -13
  64. shotgun/codebase/core/nl_query.py +1 -1
  65. shotgun/codebase/models.py +28 -3
  66. shotgun/codebase/service.py +14 -2
  67. shotgun/exceptions.py +32 -0
  68. shotgun/llm_proxy/__init__.py +19 -0
  69. shotgun/llm_proxy/clients.py +44 -0
  70. shotgun/llm_proxy/constants.py +15 -0
  71. shotgun/logging_config.py +18 -27
  72. shotgun/main.py +91 -4
  73. shotgun/posthog_telemetry.py +87 -40
  74. shotgun/prompts/agents/export.j2 +18 -1
  75. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +5 -1
  76. shotgun/prompts/agents/partials/interactive_mode.j2 +24 -7
  77. shotgun/prompts/agents/plan.j2 +1 -1
  78. shotgun/prompts/agents/research.j2 +1 -1
  79. shotgun/prompts/agents/specify.j2 +270 -3
  80. shotgun/prompts/agents/state/system_state.j2 +4 -0
  81. shotgun/prompts/agents/tasks.j2 +1 -1
  82. shotgun/prompts/codebase/partials/cypher_rules.j2 +13 -0
  83. shotgun/prompts/loader.py +2 -2
  84. shotgun/prompts/tools/web_search.j2 +14 -0
  85. shotgun/sdk/codebase.py +60 -2
  86. shotgun/sentry_telemetry.py +28 -21
  87. shotgun/settings.py +238 -0
  88. shotgun/shotgun_web/__init__.py +19 -0
  89. shotgun/shotgun_web/client.py +138 -0
  90. shotgun/shotgun_web/constants.py +21 -0
  91. shotgun/shotgun_web/models.py +47 -0
  92. shotgun/telemetry.py +24 -36
  93. shotgun/tui/app.py +275 -23
  94. shotgun/tui/commands/__init__.py +1 -1
  95. shotgun/tui/components/context_indicator.py +179 -0
  96. shotgun/tui/components/mode_indicator.py +70 -0
  97. shotgun/tui/components/status_bar.py +48 -0
  98. shotgun/tui/components/vertical_tail.py +6 -0
  99. shotgun/tui/containers.py +91 -0
  100. shotgun/tui/dependencies.py +39 -0
  101. shotgun/tui/filtered_codebase_service.py +46 -0
  102. shotgun/tui/protocols.py +45 -0
  103. shotgun/tui/screens/chat/__init__.py +5 -0
  104. shotgun/tui/screens/chat/chat.tcss +54 -0
  105. shotgun/tui/screens/chat/chat_screen.py +1234 -0
  106. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +64 -0
  107. shotgun/tui/screens/chat/codebase_index_selection.py +12 -0
  108. shotgun/tui/screens/chat/help_text.py +40 -0
  109. shotgun/tui/screens/chat/prompt_history.py +48 -0
  110. shotgun/tui/screens/chat.tcss +11 -0
  111. shotgun/tui/screens/chat_screen/command_providers.py +226 -11
  112. shotgun/tui/screens/chat_screen/history/__init__.py +22 -0
  113. shotgun/tui/screens/chat_screen/history/agent_response.py +66 -0
  114. shotgun/tui/screens/chat_screen/history/chat_history.py +116 -0
  115. shotgun/tui/screens/chat_screen/history/formatters.py +115 -0
  116. shotgun/tui/screens/chat_screen/history/partial_response.py +43 -0
  117. shotgun/tui/screens/chat_screen/history/user_question.py +42 -0
  118. shotgun/tui/screens/confirmation_dialog.py +151 -0
  119. shotgun/tui/screens/feedback.py +193 -0
  120. shotgun/tui/screens/github_issue.py +102 -0
  121. shotgun/tui/screens/model_picker.py +352 -0
  122. shotgun/tui/screens/onboarding.py +431 -0
  123. shotgun/tui/screens/pipx_migration.py +153 -0
  124. shotgun/tui/screens/provider_config.py +156 -39
  125. shotgun/tui/screens/shotgun_auth.py +295 -0
  126. shotgun/tui/screens/welcome.py +198 -0
  127. shotgun/tui/services/__init__.py +5 -0
  128. shotgun/tui/services/conversation_service.py +184 -0
  129. shotgun/tui/state/__init__.py +7 -0
  130. shotgun/tui/state/processing_state.py +185 -0
  131. shotgun/tui/utils/mode_progress.py +14 -7
  132. shotgun/tui/widgets/__init__.py +5 -0
  133. shotgun/tui/widgets/widget_coordinator.py +262 -0
  134. shotgun/utils/datetime_utils.py +77 -0
  135. shotgun/utils/env_utils.py +13 -0
  136. shotgun/utils/file_system_utils.py +22 -2
  137. shotgun/utils/marketing.py +110 -0
  138. shotgun/utils/source_detection.py +16 -0
  139. shotgun/utils/update_checker.py +73 -21
  140. shotgun_sh-0.2.11.dist-info/METADATA +130 -0
  141. shotgun_sh-0.2.11.dist-info/RECORD +194 -0
  142. {shotgun_sh-0.1.9.dist-info → shotgun_sh-0.2.11.dist-info}/entry_points.txt +1 -0
  143. {shotgun_sh-0.1.9.dist-info → shotgun_sh-0.2.11.dist-info}/licenses/LICENSE +1 -1
  144. shotgun/agents/history/token_counting.py +0 -429
  145. shotgun/agents/tools/user_interaction.py +0 -37
  146. shotgun/tui/screens/chat.py +0 -818
  147. shotgun/tui/screens/chat_screen/history.py +0 -222
  148. shotgun_sh-0.1.9.dist-info/METADATA +0 -466
  149. shotgun_sh-0.1.9.dist-info/RECORD +0 -131
  150. {shotgun_sh-0.1.9.dist-info → shotgun_sh-0.2.11.dist-info}/WHEEL +0 -0
shotgun/tui/app.py CHANGED
@@ -5,37 +5,58 @@ from textual.app import App, SystemCommand
5
5
  from textual.binding import Binding
6
6
  from textual.screen import Screen
7
7
 
8
+ from shotgun.agents.agent_manager import AgentManager
8
9
  from shotgun.agents.config import ConfigManager, get_config_manager
10
+ from shotgun.agents.models import AgentType
9
11
  from shotgun.logging_config import get_logger
12
+ from shotgun.tui.containers import TUIContainer
10
13
  from shotgun.tui.screens.splash import SplashScreen
11
14
  from shotgun.utils.file_system_utils import get_shotgun_base_path
12
- from shotgun.utils.update_checker import perform_auto_update_async
15
+ from shotgun.utils.update_checker import (
16
+ detect_installation_method,
17
+ perform_auto_update_async,
18
+ )
13
19
 
14
20
  from .screens.chat import ChatScreen
15
21
  from .screens.directory_setup import DirectorySetupScreen
22
+ from .screens.github_issue import GitHubIssueScreen
23
+ from .screens.model_picker import ModelPickerScreen
24
+ from .screens.pipx_migration import PipxMigrationScreen
16
25
  from .screens.provider_config import ProviderConfigScreen
26
+ from .screens.welcome import WelcomeScreen
17
27
 
18
28
  logger = get_logger(__name__)
19
29
 
20
30
 
21
31
  class ShotgunApp(App[None]):
32
+ # ChatScreen removed from SCREENS dict since it requires dependency injection
33
+ # and is instantiated manually in refresh_startup_screen()
22
34
  SCREENS = {
23
- "chat": ChatScreen,
24
35
  "provider_config": ProviderConfigScreen,
36
+ "model_picker": ModelPickerScreen,
25
37
  "directory_setup": DirectorySetupScreen,
38
+ "github_issue": GitHubIssueScreen,
26
39
  }
27
40
  BINDINGS = [
28
41
  Binding("ctrl+c", "quit", "Quit the app"),
29
42
  ]
43
+
30
44
  CSS_PATH = "styles.tcss"
31
45
 
32
46
  def __init__(
33
- self, no_update_check: bool = False, continue_session: bool = False
47
+ self,
48
+ no_update_check: bool = False,
49
+ continue_session: bool = False,
50
+ force_reindex: bool = False,
34
51
  ) -> None:
35
52
  super().__init__()
36
53
  self.config_manager: ConfigManager = get_config_manager()
37
54
  self.no_update_check = no_update_check
38
55
  self.continue_session = continue_session
56
+ self.force_reindex = force_reindex
57
+
58
+ # Initialize dependency injection container
59
+ self.container = TUIContainer()
39
60
 
40
61
  # Start async update check and install
41
62
  if not no_update_check:
@@ -46,36 +67,104 @@ class ShotgunApp(App[None]):
46
67
  # Track TUI startup
47
68
  from shotgun.posthog_telemetry import track_event
48
69
 
49
- track_event("tui_started", {})
70
+ track_event(
71
+ "tui_started",
72
+ {
73
+ "installation_method": detect_installation_method(),
74
+ },
75
+ )
50
76
 
51
77
  self.push_screen(
52
78
  SplashScreen(), callback=lambda _arg: self.refresh_startup_screen()
53
79
  )
54
80
 
55
- def refresh_startup_screen(self) -> None:
81
+ def refresh_startup_screen(self, skip_pipx_check: bool = False) -> None:
56
82
  """Push the appropriate screen based on configured providers."""
57
- if not self.config_manager.has_any_provider_key():
58
- if isinstance(self.screen, ProviderConfigScreen):
83
+ # Check for pipx installation and show migration modal first
84
+ if not skip_pipx_check:
85
+ installation_method = detect_installation_method()
86
+ if installation_method == "pipx":
87
+ if isinstance(self.screen, PipxMigrationScreen):
88
+ return
89
+
90
+ # Show pipx migration modal as a blocking modal screen
91
+ self.push_screen(
92
+ PipxMigrationScreen(),
93
+ callback=lambda _arg: self.refresh_startup_screen(
94
+ skip_pipx_check=True
95
+ ),
96
+ )
59
97
  return
60
98
 
61
- self.push_screen(
62
- "provider_config", callback=lambda _arg: self.refresh_startup_screen()
63
- )
64
- return
99
+ # Run async config loading in worker
100
+ async def _check_config() -> None:
101
+ # Show welcome screen if no providers are configured OR if user hasn't seen it yet
102
+ config = await self.config_manager.load()
103
+ has_any_key = await self.config_manager.has_any_provider_key()
104
+ if not has_any_key or not config.shown_welcome_screen:
105
+ if isinstance(self.screen, WelcomeScreen):
106
+ return
65
107
 
66
- if not self.check_local_shotgun_directory_exists():
67
- if isinstance(self.screen, DirectorySetupScreen):
108
+ self.push_screen(
109
+ WelcomeScreen(),
110
+ callback=lambda _arg: self.refresh_startup_screen(),
111
+ )
68
112
  return
69
113
 
70
- self.push_screen(
71
- "directory_setup", callback=lambda _arg: self.refresh_startup_screen()
114
+ if not self.check_local_shotgun_directory_exists():
115
+ if isinstance(self.screen, DirectorySetupScreen):
116
+ return
117
+
118
+ self.push_screen(
119
+ DirectorySetupScreen(),
120
+ callback=lambda _arg: self.refresh_startup_screen(),
121
+ )
122
+ return
123
+
124
+ if isinstance(self.screen, ChatScreen):
125
+ return
126
+
127
+ # Create ChatScreen with all dependencies injected from container
128
+ # Get the default agent mode (RESEARCH)
129
+ agent_mode = AgentType.RESEARCH
130
+
131
+ # Create AgentDeps asynchronously (get_provider_model is now async)
132
+ from shotgun.tui.dependencies import create_default_tui_deps
133
+
134
+ agent_deps = await create_default_tui_deps()
135
+
136
+ # Create AgentManager with async initialization
137
+ agent_manager = AgentManager(deps=agent_deps, initial_type=agent_mode)
138
+
139
+ # Create ProcessingStateManager - we'll pass the screen after creation
140
+ # For now, create with None and the ChatScreen will set itself
141
+ chat_screen = ChatScreen(
142
+ agent_manager=agent_manager,
143
+ conversation_manager=self.container.conversation_manager(),
144
+ conversation_service=self.container.conversation_service(),
145
+ widget_coordinator=self.container.widget_coordinator_factory(
146
+ screen=None
147
+ ),
148
+ processing_state=self.container.processing_state_factory(
149
+ screen=None, # Will be set after ChatScreen is created
150
+ telemetry_context={"agent_mode": agent_mode.value},
151
+ ),
152
+ command_handler=self.container.command_handler(),
153
+ placeholder_hints=self.container.placeholder_hints(),
154
+ codebase_sdk=self.container.codebase_sdk(),
155
+ deps=agent_deps,
156
+ continue_session=self.continue_session,
157
+ force_reindex=self.force_reindex,
72
158
  )
73
- return
74
159
 
75
- if isinstance(self.screen, ChatScreen):
76
- return
77
- # Pass continue_session flag to ChatScreen
78
- self.push_screen(ChatScreen(continue_session=self.continue_session))
160
+ # Update the ProcessingStateManager and WidgetCoordinator with the actual ChatScreen instance
161
+ chat_screen.processing_state.screen = chat_screen
162
+ chat_screen.widget_coordinator.screen = chat_screen
163
+
164
+ self.push_screen(chat_screen)
165
+
166
+ # Run the async config check in a worker
167
+ self.run_worker(_check_config(), exclusive=False)
79
168
 
80
169
  def check_local_shotgun_directory_exists(self) -> bool:
81
170
  shotgun_dir = get_shotgun_base_path()
@@ -83,22 +172,185 @@ class ShotgunApp(App[None]):
83
172
 
84
173
  async def action_quit(self) -> None:
85
174
  """Quit the application."""
175
+ # Shut down PostHog client to prevent threading errors
176
+ from shotgun.posthog_telemetry import shutdown
177
+
178
+ shutdown()
86
179
  self.exit()
87
180
 
88
181
  def get_system_commands(self, screen: Screen[Any]) -> Iterable[SystemCommand]:
89
- return [] # we don't want any system commands
182
+ return [
183
+ SystemCommand(
184
+ "New Issue",
185
+ "Report a bug or request a feature on GitHub",
186
+ self.action_new_issue,
187
+ )
188
+ ]
189
+
190
+ def action_new_issue(self) -> None:
191
+ """Open GitHub issue screen to guide users to create an issue."""
192
+ self.push_screen(GitHubIssueScreen())
90
193
 
91
194
 
92
- def run(no_update_check: bool = False, continue_session: bool = False) -> None:
195
+ def run(
196
+ no_update_check: bool = False,
197
+ continue_session: bool = False,
198
+ force_reindex: bool = False,
199
+ ) -> None:
93
200
  """Run the TUI application.
94
201
 
95
202
  Args:
96
203
  no_update_check: If True, disable automatic update checks.
97
204
  continue_session: If True, continue from previous conversation.
205
+ force_reindex: If True, force re-indexing of codebase (ignores existing index).
98
206
  """
99
- app = ShotgunApp(no_update_check=no_update_check, continue_session=continue_session)
207
+ # Clean up any corrupted databases BEFORE starting the TUI
208
+ # This prevents crashes from corrupted databases during initialization
209
+ import asyncio
210
+
211
+ from shotgun.codebase.core.manager import CodebaseGraphManager
212
+ from shotgun.utils import get_shotgun_home
213
+
214
+ storage_dir = get_shotgun_home() / "codebases"
215
+ manager = CodebaseGraphManager(storage_dir)
216
+
217
+ try:
218
+ removed = asyncio.run(manager.cleanup_corrupted_databases())
219
+ if removed:
220
+ logger.info(
221
+ f"Cleaned up {len(removed)} corrupted database(s) before TUI startup"
222
+ )
223
+ except Exception as e:
224
+ logger.error(f"Failed to cleanup corrupted databases: {e}")
225
+ # Continue anyway - the TUI can still function
226
+
227
+ app = ShotgunApp(
228
+ no_update_check=no_update_check,
229
+ continue_session=continue_session,
230
+ force_reindex=force_reindex,
231
+ )
100
232
  app.run(inline_no_clear=True)
101
233
 
102
234
 
235
+ def serve(
236
+ host: str = "localhost",
237
+ port: int = 8000,
238
+ public_url: str | None = None,
239
+ no_update_check: bool = False,
240
+ continue_session: bool = False,
241
+ force_reindex: bool = False,
242
+ ) -> None:
243
+ """Serve the TUI application as a web application.
244
+
245
+ Args:
246
+ host: Host address for the web server.
247
+ port: Port number for the web server.
248
+ public_url: Public URL if behind a proxy.
249
+ no_update_check: If True, disable automatic update checks.
250
+ continue_session: If True, continue from previous conversation.
251
+ force_reindex: If True, force re-indexing of codebase (ignores existing index).
252
+ """
253
+ # Clean up any corrupted databases BEFORE starting the TUI
254
+ # This prevents crashes from corrupted databases during initialization
255
+ import asyncio
256
+
257
+ from textual_serve.server import Server
258
+
259
+ from shotgun.codebase.core.manager import CodebaseGraphManager
260
+ from shotgun.utils import get_shotgun_home
261
+
262
+ storage_dir = get_shotgun_home() / "codebases"
263
+ manager = CodebaseGraphManager(storage_dir)
264
+
265
+ try:
266
+ removed = asyncio.run(manager.cleanup_corrupted_databases())
267
+ if removed:
268
+ logger.info(
269
+ f"Cleaned up {len(removed)} corrupted database(s) before TUI startup"
270
+ )
271
+ except Exception as e:
272
+ logger.error(f"Failed to cleanup corrupted databases: {e}")
273
+ # Continue anyway - the TUI can still function
274
+
275
+ # Create a new event loop after asyncio.run() closes the previous one
276
+ # This is needed for the Server.serve() method
277
+ loop = asyncio.new_event_loop()
278
+ asyncio.set_event_loop(loop)
279
+
280
+ # Build the command string based on flags
281
+ command = "shotgun"
282
+ if no_update_check:
283
+ command += " --no-update-check"
284
+ if continue_session:
285
+ command += " --continue"
286
+ if force_reindex:
287
+ command += " --force-reindex"
288
+
289
+ # Create and start the server with hardcoded title and debug=False
290
+ server = Server(
291
+ command=command,
292
+ host=host,
293
+ port=port,
294
+ title="The Shotgun",
295
+ public_url=public_url,
296
+ )
297
+
298
+ # Set up graceful shutdown on SIGTERM/SIGINT
299
+ import signal
300
+ import sys
301
+
302
+ def signal_handler(_signum: int, _frame: Any) -> None:
303
+ """Handle shutdown signals gracefully."""
304
+ from shotgun.posthog_telemetry import shutdown
305
+
306
+ logger.info("Received shutdown signal, cleaning up...")
307
+ # Restore stdout/stderr before shutting down
308
+ sys.stdout = original_stdout
309
+ sys.stderr = original_stderr
310
+ shutdown()
311
+ sys.exit(0)
312
+
313
+ signal.signal(signal.SIGTERM, signal_handler)
314
+ signal.signal(signal.SIGINT, signal_handler)
315
+
316
+ # Suppress the textual-serve banner by redirecting stdout/stderr
317
+ import io
318
+
319
+ # Capture and suppress the banner, but show the actual serving URL
320
+ original_stdout = sys.stdout
321
+ original_stderr = sys.stderr
322
+
323
+ captured_output = io.StringIO()
324
+ sys.stdout = captured_output
325
+ sys.stderr = captured_output
326
+
327
+ try:
328
+ # This will print the banner to our captured output
329
+ import logging
330
+
331
+ # Temporarily set logging to ERROR level to suppress INFO messages
332
+ textual_serve_logger = logging.getLogger("textual_serve")
333
+ original_level = textual_serve_logger.level
334
+ textual_serve_logger.setLevel(logging.ERROR)
335
+
336
+ # Print our own message to the original stdout
337
+ sys.stdout = original_stdout
338
+ sys.stderr = original_stderr
339
+ print(f"Serving Shotgun TUI at http://{host}:{port}")
340
+ print("Press Ctrl+C to quit")
341
+
342
+ # Now suppress output again for the serve call
343
+ sys.stdout = captured_output
344
+ sys.stderr = captured_output
345
+
346
+ server.serve(debug=False)
347
+ finally:
348
+ # Restore original stdout/stderr
349
+ sys.stdout = original_stdout
350
+ sys.stderr = original_stderr
351
+ if "textual_serve_logger" in locals():
352
+ textual_serve_logger.setLevel(original_level)
353
+
354
+
103
355
  if __name__ == "__main__":
104
356
  run()
@@ -57,7 +57,7 @@ class CommandHandler:
57
57
  **Keyboard Shortcuts:**
58
58
 
59
59
  * `Enter` - Send message
60
- * `Ctrl+P` - Open command palette
60
+ * `Ctrl+P` - Open command palette (for usage, context, and other commands)
61
61
  * `Shift+Tab` - Cycle agent modes
62
62
  * `Ctrl+C` - Quit application
63
63
 
@@ -0,0 +1,179 @@
1
+ """Context window indicator component for showing model usage."""
2
+
3
+ from textual.reactive import reactive
4
+ from textual.timer import Timer
5
+ from textual.widgets import Static
6
+
7
+ from shotgun.agents.config.models import MODEL_SPECS, ModelName
8
+ from shotgun.agents.context_analyzer.models import ContextAnalysis
9
+
10
+
11
+ class ContextIndicator(Static):
12
+ """Display context window usage and current model name."""
13
+
14
+ DEFAULT_CSS = """
15
+ ContextIndicator {
16
+ width: auto;
17
+ height: 1;
18
+ text-align: right;
19
+ }
20
+ """
21
+
22
+ context_analysis: reactive[ContextAnalysis | None] = reactive(None)
23
+ model_name: reactive[ModelName | None] = reactive(None)
24
+ is_streaming: reactive[bool] = reactive(False)
25
+
26
+ _animation_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
27
+ _animation_index = 0
28
+
29
+ def __init__(
30
+ self,
31
+ *,
32
+ name: str | None = None,
33
+ id: str | None = None,
34
+ classes: str | None = None,
35
+ ) -> None:
36
+ super().__init__(name=name, id=id, classes=classes)
37
+ self._animation_timer: Timer | None = None
38
+
39
+ def update_context(
40
+ self, analysis: ContextAnalysis | None, model: ModelName | None
41
+ ) -> None:
42
+ """Update the context indicator with new analysis and model data.
43
+
44
+ Args:
45
+ analysis: Context analysis with token usage data
46
+ model: Current model name
47
+ """
48
+ self.context_analysis = analysis
49
+ self.model_name = model
50
+ self._refresh_display()
51
+
52
+ def set_streaming(self, streaming: bool) -> None:
53
+ """Enable or disable streaming animation.
54
+
55
+ Args:
56
+ streaming: Whether to show streaming animation
57
+ """
58
+ self.is_streaming = streaming
59
+ if streaming:
60
+ self._start_animation()
61
+ else:
62
+ self._stop_animation()
63
+
64
+ def _start_animation(self) -> None:
65
+ """Start the pulsing animation."""
66
+ if self._animation_timer is None:
67
+ self._animation_timer = self.set_interval(0.1, self._animate_frame)
68
+
69
+ def _stop_animation(self) -> None:
70
+ """Stop the pulsing animation."""
71
+ if self._animation_timer is not None:
72
+ self._animation_timer.stop()
73
+ self._animation_timer = None
74
+ self._animation_index = 0
75
+ self._refresh_display()
76
+
77
+ def _animate_frame(self) -> None:
78
+ """Advance the animation frame."""
79
+ self._animation_index = (self._animation_index + 1) % len(
80
+ self._animation_frames
81
+ )
82
+ self._refresh_display()
83
+
84
+ def _get_percentage_color(self, percentage: float) -> str:
85
+ """Get color for percentage based on threshold.
86
+
87
+ Args:
88
+ percentage: Usage percentage (0-100)
89
+
90
+ Returns:
91
+ Color name for Textual markup
92
+ """
93
+ if percentage < 60:
94
+ return "#00ff00" # Green
95
+ elif percentage < 85:
96
+ return "#ffff00" # Yellow
97
+ else:
98
+ return "#ff0000" # Red
99
+
100
+ def _format_token_count(self, tokens: int) -> str:
101
+ """Format token count for display (e.g., 115000 -> "115K").
102
+
103
+ Args:
104
+ tokens: Token count
105
+
106
+ Returns:
107
+ Formatted string
108
+ """
109
+ if tokens >= 1_000_000:
110
+ return f"{tokens / 1_000_000:.1f}M"
111
+ elif tokens >= 1_000:
112
+ return f"{tokens / 1_000:.0f}K"
113
+ else:
114
+ return str(tokens)
115
+
116
+ def _refresh_display(self) -> None:
117
+ """Refresh the display with current context data."""
118
+ # If no analysis yet, show placeholder with model name or empty
119
+ if self.context_analysis is None:
120
+ if self.model_name:
121
+ model_spec = MODEL_SPECS.get(self.model_name)
122
+ model_display = (
123
+ model_spec.short_name if model_spec else str(self.model_name)
124
+ )
125
+ self.update(f"[bold]{model_display}[/bold]")
126
+ else:
127
+ self.update("")
128
+ return
129
+
130
+ analysis = self.context_analysis
131
+
132
+ # Calculate percentage
133
+ if analysis.max_usable_tokens > 0:
134
+ percentage = round(
135
+ (analysis.agent_context_tokens / analysis.max_usable_tokens) * 100, 1
136
+ )
137
+ else:
138
+ percentage = 0.0
139
+
140
+ # Format token counts
141
+ current_tokens = self._format_token_count(analysis.agent_context_tokens)
142
+ max_tokens = self._format_token_count(analysis.max_usable_tokens)
143
+
144
+ # Get color based on percentage
145
+ color = self._get_percentage_color(percentage)
146
+
147
+ # Build the display string - always show full context info
148
+ parts = [
149
+ "[$foreground-muted]Context window:[/]",
150
+ f"[{color}]{percentage}% ({current_tokens}/{max_tokens})[/]",
151
+ ]
152
+
153
+ # Add streaming animation indicator if streaming
154
+ if self.is_streaming:
155
+ animation_char = self._animation_frames[self._animation_index]
156
+ parts.append(f"[bold cyan]{animation_char}[/]")
157
+
158
+ # Add model name if available
159
+ if self.model_name:
160
+ model_spec = MODEL_SPECS.get(self.model_name)
161
+ model_display = (
162
+ model_spec.short_name if model_spec else str(self.model_name)
163
+ )
164
+ parts.extend(
165
+ [
166
+ "[$foreground-muted]|[/]",
167
+ f"[bold]{model_display}[/bold]",
168
+ ]
169
+ )
170
+
171
+ self.update(" ".join(parts))
172
+
173
+ def watch_context_analysis(self, analysis: ContextAnalysis | None) -> None:
174
+ """React to context analysis changes."""
175
+ self._refresh_display()
176
+
177
+ def watch_model_name(self, model: ModelName | None) -> None:
178
+ """React to model name changes."""
179
+ self._refresh_display()
@@ -0,0 +1,70 @@
1
+ """Widget to display the current agent mode."""
2
+
3
+ from textual.widget import Widget
4
+
5
+ from shotgun.agents.models import AgentType
6
+ from shotgun.tui.protocols import QAStateProvider
7
+ from shotgun.tui.utils.mode_progress import PlaceholderHints
8
+
9
+
10
+ class ModeIndicator(Widget):
11
+ """Widget to display the current agent mode."""
12
+
13
+ DEFAULT_CSS = """
14
+ ModeIndicator {
15
+ text-wrap: wrap;
16
+ padding-left: 1;
17
+ }
18
+ """
19
+
20
+ def __init__(self, mode: AgentType) -> None:
21
+ """Initialize the mode indicator.
22
+
23
+ Args:
24
+ mode: The current agent type/mode.
25
+ """
26
+ super().__init__()
27
+ self.mode = mode
28
+ self.progress_checker = PlaceholderHints().progress_checker
29
+
30
+ def render(self) -> str:
31
+ """Render the mode indicator."""
32
+ # Check if in Q&A mode first
33
+ if isinstance(self.screen, QAStateProvider) and self.screen.qa_mode:
34
+ return (
35
+ "[bold $text-accent]Q&A mode[/]"
36
+ "[$foreground-muted] (Answer the clarifying questions or ESC to cancel)[/]"
37
+ )
38
+
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
+ }
59
+
60
+ mode_title = mode_display.get(self.mode, self.mode.value.title())
61
+ description = mode_description.get(self.mode, "")
62
+
63
+ # Check if mode has content
64
+ has_content = self.progress_checker.has_mode_content(self.mode)
65
+ status_icon = " ✓" if has_content else ""
66
+
67
+ return (
68
+ f"[bold $text-accent]{mode_title}{status_icon} mode[/]"
69
+ f"[$foreground-muted] ({description})[/]"
70
+ )
@@ -0,0 +1,48 @@
1
+ """Widget to display the status bar with contextual help text."""
2
+
3
+ from textual.widget import Widget
4
+
5
+ from shotgun.tui.protocols import QAStateProvider
6
+
7
+
8
+ class StatusBar(Widget):
9
+ """Widget to display the status bar with contextual help text."""
10
+
11
+ DEFAULT_CSS = """
12
+ StatusBar {
13
+ text-wrap: wrap;
14
+ padding-left: 1;
15
+ }
16
+ """
17
+
18
+ def __init__(self, working: bool = False) -> None:
19
+ """Initialize the status bar.
20
+
21
+ Args:
22
+ working: Whether an agent is currently working.
23
+ """
24
+ super().__init__()
25
+ self.working = working
26
+
27
+ def render(self) -> str:
28
+ """Render the status bar with contextual help text."""
29
+ # Check if in Q&A mode first (highest priority)
30
+ if isinstance(self.screen, QAStateProvider) and self.screen.qa_mode:
31
+ return (
32
+ "[$foreground-muted][bold $text]esc[/] to exit Q&A mode • "
33
+ "[bold $text]enter[/] to send answer • [bold $text]ctrl+j[/] for newline[/]"
34
+ )
35
+
36
+ if self.working:
37
+ return (
38
+ "[$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[/]"
42
+ )
43
+ else:
44
+ return (
45
+ "[$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[/]"
48
+ )
@@ -1,4 +1,5 @@
1
1
  from textual.containers import VerticalScroll
2
+ from textual.geometry import Size
2
3
  from textual.reactive import reactive
3
4
 
4
5
 
@@ -11,3 +12,8 @@ class VerticalTail(VerticalScroll):
11
12
  """Handle auto_scroll property changes."""
12
13
  if value:
13
14
  self.scroll_end(animate=False)
15
+
16
+ def watch_virtual_size(self, value: Size) -> None:
17
+ """Handle virtual_size property changes."""
18
+
19
+ self.call_later(self.scroll_end, animate=False)