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

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


The following commit(s) were added to refs/heads/main by this push:
     new fdbc617ea42a CAMEL-26699: camel-jbang - chaos screen: poll targets, 
pick order, expectation count, effect of delays and polls (#29358)
fdbc617ea42a is described below

commit fdbc617ea42ac7d18cf425b2fb7fe700ed5f5813
Author: Claus Ibsen <[email protected]>
AuthorDate: Fri Nov 6 22:08:24 2026 +0100

    CAMEL-26699: camel-jbang - chaos screen: poll targets, pick order, 
expectation count, effect of delays and polls (#29358)
    
    Signed-off-by: Claus Ibsen <[email protected]>
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../apache/camel/component/chaos/ChaosRule.java    |   7 +-
 .../camel/component/chaos/ChaosRuleTest.java       |  23 +++++
 .../dsl/jbang/core/commands/tui/ChaosHere.java     |   6 +-
 .../dsl/jbang/core/commands/tui/ChaosInfo.java     |  63 +++++++++++-
 .../dsl/jbang/core/commands/tui/ChaosRecipes.java  |  47 ++++++---
 .../dsl/jbang/core/commands/tui/ChaosTab.java      | 110 +++++++++++++++++++--
 .../dsl/jbang/core/commands/tui/Confirmations.java |  38 +++++--
 .../dsl/jbang/core/commands/tui/ConsumerInfo.java  |   2 +
 .../dsl/jbang/core/commands/tui/McpFacade.java     |   6 ++
 .../dsl/jbang/core/commands/tui/MonitorTab.java    |   9 ++
 .../dsl/jbang/core/commands/tui/StatusParser.java  |   2 +
 .../jbang/core/commands/tui/TuiToolRegistry.java   |  11 ++-
 .../src/main/resources/tui/help/chaos.md           |   9 +-
 .../jbang/core/commands/tui/ChaosRecipesTest.java  |  46 +++++++++
 .../core/commands/tui/ChaosTabRenderTest.java      |  61 ++++++++++++
 .../jbang/core/commands/tui/ConfirmationsTest.java |  39 ++++++++
 16 files changed, 438 insertions(+), 41 deletions(-)

diff --git 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRule.java
 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRule.java
index b758e277bc82..80f77a1d9680 100644
--- 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRule.java
+++ 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRule.java
@@ -651,7 +651,8 @@ final class ChaosRule {
 
         /**
          * The route of this expectation counted one more of its kind. The 
time is read under the lock, so once a report
-         * saw the expectation decided, a count that comes later cannot change 
it.
+         * saw the expectation decided, a count that comes later cannot change 
it. The count goes on after that, until
+         * the time is up, so the report says how far it went over: "6 
exchanges handled, at most 0 expected".
          */
         synchronized void record() {
             recordAt(TimeSource.currentTimeMillis());
@@ -659,11 +660,11 @@ final class ChaosRule {
 
         /** The route of this expectation counted one more of its kind at the 
given time (the tests give the time). */
         synchronized void recordAt(long now) {
-            if (decided != null || frozenAt >= 0 || now > deadline) {
+            if (frozenAt >= 0 || now > deadline) {
                 return;
             }
             value++;
-            if (op == Op.AT_LEAST ? value >= count : value > count) {
+            if (decided == null && (op == Op.AT_LEAST ? value >= count : value 
> count)) {
                 decided = op == Op.AT_LEAST ? Outcome.MET : Outcome.UNMET;
                 decidedAt = now;
             }
diff --git 
a/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosRuleTest.java
 
b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosRuleTest.java
index 223b6035579e..c0499fcdbb0c 100644
--- 
a/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosRuleTest.java
+++ 
b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosRuleTest.java
@@ -176,6 +176,29 @@ class ChaosRuleTest {
         assertThat(removedLate.outcome(T0 + 1000)).isEqualTo(Outcome.UNMET);
     }
 
+    @Test
+    void aDecidedExpectationCountsOnUntilTheTimeIsUp() {
+        // CAMEL-26699: unmet at the first exchange handled, and the count 
says how far it went over
+        ChaosRule.Expectation atMost = new ChaosRule.Expectation(
+                "orders", ChaosRule.Expectation.Kind.HANDLED, 
ChaosRule.Expectation.Op.AT_MOST, 0, T0 + 100);
+        atMost.recordAt(T0 + 10);
+        assertThat(atMost.outcome(T0 + 10)).isEqualTo(Outcome.UNMET);
+        atMost.recordAt(T0 + 20);
+        atMost.recordAt(T0 + 30);
+        assertThat(atMost.value()).isEqualTo(3);
+        assertThat(atMost.outcome(T0 + 1000)).isEqualTo(Outcome.UNMET);
+        // not after the time is up
+        atMost.recordAt(T0 + 101);
+        assertThat(atMost.value()).isEqualTo(3);
+
+        // met at the count, and the count goes on: the outcome stays met
+        ChaosRule.Expectation atLeast = new ChaosRule.Expectation("orders", 1, 
T0 + 100);
+        atLeast.recordAt(T0 + 10);
+        atLeast.recordAt(T0 + 20);
+        assertThat(atLeast.value()).isEqualTo(2);
+        assertThat(atLeast.outcome(T0 + 1000)).isEqualTo(Outcome.MET);
+    }
+
     @Test
     void withinDefaultsToTheUntilDurationElseMaxDuration() {
         assertThat(rule("endpoint=x error until=1m expect=o:1", "r1", 
1).within).isEqualTo(60_000L);
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosHere.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosHere.java
index 53872d6f11c2..d55a3ca2b470 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosHere.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosHere.java
@@ -103,7 +103,7 @@ final class ChaosHere {
         String uri = consumer.uri != null ? withoutQuery(consumer.uri) : null;
         return new Target(
                 "route " + consumer.id + (uri != null ? " (" + uri + ")" : 
""), null,
-                consumer.scheduled ? uri : null, consumer.id, null);
+                consumer.polls ? uri : null, consumer.id, null);
     }
 
     /**
@@ -117,7 +117,7 @@ final class ChaosHere {
             }
             String polled = null;
             for (ConsumerInfo c : info.consumers) {
-                if (routeId.equals(c.id) && c.scheduled && c.uri != null) {
+                if (routeId.equals(c.id) && c.polls && c.uri != null) {
                     polled = withoutQuery(c.uri);
                 }
             }
@@ -137,7 +137,7 @@ final class ChaosHere {
 
     private static String polled(IntegrationInfo info, String uri) {
         for (ConsumerInfo c : info.consumers) {
-            if (c.scheduled && uri.equals(c.uri)) {
+            if (c.polls && uri.equals(c.uri)) {
                 return withoutQuery(uri);
             }
         }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosInfo.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosInfo.java
index d0a63c9ca646..668d5517f146 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosInfo.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosInfo.java
@@ -52,6 +52,8 @@ final class ChaosInfo {
         String state;
         long matches;
         long fired;
+        // the faults it fired per hook, such as poll=39 (CAMEL-26699)
+        final Map<String, Long> firedAt = new LinkedHashMap<>();
         long skipped;
         final Map<String, Long> skippedReasons = new LinkedHashMap<>();
         long lostAtDlq = -1;
@@ -121,6 +123,62 @@ final class ChaosInfo {
             return sb.toString();
         }
 
+        /**
+         * The fault in a few words, as the report says it: {@code error 
status 503}, {@code error ConnectException},
+         * {@code delay 500ms..1s}, {@code stop 5s ×3 every 20s}.
+         */
+        String faultShort() {
+            List<String> tokens = new ArrayList<>(List.of(fault().split(" ")));
+            if (tokens.isEmpty() || tokens.get(0).isEmpty()) {
+                return "";
+            }
+            String kind = tokens.remove(0);
+            Map<String, String> params = new LinkedHashMap<>();
+            for (String token : tokens) {
+                int eq = token.indexOf('=');
+                params.put(eq > 0 ? token.substring(0, eq) : token, eq > 0 ? 
token.substring(eq + 1) : "");
+            }
+            StringBuilder sb = new StringBuilder();
+            switch (kind) {
+                case "error" -> {
+                    sb.append("error");
+                    String exception = params.get("exception");
+                    if (params.containsKey("status")) {
+                        sb.append(" status ").append(params.get("status"));
+                    } else {
+                        // the exception camel-chaos throws when the rule 
names none
+                        String type = exception != null ? exception : 
"ChaosException";
+                        sb.append(' 
').append(type.substring(type.lastIndexOf('.') + 1));
+                    }
+                    if (params.containsKey("after")) {
+                        sb.append(" after ").append(params.get("after"));
+                    }
+                }
+                case "delay", "stall" -> sb.append(kind).append(' 
').append(params.getOrDefault("duration", ""));
+                case "corrupt" -> sb.append("corrupt 
").append(params.getOrDefault("mode", ""))
+                        .append(params.containsKey("header") ? " " + 
params.get("header") : "");
+                case "lifecycle" -> {
+                    sb.append(params.getOrDefault("action", 
"lifecycle")).append(' ')
+                            .append(params.getOrDefault("duration", ""));
+                    if (params.containsKey("repeat")) {
+                        sb.append(" ×").append(params.get("repeat"));
+                    }
+                    if (params.containsKey("every")) {
+                        sb.append(" every ").append(params.get("every"));
+                    }
+                }
+                default -> {
+                    return fault();
+                }
+            }
+            return sb.toString().strip();
+        }
+
+        /** The faults it fired at the polls of a consumer: a poll rule faults 
no exchange. */
+        long firedAtPoll() {
+            return firedAt.getOrDefault("poll", 0L);
+        }
+
         /** The options at, rate, until and from as they are written, for the 
RATE column. */
         String option(String name) {
             for (String token : tokens()) {
@@ -260,9 +318,10 @@ final class ChaosInfo {
         r.state = jo.getString("state");
         r.matches = num(jo.get("matches"), 0);
         if (jo.get("fired") instanceof Map<?, ?> fired) {
-            for (Object v : fired.values()) {
+            fired.forEach((k, v) -> {
                 r.fired += num(v, 0);
-            }
+                r.firedAt.put(String.valueOf(k), num(v, 0));
+            });
         }
         r.skipped = num(jo.get("skipped"), 0);
         if (jo.get("skippedReasons") instanceof Map<?, ?> reasons) {
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosRecipes.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosRecipes.java
index dbf220fe0a71..71200da83209 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosRecipes.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosRecipes.java
@@ -137,19 +137,10 @@ final class ChaosRecipes {
         }
         switch (p.pickFrom()) {
             case "values" -> answer.addAll(p.values());
-            case "producers" -> {
-                for (ProducerInfo producer : info.producers) {
-                    addUri(answer, producer.uri, p.schemes());
-                }
-                for (EndpointInfo endpoint : info.endpoints) {
-                    if ("out".equalsIgnoreCase(endpoint.direction)) {
-                        addUri(answer, endpoint.uri, p.schemes());
-                    }
-                }
-            }
+            case "producers" -> answer.addAll(producers(info, p.schemes()));
             case "pollingConsumers" -> {
                 for (ConsumerInfo consumer : info.consumers) {
-                    if (consumer.scheduled) {
+                    if (consumer.polls) {
                         addUri(answer, consumer.uri, p.schemes());
                     }
                 }
@@ -179,6 +170,40 @@ final class ChaosRecipes {
         return List.copyOf(answer);
     }
 
+    /**
+     * The endpoints the routes send to: the calls to other systems first 
(remote), then the sends within the
+     * integration, and last the endpoints only an error handler sends to, 
such as a dead letter channel. That one has a
+     * producer in every route, but no route sends to it (the endpoints have 
no {@code out} entry for it), and it has a
+     * recipe of its own. An {@code out} endpoint that a route consumes, such 
as the reply of {@code platform-http}, is
+     * not a send.
+     */
+    private static List<String> producers(IntegrationInfo info, List<String> 
schemes) {
+        Set<String> sentTo = new LinkedHashSet<>();
+        Set<String> consumed = new LinkedHashSet<>();
+        for (EndpointInfo endpoint : info.endpoints) {
+            addUri("out".equalsIgnoreCase(endpoint.direction) ? sentTo : 
consumed, endpoint.uri, List.of());
+        }
+        Set<String> remote = new LinkedHashSet<>();
+        Set<String> local = new LinkedHashSet<>();
+        Set<String> errorHandler = new LinkedHashSet<>();
+        for (ProducerInfo producer : info.producers) {
+            Set<String> uri = new LinkedHashSet<>();
+            addUri(uri, producer.uri, schemes);
+            uri.forEach(u -> (producer.remote ? remote : sentTo.contains(u) ? 
local : errorHandler).add(u));
+        }
+        for (EndpointInfo endpoint : info.endpoints) {
+            if ("out".equalsIgnoreCase(endpoint.direction)) {
+                Set<String> uri = new LinkedHashSet<>();
+                addUri(uri, endpoint.uri, schemes);
+                uri.stream().filter(u -> 
!consumed.contains(u)).forEach((endpoint.remote ? remote : local)::add);
+            }
+        }
+        List<String> answer = new ArrayList<>(remote);
+        local.stream().filter(u -> !answer.contains(u)).forEach(answer::add);
+        errorHandler.stream().filter(u -> 
!answer.contains(u)).forEach(answer::add);
+        return answer;
+    }
+
     // an id Camel made up, such as to1 or log3
     private static boolean isGeneratedId(String id) {
         int i = id.length();
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTab.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTab.java
index 2127e8154b48..cadfadf6db76 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTab.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTab.java
@@ -17,15 +17,19 @@
 package org.apache.camel.dsl.jbang.core.commands.tui;
 
 import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.Set;
 
 import dev.tamboui.layout.Constraint;
 import dev.tamboui.layout.Layout;
 import dev.tamboui.layout.Rect;
 import dev.tamboui.style.Style;
 import dev.tamboui.terminal.Frame;
+import dev.tamboui.text.CharWidth;
 import dev.tamboui.text.Line;
 import dev.tamboui.text.Span;
 import dev.tamboui.text.Text;
@@ -56,6 +60,7 @@ class ChaosTab extends AbstractTableTab {
     // the width of the sparkline in the rules table: the 60 seconds, 4 per 
column
     private static final int TABLE_SPARK = 15;
     private static final int ROUTES_WIDTH = 46;
+    private static final int MAX_EFFECT_WIDTH = 30;
 
     private final ChaosControl control;
 
@@ -98,6 +103,12 @@ class ChaosTab extends AbstractTableTab {
         return control.isConfirming();
     }
 
+    @Override
+    public boolean isConfirmCancelledByEsc() {
+        // Esc closes the confirm of a chaos action without doing it
+        return true;
+    }
+
     @Override
     public boolean handlePaste(String text) {
         return control.handlePaste(text);
@@ -150,7 +161,7 @@ class ChaosTab extends AbstractTableTab {
         List<Rect> bottom = Layout.horizontal()
                 .constraints(Constraint.fill(), 
Constraint.length(ROUTES_WIDTH))
                 .split(rows.get(2));
-        renderDetail(frame, bottom.get(0), chaos, selectedRule(chaos));
+        renderDetail(frame, bottom.get(0), info, selectedRule(chaos));
         renderRoutes(frame, bottom.get(1), chaos);
         control.render(frame, area);
     }
@@ -227,7 +238,7 @@ class ChaosTab extends AbstractTableTab {
                     Cell.from(Span.styled(r.id + (r.version > 1 ? " v" + 
r.version : ""), Style.EMPTY.fg(Theme.accent()))),
                     Cell.from(Span.styled(r.state != null ? r.state : "", 
stateStyle(r.state))),
                     Cell.from(target(r)),
-                    Cell.from(r.fault()),
+                    Cell.from(r.faultShort()),
                     Cell.from(rate(r)),
                     rightCell(String.valueOf(r.fired), 5),
                     rightCell(r.lastFiredAgo >= 0 ? shortTime(r.lastFiredAgo) 
: "-", 6),
@@ -246,6 +257,13 @@ class ChaosTab extends AbstractTableTab {
             return;
         }
         Style bold = Style.EMPTY.bold();
+        // EFFECT is as wide as its counts need ("62↩ 1…" was cut), TARGET and 
FAULT share the rest
+        int effectWidth = CharWidth.of("EFFECT");
+        for (ChaosInfo.Rule r : chaos.rules) {
+            effectWidth = Math.max(effectWidth, 
CharWidth.of(Line.from(effect(r)).rawContent()));
+        }
+        // one more, so the counts do not run into the note
+        effectWidth = Math.min(effectWidth + 1, MAX_EFFECT_WIDTH);
         Table table = Table.builder()
                 .rows(rows)
                 .header(Row.from(
@@ -268,7 +286,7 @@ class ChaosTab extends AbstractTableTab {
                         Constraint.length(5),
                         Constraint.length(6),
                         Constraint.length(TABLE_SPARK),
-                        Constraint.length(12),
+                        Constraint.length(effectWidth),
                         Constraint.fill(4))
                 .highlightStyle(Theme.selectionBg())
                 .highlightSpacing(Table.HighlightSpacing.ALWAYS)
@@ -317,16 +335,27 @@ class ChaosTab extends AbstractTableTab {
     static List<Span> effect(ChaosInfo.Rule r) {
         List<Span> spans = new ArrayList<>();
         if (r.faulted <= 0) {
+            // a poll rule faults no exchange: its effect is on the polls 
(CAMEL-26699)
+            if (r.firedAtPoll() > 0) {
+                spans.add(Span.styled(r.firedAtPoll() + (r.firedAtPoll() == 1 
? " poll " : " polls ") + pollWord(r),
+                        r.faultShort().startsWith("error") ? Theme.error() : 
Theme.warning()));
+            }
             return spans;
         }
         addCount(spans, r.completed, "✓", Theme.success());
         addCount(spans, r.recovered, "↩", Theme.warning());
         addCount(spans, r.handled, "⇩", Theme.error());
         addCount(spans, r.failed, TuiIcons.CROSS, Theme.error().bold());
-        addCount(spans, r.inFlight, "…", Theme.muted());
+        // not … , which reads as a cut text
+        addCount(spans, r.inFlight, "◷", Theme.muted());
         return spans;
     }
 
+    /** What a poll rule did to the polls: an error fails them, a drop skips 
them. */
+    private static String pollWord(ChaosInfo.Rule r) {
+        return r.faultShort().startsWith("error") ? "failed" : "skipped";
+    }
+
     private static void addCount(List<Span> spans, long count, String mark, 
Style style) {
         if (count > 0) {
             if (!spans.isEmpty()) {
@@ -381,7 +410,8 @@ class ChaosTab extends AbstractTableTab {
 
     // ---- the selected rule ----
 
-    private static void renderDetail(Frame frame, Rect area, ChaosInfo chaos, 
ChaosInfo.Rule r) {
+    private static void renderDetail(Frame frame, Rect area, IntegrationInfo 
info, ChaosInfo.Rule r) {
+        ChaosInfo chaos = info.chaos;
         if (r == null) {
             
frame.renderWidget(Paragraph.builder().text(Text.from(Line.from(Span.raw(""))))
                     .block(block(" Rule ")).build(), area);
@@ -410,7 +440,13 @@ class ChaosTab extends AbstractTableTab {
         if (r.faulted > 0) {
             lines.add(effectBar(r, Math.max(10, Math.min(40, area.width() - 
40))));
             lines.add(Line.from(effectWords(r)));
+        } else if (r.firedAtPoll() > 0) {
+            lines.add(Line.from(Span.styled(" Effect    ", label), Span.styled(
+                    r.firedAtPoll() + (r.firedAtPoll() == 1 ? " poll " : " 
polls ") + pollWord(r)
+                                                                               
+ ": the consumer polls again on its schedule",
+                    Theme.warning())));
         }
+        addImpact(lines, info, chaos, r);
         if (r.expectRoute != null) {
             String spec = "completed".equals(r.expectKind) && 
">=".equals(r.expectOp)
                     ? r.expectRoute + ":" + r.expectCount
@@ -436,16 +472,15 @@ class ChaosTab extends AbstractTableTab {
                     Span.styled(skip.getValue() + ": " + skip.getKey(), 
Theme.warning())));
         }
         boolean first = true;
-        for (ChaosInfo.RecentFault f : chaos.recentFaults) {
-            if (r.id == null || !r.id.equals(f.rule())) {
-                continue;
-            }
+        for (Map.Entry<ChaosInfo.RecentFault, Integer> e : recentOf(chaos, 
r).entrySet()) {
+            ChaosInfo.RecentFault f = e.getKey();
             lines.add(Line.from(
                     Span.styled(first ? " Recent    " : "           ", label),
                     Span.styled(String.format("%-6s", f.ago() >= 0 ? 
shortTime(f.ago()) : ""), label),
                     Span.styled("🎲 ", Theme.warning()),
                     Span.raw(f.fault() != null ? f.fault() : ""),
-                    Span.styled(where(f), label)));
+                    Span.styled(where(f), label),
+                    Span.styled(e.getValue() > 1 ? "  ×" + e.getValue() : "", 
Theme.warning())));
             first = false;
         }
         frame.renderWidget(Paragraph.builder()
@@ -454,6 +489,61 @@ class ChaosTab extends AbstractTableTab {
                 .build(), area);
     }
 
+    /**
+     * The recent faults of a rule, the latest first, with how many times each 
exchange was faulted: a redelivery is
+     * faulted again, and is shown once with its count ({@code ×3}), not once 
per attempt.
+     */
+    static Map<ChaosInfo.RecentFault, Integer> recentOf(ChaosInfo chaos, 
ChaosInfo.Rule r) {
+        Map<ChaosInfo.RecentFault, Integer> answer = new LinkedHashMap<>();
+        Map<String, ChaosInfo.RecentFault> byExchange = new LinkedHashMap<>();
+        for (ChaosInfo.RecentFault f : chaos.recentFaults) {
+            if (r.id == null || !r.id.equals(f.rule())) {
+                continue;
+            }
+            ChaosInfo.RecentFault seen = f.exchangeId() != null ? 
byExchange.get(f.exchangeId()) : null;
+            if (seen != null) {
+                answer.merge(seen, 1, Integer::sum);
+            } else {
+                if (f.exchangeId() != null) {
+                    byExchange.put(f.exchangeId(), f);
+                }
+                answer.put(f, 1);
+            }
+        }
+        return answer;
+    }
+
+    /**
+     * The routes the faults of the rule were in, with their times and 
throughput: what a delay costs, which the effect
+     * does not say (the exchanges complete), is in the times of the route.
+     */
+    private static void addImpact(List<Line> lines, IntegrationInfo info, 
ChaosInfo chaos, ChaosInfo.Rule r) {
+        Set<String> routes = new LinkedHashSet<>();
+        for (ChaosInfo.RecentFault f : chaos.recentFaults) {
+            if (r.id != null && r.id.equals(f.rule()) && f.route() != null) {
+                routes.add(f.route());
+            }
+        }
+        boolean first = true;
+        for (String routeId : routes) {
+            RouteInfo route = info.routes.stream().filter(ri -> 
routeId.equals(ri.routeId)).findFirst().orElse(null);
+            if (route == null || route.total <= 0) {
+                continue;
+            }
+            StringBuilder sb = new StringBuilder(routeId).append("  mean 
").append(route.meanTime).append("ms");
+            if (route.p99Time >= 0) {
+                sb.append(" · p95 ").append(route.p95Time).append("ms · p99 
").append(route.p99Time).append("ms");
+            } else {
+                sb.append(" · max ").append(route.maxTime).append("ms");
+            }
+            if (route.throughput != null && !route.throughput.isEmpty()) {
+                sb.append(" · ").append(route.throughput).append(" msg/s");
+            }
+            lines.add(Line.from(Span.styled(first ? " Routes    " : "          
 ", Theme.muted()), Span.raw(sb.toString())));
+            first = false;
+        }
+    }
+
     private static String where(ChaosInfo.RecentFault f) {
         StringBuilder sb = new StringBuilder();
         if (f.hook() != null) {
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/Confirmations.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/Confirmations.java
index bb2ae71bd732..9076cd41f9bb 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/Confirmations.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/Confirmations.java
@@ -22,6 +22,7 @@ import java.util.Queue;
 import java.util.function.BooleanSupplier;
 import java.util.function.Consumer;
 
+import dev.tamboui.tui.event.KeyCode;
 import dev.tamboui.tui.event.KeyEvent;
 import org.apache.camel.dsl.jbang.core.commands.ai.ToolConfirms;
 
@@ -30,12 +31,14 @@ import 
org.apache.camel.dsl.jbang.core.commands.ai.ToolConfirms;
  * of an AI tool ({@link ToolConfirms}), an AI edit waiting to be saved, a 
tool of its own an ACP agent wants to run,
  * deleting a file or discarding changes. Only the user answers them 
(CAMEL-25444): a key an MCP client sends
  * (tui_send_keys) or a tape plays never reaches one, and is dropped while one 
is open. While a tape drives the monitor
- * nothing asks at all ({@link MonitorContext#confirmFirst}), as the model or 
the tape does everything itself.
+ * nothing asks at all ({@link MonitorContext#confirmFirst}), as the model or 
the tape does everything itself. A lone
+ * Esc still reaches a dialog that Esc only cancels ({@link 
MonitorTab#isConfirmCancelledByEsc()}), so an MCP client can
+ * close a dialog it opened (CAMEL-26699).
  */
 final class Confirmations {
 
-    /** A dialog that asks to confirm, and whether it is open now. */
-    private record Dialog(String name, BooleanSupplier open) {
+    /** A dialog that asks to confirm, whether it is open now, and whether Esc 
only cancels it. */
+    private record Dialog(String name, BooleanSupplier open, BooleanSupplier 
cancelledByEsc) {
     }
 
     private final List<Dialog> dialogs = new ArrayList<>();
@@ -61,15 +64,33 @@ final class Confirmations {
                 .add("a confirm dialog of the tab", () -> {
                     MonitorTab tab = tabs.activeTab();
                     return tab != null && tab.isConfirming();
+                }, () -> {
+                    MonitorTab tab = tabs.activeTab();
+                    return tab != null && tab.isConfirmCancelledByEsc();
                 });
     }
 
     /** Adds a dialog that asks to confirm, named as the user sees it. */
     Confirmations add(String name, BooleanSupplier open) {
-        dialogs.add(new Dialog(name, open));
+        return add(name, open, () -> false);
+    }
+
+    /** Adds a dialog that asks to confirm, and whether Esc only cancels it. */
+    Confirmations add(String name, BooleanSupplier open, BooleanSupplier 
cancelledByEsc) {
+        dialogs.add(new Dialog(name, open, cancelledByEsc));
         return this;
     }
 
+    /** Whether the dialog that is open is one that Esc only cancels (false 
when none is open). */
+    boolean isOpenCancelledByEsc() {
+        for (Dialog dialog : dialogs) {
+            if (dialog.open().getAsBoolean()) {
+                return dialog.cancelledByEsc().getAsBoolean();
+            }
+        }
+        return false;
+    }
+
     /** The dialog that asks to confirm and is open, or null when none is. */
     String open() {
         for (Dialog dialog : dialogs) {
@@ -82,7 +103,8 @@ final class Confirmations {
 
     /**
      * Delivers the keys an MCP client or a tape sent that are due now, one by 
one, as a key can open a dialog. A key
-     * that comes while a dialog asks to confirm is dropped: only the user 
answers it.
+     * that comes while a dialog asks to confirm is dropped: only the user 
answers it. An Esc that only cancels the
+     * dialog gets through.
      *
      * @return whether a key was taken from the queue
      */
@@ -92,7 +114,7 @@ final class Confirmations {
         while ((key = pendingKeys.peek()) != null && key.isDue(now)) {
             pendingKeys.poll();
             taken = true;
-            if (open() != null) {
+            if (open() != null && !(isEsc(key.event()) && 
isOpenCancelledByEsc())) {
                 continue;
             }
             delivering = true;
@@ -105,6 +127,10 @@ final class Confirmations {
         return taken;
     }
 
+    private static boolean isEsc(KeyEvent ke) {
+        return ke.isKey(KeyCode.ESCAPE) && !ke.hasCtrl() && !ke.hasAlt() && 
!ke.hasShift();
+    }
+
     /** Whether the key being handled was sent by an MCP client or a tape, not 
typed by the user. */
     boolean isDelivering() {
         return delivering;
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ConsumerInfo.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ConsumerInfo.java
index 62fb00441da1..403c83db4ed8 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ConsumerInfo.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ConsumerInfo.java
@@ -22,6 +22,8 @@ class ConsumerInfo {
     String state;
     String className;
     boolean scheduled;
+    // a scheduled poll consumer (file, ftp, sql, ...): it has a scheduler, 
which a timer has not (CAMEL-26699)
+    boolean polls;
     boolean remote;
     int inflight;
     Boolean polling;
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
index 6071aff381b6..34c173cd5b7a 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
@@ -298,6 +298,12 @@ class McpFacade {
         return c != null ? c.open() : null;
     }
 
+    /** Whether the dialog that asks to confirm and is open is one that Esc 
only cancels (CAMEL-26699). */
+    boolean isOpenConfirmationCancelledByEsc() {
+        Confirmations c = confirmations;
+        return c != null && c.isOpenCancelledByEsc();
+    }
+
     void setFullScan(Runnable request, LongSupplier done) {
         this.fullScanRequest = request;
         this.fullScansDone = done;
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorTab.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorTab.java
index 0cd774be2449..7f597ea54795 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorTab.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorTab.java
@@ -71,6 +71,15 @@ interface MonitorTab {
         return false;
     }
 
+    /**
+     * Whether Esc only cancels the confirm dialog of this tab ({@link 
#isConfirming()}), so an MCP client may send it
+     * to close a dialog it opened: it can never answer yes. False by default, 
as Esc means another answer in some
+     * dialogs (CAMEL-26699).
+     */
+    default boolean isConfirmCancelledByEsc() {
+        return false;
+    }
+
     /**
      * Pastes text into the text field that has the keyboard; returns {@code 
false} when none has, as the default does.
      */
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParser.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParser.java
index 35e6a38654e7..c3ccaaa7917d 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParser.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParser.java
@@ -426,6 +426,8 @@ final class StatusParser {
                     // the consumers console says clazz (class in older 
versions)
                     ci.className = cj.getStringOrDefault("clazz", 
cj.getString("class"));
                     ci.scheduled = Boolean.TRUE.equals(cj.get("scheduled"));
+                    // only a scheduled poll consumer has a scheduler: at=poll 
of camel-chaos hooks its polls
+                    ci.polls = cj.get("schedulerClass") != null;
                     ci.remote = Boolean.TRUE.equals(cj.get("remote"));
                     ci.inflight = cj.getIntegerOrDefault("inflight", 0);
                     ci.polling = Boolean.TRUE.equals(cj.get("polling"));
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
index 6dfe562d3e00..63d187fea689 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
@@ -505,10 +505,15 @@ class TuiToolRegistry {
             return "Error: keys array is empty";
         }
         String confirmation = facade.openConfirmation();
-        if (confirmation != null) {
-            // only the user answers a dialog that asks to confirm an action 
(CAMEL-25444)
+        boolean escCancels = keys.size() == 1
+                && ("Esc".equalsIgnoreCase(keys.get(0)) || 
"Escape".equalsIgnoreCase(keys.get(0)))
+                && facade.isOpenConfirmationCancelledByEsc();
+        if (confirmation != null && !escCancels) {
+            // only the user answers a dialog that asks to confirm an action 
(CAMEL-25444); a lone Esc may cancel a
+            // dialog that Esc only cancels, never answer it (CAMEL-26699)
             return "Not sent: " + confirmation + " is open, and only the user 
answers it. Wait for the user, or tell"
-                   + " them what you need: keys sent while it is open are 
dropped.";
+                   + " them what you need: keys sent while it is open are 
dropped"
+                   + (facade.isOpenConfirmationCancelledByEsc() ? " (a lone 
Esc cancels it)." : ".");
         }
         int delay = 150;
         Object delayArg = args.get("delay");
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/chaos.md 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/chaos.md
index be5a638d025f..b812000b0429 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/chaos.md
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/chaos.md
@@ -31,12 +31,12 @@ minute, and the seed of the random draws.
 - **ID** — The id of the rule (r1, r2, ...), with its version when it was 
changed
 - **STATE** — `active`, `waiting` (it starts later, `from=`) or `ended`
 - **TARGET** — What the rule matches: `endpoint=`, `component=`, `route=` or 
`step=`
-- **FAULT** — What it does, with its parameters, such as `error status=503`
+- **FAULT** — What it does, in a few words: `error status 503`, `error 
ConnectException`, `delay 500ms..1s`, `stop 5s ×3 every 20s` (the rule in full 
is the title of the panel under the table)
 - **RATE** — The share of the matches it fired at, next to its own: `24% of 
25%`, `20% of 1 in 5`, `16% of 5s in 30s`; for a flapping route the round
 - **FIRED** — How many faults it fired
 - **LAST** — How long ago it last fired
 - **FAULTS/S** — Its faults per second over the last minute, as a sparkline
-- **EFFECT** — What its faults did to the exchanges: `✓` completed, `↩` 
recovered by redelivery, `⇩` handled by the error handler (such as the dead 
letter channel), `✗` failed, `…` in flight
+- **EFFECT** — What its faults did to the exchanges: `✓` completed, `↩` 
recovered by redelivery, `⇩` handled by the error handler (such as the dead 
letter channel), `✗` failed, `◷` in flight. A rule at the polls of a consumer 
faults no exchange: its effect is the polls it failed or skipped (`39 polls 
failed`)
 - **NOTE** — When it ends or starts, its bursts, what a stall or lifecycle 
rule does to its route, and the faults it skipped
 
 ## The selected rule
@@ -45,7 +45,10 @@ The panel under the table shows the selected rule in full: 
its faults
 per second, rate, the effect as a bar with the counts in words, its
 expectation with the time left, why it skipped faults, and its recent
 faults: when, the fault, where (the endpoint or step), the route and the
-exchange.
+exchange. An exchange faulted again on its redeliveries is shown once, with
+how many times (`×3`). The routes of its recent faults are listed with
+their mean time, p95 and p99, and throughput: what a delay costs, as the
+exchanges it delayed still complete.
 
 ## Routes
 
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosRecipesTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosRecipesTest.java
index 6a141232fa93..c27656c89d3f 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosRecipesTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosRecipesTest.java
@@ -77,11 +77,14 @@ class ChaosRecipesTest {
             producer.uri = uri;
             info.producers.add(producer);
         }
+        // both are scheduled, as the consumers console says; only the file 
consumer polls (it has a scheduler)
         ConsumerInfo timer = new ConsumerInfo();
         timer.uri = "timer://orders?period=500";
+        timer.scheduled = true;
         ConsumerInfo files = new ConsumerInfo();
         files.uri = "file://inbox?delay=1000";
         files.scheduled = true;
+        files.polls = true;
         info.consumers.add(timer);
         info.consumers.add(files);
         for (String id : List.of("orders", "inventory")) {
@@ -137,6 +140,49 @@ class ChaosRecipesTest {
         assertThat(ChaosRecipes.candidates(r.get(3).placeholders().get(1), 
info)).isEmpty();
     }
 
+    @Test
+    void theCallsToOtherSystemsFirstAndTheDeadLetterChannelLast() {
+        // CAMEL-26699: as the status of the demo shop has them
+        IntegrationInfo info = new IntegrationInfo();
+        for (String[] p : new String[][] {
+                { "direct://payment", "false" }, { "seda://dlq", "false" }, { 
"seda://shipping", "false" },
+                { "direct://inventory", "false" }, { 
"http://localhost:8765/stock";, "true" },
+                { "seda://dlq", "false" }, { "http://localhost:8765/pay";, 
"true" } }) {
+            ProducerInfo producer = new ProducerInfo();
+            producer.uri = p[0];
+            producer.remote = Boolean.parseBoolean(p[1]);
+            info.producers.add(producer);
+        }
+        for (String[] e : new String[][] {
+                { "direct://inventory", "in", "false" }, { 
"direct://inventory", "out", "false" },
+                { "platform-http:///stock";, "in", "true" }, { 
"platform-http:///stock";, "out", "true" },
+                { "http://localhost:8765/stock";, "out", "true" }, { 
"seda://dlq", "in", "false" },
+                { "direct://payment", "out", "false" }, { "seda://shipping", 
"in", "false" },
+                { "seda://shipping", "out", "false" }, { 
"http://localhost:8765/pay";, "out", "true" } }) {
+            EndpointInfo endpoint = new EndpointInfo();
+            endpoint.uri = e[0];
+            endpoint.direction = e[1];
+            endpoint.remote = Boolean.parseBoolean(e[2]);
+            info.endpoints.add(endpoint);
+        }
+        ChaosRecipes.Placeholder any = 
ChaosRecipes.parse(MODEL).recipes().get(3).placeholders().get(0);
+
+        // the reply of platform-http is not a send, and the dead letter 
channel only an error handler sends to
+        assertThat(ChaosRecipes.candidates(any, info)).containsExactly(
+                "http://localhost:8765/stock";, "http://localhost:8765/pay";, 
"direct://payment", "seda://shipping",
+                "direct://inventory", "seda://dlq");
+    }
+
+    @Test
+    void aTimerIsNotOfferedForThePollFaults() {
+        IntegrationInfo info = chaosdemo();
+        ChaosRecipes.Placeholder polled = 
ChaosRecipes.parse(MODEL).recipes().get(5).placeholders().get(0);
+        assertThat(ChaosRecipes.candidates(polled, 
info)).containsExactly("file://inbox");
+        ConsumerInfo timer = info.consumers.get(0);
+        timer.id = "orders";
+        assertThat(ChaosHere.forConsumer(timer).polled()).isNull();
+    }
+
     @Test
     void theCatalogOfTheCliHasTheRecipes() {
         ChaosRecipes recipes = ChaosRecipes.load();
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTabRenderTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTabRenderTest.java
index 52e87bd580f6..9d74e9be995a 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTabRenderTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTabRenderTest.java
@@ -145,6 +145,67 @@ class ChaosTabRenderTest {
         assertThat(screen).contains("error status 503 at send 
http://localhost:8765/stock";).contains("3F2A-1E");
     }
 
+    @Test
+    void theFaultInAFewWordsAndTheEffectOfAPollRule() throws Exception {
+        // CAMEL-26699: as the demo shop had them
+        chaos("""
+                {"enabled": true, "rules": [
+                  {"id": "r3", "version": 1, "state": "active",
+                   "rule": "endpoint=http://localhost:8765/pay* error 
exception=java.net.ConnectException rate=0.5",
+                   "hooks": ["send"], "matches": 20, "fired": {"send": 9}, 
"faultsLastMinute": 9,
+                   "effect": {"faulted": 9, "completed": 0, "recovered": 7, 
"handled": 0, "failed": 0, "inFlight": 2}},
+                  {"id": "r4", "version": 1, "state": "active",
+                   "rule": "route=shipping lifecycle action=stop duration=5s 
repeat=3 every=20s",
+                   "hooks": [], "matches": 2, "fired": {"lifecycle": 2}, 
"faultsLastMinute": 2},
+                  {"id": "r5", "version": 1, "state": "active", "rule": 
"endpoint=file://inbox* error at=poll rate=0.5",
+                   "hooks": ["poll"], "matches": 80, "fired": {"poll": 39}, 
"faultsLastMinute": 30}],
+                 "report": {"verdict": "PASS", "final": true, "seed": 42}}
+                """);
+        ChaosInfo.Rule pay = info.chaos.rule("r3");
+        assertThat(pay.faultShort()).isEqualTo("error ConnectException");
+        assertThat(info.chaos.rule("r4").faultShort()).isEqualTo("stop 5s ×3 
every 20s");
+        ChaosInfo.Rule poll = info.chaos.rule("r5");
+        assertThat(poll.firedAtPoll()).isEqualTo(39);
+
+        String screen = TuiTestHelper.renderToString(new ChaosTab(ctx), 160, 
40);
+        // in flight is not … , which reads as a cut text
+        assertThat(screen).contains("error ConnectException").contains("stop 
5s ×3 every 20s").contains("7↩ 2◷")
+                .contains("39 polls failed");
+    }
+
+    @Test
+    void theRecentFaultsOfAnExchangeOnceAndTheRoutesItWasIn() throws Exception 
{
+        chaos("""
+                {"enabled": true, "rules": [
+                  {"id": "r1", "version": 1, "state": "active", "rule": 
"step=reserve error rate=0.6",
+                   "hooks": ["step"], "matches": 30, "fired": {"step": 4}, 
"faultsLastMinute": 4,
+                   "effect": {"faulted": 2, "completed": 0, "recovered": 1, 
"handled": 1, "failed": 0, "inFlight": 0}}],
+                 "report": {"verdict": "PASS", "final": true, "seed": 42, 
"recentFaults": [
+                   {"ago": 1000, "rule": "r1", "fault": "error 
ChaosException", "hook": "step", "route": "inventory",
+                    "step": "reserve", "exchangeId": 
"98AE31B10677BEA-000000000000286B"},
+                   {"ago": 1500, "rule": "r1", "fault": "error 
ChaosException", "hook": "step", "route": "inventory",
+                    "step": "reserve", "exchangeId": 
"98AE31B10677BEA-000000000000286B"},
+                   {"ago": 2000, "rule": "r1", "fault": "error 
ChaosException", "hook": "step", "route": "inventory",
+                    "step": "reserve", "exchangeId": 
"98AE31B10677BEA-000000000000286B"},
+                   {"ago": 4000, "rule": "r1", "fault": "error 
ChaosException", "hook": "step", "route": "inventory",
+                    "step": "reserve", "exchangeId": 
"98AE31B10677BEA-0000000000002861"}]}}
+                """);
+        RouteInfo inventory = new RouteInfo();
+        inventory.routeId = "inventory";
+        inventory.total = 900;
+        inventory.meanTime = 27;
+        inventory.p95Time = 411;
+        inventory.p99Time = 907;
+        inventory.throughput = "1.69";
+        info.routes.add(inventory);
+
+        assertThat(ChaosTab.recentOf(info.chaos, 
info.chaos.rule("r1")).values()).containsExactly(3, 1);
+        String screen = TuiTestHelper.renderToString(new ChaosTab(ctx), 160, 
40);
+        assertThat(screen).contains("98AE31…286B  ×3").contains("98AE31…2861")
+                .contains("inventory  mean 27ms · p95 411ms · p99 907ms · 1.69 
msg/s");
+        assertThat(screen.lines().filter(l -> 
l.contains("…286B")).count()).isEqualTo(1);
+    }
+
     @Test
     void withoutCamelChaosOrWhenItIsOff() throws Exception {
         String none = TuiTestHelper.renderToString(new ChaosTab(ctx), 120, 15);
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ConfirmationsTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ConfirmationsTest.java
index ec38d5b61f3d..6b81a39fd80f 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ConfirmationsTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ConfirmationsTest.java
@@ -102,6 +102,45 @@ class ConfirmationsTest {
         assertThat(pending).isEmpty();
     }
 
+    @Test
+    void aLoneEscCancelsADialogThatEscOnlyCancels() {
+        // CAMEL-26699: the confirm of a chaos action, which an MCP client 
opened; Esc closes it, Enter or y never answer
+        AtomicBoolean chaosConfirm = new AtomicBoolean(true);
+        Confirmations chaos = new Confirmations(pending).add("a confirm dialog 
of the tab", chaosConfirm::get, () -> true);
+        pending.add(due(KeyEvent.ofChar('y')));
+        pending.add(due(KeyEvent.ofKey(KeyCode.ESCAPE, KeyModifiers.CTRL)));
+        pending.add(due(KeyEvent.ofKey(KeyCode.ESCAPE, KeyModifiers.NONE)));
+        List<String> delivered = new ArrayList<>();
+
+        chaos.deliverDue(0, key -> {
+            delivered.add(key.isKey(KeyCode.ESCAPE) ? "Esc" : key.string());
+            chaosConfirm.set(false);
+        });
+
+        assertThat(delivered).containsExactly("Esc");
+        assertThat(chaos.open()).isNull();
+        // the quit confirmation is not one that Esc only cancels
+        assertThat(confirmations.isOpenCancelledByEsc()).isFalse();
+    }
+
+    @Test
+    void sendKeysLetsALoneEscCancelADialogThatEscOnlyCancels() {
+        AtomicBoolean chaosConfirm = new AtomicBoolean(true);
+        Confirmations chaos = new Confirmations(pending).add("a confirm dialog 
of the tab", chaosConfirm::get, () -> true);
+        McpFacade facade = new McpFacade(
+                null, new AtomicReference<>(List.of()), null, new 
RecordingManager(null), null, null, null, null, null,
+                null, null, pending, null);
+        facade.setConfirmations(chaos);
+        TuiToolRegistry tools = new TuiToolRegistry(facade);
+
+        assertThat(tools.callSendKeys(Map.of("keys", List.of("y"))))
+                .startsWith("Not sent: a confirm dialog of the tab is 
open").endsWith("(a lone Esc cancels it).");
+        assertThat(tools.callSendKeys(Map.of("keys", List.of("Esc", 
"y")))).startsWith("Not sent");
+        assertThat(pending).isEmpty();
+        assertThat(tools.callSendKeys(Map.of("keys", 
List.of("Esc")))).startsWith("Queued 1 key");
+        assertThat(pending).hasSize(1);
+    }
+
     @Test
     void whileATapeDrivesTheMonitorNothingAsksToConfirm() {
         MonitorContext ctx = new MonitorContext(new 
AtomicReference<>(List.of()), new AtomicReference<>(List.of()));

Reply via email to