sa-assistant 0.2.0__tar.gz → 0.2.2__tar.gz
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.
- {sa_assistant-0.2.0 → sa_assistant-0.2.2}/PKG-INFO +1 -1
- {sa_assistant-0.2.0 → sa_assistant-0.2.2}/pyproject.toml +2 -2
- {sa_assistant-0.2.0 → sa_assistant-0.2.2}/src/sa_assistant.egg-info/PKG-INFO +1 -1
- {sa_assistant-0.2.0 → sa_assistant-0.2.2}/src/sa_assistant.egg-info/SOURCES.txt +1 -0
- sa_assistant-0.2.2/src/tui/app.py +386 -0
- sa_assistant-0.2.2/src/tui/styles.tcss +170 -0
- sa_assistant-0.2.0/src/tui/app.py +0 -269
- {sa_assistant-0.2.0 → sa_assistant-0.2.2}/README.md +0 -0
- {sa_assistant-0.2.0 → sa_assistant-0.2.2}/setup.cfg +0 -0
- {sa_assistant-0.2.0 → sa_assistant-0.2.2}/src/agents/__init__.py +0 -0
- {sa_assistant-0.2.0 → sa_assistant-0.2.2}/src/agents/orchestrator.py +0 -0
- {sa_assistant-0.2.0 → sa_assistant-0.2.2}/src/agents/specialists.py +0 -0
- {sa_assistant-0.2.0 → sa_assistant-0.2.2}/src/agentskills/__init__.py +0 -0
- {sa_assistant-0.2.0 → sa_assistant-0.2.2}/src/agentskills/discovery.py +0 -0
- {sa_assistant-0.2.0 → sa_assistant-0.2.2}/src/agentskills/errors.py +0 -0
- {sa_assistant-0.2.0 → sa_assistant-0.2.2}/src/agentskills/models.py +0 -0
- {sa_assistant-0.2.0 → sa_assistant-0.2.2}/src/agentskills/parser.py +0 -0
- {sa_assistant-0.2.0 → sa_assistant-0.2.2}/src/agentskills/prompt.py +0 -0
- {sa_assistant-0.2.0 → sa_assistant-0.2.2}/src/agentskills/tool.py +0 -0
- {sa_assistant-0.2.0 → sa_assistant-0.2.2}/src/cli/__init__.py +0 -0
- {sa_assistant-0.2.0 → sa_assistant-0.2.2}/src/cli/callback.py +0 -0
- {sa_assistant-0.2.0 → sa_assistant-0.2.2}/src/cli/components.py +0 -0
- {sa_assistant-0.2.0 → sa_assistant-0.2.2}/src/cli/console.py +0 -0
- {sa_assistant-0.2.0 → sa_assistant-0.2.2}/src/cli/mdstream.py +0 -0
- {sa_assistant-0.2.0 → sa_assistant-0.2.2}/src/mcp_client/client.py +0 -0
- {sa_assistant-0.2.0 → sa_assistant-0.2.2}/src/model/__init__.py +0 -0
- {sa_assistant-0.2.0 → sa_assistant-0.2.2}/src/model/load.py +0 -0
- {sa_assistant-0.2.0 → sa_assistant-0.2.2}/src/prompts/__init__.py +0 -0
- {sa_assistant-0.2.0 → sa_assistant-0.2.2}/src/prompts/system_prompts.py +0 -0
- {sa_assistant-0.2.0 → sa_assistant-0.2.2}/src/sa_assistant.egg-info/dependency_links.txt +0 -0
- {sa_assistant-0.2.0 → sa_assistant-0.2.2}/src/sa_assistant.egg-info/entry_points.txt +0 -0
- {sa_assistant-0.2.0 → sa_assistant-0.2.2}/src/sa_assistant.egg-info/requires.txt +0 -0
- {sa_assistant-0.2.0 → sa_assistant-0.2.2}/src/sa_assistant.egg-info/top_level.txt +0 -0
- {sa_assistant-0.2.0 → sa_assistant-0.2.2}/src/tui/__init__.py +0 -0
- {sa_assistant-0.2.0 → sa_assistant-0.2.2}/test/test_main.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "sa-assistant"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.2"
|
|
8
8
|
requires-python = ">=3.10"
|
|
9
9
|
description = "SA Assistant - AWS Solutions Architect Professional Agent with Multi-Agent Architecture"
|
|
10
10
|
readme = "README.md"
|
|
@@ -73,7 +73,7 @@ where = ["src"]
|
|
|
73
73
|
include = ["*"]
|
|
74
74
|
|
|
75
75
|
[tool.setuptools.package-data]
|
|
76
|
-
"*" = ["skills/**/*.md"]
|
|
76
|
+
"*" = ["skills/**/*.md", "*.tcss"]
|
|
77
77
|
|
|
78
78
|
[tool.ruff]
|
|
79
79
|
line-length = 100
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""SA Assistant TUI Application - OpenCode-style Terminal User Interface."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from textual import work
|
|
8
|
+
from textual.app import App, ComposeResult
|
|
9
|
+
from textual.binding import Binding
|
|
10
|
+
from textual.containers import ScrollableContainer, Vertical, Horizontal
|
|
11
|
+
from textual.widgets import Footer, Input, Static, RichLog, Collapsible
|
|
12
|
+
from textual.worker import Worker
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
from rich.markdown import Markdown
|
|
15
|
+
|
|
16
|
+
from strands import Agent
|
|
17
|
+
from strands.agent.conversation_manager import SlidingWindowConversationManager
|
|
18
|
+
from strands_tools import file_read, file_write, shell, image_reader
|
|
19
|
+
|
|
20
|
+
from model.load import load_sonnet
|
|
21
|
+
from prompts.system_prompts import ORCHESTRATOR_PROMPT
|
|
22
|
+
from agents.orchestrator import consult_guru, list_gurus
|
|
23
|
+
from agents.specialists import consult_specialist, list_specialists, parallel_research
|
|
24
|
+
from agentskills import discover_skills, generate_skills_prompt, create_skill_tool
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
CSS_PATH = Path(__file__).parent / "styles.tcss"
|
|
28
|
+
SKILLS_DIR = Path(__file__).parent.parent / "skills"
|
|
29
|
+
VERSION = "0.2.2"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SessionHeader(Static):
|
|
33
|
+
"""Top header showing session info in OpenCode style.
|
|
34
|
+
|
|
35
|
+
Format: # New session - {timestamp} {tokens} {percentage} (${cost}) v{version}
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self):
|
|
39
|
+
super().__init__()
|
|
40
|
+
self.session_start = datetime.now()
|
|
41
|
+
self.session_id = self.session_start.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
|
42
|
+
self.token_count = 0
|
|
43
|
+
self.max_tokens = 200000 # Claude context window
|
|
44
|
+
self.cost = 0.0
|
|
45
|
+
|
|
46
|
+
def on_mount(self) -> None:
|
|
47
|
+
self._update_display()
|
|
48
|
+
|
|
49
|
+
def _update_display(self) -> None:
|
|
50
|
+
percentage = int((self.token_count / self.max_tokens) * 100) if self.max_tokens > 0 else 0
|
|
51
|
+
header_text = (
|
|
52
|
+
f"[bold]# New session[/bold] - {self.session_id} "
|
|
53
|
+
f"[dim]{self.token_count:,}[/dim] {percentage}% "
|
|
54
|
+
f"[dim](${self.cost:.2f})[/dim] v{VERSION}"
|
|
55
|
+
)
|
|
56
|
+
self.update(header_text)
|
|
57
|
+
|
|
58
|
+
def update_stats(self, tokens: int = 0, cost: float = 0.0) -> None:
|
|
59
|
+
self.token_count += tokens
|
|
60
|
+
self.cost += cost
|
|
61
|
+
self._update_display()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class AgentIndicator(Static):
|
|
65
|
+
"""Bottom indicator showing current agent and model.
|
|
66
|
+
|
|
67
|
+
Format: ■ SA Assistant · Claude Sonnet
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, agent_name: str = "SA Assistant", model_name: str = "Claude Sonnet"):
|
|
71
|
+
super().__init__()
|
|
72
|
+
self.agent_name = agent_name
|
|
73
|
+
self.model_name = model_name
|
|
74
|
+
|
|
75
|
+
def on_mount(self) -> None:
|
|
76
|
+
self._update_display()
|
|
77
|
+
|
|
78
|
+
def _update_display(self) -> None:
|
|
79
|
+
self.update(f"[#FF9900]■[/#FF9900] {self.agent_name} · [dim]{self.model_name}[/dim]")
|
|
80
|
+
|
|
81
|
+
def set_agent(self, name: str, model: str = None) -> None:
|
|
82
|
+
self.agent_name = name
|
|
83
|
+
if model:
|
|
84
|
+
self.model_name = model
|
|
85
|
+
self._update_display()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class HotkeyBar(Static):
|
|
89
|
+
"""Bottom hotkey bar showing available shortcuts.
|
|
90
|
+
|
|
91
|
+
Format: esc interrupt ctrl+l clear ctrl+t theme ctrl+c quit
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def on_mount(self) -> None:
|
|
95
|
+
hotkeys = (
|
|
96
|
+
"[dim]esc[/dim] interrupt "
|
|
97
|
+
"[dim]ctrl+l[/dim] clear "
|
|
98
|
+
"[dim]ctrl+t[/dim] theme "
|
|
99
|
+
"[dim]ctrl+c[/dim] quit"
|
|
100
|
+
)
|
|
101
|
+
self.update(hotkeys)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class MessagePanel(Vertical):
|
|
105
|
+
"""A bordered panel for displaying a message with OpenCode styling.
|
|
106
|
+
|
|
107
|
+
Each message has a header line (# Title) and content area.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def __init__(
|
|
111
|
+
self,
|
|
112
|
+
title: str,
|
|
113
|
+
content: str = "",
|
|
114
|
+
panel_type: str = "default",
|
|
115
|
+
collapsible: bool = False,
|
|
116
|
+
collapsed: bool = False,
|
|
117
|
+
):
|
|
118
|
+
super().__init__()
|
|
119
|
+
self.title = title
|
|
120
|
+
self._content = content
|
|
121
|
+
self.panel_type = panel_type
|
|
122
|
+
self.collapsible = collapsible
|
|
123
|
+
self.collapsed = collapsed
|
|
124
|
+
self._log: Optional[RichLog] = None
|
|
125
|
+
self.add_class(f"panel-{panel_type}")
|
|
126
|
+
|
|
127
|
+
def compose(self) -> ComposeResult:
|
|
128
|
+
# Title header
|
|
129
|
+
title_class = "panel-title"
|
|
130
|
+
if self.collapsible:
|
|
131
|
+
title_class += " collapsible-title"
|
|
132
|
+
collapse_indicator = "▶ " if self.collapsed else "▼ "
|
|
133
|
+
yield Static(
|
|
134
|
+
f"[bold]{collapse_indicator}# {self.title}[/bold]",
|
|
135
|
+
id="panel-header",
|
|
136
|
+
classes=title_class,
|
|
137
|
+
)
|
|
138
|
+
else:
|
|
139
|
+
yield Static(f"[bold]# {self.title}[/bold]", id="panel-header", classes=title_class)
|
|
140
|
+
|
|
141
|
+
# Content area
|
|
142
|
+
self._log = RichLog(highlight=True, markup=True, wrap=True, id="panel-content")
|
|
143
|
+
if not self.collapsed:
|
|
144
|
+
yield self._log
|
|
145
|
+
|
|
146
|
+
def on_mount(self) -> None:
|
|
147
|
+
if self._content and self._log:
|
|
148
|
+
self._log.write(self._content)
|
|
149
|
+
|
|
150
|
+
def append(self, text: str) -> None:
|
|
151
|
+
"""Append text to the content area."""
|
|
152
|
+
if self._log:
|
|
153
|
+
self._log.write(text)
|
|
154
|
+
|
|
155
|
+
def update_content(self, text: str) -> None:
|
|
156
|
+
"""Replace all content."""
|
|
157
|
+
if self._log:
|
|
158
|
+
self._log.clear()
|
|
159
|
+
self._log.write(text)
|
|
160
|
+
|
|
161
|
+
def toggle_collapse(self) -> None:
|
|
162
|
+
"""Toggle collapsed state."""
|
|
163
|
+
if not self.collapsible:
|
|
164
|
+
return
|
|
165
|
+
self.collapsed = not self.collapsed
|
|
166
|
+
header = self.query_one("#panel-header", Static)
|
|
167
|
+
collapse_indicator = "▶ " if self.collapsed else "▼ "
|
|
168
|
+
header.update(f"[bold]{collapse_indicator}# {self.title}[/bold]")
|
|
169
|
+
|
|
170
|
+
if self.collapsed:
|
|
171
|
+
if self._log:
|
|
172
|
+
self._log.remove()
|
|
173
|
+
self._log = None
|
|
174
|
+
else:
|
|
175
|
+
self._log = RichLog(highlight=True, markup=True, wrap=True, id="panel-content")
|
|
176
|
+
self.mount(self._log)
|
|
177
|
+
if self._content:
|
|
178
|
+
self._log.write(self._content)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class ChatContainer(ScrollableContainer):
|
|
182
|
+
"""Main scrollable container for chat messages."""
|
|
183
|
+
|
|
184
|
+
def __init__(self):
|
|
185
|
+
super().__init__()
|
|
186
|
+
self._panels: list[MessagePanel] = []
|
|
187
|
+
|
|
188
|
+
def add_user_message(self, text: str) -> MessagePanel:
|
|
189
|
+
"""Add a user message panel."""
|
|
190
|
+
panel = MessagePanel(
|
|
191
|
+
title="You",
|
|
192
|
+
content=text,
|
|
193
|
+
panel_type="user",
|
|
194
|
+
collapsible=False,
|
|
195
|
+
)
|
|
196
|
+
self._panels.append(panel)
|
|
197
|
+
self.mount(panel)
|
|
198
|
+
panel.scroll_visible()
|
|
199
|
+
return panel
|
|
200
|
+
|
|
201
|
+
def add_assistant_message(self, title: str = "SA Assistant") -> MessagePanel:
|
|
202
|
+
"""Add an assistant message panel."""
|
|
203
|
+
panel = MessagePanel(
|
|
204
|
+
title=title,
|
|
205
|
+
panel_type="assistant",
|
|
206
|
+
collapsible=False,
|
|
207
|
+
)
|
|
208
|
+
self._panels.append(panel)
|
|
209
|
+
self.mount(panel)
|
|
210
|
+
panel.scroll_visible()
|
|
211
|
+
return panel
|
|
212
|
+
|
|
213
|
+
def add_tool_panel(self, tool_name: str, collapsed: bool = True) -> MessagePanel:
|
|
214
|
+
"""Add a tool execution panel (collapsible)."""
|
|
215
|
+
emoji_map = {
|
|
216
|
+
"consult_guru": "🧙",
|
|
217
|
+
"consult_specialist": "🔬",
|
|
218
|
+
"file_read": "📄",
|
|
219
|
+
"file_write": "💾",
|
|
220
|
+
"shell": "🖥️",
|
|
221
|
+
"skill": "📚",
|
|
222
|
+
"image_reader": "🖼️",
|
|
223
|
+
}
|
|
224
|
+
emoji = emoji_map.get(tool_name, "🔧")
|
|
225
|
+
panel = MessagePanel(
|
|
226
|
+
title=f"{emoji} {tool_name}",
|
|
227
|
+
panel_type="tool",
|
|
228
|
+
collapsible=True,
|
|
229
|
+
collapsed=collapsed,
|
|
230
|
+
)
|
|
231
|
+
self._panels.append(panel)
|
|
232
|
+
self.mount(panel)
|
|
233
|
+
return panel
|
|
234
|
+
|
|
235
|
+
def add_thinking_panel(self) -> MessagePanel:
|
|
236
|
+
"""Add a thinking/reasoning panel (collapsible)."""
|
|
237
|
+
panel = MessagePanel(
|
|
238
|
+
title="💭 Thinking",
|
|
239
|
+
panel_type="thinking",
|
|
240
|
+
collapsible=True,
|
|
241
|
+
collapsed=True,
|
|
242
|
+
)
|
|
243
|
+
self._panels.append(panel)
|
|
244
|
+
self.mount(panel)
|
|
245
|
+
return panel
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class SAAssistantApp(App):
|
|
249
|
+
"""SA Assistant Textual TUI Application - OpenCode Style."""
|
|
250
|
+
|
|
251
|
+
CSS_PATH = "styles.tcss"
|
|
252
|
+
TITLE = "SA Assistant"
|
|
253
|
+
|
|
254
|
+
BINDINGS = [
|
|
255
|
+
Binding("escape", "cancel", "Interrupt", show=False),
|
|
256
|
+
Binding("ctrl+c", "quit", "Quit", show=False),
|
|
257
|
+
Binding("ctrl+l", "clear", "Clear", show=False),
|
|
258
|
+
Binding("ctrl+t", "toggle_dark", "Theme", show=False),
|
|
259
|
+
]
|
|
260
|
+
|
|
261
|
+
def __init__(self):
|
|
262
|
+
super().__init__()
|
|
263
|
+
self._agent: Optional[Agent] = None
|
|
264
|
+
self._current_panel: Optional[MessagePanel] = None
|
|
265
|
+
self._response_buffer = ""
|
|
266
|
+
self._init_agent()
|
|
267
|
+
|
|
268
|
+
def _init_agent(self) -> None:
|
|
269
|
+
"""Initialize the Strands agent."""
|
|
270
|
+
discovered_skills = discover_skills(SKILLS_DIR)
|
|
271
|
+
skills_prompt = generate_skills_prompt(discovered_skills)
|
|
272
|
+
full_prompt = ORCHESTRATOR_PROMPT + skills_prompt
|
|
273
|
+
skill_tool = create_skill_tool(discovered_skills, SKILLS_DIR) if discovered_skills else None
|
|
274
|
+
|
|
275
|
+
tools = [
|
|
276
|
+
consult_guru,
|
|
277
|
+
list_gurus,
|
|
278
|
+
consult_specialist,
|
|
279
|
+
list_specialists,
|
|
280
|
+
parallel_research,
|
|
281
|
+
file_read,
|
|
282
|
+
file_write,
|
|
283
|
+
image_reader,
|
|
284
|
+
shell,
|
|
285
|
+
]
|
|
286
|
+
if skill_tool:
|
|
287
|
+
tools.append(skill_tool)
|
|
288
|
+
|
|
289
|
+
conversation_manager = SlidingWindowConversationManager(window_size=20)
|
|
290
|
+
|
|
291
|
+
self._agent = Agent(
|
|
292
|
+
model=load_sonnet(enable_thinking=False),
|
|
293
|
+
conversation_manager=conversation_manager,
|
|
294
|
+
system_prompt=full_prompt,
|
|
295
|
+
tools=tools,
|
|
296
|
+
callback_handler=self._streaming_callback,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
def _streaming_callback(self, **kwargs) -> None:
|
|
300
|
+
"""Handle streaming responses from the agent."""
|
|
301
|
+
if "data" in kwargs:
|
|
302
|
+
self._response_buffer += kwargs["data"]
|
|
303
|
+
if self._current_panel:
|
|
304
|
+
self.call_from_thread(self._current_panel.update_content, self._response_buffer)
|
|
305
|
+
|
|
306
|
+
if "current_tool_use" in kwargs:
|
|
307
|
+
tool_use = kwargs["current_tool_use"]
|
|
308
|
+
if isinstance(tool_use, dict):
|
|
309
|
+
tool_name = tool_use.get("name", "tool")
|
|
310
|
+
self.call_from_thread(self._add_tool_panel, tool_name)
|
|
311
|
+
|
|
312
|
+
def _add_tool_panel(self, tool_name: str) -> None:
|
|
313
|
+
"""Add a tool execution panel to the chat."""
|
|
314
|
+
chat = self.query_one(ChatContainer)
|
|
315
|
+
chat.add_tool_panel(tool_name, collapsed=True)
|
|
316
|
+
|
|
317
|
+
def compose(self) -> ComposeResult:
|
|
318
|
+
"""Compose the UI layout."""
|
|
319
|
+
yield SessionHeader()
|
|
320
|
+
yield ChatContainer()
|
|
321
|
+
yield AgentIndicator()
|
|
322
|
+
yield Input(placeholder="Type your message... (Enter to send)", id="input-field")
|
|
323
|
+
yield HotkeyBar()
|
|
324
|
+
|
|
325
|
+
def on_mount(self) -> None:
|
|
326
|
+
"""Focus the input field on mount."""
|
|
327
|
+
self.query_one("#input-field", Input).focus()
|
|
328
|
+
|
|
329
|
+
async def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
330
|
+
"""Handle user input submission."""
|
|
331
|
+
user_input = event.value.strip()
|
|
332
|
+
if not user_input:
|
|
333
|
+
return
|
|
334
|
+
|
|
335
|
+
# Clear input
|
|
336
|
+
event.input.value = ""
|
|
337
|
+
|
|
338
|
+
# Handle exit commands
|
|
339
|
+
if user_input.lower() in ("exit", "quit", "종료"):
|
|
340
|
+
self.exit()
|
|
341
|
+
return
|
|
342
|
+
|
|
343
|
+
# Add user message to chat
|
|
344
|
+
chat = self.query_one(ChatContainer)
|
|
345
|
+
chat.add_user_message(user_input)
|
|
346
|
+
|
|
347
|
+
# Prepare for assistant response
|
|
348
|
+
self._current_panel = chat.add_assistant_message()
|
|
349
|
+
self._response_buffer = ""
|
|
350
|
+
|
|
351
|
+
# Run agent in background thread
|
|
352
|
+
self.run_agent(user_input)
|
|
353
|
+
|
|
354
|
+
@work(thread=True)
|
|
355
|
+
def run_agent(self, prompt: str) -> None:
|
|
356
|
+
"""Run the agent in a background thread."""
|
|
357
|
+
try:
|
|
358
|
+
self._agent(prompt)
|
|
359
|
+
except Exception as e:
|
|
360
|
+
if self._current_panel:
|
|
361
|
+
self.call_from_thread(self._current_panel.update_content, f"[red]Error: {e}[/red]")
|
|
362
|
+
|
|
363
|
+
def action_cancel(self) -> None:
|
|
364
|
+
"""Cancel running operations."""
|
|
365
|
+
self.workers.cancel_all()
|
|
366
|
+
|
|
367
|
+
def action_clear(self) -> None:
|
|
368
|
+
"""Clear the chat container."""
|
|
369
|
+
chat = self.query_one(ChatContainer)
|
|
370
|
+
for panel in chat._panels:
|
|
371
|
+
panel.remove()
|
|
372
|
+
chat._panels.clear()
|
|
373
|
+
|
|
374
|
+
def action_toggle_dark(self) -> None:
|
|
375
|
+
"""Toggle dark/light theme."""
|
|
376
|
+
self.dark = not self.dark
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def run_app() -> None:
|
|
380
|
+
"""Entry point for the TUI application."""
|
|
381
|
+
app = SAAssistantApp()
|
|
382
|
+
app.run()
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
if __name__ == "__main__":
|
|
386
|
+
run_app()
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/* SA Assistant TUI - OpenCode Style Theme */
|
|
2
|
+
|
|
3
|
+
$aws-orange: #FF9900;
|
|
4
|
+
$aws-dark: #232F3E;
|
|
5
|
+
$aws-blue: #1A73E8;
|
|
6
|
+
$border-color: #3D4F5F;
|
|
7
|
+
$panel-bg: #1E1E1E;
|
|
8
|
+
|
|
9
|
+
/* ===== Screen ===== */
|
|
10
|
+
Screen {
|
|
11
|
+
background: $surface;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/* ===== Session Header ===== */
|
|
15
|
+
SessionHeader {
|
|
16
|
+
dock: top;
|
|
17
|
+
height: 3;
|
|
18
|
+
background: $surface-darken-1;
|
|
19
|
+
border-bottom: solid $border-color;
|
|
20
|
+
padding: 1 2;
|
|
21
|
+
color: $text;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/* ===== Agent Indicator ===== */
|
|
25
|
+
AgentIndicator {
|
|
26
|
+
dock: bottom;
|
|
27
|
+
height: 3;
|
|
28
|
+
background: $surface-darken-2;
|
|
29
|
+
border-top: solid $border-color;
|
|
30
|
+
padding: 1 2;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/* ===== Hotkey Bar ===== */
|
|
34
|
+
HotkeyBar {
|
|
35
|
+
dock: bottom;
|
|
36
|
+
height: 1;
|
|
37
|
+
background: $aws-dark;
|
|
38
|
+
padding: 0 2;
|
|
39
|
+
color: $text-muted;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* ===== Input Field ===== */
|
|
43
|
+
Input {
|
|
44
|
+
dock: bottom;
|
|
45
|
+
margin: 0 1;
|
|
46
|
+
border: tall $border-color;
|
|
47
|
+
background: $surface-darken-1;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
Input:focus {
|
|
51
|
+
border: tall $aws-orange;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* ===== Chat Container ===== */
|
|
55
|
+
ChatContainer {
|
|
56
|
+
height: 1fr;
|
|
57
|
+
padding: 0 1;
|
|
58
|
+
overflow-y: auto;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* ===== Message Panels ===== */
|
|
62
|
+
MessagePanel {
|
|
63
|
+
margin: 1 0;
|
|
64
|
+
border: round $border-color;
|
|
65
|
+
background: $panel-bg;
|
|
66
|
+
height: auto;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* Panel Title Header */
|
|
70
|
+
.panel-title {
|
|
71
|
+
background: $surface-darken-2;
|
|
72
|
+
padding: 0 1;
|
|
73
|
+
height: 1;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.panel-title:hover {
|
|
77
|
+
background: $surface-darken-1;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.collapsible-title {
|
|
81
|
+
text-style: bold;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.collapsible-title:hover {
|
|
85
|
+
background: $primary-darken-2;
|
|
86
|
+
cursor: pointer;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/* Panel Content */
|
|
90
|
+
#panel-content {
|
|
91
|
+
height: auto;
|
|
92
|
+
max-height: 40;
|
|
93
|
+
padding: 1 2;
|
|
94
|
+
background: $surface;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/* ===== Panel Type Variations ===== */
|
|
98
|
+
|
|
99
|
+
/* User messages - green accent */
|
|
100
|
+
.panel-user {
|
|
101
|
+
border: round green;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.panel-user .panel-title {
|
|
105
|
+
color: green;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/* Assistant messages - orange accent */
|
|
109
|
+
.panel-assistant {
|
|
110
|
+
border: round $aws-orange;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.panel-assistant .panel-title {
|
|
114
|
+
color: $aws-orange;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/* Tool panels - cyan accent, dashed border */
|
|
118
|
+
.panel-tool {
|
|
119
|
+
margin-left: 2;
|
|
120
|
+
border: dashed cyan;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.panel-tool .panel-title {
|
|
124
|
+
color: cyan;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.panel-tool #panel-content {
|
|
128
|
+
max-height: 20;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* Thinking panels - dim purple */
|
|
132
|
+
.panel-thinking {
|
|
133
|
+
margin-left: 2;
|
|
134
|
+
border: dashed magenta 50%;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.panel-thinking .panel-title {
|
|
138
|
+
color: magenta 50%;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.panel-thinking #panel-content {
|
|
142
|
+
max-height: 15;
|
|
143
|
+
color: $text-muted;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/* ===== RichLog Styling ===== */
|
|
147
|
+
RichLog {
|
|
148
|
+
height: auto;
|
|
149
|
+
padding: 1 2;
|
|
150
|
+
background: $surface;
|
|
151
|
+
scrollbar-size: 1 1;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/* ===== Scrollbar ===== */
|
|
155
|
+
ChatContainer {
|
|
156
|
+
scrollbar-size: 1 1;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/* ===== Dark Mode Adjustments ===== */
|
|
160
|
+
Screen.-dark-mode {
|
|
161
|
+
background: #0D1117;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
Screen.-dark-mode MessagePanel {
|
|
165
|
+
background: #161B22;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
Screen.-dark-mode .panel-title {
|
|
169
|
+
background: #21262D;
|
|
170
|
+
}
|
|
@@ -1,269 +0,0 @@
|
|
|
1
|
-
"""SA Assistant TUI Application - Main Textual App class."""
|
|
2
|
-
|
|
3
|
-
from datetime import datetime
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
from typing import Optional
|
|
6
|
-
|
|
7
|
-
from textual import work
|
|
8
|
-
from textual.app import App, ComposeResult
|
|
9
|
-
from textual.binding import Binding
|
|
10
|
-
from textual.containers import ScrollableContainer, Vertical
|
|
11
|
-
from textual.widgets import Footer, Header, Input, Static, RichLog, Collapsible
|
|
12
|
-
from textual.worker import Worker
|
|
13
|
-
from rich.text import Text
|
|
14
|
-
from rich.markdown import Markdown
|
|
15
|
-
|
|
16
|
-
from strands import Agent
|
|
17
|
-
from strands.agent.conversation_manager import SlidingWindowConversationManager
|
|
18
|
-
from strands_tools import file_read, file_write, shell, image_reader
|
|
19
|
-
|
|
20
|
-
from model.load import load_sonnet
|
|
21
|
-
from prompts.system_prompts import ORCHESTRATOR_PROMPT
|
|
22
|
-
from agents.orchestrator import consult_guru, list_gurus
|
|
23
|
-
from agents.specialists import consult_specialist, list_specialists, parallel_research
|
|
24
|
-
from agentskills import discover_skills, generate_skills_prompt, create_skill_tool
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
CSS_PATH = Path(__file__).parent / "styles.tcss"
|
|
28
|
-
SKILLS_DIR = Path(__file__).parent.parent / "skills"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
class SessionHeader(Static):
|
|
32
|
-
"""Top header showing session info, tokens, cost."""
|
|
33
|
-
|
|
34
|
-
def __init__(self, session_id: str = None):
|
|
35
|
-
super().__init__()
|
|
36
|
-
self.session_start = datetime.now()
|
|
37
|
-
self.session_id = session_id or self.session_start.strftime("%Y-%m-%dT%H:%M:%S")
|
|
38
|
-
self.token_count = 0
|
|
39
|
-
self.cost = 0.0
|
|
40
|
-
|
|
41
|
-
def compose(self) -> ComposeResult:
|
|
42
|
-
yield Static(id="session-info")
|
|
43
|
-
|
|
44
|
-
def on_mount(self) -> None:
|
|
45
|
-
self._update_display()
|
|
46
|
-
|
|
47
|
-
def _update_display(self) -> None:
|
|
48
|
-
info = self.query_one("#session-info", Static)
|
|
49
|
-
info.update(
|
|
50
|
-
f"# Session - {self.session_id} "
|
|
51
|
-
f"[dim]{self.token_count:,} tokens · ${self.cost:.2f}[/dim]"
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
def update_stats(self, tokens: int = 0, cost: float = 0.0) -> None:
|
|
55
|
-
self.token_count += tokens
|
|
56
|
-
self.cost += cost
|
|
57
|
-
self._update_display()
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
class AgentIndicator(Static):
|
|
61
|
-
"""Bottom indicator showing current agent and model."""
|
|
62
|
-
|
|
63
|
-
def __init__(self, agent_name: str = "SA Assistant", model_name: str = "Claude Sonnet"):
|
|
64
|
-
super().__init__()
|
|
65
|
-
self.agent_name = agent_name
|
|
66
|
-
self.model_name = model_name
|
|
67
|
-
|
|
68
|
-
def on_mount(self) -> None:
|
|
69
|
-
self._update_display()
|
|
70
|
-
|
|
71
|
-
def _update_display(self) -> None:
|
|
72
|
-
self.update(f"[cyan]■[/cyan] {self.agent_name} · [dim]{self.model_name}[/dim]")
|
|
73
|
-
|
|
74
|
-
def set_agent(self, name: str, model: str = None) -> None:
|
|
75
|
-
self.agent_name = name
|
|
76
|
-
if model:
|
|
77
|
-
self.model_name = model
|
|
78
|
-
self._update_display()
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
class MessageSection(Collapsible):
|
|
82
|
-
"""Collapsible section for a single message/action."""
|
|
83
|
-
|
|
84
|
-
def __init__(self, title: str, content: str = "", collapsed: bool = False):
|
|
85
|
-
super().__init__(title=title, collapsed=collapsed)
|
|
86
|
-
self._content = content
|
|
87
|
-
self._log: Optional[RichLog] = None
|
|
88
|
-
|
|
89
|
-
def compose(self) -> ComposeResult:
|
|
90
|
-
self._log = RichLog(highlight=True, markup=True, wrap=True)
|
|
91
|
-
yield self._log
|
|
92
|
-
|
|
93
|
-
def on_mount(self) -> None:
|
|
94
|
-
if self._content and self._log:
|
|
95
|
-
self._log.write(self._content)
|
|
96
|
-
|
|
97
|
-
def append(self, text: str) -> None:
|
|
98
|
-
if self._log:
|
|
99
|
-
self._log.write(text)
|
|
100
|
-
|
|
101
|
-
def update_content(self, text: str) -> None:
|
|
102
|
-
if self._log:
|
|
103
|
-
self._log.clear()
|
|
104
|
-
self._log.write(text)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
class ChatContainer(ScrollableContainer):
|
|
108
|
-
"""Main scrollable container for chat messages."""
|
|
109
|
-
|
|
110
|
-
def __init__(self):
|
|
111
|
-
super().__init__()
|
|
112
|
-
self._sections: list[MessageSection] = []
|
|
113
|
-
|
|
114
|
-
def add_user_message(self, text: str) -> MessageSection:
|
|
115
|
-
section = MessageSection(title=f"[green]You[/green]", content=text, collapsed=False)
|
|
116
|
-
self._sections.append(section)
|
|
117
|
-
self.mount(section)
|
|
118
|
-
section.scroll_visible()
|
|
119
|
-
return section
|
|
120
|
-
|
|
121
|
-
def add_assistant_message(self, title: str = "SA Assistant") -> MessageSection:
|
|
122
|
-
section = MessageSection(title=f"[#FF9900]{title}[/#FF9900]", collapsed=False)
|
|
123
|
-
self._sections.append(section)
|
|
124
|
-
self.mount(section)
|
|
125
|
-
section.scroll_visible()
|
|
126
|
-
return section
|
|
127
|
-
|
|
128
|
-
def add_tool_section(self, tool_name: str, collapsed: bool = True) -> MessageSection:
|
|
129
|
-
emoji_map = {
|
|
130
|
-
"consult_guru": "🧙",
|
|
131
|
-
"consult_specialist": "🔬",
|
|
132
|
-
"file_read": "📄",
|
|
133
|
-
"file_write": "💾",
|
|
134
|
-
"shell": "🖥️",
|
|
135
|
-
"skill": "📚",
|
|
136
|
-
}
|
|
137
|
-
emoji = emoji_map.get(tool_name, "🔧")
|
|
138
|
-
section = MessageSection(title=f"[cyan]{emoji} {tool_name}[/cyan]", collapsed=collapsed)
|
|
139
|
-
self._sections.append(section)
|
|
140
|
-
self.mount(section)
|
|
141
|
-
return section
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
class SAAssistantApp(App):
|
|
145
|
-
"""SA Assistant Textual TUI Application."""
|
|
146
|
-
|
|
147
|
-
CSS_PATH = "styles.tcss"
|
|
148
|
-
|
|
149
|
-
TITLE = "SA Assistant"
|
|
150
|
-
SUB_TITLE = "AWS Solutions Architect Agent"
|
|
151
|
-
|
|
152
|
-
BINDINGS = [
|
|
153
|
-
Binding("escape", "cancel", "Cancel", show=True),
|
|
154
|
-
Binding("ctrl+c", "quit", "Quit", show=True),
|
|
155
|
-
Binding("ctrl+l", "clear", "Clear", show=True),
|
|
156
|
-
Binding("ctrl+t", "toggle_dark", "Theme", show=True),
|
|
157
|
-
]
|
|
158
|
-
|
|
159
|
-
def __init__(self):
|
|
160
|
-
super().__init__()
|
|
161
|
-
self._agent: Optional[Agent] = None
|
|
162
|
-
self._current_section: Optional[MessageSection] = None
|
|
163
|
-
self._response_buffer = ""
|
|
164
|
-
self._init_agent()
|
|
165
|
-
|
|
166
|
-
def _init_agent(self) -> None:
|
|
167
|
-
discovered_skills = discover_skills(SKILLS_DIR)
|
|
168
|
-
skills_prompt = generate_skills_prompt(discovered_skills)
|
|
169
|
-
full_prompt = ORCHESTRATOR_PROMPT + skills_prompt
|
|
170
|
-
skill_tool = create_skill_tool(discovered_skills, SKILLS_DIR) if discovered_skills else None
|
|
171
|
-
|
|
172
|
-
tools = [
|
|
173
|
-
consult_guru,
|
|
174
|
-
list_gurus,
|
|
175
|
-
consult_specialist,
|
|
176
|
-
list_specialists,
|
|
177
|
-
parallel_research,
|
|
178
|
-
file_read,
|
|
179
|
-
file_write,
|
|
180
|
-
image_reader,
|
|
181
|
-
shell,
|
|
182
|
-
]
|
|
183
|
-
if skill_tool:
|
|
184
|
-
tools.append(skill_tool)
|
|
185
|
-
|
|
186
|
-
conversation_manager = SlidingWindowConversationManager(window_size=20)
|
|
187
|
-
|
|
188
|
-
self._agent = Agent(
|
|
189
|
-
model=load_sonnet(enable_thinking=False),
|
|
190
|
-
conversation_manager=conversation_manager,
|
|
191
|
-
system_prompt=full_prompt,
|
|
192
|
-
tools=tools,
|
|
193
|
-
callback_handler=self._streaming_callback,
|
|
194
|
-
)
|
|
195
|
-
|
|
196
|
-
def _streaming_callback(self, **kwargs) -> None:
|
|
197
|
-
if "data" in kwargs:
|
|
198
|
-
self._response_buffer += kwargs["data"]
|
|
199
|
-
if self._current_section:
|
|
200
|
-
self.call_from_thread(self._current_section.update_content, self._response_buffer)
|
|
201
|
-
|
|
202
|
-
if "current_tool_use" in kwargs:
|
|
203
|
-
tool_use = kwargs["current_tool_use"]
|
|
204
|
-
if isinstance(tool_use, dict):
|
|
205
|
-
tool_name = tool_use.get("name", "tool")
|
|
206
|
-
self.call_from_thread(self._add_tool_section, tool_name)
|
|
207
|
-
|
|
208
|
-
def _add_tool_section(self, tool_name: str) -> None:
|
|
209
|
-
chat = self.query_one(ChatContainer)
|
|
210
|
-
chat.add_tool_section(tool_name, collapsed=True)
|
|
211
|
-
|
|
212
|
-
def compose(self) -> ComposeResult:
|
|
213
|
-
yield Header()
|
|
214
|
-
yield SessionHeader()
|
|
215
|
-
yield ChatContainer()
|
|
216
|
-
yield AgentIndicator()
|
|
217
|
-
yield Input(placeholder="Type your message... (Enter to send)")
|
|
218
|
-
yield Footer()
|
|
219
|
-
|
|
220
|
-
def on_mount(self) -> None:
|
|
221
|
-
self.query_one(Input).focus()
|
|
222
|
-
|
|
223
|
-
async def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
224
|
-
user_input = event.value.strip()
|
|
225
|
-
if not user_input:
|
|
226
|
-
return
|
|
227
|
-
|
|
228
|
-
event.input.value = ""
|
|
229
|
-
|
|
230
|
-
if user_input.lower() in ("exit", "quit", "종료"):
|
|
231
|
-
self.exit()
|
|
232
|
-
return
|
|
233
|
-
|
|
234
|
-
chat = self.query_one(ChatContainer)
|
|
235
|
-
chat.add_user_message(user_input)
|
|
236
|
-
|
|
237
|
-
self._current_section = chat.add_assistant_message()
|
|
238
|
-
self._response_buffer = ""
|
|
239
|
-
|
|
240
|
-
self.run_agent(user_input)
|
|
241
|
-
|
|
242
|
-
@work(thread=True)
|
|
243
|
-
def run_agent(self, prompt: str) -> None:
|
|
244
|
-
try:
|
|
245
|
-
self._agent(prompt)
|
|
246
|
-
except Exception as e:
|
|
247
|
-
if self._current_section:
|
|
248
|
-
self.call_from_thread(
|
|
249
|
-
self._current_section.update_content, f"[red]Error: {e}[/red]"
|
|
250
|
-
)
|
|
251
|
-
|
|
252
|
-
def action_cancel(self) -> None:
|
|
253
|
-
self.workers.cancel_all()
|
|
254
|
-
|
|
255
|
-
def action_clear(self) -> None:
|
|
256
|
-
chat = self.query_one(ChatContainer)
|
|
257
|
-
chat.remove_children()
|
|
258
|
-
|
|
259
|
-
def action_toggle_dark(self) -> None:
|
|
260
|
-
self.dark = not self.dark
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
def run_app() -> None:
|
|
264
|
-
app = SAAssistantApp()
|
|
265
|
-
app.run()
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
if __name__ == "__main__":
|
|
269
|
-
run_app()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|