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 47d0de5f0d03 camel-tui: Add run options form, properties editor, and
F2 action improvements (#23444)
47d0de5f0d03 is described below
commit 47d0de5f0d0364195d5cfb4ca4e0c47f8cb4cf09
Author: Claus Ibsen <[email protected]>
AuthorDate: Fri May 22 07:00:22 2026 +0200
camel-tui: Add run options form, properties editor, and F2 action
improvements (#23444)
* camel-tui: Add Doctor dialog to F2 actions menu
Runs 7 environment checks (Java, Camel, JBang, Maven, Container, Ports,
Disk Space) and displays results in a centered popup with emoji status
indicators.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* camel-tui: Add emoji icons to F2 actions menu items
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* camel-tui: Add Stop All to F2 actions menu
When only integrations or only infra services are running, stops them
immediately. When both are running, shows a checkbox dialog to select
which groups to stop.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* camel-tui: Add caption overlay with typewriter effect
Adds a caption feature for recordings and education that displays
text with a typewriter animation, holds, then fades out.
Triggered via Ctrl+T or F2 menu. Supports multi-line via \n.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* camel-tui: Add classpath viewer to F2 actions menu
Adds a "Show Classpath" action that fetches the JARs from a running
integration via the jvm dev console and displays them in a scrollable
list with parsed Maven GAV coordinates. Camel JARs are highlighted.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* camel-tui: Add reusable fuzzy filter with inline search
Adds FuzzyFilter utility for fuzzy matching with character-level
highlighting. Integrates into the classpath viewer so typing
immediately filters the list with matched characters highlighted.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* camel-tui: Replace name input with expanded run options form
The Run dialog now provides Name, Port, Max seconds text fields
and Dev mode, Observe, Backlog trace checkboxes instead of just
a name input. Tab navigates between fields, Space toggles checkboxes.
Co-Authored-By: Claude <[email protected]>
* camel-jbang: Add application.properties to bundled examples
Use property placeholders in timer-log, cron-log, rest-api, and
routes examples so their values are configurable via properties.
Co-Authored-By: Claude <[email protected]>
* camel-tui: Add properties editor page to run options form
The Run dialog now has a second page for editing application.properties
from the selected example. Properties are loaded from bundled resources
or downloaded from GitHub for online examples. Modified values are
passed as --prop=key=value overrides when launching.
Co-Authored-By: Claude <[email protected]>
* camel-tui: Add custom property support with + key in run form
Press + on the properties page to add a new key:value property.
Tab switches between key and value fields. Backspace on an empty
key removes the entry. Custom properties are passed as --prop=key=value.
Co-Authored-By: Claude <[email protected]>
* camel-tui: Add cycling duration mode in run options form
The Max field now cycles between Max seconds, Max messages, and
Max idle secs with Space. The corresponding --max-seconds,
--max-messages, or --max-idle-seconds flag is passed when launching.
Co-Authored-By: Claude <[email protected]>
---------
Co-authored-by: Claude Opus 4.6 <[email protected]>
---
.../camel/cli/connector/LocalCliConnector.java | 14 +
.../examples/camel-jbang-example-catalog.json | 4 +
.../examples/cron-log/application.properties | 20 +
.../examples/cron-log/cron-log.camel.yaml | 4 +-
.../examples/rest-api/application.properties | 20 +
.../examples/rest-api/rest-api.camel.yaml | 2 +-
.../examples/routes/application.properties | 18 +
.../src/main/resources/examples/routes/beans.yaml | 2 +-
.../examples/timer-log/application.properties | 20 +
.../examples/timer-log/timer-log.camel.yaml | 4 +-
.../dsl/jbang/core/commands/tui/ActionsPopup.java | 216 ++++---
.../dsl/jbang/core/commands/tui/CamelMonitor.java | 24 +
.../jbang/core/commands/tui/CaptionOverlay.java | 217 ++++++++
.../jbang/core/commands/tui/ClasspathPopup.java | 304 ++++++++++
.../dsl/jbang/core/commands/tui/DoctorPopup.java | 283 ++++++++++
.../dsl/jbang/core/commands/tui/FuzzyFilter.java | 125 +++++
.../jbang/core/commands/tui/RunOptionsForm.java | 618 +++++++++++++++++++++
.../dsl/jbang/core/commands/tui/StopAllPopup.java | 228 ++++++++
18 files changed, 2041 insertions(+), 82 deletions(-)
diff --git
a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java
b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java
index 7eb379358811..3f5f0c10b711 100644
---
a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java
+++
b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java
@@ -302,6 +302,8 @@ public class LocalCliConnector extends ServiceSupport
implements CliConnector, C
doActionDebugTask(root);
} else if ("reset-stats".equals(action)) {
doActionResetStatsTask();
+ } else if ("jvm".equals(action)) {
+ doActionJvmTask();
} else if ("thread-dump".equals(action)) {
doActionThreadDumpTask();
} else if ("top-processors".equals(action)) {
@@ -757,6 +759,18 @@ public class LocalCliConnector extends ServiceSupport
implements CliConnector, C
}
}
+ private void doActionJvmTask() throws IOException {
+ DevConsole dc =
camelContext.getCamelContextExtension().getContextPlugin(DevConsoleRegistry.class)
+ .resolveById("jvm");
+ if (dc != null) {
+ JsonObject json = (JsonObject) dc.call(DevConsole.MediaType.JSON);
+ LOG.trace("Updating output file: {}", outputFile);
+ IOHelper.writeText(json.toJson(), outputFile);
+ } else {
+ IOHelper.writeText("{}", outputFile);
+ }
+ }
+
private void doActionThreadDumpTask() throws IOException {
DevConsole dc =
camelContext.getCamelContextExtension().getContextPlugin(DevConsoleRegistry.class)
.resolveById("thread");
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/camel-jbang-example-catalog.json
b/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/camel-jbang-example-catalog.json
index a9b467615531..4e42f22e97d8 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/camel-jbang-example-catalog.json
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/camel-jbang-example-catalog.json
@@ -96,6 +96,7 @@
"hasCitrusTests": false,
"files": [
"README.md",
+ "application.properties",
"cron-log.camel.yaml"
]
},
@@ -325,6 +326,7 @@
"hasCitrusTests": false,
"files": [
"README.md",
+ "application.properties",
"rest-api.camel.yaml"
]
},
@@ -344,6 +346,7 @@
"files": [
"Greeter.java",
"README.md",
+ "application.properties",
"beans.yaml",
"routes.camel.yaml"
]
@@ -431,6 +434,7 @@
"hasCitrusTests": false,
"files": [
"README.md",
+ "application.properties",
"timer-log.camel.yaml"
]
},
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/cron-log/application.properties
b/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/cron-log/application.properties
new file mode 100644
index 000000000000..a60bfaac62a6
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/cron-log/application.properties
@@ -0,0 +1,20 @@
+## ---------------------------------------------------------------------------
+## 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.
+## ---------------------------------------------------------------------------
+# Timer period in milliseconds
+cron.period=5000
+# Log message prefix
+cron.message=Scheduled task running at
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/cron-log/cron-log.camel.yaml
b/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/cron-log/cron-log.camel.yaml
index fd729c9f5ea0..2ccc3b93c7c9 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/cron-log/cron-log.camel.yaml
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/cron-log/cron-log.camel.yaml
@@ -20,8 +20,8 @@
from:
uri: timer:cron
parameters:
- period: "5000"
+ period: "{{cron.period}}"
steps:
- setBody:
- simple: "Scheduled task running at ${date:now:HH:mm:ss}"
+ simple: "{{cron.message}} ${date:now:HH:mm:ss}"
- log: "${body}"
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/rest-api/application.properties
b/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/rest-api/application.properties
new file mode 100644
index 000000000000..7f4c4e3284c3
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/rest-api/application.properties
@@ -0,0 +1,20 @@
+## ---------------------------------------------------------------------------
+## 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.
+## ---------------------------------------------------------------------------
+# HTTP server port
+camel.server.port=8080
+# Greeting message for the hello endpoint
+greeting.message=Hello from Camel REST API!
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/rest-api/rest-api.camel.yaml
b/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/rest-api/rest-api.camel.yaml
index dda5c6cba0fc..8bf9e16560e0 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/rest-api/rest-api.camel.yaml
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/rest-api/rest-api.camel.yaml
@@ -28,7 +28,7 @@
uri: direct:hello
steps:
- setBody:
- constant: "Hello from Camel REST API!"
+ constant: "{{greeting.message}}"
- route:
id: hello-name
from:
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/routes/application.properties
b/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/routes/application.properties
new file mode 100644
index 000000000000..f562742fc8e5
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/routes/application.properties
@@ -0,0 +1,18 @@
+## ---------------------------------------------------------------------------
+## 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.
+## ---------------------------------------------------------------------------
+# Greeting message used by the Greeter bean
+greeter.message=Hello!
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/routes/beans.yaml
b/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/routes/beans.yaml
index 70bc6a6d5d35..6b71f2f79d5d 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/routes/beans.yaml
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/routes/beans.yaml
@@ -19,4 +19,4 @@
- name: "greeter"
type: "camel.example.Greeter"
properties:
- message: 'Hello!'
+ message: '{{greeter.message}}'
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/timer-log/application.properties
b/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/timer-log/application.properties
new file mode 100644
index 000000000000..8f6509eb6b08
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/timer-log/application.properties
@@ -0,0 +1,20 @@
+## ---------------------------------------------------------------------------
+## 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.
+## ---------------------------------------------------------------------------
+# Timer period in milliseconds
+timer.period=1000
+# Greeting message to log
+greeting.message=Hello Camel!
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/timer-log/timer-log.camel.yaml
b/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/timer-log/timer-log.camel.yaml
index 48124209c60d..6a40ad378d2e 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/timer-log/timer-log.camel.yaml
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/timer-log/timer-log.camel.yaml
@@ -20,8 +20,8 @@
from:
uri: timer:tick
parameters:
- period: "1000"
+ period: "{{timer.period}}"
steps:
- setBody:
- simple: "Hello Camel! (message
#${exchangeProperty.CamelTimerCounter})"
+ simple: "{{greeting.message}}"
- log: "${body}"
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 bb12dfcec2b6..2db68d8234be 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
@@ -40,13 +40,10 @@ import dev.tamboui.widgets.Clear;
import dev.tamboui.widgets.block.Block;
import dev.tamboui.widgets.block.BorderType;
import dev.tamboui.widgets.block.Title;
-import dev.tamboui.widgets.input.TextInput;
-import dev.tamboui.widgets.input.TextInputState;
import dev.tamboui.widgets.list.ListItem;
import dev.tamboui.widgets.list.ListState;
import dev.tamboui.widgets.list.ListWidget;
import dev.tamboui.widgets.list.ScrollMode;
-import dev.tamboui.widgets.paragraph.Paragraph;
import org.apache.camel.dsl.jbang.core.common.ExampleHelper;
import org.apache.camel.dsl.jbang.core.common.LauncherHelper;
import org.apache.camel.dsl.jbang.core.common.PathUtils;
@@ -59,9 +56,13 @@ class ActionsPopup {
private static final int ACTION_RUN_EXAMPLE = 0;
private static final int ACTION_SHOW_DOCS = 1;
- private static final int ACTION_SCREENSHOT = 2;
- private static final int ACTION_SHOW_KEYSTROKES = 3;
- private static final int ACTION_COUNT = 4;
+ private static final int ACTION_CAPTION = 2;
+ private static final int ACTION_SCREENSHOT = 3;
+ private static final int ACTION_SHOW_KEYSTROKES = 4;
+ private static final int ACTION_DOCTOR = 5;
+ private static final int ACTION_CLASSPATH = 6;
+ private static final int ACTION_STOP_ALL = 7;
+ private static final int ACTION_COUNT = 8;
private final Supplier<Set<String>> runningNames;
private final Supplier<List<IntegrationInfo>> integrations;
@@ -77,8 +78,7 @@ class ActionsPopup {
private final ListState exampleBrowserState = new ListState();
private List<JsonObject> exampleCatalog;
- private boolean showNameInput;
- private TextInputState nameInputState;
+ private final RunOptionsForm runOptionsForm = new RunOptionsForm();
private JsonObject selectedExample;
private boolean showDocPicker;
@@ -90,18 +90,26 @@ class ActionsPopup {
private String docTitle;
private int docScroll;
+ private final DoctorPopup doctorPopup = new DoctorPopup();
+ private final ClasspathPopup classpathPopup = new ClasspathPopup();
+ private final StopAllPopup stopAllPopup;
+ private final CaptionOverlay captionOverlay;
+
private final List<PendingLaunch> pendingLaunches = new ArrayList<>();
private String launchNotification;
private boolean launchNotificationError;
private long launchNotificationExpiry;
ActionsPopup(Supplier<Set<String>> runningNames,
Supplier<List<IntegrationInfo>> integrations,
+ Supplier<List<InfraInfo>> infraServices, CaptionOverlay
captionOverlay,
Runnable screenshotAction, Runnable toggleKeystrokes,
Supplier<Boolean> keystrokesEnabled) {
this.runningNames = runningNames;
this.integrations = integrations;
+ this.captionOverlay = captionOverlay;
this.screenshotAction = screenshotAction;
this.toggleKeystrokes = toggleKeystrokes;
this.keystrokesEnabled = keystrokesEnabled;
+ this.stopAllPopup = new StopAllPopup(integrations, infraServices);
}
void setContext(MonitorContext ctx) {
@@ -109,7 +117,9 @@ class ActionsPopup {
}
boolean isVisible() {
- return showActionsMenu || showExampleBrowser || showNameInput ||
showDocPicker || showDocViewer;
+ return showActionsMenu || showExampleBrowser ||
runOptionsForm.isVisible() || showDocPicker || showDocViewer
+ || doctorPopup.isVisible() || classpathPopup.isVisible()
+ || stopAllPopup.isVisible() || captionOverlay.isInputVisible();
}
void open() {
@@ -120,9 +130,13 @@ class ActionsPopup {
void close() {
showActionsMenu = false;
showExampleBrowser = false;
- showNameInput = false;
+ runOptionsForm.close();
showDocPicker = false;
showDocViewer = false;
+ doctorPopup.close();
+ classpathPopup.close();
+ stopAllPopup.close();
+ captionOverlay.close();
}
String notification() {
@@ -165,26 +179,14 @@ class ActionsPopup {
}
return true;
}
- if (showNameInput) {
+ if (runOptionsForm.isVisible()) {
if (ke.isCancel()) {
- showNameInput = false;
+ runOptionsForm.close();
showExampleBrowser = true;
} else if (ke.isConfirm()) {
launchWithName();
- } else if (ke.isDeleteBackward()) {
- nameInputState.deleteBackward();
- } else if (ke.isDeleteForward()) {
- nameInputState.deleteForward();
- } else if (ke.isLeft()) {
- nameInputState.moveCursorLeft();
- } else if (ke.isRight()) {
- nameInputState.moveCursorRight();
- } else if (ke.isHome()) {
- nameInputState.moveCursorToStart();
- } else if (ke.isEnd()) {
- nameInputState.moveCursorToEnd();
- } else if (ke.code() == KeyCode.CHAR) {
- nameInputState.insert(ke.character());
+ } else {
+ runOptionsForm.handleKeyEvent(ke);
}
return true;
}
@@ -209,6 +211,19 @@ class ActionsPopup {
}
return true;
}
+ if (captionOverlay.isInputVisible()) {
+ return captionOverlay.handleKeyEvent(ke);
+ }
+ if (classpathPopup.handleKeyEvent(ke)) {
+ return true;
+ }
+ if (stopAllPopup.handleKeyEvent(ke)) {
+ checkStopAllNotification();
+ return true;
+ }
+ if (doctorPopup.handleKeyEvent(ke)) {
+ return true;
+ }
if (showActionsMenu) {
if (ke.isCancel()) {
showActionsMenu = false;
@@ -229,6 +244,19 @@ class ActionsPopup {
} else if (sel == ACTION_SHOW_KEYSTROKES) {
showActionsMenu = false;
toggleKeystrokes.run();
+ } else if (sel == ACTION_DOCTOR) {
+ showActionsMenu = false;
+ doctorPopup.open();
+ } else if (sel == ACTION_CLASSPATH) {
+ showActionsMenu = false;
+ openClasspath();
+ } else if (sel == ACTION_STOP_ALL) {
+ showActionsMenu = false;
+ stopAllPopup.open();
+ checkStopAllNotification();
+ } else if (sel == ACTION_CAPTION) {
+ showActionsMenu = false;
+ captionOverlay.openInput();
}
}
}
@@ -244,8 +272,8 @@ class ActionsPopup {
if (showExampleBrowser) {
renderExampleBrowser(frame, area);
}
- if (showNameInput) {
- renderNameInput(frame, area);
+ if (runOptionsForm.isVisible()) {
+ runOptionsForm.render(frame, area);
}
if (showDocPicker) {
renderDocPicker(frame, area);
@@ -253,9 +281,37 @@ class ActionsPopup {
if (showDocViewer) {
renderDocViewer(frame, area);
}
+ if (doctorPopup.isVisible()) {
+ doctorPopup.render(frame, area);
+ }
+ if (stopAllPopup.isVisible()) {
+ stopAllPopup.render(frame, area);
+ }
+ if (classpathPopup.isVisible()) {
+ classpathPopup.render(frame, area);
+ }
+ if (captionOverlay.isInputVisible()) {
+ captionOverlay.render(frame, area);
+ }
}
void renderFooter(List<Span> spans) {
+ if (captionOverlay.isInputVisible()) {
+ captionOverlay.renderFooter(spans);
+ return;
+ }
+ if (classpathPopup.isVisible()) {
+ classpathPopup.renderFooter(spans);
+ return;
+ }
+ if (stopAllPopup.isVisible()) {
+ stopAllPopup.renderFooter(spans);
+ return;
+ }
+ if (doctorPopup.isVisible()) {
+ doctorPopup.renderFooter(spans);
+ return;
+ }
if (showDocViewer) {
hint(spans, "↑↓", "scroll");
hintLast(spans, "Esc", "back");
@@ -267,9 +323,8 @@ class ActionsPopup {
hintLast(spans, "Esc", "back");
return;
}
- if (showNameInput) {
- hint(spans, "Enter", "launch");
- hintLast(spans, "Esc", "back");
+ if (runOptionsForm.isVisible()) {
+ runOptionsForm.renderFooter(spans);
return;
}
if (showExampleBrowser) {
@@ -297,21 +352,29 @@ class ActionsPopup {
// ---- Rendering ----
private void renderActionsMenu(Frame frame, Rect area) {
- int popupW = 32;
+ int popupW = 34;
int popupH = 2 + ACTION_COUNT;
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, Math.min(popupW, area.width()),
Math.min(popupH, area.height()));
frame.renderWidget(Clear.INSTANCE, popup);
+ // extra space after ⌨️ because it renders narrower than other emoji
String keystrokeLabel = keystrokesEnabled.get()
- ? " Hide Keystrokes"
- : " Show Keystrokes";
+ ? " ⌨️ Hide Keystrokes"
+ : " ⌨️ Show Keystrokes";
+ String stopLabel = stopAllPopup.hasBothGroups()
+ ? " 🛑 Stop All..."
+ : " 🛑 Stop All";
ListWidget list = ListWidget.builder()
- .items(ListItem.from(" Run an example..."),
- ListItem.from(" Show Documentation"),
- ListItem.from(" Take Screenshot"),
- ListItem.from(keystrokeLabel))
+ .items(ListItem.from(" 🐪 Run an example..."),
+ ListItem.from(" 📖 Show Documentation"),
+ ListItem.from(" 💬 Caption... (Ctrl+T)"),
+ ListItem.from(" 📸 Take Screenshot"),
+ ListItem.from(keystrokeLabel),
+ ListItem.from(" 🩺 Run Doctor"),
+ ListItem.from(" 📦 Show Classpath"),
+ ListItem.from(stopLabel))
.highlightStyle(Style.EMPTY.fg(Color.WHITE).bold().onBlue())
.highlightSymbol("")
.scrollMode(ScrollMode.NONE)
@@ -397,35 +460,6 @@ class ActionsPopup {
return items;
}
- private void renderNameInput(Frame frame, Rect area) {
- int popupW = Math.min(50, area.width() - 4);
- int popupH = 4;
- 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, Math.min(popupW, area.width()),
Math.min(popupH, area.height()));
-
- frame.renderWidget(Clear.INSTANCE, popup);
-
- Block block = Block.builder()
- .borderType(BorderType.ROUNDED)
- .title(" Name ")
- .titleBottom(Title.from(Line.from(
- Span.styled(" Enter", MonitorContext.HINT_KEY_STYLE),
Span.raw(" launch │"),
- Span.styled(" Esc", MonitorContext.HINT_KEY_STYLE),
Span.raw(" back "))))
- .build();
- frame.renderWidget(block, popup);
-
- Rect inner = new Rect(popup.left() + 2, popup.top() + 1, popup.width()
- 4, 1);
- frame.renderWidget(Paragraph.from(Line.from(
- Span.styled("Name for the integration:", Style.EMPTY.dim()))),
inner);
-
- Rect inputArea = new Rect(popup.left() + 2, popup.top() + 2,
popup.width() - 4, 1);
- TextInput textInput = TextInput.builder()
- .cursorStyle(Style.EMPTY.reversed())
- .build();
- frame.renderStatefulWidget(textInput, inputArea, nameInputState);
- }
-
// ---- Doc Viewer & Picker ----
private void renderDocViewer(Frame frame, Rect area) {
@@ -583,6 +617,36 @@ class ActionsPopup {
launchNotificationExpiry = System.currentTimeMillis() + 10000;
}
+ private void checkStopAllNotification() {
+ String msg = stopAllPopup.consumeNotification();
+ if (msg != null) {
+ setNotification(msg, false);
+ }
+ }
+
+ private void openClasspath() {
+ if (ctx == null) {
+ return;
+ }
+ String pid = ctx.selectedPid;
+ if (pid == null) {
+ List<IntegrationInfo> ints = integrations.get();
+ List<IntegrationInfo> alive = ints.stream().filter(i ->
!i.vanishing && i.pid != null).toList();
+ if (alive.size() == 1) {
+ pid = alive.get(0).pid;
+ }
+ }
+ if (pid == null) {
+ setNotification("Select an integration first", true);
+ return;
+ }
+ classpathPopup.open(ctx, pid, ctx.selectedName());
+ String err = classpathPopup.consumeError();
+ if (err != null) {
+ setNotification(err, true);
+ }
+ }
+
// ---- Name Input ----
private void openNameInput() {
@@ -598,8 +662,7 @@ class ActionsPopup {
String baseName = example.getStringOrDefault("name", "");
String autoName = generateUniqueName(baseName);
showExampleBrowser = false;
- showNameInput = true;
- nameInputState = new TextInputState(autoName);
+ runOptionsForm.open(autoName, baseName,
ExampleHelper.isBundled(example));
}
private String generateUniqueName(String baseName) {
@@ -616,26 +679,27 @@ class ActionsPopup {
}
private void launchWithName() {
- if (selectedExample == null || nameInputState == null) {
+ if (selectedExample == null) {
return;
}
- String customName = nameInputState.text().trim();
String exampleName = selectedExample.getStringOrDefault("name", "");
- showNameInput = false;
+ String displayName = runOptionsForm.name();
+ if (displayName.isEmpty()) {
+ displayName = exampleName;
+ }
+ List<String> extraArgs = runOptionsForm.buildArgs();
+ runOptionsForm.close();
try {
List<String> cmd = new
ArrayList<>(LauncherHelper.getCamelCommand());
cmd.add("run");
cmd.add("--example=" + exampleName);
- if (!customName.isEmpty() && !customName.equals(exampleName)) {
- cmd.add("--name=" + customName);
- }
+ cmd.addAll(extraArgs);
Path outputFile = Files.createTempFile("camel-example-", ".log");
outputFile.toFile().deleteOnExit();
ProcessBuilder pb = new ProcessBuilder(cmd);
pb.redirectErrorStream(true);
pb.redirectOutput(outputFile.toFile());
Process process = pb.start();
- String displayName = customName.isEmpty() ? exampleName :
customName;
pendingLaunches.add(new PendingLaunch(displayName, process,
outputFile, System.currentTimeMillis()));
launchNotification = "Starting: " + displayName;
launchNotificationError = false;
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 f34daf997371..72a1265c6d39 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
@@ -202,6 +202,7 @@ public class CamelMonitor extends CamelCommand {
private volatile boolean pendingScreenshot;
private boolean recording;
private final List<KeyRecord> recentKeys = new ArrayList<>();
+ private final CaptionOverlay captionOverlay = new CaptionOverlay();
private final ActionsPopup actionsPopup = new ActionsPopup(
() -> data.get().stream()
@@ -211,6 +212,10 @@ public class CamelMonitor extends CamelCommand {
() -> data.get().stream()
.filter(i -> !i.vanishing)
.collect(Collectors.toList()),
+ () -> infraData.get().stream()
+ .filter(i -> !i.vanishing)
+ .collect(Collectors.toList()),
+ captionOverlay,
() -> pendingScreenshot = true,
() -> recording = !recording,
() -> recording);
@@ -301,6 +306,14 @@ public class CamelMonitor extends CamelCommand {
recentKeys.add(new KeyRecord(label,
System.currentTimeMillis()));
}
}
+ if (captionOverlay.isCaptionVisible()) {
+ captionOverlay.handleKeyEvent(ke);
+ return true;
+ }
+ if (ke.hasCtrl() && ke.isChar('t')) {
+ captionOverlay.openInput();
+ return true;
+ }
if (actionsPopup.isVisible()) {
return actionsPopup.handleKeyEvent(ke);
}
@@ -535,6 +548,7 @@ public class CamelMonitor extends CamelCommand {
if (event instanceof TickEvent) {
long now = System.currentTimeMillis();
actionsPopup.tick(now);
+ captionOverlay.tick(now);
if (recording && !recentKeys.isEmpty()) {
long cutoff = now - 2000;
recentKeys.removeIf(k -> k.timestamp() < cutoff);
@@ -744,6 +758,9 @@ public class CamelMonitor extends CamelCommand {
renderKillConfirm(frame, mainChunks.get(4));
}
actionsPopup.render(frame, mainChunks.get(4));
+ if (captionOverlay.isCaptionVisible()) {
+ captionOverlay.render(frame, mainChunks.get(4));
+ }
renderFooter(frame, mainChunks.get(5));
lastBuffer = frame.buffer();
@@ -1567,6 +1584,13 @@ public class CamelMonitor extends CamelCommand {
screenshotMessage = null;
List<Span> spans = new ArrayList<>();
+
+ if (captionOverlay.isCaptionVisible()) {
+ captionOverlay.renderFooter(spans);
+ frame.renderWidget(Paragraph.from(Line.from(spans)), area);
+ return;
+ }
+
MonitorTab tab = activeTab();
if (tab != null) {
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CaptionOverlay.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CaptionOverlay.java
new file mode 100644
index 000000000000..6574e4eb640d
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CaptionOverlay.java
@@ -0,0 +1,217 @@
+/*
+ * 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 dev.tamboui.layout.Rect;
+import dev.tamboui.style.Color;
+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.widgets.Clear;
+import dev.tamboui.widgets.block.Block;
+import dev.tamboui.widgets.block.BorderType;
+import dev.tamboui.widgets.block.Title;
+import dev.tamboui.widgets.input.TextInput;
+import dev.tamboui.widgets.input.TextInputState;
+import dev.tamboui.widgets.paragraph.Paragraph;
+
+import static org.apache.camel.dsl.jbang.core.commands.tui.MonitorContext.hint;
+import static
org.apache.camel.dsl.jbang.core.commands.tui.MonitorContext.hintLast;
+
+class CaptionOverlay {
+
+ private static final long CHAR_DELAY_MS = 50;
+ private static final long HOLD_DURATION_MS = 3000;
+ private static final long FADE_DURATION_MS = 1000;
+
+ private boolean showInput;
+ private TextInputState inputState;
+
+ private String captionText;
+ private long captionStartTime;
+ private long captionFullyTypedTime;
+
+ boolean isInputVisible() {
+ return showInput;
+ }
+
+ boolean isCaptionVisible() {
+ return captionText != null;
+ }
+
+ boolean isVisible() {
+ return showInput || captionText != null;
+ }
+
+ void openInput() {
+ showInput = true;
+ inputState = new TextInputState("");
+ }
+
+ void close() {
+ showInput = false;
+ inputState = null;
+ captionText = null;
+ captionFullyTypedTime = 0;
+ }
+
+ boolean handleKeyEvent(KeyEvent ke) {
+ if (showInput) {
+ if (ke.isCancel()) {
+ showInput = false;
+ inputState = null;
+ } else if (ke.isConfirm()) {
+ String text = inputState.text().trim();
+ showInput = false;
+ inputState = null;
+ if (!text.isEmpty()) {
+ captionText = text;
+ captionStartTime = System.currentTimeMillis();
+ captionFullyTypedTime = 0;
+ }
+ } else if (ke.isDeleteBackward()) {
+ inputState.deleteBackward();
+ } else if (ke.isDeleteForward()) {
+ inputState.deleteForward();
+ } else if (ke.isLeft()) {
+ inputState.moveCursorLeft();
+ } else if (ke.isRight()) {
+ inputState.moveCursorRight();
+ } else if (ke.isHome()) {
+ inputState.moveCursorToStart();
+ } else if (ke.isEnd()) {
+ inputState.moveCursorToEnd();
+ } else if (ke.code() == KeyCode.CHAR) {
+ inputState.insert(ke.character());
+ }
+ return true;
+ }
+ if (captionText != null) {
+ captionText = null;
+ captionFullyTypedTime = 0;
+ return true;
+ }
+ return false;
+ }
+
+ void tick(long now) {
+ if (captionText == null) {
+ return;
+ }
+ int totalChars = captionText.length();
+ long elapsed = now - captionStartTime;
+ int charsToShow = (int) (elapsed / CHAR_DELAY_MS);
+
+ if (charsToShow >= totalChars && captionFullyTypedTime == 0) {
+ captionFullyTypedTime = now;
+ }
+ if (captionFullyTypedTime > 0 && now - captionFullyTypedTime >
HOLD_DURATION_MS + FADE_DURATION_MS) {
+ captionText = null;
+ captionFullyTypedTime = 0;
+ }
+ }
+
+ void render(Frame frame, Rect area) {
+ if (showInput) {
+ renderInput(frame, area);
+ return;
+ }
+ if (captionText != null) {
+ renderCaption(frame, area);
+ }
+ }
+
+ void renderFooter(List<Span> spans) {
+ if (showInput) {
+ hint(spans, "Enter", "show");
+ hintLast(spans, "Esc", "cancel");
+ } else if (captionText != null) {
+ hintLast(spans, "any key", "dismiss");
+ }
+ }
+
+ private void renderInput(Frame frame, Rect area) {
+ int popupW = Math.min(50, area.width() - 4);
+ int popupH = 4;
+ 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, Math.min(popupW, area.width()),
Math.min(popupH, area.height()));
+
+ frame.renderWidget(Clear.INSTANCE, popup);
+
+ Block block = Block.builder()
+ .borderType(BorderType.ROUNDED)
+ .title(" 💬 Caption ")
+ .titleBottom(Title.from(Line.from(
+ Span.styled(" Enter", MonitorContext.HINT_KEY_STYLE),
Span.raw(" show │"),
+ Span.styled(" Esc", MonitorContext.HINT_KEY_STYLE),
Span.raw(" cancel "))))
+ .build();
+ frame.renderWidget(block, popup);
+
+ Rect inner = new Rect(popup.left() + 2, popup.top() + 1, popup.width()
- 4, 1);
+ frame.renderWidget(Paragraph.from(Line.from(
+ Span.styled("Caption text (\\n for newline):",
Style.EMPTY.dim()))), inner);
+
+ Rect inputArea = new Rect(popup.left() + 2, popup.top() + 2,
popup.width() - 4, 1);
+ TextInput textInput = TextInput.builder()
+ .cursorStyle(Style.EMPTY.reversed())
+ .build();
+ frame.renderStatefulWidget(textInput, inputArea, inputState);
+ }
+
+ private void renderCaption(Frame frame, Rect area) {
+ long now = System.currentTimeMillis();
+ long elapsed = now - captionStartTime;
+ int charsToShow = Math.min((int) (elapsed / CHAR_DELAY_MS),
captionText.length());
+ String visible = captionText.substring(0, charsToShow);
+
+ Style style;
+ if (captionFullyTypedTime == 0 || now - captionFullyTypedTime <
HOLD_DURATION_MS) {
+ style = Style.EMPTY.fg(Color.WHITE).bold();
+ } else {
+ style = Style.EMPTY.dim();
+ }
+
+ String[] parts = visible.split("\\\\n", -1);
+ List<Line> lines = new ArrayList<>();
+ int maxWidth = 0;
+ for (String part : parts) {
+ lines.add(Line.from(Span.styled(" " + part + " ", style)));
+ maxWidth = Math.max(maxWidth, part.length() + 4);
+ }
+
+ int captionW = Math.min(maxWidth, area.width() - 2);
+ int captionH = lines.size();
+ int captionX = area.left() + Math.max(0, (area.width() - captionW) /
2);
+ int captionY = area.top() + Math.max(0, (area.height() - captionH) /
2);
+ Rect captionArea = new Rect(
+ captionX, captionY, Math.min(captionW, area.width()),
+ Math.min(captionH, area.height()));
+
+ frame.renderWidget(Clear.INSTANCE, captionArea);
+ frame.renderWidget(Paragraph.builder()
+ .text(Text.from(lines.toArray(Line[]::new)))
+ .build(), captionArea);
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ClasspathPopup.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ClasspathPopup.java
new file mode 100644
index 000000000000..6a63320034ec
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ClasspathPopup.java
@@ -0,0 +1,304 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Color;
+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.widgets.Clear;
+import dev.tamboui.widgets.block.Block;
+import dev.tamboui.widgets.block.BorderType;
+import dev.tamboui.widgets.block.Title;
+import dev.tamboui.widgets.list.ListItem;
+import dev.tamboui.widgets.list.ListState;
+import dev.tamboui.widgets.list.ListWidget;
+import dev.tamboui.widgets.list.ScrollMode;
+import org.apache.camel.dsl.jbang.core.common.PathUtils;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+
+import static org.apache.camel.dsl.jbang.core.commands.tui.MonitorContext.hint;
+import static
org.apache.camel.dsl.jbang.core.commands.tui.MonitorContext.hintLast;
+
+class ClasspathPopup {
+
+ private static final Style MATCH_STYLE =
Style.EMPTY.fg(Color.YELLOW).bold();
+
+ private boolean visible;
+ private final ListState listState = new ListState();
+ private final FuzzyFilter fuzzyFilter = new FuzzyFilter();
+ private List<JarEntry> entries;
+ private List<FilteredEntry> filteredEntries;
+ private String baseTitle;
+ private String errorMessage;
+
+ boolean isVisible() {
+ return visible;
+ }
+
+ void open(MonitorContext ctx, String pid, String integrationName) {
+ if (ctx == null || pid == null) {
+ errorMessage = "No integration selected";
+ return;
+ }
+
+ try {
+ Path outputFile = ctx.getOutputFile(pid);
+ Files.deleteIfExists(outputFile);
+ JsonObject action = new JsonObject();
+ action.put("action", "jvm");
+ PathUtils.writeTextSafely(action.toJson(), ctx.getActionFile(pid));
+ JsonObject response = MonitorContext.pollJsonResponse(outputFile,
5000);
+ if (response != null) {
+ Object cp = response.get("classpath");
+ List<String> paths = new ArrayList<>();
+ if (cp instanceof JsonArray arr) {
+ for (Object item : arr) {
+ paths.add(String.valueOf(item));
+ }
+ } else if (cp instanceof String[] arr) {
+ for (String s : arr) {
+ paths.add(s);
+ }
+ }
+ if (paths.isEmpty()) {
+ errorMessage = "No classpath information available";
+ return;
+ }
+ entries = new ArrayList<>();
+ for (String path : paths) {
+ entries.add(parseJarEntry(path));
+ }
+ entries.sort((a, b) ->
a.display().compareToIgnoreCase(b.display()));
+ baseTitle = (integrationName != null ? integrationName : pid)
+ " - Classpath";
+ fuzzyFilter.clearFilter();
+ refilter();
+ listState.select(0);
+ visible = true;
+ } else {
+ errorMessage = "No response from integration";
+ }
+ } catch (Exception e) {
+ errorMessage = "Error fetching classpath: " + e.getMessage();
+ }
+ }
+
+ void close() {
+ visible = false;
+ }
+
+ String consumeError() {
+ String msg = errorMessage;
+ errorMessage = null;
+ return msg;
+ }
+
+ boolean handleKeyEvent(KeyEvent ke) {
+ if (!visible) {
+ return false;
+ }
+ if (ke.isCancel()) {
+ if (fuzzyFilter.hasFilter()) {
+ fuzzyFilter.clearFilter();
+ refilter();
+ } else {
+ visible = false;
+ }
+ } else if (ke.isDeleteBackward()) {
+ if (fuzzyFilter.hasFilter()) {
+ fuzzyFilter.deleteChar();
+ refilter();
+ }
+ } else if (ke.isUp()) {
+ listState.selectPrevious();
+ } else if (ke.isDown()) {
+ listState.selectNext(filteredEntries != null ?
filteredEntries.size() : 0);
+ } else if (ke.isPageUp() || ke.isKey(KeyCode.PAGE_UP)) {
+ for (int i = 0; i < 10; i++) {
+ listState.selectPrevious();
+ }
+ } else if (ke.isPageDown() || ke.isKey(KeyCode.PAGE_DOWN)) {
+ if (filteredEntries != null) {
+ for (int i = 0; i < 10; i++) {
+ listState.selectNext(filteredEntries.size());
+ }
+ }
+ } else if (ke.code() == KeyCode.CHAR) {
+ fuzzyFilter.appendChar(ke.character());
+ refilter();
+ }
+ return true;
+ }
+
+ void render(Frame frame, Rect area) {
+ if (filteredEntries == null) {
+ return;
+ }
+ int itemCount = filteredEntries.size();
+ int popupW = Math.min(100, area.width() - 4);
+ int popupH = Math.min(itemCount + 2, Math.min(30, area.height() - 4));
+ 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, Math.min(popupW, area.width()),
Math.min(popupH, area.height()));
+
+ frame.renderWidget(Clear.INSTANCE, popup);
+
+ int contentW = popupW - 4;
+ List<ListItem> items = new ArrayList<>();
+ for (FilteredEntry fe : filteredEntries) {
+ String displayText = formatEntry(fe.entry(), contentW);
+ Style normalStyle = fe.entry().isCamel() ? Style.EMPTY :
Style.EMPTY.dim();
+ if (fe.matchPositions() != null && fe.matchPositions().length > 0)
{
+ // offset match positions by the formatting prefix (2 spaces)
+ int[] adjusted = adjustPositions(fe.matchPositions(),
fe.entry(), displayText);
+ Line line = FuzzyFilter.highlightLine(displayText, adjusted,
normalStyle, MATCH_STYLE);
+ items.add(ListItem.from(Text.from(line)));
+ } else {
+ items.add(ListItem.from(displayText).style(normalStyle));
+ }
+ }
+
+ String title = " " + baseTitle + " (" + filteredEntries.size();
+ if (fuzzyFilter.hasFilter()) {
+ title += "/" + entries.size() + ") [" + fuzzyFilter.filter() + "]
";
+ } else {
+ title += ") ";
+ }
+
+ ListWidget list = ListWidget.builder()
+ .items(items.toArray(ListItem[]::new))
+ .highlightStyle(Style.EMPTY.fg(Color.WHITE).bold().onBlue())
+ .highlightSymbol("")
+ .scrollMode(ScrollMode.AUTO_SCROLL)
+ .block(Block.builder()
+ .borderType(BorderType.ROUNDED)
+ .title(title)
+ .titleBottom(Title.from(Line.from(
+ Span.styled(" ↑↓",
MonitorContext.HINT_KEY_STYLE), Span.raw(" navigate │"),
+ Span.styled(" type",
MonitorContext.HINT_KEY_STYLE), Span.raw(" filter │"),
+ Span.styled(" Esc",
MonitorContext.HINT_KEY_STYLE),
+ Span.raw(fuzzyFilter.hasFilter() ? " clear " :
" back "))))
+ .build())
+ .build();
+ frame.renderStatefulWidget(list, popup, listState);
+ }
+
+ void renderFooter(List<Span> spans) {
+ hint(spans, "↑↓", "navigate");
+ hint(spans, "type", "filter");
+ hintLast(spans, "Esc", fuzzyFilter.hasFilter() ? "clear" : "back");
+ }
+
+ private void refilter() {
+ filteredEntries = new ArrayList<>();
+ if (entries == null) {
+ return;
+ }
+ for (JarEntry entry : entries) {
+ if (!fuzzyFilter.hasFilter()) {
+ filteredEntries.add(new FilteredEntry(entry, null));
+ } else {
+ int[] positions = fuzzyFilter.match(entry.display());
+ if (positions != null) {
+ filteredEntries.add(new FilteredEntry(entry, positions));
+ }
+ }
+ }
+ listState.select(filteredEntries.isEmpty() ? null : 0);
+ }
+
+ private int[] adjustPositions(int[] matchPositions, JarEntry entry, String
displayText) {
+ // match positions are relative to entry.display()
(groupId:artifactId:version)
+ // displayText is formatted with " " prefix and column layout
+ // find where the GAV text starts in the display text
+ String searchText = entry.display();
+ int offset = displayText.indexOf(searchText.substring(0,
Math.min(searchText.length(), 5)));
+ if (offset < 0) {
+ offset = 2;
+ }
+ int[] adjusted = new int[matchPositions.length];
+ for (int i = 0; i < matchPositions.length; i++) {
+ adjusted[i] = matchPositions[i] + offset;
+ }
+ return adjusted;
+ }
+
+ private String formatEntry(JarEntry entry, int width) {
+ if (entry.groupId() != null) {
+ String gav = entry.groupId() + ":" + entry.artifactId();
+ String ver = entry.version() != null ? entry.version() : "";
+ int gavCol = Math.min(60, width - ver.length() - 4);
+ return String.format(" %-" + gavCol + "s %s",
TuiHelper.truncate(gav, gavCol), ver);
+ }
+ return " " + TuiHelper.truncate(entry.display(), width - 2);
+ }
+
+ static JarEntry parseJarEntry(String path) {
+ // try to extract Maven GAV from path like:
.m2/repository/org/apache/camel/camel-core/4.x/camel-core-4.x.jar
+ String normalized = path.replace('\\', '/');
+ int repoIdx = normalized.indexOf("/repository/");
+ if (repoIdx >= 0) {
+ String relative = normalized.substring(repoIdx +
"/repository/".length());
+ int lastSlash = relative.lastIndexOf('/');
+ if (lastSlash > 0) {
+ String parentPath = relative.substring(0, lastSlash);
+ int versionSlash = parentPath.lastIndexOf('/');
+ if (versionSlash > 0) {
+ String version = parentPath.substring(versionSlash + 1);
+ String remaining = parentPath.substring(0, versionSlash);
+ int artifactSlash = remaining.lastIndexOf('/');
+ if (artifactSlash > 0) {
+ String artifactId = remaining.substring(artifactSlash
+ 1);
+ String groupId = remaining.substring(0,
artifactSlash).replace('/', '.');
+ return new JarEntry(groupId, artifactId, version,
path);
+ }
+ }
+ }
+ }
+ // fallback: just use filename
+ int slash = normalized.lastIndexOf('/');
+ String filename = slash >= 0 ? normalized.substring(slash + 1) :
normalized;
+ return new JarEntry(null, filename, null, path);
+ }
+
+ record JarEntry(String groupId, String artifactId, String version, String
fullPath) {
+ String display() {
+ if (groupId != null) {
+ return groupId + ":" + artifactId + ":" + version;
+ }
+ return fullPath;
+ }
+
+ boolean isCamel() {
+ return groupId != null && groupId.startsWith("org.apache.camel");
+ }
+ }
+
+ record FilteredEntry(JarEntry entry, int[] matchPositions) {
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DoctorPopup.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DoctorPopup.java
new file mode 100644
index 000000000000..865ff567dd89
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DoctorPopup.java
@@ -0,0 +1,283 @@
+/*
+ * 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.io.File;
+import java.io.OutputStream;
+import java.net.ServerSocket;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+import dev.tamboui.layout.Rect;
+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.KeyEvent;
+import dev.tamboui.widgets.Clear;
+import dev.tamboui.widgets.block.Block;
+import dev.tamboui.widgets.block.BorderType;
+import dev.tamboui.widgets.block.Title;
+import dev.tamboui.widgets.paragraph.Paragraph;
+import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.catalog.DefaultCamelCatalog;
+import org.apache.camel.dsl.jbang.core.common.VersionHelper;
+import org.apache.camel.tooling.maven.MavenDownloaderImpl;
+import org.apache.camel.tooling.maven.MavenResolutionException;
+
+import static
org.apache.camel.dsl.jbang.core.commands.tui.MonitorContext.hintLast;
+
+class DoctorPopup {
+
+ private boolean visible;
+ private List<Line> lines;
+
+ boolean isVisible() {
+ return visible;
+ }
+
+ void open() {
+ lines = new ArrayList<>();
+ checkJava(lines);
+ checkCamelVersion(lines);
+ checkJBang(lines);
+ checkMavenRepository(lines);
+ checkContainerRuntime(lines);
+ checkCommonPorts(lines);
+ checkDiskSpace(lines);
+ visible = true;
+ }
+
+ void close() {
+ visible = false;
+ }
+
+ boolean handleKeyEvent(KeyEvent ke) {
+ if (visible) {
+ if (ke.isCancel()) {
+ visible = false;
+ }
+ return true;
+ }
+ return false;
+ }
+
+ void render(Frame frame, Rect area) {
+ if (lines == null || lines.isEmpty()) {
+ return;
+ }
+ int popupW = Math.min(62, area.width() - 4);
+ int popupH = Math.min(lines.size() + 2, area.height() - 4);
+ 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, Math.min(popupW, area.width()),
Math.min(popupH, area.height()));
+
+ frame.renderWidget(Clear.INSTANCE, popup);
+ Paragraph para = Paragraph.builder()
+ .text(Text.from(lines.toArray(Line[]::new)))
+ .block(Block.builder()
+ .borderType(BorderType.ROUNDED)
+ .title(" 🩺 Doctor ")
+ .titleBottom(Title.from(Line.from(
+ Span.styled(" Esc",
MonitorContext.HINT_KEY_STYLE), Span.raw(" back "))))
+ .build())
+ .build();
+ frame.renderWidget(para, popup);
+ }
+
+ void renderFooter(List<Span> spans) {
+ hintLast(spans, "Esc", "back");
+ }
+
+ // ---- Checks ----
+
+ private void checkJava(List<Line> result) {
+ String version = System.getProperty("java.version");
+ String vendor = System.getProperty("java.vendor", "");
+ int major = Runtime.version().feature();
+ String status;
+ String emoji;
+ if (major >= 21) {
+ status = null;
+ emoji = "✅";
+ } else if (major >= 17) {
+ status = "Consider upgrading to 21 or 25";
+ emoji = "⚠️";
+ } else {
+ status = "17+ required";
+ emoji = "❌";
+ }
+ result.add(Line.from(
+ Span.raw(" ☕ "),
+ Span.styled(String.format("%-14s", "Java"),
Style.EMPTY.bold()),
+ Span.raw(String.format("%-30s", version + " (" + vendor +
")")),
+ Span.raw(" " + emoji)));
+ if (status != null) {
+ result.add(Line.from(Span.styled(" " + status,
Style.EMPTY.dim())));
+ }
+ }
+
+ private void checkCamelVersion(List<Line> result) {
+ try {
+ CamelCatalog catalog = new DefaultCamelCatalog();
+ String version = catalog.getCatalogVersion();
+ result.add(Line.from(
+ Span.raw(" 🐪 "),
+ Span.styled(String.format("%-14s", "Camel"),
Style.EMPTY.bold()),
+ Span.raw(String.format("%-30s", version)),
+ Span.raw(" ✅")));
+ } catch (Exception e) {
+ result.add(Line.from(
+ Span.raw(" 🐪 "),
+ Span.styled(String.format("%-14s", "Camel"),
Style.EMPTY.bold()),
+ Span.raw(String.format("%-30s", "Not detected")),
+ Span.raw(" ❌")));
+ }
+ }
+
+ private void checkJBang(List<Line> result) {
+ String version = VersionHelper.getJBangVersion();
+ if (version != null) {
+ result.add(Line.from(
+ Span.raw(" 📦 "),
+ Span.styled(String.format("%-14s", "JBang"),
Style.EMPTY.bold()),
+ Span.raw(String.format("%-30s", version)),
+ Span.raw(" ✅")));
+ } else {
+ result.add(Line.from(
+ Span.raw(" 📦 "),
+ Span.styled(String.format("%-14s", "JBang"),
Style.EMPTY.bold()),
+ Span.raw(String.format("%-30s", "Not detected")),
+ Span.raw(" ⚠️")));
+ }
+ }
+
+ private void checkMavenRepository(List<Line> result) {
+ try (MavenDownloaderImpl downloader = new MavenDownloaderImpl()) {
+ downloader.build();
+ CamelCatalog catalog = new DefaultCamelCatalog();
+ String version = catalog.getCatalogVersion();
+ downloader.resolveArtifacts(
+ List.of("org.apache.camel:camel-api:" + version),
+ Set.of(), false, false);
+ result.add(Line.from(
+ Span.raw(" 🔧 "),
+ Span.styled(String.format("%-14s", "Maven"),
Style.EMPTY.bold()),
+ Span.raw(String.format("%-30s", "Artifact resolution")),
+ Span.raw(" ✅")));
+ } catch (MavenResolutionException e) {
+ result.add(Line.from(
+ Span.raw(" 🔧 "),
+ Span.styled(String.format("%-14s", "Maven"),
Style.EMPTY.bold()),
+ Span.raw(String.format("%-30s", "Resolution failed")),
+ Span.raw(" ❌")));
+ result.add(Line.from(Span.styled(" " +
TuiHelper.truncate(e.getMessage(), 40),
+ Style.EMPTY.dim())));
+ } catch (Exception e) {
+ result.add(Line.from(
+ Span.raw(" 🔧 "),
+ Span.styled(String.format("%-14s", "Maven"),
Style.EMPTY.bold()),
+ Span.raw(String.format("%-30s", "Error")),
+ Span.raw(" ❌")));
+ result.add(Line.from(Span.styled(" " +
TuiHelper.truncate(e.getMessage(), 40),
+ Style.EMPTY.dim())));
+ }
+ }
+
+ private void checkContainerRuntime(List<Line> result) {
+ for (String cmd : new String[] { "docker", "podman" }) {
+ try {
+ Process p = new ProcessBuilder(cmd, "info")
+ .redirectErrorStream(true)
+ .start();
+ p.getInputStream().transferTo(OutputStream.nullOutputStream());
+ int exit = p.waitFor();
+ if (exit == 0) {
+ String name = Character.toUpperCase(cmd.charAt(0)) +
cmd.substring(1);
+ result.add(Line.from(
+ Span.raw(" 🐳 "),
+ Span.styled(String.format("%-14s", "Container"),
Style.EMPTY.bold()),
+ Span.raw(String.format("%-30s", name + "
running")),
+ Span.raw(" ✅")));
+ return;
+ }
+ } catch (Exception e) {
+ // not found, try next
+ }
+ }
+ result.add(Line.from(
+ Span.raw(" 🐳 "),
+ Span.styled(String.format("%-14s", "Container"),
Style.EMPTY.bold()),
+ Span.raw(String.format("%-30s", "Not found (optional)")),
+ Span.raw(" ⚠️")));
+ }
+
+ private void checkCommonPorts(List<Line> result) {
+ StringBuilder conflicts = new StringBuilder();
+ for (int port : new int[] { 8080, 8443, 9090 }) {
+ if (isPortInUse(port)) {
+ if (!conflicts.isEmpty()) {
+ conflicts.append(", ");
+ }
+ conflicts.append(port);
+ }
+ }
+ if (!conflicts.isEmpty()) {
+ result.add(Line.from(
+ Span.raw(" 🔌 "),
+ Span.styled(String.format("%-14s", "Ports"),
Style.EMPTY.bold()),
+ Span.raw(String.format("%-30s", "In use: " + conflicts)),
+ Span.raw(" ⚠️")));
+ } else {
+ result.add(Line.from(
+ Span.raw(" 🔌 "),
+ Span.styled(String.format("%-14s", "Ports"),
Style.EMPTY.bold()),
+ Span.raw(String.format("%-30s", "8080, 8443, 9090 free")),
+ Span.raw(" ✅")));
+ }
+ }
+
+ private static boolean isPortInUse(int port) {
+ try (ServerSocket ss = new ServerSocket(port)) {
+ ss.setReuseAddress(true);
+ return false;
+ } catch (Exception e) {
+ return true;
+ }
+ }
+
+ private void checkDiskSpace(List<Line> result) {
+ File tmpDir = new File(System.getProperty("java.io.tmpdir"));
+ long free = tmpDir.getFreeSpace();
+ long mb = free / (1024 * 1024);
+ long gb = mb / 1024;
+ String emoji = mb > 500 ? "✅" : "⚠️";
+ String unit = gb > 10 ? "GB" : "MB";
+ long value = gb > 0 ? gb : mb;
+ result.add(Line.from(
+ Span.raw(" 💾 "),
+ Span.styled(String.format("%-14s", "Disk Space"),
Style.EMPTY.bold()),
+ Span.raw(String.format("%-30s", value + " " + unit + " free in
temp dir")),
+ Span.raw(" " + emoji)));
+ if (mb <= 500) {
+ result.add(Line.from(Span.styled(" Low disk
space may cause issues",
+ Style.EMPTY.dim())));
+ }
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FuzzyFilter.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FuzzyFilter.java
new file mode 100644
index 000000000000..067de6c2b617
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FuzzyFilter.java
@@ -0,0 +1,125 @@
+/*
+ * 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 dev.tamboui.style.Style;
+import dev.tamboui.text.Line;
+import dev.tamboui.text.Span;
+
+/**
+ * Reusable fuzzy filter for TUI list views. Manages filter state and provides
utilities for fuzzy matching and
+ * highlighted rendering of matched characters.
+ *
+ * <p>
+ * Usage: call {@link #appendChar(char)} / {@link #deleteChar()} as the user
types. Use {@link #match(String)} to test
+ * each item and get match positions. Use {@link #highlightLine(String, int[],
Style, Style)} to render with matched
+ * characters highlighted.
+ */
+class FuzzyFilter {
+
+ private String filter = "";
+
+ boolean hasFilter() {
+ return !filter.isEmpty();
+ }
+
+ String filter() {
+ return filter;
+ }
+
+ void appendChar(char c) {
+ filter += Character.toLowerCase(c);
+ }
+
+ void deleteChar() {
+ if (!filter.isEmpty()) {
+ filter = filter.substring(0, filter.length() - 1);
+ }
+ }
+
+ void clearFilter() {
+ filter = "";
+ }
+
+ /**
+ * Fuzzy-match the filter against the given text. Characters in the filter
must appear in order (but not
+ * consecutively) in the text. Case-insensitive.
+ *
+ * @return positions of matched characters in the text, or null if no match
+ */
+ int[] match(String text) {
+ return fuzzyMatch(text, filter);
+ }
+
+ /**
+ * Static fuzzy match: find each character of {@code pattern} in order
within {@code text} (case-insensitive).
+ *
+ * @return array of match positions, or null if pattern doesn't match
+ */
+ static int[] fuzzyMatch(String text, String pattern) {
+ if (pattern == null || pattern.isEmpty()) {
+ return new int[0];
+ }
+ String lowerText = text.toLowerCase();
+ int[] positions = new int[pattern.length()];
+ int textIdx = 0;
+ for (int i = 0; i < pattern.length(); i++) {
+ char c = pattern.charAt(i);
+ int found = lowerText.indexOf(c, textIdx);
+ if (found < 0) {
+ return null;
+ }
+ positions[i] = found;
+ textIdx = found + 1;
+ }
+ return positions;
+ }
+
+ /**
+ * Build a {@link Line} with matched character positions highlighted.
+ *
+ * @param text the full display text
+ * @param matchPositions positions of matched characters (from {@link
#match(String)})
+ * @param normalStyle style for non-matched characters
+ * @param matchStyle style for matched characters
+ * @return a Line with interleaved normal and highlighted
spans
+ */
+ static Line highlightLine(String text, int[] matchPositions, Style
normalStyle, Style matchStyle) {
+ if (matchPositions == null || matchPositions.length == 0) {
+ return Line.from(Span.styled(text, normalStyle));
+ }
+ List<Span> spans = new ArrayList<>();
+ int pos = 0;
+ for (int matchIdx : matchPositions) {
+ if (matchIdx < pos || matchIdx >= text.length()) {
+ continue;
+ }
+ if (matchIdx > pos) {
+ spans.add(Span.styled(text.substring(pos, matchIdx),
normalStyle));
+ }
+ spans.add(Span.styled(text.substring(matchIdx, matchIdx + 1),
matchStyle));
+ pos = matchIdx + 1;
+ }
+ if (pos < text.length()) {
+ spans.add(Span.styled(text.substring(pos), normalStyle));
+ }
+ return Line.from(spans);
+ }
+}
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
new file mode 100644
index 000000000000..62e12ecdbc20
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RunOptionsForm.java
@@ -0,0 +1,618 @@
+/*
+ * 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 dev.tamboui.layout.Rect;
+import dev.tamboui.style.Style;
+import dev.tamboui.terminal.Frame;
+import dev.tamboui.text.Line;
+import dev.tamboui.text.Span;
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.widgets.Clear;
+import dev.tamboui.widgets.block.Block;
+import dev.tamboui.widgets.block.BorderType;
+import dev.tamboui.widgets.block.Title;
+import dev.tamboui.widgets.input.TextInput;
+import dev.tamboui.widgets.input.TextInputState;
+import dev.tamboui.widgets.paragraph.Paragraph;
+
+import static org.apache.camel.dsl.jbang.core.commands.tui.MonitorContext.hint;
+import static
org.apache.camel.dsl.jbang.core.commands.tui.MonitorContext.hintLast;
+
+class RunOptionsForm {
+
+ private static final int PAGE_OPTIONS = 0;
+ private static final int PAGE_PROPERTIES = 1;
+
+ // Row indices for page 0
+ private static final int ROW_NAME = 0;
+ private static final int ROW_PORT = 1;
+ private static final int ROW_MAX = 2;
+ private static final int ROW_DEV = 3;
+ private static final int ROW_OBSERVE = 4;
+ private static final int ROW_TRACE = 5;
+ private static final int ROW_COUNT = 6;
+
+ private boolean visible;
+ private int page;
+ private int selectedRow;
+
+ private static final String[] MAX_MODES = { "Max seconds:", "Max
messages:", "Max idle secs:" };
+ private static final String[] MAX_FLAGS = { "--max-seconds=",
"--max-messages=", "--max-idle-seconds=" };
+
+ // Text fields
+ private TextInputState nameInput;
+ private TextInputState portInput;
+ private TextInputState maxInput;
+ private int maxMode;
+
+ // Checkboxes
+ private boolean devMode;
+ private boolean observe;
+ private boolean backlogTrace;
+
+ private String exampleTitle;
+
+ // Properties (page 2)
+ private List<PropertyEntry> properties;
+ private int selectedProperty;
+ private boolean editingKey;
+
+ boolean isVisible() {
+ return visible;
+ }
+
+ void open(String defaultName, String exampleName, boolean bundled) {
+ nameInput = new TextInputState(defaultName != null ? defaultName : "");
+ portInput = new TextInputState("");
+ maxInput = new TextInputState("");
+ maxMode = 0;
+ devMode = false;
+ observe = false;
+ backlogTrace = false;
+ selectedRow = ROW_NAME;
+ page = PAGE_OPTIONS;
+ selectedProperty = 0;
+ exampleTitle = exampleName != null ? exampleName : "Run";
+ loadProperties(exampleName, bundled);
+ visible = true;
+ }
+
+ void close() {
+ visible = false;
+ }
+
+ String name() {
+ return nameInput != null ? nameInput.text().trim() : "";
+ }
+
+ boolean handleKeyEvent(KeyEvent ke) {
+ if (!visible) {
+ return false;
+ }
+ if (ke.isCancel()) {
+ visible = false;
+ return true;
+ }
+ if (ke.isConfirm()) {
+ return true;
+ }
+
+ if (page == PAGE_OPTIONS) {
+ return handleOptionsPage(ke);
+ } else {
+ return handlePropertiesPage(ke);
+ }
+ }
+
+ void render(Frame frame, Rect area) {
+ if (page == PAGE_OPTIONS) {
+ renderOptionsPage(frame, area);
+ } else {
+ renderPropertiesPage(frame, area);
+ }
+ }
+
+ void renderFooter(List<Span> spans) {
+ if (page == PAGE_OPTIONS) {
+ if (hasProperties()) {
+ hint(spans, "Tab", "next");
+ } else {
+ hint(spans, "Tab", "next");
+ }
+ if (selectedRow >= ROW_DEV) {
+ hint(spans, "Space", "toggle");
+ }
+ if (hasProperties()) {
+ hint(spans, "→", "properties");
+ }
+ hint(spans, "Enter", "launch");
+ hintLast(spans, "Esc", "back");
+ } else {
+ hint(spans, "←", "options");
+ hint(spans, "↑↓", "navigate");
+ hint(spans, "+", "add");
+ hint(spans, "Enter", "launch");
+ hintLast(spans, "Esc", "back");
+ }
+ }
+
+ List<String> buildArgs() {
+ List<String> args = new ArrayList<>();
+ String name = nameInput.text().trim();
+ if (!name.isEmpty()) {
+ args.add("--name=" + name);
+ }
+ String port = portInput.text().trim();
+ if (!port.isEmpty()) {
+ args.add("--port=" + port);
+ }
+ String maxVal = maxInput.text().trim();
+ if (!maxVal.isEmpty() && !"0".equals(maxVal)) {
+ args.add(MAX_FLAGS[maxMode] + maxVal);
+ }
+ if (devMode) {
+ args.add("--dev");
+ }
+ if (observe) {
+ args.add("--observe");
+ }
+ if (backlogTrace) {
+ args.add("--backlog-trace");
+ }
+ if (properties != null) {
+ for (PropertyEntry pe : properties) {
+ String current = pe.valueInput().text();
+ String key = pe.effectiveKey();
+ if (key.isEmpty()) {
+ continue;
+ }
+ if (pe.isCustom() || !current.equals(pe.originalValue())) {
+ args.add("--prop=" + key + "=" + current);
+ }
+ }
+ }
+ return args;
+ }
+
+ // ---- Options page (page 0) ----
+
+ private boolean handleOptionsPage(KeyEvent ke) {
+ if (ke.isUp()) {
+ selectedRow = (selectedRow - 1 + ROW_COUNT) % ROW_COUNT;
+ return true;
+ }
+ if (ke.isDown()) {
+ if (selectedRow == ROW_TRACE && hasProperties()) {
+ page = PAGE_PROPERTIES;
+ selectedProperty = 0;
+ } else {
+ selectedRow = (selectedRow + 1) % ROW_COUNT;
+ }
+ return true;
+ }
+ if (ke.isFocusNext()) {
+ if (selectedRow == ROW_TRACE && hasProperties()) {
+ page = PAGE_PROPERTIES;
+ selectedProperty = 0;
+ } else {
+ selectedRow = (selectedRow + 1) % ROW_COUNT;
+ }
+ return true;
+ }
+ if (ke.isFocusPrevious()) {
+ selectedRow = (selectedRow - 1 + ROW_COUNT) % ROW_COUNT;
+ return true;
+ }
+ if (ke.isRight() && hasProperties() && selectedRow >= ROW_DEV) {
+ page = PAGE_PROPERTIES;
+ selectedProperty = 0;
+ return true;
+ }
+
+ if (ke.isChar('+') && selectedRow >= ROW_DEV) {
+ page = PAGE_PROPERTIES;
+ addCustomProperty();
+ return true;
+ }
+
+ // Max row: Space cycles mode
+ if (ke.isChar(' ') && selectedRow == ROW_MAX) {
+ maxMode = (maxMode + 1) % MAX_MODES.length;
+ return true;
+ }
+
+ // Checkbox rows: Space toggles
+ if (ke.isChar(' ') && selectedRow >= ROW_DEV) {
+ switch (selectedRow) {
+ case ROW_DEV -> devMode = !devMode;
+ case ROW_OBSERVE -> observe = !observe;
+ case ROW_TRACE -> backlogTrace = !backlogTrace;
+ }
+ return true;
+ }
+
+ // Text field rows: delegate to active input
+ if (selectedRow <= ROW_MAX) {
+ TextInputState active = activeInput();
+ if (active != null) {
+ handleTextInput(ke, active, selectedRow == ROW_PORT ||
selectedRow == ROW_MAX);
+ }
+ return true;
+ }
+ return true;
+ }
+
+ // ---- Properties page (page 1) ----
+
+ private boolean handlePropertiesPage(KeyEvent ke) {
+ if (ke.isChar('+')) {
+ addCustomProperty();
+ return true;
+ }
+ if (ke.isUp()) {
+ editingKey = false;
+ if (selectedProperty == 0) {
+ page = PAGE_OPTIONS;
+ selectedRow = ROW_TRACE;
+ } else {
+ selectedProperty--;
+ }
+ return true;
+ }
+ if (ke.isDown()) {
+ editingKey = false;
+ if (selectedProperty < properties.size() - 1) {
+ selectedProperty++;
+ }
+ return true;
+ }
+ if (ke.isFocusNext()) {
+ PropertyEntry current = properties.get(selectedProperty);
+ if (current.isCustom() && editingKey) {
+ editingKey = false;
+ } else if (selectedProperty < properties.size() - 1) {
+ editingKey = false;
+ selectedProperty++;
+ }
+ return true;
+ }
+ if (ke.isFocusPrevious()) {
+ PropertyEntry current = properties.get(selectedProperty);
+ if (current.isCustom() && !editingKey) {
+ editingKey = true;
+ } else if (selectedProperty == 0) {
+ editingKey = false;
+ page = PAGE_OPTIONS;
+ selectedRow = ROW_TRACE;
+ } else {
+ editingKey = false;
+ selectedProperty--;
+ }
+ return true;
+ }
+ if (ke.isLeft()) {
+ PropertyEntry current = properties.get(selectedProperty);
+ TextInputState active = editingKey ? current.keyInput() :
current.valueInput();
+ if (active.cursorPosition() == 0) {
+ if (current.isCustom() && !editingKey) {
+ editingKey = true;
+ return true;
+ }
+ page = PAGE_OPTIONS;
+ selectedRow = ROW_TRACE;
+ editingKey = false;
+ return true;
+ }
+ }
+ if (ke.isDeleteBackward()) {
+ PropertyEntry current = properties.get(selectedProperty);
+ if (current.isCustom() && editingKey) {
+ if (current.keyInput().text().isEmpty()) {
+ properties.remove(selectedProperty);
+ if (selectedProperty >= properties.size() &&
selectedProperty > 0) {
+ selectedProperty--;
+ }
+ if (properties.isEmpty()) {
+ page = PAGE_OPTIONS;
+ selectedRow = ROW_TRACE;
+ }
+ return true;
+ }
+ }
+ }
+
+ // Text editing for selected property
+ PropertyEntry current = properties.get(selectedProperty);
+ TextInputState active = (current.isCustom() && editingKey) ?
current.keyInput() : current.valueInput();
+ handleTextInput(ke, active, false);
+ return true;
+ }
+
+ private void addCustomProperty() {
+ if (properties == null) {
+ properties = new ArrayList<>();
+ }
+ PropertyEntry entry = new PropertyEntry("", "", new
TextInputState(""), new TextInputState(""));
+ properties.add(entry);
+ selectedProperty = properties.size() - 1;
+ editingKey = true;
+ }
+
+ // ---- Rendering ----
+
+ private void renderOptionsPage(Frame frame, Rect area) {
+ int popupW = Math.min(56, area.width() - 4);
+ int popupH = 10;
+ 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, Math.min(popupW, area.width()),
Math.min(popupH, area.height()));
+
+ frame.renderWidget(Clear.INSTANCE, popup);
+
+ String title = " Run: " + exampleTitle;
+ if (hasProperties()) {
+ title += " (1/2) ";
+ } else {
+ title += " ";
+ }
+
+ List<Span> bottomSpans = new ArrayList<>();
+ bottomSpans.add(Span.styled(" Tab", MonitorContext.HINT_KEY_STYLE));
+ bottomSpans.add(Span.raw(" next"));
+ if (hasProperties()) {
+ bottomSpans.add(Span.raw(" │"));
+ bottomSpans.add(Span.styled(" →", MonitorContext.HINT_KEY_STYLE));
+ bottomSpans.add(Span.raw(" properties"));
+ }
+ bottomSpans.add(Span.raw(" │"));
+ bottomSpans.add(Span.styled(" Space", MonitorContext.HINT_KEY_STYLE));
+ bottomSpans.add(Span.raw(" toggle │"));
+ bottomSpans.add(Span.styled(" Enter", MonitorContext.HINT_KEY_STYLE));
+ bottomSpans.add(Span.raw(" launch │"));
+ bottomSpans.add(Span.styled(" Esc", MonitorContext.HINT_KEY_STYLE));
+ bottomSpans.add(Span.raw(" back "));
+
+ Block block = Block.builder()
+ .borderType(BorderType.ROUNDED)
+ .title(title)
+ .titleBottom(Title.from(Line.from(bottomSpans)))
+ .build();
+ frame.renderWidget(block, popup);
+
+ int innerX = popup.left() + 2;
+ int innerW = popup.width() - 4;
+ int labelW = 16;
+ int fieldW = innerW - labelW;
+ int rowY = popup.top() + 1;
+
+ renderLabel(frame, innerX, rowY, labelW, "Name:", selectedRow ==
ROW_NAME);
+ renderTextInput(frame, innerX + labelW, rowY, fieldW, nameInput,
selectedRow == ROW_NAME);
+ rowY++;
+
+ renderLabel(frame, innerX, rowY, labelW, "Port:", selectedRow ==
ROW_PORT);
+ renderTextInput(frame, innerX + labelW, rowY, fieldW, portInput,
selectedRow == ROW_PORT);
+ rowY++;
+
+ renderLabel(frame, innerX, rowY, labelW, MAX_MODES[maxMode],
selectedRow == ROW_MAX);
+ renderTextInput(frame, innerX + labelW, rowY, fieldW, maxInput,
selectedRow == ROW_MAX);
+ rowY++;
+
+ renderCheckbox(frame, innerX, rowY, innerW, "Dev mode (live reload)",
devMode, selectedRow == ROW_DEV);
+ rowY++;
+
+ renderCheckbox(frame, innerX, rowY, innerW, "Observe (health +
metrics)", observe, selectedRow == ROW_OBSERVE);
+ rowY++;
+
+ renderCheckbox(frame, innerX, rowY, innerW, "Backlog trace",
backlogTrace, selectedRow == ROW_TRACE);
+ }
+
+ private void renderPropertiesPage(Frame frame, Rect area) {
+ int popupW = Math.min(70, area.width() - 4);
+ int propCount = properties != null ? properties.size() : 0;
+ int popupH = Math.min(propCount + 2, Math.min(20, area.height() - 4));
+ 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, Math.min(popupW, area.width()),
Math.min(popupH, area.height()));
+
+ frame.renderWidget(Clear.INSTANCE, popup);
+
+ Block block = Block.builder()
+ .borderType(BorderType.ROUNDED)
+ .title(" Run: " + exampleTitle + " — Properties (2/2) ")
+ .titleBottom(Title.from(Line.from(
+ Span.styled(" ←", MonitorContext.HINT_KEY_STYLE),
Span.raw(" options │"),
+ Span.styled(" ↑↓", MonitorContext.HINT_KEY_STYLE),
Span.raw(" navigate │"),
+ Span.styled(" +", MonitorContext.HINT_KEY_STYLE),
Span.raw(" add │"),
+ Span.styled(" Enter", MonitorContext.HINT_KEY_STYLE),
Span.raw(" launch │"),
+ Span.styled(" Esc", MonitorContext.HINT_KEY_STYLE),
Span.raw(" back "))))
+ .build();
+ frame.renderWidget(block, popup);
+
+ int innerX = popup.left() + 2;
+ int innerW = popup.width() - 4;
+ int maxKeyLen = 0;
+ for (PropertyEntry pe : properties) {
+ maxKeyLen = Math.max(maxKeyLen, pe.key().length());
+ }
+ int labelW = Math.min(maxKeyLen + 2, innerW / 2);
+ int fieldW = innerW - labelW;
+
+ int rowY = popup.top() + 1;
+ int visibleRows = popup.height() - 2;
+ int scrollOffset = Math.max(0, selectedProperty - visibleRows + 1);
+
+ for (int i = scrollOffset; i < properties.size() && (rowY -
popup.top() - 1) < visibleRows; i++) {
+ PropertyEntry pe = properties.get(i);
+ boolean selected = (i == selectedProperty);
+
+ if (pe.isCustom()) {
+ // Custom entry: editable key and value
+ if (selected && editingKey) {
+ renderTextInput(frame, innerX, rowY, labelW - 1,
pe.keyInput(), true);
+ } else {
+ String keyText = pe.keyInput().text();
+ Style keyStyle = selected ? Style.EMPTY.bold() :
(keyText.isEmpty() ? Style.EMPTY.dim() : Style.EMPTY);
+ Rect keyArea = new Rect(innerX, rowY, labelW - 1, 1);
+ frame.renderWidget(Paragraph.from(Line.from(
+ Span.styled(keyText.isEmpty() ? "<key>" : keyText,
keyStyle))), keyArea);
+ }
+ Rect colonArea = new Rect(innerX + labelW - 1, rowY, 1, 1);
+ frame.renderWidget(Paragraph.from(Line.from(Span.styled(":",
Style.EMPTY.dim()))), colonArea);
+ if (selected && !editingKey) {
+ renderTextInput(frame, innerX + labelW, rowY, fieldW,
pe.valueInput(), true);
+ } else {
+ String text = pe.valueInput().text();
+ Style style = text.isEmpty() ? Style.EMPTY.dim() :
Style.EMPTY;
+ Rect valArea = new Rect(innerX + labelW, rowY, fieldW, 1);
+ frame.renderWidget(Paragraph.from(Line.from(
+ Span.styled(text.isEmpty() ? "<value>" : text,
style))), valArea);
+ }
+ } else {
+ // Loaded entry: fixed key, editable value
+ String keyLabel = pe.key() + ":";
+ renderLabel(frame, innerX, rowY, labelW,
TuiHelper.truncate(keyLabel, labelW), selected);
+
+ boolean modified =
!pe.valueInput().text().equals(pe.originalValue());
+ if (selected) {
+ renderTextInput(frame, innerX + labelW, rowY, fieldW,
pe.valueInput(), true);
+ } else {
+ String text = pe.valueInput().text();
+ Style style = modified ? Style.EMPTY.bold() : Style.EMPTY;
+ Rect inputArea = new Rect(innerX + labelW, rowY, fieldW,
1);
+ frame.renderWidget(Paragraph.from(Line.from(
+ Span.styled(text.isEmpty() ? "—" : text, style))),
inputArea);
+ }
+ }
+ rowY++;
+ }
+ }
+
+ // ---- Properties loading ----
+
+ private void loadProperties(String exampleName, boolean bundled) {
+ properties = new ArrayList<>();
+ if (exampleName == null || exampleName.isEmpty()) {
+ return;
+ }
+ String content;
+ if (bundled) {
+ content = DocHelper.loadResourceContent("examples/" + exampleName
+ "/application.properties");
+ } else {
+ content = DocHelper.downloadContent(
+
"https://raw.githubusercontent.com/apache/camel-jbang-examples/main/"
+ + exampleName +
"/application.properties");
+ }
+ if (content == null || content.isBlank()) {
+ return;
+ }
+ for (String line : content.split("\n")) {
+ String trimmed = line.trim();
+ if (trimmed.isEmpty() || trimmed.startsWith("#")) {
+ continue;
+ }
+ int eq = trimmed.indexOf('=');
+ if (eq > 0) {
+ String key = trimmed.substring(0, eq).trim();
+ String value = trimmed.substring(eq + 1).trim();
+ properties.add(new PropertyEntry(key, value, null, new
TextInputState(value)));
+ }
+ }
+ }
+
+ private boolean hasProperties() {
+ return properties != null && !properties.isEmpty();
+ }
+
+ // ---- Shared helpers ----
+
+ private TextInputState activeInput() {
+ return switch (selectedRow) {
+ case ROW_NAME -> nameInput;
+ case ROW_PORT -> portInput;
+ case ROW_MAX -> maxInput;
+ default -> null;
+ };
+ }
+
+ private void handleTextInput(KeyEvent ke, TextInputState active, boolean
digitsOnly) {
+ if (ke.isDeleteBackward()) {
+ active.deleteBackward();
+ } else if (ke.isDeleteForward()) {
+ active.deleteForward();
+ } else if (ke.isLeft()) {
+ active.moveCursorLeft();
+ } else if (ke.isRight()) {
+ active.moveCursorRight();
+ } else if (ke.isHome()) {
+ active.moveCursorToStart();
+ } else if (ke.isEnd()) {
+ active.moveCursorToEnd();
+ } else if (ke.code() == KeyCode.CHAR) {
+ if (digitsOnly) {
+ if (Character.isDigit(ke.character())) {
+ active.insert(ke.character());
+ }
+ } else {
+ active.insert(ke.character());
+ }
+ }
+ }
+
+ private void renderLabel(Frame frame, int x, int y, int w, String label,
boolean selected) {
+ Style style = selected ? Style.EMPTY.bold() : Style.EMPTY.dim();
+ Rect labelArea = new Rect(x, y, w, 1);
+ frame.renderWidget(Paragraph.from(Line.from(Span.styled(label,
style))), labelArea);
+ }
+
+ private void renderTextInput(Frame frame, int x, int y, int w,
TextInputState state, boolean active) {
+ Rect inputArea = new Rect(x, y, w, 1);
+ if (active) {
+ TextInput textInput = TextInput.builder()
+ .cursorStyle(Style.EMPTY.reversed())
+ .build();
+ frame.renderStatefulWidget(textInput, inputArea, state);
+ } else {
+ String text = state.text();
+ Style style = text.isEmpty() ? Style.EMPTY.dim() : Style.EMPTY;
+ frame.renderWidget(Paragraph.from(Line.from(
+ Span.styled(text.isEmpty() ? "—" : text, style))),
inputArea);
+ }
+ }
+
+ private void renderCheckbox(Frame frame, int x, int y, int w, String
label, boolean checked, boolean selected) {
+ String box = checked ? "[x]" : "[ ]";
+ Style style = selected ? Style.EMPTY.bold().reversed() : Style.EMPTY;
+ Rect cbArea = new Rect(x, y, w, 1);
+ frame.renderWidget(Paragraph.from(Line.from(
+ Span.styled(" " + box + " " + label, style))), cbArea);
+ }
+
+ record PropertyEntry(String key, String originalValue, TextInputState
keyInput, TextInputState valueInput) {
+ boolean isCustom() {
+ return keyInput != null;
+ }
+
+ String effectiveKey() {
+ return keyInput != null ? keyInput.text().trim() : key;
+ }
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StopAllPopup.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StopAllPopup.java
new file mode 100644
index 000000000000..bbf7d69750de
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StopAllPopup.java
@@ -0,0 +1,228 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.nio.file.Path;
+import java.util.List;
+import java.util.function.Supplier;
+
+import dev.tamboui.layout.Rect;
+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.KeyEvent;
+import dev.tamboui.widgets.Clear;
+import dev.tamboui.widgets.block.Block;
+import dev.tamboui.widgets.block.BorderType;
+import dev.tamboui.widgets.block.Title;
+import dev.tamboui.widgets.paragraph.Paragraph;
+import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
+import org.apache.camel.dsl.jbang.core.common.PathUtils;
+
+import static org.apache.camel.dsl.jbang.core.commands.tui.MonitorContext.hint;
+import static
org.apache.camel.dsl.jbang.core.commands.tui.MonitorContext.hintLast;
+
+class StopAllPopup {
+
+ private final Supplier<List<IntegrationInfo>> integrations;
+ private final Supplier<List<InfraInfo>> infraServices;
+
+ private boolean visible;
+ private boolean checkIntegrations = true;
+ private boolean checkInfra = true;
+ private int selectedRow;
+ private int integrationCount;
+ private int infraCount;
+
+ private String notification;
+
+ StopAllPopup(Supplier<List<IntegrationInfo>> integrations,
Supplier<List<InfraInfo>> infraServices) {
+ this.integrations = integrations;
+ this.infraServices = infraServices;
+ }
+
+ boolean isVisible() {
+ return visible;
+ }
+
+ boolean hasBothGroups() {
+ List<IntegrationInfo> ints = integrations.get();
+ List<InfraInfo> infras = infraServices.get();
+ long ic = ints.stream().filter(i -> !i.vanishing).count();
+ long fc = infras.stream().filter(i -> !i.vanishing).count();
+ return ic > 0 && fc > 0;
+ }
+
+ void open() {
+ List<IntegrationInfo> ints = integrations.get();
+ List<InfraInfo> infras = infraServices.get();
+ integrationCount = (int) ints.stream().filter(i ->
!i.vanishing).count();
+ infraCount = (int) infras.stream().filter(i -> !i.vanishing).count();
+
+ if (integrationCount == 0 && infraCount == 0) {
+ notification = "No running processes to stop";
+ return;
+ }
+
+ if (integrationCount > 0 && infraCount == 0) {
+ stopIntegrations();
+ return;
+ }
+ if (infraCount > 0 && integrationCount == 0) {
+ stopInfraServices();
+ return;
+ }
+
+ checkIntegrations = true;
+ checkInfra = true;
+ selectedRow = 0;
+ visible = true;
+ }
+
+ void close() {
+ visible = false;
+ }
+
+ String consumeNotification() {
+ String msg = notification;
+ notification = null;
+ return msg;
+ }
+
+ boolean handleKeyEvent(KeyEvent ke) {
+ if (!visible) {
+ return false;
+ }
+ if (ke.isCancel()) {
+ visible = false;
+ } else if (ke.isUp()) {
+ selectedRow = 0;
+ } else if (ke.isDown()) {
+ selectedRow = 1;
+ } else if (ke.isChar(' ')) {
+ if (selectedRow == 0) {
+ checkIntegrations = !checkIntegrations;
+ } else {
+ checkInfra = !checkInfra;
+ }
+ } else if (ke.isConfirm()) {
+ visible = false;
+ executeStop();
+ }
+ return true;
+ }
+
+ void render(Frame frame, Rect area) {
+ int popupW = Math.min(42, area.width() - 4);
+ int popupH = 6;
+ 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, Math.min(popupW, area.width()),
Math.min(popupH, area.height()));
+
+ frame.renderWidget(Clear.INSTANCE, popup);
+
+ String intLabel = (checkIntegrations ? "[x]" : "[ ]") + " All
integrations (" + integrationCount + ")";
+ String infraLabel = (checkInfra ? "[x]" : "[ ]") + " All infra
services (" + infraCount + ")";
+
+ Style normalStyle = Style.EMPTY;
+ Style selectedStyle = Style.EMPTY.bold().reversed();
+
+ Line intLine = Line.from(Span.styled(" " + intLabel, selectedRow == 0
? selectedStyle : normalStyle));
+ Line infraLine = Line.from(Span.styled(" " + infraLabel, selectedRow
== 1 ? selectedStyle : normalStyle));
+
+ Paragraph para = Paragraph.builder()
+ .text(Text.from(Line.from(""), intLine, infraLine))
+ .block(Block.builder()
+ .borderType(BorderType.ROUNDED)
+ .title(" 🛑 Stop All ")
+ .titleBottom(Title.from(Line.from(
+ Span.styled(" Space",
MonitorContext.HINT_KEY_STYLE), Span.raw(" toggle │"),
+ Span.styled(" Enter",
MonitorContext.HINT_KEY_STYLE), Span.raw(" confirm │"),
+ Span.styled(" Esc",
MonitorContext.HINT_KEY_STYLE), Span.raw(" cancel "))))
+ .build())
+ .build();
+ frame.renderWidget(para, popup);
+ }
+
+ void renderFooter(List<Span> spans) {
+ hint(spans, "Space", "toggle");
+ hint(spans, "Enter", "confirm");
+ hintLast(spans, "Esc", "cancel");
+ }
+
+ private void executeStop() {
+ int stoppedInt = 0;
+ int stoppedInfra = 0;
+ if (checkIntegrations) {
+ stoppedInt = stopIntegrations();
+ }
+ if (checkInfra) {
+ stoppedInfra = stopInfraServices();
+ }
+ if (stoppedInt == 0 && stoppedInfra == 0 && notification == null) {
+ notification = "Nothing selected to stop";
+ }
+ }
+
+ private int stopIntegrations() {
+ List<IntegrationInfo> ints = integrations.get();
+ int count = 0;
+ for (IntegrationInfo info : ints) {
+ if (info.vanishing || info.pid == null) {
+ continue;
+ }
+ try {
+ long pid = Long.parseLong(info.pid);
+ ProcessHandle.of(pid).ifPresent(ProcessHandle::destroy);
+ count++;
+ } catch (NumberFormatException e) {
+ // skip
+ }
+ }
+ if (count > 0) {
+ notification = "Stopping " + count + " integration" + (count > 1 ?
"s" : "");
+ }
+ return count;
+ }
+
+ private int stopInfraServices() {
+ List<InfraInfo> infras = infraServices.get();
+ Path camelDir = CommandLineHelper.getCamelDir();
+ int count = 0;
+ for (InfraInfo info : infras) {
+ if (info.vanishing || info.pid == null) {
+ continue;
+ }
+ PathUtils.deleteFile(camelDir.resolve("infra-" + info.alias + "-"
+ info.pid + ".json"));
+ try {
+ long pid = Long.parseLong(info.pid);
+ ProcessHandle.of(pid).ifPresent(ProcessHandle::destroy);
+ count++;
+ } catch (NumberFormatException e) {
+ // skip
+ }
+ }
+ if (count > 0) {
+ String prev = notification;
+ String msg = "Stopping " + count + " infra service" + (count > 1 ?
"s" : "");
+ notification = prev != null ? prev + " and " + msg.substring(0,
1).toLowerCase() + msg.substring(1) : msg;
+ }
+ return count;
+ }
+}