openhack 0.1.0__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 (113) hide show
  1. openhack/__init__.py +2 -0
  2. openhack/__main__.py +225 -0
  3. openhack/agents/__init__.py +30 -0
  4. openhack/agents/base.py +230 -0
  5. openhack/agents/browser_verifier.py +679 -0
  6. openhack/agents/browser_verifier_swarm.py +256 -0
  7. openhack/agents/checkpoint.py +89 -0
  8. openhack/agents/context_manager.py +356 -0
  9. openhack/agents/coordinator.py +1105 -0
  10. openhack/agents/endpoint_analyst.py +307 -0
  11. openhack/agents/feature_hunter.py +93 -0
  12. openhack/agents/hunter.py +481 -0
  13. openhack/agents/hunter_swarm.py +385 -0
  14. openhack/agents/llm.py +334 -0
  15. openhack/agents/recon.py +19 -0
  16. openhack/agents/sandbox_verifier.py +396 -0
  17. openhack/agents/sandbox_verifier_swarm.py +250 -0
  18. openhack/agents/session.py +286 -0
  19. openhack/agents/validator.py +217 -0
  20. openhack/agents/validator_swarm.py +106 -0
  21. openhack/auth.py +175 -0
  22. openhack/browser/__init__.py +12 -0
  23. openhack/browser/runner.py +385 -0
  24. openhack/categories.py +130 -0
  25. openhack/config.py +201 -0
  26. openhack/deterministic_recon.py +464 -0
  27. openhack/entry_points.py +745 -0
  28. openhack/framework_classifier.py +515 -0
  29. openhack/framework_detection.py +269 -0
  30. openhack/headless_scan.py +179 -0
  31. openhack/prompts/__init__.py +108 -0
  32. openhack/prompts/browser_verifier.py +171 -0
  33. openhack/prompts/coordinator.py +31 -0
  34. openhack/prompts/django/__init__.py +32 -0
  35. openhack/prompts/django/auth_bypass.py +76 -0
  36. openhack/prompts/django/csrf.py +62 -0
  37. openhack/prompts/django/data_exposure.py +67 -0
  38. openhack/prompts/django/idor.py +74 -0
  39. openhack/prompts/django/injection.py +67 -0
  40. openhack/prompts/django/misconfiguration.py +70 -0
  41. openhack/prompts/django/ssrf.py +64 -0
  42. openhack/prompts/endpoint_analyst.py +122 -0
  43. openhack/prompts/express/__init__.py +29 -0
  44. openhack/prompts/express/auth_bypass.py +71 -0
  45. openhack/prompts/express/data_exposure.py +77 -0
  46. openhack/prompts/express/idor.py +69 -0
  47. openhack/prompts/express/injection.py +75 -0
  48. openhack/prompts/express/misconfiguration.py +72 -0
  49. openhack/prompts/express/ssrf.py +63 -0
  50. openhack/prompts/feature_hunter.py +140 -0
  51. openhack/prompts/flask/__init__.py +29 -0
  52. openhack/prompts/flask/auth_bypass.py +86 -0
  53. openhack/prompts/flask/data_exposure.py +78 -0
  54. openhack/prompts/flask/idor.py +83 -0
  55. openhack/prompts/flask/injection.py +77 -0
  56. openhack/prompts/flask/misconfiguration.py +73 -0
  57. openhack/prompts/flask/ssrf.py +65 -0
  58. openhack/prompts/hunter.py +362 -0
  59. openhack/prompts/hunter_continuation_loop.py +12 -0
  60. openhack/prompts/hunter_continuation_no_findings.py +19 -0
  61. openhack/prompts/hunter_continuation_no_progress.py +22 -0
  62. openhack/prompts/hunter_tool_instructions.py +55 -0
  63. openhack/prompts/nextjs/__init__.py +42 -0
  64. openhack/prompts/nextjs/auth_bypass.py +80 -0
  65. openhack/prompts/nextjs/csrf.py +71 -0
  66. openhack/prompts/nextjs/data_exposure.py +88 -0
  67. openhack/prompts/nextjs/idor.py +64 -0
  68. openhack/prompts/nextjs/injection.py +65 -0
  69. openhack/prompts/nextjs/middleware_bypass.py +75 -0
  70. openhack/prompts/nextjs/misconfiguration.py +92 -0
  71. openhack/prompts/nextjs/server_actions.py +97 -0
  72. openhack/prompts/nextjs/ssrf.py +66 -0
  73. openhack/prompts/nextjs/xss.py +69 -0
  74. openhack/prompts/pr_analysis_system.py +80 -0
  75. openhack/prompts/pr_analysis_user.py +11 -0
  76. openhack/prompts/project_context.py +89 -0
  77. openhack/prompts/recon.py +199 -0
  78. openhack/prompts/reporter.py +88 -0
  79. openhack/prompts/researchers.py +434 -0
  80. openhack/prompts/sandbox_verifier.py +128 -0
  81. openhack/prompts/supabase/__init__.py +39 -0
  82. openhack/prompts/supabase/auth_tokens.py +131 -0
  83. openhack/prompts/supabase/edge_functions.py +150 -0
  84. openhack/prompts/supabase/graphql.py +102 -0
  85. openhack/prompts/supabase/postgrest.py +99 -0
  86. openhack/prompts/supabase/realtime.py +93 -0
  87. openhack/prompts/supabase/rls.py +110 -0
  88. openhack/prompts/supabase/rpc_functions.py +127 -0
  89. openhack/prompts/supabase/storage.py +110 -0
  90. openhack/prompts/supabase/tenant_isolation.py +118 -0
  91. openhack/prompts/validator.py +319 -0
  92. openhack/prompts/validator_continuation_incomplete.py +12 -0
  93. openhack/prompts/validator_tool_instructions.py +29 -0
  94. openhack/quality.py +231 -0
  95. openhack/sandbox/__init__.py +12 -0
  96. openhack/sandbox/orchestrator.py +517 -0
  97. openhack/sandbox/runner.py +177 -0
  98. openhack/scan_session.py +245 -0
  99. openhack/setup.py +452 -0
  100. openhack/static_validator.py +612 -0
  101. openhack/tools/__init__.py +1 -0
  102. openhack/tools/ast_tools.py +307 -0
  103. openhack/tools/coverage.py +1078 -0
  104. openhack/tools/filesystem.py +404 -0
  105. openhack/tools/nextjs.py +258 -0
  106. openhack/tools/registry.py +52 -0
  107. openhack/tui.py +3450 -0
  108. openhack/updates.py +170 -0
  109. openhack-0.1.0.dist-info/METADATA +189 -0
  110. openhack-0.1.0.dist-info/RECORD +113 -0
  111. openhack-0.1.0.dist-info/WHEEL +4 -0
  112. openhack-0.1.0.dist-info/entry_points.txt +2 -0
  113. openhack-0.1.0.dist-info/licenses/LICENSE +661 -0
openhack/tui.py ADDED
@@ -0,0 +1,3450 @@
1
+ """
2
+ Interactive TUI for OpenHack.
3
+
4
+ Full-screen prompt_toolkit Application with two modes:
5
+
6
+ - LANDING: ground-symbol logo + "OpenHack" wordmark + centered input. Tip and
7
+ account footer below. Type a slash command or a path/URL to scan.
8
+ - SCANNING: pinned status bar (target, elapsed, cost) + VSplit pane layout
9
+ (agents on the left, findings on the right) + input bar at the bottom.
10
+
11
+ Scan execution still uses CoordinatorAgent/Session under the hood; trace
12
+ events from the session are translated into agent/finding pane state and the
13
+ layout re-renders on every update.
14
+ """
15
+
16
+ import asyncio
17
+ import json
18
+ import logging
19
+ import os
20
+ import signal
21
+ import time
22
+ from datetime import datetime
23
+ from pathlib import Path
24
+ from typing import Any, Callable, Optional
25
+
26
+ from prompt_toolkit import HTML
27
+ from prompt_toolkit.data_structures import Point
28
+ from prompt_toolkit.application import Application
29
+ from prompt_toolkit.buffer import Buffer
30
+ from prompt_toolkit.completion import Completer, Completion
31
+ from prompt_toolkit.document import Document
32
+ from prompt_toolkit.filters import Condition
33
+ from prompt_toolkit.formatted_text import to_formatted_text
34
+ from prompt_toolkit.key_binding import KeyBindings
35
+ from prompt_toolkit.layout import Layout
36
+ from prompt_toolkit.layout.containers import (
37
+ ConditionalContainer,
38
+ Float,
39
+ FloatContainer,
40
+ HSplit,
41
+ VSplit,
42
+ Window,
43
+ WindowAlign,
44
+ )
45
+ from prompt_toolkit.layout.scrollable_pane import ScrollablePane
46
+ from prompt_toolkit.widgets import Frame
47
+ from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
48
+ from prompt_toolkit.formatted_text import split_lines
49
+ from prompt_toolkit.layout.dimension import Dimension as D
50
+ from prompt_toolkit.layout.menus import CompletionsMenu
51
+ from prompt_toolkit.layout.processors import BeforeInput
52
+ from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
53
+ from prompt_toolkit.styles import Style
54
+
55
+ from openhack.agents.coordinator import CoordinatorAgent
56
+ from openhack.agents.llm import LLMClient, Message, LLMResponse
57
+ from openhack.agents.session import Session, SessionStatus, Finding, TraceEntry
58
+ from openhack.config import (
59
+ settings,
60
+ save_user_config,
61
+ load_user_config,
62
+ resolve_provider,
63
+ reload_settings,
64
+ _PROVIDER_KEY_FIELDS,
65
+ )
66
+ from openhack.setup import run_setup_command
67
+ from openhack.tools.registry import ToolRegistry
68
+ from openhack.prompts.project_context import build_project_context
69
+ from openhack.updates import Announcement, UpdateInfo, fetch_updates, save_dismissed
70
+
71
+
72
+ # ── Brand ─────────────────────────────────────────────────────────
73
+
74
+ # OpenHack ground-symbol logo (chunky pixel blocks). All lines are padded to
75
+ # 26 cols so the line's geometric midpoint matches the blocks' visual midpoint
76
+ # (col 13.5) — keeps the "OpenHack" wordmark aligned with the vertical bar.
77
+ _LOGO_WIDTH = 26
78
+ _LOGO_LINES = [line.ljust(_LOGO_WIDTH) for line in [
79
+ " ██",
80
+ " ██",
81
+ " ██",
82
+ " ██",
83
+ " ██",
84
+ " ████████████████████",
85
+ "",
86
+ " ████████████████",
87
+ "",
88
+ " ████████████",
89
+ ]]
90
+
91
+
92
+
93
+ PROVIDER_DEFAULTS = {"openhack": "kimi-k2.5"}
94
+
95
+ CHAT_SYSTEM_PROMPT = (
96
+ "You are OpenHack, a security-focused AI assistant embedded in the OpenHack CLI. "
97
+ "You help users understand vulnerability scan results, explain security concepts, "
98
+ "and advise on remediation. Be concise and direct. "
99
+ "If the user asks you to scan, tell them to use /full-scan or /scan <path>."
100
+ )
101
+
102
+
103
+ def _esc(text: str) -> str:
104
+ return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
105
+
106
+
107
+ # ── Severity coloring ─────────────────────────────────────────────
108
+
109
+ def _sev_style(severity: str) -> str:
110
+ s = (severity or "").lower()
111
+ if s == "critical":
112
+ return "class:sev.critical"
113
+ if s == "high":
114
+ return "class:sev.high"
115
+ if s == "medium":
116
+ return "class:sev.medium"
117
+ if s == "low":
118
+ return "class:sev.low"
119
+ return "class:sev.info"
120
+
121
+
122
+ def _sev_label(severity: str) -> str:
123
+ s = (severity or "").lower()
124
+ return {
125
+ "critical": "CRIT",
126
+ "high": "HIGH",
127
+ "medium": "MED ",
128
+ "low": "LOW ",
129
+ }.get(s, "INFO")
130
+
131
+
132
+ # ── Slash command registry ────────────────────────────────────────
133
+
134
+ _SLASH_COMMANDS = [
135
+ ("/copy", "Copy the selected finding for Codex / Claude Code / OpenCode"),
136
+ ("/logout", "Sign out (clears the saved token — requires confirmation)"),
137
+ ("/verify", "Run sandbox/browser verification on loaded findings (`/verify sandbox` or `/verify browser`)"),
138
+ ("/mouse", "Toggle mouse capture — off lets you drag-to-select text natively"),
139
+ ("/discord", "Open the OpenHack Discord in your browser"),
140
+ ("/scan", "Full scan on a specific directory (defaults to current)"),
141
+ ("/pause", "Pause the running scan (Ctrl+C also pauses)"),
142
+ ("/resume", "Resume a paused scan"),
143
+ ("/cancel", "Cancel the running scan permanently"),
144
+ ("/sessions", "Browse and re-load past scan results"),
145
+ ("/findings", "Re-display findings from last scan"),
146
+ ("/sidebar", "Show/hide the Findings list sidebar (Ctrl+B)"),
147
+ ("/login", "Re-login with OpenHack account (browser flow)"),
148
+ ("/setup", "Interactive setup wizard"),
149
+ ("/config", "Show or set configuration"),
150
+ ("/provider", "Switch provider"),
151
+ ("/model", "Override model ID"),
152
+ ("/cost", "Show cost breakdown from last scan"),
153
+ ("/clear", "Clear scan history (returns to landing)"),
154
+ ("/help", "Show available commands"),
155
+ ("/test", "Run a simulated scan (no LLM)"),
156
+ ("/quit", "Exit"),
157
+ ]
158
+
159
+ _CONFIG_KEYS = [
160
+ ("provider", "LLM provider"),
161
+ ("model", "Model ID override"),
162
+ ("openhack_api_key", "OpenHack API key"),
163
+ ("openhack_model_id", "OpenHack model ID"),
164
+ ]
165
+
166
+ _CANCEL_PHRASES = {
167
+ "cancel", "cancel scan", "cancel the scan",
168
+ "stop", "stop scan", "stop the scan",
169
+ "abort", "abort scan",
170
+ }
171
+
172
+
173
+ class OpenHackCompleter(Completer):
174
+ def get_completions(self, document: Document, complete_event):
175
+ text = document.text_before_cursor
176
+ words = text.split()
177
+
178
+ if not text or (len(words) == 1 and not text.endswith(" ")):
179
+ prefix = text.lstrip()
180
+ if not prefix or prefix.startswith("/"):
181
+ for cmd, desc in _SLASH_COMMANDS:
182
+ if cmd.startswith(prefix):
183
+ yield Completion(cmd, start_position=-len(prefix), display_meta=desc)
184
+ return
185
+
186
+ # Whitespace-only input (no actual words) — nothing to complete against.
187
+ if not words:
188
+ return
189
+
190
+ cmd = words[0].lower()
191
+
192
+ if cmd == "/config":
193
+ if len(words) == 1 and text.endswith(" "):
194
+ for key, desc in _CONFIG_KEYS:
195
+ yield Completion(key, display_meta=desc)
196
+ elif len(words) == 2 and not text.endswith(" "):
197
+ partial = words[1]
198
+ for key, desc in _CONFIG_KEYS:
199
+ if key.startswith(partial):
200
+ yield Completion(key, start_position=-len(partial), display_meta=desc)
201
+ elif cmd == "/scan":
202
+ partial = words[-1] if len(words) > 1 and not text.endswith(" ") else ""
203
+ base = partial or "."
204
+ try:
205
+ base_path = Path(base)
206
+ if base_path.is_dir():
207
+ parent = base_path
208
+ prefix = ""
209
+ else:
210
+ parent = base_path.parent if base_path.parent.is_dir() else Path(".")
211
+ prefix = base_path.name
212
+ for child in sorted(parent.iterdir()):
213
+ if child.name.startswith(".") or not child.is_dir():
214
+ continue
215
+ if prefix and not child.name.startswith(prefix):
216
+ continue
217
+ yield Completion(str(child) + "/", start_position=-len(partial))
218
+ except OSError:
219
+ pass
220
+
221
+
222
+ # ── Agent/finding state derived from trace entries ────────────────
223
+
224
+ # Status icons:
225
+ # ◌ pending (not yet started)
226
+ # ● running (current step is this agent)
227
+ # ▸ working (mid-task)
228
+ # ✓ complete
229
+ # ✗ failed / cancelled
230
+
231
+ _STATUS_PENDING = ("◌", "class:status.pending")
232
+ _STATUS_RUNNING = ("●", "class:status.running")
233
+ _STATUS_WORKING = ("▸", "class:status.working")
234
+ _STATUS_DONE = ("✓", "class:status.done")
235
+ _STATUS_FAIL = ("✗", "class:status.fail")
236
+
237
+
238
+ class _AgentRow:
239
+ __slots__ = ("name", "status", "detail")
240
+
241
+ def __init__(self, name: str, status: tuple[str, str], detail: str = ""):
242
+ self.name = name
243
+ self.status = status
244
+ self.detail = detail
245
+
246
+
247
+ class ScanState:
248
+ """Derived UI state for an in-progress scan."""
249
+
250
+ def __init__(self, target: str):
251
+ self.target = target
252
+ self.start_time = time.time()
253
+ self.end_time: Optional[float] = None # set when the scan terminates
254
+ self.cost: float = 0.0
255
+ self.current_step: Optional[str] = None
256
+ self.agents: dict[str, _AgentRow] = {}
257
+ self.findings: list[Finding] = []
258
+ self.agent_order: list[str] = []
259
+ self.last_message: str = ""
260
+ # Each rendered trace line carries its source agent so the Trace tab
261
+ # can filter to "show only this agent's events".
262
+ self.trace_lines: list[tuple[str, list[tuple[str, str]]]] = []
263
+ # Unique agents in order of first appearance — drives the trace sidebar.
264
+ self.trace_agents: list[str] = []
265
+
266
+ def _append_trace(self, agent: str, fragments: list[tuple[str, str]]) -> None:
267
+ """Internal: record a rendered trace line with its agent attribution."""
268
+ self.trace_lines.append((agent, fragments))
269
+ if agent and agent not in self.trace_agents:
270
+ self.trace_agents.append(agent)
271
+
272
+ def finish(self) -> None:
273
+ """Freeze the elapsed clock — call when the scan completes/cancels/fails."""
274
+ if self.end_time is None:
275
+ self.end_time = time.time()
276
+
277
+ def elapsed_str(self) -> str:
278
+ endpoint = self.end_time if self.end_time is not None else time.time()
279
+ seconds = int(endpoint - self.start_time)
280
+ m, s = divmod(seconds, 60)
281
+ return f"{m}:{s:02d}" if m else f"0:{s:02d}"
282
+
283
+ def upsert_agent(self, name: str, status: tuple[str, str], detail: str = "") -> None:
284
+ row = self.agents.get(name)
285
+ if row is None:
286
+ self.agents[name] = _AgentRow(name, status, detail)
287
+ self.agent_order.append(name)
288
+ else:
289
+ row.status = status
290
+ if detail:
291
+ row.detail = detail
292
+
293
+ def update_from_trace(self, entry: TraceEntry) -> None:
294
+ agent = entry.agent
295
+ etype = entry.event_type
296
+
297
+ ts = self._ts(entry.timestamp)
298
+
299
+ if etype == "step_start":
300
+ self.current_step = str(entry.content)
301
+ self.last_message = f"step start · {entry.content}"
302
+ self._append_trace(agent, [
303
+ ("class:trace.time", ts),
304
+ ("class:trace.step", f" ── {entry.content} ──"),
305
+ ])
306
+ return
307
+
308
+ if etype == "step_complete":
309
+ data = entry.content if isinstance(entry.content, dict) else {}
310
+ self.cost += float(data.get("cost", 0) or 0)
311
+ self.last_message = f"step complete · {data.get('step', '')}"
312
+ self._append_trace(agent, [
313
+ ("class:trace.time", ts),
314
+ ("class:trace.dim", f" {data.get('step', 'step')} complete · "
315
+ f"${float(data.get('cost', 0)):.4f} · {data.get('tokens', 0):,} tok"),
316
+ ])
317
+ return
318
+
319
+ if etype == "swarm_start":
320
+ data = entry.content if isinstance(entry.content, dict) else {}
321
+ groups = data.get("groups", [])
322
+ base = agent.replace("_swarm", "")
323
+ for g in groups:
324
+ self.upsert_agent(f"{base}:{g}", _STATUS_PENDING, "queued")
325
+ self.last_message = f"{agent} · spawned {len(groups)} sub-agents"
326
+ count = data.get("group_count") or data.get("findings_count") or len(groups)
327
+ self._append_trace(agent, [
328
+ ("class:trace.time", ts),
329
+ ("class:trace.agent", f" {agent}"),
330
+ ("class:trace.dim", f" spawned {count} sub-agents"),
331
+ ])
332
+ return
333
+
334
+ if etype == "swarm_complete":
335
+ data = entry.content if isinstance(entry.content, dict) else {}
336
+ base = agent.replace("_swarm", "")
337
+ for name in list(self.agents):
338
+ if name.startswith(f"{base}:") and self.agents[name].status[0] != "✓":
339
+ self.upsert_agent(name, _STATUS_DONE, "complete")
340
+ cost = data.get("total_cost", 0)
341
+ self.cost += float(cost or 0)
342
+ n = data.get("total_findings") or data.get("total_confirmed") or 0
343
+ self.last_message = f"{agent} · complete"
344
+ self._append_trace(agent, [
345
+ ("class:trace.time", ts),
346
+ ("class:trace.agent", f" {agent}"),
347
+ ("class:trace.dim", f" complete · {n} findings · ${float(cost):.4f}"),
348
+ ])
349
+ return
350
+
351
+ if etype == "tool_call":
352
+ tool = entry.tool_name or "tool"
353
+ args = entry.tool_input or {}
354
+ detail = _short_tool_label(tool, args)
355
+ self.upsert_agent(agent, _STATUS_WORKING, detail)
356
+ self.last_message = f"{agent} · {detail}"
357
+ self._append_trace(agent, [
358
+ ("class:trace.time", ts),
359
+ ("class:trace.agent", f" {agent:>24}"),
360
+ ("class:trace.arrow", " → "),
361
+ ("class:trace.tool", tool),
362
+ ("class:trace.dim", f" {detail}" if detail and detail != tool else ""),
363
+ ])
364
+ return
365
+
366
+ if etype == "tool_result":
367
+ row = self.agents.get(agent)
368
+ if row and row.status[0] == "▸":
369
+ row.status = _STATUS_RUNNING
370
+ return
371
+
372
+ if etype == "thinking":
373
+ self.upsert_agent(agent, _STATUS_RUNNING, "thinking…")
374
+ content_str = str(entry.content or "").strip()
375
+ if content_str:
376
+ # Truncate hard at the source level so a 5-page chain-of-thought
377
+ # doesn't blow up the pane. Render the (possibly truncated)
378
+ # content as markdown — headers, bold, bullets, inline code.
379
+ if len(content_str) > 2000:
380
+ content_str = content_str[:1997] + "…"
381
+ line: list[tuple[str, str]] = [
382
+ ("class:trace.time", ts),
383
+ ("class:trace.agent", f" {agent:>24}"),
384
+ ("class:trace.arrow", " ⋯ "),
385
+ ]
386
+ line.extend(_render_md_prose(content_str))
387
+ self._append_trace(agent, line)
388
+ return
389
+
390
+ if etype == "finding_added":
391
+ data = entry.content if isinstance(entry.content, dict) else {}
392
+ sev = (data.get("severity") or "info").lower()
393
+ title = data.get("title", "")
394
+ file_path = data.get("file_path", "")
395
+ self.last_message = f"finding · {title}"
396
+ self._append_trace(agent, [
397
+ ("class:trace.time", ts),
398
+ ("", " "),
399
+ (_sev_style(sev), f"★ {_sev_label(sev)}"),
400
+ ("", " "),
401
+ ("class:finding.title", title),
402
+ ("class:finding.path", f" {file_path}" if file_path else ""),
403
+ ])
404
+ return
405
+
406
+ if etype == "queued":
407
+ data = entry.content if isinstance(entry.content, dict) else {}
408
+ title = data.get("title", "")
409
+ self.upsert_agent(agent, _STATUS_PENDING, "queued")
410
+ self.last_message = f"{agent} · queued"
411
+ self._append_trace(agent, [
412
+ ("class:trace.time", ts),
413
+ ("class:trace.agent", f" {agent:>24}"),
414
+ ("class:trace.dim", f" queued · {title}" if title else " queued"),
415
+ ])
416
+ return
417
+
418
+ if etype == "sandbox_starting":
419
+ msg = str(entry.content or "starting sandbox…")
420
+ self.last_message = f"{agent} · starting sandbox"
421
+ self._append_trace(agent, [
422
+ ("class:trace.time", ts),
423
+ ("class:trace.agent", f" {agent}"),
424
+ ("class:trace.dim", f" {msg}"),
425
+ ])
426
+ return
427
+
428
+ if etype == "sandbox_ready":
429
+ data = entry.content if isinstance(entry.content, dict) else {}
430
+ url = data.get("base_url", "")
431
+ self.last_message = f"sandbox ready · {url}"
432
+ self._append_trace(agent, [
433
+ ("class:trace.time", ts),
434
+ ("class:trace.agent", f" {agent}"),
435
+ ("class:trace.dim", f" sandbox ready · {url}"),
436
+ ])
437
+ return
438
+
439
+ if etype == "error":
440
+ msg = str(entry.content or "error")
441
+ self.upsert_agent(agent, _STATUS_FAIL, msg[:60])
442
+ self.last_message = f"{agent} · error"
443
+ self._append_trace(agent, [
444
+ ("class:trace.time", ts),
445
+ ("class:trace.agent", f" {agent:>24}"),
446
+ ("class:trace.arrow", " ✗ "),
447
+ ("class:status.fail", msg[:200]),
448
+ ])
449
+ return
450
+
451
+ if etype == "skipped":
452
+ msg = str(entry.content or "skipped")
453
+ self.upsert_agent(agent, _STATUS_FAIL, "skipped")
454
+ self._append_trace(agent, [
455
+ ("class:trace.time", ts),
456
+ ("class:trace.agent", f" {agent:>24}"),
457
+ ("class:trace.dim", f" skipped · {msg[:120]}"),
458
+ ])
459
+ return
460
+
461
+ if etype == "swarm_aborted":
462
+ msg = str(entry.content or "aborted")
463
+ self.last_message = f"{agent} · aborted"
464
+ self._append_trace(agent, [
465
+ ("class:trace.time", ts),
466
+ ("class:trace.agent", f" {agent}"),
467
+ ("class:trace.arrow", " ✗ "),
468
+ ("class:status.fail", msg[:200]),
469
+ ])
470
+ return
471
+
472
+ if etype == "sandbox_teardown":
473
+ msg = str(entry.content or "stopping sandbox")
474
+ self.last_message = f"{agent} · teardown"
475
+ self._append_trace(agent, [
476
+ ("class:trace.time", ts),
477
+ ("class:trace.agent", f" {agent}"),
478
+ ("class:trace.dim", f" {msg}"),
479
+ ])
480
+ return
481
+
482
+ def _ts(self, t: float) -> str:
483
+ rel = max(0, int(t - self.start_time))
484
+ m, s = divmod(rel, 60)
485
+ return f"[{m}:{s:02d}]"
486
+
487
+
488
+ # ── Syntax highlighting ───────────────────────────────────────────
489
+
490
+ def _highlight_code(code: str, file_path: str = "") -> list[tuple[str, str]]:
491
+ """Tokenize *code* with Pygments and return prompt_toolkit fragments."""
492
+ if not code:
493
+ return []
494
+ try:
495
+ from pygments.lexers import get_lexer_for_filename, guess_lexer
496
+ from pygments.token import Token
497
+ from pygments.util import ClassNotFound
498
+ except ImportError:
499
+ return [("class:code", code)]
500
+
501
+ lexer = None
502
+ if file_path:
503
+ try:
504
+ lexer = get_lexer_for_filename(file_path)
505
+ except ClassNotFound:
506
+ lexer = None
507
+ if lexer is None:
508
+ try:
509
+ lexer = guess_lexer(code)
510
+ except Exception:
511
+ return [("class:code", code)]
512
+
513
+ def style_for(token) -> str:
514
+ if token in Token.Comment:
515
+ return "class:syntax.comment"
516
+ if token in Token.String:
517
+ return "class:syntax.string"
518
+ if token in Token.Keyword:
519
+ return "class:syntax.keyword"
520
+ if token in Token.Name.Builtin:
521
+ return "class:syntax.builtin"
522
+ if token in Token.Name.Function:
523
+ return "class:syntax.function"
524
+ if token in Token.Name.Class:
525
+ return "class:syntax.class"
526
+ if token in Token.Name.Decorator:
527
+ return "class:syntax.decorator"
528
+ if token in Token.Number:
529
+ return "class:syntax.number"
530
+ if token in Token.Operator:
531
+ return "class:syntax.operator"
532
+ return "class:code"
533
+
534
+ return [(style_for(tok), text) for tok, text in lexer.get_tokens(code)]
535
+
536
+
537
+ class _ScrollableFormattedTextControl(FormattedTextControl):
538
+ """A FormattedTextControl that *always* catches scroll-wheel events and
539
+ forwards them to a callback. Used by the details pane so mouse wheel
540
+ scrolling fires reliably regardless of which fragment is hovered.
541
+ """
542
+
543
+ def __init__(self, *args, on_scroll=None, on_event=None, **kwargs):
544
+ super().__init__(*args, **kwargs)
545
+ self._on_scroll = on_scroll
546
+ self._on_event = on_event # called for *any* event — used for debug
547
+
548
+ def mouse_handler(self, mouse_event: MouseEvent): # type: ignore[override]
549
+ if self._on_event is not None:
550
+ try:
551
+ self._on_event(mouse_event)
552
+ except Exception:
553
+ pass
554
+ if self._on_scroll is not None:
555
+ if mouse_event.event_type == MouseEventType.SCROLL_DOWN:
556
+ self._on_scroll(+3)
557
+ return None
558
+ if mouse_event.event_type == MouseEventType.SCROLL_UP:
559
+ self._on_scroll(-3)
560
+ return None
561
+ return super().mouse_handler(mouse_event)
562
+
563
+
564
+ def _section_header(label: str) -> list[tuple[str, str]]:
565
+ """An open-bottom 'box top' that visually demarcates a section."""
566
+ width = 78
567
+ prefix = f"┌─ {label} "
568
+ pad = max(0, width - len(prefix) - 1)
569
+ return [
570
+ ("class:section.box", prefix),
571
+ ("class:section.box", "─" * pad),
572
+ ("class:section.box", "┐\n"),
573
+ ]
574
+
575
+
576
+ def _highlight_code_by_lang(code: str, lang: str, fallback_file: str = "") -> list[tuple[str, str]]:
577
+ """Tokenize code with Pygments using a language name; fall back to file-based detection."""
578
+ try:
579
+ from pygments.lexers import get_lexer_by_name
580
+ from pygments.token import Token
581
+ from pygments.util import ClassNotFound
582
+ except ImportError:
583
+ return [("class:code", code)]
584
+ try:
585
+ lexer = get_lexer_by_name(lang)
586
+ except ClassNotFound:
587
+ return _highlight_code(code, fallback_file)
588
+
589
+ def style_for(tok):
590
+ if tok in Token.Comment: return "class:syntax.comment"
591
+ if tok in Token.String: return "class:syntax.string"
592
+ if tok in Token.Keyword: return "class:syntax.keyword"
593
+ if tok in Token.Name.Builtin: return "class:syntax.builtin"
594
+ if tok in Token.Name.Function: return "class:syntax.function"
595
+ if tok in Token.Name.Class: return "class:syntax.class"
596
+ if tok in Token.Name.Decorator: return "class:syntax.decorator"
597
+ if tok in Token.Number: return "class:syntax.number"
598
+ if tok in Token.Operator: return "class:syntax.operator"
599
+ return "class:code"
600
+
601
+ return [(style_for(tok), t) for tok, t in lexer.get_tokens(code)]
602
+
603
+
604
+ # Inline markdown patterns: **bold**, *italic*, _italic_, `code`, [link](url)
605
+ _MD_INLINE_RE = __import__("re").compile(
606
+ r"(\*\*[^*\n]+\*\*)"
607
+ r"|(`[^`\n]+`)"
608
+ r"|(\*(?!\s)[^*\n]+?\*)"
609
+ r"|(_(?!\s)[^_\n]+?_)"
610
+ r"|(\[[^\]\n]+\]\([^)\s]+\))"
611
+ )
612
+
613
+
614
+ def _render_md_inline(text: str) -> list[tuple[str, str]]:
615
+ """Render inline markdown — bold/italic/code/links — into styled fragments."""
616
+ import re as _re
617
+ out: list[tuple[str, str]] = []
618
+ pos = 0
619
+ for m in _MD_INLINE_RE.finditer(text):
620
+ if m.start() > pos:
621
+ out.append(("", text[pos:m.start()]))
622
+ token = m.group(0)
623
+ if token.startswith("**") and token.endswith("**"):
624
+ out.append(("class:md.bold", token[2:-2]))
625
+ elif token.startswith("`") and token.endswith("`"):
626
+ out.append(("class:md.code", token[1:-1]))
627
+ elif token.startswith("*") and token.endswith("*"):
628
+ out.append(("class:md.italic", token[1:-1]))
629
+ elif token.startswith("_") and token.endswith("_"):
630
+ out.append(("class:md.italic", token[1:-1]))
631
+ elif token.startswith("["):
632
+ link_m = _re.match(r"\[([^\]]+)\]\(([^)]+)\)", token)
633
+ if link_m:
634
+ out.append(("class:md.link", link_m.group(1)))
635
+ else:
636
+ out.append(("", token))
637
+ pos = m.end()
638
+ if pos < len(text):
639
+ out.append(("", text[pos:]))
640
+ return out
641
+
642
+
643
+ def _render_md_prose(text: str) -> list[tuple[str, str]]:
644
+ """Render a chunk of markdown prose (no code fences) into styled fragments."""
645
+ import re as _re
646
+ out: list[tuple[str, str]] = []
647
+ lines = text.split("\n")
648
+ for idx, line in enumerate(lines):
649
+ # ATX headers: #, ##, ###, ...
650
+ m_h = _re.match(r"^(#{1,6})\s+(.*)$", line)
651
+ if m_h:
652
+ level = len(m_h.group(1))
653
+ content = m_h.group(2).strip()
654
+ style = (
655
+ "class:md.h1" if level == 1
656
+ else "class:md.h2" if level == 2
657
+ else "class:md.h3"
658
+ )
659
+ out.append((style, content))
660
+ # Horizontal rule
661
+ elif _re.match(r"^[-*_]{3,}\s*$", line):
662
+ out.append(("class:rule", "─" * 60))
663
+ # Bullet list
664
+ elif (m_b := _re.match(r"^(\s*)[-*+]\s+(.*)$", line)):
665
+ out.append(("", m_b.group(1)))
666
+ out.append(("class:md.bullet", "• "))
667
+ out.extend(_render_md_inline(m_b.group(2)))
668
+ # Numbered list
669
+ elif (m_n := _re.match(r"^(\s*)(\d+)\.\s+(.*)$", line)):
670
+ out.append(("", m_n.group(1)))
671
+ out.append(("class:md.bullet", f"{m_n.group(2)}. "))
672
+ out.extend(_render_md_inline(m_n.group(3)))
673
+ # Blockquote
674
+ elif (m_q := _re.match(r"^>\s?(.*)$", line)):
675
+ out.append(("class:md.quote", "│ "))
676
+ out.extend(_render_md_inline(m_q.group(1)))
677
+ # Regular line
678
+ else:
679
+ out.extend(_render_md_inline(line))
680
+ # Preserve newlines between lines
681
+ if idx < len(lines) - 1:
682
+ out.append(("", "\n"))
683
+ return out
684
+
685
+
686
+ def _render_markdown_with_code(text: str, default_file: str = "") -> list[tuple[str, str]]:
687
+ """Render markdown text. Code fences are syntax-highlighted; prose handles
688
+ headers, bold, italic, inline code, lists, blockquotes, and horizontal rules."""
689
+ import re as _re
690
+ if not text:
691
+ return []
692
+ fragments: list[tuple[str, str]] = []
693
+ pattern = _re.compile(r"```([a-zA-Z0-9_+\-]*)\n(.*?)```", _re.DOTALL)
694
+ last_end = 0
695
+ for m in pattern.finditer(text):
696
+ prose = text[last_end:m.start()]
697
+ if prose:
698
+ fragments.extend(_render_md_prose(prose))
699
+ lang = m.group(1).strip()
700
+ code = m.group(2)
701
+ if lang:
702
+ fragments.extend(_highlight_code_by_lang(code, lang, default_file))
703
+ else:
704
+ fragments.extend(_highlight_code(code, default_file))
705
+ last_end = m.end()
706
+ if last_end < len(text):
707
+ fragments.extend(_render_md_prose(text[last_end:]))
708
+ return fragments
709
+
710
+
711
+ def _short_tool_label(tool: str, args: dict) -> str:
712
+ path = args.get("path", "")
713
+ pattern = args.get("pattern", "")
714
+ # Paths are already relative to the project root (tools are rooted at
715
+ # target_dir), so we surface them verbatim in the trace.
716
+ if tool == "read_file" and path:
717
+ return f"read {path}"
718
+ if tool == "list_dir":
719
+ return f"ls {path}" if path else "ls ."
720
+ if tool == "glob" and pattern:
721
+ scope = f" in {path}" if path else ""
722
+ return f"glob {pattern}{scope}"
723
+ if tool == "grep":
724
+ p = pattern[:24] + "…" if len(pattern) > 24 else pattern
725
+ scope = f" in {path}" if path else ""
726
+ return f"grep /{p}/{scope}"
727
+ if tool == "get_project_info":
728
+ return "project info"
729
+ if tool == "get_route_map":
730
+ return "route map"
731
+ if tool == "extract_functions" and path:
732
+ return f"extract functions from {path}"
733
+ if tool == "find_dangerous_patterns" and path:
734
+ return f"find dangerous patterns in {path}"
735
+ if tool == "trace_variable":
736
+ var = args.get("variable_name", "")
737
+ return f"trace {var} in {path}" if var else f"trace variable in {path}"
738
+ if tool == "report_finding":
739
+ cat = args.get("category", "")
740
+ fp = args.get("file_path", "")
741
+ if cat and fp:
742
+ return f"report {cat} in {fp}"
743
+ return f"report {cat}" if cat else "report finding"
744
+ if tool == "validate_finding":
745
+ return f"validate {args.get('status', '')}"
746
+ if tool == "finish_hunt":
747
+ return "finish hunt"
748
+ if tool == "finish_validation":
749
+ return "finish validation"
750
+ if path:
751
+ return f"{tool} {path}"
752
+ return tool
753
+
754
+
755
+ # ── App ───────────────────────────────────────────────────────────
756
+
757
+ class OpenHackApp:
758
+ """Full-screen prompt_toolkit application driving the OpenHack TUI."""
759
+
760
+ def __init__(self) -> None:
761
+ cfg = load_user_config()
762
+ self.provider = resolve_provider(cfg.get("provider", settings.llm_provider))
763
+ self.model = cfg.get("model") or PROVIDER_DEFAULTS.get(self.provider, settings.openhack_model_id)
764
+ self.org_name: str = cfg.get("openhack_org_name") or ""
765
+ self.user_email: str = "" # populated lazily
766
+
767
+ self.mode: str = "landing" # "landing" | "scanning" | "viewing" | "sessions"
768
+ self.previous_mode: Optional[str] = None # set when entering "sessions" so Esc can return
769
+ self.active_tab: str = "trace" # "trace" | "findings" — sessions is its own mode now
770
+ self.scan: Optional[ScanState] = None
771
+ self.session: Optional[Session] = None
772
+ self.scan_task: Optional[asyncio.Task] = None
773
+ self.last_status_line: str = ""
774
+ self.last_findings: list[Finding] = [] # findings from most recent scan
775
+ self.last_session: Optional[Session] = None
776
+ self.chat_history: list[Message] = []
777
+ self._cancel_armed = False
778
+ # Sessions tab state
779
+ self.sessions_index: list[dict] = []
780
+ self.sessions_selected: int = 0
781
+ self.viewing_target: str = "" # header label when in "viewing" mode
782
+ # Findings tab selection (split pane: list left, details right)
783
+ self.findings_selected: int = 0
784
+ self.findings_list_hidden: bool = False # toggle the left list via Ctrl+B / /sidebar
785
+ # macOS terminals sometimes emit BOTH a mouse SCROLL event AND an
786
+ # arrow-key event for a single trackpad gesture. Track when the last
787
+ # mouse scroll happened so the arrow-key handler can stand down.
788
+ self._last_scroll_at: float = 0.0
789
+ # /logout uses a two-press confirmation; flag is reset on any other action.
790
+ self._logout_armed: bool = False
791
+ # /verify also uses two-press confirmation for the "enable" path so the
792
+ # user reads the warning about prereqs. Stores which subject is armed.
793
+ self._verify_arm_subject: Optional[str] = None # 'sandbox' | 'browser' | 'all' | None
794
+ # Mouse capture state. When True, prompt_toolkit consumes every mouse
795
+ # event (so wheel-scroll + click-to-select work) but the terminal's
796
+ # native drag-to-select-text is blocked. /mouse toggles this.
797
+ self._mouse_enabled: bool = True
798
+ # Centered modal-dialog state. None = no modal; otherwise a key
799
+ # identifying which one to render (e.g. 'verify:sandbox', 'logout').
800
+ self._modal_kind: Optional[str] = None
801
+ self._modal_title: str = ""
802
+ self._modal_body: str = ""
803
+ self._modal_on_yes: Optional[Any] = None # callable invoked on 'y' / Enter
804
+ # Manual scroll offset for the details pane (in logical lines).
805
+ # We bypass Window.vertical_scroll because prompt_toolkit's render
806
+ # was clamping it back to 0 in our setup — instead we clip the
807
+ # fragment list ourselves in details_text().
808
+ self._details_scroll: int = 0
809
+ # Findings sidebar width as a percentage of the Findings tab width.
810
+ # The sibling Dimensions (built in _build_layout) hold weights that
811
+ # we mutate to resize live.
812
+ self._sidebar_pct: int = 35
813
+ # Trace pane scroll. _trace_follow=True means stick to the bottom as
814
+ # new events stream in; flipped off when the user scrolls up to read
815
+ # history, flipped back on when they scroll back to bottom.
816
+ self._trace_scroll: int = 0
817
+ self._trace_follow: bool = True
818
+ # Trace sidebar: 0 = "All", 1+ = scan.trace_agents[idx-1]
819
+ self._trace_agent_idx: int = 0
820
+ # Update/announcement state — populated asynchronously on startup.
821
+ self._update_info: Optional[UpdateInfo] = None
822
+
823
+ self.input_buffer = Buffer(
824
+ multiline=False,
825
+ completer=OpenHackCompleter(),
826
+ complete_while_typing=True,
827
+ accept_handler=self._on_buffer_accept,
828
+ )
829
+
830
+ self.kb = self._build_keybindings()
831
+ self.layout = self._build_layout()
832
+ self.style = self._build_style()
833
+
834
+ self.app: Application = Application(
835
+ layout=self.layout,
836
+ key_bindings=self.kb,
837
+ style=self.style,
838
+ full_screen=True,
839
+ # Filter-driven so /mouse can toggle native copy on demand.
840
+ # When False, the terminal's built-in drag-to-select works.
841
+ mouse_support=Condition(lambda: self._mouse_enabled),
842
+ erase_when_done=True,
843
+ )
844
+
845
+ # ── Keybindings ───────────────────────────────────────────────
846
+
847
+ def _build_keybindings(self) -> KeyBindings:
848
+ kb = KeyBindings()
849
+
850
+ @kb.add("c-c")
851
+ def _ctrl_c(event):
852
+ # Behavior:
853
+ # • Scan running, not paused → pause (state preserved)
854
+ # • Scan paused → exit TUI (scan stays as 'running'
855
+ # with dead PID → reclassified as
856
+ # 'aborted' next launch; resume with
857
+ # 'r' in /sessions)
858
+ # • No scan running → exit TUI
859
+ if self.mode == "scanning" and self.session is not None:
860
+ if self.session.paused:
861
+ event.app.exit()
862
+ else:
863
+ self.session.pause()
864
+ self.last_status_line = (
865
+ "scan paused · Ctrl+C again to exit · /resume to continue · /cancel to stop"
866
+ )
867
+ self._invalidate()
868
+ else:
869
+ event.app.exit()
870
+
871
+ @kb.add("c-d")
872
+ def _ctrl_d(event):
873
+ if not self.input_buffer.text:
874
+ event.app.exit()
875
+
876
+ # Modal-dialog keys (eager so they take priority over text input
877
+ # while a modal is open — typing 'y' goes to the dialog, not the
878
+ # input box).
879
+ modal_open = Condition(lambda: self._modal_kind is not None)
880
+
881
+ @kb.add("y", filter=modal_open, eager=True)
882
+ @kb.add("Y", filter=modal_open, eager=True)
883
+ @kb.add("enter", filter=modal_open, eager=True)
884
+ def _modal_yes(event):
885
+ cb = self._modal_on_yes
886
+ self._close_modal()
887
+ if cb is not None:
888
+ try:
889
+ cb()
890
+ except Exception as exc:
891
+ self.last_status_line = f"action failed: {exc}"
892
+ self._invalidate()
893
+
894
+ @kb.add("n", filter=modal_open, eager=True)
895
+ @kb.add("N", filter=modal_open, eager=True)
896
+ @kb.add("escape", filter=modal_open, eager=True)
897
+ def _modal_no(event):
898
+ self._close_modal()
899
+ self.last_status_line = "cancelled"
900
+ self._invalidate()
901
+
902
+ def _completion_open() -> bool:
903
+ return self.input_buffer.complete_state is not None
904
+
905
+ @kb.add("escape", eager=True, filter=Condition(_completion_open))
906
+ def _escape_completion(event):
907
+ event.current_buffer.cancel_completion()
908
+
909
+ @kb.add("escape", eager=False, filter=~Condition(_completion_open))
910
+ def _escape(event):
911
+ pass
912
+
913
+ # Option+Shift+Left/Right — select word (macOS sends Escape + ShiftLeft/Right)
914
+ @kb.add("escape", "s-left")
915
+ def _select_word_left(event):
916
+ buf = event.current_buffer
917
+ pos = buf.document.find_previous_word_beginning() or 0
918
+ buf.cursor_position += pos
919
+ buf.start_selection()
920
+ # Already moved — selection is from new pos to old pos
921
+ # Re-do: move back, start selection, then move
922
+ buf.cursor_position -= pos
923
+ buf.start_selection()
924
+ buf.cursor_position += pos
925
+
926
+ @kb.add("escape", "s-right")
927
+ def _select_word_right(event):
928
+ buf = event.current_buffer
929
+ pos = buf.document.find_next_word_ending() or 0
930
+ buf.start_selection()
931
+ buf.cursor_position += pos
932
+
933
+ # Tab navigation — only in scanning/viewing modes, and only when the
934
+ # input is empty so they don't conflict with typing.
935
+ def _in_tabs() -> bool:
936
+ return self.mode in ("scanning", "viewing")
937
+
938
+ def _in_sessions() -> bool:
939
+ return self.mode == "sessions"
940
+
941
+ def _input_empty() -> bool:
942
+ return not self.input_buffer.text
943
+
944
+ @kb.add("c-t")
945
+ def _ctrl_t(event):
946
+ if _in_tabs():
947
+ self._cycle_tab(+1)
948
+
949
+ @kb.add("right", filter=Condition(lambda: _in_tabs() and _input_empty()))
950
+ def _right(event):
951
+ self._cycle_tab(+1)
952
+
953
+ @kb.add("left", filter=Condition(lambda: _in_tabs() and _input_empty()))
954
+ def _left(event):
955
+ self._cycle_tab(-1)
956
+
957
+ for i, name in enumerate(("trace", "findings"), 1):
958
+ @kb.add(str(i), filter=Condition(lambda: _in_tabs() and _input_empty()))
959
+ def _digit(event, _name=name):
960
+ self.active_tab = _name
961
+ self._invalidate()
962
+
963
+ # Findings list navigation (Findings tab + empty input).
964
+ def _on_findings() -> bool:
965
+ return _in_tabs() and self.active_tab == "findings" and _input_empty()
966
+
967
+ def _reset_details_scroll() -> None:
968
+ self._details_scroll = 0
969
+
970
+ def _move_selection(delta: int) -> None:
971
+ # If a mouse scroll fired in the last 400ms, this arrow key is
972
+ # almost certainly the paired event from a Mac trackpad gesture —
973
+ # not a deliberate keyboard press to switch findings. Stand down.
974
+ if time.monotonic() - self._last_scroll_at < 0.4:
975
+ return
976
+ n = len(self._current_findings())
977
+ if n == 0:
978
+ return
979
+ new_idx = max(0, min(n - 1, self.findings_selected + delta))
980
+ if new_idx != self.findings_selected:
981
+ self.findings_selected = new_idx
982
+ _reset_details_scroll()
983
+ self._invalidate()
984
+
985
+ def _scroll_details(delta: int) -> None:
986
+ self._details_scroll = max(0, self._details_scroll + delta)
987
+ self._invalidate()
988
+
989
+ # Up / Down switch findings (keyboard nav). [ and ] are aliases.
990
+ @kb.add("up", filter=Condition(_on_findings))
991
+ def _f_up(event):
992
+ _move_selection(-1)
993
+
994
+ @kb.add("down", filter=Condition(_on_findings))
995
+ def _f_down(event):
996
+ _move_selection(+1)
997
+
998
+ @kb.add("[", filter=Condition(_on_findings))
999
+ def _f_prev(event):
1000
+ _move_selection(-1)
1001
+
1002
+ @kb.add("]", filter=Condition(_on_findings))
1003
+ def _f_next(event):
1004
+ _move_selection(+1)
1005
+
1006
+ # 'y' = yank the finding as an AI-agent prompt to the system clipboard.
1007
+ @kb.add("y", filter=Condition(_on_findings))
1008
+ def _f_yank(event):
1009
+ self._cmd_copy_fix()
1010
+
1011
+ # < / > resize the sidebar / details split by 5% steps.
1012
+ def _resize_sidebar(delta_pct: int) -> None:
1013
+ self._sidebar_pct = max(15, min(75, self._sidebar_pct + delta_pct))
1014
+ self._sidebar_dim.weight = self._sidebar_pct
1015
+ self._details_dim.weight = 100 - self._sidebar_pct
1016
+ self.last_status_line = f"sidebar {self._sidebar_pct}% · details {100 - self._sidebar_pct}%"
1017
+ self._invalidate()
1018
+
1019
+ @kb.add("<", filter=Condition(_on_findings))
1020
+ def _f_shrink(event):
1021
+ _resize_sidebar(-5)
1022
+
1023
+ @kb.add(">", filter=Condition(_on_findings))
1024
+ def _f_grow(event):
1025
+ _resize_sidebar(+5)
1026
+
1027
+ # Trace tab scrolling — keyboard fallbacks for the mouse wheel.
1028
+ def _on_trace() -> bool:
1029
+ return _in_tabs() and self.active_tab == "trace" and _input_empty()
1030
+
1031
+ # Up / Down → navigate the trace sidebar (agent picker).
1032
+ # PgUp / PgDn → scroll the trace content (was Up/Down before).
1033
+ def _move_trace_agent(delta: int) -> None:
1034
+ if self.scan is None:
1035
+ return
1036
+ # Total entries = "All" + however many agents the tree shows.
1037
+ # Compute by counting trace_agents (which is what the tree is built
1038
+ # from) — works for both the flat sidebar and the tree variant.
1039
+ n_entries = 1 + len(self.scan.trace_agents)
1040
+ if n_entries <= 1:
1041
+ return
1042
+ self._trace_agent_idx = max(0, min(n_entries - 1, self._trace_agent_idx + delta))
1043
+ self._trace_scroll = 0
1044
+ self._trace_follow = True
1045
+ self._invalidate()
1046
+
1047
+ @kb.add("up", filter=Condition(_on_trace))
1048
+ def _t_up(event):
1049
+ _move_trace_agent(-1)
1050
+
1051
+ @kb.add("down", filter=Condition(_on_trace))
1052
+ def _t_down(event):
1053
+ _move_trace_agent(+1)
1054
+
1055
+ @kb.add("[", filter=Condition(_on_trace))
1056
+ def _t_prev_agent(event):
1057
+ _move_trace_agent(-1)
1058
+
1059
+ @kb.add("]", filter=Condition(_on_trace))
1060
+ def _t_next_agent(event):
1061
+ _move_trace_agent(+1)
1062
+
1063
+ # PgUp / PgDn scroll the trace content (was Up/Down before).
1064
+ @kb.add("pageup", filter=Condition(_on_trace))
1065
+ def _t_pgup(event):
1066
+ self._scroll_trace_by(-12)
1067
+
1068
+ @kb.add("pagedown", filter=Condition(_on_trace))
1069
+ def _t_pgdn(event):
1070
+ self._scroll_trace_by(+12)
1071
+
1072
+ @kb.add("home", filter=Condition(_on_trace))
1073
+ def _t_home(event):
1074
+ # Home: jump to "All" + reset trace to top.
1075
+ self._trace_agent_idx = 0
1076
+ self._trace_follow = False
1077
+ self._trace_scroll = 0
1078
+ self._invalidate()
1079
+
1080
+ @kb.add("end", filter=Condition(_on_trace))
1081
+ def _t_end(event):
1082
+ # End: stay on current agent filter, jump trace to bottom.
1083
+ self._trace_follow = True
1084
+ self._invalidate()
1085
+
1086
+ # Mouse wheel over the right pane is the primary scroll mechanism.
1087
+ # Keyboard fallbacks below work when the input box is empty so they
1088
+ # don't conflict with typing.
1089
+ @kb.add("pageup", filter=Condition(_on_findings))
1090
+ def _details_pgup(event):
1091
+ _scroll_details(-12)
1092
+
1093
+ @kb.add("pagedown", filter=Condition(_on_findings))
1094
+ def _details_pgdn(event):
1095
+ _scroll_details(+12)
1096
+
1097
+ @kb.add("home", filter=Condition(_on_findings))
1098
+ def _f_home(event):
1099
+ _reset_details_scroll()
1100
+ self._invalidate()
1101
+
1102
+ @kb.add("end", filter=Condition(_on_findings))
1103
+ def _f_end(event):
1104
+ _scroll_details(+10_000)
1105
+
1106
+ # Ctrl+B toggles the sidebar (left findings list) — global shortcut,
1107
+ # only meaningful on the Findings tab but harmless elsewhere.
1108
+ @kb.add("c-b", filter=Condition(lambda: _in_tabs() and _input_empty()))
1109
+ def _toggle_sidebar(event):
1110
+ self.findings_list_hidden = not self.findings_list_hidden
1111
+ self.last_status_line = "sidebar hidden" if self.findings_list_hidden else "sidebar shown"
1112
+ self._invalidate()
1113
+
1114
+ # Sessions overlay keybindings.
1115
+ @kb.add("up", filter=Condition(lambda: _in_sessions() and _input_empty()))
1116
+ def _up(event):
1117
+ if self.sessions_index:
1118
+ self.sessions_selected = max(0, self.sessions_selected - 1)
1119
+ self._invalidate()
1120
+
1121
+ @kb.add("down", filter=Condition(lambda: _in_sessions() and _input_empty()))
1122
+ def _down(event):
1123
+ if self.sessions_index:
1124
+ self.sessions_selected = min(len(self.sessions_index) - 1, self.sessions_selected + 1)
1125
+ self._invalidate()
1126
+
1127
+ @kb.add("enter", filter=Condition(lambda: _in_sessions() and _input_empty()))
1128
+ def _enter(event):
1129
+ self._load_selected_session()
1130
+
1131
+ @kb.add("r", filter=Condition(lambda: _in_sessions() and _input_empty()))
1132
+ def _resume(event):
1133
+ self._resume_selected_session()
1134
+
1135
+ @kb.add("escape", eager=True, filter=Condition(lambda: _in_sessions() and _input_empty()))
1136
+ def _esc_sessions(event):
1137
+ self._close_sessions_overlay()
1138
+
1139
+ return kb
1140
+
1141
+ def _cycle_tab(self, direction: int) -> None:
1142
+ order = ["trace", "findings"]
1143
+ try:
1144
+ idx = order.index(self.active_tab)
1145
+ except ValueError:
1146
+ idx = 0
1147
+ self.active_tab = order[(idx + direction) % len(order)]
1148
+ self._invalidate()
1149
+
1150
+ # ── Style ─────────────────────────────────────────────────────
1151
+
1152
+ def _build_style(self) -> Style:
1153
+ return Style.from_dict({
1154
+ "logo": "bold ansiwhite",
1155
+ "wordmark": "bold ansiwhite",
1156
+ "tagline": "ansigray",
1157
+ "tip": "ansigray italic",
1158
+ "tip.label": "ansiyellow",
1159
+ "footer": "ansigray",
1160
+ "input.box": "",
1161
+ "input.prompt": "bold ansibrightgreen",
1162
+ "input.placeholder": "ansigray italic",
1163
+ "hint": "ansigray",
1164
+ "hint.key": "bold ansiwhite",
1165
+ "rule": "ansigray",
1166
+ "header.brand": "bold ansicyan",
1167
+ "header.sep": "ansigray",
1168
+ "header.target": "bold ansiwhite",
1169
+ "header.meta": "ansigray",
1170
+ "tab.active": "bold reverse ansicyan",
1171
+ "tab.inactive": "ansigray",
1172
+ "tab.key": "bold ansiwhite",
1173
+ "pane.title": "bold ansiwhite",
1174
+ "pane.empty": "ansigray italic",
1175
+ "pane.dim": "ansigray",
1176
+ "verified": "bold ansigreen",
1177
+ "status.pending": "ansigray",
1178
+ "status.running": "bold ansicyan",
1179
+ "status.working": "bold ansiyellow",
1180
+ "status.done": "bold ansigreen",
1181
+ "status.fail": "bold ansired",
1182
+ "agent.name": "ansiwhite",
1183
+ "agent.detail": "ansigray",
1184
+ "sev.critical": "bold reverse ansired",
1185
+ "sev.high": "bold ansired",
1186
+ "sev.medium": "bold ansiyellow",
1187
+ "sev.low": "bold ansiblue",
1188
+ "sev.info": "ansigray",
1189
+ "finding.title": "ansiwhite",
1190
+ "finding.path": "ansigray",
1191
+ "finding.cursor": "bold reverse ansicyan",
1192
+ "trace.time": "ansigray",
1193
+ "trace.agent": "ansicyan",
1194
+ "trace.arrow": "ansigray",
1195
+ "trace.tool": "bold ansiwhite",
1196
+ "trace.dim": "ansigray",
1197
+ "trace.step": "bold ansibrightblue",
1198
+ "session.row": "ansiwhite",
1199
+ "session.row.selected": "bold reverse ansicyan",
1200
+ "session.meta": "ansigray",
1201
+ "section.label": "bold ansicyan",
1202
+ "section.box": "ansicyan",
1203
+ "code": "ansiwhite",
1204
+ "md.h1": "bold ansibrightcyan underline",
1205
+ "md.h2": "bold ansicyan",
1206
+ "md.h3": "bold ansiwhite",
1207
+ "md.bold": "bold ansiwhite",
1208
+ "md.italic": "italic",
1209
+ "md.code": "bg:#222222 ansibrightgreen",
1210
+ "md.bullet": "ansicyan",
1211
+ "md.link": "underline ansibrightblue",
1212
+ "md.quote": "italic ansigray",
1213
+ "syntax.comment": "italic ansigray",
1214
+ "syntax.string": "ansigreen",
1215
+ "syntax.keyword": "bold ansimagenta",
1216
+ "syntax.builtin": "ansicyan",
1217
+ "syntax.function": "ansiyellow",
1218
+ "syntax.class": "bold ansiyellow",
1219
+ "syntax.decorator": "ansiyellow",
1220
+ "syntax.number": "ansicyan",
1221
+ "syntax.operator": "ansiwhite",
1222
+ "log": "ansigray",
1223
+ "modal.frame": "bg:#1a1a1a ansiwhite",
1224
+ "modal.title": "bg:#1a1a1a bold ansibrightcyan",
1225
+ "modal.body": "bg:#1a1a1a ansiwhite",
1226
+ "modal.hint": "bg:#1a1a1a ansigray",
1227
+ "modal.key": "bg:#1a1a1a bold ansibrightyellow",
1228
+ "completion-menu.completion": "bg:#222222 ansiwhite",
1229
+ "completion-menu.completion.current": "bg:ansibrightblue ansiwhite",
1230
+ })
1231
+
1232
+ # ── Layout ────────────────────────────────────────────────────
1233
+
1234
+ def _build_layout(self) -> Layout:
1235
+ is_landing = Condition(lambda: self.mode == "landing")
1236
+ is_sessions = Condition(lambda: self.mode == "sessions")
1237
+ is_scanning = Condition(lambda: self.mode in ("scanning", "viewing"))
1238
+
1239
+ landing = self._build_landing_container()
1240
+ scan = self._build_scan_container()
1241
+ sessions = self._build_sessions_container()
1242
+
1243
+ body = HSplit([
1244
+ ConditionalContainer(content=landing, filter=is_landing),
1245
+ ConditionalContainer(content=sessions, filter=is_sessions),
1246
+ ConditionalContainer(content=scan, filter=is_scanning),
1247
+ ])
1248
+
1249
+ # ── Modal overlay: centered Frame shown when self._modal_kind set ──
1250
+ def _modal_text():
1251
+ return [
1252
+ ("class:modal.title", self._modal_title),
1253
+ ("", "\n\n"),
1254
+ ("class:modal.body", self._modal_body),
1255
+ ("", "\n\n"),
1256
+ ("class:modal.key", "[Y]"),
1257
+ ("class:modal.hint", " confirm "),
1258
+ ("class:modal.key", "[N]"),
1259
+ ("class:modal.hint", " cancel "),
1260
+ ("class:modal.key", "[Esc]"),
1261
+ ("class:modal.hint", " dismiss"),
1262
+ ]
1263
+
1264
+ modal_body_window = Window(
1265
+ FormattedTextControl(_modal_text),
1266
+ wrap_lines=True,
1267
+ style="class:modal.frame",
1268
+ )
1269
+ modal_frame = Frame(
1270
+ body=modal_body_window,
1271
+ title="OpenHack",
1272
+ style="class:modal.frame",
1273
+ width=D(min=50, max=80, preferred=72),
1274
+ height=D(min=8, max=20, preferred=14),
1275
+ )
1276
+ # Center via weight-1 spacers on all four sides inside the full-screen Float.
1277
+ modal_centered = HSplit([
1278
+ Window(height=D(weight=1)),
1279
+ VSplit([
1280
+ Window(width=D(weight=1)),
1281
+ modal_frame,
1282
+ Window(width=D(weight=1)),
1283
+ ]),
1284
+ Window(height=D(weight=1)),
1285
+ ])
1286
+ modal_visible = Condition(lambda: self._modal_kind is not None)
1287
+
1288
+ root = FloatContainer(
1289
+ content=body,
1290
+ floats=[
1291
+ Float(
1292
+ xcursor=True,
1293
+ ycursor=True,
1294
+ content=CompletionsMenu(max_height=12, scroll_offset=1),
1295
+ ),
1296
+ Float(
1297
+ top=0, left=0, right=0, bottom=0,
1298
+ content=ConditionalContainer(modal_centered, filter=modal_visible),
1299
+ ),
1300
+ ],
1301
+ )
1302
+ return Layout(root, focused_element=self._input_window)
1303
+
1304
+ def _build_landing_container(self) -> HSplit:
1305
+ # Centered logo + wordmark + input + hints + tip + footer.
1306
+ # Render each logo line as its own Window so WindowAlign.CENTER puts
1307
+ # them all at the same horizontal offset.
1308
+ logo_windows = [
1309
+ Window(
1310
+ FormattedTextControl(lambda line=line: [("class:logo", line)]),
1311
+ align=WindowAlign.CENTER,
1312
+ height=1,
1313
+ )
1314
+ for line in _LOGO_LINES
1315
+ ]
1316
+
1317
+ def wordmark():
1318
+ return [("class:wordmark", "OpenHack")]
1319
+
1320
+ def tip():
1321
+ return [
1322
+ ("class:tip.label", "• Tip "),
1323
+ ("class:tip", "Type "),
1324
+ ("class:hint.key", "/scan ."),
1325
+ ("class:tip", " to scan the current directory, or "),
1326
+ ("class:hint.key", "?"),
1327
+ ("class:tip", " for help"),
1328
+ ]
1329
+
1330
+ # "Get Started" box — four key commands a new user needs. Each row is
1331
+ # `command description`, column-aligned so the descriptions line up.
1332
+ _GETTING_STARTED = [
1333
+ ("/login", "login to OpenHack"),
1334
+ ("/scan <dir>", "begin a scan"),
1335
+ ("/sessions", "browse past scans"),
1336
+ ("/help", "list commands"),
1337
+ ("/discord", "chat with the community"),
1338
+ ]
1339
+ _GS_CMD_WIDTH = max(len(cmd) for cmd, _ in _GETTING_STARTED)
1340
+
1341
+ _GS_LPAD = " " # 2 spaces left padding inside the frame
1342
+ _GS_RPAD = " " # 2 spaces right padding inside the frame
1343
+
1344
+ def getting_started():
1345
+ out: list[tuple[str, str]] = [
1346
+ ("", "\n"), # top vertical padding
1347
+ ("", _GS_LPAD), ("class:pane.title", "Get Started"), ("", _GS_RPAD + "\n"),
1348
+ ("", "\n"), # spacer under the title
1349
+ ]
1350
+ for cmd, desc in _GETTING_STARTED:
1351
+ pad = " " * (_GS_CMD_WIDTH - len(cmd) + 3)
1352
+ out.append(("", _GS_LPAD))
1353
+ out.append(("class:hint.key", cmd))
1354
+ out.append(("", pad))
1355
+ out.append(("class:tip", desc))
1356
+ out.append(("", _GS_RPAD + "\n"))
1357
+ out.append(("", "\n")) # bottom vertical padding
1358
+ return out
1359
+
1360
+ # Width = lpad + command column + gap + longest description column + rpad.
1361
+ _GS_INNER_WIDTH = (
1362
+ len(_GS_LPAD) + _GS_CMD_WIDTH + 3
1363
+ + max(len(d) for _, d in _GETTING_STARTED) + len(_GS_RPAD)
1364
+ )
1365
+ _GS_FRAME_WIDTH = _GS_INNER_WIDTH + 2 # +2 for the box borders
1366
+ # Body height: 1 top pad + 1 title + 1 blank + N rows + 1 bottom pad.
1367
+ _GS_INNER_HEIGHT = 3 + len(_GETTING_STARTED) + 1
1368
+
1369
+ def hints():
1370
+ return [
1371
+ ("class:hint", " enter "),
1372
+ ("class:hint.key", "submit"),
1373
+ ("class:hint", " tab "),
1374
+ ("class:hint.key", "complete"),
1375
+ ("class:hint", " "),
1376
+ ("class:hint.key", "?"),
1377
+ ("class:hint", " help"),
1378
+ ]
1379
+
1380
+ def footer():
1381
+ cfg = load_user_config()
1382
+ first = cfg.get("openhack_user_first_name") or ""
1383
+ last = cfg.get("openhack_user_last_name") or ""
1384
+ email = cfg.get("openhack_user_email") or self.user_email or ""
1385
+ org = cfg.get("openhack_org_name") or self.org_name or ""
1386
+ # Prefer full name → first name → email.
1387
+ display_name = " ".join(p for p in (first, last) if p).strip() or email
1388
+ parts: list[tuple[str, str]] = []
1389
+ if display_name:
1390
+ parts.append(("class:footer", display_name))
1391
+ if org:
1392
+ if parts:
1393
+ parts.append(("class:footer", " · "))
1394
+ parts.append(("class:footer", org))
1395
+ if not parts:
1396
+ parts.append(("class:footer", "not logged in — run /login"))
1397
+ return parts
1398
+
1399
+ # The input bar (used by both landing and scanning, but here it's
1400
+ # styled to feel like opencode's centered prompt).
1401
+ self._input_window = Window(
1402
+ content=BufferControl(
1403
+ buffer=self.input_buffer,
1404
+ input_processors=[BeforeInput("❯ ", style="class:input.prompt")],
1405
+ ),
1406
+ height=1,
1407
+ )
1408
+
1409
+ return HSplit([
1410
+ Window(height=D(weight=1)), # top spacer
1411
+ *logo_windows,
1412
+ Window(height=1),
1413
+ Window(FormattedTextControl(wordmark), align=WindowAlign.CENTER, height=1),
1414
+ Window(height=2),
1415
+ # Input row, padded so it appears centered with consistent width.
1416
+ VSplit([
1417
+ Window(width=D(weight=1)),
1418
+ HSplit([
1419
+ Window(
1420
+ FormattedTextControl(lambda: [("class:rule", "─" * 64)]),
1421
+ height=1,
1422
+ ),
1423
+ self._input_window,
1424
+ Window(
1425
+ FormattedTextControl(lambda: [("class:rule", "─" * 64)]),
1426
+ height=1,
1427
+ ),
1428
+ ], width=64),
1429
+ Window(width=D(weight=1)),
1430
+ ]),
1431
+ Window(height=1),
1432
+ Window(FormattedTextControl(hints), align=WindowAlign.CENTER, height=1),
1433
+ Window(height=1),
1434
+ Window(FormattedTextControl(tip), align=WindowAlign.CENTER, height=1),
1435
+ Window(height=1),
1436
+ # "Get Started" box — fixed-width, horizontally centered with
1437
+ # flexible spacers on either side.
1438
+ VSplit([
1439
+ Window(width=D(weight=1)),
1440
+ Frame(
1441
+ Window(
1442
+ FormattedTextControl(getting_started),
1443
+ height=_GS_INNER_HEIGHT,
1444
+ ),
1445
+ width=_GS_FRAME_WIDTH,
1446
+ ),
1447
+ Window(width=D(weight=1)),
1448
+ ]),
1449
+ Window(height=1),
1450
+ # Update notification + announcement banners (populated async on
1451
+ # startup via the /updates endpoint). Empty if nothing to show.
1452
+ Window(
1453
+ FormattedTextControl(self._update_banner_text),
1454
+ align=WindowAlign.CENTER,
1455
+ wrap_lines=True,
1456
+ ),
1457
+ # Status-line slot: shows /verify warnings, /logout prompts, errors,
1458
+ # etc., on the landing screen. wrap_lines so long warnings stay
1459
+ # readable instead of getting truncated at the right edge.
1460
+ Window(
1461
+ FormattedTextControl(lambda: [
1462
+ ("class:log", f" {self.last_status_line}" if self.last_status_line else "")
1463
+ ]),
1464
+ wrap_lines=True,
1465
+ align=WindowAlign.CENTER,
1466
+ ),
1467
+ Window(height=D(weight=1)),
1468
+ Window(FormattedTextControl(footer), align=WindowAlign.CENTER, height=1),
1469
+ Window(height=1),
1470
+ ])
1471
+
1472
+ def _build_scan_container(self) -> HSplit:
1473
+ # ── Header bar ────────────────────────────────────────────
1474
+ def header_text():
1475
+ target = ""
1476
+ elapsed = ""
1477
+ cost = 0.0
1478
+ label = ""
1479
+ if self.scan is not None:
1480
+ target = self.scan.target or ""
1481
+ elapsed = self.scan.elapsed_str()
1482
+ cost = self.scan.cost
1483
+ if self.scan.end_time is not None and self.mode != "viewing":
1484
+ label = "complete"
1485
+ elif self.session is not None and self.session.paused:
1486
+ label = "⏸ paused"
1487
+ if self.mode == "viewing":
1488
+ target = self.viewing_target or target
1489
+ label = "viewing"
1490
+ short = self._short_target(target) if target else ""
1491
+ out: list[tuple[str, str]] = [("class:header.brand", "⏚ openhack")]
1492
+ if short:
1493
+ out.extend([
1494
+ ("class:header.sep", " · "),
1495
+ ("class:header.target", short),
1496
+ ])
1497
+ if elapsed:
1498
+ out.extend([
1499
+ ("class:header.sep", " "),
1500
+ ("class:header.meta", elapsed),
1501
+ ])
1502
+ if self.scan is not None and self.mode != "viewing":
1503
+ out.extend([
1504
+ ("class:header.sep", " · "),
1505
+ ("class:header.meta", f"${cost:.4f}"),
1506
+ ])
1507
+ if label:
1508
+ out.extend([
1509
+ ("class:header.sep", " · "),
1510
+ ("class:header.meta", label),
1511
+ ])
1512
+ return out
1513
+
1514
+ def account_text():
1515
+ # Mirror the landing-page footer: show "Name · Org" on the right
1516
+ # edge of the scan header so users always see who they're scanning
1517
+ # as. Falls back through full name → first name → email → blank.
1518
+ cfg = load_user_config()
1519
+ first = cfg.get("openhack_user_first_name") or ""
1520
+ last = cfg.get("openhack_user_last_name") or ""
1521
+ email = cfg.get("openhack_user_email") or self.user_email or ""
1522
+ org = cfg.get("openhack_org_name") or self.org_name or ""
1523
+ display_name = " ".join(p for p in (first, last) if p).strip() or email
1524
+ parts: list[tuple[str, str]] = []
1525
+ if display_name:
1526
+ parts.append(("class:header.meta", display_name))
1527
+ if org:
1528
+ if parts:
1529
+ parts.append(("class:header.sep", " · "))
1530
+ parts.append(("class:header.meta", org))
1531
+ if parts:
1532
+ # Trailing pad keeps the text off the right edge.
1533
+ parts.append(("", " "))
1534
+ return parts
1535
+
1536
+ header = VSplit([
1537
+ Window(FormattedTextControl(header_text), height=1),
1538
+ Window(
1539
+ FormattedTextControl(account_text),
1540
+ height=1,
1541
+ align=WindowAlign.RIGHT,
1542
+ ),
1543
+ ], height=1)
1544
+ rule = Window(FormattedTextControl(lambda: [("class:rule", "─" * 240)]), height=1)
1545
+
1546
+ # ── Tab bar ───────────────────────────────────────────────
1547
+ def tab_bar():
1548
+ findings = self._current_findings()
1549
+ count = len(findings)
1550
+ tabs = [("trace", "Trace"), ("findings", f"Findings ({count})")]
1551
+ out: list[tuple[str, str]] = [("", " ")]
1552
+ for i, (key, label) in enumerate(tabs, 1):
1553
+ active = self.active_tab == key
1554
+ cls = "class:tab.active" if active else "class:tab.inactive"
1555
+ out.append(("class:tab.key", f" {i} "))
1556
+ out.append((cls, f" {label} "))
1557
+ out.append(("", " "))
1558
+ out.append(("class:hint",
1559
+ " ←/→ tab · ↑↓ scroll · [ ] finding · < > resize · Ctrl+B hide · /sessions"))
1560
+ return out
1561
+
1562
+ tab_bar_window = Window(FormattedTextControl(tab_bar), height=1)
1563
+
1564
+ # ── Trace tab ─────────────────────────────────────────────
1565
+ def _agent_tree() -> list[tuple[int, str]]:
1566
+ """Flatten scan.trace_agents into [(indent_level, agent_name), …].
1567
+
1568
+ Swarms like 'hunter_swarm' adopt their 'hunter:*' children as
1569
+ level-1 entries underneath. Other agents stay at level 0.
1570
+ """
1571
+ if self.scan is None:
1572
+ return []
1573
+ agents = self.scan.trace_agents
1574
+ agent_set = set(agents)
1575
+ # Map of parent_swarm_name -> [child names in original order]
1576
+ children_map: dict[str, list[str]] = {}
1577
+ for a in agents:
1578
+ if ":" in a:
1579
+ base = a.split(":", 1)[0]
1580
+ parent = f"{base}_swarm"
1581
+ if parent in agent_set:
1582
+ children_map.setdefault(parent, []).append(a)
1583
+
1584
+ seen: set[str] = set()
1585
+ out: list[tuple[int, str]] = []
1586
+ for a in agents:
1587
+ if a in seen:
1588
+ continue
1589
+ if ":" in a:
1590
+ base = a.split(":", 1)[0]
1591
+ parent = f"{base}_swarm"
1592
+ if parent in agent_set:
1593
+ # Will be emitted under its parent when parent is visited.
1594
+ continue
1595
+ # Top-level entry.
1596
+ out.append((0, a))
1597
+ seen.add(a)
1598
+ # Children (if a is a known swarm parent).
1599
+ for c in children_map.get(a, []):
1600
+ if c not in seen:
1601
+ out.append((1, c))
1602
+ seen.add(c)
1603
+ # Orphans — any agent not yet emitted (parent wasn't actually seen).
1604
+ for a in agents:
1605
+ if a not in seen:
1606
+ out.append((0, a))
1607
+ seen.add(a)
1608
+ return out
1609
+
1610
+ def _selected_trace_agents() -> Optional[set[str]]:
1611
+ """None = show all events; otherwise a set of agent names to include.
1612
+
1613
+ Selecting a swarm parent expands to include all its children, so
1614
+ 'hunter_swarm' shows events from hunter_swarm AND every hunter:*.
1615
+ """
1616
+ if self.scan is None:
1617
+ return None
1618
+ idx = self._trace_agent_idx
1619
+ if idx <= 0:
1620
+ return None
1621
+ tree = _agent_tree()
1622
+ if not tree:
1623
+ return None
1624
+ idx = min(idx - 1, len(tree) - 1)
1625
+ _, name = tree[idx]
1626
+ if name.endswith("_swarm"):
1627
+ base = name[: -len("_swarm")]
1628
+ return {name} | {
1629
+ a for a in self.scan.trace_agents
1630
+ if a.startswith(f"{base}:")
1631
+ }
1632
+ return {name}
1633
+
1634
+ def _trace_text_raw():
1635
+ if self.scan is None or not self.scan.trace_lines:
1636
+ return [("class:pane.empty", " no trace yet — start a scan with /scan <path>")]
1637
+ wanted = _selected_trace_agents()
1638
+ out: list[tuple[str, str]] = []
1639
+ matched = 0
1640
+ for agent, fragments in self.scan.trace_lines:
1641
+ if wanted is not None and agent not in wanted:
1642
+ continue
1643
+ for fragment in fragments:
1644
+ out.append(fragment)
1645
+ out.append(("", "\n"))
1646
+ matched += 1
1647
+ if matched == 0 and wanted is not None:
1648
+ label = next(iter(wanted)) if len(wanted) == 1 else f"{len(wanted)} agents"
1649
+ return [("class:pane.empty", f" no events from {label} (yet)")]
1650
+ return out
1651
+
1652
+ def trace_text():
1653
+ """Manual viewport clipping. _trace_follow=True sticks to the
1654
+ bottom; otherwise show from _trace_scroll."""
1655
+ raw = _trace_text_raw()
1656
+ try:
1657
+ lines = list(split_lines(raw))
1658
+ except Exception:
1659
+ return raw
1660
+ if not lines:
1661
+ return raw
1662
+ info = self._trace_window.render_info if hasattr(self, '_trace_window') else None
1663
+ window_height = info.window_height if info is not None else 20
1664
+ max_scroll = max(0, len(lines) - window_height)
1665
+ if self._trace_follow:
1666
+ self._trace_scroll = max_scroll
1667
+ elif self._trace_scroll > max_scroll:
1668
+ self._trace_scroll = max_scroll
1669
+ visible = lines[self._trace_scroll:]
1670
+ out: list[tuple[str, str]] = []
1671
+ for i, line in enumerate(visible):
1672
+ out.extend(line)
1673
+ if i < len(visible) - 1:
1674
+ out.append(("", "\n"))
1675
+ return out
1676
+
1677
+ def _scroll_trace_by(delta: int) -> None:
1678
+ # If user scrolls up, break the auto-follow.
1679
+ if delta < 0:
1680
+ self._trace_follow = False
1681
+ # Bump the offset, then re-clamp on next render.
1682
+ self._trace_scroll = max(0, self._trace_scroll + delta)
1683
+ # If we scrolled down past the visible content end, re-enable follow.
1684
+ if delta > 0 and self.scan and self.scan.trace_lines:
1685
+ total_lines = sum(
1686
+ sum(frag[1].count("\n") for frag in line) + 1
1687
+ for line in self.scan.trace_lines
1688
+ )
1689
+ info = self._trace_window.render_info if hasattr(self, '_trace_window') else None
1690
+ window_height = info.window_height if info is not None else 20
1691
+ if self._trace_scroll >= max(0, total_lines - window_height):
1692
+ self._trace_follow = True
1693
+ self._invalidate()
1694
+
1695
+ trace_window = Window(
1696
+ content=_ScrollableFormattedTextControl(
1697
+ text=trace_text,
1698
+ focusable=False,
1699
+ on_scroll=_scroll_trace_by,
1700
+ ),
1701
+ # Wrap so full relative paths in tool calls stay visible. Manual
1702
+ # scroll counts logical (\n-delimited) lines, not visual rows, so
1703
+ # wrap doesn't break the scroll offset.
1704
+ wrap_lines=True,
1705
+ always_hide_cursor=True,
1706
+ )
1707
+ self._trace_window = trace_window
1708
+ self._scroll_trace_by = _scroll_trace_by
1709
+
1710
+ # ── Trace sidebar: tree of agents that have produced events. ──
1711
+ # Sidebar entries (flat list, ordered):
1712
+ # index 0 = "All"
1713
+ # index 1..N = _agent_tree() entries (level 0 or 1 with indent)
1714
+ def trace_sidebar_text():
1715
+ tree = _agent_tree() if self.scan is not None else []
1716
+ n_entries = 1 + len(tree) # "All" + tree
1717
+ out: list = [("class:pane.title", " agents\n\n")]
1718
+ if len(tree) == 0:
1719
+ # "All" + waiting message.
1720
+ def _handler_all(event: MouseEvent):
1721
+ if event.event_type == MouseEventType.MOUSE_UP:
1722
+ self._trace_agent_idx = 0
1723
+ self._invalidate()
1724
+ cls0 = "class:finding.cursor" if self._trace_agent_idx == 0 else "class:trace.agent"
1725
+ pointer0 = "❯ " if self._trace_agent_idx == 0 else " "
1726
+ out.append((cls0, f" {pointer0}All", _handler_all))
1727
+ out.append(("", "\n", _handler_all))
1728
+ out.append(("class:pane.empty", "\n (waiting for events)\n"))
1729
+ return out
1730
+
1731
+ def _make_handler(idx: int):
1732
+ def _handler(event: MouseEvent):
1733
+ if event.event_type == MouseEventType.MOUSE_UP:
1734
+ self._trace_agent_idx = idx
1735
+ self._trace_scroll = 0
1736
+ self._trace_follow = True
1737
+ self._invalidate()
1738
+ return _handler
1739
+
1740
+ # Clamp selection if agents list shrank since last selection.
1741
+ if self._trace_agent_idx >= n_entries:
1742
+ self._trace_agent_idx = 0
1743
+
1744
+ # Entry 0: "All"
1745
+ sel = self._trace_agent_idx == 0
1746
+ cls = "class:finding.cursor" if sel else "class:trace.agent"
1747
+ pointer = "❯ " if sel else " "
1748
+ h0 = _make_handler(0)
1749
+ out.append((cls, f" {pointer}All", h0))
1750
+ out.append(("", "\n", h0))
1751
+
1752
+ # Tree entries
1753
+ for i, (level, name) in enumerate(tree, start=1):
1754
+ sel = i == self._trace_agent_idx
1755
+ handler = _make_handler(i)
1756
+ pointer = "❯ " if sel else " "
1757
+ if level == 0:
1758
+ indent = ""
1759
+ label_full = name
1760
+ cls = "class:finding.cursor" if sel else "class:trace.agent"
1761
+ else:
1762
+ indent = " ├─ "
1763
+ label_full = name
1764
+ cls = "class:finding.cursor" if sel else "class:trace.dim"
1765
+ shown = label_full if len(label_full) <= 24 else label_full[:23] + "…"
1766
+ out.append((cls, f" {pointer}{indent}{shown}", handler))
1767
+ out.append(("", "\n", handler))
1768
+ return out
1769
+
1770
+ def _trace_sidebar_cursor() -> Point:
1771
+ # Row 0 = title ("agents"), row 1 = blank, row 2+ = entries.
1772
+ # Each entry is 1 row. Selected index maps to row (2 + idx).
1773
+ return Point(x=0, y=2 + self._trace_agent_idx)
1774
+
1775
+ trace_sidebar_ctrl = FormattedTextControl(
1776
+ trace_sidebar_text, focusable=False,
1777
+ get_cursor_position=_trace_sidebar_cursor,
1778
+ )
1779
+ trace_sidebar = Window(
1780
+ content=trace_sidebar_ctrl,
1781
+ wrap_lines=False,
1782
+ always_hide_cursor=True,
1783
+ width=D(weight=25, preferred=10_000),
1784
+ )
1785
+ trace_sep = Window(
1786
+ FormattedTextControl(lambda: [("class:rule", "│\n") for _ in range(0, 200)]),
1787
+ width=1,
1788
+ )
1789
+ trace_pane = VSplit([
1790
+ trace_sidebar,
1791
+ trace_sep,
1792
+ VSplit([
1793
+ Window(width=1),
1794
+ trace_window,
1795
+ ], width=D(weight=75, preferred=10_000)),
1796
+ ])
1797
+
1798
+ # ── Findings tab (split: list on left, details on right) ──
1799
+ def findings_list_text():
1800
+ findings = self._current_findings()
1801
+ count = len(findings)
1802
+ # Per-finding verification summary: how many have been confirmed by
1803
+ # the sandbox / browser verifier. Source is a comma-joined string
1804
+ # like "sandbox,browser" — split and bucket.
1805
+ sb_n = sum(1 for f in findings if "sandbox" in (f.source or ""))
1806
+ br_n = sum(1 for f in findings if "browser" in (f.source or ""))
1807
+ out: list[tuple[str, str, "MouseEvent"] | tuple[str, str]] = [
1808
+ ("class:pane.title", f" Findings ({count})\n"),
1809
+ ]
1810
+ if sb_n or br_n:
1811
+ badge_parts: list[str] = []
1812
+ if sb_n:
1813
+ badge_parts.append(f"sandbox ✓ {sb_n}/{count}")
1814
+ if br_n:
1815
+ badge_parts.append(f"browser ✓ {br_n}/{count}")
1816
+ out.append(("class:pane.dim", f" {' · '.join(badge_parts)}\n"))
1817
+ out.append(("", "\n"))
1818
+ if not findings:
1819
+ out.append(("class:pane.empty", " none yet — start a scan with /scan <path>\n"))
1820
+ return out
1821
+
1822
+ def _make_handler(idx: int):
1823
+ def _handler(event: MouseEvent):
1824
+ # Only handle clicks for selection. Mouse wheel on the
1825
+ # sidebar is intentionally a no-op (consumed but ignored)
1826
+ # so it doesn't fight with the details pane's scrolling.
1827
+ if event.event_type == MouseEventType.MOUSE_UP:
1828
+ self.findings_selected = idx
1829
+ self._invalidate()
1830
+ return _handler
1831
+
1832
+ for i, f in enumerate(findings):
1833
+ selected = i == self.findings_selected
1834
+ handler = _make_handler(i)
1835
+ pointer = "❯ " if selected else " "
1836
+ row_cls = "class:finding.cursor" if selected else "class:finding.title"
1837
+ # Verified badge: green ✓ when sandbox- or browser-validated.
1838
+ src = f.source or ""
1839
+ if "sandbox" in src and "browser" in src:
1840
+ verified_mark = ("class:verified", "✓✓ ")
1841
+ elif "sandbox" in src or "browser" in src:
1842
+ verified_mark = ("class:verified", "✓ ")
1843
+ else:
1844
+ verified_mark = ("", " ")
1845
+ # The row itself — clickable
1846
+ out.append((row_cls, f" {pointer}", handler))
1847
+ out.append((verified_mark[0], verified_mark[1], handler))
1848
+ out.append((_sev_style(f.severity), f" {_sev_label(f.severity)} ", handler))
1849
+ out.append(("", " ", handler))
1850
+ # Truncate the title to keep the list pane scannable.
1851
+ title = f.title if len(f.title) <= 60 else f.title[:57] + "…"
1852
+ out.append((row_cls, title, handler))
1853
+ out.append(("", "\n", handler))
1854
+ if f.file_path:
1855
+ short_path = f.file_path
1856
+ if len(short_path) > 64:
1857
+ short_path = "…" + short_path[-63:]
1858
+ out.append(("class:finding.path", f" {short_path}\n", handler))
1859
+ out.append(("", "\n", handler))
1860
+ return out
1861
+
1862
+ # ── Details pane: a single scrollable Window ──
1863
+ def _selected_finding():
1864
+ findings = self._current_findings()
1865
+ if not findings:
1866
+ return None
1867
+ if self.findings_selected >= len(findings):
1868
+ self.findings_selected = max(0, len(findings) - 1)
1869
+ return findings[self.findings_selected]
1870
+
1871
+ def _scroll_details_by(delta: int) -> None:
1872
+ self._details_scroll = max(0, self._details_scroll + delta)
1873
+ self._last_scroll_at = time.monotonic()
1874
+ self._invalidate()
1875
+
1876
+ def _details_text_raw():
1877
+ f = _selected_finding()
1878
+ if f is None:
1879
+ return [("class:pane.empty", " no findings to inspect")]
1880
+ out: list[tuple[str, str]] = []
1881
+
1882
+ out.append(("class:pane.title", f"{f.title}\n"))
1883
+ out.append(("", "\n"))
1884
+ out.append((_sev_style(f.severity), f" {_sev_label(f.severity)} "))
1885
+ if f.category:
1886
+ out.append(("", " "))
1887
+ out.append(("class:finding.path", f.category))
1888
+ if getattr(f, "cvss_score", None):
1889
+ out.append(("", " "))
1890
+ out.append(("class:trace.dim", f"CVSS {f.cvss_score:.1f}"))
1891
+ src = f.source or ""
1892
+ verifiers = [v for v in ("sandbox", "browser") if v in src]
1893
+ if verifiers:
1894
+ out.append(("", " "))
1895
+ out.append(("class:verified", "✓ verified via " + ", ".join(verifiers)))
1896
+ out.append(("", "\n"))
1897
+ if f.file_path:
1898
+ loc = f.file_path
1899
+ if getattr(f, "line_number", None):
1900
+ loc += f":{f.line_number}"
1901
+ out.append(("class:finding.path", f"{loc}\n"))
1902
+ out.append(("", "\n"))
1903
+
1904
+ if f.description:
1905
+ out.extend(_section_header("Description"))
1906
+ out.append(("", "\n"))
1907
+ out.append(("", f.description))
1908
+ out.append(("", "\n\n"))
1909
+
1910
+ snippet = getattr(f, "code_snippet", None)
1911
+ if snippet:
1912
+ out.extend(_section_header("Vulnerable code"))
1913
+ out.append(("", "\n"))
1914
+ out.extend(_highlight_code(snippet, f.file_path or ""))
1915
+ out.append(("", "\n\n"))
1916
+
1917
+ fix = getattr(f, "fix", None)
1918
+ if fix:
1919
+ out.extend(_section_header("Recommended fix"))
1920
+ out.append(("", "\n"))
1921
+ out.extend(_render_markdown_with_code(fix, f.file_path or ""))
1922
+ out.append(("", "\n\n"))
1923
+ else:
1924
+ out.append(("class:trace.dim", "No fix saved for this finding.\n"))
1925
+
1926
+ return out
1927
+
1928
+ def details_text():
1929
+ """Manual viewport-clipping scroll: drop the first N logical
1930
+ lines from the rendered fragments based on self._details_scroll."""
1931
+ raw = _details_text_raw()
1932
+ try:
1933
+ lines = list(split_lines(raw))
1934
+ except Exception:
1935
+ return raw
1936
+ if not lines:
1937
+ return raw
1938
+ # Clamp scroll so that the last line lands at the bottom of the
1939
+ # viewport — no scrolling past the end into blank space.
1940
+ info = self._details_window.render_info
1941
+ window_height = info.window_height if info is not None else 20
1942
+ max_scroll = max(0, len(lines) - window_height)
1943
+ if self._details_scroll > max_scroll:
1944
+ self._details_scroll = max_scroll
1945
+ visible = lines[self._details_scroll:]
1946
+ out: list[tuple[str, str]] = []
1947
+ for i, line in enumerate(visible):
1948
+ out.extend(line)
1949
+ if i < len(visible) - 1:
1950
+ out.append(("", "\n"))
1951
+ return out
1952
+
1953
+ # The custom control catches SCROLL_UP/SCROLL_DOWN at the control
1954
+ # level — guaranteed to fire on wheel events anywhere over this
1955
+ # Window, regardless of which fragment is under the cursor.
1956
+ details_window = Window(
1957
+ content=_ScrollableFormattedTextControl(
1958
+ text=details_text,
1959
+ focusable=False,
1960
+ on_scroll=_scroll_details_by,
1961
+ ),
1962
+ wrap_lines=True,
1963
+ always_hide_cursor=True,
1964
+ )
1965
+ self._details_window = details_window
1966
+
1967
+ # Resizable split: the two Dimensions are stored on self so the
1968
+ # < / > keybindings can mutate their `weight` to change the ratio.
1969
+ self._sidebar_dim = D(weight=self._sidebar_pct, preferred=10_000)
1970
+ self._details_dim = D(weight=100 - self._sidebar_pct, preferred=10_000)
1971
+
1972
+ # Findings list pane (left)
1973
+ findings_list_pane = Window(
1974
+ content=FormattedTextControl(findings_list_text, focusable=False),
1975
+ wrap_lines=False,
1976
+ always_hide_cursor=True,
1977
+ width=self._sidebar_dim,
1978
+ )
1979
+ details_sep = Window(
1980
+ FormattedTextControl(lambda: [("class:rule", "│\n") for _ in range(0, 200)]),
1981
+ width=1,
1982
+ )
1983
+ # Right pane — symmetric horizontal padding so content sits balanced.
1984
+ findings_details_pane = VSplit([
1985
+ Window(width=2),
1986
+ details_window,
1987
+ Window(width=2),
1988
+ ], width=self._details_dim)
1989
+ sidebar_visible = Condition(lambda: not self.findings_list_hidden)
1990
+ findings_pane = VSplit([
1991
+ ConditionalContainer(findings_list_pane, filter=sidebar_visible),
1992
+ ConditionalContainer(details_sep, filter=sidebar_visible),
1993
+ findings_details_pane,
1994
+ ])
1995
+
1996
+ # ── Body: one of the two tabs ─────────────────────────────
1997
+ body = HSplit([
1998
+ ConditionalContainer(content=trace_pane,
1999
+ filter=Condition(lambda: self.active_tab == "trace")),
2000
+ ConditionalContainer(content=findings_pane,
2001
+ filter=Condition(lambda: self.active_tab == "findings")),
2002
+ ])
2003
+
2004
+ # ── Bottom status line + input ────────────────────────────
2005
+ def status_line():
2006
+ msg = self.last_status_line or (self.scan.last_message if self.scan else "")
2007
+ return [("class:log", f" {msg}" if msg else "")]
2008
+
2009
+ return HSplit([
2010
+ Window(height=1), # top padding
2011
+ header,
2012
+ rule,
2013
+ tab_bar_window,
2014
+ rule,
2015
+ body,
2016
+ rule,
2017
+ Window(FormattedTextControl(status_line), height=1),
2018
+ VSplit([
2019
+ Window(width=2),
2020
+ self._input_window,
2021
+ Window(FormattedTextControl(lambda: [("class:hint", " /cancel /clear")]),
2022
+ width=20, height=1, align=WindowAlign.RIGHT),
2023
+ ]),
2024
+ Window(height=1), # bottom padding
2025
+ ])
2026
+
2027
+ def _build_sessions_container(self) -> HSplit:
2028
+ """Standalone sessions overlay — full-screen picker, no tab bar."""
2029
+ def header_text():
2030
+ return [
2031
+ ("class:header.brand", "openhack"),
2032
+ ("class:header.sep", " · "),
2033
+ ("class:header.target", "sessions"),
2034
+ ("class:header.sep", " "),
2035
+ ("class:header.meta",
2036
+ f"{len(self.sessions_index)} saved scan(s)" if self.sessions_index else "no saved scans"),
2037
+ ]
2038
+
2039
+ def sessions_text():
2040
+ out: list[tuple[str, str]] = [("", "\n")]
2041
+ if not self.sessions_index:
2042
+ out.append((
2043
+ "class:pane.empty",
2044
+ " no saved scans yet — completed scans are saved to ~/.openhack/scans/\n",
2045
+ ))
2046
+ return out
2047
+ for i, row in enumerate(self.sessions_index):
2048
+ selected = i == self.sessions_selected
2049
+ cls = "class:session.row.selected" if selected else "class:session.row"
2050
+ pointer = "❯ " if selected else " "
2051
+ out.append((cls, f" {pointer}{row.get('label', '')}"))
2052
+ out.append(("", "\n"))
2053
+ out.append(("class:session.meta", f" {row.get('meta', '')}"))
2054
+ out.append(("", "\n\n"))
2055
+ return out
2056
+
2057
+ def hint_text():
2058
+ return [
2059
+ ("class:hint", " ↑/↓ "),
2060
+ ("class:hint.key", "navigate"),
2061
+ ("class:hint", " enter "),
2062
+ ("class:hint.key", "load"),
2063
+ ("class:hint", " esc "),
2064
+ ("class:hint.key", "back"),
2065
+ ]
2066
+
2067
+ header = Window(FormattedTextControl(header_text), height=1)
2068
+ rule = Window(FormattedTextControl(lambda: [("class:rule", "─" * 240)]), height=1)
2069
+ def _sessions_cursor() -> Point:
2070
+ # Row 0 = leading blank. Each session = 3 rows (label, meta, blank).
2071
+ return Point(x=0, y=1 + self.sessions_selected * 3)
2072
+
2073
+ body = Window(
2074
+ FormattedTextControl(
2075
+ sessions_text, focusable=False,
2076
+ get_cursor_position=_sessions_cursor,
2077
+ ),
2078
+ wrap_lines=False,
2079
+ always_hide_cursor=True,
2080
+ )
2081
+ hint = Window(FormattedTextControl(hint_text), height=1)
2082
+
2083
+ return HSplit([
2084
+ Window(height=1),
2085
+ header,
2086
+ rule,
2087
+ body,
2088
+ rule,
2089
+ hint,
2090
+ VSplit([Window(width=2), self._input_window]),
2091
+ Window(height=1),
2092
+ ])
2093
+
2094
+ _SEV_RANK = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
2095
+
2096
+ def _current_findings(self) -> list[Finding]:
2097
+ if self.scan is not None and self.mode == "scanning":
2098
+ findings = self.scan.findings
2099
+ elif self.mode == "viewing":
2100
+ findings = self.last_findings
2101
+ elif self.scan is not None and self.scan.findings:
2102
+ findings = self.scan.findings
2103
+ else:
2104
+ findings = self.last_findings
2105
+ # Sort by severity (critical first), stable so equal-severity findings
2106
+ # keep their discovery order.
2107
+ return sorted(
2108
+ findings,
2109
+ key=lambda f: self._SEV_RANK.get((f.severity or "info").lower(), 99),
2110
+ )
2111
+
2112
+ @staticmethod
2113
+ def _short_target(target: str) -> str:
2114
+ try:
2115
+ home = str(Path.home())
2116
+ if target.startswith(home):
2117
+ return "~" + target[len(home):]
2118
+ except Exception:
2119
+ pass
2120
+ return target
2121
+
2122
+ # ── Update banner ────────────────────────────────────────────
2123
+
2124
+ _ANN_LEVEL_STYLE = {
2125
+ "info": "class:tip",
2126
+ "warning": "class:sev.medium",
2127
+ "critical": "class:sev.critical",
2128
+ }
2129
+
2130
+ def _update_banner_text(self) -> list[tuple[str, str]]:
2131
+ """Render update + announcement banners for the landing screen."""
2132
+ info = self._update_info
2133
+ if info is None:
2134
+ return []
2135
+ out: list[tuple[str, str]] = []
2136
+
2137
+ # Update available notification.
2138
+ if info.has_update and info.latest:
2139
+ from openhack import __version__ as cur
2140
+ out.append(("class:sev.medium", f" ⬆ Update available: {cur} → {info.latest.version}"))
2141
+ out.append(("class:tip", " · pipx upgrade openhack"))
2142
+ if info.latest.download_url:
2143
+ out.append(("class:tip", f" · {info.latest.download_url}"))
2144
+ out.append(("", "\n"))
2145
+
2146
+ # Banner-placement announcements.
2147
+ for ann in info.announcements:
2148
+ if "banner" not in ann.placement:
2149
+ continue
2150
+ style = self._ANN_LEVEL_STYLE.get(ann.level, "class:tip")
2151
+ out.append((style, f" {ann.title}"))
2152
+ if ann.body:
2153
+ # Show first line of body as a subtitle.
2154
+ first_line = ann.body.split("\n")[0].strip()
2155
+ if first_line:
2156
+ out.append(("class:tip", f" — {first_line}"))
2157
+ out.append(("", "\n"))
2158
+
2159
+ return out
2160
+
2161
+ # ── Invalidate / refresh ──────────────────────────────────────
2162
+
2163
+ def _invalidate(self) -> None:
2164
+ try:
2165
+ self.app.invalidate()
2166
+ except Exception:
2167
+ pass
2168
+
2169
+ # ── Input handling ────────────────────────────────────────────
2170
+
2171
+ def _on_buffer_accept(self, buf: Buffer) -> bool:
2172
+ text = buf.text.strip()
2173
+ buf.reset()
2174
+ if not text:
2175
+ return False
2176
+ if text == "?":
2177
+ text = "/help"
2178
+ asyncio.create_task(self._dispatch_input(text))
2179
+ return False # keep buffer alive
2180
+
2181
+ async def _dispatch_input(self, text: str) -> None:
2182
+ try:
2183
+ await self._handle_input(text)
2184
+ finally:
2185
+ self._invalidate()
2186
+
2187
+ async def _handle_input(self, text: str) -> None:
2188
+ # Cancel any pending confirmations when an unrelated input arrives.
2189
+ if self._logout_armed and not text.startswith("/logout"):
2190
+ self._logout_armed = False
2191
+ if self._verify_arm_subject is not None and not text.startswith("/verify"):
2192
+ self._verify_arm_subject = None
2193
+
2194
+ # Non-slash input.
2195
+ if not text.startswith("/"):
2196
+ if self.mode == "scanning" and self.session:
2197
+ low = text.lstrip("-").strip().lower()
2198
+ if low in _CANCEL_PHRASES:
2199
+ self._cancel_scan()
2200
+ return
2201
+ self.session.add_user_instruction(text)
2202
+ self.last_status_line = "instruction queued for scan agents"
2203
+ return
2204
+ # Landing: chat about findings.
2205
+ await self._chat(text)
2206
+ return
2207
+
2208
+ parts = text.split(None, 1)
2209
+ cmd = parts[0].lower()
2210
+ arg = parts[1] if len(parts) > 1 else ""
2211
+
2212
+ if cmd == "/help":
2213
+ self._show_help()
2214
+ elif cmd in ("/quit", "/exit"):
2215
+ if self.mode == "scanning":
2216
+ self._cancel_scan()
2217
+ self.app.exit()
2218
+ elif cmd == "/cancel":
2219
+ self._cancel_scan()
2220
+ elif cmd == "/pause":
2221
+ self._pause_scan()
2222
+ elif cmd == "/resume":
2223
+ self._resume_scan()
2224
+ elif cmd == "/clear":
2225
+ self.mode = "landing"
2226
+ self.scan = None
2227
+ self.active_tab = "trace"
2228
+ self.viewing_target = ""
2229
+ self.last_status_line = ""
2230
+ elif cmd == "/login":
2231
+ await self._cmd_login()
2232
+ elif cmd == "/logout":
2233
+ self._cmd_logout()
2234
+ elif cmd == "/setup":
2235
+ await self._cmd_setup()
2236
+ elif cmd == "/provider":
2237
+ self._cmd_provider(arg)
2238
+ elif cmd == "/model":
2239
+ self._cmd_model(arg)
2240
+ elif cmd == "/scan":
2241
+ target = (arg.strip() or os.getcwd())
2242
+ target_path = Path(target).resolve()
2243
+ if not target_path.exists():
2244
+ self.last_status_line = f"error: directory not found: {target_path}"
2245
+ else:
2246
+ self._start_scan(str(target_path))
2247
+ elif cmd == "/cost":
2248
+ self._cmd_cost()
2249
+ elif cmd == "/findings":
2250
+ self._cmd_findings()
2251
+ elif cmd == "/config":
2252
+ self._cmd_config(arg)
2253
+ elif cmd == "/test":
2254
+ self._start_test_scan()
2255
+ elif cmd == "/sessions":
2256
+ self._open_sessions_overlay()
2257
+ elif cmd == "/sidebar":
2258
+ self.findings_list_hidden = not self.findings_list_hidden
2259
+ self.last_status_line = "sidebar hidden" if self.findings_list_hidden else "sidebar shown"
2260
+ elif cmd == "/copy":
2261
+ self._cmd_copy_fix()
2262
+ elif cmd == "/verify":
2263
+ self._cmd_verify(arg)
2264
+ elif cmd == "/mouse":
2265
+ self._cmd_mouse(arg)
2266
+ elif cmd == "/discord":
2267
+ self._cmd_discord()
2268
+ else:
2269
+ self.last_status_line = f"unknown command: {cmd} — try /help"
2270
+
2271
+ # ── Commands that just update status ──────────────────────────
2272
+
2273
+ def _show_help(self) -> None:
2274
+ lines = ["commands: " + ", ".join(c for c, _ in _SLASH_COMMANDS)]
2275
+ self.last_status_line = lines[0]
2276
+
2277
+ def _cmd_provider(self, name: str) -> None:
2278
+ name = name.lower().strip()
2279
+ if name not in PROVIDER_DEFAULTS:
2280
+ self.last_status_line = f"unknown provider: {name}"
2281
+ return
2282
+ self.provider = resolve_provider(name)
2283
+ self.model = PROVIDER_DEFAULTS[name]
2284
+ save_user_config({"provider": self.provider, "model": self.model})
2285
+ self.last_status_line = f"switched to {name} ({self.model})"
2286
+
2287
+ def _cmd_model(self, arg: str) -> None:
2288
+ if arg:
2289
+ self.model = arg
2290
+ save_user_config({"model": arg})
2291
+ self.last_status_line = f"model set to {arg}"
2292
+ else:
2293
+ self.last_status_line = f"current model: {self.model}"
2294
+
2295
+ # ── Copy finding for AI agent ─────────────────────────────────
2296
+
2297
+ @staticmethod
2298
+ def _clipboard_write(text: str) -> tuple[bool, str]:
2299
+ """Write *text* to the system clipboard. Returns (success, tool_used)."""
2300
+ import subprocess
2301
+ import shutil
2302
+
2303
+ for tool, args in (
2304
+ ("pbcopy", ["pbcopy"]), # macOS
2305
+ ("wl-copy", ["wl-copy"]), # Wayland
2306
+ ("xclip", ["xclip", "-selection", "clipboard"]), # X11
2307
+ ("xsel", ["xsel", "--clipboard", "--input"]), # X11 alt
2308
+ ("clip", ["clip"]), # Windows
2309
+ ):
2310
+ if shutil.which(args[0]) is None:
2311
+ continue
2312
+ try:
2313
+ proc = subprocess.run(
2314
+ args, input=text.encode("utf-8"),
2315
+ timeout=2, check=False,
2316
+ )
2317
+ if proc.returncode == 0:
2318
+ return True, tool
2319
+ except Exception:
2320
+ continue
2321
+ return False, ""
2322
+
2323
+ @staticmethod
2324
+ def _format_finding_for_agent(f: Finding) -> str:
2325
+ """Format the finding as a self-contained prompt for an AI coding agent."""
2326
+ lines: list[str] = [
2327
+ "Please fix this security vulnerability in my codebase.",
2328
+ "",
2329
+ f"# {f.title}",
2330
+ "",
2331
+ ]
2332
+ meta_bits = [f"**Severity:** {f.severity.upper()}"]
2333
+ if f.category:
2334
+ meta_bits.append(f"**Category:** {f.category}")
2335
+ if getattr(f, "cvss_score", None):
2336
+ meta_bits.append(f"**CVSS:** {f.cvss_score:.1f}")
2337
+ lines.append(" • ".join(meta_bits))
2338
+ if f.file_path:
2339
+ loc = f.file_path
2340
+ if getattr(f, "line_number", None):
2341
+ loc += f":{f.line_number}"
2342
+ lines.append(f"**Location:** `{loc}`")
2343
+ lines.append("")
2344
+
2345
+ if f.description:
2346
+ lines += ["## Description", "", f.description, ""]
2347
+
2348
+ snippet = getattr(f, "code_snippet", None)
2349
+ if snippet:
2350
+ # Try to infer the fence language from the file extension.
2351
+ lang = ""
2352
+ if f.file_path:
2353
+ ext = f.file_path.rsplit(".", 1)[-1].lower() if "." in f.file_path else ""
2354
+ lang = {
2355
+ "ts": "typescript", "tsx": "typescript",
2356
+ "js": "javascript", "jsx": "javascript",
2357
+ "py": "python", "rb": "ruby", "go": "go",
2358
+ "rs": "rust", "java": "java", "kt": "kotlin",
2359
+ "c": "c", "cpp": "cpp", "cs": "csharp",
2360
+ "php": "php", "swift": "swift",
2361
+ }.get(ext, ext)
2362
+ lines += ["## Vulnerable code", "", f"```{lang}", snippet, "```", ""]
2363
+
2364
+ fix = getattr(f, "fix", None)
2365
+ if fix:
2366
+ lines += ["## Recommended fix", "", fix, ""]
2367
+
2368
+ if f.file_path:
2369
+ lines.append(f"Apply the recommended fix to `{f.file_path}`.")
2370
+
2371
+ return "\n".join(lines)
2372
+
2373
+ def _cmd_copy_fix(self) -> None:
2374
+ findings = self._current_findings()
2375
+ if not findings:
2376
+ self.last_status_line = "no finding selected"
2377
+ return
2378
+ if self.findings_selected >= len(findings):
2379
+ self.last_status_line = "no finding selected"
2380
+ return
2381
+ f = findings[self.findings_selected]
2382
+ text = self._format_finding_for_agent(f)
2383
+ ok, tool = self._clipboard_write(text)
2384
+ if ok:
2385
+ self.last_status_line = (
2386
+ f"copied {len(text):,} chars to clipboard via {tool} · "
2387
+ f"paste into Codex / Claude Code / OpenCode"
2388
+ )
2389
+ else:
2390
+ self.last_status_line = (
2391
+ "couldn't find a clipboard tool (pbcopy/xclip/wl-copy/clip)"
2392
+ )
2393
+
2394
+ # ── Sessions overlay ──────────────────────────────────────────
2395
+
2396
+ @staticmethod
2397
+ def _pid_alive(pid: int) -> bool:
2398
+ """Cheap liveness check — `kill -0` doesn't actually signal."""
2399
+ try:
2400
+ os.kill(pid, 0)
2401
+ return True
2402
+ except (OSError, ProcessLookupError):
2403
+ return False
2404
+
2405
+ def _resume_selected_session(self) -> None:
2406
+ """Resume an aborted scan by kicking off a fresh scan against the
2407
+ same target. The prior aborted scan's data stays preserved as a
2408
+ separate session — this is coarse resume (re-scan the target),
2409
+ not mid-scan resume from a step. Findings from the old scan can
2410
+ still be viewed via Enter on the aborted row.
2411
+ """
2412
+ if not self.sessions_index:
2413
+ return
2414
+ row = self.sessions_index[self.sessions_selected]
2415
+ target = row.get("target") or ""
2416
+ if not target or not Path(target).exists():
2417
+ self.last_status_line = f"target no longer exists: {target}"
2418
+ return
2419
+ status = (row.get("status") or "").lower()
2420
+ if status not in ("aborted", "failed", "cancelled"):
2421
+ self.last_status_line = f"can only resume aborted/failed scans (this one is {status})"
2422
+ return
2423
+ self._close_sessions_overlay()
2424
+ self._start_scan(target)
2425
+ self.last_status_line = f"resuming: re-scanning {self._short_target(target)}"
2426
+
2427
+
2428
+ def _open_sessions_overlay(self) -> None:
2429
+ """Open the sessions picker as a full-screen overlay."""
2430
+ self._refresh_sessions_index()
2431
+ self.previous_mode = self.mode # remember where to go back on Esc
2432
+ self.mode = "sessions"
2433
+ if not self.sessions_index:
2434
+ self.last_status_line = "no saved scans yet — completed scans are saved to ~/.openhack/scans/"
2435
+ else:
2436
+ self.last_status_line = (
2437
+ f"{len(self.sessions_index)} session(s) · ↑/↓ navigate · enter load · r resume (aborted) · esc back"
2438
+ )
2439
+
2440
+ def _close_sessions_overlay(self) -> None:
2441
+ """Return from the sessions overlay to whatever screen the user was on."""
2442
+ target_mode = self.previous_mode or "landing"
2443
+ self.mode = target_mode
2444
+ self.previous_mode = None
2445
+ self.last_status_line = ""
2446
+
2447
+ def _refresh_sessions_index(self) -> None:
2448
+ scans_dir = Path.home() / ".openhack" / "scans"
2449
+ self.sessions_index = []
2450
+ if not scans_dir.exists():
2451
+ return
2452
+ rows: list[tuple[float, dict]] = []
2453
+ for p in scans_dir.glob("*.json"):
2454
+ try:
2455
+ with open(p) as fp:
2456
+ data = json.load(fp)
2457
+ except (OSError, json.JSONDecodeError):
2458
+ continue
2459
+ findings = data.get("findings", []) or []
2460
+ sev_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
2461
+ for f in findings:
2462
+ sev = (f.get("severity") or "info").lower()
2463
+ sev_counts[sev] = sev_counts.get(sev, 0) + 1
2464
+ top_sev = next((s.upper() for s in ("critical", "high", "medium", "low", "info")
2465
+ if sev_counts.get(s, 0) > 0), "—")
2466
+ target = data.get("target_dir") or "(unknown)"
2467
+ started = data.get("started_at") or ""
2468
+ try:
2469
+ started_display = datetime.fromisoformat(started).strftime("%Y-%m-%d %H:%M")
2470
+ except ValueError:
2471
+ started_display = started[:16]
2472
+ duration = data.get("duration_seconds") or 0
2473
+ dur_m, dur_s = divmod(int(duration), 60)
2474
+ duration_display = f"{dur_m}:{dur_s:02d}"
2475
+ scan_id = data.get("scan_id") or p.stem
2476
+ short_id = scan_id[:8]
2477
+
2478
+ # Resolve true status: a "running" report whose PID is no longer
2479
+ # alive means the terminal closed mid-scan — reclassify as aborted.
2480
+ raw_status = (data.get("status") or "completed").lower()
2481
+ status = raw_status
2482
+ if raw_status == "running":
2483
+ pid = data.get("pid")
2484
+ if not (isinstance(pid, int) and self._pid_alive(pid)):
2485
+ status = "aborted"
2486
+
2487
+ label = f"{short_id} {self._short_target(target)}"
2488
+ meta = (
2489
+ f"[{status}] {started_display} · {len(findings)} findings · "
2490
+ f"top {top_sev} · {duration_display}"
2491
+ )
2492
+ row = {
2493
+ "path": p,
2494
+ "scan_id": scan_id,
2495
+ "label": label,
2496
+ "meta": meta,
2497
+ "target": target,
2498
+ "status": status,
2499
+ "data": data,
2500
+ }
2501
+ rows.append((p.stat().st_mtime, row))
2502
+ rows.sort(key=lambda x: x[0], reverse=True)
2503
+ self.sessions_index = [r for _, r in rows]
2504
+ if self.sessions_selected >= len(self.sessions_index):
2505
+ self.sessions_selected = max(0, len(self.sessions_index) - 1)
2506
+
2507
+ def _load_selected_session(self) -> None:
2508
+ if not self.sessions_index:
2509
+ return
2510
+ row = self.sessions_index[self.sessions_selected]
2511
+ data = row.get("data") or {}
2512
+ findings_raw = data.get("findings", []) or []
2513
+ loaded: list[Finding] = []
2514
+ for fd in findings_raw:
2515
+ try:
2516
+ # JSON uses camelCase keys (via Finding.to_dict). Accept either.
2517
+ loaded.append(Finding(
2518
+ category=fd.get("category", "") or "",
2519
+ severity=(fd.get("severity") or "info"),
2520
+ title=fd.get("title", "") or "",
2521
+ description=fd.get("description", "") or "",
2522
+ file_path=fd.get("file_path") or fd.get("filePath") or "",
2523
+ line_number=fd.get("line_number") or fd.get("lineNumber"),
2524
+ code_snippet=fd.get("code_snippet") or fd.get("relevantCode"),
2525
+ poc=fd.get("poc"),
2526
+ fix=fd.get("fix") or fd.get("recommendation"),
2527
+ cvss_score=fd.get("cvss_score") or fd.get("cvssScore"),
2528
+ confidence=fd.get("confidence", "medium"),
2529
+ validated=bool(fd.get("validated", False)),
2530
+ ))
2531
+ except Exception:
2532
+ continue
2533
+ self.last_findings = loaded
2534
+ # Build a placeholder scan with a frozen clock. start_time / end_time
2535
+ # must share the same time base — we anchor on the first trace
2536
+ # event's epoch timestamp so per-event [m:ss] offsets read sanely,
2537
+ # then set end_time = start_time + duration so the header shows the
2538
+ # actual duration (not start-epoch arithmetic).
2539
+ scan = ScanState(target=row.get("target") or "")
2540
+ scan.cost = float((data.get("cost") or {}).get("total_cost") or 0.0)
2541
+ duration = float(data.get("duration_seconds") or 0)
2542
+
2543
+ # Hydrate saved trace events (version 2+ reports). Older reports
2544
+ # have no trace field — Trace tab will show "no trace yet" for those.
2545
+ trace_raw = data.get("trace") or []
2546
+ first_ts: Optional[float] = None
2547
+ for entry_data in trace_raw:
2548
+ try:
2549
+ entry = TraceEntry(
2550
+ timestamp=float(entry_data.get("timestamp") or 0),
2551
+ agent=entry_data.get("agent", "") or "",
2552
+ event_type=entry_data.get("event_type", "") or "",
2553
+ content=entry_data.get("content"),
2554
+ tool_name=entry_data.get("tool_name"),
2555
+ tool_input=entry_data.get("tool_input"),
2556
+ tool_output=entry_data.get("tool_output"),
2557
+ )
2558
+ if first_ts is None and entry.timestamp > 0:
2559
+ first_ts = entry.timestamp
2560
+ scan.start_time = first_ts
2561
+ scan.update_from_trace(entry)
2562
+ except Exception:
2563
+ continue
2564
+
2565
+ # Anchor end_time relative to start_time so elapsed_str() reports
2566
+ # the actual duration. If no trace events were saved (older reports),
2567
+ # fall back to start_time=0 + end_time=duration.
2568
+ if first_ts is not None:
2569
+ scan.end_time = first_ts + duration
2570
+ else:
2571
+ scan.start_time = 0
2572
+ scan.end_time = duration
2573
+
2574
+ self.scan = scan
2575
+ self.viewing_target = row.get("target") or ""
2576
+ self.mode = "viewing"
2577
+ self.previous_mode = None
2578
+ self.active_tab = "findings"
2579
+ self.last_status_line = (
2580
+ f"loaded {row.get('scan_id', '')[:8]} · "
2581
+ f"{len(loaded)} findings · {row.get('meta', '')}"
2582
+ )
2583
+
2584
+ def _cmd_cost(self) -> None:
2585
+ sess = self.last_session or self.session
2586
+ if not sess:
2587
+ self.last_status_line = "no scan has been run yet"
2588
+ return
2589
+ b = sess.get_cost_breakdown()
2590
+ self.last_status_line = (
2591
+ f"cost: ${b['total_cost']:.4f} · tokens: {b['total_tokens']:,}"
2592
+ )
2593
+
2594
+ def _cmd_findings(self) -> None:
2595
+ findings = (self.last_session.findings if self.last_session else None) or self.last_findings
2596
+ if not findings:
2597
+ self.last_status_line = "no findings to display"
2598
+ return
2599
+ self.last_findings = list(findings)
2600
+ self.last_status_line = f"{len(findings)} finding(s)"
2601
+
2602
+ def _cmd_config(self, arg: str) -> None:
2603
+ if not arg.strip():
2604
+ cfg = load_user_config()
2605
+ self.last_status_line = "config: " + ", ".join(
2606
+ f"{k}={'***' if 'api_key' in k and v else v}" for k, v in cfg.items() if v
2607
+ )
2608
+ return
2609
+ parts = arg.strip().split(None, 1)
2610
+ key = parts[0].lower()
2611
+ value = parts[1] if len(parts) > 1 else ""
2612
+ valid = {"provider", "model", "openhack_api_key", "openhack_model_id"}
2613
+ if key not in valid:
2614
+ self.last_status_line = f"unknown config key: {key}"
2615
+ return
2616
+ if not value:
2617
+ cfg = load_user_config()
2618
+ current = cfg.get(key, "")
2619
+ self.last_status_line = f"{key} = {'***' if 'api_key' in key and current else current or '(not set)'}"
2620
+ return
2621
+ save_user_config({key: value})
2622
+ if key == "provider":
2623
+ self.provider = resolve_provider(value)
2624
+ self.model = PROVIDER_DEFAULTS.get(value, self.model)
2625
+ save_user_config({"provider": self.provider})
2626
+ elif key == "model":
2627
+ self.model = value
2628
+ self.last_status_line = f"saved {key}"
2629
+
2630
+ # ── Setup / login (delegate to setup.py / auth.py) ────────────
2631
+
2632
+ async def _cmd_setup(self) -> None:
2633
+ if self.mode == "scanning":
2634
+ self.last_status_line = "cannot run setup while a scan is in progress"
2635
+ return
2636
+ # Setup wizard is a separate full-screen flow; we need to suspend our
2637
+ # app to let it use the terminal.
2638
+ await self._run_external(run_setup_command())
2639
+ reload_settings()
2640
+ cfg = load_user_config()
2641
+ self.provider = resolve_provider(cfg.get("provider", settings.llm_provider))
2642
+ self.model = cfg.get("model") or PROVIDER_DEFAULTS.get(self.provider, settings.openhack_model_id)
2643
+ self.org_name = cfg.get("openhack_org_name") or self.org_name
2644
+ self.last_status_line = f"active: {self.provider} · {self.model}"
2645
+
2646
+ async def _cmd_login(self) -> None:
2647
+ if self.mode == "scanning":
2648
+ self.last_status_line = "cannot log in while a scan is in progress"
2649
+ return
2650
+ from openhack.auth import (
2651
+ DeviceLoginCancelled,
2652
+ DeviceLoginError,
2653
+ DeviceLoginExpired,
2654
+ device_login,
2655
+ )
2656
+ cfg = load_user_config()
2657
+ app_url = cfg.get("openhack_app_url") or settings.openhack_app_url
2658
+
2659
+ async def _do_login():
2660
+ try:
2661
+ return await device_login(app_url)
2662
+ except DeviceLoginCancelled:
2663
+ self.last_status_line = "login cancelled"
2664
+ except DeviceLoginExpired as exc:
2665
+ self.last_status_line = f"login expired: {exc}"
2666
+ except DeviceLoginError as exc:
2667
+ self.last_status_line = f"login failed: {exc}"
2668
+ return None
2669
+
2670
+ result = await self._run_external(_do_login())
2671
+ if not result:
2672
+ return
2673
+ new_cfg: dict = {"provider": "openhack", "openhack_api_key": result.token}
2674
+ if result.org_id: new_cfg["openhack_org_id"] = result.org_id
2675
+ if result.org_slug: new_cfg["openhack_org_slug"] = result.org_slug
2676
+ if result.org_name: new_cfg["openhack_org_name"] = result.org_name
2677
+ if result.user_email: new_cfg["openhack_user_email"] = result.user_email
2678
+ if result.user_first_name: new_cfg["openhack_user_first_name"] = result.user_first_name
2679
+ if result.user_last_name: new_cfg["openhack_user_last_name"] = result.user_last_name
2680
+ save_user_config(new_cfg)
2681
+ reload_settings()
2682
+ self.org_name = result.org_name or self.org_name
2683
+ self.last_status_line = f"logged in · {result.org_name or ''}"
2684
+
2685
+ def _cmd_logout(self) -> None:
2686
+ cfg = load_user_config()
2687
+ if not cfg.get("openhack_api_key"):
2688
+ self.last_status_line = "not signed in"
2689
+ return
2690
+ first = cfg.get("openhack_user_first_name") or ""
2691
+ last = cfg.get("openhack_user_last_name") or ""
2692
+ email = cfg.get("openhack_user_email") or ""
2693
+ who = " ".join(p for p in (first, last) if p).strip() or email or "current user"
2694
+ org = cfg.get("openhack_org_name") or ""
2695
+ target = f"{who} · {org}" if org else who
2696
+ self._open_modal(
2697
+ "logout",
2698
+ "Sign out?",
2699
+ f"You're about to sign out from {target}.\n\n"
2700
+ f"The saved API token will be cleared from ~/.openhack/config. "
2701
+ f"You can sign back in any time with /login.",
2702
+ self._do_logout,
2703
+ )
2704
+
2705
+ def _do_logout(self) -> None:
2706
+ cleared = {
2707
+ "openhack_api_key": None,
2708
+ "openhack_org_id": None,
2709
+ "openhack_org_slug": None,
2710
+ "openhack_org_name": None,
2711
+ "openhack_user_email": None,
2712
+ "openhack_user_first_name": None,
2713
+ "openhack_user_last_name": None,
2714
+ }
2715
+ # `save_user_config` merges into the existing JSON, so None values
2716
+ # would just be ignored. We need to physically remove them.
2717
+ try:
2718
+ existing = load_user_config()
2719
+ for k in cleared:
2720
+ existing.pop(k, None)
2721
+ from openhack.config import CONFIG_PATH
2722
+ import json as _json, os as _os
2723
+ with open(CONFIG_PATH, "w") as fp:
2724
+ _json.dump(existing, fp, indent=2)
2725
+ fp.write("\n")
2726
+ try:
2727
+ _os.chmod(CONFIG_PATH, 0o600)
2728
+ except OSError:
2729
+ pass
2730
+ except Exception as exc:
2731
+ self.last_status_line = f"sign-out failed: {exc}"
2732
+ self._logout_armed = False
2733
+ return
2734
+
2735
+ reload_settings()
2736
+ self.org_name = ""
2737
+ self.user_email = ""
2738
+ self.scan = None
2739
+ self.session = None
2740
+ self.mode = "landing"
2741
+ self.active_tab = "trace"
2742
+ self._logout_armed = False
2743
+ self.last_status_line = "signed out · run /login to sign back in"
2744
+ self._invalidate()
2745
+
2746
+ # ── Modal helpers ─────────────────────────────────────────────
2747
+
2748
+ def _open_modal(self, kind: str, title: str, body: str,
2749
+ on_yes: Callable[[], None]) -> None:
2750
+ self._modal_kind = kind
2751
+ self._modal_title = title
2752
+ self._modal_body = body
2753
+ self._modal_on_yes = on_yes
2754
+ self._invalidate()
2755
+
2756
+ def _close_modal(self) -> None:
2757
+ self._modal_kind = None
2758
+ self._modal_title = ""
2759
+ self._modal_body = ""
2760
+ self._modal_on_yes = None
2761
+
2762
+ def _show_announcement_modal(self, ann: Announcement) -> None:
2763
+ """Display an announcement as a modal dialog. On dismiss, persist
2764
+ the announcement ID so it won't appear again (unless critical)."""
2765
+ def _dismiss():
2766
+ save_dismissed(ann.id)
2767
+
2768
+ # Critical announcements can't be dismissed without acknowledging.
2769
+ title = ann.title or "Announcement"
2770
+ body = ann.body or ""
2771
+ self._open_modal(f"announcement:{ann.id}", title, body, _dismiss)
2772
+ self._invalidate()
2773
+
2774
+ def _cmd_discord(self) -> None:
2775
+ url = "https://openhack.com/discord"
2776
+ try:
2777
+ import webbrowser
2778
+ webbrowser.open(url)
2779
+ self.last_status_line = f"opened {url} in your browser"
2780
+ except Exception as exc:
2781
+ self.last_status_line = f"couldn't open browser: {exc} · visit {url}"
2782
+ self._invalidate()
2783
+
2784
+ def _cmd_mouse(self, arg: str) -> None:
2785
+ """Toggle mouse capture. When off, native terminal drag-to-select works
2786
+ (so users can copy text), at the cost of mouse-wheel scrolling and
2787
+ click-to-select inside the TUI. Keyboard nav still works either way.
2788
+ """
2789
+ a = arg.strip().lower()
2790
+ if a in ("on", "true", "1"):
2791
+ self._mouse_enabled = True
2792
+ elif a in ("off", "false", "0"):
2793
+ self._mouse_enabled = False
2794
+ else:
2795
+ self._mouse_enabled = not self._mouse_enabled
2796
+ if self._mouse_enabled:
2797
+ self.last_status_line = (
2798
+ "mouse ON · wheel scroll & click work · /mouse off to enable drag-to-copy"
2799
+ )
2800
+ else:
2801
+ self.last_status_line = (
2802
+ "mouse OFF · drag to select & copy text · /mouse on to re-enable"
2803
+ )
2804
+ self._invalidate()
2805
+
2806
+ # ── Verify (sandbox / browser) ────────────────────────────────
2807
+
2808
+ _VERIFY_PREREQS = {
2809
+ "sandbox": (
2810
+ "SANDBOX needs: (1) Docker Desktop or daemon running · "
2811
+ "(2) Dockerfile OR docker-compose.yml at the scan target's root · "
2812
+ "(3) the app must start and respond to a health check on / · "
2813
+ "(4) a free localhost port the sandbox can bind to."
2814
+ ),
2815
+ "browser": (
2816
+ "BROWSER needs: (1) the browser extra installed → "
2817
+ "`uv sync --extra browser` · "
2818
+ "(2) Chromium installed → `uv run playwright install chromium` · "
2819
+ "(3) the target app reachable over HTTP — usually means sandbox "
2820
+ "verification is also on (so the app is running)."
2821
+ ),
2822
+ }
2823
+
2824
+ def _cmd_verify(self, arg: str) -> None:
2825
+ """Run sandbox or browser verification against the currently-loaded
2826
+ session's findings. /verify is an *action*, not a settings toggle —
2827
+ the user loads a session via /sessions or finishes a scan, then runs
2828
+ /verify sandbox or /verify browser to add verification evidence to
2829
+ the existing findings.
2830
+ """
2831
+ parts = arg.strip().split()
2832
+ logging.getLogger("openhack.tui").info("/verify dispatched: arg=%r", arg)
2833
+
2834
+ if not parts:
2835
+ self.last_status_line = (
2836
+ "usage: /verify <sandbox|browser> "
2837
+ "— runs verification against the loaded session's findings"
2838
+ )
2839
+ return
2840
+
2841
+ kind = parts[0].lower()
2842
+ if kind not in ("sandbox", "browser"):
2843
+ self.last_status_line = f"unknown subject: {kind} (use sandbox/browser)"
2844
+ return
2845
+
2846
+ if self.mode == "scanning" and self.scan_task is not None:
2847
+ self.last_status_line = "a scan is already running · wait for it to finish first"
2848
+ return
2849
+
2850
+ findings = self._current_findings()
2851
+ if not findings:
2852
+ self.last_status_line = "no findings loaded — finish a scan or load a session from /sessions first"
2853
+ return
2854
+
2855
+ # Resolve the target directory: viewing mode stores it on viewing_target,
2856
+ # otherwise pull from the currently-loaded session.
2857
+ target_dir = (
2858
+ self.viewing_target
2859
+ or (self.scan.target if self.scan and self.scan.target else "")
2860
+ )
2861
+ if not target_dir or not Path(target_dir).exists():
2862
+ self.last_status_line = (
2863
+ f"target directory not accessible: {target_dir or '(unknown)'}"
2864
+ )
2865
+ return
2866
+
2867
+ title = f"Run {kind} verification on {len(findings)} finding(s)?"
2868
+ body = (
2869
+ f"{self._VERIFY_PREREQS[kind]}\n\n"
2870
+ f"Target: {target_dir}\n"
2871
+ f"This will spin up the verification swarm against the loaded findings, "
2872
+ f"stream events into the Trace tab, and write a new report to "
2873
+ f"~/.openhack/scans/ when it finishes."
2874
+ )
2875
+
2876
+ def _apply():
2877
+ task = asyncio.create_task(self._run_verification(kind, target_dir, list(findings)))
2878
+ self.scan_task = task
2879
+
2880
+ self._open_modal(f"verify:{kind}", title, body, _apply)
2881
+
2882
+ async def _run_verification(self, kind: str, target_dir: str,
2883
+ findings: list[Finding]) -> None:
2884
+ """Spin up the sandbox/browser verifier swarm against an existing
2885
+ findings set. Streams trace events live and writes a new report when done.
2886
+ """
2887
+ reload_settings()
2888
+
2889
+ # Preserve the loaded scan's existing trace and findings — verification
2890
+ # is an *extension* of an existing scan, not a fresh run. We mutate the
2891
+ # current ScanState (created either by the previous scan or by
2892
+ # _open_session) so:
2893
+ # • trace_lines / trace_agents from the original scan stay intact
2894
+ # • the new sandbox/browser swarms append to that same trace
2895
+ # • scan.findings already has every finding ready for the Findings tab
2896
+ if self.scan is None:
2897
+ self.scan = ScanState(target=target_dir)
2898
+ # Reset the clock so the elapsed counter reflects this verification run.
2899
+ self.scan.start_time = time.time()
2900
+ self.scan.end_time = None
2901
+ # Ensure scan.findings holds the findings we're about to verify so the
2902
+ # Findings tab reads them in scanning mode. (When loaded from /sessions
2903
+ # the trace got hydrated but findings live on self.last_findings — we
2904
+ # mirror them onto scan.findings here.) Use the *same* objects so the
2905
+ # verifier's mutations show up on the rendered list.
2906
+ if not self.scan.findings:
2907
+ self.scan.findings = list(findings)
2908
+ self.last_findings = list(findings)
2909
+ self.mode = "scanning"
2910
+ self.active_tab = "trace"
2911
+ self._invalidate()
2912
+
2913
+ session: Optional[Session] = None
2914
+ try:
2915
+ session = Session(
2916
+ target_dir=target_dir,
2917
+ on_trace=self._on_trace,
2918
+ )
2919
+ # Seed the verifier with the findings being verified — same Finding
2920
+ # *objects* as scan.findings so the swarm's mutations are visible
2921
+ # in the rendered list without copying.
2922
+ for f in findings:
2923
+ session.findings.append(f)
2924
+ self.session = session
2925
+
2926
+ tools = ToolRegistry(target_dir=Path(target_dir))
2927
+ llm = LLMClient(
2928
+ model=self.model, temperature=0.0, max_tokens=8192,
2929
+ provider=self.provider, prompt_cache_key=session.id,
2930
+ )
2931
+
2932
+ if kind == "sandbox":
2933
+ from openhack.agents.sandbox_verifier_swarm import SandboxVerifierSwarmAgent
2934
+ from openhack.sandbox.orchestrator import SandboxConfig
2935
+ sandbox_cfg = SandboxConfig(
2936
+ health_check_path=settings.sandbox_health_check_path,
2937
+ health_check_timeout=settings.sandbox_health_check_timeout,
2938
+ teardown_on_complete=settings.sandbox_teardown_on_complete,
2939
+ )
2940
+ swarm = SandboxVerifierSwarmAgent(
2941
+ llm, tools, session, sandbox_config=sandbox_cfg,
2942
+ )
2943
+ else:
2944
+ from openhack.agents.browser_verifier_swarm import BrowserVerifierSwarmAgent
2945
+ from openhack.sandbox.orchestrator import SandboxConfig
2946
+ sandbox_cfg = SandboxConfig(
2947
+ health_check_path=settings.sandbox_health_check_path,
2948
+ health_check_timeout=settings.sandbox_health_check_timeout,
2949
+ teardown_on_complete=settings.sandbox_teardown_on_complete,
2950
+ )
2951
+ swarm = BrowserVerifierSwarmAgent(
2952
+ llm, tools, session, sandbox_config=sandbox_cfg,
2953
+ )
2954
+
2955
+ # The swarm reads findings from context["confirmed_findings"] as dicts.
2956
+ findings_dicts = [f.to_dict() for f in findings]
2957
+ result = await swarm.run(
2958
+ f"Run {kind} verification on the loaded findings.",
2959
+ context={"confirmed_findings": findings_dicts},
2960
+ )
2961
+
2962
+ # The swarm returns lists of {finding_index, status, evidence, ...}.
2963
+ # Stamp the matching Finding objects so the Findings tab can render
2964
+ # a ✓ next to verified ones. Findings are mutated in place, which
2965
+ # `self.scan.findings` shares — the UI picks up the changes on the
2966
+ # next invalidate.
2967
+ exploitable = (result or {}).get("exploitable") or []
2968
+ verified_by = "sandbox" if kind == "sandbox" else "browser"
2969
+ verified_count = 0
2970
+ for item in exploitable:
2971
+ idx = item.get("finding_index")
2972
+ if idx is None or idx >= len(findings):
2973
+ continue
2974
+ f = findings[idx]
2975
+ # source is a comma-joined string when multiple verifiers have
2976
+ # validated the same finding (e.g., "sandbox,browser").
2977
+ existing = {s.strip() for s in (f.source or "").split(",") if s.strip()}
2978
+ existing.add(verified_by)
2979
+ f.source = ",".join(sorted(existing))
2980
+ evidence = item.get("evidence")
2981
+ if evidence and not f.poc:
2982
+ f.poc = evidence
2983
+ verified_count += 1
2984
+
2985
+ # Persist the now-annotated findings so the user can find them in /sessions.
2986
+ fatal = (result or {}).get("fatal_error")
2987
+ status = "failed" if fatal else "completed"
2988
+ self._write_report(session, target_dir, status=status)
2989
+ self.last_findings = list(session.findings)
2990
+ self.last_session = session
2991
+ if fatal:
2992
+ self.last_status_line = (
2993
+ f"{kind} verification aborted · {fatal}"
2994
+ )
2995
+ else:
2996
+ self.last_status_line = (
2997
+ f"{kind} verification complete · "
2998
+ f"{verified_count}/{len(findings)} verified · "
2999
+ f"report saved to ~/.openhack/scans/{session.id[:8]}.json"
3000
+ )
3001
+
3002
+ except asyncio.CancelledError:
3003
+ self.last_status_line = f"{kind} verification cancelled"
3004
+ if session is not None:
3005
+ self._write_report(session, target_dir, status="cancelled")
3006
+ raise
3007
+ except Exception as exc:
3008
+ self.last_status_line = f"{kind} verification failed: {exc}"
3009
+ if session is not None:
3010
+ self._write_report(session, target_dir, status="failed")
3011
+ finally:
3012
+ if self.scan is not None:
3013
+ self.scan.finish()
3014
+ self.scan_task = None
3015
+ self.active_tab = "findings"
3016
+ self.findings_selected = 0
3017
+ self._invalidate()
3018
+
3019
+ async def _run_external(self, awaitable):
3020
+ """Suspend the full-screen app, run an external async flow, then resume."""
3021
+ # Prompt_toolkit's run_in_terminal lets us yield the terminal to a
3022
+ # non-app process. The 'in_executor=False' default suits async work.
3023
+ from prompt_toolkit.application.run_in_terminal import in_terminal
3024
+ result_holder = {}
3025
+
3026
+ async def _runner():
3027
+ try:
3028
+ result_holder["v"] = await awaitable
3029
+ except Exception as exc: # surface any exception
3030
+ result_holder["err"] = exc
3031
+
3032
+ async with in_terminal():
3033
+ await _runner()
3034
+ if "err" in result_holder:
3035
+ self.last_status_line = f"error: {result_holder['err']}"
3036
+ return None
3037
+ return result_holder.get("v")
3038
+
3039
+ # ── Scan kickoff ──────────────────────────────────────────────
3040
+
3041
+ def _start_scan(self, target_dir: str) -> None:
3042
+ if self.mode == "scanning":
3043
+ self.last_status_line = "a scan is already in progress"
3044
+ return
3045
+ self.scan = ScanState(target=target_dir)
3046
+ self.mode = "scanning"
3047
+ self.active_tab = "trace"
3048
+ self.viewing_target = ""
3049
+ self._cancel_armed = False
3050
+ self.scan_task = asyncio.create_task(self._run_scan(target_dir))
3051
+
3052
+ def _start_test_scan(self) -> None:
3053
+ if self.mode == "scanning":
3054
+ self.last_status_line = "a scan is already in progress"
3055
+ return
3056
+ self.scan = ScanState(target=os.getcwd() + " (test)")
3057
+ self.mode = "scanning"
3058
+ self.active_tab = "trace"
3059
+ self.viewing_target = ""
3060
+ self._cancel_armed = False
3061
+ self.scan_task = asyncio.create_task(self._run_test_scan())
3062
+
3063
+ def _cancel_scan(self) -> None:
3064
+ if self.mode != "scanning":
3065
+ self.last_status_line = "no scan is running"
3066
+ return
3067
+ self.last_status_line = "cancelling…"
3068
+ if self.session:
3069
+ self.session.cancel()
3070
+ if self.scan_task and not self.scan_task.done():
3071
+ self.scan_task.cancel()
3072
+
3073
+ def _pause_scan(self) -> None:
3074
+ if self.mode != "scanning" or self.session is None:
3075
+ self.last_status_line = "no scan is running"
3076
+ return
3077
+ if self.session.paused:
3078
+ self.last_status_line = "scan is already paused · /resume to continue"
3079
+ return
3080
+ self.session.pause()
3081
+ self.last_status_line = "scan paused · /resume to continue · /cancel to stop"
3082
+ self._invalidate()
3083
+
3084
+ def _resume_scan(self) -> None:
3085
+ if self.mode != "scanning" or self.session is None:
3086
+ self.last_status_line = "no scan is running"
3087
+ return
3088
+ if not self.session.paused:
3089
+ self.last_status_line = "scan is not paused"
3090
+ return
3091
+ self.session.resume()
3092
+ self.last_status_line = "scan resumed"
3093
+ self._invalidate()
3094
+
3095
+ def _on_trace(self, entry: TraceEntry) -> None:
3096
+ if self.scan is None:
3097
+ return
3098
+ self.scan.update_from_trace(entry)
3099
+ # Live-tick the elapsed clock by invalidating.
3100
+ self._invalidate()
3101
+
3102
+ async def _run_scan(self, target_dir: str) -> None:
3103
+ reload_settings()
3104
+ session: Optional[Session] = None
3105
+ try:
3106
+ project_context = build_project_context(target_dir)
3107
+ session = Session(
3108
+ target_dir=target_dir,
3109
+ on_trace=self._on_trace,
3110
+ project_context=project_context,
3111
+ )
3112
+ self.session = session
3113
+
3114
+ # Wrap on_trace to also persist on key milestones (step_complete,
3115
+ # finding_added) so a crashed scan still leaves a readable report.
3116
+ def _checkpoint(entry: TraceEntry) -> None:
3117
+ self._on_trace(entry)
3118
+ if entry.event_type in ("step_complete", "swarm_complete", "finding_added"):
3119
+ self._write_report(session, target_dir, status="running")
3120
+
3121
+ session._on_trace = _checkpoint # type: ignore[attr-defined]
3122
+
3123
+ # Wrap add_finding to bubble findings into ScanState + persist.
3124
+ original_add_finding = session.add_finding
3125
+
3126
+ def _patched_add_finding(f: Finding) -> None:
3127
+ original_add_finding(f)
3128
+ if self.scan is not None:
3129
+ self.scan.findings.append(f)
3130
+ self._write_report(session, target_dir, status="running")
3131
+ self._invalidate()
3132
+
3133
+ session.add_finding = _patched_add_finding # type: ignore[method-assign]
3134
+
3135
+ # Write an initial 'running' report so /sessions sees it immediately.
3136
+ self._write_report(session, target_dir, status="running")
3137
+
3138
+ tools = ToolRegistry(target_dir=Path(target_dir))
3139
+ llm = LLMClient(
3140
+ model=self.model, temperature=0.0, max_tokens=8192,
3141
+ provider=self.provider, prompt_cache_key=session.id,
3142
+ )
3143
+ coordinator = CoordinatorAgent(llm, tools, session)
3144
+ await coordinator.run_full_scan()
3145
+
3146
+ self.last_session = session
3147
+ self.last_findings = list(session.findings)
3148
+ self._write_report(session, target_dir, status="completed")
3149
+ self.last_status_line = (
3150
+ f"scan complete · {len(session.findings)} findings · "
3151
+ f"${session.total_cost:.4f}"
3152
+ )
3153
+
3154
+ except asyncio.CancelledError:
3155
+ if session is not None:
3156
+ self._write_report(session, target_dir, status="cancelled")
3157
+ self.last_status_line = (
3158
+ f"scan cancelled · resume with: openhack resume {session.id}"
3159
+ )
3160
+ else:
3161
+ self.last_status_line = "scan cancelled"
3162
+ raise
3163
+ except Exception as exc:
3164
+ if session is not None:
3165
+ self._write_report(session, target_dir, status="failed")
3166
+ self.last_status_line = (
3167
+ f"scan failed: {exc} · retry with: openhack resume {session.id}"
3168
+ )
3169
+ else:
3170
+ self.last_status_line = f"scan failed: {exc}"
3171
+ finally:
3172
+ if self.scan is not None:
3173
+ self.scan.finish()
3174
+ self.scan_task = None
3175
+ # On scan completion, jump from Trace → Findings so the user
3176
+ # lands on the results without having to switch tabs.
3177
+ self.active_tab = "findings"
3178
+ self.findings_selected = 0
3179
+ self._invalidate()
3180
+
3181
+ def _write_report(
3182
+ self,
3183
+ session: Session,
3184
+ target_dir: str,
3185
+ status: Optional[str] = None,
3186
+ ) -> None:
3187
+ """Atomically write the scan report. Called incrementally during a scan
3188
+ (status='running') and at end (status='completed'/'cancelled'/'failed').
3189
+ """
3190
+ try:
3191
+ report_dir = Path.home() / ".openhack" / "scans"
3192
+ report_dir.mkdir(parents=True, exist_ok=True)
3193
+ report_path = report_dir / f"{session.id}.json"
3194
+ elapsed = time.time() - (self.scan.start_time if self.scan else session.created_at)
3195
+
3196
+ # Serialize trace entries so the Trace tab can re-render later.
3197
+ def _trace_dict(e: TraceEntry) -> dict:
3198
+ tool_output = e.tool_output
3199
+ # Tool outputs can be enormous; cap so reports stay sane.
3200
+ if tool_output is not None and not isinstance(tool_output, (dict, list, int, float, bool)):
3201
+ s = str(tool_output)
3202
+ tool_output = s if len(s) <= 2000 else s[:2000] + "…"
3203
+ return {
3204
+ "timestamp": e.timestamp,
3205
+ "agent": e.agent,
3206
+ "event_type": e.event_type,
3207
+ "content": e.content,
3208
+ "tool_name": e.tool_name,
3209
+ "tool_input": e.tool_input,
3210
+ "tool_output": tool_output,
3211
+ }
3212
+
3213
+ report = {
3214
+ "version": 2,
3215
+ "scan_id": session.id,
3216
+ "target_dir": target_dir,
3217
+ "provider": self.provider,
3218
+ "model": self.model,
3219
+ "status": status or session.status.value,
3220
+ "pid": os.getpid(),
3221
+ "started_at": datetime.fromtimestamp(session.created_at).isoformat(),
3222
+ "duration_seconds": round(elapsed, 2),
3223
+ "cost": session.get_cost_breakdown(),
3224
+ "findings": [f.to_dict() for f in session.findings],
3225
+ "trace": [_trace_dict(e) for e in session.trace],
3226
+ }
3227
+ # Atomic write: temp file + rename to avoid corrupting on crash.
3228
+ tmp_path = report_path.with_suffix(".json.tmp")
3229
+ with open(tmp_path, "w") as fp:
3230
+ json.dump(report, fp, indent=2, default=str, ensure_ascii=False)
3231
+ os.replace(tmp_path, report_path)
3232
+ except Exception:
3233
+ pass
3234
+
3235
+ async def _run_test_scan(self) -> None:
3236
+ import random
3237
+ from openhack.agents.session import Session as _S
3238
+
3239
+ session = _S(target_dir=os.getcwd(), on_trace=self._on_trace)
3240
+ self.session = session
3241
+
3242
+ # Hook live add_finding.
3243
+ original_add_finding = session.add_finding
3244
+
3245
+ def _patched_add_finding(f: Finding) -> None:
3246
+ original_add_finding(f)
3247
+ if self.scan is not None:
3248
+ self.scan.findings.append(f)
3249
+ self._invalidate()
3250
+
3251
+ session.add_finding = _patched_add_finding # type: ignore[method-assign]
3252
+
3253
+ def _d():
3254
+ return random.uniform(0.05, 0.25)
3255
+
3256
+ try:
3257
+ session.add_trace("coordinator", "step_start", "Step 1: Reconnaissance")
3258
+ await asyncio.sleep(_d())
3259
+ for tool in ["get_project_info", "list_dir", "read_file", "get_route_map",
3260
+ "check_dependencies", "grep", "find_dangerous_patterns"]:
3261
+ session.add_trace("recon", "tool_call", "",
3262
+ tool_name=tool, tool_input={"path": "src"})
3263
+ await asyncio.sleep(_d())
3264
+ session.add_trace("recon", "tool_result", "", tool_name=tool,
3265
+ tool_output={"ok": True})
3266
+ session.add_trace("coordinator", "step_complete",
3267
+ {"step": "recon", "cost": 0.04, "tokens": 85000})
3268
+
3269
+ groups = ["input_validation", "access_control", "data_handling"]
3270
+ session.add_trace("hunter_swarm", "swarm_start",
3271
+ {"groups": groups, "group_count": len(groups)})
3272
+ for g in groups:
3273
+ a = f"hunter:{g}"
3274
+ for tool in ["read_file", "grep", "trace_variable"]:
3275
+ session.add_trace(a, "tool_call", "",
3276
+ tool_name=tool, tool_input={"path": "src/lib/auth.ts"})
3277
+ await asyncio.sleep(_d())
3278
+ session.add_trace(a, "tool_result", "", tool_name=tool)
3279
+
3280
+ findings = [
3281
+ ("IDOR", "critical", "src/app/dashboard/[id]/page.tsx",
3282
+ "IDOR in workspace page — no ownership check"),
3283
+ ("SQL Injection", "critical", "src/lib/db.ts",
3284
+ "SQL Injection via queryRawUnsafe"),
3285
+ ("XSS", "high", "src/components/note-card.tsx",
3286
+ "Stored XSS via dangerouslySetInnerHTML"),
3287
+ ("Auth Bypass", "high", "src/app/api/users/route.ts",
3288
+ "Missing auth check on user list endpoint"),
3289
+ ("Open Redirect", "medium", "src/app/api/auth/callback/route.ts",
3290
+ "Unvalidated redirect URL in OAuth callback"),
3291
+ ]
3292
+ for cat, sev, fp, title in findings:
3293
+ session.add_finding(Finding(
3294
+ category=cat, severity=sev, title=title,
3295
+ description=title, file_path=fp,
3296
+ ))
3297
+ await asyncio.sleep(_d())
3298
+
3299
+ session.add_trace("hunter_swarm", "swarm_complete",
3300
+ {"total_findings": len(findings), "total_cost": 0.18})
3301
+ session.add_trace("coordinator", "step_complete",
3302
+ {"step": "hunters", "cost": 0.18, "tokens": 320000})
3303
+
3304
+ session.total_cost = 0.22
3305
+ session.status = SessionStatus.COMPLETED
3306
+ self.last_session = session
3307
+ self.last_findings = list(session.findings)
3308
+ self.last_status_line = (
3309
+ f"test scan complete · {len(session.findings)} findings"
3310
+ )
3311
+ except asyncio.CancelledError:
3312
+ self.last_status_line = "test scan cancelled"
3313
+ raise
3314
+ finally:
3315
+ if self.scan is not None:
3316
+ self.scan.finish()
3317
+ self.scan_task = None
3318
+ self.active_tab = "findings"
3319
+ self.findings_selected = 0
3320
+ self._invalidate()
3321
+
3322
+ # ── Chat ──────────────────────────────────────────────────────
3323
+
3324
+ async def _chat(self, user_message: str) -> None:
3325
+ self.chat_history.append(Message(role="user", content=user_message))
3326
+ reload_settings()
3327
+ try:
3328
+ llm = LLMClient(
3329
+ model=self.model, temperature=0.3, max_tokens=4096,
3330
+ provider=self.provider,
3331
+ )
3332
+ except Exception as exc:
3333
+ self.last_status_line = f"llm error: {exc}"
3334
+ self.chat_history.pop()
3335
+ return
3336
+
3337
+ context_parts = [CHAT_SYSTEM_PROMPT]
3338
+ if self.last_session and self.last_session.findings:
3339
+ summary = []
3340
+ for i, f in enumerate(self.last_session.findings, 1):
3341
+ summary.append(
3342
+ f"{i}. [{f.severity.upper()}] {f.category} - {f.title}"
3343
+ + (f" ({f.file_path})" if f.file_path else "")
3344
+ )
3345
+ context_parts.append("\n\nCurrent scan findings:\n" + "\n".join(summary))
3346
+
3347
+ self.last_status_line = "thinking…"
3348
+ self._invalidate()
3349
+ try:
3350
+ response: LLMResponse = await llm.chat(
3351
+ messages=self.chat_history, system="".join(context_parts),
3352
+ )
3353
+ except Exception as exc:
3354
+ self.last_status_line = f"llm error: {exc}"
3355
+ self.chat_history.pop()
3356
+ return
3357
+
3358
+ reply = (response.content or "").strip() or "(no response)"
3359
+ self.chat_history.append(Message(role="assistant", content=reply))
3360
+ if len(self.chat_history) > 40:
3361
+ self.chat_history = self.chat_history[-30:]
3362
+ # Show the reply as a short status line; full reply truncated for
3363
+ # the status bar — better display will come in v2.
3364
+ self.last_status_line = reply if len(reply) <= 200 else reply[:197] + "…"
3365
+
3366
+ # ── Run ───────────────────────────────────────────────────────
3367
+
3368
+ async def run(self) -> None:
3369
+ # Tick the clock every second while scanning.
3370
+ async def _ticker():
3371
+ while True:
3372
+ await asyncio.sleep(1.0)
3373
+ if self.mode == "scanning":
3374
+ self._invalidate()
3375
+
3376
+ async def _check_updates():
3377
+ info = await fetch_updates()
3378
+ if info is None:
3379
+ return
3380
+ self._update_info = info
3381
+ self._invalidate()
3382
+ # If there are modal-placement announcements, queue the first one
3383
+ # as a modal dialog after a short delay (so it doesn't fight the
3384
+ # landing screen initial render).
3385
+ modal_anns = [a for a in info.announcements if "modal" in a.placement]
3386
+ if modal_anns:
3387
+ await asyncio.sleep(0.5)
3388
+ self._show_announcement_modal(modal_anns[0])
3389
+
3390
+ tick_task = asyncio.create_task(_ticker())
3391
+ asyncio.create_task(_check_updates())
3392
+ try:
3393
+ await self.app.run_async()
3394
+ finally:
3395
+ tick_task.cancel()
3396
+ if self.scan_task and not self.scan_task.done():
3397
+ self.scan_task.cancel()
3398
+ try:
3399
+ await self.scan_task
3400
+ except (asyncio.CancelledError, Exception):
3401
+ pass
3402
+
3403
+
3404
+ def _configure_logging() -> None:
3405
+ """Route all logging to a file so messages don't corrupt the full-screen UI.
3406
+
3407
+ Anything that calls `logger.warning(...)` / `logger.error(..., exc_info=True)`
3408
+ (e.g. LLMClient retries, upstream errors) would otherwise hit stderr and
3409
+ overlap the layout. The log file lives at ~/.openhack/logs/openhack.log.
3410
+ """
3411
+ log_dir = Path.home() / ".openhack" / "logs"
3412
+ try:
3413
+ log_dir.mkdir(parents=True, exist_ok=True)
3414
+ except OSError:
3415
+ return
3416
+ log_path = log_dir / "openhack.log"
3417
+
3418
+ root = logging.getLogger()
3419
+ # Remove any existing StreamHandlers that would write to the terminal.
3420
+ for h in list(root.handlers):
3421
+ if isinstance(h, logging.StreamHandler) and not isinstance(h, logging.FileHandler):
3422
+ root.removeHandler(h)
3423
+ # Add our file handler if not already there.
3424
+ have_file = any(
3425
+ isinstance(h, logging.FileHandler) and Path(getattr(h, "baseFilename", "")) == log_path
3426
+ for h in root.handlers
3427
+ )
3428
+ if not have_file:
3429
+ fh = logging.FileHandler(log_path)
3430
+ fh.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
3431
+ root.addHandler(fh)
3432
+ if root.level == logging.NOTSET or root.level > logging.INFO:
3433
+ root.setLevel(logging.INFO)
3434
+
3435
+
3436
+ def main():
3437
+ signal.signal(signal.SIGHUP, lambda *_: os._exit(1))
3438
+ signal.signal(signal.SIGTERM, lambda *_: os._exit(1))
3439
+ _configure_logging()
3440
+
3441
+ app = OpenHackApp()
3442
+ try:
3443
+ asyncio.run(app.run())
3444
+ except KeyboardInterrupt:
3445
+ pass
3446
+
3447
+
3448
+ # ── Back-compat aliases for existing imports ──────────────────────
3449
+
3450
+ OpenHackCLI = OpenHackApp # legacy name used by __main__.py