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 728b01afd747 chore: camel-jbang - the file tools take a directory as 
the integration whose sources it holds
728b01afd747 is described below

commit 728b01afd74762e70a6ca0af1ccc8537e743890a
Author: Claus Ibsen <[email protected]>
AuthorDate: Tue Oct 13 21:36:27 2026 +0200

    chore: camel-jbang - the file tools take a directory as the integration 
whose sources it holds
    
    The shared file tools of the TUI move into TuiFileTools. A directory passed 
to
    camel_write_file, camel_edit_file or camel_save_project_summary is taken as 
the
    integration whose sources it holds, so the write goes through the AI panel's
    write mode like any other; other directories are not written. An edit the 
user
    rejected is no longer reported as edited.
    
    CAMEL-25448: camel-jbang - the monitor uses per-request action files
    
    The monitor sent its requests through the shared <pid>-action.json, which 
the
    connector reads about once a second, so the next request of a tab could
    overwrite a route command before the connector read it: F10 Stop Routes did
    nothing while the Diagram tab refreshed its topology. For an integration of
    Camel 4.21 or later (which takes <pid>-action-<id>.json) executeAction and
    fireAction now use a file per request, without the lock per integration; an
    older integration keeps the shared file.
    
    chore: camel-jbang - the timer check knows that its exchange properties 
need includeMetadata
    
    The timer sets CamelTimerCounter, CamelTimerName, CamelTimerPeriod and
    CamelTimerTime only with includeMetadata=true, so a route without it reads 
null
    in either form, which the check now says. With it, 
${header.CamelTimerCounter}
    is no longer reported as null: Simple's header function falls back to the
    exchange properties (${headers.x} does not, and is still reported).
    
    chore: camel-jbang - the go to popups select the entry that contains what 
was typed
    
    A popup that narrows a list by typing selected the first row shown, which in
    the Ctrl+G popup of the Source tab is the route of a matching processor, 
shown
    as context: typing log and Enter went to the route. The popups now select 
the
    first entry whose text contains what was typed, else the first that matches
    itself.
    
    chore: camel-jbang - a dialog over the editor shows its keys in the footer 
during a live edit
    
    While an AI edit was replayed in the editor, the footer kept showing the 
keys
    of the edit when the editor opened a dialog: the discard question, the 
problems
    of a save, the diff (F7) or the refactorings, whose own keys were then not
    shown. The footer of the dialog now goes first. SourceViewer grows by the 
five
    lines of showsDialog(), which reads its private dialog state (SourceSizeTest
    raised to match).
    
    chore: camel-jbang - a footer too wide drops the common F-keys before the 
screen's own hints
    
    When the footer does not fit, the F-keys every screen has go first (F10, F2,
    then F1), then the screen's own hints from the tail, and its own F-keys 
such as
    Shift+F8 fix with AI last; before, those went before any other hint of the
    screen. A hint of several spans (show BHpv) goes whole. The F1 help hint is
    shown on the Overview only, as it is seldom pressed; F1 works on every tab.
    The fitting moves from CamelMonitor into FooterHints.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../dsl/jbang/core/commands/ai/HeaderChecks.java   |  58 +++++++-
 .../commands/ai/SourceValidatorEndpointTest.java   |  37 ++++-
 dsl/camel-jbang/camel-jbang-plugin-tui/AGENTS.md   |   8 +-
 .../dsl/jbang/core/commands/tui/CamelMonitor.java  |  66 ++-------
 .../jbang/core/commands/tui/FilterListPopup.java   |  37 ++++-
 .../dsl/jbang/core/commands/tui/FooterHints.java   |  85 +++++++++++
 .../dsl/jbang/core/commands/tui/GotoNodePopup.java |   7 +-
 .../jbang/core/commands/tui/GotoRoutePopup.java    |   7 +-
 .../core/commands/tui/GotoSourceNodePopup.java     |  10 +-
 .../dsl/jbang/core/commands/tui/GotoTabPopup.java  |   5 +
 .../dsl/jbang/core/commands/tui/McpFacade.java     |  53 ++++++-
 .../jbang/core/commands/tui/MonitorContext.java    |  91 ++++++++++++
 .../core/commands/tui/SourceRefactorActions.java   |  12 +-
 .../dsl/jbang/core/commands/tui/SourceTab.java     |   5 +
 .../dsl/jbang/core/commands/tui/SourceViewer.java  |   5 +
 .../dsl/jbang/core/commands/tui/TocPopup.java      |   7 +-
 .../dsl/jbang/core/commands/tui/TuiFileTools.java  | 153 +++++++++++++++++++
 .../jbang/core/commands/tui/TuiToolRegistry.java   |  91 ++----------
 .../jbang/core/commands/tui/CamelMonitorTest.java  | 123 ----------------
 .../jbang/core/commands/tui/FooterHintsTest.java   | 146 ++++++++++++++++++
 .../core/commands/tui/GotoSourceNodePopupTest.java |  31 +++-
 .../core/commands/tui/McpFacadeWriteFileTest.java  |  17 ++-
 .../tui/MonitorContextActionFilesTest.java         | 163 +++++++++++++++++++++
 .../jbang/core/commands/tui/SourceSizeTest.java    |   4 +-
 .../core/commands/tui/SourceViewerEditTest.java    |  20 +++
 .../jbang/core/commands/tui/TuiFileToolsTest.java  | 111 ++++++++++++++
 26 files changed, 1063 insertions(+), 289 deletions(-)

diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderChecks.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderChecks.java
index 9a23ad0f6ffc..0960cdb14c73 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderChecks.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderChecks.java
@@ -51,6 +51,34 @@ final class HeaderChecks {
     static final Map<String, List<String>> EXCHANGE_PROPERTIES = Map.of(
             "timer", List.of("CamelTimerCounter", "CamelTimerName", 
"CamelTimerPeriod", "CamelTimerTime"));
 
+    /**
+     * The option without which a component sets none of its {@link 
#EXCHANGE_PROPERTIES}: TimerConsumer sets them only
+     * with includeMetadata=true.
+     */
+    static final Map<String, String> METADATA_OPTION = Map.of("timer", 
"includeMetadata");
+
+    static final Pattern EXCHANGE_PROPERTY_REF_PATTERN = 
Pattern.compile("\\$\\{exchangeProperty\\.(Camel[A-Z][A-Za-z0-9]*)");
+
+    /** Whether the option that makes the component set its exchange 
properties is true somewhere in the content. */
+    static boolean setsExchangeProperties(String scheme, String content) {
+        String option = METADATA_OPTION.get(scheme);
+        return option == null
+                || Pattern.compile(Pattern.quote(option) + 
"\\s*[=:]\\s*[\"']?true").matcher(content).find();
+    }
+
+    /**
+     * Whether a header reference also finds an exchange property of that 
name: Simple's ${header.x} and the header("x")
+     * expression look in the exchange properties when the message has no such 
header; ${headers.x} reads the map.
+     */
+    static boolean fallsBackToProperties(String reference) {
+        return reference.startsWith("${header.") || 
reference.startsWith("header(");
+    }
+
+    private static String onlyWithOption(int line, String scheme, String name) 
{
+        return "Line " + line + ": " + scheme + " sets " + name + " only with 
" + METADATA_OPTION.get(scheme)
+               + "=true on the endpoint, as the exchange property 
${exchangeProperty." + name + "}";
+    }
+
     /**
      * What an invented Camel* header is usually reaching for, keyed on a 
fragment of its name, and the expression that
      * answers it. Tried when no real header is close: in a timer-triggered 
route there is no message source to name, so
@@ -139,8 +167,12 @@ final class HeaderChecks {
                 if (propertyOwner != null) {
                     // before the header metadata: an exchange property is 
never a header, whatever the metadata says
                     reported.add(name);
-                    msgs.add("Line " + (i + 1) + ": " + name + " is an 
exchange property set by " + propertyOwner
-                             + ", not a header (the header would be null): 
write ${exchangeProperty." + name + "}");
+                    if (!setsExchangeProperties(propertyOwner, content)) {
+                        msgs.add(onlyWithOption(i + 1, propertyOwner, name));
+                    } else if (!fallsBackToProperties(m.group())) {
+                        msgs.add("Line " + (i + 1) + ": " + name + " is an 
exchange property set by " + propertyOwner
+                                 + ", not a header (the header would be null): 
write ${exchangeProperty." + name + "}");
+                    }
                     continue;
                 }
                 if (!reported.add(name)) {
@@ -179,8 +211,11 @@ final class HeaderChecks {
                 for (String sc : schemes) {
                     List<String> props = EXCHANGE_PROPERTIES.getOrDefault(sc, 
List.of());
                     if (!props.isEmpty()) {
-                        sb.append(" ").append(sc).append(" sets the exchange 
properties ").append(String.join(", ", props))
-                                .append(" 
(${exchangeProperty.").append(props.get(0)).append("}).");
+                        sb.append(" ").append(sc).append(" sets the exchange 
properties ").append(String.join(", ", props));
+                        if (METADATA_OPTION.containsKey(sc)) {
+                            sb.append(" with 
").append(METADATA_OPTION.get(sc)).append("=true");
+                        }
+                        sb.append(" 
(${exchangeProperty.").append(props.get(0)).append("}).");
                     }
                 }
                 if (m.group(1) == null) {
@@ -189,6 +224,21 @@ final class HeaderChecks {
                 msgs.add(sb.toString());
             }
         }
+        // an exchange property read without the option that sets it stays 
null as well
+        for (String scheme : schemes) {
+            if (setsExchangeProperties(scheme, content)) {
+                continue;
+            }
+            List<String> props = EXCHANGE_PROPERTIES.getOrDefault(scheme, 
List.of());
+            for (int i = 0; i < lines.length; i++) {
+                Matcher pm = EXCHANGE_PROPERTY_REF_PATTERN.matcher(lines[i]);
+                while (pm.find()) {
+                    if (props.contains(pm.group(1)) && 
reported.add(pm.group(1))) {
+                        msgs.add(onlyWithOption(i + 1, scheme, pm.group(1)));
+                    }
+                }
+            }
+        }
         return msgs;
     }
 
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorEndpointTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorEndpointTest.java
index 39382f8f2c9c..afaa47ac342c 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorEndpointTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorEndpointTest.java
@@ -442,16 +442,45 @@ class SourceValidatorEndpointTest {
     }
 
     @Test
-    void aTimerExchangePropertyUsedAsAHeaderIsNamed() {
-        // TimerConsumer sets the counter, name, period and time as exchange 
properties; only the fired time is a header
+    void aTimerExchangePropertyIsSetOnlyWithIncludeMetadata() {
+        // TimerConsumer sets the counter, name, period and time as exchange 
properties, and only with includeMetadata
         List<String> msgs = SourceValidator.validateKnownHeaders("""
                 - from:
                     uri: "timer:tick?period=1000"
                     steps:
                       - log: "${header.CamelTimerCounter} 
${header.CamelTimerFiredTime} ${exchangeProperty.CamelTimerName}"
                 """, catalog);
-        assertThat(msgs).hasSize(1);
-        assertThat(msgs.get(0)).contains("CamelTimerCounter is an exchange 
property set by timer, not a header")
+        assertThat(msgs).hasSize(2);
+        assertThat(msgs.get(0)).startsWith("Line 4:")
+                .contains("timer sets CamelTimerCounter only with 
includeMetadata=true")
+                .contains("${exchangeProperty.CamelTimerCounter}");
+        assertThat(msgs.get(1)).startsWith("Line 4:").contains("timer sets 
CamelTimerName only with includeMetadata=true");
+    }
+
+    @Test
+    void withIncludeMetadataAHeaderLookupFindsTheExchangeProperty() {
+        // Simple's ${header.x} falls back to the exchange properties, so it 
reads the counter
+        List<String> msgs = SourceValidator.validateKnownHeaders("""
+                - from:
+                    uri: "timer:tick?period=1000&includeMetadata=true"
+                    steps:
+                      - log: "${header.CamelTimerCounter} 
${exchangeProperty.CamelTimerName}"
+                """, catalog);
+        assertThat(msgs).isEmpty();
+    }
+
+    @Test
+    void withIncludeMetadataTheHeadersMapStillHasNoSuchHeader() {
+        List<String> msgs = SourceValidator.validateKnownHeaders("""
+                - from:
+                    uri: timer:tick
+                    parameters:
+                      includeMetadata: true
+                    steps:
+                      - log: "${headers.CamelTimerCounter}"
+                """, catalog);
+        assertThat(msgs).singleElement().asString()
+                .contains("CamelTimerCounter is an exchange property set by 
timer, not a header")
                 .contains("${exchangeProperty.CamelTimerCounter}");
     }
 
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/AGENTS.md 
b/dsl/camel-jbang/camel-jbang-plugin-tui/AGENTS.md
index 3e7e804e91ca..15f750ec1c97 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/AGENTS.md
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/AGENTS.md
@@ -28,7 +28,7 @@ and use each other's package-private members, so there are no 
sub-packages. Clas
 | `TabRegistry` | the tabs: the nine digit-key tabs, and the More tabs in 
`moreTabs` (one `MoreTab` entry each) |
 | `MonitorTab`, `AbstractTab`, `AbstractTableTab` | what a tab implements, and 
its base classes |
 | `*Tab` | one tab each |
-| `MonitorContext` (`ctx`) | what the tabs share: the selected integration 
(`selectedPid`), settings, callbacks into CamelMonitor, `backgroundExecutor`, 
`runner`, `dataService`, and `executeAction` to ask an integration |
+| `MonitorContext` (`ctx`) | what the tabs share: the selected integration 
(`selectedPid`), settings, callbacks into CamelMonitor, `backgroundExecutor`, 
`runner`, `dataService`, `executeAction` to ask an integration and `fireAction` 
to tell it (a file per request since Camel 4.21, so requests cannot overwrite 
each other) |
 | `DataRefreshService`, `StatusFileReader`, `StatusParser` | read the status 
files of the integrations (`~/.camel/<pid>-status.json`) into `IntegrationInfo` 
and the `*Info` classes |
 | `*Popup`, `PopupManager` | popups; `PopupManager` has the switch integration 
(F3), More and confirm popups |
 | `FilterListPopup`, `ChoicePopup`, `ActivityLogPopup`, `DialogHelper` | what 
popups are built on: a list narrowed down by typing, a short list of choices, a 
log with a detail panel, and centered confirm and input dialogs |
@@ -37,7 +37,7 @@ and use each other's package-private members, so there are no 
sub-packages. Clas
 | `AbstractDiagramTab` | what the Diagram and Routes tabs share of the route 
diagram: its state, keys, drilling into a route and back |
 | `AiPanel`, `AiSlashCommandRegistry` | the F8 AI panel; its input line is 
`AiInputLine`, its tools and system prompt `AiToolSet`, its usage statistics 
`AiUsageStats`, and an external coding agent (ACP) `AcpSession` over 
`AcpAgentClient` |
 | `LiveEdit`, `EditReplay` | `/write live`: a file an AI tool writes is typed 
into the source editor, to watch and then save or discard |
-| `TuiMcpServer`, `TuiToolDefinitions`, `TuiToolRegistry`, `McpFacade` | the 
MCP server: the tools (`TuiTool`: schema, handler, flags), their 
implementations, the bridge to the screen |
+| `TuiMcpServer`, `TuiToolDefinitions`, `TuiToolRegistry`, `McpFacade` | the 
MCP server: the tools (`TuiTool`: schema, handler, flags), their 
implementations, the bridge to the screen; the shared file tools are 
`TuiFileTools`, which writes only to an integration's source directory, through 
the AI panel's write mode |
 | `Theme`, `TuiIcons` | colours (themes in `src/main/resources/tui/themes`) 
and icons |
 | `TuiSettings`, `SettingsPopup` | persisted settings |
 | `TuiHelper` | shared helpers: text width, wrapping and truncation, ANSI, 
number and duration formats, footer hints |
@@ -66,7 +66,9 @@ popups, the AI panel (F8), the global keys 
(`handleGlobalKeys`), and last to the
 
 Each key a tab takes is written down in three places: the footer hint 
(`renderFooter`, with `TuiHelper.hint`), the
 Keys section of the tab's help page (`src/main/resources/tui/help/<tab>.md`), 
and the user manual. Change all three;
-`FooterKeysInHelpTest` fails when a key of the footer of a tab is missing from 
its help page.
+`FooterKeysInHelpTest` fails when a key of the footer of a tab is missing from 
its help page. A footer too wide for
+the screen drops the F-keys every screen has first, then the tab's hints from 
the tail (`FooterHints`): put the
+important hints of a tab first.
 
 ## Threads and time
 
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 1f3d99a5752a..9628368c2276 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
@@ -2460,7 +2460,7 @@ public class CamelMonitor extends CamelCommand {
         } else if (aiPanel.isOpen()) {
             // asking about a paused AI edit, or the panel came back after a 
replay
             aiPanel.renderFooter(spans);
-        } else if (liveEdit.isShown()) {
+        } else if (liveEdit.isShown() && 
!tabRegistry.sourceTab().showsViewerDialog()) {
             liveEdit.renderFooter(spans);
         } else if (shellPanel.isOpen()) {
             shellPanel.renderFooter(spans);
@@ -2527,14 +2527,10 @@ public class CamelMonitor extends CamelCommand {
             rightSpans.clear();
             rightWidth = 0;
             minGap = 0;
-            // Drop secondary F-key hints (F2/F3/F6) before tab-specific 
action hints.
-            hintsWidth = dropFKeyHints(spans, fKeyTotal, tabFKeySpans, 
hintsWidth, area.width());
-            // Then drop tab-specific hints from the tail, keeping at least 4 
spans
-            while (spans.size() > 4 && hintsWidth > area.width()) {
-                Span labelSpan = spans.remove(spans.size() - 1);
-                Span keySpan = spans.remove(spans.size() - 1);
-                hintsWidth -= keySpan.width() + labelSpan.width();
-            }
+            // the F-keys every screen has go first, what belongs to the 
screen last
+            hintsWidth = fKeyTotal > 0
+                    ? FooterHints.fit(spans, fKeyStart, fKeyTotal - 
tabFKeySpans, tabFKeySpans, hintsWidth, area.width())
+                    : FooterHints.fit(spans, spans.size(), 0, 0, hintsWidth, 
area.width());
         }
 
         if (!rightSpans.isEmpty()) {
@@ -2547,15 +2543,16 @@ public class CamelMonitor extends CamelCommand {
         frame.renderWidget(Paragraph.from(Line.from(spans)), area);
     }
 
-    // how many of the F-key hint spans of the footer are the screen's own 
(after F1/F2/F10)
+    // how many of the F-key hint spans of the footer are the screen's own 
(after F1/F2/F10), and where they start
     private int tabFKeySpans;
+    private int fKeyStart;
 
     private int insertFKeyHints(List<Span> spans) {
         int insertPos = Math.min(2, spans.size());
         List<Span> fKeySpans = new ArrayList<>();
         MonitorTab tab = tabRegistry.activeTab();
-        boolean hasHelp = tab != null && tab.getHelpText() != null;
-        if (hasHelp) {
+        // F1 opens the help of every tab, but is seldom pressed: the 
Overview, the first screen, names it
+        if (tab == tabRegistry.overviewTab() && tab.getHelpText() != null) {
             hint(fKeySpans, "F1", "help");
         }
         hint(fKeySpans, "F2", "actions");
@@ -2565,53 +2562,12 @@ public class CamelMonitor extends CamelCommand {
             tab.renderFKeyHints(fKeySpans);
         }
         tabFKeySpans = fKeySpans.size() - globalFKeys;
+        fKeyStart = insertPos;
         spans.addAll(insertPos, fKeySpans);
-        // Return total F-key span count. The footer drop loop uses this to 
remove pairs from
-        // the tail, stopping before the first pair (F1 help when present).
+        // the number of F-key spans, global and the screen's own: 
FooterHints.fit drops the global ones first
         return fKeySpans.size();
     }
 
-    /**
-     * Drops secondary F-key hint pairs from an overflowing footer. The F-key 
pairs are inserted at position 2 (after
-     * the first tab hint), so the last pair's key span sits at index {@code 
fKeyTotal}. Pairs are removed from the
-     * tail, so F2 goes first, and the loop stops at 2 so the first pair (F1 
help when present) is always preserved.
-     *
-     * @param  spans      the footer spans, mutated in place by removing 
dropped pairs
-     * @param  fKeyTotal  total number of F-key spans that were inserted (e.g. 
4 for F1/F2)
-     * @param  hintsWidth the current rendered width of {@code spans}
-     * @param  available  the available footer width
-     * @return            the rendered width of {@code spans} after dropping
-     */
-    static int dropFKeyHints(List<Span> spans, int fKeyTotal, int hintsWidth, 
int available) {
-        return dropFKeyHints(spans, fKeyTotal, 0, hintsWidth, available);
-    }
-
-    /**
-     * As {@link #dropFKeyHints(List, int, int, int)}, with the screen's own 
F-key hints (such as Shift+F8 fix with AI
-     * or F12 file actions) after the global ones: the global F2/F10 are 
dropped first, as they are the same on every
-     * screen, and then the screen's own from the tail.
-     *
-     * @param tabFKeys how many of the F-key spans are the screen's own, at 
the end of the F-key spans
-     */
-    static int dropFKeyHints(List<Span> spans, int fKeyTotal, int tabFKeys, 
int hintsWidth, int available) {
-        int globalTotal = fKeyTotal - Math.max(0, tabFKeys);
-        while (globalTotal > 2 && hintsWidth > available) {
-            // the last global pair: its key span is at index globalTotal (the 
F-keys start at 2)
-            Span labelSpan = spans.remove(globalTotal + 1);
-            Span keySpan = spans.remove(globalTotal);
-            hintsWidth -= keySpan.width() + labelSpan.width();
-            globalTotal -= 2;
-            fKeyTotal -= 2;
-        }
-        while (fKeyTotal > Math.max(2, globalTotal) && hintsWidth > available) 
{
-            Span labelSpan = spans.remove(fKeyTotal + 1);
-            Span keySpan = spans.remove(fKeyTotal);
-            hintsWidth -= keySpan.width() + labelSpan.width();
-            fKeyTotal -= 2;
-        }
-        return hintsWidth;
-    }
-
     private int renderOverviewFooter(List<Span> spans) {
         tabRegistry.overviewTab().renderFooter(spans);
         int fKeyTotal = insertFKeyHints(spans);
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FilterListPopup.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FilterListPopup.java
index 89f01ab4cec2..ccdf015d0145 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FilterListPopup.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FilterListPopup.java
@@ -177,7 +177,7 @@ abstract class FilterListPopup<T> {
         listState.select(index);
     }
 
-    /** Narrows the entries down again after the typed text, or the entries, 
changed; selects the first. */
+    /** Narrows the entries down again after the typed text, or the entries, 
changed; selects the best match. */
     protected final void refilter() {
         List<T> all = entries();
         if (all == null) {
@@ -185,7 +185,40 @@ abstract class FilterListPopup<T> {
             return;
         }
         shown = filter.hasFilter() ? filter(all, filter.filter()) : new 
ArrayList<>(all);
-        listState.select(shown.isEmpty() ? null : 0);
+        listState.select(shown.isEmpty() ? null : bestMatch());
+    }
+
+    /**
+     * The text an entry is matched against: with it the entry that contains 
the typed text as it is gets selected,
+     * rather than the first one shown. Null when the popup does not say.
+     */
+    protected String matchText(T entry) {
+        return null;
+    }
+
+    /**
+     * The entry to select: the first whose text contains the typed text as it 
is (log[${body}] for log, before the
+     * fuzzy l...o...g of a longer one above it), else the first that matches 
itself (not a row shown as the context of
+     * others, such as the route of a matching processor), else the first.
+     */
+    private int bestMatch() {
+        if (!filter.hasFilter()) {
+            return 0;
+        }
+        String typed = filter.filter();
+        String lower = typed.toLowerCase();
+        int firstMatch = -1;
+        for (int i = 0; i < shown.size(); i++) {
+            T entry = shown.get(i);
+            String text = matchText(entry);
+            if (text != null && text.toLowerCase().contains(lower)) {
+                return i;
+            }
+            if (firstMatch < 0 && matches(entry, typed)) {
+                firstMatch = i;
+            }
+        }
+        return Math.max(firstMatch, 0);
     }
 
     boolean handleKeyEvent(KeyEvent ke) {
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FooterHints.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FooterHints.java
new file mode 100644
index 000000000000..2dd5a9ed9b14
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FooterHints.java
@@ -0,0 +1,85 @@
+/*
+ * 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.List;
+
+import dev.tamboui.text.Span;
+
+/**
+ * Fits the key hints of the footer into its width. When they do not fit, 
whole hints are dropped: first the F-keys
+ * every screen has (F10, F2, then F1), then the screen's own hints from the 
tail, then its own F-keys (such as Shift+F8
+ * fix with AI, F12 file actions). What belongs to the screen goes last; its 
first hint (what Esc does) stays.
+ */
+final class FooterHints {
+
+    private FooterHints() {
+    }
+
+    /**
+     * @param  spans       the footer: the screen's hints, with the F-key 
hints inserted at {@code fKeyStart}; the hints
+     *                     dropped are removed from it
+     * @param  fKeyStart   where the F-key hints start; the size of the spans 
when there are none
+     * @param  globalSpans the spans of the F-keys every screen has (F1, F2, 
F10), first among the F-keys
+     * @param  screenSpans the spans of the screen's own F-keys, after the 
global ones
+     * @param  width       the width of the spans
+     * @param  available   the width of the footer
+     * @return             the width of the spans after dropping
+     */
+    static int fit(List<Span> spans, int fKeyStart, int globalSpans, int 
screenSpans, int width, int available) {
+        while (globalSpans > 0 && width > available) {
+            width -= removePair(spans, fKeyStart + globalSpans - 2);
+            globalSpans -= 2;
+        }
+        int fKeyEnd = fKeyStart + globalSpans + screenSpans;
+        while (spans.size() > fKeyEnd && width > available) {
+            width -= removeLastHint(spans, fKeyEnd);
+        }
+        while (screenSpans > 0 && width > available) {
+            width -= removePair(spans, fKeyStart + globalSpans + screenSpans - 
2);
+            screenSpans -= 2;
+        }
+        // a footer without F-keys (a popup's) keeps two hints, as Esc is 
often the second
+        while (spans.size() > 4 && width > available) {
+            width -= removeLastHint(spans, 2);
+        }
+        return width;
+    }
+
+    /** Removes the hint whose key span is at the index, and its label. */
+    private static int removePair(List<Span> spans, int keyIndex) {
+        Span label = spans.remove(keyIndex + 1);
+        Span key = spans.remove(keyIndex);
+        return key.width() + label.width();
+    }
+
+    /**
+     * Removes the last hint, from its key to the end: a hint is a key and its 
label, or more spans after the key (the
+     * show BHPV toggles of the Errors tab), so it goes whole.
+     */
+    private static int removeLastHint(List<Span> spans, int floor) {
+        int start = spans.size() - 1;
+        while (start > floor && 
!Theme.hintKey().equals(spans.get(start).style())) {
+            start--;
+        }
+        int width = 0;
+        while (spans.size() > start) {
+            width += spans.remove(spans.size() - 1).width();
+        }
+        return width;
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoNodePopup.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoNodePopup.java
index af91c8fb8edf..b48b447517a8 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoNodePopup.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoNodePopup.java
@@ -71,9 +71,14 @@ class GotoNodePopup extends 
FilterListPopup<GotoNodePopup.NodeEntry> {
         return "Go to Node";
     }
 
+    @Override
+    protected String matchText(NodeEntry entry) {
+        return entry.type() + " " + entry.label() + " " + entry.nodeId();
+    }
+
     @Override
     protected boolean matches(NodeEntry entry, String typed) {
-        return matchesText(entry.type() + " " + entry.label() + " " + 
entry.nodeId(), typed);
+        return matchesText(matchText(entry), typed);
     }
 
     @Override
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoRoutePopup.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoRoutePopup.java
index eee5833a84ed..a2b5d4317e8f 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoRoutePopup.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoRoutePopup.java
@@ -68,9 +68,14 @@ class GotoRoutePopup extends 
FilterListPopup<GotoRoutePopup.RouteItem> {
         return name;
     }
 
+    @Override
+    protected String matchText(RouteItem entry) {
+        return entry.routeId() + " " + entry.fromUri();
+    }
+
     @Override
     protected boolean matches(RouteItem entry, String typed) {
-        return matchesText(entry.routeId() + " " + entry.fromUri(), typed);
+        return matchesText(matchText(entry), typed);
     }
 
     @Override
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoSourceNodePopup.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoSourceNodePopup.java
index e7cf4922c501..fbda61dea5e2 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoSourceNodePopup.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoSourceNodePopup.java
@@ -143,11 +143,15 @@ class GotoSourceNodePopup extends 
FilterListPopup<YamlRouteNodeScanner.NodeEntry
     }
 
     @Override
-    protected boolean matches(YamlRouteNodeScanner.NodeEntry entry, String 
typed) {
-        String searchText = entry.kind() == 
YamlRouteNodeScanner.EntryKind.ROUTE
+    protected String matchText(YamlRouteNodeScanner.NodeEntry entry) {
+        return entry.kind() == YamlRouteNodeScanner.EntryKind.ROUTE
                 ? entry.routeId() + " " + entry.fromUri()
                 : entry.type() + " " + entry.label() + " " + entry.routeId();
-        return matchesText(searchText, typed);
+    }
+
+    @Override
+    protected boolean matches(YamlRouteNodeScanner.NodeEntry entry, String 
typed) {
+        return matchesText(matchText(entry), typed);
     }
 
     @Override
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoTabPopup.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoTabPopup.java
index 19d31bfb62bc..ec2b2804ce1a 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoTabPopup.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoTabPopup.java
@@ -96,6 +96,11 @@ class GotoTabPopup extends 
FilterListPopup<TabRegistry.TabEntry> {
         return "Go to Tab";
     }
 
+    @Override
+    protected String matchText(TabRegistry.TabEntry entry) {
+        return entry.name();
+    }
+
     @Override
     protected boolean matches(TabRegistry.TabEntry entry, String typed) {
         boolean descMatch = typed.length() >= 2 && entry.description() != null
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 d1109eb60c6a..048cebe3cecb 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
@@ -19,11 +19,13 @@ package org.apache.camel.dsl.jbang.core.commands.tui;
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
+import java.nio.file.InvalidPathException;
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Locale;
 import java.util.Queue;
+import java.util.Set;
 import java.util.concurrent.atomic.AtomicReference;
 import java.util.function.BiFunction;
 import java.util.function.Function;
@@ -1119,11 +1121,56 @@ class McpFacade {
                     return info;
                 }
             }
+            // the pid, which tells apart two integrations of the same name 
(see integrationWithSourceDirectory)
+            for (IntegrationInfo info : data.get()) {
+                if (!info.vanishing && name.equals(info.pid)) {
+                    return info;
+                }
+            }
             return null;
         }
         return ctx != null ? ctx.findSelectedIntegration() : null;
     }
 
+    /**
+     * The pid of the integration whose source directory the given one is, as 
an agent passes it to a file tool: the
+     * selected integration's when it is that, else the first other one's; 
null when it is the source directory of none.
+     */
+    String integrationWithSourceDirectory(String directory) {
+        Path wanted;
+        try {
+            wanted = realDirectory(Path.of(directory));
+        } catch (InvalidPathException e) {
+            return null;
+        }
+        IntegrationInfo selected = findIntegration(null);
+        if (selected != null && !selected.vanishing && 
wanted.equals(sourceDirectory(selected))) {
+            return selected.pid;
+        }
+        for (IntegrationInfo info : data.get()) {
+            if (!info.vanishing && wanted.equals(sourceDirectory(info))) {
+                return info.pid;
+            }
+        }
+        return null;
+    }
+
+    private static Path sourceDirectory(IntegrationInfo info) {
+        Path dir = FilesBrowser.resolveSourceDirectory(info);
+        return dir != null ? realDirectory(dir) : null;
+    }
+
+    /**
+     * The directory with symlinks resolved (/tmp is /private/tmp on macOS), 
or as it is written when it is not there.
+     */
+    private static Path realDirectory(Path dir) {
+        try {
+            return dir.toRealPath();
+        } catch (IOException e) {
+            return dir.toAbsolutePath().normalize();
+        }
+    }
+
     /**
      * Tells an agent where the sources are and whether editing them makes 
sense: the directory differs with how the
      * integration was started (plain files, --source-dir, an example 
extracted to a temporary folder, an exported
@@ -1419,6 +1466,9 @@ class McpFacade {
     }
 
     /** As {@link #editFile(String, String, String, String, boolean)} with 
several edits, written once (CAMEL-25501). */
+    /** The statuses of a write that changed the file: written directly, or 
saved in the editor after a live replay. */
+    private static final Set<String> APPLIED_WRITES = Set.of("overwritten", 
"created", "written");
+
     JsonObject editFile(String name, String file, String find, String replace, 
String edits, boolean confirm) {
         IntegrationInfo target = findIntegration(name);
         if (target == null) {
@@ -1435,7 +1485,8 @@ class McpFacade {
             return edit; // not-found, ambiguous or an error: the shared 
answer says what to do
         }
         JsonObject result = writeFile(name, file, content, confirm);
-        if (!"invalid".equals(result.getString("status")) && 
!"error".equals(result.getString("status"))) {
+        // only an edit the file got is "edited": one the user rejected, or a 
live replay not yet saved, keeps saying so
+        if (APPLIED_WRITES.contains(result.getString("status"))) {
             result.put("status", "edited");
             result.put("editedAtLine", edit.getInteger("editedAtLine"));
             result.put("replacedLines", edit.getInteger("replacedLines"));
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java
index a292dbe6ccff..b88ccc8cbd83 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java
@@ -17,8 +17,11 @@
 package org.apache.camel.dsl.jbang.core.commands.tui;
 
 import java.io.IOException;
+import java.nio.file.Files;
 import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
 import java.util.List;
+import java.util.UUID;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.ExecutorService;
@@ -26,6 +29,8 @@ import java.util.concurrent.Executors;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.concurrent.atomic.AtomicReference;
 import java.util.function.BiConsumer;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 import dev.tamboui.style.Style;
 import dev.tamboui.tui.TuiRunner;
@@ -183,7 +188,16 @@ class MonitorContext {
 
     private final ConcurrentHashMap<String, Object> actionLocks = new 
ConcurrentHashMap<>();
 
+    /**
+     * Sends a request no one waits for an answer to: a route command, reset 
stats, a logging level. The connector of
+     * Camel 4.21 or later gets a file of its own for it (see {@link 
#takesRequestFiles(String)}), which the next
+     * request of a tab cannot overwrite before the connector read it.
+     */
     void fireAction(String pid, JsonObject request) {
+        if (takesRequestFiles(pid)) {
+            fireRequest(pid, request);
+            return;
+        }
         Object lock = actionLocks.computeIfAbsent(pid, k -> new Object());
         synchronized (lock) {
             Path actionFile = getActionFile(pid);
@@ -191,6 +205,76 @@ class MonitorContext {
         }
     }
 
+    /** How long a request sent with fireAction may wait for the connector, 
which runs it within a second or so. */
+    static final long FIRED_REQUEST_TIMEOUT_MS = 60_000;
+
+    /**
+     * Writes the request in a file of its own, which the connector deletes 
once it ran it. An answer it writes for it
+     * (one that acknowledges the action) is removed, and so is a request 
still there after a minute (the integration
+     * stopped).
+     */
+    private void fireRequest(String pid, JsonObject request) {
+        String requestId = UUID.randomUUID().toString();
+        Path dir = CommandLineHelper.getCamelDir();
+        Path actionFile = dir.resolve(pid + "-action-" + requestId + ".json");
+        Path outputFile = dir.resolve(pid + "-output-" + requestId + ".json");
+        Path temporary = dir.resolve(pid + "-" + requestId + ".tmp");
+        try {
+            Files.writeString(temporary, request.toJson());
+            // the connector must never read half a request
+            Files.move(temporary, actionFile, StandardCopyOption.ATOMIC_MOVE);
+        } catch (IOException e) {
+            PathUtils.deleteFile(temporary);
+            return;
+        }
+        backgroundExecutor.execute(() -> {
+            long deadline = MonotonicClock.millis() + FIRED_REQUEST_TIMEOUT_MS;
+            try {
+                while (Files.exists(actionFile) && MonotonicClock.millis() < 
deadline) {
+                    Thread.sleep(100);
+                }
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+            } finally {
+                PathUtils.deleteFile(actionFile);
+                PathUtils.deleteFile(outputFile);
+            }
+        });
+    }
+
+    /**
+     * Whether the connector of the integration takes a file per request 
({@code <pid>-action-<id>.json}, answered in
+     * {@code <pid>-output-<id>.json}), which it does since Camel 4.21 
(CAMEL-25448). The tabs, and other clients such
+     * as camel ask, then do not overwrite or take each other's requests and 
answers. An older connector only reads the
+     * shared {@code <pid>-action.json}, one request at a time.
+     */
+    boolean takesRequestFiles(String pid) {
+        List<IntegrationInfo> infos = data.get();
+        if (pid == null || infos == null || pid.isEmpty() || 
!pid.chars().allMatch(Character::isDigit)) {
+            // a phantom project has no process to ask
+            return false;
+        }
+        for (IntegrationInfo info : infos) {
+            if (pid.equals(info.pid)) {
+                return isCamelVersionAtLeast(info.camelVersion, 4, 21);
+            }
+        }
+        return false;
+    }
+
+    /** Whether a Camel version such as 4.23.0-SNAPSHOT is the given 
major.minor or later; false when unknown. */
+    static boolean isCamelVersionAtLeast(String version, int major, int minor) 
{
+        Matcher m = version != null ? VERSION.matcher(version) : null;
+        if (m == null || !m.find()) {
+            return false;
+        }
+        int actualMajor = Integer.parseInt(m.group(1));
+        int actualMinor = Integer.parseInt(m.group(2));
+        return actualMajor > major || actualMajor == major && actualMinor >= 
minor;
+    }
+
+    private static final Pattern VERSION = Pattern.compile("^(\\d+)\\.(\\d+)");
+
     /** How long the jvm answer of an integration is kept for the tabs that 
need its classpath. */
     static final long JVM_INFO_TTL_MS = 30_000;
     private final ConcurrentHashMap<String, CachedAnswer> jvmInfo = new 
ConcurrentHashMap<>();
@@ -221,7 +305,14 @@ class MonitorContext {
         }
     }
 
+    /**
+     * Sends a request and waits for its answer; null when none came in time. 
The connector of Camel 4.21 or later gets
+     * files of its own for it, so the requests of several tabs are answered 
together, and no lock is needed.
+     */
     JsonObject executeAction(String pid, JsonObject request, long timeoutMs) {
+        if (takesRequestFiles(pid)) {
+            return executeIndependentAction(pid, request, timeoutMs);
+        }
         Object lock = actionLocks.computeIfAbsent(pid, k -> new Object());
         synchronized (lock) {
             Path outputFile = getOutputFile(pid);
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceRefactorActions.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceRefactorActions.java
index fa03b8edf7bb..7600664405c7 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceRefactorActions.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceRefactorActions.java
@@ -72,16 +72,20 @@ class SourceRefactorActions {
         this.editor = editor;
     }
 
+    boolean isPopupOpen() {
+        return refactorPopup != null && refactorPopup.isVisible();
+    }
+
     /** Closes the popup; true when it was open. */
     boolean closePopup() {
-        boolean open = refactorPopup != null && refactorPopup.isVisible();
+        boolean open = isPopupOpen();
         refactorPopup = null;
         return open;
     }
 
     /** The keys of the popup while it is open: true then, as it takes them 
all. */
     boolean handleKeyEvent(KeyEvent ke) {
-        if (refactorPopup == null || !refactorPopup.isVisible()) {
+        if (!isPopupOpen()) {
             return false;
         }
         refactorPopup.handleKeyEvent(ke);
@@ -93,14 +97,14 @@ class SourceRefactorActions {
     }
 
     void render(Frame frame, Rect area) {
-        if (refactorPopup != null && refactorPopup.isVisible()) {
+        if (isPopupOpen()) {
             refactorPopup.render(frame, area);
         }
     }
 
     /** The footer hints of the popup while it is open: true then, as they 
replace those of the editor. */
     boolean renderFooter(List<Span> spans) {
-        if (refactorPopup == null || !refactorPopup.isVisible()) {
+        if (!isPopupOpen()) {
             return false;
         }
         refactorPopup.renderFooter(spans);
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTab.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTab.java
index 10d40df7a48c..3d8848fcec8b 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTab.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTab.java
@@ -125,6 +125,11 @@ class SourceTab extends AbstractTab {
         return sourceViewer.isEditMode();
     }
 
+    /** Whether a dialog over the editor has the keys (discard, the problems 
of a save, the diff, refactor). */
+    boolean showsViewerDialog() {
+        return sourceViewer.showsDialog();
+    }
+
     /** Steps whose uri: lines are destinations; for switch those of its cases 
and fallback. */
     private static final Set<String> LINKABLE_KEYWORDS = Set.of(
             "to", "toD", "wireTap", "enrich", "pollEnrich", 
"deadLetterChannel", "switch");
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java
index 978303d38318..8a865ea16e53 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java
@@ -480,6 +480,11 @@ class SourceViewer {
         return plainMode;
     }
 
+    /** Whether a dialog over the editor has the keys (discard, the problems 
of a save, the diff, refactor). */
+    boolean showsDialog() {
+        return pendingDiscard || editMode && (validationErrors != null || 
diffOverlay || refactor.isPopupOpen());
+    }
+
     /**
      * True when the viewer is consuming typed input (search box or plain-text 
edit mode). Used by the monitor to avoid
      * treating digit/letter keys as global shortcuts.
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TocPopup.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TocPopup.java
index b0b2805460de..4efaf0f3842f 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TocPopup.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TocPopup.java
@@ -55,9 +55,14 @@ class TocPopup extends FilterListPopup<TocPopup.TocEntry> {
         return "Table of Contents";
     }
 
+    @Override
+    protected String matchText(TocEntry entry) {
+        return entry.title();
+    }
+
     @Override
     protected boolean matches(TocEntry entry, String typed) {
-        return matchesText(entry.title(), typed);
+        return matchesText(matchText(entry), typed);
     }
 
     @Override
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiFileTools.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiFileTools.java
new file mode 100644
index 000000000000..a81db479e8b7
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiFileTools.java
@@ -0,0 +1,153 @@
+/*
+ * 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.nio.file.Path;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.apache.camel.dsl.jbang.core.commands.ai.AuthoringTools;
+import org.apache.camel.dsl.jbang.core.commands.ai.IntegrationSummary;
+import org.apache.camel.dsl.jbang.core.commands.ai.ToolExecutionException;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+
+import static 
org.apache.camel.dsl.jbang.core.commands.tui.TuiToolRegistry.EDIT_TOOL;
+import static 
org.apache.camel.dsl.jbang.core.commands.tui.TuiToolRegistry.FILES_TOOL;
+import static 
org.apache.camel.dsl.jbang.core.commands.tui.TuiToolRegistry.SAVE_SUMMARY_TOOL;
+import static 
org.apache.camel.dsl.jbang.core.commands.tui.TuiToolRegistry.VALIDATE_TOOL;
+import static 
org.apache.camel.dsl.jbang.core.commands.tui.TuiToolRegistry.WRITE_TOOL;
+
+/**
+ * The shared file tools in the TUI ({@code camel_get_files}, {@code 
camel_write_file}, {@code camel_edit_file},
+ * {@code camel_validate_source}, {@code camel_save_project_summary}): they 
work on the source directory of the selected
+ * integration, or of the one named. A file is written only there, after the 
user confirmed the change or watched it
+ * replayed in the editor (the AI panel's /write mode); a directory given 
names the integration whose sources it holds.
+ */
+class TuiFileTools {
+
+    static final Set<String> TOOLS = Set.of(FILES_TOOL, WRITE_TOOL, EDIT_TOOL, 
VALIDATE_TOOL, SAVE_SUMMARY_TOOL);
+    private static final Set<String> WRITE_TOOLS = Set.of(WRITE_TOOL, 
EDIT_TOOL, SAVE_SUMMARY_TOOL);
+
+    private final McpFacade facade;
+    private final TuiToolRegistry registry;
+
+    TuiFileTools(McpFacade facade, TuiToolRegistry registry) {
+        this.facade = facade;
+        this.registry = registry;
+    }
+
+    String execute(String name, Map<String, Object> args) {
+        if (WRITE_TOOLS.contains(name) && args.get("directory") instanceof 
String d && !d.isBlank()) {
+            String owner = facade.integrationWithSourceDirectory(d);
+            if (owner == null || SAVE_SUMMARY_TOOL.equals(name) && 
!owner.equals(facade.getSelectedPid())) {
+                JsonObject refused = new JsonObject();
+                refused.put("status", "error");
+                refused.put("error", d + " is not the source directory of " + 
(owner == null
+                        ? "a running integration or an open project" : "the 
selected integration")
+                                     + ": leave directory out to write to the 
selected integration's");
+                return Jsoner.serialize(refused);
+            }
+            Map<String, Object> byOwner = new HashMap<>(args);
+            byOwner.remove("directory");
+            byOwner.put("name", owner);
+            args = byOwner;
+        }
+        boolean hasDirectory = args.get("directory") instanceof String d && 
!d.isBlank();
+        boolean named = args.get("name") instanceof String n && !n.isBlank();
+        boolean facadeSelection = named || facade.getSelectedIntegrationName() 
!= null;
+        return switch (name) {
+            case FILES_TOOL -> !hasDirectory && facadeSelection ? 
callGetFiles(args) : registry.executeShared(name, args);
+            case WRITE_TOOL -> !hasDirectory && facadeSelection ? 
callWriteFile(args) : registry.executeShared(name, args);
+            case EDIT_TOOL -> !hasDirectory && facadeSelection ? 
callEditFile(args) : registry.executeShared(name, args);
+            case VALIDATE_TOOL -> !hasDirectory && facadeSelection && 
args.get("content") == null
+                    ? callValidateSource(args) : registry.executeShared(name, 
args);
+            case SAVE_SUMMARY_TOOL -> !hasDirectory && facadeSelection
+                    ? callSaveSummary(args) : registry.executeShared(name, 
args);
+            default -> registry.executeShared(name, args);
+        };
+    }
+
+    /**
+     * Saves the AI project summary of the selected integration through the 
TUI's write path, so the user sees and
+     * confirms it like any file the model writes (CAMEL-25143).
+     */
+    private String callSaveSummary(Map<String, Object> args) {
+        Path dir = facade.getSelectedSourceDirectory();
+        if (dir == null) {
+            return registry.executeShared(SAVE_SUMMARY_TOOL, args);
+        }
+        try {
+            AuthoringTools.SummaryUpdate update
+                    = AuthoringTools.summaryUpdate(dir, 
registry.sharedContext(), TuiToolRegistry.stringArgs(args));
+            String name = args.get("name") instanceof String n ? n : null;
+            JsonObject written = facade.writeFile(name, 
IntegrationSummary.FILE_NAME, update.content(), true);
+            JsonObject result = update.result();
+            result.put("status", written.get("status"));
+            if (written.get("message") != null) {
+                result.put("message", written.get("message"));
+            }
+            IntegrationSummaryHints.invalidate(dir);
+            return result.toJson();
+        } catch (ToolExecutionException e) {
+            return "Error: " + e.getMessage();
+        }
+    }
+
+    private String callGetFiles(Map<String, Object> args) {
+        String name = args.get("name") instanceof String s ? s : null;
+        String file = args.get("file") instanceof String s ? s : null;
+        JsonObject response = facade.getFiles(name, file);
+        if (response == null) {
+            return name != null
+                    ? "No source files found for integration '" + name + "'"
+                    : "No source files found for the selected integration";
+        }
+        return Jsoner.serialize(response);
+    }
+
+    private String callWriteFile(Map<String, Object> args) {
+        String name = args.get("name") instanceof String s ? s : null;
+        String file = args.get("file") instanceof String s ? s : null;
+        String content = args.get("content") instanceof String s ? s : null;
+        boolean confirm = !Boolean.FALSE.equals(args.get("confirm"));
+        // the write always validates: the tool has no switch, a model given 
one turns it off (CAMEL-24897)
+        return Jsoner.serialize(facade.writeFile(name, file, content, 
confirm));
+    }
+
+    private String callEditFile(Map<String, Object> args) {
+        String name = args.get("name") instanceof String s ? s : null;
+        String file = args.get("file") instanceof String s ? s : null;
+        String find = args.get("find") instanceof String s ? s : null;
+        String replace = args.get("replace") instanceof String s ? s : "";
+        Object list = args.get("edits");
+        String edits = list instanceof Map<?, ?> || list instanceof List<?> ? 
Jsoner.serialize(list)
+                : list instanceof String text ? text : null;
+        boolean confirm = !Boolean.FALSE.equals(args.get("confirm"));
+        // an edit is confirmed and replayed in the editor like a write 
(CAMEL-24909), several edits as one (CAMEL-25501)
+        return Jsoner.serialize(facade.editFile(name, file, find, replace, 
edits, confirm));
+    }
+
+    private String callValidateSource(Map<String, Object> args) {
+        String name = args.get("name") instanceof String s ? s : null;
+        String file = args.get("file") instanceof String s ? s : null;
+        String content = args.get("content") instanceof String s ? s : null;
+        return Jsoner.serialize(facade.validateSource(name, file, content));
+    }
+}
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 b84f7d51fb46..99b2be49a690 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
@@ -37,7 +37,6 @@ import dev.tamboui.export.ExportRequest;
 import dev.tamboui.style.Color;
 import dev.tamboui.style.Style;
 import org.apache.camel.catalog.CamelCatalog;
-import org.apache.camel.dsl.jbang.core.commands.ai.AuthoringTools;
 import org.apache.camel.dsl.jbang.core.commands.ai.IntegrationSummary;
 import org.apache.camel.dsl.jbang.core.commands.ai.ToolContext;
 import org.apache.camel.dsl.jbang.core.commands.ai.ToolDescriptor;
@@ -78,6 +77,7 @@ class TuiToolRegistry {
     }
 
     private final McpFacade facade;
+    private final TuiFileTools fileTools;
     private final AtomicInteger animCounter = new AtomicInteger();
     private volatile AnimationState currentAnimation;
 
@@ -87,6 +87,7 @@ class TuiToolRegistry {
 
     TuiToolRegistry(McpFacade facade) {
         this.facade = facade;
+        this.fileTools = facade != null ? new TuiFileTools(facade, this) : 
null;
     }
 
     /** The launcher the TUI wired into the facade, or null when there is none 
to launch with. */
@@ -95,9 +96,9 @@ class TuiToolRegistry {
     }
 
     /**
-     * Runs a shared {@code camel_} tool with the TUI's extras: the file tools 
work on the selected integration's source
-     * directory unless a directory is given (a write then goes through the 
TUI's confirm dialog or live replay),
-     * camel_control knows the TUI's own actions, camel_get_log also reads an 
infra service log.
+     * Runs a shared {@code camel_} tool with the TUI's extras: the file tools 
are {@link TuiFileTools}' (the source
+     * directory of an integration, each write confirmed by the user), 
camel_control knows the TUI's own actions,
+     * camel_get_log also reads an infra service log.
      */
     String executeSharedWithTuiExtras(String name, Map<String, Object> args) {
         if (facade != null && args.get("directory") instanceof String d && 
!d.isBlank() && !d.contains("/")
@@ -108,18 +109,12 @@ class TuiToolRegistry {
             byName.put("name", d);
             args = byName;
         }
-        boolean hasDirectory = args.get("directory") instanceof String d && 
!d.isBlank();
-        boolean named = args.get("name") instanceof String n && !n.isBlank();
-        boolean facadeSelection = facade != null && (named || 
facade.getSelectedIntegrationName() != null);
+        if (fileTools != null && TuiFileTools.TOOLS.contains(name)) {
+            return fileTools.execute(name, args);
+        }
         return switch (name) {
             case CONTROL_TOOL -> facade != null ? callControl(args) : 
executeShared(name, args);
             case LOG_TOOL -> facade != null ? callGetLog(args) : 
executeShared(name, args);
-            case FILES_TOOL -> !hasDirectory && facadeSelection ? 
callGetFiles(args) : executeShared(name, args);
-            case WRITE_TOOL -> !hasDirectory && facadeSelection ? 
callWriteFile(args) : executeShared(name, args);
-            case EDIT_TOOL -> !hasDirectory && facadeSelection ? 
callEditFile(args) : executeShared(name, args);
-            case VALIDATE_TOOL -> !hasDirectory && facadeSelection && 
args.get("content") == null
-                    ? callValidateSource(args) : executeShared(name, args);
-            case SAVE_SUMMARY_TOOL -> !hasDirectory && facadeSelection ? 
callSaveSummary(args) : executeShared(name, args);
             default -> executeShared(name, args);
         };
     }
@@ -143,7 +138,7 @@ class TuiToolRegistry {
         }
     }
 
-    private static Map<String, String> stringArgs(Map<String, Object> args) {
+    static Map<String, String> stringArgs(Map<String, Object> args) {
         Map<String, String> stringArgs = new LinkedHashMap<>();
         for (Map.Entry<String, Object> e : args.entrySet()) {
             if (e.getValue() instanceof Map<?, ?> || e.getValue() instanceof 
List<?>) {
@@ -157,7 +152,7 @@ class TuiToolRegistry {
     }
 
     /** A tool context over the TUI's selection: pid, Camel version, source 
directory, property check. */
-    private ToolContext sharedContext() {
+    ToolContext sharedContext() {
         ToolContext ctx = new ToolContext();
         // what the user selected is the integration; nothing selected means 
nothing, not the only one running
         ctx.setAutoSelectSingleProcess(false);
@@ -177,31 +172,6 @@ class TuiToolRegistry {
         return ctx;
     }
 
-    /**
-     * Saves the AI project summary of the selected integration through the 
TUI's write path, so the user sees and
-     * confirms it like any file the model writes (CAMEL-25143).
-     */
-    private String callSaveSummary(Map<String, Object> args) {
-        Path dir = facade.getSelectedSourceDirectory();
-        if (dir == null) {
-            return executeShared(SAVE_SUMMARY_TOOL, args);
-        }
-        try {
-            AuthoringTools.SummaryUpdate update = 
AuthoringTools.summaryUpdate(dir, sharedContext(), stringArgs(args));
-            String name = args.get("name") instanceof String n ? n : null;
-            JsonObject written = facade.writeFile(name, 
IntegrationSummary.FILE_NAME, update.content(), true);
-            JsonObject result = update.result();
-            result.put("status", written.get("status"));
-            if (written.get("message") != null) {
-                result.put("message", written.get("message"));
-            }
-            IntegrationSummaryHints.invalidate(dir);
-            return result.toJson();
-        } catch (ToolExecutionException e) {
-            return "Error: " + e.getMessage();
-        }
-    }
-
     static final String SHARED_PREFIX = "camel_";
     static final String CONTROL_TOOL = "camel_control";
     static final String LOG_TOOL = "camel_get_log";
@@ -1469,52 +1439,11 @@ class TuiToolRegistry {
         return facade.openProject(directory);
     }
 
-    private String callGetFiles(Map<String, Object> args) {
-        String name = args.get("name") instanceof String s ? s : null;
-        String file = args.get("file") instanceof String s ? s : null;
-        JsonObject response = facade.getFiles(name, file);
-        if (response == null) {
-            return name != null
-                    ? "No source files found for integration '" + name + "'"
-                    : "No source files found for the selected integration";
-        }
-        return Jsoner.serialize(response);
-    }
-
     /** Time the last tool call spent waiting for the user (a camel_write_file 
confirmation), see the AI panel. */
     long consumeConfirmWaitMs() {
         return facade != null ? facade.consumeConfirmWaitMs() : 0;
     }
 
-    private String callWriteFile(Map<String, Object> args) {
-        String name = args.get("name") instanceof String s ? s : null;
-        String file = args.get("file") instanceof String s ? s : null;
-        String content = args.get("content") instanceof String s ? s : null;
-        boolean confirm = !Boolean.FALSE.equals(args.get("confirm"));
-        // the write always validates: the tool has no switch, a model given 
one turns it off (CAMEL-24897)
-        return Jsoner.serialize(facade.writeFile(name, file, content, 
confirm));
-    }
-
-    private String callEditFile(Map<String, Object> args) {
-        String name = args.get("name") instanceof String s ? s : null;
-        String file = args.get("file") instanceof String s ? s : null;
-        String find = args.get("find") instanceof String s ? s : null;
-        String replace = args.get("replace") instanceof String s ? s : "";
-        Object list = args.get("edits");
-        String edits = list instanceof Map<?, ?> || list instanceof List<?> ? 
Jsoner.serialize(list)
-                : list instanceof String text ? text : null;
-        boolean confirm = !Boolean.FALSE.equals(args.get("confirm"));
-        // an edit is confirmed and replayed in the editor like a write 
(CAMEL-24909), several edits as one (CAMEL-25501)
-        return Jsoner.serialize(facade.editFile(name, file, find, replace, 
edits, confirm));
-    }
-
-    private String callValidateSource(Map<String, Object> args) {
-        String name = args.get("name") instanceof String s ? s : null;
-        String file = args.get("file") instanceof String s ? s : null;
-        String content = args.get("content") instanceof String s ? s : null;
-        return Jsoner.serialize(facade.validateSource(name, file, content));
-    }
-
     @SuppressWarnings("unchecked")
     String callLocate(Map<String, Object> args) {
         String text = args.get("text") instanceof String s ? s : null;
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitorTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitorTest.java
index 0ee9f76cec62..49b31945a096 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitorTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitorTest.java
@@ -16,15 +16,11 @@
  */
 package org.apache.camel.dsl.jbang.core.commands.tui;
 
-import java.util.ArrayList;
-import java.util.List;
-
 import dev.tamboui.buffer.Buffer;
 import dev.tamboui.buffer.Cell;
 import dev.tamboui.layout.Rect;
 import dev.tamboui.style.Color;
 import dev.tamboui.style.Style;
-import dev.tamboui.text.Span;
 import dev.tamboui.tui.event.KeyCode;
 import dev.tamboui.tui.event.KeyEvent;
 import dev.tamboui.tui.event.KeyModifiers;
@@ -34,7 +30,6 @@ import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.parallel.Isolated;
 import picocli.CommandLine;
 
-import static org.apache.camel.dsl.jbang.core.commands.tui.TuiHelper.hint;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNull;
@@ -93,124 +88,6 @@ class CamelMonitorTest {
     // (F6 first, then F3, F2). The first F-key pair must survive: F1 (help) 
when present, so the
     // user can always reach help, even on a narrow terminal.
 
-    @Test
-    void dropFKeyHintsRemovesF6BeforeF1() {
-        // tab hint + F1/F2/F3/F6, fKeyTotal = 8 spans
-        List<Span> spans = footer("Enter", "open");
-        hint(spans, "F1", "help");
-        hint(spans, "F2", "actions");
-        hint(spans, "F3", "switch");
-        hint(spans, "F6", "shell");
-        int width = width(spans);
-
-        // Available width is one pair short, so exactly one pair (F6) must be 
dropped.
-        int available = width - pairWidth(spans, "F6");
-        int newWidth = CamelMonitor.dropFKeyHints(spans, 8, width, available);
-
-        assertFalse(containsKey(spans, "F6"), "F6 (shell) must be dropped 
first");
-        assertTrue(containsKey(spans, "F1"), "F1 (help) must be preserved");
-        assertTrue(containsKey(spans, "F2"), "F2 must remain when only one 
pair needs dropping");
-        assertTrue(containsKey(spans, "F3"), "F3 must remain when only one 
pair needs dropping");
-        assertEquals(width(spans), newWidth, "returned width must match the 
remaining spans");
-    }
-
-    @Test
-    void dropFKeyHintsNeverDropsF1Help() {
-        List<Span> spans = footer("Enter", "open");
-        hint(spans, "F1", "help");
-        hint(spans, "F2", "actions");
-        hint(spans, "F3", "switch");
-        hint(spans, "F6", "shell");
-
-        // A tiny terminal forces every droppable pair to go.
-        int newWidth = CamelMonitor.dropFKeyHints(spans, 8, width(spans), 1);
-
-        assertTrue(containsKey(spans, "F1"), "F1 (help) must never be 
dropped");
-        assertFalse(containsKey(spans, "F2"), "F2 must be dropped under heavy 
overflow");
-        assertFalse(containsKey(spans, "F3"), "F3 must be dropped under heavy 
overflow");
-        assertFalse(containsKey(spans, "F6"), "F6 must be dropped under heavy 
overflow");
-        assertTrue(containsKey(spans, "Enter"), "the leading tab hint must be 
preserved");
-        assertEquals(width(spans), newWidth, "returned width must match the 
remaining spans");
-    }
-
-    @Test
-    void dropFKeyHintsKeepsFirstSecondaryHintWhenNoHelp() {
-        // No F1 (tab without help text): F2/F3/F6 only, fKeyTotal = 6 spans
-        List<Span> spans = footer("Enter", "open");
-        hint(spans, "F2", "actions");
-        hint(spans, "F3", "switch");
-        hint(spans, "F6", "shell");
-
-        int newWidth = CamelMonitor.dropFKeyHints(spans, 6, width(spans), 1);
-
-        assertTrue(containsKey(spans, "F2"), "first secondary hint is 
preserved as the loop stops at 2");
-        assertFalse(containsKey(spans, "F3"), "F3 must be dropped");
-        assertFalse(containsKey(spans, "F6"), "F6 must be dropped");
-        assertEquals(width(spans), newWidth, "returned width must match the 
remaining spans");
-    }
-
-    @Test
-    void dropFKeyHintsDropsTheGlobalOnesBeforeTheScreensOwn() {
-        // tab hint + F1/F2/F10 + the screen's own Shift+F8, fKeyTotal = 8 
spans of which 2 are the screen's
-        List<Span> spans = footer("Esc", "back");
-        hint(spans, "F1", "help");
-        hint(spans, "F2", "actions");
-        hint(spans, "F10", "run");
-        hint(spans, "Shift+F8", "fix with AI");
-        int width = width(spans);
-
-        int available = width - pairWidth(spans, "F10") - pairWidth(spans, 
"F2");
-        int newWidth = CamelMonitor.dropFKeyHints(spans, 8, 2, width, 
available);
-
-        assertTrue(containsKey(spans, "Shift+F8"), "the screen's own F-key 
stays while global ones can go");
-        assertTrue(containsKey(spans, "F1"), "F1 (help) must be preserved");
-        assertFalse(containsKey(spans, "F10"), "F10 is dropped first");
-        assertFalse(containsKey(spans, "F2"), "F2 is dropped next");
-        assertEquals(width(spans), newWidth, "returned width must match the 
remaining spans");
-
-        // under heavy overflow the screen's own goes too, F1 stays
-        CamelMonitor.dropFKeyHints(spans, 4, 2, width(spans), 1);
-        assertFalse(containsKey(spans, "Shift+F8"));
-        assertTrue(containsKey(spans, "F1"));
-    }
-
-    @Test
-    void dropFKeyHintsLeavesFooterUntouchedWhenItFits() {
-        List<Span> spans = footer("Enter", "open");
-        hint(spans, "F1", "help");
-        hint(spans, "F2", "actions");
-        int width = width(spans);
-
-        int newWidth = CamelMonitor.dropFKeyHints(spans, 4, width, 1000);
-
-        assertEquals(width, newWidth, "no spans dropped when the footer 
already fits");
-        assertTrue(containsKey(spans, "F1"));
-        assertTrue(containsKey(spans, "F2"));
-    }
-
-    private static List<Span> footer(String key, String label) {
-        List<Span> spans = new ArrayList<>();
-        hint(spans, key, label);
-        return spans;
-    }
-
-    private static int width(List<Span> spans) {
-        return spans.stream().mapToInt(Span::width).sum();
-    }
-
-    private static boolean containsKey(List<Span> spans, String key) {
-        return spans.stream().anyMatch(s -> s.content().trim().equals(key));
-    }
-
-    private static int pairWidth(List<Span> spans, String key) {
-        for (int i = 0; i + 1 < spans.size(); i++) {
-            if (spans.get(i).content().trim().equals(key)) {
-                return spans.get(i).width() + spans.get(i + 1).width();
-            }
-        }
-        throw new IllegalArgumentException("no hint pair for key " + key);
-    }
-
     // Footer key bindings are clickable only when the hint token maps to an 
unambiguous single key.
     // footerKeyEvent parses the token; footerRegionAt maps a click x back to 
the region under it.
 
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/FooterHintsTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/FooterHintsTest.java
new file mode 100644
index 000000000000..e974a8dc180f
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/FooterHintsTest.java
@@ -0,0 +1,146 @@
+/*
+ * 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.style.Style;
+import dev.tamboui.text.Span;
+import org.junit.jupiter.api.Test;
+
+import static org.apache.camel.dsl.jbang.core.commands.tui.TuiHelper.hint;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * A footer too wide for the screen drops the F-keys every screen has first, 
and what belongs to the screen last.
+ */
+class FooterHintsTest {
+
+    /** Esc back, then the F-keys (F1, F2, F10 and the screen's own Shift+F8), 
then the screen's other hints. */
+    private static List<Span> footer() {
+        List<Span> spans = new ArrayList<>();
+        hint(spans, "Esc", "back");
+        hint(spans, "F1", "help");
+        hint(spans, "F2", "actions");
+        hint(spans, "F10", "run");
+        hint(spans, "Shift+F8", "fix with AI");
+        hint(spans, "/", "find");
+        hint(spans, "h", "highlight");
+        return spans;
+    }
+
+    @Test
+    void theFKeysEveryScreenHasGoFirstLastOneFirst() {
+        List<Span> spans = footer();
+        int width = width(spans);
+
+        int fitted = FooterHints.fit(spans, 2, 6, 2, width, width - 
hintWidth(spans, "F10"));
+        assertThat(keys(spans)).containsExactly("Esc", "F1", "F2", "Shift+F8", 
"/", "h");
+        assertThat(fitted).isEqualTo(width(spans));
+
+        spans = footer();
+        FooterHints.fit(spans, 2, 6, 2, width,
+                width - hintWidth(spans, "F10") - hintWidth(spans, "F2") - 
hintWidth(spans, "F1"));
+        assertThat(keys(spans)).containsExactly("Esc", "Shift+F8", "/", "h");
+    }
+
+    @Test
+    void thenTheScreensHintsFromTheTailAndItsOwnFKeysLast() {
+        List<Span> spans = footer();
+        int width = width(spans);
+        int globals = hintWidth(spans, "F10") + hintWidth(spans, "F2") + 
hintWidth(spans, "F1");
+
+        FooterHints.fit(spans, 2, 6, 2, width, width - globals - 
hintWidth(spans, "h"));
+        assertThat(keys(spans)).containsExactly("Esc", "Shift+F8", "/");
+
+        spans = footer();
+        FooterHints.fit(spans, 2, 6, 2, width, width - globals - 
hintWidth(spans, "h") - hintWidth(spans, "/"));
+        assertThat(keys(spans)).containsExactly("Esc", "Shift+F8");
+    }
+
+    @Test
+    void theFirstHintOfTheScreenStays() {
+        List<Span> spans = footer();
+
+        int fitted = FooterHints.fit(spans, 2, 6, 2, width(spans), 1);
+
+        assertThat(keys(spans)).containsExactly("Esc");
+        assertThat(fitted).isEqualTo(width(spans));
+    }
+
+    @Test
+    void aHintOfSeveralSpansGoesWhole() {
+        List<Span> spans = new ArrayList<>();
+        hint(spans, "Esc", "back");
+        hint(spans, "F2", "actions");
+        hint(spans, "F10", "run");
+        hint(spans, "s", "sort");
+        // as the Errors tab draws it: the key, then the toggles
+        spans.add(Span.styled(" show ", Theme.hintKey()));
+        spans.add(Span.raw(" "));
+        for (String toggle : List.of("B", "h", "p", "v")) {
+            spans.add(Span.styled(toggle, Style.EMPTY.dim()));
+        }
+        spans.add(Span.raw("  "));
+        int width = width(spans);
+
+        FooterHints.fit(spans, 2, 4, 0, width,
+                width - hintWidth(spans, "F10") - hintWidth(spans, "F2") - 1);
+
+        assertThat(keys(spans)).containsExactly("Esc", "s");
+        assertThat(spans).hasSize(4).noneMatch(s -> s.content().equals("B"));
+    }
+
+    @Test
+    void aFooterThatFitsIsLeftAlone() {
+        List<Span> spans = footer();
+        int width = width(spans);
+
+        assertThat(FooterHints.fit(spans, 2, 6, 2, width, 
width)).isEqualTo(width);
+        assertThat(keys(spans)).containsExactly("Esc", "F1", "F2", "F10", 
"Shift+F8", "/", "h");
+    }
+
+    @Test
+    void aFooterWithoutFKeysKeepsTwoHints() {
+        List<Span> spans = new ArrayList<>();
+        hint(spans, "Enter", "select");
+        hint(spans, "Esc", "close");
+        hint(spans, "Tab", "next");
+
+        FooterHints.fit(spans, spans.size(), 0, 0, width(spans), 1);
+
+        assertThat(keys(spans)).containsExactly("Enter", "Esc");
+    }
+
+    private static List<String> keys(List<Span> spans) {
+        return spans.stream().filter(s -> 
Theme.hintKey().equals(s.style())).map(s -> s.content().strip()).toList();
+    }
+
+    private static int width(List<Span> spans) {
+        return spans.stream().mapToInt(Span::width).sum();
+    }
+
+    private static int hintWidth(List<Span> spans, String key) {
+        for (int i = 0; i + 1 < spans.size(); i++) {
+            if (spans.get(i).content().strip().equals(key)) {
+                return spans.get(i).width() + spans.get(i + 1).width();
+            }
+        }
+        throw new IllegalArgumentException("no hint for key " + key);
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoSourceNodePopupTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoSourceNodePopupTest.java
index 22ea0b70db5c..cdd0e14c70fe 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoSourceNodePopupTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoSourceNodePopupTest.java
@@ -74,7 +74,7 @@ class GotoSourceNodePopupTest {
         popup.handleKeyEvent(KeyEvent.ofChar('f', KeyModifiers.NONE));
         popup.handleKeyEvent(KeyEvent.ofChar('k', KeyModifiers.NONE));
         popup.handleKeyEvent(KeyEvent.ofChar('a', KeyModifiers.NONE));
-        popup.handleKeyEvent(KeyEvent.ofKey(KeyCode.DOWN, KeyModifiers.NONE));
+        // the processor that matches is selected, not its route shown above 
it as context
         popup.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
 
         YamlRouteNodeScanner.NodeEntry selected = popup.consumeSelection();
@@ -111,7 +111,6 @@ class GotoSourceNodePopupTest {
         popup.handleKeyEvent(KeyEvent.ofChar('p', KeyModifiers.NONE));
         popup.handleKeyEvent(KeyEvent.ofChar('h', KeyModifiers.NONE));
         popup.handleKeyEvent(KeyEvent.ofChar('a', KeyModifiers.NONE));
-        popup.handleKeyEvent(KeyEvent.ofKey(KeyCode.DOWN, KeyModifiers.NONE));
         popup.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
 
         YamlRouteNodeScanner.NodeEntry selected = popup.consumeSelection();
@@ -120,6 +119,34 @@ class GotoSourceNodePopupTest {
         assertThat(selected.routeFromLine()).isEqualTo(0);
     }
 
+    @Test
+    void theNodeThatContainsTheTypedTextIsSelectedBeforeAFuzzyMatch() {
+        // "log" matches the when fuzzily (l in simple, o in Counter, g in 
regex) and the log as it is
+        var entries = List.of(
+                new YamlRouteNodeScanner.NodeEntry(
+                        YamlRouteNodeScanner.EntryKind.ROUTE,
+                        "process", "direct:process", "route", "direct:process",
+                        "/tmp/routes.yaml", 0, 0, 0),
+                new YamlRouteNodeScanner.NodeEntry(
+                        YamlRouteNodeScanner.EntryKind.PROCESSOR,
+                        "process", null, "when", 
"simple{${header.CamelTimerCounter} regex '.*[05]$'}",
+                        "/tmp/routes.yaml", 5, 2, 0),
+                new YamlRouteNodeScanner.NodeEntry(
+                        YamlRouteNodeScanner.EntryKind.PROCESSOR,
+                        "process", null, "log", "${body}",
+                        "/tmp/routes.yaml", 12, 3, 0));
+        var popup = new GotoSourceNodePopup();
+        popup.open(entries, 100);
+
+        popup.handleKeyEvent(KeyEvent.ofChar('l', KeyModifiers.NONE));
+        popup.handleKeyEvent(KeyEvent.ofChar('o', KeyModifiers.NONE));
+        popup.handleKeyEvent(KeyEvent.ofChar('g', KeyModifiers.NONE));
+        assertThat(popup.shown()).hasSize(3);
+        popup.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
+
+        assertThat(popup.consumeSelection().type()).isEqualTo("log");
+    }
+
     @Test
     void typingLineNumberJumpsToLine() {
         var popup = new GotoSourceNodePopup();
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeWriteFileTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeWriteFileTest.java
index cffa670ff52d..9f0eac3bbeff 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeWriteFileTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeWriteFileTest.java
@@ -36,7 +36,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 class McpFacadeWriteFileTest {
 
     /** Answers the confirm dialog without a UI, and remembers what it was 
asked. */
-    private static class ConfirmingBridge implements McpFacade.MonitorBridge {
+    static class ConfirmingBridge implements McpFacade.MonitorBridge {
         final boolean answer;
         McpFacade.FileWrite request;
         int asked;
@@ -104,7 +104,7 @@ class McpFacadeWriteFileTest {
         }
     }
 
-    private static McpFacade facade(Path sourceDir, boolean devMode, 
ConfirmingBridge bridge) {
+    static McpFacade facade(Path sourceDir, boolean devMode, ConfirmingBridge 
bridge) {
         IntegrationInfo info = new IntegrationInfo();
         info.name = "demo";
         info.pid = "1";
@@ -204,6 +204,19 @@ class McpFacadeWriteFileTest {
         assertEquals(1, bridge.asked, "nothing to confirm when the snippet is 
not there");
     }
 
+    @Test
+    void anEditTheUserRejectsIsNotReportedAsEdited(@TempDir Path dir) throws 
IOException {
+        Files.writeString(dir.resolve("demo.camel.yaml"), "- route:\n    id: 
x\n");
+        ConfirmingBridge bridge = new ConfirmingBridge(false);
+        McpFacade facade = facade(dir, true, bridge);
+
+        JsonObject rejected = facade.editFile("demo", "demo.camel.yaml", "id: 
x", "id: y", true);
+        assertEquals("rejected", rejected.getString("status"), 
rejected.toJson());
+        assertNull(rejected.get("editedAtLine"), rejected.toJson());
+        assertEquals(1, bridge.asked);
+        assertEquals("- route:\n    id: x\n", 
Files.readString(dir.resolve("demo.camel.yaml"), StandardCharsets.UTF_8));
+    }
+
     /** CAMEL-25501: several edits are one change to confirm; a send to a 
direct: no route consumes yet is noted. */
     @Test
     void 
severalEditsAreOneConfirmedChangeAndAnUnconsumedDirectIsNoted(@TempDir Path 
dir) throws IOException {
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContextActionFilesTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContextActionFilesTest.java
new file mode 100644
index 000000000000..bba75cc149b5
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContextActionFilesTest.java
@@ -0,0 +1,163 @@
+/*
+ * 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.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Stream;
+
+import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.api.parallel.Isolated;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+
+/**
+ * The requests of the TUI to an integration of Camel 4.21 or later go in 
files of their own (CAMEL-25448), so a route
+ * command cannot be overwritten by the next request of a tab before the 
connector read it; an older integration gets
+ * the shared action file.
+ */
+@Isolated("Uses a temporary Camel home for real file IPC")
+class MonitorContextActionFilesTest {
+
+    @TempDir
+    Path home;
+
+    private Path previousHome;
+    private Path camelDir;
+    private final IntegrationInfo info = new IntegrationInfo();
+    private MonitorContext context;
+
+    @BeforeEach
+    void useTemporaryCamelHome() throws Exception {
+        previousHome = CommandLineHelper.getHomeDir();
+        CommandLineHelper.useHomeDir(home.toString());
+        camelDir = Files.createDirectories(CommandLineHelper.getCamelDir());
+        info.pid = "123";
+        info.camelVersion = "4.23.0-SNAPSHOT";
+        context = new MonitorContext(new AtomicReference<>(List.of(info)), new 
AtomicReference<>(List.of()));
+    }
+
+    @AfterEach
+    void restoreCamelHome() throws Exception {
+        context.backgroundExecutor.shutdownNow();
+        context.backgroundExecutor.awaitTermination(3, TimeUnit.SECONDS);
+        CommandLineHelper.useHomeDir(previousHome.toString());
+    }
+
+    @Test
+    void theRequestFilesCameWithCamel421() {
+        assertThat(MonitorContext.isCamelVersionAtLeast("4.23.0-SNAPSHOT", 4, 
21)).isTrue();
+        assertThat(MonitorContext.isCamelVersionAtLeast("4.21.0", 4, 
21)).isTrue();
+        assertThat(MonitorContext.isCamelVersionAtLeast("5.0.0", 4, 
21)).isTrue();
+        assertThat(MonitorContext.isCamelVersionAtLeast("4.18.3", 4, 
21)).isFalse();
+        assertThat(MonitorContext.isCamelVersionAtLeast(null, 4, 
21)).isFalse();
+        assertThat(MonitorContext.isCamelVersionAtLeast("unknown", 4, 
21)).isFalse();
+    }
+
+    @Test
+    void eachCommandGetsAFileOfItsOwn() throws Exception {
+        context.fireAction("123", routeCommand("stop"));
+        context.fireAction("123", routeCommand("start"));
+
+        List<Path> requests = requestFiles();
+        assertThat(requests).hasSize(2);
+        
assertThat(requests).map(MonitorContextActionFilesTest::command).containsExactlyInAnyOrder("stop",
 "start");
+        assertThat(camelDir.resolve("123-action.json")).doesNotExist();
+    }
+
+    @Test
+    void theAnswerToACommandIsRemovedOnceTheConnectorRanIt() throws Exception {
+        context.fireAction("123", routeCommand("stop"));
+        Path request = requestFiles().get(0);
+        Path answer = 
request.resolveSibling(request.getFileName().toString().replace("-action-", 
"-output-"));
+
+        // the connector writes an answer, then deletes the request it ran
+        Files.writeString(answer, "{\"status\":\"success\"}");
+        Files.delete(request);
+
+        await().atMost(3, TimeUnit.SECONDS).until(() -> !Files.exists(answer));
+    }
+
+    @Test
+    void aRequestThatWaitsForItsAnswerHasItsOwnFilesToo() throws Exception {
+        JsonObject question = new JsonObject();
+        question.put("action", "route-topology");
+        CompletableFuture<JsonObject> answer = CompletableFuture.supplyAsync(
+                () -> context.executeAction("123", question, 5000), 
context.backgroundExecutor);
+        context.fireAction("123", routeCommand("stop"));
+
+        await().atMost(3, TimeUnit.SECONDS).until(() -> requestFiles().size() 
== 2);
+        Path asked = requestFiles().stream().filter(p -> 
"route-topology".equals(action(p))).findFirst().orElseThrow();
+        
Files.writeString(asked.resolveSibling(asked.getFileName().toString().replace("-action-",
 "-output-")),
+                "{\"routes\":[]}");
+
+        assertThat(answer.get(3, TimeUnit.SECONDS)).containsKey("routes");
+        // the command is still there for the connector: nothing overwrote it
+        assertThat(requestFiles()).singleElement().satisfies(p -> 
assertThat(command(p)).isEqualTo("stop"));
+    }
+
+    @Test
+    void anIntegrationOlderThanCamel421GetsTheSharedFile() throws Exception {
+        info.camelVersion = "4.18.3";
+
+        context.fireAction("123", routeCommand("stop"));
+
+        assertThat(requestFiles()).isEmpty();
+        
assertThat(command(camelDir.resolve("123-action.json"))).isEqualTo("stop");
+    }
+
+    private List<Path> requestFiles() throws Exception {
+        try (Stream<Path> files = Files.list(camelDir)) {
+            return files.filter(p -> 
p.getFileName().toString().startsWith("123-action-")).toList();
+        }
+    }
+
+    private static JsonObject routeCommand(String command) {
+        JsonObject root = new JsonObject();
+        root.put("action", "route");
+        root.put("id", "*");
+        root.put("command", command);
+        return root;
+    }
+
+    private static String command(Path request) {
+        return read(request).getString("command");
+    }
+
+    private static String action(Path request) {
+        return read(request).getString("action");
+    }
+
+    private static JsonObject read(Path request) {
+        try {
+            return Jsoner.deserialize(Files.readString(request), new 
JsonObject());
+        } catch (Exception e) {
+            throw new IllegalStateException(e);
+        }
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceSizeTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceSizeTest.java
index a34c28d60676..70619005c4c9 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceSizeTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceSizeTest.java
@@ -59,7 +59,7 @@ class SourceSizeTest {
             AiPanel.java 3189
             AiPanel.runAgentLoop 159
             AiUsageStats.render 166
-            CamelMonitor.java 2833
+            CamelMonitor.java 2789
             DiagramDetailSupport.renderEipInfoPanel 159
             DiagramDetailSupport.renderRouteInfoPanel 154
             DiagramSupport.java 2697
@@ -80,7 +80,7 @@ class SourceSizeTest {
             SettingsPopup.handleKeyEvent 160
             SourceEditAssist.java 2006
             SourceViewer.handleEditKeyEvent 231
-            SourceViewer.java 3050
+            SourceViewer.java 3055
             SourceViewer.renderContent 207
             SourceViewer.renderEditMode 256
             TuiToolDefinitions.addInteractionTools 183
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerEditTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerEditTest.java
index 110a2e1f4bce..d3f92c2f3a3d 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerEditTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerEditTest.java
@@ -70,6 +70,26 @@ class SourceViewerEditTest {
                 """, StandardCharsets.UTF_8);
     }
 
+    @Test
+    void theDiffAndTheDiscardQuestionAreDialogsOverTheEditor() {
+        // their footer goes before that of a live edit, which shows the keys 
of the edit
+        viewer.loadFile(sourceFile);
+        viewer.enterEditMode();
+        assertThat(viewer.showsDialog()).isFalse();
+
+        viewer.handleKeyEvent(KeyEvent.ofChar('x', KeyModifiers.NONE));
+        viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.F7, KeyModifiers.NONE));
+        assertThat(viewer.showsDialog()).as("the diff").isTrue();
+        viewer.cancelEdit();
+        assertThat(viewer.showsDialog()).isFalse();
+
+        viewer.cancelEdit();
+        assertThat(viewer.showsDialog()).as("discard the change?").isTrue();
+        viewer.cancelEdit();
+        assertThat(viewer.showsDialog()).isFalse();
+        assertThat(viewer.isEditMode()).isFalse();
+    }
+
     @Test
     void loadFileMarksLocalWritableFileEditable() {
         viewer.loadFile(sourceFile);
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiFileToolsTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiFileToolsTest.java
new file mode 100644
index 000000000000..7aeb6792dcef
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiFileToolsTest.java
@@ -0,0 +1,111 @@
+/*
+ * 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.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Map;
+
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * The file tools write to the source directory of an integration, after the 
user confirmed the change, also when the
+ * agent passes the directory itself.
+ */
+class TuiFileToolsTest {
+
+    private static final String OLD = "- route: {}\n";
+    private static final String NEW = "- route:\n    id: x\n";
+
+    @TempDir
+    Path tempDir;
+
+    @Test
+    void theDirectoryOfTheIntegrationIsWrittenAfterTheUserConfirms() throws 
Exception {
+        Path project = project();
+        McpFacadeWriteFileTest.ConfirmingBridge bridge = new 
McpFacadeWriteFileTest.ConfirmingBridge(false);
+        TuiToolRegistry registry = new 
TuiToolRegistry(McpFacadeWriteFileTest.facade(project, false, bridge));
+
+        JsonObject result = call(registry, "camel_write_file",
+                Map.of("directory", project.toString(), "file", 
"demo.camel.yaml", "content", NEW));
+
+        assertThat(result.getString("status")).isEqualTo("rejected");
+        assertThat(bridge.asked).isEqualTo(1);
+        
assertThat(Files.readString(project.resolve("demo.camel.yaml"))).isEqualTo(OLD);
+    }
+
+    @Test
+    void theSameDirectoryWrittenAnotherWayIsTheIntegrations() throws Exception 
{
+        Path project = project();
+        Files.createDirectories(project.resolve("sub"));
+        McpFacadeWriteFileTest.ConfirmingBridge bridge = new 
McpFacadeWriteFileTest.ConfirmingBridge(true);
+        TuiToolRegistry registry = new 
TuiToolRegistry(McpFacadeWriteFileTest.facade(project, false, bridge));
+
+        JsonObject result = call(registry, "camel_write_file",
+                Map.of("directory", project.resolve("sub/..").toString(), 
"file", "demo.camel.yaml", "content", NEW));
+
+        assertThat(result.getString("status")).isEqualTo("overwritten");
+        assertThat(bridge.asked).isEqualTo(1);
+        
assertThat(Files.readString(project.resolve("demo.camel.yaml"))).isEqualTo(NEW);
+    }
+
+    @Test
+    void anEditIsConfirmedAsAWriteIs() throws Exception {
+        Path project = project();
+        McpFacadeWriteFileTest.ConfirmingBridge bridge = new 
McpFacadeWriteFileTest.ConfirmingBridge(false);
+        TuiToolRegistry registry = new 
TuiToolRegistry(McpFacadeWriteFileTest.facade(project, false, bridge));
+
+        JsonObject result = call(registry, "camel_edit_file",
+                Map.of("directory", project.toString(), "file", 
"demo.camel.yaml", "find", "route: {}",
+                        "replace", "route: {id: y}"));
+
+        assertThat(result.getString("status")).isEqualTo("rejected");
+        assertThat(bridge.asked).isEqualTo(1);
+        
assertThat(Files.readString(project.resolve("demo.camel.yaml"))).isEqualTo(OLD);
+    }
+
+    @Test
+    void anotherDirectoryIsNotWritten() throws Exception {
+        Path project = project();
+        Path elsewhere = Files.createDirectories(tempDir.resolve("elsewhere"));
+        McpFacadeWriteFileTest.ConfirmingBridge bridge = new 
McpFacadeWriteFileTest.ConfirmingBridge(true);
+        TuiToolRegistry registry = new 
TuiToolRegistry(McpFacadeWriteFileTest.facade(project, false, bridge));
+
+        JsonObject result = call(registry, "camel_write_file",
+                Map.of("directory", elsewhere.toString(), "file", 
"demo.camel.yaml", "content", NEW));
+
+        assertThat(result.getString("status")).isEqualTo("error");
+        assertThat(result.getString("error")).contains("is not the source 
directory");
+        assertThat(bridge.asked).isZero();
+        assertThat(elsewhere.resolve("demo.camel.yaml")).doesNotExist();
+    }
+
+    private Path project() throws Exception {
+        Path project = Files.createDirectories(tempDir.resolve("project"));
+        Files.writeString(project.resolve("demo.camel.yaml"), OLD);
+        return project;
+    }
+
+    private static JsonObject call(TuiToolRegistry registry, String tool, 
Map<String, Object> args) {
+        return Jsoner.deserialize(registry.executeSharedWithTuiExtras(tool, 
args), new JsonObject());
+    }
+}

Reply via email to