Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,6 @@ dmypy.json
tests/e2e/attacks/

.tmp

# pytest-textual-snapshot report artifact
snapshot_report.html
57 changes: 31 additions & 26 deletions hackagent/cli/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from textual.widgets import Footer, TabbedContent, TabPane

from hackagent.cli.config import CLIConfig
from hackagent.cli.tui.theme import css_variables
from hackagent.cli.tui.views.agents import AgentsTab
from hackagent.cli.tui.views.attacks import AttacksTab
from hackagent.cli.tui.views.config import ConfigTab
Expand All @@ -29,19 +30,19 @@ class HackAgentTUI(App):
}

Header {
background: #8b0000; /* dark red - HackAgent brand color */
color: #ffffff;
background: $brand-dark; /* dark red - HackAgent brand color */
color: $brand-text;
height: 3;
}

Footer {
background: #2b0000; /* darker red */
color: #ffffff;
background: $brand-darker; /* darker red */
color: $brand-text;
}

TabbedContent {
height: 100%;
border: solid #ff0000; /* red - HackAgent brand color */
border: solid $brand; /* red - HackAgent brand color */
}

TabPane {
Expand All @@ -53,43 +54,43 @@ class HackAgentTUI(App):
}

Tabs {
background: #2b0000;
background: $brand-darker;
}

Tab {
color: #cccccc;
background: #2b0000;
color: $brand-text-muted;
background: $brand-darker;
}

Tab.-active {
color: #ffffff;
background: #8b0000; /* dark red when active */
color: $brand-text;
background: $brand-dark; /* dark red when active */
text-style: bold;
}

Tab:hover {
background: #5b0000;
background: $brand-hover;
}

.title-bar {
dock: top;
width: 100%;
background: #8b0000;
color: #ffffff;
background: $brand-dark;
color: $brand-text;
height: 3;
content-align: center middle;
}

.section {
border: solid #ff0000;
border: solid $brand;
padding: 1;
margin: 1;
height: auto;
}

.info-box {
background: $panel;
border: solid #ff0000;
border: solid $brand;
padding: 1;
margin: 1;
}
Expand All @@ -99,31 +100,31 @@ class HackAgentTUI(App):
}

Button.-primary {
background: #8b0000;
color: #ffffff;
background: $brand-dark;
color: $brand-text;
}

Button.-primary:hover {
background: #ff0000;
background: $brand;
}

DataTable {
height: 100%;
}

DataTable > .datatable--header {
background: #8b0000;
color: #ffffff;
background: $brand-dark;
color: $brand-text;
text-style: bold;
}

DataTable > .datatable--cursor {
background: #5b0000;
background: $brand-hover;
}

/* Results tab specific styles - horizontal split 20-80 */
ResultsTab #results-left-panel {
border-right: solid #ff0000;
border-right: solid $brand;
background: $panel;
}

Expand All @@ -135,17 +136,17 @@ class HackAgentTUI(App):
height: 3;
width: 100%;
text-align: center;
background: #8b0000;
color: #ffffff;
background: $brand-dark;
color: $brand-text;
padding: 1;
}

ResultsTab #details-title {
height: 3;
width: 100%;
text-align: center;
background: #8b0000;
color: #ffffff;
background: $brand-dark;
color: $brand-text;
padding: 1;
}

Expand Down Expand Up @@ -188,6 +189,10 @@ def __init__(
self.initial_data = initial_data or {}
self.dark = True # Use dark theme by default

def get_css_variables(self) -> dict[str, str]:
"""Expose the HackAgent brand palette as CSS variables."""
return {**super().get_css_variables(), **css_variables()}

def compose(self) -> ComposeResult:
"""Compose the UI layout."""
with TabbedContent(initial=self.initial_tab):
Expand Down
2 changes: 1 addition & 1 deletion hackagent/cli/tui/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ class HackAgentHeader(Container):
}

HackAgentHeader Static {
color: #ff0000;
color: $brand;
text-style: bold;
width: 100%;
content-align: center middle;
Expand Down
187 changes: 187 additions & 0 deletions hackagent/cli/tui/theme.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
# Copyright 2026 - AI4I. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""
TUI Theme and Terminology

Single source of truth for the HackAgent TUI's colour palette and for the
vocabulary used to describe evaluation outcomes and run states.

Two rules keep the interface coherent:

1. **Defender polarity.** A result is described from the point of view of the
agent under test, never the attacker. A jailbreak that got through is a
``Vulnerable`` result and is always red; a jailbreak that was refused is a
``Mitigated`` result and is always green. "Successful attack" wording (which
would paint a vulnerability green) is not used anywhere in the TUI.
2. **One palette.** Brand colours live here so views and CSS never re-invent
their own shade of red.
"""

from dataclasses import dataclass
from typing import Any

# --------------------------------------------------------------------------
# Brand palette
# --------------------------------------------------------------------------

BRAND_RED = "#ff0000"
"""Primary brand red — borders, logo, accents."""

BRAND_RED_DARK = "#8b0000"
"""Dark red — headers, active tabs, primary buttons."""

BRAND_RED_DARKER = "#2b0000"
"""Darkest red — footer and inactive tab strip backgrounds."""

BRAND_RED_HOVER = "#5b0000"
"""Mid red — hover and cursor highlights."""

TEXT_ON_BRAND = "#ffffff"
"""Foreground colour on top of any brand red background."""

TEXT_MUTED = "#cccccc"
"""Foreground colour for de-emphasised text on dark backgrounds."""


def css_variables() -> dict[str, str]:
"""Return the brand palette as Textual CSS variables.

The returned names are usable in stylesheets as ``$brand``,
``$brand-dark``, ``$brand-darker``, ``$brand-hover``, ``$brand-text`` and
``$brand-text-muted``.
"""
return {
"brand": BRAND_RED,
"brand-dark": BRAND_RED_DARK,
"brand-darker": BRAND_RED_DARKER,
"brand-hover": BRAND_RED_HOVER,
"brand-text": TEXT_ON_BRAND,
"brand-text-muted": TEXT_MUTED,
}


# --------------------------------------------------------------------------
# Evaluation outcomes
# --------------------------------------------------------------------------


@dataclass(frozen=True)
class Outcome:
"""Presentation vocabulary for a single evaluation outcome.

Attributes:
key: Stable identifier, also used as the CSS modifier class.
label: Human-readable label shown to the user.
color: Rich colour name used to render the label.
icon: Emoji shown next to the label.
"""

key: str
label: str
color: str
icon: str

def render(self) -> str:
"""Return the outcome as Rich markup, e.g. ``[red]🔓 Vulnerable[/red]``."""
return f"[{self.color}]{self.icon} {self.label}[/{self.color}]"

@property
def css_class(self) -> str:
"""Return the CSS modifier class for this outcome."""
return f"-{self.key}"


VULNERABLE = Outcome("vulnerable", "Vulnerable", "red", "🔓")
"""The attack got through: the target agent is vulnerable."""

MITIGATED = Outcome("mitigated", "Mitigated", "green", "🛡")
"""The attack was refused or blocked by the target agent."""

ERRORED = Outcome("errored", "Error", "yellow", "⚠")
"""The attempt could not be evaluated because something went wrong."""

NOT_EVALUATED = Outcome("not-evaluated", "Not Evaluated", "dim", "⏳")
"""No verdict is available yet."""

OUTCOMES = (VULNERABLE, MITIGATED, ERRORED, NOT_EVALUATED)


def classify_evaluation_status(status: Any) -> Outcome:
"""Map a raw evaluation status onto the TUI outcome vocabulary.

Args:
status: An ``EvaluationStatus`` enum member, a string, or anything
coercible to a string. ``None`` is treated as unevaluated.

Returns:
The matching :class:`Outcome`.
"""
if status is None:
return NOT_EVALUATED

raw = getattr(status, "value", status)
text = str(raw).upper()

if "JAILBREAK" in text:
if "SUCCESSFUL" in text:
return VULNERABLE
if "FAILED" in text:
return MITIGATED
if "ERROR" in text:
return ERRORED
return NOT_EVALUATED


# --------------------------------------------------------------------------
# Run states
# --------------------------------------------------------------------------


@dataclass(frozen=True)
class RunState:
"""Presentation vocabulary for a run's lifecycle state."""

key: str
label: str
color: str
icon: str

def render(self) -> str:
"""Return the state as Rich markup."""
return f"[{self.color}]{self.icon} {self.label}[/{self.color}]"

def render_icon(self) -> str:
"""Return just the coloured icon, for compact table cells."""
return f"[{self.color}]{self.icon}[/{self.color}]"


RUN_COMPLETED = RunState("completed", "Completed", "green", "✅")
RUN_RUNNING = RunState("running", "Running", "cyan", "🔄")
RUN_FAILED = RunState("failed", "Failed", "red", "❌")
RUN_PENDING = RunState("pending", "Pending", "yellow", "⏳")
RUN_UNKNOWN = RunState("unknown", "Unknown", "dim", "❓")

RUN_STATES = (RUN_COMPLETED, RUN_RUNNING, RUN_FAILED, RUN_PENDING, RUN_UNKNOWN)


def classify_run_status(status: Any) -> RunState:
"""Map a raw run status onto the TUI run-state vocabulary.

Args:
status: A status enum member, a string, or anything coercible to a
string. ``None`` is treated as unknown.

Returns:
The matching :class:`RunState`.
"""
if status is None:
return RUN_UNKNOWN

raw = getattr(status, "value", status)
text = str(raw).upper()

for state in (RUN_COMPLETED, RUN_RUNNING, RUN_FAILED, RUN_PENDING):
if state.key.upper() == text:
return state
return RUN_UNKNOWN
10 changes: 5 additions & 5 deletions hackagent/cli/tui/views/attacks/layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,9 +177,9 @@ def compose(self) -> ComposeResult:
# order in attack_specs.py), not the desired campaign order
# (h4rm3l → TAP → PAIR). `on_mount` selects the default
# campaign attacks explicitly, in the right order, instead.
yield Static("[bold]Attack Strategy[/bold]", classes="section-title")
yield Static("[bold]Attacks[/bold]", classes="section-title")
yield Static(
"[dim]Select one attack, or check multiple to chain them "
"[dim]Select one attack, or check multiple to chain them. "
"Check order sets the chain order. Defaults to the Jailbreak "
"evaluation campaign (h4rm3l → TAP → PAIR).[/dim]"
)
Expand All @@ -189,18 +189,18 @@ def compose(self) -> ComposeResult:
yield Checkbox(
"Escalate only mitigated goals to the next attack",
id="escalate-only-mitigated",
value=False,
value=True,
)
yield Static(
"[dim]Chain mode (2+ attacks checked): a goal moves to "
"the next attack only if the previous one mitigated it; "
"goals that already succeeded are dropped. Uncheck to "
"goals that are already vulnerable are dropped. Uncheck to "
"instead run every checked attack against every goal.[/dim]",
id="escalate-only-mitigated-help",
)
yield Static("")

yield Label("Configuring:")
yield Label("Configuring attack:")
yield Select(
strategy_choices,
id="attack-strategy-focus",
Expand Down
Loading
Loading