shotgun-sh 0.2.3.dev2__py3-none-any.whl → 0.2.11.dev1__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 (107) hide show
  1. shotgun/agents/agent_manager.py +524 -58
  2. shotgun/agents/common.py +62 -62
  3. shotgun/agents/config/constants.py +0 -6
  4. shotgun/agents/config/manager.py +14 -3
  5. shotgun/agents/config/models.py +16 -0
  6. shotgun/agents/config/provider.py +68 -13
  7. shotgun/agents/context_analyzer/__init__.py +28 -0
  8. shotgun/agents/context_analyzer/analyzer.py +493 -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 +24 -2
  14. shotgun/agents/export.py +4 -5
  15. shotgun/agents/history/compaction.py +9 -4
  16. shotgun/agents/history/context_extraction.py +93 -6
  17. shotgun/agents/history/history_processors.py +14 -2
  18. shotgun/agents/history/token_counting/anthropic.py +32 -10
  19. shotgun/agents/models.py +50 -2
  20. shotgun/agents/plan.py +4 -5
  21. shotgun/agents/research.py +4 -5
  22. shotgun/agents/specify.py +4 -5
  23. shotgun/agents/tasks.py +4 -5
  24. shotgun/agents/tools/__init__.py +0 -2
  25. shotgun/agents/tools/codebase/codebase_shell.py +6 -0
  26. shotgun/agents/tools/codebase/directory_lister.py +6 -0
  27. shotgun/agents/tools/codebase/file_read.py +6 -0
  28. shotgun/agents/tools/codebase/query_graph.py +6 -0
  29. shotgun/agents/tools/codebase/retrieve_code.py +6 -0
  30. shotgun/agents/tools/file_management.py +71 -9
  31. shotgun/agents/tools/registry.py +217 -0
  32. shotgun/agents/tools/web_search/__init__.py +24 -12
  33. shotgun/agents/tools/web_search/anthropic.py +24 -3
  34. shotgun/agents/tools/web_search/gemini.py +22 -10
  35. shotgun/agents/tools/web_search/openai.py +21 -12
  36. shotgun/api_endpoints.py +7 -3
  37. shotgun/build_constants.py +1 -1
  38. shotgun/cli/clear.py +52 -0
  39. shotgun/cli/compact.py +186 -0
  40. shotgun/cli/context.py +111 -0
  41. shotgun/cli/models.py +1 -0
  42. shotgun/cli/update.py +16 -2
  43. shotgun/codebase/core/manager.py +10 -1
  44. shotgun/llm_proxy/__init__.py +5 -2
  45. shotgun/llm_proxy/clients.py +12 -7
  46. shotgun/logging_config.py +8 -10
  47. shotgun/main.py +70 -10
  48. shotgun/posthog_telemetry.py +9 -3
  49. shotgun/prompts/agents/export.j2 +18 -1
  50. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +5 -1
  51. shotgun/prompts/agents/partials/interactive_mode.j2 +24 -7
  52. shotgun/prompts/agents/plan.j2 +1 -1
  53. shotgun/prompts/agents/research.j2 +1 -1
  54. shotgun/prompts/agents/specify.j2 +270 -3
  55. shotgun/prompts/agents/state/system_state.j2 +4 -0
  56. shotgun/prompts/agents/tasks.j2 +1 -1
  57. shotgun/prompts/loader.py +2 -2
  58. shotgun/prompts/tools/web_search.j2 +14 -0
  59. shotgun/sentry_telemetry.py +4 -15
  60. shotgun/settings.py +238 -0
  61. shotgun/telemetry.py +15 -32
  62. shotgun/tui/app.py +203 -9
  63. shotgun/tui/commands/__init__.py +1 -1
  64. shotgun/tui/components/context_indicator.py +136 -0
  65. shotgun/tui/components/mode_indicator.py +70 -0
  66. shotgun/tui/components/status_bar.py +48 -0
  67. shotgun/tui/containers.py +93 -0
  68. shotgun/tui/dependencies.py +39 -0
  69. shotgun/tui/protocols.py +45 -0
  70. shotgun/tui/screens/chat/__init__.py +5 -0
  71. shotgun/tui/screens/chat/chat.tcss +54 -0
  72. shotgun/tui/screens/chat/chat_screen.py +1110 -0
  73. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +64 -0
  74. shotgun/tui/screens/chat/codebase_index_selection.py +12 -0
  75. shotgun/tui/screens/chat/help_text.py +39 -0
  76. shotgun/tui/screens/chat/prompt_history.py +48 -0
  77. shotgun/tui/screens/chat.tcss +11 -0
  78. shotgun/tui/screens/chat_screen/command_providers.py +68 -2
  79. shotgun/tui/screens/chat_screen/history/__init__.py +22 -0
  80. shotgun/tui/screens/chat_screen/history/agent_response.py +66 -0
  81. shotgun/tui/screens/chat_screen/history/chat_history.py +116 -0
  82. shotgun/tui/screens/chat_screen/history/formatters.py +115 -0
  83. shotgun/tui/screens/chat_screen/history/partial_response.py +43 -0
  84. shotgun/tui/screens/chat_screen/history/user_question.py +42 -0
  85. shotgun/tui/screens/confirmation_dialog.py +151 -0
  86. shotgun/tui/screens/model_picker.py +30 -6
  87. shotgun/tui/screens/pipx_migration.py +153 -0
  88. shotgun/tui/screens/welcome.py +24 -5
  89. shotgun/tui/services/__init__.py +5 -0
  90. shotgun/tui/services/conversation_service.py +182 -0
  91. shotgun/tui/state/__init__.py +7 -0
  92. shotgun/tui/state/processing_state.py +185 -0
  93. shotgun/tui/widgets/__init__.py +5 -0
  94. shotgun/tui/widgets/widget_coordinator.py +247 -0
  95. shotgun/utils/datetime_utils.py +77 -0
  96. shotgun/utils/file_system_utils.py +3 -2
  97. shotgun/utils/update_checker.py +69 -14
  98. shotgun_sh-0.2.11.dev1.dist-info/METADATA +129 -0
  99. shotgun_sh-0.2.11.dev1.dist-info/RECORD +190 -0
  100. {shotgun_sh-0.2.3.dev2.dist-info → shotgun_sh-0.2.11.dev1.dist-info}/entry_points.txt +1 -0
  101. {shotgun_sh-0.2.3.dev2.dist-info → shotgun_sh-0.2.11.dev1.dist-info}/licenses/LICENSE +1 -1
  102. shotgun/agents/tools/user_interaction.py +0 -37
  103. shotgun/tui/screens/chat.py +0 -804
  104. shotgun/tui/screens/chat_screen/history.py +0 -352
  105. shotgun_sh-0.2.3.dev2.dist-info/METADATA +0 -467
  106. shotgun_sh-0.2.3.dev2.dist-info/RECORD +0 -154
  107. {shotgun_sh-0.2.3.dev2.dist-info → shotgun_sh-0.2.11.dev1.dist-info}/WHEEL +0 -0
shotgun/telemetry.py CHANGED
@@ -1,9 +1,7 @@
1
1
  """Observability setup for Logfire."""
2
2
 
3
- import os
4
-
5
3
  from shotgun.logging_config import get_early_logger
6
- from shotgun.utils.env_utils import is_falsy, is_truthy
4
+ from shotgun.settings import settings
7
5
 
8
6
  # Use early logger to prevent automatic StreamHandler creation
9
7
  logger = get_early_logger(__name__)
@@ -15,36 +13,13 @@ def setup_logfire_observability() -> bool:
15
13
  Returns:
16
14
  True if Logfire was successfully set up, False otherwise
17
15
  """
18
- # Try to get Logfire configuration from build constants first, fall back to env vars
19
- logfire_enabled = None
20
- logfire_token = None
21
-
22
- try:
23
- from shotgun.build_constants import LOGFIRE_ENABLED, LOGFIRE_TOKEN
24
-
25
- # Use build constants if they're not empty
26
- if LOGFIRE_ENABLED:
27
- logfire_enabled = LOGFIRE_ENABLED
28
- if LOGFIRE_TOKEN:
29
- logfire_token = LOGFIRE_TOKEN
30
- except ImportError:
31
- # No build constants available
32
- pass
33
-
34
- # Fall back to environment variables if not set from build constants
35
- if not logfire_enabled:
36
- logfire_enabled = os.getenv("LOGFIRE_ENABLED", "false")
37
- if not logfire_token:
38
- logfire_token = os.getenv("LOGFIRE_TOKEN")
39
-
40
- # Allow environment variable to override and disable Logfire
41
- env_override = os.getenv("LOGFIRE_ENABLED")
42
- if env_override and is_falsy(env_override):
43
- logfire_enabled = env_override
16
+ # Get Logfire configuration from settings (handles build constants + env vars)
17
+ logfire_enabled = settings.telemetry.logfire_enabled
18
+ logfire_token = settings.telemetry.logfire_token
44
19
 
45
20
  # Check if Logfire observability is enabled
46
- if not is_truthy(logfire_enabled):
47
- logger.debug("Logfire observability disabled via LOGFIRE_ENABLED")
21
+ if not logfire_enabled:
22
+ logger.debug("Logfire observability disabled")
48
23
  return False
49
24
 
50
25
  try:
@@ -52,7 +27,7 @@ def setup_logfire_observability() -> bool:
52
27
 
53
28
  # Check for Logfire token
54
29
  if not logfire_token:
55
- logger.warning("LOGFIRE_TOKEN not set, Logfire observability disabled")
30
+ logger.warning("Logfire token not set, Logfire observability disabled")
56
31
  return False
57
32
 
58
33
  # Configure Logfire
@@ -65,6 +40,14 @@ def setup_logfire_observability() -> bool:
65
40
  # Instrument Pydantic AI for better observability
66
41
  logfire.instrument_pydantic_ai()
67
42
 
43
+ # Add LogfireLoggingHandler to root logger so logfire logs also go to file
44
+ import logging
45
+
46
+ root_logger = logging.getLogger()
47
+ logfire_handler = logfire.LogfireLoggingHandler()
48
+ root_logger.addHandler(logfire_handler)
49
+ logger.debug("Added LogfireLoggingHandler to root logger for file integration")
50
+
68
51
  # Set user context using baggage for all logs and spans
69
52
  try:
70
53
  from opentelemetry import baggage, context
shotgun/tui/app.py CHANGED
@@ -6,15 +6,21 @@ from textual.binding import Binding
6
6
  from textual.screen import Screen
7
7
 
8
8
  from shotgun.agents.config import ConfigManager, get_config_manager
9
+ from shotgun.agents.models import AgentType
9
10
  from shotgun.logging_config import get_logger
11
+ from shotgun.tui.containers import TUIContainer
10
12
  from shotgun.tui.screens.splash import SplashScreen
11
13
  from shotgun.utils.file_system_utils import get_shotgun_base_path
12
- from shotgun.utils.update_checker import perform_auto_update_async
14
+ from shotgun.utils.update_checker import (
15
+ detect_installation_method,
16
+ perform_auto_update_async,
17
+ )
13
18
 
14
19
  from .screens.chat import ChatScreen
15
20
  from .screens.directory_setup import DirectorySetupScreen
16
21
  from .screens.feedback import FeedbackScreen
17
22
  from .screens.model_picker import ModelPickerScreen
23
+ from .screens.pipx_migration import PipxMigrationScreen
18
24
  from .screens.provider_config import ProviderConfigScreen
19
25
  from .screens.welcome import WelcomeScreen
20
26
 
@@ -22,8 +28,9 @@ logger = get_logger(__name__)
22
28
 
23
29
 
24
30
  class ShotgunApp(App[None]):
31
+ # ChatScreen removed from SCREENS dict since it requires dependency injection
32
+ # and is instantiated manually in refresh_startup_screen()
25
33
  SCREENS = {
26
- "chat": ChatScreen,
27
34
  "provider_config": ProviderConfigScreen,
28
35
  "model_picker": ModelPickerScreen,
29
36
  "directory_setup": DirectorySetupScreen,
@@ -36,12 +43,19 @@ class ShotgunApp(App[None]):
36
43
  CSS_PATH = "styles.tcss"
37
44
 
38
45
  def __init__(
39
- self, no_update_check: bool = False, continue_session: bool = False
46
+ self,
47
+ no_update_check: bool = False,
48
+ continue_session: bool = False,
49
+ force_reindex: bool = False,
40
50
  ) -> None:
41
51
  super().__init__()
42
52
  self.config_manager: ConfigManager = get_config_manager()
43
53
  self.no_update_check = no_update_check
44
54
  self.continue_session = continue_session
55
+ self.force_reindex = force_reindex
56
+
57
+ # Initialize dependency injection container
58
+ self.container = TUIContainer()
45
59
 
46
60
  # Start async update check and install
47
61
  if not no_update_check:
@@ -52,14 +66,35 @@ class ShotgunApp(App[None]):
52
66
  # Track TUI startup
53
67
  from shotgun.posthog_telemetry import track_event
54
68
 
55
- track_event("tui_started", {})
69
+ track_event(
70
+ "tui_started",
71
+ {
72
+ "installation_method": detect_installation_method(),
73
+ },
74
+ )
56
75
 
57
76
  self.push_screen(
58
77
  SplashScreen(), callback=lambda _arg: self.refresh_startup_screen()
59
78
  )
60
79
 
61
- def refresh_startup_screen(self) -> None:
80
+ def refresh_startup_screen(self, skip_pipx_check: bool = False) -> None:
62
81
  """Push the appropriate screen based on configured providers."""
82
+ # Check for pipx installation and show migration modal first
83
+ if not skip_pipx_check:
84
+ installation_method = detect_installation_method()
85
+ if installation_method == "pipx":
86
+ if isinstance(self.screen, PipxMigrationScreen):
87
+ return
88
+
89
+ # Show pipx migration modal as a blocking modal screen
90
+ self.push_screen(
91
+ PipxMigrationScreen(),
92
+ callback=lambda _arg: self.refresh_startup_screen(
93
+ skip_pipx_check=True
94
+ ),
95
+ )
96
+ return
97
+
63
98
  # Show welcome screen if no providers are configured OR if user hasn't seen it yet
64
99
  config = self.config_manager.load()
65
100
  if (
@@ -87,8 +122,38 @@ class ShotgunApp(App[None]):
87
122
 
88
123
  if isinstance(self.screen, ChatScreen):
89
124
  return
90
- # Pass continue_session flag to ChatScreen
91
- self.push_screen(ChatScreen(continue_session=self.continue_session))
125
+
126
+ # Create ChatScreen with all dependencies injected from container
127
+ # Get the default agent mode (RESEARCH)
128
+ agent_mode = AgentType.RESEARCH
129
+
130
+ # Create AgentManager with the correct mode
131
+ agent_manager = self.container.agent_manager_factory(initial_type=agent_mode)
132
+
133
+ # Create ProcessingStateManager - we'll pass the screen after creation
134
+ # For now, create with None and the ChatScreen will set itself
135
+ chat_screen = ChatScreen(
136
+ agent_manager=agent_manager,
137
+ conversation_manager=self.container.conversation_manager(),
138
+ conversation_service=self.container.conversation_service(),
139
+ widget_coordinator=self.container.widget_coordinator_factory(screen=None),
140
+ processing_state=self.container.processing_state_factory(
141
+ screen=None, # Will be set after ChatScreen is created
142
+ telemetry_context={"agent_mode": agent_mode.value},
143
+ ),
144
+ command_handler=self.container.command_handler(),
145
+ placeholder_hints=self.container.placeholder_hints(),
146
+ codebase_sdk=self.container.codebase_sdk(),
147
+ deps=self.container.agent_deps(),
148
+ continue_session=self.continue_session,
149
+ force_reindex=self.force_reindex,
150
+ )
151
+
152
+ # Update the ProcessingStateManager and WidgetCoordinator with the actual ChatScreen instance
153
+ chat_screen.processing_state.screen = chat_screen
154
+ chat_screen.widget_coordinator.screen = chat_screen
155
+
156
+ self.push_screen(chat_screen)
92
157
 
93
158
  def check_local_shotgun_directory_exists(self) -> bool:
94
159
  shotgun_dir = get_shotgun_base_path()
@@ -121,12 +186,17 @@ class ShotgunApp(App[None]):
121
186
  self.push_screen(FeedbackScreen(), callback=handle_feedback)
122
187
 
123
188
 
124
- def run(no_update_check: bool = False, continue_session: bool = False) -> None:
189
+ def run(
190
+ no_update_check: bool = False,
191
+ continue_session: bool = False,
192
+ force_reindex: bool = False,
193
+ ) -> None:
125
194
  """Run the TUI application.
126
195
 
127
196
  Args:
128
197
  no_update_check: If True, disable automatic update checks.
129
198
  continue_session: If True, continue from previous conversation.
199
+ force_reindex: If True, force re-indexing of codebase (ignores existing index).
130
200
  """
131
201
  # Clean up any corrupted databases BEFORE starting the TUI
132
202
  # This prevents crashes from corrupted databases during initialization
@@ -148,9 +218,133 @@ def run(no_update_check: bool = False, continue_session: bool = False) -> None:
148
218
  logger.error(f"Failed to cleanup corrupted databases: {e}")
149
219
  # Continue anyway - the TUI can still function
150
220
 
151
- app = ShotgunApp(no_update_check=no_update_check, continue_session=continue_session)
221
+ app = ShotgunApp(
222
+ no_update_check=no_update_check,
223
+ continue_session=continue_session,
224
+ force_reindex=force_reindex,
225
+ )
152
226
  app.run(inline_no_clear=True)
153
227
 
154
228
 
229
+ def serve(
230
+ host: str = "localhost",
231
+ port: int = 8000,
232
+ public_url: str | None = None,
233
+ no_update_check: bool = False,
234
+ continue_session: bool = False,
235
+ force_reindex: bool = False,
236
+ ) -> None:
237
+ """Serve the TUI application as a web application.
238
+
239
+ Args:
240
+ host: Host address for the web server.
241
+ port: Port number for the web server.
242
+ public_url: Public URL if behind a proxy.
243
+ no_update_check: If True, disable automatic update checks.
244
+ continue_session: If True, continue from previous conversation.
245
+ force_reindex: If True, force re-indexing of codebase (ignores existing index).
246
+ """
247
+ # Clean up any corrupted databases BEFORE starting the TUI
248
+ # This prevents crashes from corrupted databases during initialization
249
+ import asyncio
250
+
251
+ from textual_serve.server import Server
252
+
253
+ from shotgun.codebase.core.manager import CodebaseGraphManager
254
+ from shotgun.utils import get_shotgun_home
255
+
256
+ storage_dir = get_shotgun_home() / "codebases"
257
+ manager = CodebaseGraphManager(storage_dir)
258
+
259
+ try:
260
+ removed = asyncio.run(manager.cleanup_corrupted_databases())
261
+ if removed:
262
+ logger.info(
263
+ f"Cleaned up {len(removed)} corrupted database(s) before TUI startup"
264
+ )
265
+ except Exception as e:
266
+ logger.error(f"Failed to cleanup corrupted databases: {e}")
267
+ # Continue anyway - the TUI can still function
268
+
269
+ # Create a new event loop after asyncio.run() closes the previous one
270
+ # This is needed for the Server.serve() method
271
+ loop = asyncio.new_event_loop()
272
+ asyncio.set_event_loop(loop)
273
+
274
+ # Build the command string based on flags
275
+ command = "shotgun"
276
+ if no_update_check:
277
+ command += " --no-update-check"
278
+ if continue_session:
279
+ command += " --continue"
280
+ if force_reindex:
281
+ command += " --force-reindex"
282
+
283
+ # Create and start the server with hardcoded title and debug=False
284
+ server = Server(
285
+ command=command,
286
+ host=host,
287
+ port=port,
288
+ title="The Shotgun",
289
+ public_url=public_url,
290
+ )
291
+
292
+ # Set up graceful shutdown on SIGTERM/SIGINT
293
+ import signal
294
+ import sys
295
+
296
+ def signal_handler(_signum: int, _frame: Any) -> None:
297
+ """Handle shutdown signals gracefully."""
298
+ from shotgun.posthog_telemetry import shutdown
299
+
300
+ logger.info("Received shutdown signal, cleaning up...")
301
+ # Restore stdout/stderr before shutting down
302
+ sys.stdout = original_stdout
303
+ sys.stderr = original_stderr
304
+ shutdown()
305
+ sys.exit(0)
306
+
307
+ signal.signal(signal.SIGTERM, signal_handler)
308
+ signal.signal(signal.SIGINT, signal_handler)
309
+
310
+ # Suppress the textual-serve banner by redirecting stdout/stderr
311
+ import io
312
+
313
+ # Capture and suppress the banner, but show the actual serving URL
314
+ original_stdout = sys.stdout
315
+ original_stderr = sys.stderr
316
+
317
+ captured_output = io.StringIO()
318
+ sys.stdout = captured_output
319
+ sys.stderr = captured_output
320
+
321
+ try:
322
+ # This will print the banner to our captured output
323
+ import logging
324
+
325
+ # Temporarily set logging to ERROR level to suppress INFO messages
326
+ textual_serve_logger = logging.getLogger("textual_serve")
327
+ original_level = textual_serve_logger.level
328
+ textual_serve_logger.setLevel(logging.ERROR)
329
+
330
+ # Print our own message to the original stdout
331
+ sys.stdout = original_stdout
332
+ sys.stderr = original_stderr
333
+ print(f"Serving Shotgun TUI at http://{host}:{port}")
334
+ print("Press Ctrl+C to quit")
335
+
336
+ # Now suppress output again for the serve call
337
+ sys.stdout = captured_output
338
+ sys.stderr = captured_output
339
+
340
+ server.serve(debug=False)
341
+ finally:
342
+ # Restore original stdout/stderr
343
+ sys.stdout = original_stdout
344
+ sys.stderr = original_stderr
345
+ if "textual_serve_logger" in locals():
346
+ textual_serve_logger.setLevel(original_level)
347
+
348
+
155
349
  if __name__ == "__main__":
156
350
  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,136 @@
1
+ """Context window indicator component for showing model usage."""
2
+
3
+ from textual.reactive import reactive
4
+ from textual.widgets import Static
5
+
6
+ from shotgun.agents.config.models import MODEL_SPECS, ModelName
7
+ from shotgun.agents.context_analyzer.models import ContextAnalysis
8
+
9
+
10
+ class ContextIndicator(Static):
11
+ """Display context window usage and current model name."""
12
+
13
+ DEFAULT_CSS = """
14
+ ContextIndicator {
15
+ width: auto;
16
+ height: 1;
17
+ text-align: right;
18
+ }
19
+ """
20
+
21
+ context_analysis: reactive[ContextAnalysis | None] = reactive(None)
22
+ model_name: reactive[ModelName | None] = reactive(None)
23
+
24
+ def __init__(
25
+ self,
26
+ *,
27
+ name: str | None = None,
28
+ id: str | None = None,
29
+ classes: str | None = None,
30
+ ) -> None:
31
+ super().__init__(name=name, id=id, classes=classes)
32
+
33
+ def update_context(
34
+ self, analysis: ContextAnalysis | None, model: ModelName | None
35
+ ) -> None:
36
+ """Update the context indicator with new analysis and model data.
37
+
38
+ Args:
39
+ analysis: Context analysis with token usage data
40
+ model: Current model name
41
+ """
42
+ self.context_analysis = analysis
43
+ self.model_name = model
44
+ self._refresh_display()
45
+
46
+ def _get_percentage_color(self, percentage: float) -> str:
47
+ """Get color for percentage based on threshold.
48
+
49
+ Args:
50
+ percentage: Usage percentage (0-100)
51
+
52
+ Returns:
53
+ Color name for Textual markup
54
+ """
55
+ if percentage < 60:
56
+ return "#00ff00" # Green
57
+ elif percentage < 85:
58
+ return "#ffff00" # Yellow
59
+ else:
60
+ return "#ff0000" # Red
61
+
62
+ def _format_token_count(self, tokens: int) -> str:
63
+ """Format token count for display (e.g., 115000 -> "115K").
64
+
65
+ Args:
66
+ tokens: Token count
67
+
68
+ Returns:
69
+ Formatted string
70
+ """
71
+ if tokens >= 1_000_000:
72
+ return f"{tokens / 1_000_000:.1f}M"
73
+ elif tokens >= 1_000:
74
+ return f"{tokens / 1_000:.0f}K"
75
+ else:
76
+ return str(tokens)
77
+
78
+ def _refresh_display(self) -> None:
79
+ """Refresh the display with current context data."""
80
+ # If no analysis yet, show placeholder with model name or empty
81
+ if self.context_analysis is None:
82
+ if self.model_name:
83
+ model_spec = MODEL_SPECS.get(self.model_name)
84
+ model_display = (
85
+ model_spec.short_name if model_spec else str(self.model_name)
86
+ )
87
+ self.update(f"[bold]{model_display}[/bold]")
88
+ else:
89
+ self.update("")
90
+ return
91
+
92
+ analysis = self.context_analysis
93
+
94
+ # Calculate percentage
95
+ if analysis.max_usable_tokens > 0:
96
+ percentage = round(
97
+ (analysis.agent_context_tokens / analysis.max_usable_tokens) * 100, 1
98
+ )
99
+ else:
100
+ percentage = 0.0
101
+
102
+ # Format token counts
103
+ current_tokens = self._format_token_count(analysis.agent_context_tokens)
104
+ max_tokens = self._format_token_count(analysis.max_usable_tokens)
105
+
106
+ # Get color based on percentage
107
+ color = self._get_percentage_color(percentage)
108
+
109
+ # Build the display string - always show full context info
110
+ parts = [
111
+ "[$foreground-muted]Context window:[/]",
112
+ f"[{color}]{percentage}% ({current_tokens}/{max_tokens})[/]",
113
+ ]
114
+
115
+ # Add model name if available
116
+ if self.model_name:
117
+ model_spec = MODEL_SPECS.get(self.model_name)
118
+ model_display = (
119
+ model_spec.short_name if model_spec else str(self.model_name)
120
+ )
121
+ parts.extend(
122
+ [
123
+ "[$foreground-muted]|[/]",
124
+ f"[bold]{model_display}[/bold]",
125
+ ]
126
+ )
127
+
128
+ self.update(" ".join(parts))
129
+
130
+ def watch_context_analysis(self, analysis: ContextAnalysis | None) -> None:
131
+ """React to context analysis changes."""
132
+ self._refresh_display()
133
+
134
+ def watch_model_name(self, model: ModelName | None) -> None:
135
+ """React to model name changes."""
136
+ 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
+ )