shotgun-sh 0.2.11.dev7__py3-none-any.whl → 0.2.19__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 +22 -11
- shotgun/agents/config/README.md +89 -0
- shotgun/agents/config/__init__.py +10 -1
- shotgun/agents/config/manager.py +287 -32
- shotgun/agents/config/models.py +17 -1
- shotgun/agents/config/provider.py +27 -0
- shotgun/agents/config/streaming_test.py +119 -0
- shotgun/build_constants.py +3 -3
- shotgun/logging_config.py +42 -0
- shotgun/main.py +2 -0
- shotgun/posthog_telemetry.py +18 -25
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +3 -2
- shotgun/sentry_telemetry.py +140 -2
- shotgun/settings.py +5 -0
- shotgun/tui/app.py +7 -1
- shotgun/tui/screens/chat/chat_screen.py +66 -35
- shotgun/tui/screens/chat_screen/command_providers.py +3 -2
- shotgun/tui/screens/chat_screen/history/chat_history.py +1 -2
- shotgun/tui/screens/directory_setup.py +14 -5
- shotgun/tui/screens/feedback.py +10 -3
- shotgun/tui/screens/github_issue.py +11 -2
- shotgun/tui/screens/model_picker.py +8 -1
- shotgun/tui/screens/pipx_migration.py +12 -6
- shotgun/tui/screens/provider_config.py +25 -8
- shotgun/tui/screens/shotgun_auth.py +0 -10
- shotgun/tui/screens/welcome.py +32 -0
- shotgun/tui/widgets/widget_coordinator.py +3 -2
- shotgun_sh-0.2.19.dist-info/METADATA +465 -0
- {shotgun_sh-0.2.11.dev7.dist-info → shotgun_sh-0.2.19.dist-info}/RECORD +32 -30
- shotgun_sh-0.2.11.dev7.dist-info/METADATA +0 -130
- {shotgun_sh-0.2.11.dev7.dist-info → shotgun_sh-0.2.19.dist-info}/WHEEL +0 -0
- {shotgun_sh-0.2.11.dev7.dist-info → shotgun_sh-0.2.19.dist-info}/entry_points.txt +0 -0
- {shotgun_sh-0.2.11.dev7.dist-info → shotgun_sh-0.2.19.dist-info}/licenses/LICENSE +0 -0
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import logging
|
|
5
|
+
import time
|
|
5
6
|
from datetime import datetime, timezone
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
from typing import cast
|
|
@@ -11,6 +12,7 @@ from pydantic_ai.messages import (
|
|
|
11
12
|
ModelRequest,
|
|
12
13
|
ModelResponse,
|
|
13
14
|
TextPart,
|
|
15
|
+
ToolCallPart,
|
|
14
16
|
ToolReturnPart,
|
|
15
17
|
UserPromptPart,
|
|
16
18
|
)
|
|
@@ -102,7 +104,6 @@ class ChatScreen(Screen[None]):
|
|
|
102
104
|
history: PromptHistory = PromptHistory()
|
|
103
105
|
messages = reactive(list[ModelMessage | HintMessage]())
|
|
104
106
|
indexing_job: reactive[CodebaseIndexSelection | None] = reactive(None)
|
|
105
|
-
partial_message: reactive[ModelMessage | None] = reactive(None)
|
|
106
107
|
|
|
107
108
|
# Q&A mode state (for structured output clarifying questions)
|
|
108
109
|
qa_mode = reactive(False)
|
|
@@ -113,6 +114,10 @@ class ChatScreen(Screen[None]):
|
|
|
113
114
|
# Working state - keep reactive for Textual watchers
|
|
114
115
|
working = reactive(False)
|
|
115
116
|
|
|
117
|
+
# Throttle context indicator updates (in seconds)
|
|
118
|
+
_last_context_update: float = 0.0
|
|
119
|
+
_context_update_throttle: float = 5.0 # 5 seconds
|
|
120
|
+
|
|
116
121
|
def __init__(
|
|
117
122
|
self,
|
|
118
123
|
agent_manager: AgentManager,
|
|
@@ -279,10 +284,8 @@ class ChatScreen(Screen[None]):
|
|
|
279
284
|
def action_toggle_mode(self) -> None:
|
|
280
285
|
# Prevent mode switching during Q&A
|
|
281
286
|
if self.qa_mode:
|
|
282
|
-
self.
|
|
283
|
-
"Cannot switch modes while answering questions"
|
|
284
|
-
severity="warning",
|
|
285
|
-
timeout=3,
|
|
287
|
+
self.agent_manager.add_hint_message(
|
|
288
|
+
HintMessage(message="⚠️ Cannot switch modes while answering questions")
|
|
286
289
|
)
|
|
287
290
|
return
|
|
288
291
|
|
|
@@ -304,14 +307,18 @@ class ChatScreen(Screen[None]):
|
|
|
304
307
|
if usage_hint:
|
|
305
308
|
self.mount_hint(usage_hint)
|
|
306
309
|
else:
|
|
307
|
-
self.
|
|
310
|
+
self.agent_manager.add_hint_message(
|
|
311
|
+
HintMessage(message="⚠️ No usage hint available")
|
|
312
|
+
)
|
|
308
313
|
|
|
309
314
|
async def action_show_context(self) -> None:
|
|
310
315
|
context_hint = await self.agent_manager.get_context_hint()
|
|
311
316
|
if context_hint:
|
|
312
317
|
self.mount_hint(context_hint)
|
|
313
318
|
else:
|
|
314
|
-
self.
|
|
319
|
+
self.agent_manager.add_hint_message(
|
|
320
|
+
HintMessage(message="⚠️ No context analysis available")
|
|
321
|
+
)
|
|
315
322
|
|
|
316
323
|
def action_view_onboarding(self) -> None:
|
|
317
324
|
"""Show the onboarding modal."""
|
|
@@ -436,7 +443,9 @@ class ChatScreen(Screen[None]):
|
|
|
436
443
|
|
|
437
444
|
except Exception as e:
|
|
438
445
|
logger.error(f"Failed to compact conversation: {e}", exc_info=True)
|
|
439
|
-
self.
|
|
446
|
+
self.agent_manager.add_hint_message(
|
|
447
|
+
HintMessage(message=f"❌ Failed to compact: {e}")
|
|
448
|
+
)
|
|
440
449
|
finally:
|
|
441
450
|
# Hide spinner
|
|
442
451
|
self.processing_state.stop_processing()
|
|
@@ -484,7 +493,9 @@ class ChatScreen(Screen[None]):
|
|
|
484
493
|
|
|
485
494
|
except Exception as e:
|
|
486
495
|
logger.error(f"Failed to clear conversation: {e}", exc_info=True)
|
|
487
|
-
self.
|
|
496
|
+
self.agent_manager.add_hint_message(
|
|
497
|
+
HintMessage(message=f"❌ Failed to clear: {e}")
|
|
498
|
+
)
|
|
488
499
|
|
|
489
500
|
@work(exclusive=False)
|
|
490
501
|
async def update_context_indicator(self) -> None:
|
|
@@ -573,8 +584,6 @@ class ChatScreen(Screen[None]):
|
|
|
573
584
|
|
|
574
585
|
@on(PartialResponseMessage)
|
|
575
586
|
def handle_partial_response(self, event: PartialResponseMessage) -> None:
|
|
576
|
-
self.partial_message = event.message
|
|
577
|
-
|
|
578
587
|
# Filter event.messages to exclude ModelRequest with only ToolReturnPart
|
|
579
588
|
# These are intermediate tool results that would render as empty (UserQuestionWidget
|
|
580
589
|
# filters out ToolReturnPart in format_prompt_parts), causing user messages to disappear
|
|
@@ -598,16 +607,33 @@ class ChatScreen(Screen[None]):
|
|
|
598
607
|
)
|
|
599
608
|
|
|
600
609
|
# Use widget coordinator to set partial response
|
|
601
|
-
self.widget_coordinator.set_partial_response(
|
|
602
|
-
|
|
610
|
+
self.widget_coordinator.set_partial_response(event.message, new_message_list)
|
|
611
|
+
|
|
612
|
+
# Skip context updates for file write operations (they don't add to input context)
|
|
613
|
+
has_file_write = any(
|
|
614
|
+
isinstance(msg, ModelResponse)
|
|
615
|
+
and any(
|
|
616
|
+
isinstance(part, ToolCallPart)
|
|
617
|
+
and part.tool_name in ("write_file", "append_file")
|
|
618
|
+
for part in msg.parts
|
|
619
|
+
)
|
|
620
|
+
for msg in event.messages
|
|
603
621
|
)
|
|
604
622
|
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
)
|
|
623
|
+
if has_file_write:
|
|
624
|
+
return # Skip context update for file writes
|
|
625
|
+
|
|
626
|
+
# Throttle context indicator updates to improve performance during streaming
|
|
627
|
+
# Only update at most once per 5 seconds to avoid excessive token calculations
|
|
628
|
+
current_time = time.time()
|
|
629
|
+
if current_time - self._last_context_update >= self._context_update_throttle:
|
|
630
|
+
self._last_context_update = current_time
|
|
631
|
+
# Update context indicator with full message history including streaming messages
|
|
632
|
+
# Combine existing agent history with new streaming messages for accurate token count
|
|
633
|
+
combined_agent_history = self.agent_manager.message_history + event.messages
|
|
634
|
+
self.update_context_indicator_with_messages(
|
|
635
|
+
combined_agent_history, new_message_list
|
|
636
|
+
)
|
|
611
637
|
|
|
612
638
|
def _clear_partial_response(self) -> None:
|
|
613
639
|
# Use widget coordinator to clear partial response
|
|
@@ -919,11 +945,15 @@ class ChatScreen(Screen[None]):
|
|
|
919
945
|
async def delete_codebase(self, graph_id: str) -> None:
|
|
920
946
|
try:
|
|
921
947
|
await self.codebase_sdk.delete_codebase(graph_id)
|
|
922
|
-
self.
|
|
948
|
+
self.agent_manager.add_hint_message(
|
|
949
|
+
HintMessage(message=f"✓ Deleted codebase: {graph_id}")
|
|
950
|
+
)
|
|
923
951
|
except CodebaseNotFoundError as exc:
|
|
924
|
-
self.
|
|
952
|
+
self.agent_manager.add_hint_message(HintMessage(message=f"❌ {exc}"))
|
|
925
953
|
except Exception as exc: # pragma: no cover - defensive UI path
|
|
926
|
-
self.
|
|
954
|
+
self.agent_manager.add_hint_message(
|
|
955
|
+
HintMessage(message=f"❌ Failed to delete codebase: {exc}")
|
|
956
|
+
)
|
|
927
957
|
|
|
928
958
|
def _is_kuzu_corruption_error(self, exception: Exception) -> bool:
|
|
929
959
|
"""Check if error is related to kuzu database corruption.
|
|
@@ -1030,9 +1060,10 @@ class ChatScreen(Screen[None]):
|
|
|
1030
1060
|
)
|
|
1031
1061
|
cleaned = await manager.cleanup_corrupted_databases()
|
|
1032
1062
|
logger.info(f"Cleaned up {len(cleaned)} corrupted database(s)")
|
|
1033
|
-
self.
|
|
1034
|
-
|
|
1035
|
-
|
|
1063
|
+
self.agent_manager.add_hint_message(
|
|
1064
|
+
HintMessage(
|
|
1065
|
+
message=f"🔄 Retrying indexing after cleanup (attempt {attempt + 1}/{max_retries})..."
|
|
1066
|
+
)
|
|
1036
1067
|
)
|
|
1037
1068
|
|
|
1038
1069
|
# Pass the current working directory as the indexed_from_cwd
|
|
@@ -1060,22 +1091,22 @@ class ChatScreen(Screen[None]):
|
|
|
1060
1091
|
logger.info(
|
|
1061
1092
|
f"Successfully indexed codebase '{result.name}' (ID: {result.graph_id})"
|
|
1062
1093
|
)
|
|
1063
|
-
self.
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1094
|
+
self.agent_manager.add_hint_message(
|
|
1095
|
+
HintMessage(
|
|
1096
|
+
message=f"✓ Indexed codebase '{result.name}' (ID: {result.graph_id})"
|
|
1097
|
+
)
|
|
1067
1098
|
)
|
|
1068
1099
|
break # Success - exit retry loop
|
|
1069
1100
|
|
|
1070
1101
|
except CodebaseAlreadyIndexedError as exc:
|
|
1071
1102
|
progress_timer.stop()
|
|
1072
1103
|
logger.warning(f"Codebase already indexed: {exc}")
|
|
1073
|
-
self.
|
|
1104
|
+
self.agent_manager.add_hint_message(HintMessage(message=f"⚠️ {exc}"))
|
|
1074
1105
|
return
|
|
1075
1106
|
except InvalidPathError as exc:
|
|
1076
1107
|
progress_timer.stop()
|
|
1077
1108
|
logger.error(f"Invalid path error: {exc}")
|
|
1078
|
-
self.
|
|
1109
|
+
self.agent_manager.add_hint_message(HintMessage(message=f"❌ {exc}"))
|
|
1079
1110
|
return
|
|
1080
1111
|
|
|
1081
1112
|
except Exception as exc: # pragma: no cover - defensive UI path
|
|
@@ -1094,10 +1125,10 @@ class ChatScreen(Screen[None]):
|
|
|
1094
1125
|
f"Failed to index codebase after {attempt + 1} attempts - "
|
|
1095
1126
|
f"repo_path: {selection.repo_path}, name: {selection.name}, error: {exc}"
|
|
1096
1127
|
)
|
|
1097
|
-
self.
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1128
|
+
self.agent_manager.add_hint_message(
|
|
1129
|
+
HintMessage(
|
|
1130
|
+
message=f"❌ Failed to index codebase after {attempt + 1} attempts: {exc}"
|
|
1131
|
+
)
|
|
1101
1132
|
)
|
|
1102
1133
|
break
|
|
1103
1134
|
|
|
@@ -5,6 +5,7 @@ from textual.command import DiscoveryHit, Hit, Provider
|
|
|
5
5
|
|
|
6
6
|
from shotgun.agents.models import AgentType
|
|
7
7
|
from shotgun.codebase.models import CodebaseGraph
|
|
8
|
+
from shotgun.tui.screens.chat_screen.hint_message import HintMessage
|
|
8
9
|
from shotgun.tui.screens.model_picker import ModelPickerScreen
|
|
9
10
|
from shotgun.tui.screens.provider_config import ProviderConfigScreen
|
|
10
11
|
|
|
@@ -271,8 +272,8 @@ class DeleteCodebasePaletteProvider(Provider):
|
|
|
271
272
|
try:
|
|
272
273
|
result = await self.chat_screen.codebase_sdk.list_codebases()
|
|
273
274
|
except Exception as exc: # pragma: no cover - defensive UI path
|
|
274
|
-
self.chat_screen.
|
|
275
|
-
f"Unable to load codebases: {exc}"
|
|
275
|
+
self.chat_screen.agent_manager.add_hint_message(
|
|
276
|
+
HintMessage(message=f"❌ Unable to load codebases: {exc}")
|
|
276
277
|
)
|
|
277
278
|
return []
|
|
278
279
|
return result.graphs
|
|
@@ -47,7 +47,6 @@ class ChatHistory(Widget):
|
|
|
47
47
|
super().__init__()
|
|
48
48
|
self.items: Sequence[ModelMessage | HintMessage] = []
|
|
49
49
|
self.vertical_tail: VerticalTail | None = None
|
|
50
|
-
self.partial_response = None
|
|
51
50
|
self._rendered_count = 0 # Track how many messages have been mounted
|
|
52
51
|
|
|
53
52
|
def compose(self) -> ComposeResult:
|
|
@@ -63,7 +62,7 @@ class ChatHistory(Widget):
|
|
|
63
62
|
yield HintMessageWidget(item)
|
|
64
63
|
elif isinstance(item, ModelResponse):
|
|
65
64
|
yield AgentResponseWidget(item)
|
|
66
|
-
yield PartialResponseWidget(
|
|
65
|
+
yield PartialResponseWidget(None).data_bind(
|
|
67
66
|
item=ChatHistory.partial_response
|
|
68
67
|
)
|
|
69
68
|
|
|
@@ -8,7 +8,7 @@ from textual import on
|
|
|
8
8
|
from textual.app import ComposeResult
|
|
9
9
|
from textual.containers import Horizontal, Vertical
|
|
10
10
|
from textual.screen import Screen
|
|
11
|
-
from textual.widgets import Button, Static
|
|
11
|
+
from textual.widgets import Button, Label, Static
|
|
12
12
|
|
|
13
13
|
from shotgun.utils.file_system_utils import ensure_shotgun_directory_exists
|
|
14
14
|
|
|
@@ -56,6 +56,14 @@ class DirectorySetupScreen(Screen[None]):
|
|
|
56
56
|
#directory-actions > * {
|
|
57
57
|
margin-right: 2;
|
|
58
58
|
}
|
|
59
|
+
|
|
60
|
+
#directory-status {
|
|
61
|
+
height: auto;
|
|
62
|
+
padding: 0 1;
|
|
63
|
+
min-height: 1;
|
|
64
|
+
color: $error;
|
|
65
|
+
text-align: center;
|
|
66
|
+
}
|
|
59
67
|
"""
|
|
60
68
|
|
|
61
69
|
BINDINGS = [
|
|
@@ -69,6 +77,7 @@ class DirectorySetupScreen(Screen[None]):
|
|
|
69
77
|
yield Static("Shotgun keeps workspace data in a .shotgun directory.\n")
|
|
70
78
|
yield Static("Initialize it in the current directory?\n")
|
|
71
79
|
yield Static(f"[$foreground-muted]({Path.cwd().resolve()})[/]")
|
|
80
|
+
yield Label("", id="directory-status")
|
|
72
81
|
with Horizontal(id="directory-actions"):
|
|
73
82
|
yield Button(
|
|
74
83
|
"Initialize and proceed \\[ENTER]", variant="primary", id="initialize"
|
|
@@ -93,17 +102,17 @@ class DirectorySetupScreen(Screen[None]):
|
|
|
93
102
|
self._exit_application()
|
|
94
103
|
|
|
95
104
|
def _initialize_directory(self) -> None:
|
|
105
|
+
status_label = self.query_one("#directory-status", Label)
|
|
96
106
|
try:
|
|
97
107
|
path = ensure_shotgun_directory_exists()
|
|
98
108
|
except Exception as exc: # pragma: no cover - defensive; textual path
|
|
99
|
-
|
|
109
|
+
status_label.update(f"❌ Failed to initialize directory: {exc}")
|
|
100
110
|
return
|
|
101
111
|
|
|
102
112
|
# Double-check a directory now exists; guard against unexpected filesystem state.
|
|
103
113
|
if not path.is_dir():
|
|
104
|
-
|
|
105
|
-
"Unable to initialize .shotgun directory due to filesystem conflict."
|
|
106
|
-
severity="error",
|
|
114
|
+
status_label.update(
|
|
115
|
+
"❌ Unable to initialize .shotgun directory due to filesystem conflict."
|
|
107
116
|
)
|
|
108
117
|
return
|
|
109
118
|
|
shotgun/tui/screens/feedback.py
CHANGED
|
@@ -76,6 +76,13 @@ class FeedbackScreen(Screen[Feedback | None]):
|
|
|
76
76
|
#feedback-type-list {
|
|
77
77
|
padding: 1;
|
|
78
78
|
}
|
|
79
|
+
|
|
80
|
+
#feedback-status {
|
|
81
|
+
height: auto;
|
|
82
|
+
padding: 0 1;
|
|
83
|
+
min-height: 1;
|
|
84
|
+
color: $error;
|
|
85
|
+
}
|
|
79
86
|
"""
|
|
80
87
|
|
|
81
88
|
BINDINGS = [
|
|
@@ -96,6 +103,7 @@ class FeedbackScreen(Screen[Feedback | None]):
|
|
|
96
103
|
"",
|
|
97
104
|
id="feedback-description",
|
|
98
105
|
)
|
|
106
|
+
yield Label("", id="feedback-status")
|
|
99
107
|
with Horizontal(id="feedback-actions"):
|
|
100
108
|
yield Button("Submit", variant="primary", id="submit")
|
|
101
109
|
yield Button("Cancel \\[ESC]", id="cancel")
|
|
@@ -176,9 +184,8 @@ class FeedbackScreen(Screen[Feedback | None]):
|
|
|
176
184
|
description = text_area.text.strip()
|
|
177
185
|
|
|
178
186
|
if not description:
|
|
179
|
-
self.
|
|
180
|
-
|
|
181
|
-
)
|
|
187
|
+
status_label = self.query_one("#feedback-status", Label)
|
|
188
|
+
status_label.update("❌ Please enter a description before submitting.")
|
|
182
189
|
return
|
|
183
190
|
|
|
184
191
|
app = cast("ShotgunApp", self.app)
|
|
@@ -6,7 +6,7 @@ from textual import on
|
|
|
6
6
|
from textual.app import ComposeResult
|
|
7
7
|
from textual.containers import Container, Vertical
|
|
8
8
|
from textual.screen import ModalScreen
|
|
9
|
-
from textual.widgets import Button, Markdown, Static
|
|
9
|
+
from textual.widgets import Button, Label, Markdown, Static
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
class GitHubIssueScreen(ModalScreen[None]):
|
|
@@ -47,6 +47,13 @@ class GitHubIssueScreen(ModalScreen[None]):
|
|
|
47
47
|
margin: 1 1;
|
|
48
48
|
min-width: 20;
|
|
49
49
|
}
|
|
50
|
+
|
|
51
|
+
#issue-status {
|
|
52
|
+
height: auto;
|
|
53
|
+
padding: 1;
|
|
54
|
+
min-height: 1;
|
|
55
|
+
text-align: center;
|
|
56
|
+
}
|
|
50
57
|
"""
|
|
51
58
|
|
|
52
59
|
BINDINGS = [
|
|
@@ -85,6 +92,7 @@ We review all issues and will respond as soon as possible!
|
|
|
85
92
|
id="issue-markdown",
|
|
86
93
|
)
|
|
87
94
|
with Vertical(id="issue-buttons"):
|
|
95
|
+
yield Label("", id="issue-status")
|
|
88
96
|
yield Button(
|
|
89
97
|
"🐙 Open GitHub Issues", id="github-button", variant="primary"
|
|
90
98
|
)
|
|
@@ -94,7 +102,8 @@ We review all issues and will respond as soon as possible!
|
|
|
94
102
|
def handle_github(self) -> None:
|
|
95
103
|
"""Open GitHub issues page in browser."""
|
|
96
104
|
webbrowser.open("https://github.com/shotgun-sh/shotgun/issues")
|
|
97
|
-
self.
|
|
105
|
+
status_label = self.query_one("#issue-status", Label)
|
|
106
|
+
status_label.update("✓ Opening GitHub Issues in your browser...")
|
|
98
107
|
|
|
99
108
|
@on(Button.Pressed, "#close-button")
|
|
100
109
|
def handle_close(self) -> None:
|
|
@@ -72,6 +72,11 @@ class ModelPickerScreen(Screen[ModelConfigUpdated | None]):
|
|
|
72
72
|
padding: 1 0;
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
|
+
#model-picker-status {
|
|
76
|
+
height: auto;
|
|
77
|
+
padding: 0 1;
|
|
78
|
+
color: $error;
|
|
79
|
+
}
|
|
75
80
|
#model-actions {
|
|
76
81
|
padding: 1;
|
|
77
82
|
}
|
|
@@ -94,6 +99,7 @@ class ModelPickerScreen(Screen[ModelConfigUpdated | None]):
|
|
|
94
99
|
id="model-picker-summary",
|
|
95
100
|
)
|
|
96
101
|
yield ListView(id="model-list")
|
|
102
|
+
yield Label("", id="model-picker-status")
|
|
97
103
|
with Horizontal(id="model-actions"):
|
|
98
104
|
yield Button("Select \\[ENTER]", variant="primary", id="select")
|
|
99
105
|
yield Button("Done \\[ESC]", id="done")
|
|
@@ -349,4 +355,5 @@ class ModelPickerScreen(Screen[ModelConfigUpdated | None]):
|
|
|
349
355
|
)
|
|
350
356
|
)
|
|
351
357
|
except Exception as exc: # pragma: no cover - defensive; textual path
|
|
352
|
-
self.
|
|
358
|
+
status_label = self.query_one("#model-picker-status", Label)
|
|
359
|
+
status_label.update(f"❌ Failed to select model: {exc}")
|
|
@@ -8,7 +8,7 @@ from textual import on
|
|
|
8
8
|
from textual.app import ComposeResult
|
|
9
9
|
from textual.containers import Container, Horizontal, VerticalScroll
|
|
10
10
|
from textual.screen import ModalScreen
|
|
11
|
-
from textual.widgets import Button, Markdown
|
|
11
|
+
from textual.widgets import Button, Label, Markdown
|
|
12
12
|
|
|
13
13
|
if TYPE_CHECKING:
|
|
14
14
|
pass
|
|
@@ -51,6 +51,13 @@ class PipxMigrationScreen(ModalScreen[None]):
|
|
|
51
51
|
margin: 0 1;
|
|
52
52
|
min-width: 20;
|
|
53
53
|
}
|
|
54
|
+
|
|
55
|
+
#migration-status {
|
|
56
|
+
height: auto;
|
|
57
|
+
padding: 1;
|
|
58
|
+
min-height: 1;
|
|
59
|
+
text-align: center;
|
|
60
|
+
}
|
|
54
61
|
"""
|
|
55
62
|
|
|
56
63
|
BINDINGS = [
|
|
@@ -106,6 +113,7 @@ Or install permanently: `uv tool install shotgun-sh`
|
|
|
106
113
|
)
|
|
107
114
|
|
|
108
115
|
with Container(id="buttons-container"):
|
|
116
|
+
yield Label("", id="migration-status")
|
|
109
117
|
with Horizontal(id="action-buttons"):
|
|
110
118
|
yield Button(
|
|
111
119
|
"Copy Instructions to Clipboard",
|
|
@@ -136,16 +144,14 @@ curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
|
136
144
|
|
|
137
145
|
# Step 3: Run shotgun with uvx
|
|
138
146
|
uvx shotgun-sh"""
|
|
147
|
+
status_label = self.query_one("#migration-status", Label)
|
|
139
148
|
try:
|
|
140
149
|
import pyperclip # type: ignore[import-untyped] # noqa: PGH003
|
|
141
150
|
|
|
142
151
|
pyperclip.copy(instructions)
|
|
143
|
-
|
|
152
|
+
status_label.update("✓ Copied migration instructions to clipboard!")
|
|
144
153
|
except ImportError:
|
|
145
|
-
|
|
146
|
-
"Clipboard not available. See instructions above.",
|
|
147
|
-
severity="warning",
|
|
148
|
-
)
|
|
154
|
+
status_label.update("⚠️ Clipboard not available. See instructions above.")
|
|
149
155
|
|
|
150
156
|
@on(Button.Pressed, "#continue")
|
|
151
157
|
def _continue(self) -> None:
|
|
@@ -77,6 +77,14 @@ class ProviderConfigScreen(Screen[None]):
|
|
|
77
77
|
#provider-list {
|
|
78
78
|
padding: 1;
|
|
79
79
|
}
|
|
80
|
+
#provider-status {
|
|
81
|
+
height: auto;
|
|
82
|
+
padding: 0 1;
|
|
83
|
+
min-height: 1;
|
|
84
|
+
}
|
|
85
|
+
#provider-status.error {
|
|
86
|
+
color: $error;
|
|
87
|
+
}
|
|
80
88
|
"""
|
|
81
89
|
|
|
82
90
|
BINDINGS = [
|
|
@@ -103,6 +111,7 @@ class ProviderConfigScreen(Screen[None]):
|
|
|
103
111
|
password=True,
|
|
104
112
|
id="api-key",
|
|
105
113
|
)
|
|
114
|
+
yield Label("", id="provider-status")
|
|
106
115
|
with Horizontal(id="provider-actions"):
|
|
107
116
|
yield Button("Save key \\[ENTER]", variant="primary", id="save")
|
|
108
117
|
yield Button("Authenticate", variant="success", id="authenticate")
|
|
@@ -280,9 +289,11 @@ class ProviderConfigScreen(Screen[None]):
|
|
|
280
289
|
"""Async implementation of API key saving."""
|
|
281
290
|
input_widget = self.query_one("#api-key", Input)
|
|
282
291
|
api_key = input_widget.value.strip()
|
|
292
|
+
status_label = self.query_one("#provider-status", Label)
|
|
283
293
|
|
|
284
294
|
if not api_key:
|
|
285
|
-
|
|
295
|
+
status_label.update("❌ Enter an API key before saving.")
|
|
296
|
+
status_label.add_class("error")
|
|
286
297
|
return
|
|
287
298
|
|
|
288
299
|
try:
|
|
@@ -291,25 +302,29 @@ class ProviderConfigScreen(Screen[None]):
|
|
|
291
302
|
api_key=api_key,
|
|
292
303
|
)
|
|
293
304
|
except Exception as exc: # pragma: no cover - defensive; textual path
|
|
294
|
-
|
|
305
|
+
status_label.update(f"❌ Failed to save key: {exc}")
|
|
306
|
+
status_label.add_class("error")
|
|
295
307
|
return
|
|
296
308
|
|
|
297
309
|
input_widget.value = ""
|
|
298
310
|
await self.refresh_provider_status()
|
|
299
311
|
await self._update_done_button_visibility()
|
|
300
|
-
|
|
301
|
-
f"Saved API key for {self._provider_display_name(self.selected_provider)}."
|
|
312
|
+
status_label.update(
|
|
313
|
+
f"✓ Saved API key for {self._provider_display_name(self.selected_provider)}."
|
|
302
314
|
)
|
|
315
|
+
status_label.remove_class("error")
|
|
303
316
|
|
|
304
317
|
def _clear_api_key(self) -> None:
|
|
305
318
|
self.run_worker(self._do_clear_api_key(), exclusive=True)
|
|
306
319
|
|
|
307
320
|
async def _do_clear_api_key(self) -> None:
|
|
308
321
|
"""Async implementation of API key clearing."""
|
|
322
|
+
status_label = self.query_one("#provider-status", Label)
|
|
309
323
|
try:
|
|
310
324
|
await self.config_manager.clear_provider_key(self.selected_provider)
|
|
311
325
|
except Exception as exc: # pragma: no cover - defensive; textual path
|
|
312
|
-
|
|
326
|
+
status_label.update(f"❌ Failed to clear key: {exc}")
|
|
327
|
+
status_label.add_class("error")
|
|
313
328
|
return
|
|
314
329
|
|
|
315
330
|
await self.refresh_provider_status()
|
|
@@ -321,9 +336,10 @@ class ProviderConfigScreen(Screen[None]):
|
|
|
321
336
|
auth_button = self.query_one("#authenticate", Button)
|
|
322
337
|
auth_button.display = True
|
|
323
338
|
|
|
324
|
-
|
|
325
|
-
f"Cleared API key for {self._provider_display_name(self.selected_provider)}."
|
|
339
|
+
status_label.update(
|
|
340
|
+
f"✓ Cleared API key for {self._provider_display_name(self.selected_provider)}."
|
|
326
341
|
)
|
|
342
|
+
status_label.remove_class("error")
|
|
327
343
|
|
|
328
344
|
async def _start_shotgun_auth(self) -> None:
|
|
329
345
|
"""Launch Shotgun Account authentication flow."""
|
|
@@ -335,4 +351,5 @@ class ProviderConfigScreen(Screen[None]):
|
|
|
335
351
|
# Refresh provider status after auth completes
|
|
336
352
|
if result:
|
|
337
353
|
await self.refresh_provider_status()
|
|
338
|
-
#
|
|
354
|
+
# Auto-dismiss provider config screen after successful auth
|
|
355
|
+
self.dismiss()
|
|
@@ -182,12 +182,10 @@ class ShotgunAuthScreen(Screen[bool]):
|
|
|
182
182
|
self.query_one("#status", Label).update(
|
|
183
183
|
f"❌ Error: Failed to create authentication token\n{e}"
|
|
184
184
|
)
|
|
185
|
-
self.notify("Failed to start authentication", severity="error")
|
|
186
185
|
|
|
187
186
|
except Exception as e:
|
|
188
187
|
logger.error("Unexpected error during auth flow: %s", e)
|
|
189
188
|
self.query_one("#status", Label).update(f"❌ Unexpected error: {e}")
|
|
190
|
-
self.notify("Authentication failed", severity="error")
|
|
191
189
|
|
|
192
190
|
async def _poll_token_status(self) -> None:
|
|
193
191
|
"""Poll token status until completed or expired."""
|
|
@@ -224,17 +222,12 @@ class ShotgunAuthScreen(Screen[bool]):
|
|
|
224
222
|
"✅ Authentication successful! Saving credentials..."
|
|
225
223
|
)
|
|
226
224
|
await asyncio.sleep(1)
|
|
227
|
-
self.notify(
|
|
228
|
-
"Shotgun Account configured successfully!",
|
|
229
|
-
severity="information",
|
|
230
|
-
)
|
|
231
225
|
self.dismiss(True)
|
|
232
226
|
else:
|
|
233
227
|
logger.error("Completed but missing keys")
|
|
234
228
|
self.query_one("#status", Label).update(
|
|
235
229
|
"❌ Error: Authentication completed but keys are missing"
|
|
236
230
|
)
|
|
237
|
-
self.notify("Authentication failed", severity="error")
|
|
238
231
|
await asyncio.sleep(3)
|
|
239
232
|
self.dismiss(False)
|
|
240
233
|
return
|
|
@@ -250,7 +243,6 @@ class ShotgunAuthScreen(Screen[bool]):
|
|
|
250
243
|
"❌ Authentication token expired (30 minutes)\n"
|
|
251
244
|
"Please try again."
|
|
252
245
|
)
|
|
253
|
-
self.notify("Authentication token expired", severity="error")
|
|
254
246
|
await asyncio.sleep(3)
|
|
255
247
|
self.dismiss(False)
|
|
256
248
|
return
|
|
@@ -269,7 +261,6 @@ class ShotgunAuthScreen(Screen[bool]):
|
|
|
269
261
|
self.query_one("#status", Label).update(
|
|
270
262
|
"❌ Authentication token expired"
|
|
271
263
|
)
|
|
272
|
-
self.notify("Authentication token expired", severity="error")
|
|
273
264
|
await asyncio.sleep(3)
|
|
274
265
|
self.dismiss(False)
|
|
275
266
|
return
|
|
@@ -290,6 +281,5 @@ class ShotgunAuthScreen(Screen[bool]):
|
|
|
290
281
|
self.query_one("#status", Label).update(
|
|
291
282
|
"❌ Authentication timeout (30 minutes)\nPlease try again."
|
|
292
283
|
)
|
|
293
|
-
self.notify("Authentication timeout", severity="error")
|
|
294
284
|
await asyncio.sleep(3)
|
|
295
285
|
self.dismiss(False)
|
shotgun/tui/screens/welcome.py
CHANGED
|
@@ -85,6 +85,21 @@ class WelcomeScreen(Screen[None]):
|
|
|
85
85
|
margin: 1 0 0 0;
|
|
86
86
|
width: 100%;
|
|
87
87
|
}
|
|
88
|
+
|
|
89
|
+
#migration-warning {
|
|
90
|
+
width: 80%;
|
|
91
|
+
height: auto;
|
|
92
|
+
padding: 2;
|
|
93
|
+
margin: 1 0;
|
|
94
|
+
border: solid $warning;
|
|
95
|
+
background: $warning 20%;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#migration-warning-title {
|
|
99
|
+
text-style: bold;
|
|
100
|
+
color: $warning;
|
|
101
|
+
padding: 0 0 1 0;
|
|
102
|
+
}
|
|
88
103
|
"""
|
|
89
104
|
|
|
90
105
|
BINDINGS = [
|
|
@@ -99,6 +114,23 @@ class WelcomeScreen(Screen[None]):
|
|
|
99
114
|
id="welcome-subtitle",
|
|
100
115
|
)
|
|
101
116
|
|
|
117
|
+
# Show migration warning if migration failed
|
|
118
|
+
app = cast("ShotgunApp", self.app)
|
|
119
|
+
# Note: This is a synchronous call in compose, but config should already be loaded
|
|
120
|
+
if hasattr(app, "config_manager") and app.config_manager._config:
|
|
121
|
+
config = app.config_manager._config
|
|
122
|
+
if config.migration_failed:
|
|
123
|
+
with Vertical(id="migration-warning"):
|
|
124
|
+
yield Static(
|
|
125
|
+
"⚠️ Configuration Migration Failed",
|
|
126
|
+
id="migration-warning-title",
|
|
127
|
+
)
|
|
128
|
+
backup_msg = "Your previous configuration couldn't be migrated automatically."
|
|
129
|
+
if config.migration_backup_path:
|
|
130
|
+
backup_msg += f"\n\nYour old configuration (including API keys) has been backed up to:\n{config.migration_backup_path}"
|
|
131
|
+
backup_msg += "\n\nYou'll need to reconfigure Shotgun by choosing an option below."
|
|
132
|
+
yield Markdown(backup_msg)
|
|
133
|
+
|
|
102
134
|
with Container(id="options-container"):
|
|
103
135
|
with Horizontal(id="options"):
|
|
104
136
|
# Left box - Shotgun Account
|
|
@@ -166,8 +166,9 @@ class WidgetCoordinator:
|
|
|
166
166
|
|
|
167
167
|
try:
|
|
168
168
|
chat_history = self.screen.query_one(ChatHistory)
|
|
169
|
-
|
|
170
|
-
|
|
169
|
+
# Set the reactive attribute to trigger the PartialResponseWidget update
|
|
170
|
+
chat_history.partial_response = message
|
|
171
|
+
# Also update the full message list
|
|
171
172
|
chat_history.update_messages(messages)
|
|
172
173
|
except Exception as e:
|
|
173
174
|
logger.exception(f"Failed to set partial response: {e}")
|