sa-assistant 0.2.0__tar.gz → 0.2.1__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.
Files changed (35) hide show
  1. {sa_assistant-0.2.0 → sa_assistant-0.2.1}/PKG-INFO +1 -1
  2. {sa_assistant-0.2.0 → sa_assistant-0.2.1}/pyproject.toml +2 -2
  3. {sa_assistant-0.2.0 → sa_assistant-0.2.1}/src/sa_assistant.egg-info/PKG-INFO +1 -1
  4. {sa_assistant-0.2.0 → sa_assistant-0.2.1}/src/sa_assistant.egg-info/SOURCES.txt +1 -0
  5. sa_assistant-0.2.1/src/tui/app.py +386 -0
  6. sa_assistant-0.2.1/src/tui/styles.tcss +170 -0
  7. sa_assistant-0.2.0/src/tui/app.py +0 -269
  8. {sa_assistant-0.2.0 → sa_assistant-0.2.1}/README.md +0 -0
  9. {sa_assistant-0.2.0 → sa_assistant-0.2.1}/setup.cfg +0 -0
  10. {sa_assistant-0.2.0 → sa_assistant-0.2.1}/src/agents/__init__.py +0 -0
  11. {sa_assistant-0.2.0 → sa_assistant-0.2.1}/src/agents/orchestrator.py +0 -0
  12. {sa_assistant-0.2.0 → sa_assistant-0.2.1}/src/agents/specialists.py +0 -0
  13. {sa_assistant-0.2.0 → sa_assistant-0.2.1}/src/agentskills/__init__.py +0 -0
  14. {sa_assistant-0.2.0 → sa_assistant-0.2.1}/src/agentskills/discovery.py +0 -0
  15. {sa_assistant-0.2.0 → sa_assistant-0.2.1}/src/agentskills/errors.py +0 -0
  16. {sa_assistant-0.2.0 → sa_assistant-0.2.1}/src/agentskills/models.py +0 -0
  17. {sa_assistant-0.2.0 → sa_assistant-0.2.1}/src/agentskills/parser.py +0 -0
  18. {sa_assistant-0.2.0 → sa_assistant-0.2.1}/src/agentskills/prompt.py +0 -0
  19. {sa_assistant-0.2.0 → sa_assistant-0.2.1}/src/agentskills/tool.py +0 -0
  20. {sa_assistant-0.2.0 → sa_assistant-0.2.1}/src/cli/__init__.py +0 -0
  21. {sa_assistant-0.2.0 → sa_assistant-0.2.1}/src/cli/callback.py +0 -0
  22. {sa_assistant-0.2.0 → sa_assistant-0.2.1}/src/cli/components.py +0 -0
  23. {sa_assistant-0.2.0 → sa_assistant-0.2.1}/src/cli/console.py +0 -0
  24. {sa_assistant-0.2.0 → sa_assistant-0.2.1}/src/cli/mdstream.py +0 -0
  25. {sa_assistant-0.2.0 → sa_assistant-0.2.1}/src/mcp_client/client.py +0 -0
  26. {sa_assistant-0.2.0 → sa_assistant-0.2.1}/src/model/__init__.py +0 -0
  27. {sa_assistant-0.2.0 → sa_assistant-0.2.1}/src/model/load.py +0 -0
  28. {sa_assistant-0.2.0 → sa_assistant-0.2.1}/src/prompts/__init__.py +0 -0
  29. {sa_assistant-0.2.0 → sa_assistant-0.2.1}/src/prompts/system_prompts.py +0 -0
  30. {sa_assistant-0.2.0 → sa_assistant-0.2.1}/src/sa_assistant.egg-info/dependency_links.txt +0 -0
  31. {sa_assistant-0.2.0 → sa_assistant-0.2.1}/src/sa_assistant.egg-info/entry_points.txt +0 -0
  32. {sa_assistant-0.2.0 → sa_assistant-0.2.1}/src/sa_assistant.egg-info/requires.txt +0 -0
  33. {sa_assistant-0.2.0 → sa_assistant-0.2.1}/src/sa_assistant.egg-info/top_level.txt +0 -0
  34. {sa_assistant-0.2.0 → sa_assistant-0.2.1}/src/tui/__init__.py +0 -0
  35. {sa_assistant-0.2.0 → sa_assistant-0.2.1}/test/test_main.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sa-assistant
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: SA Assistant - AWS Solutions Architect Professional Agent with Multi-Agent Architecture
5
5
  Author-email: onesuit <wltks2155@gmail.com>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sa-assistant"
7
- version = "0.2.0"
7
+ version = "0.2.1"
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sa-assistant
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: SA Assistant - AWS Solutions Architect Professional Agent with Multi-Agent Architecture
5
5
  Author-email: onesuit <wltks2155@gmail.com>
6
6
  License: MIT
@@ -28,4 +28,5 @@ src/sa_assistant.egg-info/requires.txt
28
28
  src/sa_assistant.egg-info/top_level.txt
29
29
  src/tui/__init__.py
30
30
  src/tui/app.py
31
+ src/tui/styles.tcss
31
32
  test/test_main.py
@@ -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.1"
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::-webkit-scrollbar {
156
+ width: 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