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 e78bb3caf043 CAMEL-24372: TUI YAML editor undo/redo, block ops, word 
nav, find in edit
e78bb3caf043 is described below

commit e78bb3caf043121bfd9e6c0004c62992de9db25d
Author: Omar Atie <[email protected]>
AuthorDate: Mon Aug 10 01:31:22 2026 -0700

    CAMEL-24372: TUI YAML editor undo/redo, block ops, word nav, find in edit
    
    Add enhanced plain-text edit mode to the TUI source viewer with
    undo/redo (Ctrl+Z/Y), YAML block move/duplicate/delete (Alt+arrows,
    Ctrl+D/K), comment toggle (Ctrl+/), word navigation/delete, smart Home,
    and find while editing. Decomposed into SourceEditHistory,
    YamlBlockEditor, and SourceEditorNavigation helpers with comprehensive
    unit and integration tests.
    
    Closes #25408
    Co-authored-by: cursoragent <[email protected]>
    Co-authored-by: Omar Atie <[email protected]>
    Co-Authored-By: Claude Opus 4.6 <[email protected]>
---
 .../jbang/core/commands/tui/SearchHighlighter.java |  38 +++
 .../jbang/core/commands/tui/SourceEditHistory.java |  89 +++++++
 .../core/commands/tui/SourceEditorNavigation.java  | 147 +++++++++++
 .../dsl/jbang/core/commands/tui/SourceTab.java     |  13 +
 .../dsl/jbang/core/commands/tui/SourceViewer.java  | 141 ++++++++++-
 .../jbang/core/commands/tui/YamlBlockEditor.java   | 274 +++++++++++++++++++++
 .../core/commands/tui/SourceEditHistoryTest.java   |  87 +++++++
 .../commands/tui/SourceEditorNavigationTest.java   |  95 +++++++
 .../commands/tui/SourceViewerEditorOpsTest.java    | 219 ++++++++++++++++
 .../core/commands/tui/YamlBlockEditorTest.java     | 173 +++++++++++++
 10 files changed, 1268 insertions(+), 8 deletions(-)

diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SearchHighlighter.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SearchHighlighter.java
index 9bdae7aa3b97..059c2a3350ff 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SearchHighlighter.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SearchHighlighter.java
@@ -312,4 +312,42 @@ class SearchHighlighter {
         highlightTerm = null;
         highlightPattern = null;
     }
+
+    /** Closes an active search input without clearing an existing find term. 
*/
+    void closeInputOnly() {
+        findInputActive = false;
+        highlightInputActive = false;
+        searchInputState = new TextInputState("");
+    }
+
+    void openFindInput() {
+        findInputActive = true;
+        highlightInputActive = false;
+        searchInputState = new TextInputState(findTerm != null ? findTerm : 
"");
+    }
+
+    /**
+     * Find navigation while editing plain text (Ctrl+F opens find, 
Ctrl+N/Ctrl+Shift+N step matches).
+     * <p>
+     * Unlike view mode, plain {@code n}/{@code N} are not consumed so users 
can type freely while a find term is
+     * active.
+     */
+    boolean handleEditFindKeyEvent(KeyEvent ke) {
+        if (findInputActive || highlightInputActive) {
+            return handleSearchInput(ke);
+        }
+        if (ke.hasCtrl() && ke.isCharIgnoreCase('f')) {
+            openFindInput();
+            return true;
+        }
+        if (findTerm != null && ke.hasCtrl() && ke.isCharIgnoreCase('n') && 
!ke.hasAlt()) {
+            if (ke.hasShift()) {
+                navigateToPrevMatch();
+            } else {
+                navigateToNextMatch();
+            }
+            return true;
+        }
+        return false;
+    }
 }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditHistory.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditHistory.java
new file mode 100644
index 000000000000..fac4bf77511b
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditHistory.java
@@ -0,0 +1,89 @@
+/*
+ * 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.ArrayDeque;
+import java.util.Deque;
+
+import dev.tamboui.widgets.input.TextAreaState;
+
+/**
+ * Undo/redo stack for {@link SourceViewer} plain-text edit mode.
+ */
+final class SourceEditHistory {
+
+    private static final int MAX_DEPTH = 100;
+
+    record Snapshot(String text, int row, int col) {
+    }
+
+    private final Deque<Snapshot> undo = new ArrayDeque<>();
+    private final Deque<Snapshot> redo = new ArrayDeque<>();
+
+    void clear() {
+        undo.clear();
+        redo.clear();
+    }
+
+    void seedInitial(TextAreaState state) {
+        clear();
+        undo.push(capture(state));
+    }
+
+    void beforeChange(TextAreaState state) {
+        Snapshot snap = capture(state);
+        undo.push(snap);
+        trim(undo);
+        redo.clear();
+    }
+
+    boolean undo(TextAreaState state) {
+        if (undo.size() <= 1) {
+            return false;
+        }
+        redo.push(capture(state));
+        undo.pop();
+        restore(state, undo.peek());
+        return true;
+    }
+
+    boolean redo(TextAreaState state) {
+        if (redo.isEmpty()) {
+            return false;
+        }
+        Snapshot next = redo.pop();
+        undo.push(capture(state));
+        trim(undo);
+        restore(state, next);
+        return true;
+    }
+
+    static Snapshot capture(TextAreaState state) {
+        return new Snapshot(state.text(), state.cursorRow(), 
state.cursorCol());
+    }
+
+    private static void restore(TextAreaState state, Snapshot snapshot) {
+        state.setText(snapshot.text());
+        SourceEditorNavigation.positionCursor(state, snapshot.row(), 
snapshot.col());
+    }
+
+    private static void trim(Deque<Snapshot> stack) {
+        while (stack.size() > MAX_DEPTH) {
+            stack.removeLast();
+        }
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditorNavigation.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditorNavigation.java
new file mode 100644
index 000000000000..0e95d498130f
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditorNavigation.java
@@ -0,0 +1,147 @@
+/*
+ * 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 dev.tamboui.widgets.input.TextAreaState;
+
+/**
+ * Word navigation and smart-home helpers for the source editor.
+ */
+final class SourceEditorNavigation {
+
+    private SourceEditorNavigation() {
+    }
+
+    static boolean isWordChar(char ch) {
+        return Character.isLetterOrDigit(ch) || ch == '_' || ch == '-' || ch 
== '.';
+    }
+
+    static int wordBoundaryLeft(String line, int col) {
+        if (line.isEmpty() || col <= 0) {
+            return 0;
+        }
+        int pos = Math.min(col, line.length());
+        while (pos > 0 && !isWordChar(line.charAt(pos - 1))) {
+            pos--;
+        }
+        while (pos > 0 && isWordChar(line.charAt(pos - 1))) {
+            pos--;
+        }
+        return pos;
+    }
+
+    static int wordBoundaryRight(String line, int col) {
+        if (line.isEmpty()) {
+            return 0;
+        }
+        int pos = Math.min(col, line.length());
+        while (pos < line.length() && !isWordChar(line.charAt(pos))) {
+            pos++;
+        }
+        while (pos < line.length() && isWordChar(line.charAt(pos))) {
+            pos++;
+        }
+        return pos;
+    }
+
+    static void moveWordLeft(TextAreaState state) {
+        String line = state.getLine(state.cursorRow());
+        int col = state.cursorCol();
+        int target = wordBoundaryLeft(line, col);
+        if (target == col && col > 0) {
+            target = wordBoundaryLeft(line, col - 1);
+        }
+        positionCursor(state, state.cursorRow(), target);
+    }
+
+    static void moveWordRight(TextAreaState state) {
+        String line = state.getLine(state.cursorRow());
+        int col = state.cursorCol();
+        int target = wordBoundaryRight(line, col);
+        if (target == col && col < line.length()) {
+            target = wordBoundaryRight(line, col + 1);
+        }
+        positionCursor(state, state.cursorRow(), target);
+    }
+
+    static void deleteWordBackward(TextAreaState state) {
+        String line = state.getLine(state.cursorRow());
+        int col = state.cursorCol();
+        if (col == 0) {
+            if (state.cursorRow() > 0) {
+                state.deleteBackward();
+            }
+            return;
+        }
+        int start = wordBoundaryLeft(line, col);
+        String prefix = line.substring(0, start);
+        String suffix = line.substring(col);
+        replaceLine(state, prefix + suffix, start);
+    }
+
+    static void deleteWordForward(TextAreaState state) {
+        String line = state.getLine(state.cursorRow());
+        int col = state.cursorCol();
+        if (col >= line.length()) {
+            state.deleteForward();
+            return;
+        }
+        int end = wordBoundaryRight(line, col);
+        String prefix = line.substring(0, col);
+        String suffix = line.substring(end);
+        replaceLine(state, prefix + suffix, col);
+    }
+
+    static void smartHome(TextAreaState state, boolean toAbsoluteStart) {
+        String line = state.getLine(state.cursorRow());
+        int contentStart = 0;
+        while (contentStart < line.length() && line.charAt(contentStart) == ' 
') {
+            contentStart++;
+        }
+        if (toAbsoluteStart || state.cursorCol() <= contentStart) {
+            positionCursor(state, state.cursorRow(), 0);
+        } else {
+            positionCursor(state, state.cursorRow(), contentStart);
+        }
+    }
+
+    static void positionCursor(TextAreaState state, int row, int col) {
+        state.moveCursorToStart();
+        for (int i = 0; i < row && i < state.lineCount(); i++) {
+            state.moveCursorDown();
+        }
+        state.moveCursorToLineStart();
+        String line = state.getLine(state.cursorRow());
+        int target = Math.min(col, line.length());
+        for (int i = 0; i < target; i++) {
+            state.moveCursorRight();
+        }
+    }
+
+    private static void replaceLine(TextAreaState state, String newLine, int 
cursorCol) {
+        int row = state.cursorRow();
+        StringBuilder text = new StringBuilder();
+        for (int i = 0; i < state.lineCount(); i++) {
+            if (i > 0) {
+                text.append('\n');
+            }
+            text.append(i == row ? newLine : state.getLine(i));
+        }
+        state.setText(text.toString());
+        positionCursor(state, row, Math.min(cursorCol, newLine.length()));
+    }
+}
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 cce9ca721d92..ebf50ccf961e 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
@@ -416,6 +416,19 @@ class SourceTab extends AbstractTab {
                 - **p** — toggle plain mode (hides line numbers, borders, and 
file panel for easy copy/paste)
                 - **Esc/c** — close source viewer
 
+                ## Edit Mode (Shortcuts)
+                - **Ctrl+Z** — undo
+                - **Ctrl+Y / Ctrl+Shift+Z** — redo
+                - **Alt+Up / Alt+Down** — move YAML list block up/down
+                - **Ctrl+D** — duplicate current block
+                - **Ctrl+Shift+K** — delete current block
+                - **Ctrl+/** — toggle comment on current block
+                - **Ctrl+Left / Ctrl+Right** — word navigation
+                - **Ctrl+Backspace / Ctrl+Delete** — delete word 
backward/forward
+                - **Ctrl+F** — find in edit mode
+                - **Ctrl+N / Ctrl+Shift+N** — next/previous find match
+                - **Home** — smart home (content indent, then column 0)
+
                 ## Edit Mode (Tab Completion)
                 Press **F4** to enter edit mode, then **Tab** for 
context-aware completion:
 
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 7735e9e97ef4..e4eee1a0b03d 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
@@ -160,6 +160,7 @@ class SourceViewer {
     private EndpointValidator endpointValidator;
     private List<String> validationErrors;
     private int validationErrorScroll;
+    private final SourceEditHistory editHistory = new SourceEditHistory();
 
     private record CachedSource(
             List<String> lines, List<JsonObject> codeData,
@@ -272,6 +273,7 @@ class SourceViewer {
         return dirty;
     }
 
+    /** Package-private for tests that drive the edit buffer directly. */
     TextAreaState editState() {
         return editState;
     }
@@ -519,6 +521,44 @@ class SourceViewer {
         return true;
     }
 
+    private void recordEditChange() {
+        editHistory.beforeChange(editState);
+        dirty = true;
+    }
+
+    private List<String> editLines() {
+        List<String> answer = new ArrayList<>(editState.lineCount());
+        for (int i = 0; i < editState.lineCount(); i++) {
+            answer.add(editState.getLine(i));
+        }
+        return answer;
+    }
+
+    private void applyBlockEdit(YamlBlockEditor.EditResult result) {
+        if (result == null) {
+            return;
+        }
+        recordEditChange();
+        editState.setText(YamlBlockEditor.fromLines(result.lines()));
+        SourceEditorNavigation.positionCursor(editState, result.cursorRow(), 
result.cursorCol());
+    }
+
+    private void refreshEditFindMatches() {
+        search.buildFindMatches(editLines());
+    }
+
+    private void jumpEditToCurrentFindMatch() {
+        int line = search.jumpToNearestMatch(editState.cursorRow());
+        if (line >= 0) {
+            SourceEditorNavigation.positionCursor(editState, line, 0);
+        }
+    }
+
+    /** Package-private for tests that assert on edit buffer content. */
+    String editText() {
+        return editState.text();
+    }
+
     private boolean handleEditKeyEvent(KeyEvent ke) {
         if (validationErrors != null) {
             if (ke.isCancel() || ke.isKey(KeyCode.ENTER)) {
@@ -547,6 +587,72 @@ class SourceViewer {
             }
             return true;
         }
+        if (search.handleEditFindKeyEvent(ke)) {
+            if (!search.isSearchInputActive()) {
+                refreshEditFindMatches();
+                jumpEditToCurrentFindMatch();
+            }
+            return true;
+        }
+        if (ke.hasCtrl() && ke.isCharIgnoreCase('z') && !ke.hasShift()) {
+            if (editHistory.undo(editState)) {
+                dirty = true;
+                refreshEditFindMatches();
+            }
+            return true;
+        }
+        if (ke.hasCtrl() && (ke.isCharIgnoreCase('y') || 
(ke.isCharIgnoreCase('z') && ke.hasShift()))) {
+            if (editHistory.redo(editState)) {
+                dirty = true;
+                refreshEditFindMatches();
+            }
+            return true;
+        }
+        boolean yamlListBlocks = isCamelYamlFile();
+        if (ke.isKey(KeyCode.UP) && ke.hasAlt() && !ke.hasShift()) {
+            applyBlockEdit(YamlBlockEditor.moveBlockUp(editLines(), 
editState.cursorRow(), yamlListBlocks));
+            return true;
+        }
+        if (ke.isKey(KeyCode.DOWN) && ke.hasAlt() && !ke.hasShift()) {
+            applyBlockEdit(YamlBlockEditor.moveBlockDown(editLines(), 
editState.cursorRow(), yamlListBlocks));
+            return true;
+        }
+        if (ke.hasCtrl() && ke.isCharIgnoreCase('d') && !ke.hasShift()) {
+            applyBlockEdit(YamlBlockEditor.duplicateBlock(editLines(), 
editState.cursorRow(), yamlListBlocks));
+            return true;
+        }
+        if (ke.hasCtrl() && ke.hasShift() && (ke.isChar('k') || 
ke.isChar('K'))) {
+            applyBlockEdit(YamlBlockEditor.deleteBlock(editLines(), 
editState.cursorRow(), yamlListBlocks));
+            return true;
+        }
+        if (ke.hasCtrl() && ke.isChar('/')) {
+            YamlBlockEditor.BlockRange block
+                    = YamlBlockEditor.findBlock(editLines(), 
editState.cursorRow(), yamlListBlocks);
+            recordEditChange();
+            List<String> toggled = YamlBlockEditor.toggleComment(editLines(), 
block);
+            editState.setText(YamlBlockEditor.fromLines(toggled));
+            SourceEditorNavigation.positionCursor(editState, block.startRow(),
+                    
YamlBlockEditor.leadingSpaces(toggled.get(block.startRow())));
+            return true;
+        }
+        if (ke.isKey(KeyCode.LEFT) && ke.hasCtrl()) {
+            SourceEditorNavigation.moveWordLeft(editState);
+            return true;
+        }
+        if (ke.isKey(KeyCode.RIGHT) && ke.hasCtrl()) {
+            SourceEditorNavigation.moveWordRight(editState);
+            return true;
+        }
+        if (ke.isKey(KeyCode.BACKSPACE) && ke.hasCtrl()) {
+            recordEditChange();
+            SourceEditorNavigation.deleteWordBackward(editState);
+            return true;
+        }
+        if (ke.isKey(KeyCode.DELETE) && ke.hasCtrl()) {
+            recordEditChange();
+            SourceEditorNavigation.deleteWordForward(editState);
+            return true;
+        }
         if (pendingDiscard) {
             if (ke.isConfirm()) {
                 pendingDiscard = false;
@@ -557,6 +663,9 @@ class SourceViewer {
             return true;
         }
         if (ke.isCancel()) {
+            if (search.handleEscape()) {
+                return true;
+            }
             if (dirty) {
                 pendingDiscard = true;
                 return true;
@@ -573,6 +682,7 @@ class SourceViewer {
             return true;
         }
         if (ke.isConfirm()) {
+            recordEditChange();
             int prevRow = editState.cursorRow();
             String prevLine = editState.getLine(prevRow);
             int indent = countLeadingSpaces(prevLine);
@@ -593,7 +703,6 @@ class SourceViewer {
             } else if (indent > 0) {
                 editState.insert(" ".repeat(indent));
             }
-            dirty = true;
             return true;
         }
         if (ke.isUp()) {
@@ -613,7 +722,7 @@ class SourceViewer {
             return true;
         }
         if (ke.isHome() || ke.isKey(KeyCode.HOME)) {
-            editState.moveCursorToLineStart();
+            SourceEditorNavigation.smartHome(editState, false);
             return true;
         }
         if (ke.isEnd() || ke.isKey(KeyCode.END)) {
@@ -635,13 +744,13 @@ class SourceViewer {
             return true;
         }
         if (ke.isDeleteBackward()) {
+            recordEditChange();
             editState.deleteBackward();
-            dirty = true;
             return true;
         }
         if (ke.isDeleteForward()) {
+            recordEditChange();
             editState.deleteForward();
-            dirty = true;
             return true;
         }
         if (ke.isKey(KeyCode.TAB) && autocompleteProvider != null) {
@@ -649,8 +758,8 @@ class SourceViewer {
             return true;
         }
         if (ke.code() == KeyCode.CHAR && !ke.hasCtrl() && !ke.hasAlt()) {
+            recordEditChange();
             editState.insert(ke.character());
-            dirty = true;
             return true;
         }
         return true;
@@ -670,16 +779,19 @@ class SourceViewer {
         markdownModeBeforeEdit = markdownMode;
         markdownMode = false;
         quickDocEnabled = false;
-        search.reset();
+        search.closeInputOnly();
         dirty = false;
         validationErrors = null;
         editMode = true;
+        editHistory.seedInitial(editState);
+        refreshEditFindMatches();
     }
 
     private void exitEditMode() {
         boolean wasEditing = editMode;
         editMode = false;
         editState.clear();
+        editHistory.clear();
         autocompletePopup = null;
         validationErrors = null;
         pendingDiscard = false;
@@ -1531,7 +1643,7 @@ class SourceViewer {
     }
 
     private void insertCompletion(AutocompletePopup.CompletionItem item, 
boolean valueMode, boolean listItem) {
-        dirty = true;
+        recordEditChange();
         String currentLine = editState.getLine(editState.cursorRow());
         if (isCamelYamlFile()) {
             insertYamlCompletion(item, valueMode, currentLine, listItem);
@@ -1858,9 +1970,13 @@ class SourceViewer {
 
     void handlePaste(String text) {
         if (editMode) {
+            if (search.isSearchInputActive()) {
+                search.handlePaste(text);
+                return;
+            }
             if (text != null && !text.isEmpty()) {
+                recordEditChange();
                 editState.insert(text);
-                dirty = true;
             }
             return;
         }
@@ -2214,10 +2330,19 @@ class SourceViewer {
             TuiHelper.hint(spans, "Esc", "cancel");
             TuiHelper.hint(spans, "F5", "save & close");
             TuiHelper.hint(spans, "Shift+F5", "save");
+            TuiHelper.hint(spans, "Ctrl+Z", "undo");
+            TuiHelper.hint(spans, "Ctrl+Y", "redo");
+            TuiHelper.hint(spans, "Alt+↑/↓", "move block");
+            TuiHelper.hint(spans, "Ctrl+D", "duplicate");
+            TuiHelper.hint(spans, "Ctrl+Shift+K", "delete block");
+            TuiHelper.hint(spans, "Ctrl+/", "comment");
+            TuiHelper.hint(spans, "Ctrl+F", "find");
+            TuiHelper.hint(spans, "Ctrl+N", "next match");
             if (autocompleteProvider != null) {
                 TuiHelper.hint(spans, "Tab", "complete");
             }
             TuiHelper.hint(spans, TuiIcons.HINT_SCROLL, "move");
+            search.renderFindStatus(spans);
             return;
         }
         if (markdownMode) {
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlBlockEditor.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlBlockEditor.java
new file mode 100644
index 000000000000..8c31ad3cef94
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlBlockEditor.java
@@ -0,0 +1,274 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * YAML-structure-aware block operations for the TUI source editor.
+ */
+final class YamlBlockEditor {
+
+    record BlockRange(int startRow, int endRow) {
+        boolean isEmpty() {
+            return startRow < 0 || endRow < startRow;
+        }
+    }
+
+    record EditResult(List<String> lines, int cursorRow, int cursorCol) {
+    }
+
+    private YamlBlockEditor() {
+    }
+
+    static List<String> toLines(String text) {
+        if (text.isEmpty()) {
+            return new ArrayList<>(List.of(""));
+        }
+        return new ArrayList<>(List.of(text.split("\n", -1)));
+    }
+
+    static String fromLines(List<String> lines) {
+        return String.join("\n", lines);
+    }
+
+    static BlockRange findBlock(List<String> lines, int row, boolean 
yamlListBlocks) {
+        if (lines.isEmpty() || row < 0 || row >= lines.size()) {
+            return new BlockRange(row, row);
+        }
+        if (!yamlListBlocks) {
+            return new BlockRange(row, row);
+        }
+        int startRow = findBlockStart(lines, row);
+        int blockIndent = leadingSpaces(lines.get(startRow));
+        int endRow = findBlockEnd(lines, startRow, blockIndent);
+        return new BlockRange(startRow, endRow);
+    }
+
+    static EditResult deleteBlock(List<String> lines, int row, boolean 
yamlListBlocks) {
+        BlockRange block = findBlock(lines, row, yamlListBlocks);
+        if (block.isEmpty()) {
+            return new EditResult(lines, row, 0);
+        }
+        List<String> answer = new ArrayList<>(lines);
+        answer.subList(block.startRow(), block.endRow() + 1).clear();
+        if (answer.isEmpty()) {
+            answer.add("");
+        }
+        int cursorRow = Math.min(block.startRow(), answer.size() - 1);
+        return new EditResult(answer, cursorRow, 
leadingSpaces(answer.get(cursorRow)));
+    }
+
+    static EditResult duplicateBlock(List<String> lines, int row, boolean 
yamlListBlocks) {
+        BlockRange block = findBlock(lines, row, yamlListBlocks);
+        if (block.isEmpty()) {
+            return new EditResult(lines, row, 0);
+        }
+        List<String> copy = new ArrayList<>(lines.subList(block.startRow(), 
block.endRow() + 1));
+        List<String> answer = new ArrayList<>(lines);
+        answer.addAll(block.endRow() + 1, copy);
+        return new EditResult(answer, block.endRow() + 1, 
leadingSpaces(copy.get(0)));
+    }
+
+    static EditResult moveBlockUp(List<String> lines, int row, boolean 
yamlListBlocks) {
+        BlockRange block = findBlock(lines, row, yamlListBlocks);
+        if (block.isEmpty() || block.startRow() == 0) {
+            return null;
+        }
+        BlockRange previous = findPreviousSibling(lines, block, 
yamlListBlocks);
+        if (previous == null || previous.isEmpty()) {
+            return null;
+        }
+        EditResult swapped = swapBlocks(lines, previous, block);
+        List<String> answer = swapped.lines();
+        int cursorRow = previous.startRow();
+        int cursorCol = answer.isEmpty() ? 0 : 
YamlBlockEditor.leadingSpaces(answer.get(cursorRow));
+        return new EditResult(answer, cursorRow, cursorCol);
+    }
+
+    static EditResult moveBlockDown(List<String> lines, int row, boolean 
yamlListBlocks) {
+        BlockRange block = findBlock(lines, row, yamlListBlocks);
+        if (block.isEmpty() || block.endRow() >= lines.size() - 1) {
+            return null;
+        }
+        BlockRange next = findNextSibling(lines, block, yamlListBlocks);
+        if (next == null || next.isEmpty()) {
+            return null;
+        }
+        EditResult swapped = swapBlocks(lines, block, next);
+        List<String> answer = swapped.lines();
+        int nextHeight = next.endRow() - next.startRow() + 1;
+        int cursorRow = block.startRow() + nextHeight + (next.startRow() - 
block.endRow() - 1);
+        int cursorCol = answer.isEmpty() ? 0 : 
leadingSpaces(answer.get(cursorRow));
+        return new EditResult(answer, cursorRow, cursorCol);
+    }
+
+    static List<String> toggleComment(List<String> lines, BlockRange block) {
+        List<String> answer = new ArrayList<>(lines);
+        boolean uncomment = true;
+        for (int i = block.startRow(); i <= block.endRow() && i < 
answer.size(); i++) {
+            String line = answer.get(i);
+            if (line.isBlank()) {
+                continue;
+            }
+            if (!isCommented(line)) {
+                uncomment = false;
+                break;
+            }
+        }
+        for (int i = block.startRow(); i <= block.endRow() && i < 
answer.size(); i++) {
+            answer.set(i, uncomment ? uncommentLine(answer.get(i)) : 
commentLine(answer.get(i)));
+        }
+        return answer;
+    }
+
+    private static EditResult swapBlocks(List<String> lines, BlockRange first, 
BlockRange second) {
+        List<String> firstLines = new 
ArrayList<>(lines.subList(first.startRow(), first.endRow() + 1));
+        List<String> secondLines = new 
ArrayList<>(lines.subList(second.startRow(), second.endRow() + 1));
+        List<String> answer = new ArrayList<>();
+        answer.addAll(lines.subList(0, first.startRow()));
+        answer.addAll(secondLines);
+        answer.addAll(lines.subList(first.endRow() + 1, second.startRow()));
+        answer.addAll(firstLines);
+        answer.addAll(lines.subList(second.endRow() + 1, lines.size()));
+        return new EditResult(answer, second.startRow(), 
leadingSpaces(secondLines.get(0)));
+    }
+
+    private static BlockRange findPreviousSibling(List<String> lines, 
BlockRange block, boolean yamlListBlocks) {
+        if (!yamlListBlocks) {
+            if (block.startRow() == 0) {
+                return null;
+            }
+            return new BlockRange(block.startRow() - 1, block.startRow() - 1);
+        }
+        int blockIndent = leadingSpaces(lines.get(block.startRow()));
+        for (int i = block.startRow() - 1; i >= 0; i--) {
+            String line = lines.get(i);
+            if (line.isBlank()) {
+                continue;
+            }
+            int indent = leadingSpaces(line);
+            if (indent == blockIndent && line.trim().startsWith("- ")) {
+                return findBlock(lines, i, true);
+            }
+            if (indent < blockIndent) {
+                break;
+            }
+        }
+        return null;
+    }
+
+    private static BlockRange findNextSibling(List<String> lines, BlockRange 
block, boolean yamlListBlocks) {
+        if (!yamlListBlocks) {
+            if (block.endRow() >= lines.size() - 1) {
+                return null;
+            }
+            return new BlockRange(block.endRow() + 1, block.endRow() + 1);
+        }
+        int blockIndent = leadingSpaces(lines.get(block.startRow()));
+        for (int i = block.endRow() + 1; i < lines.size(); i++) {
+            String line = lines.get(i);
+            if (line.isBlank()) {
+                continue;
+            }
+            int indent = leadingSpaces(line);
+            if (indent == blockIndent && line.trim().startsWith("- ")) {
+                return findBlock(lines, i, true);
+            }
+            if (indent < blockIndent) {
+                break;
+            }
+        }
+        return null;
+    }
+
+    private static int findBlockStart(List<String> lines, int row) {
+        int cursorIndent = leadingSpaces(lines.get(row));
+        for (int i = row; i >= 0; i--) {
+            String line = lines.get(i);
+            if (line.isBlank()) {
+                continue;
+            }
+            int indent = leadingSpaces(line);
+            if (line.trim().startsWith("- ") && indent <= cursorIndent) {
+                return i;
+            }
+            if (indent < cursorIndent) {
+                return Math.max(i, 0);
+            }
+        }
+        return row;
+    }
+
+    private static int findBlockEnd(List<String> lines, int startRow, int 
blockIndent) {
+        int endRow = startRow;
+        for (int i = startRow + 1; i < lines.size(); i++) {
+            String line = lines.get(i);
+            if (line.isBlank()) {
+                endRow = i;
+                continue;
+            }
+            int indent = leadingSpaces(line);
+            if (indent <= blockIndent && (line.trim().startsWith("- ") || 
indent < blockIndent)) {
+                break;
+            }
+            endRow = i;
+        }
+        return endRow;
+    }
+
+    static int leadingSpaces(String line) {
+        int count = 0;
+        for (int i = 0; i < line.length(); i++) {
+            if (line.charAt(i) == ' ') {
+                count++;
+            } else {
+                break;
+            }
+        }
+        return count;
+    }
+
+    private static boolean isCommented(String line) {
+        String trimmed = line.stripLeading();
+        return trimmed.startsWith("#");
+    }
+
+    private static String commentLine(String line) {
+        if (line.isBlank() || isCommented(line)) {
+            return line;
+        }
+        int indent = leadingSpaces(line);
+        return line.substring(0, indent) + "# " + line.substring(indent);
+    }
+
+    private static String uncommentLine(String line) {
+        if (line.isBlank()) {
+            return line;
+        }
+        int indent = leadingSpaces(line);
+        String rest = line.substring(indent);
+        if (rest.startsWith("# ")) {
+            return line.substring(0, indent) + rest.substring(2);
+        }
+        if (rest.startsWith("#")) {
+            return line.substring(0, indent) + rest.substring(1);
+        }
+        return line;
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditHistoryTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditHistoryTest.java
new file mode 100644
index 000000000000..f2d35b55ae7d
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditHistoryTest.java
@@ -0,0 +1,87 @@
+/*
+ * 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 dev.tamboui.widgets.input.TextAreaState;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for edit undo/redo history (CAMEL-24372).
+ */
+class SourceEditHistoryTest {
+
+    private SourceEditHistory history;
+    private TextAreaState state;
+
+    @BeforeEach
+    void setUp() {
+        history = new SourceEditHistory();
+        state = new TextAreaState("alpha\nbeta\n");
+        history.seedInitial(state);
+    }
+
+    @Test
+    void undoRestoresPreviousSnapshot() {
+        history.beforeChange(state);
+        state.setText("alpha\nchanged\n");
+
+        assertThat(history.undo(state)).isTrue();
+        assertThat(state.text()).isEqualTo("alpha\nbeta\n");
+    }
+
+    @Test
+    void redoReappliesUndoneChange() {
+        history.beforeChange(state);
+        state.setText("alpha\nchanged\n");
+        history.undo(state);
+
+        assertThat(history.redo(state)).isTrue();
+        assertThat(state.text()).isEqualTo("alpha\nchanged\n");
+    }
+
+    @Test
+    void undoAtInitialStateReturnsFalse() {
+        assertThat(history.undo(state)).isFalse();
+    }
+
+    @Test
+    void redoWhenEmptyReturnsFalse() {
+        assertThat(history.redo(state)).isFalse();
+    }
+
+    @Test
+    void positionCursorRestoresRowAndColumn() {
+        SourceEditorNavigation.positionCursor(state, 1, 2);
+
+        assertThat(state.cursorRow()).isEqualTo(1);
+        assertThat(state.cursorCol()).isEqualTo(2);
+        assertThat(state.getLine(1)).startsWith("be");
+    }
+
+    @Test
+    void clearEmptiesStacks() {
+        history.beforeChange(state);
+        state.insert('X');
+        history.clear();
+        history.seedInitial(state);
+
+        assertThat(history.undo(state)).isFalse();
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditorNavigationTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditorNavigationTest.java
new file mode 100644
index 000000000000..f3e12ec34032
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditorNavigationTest.java
@@ -0,0 +1,95 @@
+/*
+ * 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 dev.tamboui.widgets.input.TextAreaState;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for word navigation and smart home (CAMEL-24372).
+ */
+class SourceEditorNavigationTest {
+
+    private TextAreaState state;
+
+    @BeforeEach
+    void setUp() {
+        state = new TextAreaState("  from: timer:tick\n");
+        SourceEditorNavigation.positionCursor(state, 0, 14);
+    }
+
+    @Test
+    void wordBoundaryLeftSkipsToPreviousToken() {
+        assertThat(SourceEditorNavigation.wordBoundaryLeft("hello world", 
11)).isEqualTo(6);
+        assertThat(SourceEditorNavigation.wordBoundaryLeft("hello world", 
0)).isZero();
+    }
+
+    @Test
+    void wordBoundaryRightSkipsToNextToken() {
+        assertThat(SourceEditorNavigation.wordBoundaryRight("hello world", 
0)).isEqualTo(5);
+        assertThat(SourceEditorNavigation.wordBoundaryRight("hello world", 
6)).isEqualTo(11);
+    }
+
+    @Test
+    void colonSeparatesWordsInComponentNames() {
+        assertThat(SourceEditorNavigation.wordBoundaryLeft("timer:tick", 
10)).isEqualTo(6);
+        assertThat(SourceEditorNavigation.wordBoundaryRight("timer:tick", 
0)).isEqualTo(5);
+        assertThat(SourceEditorNavigation.wordBoundaryRight("timer:tick", 
6)).isEqualTo(10);
+    }
+
+    @Test
+    void moveWordLeftAndRightUpdateCursor() {
+        SourceEditorNavigation.moveWordRight(state);
+        assertThat(state.cursorCol()).isEqualTo(18);
+
+        SourceEditorNavigation.moveWordLeft(state);
+        assertThat(state.cursorCol()).isEqualTo(14);
+    }
+
+    @Test
+    void deleteWordBackwardRemovesPreviousWord() {
+        SourceEditorNavigation.deleteWordBackward(state);
+
+        assertThat(state.getLine(0)).isEqualTo("  from: tick");
+        assertThat(state.cursorCol()).isEqualTo(8);
+    }
+
+    @Test
+    void deleteWordForwardRemovesNextWordToken() {
+        SourceEditorNavigation.positionCursor(state, 0, 8);
+
+        SourceEditorNavigation.deleteWordForward(state);
+
+        assertThat(state.getLine(0)).isEqualTo("  from: :tick");
+        assertThat(state.cursorCol()).isEqualTo(8);
+    }
+
+    @Test
+    void smartHomeTogglesBetweenContentStartAndColumnZero() {
+        SourceEditorNavigation.smartHome(state, false);
+        assertThat(state.cursorCol()).isEqualTo(2);
+
+        SourceEditorNavigation.smartHome(state, false);
+        assertThat(state.cursorCol()).isZero();
+
+        SourceEditorNavigation.smartHome(state, true);
+        assertThat(state.cursorCol()).isZero();
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerEditorOpsTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerEditorOpsTest.java
new file mode 100644
index 000000000000..1eebd6fafa46
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerEditorOpsTest.java
@@ -0,0 +1,219 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.tui.event.KeyModifiers;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Integration tests for SourceViewer YAML editor operations (CAMEL-24372).
+ */
+class SourceViewerEditorOpsTest {
+
+    private static final KeyModifiers CTRL = KeyModifiers.of(true, false, 
false);
+    private static final KeyModifiers ALT = KeyModifiers.of(false, true, 
false);
+    private static final KeyModifiers CTRL_SHIFT = KeyModifiers.of(true, 
false, true);
+
+    @TempDir
+    Path tempDir;
+
+    private SourceViewer viewer;
+    private Path yamlFile;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        Theme.resetForTesting();
+        viewer = new SourceViewer();
+        viewer.setValidateOnSave(false);
+        yamlFile = tempDir.resolve("route.camel.yaml");
+        Files.writeString(yamlFile, """
+                - route:
+                    from:
+                      uri: timer:tick
+                      steps:
+                        - to: log:info
+                        - to: log:warn
+                """, StandardCharsets.UTF_8);
+        viewer.loadFile(yamlFile);
+        viewer.enterEditMode();
+    }
+
+    @Test
+    void ctrlZUndoesTypedChange() {
+        viewer.handleKeyEvent(KeyEvent.ofChar('X', KeyModifiers.NONE));
+        assertThat(viewer.editText()).contains("X");
+
+        viewer.handleKeyEvent(KeyEvent.ofChar('z', CTRL));
+
+        assertThat(viewer.editText()).doesNotContain("X");
+    }
+
+    @Test
+    void ctrlYRedoesUndoneChange() {
+        viewer.handleKeyEvent(KeyEvent.ofChar('X', KeyModifiers.NONE));
+        viewer.handleKeyEvent(KeyEvent.ofChar('z', CTRL));
+        viewer.handleKeyEvent(KeyEvent.ofChar('y', CTRL));
+
+        assertThat(viewer.editText()).contains("X");
+    }
+
+    @Test
+    void ctrlSlashTogglesCommentOnCurrentBlock() {
+        moveCursorToLineContaining("log:info");
+
+        viewer.handleKeyEvent(KeyEvent.ofChar('/', CTRL));
+
+        assertThat(viewer.editText()).contains("# ");
+        assertThat(viewer.editText()).contains("log:info");
+
+        viewer.handleKeyEvent(KeyEvent.ofChar('/', CTRL));
+
+        assertThat(viewer.editText()).doesNotContain("# log:info");
+    }
+
+    @Test
+    void ctrlDDuplicatesYamlBlock() {
+        moveCursorToLineContaining("log:info");
+        int before = countOccurrences(viewer.editText(), "log:info");
+
+        viewer.handleKeyEvent(KeyEvent.ofChar('d', CTRL));
+
+        assertThat(countOccurrences(viewer.editText(), 
"log:info")).isEqualTo(before + 1);
+    }
+
+    @Test
+    void ctrlShiftKDeletesYamlBlock() {
+        moveCursorToLineContaining("log:warn");
+
+        viewer.handleKeyEvent(KeyEvent.ofChar('k', CTRL_SHIFT));
+
+        assertThat(viewer.editText()).doesNotContain("log:warn");
+        assertThat(viewer.editText()).contains("log:info");
+    }
+
+    @Test
+    void smartHomeInEditModeUsesContentThenLineStart() {
+        moveCursorToLineContaining("uri:");
+        String line = 
viewer.editState().getLine(viewer.editState().cursorRow());
+        SourceEditorNavigation.positionCursor(viewer.editState(), 
viewer.editState().cursorRow(), line.length());
+        int contentStart = YamlBlockEditor.leadingSpaces(line);
+
+        SourceEditorNavigation.smartHome(viewer.editState(), false);
+        assertThat(viewer.editState().cursorCol()).isEqualTo(contentStart);
+
+        SourceEditorNavigation.smartHome(viewer.editState(), false);
+        assertThat(viewer.editState().cursorCol()).isZero();
+    }
+
+    @Test
+    void altDownMovesBlockDown() {
+        moveCursorToLineContaining("log:info");
+
+        viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.DOWN, KeyModifiers.ALT));
+
+        
assertThat(viewer.editText().indexOf("log:warn")).isLessThan(viewer.editText().indexOf("log:info"));
+    }
+
+    @Test
+    void ctrlLeftAndRightMoveByWordViaKeyBindings() {
+        moveCursorToLineContaining("uri:");
+        SourceEditorNavigation.positionCursor(viewer.editState(), 
viewer.editState().cursorRow(), 0);
+
+        viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.RIGHT, CTRL));
+        assertThat(viewer.editState().cursorCol()).isEqualTo(9);
+
+        viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.LEFT, CTRL));
+        assertThat(viewer.editState().cursorCol()).isEqualTo(6);
+    }
+
+    @Test
+    void ctrlFOpensFindInEditMode() {
+        viewer.handleKeyEvent(KeyEvent.ofChar('f', CTRL));
+
+        assertThat(viewer.isSearchInputActive()).isTrue();
+    }
+
+    @Test
+    void plainNInsertsWhileFindTermActive() {
+        viewer.handleKeyEvent(KeyEvent.ofChar('f', CTRL));
+        viewer.handleKeyEvent(KeyEvent.ofChar('l', KeyModifiers.NONE));
+        viewer.handleKeyEvent(KeyEvent.ofChar('o', KeyModifiers.NONE));
+        viewer.handleKeyEvent(KeyEvent.ofChar('g', KeyModifiers.NONE));
+        viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, 
KeyModifiers.NONE));
+
+        viewer.handleKeyEvent(KeyEvent.ofChar('n', KeyModifiers.NONE));
+
+        assertThat(viewer.editText()).contains("n");
+    }
+
+    @Test
+    void footerShowsNewEditorHints() {
+        List<dev.tamboui.text.Span> spans = new ArrayList<>();
+        viewer.renderFooter(spans);
+        String footer = spansToString(spans);
+
+        assertThat(footer).contains("Ctrl+Z");
+        assertThat(footer).contains("Ctrl+Y");
+        assertThat(footer).contains("Alt+↑/↓");
+        assertThat(footer).contains("Ctrl+D");
+        assertThat(footer).contains("Ctrl+Shift+K");
+        assertThat(footer).contains("Ctrl+/");
+        assertThat(footer).contains("Ctrl+F");
+        assertThat(footer).contains("Ctrl+N");
+    }
+
+    private void moveCursorToLineContaining(String needle) {
+        String[] lines = viewer.editText().split("\n", -1);
+        for (int row = 0; row < lines.length; row++) {
+            if (lines[row].contains(needle)) {
+                SourceEditorNavigation.positionCursor(viewer.editState(), row, 
lines[row].indexOf(needle));
+                return;
+            }
+        }
+        throw new AssertionError("Line not found: " + needle);
+    }
+
+    private static int countOccurrences(String text, String needle) {
+        int count = 0;
+        int idx = 0;
+        while ((idx = text.indexOf(needle, idx)) >= 0) {
+            count++;
+            idx += needle.length();
+        }
+        return count;
+    }
+
+    private static String spansToString(List<dev.tamboui.text.Span> spans) {
+        StringBuilder sb = new StringBuilder();
+        for (dev.tamboui.text.Span span : spans) {
+            sb.append(span.content());
+        }
+        return sb.toString();
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlBlockEditorTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlBlockEditorTest.java
new file mode 100644
index 000000000000..f2fa1e947eeb
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlBlockEditorTest.java
@@ -0,0 +1,173 @@
+/*
+ * 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 org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for YAML block operations (CAMEL-24372).
+ */
+class YamlBlockEditorTest {
+
+    private static final String SAMPLE = """
+            - route:
+                from:
+                  uri: timer:tick
+                  steps:
+                    - to: log:info
+                    - to: log:warn
+            """;
+
+    @Test
+    void findBlockSelectsYamlListItemWithChildren() {
+        List<String> lines = YamlBlockEditor.toLines(SAMPLE);
+        int stepsRow = findLineContaining(lines, "- to: log:info");
+
+        YamlBlockEditor.BlockRange block = YamlBlockEditor.findBlock(lines, 
stepsRow, true);
+
+        assertThat(block.startRow()).isEqualTo(stepsRow);
+        assertThat(lines.get(block.startRow())).contains("log:info");
+        assertThat(block.endRow()).isGreaterThanOrEqualTo(block.startRow());
+    }
+
+    @Test
+    void duplicateBlockInsertsCopyBelow() {
+        List<String> lines = YamlBlockEditor.toLines(SAMPLE);
+        int row = findLineContaining(lines, "- to: log:info");
+
+        YamlBlockEditor.EditResult result = 
YamlBlockEditor.duplicateBlock(lines, row, true);
+
+        assertThat(result.lines()).hasSize(lines.size() + 
(YamlBlockEditor.findBlock(lines, row, true).endRow()
+                                                           - 
YamlBlockEditor.findBlock(lines, row, true).startRow() + 1));
+        String text = YamlBlockEditor.fromLines(result.lines());
+        assertThat(text.split("- to: log:info", -1)).hasSize(3);
+    }
+
+    @Test
+    void deleteBlockRemovesSelectedYamlBlock() {
+        List<String> lines = YamlBlockEditor.toLines(SAMPLE);
+        int row = findLineContaining(lines, "- to: log:warn");
+        int before = lines.size();
+
+        YamlBlockEditor.EditResult result = YamlBlockEditor.deleteBlock(lines, 
row, true);
+
+        assertThat(result.lines()).hasSizeLessThan(before);
+        
assertThat(YamlBlockEditor.fromLines(result.lines())).doesNotContain("log:warn");
+        
assertThat(YamlBlockEditor.fromLines(result.lines())).contains("log:info");
+    }
+
+    @Test
+    void moveBlockDownCursorFollowsMovedBlock() {
+        List<String> lines = YamlBlockEditor.toLines(SAMPLE);
+        int firstStep = findLineContaining(lines, "- to: log:info");
+
+        YamlBlockEditor.EditResult result = 
YamlBlockEditor.moveBlockDown(lines, firstStep, true);
+
+        assertThat(result).isNotNull();
+        
assertThat(result.lines().get(result.cursorRow())).contains("log:info");
+        
assertThat(YamlBlockEditor.fromLines(result.lines()).indexOf("log:warn"))
+                
.isLessThan(YamlBlockEditor.fromLines(result.lines()).indexOf("log:info"));
+    }
+
+    @Test
+    void moveBlockDownSwapsWithNextSibling() {
+        List<String> lines = YamlBlockEditor.toLines(SAMPLE);
+        int firstStep = findLineContaining(lines, "- to: log:info");
+
+        YamlBlockEditor.EditResult result = 
YamlBlockEditor.moveBlockDown(lines, firstStep, true);
+
+        assertThat(result).isNotNull();
+        String text = YamlBlockEditor.fromLines(result.lines());
+        
assertThat(text.indexOf("log:warn")).isLessThan(text.indexOf("log:info"));
+    }
+
+    @Test
+    void moveBlockUpSwapsWithPreviousSibling() {
+        List<String> lines = YamlBlockEditor.toLines(SAMPLE);
+        int secondStep = findLineContaining(lines, "- to: log:warn");
+
+        YamlBlockEditor.EditResult result = YamlBlockEditor.moveBlockUp(lines, 
secondStep, true);
+
+        assertThat(result).isNotNull();
+        String text = YamlBlockEditor.fromLines(result.lines());
+        
assertThat(text.indexOf("log:warn")).isLessThan(text.indexOf("log:info"));
+    }
+
+    @Test
+    void moveBlockUpAtTopReturnsNull() {
+        List<String> lines = YamlBlockEditor.toLines(SAMPLE);
+        int row = findLineContaining(lines, "- route:");
+
+        assertThat(YamlBlockEditor.moveBlockUp(lines, row, true)).isNull();
+    }
+
+    @Test
+    void toggleCommentCommentsUncommentedLines() {
+        List<String> lines = YamlBlockEditor.toLines("key: value\nother: x\n");
+        YamlBlockEditor.BlockRange block = new YamlBlockEditor.BlockRange(0, 
1);
+
+        List<String> commented = YamlBlockEditor.toggleComment(lines, block);
+
+        assertThat(commented.get(0)).startsWith("# ");
+        assertThat(commented.get(1)).startsWith("# ");
+    }
+
+    @Test
+    void toggleCommentUncommentsWhenAllLinesCommented() {
+        List<String> lines = YamlBlockEditor.toLines("# key: value\n# other: 
x\n");
+        YamlBlockEditor.BlockRange block = new YamlBlockEditor.BlockRange(0, 
1);
+
+        List<String> uncommented = YamlBlockEditor.toggleComment(lines, block);
+
+        assertThat(uncommented.get(0)).doesNotStartWith("#");
+        assertThat(uncommented.get(0)).contains("key: value");
+        assertThat(uncommented.get(1)).contains("other: x");
+    }
+
+    @Test
+    void nonYamlListModeUsesSingleLineBlocks() {
+        List<String> lines = YamlBlockEditor.toLines("alpha\nbeta\ngamma\n");
+
+        YamlBlockEditor.BlockRange block = YamlBlockEditor.findBlock(lines, 1, 
false);
+        assertThat(block.startRow()).isEqualTo(1);
+        assertThat(block.endRow()).isEqualTo(1);
+
+        YamlBlockEditor.EditResult moved = 
YamlBlockEditor.moveBlockDown(lines, 0, false);
+        assertThat(moved).isNotNull();
+        
assertThat(YamlBlockEditor.fromLines(moved.lines())).isEqualTo("beta\nalpha\ngamma\n");
+    }
+
+    @Test
+    void toLinesAndFromLinesPreserveTrailingNewlineContent() {
+        List<String> lines = YamlBlockEditor.toLines("a\nb");
+        assertThat(lines).containsExactly("a", "b");
+        assertThat(YamlBlockEditor.fromLines(lines)).isEqualTo("a\nb");
+    }
+
+    private static int findLineContaining(List<String> lines, String needle) {
+        for (int i = 0; i < lines.size(); i++) {
+            if (lines.get(i).contains(needle)) {
+                return i;
+            }
+        }
+        throw new AssertionError("Line not found: " + needle);
+    }
+}

Reply via email to