splitagent 0.0.3__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.
Files changed (56) hide show
  1. splitagent/__init__.py +8 -0
  2. splitagent/__main__.py +6 -0
  3. splitagent/agents/__init__.py +10 -0
  4. splitagent/agents/base.py +477 -0
  5. splitagent/agents/blue.py +57 -0
  6. splitagent/agents/chat.py +60 -0
  7. splitagent/agents/prompts.py +462 -0
  8. splitagent/agents/red.py +75 -0
  9. splitagent/cli.py +701 -0
  10. splitagent/config.py +697 -0
  11. splitagent/core/__init__.py +19 -0
  12. splitagent/core/bus.py +62 -0
  13. splitagent/core/context.py +587 -0
  14. splitagent/core/context_manager.py +381 -0
  15. splitagent/core/engine.py +424 -0
  16. splitagent/core/models.py +310 -0
  17. splitagent/core/proc.py +73 -0
  18. splitagent/core/sandbox.py +184 -0
  19. splitagent/core/toolbox.py +520 -0
  20. splitagent/core/workspace.py +420 -0
  21. splitagent/desktop/__init__.py +7 -0
  22. splitagent/desktop/api.py +525 -0
  23. splitagent/desktop/app.py +1131 -0
  24. splitagent/desktop/web/app.js +3067 -0
  25. splitagent/desktop/web/assets/Inter.ttf +0 -0
  26. splitagent/desktop/web/assets/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
  27. splitagent/desktop/web/index.html +760 -0
  28. splitagent/desktop/web/styles.css +1612 -0
  29. splitagent/errors.py +27 -0
  30. splitagent/llm/__init__.py +8 -0
  31. splitagent/llm/client.py +488 -0
  32. splitagent/llm/types.py +172 -0
  33. splitagent/report/__init__.py +9 -0
  34. splitagent/report/cvss.py +93 -0
  35. splitagent/report/generator.py +733 -0
  36. splitagent/tools/__init__.py +8 -0
  37. splitagent/tools/base.py +135 -0
  38. splitagent/tools/defense.py +475 -0
  39. splitagent/tools/exploit.py +318 -0
  40. splitagent/tools/http_pool.py +109 -0
  41. splitagent/tools/knowledge.py +376 -0
  42. splitagent/tools/recon.py +182 -0
  43. splitagent/tools/registry.py +62 -0
  44. splitagent/tools/validate.py +908 -0
  45. splitagent/tools/web.py +386 -0
  46. splitagent/tools/workspace_tools.py +411 -0
  47. splitagent/ui/__init__.py +5 -0
  48. splitagent/ui/app.py +389 -0
  49. splitagent/ui/stream.py +234 -0
  50. splitagent/ui/theme.py +72 -0
  51. splitagent-0.0.3.dist-info/METADATA +987 -0
  52. splitagent-0.0.3.dist-info/RECORD +56 -0
  53. splitagent-0.0.3.dist-info/WHEEL +5 -0
  54. splitagent-0.0.3.dist-info/entry_points.txt +2 -0
  55. splitagent-0.0.3.dist-info/licenses/LICENSE +21 -0
  56. splitagent-0.0.3.dist-info/top_level.txt +1 -0
splitagent/ui/app.py ADDED
@@ -0,0 +1,389 @@
1
+ """Textual TUI for SplitAgent, styled after the OpenCode interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from textual import work
9
+ from textual.app import App, ComposeResult
10
+ from textual.binding import Binding
11
+ from textual.containers import Horizontal, Vertical, VerticalScroll
12
+ from textual.screen import ModalScreen
13
+ from textual.widgets import (
14
+ Button,
15
+ DataTable,
16
+ Footer,
17
+ Input,
18
+ Label,
19
+ Markdown,
20
+ Select,
21
+ Static,
22
+ TabbedContent,
23
+ TabPane,
24
+ )
25
+
26
+ from splitagent.config import (
27
+ PROVIDER_PRESETS,
28
+ GlobalConfig,
29
+ ProjectConfig,
30
+ save_global_config,
31
+ )
32
+ from splitagent.core.bus import Event, EventBus
33
+ from splitagent.core.context import SharedContext
34
+ from splitagent.core.engine import Engine
35
+ from splitagent.report.generator import build_markdown
36
+ from splitagent.ui import theme as t
37
+
38
+ MAX_BLOCKS = 500
39
+
40
+
41
+ class ConfigScreen(ModalScreen[GlobalConfig | None]):
42
+ """Modal to configure the model API from inside the program."""
43
+
44
+ BINDINGS = [Binding("escape", "dismiss(None)", "Cancel")]
45
+
46
+ def __init__(self, config: GlobalConfig) -> None:
47
+ super().__init__()
48
+ self.config = config
49
+
50
+ def compose(self) -> ComposeResult:
51
+ provider_options = [(name, name) for name in PROVIDER_PRESETS]
52
+ with Vertical(id="config-dialog"):
53
+ yield Label("Model API configuration", id="config-title")
54
+ yield Label("Provider", classes="field-label")
55
+ yield Select(
56
+ provider_options,
57
+ value=self.config.llm.provider,
58
+ allow_blank=False,
59
+ id="provider",
60
+ )
61
+ yield Label("Base URL", classes="field-label")
62
+ yield Input(value=self.config.llm.base_url, id="base_url")
63
+ yield Label("API key", classes="field-label")
64
+ yield Input(
65
+ value=self.config.llm.api_key,
66
+ password=True,
67
+ placeholder="sk-...",
68
+ id="api_key",
69
+ )
70
+ yield Label("Model", classes="field-label")
71
+ yield Input(value=self.config.llm.model, id="model")
72
+ yield Label("Temperature", classes="field-label")
73
+ yield Input(value=str(self.config.llm.temperature), id="temperature")
74
+ yield Static("", id="config-status")
75
+ with Horizontal(id="config-buttons"):
76
+ yield Button("Test", id="test", variant="default")
77
+ yield Button("Save", id="save", variant="primary")
78
+ yield Button("Cancel", id="cancel")
79
+
80
+ def on_select_changed(self, event: Select.Changed) -> None:
81
+ if event.select.id != "provider" or not event.value:
82
+ return
83
+ preset = PROVIDER_PRESETS.get(str(event.value))
84
+ if not preset:
85
+ return
86
+ self.query_one("#base_url", Input).value = preset["base_url"]
87
+ self.query_one("#model", Input).value = preset["model"]
88
+
89
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
90
+ if event.button.id == "cancel":
91
+ self.dismiss(None)
92
+ return
93
+ updated = self._collect()
94
+ if event.button.id == "save":
95
+ self.dismiss(updated)
96
+ return
97
+ if event.button.id == "test":
98
+ status = self.query_one("#config-status", Static)
99
+ status.update("Testing connection...")
100
+ from splitagent.llm.client import LLMClient
101
+
102
+ async with LLMClient(updated.llm) as client:
103
+ ok, message = await client.test_connection()
104
+ status.update(f"OK: {message}" if ok else f"Failed: {message}")
105
+
106
+ def _collect(self) -> GlobalConfig:
107
+ cfg = GlobalConfig(
108
+ llm=type(self.config.llm)(**vars(self.config.llm)),
109
+ ui=self.config.ui,
110
+ sandbox=self.config.sandbox,
111
+ authorized=self.config.authorized,
112
+ )
113
+ cfg.llm.provider = str(self.query_one("#provider", Select).value or "custom")
114
+ cfg.llm.base_url = self.query_one("#base_url", Input).value.strip()
115
+ cfg.llm.api_key = self.query_one("#api_key", Input).value.strip()
116
+ cfg.llm.model = self.query_one("#model", Input).value.strip()
117
+ try:
118
+ cfg.llm.temperature = float(self.query_one("#temperature", Input).value or "0.2")
119
+ except ValueError:
120
+ cfg.llm.temperature = 0.2
121
+ preset = PROVIDER_PRESETS.get(cfg.llm.provider)
122
+ if preset:
123
+ cfg.llm.protocol = preset.get("protocol", "openai")
124
+ return cfg
125
+
126
+
127
+ class SplitAgentApp(App[None]):
128
+ """The SplitAgent cockpit."""
129
+
130
+ TITLE = "SplitAgent"
131
+ SUB_TITLE = "autonomous dual-team security"
132
+ CSS = f"""
133
+ Screen {{ background: {t.BG}; color: {t.TEXT}; }}
134
+ #topbar {{
135
+ height: 3; padding: 0 1; background: {t.PANEL};
136
+ border-bottom: solid {t.BORDER};
137
+ }}
138
+ #topbar-title {{ color: {t.PRIMARY}; text-style: bold; }}
139
+ #topbar-meta {{ color: {t.MUTED}; }}
140
+ #run-button {{ dock: right; margin: 0 1; min-width: 14; }}
141
+ #body {{ height: 1fr; }}
142
+ .agent-pane {{ width: 1fr; border: round {t.BORDER}; }}
143
+ .agent-pane-title {{ padding: 0 1; background: {t.ELEMENT}; color: {t.TEXT}; }}
144
+ #red-pane {{ border: round {t.RED}; }}
145
+ #blue-pane {{ border: round {t.SECONDARY}; }}
146
+ VerticalScroll {{ background: {t.BG}; }}
147
+ #findings-table, #mitigations-table {{ background: {t.PANEL}; }}
148
+ #report-view {{ background: {t.BG}; padding: 1 2; }}
149
+ #config-dialog {{
150
+ width: 70; height: auto; max-height: 90%;
151
+ background: {t.PANEL}; border: thick {t.PRIMARY}; padding: 1 2;
152
+ align: center middle;
153
+ }}
154
+ #config-title {{ color: {t.PRIMARY}; text-style: bold; padding-bottom: 1; }}
155
+ .field-label {{ color: {t.MUTED}; padding-top: 1; }}
156
+ #config-status {{ color: {t.CYAN}; padding-top: 1; min-height: 2; }}
157
+ #config-buttons {{ height: 3; align-horizontal: right; padding-top: 1; }}
158
+ #config-buttons Button {{ margin-left: 1; }}
159
+ TabbedContent {{ background: {t.BG}; }}
160
+ Tabs {{ background: {t.PANEL}; }}
161
+ Tab {{ color: {t.MUTED}; }}
162
+ Tab.-active {{ color: {t.PRIMARY}; text-style: bold; }}
163
+ Footer {{ background: {t.PANEL}; color: {t.MUTED}; }}
164
+ """
165
+
166
+ BINDINGS = [
167
+ Binding("q", "quit", "Quit"),
168
+ Binding("r", "run_audit", "Run"),
169
+ Binding("c", "configure", "Configure"),
170
+ Binding("e", "export", "Export"),
171
+ Binding("s", "save_session", "Save"),
172
+ ]
173
+
174
+ def __init__(self, global_config: GlobalConfig, project: ProjectConfig) -> None:
175
+ super().__init__()
176
+ self.global_config = global_config
177
+ self.project = project
178
+ self.bus = EventBus()
179
+ self.bus.subscribe(self._on_event)
180
+ self.engine: Engine | None = None
181
+ self.buffers: dict[str, list[str]] = {"red": [], "blue": []}
182
+ self.findings: list[dict[str, Any]] = []
183
+ self.mitigations: list[dict[str, Any]] = []
184
+ self.logs: list[str] = []
185
+ self.round = 0
186
+ self.total_rounds = project.run.rounds
187
+ self.phase = "idle"
188
+ self.resilience = 0.0
189
+ self.running = False
190
+ self._session: SharedContext | None = None
191
+
192
+ # -- layout ------------------------------------------------------------ #
193
+ def compose(self) -> ComposeResult:
194
+ with Horizontal(id="topbar"):
195
+ yield Static("SPLITAGENT", id="topbar-title")
196
+ yield Static("", id="topbar-meta")
197
+ yield Button("> Run audit", id="run-button", variant="primary")
198
+ with TabbedContent(id="body"):
199
+ with TabPane("Stream", id="tab-stream"), Horizontal():
200
+ with Vertical(id="red-pane", classes="agent-pane"):
201
+ yield Static("RED AGENT", classes="agent-pane-title")
202
+ with VerticalScroll(id="red-scroll"):
203
+ yield Static("", id="red-log")
204
+ with Vertical(id="blue-pane", classes="agent-pane"):
205
+ yield Static("BLUE AGENT", classes="agent-pane-title")
206
+ with VerticalScroll(id="blue-scroll"):
207
+ yield Static("", id="blue-log")
208
+ with TabPane("Findings", id="tab-findings"):
209
+ yield DataTable(id="findings-table", zebra_stripes=True)
210
+ with TabPane("Mitigations", id="tab-mitigations"):
211
+ yield DataTable(id="mitigations-table", zebra_stripes=True)
212
+ with TabPane("Report", id="tab-report"), VerticalScroll():
213
+ yield Markdown("_Run an audit to generate the report._", id="report-view")
214
+ with TabPane("Activity", id="tab-activity"), VerticalScroll():
215
+ yield Static("", id="activity-log")
216
+ yield Footer()
217
+
218
+ def on_mount(self) -> None:
219
+ findings = self.query_one("#findings-table", DataTable)
220
+ findings.add_columns("ID", "Severity", "CVSS", "Title", "Status")
221
+ mitigations = self.query_one("#mitigations-table", DataTable)
222
+ mitigations.add_columns("ID", "Kind", "Finding", "Title", "Status")
223
+ self._refresh_topbar()
224
+ if not self.global_config.configured:
225
+ self._prompt_config(first_run=True)
226
+
227
+ # -- top bar ----------------------------------------------------------- #
228
+ def _refresh_topbar(self) -> None:
229
+ meta = (
230
+ f" target {self.project.target.url or 'n/a'} "
231
+ f"model {self.global_config.llm.provider}/{self.global_config.llm.model} "
232
+ f"round {self.round}/{self.total_rounds} "
233
+ f"phase {self.phase} resilience {self.resilience}/100"
234
+ )
235
+ self.query_one("#topbar-meta", Static).update(meta)
236
+
237
+ # -- actions ----------------------------------------------------------- #
238
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
239
+ if event.button.id == "run-button":
240
+ self.action_run_audit()
241
+
242
+ def action_run_audit(self) -> None:
243
+ if self.running:
244
+ self._log_line("A run is already in progress.")
245
+ return
246
+ if not self.global_config.configured:
247
+ self._prompt_config(first_run=True)
248
+ return
249
+ self.running = True
250
+ self._log_line("Starting audit...")
251
+ self._run_engine()
252
+
253
+ def action_configure(self) -> None:
254
+ self._prompt_config()
255
+
256
+ def _prompt_config(self, first_run: bool = False) -> None:
257
+ def _apply(result: GlobalConfig | None) -> None:
258
+ if result is None:
259
+ return
260
+ self.global_config = result
261
+ path = save_global_config(result)
262
+ self._log_line(f"Configuration saved to {path}")
263
+ self._refresh_topbar()
264
+
265
+ self.push_screen(ConfigScreen(self.global_config), _apply)
266
+
267
+ def action_save_session(self) -> None:
268
+ if self._session is not None:
269
+ path = self._session.save()
270
+ self._log_line(f"Session saved to {path}")
271
+ else:
272
+ self._log_line("Nothing to save yet.")
273
+
274
+ def action_export(self) -> None:
275
+ if self._session is None:
276
+ self._log_line("Run an audit first.")
277
+ return
278
+ from pathlib import Path
279
+
280
+ from splitagent.report.generator import write_reports
281
+
282
+ paths = write_reports(
283
+ self._session.state, self.project.report, Path(self.project.report.output_dir)
284
+ )
285
+ for path in paths:
286
+ self._log_line(f"Report written: {path}")
287
+
288
+ # -- engine worker ----------------------------------------------------- #
289
+ @work(exclusive=True)
290
+ async def _run_engine(self) -> None:
291
+ try:
292
+ self.engine = Engine(self.global_config, self.project, bus=self.bus)
293
+ self._session = await self.engine.run()
294
+ self._log_line("Audit complete.")
295
+ markdown = build_markdown(self._session.state, self.project.report)
296
+ self.query_one("#report-view", Markdown).update(markdown)
297
+ except Exception as exc:
298
+ self._log_line(f"Run failed: {type(exc).__name__}: {exc}")
299
+ finally:
300
+ self.running = False
301
+ self.phase = "done"
302
+ self._refresh_topbar()
303
+
304
+ # -- event bus --------------------------------------------------------- #
305
+ def _on_event(self, event: Event) -> None:
306
+ etype = event.type
307
+ if etype == "session.start":
308
+ self.project.target.url = self.project.target.url or event.data.get("target", "")
309
+ self.total_rounds = event.data.get("rounds", self.total_rounds)
310
+ self.phase = "starting"
311
+ elif etype == "round.start":
312
+ self.round = event.data.get("round", self.round)
313
+ self.phase = "round"
314
+ elif etype == "phase.start":
315
+ self.phase = event.data.get("phase", self.phase)
316
+ self._append(event.agent, f"\n-- {event.agent.upper()} - {self.phase} --\n")
317
+ elif etype == "agent.text":
318
+ self._append(event.agent, event.data.get("text", ""))
319
+ elif etype == "agent.tool_call":
320
+ args = json.dumps(event.data.get("arguments", {}), ensure_ascii=False)
321
+ self._append(event.agent, f"\n* {event.data.get('tool')}({args[:180]})\n")
322
+ elif etype == "agent.tool_result":
323
+ output = str(event.data.get("output", "")).replace("\n", " ")
324
+ self._append(event.agent, f" ~ {output[:200]}\n")
325
+ elif etype == "finding":
326
+ self.findings.append(event.data)
327
+ self._add_finding_row(event.data)
328
+ elif etype == "mitigation":
329
+ self.mitigations.append(event.data)
330
+ self._add_mitigation_row(event.data)
331
+ elif etype == "round.end":
332
+ self.resilience = float(event.data.get("resilience", self.resilience))
333
+ elif etype == "log":
334
+ self._log_line(event.data.get("text", ""))
335
+ elif etype == "error":
336
+ self._log_line(f"error: {event.data.get('text', '')}")
337
+ elif etype == "session.end":
338
+ self.phase = "done"
339
+ self.resilience = float(event.data.get("resilience", self.resilience))
340
+ self._refresh_topbar()
341
+
342
+ # -- widget helpers ---------------------------------------------------- #
343
+ def _append(self, agent: str, text: str) -> None:
344
+ buffer = self.buffers.setdefault(agent, [])
345
+ buffer.append(text)
346
+ if len(buffer) > MAX_BLOCKS:
347
+ del buffer[: len(buffer) - MAX_BLOCKS]
348
+ widget_id = "#red-log" if agent == "red" else "#blue-log"
349
+ scroll_id = "#red-scroll" if agent == "red" else "#blue-scroll"
350
+ try:
351
+ self.query_one(widget_id, Static).update("".join(buffer))
352
+ self.query_one(scroll_id, VerticalScroll).scroll_end(animate=False)
353
+ except Exception: # pragma: no cover - widget not mounted yet
354
+ pass
355
+
356
+ def _log_line(self, message: str) -> None:
357
+ self.logs.append(message)
358
+ try:
359
+ self.query_one("#activity-log", Static).update("\n".join(self.logs[-300:]))
360
+ except Exception: # pragma: no cover
361
+ pass
362
+
363
+ def _add_finding_row(self, data: dict[str, Any]) -> None:
364
+ try:
365
+ table = self.query_one("#findings-table", DataTable)
366
+ table.add_row(
367
+ data.get("id", ""),
368
+ data.get("severity", "").upper(),
369
+ f"{data.get('cvss_score', 0):.1f}",
370
+ data.get("title", ""),
371
+ "open",
372
+ key=data.get("id"),
373
+ )
374
+ except Exception: # pragma: no cover
375
+ pass
376
+
377
+ def _add_mitigation_row(self, data: dict[str, Any]) -> None:
378
+ try:
379
+ table = self.query_one("#mitigations-table", DataTable)
380
+ table.add_row(
381
+ data.get("id", ""),
382
+ data.get("kind", ""),
383
+ data.get("finding_id", ""),
384
+ data.get("title", ""),
385
+ "proposed",
386
+ key=data.get("id"),
387
+ )
388
+ except Exception: # pragma: no cover
389
+ pass
@@ -0,0 +1,234 @@
1
+ """Live streaming renderer for the CLI (Rich).
2
+
3
+ Renders a two-column Red/Blue console with a status header and a findings
4
+ footer, updating as events arrive from the engine's event bus.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from typing import Any
11
+
12
+ from rich.console import Console, Group
13
+ from rich.layout import Layout
14
+ from rich.live import Live
15
+ from rich.panel import Panel
16
+ from rich.table import Table
17
+ from rich.text import Text
18
+
19
+ from splitagent.core.bus import Event
20
+ from splitagent.ui import theme as t
21
+
22
+ MAX_LINES = 600
23
+
24
+
25
+ class StreamRenderer:
26
+ """Subscribes to the bus and paints the audit in real time."""
27
+
28
+ def __init__(self, console: Console | None = None, show_thinking: bool = True) -> None:
29
+ self.console = console or Console()
30
+ self.show_thinking = show_thinking
31
+ self.buffers: dict[str, list[tuple[str, str]]] = {"red": [], "blue": []}
32
+ self.findings: list[dict[str, Any]] = []
33
+ self.mitigations: list[dict[str, Any]] = []
34
+ self.logs: list[str] = []
35
+ self.round = 0
36
+ self.total_rounds = 0
37
+ self.phase = "idle"
38
+ self.target = ""
39
+ self.model = ""
40
+ self.provider = ""
41
+ self.resilience = 0.0
42
+ self._live: Live | None = None
43
+ self._printed_logs = 0
44
+
45
+ # -- wiring ------------------------------------------------------------ #
46
+ def attach(self, bus) -> Any:
47
+ return bus.subscribe(self.handle)
48
+
49
+ # -- lifecycle --------------------------------------------------------- #
50
+ def start(self) -> None:
51
+ if self.console.is_terminal:
52
+ self._live = Live(
53
+ self.render(),
54
+ console=self.console,
55
+ refresh_per_second=12,
56
+ screen=False,
57
+ transient=False,
58
+ )
59
+ self._live.start()
60
+ else:
61
+ self.console.print(
62
+ Text(t.BANNER, style=t.PRIMARY),
63
+ )
64
+
65
+ def stop(self) -> None:
66
+ if self._live is not None:
67
+ self._live.update(self.render())
68
+ self._live.stop()
69
+ self._live = None
70
+
71
+ def _refresh(self) -> None:
72
+ if self._live is not None:
73
+ self._live.update(self.render())
74
+ else:
75
+ self._print_compact()
76
+
77
+ def _print_compact(self) -> None:
78
+ # Non-interactive output: emit each new log line exactly once.
79
+ while self._printed_logs < len(self.logs):
80
+ self.console.print(self.logs[self._printed_logs])
81
+ self._printed_logs += 1
82
+
83
+ # -- event handling ---------------------------------------------------- #
84
+ def handle(self, event: Event) -> None:
85
+ etype = event.type
86
+ if etype == "session.start":
87
+ self.target = event.data.get("target", "")
88
+ self.model = event.data.get("model", "")
89
+ self.provider = event.data.get("provider", "")
90
+ self.total_rounds = event.data.get("rounds", 0)
91
+ self.phase = "starting"
92
+ elif etype == "round.start":
93
+ self.round = event.data.get("round", self.round)
94
+ self.phase = "round"
95
+ for agent in ("red", "blue"):
96
+ self._push(agent, "sep", f"-- Round {self.round}/{self.total_rounds} --")
97
+ elif etype == "phase.start":
98
+ self.phase = event.data.get("phase", self.phase)
99
+ color = t.agent_color(event.agent)
100
+ self._push(event.agent, "sep", f"> {event.agent.upper()} phase: {self.phase}")
101
+ self.logs.append(f"[{color}]{event.agent}[/] {self.phase} (round {self.round})")
102
+ elif etype == "agent.text":
103
+ self._push(event.agent, "text", event.data.get("text", ""))
104
+ elif etype == "agent.tool_call":
105
+ tool = event.data.get("tool", "?")
106
+ args = event.data.get("arguments", {})
107
+ preview = json.dumps(args, ensure_ascii=False)
108
+ if len(preview) > 160:
109
+ preview = preview[:160] + "..."
110
+ self._push(event.agent, "tool", f"\n* {tool}({preview})")
111
+ self.logs.append(f"[{t.MUTED}]{event.agent} -> {tool}[/]")
112
+ elif etype == "agent.tool_result":
113
+ output = str(event.data.get("output", "")).replace("\n", " ")
114
+ self._push(event.agent, "result", f" ~ {output[:220]}")
115
+ elif etype == "finding":
116
+ self.findings.append(event.data)
117
+ self.logs.append(
118
+ f"[{t.severity_color(event.data.get('severity', 'info'))}]"
119
+ f"finding {event.data.get('id')}: {event.data.get('title')}[/]"
120
+ )
121
+ elif etype == "mitigation":
122
+ self.mitigations.append(event.data)
123
+ self.logs.append(
124
+ f"[{t.GREEN}]mitigation {event.data.get('id')} ({event.data.get('kind')})[/]"
125
+ )
126
+ elif etype == "round.end":
127
+ self.resilience = float(event.data.get("resilience", self.resilience))
128
+ elif etype == "log":
129
+ self.logs.append(
130
+ f"[{t.status_color(event.data.get('level', 'info'))}]"
131
+ f"{event.data.get('text', '')}[/]"
132
+ )
133
+ elif etype == "error":
134
+ self.logs.append(f"[{t.RED}]error: {event.data.get('text', '')}[/]")
135
+ elif etype == "session.end":
136
+ self.phase = "done"
137
+ self.resilience = float(event.data.get("resilience", self.resilience))
138
+ self.logs.append(f"[{t.GREEN}]session complete - resilience {self.resilience}/100[/]")
139
+ self._refresh()
140
+
141
+ def _push(self, agent: str, kind: str, text: str) -> None:
142
+ buffer = self.buffers.setdefault(agent, [])
143
+ buffer.append((kind, text))
144
+ if len(buffer) > MAX_LINES:
145
+ del buffer[: len(buffer) - MAX_LINES]
146
+
147
+ # -- rendering --------------------------------------------------------- #
148
+ def render(self) -> Layout:
149
+ layout = Layout()
150
+ layout.split_column(
151
+ Layout(name="header", size=3),
152
+ Layout(name="body", ratio=1),
153
+ Layout(name="footer", size=7),
154
+ )
155
+ layout["header"].update(self._header())
156
+ body = Table.grid(expand=True)
157
+ body.add_column(ratio=1)
158
+ body.add_column(ratio=1)
159
+ body.add_row(
160
+ Panel(
161
+ self._agent_text("red"),
162
+ title=f"[{t.RED}]RED AGENT[/]",
163
+ border_style=t.RED if self.phase == "offense" else t.BORDER,
164
+ padding=(0, 1),
165
+ ),
166
+ Panel(
167
+ self._agent_text("blue"),
168
+ title=f"[{t.SECONDARY}]BLUE AGENT[/]",
169
+ border_style=t.SECONDARY if self.phase == "defense" else t.BORDER,
170
+ padding=(0, 1),
171
+ ),
172
+ )
173
+ layout["body"].update(body)
174
+ layout["footer"].update(self._footer())
175
+ return layout
176
+
177
+ def _header(self) -> Panel:
178
+ counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
179
+ for finding in self.findings:
180
+ counts[finding.get("severity", "info")] = (
181
+ counts.get(finding.get("severity", "info"), 0) + 1
182
+ )
183
+ line = Text()
184
+ line.append("SPLITAGENT ", style=f"bold {t.PRIMARY}")
185
+ line.append("- ", style=t.MUTED)
186
+ line.append(self.target or "no target", style=t.TEXT)
187
+ line.append(" - ", style=t.MUTED)
188
+ line.append(self.provider or "?", style=t.SECONDARY)
189
+ line.append("/", style=t.MUTED)
190
+ line.append(self.model or "?", style=t.SECONDARY)
191
+ line.append(" - ", style=t.MUTED)
192
+ line.append(f"round {self.round}/{self.total_rounds}", style=t.TEXT)
193
+ line.append(" - ", style=t.MUTED)
194
+ line.append(f"phase {self.phase}", style=t.ACCENT)
195
+ line.append(" - ", style=t.MUTED)
196
+ line.append(f"resilience {self.resilience}/100", style=t.GREEN)
197
+ return Panel(line, border_style=t.BORDER, padding=(0, 1))
198
+
199
+ def _agent_text(self, agent: str) -> Text:
200
+ text = Text()
201
+ for kind, payload in self.buffers.get(agent, [])[-MAX_LINES:]:
202
+ if kind == "text":
203
+ text.append(payload, style=t.TEXT)
204
+ elif kind == "tool":
205
+ text.append(payload + "\n", style=f"bold {t.CYAN}")
206
+ elif kind == "result":
207
+ text.append(payload + "\n", style=t.MUTED)
208
+ elif kind == "sep":
209
+ text.append("\n" + payload + "\n", style=f"bold {t.ACCENT}")
210
+ if not text.plain:
211
+ text.append("waiting...", style=t.MUTED)
212
+ return text
213
+
214
+ def _footer(self) -> Panel:
215
+ table = Table.grid(expand=True)
216
+ table.add_column(ratio=1)
217
+ table.add_column(ratio=2)
218
+ counts = Text()
219
+ for name in ("critical", "high", "medium", "low", "info"):
220
+ value = sum(1 for f in self.findings if f.get("severity") == name)
221
+ counts.append(f"{name[:4].upper()} {value} ", style=t.severity_color(name))
222
+ counts.append(f" mitigations {len(self.mitigations)}", style=t.GREEN)
223
+ recent = Text()
224
+ for line in self.logs[-5:]:
225
+ recent.append(line + "\n")
226
+ if not recent.plain:
227
+ recent.append("waiting for events...", style=t.MUTED)
228
+ table.add_row(counts, recent)
229
+ return Panel(
230
+ Group(counts, recent),
231
+ title=f"[{t.MUTED}]STATUS[/]",
232
+ border_style=t.BORDER,
233
+ padding=(0, 1),
234
+ )
splitagent/ui/theme.py ADDED
@@ -0,0 +1,72 @@
1
+ """OpenCode-inspired colour system.
2
+
3
+ The palette mirrors the default ``opencode`` theme (packages/tui/src/theme)
4
+ so the SplitAgent TUI feels native to the same visual language: a near-black
5
+ canvas, warm sand accent and a restrained, low-contrast grayscale.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ # Core steps
11
+ BG = "#0a0a0a"
12
+ PANEL = "#141414"
13
+ ELEMENT = "#1e1e1e"
14
+ STEP4 = "#282828"
15
+ STEP5 = "#323232"
16
+ BORDER_SUBTLE = "#3c3c3c"
17
+ BORDER = "#484848"
18
+ BORDER_ACTIVE = "#606060"
19
+ PRIMARY = "#fab283"
20
+ PRIMARY_SOFT = "#ffc09f"
21
+ MUTED = "#808080"
22
+ TEXT = "#eeeeee"
23
+
24
+ # Semantic
25
+ SECONDARY = "#5c9cf5"
26
+ ACCENT = "#9d7cd8"
27
+ RED = "#e06c75"
28
+ ORANGE = "#f5a742"
29
+ GREEN = "#7fd88f"
30
+ CYAN = "#56b6c2"
31
+ YELLOW = "#e5c07b"
32
+
33
+ SEVERITY_COLORS = {
34
+ "critical": RED,
35
+ "high": ORANGE,
36
+ "medium": YELLOW,
37
+ "low": GREEN,
38
+ "info": CYAN,
39
+ }
40
+
41
+ AGENT_COLORS = {
42
+ "red": RED,
43
+ "blue": SECONDARY,
44
+ "core": PRIMARY,
45
+ }
46
+
47
+ STATUS_COLORS = {
48
+ "info": CYAN,
49
+ "warning": ORANGE,
50
+ "error": RED,
51
+ "success": GREEN,
52
+ }
53
+
54
+ BANNER = r"""
55
+ ___ _ _ _ _ _
56
+ / __|_ __ (_) |_| | | |__ _ _ _ __| |_
57
+ \__ \ '_ \ | | _| |__| / _` | ' \/ _` _|
58
+ |___/ .__/ |_|\__|____|_\__,_|_||_\__,_\__|
59
+ |_| autonomous dual-team security
60
+ """
61
+
62
+
63
+ def severity_color(severity: str) -> str:
64
+ return SEVERITY_COLORS.get(severity.lower(), MUTED)
65
+
66
+
67
+ def agent_color(agent: str) -> str:
68
+ return AGENT_COLORS.get(agent, MUTED)
69
+
70
+
71
+ def status_color(level: str) -> str:
72
+ return STATUS_COLORS.get(level, MUTED)