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 1f16b60c3d88 chore: camel-jbang - live edit polish: a pace that keeps 
up, a compact panel while asking, keys stay with it, no half-hour wait (#27276)
1f16b60c3d88 is described below

commit 1f16b60c3d88a52b4df5b4f12d01819899d1d447
Author: Claus Ibsen <[email protected]>
AuthorDate: Fri Oct 2 12:53:18 2026 +0200

    chore: camel-jbang - live edit polish: a pace that keeps up, a compact 
panel while asking, keys stay with it, no half-hour wait (#27276)
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---
 .../ROOT/pages/camel-jbang-tui-ai-agents.adoc      | 13 ++++--
 .../camel/dsl/jbang/core/commands/tui/AiPanel.java | 18 ++++++++
 .../dsl/jbang/core/commands/tui/CamelMonitor.java  | 36 ++++++++++++++--
 .../dsl/jbang/core/commands/tui/EditReplay.java    | 48 +++++++++++++++++++---
 .../dsl/jbang/core/commands/tui/McpFacade.java     | 25 ++++++++++-
 .../src/main/resources/tui/help/source.md          |  9 ++--
 .../dsl/jbang/core/commands/tui/AiPanelTest.java   | 25 +++++++++++
 .../jbang/core/commands/tui/EditReplayTest.java    | 48 ++++++++++++++++++++++
 .../core/commands/tui/McpFacadeWriteFileTest.java  | 21 ++++++++++
 9 files changed, 225 insertions(+), 18 deletions(-)

diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai-agents.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai-agents.adoc
index e007b72799e0..284f92771308 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai-agents.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai-agents.adoc
@@ -231,7 +231,8 @@ documentation, so the agent can check an endpoint before it 
answers or writes it
 
 With `/write live` the change is not shown as a diff but replayed in the 
Source tab's editor: the AI
 panel hides so the editor has the whole screen, the file opens in edit mode, 
the cursor jumps to the
-first change, removed lines disappear and added lines are typed at a readable 
pace. The AI panel comes
+first change, removed lines disappear and added lines are typed at a readable 
pace. A large change is typed
+faster, so no single change takes longer than a few seconds. The AI panel comes
 back once you have saved or discarded. This is meant for learning Camel and 
the YAML DSL: you see the edit land in
 the full, syntax-highlighted file and can look up what the new lines mean. 
Every change is its own step,
 even when changes sit a line apart, and between them the replay pauses: 
*Enter* continues with the next change, *Esc* stops (what was typed stays in 
the editor), any
@@ -240,17 +241,21 @@ so you can edit yourself; *F9* then continues with the 
remaining changes. Those
 so your own edits elsewhere in the file shift them rather than break them; a 
change whose surroundings
 you edited is skipped and reported to the agent. When the replay is over you 
review with *F7* and
 save with *Ctrl+S* or *F5*, which is the confirmation, or discard with *Esc*. 
The agent waits until then
-and is told what was applied, what was skipped, and the content of the saved 
file if you changed it.
+and is told what was applied, what was skipped, and the content of the saved 
file if you changed it. If you take
+longer than five minutes, the agent stops waiting and ends its turn; the edit 
stays in the editor, and the agent is
+told what became of it with your next question.
 
 An edit (`camel_edit_file`, which the agent uses to change a file without 
rewriting it) goes the same way: the
 snippet is replaced in the file's content and the result is confirmed or 
replayed like any write, so what you see
 in the editor is the finished file either way.
 
 While the replay pauses you can also ask the agent about the change it just 
made: *F8* opens the AI panel
-with the question prefilled (`About edit 2 of 3:`), you complete it and press 
*Enter*. The waiting
+with the question prefilled (`About edit 2 of 3:`), in a compact panel that 
leaves the edit in view (*Shift+F8*
+sizes it), you complete it and press *Enter*. The waiting
 `camel_write_file` call returns to the agent with the question, the edits 
applied so far and the editor's
 content, and the agent answers in the same turn -- nothing is written 
meanwhile. Close the panel with *F8*
-(or *Esc* to leave without asking) and the pause continues where it was. If 
your question makes the agent
+(or *Esc* to leave without asking) and the pause continues where it was; while 
the panel is open, the
+keys stay with it and do not reach the editor beneath. If your question makes 
the agent
 revise the change, its next `camel_write_file` of the same file continues in 
the editor from the current
 content instead of starting over. If you save or discard without the agent 
being involved again, it is
 told what became of the edit with your next question.
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
index 16b96c78ba68..78e0e8f7b457 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
@@ -200,6 +200,9 @@ class AiPanel {
     // set while the user asks about a paused live edit (F8 at the pause): 
input is accepted although a tool call is
     // still running, and Enter hands the question to the waiting call (true) 
or sends it as a normal question (false)
     private volatile Predicate<String> editQuestionHandler;
+    // asking about a paused live edit: the panel stays compact, so the edit 
in the editor stays in view, until it
+    // closes or the user sizes it
+    private boolean editCompact;
     // what became of a parked live edit; told to the model with the next 
question
     private volatile String pendingNote;
     private String initError;
@@ -485,6 +488,17 @@ class AiPanel {
         return anim.panelHeight();
     }
 
+    /** The height to render at: compact while asking about a paused live 
edit, else the panel's own height. */
+    int panelHeight(int contentHeight) {
+        int h = anim.panelHeight();
+        return editCompact ? Math.min(h, editQuestionHeight(contentHeight)) : 
h;
+    }
+
+    /** A quarter of the content, at least 10 rows: the question, a short 
answer and the hints. */
+    static int editQuestionHeight(int contentHeight) {
+        return Math.min(contentHeight, Math.max(10, contentHeight / 4));
+    }
+
     boolean isAnimating() {
         return anim.isAnimating();
     }
@@ -514,10 +528,12 @@ class AiPanel {
     }
 
     void cycleHeight(int contentHeight) {
+        editCompact = false;
         anim.cycleHeight(contentHeight);
     }
 
     void setPanelHeight(int height) {
+        editCompact = false;
         anim.setPanelHeight(height);
     }
 
@@ -594,6 +610,7 @@ class AiPanel {
     void close() {
         visible = false;
         editQuestionHandler = null;
+        editCompact = false;
         providerSwitchPopup.close();
     }
 
@@ -612,6 +629,7 @@ class AiPanel {
         scrollOffset = 0;
         replaceInputBuffer(prefill);
         editQuestionHandler = handler;
+        editCompact = true;
     }
 
     /**
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 ddb670e7b515..315ffc1a9e96 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
@@ -35,6 +35,7 @@ import java.util.Queue;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ConcurrentLinkedQueue;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
 import java.util.function.BiConsumer;
 import java.util.stream.Collectors;
 
@@ -202,6 +203,11 @@ public class CamelMonitor extends CamelCommand {
     private EditReplay.Editor activeReplayEditor;
     // null while the replay is parked: the tool call returned early with the 
user's question about the edit
     private volatile CompletableFuture<McpFacade.ReplayOutcome> 
activeReplayOutcome;
+    /**
+     * How long a live edit's tool call waits for the user to save or discard; 
after that the AI's turn goes on and the
+     * edit stays parked in the editor.
+     */
+    static final long REPLAY_WAIT_MINUTES = 5;
     private volatile String pendingEditQuestion;
     // the AI panel overlays the editor, so it is hidden while an edit is 
replayed and shown again afterwards
     private boolean replayHidAiPanel;
@@ -821,8 +827,20 @@ public class CamelMonitor extends CamelCommand {
                         pendingReplayOutcome = outcome;
                         pendingReplay = request;
                         try {
-                            // the user decides when this ends (save or 
discard); give up after a long while
-                            return outcome.get(30, TimeUnit.MINUTES);
+                            // the user decides when this ends (save or 
discard)
+                            return outcome.get(REPLAY_WAIT_MINUTES, 
TimeUnit.MINUTES);
+                        } catch (TimeoutException e) {
+                            if (pendingReplay == request) {
+                                // never started in the editor: nothing was 
replayed, nothing is pending
+                                pendingReplay = null;
+                                pendingReplayOutcome = null;
+                                return new McpFacade.ReplayOutcome(false, 0, 
List.of(), null);
+                            }
+                            // the user has not decided for a long while: the 
AI's turn goes on without it; the edit
+                            // stays parked in the editor and the AI is told 
what became of it with the next question.
+                            // complete() is atomic: a save at the same moment 
wins
+                            McpFacade.ReplayOutcome undecided = 
McpFacade.ReplayOutcome.undecidedOutcome();
+                            return outcome.complete(undecided) ? undecided : 
outcome.join();
                         } catch (Exception e) {
                             pendingReplay = null;
                             editReplay.abort();
@@ -894,6 +912,10 @@ public class CamelMonitor extends CamelCommand {
         if (activeReplay == null) {
             return;
         }
+        if (activeReplayOutcome != null && activeReplayOutcome.isDone()) {
+            // the tool call stopped waiting (the user took long to decide): 
the replay is parked
+            activeReplayOutcome = null;
+        }
         String question = pendingEditQuestion;
         if (question != null) {
             pendingEditQuestion = null;
@@ -1310,6 +1332,14 @@ public class CamelMonitor extends CamelCommand {
                 if (aiPanel.handleKeyEvent(ke)) {
                     return true;
                 }
+                if (editReplay.isAsking() && !ke.isCtrlC()) {
+                    // asking about a paused live edit: the keys the panel 
does not take stay away from the editor
+                    // beneath (its Esc would discard the edit); Esc and F8 go 
back to the edit
+                    if (ke.isCancel() || ke.isKey(KeyCode.F8)) {
+                        aiPanel.close();
+                    }
+                    return true;
+                }
             }
             if (handleGlobalKeys(ke, runner)) {
                 return true;
@@ -2035,7 +2065,7 @@ public class CamelMonitor extends CamelCommand {
             renderSidePanel(frame, contentArea, shellPanel.panelHeight(), 
shellPanel::render);
         } else if (aiPanel.isOpen()) {
             aiPanel.initHeight(contentArea.height());
-            renderSidePanel(frame, contentArea, aiPanel.panelHeight(), 
aiPanel::render);
+            renderSidePanel(frame, contentArea, 
aiPanel.panelHeight(contentArea.height()), aiPanel::render);
         } else if (logPinned && tabRegistry.selectedTabIndex() != TAB_LOG) {
             logPinAnim.initHeight(contentArea.height());
             int ph = logPinAnim.panelHeight();
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/EditReplay.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/EditReplay.java
index 10f0a247845e..4cbd83e2f0ec 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/EditReplay.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/EditReplay.java
@@ -70,6 +70,12 @@ final class EditReplay {
     /** Milliseconds per typed character, and per line removal. */
     static final long CHAR_DELAY_MS = 12;
     static final long LINE_DELAY_MS = 90;
+    /**
+     * At most this long to type one change: a longer one is typed faster, so 
a large edit does not keep the user (and
+     * the AI) waiting, while a small one keeps the pace of someone typing.
+     */
+    static final long HUNK_BUDGET_MS = 4000;
+    private static final long MAX_CATCH_UP_MS = 250;
 
     private final LongSupplier clock;
     private Editor editor;
@@ -83,8 +89,11 @@ final class EditReplay {
     private int row;
     private int bodyIndex;
     private int charIndex;
-    private long nextActionAt;
+    // fractional, so a fast pace below a millisecond per character still 
spreads over the ticks
+    private double nextActionAt;
     private int minRow;
+    // the pace of the current hunk: 1 for the natural pace, less for a change 
that would take longer than the budget
+    private double pace = 1;
     // where a question to the AI returns to: the pause, or the finished 
replay (asked after the last hunk)
     private Phase askedFrom = Phase.PAUSED;
 
@@ -196,9 +205,10 @@ final class EditReplay {
             row = start + hunk.before().size();
             bodyIndex = 0;
             charIndex = 0;
+            pace = pace(hunk);
             editor.moveToRow(row);
             phase = Phase.TYPING;
-            nextActionAt = now + LINE_DELAY_MS;
+            nextActionAt = now + delay(LINE_DELAY_MS);
             return;
         }
         phase = Phase.FINISHED;
@@ -213,6 +223,9 @@ final class EditReplay {
             phase = Phase.FINISHED;
             return;
         }
+        // the steps follow their own schedule, several in one tick when it is 
due (a tick is about 40 ms, a character
+        // 12 ms or less); after a stall the replay does not rush to catch up
+        nextActionAt = Math.max(nextActionAt, now - MAX_CATCH_UP_MS);
         while (phase == Phase.TYPING && now >= nextActionAt) {
             step(now);
         }
@@ -230,12 +243,12 @@ final class EditReplay {
             row++;
             bodyIndex++;
             editor.moveToRow(row);
-            nextActionAt = now + LINE_DELAY_MS / 3;
+            nextActionAt += delay(LINE_DELAY_MS / 3);
         } else if (entry.type() == '-') {
             editor.moveToRow(row);
             editor.deleteCurrentLine();
             bodyIndex++;
-            nextActionAt = now + LINE_DELAY_MS;
+            nextActionAt += delay(LINE_DELAY_MS);
         } else {
             String text = entry.text();
             if (charIndex == 0) {
@@ -244,17 +257,40 @@ final class EditReplay {
             if (charIndex < text.length()) {
                 editor.insertAtCursor(String.valueOf(text.charAt(charIndex)));
                 charIndex++;
-                nextActionAt = now + CHAR_DELAY_MS;
+                nextActionAt += delay(CHAR_DELAY_MS);
             } else {
                 editor.insertAtCursor("\n");
                 row++;
                 bodyIndex++;
                 charIndex = 0;
-                nextActionAt = now + LINE_DELAY_MS;
+                nextActionAt += delay(LINE_DELAY_MS);
             }
         }
     }
 
+    /** How long the hunk takes at the natural pace. */
+    static long naturalTime(EditDiff.Hunk hunk) {
+        long ms = LINE_DELAY_MS;
+        for (EditDiff.DiffEntry entry : hunk.body()) {
+            ms += switch (entry.type()) {
+                case ' ' -> LINE_DELAY_MS / 3;
+                case '-' -> LINE_DELAY_MS;
+                default -> entry.text().length() * CHAR_DELAY_MS + 
LINE_DELAY_MS;
+            };
+        }
+        return ms;
+    }
+
+    /** The pace that types the hunk within the budget: 1 when it fits at the 
natural pace. */
+    static double pace(EditDiff.Hunk hunk) {
+        long natural = naturalTime(hunk);
+        return natural <= HUNK_BUDGET_MS ? 1 : (double) HUNK_BUDGET_MS / 
natural;
+    }
+
+    private double delay(long ms) {
+        return ms * pace;
+    }
+
     /** Applies the rest of the current hunk at once. */
     private void finishHunkNow() {
         EditDiff.Hunk hunk = hunks.get(hunkIndex);
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 e4293c712ae8..f6915ee3923d 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
@@ -139,10 +139,23 @@ class McpFacade {
      * still holds the applied hunks ({@code content} is the buffer, not the 
file), {@code remaining} hunks wait.
      */
     record ReplayOutcome(boolean saved, int applied, List<Integer> skipped, 
String content, String question,
-            int remaining) {
+            int remaining, boolean undecided) {
+
+        ReplayOutcome(boolean saved, int applied, List<Integer> skipped, 
String content, String question,
+                      int remaining) {
+            this(saved, applied, skipped, content, question, remaining, false);
+        }
 
         ReplayOutcome(boolean saved, int applied, List<Integer> skipped, 
String content) {
-            this(saved, applied, skipped, content, null, 0);
+            this(saved, applied, skipped, content, null, 0, false);
+        }
+
+        /**
+         * The user has neither saved nor discarded the replayed edit for a 
long while: the tool call returns, the edit
+         * stays in the editor, and what the user does with it is told with 
the next question.
+         */
+        static ReplayOutcome undecidedOutcome() {
+            return new ReplayOutcome(false, 0, List.of(), null, null, 0, true);
         }
 
         boolean paused() {
@@ -1335,6 +1348,14 @@ class McpFacade {
         result.put("file", file);
         JsonArray skipped = new JsonArray();
         skipped.addAll(outcome.skipped());
+        if (outcome.undecided()) {
+            result.put("status", "pending");
+            result.put("message", "The change is in the user's editor, 
replayed as a live edit, but the user has not"
+                                  + " saved or discarded it yet; the file on 
disk is unchanged. End your turn now:"
+                                  + " do not write " + file + " again and do 
not say it is saved. Your next message"
+                                  + " tells you what the user did with it.");
+            return result;
+        }
         if (outcome.paused()) {
             result.put("status", "paused");
             result.put("appliedHunks", outcome.applied());
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/source.md 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/source.md
index c8646fc8bdd1..6b551084b7d8 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/source.md
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/source.md
@@ -183,17 +183,20 @@ a YAML or XML file); **Enter** goes to its declaration.
 
 With `/write live` in the AI panel (`F8`), a change the AI makes is replayed
 here instead of shown as a diff: the AI panel hides, the file opens in edit
-mode and the change is typed hunk by hunk so you can follow it in context.
+mode and the change is typed hunk by hunk so you can follow it in context
+(a large change is typed faster, a few seconds at most).
 
 - **Enter** — continue with the next change
 - **any other key** — finish the current change at once
 - **F4** — edit yourself; the remaining changes wait
 - **F9** — continue the AI changes after editing yourself (a change whose
   surrounding lines you edited is skipped and reported to the AI)
-- **F8** — ask the AI about the current change: the AI panel opens with the
-  question prefilled ("About edit 2 of 3: ..."), the answer comes back in the
+- **F8** — ask the AI about the current change: a compact AI panel opens with
+  the question prefilled ("About edit 2 of 3: ..."), the answer comes back in 
the
   same turn, and closing the panel (`F8` or `Esc`) returns to the pause; if
   the AI revises the change it continues in the editor from where it is
 - **Esc** — stop; what was typed stays in the editor
 - then **Ctrl+S** / **F5** saves (this is the confirmation, dev mode reloads),
   **F7** shows the diff, **Esc** discards; the AI panel comes back afterwards
+- after five minutes without saving or discarding, the AI stops waiting; the
+  edit stays here and the AI is told what you did with it next time you ask
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelTest.java
index 8df01c361505..13de5029cab0 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelTest.java
@@ -1866,4 +1866,29 @@ class AiPanelTest {
             return new ChatResponse(null, List.of(), "error", false, 
TokenUsage.EMPTY);
         }
     }
+
+    @Test
+    void askingAboutAPausedLiveEditKeepsThePanelCompactUntilTheUserSizesIt() {
+        AiPanel panel = new AiPanel();
+        panel.initHeight(80);
+        assertEquals(40, panel.panelHeight(80), "half the content by default");
+
+        panel.askAboutEdit("About edit 1 of 2: ", question -> true);
+        // the edit in the editor stays in view: a quarter of the content, at 
least 10 rows
+        assertEquals(20, panel.panelHeight(80));
+        assertEquals(10, panel.panelHeight(24));
+
+        // the user sizes it: then it is the user's height
+        panel.cycleHeight(80);
+        while (panel.isAnimating()) {
+            panel.tickAnimation();
+        }
+        assertEquals(60, panel.panelHeight(80), "three quarters, no longer 
capped");
+        panel.close();
+
+        panel.askAboutEdit("About edit 2 of 2: ", question -> true);
+        assertEquals(20, panel.panelHeight(80));
+        panel.close();
+        assertEquals(60, panel.panelHeight(80), "a normal question opens the 
panel at its own height again");
+    }
 }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/EditReplayTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/EditReplayTest.java
index c33f4faa96d5..8728f439c9f3 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/EditReplayTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/EditReplayTest.java
@@ -321,4 +321,52 @@ class EditReplayTest {
         assertEquals(third.locate(original, 0) + 2, third.locate(shifted, 0));
         assertEquals(-1, third.locate(List.of("nothing", "here"), 0));
     }
+
+    /** Ticks as the TUI does (every 40 ms) until the hunk is typed; the time 
it took. */
+    private static long typeAtUiTicks(EditReplay replay, FakeClock clock) {
+        long start = clock.now;
+        for (int i = 0; i < 100_000 && replay.phase() == 
EditReplay.Phase.TYPING; i++) {
+            clock.now += 40;
+            replay.tick(clock.now);
+        }
+        return clock.now - start;
+    }
+
+    @Test
+    void 
aSmallChangeIsTypedAtTheNaturalPaceAlthoughATickIsLongerThanACharacter() {
+        String before = "- route:\n    from:\n      uri: timer:tick\n";
+        String after = before + "      steps:\n        - log: \"Hello from the 
live edit\"\n";
+        List<EditDiff.Hunk> hunks = EditDiff.hunks(before.lines().toList(), 
after.lines().toList(), 3);
+        assertEquals(1.0, EditReplay.pace(hunks.get(0)));
+
+        FakeClock clock = new FakeClock();
+        EditReplay replay = replay(clock);
+        replay.start(new MemoryEditor(before), hunks);
+        long took = typeAtUiTicks(replay, clock);
+        // several characters in one tick: about the natural time, not one 
character per 40 ms tick
+        long natural = EditReplay.naturalTime(hunks.get(0));
+        assertTrue(took <= natural + 80, "took " + took + " ms, natural " + 
natural + " ms");
+        assertEquals(EditReplay.Phase.FINISHED, replay.phase());
+    }
+
+    @Test
+    void aLargeChangeIsTypedWithinTheBudget() {
+        String before = "- route:\n    from:\n      uri: timer:tick\n      
steps:\n";
+        StringBuilder after = new StringBuilder(before);
+        for (int i = 0; i < 120; i++) {
+            after.append("        - log: \"line ").append(i).append(" of a 
long change the AI writes at once\"\n");
+        }
+        List<EditDiff.Hunk> hunks = EditDiff.hunks(before.lines().toList(), 
after.toString().lines().toList(), 3);
+        assertTrue(EditReplay.naturalTime(hunks.get(0)) > 
EditReplay.HUNK_BUDGET_MS * 10, "a change of minutes");
+        assertTrue(EditReplay.pace(hunks.get(0)) < 0.1);
+
+        FakeClock clock = new FakeClock();
+        EditReplay replay = replay(clock);
+        MemoryEditor editor = new MemoryEditor(before);
+        replay.start(editor, hunks);
+        long took = typeAtUiTicks(replay, clock);
+        assertTrue(took <= EditReplay.HUNK_BUDGET_MS + 200, "took " + took + " 
ms");
+        assertTrue(took >= EditReplay.HUNK_BUDGET_MS / 2, "still typed, not 
pasted: " + took + " ms");
+        assertEquals(after.toString().strip(), editor.text().strip());
+    }
 }
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 48574d85ee6d..5422c4dddf7c 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
@@ -274,6 +274,27 @@ class McpFacadeWriteFileTest {
         assertEquals("- route: {}\n", bridge.request.oldContent());
     }
 
+    @Test
+    void 
liveModeReturnsWhenTheUserTakesLongToDecideAndTheEditStaysInTheEditor(@TempDir 
Path dir) throws IOException {
+        Files.writeString(dir.resolve("demo.camel.yaml"), "- route: {}\n");
+        ConfirmingBridge bridge = new ConfirmingBridge(true) {
+            @Override
+            public McpFacade.ReplayOutcome replayFileWrite(McpFacade.FileWrite 
request) {
+                return McpFacade.ReplayOutcome.undecidedOutcome();
+            }
+        };
+        McpFacade facade = facade(dir, true, bridge);
+        facade.setWriteMode(McpFacade.WriteMode.LIVE);
+
+        JsonObject result = facade.writeFile("demo", "demo.camel.yaml", "- 
route:\n    id: typed\n", true);
+
+        assertEquals("pending", result.getString("status"));
+        assertTrue(result.getString("message").contains("End your turn"));
+        assertTrue(result.getString("message").contains("do not write 
demo.camel.yaml again"));
+        assertEquals(0, bridge.asked, "no confirm dialog: the edit is still in 
the editor");
+        assertEquals("- route: {}\n", 
Files.readString(dir.resolve("demo.camel.yaml"), StandardCharsets.UTF_8));
+    }
+
     @Test
     void liveModeDefersOtherFilesWhileAnEditIsParkedInTheEditor(@TempDir Path 
dir) throws IOException {
         Files.writeString(dir.resolve("demo.camel.yaml"), "- route: {}\n");

Reply via email to