shotgun-sh 0.2.8.dev2__py3-none-any.whl → 0.2.17__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 (117) hide show
  1. shotgun/agents/agent_manager.py +354 -46
  2. shotgun/agents/common.py +14 -8
  3. shotgun/agents/config/constants.py +0 -6
  4. shotgun/agents/config/manager.py +66 -35
  5. shotgun/agents/config/models.py +41 -1
  6. shotgun/agents/config/provider.py +33 -5
  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 +2 -0
  13. shotgun/agents/conversation_manager.py +35 -19
  14. shotgun/agents/export.py +2 -2
  15. shotgun/agents/history/compaction.py +9 -4
  16. shotgun/agents/history/history_processors.py +113 -5
  17. shotgun/agents/history/token_counting/anthropic.py +17 -1
  18. shotgun/agents/history/token_counting/base.py +14 -3
  19. shotgun/agents/history/token_counting/openai.py +11 -1
  20. shotgun/agents/history/token_counting/sentencepiece_counter.py +8 -0
  21. shotgun/agents/history/token_counting/tokenizer_cache.py +3 -1
  22. shotgun/agents/history/token_counting/utils.py +0 -3
  23. shotgun/agents/plan.py +2 -2
  24. shotgun/agents/research.py +3 -3
  25. shotgun/agents/specify.py +2 -2
  26. shotgun/agents/tasks.py +2 -2
  27. shotgun/agents/tools/codebase/codebase_shell.py +6 -0
  28. shotgun/agents/tools/codebase/directory_lister.py +6 -0
  29. shotgun/agents/tools/codebase/file_read.py +11 -2
  30. shotgun/agents/tools/codebase/query_graph.py +6 -0
  31. shotgun/agents/tools/codebase/retrieve_code.py +6 -0
  32. shotgun/agents/tools/file_management.py +27 -7
  33. shotgun/agents/tools/registry.py +217 -0
  34. shotgun/agents/tools/web_search/__init__.py +8 -8
  35. shotgun/agents/tools/web_search/anthropic.py +8 -2
  36. shotgun/agents/tools/web_search/gemini.py +7 -1
  37. shotgun/agents/tools/web_search/openai.py +7 -1
  38. shotgun/agents/tools/web_search/utils.py +2 -2
  39. shotgun/agents/usage_manager.py +16 -11
  40. shotgun/api_endpoints.py +7 -3
  41. shotgun/build_constants.py +3 -3
  42. shotgun/cli/clear.py +53 -0
  43. shotgun/cli/compact.py +186 -0
  44. shotgun/cli/config.py +8 -5
  45. shotgun/cli/context.py +111 -0
  46. shotgun/cli/export.py +1 -1
  47. shotgun/cli/feedback.py +4 -2
  48. shotgun/cli/models.py +1 -0
  49. shotgun/cli/plan.py +1 -1
  50. shotgun/cli/research.py +1 -1
  51. shotgun/cli/specify.py +1 -1
  52. shotgun/cli/tasks.py +1 -1
  53. shotgun/cli/update.py +16 -2
  54. shotgun/codebase/core/change_detector.py +5 -3
  55. shotgun/codebase/core/code_retrieval.py +4 -2
  56. shotgun/codebase/core/ingestor.py +10 -8
  57. shotgun/codebase/core/manager.py +13 -4
  58. shotgun/codebase/core/nl_query.py +1 -1
  59. shotgun/exceptions.py +32 -0
  60. shotgun/logging_config.py +18 -27
  61. shotgun/main.py +73 -11
  62. shotgun/posthog_telemetry.py +37 -28
  63. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +3 -2
  64. shotgun/sentry_telemetry.py +163 -16
  65. shotgun/settings.py +238 -0
  66. shotgun/telemetry.py +10 -33
  67. shotgun/tui/app.py +243 -43
  68. shotgun/tui/commands/__init__.py +1 -1
  69. shotgun/tui/components/context_indicator.py +179 -0
  70. shotgun/tui/components/mode_indicator.py +70 -0
  71. shotgun/tui/components/status_bar.py +48 -0
  72. shotgun/tui/containers.py +91 -0
  73. shotgun/tui/dependencies.py +39 -0
  74. shotgun/tui/protocols.py +45 -0
  75. shotgun/tui/screens/chat/__init__.py +5 -0
  76. shotgun/tui/screens/chat/chat.tcss +54 -0
  77. shotgun/tui/screens/chat/chat_screen.py +1254 -0
  78. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +64 -0
  79. shotgun/tui/screens/chat/codebase_index_selection.py +12 -0
  80. shotgun/tui/screens/chat/help_text.py +40 -0
  81. shotgun/tui/screens/chat/prompt_history.py +48 -0
  82. shotgun/tui/screens/chat.tcss +11 -0
  83. shotgun/tui/screens/chat_screen/command_providers.py +78 -2
  84. shotgun/tui/screens/chat_screen/history/__init__.py +22 -0
  85. shotgun/tui/screens/chat_screen/history/agent_response.py +66 -0
  86. shotgun/tui/screens/chat_screen/history/chat_history.py +115 -0
  87. shotgun/tui/screens/chat_screen/history/formatters.py +115 -0
  88. shotgun/tui/screens/chat_screen/history/partial_response.py +43 -0
  89. shotgun/tui/screens/chat_screen/history/user_question.py +42 -0
  90. shotgun/tui/screens/confirmation_dialog.py +151 -0
  91. shotgun/tui/screens/feedback.py +4 -4
  92. shotgun/tui/screens/github_issue.py +102 -0
  93. shotgun/tui/screens/model_picker.py +49 -24
  94. shotgun/tui/screens/onboarding.py +431 -0
  95. shotgun/tui/screens/pipx_migration.py +153 -0
  96. shotgun/tui/screens/provider_config.py +50 -27
  97. shotgun/tui/screens/shotgun_auth.py +2 -2
  98. shotgun/tui/screens/welcome.py +14 -11
  99. shotgun/tui/services/__init__.py +5 -0
  100. shotgun/tui/services/conversation_service.py +184 -0
  101. shotgun/tui/state/__init__.py +7 -0
  102. shotgun/tui/state/processing_state.py +185 -0
  103. shotgun/tui/utils/mode_progress.py +14 -7
  104. shotgun/tui/widgets/__init__.py +5 -0
  105. shotgun/tui/widgets/widget_coordinator.py +263 -0
  106. shotgun/utils/file_system_utils.py +22 -2
  107. shotgun/utils/marketing.py +110 -0
  108. shotgun/utils/update_checker.py +69 -14
  109. shotgun_sh-0.2.17.dist-info/METADATA +465 -0
  110. shotgun_sh-0.2.17.dist-info/RECORD +194 -0
  111. {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.2.17.dist-info}/entry_points.txt +1 -0
  112. {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.2.17.dist-info}/licenses/LICENSE +1 -1
  113. shotgun/tui/screens/chat.py +0 -996
  114. shotgun/tui/screens/chat_screen/history.py +0 -335
  115. shotgun_sh-0.2.8.dev2.dist-info/METADATA +0 -126
  116. shotgun_sh-0.2.8.dev2.dist-info/RECORD +0 -155
  117. {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.2.17.dist-info}/WHEEL +0 -0
shotgun/tui/app.py CHANGED
@@ -5,16 +5,23 @@ 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
16
- from .screens.feedback import FeedbackScreen
22
+ from .screens.github_issue import GitHubIssueScreen
17
23
  from .screens.model_picker import ModelPickerScreen
24
+ from .screens.pipx_migration import PipxMigrationScreen
18
25
  from .screens.provider_config import ProviderConfigScreen
19
26
  from .screens.welcome import WelcomeScreen
20
27
 
@@ -22,12 +29,13 @@ logger = get_logger(__name__)
22
29
 
23
30
 
24
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()
25
34
  SCREENS = {
26
- "chat": ChatScreen,
27
35
  "provider_config": ProviderConfigScreen,
28
36
  "model_picker": ModelPickerScreen,
29
37
  "directory_setup": DirectorySetupScreen,
30
- "feedback": FeedbackScreen,
38
+ "github_issue": GitHubIssueScreen,
31
39
  }
32
40
  BINDINGS = [
33
41
  Binding("ctrl+c", "quit", "Quit the app"),
@@ -36,12 +44,19 @@ class ShotgunApp(App[None]):
36
44
  CSS_PATH = "styles.tcss"
37
45
 
38
46
  def __init__(
39
- 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,
40
51
  ) -> None:
41
52
  super().__init__()
42
53
  self.config_manager: ConfigManager = get_config_manager()
43
54
  self.no_update_check = no_update_check
44
55
  self.continue_session = continue_session
56
+ self.force_reindex = force_reindex
57
+
58
+ # Initialize dependency injection container
59
+ self.container = TUIContainer()
45
60
 
46
61
  # Start async update check and install
47
62
  if not no_update_check:
@@ -52,43 +67,104 @@ class ShotgunApp(App[None]):
52
67
  # Track TUI startup
53
68
  from shotgun.posthog_telemetry import track_event
54
69
 
55
- track_event("tui_started", {})
70
+ track_event(
71
+ "tui_started",
72
+ {
73
+ "installation_method": detect_installation_method(),
74
+ },
75
+ )
56
76
 
57
77
  self.push_screen(
58
78
  SplashScreen(), callback=lambda _arg: self.refresh_startup_screen()
59
79
  )
60
80
 
61
- def refresh_startup_screen(self) -> None:
81
+ def refresh_startup_screen(self, skip_pipx_check: bool = False) -> None:
62
82
  """Push the appropriate screen based on configured providers."""
63
- # Show welcome screen if no providers are configured OR if user hasn't seen it yet
64
- config = self.config_manager.load()
65
- if (
66
- not self.config_manager.has_any_provider_key()
67
- or not config.shown_welcome_screen
68
- ):
69
- if isinstance(self.screen, WelcomeScreen):
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
+ )
70
97
  return
71
98
 
72
- self.push_screen(
73
- WelcomeScreen(),
74
- callback=lambda _arg: self.refresh_startup_screen(),
75
- )
76
- 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
107
+
108
+ self.push_screen(
109
+ WelcomeScreen(),
110
+ callback=lambda _arg: self.refresh_startup_screen(),
111
+ )
112
+ return
113
+
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
77
123
 
78
- if not self.check_local_shotgun_directory_exists():
79
- if isinstance(self.screen, DirectorySetupScreen):
124
+ if isinstance(self.screen, ChatScreen):
80
125
  return
81
126
 
82
- self.push_screen(
83
- DirectorySetupScreen(),
84
- callback=lambda _arg: self.refresh_startup_screen(),
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,
85
158
  )
86
- return
87
159
 
88
- if isinstance(self.screen, ChatScreen):
89
- return
90
- # Pass continue_session flag to ChatScreen
91
- 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)
92
168
 
93
169
  def check_local_shotgun_directory_exists(self) -> bool:
94
170
  shotgun_dir = get_shotgun_base_path()
@@ -105,28 +181,28 @@ class ShotgunApp(App[None]):
105
181
  def get_system_commands(self, screen: Screen[Any]) -> Iterable[SystemCommand]:
106
182
  return [
107
183
  SystemCommand(
108
- "Feedback", "Send us feedback or report a bug", self.action_feedback
184
+ "New Issue",
185
+ "Report a bug or request a feature on GitHub",
186
+ self.action_new_issue,
109
187
  )
110
- ] # we don't want any system commands
188
+ ]
111
189
 
112
- def action_feedback(self) -> None:
113
- """Open feedback screen and submit feedback."""
114
- from shotgun.posthog_telemetry import Feedback, submit_feedback_survey
190
+ def action_new_issue(self) -> None:
191
+ """Open GitHub issue screen to guide users to create an issue."""
192
+ self.push_screen(GitHubIssueScreen())
115
193
 
116
- def handle_feedback(feedback: Feedback | None) -> None:
117
- if feedback is not None:
118
- submit_feedback_survey(feedback)
119
- self.notify("Feedback sent. Thank you!")
120
194
 
121
- self.push_screen(FeedbackScreen(), callback=handle_feedback)
122
-
123
-
124
- 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:
125
200
  """Run the TUI application.
126
201
 
127
202
  Args:
128
203
  no_update_check: If True, disable automatic update checks.
129
204
  continue_session: If True, continue from previous conversation.
205
+ force_reindex: If True, force re-indexing of codebase (ignores existing index).
130
206
  """
131
207
  # Clean up any corrupted databases BEFORE starting the TUI
132
208
  # This prevents crashes from corrupted databases during initialization
@@ -148,9 +224,133 @@ def run(no_update_check: bool = False, continue_session: bool = False) -> None:
148
224
  logger.error(f"Failed to cleanup corrupted databases: {e}")
149
225
  # Continue anyway - the TUI can still function
150
226
 
151
- app = ShotgunApp(no_update_check=no_update_check, continue_session=continue_session)
227
+ app = ShotgunApp(
228
+ no_update_check=no_update_check,
229
+ continue_session=continue_session,
230
+ force_reindex=force_reindex,
231
+ )
152
232
  app.run(inline_no_clear=True)
153
233
 
154
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
+
155
355
  if __name__ == "__main__":
156
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
+ )