This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch sql-query-tui
in repository https://gitbox.apache.org/repos/asf/camel.git

commit 02518a3dbcf5afab76a6a4ffa6e700ef42496c1e
Author: Claus Ibsen <[email protected]>
AuthorDate: Sun Jun 28 11:56:18 2026 +0200

    chore: camel-jbang - Add inline row editing to SQL query tab
    
    Add the ability to edit result rows directly in the TUI SQL query tab.
    Press Enter on a result row to open an edit form, change values, and
    press F5 to auto-generate and execute an UPDATE statement. Includes
    single-table detection via JDBC metadata, primary key discovery,
    PreparedStatement-based updates with proper type coercion, and an
    MCP tui_update_row tool for AI agents.
    
    Also fixes: ResultSet conflict crash (PK lookup moved after row
    iteration), number keys switching tabs during edit mode, and
    integer column type handling in updates.
    
    Co-Authored-By: Claude <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../camel/impl/console/SqlQueryDevConsole.java     | 303 +++++++++++++---
 .../camel/cli/connector/LocalCliConnector.java     |  23 ++
 .../camel/dsl/jbang/core/common/RuntimeHelper.java |  19 +
 .../dsl/jbang/core/commands/tui/CamelMonitor.java  |  13 +
 .../dsl/jbang/core/commands/tui/SqlQueryTab.java   | 386 ++++++++++++++++++++-
 .../dsl/jbang/core/commands/tui/TuiMcpServer.java  |  35 ++
 6 files changed, 732 insertions(+), 47 deletions(-)

diff --git 
a/core/camel-console/src/main/java/org/apache/camel/impl/console/SqlQueryDevConsole.java
 
b/core/camel-console/src/main/java/org/apache/camel/impl/console/SqlQueryDevConsole.java
index 81ecc1b3c460..4bd24bba64d2 100644
--- 
a/core/camel-console/src/main/java/org/apache/camel/impl/console/SqlQueryDevConsole.java
+++ 
b/core/camel-console/src/main/java/org/apache/camel/impl/console/SqlQueryDevConsole.java
@@ -17,10 +17,16 @@
 package org.apache.camel.impl.console;
 
 import java.sql.Connection;
+import java.sql.DatabaseMetaData;
+import java.sql.PreparedStatement;
 import java.sql.ResultSet;
 import java.sql.ResultSetMetaData;
 import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 import javax.sql.DataSource;
 
@@ -32,6 +38,7 @@ import org.apache.camel.util.StopWatch;
 import org.apache.camel.util.TimeUtils;
 import org.apache.camel.util.json.JsonArray;
 import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
 
 @DevConsole(name = "sql-query", displayName = "SQL Query", description = 
"Execute SQL queries on DataSource beans")
 @Configurer(extended = true)
@@ -57,6 +64,26 @@ public class SqlQueryDevConsole extends AbstractDevConsole {
      */
     public static final String QUERY_TIMEOUT = "queryTimeout";
 
+    /**
+     * Action type: "query" (default) or "update-row"
+     */
+    public static final String ACTION_TYPE = "actionType";
+
+    /**
+     * Table name for update-row action
+     */
+    public static final String TABLE = "table";
+
+    /**
+     * Primary key column-value pairs as JSON string for update-row action
+     */
+    public static final String PRIMARY_KEY_VALUES = "primaryKeyValues";
+
+    /**
+     * Changed column-value pairs as JSON string for update-row action
+     */
+    public static final String COLUMN_VALUES = "columnValues";
+
     @Metadata(defaultValue = "100",
               description = "Maximum number of rows to return from a query")
     private int defaultMaxRows = 100;
@@ -185,6 +212,14 @@ public class SqlQueryDevConsole extends AbstractDevConsole 
{
 
     @Override
     protected JsonObject doCallJson(Map<String, Object> options) {
+        String actionType = (String) options.get(ACTION_TYPE);
+        if ("update-row".equals(actionType)) {
+            return doUpdateRow(options);
+        }
+        return doQuery(options);
+    }
+
+    private JsonObject doQuery(Map<String, Object> options) {
         JsonObject root = new JsonObject();
 
         String sql = (String) options.get(SQL);
@@ -198,36 +233,11 @@ public class SqlQueryDevConsole extends 
AbstractDevConsole {
         int maxRows = parseIntOption(options, MAX_ROWS, defaultMaxRows);
         int queryTimeout = parseIntOption(options, QUERY_TIMEOUT, 
defaultQueryTimeout);
 
-        DataSource ds;
-        String resolvedName;
-        if (dsName != null && !dsName.isBlank()) {
-            ds = getCamelContext().getRegistry().lookupByNameAndType(dsName, 
DataSource.class);
-            resolvedName = dsName;
-            if (ds == null) {
-                root.put("status", "error");
-                root.put("message", String.format("DataSource '%s' not found 
in registry", dsName));
-                return root;
-            }
-        } else {
-            Map<String, DataSource> all = 
getCamelContext().getRegistry().findByTypeWithName(DataSource.class);
-            if (all.isEmpty()) {
-                root.put("status", "error");
-                root.put("message", "No DataSource found in registry");
-                return root;
-            }
-            if (all.size() > 1) {
-                root.put("status", "error");
-                root.put("message", "Multiple DataSources found, specify one: 
" + String.join(", ", all.keySet()));
-                // include available names for the caller
-                JsonArray names = new JsonArray();
-                all.keySet().forEach(names::add);
-                root.put("availableDataSources", names);
-                return root;
-            }
-            Map.Entry<String, DataSource> single = 
all.entrySet().iterator().next();
-            ds = single.getValue();
-            resolvedName = single.getKey();
+        DataSource ds = resolveDataSource(dsName, root);
+        if (ds == null) {
+            return root;
         }
+        String resolvedName = root.getString("datasource");
 
         StopWatch watch = new StopWatch();
         try (Connection conn = ds.getConnection();
@@ -241,46 +251,104 @@ public class SqlQueryDevConsole extends 
AbstractDevConsole {
 
             root.put("status", "success");
             root.put("elapsed", elapsed);
-            root.put("datasource", resolvedName);
 
             if (hasResultSet) {
+                // read all data and metadata from ResultSet first, then close 
it
+                // before any DatabaseMetaData calls (some drivers only 
support one
+                // open ResultSet per connection)
+                String singleTable = null;
+                String catalog = null;
+                String schema = null;
+                JsonArray columns = new JsonArray();
+                JsonArray rows = new JsonArray();
+                int rowCount = 0;
+                String[] colLabels;
+
                 try (ResultSet rs = stmt.getResultSet()) {
                     ResultSetMetaData meta = rs.getMetaData();
                     int colCount = meta.getColumnCount();
+                    colLabels = new String[colCount];
+
+                    // detect single-table query for editability
+                    boolean allSameTable = true;
+                    for (int i = 1; i <= colCount; i++) {
+                        colLabels[i - 1] = meta.getColumnLabel(i);
+                        String tn = meta.getTableName(i);
+                        if (tn == null || tn.isEmpty()) {
+                            allSameTable = false;
+                        } else if (singleTable == null) {
+                            singleTable = tn;
+                            catalog = meta.getCatalogName(i);
+                            schema = meta.getSchemaName(i);
+                        } else if (!singleTable.equalsIgnoreCase(tn)) {
+                            allSameTable = false;
+                        }
+                    }
+                    if (!allSameTable) {
+                        singleTable = null;
+                    }
 
-                    JsonArray columns = new JsonArray();
                     for (int i = 1; i <= colCount; i++) {
                         JsonObject col = new JsonObject();
                         col.put("name", meta.getColumnLabel(i));
                         col.put("type", meta.getColumnTypeName(i));
                         columns.add(col);
                     }
-                    root.put("columns", columns);
 
-                    JsonArray rows = new JsonArray();
-                    int rowCount = 0;
                     while (rs.next()) {
                         JsonObject row = new JsonObject();
                         for (int i = 1; i <= colCount; i++) {
-                            String colName = meta.getColumnLabel(i);
                             Object val = rs.getObject(i);
                             if (val == null) {
-                                row.put(colName, null);
+                                row.put(colLabels[i - 1], null);
                             } else if (val instanceof Number n) {
-                                row.put(colName, n);
+                                row.put(colLabels[i - 1], n);
                             } else if (val instanceof Boolean b) {
-                                row.put(colName, b);
+                                row.put(colLabels[i - 1], b);
                             } else {
-                                row.put(colName, String.valueOf(val));
+                                row.put(colLabels[i - 1], String.valueOf(val));
                             }
                         }
                         rows.add(row);
                         rowCount++;
                     }
-                    root.put("rows", rows);
-                    root.put("rowCount", rowCount);
-                    root.put("truncated", rowCount >= maxRows);
                 }
+
+                // ResultSet is now closed — safe to query DatabaseMetaData
+                Set<String> pkColumns = new LinkedHashSet<>();
+                if (singleTable != null) {
+                    try {
+                        DatabaseMetaData dbMeta = conn.getMetaData();
+                        try (ResultSet pkRs = dbMeta.getPrimaryKeys(
+                                catalog != null && !catalog.isEmpty() ? 
catalog : null,
+                                schema != null && !schema.isEmpty() ? schema : 
null,
+                                singleTable)) {
+                            while (pkRs.next()) {
+                                pkColumns.add(pkRs.getString("COLUMN_NAME"));
+                            }
+                        }
+                    } catch (Exception e) {
+                        // PK lookup failed — not editable
+                    }
+                }
+
+                // annotate columns with PK info
+                if (singleTable != null && !pkColumns.isEmpty()) {
+                    for (int i = 0; i < columns.size(); i++) {
+                        JsonObject col = (JsonObject) columns.get(i);
+                        col.put("primaryKey", 
pkColumns.contains(col.getString("name")));
+                    }
+                    root.put("tableName", singleTable);
+                    JsonArray pkArr = new JsonArray();
+                    pkColumns.forEach(pkArr::add);
+                    root.put("primaryKeys", pkArr);
+                    root.put("editable", true);
+                }
+
+                root.put("columns", columns);
+                root.put("rows", rows);
+                root.put("rowCount", rowCount);
+                root.put("truncated", rowCount >= maxRows);
             } else {
                 int updateCount = stmt.getUpdateCount();
                 root.put("updateCount", updateCount);
@@ -295,6 +363,157 @@ public class SqlQueryDevConsole extends 
AbstractDevConsole {
         return root;
     }
 
+    private JsonObject doUpdateRow(Map<String, Object> options) {
+        JsonObject root = new JsonObject();
+
+        String tableName = (String) options.get(TABLE);
+        if (tableName == null || tableName.isBlank()) {
+            root.put("status", "error");
+            root.put("message", "No table name provided");
+            return root;
+        }
+
+        String pkJson = (String) options.get(PRIMARY_KEY_VALUES);
+        String colJson = (String) options.get(COLUMN_VALUES);
+        if (pkJson == null || colJson == null) {
+            root.put("status", "error");
+            root.put("message", "Missing primaryKeyValues or columnValues");
+            return root;
+        }
+
+        JsonObject pkValues;
+        JsonObject colValues;
+        try {
+            pkValues = (JsonObject) Jsoner.deserialize(pkJson);
+            colValues = (JsonObject) Jsoner.deserialize(colJson);
+        } catch (Exception e) {
+            root.put("status", "error");
+            root.put("message", "Invalid JSON: " + e.getMessage());
+            return root;
+        }
+
+        if (colValues.isEmpty()) {
+            root.put("status", "error");
+            root.put("message", "No columns to update");
+            return root;
+        }
+
+        String dsName = (String) options.get(DATASOURCE);
+        DataSource ds = resolveDataSource(dsName, root);
+        if (ds == null) {
+            return root;
+        }
+
+        // build UPDATE table SET col1=?, col2=? WHERE pk1=? AND pk2=?
+        List<String> setCols = new ArrayList<>(colValues.keySet());
+        List<String> pkCols = new ArrayList<>(pkValues.keySet());
+
+        StringBuilder sb = new StringBuilder("UPDATE ");
+        sb.append(tableName).append(" SET ");
+        for (int i = 0; i < setCols.size(); i++) {
+            if (i > 0) {
+                sb.append(", ");
+            }
+            sb.append(setCols.get(i)).append(" = ?");
+        }
+        sb.append(" WHERE ");
+        for (int i = 0; i < pkCols.size(); i++) {
+            if (i > 0) {
+                sb.append(" AND ");
+            }
+            sb.append(pkCols.get(i)).append(" = ?");
+        }
+
+        StopWatch watch = new StopWatch();
+        try (Connection conn = ds.getConnection();
+             PreparedStatement ps = conn.prepareStatement(sb.toString())) {
+
+            int idx = 1;
+            for (String col : setCols) {
+                setParameter(ps, idx++, colValues.get(col));
+            }
+            for (String col : pkCols) {
+                setParameter(ps, idx++, pkValues.get(col));
+            }
+
+            int updateCount = ps.executeUpdate();
+            long elapsed = watch.taken();
+            root.put("status", "success");
+            root.put("elapsed", elapsed);
+            root.put("updateCount", updateCount);
+        } catch (Exception e) {
+            long elapsed = watch.taken();
+            root.put("status", "error");
+            root.put("elapsed", elapsed);
+            root.put("message", e.getMessage());
+        }
+
+        return root;
+    }
+
+    private DataSource resolveDataSource(String dsName, JsonObject root) {
+        if (dsName != null && !dsName.isBlank()) {
+            DataSource ds = 
getCamelContext().getRegistry().lookupByNameAndType(dsName, DataSource.class);
+            if (ds == null) {
+                root.put("status", "error");
+                root.put("message", String.format("DataSource '%s' not found 
in registry", dsName));
+                return null;
+            }
+            root.put("datasource", dsName);
+            return ds;
+        }
+
+        Map<String, DataSource> all = 
getCamelContext().getRegistry().findByTypeWithName(DataSource.class);
+        if (all.isEmpty()) {
+            root.put("status", "error");
+            root.put("message", "No DataSource found in registry");
+            return null;
+        }
+        if (all.size() > 1) {
+            root.put("status", "error");
+            root.put("message", "Multiple DataSources found, specify one: " + 
String.join(", ", all.keySet()));
+            JsonArray names = new JsonArray();
+            all.keySet().forEach(names::add);
+            root.put("availableDataSources", names);
+            return null;
+        }
+        Map.Entry<String, DataSource> single = 
all.entrySet().iterator().next();
+        root.put("datasource", single.getKey());
+        return single.getValue();
+    }
+
+    private static void setParameter(PreparedStatement ps, int index, Object 
value) throws Exception {
+        if (value == null) {
+            ps.setNull(index, java.sql.Types.NULL);
+        } else if (value instanceof Number n) {
+            if (value instanceof Integer || value instanceof Long) {
+                ps.setLong(index, n.longValue());
+            } else {
+                ps.setDouble(index, n.doubleValue());
+            }
+        } else if (value instanceof Boolean b) {
+            ps.setBoolean(index, b);
+        } else {
+            String s = String.valueOf(value);
+            if ("null".equalsIgnoreCase(s)) {
+                ps.setNull(index, java.sql.Types.NULL);
+            } else if ("true".equalsIgnoreCase(s) || 
"false".equalsIgnoreCase(s)) {
+                ps.setBoolean(index, Boolean.parseBoolean(s));
+            } else {
+                // try numeric types so the JDBC driver gets the right type
+                try {
+                    ps.setLong(index, Long.parseLong(s));
+                } catch (NumberFormatException e1) {
+                    try {
+                        ps.setDouble(index, Double.parseDouble(s));
+                    } catch (NumberFormatException e2) {
+                        ps.setString(index, s);
+                    }
+                }
+            }
+        }
+    }
+
     private static int parseIntOption(Map<String, Object> options, String key, 
int defaultValue) {
         Object val = options.get(key);
         if (val instanceof Number n) {
diff --git 
a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java
 
b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java
index ae14b3906620..731df87d90f5 100644
--- 
a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java
+++ 
b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java
@@ -373,6 +373,8 @@ public class LocalCliConnector extends ServiceSupport 
implements CliConnector, C
                 doActionReadmeTask(root);
             } else if ("sql-query".equals(action)) {
                 doActionSqlQueryTask(root);
+            } else if ("sql-update-row".equals(action)) {
+                doActionSqlUpdateRowTask(root);
             } else if ("cli-debug".equals(action)) {
                 doActionCliDebug(root);
             }
@@ -1037,6 +1039,27 @@ public class LocalCliConnector extends ServiceSupport 
implements CliConnector, C
         }
     }
 
+    private void doActionSqlUpdateRowTask(JsonObject root) throws Exception {
+        DevConsole dc = 
camelContext.getCamelContextExtension().getContextPlugin(DevConsoleRegistry.class)
+                .resolveById("sql-query");
+        if (dc != null) {
+            Map<String, Object> args = new HashMap<>();
+            args.put("actionType", "update-row");
+            args.put("table", root.getString("table"));
+            String datasource = root.getString("datasource");
+            if (datasource != null) {
+                args.put("datasource", datasource);
+            }
+            args.put("primaryKeyValues", root.getString("primaryKeyValues"));
+            args.put("columnValues", root.getString("columnValues"));
+            JsonObject json = (JsonObject) dc.call(DevConsole.MediaType.JSON, 
args);
+            LOG.trace("Updating output file: {}", outputFile);
+            IOHelper.writeText(json.toJson(), outputFile);
+        } else {
+            IOHelper.writeText("{}", outputFile);
+        }
+    }
+
     private void doActionLoadTask(JsonObject root) throws Exception {
         List<String> files = root.getCollection("source");
         boolean restart = root.getBooleanOrDefault("restart", false);
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/RuntimeHelper.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/RuntimeHelper.java
index 91caf955c9f4..a492e4e57d72 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/RuntimeHelper.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/RuntimeHelper.java
@@ -276,6 +276,25 @@ public final class RuntimeHelper {
         }
     }
 
+    public static JsonObject executeRowUpdate(
+            long pid, String table, String datasource, String pkValuesJson, 
String colValuesJson) {
+        String result = executeAction(pid, "sql-update-row", root -> {
+            root.put("table", table);
+            if (datasource != null) {
+                root.put("datasource", datasource);
+            }
+            root.put("primaryKeyValues", pkValuesJson);
+            root.put("columnValues", colValuesJson);
+        });
+        try {
+            return (JsonObject) Jsoner.deserialize(result);
+        } catch (Exception e) {
+            JsonObject wrapper = new JsonObject();
+            wrapper.put("result", result);
+            return wrapper;
+        }
+    }
+
     public static JsonObject executeSqlQuery(long pid, String sql, String 
datasource, int maxRows, int queryTimeout) {
         long timeout = (queryTimeout + 10) * 1000L;
         String result = executeAction(pid, "sql-query", root -> {
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 bd4b5c97243d..4650772bea9c 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
@@ -3017,6 +3017,19 @@ public class CamelMonitor extends CamelCommand {
         return RuntimeHelper.executeSqlQuery(pid, sql, datasource, maxRows, 
queryTimeout);
     }
 
+    JsonObject updateRow(String table, String datasource, String pkValuesJson, 
String colValuesJson) {
+        if (ctx.selectedPid == null) {
+            return null;
+        }
+        long pid;
+        try {
+            pid = Long.parseLong(ctx.selectedPid);
+        } catch (NumberFormatException e) {
+            return null;
+        }
+        return RuntimeHelper.executeRowUpdate(pid, table, datasource, 
pkValuesJson, colValuesJson);
+    }
+
     String controlIntegration(String action) {
         if (action == null || action.isBlank()) {
             return "Error: action is required";
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SqlQueryTab.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SqlQueryTab.java
index a6e9d06591c7..7a9faa2e5ef1 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SqlQueryTab.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SqlQueryTab.java
@@ -32,11 +32,14 @@ import dev.tamboui.text.Span;
 import dev.tamboui.text.Text;
 import dev.tamboui.tui.event.KeyCode;
 import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.widgets.Clear;
 import dev.tamboui.widgets.block.Block;
 import dev.tamboui.widgets.block.BorderType;
 import dev.tamboui.widgets.block.Title;
 import dev.tamboui.widgets.input.TextArea;
 import dev.tamboui.widgets.input.TextAreaState;
+import dev.tamboui.widgets.input.TextInput;
+import dev.tamboui.widgets.input.TextInputState;
 import dev.tamboui.widgets.paragraph.Paragraph;
 import dev.tamboui.widgets.table.Cell;
 import dev.tamboui.widgets.table.Row;
@@ -63,6 +66,7 @@ class SqlQueryTab implements MonitorTab {
 
     // results
     private String[] columnNames;
+    private boolean[] columnIsPk;
     private List<JsonObject> resultRows;
     private int rowCount;
     private boolean truncated;
@@ -70,16 +74,38 @@ class SqlQueryTab implements MonitorTab {
     private String errorMessage;
     private Integer updateCount;
 
+    // editability metadata from response
+    private String tableName;
+    private String[] primaryKeys;
+
+    // edit row state
+    private boolean editMode;
+    private int editField;
+    private int editScrollOffset;
+    private TextInputState[] editInputs;
+    private String[] editOriginalValues;
+    private int editRowIndex;
+    private String editUpdateMessage;
+
+    // store last query for re-execution after update
+    private String lastSql;
+    private String lastDsName;
+
     SqlQueryTab(MonitorContext ctx) {
         this.ctx = ctx;
     }
 
     boolean isInputActive() {
-        return focusOnInput;
+        return editMode || focusOnInput;
     }
 
     void handlePaste(String text) {
-        if (focusOnInput) {
+        if (editMode) {
+            if (editInputs != null && editField >= 0 && editField < 
editInputs.length
+                    && !columnIsPk[editField]) {
+                FormHelper.handlePaste(text, editInputs[editField]);
+            }
+        } else if (focusOnInput) {
             sqlInput.insert(text);
         }
     }
@@ -95,6 +121,7 @@ class SqlQueryTab implements MonitorTab {
             }
         }
         clearResults();
+        closeEditMode();
     }
 
     @Override
@@ -109,6 +136,11 @@ class SqlQueryTab implements MonitorTab {
             return true;
         }
 
+        // edit mode takes priority
+        if (editMode) {
+            return handleEditKeyEvent(ke);
+        }
+
         // history popup takes priority
         if (sqlHistory.isPopupVisible()) {
             sqlHistory.handleKeyEvent(ke);
@@ -146,12 +178,16 @@ class SqlQueryTab implements MonitorTab {
             return true;
         }
 
-        // Enter adds newline in input mode
+        // Enter: newline in input, or open edit in results
         if (ke.isConfirm()) {
             if (focusOnInput) {
                 sqlInput.insert('\n');
                 return true;
             }
+            // results mode: open edit if editable
+            if (isEditable()) {
+                openEditMode();
+            }
             return true;
         }
 
@@ -222,8 +258,53 @@ class SqlQueryTab implements MonitorTab {
         return true;
     }
 
+    private boolean handleEditKeyEvent(KeyEvent ke) {
+        if (ke.isCancel()) {
+            closeEditMode();
+            return true;
+        }
+
+        // F5 to save changes
+        if (ke.code() == KeyCode.F5) {
+            saveEditedRow();
+            return true;
+        }
+
+        // navigate fields
+        if (ke.isUp()) {
+            moveEditField(-1);
+            return true;
+        }
+        if (ke.isDown() || ke.code() == KeyCode.TAB) {
+            moveEditField(1);
+            return true;
+        }
+
+        // edit current field (only non-PK)
+        if (editField >= 0 && editField < editInputs.length && 
!columnIsPk[editField]) {
+            FormHelper.handleTextInput(ke, editInputs[editField]);
+        }
+        return true;
+    }
+
+    private void moveEditField(int direction) {
+        int count = columnNames.length;
+        int next = editField;
+        for (int i = 0; i < count; i++) {
+            next = (next + direction + count) % count;
+            if (!columnIsPk[next]) {
+                editField = next;
+                return;
+            }
+        }
+    }
+
     @Override
     public boolean handleEscape() {
+        if (editMode) {
+            closeEditMode();
+            return true;
+        }
         if (sqlHistory.isPopupVisible()) {
             sqlHistory.hidePopup();
             return true;
@@ -268,6 +349,10 @@ class SqlQueryTab implements MonitorTab {
         renderResults(frame, parts.get(1));
 
         sqlHistory.renderPopup(frame, area, "Query History");
+
+        if (editMode) {
+            renderEditPopup(frame, area);
+        }
     }
 
     private void renderInputArea(Frame frame, Rect area, int dsBarH) {
@@ -421,7 +506,11 @@ class SqlQueryTab implements MonitorTab {
     private Cell[] buildHeaderCells() {
         Cell[] cells = new Cell[columnNames.length];
         for (int i = 0; i < columnNames.length; i++) {
-            cells[i] = Cell.from(Span.styled(columnNames[i], 
Style.EMPTY.fg(Color.YELLOW)));
+            String label = columnNames[i];
+            if (columnIsPk != null && columnIsPk[i]) {
+                label = label + " 🔑";
+            }
+            cells[i] = Cell.from(Span.styled(label, 
Style.EMPTY.fg(Color.YELLOW)));
         }
         return cells;
     }
@@ -432,6 +521,9 @@ class SqlQueryTab implements MonitorTab {
 
         for (int i = 0; i < colCount; i++) {
             widths[i] = columnNames[i].length();
+            if (columnIsPk != null && columnIsPk[i]) {
+                widths[i] += 3;
+            }
         }
         if (resultRows != null) {
             for (JsonObject row : resultRows) {
@@ -451,9 +543,253 @@ class SqlQueryTab implements MonitorTab {
         return widths;
     }
 
+    // ---- Edit row popup ----
+
+    private boolean isEditable() {
+        return tableName != null && primaryKeys != null && primaryKeys.length 
> 0
+                && resultRows != null && !resultRows.isEmpty();
+    }
+
+    private void openEditMode() {
+        Integer sel = tableState.selected();
+        if (sel == null || sel < 0 || sel >= resultRows.size()) {
+            return;
+        }
+        editRowIndex = sel;
+        JsonObject row = resultRows.get(sel);
+
+        editInputs = new TextInputState[columnNames.length];
+        editOriginalValues = new String[columnNames.length];
+        for (int i = 0; i < columnNames.length; i++) {
+            Object val = row.get(columnNames[i]);
+            String strVal = val != null ? String.valueOf(val) : "";
+            editOriginalValues[i] = strVal;
+            editInputs[i] = new TextInputState(strVal);
+        }
+
+        // focus on first non-PK field
+        editField = 0;
+        for (int i = 0; i < columnNames.length; i++) {
+            if (!columnIsPk[i]) {
+                editField = i;
+                break;
+            }
+        }
+        editScrollOffset = 0;
+        editUpdateMessage = null;
+        editMode = true;
+    }
+
+    private void closeEditMode() {
+        editMode = false;
+        editInputs = null;
+        editOriginalValues = null;
+        editUpdateMessage = null;
+    }
+
+    private void saveEditedRow() {
+        if (!editMode || editInputs == null || executing.get()) {
+            return;
+        }
+
+        // build changed columns
+        JsonObject colValues = new JsonObject();
+        for (int i = 0; i < columnNames.length; i++) {
+            if (columnIsPk[i]) {
+                continue;
+            }
+            String newVal = editInputs[i].text();
+            if (!newVal.equals(editOriginalValues[i])) {
+                if (newVal.isEmpty()) {
+                    colValues.put(columnNames[i], null);
+                } else {
+                    colValues.put(columnNames[i], newVal);
+                }
+            }
+        }
+
+        if (colValues.isEmpty()) {
+            editUpdateMessage = "No changes";
+            return;
+        }
+
+        // build PK values from the original row
+        JsonObject pkValues = new JsonObject();
+        JsonObject row = resultRows.get(editRowIndex);
+        for (String pk : primaryKeys) {
+            Object val = row.get(pk);
+            if (val != null) {
+                pkValues.put(pk, val);
+            } else {
+                pkValues.put(pk, null);
+            }
+        }
+
+        if (!executing.compareAndSet(false, true)) {
+            return;
+        }
+
+        String dsName = dsNames.isEmpty() ? null : dsNames.get(selectedDs);
+        String pkJson = pkValues.toJson();
+        String colJson = colValues.toJson();
+        String savedSql = lastSql;
+        String savedDs = lastDsName;
+
+        ctx.runner.scheduler().execute(() -> {
+            try {
+                Path outputFile = ctx.getOutputFile(ctx.selectedPid);
+                PathUtils.deleteFile(outputFile);
+
+                JsonObject action = new JsonObject();
+                action.put("action", "sql-update-row");
+                action.put("table", tableName);
+                if (dsName != null) {
+                    action.put("datasource", dsName);
+                }
+                action.put("primaryKeyValues", pkJson);
+                action.put("columnValues", colJson);
+
+                Path actionFile = ctx.getActionFile(ctx.selectedPid);
+                PathUtils.writeTextSafely(action.toJson(), actionFile);
+
+                JsonObject jo = pollJsonResponse(outputFile, 15000);
+                PathUtils.deleteFile(outputFile);
+
+                if (jo == null) {
+                    if (ctx.runner != null) {
+                        ctx.runner.runOnRenderThread(() -> editUpdateMessage = 
"Timeout");
+                    }
+                    return;
+                }
+
+                String status = jo.getString("status");
+                if ("error".equals(status)) {
+                    String msg = jo.getString("message");
+                    if (ctx.runner != null) {
+                        ctx.runner.runOnRenderThread(() -> editUpdateMessage = 
"Error: " + msg);
+                    }
+                    return;
+                }
+
+                int uc = jo.getIntegerOrDefault("updateCount", 0);
+                if (ctx.runner != null) {
+                    ctx.runner.runOnRenderThread(() -> {
+                        editUpdateMessage = uc + " row(s) updated";
+                        closeEditMode();
+                    });
+                }
+
+                // re-execute the original query to refresh results
+                if (savedSql != null) {
+                    executeInBackground(ctx.selectedPid, savedSql, savedDs);
+                }
+            } finally {
+                executing.set(false);
+            }
+        });
+    }
+
+    private void renderEditPopup(Frame frame, Rect area) {
+        int colCount = columnNames.length;
+        int labelW = 0;
+        for (String col : columnNames) {
+            labelW = Math.max(labelW, col.length());
+        }
+        labelW += 4;
+
+        int popupW = Math.min(area.width() - 4, Math.max(50, labelW + 30));
+        int visibleRows = Math.min(colCount, area.height() - 6);
+        int popupH = visibleRows + 4;
+        int x = area.left() + Math.max(0, (area.width() - popupW) / 2);
+        int y = area.top() + Math.max(1, (area.height() - popupH) / 2);
+        Rect popup = new Rect(x, y, popupW, popupH);
+
+        frame.renderWidget(Clear.INSTANCE, popup);
+
+        String title = " Edit Row — " + tableName + " ";
+        if (editUpdateMessage != null) {
+            title = " " + editUpdateMessage + " ";
+        }
+
+        Block block = Block.builder()
+                .borderType(BorderType.ROUNDED)
+                .title(Title.from(Line.from(Span.styled(title, 
Style.EMPTY.fg(Color.YELLOW).bold()))))
+                .build();
+        Rect inner = block.inner(popup);
+        frame.renderWidget(block, popup);
+
+        // adjust scroll to keep focused field visible
+        if (editField < editScrollOffset) {
+            editScrollOffset = editField;
+        } else if (editField >= editScrollOffset + visibleRows) {
+            editScrollOffset = editField - visibleRows + 1;
+        }
+
+        int fieldW = inner.width() - labelW - 1;
+        int end = Math.min(editScrollOffset + visibleRows, colCount);
+        for (int i = editScrollOffset; i < end; i++) {
+            int row = inner.top() + (i - editScrollOffset);
+            boolean isPk = columnIsPk[i];
+            boolean isFocused = (i == editField);
+
+            // column label
+            String label = columnNames[i] + (isPk ? " *" : "  ");
+            Style labelStyle;
+            if (isPk) {
+                labelStyle = Style.EMPTY.fg(Color.DARK_GRAY);
+            } else if (isFocused) {
+                labelStyle = Style.EMPTY.fg(Color.CYAN).bold();
+            } else {
+                labelStyle = Style.EMPTY;
+            }
+            Rect labelArea = new Rect(inner.left(), row, labelW, 1);
+            frame.renderWidget(Paragraph.from(Line.from(
+                    Span.styled(String.format("%" + (labelW - 1) + "s", label) 
+ " ", labelStyle))), labelArea);
+
+            // value field
+            Rect valArea = new Rect(inner.left() + labelW, row, fieldW, 1);
+            if (isPk) {
+                String val = editOriginalValues[i];
+                frame.renderWidget(Paragraph.from(Line.from(
+                        Span.styled(val.isEmpty() ? "null" : val, 
Style.EMPTY.fg(Color.DARK_GRAY)))), valArea);
+            } else if (isFocused) {
+                boolean changed = 
!editInputs[i].text().equals(editOriginalValues[i]);
+                Style cursorStyle = changed ? 
Style.EMPTY.reversed().fg(Color.GREEN) : Style.EMPTY.reversed();
+                TextInput input = TextInput.builder()
+                        .cursorStyle(cursorStyle)
+                        .build();
+                frame.renderStatefulWidget(input, valArea, editInputs[i]);
+            } else {
+                String val = editInputs[i].text();
+                boolean changed = !val.equals(editOriginalValues[i]);
+                Style valStyle = changed ? Style.EMPTY.fg(Color.GREEN) : 
Style.EMPTY;
+                frame.renderWidget(Paragraph.from(Line.from(
+                        Span.styled(val.isEmpty() ? "null" : val, valStyle))), 
valArea);
+            }
+        }
+
+        // footer hint inside popup
+        int footerY = inner.top() + visibleRows + 1;
+        if (footerY < popup.bottom() - 1) {
+            Rect footerArea = new Rect(inner.left(), footerY, inner.width(), 
1);
+            frame.renderWidget(Paragraph.from(Line.from(
+                    Span.styled(" F5", Style.EMPTY.fg(Color.YELLOW)),
+                    Span.styled("=Save  ", Style.EMPTY.fg(Color.DARK_GRAY)),
+                    Span.styled("Esc", Style.EMPTY.fg(Color.YELLOW)),
+                    Span.styled("=Cancel  ", Style.EMPTY.fg(Color.DARK_GRAY)),
+                    Span.styled("*", Style.EMPTY.fg(Color.DARK_GRAY)),
+                    Span.styled("=Primary Key", 
Style.EMPTY.fg(Color.DARK_GRAY)))), footerArea);
+        }
+    }
+
     @Override
     public void renderFooter(List<Span> spans) {
-        if (focusOnInput) {
+        if (editMode) {
+            spans.add(Span.styled(" F5", Style.EMPTY.fg(Color.YELLOW)));
+            spans.add(Span.styled("=Save ", Style.EMPTY.fg(Color.DARK_GRAY)));
+            spans.add(Span.styled(" Esc", Style.EMPTY.fg(Color.YELLOW)));
+            spans.add(Span.styled("=Cancel ", 
Style.EMPTY.fg(Color.DARK_GRAY)));
+        } else if (focusOnInput) {
             spans.add(Span.styled(" F5", Style.EMPTY.fg(Color.YELLOW)));
             spans.add(Span.styled("=Execute ", 
Style.EMPTY.fg(Color.DARK_GRAY)));
             if (!sqlHistory.isEmpty()) {
@@ -473,6 +809,10 @@ class SqlQueryTab implements MonitorTab {
             spans.add(Span.styled("=Input ", Style.EMPTY.fg(Color.DARK_GRAY)));
             spans.add(Span.styled(" ↑↓", Style.EMPTY.fg(Color.YELLOW)));
             spans.add(Span.styled("=Navigate ", 
Style.EMPTY.fg(Color.DARK_GRAY)));
+            if (isEditable()) {
+                spans.add(Span.styled(" Enter", Style.EMPTY.fg(Color.YELLOW)));
+                spans.add(Span.styled("=Edit ", 
Style.EMPTY.fg(Color.DARK_GRAY)));
+            }
         }
     }
 
@@ -493,6 +833,14 @@ class SqlQueryTab implements MonitorTab {
                 - Use **Esc** to return focus to the input field from results
                 - Use **Ctrl+Left/Right** to switch between DataSources (when 
multiple exist)
 
+                ## Inline Editing
+                - For simple single-table SELECT queries, press **Enter** on a 
result row to edit
+                - Primary key columns (marked with *) are read-only
+                - Changed values are highlighted in green
+                - Press **F5** to save changes (executes an UPDATE statement)
+                - Press **Esc** to cancel editing
+                - The query is automatically re-executed after a successful 
update
+
                 ## Supported Queries
                 - SELECT queries return a result table
                 - INSERT, UPDATE, DELETE return an update count
@@ -518,6 +866,10 @@ class SqlQueryTab implements MonitorTab {
             root.put("rowCount", rowCount);
             root.put("truncated", truncated);
             root.put("elapsed", elapsed);
+            if (tableName != null) {
+                root.put("tableName", tableName);
+                root.put("editable", true);
+            }
         }
         if (errorMessage != null) {
             root.put("error", errorMessage);
@@ -542,6 +894,8 @@ class SqlQueryTab implements MonitorTab {
         sqlHistory.add(sql);
         String pid = ctx.selectedPid;
         String dsName = dsNames.isEmpty() ? null : dsNames.get(selectedDs);
+        lastSql = sql;
+        lastDsName = dsName;
 
         ctx.runner.scheduler().execute(() -> {
             try {
@@ -614,9 +968,11 @@ class SqlQueryTab implements MonitorTab {
         }
 
         String[] cols = new String[columns.size()];
+        boolean[] isPk = new boolean[columns.size()];
         for (int i = 0; i < columns.size(); i++) {
             JsonObject col = (JsonObject) columns.get(i);
             cols[i] = col.getString("name");
+            isPk[i] = col.getBooleanOrDefault("primaryKey", false);
         }
 
         List<JsonObject> parsedRows = new ArrayList<>();
@@ -628,13 +984,30 @@ class SqlQueryTab implements MonitorTab {
         boolean trunc = jo.getBooleanOrDefault("truncated", false);
         long el = jo.getLongOrDefault("elapsed", 0);
 
+        // editability metadata
+        String tblName = jo.getString("tableName");
+        String[] pks = null;
+        if (tblName != null) {
+            JsonArray pkArr = jo.getCollection("primaryKeys");
+            if (pkArr != null && !pkArr.isEmpty()) {
+                pks = new String[pkArr.size()];
+                for (int i = 0; i < pkArr.size(); i++) {
+                    pks[i] = String.valueOf(pkArr.get(i));
+                }
+            }
+        }
+
+        String[] finalPks = pks;
         if (ctx.runner != null) {
             ctx.runner.runOnRenderThread(() -> {
                 columnNames = cols;
+                columnIsPk = isPk;
                 resultRows = parsedRows;
                 rowCount = rc;
                 truncated = trunc;
                 elapsed = el;
+                tableName = tblName;
+                primaryKeys = finalPks;
                 tableState.select(0);
                 focusOnInput = true;
             });
@@ -643,12 +1016,15 @@ class SqlQueryTab implements MonitorTab {
 
     private void clearResults() {
         columnNames = null;
+        columnIsPk = null;
         resultRows = null;
         rowCount = 0;
         truncated = false;
         elapsed = 0;
         errorMessage = null;
         updateCount = null;
+        tableName = null;
+        primaryKeys = null;
         tableState.select(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 d3921228b7e6..912fabb772f5 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
@@ -457,6 +457,19 @@ class TuiMcpServer {
                         "queryTimeout", propDef("integer",
                                 "Query timeout in seconds (default 30)")),
                 List.of("query")));
+        toolList.add(toolDef(
+                "tui_update_row",
+                "Updates a single row in a database table. Use after 
tui_execute_sql returns "
+                                  + "editable=true with tableName and 
primaryKeys. Builds and executes "
+                                  + "an UPDATE statement using 
PreparedStatement for safety.",
+                Map.of("table", propDef("string", "The table name to update"),
+                        "primaryKeyValues", propDef("string",
+                                "JSON object of primary key column-value 
pairs, e.g. {\"id\": 1}"),
+                        "columnValues", propDef("string",
+                                "JSON object of column-value pairs to update, 
e.g. {\"name\": \"new\"}"),
+                        "datasource", propDef("string",
+                                "Name of the DataSource bean (auto-detected if 
only one exists)")),
+                List.of("table", "primaryKeyValues", "columnValues")));
         toolList.add(toolDef(
                 "tui_set_log_level",
                 "Changes the runtime log level of the selected integration. "
@@ -580,6 +593,7 @@ class TuiMcpServer {
                 case "tui_get_topology" -> callGetTopology();
                 case "tui_send_message" -> callSendMessage(args);
                 case "tui_execute_sql" -> callExecuteSql(args);
+                case "tui_update_row" -> callUpdateRow(args);
                 case "tui_set_log_level" -> callSetLogLevel(args);
                 case "tui_filter" -> callFilter(args);
                 case "tui_toggle_trace_display" -> 
callToggleTraceDisplay(args);
@@ -1216,6 +1230,27 @@ class TuiMcpServer {
         return Jsoner.serialize(response);
     }
 
+    private String callUpdateRow(Map<String, Object> args) {
+        String table = (String) args.get("table");
+        if (table == null || table.isBlank()) {
+            return "Error: table is required";
+        }
+        String pkValues = (String) args.get("primaryKeyValues");
+        if (pkValues == null || pkValues.isBlank()) {
+            return "Error: primaryKeyValues is required (JSON object)";
+        }
+        String colValues = (String) args.get("columnValues");
+        if (colValues == null || colValues.isBlank()) {
+            return "Error: columnValues is required (JSON object)";
+        }
+        String datasource = args.get("datasource") instanceof String s ? s : 
null;
+        JsonObject response = monitor.updateRow(table, datasource, pkValues, 
colValues);
+        if (response == null) {
+            return "Error: no integration selected or PID unavailable";
+        }
+        return Jsoner.serialize(response);
+    }
+
     private String callSetLogLevel(Map<String, Object> args) {
         String level = (String) args.get("level");
         if (level == null || level.isBlank()) {


Reply via email to