shotgun-sh 0.2.8.dev2__py3-none-any.whl → 0.3.3.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.
- shotgun/agents/agent_manager.py +382 -60
- shotgun/agents/common.py +15 -9
- shotgun/agents/config/README.md +89 -0
- shotgun/agents/config/__init__.py +10 -1
- shotgun/agents/config/constants.py +0 -6
- shotgun/agents/config/manager.py +383 -82
- shotgun/agents/config/models.py +122 -18
- shotgun/agents/config/provider.py +81 -15
- shotgun/agents/config/streaming_test.py +119 -0
- shotgun/agents/context_analyzer/__init__.py +28 -0
- shotgun/agents/context_analyzer/analyzer.py +475 -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/__init__.py +18 -0
- shotgun/agents/conversation/filters.py +164 -0
- shotgun/agents/conversation/history/chunking.py +278 -0
- shotgun/agents/{history → conversation/history}/compaction.py +36 -5
- shotgun/agents/{history → conversation/history}/constants.py +5 -0
- shotgun/agents/conversation/history/file_content_deduplication.py +216 -0
- shotgun/agents/{history → conversation/history}/history_processors.py +380 -8
- shotgun/agents/{history → conversation/history}/token_counting/anthropic.py +25 -1
- shotgun/agents/{history → conversation/history}/token_counting/base.py +14 -3
- shotgun/agents/{history → conversation/history}/token_counting/openai.py +11 -1
- shotgun/agents/{history → conversation/history}/token_counting/sentencepiece_counter.py +8 -0
- shotgun/agents/{history → conversation/history}/token_counting/tokenizer_cache.py +3 -1
- shotgun/agents/{history → conversation/history}/token_counting/utils.py +0 -3
- shotgun/agents/{conversation_manager.py → conversation/manager.py} +36 -20
- shotgun/agents/{conversation_history.py → conversation/models.py} +8 -92
- shotgun/agents/error/__init__.py +11 -0
- shotgun/agents/error/models.py +19 -0
- shotgun/agents/export.py +2 -2
- shotgun/agents/plan.py +2 -2
- shotgun/agents/research.py +3 -3
- shotgun/agents/runner.py +230 -0
- shotgun/agents/specify.py +2 -2
- shotgun/agents/tasks.py +2 -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 +27 -7
- shotgun/agents/tools/registry.py +217 -0
- shotgun/agents/tools/web_search/__init__.py +8 -8
- shotgun/agents/tools/web_search/anthropic.py +8 -2
- shotgun/agents/tools/web_search/gemini.py +7 -1
- shotgun/agents/tools/web_search/openai.py +8 -2
- shotgun/agents/tools/web_search/utils.py +2 -2
- shotgun/agents/usage_manager.py +16 -11
- shotgun/api_endpoints.py +7 -3
- shotgun/build_constants.py +2 -2
- shotgun/cli/clear.py +53 -0
- shotgun/cli/compact.py +188 -0
- shotgun/cli/config.py +8 -5
- shotgun/cli/context.py +154 -0
- shotgun/cli/error_handler.py +24 -0
- shotgun/cli/export.py +34 -34
- shotgun/cli/feedback.py +4 -2
- shotgun/cli/models.py +1 -0
- shotgun/cli/plan.py +34 -34
- shotgun/cli/research.py +18 -10
- shotgun/cli/spec/__init__.py +5 -0
- shotgun/cli/spec/backup.py +81 -0
- shotgun/cli/spec/commands.py +132 -0
- shotgun/cli/spec/models.py +48 -0
- shotgun/cli/spec/pull_service.py +219 -0
- shotgun/cli/specify.py +20 -19
- shotgun/cli/tasks.py +34 -34
- shotgun/cli/update.py +16 -2
- shotgun/codebase/core/change_detector.py +5 -3
- shotgun/codebase/core/code_retrieval.py +4 -2
- shotgun/codebase/core/ingestor.py +163 -15
- shotgun/codebase/core/manager.py +13 -4
- shotgun/codebase/core/nl_query.py +1 -1
- shotgun/codebase/models.py +2 -0
- shotgun/exceptions.py +357 -0
- shotgun/llm_proxy/__init__.py +17 -0
- shotgun/llm_proxy/client.py +215 -0
- shotgun/llm_proxy/models.py +137 -0
- shotgun/logging_config.py +60 -27
- shotgun/main.py +77 -11
- shotgun/posthog_telemetry.py +38 -29
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +28 -2
- shotgun/prompts/agents/partials/interactive_mode.j2 +3 -3
- shotgun/prompts/agents/plan.j2 +16 -0
- shotgun/prompts/agents/research.j2 +16 -3
- shotgun/prompts/agents/specify.j2 +54 -1
- shotgun/prompts/agents/state/system_state.j2 +0 -2
- shotgun/prompts/agents/tasks.j2 +16 -0
- shotgun/prompts/history/chunk_summarization.j2 +34 -0
- shotgun/prompts/history/combine_summaries.j2 +53 -0
- shotgun/sdk/codebase.py +14 -3
- shotgun/sentry_telemetry.py +163 -16
- shotgun/settings.py +243 -0
- shotgun/shotgun_web/__init__.py +67 -1
- shotgun/shotgun_web/client.py +42 -1
- shotgun/shotgun_web/constants.py +46 -0
- shotgun/shotgun_web/exceptions.py +29 -0
- shotgun/shotgun_web/models.py +390 -0
- shotgun/shotgun_web/shared_specs/__init__.py +32 -0
- shotgun/shotgun_web/shared_specs/file_scanner.py +175 -0
- shotgun/shotgun_web/shared_specs/hasher.py +83 -0
- shotgun/shotgun_web/shared_specs/models.py +71 -0
- shotgun/shotgun_web/shared_specs/upload_pipeline.py +329 -0
- shotgun/shotgun_web/shared_specs/utils.py +34 -0
- shotgun/shotgun_web/specs_client.py +703 -0
- shotgun/shotgun_web/supabase_client.py +31 -0
- shotgun/telemetry.py +10 -33
- shotgun/tui/app.py +310 -46
- 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/containers.py +91 -0
- shotgun/tui/dependencies.py +39 -0
- shotgun/tui/layout.py +5 -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 +1531 -0
- shotgun/tui/screens/chat/codebase_index_prompt_screen.py +243 -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 +91 -4
- shotgun/tui/screens/chat_screen/hint_message.py +76 -1
- 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 +115 -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 +191 -0
- shotgun/tui/screens/directory_setup.py +45 -41
- shotgun/tui/screens/feedback.py +14 -7
- shotgun/tui/screens/github_issue.py +111 -0
- shotgun/tui/screens/model_picker.py +77 -32
- shotgun/tui/screens/onboarding.py +580 -0
- shotgun/tui/screens/pipx_migration.py +205 -0
- shotgun/tui/screens/provider_config.py +116 -35
- shotgun/tui/screens/shared_specs/__init__.py +21 -0
- shotgun/tui/screens/shared_specs/create_spec_dialog.py +273 -0
- shotgun/tui/screens/shared_specs/models.py +56 -0
- shotgun/tui/screens/shared_specs/share_specs_dialog.py +390 -0
- shotgun/tui/screens/shared_specs/upload_progress_screen.py +452 -0
- shotgun/tui/screens/shotgun_auth.py +112 -18
- shotgun/tui/screens/spec_pull.py +288 -0
- shotgun/tui/screens/welcome.py +137 -11
- shotgun/tui/services/__init__.py +5 -0
- shotgun/tui/services/conversation_service.py +187 -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 +263 -0
- shotgun/utils/file_system_utils.py +22 -2
- shotgun/utils/marketing.py +110 -0
- shotgun/utils/update_checker.py +69 -14
- shotgun_sh-0.3.3.dev1.dist-info/METADATA +472 -0
- shotgun_sh-0.3.3.dev1.dist-info/RECORD +229 -0
- {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/WHEEL +1 -1
- {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/entry_points.txt +1 -0
- {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/licenses/LICENSE +1 -1
- shotgun/tui/screens/chat.py +0 -996
- shotgun/tui/screens/chat_screen/history.py +0 -335
- shotgun_sh-0.2.8.dev2.dist-info/METADATA +0 -126
- shotgun_sh-0.2.8.dev2.dist-info/RECORD +0 -155
- /shotgun/agents/{history → conversation/history}/__init__.py +0 -0
- /shotgun/agents/{history → conversation/history}/context_extraction.py +0 -0
- /shotgun/agents/{history → conversation/history}/history_building.py +0 -0
- /shotgun/agents/{history → conversation/history}/message_utils.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/__init__.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_estimation.py +0 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Agent response widget for chat history."""
|
|
2
|
+
|
|
3
|
+
from pydantic_ai.messages import (
|
|
4
|
+
BuiltinToolCallPart,
|
|
5
|
+
BuiltinToolReturnPart,
|
|
6
|
+
ModelResponse,
|
|
7
|
+
TextPart,
|
|
8
|
+
ThinkingPart,
|
|
9
|
+
ToolCallPart,
|
|
10
|
+
)
|
|
11
|
+
from textual.app import ComposeResult
|
|
12
|
+
from textual.widget import Widget
|
|
13
|
+
from textual.widgets import Markdown
|
|
14
|
+
|
|
15
|
+
from .formatters import ToolFormatter
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AgentResponseWidget(Widget):
|
|
19
|
+
"""Widget that displays agent responses in the chat history."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, item: ModelResponse | None) -> None:
|
|
22
|
+
super().__init__()
|
|
23
|
+
self.item = item
|
|
24
|
+
|
|
25
|
+
def compose(self) -> ComposeResult:
|
|
26
|
+
self.display = self.item is not None
|
|
27
|
+
if self.item is None:
|
|
28
|
+
yield Markdown(markdown="")
|
|
29
|
+
else:
|
|
30
|
+
yield Markdown(markdown=self.compute_output())
|
|
31
|
+
|
|
32
|
+
def compute_output(self) -> str:
|
|
33
|
+
"""Compute the markdown output for the agent response."""
|
|
34
|
+
acc = ""
|
|
35
|
+
if self.item is None:
|
|
36
|
+
return ""
|
|
37
|
+
|
|
38
|
+
for idx, part in enumerate(self.item.parts):
|
|
39
|
+
if isinstance(part, TextPart):
|
|
40
|
+
# Only show the circle prefix if there's actual content
|
|
41
|
+
if part.content and part.content.strip():
|
|
42
|
+
acc += f"**⏺** {part.content}\n\n"
|
|
43
|
+
elif isinstance(part, ToolCallPart):
|
|
44
|
+
parts_str = ToolFormatter.format_tool_call_part(part)
|
|
45
|
+
if parts_str: # Only add if there's actual content
|
|
46
|
+
acc += parts_str + "\n\n"
|
|
47
|
+
elif isinstance(part, BuiltinToolCallPart):
|
|
48
|
+
# Format builtin tool calls using registry
|
|
49
|
+
formatted = ToolFormatter.format_builtin_tool_call(part)
|
|
50
|
+
if formatted: # Only add if not hidden
|
|
51
|
+
acc += formatted + "\n\n"
|
|
52
|
+
elif isinstance(part, BuiltinToolReturnPart):
|
|
53
|
+
# Don't show tool return parts in the UI
|
|
54
|
+
pass
|
|
55
|
+
elif isinstance(part, ThinkingPart):
|
|
56
|
+
if (
|
|
57
|
+
idx == len(self.item.parts) - 1
|
|
58
|
+
): # show the thinking part only if it's the last part
|
|
59
|
+
acc += (
|
|
60
|
+
f"thinking: {part.content}\n\n"
|
|
61
|
+
if part.content
|
|
62
|
+
else "Thinking..."
|
|
63
|
+
)
|
|
64
|
+
else:
|
|
65
|
+
continue
|
|
66
|
+
return acc.strip()
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Chat history widget - main container for message display."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Generator, Sequence
|
|
4
|
+
|
|
5
|
+
from pydantic_ai.messages import (
|
|
6
|
+
ModelMessage,
|
|
7
|
+
ModelRequest,
|
|
8
|
+
ModelResponse,
|
|
9
|
+
UserPromptPart,
|
|
10
|
+
)
|
|
11
|
+
from textual.app import ComposeResult
|
|
12
|
+
from textual.reactive import reactive
|
|
13
|
+
from textual.widget import Widget
|
|
14
|
+
|
|
15
|
+
from shotgun.tui.components.vertical_tail import VerticalTail
|
|
16
|
+
from shotgun.tui.screens.chat_screen.hint_message import HintMessage, HintMessageWidget
|
|
17
|
+
|
|
18
|
+
from .agent_response import AgentResponseWidget
|
|
19
|
+
from .partial_response import PartialResponseWidget
|
|
20
|
+
from .user_question import UserQuestionWidget
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ChatHistory(Widget):
|
|
24
|
+
"""Main widget for displaying chat message history."""
|
|
25
|
+
|
|
26
|
+
DEFAULT_CSS = """
|
|
27
|
+
VerticalTail {
|
|
28
|
+
align: left bottom;
|
|
29
|
+
|
|
30
|
+
}
|
|
31
|
+
VerticalTail > * {
|
|
32
|
+
height: auto;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
Horizontal {
|
|
36
|
+
height: auto;
|
|
37
|
+
background: $secondary-muted;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
Markdown {
|
|
41
|
+
height: auto;
|
|
42
|
+
}
|
|
43
|
+
"""
|
|
44
|
+
partial_response: reactive[ModelMessage | None] = reactive(None)
|
|
45
|
+
|
|
46
|
+
def __init__(self) -> None:
|
|
47
|
+
super().__init__()
|
|
48
|
+
self.items: Sequence[ModelMessage | HintMessage] = []
|
|
49
|
+
self.vertical_tail: VerticalTail | None = None
|
|
50
|
+
self._rendered_count = 0 # Track how many messages have been mounted
|
|
51
|
+
|
|
52
|
+
def compose(self) -> ComposeResult:
|
|
53
|
+
"""Compose the chat history widget."""
|
|
54
|
+
self.vertical_tail = VerticalTail()
|
|
55
|
+
|
|
56
|
+
filtered = list(self.filtered_items())
|
|
57
|
+
with self.vertical_tail:
|
|
58
|
+
for item in filtered:
|
|
59
|
+
if isinstance(item, ModelRequest):
|
|
60
|
+
yield UserQuestionWidget(item)
|
|
61
|
+
elif isinstance(item, HintMessage):
|
|
62
|
+
yield HintMessageWidget(item)
|
|
63
|
+
elif isinstance(item, ModelResponse):
|
|
64
|
+
yield AgentResponseWidget(item)
|
|
65
|
+
yield PartialResponseWidget(None).data_bind(
|
|
66
|
+
item=ChatHistory.partial_response
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Track how many messages were rendered during initial compose
|
|
70
|
+
self._rendered_count = len(filtered)
|
|
71
|
+
|
|
72
|
+
def filtered_items(self) -> Generator[ModelMessage | HintMessage, None, None]:
|
|
73
|
+
"""Filter and yield items for display."""
|
|
74
|
+
for item in self.items:
|
|
75
|
+
# Skip ModelRequest messages that only contain ToolReturnPart
|
|
76
|
+
# (these are internal tool results, not user prompts)
|
|
77
|
+
if isinstance(item, ModelRequest):
|
|
78
|
+
has_user_content = any(
|
|
79
|
+
isinstance(part, UserPromptPart) for part in item.parts
|
|
80
|
+
)
|
|
81
|
+
if not has_user_content:
|
|
82
|
+
# This is just a tool return, skip displaying it
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
yield item
|
|
86
|
+
|
|
87
|
+
def update_messages(self, messages: list[ModelMessage | HintMessage]) -> None:
|
|
88
|
+
"""Update the displayed messages using incremental mounting."""
|
|
89
|
+
if not self.vertical_tail:
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
self.items = messages
|
|
93
|
+
filtered = list(self.filtered_items())
|
|
94
|
+
|
|
95
|
+
# Only mount new messages that haven't been rendered yet
|
|
96
|
+
if len(filtered) > self._rendered_count:
|
|
97
|
+
new_messages = filtered[self._rendered_count :]
|
|
98
|
+
for item in new_messages:
|
|
99
|
+
widget: Widget
|
|
100
|
+
if isinstance(item, ModelRequest):
|
|
101
|
+
widget = UserQuestionWidget(item)
|
|
102
|
+
elif isinstance(item, HintMessage):
|
|
103
|
+
widget = HintMessageWidget(item)
|
|
104
|
+
elif isinstance(item, ModelResponse):
|
|
105
|
+
widget = AgentResponseWidget(item)
|
|
106
|
+
else:
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
# Mount before the PartialResponseWidget
|
|
110
|
+
self.vertical_tail.mount(widget, before=self.vertical_tail.children[-1])
|
|
111
|
+
|
|
112
|
+
self._rendered_count = len(filtered)
|
|
113
|
+
|
|
114
|
+
# Scroll to bottom to show newly added messages
|
|
115
|
+
self.vertical_tail.scroll_end(animate=False)
|
|
@@ -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,191 @@
|
|
|
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.events import Resize
|
|
9
|
+
from textual.screen import ModalScreen
|
|
10
|
+
from textual.widgets import Button, Label, Static
|
|
11
|
+
|
|
12
|
+
from shotgun.tui.layout import COMPACT_HEIGHT_THRESHOLD
|
|
13
|
+
|
|
14
|
+
ButtonVariant = Literal["default", "primary", "success", "warning", "error"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConfirmationDialog(ModalScreen[bool]):
|
|
18
|
+
"""Reusable confirmation dialog for destructive actions.
|
|
19
|
+
|
|
20
|
+
This modal dialog presents a confirmation prompt with a title, explanatory
|
|
21
|
+
message, and customizable confirm/cancel buttons. Useful for preventing
|
|
22
|
+
accidental destructive actions like clearing data or deleting resources.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
title: Dialog title text (e.g., "Clear conversation?")
|
|
26
|
+
message: Detailed explanation of what will happen
|
|
27
|
+
confirm_label: Label for the confirm button (default: "Confirm")
|
|
28
|
+
cancel_label: Label for the cancel button (default: "Cancel")
|
|
29
|
+
confirm_variant: Button variant for confirm button (default: "warning")
|
|
30
|
+
danger: Whether this is a dangerous/destructive action (default: False)
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
True if user confirms, False if user cancels
|
|
34
|
+
|
|
35
|
+
Example:
|
|
36
|
+
```python
|
|
37
|
+
should_delete = await self.app.push_screen_wait(
|
|
38
|
+
ConfirmationDialog(
|
|
39
|
+
title="Delete item?",
|
|
40
|
+
message="This will permanently delete the item. This cannot be undone.",
|
|
41
|
+
confirm_label="Delete",
|
|
42
|
+
cancel_label="Keep",
|
|
43
|
+
confirm_variant="warning",
|
|
44
|
+
danger=True,
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
if should_delete:
|
|
48
|
+
# Proceed with deletion
|
|
49
|
+
...
|
|
50
|
+
```
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
DEFAULT_CSS = """
|
|
54
|
+
ConfirmationDialog {
|
|
55
|
+
align: center middle;
|
|
56
|
+
background: rgba(0, 0, 0, 0.0);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
ConfirmationDialog > #dialog-container {
|
|
60
|
+
width: 60%;
|
|
61
|
+
max-width: 70;
|
|
62
|
+
height: auto;
|
|
63
|
+
border: wide $warning;
|
|
64
|
+
padding: 1 2;
|
|
65
|
+
layout: vertical;
|
|
66
|
+
background: $surface;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
ConfirmationDialog.danger > #dialog-container {
|
|
70
|
+
border: wide $error;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
#dialog-title {
|
|
74
|
+
text-style: bold;
|
|
75
|
+
color: $text;
|
|
76
|
+
padding-bottom: 1;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
#dialog-message {
|
|
80
|
+
padding-bottom: 1;
|
|
81
|
+
color: $text-muted;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
#dialog-buttons {
|
|
85
|
+
layout: horizontal;
|
|
86
|
+
align-horizontal: right;
|
|
87
|
+
height: auto;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
#dialog-buttons Button {
|
|
91
|
+
margin-left: 1;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* Compact styles for short terminals */
|
|
95
|
+
#dialog-container.compact {
|
|
96
|
+
padding: 0 2;
|
|
97
|
+
max-height: 98%;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
#dialog-title.compact {
|
|
101
|
+
padding-bottom: 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
#dialog-message.compact {
|
|
105
|
+
padding-bottom: 0;
|
|
106
|
+
}
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
title: str,
|
|
112
|
+
message: str,
|
|
113
|
+
confirm_label: str = "Confirm",
|
|
114
|
+
cancel_label: str = "Cancel",
|
|
115
|
+
confirm_variant: ButtonVariant = "warning",
|
|
116
|
+
danger: bool = False,
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Initialize the confirmation dialog.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
title: Dialog title text
|
|
122
|
+
message: Detailed explanation of what will happen
|
|
123
|
+
confirm_label: Label for the confirm button
|
|
124
|
+
cancel_label: Label for the cancel button
|
|
125
|
+
confirm_variant: Button variant for confirm button
|
|
126
|
+
danger: Whether this is a dangerous/destructive action
|
|
127
|
+
"""
|
|
128
|
+
super().__init__()
|
|
129
|
+
self.title_text = title
|
|
130
|
+
self.message_text = message
|
|
131
|
+
self.confirm_label = confirm_label
|
|
132
|
+
self.cancel_label = cancel_label
|
|
133
|
+
self.confirm_variant = confirm_variant
|
|
134
|
+
self.is_danger = danger
|
|
135
|
+
|
|
136
|
+
def compose(self) -> ComposeResult:
|
|
137
|
+
"""Compose the dialog widgets."""
|
|
138
|
+
with Container(id="dialog-container"):
|
|
139
|
+
yield Label(self.title_text, id="dialog-title")
|
|
140
|
+
yield Static(self.message_text, id="dialog-message")
|
|
141
|
+
with Container(id="dialog-buttons"):
|
|
142
|
+
yield Button(
|
|
143
|
+
self.confirm_label,
|
|
144
|
+
id="confirm",
|
|
145
|
+
variant=self.confirm_variant,
|
|
146
|
+
)
|
|
147
|
+
yield Button(self.cancel_label, id="cancel")
|
|
148
|
+
|
|
149
|
+
def on_mount(self) -> None:
|
|
150
|
+
"""Set up the dialog after mounting."""
|
|
151
|
+
# Apply danger class if needed
|
|
152
|
+
if self.is_danger:
|
|
153
|
+
self.add_class("danger")
|
|
154
|
+
|
|
155
|
+
# Focus cancel button by default for safety
|
|
156
|
+
self.query_one("#cancel", Button).focus()
|
|
157
|
+
|
|
158
|
+
# Apply compact layout if starting in a short terminal
|
|
159
|
+
self._apply_compact_layout(self.app.size.height < COMPACT_HEIGHT_THRESHOLD)
|
|
160
|
+
|
|
161
|
+
@on(Resize)
|
|
162
|
+
def handle_resize(self, event: Resize) -> None:
|
|
163
|
+
"""Adjust layout based on terminal height."""
|
|
164
|
+
self._apply_compact_layout(event.size.height < COMPACT_HEIGHT_THRESHOLD)
|
|
165
|
+
|
|
166
|
+
def _apply_compact_layout(self, compact: bool) -> None:
|
|
167
|
+
"""Apply or remove compact layout classes for short terminals."""
|
|
168
|
+
container = self.query_one("#dialog-container")
|
|
169
|
+
title = self.query_one("#dialog-title")
|
|
170
|
+
message = self.query_one("#dialog-message")
|
|
171
|
+
|
|
172
|
+
if compact:
|
|
173
|
+
container.add_class("compact")
|
|
174
|
+
title.add_class("compact")
|
|
175
|
+
message.add_class("compact")
|
|
176
|
+
else:
|
|
177
|
+
container.remove_class("compact")
|
|
178
|
+
title.remove_class("compact")
|
|
179
|
+
message.remove_class("compact")
|
|
180
|
+
|
|
181
|
+
@on(Button.Pressed, "#cancel")
|
|
182
|
+
def handle_cancel(self, event: Button.Pressed) -> None:
|
|
183
|
+
"""Handle cancel button press."""
|
|
184
|
+
event.stop()
|
|
185
|
+
self.dismiss(False)
|
|
186
|
+
|
|
187
|
+
@on(Button.Pressed, "#confirm")
|
|
188
|
+
def handle_confirm(self, event: Button.Pressed) -> None:
|
|
189
|
+
"""Handle confirm button press."""
|
|
190
|
+
event.stop()
|
|
191
|
+
self.dismiss(True)
|