shotgun-sh 0.1.14__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.
- shotgun/agents/agent_manager.py +715 -75
- shotgun/agents/common.py +80 -75
- shotgun/agents/config/constants.py +21 -10
- shotgun/agents/config/manager.py +322 -97
- shotgun/agents/config/models.py +114 -84
- shotgun/agents/config/provider.py +232 -88
- shotgun/agents/context_analyzer/__init__.py +28 -0
- shotgun/agents/context_analyzer/analyzer.py +471 -0
- shotgun/agents/context_analyzer/constants.py +9 -0
- shotgun/agents/context_analyzer/formatter.py +115 -0
- shotgun/agents/context_analyzer/models.py +212 -0
- shotgun/agents/conversation_history.py +125 -2
- shotgun/agents/conversation_manager.py +57 -19
- shotgun/agents/export.py +6 -7
- shotgun/agents/history/compaction.py +10 -5
- shotgun/agents/history/context_extraction.py +93 -6
- shotgun/agents/history/history_processors.py +129 -12
- shotgun/agents/history/token_counting/__init__.py +31 -0
- shotgun/agents/history/token_counting/anthropic.py +127 -0
- shotgun/agents/history/token_counting/base.py +78 -0
- shotgun/agents/history/token_counting/openai.py +90 -0
- shotgun/agents/history/token_counting/sentencepiece_counter.py +127 -0
- shotgun/agents/history/token_counting/tokenizer_cache.py +92 -0
- shotgun/agents/history/token_counting/utils.py +144 -0
- shotgun/agents/history/token_estimation.py +12 -12
- shotgun/agents/llm.py +62 -0
- shotgun/agents/models.py +59 -4
- shotgun/agents/plan.py +6 -7
- shotgun/agents/research.py +7 -8
- shotgun/agents/specify.py +6 -7
- shotgun/agents/tasks.py +6 -7
- shotgun/agents/tools/__init__.py +0 -2
- shotgun/agents/tools/codebase/codebase_shell.py +6 -0
- shotgun/agents/tools/codebase/directory_lister.py +6 -0
- shotgun/agents/tools/codebase/file_read.py +11 -2
- shotgun/agents/tools/codebase/query_graph.py +6 -0
- shotgun/agents/tools/codebase/retrieve_code.py +6 -0
- shotgun/agents/tools/file_management.py +82 -16
- shotgun/agents/tools/registry.py +217 -0
- shotgun/agents/tools/web_search/__init__.py +55 -16
- shotgun/agents/tools/web_search/anthropic.py +76 -51
- shotgun/agents/tools/web_search/gemini.py +50 -27
- shotgun/agents/tools/web_search/openai.py +26 -17
- shotgun/agents/tools/web_search/utils.py +2 -2
- shotgun/agents/usage_manager.py +164 -0
- shotgun/api_endpoints.py +15 -0
- shotgun/cli/clear.py +53 -0
- shotgun/cli/compact.py +186 -0
- shotgun/cli/config.py +41 -67
- shotgun/cli/context.py +111 -0
- shotgun/cli/export.py +1 -1
- shotgun/cli/feedback.py +50 -0
- shotgun/cli/models.py +3 -2
- shotgun/cli/plan.py +1 -1
- shotgun/cli/research.py +1 -1
- shotgun/cli/specify.py +1 -1
- shotgun/cli/tasks.py +1 -1
- shotgun/cli/update.py +16 -2
- shotgun/codebase/core/change_detector.py +5 -3
- shotgun/codebase/core/code_retrieval.py +4 -2
- shotgun/codebase/core/ingestor.py +57 -16
- shotgun/codebase/core/manager.py +20 -7
- shotgun/codebase/core/nl_query.py +1 -1
- shotgun/codebase/models.py +4 -4
- shotgun/exceptions.py +32 -0
- shotgun/llm_proxy/__init__.py +19 -0
- shotgun/llm_proxy/clients.py +44 -0
- shotgun/llm_proxy/constants.py +15 -0
- shotgun/logging_config.py +18 -27
- shotgun/main.py +91 -12
- shotgun/posthog_telemetry.py +81 -10
- shotgun/prompts/agents/export.j2 +18 -1
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +5 -1
- shotgun/prompts/agents/partials/interactive_mode.j2 +24 -7
- shotgun/prompts/agents/plan.j2 +1 -1
- shotgun/prompts/agents/research.j2 +1 -1
- shotgun/prompts/agents/specify.j2 +270 -3
- shotgun/prompts/agents/state/system_state.j2 +4 -0
- shotgun/prompts/agents/tasks.j2 +1 -1
- shotgun/prompts/loader.py +2 -2
- shotgun/prompts/tools/web_search.j2 +14 -0
- shotgun/sentry_telemetry.py +27 -18
- shotgun/settings.py +238 -0
- shotgun/shotgun_web/__init__.py +19 -0
- shotgun/shotgun_web/client.py +138 -0
- shotgun/shotgun_web/constants.py +21 -0
- shotgun/shotgun_web/models.py +47 -0
- shotgun/telemetry.py +24 -36
- shotgun/tui/app.py +251 -23
- shotgun/tui/commands/__init__.py +1 -1
- shotgun/tui/components/context_indicator.py +179 -0
- shotgun/tui/components/mode_indicator.py +70 -0
- shotgun/tui/components/status_bar.py +48 -0
- shotgun/tui/containers.py +91 -0
- shotgun/tui/dependencies.py +39 -0
- shotgun/tui/protocols.py +45 -0
- shotgun/tui/screens/chat/__init__.py +5 -0
- shotgun/tui/screens/chat/chat.tcss +54 -0
- shotgun/tui/screens/chat/chat_screen.py +1234 -0
- shotgun/tui/screens/chat/codebase_index_prompt_screen.py +64 -0
- shotgun/tui/screens/chat/codebase_index_selection.py +12 -0
- shotgun/tui/screens/chat/help_text.py +40 -0
- shotgun/tui/screens/chat/prompt_history.py +48 -0
- shotgun/tui/screens/chat.tcss +11 -0
- shotgun/tui/screens/chat_screen/command_providers.py +226 -11
- shotgun/tui/screens/chat_screen/history/__init__.py +22 -0
- shotgun/tui/screens/chat_screen/history/agent_response.py +66 -0
- shotgun/tui/screens/chat_screen/history/chat_history.py +116 -0
- shotgun/tui/screens/chat_screen/history/formatters.py +115 -0
- shotgun/tui/screens/chat_screen/history/partial_response.py +43 -0
- shotgun/tui/screens/chat_screen/history/user_question.py +42 -0
- shotgun/tui/screens/confirmation_dialog.py +151 -0
- shotgun/tui/screens/feedback.py +193 -0
- shotgun/tui/screens/github_issue.py +102 -0
- shotgun/tui/screens/model_picker.py +352 -0
- shotgun/tui/screens/onboarding.py +431 -0
- shotgun/tui/screens/pipx_migration.py +153 -0
- shotgun/tui/screens/provider_config.py +156 -39
- shotgun/tui/screens/shotgun_auth.py +295 -0
- shotgun/tui/screens/welcome.py +198 -0
- shotgun/tui/services/__init__.py +5 -0
- shotgun/tui/services/conversation_service.py +184 -0
- shotgun/tui/state/__init__.py +7 -0
- shotgun/tui/state/processing_state.py +185 -0
- shotgun/tui/utils/mode_progress.py +14 -7
- shotgun/tui/widgets/__init__.py +5 -0
- shotgun/tui/widgets/widget_coordinator.py +262 -0
- shotgun/utils/datetime_utils.py +77 -0
- shotgun/utils/env_utils.py +13 -0
- shotgun/utils/file_system_utils.py +22 -2
- shotgun/utils/marketing.py +110 -0
- shotgun/utils/update_checker.py +69 -14
- shotgun_sh-0.2.11.dist-info/METADATA +130 -0
- shotgun_sh-0.2.11.dist-info/RECORD +194 -0
- {shotgun_sh-0.1.14.dist-info → shotgun_sh-0.2.11.dist-info}/entry_points.txt +1 -0
- {shotgun_sh-0.1.14.dist-info → shotgun_sh-0.2.11.dist-info}/licenses/LICENSE +1 -1
- shotgun/agents/history/token_counting.py +0 -429
- shotgun/agents/tools/user_interaction.py +0 -37
- shotgun/tui/screens/chat.py +0 -797
- shotgun/tui/screens/chat_screen/history.py +0 -350
- shotgun_sh-0.1.14.dist-info/METADATA +0 -466
- shotgun_sh-0.1.14.dist-info/RECORD +0 -133
- {shotgun_sh-0.1.14.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
|
|
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,
|
|
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(
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
65
117
|
|
|
66
|
-
|
|
67
|
-
|
|
118
|
+
self.push_screen(
|
|
119
|
+
DirectorySetupScreen(),
|
|
120
|
+
callback=lambda _arg: self.refresh_startup_screen(),
|
|
121
|
+
)
|
|
68
122
|
return
|
|
69
123
|
|
|
70
|
-
self.
|
|
71
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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()
|
|
@@ -90,15 +179,30 @@ class ShotgunApp(App[None]):
|
|
|
90
179
|
self.exit()
|
|
91
180
|
|
|
92
181
|
def get_system_commands(self, screen: Screen[Any]) -> Iterable[SystemCommand]:
|
|
93
|
-
return [
|
|
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())
|
|
94
193
|
|
|
95
194
|
|
|
96
|
-
def run(
|
|
195
|
+
def run(
|
|
196
|
+
no_update_check: bool = False,
|
|
197
|
+
continue_session: bool = False,
|
|
198
|
+
force_reindex: bool = False,
|
|
199
|
+
) -> None:
|
|
97
200
|
"""Run the TUI application.
|
|
98
201
|
|
|
99
202
|
Args:
|
|
100
203
|
no_update_check: If True, disable automatic update checks.
|
|
101
204
|
continue_session: If True, continue from previous conversation.
|
|
205
|
+
force_reindex: If True, force re-indexing of codebase (ignores existing index).
|
|
102
206
|
"""
|
|
103
207
|
# Clean up any corrupted databases BEFORE starting the TUI
|
|
104
208
|
# This prevents crashes from corrupted databases during initialization
|
|
@@ -120,9 +224,133 @@ def run(no_update_check: bool = False, continue_session: bool = False) -> None:
|
|
|
120
224
|
logger.error(f"Failed to cleanup corrupted databases: {e}")
|
|
121
225
|
# Continue anyway - the TUI can still function
|
|
122
226
|
|
|
123
|
-
app = ShotgunApp(
|
|
227
|
+
app = ShotgunApp(
|
|
228
|
+
no_update_check=no_update_check,
|
|
229
|
+
continue_session=continue_session,
|
|
230
|
+
force_reindex=force_reindex,
|
|
231
|
+
)
|
|
124
232
|
app.run(inline_no_clear=True)
|
|
125
233
|
|
|
126
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
|
+
|
|
127
355
|
if __name__ == "__main__":
|
|
128
356
|
run()
|
shotgun/tui/commands/__init__.py
CHANGED
|
@@ -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
|
+
)
|