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 7a87922020eb CAMEL-25425: camel-jbang - the monitor keeps running 
examples' files, and says when a start or reload fails (#27516)
7a87922020eb is described below

commit 7a87922020eb0a910770c9f1c50eadc6ff89e00e
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Oct 8 00:36:19 2026 +0200

    CAMEL-25425: camel-jbang - the monitor keeps running examples' files, and 
says when a start or reload fails (#27516)
    
    * CAMEL-25425: camel-jbang - quitting the monitor keeps the files of the 
examples that still run
    
    The examples the monitor starts keep running when it quits, but their files
    were deleted when it exited (File.deleteOnExit), so a running example lost
    the files it reads, and a dev-mode reload ended with no routes. The folders
    were also left behind empty, as deleteOnExit cannot delete a folder that is
    not empty: hundreds piled up in the temporary directory.
    
    The monitor now deletes, when it quits, the folders of the examples it
    started that no longer run, and the example folders left behind for an hour
    or more that no running integration uses. camel run --example deletes its
    folder with what the example wrote in it, and keeps it when run with
    --background, as the example then outlives the command.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
    
    * CAMEL-25426: camel-jbang - an infra service that fails to start in the 
monitor says why
    
    The monitor started infra services with camel infra run --background, whose
    output is lost, so a service that failed to start (the Mosquitto container
    cannot bind port 1883 while Artemis has it) was silent: it never appeared,
    and an example waiting for it was dropped after 120 seconds with only a
    timeout notice. A first start that pulls a large image (Keycloak, Ollama)
    also took longer than that, so the example never ran.
    
    The monitor now runs camel infra run itself, in a process that outlives it
    as before, and keeps the output: a failed start shows the failure log, and
    the example waiting for it is dropped at once with a notice. An example
    waits as long as its infra is still starting (at most 15 minutes).
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
    
    * CAMEL-25426: camel-jbang - the monitor's MCP tool that runs an example 
says whether it started
    
    It answered "started" as soon as the example was launched, also for an
    example that failed a few seconds later (a missing OPENAI_API_KEY), so an
    agent never learned that it failed. It now waits up to 30 seconds for Camel
    to start, and answers started, failed with the end of the log (without
    colors and stack frames, so the cause is in it), or starting.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
    
    * CAMEL-25426: camel-jbang - an opened project whose run ended without an 
app is Stopped at once
    
    An opened project showed Starting for up to 5 minutes when its run ended
    before its app showed up. It is now Stopped as soon as the run's process
    has ended; a run that failed also shows its failure log, as before.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
    
    * CAMEL-25426: camel-jbang - the monitor shows a failed reload, as camel ps 
does
    
    When a dev-mode reload failed (a file with an error, a file that is gone),
    camel ps showed the app as Error with the reason, but the monitor kept
    showing it as Running, and a save in the source editor that broke the
    reload said only "Saved". The old routes keep running and nothing said so.
    
    The monitor now reads the last reload error from the status: the Overview
    shows the app as Error, the info panel says why, a notice tells when a
    reload fails, and the Overview table gives the reason to an agent. It clears
    when a reload succeeds again.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
    
    * CAMEL-25426: camel-jbang - the monitor's tables show their rows again 
after the list shrinks
    
    A table only scrolled to keep the selected row in view, so when its list
    shrank (apps stopped, projects closed) it kept its old scroll offset: after
    8 rows became 2 the Overview drew only one, and the other app looked
    stopped. The offset is now pulled back so the rows fill the view.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
    
    * CAMEL-25426: camel-jbang - camel_control in the monitor lists infra 
services for an unknown name
    
    Review follow-up of CAMEL-25424: the name can be an infra service, but the
    error for an unknown name listed only the integrations. The deferred launch
    of an example that needs aws sqs is tested too.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
    
    * CAMEL-25425: camel-jbang - keep the Javadoc of quitTui on quitTui
    
    Review follow-up: deleteExampleDirs was added between quitTui and its
    Javadoc. It now comes after quitTui.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
    
    * CAMEL-25426: camel-jbang - an infra start that times out in the monitor 
is recorded as failed
    
    Review follow-up: every other end of a watched launch records its outcome.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
    
    * CAMEL-25426: camel-jbang - an infra start that times out in the monitor 
shows its failure log
    
    Review follow-up: as the other failed starts do.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
    
    ---------
    
    Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
 .../apache/camel/dsl/jbang/core/commands/Run.java  |   5 +-
 .../camel/dsl/jbang/core/common/ExampleHelper.java |  84 +++++++++-
 .../core/common/ExampleHelperStaleDirsTest.java    |  68 +++++++++
 .../dsl/jbang/core/commands/tui/AbstractTab.java   |  16 ++
 .../dsl/jbang/core/commands/tui/CamelMonitor.java  |  21 +++
 .../core/commands/tui/DataRefreshService.java      |  25 ++-
 .../jbang/core/commands/tui/InfraBrowserPopup.java |  22 +--
 .../jbang/core/commands/tui/IntegrationInfo.java   |   2 +
 .../dsl/jbang/core/commands/tui/LaunchManager.java | 170 +++++++++++++++++++--
 .../dsl/jbang/core/commands/tui/McpFacade.java     |  12 +-
 .../dsl/jbang/core/commands/tui/OverviewTab.java   |  15 +-
 .../dsl/jbang/core/commands/tui/StatusParser.java  |   2 +
 .../core/commands/tui/TuiToolDefinitions.java      |   2 +
 .../jbang/core/commands/tui/TuiToolRegistry.java   |  27 +++-
 .../core/commands/tui/LaunchManagerInfraTest.java  |  62 ++++++++
 .../tui/LaunchManagerStartFailureTest.java         |  17 +++
 .../core/commands/tui/McpFacadeControlTest.java    |  12 ++
 .../commands/tui/OpenedProjectLifecycleTest.java   |  17 +++
 .../jbang/core/commands/tui/StatusParserTest.java  |  26 ++++
 .../core/commands/tui/TableOffsetClampTest.java    |  73 +++++++++
 20 files changed, 626 insertions(+), 52 deletions(-)

diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Run.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Run.java
index 69dbb782ac58..597bce0c72d4 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Run.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Run.java
@@ -536,7 +536,8 @@ public class Run extends CamelCommand {
     }
 
     private int runBundledExample(JsonObject entry) throws Exception {
-        Path tempDir = ExampleHelper.extractBundledExample(entry);
+        // an example run in the background outlives this JVM, so its files 
must stay (CAMEL-25425)
+        Path tempDir = ExampleHelper.extractBundledExample(entry, !background);
         return runExampleIn(entry, tempDir);
     }
 
@@ -549,7 +550,7 @@ public class Run extends CamelCommand {
 
         Path tempDir;
         try {
-            tempDir = ExampleHelper.downloadGithubExample(entry);
+            tempDir = ExampleHelper.downloadGithubExample(entry, !background);
         } catch (Exception e) {
             printer().printErr("Failed to fetch example from GitHub: " + 
e.getMessage());
             printer().printErr("This example requires an internet 
connection.");
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ExampleHelper.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ExampleHelper.java
index bd2449a2fce5..3ffb45a09df4 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ExampleHelper.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ExampleHelper.java
@@ -33,6 +33,7 @@ import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 
+import org.apache.camel.util.FileUtil;
 import org.apache.camel.util.IOHelper;
 import org.apache.camel.util.json.JsonArray;
 import org.apache.camel.util.json.JsonObject;
@@ -437,10 +438,23 @@ public final class ExampleHelper {
         return new ArrayList<>(files);
     }
 
+    /** The name prefix of the temporary folder an example runs in. */
+    public static final String EXAMPLE_DIR_PREFIX = "camel-example-";
+
     public static Path extractBundledExample(JsonObject entry) throws 
Exception {
+        return extractBundledExample(entry, true);
+    }
+
+    /**
+     * Extracts a bundled example to a temporary folder of its own.
+     *
+     * @param deleteOnExit whether the folder is deleted when this JVM exits; 
false when the example may outlive it, as
+     *                     the examples the monitor starts do (they keep 
running when it quits)
+     */
+    public static Path extractBundledExample(JsonObject entry, boolean 
deleteOnExit) throws Exception {
         String name = entry.getString("name");
         List<String> fileNames = getFiles(entry);
-        Path tempDir = Files.createTempDirectory("camel-example-");
+        Path tempDir = Files.createTempDirectory(EXAMPLE_DIR_PREFIX);
 
         for (String fileName : fileNames) {
             String resourcePath = "examples/" + name + "/" + fileName;
@@ -451,20 +465,29 @@ public final class ExampleHelper {
                     // create parent dirs for nested files like 
input/account.xml
                     Files.createDirectories(targetFile.getParent());
                     Files.writeString(targetFile, content);
-                    targetFile.toFile().deleteOnExit();
-                    targetFile.getParent().toFile().deleteOnExit();
                 }
             }
         }
 
-        tempDir.toFile().deleteOnExit();
+        if (deleteOnExit) {
+            deleteOnExit(tempDir);
+        }
         return tempDir;
     }
 
     public static Path downloadGithubExample(JsonObject entry) throws 
Exception {
+        return downloadGithubExample(entry, true);
+    }
+
+    /**
+     * Downloads an example from GitHub to a temporary folder of its own.
+     *
+     * @param deleteOnExit whether the folder is deleted when this JVM exits; 
false when the example may outlive it
+     */
+    public static Path downloadGithubExample(JsonObject entry, boolean 
deleteOnExit) throws Exception {
         String name = entry.getString("name");
         List<String> fileNames = getFiles(entry);
-        Path tempDir = Files.createTempDirectory("camel-example-");
+        Path tempDir = Files.createTempDirectory(EXAMPLE_DIR_PREFIX);
 
         HttpClient hc = 
HttpClient.newBuilder().proxy(ProxySelector.getDefault()).build();
         for (String fileName : fileNames) {
@@ -476,15 +499,60 @@ public final class ExampleHelper {
                 Path targetFile = tempDir.resolve(fileName);
                 Files.createDirectories(targetFile.getParent());
                 Files.writeString(targetFile, res.body());
-                targetFile.toFile().deleteOnExit();
-                targetFile.getParent().toFile().deleteOnExit();
             }
         }
 
-        tempDir.toFile().deleteOnExit();
+        if (deleteOnExit) {
+            deleteOnExit(tempDir);
+        }
         return tempDir;
     }
 
+    /**
+     * Deletes the folder of an example, with what the example wrote in it 
(out/, a done folder), when this JVM exits.
+     * File.deleteOnExit leaves the folder behind once anything else is in it.
+     */
+    private static void deleteOnExit(Path dir) {
+        Runtime.getRuntime().addShutdownHook(new Thread(() -> 
FileUtil.removeDir(dir.toFile()), "CamelExampleCleanup"));
+    }
+
+    /**
+     * The folders of examples in the temporary directory that are older than 
the given age and that none of the given
+     * folders (those of running integrations) is in: left behind by runs that 
ended, or by a process that was killed.
+     */
+    public static List<Path> staleExampleDirs(Collection<Path> inUse, Duration 
minAge) {
+        return staleExampleDirs(Path.of(System.getProperty("java.io.tmpdir")), 
inUse, minAge);
+    }
+
+    static List<Path> staleExampleDirs(Path tmp, Collection<Path> inUse, 
Duration minAge) {
+        List<Path> answer = new ArrayList<>();
+        List<Path> used = inUse.stream().map(ExampleHelper::realPath).toList();
+        long cutoff = System.currentTimeMillis() - minAge.toMillis();
+        try (var dirs = Files.newDirectoryStream(tmp, EXAMPLE_DIR_PREFIX + 
"*")) {
+            for (Path dir : dirs) {
+                if (!Files.isDirectory(dir) || 
Files.getLastModifiedTime(dir).toMillis() > cutoff) {
+                    continue;
+                }
+                Path real = realPath(dir);
+                if (used.stream().noneMatch(u -> u.startsWith(real))) {
+                    answer.add(dir);
+                }
+            }
+        } catch (Exception e) {
+            // the temporary directory cannot be listed: nothing to clean
+        }
+        return answer;
+    }
+
+    // on macOS the temporary directory is reached through a link (/var is 
/private/var)
+    private static Path realPath(Path p) {
+        try {
+            return p.toRealPath();
+        } catch (Exception e) {
+            return p.toAbsolutePath().normalize();
+        }
+    }
+
     public static String getGithubUrl(JsonObject entry) {
         return GITHUB_EXAMPLES_URL + entry.getString("name");
     }
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/ExampleHelperStaleDirsTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/ExampleHelperStaleDirsTest.java
new file mode 100644
index 000000000000..72af08e0ff5f
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/ExampleHelperStaleDirsTest.java
@@ -0,0 +1,68 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.common;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.FileTime;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * The folders of examples left in the temporary directory are found for 
cleanup, but never the folder of an example
+ * that still runs, nor one just made for an example that is starting 
(CAMEL-25425).
+ */
+class ExampleHelperStaleDirsTest {
+
+    @TempDir
+    Path tmp;
+
+    @Test
+    void anOldFolderNoIntegrationRunsInIsStale() throws Exception {
+        Path left = exampleDir("camel-example-1", Duration.ofHours(2));
+        Path running = exampleDir("camel-example-2", Duration.ofHours(2));
+        Path starting = exampleDir("camel-example-3", Duration.ZERO);
+        Path other = Files.createDirectory(tmp.resolve("something-else"));
+        Files.setLastModifiedTime(other, 
FileTime.from(Instant.now().minus(Duration.ofHours(2))));
+
+        List<Path> stale = ExampleHelper.staleExampleDirs(tmp, 
List.of(running), Duration.ofHours(1));
+
+        assertThat(stale).containsExactly(left);
+        assertThat(starting).exists();
+    }
+
+    @Test
+    void anIntegrationRunningInASubfolderKeepsTheFolder() throws Exception {
+        Path dir = exampleDir("camel-example-1", Duration.ofHours(2));
+        Path sub = Files.createDirectories(dir.resolve("app"));
+
+        assertThat(ExampleHelper.staleExampleDirs(tmp, List.of(sub), 
Duration.ZERO)).isEmpty();
+    }
+
+    private Path exampleDir(String name, Duration age) throws Exception {
+        Path dir = Files.createDirectory(tmp.resolve(name));
+        Files.writeString(dir.resolve("route.camel.yaml"), "- route: {}\n");
+        Files.setLastModifiedTime(dir, 
FileTime.from(Instant.now().minus(age)));
+        return dir;
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AbstractTab.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AbstractTab.java
index 3ccc74ce9fe1..8b18bf09c37e 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AbstractTab.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AbstractTab.java
@@ -142,6 +142,7 @@ abstract class AbstractTab implements MonitorTab {
         if (tableArea == null || table == null || tableState == null || 
scrollState == null) {
             return;
         }
+        clampTableOffset(table, tableArea, tableState, rowCount);
         int visibleRows = table.viewportHeight(tableArea);
         if (visibleRows <= 0 || rowCount <= visibleRows) {
             return;
@@ -158,6 +159,21 @@ abstract class AbstractTab implements MonitorTab {
         frame.renderStatefulWidget(Scrollbar.builder().build(), scrollRect, 
scrollState);
     }
 
+    /**
+     * Pulls the scroll offset of a table back when rows went away, so the 
rows fill the view. The table only scrolls to
+     * keep the selected row in view: when the list shrinks (apps stopped, 
projects closed), rows that fit could stay
+     * above the top, and a running app looked gone.
+     */
+    static void clampTableOffset(Table table, Rect tableArea, TableState 
tableState, int rowCount) {
+        if (tableArea == null || table == null || tableState == null) {
+            return;
+        }
+        int maxOffset = Math.max(0, rowCount - Math.max(0, 
table.viewportHeight(tableArea)));
+        if (tableState.offset() > maxOffset) {
+            tableState.setOffset(maxOffset);
+        }
+    }
+
     protected static boolean handleTableClick(MouseEvent me, Rect tableArea, 
TableState tableState, int rowCount) {
         if (tableArea == null || tableState == null || rowCount <= 0) {
             return false;
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
index 90441c84e7ef..f59d77b89510 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
@@ -582,6 +582,22 @@ public class CamelMonitor extends CamelCommand {
         }
     }
 
+    /**
+     * Deletes the folders of the examples that no longer run; an example that 
still runs keeps its folder and the files
+     * it reads (CAMEL-25425).
+     */
+    private void deleteExampleDirs() {
+        try {
+            List<Path> runningDirs = ctx.data.get().stream()
+                    .filter(i -> !i.vanishing && i.directory != null)
+                    .map(i -> Path.of(i.directory))
+                    .toList();
+            actionsPopup.getLaunchManager().deleteExampleDirs(runningDirs);
+        } catch (Exception e) {
+            // best effort: what is left is removed by a later session
+        }
+    }
+
     /**
      * Creates the tab registry, initialises all tabs and wires their data 
refresh callbacks.
      */
@@ -1149,6 +1165,7 @@ public class CamelMonitor extends CamelCommand {
                 webServer.stop();
             }
             deleteMcpJson(mcpJsonFile);
+            deleteExampleDirs();
             this.runner = null;
             if (record != null) {
                 // Only the session that set the properties clears them again
@@ -2058,6 +2075,10 @@ public class CamelMonitor extends CamelCommand {
         if (reloadMsg != null) {
             setNotification(reloadMsg, false);
         }
+        String reloadFailed = dataService.consumeReloadFailedNotification();
+        if (reloadFailed != null) {
+            setNotification(reloadFailed, true);
+        }
 
         Rect contentArea;
         if (canvasOverlay.isVisible()) {
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DataRefreshService.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DataRefreshService.java
index 6a8f90242af2..67e75eaca3dd 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DataRefreshService.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DataRefreshService.java
@@ -437,6 +437,10 @@ class DataRefreshService {
                     ctx.selectedPid = phantom.pid;
                 }
                 phantom.linkedPid = null;
+                if (phantom.startingSince > 0 && phantom.launchedProcess != 
null && !phantom.launchedProcess.isAlive()) {
+                    // its run ended before its app showed up: the project is 
Stopped, not Starting for minutes
+                    phantom.startingSince = 0;
+                }
                 infos.add(phantom);
             }
         }
@@ -819,7 +823,16 @@ class DataRefreshService {
     // ---- Helpers ----
 
     private void detectReload(IntegrationInfo info) {
-        if (info.pid == null || info.reloaded <= 0) {
+        if (info.pid == null) {
+            return;
+        }
+        // a reload that fails (a file with an error, a file that is gone) 
leaves the old routes running: say so
+        String prevError = lastReloadError.put(info.pid, info.reloadError != 
null ? info.reloadError : "");
+        if (info.reloadError != null && !info.reloadError.equals(prevError)) {
+            String label = info.name != null ? info.name : info.pid;
+            reloadFailedNotification = label + " reload failed: " + 
info.reloadError;
+        }
+        if (info.reloaded <= 0) {
             return;
         }
         Integer prev = lastReloadCount.put(info.pid, info.reloaded);
@@ -829,6 +842,16 @@ class DataRefreshService {
         }
     }
 
+    private final Map<String, String> lastReloadError = new 
ConcurrentHashMap<>();
+    private volatile String reloadFailedNotification;
+
+    /** Why the latest reload failed, once: the old routes keep running, and 
nothing else says so. */
+    String consumeReloadFailedNotification() {
+        String msg = reloadFailedNotification;
+        reloadFailedNotification = null;
+        return msg;
+    }
+
     String consumeReloadNotification() {
         String msg = reloadNotification;
         reloadNotification = null;
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/InfraBrowserPopup.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/InfraBrowserPopup.java
index 8d5d7b7b2355..667e5f03bbdf 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/InfraBrowserPopup.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/InfraBrowserPopup.java
@@ -18,7 +18,6 @@ package org.apache.camel.dsl.jbang.core.commands.tui;
 
 import java.io.InputStream;
 import java.nio.charset.StandardCharsets;
-import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.LinkedHashMap;
 import java.util.List;
@@ -50,7 +49,6 @@ import dev.tamboui.widgets.list.ScrollMode;
 import dev.tamboui.widgets.paragraph.Paragraph;
 import org.apache.camel.catalog.CamelCatalog;
 import org.apache.camel.catalog.DefaultCamelCatalog;
-import org.apache.camel.dsl.jbang.core.common.LauncherHelper;
 import org.apache.camel.util.json.JsonArray;
 import org.apache.camel.util.json.JsonObject;
 import org.apache.camel.util.json.Jsoner;
@@ -493,24 +491,8 @@ class InfraBrowserPopup {
         String portStr = portState != null ? portState.text().trim() : "";
         showPortDialog = false;
         try {
-            List<String> cmd = new 
ArrayList<>(LauncherHelper.getCamelCommand());
-            cmd.add("infra");
-            cmd.add("run");
-            cmd.add(alias);
-            if (impl != null) {
-                cmd.add(impl);
-            }
-            cmd.add("--background");
-            if (!portStr.isEmpty()) {
-                cmd.add("--port=" + portStr);
-            }
-            Path outputFile = 
LaunchManager.createSecureTempFile("camel-infra-", ".log");
-            outputFile.toFile().deleteOnExit();
-            ProcessBuilder pb = new ProcessBuilder(cmd);
-            pb.redirectErrorStream(true);
-            pb.redirectOutput(outputFile.toFile());
-            Process process = pb.start();
-            launchManager.addPendingLaunchNoAutoSelect(alias, process, 
outputFile);
+            // a start that fails (its port taken) shows why, as the launcher 
keeps the output
+            launchManager.startInfra(impl != null ? alias + " " + impl : 
alias, portStr);
             if (burstCallback != null) {
                 burstCallback.run();
             }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationInfo.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationInfo.java
index 6d8b42ff28ee..899da1619885 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationInfo.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationInfo.java
@@ -68,6 +68,8 @@ class IntegrationInfo {
     String sinceLastCompleted;
     String sinceLastFailed;
     int reloaded;
+    // why the last reload failed (dev mode), until a reload succeeds again; 
camel ps shows it as Error
+    String reloadError;
     String rootLogLevel;
     int routeStarted;
     int routeTotal;
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManager.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManager.java
index 7a13f17aba5f..f5e1bea720dd 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManager.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManager.java
@@ -23,12 +23,17 @@ import java.nio.ByteBuffer;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.time.Duration;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashSet;
 import java.util.Iterator;
 import java.util.List;
+import java.util.Map;
 import java.util.Queue;
 import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ConcurrentLinkedQueue;
 import java.util.concurrent.TimeUnit;
 import java.util.function.BiConsumer;
@@ -38,6 +43,7 @@ import java.util.stream.Collectors;
 
 import org.apache.camel.dsl.jbang.core.common.ExampleHelper;
 import org.apache.camel.dsl.jbang.core.common.LauncherHelper;
+import org.apache.camel.util.FileUtil;
 import org.apache.camel.util.json.JsonObject;
 
 class LaunchManager {
@@ -84,6 +90,29 @@ class LaunchManager {
      * tracked and monitored like an example started from the F2 Actions menu. 
Output is redirected to a temporary log
      * file. Used by the AI panel's {@code /run} and {@code /infra run} slash 
commands.
      */
+    // the folders of the examples started here, with the process that runs 
each (camel run, which waits for the app)
+    private final Map<Path, Process> exampleDirs = new ConcurrentHashMap<>();
+
+    /**
+     * Deletes the folders of the examples that are no longer running: those 
started here, and those left behind in the
+     * temporary directory for an hour or more (by an earlier session, or a 
run that was killed). The folder of an
+     * example that still runs is kept, with the files it reads, as the 
example keeps running when the TUI quits.
+     *
+     * @param runningDirs the directories of the running integrations
+     */
+    void deleteExampleDirs(Collection<Path> runningDirs) {
+        Set<Path> unused = new 
HashSet<>(ExampleHelper.staleExampleDirs(runningDirs, Duration.ZERO));
+        exampleDirs.forEach((dir, process) -> {
+            // an example that is still being built (exported and packaged by 
Maven) is not running yet
+            if (!process.isAlive() && unused.contains(dir)) {
+                FileUtil.removeDir(dir.toFile());
+            }
+        });
+        for (Path dir : ExampleHelper.staleExampleDirs(runningDirs, 
Duration.ofHours(1))) {
+            FileUtil.removeDir(dir.toFile());
+        }
+    }
+
     void launchDetached(String displayName, List<String> extraArgs) throws 
IOException {
         JsonObject example = exampleOf(extraArgs);
         if (example == null) {
@@ -94,8 +123,11 @@ class LaunchManager {
         // where camel run --example would run in the directory of the TUI; a 
GitHub example is downloaded first
         Thread t = new Thread(() -> {
             try {
+                // the example keeps running when the TUI quits, so its folder 
is not deleted on exit (CAMEL-25425):
+                // deleteExampleDirs removes it once the example has stopped
                 Path dir = ExampleHelper.isBundled(example)
-                        ? ExampleHelper.extractBundledExample(example) : 
ExampleHelper.downloadGithubExample(example);
+                        ? ExampleHelper.extractBundledExample(example, false)
+                        : ExampleHelper.downloadGithubExample(example, false);
                 start(displayName, exampleArgs(extraArgs, example), dir);
             } catch (Exception e) {
                 notify("Failed to start: " + displayName + " - " + 
e.getMessage(), true);
@@ -117,6 +149,9 @@ class LaunchManager {
         pb.redirectErrorStream(true);
         pb.redirectOutput(outputFile.toFile());
         Process process = pb.start();
+        if (dir != null) {
+            exampleDirs.put(dir, process);
+        }
         addPendingLaunch(displayName, process, outputFile);
     }
 
@@ -173,10 +208,6 @@ class LaunchManager {
         launched.clear();
     }
 
-    void addPendingLaunchNoAutoSelect(String name, Process process, Path 
outputFile) {
-        pendingLaunches.add(new PendingLaunch(name, process, outputFile, 
System.currentTimeMillis()));
-    }
-
     void tick(long now) {
         monitorPendingLaunches(now);
         checkDeferredLaunch(now);
@@ -216,23 +247,52 @@ class LaunchManager {
     }
 
     /**
-     * Starts an infra service in the background via {@code camel infra run 
<alias> --background}, where the alias may
-     * name the implementation too ({@code aws sqs}). The launch is monitored 
like any other, so a failure surfaces
-     * through the failure log callback.
+     * Starts an infra service via {@code camel infra run <alias>}, where the 
alias may name the implementation too
+     * ({@code aws sqs}). It runs in a process of its own, which outlives the 
TUI, as {@code --background} would start
+     * it; but the TUI keeps its output, so a service that fails to start (its 
port taken, its image not pulled) shows
+     * why through the failure log callback, where {@code --background} would 
lose it.
      */
     void startInfra(String alias) throws IOException {
+        startInfra(alias, null);
+    }
+
+    /** Starts an infra service as {@link #startInfra(String)} does, on the 
given port (none: its default). */
+    void startInfra(String alias, String port) throws IOException {
         List<String> cmd = new ArrayList<>(LauncherHelper.getCamelCommand());
         cmd.add("infra");
         cmd.add("run");
         cmd.addAll(Arrays.asList(alias.trim().split("\\s+")));
-        cmd.add("--background");
+        if (port != null && !port.isBlank()) {
+            cmd.add("--port=" + port.trim());
+        }
+        launchInfra(alias, cmd);
+    }
+
+    /** Runs the command that starts the given infra service, and watches it 
until the service is up. */
+    void launchInfra(String alias, List<String> cmd) throws IOException {
         Path outputFile = createSecureTempFile("camel-infra-", ".log");
         outputFile.toFile().deleteOnExit();
         ProcessBuilder pb = new ProcessBuilder(cmd);
         pb.redirectErrorStream(true);
         pb.redirectOutput(outputFile.toFile());
         Process process = pb.start();
-        pendingLaunches.add(new PendingLaunch(alias, process, outputFile, 
System.currentTimeMillis()));
+        PendingLaunch pl = new PendingLaunch(alias, process, outputFile, 
System.currentTimeMillis());
+        pl.infraAlias = serviceOf(alias);
+        pendingLaunches.add(pl);
+    }
+
+    /**
+     * Whether an infra service of the given ones is still starting: its camel 
infra run lives, and it is not up yet.
+     */
+    private boolean infraStarting(List<String> aliases) {
+        Set<String> services = 
aliases.stream().map(LaunchManager::serviceOf).collect(Collectors.toSet());
+        return pendingLaunches.stream()
+                .anyMatch(pl -> pl.infraAlias != null && 
services.contains(pl.infraAlias) && pl.process.isAlive());
+    }
+
+    /** Runs the launch once the given infra services are up, or drops it when 
they fail to start. */
+    void deferUntilInfra(List<String> infra, String displayName, Runnable 
launchAction) {
+        deferredLaunch = new DeferredLaunch(displayName, infra, 
System.currentTimeMillis(), launchAction);
     }
 
     void startMissingInfraAndDefer(List<String> missingInfra, String 
displayName, Runnable launchAction) {
@@ -244,7 +304,7 @@ class LaunchManager {
                 return;
             }
         }
-        deferredLaunch = new DeferredLaunch(displayName, missingInfra, 
System.currentTimeMillis(), launchAction);
+        deferUntilInfra(missingInfra, displayName, launchAction);
         if (infraCatalogClearer != null) {
             infraCatalogClearer.run();
         }
@@ -358,7 +418,13 @@ class LaunchManager {
                 DeferredLaunch dl = deferredLaunch;
                 deferredLaunch = null;
                 dl.launchAction.run();
-            } else if (now - deferredLaunch.startTime > 120_000) {
+            } else if (!infraStarting(deferredLaunch.requiredInfra)) {
+                // the infra failed to start: the failure log says why
+                DeferredLaunch dl = deferredLaunch;
+                deferredLaunch = null;
+                notify("Not started: " + dl.displayName() + " (its infra 
services failed to start)", true);
+            } else if (now - deferredLaunch.startTime > INFRA_WATCH_MS) {
+                // a first start pulls the container image, which can take 
minutes: wait while the infra is starting
                 deferredLaunch = null;
                 notify("Timeout waiting for infra services to start", true);
             }
@@ -366,30 +432,59 @@ class LaunchManager {
     }
 
     private void monitorPendingLaunches(long now) {
+        Set<String> runningAliases = null;
         Iterator<PendingLaunch> it = pendingLaunches.iterator();
         while (it.hasNext()) {
             PendingLaunch pl = it.next();
+            if (pl.infraAlias != null && !pl.started) {
+                if (runningAliases == null) {
+                    runningAliases = infraServices.get().stream()
+                            .filter(i -> i.alive).map(i -> 
i.alias).collect(Collectors.toSet());
+                }
+                // an infra service has started when it is up
+                if (runningAliases.contains(pl.infraAlias)) {
+                    pl.started = true;
+                    notify("Started: " + pl.name, false);
+                    pl.announced = true;
+                }
+            }
             if (!pl.process.isAlive()) {
                 int exitCode = pl.process.exitValue();
-                if (exitCode == 0 || pl.started) {
+                boolean ok = pl.infraAlias != null ? pl.started : exitCode == 
0 || pl.started;
+                if (ok) {
                     if (!pl.announced) {
                         notify("Started: " + pl.name, false);
                     }
-                } else if (failureLogCallback != null) {
-                    failureLogCallback.accept(pl.name, pl.outputFile);
+                    outcomes.put(pl.name, LaunchOutcome.started());
+                } else {
+                    outcomes.put(pl.name, LaunchOutcome.failed(pl.outputFile));
+                    if (failureLogCallback != null) {
+                        failureLogCallback.accept(pl.name, pl.outputFile);
+                    }
                 }
                 it.remove();
             } else if (pl.started) {
                 // up and running: a stop or a failure from now on is not a 
failed start
+                outcomes.put(pl.name, LaunchOutcome.started());
                 it.remove();
             } else if (pl.startFailed()) {
                 // the app gave up starting (port in use, build failure) but 
its JVM lives on: stop it, and show why
                 pl.process.descendants().forEach(ProcessHandle::destroy);
                 pl.process.destroy();
+                outcomes.put(pl.name, LaunchOutcome.failed(pl.outputFile));
                 if (failureLogCallback != null) {
                     failureLogCallback.accept(pl.name, pl.outputFile);
                 }
                 it.remove();
+            } else if (pl.infraAlias != null) {
+                if (now - pl.startTime > INFRA_WATCH_MS) {
+                    // not up after the longest an image pull should take: a 
failed start, as the other ends record
+                    outcomes.put(pl.name, LaunchOutcome.failed(pl.outputFile));
+                    if (failureLogCallback != null) {
+                        failureLogCallback.accept(pl.name, pl.outputFile);
+                    }
+                    it.remove();
+                }
             } else {
                 if (!pl.announced && now - pl.startTime > 8000) {
                     notify("Started: " + pl.name, false);
@@ -414,6 +509,49 @@ class LaunchManager {
      */
     static final long WATCH_MS = 5 * 60_000;
 
+    // how the latest launch of each name went, for an agent that asks 
(tui_run_example waits for it)
+    private final Map<String, LaunchOutcome> outcomes = new 
ConcurrentHashMap<>();
+
+    /**
+     * How a launch went: started (Camel said so), or failed with the end of 
its output; null while it is starting.
+     */
+    record LaunchOutcome(boolean ok, String log) {
+
+        private static final Pattern ANSI = 
Pattern.compile("\u001B\\[[0-9;]*m");
+
+        static LaunchOutcome started() {
+            return new LaunchOutcome(true, null);
+        }
+
+        static LaunchOutcome failed(Path outputFile) {
+            String tail = null;
+            try {
+                // without colors and stack frames: the messages and their 
causes are what tell why
+                List<String> lines = Files.readAllLines(outputFile, 
StandardCharsets.UTF_8).stream()
+                        .map(l -> ANSI.matcher(l).replaceAll(""))
+                        .filter(l -> !l.stripLeading().startsWith("at ") && 
!l.stripLeading().startsWith("... "))
+                        .toList();
+                tail = String.join("\n", lines.subList(Math.max(0, 
lines.size() - 30), lines.size()));
+            } catch (Exception e) {
+                // no output to show
+            }
+            return new LaunchOutcome(false, tail);
+        }
+    }
+
+    /** Forgets how the last launch of the given name went, before it is 
launched again. */
+    void clearOutcome(String name) {
+        outcomes.remove(name);
+    }
+
+    /** How the latest launch of the given name went, or null while it is 
still starting. */
+    LaunchOutcome outcome(String name) {
+        return outcomes.get(name);
+    }
+
+    /** How long an infra service may take to start: the first start pulls its 
container image. */
+    static final long INFRA_WATCH_MS = 15 * 60_000;
+
     /** What a runtime prints when it gives up starting, while its JVM may 
stay up. */
     static final List<String> START_FAILURES = List.of(
             "APPLICATION FAILED TO START", "[ERROR] BUILD FAILURE", "Failed to 
start application");
@@ -427,6 +565,8 @@ class LaunchManager {
         final Process process;
         final Path outputFile;
         final long startTime;
+        // the service of an infra launch: it has started once the service is 
up; null for an integration
+        String infraAlias;
         boolean announced;
         // Camel said it started: the launch is no longer watched for a failed 
start
         boolean started;
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 7bec98b44e7c..c2859f6e976c 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
@@ -29,7 +29,6 @@ import java.util.function.BiFunction;
 import java.util.function.Function;
 import java.util.function.LongSupplier;
 import java.util.function.Supplier;
-import java.util.stream.Collectors;
 
 import dev.tamboui.buffer.Buffer;
 import dev.tamboui.export.ExportRequest;
@@ -1549,8 +1548,15 @@ class McpFacade {
         if (nameOrPid != null && !nameOrPid.isBlank()) {
             String pid = findPidByNameOrPid(nameOrPid.trim());
             if (pid == null) {
-                String names = data.get().stream().filter(i -> !i.vanishing)
-                        .map(i -> i.name + " (pid " + i.pid + 
")").collect(Collectors.joining(", "));
+                List<String> known = new ArrayList<>();
+                data.get().stream().filter(i -> !i.vanishing).forEach(i -> 
known.add(i.name + " (pid " + i.pid + ")"));
+                List<InfraInfo> infras = ctx.infraData != null ? 
ctx.infraData.get() : null;
+                if (infras != null) {
+                    // infra services can be named too
+                    infras.stream().filter(i -> !i.vanishing)
+                            .forEach(i -> known.add(i.alias + " (infra, pid " 
+ i.pid + ")"));
+                }
+                String names = String.join(", ", known);
                 return "Error: no integration with name or pid " + nameOrPid
                        + (names.isEmpty() ? "; none is running" : ". Known: " 
+ names);
             }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java
index 5b9f4b10696b..abd201df7753 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java
@@ -346,6 +346,7 @@ class OverviewTab extends AbstractTab {
 
         lastTableArea = chunks.get(0);
         TableState renderState = infraFocused ? new TableState() : tableState;
+        clampTableOffset(table, chunks.get(0), renderState, integrationCount);
         frame.renderStatefulWidget(table, chunks.get(0), renderState);
         renderTableScrollbar(frame, lastTableArea, table, tableState, 
tableScrollState, integrationCount);
 
@@ -569,9 +570,13 @@ class OverviewTab extends AbstractTab {
                 } else if ("Running".equals(stateText) && info.routeStarted == 
0 && info.routeTotal > 0) {
                     stateText = "Stopped";
                 }
+                if (info.reloadError != null && !"Stopping".equals(stateText)) 
{
+                    // its last reload failed, as camel ps shows it; the info 
panel says why
+                    stateText = "Error";
+                }
                 Style statusStyle = switch (stateText) {
                     case "Started", "Running" -> Theme.success();
-                    case "Stopped" -> Theme.error();
+                    case "Stopped", "Error" -> Theme.error();
                     default -> Theme.warning();
                 };
 
@@ -986,6 +991,11 @@ class OverviewTab extends AbstractTab {
                 }
                 lines.add(Line.from(profileSpans));
             }
+            if (sel.reloadError != null) {
+                // the old routes keep running; the next save that loads 
reloads them
+                lines.add(Line.from(Span.styled(
+                        TuiHelper.truncate("Reload failed: " + 
sel.reloadError, inner.width()), Theme.error())));
+            }
             lines.add(Line.from(Span.raw("")));
             if (sel.javaVersion != null) {
                 lines.add(Line.from(
@@ -1446,6 +1456,9 @@ class OverviewTab extends AbstractTab {
             row.put("camelVersion", info.camelVersion);
             row.put("platform", info.platform);
             row.put("state", info.state);
+            if (info.reloadError != null) {
+                row.put("reloadError", info.reloadError);
+            }
             row.put("ready", info.ready);
             // info.uptime holds the process start time (epoch millis), which 
the screen shows as an elapsed
             // duration; export the same duration plus the raw values with 
unambiguous names so an AI reading
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 9df917f0bdc1..d9db4a4cc70e 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
@@ -201,6 +201,8 @@ final class StatusParser {
             Map<String, ?> reloadStats = (Map<String, ?>) stats.get("reload");
             if (reloadStats != null) {
                 info.reloaded = (int) objToLong(reloadStats.get("reloaded"));
+                Map<String, ?> lastError = (Map<String, ?>) 
reloadStats.get("lastError");
+                info.reloadError = lastError != null ? (String) 
lastError.get("message") : null;
             }
         }
     }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolDefinitions.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolDefinitions.java
index 5a80bfdb1577..10b3675274d5 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolDefinitions.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolDefinitions.java
@@ -529,6 +529,8 @@ final class TuiToolDefinitions {
                 "Launches a named bundled example as a background process. "
                                    + "Bypasses the F2 menu entirely — no UI 
navigation needed. "
                                    + "Automatically starts required infra 
services (Docker containers) if needed. "
+                                   + "Waits up to 30 seconds for the example 
to start: status started, failed (with "
+                                   + "the end of its log), starting, or 
starting_infra. "
                                    + "Use tui_list_examples to discover 
available example names.",
                 Map.of("name", propDef("string",
                         "Example name from the catalog (e.g. 
'quick-start/timer-log')"),
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 36dd757d9f8a..0e8d17252930 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
@@ -1910,15 +1910,38 @@ class TuiToolRegistry {
         }
 
         List<String> camelArgs = buildExampleArgs(name, args);
+        lm.clearOutcome(name);
         lm.launchDetached(name, camelArgs);
 
+        // an example can fail a few seconds after it is launched (a missing 
API key, a bad route): wait for Camel to
+        // say it started, or for the failure, so the agent learns how it went
+        LaunchManager.LaunchOutcome outcome = null;
+        long deadline = System.currentTimeMillis() + RUN_EXAMPLE_WAIT_MS;
+        while ((outcome = lm.outcome(name)) == null && 
System.currentTimeMillis() < deadline) {
+            Thread.sleep(250);
+        }
         JsonObject result = new JsonObject();
-        result.put("status", "started");
-        result.put("message", "Started: " + name);
         result.put("name", name);
+        if (outcome == null) {
+            result.put("status", "starting");
+            result.put("message", "Still starting: " + name + " (a first run 
downloads its dependencies); "
+                                  + "tui_get_options lists it once it runs");
+        } else if (outcome.ok()) {
+            result.put("status", "started");
+            result.put("message", "Started: " + name);
+        } else {
+            result.put("status", "failed");
+            result.put("message", "Failed to start: " + name);
+            if (outcome.log() != null) {
+                result.put("log", outcome.log());
+            }
+        }
         return Jsoner.serialize(result);
     }
 
+    // how long tui_run_example waits for an example to start or fail
+    static final long RUN_EXAMPLE_WAIT_MS = 30_000;
+
     private static List<String> buildExampleArgs(String name, Map<String, 
Object> args) {
         List<String> camelArgs = new ArrayList<>();
         camelArgs.add("run");
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManagerInfraTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManagerInfraTest.java
index 88949e47f7db..e3befe7d9ff0 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManagerInfraTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManagerInfraTest.java
@@ -18,6 +18,7 @@ package org.apache.camel.dsl.jbang.core.commands.tui;
 
 import java.util.ArrayList;
 import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
 
 import org.apache.camel.util.json.JsonObject;
 import org.junit.jupiter.api.Test;
@@ -50,6 +51,67 @@ class LaunchManagerInfraTest {
         
assertThat(lm.findMissingInfraServices(example)).containsExactly("kafka");
     }
 
+    @Test
+    void aLaunchWaitsWhileItsInfraIsStartingAndRunsOnceItIsUp() throws 
Exception {
+        List<String> notices = new ArrayList<>();
+        lm.setNotificationCallback((msg, error) -> notices.add(msg));
+        AtomicBoolean launched = new AtomicBoolean();
+        // a first start pulls the image: the infra takes a while
+        lm.launchInfra("kafka", List.of("sleep", "30"));
+        lm.deferUntilInfra(List.of("kafka"), "kafka-orders", () -> 
launched.set(true));
+
+        lm.tick(System.currentTimeMillis());
+        assertThat(launched).isFalse();
+        assertThat(notices).isEmpty();
+
+        running.add(infra("kafka"));
+        lm.tick(System.currentTimeMillis());
+        assertThat(launched).isTrue();
+        assertThat(notices).contains("Started: kafka");
+        ProcessHandle.current().children()
+                .filter(p -> p.info().command().map(c -> 
c.endsWith("sleep")).orElse(false))
+                .forEach(ProcessHandle::destroy);
+    }
+
+    @Test
+    void aLaunchThatNeedsAServiceWithItsImplementationRunsOnceTheServiceIsUp() 
throws Exception {
+        AtomicBoolean launched = new AtomicBoolean();
+        // camel infra run aws sqs runs as the aws service
+        lm.launchInfra("aws sqs", List.of("sleep", "30"));
+        lm.deferUntilInfra(List.of("aws sqs"), "aws-sqs", () -> 
launched.set(true));
+
+        lm.tick(System.currentTimeMillis());
+        assertThat(launched).isFalse();
+
+        running.add(infra("aws"));
+        lm.tick(System.currentTimeMillis());
+        assertThat(launched).isTrue();
+        ProcessHandle.current().children()
+                .filter(p -> p.info().command().map(c -> 
c.endsWith("sleep")).orElse(false))
+                .forEach(ProcessHandle::destroy);
+    }
+
+    @Test
+    void aLaunchIsDroppedWhenItsInfraFailsToStart() throws Exception {
+        List<String> notices = new ArrayList<>();
+        List<String> failures = new ArrayList<>();
+        lm.setNotificationCallback((msg, error) -> notices.add(msg));
+        lm.setFailureLogCallback((name, log) -> failures.add(name));
+        AtomicBoolean launched = new AtomicBoolean();
+        // the container cannot bind its port: camel infra run ends with an 
error
+        lm.launchInfra("mosquitto", List.of("sh", "-c", "echo port is already 
allocated; exit 1"));
+        lm.deferUntilInfra(List.of("mosquitto"), "mqtt", () -> 
launched.set(true));
+        long deadline = System.currentTimeMillis() + 10_000;
+        while (failures.isEmpty() && System.currentTimeMillis() < deadline) {
+            lm.tick(System.currentTimeMillis());
+            Thread.sleep(50);
+        }
+
+        assertThat(launched).isFalse();
+        assertThat(failures).containsExactly("mosquitto");
+        assertThat(notices).contains("Not started: mqtt (its infra services 
failed to start)");
+    }
+
     private static InfraInfo infra(String alias) {
         InfraInfo info = new InfraInfo();
         info.alias = alias;
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManagerStartFailureTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManagerStartFailureTest.java
index c659b6448f3f..150fd59b360e 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManagerStartFailureTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManagerStartFailureTest.java
@@ -73,6 +73,23 @@ class LaunchManagerStartFailureTest {
         assertThat(new LaunchManager.PendingLaunch("metrics", null, out, 
0).startFailed()).isTrue();
     }
 
+    @Test
+    void aFailedLaunchTellsWhyWithoutColorsAndStackFrames() throws Exception {
+        Path out = Files.writeString(dir.resolve("launch.log"),
+                "\u001B[32m INFO\u001B[m Starting\n"
+                                                                + "ERROR Error 
starting Camel: Property with key [env:OPENAI_API_KEY] returned null\n"
+                                                                + "\tat 
org.apache.camel.Foo.bar(Foo.java:1)\n"
+                                                                + "Caused by: 
java.lang.IllegalArgumentException: no key\n"
+                                                                + "\t... 12 
more\n");
+
+        LaunchManager.LaunchOutcome outcome = 
LaunchManager.LaunchOutcome.failed(out);
+
+        assertThat(outcome.ok()).isFalse();
+        assertThat(outcome.log()).isEqualTo(" INFO Starting\n"
+                                            + "ERROR Error starting Camel: 
Property with key [env:OPENAI_API_KEY] returned null\n"
+                                            + "Caused by: 
java.lang.IllegalArgumentException: no key");
+    }
+
     private static void append(Path file, String text) throws Exception {
         Files.writeString(file, text, StandardOpenOption.APPEND);
     }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeControlTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeControlTest.java
index bb24bc6f384d..dbd892585af9 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeControlTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeControlTest.java
@@ -82,6 +82,18 @@ class McpFacadeControlTest {
         assertThat(calls).isEmpty();
     }
 
+    @Test
+    void anUnknownNameAlsoListsTheInfraServices() {
+        integration("orders", "100");
+        InfraInfo kafka = new InfraInfo();
+        kafka.alias = "kafka";
+        kafka.pid = "300";
+        infra.get().add(kafka);
+
+        assertThat(facade.controlIntegration("stop", "kafak"))
+                .contains("orders (pid 100)").contains("kafka (infra, pid 
300)");
+    }
+
     @Test
     void withoutANameTheOnlyOneRunningIsUsed() {
         integration("orders", "100");
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OpenedProjectLifecycleTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OpenedProjectLifecycleTest.java
index 90c51c49ff98..804241b47acf 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OpenedProjectLifecycleTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OpenedProjectLifecycleTest.java
@@ -92,6 +92,23 @@ class OpenedProjectLifecycleTest {
         assertTrue(merge(other).contains(project));
     }
 
+    @Test
+    void aProjectWhoseRunEndedWithoutAnAppIsStoppedAtOnce() throws Exception {
+        IntegrationInfo project = new IntegrationInfo();
+        project.name = "orders";
+        project.sourceDir = "/work/orders";
+        project.startingSince = System.currentTimeMillis();
+        // camel run pom.xml ended (its Maven build failed) before the app 
showed up
+        Process run = new ProcessBuilder("true").start();
+        run.waitFor();
+        project.launchedProcess = run.toHandle();
+        ctx.addPhantom(project);
+
+        merge();
+
+        assertEquals(0, project.startingSince);
+    }
+
     @Test
     void aProjectIsStartingUntilItsAppShowsUp() {
         IntegrationInfo project = new IntegrationInfo();
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParserTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParserTest.java
index 96fa88b2ae72..4ccc52eff20a 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParserTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParserTest.java
@@ -52,6 +52,32 @@ class StatusParserTest {
         assertEquals(5, info.state);
     }
 
+    @Test
+    void aFailedReloadIsReadAsCamelPsReadsIt() {
+        JsonObject root = new JsonObject();
+        JsonObject context = new JsonObject();
+        context.put("name", "routes");
+        JsonObject stats = new JsonObject();
+        JsonObject reload = new JsonObject();
+        reload.put("reloaded", 2);
+        reload.put("failed", 1);
+        JsonObject lastError = new JsonObject();
+        lastError.put("message", "Greeter.java does not exist");
+        reload.put("lastError", lastError);
+        stats.put("reload", reload);
+        context.put("statistics", stats);
+        root.put("context", context);
+
+        IntegrationInfo info = 
StatusParser.parseIntegration(ProcessHandle.current(), root);
+
+        assertEquals(2, info.reloaded);
+        assertEquals("Greeter.java does not exist", info.reloadError);
+
+        // a reload that succeeds again clears it
+        reload.remove("lastError");
+        assertNull(StatusParser.parseIntegration(ProcessHandle.current(), 
root).reloadError);
+    }
+
     @Test
     void parseIntegrationReturnsNullWhenNoContext() {
         JsonObject root = new JsonObject();
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TableOffsetClampTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TableOffsetClampTest.java
new file mode 100644
index 000000000000..1b96a5320ac6
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TableOffsetClampTest.java
@@ -0,0 +1,73 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import dev.tamboui.layout.Constraint;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.widgets.block.Block;
+import dev.tamboui.widgets.table.Row;
+import dev.tamboui.widgets.table.Table;
+import dev.tamboui.widgets.table.TableState;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * A table whose list shrinks shows its rows from the top again: after 8 apps 
became 2, the first stayed above the top
+ * of the Overview, and looked stopped.
+ */
+class TableOffsetClampTest {
+
+    @Test
+    void theOffsetIsPulledBackWhenRowsGoAway() {
+        Rect area = new Rect(0, 0, 40, 6);
+        TableState state = new TableState();
+        state.setOffset(6);
+
+        AbstractTab.clampTableOffset(table(2), area, state, 2);
+
+        assertThat(state.offset()).isZero();
+    }
+
+    @Test
+    void anOffsetThatStillFitsIsKept() {
+        Rect area = new Rect(0, 0, 40, 6);
+        Table table = table(20);
+        int visible = table.viewportHeight(area);
+        TableState state = new TableState();
+        state.setOffset(5);
+
+        AbstractTab.clampTableOffset(table, area, state, 20);
+        assertThat(state.offset()).isEqualTo(5);
+
+        state.setOffset(19);
+        AbstractTab.clampTableOffset(table, area, state, 20);
+        assertThat(state.offset()).isEqualTo(20 - visible);
+    }
+
+    private static Table table(int rows) {
+        List<Row> list = new ArrayList<>();
+        for (int i = 0; i < rows; i++) {
+            list.add(Row.from("app-" + i));
+        }
+        return 
Table.builder().rows(list).header(Row.from("NAME")).widths(Constraint.fill())
+                .block(Block.bordered()).build();
+    }
+}

Reply via email to