This is an automated email from the ASF dual-hosted git repository.

luigidemasi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git

commit c000a818b01b5ab6d1ac9f0c6d9e6e46e6aca519
Author: Luigi De Masi <[email protected]>
AuthorDate: Sat Oct 10 12:49:24 2026 +0200

    CAMEL-25499: Improve the semantic audit inspector and navigation
    
    Show route decisions beside linked evaluation evidence and retained input. 
Add readable settings, filter selectors, local timestamps, decision colours, 
linked-record navigation and pausable live refresh. Include real showcase 
screenshots in the manual.
    
    Co-authored-by: OpenAI Codex <[email protected]>
---
 .../jbang/camel-tui-semantic-audit-allow.png       | Bin 0 -> 407111 bytes
 .../jbang/camel-tui-semantic-audit-block.png       | Bin 0 -> 403161 bytes
 .../ROOT/pages/camel-jbang-tui-observe.adoc        |  31 +-
 .../jbang/core/commands/tui/SemanticAuditView.java | 541 ++++++++++++++++++---
 .../jbang/core/commands/tui/SemanticDetails.java   |   5 +-
 .../src/main/resources/tui/help/semantic.md        |  19 +-
 .../jbang/core/commands/tui/SemanticTabTest.java   | 216 +++++++-
 7 files changed, 724 insertions(+), 88 deletions(-)

diff --git 
a/docs/user-manual/modules/ROOT/images/jbang/camel-tui-semantic-audit-allow.png 
b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-semantic-audit-allow.png
new file mode 100644
index 000000000000..fe23fb84a63b
Binary files /dev/null and 
b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-semantic-audit-allow.png 
differ
diff --git 
a/docs/user-manual/modules/ROOT/images/jbang/camel-tui-semantic-audit-block.png 
b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-semantic-audit-block.png
new file mode 100644
index 000000000000..586f87ffa778
Binary files /dev/null and 
b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-semantic-audit-block.png 
differ
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc
index a1e331ffb55b..2d5e4d43ff1b 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc
@@ -471,10 +471,39 @@ connection and is independent of OpenTelemetry.
 
 Use `/` for exact `field=value` filters (`category`, `action`, `expert`, 
`routeId`,
 `namespace`, `correlationId`, `since`), `n` for older records, `g` for the 
latest page and `r` to refresh.
-Select a record and use `Tab` to focus its inspector, then use the arrows to 
scroll details and linked evidence. The view reports
+The header shows the global audit default, per-expert overrides and 
OpenTelemetry independently,
+with the active filters and reader below. Decisions are coloured by action. 
Timestamps use the
+local timezone shown below the table; the inspector also retains the full 
timestamp.
+
+These captures show auditing enabled for the `security` expert and disabled 
for the
+`decisions` expert, with OpenTelemetry and input capture disabled in these 
screenshots. The expert supplies the evaluation;
+the route records its policy decision separately.
+
+.A blocked request with the linked evaluation that detected prompt injection
+image::jbang/camel-tui-semantic-audit-block.png[Semantic Audit showing a BLOCK 
decision beside its linked security evaluation with result true]
+
+.An allowed request with the linked evaluation that found no prompt injection
+image::jbang/camel-tui-semantic-audit-allow.png[Semantic Audit showing an 
ALLOW decision beside its linked security evaluation with result false]
+
+Select a record and use `Tab` to focus its inspector, then use the arrows to 
scroll.
+Wide terminals show the route decision and its linked evaluation side by side; 
narrow terminals
+stack the same details. Use `[` / `]` to choose between multiple evidence 
records, `Enter` to follow
+the selected linked evaluation, and `Esc` or `Enter` to return to the decision.
+When input capture is enabled for an expert, the inspector shows the stored 
*Input* alongside
+its evaluation, including linked evidence. Otherwise it shows *not captured* 
or an omission
+reason. Input capture is disabled by default; configure it in the semantic 
audit DSL.
+Use `Tab` and the arrows to scroll longer input. Only the selected state is 
captured, subject
+to its configured size limit and optional redactor.
+The latest page refreshes every second; `Space` pauses or resumes polling. 
Older pages stay fixed
+until explicitly refreshed. Filters use exact values; no time window is 
applied until `since` is set.
+The view reports
 evictions and delivery failures; the built-in memory backend is recent 
history, not
 persistent storage. The read-only `tui_get_audit` MCP tool queries the same 
history, linked evidence
 and backend health without running an expert or changing the visible screen. 
Pass `eventId` alone,
 or filters plus `limit` and the returned `cursor`. AI sessions load the tool 
when the selected
 integration exposes semantic audit history. Use `tui_get_table` to inspect the 
visible Audit screen.
 `r` refreshes the current audit page; `g` returns to the newest page. Repeated 
refreshes are coalesced.
+
+In Audit, `c`, `a`, `e` and `N` open searchable selectors for category, 
action, expert and
+namespace. Use arrows and `Enter` to apply, or `Esc` to cancel. Suggestions 
include values
+on the current page; `/` accepts arbitrary exact filters, including an 
ISO-8601 `since` timestamp.
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticAuditView.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticAuditView.java
index bc5e9cfad44b..2c0f586adf40 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticAuditView.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticAuditView.java
@@ -17,13 +17,20 @@
 package org.apache.camel.dsl.jbang.core.commands.tui;
 
 import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
+import java.util.EnumSet;
 import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
+import java.util.TreeSet;
 import java.util.concurrent.CompletableFuture;
+import java.util.function.LongSupplier;
+import java.util.stream.Collectors;
 
 import dev.tamboui.layout.Constraint;
 import dev.tamboui.layout.Layout;
@@ -39,6 +46,7 @@ import dev.tamboui.widgets.block.BorderType;
 import dev.tamboui.widgets.block.Borders;
 import dev.tamboui.widgets.input.TextInputState;
 import dev.tamboui.widgets.paragraph.Paragraph;
+import dev.tamboui.widgets.table.Cell;
 import dev.tamboui.widgets.table.Row;
 import dev.tamboui.widgets.table.Table;
 import dev.tamboui.widgets.table.TableState;
@@ -52,11 +60,18 @@ final class SemanticAuditView {
     private static final Set<String> FILTERS
             = Set.of("category", "action", "expert", "routeId", "namespace", 
"correlationId", "since");
     private final MonitorContext context;
+    private final LongSupplier nanoTime;
+    private long lastRefresh;
+    private boolean paused;
+    private String returnId;
+    private int evidenceIndex;
     private final TableState selection = new TableState();
     private String pid;
     private String filter = "";
     private Map<String, String> filters = Map.of();
     private TextInputState input;
+    private AutocompletePopup filterChoices;
+    private String filterField;
     private JsonObject page;
     private JsonObject details;
     private CompletableFuture<JsonObject> pending;
@@ -72,7 +87,12 @@ final class SemanticAuditView {
     private boolean detailFocused;
 
     SemanticAuditView(MonitorContext context) {
+        this(context, System::nanoTime);
+    }
+
+    SemanticAuditView(MonitorContext context, LongSupplier nanoTime) {
         this.context = context;
+        this.nanoTime = nanoTime;
     }
 
     void reset() {
@@ -83,6 +103,10 @@ final class SemanticAuditView {
             pendingDetails.cancel(true);
         }
         pid = null;
+        paused = false;
+        returnId = null;
+        evidenceIndex = 0;
+        lastRefresh = 0;
         pending = null;
         pendingDetails = null;
         requestedQuery = null;
@@ -95,6 +119,8 @@ final class SemanticAuditView {
         selectedId = null;
         error = null;
         input = null;
+        filterChoices = null;
+        filterField = null;
         filter = "";
         filters = Map.of();
         scroll = 0;
@@ -156,6 +182,7 @@ final class SemanticAuditView {
         if (pending != null && pending.isDone()) {
             JsonObject response = pending.getNow(null);
             pending = null;
+            lastRefresh = nanoTime.getAsLong();
             if (queuedQuery != null) {
                 JsonObject next = queuedQuery;
                 queuedQuery = null;
@@ -182,19 +209,20 @@ final class SemanticAuditView {
                 if (index < 0) {
                     selection.clearSelection();
                     if (selectedId != null) {
-                        select(selectedId);
+                        refreshSelected(selectedId);
                     } else {
                         details = null;
                     }
                 } else {
                     selection.select(index);
-                    select(rows.get(index).getString("eventId"));
+                    refreshSelected(rows.get(index).getString("eventId"));
                 }
             }
         }
         if (pendingDetails != null && pendingDetails.isDone()) {
             JsonObject response = pendingDetails.getNow(null);
             pendingDetails = null;
+            lastRefresh = nanoTime.getAsLong();
             if (Objects.equals(requestedDetail, selectedId)) {
                 if (response != null && !response.containsKey("error")) {
                     details = response;
@@ -211,8 +239,14 @@ final class SemanticAuditView {
     }
 
     boolean ensureLoaded() {
+        if (!Objects.equals(pid, currentPid())) {
+            reset();
+            pid = currentPid();
+        }
         refresh();
-        if (page == null && pending == null && error == null) {
+        if (pending == null && error == null && (page == null
+                || pendingDetails == null && !paused && cursor == null && 
!inputActive()
+                        && nanoTime.getAsLong() - lastRefresh >= 
1_000_000_000L)) {
             load();
         }
         return pending != null || selectedId != null && pendingDetails != null;
@@ -234,12 +268,39 @@ final class SemanticAuditView {
         return rows().stream().filter(r -> Objects.equals(selectedId, 
r.getString("eventId"))).findFirst().orElse(null);
     }
 
+    private void refreshSelected(String id) {
+        if (Objects.equals(selectedId, id) && details != null) {
+            // Keep the inspector and scroll position while refreshing 
delivery/eviction of linked evidence.
+            if (pendingDetails == null) {
+                startDetails(id);
+            }
+        } else {
+            select(id);
+        }
+    }
+
     void select(String id) {
         if (pid == null) {
             error = "Select a connected integration.";
             return;
         }
+        if (Objects.equals(selectedId, id) && details != null) {
+            return;
+        }
         selectedId = id;
+        int index = -1;
+        for (int i = 0; i < rows().size(); i++) {
+            if (Objects.equals(id, rows().get(i).getString("eventId"))) {
+                index = i;
+                break;
+            }
+        }
+        if (index < 0) {
+            selection.clearSelection();
+        } else {
+            selection.select(index);
+        }
+        evidenceIndex = 0;
         details = null;
         scroll = 0;
         if (pendingDetails != null) {
@@ -266,6 +327,7 @@ final class SemanticAuditView {
         }
         int index = selection.selected() == null ? 0 : selection.selected();
         selection.select(Math.max(0, Math.min(rows().size() - 1, index + 
delta)));
+        returnId = null;
         select(rows().get(selection.selected()).getString("eventId"));
     }
 
@@ -298,6 +360,7 @@ final class SemanticAuditView {
         filter = text;
         selection.clearSelection();
         selectedId = null;
+        returnId = null;
         details = null;
         page = null;
         query(null);
@@ -309,6 +372,7 @@ final class SemanticAuditView {
             return setFilter(value);
         }
         if ("audit.eventId".equals(field) && !value.isBlank() && 
value.length() <= 256) {
+            returnId = null;
             select(value);
             return true;
         }
@@ -326,7 +390,7 @@ final class SemanticAuditView {
     }
 
     boolean inputActive() {
-        return input != null;
+        return input != null || filterChoices != null;
     }
 
     void paste(String value) {
@@ -336,10 +400,21 @@ final class SemanticAuditView {
     }
 
     boolean escape() {
+        if (filterChoices != null) {
+            filterChoices = null;
+            filterField = null;
+            return true;
+        }
         if (input != null) {
             input = null;
             return true;
         }
+        if (returnId != null) {
+            String previous = returnId;
+            returnId = null;
+            select(previous);
+            return true;
+        }
         if (detailFocused) {
             detailFocused = false;
             return true;
@@ -353,6 +428,24 @@ final class SemanticAuditView {
 
     boolean key(KeyEvent key) {
         refresh();
+        if (filterChoices != null) {
+            if (filterChoices.handleKeyEvent(key) == 
AutocompletePopup.Result.CLOSED) {
+                var choice = filterChoices.consumeSelectedItem();
+                if (choice != null) {
+                    Map<String, String> values = new LinkedHashMap<>(filters);
+                    if (choice.insertText().isEmpty()) {
+                        values.remove(filterField);
+                    } else {
+                        values.put(filterField, choice.insertText());
+                    }
+                    setFilter(values.entrySet().stream().map(e -> e.getKey() + 
"=" + e.getValue())
+                            .collect(Collectors.joining(" ")));
+                }
+                filterChoices = null;
+                filterField = null;
+            }
+            return true;
+        }
         if (input != null) {
             if (key.isCancel()) {
                 input = null;
@@ -365,6 +458,12 @@ final class SemanticAuditView {
             }
             return true;
         }
+        for (var shortcut : Map.of('c', "category", 'a', "action", 'e', 
"expert", 'N', "namespace").entrySet()) {
+            if (key.isChar(shortcut.getKey())) {
+                chooseFilter(shortcut.getValue());
+                return true;
+            }
+        }
         if (key.isChar('/')) {
             input = new TextInputState(filter);
             return true;
@@ -381,7 +480,35 @@ final class SemanticAuditView {
             query(null);
             return true;
         }
-        if (key.isKey(KeyCode.TAB) || key.isConfirm()) {
+        if (key.isChar(' ')) {
+            paused = !paused;
+            if (!paused && cursor == null) {
+                query(null);
+            }
+            return true;
+        }
+        if (key.isChar('[') || key.isChar(']')) {
+            int count = SemanticTab.objects(details, "evidence").size();
+            if (count > 0) {
+                evidenceIndex = Math.floorMod(evidenceIndex + (key.isChar('[') 
? -1 : 1), count);
+                scroll = 0;
+            }
+            return true;
+        }
+        if (key.isConfirm()) {
+            List<JsonObject> evidence = SemanticTab.objects(details, 
"evidence");
+            if (!evidence.isEmpty()) {
+                JsonObject linked = evidence.get(Math.min(evidenceIndex, 
evidence.size() - 1));
+                if (!Boolean.TRUE.equals(linked.get("unavailable"))) {
+                    returnId = selectedId;
+                    select(linked.getString("eventId"));
+                }
+            } else if (returnId != null) {
+                escape();
+            }
+            return true;
+        }
+        if (key.isKey(KeyCode.TAB)) {
             detailFocused = !detailFocused;
             return true;
         }
@@ -392,6 +519,43 @@ final class SemanticAuditView {
         return false;
     }
 
+    private void chooseFilter(String field) {
+        Set<String> values = new TreeSet<>();
+        if ("category".equals(field)) {
+            values.addAll(List.of("decision", "evaluation", "request"));
+        } else if ("action".equals(field)) {
+            values.addAll(List.of("allow", "block", "review"));
+        } else if ("expert".equals(field) && page != null
+                && page.get("audit") instanceof JsonObject audit
+                && audit.get("experts") instanceof Map<?, ?> experts) {
+            experts.keySet().forEach(name -> values.add(name.toString()));
+        }
+        for (JsonObject row : rows()) {
+            if (row.get(field) != null) {
+                values.add(text(row, field));
+            }
+        }
+        if (filters.containsKey(field)) {
+            values.add(filters.get(field));
+        }
+        List<AutocompletePopup.CompletionItem> items = new ArrayList<>();
+        items.add(new AutocompletePopup.CompletionItem(
+                "All", "Remove the " + field + " filter.", null, null,
+                false, null, null, false, ""));
+        for (String value : values) {
+            if (!value.isBlank() && value.length() <= 256 && 
value.chars().noneMatch(Character::isWhitespace)) {
+                items.add(new AutocompletePopup.CompletionItem(
+                        value,
+                        "Filter by exact " + field + ". Suggestions include 
values on this page; / accepts any exact value.",
+                        null, null, false, null, null));
+            }
+        }
+        filterChoices = new AutocompletePopup(items, "", "", true);
+        filterChoices.setTitlePrefix("Audit " + field);
+        filterChoices.setFullKeys(true);
+        filterField = field;
+    }
+
     boolean detailFocused() {
         return detailFocused;
     }
@@ -415,6 +579,10 @@ final class SemanticAuditView {
         state.put("evidence", SemanticTab.objects(details, "evidence"));
         state.put("filters", filters);
         state.put("filter", filter);
+        state.put("paused", paused);
+        state.put("live", !paused && cursor == null && error == null);
+        state.put("evidenceIndex", evidenceIndex);
+        state.put("filterField", filterField);
         state.put("pending", pending != null || pendingDetails != null);
         state.put("connected", currentPid() != null && Objects.equals(pid, 
currentPid()));
         state.put("error", error);
@@ -426,106 +594,310 @@ final class SemanticAuditView {
     }
 
     void render(Frame frame, Rect area) {
-        refresh();
-        List<Rect> panels
-                = Layout.vertical().constraints(Constraint.length(3), 
Constraint.percentage(42), Constraint.fill()).split(area);
-        JsonObject status = page == null ? null : page.getJsonObject("audit");
-        String statusLine = status == null
-                ? "Audit history" : "Master: " + status.get("enabled") + "   
Experts: " + status.get("experts")
-                                    + "   OpenTelemetry: " + 
status.get("openTelemetry") + "   Reader: " + status.get("reader")
-                                    + "   Dropped: " + status.get("dropped") + 
"   Sink errors: " + status.get("sinkErrors");
-        String note = error != null ? error : pending != null ? "Loading..."
-                : "Evicted: " + (page == null ? 0 : page.get("evicted"))
-                  + (page != null && 
Boolean.TRUE.equals(page.get("cursorExpired"))
-                          ? "   Older cursor expired; refresh for retained 
history." : "")
-                  + (filter.isBlank() ? "" : "   " + filter);
-        frame.renderWidget(Paragraph.builder().text(statusLine + "\n" + 
note).build(), panels.get(0));
+        ensureLoaded();
         boolean wide = area.width() >= 140;
+        List<Rect> panels = 
Layout.vertical().constraints(Constraint.length(wide ? 4 : 6),
+                Constraint.length(Math.max(6, Math.min(14, area.height() * 42 
/ 100))),
+                Constraint.fill(), Constraint.length(1)).split(area);
+        JsonObject status = page == null ? null : page.getJsonObject("audit");
+        renderHeader(frame, panels.get(0), status);
+        renderTable(frame, panels.get(1), wide);
+        renderDetails(frame, panels.get(2));
+        renderHealth(frame, panels.get(3), status);
+        if (filterChoices != null) {
+            filterChoices.render(frame, area, 2, 0);
+        }
+    }
+
+    private void renderHeader(Frame frame, Rect area, JsonObject status) {
+        List<Span> indicators = new ArrayList<>();
+        indicators.add(Span.styled("Audit default: ", Theme.label()));
+        indicators.add(toggle(status == null ? null : status.get("enabled")));
+        if (status != null && status.get("experts") instanceof Map<?, ?> 
experts) {
+            experts.forEach((expert, enabled) -> {
+                indicators.add(Span.styled("   " + expert + ": ", 
Theme.label()));
+                indicators.add(toggle(enabled));
+                indicators.add(Span.styled(" (override)", Theme.muted()));
+            });
+        }
+        String otel = text(status, "openTelemetry");
+        indicators.add(Span.styled("   OpenTelemetry: ", Theme.label()));
+        indicators.add(toggle(switch (otel) {
+            case "active" -> Boolean.TRUE;
+            case "inactive" -> Boolean.FALSE;
+            default -> null;
+        }));
+        List<Line> lines = new ArrayList<>();
+        lines.add(Line.from(indicators));
+        lines.add(Line.empty());
+        List<Span> query = new ArrayList<>();
+        query.add(Span.styled("Filters: ", Theme.label()));
+        for (String field : List.of("category", "action", "expert", 
"namespace")) {
+            String shortcut = "namespace".equals(field) ? "N" : 
field.substring(0, 1);
+            String label = Character.toUpperCase(field.charAt(0)) + 
field.substring(1);
+            query.add(Span.raw(" ["));
+            query.add(Span.styled(shortcut, Theme.label().underlined()));
+            query.add(Span.raw(" " + label + ": " + 
filters.getOrDefault(field, "all") + " ▾] "));
+        }
+        query.add(Span.styled(filters.containsKey("since") ? " Since " + 
filters.get("since") : " All retained time",
+                Theme.muted()));
+        query.add(Span.styled("   Backend: " + text(status, "reader"), 
Theme.info()));
+        query.add(Span.styled("   / edit", Theme.muted()));
+        lines.add(Line.from(query));
+        List<String> other = new ArrayList<>();
+        for (String field : List.of("routeId", "correlationId")) {
+            if (filters.containsKey(field)) {
+                other.add(field + "=" + filters.get(field));
+            }
+        }
+        if (error != null) {
+            lines.add(Line.from(Span.styled(error + " · r to retry", 
Theme.error())));
+        } else if (page != null && 
Boolean.TRUE.equals(page.get("cursorExpired"))) {
+            lines.add(Line.from(Span.styled("Older cursor expired; press g for 
retained history.", Theme.warning())));
+        } else if (!other.isEmpty()) {
+            lines.add(Line.from(Span.styled(String.join("   ", other), 
Theme.muted())));
+        }
+        SemanticDetails.paragraph(frame, area, lines, 0);
+    }
+
+    private void renderTable(Frame frame, Rect area, boolean wide) {
         List<Row> content = new ArrayList<>();
         for (JsonObject row : rows()) {
-            String timestamp = text(row, "timestamp");
-            String time = timestamp.length() > 23 ? timestamp.substring(11, 
23) + "Z" : timestamp;
+            Cell action = Cell.from(Span.styled(text(row, 
"action").toUpperCase(Locale.ROOT), actionStyle(row)));
+            Cell reason = Cell.from(Span.styled(text(row, "reasonCode"),
+                    "failed".equals(row.get("status")) || 
"timeout".equals(row.get("status"))
+                            ? Theme.error() : Theme.muted()));
             content.add(wide
-                    ? Row.from(time, text(row, "action"), text(row, 
"category"), text(row, "operation"), text(row, "target"),
-                            text(row, "namespace"), text(row, "reasonCode"), 
text(row, "correlationId"))
-                    : Row.from(time, text(row, "action"), text(row, 
"category"), text(row, "expert"), text(row, "reasonCode")));
-        }
-        Block tableBlock = block("Audit history [" + rows().size() + "]", 
!detailFocused);
+                    ? Row.from(Cell.from(time(row, "HH:mm:ss.SSS")), action, 
Cell.from(text(row, "category")),
+                            Cell.from(text(row, "operation")), 
Cell.from(text(row, "target")),
+                            Cell.from(text(row, "namespace")), reason, 
Cell.from(text(row, "correlationId")))
+                    : Row.from(Cell.from(time(row, "HH:mm:ss.SSS")), action, 
Cell.from(text(row, "category")),
+                            Cell.from(text(row, "expert")), reason));
+        }
+        Block tableBlock = block("Audit records [" + rows().size() + "] · " + 
mode(), !detailFocused);
+        frame.renderWidget(tableBlock, area);
+        Rect inner = tableBlock.inner(area);
+        List<Rect> parts = Layout.vertical().constraints(Constraint.fill(), 
Constraint.length(1)).split(inner);
         if (content.isEmpty()) {
-            frame.renderWidget(Paragraph.builder().text("No retained records 
match this query. Capture may be disabled.")
-                    .block(tableBlock).build(), panels.get(1));
+            frame.renderWidget(Paragraph.builder().text(pending != null
+                    ? "Loading audit history…"
+                    : "No retained records match this query. Capture may be 
disabled.").build(), parts.get(0));
         } else {
             Table table = Table.builder().rows(content)
                     .header((wide
-                            ? Row.from("TIMESTAMP", "ACTION", "CATEGORY", 
"OPERATION", "TARGET", "NAMESPACE", "REASON CODE",
-                                    "CORRELATION")
-                            : Row.from("TIMESTAMP", "ACTION", "CATEGORY", 
"EXPERT", "REASON CODE")).style(Theme.label()))
+                            ? Row.from("TIMESTAMP", "DECISION", "CATEGORY", 
"OPERATION", "TARGET", "NAMESPACE", "REASON CODE",
+                                    "CORRELATION ID")
+                            : Row.from("TIMESTAMP", "DECISION", "CATEGORY", 
"EXPERT", "REASON CODE")).style(Theme.label()))
                     .widths(wide
-                            ? List.of(Constraint.length(14), 
Constraint.length(8), Constraint.length(11), Constraint.length(13),
+                            ? List.of(Constraint.length(14), 
Constraint.length(9), Constraint.length(11), Constraint.length(13),
                                     Constraint.percentage(15), 
Constraint.percentage(10), Constraint.fill(),
-                                    Constraint.length(13))
-                            : List.of(Constraint.length(14), 
Constraint.length(8), Constraint.length(11),
+                                    Constraint.length(16))
+                            : List.of(Constraint.length(14), 
Constraint.length(9), Constraint.length(11),
                                     Constraint.percentage(20), 
Constraint.fill()))
-                    .block(tableBlock).highlightSymbol("› 
").highlightStyle(Theme.selectionBg()).build();
-            frame.renderStatefulWidget(table, panels.get(1), selection);
+                    .highlightSymbol("› 
").highlightStyle(Theme.selectionBg()).build();
+            frame.renderStatefulWidget(table, parts.get(0), selection);
         }
-        List<Line> lines = new ArrayList<>();
+        String date = selected() == null ? "" : time(selected(), "dd MMM uuuu 
· XXX") + " · ";
+        SemanticDetails.paragraph(frame, parts.get(1), 
List.of(Line.from(Span.styled(
+                "Newest first · " + date + ZoneId.systemDefault() + (wide ? "  
  ↑↓ select · Enter follows linked record" : ""),
+                Theme.muted()))), 0);
+    }
+
+    private void renderDetails(Frame frame, Rect area) {
         JsonObject record = selected();
+        Block border = block("Record · " + (selectedId == null ? "none 
selected" : selectedId), detailFocused);
+        frame.renderWidget(border, area);
+        Rect inner = SemanticDetails.inset(border.inner(area));
         if (record == null) {
-            lines.add(Line.from("Select an audit record."));
+            SemanticDetails.paragraph(frame, inner, 
List.of(Line.from(selectedId == null ? "Select an audit record."
+                    : pendingDetails != null ? "Loading record…" : "Record 
unavailable in this backend.")), 0);
+            return;
+        }
+        boolean decision = "decision".equals(record.get("category"));
+        Line heading = Line.from(Span.styled(text(record, decision ? "action" 
: "status").toUpperCase(Locale.ROOT) + "   ",
+                actionStyle(record).bold()), Span.styled(text(record, 
"target"), Theme.title()),
+                Span.styled("   " + text(record, "operation") + " · " + 
text(record, "namespace") + " · "
+                            + text(record, "correlationId"),
+                        Theme.muted()));
+        List<Line> left = new ArrayList<>();
+        String section = switch (text(record, "category")) {
+            case "decision" -> "Route decision";
+            case "request" -> "Evaluation request";
+            default -> "Expert evaluation";
+        };
+        left.add(Line.from(Span.styled(section, Theme.title())));
+        if (decision) {
+            add(left, "Policy", record, "policyId");
+            add(left, "Policy version", record, "policyVersion");
+            add(left, "Rule", record, "rule");
+            add(left, "Route", record, "routeId");
+            add(left, "Reason", record, "reasonCode");
+            SemanticDetails.add(left, "Action", text(record, "action") + " · 
reported by the route");
         } else {
-            for (String key : List.of("eventId", "category", "timestamp", 
"action", "operation", "target", "namespace",
-                    "reasonCode", "correlationId", "policyId", 
"policyVersion", "rule", "expert", "definition", "origin",
-                    "routeId", "invocationId", "batchId", "status")) {
-                if (record.get(key) != null) {
-                    lines.add(Line.from(Span.styled(key + "  ", 
Theme.label()), Span.raw(String.valueOf(record.get(key)))));
-                }
+            evaluationLines(left, record);
+        }
+        left.add(Line.empty());
+        for (String field : List.of("timestamp", "startedAt", "exchangeId", 
"correlationId", "invocationId", "batchId")) {
+            add(left, switch (field) {
+                case "startedAt" -> "Started";
+                case "exchangeId" -> "Exchange";
+                case "correlationId" -> "Correlation";
+                case "invocationId" -> "Invocation";
+                case "batchId" -> "Batch";
+                default -> "Recorded";
+            }, record, field);
+        }
+        List<Line> right = new ArrayList<>();
+        List<JsonObject> evidence = SemanticTab.objects(details, "evidence");
+        if (!evidence.isEmpty()) {
+            JsonObject linked = evidence.get(Math.min(evidenceIndex, 
evidence.size() - 1));
+            right.add(Line.from(Span.styled("Linked evaluation · " + 
text(linked, "eventId"), Theme.title())));
+            if (evidence.size() > 1) {
+                right.add(Line.from(Span.styled((evidenceIndex + 1) + " / " + 
evidence.size() + " · [ ] change evidence",
+                        Theme.muted())));
             }
-            resultLines(lines, record);
-            for (JsonObject linked : SemanticTab.objects(details, "evidence")) 
{
-                lines.add(Line.empty());
-                lines.add(Line.from(Span.styled("Evidence: " + text(linked, 
"eventId"), Style.EMPTY.fg(Theme.accent()))));
-                if (Boolean.TRUE.equals(linked.get("unavailable"))) {
-                    lines.add(Line.from("Unavailable in this backend 
(disabled, evicted, or not delivered)."));
-                } else {
-                    lines.add(Line
-                            .from(text(linked, "expert") + " / " + 
text(linked, "operation") + " · " + text(linked, "status")));
-                    resultLines(lines, linked);
-                }
+            if (Boolean.TRUE.equals(linked.get("unavailable"))) {
+                right.add(Line.from(Span.styled("Unavailable (disabled, 
evicted, or not delivered).", Theme.warning())));
+            } else {
+                evaluationLines(right, linked);
             }
-            lines.add(Line.empty());
-            lines.add(Line.from("Input, headers, arbitrary metadata and 
submitted score descriptions are omitted."));
+        } else if (decision) {
+            right.add(Line.from(Span.styled(pendingDetails != null ? "Loading 
linked evidence…" : "No linked evidence.",
+                    Theme.muted())));
+        }
+        if (returnId != null) {
+            right.add(Line.from(Span.styled("Esc / Enter returns to the 
decision.", Theme.info())));
+        }
+        Line omitted = Line.from(Span.styled(
+                "Input captured only by expert opt-in · Exchange headers and 
arbitrary metadata omitted", Theme.muted()));
+        if (inner.width() < 100) {
+            List<Line> all = new ArrayList<>(List.of(heading, Line.empty()));
+            all.addAll(left);
+            all.add(Line.empty());
+            all.addAll(right);
+            all.add(omitted);
+            SemanticDetails.paragraph(frame, inner, all, scroll);
+        } else {
+            List<Rect> parts = 
Layout.vertical().constraints(Constraint.length(2), Constraint.fill(), 
Constraint.length(1))
+                    .split(inner);
+            SemanticDetails.paragraph(frame, parts.get(0), List.of(heading), 
0);
+            List<Rect> columns = 
Layout.horizontal().constraints(Constraint.percentage(48), Constraint.length(3),
+                    Constraint.fill()).split(parts.get(1));
+            SemanticDetails.paragraph(frame, columns.get(0), left, scroll);
+            
frame.renderWidget(Block.builder().borders(EnumSet.of(Borders.LEFT)).borderStyle(Theme.border()).build(),
+                    columns.get(1));
+            SemanticDetails.paragraph(frame, columns.get(2), right, scroll);
+            SemanticDetails.paragraph(frame, parts.get(2), List.of(omitted), 
0);
         }
-        Block detailsBlock = block("Record details", detailFocused);
-        frame.renderWidget(detailsBlock, panels.get(2));
-        SemanticDetails.paragraph(frame, detailsBlock.inner(panels.get(2)), 
lines, scroll);
     }
 
-    private static void resultLines(List<Line> lines, JsonObject record) {
-        for (String key : List.of("startedAt", "provider", "model", 
"revision")) {
-            if (record.get(key) != null) {
-                lines.add(Line.from(Span.styled(key + "  ", Theme.label()), 
Span.raw(String.valueOf(record.get(key)))));
+    private static void evaluationLines(List<Line> lines, JsonObject record) {
+        add(lines, "Definition", record, "definition");
+        SemanticDetails.add(lines, "Expert", text(record, "expert") + " · " + 
text(record, "operation"));
+        if (record.containsKey("input")) {
+            SemanticDetails.parameter(lines, "",
+                    Boolean.TRUE.equals(record.get("inputRedacted")) ? "Input 
(redacted): " : "Input: ",
+                    record.get("input"));
+        } else {
+            SemanticDetails.add(lines, "Input", 
record.containsKey("inputOmitted")
+                    ? "omitted · " + text(record, "inputOmitted") : "not 
captured");
+        }
+        JsonObject semantics = record.getJsonObject("semantics");
+        JsonObject result = record.getJsonObject("result");
+        if (result != null) {
+            lines.add(Line.from(Span.styled("Result: ", Theme.label()), 
Span.styled(text(result, "value"), Theme.info().bold()),
+                    Span.styled(" · " + text(semantics, 
"resultType").toLowerCase(Locale.ROOT), Theme.muted())));
+            add(lines, "Meaning", semantics, "meaning");
+            add(lines, "Probability", result, "probability");
+            if (result.get("probabilities") instanceof Map<?, ?> 
probabilities) {
+                probabilities.forEach((label, value) -> 
SemanticDetails.add(lines, "P(" + label + ")", String.valueOf(value)));
             }
+            add(lines, "Probability meaning", semantics, "probabilityMeaning");
+            add(lines, "Confidence", result, "confidence");
+            add(lines, "Confidence meaning", semantics, "confidenceMeaning");
+        }
+        add(lines, "Result unavailable", record, "resultOmitted");
+        add(lines, "Status", record, "status");
+        if (!"success".equals(record.get("status"))) {
+            add(lines, "Reason", record, "reasonCode");
+        }
+        if (record.get("durationNanos") instanceof Number duration) {
+            SemanticDetails.add(lines, "Duration", 
SemanticResultView.number(duration.doubleValue() / 1_000_000) + " ms");
         }
-        if (record.get("resultOmitted") != null) {
-            lines.add(Line.from("Result unavailable: " + text(record, 
"resultOmitted")));
+        for (String field : List.of("origin", "provider", "model", 
"revision")) {
+            add(lines, Character.toUpperCase(field.charAt(0)) + 
field.substring(1), record, field);
         }
-        if (!(record.get("result") instanceof JsonObject result)) {
+    }
+
+    private static void add(List<Line> lines, String label, JsonObject record, 
String field) {
+        if (record != null && record.get(field) != null) {
+            SemanticDetails.add(lines, label, text(record, field));
+        }
+    }
+
+    private void renderHealth(Frame frame, Rect area, JsonObject status) {
+        if (status == null) {
             return;
         }
-        JsonObject response = new JsonObject(result);
-        response.put("status", "success");
-        response.put("elapsedMillis", record.get("durationNanos") instanceof 
Number n ? n.longValue() / 1000000 : 0);
-        JsonObject semantics = record.getJsonObject("semantics");
-        JsonObject contract = new JsonObject();
-        if (semantics != null) {
-            contract.put("resultMeaning", semantics.get("meaning"));
-            contract.put("probabilityMeaning", 
semantics.get("probabilityMeaning"));
+        List<Span> health = new ArrayList<>();
+        if ("memory".equals(status.get("reader"))) {
+            health.add(Span.styled("Memory: " + text(status, "retained") + " / 
" + text(status, "capacity") + " retained · ",
+                    Theme.muted()));
+        }
+        health.add(Span.styled(text(page, "evicted") + " evicted · " + 
text(status, "dropped") + " dropped   ", Theme.muted()));
+        List<String> failures = new ArrayList<>();
+        if (status.get("sinkErrors") instanceof Map<?, ?> sinks) {
+            sinks.forEach((name, count) -> {
+                if (count instanceof Number n && n.longValue() > 0) {
+                    failures.add(name + ": " + n + " errors");
+                }
+            });
+        }
+        for (String field : List.of("dropped", "observerErrors")) {
+            if (status.get(field) instanceof Number n && n.longValue() > 0) {
+                failures.add(n + ("dropped".equals(field) ? " dropped" : " 
observer errors"));
+            }
+        }
+        if (error != null) {
+            failures.add("query failed · showing last response");
+        }
+        health.add(Span.styled(failures.isEmpty() ? "● No delivery errors 
reported" : "● " + String.join(" · ", failures),
+                failures.isEmpty() ? Theme.success() : Theme.warning()));
+        health.add(Span.styled(" · " + mode() + (pending != null ? " · 
refreshing…" : ""), Theme.muted()));
+        SemanticDetails.paragraph(frame, area, List.of(Line.from(health)), 0);
+    }
+
+    private String mode() {
+        if (paused) {
+            return "paused";
+        }
+        if (cursor != null) {
+            return "older page";
+        }
+        return error == null ? "live · refresh 1 s" : "refresh stopped";
+    }
+
+    private static Span toggle(Object value) {
+        return Span.styled(value == null ? "UNKNOWN" : 
Boolean.TRUE.equals(value) ? "ON" : "OFF",
+                Boolean.TRUE.equals(value) ? Theme.success() : Theme.muted());
+    }
+
+    private static Style actionStyle(JsonObject record) {
+        return switch (text(record, "action")) {
+            case "allow" -> Theme.success();
+            case "block", "deny", "reject" -> Theme.error();
+            case "review" -> Theme.warning();
+            default -> Theme.muted();
+        };
+    }
+
+    private static String time(JsonObject record, String pattern) {
+        try {
+            return DateTimeFormatter.ofPattern(pattern, 
Locale.ROOT).withZone(ZoneId.systemDefault())
+                    .format(Instant.parse(text(record, "timestamp")));
+        } catch (RuntimeException e) {
+            return text(record, "timestamp");
         }
-        JsonObject operation = new JsonObject();
-        operation.put("contract", contract);
-        operation.put("resultType", semantics == null ? "" : 
semantics.get("resultType"));
-        lines.addAll(SemanticResultView.lines(response, operation, List.of()));
     }
 
     private static Block block(String title, boolean focused) {
@@ -534,14 +906,21 @@ final class SemanticAuditView {
     }
 
     void footer(List<Span> spans) {
-        if (input != null) {
+        if (filterChoices != null) {
+            hint(spans, "↑↓", "select");
+            hint(spans, "Enter", "apply");
+            hint(spans, "Esc", "cancel");
+        } else if (input != null) {
             spans.add(Span.raw(" /" + input.text() + "█  "));
             hint(spans, "Enter", "apply");
             hint(spans, "Esc", "cancel");
         } else {
             hint(spans, "v", "view");
             hint(spans, "/", "filter");
-            hint(spans, "Tab", "pane");
+            hint(spans, "↑↓", "select");
+            hint(spans, "Tab", "detail");
+            hint(spans, "Space", paused ? "resume" : "pause");
+            hint(spans, "Enter", returnId == null ? "related record" : 
"decision");
             hint(spans, "n", "older");
             hint(spans, "g", "latest");
             hint(spans, "r", "refresh");
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticDetails.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticDetails.java
index 2ac39f71cb8a..c49b8ef6865c 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticDetails.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticDetails.java
@@ -92,7 +92,8 @@ final class SemanticDetails {
         map.forEach((name, value) -> parameter(lines, "  ", name + ": ", 
value));
     }
 
-    private static void parameter(List<Line> lines, String indent, String 
label, Object value) {
+    static void parameter(List<Line> lines, String indent, String label, 
Object value) {
+        label = label.replaceAll("[\\p{Cntrl}\\p{Cf}]", "");
         if (value instanceof Map<?, ?> map && !map.isEmpty()) {
             lines.add(Line.from(Span.styled(indent + label.stripTrailing(), 
Theme.label())));
             map.forEach((name, child) -> parameter(lines, indent + "  ", "• " 
+ name + ": ", child));
@@ -105,7 +106,7 @@ final class SemanticDetails {
             // Put text under its label so wrapped prose keeps its place in 
the parameter tree.
             lines.add(Line.from(Span.styled(indent + label.stripTrailing(), 
Theme.label())));
             for (String part : text.split("\\R", -1)) {
-                lines.add(Line.from(indent + "  " + (part.isEmpty() ? "\"\"" : 
part)));
+                lines.add(Line.from(indent + "  " + (part.isEmpty() ? "\"\"" : 
part.replaceAll("[\\p{Cntrl}\\p{Cf}]", ""))));
             }
         } else {
             lines.add(Line.from(Span.styled(indent + label, Theme.label()), 
Span.raw(String.valueOf(value))));
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/semantic.md
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/semantic.md
index b950b7140b85..5a8bd444c72b 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/semantic.md
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/semantic.md
@@ -104,7 +104,7 @@ Auditing and OpenTelemetry are independent.
 - `/` edits exact `field=value` filters, separated by spaces. Available fields 
are
   `category`, `action`, `expert`, `routeId`, `namespace`, `correlationId` and 
`since`.
   Example: `expert=security since=2026-10-09T12:00:00Z`.
-- Up/Down select a record. `Tab` or Enter focuses the inspector; arrows scroll 
its details.
+- Up/Down select a record. `Tab` focuses the inspector; arrows scroll its 
details.
 - `n` loads older records; `g` returns to the latest page; `r` refreshes the 
current page.
 - Esc leaves details or clears the current filter.
 
@@ -120,3 +120,20 @@ Pass `eventId` alone to inspect a record and its evidence. 
For pages, pass `cate
 `routeId`, `namespace`, `correlationId`, `since`, `limit` and a returned 
`cursor` as needed.
 `tui_get_table` reads the visible Audit screen; `tui_set_input` changes it 
explicitly.
 Audit refreshes are coalesced while a query is pending; changed filters use 
the latest submitted value.
+
+### Audit inspector and live history
+
+Audit shows the global default and per-expert overrides separately from 
OpenTelemetry.
+The filter bar shows the current query; `/` edits exact `field=value` filters. 
With no `since`,
+the query includes all retained time. Timestamps use the local timezone 
displayed below the table.
+Route decisions and linked evaluations appear side by side on wide terminals 
and stack on narrow terminals.
+`Tab` focuses details; arrows scroll. `[` / `]` choose evidence when there is 
more than one linked record.
+`Enter` follows that evaluation; `Esc` or `Enter` returns to the decision.
+The newest page refreshes every second. `Space` pauses/resumes; older pages do 
not auto-refresh.
+The footer reports retention, evictions, dropped records and delivery errors.
+
+In Audit, `c`, `a`, `e` and `N` open searchable selectors for category, 
action, expert and
+namespace. Use arrows and `Enter` to apply, or `Esc` to cancel. Suggestions 
include values
+on the current page; `/` accepts arbitrary exact filters, including an 
ISO-8601 `since` timestamp.
+
+Audit input capture is disabled by default. Per-expert 
`audit.experts.<name>.input.enabled` retains the selected state; the inspector 
shows Input, Input (redacted), or an omission reason. Tab and arrows scroll 
longer input.
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticTabTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticTabTest.java
index ccdc8d3933ed..03642931cf93 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticTabTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticTabTest.java
@@ -23,6 +23,7 @@ import java.util.Map;
 import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
 import java.util.concurrent.atomic.AtomicReference;
 import java.util.stream.Collectors;
 
@@ -91,11 +92,201 @@ class SemanticTabTest {
             tab.setInputValue("audit.view", "");
             await().atMost(5, TimeUnit.SECONDS).until(() -> 
!tab.ensureDataLoaded());
             String rendered = TuiTestHelper.renderToString(tab, 180, 45);
-            assertThat(rendered).contains("TIMESTAMP", "REASON CODE", 
"tools/call", "support-request", "Master: true");
+            assertThat(rendered).contains("TIMESTAMP", "REASON CODE", 
"tools/call", "support-request", "Audit default: ON",
+                    "security: ON (override)", "decisions: OFF (override)", 
"OpenTelemetry: OFF", "Filters:",
+                    "Route decision", "Linked evaluation", "Definition: 
screenPrompt", "Result: true", "BLOCK",
+                    "Policy: support-access", "Model: detector-v2", "Input 
captured only by expert opt-in", "Memory: 2 / 1000",
+                    "No delivery errors reported", "refresh 1 s");
             assertThat(TuiTestHelper.renderToString(tab, 80, 
24)).contains("CATEGORY", "EXPERT");
         }
     }
 
+    @Test
+    void auditRendersStoredInputAndOmissionReasonsInLinkedAndDirectRecords() 
throws Exception {
+        try (var runtime = new Runtime()) {
+            runtime.auditInput.put("input", Map.of("prompt", 
"hello\nworld\u001b[31m"));
+            runtime.auditInput.put("inputRedacted", true);
+            var tab = loaded(runtime);
+            tab.setInputValue("audit.view", "");
+            await().atMost(5, TimeUnit.SECONDS).until(() -> 
!tab.ensureDataLoaded());
+            assertThat(TuiTestHelper.renderToString(tab, 180, 
55)).contains("Input (redacted)", "prompt", "hello", "world")
+                    .doesNotContain("\u001b");
+            tab.setInputValue("audit.eventId", "evaluation-1");
+            await().atMost(5, TimeUnit.SECONDS).until(() -> 
!tab.ensureDataLoaded());
+            assertThat(TuiTestHelper.renderToString(tab, 180, 
55)).contains("Input (redacted)", "hello");
+            runtime.auditInput.clear();
+            runtime.auditInput.put("inputOmitted", "redaction_failed");
+            tab.handleKeyEvent(KeyEvent.ofChar('r'));
+            await().atMost(5, TimeUnit.SECONDS).until(() -> 
!tab.ensureDataLoaded());
+            assertThat(TuiTestHelper.renderToString(tab, 180, 
55)).contains("Input: omitted", "redaction_failed");
+        }
+    }
+
+    @Test
+    void auditRefreshesLinkedEvidenceWithoutClearingTheInspector() throws 
Exception {
+        try (var runtime = new Runtime()) {
+            runtime.multipleAuditEvidence = true;
+            var tab = loaded(runtime);
+            tab.setInputValue("audit.view", "");
+            await().atMost(5, TimeUnit.SECONDS).until(() -> 
!tab.ensureDataLoaded());
+            assertThat(TuiTestHelper.renderToString(tab, 180, 
45)).contains("Unavailable");
+            runtime.multipleAuditEvidence = false;
+            tab.handleKeyEvent(KeyEvent.ofChar('r'));
+            await().atMost(5, TimeUnit.SECONDS).until(() -> 
!tab.ensureDataLoaded());
+            
assertThat(tab.getTableDataAsJson()).containsEntry("selectedEventId", 
"decision-2");
+            assertThat(TuiTestHelper.renderToString(tab, 180, 
45)).contains("Result: true").doesNotContain("Unavailable");
+        }
+    }
+
+    @Test
+    void auditFilterSelectorsApplyOnlyTheChosenFieldAndCanBeCancelled() throws 
Exception {
+        try (var runtime = new Runtime()) {
+            var tab = loaded(runtime);
+            tab.setInputValue("audit.view", "");
+            tab.setInputValue("audit.filter", "namespace=default");
+            await().atMost(5, TimeUnit.SECONDS).until(() -> 
!tab.ensureDataLoaded());
+            tab.handleKeyEvent(KeyEvent.ofChar('c'));
+            assertThat(tab.getTableDataAsJson()).containsEntry("filterField", 
"category");
+            assertThat(TuiTestHelper.renderToString(tab, 180, 
45)).contains("Audit category");
+            for (char c : "decision".toCharArray()) {
+                tab.handleKeyEvent(KeyEvent.ofChar(c));
+            }
+            tab.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER));
+            await().atMost(5, TimeUnit.SECONDS).until(() -> 
!tab.ensureDataLoaded());
+            assertThat(new 
JsonObject(tab.getTableDataAsJson().getMap("filters")))
+                    .containsEntry("category", 
"decision").containsEntry("namespace", "default");
+            tab.handleKeyEvent(KeyEvent.ofChar('a'));
+            tab.handleEscape();
+            assertThat(new 
JsonObject(tab.getTableDataAsJson().getMap("filters"))).doesNotContainKey("action");
+            tab.handleKeyEvent(KeyEvent.ofChar('c'));
+            for (char c : "All".toCharArray()) {
+                tab.handleKeyEvent(KeyEvent.ofChar(c));
+            }
+            tab.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER));
+            await().atMost(5, TimeUnit.SECONDS).until(() -> 
!tab.ensureDataLoaded());
+            assertThat(new 
JsonObject(tab.getTableDataAsJson().getMap("filters")))
+                    .containsOnlyKeys("namespace");
+        }
+    }
+
+    @Test
+    void auditSelectsAmongEvidenceAndDoesNotFollowUnavailableRecords() throws 
Exception {
+        try (var runtime = new Runtime()) {
+            runtime.multipleAuditEvidence = true;
+            var tab = loaded(runtime);
+            tab.setInputValue("audit.view", "");
+            await().atMost(5, TimeUnit.SECONDS).until(() -> 
!tab.ensureDataLoaded());
+            assertThat(TuiTestHelper.renderToString(tab, 180, 
45)).contains("evicted-1", "1 / 2", "Unavailable");
+            tab.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER));
+            
assertThat(tab.getTableDataAsJson()).containsEntry("selectedEventId", 
"decision-2");
+            tab.handleKeyEvent(KeyEvent.ofChar(']'));
+            assertThat(TuiTestHelper.renderToString(tab, 180, 45)).contains("2 
/ 2", "Result: true");
+            tab.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER));
+            await().atMost(5, TimeUnit.SECONDS).until(() -> 
!tab.ensureDataLoaded());
+            
assertThat(tab.getTableDataAsJson()).containsEntry("selectedEventId", 
"evaluation-1");
+        }
+    }
+
+    @Test
+    void auditShowsDeliveryFailuresAndStaleDataAfterAQueryFailure() throws 
Exception {
+        try (var runtime = new Runtime()) {
+            runtime.auditHealth = new JsonObject(Map.of("sinkErrors", 
Map.of("log", 3), "dropped", 2));
+            var tab = loaded(runtime);
+            tab.setInputValue("audit.view", "");
+            await().atMost(5, TimeUnit.SECONDS).until(() -> 
!tab.ensureDataLoaded());
+            assertThat(TuiTestHelper.renderToString(tab, 180, 
45)).contains("log: 3 errors", "2 dropped")
+                    .doesNotContain("No delivery errors reported");
+            runtime.auditError = true;
+            tab.handleKeyEvent(KeyEvent.ofChar('r'));
+            await().atMost(5, TimeUnit.SECONDS).until(() -> 
!tab.ensureDataLoaded());
+            assertThat(TuiTestHelper.renderToString(tab, 180, 45))
+                    .contains("audit_query_failed", "showing last response", 
"refresh stopped", "support-request");
+        }
+    }
+
+    @Test
+    void auditRendersWhileTheFirstQueryIsPending() throws Exception {
+        try (var runtime = new Runtime()) {
+            var tab = loaded(runtime);
+            runtime.pageEntered = new CountDownLatch(1);
+            runtime.pageRelease = new CountDownLatch(1);
+            tab.setInputValue("audit.view", "");
+            assertThat(runtime.pageEntered.await(5, 
TimeUnit.SECONDS)).isTrue();
+            assertThat(TuiTestHelper.renderToString(tab, 180, 45))
+                    .contains("Audit default: UNKNOWN", "OpenTelemetry: 
UNKNOWN", "Loading audit history");
+            assertThat(TuiTestHelper.renderToString(tab, 80, 
24)).contains("Loading audit history");
+        }
+    }
+
+    @Test
+    void auditLiveRefreshWaitsForSlowDetailsBeforeStartingItsNextInterval() 
throws Exception {
+        try (var runtime = new Runtime()) {
+            AtomicLong clock = new AtomicLong();
+            SemanticAuditView view = new SemanticAuditView(runtime, 
clock::get);
+            await().atMost(5, TimeUnit.SECONDS).until(() -> 
!view.ensureLoaded());
+            runtime.detailsEntered = new CountDownLatch(1);
+            runtime.detailsRelease = new CountDownLatch(1);
+            view.select("evaluation-1");
+            assertThat(runtime.detailsEntered.await(5, 
TimeUnit.SECONDS)).isTrue();
+            clock.addAndGet(2_000_000_000L);
+            assertThat(view.ensureLoaded()).isTrue();
+            assertThat(auditQueries(runtime)).isEqualTo(1);
+            runtime.detailsRelease.countDown();
+            await().atMost(5, TimeUnit.SECONDS).until(() -> 
!view.ensureLoaded());
+            assertThat(auditQueries(runtime)).isEqualTo(1);
+            clock.addAndGet(1_000_000_000L);
+            await().atMost(5, TimeUnit.SECONDS).until(() -> 
!view.ensureLoaded());
+            assertThat(auditQueries(runtime)).isEqualTo(2);
+        }
+    }
+
+    @Test
+    void auditLiveRefreshCanPauseAndNeverReplacesOlderPages() throws Exception 
{
+        try (var runtime = new Runtime()) {
+            runtime.pagedAudit = true;
+            AtomicLong clock = new AtomicLong();
+            SemanticAuditView view = new SemanticAuditView(runtime, 
clock::get);
+            await().atMost(5, TimeUnit.SECONDS).until(() -> 
!view.ensureLoaded());
+            assertThat(auditQueries(runtime)).isEqualTo(1);
+            clock.addAndGet(1_000_000_000L);
+            await().atMost(5, TimeUnit.SECONDS).until(() -> 
!view.ensureLoaded());
+            assertThat(auditQueries(runtime)).isEqualTo(2);
+            view.key(KeyEvent.ofChar(' '));
+            clock.addAndGet(10_000_000_000L);
+            assertThat(view.ensureLoaded()).isFalse();
+            assertThat(view.snapshot()).containsEntry("paused", 
true).containsEntry("live", false);
+            assertThat(auditQueries(runtime)).isEqualTo(2);
+            view.key(KeyEvent.ofChar(' '));
+            await().atMost(5, TimeUnit.SECONDS).until(() -> 
!view.ensureLoaded());
+            assertThat(auditQueries(runtime)).isEqualTo(3);
+            view.key(KeyEvent.ofChar('n'));
+            await().atMost(5, TimeUnit.SECONDS).until(() -> 
!view.ensureLoaded());
+            clock.addAndGet(10_000_000_000L);
+            assertThat(view.ensureLoaded()).isFalse();
+            assertThat(auditQueries(runtime)).isEqualTo(4);
+            assertThat(view.snapshot()).containsEntry("live", false);
+        }
+    }
+
+    @Test
+    void auditFollowsEvidenceAndReturnsToTheDecisionWithoutChangingFilters() 
throws Exception {
+        try (var runtime = new Runtime()) {
+            var tab = loaded(runtime);
+            tab.setInputValue("audit.view", "");
+            tab.setInputValue("audit.filter", "category=decision 
namespace=default");
+            await().atMost(5, TimeUnit.SECONDS).until(() -> 
!tab.ensureDataLoaded());
+            tab.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER));
+            await().atMost(5, TimeUnit.SECONDS).until(() -> 
!tab.ensureDataLoaded());
+            
assertThat(tab.getTableDataAsJson()).containsEntry("selectedEventId", 
"evaluation-1")
+                    .containsEntry("filter", "category=decision 
namespace=default");
+            assertThat(TuiTestHelper.renderToString(tab, 180, 
45)).contains("Expert evaluation", "Result: true",
+                    "Esc / Enter returns to the decision");
+            tab.handleEscape();
+            await().atMost(5, TimeUnit.SECONDS).until(() -> 
!tab.ensureDataLoaded());
+            
assertThat(tab.getTableDataAsJson()).containsEntry("selectedEventId", 
"decision-2");
+        }
+    }
+
     @Test
     void auditFilterDiscardsPendingDetailsWhenTheNewPageIsEmpty() throws 
Exception {
         try (var runtime = new Runtime()) {
@@ -1008,6 +1199,10 @@ class SemanticTabTest {
         volatile CountDownLatch pageEntered;
         volatile CountDownLatch pageRelease;
         volatile boolean pagedAudit;
+        volatile boolean multipleAuditEvidence;
+        volatile boolean auditError;
+        JsonObject auditHealth = new JsonObject();
+        JsonObject auditInput = new JsonObject();
         volatile CountDownLatch detailsRelease;
         volatile CountDownLatch detailsEntered;
         volatile boolean detailsFinished;
@@ -1032,10 +1227,17 @@ class SemanticTabTest {
                     firstFinished = true;
                 }
                 if ("semantic-audit".equals(request.getString("action"))) {
+                    if (auditError) {
+                        return new JsonObject(Map.of("error", 
"audit_query_failed"));
+                    }
                     JsonObject evaluation = new JsonObject(
                             Map.of("eventId", "evaluation-1", "category", 
"evaluation", "expert", "security",
                                     "operation", "injection", "status", 
"success", "timestamp", "2026-10-09T14:20:29.411Z",
                                     "reasonCode", "evaluation_completed", 
"result", new JsonObject(Map.of("value", true))));
+                    evaluation.put("definition", "screenPrompt");
+                    evaluation.putAll(auditInput);
+                    evaluation.put("semantics",
+                            new JsonObject(Map.of("meaning", "Injection 
detected", "resultType", "BOOLEAN")));
                     evaluation.put("provider", "test");
                     evaluation.put("model", "detector-v2");
                     evaluation.put("revision", "abc123");
@@ -1043,10 +1245,18 @@ class SemanticTabTest {
                             Map.of("eventId", "decision-2", "category", 
"decision", "action", "block",
                                     "operation", "tools/call", "target", 
"support-request", "reasonCode", "prompt_injection",
                                     "timestamp", "2026-10-09T14:20:29.418Z", 
"evidence", List.of("evaluation-1")));
+                    decision.put("policyId", "support-access");
+                    decision.put("namespace", "default");
                     JsonObject status = new JsonObject(
-                            Map.of("enabled", true, "experts", new 
JsonObject(Map.of("security", true)),
+                            Map.of("enabled", true, "experts", new 
JsonObject(Map.of("security", true, "decisions", false)),
                                     "reader", "memory", "dropped", 0, 
"sinkErrors", new JsonObject(), "openTelemetry",
                                     "inactive"));
+                    status.put("retained", 2);
+                    status.put("capacity", 1000);
+                    status.putAll(auditHealth);
+                    List<JsonObject> evidence = multipleAuditEvidence
+                            ? List.of(new JsonObject(Map.of("eventId", 
"evicted-1", "unavailable", true)), evaluation)
+                            : List.of(evaluation);
                     if (request.getString("eventId") != null) {
                         if (detailsRelease != null) {
                             detailsEntered.countDown();
@@ -1058,7 +1268,7 @@ class SemanticTabTest {
                         return new JsonObject(
                                 Map.of("record", 
"evaluation-1".equals(request.getString("eventId")) ? evaluation : decision,
                                         "evidence",
-                                        
"evaluation-1".equals(request.getString("eventId")) ? List.of() : 
List.of(evaluation),
+                                        
"evaluation-1".equals(request.getString("eventId")) ? List.of() : evidence,
                                         "audit", status));
                     }
                     if (pageRelease != null) {

Reply via email to