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 14956e1214ab CAMEL-25358: camel-jbang - Log tab: Shift+F8 (fix with
AI) on an ERROR on the screen
14956e1214ab is described below
commit 14956e1214ab31cf1c7ee96cbb4e3ac5c15785c0
Author: Claus Ibsen <[email protected]>
AuthorDate: Tue Oct 6 09:34:17 2026 +0200
CAMEL-25358: camel-jbang - Log tab: Shift+F8 (fix with AI) on an ERROR on
the screen
Shift+F8 in the Log tab asks the AI about the ERROR on the screen. For a
failed
exchange the step that failed comes from its Message History (the last row,
file:line), and for the log EIP the logger names the source line, so the AI
is
asked to fix that line of the route; any other ERROR is explained, and fixed
when the cause is in the project. When different ERRORs are on the screen a
pick list comes up, newest first; the same error repeated is listed once.
The
Shift+F8 hint sits with the F-keys in the footer.
Also fixes follow mode with word wrap: the last entries were taken by
count, so
wrapped lines pushed the newest ones below the screen and the log looked
frozen. The entries shown are now the ones that fit when wrapped.
Closes #27398
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
---
.../jbang/camel-tui-log-fix-with-ai-pick.png | Bin 0 -> 559285 bytes
.../ROOT/pages/camel-jbang-tui-observe.adoc | 8 +
.../dsl/jbang/core/commands/tui/AiFixPrompt.java | 12 ++
.../dsl/jbang/core/commands/tui/CamelMonitor.java | 5 +
.../dsl/jbang/core/commands/tui/LogErrors.java | 209 +++++++++++++++++++++
.../camel/dsl/jbang/core/commands/tui/LogTab.java | 206 +++++++++++++++++++-
.../jbang/core/commands/tui/MonitorContext.java | 7 +
.../src/main/resources/tui/help/errors.md | 1 +
.../src/main/resources/tui/help/log.md | 11 ++
.../dsl/jbang/core/commands/tui/LogErrorsTest.java | 163 ++++++++++++++++
.../jbang/core/commands/tui/LogTabRenderTest.java | 75 ++++++++
11 files changed, 696 insertions(+), 1 deletion(-)
diff --git
a/docs/user-manual/modules/ROOT/images/jbang/camel-tui-log-fix-with-ai-pick.png
b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-log-fix-with-ai-pick.png
new file mode 100644
index 000000000000..9ca747cc2eb8
Binary files /dev/null and
b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-log-fix-with-ai-pick.png
differ
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc
index 5ef6afb403bd..8746fc7f9035 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc
@@ -136,6 +136,14 @@
xref:camel-jbang-tui-source-editor.adoc#_fix_with_ai[Source editor].
image::jbang/camel-tui-errors-fix-with-ai.png[Shift+F8 on an error writes the
question for the AI]
+The Log tab has the same key. *Shift+F8* asks the AI about the ERROR on the
screen: for a failed exchange, the step that
+failed comes from its Message History, so the AI is asked to fix that line of
the route; for a `log` with
+`loggingLevel: ERROR`, the logger names the source line. Any other ERROR is
explained, and fixed when the cause is in the
+project. When different ERRORs are on the screen, a list comes up to pick one,
newest first; the same error repeated is
+listed once.
+
+image::jbang/camel-tui-log-fix-with-ai-pick.png[Shift+F8 in the Log tab with
two different ERRORs on the screen]
+
image::jbang/camel-tui-observe-error-diagram.png[A failed order: the path from
checkout to payment-provider, and the onException handler that parked it]
== OpenTelemetry Spans
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiFixPrompt.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiFixPrompt.java
index 0f5015772de4..4e6775df1a77 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiFixPrompt.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiFixPrompt.java
@@ -55,6 +55,18 @@ final class AiFixPrompt {
+ " Dev mode reloads the file when it is saved.";
}
+ /**
+ * The question for an ERROR of the log whose source line is not known
(Shift+F8 in the Log tab): explain it, and
+ * fix it when the cause is in the project's routes.
+ *
+ * @param error the ERROR line, the exception and its causes
+ */
+ static String ofLogError(String error) {
+ return "Explain this ERROR from the log of the running integration:\n"
+ error + "\n"
+ + "If the cause is in the project's routes or configuration,
fix it with camel_edit_file and check the"
+ + " file with camel_validate_source. Dev mode reloads the file
when it is saved.";
+ }
+
/** The name of the file relative to the project directory, when it is
inside it. */
private static String nameOf(Path directory, Path file) {
String name = file.toString();
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 cf7fa9aa6d7d..05a4bd1a5ea8 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
@@ -530,6 +530,11 @@ public class CamelMonitor extends CamelCommand {
public void fixFailure(Path file, int line, String failure, String
lineText) {
openAiWithQuestion(dir -> AiFixPrompt.ofFailure(dir, file,
line, failure, lineText));
}
+
+ @Override
+ public void explainLogError(String error) {
+ openAiWithQuestion(dir -> AiFixPrompt.ofLogError(error));
+ }
};
ctx.projectOverviewCallback = () -> {
if (shellPanel.isOpen()) {
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LogErrors.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LogErrors.java
new file mode 100644
index 000000000000..2826aedcaa19
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LogErrors.java
@@ -0,0 +1,209 @@
+/*
+ * 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.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * The ERRORs on the screen of the Log tab, for fix with AI (Shift+F8): each
ERROR line with the lines that follow it
+ * (the Message History and the stack trace of a failed exchange), the same
error repeated counted once, newest first.
+ * The source line of a failed exchange comes from its Message History, whose
last row is the step that failed.
+ */
+final class LogErrors {
+
+ private static final Pattern EXCEPTION
+ = Pattern
+ .compile("^(?:Caused
by:\\s*)?((?:[a-zA-Z_$][\\w$]*\\.)+([A-Z][\\w$]*(?:Exception|Error|Throwable)))(.*)$");
+ private static final Pattern HISTORY_ROW =
Pattern.compile("^(\\S+:\\d+)\\s+\\S+/\\S+\\s");
+ // a logger named after the source location (the log EIP in dev mode:
orders.camel.yaml:44)
+ private static final Pattern LOGGER_SOURCE
+ =
Pattern.compile("\\s(\\S+\\.(?:yaml|yml|xml|java|groovy|kts|kt|js):\\d+)\\s+:\\s");
+ private static final Pattern NOISE =
Pattern.compile("[0-9A-Fa-f]{8,}(?:-[0-9A-Fa-f]+)*|\\d+");
+ private static final int MAX_CAUSES = 3;
+ private static final int MAX_TEXT = 300;
+
+ /**
+ * An ERROR of the log.
+ *
+ * @param time when it last happened
+ * @param logger the logger
+ * @param message the message of the ERROR line
+ * @param exception the exception (simple class name and message), or null
+ * @param causes the Caused by lines, at most three
+ * @param source the source location (file:line) of the step that
failed, from the Message History, or null
+ * @param count how many times it is on the screen
+ */
+ record LogError(String time, String logger, String message, String
exception, List<String> causes, String source,
+ int count) {
+
+ /** One line for the pick list: count, time and what failed. */
+ String label() {
+ String what = exception != null ? exception : message;
+ return (count > 1 ? "x" + count + " " : "") + time + " " + what;
+ }
+
+ /** The failure for the AI on a source line: the exception from the
log, and how often. */
+ String failure() {
+ String what = exception != null ? exception : message;
+ return "ERROR in the log" + (count > 1 ? " (" + count + " times)"
: "") + ": " + what;
+ }
+
+ /** The error for the AI when its source line is not known: the ERROR
line, the exception and its causes. */
+ String text() {
+ StringBuilder sb = new StringBuilder();
+ sb.append(time).append(" ERROR ").append(logger != null ? logger +
": " : "").append(message);
+ if (exception != null) {
+ sb.append('\n').append(exception);
+ }
+ for (String c : causes) {
+ sb.append('\n').append(c);
+ }
+ if (count > 1) {
+ sb.append("\n(").append(count).append(" times on the screen)");
+ }
+ return sb.toString();
+ }
+ }
+
+ private LogErrors() {
+ }
+
+ /**
+ * The distinct ERRORs on the screen, newest first.
+ *
+ * @param entries the log entries of the tab
+ * @param start the index of the first entry on the screen
+ * @param count how many entries are on the screen
+ */
+ static List<LogError> inView(List<LogEntry> entries, int start, int count)
{
+ if (entries == null || entries.isEmpty() || count <= 0) {
+ return List.of();
+ }
+ int from = Math.max(0, Math.min(start, entries.size() - 1));
+ // an ERROR whose stack trace is on the screen but not its first line
+ while (from > 0 && isContinuation(entries.get(from))) {
+ from--;
+ }
+ int to = Math.min(entries.size(), Math.max(start, 0) + count);
+ // the rest of the last ERROR on the screen (its Message History and
stack trace)
+ while (to < entries.size() && isContinuation(entries.get(to))) {
+ to++;
+ }
+ Map<String, LogError> distinct = new LinkedHashMap<>();
+ for (int i = from; i < to; i++) {
+ LogEntry head = entries.get(i);
+ if (isContinuation(head) || !"ERROR".equalsIgnoreCase(head.level))
{
+ continue;
+ }
+ List<String> detail = new ArrayList<>();
+ for (int j = i + 1; j < entries.size() &&
isContinuation(entries.get(j)); j++) {
+ detail.add(textOf(entries.get(j)));
+ }
+ LogError error = errorOf(head, detail);
+ String key = keyOf(error);
+ LogError seen = distinct.remove(key);
+ int times = (seen != null ? seen.count() : 0) + Math.max(1,
head.repeat);
+ distinct.put(key, new LogError(
+ error.time(), error.logger(), error.message(),
error.exception(),
+ error.causes(), error.source(), times));
+ }
+ List<LogError> answer = new ArrayList<>(distinct.values());
+ // newest first: the one last put is the last on the screen
+ Collections.reverse(answer);
+ return answer;
+ }
+
+ static LogError errorOf(LogEntry head, List<String> detail) {
+ String exception = null;
+ List<String> causes = new ArrayList<>();
+ String source = null;
+ boolean history = false;
+ for (String line : detail) {
+ String s = line.strip();
+ if (s.equals("Message History")) {
+ history = true;
+ continue;
+ }
+ if (history) {
+ Matcher row = HISTORY_ROW.matcher(s);
+ if (row.find()) {
+ // the last row is the step that failed
+ source = row.group(1);
+ continue;
+ }
+ if (s.equals("Stacktrace")) {
+ history = false;
+ }
+ }
+ Matcher m = EXCEPTION.matcher(s);
+ if (m.matches()) {
+ String simple = shorten(m.group(2) + m.group(3));
+ if (s.startsWith("Caused by:")) {
+ if (causes.size() < MAX_CAUSES) {
+ causes.add("Caused by: " + simple);
+ }
+ } else if (exception == null) {
+ exception = simple;
+ }
+ }
+ }
+ if (exception == null) {
+ // an exception in the ERROR line itself
+ Matcher m = EXCEPTION.matcher(head.message != null ?
head.message.strip() : "");
+ if (m.matches()) {
+ exception = shorten(m.group(2) + m.group(3));
+ }
+ }
+ if (source == null && head.raw != null) {
+ Matcher m = LOGGER_SOURCE.matcher(TuiHelper.stripAnsi(head.raw));
+ if (m.find()) {
+ source = m.group(1);
+ }
+ }
+ return new LogError(
+ head.time, head.logger, shorten(head.message != null ?
head.message : ""), exception,
+ causes, source, Math.max(1, head.repeat));
+ }
+
+ /** The same error repeated: the logger and what failed, without the ids,
counters and times in them. */
+ static String keyOf(LogError error) {
+ String what = error.exception() != null ? error.exception() :
error.message();
+ return error.logger() + "|" + NOISE.matcher(what != null ? what :
"").replaceAll("#");
+ }
+
+ private static boolean isContinuation(LogEntry e) {
+ return e.time == null || e.time.isEmpty();
+ }
+
+ private static String textOf(LogEntry e) {
+ if (e.message != null && !e.message.isEmpty()) {
+ return e.message;
+ }
+ return e.raw != null ? TuiHelper.stripAnsi(e.raw) : "";
+ }
+
+ private static String shorten(String s) {
+ String t = s.strip();
+ return t.length() > MAX_TEXT ? t.substring(0, MAX_TEXT) + "..." : t;
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTab.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTab.java
index 1a1bd3d93d42..451da88bc679 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTab.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTab.java
@@ -38,6 +38,7 @@ import dev.tamboui.text.CharWidth;
import dev.tamboui.text.Line;
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.tui.event.MouseEvent;
import dev.tamboui.tui.event.MouseEventKind;
@@ -98,6 +99,12 @@ class LogTab extends AbstractTab {
private boolean cachedCompact;
private int hScroll;
private boolean showLogLevelPopup;
+ // fix with AI (Shift+F8): the entries on the screen, and the pick list
when several ERRORs are on it
+ private int lastViewStart;
+ private int lastViewCount;
+ private boolean showErrorPickPopup;
+ private List<LogErrors.LogError> errorChoices = List.of();
+ private final ListState errorPickListState = new ListState();
private final SearchHighlighter search = new SearchHighlighter();
@@ -139,6 +146,31 @@ class LogTab extends AbstractTab {
return handled;
}
+ if (showErrorPickPopup) {
+ if (ke.isUp()) {
+ errorPickListState.selectPrevious();
+ return true;
+ }
+ if (ke.isDown()) {
+ errorPickListState.selectNext(errorChoices.size());
+ return true;
+ }
+ if (ke.isConfirm()) {
+ Integer sel = errorPickListState.selected();
+ showErrorPickPopup = false;
+ if (sel != null && sel >= 0 && sel < errorChoices.size()) {
+ askAiAbout(errorChoices.get(sel));
+ }
+ return true;
+ }
+ return true;
+ }
+ if (ke.isKey(KeyCode.F8) && ke.hasShift()) {
+ // fix with AI: the ERROR on the screen, or pick one when there
are several
+ askAiAboutErrorsInView();
+ return true;
+ }
+
if (showLogLevelPopup) {
if (ke.isUp()) {
logLevelListState.selectPrevious();
@@ -244,6 +276,10 @@ class LogTab extends AbstractTab {
showLogLevelPopup = false;
return true;
}
+ if (showErrorPickPopup) {
+ showErrorPickPopup = false;
+ return true;
+ }
return false;
}
@@ -395,7 +431,19 @@ class LogTab extends AbstractTab {
List<Line> allLines = cachedLogLines;
int start = Math.min(scroll, Math.max(0, allLines.size() -
visibleHeight));
- List<Line> visibleLines = allLines.subList(start,
Math.min(allLines.size(), start + visibleHeight));
+ int count = visibleHeight;
+ if (wordWrap) {
+ // a wrapped line takes more than one row: show the entries that
fit, the newest ones when following
+ int textWidth = Math.max(1, inner.width() - 1);
+ if (followMode) {
+ start = firstEntryThatFits(allLines, visibleHeight, textWidth);
+ scroll = start;
+ }
+ count = entriesThatFit(allLines, start, visibleHeight, textWidth);
+ }
+ List<Line> visibleLines = allLines.subList(start,
Math.min(allLines.size(), start + count));
+ lastViewStart = start;
+ lastViewCount = count;
int currentMatchLine = search.currentMatchLine();
if (currentMatchLine >= 0 || search.hasFindTerm() ||
search.hasHighlightTerm()) {
@@ -427,6 +475,9 @@ class LogTab extends AbstractTab {
if (showLogLevelPopup) {
renderLogLevelPopup(frame, area);
}
+ if (showErrorPickPopup) {
+ renderErrorPickPopup(frame, area);
+ }
}
@Override
@@ -440,6 +491,11 @@ class LogTab extends AbstractTab {
hintLast(spans, "Enter", "set level");
return;
}
+ if (showErrorPickPopup) {
+ hint(spans, "Esc", "cancel");
+ hintLast(spans, "Enter", "fix with AI");
+ return;
+ }
if (search.hasFindTerm()) {
search.renderFindStatus(spans);
@@ -460,6 +516,87 @@ class LogTab extends AbstractTab {
}
}
+ @Override
+ public void renderFKeyHints(List<Span> spans) {
+ // with the global F-keys, as in the Source and Errors tabs; not while
a pick list is open
+ if (!showErrorPickPopup && !showLogLevelPopup && ctx.askAiCallback !=
null && hasErrorInView()) {
+ hint(spans, "Shift+F8", "fix with AI");
+ }
+ }
+
+ /**
+ * Whether an ERROR is on the screen, for the Shift+F8 hint: also one
whose stack trace is on it but not its first
+ * line, as the screen is often all stack trace.
+ */
+ private boolean hasErrorInView() {
+ return !LogErrors.inView(filteredLogEntries, lastViewStart,
lastViewCount).isEmpty();
+ }
+
+ /**
+ * Fix with AI (Shift+F8) in the log: the ERROR on the screen is asked
about right away; when different ERRORs are
+ * on it, a pick list comes up, newest first.
+ */
+ private void askAiAboutErrorsInView() {
+ if (ctx.askAiCallback == null) {
+ return;
+ }
+ List<LogErrors.LogError> errors = LogErrors.inView(filteredLogEntries,
lastViewStart, lastViewCount);
+ if (errors.isEmpty()) {
+ if (ctx.notificationCallback != null) {
+ ctx.notificationCallback.accept("No ERROR on the screen:
scroll to one, or ask the AI with F8", false);
+ }
+ } else if (errors.size() == 1) {
+ askAiAbout(errors.get(0));
+ } else {
+ errorChoices = errors;
+ errorPickListState.select(0);
+ showErrorPickPopup = true;
+ }
+ }
+
+ /**
+ * Asks the AI about an ERROR of the log: on its source line when the
Message History of a failed exchange names the
+ * step that failed, else the error itself.
+ */
+ private void askAiAbout(LogErrors.LogError error) {
+ IntegrationInfo info = ctx.findSelectedIntegration();
+ Path file = error.source() != null && info != null
+ ?
RuntimeFailures.fileOf(FilesBrowser.resolveSourceDirectory(info),
error.source()) : null;
+ int line = file != null ? LiveRunLines.lineOf(error.source(),
file.getFileName().toString()) : -1;
+ if (line >= 0) {
+ ctx.askAiCallback.fixFailure(file, line + 1, error.failure(),
RuntimeFailures.lineText(file, line));
+ } else {
+ ctx.askAiCallback.explainLogError(error.text());
+ }
+ }
+
+ private void renderErrorPickPopup(Frame frame, Rect area) {
+ int popupW = Math.min(area.width(), Math.max(40, area.width() * 3 /
4));
+ int popupH = Math.min(area.height(), errorChoices.size() + 2);
+ int x = area.left() + Math.max(0, (area.width() - popupW) / 2);
+ int y = area.top() + Math.max(0, (area.height() - popupH) / 2);
+ Rect popup = new Rect(x, y, popupW, popupH);
+
+ frame.renderWidget(Clear.INSTANCE, popup);
+
+ List<ListItem> items = new ArrayList<>(errorChoices.size());
+ for (LogErrors.LogError e : errorChoices) {
+ items.add(ListItem.from(" " + e.label()).style(Theme.error()));
+ }
+ ListWidget list = ListWidget.builder()
+ .items(items.toArray(new ListItem[0]))
+ .highlightStyle(Theme.selectionBg())
+ .highlightSymbol("")
+ .scrollMode(ScrollMode.NONE)
+ .block(Block.builder()
+ .borderType(BorderType.ROUNDED).borders(Borders.ALL)
+ .title(" Fix with AI: which ERROR? ")
+ .build())
+ .build();
+
+ frame.renderStatefulWidget(list, popup, errorPickListState);
+ }
+
private void renderLogLevelPopup(Frame frame, Rect area) {
int popupW = 24;
int popupH = 7;
@@ -488,6 +625,12 @@ class LogTab extends AbstractTab {
frame.renderStatefulWidget(list, popup, logLevelListState);
}
+ /** The entries of the tab, as read from the log file (tests). */
+ void setEntriesForTesting(List<LogEntry> entries) {
+ filteredLogEntries = new ArrayList<>(entries);
+ logLoading = false;
+ }
+
void setLogLevel(String level) {
if (ctx.selectedPid != null) {
sendLoggerLevelAction(ctx.selectedPid, level);
@@ -681,6 +824,67 @@ class LogTab extends AbstractTab {
Span.raw(entry.message != null ? entry.message : ""));
}
+ /**
+ * The rows a line of text takes when wrapped at word boundaries into the
width, as the paragraph wraps it with word
+ * wrap on (a word longer than the width is broken).
+ */
+ static int wrappedRows(String text, int width) {
+ if (width <= 0 || text == null || text.isEmpty()) {
+ return 1;
+ }
+ int rows = 1;
+ int col = 0;
+ boolean first = true;
+ for (String word : text.split(" ", -1)) {
+ int w = CharWidth.of(word);
+ if (first) {
+ col = w;
+ first = false;
+ } else if (col + 1 + w <= width) {
+ col += 1 + w;
+ } else {
+ rows++;
+ col = w;
+ }
+ while (col > width) {
+ rows++;
+ col -= width;
+ }
+ }
+ return rows;
+ }
+
+ /** The first entry from which the last entries fit the height when
wrapped (follow mode with word wrap). */
+ static int firstEntryThatFits(List<Line> lines, int height, int width) {
+ int rows = 0;
+ int i = lines.size();
+ while (i > 0) {
+ int r = wrappedRows(lineText(lines.get(i - 1)), width);
+ if (rows + r > height) {
+ break;
+ }
+ rows += r;
+ i--;
+ }
+ // an entry taller than the screen is still shown, from its first row
+ return i == lines.size() ? Math.max(0, i - 1) : i;
+ }
+
+ /** How many entries from the start fit the height when wrapped (at least
one). */
+ static int entriesThatFit(List<Line> lines, int start, int height, int
width) {
+ int rows = 0;
+ int n = 0;
+ for (int i = start; i < lines.size(); i++) {
+ int r = wrappedRows(lineText(lines.get(i)), width);
+ if (rows + r > height && n > 0) {
+ break;
+ }
+ rows += r;
+ n++;
+ }
+ return n;
+ }
+
private static String lineText(Line line) {
StringBuilder sb = new StringBuilder();
line.spans().forEach(sp -> sb.append(sp.content()));
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java
index bb31fa8400f0..5af4f3701998 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java
@@ -225,5 +225,12 @@ class MonitorContext {
default void fixFailure(Path file, int line, String failure, String
lineText) {
fixProblem(file, line, failure, lineText);
}
+
+ /**
+ * Asks the AI about an ERROR of the log whose source line is not
known (Shift+F8 in the Log tab): the error
+ * line, the exception and its causes.
+ */
+ default void explainLogError(String error) {
+ }
}
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/errors.md
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/errors.md
index bfca204c2d1e..8db5b942e89e 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/errors.md
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/errors.md
@@ -110,6 +110,7 @@ Press `Esc` to navigate back one route in drill-down mode.
- `Up/Down` — select error (list) / navigate path steps (diagram)
- `Enter` — view error details
+- `Shift+F8` — fix with AI: ask the AI about the selected error, on the source
line it happened at
- `d` — toggle error diagram (open and close)
- `Esc` — back to list / back one route in diagram drill-down
- `i` — cycle info panel size (narrow / wide / full) in diagram
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/log.md
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/log.md
index b789890600a0..ea077b993d5a 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/log.md
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/log.md
@@ -81,6 +81,16 @@ restores the pinned log automatically.
The pinned log panel can also be resized by dragging its top border
with the mouse.
+## Fix with AI
+
+Press `Shift+F8` to ask the AI about an ERROR on the screen. When the
+ERROR is a failed exchange, the step that failed comes from its Message
+History, and the AI is asked to fix that line of the route. Otherwise
+the AI explains the error and fixes it if the cause is in the project.
+
+When different ERRORs are on the screen, a list comes up to pick one,
+newest first; the same error repeated is listed once with its count.
+
## Keys
- `Up/Down` — scroll log
@@ -91,6 +101,7 @@ with the mouse.
- `N` — previous match
- `h` — highlight a word
- `l` — change log level
+- `Shift+F8` — fix with AI: the ERROR on the screen (a list to pick from when
there are several)
- `f` — toggle follow mode (`End` turns it on). Scrolling with the mouse
wheel, `Up`, `PgUp` or `Home` turns it off; the title then shows `(paused, End
follows)` when new lines arrive below the view
- `w` — toggle word wrap
- `c` — toggle the compact view (on by default): the time, level, logger and
message of each line, without the date, pid and thread
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LogErrorsTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LogErrorsTest.java
new file mode 100644
index 000000000000..722106f000d2
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LogErrorsTest.java
@@ -0,0 +1,163 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * The ERRORs on the screen of the Log tab for fix with AI (Shift+F8,
CAMEL-25358), from the log lines of a failed
+ * exchange as Camel writes them.
+ */
+class LogErrorsTest {
+
+ private static final String DIRECT
+ = "DirectConsumerNotAvailableException: No consumers available on
endpoint: direct://big-orders.";
+
+ /** The ERROR of a failed exchange, as the default error handler logs it
in dev mode. */
+ private static List<String> failedDelivery(String time, String exchangeId)
{
+ return List.of(
+ "2026-10-05 " + time + " ERROR 29373 --- [ timer://orders]
ocessor.errorhandler.DefaultErrorHandler : "
+ + "Failed delivery for (MessageId: " + exchangeId + "
on ExchangeId: " + exchangeId
+ + "). Exhausted after delivery attempt: 1 caught: "
+ +
"org.apache.camel.component.direct.DirectConsumerNotAvailableException: No
consumers",
+ "",
+ "Message History",
+
"--------------------------------------------------------------------------------------------",
+ "Source ID
Processor ",
+ "orders.camel.yaml:4 orders/orders
from[timer://orders] ",
+ "orders.camel.yaml:20 orders/choice1
choice[when[simple]] ",
+ "orders.camel.yaml:24 orders/to1
to[direct:big-orders] ",
+ "",
+ "Stacktrace",
+
"--------------------------------------------------------------------------------------------",
+
"org.apache.camel.component.direct.DirectConsumerNotAvailableException: No
consumers available on "
+
+ "endpoint: direct://big-orders. Exchange["
+
+ exchangeId
+
+ "]",
+ "\tat
org.apache.camel.component.direct.DirectProducer.process(DirectProducer.java:83)",
+ "\tat
org.apache.camel.processor.SendProcessor.process(SendProcessor.java:172)");
+ }
+
+ private static List<String> info(String time, String message) {
+ return List.of("2026-10-05 " + time + " INFO 29373 --- [
timer://orders] orders.camel.yaml:18 : "
+ + message);
+ }
+
+ private static List<LogEntry> entries(List<List<String>> records) {
+ List<LogEntry> answer = new ArrayList<>();
+ for (List<String> r : records) {
+ for (String line : r) {
+ answer.add(LogTab.parseLogLine(line));
+ }
+ }
+ return answer;
+ }
+
+ @Test
+ void theErrorOfAFailedExchangeHasItsSourceLineAndException() {
+ List<LogEntry> log = entries(List.of(failedDelivery("19:57:30.116",
"E50D-01")));
+
+ List<LogErrors.LogError> errors = LogErrors.inView(log, 0, log.size());
+
+ assertThat(errors).hasSize(1);
+ LogErrors.LogError e = errors.get(0);
+ assertThat(e.source()).isEqualTo("orders.camel.yaml:24");
+ assertThat(e.exception()).startsWith(DIRECT);
+ assertThat(e.logger()).isEqualTo("DefaultErrorHandler");
+ assertThat(e.failure()).startsWith("ERROR in the log: " + DIRECT);
+ }
+
+ @Test
+ void theSameErrorIsCountedOnceAndDifferentOnesNewestFirst() {
+ List<String> other = List.of(
+ "2026-10-05 19:58:40.000 ERROR 29373 --- [ timer://orders]
org.acme.Billing : "
+ + "Billing failed",
+ "java.lang.IllegalStateException: no account 42",
+ "\tat org.acme.Billing.charge(Billing.java:10)",
+ "Caused by: java.net.ConnectException: Connection refused");
+ List<LogEntry> log = entries(List.of(failedDelivery("19:57:30.116",
"E50D-01"), info("19:57:33.000", "Order 2"),
+ failedDelivery("19:58:15.134", "E50D-07"), other));
+
+ List<LogErrors.LogError> errors = LogErrors.inView(log, 0, log.size());
+
+ assertThat(errors).hasSize(2);
+ assertThat(errors.get(0).logger()).isEqualTo("Billing");
+
assertThat(errors.get(0).exception()).isEqualTo("IllegalStateException: no
account 42");
+ assertThat(errors.get(0).causes()).containsExactly("Caused by:
ConnectException: Connection refused");
+ assertThat(errors.get(0).source()).isNull();
+ assertThat(errors.get(1).count()).isEqualTo(2);
+ assertThat(errors.get(1).time()).isEqualTo("19:58:15.134");
+ assertThat(errors.get(1).label()).startsWith("x2 19:58:15.134 " +
DIRECT);
+ }
+
+ @Test
+ void aStackTraceOnTheScreenBelongsToTheErrorAboveIt() {
+ List<LogEntry> log = entries(List.of(info("19:57:29.000", "Order 1"),
failedDelivery("19:57:30.116", "E50D-01"),
+ info("19:57:33.000", "Order 2")));
+ // the screen starts at the stack trace, below the ERROR line
+ int start = log.size() - 3;
+
+ List<LogErrors.LogError> errors = LogErrors.inView(log, start, 3);
+
+ assertThat(errors).hasSize(1);
+ assertThat(errors.get(0).source()).isEqualTo("orders.camel.yaml:24");
+ }
+
+ @Test
+ void anErrorOfTheLogEipHasTheSourceLineOfItsLogger() {
+ List<LogEntry> log = entries(List.of(List.of(
+ "2026-10-05 20:14:59.906 ERROR 33838 --- [mer://inventory]
orders.camel.yaml:44 : "
+ + "Stock is low for item
7 in warehouse Copenhagen")));
+
+ LogErrors.LogError e = LogErrors.inView(log, 0, log.size()).get(0);
+
+ assertThat(e.source()).isEqualTo("orders.camel.yaml:44");
+ assertThat(e.exception()).isNull();
+ assertThat(e.failure()).isEqualTo("ERROR in the log: Stock is low for
item 7 in warehouse Copenhagen");
+ }
+
+ @Test
+ void noErrorOnTheScreen() {
+ List<LogEntry> log = entries(List.of(failedDelivery("19:57:30.116",
"E50D-01"), info("19:57:33.000", "Order 2"),
+ info("19:57:36.000", "Order 3")));
+
+ assertThat(LogErrors.inView(log, log.size() - 2, 2)).isEmpty();
+ assertThat(LogErrors.inView(List.of(), 0, 10)).isEmpty();
+ }
+
+ @Test
+ void theQuestionAboutAnErrorOfTheLog() {
+ List<LogEntry> log = entries(List.of(List.of(
+ "2026-10-05 19:58:40.000 ERROR 29373 --- [ timer://orders]
org.acme.Billing : "
+ + "Billing failed",
+ "java.lang.IllegalStateException: no account 42",
+ "Caused by: java.net.ConnectException: Connection refused")));
+
+ String q = AiFixPrompt.ofLogError(LogErrors.inView(log, 0,
log.size()).get(0).text());
+
+ assertThat(q).startsWith("Explain this ERROR from the log of the
running integration:\n")
+ .contains("ERROR Billing: Billing failed")
+ .contains("IllegalStateException: no account 42")
+ .contains("Caused by: ConnectException: Connection refused")
+ .contains("camel_edit_file");
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTabRenderTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTabRenderTest.java
index ddb156189ecb..4a3c67e96d6a 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTabRenderTest.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTabRenderTest.java
@@ -21,6 +21,9 @@ import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import dev.tamboui.text.Span;
+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;
@@ -107,6 +110,78 @@ class LogTabRenderTest {
"Should show loading state or Log title");
}
+ @Test
+ void wrappedLinesAreCountedByTheirRows() {
+ assertEquals(1, LogTab.wrappedRows("short line", 40));
+ assertEquals(2, LogTab.wrappedRows("one two three four five six seven
eight nine ten", 30));
+ assertEquals(3, LogTab.wrappedRows("x".repeat(70), 30));
+ assertEquals(1, LogTab.wrappedRows("", 30));
+ }
+
+ @Test
+ void followingWithWordWrapShowsTheNewestLine() {
+ List<LogEntry> entries = new ArrayList<>();
+ for (int i = 0; i < 40; i++) {
+ // long continuation lines that wrap to two rows each
+ entries.add(LogTab.parseLogLine("orders.camel.yaml:" + i + "
orders/to" + i + " "
+ + "to[direct:big-orders]
".repeat(8)));
+ }
+ entries.add(LogTab.parseLogLine(
+ "2026-10-05 20:14:59.906 ERROR 33838 --- [mer://inventory]
orders.camel.yaml:44 : The newest line"));
+ LogTab tab = new LogTab(ctx);
+ tab.setEntriesForTesting(entries);
+
+ String rendered = TuiTestHelper.renderToString(tab, 120, 20);
+
+ assertTrue(rendered.contains("The newest line"), rendered);
+ }
+
+ @Test
+ void shiftF8WithDifferentErrorsOnTheScreenShowsThePickList() {
+ ctx.askAiCallback = (file, line, problem, text) -> {
+ };
+ List<LogEntry> entries = new ArrayList<>();
+ for (String line : List.of(
+ "2026-10-05 20:14:57.676 ERROR 33838 --- [timer://billing]
ocessor.errorhandler.DefaultErrorHandler : "
+ + "Failed delivery for (MessageId: 4E93-1).
Exhausted after delivery attempt: 1",
+ "Message History",
+ "orders.camel.yaml:34
billing/throwException2 throwException[]",
+ "Stacktrace",
+ "java.lang.IllegalStateException: No account for customer 42",
+ "\tat
org.apache.camel.processor.ThrowExceptionProcessor.process(ThrowExceptionProcessor.java:68)",
+ "2026-10-05 20:14:59.906 ERROR 33838 --- [mer://inventory]
orders.camel.yaml:44 : "
+
+ "Stock is low for item 7")) {
+ entries.add(LogTab.parseLogLine(line));
+ }
+ LogTab tab = new LogTab(ctx);
+ tab.setEntriesForTesting(entries);
+ TuiTestHelper.renderToString(tab, 120, 20);
+
+ tab.handleKeyEvent(KeyEvent.ofKey(KeyCode.F8, KeyModifiers.SHIFT));
+ String rendered = TuiTestHelper.renderToString(tab, 120, 20);
+
+ assertTrue(rendered.contains("which ERROR"), rendered);
+ assertTrue(rendered.contains("Stock is low for item 7"), rendered);
+ assertTrue(rendered.contains("IllegalStateException: No account for
customer 42"), rendered);
+ }
+
+ @Test
+ void theFixWithAiHintIsWithTheFKeysWhenAnErrorIsOnTheScreen() {
+ ctx.askAiCallback = (file, line, problem, text) -> {
+ };
+ LogTab tab = new LogTab(ctx);
+ tab.setEntriesForTesting(List.of(
+ LogTab.parseLogLine("2026-10-05 21:10:20.969 INFO 1 --- [
timer://orders] orders.camel.yaml:18 : Order 12"),
+ LogTab.parseLogLine(
+ "2026-10-05 21:10:25.025 ERROR 1 --- [mer://inventory]
orders.camel.yaml:43 : Stock is low")));
+ TuiTestHelper.renderToString(tab, 120, 20);
+
+ List<Span> spans = new ArrayList<>();
+ tab.renderFKeyHints(spans);
+ String hints = spans.stream().map(Span::content).reduce("",
String::concat);
+ assertTrue(hints.contains("Shift+F8"), hints);
+ }
+
@Test
void renderFooterHints() {
LogTab tab = new LogTab(ctx);