feat(tui): enhance splash screen and agent status display

- Reduced animation timer for splash screen to improve responsiveness.
- Added URL display to the splash screen.
- Improved start line animation with dynamic character styling.
- Updated agent status display to show "Initializing Agent" when no real activity is detected.
- Enhanced waiting and animated verb text with dynamic styling.
- Implemented sidebar visibility toggle based on window size.
- Updated live stats to include model information from agent configuration.
- Refined TUI styles for better visual consistency.
This commit is contained in:
0xallam
2026-01-04 19:49:55 -08:00
committed by Ahmed Allam
parent e6ddcb1801
commit 7bcdedfb18
3 changed files with 214 additions and 312 deletions

View File

@@ -1,13 +1,14 @@
Screen {
background: #1a1a1a;
background: #000000;
color: #d4d4d4;
}
#splash_screen {
height: 100%;
width: 100%;
background: #1a1a1a;
background: #000000;
color: #22c55e;
align: center middle;
content-align: center middle;
text-align: center;
}
@@ -17,6 +18,7 @@ Screen {
height: auto;
background: transparent;
text-align: center;
content-align: center middle;
padding: 2;
}
@@ -24,7 +26,7 @@ Screen {
height: 100%;
padding: 0;
margin: 0;
background: #1a1a1a;
background: #000000;
}
#content_container {
@@ -39,10 +41,14 @@ Screen {
margin-left: 1;
}
#sidebar.-hidden {
display: none;
}
#agents_tree {
height: 1fr;
background: transparent;
border: round #262626;
border: round #1a1a1a;
border-title-color: #a8a29e;
border-title-style: bold;
padding: 1;
@@ -62,16 +68,20 @@ Screen {
background: transparent;
}
#chat_area_container.-full-width {
width: 100%;
}
#chat_history {
height: 1fr;
background: transparent;
border: round #1a1a1a;
border: round #0a0a0a;
padding: 0;
margin-bottom: 0;
margin-right: 0;
scrollbar-background: #0f0f0f;
scrollbar-color: #262626;
scrollbar-corner-color: #0f0f0f;
scrollbar-background: #000000;
scrollbar-color: #1a1a1a;
scrollbar-corner-color: #000000;
scrollbar-size: 1 1;
}
@@ -113,7 +123,7 @@ Screen {
#chat_input_container {
height: 3;
background: transparent;
border: round #525252;
border: round #333333;
margin-right: 0;
padding: 0;
layout: horizontal;
@@ -210,23 +220,20 @@ Screen {
margin-top: 0 !important;
margin-bottom: 0 !important;
padding: 0 1;
background: #0a0a0a;
border: round #1a1a1a;
border-left: thick #f59e0b;
background: transparent;
border: none;
width: 100%;
}
.tool-call.status-completed {
border-left: thick #22c55e;
background: #0d1f12;
background: transparent;
margin: 0 !important;
margin-top: 0 !important;
margin-bottom: 0 !important;
}
.tool-call.status-running {
border-left: thick #f59e0b;
background: #1f1611;
background: transparent;
margin: 0 !important;
margin-top: 0 !important;
margin-bottom: 0 !important;
@@ -234,8 +241,7 @@ Screen {
.tool-call.status-failed,
.tool-call.status-error {
border-left: thick #ef4444;
background: #1f0d0d;
background: transparent;
margin: 0 !important;
margin-top: 0 !important;
margin-bottom: 0 !important;
@@ -257,202 +263,39 @@ Screen {
margin: 0 !important;
margin-top: 0 !important;
margin-bottom: 0 !important;
}
.browser-tool {
border-left: thick #06b6d4;
}
.browser-tool.status-completed {
border-left: thick #06b6d4;
background: transparent;
margin: 0 !important;
margin-top: 0 !important;
margin-bottom: 0 !important;
}
.browser-tool.status-running {
border-left: thick #0891b2;
background: transparent;
margin: 0 !important;
margin-top: 0 !important;
margin-bottom: 0 !important;
}
.terminal-tool {
border-left: thick #22c55e;
}
.terminal-tool.status-completed {
border-left: thick #22c55e;
background: transparent;
}
.terminal-tool.status-running {
border-left: thick #16a34a;
background: transparent;
}
.python-tool {
border-left: thick #3b82f6;
}
.python-tool.status-completed {
border-left: thick #3b82f6;
background: transparent;
}
.python-tool.status-running {
border-left: thick #2563eb;
background: transparent;
}
.agents-graph-tool {
border-left: thick #fbbf24;
}
.agents-graph-tool.status-completed {
border-left: thick #fbbf24;
background: transparent;
}
.agents-graph-tool.status-running {
border-left: thick #f59e0b;
background: transparent;
}
.file-edit-tool {
border-left: thick #10b981;
}
.file-edit-tool.status-completed {
border-left: thick #10b981;
background: transparent;
}
.file-edit-tool.status-running {
border-left: thick #059669;
background: transparent;
}
.proxy-tool {
border-left: thick #06b6d4;
}
.proxy-tool.status-completed {
border-left: thick #06b6d4;
background: transparent;
}
.proxy-tool.status-running {
border-left: thick #0891b2;
background: transparent;
}
.notes-tool {
border-left: thick #fbbf24;
}
.notes-tool.status-completed {
border-left: thick #fbbf24;
background: transparent;
}
.notes-tool.status-running {
border-left: thick #f59e0b;
background: transparent;
}
.thinking-tool {
border-left: thick #a855f7;
}
.thinking-tool.status-completed {
border-left: thick #a855f7;
background: transparent;
}
.thinking-tool.status-running {
border-left: thick #9333ea;
background: transparent;
}
.web-search-tool {
border-left: thick #22c55e;
}
.web-search-tool.status-completed {
border-left: thick #22c55e;
background: transparent;
}
.web-search-tool.status-running {
border-left: thick #16a34a;
background: transparent;
}
.finish-tool {
border-left: thick #dc2626;
}
.finish-tool.status-completed {
border-left: thick #dc2626;
background: transparent;
}
.finish-tool.status-running {
border-left: thick #b91c1c;
background: transparent;
}
.reporting-tool {
border-left: thick #ea580c;
}
.reporting-tool.status-completed {
border-left: thick #ea580c;
background: transparent;
}
.reporting-tool.status-running {
border-left: thick #c2410c;
background: transparent;
}
.scan-info-tool {
border-left: thick #22c55e;
background: transparent;
margin: 0 !important;
margin-top: 0 !important;
margin-bottom: 0 !important;
}
.scan-info-tool.status-completed {
border-left: thick #22c55e;
background: transparent;
}
.scan-info-tool.status-running {
border-left: thick #16a34a;
background: transparent;
}
.subagent-info-tool {
border-left: thick #22c55e;
background: transparent;
margin: 0 !important;
margin-top: 0 !important;
margin-bottom: 0 !important;
}
.subagent-info-tool.status-completed {
border-left: thick #22c55e;
background: transparent;
}
.browser-tool.status-completed,
.browser-tool.status-running,
.terminal-tool.status-completed,
.terminal-tool.status-running,
.python-tool.status-completed,
.python-tool.status-running,
.agents-graph-tool.status-completed,
.agents-graph-tool.status-running,
.file-edit-tool.status-completed,
.file-edit-tool.status-running,
.proxy-tool.status-completed,
.proxy-tool.status-running,
.notes-tool.status-completed,
.notes-tool.status-running,
.thinking-tool.status-completed,
.thinking-tool.status-running,
.web-search-tool.status-completed,
.web-search-tool.status-running,
.finish-tool.status-completed,
.finish-tool.status-running,
.reporting-tool.status-completed,
.reporting-tool.status-running,
.scan-info-tool.status-completed,
.scan-info-tool.status-running,
.subagent-info-tool.status-completed,
.subagent-info-tool.status-running {
border-left: thick #16a34a;
background: transparent;
margin: 0 !important;
margin-top: 0 !important;
margin-bottom: 0 !important;
}
Tree {
@@ -470,7 +313,7 @@ Tree > .tree--label {
background: transparent;
padding: 0 1;
margin-bottom: 1;
border-bottom: solid #262626;
border-bottom: solid #1a1a1a;
text-align: center;
}
@@ -510,7 +353,7 @@ Tree > .tree--label {
}
Tree:focus {
border: round #262626;
border: round #1a1a1a;
}
Tree:focus > .tree--label {
@@ -554,7 +397,7 @@ StopAgentScreen {
width: 30;
height: auto;
border: round #a3a3a3;
background: #1a1a1a 98%;
background: #000000 98%;
}
#stop_agent_title {
@@ -616,8 +459,8 @@ QuitScreen {
padding: 1;
width: 24;
height: auto;
border: round #525252;
background: #1a1a1a 98%;
border: round #333333;
background: #000000 98%;
}
#quit_title {
@@ -680,7 +523,7 @@ HelpScreen {
width: 40;
height: auto;
border: round #22c55e;
background: #1a1a1a 98%;
background: #000000 98%;
}
#help_title {

View File

@@ -121,7 +121,7 @@ class SplashScreen(Static): # type: ignore[misc]
yield panel_static
def on_mount(self) -> None:
self._animation_timer = self.set_interval(0.45, self._animate_start_line)
self._animation_timer = self.set_interval(0.05, self._animate_start_line)
def on_unmount(self) -> None:
if self._animation_timer is not None:
@@ -146,10 +146,15 @@ class SplashScreen(Static): # type: ignore[misc]
Align.center(self._build_tagline_text()),
Align.center(Text(" ")),
Align.center(start_line.copy()),
Align.center(Text(" ")),
Align.center(self._build_url_text()),
)
return Panel.fit(content, border_style=self.PRIMARY_GREEN, padding=(1, 6))
def _build_url_text(self) -> Text:
return Text("strix.ai", style=Style(color=self.PRIMARY_GREEN, bold=True))
def _build_welcome_text(self) -> Text:
text = Text("Welcome to ", style=Style(color="white", bold=True))
text.append("Strix", style=Style(color=self.PRIMARY_GREEN, bold=True))
@@ -163,13 +168,25 @@ class SplashScreen(Static): # type: ignore[misc]
return Text("Open-source AI hackers for your apps", style=Style(color="white", dim=True))
def _build_start_line_text(self, phase: int) -> Text:
emphasize = phase % 2 == 1
base_style = Style(color="white", dim=not emphasize, bold=emphasize)
strix_style = Style(color=self.PRIMARY_GREEN, bold=bool(emphasize))
full_text = "Starting Strix Agent"
text_len = len(full_text)
text = Text("Starting ", style=base_style)
text.append("Strix", style=strix_style)
text.append(" Cybersecurity Agent", style=base_style)
shine_pos = phase % (text_len + 8)
text = Text()
for i, char in enumerate(full_text):
dist = abs(i - shine_pos)
if dist <= 1:
style = Style(color="bright_white", bold=True)
elif dist <= 3:
style = Style(color="white", bold=True)
elif dist <= 5:
style = Style(color="#a3a3a3")
else:
style = Style(color="#525252")
text.append(char, style=style)
return text
@@ -286,6 +303,8 @@ class QuitScreen(ModalScreen): # type: ignore[misc]
class StrixTUIApp(App): # type: ignore[misc]
CSS_PATH = "assets/tui_styles.tcss"
SIDEBAR_MIN_WIDTH = 100
selected_agent_id: reactive[str | None] = reactive(default=None)
show_splash: reactive[bool] = reactive(default=True)
@@ -319,15 +338,20 @@ class StrixTUIApp(App): # type: ignore[misc]
"Generating",
"Scanning",
"Analyzing",
"Probing",
"Hacking",
"Testing",
"Exploiting",
"Investigating",
"Pwning",
"Loading",
"Running",
"Working",
"Strixing",
"Thinking",
"Reasoning",
]
self._agent_verbs: dict[str, str] = {} # agent_id -> current_verb
self._agent_verb_timers: dict[str, Any] = {} # agent_id -> timer
self._agent_dot_states: dict[str, int] = {} # agent_id -> dot_count (0-3)
self._agent_dot_states: dict[str, float] = {} # agent_id -> shine position
self._dot_animation_timer: Any | None = None
self._setup_cleanup_handlers()
@@ -620,6 +644,45 @@ class StrixTUIApp(App): # type: ignore[misc]
return "\n\n".join(content_lines)
def _get_status_display_content(
self, agent_id: str, agent_data: dict[str, Any]
) -> tuple[str | Text, str, bool]:
status = agent_data.get("status", "running")
simple_statuses = {
"stopping": ("Agent stopping...", "", False),
"stopped": ("Agent stopped", "", False),
"completed": ("Agent completed", "", False),
}
if status in simple_statuses:
return simple_statuses[status]
if status == "llm_failed":
error_msg = agent_data.get("error_message", "")
display_msg = (
f"[red]{escape_markup(error_msg)}[/red]"
if error_msg
else "[red]LLM request failed[/red]"
)
self._stop_dot_animation()
return (display_msg, "[dim]Send message to retry[/dim]", False)
if status == "waiting":
animated_text = self._get_animated_waiting_text(agent_id)
return (animated_text, "[dim]Send message to resume[/dim]", True)
if status == "running":
verb = (
self._get_agent_verb(agent_id)
if self._agent_has_real_activity(agent_id)
else "Initializing Agent"
)
animated_text = self._get_animated_verb_text(agent_id, verb)
return (animated_text, "[dim]ESC to stop | CTRL-C to quit and save[/dim]", True)
return ("", "", False)
def _update_agent_status_display(self) -> None:
try:
status_display = self.query_one("#agent_status_display", Horizontal)
@@ -638,52 +701,20 @@ class StrixTUIApp(App): # type: ignore[misc]
try:
agent_data = self.tracer.agents[self.selected_agent_id]
status = agent_data.get("status", "running")
content, keymap, should_animate = self._get_status_display_content(
self.selected_agent_id, agent_data
)
if status == "stopping":
self._safe_widget_operation(status_text.update, "Agent stopping...")
self._safe_widget_operation(keymap_indicator.update, "")
self._safe_widget_operation(status_display.remove_class, "hidden")
elif status == "stopped":
self._safe_widget_operation(status_text.update, "Agent stopped")
self._safe_widget_operation(keymap_indicator.update, "")
self._safe_widget_operation(status_display.remove_class, "hidden")
elif status == "completed":
self._safe_widget_operation(status_text.update, "Agent completed")
self._safe_widget_operation(keymap_indicator.update, "")
self._safe_widget_operation(status_display.remove_class, "hidden")
elif status == "llm_failed":
error_msg = agent_data.get("error_message", "")
display_msg = (
f"[red]{escape_markup(error_msg)}[/red]"
if error_msg
else "[red]LLM request failed[/red]"
)
self._safe_widget_operation(status_text.update, display_msg)
self._safe_widget_operation(
keymap_indicator.update, "[dim]Send message to retry[/dim]"
)
self._safe_widget_operation(status_display.remove_class, "hidden")
self._stop_dot_animation()
elif status == "waiting":
animated_text = self._get_animated_waiting_text(self.selected_agent_id)
self._safe_widget_operation(status_text.update, animated_text)
self._safe_widget_operation(
keymap_indicator.update, "[dim]Send message to resume[/dim]"
)
self._safe_widget_operation(status_display.remove_class, "hidden")
self._start_dot_animation()
elif status == "running":
current_verb = self._get_agent_verb(self.selected_agent_id)
animated_text = self._get_animated_verb_text(self.selected_agent_id, current_verb)
self._safe_widget_operation(status_text.update, animated_text)
self._safe_widget_operation(
keymap_indicator.update, "[dim]ESC to stop | CTRL-C to quit and save[/dim]"
)
self._safe_widget_operation(status_display.remove_class, "hidden")
self._start_dot_animation()
else:
if not content:
self._safe_widget_operation(status_display.add_class, "hidden")
return
self._safe_widget_operation(status_text.update, content)
self._safe_widget_operation(keymap_indicator.update, keymap)
self._safe_widget_operation(status_display.remove_class, "hidden")
if should_animate:
self._start_dot_animation()
except (KeyError, Exception):
self._safe_widget_operation(status_display.add_class, "hidden")
@@ -707,8 +738,6 @@ class StrixTUIApp(App): # type: ignore[misc]
stats_panel = Panel(
stats_content,
title="📊 Live Stats",
title_align="left",
border_style="#22c55e",
padding=(0, 1),
)
@@ -743,26 +772,43 @@ class StrixTUIApp(App): # type: ignore[misc]
if self.selected_agent_id == agent_id:
self._update_agent_status_display()
def _get_animated_verb_text(self, agent_id: str, verb: str) -> str:
def _get_shine_style(self, dist: float) -> Style:
if dist <= 0.5:
return Style(color="bright_white", bold=True)
if dist <= 1.5:
return Style(color="white", bold=True)
if dist <= 2.5:
return Style(color="#a3a3a3")
return Style(color="#525252")
def _get_animated_verb_text(self, agent_id: str, verb: str) -> Text:
if agent_id not in self._agent_dot_states:
self._agent_dot_states[agent_id] = 0
self._agent_dot_states[agent_id] = 0.0
dot_count = self._agent_dot_states[agent_id]
dots = "." * dot_count
return f"{verb}{dots}"
shine_pos = self._agent_dot_states[agent_id]
text = Text()
for i, char in enumerate(verb):
dist = abs(i - shine_pos)
text.append(char, style=self._get_shine_style(dist))
def _get_animated_waiting_text(self, agent_id: str) -> str:
return text
def _get_animated_waiting_text(self, agent_id: str) -> Text:
if agent_id not in self._agent_dot_states:
self._agent_dot_states[agent_id] = 0
self._agent_dot_states[agent_id] = 0.0
dot_count = self._agent_dot_states[agent_id]
dots = "." * dot_count
shine_pos = self._agent_dot_states[agent_id]
word = "Waiting"
text = Text()
for i, char in enumerate(word):
dist = abs(i - shine_pos)
text.append(char, style=self._get_shine_style(dist))
return f"Waiting{dots}"
return text
def _start_dot_animation(self) -> None:
if self._dot_animation_timer is None:
self._dot_animation_timer = self.set_interval(0.6, self._animate_dots)
self._dot_animation_timer = self.set_interval(0.008, self._animate_dots)
def _stop_dot_animation(self) -> None:
if self._dot_animation_timer is not None:
@@ -776,8 +822,15 @@ class StrixTUIApp(App): # type: ignore[misc]
status = agent_data.get("status", "running")
if status in ["running", "waiting"]:
has_active_agents = True
current_dots = self._agent_dot_states.get(agent_id, 0)
self._agent_dot_states[agent_id] = (current_dots + 1) % 4
if status == "waiting":
verb = "Waiting"
elif self._agent_has_real_activity(agent_id):
verb = self._get_agent_verb(agent_id)
else:
verb = "Initializing Agent"
text_len = len(verb)
current_shine = self._agent_dot_states.get(agent_id, 0.0)
self._agent_dot_states[agent_id] = (current_shine + 0.12) % (text_len + 3)
if (
has_active_agents
@@ -796,6 +849,16 @@ class StrixTUIApp(App): # type: ignore[misc]
) not in ["running", "waiting"]:
del self._agent_dot_states[agent_id]
def _agent_has_real_activity(self, agent_id: str) -> bool:
initial_tools = {"scan_start_info", "subagent_start_info"}
for _exec_id, tool_data in list(self.tracer.tool_executions.items()):
if tool_data.get("agent_id") == agent_id:
tool_name = tool_data.get("tool_name", "")
if tool_name not in initial_tools:
return True
return False
def _gather_agent_events(self, agent_id: str) -> list[dict[str, Any]]:
chat_events = [
{
@@ -1030,25 +1093,6 @@ class StrixTUIApp(App): # type: ignore[misc]
status = tool_data.get("status", "unknown")
result = tool_data.get("result")
tool_colors = {
"terminal_execute": "#22c55e",
"browser_action": "#06b6d4",
"python_action": "#3b82f6",
"agents_graph_action": "#fbbf24",
"file_edit_action": "#10b981",
"proxy_action": "#06b6d4",
"notes_action": "#fbbf24",
"thinking_action": "#a855f7",
"web_search_action": "#22c55e",
"finish_action": "#dc2626",
"reporting_action": "#ea580c",
"scan_start_info": "#22c55e",
"subagent_start_info": "#22c55e",
"llm_error_details": "#dc2626",
}
color = tool_colors.get(tool_name, "#737373")
from strix.interface.tool_components.registry import get_tool_renderer
renderer = get_tool_renderer(tool_name)
@@ -1090,9 +1134,7 @@ class StrixTUIApp(App): # type: ignore[misc]
content = "\n".join(lines)
lines = content.split("\n")
bordered_lines = [f"[{color}]▍[/{color}] {line}" for line in lines]
return "\n".join(bordered_lines)
return content
@on(Tree.NodeHighlighted) # type: ignore[misc]
def handle_tree_highlight(self, event: Tree.NodeHighlighted) -> None:
@@ -1293,6 +1335,23 @@ class StrixTUIApp(App): # type: ignore[misc]
plain_text = re.sub(r"\[.*?\]", "", content)
widget.update(plain_text)
def on_resize(self, event: events.Resize) -> None:
if self.show_splash or not self.is_mounted:
return
try:
sidebar = self.query_one("#sidebar", Vertical)
chat_area = self.query_one("#chat_area_container", Vertical)
except (ValueError, Exception):
return
if event.size.width < self.SIDEBAR_MIN_WIDTH:
sidebar.add_class("-hidden")
chat_area.add_class("-full-width")
else:
sidebar.remove_class("-hidden")
chat_area.remove_class("-full-width")
async def run_tui(args: argparse.Namespace) -> None:
"""Run strix in interactive TUI mode with textual."""

View File

@@ -134,6 +134,12 @@ def build_live_stats_text(tracer: Any, agent_config: dict[str, Any] | None = Non
if not tracer:
return stats_text
if agent_config:
llm_config = agent_config["llm_config"]
model = getattr(llm_config, "model_name", "Unknown")
stats_text.append(f"🧠 Model: {model}")
stats_text.append("\n")
vuln_count = len(tracer.vulnerability_reports)
tool_count = tracer.get_real_tool_count()
agent_count = len(tracer.agents)
@@ -165,12 +171,6 @@ def build_live_stats_text(tracer: Any, agent_config: dict[str, Any] | None = Non
stats_text.append("\n")
if agent_config:
llm_config = agent_config["llm_config"]
model = getattr(llm_config, "model_name", "Unknown")
stats_text.append(f"🧠 Model: {model}")
stats_text.append("\n")
stats_text.append("🤖 Agents: ", style="bold white")
stats_text.append(str(agent_count), style="dim white")
stats_text.append("", style="dim white")