sa-assistant 0.2.0__py3-none-any.whl → 0.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sa-assistant
3
- Version: 0.2.0
3
+ Version: 0.2.2
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
@@ -19,9 +19,10 @@ 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
21
  tui/__init__.py,sha256=AhmFoE3tpm8UY9zYeHnlFRgYh7EDREZ7-W8Ke1RozkI,147
22
- tui/app.py,sha256=cgKH_A5P9p5bmjtaAs5Z4KCzHJIzTZ6VdFkuxoIjDG4,8669
23
- sa_assistant-0.2.0.dist-info/METADATA,sha256=LoQrylOPi2AqrC_95Xc-tqktb9UY9NdXjsahWnvY7iY,3065
24
- sa_assistant-0.2.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
25
- sa_assistant-0.2.0.dist-info/entry_points.txt,sha256=TeU6i2gSZtrDvfygfvLvplx5Cb3-Z5eqdMwdzG9_eM0,78
26
- sa_assistant-0.2.0.dist-info/top_level.txt,sha256=g9-TL1R8z-SyVwHtV25DDxAxOiWCgU1ql21VYgzvERc,52
27
- sa_assistant-0.2.0.dist-info/RECORD,,
22
+ tui/app.py,sha256=m1FlmMsRRGAxy3I-iBICMsMnyYSksNEmXTxrhhKQpL8,12433
23
+ tui/styles.tcss,sha256=ZiaaFYteHG8QqQHlNoA3l_CX9Lj--Etmv3o1kPxFZXo,2790
24
+ sa_assistant-0.2.2.dist-info/METADATA,sha256=aql71QFB825xZ2yTFzyBNZc2_or_W_yoqs3-XivR03U,3065
25
+ sa_assistant-0.2.2.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
26
+ sa_assistant-0.2.2.dist-info/entry_points.txt,sha256=TeU6i2gSZtrDvfygfvLvplx5Cb3-Z5eqdMwdzG9_eM0,78
27
+ sa_assistant-0.2.2.dist-info/top_level.txt,sha256=g9-TL1R8z-SyVwHtV25DDxAxOiWCgU1ql21VYgzvERc,52
28
+ sa_assistant-0.2.2.dist-info/RECORD,,
tui/app.py CHANGED
@@ -1,4 +1,4 @@
1
- """SA Assistant TUI Application - Main Textual App class."""
1
+ """SA Assistant TUI Application - OpenCode-style Terminal User Interface."""
2
2
 
3
3
  from datetime import datetime
4
4
  from pathlib import Path
@@ -7,8 +7,8 @@ from typing import Optional
7
7
  from textual import work
8
8
  from textual.app import App, ComposeResult
9
9
  from textual.binding import Binding
10
- from textual.containers import ScrollableContainer, Vertical
11
- from textual.widgets import Footer, Header, Input, Static, RichLog, Collapsible
10
+ from textual.containers import ScrollableContainer, Vertical, Horizontal
11
+ from textual.widgets import Footer, Input, Static, RichLog, Collapsible
12
12
  from textual.worker import Worker
13
13
  from rich.text import Text
14
14
  from rich.markdown import Markdown
@@ -26,30 +26,34 @@ from agentskills import discover_skills, generate_skills_prompt, create_skill_to
26
26
 
27
27
  CSS_PATH = Path(__file__).parent / "styles.tcss"
28
28
  SKILLS_DIR = Path(__file__).parent.parent / "skills"
29
+ VERSION = "0.2.2"
29
30
 
30
31
 
31
32
  class SessionHeader(Static):
32
- """Top header showing session info, tokens, cost."""
33
+ """Top header showing session info in OpenCode style.
33
34
 
34
- def __init__(self, session_id: str = None):
35
+ Format: # New session - {timestamp} {tokens} {percentage} (${cost}) v{version}
36
+ """
37
+
38
+ def __init__(self):
35
39
  super().__init__()
36
40
  self.session_start = datetime.now()
37
- self.session_id = session_id or self.session_start.strftime("%Y-%m-%dT%H:%M:%S")
41
+ self.session_id = self.session_start.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
38
42
  self.token_count = 0
43
+ self.max_tokens = 200000 # Claude context window
39
44
  self.cost = 0.0
40
45
 
41
- def compose(self) -> ComposeResult:
42
- yield Static(id="session-info")
43
-
44
46
  def on_mount(self) -> None:
45
47
  self._update_display()
46
48
 
47
49
  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]"
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}"
52
55
  )
56
+ self.update(header_text)
53
57
 
54
58
  def update_stats(self, tokens: int = 0, cost: float = 0.0) -> None:
55
59
  self.token_count += tokens
@@ -58,7 +62,10 @@ class SessionHeader(Static):
58
62
 
59
63
 
60
64
  class AgentIndicator(Static):
61
- """Bottom indicator showing current agent and model."""
65
+ """Bottom indicator showing current agent and model.
66
+
67
+ Format: ■ SA Assistant · Claude Sonnet
68
+ """
62
69
 
63
70
  def __init__(self, agent_name: str = "SA Assistant", model_name: str = "Claude Sonnet"):
64
71
  super().__init__()
@@ -69,7 +76,7 @@ class AgentIndicator(Static):
69
76
  self._update_display()
70
77
 
71
78
  def _update_display(self) -> None:
72
- self.update(f"[cyan]■[/cyan] {self.agent_name} · [dim]{self.model_name}[/dim]")
79
+ self.update(f"[#FF9900]■[/#FF9900] {self.agent_name} · [dim]{self.model_name}[/dim]")
73
80
 
74
81
  def set_agent(self, name: str, model: str = None) -> None:
75
82
  self.agent_name = name
@@ -78,54 +85,133 @@ class AgentIndicator(Static):
78
85
  self._update_display()
79
86
 
80
87
 
81
- class MessageSection(Collapsible):
82
- """Collapsible section for a single message/action."""
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.
83
106
 
84
- def __init__(self, title: str, content: str = "", collapsed: bool = False):
85
- super().__init__(title=title, collapsed=collapsed)
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
86
120
  self._content = content
121
+ self.panel_type = panel_type
122
+ self.collapsible = collapsible
123
+ self.collapsed = collapsed
87
124
  self._log: Optional[RichLog] = None
125
+ self.add_class(f"panel-{panel_type}")
88
126
 
89
127
  def compose(self) -> ComposeResult:
90
- self._log = RichLog(highlight=True, markup=True, wrap=True)
91
- yield self._log
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
92
145
 
93
146
  def on_mount(self) -> None:
94
147
  if self._content and self._log:
95
148
  self._log.write(self._content)
96
149
 
97
150
  def append(self, text: str) -> None:
151
+ """Append text to the content area."""
98
152
  if self._log:
99
153
  self._log.write(text)
100
154
 
101
155
  def update_content(self, text: str) -> None:
156
+ """Replace all content."""
102
157
  if self._log:
103
158
  self._log.clear()
104
159
  self._log.write(text)
105
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
+
106
180
 
107
181
  class ChatContainer(ScrollableContainer):
108
182
  """Main scrollable container for chat messages."""
109
183
 
110
184
  def __init__(self):
111
185
  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:
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)."""
129
215
  emoji_map = {
130
216
  "consult_guru": "🧙",
131
217
  "consult_specialist": "🔬",
@@ -133,37 +219,54 @@ class ChatContainer(ScrollableContainer):
133
219
  "file_write": "💾",
134
220
  "shell": "🖥️",
135
221
  "skill": "📚",
222
+ "image_reader": "🖼️",
136
223
  }
137
224
  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
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
142
246
 
143
247
 
144
248
  class SAAssistantApp(App):
145
- """SA Assistant Textual TUI Application."""
249
+ """SA Assistant Textual TUI Application - OpenCode Style."""
146
250
 
147
251
  CSS_PATH = "styles.tcss"
148
-
149
252
  TITLE = "SA Assistant"
150
- SUB_TITLE = "AWS Solutions Architect Agent"
151
253
 
152
254
  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),
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),
157
259
  ]
158
260
 
159
261
  def __init__(self):
160
262
  super().__init__()
161
263
  self._agent: Optional[Agent] = None
162
- self._current_section: Optional[MessageSection] = None
264
+ self._current_panel: Optional[MessagePanel] = None
163
265
  self._response_buffer = ""
164
266
  self._init_agent()
165
267
 
166
268
  def _init_agent(self) -> None:
269
+ """Initialize the Strands agent."""
167
270
  discovered_skills = discover_skills(SKILLS_DIR)
168
271
  skills_prompt = generate_skills_prompt(discovered_skills)
169
272
  full_prompt = ORCHESTRATOR_PROMPT + skills_prompt
@@ -194,73 +297,87 @@ class SAAssistantApp(App):
194
297
  )
195
298
 
196
299
  def _streaming_callback(self, **kwargs) -> None:
300
+ """Handle streaming responses from the agent."""
197
301
  if "data" in kwargs:
198
302
  self._response_buffer += kwargs["data"]
199
- if self._current_section:
200
- self.call_from_thread(self._current_section.update_content, self._response_buffer)
303
+ if self._current_panel:
304
+ self.call_from_thread(self._current_panel.update_content, self._response_buffer)
201
305
 
202
306
  if "current_tool_use" in kwargs:
203
307
  tool_use = kwargs["current_tool_use"]
204
308
  if isinstance(tool_use, dict):
205
309
  tool_name = tool_use.get("name", "tool")
206
- self.call_from_thread(self._add_tool_section, tool_name)
310
+ self.call_from_thread(self._add_tool_panel, tool_name)
207
311
 
208
- def _add_tool_section(self, tool_name: str) -> None:
312
+ def _add_tool_panel(self, tool_name: str) -> None:
313
+ """Add a tool execution panel to the chat."""
209
314
  chat = self.query_one(ChatContainer)
210
- chat.add_tool_section(tool_name, collapsed=True)
315
+ chat.add_tool_panel(tool_name, collapsed=True)
211
316
 
212
317
  def compose(self) -> ComposeResult:
213
- yield Header()
318
+ """Compose the UI layout."""
214
319
  yield SessionHeader()
215
320
  yield ChatContainer()
216
321
  yield AgentIndicator()
217
- yield Input(placeholder="Type your message... (Enter to send)")
218
- yield Footer()
322
+ yield Input(placeholder="Type your message... (Enter to send)", id="input-field")
323
+ yield HotkeyBar()
219
324
 
220
325
  def on_mount(self) -> None:
221
- self.query_one(Input).focus()
326
+ """Focus the input field on mount."""
327
+ self.query_one("#input-field", Input).focus()
222
328
 
223
329
  async def on_input_submitted(self, event: Input.Submitted) -> None:
330
+ """Handle user input submission."""
224
331
  user_input = event.value.strip()
225
332
  if not user_input:
226
333
  return
227
334
 
335
+ # Clear input
228
336
  event.input.value = ""
229
337
 
338
+ # Handle exit commands
230
339
  if user_input.lower() in ("exit", "quit", "종료"):
231
340
  self.exit()
232
341
  return
233
342
 
343
+ # Add user message to chat
234
344
  chat = self.query_one(ChatContainer)
235
345
  chat.add_user_message(user_input)
236
346
 
237
- self._current_section = chat.add_assistant_message()
347
+ # Prepare for assistant response
348
+ self._current_panel = chat.add_assistant_message()
238
349
  self._response_buffer = ""
239
350
 
351
+ # Run agent in background thread
240
352
  self.run_agent(user_input)
241
353
 
242
354
  @work(thread=True)
243
355
  def run_agent(self, prompt: str) -> None:
356
+ """Run the agent in a background thread."""
244
357
  try:
245
358
  self._agent(prompt)
246
359
  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
- )
360
+ if self._current_panel:
361
+ self.call_from_thread(self._current_panel.update_content, f"[red]Error: {e}[/red]")
251
362
 
252
363
  def action_cancel(self) -> None:
364
+ """Cancel running operations."""
253
365
  self.workers.cancel_all()
254
366
 
255
367
  def action_clear(self) -> None:
368
+ """Clear the chat container."""
256
369
  chat = self.query_one(ChatContainer)
257
- chat.remove_children()
370
+ for panel in chat._panels:
371
+ panel.remove()
372
+ chat._panels.clear()
258
373
 
259
374
  def action_toggle_dark(self) -> None:
375
+ """Toggle dark/light theme."""
260
376
  self.dark = not self.dark
261
377
 
262
378
 
263
379
  def run_app() -> None:
380
+ """Entry point for the TUI application."""
264
381
  app = SAAssistantApp()
265
382
  app.run()
266
383
 
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 {
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
+ }