shotgun-sh 0.1.9__py3-none-any.whl → 0.2.11__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of shotgun-sh might be problematic. Click here for more details.
- shotgun/agents/agent_manager.py +761 -52
- shotgun/agents/common.py +80 -75
- shotgun/agents/config/constants.py +21 -10
- shotgun/agents/config/manager.py +322 -97
- shotgun/agents/config/models.py +114 -84
- shotgun/agents/config/provider.py +232 -88
- shotgun/agents/context_analyzer/__init__.py +28 -0
- shotgun/agents/context_analyzer/analyzer.py +471 -0
- shotgun/agents/context_analyzer/constants.py +9 -0
- shotgun/agents/context_analyzer/formatter.py +115 -0
- shotgun/agents/context_analyzer/models.py +212 -0
- shotgun/agents/conversation_history.py +125 -2
- shotgun/agents/conversation_manager.py +57 -19
- shotgun/agents/export.py +6 -7
- shotgun/agents/history/compaction.py +23 -3
- shotgun/agents/history/context_extraction.py +93 -6
- shotgun/agents/history/history_processors.py +179 -11
- shotgun/agents/history/token_counting/__init__.py +31 -0
- shotgun/agents/history/token_counting/anthropic.py +127 -0
- shotgun/agents/history/token_counting/base.py +78 -0
- shotgun/agents/history/token_counting/openai.py +90 -0
- shotgun/agents/history/token_counting/sentencepiece_counter.py +127 -0
- shotgun/agents/history/token_counting/tokenizer_cache.py +92 -0
- shotgun/agents/history/token_counting/utils.py +144 -0
- shotgun/agents/history/token_estimation.py +12 -12
- shotgun/agents/llm.py +62 -0
- shotgun/agents/models.py +59 -4
- shotgun/agents/plan.py +6 -7
- shotgun/agents/research.py +7 -8
- shotgun/agents/specify.py +6 -7
- shotgun/agents/tasks.py +6 -7
- shotgun/agents/tools/__init__.py +0 -2
- shotgun/agents/tools/codebase/codebase_shell.py +6 -0
- shotgun/agents/tools/codebase/directory_lister.py +6 -0
- shotgun/agents/tools/codebase/file_read.py +11 -2
- shotgun/agents/tools/codebase/query_graph.py +6 -0
- shotgun/agents/tools/codebase/retrieve_code.py +6 -0
- shotgun/agents/tools/file_management.py +82 -16
- shotgun/agents/tools/registry.py +217 -0
- shotgun/agents/tools/web_search/__init__.py +55 -16
- shotgun/agents/tools/web_search/anthropic.py +76 -51
- shotgun/agents/tools/web_search/gemini.py +50 -27
- shotgun/agents/tools/web_search/openai.py +26 -17
- shotgun/agents/tools/web_search/utils.py +2 -2
- shotgun/agents/usage_manager.py +164 -0
- shotgun/api_endpoints.py +15 -0
- shotgun/cli/clear.py +53 -0
- shotgun/cli/codebase/commands.py +71 -2
- shotgun/cli/compact.py +186 -0
- shotgun/cli/config.py +41 -67
- shotgun/cli/context.py +111 -0
- shotgun/cli/export.py +1 -1
- shotgun/cli/feedback.py +50 -0
- shotgun/cli/models.py +3 -2
- shotgun/cli/plan.py +1 -1
- shotgun/cli/research.py +1 -1
- shotgun/cli/specify.py +1 -1
- shotgun/cli/tasks.py +1 -1
- shotgun/cli/update.py +18 -5
- shotgun/codebase/core/change_detector.py +5 -3
- shotgun/codebase/core/code_retrieval.py +4 -2
- shotgun/codebase/core/ingestor.py +169 -19
- shotgun/codebase/core/manager.py +177 -13
- shotgun/codebase/core/nl_query.py +1 -1
- shotgun/codebase/models.py +28 -3
- shotgun/codebase/service.py +14 -2
- shotgun/exceptions.py +32 -0
- shotgun/llm_proxy/__init__.py +19 -0
- shotgun/llm_proxy/clients.py +44 -0
- shotgun/llm_proxy/constants.py +15 -0
- shotgun/logging_config.py +18 -27
- shotgun/main.py +91 -4
- shotgun/posthog_telemetry.py +87 -40
- shotgun/prompts/agents/export.j2 +18 -1
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +5 -1
- shotgun/prompts/agents/partials/interactive_mode.j2 +24 -7
- shotgun/prompts/agents/plan.j2 +1 -1
- shotgun/prompts/agents/research.j2 +1 -1
- shotgun/prompts/agents/specify.j2 +270 -3
- shotgun/prompts/agents/state/system_state.j2 +4 -0
- shotgun/prompts/agents/tasks.j2 +1 -1
- shotgun/prompts/codebase/partials/cypher_rules.j2 +13 -0
- shotgun/prompts/loader.py +2 -2
- shotgun/prompts/tools/web_search.j2 +14 -0
- shotgun/sdk/codebase.py +60 -2
- shotgun/sentry_telemetry.py +28 -21
- shotgun/settings.py +238 -0
- shotgun/shotgun_web/__init__.py +19 -0
- shotgun/shotgun_web/client.py +138 -0
- shotgun/shotgun_web/constants.py +21 -0
- shotgun/shotgun_web/models.py +47 -0
- shotgun/telemetry.py +24 -36
- shotgun/tui/app.py +275 -23
- shotgun/tui/commands/__init__.py +1 -1
- shotgun/tui/components/context_indicator.py +179 -0
- shotgun/tui/components/mode_indicator.py +70 -0
- shotgun/tui/components/status_bar.py +48 -0
- shotgun/tui/components/vertical_tail.py +6 -0
- shotgun/tui/containers.py +91 -0
- shotgun/tui/dependencies.py +39 -0
- shotgun/tui/filtered_codebase_service.py +46 -0
- shotgun/tui/protocols.py +45 -0
- shotgun/tui/screens/chat/__init__.py +5 -0
- shotgun/tui/screens/chat/chat.tcss +54 -0
- shotgun/tui/screens/chat/chat_screen.py +1234 -0
- shotgun/tui/screens/chat/codebase_index_prompt_screen.py +64 -0
- shotgun/tui/screens/chat/codebase_index_selection.py +12 -0
- shotgun/tui/screens/chat/help_text.py +40 -0
- shotgun/tui/screens/chat/prompt_history.py +48 -0
- shotgun/tui/screens/chat.tcss +11 -0
- shotgun/tui/screens/chat_screen/command_providers.py +226 -11
- shotgun/tui/screens/chat_screen/history/__init__.py +22 -0
- shotgun/tui/screens/chat_screen/history/agent_response.py +66 -0
- shotgun/tui/screens/chat_screen/history/chat_history.py +116 -0
- shotgun/tui/screens/chat_screen/history/formatters.py +115 -0
- shotgun/tui/screens/chat_screen/history/partial_response.py +43 -0
- shotgun/tui/screens/chat_screen/history/user_question.py +42 -0
- shotgun/tui/screens/confirmation_dialog.py +151 -0
- shotgun/tui/screens/feedback.py +193 -0
- shotgun/tui/screens/github_issue.py +102 -0
- shotgun/tui/screens/model_picker.py +352 -0
- shotgun/tui/screens/onboarding.py +431 -0
- shotgun/tui/screens/pipx_migration.py +153 -0
- shotgun/tui/screens/provider_config.py +156 -39
- shotgun/tui/screens/shotgun_auth.py +295 -0
- shotgun/tui/screens/welcome.py +198 -0
- shotgun/tui/services/__init__.py +5 -0
- shotgun/tui/services/conversation_service.py +184 -0
- shotgun/tui/state/__init__.py +7 -0
- shotgun/tui/state/processing_state.py +185 -0
- shotgun/tui/utils/mode_progress.py +14 -7
- shotgun/tui/widgets/__init__.py +5 -0
- shotgun/tui/widgets/widget_coordinator.py +262 -0
- shotgun/utils/datetime_utils.py +77 -0
- shotgun/utils/env_utils.py +13 -0
- shotgun/utils/file_system_utils.py +22 -2
- shotgun/utils/marketing.py +110 -0
- shotgun/utils/source_detection.py +16 -0
- shotgun/utils/update_checker.py +73 -21
- shotgun_sh-0.2.11.dist-info/METADATA +130 -0
- shotgun_sh-0.2.11.dist-info/RECORD +194 -0
- {shotgun_sh-0.1.9.dist-info → shotgun_sh-0.2.11.dist-info}/entry_points.txt +1 -0
- {shotgun_sh-0.1.9.dist-info → shotgun_sh-0.2.11.dist-info}/licenses/LICENSE +1 -1
- shotgun/agents/history/token_counting.py +0 -429
- shotgun/agents/tools/user_interaction.py +0 -37
- shotgun/tui/screens/chat.py +0 -818
- shotgun/tui/screens/chat_screen/history.py +0 -222
- shotgun_sh-0.1.9.dist-info/METADATA +0 -466
- shotgun_sh-0.1.9.dist-info/RECORD +0 -131
- {shotgun_sh-0.1.9.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()
|