sa-assistant 0.1.1__py3-none-any.whl → 0.2.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sa-assistant
3
- Version: 0.1.1
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
@@ -30,6 +30,7 @@ Requires-Dist: pyyaml>=6.0
30
30
  Requires-Dist: rich>=13.0.0
31
31
  Requires-Dist: strands-agents>=1.13.0
32
32
  Requires-Dist: strands-agents-tools>=0.2.16
33
+ Requires-Dist: textual>=0.85.0
33
34
  Provides-Extra: dev
34
35
  Requires-Dist: pytest>=7.0.0; extra == "dev"
35
36
  Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
@@ -18,8 +18,11 @@ model/__init__.py,sha256=x52FkeYWNIeyl4vmDhhNXiH2Son6Rx2avdYYeq9mHj4,316
18
18
  model/load.py,sha256=HTD4RCqoyTwASGBC3AaMEhFVrXwu3bN_URcxLbNSzVk,3036
19
19
  prompts/__init__.py,sha256=gwn1ncE7d-zuoe1pRI5uwElTQQvO8cJgwZqQEJ6oPYs,1518
20
20
  prompts/system_prompts.py,sha256=DynDO1Kjwv_fZUm1nGvSTFGH-MEmS5J9w5U01Csn368,16241
21
- sa_assistant-0.1.1.dist-info/METADATA,sha256=NaQ-ZQUnHNxHcW03msWtbiNuWHFEnkIJEVrx37DKflI,3034
22
- sa_assistant-0.1.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
23
- sa_assistant-0.1.1.dist-info/entry_points.txt,sha256=O6pc3nG9bmogRr3Cu4vOvgvU23JLAbcapPTyBPeIwMA,46
24
- sa_assistant-0.1.1.dist-info/top_level.txt,sha256=SL5U7b8oo3VLFHZGxYgNEWRxLrvEFnP8HFUGvs_N3w8,48
25
- sa_assistant-0.1.1.dist-info/RECORD,,
21
+ tui/__init__.py,sha256=AhmFoE3tpm8UY9zYeHnlFRgYh7EDREZ7-W8Ke1RozkI,147
22
+ tui/app.py,sha256=dyQ4r5tRK1mUcJah3YLHl-onVcoQW4F1M5G3DrSbd38,12433
23
+ tui/styles.tcss,sha256=DVzqtP_rkkE3_y1U6saoGHGWpmYoyG6GIo8229q-_6A,2798
24
+ sa_assistant-0.2.1.dist-info/METADATA,sha256=1ZzcfegeekWMXb70UV-Yq9_Rg4UNNpullQ5HmC1GIcw,3065
25
+ sa_assistant-0.2.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
26
+ sa_assistant-0.2.1.dist-info/entry_points.txt,sha256=TeU6i2gSZtrDvfygfvLvplx5Cb3-Z5eqdMwdzG9_eM0,78
27
+ sa_assistant-0.2.1.dist-info/top_level.txt,sha256=g9-TL1R8z-SyVwHtV25DDxAxOiWCgU1ql21VYgzvERc,52
28
+ sa_assistant-0.2.1.dist-info/RECORD,,
@@ -1,2 +1,3 @@
1
1
  [console_scripts]
2
2
  sa-assistant = main:run_cli
3
+ sa-assistant-tui = main:run_tui
@@ -4,3 +4,4 @@ cli
4
4
  mcp_client
5
5
  model
6
6
  prompts
7
+ tui
tui/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """SA Assistant TUI - Textual-based Terminal User Interface."""
2
+
3
+ from .app import SAAssistantApp, run_app
4
+
5
+ __all__ = ["SAAssistantApp", "run_app"]
tui/app.py ADDED
@@ -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()
tui/styles.tcss ADDED
@@ -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
+ }