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.

Files changed (33) hide show
  1. shotgun/agents/agent_manager.py +22 -11
  2. shotgun/agents/config/README.md +89 -0
  3. shotgun/agents/config/__init__.py +10 -1
  4. shotgun/agents/config/manager.py +287 -32
  5. shotgun/agents/config/models.py +17 -1
  6. shotgun/agents/config/provider.py +27 -0
  7. shotgun/agents/config/streaming_test.py +119 -0
  8. shotgun/build_constants.py +3 -3
  9. shotgun/logging_config.py +42 -0
  10. shotgun/main.py +2 -0
  11. shotgun/posthog_telemetry.py +18 -25
  12. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +3 -2
  13. shotgun/sentry_telemetry.py +140 -2
  14. shotgun/settings.py +5 -0
  15. shotgun/tui/app.py +7 -1
  16. shotgun/tui/screens/chat/chat_screen.py +66 -35
  17. shotgun/tui/screens/chat_screen/command_providers.py +3 -2
  18. shotgun/tui/screens/chat_screen/history/chat_history.py +1 -2
  19. shotgun/tui/screens/directory_setup.py +14 -5
  20. shotgun/tui/screens/feedback.py +10 -3
  21. shotgun/tui/screens/github_issue.py +11 -2
  22. shotgun/tui/screens/model_picker.py +8 -1
  23. shotgun/tui/screens/pipx_migration.py +12 -6
  24. shotgun/tui/screens/provider_config.py +25 -8
  25. shotgun/tui/screens/shotgun_auth.py +0 -10
  26. shotgun/tui/screens/welcome.py +32 -0
  27. shotgun/tui/widgets/widget_coordinator.py +3 -2
  28. shotgun_sh-0.2.19.dist-info/METADATA +465 -0
  29. {shotgun_sh-0.2.11.dev7.dist-info → shotgun_sh-0.2.19.dist-info}/RECORD +32 -30
  30. shotgun_sh-0.2.11.dev7.dist-info/METADATA +0 -130
  31. {shotgun_sh-0.2.11.dev7.dist-info → shotgun_sh-0.2.19.dist-info}/WHEEL +0 -0
  32. {shotgun_sh-0.2.11.dev7.dist-info → shotgun_sh-0.2.19.dist-info}/entry_points.txt +0 -0
  33. {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.notify(
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.notify("No usage hint available", severity="error")
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.notify("No context analysis available", severity="error")
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.notify(f"Failed to compact: {e}", severity="error")
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.notify(f"Failed to clear: {e}", severity="error")
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
- self.partial_message, new_message_list
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
- # Update context indicator with full message history including streaming messages
606
- # Combine existing agent history with new streaming messages for accurate token count
607
- combined_agent_history = self.agent_manager.message_history + event.messages
608
- self.update_context_indicator_with_messages(
609
- combined_agent_history, new_message_list
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.notify(f"Deleted codebase: {graph_id}", severity="information")
948
+ self.agent_manager.add_hint_message(
949
+ HintMessage(message=f"✓ Deleted codebase: {graph_id}")
950
+ )
923
951
  except CodebaseNotFoundError as exc:
924
- self.notify(str(exc), severity="error")
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.notify(f"Failed to delete codebase: {exc}", severity="error")
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.notify(
1034
- f"Retrying indexing after cleanup (attempt {attempt + 1}/{max_retries})...",
1035
- severity="information",
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.notify(
1064
- f"Indexed codebase '{result.name}' (ID: {result.graph_id})",
1065
- severity="information",
1066
- timeout=8,
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.notify(str(exc), severity="warning")
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.notify(str(exc), severity="error")
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.notify(
1098
- f"Failed to index codebase after {attempt + 1} attempts: {exc}",
1099
- severity="error",
1100
- timeout=30, # Keep error visible for 30 seconds
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.notify(
275
- f"Unable to load codebases: {exc}", severity="error"
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(self.partial_response).data_bind(
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
- self.notify(f"Failed to initialize directory: {exc}", severity="error")
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
- self.notify(
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
 
@@ -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.notify(
180
- "Please enter a description before submitting.", severity="error"
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.notify("Opening GitHub Issues in your browser...")
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.notify(f"Failed to select model: {exc}", severity="error")
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
- self.notify("Copied migration instructions to clipboard!")
152
+ status_label.update("Copied migration instructions to clipboard!")
144
153
  except ImportError:
145
- self.notify(
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
- self.notify("Enter an API key before saving.", severity="error")
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
- self.notify(f"Failed to save key: {exc}", severity="error")
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
- self.notify(
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
- self.notify(f"Failed to clear key: {exc}", severity="error")
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
- self.notify(
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
- # Notify handled by auth screen
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)
@@ -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
- if message:
170
- chat_history.partial_response = message
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}")