This is an automated email from the ASF dual-hosted git repository. luigidemasi pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/camel.git
commit 12e8bdcdf1affa19b3d62786d4a4001db936b164 Author: Luigi De Masi <[email protected]> AuthorDate: Wed Oct 14 13:41:12 2026 +0200 CAMEL-25618: Make expert parameter controls visible in the TUI Place Load params and Clear params beside Run, support their keyboard and mouse actions, and preserve the input being assessed. Keep scalar tokens beside parameter labels while retaining indentation for longer prose. Cover loading, clearing, pending evaluations and narrow layouts. Co-authored-by: Codex <[email protected]> --- .../ROOT/pages/camel-jbang-tui-observe.adoc | 7 ++ .../jbang/core/commands/tui/SemanticDetails.java | 8 +- .../core/commands/tui/SemanticPlayground.java | 83 ++++++++++++++++--- .../dsl/jbang/core/commands/tui/SemanticTab.java | 15 ++-- .../src/main/resources/tui/help/semantic.md | 6 +- .../jbang/core/commands/tui/SemanticTabTest.java | 92 +++++++++++++++++++++- 6 files changed, 187 insertions(+), 24 deletions(-) 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 da017a6109df..d13bb945ff69 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 @@ -371,6 +371,13 @@ Criteria and other parameters stay on the right. Operations without instructions *Ctrl+l* clears the current field or restores an optional choice to its expert default; *Esc* leaves the editor while preserving the input and output. +Click *Load params* or press *Ctrl+p* to copy the selected definition's parameters, including +Instructions, into the form. The shortcut works while editing; *p* also works outside the editor. +Click *Clear params* or press *Ctrl+k* to empty Instructions and all other parameter fields and rows. +Both actions preserve the text/state to assess and change only the editable draft. The definition's state +selector is not applied. Unset optional parameters use expert defaults; required values must be filled before Run. +These controls are disabled while an evaluation is running. + While editing text/state, use *Ctrl+t* to switch between literal text and JSON state when the operation supports both. Instructions remain plain text. Structured-only operations open in JSON mode. JSON state must be an object or array. diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticDetails.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticDetails.java index 87f2af974d15..49ab089ffc55 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticDetails.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticDetails.java @@ -103,7 +103,13 @@ final class SemanticDetails { parameter(lines, indent + " ", "[" + i + "] ", list.get(i)); } } else if (value instanceof String text) { - // Put text under its label so wrapped prose keeps its place in the parameter tree. + // Keep scalar tokens beside their labels; prose retains its own hanging indentation below. + if (text.codePoints().noneMatch(Character::isWhitespace)) { + String token = text.replaceAll("[\\p{Cntrl}\\p{Cf}]", ""); + lines.add(Line.from(Span.styled(indent + label, Theme.label()), + Span.raw(token.isEmpty() ? "\"\"" : token))); + return; + } lines.add(Line.from(Span.styled(indent + label.stripTrailing(), Theme.label()))); for (String part : text.split("\\R", -1)) { lines.add(Line.from(indent + " " + (part.isEmpty() ? "\"\"" : part.replaceAll("[\\p{Cntrl}\\p{Cf}]", "")))); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticPlayground.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticPlayground.java index 9de134d25299..0636eba05e9c 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticPlayground.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticPlayground.java @@ -28,7 +28,6 @@ import dev.tamboui.style.Style; import dev.tamboui.terminal.Frame; 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; @@ -37,7 +36,6 @@ import dev.tamboui.widgets.block.BorderType; import dev.tamboui.widgets.block.Borders; import dev.tamboui.widgets.input.TextArea; import dev.tamboui.widgets.input.TextAreaState; -import dev.tamboui.widgets.paragraph.Paragraph; import org.apache.camel.util.json.JsonObject; import org.apache.camel.util.json.Jsoner; @@ -64,6 +62,7 @@ final class SemanticPlayground { private List<String> submittedScoreLevels = List.of(); private JsonObject submittedInterpretation; private String parameterSource; + private JsonObject definition; private String draftWarning; private boolean expanded; private long submittedInstructions; @@ -74,6 +73,8 @@ final class SemanticPlayground { private Rect parameterArea; private Rect outputArea; private Rect runArea; + private Rect loadArea; + private Rect clearArea; SemanticPlayground(MonitorContext ctx, String pid, String expert, JsonObject operation) { this.ctx = ctx; @@ -103,17 +104,34 @@ final class SemanticPlayground { + "current defaults, expert/model revisions and route context may differ. Review before Run."; } - boolean loadParameters(JsonObject definition) { - if (pending != null) { + void setDefinition(JsonObject definition) { + this.definition = definition != null && definition.get("error") == null + && Objects.equals(expert, definition.get("expert")) + && Objects.equals(operation.get("name"), definition.get("operation")) ? definition : null; + } + + boolean loadParameters() { + if (pending != null || definition == null || !available()) { return false; } Map<?, ?> values = definition.get("parameters") instanceof Map<?, ?> map ? map : Map.of(); instructions.load(values); parameters.load(values); parameterSource = text(definition, "name"); + focusFirstField(); return true; } + private void clearParameters() { + if (pending == null && available()) { + instructions.load(Map.of()); + parameters.load(Map.of()); + parameterSource = null; + error = null; + focusFirstField(); + } + } + boolean setInputValue(String name, String value) { refresh(); if (pending != null || !available()) { @@ -197,6 +215,10 @@ final class SemanticPlayground { refresh(); if (key.hasCtrl() && key.isCharIgnoreCase('r')) { run(); + } else if (key.hasCtrl() && key.isCharIgnoreCase('p')) { + loadParameters(); + } else if (key.hasCtrl() && key.isCharIgnoreCase('k')) { + clearParameters(); } else if (key.isKey(KeyCode.TAB)) { boolean back = key.hasShift(); if (field == 1 && parameters.next(back)) { @@ -259,6 +281,14 @@ final class SemanticPlayground { run(); return true; } + if (loadArea != null && loadArea.contains(event.x(), event.y())) { + loadParameters(); + return true; + } + if (clearArea != null && clearArea.contains(event.x(), event.y())) { + clearParameters(); + return true; + } if (pending == null && instructions.click(event.x(), event.y())) { field = -1; return true; @@ -356,6 +386,8 @@ final class SemanticPlayground { parameterArea = null; outputArea = null; runArea = null; + loadArea = null; + clearArea = null; Block block = Block.builder().borders(Borders.ALL).borderType(BorderType.ROUNDED) .borderStyle(ctx.paneBorder(focused)) .title(" " + truncate("Try expert · " + text(operation, "name") + " · direct" @@ -365,7 +397,24 @@ final class SemanticPlayground { .build(); frame.renderWidget(block, area); Rect inner = block.inner(area); - if (inner.height() < (instructions.isEmpty() ? 7 : 11) || inner.width() < 30) { + String loadLabel = " Ctrl+p Load params "; + String clearLabel = " Ctrl+k Clear params "; + String runLabel = !connected() ? " Disconnected " : pending == null ? " Ctrl+r Run " : " Running… "; + boolean hasParameters = !instructions.isEmpty() || !parameters.isEmpty(); + List<String> labels = hasParameters ? List.of(loadLabel, clearLabel, runLabel) : List.of(runLabel); + List<Rect> actions = new ArrayList<>(); + int actionX = inner.x() + 1; + int actionY = inner.y() + 1; + for (String label : labels) { + if (actionX > inner.x() + 1 && actionX + label.length() > inner.right() - 1) { + actionX = inner.x() + 1; + actionY++; + } + actions.add(new Rect(actionX, actionY, label.length(), 1)); + actionX += label.length() + 1; + } + int actionRows = actionY - inner.y(); + if (inner.height() < (instructions.isEmpty() ? 7 : 11) + actionRows || inner.width() < 30) { paragraph(frame, inner, List.of(Line.from("Enlarge the terminal to use the expert playground.")), 0); return; } @@ -373,19 +422,23 @@ final class SemanticPlayground { paragraph(frame, inset(inner), List.of(Line.from("No supported input type is published for this operation.")), 0); return; } - Rect title = new Rect(inner.x() + 1, inner.y(), Math.max(0, inner.width() - 20), 1); + Rect title = new Rect(inner.x() + 1, inner.y(), Math.max(0, inner.width() - 2), 1); paragraph(frame, title, List.of(Line.from(Span.styled(expert + " › ", Theme.info()), Span.raw(text(operation, "name") + " → " + text(operation, "resultType")), Span.styled(" · input: " + (structured ? "JSON" : "text"), Theme.muted()))), 0); - runArea = new Rect(inner.right() - 17, inner.y(), 16, 1); - frame.renderWidget(Paragraph.builder().text(Text.from(Line.from(Span.styled( - !connected() ? " Disconnected " : pending == null ? " Ctrl+r Run " : " Running… ", - connected() ? Theme.hintKey() : Theme.muted())))).build(), runArea); + runArea = actions.get(actions.size() - 1); + action(frame, runArea, runLabel, connected() && pending == null); boolean sideBySide = !parameters.isEmpty(); int editorHeight = Math.max(instructions.isEmpty() ? 3 : 6, Math.min(expanded ? Integer.MAX_VALUE : sideBySide || !instructions.isEmpty() ? 22 : 5, - (inner.height() - (parameterSource == null ? 0 : 1)) * 2 / 3)); - int y = inner.y() + 1; + (inner.height() - actionRows - (parameterSource == null ? 0 : 1)) * 2 / 3)); + int y = inner.y() + 1 + actionRows; + if (hasParameters) { + loadArea = actions.get(0); + clearArea = actions.get(1); + action(frame, loadArea, loadLabel, pending == null && definition != null); + action(frame, clearArea, clearLabel, pending == null); + } if (parameterSource != null) { paragraph(frame, new Rect(inner.x() + 1, y++, inner.width() - 2, 1), List.of(Line @@ -419,6 +472,10 @@ final class SemanticPlayground { paragraph(frame, outputArea, lines, outputScroll); } + private void action(Frame frame, Rect area, String label, boolean enabled) { + paragraph(frame, area, List.of(Line.from(Span.styled(label, enabled ? Theme.hintKey() : Theme.muted()))), 0); + } + private Rect renderEditor(Frame frame, Rect area, TextAreaState state, String title, boolean focused, String placeholder) { Block block = Block.builder().borders(Borders.ALL).borderType(BorderType.ROUNDED) .borderStyle(ctx.paneBorder(focused)).title(" " + truncate(title, area.width() - 4) + " ").build(); @@ -471,7 +528,7 @@ final class SemanticPlayground { hint(spans, "Esc", "leave"); hint(spans, "Ctrl+r", !connected() ? "disconnected" : pending == null ? "run" : "running"); hint(spans, "Tab/⇧Tab", "field"); - hint(spans, "Ctrl+l", "clear"); + hint(spans, "Ctrl+l", "clear field"); if (field == 1) { parameters.renderFooter(spans); } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticTab.java index cec2acbc64d0..efbc04c2e5e1 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticTab.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticTab.java @@ -419,6 +419,9 @@ class SemanticTab extends AbstractTableTab { } auditDraft = null; } + if (playground != null) { + playground.setDefinition(selectedDefinition()); + } } private void view(View selected) { @@ -516,6 +519,12 @@ class SemanticTab extends AbstractTableTab { } return true; } + if (view == View.EXPERTS && playground != null && key.hasCtrl() + && (key.isCharIgnoreCase('p') || key.isCharIgnoreCase('k'))) { + playground.handleKeyEvent(key); + focus = Focus.PLAYGROUND; + return true; + } if (focus == Focus.PLAYGROUND && playground != null) { if (key.hasCtrl() && key.isCharIgnoreCase('e')) { playgroundExpanded = !playgroundExpanded; @@ -569,12 +578,8 @@ class SemanticTab extends AbstractTableTab { return true; } if (key.isChar('p') && view == View.EXPERTS) { - JsonObject definition = selectedDefinition(); - if (playground != null && definition != null && definition.get("error") == null - && Objects.equals(operationName, definition.get("operation")) - && playground.loadParameters(definition)) { + if (playground != null && playground.loadParameters()) { focus = Focus.PLAYGROUND; - playground.focusFirstField(); } return true; } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/semantic.md b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/semantic.md index 572c51d8b0e7..da6ee25580d6 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/semantic.md +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/semantic.md @@ -64,9 +64,13 @@ Failures/timeouts keep your draft and show an inline error; `Ctrl+r` retries exp Run is disabled when the selected application disconnects. Listing an expert does not prove its service is reachable; no inference is made just to test availability. -Press `p` outside the editor to load the selected definition's parameters, including instructions. +Click **Load params** or press `Ctrl+p` to load the selected definition's parameters, including instructions. +The shortcut also works while editing; `p` remains available outside the editor. This replaces the parameter draft with an editable copy labeled with its source; it keeps your text/state. The definition's state selector is not applied. Without this action the form starts with expert defaults. +Click **Clear params** or press `Ctrl+k` to empty Instructions and all other parameter fields and rows, +keeping your text/state. Unset optional parameters use expert defaults; required parameters must be filled before Run. +Loading and clearing only change the editable draft, and are disabled while an evaluation is running. ## Definition sample evaluation diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticTabTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticTabTest.java index 5a547a0975eb..f6d03b2d3fe2 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticTabTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticTabTest.java @@ -733,7 +733,7 @@ class SemanticTabTest { JsonObject overview = (JsonObject) Jsoner.deserialize(OVERVIEW); JsonObject definition = SemanticTab.objects(overview, "evaluations").get(0); definition.put("parameters", Jsoner.deserialize(""" - {"instructions":"Which department should handle this support request?", + {"uncertaintyPolicy":"fail","instructions":"Which department should handle this support request?", "criteria":{"billing":"Invoices, payments, refunds and duplicate charges", "technical":"Bugs, outages and system errors"}} """)); @@ -741,14 +741,16 @@ class SemanticTabTest { var tab = loaded(runtime); for (int width : List.of(160, 80)) { String rendered = TuiTestHelper.renderToString(tab, width, 48); - assertThat(rendered).contains("instructions:", "criteria:", "• billing:", "• technical:", - "Invoices, payments, refunds and duplicate charges") + assertThat(rendered) + .contains("uncertaintyPolicy: fail", "instructions:", "criteria:", "• billing:", "• technical:", + "Invoices, payments, refunds and duplicate charges") .doesNotContain("criteria =", "\"billing\"", "instructions ="); assertThat(rendered.lines().filter(line -> line.contains("• billing:"))) .allSatisfy(line -> assertThat(line).doesNotContain("technical", "Invoices")); } tab.subViewBar().views().get(2).select().run(); - assertThat(TuiTestHelper.renderToString(tab, 180, 48)).contains("• billing:", "• technical:"); + assertThat(TuiTestHelper.renderToString(tab, 180, 48)) + .contains("uncertaintyPolicy: fail", "• billing:", "• technical:"); definition.put("parameters", Jsoner.deserialize(""" {"criteria":["Routine request with no urgency","Needs attention soon", @@ -1277,6 +1279,88 @@ class SemanticTabTest { } } + @ParameterizedTest + @CsvSource({ "180,false", "100,true", "88,true" }) + void parameterControlsLoadAndClearWithoutReplacingInput(int width, boolean mouse) throws Exception { + try (var runtime = new Runtime()) { + JsonObject overview = (JsonObject) Jsoner.deserialize(OVERVIEW); + JsonObject operation = SemanticTab.objects(SemanticTab.objects(overview, "experts").get(0), "operations").get(0); + SemanticDetails.contract(operation).put("parameters", Jsoner.deserialize(""" + [{"name":"instructions","type":"String","required":true,"minSize":1}, + {"name":"criteria","type":"Map","itemType":"String"}, + {"name":"levels","type":"List","itemType":"String"}, + {"name":"threshold","type":"Number"}, + {"name":"policy","type":"String","values":["fail","continue"]}] + """)); + SemanticTab.objects(overview, "evaluations").get(0).put("parameters", Map.of( + "instructions", "Choose a department", "criteria", Map.of("billing", "Invoices"), + "levels", List.of("Routine", "Urgent"), "threshold", 0.7, "policy", "fail")); + runtime.overview = overview.toJson(); + var tab = loaded(runtime); + tab.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER)); + tab.setInputValue("input", "Keep this text to assess"); + String screen = TuiTestHelper.renderToString(tab, width, 44); + assertThat(screen).contains("Ctrl+p Load params", "Ctrl+k Clear params", "Ctrl+r Run"); + if (width == 180) { + assertThat(screen.lines().filter(line -> line.contains("Load params"))) + .singleElement().asString().contains("Clear params", "Ctrl+r Run"); + } + if (mouse) { + clickParameterAction(tab, width, "Load params"); + } else { + tab.handleKeyEvent(KeyEvent.ofChar('p', KeyModifiers.CTRL)); + } + JsonObject draft = tab.getTableDataAsJson().getJsonObject("playground"); + assertThat(draft).containsEntry("parameterSource", "screenPrompt") + .containsEntry("input", "Keep this text to assess"); + List<JsonObject> loadedParameters = SemanticTab.objects(draft, "parameters"); + assertThat(loadedParameters).extracting(p -> p.get("value")) + .contains("Choose a department", Map.of("billing", "Invoices"), List.of("Routine", "Urgent"), "fail"); + assertThat(runtime.requests).hasSize(1); + + runtime.release = new CountDownLatch(1); + tab.handleKeyEvent(KeyEvent.ofChar('r', KeyModifiers.CTRL)); + assertThat(tab.getTableDataAsJson().getJsonObject("playground")).containsEntry("pending", true); + if (mouse) { + clickParameterAction(tab, width, "Clear params"); + } else { + tab.handleKeyEvent(KeyEvent.ofChar('k', KeyModifiers.CTRL)); + } + assertThat(SemanticTab.objects(tab.getTableDataAsJson().getJsonObject("playground"), "parameters")) + .isEqualTo(loadedParameters); + runtime.release.countDown(); + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> assertThat( + tab.getTableDataAsJson().getJsonObject("playground")).containsEntry("pending", false)); + if (mouse) { + clickParameterAction(tab, width, "Clear params"); + } else { + tab.handleKeyEvent(KeyEvent.ofChar('k', KeyModifiers.CTRL)); + } + draft = tab.getTableDataAsJson().getJsonObject("playground"); + assertThat(draft).containsEntry("parameterSource", null).containsEntry("input", "Keep this text to assess") + .containsEntry("inputChanged", true); + for (JsonObject parameter : SemanticTab.objects(draft, "parameters")) { + if (parameter.containsKey("entries")) { + assertThat(parameter.get("entries")).isEqualTo(List.of(Map.of("key", "", "value", ""))); + } else { + assertThat(parameter).containsEntry("text", ""); + } + } + assertThat(runtime.requests).hasSize(2); + tab.handleKeyEvent(KeyEvent.ofChar('p', KeyModifiers.CTRL)); + assertThat(SemanticTab.objects(tab.getTableDataAsJson().getJsonObject("playground"), "parameters")) + .isEqualTo(loadedParameters); + } + } + + private static void clickParameterAction(SemanticTab tab, int width, String label) { + List<String> lines = TuiTestHelper.renderToString(tab, width, 44).lines().toList(); + int row = IntStream.range(0, lines.size()).filter(i -> lines.get(i).contains(label)) + .findFirst().orElseThrow(); + assertThat(tab.handleMouseEvent(MouseEvent.press(MouseButton.LEFT, lines.get(row).indexOf(label), row), + new Rect(0, 0, width, 44))).isTrue(); + } + @Test void operationDraftsAndExplicitParameterCopiesSurviveSwitching() throws Exception { try (var runtime = new Runtime()) {
