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 c421495544ca chore: camel-jbang - layout polish: an Overview summary,
wrapped lines keep their indent, a compact log, a waterfall time line (#27225)
c421495544ca is described below
commit c421495544cad14ac8643ba1f6f695028d62a024
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Oct 1 20:07:15 2026 +0200
chore: camel-jbang - layout polish: an Overview summary, wrapped lines keep
their indent, a compact log, a waterfall time line (#27225)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---
.../dsl/jbang/core/commands/tui/ActionsPopup.java | 6 +-
.../jbang/core/commands/tui/ArchitectureView.java | 8 +-
.../dsl/jbang/core/commands/tui/HistoryTab.java | 82 ++++++++++++++-----
.../camel/dsl/jbang/core/commands/tui/LogTab.java | 65 +++++++++++++--
.../dsl/jbang/core/commands/tui/OverviewTab.java | 60 +++++++++++++-
.../jbang/core/commands/tui/RunOptionsForm.java | 13 ++-
.../dsl/jbang/core/commands/tui/SubViewBar.java | 17 ++++
.../dsl/jbang/core/commands/tui/TuiHelper.java | 92 ++++++++++++++++++++++
.../src/main/resources/tui/help/log.md | 1 +
.../jbang/core/commands/tui/ActionsPopupTest.java | 14 ++++
.../jbang/core/commands/tui/HangingWrapTest.java | 75 ++++++++++++++++++
.../core/commands/tui/HistoryTabRenderTest.java | 26 ++++++
.../jbang/core/commands/tui/LogTabRenderTest.java | 8 ++
.../jbang/core/commands/tui/SubViewBarTest.java | 15 ++++
14 files changed, 448 insertions(+), 34 deletions(-)
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java
index 0c27ae5b40e9..000c2c44fa3c 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java
@@ -1483,7 +1483,11 @@ class ActionsPopup {
Theme.toggle();
refreshTheme();
}
- case SCREENSHOT -> screenshotAction.run();
+ case SCREENSHOT -> {
+ // the screen as it is shown, with an open menu or popup (for
docs): nothing is closed first
+ screenshotAction.run();
+ return true;
+ }
case SHOW_KEYSTROKES -> toggleKeystrokes.run();
case TAPE_RECORDING -> toggleTapeRecording.run();
case DOCTOR -> doctorPopup.open();
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ArchitectureView.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ArchitectureView.java
index 33d7d5645917..7b5dad3d9d70 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ArchitectureView.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ArchitectureView.java
@@ -596,8 +596,8 @@ final class ArchitectureView {
section(lines, "Routes", names(g.routes()), Set.of());
section(lines, "Endpoints", g.entryPoints(), Set.of());
frame.renderWidget(Paragraph.builder()
- .text(Text.from(lines))
- .overflow(Overflow.WRAP_WORD)
+ .text(Text.from(TuiHelper.hangingWrap(lines, Math.max(1,
area.width() - 2))))
+ .overflow(Overflow.CLIP)
.block(Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL).title("
Info ").build())
.build(), area);
return;
@@ -619,8 +619,8 @@ final class ArchitectureView {
Theme.warning())));
}
frame.renderWidget(Paragraph.builder()
- .text(Text.from(lines))
- .overflow(Overflow.WRAP_WORD)
+ .text(Text.from(TuiHelper.hangingWrap(lines, Math.max(1,
area.width() - 2))))
+ .overflow(Overflow.CLIP)
.block(Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL).title("
Info ").build())
.build(), area);
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java
index 09d6b152d7ac..eaea39b466ff 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java
@@ -1437,22 +1437,23 @@ class HistoryTab extends AbstractTab {
}
record WaterfallStep(String nodeId, String processor, String direction,
boolean first, boolean last,
- int nodeLevel, long elapsed, int inlineDepth) {
+ int nodeLevel, long elapsed, int inlineDepth, long startMs) {
static WaterfallStep fromTrace(TraceEntry e) {
return new WaterfallStep(
e.nodeId, e.processor, e.direction, e.first, e.last,
e.nodeLevel, e.elapsed,
- e.inlineDepth);
+ e.inlineDepth, e.epochMs);
}
static WaterfallStep fromHistory(HistoryEntry e) {
return new WaterfallStep(
e.nodeId, e.processor, e.direction, e.first, e.last,
e.nodeLevel, e.elapsed,
- e.inlineDepth);
+ e.inlineDepth, e.epochMs);
}
WaterfallStep withElapsed(long newElapsed) {
- return new WaterfallStep(nodeId, processor, direction, first,
last, nodeLevel, newElapsed, inlineDepth);
+ return new WaterfallStep(
+ nodeId, processor, direction, first, last, nodeLevel,
newElapsed, inlineDepth, startMs);
}
String label() {
@@ -1507,11 +1508,14 @@ class HistoryTab extends AbstractTab {
return;
}
+ long[] offsets = waterfallOffsets(forward);
long maxElapsed = 0;
long minDuration = Long.MAX_VALUE;
long maxDuration = 0;
- for (WaterfallStep e : forward) {
- maxElapsed = Math.max(maxElapsed, e.elapsed);
+ for (int i = 0; i < forward.size(); i++) {
+ WaterfallStep e = forward.get(i);
+ // the time line: as long as the step that ends last
+ maxElapsed = Math.max(maxElapsed, offsets[i] + e.elapsed);
if (!e.first) {
minDuration = Math.min(minDuration, e.elapsed);
maxDuration = Math.max(maxDuration, e.elapsed);
@@ -1558,7 +1562,7 @@ class HistoryTab extends AbstractTab {
int end = Math.min(waterfallScroll + visibleLines, forward.size());
List<Line> lines = new ArrayList<>();
for (int i = waterfallScroll; i < end; i++) {
- lines.add(renderWaterfallStep(forward.get(i), labelWidth,
barMaxWidth,
+ lines.add(renderWaterfallStep(forward.get(i), offsets[i],
labelWidth, barMaxWidth,
maxElapsed, minDuration, maxDuration, i ==
selectedForwardIndex));
}
@@ -1577,8 +1581,50 @@ class HistoryTab extends AbstractTab {
}
}
+ /**
+ * When each step started, in ms from the first step processed, so the
bars of a waterfall stand where they ran. A
+ * route row starts with its first step (its own time is when the exchange
was created, before it was processed).
+ * Without the times of the steps, every bar starts at 0.
+ */
+ static long[] waterfallOffsets(List<WaterfallStep> steps) {
+ long[] offsets = new long[steps.size()];
+ // the steps processed set the time line; a route's first and last
rows carry when the exchange was created
+ long t0 = Long.MAX_VALUE;
+ for (WaterfallStep s : steps) {
+ if (!s.first() && !s.last()) {
+ if (s.startMs() <= 0) {
+ return offsets;
+ }
+ t0 = Math.min(t0, s.startMs());
+ }
+ }
+ if (t0 == Long.MAX_VALUE) {
+ return offsets;
+ }
+ for (int i = 0; i < steps.size(); i++) {
+ WaterfallStep s = steps.get(i);
+ if (s.first()) {
+ // a route starts with its first step
+ long start = i + 1 < steps.size() && !steps.get(i + 1).last()
? steps.get(i + 1).startMs() : t0;
+ offsets[i] = Math.max(0, start - t0);
+ } else if (s.last()) {
+ // the end of a route stands where the route started
+ offsets[i] = 0;
+ for (int j = i - 1; j >= 0; j--) {
+ if (steps.get(j).first() &&
nodeIdEquals(steps.get(j).nodeId(), s.nodeId())) {
+ offsets[i] = offsets[j];
+ break;
+ }
+ }
+ } else {
+ offsets[i] = Math.max(0, s.startMs() - t0);
+ }
+ }
+ return offsets;
+ }
+
private static Line renderWaterfallStep(
- WaterfallStep entry, int labelWidth, int maxBarWidth,
+ WaterfallStep entry, long offset, int labelWidth, int maxBarWidth,
long maxElapsed, long minDuration, long maxDuration, boolean
selected) {
String indicator = selected ? ">> " : " ";
String indent = " ".repeat(entry.nodeLevel);
@@ -1593,8 +1639,10 @@ class HistoryTab extends AbstractTab {
Style bandStyle = isRoute ? Style.EMPTY.dim() :
TuiHelper.colorForDuration(entry.elapsed, minDuration, maxDuration);
double ratio = maxElapsed > 0 ? (double) entry.elapsed / maxElapsed :
0;
+ int lead = maxElapsed > 0 ? (int) Math.round((double) offset /
maxElapsed * maxBarWidth) : 0;
int barWidth = Math.max(1, (int) Math.round(ratio * maxBarWidth));
- String bar = "█".repeat(barWidth);
+ lead = Math.min(lead, Math.max(0, maxBarWidth - barWidth));
+ String bar = " ".repeat(lead) + "█".repeat(barWidth);
String durationStr = entry.elapsed + "ms";
int pad = Math.max(1, 8 - durationStr.length());
@@ -2327,19 +2375,11 @@ class HistoryTab extends AbstractTab {
Rect inner = block.inner(area);
int visibleHeight = Math.max(1, inner.height());
int visibleWidth = Math.max(1, inner.width() - 1);
- int contentHeight;
if (wordWrap) {
- contentHeight = 0;
- for (Line l : lines) {
- int w = l.width();
- contentHeight += Math.max(1, (w + visibleWidth - 1) /
visibleWidth);
- }
- // word-wrap breaks at word boundaries which can produce more lines
- // than char-based math; add padding so last section is always
reachable
- contentHeight += visibleHeight;
- } else {
- contentHeight = lines.size();
+ // wrapped here, so a long header value continues under the value,
not at the left edge
+ lines = TuiHelper.hangingWrap(lines, visibleWidth);
}
+ int contentHeight = lines.size();
int maxScroll = Math.max(0, contentHeight - visibleHeight);
if (scroll[0] > maxScroll) {
scroll[0] = maxScroll;
@@ -2357,7 +2397,7 @@ class HistoryTab extends AbstractTab {
List<Line> visibleLines = (!wordWrap && hScroll[0] > 0) ?
applyHSkip(lines, hScroll[0]) : lines;
Paragraph detail = Paragraph.builder()
.text(Text.from(visibleLines))
- .overflow(wordWrap ? Overflow.WRAP_WORD : Overflow.CLIP)
+ .overflow(Overflow.CLIP)
.scroll(scroll[0])
.build();
frame.renderWidget(detail, hChunks.get(0));
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 838ed865fbee..1a1bd3d93d42 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
@@ -93,6 +93,9 @@ class LogTab extends AbstractTab {
private long evictedSeen;
private boolean followMode = true;
private boolean wordWrap = true;
+ // time, level, logger and message only: the date, pid and thread take
most of a row in a terminal
+ private boolean compact = true;
+ private boolean cachedCompact;
private int hScroll;
private boolean showLogLevelPopup;
@@ -178,6 +181,11 @@ class LogTab extends AbstractTab {
hScroll = 0;
return true;
}
+ if (ke.isCharIgnoreCase('c')) {
+ compact = !compact;
+ hScroll = 0;
+ return true;
+ }
if (!wordWrap) {
if (ke.isLeft()) {
followMode = false;
@@ -340,19 +348,29 @@ class LogTab extends AbstractTab {
int hSkip = wordWrap ? 0 : hScroll;
boolean entriesChanged = entries != cachedLogEntries;
- if (entriesChanged || hSkip != cachedLogHSkip) {
+ if (entriesChanged || hSkip != cachedLogHSkip || compact !=
cachedCompact) {
cachedLogEntries = entries;
cachedLogHSkip = hSkip;
+ cachedCompact = compact;
List<Line> built = new ArrayList<>(entries.size());
int maxW = 0;
for (int i = 0; i < entries.size(); i++) {
LogEntry entry = entries.get(i);
String raw = entry.raw != null ? entry.raw : "";
- if (!wordWrap) {
- maxW = Math.max(maxW,
CharWidth.of(TuiHelper.stripAnsi(raw)));
+ Line line;
+ if (compact && !entry.time.isEmpty()) {
+ line = compactLine(entry);
+ if (!wordWrap) {
+ maxW = Math.max(maxW, line.width());
+ line = hSkip > 0 ?
TuiHelper.ansiToLine(lineText(line), hSkip) : line;
+ }
+ } else {
+ if (!wordWrap) {
+ maxW = Math.max(maxW,
CharWidth.of(TuiHelper.stripAnsi(raw)));
+ }
+ line = raw.indexOf('\u001B') >= 0
+ ? TuiHelper.ansiToLine(raw, hSkip) :
colorizePlainLog(raw, entry);
}
- Line line = raw.indexOf('\u001B') >= 0
- ? TuiHelper.ansiToLine(raw, hSkip) :
colorizePlainLog(raw, entry);
if (entry.repeat > 1) {
line = line.append(Span.styled(" (x" + entry.repeat +
")", Theme.warning().bold()));
}
@@ -430,6 +448,7 @@ class LogTab extends AbstractTab {
}
search.renderSearchHints(spans);
hint(spans, "w", "wrap" + (wordWrap ? " [on]" : " [off]"));
+ hint(spans, "c", "compact" + (compact ? " [on]" : " [off]"));
if (!ctx.isInfraSelected()) {
hint(spans, "l", "level");
}
@@ -643,6 +662,42 @@ class LogTab extends AbstractTab {
+
"\\[([^]]*)]\\s+"
+
"(\\S+)\\s*:\\s*(.*)$");
+ /** A log line in the compact view: 17:24:30.257 INFO
VertxPlatformHttpServer Vert.x HttpServer started ... */
+ static Line compactLine(LogEntry entry) {
+ String time = entry.time.length() > 12 ?
entry.time.substring(entry.time.length() - 12) : entry.time;
+ // the time of day: the date is the same for every line of a run
+ int space = time.indexOf(' ');
+ if (space >= 0) {
+ time = time.substring(space + 1);
+ }
+ String logger = entry.logger != null ?
TuiHelper.truncate(entry.logger, 24) : "";
+ return Line.from(
+ Span.styled(time, DIM),
+ Span.raw(" "),
+ Span.styled(String.format("%5s", entry.level),
levelStyle(entry.level)),
+ Span.raw(" "),
+ Span.styled(String.format("%-24s", logger),
Style.EMPTY.fg(Theme.accent())),
+ Span.raw(" "),
+ Span.raw(entry.message != null ? entry.message : ""));
+ }
+
+ private static String lineText(Line line) {
+ StringBuilder sb = new StringBuilder();
+ line.spans().forEach(sp -> sb.append(sp.content()));
+ return sb.toString();
+ }
+
+ private static Style levelStyle(String level) {
+ return switch (level) {
+ case "ERROR", "FATAL" -> Theme.error();
+ case "WARN" -> Theme.warning();
+ case "INFO" -> Theme.success();
+ case "DEBUG" -> Style.EMPTY.fg(Theme.accent());
+ case "TRACE" -> Style.EMPTY.dim();
+ default -> Style.EMPTY;
+ };
+ }
+
private static Line colorizePlainLog(String raw, LogEntry entry) {
String plain = TuiHelper.stripAnsi(raw);
Matcher m = PID_PATTERN.matcher(plain);
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java
index 939b5e382764..5b9f4b10696b 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java
@@ -274,6 +274,10 @@ class OverviewTab extends AbstractTab {
infraFocused = false;
}
+ if (ctx.selectedPid == null && !infraFocused) {
+ // no integration selected: no row marked as if one were (the
header and info panel say none is)
+ tableState.clearSelection();
+ }
if (ctx.selectedPid != null) {
for (int i = 0; i < infos.size(); i++) {
if (ctx.selectedPid.equals(infos.get(i).pid)) {
@@ -515,6 +519,9 @@ class OverviewTab extends AbstractTab {
long maxFailed = infos.stream().mapToLong(i ->
i.failed).max().orElse(0);
int tw = Math.max(numWidth(maxTotal), 6);
int fw = Math.max(numWidth(maxFailed), 6);
+ // TOTAL and FAIL are as wide as the longest "count (since last)", so
the since part is not cut
+ int totalColWidth = 14;
+ int failColWidth = 14;
for (IntegrationInfo info : infos) {
boolean isEven = (rowIndex++ % 2 == 0);
Style rowBg = isEven ? Style.EMPTY.bg(Theme.zebra()) : Style.EMPTY;
@@ -620,6 +627,8 @@ class OverviewTab extends AbstractTab {
? Line.from(Span.styled(String.format("%" + fw + "d",
info.failed), failStyle),
Span.styled(" (" + info.sinceLastFailed + ")",
Theme.muted()))
: Line.from(Span.styled(String.format("%" + fw + "d",
info.failed), failStyle));
+ totalColWidth = Math.max(totalColWidth, totalCell.width() + 1);
+ failColWidth = Math.max(failColWidth, failCell.width() + 1);
rows.add(Row.from(
Cell.from(info.pid),
@@ -658,8 +667,8 @@ class OverviewTab extends AbstractTab {
Constraint.length(10),
Constraint.length(7),
Constraint.length(8),
- Constraint.length(14),
- Constraint.length(14),
+ Constraint.length(totalColWidth),
+ Constraint.length(failColWidth),
Constraint.min(20),
Constraint.length(0)
};
@@ -829,6 +838,44 @@ class OverviewTab extends AbstractTab {
renderInfoPanel(frame, infoArea);
}
+ /** The info panel when no integration is selected: how many run, and
their messages and memory together. */
+ static List<Line> summaryLines(List<IntegrationInfo> infos) {
+ Style dim = Theme.muted();
+ int running = 0;
+ int projects = 0;
+ long total = 0;
+ long failed = 0;
+ long heap = 0;
+ for (IntegrationInfo info : infos) {
+ if (info.vanishing) {
+ continue;
+ }
+ if (info.phantom) {
+ projects++;
+ continue;
+ }
+ running++;
+ total += info.exchangesTotal;
+ failed += info.failed;
+ heap += Math.max(0, info.heapMemUsed);
+ }
+ List<Line> lines = new ArrayList<>();
+ lines.add(Line.from(Span.styled("Running: ", dim),
Span.raw(String.valueOf(running))));
+ if (projects > 0) {
+ lines.add(Line.from(Span.styled("Opened: ", dim),
Span.raw(projects + " not running")));
+ }
+ lines.add(Line.from(Span.raw("")));
+ lines.add(Line.from(Span.styled("Total: ", dim),
Span.raw(String.valueOf(total))));
+ lines.add(Line.from(Span.styled("Failed: ", dim),
+ Span.styled(String.valueOf(failed), failed > 0 ? Theme.error()
: Style.EMPTY)));
+ if (heap > 0) {
+ lines.add(Line.from(Span.styled("Heap: ", dim),
Span.raw(TuiHelper.formatBytes(heap))));
+ }
+ lines.add(Line.from(Span.raw("")));
+ lines.add(Line.from(Span.styled("\u2191\u2193", Theme.hintKey()),
Span.styled(" select one", dim)));
+ return lines;
+ }
+
/** How long an opened project shows as Starting when its app does not
show up. */
static final long PROJECT_START_MS = 5 * 60_000;
@@ -890,6 +937,15 @@ class OverviewTab extends AbstractTab {
sel = active.get(0);
}
}
+ if (sel == null) {
+ // nothing selected: what runs, in numbers, rather than an empty
box
+ Block allBlock =
Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL)
+ .title(Title.from(Line.from(Span.styled(" All ",
Theme.title())))).build();
+ frame.renderWidget(allBlock, area);
+
frame.renderWidget(Paragraph.builder().text(Text.from(summaryLines(ctx.data.get()))).build(),
+ allBlock.inner(area));
+ return;
+ }
Block infoBlock =
Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL).build();
frame.renderWidget(infoBlock, area);
Rect inner = infoBlock.inner(area);
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RunOptionsForm.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RunOptionsForm.java
index f2d3f3f35c71..821316ee3f1d 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RunOptionsForm.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RunOptionsForm.java
@@ -83,6 +83,16 @@ class RunOptionsForm {
TuiIcons.labeled(TuiIcons.JBANG, "JBang")
};
private static final String[] RUNTIME_VALUES = { "camel-main",
"spring-boot", "quarkus", "jbang" };
+
+ /** Where each runtime serves the developer console: each keeps its own
default path. */
+ static String consolePath(int runtime) {
+ return switch (runtime) {
+ case 1 -> "/actuator/camel";
+ case 2 -> "/q/camel/dev-console";
+ default -> "/q/dev";
+ };
+ }
+
private static final String[] PROFILE_LABELS = {
TuiIcons.labeled(TuiIcons.DEV_PROFILE, "dev"),
TuiIcons.labeled(TuiIcons.PROD_PROFILE, "prod")
@@ -600,7 +610,8 @@ class RunOptionsForm {
renderTextInput(frame, innerX + labelW, rowY, fieldW, maxInput,
selectedRow == ROW_MAX);
rowY++;
- renderCheckbox(frame, innerX, rowY, innerW, "Web console (/q/dev)",
webConsole, selectedRow == ROW_CONSOLE);
+ renderCheckbox(frame, innerX, rowY, innerW, "Web console (" +
consolePath(runtimeMode) + ")", webConsole,
+ selectedRow == ROW_CONSOLE);
rowY++;
if (runtimeLocked) {
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SubViewBar.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SubViewBar.java
index 4867202f8045..c559fabd1e7b 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SubViewBar.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SubViewBar.java
@@ -61,6 +61,8 @@ final class SubViewBar {
* @param cycleKey the key that moves through the views, or null
* @param levels whether the views are levels of one another (separated
by ›) rather than peers (│)
*/
+ private static final String MORE = "\u2026 ";
+
record Spec(String cycleKey, List<View> views, List<Toggle> toggles,
boolean levels) {
}
@@ -108,6 +110,7 @@ final class SubViewBar {
List<int[]> rightBoxes = new ArrayList<>();
List<String> rightKeys = new ArrayList<>();
int rightWidth = 0;
+ boolean dropped = false;
for (Toggle t : spec.toggles()) {
// an on/off setting shows its state as a mark, a setting with
modes names the mode
boolean onOff = "on".equals(t.state()) || "off".equals(t.state());
@@ -116,6 +119,7 @@ final class SubViewBar {
String mark = (onOff ? (t.on() ? "●" : "○") : "") + " ";
int w = CharWidth.of(chip) + CharWidth.of(label) +
CharWidth.of(mark);
if (used + rightWidth + w + 2 > area.width()) {
+ dropped = true;
break;
}
right.add(Span.styled(chip, Theme.hintKey()));
@@ -125,6 +129,19 @@ final class SubViewBar {
rightKeys.add(t.key());
rightWidth += w;
}
+ if (dropped) {
+ // a setting that does not fit is not dropped silently: a … says
there is more (F1 lists them all)
+ while (!rightBoxes.isEmpty() && used + rightWidth + MORE.length()
> area.width()) {
+ int[] last = rightBoxes.remove(rightBoxes.size() - 1);
+ rightKeys.remove(rightKeys.size() - 1);
+ right.subList(right.size() - 3, right.size()).clear();
+ rightWidth = last[0];
+ }
+ if (used + rightWidth + MORE.length() <= area.width()) {
+ right.add(Span.styled(MORE, Theme.muted()));
+ rightWidth += MORE.length();
+ }
+ }
if (!right.isEmpty()) {
int start = area.x() + area.width() - rightWidth;
spans.add(Span.raw(" ".repeat(area.width() - used - rightWidth)));
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelper.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelper.java
index 4fd9123896f8..1a244a3ac33f 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelper.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelper.java
@@ -606,6 +606,98 @@ final class TuiHelper {
: String.format(Locale.US, "%.1fs", ms / 1000.0);
}
+ /**
+ * Wraps lines to a width with a hanging indent: the continuation of a
"key = value" line starts under the value,
+ * and of an indented line under its first character, where a plain word
wrap would start it at the left edge.
+ */
+ static List<Line> hangingWrap(List<Line> lines, int width) {
+ List<Line> out = new ArrayList<>(lines.size());
+ for (Line line : lines) {
+ if (width < 8 || line.width() <= width) {
+ out.add(line);
+ } else {
+ wrapLine(line, width, out);
+ }
+ }
+ return out;
+ }
+
+ private record StyledChar(String text, Style style, int width) {
+ }
+
+ private static void wrapLine(Line line, int width, List<Line> out) {
+ List<StyledChar> chars = new ArrayList<>();
+ StringBuilder plain = new StringBuilder();
+ for (Span span : line.spans()) {
+ String content = span.content();
+ for (int i = 0; i < content.length();) {
+ int cp = content.codePointAt(i);
+ String ch = new String(Character.toChars(cp));
+ chars.add(new StyledChar(ch, span.style(), Math.max(0,
CharWidth.of(cp))));
+ plain.append(ch);
+ i += Character.charCount(cp);
+ }
+ }
+ String text = plain.toString();
+ int indent = 0;
+ int eq = text.indexOf(" = ");
+ if (eq > 0 && eq + 3 < width * 3 / 5) {
+ indent = eq + 3;
+ } else {
+ while (indent < text.length() && text.charAt(indent) == ' ') {
+ indent++;
+ }
+ }
+ indent = Math.min(indent, width / 2);
+
+ int start = 0;
+ boolean first = true;
+ while (start < chars.size()) {
+ int room = first ? width : width - indent;
+ int used = 0;
+ int end = start;
+ int lastSpace = -1;
+ while (end < chars.size() && used + chars.get(end).width() <=
room) {
+ if (" ".equals(chars.get(end).text())) {
+ lastSpace = end;
+ }
+ used += chars.get(end).width();
+ end++;
+ }
+ if (end < chars.size() && lastSpace > start) {
+ // break after the last space that fits, so a word is not cut
+ end = lastSpace + 1;
+ } else if (end == start) {
+ end = start + 1;
+ }
+ List<Span> spans = new ArrayList<>();
+ if (!first && indent > 0) {
+ spans.add(Span.raw(" ".repeat(indent)));
+ }
+ StringBuilder run = new StringBuilder();
+ Style runStyle = null;
+ for (int i = start; i < end; i++) {
+ StyledChar c = chars.get(i);
+ if (runStyle != null && !runStyle.equals(c.style())) {
+ spans.add(Span.styled(run.toString(), runStyle));
+ run.setLength(0);
+ }
+ runStyle = c.style();
+ run.append(c.text());
+ }
+ if (!run.isEmpty()) {
+ spans.add(Span.styled(run.toString(), runStyle));
+ }
+ out.add(Line.from(spans));
+ start = end;
+ // a continuation does not start with the spaces it broke at
+ while (start < chars.size() && "
".equals(chars.get(start).text())) {
+ start++;
+ }
+ first = false;
+ }
+ }
+
static String formatLoad(String l1, String l5, String l15) {
String s1 = l1 != null && !"0.00".equals(l1) ? l1 : "0";
String s5 = l5 != null && !"0.00".equals(l5) ? l5 : "0";
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 881253076328..b789890600a0 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
@@ -93,5 +93,6 @@ with the mouse.
- `l` — change log level
- `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
- `Ctrl+L` — pin/cycle/unpin log panel (works from any tab)
- `Esc` — clear find / back
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopupTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopupTest.java
index 8ebda7b7aed2..34bf2cd6bdde 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopupTest.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopupTest.java
@@ -51,6 +51,20 @@ class ActionsPopupTest {
assertEquals(ActionsPopup.Action.QUIT,
ActionsPopup.actionForLabel("exit"));
}
+ @Test
+ void aScreenshotKeepsTheMenuOpenSoItIsInThePicture() {
+ AtomicBoolean shot = new AtomicBoolean();
+ ActionsPopup popup = new ActionsPopup(
+ Set::of, List::of, List::of, null, () -> shot.set(true), () ->
{
+ }, () -> false, () -> {
+ }, () -> false, () -> {
+ }, new HashSet<>());
+ popup.open();
+ assertTrue(popup.executeActionByName("screenshot"));
+ assertTrue(shot.get());
+ assertTrue(popup.isVisible(), "the menu stays, as it was on the
screen");
+ }
+
@Test
void quitIsTheLastMenuEntryAndRunsTheWiredAction() {
ActionsPopup popup = new ActionsPopup(
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/HangingWrapTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/HangingWrapTest.java
new file mode 100644
index 000000000000..1c3b2486332f
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/HangingWrapTest.java
@@ -0,0 +1,75 @@
+/*
+ * 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 dev.tamboui.style.Style;
+import dev.tamboui.text.Line;
+import dev.tamboui.text.Span;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * A wrapped detail line continues under its value or its indent, not at the
left edge.
+ */
+class HangingWrapTest {
+
+ @Test
+ void aHeaderValueContinuesUnderTheValue() {
+ Line header = Line.from(Span.styled(" CamelFileAbsolutePath",
Theme.muted()),
+ Span.raw(" =
/private/var/folders/33/4s84/T/camel-example-75917/orders/order-1003.json"));
+ List<String> rows = text(TuiHelper.hangingWrap(List.of(header), 60));
+
+ assertThat(rows).hasSizeGreaterThan(1);
+ int value = rows.get(0).indexOf("= ") + 2;
+ assertThat(rows.get(1)).startsWith("
".repeat(value)).doesNotStartWith(" ".repeat(value + 1));
+ assertThat(String.join("", rows).replace(" ", ""))
+ .isEqualTo(text(List.of(header)).get(0).replace(" ", ""));
+ assertThat(rows).allMatch(r -> r.length() <= 60);
+ }
+
+ @Test
+ void anIndentedLineContinuesUnderItsIndent() {
+ Line hint = Line.from(Span.raw(" not grouped yet: /overview groups the
routes into capabilities"));
+ List<String> rows = text(TuiHelper.hangingWrap(List.of(hint), 30));
+ assertThat(rows.get(0)).startsWith(" not grouped");
+ assertThat(rows.subList(1, rows.size())).allMatch(r -> r.startsWith("
") && !r.startsWith(" "));
+ }
+
+ @Test
+ void stylesAreKeptAndShortLinesUntouched() {
+ Style red = Theme.error();
+ Line line = Line.from(Span.styled("error: ", red), Span.raw("a b c d e
f g h i j k l m n o p q r s t u v w"));
+ List<Line> wrapped = TuiHelper.hangingWrap(List.of(line), 20);
+ assertThat(wrapped.get(0).spans().get(0).style()).isEqualTo(red);
+
+ Line shortLine = Line.from(Span.raw("short"));
+ assertThat(TuiHelper.hangingWrap(List.of(shortLine),
20).get(0)).isSameAs(shortLine);
+ }
+
+ @Test
+ void aWordLongerThanTheRowIsCut() {
+ List<String> rows =
text(TuiHelper.hangingWrap(List.of(Line.from(Span.raw("x".repeat(50)))), 20));
+ assertThat(rows).containsExactly("x".repeat(20), "x".repeat(20),
"x".repeat(10));
+ }
+
+ private static List<String> text(List<Line> lines) {
+ return lines.stream().map(l ->
l.spans().stream().map(Span::content).reduce("", String::concat)).toList();
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTabRenderTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTabRenderTest.java
index ee5ebb15c425..76305b44c6f6 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTabRenderTest.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTabRenderTest.java
@@ -17,6 +17,7 @@
package org.apache.camel.dsl.jbang.core.commands.tui;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
@@ -207,4 +208,29 @@ class HistoryTabRenderTest {
e.last = last;
return e;
}
+
+ @Test
+ void theWaterfallBarsStandWhereTheStepsRan() {
+ // the exchange was created 2s before it was processed: the route row
starts with its first step
+ // the last row of a route carries the creation time too: it stands
where its route started
+ List<HistoryTab.WaterfallStep> steps = List.of(
+ step("from1", true, false, 17, 1_000),
+ step("unmarshal1", false, false, 0, 3_000),
+ step("to2", false, false, 16, 3_001),
+ step("from2", true, false, 12, 1_000),
+ step("log3", false, false, 0, 3_005),
+ step("from2", false, true, 12, 1_000),
+ step("from1", false, true, 17, 1_000));
+ long[] offsets = HistoryTab.waterfallOffsets(steps);
+ assertTrue(Arrays.equals(new long[] { 0, 0, 1, 5, 5, 5, 0 }, offsets),
Arrays.toString(offsets));
+
+ // without the times of the steps, every bar starts at 0
+ long[] none = HistoryTab.waterfallOffsets(
+ List.of(step("from1", true, false, 5, 0), step("log1", false,
false, 5, 0)));
+ assertTrue(Arrays.equals(new long[] { 0, 0 }, none));
+ }
+
+ private static HistoryTab.WaterfallStep step(String id, boolean first,
boolean last, long elapsed, long start) {
+ return new HistoryTab.WaterfallStep(id, id, "-->", first, last, 0,
elapsed, 0, start);
+ }
}
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 8d3b479a5bfb..ddb156189ecb 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
@@ -67,6 +67,14 @@ class LogTabRenderTest {
assertEquals(1, entries.get(0).repeat, "a line that happened once is
not a repeat");
}
+ @Test
+ void theCompactViewShowsTimeLevelLoggerAndMessage() {
+ LogEntry entry = LogTab.parseLogLine("2026-10-01 17:24:30.257 INFO
85013 --- [ntloop-thread-0]"
+ + "
tform.http.vertx.VertxPlatformHttpServer : Vert.x HttpServer started on
0.0.0.0:8080");
+ String text =
LogTab.compactLine(entry).spans().stream().map(Span::content).reduce("",
String::concat);
+ assertEquals("17:24:30.257 INFO VertxPlatformHttpServer Vert.x
HttpServer started on 0.0.0.0:8080", text);
+ }
+
@Test
void renderNoSelectionShowsPrompt() {
ctx.selectedPid = null;
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SubViewBarTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SubViewBarTest.java
index 7216366dc5f0..3ed6917aad1d 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SubViewBarTest.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SubViewBarTest.java
@@ -68,6 +68,21 @@ class SubViewBarTest {
assertThat(line).contains(" b view: business ", " e external: edges
", " m metrics ○");
}
+ @Test
+ void settingsThatDoNotFitEndInAnEllipsis() {
+ SubViewBar bar = new SubViewBar();
+ Rect area = new Rect(0, 0, 90, 1);
+ Buffer buffer = Buffer.empty(area);
+ bar.render(Frame.forTesting(buffer), area, levels());
+ String line = TuiTestHelper.bufferToString(buffer).split("\n")[0];
+
+ assertThat(line).contains(" b view: business
").doesNotContain("metrics");
+ assertThat(line.stripTrailing()).endsWith("…");
+ assertThat(line.length()).isLessThanOrEqualTo(90);
+ // all fit on a wide bar: no ellipsis
+ assertThat(render(bar, levels(), 0)).doesNotContain("…");
+ }
+
@Test
void aClickGoesToAViewOrPressesTheKeyOfASetting() {
SubViewBar bar = new SubViewBar();