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
@@ -0,0 +1,42 @@
1
+ """User question widget for chat history."""
2
+
3
+ from collections.abc import Sequence
4
+
5
+ from pydantic_ai.messages import (
6
+ ModelRequest,
7
+ ModelRequestPart,
8
+ ToolReturnPart,
9
+ UserPromptPart,
10
+ )
11
+ from textual.app import ComposeResult
12
+ from textual.widget import Widget
13
+ from textual.widgets import Markdown
14
+
15
+
16
+ class UserQuestionWidget(Widget):
17
+ """Widget that displays user prompts in the chat history."""
18
+
19
+ def __init__(self, item: ModelRequest | None) -> None:
20
+ super().__init__()
21
+ self.item = item
22
+
23
+ def compose(self) -> ComposeResult:
24
+ self.display = self.item is not None
25
+ if self.item is None:
26
+ yield Markdown(markdown="")
27
+ else:
28
+ prompt = self.format_prompt_parts(self.item.parts)
29
+ yield Markdown(markdown=prompt)
30
+
31
+ def format_prompt_parts(self, parts: Sequence[ModelRequestPart]) -> str:
32
+ """Format user prompt parts into markdown."""
33
+ acc = ""
34
+ for part in parts:
35
+ if isinstance(part, UserPromptPart):
36
+ acc += (
37
+ f"**>** {part.content if isinstance(part.content, str) else ''}\n\n"
38
+ )
39
+ elif isinstance(part, ToolReturnPart):
40
+ # Don't show tool return parts in the UI
41
+ pass
42
+ return acc
@@ -0,0 +1,151 @@
1
+ """Reusable confirmation dialog for destructive actions in the TUI."""
2
+
3
+ from typing import Literal
4
+
5
+ from textual import on
6
+ from textual.app import ComposeResult
7
+ from textual.containers import Container
8
+ from textual.screen import ModalScreen
9
+ from textual.widgets import Button, Label, Static
10
+
11
+ ButtonVariant = Literal["default", "primary", "success", "warning", "error"]
12
+
13
+
14
+ class ConfirmationDialog(ModalScreen[bool]):
15
+ """Reusable confirmation dialog for destructive actions.
16
+
17
+ This modal dialog presents a confirmation prompt with a title, explanatory
18
+ message, and customizable confirm/cancel buttons. Useful for preventing
19
+ accidental destructive actions like clearing data or deleting resources.
20
+
21
+ Args:
22
+ title: Dialog title text (e.g., "Clear conversation?")
23
+ message: Detailed explanation of what will happen
24
+ confirm_label: Label for the confirm button (default: "Confirm")
25
+ cancel_label: Label for the cancel button (default: "Cancel")
26
+ confirm_variant: Button variant for confirm button (default: "warning")
27
+ danger: Whether this is a dangerous/destructive action (default: False)
28
+
29
+ Returns:
30
+ True if user confirms, False if user cancels
31
+
32
+ Example:
33
+ ```python
34
+ should_delete = await self.app.push_screen_wait(
35
+ ConfirmationDialog(
36
+ title="Delete item?",
37
+ message="This will permanently delete the item. This cannot be undone.",
38
+ confirm_label="Delete",
39
+ cancel_label="Keep",
40
+ confirm_variant="warning",
41
+ danger=True,
42
+ )
43
+ )
44
+ if should_delete:
45
+ # Proceed with deletion
46
+ ...
47
+ ```
48
+ """
49
+
50
+ DEFAULT_CSS = """
51
+ ConfirmationDialog {
52
+ align: center middle;
53
+ background: rgba(0, 0, 0, 0.0);
54
+ }
55
+
56
+ ConfirmationDialog > #dialog-container {
57
+ width: 60%;
58
+ max-width: 70;
59
+ height: auto;
60
+ border: wide $warning;
61
+ padding: 1 2;
62
+ layout: vertical;
63
+ background: $surface;
64
+ }
65
+
66
+ ConfirmationDialog.danger > #dialog-container {
67
+ border: wide $error;
68
+ }
69
+
70
+ #dialog-title {
71
+ text-style: bold;
72
+ color: $text;
73
+ padding-bottom: 1;
74
+ }
75
+
76
+ #dialog-message {
77
+ padding-bottom: 1;
78
+ color: $text-muted;
79
+ }
80
+
81
+ #dialog-buttons {
82
+ layout: horizontal;
83
+ align-horizontal: right;
84
+ height: auto;
85
+ }
86
+
87
+ #dialog-buttons Button {
88
+ margin-left: 1;
89
+ }
90
+ """
91
+
92
+ def __init__(
93
+ self,
94
+ title: str,
95
+ message: str,
96
+ confirm_label: str = "Confirm",
97
+ cancel_label: str = "Cancel",
98
+ confirm_variant: ButtonVariant = "warning",
99
+ danger: bool = False,
100
+ ) -> None:
101
+ """Initialize the confirmation dialog.
102
+
103
+ Args:
104
+ title: Dialog title text
105
+ message: Detailed explanation of what will happen
106
+ confirm_label: Label for the confirm button
107
+ cancel_label: Label for the cancel button
108
+ confirm_variant: Button variant for confirm button
109
+ danger: Whether this is a dangerous/destructive action
110
+ """
111
+ super().__init__()
112
+ self.title_text = title
113
+ self.message_text = message
114
+ self.confirm_label = confirm_label
115
+ self.cancel_label = cancel_label
116
+ self.confirm_variant = confirm_variant
117
+ self.is_danger = danger
118
+
119
+ def compose(self) -> ComposeResult:
120
+ """Compose the dialog widgets."""
121
+ with Container(id="dialog-container"):
122
+ yield Label(self.title_text, id="dialog-title")
123
+ yield Static(self.message_text, id="dialog-message")
124
+ with Container(id="dialog-buttons"):
125
+ yield Button(
126
+ self.confirm_label,
127
+ id="confirm",
128
+ variant=self.confirm_variant,
129
+ )
130
+ yield Button(self.cancel_label, id="cancel")
131
+
132
+ def on_mount(self) -> None:
133
+ """Set up the dialog after mounting."""
134
+ # Apply danger class if needed
135
+ if self.is_danger:
136
+ self.add_class("danger")
137
+
138
+ # Focus cancel button by default for safety
139
+ self.query_one("#cancel", Button).focus()
140
+
141
+ @on(Button.Pressed, "#cancel")
142
+ def handle_cancel(self, event: Button.Pressed) -> None:
143
+ """Handle cancel button press."""
144
+ event.stop()
145
+ self.dismiss(False)
146
+
147
+ @on(Button.Pressed, "#confirm")
148
+ def handle_confirm(self, event: Button.Pressed) -> None:
149
+ """Handle confirm button press."""
150
+ event.stop()
151
+ self.dismiss(True)
@@ -11,8 +11,13 @@ from textual.reactive import reactive
11
11
  from textual.screen import Screen
12
12
  from textual.widgets import Button, Label, ListItem, ListView, Static
13
13
 
14
+ from shotgun.agents.agent_manager import ModelConfigUpdated
14
15
  from shotgun.agents.config import ConfigManager
15
16
  from shotgun.agents.config.models import MODEL_SPECS, ModelName, ShotgunConfig
17
+ from shotgun.agents.config.provider import (
18
+ get_default_model_for_provider,
19
+ get_provider_model,
20
+ )
16
21
  from shotgun.logging_config import get_logger
17
22
 
18
23
  if TYPE_CHECKING:
@@ -30,8 +35,11 @@ def _sanitize_model_name_for_id(model_name: ModelName) -> str:
30
35
  return model_name.value.replace(".", "-")
31
36
 
32
37
 
33
- class ModelPickerScreen(Screen[None]):
34
- """Select AI model to use."""
38
+ class ModelPickerScreen(Screen[ModelConfigUpdated | None]):
39
+ """Select AI model to use.
40
+
41
+ Returns ModelConfigUpdated when a model is selected, None if cancelled.
42
+ """
35
43
 
36
44
  CSS = """
37
45
  ModelPicker {
@@ -111,7 +119,7 @@ class ModelPickerScreen(Screen[None]):
111
119
  config_manager._provider_has_api_key(config.shotgun),
112
120
  )
113
121
 
114
- current_model = config.selected_model or ModelName.CLAUDE_SONNET_4_5
122
+ current_model = config.selected_model or get_default_model_for_provider(config)
115
123
  self.selected_model = current_model
116
124
  logger.debug("Current selected model: %s", current_model)
117
125
 
@@ -193,7 +201,7 @@ class ModelPickerScreen(Screen[None]):
193
201
  """
194
202
  # Load config once with force_reload
195
203
  config = self.config_manager.load(force_reload=True)
196
- current_model = config.selected_model or ModelName.CLAUDE_SONNET_4_5
204
+ current_model = config.selected_model or get_default_model_for_provider(config)
197
205
 
198
206
  # Update labels for available models only
199
207
  for model_name in AVAILABLE_MODELS:
@@ -318,10 +326,26 @@ class ModelPickerScreen(Screen[None]):
318
326
  def _select_model(self) -> None:
319
327
  """Save the selected model."""
320
328
  try:
329
+ # Get old model before updating
330
+ config = self.config_manager.load()
331
+ old_model = config.selected_model
332
+
333
+ # Update the selected model in config
321
334
  self.config_manager.update_selected_model(self.selected_model)
322
335
  self.refresh_model_labels()
323
- self.notify(
324
- f"Selected model: {self._model_display_name(self.selected_model)}"
336
+
337
+ # Get the full model config with provider information
338
+ model_config = get_provider_model(self.selected_model)
339
+
340
+ # Dismiss the screen and return the model config update to the caller
341
+ self.dismiss(
342
+ ModelConfigUpdated(
343
+ old_model=old_model,
344
+ new_model=self.selected_model,
345
+ provider=model_config.provider,
346
+ key_provider=model_config.key_provider,
347
+ model_config=model_config,
348
+ )
325
349
  )
326
350
  except Exception as exc: # pragma: no cover - defensive; textual path
327
351
  self.notify(f"Failed to select model: {exc}", severity="error")
@@ -0,0 +1,153 @@
1
+ """Migration notice screen for pipx users."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from textual import on
8
+ from textual.app import ComposeResult
9
+ from textual.containers import Container, Horizontal, VerticalScroll
10
+ from textual.screen import ModalScreen
11
+ from textual.widgets import Button, Markdown
12
+
13
+ if TYPE_CHECKING:
14
+ pass
15
+
16
+
17
+ class PipxMigrationScreen(ModalScreen[None]):
18
+ """Modal screen warning pipx users about migration to uvx."""
19
+
20
+ CSS = """
21
+ PipxMigrationScreen {
22
+ align: center middle;
23
+ }
24
+
25
+ #migration-container {
26
+ width: 90;
27
+ height: auto;
28
+ max-height: 90%;
29
+ border: thick $error;
30
+ background: $surface;
31
+ padding: 2;
32
+ }
33
+
34
+ #migration-content {
35
+ height: 1fr;
36
+ padding: 1 0;
37
+ }
38
+
39
+ #buttons-container {
40
+ height: auto;
41
+ padding: 2 0 1 0;
42
+ }
43
+
44
+ #action-buttons {
45
+ width: 100%;
46
+ height: auto;
47
+ align: center middle;
48
+ }
49
+
50
+ .action-button {
51
+ margin: 0 1;
52
+ min-width: 20;
53
+ }
54
+ """
55
+
56
+ BINDINGS = [
57
+ ("escape", "dismiss", "Continue Anyway"),
58
+ ("ctrl+c", "app.quit", "Quit"),
59
+ ]
60
+
61
+ def compose(self) -> ComposeResult:
62
+ """Compose the migration notice modal."""
63
+ with Container(id="migration-container"):
64
+ with VerticalScroll(id="migration-content"):
65
+ yield Markdown(
66
+ """
67
+ ## We've Switched to uvx
68
+
69
+ We've switched from `pipx` to `uvx` as the primary installation method due to critical build issues with our `kuzu` dependency.
70
+
71
+ ### The Problem
72
+ Users with pipx encounter cmake build errors during installation because pip falls back to building from source instead of using pre-built binary wheels.
73
+
74
+ ### The Solution: uvx
75
+ - ✅ **No build tools required** - Binary wheels enforced
76
+ - ✅ **10-100x faster** - Much faster than pipx
77
+ - ✅ **Better reliability** - No cmake/build errors
78
+
79
+ ### How to Migrate
80
+
81
+ **1. Uninstall shotgun-sh from pipx:**
82
+ ```bash
83
+ pipx uninstall shotgun-sh
84
+ ```
85
+
86
+ **2. Install uv:**
87
+ ```bash
88
+ curl -LsSf https://astral.sh/uv/install.sh | sh
89
+ ```
90
+ Or with Homebrew: `brew install uv`
91
+
92
+ **3. Run shotgun-sh with uvx:**
93
+ ```bash
94
+ uvx shotgun-sh
95
+ ```
96
+ Or install permanently: `uv tool install shotgun-sh`
97
+
98
+ ---
99
+
100
+ ### Need Help?
101
+
102
+ **Discord:** https://discord.gg/5RmY6J2N7s
103
+
104
+ **Full Migration Guide:** https://github.com/shotgun-sh/shotgun/blob/main/docs/PIPX_MIGRATION.md
105
+ """
106
+ )
107
+
108
+ with Container(id="buttons-container"):
109
+ with Horizontal(id="action-buttons"):
110
+ yield Button(
111
+ "Copy Instructions to Clipboard",
112
+ variant="default",
113
+ id="copy-instructions",
114
+ classes="action-button",
115
+ )
116
+ yield Button(
117
+ "Continue Anyway",
118
+ variant="primary",
119
+ id="continue",
120
+ classes="action-button",
121
+ )
122
+
123
+ def on_mount(self) -> None:
124
+ """Focus the continue button and ensure scroll starts at top."""
125
+ self.query_one("#continue", Button).focus()
126
+ self.query_one("#migration-content", VerticalScroll).scroll_home(animate=False)
127
+
128
+ @on(Button.Pressed, "#copy-instructions")
129
+ def _copy_instructions(self) -> None:
130
+ """Copy all migration instructions to clipboard."""
131
+ instructions = """# Step 1: Uninstall from pipx
132
+ pipx uninstall shotgun-sh
133
+
134
+ # Step 2: Install uv
135
+ curl -LsSf https://astral.sh/uv/install.sh | sh
136
+
137
+ # Step 3: Run shotgun with uvx
138
+ uvx shotgun-sh"""
139
+ try:
140
+ import pyperclip # type: ignore[import-untyped] # noqa: PGH003
141
+
142
+ pyperclip.copy(instructions)
143
+ self.notify("Copied migration instructions to clipboard!")
144
+ except ImportError:
145
+ self.notify(
146
+ "Clipboard not available. See instructions above.",
147
+ severity="warning",
148
+ )
149
+
150
+ @on(Button.Pressed, "#continue")
151
+ def _continue(self) -> None:
152
+ """Dismiss the modal and continue."""
153
+ self.dismiss()
@@ -136,6 +136,12 @@ class WelcomeScreen(Screen[None]):
136
136
 
137
137
  def on_mount(self) -> None:
138
138
  """Focus the first button on mount."""
139
+ # Update BYOK button text based on whether user has existing providers
140
+ byok_button = self.query_one("#byok-button", Button)
141
+ app = cast("ShotgunApp", self.app)
142
+ if app.config_manager.has_any_provider_key():
143
+ byok_button.label = "I'll stick with my BYOK setup"
144
+
139
145
  self.query_one("#shotgun-button", Button).focus()
140
146
 
141
147
  @on(Button.Pressed, "#shotgun-button")
@@ -146,14 +152,27 @@ class WelcomeScreen(Screen[None]):
146
152
  @on(Button.Pressed, "#byok-button")
147
153
  def _on_byok_pressed(self) -> None:
148
154
  """Handle BYOK button press."""
155
+ self.run_worker(self._start_byok_config(), exclusive=True)
156
+
157
+ async def _start_byok_config(self) -> None:
158
+ """Launch BYOK provider configuration flow."""
149
159
  self._mark_welcome_shown()
150
- # Push provider config screen before dismissing
160
+
161
+ app = cast("ShotgunApp", self.app)
162
+
163
+ # If user already has providers, just dismiss and continue to chat
164
+ if app.config_manager.has_any_provider_key():
165
+ self.dismiss()
166
+ return
167
+
168
+ # Otherwise, push provider config screen and wait for result
151
169
  from .provider_config import ProviderConfigScreen
152
170
 
153
- self.app.push_screen(
154
- ProviderConfigScreen(),
155
- callback=lambda _arg: self.dismiss(),
156
- )
171
+ await self.app.push_screen_wait(ProviderConfigScreen())
172
+
173
+ # Dismiss welcome screen after config if providers are now configured
174
+ if app.config_manager.has_any_provider_key():
175
+ self.dismiss()
157
176
 
158
177
  async def _start_shotgun_auth(self) -> None:
159
178
  """Launch Shotgun Account authentication flow."""
@@ -0,0 +1,5 @@
1
+ """Services for TUI business logic."""
2
+
3
+ from shotgun.tui.services.conversation_service import ConversationService
4
+
5
+ __all__ = ["ConversationService"]
@@ -0,0 +1,182 @@
1
+ """Service for managing conversation persistence and restoration.
2
+
3
+ This service extracts conversation save/load/restore logic from ChatScreen,
4
+ making it testable and reusable.
5
+ """
6
+
7
+ import logging
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING
10
+
11
+ from shotgun.agents.conversation_history import ConversationHistory, ConversationState
12
+ from shotgun.agents.conversation_manager import ConversationManager
13
+ from shotgun.agents.models import AgentType
14
+
15
+ if TYPE_CHECKING:
16
+ from shotgun.agents.agent_manager import AgentManager
17
+ from shotgun.agents.usage_manager import SessionUsageManager
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class ConversationService:
23
+ """Handles conversation persistence and restoration.
24
+
25
+ This service provides:
26
+ - Save current conversation to disk
27
+ - Load conversation from disk
28
+ - Restore conversation state to agent manager
29
+ - Handle corrupted conversations gracefully
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ conversation_manager: ConversationManager | None = None,
35
+ conversation_path: Path | None = None,
36
+ ):
37
+ """Initialize the conversation service.
38
+
39
+ Args:
40
+ conversation_manager: Optional conversation manager. If not provided,
41
+ creates a default one.
42
+ conversation_path: Optional custom path for conversation storage.
43
+ """
44
+ if conversation_manager:
45
+ self.conversation_manager = conversation_manager
46
+ elif conversation_path:
47
+ self.conversation_manager = ConversationManager(conversation_path)
48
+ else:
49
+ self.conversation_manager = ConversationManager()
50
+
51
+ def save_conversation(self, agent_manager: "AgentManager") -> bool:
52
+ """Save the current conversation to persistent storage.
53
+
54
+ Args:
55
+ agent_manager: The agent manager containing conversation state.
56
+
57
+ Returns:
58
+ True if save was successful, False otherwise.
59
+ """
60
+ try:
61
+ # Get conversation state from agent manager
62
+ state = agent_manager.get_conversation_state()
63
+
64
+ # Create conversation history object
65
+ conversation = ConversationHistory(
66
+ last_agent_model=state.agent_type,
67
+ )
68
+ conversation.set_agent_messages(state.agent_messages)
69
+ conversation.set_ui_messages(state.ui_messages)
70
+
71
+ # Save to file
72
+ self.conversation_manager.save(conversation)
73
+ logger.debug("Conversation saved successfully")
74
+ return True
75
+ except Exception as e:
76
+ logger.exception(f"Failed to save conversation: {e}")
77
+ return False
78
+
79
+ def load_conversation(self) -> ConversationHistory | None:
80
+ """Load conversation from persistent storage.
81
+
82
+ Returns:
83
+ The loaded conversation history, or None if no conversation exists
84
+ or if loading failed.
85
+ """
86
+ try:
87
+ conversation = self.conversation_manager.load()
88
+ if conversation is None:
89
+ logger.debug("No conversation file found")
90
+ return None
91
+
92
+ logger.debug("Conversation loaded successfully")
93
+ return conversation
94
+ except Exception as e:
95
+ logger.exception(f"Failed to load conversation: {e}")
96
+ return None
97
+
98
+ def check_for_corrupted_conversation(self) -> bool:
99
+ """Check if a conversation backup exists (indicating corruption).
100
+
101
+ Returns:
102
+ True if a backup exists (conversation was corrupted), False otherwise.
103
+ """
104
+ backup_path = self.conversation_manager.conversation_path.with_suffix(
105
+ ".json.backup"
106
+ )
107
+ return backup_path.exists()
108
+
109
+ def restore_conversation(
110
+ self,
111
+ agent_manager: "AgentManager",
112
+ usage_manager: "SessionUsageManager | None" = None,
113
+ ) -> tuple[bool, str | None, AgentType | None]:
114
+ """Restore conversation state from disk.
115
+
116
+ Args:
117
+ agent_manager: The agent manager to restore state to.
118
+ usage_manager: Optional usage manager to restore usage state.
119
+
120
+ Returns:
121
+ Tuple of (success, error_message, restored_agent_type)
122
+ - success: True if restoration succeeded
123
+ - error_message: Error message if restoration failed, None otherwise
124
+ - restored_agent_type: The agent type from restored conversation
125
+ """
126
+ conversation = self.load_conversation()
127
+
128
+ if conversation is None:
129
+ # Check for corruption
130
+ if self.check_for_corrupted_conversation():
131
+ return (
132
+ False,
133
+ "⚠️ Previous session was corrupted and has been backed up. Starting fresh conversation.",
134
+ None,
135
+ )
136
+ return True, None, None # No conversation to restore is not an error
137
+
138
+ try:
139
+ # Restore agent state
140
+ agent_messages = conversation.get_agent_messages()
141
+ ui_messages = conversation.get_ui_messages()
142
+
143
+ # Create ConversationState for restoration
144
+ state = ConversationState(
145
+ agent_messages=agent_messages,
146
+ ui_messages=ui_messages,
147
+ agent_type=conversation.last_agent_model,
148
+ )
149
+
150
+ agent_manager.restore_conversation_state(state)
151
+
152
+ # Restore usage state if manager provided
153
+ if usage_manager:
154
+ usage_manager.restore_usage_state()
155
+
156
+ restored_type = AgentType(conversation.last_agent_model)
157
+ logger.info(f"Conversation restored successfully (mode: {restored_type})")
158
+ return True, None, restored_type
159
+
160
+ except Exception as e:
161
+ logger.exception(f"Failed to restore conversation state: {e}")
162
+ return (
163
+ False,
164
+ "⚠️ Could not restore previous session. Starting fresh conversation.",
165
+ None,
166
+ )
167
+
168
+ def clear_conversation(self) -> bool:
169
+ """Clear the saved conversation file.
170
+
171
+ Returns:
172
+ True if clearing succeeded, False otherwise.
173
+ """
174
+ try:
175
+ conversation_path = self.conversation_manager.conversation_path
176
+ if conversation_path.exists():
177
+ conversation_path.unlink()
178
+ logger.info("Conversation file cleared")
179
+ return True
180
+ except Exception as e:
181
+ logger.exception(f"Failed to clear conversation: {e}")
182
+ return False
@@ -0,0 +1,7 @@
1
+ """State management utilities for TUI."""
2
+
3
+ from .processing_state import ProcessingStateManager
4
+
5
+ __all__ = [
6
+ "ProcessingStateManager",
7
+ ]