shotgun-sh 0.3.3.dev1__py3-none-any.whl → 0.6.2__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 +497 -30
- shotgun/agents/cancellation.py +103 -0
- shotgun/agents/common.py +90 -77
- shotgun/agents/config/README.md +0 -1
- shotgun/agents/config/manager.py +52 -8
- shotgun/agents/config/models.py +21 -27
- shotgun/agents/config/provider.py +44 -27
- shotgun/agents/conversation/history/file_content_deduplication.py +66 -43
- shotgun/agents/conversation/history/token_counting/base.py +51 -9
- shotgun/agents/export.py +12 -13
- shotgun/agents/file_read.py +176 -0
- shotgun/agents/messages.py +15 -3
- shotgun/agents/models.py +90 -2
- shotgun/agents/plan.py +12 -13
- shotgun/agents/research.py +13 -10
- shotgun/agents/router/__init__.py +47 -0
- shotgun/agents/router/models.py +384 -0
- shotgun/agents/router/router.py +185 -0
- shotgun/agents/router/tools/__init__.py +18 -0
- shotgun/agents/router/tools/delegation_tools.py +557 -0
- shotgun/agents/router/tools/plan_tools.py +403 -0
- shotgun/agents/runner.py +17 -2
- shotgun/agents/specify.py +12 -13
- shotgun/agents/tasks.py +12 -13
- shotgun/agents/tools/__init__.py +8 -0
- shotgun/agents/tools/codebase/directory_lister.py +27 -39
- shotgun/agents/tools/codebase/file_read.py +26 -35
- shotgun/agents/tools/codebase/query_graph.py +9 -0
- shotgun/agents/tools/codebase/retrieve_code.py +9 -0
- shotgun/agents/tools/file_management.py +81 -3
- shotgun/agents/tools/file_read_tools/__init__.py +7 -0
- shotgun/agents/tools/file_read_tools/multimodal_file_read.py +167 -0
- shotgun/agents/tools/markdown_tools/__init__.py +62 -0
- shotgun/agents/tools/markdown_tools/insert_section.py +148 -0
- shotgun/agents/tools/markdown_tools/models.py +86 -0
- shotgun/agents/tools/markdown_tools/remove_section.py +114 -0
- shotgun/agents/tools/markdown_tools/replace_section.py +119 -0
- shotgun/agents/tools/markdown_tools/utils.py +453 -0
- shotgun/agents/tools/registry.py +46 -6
- shotgun/agents/tools/web_search/__init__.py +1 -2
- shotgun/agents/tools/web_search/gemini.py +1 -3
- shotgun/agents/tools/web_search/openai.py +42 -23
- shotgun/attachments/__init__.py +41 -0
- shotgun/attachments/errors.py +60 -0
- shotgun/attachments/models.py +107 -0
- shotgun/attachments/parser.py +257 -0
- shotgun/attachments/processor.py +193 -0
- shotgun/build_constants.py +4 -7
- shotgun/cli/clear.py +2 -2
- shotgun/cli/codebase/commands.py +181 -65
- shotgun/cli/compact.py +2 -2
- shotgun/cli/context.py +2 -2
- shotgun/cli/error_handler.py +2 -2
- shotgun/cli/run.py +90 -0
- shotgun/cli/spec/backup.py +2 -1
- shotgun/codebase/__init__.py +2 -0
- shotgun/codebase/benchmarks/__init__.py +35 -0
- shotgun/codebase/benchmarks/benchmark_runner.py +309 -0
- shotgun/codebase/benchmarks/exporters.py +119 -0
- shotgun/codebase/benchmarks/formatters/__init__.py +49 -0
- shotgun/codebase/benchmarks/formatters/base.py +34 -0
- shotgun/codebase/benchmarks/formatters/json_formatter.py +106 -0
- shotgun/codebase/benchmarks/formatters/markdown.py +136 -0
- shotgun/codebase/benchmarks/models.py +129 -0
- shotgun/codebase/core/__init__.py +4 -0
- shotgun/codebase/core/call_resolution.py +91 -0
- shotgun/codebase/core/change_detector.py +11 -6
- shotgun/codebase/core/errors.py +159 -0
- shotgun/codebase/core/extractors/__init__.py +23 -0
- shotgun/codebase/core/extractors/base.py +138 -0
- shotgun/codebase/core/extractors/factory.py +63 -0
- shotgun/codebase/core/extractors/go/__init__.py +7 -0
- shotgun/codebase/core/extractors/go/extractor.py +122 -0
- shotgun/codebase/core/extractors/javascript/__init__.py +7 -0
- shotgun/codebase/core/extractors/javascript/extractor.py +132 -0
- shotgun/codebase/core/extractors/protocol.py +109 -0
- shotgun/codebase/core/extractors/python/__init__.py +7 -0
- shotgun/codebase/core/extractors/python/extractor.py +141 -0
- shotgun/codebase/core/extractors/rust/__init__.py +7 -0
- shotgun/codebase/core/extractors/rust/extractor.py +139 -0
- shotgun/codebase/core/extractors/types.py +15 -0
- shotgun/codebase/core/extractors/typescript/__init__.py +7 -0
- shotgun/codebase/core/extractors/typescript/extractor.py +92 -0
- shotgun/codebase/core/gitignore.py +252 -0
- shotgun/codebase/core/ingestor.py +644 -354
- shotgun/codebase/core/kuzu_compat.py +119 -0
- shotgun/codebase/core/language_config.py +239 -0
- shotgun/codebase/core/manager.py +256 -46
- shotgun/codebase/core/metrics_collector.py +310 -0
- shotgun/codebase/core/metrics_types.py +347 -0
- shotgun/codebase/core/parallel_executor.py +424 -0
- shotgun/codebase/core/work_distributor.py +254 -0
- shotgun/codebase/core/worker.py +768 -0
- shotgun/codebase/indexing_state.py +86 -0
- shotgun/codebase/models.py +94 -0
- shotgun/codebase/service.py +13 -0
- shotgun/exceptions.py +9 -9
- shotgun/main.py +3 -16
- shotgun/posthog_telemetry.py +165 -24
- shotgun/prompts/agents/export.j2 +2 -0
- shotgun/prompts/agents/file_read.j2 +48 -0
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +19 -52
- shotgun/prompts/agents/partials/content_formatting.j2 +12 -33
- shotgun/prompts/agents/partials/interactive_mode.j2 +9 -32
- shotgun/prompts/agents/partials/router_delegation_mode.j2 +35 -0
- shotgun/prompts/agents/plan.j2 +38 -12
- shotgun/prompts/agents/research.j2 +70 -31
- shotgun/prompts/agents/router.j2 +713 -0
- shotgun/prompts/agents/specify.j2 +53 -16
- shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2 +14 -1
- shotgun/prompts/agents/state/system_state.j2 +24 -13
- shotgun/prompts/agents/tasks.j2 +72 -34
- shotgun/settings.py +49 -10
- shotgun/tui/app.py +154 -24
- shotgun/tui/commands/__init__.py +9 -1
- shotgun/tui/components/attachment_bar.py +87 -0
- shotgun/tui/components/mode_indicator.py +120 -25
- shotgun/tui/components/prompt_input.py +25 -28
- shotgun/tui/components/status_bar.py +14 -7
- shotgun/tui/dependencies.py +58 -8
- shotgun/tui/protocols.py +55 -0
- shotgun/tui/screens/chat/chat.tcss +24 -1
- shotgun/tui/screens/chat/chat_screen.py +1376 -213
- shotgun/tui/screens/chat/codebase_index_prompt_screen.py +8 -4
- shotgun/tui/screens/chat_screen/attachment_hint.py +40 -0
- shotgun/tui/screens/chat_screen/command_providers.py +0 -97
- shotgun/tui/screens/chat_screen/history/agent_response.py +7 -3
- shotgun/tui/screens/chat_screen/history/chat_history.py +58 -6
- shotgun/tui/screens/chat_screen/history/formatters.py +75 -15
- shotgun/tui/screens/chat_screen/history/partial_response.py +11 -1
- shotgun/tui/screens/chat_screen/history/user_question.py +25 -3
- shotgun/tui/screens/chat_screen/messages.py +219 -0
- shotgun/tui/screens/database_locked_dialog.py +219 -0
- shotgun/tui/screens/database_timeout_dialog.py +158 -0
- shotgun/tui/screens/kuzu_error_dialog.py +135 -0
- shotgun/tui/screens/model_picker.py +1 -3
- shotgun/tui/screens/models.py +11 -0
- shotgun/tui/state/processing_state.py +19 -0
- shotgun/tui/utils/mode_progress.py +20 -86
- shotgun/tui/widgets/__init__.py +2 -1
- shotgun/tui/widgets/approval_widget.py +152 -0
- shotgun/tui/widgets/cascade_confirmation_widget.py +203 -0
- shotgun/tui/widgets/plan_panel.py +129 -0
- shotgun/tui/widgets/step_checkpoint_widget.py +180 -0
- shotgun/tui/widgets/widget_coordinator.py +18 -0
- shotgun/utils/file_system_utils.py +4 -1
- {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/METADATA +88 -35
- shotgun_sh-0.6.2.dist-info/RECORD +291 -0
- shotgun/cli/export.py +0 -81
- shotgun/cli/plan.py +0 -73
- shotgun/cli/research.py +0 -93
- shotgun/cli/specify.py +0 -70
- shotgun/cli/tasks.py +0 -78
- shotgun/sentry_telemetry.py +0 -232
- shotgun/tui/screens/onboarding.py +0 -580
- shotgun_sh-0.3.3.dev1.dist-info/RECORD +0 -229
- {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/WHEEL +0 -0
- {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/entry_points.txt +0 -0
- {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -11,9 +11,11 @@ from textual.events import Resize
|
|
|
11
11
|
from textual.screen import ModalScreen
|
|
12
12
|
from textual.widgets import Button, Label, Markdown, Static
|
|
13
13
|
|
|
14
|
-
from shotgun.tui.layout import COMPACT_HEIGHT_THRESHOLD
|
|
15
14
|
from shotgun.utils.file_system_utils import get_shotgun_home
|
|
16
15
|
|
|
16
|
+
# Use a higher threshold than the global default since this dialog has more content
|
|
17
|
+
INDEX_PROMPT_COMPACT_THRESHOLD = 45
|
|
18
|
+
|
|
17
19
|
|
|
18
20
|
def _is_home_directory() -> bool:
|
|
19
21
|
"""Check if cwd is user's home directory.
|
|
@@ -46,7 +48,7 @@ class CodebaseIndexPromptScreen(ModalScreen[bool]):
|
|
|
46
48
|
max-width: 90;
|
|
47
49
|
height: auto;
|
|
48
50
|
max-height: 85%;
|
|
49
|
-
border:
|
|
51
|
+
border: none;
|
|
50
52
|
padding: 1 2;
|
|
51
53
|
layout: vertical;
|
|
52
54
|
background: $surface;
|
|
@@ -200,12 +202,14 @@ We take your privacy seriously. You can read our full [privacy policy](https://a
|
|
|
200
202
|
if _is_home_directory():
|
|
201
203
|
_track_event("home_directory_warning_shown")
|
|
202
204
|
# Apply compact layout if starting in a short terminal
|
|
203
|
-
self._apply_compact_layout(
|
|
205
|
+
self._apply_compact_layout(
|
|
206
|
+
self.app.size.height < INDEX_PROMPT_COMPACT_THRESHOLD
|
|
207
|
+
)
|
|
204
208
|
|
|
205
209
|
@on(Resize)
|
|
206
210
|
def handle_resize(self, event: Resize) -> None:
|
|
207
211
|
"""Adjust layout based on terminal height."""
|
|
208
|
-
self._apply_compact_layout(event.size.height <
|
|
212
|
+
self._apply_compact_layout(event.size.height < INDEX_PROMPT_COMPACT_THRESHOLD)
|
|
209
213
|
|
|
210
214
|
def _apply_compact_layout(self, compact: bool) -> None:
|
|
211
215
|
"""Apply or remove compact layout classes for short terminals."""
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Attachment hint widget for displaying attachments in chat history."""
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.widget import Widget
|
|
5
|
+
from textual.widgets import Static
|
|
6
|
+
|
|
7
|
+
from shotgun.attachments import AttachmentHint, get_attachment_icon
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AttachmentHintWidget(Widget):
|
|
11
|
+
"""Widget that displays attachment indicator in chat history.
|
|
12
|
+
|
|
13
|
+
Display format: "icon Attached: filename (size)"
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
DEFAULT_CSS = """
|
|
17
|
+
AttachmentHintWidget {
|
|
18
|
+
height: auto;
|
|
19
|
+
padding: 0 1;
|
|
20
|
+
margin: 0 1;
|
|
21
|
+
color: $text-muted;
|
|
22
|
+
}
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, hint: AttachmentHint) -> None:
|
|
26
|
+
"""Initialize with attachment hint data.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
hint: AttachmentHint model containing display info.
|
|
30
|
+
"""
|
|
31
|
+
super().__init__()
|
|
32
|
+
self.hint = hint
|
|
33
|
+
|
|
34
|
+
def compose(self) -> ComposeResult:
|
|
35
|
+
"""Compose the attachment hint widget."""
|
|
36
|
+
icon = get_attachment_icon(self.hint.file_type)
|
|
37
|
+
display_text = (
|
|
38
|
+
f"{icon} Attached: {self.hint.filename} ({self.hint.file_size_display})"
|
|
39
|
+
)
|
|
40
|
+
yield Static(display_text)
|
|
@@ -3,7 +3,6 @@ from typing import TYPE_CHECKING, cast
|
|
|
3
3
|
|
|
4
4
|
from textual.command import DiscoveryHit, Hit, Provider
|
|
5
5
|
|
|
6
|
-
from shotgun.agents.models import AgentType
|
|
7
6
|
from shotgun.codebase.models import CodebaseGraph
|
|
8
7
|
from shotgun.tui.screens.chat_screen.hint_message import HintMessage
|
|
9
8
|
from shotgun.tui.screens.model_picker import ModelPickerScreen
|
|
@@ -13,92 +12,6 @@ if TYPE_CHECKING:
|
|
|
13
12
|
from shotgun.tui.screens.chat import ChatScreen
|
|
14
13
|
|
|
15
14
|
|
|
16
|
-
class AgentModeProvider(Provider):
|
|
17
|
-
"""Command provider for agent mode switching."""
|
|
18
|
-
|
|
19
|
-
@property
|
|
20
|
-
def chat_screen(self) -> "ChatScreen":
|
|
21
|
-
from shotgun.tui.screens.chat import ChatScreen
|
|
22
|
-
|
|
23
|
-
return cast(ChatScreen, self.screen)
|
|
24
|
-
|
|
25
|
-
def set_mode(self, mode: AgentType) -> None:
|
|
26
|
-
"""Switch to research mode."""
|
|
27
|
-
self.chat_screen.mode = mode
|
|
28
|
-
|
|
29
|
-
async def discover(self) -> AsyncGenerator[DiscoveryHit, None]:
|
|
30
|
-
"""Provide default mode switching commands when palette opens."""
|
|
31
|
-
yield DiscoveryHit(
|
|
32
|
-
"Switch to Research Mode",
|
|
33
|
-
lambda: self.set_mode(AgentType.RESEARCH),
|
|
34
|
-
help="🔬 Research topics with web search and synthesize findings",
|
|
35
|
-
)
|
|
36
|
-
yield DiscoveryHit(
|
|
37
|
-
"Switch to Specify Mode",
|
|
38
|
-
lambda: self.set_mode(AgentType.SPECIFY),
|
|
39
|
-
help="📝 Create detailed specifications and requirements documents",
|
|
40
|
-
)
|
|
41
|
-
yield DiscoveryHit(
|
|
42
|
-
"Switch to Plan Mode",
|
|
43
|
-
lambda: self.set_mode(AgentType.PLAN),
|
|
44
|
-
help="📋 Create comprehensive, actionable plans with milestones",
|
|
45
|
-
)
|
|
46
|
-
yield DiscoveryHit(
|
|
47
|
-
"Switch to Tasks Mode",
|
|
48
|
-
lambda: self.set_mode(AgentType.TASKS),
|
|
49
|
-
help="✅ Generate specific, actionable tasks from research and plans",
|
|
50
|
-
)
|
|
51
|
-
yield DiscoveryHit(
|
|
52
|
-
"Switch to Export Mode",
|
|
53
|
-
lambda: self.set_mode(AgentType.EXPORT),
|
|
54
|
-
help="📤 Export artifacts and findings to various formats",
|
|
55
|
-
)
|
|
56
|
-
|
|
57
|
-
async def search(self, query: str) -> AsyncGenerator[Hit, None]:
|
|
58
|
-
"""Search for mode commands."""
|
|
59
|
-
matcher = self.matcher(query)
|
|
60
|
-
|
|
61
|
-
commands = [
|
|
62
|
-
(
|
|
63
|
-
"Switch to Research Mode",
|
|
64
|
-
"🔬 Research topics with web search and synthesize findings",
|
|
65
|
-
lambda: self.set_mode(AgentType.RESEARCH),
|
|
66
|
-
AgentType.RESEARCH,
|
|
67
|
-
),
|
|
68
|
-
(
|
|
69
|
-
"Switch to Specify Mode",
|
|
70
|
-
"📝 Create detailed specifications and requirements documents",
|
|
71
|
-
lambda: self.set_mode(AgentType.SPECIFY),
|
|
72
|
-
AgentType.SPECIFY,
|
|
73
|
-
),
|
|
74
|
-
(
|
|
75
|
-
"Switch to Plan Mode",
|
|
76
|
-
"📋 Create comprehensive, actionable plans with milestones",
|
|
77
|
-
lambda: self.set_mode(AgentType.PLAN),
|
|
78
|
-
AgentType.PLAN,
|
|
79
|
-
),
|
|
80
|
-
(
|
|
81
|
-
"Switch to Tasks Mode",
|
|
82
|
-
"✅ Generate specific, actionable tasks from research and plans",
|
|
83
|
-
lambda: self.set_mode(AgentType.TASKS),
|
|
84
|
-
AgentType.TASKS,
|
|
85
|
-
),
|
|
86
|
-
(
|
|
87
|
-
"Switch to Export Mode",
|
|
88
|
-
"📤 Export artifacts and findings to various formats",
|
|
89
|
-
lambda: self.set_mode(AgentType.EXPORT),
|
|
90
|
-
AgentType.EXPORT,
|
|
91
|
-
),
|
|
92
|
-
]
|
|
93
|
-
|
|
94
|
-
for title, help_text, callback, mode in commands:
|
|
95
|
-
if self.chat_screen.mode == mode:
|
|
96
|
-
continue
|
|
97
|
-
score = matcher.match(title)
|
|
98
|
-
if score > 0:
|
|
99
|
-
yield Hit(score, matcher.highlight(title), callback, help=help_text)
|
|
100
|
-
|
|
101
|
-
|
|
102
15
|
class UsageProvider(Provider):
|
|
103
16
|
"""Command provider for agent mode switching."""
|
|
104
17
|
|
|
@@ -375,11 +288,6 @@ class UnifiedCommandProvider(Provider):
|
|
|
375
288
|
self.chat_screen.action_show_usage,
|
|
376
289
|
help="Display usage information for the current session",
|
|
377
290
|
)
|
|
378
|
-
yield DiscoveryHit(
|
|
379
|
-
"View Onboarding",
|
|
380
|
-
self.chat_screen.action_view_onboarding,
|
|
381
|
-
help="View the onboarding tutorial and helpful resources",
|
|
382
|
-
)
|
|
383
291
|
|
|
384
292
|
async def search(self, query: str) -> AsyncGenerator[Hit, None]:
|
|
385
293
|
"""Search for commands in alphabetical order."""
|
|
@@ -432,11 +340,6 @@ class UnifiedCommandProvider(Provider):
|
|
|
432
340
|
self.chat_screen.action_show_usage,
|
|
433
341
|
"Display usage information for the current session",
|
|
434
342
|
),
|
|
435
|
-
(
|
|
436
|
-
"View Onboarding",
|
|
437
|
-
self.chat_screen.action_view_onboarding,
|
|
438
|
-
"View the onboarding tutorial and helpful resources",
|
|
439
|
-
),
|
|
440
343
|
]
|
|
441
344
|
|
|
442
345
|
for title, callback, help_text in commands:
|
|
@@ -18,9 +18,10 @@ from .formatters import ToolFormatter
|
|
|
18
18
|
class AgentResponseWidget(Widget):
|
|
19
19
|
"""Widget that displays agent responses in the chat history."""
|
|
20
20
|
|
|
21
|
-
def __init__(self, item: ModelResponse | None) -> None:
|
|
21
|
+
def __init__(self, item: ModelResponse | None, is_sub_agent: bool = False) -> None:
|
|
22
22
|
super().__init__()
|
|
23
23
|
self.item = item
|
|
24
|
+
self.is_sub_agent = is_sub_agent
|
|
24
25
|
|
|
25
26
|
def compose(self) -> ComposeResult:
|
|
26
27
|
self.display = self.item is not None
|
|
@@ -35,11 +36,14 @@ class AgentResponseWidget(Widget):
|
|
|
35
36
|
if self.item is None:
|
|
36
37
|
return ""
|
|
37
38
|
|
|
39
|
+
# Use different prefix for sub-agent responses
|
|
40
|
+
prefix = "**⏺** " if not self.is_sub_agent else " **↳** "
|
|
41
|
+
|
|
38
42
|
for idx, part in enumerate(self.item.parts):
|
|
39
43
|
if isinstance(part, TextPart):
|
|
40
|
-
# Only show the
|
|
44
|
+
# Only show the prefix if there's actual content
|
|
41
45
|
if part.content and part.content.strip():
|
|
42
|
-
acc += f"
|
|
46
|
+
acc += f"{prefix}{part.content}\n\n"
|
|
43
47
|
elif isinstance(part, ToolCallPart):
|
|
44
48
|
parts_str = ToolFormatter.format_tool_call_part(part)
|
|
45
49
|
if parts_str: # Only add if there's actual content
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Chat history widget - main container for message display."""
|
|
2
2
|
|
|
3
|
+
import logging
|
|
3
4
|
from collections.abc import Generator, Sequence
|
|
4
5
|
|
|
5
6
|
from pydantic_ai.messages import (
|
|
@@ -8,10 +9,13 @@ from pydantic_ai.messages import (
|
|
|
8
9
|
ModelResponse,
|
|
9
10
|
UserPromptPart,
|
|
10
11
|
)
|
|
12
|
+
from textual import events
|
|
11
13
|
from textual.app import ComposeResult
|
|
12
14
|
from textual.reactive import reactive
|
|
13
15
|
from textual.widget import Widget
|
|
14
16
|
|
|
17
|
+
from shotgun.agents.messages import InternalPromptPart
|
|
18
|
+
from shotgun.tui.components.prompt_input import PromptInput
|
|
15
19
|
from shotgun.tui.components.vertical_tail import VerticalTail
|
|
16
20
|
from shotgun.tui.screens.chat_screen.hint_message import HintMessage, HintMessageWidget
|
|
17
21
|
|
|
@@ -19,6 +23,8 @@ from .agent_response import AgentResponseWidget
|
|
|
19
23
|
from .partial_response import PartialResponseWidget
|
|
20
24
|
from .user_question import UserQuestionWidget
|
|
21
25
|
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
22
28
|
|
|
23
29
|
class ChatHistory(Widget):
|
|
24
30
|
"""Main widget for displaying chat message history."""
|
|
@@ -72,14 +78,16 @@ class ChatHistory(Widget):
|
|
|
72
78
|
def filtered_items(self) -> Generator[ModelMessage | HintMessage, None, None]:
|
|
73
79
|
"""Filter and yield items for display."""
|
|
74
80
|
for item in self.items:
|
|
75
|
-
# Skip ModelRequest messages
|
|
76
|
-
# (these are internal tool results, not user prompts)
|
|
81
|
+
# Skip ModelRequest messages without visible user content
|
|
77
82
|
if isinstance(item, ModelRequest):
|
|
78
|
-
|
|
79
|
-
|
|
83
|
+
# Check for visible user content (UserPromptPart but NOT InternalPromptPart)
|
|
84
|
+
has_visible_user_content = any(
|
|
85
|
+
isinstance(part, UserPromptPart)
|
|
86
|
+
and not isinstance(part, InternalPromptPart)
|
|
87
|
+
for part in item.parts
|
|
80
88
|
)
|
|
81
|
-
if not
|
|
82
|
-
#
|
|
89
|
+
if not has_visible_user_content:
|
|
90
|
+
# Skip: either just tool returns or internal system prompts
|
|
83
91
|
continue
|
|
84
92
|
|
|
85
93
|
yield item
|
|
@@ -87,14 +95,35 @@ class ChatHistory(Widget):
|
|
|
87
95
|
def update_messages(self, messages: list[ModelMessage | HintMessage]) -> None:
|
|
88
96
|
"""Update the displayed messages using incremental mounting."""
|
|
89
97
|
if not self.vertical_tail:
|
|
98
|
+
logger.debug(
|
|
99
|
+
"[CHAT_HISTORY] update_messages called but vertical_tail is None"
|
|
100
|
+
)
|
|
90
101
|
return
|
|
91
102
|
|
|
92
103
|
self.items = messages
|
|
93
104
|
filtered = list(self.filtered_items())
|
|
94
105
|
|
|
106
|
+
# If rendered count is higher than filtered count, the message list was
|
|
107
|
+
# modified (not just appended). Reset rendered count to allow new messages
|
|
108
|
+
# to be mounted. This can happen when messages are compacted or filtered.
|
|
109
|
+
if self._rendered_count > len(filtered):
|
|
110
|
+
logger.debug(
|
|
111
|
+
"[CHAT_HISTORY] Rendered count (%d) > filtered count (%d), "
|
|
112
|
+
"resetting to allow new messages to be mounted",
|
|
113
|
+
self._rendered_count,
|
|
114
|
+
len(filtered),
|
|
115
|
+
)
|
|
116
|
+
self._rendered_count = len(filtered)
|
|
117
|
+
|
|
95
118
|
# Only mount new messages that haven't been rendered yet
|
|
96
119
|
if len(filtered) > self._rendered_count:
|
|
97
120
|
new_messages = filtered[self._rendered_count :]
|
|
121
|
+
logger.debug(
|
|
122
|
+
"[CHAT_HISTORY] Mounting %d new messages (total=%d, filtered=%d)",
|
|
123
|
+
len(new_messages),
|
|
124
|
+
len(messages),
|
|
125
|
+
len(filtered),
|
|
126
|
+
)
|
|
98
127
|
for item in new_messages:
|
|
99
128
|
widget: Widget
|
|
100
129
|
if isinstance(item, ModelRequest):
|
|
@@ -104,6 +133,10 @@ class ChatHistory(Widget):
|
|
|
104
133
|
elif isinstance(item, ModelResponse):
|
|
105
134
|
widget = AgentResponseWidget(item)
|
|
106
135
|
else:
|
|
136
|
+
logger.debug(
|
|
137
|
+
"[CHAT_HISTORY] Skipping unknown message type: %s",
|
|
138
|
+
type(item).__name__,
|
|
139
|
+
)
|
|
107
140
|
continue
|
|
108
141
|
|
|
109
142
|
# Mount before the PartialResponseWidget
|
|
@@ -113,3 +146,22 @@ class ChatHistory(Widget):
|
|
|
113
146
|
|
|
114
147
|
# Scroll to bottom to show newly added messages
|
|
115
148
|
self.vertical_tail.scroll_end(animate=False)
|
|
149
|
+
|
|
150
|
+
def on_click(self, event: events.Click) -> None:
|
|
151
|
+
"""Focus the prompt input when clicking on the history area.
|
|
152
|
+
|
|
153
|
+
Skip focusing if text is selected (to allow copy operations).
|
|
154
|
+
"""
|
|
155
|
+
# Only handle left clicks
|
|
156
|
+
if event.button != 1:
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
# Don't focus input if user has selected text (they might want to copy it)
|
|
160
|
+
if self.screen.get_selected_text():
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
results = self.screen.query(PromptInput)
|
|
164
|
+
if results:
|
|
165
|
+
prompt_input = results.first()
|
|
166
|
+
if prompt_input.display:
|
|
167
|
+
prompt_input.focus()
|
|
@@ -29,6 +29,53 @@ class ToolFormatter:
|
|
|
29
29
|
return {}
|
|
30
30
|
return args if isinstance(args, dict) else {}
|
|
31
31
|
|
|
32
|
+
@classmethod
|
|
33
|
+
def _extract_key_arg(
|
|
34
|
+
cls,
|
|
35
|
+
args: dict[str, object],
|
|
36
|
+
key_arg: str,
|
|
37
|
+
tool_name: str | None = None,
|
|
38
|
+
) -> str | None:
|
|
39
|
+
"""Extract key argument value, handling nested args and special cases.
|
|
40
|
+
|
|
41
|
+
Supports:
|
|
42
|
+
- Direct key access: key_arg="query" -> args["query"]
|
|
43
|
+
- Nested access: key_arg="task" -> args["input"]["task"] (for Pydantic model inputs)
|
|
44
|
+
- Special handling for codebase_shell
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
args: Parsed tool arguments dict
|
|
48
|
+
key_arg: The key argument to extract
|
|
49
|
+
tool_name: Optional tool name for special handling
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
The extracted value as a string, or None if not found
|
|
53
|
+
"""
|
|
54
|
+
if not args or not isinstance(args, dict):
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
# Special handling for codebase_shell which needs command + args
|
|
58
|
+
if tool_name == "codebase_shell" and "command" in args:
|
|
59
|
+
command = args.get("command", "")
|
|
60
|
+
cmd_args = args.get("args", [])
|
|
61
|
+
if isinstance(cmd_args, list):
|
|
62
|
+
args_str = " ".join(str(arg) for arg in cmd_args)
|
|
63
|
+
else:
|
|
64
|
+
args_str = ""
|
|
65
|
+
return f"{command} {args_str}".strip()
|
|
66
|
+
|
|
67
|
+
# Direct key access
|
|
68
|
+
if key_arg in args:
|
|
69
|
+
return str(args[key_arg])
|
|
70
|
+
|
|
71
|
+
# Try nested access through "input" (for Pydantic model inputs)
|
|
72
|
+
if "input" in args and isinstance(args["input"], dict):
|
|
73
|
+
input_dict = args["input"]
|
|
74
|
+
if key_arg in input_dict:
|
|
75
|
+
return str(input_dict[key_arg])
|
|
76
|
+
|
|
77
|
+
return None
|
|
78
|
+
|
|
32
79
|
@classmethod
|
|
33
80
|
def format_tool_call_part(cls, part: ToolCallPart) -> str:
|
|
34
81
|
"""Format a tool call part using the tool display registry."""
|
|
@@ -44,19 +91,21 @@ class ToolFormatter:
|
|
|
44
91
|
args = cls.parse_args(part.args)
|
|
45
92
|
|
|
46
93
|
# Get the key argument value
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
94
|
+
key_value = cls._extract_key_arg(
|
|
95
|
+
args, display_config.key_arg, part.tool_name
|
|
96
|
+
)
|
|
97
|
+
if key_value:
|
|
98
|
+
# Check for secondary key arg
|
|
99
|
+
if display_config.secondary_key_arg:
|
|
100
|
+
secondary_value = cls._extract_key_arg(
|
|
101
|
+
args, display_config.secondary_key_arg, part.tool_name
|
|
102
|
+
)
|
|
103
|
+
if secondary_value:
|
|
104
|
+
# Format: "display_text: key_value → secondary_value"
|
|
105
|
+
return (
|
|
106
|
+
f"{display_config.display_text}: "
|
|
107
|
+
f"{cls.truncate(key_value)} → {cls.truncate(secondary_value)}"
|
|
108
|
+
)
|
|
60
109
|
# Format: "display_text: key_value"
|
|
61
110
|
return f"{display_config.display_text}: {cls.truncate(key_value)}"
|
|
62
111
|
else:
|
|
@@ -95,8 +144,19 @@ class ToolFormatter:
|
|
|
95
144
|
|
|
96
145
|
args = cls.parse_args(part.args)
|
|
97
146
|
# Get the key argument value
|
|
98
|
-
|
|
99
|
-
|
|
147
|
+
key_value = cls._extract_key_arg(args, display_config.key_arg)
|
|
148
|
+
if key_value:
|
|
149
|
+
# Check for secondary key arg
|
|
150
|
+
if display_config.secondary_key_arg:
|
|
151
|
+
secondary_value = cls._extract_key_arg(
|
|
152
|
+
args, display_config.secondary_key_arg
|
|
153
|
+
)
|
|
154
|
+
if secondary_value:
|
|
155
|
+
# Format: "display_text: key_value → secondary_value"
|
|
156
|
+
return (
|
|
157
|
+
f"{display_config.display_text}: "
|
|
158
|
+
f"{cls.truncate(key_value)} → {cls.truncate(secondary_value)}"
|
|
159
|
+
)
|
|
100
160
|
# Format: "display_text: key_value"
|
|
101
161
|
return f"{display_config.display_text}: {cls.truncate(key_value)}"
|
|
102
162
|
else:
|
|
@@ -5,6 +5,8 @@ from textual.app import ComposeResult
|
|
|
5
5
|
from textual.reactive import reactive
|
|
6
6
|
from textual.widget import Widget
|
|
7
7
|
|
|
8
|
+
from shotgun.tui.protocols import ActiveSubAgentProvider
|
|
9
|
+
|
|
8
10
|
from .agent_response import AgentResponseWidget
|
|
9
11
|
from .user_question import UserQuestionWidget
|
|
10
12
|
|
|
@@ -27,11 +29,19 @@ class PartialResponseWidget(Widget): # TODO: doesn't work lol
|
|
|
27
29
|
super().__init__()
|
|
28
30
|
self.item = item
|
|
29
31
|
|
|
32
|
+
def _is_sub_agent_active(self) -> bool:
|
|
33
|
+
"""Check if a sub-agent is currently active."""
|
|
34
|
+
if isinstance(self.screen, ActiveSubAgentProvider):
|
|
35
|
+
return self.screen.active_sub_agent is not None
|
|
36
|
+
return False
|
|
37
|
+
|
|
30
38
|
def compose(self) -> ComposeResult:
|
|
31
39
|
if self.item is None:
|
|
32
40
|
pass
|
|
33
41
|
elif self.item.kind == "response":
|
|
34
|
-
yield AgentResponseWidget(
|
|
42
|
+
yield AgentResponseWidget(
|
|
43
|
+
self.item, is_sub_agent=self._is_sub_agent_active()
|
|
44
|
+
)
|
|
35
45
|
elif self.item.kind == "request":
|
|
36
46
|
yield UserQuestionWidget(self.item)
|
|
37
47
|
|
|
@@ -12,6 +12,8 @@ from textual.app import ComposeResult
|
|
|
12
12
|
from textual.widget import Widget
|
|
13
13
|
from textual.widgets import Markdown
|
|
14
14
|
|
|
15
|
+
from shotgun.agents.messages import InternalPromptPart
|
|
16
|
+
|
|
15
17
|
|
|
16
18
|
class UserQuestionWidget(Widget):
|
|
17
19
|
"""Widget that displays user prompts in the chat history."""
|
|
@@ -33,10 +35,30 @@ class UserQuestionWidget(Widget):
|
|
|
33
35
|
acc = ""
|
|
34
36
|
for part in parts:
|
|
35
37
|
if isinstance(part, UserPromptPart):
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
# Skip internal prompts (system-generated, not user input)
|
|
39
|
+
if isinstance(part, InternalPromptPart):
|
|
40
|
+
continue
|
|
41
|
+
content = self._extract_text_content(part.content)
|
|
42
|
+
if content:
|
|
43
|
+
acc += f"**>** {content}\n\n"
|
|
44
|
+
# Skip if no displayable text (e.g., only binary files)
|
|
39
45
|
elif isinstance(part, ToolReturnPart):
|
|
40
46
|
# Don't show tool return parts in the UI
|
|
41
47
|
pass
|
|
42
48
|
return acc
|
|
49
|
+
|
|
50
|
+
def _extract_text_content(self, content: object) -> str:
|
|
51
|
+
"""Extract displayable text from UserPromptPart content.
|
|
52
|
+
|
|
53
|
+
Content can be:
|
|
54
|
+
- str: Return directly
|
|
55
|
+
- list: Extract text strings, skip binary content (BinaryContent, ImageUrl, etc.)
|
|
56
|
+
- other: Return empty string
|
|
57
|
+
"""
|
|
58
|
+
if isinstance(content, str):
|
|
59
|
+
return content
|
|
60
|
+
if isinstance(content, list):
|
|
61
|
+
# Multimodal content - extract only text strings
|
|
62
|
+
text_parts = [item for item in content if isinstance(item, str)]
|
|
63
|
+
return " ".join(text_parts) if text_parts else ""
|
|
64
|
+
return ""
|