feat: modernize TUI status bar with sweep animation
- Replace braille spinner with ping-pong sweep animation using colored squares - Add smooth gradient fade with 8 color steps from dim to bright green - Modernize keymap styling: keys in white, actions in dim, separated by · - Move "esc stop" to left side next to animation - Change ctrl-c to ctrl-q for quit - Simplify "Initializing Agent" to just "Initializing" - Remove italic styling from status text - Waiting state shows only "Send message to resume" hint - Remove unused action verbs and related dead code Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -213,7 +213,7 @@ VulnerabilityDetailScreen {
|
|||||||
color: #a3a3a3;
|
color: #a3a3a3;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
content-align: left middle;
|
content-align: left middle;
|
||||||
text-style: italic;
|
text-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import argparse
|
|||||||
import asyncio
|
import asyncio
|
||||||
import atexit
|
import atexit
|
||||||
import logging
|
import logging
|
||||||
import random
|
|
||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
@@ -18,7 +17,6 @@ if TYPE_CHECKING:
|
|||||||
from rich.align import Align
|
from rich.align import Align
|
||||||
from rich.console import Group
|
from rich.console import Group
|
||||||
from rich.panel import Panel
|
from rich.panel import Panel
|
||||||
from rich.spinner import SPINNERS
|
|
||||||
from rich.style import Style
|
from rich.style import Style
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
from textual import events, on
|
from textual import events, on
|
||||||
@@ -697,25 +695,18 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|||||||
self._scan_stop_event = threading.Event()
|
self._scan_stop_event = threading.Event()
|
||||||
self._scan_completed = threading.Event()
|
self._scan_completed = threading.Event()
|
||||||
|
|
||||||
self._action_verbs = [
|
self._spinner_frame_index: int = 0 # Current animation frame index
|
||||||
"Generating",
|
self._sweep_num_squares: int = 6 # Number of squares in sweep animation
|
||||||
"Scanning",
|
self._sweep_colors: list[str] = [
|
||||||
"Analyzing",
|
"#000000", # Dimmest (shows dot)
|
||||||
"Hacking",
|
"#031a09",
|
||||||
"Testing",
|
"#052e16",
|
||||||
"Exploiting",
|
"#0d4a2a",
|
||||||
"Pwning",
|
"#15803d",
|
||||||
"Loading",
|
"#22c55e",
|
||||||
"Running",
|
"#4ade80",
|
||||||
"Working",
|
"#86efac", # Brightest
|
||||||
"Strixing",
|
|
||||||
"Thinking",
|
|
||||||
"Reasoning",
|
|
||||||
]
|
]
|
||||||
self._agent_verbs: dict[str, str] = {} # agent_id -> current_verb
|
|
||||||
self._agent_verb_timers: dict[str, Any] = {} # agent_id -> timer
|
|
||||||
self._spinner_frame_index: int = 0 # Current spinner frame index
|
|
||||||
self._spinner_frames: list[str] = list(SPINNERS["dots"]["frames"]) # Braille spinner frames
|
|
||||||
self._dot_animation_timer: Any | None = None
|
self._dot_animation_timer: Any | None = None
|
||||||
|
|
||||||
self._setup_cleanup_handlers()
|
self._setup_cleanup_handlers()
|
||||||
@@ -927,13 +918,6 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|||||||
vuln_indicator = f" ({vuln_count})" if vuln_count > 0 else ""
|
vuln_indicator = f" ({vuln_count})" if vuln_count > 0 else ""
|
||||||
agent_name = f"{status_icon} {agent_name_raw}{vuln_indicator}"
|
agent_name = f"{status_icon} {agent_name_raw}{vuln_indicator}"
|
||||||
|
|
||||||
if status == "running":
|
|
||||||
self._start_agent_verb_timer(agent_id)
|
|
||||||
elif status == "waiting":
|
|
||||||
self._stop_agent_verb_timer(agent_id)
|
|
||||||
else:
|
|
||||||
self._stop_agent_verb_timer(agent_id)
|
|
||||||
|
|
||||||
if agent_node.label != agent_name:
|
if agent_node.label != agent_name:
|
||||||
agent_node.set_label(agent_name)
|
agent_node.set_label(agent_name)
|
||||||
return True
|
return True
|
||||||
@@ -1123,9 +1107,14 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|||||||
) -> tuple[Text | None, Text, bool]:
|
) -> tuple[Text | None, Text, bool]:
|
||||||
status = agent_data.get("status", "running")
|
status = agent_data.get("status", "running")
|
||||||
|
|
||||||
def keymap_text(msg: str) -> Text:
|
def keymap_styled(keys: list[tuple[str, str]]) -> Text:
|
||||||
t = Text()
|
t = Text()
|
||||||
t.append(msg, style="dim")
|
for i, (key, action) in enumerate(keys):
|
||||||
|
if i > 0:
|
||||||
|
t.append(" · ", style="dim")
|
||||||
|
t.append(key, style="white")
|
||||||
|
t.append(" ", style="dim")
|
||||||
|
t.append(action, style="dim")
|
||||||
return t
|
return t
|
||||||
|
|
||||||
simple_statuses: dict[str, tuple[str, str]] = {
|
simple_statuses: dict[str, tuple[str, str]] = {
|
||||||
@@ -1135,10 +1124,10 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|||||||
}
|
}
|
||||||
|
|
||||||
if status in simple_statuses:
|
if status in simple_statuses:
|
||||||
msg, km = simple_statuses[status]
|
msg, _ = simple_statuses[status]
|
||||||
text = Text()
|
text = Text()
|
||||||
text.append(msg)
|
text.append(msg)
|
||||||
return (text, keymap_text(km), False)
|
return (text, Text(), False)
|
||||||
|
|
||||||
if status == "llm_failed":
|
if status == "llm_failed":
|
||||||
error_msg = agent_data.get("error_message", "")
|
error_msg = agent_data.get("error_message", "")
|
||||||
@@ -1148,20 +1137,25 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|||||||
else:
|
else:
|
||||||
text.append("LLM request failed", style="red")
|
text.append("LLM request failed", style="red")
|
||||||
self._stop_dot_animation()
|
self._stop_dot_animation()
|
||||||
return (text, keymap_text("Send message to retry"), False)
|
keymap = Text()
|
||||||
|
keymap.append("Send message to retry", style="dim")
|
||||||
|
return (text, keymap, False)
|
||||||
|
|
||||||
if status == "waiting":
|
if status == "waiting":
|
||||||
animated_text = self._get_animated_waiting_text(agent_id)
|
keymap = Text()
|
||||||
return (animated_text, keymap_text("Send message to resume"), True)
|
keymap.append("Send message to resume", style="dim")
|
||||||
|
return (Text(" "), keymap, False)
|
||||||
|
|
||||||
if status == "running":
|
if status == "running":
|
||||||
verb = (
|
if self._agent_has_real_activity(agent_id):
|
||||||
self._get_agent_verb(agent_id)
|
animated_text = Text()
|
||||||
if self._agent_has_real_activity(agent_id)
|
animated_text.append_text(self._get_sweep_animation(self._sweep_colors))
|
||||||
else "Initializing Agent"
|
animated_text.append("esc", style="white")
|
||||||
)
|
animated_text.append(" ", style="dim")
|
||||||
animated_text = self._get_animated_verb_text(agent_id, verb)
|
animated_text.append("stop", style="dim")
|
||||||
return (animated_text, keymap_text("ESC to stop | CTRL-C to quit and save"), True)
|
return (animated_text, keymap_styled([("ctrl-q", "quit")]), True)
|
||||||
|
animated_text = self._get_animated_verb_text(agent_id, "Initializing")
|
||||||
|
return (animated_text, keymap_styled([("ctrl-q", "quit")]), True)
|
||||||
|
|
||||||
return (None, Text(), False)
|
return (None, Text(), False)
|
||||||
|
|
||||||
@@ -1266,53 +1260,49 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|||||||
return name
|
return name
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _get_agent_verb(self, agent_id: str) -> str:
|
def _get_sweep_animation(self, color_palette: list[str]) -> Text:
|
||||||
if agent_id not in self._agent_verbs:
|
text = Text()
|
||||||
self._agent_verbs[agent_id] = random.choice(self._action_verbs) # nosec B311 # noqa: S311
|
num_squares = self._sweep_num_squares
|
||||||
return self._agent_verbs[agent_id]
|
num_colors = len(color_palette)
|
||||||
|
|
||||||
def _start_agent_verb_timer(self, agent_id: str) -> None:
|
offset = num_colors - 1
|
||||||
if agent_id not in self._agent_verb_timers:
|
max_pos = (num_squares - 1) + offset
|
||||||
self._agent_verb_timers[agent_id] = self.set_interval(
|
total_range = max_pos + offset
|
||||||
30.0, lambda: self._change_agent_action_verb(agent_id)
|
cycle_length = total_range * 2
|
||||||
)
|
frame_in_cycle = self._spinner_frame_index % cycle_length
|
||||||
|
|
||||||
def _stop_agent_verb_timer(self, agent_id: str) -> None:
|
wave_pos = total_range - abs(total_range - frame_in_cycle)
|
||||||
if agent_id in self._agent_verb_timers:
|
sweep_pos = wave_pos - offset
|
||||||
self._agent_verb_timers[agent_id].stop()
|
|
||||||
del self._agent_verb_timers[agent_id]
|
|
||||||
|
|
||||||
def _change_agent_action_verb(self, agent_id: str) -> None:
|
dot_color = "#0a3d1f"
|
||||||
if agent_id not in self._agent_verbs:
|
|
||||||
self._agent_verbs[agent_id] = random.choice(self._action_verbs) # nosec B311 # noqa: S311
|
|
||||||
return
|
|
||||||
|
|
||||||
current_verb = self._agent_verbs[agent_id]
|
for i in range(num_squares):
|
||||||
available_verbs = [verb for verb in self._action_verbs if verb != current_verb]
|
dist = abs(i - sweep_pos)
|
||||||
self._agent_verbs[agent_id] = random.choice(available_verbs) # nosec B311 # noqa: S311
|
color_idx = max(0, num_colors - 1 - dist)
|
||||||
|
|
||||||
if self.selected_agent_id == agent_id:
|
if color_idx == 0:
|
||||||
self._update_agent_status_display()
|
text.append("·", style=Style(color=dot_color))
|
||||||
|
else:
|
||||||
|
color = color_palette[color_idx]
|
||||||
|
text.append("▪", style=Style(color=color))
|
||||||
|
|
||||||
|
text.append(" ")
|
||||||
|
return text
|
||||||
|
|
||||||
def _get_animated_verb_text(self, agent_id: str, verb: str) -> Text: # noqa: ARG002
|
def _get_animated_verb_text(self, agent_id: str, verb: str) -> Text: # noqa: ARG002
|
||||||
text = Text()
|
text = Text()
|
||||||
spinner_char = self._spinner_frames[self._spinner_frame_index % len(self._spinner_frames)]
|
sweep = self._get_sweep_animation(self._sweep_colors)
|
||||||
text.append(spinner_char, style=Style(color="#22c55e"))
|
text.append_text(sweep)
|
||||||
text.append(" ", style=Style(color="white"))
|
parts = verb.split(" ", 1)
|
||||||
text.append(verb, style=Style(color="white"))
|
text.append(parts[0], style="white")
|
||||||
return text
|
if len(parts) > 1:
|
||||||
|
text.append(" ", style="dim")
|
||||||
def _get_animated_waiting_text(self, agent_id: str) -> Text: # noqa: ARG002
|
text.append(parts[1], style="dim")
|
||||||
text = Text()
|
|
||||||
spinner_char = self._spinner_frames[self._spinner_frame_index % len(self._spinner_frames)]
|
|
||||||
text.append(spinner_char, style=Style(color="#fbbf24"))
|
|
||||||
text.append(" ", style=Style(color="white"))
|
|
||||||
text.append("Waiting", style=Style(color="#fbbf24"))
|
|
||||||
return text
|
return text
|
||||||
|
|
||||||
def _start_dot_animation(self) -> None:
|
def _start_dot_animation(self) -> None:
|
||||||
if self._dot_animation_timer is None:
|
if self._dot_animation_timer is None:
|
||||||
self._dot_animation_timer = self.set_interval(0.05, self._animate_dots)
|
self._dot_animation_timer = self.set_interval(0.06, self._animate_dots)
|
||||||
|
|
||||||
def _stop_dot_animation(self) -> None:
|
def _stop_dot_animation(self) -> None:
|
||||||
if self._dot_animation_timer is not None:
|
if self._dot_animation_timer is not None:
|
||||||
@@ -1327,9 +1317,12 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|||||||
status = agent_data.get("status", "running")
|
status = agent_data.get("status", "running")
|
||||||
if status in ["running", "waiting"]:
|
if status in ["running", "waiting"]:
|
||||||
has_active_agents = True
|
has_active_agents = True
|
||||||
self._spinner_frame_index = (self._spinner_frame_index + 1) % len(
|
num_colors = len(self._sweep_colors)
|
||||||
self._spinner_frames
|
offset = num_colors - 1
|
||||||
)
|
max_pos = (self._sweep_num_squares - 1) + offset
|
||||||
|
total_range = max_pos + offset
|
||||||
|
cycle_length = total_range * 2
|
||||||
|
self._spinner_frame_index = (self._spinner_frame_index + 1) % cycle_length
|
||||||
self._update_agent_status_display()
|
self._update_agent_status_display()
|
||||||
|
|
||||||
if not has_active_agents:
|
if not has_active_agents:
|
||||||
@@ -1350,7 +1343,9 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|||||||
tool_name = tool_data.get("tool_name", "")
|
tool_name = tool_data.get("tool_name", "")
|
||||||
if tool_name not in initial_tools:
|
if tool_name not in initial_tools:
|
||||||
return True
|
return True
|
||||||
return False
|
|
||||||
|
streaming = self.tracer.get_streaming_content(agent_id)
|
||||||
|
return bool(streaming and streaming.strip())
|
||||||
|
|
||||||
def _agent_vulnerability_count(self, agent_id: str) -> int:
|
def _agent_vulnerability_count(self, agent_id: str) -> int:
|
||||||
count = 0
|
count = 0
|
||||||
@@ -1467,9 +1462,6 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|||||||
vuln_indicator = f" ({vuln_count})" if vuln_count > 0 else ""
|
vuln_indicator = f" ({vuln_count})" if vuln_count > 0 else ""
|
||||||
agent_name = f"{status_icon} {agent_name_raw}{vuln_indicator}"
|
agent_name = f"{status_icon} {agent_name_raw}{vuln_indicator}"
|
||||||
|
|
||||||
if status in ["running", "waiting"]:
|
|
||||||
self._start_agent_verb_timer(agent_id)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if parent_id and parent_id in self.agent_nodes:
|
if parent_id and parent_id in self.agent_nodes:
|
||||||
parent_node = self.agent_nodes[parent_id]
|
parent_node = self.agent_nodes[parent_id]
|
||||||
@@ -1876,9 +1868,6 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|||||||
logging.exception(f"Failed to stop agent {agent_id}")
|
logging.exception(f"Failed to stop agent {agent_id}")
|
||||||
|
|
||||||
def action_custom_quit(self) -> None:
|
def action_custom_quit(self) -> None:
|
||||||
for agent_id in list(self._agent_verb_timers.keys()):
|
|
||||||
self._stop_agent_verb_timer(agent_id)
|
|
||||||
|
|
||||||
if self._scan_thread and self._scan_thread.is_alive():
|
if self._scan_thread and self._scan_thread.is_alive():
|
||||||
self._scan_stop_event.set()
|
self._scan_stop_event.set()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user