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.

Files changed (143) hide show
  1. shotgun/agents/agent_manager.py +715 -75
  2. shotgun/agents/common.py +80 -75
  3. shotgun/agents/config/constants.py +21 -10
  4. shotgun/agents/config/manager.py +322 -97
  5. shotgun/agents/config/models.py +114 -84
  6. shotgun/agents/config/provider.py +232 -88
  7. shotgun/agents/context_analyzer/__init__.py +28 -0
  8. shotgun/agents/context_analyzer/analyzer.py +471 -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 +57 -19
  14. shotgun/agents/export.py +6 -7
  15. shotgun/agents/history/compaction.py +10 -5
  16. shotgun/agents/history/context_extraction.py +93 -6
  17. shotgun/agents/history/history_processors.py +129 -12
  18. shotgun/agents/history/token_counting/__init__.py +31 -0
  19. shotgun/agents/history/token_counting/anthropic.py +127 -0
  20. shotgun/agents/history/token_counting/base.py +78 -0
  21. shotgun/agents/history/token_counting/openai.py +90 -0
  22. shotgun/agents/history/token_counting/sentencepiece_counter.py +127 -0
  23. shotgun/agents/history/token_counting/tokenizer_cache.py +92 -0
  24. shotgun/agents/history/token_counting/utils.py +144 -0
  25. shotgun/agents/history/token_estimation.py +12 -12
  26. shotgun/agents/llm.py +62 -0
  27. shotgun/agents/models.py +59 -4
  28. shotgun/agents/plan.py +6 -7
  29. shotgun/agents/research.py +7 -8
  30. shotgun/agents/specify.py +6 -7
  31. shotgun/agents/tasks.py +6 -7
  32. shotgun/agents/tools/__init__.py +0 -2
  33. shotgun/agents/tools/codebase/codebase_shell.py +6 -0
  34. shotgun/agents/tools/codebase/directory_lister.py +6 -0
  35. shotgun/agents/tools/codebase/file_read.py +11 -2
  36. shotgun/agents/tools/codebase/query_graph.py +6 -0
  37. shotgun/agents/tools/codebase/retrieve_code.py +6 -0
  38. shotgun/agents/tools/file_management.py +82 -16
  39. shotgun/agents/tools/registry.py +217 -0
  40. shotgun/agents/tools/web_search/__init__.py +55 -16
  41. shotgun/agents/tools/web_search/anthropic.py +76 -51
  42. shotgun/agents/tools/web_search/gemini.py +50 -27
  43. shotgun/agents/tools/web_search/openai.py +26 -17
  44. shotgun/agents/tools/web_search/utils.py +2 -2
  45. shotgun/agents/usage_manager.py +164 -0
  46. shotgun/api_endpoints.py +15 -0
  47. shotgun/cli/clear.py +53 -0
  48. shotgun/cli/compact.py +186 -0
  49. shotgun/cli/config.py +41 -67
  50. shotgun/cli/context.py +111 -0
  51. shotgun/cli/export.py +1 -1
  52. shotgun/cli/feedback.py +50 -0
  53. shotgun/cli/models.py +3 -2
  54. shotgun/cli/plan.py +1 -1
  55. shotgun/cli/research.py +1 -1
  56. shotgun/cli/specify.py +1 -1
  57. shotgun/cli/tasks.py +1 -1
  58. shotgun/cli/update.py +16 -2
  59. shotgun/codebase/core/change_detector.py +5 -3
  60. shotgun/codebase/core/code_retrieval.py +4 -2
  61. shotgun/codebase/core/ingestor.py +57 -16
  62. shotgun/codebase/core/manager.py +20 -7
  63. shotgun/codebase/core/nl_query.py +1 -1
  64. shotgun/codebase/models.py +4 -4
  65. shotgun/exceptions.py +32 -0
  66. shotgun/llm_proxy/__init__.py +19 -0
  67. shotgun/llm_proxy/clients.py +44 -0
  68. shotgun/llm_proxy/constants.py +15 -0
  69. shotgun/logging_config.py +18 -27
  70. shotgun/main.py +91 -12
  71. shotgun/posthog_telemetry.py +81 -10
  72. shotgun/prompts/agents/export.j2 +18 -1
  73. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +5 -1
  74. shotgun/prompts/agents/partials/interactive_mode.j2 +24 -7
  75. shotgun/prompts/agents/plan.j2 +1 -1
  76. shotgun/prompts/agents/research.j2 +1 -1
  77. shotgun/prompts/agents/specify.j2 +270 -3
  78. shotgun/prompts/agents/state/system_state.j2 +4 -0
  79. shotgun/prompts/agents/tasks.j2 +1 -1
  80. shotgun/prompts/loader.py +2 -2
  81. shotgun/prompts/tools/web_search.j2 +14 -0
  82. shotgun/sentry_telemetry.py +27 -18
  83. shotgun/settings.py +238 -0
  84. shotgun/shotgun_web/__init__.py +19 -0
  85. shotgun/shotgun_web/client.py +138 -0
  86. shotgun/shotgun_web/constants.py +21 -0
  87. shotgun/shotgun_web/models.py +47 -0
  88. shotgun/telemetry.py +24 -36
  89. shotgun/tui/app.py +251 -23
  90. shotgun/tui/commands/__init__.py +1 -1
  91. shotgun/tui/components/context_indicator.py +179 -0
  92. shotgun/tui/components/mode_indicator.py +70 -0
  93. shotgun/tui/components/status_bar.py +48 -0
  94. shotgun/tui/containers.py +91 -0
  95. shotgun/tui/dependencies.py +39 -0
  96. shotgun/tui/protocols.py +45 -0
  97. shotgun/tui/screens/chat/__init__.py +5 -0
  98. shotgun/tui/screens/chat/chat.tcss +54 -0
  99. shotgun/tui/screens/chat/chat_screen.py +1234 -0
  100. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +64 -0
  101. shotgun/tui/screens/chat/codebase_index_selection.py +12 -0
  102. shotgun/tui/screens/chat/help_text.py +40 -0
  103. shotgun/tui/screens/chat/prompt_history.py +48 -0
  104. shotgun/tui/screens/chat.tcss +11 -0
  105. shotgun/tui/screens/chat_screen/command_providers.py +226 -11
  106. shotgun/tui/screens/chat_screen/history/__init__.py +22 -0
  107. shotgun/tui/screens/chat_screen/history/agent_response.py +66 -0
  108. shotgun/tui/screens/chat_screen/history/chat_history.py +116 -0
  109. shotgun/tui/screens/chat_screen/history/formatters.py +115 -0
  110. shotgun/tui/screens/chat_screen/history/partial_response.py +43 -0
  111. shotgun/tui/screens/chat_screen/history/user_question.py +42 -0
  112. shotgun/tui/screens/confirmation_dialog.py +151 -0
  113. shotgun/tui/screens/feedback.py +193 -0
  114. shotgun/tui/screens/github_issue.py +102 -0
  115. shotgun/tui/screens/model_picker.py +352 -0
  116. shotgun/tui/screens/onboarding.py +431 -0
  117. shotgun/tui/screens/pipx_migration.py +153 -0
  118. shotgun/tui/screens/provider_config.py +156 -39
  119. shotgun/tui/screens/shotgun_auth.py +295 -0
  120. shotgun/tui/screens/welcome.py +198 -0
  121. shotgun/tui/services/__init__.py +5 -0
  122. shotgun/tui/services/conversation_service.py +184 -0
  123. shotgun/tui/state/__init__.py +7 -0
  124. shotgun/tui/state/processing_state.py +185 -0
  125. shotgun/tui/utils/mode_progress.py +14 -7
  126. shotgun/tui/widgets/__init__.py +5 -0
  127. shotgun/tui/widgets/widget_coordinator.py +262 -0
  128. shotgun/utils/datetime_utils.py +77 -0
  129. shotgun/utils/env_utils.py +13 -0
  130. shotgun/utils/file_system_utils.py +22 -2
  131. shotgun/utils/marketing.py +110 -0
  132. shotgun/utils/update_checker.py +69 -14
  133. shotgun_sh-0.2.11.dist-info/METADATA +130 -0
  134. shotgun_sh-0.2.11.dist-info/RECORD +194 -0
  135. {shotgun_sh-0.1.14.dist-info → shotgun_sh-0.2.11.dist-info}/entry_points.txt +1 -0
  136. {shotgun_sh-0.1.14.dist-info → shotgun_sh-0.2.11.dist-info}/licenses/LICENSE +1 -1
  137. shotgun/agents/history/token_counting.py +0 -429
  138. shotgun/agents/tools/user_interaction.py +0 -37
  139. shotgun/tui/screens/chat.py +0 -797
  140. shotgun/tui/screens/chat_screen/history.py +0 -350
  141. shotgun_sh-0.1.14.dist-info/METADATA +0 -466
  142. shotgun_sh-0.1.14.dist-info/RECORD +0 -133
  143. {shotgun_sh-0.1.14.dist-info → shotgun_sh-0.2.11.dist-info}/WHEEL +0 -0
@@ -0,0 +1,115 @@
1
+ """Tool formatting utilities for chat history display."""
2
+
3
+ import json
4
+
5
+ from pydantic_ai.messages import BuiltinToolCallPart, ToolCallPart
6
+
7
+ from shotgun.agents.tools.registry import get_tool_display_config
8
+
9
+
10
+ class ToolFormatter:
11
+ """Formats tool calls for display in the TUI."""
12
+
13
+ @staticmethod
14
+ def truncate(text: str, max_length: int = 100) -> str:
15
+ """Truncate text to max_length characters, adding ellipsis if needed."""
16
+ if len(text) <= max_length:
17
+ return text
18
+ return text[: max_length - 3] + "..."
19
+
20
+ @staticmethod
21
+ def parse_args(args: dict[str, object] | str | None) -> dict[str, object]:
22
+ """Parse tool call arguments, handling both dict and JSON string formats."""
23
+ if args is None:
24
+ return {}
25
+ if isinstance(args, str):
26
+ try:
27
+ return json.loads(args) if args.strip() else {}
28
+ except json.JSONDecodeError:
29
+ return {}
30
+ return args if isinstance(args, dict) else {}
31
+
32
+ @classmethod
33
+ def format_tool_call_part(cls, part: ToolCallPart) -> str:
34
+ """Format a tool call part using the tool display registry."""
35
+ # Look up the display config for this tool
36
+ display_config = get_tool_display_config(part.tool_name)
37
+
38
+ if display_config:
39
+ # Tool is registered - use its display config
40
+ if display_config.hide:
41
+ return ""
42
+
43
+ # Parse args
44
+ args = cls.parse_args(part.args)
45
+
46
+ # Get the key argument value
47
+ if args and isinstance(args, dict) and display_config.key_arg in args:
48
+ # Special handling for codebase_shell which needs command + args
49
+ if part.tool_name == "codebase_shell" and "command" in args:
50
+ command = args.get("command", "")
51
+ cmd_args = args.get("args", [])
52
+ if isinstance(cmd_args, list):
53
+ args_str = " ".join(str(arg) for arg in cmd_args)
54
+ else:
55
+ args_str = ""
56
+ key_value = f"{command} {args_str}".strip()
57
+ else:
58
+ key_value = str(args[display_config.key_arg])
59
+
60
+ # Format: "display_text: key_value"
61
+ return f"{display_config.display_text}: {cls.truncate(key_value)}"
62
+ else:
63
+ # No key arg value available - show just display_text
64
+ return display_config.display_text
65
+
66
+ # Tool not registered - use fallback formatting
67
+ args = cls.parse_args(part.args)
68
+ if args and isinstance(args, dict):
69
+ # Try to extract common fields
70
+ if "query" in args:
71
+ return f"{part.tool_name}: {cls.truncate(str(args['query']))}"
72
+ elif "question" in args:
73
+ return f"{part.tool_name}: {cls.truncate(str(args['question']))}"
74
+ elif "filename" in args:
75
+ return f"{part.tool_name}: {args['filename']}"
76
+ else:
77
+ # Show tool name with truncated args
78
+ args_str = (
79
+ str(part.args)[:50] + "..."
80
+ if len(str(part.args)) > 50
81
+ else str(part.args)
82
+ )
83
+ return f"{part.tool_name}({args_str})"
84
+ else:
85
+ return f"{part.tool_name}()"
86
+
87
+ @classmethod
88
+ def format_builtin_tool_call(cls, part: BuiltinToolCallPart) -> str:
89
+ """Format a builtin tool call part using the tool display registry."""
90
+ display_config = get_tool_display_config(part.tool_name or "")
91
+
92
+ if display_config:
93
+ if display_config.hide:
94
+ return ""
95
+
96
+ args = cls.parse_args(part.args)
97
+ # Get the key argument value
98
+ if args and isinstance(args, dict) and display_config.key_arg in args:
99
+ key_value = str(args[display_config.key_arg])
100
+ # Format: "display_text: key_value"
101
+ return f"{display_config.display_text}: {cls.truncate(key_value)}"
102
+ else:
103
+ # No key arg value available - show just display_text
104
+ return display_config.display_text
105
+ else:
106
+ # Fallback for unregistered builtin tools
107
+ if part.args:
108
+ args_str = (
109
+ str(part.args)[:50] + "..."
110
+ if len(str(part.args)) > 50
111
+ else str(part.args)
112
+ )
113
+ return f"{part.tool_name}({args_str})"
114
+ else:
115
+ return f"{part.tool_name}()"
@@ -0,0 +1,43 @@
1
+ """Partial response widget for streaming chat messages."""
2
+
3
+ from pydantic_ai.messages import ModelMessage
4
+ from textual.app import ComposeResult
5
+ from textual.reactive import reactive
6
+ from textual.widget import Widget
7
+
8
+ from .agent_response import AgentResponseWidget
9
+ from .user_question import UserQuestionWidget
10
+
11
+
12
+ class PartialResponseWidget(Widget): # TODO: doesn't work lol
13
+ """Widget that displays a streaming/partial response in the chat history."""
14
+
15
+ DEFAULT_CSS = """
16
+ PartialResponseWidget {
17
+ height: auto;
18
+ }
19
+ Markdown, AgentResponseWidget, UserQuestionWidget {
20
+ height: auto;
21
+ }
22
+ """
23
+
24
+ item: reactive[ModelMessage | None] = reactive(None, recompose=True)
25
+
26
+ def __init__(self, item: ModelMessage | None) -> None:
27
+ super().__init__()
28
+ self.item = item
29
+
30
+ def compose(self) -> ComposeResult:
31
+ if self.item is None:
32
+ pass
33
+ elif self.item.kind == "response":
34
+ yield AgentResponseWidget(self.item)
35
+ elif self.item.kind == "request":
36
+ yield UserQuestionWidget(self.item)
37
+
38
+ def watch_item(self, item: ModelMessage | None) -> None:
39
+ """React to changes in the item."""
40
+ if item is None:
41
+ self.display = False
42
+ else:
43
+ self.display = True
@@ -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)
@@ -0,0 +1,193 @@
1
+ """Screen for submitting user feedback."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, cast
6
+
7
+ from textual import on
8
+ from textual.app import ComposeResult
9
+ from textual.containers import Horizontal, Vertical
10
+ from textual.reactive import reactive
11
+ from textual.screen import Screen
12
+ from textual.widgets import Button, Label, ListItem, ListView, Static, TextArea
13
+
14
+ from shotgun.posthog_telemetry import Feedback, FeedbackKind
15
+
16
+ if TYPE_CHECKING:
17
+ from ..app import ShotgunApp
18
+
19
+
20
+ class FeedbackScreen(Screen[Feedback | None]):
21
+ """Collect feedback from users."""
22
+
23
+ CSS = """
24
+ FeedbackScreen {
25
+ layout: vertical;
26
+ }
27
+
28
+ FeedbackScreen > * {
29
+ height: auto;
30
+ }
31
+
32
+ Label {
33
+ padding: 0 1;
34
+ }
35
+
36
+ #titlebox {
37
+ height: auto;
38
+ margin: 2 0;
39
+ padding: 1;
40
+ border: hkey $border;
41
+ content-align: center middle;
42
+
43
+ & > * {
44
+ text-align: center;
45
+ }
46
+ }
47
+
48
+ #feedback-title {
49
+ padding: 1 0;
50
+ margin-bottom: 2;
51
+ text-style: bold;
52
+ color: $text-accent;
53
+ }
54
+
55
+ #feedback-type-list {
56
+ height: auto;
57
+ & > * {
58
+ padding: 1 0;
59
+ }
60
+ }
61
+
62
+ #feedback-description {
63
+ margin: 1 0;
64
+ height: 10;
65
+ border: solid $border;
66
+ }
67
+
68
+ #feedback-actions {
69
+ padding: 1;
70
+ }
71
+
72
+ #feedback-actions > * {
73
+ margin-right: 2;
74
+ }
75
+
76
+ #feedback-type-list {
77
+ padding: 1;
78
+ }
79
+ """
80
+
81
+ BINDINGS = [
82
+ ("escape", "cancel", "Cancel"),
83
+ ]
84
+
85
+ selected_kind: reactive[FeedbackKind] = reactive(FeedbackKind.BUG)
86
+
87
+ def compose(self) -> ComposeResult:
88
+ with Vertical(id="titlebox"):
89
+ yield Static("Send us feedback", id="feedback-title")
90
+ yield Static(
91
+ "Select the type of feedback and provide details below.",
92
+ id="feedback-summary",
93
+ )
94
+ yield ListView(*self._build_feedback_type_items(), id="feedback-type-list")
95
+ yield TextArea(
96
+ "",
97
+ id="feedback-description",
98
+ )
99
+ with Horizontal(id="feedback-actions"):
100
+ yield Button("Submit", variant="primary", id="submit")
101
+ yield Button("Cancel \\[ESC]", id="cancel")
102
+
103
+ def on_mount(self) -> None:
104
+ list_view = self.query_one(ListView)
105
+ if list_view.children:
106
+ list_view.index = 0
107
+ self.selected_kind = FeedbackKind.BUG
108
+ text_area = self.query_one("#feedback-description", TextArea)
109
+ text_area.focus()
110
+
111
+ def action_cancel(self) -> None:
112
+ self.dismiss(None)
113
+
114
+ @on(ListView.Highlighted)
115
+ def _on_kind_highlighted(self, event: ListView.Highlighted) -> None:
116
+ kind = self._kind_from_item(event.item)
117
+ if kind:
118
+ self.selected_kind = kind
119
+
120
+ @on(ListView.Selected)
121
+ def _on_kind_selected(self, event: ListView.Selected) -> None:
122
+ kind = self._kind_from_item(event.item)
123
+ if kind:
124
+ self.selected_kind = kind
125
+ self.set_focus(self.query_one("#feedback-description", TextArea))
126
+
127
+ @on(Button.Pressed, "#submit")
128
+ async def _on_submit_pressed(self) -> None:
129
+ await self._submit_feedback()
130
+
131
+ @on(Button.Pressed, "#cancel")
132
+ def _on_cancel_pressed(self) -> None:
133
+ self.action_cancel()
134
+
135
+ def watch_selected_kind(self, kind: FeedbackKind) -> None:
136
+ if not self.is_mounted:
137
+ return
138
+ # Update the placeholder in text area based on selected kind
139
+ text_area = self.query_one("#feedback-description", TextArea)
140
+ text_area.placeholder = self._placeholder_for_kind(kind)
141
+
142
+ def _build_feedback_type_items(self) -> list[ListItem]:
143
+ items: list[ListItem] = []
144
+ for kind in FeedbackKind:
145
+ label = Label(self._kind_label(kind), id=f"label-{kind.value}")
146
+ items.append(ListItem(label, id=f"kind-{kind.value}"))
147
+ return items
148
+
149
+ def _kind_from_item(self, item: ListItem | None) -> FeedbackKind | None:
150
+ if item is None or item.id is None:
151
+ return None
152
+ kind_id = item.id.removeprefix("kind-")
153
+ try:
154
+ return FeedbackKind(kind_id)
155
+ except ValueError:
156
+ return None
157
+
158
+ def _kind_label(self, kind: FeedbackKind) -> str:
159
+ display_names = {
160
+ FeedbackKind.BUG: "Bug Report",
161
+ FeedbackKind.FEATURE: "Feature Request",
162
+ FeedbackKind.OTHER: "Other",
163
+ }
164
+ return display_names.get(kind, kind.value.title())
165
+
166
+ def _placeholder_for_kind(self, kind: FeedbackKind) -> str:
167
+ placeholders = {
168
+ FeedbackKind.BUG: "Describe the bug you encountered...",
169
+ FeedbackKind.FEATURE: "Describe the feature you'd like to see...",
170
+ FeedbackKind.OTHER: "Tell us what's on your mind...",
171
+ }
172
+ return placeholders.get(kind, "Enter your feedback...")
173
+
174
+ async def _submit_feedback(self) -> None:
175
+ text_area = self.query_one("#feedback-description", TextArea)
176
+ description = text_area.text.strip()
177
+
178
+ if not description:
179
+ self.notify(
180
+ "Please enter a description before submitting.", severity="error"
181
+ )
182
+ return
183
+
184
+ app = cast("ShotgunApp", self.app)
185
+ shotgun_instance_id = await app.config_manager.get_shotgun_instance_id()
186
+
187
+ feedback = Feedback(
188
+ kind=self.selected_kind,
189
+ description=description,
190
+ shotgun_instance_id=shotgun_instance_id,
191
+ )
192
+
193
+ self.dismiss(feedback)
@@ -0,0 +1,102 @@
1
+ """Screen for guiding users to create GitHub issues."""
2
+
3
+ import webbrowser
4
+
5
+ from textual import on
6
+ from textual.app import ComposeResult
7
+ from textual.containers import Container, Vertical
8
+ from textual.screen import ModalScreen
9
+ from textual.widgets import Button, Markdown, Static
10
+
11
+
12
+ class GitHubIssueScreen(ModalScreen[None]):
13
+ """Guide users to create issues on GitHub."""
14
+
15
+ CSS = """
16
+ GitHubIssueScreen {
17
+ align: center middle;
18
+ }
19
+
20
+ #issue-container {
21
+ width: 70;
22
+ max-width: 100;
23
+ height: auto;
24
+ border: thick $primary;
25
+ background: $surface;
26
+ padding: 2;
27
+ }
28
+
29
+ #issue-header {
30
+ text-style: bold;
31
+ color: $text-accent;
32
+ padding-bottom: 1;
33
+ text-align: center;
34
+ }
35
+
36
+ #issue-content {
37
+ padding: 1 0;
38
+ }
39
+
40
+ #issue-buttons {
41
+ height: auto;
42
+ padding: 2 0 0 0;
43
+ align: center middle;
44
+ }
45
+
46
+ #issue-buttons Button {
47
+ margin: 1 1;
48
+ min-width: 20;
49
+ }
50
+ """
51
+
52
+ BINDINGS = [
53
+ ("escape", "dismiss", "Close"),
54
+ ]
55
+
56
+ def compose(self) -> ComposeResult:
57
+ """Compose the GitHub issue screen."""
58
+ with Container(id="issue-container"):
59
+ yield Static("Create a GitHub Issue", id="issue-header")
60
+ with Vertical(id="issue-content"):
61
+ yield Markdown(
62
+ """
63
+ ## Report Bugs or Request Features
64
+
65
+ We track all bugs, feature requests, and improvements on GitHub Issues.
66
+
67
+ ### How to Create an Issue:
68
+
69
+ 1. Click the button below to open our GitHub Issues page
70
+ 2. Click **"New Issue"**
71
+ 3. Choose a template:
72
+ - **Bug Report** - Report a bug or unexpected behavior
73
+ - **Feature Request** - Suggest new functionality
74
+ - **Documentation** - Report docs issues or improvements
75
+ 4. Fill in the details and submit
76
+
77
+ We review all issues and will respond as soon as possible!
78
+
79
+ ### Before Creating an Issue:
80
+
81
+ - Search existing issues to avoid duplicates
82
+ - Include steps to reproduce for bugs
83
+ - Be specific about what you'd like for feature requests
84
+ """,
85
+ id="issue-markdown",
86
+ )
87
+ with Vertical(id="issue-buttons"):
88
+ yield Button(
89
+ "🐙 Open GitHub Issues", id="github-button", variant="primary"
90
+ )
91
+ yield Button("Close", id="close-button")
92
+
93
+ @on(Button.Pressed, "#github-button")
94
+ def handle_github(self) -> None:
95
+ """Open GitHub issues page in browser."""
96
+ webbrowser.open("https://github.com/shotgun-sh/shotgun/issues")
97
+ self.notify("Opening GitHub Issues in your browser...")
98
+
99
+ @on(Button.Pressed, "#close-button")
100
+ def handle_close(self) -> None:
101
+ """Handle close button press."""
102
+ self.dismiss()