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 b6f1119a6689 chore: camel-jbang - timers and animations use a 
monotonic clock, not the wall clock (#27720)
b6f1119a6689 is described below

commit b6f1119a668973529b150d428b8d4d121406b8f6
Author: Claus Ibsen <[email protected]>
AuthorDate: Mon Oct 12 16:44:07 2026 +0200

    chore: camel-jbang - timers and animations use a monotonic clock, not the 
wall clock (#27720)
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---
 .../dsl/jbang/core/commands/tui/ActionsPopup.java  |  3 +-
 .../camel/dsl/jbang/core/commands/tui/AiPanel.java | 39 +++++----
 .../jbang/core/commands/tui/ArchitectureView.java  |  2 +-
 .../dsl/jbang/core/commands/tui/CamelMonitor.java  | 14 ++--
 .../jbang/core/commands/tui/CaptionOverlay.java    | 32 +++++--
 .../core/commands/tui/DataRefreshService.java      | 19 +++--
 .../dsl/jbang/core/commands/tui/DrawOverlay.java   |  3 +-
 .../dsl/jbang/core/commands/tui/EditReplay.java    |  4 +-
 .../dsl/jbang/core/commands/tui/HttpHelper.java    |  4 +-
 .../dsl/jbang/core/commands/tui/InfraInfo.java     |  1 +
 .../jbang/core/commands/tui/IntegrationInfo.java   |  3 +-
 .../core/commands/tui/IntegrationSummaryHints.java |  4 +-
 .../dsl/jbang/core/commands/tui/LaunchManager.java |  8 +-
 .../dsl/jbang/core/commands/tui/McpFacade.java     | 26 +++---
 .../dsl/jbang/core/commands/tui/MemoryLeakTab.java |  5 +-
 .../jbang/core/commands/tui/MetricsCollector.java  | 16 ++--
 .../jbang/core/commands/tui/MonitorContext.java    |  2 +-
 .../jbang/core/commands/tui/MonotonicClock.java    | 43 ++++++++++
 .../dsl/jbang/core/commands/tui/OllamaMonitor.java | 39 +++++----
 .../dsl/jbang/core/commands/tui/OllamaTab.java     |  2 +-
 .../dsl/jbang/core/commands/tui/OverviewTab.java   | 15 ++--
 .../core/commands/tui/ProjectOverviewAssist.java   |  4 +-
 .../jbang/core/commands/tui/RecordingManager.java  | 14 ++--
 .../dsl/jbang/core/commands/tui/RouteGroups.java   |  6 +-
 .../dsl/jbang/core/commands/tui/RouteKeys.java     |  6 +-
 .../jbang/core/commands/tui/RunOptionsForm.java    |  2 +-
 .../jbang/core/commands/tui/SourceEditAssist.java  |  4 +-
 .../dsl/jbang/core/commands/tui/SourceTab.java     |  2 +-
 .../dsl/jbang/core/commands/tui/SourceViewer.java  |  6 +-
 .../dsl/jbang/core/commands/tui/TapeRecorder.java  | 15 ++--
 .../dsl/jbang/core/commands/tui/ThreadsTab.java    |  4 +-
 .../dsl/jbang/core/commands/tui/TuiHelper.java     |  4 +-
 .../dsl/jbang/core/commands/tui/TuiMcpServer.java  | 10 +--
 .../jbang/core/commands/tui/TuiToolRegistry.java   | 30 +++----
 .../dsl/jbang/core/commands/tui/AiPanelTest.java   |  6 +-
 .../core/commands/tui/LaunchManagerInfraTest.java  | 30 +++----
 .../core/commands/tui/MonotonicClockTest.java      | 54 ++++++++++++
 .../core/commands/tui/MonotonicTimersTest.java     | 97 ++++++++++++++++++++++
 .../jbang/core/commands/tui/OllamaMonitorTest.java | 17 ++--
 .../commands/tui/OpenedProjectLifecycleTest.java   |  6 +-
 .../core/commands/tui/RecordingManagerTest.java    |  2 +-
 .../core/commands/tui/RunOptionsFormTest.java      |  2 +-
 42 files changed, 423 insertions(+), 182 deletions(-)

diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java
index 4a2fdb261e6f..3ec314ff7dda 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java
@@ -981,6 +981,7 @@ class ActionsPopup {
         }
     }
 
+    /** Watches the launches; now is a {@link MonotonicClock} time. */
     void tick(long now) {
         launchManager.tick(now);
     }
@@ -1331,7 +1332,7 @@ class ActionsPopup {
         runOptionsForm.close();
 
         // the project stays listed (Starting) until its app shows up and 
stands in for it
-        phantom.startingSince = System.currentTimeMillis();
+        phantom.startingSince = MonotonicClock.millis();
 
         // the app is found by the process it runs in: a folder runs from 
elsewhere, and reports another directory
         phantom.launchedProcess = phantom.projectType != null
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 504137b65c51..4f1e0e0d81b6 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
@@ -574,7 +574,7 @@ class AiPanel {
             // it waits until the panel is idle, so a question asked right 
away is answered first
             Thread t = new Thread(
                     () -> projectOverview.autoExplain(dir, c, mcpFacade, 
this::addOverviewEntry,
-                            () -> idleFor(OVERVIEW_IDLE_MS, 
System.currentTimeMillis())),
+                            () -> idleFor(OVERVIEW_IDLE_MS, 
MonotonicClock.millis())),
                     "tui-ai-overview-check");
             t.setDaemon(true);
             t.start();
@@ -584,7 +584,10 @@ class AiPanel {
     /** How long the panel is left alone (nothing typed, no question running) 
before the summary starts. */
     static final long OVERVIEW_IDLE_MS = 3000;
 
-    /** Whether the panel has been idle for a while: no question or command 
running, nothing typed. */
+    /**
+     * Whether the panel has been idle for a while: no question or command 
running, nothing typed. Now is a
+     * {@link MonotonicClock} time.
+     */
     boolean idleFor(long ms, long now) {
         return !thinking.get() && activeCliCommand == null && 
inputBuffer.isEmpty() && now - lastInputMs >= ms;
     }
@@ -863,7 +866,7 @@ class AiPanel {
     }
 
     boolean handleKeyEvent(KeyEvent ke) {
-        lastInputMs = System.currentTimeMillis();
+        lastInputMs = MonotonicClock.millis();
         if (permissionPopup.isVisible()) {
             if (ke.isCtrlC()) {
                 interruptBusyOperation();
@@ -1581,7 +1584,7 @@ class AiPanel {
         noteQuestionStarted(question);
         log(LogLevel.QUESTION, "Question", question);
         thinkingVerb = 
THINKING_VERBS.get(ThreadLocalRandom.current().nextInt(THINKING_VERBS.size()));
-        thinkingStartTime = System.currentTimeMillis();
+        thinkingStartTime = MonotonicClock.millis();
         thinking.set(true);
 
         // re-read the tool mode so a change made in F2 -> Settings applies to 
the next question, and rebuild the
@@ -1650,10 +1653,10 @@ class AiPanel {
                 throw new InterruptedException();
             }
 
-            long callStart = System.currentTimeMillis();
+            long callStart = MonotonicClock.millis();
             drainClientOutput();
             LlmClient.ChatResponse response = 
client.chatWithTools(systemPrompt, messages, tools);
-            long callLatency = System.currentTimeMillis() - callStart;
+            long callLatency = MonotonicClock.millis() - callStart;
             turnAiMs += callLatency;
             if (response == null) {
                 String err = "No response from LLM";
@@ -1692,7 +1695,7 @@ class AiPanel {
                     String key = toolCall.name() + " " + arguments;
                     int repeats = callCounts.merge(key, 1, Integer::sum);
                     String result;
-                    long toolStart = System.currentTimeMillis();
+                    long toolStart = MonotonicClock.millis();
                     if (repeats > MAX_IDENTICAL_TOOL_CALLS) {
                         result = "You have already called " + toolCall.name() 
+ " with these exact arguments "
                                  + (repeats - 1) + " times in this turn and 
the result will not change. Stop calling "
@@ -1700,7 +1703,7 @@ class AiPanel {
                     } else {
                         result = executeTuiTool(toolCall.name(), 
toolCall.arguments());
                     }
-                    long toolElapsed = System.currentTimeMillis() - toolStart;
+                    long toolElapsed = MonotonicClock.millis() - toolStart;
                     if (toolRegistry != null) {
                         // waiting for the user to confirm a file write is not 
tool time
                         toolElapsed = Math.max(0, toolElapsed - 
toolRegistry.consumeConfirmWaitMs());
@@ -1740,10 +1743,10 @@ class AiPanel {
                 "You have used all " + MAX_ITERATIONS + " tool calls available 
for this question and cannot call any"
                                             + " more; do not call a tool. 
Answer now: summarize what you did, what"
                                             + " you found, what failed, and 
what the user could try next."));
-        long wrapUpStart = System.currentTimeMillis();
+        long wrapUpStart = MonotonicClock.millis();
         drainClientOutput();
         LlmClient.ChatResponse wrapUp = client.chatWithTools(systemPrompt, 
messages, tools);
-        long wrapUpLatency = System.currentTimeMillis() - wrapUpStart;
+        long wrapUpLatency = MonotonicClock.millis() - wrapUpStart;
         turnAiMs += wrapUpLatency;
         if (wrapUp != null) {
             turnRequests++;
@@ -1752,10 +1755,10 @@ class AiPanel {
         }
         if (wrapUp == null || wrapUp.text() == null || 
wrapUp.text().isBlank()) {
             // the model called a tool regardless: once more without any tool 
to call, at the price of the prefix
-            wrapUpStart = System.currentTimeMillis();
+            wrapUpStart = MonotonicClock.millis();
             drainClientOutput();
             wrapUp = client.chatWithTools(systemPrompt, messages, List.of());
-            wrapUpLatency = System.currentTimeMillis() - wrapUpStart;
+            wrapUpLatency = MonotonicClock.millis() - wrapUpStart;
             turnAiMs += wrapUpLatency;
             if (wrapUp != null) {
                 turnRequests++;
@@ -1798,7 +1801,7 @@ class AiPanel {
             List<LlmClient.Message> messages) {
         sessionTotalTokens += totalUsage.totalTokens();
         if (text != null && !text.isBlank()) {
-            long elapsed = System.currentTimeMillis() - thinkingStartTime;
+            long elapsed = MonotonicClock.millis() - thinkingStartTime;
             String note = problems.isEmpty() ? null : answerNote(problems);
             ConversationEntry entry = new ConversationEntry(
                     AiRole.ASSISTANT, text, elapsed, turnAiMs, turnToolMs, 
turnToolCalls,
@@ -2011,7 +2014,7 @@ class AiPanel {
         conversation.add(new ConversationEntry(AiRole.USER, question));
         log(LogLevel.QUESTION, "Question", question);
         thinkingVerb = 
THINKING_VERBS.get(ThreadLocalRandom.current().nextInt(THINKING_VERBS.size()));
-        thinkingStartTime = System.currentTimeMillis();
+        thinkingStartTime = MonotonicClock.millis();
         thinking.set(true);
         // The panel keeps showing exactly what was typed (including an 
explicit /agent: escape), but the agent
         // itself only ever sees its own command name: /agent:clear reaches it 
as /clear.
@@ -2269,7 +2272,7 @@ class AiPanel {
         }
 
         synchronized void finish(String stopReason) {
-            long elapsed = System.currentTimeMillis() - thinkingStartTime;
+            long elapsed = MonotonicClock.millis() - thinkingStartTime;
             // the agent reports what its context holds now, not what the turn 
spent: the turn is the growth, and a
             // context that shrank because the agent compacted spends nothing
             long previous = acpContextUsed;
@@ -2681,15 +2684,15 @@ class AiPanel {
     }
 
     private void renderThinkingStatus(Frame frame, Rect area) {
-        long elapsed = (System.currentTimeMillis() - thinkingStartTime) / 1000;
+        long elapsed = (MonotonicClock.millis() - thinkingStartTime) / 1000;
         Spinner spinner = Spinner.builder()
                 .spinnerStyle(SpinnerStyle.DOTS)
                 .style(Style.EMPTY.fg(Theme.accent()).bold())
                 .build();
         Rect spinnerArea = new Rect(area.left(), area.top(), 2, 1);
-        frame.renderStatefulWidget(spinner, spinnerArea, new 
SpinnerState(System.currentTimeMillis() / 100));
+        frame.renderStatefulWidget(spinner, spinnerArea, new 
SpinnerState(MonotonicClock.millis() / 100));
 
-        long dots = (System.currentTimeMillis() / 500) % 4;
+        long dots = (MonotonicClock.millis() / 500) % 4;
         String text = " " + (thinkingVerb != null ? thinkingVerb : 
THINKING_VERBS.get(0));
         if (elapsed > 0) {
             text += " (" + elapsed + "s)";
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ArchitectureView.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ArchitectureView.java
index 2677a14ac04a..99398cfb1abb 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ArchitectureView.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ArchitectureView.java
@@ -215,7 +215,7 @@ final class ArchitectureView {
      * summary /overview has just written, without a reload key.
      */
     void checkForChanges() {
-        long now = System.currentTimeMillis();
+        long now = MonotonicClock.millis();
         if (!isActive() || loading || checking || dir == null || now - 
lastCheck < RECHECK_MS) {
             return;
         }
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 e259a7a4ff8b..edd4b522cd23 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
@@ -312,7 +312,7 @@ public class CamelMonitor extends CamelCommand {
         TapePlayer.Driver driver = new TapePlayer.Driver() {
             @Override
             public void key(KeyEvent event) {
-                pendingKeys.add(new McpFacade.PendingKey(event, 
System.currentTimeMillis()));
+                pendingKeys.add(new McpFacade.PendingKey(event, 
MonotonicClock.millis()));
             }
 
             @Override
@@ -2030,10 +2030,10 @@ public class CamelMonitor extends CamelCommand {
     }
 
     private boolean handleTickEvent(TuiRunner runner) {
-        long now = System.currentTimeMillis();
+        long now = MonotonicClock.millis();
         boolean keyProcessed = false;
         McpFacade.PendingKey pk;
-        while ((pk = pendingKeys.peek()) != null && now >= pk.fireAt()) {
+        while ((pk = pendingKeys.peek()) != null && pk.isDue(now)) {
             pendingKeys.poll();
             mcpInjectedKey = true;
             handleEvent(pk.event(), runner);
@@ -2249,7 +2249,7 @@ public class CamelMonitor extends CamelCommand {
             }
         }
         if (monitorNotification != null) {
-            if (System.currentTimeMillis() > monitorNotificationExpiry) {
+            if (MonotonicClock.millis() > monitorNotificationExpiry) {
                 monitorNotification = null;
             }
         }
@@ -2798,7 +2798,7 @@ public class CamelMonitor extends CamelCommand {
     private void setNotification(String message, boolean error) {
         monitorNotification = message;
         monitorNotificationError = error;
-        monitorNotificationExpiry = System.currentTimeMillis() + (error ? 
20000 : 10000);
+        monitorNotificationExpiry = MonotonicClock.millis() + (error ? 20000 : 
10000);
         notificationWaveState.reset();
     }
 
@@ -2981,12 +2981,12 @@ public class CamelMonitor extends CamelCommand {
         List<Span> rightSpans = new ArrayList<>();
 
         if (recordingManager.isRecording() && 
!recordingManager.getRecentKeys().isEmpty()) {
-            long now = System.currentTimeMillis();
+            long now = MonotonicClock.millis();
             List<RecordingManager.KeyRecord> recentKeys = 
recordingManager.getRecentKeys();
             int maxKeys = Math.min(recentKeys.size(), 8);
             List<RecordingManager.KeyRecord> visible = 
recentKeys.subList(recentKeys.size() - maxKeys, recentKeys.size());
             for (RecordingManager.KeyRecord kr : visible) {
-                long age = now - kr.timestamp();
+                long age = now - kr.pressedAt();
                 Style style = age < 1000
                         ? Theme.selectionBg()
                         : Theme.muted();
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CaptionOverlay.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CaptionOverlay.java
index cbf8cd83245a..a1e1a29f42c2 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CaptionOverlay.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CaptionOverlay.java
@@ -18,6 +18,7 @@ package org.apache.camel.dsl.jbang.core.commands.tui;
 
 import java.util.ArrayList;
 import java.util.List;
+import java.util.function.LongSupplier;
 
 import dev.tamboui.layout.Rect;
 import dev.tamboui.style.Style;
@@ -40,6 +41,9 @@ class CaptionOverlay {
     private static final long FADE_DURATION_MS = 1000;
     private static final long INLINE_IDLE_TIMEOUT_MS = 3000;
 
+    // the clock of the times below: the MonotonicClock, or a fake one in tests
+    private final LongSupplier clock;
+
     private boolean inlineMode;
     private StringBuilder inlineBuffer;
     private long inlineLastKeystroke;
@@ -49,6 +53,15 @@ class CaptionOverlay {
     private long captionFullyTypedTime;
     private long captionAutoDismissTime;
 
+    CaptionOverlay() {
+        this(MonotonicClock::millis);
+    }
+
+    /** With a clock, for tests that drive the time. */
+    CaptionOverlay(LongSupplier clock) {
+        this.clock = clock;
+    }
+
     boolean isInlineMode() {
         return inlineMode;
     }
@@ -69,25 +82,25 @@ class CaptionOverlay {
     void openInline() {
         inlineMode = true;
         inlineBuffer = new StringBuilder();
-        inlineLastKeystroke = System.currentTimeMillis();
+        inlineLastKeystroke = clock.getAsLong();
         captionText = "";
-        captionStartTime = System.currentTimeMillis();
+        captionStartTime = clock.getAsLong();
         captionFullyTypedTime = 0;
     }
 
     void showCaption(String text) {
         captionText = text.replace("\\n", "\n");
-        captionStartTime = System.currentTimeMillis();
+        captionStartTime = clock.getAsLong();
         captionFullyTypedTime = 0;
         captionAutoDismissTime = 0;
     }
 
     void showCaption(String text, int durationSeconds) {
         captionText = text.replace("\\n", "\n");
-        captionStartTime = System.currentTimeMillis();
+        captionStartTime = clock.getAsLong();
         captionFullyTypedTime = 0;
         if (durationSeconds > 0) {
-            captionAutoDismissTime = System.currentTimeMillis() + 
(durationSeconds * 1000L);
+            captionAutoDismissTime = clock.getAsLong() + (durationSeconds * 
1000L);
         } else {
             captionAutoDismissTime = 0;
         }
@@ -109,12 +122,12 @@ class CaptionOverlay {
                     // the last character, both chars of an emoji
                     
inlineBuffer.setLength(inlineBuffer.offsetByCodePoints(inlineBuffer.length(), 
-1));
                     captionText = inlineBuffer.toString();
-                    inlineLastKeystroke = System.currentTimeMillis();
+                    inlineLastKeystroke = clock.getAsLong();
                 }
             } else if (FormHelper.typedText(ke) != null) {
                 inlineBuffer.append(ke.string());
                 captionText = inlineBuffer.toString();
-                inlineLastKeystroke = System.currentTimeMillis();
+                inlineLastKeystroke = clock.getAsLong();
             }
             return true;
         }
@@ -129,6 +142,7 @@ class CaptionOverlay {
         return false;
     }
 
+    /** Finishes an idle inline caption and dismisses an expired one; now is a 
time of the caption's clock. */
     void tick(long now) {
         if (inlineMode && !inlineBuffer.isEmpty()
                 && now - inlineLastKeystroke > INLINE_IDLE_TIMEOUT_MS) {
@@ -170,7 +184,7 @@ class CaptionOverlay {
         }
         captionText = inlineBuffer.toString().replace("\\n", "\n");
         inlineBuffer = null;
-        captionFullyTypedTime = System.currentTimeMillis();
+        captionFullyTypedTime = clock.getAsLong();
     }
 
     void render(Frame frame, Rect area) {
@@ -220,7 +234,7 @@ class CaptionOverlay {
     }
 
     private void renderCaption(Frame frame, Rect area) {
-        long now = System.currentTimeMillis();
+        long now = clock.getAsLong();
         long elapsed = now - captionStartTime;
         // typed a character at a time, an emoji (two chars) at once
         int charsToShow = Math.min((int) (elapsed / CHAR_DELAY_MS), 
captionText.codePointCount(0, captionText.length()));
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DataRefreshService.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DataRefreshService.java
index 67e75eaca3dd..ec0d8f7ef0bd 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DataRefreshService.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DataRefreshService.java
@@ -158,6 +158,7 @@ class DataRefreshService {
         return stoppingPids;
     }
 
+    /** When the last refresh started, a {@link MonotonicClock} time. */
     long lastRefresh() {
         return lastRefresh;
     }
@@ -165,11 +166,11 @@ class DataRefreshService {
     // ---- Burst mode ----
 
     void enableBurstMode() {
-        burstModeUntil = System.currentTimeMillis() + 20_000;
+        burstModeUntil = MonotonicClock.millis() + 20_000;
     }
 
     void forceFullScan() {
-        forceFullScanUntil = System.currentTimeMillis() + 20_000;
+        forceFullScanUntil = MonotonicClock.millis() + 20_000;
     }
 
     /** How many scans of every integration have published their data. */
@@ -178,7 +179,7 @@ class DataRefreshService {
     }
 
     boolean isBurstMode() {
-        return System.currentTimeMillis() < burstModeUntil;
+        return MonotonicClock.millis() < burstModeUntil;
     }
 
     // ---- Refresh orchestration ----
@@ -199,7 +200,7 @@ class DataRefreshService {
         if (!refreshInProgress.compareAndSet(false, true)) {
             return;
         }
-        lastRefresh = System.currentTimeMillis();
+        lastRefresh = MonotonicClock.millis();
         ctx.backgroundExecutor.execute(() -> {
             try {
                 refreshSync(logRefresher, conditionalRefresher);
@@ -213,7 +214,7 @@ class DataRefreshService {
      * Perform a synchronous data refresh cycle: log data, integration scan, 
auto-select, and conditional tab data.
      */
     void refreshSync(Runnable logRefresher, Runnable conditionalRefresher) {
-        lastRefresh = System.currentTimeMillis();
+        lastRefresh = MonotonicClock.millis();
         try {
             logRefresher.run();
             boolean fullScan = scanIntegrations();
@@ -282,7 +283,7 @@ class DataRefreshService {
 
     private boolean scanIntegrations() {
         List<IntegrationInfo> infos = new ArrayList<>();
-        long now = System.currentTimeMillis();
+        long now = MonotonicClock.millis();
         boolean wantFullScan = refreshCtx.selectedTab() == 
TabRegistry.TAB_OVERVIEW || refreshCtx.isSwitchPopupVisible()
                 || cachedPids.isEmpty()
                 || forceFullScanUntil > now;
@@ -387,7 +388,7 @@ class DataRefreshService {
                 if (wasExplicitStop) {
                     metrics.removeVanished(prev.pid);
                 } else {
-                    vanishing.put(prev.pid, new VanishingInfo(prev, 
System.currentTimeMillis()));
+                    vanishing.put(prev.pid, new VanishingInfo(prev, 
MonotonicClock.millis()));
                 }
             }
         }
@@ -588,10 +589,10 @@ class DataRefreshService {
         List<InfraInfo> previousInfra = infraData.get();
         for (InfraInfo prev : previousInfra) {
             if (!prev.vanishing && !liveInfraPids.contains(prev.pid) && 
!vanishingInfra.containsKey(prev.pid)) {
-                vanishingInfra.put(prev.pid, new VanishingInfraInfo(prev, 
System.currentTimeMillis()));
+                vanishingInfra.put(prev.pid, new VanishingInfraInfo(prev, 
MonotonicClock.millis()));
             }
         }
-        long now = System.currentTimeMillis();
+        long now = MonotonicClock.millis();
         Iterator<Map.Entry<String, VanishingInfraInfo>> infraIt = 
vanishingInfra.entrySet().iterator();
         while (infraIt.hasNext()) {
             Map.Entry<String, VanishingInfraInfo> entry = infraIt.next();
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DrawOverlay.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DrawOverlay.java
index 6e94f254002d..25508321717e 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DrawOverlay.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DrawOverlay.java
@@ -47,7 +47,7 @@ class DrawOverlay {
     void setDrawing(List<DrawCell> newCells, int durationSeconds) {
         this.cells = new ArrayList<>(newCells);
         if (durationSeconds > 0) {
-            this.autoDismissTime = System.currentTimeMillis() + 
(durationSeconds * 1000L);
+            this.autoDismissTime = MonotonicClock.millis() + (durationSeconds 
* 1000L);
         } else {
             this.autoDismissTime = 0;
         }
@@ -66,6 +66,7 @@ class DrawOverlay {
         autoDismissTime = 0;
     }
 
+    /** Dismisses the drawing once its duration is over; now is a {@link 
MonotonicClock} time. */
     void tick(long now) {
         if (autoDismissTime > 0 && now > autoDismissTime) {
             clear();
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 4cbd83e2f0ec..7c0aa3ba5cab 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
@@ -98,7 +98,7 @@ final class EditReplay {
     private Phase askedFrom = Phase.PAUSED;
 
     EditReplay() {
-        this(System::currentTimeMillis);
+        this(MonotonicClock::millis);
     }
 
     /**
@@ -214,7 +214,7 @@ final class EditReplay {
         phase = Phase.FINISHED;
     }
 
-    /** Advances the replay; call on every UI tick. */
+    /** Advances the replay; call on every UI tick, with a time of the 
replay's clock (the {@link MonotonicClock}). */
     void tick(long now) {
         if (phase != Phase.TYPING) {
             return;
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HttpHelper.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HttpHelper.java
index dc036f3cde12..40ed56a24817 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HttpHelper.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HttpHelper.java
@@ -59,10 +59,10 @@ final class HttpHelper {
                 }
             }
 
-            long start = System.currentTimeMillis();
+            long start = MonotonicClock.millis();
             HttpResponse<String> response = client.send(reqBuilder.build(),
                     HttpResponse.BodyHandlers.ofString());
-            long elapsed = System.currentTimeMillis() - start;
+            long elapsed = MonotonicClock.millis() - start;
 
             int statusCode = response.statusCode();
 
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/InfraInfo.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/InfraInfo.java
index eb063c2f37c7..4abfa99633b5 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/InfraInfo.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/InfraInfo.java
@@ -27,5 +27,6 @@ class InfraInfo {
     Map<String, Object> properties = new TreeMap<>();
     boolean alive;
     boolean vanishing;
+    // when it was found gone, a MonotonicClock time: it fades out from then
     long vanishStart;
 }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationInfo.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationInfo.java
index 899da1619885..77743bb194db 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationInfo.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationInfo.java
@@ -37,7 +37,7 @@ class IntegrationInfo {
     String linkedPid;
     // a running app: the name of the project it was opened as, when it 
differs from the app's own name
     String openedAs;
-    // an opened project: when it was asked to run, until its app shows up (or 
the launch fails)
+    // an opened project: when it was asked to run (a MonotonicClock time), 
until its app shows up (or the launch fails)
     long startingSince;
     // the process F10 launched for an opened project: its app is that process 
or a descendant of it (CAMEL-25417)
     ProcessHandle launchedProcess;
@@ -91,6 +91,7 @@ class IntegrationInfo {
     int threadCount;
     int peakThreadCount;
     boolean vanishing;
+    // when it was found gone, a MonotonicClock time: it fades out from then
     long vanishStart;
     final List<RouteInfo> routes = new ArrayList<>();
     final List<ConsumerInfo> consumers = new ArrayList<>();
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationSummaryHints.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationSummaryHints.java
index 3972690dbb12..a9cc8e089f5f 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationSummaryHints.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationSummaryHints.java
@@ -137,7 +137,7 @@ final class IntegrationSummaryHints {
     }
 
     private static Entry entry(Path dir) {
-        long now = System.currentTimeMillis();
+        long now = MonotonicClock.millis();
         Entry e = CACHE.get(dir);
         if (e != null && now - e.checkedAt() < RECHECK_MS) {
             return e;
@@ -179,7 +179,7 @@ final class IntegrationSummaryHints {
 
     /** Whether the AI overview setting is not off, whatever the ai view 
setting. */
     static boolean settingEnabled() {
-        long now = System.currentTimeMillis();
+        long now = MonotonicClock.millis();
         if (now - modeCheckedAt > MODE_RECHECK_MS) {
             modeCheckedAt = now;
             enabled = 
!ProjectOverviewAssist.MODE_OFF.equals(ProjectOverviewAssist.mode());
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManager.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManager.java
index 0dded3a0874d..d79c01c88e39 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManager.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManager.java
@@ -188,7 +188,7 @@ class LaunchManager {
     }
 
     void addPendingLaunch(String name, Process process, Path outputFile) {
-        pendingLaunches.add(new PendingLaunch(name, process, outputFile, 
System.currentTimeMillis()));
+        pendingLaunches.add(new PendingLaunch(name, process, outputFile, 
MonotonicClock.millis()));
         pendingAutoSelect = name;
         launched.add(process);
     }
@@ -208,6 +208,7 @@ class LaunchManager {
         launched.clear();
     }
 
+    /** Watches the launches; now is a {@link MonotonicClock} time, as their 
start times. */
     void tick(long now) {
         monitorPendingLaunches(now);
         checkDeferredLaunch(now);
@@ -276,7 +277,7 @@ class LaunchManager {
         pb.redirectErrorStream(true);
         pb.redirectOutput(outputFile.toFile());
         Process process = pb.start();
-        PendingLaunch pl = new PendingLaunch(alias, process, outputFile, 
System.currentTimeMillis());
+        PendingLaunch pl = new PendingLaunch(alias, process, outputFile, 
MonotonicClock.millis());
         pl.infraAlias = serviceOf(alias);
         pendingLaunches.add(pl);
     }
@@ -292,7 +293,7 @@ class LaunchManager {
 
     /** Runs the launch once the given infra services are up, or drops it when 
they fail to start. */
     void deferUntilInfra(List<String> infra, String displayName, Runnable 
launchAction) {
-        deferredLaunch = new DeferredLaunch(displayName, infra, 
System.currentTimeMillis(), launchAction);
+        deferredLaunch = new DeferredLaunch(displayName, infra, 
MonotonicClock.millis(), launchAction);
     }
 
     void startMissingInfraAndDefer(List<String> missingInfra, String 
displayName, Runnable launchAction) {
@@ -620,6 +621,7 @@ class LaunchManager {
         final String name;
         final Process process;
         final Path outputFile;
+        // a MonotonicClock time
         final long startTime;
         // the service of an infra launch: it has started once the service is 
up; null for an integration
         String infraAlias;
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 941a18b7ba71..d1109eb60c6a 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
@@ -59,9 +59,15 @@ import static 
org.apache.camel.dsl.jbang.core.commands.tui.TuiHelper.hintLast;
 class McpFacade {
 
     /**
-     * Pending key event queued for injection into the TUI event loop.
+     * Pending key event queued for injection into the TUI event loop, by an 
MCP client or a tape. It fires on the first
+     * tick at or after fireAt, a {@link MonotonicClock} time.
      */
     record PendingKey(KeyEvent event, long fireAt) {
+
+        /** Whether the key fires at the given {@link MonotonicClock} time. */
+        boolean isDue(long now) {
+            return now >= fireAt;
+        }
     }
 
     /**
@@ -283,8 +289,8 @@ class McpFacade {
         }
         long before = done.getAsLong();
         request.run();
-        long deadline = System.currentTimeMillis() + FULL_SCAN_TIMEOUT_MS;
-        while (done.getAsLong() <= before && System.currentTimeMillis() < 
deadline) {
+        long deadline = MonotonicClock.millis() + FULL_SCAN_TIMEOUT_MS;
+        while (done.getAsLong() <= before && MonotonicClock.millis() < 
deadline) {
             try {
                 Thread.sleep(50);
             } catch (InterruptedException e) {
@@ -619,7 +625,7 @@ class McpFacade {
     // ---- Key injection ----
 
     int injectKeys(List<String> keys, int delayMs) {
-        long fireAt = System.currentTimeMillis();
+        long fireAt = MonotonicClock.millis();
         int count = 0;
         for (String key : keys) {
             KeyEvent ke = parseKey(key);
@@ -739,8 +745,8 @@ class McpFacade {
         if (data != null || !tab.ensureDataLoaded()) {
             return data;
         }
-        long deadline = System.currentTimeMillis() + timeoutMs;
-        while (data == null && System.currentTimeMillis() < deadline && 
tab.dataLoadError() == null) {
+        long deadline = MonotonicClock.millis() + timeoutMs;
+        while (data == null && MonotonicClock.millis() < deadline && 
tab.dataLoadError() == null) {
             try {
                 Thread.sleep(100);
             } catch (InterruptedException e) {
@@ -1348,18 +1354,18 @@ class McpFacade {
             }
         }
         if (writeMode == WriteMode.LIVE && exists && bridge != null) {
-            long waitStart = System.currentTimeMillis();
+            long waitStart = MonotonicClock.millis();
             ReplayOutcome outcome = bridge.replayFileWrite(request);
-            lastConfirmWaitMs = System.currentTimeMillis() - waitStart;
+            lastConfirmWaitMs = MonotonicClock.millis() - waitStart;
             if (outcome != null) {
                 return replayResult(target, dir, file, content, outcome);
             }
             // could not replay (for example unsaved edits in the editor): 
fall back to the dialog
         }
         if (confirm || writeMode != WriteMode.AUTO) {
-            long waitStart = System.currentTimeMillis();
+            long waitStart = MonotonicClock.millis();
             boolean confirmed = bridge != null && 
bridge.confirmFileWrite(request);
-            lastConfirmWaitMs = System.currentTimeMillis() - waitStart;
+            lastConfirmWaitMs = MonotonicClock.millis() - waitStart;
             if (!confirmed) {
                 JsonObject result = new JsonObject();
                 result.put("status", "rejected");
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MemoryLeakTab.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MemoryLeakTab.java
index c7b3d6835da1..e6625e1f6053 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MemoryLeakTab.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MemoryLeakTab.java
@@ -73,6 +73,7 @@ class MemoryLeakTab extends AbstractTab {
     private State state = State.IDLE;
     private RecordingMode recordingMode = RecordingMode.DUAL;
     private int duration = 60;
+    // the wall clock (epoch millis): the start of a recording may come from 
the integration's status
     private long recordingStartTime;
     private int currentRecordingDuration;
 
@@ -1521,8 +1522,8 @@ class MemoryLeakTab extends AbstractTab {
             return;
         }
         startDaemonThread("jfr-poll-" + pid, () -> {
-            long deadline = System.currentTimeMillis() + (dur + 30) * 1000L;
-            while (System.currentTimeMillis() < deadline) {
+            long deadline = MonotonicClock.millis() + (dur + 30) * 1000L;
+            while (MonotonicClock.millis() < deadline) {
                 try {
                     Thread.sleep(3000);
                 } catch (InterruptedException e) {
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MetricsCollector.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MetricsCollector.java
index 0df55e2b752e..18bbcd30847e 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MetricsCollector.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MetricsCollector.java
@@ -201,7 +201,7 @@ class MetricsCollector {
 
     void updateThroughputHistory(IntegrationInfo info) {
         long currentFailed = info.failed;
-        long now = System.currentTimeMillis();
+        long now = MonotonicClock.millis();
 
         String pid = info.pid;
         LinkedList<long[]> samples = throughputSamples.computeIfAbsent(pid, k 
-> new LinkedList<>());
@@ -260,7 +260,7 @@ class MetricsCollector {
                 .filter(ep -> "out".equals(ep.direction) && (ep.remote || 
ep.stub))
                 .mapToLong(ep -> ep.hits).sum();
 
-        long now = System.currentTimeMillis();
+        long now = MonotonicClock.millis();
         String pid = info.pid;
 
         recordEndpointSample(pid, now, inTotal, outTotal,
@@ -332,7 +332,7 @@ class MetricsCollector {
                 .filter(s -> "out".equals(s.direction))
                 .mapToLong(s -> s.hits).sum();
 
-        long now = System.currentTimeMillis();
+        long now = MonotonicClock.millis();
         recordEndpointSample(info.pid, now, inTotal, outTotal,
                 serviceSamples, previousServiceTime, serviceInHistory, 
serviceOutHistory);
 
@@ -406,7 +406,7 @@ class MetricsCollector {
     }
 
     void updateCbHistory(IntegrationInfo info) {
-        long now = System.currentTimeMillis();
+        long now = MonotonicClock.millis();
         for (CircuitBreakerInfo cb : info.circuitBreakers) {
             if (cb.id == null) {
                 continue;
@@ -423,7 +423,7 @@ class MetricsCollector {
 
     void updateHeapHistory(IntegrationInfo info) {
         if (info.heapMemUsed > 0) {
-            long now = System.currentTimeMillis();
+            long now = MonotonicClock.millis();
             Long lastTime = previousHeapTime.get(info.pid);
             if (lastTime == null || now - lastTime >= HEAP_SAMPLE_INTERVAL_MS) 
{
                 previousHeapTime.put(info.pid, now);
@@ -438,17 +438,17 @@ class MetricsCollector {
         Optional<Duration> durOpt = ph.info().totalCpuDuration();
         if (durOpt.isPresent()) {
             long cpuNanos = durOpt.get().toNanos();
-            long wallMs = System.currentTimeMillis();
+            long nowMs = MonotonicClock.millis();
             long[] prev = prevCpuSample.get(pid);
             if (prev != null) {
                 long deltaCpuNanos = cpuNanos - prev[0];
-                long deltaWallNanos = (wallMs - prev[1]) * 1_000_000L;
+                long deltaWallNanos = (nowMs - prev[1]) * 1_000_000L;
                 if (deltaWallNanos > 0) {
                     double cpuPct = (double) deltaCpuNanos / deltaWallNanos * 
100.0;
                     cpuLoadAvg.computeIfAbsent(pid, k -> new 
LoadAvg()).update(Math.max(0, cpuPct));
                 }
             }
-            prevCpuSample.put(pid, new long[] { cpuNanos, wallMs });
+            prevCpuSample.put(pid, new long[] { cpuNanos, nowMs });
         }
     }
 
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 1a422ca3c3bb..de03b305970e 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
@@ -202,7 +202,7 @@ class MonitorContext {
         Object lock = actionLocks.computeIfAbsent(pid, k -> new Object());
         synchronized (lock) {
             CachedAnswer cached = jvmInfo.get(pid);
-            long now = System.currentTimeMillis();
+            long now = MonotonicClock.millis();
             if (cached != null && now - cached.time() < JVM_INFO_TTL_MS) {
                 return cached.answer();
             }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonotonicClock.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonotonicClock.java
new file mode 100644
index 000000000000..b0d4ab15e663
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonotonicClock.java
@@ -0,0 +1,43 @@
+/*
+ * 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.concurrent.TimeUnit;
+
+/**
+ * The clock of the TUI's timers, deadlines and animations: milliseconds that 
only move forward, read from
+ * {@link System#nanoTime()}. The wall clock ({@link 
System#currentTimeMillis()}) can be set back or forward (an NTP
+ * step, a manual change, a VM resume), and a timer measured with it then 
freezes until the clock catches up, or fires
+ * at once.
+ * <p/>
+ * A time of this clock is no time of day: compare it only with another time 
of this clock, never with an epoch time
+ * from the wall clock, a file or another process.
+ */
+final class MonotonicClock {
+
+    // the clock starts a day in, so a time still at 0 (never set) reads as 
long ago, as it does on the wall clock
+    private static final long ORIGIN = System.nanoTime() - 
TimeUnit.DAYS.toNanos(1);
+
+    private MonotonicClock() {
+    }
+
+    /** The time in milliseconds: at least a day, and never less than a time 
read before. */
+    static long millis() {
+        // nanoTime is compared only by its difference, which is right even 
when its value overflows
+        return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - ORIGIN);
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitor.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitor.java
index 4c42db936cca..31a5015ae815 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitor.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitor.java
@@ -373,12 +373,13 @@ final class OllamaMonitor {
     /**
      * The AI panel question being answered right now, so the tab can list it 
from the moment it was asked: before the
      * first request returns there is no {@link RequestEntry} for it, and 
while tools run its last step says
-     * {@code tool_calls} like a finished question's would.
+     * {@code tool_calls} like a finished question's would. It was asked at 
startedAt, the time of day shown, and at
+     * monotonicStart, the {@link MonotonicClock} time it is timed from.
      */
-    record ActiveQuestion(int question, String text, Instant startedAt) {
+    record ActiveQuestion(int question, String text, Instant startedAt, long 
monotonicStart) {
 
         long elapsedMs() {
-            return Math.max(0, System.currentTimeMillis() - 
startedAt.toEpochMilli());
+            return Math.max(0, MonotonicClock.millis() - monotonicStart);
         }
 
         boolean matches(QuestionGroup g) {
@@ -498,6 +499,7 @@ final class OllamaMonitor {
     private int panelBudget;
     private ActiveQuestion activeQuestion;
 
+    // MonotonicClock times of the last look at each source; the rate windows 
and CPU samples are on this clock too
     private long lastProbe;
     private long lastVersion;
     private long lastPs;
@@ -529,7 +531,7 @@ final class OllamaMonitor {
             return;
         }
         try {
-            long now = System.currentTimeMillis();
+            long now = MonotonicClock.millis();
             if (now - lastProbe >= DETECT_INTERVAL_MS) {
                 lastProbe = now;
                 lastVersion = now;
@@ -677,7 +679,7 @@ final class OllamaMonitor {
 
     /** True once every few seconds: the GenAI spans of a route are worth 
re-reading. */
     boolean wantsSpans() {
-        long now = System.currentTimeMillis();
+        long now = MonotonicClock.millis();
         if (now - lastSpanIngest >= SPAN_INTERVAL_MS) {
             lastSpanIngest = now;
             return true;
@@ -696,7 +698,7 @@ final class OllamaMonitor {
     /** The AI panel started working on a question; it is listed as in 
progress until {@link #questionFinished()}. */
     void questionStarted(int question, String text) {
         synchronized (lock) {
-            activeQuestion = new ActiveQuestion(question, text, Instant.now());
+            activeQuestion = new ActiveQuestion(question, text, Instant.now(), 
MonotonicClock.millis());
         }
     }
 
@@ -784,12 +786,16 @@ final class OllamaMonitor {
 
     /** Applies a runner slot sample; the decode and prefill rate windows 
advance from its counters. */
     void updateSlot(SlotState state) {
+        updateSlot(state, MonotonicClock.millis());
+    }
+
+    /** Applies a runner slot sample read at the given {@link MonotonicClock} 
time. */
+    void updateSlot(SlotState state, long readAt) {
         synchronized (lock) {
             slot = state;
             if (state != null) {
-                long now = state.sampledAt() != null ? 
state.sampledAt().toEpochMilli() : System.currentTimeMillis();
-                decodeWindow.sample(now, state.decoded());
-                prefillWindow.sample(now, state.promptProcessed());
+                decodeWindow.sample(readAt, state.decoded());
+                prefillWindow.sample(readAt, state.promptProcessed());
             }
         }
     }
@@ -800,19 +806,22 @@ final class OllamaMonitor {
         }
     }
 
-    /** Ends one poll: appends the current live decode rate to the history and 
stamps the poll time. */
+    /**
+     * Ends one poll: appends the current live decode rate to the history and 
stamps the poll time (of day). Now is a
+     * {@link MonotonicClock} time.
+     */
     void tick(long nowMillis) {
         synchronized (lock) {
             long rate = Math.round(decodeWindow.ratePerSecond(nowMillis));
             System.arraycopy(decodeHistory, 1, decodeHistory, 0, 
decodeHistory.length - 1);
             decodeHistory[decodeHistory.length - 1] = rate;
-            lastPoll = Instant.ofEpochMilli(nowMillis);
+            lastPoll = Instant.now();
         }
     }
 
     Snapshot snapshot() {
         synchronized (lock) {
-            long now = System.currentTimeMillis();
+            long now = MonotonicClock.millis();
             return new Snapshot(
                     server, models, installed, slot, runner, host, 
List.copyOf(requests),
                     decodeWindow.ratePerSecond(now), 
prefillWindow.ratePerSecond(now),
@@ -844,7 +853,7 @@ final class OllamaMonitor {
             return;
         }
         try {
-            doPoll(System.currentTimeMillis());
+            doPoll(MonotonicClock.millis());
         } catch (Exception e) {
             LOG.debug("Ollama poll failed", e);
         } finally {
@@ -945,7 +954,7 @@ final class OllamaMonitor {
                 lastSlotPoll = now;
                 JsonArray slots = getJsonArray("http://127.0.0.1:"; + 
current.port() + "/slots");
                 if (slots != null) {
-                    updateSlot(OllamaParsers.parseSlots(slots, 
Instant.ofEpochMilli(now)));
+                    updateSlot(OllamaParsers.parseSlots(slots, Instant.now()));
                 } else {
                     updateRunner(null);
                     current = null;
@@ -1064,7 +1073,7 @@ final class OllamaMonitor {
         if (gpu == null && serverStats == null && runnerStats == null) {
             return null;
         }
-        return new HostStats(gpu, serverStats, runnerStats, 
Instant.ofEpochMilli(now));
+        return new HostStats(gpu, serverStats, runnerStats, Instant.now());
     }
 
     private GpuStats probeGpu() {
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaTab.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaTab.java
index 1e5878154455..d1d211040088 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaTab.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaTab.java
@@ -642,7 +642,7 @@ class OllamaTab extends AbstractTab {
     private static final String[] SPINNER = { "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", 
"⠦", "⠧", "⠇", "⠏" };
 
     private static Span workingSpan() {
-        String frame = SPINNER[(int) ((System.currentTimeMillis() / 100) % 
SPINNER.length)];
+        String frame = SPINNER[(int) ((MonotonicClock.millis() / 100) % 
SPINNER.length)];
         return Span.styled(" " + frame + " working", Theme.info());
     }
 
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java
index f7d566650535..50dbd260e8d8 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java
@@ -405,7 +405,7 @@ class OverviewTab extends AbstractTab {
             Style rowBg = isEven ? Style.EMPTY.bg(Theme.zebra()) : Style.EMPTY;
 
             if (info.vanishing) {
-                long elapsed = System.currentTimeMillis() - info.vanishStart;
+                long elapsed = MonotonicClock.millis() - info.vanishStart;
                 float fade = 1.0f - Math.min(1.0f, (float) elapsed / 
VANISH_DURATION_MS);
                 int gray = (int) (100 * fade);
                 Style dimStyle = Style.EMPTY.fg(Color.indexed(232 + 
Math.min(gray / 4, 23)));
@@ -555,7 +555,7 @@ class OverviewTab extends AbstractTab {
                         Cell.from(Span.styled(TuiHelper.truncate(nameText, 
nameWidth), Theme.info())),
                         Cell.from(Span.styled("", Theme.muted())),
                         Cell.from(Span.styled("", Theme.muted())),
-                        Cell.from(projectStatus(info, 
System.currentTimeMillis())),
+                        Cell.from(projectStatus(info, 
MonotonicClock.millis())),
                         Cell.from(Span.styled("", Theme.muted())),
                         Cell.from(Span.styled("", Theme.muted())),
                         Cell.from(Span.styled("", Theme.muted())),
@@ -563,7 +563,7 @@ class OverviewTab extends AbstractTab {
                         Cell.from(Span.styled("", Theme.muted())),
                         Cell.from(Span.styled("", 
Theme.muted()))).style(rowBg));
             } else if (info.vanishing) {
-                long elapsed = System.currentTimeMillis() - info.vanishStart;
+                long elapsed = MonotonicClock.millis() - info.vanishStart;
                 float fade = 1.0f - Math.min(1.0f, (float) elapsed / 
VANISH_DURATION_MS);
                 int gray = (int) (100 * fade);
                 Style dimStyle = Style.EMPTY.fg(Color.indexed(232 + 
Math.min(gray / 4, 23)));
@@ -915,7 +915,10 @@ class OverviewTab extends AbstractTab {
     /** How long an opened project shows as Starting when its app does not 
show up. */
     static final long PROJECT_START_MS = 5 * 60_000;
 
-    /** The status of an opened project: Starting while it was asked to run 
and its app is not up yet, else Stopped. */
+    /**
+     * The status of an opened project: Starting while it was asked to run and 
its app is not up yet, else Stopped. Now
+     * is a {@link MonotonicClock} time, as the time it was asked to run.
+     */
     static Span projectStatus(IntegrationInfo project, long now) {
         if (project.startingSince > 0 && now - project.startingSince < 
PROJECT_START_MS) {
             return Span.styled(TuiIcons.GEAR + " Starting", Theme.warning());
@@ -942,7 +945,7 @@ class OverviewTab extends AbstractTab {
                     Span.raw(TuiHelper.truncateStart(project.sourceDir, 
Math.max(1, width - 9)))));
         }
         lines.add(Line.from(Span.raw("")));
-        lines.add(Line.from(projectStatus(project, 
System.currentTimeMillis())));
+        lines.add(Line.from(projectStatus(project, MonotonicClock.millis())));
         lines.add(Line.from(Span.raw("")));
         if (project.startingSince == 0) {
             lines.add(Line.from(Span.styled(" F10 ", Theme.hintKey()), 
Span.styled(" runs it", dim)));
@@ -1151,7 +1154,7 @@ class OverviewTab extends AbstractTab {
             Style statusStyle = info.alive ? Theme.success() : Theme.error();
 
             if (info.vanishing) {
-                long elapsed = System.currentTimeMillis() - info.vanishStart;
+                long elapsed = MonotonicClock.millis() - info.vanishStart;
                 float fade = 1.0f - Math.min(1.0f, (float) elapsed / 
VANISH_DURATION_MS);
                 int gray = (int) (100 * fade);
                 Style dimStyle = Style.EMPTY.fg(Color.indexed(232 + 
Math.min(gray / 4, 23)));
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ProjectOverviewAssist.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ProjectOverviewAssist.java
index 6a9f5b2616cd..a6e4a1e3fc61 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ProjectOverviewAssist.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ProjectOverviewAssist.java
@@ -181,9 +181,9 @@ final class ProjectOverviewAssist {
 
     /** Waits until {@code idle} holds, checking a few times a second; false 
when it never did in time. */
     static boolean awaitIdle(BooleanSupplier idle, long maxWaitMs) {
-        long until = System.currentTimeMillis() + maxWaitMs;
+        long until = MonotonicClock.millis() + maxWaitMs;
         while (!idle.getAsBoolean()) {
-            if (System.currentTimeMillis() >= until) {
+            if (MonotonicClock.millis() >= until) {
                 return false;
             }
             try {
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RecordingManager.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RecordingManager.java
index 383a77bcea6c..0c7dd4b7f76d 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RecordingManager.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RecordingManager.java
@@ -36,7 +36,8 @@ import dev.tamboui.tui.event.KeyEvent;
  */
 class RecordingManager {
 
-    record KeyRecord(String label, long timestamp) {
+    /** A key shown while recording; pressedAt is a {@link MonotonicClock} 
time. */
+    record KeyRecord(String label, long pressedAt) {
     }
 
     private final CaptionOverlay captionOverlay;
@@ -70,7 +71,7 @@ class RecordingManager {
                 eventLog.record(label, label);
             }
             if (recording) {
-                recentKeys.add(new KeyRecord(label, 
System.currentTimeMillis()));
+                recentKeys.add(new KeyRecord(label, MonotonicClock.millis()));
             }
             TapeRecorder tr = tapeRecorderRef.get();
             if (tr != null && tr.isActive() && !mcpInjected) {
@@ -119,10 +120,10 @@ class RecordingManager {
             ExportRequest.export(buf).text().toFile(txtPath);
             ExportRequest.export(buf).text().options(o -> 
o.styles(true)).toFile(ansPath);
             screenshotMessage = "Screenshot saved to " + 
svgPath.toAbsolutePath() + " (and .txt, .ans)";
-            screenshotMessageTime = System.currentTimeMillis();
+            screenshotMessageTime = MonotonicClock.millis();
         } catch (IOException e) {
             screenshotMessage = "Screenshot failed: " + e.getMessage();
-            screenshotMessageTime = System.currentTimeMillis();
+            screenshotMessageTime = MonotonicClock.millis();
         }
     }
 
@@ -150,7 +151,7 @@ class RecordingManager {
 
     String screenshotFlashMessage() {
         String msg = screenshotMessage;
-        if (msg != null && System.currentTimeMillis() - screenshotMessageTime 
< 5000) {
+        if (msg != null && MonotonicClock.millis() - screenshotMessageTime < 
5000) {
             return msg;
         }
         screenshotMessage = null;
@@ -234,10 +235,11 @@ class RecordingManager {
         return recentKeys;
     }
 
+    /** Drops the keys shown for two seconds; now is a {@link MonotonicClock} 
time. */
     void tickRecentKeys(long now) {
         if (recording && !recentKeys.isEmpty()) {
             long cutoff = now - 2000;
-            recentKeys.removeIf(k -> k.timestamp() < cutoff);
+            recentKeys.removeIf(k -> k.pressedAt() < cutoff);
         }
     }
 
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteGroups.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteGroups.java
index 6e95a72ad36a..eac7eee00008 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteGroups.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteGroups.java
@@ -71,15 +71,15 @@ final class RouteGroups {
         }
         Path key = dir.toAbsolutePath().normalize();
         Entry e = CACHE.get(key);
-        if ((e == null || System.currentTimeMillis() - e.checkedAt() > 
RECHECK_MS) && LOADING.add(key)) {
+        if ((e == null || MonotonicClock.millis() - e.checkedAt() > 
RECHECK_MS) && LOADING.add(key)) {
             Thread t = new Thread(() -> {
                 try {
                     ProjectOverview.Overview overview = 
ProjectOverview.analyze(key, ProjectOverviewAssist.catalog());
                     CACHE.put(key, new Entry(
-                            System.currentTimeMillis(), tags(overview, 
IntegrationSummary.read(key)),
+                            MonotonicClock.millis(), tags(overview, 
IntegrationSummary.read(key)),
                             tags(overview, null)));
                 } catch (RuntimeException ex) {
-                    CACHE.put(key, new Entry(System.currentTimeMillis(), 
Map.of(), Map.of()));
+                    CACHE.put(key, new Entry(MonotonicClock.millis(), 
Map.of(), Map.of()));
                 } finally {
                     LOADING.remove(key);
                 }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteKeys.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteKeys.java
index f5dd3ea22008..a86c6730005f 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteKeys.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteKeys.java
@@ -76,15 +76,15 @@ final class RouteKeys {
         List<Running> running = RUNNING.getOrDefault(key, List.of());
         Entry e = CACHE.get(key);
         boolean stale = e == null || !e.running().equals(running)
-                || System.currentTimeMillis() - e.checkedAt() > RECHECK_MS;
+                || MonotonicClock.millis() - e.checkedAt() > RECHECK_MS;
         if (stale && !running.isEmpty() && LOADING.add(key)) {
             Thread t = new Thread(() -> {
                 try {
                     CamelCatalog catalog = ProjectOverviewAssist.catalog();
                     ProjectOverview.Overview o = ProjectOverview.analyze(key, 
catalog);
-                    CACHE.put(key, new Entry(System.currentTimeMillis(), 
running, match(o, running, catalog)));
+                    CACHE.put(key, new Entry(MonotonicClock.millis(), running, 
match(o, running, catalog)));
                 } catch (RuntimeException ex) {
-                    CACHE.put(key, new Entry(System.currentTimeMillis(), 
running, Map.of()));
+                    CACHE.put(key, new Entry(MonotonicClock.millis(), running, 
Map.of()));
                 } finally {
                     LOADING.remove(key);
                 }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RunOptionsForm.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RunOptionsForm.java
index 4d70ba9fa0ff..b562236e6a57 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RunOptionsForm.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RunOptionsForm.java
@@ -547,7 +547,7 @@ class RunOptionsForm {
     private void renderOptionsPage(Frame frame, Rect area) {
         // wide enough for the runtime cycler to show all runtimes (Camel 
Main, Spring Boot, Quarkus, JBang)
         int popupW = Math.min(80, area.width() - 4);
-        String warning = portWarning(System.currentTimeMillis());
+        String warning = portWarning(MonotonicClock.millis());
         int popupH = PAGE1_HEIGHT + (errorMessage != null ? 1 : 0) + (warning 
!= null ? 1 : 0);
         Rect popup = DialogHelper.centered(area, popupW, popupH);
 
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditAssist.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditAssist.java
index aafa6fa6e42e..c421f9ab93f9 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditAssist.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditAssist.java
@@ -1542,7 +1542,7 @@ final class SourceEditAssist {
             return List.of();
         }
 
-        long now = System.currentTimeMillis();
+        long now = MonotonicClock.millis();
         if (placeholderCache != null && rootDir.equals(placeholderCacheDir) && 
(now - placeholderCacheTime) < 5000) {
             return placeholderCache;
         }
@@ -1950,7 +1950,7 @@ final class SourceEditAssist {
 
     /** The Java sources of the project, for the constants a route refers to 
in another class; kept for a while. */
     private Map<String, Supplier<String>> javaSources() {
-        long now = System.currentTimeMillis();
+        long now = MonotonicClock.millis();
         if (javaSourcesCache == null || now - javaSourcesCacheTime > 
JAVA_SOURCES_TTL_MS
                 || !Objects.equals(javaSourcesCacheDir, rootDir)) {
             javaSourcesCache = RouteAssist.javaSources(rootDir);
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 1b5f6b89a8db..9b7a302843bb 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
@@ -1582,7 +1582,7 @@ class SourceTab extends AbstractTab {
 
     /** The beans and classes the project declares, read again when a file of 
the project changed. */
     ProjectBeans projectBeans() {
-        long now = System.currentTimeMillis();
+        long now = MonotonicClock.millis();
         if (now - projectBeansChecked < 2000) {
             // the quick doc asks on every frame: look at the files at most 
every 2 seconds
             return projectBeans;
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 64956d5f4689..1df5455c890b 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
@@ -444,7 +444,7 @@ class SourceViewer {
         if (liveRunData == null || loadedFilePath == null) {
             return Collections.emptyMap();
         }
-        long now = System.currentTimeMillis();
+        long now = MonotonicClock.millis();
         if (now - liveLinesTime > 1000) {
             refreshLiveLines(now);
         }
@@ -453,7 +453,7 @@ class SourceViewer {
 
     /** Package-private for tests: reads the live run data again now, as the 
render does once a second. */
     void refreshLiveLinesForTesting() {
-        refreshLiveLines(System.currentTimeMillis());
+        refreshLiveLines(MonotonicClock.millis());
     }
 
     /** Package-private for tests: the text of the live column of a line. */
@@ -2362,7 +2362,7 @@ class SourceViewer {
         if (!dirty || validationErrors != null) {
             return;
         }
-        long now = System.currentTimeMillis();
+        long now = MonotonicClock.millis();
         if (now - lastBackgroundValidationTime < 
BACKGROUND_VALIDATION_INTERVAL_MS) {
             return;
         }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TapeRecorder.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TapeRecorder.java
index 08a72553ef33..e825e08b11c1 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TapeRecorder.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TapeRecorder.java
@@ -29,6 +29,7 @@ class TapeRecorder {
             "pgup", "pgdn");
 
     private final List<String> lines = new ArrayList<>();
+    // MonotonicClock times: the sleeps between the recorded events
     private long startTime;
     private long lastEventTime;
     private int keyCount;
@@ -47,7 +48,7 @@ class TapeRecorder {
         lines.add("Set CursorBlink false");
         lines.add("Set Theme \"Aardvark Blue\"");
         lines.add("");
-        startTime = System.currentTimeMillis();
+        startTime = MonotonicClock.millis();
         lastEventTime = startTime;
         keyCount = 0;
         active = true;
@@ -58,7 +59,7 @@ class TapeRecorder {
             return;
         }
         preKeyPosition = lines.size();
-        long now = System.currentTimeMillis();
+        long now = MonotonicClock.millis();
         long elapsed = now - lastEventTime;
         if (elapsed > 200) {
             lines.add("Sleep " + formatSleep(elapsed));
@@ -80,7 +81,7 @@ class TapeRecorder {
     void trimTo(int position) {
         if (position >= 0 && position < lines.size()) {
             lines.subList(position, lines.size()).clear();
-            lastEventTime = System.currentTimeMillis();
+            lastEventTime = MonotonicClock.millis();
         }
     }
 
@@ -88,7 +89,7 @@ class TapeRecorder {
         if (!active) {
             return;
         }
-        long now = System.currentTimeMillis();
+        long now = MonotonicClock.millis();
         long elapsed = now - lastEventTime;
         if (elapsed > 200) {
             lines.add("Sleep " + formatSleep(elapsed));
@@ -120,7 +121,7 @@ class TapeRecorder {
             return;
         }
         lines.add("Sleep " + formatSleep(ms));
-        lastEventTime = System.currentTimeMillis();
+        lastEventTime = MonotonicClock.millis();
     }
 
     void recordCaption(String text, int durationSeconds) {
@@ -138,14 +139,14 @@ class TapeRecorder {
         if (durationSeconds > 0) {
             lines.add("Sleep " + durationSeconds + "s");
         }
-        lastEventTime = System.currentTimeMillis();
+        lastEventTime = MonotonicClock.millis();
     }
 
     void resetClock() {
         if (!active) {
             return;
         }
-        lastEventTime = System.currentTimeMillis();
+        lastEventTime = MonotonicClock.millis();
     }
 
     String stop() {
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ThreadsTab.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ThreadsTab.java
index 15b93e9c7e1a..376e391aaa99 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ThreadsTab.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ThreadsTab.java
@@ -149,7 +149,7 @@ class ThreadsTab extends AbstractTableTab {
 
     @Override
     protected void renderContent(Frame frame, Rect area, IntegrationInfo info) 
{
-        long now = System.currentTimeMillis();
+        long now = MonotonicClock.millis();
         if (!allThreads.isEmpty() && now - lastRefreshTime >= 
AUTO_REFRESH_INTERVAL_MS) {
             loadThreads();
         }
@@ -426,7 +426,7 @@ class ThreadsTab extends AbstractTableTab {
                 threadCount = tc;
                 peakThreadCount = peak;
                 lastPid = pid;
-                lastRefreshTime = System.currentTimeMillis();
+                lastRefreshTime = MonotonicClock.millis();
             });
         }
     }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelper.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelper.java
index 0b89c26f32f2..ce4d9bf8884d 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelper.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelper.java
@@ -657,8 +657,8 @@ final class TuiHelper {
     };
 
     static JsonObject pollJsonResponse(Path outputFile, long timeout) {
-        long start = System.currentTimeMillis();
-        while (System.currentTimeMillis() - start < timeout) {
+        long start = MonotonicClock.millis();
+        while (MonotonicClock.millis() - start < timeout) {
             try {
                 Thread.sleep(100);
                 if (Files.exists(outputFile) && outputFile.toFile().length() > 
0) {
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
index 82b3e279469d..0fa62c2479e4 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
@@ -134,7 +134,7 @@ class TuiMcpServer {
     }
 
     boolean isRecentActivity() {
-        return System.currentTimeMillis() - lastToolCallTime < 2000;
+        return MonotonicClock.millis() - lastToolCallTime < 2000;
     }
 
     int getToolCallCount() {
@@ -146,7 +146,7 @@ class TuiMcpServer {
      * client that never says initialize, one that sent a request in the last 
minute. Null when there is none.
      */
     String getConnectedClient() {
-        if (sessionOpen || System.currentTimeMillis() - lastActivity < 
CLIENT_TIMEOUT_MS) {
+        if (sessionOpen || MonotonicClock.millis() - lastActivity < 
CLIENT_TIMEOUT_MS) {
             return clientName != null ? clientName : "connected";
         }
         return null;
@@ -154,12 +154,12 @@ class TuiMcpServer {
 
     /** For tests: as if the last request came that many milliseconds ago. */
     void idleFor(long millis) {
-        lastActivity = System.currentTimeMillis() - millis;
+        lastActivity = MonotonicClock.millis() - millis;
     }
 
     private void handleMcp(HttpExchange exchange) throws IOException {
         try {
-            lastActivity = System.currentTimeMillis();
+            lastActivity = MonotonicClock.millis();
             String method = exchange.getRequestMethod();
             if ("DELETE".equals(method)) {
                 // the client closes its session: it is gone
@@ -527,7 +527,7 @@ class TuiMcpServer {
             args = Map.of();
         }
 
-        lastToolCallTime = System.currentTimeMillis();
+        lastToolCallTime = MonotonicClock.millis();
         toolCallCount.incrementAndGet();
 
         String text;
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 83010b29f77a..db43992f1617 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
@@ -503,8 +503,8 @@ class TuiToolRegistry {
         }
 
         long beforeGen = facade.getRenderGeneration();
-        long deadline = System.currentTimeMillis() + 2000;
-        while (System.currentTimeMillis() < deadline) {
+        long deadline = MonotonicClock.millis() + 2000;
+        while (MonotonicClock.millis() < deadline) {
             if (facade.getRenderGeneration() >= beforeGen + 2) {
                 break;
             }
@@ -569,12 +569,12 @@ class TuiToolRegistry {
             return "Queued " + sent + " key(s) with " + delay + "ms delay";
         }
 
-        long lastKeyFireAt = System.currentTimeMillis() + (long) (sent - 1) * 
delay;
+        long lastKeyFireAt = MonotonicClock.millis() + (long) (sent - 1) * 
delay;
         long waitDeadline = lastKeyFireAt + 5000;
-        long start = System.currentTimeMillis();
+        long start = MonotonicClock.millis();
 
-        while (System.currentTimeMillis() < waitDeadline) {
-            long now = System.currentTimeMillis();
+        while (MonotonicClock.millis() < waitDeadline) {
+            long now = MonotonicClock.millis();
             if (now >= lastKeyFireAt) {
                 long gen = facade.getRenderGeneration();
                 if (gen >= beforeGen + sent + 2) {
@@ -593,7 +593,7 @@ class TuiToolRegistry {
         JsonObject result = new JsonObject();
         result.put("sent", sent);
         result.put("delay", delay);
-        result.put("waitedMs", System.currentTimeMillis() - start);
+        result.put("waitedMs", MonotonicClock.millis() - start);
         if (buf != null) {
             result.put("screen", ExportRequest.export(buf).text().toString());
         }
@@ -655,16 +655,16 @@ class TuiToolRegistry {
         }
 
         long startGeneration = facade.getRenderGeneration();
-        long start = System.currentTimeMillis();
+        long start = MonotonicClock.millis();
         long deadline = start + timeout;
 
-        while (System.currentTimeMillis() < deadline) {
+        while (MonotonicClock.millis() < deadline) {
             long current = facade.getRenderGeneration();
             if (current >= startGeneration + requiredFrames) {
                 Buffer buf = facade.getLastBuffer();
                 JsonObject result = new JsonObject();
                 result.put("settled", true);
-                result.put("waitedMs", System.currentTimeMillis() - start);
+                result.put("waitedMs", MonotonicClock.millis() - start);
                 result.put("frames", current - startGeneration);
                 if (buf != null) {
                     result.put("screen", 
ExportRequest.export(buf).text().toString());
@@ -682,7 +682,7 @@ class TuiToolRegistry {
 
         JsonObject result = new JsonObject();
         result.put("settled", false);
-        result.put("waitedMs", System.currentTimeMillis() - start);
+        result.put("waitedMs", MonotonicClock.millis() - start);
         result.put("reason", "timeout");
         return Jsoner.serialize(result);
     }
@@ -1111,9 +1111,9 @@ class TuiToolRegistry {
 
     /** Calls the lookup until it finds something or the time is up; the last 
answer (null when not found). */
     static String retryUntilFound(Supplier<String> lookup, BooleanSupplier 
settled, long maxWaitMs) {
-        long until = System.currentTimeMillis() + maxWaitMs;
+        long until = MonotonicClock.millis() + maxWaitMs;
         String found = lookup.get();
-        while (found == null && !settled.getAsBoolean() && 
System.currentTimeMillis() < until) {
+        while (found == null && !settled.getAsBoolean() && 
MonotonicClock.millis() < until) {
             try {
                 Thread.sleep(100);
             } catch (InterruptedException e) {
@@ -1973,8 +1973,8 @@ class TuiToolRegistry {
         // an example can fail a few seconds after it is launched (a missing 
API key, a bad route): wait for Camel to
         // say it started, or for the failure, so the agent learns how it went
         LaunchManager.LaunchOutcome outcome = null;
-        long deadline = System.currentTimeMillis() + RUN_EXAMPLE_WAIT_MS;
-        while ((outcome = lm.outcome(name)) == null && 
System.currentTimeMillis() < deadline) {
+        long deadline = MonotonicClock.millis() + RUN_EXAMPLE_WAIT_MS;
+        while ((outcome = lm.outcome(name)) == null && MonotonicClock.millis() 
< deadline) {
             Thread.sleep(250);
         }
         JsonObject result = new JsonObject();
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 9aba88c5c5ee..e3cef1f7aca9 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
@@ -71,13 +71,13 @@ class AiPanelTest {
     @Test
     void thePanelIsIdleOnlyWhenNothingIsTypedOrAsked() {
         AiPanel panel = new AiPanel();
-        long now = System.currentTimeMillis();
+        long now = MonotonicClock.millis();
         assertTrue(panel.idleFor(AiPanel.OVERVIEW_IDLE_MS, now), "nothing 
typed yet");
 
         type(panel, "why");
-        assertFalse(panel.idleFor(AiPanel.OVERVIEW_IDLE_MS, 
System.currentTimeMillis()), "typing a question");
+        assertFalse(panel.idleFor(AiPanel.OVERVIEW_IDLE_MS, 
MonotonicClock.millis()), "typing a question");
         // still typed, even long after the last key
-        assertFalse(panel.idleFor(AiPanel.OVERVIEW_IDLE_MS, 
System.currentTimeMillis() + 60_000));
+        assertFalse(panel.idleFor(AiPanel.OVERVIEW_IDLE_MS, 
MonotonicClock.millis() + 60_000));
     }
 
     @Test
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManagerInfraTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManagerInfraTest.java
index 583e079227f5..7bd8a8bcc9ad 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManagerInfraTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManagerInfraTest.java
@@ -18,12 +18,14 @@ package org.apache.camel.dsl.jbang.core.commands.tui;
 
 import java.util.ArrayList;
 import java.util.List;
+import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
 
 import org.apache.camel.util.json.JsonObject;
 import org.junit.jupiter.api.Test;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
 
 /**
  * The infra services an example needs may name the implementation too ({@code 
aws sqs}): it is started with both, and
@@ -60,12 +62,12 @@ class LaunchManagerInfraTest {
         lm.launchInfra("kafka", List.of("sleep", "30"));
         lm.deferUntilInfra(List.of("kafka"), "kafka-orders", () -> 
launched.set(true));
 
-        lm.tick(System.currentTimeMillis());
+        lm.tick(MonotonicClock.millis());
         assertThat(launched).isFalse();
         assertThat(notices).isEmpty();
 
         running.add(infra("kafka"));
-        lm.tick(System.currentTimeMillis());
+        lm.tick(MonotonicClock.millis());
         assertThat(launched).isTrue();
         assertThat(notices).contains("Started: kafka");
         ProcessHandle.current().children()
@@ -80,11 +82,11 @@ class LaunchManagerInfraTest {
         lm.launchInfra("aws sqs", List.of("sleep", "30"));
         lm.deferUntilInfra(List.of("aws sqs"), "aws-sqs", () -> 
launched.set(true));
 
-        lm.tick(System.currentTimeMillis());
+        lm.tick(MonotonicClock.millis());
         assertThat(launched).isFalse();
 
         running.add(infra("aws"));
-        lm.tick(System.currentTimeMillis());
+        lm.tick(MonotonicClock.millis());
         assertThat(launched).isTrue();
         ProcessHandle.current().children()
                 .filter(p -> p.info().command().map(c -> 
c.endsWith("sleep")).orElse(false))
@@ -101,11 +103,10 @@ class LaunchManagerInfraTest {
         // the container cannot bind its port: camel infra run ends with an 
error
         lm.launchInfra("mosquitto", List.of("sh", "-c", "echo port is already 
allocated; exit 1"));
         lm.deferUntilInfra(List.of("mosquitto"), "mqtt", () -> 
launched.set(true));
-        long deadline = System.currentTimeMillis() + 10_000;
-        while (failures.isEmpty() && System.currentTimeMillis() < deadline) {
-            lm.tick(System.currentTimeMillis());
-            Thread.sleep(50);
-        }
+        await().atMost(10, TimeUnit.SECONDS).pollInterval(50, 
TimeUnit.MILLISECONDS).pollInSameThread().until(() -> {
+            lm.tick(MonotonicClock.millis());
+            return !failures.isEmpty();
+        });
 
         assertThat(launched).isFalse();
         assertThat(failures).containsExactly("mosquitto");
@@ -138,15 +139,14 @@ class LaunchManagerInfraTest {
         lm.setNotificationCallback((msg, error) -> notices.add(msg));
         lm.launchInfra("ollama", List.of("sh", "-c",
                 "echo 'Pulling image layers:  3 pending,  2 downloaded,  0 
extracted, (1.2 GB/3.4 GB)'; sleep 30"));
-        long deadline = System.currentTimeMillis() + 10_000;
-        while (notices.isEmpty() && System.currentTimeMillis() < deadline) {
-            lm.tick(System.currentTimeMillis());
-            Thread.sleep(50);
-        }
+        await().atMost(10, TimeUnit.SECONDS).pollInterval(50, 
TimeUnit.MILLISECONDS).pollInSameThread().until(() -> {
+            lm.tick(MonotonicClock.millis());
+            return !notices.isEmpty();
+        });
 
         assertThat(notices).containsExactly("Pulling image of ollama: 2 of 5 
layers, 1.2 GB of 3.4 GB (35%)");
         // the same progress is not shown again
-        lm.tick(System.currentTimeMillis());
+        lm.tick(MonotonicClock.millis());
         assertThat(notices).hasSize(1);
         ProcessHandle.current().children()
                 .filter(p -> p.info().command().map(c -> 
c.endsWith("sh")).orElse(false))
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/MonotonicClockTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/MonotonicClockTest.java
new file mode 100644
index 000000000000..a79cbcf78895
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/MonotonicClockTest.java
@@ -0,0 +1,54 @@
+/*
+ * 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.concurrent.TimeUnit;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+
+class MonotonicClockTest {
+
+    @Test
+    void neverGoesBack() {
+        long previous = MonotonicClock.millis();
+        for (int i = 0; i < 100_000; i++) {
+            long now = MonotonicClock.millis();
+            assertThat(now).isGreaterThanOrEqualTo(previous);
+            previous = now;
+        }
+    }
+
+    @Test
+    void movesOnWithTheTimeThatPasses() {
+        long start = MonotonicClock.millis();
+        long startNanos = System.nanoTime();
+        await().atMost(5, TimeUnit.SECONDS)
+                .until(() -> System.nanoTime() - startNanos >= 
TimeUnit.MILLISECONDS.toNanos(50));
+
+        assertThat(MonotonicClock.millis() - start).isGreaterThanOrEqualTo(50);
+    }
+
+    @Test
+    void aTimeNeverSetReadsAsLongAgo() {
+        // a time field starts at 0: an interval since then has passed, as it 
has on the wall clock
+        long neverSet = 0;
+        assertThat(MonotonicClock.millis() - 
neverSet).isGreaterThanOrEqualTo(TimeUnit.DAYS.toMillis(1));
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/MonotonicTimersTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/MonotonicTimersTest.java
new file mode 100644
index 000000000000..196be71bde73
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/MonotonicTimersTest.java
@@ -0,0 +1,97 @@
+/*
+ * 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 java.util.Queue;
+import java.util.concurrent.ConcurrentLinkedQueue;
+
+import dev.tamboui.style.Style;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * The timers run on the {@link MonotonicClock}, so a wall clock set back or 
forward (an NTP step, a manual change, a VM
+ * resume) neither freezes them nor fires them at once. The times here are far 
from any wall-clock time: a timer that
+ * read the wall clock would fire at once, or never.
+ */
+class MonotonicTimersTest {
+
+    /** A clock the tests set by hand. */
+    private static final class FakeClock {
+        long now;
+    }
+
+    @Test
+    void aPendingMcpKeyFiresOnTime() {
+        Queue<McpFacade.PendingKey> keys = new ConcurrentLinkedQueue<>();
+        McpFacade facade = new McpFacade(null, null, null, null, null, null, 
null, null, null, null, null, keys, null);
+
+        long before = MonotonicClock.millis();
+        assertThat(facade.injectKeys(List.of("a", "Enter"), 500)).isEqualTo(2);
+        long after = MonotonicClock.millis();
+
+        // the tick compares the keys with the monotonic time: the first fires 
at once, the next half a second later
+        McpFacade.PendingKey first = keys.poll();
+        McpFacade.PendingKey second = keys.poll();
+        assertThat(first.fireAt()).isBetween(before, after);
+        assertThat(first.isDue(after)).isTrue();
+        assertThat(second.isDue(first.fireAt() + 499)).isFalse();
+        assertThat(second.isDue(first.fireAt() + 500)).isTrue();
+    }
+
+    @Test
+    void aCaptionExpiresOnItsClock() {
+        FakeClock clock = new FakeClock();
+        clock.now = 5_000;
+        CaptionOverlay caption = new CaptionOverlay(() -> clock.now);
+
+        caption.showCaption("Hello", 2);
+        clock.now += 2_000;
+        caption.tick(clock.now);
+        assertThat(caption.isCaptionVisible()).as("shown for two 
seconds").isTrue();
+        clock.now += 1;
+        caption.tick(clock.now);
+        assertThat(caption.isCaptionVisible()).isFalse();
+
+        // without a duration: typed a character every 50 ms, then held for 
three seconds and faded out in one
+        caption.showCaption("Hi");
+        clock.now += 100;
+        caption.tick(clock.now);
+        clock.now += 4_000;
+        caption.tick(clock.now);
+        assertThat(caption.isCaptionVisible()).as("held and fading").isTrue();
+        clock.now += 1;
+        caption.tick(clock.now);
+        assertThat(caption.isCaptionVisible()).isFalse();
+    }
+
+    @Test
+    void aDrawingExpiresOnTime() {
+        DrawOverlay overlay = new DrawOverlay();
+        long before = MonotonicClock.millis();
+        overlay.setDrawing(List.of(new DrawOverlay.DrawCell(0, 0, "x", 
Style.EMPTY)), 1);
+        long after = MonotonicClock.millis();
+
+        // the tick passes the monotonic time: the drawing shows for its 
second, then goes
+        overlay.tick(before + 1_000);
+        assertThat(overlay.isVisible()).isTrue();
+        overlay.tick(after + 1_001);
+        assertThat(overlay.isVisible()).isFalse();
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitorTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitorTest.java
index aeef31603617..0591699e51fc 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitorTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitorTest.java
@@ -170,7 +170,7 @@ class OllamaMonitorTest {
         assertEquals(12, first.contextPercent());
 
         // the runner's slot wins over the model list when present
-        monitor.updateSlot(slot(false, 0, System.currentTimeMillis()));
+        monitor.updateSlot(slot(false, 0));
         monitor.recordRequest("m", new LlmClient.TokenUsage(20_000, 60, 
20_060, 0, 900, 800, 0, 1800), 0, "stop");
         assertEquals(262144, monitor.snapshot().lastRequest().contextSize());
         assertEquals(7, monitor.snapshot().lastRequest().contextPercent());
@@ -188,10 +188,11 @@ class OllamaMonitorTest {
     @Test
     void slotSamplesDriveTheLiveDecodeRateAndHistory() {
         OllamaMonitor monitor = new OllamaMonitor();
-        long t0 = System.currentTimeMillis();
-        monitor.updateSlot(slot(true, 0, t0));
-        monitor.updateSlot(slot(true, 25, t0 + 500));
-        monitor.updateSlot(slot(true, 50, t0 + 1000));
+        // read half a second apart, by the monotonic clock the rates are 
measured with
+        long t0 = MonotonicClock.millis();
+        monitor.updateSlot(slot(true, 0), t0);
+        monitor.updateSlot(slot(true, 25), t0 + 500);
+        monitor.updateSlot(slot(true, 50), t0 + 1000);
         monitor.tick(t0 + 1000);
 
         Snapshot s = monitor.snapshot();
@@ -422,7 +423,7 @@ class OllamaMonitorTest {
                         List.of("completion", "tools")))));
         monitor.updateInstalled(List.of("qwen3.6:35b-a3b", "llama3.2:latest"));
         monitor.updateRunner(new OllamaMonitor.RunnerInfo(23629, 58237, 
262144, 1, "/blob", "llama-server"));
-        monitor.updateSlot(slot(false, 6, System.currentTimeMillis()));
+        monitor.updateSlot(slot(false, 6));
         monitor.updateHost(new OllamaMonitor.HostStats(
                 new OllamaMonitor.GpuStats("Apple GPU", 25, 31463079936L, 
34170552320L, 1),
                 new OllamaMonitor.ProcessStats(70566, "ollama serve", 0.1, 
82176L * 1024),
@@ -476,8 +477,8 @@ class OllamaMonitorTest {
         assertEquals(0, ((JsonArray) json.get("requests")).size());
     }
 
-    private static SlotState slot(boolean processing, long decoded, long 
atMillis) {
-        return new SlotState(processing, 25, 25, 9, decoded, 262144, 
"draft-mtp", 1, Instant.ofEpochMilli(atMillis));
+    private static SlotState slot(boolean processing, long decoded) {
+        return new SlotState(processing, 25, 25, 9, decoded, 262144, 
"draft-mtp", 1, Instant.now());
     }
 
     private static SpanEntry genAiSpan(
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OpenedProjectLifecycleTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OpenedProjectLifecycleTest.java
index 804241b47acf..dc21943f9b18 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OpenedProjectLifecycleTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OpenedProjectLifecycleTest.java
@@ -73,7 +73,7 @@ class OpenedProjectLifecycleTest {
         IntegrationInfo project = new IntegrationInfo();
         project.name = "custom-kamelet";
         project.sourceDir = "/work/custom-kamelet";
-        project.startingSince = System.currentTimeMillis();
+        project.startingSince = MonotonicClock.millis();
         project.launchedProcess = ProcessHandle.current();
         ctx.addPhantom(project);
         ctx.selectedPid = project.pid;
@@ -97,7 +97,7 @@ class OpenedProjectLifecycleTest {
         IntegrationInfo project = new IntegrationInfo();
         project.name = "orders";
         project.sourceDir = "/work/orders";
-        project.startingSince = System.currentTimeMillis();
+        project.startingSince = MonotonicClock.millis();
         // camel run pom.xml ended (its Maven build failed) before the app 
showed up
         Process run = new ProcessBuilder("true").start();
         run.waitFor();
@@ -115,7 +115,7 @@ class OpenedProjectLifecycleTest {
         project.name = "metrics";
         project.sourceDir = "/work/metrics";
         ctx.addPhantom(project);
-        long now = System.currentTimeMillis();
+        long now = MonotonicClock.millis();
         assertEquals("✖ Stopped", OverviewTab.projectStatus(project, 
now).content());
 
         project.startingSince = now;
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/RecordingManagerTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/RecordingManagerTest.java
index c14680b49259..30020113ecca 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/RecordingManagerTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/RecordingManagerTest.java
@@ -266,7 +266,7 @@ class RecordingManagerTest {
         assertFalse(manager.getRecentKeys().isEmpty());
 
         // Tick with a time far in the future to expire all entries
-        manager.tickRecentKeys(System.currentTimeMillis() + 10_000);
+        manager.tickRecentKeys(MonotonicClock.millis() + 10_000);
         assertTrue(manager.getRecentKeys().isEmpty(),
                 "Old key records should be removed after tick");
     }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/RunOptionsFormTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/RunOptionsFormTest.java
index 70f87f09c609..ee254414a8d1 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/RunOptionsFormTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/RunOptionsFormTest.java
@@ -116,7 +116,7 @@ class RunOptionsFormTest {
         List<String> inner = renderPopupRows(form);
 
         // Name .. JFR: one row each, nothing left empty below the last 
checkbox
-        assertThat(inner).hasSize(14 + 
(form.portWarning(System.currentTimeMillis()) != null ? 1 : 0));
+        assertThat(inner).hasSize(14 + 
(form.portWarning(MonotonicClock.millis()) != null ? 1 : 0));
         assertThat(inner).noneMatch(String::isBlank);
     }
 

Reply via email to